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>
224 lines
8.7 KiB
Python
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)
|