operations_research/bilder_04/erzeuge_kkt.py
dschlueter b7af2f1d9a Version 04 als eigenes Repository
Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python"
(Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im
uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch
Version_03 (eingefroren) verwaltet und Version_04/ ignoriert.

Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit
der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen
Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist.

Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise,
325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren,
25 Notebooks, PDF mit 715 Seiten.

Zusaetzlich in diesem Commit:

* pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api,
  figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt
  unveraendert daneben bestehen. ortools steht in der Grundausstattung,
  highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der
  schlanken Installation gar nicht erst auftreten.

* Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py
  importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo
  steht in requirements.txt, wird aber von keinem Programm importiert,
  sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in
  [empfehlungen]).

* NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es
  ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden
  Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen.

* PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den
  tatsaechlichen aus erzeuge_titelseite.py korrigiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:20:09 +02:00

224 lines
8.7 KiB
Python

#!/usr/bin/env python3
# erzeuge_kkt.py
"""
Erzeugt das KKT-Diagramm zum QP/NLP-Kapitel:
bilder_04/kap_qp_kkt.svg / .png
Zwei Bilder nebeneinander:
Links - die Handrechnung des Kapitels, eindimensional:
min (x-5)^2 u.d.N. x - 3 <= 0 -> x* = 3, lambda* = 4
Sichtbar wird, was lambda BEDEUTET: Es ist die Steigung von f am
Rand, also der Preis dafuer, dass die Grenze nicht weiter darf.
Rechts - dieselbe Aussage in zwei Dimensionen: Am Optimum zeigen der
Zielgradient und der mit lambda gewichtete Restriktionsgradient
GENAU GEGENEINANDER und heben sich auf.
Warum neu: Im Vorgaengerbild war der Zielpfeil mit "-grad f" beschriftet und
zeigte entgegen lambda*grad g. Das ist die Konstellation einer MAXIMIERUNG. Das
Kapitel formuliert KKT aber fuer die Minimierung - dort gilt
grad f + lambda*grad g = 0, also zeigen grad f (ohne Minus) und lambda*grad g
gegeneinander. Eine Beschriftung, ein Vorzeichen, und die Aussage kippt.
Alle Werte werden gerechnet, nicht eingetragen: Das Optimum kommt aus
scipy.optimize, die Gradienten aus der Ableitung, und das Skript prueft alle
vier KKT-Bedingungen numerisch, bevor es zeichnet.
Aufruf (aus dem Repository-Wurzelverzeichnis):
python3 bilder_04/erzeuge_kkt.py
Benoetigt: numpy, scipy, matplotlib
"""
from __future__ import annotations
import os
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from scipy.optimize import minimize
# Reproduzierbare SVG-Ausgabe (siehe erzeuge_titelseite.py)
plt.rcParams["svg.hashsalt"] = "or-mit-python-v04"
BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BILDER = os.path.join(BASIS, "bilder_04")
INDIGO, CYAN, AMBER, GRUEN, ROT = (
"#4338ca", "#0891b2", "#b45309", "#059669", "#be123c")
# --- Eindimensional: die Handrechnung des Kapitels --------------------------
ZIEL_1D = 5.0 # unbeschraenktes Minimum von (x-5)^2
GRENZE_1D = 3.0 # x <= 3
# --- Zweidimensional: dieselbe Struktur, ein Ziel mehr ----------------------
ZENTRUM = np.array([3.5, 3.5]) # unbeschraenktes Minimum
BUDGET = 4.0 # x1 + x2 <= 4
def f(x):
return float(np.sum((np.asarray(x) - ZENTRUM) ** 2))
def grad_f(x):
return 2.0 * (np.asarray(x) - ZENTRUM)
def g(x):
return float(np.sum(x) - BUDGET)
GRAD_G = np.array([1.0, 1.0])
def loese_2d():
ergebnis = minimize(f, x0=np.zeros(2), method="SLSQP",
constraints=[{"type": "ineq", "fun": lambda x: -g(x)}])
if not ergebnis.success:
raise SystemExit(f"SLSQP gescheitert: {ergebnis.message}")
x = np.asarray(ergebnis.x)
# lambda aus der Stationaritaet: grad f + lambda * grad g = 0
lam = float(-(grad_f(x) @ GRAD_G) / (GRAD_G @ GRAD_G))
return x, lam
def pruefe_kkt(x, lam, toleranz=1e-5) -> None:
"""Alle vier Bedingungen, bevor gezeichnet wird - nicht danach."""
stationaritaet = grad_f(x) + lam * GRAD_G
pruefungen = {
"Stationarität grad f + lambda*grad g = 0":
float(np.linalg.norm(stationaritaet)) < toleranz,
"primale Zulässigkeit g(x) <= 0": g(x) < toleranz,
"duale Zulässigkeit lambda >= 0": lam > -toleranz,
"komplementärer Schlupf lambda*g(x) = 0": abs(lam * g(x)) < toleranz,
}
for name, erfuellt in pruefungen.items():
print(f" {'OK ' if erfuellt else 'NEIN'} {name}")
if not all(pruefungen.values()):
raise SystemExit("FEHLER: KKT nicht erfuellt - es wird nichts gezeichnet.")
def zahl(wert: float) -> str:
text = f"{wert:.2f}".rstrip("0").rstrip(".")
return text.replace(".", ",")
def zeichne_1d(achse, lam_1d: float) -> None:
x = np.linspace(0.5, 6.5, 400)
achse.plot(x, (x - ZIEL_1D) ** 2, color=INDIGO, linewidth=2.0,
label="$f(x) = (x-5)^2$")
achse.axvspan(GRENZE_1D, 6.5, color=ROT, alpha=0.07)
achse.axvline(GRENZE_1D, color=ROT, linewidth=1.8, linestyle="--")
achse.annotate("unzulässig:\n$x > 3$", (GRENZE_1D, 20),
textcoords="offset points", xytext=(10, 0),
fontsize=8.5, color=ROT, va="center")
achse.scatter([ZIEL_1D], [0.0], s=90, facecolors="none",
edgecolors="#94a3b8", linewidths=2.0, zorder=4)
achse.annotate("freies Minimum $x=5$\n— nicht erlaubt", (ZIEL_1D, 0.0),
textcoords="offset points", xytext=(10, 16),
ha="left", fontsize=8.5, color="#64748b")
wert = (GRENZE_1D - ZIEL_1D) ** 2
achse.scatter([GRENZE_1D], [wert], s=130, color=GRUEN, zorder=5)
achse.annotate(f"$x^*=3$, $f=4$", (GRENZE_1D, wert),
textcoords="offset points", xytext=(-10, 12),
ha="right", fontsize=9, color=GRUEN, fontweight="bold")
# Die Tangente am Rand: ihre Steigung ist -lambda*
steigung = 2 * (GRENZE_1D - ZIEL_1D)
nahe = np.linspace(GRENZE_1D - 1.2, GRENZE_1D + 0.8, 20)
achse.plot(nahe, wert + steigung * (nahe - GRENZE_1D), color=AMBER,
linewidth=1.6, linestyle="-",
label=f"Steigung $-\\lambda^* = {zahl(steigung)}$")
achse.set_xlabel("$x$")
achse.set_ylabel("$f(x)$")
achse.set_title("Die Handrechnung: $\\lambda^*$ ist die Steigung am Rand",
fontsize=10.5)
achse.set_ylim(-1.5, 22)
achse.grid(linestyle=":", alpha=0.45)
achse.legend(fontsize=8, loc="upper left", frameon=False)
for seite in ("top", "right"):
achse.spines[seite].set_visible(False)
def zeichne_2d(achse, x_stern, lam) -> None:
gitter = np.linspace(-0.2, 5.4, 300)
X1, X2 = np.meshgrid(gitter, gitter)
werte = (X1 - ZENTRUM[0]) ** 2 + (X2 - ZENTRUM[1]) ** 2
achse.contour(X1, X2, werte, levels=[0.5, 2, 4.5, 8, 12.5, 18],
colors=[INDIGO], linewidths=0.8, alpha=0.55)
rand = np.linspace(-0.2, 5.4, 10)
achse.plot(rand, BUDGET - rand, color=ROT, linewidth=2.0,
label="$g(x)=x_1+x_2-4=0$")
achse.fill_between(rand, -0.2, np.minimum(BUDGET - rand, 5.4),
color=CYAN, alpha=0.08)
achse.scatter(*ZENTRUM, s=90, facecolors="none", edgecolors="#94a3b8",
linewidths=2.0, zorder=4)
achse.annotate("freies Minimum\n— unzulässig", ZENTRUM,
textcoords="offset points", xytext=(12, -8),
va="top", fontsize=8.5, color="#64748b")
achse.scatter(*x_stern, s=140, color=GRUEN, zorder=6)
achse.annotate(f"$x^* = ({zahl(x_stern[0])}; {zahl(x_stern[1])})$",
x_stern, textcoords="offset points", xytext=(-12, -20),
ha="right", fontsize=9, color=GRUEN, fontweight="bold")
zielgradient = grad_f(x_stern)
achse.annotate("", xy=x_stern + zielgradient * 0.45, xytext=x_stern,
arrowprops=dict(arrowstyle="-|>", color=INDIGO, lw=2.4))
achse.annotate("$\\nabla f$ — zeigt bergauf,\nweg vom freien Minimum",
x_stern + zielgradient * 0.45,
textcoords="offset points", xytext=(-8, -8),
ha="right", va="top", fontsize=8.5, color=INDIGO)
restriktionsgradient = lam * GRAD_G
achse.annotate("", xy=x_stern + restriktionsgradient * 0.45, xytext=x_stern,
arrowprops=dict(arrowstyle="-|>", color=AMBER, lw=2.4))
achse.annotate(f"$\\lambda^*\\nabla g$ mit $\\lambda^* = {zahl(lam)}$",
x_stern + restriktionsgradient * 0.45,
textcoords="offset points", xytext=(-10, 8),
ha="right", fontsize=8.5, color=AMBER)
achse.set_xlim(-0.2, 5.4)
achse.set_ylim(-0.2, 5.4)
achse.set_xlabel("$x_1$")
achse.set_ylabel("$x_2$")
achse.set_title("Am Optimum heben sich beide Gradienten auf:\n"
"$\\nabla f + \\lambda^*\\nabla g = 0$", fontsize=10.5)
achse.grid(linestyle=":", alpha=0.45)
achse.legend(fontsize=8, loc="upper right", frameon=False)
for seite in ("top", "right"):
achse.spines[seite].set_visible(False)
if __name__ == "__main__":
x_stern, lam = loese_2d()
print(f"2D: x* = ({x_stern[0]:.4f}; {x_stern[1]:.4f}), lambda* = {lam:.4f}")
pruefe_kkt(x_stern, lam)
lam_1d = -2 * (GRENZE_1D - ZIEL_1D)
print(f"1D: x* = {GRENZE_1D:g}, lambda* = {lam_1d:g} "
f"(Handrechnung: 3 und 4)")
if abs(lam_1d - 4.0) > 1e-9:
raise SystemExit("FEHLER: 1D-Lambda weicht von der Handrechnung ab.")
figur, (links, rechts) = plt.subplots(1, 2, figsize=(11.0, 4.8))
zeichne_1d(links, lam_1d)
zeichne_2d(rechts, x_stern, lam)
figur.tight_layout()
os.makedirs(BILDER, exist_ok=True)
for endung in ("svg", "png"):
pfad = os.path.join(BILDER, f"kap_qp_kkt.{endung}")
figur.savefig(pfad, format=endung, dpi=160,
metadata={"Date": None} if endung == "svg" else None)
print(f"geschrieben: {pfad}")
plt.close(figur)