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
|
|
|
|
#!/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
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
Aufgeraeumt: 33 PNG-Zweitfassungen, Bau-Ueberbleibsel, Ausgabepfade
Drei Aufraeumarbeiten - und zwei Funde, die dabei auffielen.
1. DIE PNG-ZWEITFASSUNGEN SIND WEG. Jedes Diagramm lag doppelt vor, als SVG
und als PNG, und kein einziges src=/href= in der Website zeigte je auf ein
PNG. Der Build kopierte sie trotzdem mit: 3,3 MB im Repository plus 3,3 MB,
die bei jeder Veroeffentlichung auf den Webserver gingen. Die 15 Generatoren
schreiben jetzt nur noch SVG, die Docstrings sind mitgezogen. Vor dem
Loeschen geprueft: Jedes PNG hatte sein gleichnamiges SVG, alle 33 waren
versioniert.
FUND 1: erzeuge_kap06_gantt.py folgte als einziger Generator nicht der
Konvention - weder svg.hashsalt noch metadata={"Date": None}. Sein SVG trug
einen echten Zeitstempel und bei jedem Lauf andere clip-path-IDs, war also
nie byteidentisch reproduzierbar, obwohl CLAUDE.md genau das fuer alle
Generatoren festhaelt. Aufgefallen nur, weil nach der PNG-Umstellung 32 von
33 SVGs bitgleich blieben und eines nicht. Jetzt byteidentisch ueber zwei
Laeufe.
FUND 2: spiegle_bilder() legte leere Verzeichnisse auf dem Webserver an. Der
Dateifilter arbeitete korrekt, aber os.walk durchlief auch __pycache__/, und
os.makedirs() erzeugte es am Ziel. Die Verzeichnisliste wird jetzt vorher
gefiltert. Gegengetestet.
2. BAU-UEBERBLEIBSEL entfernt (alle ignoriert und neu erzeugbar):
svg-inkscape/, build_v04.log, .pytest_cache/, Programme/output/, vier
__pycache__/ und die Excel-Mappen. Arbeitsbaum 36 -> 33 MB, danach null
ignorierte Ueberbleibsel.
3. Excel_Bruecke.py SCHREIBT NEBEN DAS SKRIPT statt ins
Arbeitsverzeichnis. Es benutzte blanke relative Namen; wer es aus der
Repository-Wurzel startete, verstreute dort produktionsmix.xlsx und
produktionsmix_ergebnis.xlsx. Jetzt wie die vier anderen schreibenden
Programme ueber os.path.dirname(os.path.abspath(__file__)). Nachgemessen:
Lauf aus der Wurzel legt dort null Dateien ab.
Ausserdem git gc: 653 lose Objekte gepackt, .git von 71 MB auf 28 MB -
reines Repacken, kein Inhalt beruehrt.
Geprueft: alle 33 im Buch referenzierten SVGs vorhanden, in Quelle und
Website; 16 Generatoren fehlerfrei; keine fehlenden Bilder im LaTeX-Lauf;
33 pytest-Tests; PDF unveraendert 760 Seiten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:22:26 +02:00
|
|
|
|
for endung in ("svg",):
|
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
|
|
|
|
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)
|