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
468 changed files with 262141 additions and 0 deletions
260
OR_HTML_04/programme/Mehrziel_Pareto.py
Normal file
260
OR_HTML_04/programme/Mehrziel_Pareto.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Mehrziel_Pareto.py
|
||||
"""
|
||||
Kapitel Mehrziel: Kosten gegen CO2 - und warum Gewichte nicht genuegen.
|
||||
|
||||
Eine Spedition vergibt zwoelf Sendungen an drei Verkehrstraeger: LKW (schnell,
|
||||
teuer, schmutzig), Bahn (billig und sauber, aber nur fuenf Trassen frei) und
|
||||
Kombinierten Verkehr (dazwischen). Zwei Ziele stehen gegeneinander:
|
||||
Transportkosten und CO2-Ausstoss.
|
||||
|
||||
Der uebliche Reflex ist, beide Ziele zu einem zusammenzuruehren:
|
||||
|
||||
minimiere Kosten + w * CO2
|
||||
|
||||
Das ist bequem, liefert zulaessige Loesungen - und ist unvollstaendig. Bei
|
||||
ganzzahligen Entscheidungen gibt es Kompromisse, die auf diesem Weg
|
||||
GRUNDSAETZLICH nicht erreichbar sind, egal welches w man waehlt. Nicht "schwer
|
||||
zu finden", sondern beweisbar unerreichbar.
|
||||
|
||||
Das Programm zeigt in vier Teilen:
|
||||
|
||||
1. Die beiden Extreme - was jedes Ziel allein kostet.
|
||||
2. Die lineare Skalarisierung ueber ein feines Gewichtsraster.
|
||||
3. Die vollstaendige Pareto-Front ueber das eps-Constraint-Verfahren.
|
||||
4. Den Nachweis, dass die Luecke keine Frage des Rasters ist, sondern
|
||||
Geometrie: Die fehlenden Punkte liegen strikt oberhalb der konvexen
|
||||
Huelle und koennen deshalb von keiner Geraden gestuetzt werden.
|
||||
|
||||
Benoetigt: numpy, scipy
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import linprog
|
||||
|
||||
TRAEGER = ["LKW", "Bahn", "Kombiniert"]
|
||||
BAHN_TRASSEN = 5 # so viele Sendungen passen hoechstens auf die Bahn
|
||||
SAAT = 5
|
||||
|
||||
|
||||
def erzeuge_sendungen(anzahl: int = 12, saat: int = SAAT):
|
||||
"""Kosten und CO2 je Sendung und Verkehrstraeger.
|
||||
|
||||
Die Bahn ist immer billiger und sauberer als der LKW - der Zielkonflikt
|
||||
entsteht nicht zwischen den Traegern, sondern durch die KNAPPHEIT der
|
||||
Trassen. Genau so sieht es in der Praxis aus.
|
||||
"""
|
||||
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: np.ndarray, co2_grenze: float | None = None,
|
||||
kosten_grenze: float | None = None):
|
||||
"""Jede Sendung genau einem Traeger zuordnen; Trassen sind knapp.
|
||||
|
||||
'ziel' ist die zu minimierende Matrix (Kosten, CO2 oder eine Mischung).
|
||||
Die beiden Grenzen sind das Werkzeug fuer eps-Constraint und
|
||||
lexikografische Optimierung - sie machen aus einem Ziel eine Schranke.
|
||||
"""
|
||||
gleichungen = np.zeros((N, N * 3))
|
||||
for i in range(N):
|
||||
gleichungen[i, i * 3:(i + 1) * 3] = 1.0 # genau ein Traeger
|
||||
|
||||
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))
|
||||
if kosten_grenze is not None:
|
||||
ungleichungen.append(KOSTEN.reshape(-1).astype(float))
|
||||
grenzen.append(float(kosten_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), plan)
|
||||
|
||||
|
||||
def pareto_front():
|
||||
"""Vollstaendige Front ueber das eps-Constraint-Verfahren.
|
||||
|
||||
Der Ablauf ist der eigentliche Inhalt dieser Funktion: Erst das
|
||||
Kostenminimum bestimmen (der eine Rand der Front), dann die CO2-Schranke
|
||||
schrittweise um genau ein Kilogramm unter den zuletzt erreichten Wert
|
||||
druecken. Jeder Lauf liefert den naechsten Punkt - und wenn keiner mehr
|
||||
zulaessig ist, ist die Front vollstaendig.
|
||||
|
||||
Das braucht so viele Solveraufrufe, wie die Front Punkte hat. Ein Raster
|
||||
ueber alle moeglichen CO2-Werte braeuchte hier fast tausend.
|
||||
"""
|
||||
front = []
|
||||
start = plane(KOSTEN)
|
||||
grenze = start[1]
|
||||
while True:
|
||||
ergebnis = plane(KOSTEN, co2_grenze=grenze)
|
||||
if ergebnis is None:
|
||||
break
|
||||
front.append((ergebnis[0], ergebnis[1]))
|
||||
grenze = ergebnis[1] - 1
|
||||
return front
|
||||
|
||||
|
||||
def skalarisierung(gewichte):
|
||||
"""Was 'Kosten + w * CO2' fuer viele w hergibt - als Menge von Punkten."""
|
||||
gefunden = {}
|
||||
for w in gewichte:
|
||||
ergebnis = plane(KOSTEN + w * CO2)
|
||||
if ergebnis:
|
||||
gefunden.setdefault((ergebnis[0], ergebnis[1]), []).append(w)
|
||||
return gefunden
|
||||
|
||||
|
||||
def untere_huelle(punkte):
|
||||
"""Untere linke konvexe Huelle - genau die Punkte, die eine Gerade stuetzt.
|
||||
|
||||
Der Zusammenhang, um den es geht: 'Kosten + w*CO2 minimieren' heisst
|
||||
geometrisch, eine Gerade der Steigung -1/w von links unten an die
|
||||
Punktwolke zu schieben. Sie beruehrt immer einen Eckpunkt der konvexen
|
||||
Huelle. Punkte, die oberhalb liegen, werden nie beruehrt - fuer kein w.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 80)
|
||||
print(" KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN")
|
||||
print("=" * 80)
|
||||
print(f"{N} Sendungen, {len(TRAEGER)} Verkehrstraeger, "
|
||||
f"{BAHN_TRASSEN} freie Bahntrassen.\n")
|
||||
|
||||
# --- 1. Die beiden Extreme -------------------------------------------
|
||||
guenstigst = plane(KOSTEN)
|
||||
saubersten = plane(CO2)
|
||||
print("1. Was jedes Ziel allein ergibt\n")
|
||||
print(f" {'':<22} {'Kosten':>10} {'CO2':>10}")
|
||||
print(" " + "-" * 44)
|
||||
print(f" {'nur Kosten minimal':<22} {guenstigst[0]:>10,} {guenstigst[1]:>9,} kg")
|
||||
print(f" {'nur CO2 minimal':<22} {saubersten[0]:>10,} {saubersten[1]:>9,} kg")
|
||||
print(f"\n Der Zielkonflikt ist echt, aber klein: "
|
||||
f"{saubersten[0] - guenstigst[0]:,} EUR mehr")
|
||||
print(f" ({(saubersten[0] - guenstigst[0]) / guenstigst[0] * 100:.1f} %) sparen "
|
||||
f"{guenstigst[1] - saubersten[1]:,} kg CO2 "
|
||||
f"({(guenstigst[1] - saubersten[1]) / guenstigst[1] * 100:.1f} %).")
|
||||
print(" Genau solche Zahlen will die Geschaeftsfuehrung sehen - nicht ein")
|
||||
print(" Gewicht, das niemand interpretieren kann.")
|
||||
|
||||
# --- 2. Die lineare Skalarisierung -----------------------------------
|
||||
gewichte = np.concatenate([np.linspace(0.0, 3.0, 1201),
|
||||
np.geomspace(3.0, 1000.0, 200)])
|
||||
gefunden = skalarisierung(gewichte)
|
||||
print("\n" + "-" * 80)
|
||||
print(f"2. Lineare Skalarisierung: 'Kosten + w * CO2' fuer "
|
||||
f"{len(gewichte):,} Gewichte\n")
|
||||
print(f" {'Kosten':>10} {'CO2':>10} {'gefunden bei w':>16}")
|
||||
print(" " + "-" * 46)
|
||||
for (k, c), ws in sorted(gefunden.items()):
|
||||
print(f" {k:>10,} {c:>9,} kg {min(ws):>7.3f} bis {max(ws):>7.3f}")
|
||||
print(f"\n {len(gefunden)} verschiedene Plaene - fuer {len(gewichte):,} Gewichte.")
|
||||
print(" Das Gewicht ist also gar keine Feineinstellung: Weite Bereiche")
|
||||
print(" liefern dasselbe Ergebnis, und dazwischen springt es.")
|
||||
|
||||
# --- 3. Die vollstaendige Front --------------------------------------
|
||||
front = pareto_front()
|
||||
print("\n" + "-" * 80)
|
||||
print(f"3. Die vollstaendige Pareto-Front ueber eps-Constraint "
|
||||
f"({len(front)} Solverlaeufe)\n")
|
||||
print(f" {'Kosten':>10} {'CO2':>10} {'Aufpreis':>9} {'CO2-Ersparnis':>14} "
|
||||
f"{'EUR je kg':>10}")
|
||||
print(" " + "-" * 60)
|
||||
for k, c in front:
|
||||
auf = k - guenstigst[0]
|
||||
ersparnis = guenstigst[1] - c
|
||||
preis = auf / ersparnis if ersparnis else 0.0
|
||||
print(f" {k:>10,} {c:>9,} kg {auf:>8,} {ersparnis:>13,} "
|
||||
f"{preis:>10.2f}")
|
||||
|
||||
# --- 4. Der Nachweis --------------------------------------------------
|
||||
huelle = untere_huelle(front)
|
||||
unerreichbar = [p for p in front if p not in huelle]
|
||||
print("\n" + "-" * 80)
|
||||
print("4. Was die Skalarisierung nicht findet\n")
|
||||
erreicht = [p for p in front if p in gefunden]
|
||||
print(f" Pareto-Punkte insgesamt: {len(front)}")
|
||||
print(f" davon von der Skalarisierung gefunden: {len(erreicht)}")
|
||||
print(f" nie gefunden: {len(front) - len(erreicht)}")
|
||||
print()
|
||||
print(" Diese Kompromisse sind fuer KEIN Gewicht erreichbar:\n")
|
||||
print(f" {'Kosten':>10} {'CO2':>10} {'Aufpreis':>9} {'CO2-Ersparnis':>14}")
|
||||
print(" " + "-" * 50)
|
||||
for k, c in unerreichbar:
|
||||
print(f" {k:>10,} {c:>9,} kg {k - guenstigst[0]:>8,} "
|
||||
f"{guenstigst[1] - c:>13,}")
|
||||
|
||||
stimmt = sorted(unerreichbar) == sorted(p for p in front if p not in gefunden)
|
||||
print(f"\n Gegenprobe ueber die Geometrie: {'bestanden' if stimmt else 'ABWEICHUNG'}")
|
||||
print(" Genau die Punkte, die das Gewichtsraster verfehlt, liegen strikt")
|
||||
print(" oberhalb der unteren konvexen Huelle. Das ist kein Rasterproblem -")
|
||||
print(" eine Gerade, die von links unten an die Wolke geschoben wird,")
|
||||
print(" beruehrt immer einen Eckpunkt der Huelle und nie einen Punkt")
|
||||
print(" darueber. Ein feineres Raster aendert daran nichts.")
|
||||
|
||||
# --- 5. Lexikografisch -------------------------------------------------
|
||||
print("\n" + "-" * 80)
|
||||
print("5. Lexikografisch: erst Kosten, dann CO2 im Rahmen eines Budgets\n")
|
||||
print(f" {'Kostenbudget':>14} {'Kosten':>10} {'CO2':>10} {'gegenueber Minimum':>20}")
|
||||
print(" " + "-" * 58)
|
||||
for aufschlag in (0.00, 0.01, 0.02, 0.05, 0.10):
|
||||
budget = guenstigst[0] * (1 + aufschlag)
|
||||
ergebnis = plane(CO2, kosten_grenze=budget)
|
||||
if ergebnis is None:
|
||||
print(f" {aufschlag:>13.0%} unzulaessig")
|
||||
continue
|
||||
print(f" {aufschlag:>13.0%} {ergebnis[0]:>10,} {ergebnis[1]:>9,} kg "
|
||||
f"{guenstigst[1] - ergebnis[1]:>15,} kg weniger")
|
||||
|
||||
print("\n Das ist die Form, die im Betrieb am ehesten trifft: Nicht 'wie")
|
||||
print(" wichtig ist CO2?', sondern 'wir geben zwei Prozent mehr aus - was")
|
||||
print(" bringt das?'. Die Frage kann ein Kaufmann beantworten.")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print(" WAS MAN DARAUS MITNIMMT")
|
||||
print("=" * 80)
|
||||
print("Ein Gewicht zu setzen heisst, die Entscheidung heimlich zu treffen -")
|
||||
print("und dabei einen Teil der Moeglichkeiten gar nicht erst zu sehen.")
|
||||
print()
|
||||
print("Die Pareto-Front ist die ehrlichere Antwort: Sie legt dem Betrieb")
|
||||
print("alle sinnvollen Kompromisse vor und ueberlaesst ihm die Wahl. Die")
|
||||
print("Spalte 'EUR je kg' macht sie entscheidbar - man vergleicht sie mit")
|
||||
print("dem CO2-Preis, den das Unternehmen ohnehin ansetzt.")
|
||||
print("=" * 80)
|
||||
Loading…
Reference in a new issue