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>
231 lines
8.8 KiB
Python
231 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
||
|
||
# erzeuge_runden.py
|
||
"""
|
||
Erzeugt das Diagramm "Warum Runden scheitert" zum MILP-Kapitel:
|
||
|
||
bilder_04/kap_milp_runden.svg statisch, fuer das PDF
|
||
bilder_04/kap_milp_runden.png Rasterfassung
|
||
|
||
Zwei Bilder nebeneinander, beide aus dem Kapitel selbst:
|
||
|
||
Links - die Handrechnung des Kapitels, gezeichnet:
|
||
max x1 + x2 u.d.N. 2 x1 + 2 x2 <= 3, x ganzzahlig
|
||
Der Witz ist die Entartung: Die Zielfunktion laeuft PARALLEL zur
|
||
Restriktion, also ist die ganze Kante optimal, nicht eine Ecke.
|
||
Aufrunden auf (1,1) fuehrt aus dem zulaessigen Bereich heraus,
|
||
Abrunden auf (0,0) auf den Wert null. Beides sieht man sofort.
|
||
Rechts - die Messung aus Runden_Gegenbeispiel.py, mit derselben Saat
|
||
nachgerechnet: Wie oft ist die aufgerundete Loesung unzulaessig,
|
||
und was kostet Abrunden - mit und ohne gieriges Auffuellen?
|
||
|
||
Warum ueberhaupt neu: Das Vorgaengerbild zeigte ein LP, das im Buch nicht
|
||
vorkommt (LP-Optimum bei (1,8; 3,4), Z = 42,6), ohne die Zielfunktion zu nennen
|
||
- unmittelbar ueber der Handrechnung, die etwas voellig anderes rechnet.
|
||
|
||
Aufruf (aus dem Repository-Wurzelverzeichnis, dauert rund eine Minute):
|
||
python3 bilder_04/erzeuge_runden.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 linprog
|
||
|
||
# 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")
|
||
|
||
GROESSEN = [(5, 2), (10, 3), (20, 5), (40, 8)]
|
||
INSTANZEN = 200
|
||
SAAT = 2026
|
||
|
||
|
||
# --- Messung: wortgleich zu Runden_Gegenbeispiel.py -------------------------
|
||
|
||
def erzeuge_instanz(n, m, rng):
|
||
c = rng.integers(3, 20, size=n).astype(float)
|
||
A = rng.integers(1, 9, size=(m, n)).astype(float)
|
||
b = (A.sum(axis=1) * rng.uniform(0.25, 0.45)).round()
|
||
return c, A, b
|
||
|
||
|
||
def loese_lp(c, A, b, ganzzahlig=False):
|
||
n = len(c)
|
||
ergebnis = linprog(
|
||
c=-c, A_ub=A, b_ub=b, bounds=[(0, None)] * n,
|
||
integrality=np.ones(n) if ganzzahlig else None,
|
||
method="highs")
|
||
return (-ergebnis.fun, ergebnis.x) if ergebnis.success else (None, None)
|
||
|
||
|
||
def abrunden_und_reparieren(x_lp, c, A, b):
|
||
x = np.floor(x_lp + 1e-9)
|
||
verbessert = True
|
||
while verbessert:
|
||
verbessert = False
|
||
for j in np.argsort(-c):
|
||
kandidat = x.copy()
|
||
kandidat[j] += 1
|
||
if np.all(A @ kandidat <= b + 1e-9):
|
||
x = kandidat
|
||
verbessert = True
|
||
break
|
||
return c @ x, x
|
||
|
||
|
||
def messreihe():
|
||
"""Dieselbe Schleife wie im Buchprogramm, mit derselben Saat.
|
||
|
||
Die Saat sitzt VOR der Schleife ueber die Groessen, nicht darin - so wie
|
||
dort. Nur deshalb kommen hier dieselben Zahlen heraus wie in der
|
||
abgedruckten Tabelle.
|
||
"""
|
||
rng = np.random.default_rng(SAAT)
|
||
zeilen = []
|
||
for n, m in GROESSEN:
|
||
unzulaessig = 0
|
||
verluste_ab, verluste_gierig = [], []
|
||
gezaehlt = 0
|
||
for _ in range(INSTANZEN):
|
||
c, A, b = erzeuge_instanz(n, m, rng)
|
||
z_lp, x_lp = loese_lp(c, A, b, ganzzahlig=False)
|
||
z_ip, _ = loese_lp(c, A, b, ganzzahlig=True)
|
||
if z_lp is None or z_ip is None or z_ip <= 0:
|
||
continue
|
||
gezaehlt += 1
|
||
x_auf = np.ceil(x_lp - 1e-9)
|
||
if np.any(A @ x_auf > b + 1e-9):
|
||
unzulaessig += 1
|
||
x_ab = np.floor(x_lp + 1e-9)
|
||
verluste_ab.append(1.0 - (c @ x_ab) / z_ip)
|
||
z_gierig, _ = abrunden_und_reparieren(x_lp, c, A, b)
|
||
verluste_gierig.append(1.0 - z_gierig / z_ip)
|
||
zeilen.append({
|
||
"name": f"{n}×{m}",
|
||
"unzulaessig": unzulaessig / gezaehlt,
|
||
"ab": float(np.mean(verluste_ab)),
|
||
"gierig": float(np.mean(verluste_gierig)),
|
||
})
|
||
return zeilen
|
||
|
||
|
||
# --- Zeichnen ---------------------------------------------------------------
|
||
|
||
def zeichne_handrechnung(achse) -> None:
|
||
"""Die Handrechnung des Kapitels: max x1+x2 u.d.N. 2x1+2x2 <= 3."""
|
||
ecken = np.array([[0.0, 0.0], [1.5, 0.0], [0.0, 1.5]])
|
||
achse.fill(ecken[:, 0], ecken[:, 1], color=CYAN, alpha=0.13, zorder=1)
|
||
achse.plot([1.5, 0.0], [0.0, 1.5], color=CYAN, linewidth=2.0, zorder=3,
|
||
label="$2x_1+2x_2 = 3$ — zugleich das LP-Optimum")
|
||
|
||
# Ganzzahlige Punkte im Fenster, zulaessig und unzulaessig getrennt
|
||
for x1 in range(0, 3):
|
||
for x2 in range(0, 3):
|
||
zulaessig = 2 * x1 + 2 * x2 <= 3
|
||
achse.scatter(x1, x2, s=48, zorder=4,
|
||
color=INDIGO if zulaessig else "#cbd5e1")
|
||
|
||
achse.scatter([0.75], [0.75], marker="*", s=260, color=ROT, zorder=6)
|
||
achse.annotate("LP-Lösung $(0{,}75;\\ 0{,}75)$\n$Z_{LP} = 1{,}5$",
|
||
(0.75, 0.75), textcoords="offset points", xytext=(-12, -6),
|
||
ha="right", va="top", fontsize=9, color=ROT,
|
||
fontweight="bold")
|
||
|
||
achse.scatter([1], [1], s=150, facecolors="none", edgecolors=AMBER,
|
||
linewidths=2.4, zorder=6)
|
||
achse.annotate("aufgerundet $(1;1)$\n$2+2=4>3$ — unzulässig",
|
||
(1, 1), textcoords="offset points", xytext=(12, -6),
|
||
fontsize=8.5, color=AMBER, va="top")
|
||
|
||
achse.scatter([0], [0], s=150, facecolors="none", edgecolors="#64748b",
|
||
linewidths=2.2, zorder=6)
|
||
achse.annotate("abgerundet $(0;0)$\n$Z = 0$ — alles verschenkt",
|
||
(0, 0), textcoords="offset points", xytext=(14, 8),
|
||
fontsize=8.5, color="#475569")
|
||
|
||
for punkt in ((1, 0), (0, 1)):
|
||
achse.scatter(*punkt, s=140, color=GRUEN, zorder=7)
|
||
achse.annotate("wahres Optimum $Z_{IP}=1$", (1, 0),
|
||
textcoords="offset points", xytext=(12, -4),
|
||
va="top", fontsize=8.5, color=GRUEN, fontweight="bold")
|
||
|
||
achse.set_xlim(-0.35, 2.35)
|
||
achse.set_ylim(-0.35, 2.35)
|
||
achse.set_xlabel("$x_1$")
|
||
achse.set_ylabel("$x_2$")
|
||
achse.set_title("Die Handrechnung als Bild:\nrunden geht in beide Richtungen schief",
|
||
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)
|
||
|
||
|
||
def zeichne_messung(achse, zeilen) -> None:
|
||
namen = [z["name"] for z in zeilen]
|
||
stellen = np.arange(len(zeilen))
|
||
breite = 0.26
|
||
|
||
achse.bar(stellen - breite, [z["unzulaessig"] * 100 for z in zeilen],
|
||
breite, color=AMBER, label="Aufrunden unzulässig")
|
||
achse.bar(stellen, [z["ab"] * 100 for z in zeilen],
|
||
breite, color=ROT, label="Abrunden: mittlerer Verlust")
|
||
achse.bar(stellen + breite, [z["gierig"] * 100 for z in zeilen],
|
||
breite, color=GRUEN, label="abrunden + gierig auffüllen")
|
||
|
||
for stelle, zeile in zip(stellen, zeilen):
|
||
for versatz, wert in ((-breite, zeile["unzulaessig"]),
|
||
(0.0, zeile["ab"]), (breite, zeile["gierig"])):
|
||
achse.annotate(f"{wert * 100:.0f}", (stelle + versatz, wert * 100),
|
||
textcoords="offset points", xytext=(0, 3),
|
||
ha="center", fontsize=7.5, color="#334155")
|
||
|
||
achse.set_xticks(stellen)
|
||
achse.set_xticklabels(namen)
|
||
achse.set_xlabel("Instanzgröße (Variablen × Nebenbedingungen)")
|
||
achse.set_ylabel("Anteil bzw. Verlust (%)")
|
||
achse.set_ylim(0, 118)
|
||
achse.set_title(f"Gemessen an je {INSTANZEN} Zufallsinstanzen:\n"
|
||
"Aufrunden wird mit der Größe hoffnungslos", fontsize=10.5)
|
||
achse.grid(axis="y", linestyle=":", alpha=0.45)
|
||
achse.legend(fontsize=8, loc="upper center", frameon=False, ncol=1)
|
||
for seite in ("top", "right"):
|
||
achse.spines[seite].set_visible(False)
|
||
|
||
|
||
def zeichne(zeilen) -> None:
|
||
figur, (links, rechts) = plt.subplots(1, 2, figsize=(11.0, 4.8))
|
||
zeichne_handrechnung(links)
|
||
zeichne_messung(rechts, zeilen)
|
||
figur.tight_layout()
|
||
|
||
os.makedirs(BILDER, exist_ok=True)
|
||
for endung in ("svg", "png"):
|
||
pfad = os.path.join(BILDER, f"kap_milp_runden.{endung}")
|
||
figur.savefig(pfad, format=endung, dpi=160,
|
||
metadata={"Date": None} if endung == "svg" else None)
|
||
print(f"geschrieben: {pfad}")
|
||
plt.close(figur)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
zeilen = messreihe()
|
||
print(f"{'n x m':>8} | {'Aufrunden unzul.':>17} | {'Abrunden':>10} | {'gierig':>8}")
|
||
print("-" * 54)
|
||
for zeile in zeilen:
|
||
print(f"{zeile['name']:>8} | {zeile['unzulaessig'] * 100:>16.1f} % | "
|
||
f"{zeile['ab'] * 100:>9.1f} % | {zeile['gierig'] * 100:>7.1f} %")
|
||
zeichne(zeilen)
|