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>
This commit is contained in:
commit
b7af2f1d9a
85 changed files with 262141 additions and 0 deletions
159
bilder_04/erzeuge_architektur_diagramme.py
Normal file
159
bilder_04/erzeuge_architektur_diagramme.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_architektur_diagramme.py
|
||||
"""
|
||||
Erzeugt die beiden Architekturdiagramme fuer den Weg vom Skript zum Betrieb:
|
||||
|
||||
bilder_04/kap_praxisfallen_schichten.svg / .png Schichten von or_kern.py
|
||||
bilder_04/kap_testing_dienst.svg / .png der Dienst und sein Ablauf
|
||||
|
||||
Beide bilden ab, was in den Programmen tatsaechlich steht - nicht ein
|
||||
allgemeines Architekturschema:
|
||||
|
||||
Schichten - die sechs Abschnitte von or_kern.py, in der Reihenfolge ihrer
|
||||
Abhaengigkeiten. Die Aussage steckt in der Pfeilrichtung: Alles
|
||||
zeigt nach innen auf die Domaene, und die Domaene zeigt auf
|
||||
nichts. Genau deshalb laesst sich der Solver austauschen, ohne
|
||||
das Fachmodell anzufassen.
|
||||
Dienst - der Ablauf in Optimierungsdienst.py. Die Aussage ist die
|
||||
202-Antwort: Wer eine Optimierung synchron beantwortet, baut
|
||||
einen Dienst, der unter Last umkippt.
|
||||
|
||||
Beide entstehen mit Graphviz, in derselben Palette wie die
|
||||
Entscheidungsdiagramme (siehe erzeuge_solverwahl_diagramme.py).
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_architektur_diagramme.py
|
||||
|
||||
Benoetigt: graphviz (Python-Paket) und das Kommandozeilenwerkzeug 'dot'
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import graphviz
|
||||
|
||||
BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
BILDER = os.path.join(BASIS, "bilder_04")
|
||||
|
||||
INDIGO, CYAN, VIOLETT, GRUEN, AMBER = (
|
||||
"#4338ca", "#0891b2", "#7c3aed", "#059669", "#b45309")
|
||||
TINT = {INDIGO: "#eef2ff", CYAN: "#ecfeff", VIOLETT: "#f5f3ff",
|
||||
GRUEN: "#ecfdf5", AMBER: "#fffbeb"}
|
||||
|
||||
|
||||
def neuer_graph(titel: str, richtung: str = "TB") -> graphviz.Digraph:
|
||||
punkt = graphviz.Digraph(format="svg")
|
||||
punkt.attr(rankdir=richtung, splines="polyline", nodesep="0.35",
|
||||
ranksep="0.5", fontname="DejaVu Sans", label=titel, labelloc="t",
|
||||
fontsize="13", bgcolor="transparent")
|
||||
punkt.attr("node", fontname="DejaVu Sans", fontsize="10")
|
||||
punkt.attr("edge", fontname="DejaVu Sans", fontsize="9", color="#5c6380")
|
||||
return punkt
|
||||
|
||||
|
||||
def kasten(punkt: graphviz.Digraph, name: str, text: str, farbe: str,
|
||||
form: str = "box") -> None:
|
||||
punkt.node(name, text, shape=form, style="filled,rounded",
|
||||
fillcolor=TINT[farbe], color=farbe, penwidth="2.0",
|
||||
margin="0.16,0.09")
|
||||
|
||||
|
||||
def schreibe(punkt: graphviz.Digraph, name: str) -> None:
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
punkt.format = endung
|
||||
pfad = punkt.render(os.path.join(BILDER, name), cleanup=True)
|
||||
print(f"geschrieben: {pfad}")
|
||||
|
||||
|
||||
# --- 1. Die Schichten von or_kern.py ----------------------------------------
|
||||
|
||||
def schichten() -> None:
|
||||
punkt = neuer_graph("Die Schichten von or_kern.py — alle Pfeile zeigen nach innen")
|
||||
|
||||
kasten(punkt, "io", "Ein- und Ausgabe\n"
|
||||
"lade_produktionsproblem() · schreibe_ergebnis()\n"
|
||||
"Excel, CSV — die Wirklichkeit des Anwenders", AMBER)
|
||||
kasten(punkt, "bau", "Modellbauer — austauschbar\n"
|
||||
"loese_mit_glop() · loese_mit_scipy()\n"
|
||||
"je Solver einer, gleiche Signatur", VIOLETT)
|
||||
kasten(punkt, "pruef", "Abnahmeprüfung\npruefe_loesung()\n"
|
||||
"prüft die Lösung OHNE Solver", GRUEN)
|
||||
kasten(punkt, "erg", "Ergebnis — solverunabhängig\n"
|
||||
"SolverStatus · Loesung\n"
|
||||
"fünf Bibliotheken, eine Sprache", CYAN)
|
||||
kasten(punkt, "dom", "Domäne\nProdukt · Produktionsproblem\n"
|
||||
"validiert (Pydantic), kennt keinen Solver", INDIGO)
|
||||
|
||||
punkt.edge("io", "dom", label="liest ein, schreibt zurück")
|
||||
punkt.edge("bau", "dom", label="baut das Modell aus")
|
||||
punkt.edge("bau", "erg", label="liefert")
|
||||
punkt.edge("pruef", "dom", label="prüft gegen")
|
||||
punkt.edge("pruef", "erg", label="prüft")
|
||||
punkt.edge("erg", "dom", style="invis") # haelt die Ebenen sauber
|
||||
|
||||
with punkt.subgraph() as gleich:
|
||||
gleich.attr(rank="same")
|
||||
gleich.node("bau")
|
||||
gleich.node("pruef")
|
||||
|
||||
punkt.node("regel",
|
||||
"Die Domäne importiert nichts von außen.\\l"
|
||||
"Deshalb kostet ein Solverwechsel eine Funktion —\\l"
|
||||
"und kein einziges Fachdetail.\\l",
|
||||
shape="note", style="filled", fillcolor="#f8fafc",
|
||||
color="#94a3b8", fontsize="9", margin="0.18,0.12")
|
||||
punkt.edge("dom", "regel", style="dashed", color="#94a3b8", arrowhead="none")
|
||||
|
||||
schreibe(punkt, "kap_praxisfallen_schichten")
|
||||
|
||||
|
||||
# --- 2. Der Optimierungsdienst ----------------------------------------------
|
||||
|
||||
def dienst() -> None:
|
||||
punkt = neuer_graph("Optimierungsdienst: warum die Antwort 202 lautet, nicht 200")
|
||||
|
||||
kasten(punkt, "klient", "Aufrufer\n(curl, Fachanwendung)", CYAN)
|
||||
kasten(punkt, "post", "POST /plaene\nJSON mit dem Problem", INDIGO)
|
||||
kasten(punkt, "valid", "Pydantic prüft\ndie Eingabe", VIOLETT, form="diamond")
|
||||
kasten(punkt, "fehler", "422\nFehler benannt,\nbevor ein Solver läuft", AMBER)
|
||||
kasten(punkt, "an", "202 Accepted\n+ Auftrags-ID", GRUEN)
|
||||
kasten(punkt, "rechne", "Hintergrundrechnung\nrechne() → or_kern", VIOLETT)
|
||||
kasten(punkt, "get", "GET /plaene/{id}\nlaeuft · fertig · fehlgeschlagen", INDIGO)
|
||||
kasten(punkt, "gesund", "GET /gesundheit\nfür die Überwachung", CYAN)
|
||||
|
||||
punkt.edge("klient", "post")
|
||||
punkt.edge("post", "valid")
|
||||
punkt.edge("valid", "fehler", label="ungültig")
|
||||
punkt.edge("valid", "an", label="gültig")
|
||||
punkt.edge("an", "rechne", label="startet")
|
||||
# constraint=false: Die Rueckmeldung darf die Rangfolge nicht umsortieren,
|
||||
# sonst wandert der Aufrufer unter den Dienst.
|
||||
punkt.edge("an", "klient", label="sofort zurück", style="dashed",
|
||||
constraint="false")
|
||||
punkt.edge("klient", "get", label="fragt nach")
|
||||
punkt.edge("rechne", "get", label="hinterlegt das Ergebnis", style="dashed")
|
||||
punkt.edge("klient", "gesund", style="dotted")
|
||||
|
||||
with punkt.subgraph() as gleich:
|
||||
gleich.attr(rank="same")
|
||||
gleich.node("fehler")
|
||||
gleich.node("an")
|
||||
|
||||
punkt.node("regel",
|
||||
"Eine Optimierung dauert Sekunden bis Minuten.\\l"
|
||||
"Synchron beantwortet, blockiert sie den Dienst\\l"
|
||||
"und läuft in jedes Gateway-Timeout.\\l",
|
||||
shape="note", style="filled", fillcolor="#f8fafc",
|
||||
color="#94a3b8", fontsize="9", margin="0.18,0.12")
|
||||
punkt.edge("rechne", "regel", style="dashed", color="#94a3b8",
|
||||
arrowhead="none")
|
||||
|
||||
schreibe(punkt, "kap_testing_dienst")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
schichten()
|
||||
dienst()
|
||||
192
bilder_04/erzeuge_branch_and_bound.py
Normal file
192
bilder_04/erzeuge_branch_and_bound.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_branch_and_bound.py
|
||||
"""
|
||||
Erzeugt den Branch-and-Bound-Suchbaum zum MILP-Kapitel:
|
||||
|
||||
bilder_04/kap_milp_suchbaum.svg / .png
|
||||
|
||||
Gezeichnet wird der Baum der Handrechnung des Kapitels:
|
||||
|
||||
max 5 x1 + 4 x2 u.d.N. 6 x1 + 4 x2 <= 24, x1 + 2 x2 <= 6, x ganzzahlig
|
||||
|
||||
Der Baum wird NICHT abgeschrieben, sondern gerechnet: Eine kleine
|
||||
Branch-and-Bound-Schleife loest die LP-Relaxationen mit scipy.linprog,
|
||||
verzweigt ueber die erste gebrochene Variable und kappt nach denselben drei
|
||||
Regeln, die der Kapiteltext nennt (unzulaessig, Schranke schlechter als der
|
||||
Incumbent, Loesung bereits ganzzahlig). Am Ende prueft das Skript, dass Optimum
|
||||
und Zahl der geloesten LPs mit dem Buchtext uebereinstimmen - stimmt etwas
|
||||
nicht, bricht es ab, statt ein plausibles Bild zu malen.
|
||||
|
||||
Warum ueberhaupt neu: Das Vorgaengerbild zeigte einen Baum mit voellig anderen
|
||||
Zahlen (Wurzel x1 = 2,7, Schranke 150,4, Incumbent 138) zu einem Modell, das
|
||||
nirgends im Buch steht.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_branch_and_bound.py
|
||||
|
||||
Benoetigt: numpy, scipy, graphviz (Python-Paket) und das Werkzeug 'dot'
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import graphviz
|
||||
import numpy as np
|
||||
from scipy.optimize import linprog
|
||||
|
||||
BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
BILDER = os.path.join(BASIS, "bilder_04")
|
||||
|
||||
INDIGO, CYAN, VIOLETT, GRUEN, AMBER = (
|
||||
"#4338ca", "#0891b2", "#7c3aed", "#059669", "#b45309")
|
||||
TINT = {INDIGO: "#eef2ff", CYAN: "#ecfeff", VIOLETT: "#f5f3ff",
|
||||
GRUEN: "#ecfdf5", AMBER: "#fffbeb"}
|
||||
|
||||
# Modell der Handrechnung
|
||||
C = np.array([5.0, 4.0])
|
||||
A = np.array([[6.0, 4.0], [1.0, 2.0]])
|
||||
B = np.array([24.0, 6.0])
|
||||
TOL = 1e-7
|
||||
|
||||
|
||||
def loese(untergrenzen, obergrenzen):
|
||||
"""LP-Relaxation mit zusaetzlichen Schranken. Liefert (x, Z) oder (None, None)."""
|
||||
ergebnis = linprog(c=-C, A_ub=A, b_ub=B,
|
||||
bounds=list(zip(untergrenzen, obergrenzen)),
|
||||
method="highs")
|
||||
if not ergebnis.success:
|
||||
return None, None
|
||||
return np.asarray(ergebnis.x), float(-ergebnis.fun)
|
||||
|
||||
|
||||
def erste_gebrochene(x):
|
||||
for j, wert in enumerate(x):
|
||||
if abs(wert - round(wert)) > 1e-6:
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
def zahl(wert: float) -> str:
|
||||
"""Deutsche Schreibweise, hoechstens zwei Nachkommastellen.
|
||||
|
||||
Das '+ 0.0' faengt die negative Null ab: linprog liefert fuer x2 = 0
|
||||
gelegentlich -0.0, und '(4; -0)' im Buch waere ein Schoenheitsfehler mit
|
||||
Ansage.
|
||||
"""
|
||||
text = f"{wert + 0.0:.2f}".rstrip("0").rstrip(".")
|
||||
if text in ("-0", "-"):
|
||||
text = "0"
|
||||
return text.replace(".", ",")
|
||||
|
||||
|
||||
def branch_and_bound():
|
||||
"""Baut den Suchbaum und liefert (Knotenliste, Kanten, Optimum, Zielwert).
|
||||
|
||||
Die Reihenfolge - erst der Ast mit der kleineren Obergrenze, dann der
|
||||
andere - entspricht der Handrechnung: Ast A (x2 <= 1) wird vor Ast B
|
||||
(x2 >= 2) bearbeitet, weshalb der Incumbent aus A stammt und B daran
|
||||
scheitert.
|
||||
"""
|
||||
knoten, kanten = [], []
|
||||
incumbent, incumbent_x = -np.inf, None
|
||||
# TIEFENSUCHE (Stapel, nicht Warteschlange). Das ist keine Kosmetik: Nur so
|
||||
# ist Ast A samt seinen Blaettern fertig, bevor Ast B an die Reihe kommt -
|
||||
# und nur deshalb scheitert B am Incumbent 20, wie es der Kapiteltext
|
||||
# beschreibt. Breitensuche wuerde B zum Incumbent machen und die Aussage
|
||||
# des Bildes umdrehen.
|
||||
stapel = [("P₀", None, [0.0, 0.0], [np.inf, np.inf], None)]
|
||||
zaehler = 0
|
||||
|
||||
while stapel:
|
||||
name, elter, unten, oben, kantentext = stapel.pop()
|
||||
x, z = loese(unten, oben)
|
||||
zaehler += 1
|
||||
if elter is not None:
|
||||
kanten.append((elter, name, kantentext))
|
||||
|
||||
if x is None:
|
||||
knoten.append((name, "unzulässig — gekappt", AMBER, None))
|
||||
continue
|
||||
if z < incumbent - TOL:
|
||||
knoten.append((name, f"Z = {zahl(z)} ≤ Incumbent {zahl(incumbent)}\\n"
|
||||
"→ gekappt", AMBER, None))
|
||||
continue
|
||||
|
||||
j = erste_gebrochene(x)
|
||||
if j is None:
|
||||
besser = z > incumbent + TOL
|
||||
if besser:
|
||||
incumbent, incumbent_x = z, x
|
||||
knoten.append((name,
|
||||
f"x = ({zahl(x[0])}; {zahl(x[1])}), Z = {zahl(z)}\\n"
|
||||
+ ("ganzzahlig — neuer Incumbent" if besser
|
||||
else f"ganzzahlig, aber ≤ Incumbent {zahl(incumbent)}"),
|
||||
GRUEN if besser else AMBER, z))
|
||||
continue
|
||||
|
||||
knoten.append((name,
|
||||
f"x = ({zahl(x[0])}; {zahl(x[1])}), Schranke Z ≤ {zahl(z)}\\n"
|
||||
f"x{j + 1} gebrochen — verzweigen", INDIGO if elter is None else CYAN,
|
||||
z))
|
||||
grenze = float(np.floor(x[j]))
|
||||
links_oben = list(oben); links_oben[j] = grenze
|
||||
rechts_unten = list(unten); rechts_unten[j] = grenze + 1
|
||||
# Zuerst den ">="-Ast auflegen, damit der "<="-Ast oben liegt und
|
||||
# als Erster bearbeitet wird - die Reihenfolge der Handrechnung.
|
||||
# Namen wie im Kapiteltext: die Wurzel verzweigt in Ast A und Ast B,
|
||||
# deren Kinder heissen A1/A2 bzw. B1/B2.
|
||||
links, rechts = ("A", "B") if elter is None else (f"{name}1", f"{name}2")
|
||||
stapel.append((rechts, name, rechts_unten, list(oben),
|
||||
f"x{j + 1} ≥ {int(grenze) + 1}"))
|
||||
stapel.append((links, name, list(unten), links_oben,
|
||||
f"x{j + 1} ≤ {int(grenze)}"))
|
||||
|
||||
return knoten, kanten, incumbent_x, incumbent, zaehler
|
||||
|
||||
|
||||
def zeichne(knoten, kanten, bestes, zielwert, geloest) -> None:
|
||||
punkt = graphviz.Digraph(format="svg")
|
||||
punkt.attr(rankdir="TB", splines="polyline", nodesep="0.45", ranksep="0.55",
|
||||
fontname="DejaVu Sans", bgcolor="transparent", fontsize="12",
|
||||
labelloc="t",
|
||||
label=(f"Branch-and-Bound: {geloest} gelöste LPs statt aller "
|
||||
f"ganzzahligen Punkte\\n"
|
||||
f"max 5x₁ + 4x₂ u.d.N. 6x₁+4x₂ ≤ 24, x₁+2x₂ ≤ 6"))
|
||||
punkt.attr("node", fontname="DejaVu Sans", fontsize="10")
|
||||
punkt.attr("edge", fontname="DejaVu Sans", fontsize="9", color="#5c6380")
|
||||
|
||||
for name, text, farbe, _ in knoten:
|
||||
beschriftung = f"{name}\\n{text}"
|
||||
punkt.node(name, beschriftung, shape="box", style="filled,rounded",
|
||||
fillcolor=TINT[farbe], color=farbe, penwidth="2.0",
|
||||
margin="0.16,0.09")
|
||||
for von, nach, text in kanten:
|
||||
punkt.edge(von, nach, label=text or "")
|
||||
|
||||
punkt.node("ergebnis",
|
||||
f"Optimum: x* = ({zahl(bestes[0])}; {zahl(bestes[1])}), "
|
||||
f"Z* = {zahl(zielwert)}",
|
||||
shape="note", style="filled", fillcolor="#f8fafc",
|
||||
color="#94a3b8", fontsize="10", margin="0.18,0.12")
|
||||
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
punkt.format = endung
|
||||
pfad = punkt.render(os.path.join(BILDER, "kap_milp_suchbaum"), cleanup=True)
|
||||
print(f"geschrieben: {pfad}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
knoten, kanten, bestes, zielwert, geloest = branch_and_bound()
|
||||
print(f"{len(knoten)} Knoten, {geloest} geloeste LPs")
|
||||
for name, text, _, _ in knoten:
|
||||
print(f" {name:5} {text.replace(chr(92) + 'n', ' | ')}")
|
||||
print(f"Optimum: ({bestes[0]:g}; {bestes[1]:g}), Z = {zielwert:g}")
|
||||
|
||||
# Gegenprobe gegen die Handrechnung im Kapitel
|
||||
if not (np.allclose(bestes, [4.0, 0.0]) and abs(zielwert - 20.0) < TOL):
|
||||
raise SystemExit("FEHLER: Ergebnis weicht von der Handrechnung ab "
|
||||
"(erwartet x* = (4; 0), Z* = 20).")
|
||||
zeichne(knoten, kanten, bestes, zielwert, geloest)
|
||||
275
bilder_04/erzeuge_effizienzlinie.py
Normal file
275
bilder_04/erzeuge_effizienzlinie.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_effizienzlinie.py
|
||||
"""
|
||||
Erzeugt die Figur "Was die Hausregeln kosten" zum Kapitel Markowitz:
|
||||
|
||||
bilder_04/kap_markowitz_restriktionen.svg statisch, fuer das PDF
|
||||
bilder_04/kap_markowitz_restriktionen.png Rasterfassung
|
||||
bilder_04/plotly/kap_markowitz_restriktionen.html interaktiv, fuer die Website
|
||||
|
||||
Gezeigt werden zwei Effizienzlinien desselben Universums: einmal ohne
|
||||
Beschraenkungen, einmal mit den institutionellen Regeln aus dem Kapitel
|
||||
(hoechstens 20 % je Titel, Sektorgrenzen). Der senkrechte Abstand zwischen
|
||||
beiden Kurven IST der Preis der Hausregeln - in Renditepunkten, bei gleichem
|
||||
Risiko.
|
||||
|
||||
Warum eine feste Instanz und nicht die Kursdaten aus Markowitz_CVXPY.py:
|
||||
Jenes Programm laedt Livedaten. Ein Diagramm daraus saehe an jedem Tag anders
|
||||
aus, und der abgedruckte Text koennte nicht dazu passen. Hier wird deshalb ein
|
||||
FESTES Universum aus einem Faktormodell erzeugt (Marktfaktor, vier
|
||||
Sektorfaktoren, titelspezifisches Rauschen) - dieselbe Struktur wie im Kapitel,
|
||||
dieselben Regeln, aber reproduzierbar. Die Zahlen des Livelaufs weichen ab, die
|
||||
Aussage nicht.
|
||||
|
||||
Die interaktive Fassung zeigt im Hover, was das statische Bild nicht kann: die
|
||||
GEWICHTE des jeweiligen Portfolios. Damit wird sichtbar, wie sich die
|
||||
Zusammensetzung entlang der Linie verschiebt - und woran die beschraenkte
|
||||
Fassung scheitert, sobald ein einzelner Titel an seine 20-%-Grenze stoesst.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_effizienzlinie.py
|
||||
|
||||
Benoetigt: numpy, cvxpy, matplotlib, plotly
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import cvxpy as cp
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import plotly.graph_objects as go
|
||||
|
||||
# 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")
|
||||
PLOTLY_VERZ = os.path.join(BILDER, "plotly")
|
||||
|
||||
INDIGO, CYAN, AMBER, GRUEN = "#4338ca", "#0891b2", "#b45309", "#059669"
|
||||
|
||||
# Struktur wie in Markowitz_CVXPY.py - zehn Titel in vier Sektoren
|
||||
TITEL = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
|
||||
SEKTOR_VON = ["Technologie"] * 4 + ["Gesundheit"] * 2 + ["Finanzen"] * 2 + ["Energie"] * 2
|
||||
SEKTORGRENZEN = {"Technologie": 0.35, "Gesundheit": 0.40,
|
||||
"Finanzen": 0.40, "Energie": 0.40}
|
||||
MAX_GEWICHT = 0.20
|
||||
PUNKTE = 40
|
||||
|
||||
|
||||
def universum(saat: int = 3) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Erwartete Renditen und Kovarianzmatrix aus einem Faktormodell.
|
||||
|
||||
Jeder Titel haengt am Markt, an seinem Sektor und an eigenem Rauschen.
|
||||
Genau diese Struktur macht Diversifikation ueberhaupt moeglich: Titel
|
||||
desselben Sektors laufen staerker im Gleichschritt als Titel verschiedener
|
||||
Sektoren - und nur der zweite Fall traegt.
|
||||
"""
|
||||
rng = np.random.default_rng(saat)
|
||||
n = len(TITEL)
|
||||
sektoren = sorted(set(SEKTOR_VON))
|
||||
ladung_markt = rng.uniform(0.7, 1.3, n)
|
||||
ladung_sektor = rng.uniform(0.4, 0.9, n)
|
||||
eigen = rng.uniform(0.10, 0.26, n)
|
||||
|
||||
sigma_markt, sigma_sektor = 0.16, 0.12
|
||||
kovarianz = np.outer(ladung_markt, ladung_markt) * sigma_markt ** 2
|
||||
for sektor in sektoren:
|
||||
maske = np.array([s == sektor for s in SEKTOR_VON], dtype=float)
|
||||
gewicht = ladung_sektor * maske
|
||||
kovarianz += np.outer(gewicht, gewicht) * sigma_sektor ** 2
|
||||
kovarianz += np.diag(eigen ** 2)
|
||||
|
||||
# Erwartete Renditen: Marktprämie plus etwas titelspezifische Streuung
|
||||
mu = 0.03 + 0.055 * ladung_markt + rng.normal(0, 0.012, n)
|
||||
return mu, kovarianz
|
||||
|
||||
|
||||
def sektormasken() -> dict[str, np.ndarray]:
|
||||
return {sektor: np.array([s == sektor for s in SEKTOR_VON], dtype=float)
|
||||
for sektor in SEKTORGRENZEN}
|
||||
|
||||
|
||||
def linie(mu: np.ndarray, kovarianz: np.ndarray, beschraenkt: bool):
|
||||
"""Effizienzlinie ueber das epsilon-Constraint-Verfahren.
|
||||
|
||||
Fuer jede Zielrendite wird das minimale Risiko gesucht. Das ist dasselbe
|
||||
Verfahren, mit dem im Kapitel Mehrziel die Pareto-Front entsteht - eine
|
||||
Effizienzlinie IST eine Pareto-Front aus Rendite und Risiko.
|
||||
"""
|
||||
n = len(mu)
|
||||
w = cp.Variable(n, nonneg=True)
|
||||
ziel_rendite = cp.Parameter(nonneg=True)
|
||||
|
||||
# Cholesky statt quad_form: die 2-Norm eines affinen Ausdrucks ist
|
||||
# zweifelsfrei DCP (siehe die Fehlerdiagnose zu DCPError).
|
||||
wurzel = np.linalg.cholesky(kovarianz + 1e-12 * np.eye(n))
|
||||
|
||||
bedingungen = [cp.sum(w) == 1, mu @ w >= ziel_rendite]
|
||||
if beschraenkt:
|
||||
bedingungen.append(w <= MAX_GEWICHT)
|
||||
for sektor, maske in sektormasken().items():
|
||||
bedingungen.append(maske @ w <= SEKTORGRENZEN[sektor])
|
||||
|
||||
problem = cp.Problem(cp.Minimize(cp.norm2(wurzel.T @ w)), bedingungen)
|
||||
|
||||
# Der obere Rand ist NICHT max(mu): Unter Restriktionen ist der beste
|
||||
# Einzeltitel gar nicht voll kaufbar. Also wird er ausgerechnet.
|
||||
w_max = cp.Variable(n, nonneg=True)
|
||||
obere = [cp.sum(w_max) == 1]
|
||||
if beschraenkt:
|
||||
obere.append(w_max <= MAX_GEWICHT)
|
||||
for sektor, maske in sektormasken().items():
|
||||
obere.append(maske @ w_max <= SEKTORGRENZEN[sektor])
|
||||
cp.Problem(cp.Maximize(mu @ w_max), obere).solve()
|
||||
hoechste = float(mu @ w_max.value)
|
||||
|
||||
# Untere Grenze: die Rendite des Minimum-Varianz-Portfolios
|
||||
ziel_rendite.value = 0.0
|
||||
problem.solve()
|
||||
kleinste = float(mu @ w.value)
|
||||
|
||||
punkte = []
|
||||
for ziel in np.linspace(kleinste, hoechste, PUNKTE):
|
||||
ziel_rendite.value = float(ziel)
|
||||
problem.solve()
|
||||
if problem.status not in (cp.OPTIMAL, cp.OPTIMAL_INACCURATE):
|
||||
continue
|
||||
gewichte = np.asarray(w.value).ravel()
|
||||
risiko = float(np.sqrt(gewichte @ kovarianz @ gewichte))
|
||||
punkte.append((risiko, float(mu @ gewichte), gewichte))
|
||||
return punkte
|
||||
|
||||
|
||||
def aufpreis(frei, gebunden) -> tuple[float, float]:
|
||||
"""Groesster senkrechter Abstand: Wo kosten die Regeln am meisten Rendite?
|
||||
|
||||
Verglichen wird bei GLEICHEM Risiko - alles andere waere kein Preis,
|
||||
sondern nur ein anderer Punkt auf der Kurve.
|
||||
"""
|
||||
risiken_frei = np.array([p[0] for p in frei])
|
||||
renditen_frei = np.array([p[1] for p in frei])
|
||||
bester_abstand, bestes_risiko = 0.0, 0.0
|
||||
for risiko, rendite, _ in gebunden:
|
||||
if risiko < risiken_frei.min() or risiko > risiken_frei.max():
|
||||
continue
|
||||
vergleich = float(np.interp(risiko, risiken_frei, renditen_frei))
|
||||
if vergleich - rendite > bester_abstand:
|
||||
bester_abstand, bestes_risiko = vergleich - rendite, risiko
|
||||
return bester_abstand, bestes_risiko
|
||||
|
||||
|
||||
def zeichne_statisch(frei, gebunden, abstand, risiko_stelle) -> None:
|
||||
figur, achse = plt.subplots(figsize=(7.4, 4.6))
|
||||
for punkte, farbe, name, stil in (
|
||||
(frei, INDIGO, "ohne Beschränkungen", "-"),
|
||||
(gebunden, AMBER, "mit Hausregeln (20 % je Titel, Sektorgrenzen)", "-")):
|
||||
x = [p[0] * 100 for p in punkte]
|
||||
y = [p[1] * 100 for p in punkte]
|
||||
achse.plot(x, y, stil, color=farbe, linewidth=2.0, label=name)
|
||||
|
||||
if abstand > 0:
|
||||
oben = float(np.interp(risiko_stelle,
|
||||
[p[0] for p in frei], [p[1] for p in frei]))
|
||||
unten = oben - abstand
|
||||
achse.annotate("", xy=(risiko_stelle * 100, oben * 100),
|
||||
xytext=(risiko_stelle * 100, unten * 100),
|
||||
arrowprops=dict(arrowstyle="<->", color=GRUEN, lw=1.8))
|
||||
text = f"{abstand * 100:.2f} Prozentpunkte".replace(".", ",")
|
||||
achse.annotate(f"{text}\nRendite, bei gleichem Risiko",
|
||||
(risiko_stelle * 100, (oben + unten) / 2 * 100),
|
||||
textcoords="offset points", xytext=(10, 0),
|
||||
fontsize=8.5, color=GRUEN, va="center")
|
||||
|
||||
# Der teuerste Effekt ist nicht der senkrechte Abstand, sondern die Decke:
|
||||
# Die beschraenkte Linie endet frueher. Alles rechts davon ist mit den
|
||||
# Hausregeln ueberhaupt nicht erreichbar - egal, wie viel Risiko man traegt.
|
||||
ende_gebunden, ende_frei = gebunden[-1], frei[-1]
|
||||
achse.scatter([ende_gebunden[0] * 100], [ende_gebunden[1] * 100], s=110,
|
||||
facecolors="none", edgecolors=AMBER, linewidths=2.2, zorder=5)
|
||||
verlust = (ende_frei[1] - ende_gebunden[1]) * 100
|
||||
achse.annotate(f"hier ist Schluss: {ende_gebunden[1] * 100:.2f} % statt "
|
||||
f"{ende_frei[1] * 100:.2f} %\n"
|
||||
f"— {verlust:.2f} Punkte, die kein Risiko mehr kauft".replace(".", ","),
|
||||
(ende_gebunden[0] * 100, ende_gebunden[1] * 100),
|
||||
textcoords="offset points", xytext=(8, -22),
|
||||
fontsize=8.5, color=AMBER)
|
||||
|
||||
achse.set_xlabel("Volatilität $\\sigma$ (% p. a.)")
|
||||
achse.set_ylabel("erwartete Rendite $\\mu$ (% p. a.)")
|
||||
achse.set_title("Was die Hausregeln kosten", fontsize=11)
|
||||
achse.grid(linestyle=":", alpha=0.5)
|
||||
achse.legend(fontsize=8.5, loc="lower right", frameon=False)
|
||||
for rand in ("top", "right"):
|
||||
achse.spines[rand].set_visible(False)
|
||||
figur.tight_layout()
|
||||
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
pfad = os.path.join(BILDER, f"kap_markowitz_restriktionen.{endung}")
|
||||
figur.savefig(pfad, format=endung, dpi=160,
|
||||
metadata={"Date": None} if endung == "svg" else None)
|
||||
print(f"geschrieben: {pfad}")
|
||||
plt.close(figur)
|
||||
|
||||
|
||||
def zeichne_interaktiv(frei, gebunden) -> None:
|
||||
figur = go.Figure()
|
||||
|
||||
def beschriftung(punkt):
|
||||
risiko, rendite, gewichte = punkt
|
||||
reihenfolge = np.argsort(-gewichte)[:4]
|
||||
anteile = "<br>".join(
|
||||
f" {TITEL[i]} ({SEKTOR_VON[i]}): {gewichte[i] * 100:.1f} %"
|
||||
for i in reihenfolge if gewichte[i] > 0.005)
|
||||
sektoren = "<br>".join(
|
||||
f" {sektor}: {float(maske @ gewichte) * 100:.1f} %"
|
||||
for sektor, maske in sektormasken().items())
|
||||
return (f"<b>{rendite * 100:.2f} % Rendite</b><br>"
|
||||
f"{risiko * 100:.2f} % Volatilität<br>"
|
||||
f"Sharpe {(rendite - 0.03) / risiko:.2f}<br><br>"
|
||||
f"<b>größte Positionen</b><br>{anteile}<br><br>"
|
||||
f"<b>je Sektor</b><br>{sektoren}<extra></extra>")
|
||||
|
||||
for punkte, farbe, name in ((frei, INDIGO, "ohne Beschränkungen"),
|
||||
(gebunden, AMBER, "mit Hausregeln")):
|
||||
figur.add_trace(go.Scatter(
|
||||
x=[p[0] * 100 for p in punkte], y=[p[1] * 100 for p in punkte],
|
||||
mode="lines+markers", name=name,
|
||||
line=dict(color=farbe, width=2.2), marker=dict(size=6),
|
||||
hovertemplate=[beschriftung(p) for p in punkte]))
|
||||
|
||||
figur.update_layout(
|
||||
title="Effizienzlinie mit und ohne Hausregeln — Gewichte im Hover",
|
||||
xaxis=dict(title="Volatilität σ (% p. a.)"),
|
||||
yaxis=dict(title="erwartete Rendite μ (% p. a.)"),
|
||||
height=460, margin=dict(l=70, r=20, t=55, b=50),
|
||||
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
||||
legend=dict(orientation="h", y=-0.18), hovermode="closest")
|
||||
|
||||
os.makedirs(PLOTLY_VERZ, exist_ok=True)
|
||||
pfad = os.path.join(PLOTLY_VERZ, "kap_markowitz_restriktionen.html")
|
||||
with open(pfad, "w", encoding="utf-8") as datei:
|
||||
datei.write(figur.to_html(full_html=False, include_plotlyjs=False,
|
||||
div_id="kap-markowitz-restriktionen"))
|
||||
print(f"geschrieben: {pfad}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mu, kovarianz = universum()
|
||||
frei = linie(mu, kovarianz, beschraenkt=False)
|
||||
gebunden = linie(mu, kovarianz, beschraenkt=True)
|
||||
abstand, stelle = aufpreis(frei, gebunden)
|
||||
print(f"frei: {len(frei)} Punkte, Rendite "
|
||||
f"{frei[0][1] * 100:.2f} bis {frei[-1][1] * 100:.2f} %")
|
||||
print(f"gebunden: {len(gebunden)} Punkte, Rendite "
|
||||
f"{gebunden[0][1] * 100:.2f} bis {gebunden[-1][1] * 100:.2f} %")
|
||||
print(f"groesster Abstand: {abstand * 100:.2f} Prozentpunkte "
|
||||
f"bei sigma = {stelle * 100:.2f} %")
|
||||
zeichne_statisch(frei, gebunden, abstand, stelle)
|
||||
zeichne_interaktiv(frei, gebunden)
|
||||
170
bilder_04/erzeuge_kap06_gantt.py
Normal file
170
bilder_04/erzeuge_kap06_gantt.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_kap06_gantt.py
|
||||
"""
|
||||
Erzeugt das Gantt-Diagramm zum Job-Shop-Beispiel aus Kapitel 6 in zwei Fassungen:
|
||||
|
||||
bilder_04/kap06_jobshop_gantt.svg statisch, fuer das PDF
|
||||
bilder_04/kap06_jobshop_gantt.png statisch, Rasterfassung
|
||||
bilder_04/plotly/kap06_jobshop_gantt.html interaktiv, fuer die Website
|
||||
|
||||
Die Daten sind identisch mit JobShop_Intervalle.py; geloest wird hier erneut,
|
||||
damit Diagramm und Buchtext nicht auseinanderlaufen koennen.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_kap06_gantt.py
|
||||
|
||||
Benoetigt: ortools, matplotlib, plotly
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg") # kein Fenster, auch auf Servern
|
||||
import matplotlib.pyplot as plt
|
||||
import plotly.graph_objects as go
|
||||
from ortools.sat.python import cp_model
|
||||
|
||||
# Identisch zu JobShop_Intervalle.py
|
||||
AUFTRAEGE = [
|
||||
[(0, 3), (1, 2), (2, 2)], # Auftrag 0
|
||||
[(0, 2), (2, 1), (1, 4)], # Auftrag 1
|
||||
[(1, 4), (2, 3)], # Auftrag 2
|
||||
]
|
||||
MASCHINENNAMEN = ["Fraese", "Dreherei", "Lackiererei"]
|
||||
|
||||
# Buchpalette (Indigo, Cyan, Amber) - dieselben Farben wie im uebrigen Werk
|
||||
FARBEN = ["#4338ca", "#0891b2", "#b45309"]
|
||||
|
||||
BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
BILDER = os.path.join(BASIS, "bilder_04")
|
||||
PLOTLY_VERZ = os.path.join(BILDER, "plotly")
|
||||
|
||||
|
||||
def loese() -> tuple[list[tuple[int, int, int, int]], int]:
|
||||
"""Liefert (Auftrag, Maschine, Beginn, Dauer) je Arbeitsgang und den Makespan."""
|
||||
anzahl_maschinen = 1 + max(m for auftrag in AUFTRAEGE for m, _ in auftrag)
|
||||
horizont = sum(dauer for auftrag in AUFTRAEGE for _, dauer in auftrag)
|
||||
|
||||
modell = cp_model.CpModel()
|
||||
intervalle: dict[int, list] = {m: [] for m in range(anzahl_maschinen)}
|
||||
beginn_var: dict[tuple[int, int], object] = {}
|
||||
ende_var: dict[tuple[int, int], object] = {}
|
||||
|
||||
for a, auftrag in enumerate(AUFTRAEGE):
|
||||
for g, (maschine, dauer) in enumerate(auftrag):
|
||||
beginn = modell.NewIntVar(0, horizont, f"b_{a}_{g}")
|
||||
ende = modell.NewIntVar(0, horizont, f"e_{a}_{g}")
|
||||
intervall = modell.NewIntervalVar(beginn, dauer, ende, f"i_{a}_{g}")
|
||||
intervalle[maschine].append(intervall)
|
||||
beginn_var[a, g] = beginn
|
||||
ende_var[a, g] = ende
|
||||
if g > 0: # Arbeitsgaenge in ihrer Reihenfolge
|
||||
modell.Add(beginn >= ende_var[a, g - 1])
|
||||
|
||||
for maschine in range(anzahl_maschinen):
|
||||
modell.AddNoOverlap(intervalle[maschine])
|
||||
|
||||
makespan = modell.NewIntVar(0, horizont, "makespan")
|
||||
modell.AddMaxEquality(makespan, [ende_var[a, len(auftrag) - 1]
|
||||
for a, auftrag in enumerate(AUFTRAEGE)])
|
||||
modell.Minimize(makespan)
|
||||
|
||||
loeser = cp_model.CpSolver()
|
||||
# Dieselben Parameter wie in JobShop_Intervalle.py - sonst zeigt das
|
||||
# Diagramm einen anderen (gleich guten) Plan als der Buchtext.
|
||||
loeser.parameters.num_workers = 1
|
||||
loeser.parameters.random_seed = 1
|
||||
status = loeser.Solve(modell)
|
||||
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
||||
raise RuntimeError(f"Kein Plan gefunden: {loeser.StatusName(status)}")
|
||||
|
||||
plan = [(a, maschine, loeser.Value(beginn_var[a, g]), dauer)
|
||||
for a, auftrag in enumerate(AUFTRAEGE)
|
||||
for g, (maschine, dauer) in enumerate(auftrag)]
|
||||
return plan, loeser.Value(makespan)
|
||||
|
||||
|
||||
def zeichne_statisch(plan, makespan: int) -> None:
|
||||
"""Matplotlib-Fassung fuer das PDF."""
|
||||
figur, achse = plt.subplots(figsize=(9.0, 3.2))
|
||||
for auftrag, maschine, beginn, dauer in plan:
|
||||
achse.barh(y=maschine, width=dauer, left=beginn, height=0.55,
|
||||
color=FARBEN[auftrag % len(FARBEN)],
|
||||
edgecolor="white", linewidth=1.2)
|
||||
achse.text(beginn + dauer / 2, maschine, f"A{auftrag}",
|
||||
ha="center", va="center", color="white",
|
||||
fontsize=10, fontweight="bold")
|
||||
|
||||
achse.set_yticks(range(len(MASCHINENNAMEN)))
|
||||
achse.set_yticklabels(MASCHINENNAMEN)
|
||||
achse.invert_yaxis()
|
||||
achse.set_xlabel("Zeiteinheiten")
|
||||
achse.set_xlim(0, makespan)
|
||||
achse.set_xticks(range(makespan + 1))
|
||||
achse.set_title(f"Job-Shop-Plan, Makespan = {makespan}", fontsize=11)
|
||||
achse.grid(axis="x", linestyle=":", alpha=0.5)
|
||||
for rand in ("top", "right", "left"):
|
||||
achse.spines[rand].set_visible(False)
|
||||
figur.tight_layout()
|
||||
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
pfad = os.path.join(BILDER, f"kap06_jobshop_gantt.{endung}")
|
||||
figur.savefig(pfad, format=endung, dpi=160)
|
||||
print(f"geschrieben: {pfad}")
|
||||
plt.close(figur)
|
||||
|
||||
|
||||
def zeichne_interaktiv(plan, makespan: int) -> None:
|
||||
"""Plotly-Fassung fuer die Website.
|
||||
|
||||
include_plotlyjs=False: Die Bibliothek wird vom Build einmal je Seite
|
||||
eingebunden (assets/plotly.min.js), nicht in jede Figur kopiert.
|
||||
"""
|
||||
figur = go.Figure()
|
||||
schon_beschriftet = set()
|
||||
for auftrag, maschine, beginn, dauer in plan:
|
||||
figur.add_trace(go.Bar(
|
||||
x=[dauer], y=[MASCHINENNAMEN[maschine]], base=[beginn],
|
||||
orientation="h", name=f"Auftrag {auftrag}",
|
||||
legendgroup=f"A{auftrag}",
|
||||
showlegend=auftrag not in schon_beschriftet,
|
||||
marker_color=FARBEN[auftrag % len(FARBEN)],
|
||||
marker_line=dict(color="white", width=1.5),
|
||||
hovertemplate=(f"<b>Auftrag {auftrag}</b><br>"
|
||||
f"{MASCHINENNAMEN[maschine]}<br>"
|
||||
f"von {beginn} bis {beginn + dauer} "
|
||||
f"({dauer} Zeiteinheiten)<extra></extra>"),
|
||||
))
|
||||
schon_beschriftet.add(auftrag)
|
||||
|
||||
figur.update_layout(
|
||||
barmode="overlay",
|
||||
title=f"Job-Shop-Plan, Makespan = {makespan}",
|
||||
xaxis=dict(title="Zeiteinheiten", dtick=1, range=[0, makespan]),
|
||||
yaxis=dict(autorange="reversed"),
|
||||
height=320,
|
||||
margin=dict(l=90, r=20, t=50, b=45),
|
||||
# Transparenter Hintergrund, damit die Figur sich in helles wie
|
||||
# dunkles Seitendesign einfuegt.
|
||||
paper_bgcolor="rgba(0,0,0,0)",
|
||||
plot_bgcolor="rgba(0,0,0,0)",
|
||||
legend=dict(orientation="h", y=-0.25),
|
||||
)
|
||||
|
||||
os.makedirs(PLOTLY_VERZ, exist_ok=True)
|
||||
pfad = os.path.join(PLOTLY_VERZ, "kap06_jobshop_gantt.html")
|
||||
with open(pfad, "w", encoding="utf-8") as datei:
|
||||
datei.write(figur.to_html(full_html=False, include_plotlyjs=False,
|
||||
div_id="kap06-jobshop-gantt"))
|
||||
print(f"geschrieben: {pfad}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
plan, makespan = loese()
|
||||
print(f"Makespan: {makespan}")
|
||||
zeichne_statisch(plan, makespan)
|
||||
zeichne_interaktiv(plan, makespan)
|
||||
224
bilder_04/erzeuge_kkt.py
Normal file
224
bilder_04/erzeuge_kkt.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
#!/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)
|
||||
204
bilder_04/erzeuge_kondition.py
Normal file
204
bilder_04/erzeuge_kondition.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_kondition.py
|
||||
"""
|
||||
Erzeugt das Konditionszahl-Diagramm zum Kapitel Fundament:
|
||||
|
||||
bilder_04/kap_fundament_kondition.svg statisch, fuer das PDF
|
||||
bilder_04/kap_fundament_kondition.png Rasterfassung
|
||||
|
||||
Zwei Aussagen, beide gerechnet und nicht behauptet:
|
||||
|
||||
Links - kappa(A) ist keine grobe Faustregel, sondern eine SCHARFE obere
|
||||
Schranke. Die gemessene groesste Fehlerverstaerkung liegt ueber
|
||||
zwoelf Groessenordnungen hinweg praktisch auf der Geraden y = kappa;
|
||||
der Median darunter. Wer also mit kappa = 1e8 rechnet, verliert im
|
||||
schlimmsten Fall genau acht Stellen - nicht ungefaehr, sondern
|
||||
nachweisbar.
|
||||
Rechts - Was die Ruiz-Equilibrierung an einem Modell ausrichtet, in dem
|
||||
Euro-Betraege und Tonnen-Angaben in derselben Matrix stehen.
|
||||
|
||||
Verfahren und Instanz sind wortgleich zu Skalierung_Kondition.py, und
|
||||
gerechnet wird hier erneut - so koennen Diagramm und Buchtext nicht
|
||||
auseinanderlaufen.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_kondition.py
|
||||
|
||||
Benoetigt: numpy, matplotlib
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
# 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 = "#4338ca", "#0891b2", "#b45309", "#059669"
|
||||
|
||||
STOERUNG = 1e-10 # relative Stoerung der rechten Seite
|
||||
|
||||
GUT = np.array([[2.0, 1.0], [1.0, 3.0]])
|
||||
SCHLECHT = np.array([[1.0, 1.0], [1.0, 1.0 + 1e-8]])
|
||||
|
||||
|
||||
# --- Messung: wortgleich zu Skalierung_Kondition.py -------------------------
|
||||
|
||||
def fehlerverstaerkung(A: np.ndarray, rng: np.random.Generator,
|
||||
versuche: int = 200) -> tuple[float, float]:
|
||||
"""Stoert b relativ um STOERUNG und misst den Faktor, um den der
|
||||
Loesungsfehler groesser ausfaellt. Liefert (Median, Maximum)."""
|
||||
x_wahr = np.ones(A.shape[1])
|
||||
b = A @ x_wahr
|
||||
faktoren = []
|
||||
for _ in range(versuche):
|
||||
richtung = rng.normal(size=b.size)
|
||||
richtung /= np.linalg.norm(richtung)
|
||||
b_gestoert = b + STOERUNG * np.linalg.norm(b) * richtung
|
||||
x_gestoert = np.linalg.solve(A, b_gestoert)
|
||||
rel_x = np.linalg.norm(x_gestoert - x_wahr) / np.linalg.norm(x_wahr)
|
||||
faktoren.append(rel_x / STOERUNG)
|
||||
return float(np.median(faktoren)), float(np.max(faktoren))
|
||||
|
||||
|
||||
def ruiz_equilibrierung(A: np.ndarray, durchlaeufe: int = 20) -> np.ndarray:
|
||||
"""Zeilen- und Spaltenskalierung, bis alle Maxima nahe 1 liegen."""
|
||||
A_s = A.astype(float).copy()
|
||||
for _ in range(durchlaeufe):
|
||||
zeilen_max = np.abs(A_s).max(axis=1)
|
||||
zeilen_max[zeilen_max == 0] = 1.0
|
||||
A_s = (1.0 / np.sqrt(zeilen_max))[:, None] * A_s
|
||||
spalten_max = np.abs(A_s).max(axis=0)
|
||||
spalten_max[spalten_max == 0] = 1.0
|
||||
A_s = A_s * (1.0 / np.sqrt(spalten_max))[None, :]
|
||||
return A_s
|
||||
|
||||
|
||||
def mischmodell() -> np.ndarray:
|
||||
"""Das Modell mit den vier unvertraeglichen Einheiten.
|
||||
|
||||
Skalierung_Kondition.py zieht diese Matrix aus DEMSELBEN Generator, aus dem
|
||||
zuvor schon die Stoerungen von Experiment 1 kamen. Damit hier bitgleich
|
||||
dieselbe Matrix entsteht - und das Diagramm dieselbe Konditionszahl zeigt
|
||||
wie der abgedruckte Programmlauf -, wird die Ziehungsreihenfolge exakt
|
||||
nachgespielt: erst 2 x 200 Stoerungen, dann die Grundmatrix.
|
||||
"""
|
||||
rng = np.random.default_rng(42)
|
||||
for A in (GUT, SCHLECHT):
|
||||
fehlerverstaerkung(A, rng)
|
||||
grundmatrix = rng.uniform(0.5, 2.0, size=(4, 4))
|
||||
einheiten = np.array([1e7, 1e-3, 1e5, 1e-2])
|
||||
return grundmatrix * einheiten[:, None]
|
||||
|
||||
|
||||
def messreihe() -> tuple[list[float], list[float], list[float]]:
|
||||
"""Eine Familie fast paralleler Zeilen: A = [[1, 1], [1, 1 + eps]].
|
||||
|
||||
Mit fallendem eps waechst kappa(A) um zwoelf Groessenordnungen, ohne dass
|
||||
sich sonst irgendetwas am Modell aendert. Genau das macht die Kurve
|
||||
aussagekraeftig: Der einzige Unterschied ist die Kondition.
|
||||
"""
|
||||
# Eigener Generator, damit die Messreihe den nachgespielten Strom in
|
||||
# mischmodell() nicht stoert.
|
||||
rng = np.random.default_rng(7)
|
||||
kappas, mediane, maxima = [], [], []
|
||||
for exponent in np.arange(-1.0, -12.5, -0.5):
|
||||
eps = 10.0 ** exponent
|
||||
A = np.array([[1.0, 1.0], [1.0, 1.0 + eps]])
|
||||
median, maximum = fehlerverstaerkung(A, rng)
|
||||
kappas.append(float(np.linalg.cond(A)))
|
||||
mediane.append(median)
|
||||
maxima.append(maximum)
|
||||
return kappas, mediane, maxima
|
||||
|
||||
|
||||
# --- Zeichnen ---------------------------------------------------------------
|
||||
|
||||
def zeichne(kappas, mediane, maxima, kappa_vorher, kappa_nachher) -> None:
|
||||
figur, (links, rechts) = plt.subplots(
|
||||
1, 2, figsize=(10.4, 4.3), gridspec_kw={"width_ratios": [1.75, 1]})
|
||||
|
||||
# --- links: Verstaerkung gegen kappa ---
|
||||
# Die Gerade reicht bewusst ueber die Messpunkte hinaus: Innerhalb des
|
||||
# Messbereichs liegt sie exakt unter der Messkurve - und genau das ist die
|
||||
# Aussage. Sichtbar bleibt sie dadurch an den Enden.
|
||||
grenze = np.array([min(kappas) / 30, max(kappas) * 30])
|
||||
links.plot(grenze, grenze, color="#94a3b8", linewidth=3.0, linestyle="-",
|
||||
alpha=0.55, zorder=1,
|
||||
label="theoretische Schranke $y=\\kappa(A)$")
|
||||
links.plot(kappas, maxima, "o-", color=INDIGO, markersize=4.5, linewidth=1.4,
|
||||
zorder=3, label="gemessen: größte Verstärkung")
|
||||
links.plot(kappas, mediane, "s-", color=CYAN, markersize=4.0, linewidth=1.4,
|
||||
zorder=2, label="gemessen: Median")
|
||||
|
||||
marken_rng = np.random.default_rng(7)
|
||||
for A, name, versatz in ((GUT, "gut konditioniert", (14, -4)),
|
||||
(SCHLECHT, "fast parallele Zeilen", (-12, 14))):
|
||||
_, maximum = fehlerverstaerkung(A, marken_rng)
|
||||
k = float(np.linalg.cond(A))
|
||||
links.scatter([k], [maximum], s=95, facecolors="none", edgecolors=AMBER,
|
||||
linewidths=2.0, zorder=4)
|
||||
links.annotate(name, (k, maximum), textcoords="offset points",
|
||||
xytext=versatz, fontsize=8, color=AMBER,
|
||||
ha="right" if versatz[0] < 0 else "left")
|
||||
|
||||
links.set_xscale("log")
|
||||
links.set_yscale("log")
|
||||
links.set_xlabel("Konditionszahl $\\kappa(A)$")
|
||||
links.set_ylabel("Fehlerverstärkung (Faktor)")
|
||||
links.set_title("$\\kappa(A)$ ist eine scharfe Schranke, keine Faustregel",
|
||||
fontsize=10.5)
|
||||
links.grid(which="both", linestyle=":", alpha=0.45)
|
||||
links.legend(fontsize=8, loc="upper left", frameon=False)
|
||||
links.annotate("Die Messung liegt auf der Schranke:\n"
|
||||
"$\\kappa$ wird im schlimmsten Fall\nvollständig ausgeschöpft.",
|
||||
xy=(0.97, 0.06), xycoords="axes fraction", fontsize=8,
|
||||
ha="right", va="bottom", color="#475569")
|
||||
for rand in ("top", "right"):
|
||||
links.spines[rand].set_visible(False)
|
||||
|
||||
# --- rechts: was Ruiz ausrichtet ---
|
||||
balken = rechts.bar(["vorher", "nach Ruiz"], [kappa_vorher, kappa_nachher],
|
||||
color=[AMBER, GRUEN], width=0.55)
|
||||
rechts.set_yscale("log")
|
||||
rechts.set_ylim(1, kappa_vorher * 40)
|
||||
for stab, wert in zip(balken, (kappa_vorher, kappa_nachher)):
|
||||
rechts.annotate(f"{wert:.2e}", (stab.get_x() + stab.get_width() / 2, wert),
|
||||
textcoords="offset points", xytext=(0, 6),
|
||||
ha="center", fontsize=9, fontweight="bold")
|
||||
stellen = int(round(np.log10(kappa_vorher / kappa_nachher)))
|
||||
rechts.set_title(f"Ruiz-Equilibrierung:\n{stellen} Größenordnungen besser",
|
||||
fontsize=10.5)
|
||||
rechts.set_ylabel("$\\kappa(A)$")
|
||||
rechts.grid(axis="y", which="both", linestyle=":", alpha=0.45)
|
||||
for rand in ("top", "right"):
|
||||
rechts.spines[rand].set_visible(False)
|
||||
|
||||
figur.tight_layout()
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
pfad = os.path.join(BILDER, f"kap_fundament_kondition.{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__":
|
||||
kappas, mediane, maxima = messreihe()
|
||||
modell = mischmodell()
|
||||
kappa_vorher = float(np.linalg.cond(modell))
|
||||
kappa_nachher = float(np.linalg.cond(ruiz_equilibrierung(modell)))
|
||||
print(f"Messreihe: {len(kappas)} Matrizen, kappa von {min(kappas):.2e} "
|
||||
f"bis {max(kappas):.2e}")
|
||||
print(f"Ruiz: kappa {kappa_vorher:.2e} -> {kappa_nachher:.2e}")
|
||||
zeichne(kappas, mediane, maxima, kappa_vorher, kappa_nachher)
|
||||
299
bilder_04/erzeuge_metaheuristik_landschaft.py
Normal file
299
bilder_04/erzeuge_metaheuristik_landschaft.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_metaheuristik_landschaft.py
|
||||
"""
|
||||
Erzeugt das Suchverlauf-Diagramm zum Kapitel Metaheuristiken:
|
||||
|
||||
bilder_04/kap_metaheuristiken_landschaft.svg statisch, fuer das PDF
|
||||
bilder_04/kap_metaheuristiken_landschaft.png Rasterfassung
|
||||
bilder_04/plotly/kap_metaheuristiken_verlauf.html interaktiv, fuer die Website
|
||||
|
||||
Eine Suchlandschaft laesst sich bei 200 Auftraegen nicht zeichnen - der Raum
|
||||
hat 200! Punkte. Zeichnen laesst sich aber, wie die Suche sie DURCHLAEUFT, und
|
||||
das zeigt dasselbe:
|
||||
|
||||
Links - der Zielwert waehrend der Suche. Bergsteigen faellt monoton und
|
||||
bleibt liegen. Annealing bei T=1 faellt fast genauso, mit winzigen
|
||||
Ausschlaegen nach oben. Bei T=8 irrt die Suche weit ueber den
|
||||
Startwert hinaus und findet nicht zurueck - die Temperatur ist kein
|
||||
Beiwerk.
|
||||
Rechts - das Ergebnis ueber die Starttemperatur. Es gibt ein Optimum, es ist
|
||||
schmal, und jenseits davon wird die Metaheuristik schlechter als die
|
||||
Faustregel, mit der sie gestartet ist.
|
||||
|
||||
Instanz, Zug und Abkuehlplan sind wortgleich zu Simulated_Annealing.py, und
|
||||
gerechnet wird hier erneut - so koennen Diagramm und Buchtext nicht
|
||||
auseinanderlaufen. Der einzige Unterschied: Die Suche zeichnet ihren Verlauf
|
||||
mit, was das Buchprogramm nicht braucht.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis, dauert rund eine Minute):
|
||||
python3 bilder_04/erzeuge_metaheuristik_landschaft.py
|
||||
|
||||
Die interaktive Fassung traegt im Hover die Groesse, die das statische Bild
|
||||
nicht zeigen kann: die Temperatur an dieser Stelle des Abkuehlplans - und was
|
||||
sie konkret bedeutet, naemlich mit welcher Wahrscheinlichkeit dort ein Zug
|
||||
typischer Groesse (Median 70 Minuten, im Kapitel gemessen) angenommen wuerde.
|
||||
Damit wird an der Kurve ablesbar, warum T0 = 8 so lange braucht, um
|
||||
zurueckzufinden.
|
||||
|
||||
Benoetigt: numpy, matplotlib, plotly
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import plotly.graph_objects as go
|
||||
|
||||
# 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")
|
||||
PLOTLY_VERZ = os.path.join(BILDER, "plotly")
|
||||
|
||||
INDIGO, CYAN, AMBER, GRAU = "#4338ca", "#0891b2", "#b45309", "#94a3b8"
|
||||
|
||||
N_AUFTRAEGE = 200
|
||||
ZUEGE = 400_000
|
||||
SAAT = 11
|
||||
STUETZSTELLEN = 800 # so viele Punkte je Verlaufskurve
|
||||
MEDIAN_ZUG = 70 # typische Zuggroesse in Minuten (im Kapitel gemessen)
|
||||
|
||||
|
||||
# --- Instanz und Zug: wortgleich zu Simulated_Annealing.py ------------------
|
||||
|
||||
def erzeuge_ruestmatrix(n: int, saat: int = SAAT) -> np.ndarray:
|
||||
rng = np.random.default_rng(saat)
|
||||
familien = max(6, n // 10)
|
||||
zwischen = rng.integers(8, 60, (familien, familien))
|
||||
np.fill_diagonal(zwischen, 2)
|
||||
familie = rng.integers(0, familien, n)
|
||||
farbe = rng.integers(0, 10, n)
|
||||
matrix = np.zeros((n, n), dtype=np.int64)
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if i != j:
|
||||
matrix[i, j] = (zwischen[familie[i], familie[j]]
|
||||
+ max(0, farbe[i] - farbe[j]) * 3)
|
||||
return matrix
|
||||
|
||||
|
||||
def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
|
||||
return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
|
||||
|
||||
|
||||
def faustregel(matrix: np.ndarray) -> list[int]:
|
||||
n = len(matrix)
|
||||
offen = set(range(1, n))
|
||||
reihe = [0]
|
||||
while offen:
|
||||
naechster = min(offen, key=lambda j: matrix[reihe[-1], j])
|
||||
reihe.append(naechster)
|
||||
offen.discard(naechster)
|
||||
return reihe
|
||||
|
||||
|
||||
def delta_verschieben(reihe: list[int], matrix: np.ndarray, von: int, nach: int):
|
||||
n = len(reihe)
|
||||
if von == nach or nach == von + 1:
|
||||
return None
|
||||
heraus = 0
|
||||
if von > 0:
|
||||
heraus += matrix[reihe[von - 1], reihe[von]]
|
||||
if von < n - 1:
|
||||
heraus += matrix[reihe[von], reihe[von + 1]]
|
||||
if 0 < von < n - 1:
|
||||
heraus -= matrix[reihe[von - 1], reihe[von + 1]]
|
||||
rest = reihe[:von] + reihe[von + 1:]
|
||||
stelle = nach if nach < von else nach - 1
|
||||
hinein = 0
|
||||
if stelle > 0:
|
||||
hinein += matrix[rest[stelle - 1], reihe[von]]
|
||||
if stelle < len(rest):
|
||||
hinein += matrix[reihe[von], rest[stelle]]
|
||||
if 0 < stelle < len(rest):
|
||||
hinein -= matrix[rest[stelle - 1], rest[stelle]]
|
||||
return int(hinein - heraus), rest, stelle
|
||||
|
||||
|
||||
# --- Die Suche, um die Verlaufsaufzeichnung ergaenzt ------------------------
|
||||
|
||||
def suche_mit_verlauf(matrix: np.ndarray, start_temperatur: float,
|
||||
saat: int = 1, bergsteigen: bool = False,
|
||||
end_temperatur: float = 0.05):
|
||||
"""Wie suche() im Buchprogramm, protokolliert zusaetzlich den Zielwert.
|
||||
|
||||
Aufgezeichnet wird der AKTUELLE Zustand, nicht der beste. Genau darin liegt
|
||||
die Aussage: Der beste Wert faellt bei jedem Verfahren monoton - was die
|
||||
Verfahren unterscheidet, ist, wie weit sich die Suche unterwegs vom Guten
|
||||
entfernen darf.
|
||||
"""
|
||||
rng = np.random.default_rng(saat)
|
||||
n = len(matrix)
|
||||
reihe = faustregel(matrix)
|
||||
kosten = gesamtruestzeit(reihe, matrix)
|
||||
beste_kosten = kosten
|
||||
takt = max(1, ZUEGE // STUETZSTELLEN)
|
||||
x, y, temperaturen = [0], [kosten], [start_temperatur]
|
||||
|
||||
for zug in range(ZUEGE):
|
||||
temperatur = start_temperatur * (end_temperatur / start_temperatur) \
|
||||
** (zug / ZUEGE)
|
||||
von = int(rng.integers(0, n))
|
||||
nach = int(rng.integers(0, n + 1))
|
||||
ergebnis = delta_verschieben(reihe, matrix, von, nach)
|
||||
if ergebnis is not None:
|
||||
aenderung, rest, stelle = ergebnis
|
||||
if aenderung <= 0:
|
||||
annehmen = True
|
||||
else:
|
||||
annehmen = (not bergsteigen
|
||||
and rng.random() < math.exp(-aenderung / temperatur))
|
||||
if annehmen:
|
||||
reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
|
||||
kosten += aenderung
|
||||
beste_kosten = min(beste_kosten, kosten)
|
||||
if (zug + 1) % takt == 0:
|
||||
x.append(zug + 1)
|
||||
y.append(kosten)
|
||||
temperaturen.append(temperatur)
|
||||
return x, y, beste_kosten, temperaturen
|
||||
|
||||
|
||||
# --- Zeichnen ---------------------------------------------------------------
|
||||
|
||||
def zeichne(verlaeufe, start, temperaturreihe) -> None:
|
||||
figur, (links, rechts) = plt.subplots(
|
||||
1, 2, figsize=(10.6, 4.4), gridspec_kw={"width_ratios": [1.7, 1]})
|
||||
|
||||
links.axhline(start, color=GRAU, linewidth=1.2, linestyle="--", zorder=1)
|
||||
links.annotate(f"Faustregel: {start:,}".replace(",", "."),
|
||||
(ZUEGE, start), textcoords="offset points", xytext=(-4, 6),
|
||||
ha="right", fontsize=8, color="#475569")
|
||||
|
||||
for (name, farbe, breite), (x, y, bestes, _) in verlaeufe:
|
||||
links.plot(x, y, color=farbe, linewidth=breite, alpha=0.9,
|
||||
label=f"{name} → {bestes:,}".replace(",", "."))
|
||||
|
||||
links.set_xlabel("geprüfte Züge")
|
||||
links.set_ylabel("Rüstzeit des aktuellen Plans (Minuten)")
|
||||
links.set_title("Wie die Suche die Landschaft durchläuft", fontsize=10.5)
|
||||
links.grid(linestyle=":", alpha=0.45)
|
||||
links.legend(fontsize=8, loc="upper right", frameon=False)
|
||||
links.set_xlim(0, ZUEGE)
|
||||
for rand in ("top", "right"):
|
||||
links.spines[rand].set_visible(False)
|
||||
|
||||
temperaturen = [t for t, _ in temperaturreihe]
|
||||
ergebnisse = [e for _, e in temperaturreihe]
|
||||
rechts.axhline(start, color=GRAU, linewidth=1.2, linestyle="--", zorder=1)
|
||||
rechts.plot(temperaturen, ergebnisse, "o-", color=INDIGO, markersize=5,
|
||||
linewidth=1.5, zorder=3)
|
||||
bester = int(np.argmin(ergebnisse))
|
||||
rechts.scatter([temperaturen[bester]], [ergebnisse[bester]], s=140,
|
||||
facecolors="none", edgecolors=AMBER, linewidths=2.2, zorder=4)
|
||||
rechts.annotate(f"bestes T = {temperaturen[bester]:g}",
|
||||
(temperaturen[bester], ergebnisse[bester]),
|
||||
textcoords="offset points", xytext=(10, -2),
|
||||
fontsize=8, color=AMBER)
|
||||
rechts.annotate(f"Faustregel: {start:,}".replace(",", "."),
|
||||
(temperaturen[0], start), textcoords="offset points",
|
||||
xytext=(2, 4), fontsize=8, color="#475569")
|
||||
rechts.annotate("hier ist der ganze\nVorsprung aufgezehrt",
|
||||
(temperaturen[-1], ergebnisse[-1]),
|
||||
textcoords="offset points", xytext=(-8, -4),
|
||||
ha="right", va="top", fontsize=8, color="#475569")
|
||||
rechts.set_ylim(min(ergebnisse) - 8, start + 14)
|
||||
|
||||
rechts.set_xscale("log")
|
||||
rechts.set_xticks(temperaturen)
|
||||
rechts.set_xticklabels([f"{t:g}" for t in temperaturen])
|
||||
rechts.set_xlabel("Starttemperatur $T_0$")
|
||||
rechts.set_ylabel("erreichte Rüstzeit (Minuten)")
|
||||
rechts.set_title("Das Optimum ist schmal", fontsize=10.5)
|
||||
rechts.grid(linestyle=":", alpha=0.45)
|
||||
for rand in ("top", "right"):
|
||||
rechts.spines[rand].set_visible(False)
|
||||
|
||||
figur.tight_layout()
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
pfad = os.path.join(BILDER, f"kap_metaheuristiken_landschaft.{endung}")
|
||||
figur.savefig(pfad, format=endung, dpi=160,
|
||||
metadata={"Date": None} if endung == "svg" else None)
|
||||
print(f"geschrieben: {pfad}")
|
||||
plt.close(figur)
|
||||
|
||||
|
||||
def zeichne_interaktiv(verlaeufe, start) -> None:
|
||||
"""Dieselben Verlaeufe, aber mit der Temperatur im Hover.
|
||||
|
||||
Das ist der Grund fuer die interaktive Fassung: Im statischen Bild sieht
|
||||
man, DASS die T0=8-Kurve lange oben bleibt. Hier laesst sich an jeder
|
||||
Stelle ablesen, WARUM - naemlich wie wahrscheinlich dort ein Zug typischer
|
||||
Groesse noch angenommen wird.
|
||||
"""
|
||||
figur = go.Figure()
|
||||
figur.add_hline(y=start, line=dict(color=GRAU, width=1.2, dash="dash"),
|
||||
annotation_text=f"Faustregel: {start}",
|
||||
annotation_position="bottom right")
|
||||
|
||||
for (name, farbe, breite), (x, y, bestes, temps) in verlaeufe:
|
||||
beschriftung = []
|
||||
for zug, wert, temperatur in zip(x, y, temps):
|
||||
quote = math.exp(-MEDIAN_ZUG / temperatur) if temperatur > 0 else 0.0
|
||||
beschriftung.append(
|
||||
f"<b>{wert} Minuten</b><br>nach {zug:,} Zügen<br>"
|
||||
f"Temperatur {temperatur:.3f}<br>"
|
||||
f"typischer Verschlechterungszug ({MEDIAN_ZUG} min) würde<br>"
|
||||
f"mit {quote:.1e} angenommen<extra></extra>".replace(",", "."))
|
||||
figur.add_trace(go.Scatter(
|
||||
x=x, y=y, mode="lines", name=f"{name} → {bestes}",
|
||||
line=dict(color=farbe, width=breite + 0.4),
|
||||
hovertemplate=beschriftung))
|
||||
|
||||
figur.update_layout(
|
||||
title="Der Suchverlauf — mit der Temperatur im Hover",
|
||||
xaxis=dict(title="geprüfte Züge"),
|
||||
yaxis=dict(title="Rüstzeit des aktuellen Plans (Minuten)"),
|
||||
height=430, margin=dict(l=70, r=20, t=55, b=50),
|
||||
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
||||
legend=dict(orientation="h", y=-0.2),
|
||||
hovermode="closest")
|
||||
|
||||
os.makedirs(PLOTLY_VERZ, exist_ok=True)
|
||||
pfad = os.path.join(PLOTLY_VERZ, "kap_metaheuristiken_verlauf.html")
|
||||
with open(pfad, "w", encoding="utf-8") as datei:
|
||||
datei.write(figur.to_html(full_html=False, include_plotlyjs=False,
|
||||
div_id="kap-metaheuristiken-verlauf"))
|
||||
print(f"geschrieben: {pfad}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
matrix = erzeuge_ruestmatrix(N_AUFTRAEGE)
|
||||
start = gesamtruestzeit(faustregel(matrix), matrix)
|
||||
print(f"Instanz: {N_AUFTRAEGE} Auftraege, Faustregel {start} Minuten")
|
||||
|
||||
verlaeufe = []
|
||||
for beschriftung, kalt, temperatur in (
|
||||
(("Bergsteigen", CYAN, 1.3), True, 1.0),
|
||||
(("Annealing $T_0=1$", INDIGO, 1.3), False, 1.0),
|
||||
(("Annealing $T_0=8$", AMBER, 1.0), False, 8.0)):
|
||||
x, y, bestes, temps = suche_mit_verlauf(matrix, temperatur, bergsteigen=kalt)
|
||||
print(f" {beschriftung[0]:<22} bestes {bestes}")
|
||||
verlaeufe.append((beschriftung, (x, y, bestes, temps)))
|
||||
|
||||
temperaturreihe = []
|
||||
for temperatur in (0.5, 1.0, 2.0, 4.0, 8.0):
|
||||
_, _, bestes, _ = suche_mit_verlauf(matrix, temperatur)
|
||||
temperaturreihe.append((temperatur, bestes))
|
||||
print(f" T0={temperatur:<5} -> {bestes}")
|
||||
|
||||
zeichne(verlaeufe, start, temperaturreihe)
|
||||
zeichne_interaktiv(verlaeufe, start)
|
||||
148
bilder_04/erzeuge_min_cost_flow.py
Normal file
148
bilder_04/erzeuge_min_cost_flow.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_min_cost_flow.py
|
||||
"""
|
||||
Erzeugt das Flussdiagramm zum Graphen-Kapitel:
|
||||
|
||||
bilder_04/kap_graphen_min_cost_flow.svg / .png
|
||||
|
||||
Das Vorgaengerbild zeigte die Instanz von Min_Cost_Flow.py korrekt - aber nur
|
||||
die Instanz. Kosten und Kapazitaeten standen an den Kanten, der FLUSS nicht.
|
||||
Damit fehlte genau das, was das Kapitel erklaert.
|
||||
|
||||
Hier wird das Netzwerk geloest und das Ergebnis eingezeichnet:
|
||||
|
||||
* Kantenbeschriftung "Fluss / Kapazitaet, Kosten je Einheit"
|
||||
* Strichstaerke waechst mit dem Fluss
|
||||
* ausgelastete Kanten hervorgehoben, ungenutzte gestrichelt und blass
|
||||
* Knotenpreise (die Dualwerte der Flusserhaltung) an den Knoten
|
||||
|
||||
Zwei Dinge werden dadurch sichtbar, die im alten Bild fehlten: Die billige
|
||||
Route ueber den Umschlag laeuft voll, und trotzdem muss die teure Direktkante
|
||||
Werk_B -> Kunde_2 bedient werden - weil der Umschlagweg dorthin schon
|
||||
ausgelastet ist. Genau das ist der Punkt des Abschnitts.
|
||||
|
||||
Modell und Loesungsweg sind wortgleich zu Min_Cost_Flow.py, und gerechnet wird
|
||||
hier erneut. Das Skript prueft, dass Zielwert und Fluesse zum Programmlauf
|
||||
passen - stimmt etwas nicht, bricht es ab.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_min_cost_flow.py
|
||||
|
||||
Benoetigt: numpy, scipy, graphviz (Python-Paket) und das Werkzeug 'dot'
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import graphviz
|
||||
import numpy as np
|
||||
from scipy.optimize import linprog
|
||||
|
||||
BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
BILDER = os.path.join(BASIS, "bilder_04")
|
||||
|
||||
INDIGO, CYAN, VIOLETT, GRUEN, AMBER = (
|
||||
"#4338ca", "#0891b2", "#7c3aed", "#059669", "#b45309")
|
||||
TINT = {INDIGO: "#eef2ff", CYAN: "#ecfeff", VIOLETT: "#f5f3ff",
|
||||
GRUEN: "#ecfdf5", AMBER: "#fffbeb"}
|
||||
BLASS = "#cbd5e1"
|
||||
|
||||
# --- Netzwerk: wortgleich zu Min_Cost_Flow.py -------------------------------
|
||||
KNOTEN = ["Werk_A", "Werk_B", "Umschlag", "Kunde_1", "Kunde_2"]
|
||||
KANTEN = [
|
||||
("Werk_A", "Umschlag", 2.0, 15),
|
||||
("Werk_A", "Kunde_1", 5.0, 10),
|
||||
("Werk_B", "Umschlag", 4.0, 10),
|
||||
("Werk_B", "Kunde_2", 6.0, 10),
|
||||
("Umschlag", "Kunde_1", 1.0, 20),
|
||||
("Umschlag", "Kunde_2", 3.0, 10),
|
||||
]
|
||||
SALDO = {"Werk_A": 20, "Werk_B": 10, "Umschlag": 0, "Kunde_1": -15, "Kunde_2": -15}
|
||||
|
||||
ERWARTETE_KOSTEN = 140.0 # aus dem abgedruckten Programmlauf
|
||||
|
||||
|
||||
def loese():
|
||||
"""Flussproblem als LP - derselbe Aufbau wie im Buchprogramm."""
|
||||
knoten_index = {k: i for i, k in enumerate(KNOTEN)}
|
||||
kosten = np.array([k[2] for k in KANTEN])
|
||||
A_eq = np.zeros((len(KNOTEN), len(KANTEN)))
|
||||
for e, (von, nach, _, _) in enumerate(KANTEN):
|
||||
A_eq[knoten_index[von], e] = +1.0
|
||||
A_eq[knoten_index[nach], e] = -1.0
|
||||
b_eq = np.array([SALDO[k] for k in KNOTEN], dtype=float)
|
||||
schranken = [(0, k[3]) for k in KANTEN]
|
||||
ergebnis = linprog(c=kosten, A_eq=A_eq, b_eq=b_eq, bounds=schranken,
|
||||
method="highs")
|
||||
if not ergebnis.success:
|
||||
raise SystemExit(f"Nicht loesbar: {ergebnis.message}")
|
||||
return float(ergebnis.fun), np.asarray(ergebnis.x), np.asarray(ergebnis.eqlin.marginals)
|
||||
|
||||
|
||||
def zahl(wert: float) -> str:
|
||||
text = f"{wert:.2f}".rstrip("0").rstrip(".")
|
||||
if text in ("-0", "-"):
|
||||
text = "0"
|
||||
return text.replace(".", ",")
|
||||
|
||||
|
||||
def zeichne(gesamtkosten: float, fluss: np.ndarray, preise: np.ndarray) -> None:
|
||||
punkt = graphviz.Digraph(format="svg")
|
||||
punkt.attr(rankdir="LR", splines="spline", nodesep="0.5", ranksep="1.1",
|
||||
fontname="DejaVu Sans", bgcolor="transparent", fontsize="12",
|
||||
labelloc="t",
|
||||
label=(f"Kostenminimaler Fluss: {zahl(gesamtkosten)} € für 30 Einheiten\\n"
|
||||
"Kantenbeschriftung: Fluss / Kapazität, Kosten je Einheit"))
|
||||
punkt.attr("node", fontname="DejaVu Sans", fontsize="10")
|
||||
punkt.attr("edge", fontname="DejaVu Sans", fontsize="9")
|
||||
|
||||
preis_von = dict(zip(KNOTEN, preise))
|
||||
for name in KNOTEN:
|
||||
saldo = SALDO[name]
|
||||
if saldo > 0:
|
||||
farbe, rolle = GRUEN, f"Angebot +{saldo}"
|
||||
elif saldo < 0:
|
||||
farbe, rolle = INDIGO, f"Bedarf {saldo}"
|
||||
else:
|
||||
farbe, rolle = VIOLETT, "Durchleitung"
|
||||
punkt.node(name, f"{name.replace('_', ' ')}\\n{rolle}\\n"
|
||||
f"Knotenpreis {zahl(preis_von[name])} €",
|
||||
shape="box", style="filled,rounded", fillcolor=TINT[farbe],
|
||||
color=farbe, penwidth="2.0", margin="0.16,0.09")
|
||||
|
||||
for (von, nach, kosten, kapazitaet), menge in zip(KANTEN, fluss):
|
||||
voll = menge > kapazitaet - 1e-7
|
||||
leer = menge < 1e-7
|
||||
beschriftung = (f"{zahl(menge)} / {kapazitaet}, {zahl(kosten)} €"
|
||||
+ (" ⟵ voll" if voll else ""))
|
||||
if leer:
|
||||
punkt.edge(von, nach, label=f"{beschriftung} (ungenutzt)",
|
||||
color=BLASS, fontcolor="#94a3b8", style="dashed",
|
||||
penwidth="1.0")
|
||||
else:
|
||||
punkt.edge(von, nach, label=beschriftung,
|
||||
color=AMBER if voll else CYAN,
|
||||
fontcolor=AMBER if voll else "#334155",
|
||||
penwidth=f"{1.0 + 3.0 * menge / kapazitaet:.1f}")
|
||||
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
punkt.format = endung
|
||||
pfad = punkt.render(os.path.join(BILDER, "kap_graphen_min_cost_flow"),
|
||||
cleanup=True)
|
||||
print(f"geschrieben: {pfad}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gesamtkosten, fluss, preise = loese()
|
||||
print(f"Minimale Transportkosten: {gesamtkosten:.2f} EUR")
|
||||
for (von, nach, kosten, kapazitaet), menge in zip(KANTEN, fluss):
|
||||
marke = " (VOLL)" if menge > kapazitaet - 1e-7 else ""
|
||||
print(f" {von:9} -> {nach:9} {menge:6.1f} / {kapazitaet:3}{marke}")
|
||||
|
||||
if abs(gesamtkosten - ERWARTETE_KOSTEN) > 1e-6:
|
||||
raise SystemExit(f"FEHLER: Zielwert {gesamtkosten} weicht vom "
|
||||
f"abgedruckten Programmlauf ({ERWARTETE_KOSTEN}) ab.")
|
||||
zeichne(gesamtkosten, fluss, preise)
|
||||
208
bilder_04/erzeuge_pareto_front.py
Normal file
208
bilder_04/erzeuge_pareto_front.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_pareto_front.py
|
||||
"""
|
||||
Erzeugt das Pareto-Front-Diagramm zum Mehrziel-Kapitel in zwei Fassungen:
|
||||
|
||||
bilder_04/kap_mehrziel_pareto.svg statisch, fuer das PDF
|
||||
bilder_04/kap_mehrziel_pareto.png statisch, Rasterfassung
|
||||
bilder_04/plotly/kap_mehrziel_pareto.html interaktiv, fuer die Website
|
||||
|
||||
Die Instanz ist identisch mit Mehrziel_Pareto.py, und gerechnet wird hier
|
||||
erneut - so koennen Diagramm und Buchtext nicht auseinanderlaufen.
|
||||
|
||||
Das Diagramm zeigt genau die Aussage des Kapitels: Die untere konvexe Huelle
|
||||
(gestrichelt) ist alles, was eine gewichtete Summe je erreichen kann. Die
|
||||
Punkte darueber sind Pareto-optimal und trotzdem fuer kein Gewicht zu haben.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_pareto_front.py
|
||||
|
||||
Benoetigt: numpy, scipy, matplotlib, plotly
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import plotly.graph_objects as go
|
||||
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")
|
||||
PLOTLY_VERZ = os.path.join(BILDER, "plotly")
|
||||
|
||||
INDIGO, CYAN, AMBER = "#4338ca", "#0891b2", "#b45309"
|
||||
BAHN_TRASSEN = 5
|
||||
|
||||
|
||||
# --- Instanz und Modell: wortgleich zu Mehrziel_Pareto.py -------------------
|
||||
|
||||
def erzeuge_sendungen(anzahl: int = 12, saat: int = 5):
|
||||
rng = np.random.default_rng(saat)
|
||||
kosten = np.zeros((anzahl, 3))
|
||||
co2 = np.zeros((anzahl, 3))
|
||||
for i in range(anzahl):
|
||||
grund_kosten = rng.integers(600, 2400)
|
||||
grund_co2 = rng.integers(400, 1800)
|
||||
kosten[i] = [grund_kosten,
|
||||
grund_kosten * rng.uniform(0.55, 0.85),
|
||||
grund_kosten * rng.uniform(0.70, 1.00)]
|
||||
co2[i] = [grund_co2,
|
||||
grund_co2 * rng.uniform(0.15, 0.35),
|
||||
grund_co2 * rng.uniform(0.40, 0.70)]
|
||||
return np.round(kosten).astype(int), np.round(co2).astype(int)
|
||||
|
||||
|
||||
KOSTEN, CO2 = erzeuge_sendungen()
|
||||
N = len(KOSTEN)
|
||||
|
||||
|
||||
def plane(ziel, co2_grenze=None):
|
||||
gleichungen = np.zeros((N, N * 3))
|
||||
for i in range(N):
|
||||
gleichungen[i, i * 3:(i + 1) * 3] = 1.0
|
||||
ungleichungen = [[1.0 if j == 1 else 0.0 for _ in range(N) for j in range(3)]]
|
||||
grenzen = [float(BAHN_TRASSEN)]
|
||||
if co2_grenze is not None:
|
||||
ungleichungen.append(CO2.reshape(-1).astype(float))
|
||||
grenzen.append(float(co2_grenze))
|
||||
ergebnis = linprog(ziel.reshape(-1).astype(float),
|
||||
A_ub=ungleichungen, b_ub=grenzen,
|
||||
A_eq=gleichungen, b_eq=np.ones(N),
|
||||
bounds=(0, 1), integrality=1, method="highs")
|
||||
if not ergebnis.success:
|
||||
return None
|
||||
plan = np.round(ergebnis.x).astype(int)
|
||||
return int(KOSTEN.reshape(-1) @ plan), int(CO2.reshape(-1) @ plan)
|
||||
|
||||
|
||||
def pareto_front():
|
||||
front = []
|
||||
grenze = plane(KOSTEN)[1]
|
||||
while True:
|
||||
ergebnis = plane(KOSTEN, co2_grenze=grenze)
|
||||
if ergebnis is None:
|
||||
return front
|
||||
front.append(ergebnis)
|
||||
grenze = ergebnis[1] - 1
|
||||
|
||||
|
||||
def untere_huelle(punkte):
|
||||
huelle = []
|
||||
for punkt in sorted(punkte):
|
||||
while len(huelle) >= 2:
|
||||
(x1, y1), (x2, y2) = huelle[-2], huelle[-1]
|
||||
if (x2 - x1) * (punkt[1] - y1) - (y2 - y1) * (punkt[0] - x1) <= 0:
|
||||
huelle.pop()
|
||||
else:
|
||||
break
|
||||
huelle.append(punkt)
|
||||
return huelle
|
||||
|
||||
|
||||
# --- Statische Fassung ------------------------------------------------------
|
||||
|
||||
def zeichne_statisch(front, huelle) -> None:
|
||||
unerreichbar = [p for p in front if p not in huelle]
|
||||
figur, achse = plt.subplots(figsize=(7.2, 4.4))
|
||||
|
||||
hx, hy = zip(*huelle)
|
||||
achse.plot(hx, hy, linestyle="--", color=INDIGO, linewidth=1.4, zorder=1,
|
||||
label="untere konvexe Hülle")
|
||||
achse.scatter(hx, hy, s=70, color=INDIGO, zorder=3,
|
||||
label=f"von Gewichten erreichbar ({len(huelle)})")
|
||||
if unerreichbar:
|
||||
ux, uy = zip(*unerreichbar)
|
||||
achse.scatter(ux, uy, s=90, facecolors="none", edgecolors=AMBER,
|
||||
linewidths=2.0, zorder=4,
|
||||
label=f"Pareto-optimal, aber unerreichbar ({len(unerreichbar)})")
|
||||
|
||||
for x, y in front:
|
||||
achse.annotate(f"{y:,} kg".replace(",", "."), (x, y),
|
||||
textcoords="offset points", xytext=(6, 6), fontsize=7,
|
||||
color="#475569")
|
||||
|
||||
achse.set_xlabel("Transportkosten (EUR)")
|
||||
achse.set_ylabel("CO₂-Ausstoß (kg)")
|
||||
achse.set_title("Pareto-Front: Kosten gegen CO₂", fontsize=11)
|
||||
achse.grid(linestyle=":", alpha=0.5)
|
||||
for rand in ("top", "right"):
|
||||
achse.spines[rand].set_visible(False)
|
||||
achse.legend(fontsize=8, loc="upper right", frameon=False)
|
||||
figur.tight_layout()
|
||||
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
pfad = os.path.join(BILDER, f"kap_mehrziel_pareto.{endung}")
|
||||
figur.savefig(pfad, format=endung, dpi=160,
|
||||
metadata={"Date": None} if endung == "svg" else None)
|
||||
print(f"geschrieben: {pfad}")
|
||||
plt.close(figur)
|
||||
|
||||
|
||||
# --- Interaktive Fassung ----------------------------------------------------
|
||||
|
||||
def zeichne_interaktiv(front, huelle) -> None:
|
||||
unerreichbar = [p for p in front if p not in huelle]
|
||||
guenstigst = front[0]
|
||||
figur = go.Figure()
|
||||
|
||||
hx, hy = zip(*huelle)
|
||||
figur.add_trace(go.Scatter(
|
||||
x=hx, y=hy, mode="lines", line=dict(color=INDIGO, dash="dash", width=1.5),
|
||||
name="untere konvexe Hülle", hoverinfo="skip"))
|
||||
|
||||
def beschriftung(punkt):
|
||||
aufpreis = punkt[0] - guenstigst[0]
|
||||
ersparnis = guenstigst[1] - punkt[1]
|
||||
preis = f"{aufpreis / ersparnis:.2f} EUR je kg" if ersparnis else "Kostenminimum"
|
||||
return (f"<b>{punkt[0]:,} EUR</b><br>{punkt[1]:,} kg CO₂<br>"
|
||||
f"Aufpreis {aufpreis:,} EUR<br>"
|
||||
f"Ersparnis {ersparnis:,} kg<br>{preis}<extra></extra>")
|
||||
|
||||
figur.add_trace(go.Scatter(
|
||||
x=hx, y=hy, mode="markers", marker=dict(size=11, color=INDIGO),
|
||||
name=f"von Gewichten erreichbar ({len(huelle)})",
|
||||
hovertemplate=[beschriftung(p) for p in huelle]))
|
||||
|
||||
if unerreichbar:
|
||||
ux, uy = zip(*unerreichbar)
|
||||
figur.add_trace(go.Scatter(
|
||||
x=ux, y=uy, mode="markers",
|
||||
marker=dict(size=13, color="rgba(0,0,0,0)",
|
||||
line=dict(color=AMBER, width=2.5)),
|
||||
name=f"Pareto-optimal, aber unerreichbar ({len(unerreichbar)})",
|
||||
hovertemplate=[beschriftung(p) for p in unerreichbar]))
|
||||
|
||||
figur.update_layout(
|
||||
title="Pareto-Front: Kosten gegen CO₂",
|
||||
xaxis=dict(title="Transportkosten (EUR)"),
|
||||
yaxis=dict(title="CO₂-Ausstoß (kg)"),
|
||||
height=430, margin=dict(l=70, r=20, t=55, b=50),
|
||||
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
||||
legend=dict(orientation="h", y=-0.2),
|
||||
hovermode="closest")
|
||||
|
||||
os.makedirs(PLOTLY_VERZ, exist_ok=True)
|
||||
pfad = os.path.join(PLOTLY_VERZ, "kap_mehrziel_pareto.html")
|
||||
with open(pfad, "w", encoding="utf-8") as datei:
|
||||
datei.write(figur.to_html(full_html=False, include_plotlyjs=False,
|
||||
div_id="kap-mehrziel-pareto"))
|
||||
print(f"geschrieben: {pfad}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
front = pareto_front()
|
||||
huelle = untere_huelle(front)
|
||||
print(f"Pareto-Punkte: {len(front)}, davon gestuetzt: {len(huelle)}, "
|
||||
f"unerreichbar: {len(front) - len(huelle)}")
|
||||
zeichne_statisch(front, huelle)
|
||||
zeichne_interaktiv(front, huelle)
|
||||
254
bilder_04/erzeuge_polyeder.py
Normal file
254
bilder_04/erzeuge_polyeder.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_polyeder.py
|
||||
"""
|
||||
Erzeugt die beiden Polyeder-Diagramme zum Fundament- und zum LP-Kapitel:
|
||||
|
||||
bilder_04/kap_fundament_polyeder.svg / .png der zulaessige Bereich
|
||||
bilder_04/kap_lp_simplex_ecken.svg / .png der Eckenlauf des Simplex
|
||||
|
||||
Beide zeigen DASSELBE Modell - das Bot-Allokationsproblem, das sich durch die
|
||||
ersten Kapitel zieht:
|
||||
|
||||
max 150 x1 + 250 x2
|
||||
u.d.N. 2 x1 + 5 x2 <= 40 (vCPU)
|
||||
4 x1 + 6 x2 <= 60 (RAM)
|
||||
x1 <= 8 (Marktlimit)
|
||||
x1, x2 >= 0
|
||||
|
||||
Warum das wichtig ist: Die Vorgaengerbilder zeigten ein voellig anderes LP
|
||||
(2x1+3x2 <= 24, 3x1+2x2 <= 24, Optimum bei (4,8; 4,8)) - unmittelbar neben einer
|
||||
Handrechnung, die mit dem Bot-Modell rechnet und (7,5; 5) herausbekommt. Ein
|
||||
Leser musste annehmen, beides gehoere zusammen.
|
||||
|
||||
Die Ecken werden NICHT hingeschrieben, sondern nach demselben Verfahren
|
||||
bestimmt wie in Visualisierung_Loesungsraum.py: Schnittpunkte je zweier
|
||||
Begrenzungsgeraden, die zulaessig sind. Der Simplex-Weg im zweiten Bild ist der
|
||||
Weg der Handrechnung im LP-Kapitel: (0,0) -> (0,8) -> (7,5; 5), also drei der
|
||||
fuenf Ecken.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_polyeder.py
|
||||
|
||||
Benoetigt: numpy, matplotlib
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import os
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
# 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 = "#4338ca", "#0891b2", "#b45309", "#059669"
|
||||
|
||||
# Modell wortgleich zu Visualisierung_Loesungsraum.py
|
||||
C = np.array([150.0, 250.0])
|
||||
A = np.array([[2.0, 5.0], [4.0, 6.0], [1.0, 0.0]])
|
||||
B = np.array([40.0, 60.0, 8.0])
|
||||
NAMEN = ["vCPU: $2x_1+5x_2 \\leq 40$", "RAM: $4x_1+6x_2 \\leq 60$",
|
||||
"Marktlimit: $x_1 \\leq 8$"]
|
||||
FARBEN = [INDIGO, CYAN, AMBER]
|
||||
|
||||
# Der Weg der Handrechnung im LP-Kapitel: Start im Ursprung, dann x2 aufnehmen
|
||||
# (Pivotspalte -250), dann x1 (Pivotspalte -50).
|
||||
SIMPLEXWEG = [(0.0, 0.0), (0.0, 8.0), (7.5, 5.0)]
|
||||
|
||||
|
||||
def ist_zulaessig(punkt: np.ndarray, tol: float = 1e-7) -> bool:
|
||||
return bool(np.all(A @ punkt <= B + tol) and np.all(punkt >= -tol))
|
||||
|
||||
|
||||
def berechne_ecken() -> np.ndarray:
|
||||
"""Ecken = zulaessige Schnittpunkte je zweier Begrenzungsgeraden.
|
||||
|
||||
Wortgleich zu Visualisierung_Loesungsraum.py - die Koordinaten im Bild
|
||||
stammen also aus derselben Rechnung wie die im Buch abgedruckte Tabelle,
|
||||
nicht aus einer zweiten Quelle.
|
||||
"""
|
||||
geraden = [(A[i], B[i]) for i in range(len(B))]
|
||||
geraden.append((np.array([1.0, 0.0]), 0.0)) # x1 = 0
|
||||
geraden.append((np.array([0.0, 1.0]), 0.0)) # x2 = 0
|
||||
|
||||
ecken: list[np.ndarray] = []
|
||||
for (n1, d1), (n2, d2) in itertools.combinations(geraden, 2):
|
||||
matrix = np.array([n1, n2])
|
||||
if abs(np.linalg.det(matrix)) < 1e-9:
|
||||
continue
|
||||
punkt = np.linalg.solve(matrix, np.array([d1, d2]))
|
||||
if ist_zulaessig(punkt) and not any(np.allclose(punkt, e) for e in ecken):
|
||||
ecken.append(punkt)
|
||||
return np.array(ecken)
|
||||
|
||||
|
||||
# Beschriftungsposition je Ecke, in Punkten relativ zum Eckpunkt. Von Hand
|
||||
# gesetzt und nicht gerechnet: (7,5; 5) und (8; 4,67) liegen eine halbe Einheit
|
||||
# auseinander, und jede automatische Regel legt ihre Beschriftungen uebereinander.
|
||||
# Das ist Layout, keine Aussage - die Koordinaten selbst bleiben gerechnet.
|
||||
VERSATZ = {
|
||||
(0.0, 0.0): (14, 10),
|
||||
(0.0, 8.0): (14, -2),
|
||||
(8.0, 0.0): (-10, 14),
|
||||
(7.5, 5.0): (-12, 16),
|
||||
(8.0, 4.67): (16, -4),
|
||||
}
|
||||
|
||||
|
||||
# Im Simplex-Bild laeuft der Pfad durch zwei dieser Positionen - dort andere.
|
||||
VERSATZ_SIMPLEX = {
|
||||
(0.0, 8.0): (16, 12),
|
||||
(7.5, 5.0): (14, 16),
|
||||
}
|
||||
|
||||
|
||||
def versatz(punkt: np.ndarray, mit_pfad: bool = False) -> tuple:
|
||||
schluessel = (round(float(punkt[0]), 2), round(float(punkt[1]), 2))
|
||||
if mit_pfad and schluessel in VERSATZ_SIMPLEX:
|
||||
return VERSATZ_SIMPLEX[schluessel]
|
||||
return VERSATZ.get(schluessel, (12, 8))
|
||||
|
||||
|
||||
def punkt_text(punkt: np.ndarray) -> str:
|
||||
"""Koordinaten deutsch und auf zwei Stellen - '(8; 4,67)' statt '(8; 4.66667)'."""
|
||||
teile = []
|
||||
for wert in punkt:
|
||||
text = f"{wert:.2f}".rstrip("0").rstrip(".")
|
||||
teile.append(text.replace(".", ","))
|
||||
return f"({teile[0]}; {teile[1]})"
|
||||
|
||||
|
||||
def euro(wert: float) -> str:
|
||||
return f"{wert:,.0f} €".replace(",", ".")
|
||||
|
||||
|
||||
def sortiere_umlaufend(ecken: np.ndarray) -> np.ndarray:
|
||||
"""Ecken gegen den Uhrzeigersinn ordnen, damit das Polygon nicht verknotet."""
|
||||
mitte = ecken.mean(axis=0)
|
||||
winkel = np.arctan2(ecken[:, 1] - mitte[1], ecken[:, 0] - mitte[0])
|
||||
return ecken[np.argsort(winkel)]
|
||||
|
||||
|
||||
def zeichne_rahmen(achse, ecken: np.ndarray) -> None:
|
||||
"""Restriktionsgeraden, zulaessiger Bereich und Achsenbeschriftung."""
|
||||
rand = sortiere_umlaufend(ecken)
|
||||
achse.fill(rand[:, 0], rand[:, 1], color=INDIGO, alpha=0.10, zorder=1)
|
||||
achse.plot(np.append(rand[:, 0], rand[0, 0]),
|
||||
np.append(rand[:, 1], rand[0, 1]),
|
||||
color=INDIGO, linewidth=1.6, zorder=2)
|
||||
|
||||
x = np.linspace(0, 11, 200)
|
||||
for zeile, grenze, name, farbe in zip(A, B, NAMEN, FARBEN):
|
||||
if abs(zeile[1]) < 1e-9: # senkrechte Gerade x1 = c
|
||||
achse.axvline(grenze / zeile[0], color=farbe, linewidth=1.3,
|
||||
linestyle="--", alpha=0.8, label=name)
|
||||
else:
|
||||
achse.plot(x, (grenze - zeile[0] * x) / zeile[1], color=farbe,
|
||||
linewidth=1.3, linestyle="--", alpha=0.8, label=name)
|
||||
|
||||
achse.set_xlim(-0.9, 11.4)
|
||||
achse.set_ylim(-0.9, 11)
|
||||
achse.set_xlabel("$x_1$ — Bots vom Typ A")
|
||||
achse.set_ylabel("$x_2$ — Bots vom Typ B")
|
||||
achse.grid(linestyle=":", alpha=0.45)
|
||||
for seite in ("top", "right"):
|
||||
achse.spines[seite].set_visible(False)
|
||||
|
||||
|
||||
def schreibe(figur, name: str) -> None:
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
pfad = os.path.join(BILDER, f"{name}.{endung}")
|
||||
figur.savefig(pfad, format=endung, dpi=160,
|
||||
metadata={"Date": None} if endung == "svg" else None)
|
||||
print(f"geschrieben: {pfad}")
|
||||
plt.close(figur)
|
||||
|
||||
|
||||
def zeichne_polyeder(ecken: np.ndarray, bestes: np.ndarray) -> None:
|
||||
"""Fundament: alle Ecken mit ihrem Zielwert - der Fundamentalsatz zum Anfassen."""
|
||||
figur, achse = plt.subplots(figsize=(7.2, 5.2))
|
||||
zeichne_rahmen(achse, ecken)
|
||||
|
||||
for ecke in ecken:
|
||||
wert = float(C @ ecke)
|
||||
optimal = np.allclose(ecke, bestes)
|
||||
achse.scatter(*ecke, s=120 if optimal else 55, zorder=4,
|
||||
color=GRUEN if optimal else INDIGO)
|
||||
beschriftung = f"{punkt_text(ecke)}\n{euro(wert)}"
|
||||
if optimal:
|
||||
beschriftung += "\n← Optimum"
|
||||
weg = versatz(ecke)
|
||||
achse.annotate(beschriftung, ecke, textcoords="offset points",
|
||||
xytext=weg, fontsize=8.5,
|
||||
ha="left" if weg[0] >= 0 else "right",
|
||||
va="bottom" if weg[1] >= 0 else "top",
|
||||
color=GRUEN if optimal else "#334155",
|
||||
fontweight="bold" if optimal else "normal")
|
||||
achse.set_title("Der zulässige Bereich und seine Ecken\n"
|
||||
"$\\max\\ 150x_1 + 250x_2$", fontsize=11)
|
||||
achse.legend(fontsize=8, loc="upper right", frameon=False)
|
||||
figur.tight_layout()
|
||||
schreibe(figur, "kap_fundament_polyeder")
|
||||
|
||||
|
||||
def zeichne_simplexweg(ecken: np.ndarray, bestes: np.ndarray) -> None:
|
||||
"""LP-Kapitel: derselbe Bereich, aber der Weg der Handrechnung."""
|
||||
figur, achse = plt.subplots(figsize=(7.2, 5.2))
|
||||
zeichne_rahmen(achse, ecken)
|
||||
|
||||
besucht = [np.array(p) for p in SIMPLEXWEG]
|
||||
unbesucht = [e for e in ecken
|
||||
if not any(np.allclose(e, p) for p in besucht)]
|
||||
for ecke in unbesucht:
|
||||
achse.scatter(*ecke, s=55, color="#94a3b8", zorder=3)
|
||||
weg = versatz(ecke, mit_pfad=True)
|
||||
achse.annotate(punkt_text(ecke), ecke, textcoords="offset points",
|
||||
xytext=weg, fontsize=8, color="#64748b",
|
||||
ha="left" if weg[0] >= 0 else "right",
|
||||
va="bottom" if weg[1] >= 0 else "top")
|
||||
|
||||
weg = np.array(besucht)
|
||||
achse.plot(weg[:, 0], weg[:, 1], color=AMBER, linewidth=2.4, zorder=4)
|
||||
for schritt, punkt in enumerate(besucht):
|
||||
wert = float(C @ punkt)
|
||||
letzter = schritt == len(besucht) - 1
|
||||
achse.scatter(*punkt, s=140 if letzter else 90, zorder=5,
|
||||
color=GRUEN if letzter else AMBER)
|
||||
weg = versatz(punkt, mit_pfad=True)
|
||||
achse.annotate(f"{schritt}. {punkt_text(punkt)}\nZ = {euro(wert)}",
|
||||
punkt, textcoords="offset points", xytext=weg,
|
||||
fontsize=8.5, fontweight="bold",
|
||||
ha="left" if weg[0] >= 0 else "right",
|
||||
va="bottom" if weg[1] >= 0 else "top",
|
||||
color=GRUEN if letzter else "#334155")
|
||||
|
||||
achse.set_title(f"Der Simplex besucht {len(besucht)} von {len(ecken)} Ecken\n"
|
||||
"und keine davon zweimal", fontsize=11)
|
||||
achse.legend(fontsize=8, loc="upper right", frameon=False)
|
||||
figur.tight_layout()
|
||||
schreibe(figur, "kap_lp_simplex_ecken")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ecken = berechne_ecken()
|
||||
werte = ecken @ C
|
||||
bestes = ecken[int(np.argmax(werte))]
|
||||
print(f"{len(ecken)} Ecken gefunden:")
|
||||
for ecke, wert in sorted(zip(ecken.tolist(), werte.tolist()),
|
||||
key=lambda p: -p[1]):
|
||||
print(f" ({ecke[0]:6.2f}; {ecke[1]:5.2f}) Z = {wert:9,.2f} EUR")
|
||||
print(f"Optimum: ({bestes[0]:g}; {bestes[1]:g})")
|
||||
for punkt in SIMPLEXWEG:
|
||||
if not any(np.allclose(np.array(punkt), e) for e in ecken):
|
||||
raise SystemExit(f"FEHLER: {punkt} ist keine Ecke des Polyeders.")
|
||||
zeichne_polyeder(ecken, bestes)
|
||||
zeichne_simplexweg(ecken, bestes)
|
||||
231
bilder_04/erzeuge_runden.py
Normal file
231
bilder_04/erzeuge_runden.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
#!/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)
|
||||
132
bilder_04/erzeuge_solverwahl_diagramme.py
Normal file
132
bilder_04/erzeuge_solverwahl_diagramme.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_solverwahl_diagramme.py
|
||||
"""
|
||||
Erzeugt die beiden Entscheidungs-Flussdiagramme "Welches Werkzeug passt zu
|
||||
meinem Problem?" fuer die Auftakte von Teil II und Teil III:
|
||||
|
||||
bilder_04/teil2_solverwahl.svg / .png deterministische Verfahren
|
||||
bilder_04/teil3_solverwahl.svg / .png Nichtlinearitaet und Unsicherheit
|
||||
|
||||
Beide entstehen mit Graphviz. Die Farben folgen der Buchpalette
|
||||
(Indigo, Cyan, Violett, Gruen, Amber), die Kastenform codiert die Rolle:
|
||||
Raute = Frage, abgerundeter Kasten = Empfehlung.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_solverwahl_diagramme.py
|
||||
|
||||
Benoetigt: graphviz (Python-Paket) und das Kommandozeilenwerkzeug 'dot'
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import graphviz
|
||||
|
||||
BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
BILDER = os.path.join(BASIS, "bilder_04")
|
||||
|
||||
INDIGO, CYAN, VIOLETT, GRUEN, AMBER = (
|
||||
"#4338ca", "#0891b2", "#7c3aed", "#059669", "#b45309")
|
||||
TINT = {INDIGO: "#eef2ff", CYAN: "#ecfeff", VIOLETT: "#f5f3ff",
|
||||
GRUEN: "#ecfdf5", AMBER: "#fffbeb"}
|
||||
|
||||
|
||||
def neuer_graph(titel: str) -> graphviz.Digraph:
|
||||
punkt = graphviz.Digraph(format="svg")
|
||||
punkt.attr(rankdir="TB", splines="polyline", nodesep="0.35", ranksep="0.45",
|
||||
fontname="DejaVu Sans", label=titel, labelloc="t",
|
||||
fontsize="13", bgcolor="transparent")
|
||||
punkt.attr("node", fontname="DejaVu Sans", fontsize="10")
|
||||
punkt.attr("edge", fontname="DejaVu Sans", fontsize="9", color="#5c6380")
|
||||
return punkt
|
||||
|
||||
|
||||
def frage(punkt: graphviz.Digraph, name: str, text: str, farbe: str = INDIGO) -> None:
|
||||
punkt.node(name, text, shape="diamond", style="filled",
|
||||
fillcolor=TINT[farbe], color=farbe, penwidth="1.6",
|
||||
margin="0.08,0.02")
|
||||
|
||||
|
||||
def empfehlung(punkt: graphviz.Digraph, name: str, text: str, farbe: str) -> None:
|
||||
punkt.node(name, text, shape="box", style="filled,rounded",
|
||||
fillcolor=TINT[farbe], color=farbe, penwidth="2.0",
|
||||
margin="0.16,0.09")
|
||||
|
||||
|
||||
def schreibe(punkt: graphviz.Digraph, name: str) -> None:
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
punkt.format = endung
|
||||
pfad = punkt.render(os.path.join(BILDER, name), cleanup=True)
|
||||
print(f"geschrieben: {pfad}")
|
||||
|
||||
|
||||
# --- Teil II: deterministische Optimierung ----------------------------------
|
||||
|
||||
def teil2() -> None:
|
||||
punkt = neuer_graph("Welches Werkzeug passt zu meinem Problem? (Teil II)")
|
||||
|
||||
frage(punkt, "f1", "Fahrzeuge, Depots,\nZeitfenster?")
|
||||
frage(punkt, "f2", "Ja/Nein-Entscheidungen,\nZuordnungen, Reihenfolgen?")
|
||||
frage(punkt, "f3", "Zielfunktion und alle\nNebenbedingungen linear?")
|
||||
frage(punkt, "f4", "Logische Regeln\n(wenn-dann, entweder-oder)\noder Zeitplanung?")
|
||||
frage(punkt, "f5", "Nur Fluss oder\nZuordnung im Netzwerk?")
|
||||
|
||||
empfehlung(punkt, "routing", "OR-Tools Routing\n(Kapitel Graphen)", AMBER)
|
||||
empfehlung(punkt, "cpsat", "CP-SAT\n(Kapitel CP-SAT)", VIOLETT)
|
||||
empfehlung(punkt, "milp", "MILP: HiGHS / CP-SAT\n(Kapitel MILP)", CYAN)
|
||||
empfehlung(punkt, "netz", "Spezialalgorithmus:\nMin-Cost-Flow,\nUngarische Methode", GRUEN)
|
||||
empfehlung(punkt, "lp", "LP: scipy.linprog\noder highspy\n(Kapitel LP)", INDIGO)
|
||||
empfehlung(punkt, "teil3", "weiter in Teil III", AMBER)
|
||||
|
||||
punkt.edge("f1", "routing", "ja")
|
||||
punkt.edge("f1", "f2", "nein")
|
||||
punkt.edge("f2", "f4", "ja")
|
||||
punkt.edge("f2", "f3", "nein")
|
||||
punkt.edge("f4", "cpsat", "ja")
|
||||
punkt.edge("f4", "milp", "nein\n(Fixkosten,\nKardinalität)")
|
||||
punkt.edge("f3", "f5", "ja")
|
||||
punkt.edge("f3", "teil3", "nein")
|
||||
punkt.edge("f5", "netz", "ja")
|
||||
punkt.edge("f5", "lp", "nein")
|
||||
|
||||
schreibe(punkt, "teil2_solverwahl")
|
||||
|
||||
|
||||
# --- Teil III: Nichtlinearitaet, Unsicherheit, Dynamik ----------------------
|
||||
|
||||
def teil3() -> None:
|
||||
punkt = neuer_graph("Welches Werkzeug passt zu meinem Problem? (Teil III)")
|
||||
|
||||
frage(punkt, "g1", "Sind die Daten sicher\nbekannt?")
|
||||
frage(punkt, "g2", "Entscheidung fällt in\nmehreren Stufen\nüber die Zeit?")
|
||||
frage(punkt, "g3", "Wahrscheinlichkeiten\nbekannt?")
|
||||
frage(punkt, "g4", "Zielfunktion konvex?\n(Quadrate, Normen,\nlog/exp konvex kombiniert)")
|
||||
frage(punkt, "g5", "Quadratisch mit\nlinearen Nebenbedingungen?")
|
||||
|
||||
empfehlung(punkt, "dp", "Dynamische Programmierung\n(Bellman)", AMBER)
|
||||
empfehlung(punkt, "stoch", "Zweistufige stochastische\nOptimierung, Szenarien", VIOLETT)
|
||||
empfehlung(punkt, "robust", "Robuste Optimierung\n(Worst Case)", VIOLETT)
|
||||
empfehlung(punkt, "qp", "QP über CVXPY\n(OSQP, Clarabel)", CYAN)
|
||||
empfehlung(punkt, "konvex", "CVXPY\n— prüft Konvexität selbst", GRUEN)
|
||||
empfehlung(punkt, "nichtkonvex", "scipy.optimize.minimize\nmit mehreren Startpunkten\n— nur lokales Optimum!", AMBER)
|
||||
|
||||
punkt.edge("g1", "g4", "ja")
|
||||
punkt.edge("g1", "g2", "nein")
|
||||
punkt.edge("g2", "dp", "ja")
|
||||
punkt.edge("g2", "g3", "nein")
|
||||
punkt.edge("g3", "stoch", "ja")
|
||||
punkt.edge("g3", "robust", "nein,\nnur Bandbreiten")
|
||||
punkt.edge("g4", "g5", "ja")
|
||||
punkt.edge("g4", "nichtkonvex", "nein")
|
||||
punkt.edge("g5", "qp", "ja")
|
||||
punkt.edge("g5", "konvex", "nein")
|
||||
|
||||
schreibe(punkt, "teil3_solverwahl")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
teil2()
|
||||
teil3()
|
||||
338
bilder_04/erzeuge_titelseite.py
Normal file
338
bilder_04/erzeuge_titelseite.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_titelseite.py
|
||||
"""
|
||||
Baut die Titelseite bilder_04/titelseite.svg (und die HTML-Kopie
|
||||
OR_HTML_04/bilder_04/titelseite.svg) als SVG mit matplotlib. Das Datum im Fußband
|
||||
('Stand: <datum>') wird dynamisch aus datetime.now() gesetzt, damit es nach
|
||||
jedem Build den aktuellen Stand zeigt.
|
||||
|
||||
Die Koordinaten unten sind SVG-Punkte (595.44 x 841.68, A4 @ 72 dpi). Die
|
||||
y-Achse wird invertiert (set_ylim(841.68, 0)), sodass y von oben zählt und
|
||||
die Werte direkt mit der alten, von Hand erzeugten titelseite.svg
|
||||
übereinstimmen.
|
||||
|
||||
Design (aus der Vorgängerversion abgeleitet):
|
||||
- weißer Hintergrund
|
||||
- 5 farbige Akzentbalken oben
|
||||
- 7 konzentrische Ringe oben rechts (indigo, transparent)
|
||||
- violette Trennlinie unter dem Untertitel
|
||||
- grüne, konkave 'Optimierungskurve' mit rotem Stern-Marker (Optimum)
|
||||
- große Solver-Karte (hell, violetter Rand) mit vier kleinen farbigen
|
||||
Solver-Karten (HiGHS indigo, SciPy grün, Google OR-Tools cyan, CVXPY violett)
|
||||
- indigo Fußband unten mit Autor (weiß) und Stand-Datum (hellviolett)
|
||||
|
||||
Aufruf:
|
||||
python3 bilder_04/erzeuge_titelseite.py
|
||||
(wird vom Build-Skript build_version_03.py vor dem PDF-/HTML-Bau aufgerufen).
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import Circle, FancyBboxPatch, Rectangle
|
||||
|
||||
# Reproduzierbare SVG-Ausgabe. Ohne diese beiden Vorkehrungen unterscheiden sich
|
||||
# zwei Laeufe - und sogar die zwei Kopien AUS DEMSELBEN Lauf - in zwei Punkten:
|
||||
# * svg.hashsalt: matplotlib wuerfelt sonst die clip-path-IDs je Figure neu.
|
||||
# * metadata={"Date": None} beim Speichern: sonst steht der Zeitstempel des
|
||||
# Schreibvorgangs (auf die Mikrosekunde) in den SVG-Metadaten.
|
||||
# Beides ist unsichtbar, macht aber jeden git-diff unlesbar und die beiden
|
||||
# Kopien bilder_04/ und OR_HTML_04/bilder_04/ dauerhaft verschieden. Die Datei
|
||||
# aendert sich jetzt nur noch, wenn sich das SICHTBARE Datum im Fussband
|
||||
# aendert - also einmal pro Tag.
|
||||
plt.rcParams["svg.hashsalt"] = "or-mit-python-v04"
|
||||
|
||||
# Hardcodierte deutsche Monatsnamen statt strftime("%B") - so ist das Datum
|
||||
# unabhängig von der gesetzten Locale (de_DE.UTF-8) immer korrekt geschrieben,
|
||||
# auch in CI-/Docker-/Headless-Umgebungen ohne deutsche Locale.
|
||||
MONATE = ["Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember"]
|
||||
|
||||
# A4 bei 72 dpi -> 595.44 x 841.68 pt (matplotlib figure-Koordinaten).
|
||||
BREITE, HOEHE = 595.44, 841.68
|
||||
|
||||
# Geometrie des Mittelteils. _design() zeichnet danach, _texte() setzt danach -
|
||||
# beide lesen aus DIESEN Werten, damit Kasten und Text nie auseinanderlaufen.
|
||||
# Trennlinie und Kartenoberkante sind gegenueber dem urspruenglichen Entwurf
|
||||
# (336.7 / 368.7) nach oben gerueckt: Der Untertitel braucht seit der
|
||||
# Titelaenderung nur noch zwei statt vier Zeilen, und der gewonnene Platz kommt
|
||||
# der Solver-Karte zugute, die jetzt zwei Absaetze traegt.
|
||||
TRENNLINIE_Y = 308.0
|
||||
KARTE_X0, KARTE_BREITE = 64.30752, 466.82496
|
||||
KARTE_Y0, KARTE_Y1 = 340.0, 481.44096
|
||||
|
||||
# Solver-Karten (x0, Breite, Farbe, Label) - bewusst NICHT gleich breit:
|
||||
# "Google OR-Tools" ist deutlich laenger als die anderen Labels und liefe bei
|
||||
# einheitlicher Breite ueber die Karte hinaus - da das Label weiss ist, wuerde
|
||||
# der ueberstehende Teil auf dem weissen Seitenhintergrund unsichtbar. Reihe
|
||||
# beginnt bei x=71.4528 (linker Rand wie Titel/Untertitel) und endet bei
|
||||
# x=523.9872 (wie die violette Trennlinie), dazwischen 3 Luecken von je 10pt.
|
||||
KARTEN_Y0, KARTEN_Y1 = 494.06616, 541.20024
|
||||
KARTEN = [
|
||||
(71.4528, 94.0, "#4338ca", "HiGHS"),
|
||||
(175.4528, 94.0, "#059669", "SciPy"),
|
||||
(279.4528, 140.5344, "#0891b2", "Google OR-Tools"),
|
||||
(429.9872, 94.0, "#7c3aed", "CVXPY"),
|
||||
]
|
||||
|
||||
|
||||
def datum_string() -> str:
|
||||
"""Heutiges Datum als '5. September 2026' (locale-unabhängig)."""
|
||||
n = datetime.now()
|
||||
return f"{n.day}. {MONATE[n.month - 1]} {n.year}"
|
||||
|
||||
|
||||
def _ring(ax, x, y, r):
|
||||
ax.add_patch(Circle((x, y), r, fill=False, ec="#4338ca", alpha=0.1,
|
||||
lw=1.4, clip_on=True))
|
||||
|
||||
|
||||
def _design(ax):
|
||||
# Weißer Hintergrund.
|
||||
ax.add_patch(Rectangle((0, 0), BREITE, HOEHE, facecolor="white",
|
||||
edgecolor="none", zorder=0))
|
||||
|
||||
# 5 farbige Akzentbalken oben (Leiste), y 34.51..46.29, je 90.51 breit.
|
||||
balken_farben = ["#4338ca", "#0891b2", "#7c3aed", "#059669", "#d97706"]
|
||||
for i, farbe in enumerate(balken_farben):
|
||||
ax.add_patch(Rectangle((71.4528 + i * 90.50688, 34.50888),
|
||||
90.50688, 11.78352, facecolor=farbe,
|
||||
edgecolor="none", zorder=1))
|
||||
|
||||
# 7 konzentrische Ringe oben rechts, Zentrum (553.76, 84.17).
|
||||
for r in (42.084, 77.154, 112.224, 147.294, 182.364, 217.434, 252.504):
|
||||
_ring(ax, 553.7592, 84.168, r)
|
||||
|
||||
# Große Solver-Karte (abgerundet): (64.31, 368.66) bis (531.13, 481.44),
|
||||
# Radius ~11.91 (Q-Offset in der Original-SVG).
|
||||
ax.add_patch(FancyBboxPatch(
|
||||
(KARTE_X0, KARTE_Y0), KARTE_BREITE, KARTE_Y1 - KARTE_Y0,
|
||||
boxstyle="round,pad=0,rounding_size=11.9098",
|
||||
facecolor="#f5f3ff", edgecolor="#7c3aed", lw=1.6, zorder=1))
|
||||
|
||||
# Vier farbige Solver-Karten (HiGHS, SciPy, Google OR-Tools, CVXPY),
|
||||
# individuelle Breiten (siehe KARTEN), y 494.07..541.20.
|
||||
for x0, breite, farbe, _label in KARTEN:
|
||||
ax.add_patch(FancyBboxPatch(
|
||||
(x0, KARTEN_Y0), breite, KARTEN_Y1 - KARTEN_Y0,
|
||||
boxstyle="round,pad=0,rounding_size=11.9098",
|
||||
facecolor=farbe, edgecolor="none", zorder=2))
|
||||
|
||||
# Violette Trennlinie unter dem Untertitel, x 71.45..523.99.
|
||||
ax.plot([71.4528, 523.9872], [TRENNLINIE_Y, TRENNLINIE_Y], color="#7c3aed",
|
||||
lw=2.5, solid_capstyle="butt", zorder=3)
|
||||
|
||||
# Indigo Fußband unten: (0, 757.51) bis (595.44, 841.68).
|
||||
ax.add_patch(Rectangle((0, 757.512), BREITE, HOEHE - 757.512,
|
||||
facecolor="#4338ca", edgecolor="none", zorder=1))
|
||||
|
||||
# Grüne 'Optimierungskurve' (konkav, abflachend) von (71.45, 673.34) nach
|
||||
# (523.99, 639.68) - approximiert die dekorative Kurve der Vorgängerversion
|
||||
# (dort 99 geglättete Stützpunkte, hier eine konkave Wurzelfunktion, die
|
||||
# denselben visuellen Eindruck erzeugt).
|
||||
import numpy as np
|
||||
xs = np.linspace(71.4528, 523.9872, 100)
|
||||
t = (xs - 71.4528) / (523.9872 - 71.4528)
|
||||
ys = 673.344 - 33.664 * np.sqrt(t) # konkav: steiler Start, flaches Ende
|
||||
ax.plot(xs, ys, color="#059669", lw=3, solid_capstyle="butt", zorder=3)
|
||||
# Roter Stern-Marker ('Optimum') bei (446.28, 641.67) auf der Kurve.
|
||||
ax.plot([446.279273], [641.670502], marker="*", color="#e11d48",
|
||||
markersize=12, mec="#e11d48", mfc="#e11d48", zorder=4)
|
||||
|
||||
|
||||
# --- Textumbruch: gemessen statt geschaetzt -------------------------------
|
||||
# Bis zur Titelaenderung standen alle Textzeilen mit von Hand vermessenen
|
||||
# Koordinaten hier - uebernommen aus der urspruenglichen, handgezeichneten SVG.
|
||||
# Das traegt genau so lange, wie sich der Text nicht aendert: Der neue Titel ist
|
||||
# knapp dreimal so lang wie der alte, der Untertitel mehr als doppelt so lang.
|
||||
# Deshalb werden Umbruch und Schriftgroesse jetzt GEMESSEN. Wer den Titel das
|
||||
# naechste Mal aendert, aendert nur noch die Zeichenketten weiter unten.
|
||||
|
||||
def _textbreite(ax, text, groesse, fett=False) -> float:
|
||||
"""Breite eines Textes in Seitenpunkten - ueber den echten Renderer."""
|
||||
marke = ax.text(0, 0, text, fontsize=groesse,
|
||||
fontweight="bold" if fett else "normal")
|
||||
kasten = marke.get_window_extent(
|
||||
renderer=ax.figure.canvas.get_renderer()).transformed(
|
||||
ax.transData.inverted())
|
||||
marke.remove()
|
||||
return abs(kasten.width)
|
||||
|
||||
|
||||
def _umbrechen(ax, text, groesse, maxbreite, fett=False) -> list[str]:
|
||||
"""Bricht 'text' so um, dass keine Zeile breiter als 'maxbreite' wird."""
|
||||
zeilen, zeile = [], ""
|
||||
for wort in text.split():
|
||||
probe = f"{zeile} {wort}".strip()
|
||||
if not zeile or _textbreite(ax, probe, groesse, fett) <= maxbreite:
|
||||
zeile = probe
|
||||
else:
|
||||
zeilen.append(zeile)
|
||||
zeile = wort
|
||||
if zeile:
|
||||
zeilen.append(zeile)
|
||||
return zeilen
|
||||
|
||||
|
||||
def _setze_block(ax, zeilen, x, y0, groesse, durchschuss, **stil) -> float:
|
||||
"""Setzt die Zeilen ab Grundlinie y0 und liefert die Grundlinie danach."""
|
||||
for i, zeile in enumerate(zeilen):
|
||||
ax.text(x, y0 + i * groesse * durchschuss, zeile,
|
||||
fontsize=groesse, zorder=5, **stil)
|
||||
return y0 + (len(zeilen) - 1) * groesse * durchschuss
|
||||
|
||||
|
||||
# --- Die Texte der Titelseite ----------------------------------------------
|
||||
# Nur diese Zeichenketten aendern, wenn das Buch anders heissen soll. Der Text
|
||||
# stammt aus Kritik_und_Verbesserungsvorschlaege/NEUER_TITEL.md; die beiden
|
||||
# Absaetze KOMPENDIUM und ANWENDUNGEN
|
||||
# stehen gemeinsam in der grossen Solver-Karte.
|
||||
#
|
||||
# Im TITEL steht ein \n: An dieser Stelle wird bewusst umbrochen. Ohne ihn
|
||||
# wuerde automatisch umbrochen und "mit Python" auseinandergerissen. Die Breite
|
||||
# jeder Zeile wird in beiden Faellen geprueft.
|
||||
#
|
||||
# Warum drei Zeilen und nicht zwei: "Entscheidungsfindung" ist bei 34 pt schon
|
||||
# 429 pt breit und damit fast so breit wie der ganze Satzspiegel (453 pt). Jede
|
||||
# zweizeilige Aufteilung braeuchte mindestens 649 pt - das passt bei keiner
|
||||
# lesbaren Schriftgroesse.
|
||||
TITEL = "Optimierte\nEntscheidungsfindung\nmit Python"
|
||||
UNTERTITEL = ("Von der mathematischen Modellierung zu praktischen "
|
||||
"intelligenten Lösungen für Betrieb, Technik und Finanzmärkte")
|
||||
KOMPENDIUM = ("Ein praxisorientiertes Kompendium für Entscheider mit "
|
||||
"Constraint Programming, Vektor- und Matrixmethoden sowie "
|
||||
"HiGHS, SciPy, Google OR-Tools und CVXPY.")
|
||||
ANWENDUNGEN = ("Von Personaleinsatz-, Schicht- und Vertretungsplänen, "
|
||||
"Fahrtrouten, Logistik und Maschinen- und Netzwerkauslastung "
|
||||
"über Energieverteilung "
|
||||
"und Ressourcenplanung bis zur Portfoliooptimierung und besseren "
|
||||
"Entscheidungen an den Finanzmärkten – mit Python modellieren, "
|
||||
"optimieren und belegbar fundierte Entscheidungen treffen.")
|
||||
AUTOR = "Autor / Herausgeber: Dieter Schlüter"
|
||||
|
||||
# Satzspiegel: linker Rand und rechte Kante wie Trennlinie und Kartenreihe.
|
||||
LINKS, RECHTS = 71.4528, 523.9872
|
||||
SATZBREITE = RECHTS - LINKS
|
||||
|
||||
# Schriftgroessen und Durchschuss (Faktor auf die Schriftgroesse). Nachgemessen,
|
||||
# nicht geraten: Bei 34 pt ist "Entscheidungsfindung" 429 pt breit und bleibt
|
||||
# damit im Satzspiegel von 453 pt; bei 36 pt waeren es 454 pt - eines zu viel.
|
||||
TITEL_GROESSE, TITEL_DURCHSCHUSS = 34, 1.40
|
||||
# Untertitel 14 statt 15 pt: Bei 15 pt braucht er drei Zeilen, und die dritte
|
||||
# traegt nur noch das Wort "Finanzmärkte" - eine Hurenkind-Zeile mitten auf dem
|
||||
# Titelblatt. Bei 14 pt sind es zwei ausgeglichene Zeilen.
|
||||
UNTER_GROESSE, UNTER_DURCHSCHUSS = 14, 1.23
|
||||
# Kartentext 11.5 statt 12 pt: Der Text ist auf neun Zeilen gewachsen; bei 12 pt
|
||||
# blieben nur noch 9 pt Luft ueber und unter dem Block, und der Text geriete in
|
||||
# die abgerundeten Kartenecken (Radius 11.9 pt).
|
||||
KOMP_GROESSE, KOMP_DURCHSCHUSS = 11.5, 1.27
|
||||
KOMP_BREITE = 440.0 # nutzbare Textbreite in der Karte
|
||||
|
||||
# Anker des Satzes.
|
||||
TITEL_START = 135.0 # erste Grundlinie des Haupttitels
|
||||
ABSTAND_TITEL_UNTER = 42.0 # Luft zwischen Titel- und Untertitelblock
|
||||
|
||||
|
||||
def _texte(ax, datum):
|
||||
# Haupttitel: die letzte Zeile ("mit Python") in Anthrazit statt Indigo -
|
||||
# so bleibt die Zweifarbigkeit des urspruenglichen Entwurfs erhalten, in
|
||||
# dem "Operations Research" indigo und "mit Python" dunkel gesetzt war.
|
||||
# Ein \n im TITEL heisst: hier wird umbrochen. Sonst automatisch.
|
||||
if "\n" in TITEL:
|
||||
titelzeilen = TITEL.split("\n")
|
||||
zu_breit = [z for z in titelzeilen
|
||||
if _textbreite(ax, z, TITEL_GROESSE, True) > SATZBREITE]
|
||||
if zu_breit:
|
||||
raise SystemExit(
|
||||
f"Titelblatt: Diese Titelzeile passt bei {TITEL_GROESSE} pt "
|
||||
f"nicht in den Satzspiegel ({SATZBREITE:.0f} pt): {zu_breit[0]!r}")
|
||||
else:
|
||||
titelzeilen = _umbrechen(ax, TITEL, TITEL_GROESSE, SATZBREITE, fett=True)
|
||||
for i, zeile in enumerate(titelzeilen):
|
||||
ax.text(LINKS, TITEL_START + i * TITEL_GROESSE * TITEL_DURCHSCHUSS,
|
||||
zeile, fontsize=TITEL_GROESSE, fontweight="bold", zorder=5,
|
||||
color="#1a1a2e" if i == len(titelzeilen) - 1 else "#4338ca")
|
||||
titel_ende = TITEL_START + (len(titelzeilen) - 1) * TITEL_GROESSE * TITEL_DURCHSCHUSS
|
||||
|
||||
# Untertitel direkt darunter; die Zeilenzahl ergibt sich aus dem Umbruch.
|
||||
unterzeilen = _umbrechen(ax, UNTERTITEL, UNTER_GROESSE, SATZBREITE)
|
||||
unter_ende = _setze_block(ax, unterzeilen, LINKS,
|
||||
titel_ende + ABSTAND_TITEL_UNTER,
|
||||
UNTER_GROESSE, UNTER_DURCHSCHUSS, color="#334155")
|
||||
# Sicherung: Der Untertitel darf die Trennlinie nicht beruehren. Lieber ein
|
||||
# Abbruch beim Erzeugen als ein Titelblatt, auf dem Text auf der Linie liegt.
|
||||
if unter_ende > TRENNLINIE_Y - 8:
|
||||
raise SystemExit(
|
||||
f"Titelblatt: Der Untertitel reicht bis y={unter_ende:.1f} und "
|
||||
f"kollidiert mit der Trennlinie bei y={TRENNLINIE_Y}. "
|
||||
f"Schriftgroesse oder Startpunkt anpassen.")
|
||||
|
||||
# KOMPENDIUM-Label
|
||||
ax.text(LINKS, 84.168, "K O M P E N D I U M",
|
||||
fontsize=13, color="#0891b2", zorder=5)
|
||||
|
||||
# Die beiden Absaetze in der grossen Solver-Karte, durch eine Leerzeile
|
||||
# getrennt und als Block vertikal zentriert. Zentriert gesetzt statt links,
|
||||
# weil die Karte breiter ist als der Satzspiegel.
|
||||
kartenmitte_x = KARTE_X0 + KARTE_BREITE / 2
|
||||
kartenzeilen = (_umbrechen(ax, KOMPENDIUM, KOMP_GROESSE, KOMP_BREITE)
|
||||
+ [""]
|
||||
+ _umbrechen(ax, ANWENDUNGEN, KOMP_GROESSE, KOMP_BREITE))
|
||||
hoehe = (len(kartenzeilen) - 1) * KOMP_GROESSE * KOMP_DURCHSCHUSS
|
||||
start = (KARTE_Y0 + KARTE_Y1) / 2 - hoehe / 2 + KOMP_GROESSE * 0.35
|
||||
# Sicherung: Der Block darf nicht ueber den Kartenrand hinauslaufen.
|
||||
if start - KOMP_GROESSE < KARTE_Y0 or start + hoehe > KARTE_Y1 - 4:
|
||||
raise SystemExit(
|
||||
f"Titelblatt: Der Kartentext ({len(kartenzeilen)} Zeilen) passt "
|
||||
f"nicht in die Karte ({KARTE_Y1 - KARTE_Y0:.0f} pt hoch). "
|
||||
f"Schriftgroesse verkleinern oder Karte vergroessern.")
|
||||
_setze_block(ax, kartenzeilen, kartenmitte_x, start,
|
||||
KOMP_GROESSE, KOMP_DURCHSCHUSS, color="#1a1a2e", ha="center")
|
||||
|
||||
# Autor (im Fußband, weiß)
|
||||
ax.text(LINKS, 793.402715, AUTOR,
|
||||
fontsize=14, color="#ffffff", zorder=5)
|
||||
# text_7: Stand-Datum (im Fußband, hellviolett) - DYNAMISCH
|
||||
ax.text(71.4528, 818.05749, f"Stand: {datum}",
|
||||
fontsize=12, color="#c7d2fe", zorder=5)
|
||||
# Solver-Namen, zentriert in ihrer Karte (weiß). ha/va="center"
|
||||
# macht die Zentrierung unabhängig von Schriftmetriken - robuster als
|
||||
# von Hand vermessene x/y-Offsets.
|
||||
mitte_y = (KARTEN_Y0 + KARTEN_Y1) / 2
|
||||
for x0, breite, _farbe, label in KARTEN:
|
||||
ax.text(x0 + breite / 2, mitte_y, label,
|
||||
fontsize=13, color="#ffffff", ha="center", va="center",
|
||||
zorder=5)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
basis = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ausgaben = [
|
||||
os.path.join(basis, "bilder_04", "titelseite.svg"),
|
||||
os.path.join(basis, "OR_HTML_04", "bilder_04", "titelseite.svg"),
|
||||
]
|
||||
d = datum_string()
|
||||
|
||||
fig = plt.figure(figsize=(8.27, 11.69))
|
||||
ax = fig.add_axes([0, 0, 1, 1])
|
||||
ax.set_xlim(0, BREITE)
|
||||
ax.set_ylim(HOEHE, 0) # invertiert: y zählt von oben (wie SVG)
|
||||
ax.axis("off")
|
||||
|
||||
_design(ax)
|
||||
_texte(ax, d)
|
||||
|
||||
for pfad in ausgaben:
|
||||
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
||||
fig.savefig(pfad, format="svg", metadata={"Date": None})
|
||||
plt.close(fig)
|
||||
print(f"Titelseite erzeugt ({d}): {', '.join(ausgaben)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
165
bilder_04/erzeuge_var_cvar.py
Normal file
165
bilder_04/erzeuge_var_cvar.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_var_cvar.py
|
||||
"""
|
||||
Erzeugt das VaR/CVaR-Diagramm zum Kapitel CVaR:
|
||||
|
||||
bilder_04/kap_cvar_var_vergleich.svg statisch, fuer das PDF
|
||||
bilder_04/kap_cvar_var_vergleich.png Rasterfassung
|
||||
|
||||
Gezeigt wird die Verteilung simulierter Tagesrenditen mit beiden Kennzahlen:
|
||||
Der VaR ist die SCHWELLE, unter die nur 5 % der Tage fallen; der CVaR ist der
|
||||
MITTELWERT genau dieser 5 %. Der Abstand zwischen beiden Linien ist die
|
||||
Information, die der VaR wegwirft.
|
||||
|
||||
Zur Instanz: Die Renditen stammen aus einer t-Verteilung mit drei
|
||||
Freiheitsgraden, auf Standardabweichung 1 normiert und dann auf eine realistische
|
||||
Tagesvolatilitaet skaliert - dasselbe Fat-Tail-Modell, mit dem Teil 1 von
|
||||
VaR_CVaR_Demo.py rechnet. Die beiden Kennzahlen werden mit den Funktionen aus
|
||||
demselben Programm bestimmt, insbesondere der CVaR ueber Rockafellar-Uryasev
|
||||
statt ueber den naheliegenden, aber falschen Mittelwert aller Werte jenseits des
|
||||
VaR.
|
||||
|
||||
Die Zahlen, die das Skript ausgibt, stehen so auch im Kapiteltext. Wer die
|
||||
Instanz aendert, muss beides nachziehen - deshalb druckt das Skript sie.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_var_cvar.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 import stats
|
||||
|
||||
# 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, AMBER, ROT = "#4338ca", "#b45309", "#be123c"
|
||||
|
||||
TAGE = 20_000 # simulierte Handelstage
|
||||
FREIHEITSGRADE = 3 # Fat-Tail-Modell des Kapitels
|
||||
TAGESVOLA = 0.014 # 1,4 % Tagesschwankung - realistisch fuer ein Aktienportfolio
|
||||
TAGESDRIFT = 0.0003 # leicht positiver Erwartungswert
|
||||
ALPHA = 0.95
|
||||
|
||||
|
||||
# --- Kennzahlen: wortgleich zu VaR_CVaR_Demo.py -----------------------------
|
||||
|
||||
def var_quantil(verluste: np.ndarray, alpha: float = ALPHA) -> float:
|
||||
"""VaR = Quantil der Verlustverteilung (Verluste positiv, Gewinne negativ)."""
|
||||
return float(np.quantile(verluste, alpha))
|
||||
|
||||
|
||||
def cvar_rockafellar(verluste: np.ndarray, alpha: float = ALPHA) -> float:
|
||||
"""CVaR ueber min_gamma { gamma + 1/(1-alpha) * E[max(Verlust - gamma, 0)] }.
|
||||
|
||||
Der naheliegende Weg "Mittelwert aller Werte >= VaR" ist falsch, sobald die
|
||||
Verteilung Atome hat. Hier hat sie keine, aber die Formel bleibt dieselbe
|
||||
wie im Buchprogramm - und nur so ist der Wert im Bild derselbe wie dort.
|
||||
"""
|
||||
kandidaten = np.unique(verluste)
|
||||
return float(min(g + np.mean(np.maximum(verluste - g, 0.0)) / (1.0 - alpha)
|
||||
for g in kandidaten))
|
||||
|
||||
|
||||
def renditen(saat: int = 2026) -> np.ndarray:
|
||||
"""Tagesrenditen aus dem Fat-Tail-Modell des Kapitels."""
|
||||
rng = np.random.default_rng(saat)
|
||||
verteilung = stats.t(df=FREIHEITSGRADE)
|
||||
roh = verteilung.rvs(size=TAGE, random_state=rng) / verteilung.std()
|
||||
return TAGESDRIFT + TAGESVOLA * roh
|
||||
|
||||
|
||||
AUSSCHNITT = 8.0 # Prozent - der sichtbare Bereich der x-Achse
|
||||
|
||||
|
||||
def zeichne(werte: np.ndarray, var: float, cvar: float) -> None:
|
||||
"""Zeichnet die Verteilung mit beiden Kennzahlen.
|
||||
|
||||
Die x-Achse ist bewusst auf +/- 8 % beschnitten. Eine t-Verteilung mit drei
|
||||
Freiheitsgraden hat keine endliche Woelbung: Einzelne Tage liegen bei +70 %,
|
||||
und ungeschnitten quetscht sich die gesamte Verteilung zu einem Strich
|
||||
zusammen. Wie viele Tage ausserhalb liegen, steht im Bild - sie sind nicht
|
||||
verschwiegen, sondern gerade der Punkt.
|
||||
"""
|
||||
figur, achse = plt.subplots(figsize=(8.0, 4.6))
|
||||
prozent = werte * 100
|
||||
sichtbar = prozent[np.abs(prozent) <= AUSSCHNITT]
|
||||
achse.hist(sichtbar, bins=90, color=INDIGO, alpha=0.75,
|
||||
edgecolor="white", linewidth=0.3)
|
||||
achse.set_xlim(-AUSSCHNITT, AUSSCHNITT)
|
||||
|
||||
achse.axvspan(-AUSSCHNITT, -var * 100, color=ROT, alpha=0.08, zorder=0)
|
||||
achse.axvline(-var * 100, color=AMBER, linewidth=2.0, linestyle="--")
|
||||
achse.axvline(-cvar * 100, color=ROT, linewidth=2.0)
|
||||
|
||||
def komma(wert: float, stellen: int = 2) -> str:
|
||||
return f"{wert:.{stellen}f}".replace(".", ",")
|
||||
|
||||
# Platz oberhalb der Verteilung schaffen, damit keine Beschriftung in den
|
||||
# Balken liegt - die Aussage steht sonst buchstaeblich im Weg.
|
||||
hoehe = achse.get_ylim()[1]
|
||||
achse.set_ylim(0, hoehe * 1.30)
|
||||
|
||||
achse.annotate(f"VaR 95 % = {komma(var * 100)} %\ndie Schwelle",
|
||||
(-var * 100, hoehe * 1.24), textcoords="offset points",
|
||||
xytext=(8, 0), fontsize=9, color=AMBER, va="top")
|
||||
achse.annotate(f"CVaR 95 % = {komma(cvar * 100)} %\nder Mittelwert dahinter",
|
||||
(-cvar * 100, hoehe * 1.24), textcoords="offset points",
|
||||
xytext=(-8, 0), fontsize=9, color=ROT, va="top", ha="right")
|
||||
|
||||
anteil = float(np.mean(werte <= -var))
|
||||
achse.annotate(f"die schlechtesten\n{anteil * 100:.0f} % der Tage",
|
||||
((-AUSSCHNITT - var * 100) / 2, hoehe * 0.55),
|
||||
fontsize=8.5, color="#475569", ha="center")
|
||||
|
||||
draussen = int(np.sum(np.abs(prozent) > AUSSCHNITT))
|
||||
schlimmster = float(prozent.min())
|
||||
achse.annotate(f"{draussen} Tage liegen außerhalb dieses Ausschnitts,\n"
|
||||
f"der schlechteste bei {komma(schlimmster, 1)} %.\n"
|
||||
f"Genau sie zählt der VaR nicht mit.",
|
||||
xy=(0.985, 0.80), xycoords="axes fraction", fontsize=8.5,
|
||||
color="#475569", ha="right", va="top")
|
||||
|
||||
achse.set_xlabel("Tagesrendite des Portfolios (%)")
|
||||
achse.set_ylabel("Häufigkeit (von 20 000 Tagen)")
|
||||
achse.set_title("Der VaR ist eine Schwelle, der CVaR ein Mittelwert",
|
||||
fontsize=11)
|
||||
achse.grid(axis="y", linestyle=":", alpha=0.45)
|
||||
for rand in ("top", "right"):
|
||||
achse.spines[rand].set_visible(False)
|
||||
figur.tight_layout()
|
||||
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
pfad = os.path.join(BILDER, f"kap_cvar_var_vergleich.{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__":
|
||||
werte = renditen()
|
||||
verluste = -werte
|
||||
var = var_quantil(verluste)
|
||||
cvar = cvar_rockafellar(verluste)
|
||||
schlimmster = float(verluste.max())
|
||||
print(f"{TAGE} simulierte Tage, t(df={FREIHEITSGRADE}), "
|
||||
f"Tagesvola {TAGESVOLA * 100:.1f} %")
|
||||
print(f"VaR 95 % = {var * 100:.2f} % (Schwelle)")
|
||||
print(f"CVaR 95 % = {cvar * 100:.2f} % (Mittel der schlechtesten 5 %)")
|
||||
print(f"schlimmster Einzeltag: {schlimmster * 100:.2f} %")
|
||||
print(f"Verhaeltnis CVaR/VaR: {cvar / var:.2f}")
|
||||
zeichne(werte, var, cvar)
|
||||
191
bilder_04/erzeuge_vrp_touren.py
Normal file
191
bilder_04/erzeuge_vrp_touren.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_vrp_touren.py
|
||||
"""
|
||||
Erzeugt die Tourenkarte zum Graphen-Kapitel:
|
||||
|
||||
bilder_04/kap_graphen_vrp_touren.svg / .png
|
||||
|
||||
Das Vorgaengerbild war ein Schema: zwei Touren, vier Kunden, Zeitfenster in
|
||||
Uhrzeiten. Die Instanz des Kapitels hat 16 Kunden, vier Fahrzeuge und
|
||||
Zeitfenster in Minuten - das Bild zeigte also die Bauform, nicht den Fall.
|
||||
|
||||
Hier wird die Instanz aus VRP_Flotten_Routing.py geloest und die Loesung als
|
||||
Karte gezeichnet: Depot, Kunden (Punktgroesse = Bedarf), die vier Touren in
|
||||
vier Farben, und in der Legende je Fahrzeug Strecke und Auslastung.
|
||||
|
||||
ZUR REPRODUZIERBARKEIT: Das Buchprogramm gibt der Suche ein ZEITBUDGET von
|
||||
fuenf Sekunden. Ein Zeitbudget ist auf einer anderen Maschine ein anderes
|
||||
Rechenbudget, das Ergebnis also nicht garantiert identisch. Fuer das Bild wird
|
||||
deshalb zusaetzlich eine feste Loesungszahl als Abbruchkriterium gesetzt und
|
||||
die Gesamtstrecke ausgegeben - weicht sie vom abgedruckten Lauf ab, faellt es
|
||||
sofort auf, statt still ins Buch zu wandern.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_vrp_touren.py
|
||||
|
||||
Benoetigt: numpy, matplotlib, ortools
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
|
||||
|
||||
# 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")
|
||||
|
||||
TOURFARBEN = ["#4338ca", "#0891b2", "#b45309", "#059669"]
|
||||
|
||||
# --- Instanz: wortgleich zu VRP_Flotten_Routing.py --------------------------
|
||||
SERVICEZEIT = 10
|
||||
WARTEZEIT_MAX = 60
|
||||
SCHICHTLAENGE = 600
|
||||
SAAT = 42
|
||||
ZEITBUDGET_S = 5
|
||||
LOESUNGSGRENZE = 300 # zusaetzliches, maschinenunabhaengiges Kriterium
|
||||
|
||||
ZEITFENSTER = [
|
||||
(0, SCHICHTLAENGE),
|
||||
(30, 120), (60, 180), (100, 240), (150, 300),
|
||||
(60, 180), (120, 240), (200, 360), (300, 450),
|
||||
(180, 300), (240, 360), (300, 480), (360, 500),
|
||||
(60, 200), (120, 300), (240, 400), (300, 550),
|
||||
]
|
||||
BEDARFE = [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2]
|
||||
KAPAZITAETEN = [10, 10, 10, 10]
|
||||
|
||||
|
||||
def erzeuge_daten():
|
||||
anzahl_orte = len(ZEITFENSTER)
|
||||
rng = np.random.default_rng(SAAT)
|
||||
koordinaten = rng.random((anzahl_orte, 2)) * 100
|
||||
distanz = np.zeros((anzahl_orte, anzahl_orte), dtype=int)
|
||||
for i in range(anzahl_orte):
|
||||
for j in range(anzahl_orte):
|
||||
distanz[i][j] = int(np.linalg.norm(koordinaten[i] - koordinaten[j]))
|
||||
return koordinaten, distanz
|
||||
|
||||
|
||||
def loese(distanz):
|
||||
"""CVRPTW mit denselben Einstellungen wie im Buchprogramm."""
|
||||
manager = pywrapcp.RoutingIndexManager(len(distanz), len(KAPAZITAETEN), 0)
|
||||
routing = pywrapcp.RoutingModel(manager)
|
||||
|
||||
def zeit_callback(von_index, nach_index):
|
||||
von = manager.IndexToNode(von_index)
|
||||
nach = manager.IndexToNode(nach_index)
|
||||
service = SERVICEZEIT if von != 0 else 0
|
||||
return int(distanz[von][nach]) + service
|
||||
|
||||
zeit_index = routing.RegisterTransitCallback(zeit_callback)
|
||||
routing.SetArcCostEvaluatorOfAllVehicles(zeit_index)
|
||||
|
||||
def bedarf_callback(von_index):
|
||||
return BEDARFE[manager.IndexToNode(von_index)]
|
||||
|
||||
bedarf_index = routing.RegisterUnaryTransitCallback(bedarf_callback)
|
||||
routing.AddDimensionWithVehicleCapacity(
|
||||
bedarf_index, 0, KAPAZITAETEN, True, "Kapazitaet")
|
||||
|
||||
routing.AddDimension(zeit_index, WARTEZEIT_MAX, SCHICHTLAENGE, False, "Zeit")
|
||||
zeit_dimension = routing.GetDimensionOrDie("Zeit")
|
||||
for ort, (fruehestens, spaetestens) in enumerate(ZEITFENSTER):
|
||||
zeit_dimension.CumulVar(manager.NodeToIndex(ort)).SetRange(
|
||||
fruehestens, spaetestens)
|
||||
|
||||
parameter = pywrapcp.DefaultRoutingSearchParameters()
|
||||
parameter.first_solution_strategy = (
|
||||
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)
|
||||
parameter.local_search_metaheuristic = (
|
||||
routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
|
||||
parameter.time_limit.seconds = ZEITBUDGET_S
|
||||
parameter.solution_limit = LOESUNGSGRENZE
|
||||
|
||||
loesung = routing.SolveWithParameters(parameter)
|
||||
if not loesung:
|
||||
raise SystemExit("Keine zulaessige Routenfuehrung gefunden.")
|
||||
|
||||
touren = []
|
||||
for fahrzeug in range(len(KAPAZITAETEN)):
|
||||
index = routing.Start(fahrzeug)
|
||||
if routing.IsEnd(loesung.Value(routing.NextVar(index))):
|
||||
continue
|
||||
stationen, fracht, strecke = [], 0, 0
|
||||
while not routing.IsEnd(index):
|
||||
knoten = manager.IndexToNode(index)
|
||||
stationen.append(knoten)
|
||||
fracht += BEDARFE[knoten]
|
||||
vorher = index
|
||||
index = loesung.Value(routing.NextVar(index))
|
||||
strecke += int(distanz[manager.IndexToNode(vorher)][
|
||||
manager.IndexToNode(index)])
|
||||
stationen.append(0)
|
||||
touren.append({"stationen": stationen, "fracht": fracht,
|
||||
"strecke": strecke,
|
||||
"kapazitaet": KAPAZITAETEN[fahrzeug]})
|
||||
return touren
|
||||
|
||||
|
||||
def zeichne(koordinaten, touren) -> None:
|
||||
figur, achse = plt.subplots(figsize=(7.6, 6.2))
|
||||
gesamt = sum(t["strecke"] for t in touren)
|
||||
|
||||
for nummer, (tour, farbe) in enumerate(zip(touren, TOURFARBEN), start=1):
|
||||
weg = koordinaten[tour["stationen"]]
|
||||
achse.plot(weg[:, 0], weg[:, 1], "-", color=farbe, linewidth=1.8,
|
||||
alpha=0.85, zorder=2,
|
||||
label=f"Fahrzeug {nummer}: {tour['strecke']} km, "
|
||||
f"{tour['fracht']}/{tour['kapazitaet']} Einheiten")
|
||||
|
||||
for ort in range(1, len(koordinaten)):
|
||||
achse.scatter(*koordinaten[ort], s=30 + 26 * BEDARFE[ort],
|
||||
color="#334155", zorder=3)
|
||||
achse.annotate(f"K{ort}", koordinaten[ort], textcoords="offset points",
|
||||
xytext=(7, 5), fontsize=7.5, color="#475569")
|
||||
|
||||
achse.scatter(*koordinaten[0], marker="s", s=190, color="#be123c", zorder=4)
|
||||
achse.annotate("Depot", koordinaten[0], textcoords="offset points",
|
||||
xytext=(9, 7), fontsize=9.5, color="#be123c",
|
||||
fontweight="bold")
|
||||
|
||||
achse.set_xlabel("km")
|
||||
achse.set_ylabel("km")
|
||||
achse.set_title(f"Vier Touren für 16 Kunden mit Zeitfenstern — "
|
||||
f"{gesamt} km insgesamt\n"
|
||||
"Punktgröße = Liefermenge", fontsize=10.5)
|
||||
achse.grid(linestyle=":", alpha=0.4)
|
||||
achse.legend(fontsize=8, loc="upper center", bbox_to_anchor=(0.5, -0.11),
|
||||
ncol=2, frameon=False)
|
||||
for seite in ("top", "right"):
|
||||
achse.spines[seite].set_visible(False)
|
||||
figur.tight_layout()
|
||||
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
pfad = os.path.join(BILDER, f"kap_graphen_vrp_touren.{endung}")
|
||||
figur.savefig(pfad, format=endung, dpi=160, bbox_inches="tight",
|
||||
metadata={"Date": None} if endung == "svg" else None)
|
||||
print(f"geschrieben: {pfad}")
|
||||
plt.close(figur)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
koordinaten, distanz = erzeuge_daten()
|
||||
touren = loese(distanz)
|
||||
gesamt = sum(t["strecke"] for t in touren)
|
||||
for nummer, tour in enumerate(touren, start=1):
|
||||
stationen = " -> ".join("Depot" if s == 0 else f"K{s}"
|
||||
for s in tour["stationen"])
|
||||
print(f"Fahrzeug {nummer}: {tour['strecke']:3} km, "
|
||||
f"{tour['fracht']}/{tour['kapazitaet']} | {stationen}")
|
||||
print(f"Gesamtstrecke: {gesamt} km")
|
||||
zeichne(koordinaten, touren)
|
||||
BIN
bilder_04/kap01_analytik_reifegrad.png
Normal file
BIN
bilder_04/kap01_analytik_reifegrad.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
48
bilder_04/kap01_analytik_reifegrad.svg
Normal file
48
bilder_04/kap01_analytik_reifegrad.svg
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 14.1.2 (20260126.1125)
|
||||
-->
|
||||
<!-- Title: d01 Pages: 1 -->
|
||||
<svg width="507pt" height="87pt"
|
||||
viewBox="7.00 7.00 500.00 79.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(11.2 75.48)">
|
||||
<title>d01</title>
|
||||
<!-- desk -->
|
||||
<g id="node1" class="node">
|
||||
<title>desk</title>
|
||||
<path fill="#eef2ff" stroke="#4338ca" stroke-width="2" d="M117.42,-64.28C117.42,-64.28 12,-64.28 12,-64.28 6,-64.28 0,-58.28 0,-52.28 0,-52.28 0,-12 0,-12 0,-6 6,0 12,0 12,0 117.42,0 117.42,0 123.42,0 129.42,-6 129.42,-12 129.42,-12 129.42,-52.28 129.42,-52.28 129.42,-58.28 123.42,-64.28 117.42,-64.28"/>
|
||||
<text xml:space="preserve" text-anchor="start" x="21.96" y="-44.29" font-family="DejaVu Sans" font-weight="bold" font-size="13.00" fill="#4338ca">DESKRIPTIV</text>
|
||||
<text xml:space="preserve" text-anchor="start" x="12.96" y="-20.14" font-family="DejaVu Sans" font-size="10.00" fill="#1a1a2e">Business Intelligence</text>
|
||||
<text xml:space="preserve" text-anchor="start" x="12.96" y="-10.14" font-family="DejaVu Sans" font-size="10.00" fill="#1a1a2e">„Was ist passiert?“</text>
|
||||
</g>
|
||||
<!-- pred -->
|
||||
<g id="node2" class="node">
|
||||
<title>pred</title>
|
||||
<path fill="#ecfeff" stroke="#0891b2" stroke-width="2" d="M300.34,-64.28C300.34,-64.28 184.42,-64.28 184.42,-64.28 178.42,-64.28 172.42,-58.28 172.42,-52.28 172.42,-52.28 172.42,-12 172.42,-12 172.42,-6 178.42,0 184.42,0 184.42,0 300.34,0 300.34,0 306.34,0 312.34,-6 312.34,-12 312.34,-12 312.34,-52.28 312.34,-52.28 312.34,-58.28 306.34,-64.28 300.34,-64.28"/>
|
||||
<text xml:space="preserve" text-anchor="start" x="203.75" y="-44.29" font-family="DejaVu Sans" font-weight="bold" font-size="13.00" fill="#0891b2">PRÄDIKTIV</text>
|
||||
<text xml:space="preserve" text-anchor="start" x="185.38" y="-20.14" font-family="DejaVu Sans" font-size="10.00" fill="#1a1a2e">Data Science / ML</text>
|
||||
<text xml:space="preserve" text-anchor="start" x="185.38" y="-10.14" font-family="DejaVu Sans" font-size="10.00" fill="#1a1a2e">„Was wird geschehen?“</text>
|
||||
</g>
|
||||
<!-- desk->pred -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>desk->pred</title>
|
||||
<path fill="none" stroke="#334155" stroke-width="2" d="M130.13,-32.14C139.73,-32.14 149.74,-32.14 159.65,-32.14"/>
|
||||
<polygon fill="#334155" stroke="#334155" stroke-width="2" points="159.62,-35.29 168.62,-32.14 159.62,-28.99 159.62,-35.29"/>
|
||||
</g>
|
||||
<!-- pres -->
|
||||
<g id="node3" class="node">
|
||||
<title>pres</title>
|
||||
<path fill="#7c3aed" stroke="#7c3aed" stroke-width="2" d="M472.76,-64.28C472.76,-64.28 367.34,-64.28 367.34,-64.28 361.34,-64.28 355.34,-58.28 355.34,-52.28 355.34,-52.28 355.34,-12 355.34,-12 355.34,-6 361.34,0 367.34,0 367.34,0 472.76,0 472.76,0 478.76,0 484.76,-6 484.76,-12 484.76,-12 484.76,-52.28 484.76,-52.28 484.76,-58.28 478.76,-64.28 472.76,-64.28"/>
|
||||
<text xml:space="preserve" text-anchor="start" x="372.42" y="-44.29" font-family="DejaVu Sans" font-weight="bold" font-size="13.00" fill="#ffffff">PRÄSKRIPTIV</text>
|
||||
<text xml:space="preserve" text-anchor="start" x="368.3" y="-20.14" font-family="DejaVu Sans" font-size="10.00" fill="#ffffff">Operations Research</text>
|
||||
<text xml:space="preserve" text-anchor="start" x="368.3" y="-10.14" font-family="DejaVu Sans" font-size="10.00" fill="#ffffff">„Was sollen wir tun?“</text>
|
||||
</g>
|
||||
<!-- pred->pres -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>pred->pres</title>
|
||||
<path fill="none" stroke="#334155" stroke-width="2" d="M313.31,-32.14C322.97,-32.14 332.93,-32.14 342.7,-32.14"/>
|
||||
<polygon fill="#334155" stroke="#334155" stroke-width="2" points="342.46,-35.29 351.46,-32.14 342.46,-28.99 342.46,-35.29"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
BIN
bilder_04/kap01_vier_bausteine.png
Normal file
BIN
bilder_04/kap01_vier_bausteine.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
58
bilder_04/kap01_vier_bausteine.svg
Normal file
58
bilder_04/kap01_vier_bausteine.svg
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 14.1.2 (20260126.1125)
|
||||
-->
|
||||
<!-- Title: d02 Pages: 1 -->
|
||||
<svg width="447pt" height="271pt"
|
||||
viewBox="7.00 7.00 439.00 264.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(11.2 260.04)">
|
||||
<title>d02</title>
|
||||
<!-- param -->
|
||||
<g id="node1" class="node">
|
||||
<title>param</title>
|
||||
<path fill="#fffbeb" stroke="#d97706" stroke-width="2" d="M292.54,-248.84C292.54,-248.84 128.62,-248.84 128.62,-248.84 122.62,-248.84 116.62,-242.84 116.62,-236.84 116.62,-236.84 116.62,-206.56 116.62,-206.56 116.62,-200.56 122.62,-194.56 128.62,-194.56 128.62,-194.56 292.54,-194.56 292.54,-194.56 298.54,-194.56 304.54,-200.56 304.54,-206.56 304.54,-206.56 304.54,-236.84 304.54,-236.84 304.54,-242.84 298.54,-248.84 292.54,-248.84"/>
|
||||
<text xml:space="preserve" text-anchor="start" x="167.08" y="-228.85" font-family="DejaVu Sans" font-weight="bold" font-size="13.00" fill="#d97706">PARAMETER</text>
|
||||
<text xml:space="preserve" text-anchor="start" x="129.58" y="-204.7" font-family="DejaVu Sans" font-size="10.00" fill="#1a1a2e">Konstanten, Daten, Preise, Limits</text>
|
||||
</g>
|
||||
<!-- var -->
|
||||
<g id="node2" class="node">
|
||||
<title>var</title>
|
||||
<path fill="#eef2ff" stroke="#4338ca" stroke-width="2" d="M314.29,-151.56C314.29,-151.56 106.88,-151.56 106.88,-151.56 100.88,-151.56 94.88,-145.56 94.88,-139.56 94.88,-139.56 94.88,-109.28 94.88,-109.28 94.88,-103.28 100.88,-97.28 106.88,-97.28 106.88,-97.28 314.29,-97.28 314.29,-97.28 320.29,-97.28 326.29,-103.28 326.29,-109.28 326.29,-109.28 326.29,-139.56 326.29,-139.56 326.29,-145.56 320.29,-151.56 314.29,-151.56"/>
|
||||
<text xml:space="preserve" text-anchor="start" x="107.83" y="-131.57" font-family="DejaVu Sans" font-weight="bold" font-size="13.00" fill="#4338ca">ENTSCHEIDUNGSVARIABLEN</text>
|
||||
<text xml:space="preserve" text-anchor="start" x="107.83" y="-107.42" font-family="DejaVu Sans" font-size="10.00" fill="#1a1a2e">x₁, x₂, …, xₙ : Was wird gesteuert?</text>
|
||||
</g>
|
||||
<!-- param->var -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>param->var</title>
|
||||
<path fill="none" stroke="#334155" stroke-width="2" d="M210.58,-193.65C210.58,-184.6 210.58,-174.32 210.58,-164.53"/>
|
||||
<polygon fill="#334155" stroke="#334155" stroke-width="2" points="213.74,-164.56 210.59,-155.56 207.44,-164.56 213.74,-164.56"/>
|
||||
</g>
|
||||
<!-- ziel -->
|
||||
<g id="node3" class="node">
|
||||
<title>ziel</title>
|
||||
<path fill="#ecfdf5" stroke="#059669" stroke-width="2" d="M181.17,-54.28C181.17,-54.28 12,-54.28 12,-54.28 6,-54.28 0,-48.28 0,-42.28 0,-42.28 0,-12 0,-12 0,-6 6,0 12,0 12,0 181.17,0 181.17,0 187.17,0 193.17,-6 193.17,-12 193.17,-12 193.17,-42.28 193.17,-42.28 193.17,-48.28 187.17,-54.28 181.17,-54.28"/>
|
||||
<text xml:space="preserve" text-anchor="start" x="42.58" y="-34.29" font-family="DejaVu Sans" font-weight="bold" font-size="13.00" fill="#059669">ZIELFUNKTION</text>
|
||||
<text xml:space="preserve" text-anchor="start" x="12.96" y="-10.14" font-family="DejaVu Sans" font-size="10.00" fill="#1a1a2e">Minimiere Kosten / Maximiere ROI</text>
|
||||
</g>
|
||||
<!-- var->ziel -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>var->ziel</title>
|
||||
<path fill="none" stroke="#334155" stroke-width="2" d="M178.22,-96.37C165.71,-85.91 151.23,-73.81 137.97,-62.73"/>
|
||||
<polygon fill="#334155" stroke="#334155" stroke-width="2" points="140.28,-60.55 131.35,-57.2 136.24,-65.39 140.28,-60.55"/>
|
||||
</g>
|
||||
<!-- neben -->
|
||||
<g id="node4" class="node">
|
||||
<title>neben</title>
|
||||
<path fill="#ecfeff" stroke="#0891b2" stroke-width="2" d="M412.17,-54.28C412.17,-54.28 237,-54.28 237,-54.28 231,-54.28 225,-48.28 225,-42.28 225,-42.28 225,-12 225,-12 225,-6 231,0 237,0 237,0 412.17,0 412.17,0 418.17,0 424.17,-6 424.17,-12 424.17,-12 424.17,-42.28 424.17,-42.28 424.17,-48.28 418.17,-54.28 412.17,-54.28"/>
|
||||
<text xml:space="preserve" text-anchor="start" x="245.46" y="-34.29" font-family="DejaVu Sans" font-weight="bold" font-size="13.00" fill="#0891b2">NEBENBEDINGUNGEN</text>
|
||||
<text xml:space="preserve" text-anchor="start" x="237.96" y="-10.14" font-family="DejaVu Sans" font-size="10.00" fill="#1a1a2e">Harte Schranken: Budget, Kapazität</text>
|
||||
</g>
|
||||
<!-- var->neben -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>var->neben</title>
|
||||
<path fill="none" stroke="#334155" stroke-width="2" d="M242.95,-96.37C255.46,-85.91 269.94,-73.81 283.2,-62.73"/>
|
||||
<polygon fill="#334155" stroke="#334155" stroke-width="2" points="284.93,-65.39 289.82,-57.2 280.89,-60.55 284.93,-65.39"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
BIN
bilder_04/kap02_konvexitaet.png
Normal file
BIN
bilder_04/kap02_konvexitaet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue