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:
dschlueter 2026-09-08 01:20:09 +02:00
commit b7af2f1d9a
468 changed files with 262141 additions and 0 deletions

View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
# Backtest_Fallen.py
"""
Kapitel Handelsmaschine: Wie leicht man sich selbst betruegt.
Demonstriert an SIMULIERTEN Daten (also ohne jede echte Prognosekraft),
wie gross die scheinbare Ueberrendite durch typische Backtest-Fehler wird.
Weil die Daten reines Rauschen sind, ist JEDE positive Ueberrendite ein Artefakt.
"""
import numpy as np
import pandas as pd
def erzeuge_zufallsmarkt(tage=1260, titel=15, seed=7):
"""Reines Rauschen: kein Titel hat echte Prognosekraft."""
rng = np.random.default_rng(seed)
renditen = rng.normal(0.0003, 0.015, size=(tage, titel))
index = pd.bdate_range("2020-01-01", periods=tage)
return pd.DataFrame(renditen, index=index,
columns=[f"T{i:02d}" for i in range(titel)])
def strategie(renditen, lookahead=False, survivorship=False,
ohne_kosten=False, gebuehr=0.0015):
"""Einfache Momentum-Strategie mit optional eingebauten Fehlern."""
fenster = 60
kapital = [100.0]
gewichte = np.ones(renditen.shape[1]) / renditen.shape[1]
daten = renditen
if survivorship:
# FEHLER: nur die im Nachhinein besten Titel ins Universum lassen
beste = renditen.sum().nlargest(renditen.shape[1] // 2).index
daten = renditen[beste]
gewichte = np.ones(len(beste)) / len(beste)
for t in range(fenster, len(daten) - 1):
if t % 21 == 0: # monatlich
if lookahead:
# FEHLER: Signal nutzt die Rendite von MORGEN
signal = daten.iloc[t + 1].values
else:
signal = daten.iloc[t - fenster:t].mean().values
neue = (signal > np.median(signal)).astype(float)
neue = neue / neue.sum() if neue.sum() > 0 else gewichte
if not ohne_kosten:
kapital[-1] *= (1 - np.abs(neue - gewichte).sum() * gebuehr)
gewichte = neue
kapital.append(kapital[-1] * (1 + float(gewichte @ daten.iloc[t + 1].values)))
return np.array(kapital)
def cagr(verlauf, jahre):
return (verlauf[-1] / verlauf[0]) ** (1 / jahre) - 1
if __name__ == "__main__":
renditen = erzeuge_zufallsmarkt()
jahre = len(renditen) / 252
markt = 100 * (1 + renditen.mean(axis=1)).cumprod().values
print("=" * 84)
print(" DIE FUENF SELBSTTAEUSCHUNGEN - GEMESSEN AN REINEM RAUSCHEN")
print("=" * 84)
print("Die Daten sind zufaellig erzeugt. Es gibt KEINE echte Prognosekraft.")
print("Jede Ueberrendite unten ist daher ein reines Artefakt.\n")
print(f"{'Variante':<44} {'CAGR':>9} {'ggue. Markt':>13}")
print("-" * 84)
basis = cagr(markt, jahre)
print(f"{'Markt (Gleichgewichtung, Referenz)':<44} {basis*100:>8.2f} % "
f"{0.0:>12.2f} %")
varianten = [
("Ehrlich: Signal aus Vergangenheit, mit Kosten", {}),
("FEHLER 1: Lookahead (Signal kennt morgen)", {"lookahead": True}),
("FEHLER 2: Survivorship (nur Gewinner im Universum)", {"survivorship": True}),
("FEHLER 3: Transaktionskosten ignoriert", {"ohne_kosten": True}),
("FEHLER 1+2+3 kombiniert", {"lookahead": True, "survivorship": True,
"ohne_kosten": True}),
]
for name, argumente in varianten:
verlauf = strategie(renditen, **argumente)
wert = cagr(verlauf, jahre)
print(f"{name:<44} {wert*100:>8.2f} % {(wert-basis)*100:>+12.2f} %")
print("-" * 84)
print("FEHLER 4 (Overfitting) und FEHLER 5 (Data Snooping) lassen sich so nicht")
print("zeigen - sie entstehen erst durch WIEDERHOLTES Probieren. Faustregel:")
print("Wer 20 Strategien testet, findet auch in reinem Rauschen eine mit")
print("Signifikanz auf dem 5-%-Niveau. Genau das ist der Punkt.")
print("=" * 84)

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
# Bausteine_Vorlage.py
"""
Kapitel Einfuehrung: Die Bausteine-Vorlage als ausfuellbares Python-Geruest.
Uebersetzt die Vier-Bausteine-Vorlage aus dem Abschnitt 'Die vier
universellen Bausteine jedes OR-Problems' direkt in Code: Wer sie
auf Papier ausgefuellt hat, kann sie fast unveraendert in ein loesbares
Modell umsetzen. Angewendet auf das Baeckerei-Beispiel der Handrechnung.
"""
from dataclasses import dataclass
from ortools.sat.python import cp_model
@dataclass
class Bausteine:
"""Die vier Bausteine jedes OR-Problems - wortwoertlich aus der Vorlage
im Abschnitt 'Die vier universellen Bausteine' uebernommen."""
variablen: str
parameter: str
zielfunktion: str
harte_bedingungen: str
weiche_bedingungen: str
def zeige(self) -> None:
print("=" * 78)
print(" AUSGEFUELLTE BAUSTEINE-VORLAGE")
print("=" * 78)
for feld, wert in [
("Entscheidungsvariablen", self.variablen),
("Parameter", self.parameter),
("Zielfunktion", self.zielfunktion),
("Harte Bedingungen", self.harte_bedingungen),
("Weiche Bedingungen", self.weiche_bedingungen),
]:
print(f"{feld:<24}: {wert}")
print("=" * 78)
# --- Baeckerei-Beispiel aus der Handrechnung ---------------------------------
VORLAGE = Bausteine(
variablen="x1 = Anzahl Brote, x2 = Anzahl Broetchen-Zehnerpackungen "
"(beide ganzzahlig >= 0)",
parameter="Deckungsbeitraege (2,50; 3,00) EUR; Mehlbedarf (0,5; 0,6) kg; "
"Ofenzeit (4; 3) min; Kapazitaeten (90 kg, 600 min); Mindestmenge 40 Brote",
zielfunktion="max 2,5*x1 + 3,0*x2 (Einheit: Euro Deckungsbeitrag)",
harte_bedingungen="0,5*x1 + 0,6*x2 <= 90 (Mehl); 4*x1 + 3*x2 <= 600 (Ofen); "
"x1 >= 40 (Vertrag)",
weiche_bedingungen="keine",
)
def loese_baeckerei():
"""Baut aus der ausgefuellten Vorlage mechanisch ein CP-SAT-Modell."""
modell = cp_model.CpModel()
x1 = modell.NewIntVar(40, 1000, "Brote") # Vertrag: mindestens 40
x2 = modell.NewIntVar(0, 1000, "Broetchen_Zehner")
modell.Add(5 * x1 + 6 * x2 <= 900) # Mehl, x10 fuer Ganzzahligkeit
modell.Add(4 * x1 + 3 * x2 <= 600) # Ofenzeit
modell.Maximize(25 * x1 + 30 * x2) # Deckungsbeitrag x10
loeser = cp_model.CpSolver()
status = loeser.Solve(modell)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
raise SystemExit(f"Keine Loesung: {loeser.StatusName(status)}")
return loeser.Value(x1), loeser.Value(x2), loeser.ObjectiveValue() / 10
if __name__ == "__main__":
VORLAGE.zeige()
print("\n--- Aus der Vorlage mechanisch abgeleitetes Modell ---")
brote, broetchen, gewinn = loese_baeckerei()
print(f"Optimale Brote: {brote}")
print(f"Optimale Broetchen-Zehner: {broetchen}")
print(f"Maximaler Deckungsbeitrag: {gewinn:.2f} EUR")
print()
print("Genau diese drei Codezeilen (Variablen, Add, Maximize) entstehen")
print("direkt aus den fuenf Zeilen der Vorlage - das ist der ganze Trick.")

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
# Bellman_Minimalbeispiel.py
"""
Kapitel Dynamische Programmierung: Die Handrechnung zur Rueckwaertsinduktion als Code.
Zeigt die Wertfunktionstabelle und die optimale Politik Schritt fuer Schritt.
"""
import numpy as np
GESAMT = 3 # zu verkaufende Einheiten
PERIODEN = 2 # Anzahl Verkaufsperioden
def kosten(menge: int) -> float:
"""Ueberproportionale Marktauswirkung: doppelte Menge kostet vierfach."""
return float(menge ** 2)
def loese_rueckwaerts():
# V[t, x] = minimale Restkosten, wenn zu Beginn von Periode t noch x Stueck offen sind
V = np.full((PERIODEN + 1, GESAMT + 1), np.inf)
politik = np.zeros((PERIODEN, GESAMT + 1), dtype=int)
# Endbedingung: nach der letzten Periode darf nichts mehr offen sein
V[PERIODEN, 0] = 0.0
print("=" * 70)
print(" RUECKWAERTSINDUKTION SCHRITT FUER SCHRITT")
print("=" * 70)
for t in range(PERIODEN - 1, -1, -1):
letzte_periode = (t == PERIODEN - 1)
print(f"\nStufe t = {t}" + (" (letzte Periode: alles muss weg)" if letzte_periode
else " (freie Wahl der Menge)"))
print(f" {'Zustand x':>10} | {'beste Aktion':>12} | {'Sofortkosten':>13} | "
f"{'V[t+1]':>9} | {'V[t]':>8}")
print(" " + "-" * 62)
for x in range(GESAMT + 1):
aktionen = [x] if letzte_periode else range(x + 1)
bester_wert, beste_aktion, beste_teile = np.inf, 0, (0.0, 0.0)
for n in aktionen:
rest = x - n
sofort = kosten(n)
zukunft = V[t + 1, rest]
gesamt = sofort + zukunft
if gesamt < bester_wert:
bester_wert, beste_aktion = gesamt, n
beste_teile = (sofort, zukunft)
V[t, x] = bester_wert
politik[t, x] = beste_aktion
print(f" {x:>10} | {beste_aktion:>12} | {beste_teile[0]:>13.1f} | "
f"{beste_teile[1]:>9.1f} | {bester_wert:>8.1f}")
return V, politik
if __name__ == "__main__":
V, politik = loese_rueckwaerts()
# --- Vorwaertspfad: der optimalen Politik folgen ---------------------
print("\n" + "=" * 70)
print(" OPTIMALER PFAD (Vorwaertssimulation)")
print("=" * 70)
bestand = GESAMT
gesamtkosten = 0.0
for t in range(PERIODEN):
aktion = politik[t, bestand]
gesamtkosten += kosten(aktion)
print(f" Periode {t}: Bestand {bestand} -> verkaufe {aktion} "
f"(Kosten {kosten(aktion):.1f}) -> Rest {bestand - aktion}")
bestand -= aktion
print(f"\n Gesamtkosten: {gesamtkosten:.1f} (V[0, {GESAMT}] = {V[0, GESAMT]:.1f})")
assert abs(gesamtkosten - V[0, GESAMT]) < 1e-9, "Pfadkosten != Wertfunktion!"
# --- Vergleich mit naiven Strategien ---------------------------------
alles_sofort = kosten(GESAMT)
print(f"\n Zum Vergleich - alles in Periode 0 verkaufen: {alles_sofort:.1f}")
print(f" Ersparnis durch Stueckelung: {alles_sofort - gesamtkosten:.1f} "
f"({(1 - gesamtkosten/alles_sofort)*100:.0f} %)")
print("=" * 70)

View file

@ -0,0 +1,202 @@
#!/usr/bin/env python3
# Benchmark_Skalierung.py
"""
Kapitel Testen: Ein Vergleich, dem man glauben kann.
Solververgleiche stehen in jedem Blog, und die meisten sind wertlos - nicht
weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird. Dieses
Programm misst dasselbe Transportproblem in drei Groessen mit vier
Bibliotheken und haelt sich dabei an fuenf Regeln, die den Unterschied machen:
1. EIGENER PROZESS je Bibliothek. Nicht nur wegen des Importkonflikts
zwischen ortools und highspy (Kapitel Oekosystem) - auch, damit der
Speicherverbrauch der einen nicht in der Messung der anderen auftaucht.
2. AUFBAU UND LOESEN GETRENNT messen. Bei grossen Instanzen ist der Aufbau
des Modells in Python regelmaessig teurer als das Loesen. Wer nur die
Gesamtzeit misst, optimiert am Ende die falsche Haelfte.
3. ZIELWERTE GEGENEINANDER PRUEFEN. Eine Bibliothek, die schneller ist und
etwas anderes ausrechnet, hat den Vergleich nicht gewonnen. Diese Pruefung
ist der wichtigste Teil des Programms.
4. SPEICHER MITMESSEN. Bei 10.000 Variablen entscheidet oft er und nicht die
Zeit darueber, was auf einer Maschine noch laeuft.
5. DIESELBE INSTANZ fuer alle. Feste Saat, kein Neuwuerfeln zwischendurch.
Und die Einschraenkung, die dazugehoert: Gemessen wird EIN Problemtyp in EINER
Formulierung auf EINER Maschine. Das Ergebnis ist keine Rangliste der Solver,
sondern eine Entscheidungshilfe fuer genau diesen Fall. Wer es verallgemeinert,
macht denselben Fehler wie jemand, der aus einem Backtest auf die Zukunft
schliesst (Kapitel Handelsmaschine).
Benoetigt: numpy; in den Kindprozessen scipy, highspy, ortools, cvxpy
"""
from __future__ import annotations
import json
import subprocess
import sys
import textwrap
GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen
# Jeder Eintrag ist ein eigenstaendiges Programm: Instanz aufbauen, loesen,
# Ergebnis als JSON ausgeben. Die Instanz wird in jedem Kindprozess aus
# derselben Saat neu erzeugt - so reist nichts ueber die Prozessgrenze,
# was das Ergebnis verfaelschen koennte.
VORSPANN = """
import json, time, resource
import numpy as np
def instanz(m, n):
rng = np.random.default_rng(20)
kosten = rng.integers(5, 95, (m, n)).astype(float)
angebot = rng.integers(50, 150, m).astype(float)
bedarf = angebot.sum() * rng.dirichlet(np.ones(n))
return kosten, angebot, bedarf
def speicher_mb():
# ru_maxrss ist unter Linux in Kilobyte
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
M, N = {m}, {n}
kosten, angebot, bedarf = instanz(M, N)
"""
ANSAETZE = {
"scipy.linprog": """
from scipy.optimize import linprog
t0 = time.perf_counter()
c = kosten.reshape(-1)
A_ub = np.zeros((M, M * N)); A_eq = np.zeros((N, M * N))
for i in range(M):
A_ub[i, i * N:(i + 1) * N] = 1.0
for j in range(N):
A_eq[j, j::N] = 1.0
aufbau = time.perf_counter() - t0
t0 = time.perf_counter()
r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,
bounds=(0, None), method="highs")
loesen = time.perf_counter() - t0
ausgabe = (float(r.fun), aufbau, loesen, speicher_mb())
""",
"highspy": """
import highspy
t0 = time.perf_counter()
h = highspy.Highs(); h.setOptionValue("output_flag", False)
h.addVars(M * N, np.zeros(M * N), np.full(M * N, highspy.kHighsInf))
for k in range(M * N):
h.changeColCost(k, float(kosten.reshape(-1)[k]))
for i in range(M):
idx = np.arange(i * N, (i + 1) * N, dtype=np.int32)
h.addRow(-highspy.kHighsInf, float(angebot[i]), N, idx, np.ones(N))
for j in range(N):
idx = np.arange(j, M * N, N, dtype=np.int32)
h.addRow(float(bedarf[j]), float(bedarf[j]), M, idx, np.ones(M))
aufbau = time.perf_counter() - t0
t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0
ausgabe = (h.getInfo().objective_function_value, aufbau, loesen, speicher_mb())
""",
"ortools/GLOP": """
from ortools.linear_solver import pywraplp
t0 = time.perf_counter()
s = pywraplp.Solver.CreateSolver("GLOP")
x = [[s.NumVar(0, s.infinity(), f"x{i}_{j}") for j in range(N)]
for i in range(M)]
for i in range(M):
s.Add(sum(x[i]) <= float(angebot[i]))
for j in range(N):
s.Add(sum(x[i][j] for i in range(M)) == float(bedarf[j]))
s.Minimize(sum(float(kosten[i, j]) * x[i][j]
for i in range(M) for j in range(N)))
aufbau = time.perf_counter() - t0
t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0
ausgabe = (s.Objective().Value(), aufbau, loesen, speicher_mb())
""",
"cvxpy": """
import cvxpy as cp
t0 = time.perf_counter()
x = cp.Variable((M, N), nonneg=True)
problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),
[cp.sum(x, axis=1) <= angebot,
cp.sum(x, axis=0) == bedarf])
aufbau = time.perf_counter() - t0
t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0
ausgabe = (float(problem.value), aufbau, loesen, speicher_mb())
""",
}
def messe(name: str, quelltext: str, m: int, n: int):
"""Fuehrt einen Ansatz in einem eigenen Prozess aus."""
programm = (VORSPANN.format(m=m, n=n) + textwrap.dedent(quelltext)
+ "\nprint(json.dumps(ausgabe))\n")
ergebnis = subprocess.run([sys.executable, "-c", programm],
capture_output=True, text=True, timeout=600)
if ergebnis.returncode != 0:
return None, ergebnis.stderr.strip().splitlines()[-1][:60]
return json.loads(ergebnis.stdout.strip().splitlines()[-1]), None
if __name__ == "__main__":
print("=" * 92)
print(" SKALIERUNGSVERGLEICH: TRANSPORTPROBLEM, VIER BIBLIOTHEKEN")
print("=" * 92)
print("Jede Zeile ein eigener Prozess. Zeiten und Speicher sind "
"hardwareabhaengig,")
print("die Zielwerte und ihr Verhaeltnis zueinander nicht.\n")
for m, n in GROESSEN:
kopf = f"--- {m} Lager x {n} Kunden = {m * n:,} Variablen "
print(kopf + "-" * max(3, 92 - len(kopf)))
print(f" {'Bibliothek':<16} {'Zielwert':>14} {'Aufbau':>9} "
f"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}")
print(" " + "-" * 72)
zielwerte = {}
for name, quelltext in ANSAETZE.items():
werte, fehler = messe(name, quelltext, m, n)
if werte is None:
print(f" {name:<16} nicht verfuegbar: {fehler}")
continue
ziel, aufbau, loesen, speicher = werte
zielwerte[name] = ziel
anteil = aufbau / (aufbau + loesen) * 100
print(f" {name:<16} {ziel:>14,.2f} {aufbau:>8.3f}s "
f"{loesen:>8.3f}s {anteil:>7.0f}% {speicher:>9.0f} MB")
# Die wichtigste Zeile: Rechnen alle dasselbe aus?
spanne = max(zielwerte.values()) - min(zielwerte.values())
bezug = max(abs(v) for v in zielwerte.values())
print(f" {'':16} Spannweite der Zielwerte: {spanne:.2e} "
f"(relativ {spanne / bezug:.1e})")
if spanne / bezug > 1e-6:
print(" ACHTUNG: Die Bibliotheken widersprechen sich - "
"der Zeitvergleich ist wertlos.")
print()
print("=" * 92)
print(" WAS MAN AUS SO EINER TABELLE ABLESEN DARF - UND WAS NICHT")
print("=" * 92)
print("DARF man ablesen:")
print(" * Die Spalte 'Anteil' - wie viel der Zeit in den AUFBAU geht statt")
print(" ins Loesen. Wenn dort 80 % stehen, ist ein schnellerer Solver die")
print(" falsche Antwort; dann gehoert das Modell vektorisiert aufgebaut")
print(" (Kapitel Oekosystem).")
print(" * Die Groessenordnung des Speicherbedarfs. Sie entscheidet, was auf")
print(" einer bestimmten Maschine ueberhaupt laeuft.")
print(" * Wie sich beides mit der Groesse ENTWICKELT. Der Trend ist")
print(" uebertragbarer als der Absolutwert.")
print()
print("NICHT ablesen darf man:")
print(" * 'Bibliothek X ist schneller als Y.' Gemessen wurde EIN")
print(" Problemtyp in EINER Formulierung. Ein MILP, ein QP oder eine")
print(" andere Modellierung desselben Problems koennen die Reihenfolge")
print(" umdrehen.")
print(" * Etwas ueber Ihre Maschine. Diese Zahlen stammen von einer")
print(" anderen. Der Sinn des Programms ist, dass Sie es auf Ihrer")
print(" laufen lassen.")
print("=" * 92)

View file

@ -0,0 +1,180 @@
#!/usr/bin/env python3
# Betriebsueberwachung.py
"""
Kapitel Praxisfallen: Das Modell, das still aufgehoert hat zu optimieren.
Ein Optimierungsmodell geht in Betrieb und laeuft jede Nacht. Es liefert
zuverlaessig eine Zahl, die Job-Dauer bleibt konstant, es gibt keine
Fehlermeldung. Alles sieht gut aus.
Waehrenddessen waechst das Unternehmen. Aus 15 Lagern werden 40, dann 80. Das
Zeitlimit von drei Sekunden - vor zwei Jahren grosszuegig bemessen - reicht
irgendwann nicht mehr. Der Solver bricht ab und liefert die beste Loesung, die
er bis dahin gefunden hat.
Das ist nicht falsch. Es ist sogar richtig so. Aber es ist etwas anderes als
das, was das Modell einmal geleistet hat - und niemand merkt es, weil im
Protokoll nur die Kosten stehen.
Dieses Programm zeigt die Verschlechterung und die Ueberwachung, die sie
sichtbar macht.
WICHTIG: ortools wird hier nicht importiert (Konflikt mit highspy, siehe
Kapitel Oekosystem).
Benoetigt: numpy, highspy
"""
from __future__ import annotations
import time
from dataclasses import dataclass
import numpy as np
import highspy
ZEITLIMIT = 3.0 # das Budget, das der Nachtjob hat
GAP_WARNSCHWELLE = 0.02 # ab 2 % Gap wollen wir es wissen
ZEITAUSLASTUNG_WARNSCHWELLE = 0.9
@dataclass
class Laufprotokoll:
"""Was nach JEDEM Produktivlauf protokolliert gehoert.
Die meisten Systeme schreiben nur 'kosten'. Genau deshalb faellt eine
schleichende Verschlechterung ueber Monate nicht auf.
"""
zeitpunkt: str
lager: int
kunden: int
status: str
kosten: float
gap: float
laufzeit: float
knoten: int
@property
def warnungen(self) -> list[str]:
meldungen = []
if self.status != "Optimal":
meldungen.append(f"nicht beweisbar optimal ({self.status})")
if self.gap > GAP_WARNSCHWELLE:
meldungen.append(f"Gap {self.gap:.1%} ueber Schwelle "
f"{GAP_WARNSCHWELLE:.0%}")
if self.laufzeit > ZEITAUSLASTUNG_WARNSCHWELLE * ZEITLIMIT:
meldungen.append(f"Zeitbudget zu {self.laufzeit / ZEITLIMIT:.0%} "
f"ausgeschoepft")
return meldungen
def plane_netzwerk(n_lager: int, n_kunden: int, zeitlimit: float,
saat: int = 7) -> tuple[str, float, float, float, int]:
"""Standortplanung wie in Kapitel MILP, nur mit wachsender Groesse."""
rng = np.random.default_rng(saat)
fixkosten = rng.uniform(3000, 9000, n_lager)
transport = rng.uniform(5, 60, (n_lager, n_kunden))
bedarf = rng.uniform(10, 60, n_kunden)
kapazitaet = np.full(n_lager, bedarf.sum() * 0.22)
modell = highspy.Highs()
modell.setOptionValue("output_flag", False)
modell.setOptionValue("time_limit", zeitlimit)
anzahl_x = n_lager * n_kunden
unendlich = highspy.kHighsInf
modell.addVars(anzahl_x, np.zeros(anzahl_x), np.full(anzahl_x, unendlich))
modell.addVars(n_lager, np.zeros(n_lager), np.ones(n_lager))
for i in range(n_lager):
modell.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
modell.changeColCost(anzahl_x + i, fixkosten[i])
for j in range(n_kunden):
modell.changeColCost(i * n_kunden + j, transport[i, j])
for j in range(n_kunden):
index = np.array([i * n_kunden + j for i in range(n_lager)], dtype=np.int32)
modell.addRow(bedarf[j], bedarf[j], len(index), index, np.ones(len(index)))
for i in range(n_lager):
index = np.array([i * n_kunden + j for j in range(n_kunden)] + [anzahl_x + i],
dtype=np.int32)
werte = np.concatenate([np.ones(n_kunden), [-kapazitaet[i]]])
modell.addRow(-unendlich, 0.0, len(index), index, werte)
t0 = time.perf_counter()
modell.run()
laufzeit = time.perf_counter() - t0
info = modell.getInfo()
return (modell.modelStatusToString(modell.getModelStatus()),
info.objective_function_value, info.mip_gap, laufzeit,
info.mip_node_count)
if __name__ == "__main__":
# So ist das Unternehmen ueber zwei Jahre gewachsen.
entwicklung = [("2024-Q1", 15, 40), ("2024-Q3", 25, 70),
("2025-Q1", 40, 110), ("2025-Q3", 60, 160),
("2026-Q1", 80, 220)]
print("=" * 84)
print(" WAS DAS PROTOKOLL ZEIGT - UND WAS ES ZEIGEN SOLLTE")
print("=" * 84)
print(f"Derselbe Nachtjob, unveraendert, mit {ZEITLIMIT:.0f} Sekunden Zeitlimit.\n")
protokolle = []
for zeitpunkt, lager, kunden in entwicklung:
status, kosten, gap, laufzeit, knoten = plane_netzwerk(
lager, kunden, ZEITLIMIT)
protokolle.append(Laufprotokoll(zeitpunkt, lager, kunden, status,
kosten, gap, laufzeit, knoten))
print("So sieht das ueblich gefuehrte Protokoll aus:\n")
print(f" {'Zeitpunkt':<10} {'Kosten':>12}")
print(" " + "-" * 24)
for p in protokolle:
print(f" {p.zeitpunkt:<10} {p.kosten:>12,.0f}")
print("\n -> Die Kosten steigen. Das Unternehmen waechst ja auch.")
print(" Nichts an dieser Tabelle deutet auf ein Problem hin.")
print("\n" + "-" * 84)
print("So sieht ein vollstaendiges Protokoll aus:\n")
print(f" {'Zeitpunkt':<10} {'Groesse':>10} {'Status':>20} {'Kosten':>11} "
f"{'Gap':>8} {'Zeit':>7}")
print(" " + "-" * 80)
for p in protokolle:
groesse = f"{p.lager}x{p.kunden}"
print(f" {p.zeitpunkt:<10} {groesse:>10} {p.status:>20} "
f"{p.kosten:>11,.0f} {p.gap * 100:7.2f} % {p.laufzeit:6.2f}s")
print("\n" + "-" * 84)
print("Und so sehen die Warnungen aus, die daraus folgen:\n")
for p in protokolle:
if p.warnungen:
print(f" {p.zeitpunkt}: " + "; ".join(p.warnungen))
else:
print(f" {p.zeitpunkt}: in Ordnung")
letzte = protokolle[-1]
erste_warnung = next(p for p in protokolle if p.warnungen)
print("\n" + "=" * 84)
print(" WAS DA PASSIERT IST")
print("=" * 84)
print(f"Seit {erste_warnung.zeitpunkt} erreicht der Job das Zeitlimit und liefert")
print("nicht mehr das Optimum, sondern die beste bis dahin gefundene Loesung.")
print(f"Im letzten Lauf betraegt der Abstand zum Bestmoeglichen "
f"{letzte.gap:.1%}.")
print()
print("Der Job ist nicht abgestuerzt. Er hat keine Fehlermeldung erzeugt.")
print("Die Laufzeit ist sogar bemerkenswert STABIL geblieben - genau deshalb,")
print("weil das Zeitlimit greift. Ein Ueberwachungssystem, das auf Abstuerze")
print("und Laufzeitspitzen achtet, sieht hier nichts.")
print()
print("Drei Zahlen gehoeren deshalb in jedes Protokoll eines Optimierungsjobs:")
print(" * der STATUS - 'Optimal' oder etwas anderes?")
print(" * der GAP - wie weit ist die Loesung vom Bestmoeglichen entfernt?")
print(" * die LAUFZEIT im Verhaeltnis zum Limit - wie nah am Anschlag?")
print()
print("Die dritte ist die frueheste Warnung: Sie steigt, lange bevor der Gap")
print("sichtbar wird, und gibt Zeit zum Handeln.")
print("=" * 84)

View file

@ -0,0 +1,194 @@
#!/usr/bin/env python3
# Big_M_Falle.py
"""
Kapitel MILP: Was ein zu grosses Big-M wirklich anrichtet.
Lehrbuecher warnen vor grossem M mit dem Hinweis "die Relaxation wird
schwach, der Solver braucht mehr Knoten". Das stimmt - ist aber die
harmlosere Haelfte der Wahrheit. Die gefaehrlichere: Bei sehr grossem M
kann der Solver eine Loesung als ganzzahlig ANNEHMEN, in der die
Binaervariablen bei 1e-8 stehen. Dann liefern zugeschaltete Anlagen Ware
aus, waehrend das Modell ihre Fixkosten mit 0 verbucht.
Beispiel: Standortplanung, 12 moegliche Lager, 40 Kunden.
min sum_i fix_i * y_i + sum_ij kosten_ij * x_ij
u.d.N. sum_i x_ij = bedarf_j (jeder Kunde beliefert)
sum_j x_ij <= M_i * y_i (Lager offen, wenn es liefert)
y_i binaer, x_ij >= 0
Zwei Laeufe:
1. M knapp gewaehlt (= tatsaechliche Lagerkapazitaet) -> richtig
2. M = 1e7-fach zu gross, Presolve abgeschaltet -> stilles Desaster
Beide werden mit derselben Pruefung kontrolliert, die den Fall auffliegen laesst.
Benoetigt: numpy, highspy
"""
from __future__ import annotations
import time
import numpy as np
import highspy
RNG = np.random.default_rng(11)
N_LAGER, N_KUNDE = 12, 40
FIXKOSTEN = RNG.uniform(2000, 5000, N_LAGER)
TRANSPORT = RNG.uniform(5, 40, (N_LAGER, N_KUNDE))
BEDARF = RNG.uniform(10, 60, N_KUNDE)
KAPAZITAET = BEDARF.sum() * 0.45 # jedes Lager schafft 45 % des Gesamtbedarfs
# Toleranz, ab der ein Solver eine Variable als ganzzahlig durchgehen laesst.
# HiGHS und die meisten anderen verwenden 1e-6 als Standard.
GANZZAHL_TOLERANZ = 1e-6
def loese(big_m: float, presolve: str = "on") -> dict:
"""Baut und loest das Standortmodell. Liefert Loesung und Solverkennzahlen."""
hochschule = highspy.Highs()
hochschule.setOptionValue("output_flag", False)
hochschule.setOptionValue("presolve", presolve)
hochschule.setOptionValue("time_limit", 300.0)
anzahl_x = N_LAGER * N_KUNDE
unendlich = highspy.kHighsInf
# Spalten: erst alle x_ij, dann die y_i
hochschule.addVars(anzahl_x, np.zeros(anzahl_x), np.full(anzahl_x, unendlich))
hochschule.addVars(N_LAGER, np.zeros(N_LAGER), np.ones(N_LAGER))
for i in range(N_LAGER):
hochschule.changeColIntegrality(anzahl_x + i,
highspy.HighsVarType.kInteger)
hochschule.changeColCost(anzahl_x + i, FIXKOSTEN[i])
for j in range(N_KUNDE):
hochschule.changeColCost(i * N_KUNDE + j, TRANSPORT[i, j])
# Jeder Kunde wird genau beliefert
for j in range(N_KUNDE):
index = np.array([i * N_KUNDE + j for i in range(N_LAGER)], dtype=np.int32)
hochschule.addRow(BEDARF[j], BEDARF[j], len(index), index,
np.ones(len(index)))
# Die Kopplung: sum_j x_ij - M * y_i <= 0
for i in range(N_LAGER):
index = np.array([i * N_KUNDE + j for j in range(N_KUNDE)] + [anzahl_x + i],
dtype=np.int32)
werte = np.concatenate([np.ones(N_KUNDE), [-big_m]])
hochschule.addRow(-unendlich, 0.0, len(index), index, werte)
t0 = time.perf_counter()
hochschule.run()
dauer = time.perf_counter() - t0
loesung = np.array(hochschule.getSolution().col_value)
info = hochschule.getInfo()
return {
"x": loesung[:anzahl_x].reshape(N_LAGER, N_KUNDE),
"y": loesung[anzahl_x:],
"zielwert": info.objective_function_value,
"knoten": info.mip_node_count,
"dauer": dauer,
}
def pruefe(ergebnis: dict) -> tuple[bool, list[str]]:
"""Die Pruefung, die in jedes MILP-Auswertungsskript gehoert.
Sie rechnet die Kosten AUS DER LOESUNG neu aus, statt dem Zielwert des
Solvers zu glauben - und vergleicht beide. Genau diese Gegenrechnung
entlarvt eine Loesung, in der Binaervariablen bei 1e-8 haengengeblieben
sind.
"""
beanstandungen = []
x, y = ergebnis["x"], ergebnis["y"]
# 1. Sind die Binaervariablen wirklich binaer?
abstand = np.abs(y - np.round(y))
if abstand.max() > GANZZAHL_TOLERANZ:
beanstandungen.append(
f"y ist nicht ganzzahlig: groesster Abstand {abstand.max():.2e}")
# 2. Liefert ein Lager, dessen Schalter aus ist?
liefert = x.sum(axis=1) > 1e-6
geschlossen_aber_aktiv = np.where(liefert & (y < 0.5))[0]
if geschlossen_aber_aktiv.size:
beanstandungen.append(
f"Lager {geschlossen_aber_aktiv.tolist()} liefern Ware, "
f"gelten im Modell aber als geschlossen")
# 3. Stimmt der Zielwert mit den echten Kosten ueberein?
echte_fixkosten = FIXKOSTEN[liefert].sum()
echte_transportkosten = float((TRANSPORT * x).sum())
echte_kosten = echte_fixkosten + echte_transportkosten
if abs(echte_kosten - ergebnis["zielwert"]) > 1e-4 * max(1.0, echte_kosten):
beanstandungen.append(
f"Zielwert {ergebnis['zielwert']:,.2f} weicht von den echten Kosten "
f"{echte_kosten:,.2f} ab (Differenz {echte_kosten - ergebnis['zielwert']:,.2f})")
return not beanstandungen, beanstandungen
def zeige(titel: str, ergebnis: dict) -> None:
x, y = ergebnis["x"], ergebnis["y"]
liefert = x.sum(axis=1) > 1e-6
print(f"\n{titel}")
print(f" Zielwert laut Solver {ergebnis['zielwert']:>14,.2f} EUR")
print(f" Knoten / Zeit {ergebnis['knoten']:>14,} "
f"/ {ergebnis['dauer']:.3f} s")
print(f" Lager mit y = 1 {int((y > 0.5).sum()):>14}")
print(f" Lager, die tatsaechlich liefern {int(liefert.sum()):>10}")
unter = y[y < 0.5]
print(f" groesster y-Wert unter 0.5 "
f"{(unter.max() if unter.size else 0.0):>14.3e}")
print(f" Fixkosten real / verbucht {FIXKOSTEN[liefert].sum():>14,.2f} "
f"/ {float((FIXKOSTEN * y).sum()):,.2f} EUR")
in_ordnung, beanstandungen = pruefe(ergebnis)
if in_ordnung:
print(" PRUEFUNG: bestanden")
else:
print(" PRUEFUNG: DURCHGEFALLEN")
for text in beanstandungen:
print(f" - {text}")
if __name__ == "__main__":
print("=" * 78)
print(" DIE BIG-M-FALLE: STANDORTPLANUNG, 12 LAGER, 40 KUNDEN")
print("=" * 78)
print(f"Tatsaechliche Lagerkapazitaet: {KAPAZITAET:,.1f} Einheiten.")
print("Genau das ist das kleinstmoegliche gueltige M - mehr kann ein Lager")
print("ohnehin nicht ausliefern.")
knapp = loese(KAPAZITAET)
zeige("[1] M = Kapazitaet (richtig gewaehlt)", knapp)
gross = loese(1e7 * KAPAZITAET, presolve="off")
zeige("[2] M = 10 Millionen mal Kapazitaet, Presolve abgeschaltet", gross)
print("\n" + "=" * 78)
print(" WAS DA PASSIERT IST")
print("=" * 78)
fehlbetrag = knapp["zielwert"] - gross["zielwert"]
print(f"Lauf [2] meldet {gross['zielwert']:,.2f} EUR und sieht damit um")
print(f"{fehlbetrag:,.2f} EUR BESSER aus als die richtige Loesung - ein Ergebnis,")
print("ueber das sich jeder Auftraggeber freuen wuerde.")
print()
print("Der Grund steht in der Zeile 'groesster y-Wert unter 0.5': Die")
print(f"Schaltervariablen stehen bei rund "
f"{gross['y'][gross['y'] < 0.5].max():.0e}.")
print(f"Das ist kleiner als die Ganzzahltoleranz {GANZZAHL_TOLERANZ:.0e}, also gilt")
print("y = 0 - 'Lager geschlossen'. Zugleich ist M so gross, dass")
print(" sum_j x_ij <= M * 1e-8")
print("immer noch reichlich Liefermenge erlaubt. Die Lager liefern also,")
print("ohne dass ihre Fixkosten je bezahlt werden. Der Fachbegriff dafuer")
print("ist 'trickle flow'.")
print()
print("WICHTIG: Mit eingeschaltetem Presolve (Standard) faellt HiGHS hier")
print("nicht darauf herein - es zieht M selbst zurecht. Verlassen Sie sich")
print("nicht darauf: Presolve kann das nur, wenn eine implizite Schranke")
print("herleitbar ist. Die Pruefung aus pruefe() kostet Millisekunden und")
print("funktioniert immer.")
print("=" * 78)

View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
# Bot_Allokation.py
"""
Kapitel Einfuehrung: Erstes Optimierungsmodell mit Google OR-Tools (CP-SAT).
Problem: Server-Allokation für Trading-Bots.
Modell mit expliziter Nebenbedingung statt versteckter Variablengrenze,
vollständiger Statusauswertung und Ergebnisprüfung.
"""
from ortools.sat.python import cp_model
# --- Parameter (die "Fakten" des Problems) ---------------------------------
GEWINN_A, GEWINN_B = 150, 250 # Euro pro Bot und Tag
CPU_A, CPU_B = 2, 5 # vCPU-Bedarf je Bot
RAM_A, RAM_B = 4, 6 # GB Arbeitsspeicher je Bot
CPU_GESAMT, RAM_GESAMT = 40, 60 # verfügbare Kapazitäten
MAX_ARBITRAGE = 8 # Marktliquiditätsgrenze
def loese_bot_allokation(zeitlimit_s: float = 10.0):
"""Baut das Modell, löst es und gibt eine Auswertung aus."""
# 1. Modell instanziieren
modell = cp_model.CpModel()
# 2. Entscheidungsvariablen anlegen
# NewIntVar(untere_grenze, obere_grenze, name)
# Die obere Grenze ist bewusst großzügig; die echte Beschränkung
# formulieren wir unten als Nebenbedingung, damit das Modell die
# mathematische Formulierung 1:1 abbildet.
x_a = modell.NewIntVar(0, 100, "Arbitrage_Bots")
x_b = modell.NewIntVar(0, 100, "Trend_Bots")
# 3. Nebenbedingungen definieren
c_cpu = modell.Add(CPU_A * x_a + CPU_B * x_b <= CPU_GESAMT) # vCPU-Limit
c_ram = modell.Add(RAM_A * x_a + RAM_B * x_b <= RAM_GESAMT) # RAM-Limit
c_liq = modell.Add(x_a <= MAX_ARBITRAGE) # Marktliquidität
# 4. Zielfunktion: Maximiere den Tagesgewinn
modell.Maximize(GEWINN_A * x_a + GEWINN_B * x_b)
# 5. Solver konfigurieren und ausführen
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = zeitlimit_s
status = loeser.Solve(modell)
# 6. Status auswerten -- IMMER alle Fälle behandeln
status_text = loeser.StatusName(status)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
print(f"Keine verwertbare Lösung. Solver-Status: {status_text}")
if status == cp_model.INFEASIBLE:
print("Das Modell ist unlösbar - die Bedingungen widersprechen sich.")
return None
a, b = loeser.Value(x_a), loeser.Value(x_b)
gewinn = loeser.ObjectiveValue()
# 7. Ergebnis ausgeben
print("=" * 58)
print(" OPTIMALE BOT-ALLOKATION")
print("=" * 58)
print(f"Solver-Status: {status_text}"
f"{' (beweisbar optimal)' if status == cp_model.OPTIMAL else ' (zulässig, nicht bewiesen)'}")
print(f"Arbitrage-Bots (x_A): {a} Instanzen")
print(f"Trendfolge-Bots (x_B): {b} Instanzen")
print(f"Täglicher Max-Gewinn: {gewinn:,.2f} EUR")
genutzt_cpu = CPU_A * a + CPU_B * b
genutzt_ram = RAM_A * a + RAM_B * b
print("-" * 58)
print(f"CPU-Auslastung: {genutzt_cpu} / {CPU_GESAMT} vCPUs "
f"({genutzt_cpu / CPU_GESAMT * 100:.1f} %)")
print(f"RAM-Auslastung: {genutzt_ram} / {RAM_GESAMT} GB "
f"({genutzt_ram / RAM_GESAMT * 100:.1f} %)")
print(f"Arbitrage-Limit: {a} / {MAX_ARBITRAGE}")
# 8. Selbstkontrolle: Sind wirklich alle Bedingungen eingehalten?
assert genutzt_cpu <= CPU_GESAMT, "vCPU-Limit verletzt!"
assert genutzt_ram <= RAM_GESAMT, "RAM-Limit verletzt!"
assert a <= MAX_ARBITRAGE, "Arbitrage-Limit verletzt!"
# 9. Zum Vergleich: die naive Strategie "nur der lukrativere Bot"
naiv_b = min(CPU_GESAMT // CPU_B, RAM_GESAMT // RAM_B)
naiv_gewinn = GEWINN_B * naiv_b
print("-" * 58)
print(f"Naive Strategie (nur Trendfolge): {naiv_b} Bots -> {naiv_gewinn:,.2f} EUR")
print(f"Vorteil der Optimierung: {gewinn - naiv_gewinn:,.2f} EUR pro Tag "
f"({(gewinn / naiv_gewinn - 1) * 100:.1f} %)")
print("=" * 58)
return a, b, gewinn
if __name__ == "__main__":
loese_bot_allokation()

View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
# Brute_Force_Vergleich.py
"""
Kapitel Einfuehrung: Warum Ausprobieren scheitert.
Vergleicht vollständige Enumeration mit einem Constraint-Solver an einem
Zuordnungsproblem wachsender Größe (n Mitarbeiter auf n Aufgaben).
"""
import itertools
import math
import time
import numpy as np
from ortools.sat.python import cp_model
def erzeuge_kostenmatrix(n: int, seed: int = 7) -> np.ndarray:
"""Zufällige, aber reproduzierbare Kosten: Wer bearbeitet welche Aufgabe wie teuer?"""
rng = np.random.default_rng(seed)
return rng.integers(low=10, high=100, size=(n, n))
def loese_brute_force(kosten: np.ndarray) -> tuple[float, tuple, int]:
"""
Probiert ALLE n! Zuordnungen durch und behält die beste.
Rückgabe: (bester Kostenwert, beste Permutation, Anzahl geprüfter Kombinationen)
"""
n = len(kosten)
bester_wert = math.inf
beste_zuordnung = None
geprueft = 0
# itertools.permutations(range(n)) liefert nacheinander jede Reihenfolge
for zuordnung in itertools.permutations(range(n)):
# zuordnung[i] = Aufgabe, die Mitarbeiter i übernimmt
wert = sum(kosten[i][zuordnung[i]] for i in range(n))
geprueft += 1
if wert < bester_wert:
bester_wert = wert
beste_zuordnung = zuordnung
return bester_wert, beste_zuordnung, geprueft
def loese_mit_solver(kosten: np.ndarray) -> tuple[float, tuple]:
"""Dasselbe Problem als Constraint-Programm — der Solver probiert NICHT alles durch."""
n = len(kosten)
modell = cp_model.CpModel()
# x[i][j] = 1 <=> Mitarbeiter i übernimmt Aufgabe j
x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(n)] for i in range(n)]
for i in range(n):
modell.AddExactlyOne(x[i][j] for j in range(n)) # jeder genau eine Aufgabe
for j in range(n):
modell.AddExactlyOne(x[i][j] for i in range(n)) # jede Aufgabe genau einmal
modell.Minimize(sum(int(kosten[i][j]) * x[i][j] for i in range(n) for j in range(n)))
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = 30.0
status = loeser.Solve(modell)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
raise RuntimeError("Solver fand keine Lösung.")
zuordnung = tuple(
next(j for j in range(n) if loeser.Value(x[i][j]) == 1) for i in range(n)
)
return loeser.ObjectiveValue(), zuordnung
if __name__ == "__main__":
print("=" * 72)
print(" VOLLSTÄNDIGES DURCHPROBIEREN vs. OPERATIONS RESEARCH")
print("=" * 72)
print(f"{'n':>3} | {'Kombinationen':>18} | {'Brute Force':>14} | {'Solver':>10} | gleich?")
print("-" * 72)
for n in [4, 6, 8, 9, 10]: # n = 11 dauert bereits ~2 Minuten
kosten = erzeuge_kostenmatrix(n)
t0 = time.perf_counter()
wert_bf, zuord_bf, geprueft = loese_brute_force(kosten)
zeit_bf = time.perf_counter() - t0
t0 = time.perf_counter()
wert_or, zuord_or = loese_mit_solver(kosten)
zeit_or = time.perf_counter() - t0
gleich = "ja" if abs(wert_bf - wert_or) < 1e-9 else "NEIN!"
print(f"{n:>3} | {geprueft:>18,} | {zeit_bf:>11.4f} s | {zeit_or:>7.4f} s | {gleich}")
print("-" * 72)
# Hochrechnung: Wie lange bräuchte Brute Force bei n = 20?
kombis_20 = math.factorial(20)
# Messbasis: Prüfungen pro Sekunde aus dem letzten Lauf schätzen
pro_sekunde = geprueft / max(zeit_bf, 1e-9)
jahre = kombis_20 / pro_sekunde / (60 * 60 * 24 * 365.25)
print(f"Hochrechnung für n = 20: {kombis_20:,} Kombinationen")
print(f"Bei gemessenen {pro_sekunde:,.0f} Prüfungen/s wären das {jahre:,.0f} Jahre.")
print("Der Solver löst dieselbe Instanz in Sekundenbruchteilen.")
print("=" * 72)

View file

@ -0,0 +1,223 @@
#!/usr/bin/env python3
# CP_SAT_Statusfaelle.py
"""
Kapitel CP-SAT: Alle fuenf Antworten von CP-SAT - und was jede bedeutet.
Der haeufigste Fehler beim Einsatz von CP-SAT im Betrieb ist eine Zeile wie
if status == cp_model.OPTIMAL:
...verwerte die Loesung...
Sie wirft in drei von fuenf Faellen etwas weg, das man haette gebrauchen
koennen, und verschweigt in einem weiteren Fall einen Modellierungsfehler.
Dieses Programm erzeugt jeden Statusfall absichtlich und zeigt die passende
Reaktion:
OPTIMAL beweisbar bestmoeglich -> ausfuehren
FEASIBLE zulaessig, Zeit war um -> Gap pruefen, dann entscheiden
INFEASIBLE kein Plan existiert -> harte Regeln lockern
MODEL_INVALID das Modell ist fehlerhaft -> Programmierfehler beheben
UNKNOWN nichts gefunden, Zeit war um -> mehr Zeit oder Heuristik
Benoetigt: numpy, ortools
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from ortools.sat.python import cp_model
@dataclass
class Auswertung:
"""Was nach einem CP-SAT-Lauf tatsaechlich feststeht."""
status: str
hat_loesung: bool
beweisbar_optimal: bool
zielwert: float | None
schranke: float | None
gap: float | None
empfehlung: str
def werte_aus(loeser: cp_model.CpSolver, status: int,
mit_zielfunktion: bool) -> Auswertung:
"""Uebersetzt einen CP-SAT-Status in eine Handlungsempfehlung.
Genau diese Funktion fehlt in den meisten Projekten. Sie ist kurz, aber
sie ist der Unterschied zwischen einem Prototyp und einem System, das
nachts ohne Aufsicht laeuft.
"""
name = loeser.StatusName(status)
hat_loesung = status in (cp_model.OPTIMAL, cp_model.FEASIBLE)
zielwert = schranke = gap = None
if hat_loesung and mit_zielfunktion:
zielwert = loeser.ObjectiveValue()
schranke = loeser.BestObjectiveBound()
nenner = max(abs(zielwert), 1e-9)
gap = abs(zielwert - schranke) / nenner
if status == cp_model.OPTIMAL:
empfehlung = "Ausfuehren. Besser geht es nachweislich nicht."
elif status == cp_model.FEASIBLE:
empfehlung = (f"Brauchbar. Der Plan ist hoechstens {gap * 100:.2f} % "
f"schlechter als das theoretisch Bestmoegliche."
if gap is not None else
"Brauchbar, aber nicht als optimal bewiesen.")
elif status == cp_model.INFEASIBLE:
empfehlung = ("KEIN Plan existiert. Das ist eine Aussage ueber Ihre "
"harten Regeln, nicht ueber die Rechenzeit: Auch "
"unendlich viel Zeit wuerde nichts aendern. Regeln "
"lockern oder weich machen (Anhang C).")
elif status == cp_model.MODEL_INVALID:
empfehlung = ("Das MODELL ist fehlerhaft, nicht das Problem. "
"Typisch: eine Variable mit leerem Wertebereich. "
"model.Validate() nennt die Ursache.")
else: # UNKNOWN
empfehlung = ("Nichts gefunden - aber auch nicht widerlegt. Mehr Zeit "
"geben, das Modell vereinfachen oder mit einer "
"Heuristik vorbelegen (AddHint).")
return Auswertung(
status=name,
hat_loesung=hat_loesung,
beweisbar_optimal=status == cp_model.OPTIMAL,
zielwert=zielwert, schranke=schranke, gap=gap,
empfehlung=empfehlung,
)
def loese(baue, zeitlimit: float, mit_zielfunktion: bool) -> Auswertung:
modell = cp_model.CpModel()
baue(modell)
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = zeitlimit
# Fuer reproduzierbare Buchausgaben; im Betrieb weglassen.
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1
status = loeser.Solve(modell)
return werte_aus(loeser, status, mit_zielfunktion)
# --- Die fuenf Faelle --------------------------------------------------------
def dienstplan_loesbar(modell: cp_model.CpModel) -> None:
"""Vier Personen, fuenf Dienste, hoechstens zwei je Person - loesbar."""
personen, dienste = 4, 5
x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
for i in range(personen)]
for j in range(dienste):
modell.AddExactlyOne(x[i][j] for i in range(personen))
for i in range(personen):
modell.Add(sum(x[i]) <= 2)
def dienstplan_unloesbar(modell: cp_model.CpModel) -> None:
"""Dieselben fuenf Dienste, aber hoechstens EIN Dienst je Person.
Vier Personen koennen zusammen hoechstens vier Dienste uebernehmen - fuer
fuenf Dienste reicht das nicht. Kein Solver der Welt findet hier etwas.
"""
personen, dienste = 4, 5
x = [[modell.NewBoolVar(f"x_{i}_{j}") for j in range(dienste)]
for i in range(personen)]
for j in range(dienste):
modell.AddExactlyOne(x[i][j] for i in range(personen))
for i in range(personen):
modell.Add(sum(x[i]) <= 1)
def modell_fehlerhaft(modell: cp_model.CpModel) -> None:
"""Eine Variable mit leerem Wertebereich: untere Schranke > obere.
Im Alltag entsteht das durch einen Rechenfehler in den Grenzen, etwa
NewIntVar(mindestbesetzung, kapazitaet, ...) bei falsch sortierten Daten.
"""
kaputt = modell.NewIntVar(5, 2, "leerer_bereich")
modell.Add(kaputt >= 0)
def lastverteilung(modell: cp_model.CpModel) -> None:
"""120 Auftraege auf 11 Maschinen - gross genug, dass der Optimalitaets-
beweis laenger dauert als das Finden einer sehr guten Loesung."""
rng = np.random.default_rng(3)
dauer = rng.integers(100, 9000, 120)
n, maschinen = len(dauer), 11
obergrenze = int(dauer.sum())
x = [[modell.NewBoolVar(f"x_{i}_{k}") for k in range(maschinen)]
for i in range(n)]
for i in range(n):
modell.AddExactlyOne(x[i])
belegung = [modell.NewIntVar(0, obergrenze, f"l_{k}") for k in range(maschinen)]
for k in range(maschinen):
modell.Add(belegung[k] == sum(int(dauer[i]) * x[i][k] for i in range(n)))
makespan = modell.NewIntVar(0, obergrenze, "makespan")
modell.AddMaxEquality(makespan, belegung)
modell.Minimize(makespan)
def zahlpartition(modell: cp_model.CpModel) -> None:
"""46 grosse Zahlen exakt in zwei gleich schwere Haelften teilen.
Ein beruehmt schweres Problem: Es gibt keine Zielfunktion, entweder es
geht auf oder nicht - und beides zu entscheiden dauert lange.
"""
rng = np.random.default_rng(1)
gewichte = rng.integers(10**6, 2 * 10**6, 46)
x = [modell.NewBoolVar(f"x_{i}") for i in range(len(gewichte))]
modell.Add(sum(int(gewichte[i]) * x[i] for i in range(len(gewichte)))
== int(gewichte.sum() // 2))
def zeige(titel: str, a: Auswertung) -> None:
print(f"\n{titel}")
print(f" Status {a.status}")
print(f" Loesung vorhanden {'ja' if a.hat_loesung else 'nein'}")
if a.zielwert is not None:
print(f" Zielwert / Schranke {a.zielwert:,.0f} / {a.schranke:,.0f}"
f" (Gap {a.gap * 100:.3f} %)")
print(f" -> {a.empfehlung}")
if __name__ == "__main__":
print("=" * 78)
print(" DIE FUENF ANTWORTEN VON CP-SAT")
print("=" * 78)
zeige("[1] Dienstplan, hoechstens 2 Dienste je Person",
loese(dienstplan_loesbar, 10.0, mit_zielfunktion=False))
zeige("[2] Lastverteilung 120 Auftraege / 11 Maschinen, 0,5 s Limit",
loese(lastverteilung, 0.5, mit_zielfunktion=True))
zeige("[3] Derselbe Fall mit 10 s Limit",
loese(lastverteilung, 10.0, mit_zielfunktion=True))
zeige("[4] Dienstplan, hoechstens 1 Dienst je Person",
loese(dienstplan_unloesbar, 10.0, mit_zielfunktion=False))
zeige("[5] Variable mit leerem Wertebereich",
loese(modell_fehlerhaft, 10.0, mit_zielfunktion=False))
zeige("[6] Zahlpartition mit 46 grossen Zahlen, 2 s Limit",
loese(zahlpartition, 2.0, mit_zielfunktion=False))
print("\n" + "=" * 78)
print(" DIE DREI, DIE MAN NICHT VERWECHSELN DARF")
print("=" * 78)
print("INFEASIBLE Es gibt keine Loesung. Eine Aussage ueber Ihr MODELL.")
print(" Mehr Rechenzeit aendert daran nichts.")
print("UNKNOWN Es wurde keine gefunden. Eine Aussage ueber die ZEIT.")
print(" Ob es eine gibt, ist offen.")
print("MODEL_INVALID Das Modell ist gar kein gueltiges Modell. Eine")
print(" Aussage ueber Ihren CODE - immer ein Programmierfehler.")
print()
print("Wer diese drei in einem 'else: return None' zusammenfasst, verliert")
print("genau die Information, die zur Fehlersuche noetig waere.")
print("=" * 78)

View file

@ -0,0 +1,242 @@
#!/usr/bin/env python3
# CP_SAT_Vertretungssystem.py
"""
Kapitel CP-SAT: Dynamisches Vertretungs- und Einsatzplanungssystem mit CP-SAT.
Eigenschaften:
* Solver-Status wird ueber SolverStatus aus or_kern.py ausgewertet - dieselbe
Fallunterscheidung wie bei jedem anderen Solver im Buch
* Strafkosten-Zerlegung wird ausgewiesen (Erklaerbarkeit, Kapitel Praxisfallen)
* Fairness-Kriterium ergaenzt (Spannweite der Belastung minimieren)
* Vorabdiagnose auf offensichtliche Unloesbarkeit
* Abnahmepruefung des fertigen Plans OHNE den Solver zu fragen
Benoetigt: ortools, pandas, pydantic (ueber or_kern)
"""
from __future__ import annotations
from ortools.sat.python import cp_model
import pandas as pd
from or_kern import Loesung, SolverStatus, status_von_cpsat
# --- 1. Datenbasis --------------------------------------------------------
SLOTS = [1, 2, 3, 4]
FAECHER = ["Mathematik", "Physik", "Mathematik", "Informatik"]
KLASSEN = ["Klasse 8a", "Klasse 10b", "Klasse 7a", "Klasse 9c"]
PERSONAL = ["Frau_Mueller", "Herr_Schmidt", "Frau_Albrecht", "Herr_Bauer", "Frau_Koch"]
QUALIFIKATION = {
"Frau_Mueller": {"Mathematik", "Physik"},
"Herr_Schmidt": {"Informatik", "Mathematik"},
"Frau_Albrecht": {"Physik", "Informatik"},
"Herr_Bauer": {"Mathematik"},
"Frau_Koch": {"Mathematik", "Physik", "Informatik"},
}
VORBELASTUNG = { # bereits geleistete Stunden heute
"Frau_Mueller": 1, "Herr_Schmidt": 0, "Frau_Albrecht": 2,
"Herr_Bauer": 0, "Frau_Koch": 1,
}
MAX_VERTRETUNGEN = 2
STRAFE_VORBELASTUNG = 50 # je Stunde Vorbelastung und Zuweisung
STRAFE_LOCH = 80 # je Freistundenloch
STRAFE_UNFAIRNESS = 30 # je Einheit Spannweite der Gesamtbelastung
def pruefe_grundsaetzliche_loesbarkeit() -> bool:
"""Vorabdiagnose: Gibt es fuer jeden Slot ueberhaupt qualifiziertes Personal?"""
ok = True
for s, fach in enumerate(FAECHER):
kandidaten = [p for p in PERSONAL if fach in QUALIFIKATION[p]]
if not kandidaten:
print(f" UNLOESBAR: Fuer Slot {SLOTS[s]} ({fach}) gibt es niemanden.")
ok = False
kapazitaet = len(PERSONAL) * MAX_VERTRETUNGEN
if kapazitaet < len(SLOTS):
print(f" UNLOESBAR: Kapazitaet {kapazitaet} < {len(SLOTS)} offene Stunden.")
ok = False
return ok
def pruefe_plan(plan: dict[int, str]) -> list[str]:
"""Prueft den fertigen Plan gegen die harten Regeln - ohne den Solver.
Der Solver kann nur pruefen, was ihm gesagt wurde. Diese Funktion prueft
gegen die ANFORDERUNG und benutzt dafuer bewusst keine Modellvariable
(siehe Kapitel Praxisfallen). Leere Liste heisst bestanden.
"""
beanstandungen: list[str] = []
if sorted(plan) != list(range(len(SLOTS))):
beanstandungen.append(f"nicht jede Stunde genau einmal besetzt: {sorted(plan)}")
return beanstandungen
for s, person in plan.items():
if FAECHER[s] not in QUALIFIKATION[person]:
beanstandungen.append(f"{person} ist nicht fuer {FAECHER[s]} qualifiziert")
for person in PERSONAL:
anzahl = sum(1 for p in plan.values() if p == person)
if anzahl > MAX_VERTRETUNGEN:
beanstandungen.append(f"{person} hat {anzahl} Vertretungen "
f"(hoechstens {MAX_VERTRETUNGEN})")
return beanstandungen
def plane_vertretung() -> list[dict[str, str]] | None:
if not pruefe_grundsaetzliche_loesbarkeit():
return None
modell = cp_model.CpModel()
# Entscheidungsvariablen: x[person, slot] = 1 <=> Person uebernimmt Slot
x = {(p, s): modell.NewBoolVar(f"zuweisung_{p}_slot{s+1}")
for p in PERSONAL for s in range(len(SLOTS))}
# --- HARTE NEBENBEDINGUNGEN ------------------------------------------
# H1: Jede Stunde genau einmal besetzen
for s in range(len(SLOTS)):
modell.AddExactlyOne(x[p, s] for p in PERSONAL)
# H2: Qualifikation - unqualifizierte Zuweisung ausschliessen
for s, fach in enumerate(FAECHER):
for p in PERSONAL:
if fach not in QUALIFIKATION[p]:
modell.Add(x[p, s] == 0)
# H3: Hoechstens MAX_VERTRETUNGEN Stunden je Person
for p in PERSONAL:
modell.Add(sum(x[p, s] for s in range(len(SLOTS))) <= MAX_VERTRETUNGEN)
# (H4 "nicht an zwei Orten gleichzeitig" ist durch H1 bereits erfuellt:
# jeder Slot hat genau eine Person, und eine Person kann pro Slot nur
# eine Variable auf 1 setzen.)
# --- WEICHE ZIELE ----------------------------------------------------
strafterme = []
# W1: Vorbelastete Personen schonen
strafe_last = []
for p in PERSONAL:
zuweisungen = sum(x[p, s] for s in range(len(SLOTS)))
strafe_last.append(VORBELASTUNG[p] * STRAFE_VORBELASTUNG * zuweisungen)
strafterme.extend(strafe_last)
# W2: Freistundenloecher vermeiden (Muster: arbeitet, frei, arbeitet)
loch_variablen = []
for p in PERSONAL:
for s in range(len(SLOTS) - 2):
loch = modell.NewBoolVar(f"loch_{p}_{s}")
# Reifizierung: loch == 1 <=> (x_s AND NOT x_{s+1} AND x_{s+2})
modell.AddBoolAnd([x[p, s], x[p, s + 1].Not(), x[p, s + 2]]).OnlyEnforceIf(loch)
modell.AddBoolOr([x[p, s].Not(), x[p, s + 1], x[p, s + 2].Not()]) \
.OnlyEnforceIf(loch.Not())
loch_variablen.append(loch)
strafterme.append(loch * STRAFE_LOCH)
# W3: Fairness - Spannweite der Gesamtbelastung minimieren
gesamtlast = {}
for p in PERSONAL:
last = modell.NewIntVar(0, len(SLOTS) + max(VORBELASTUNG.values()), f"last_{p}")
modell.Add(last == VORBELASTUNG[p] + sum(x[p, s] for s in range(len(SLOTS))))
gesamtlast[p] = last
max_last = modell.NewIntVar(0, 10, "max_last")
min_last = modell.NewIntVar(0, 10, "min_last")
modell.AddMaxEquality(max_last, list(gesamtlast.values()))
modell.AddMinEquality(min_last, list(gesamtlast.values()))
spannweite = modell.NewIntVar(0, 10, "spannweite")
modell.Add(spannweite == max_last - min_last)
strafterme.append(spannweite * STRAFE_UNFAIRNESS)
modell.Minimize(sum(strafterme))
# --- LOESEN -----------------------------------------------------------
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = 5.0
# Reproduzierbarkeit: ein Arbeiter, fester Startwert. Ohne das kann CP-SAT
# bei mehreren gleich guten Plaenen von Lauf zu Lauf einen anderen liefern
# (siehe JobShop_Intervalle.py im naechsten Abschnitt).
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1
rohstatus = loeser.Solve(modell)
# Der Rueckgabewert wird in die gemeinsame Sprache uebersetzt. Ab hier
# sieht die Auswertung genauso aus wie bei HiGHS, GLOP oder CVXPY.
status = status_von_cpsat(rohstatus)
loesung = Loesung(
status=status,
zielwert=loeser.ObjectiveValue() if status.brauchbar else None,
schranke=loeser.BestObjectiveBound() if status.brauchbar else None,
laufzeit=loeser.WallTime())
print("=" * 78)
print(" DYNAMISCHER VERTRETUNGSPLAN (CP-SAT OPTIMIERT)")
print("=" * 78)
if status.modellfehler:
print(f"Kein zulaessiger Plan moeglich (Status: {status.value}).")
print("Die harten Regeln widersprechen sich. Naechster Schritt: eine Regel")
print("weich machen (Kapitel Praxisfallen) oder den Konflikt eingrenzen")
print("(Anhang Fehlerdiagnose).")
return None
if not status.brauchbar:
print(f"Solver lieferte kein Ergebnis (Status: {status.value}).")
print("Zeitlimit erhoehen oder das Modell vereinfachen.")
return None
guete = ("beweisbar optimal" if status is SolverStatus.OPTIMAL
else "zulaessig, aber nicht bewiesen - Gap beachten")
print(f"Solver-Status: {loeser.StatusName(rohstatus)} -> {status.value} ({guete})")
print(f"{loesung.als_bericht()}\n")
# --- Plan ausgeben ----------------------------------------------------
zuweisung = {s: next(p for p in PERSONAL if loeser.Value(x[p, s]) == 1)
for s in range(len(SLOTS))}
plan = [{
"Stunde": f"Std. {SLOTS[s]}",
"Klasse": KLASSEN[s],
"Fach": FAECHER[s],
"Vertretung": zuweisung[s],
"Vorbelastung": f"{VORBELASTUNG[zuweisung[s]]} Std.",
} for s in range(len(SLOTS))]
print(pd.DataFrame(plan).to_string(index=False))
# --- Abnahmepruefung gegen die Anforderung ----------------------------
beanstandungen = pruefe_plan(zuweisung)
if beanstandungen:
print("\nABNAHMEPRUEFUNG FEHLGESCHLAGEN:")
for beanstandung in beanstandungen:
print(f" - {beanstandung}")
return None
print("\nAbnahmepruefung: bestanden (Besetzung, Qualifikation, Hoechstzahl).")
# --- Erklaerbarkeit: Strafkosten aufschluesseln -----------------------
kosten_last = sum(VORBELASTUNG[p] * STRAFE_VORBELASTUNG
* sum(loeser.Value(x[p, s]) for s in range(len(SLOTS)))
for p in PERSONAL)
kosten_loecher = sum(loeser.Value(v) for v in loch_variablen) * STRAFE_LOCH
kosten_fairness = loeser.Value(spannweite) * STRAFE_UNFAIRNESS
print("\n--- Woraus bestehen die Strafkosten? ---")
print(f" Vorbelastung geschont: {kosten_last:5.0f} Punkte")
print(f" Freistundenloecher: {kosten_loecher:5.0f} Punkte "
f"({sum(loeser.Value(v) for v in loch_variablen)} Loch/Loecher)")
print(f" Fairness (Spannweite {loeser.Value(spannweite)}): {kosten_fairness:5.0f} Punkte")
print(f" {'Summe':<23} {loeser.ObjectiveValue():5.0f} Punkte")
print("\n--- Auslastung nach Optimierung ---")
for p in PERSONAL:
heute = sum(loeser.Value(x[p, s]) for s in range(len(SLOTS)))
print(f" * {p:<15}: {VORBELASTUNG[p]} vorher + {heute} Vertretung "
f"= {VORBELASTUNG[p] + heute} Stunden")
print(f"\nSuchstatistik: {loeser.NumBranches()} Verzweigungen, "
f"{loeser.NumConflicts()} Konflikte, {loeser.WallTime():.3f} s")
print("=" * 78)
return plan
if __name__ == "__main__":
plane_vertretung()

View file

@ -0,0 +1,188 @@
#!/usr/bin/env python3
# CVaR_Portfolio.py
"""
Kapitel CVaR: CVaR-Optimierung mit L1-Transaktionskosten via CVXPY.
Eigenschaften:
* Spaltenreihenfolge erzwungen
* Einheiten konsistent (alles taeglich, Annualisierung nur in der Ausgabe)
* Szenario-Nebenbedingungen VEKTORISIERT statt in einer Python-Schleife
(eine Matrixbedingung statt S einzelner Constraints - deutlich schneller)
* Vergleich CVaR- gegen Varianz-Optimierung
* Nachrechnung von VaR/CVaR aus den realisierten Szenarien
"""
import time
import cvxpy as cp
import numpy as np
import pandas as pd
import yfinance as yf
TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
ALPHA = 0.95 # Konfidenzniveau: schlechteste 5 % der Tage
MAX_GEWICHT = 0.25
GEBUEHRENSATZ = 0.002 # 0,2 % je Einheit Turnover (Spread + Brokerage)
RISIKOAVERSION = 1.5 # bezogen auf TAEGLICHE Groessen
HANDELSTAGE = 252
def lade_renditen():
ende = pd.Timestamp.today().normalize()
start = ende - pd.DateOffset(years=2)
roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
if roh.empty:
raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
if isinstance(roh.columns, pd.MultiIndex):
kurse = roh["Close"][TICKER].dropna() # erzwingt eigene Spaltenreihenfolge
else:
kurse = roh[["Close"]].dropna()
kurse.columns = TICKER
assert list(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
return kurse.pct_change().dropna()
def optimiere_cvar(R, w_alt, vektorisiert=True):
"""
Maximiere: taegliche Rendite - lambda * CVaR - Transaktionskosten
Alle Groessen TAEGLICH.
"""
S, N = R.shape
mu_taeglich = R.mean(axis=0)
w = cp.Variable(N, nonneg=True)
gamma = cp.Variable() # wird im Optimum zum VaR
u = cp.Variable(S, nonneg=True) # Ueberschuss ueber die Schwelle
cvar = gamma + (1.0 / (S * (1.0 - ALPHA))) * cp.sum(u)
turnover = cp.norm1(w - w_alt)
kosten = GEBUEHRENSATZ * turnover
ziel = cp.Maximize(mu_taeglich @ w - RISIKOAVERSION * cvar - kosten)
bedingungen = [cp.sum(w) == 1, w <= MAX_GEWICHT]
if vektorisiert:
# EINE Matrixbedingung statt S einzelner - deutlich schneller
bedingungen.append(u >= -(R @ w) - gamma)
else:
for s in range(S): # Alternative: S einzelne Constraints (langsamer)
bedingungen.append(u[s] >= -R[s] @ w - gamma)
problem = cp.Problem(ziel, bedingungen)
problem.solve()
if problem.status not in ("optimal", "optimal_inaccurate"):
raise SystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
return w.value, float(gamma.value), float(cvar.value), float(turnover.value)
def optimiere_varianz(R, w_alt):
"""Klassisches Mean-Variance zum Vergleich - ebenfalls taeglich gerechnet.
ACHTUNG, DCP-Falle: Die Standardabweichung ist hier NICHT als
cp.sqrt(cp.quad_form(w, sigma)) formulierbar. cp.sqrt ist konkav und
verlangt ein konkaves Argument; quad_form ist konvex - CVXPY lehnt den
Ausdruck mit einem DCPError ab, und zwar voellig zu Recht (Anhang
Fehlerdiagnose). cp.psd_wrap() hilft dagegen nicht: Es behebt eine
NUMERISCHE Beanstandung an sigma, keine Regelverletzung im Aufbau.
Der Standardweg ist die Cholesky-Zerlegung sigma = L L^T. Damit gilt
w' sigma w = ||L^T w||^2, also ist die Standardabweichung die 2-Norm
eines AFFINEN Ausdrucks - konvex und damit regelkonform.
"""
N = R.shape[1]
mu_taeglich = R.mean(axis=0)
sigma = np.cov(R, rowvar=False, ddof=1)
# Der winzige Diagonalzuschlag faengt den Fall ab, dass sigma numerisch
# nur halbdefinit ist (mehr Titel als Handelstage, doppelte Spalten).
L = np.linalg.cholesky(sigma + 1e-12 * np.eye(N))
w = cp.Variable(N, nonneg=True)
ziel = cp.Maximize(mu_taeglich @ w
- RISIKOAVERSION * cp.norm2(L.T @ w)
- GEBUEHRENSATZ * cp.norm1(w - w_alt))
problem = cp.Problem(ziel, [cp.sum(w) == 1, w <= MAX_GEWICHT])
problem.solve()
if problem.status not in ("optimal", "optimal_inaccurate"):
raise SystemExit(f"Varianz-Problem nicht loesbar: {problem.status}")
return w.value
def realisierte_kennzahlen(w, R):
"""VaR und CVaR direkt aus den Szenarien - unabhaengige Gegenprobe."""
portfoliorenditen = R @ w
verluste = -portfoliorenditen
var = float(np.quantile(verluste, ALPHA))
cvar = float(verluste[verluste >= var].mean())
return var, cvar, float(portfoliorenditen.mean()), float(portfoliorenditen.std(ddof=1))
if __name__ == "__main__":
renditen = lade_renditen()
R = renditen.values
S, N = R.shape
w_alt = np.ones(N) / N # Ausgangslage: Gleichgewichtung
t0 = time.perf_counter()
w_cvar, var_modell, cvar_modell, turnover = optimiere_cvar(R, w_alt, vektorisiert=True)
dauer_vektor = time.perf_counter() - t0
w_var = optimiere_varianz(R, w_alt)
print("=" * 90)
print(" CVaR-PORTFOLIO-OPTIMIERUNG MIT TRANSAKTIONSKOSTEN")
print("=" * 90)
print(f"Datenbasis: {S} Handelstage, {N} Titel | Konfidenzniveau "
f"{ALPHA*100:.0f} % | Loesungszeit {dauer_vektor:.2f} s\n")
# --- Gegenprobe: Modellwerte gegen realisierte Szenariowerte ---------
var_real, cvar_real, mu_real, sd_real = realisierte_kennzahlen(w_cvar, R)
print("--- Gegenprobe: stimmen Modell und Szenarien ueberein? ---")
print(f" VaR aus dem Modell (gamma): {var_modell*100:7.4f} % | "
f"aus den Szenarien: {var_real*100:7.4f} %")
print(f" CVaR aus dem Modell: {cvar_modell*100:7.4f} % | "
f"aus den Szenarien: {cvar_real*100:7.4f} %")
assert abs(cvar_modell - cvar_real) < 1e-4, "CVaR stimmt nicht mit den Szenarien!"
print(" -> Der Rockafellar-Uryasev-Trick liefert exakt den empirischen CVaR.")
# --- Kennzahlen beider Portfolios ------------------------------------
print(f"\n{'Portfolio':<24} {'Rendite p.a.':>13} {'Vola p.a.':>11} "
f"{'VaR 95% (Tag)':>15} {'CVaR 95% (Tag)':>16} {'Turnover':>10}")
print("-" * 90)
for name, w in [("CVaR-optimiert", w_cvar), ("Varianz-optimiert", w_var),
("Gleichgewichtung", w_alt)]:
v, c, m, s = realisierte_kennzahlen(w, R)
to = float(np.abs(w - w_alt).sum())
print(f"{name:<24} {m*HANDELSTAGE*100:>12.2f} % "
f"{s*np.sqrt(HANDELSTAGE)*100:>10.2f} % {v*100:>14.3f} % "
f"{c*100:>15.3f} % {to*100:>9.1f} %")
print("\nHinweis zur Annualisierung: Renditen werden mit 252 skaliert,")
print("Volatilitaeten mit sqrt(252). Fuer VaR/CVaR ist eine solche Skalierung")
print("nur unter starken Annahmen (Unabhaengigkeit, kein Drift) zulaessig -")
print("sie werden hier deshalb bewusst als TAGESwerte ausgewiesen.")
# --- Allokationstabelle ------------------------------------------------
print("\n--- Allokation ---")
tabelle = pd.DataFrame({
"Ticker": TICKER,
"vorher": [f"{v*100:5.1f} %" for v in w_alt],
"CVaR-opt.": [f"{v*100:5.1f} %" for v in w_cvar],
"Handel": [f"{(w_cvar[i]-w_alt[i])*100:+6.1f} %" for i in range(N)],
"Varianz-opt.": [f"{v*100:5.1f} %" for v in w_var],
})
print(tabelle.to_string(index=False))
print(f"\nTurnover {turnover*100:.1f} % -> Transaktionskosten "
f"{GEBUEHRENSATZ*turnover*100:.3f} % des Portfoliowerts")
# --- Laufzeitvergleich vektorisiert vs. Schleife ----------------------
if S <= 600: # bei sehr vielen Szenarien zu langsam
t0 = time.perf_counter()
optimiere_cvar(R, w_alt, vektorisiert=False)
dauer_schleife = time.perf_counter() - t0
print(f"\n--- Laufzeit: {S} Nebenbedingungen aufbauen ---")
print(f" vektorisiert (u >= -(R @ w) - gamma): {dauer_vektor:6.2f} s")
print(f" Schleife ueber Szenarien (V01): {dauer_schleife:6.2f} s "
f"({dauer_schleife/dauer_vektor:.1f}x langsamer)")
print("=" * 90)

View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
# Data_Snooping.py
"""
Kapitel Handelsmaschine: Die Selbsttaeuschung, die man einem Backtest nicht ansieht.
Backtest_Fallen.py zeigt drei Fehler, die IM Backtest stecken: Lookahead,
Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen,
wenn man es liest.
Die beiden anderen - Overfitting und Data Snooping - entstehen nicht im
Backtest, sondern in der ARBEITSWEISE davor. Sie hinterlassen im Code keine
Spur. Ein Backtest, der zwanzigmal angepasst wurde, sieht am Ende genauso
sauber aus wie einer, der beim ersten Versuch funktioniert hat.
Dieses Programm macht den Effekt messbar. Es testet Strategien, die
NACHWEISLICH keinerlei Prognosekraft haben (ihre taeglichen Ergebnisse sind
Zufallszahlen mit Erwartungswert exakt null), und zeigt, wie gut die jeweils
BESTE davon aussieht - allein als Funktion der Anzahl der Versuche.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import numpy as np
from scipy import stats
HANDELSTAGE = 1260 # fuenf Jahre
TAGESSCHWANKUNG = 0.010 # 1 % taeglich
WIEDERHOLUNGEN = 300 # damit die Tabelle nicht vom Zufall abhaengt
NIVEAU = 0.05
RNG = np.random.default_rng(42)
def bewerte(ergebnisse: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Sharpe-Kennzahl und einseitiger p-Wert je Strategie.
ergebnisse hat die Form (Strategien, Handelstage). Der p-Wert prueft die
Nullhypothese 'mittleres Tagesergebnis ist null' - also genau die Frage,
die ein Backtest beantworten soll.
"""
mittel = ergebnisse.mean(axis=1)
streuung = ergebnisse.std(axis=1, ddof=1)
sharpe = mittel / streuung * np.sqrt(252)
t_wert = mittel / (streuung / np.sqrt(ergebnisse.shape[1]))
return sharpe, stats.t.sf(t_wert, ergebnisse.shape[1] - 1)
def probiere(anzahl_strategien: int) -> tuple[float, float, float, float]:
"""Liefert (beste Sharpe, ihr p-Wert, Anteil scheinbar signifikanter,
Anteil, der auch die Bonferroni-Huerde nimmt) - gemittelt ueber
WIEDERHOLUNGEN unabhaengige Durchgaenge."""
beste_sharpe, beste_p, anteil_signifikant, ueberlebt_bonferroni = [], [], [], []
for _ in range(WIEDERHOLUNGEN):
# Der springende Punkt: Erwartungswert exakt 0. Keine dieser
# Strategien hat einen echten Vorteil - per Konstruktion.
ergebnisse = RNG.normal(0.0, TAGESSCHWANKUNG,
(anzahl_strategien, HANDELSTAGE))
sharpe, p_werte = bewerte(ergebnisse)
beste = int(sharpe.argmax())
beste_sharpe.append(float(sharpe[beste]))
beste_p.append(float(p_werte[beste]))
anteil_signifikant.append(float((p_werte < NIVEAU).mean()))
# Bonferroni: Wer n Tests macht, muss die Huerde durch n teilen.
ueberlebt_bonferroni.append(
float((p_werte < NIVEAU / anzahl_strategien).mean()))
return (float(np.mean(beste_sharpe)), float(np.mean(beste_p)),
float(np.mean(anteil_signifikant)),
float(np.mean(ueberlebt_bonferroni)))
if __name__ == "__main__":
print("=" * 78)
print(" WIE GUT SIEHT DIE BESTE VON N STRATEGIEN AUS,")
print(" WENN KEINE EINZIGE ETWAS TAUGT?")
print("=" * 78)
print(f"{HANDELSTAGE} Handelstage, taegliche Ergebnisse mit Erwartungswert "
f"EXAKT null.")
print(f"Jede Zeile ist der Mittelwert aus {WIEDERHOLUNGEN} unabhaengigen "
f"Durchgaengen.\n")
print(f"{'getestet':>9} {'beste Sharpe':>14} {'ihr p-Wert':>12} "
f"{'scheinbar sign.':>16}")
print("-" * 78)
ergebnisse = {}
for anzahl in (1, 10, 100, 1000):
sharpe, p_wert, anteil, _ = probiere(anzahl)
ergebnisse[anzahl] = (sharpe, p_wert, anteil)
print(f"{anzahl:9d} {sharpe:14.2f} {p_wert:12.4f} {anteil * 100:15.1f} %")
print("-" * 78)
sharpe_100, p_100, _ = ergebnisse[100]
sharpe_1000, p_1000, _ = ergebnisse[1000]
print("\nLesen Sie die dritte Spalte von unten nach oben:")
print(f" Wer {1000} Varianten durchprobiert, findet eine mit p = {p_1000:.4f} -")
print(" ein Wert, den jede Zeitschrift als 'hochsignifikant' druckt.")
print(f" Wer {100} probiert, findet p = {p_100:.4f} und eine Sharpe-Kennzahl")
print(f" von {sharpe_100:.2f} - damit geht man zum Vorgesetzten.")
print(" Und keine dieser Strategien hat auch nur den Hauch eines Vorteils.")
print()
print("Die LETZTE Spalte bleibt dagegen konstant bei rund 5 %. Das ist kein")
print("Widerspruch, sondern die Definition: Ein Test zum 5-%-Niveau irrt in")
print("5 % der Faelle. Was sich aendert, ist nicht der Anteil, sondern die")
print("ANZAHL - und weil man nur die beste Strategie praesentiert, sieht man")
print("von allen Fehlversuchen genau einen: den erfolgreichsten.")
print("\n" + "=" * 78)
print(" WAS MAN DAGEGEN TUN KANN")
print("=" * 78)
print("1. ZAEHLEN. Notieren Sie, wie viele Varianten Sie ausprobiert haben -")
print(" auch die verworfenen, auch die 'nur mal schnell'. Ohne diese Zahl")
print(" ist kein p-Wert und keine Sharpe-Kennzahl interpretierbar.")
print()
print("2. KORRIGIEREN. Die Bonferroni-Korrektur teilt die Huerde durch die")
print(" Zahl der Versuche:")
for anzahl in (10, 100, 1000):
print(f" bei {anzahl:5d} Versuchen: p < {NIVEAU / anzahl:.5f} statt "
f"p < {NIVEAU:.2f}")
print(" Sie ist konservativ, aber sie ist ehrlich. Fuer Handelsstrategien")
print(" gibt es verfeinerte Varianten (Deflated Sharpe Ratio nach Bailey")
print(" und Lopez de Prado), die dasselbe Prinzip verfolgen.")
print()
print("3. ZURUECKHALTEN. Legen Sie einen Zeitraum beiseite, BEVOR Sie")
print(" anfangen, und ruehren Sie ihn bis zum Schluss nicht an. Er ist")
print(" der einzige Test, den Sie nicht durch Probieren verderben koennen -")
print(" und er ist genau einmal verwendbar.")
print("=" * 78)

View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
# Diversifikation_Demo.py
"""
Kapitel Markowitz: Portfoliorisiko in Abhaengigkeit von der Korrelation - die
Handrechnung zum Gratis-Mittagessen, geprueft ueber die analytische Formel und
ueber simulierte, korrelierte Renditen.
"""
import numpy as np
MU = 0.08
SIGMA = 0.20
def sigma_portfolio(rho: float, w1: float = 0.5) -> float:
"""Analytische Formel: sigma_p^2 = w1^2 s1^2 + w2^2 s2^2 + 2 w1 w2 rho s1 s2."""
w2 = 1 - w1
varianz = w1**2 * SIGMA**2 + w2**2 * SIGMA**2 + 2 * w1 * w2 * rho * SIGMA**2
return float(np.sqrt(varianz))
def simuliere(rho: float, n: int = 500_000, seed: int = 7) -> float:
"""Erzeugt n korrelierte Renditepaare und misst die Portfolio-Volatilitaet empirisch."""
rng = np.random.default_rng(seed)
kovarianz = np.array([[SIGMA**2, rho * SIGMA**2],
[rho * SIGMA**2, SIGMA**2]])
renditen = rng.multivariate_normal([MU, MU], kovarianz, size=n)
portfolio = 0.5 * renditen[:, 0] + 0.5 * renditen[:, 1]
return float(portfolio.std(ddof=1))
if __name__ == "__main__":
print("=" * 78)
print(" PORTFOLIORISIKO IN ABHAENGIGKEIT VON DER KORRELATION")
print("=" * 78)
print(f"{'rho':>6} | {'sigma_p (Formel)':>18} | {'sigma_p (Simulation)':>20} | {'Risikoreduktion':>16}")
print("-" * 78)
for rho in [1.0, 0.5, 0.0, -0.5, -1.0]:
formel = sigma_portfolio(rho)
sim = simuliere(rho)
reduktion = (1 - formel / SIGMA) * 100
print(f"{rho:6.1f} | {formel*100:16.2f} % | {sim*100:18.2f} % | {reduktion:14.0f} %")
print("\nDie simulierten Werte (500.000 gezogene Renditepaare je rho) bestaetigen")
print("die Formel aus der Handrechnung bis auf statistisches Rauschen - und das,")
print("obwohl Formel und Simulation nichts voneinander wissen.")

View file

@ -0,0 +1,78 @@
#!/usr/bin/env python3
# Dualitaet_Nachweis.py
"""
Kapitel LP: Primales und duales Problem unabhaengig loesen und den starken
Dualitaetssatz sowie den komplementaeren Schlupf numerisch nachweisen.
Modell aus der Simplex-Handrechnung (Bot-Allokation, LP-Relaxation):
max 150*x1 + 250*x2 u.d.N. 2*x1+5*x2<=40, 4*x1+6*x2<=60, x1<=8, x>=0
"""
import numpy as np
from scipy.optimize import linprog
# --- Primales Problem --------------------------------------------------------
C_PRIMAL = np.array([150.0, 250.0])
A_PRIMAL = np.array([[2.0, 5.0], [4.0, 6.0], [1.0, 0.0]])
B_PRIMAL = np.array([40.0, 60.0, 8.0])
# --- Duales Problem: min b^T y u.d.N. A^T y >= c, y >= 0 ------------------
# linprog kennt nur <=, also A^T y >= c <=> -A^T y <= -c
C_DUAL = B_PRIMAL
A_DUAL = -A_PRIMAL.T
B_DUAL = -C_PRIMAL
def loese():
primal = linprog(c=-C_PRIMAL, A_ub=A_PRIMAL, b_ub=B_PRIMAL,
bounds=[(0, None)] * 2, method="highs")
dual = linprog(c=C_DUAL, A_ub=A_DUAL, b_ub=B_DUAL,
bounds=[(0, None)] * 3, method="highs")
if not primal.success or not dual.success:
raise SystemExit("Primal oder Dual nicht loesbar.")
return primal, dual
if __name__ == "__main__":
primal, dual = loese()
x = primal.x
y = dual.x
z_primal = -primal.fun
z_dual = dual.fun
print("=" * 78)
print(" PRIMALES PROBLEM")
print("=" * 78)
print(f"x* = ({x[0]:.4f}, {x[1]:.4f})")
print(f"Z* = {z_primal:.4f}")
print("\n" + "=" * 78)
print(" DUALES PROBLEM")
print("=" * 78)
print(f"y* = ({y[0]:.4f}, {y[1]:.4f}, {y[2]:.4f})")
print(f"W* = {z_dual:.4f}")
print("\n" + "=" * 78)
print(" STARKER DUALITAETSSATZ: c^T x* == b^T y* ?")
print("=" * 78)
print(f" Primal Z* = {z_primal:.6f}")
print(f" Dual W* = {z_dual:.6f}")
differenz = abs(z_primal - z_dual)
print(f" Differenz = {differenz:.2e} -> "
f"{'BESTAETIGT' if differenz < 1e-6 else 'VERLETZT!'}")
print("\n" + "=" * 78)
print(" KOMPLEMENTAERER SCHLUPF: s_i * y_i == 0 fuer alle i ?")
print("=" * 78)
schlupf = B_PRIMAL - A_PRIMAL @ x
ressourcen = ["vCPU (s1)", "RAM (s2)", "Marktlimit (s3)"]
for name, s, yi in zip(ressourcen, schlupf, y):
produkt = s * yi
print(f" {name:<16} Schlupf s={s:6.4f} Schattenpreis y={yi:6.4f} "
f"s*y={produkt:.2e} {'OK' if abs(produkt) < 1e-6 else 'VERLETZT!'}")
print("\nFazit: Das dual geloeste y* stimmt exakt mit den Schattenpreisen")
print("überein, die die Simplex-Rechnung von Hand in der Z-Zeile")
print("ablas - unabhaengig voneinander berechnet, identisches Ergebnis.")
print("=" * 78)

View file

@ -0,0 +1,120 @@
#!/usr/bin/env python3
# Ein_System_Vier_Ansaetze.py
"""
Kapitel Oekosystem: Dasselbe LP in vier Bibliotheken.
max 10*x1 + 15*x2 + 25*x3
u.d.N. x1 + x2 + 2*x3 <= 40
2*x1 + 3*x2 + x3 <= 50
x >= 0
Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich am Ende.
WICHTIG: Jeder Solver läuft in einem EIGENEN Prozess, weil sich ortools und
highspy auf vielen Systemen nicht gemeinsam importieren lassen (beide bringen
eine eigene HiGHS-Kopie mit -> Symbolkonflikt).
"""
import json
import subprocess
import sys
import textwrap
import time
ERWARTET = 530.0 # Ergebnis der Handrechnung zum Produktionsprogramm
# Jeder Eintrag ist ein eigenständiges Miniprogramm, das sein Ergebnis als
# JSON auf stdout ausgibt. So bleibt jeder Import in seinem eigenen Prozess.
ANSAETZE: dict[str, str] = {
"scipy.optimize.linprog": """
from scipy.optimize import linprog
res = linprog(c=[-10.0, -15.0, -25.0], # linprog MINIMIERT -> negieren
A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50],
bounds=[(0, None)] * 3, method="highs")
ausgabe = (-res.fun, list(res.x))
""",
"highspy (natives HiGHS)": """
import numpy as np, highspy
h = highspy.Highs()
h.setOptionValue("output_flag", False)
h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))
h.changeObjectiveSense(highspy.ObjSense.kMaximize)
for j, wert in enumerate([10.0, 15.0, 25.0]):
h.changeColCost(j, wert)
# CSR-Format: starts[i] = Beginn von Zeile i in indices/values
h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,
np.array([0, 3], dtype=np.int32),
np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),
np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0]))
h.run()
ausgabe = (h.getInfo().objective_function_value,
list(h.getSolution().col_value[:3]))
""",
"cvxpy": """
import numpy as np, cvxpy as cp
x = cp.Variable(3, nonneg=True)
problem = cp.Problem(cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x),
[np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40, 50])])
problem.solve()
ausgabe = (float(problem.value), [float(v) for v in x.value])
""",
"ortools / GLOP": """
from ortools.linear_solver import pywraplp
s = pywraplp.Solver.CreateSolver("GLOP")
x = [s.NumVar(0, s.infinity(), f"x{j+1}") for j in range(3)]
A = [[1, 1, 2], [2, 3, 1]]
for i, kap in enumerate([40, 50]):
s.Add(sum(A[i][j] * x[j] for j in range(3)) <= kap)
s.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])
s.Solve()
ausgabe = (s.Objective().Value(), [v.solution_value() for v in x])
""",
}
def fuehre_in_eigenem_prozess_aus(quelltext: str) -> tuple[float, list[float]]:
"""Startet den Codeschnipsel als separaten Python-Prozess und liest das Ergebnis."""
programm = textwrap.dedent(quelltext) + "\nimport json; print(json.dumps(ausgabe))\n"
ergebnis = subprocess.run([sys.executable, "-c", programm],
capture_output=True, text=True, timeout=120)
if ergebnis.returncode != 0:
raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])
wert, loesung = json.loads(ergebnis.stdout.strip().splitlines()[-1])
return wert, loesung
if __name__ == "__main__":
print("=" * 78)
print(" EIN SYSTEM - VIER ANSAETZE (je eigener Prozess)")
print("=" * 78)
print(f"{'Bibliothek':<26} {'Z*':>10} {'x1':>7} {'x2':>7} {'x3':>7} {'Zeit':>10}")
print("-" * 78)
werte = []
for name, quelltext in ANSAETZE.items():
t0 = time.perf_counter()
try:
wert, x = fuehre_in_eigenem_prozess_aus(quelltext)
except RuntimeError as fehler:
print(f"{name:<26} nicht verfuegbar: {fehler[:40]}")
continue
dauer = time.perf_counter() - t0
werte.append(wert)
print(f"{name:<26} {wert:>10.2f} {x[0]:>7.2f} {x[1]:>7.2f} {x[2]:>7.2f} "
f"{dauer:>8.2f} s")
print("-" * 78)
spanne = max(werte) - min(werte)
print(f"Spannweite zwischen den Bibliotheken: {spanne:.2e}")
print(f"Abweichung zur Handrechnung ({ERWARTET:.0f}): "
f"{abs(werte[0] - ERWARTET):.2e}")
assert spanne < 1e-6, "Die Bibliotheken widersprechen sich!"
assert abs(werte[0] - ERWARTET) < 1e-6, "Ergebnis weicht von der Handrechnung ab!"
print("Alle Wege fuehren zum selben, von Hand bestaetigten Optimum.")
print("(Die Zeiten enthalten den Prozessstart und den Import - sie messen")
print(" NICHT die reine Solverleistung, siehe Uebung 3.5.)")
print("=" * 78)

View file

@ -0,0 +1,138 @@
#!/usr/bin/env python3
# Entropie_Maximierte_Allokation.py
"""
Kapitel QP/NLP: Nichtlineare Optimierung (NLP) mit scipy SLSQP.
Modell: Mean-Variance-Portfolio mit Shannon-Entropie-Diversifikation.
Achtung, haeufiger Fehler: Sigma per A@A.T zu erzeugen und anschliessend die
Diagonale zu ueberschreiben zerstoert die positive Semidefinitheit - die
Matrix kann dadurch einen negativen Eigenwert bekommen und unmoegliche
Korrelationen (> 1) implizieren. Dieses Programm baut Sigma stattdessen als
D * C * D aus Volatilitaeten und einer echten Korrelationsmatrix -
konstruktionsbedingt immer PSD.
Zusaetzlich: Multistart, weil SLSQP nur lokale Optima findet.
"""
import numpy as np
import pandas as pd
from scipy.optimize import minimize
ASSETS = ["Tech-Aktien", "Rohstoffe", "US-Treasuries", "Krypto"]
MU = np.array([0.14, 0.07, 0.03, 0.22]) # erwartete Jahresrenditen
VOLA = np.array([0.20, 0.14, 0.07, 0.40]) # Volatilitaeten p.a.
KORRELATION = np.array([
[1.00, 0.25, -0.10, 0.55],
[0.25, 1.00, 0.05, 0.20],
[-0.10, 0.05, 1.00, -0.15],
[0.55, 0.20, -0.15, 1.00],
])
ALPHA = 1.0 # Gewicht des Ertrags
BETA = 0.015 # Gewicht der Entropie-Diversifikation
UNTERGRENZE = 0.001
OBERGRENZE = 0.80
def baue_kovarianz() -> np.ndarray:
"""Sigma = D * C * D. Immer PSD, wenn C eine gueltige Korrelationsmatrix ist."""
eig_c = np.linalg.eigvalsh(KORRELATION)
if eig_c.min() < -1e-10:
raise ValueError(f"Korrelationsmatrix unmoeglich (Eigenwert {eig_c.min():.4f}).")
D = np.diag(VOLA)
Sigma = D @ KORRELATION @ D
eig_s = np.linalg.eigvalsh(Sigma)
assert eig_s.min() > 0, "Sigma nicht positiv definit!"
return Sigma
SIGMA = baue_kovarianz()
def zielfunktion(w, alpha=ALPHA, beta=BETA):
"""Risiko - Ertrag - Entropiepraemie (wird minimiert)."""
varianz = 0.5 * w @ SIGMA @ w
ertrag = MU @ w
entropie = -np.sum(w * np.log(w + 1e-12))
return varianz - alpha * ertrag - beta * entropie
def gradient(w, alpha=ALPHA, beta=BETA):
"""Exakter analytischer Gradient - beschleunigt und stabilisiert SLSQP."""
grad_varianz = SIGMA @ w # d/dw von 0.5 w'Sigma w
grad_ertrag = -alpha * MU
grad_entropie = beta * (np.log(w + 1e-12) + 1.0)
return grad_varianz + grad_ertrag + grad_entropie
def optimiere(startpunkt, alpha=ALPHA, beta=BETA):
"""alpha und beta werden durchgereicht - so bleibt die Funktion seiteneffektfrei."""
return minimize(
zielfunktion, startpunkt, args=(alpha, beta), jac=gradient,
method="SLSQP", bounds=[(UNTERGRENZE, OBERGRENZE)] * len(MU),
constraints=({"type": "eq",
"fun": lambda w: np.sum(w) - 1.0,
"jac": lambda w: np.ones(len(MU))}),
options={"ftol": 1e-12, "maxiter": 300})
if __name__ == "__main__":
n = len(MU)
eig = np.linalg.eigvalsh(SIGMA)
print("=" * 74)
print(" NICHTLINEARE ENTROPIE-OPTIMIERTE ASSET-ALLOKATION")
print("=" * 74)
print("Pruefung der Kovarianzmatrix:")
print(f" Eigenwerte: {np.round(eig, 6)}")
print(f" Positiv definit: {bool(eig.min() > 0)}")
print(f" Groesste Korrelation ausserhalb der Diagonale: "
f"{np.abs(KORRELATION - np.eye(n)).max():.2f} (muss <= 1 sein)")
# --- Multistart: SLSQP findet nur lokale Optima -----------------------
rng = np.random.default_rng(7)
startpunkte = [np.ones(n) / n] # Gleichgewichtung
for _ in range(9):
z = rng.random(n) + 0.05
startpunkte.append(z / z.sum())
ergebnisse = [optimiere(s) for s in startpunkte]
erfolgreich = [r for r in ergebnisse if r.success]
bestes = min(erfolgreich, key=lambda r: r.fun)
zielwerte = np.array([r.fun for r in erfolgreich])
print(f"\nMultistart mit {len(startpunkte)} Startpunkten:")
print(f" Erfolgreich konvergiert: {len(erfolgreich)}")
print(f" Spannweite der Zielwerte: {zielwerte.max() - zielwerte.min():.2e}")
print(" -> " + ("alle Startpunkte fuehren zum selben Optimum (Indiz fuer Konvexitaet)"
if zielwerte.max() - zielwerte.min() < 1e-6
else "ACHTUNG: verschiedene lokale Optima gefunden!"))
w_opt = bestes.x
rendite = MU @ w_opt
volatilitaet = np.sqrt(w_opt @ SIGMA @ w_opt)
entropie = -np.sum(w_opt * np.log(w_opt))
print(f"\nKonvergenz: {bestes.message}")
print(f"Iterationen: {bestes.nit}")
print(f"Erwartete Jahresrendite: {rendite * 100:6.2f} %")
print(f"Erwartete Volatilitaet: {volatilitaet * 100:6.2f} %")
print(f"Diversifikations-Entropie: {entropie:.4f} "
f"(Maximum bei Gleichgewichtung: {np.log(n):.4f})")
print("\n" + pd.DataFrame({
"Asset": ASSETS,
"Erw. Rendite": [f"{r*100:.1f} %" for r in MU],
"Volatilitaet": [f"{v*100:.1f} %" for v in VOLA],
"Gewicht": [f"{w*100:6.2f} %" for w in w_opt],
}).to_string(index=False))
# --- Vergleich: was passiert ohne Entropieterm? ----------------------
ohne = optimiere(np.ones(n) / n, beta=0.0)
print("\n--- Wirkung des Entropieterms ---")
print(f" Mit Entropie (beta={BETA}): Gewichte {np.round(w_opt * 100, 1)}")
print(f" Ohne Entropie (beta=0): Gewichte {np.round(ohne.x * 100, 1)}")
print(" Der Entropieterm zieht Kapital aus der Spitzenposition heraus,")
print(" ohne dass eine harte Obergrenze noetig waere.")
print("=" * 74)

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
# Erklaerbarkeit.py
"""
Kapitel Praxisfallen: Ein Constraint-Trace, der die drei Fragen der Faustregel fuer
Erklaerbarkeit (Abschnitt 'Die fuenf typischen Praxisfallen') fuer eine
einzelne Zuweisung beantwortet:
Warum diese Loesung? Warum nicht die Alternative? Was wuerde sie verbessern?
"""
from ortools.sat.python import cp_model
PERSONAL = ["Anna", "Ben", "Clara"]
BEREITS_STUNDEN = {"Anna": 1, "Ben": 2, "Clara": 0} # diese Woche schon geleistet
MAX_STUNDEN = 2 # gesetzliche Obergrenze
STRAFE_VORBELASTUNG = 50 # pro bereits geleisteter Stunde
STRAFKOSTEN_WUNSCH = {"Anna": 0, "Ben": 30, "Clara": 100}
def loese(max_stunden: int):
modell = cp_model.CpModel()
x = {p: modell.NewBoolVar(f"x_{p}") for p in PERSONAL}
modell.AddExactlyOne(x.values())
kosten = []
for p in PERSONAL:
if BEREITS_STUNDEN[p] + 1 > max_stunden:
modell.Add(x[p] == 0) # hartes Ausschlusskriterium
strafe = BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
kosten.append(strafe * x[p])
modell.Minimize(sum(kosten))
loeser = cp_model.CpSolver()
status = loeser.Solve(modell)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
return None
gewinner = next(p for p in PERSONAL if loeser.Value(x[p]))
return gewinner, loeser.ObjectiveValue()
def gesamtkosten(p: str) -> int:
return BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
if __name__ == "__main__":
gewinner, kosten_opt = loese(MAX_STUNDEN)
print("=" * 78)
print(" CONSTRAINT-TRACE: Warum bekommt", gewinner, "die Vertretungsstunde?")
print("=" * 78)
print("\n1. WARUM DIESE LOESUNG?")
v, w = BEREITS_STUNDEN[gewinner] * STRAFE_VORBELASTUNG, STRAFKOSTEN_WUNSCH[gewinner]
print(f" {gewinner}: Vorbelastung {v} Strafpunkte + Wunsch {w} Strafpunkte "
f"= {v + w} Strafpunkte insgesamt (Minimum).")
print("\n2. WARUM NICHT DIE NAHELIEGENDEN ALTERNATIVEN?")
for p in PERSONAL:
if p == gewinner:
continue
if BEREITS_STUNDEN[p] + 1 > MAX_STUNDEN:
print(f" {p}: AUSGESCHLOSSEN - hat bereits {BEREITS_STUNDEN[p]} Stunden, "
f"eine dritte würde die Obergrenze von {MAX_STUNDEN} Stunden verletzen "
f"(harte Bedingung, nicht verhandelbar).")
else:
differenz = gesamtkosten(p) - kosten_opt
print(f" {p}: zulässig, aber {differenz:.0f} Strafpunkte teurer als "
f"{gewinner} ({gesamtkosten(p):.0f} statt {kosten_opt:.0f}).")
print("\n3. WAS WUERDE DIE LOESUNG VERBESSERN?")
gewinner_gelockert, kosten_gelockert = loese(MAX_STUNDEN + 1)
if kosten_gelockert < kosten_opt:
print(f" Obergrenze auf {MAX_STUNDEN + 1} Stunden gelockert: {gewinner_gelockert} "
f"uebernimmt jetzt zu {kosten_gelockert:.0f} Strafpunkten "
f"({kosten_opt - kosten_gelockert:.0f} weniger als heute).")
print(" -> Diese Bedingung hat tatsaechlich einen Preis.")
else:
print(f" Obergrenze auf {MAX_STUNDEN + 1} Stunden gelockert: Ergebnis bleibt bei "
f"{gewinner} mit {kosten_gelockert:.0f} Strafpunkten (unveraendert).")
blockiert = ", ".join(p for p in PERSONAL
if BEREITS_STUNDEN[p] + 1 > MAX_STUNDEN and p != gewinner)
print(f" -> Die Obergrenze blockiert zwar {blockiert}, kostet aber gerade NICHTS:")
print(" Selbst ohne sie waere die Loesung dieselbe.")
print(" Blockierend und kostenrelevant sind zwei verschiedene Dinge.")
print("=" * 78)

View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
# Excel_Bruecke.py
"""
Kapitel Einfuehrung: Vom Excel-Solver zu Python - dieselbe Rechnung, dieselbe Datei.
Das Programm bildet den vollstaendigen Arbeitsweg ab, den ein Excel-Modell im
Betrieb sonst von Hand geht:
Tabellenblatt lesen -> Modell bauen -> loesen -> Ergebnis zurueckschreiben
Damit es ohne Vorbereitung laeuft, legt es die Eingabedatei beim ersten Start
selbst an. Ersetzen Sie sie durch Ihre eigene Datei - solange die Spalten
gleich heissen, aendert sich am Code nichts.
Die vier Arbeitsschritte kommen aus or_kern.py (Kapitel Praxisfallen). Was
hier steht, ist nur noch das, was diesen Fall ausmacht - der Rest ist
gemeinsamer Unterbau. Wer das Modul nicht kennt: Es ist im Buch vollstaendig
abgedruckt und liegt im selben Verzeichnis.
Erzeugte Dateien (im Arbeitsverzeichnis):
produktionsmix.xlsx Eingabe: Blaetter 'Produkte' und 'Kapazitaeten'
produktionsmix_ergebnis.xlsx Ausgabe: Blaetter 'Plan' und 'Kennzahlen'
Benoetigt: pandas, openpyxl, ortools, pydantic (ueber or_kern)
"""
from __future__ import annotations
import os
import pandas as pd
from or_kern import (lade_produktionsproblem, loese_mit_glop, pruefe_loesung,
schreibe_ergebnis)
EINGABE = "produktionsmix.xlsx"
AUSGABE = "produktionsmix_ergebnis.xlsx"
def lege_beispieldatei_an(pfad: str) -> None:
"""Schreibt die Eingabemappe, wie sie auch aus dem Controlling kaeme."""
produkte = pd.DataFrame({
"Produkt": ["Tisch", "Stuhl", "Regal"],
"Deckungsbeitrag": [240.0, 60.0, 130.0],
"Montagestunden": [3.0, 1.0, 2.0],
"Plattenmaterial": [6.0, 1.0, 4.0],
})
kapazitaeten = pd.DataFrame({
"Ressource": ["Montagestunden", "Plattenmaterial"],
"Verfuegbar": [150.0, 240.0],
})
with pd.ExcelWriter(pfad, engine="openpyxl") as mappe:
produkte.to_excel(mappe, sheet_name="Produkte", index=False)
kapazitaeten.to_excel(mappe, sheet_name="Kapazitaeten", index=False)
if __name__ == "__main__":
if not os.path.exists(EINGABE):
lege_beispieldatei_an(EINGABE)
print(f"Beispiel-Eingabedatei angelegt: {EINGABE}")
# 1. Lesen und pruefen. Faellt hier etwas auf, ist der Fehler noch
# zuzuordnen - spaeter waere er nur noch eine merkwuerdige Zahl.
problem = lade_produktionsproblem(EINGABE)
# 2. Loesen. Einzige solverabhaengige Zeile des ganzen Programms -
# 'loese_mit_scipy' waere ein Einzeiler-Wechsel.
loesung = loese_mit_glop(problem)
# 3. Abnahmepruefung gegen die Anforderungen, ohne den Solver zu fragen.
beanstandungen = pruefe_loesung(problem, loesung)
if beanstandungen:
raise SystemExit("Abnahmepruefung fehlgeschlagen:\n - "
+ "\n - ".join(beanstandungen))
# 4. Zurueckschreiben - im Format, das die Fachabteilung ohnehin benutzt.
schreibe_ergebnis(AUSGABE, problem, loesung)
print("=" * 66)
print(" PRODUKTIONSPLAN AUS DER EXCEL-MAPPE")
print("=" * 66)
for produkt in problem.produkte:
menge = loesung.werte[produkt.name]
print(f"{produkt.name:<18} {menge:8.1f} Stueck "
f"{menge * produkt.deckungsbeitrag:12,.2f} EUR")
print("-" * 66)
print(loesung.als_bericht())
print("Abnahmepruefung: bestanden\n")
# Die Schattenpreise beantworten die Frage, die der Plan nicht stellt:
# Was waere eine zusaetzliche Einheit dieser Ressource wert?
for ressource, preis in loesung.schattenpreise.items():
print(f" eine Einheit {ressource:<18} mehr waere wert: {preis:7.2f} EUR")
print("=" * 66)
print(f"Ergebnis geschrieben nach: {AUSGABE}")

View file

@ -0,0 +1,147 @@
#!/usr/bin/env python3
# Finanzdaten_Ledoit_Wolf.py
"""
Kapitel Finanzdaten: Automatisierte Finanzdaten-Pipeline und Kovarianz-Shrinkage.
Eigenschaften:
* Spaltenreihenfolge wird erzwungen und per assert geprueft
* beide Shrinkage-Ziele werden berechnet und verglichen
* Datenqualitaetspruefungen (Luecken, Ausreisser, Mindestlaenge)
* Eigenwertspektrum wird ausgewiesen, nicht nur die Konditionszahl
"""
import numpy as np
import pandas as pd
import yfinance as yf
from sklearn.covariance import LedoitWolf
HANDELSTAGE = 252
def lade_kurse(tickers: list[str], start, ende) -> pd.DataFrame:
"""Laedt bereinigte Schlusskurse und garantiert die Spaltenreihenfolge."""
print(f"Lade {len(tickers)} Ticker von {start} bis {ende} ...")
roh = yf.download(tickers, start=start, end=ende, auto_adjust=True, progress=False)
if roh.empty:
raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
if isinstance(roh.columns, pd.MultiIndex):
# [tickers] erzwingt die gewuenschte Spaltenreihenfolge
kurse = roh["Close"][tickers].dropna()
else:
kurse = roh[["Close"]].dropna()
kurse.columns = tickers
assert list(kurse.columns) == tickers, (
f"Spaltenreihenfolge weicht ab!\n erwartet: {tickers}\n"
f" erhalten: {list(kurse.columns)}")
return kurse
def pruefe_datenqualitaet(kurse: pd.DataFrame, mindest_tage: int = 200) -> None:
"""Faengt die haeufigsten Datenprobleme ab, bevor sie ins Modell gelangen."""
T, N = kurse.shape
print(f"\nDatenqualitaet: {T} Handelstage, {N} Titel (T/N = {T/N:.1f})")
if T < mindest_tage:
raise SystemExit(f"Zu wenige Beobachtungen ({T} < {mindest_tage}).")
if T < N:
print(" WARNUNG: T < N - die Stichprobenkovarianz ist singulaer!")
elif T < 3 * N:
print(" WARNUNG: T < 3N - erhebliches Schaetzrauschen zu erwarten.")
renditen = kurse.pct_change().dropna()
extreme = (renditen.abs() > 0.25).sum().sum()
if extreme:
print(f" Hinweis: {extreme} Tagesrenditen ueber 25 % "
f"(Splits, Sondersituationen oder Datenfehler pruefen).")
luecken = kurse.isna().sum().sum()
print(f" Fehlende Werte nach dropna: {luecken}")
def ziel_konstante_korrelation(renditen: np.ndarray) -> np.ndarray:
"""
Shrinkage-Ziel nach Ledoit/Wolf 2003: Einzelvarianzen behalten,
alle Korrelationen durch ihren Mittelwert ersetzen.
(sklearn nutzt stattdessen die skalierte Einheitsmatrix - siehe oben.)
"""
S = np.cov(renditen, rowvar=False, ddof=1)
d = np.sqrt(np.diag(S))
korrelation = S / np.outer(d, d)
n = len(S)
r_quer = (korrelation.sum() - n) / (n * (n - 1)) # Mittel ohne Diagonale
F = r_quer * np.outer(d, d)
np.fill_diagonal(F, np.diag(S))
return F
def analysiere(kurse: pd.DataFrame):
renditen = kurse.pct_change().dropna()
T, N = renditen.shape
mu = renditen.mean().values * HANDELSTAGE
sigma_stichprobe = renditen.cov().values * HANDELSTAGE
lw = LedoitWolf(assume_centered=False).fit(renditen.values)
sigma_lw = lw.covariance_ * HANDELSTAGE
delta = lw.shrinkage_
print("\n" + "=" * 84)
print(" FINANZDATEN-PIPELINE UND MATRIX-KONDITIONIERUNG")
print("=" * 84)
print(f"Beobachtungen T: {T}")
print(f"Titel N: {N}")
print(f"Optimales Shrinkage delta: {delta:.4f} "
f"({delta*100:.1f} % Gewicht auf dem strukturierten Ziel)")
eig_stich = np.linalg.eigvalsh(sigma_stichprobe)
eig_lw = np.linalg.eigvalsh(sigma_lw)
print("\n--- Eigenwertspektrum (annualisiert) ---")
print(f"{'':<14} {'kleinster':>12} {'Median':>12} {'groesster':>12} "
f"{'Kondition':>12}")
for name, eig in [("Stichprobe", eig_stich), ("Ledoit-Wolf", eig_lw)]:
kondition = eig.max() / max(eig.min(), 1e-12)
print(f"{name:<14} {eig.min():>12.6f} {np.median(eig):>12.6f} "
f"{eig.max():>12.6f} {kondition:>12.1f}")
print("\nDie Konditionszahl misst, wie stark sich kleine Datenaenderungen auf")
print("die Inverse auswirken. Je kleiner, desto stabiler die Portfoliogewichte.")
# --- Vergleich der beiden Shrinkage-Ziele ----------------------------
S_taeglich = np.cov(renditen.values, rowvar=False, ddof=1)
F_identitaet = np.eye(N) * np.trace(S_taeglich) / N
F_korrelation = ziel_konstante_korrelation(renditen.values)
print("\n--- Die beiden Shrinkage-Ziele im Vergleich ---")
for name, F in [("skalierte Einheitsmatrix (sklearn)", F_identitaet),
("konstante Korrelation (LW 2003)", F_korrelation)]:
gemischt = ((1 - delta) * S_taeglich + delta * F) * HANDELSTAGE
eig = np.linalg.eigvalsh(gemischt)
print(f" {name:<36}: Kondition {eig.max()/max(eig.min(),1e-12):8.1f}, "
f"kleinster EW {eig.min():.6f}")
print("\n--- Erwartete Renditen (annualisiert) ---")
uebersicht = pd.DataFrame({
"Ticker": kurse.columns,
"Rendite p.a.": [f"{r*100:+7.2f} %" for r in mu],
"Volatilitaet p.a.": [f"{np.sqrt(sigma_lw[i, i])*100:6.2f} %" for i in range(N)],
})
print(uebersicht.to_string(index=False))
print("=" * 84)
return renditen, mu, sigma_stichprobe, sigma_lw
if __name__ == "__main__":
UNIVERSUM = ["AAPL", "MSFT", "NVDA", "AMZN", # Technologie
"JNJ", "PFE", # Gesundheit
"JPM", "GS", # Banken
"XOM", "CVX"] # Energie
# Relatives 2-Jahres-Fenster, damit das Beispiel nicht "altert"
ende = pd.Timestamp.today().normalize()
start = ende - pd.DateOffset(years=2)
kurse = lade_kurse(UNIVERSUM, start.strftime("%Y-%m-%d"), ende.strftime("%Y-%m-%d"))
pruefe_datenqualitaet(kurse)
analysiere(kurse)

View file

@ -0,0 +1,53 @@
#!/usr/bin/env python3
# Fluch_des_Durchschnitts.py
"""
Kapitel Unsicherheit: Der Fluch des Durchschnitts (die Handrechnung dazu) als Scan ueber alle
moeglichen Kapazitaeten - zeigt, dass das Optimum nicht beim Mittelwert liegt.
"""
import numpy as np
SZENARIEN = np.array([100, 250, 500])
WAHRSCHEINLICHKEITEN = np.array([0.5, 0.3, 0.2])
PREIS_VORAB = 40.0
PREIS_ZUKAUF = 120.0
PREIS_VERWALTUNG = 5.0
ERWARTUNGSWERT = float(SZENARIEN @ WAHRSCHEINLICHKEITEN)
def erwartete_kosten(x):
kosten = np.where(
SZENARIEN >= x,
PREIS_VORAB * x + PREIS_ZUKAUF * (SZENARIEN - x),
PREIS_VORAB * x + PREIS_VERWALTUNG * (x - SZENARIEN),
)
return float(kosten @ WAHRSCHEINLICHKEITEN)
if __name__ == "__main__":
print("=" * 70)
print(" DER FLUCH DES DURCHSCHNITTS")
print("=" * 70)
print(f"Erwarteter Bedarf (Mittelwert): {ERWARTUNGSWERT:.1f}")
print(f"Kosten bei naiver Planung x={ERWARTUNGSWERT:.0f}: "
f"{erwartete_kosten(ERWARTUNGSWERT):,.2f} EUR\n")
x_werte = np.arange(0, 501, 1)
kosten_werte = np.array([erwartete_kosten(x) for x in x_werte])
x_optimal = x_werte[np.argmin(kosten_werte)]
kosten_optimal = kosten_werte.min()
print(f"Optimales x (durch Scan gefunden): {x_optimal}")
print(f"Kosten beim Optimum: {kosten_optimal:,.2f} EUR")
print(f"Ersparnis gegenueber naiver Planung: "
f"{erwartete_kosten(ERWARTUNGSWERT) - kosten_optimal:,.2f} EUR\n")
print("Kosten fuer ausgewaehlte x zum Vergleich:")
for x in [100, 225, 250, 300, 500]:
markierung = " <- Mittelwert" if x == 225 else (" <- Optimum" if x == 250 else "")
print(f" x={x:4d}: {erwartete_kosten(x):>12,.2f} EUR{markierung}")
print("\nDas Optimum liegt exakt auf einem Szenariowert (250 = 'Volatil'),")
print("nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.")

View file

@ -0,0 +1,112 @@
#!/usr/bin/env python3
# Infeasibility_Diagnose.py
"""
Kapitel Praxisfallen: Aus INFEASIBLE einen Notfallplan machen.
Demonstriert die hierarchische Relaxation an einem Dienstplan, der in der
harten Fassung unloesbar ist, und zeigt, wie der Solver die Ursache benennt.
"""
from ortools.sat.python import cp_model
SLOTS = ["Mo frueh", "Mo spaet", "Di frueh", "Di spaet"]
FAECHER = ["Mathematik", "Physik", "Mathematik", "Chemie"] # Chemie kann niemand!
PERSONAL = ["Alice", "Bob", "Carla"]
QUALIFIKATION = {
"Alice": {"Mathematik", "Physik"},
"Bob": {"Mathematik"},
"Carla": {"Physik", "Mathematik"},
}
MAX_PRO_PERSON = 2
STRAFE_UNBESETZT = 10_000 # sehr teuer, aber nicht unmoeglich
STRAFE_UEBERLAST = 500 # teuer, aber billiger als Ausfall
def plane(harte_fassung: bool):
"""harte_fassung=True: klassisch (kann INFEASIBLE werden).
harte_fassung=False: mit Schlupfvariablen (immer loesbar)."""
modell = cp_model.CpModel()
x = {(p, s): modell.NewBoolVar(f"x_{p}_{s}")
for p in PERSONAL for s in range(len(SLOTS))}
strafen = []
unbesetzt = {}
ueberlast = {}
for s in range(len(SLOTS)):
if harte_fassung:
modell.AddExactlyOne(x[p, s] for p in PERSONAL)
else:
# Schlupfvariable: der Slot DARF unbesetzt bleiben - gegen hohe Strafe
unbesetzt[s] = modell.NewBoolVar(f"unbesetzt_{s}")
modell.AddExactlyOne([x[p, s] for p in PERSONAL] + [unbesetzt[s]])
strafen.append(unbesetzt[s] * STRAFE_UNBESETZT)
# Qualifikation bleibt IMMER hart - fachfremder Unterricht ist keine Option
for s, fach in enumerate(FAECHER):
for p in PERSONAL:
if fach not in QUALIFIKATION[p]:
modell.Add(x[p, s] == 0)
for p in PERSONAL:
last = sum(x[p, s] for s in range(len(SLOTS)))
if harte_fassung:
modell.Add(last <= MAX_PRO_PERSON)
else:
# Ueberlast erlaubt - aber teuer
ueberlast[p] = modell.NewIntVar(0, len(SLOTS), f"ueberlast_{p}")
modell.Add(last <= MAX_PRO_PERSON + ueberlast[p])
strafen.append(ueberlast[p] * STRAFE_UEBERLAST)
if strafen:
modell.Minimize(sum(strafen))
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = 5.0
status = loeser.Solve(modell)
return loeser, status, x, unbesetzt, ueberlast
if __name__ == "__main__":
print("=" * 78)
print(" VARIANTE A: KLASSISCH MIT LAUTER HARTEN BEDINGUNGEN")
print("=" * 78)
loeser, status, *_ = plane(harte_fassung=True)
print(f"Solver-Status: {loeser.StatusName(status)}")
if status == cp_model.INFEASIBLE:
print("Das System kann keinen Plan liefern. Der Anwender erfaehrt NICHT,")
print("welche Regel das Problem verursacht - nur, dass es nicht geht.\n")
print("=" * 78)
print(" VARIANTE B: MIT HIERARCHISCHER RELAXATION")
print("=" * 78)
loeser, status, x, unbesetzt, ueberlast = plane(harte_fassung=False)
print(f"Solver-Status: {loeser.StatusName(status)} | "
f"Strafkosten: {loeser.ObjectiveValue():.0f}\n")
print(f"{'Slot':<12} {'Fach':<12} {'Zuweisung':<18} {'Bemerkung'}")
print("-" * 78)
for s, slot in enumerate(SLOTS):
if loeser.Value(unbesetzt[s]):
print(f"{slot:<12} {FAECHER[s]:<12} {'-- UNBESETZT --':<18} "
f"Kein qualifiziertes Personal verfuegbar")
else:
person = next(p for p in PERSONAL if loeser.Value(x[p, s]))
print(f"{slot:<12} {FAECHER[s]:<12} {person:<18}")
print("\n--- Diagnose ---")
for s, slot in enumerate(SLOTS):
if loeser.Value(unbesetzt[s]):
fach = FAECHER[s]
qualifiziert = [p for p in PERSONAL if fach in QUALIFIKATION[p]]
print(f" '{slot}' ({fach}): {len(qualifiziert)} qualifizierte Personen "
f"{qualifiziert if qualifiziert else '-> URSACHE: niemand kann dieses Fach'}")
for p in PERSONAL:
if loeser.Value(ueberlast[p]):
print(f" {p} arbeitet {loeser.Value(ueberlast[p])} Stunden ueber der Grenze.")
print("\nDer Anwender bekommt jetzt einen Plan PLUS eine konkrete Ursache -")
print("und kann handeln: Vertretung von aussen holen, Stunde verlegen,")
print("oder die Klasse zusammenlegen.")
print("=" * 78)

View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
# Installationstest.py
"""
Vorspann: Prüft die vollständige Kurs-Installation.
Ausgabe: eine Zeile pro Paket plus ein gelöstes Mini-Modell je Solver-Familie.
"""
import importlib.metadata
import importlib.util
import logging
import sys
PAKETE = [
("numpy", "Numerische Basis (Vektoren, Matrizen)"),
("scipy", "Wissenschaftliche Algorithmen, linprog/minimize"),
("pandas", "Tabellen und Zeitreihen"),
("matplotlib", "Diagramme"),
("ortools", "Google OR-Tools: CP-SAT und Routing"),
("highspy", "HiGHS-Solver, direkte Steuerung"),
("cvxpy", "Konvexe Optimierung (Portfolio, CVaR)"),
("sklearn", "Ledoit-Wolf-Shrinkage der Kovarianzmatrix"),
("yfinance", "Kursdatenbezug (nur die Finanzkapitel)"),
]
# Nur auf Anwesenheit prüfen, NICHT importieren (siehe Kapitel Ökosystem):
# - highspy: seine HiGHS-Bibliothek verträgt sich nicht mit der Kopie von
# ortools im selben Prozess.
# - cvxpy: importiert bei der Solver-Erkennung ein installiertes highspy
# selbst mit und löst so denselben Konflikt aus. Importiert wird cvxpy
# erst im Funktionstest, nachdem ortools bereits geladen ist.
NUR_PRUEFEN = {"highspy", "cvxpy"}
def paket_version(name: str) -> str:
"""Liefert die installierte Version; ImportError, falls das Paket fehlt."""
if name in NUR_PRUEFEN:
if importlib.util.find_spec(name) is None:
raise ImportError(name) # nicht installiert
try:
# Version aus den Metadaten — das Modul wird ja nicht geladen
return importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
return "unbekannt" # installiert, aber ohne Metadaten
modul = importlib.import_module(name)
return getattr(modul, "__version__", "unbekannt")
def pruefe_pakete() -> list[str]:
"""Prüft jedes Paket und meldet Version oder Fehlgrund."""
fehlend = []
print(f"Python-Version: {sys.version.split()[0]}\n")
print(f"{'Paket':<12} {'Version':<12} {'Zweck'}")
print("-" * 78)
for name, zweck in PAKETE:
try:
print(f"{name:<12} {paket_version(name):<12} {zweck}")
except ImportError:
print(f"{name:<12} {'FEHLT':<12} {zweck}")
fehlend.append(name)
return fehlend
def teste_cp_sat() -> bool:
"""Löst 'maximiere x+y unter x+2y<=10, x<=4' mit CP-SAT. Erwartet: x=4, y=3."""
from ortools.sat.python import cp_model
modell = cp_model.CpModel()
x = modell.NewIntVar(0, 4, "x")
y = modell.NewIntVar(0, 10, "y")
modell.Add(x + 2 * y <= 10)
modell.Maximize(x + y)
loeser = cp_model.CpSolver()
status = loeser.Solve(modell)
ok = status == cp_model.OPTIMAL and loeser.Value(x) == 4 and loeser.Value(y) == 3
print(f"CP-SAT : x={loeser.Value(x)}, y={loeser.Value(y)} -> {'OK' if ok else 'FEHLER'}")
return ok
def teste_scipy_linprog() -> bool:
"""Löst dasselbe Problem kontinuierlich mit HiGHS über SciPy. Erwartet: x=4, y=3."""
from scipy.optimize import linprog
# linprog minimiert -> Zielfunktion negieren, um zu maximieren
ergebnis = linprog(c=[-1, -1], A_ub=[[1, 2]], b_ub=[10],
bounds=[(0, 4), (0, 10)], method="highs")
ok = ergebnis.success and abs(ergebnis.x[0] - 4) < 1e-6 and abs(ergebnis.x[1] - 3) < 1e-6
print(f"SciPy/HiGHS : x={ergebnis.x[0]:.2f}, y={ergebnis.x[1]:.2f} -> {'OK' if ok else 'FEHLER'}")
return ok
def teste_cvxpy() -> bool:
"""Minimiert (x-2)^2 unter x<=1 mit CVXPY. Erwartet: x=1."""
# CVXPY warnt beim Import, wenn sein HIGHS-Interface wegen der
# HiGHS-Kollision (siehe Kapitel Ökosystem) nicht lädt — für diesen Test
# folgenlos, deshalb die Warnung kurz stillstellen.
logging.disable(logging.WARNING)
import cvxpy as cp
logging.disable(logging.NOTSET)
x = cp.Variable()
problem = cp.Problem(cp.Minimize(cp.square(x - 2)), [x <= 1])
problem.solve()
ok = problem.status == "optimal" and abs(x.value - 1.0) < 1e-6
print(f"CVXPY : x={x.value:.4f} -> {'OK' if ok else 'FEHLER'}")
return ok
if __name__ == "__main__":
# ortools' native Bibliothek zuerst laden (Kapitel Ökosystem): die zuerst
# geladene HiGHS-Kopie gewinnt — und das soll die von ortools sein.
if importlib.util.find_spec("ortools") is not None:
from ortools.sat.python import cp_model
fehlend = pruefe_pakete()
print("\nSolver-Funktionstest")
print("-" * 78)
if fehlend:
print(f"Abbruch: Es fehlen {len(fehlend)} Pakete: {', '.join(fehlend)}")
print("Installation: pip install " + " ".join(
"scikit-learn" if p == "sklearn" else p for p in fehlend))
sys.exit(1)
alle_ok = all([teste_cp_sat(), teste_scipy_linprog(), teste_cvxpy()])
print("-" * 78)
print("Alles bereit — Sie können mit dem ersten Kapitel beginnen."
if alle_ok else "Mindestens ein Solver arbeitet fehlerhaft.")
sys.exit(0 if alle_ok else 1)

View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
# JobShop_Intervalle.py
"""
Kapitel CP-SAT: Job-Shop-Scheduling mit Intervallvariablen.
Zeigt NewIntervalVar, AddNoOverlap und die Minimierung des Makespan.
"""
import collections
from ortools.sat.python import cp_model
# (Maschine, Dauer) je Arbeitsgang, in der Reihenfolge des Auftrags
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"]
def loese_jobshop():
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()
Gang = collections.namedtuple("Gang", "start ende intervall")
plaene = {}
maschinen_intervalle = collections.defaultdict(list)
# --- Intervallvariablen anlegen --------------------------------------
for a, auftrag in enumerate(AUFTRAEGE):
for g, (maschine, dauer) in enumerate(auftrag):
start = modell.NewIntVar(0, horizont, f"start_{a}_{g}")
ende = modell.NewIntVar(0, horizont, f"ende_{a}_{g}")
# Ein Intervall koppelt start + dauer == ende automatisch
intervall = modell.NewIntervalVar(start, dauer, ende, f"intervall_{a}_{g}")
plaene[a, g] = Gang(start, ende, intervall)
maschinen_intervalle[maschine].append(intervall)
# --- H1: eine Maschine bearbeitet nur einen Gang gleichzeitig --------
for maschine in range(anzahl_maschinen):
modell.AddNoOverlap(maschinen_intervalle[maschine])
# --- H2: Reihenfolge innerhalb eines Auftrags einhalten -------------
for a, auftrag in enumerate(AUFTRAEGE):
for g in range(len(auftrag) - 1):
modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)
# --- Ziel: Makespan minimieren ---------------------------------------
makespan = modell.NewIntVar(0, horizont, "makespan")
modell.AddMaxEquality(makespan, [plaene[a, len(auftrag) - 1].ende
for a, auftrag in enumerate(AUFTRAEGE)])
modell.Minimize(makespan)
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = 10.0
# Es gibt mehrere Plaene mit demselben kuerzesten Makespan. Welchen CP-SAT
# findet, haengt sonst davon ab, welcher seiner parallelen Suchstraenge
# zuerst fertig wird - dasselbe Programm liefert dann von Lauf zu Lauf
# verschiedene (gleich gute) Plaene. Fuer ein reproduzierbares Buchbeispiel
# fixieren wir beides. Im Produktivbetrieb laesst man die Standardwerte
# stehen: Mehrere Arbeiter sind dort deutlich schneller.
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1
status = loeser.Solve(modell)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
print(f"Kein Plan gefunden: {loeser.StatusName(status)}")
return
print("=" * 74)
print(" JOB-SHOP-SCHEDULING MIT INTERVALLVARIABLEN")
print("=" * 74)
print(f"Status: {loeser.StatusName(status)} | "
f"Kuerzeste Gesamtdauer (Makespan): {loeser.Value(makespan)} Zeiteinheiten\n")
# --- Gantt-Diagramm als Text ------------------------------------------
dauer_gesamt = loeser.Value(makespan)
print(f"{'Maschine':<14}|" + "".join(f"{t:<3}" for t in range(dauer_gesamt)))
print("-" * (15 + 3 * dauer_gesamt))
for maschine in range(anzahl_maschinen):
zeile = [" . "] * dauer_gesamt
for a, auftrag in enumerate(AUFTRAEGE):
for g, (m, dauer) in enumerate(auftrag):
if m == maschine:
beginn = loeser.Value(plaene[a, g].start)
for t in range(beginn, beginn + dauer):
zeile[t] = f" A{a}"
print(f"{MASCHINENNAMEN[maschine]:<14}|" + "".join(zeile))
print("\n--- Detailplan ---")
for a, auftrag in enumerate(AUFTRAEGE):
teile = []
for g, (maschine, dauer) in enumerate(auftrag):
beginn = loeser.Value(plaene[a, g].start)
teile.append(f"{MASCHINENNAMEN[maschine]} {beginn}-{beginn + dauer}")
print(f" Auftrag {a}: " + " -> ".join(teile))
# --- Untere Schranke zum Vergleich ------------------------------------
laengster_auftrag = max(sum(d for _, d in a) for a in AUFTRAEGE)
hoechste_maschinenlast = max(
sum(d for auftrag in AUFTRAEGE for m, d in auftrag if m == maschine)
for maschine in range(anzahl_maschinen))
schranke = max(laengster_auftrag, hoechste_maschinenlast)
print(f"\nUntere Schranke (laengster Auftrag / hoechste Maschinenlast): {schranke}")
print(f"Erreichter Makespan: {loeser.Value(makespan)}"
f"{' -> beweisbar bestmoeglich' if loeser.Value(makespan) == schranke else ''}")
print("=" * 74)
if __name__ == "__main__":
loese_jobshop()

View file

@ -0,0 +1,110 @@
#!/usr/bin/env python3
# KKT_Nachweis.py
"""
Kapitel QP/NLP: Die KKT-Bedingungen numerisch nachpruefen.
Loest ein QP mit CVXPY, liest die Dualwerte aus und prueft alle vier
KKT-Bedingungen einzeln nach. Das ist zugleich eine Vorlage fuer die
Qualitaetssicherung eigener Modelle.
"""
import numpy as np
import cvxpy as cp
def baue_gueltige_kovarianz(vola, korrelationen, seed=0):
"""
Baut Sigma = D * C * D aus Volatilitaeten und einer Korrelationsmatrix.
Dieses Vorgehen ist konstruktionsbedingt positiv semidefinit - im
Gegensatz zum nachtraeglichen Ueberschreiben der Diagonalen, das die
positive Semidefinitheit zerstoeren kann.
"""
C = np.array(korrelationen, dtype=float)
assert np.allclose(C, C.T), "Korrelationsmatrix muss symmetrisch sein."
assert np.allclose(np.diag(C), 1.0), "Diagonale der Korrelationsmatrix muss 1 sein."
eigen = np.linalg.eigvalsh(C)
assert eigen.min() > -1e-10, (
f"Korrelationsmatrix ist nicht positiv semidefinit "
f"(kleinster Eigenwert {eigen.min():.4f}). Solche Korrelationen sind unmoeglich.")
D = np.diag(vola)
return D @ C @ D
if __name__ == "__main__":
# --- Ein kleines Portfolio-QP ----------------------------------------
vola = np.array([0.20, 0.14, 0.30]) # Volatilitaeten
korr = [[1.00, 0.30, 0.10],
[0.30, 1.00, 0.25],
[0.10, 0.25, 1.00]]
Sigma = baue_gueltige_kovarianz(vola, korr)
mu = np.array([0.09, 0.05, 0.13]) # erwartete Renditen
lam = 3.0 # Risikoaversion
eigenwerte = np.linalg.eigvalsh(Sigma)
print("=" * 74)
print(" KKT-BEDINGUNGEN AM PORTFOLIO-QP")
print("=" * 74)
print(f"Eigenwerte von Sigma: {np.round(eigenwerte, 6)}")
print(f" -> positiv definit: {bool(eigenwerte.min() > 0)} "
f"(Problem ist streng konvex, Loesung eindeutig)")
print(f" -> Konditionszahl: {eigenwerte.max() / eigenwerte.min():.2f}")
# --- Modell: min lam/2 * w'Sigma w - mu'w u.d.N. sum(w)=1, w>=0 ----
n = len(mu)
w = cp.Variable(n)
ziel = cp.Minimize(0.5 * lam * cp.quad_form(w, Sigma) - mu @ w)
budget = cp.sum(w) == 1
nichtnegativ = w >= 0
problem = cp.Problem(ziel, [budget, nichtnegativ])
problem.solve()
w_opt = w.value
nu = budget.dual_value # Multiplikator der Gleichung
lam_i = nichtnegativ.dual_value # Multiplikatoren der Ungleichungen
print(f"\nStatus: {problem.status}")
print(f"Optimale Gewichte: {np.round(w_opt, 6)}")
print(f"Zielwert: {problem.value:.6f}")
print(f"Multiplikator der Budgetgleichung (nu): {nu:.6f}")
print(f"Multiplikatoren der w>=0-Bedingungen: {np.round(lam_i, 6)}")
# --- KKT-Bedingungen einzeln pruefen ---------------------------------
print("\n--- Pruefung der vier KKT-Bedingungen ---")
# 1. Stationaritaet: grad f - lambda + nu*1 = 0
# f(w) = lam/2 w'Sigma w - mu'w -> grad f = lam*Sigma w - mu
# g_i(w) = -w_i <= 0 -> grad g_i = -e_i
# h(w) = sum(w) - 1 = 0 -> grad h = 1
grad_f = lam * (Sigma @ w_opt) - mu
stationaritaet = grad_f - lam_i + nu * np.ones(n)
print(f"1. Stationaritaet : max|Residuum| = {np.abs(stationaritaet).max():.2e}")
# 2. Primale Zulaessigkeit
print(f"2. Primal zulaessig : sum(w)-1 = {w_opt.sum()-1:.2e}, "
f"min(w) = {w_opt.min():.2e}")
# 3. Duale Zulaessigkeit
print(f"3. Dual zulaessig : min(lambda) = {lam_i.min():.2e} (muss >= 0 sein)")
# 4. Komplementaerer Schlupf: lambda_i * w_i = 0
print(f"4. Kompl. Schlupf : max|lambda_i * w_i| = "
f"{np.abs(lam_i * w_opt).max():.2e}")
alle_ok = (np.abs(stationaritaet).max() < 1e-6
and abs(w_opt.sum() - 1) < 1e-8
and w_opt.min() > -1e-8
and lam_i.min() > -1e-8
and np.abs(lam_i * w_opt).max() < 1e-6)
print(f"\nAlle vier KKT-Bedingungen erfuellt: {alle_ok}")
# --- Interpretation von nu -------------------------------------------
print("\n--- Was bedeutet nu? ---")
print("nu ist der Schattenpreis des Budgets: Um so viel aendert sich der")
print("Zielwert, wenn man statt 100 % nur 99 % investieren duerfte.")
problem2 = cp.Problem(cp.Minimize(0.5 * lam * cp.quad_form(w, Sigma) - mu @ w),
[cp.sum(w) == 1.01, w >= 0])
problem2.solve()
print(f" Vorhergesagt (nu * 0.01): {nu * 0.01:+.6f}")
print(f" Tatsaechlich gemessen: {problem2.value - problem.value:+.6f}")
print("=" * 74)

View file

@ -0,0 +1,225 @@
#!/usr/bin/env python3
# Konfliktsuche.py
"""
Anhang Fehlerdiagnose: Den Konflikt finden, der INFEASIBLE verursacht.
Ein Deletion Filter (auch: IIS, Irreducible Infeasible Subset) isoliert aus
einem unloesbaren Modell eine kleinste widerspruechliche Teilmenge von
Bedingungen: Nimmt man aus ihr auch nur eine einzige Bedingung heraus, ist
der Rest wieder loesbar.
Das Programm zeigt sechs Dinge:
1. Der Solver meldet INFEASIBLE - und sonst nichts.
2. Die naive Suche ("jede Bedingung einmal weglassen") findet hier gar nichts.
3. Der Deletion Filter findet einen Konflikt in n Solveraufrufen.
4. Ein Konflikt ist selten einer: nach der Reparatur folgt der naechste.
5. "Den" kleinsten Konflikt gibt es nicht - dieses Modell enthaelt neun.
6. Welchen davon man zu sehen bekommt, steuert die Pruefreihenfolge.
Abgrenzung zu Infeasibility_Diagnose.py (Kapitel Praxisfallen): Dort geht es
um die Relaxation - das Modell soll trotz Widerspruch eine brauchbare Antwort
liefern. Hier geht es um die Diagnose - welche Bedingungen widersprechen sich
ueberhaupt. Beides zusammen ergibt den Umgang mit INFEASIBLE in Produktion.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import linprog
PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]
# Jede Bedingung traegt einen sprechenden Namen - das ist keine Kosmetik,
# sondern die Voraussetzung dafuer, dass der Befund lesbar wird.
# (Name, Koeffizienten je Produkt, Richtung, rechte Seite)
BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
("Kapazitaet Montage", [3, 2, 1, 4, 2], "<=", 400),
("Kapazitaet Lackieren", [2, 3, 0, 1, 0], "<=", 150),
("Kapazitaet Pruefung", [1, 1, 1, 1, 1], "<=", 300),
("Liefervertrag Rahmen", [1, 0, 0, 0, 0], ">=", 40),
("Liefervertrag Gehaeuse", [0, 1, 0, 0, 0], ">=", 30),
("Liefervertrag Deckel", [0, 0, 1, 0, 0], ">=", 20),
("Liefervertrag Traeger", [0, 0, 0, 1, 0], ">=", 25),
("Marktgrenze Rahmen", [1, 0, 0, 0, 0], "<=", 120),
("Marktgrenze Gehaeuse", [0, 1, 0, 0, 0], "<=", 90),
("Marktgrenze Deckel", [0, 0, 1, 0, 0], "<=", 150),
("Marktgrenze Halter", [0, 0, 0, 0, 1], "<=", 200),
("Mindestumsatz", [90, 70, 30, 60, 20], ">=", 12000),
("Sortimentsbreite", [0, 0, 1, 1, 1], ">=", 100),
("Lackierbudget Schicht 2", [0, 1, 0, 1, 0], "<=", 55),
]
# Welche Bedingungen koennte ein Planer im Ernstfall wirklich veraendern?
# Kapazitaeten lassen sich durch Sonderschichten dehnen, Liefervertraege und
# Marktgrenzen nicht.
VERHANDELBAR = {"Kapazitaet Montage", "Kapazitaet Lackieren",
"Kapazitaet Pruefung", "Lackierbudget Schicht 2"}
aufrufe = 0 # zaehlt jeden Solveraufruf mit - der Preis des Verfahrens
def ist_loesbar(auswahl: list[int]) -> bool:
"""Gibt es einen Punkt, der ALLE Bedingungen aus 'auswahl' erfuellt?
Es wird nur Zulaessigkeit geprueft, keine Zielfunktion optimiert: die
Zielfunktion ist konstant null. INFEASIBLE haengt nie an der Zielfunktion.
"""
global aufrufe
aufrufe += 1
matrix, rechte_seite = [], []
for i in auswahl:
_, koeffizienten, richtung, grenze = BEDINGUNGEN[i]
zeile = np.array(koeffizienten, dtype=float)
# linprog kennt nur "<=", also ">=" durch Negation umdrehen
matrix.append(zeile if richtung == "<=" else -zeile)
rechte_seite.append(grenze if richtung == "<=" else -grenze)
ergebnis = linprog(np.zeros(len(PRODUKTE)),
A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
bounds=(0, None), method="highs")
return ergebnis.status == 0
def deletion_filter(auswahl: list[int]) -> list[int]:
"""Verkleinert eine unloesbare Menge zu einer kleinsten unloesbaren Menge.
Der Kern des Verfahrens ist eine einzige Regel: Nimm eine Bedingung
versuchsweise heraus. Bleibt der Rest unloesbar, wurde sie fuer den
Widerspruch nicht gebraucht - sie darf endgueltig weg. Wird der Rest
loesbar, war sie beteiligt und muss bleiben.
Nach genau einem Durchlauf ueber alle Bedingungen ist das Ergebnis
unreduzierbar: n Solveraufrufe statt 2^n Teilmengen.
"""
rest = list(auswahl)
for i in list(auswahl):
probe = [j for j in rest if j != i]
if not ist_loesbar(probe):
rest = probe
return rest
def namen(auswahl: list[int]) -> list[str]:
return [BEDINGUNGEN[i][0] for i in auswahl]
def teil_1_der_befund(alle: list[int]) -> None:
print("=" * 68)
print("1. Was der Solver sagt")
print("=" * 68)
print(f"Modell: {len(PRODUKTE)} Variablen, {len(BEDINGUNGEN)} Bedingungen")
status = "OPTIMAL" if ist_loesbar(alle) else "INFEASIBLE"
print(f"Status: {status}")
print("\nMehr ist es nicht. Der Solver nennt keine Ursache, weil es die eine")
print("Ursache nicht gibt: Unloesbarkeit ist eine Eigenschaft von Mengen von")
print("Bedingungen, nicht von einzelnen Bedingungen.")
def teil_2_die_naive_suche(alle: list[int]) -> None:
print("\n" + "=" * 68)
print("2. Die naheliegende Idee - und warum sie hier scheitert")
print("=" * 68)
print("Jede Bedingung einmal weglassen und schauen, ob es dann geht:\n")
treffer = [i for i in alle if ist_loesbar([j for j in alle if j != i])]
for i in alle:
print(f" ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if i in treffer else 'weiter unloesbar'}")
print(f"\nGefundene Schuldige: {len(treffer)}")
print("Keine einzelne Bedingung ist schuld. Genau das ist der Normalfall -")
print("und der Grund, warum diese Suche in der Praxis so oft im Nichts endet.")
def teil_3_der_filter(alle: list[int]) -> list[int]:
global aufrufe
print("\n" + "=" * 68)
print("3. Der Deletion Filter")
print("=" * 68)
vorher = aufrufe
konflikt = deletion_filter(alle)
kosten = aufrufe - vorher
print(f"{kosten} Solveraufrufe -> Konflikt aus {len(konflikt)} von "
f"{len(BEDINGUNGEN)} Bedingungen:\n")
for name in namen(konflikt):
print(f" * {name}")
print("\nProbe auf Unreduzierbarkeit - jede einzelne davon weglassen:")
for i in konflikt:
rest_loesbar = ist_loesbar([j for j in konflikt if j != i])
print(f" ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if rest_loesbar else 'UNLOESBAR - nicht minimal!'}")
print(f"\nAufwand: {kosten} Aufrufe. Alle Teilmengen durchzuprobieren waeren "
f"2^{len(BEDINGUNGEN)} = {2 ** len(BEDINGUNGEN):,} gewesen.".replace(",", "."))
return konflikt
def teil_4_der_naechste_konflikt(alle: list[int], konflikt: list[int]) -> None:
print("\n" + "=" * 68)
print("4. Ein Konflikt ist selten einer")
print("=" * 68)
entfernt = konflikt[0]
rest = [i for i in alle if i != entfernt]
print(f"Angenommen, '{BEDINGUNGEN[entfernt][0]}' laesst sich verhandeln")
print("und wird aus dem Modell genommen. Dann ist das Modell ...")
if ist_loesbar(rest):
print("... loesbar. Fertig.")
return
print("... immer noch unloesbar. Der Filter erneut:\n")
zweiter = deletion_filter(rest)
for name in namen(zweiter):
print(f" * {name}")
gemeinsam = set(zweiter) & set(konflikt)
print(f"\nUeberschneidung mit dem ersten Konflikt: {len(gemeinsam)} Bedingungen")
print("Ein zweiter, unabhaengiger Widerspruch, den der erste verdeckt hat.")
print("Deshalb ist die Konfliktsuche eine Schleife, kein einzelner Aufruf:")
print("reparieren, neu suchen, bis das Modell loesbar ist.")
def teil_5_die_reihenfolge(alle: list[int]) -> None:
print("\n" + "=" * 68)
print("5. Es gibt nicht DEN Konflikt")
print("=" * 68)
print("Derselbe Filter, nur eine andere Pruefreihenfolge - 200-mal gewuerfelt:\n")
zufall = np.random.default_rng(0)
haeufigkeit: dict[tuple[int, ...], int] = {}
for _ in range(200):
ordnung = [int(i) for i in zufall.permutation(alle)]
schluessel = tuple(sorted(deletion_filter(ordnung)))
haeufigkeit[schluessel] = haeufigkeit.get(schluessel, 0) + 1
for schluessel, anzahl in sorted(haeufigkeit.items(), key=lambda p: -p[1]):
print(f" {anzahl:3d}x {len(schluessel)} Bedingungen: "
f"{', '.join(namen(list(schluessel)))}")
print(f"\n{len(haeufigkeit)} verschiedene minimale Konflikte in EINEM Modell.")
print("Der Filter liefert *einen* kleinsten Konflikt, nicht *den* kleinsten -")
print("den gibt es nicht. Alle oben sind gleichermassen korrekt.")
def teil_6_den_befund_steuern(alle: list[int]) -> None:
print("\n" + "=" * 68)
print("6. Den Befund brauchbar machen")
print("=" * 68)
print("Der Filter wirft heraus, was er zuerst in die Hand bekommt. Wer die")
print("unveraenderlichen Bedingungen zuerst pruefen laesst, bekommt sie eher")
print("aus dem Befund heraus - und sieht dafuer mehr Stellschrauben.\n")
fest = [i for i in alle if BEDINGUNGEN[i][0] not in VERHANDELBAR]
frei = [i for i in alle if BEDINGUNGEN[i][0] in VERHANDELBAR]
for titel, ordnung in (("unveraenderliche zuerst", fest + frei),
("veraenderliche zuerst ", frei + fest)):
konflikt = sorted(deletion_filter(ordnung))
stellschrauben = [i for i in konflikt if BEDINGUNGEN[i][0] in VERHANDELBAR]
print(f" {titel} -> {len(konflikt)} Bedingungen, davon "
f"{len(stellschrauben)} veraenderlich")
print(f" {', '.join(namen(konflikt))}")
print("\nBeide Befunde sind wahr. Nur einer davon nennt dem Planer etwas,")
print("das er tatsaechlich tun kann.")
if __name__ == "__main__":
alle = list(range(len(BEDINGUNGEN)))
teil_1_der_befund(alle)
teil_2_die_naive_suche(alle)
konflikt = teil_3_der_filter(alle)
teil_4_der_naechste_konflikt(alle, konflikt)
teil_5_die_reihenfolge(alle)
teil_6_den_befund_steuern(alle)
print("\n" + "=" * 68)
print(f"Insgesamt {aufrufe} Solveraufrufe fuer die gesamte Diagnose.")
print("=" * 68)

View file

@ -0,0 +1,73 @@
#!/usr/bin/env python3
# Konvexitaet_Demo.py
"""
Kapitel Fundament: Konvexität praktisch erfahrbar machen.
Teil 1: Die Sehnen-Bedingung numerisch nachprüfen.
Teil 2: Zeigen, warum lokale Suche bei nicht-konvexen Funktionen scheitert.
"""
import numpy as np
from scipy.optimize import minimize
# --- Teil 1: Sehnen-Test ---------------------------------------------------
def ist_konvex_numerisch(f, unten, oben, proben=2000, seed=0):
"""
Prüft die Konvexitätsdefinition an zufälligen Punktepaaren:
f(theta*x + (1-theta)*y) <= theta*f(x) + (1-theta)*f(y) ?
Findet Gegenbeispiele - beweist aber KEINE Konvexität.
"""
rng = np.random.default_rng(seed)
schlimmste_verletzung = 0.0
for _ in range(proben):
x, y = rng.uniform(unten, oben, size=2)
theta = rng.uniform(0.0, 1.0)
links = f(theta * x + (1 - theta) * y)
rechts = theta * f(x) + (1 - theta) * f(y)
schlimmste_verletzung = max(schlimmste_verletzung, links - rechts)
return schlimmste_verletzung
funktionen = {
"f(x) = x^2 (konvex)": lambda x: x ** 2,
"f(x) = |x| (konvex)": lambda x: abs(x),
"f(x) = e^x (konvex)": lambda x: np.exp(x),
"f(x) = x^3 (NICHT konvex)": lambda x: x ** 3,
"f(x) = x^2+3sin(3x) (NICHT konvex)": lambda x: x ** 2 + 3 * np.sin(3 * x),
}
print("=" * 68)
print(" TEIL 1: SEHNEN-TEST (positive Zahl = Konvexität verletzt)")
print("=" * 68)
for name, f in funktionen.items():
verletzung = ist_konvex_numerisch(f, -3.0, 3.0)
urteil = "konvex (keine Verletzung gefunden)" if verletzung < 1e-9 \
else f"NICHT konvex (Verletzung bis {verletzung:.3f})"
print(f"{name:<34} -> {urteil}")
# --- Teil 2: Lokale Suche von verschiedenen Startpunkten -------------------
def wellige_funktion(x):
"""Nicht-konvex: eine Parabel mit aufmodulierter Welle -> viele lokale Minima."""
return x[0] ** 2 + 3.0 * np.sin(3.0 * x[0])
print("\n" + "=" * 68)
print(" TEIL 2: LOKALE SUCHE BEI NICHT-KONVEXER FUNKTION")
print("=" * 68)
print(f"{'Startpunkt':>12} | {'gefundenes Minimum':>20} | {'Funktionswert':>14}")
print("-" * 68)
ergebnisse = []
for start in [-3.0, -1.5, 0.0, 1.5, 3.0]:
res = minimize(wellige_funktion, x0=[start], method="BFGS")
ergebnisse.append((start, res.x[0], res.fun))
print(f"{start:>12.1f} | {res.x[0]:>20.4f} | {res.fun:>14.4f}")
bester = min(ergebnisse, key=lambda t: t[2])
print("-" * 68)
print(f"Je nach Startpunkt landet derselbe Algorithmus in "
f"{len({round(e[1], 3) for e in ergebnisse})} verschiedenen Minima.")
print(f"Das beste gefundene: x = {bester[1]:.4f} mit f = {bester[2]:.4f} "
f"(Start bei {bester[0]:.1f})")
print("Bei einer KONVEXEN Funktion waeren alle Zeilen identisch --")
print("der Startpunkt waere voellig gleichgueltig.")
print("=" * 68)

View file

@ -0,0 +1,123 @@
#!/usr/bin/env python3
# Kovarianz_Falle.py
"""
Kapitel Finanzdaten: Warum die historische Kovarianzmatrix den Optimierer belaeugt.
Ein Portfolio aus 30 Anlagen soll das Risiko minimieren. Geschaetzt wird die
Kovarianzmatrix aus 20 Beobachtungen - in der Praxis ein voellig ueblicher
Fall (30 Titel, ein Quartal Wochendaten).
Das Ergebnis ist ein Portfolio mit einem gemessenen Risiko von 0,000 %. Der
Optimierer haelt es fuer risikofrei. Ausserhalb des Schaetzzeitraums ist es
das schlechteste der drei untersuchten Portfolios - schlechter sogar als
blosse Gleichgewichtung.
Der Grund ist nicht Zufall, sondern Struktur: Bei N Anlagen und T < N
Beobachtungen hat die Stichproben-Kovarianzmatrix hoechstens Rang T-1. Es gibt
dann ganze Richtungen im Gewichtsraum, in denen sie exakt null Varianz misst -
Richtungen, die es in Wirklichkeit nicht gibt. Der Optimierer findet sie
zuverlaessig, denn er sucht ja genau danach.
Die Daten sind synthetisch (ein Marktfaktor plus Eigenrauschen), damit das
Ergebnis reproduzierbar ist und kein Internetzugang noetig wird. Mit echten
Kursdaten sieht es genauso aus.
Benoetigt: numpy, cvxpy, scikit-learn
"""
from __future__ import annotations
import numpy as np
import cvxpy as cp
from sklearn.covariance import LedoitWolf
ANZAHL_ANLAGEN = 30
BEOBACHTUNGEN_SCHAETZUNG = 20 # weniger als Anlagen - genau darum geht es
BEOBACHTUNGEN_TEST = 2000 # der "spaetere Verlauf"
RNG = np.random.default_rng(11)
# Jede Anlage reagiert unterschiedlich stark auf den Gesamtmarkt.
BETA = RNG.uniform(0.6, 1.4, ANZAHL_ANLAGEN)
def erzeuge_renditen(perioden: int) -> np.ndarray:
"""Ein Marktfaktor, auf den alle reagieren, plus Eigenrauschen je Anlage."""
markt = RNG.normal(0.0, 0.010, perioden)
eigen = RNG.normal(0.0, 0.012, (perioden, ANZAHL_ANLAGEN))
return np.outer(markt, BETA) + eigen
def minimales_risiko(kovarianz: np.ndarray) -> tuple[np.ndarray, float]:
"""Minimum-Varianz-Portfolio: Gewichte summieren sich zu 1, sonst frei.
cp.psd_wrap() sagt CVXPY: 'Ich weiss, dass diese Matrix numerisch nicht
exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so
gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im
Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man
nachgehen muss (siehe Denkfehler 8.1).
"""
gewichte = cp.Variable(ANZAHL_ANLAGEN)
problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),
[cp.sum(gewichte) == 1])
problem.solve()
return np.array(gewichte.value).ravel(), max(problem.value, 0.0)
if __name__ == "__main__":
schaetzzeitraum = erzeuge_renditen(BEOBACHTUNGEN_SCHAETZUNG)
spaeterer_verlauf = erzeuge_renditen(BEOBACHTUNGEN_TEST)
stichprobe = np.cov(schaetzzeitraum, rowvar=False)
ledoit_wolf = LedoitWolf().fit(schaetzzeitraum)
print("=" * 78)
print(" DIE KOVARIANZ-FALLE")
print("=" * 78)
print(f"{ANZAHL_ANLAGEN} Anlagen, geschaetzt aus {BEOBACHTUNGEN_SCHAETZUNG} "
f"Beobachtungen.")
print(f"Rang der Stichprobenmatrix: "
f"{np.linalg.matrix_rank(stichprobe)} - noetig waeren {ANZAHL_ANLAGEN}.")
print(f"Kleinster Eigenwert: {np.linalg.eigvalsh(stichprobe).min():.2e}")
print()
print("Es gibt also Richtungen, in denen diese Matrix EXAKT null Varianz")
print("misst. Genau dort sucht ein Minimum-Varianz-Optimierer.")
print()
print(f"{'Schaetzer':<26} {'Risiko im Zeitraum':>19} "
f"{'Risiko spaeter':>16} {'Hebel':>7}")
print("-" * 78)
ergebnisse = {}
for name, matrix in [
("Stichprobe (roh)", stichprobe),
(f"Ledoit-Wolf (d={ledoit_wolf.shrinkage_:.2f})", ledoit_wolf.covariance_)]:
gewichte, varianz_intern = minimales_risiko(matrix)
risiko_spaeter = float(np.std(spaeterer_verlauf @ gewichte))
hebel = float(np.abs(gewichte).sum())
ergebnisse[name] = (risiko_spaeter, gewichte)
print(f"{name:<26} {np.sqrt(varianz_intern) * 100:>18.3f} % "
f"{risiko_spaeter * 100:>15.3f} % {hebel:>7.1f}")
gleich = np.full(ANZAHL_ANLAGEN, 1.0 / ANZAHL_ANLAGEN)
print(f"{'Gleichgewichtung 1/N':<26} "
f"{float(np.std(schaetzzeitraum @ gleich)) * 100:>18.3f} % "
f"{float(np.std(spaeterer_verlauf @ gleich)) * 100:>15.3f} % {1.0:>7.1f}")
print("-" * 78)
print("\nDrei Beobachtungen:")
print("1. Die rohe Schaetzung meldet 0,000 % Risiko - und liefert spaeter das")
print(" SCHLECHTESTE Ergebnis der drei. Sie hat kein Portfolio optimiert,")
print(" sondern eine Luecke in den eigenen Daten gefunden.")
print("2. Die Gleichgewichtung, die gar nicht optimiert, schlaegt den rohen")
print(" Optimierer deutlich. Das ist kein Zufallsbefund, sondern in der")
print(" Literatur breit belegt.")
print("3. Ledoit-Wolf ist im Schaetzzeitraum EHRLICHER (0,441 statt 0,000 %)")
print(" und spaeter am besten. Der Hebel faellt von 3,8 auf 1,8 - das")
print(" Portfolio wird auch praktisch handelbarer.")
print()
print("Merksatz: Ein Optimierer glaubt seinen Eingabedaten vollstaendig.")
print("Je mehr Freiheit Sie ihm geben, desto gruendlicher findet er deren")
print("Fehler. Das gilt fuer Kovarianzmatrizen wie fuer Lieferzeiten,")
print("Ausfallraten und jede andere geschaetzte Groesse.")
print("=" * 78)

View file

@ -0,0 +1,338 @@
#!/usr/bin/env python3
# Kraftwerkseinsatz.py
"""
Kapitel Supply-Chain: Welche Bloecke laufen morgen? Und was, wenn kein Wind weht?
Die Kraftwerkseinsatzplanung (englisch Unit Commitment) ist die Aufgabe, an der
sich in der Energiewirtschaft alles entscheidet: Fuer jede Stunde des naechsten
Tages muss feststehen, welche Bloecke am Netz sind. Ein Kernblock braucht acht
Stunden Mindestlaufzeit und 40.000 Euro Anfahrkosten - wer ihn abschaltet, hat
ihn fuer den Rest des Tages verloren.
Das Besondere liegt in der Zeitstruktur, und es geht ueber das zweistufige
Modell aus dem Kapitel Unsicherheit hinaus:
STUFE 1 Das AN/AUS je Block und Stunde. Binaer, und es steht am Vorabend
fest - bevor irgendjemand weiss, wie viel Wind morgen weht.
STUFE 2 Die Fahrweise: wie viel jeder laufende Block liefert. Das darf
sich stundenweise an die Wirklichkeit anpassen.
Die erste Stufe ist also GANZZAHLIG und szenariouebergreifend gleich, die
zweite kontinuierlich und je Szenario verschieden. Genau diese Kombination
macht das Problem interessant - und sie ist der Grund, warum ein Plan, der auf
den Wind-Erwartungswert gerechnet wurde, in der Wirklichkeit teuer wird.
Das Programm zeigt drei Plaene auf denselben Daten:
1. Deterministisch: gerechnet mit dem Wind-ERWARTUNGSWERT, danach gegen 40
Szenarien ausgewertet.
2. Zweistufig: der Commitment-Plan sieht alle 40 Szenarien.
3. Mit Versorgungssicherheit: die Nichtdeckung in den schlechtesten Faellen
wird begrenzt - und der Preis dafuer in Euro je vermiedener MWh
ausgewiesen.
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import time
import numpy as np
from ortools.linear_solver import pywraplp
# (Name, Mindestleistung, Nennleistung, Grenzkosten, Anfahrkosten, Mindestlaufzeit)
KRAFTWERKE = [
("Kernblock", 200, 600, 22, 40_000, 8),
("Braunkohle", 100, 400, 35, 18_000, 6),
("Steinkohle", 80, 300, 48, 12_000, 4),
("Gas GuD", 50, 250, 72, 6_000, 2),
("Gasturbine", 20, 150, 105, 1_500, 1),
]
STUNDEN = 24
SZENARIEN = 40
FLAUTENANTEIL = 0.15
ALPHA = 0.90 # die schlechtesten 10 % der Szenarien
LASTABWURF = 3_000.0 # EUR je nicht gedeckter MWh ("value of lost load")
NIEDRIGER_ABWURFPREIS = 300.0 # zum Vergleich: ein Marktpreisdeckel
SAAT = 4
_t = np.arange(STUNDEN)
LAST = 620 + 260 * np.sin((_t - 7) / 24 * 2 * np.pi) + 90 * np.sin((_t - 4) / 12 * 2 * np.pi)
WIND_ERWARTUNG = np.maximum(0.0, 160 + 130 * np.sin((_t - 14) / 24 * 2 * np.pi))
def erzeuge_windszenarien(anzahl: int = SZENARIEN, saat: int = SAAT):
"""Windeinspeisung je Szenario und Stunde.
Zwei Zutaten, die zusammen den Unterschied machen: eine breite
lognormale Streuung des Tagesniveaus - und in 15 % der Faelle eine
DUNKELFLAUTE, in der praktisch gar kein Wind weht. Solche seltenen,
extremen Faelle sind der Grund, warum der Erwartungswert als
Planungsgrundlage nicht genuegt.
"""
rng = np.random.default_rng(saat)
tagesniveau = rng.lognormal(0, 0.40, (anzahl, 1))
wind = np.clip(WIND_ERWARTUNG * tagesniveau
+ rng.normal(0, 25, (anzahl, STUNDEN)), 0, 420)
ist_flaute = rng.random(anzahl) < FLAUTENANTEIL
wind[ist_flaute] *= 0.05
return wind, ist_flaute
def plane(wind: np.ndarray, gewichte, cvar_grenze: float | None = None,
abwurfpreis: float = None, zeitlimit: float = 300.0):
"""Commitment (Stufe 1) und Fahrweise je Szenario (Stufe 2) in einem Modell.
'wind' hat die Form (Szenarien, Stunden). Mit einer einzigen Zeile
Windprognose wird daraus die deterministische Planung.
'cvar_grenze' begrenzt den CVaR der Nichtdeckung ueber die Szenarien -
dieselbe Rockafellar-Uryasev-Konstruktion wie im Kapitel CVaR, nur dass
hier keine Verluste in Euro, sondern Megawattstunden begrenzt werden.
"""
abwurfpreis = LASTABWURF if abwurfpreis is None else abwurfpreis
anzahl_szenarien = wind.shape[0]
anzahl_bloecke = len(KRAFTWERKE)
solver = pywraplp.Solver.CreateSolver("SCIP")
solver.SetTimeLimit(int(zeitlimit * 1000))
# --- Stufe 1: binaer, szenariouebergreifend gleich --------------------
laeuft = [[solver.BoolVar(f"laeuft_{k}_{t}") for t in range(STUNDEN)]
for k in range(anzahl_bloecke)]
faehrt_an = [[solver.BoolVar(f"start_{k}_{t}") for t in range(STUNDEN)]
for k in range(anzahl_bloecke)]
# --- Stufe 2: kontinuierlich, je Szenario -----------------------------
leistung = [[[solver.NumVar(0, KRAFTWERKE[k][2], f"p_{j}_{k}_{t}")
for t in range(STUNDEN)] for k in range(anzahl_bloecke)]
for j in range(anzahl_szenarien)]
nichtdeckung = [[solver.NumVar(0, solver.infinity(), f"y_{j}_{t}")
for t in range(STUNDEN)] for j in range(anzahl_szenarien)]
for k, (_, pmin, pmax, _, _, mindestlaufzeit) in enumerate(KRAFTWERKE):
for t in range(STUNDEN):
# Anfahren erkennen: aus im Vortakt, an im aktuellen
vorher = laeuft[k][t - 1] if t > 0 else 0
solver.Add(faehrt_an[k][t] >= laeuft[k][t] - vorher)
# Mindestlaufzeit: wer anfaehrt, laeuft die naechsten Stunden weiter
for spaeter in range(t, min(STUNDEN, t + mindestlaufzeit)):
solver.Add(laeuft[k][spaeter] >= faehrt_an[k][t])
# Ein laufender Block liefert zwischen Mindest- und Nennleistung,
# ein stehender gar nichts. Das koppelt beide Stufen.
for j in range(anzahl_szenarien):
solver.Add(leistung[j][k][t] >= pmin * laeuft[k][t])
solver.Add(leistung[j][k][t] <= pmax * laeuft[k][t])
for j in range(anzahl_szenarien):
for t in range(STUNDEN):
solver.Add(sum(leistung[j][k][t] for k in range(anzahl_bloecke))
+ float(wind[j, t]) + nichtdeckung[j][t] >= LAST[t])
# --- Versorgungssicherheit als CVaR-Schranke --------------------------
if cvar_grenze is not None:
schwelle = solver.NumVar(-solver.infinity(), solver.infinity(), "schwelle")
ueberschuss = [solver.NumVar(0, solver.infinity(), f"u_{j}")
for j in range(anzahl_szenarien)]
for j in range(anzahl_szenarien):
solver.Add(ueberschuss[j] >= sum(nichtdeckung[j][t]
for t in range(STUNDEN)) - schwelle)
solver.Add(schwelle + (1.0 / (anzahl_szenarien * (1 - ALPHA)))
* sum(ueberschuss) <= cvar_grenze)
anfahrkosten = sum(KRAFTWERKE[k][4] * faehrt_an[k][t]
for k in range(anzahl_bloecke) for t in range(STUNDEN))
betriebskosten = sum(
gewichte[j] * sum(KRAFTWERKE[k][3] * leistung[j][k][t]
for k in range(anzahl_bloecke) for t in range(STUNDEN))
for j in range(anzahl_szenarien))
abwurfkosten = sum(gewichte[j] * abwurfpreis
* sum(nichtdeckung[j][t] for t in range(STUNDEN))
for j in range(anzahl_szenarien))
solver.Minimize(anfahrkosten + betriebskosten + abwurfkosten)
beginn = time.perf_counter()
status = solver.Solve()
dauer = time.perf_counter() - beginn
if status not in (pywraplp.Solver.OPTIMAL, pywraplp.Solver.FEASIBLE):
return None, dauer, False
plan = np.array([[laeuft[k][t].solution_value() for t in range(STUNDEN)]
for k in range(anzahl_bloecke)]).round()
return plan, dauer, status == pywraplp.Solver.OPTIMAL
def bewerte(plan: np.ndarray, wind: np.ndarray, abwurfpreis: float = None):
"""Commitment steht fest - nur noch die Fahrweise je Szenario optimieren.
Das ist die ehrliche Bewertung eines Plans: Er wird der Wirklichkeit
ausgesetzt und darf nur noch das anpassen, was sich am Tag selbst
anpassen laesst.
"""
abwurfpreis = LASTABWURF if abwurfpreis is None else abwurfpreis
anzahl_bloecke = len(KRAFTWERKE)
kosten, fehlmengen = [], []
for j in range(wind.shape[0]):
solver = pywraplp.Solver.CreateSolver("GLOP")
leistung = [[solver.NumVar(0, KRAFTWERKE[k][2], f"p_{k}_{t}")
for t in range(STUNDEN)] for k in range(anzahl_bloecke)]
fehlt = [solver.NumVar(0, solver.infinity(), f"y_{t}")
for t in range(STUNDEN)]
for k, (_, pmin, pmax, _, _, _) in enumerate(KRAFTWERKE):
for t in range(STUNDEN):
solver.Add(leistung[k][t] >= pmin * plan[k, t])
solver.Add(leistung[k][t] <= pmax * plan[k, t])
for t in range(STUNDEN):
solver.Add(sum(leistung[k][t] for k in range(anzahl_bloecke))
+ float(wind[j, t]) + fehlt[t] >= LAST[t])
solver.Minimize(
sum(KRAFTWERKE[k][3] * leistung[k][t]
for k in range(anzahl_bloecke) for t in range(STUNDEN))
+ sum(abwurfpreis * fehlt[t] for t in range(STUNDEN)))
solver.Solve()
kosten.append(solver.Objective().Value())
fehlmengen.append(sum(f.solution_value() for f in fehlt))
anfahrten = sum(KRAFTWERKE[k][4]
* max(0.0, plan[k, t] - (plan[k, t - 1] if t > 0 else 0.0))
for k in range(anzahl_bloecke) for t in range(STUNDEN))
return np.array(kosten) + anfahrten, np.array(fehlmengen)
def cvar(werte: np.ndarray, alpha: float = ALPHA) -> float:
"""Mittelwert der schlechtesten (1-alpha) Faelle."""
schwelle = np.quantile(werte, alpha)
schlechteste = werte[werte >= schwelle]
return float(schlechteste.mean()) if len(schlechteste) else 0.0
def zeige(name: str, plan, wind, abwurfpreis: float = None) -> dict:
kosten, fehl = bewerte(plan, wind, abwurfpreis)
kennzahlen = {"mittel": kosten.mean(), "max": kosten.max(),
"fehl_mittel": fehl.mean(), "fehl_cvar": cvar(fehl),
"betroffen": int((fehl > 0.01).sum()),
"blockstunden": int(plan.sum())}
print(f" {name:<32} {kennzahlen['mittel']:>10,.0f} {kennzahlen['max']:>11,.0f} "
f"{kennzahlen['fehl_mittel']:>9.1f} {kennzahlen['fehl_cvar']:>10.1f} "
f"{kennzahlen['betroffen']:>6}/{len(fehl)}")
return kennzahlen
if __name__ == "__main__":
wind, ist_flaute = erzeuge_windszenarien()
print("=" * 88)
print(" KRAFTWERKSEINSATZ: DER PLAN STEHT, BEVOR DER WIND WEHT")
print("=" * 88)
print(f"{len(KRAFTWERKE)} Bloecke, {STUNDEN} Stunden, {SZENARIEN} Windszenarien "
f"(davon {int(ist_flaute.sum())} Dunkelflauten).")
print(f"Last {LAST.min():.0f} bis {LAST.max():.0f} MW, "
f"Wind im Erwartungswert {WIND_ERWARTUNG.min():.0f} bis "
f"{WIND_ERWARTUNG.max():.0f} MW.")
print(f"Nicht gedeckte Last kostet {LASTABWURF:,.0f} EUR je MWh.\n")
print(f" {'Planungsgrundlage':<32} {'Kosten':>10} {'Kosten':>11} "
f"{'Fehlmenge':>9} {'Fehlmenge':>10} {'Szen. mit':>12}")
print(f" {'':<32} {'im Mittel':>10} {'schlimmst.':>11} "
f"{'Mittel':>9} {'CVaR 90%':>10} {'Abwurf':>12}")
print(" " + "-" * 86)
# --- 1. Deterministisch ----------------------------------------------
plan_det, dauer_det, _ = plane(WIND_ERWARTUNG.reshape(1, -1), [1.0])
det = zeige("nur Wind-Erwartungswert", plan_det, wind)
# --- 2. Zweistufig, risikoneutral ------------------------------------
gleich = [1.0 / SZENARIEN] * SZENARIEN
plan_sto, dauer_sto, optimal_sto = plane(wind, gleich)
sto = zeige("alle 40 Szenarien", plan_sto, wind)
# --- 3. Mit Versorgungssicherheit ------------------------------------
plan_sicher, dauer_sicher, optimal_sicher = plane(wind, gleich, cvar_grenze=0.0)
sicher = zeige("+ Nichtdeckung CVaR 90 % = 0", plan_sicher, wind)
print(f"\n Rechenzeiten: {dauer_det:.1f}s / {dauer_sto:.1f}s / {dauer_sicher:.1f}s "
f"(alle beweisbar optimal: {optimal_sto and optimal_sicher})")
# --- Was die Zeilen bedeuten -----------------------------------------
print("\n" + "-" * 88)
print("Was der Erwartungswert-Plan anrichtet\n")
print(f" Er ist auf dem Papier der billigste - gerechnet auf den mittleren")
print(f" Wind kostet er weniger als jeder andere. In der Wirklichkeit liegt")
print(f" er {det['mittel'] / sto['mittel'] - 1:+.0%} ueber dem zweistufigen Plan, und sein "
f"schlimmster Tag")
print(f" kostet {det['max'] / sto['max']:.1f}-mal so viel.")
print(f"\n Der Grund steht in der letzten Spalte: In {det['betroffen']} von {SZENARIEN} "
f"Szenarien muss")
print(f" Last abgeworfen werden. Was fehlt, sind nicht Kraftwerke, sondern")
print(f" ANGEFAHRENE Kraftwerke - und ein Block mit acht Stunden")
print(f" Mindestlaufzeit laesst sich um 18 Uhr nicht mehr herbeirufen.")
print(f"\n Der Unterschied im Plan ist klein:")
for name, plan in [("Erwartungswert", plan_det), ("zweistufig", plan_sto),
("mit Sicherheit", plan_sicher)]:
laufend = plan.sum(axis=0).astype(int)
print(f" {name:<16} {''.join(str(x) for x in laufend)} "
f"({int(plan.sum())} Blockstunden)")
print(f"\n Der zweistufige Plan haelt {sto['blockstunden'] - det['blockstunden']} "
f"Blockstunden mehr vor - das genuegt,")
print(f" um alle {det['betroffen']} Lastabwuerfe zu vermeiden.")
# --- Der Preis der Sicherheit ----------------------------------------
print("\n" + "-" * 88)
print("Was Versorgungssicherheit kostet\n")
print(f" Bei {LASTABWURF:,.0f} EUR/MWh kostet die CVaR-Schranke NICHTS: Der")
print(" risikoneutrale Plan haelt sie schon ein. Das ist kein Zufall - bei")
print(" diesem Preis lohnt sich Vorhaltung bereits im Erwartungswert.")
print("\n Interessant wird die Schranke dort, wo der Schaden ZU NIEDRIG")
print(" bepreist ist. Dieselbe Rechnung mit einem Marktpreisdeckel von")
print(f" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh statt {LASTABWURF:,.0f}:\n")
print(f" {'Planungsgrundlage':<32} {'Kosten':>10} {'Kosten':>11} "
f"{'Fehlmenge':>9} {'Fehlmenge':>10} {'Szen. mit':>12}")
print(f" {'':<32} {'im Mittel':>10} {'schlimmst.':>11} "
f"{'Mittel':>9} {'CVaR 90%':>10} {'Abwurf':>12}")
print(" " + "-" * 86)
plan_billig, _, _ = plane(wind, gleich, abwurfpreis=NIEDRIGER_ABWURFPREIS)
billig = zeige("risikoneutral, billiger Abwurf", plan_billig, wind,
NIEDRIGER_ABWURFPREIS)
plan_billig_sicher, _, _ = plane(wind, gleich, cvar_grenze=0.0,
abwurfpreis=NIEDRIGER_ABWURFPREIS)
billig_sicher = zeige("+ CVaR 90 % der Fehlmenge = 0", plan_billig_sicher, wind,
NIEDRIGER_ABWURFPREIS)
aufpreis = billig_sicher["mittel"] - billig["mittel"]
vermieden = billig["fehl_cvar"] - billig_sicher["fehl_cvar"]
print(f"\n Jetzt greift die Schranke: Der risikoneutrale Plan nimmt in")
print(f" {billig['betroffen']} von {SZENARIEN} Szenarien einen Lastabwurf in Kauf, weil er bei")
print(f" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh billiger ist als das Vorhalten eines Blocks.")
print(f"\n Aufpreis fuer die Sicherheit: {aufpreis:,.0f} EUR je Tag")
if vermieden > 0.01:
print(f" Vermiedene Fehlmenge (CVaR 90 %): {vermieden:.1f} MWh")
print(f" -> {aufpreis / vermieden:,.0f} EUR je vermiedener MWh")
print(f"\n Diese Zahl ist die Entscheidungsgrundlage - genau wie die Spalte")
print(f" 'EUR je kg' im Kapitel Mehrziel. Sie sagt der Aufsicht, was ihre")
print(f" Versorgungssicherheitsvorgabe tatsaechlich kostet, statt darueber")
print(f" zu streiten, wie wichtig sie ist.")
print("\n" + "=" * 88)
print(" WAS MAN DARAUS MITNIMMT")
print("=" * 88)
print("1. Die erste Stufe ist binaer und traege. Was am Vorabend nicht")
print(" angefahren wurde, steht am naechsten Tag nicht zur Verfuegung -")
print(" egal, wie hoch der Preis dann steigt.")
print("2. Deshalb ist der Erwartungswert als Planungsgrundlage nicht nur")
print(" ungenau, sondern SYSTEMATISCH zu knapp: Er plant fuer einen Tag,")
print(" der so nie eintritt, und laesst keine Reserve fuer die Haelfte")
print(" der Faelle, in denen es schlechter kommt.")
print("3. Der Ausweg ist keine bessere Windprognose, sondern ein Modell,")
print(" das die Szenarien SIEHT. Die Rechenzeit dafuer liegt hier im")
print(" Sekundenbereich - der Aufwand ist die Modellierung, nicht der")
print(" Solver.")
print("4. Wo der Lastabwurf teuer genug bepreist ist, deckt schon die")
print(" Minimierung der ERWARTETEN Kosten die Extremfaelle mit ab. Die")
print(" Risikoschranke wird genau dann gebraucht, wenn der Schaden ZU")
print(" NIEDRIG bepreist ist - was bei Versorgungssicherheit die Regel")
print(" ist, weil ein Marktpreisdeckel nicht den volkswirtschaftlichen")
print(" Schaden abbildet. Dann liefert sie den Preis der Vorgabe in Euro")
print(" je vermiedener MWh, und darueber laesst sich verhandeln.")
print("=" * 88)

View file

@ -0,0 +1,286 @@
#!/usr/bin/env python3
# Large_Neighborhood_Search.py
"""
Kapitel Metaheuristiken: Zerstoeren und exakt reparieren - der Solver im Dienst der Heuristik.
Metaheuristik_vs_Exakt.py endet mit einem Widerspruch. Bei 500 Auftraegen gilt:
* Der exakte Solver scheitert an der GROESSE - seine Loesung nach 30 Sekunden
ist schlechter als die Faustregel eines Meisters.
* Die Metaheuristik kommt weiter, laesst aber laut Schranke noch rund ein
Viertel liegen. Ihre Zuege sind zu kleinteilig: Sie verschiebt jeweils
EINEN Auftrag und kann eine ganze Passage nicht auf einmal umbauen.
Large Neighborhood Search loest den Widerspruch, indem sie beide einsetzt -
jeden fuer das, was er kann:
ZERSTOEREN Ein Stueck des Plans herausbrechen (hier: ein zusammen-
haengendes Fenster von 20 Auftraegen).
REPARIEREN Genau dieses Stueck EXAKT neu optimieren. 20 Auftraege sind
fuer CP-SAT eine Kleinigkeit, 500 sind es nicht.
UEBERNEHMEN Nur behalten, wenn der Gesamtplan besser wurde.
Der Solver bekommt also nicht mehr das ganze Problem, sondern immer wieder ein
kleines. Das ist der ganze Trick, und er ist in der Praxis der wichtigste
Baustein dieses Kapitels.
ZUR REPRODUZIERBARKEIT: Alle Laeufe haben eine feste RUNDENzahl, nicht ein
Zeitbudget - die Ergebniswerte sind damit auf jeder Maschine gleich. Die
Laufzeiten daneben sind hardwareabhaengig.
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import math
import time
import numpy as np
from ortools.sat.python import cp_model
FENSTER = 20 # so viele Auftraege werden je Runde herausgebrochen
RUNDEN = 120
ZUEGE = 400_000 # Zugbudget des Annealings (wie Simulated_Annealing.py)
N = 500
# --- Instanz und Faustregel (wie in Simulated_Annealing.py) ----------------
def erzeuge_ruestmatrix(n: int, saat: int = 11) -> 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]]
+ 3 * max(0, farbe[i] - farbe[j]))
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, matrix, von, nach):
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
def annealing(matrix: np.ndarray, zuege: int = ZUEGE,
start_temperatur: float = 1.0, saat: int = 1) -> list[int]:
rng = np.random.default_rng(saat)
n = len(matrix)
reihe = faustregel(matrix)
kosten = gesamtruestzeit(reihe, matrix)
beste, beste_kosten = reihe[:], kosten
for zug in range(zuege):
temperatur = start_temperatur * (0.05 / start_temperatur) ** (zug / zuege)
von, nach = int(rng.integers(0, n)), int(rng.integers(0, n + 1))
ergebnis = delta_verschieben(reihe, matrix, von, nach)
if ergebnis is None:
continue
aenderung, rest, stelle = ergebnis
if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):
reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
kosten += aenderung
if kosten < beste_kosten:
beste, beste_kosten = reihe[:], kosten
return beste
# --- Der Reparaturschritt: ein kleines Problem, exakt geloest --------------
def repariere_fenster(reihe: list[int], matrix: np.ndarray,
a: int, b: int) -> list[int] | None:
"""Ordnet reihe[a:b] optimal neu; die beiden Raender bleiben, wo sie sind.
Die Raender festzuhalten ist entscheidend. Ohne sie waere das Teilproblem
ein anderes als der Ausschnitt aus dem Gesamtplan: Der Uebergang vom
Vorgaenger in das Fenster und aus dem Fenster in den Nachfolger gehoert
zu den Kosten dazu. Genau dafuer steht der Hilfsknoten k - er vertritt
beide Raender in einem.
"""
innen = reihe[a:b]
k = len(innen)
vorgaenger = reihe[a - 1] if a > 0 else None
nachfolger_rand = reihe[b] if b < len(reihe) else None
modell = cp_model.CpModel()
kanten, ziel = [], []
for i in range(k + 1):
for j in range(k + 1):
if i == j:
continue
aktiv = modell.NewBoolVar(f"kante_{i}_{j}")
kanten.append((i, j, aktiv))
if i < k and j < k:
ziel.append(int(matrix[innen[i], innen[j]]) * aktiv)
elif i == k and j < k and vorgaenger is not None:
ziel.append(int(matrix[vorgaenger, innen[j]]) * aktiv)
elif j == k and i < k and nachfolger_rand is not None:
ziel.append(int(matrix[innen[i], nachfolger_rand]) * aktiv)
modell.AddCircuit(kanten)
modell.Minimize(sum(ziel))
loeser = cp_model.CpSolver()
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1
loeser.parameters.max_time_in_seconds = 10.0
status = loeser.Solve(modell)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
# Auch das gehoert dazu: Wenn das Teilproblem nicht loest, bleibt der
# Plan, wie er war. Eine LNS-Runde darf scheitern.
return None
nachfolger = {i: j for i, j, aktiv in kanten if loeser.Value(aktiv)}
neue_folge, aktuell = [], nachfolger[k]
while aktuell != k:
neue_folge.append(innen[aktuell])
aktuell = nachfolger[aktuell]
return reihe[:a] + neue_folge + reihe[b:]
def lns(reihe: list[int], matrix: np.ndarray, runden: int = RUNDEN,
fenster: int = FENSTER, saat: int = 3):
"""Zerstoeren, exakt reparieren, uebernehmen - so lange das Budget reicht."""
rng = np.random.default_rng(saat)
kosten = gesamtruestzeit(reihe, matrix)
verbesserungen = 0
start = time.perf_counter()
for _ in range(runden):
a = int(rng.integers(0, len(reihe) - fenster))
neu = repariere_fenster(reihe, matrix, a, a + fenster)
if neu is None:
continue
neue_kosten = gesamtruestzeit(neu, matrix)
if neue_kosten < kosten:
reihe, kosten = neu, neue_kosten
verbesserungen += 1
return reihe, kosten, verbesserungen, time.perf_counter() - start
if __name__ == "__main__":
matrix = erzeuge_ruestmatrix(N)
start_reihe = faustregel(matrix)
start_kosten = gesamtruestzeit(start_reihe, matrix)
SCHRANKE = 1768 # aus dem CP-SAT-Lauf in Metaheuristik_vs_Exakt.py
print("=" * 84)
print(" ZERSTOEREN UND EXAKT REPARIEREN")
print("=" * 84)
print(f"{N} Auftraege. Je Runde werden {FENSTER} aufeinanderfolgende Auftraege")
print(f"herausgebrochen und exakt neu geordnet, {RUNDEN} Runden lang.\n")
print("Erst die Metaheuristik allein, dann LNS auf ihrem Ergebnis:\n")
sa_reihe = annealing(matrix)
sa_kosten = gesamtruestzeit(sa_reihe, matrix)
lns_ab_faustregel = lns(start_reihe[:], matrix)
lns_ab_annealing = lns(sa_reihe[:], matrix)
print(f" {'Verfahren':<34} {'Ruestzeit':>10} {'ueber Schranke':>15}")
print(" " + "-" * 62)
for name, wert in [
("Faustregel (ohne Solver)", start_kosten),
("CP-SAT allein, 30 s", 2628),
("Simulated Annealing", sa_kosten),
("LNS ab Faustregel", lns_ab_faustregel[1]),
("Annealing, dann LNS", lns_ab_annealing[1])]:
print(f" {name:<34} {wert:>10,} {(wert - SCHRANKE) / SCHRANKE * 100:>13.1f} %")
print(f" {'untere Schranke (CP-SAT)':<34} {SCHRANKE:>10,} {0.0:>13.1f} %")
print(f"\n LNS ab Faustregel: {lns_ab_faustregel[2]} von {RUNDEN} Runden brachten "
f"eine Verbesserung ({lns_ab_faustregel[3]:.0f} s).")
print(f" LNS ab Annealing: {lns_ab_annealing[2]} von {RUNDEN} Runden "
f"({lns_ab_annealing[3]:.0f} s).")
# --- Die Fenstergroesse ----------------------------------------------
# Verglichen wird bei ANNAEHERND GLEICHER ZEIT, nicht bei gleicher
# Rundenzahl: Eine Runde mit Fenster 30 kostet ein Vielfaches einer Runde
# mit Fenster 10. Wer nach Runden vergleicht, misst nur, dass groessere
# Teilprobleme mehr finden - und uebersieht, was sie kosten. Die
# Rundenzahlen unten sind so gewaehlt, dass alle drei Laeufe in derselben
# Groessenordnung liegen.
print("\n" + "-" * 84)
print("Die Fenstergroesse ist die eine Stellschraube - und sie hat ein")
print("Optimum in der Mitte. Gleiche Zeit, verschiedene Fenster:\n")
print(f" {'Fenster':>8} {'Runden':>8} {'Ruestzeit':>11} {'Verbesser-':>12} {'Zeit':>8}")
print(f" {'':>8} {'':>8} {'':>11} {'ungen':>12} {'':>8}")
print(" " + "-" * 52)
fenster_ergebnis = {}
for fenster, runden in ((10, 4000), (20, 150), (30, 20)):
_, wert, verbesserungen, dauer = lns(sa_reihe[:], matrix,
runden=runden, fenster=fenster)
fenster_ergebnis[fenster] = wert
print(f" {fenster:>8} {runden:>8,} {wert:>11,} {verbesserungen:>12} "
f"{dauer:>7.0f}s")
print("\n Nach unten ist die Grenze nicht die Zeit, sondern die SAETTIGUNG:")
print(f" Mit Fenster 10 bleiben von {4000:,} Runden nur eine Handvoll")
print(" Verbesserungen uebrig. Zwanzig Auftraege lassen sich in einem Zug")
print(" umbauen, zehn nicht - und was ein Fenster nicht umbauen kann, findet")
print(" es auch in beliebig vielen Runden nicht.")
print("\n Nach oben ist die Grenze die Rechenzeit: Fenster 30 findet pro Runde")
print(" mehr, kommt in derselben Zeit aber nur auf einen Bruchteil der Runden")
print(f" und landet bei {fenster_ergebnis[30]:,} statt {fenster_ergebnis[20]:,}. Eine Reparatur mit 30 Auftraegen")
print(" ist eben genau das Problem, an dem der exakte Solver im Grossen")
print(" scheitert - nur eine Nummer kleiner.")
print("\n Diese Tabelle gehoert an den Anfang jedes LNS-Projekts. Sie zu raten")
print(" statt zu messen ist der haeufigste Fehler beim Einsatz des Verfahrens.")
print("\n" + "=" * 84)
print(" WAS DAS HEISST")
print("=" * 84)
bester = lns_ab_annealing[1]
print(f"Der beste Plan kommt aus der Kombination: {bester:,} Minuten,")
print(f"{(start_kosten - bester) / start_kosten * 100:.1f} % unter der Faustregel und "
f"{(sa_kosten - bester) / sa_kosten * 100:.1f} % unter dem reinen Annealing.")
print()
print("Weder der exakte Solver noch die Metaheuristik allein kommen dorthin.")
print("Der Solver scheitert an der Groesse, die Metaheuristik an der Kleinheit")
print("ihrer Zuege. LNS gibt dem Solver Teilprobleme in einer Groesse, die er")
print("beherrscht, und der Metaheuristik die Umbauten, die sie nicht kann.")
print()
print(f"Und die Ehrlichkeit zum Schluss: Auch {bester:,} liegt noch "
f"{(bester - SCHRANKE) / SCHRANKE * 100:.0f} % ueber der")
print("Schranke. Ob dort wirklich noch so viel Luft ist oder ob die Schranke")
print("nur schwach ist, weiss man nicht - das ist die Lage, in der man mit")
print("einer Metaheuristik arbeitet. Man kennt seine Loesung, nicht ihren")
print("Abstand zum Optimum.")
print("=" * 84)

View file

@ -0,0 +1,168 @@
#!/usr/bin/env python3
# Lokale_Optima_Multistart.py
"""
Kapitel QP/NLP: Was passiert, wenn die Konvexitaet fehlt.
CVXPY verweigert nicht-konvexe Probleme - das ist sein Schutzmechanismus.
scipy.optimize.minimize verweigert nichts. Es rechnet, meldet 'success: True'
und liefert ein Ergebnis. Nur ist das Ergebnis dann kein Optimum, sondern
irgendein lokales Minimum, das vom Startpunkt abhaengt.
Beispiel aus dem Einkauf: 700 Tonnen Rohstoff werden auf vier Lieferanten
verteilt. Jeder gewaehrt einen MENGENRABATT - der Stueckpreis faellt, je mehr
man bei ihm bestellt:
preis_i(q) = basis_i * (1 - rabatt_i * (1 - exp(-q / skala_i)))
Genau das macht die Zielfunktion nicht-konvex: Grosse Bestellungen lohnen
sich ueberproportional, es gibt also mehrere sinnvolle "Cluster"-Loesungen -
und dazwischen schlechtere Taeler.
Das Programm zeigt drei Dinge:
1. Ein einzelner Lauf liefert ein plausibles Ergebnis - ohne jede Warnung.
2. 200 Startpunkte foerdern mehrere verschiedene lokale Optima zutage.
3. Der Unterschied zwischen bestem und schlechtestem betraegt hier 8 %.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import minimize
# Vier Lieferanten
NAMEN = ["Nord AG", "Ost GmbH", "Sued KG", "West SE"]
BASISPREIS = np.array([50.0, 47.0, 53.0, 45.0]) # EUR je Tonne ohne Rabatt
MAX_RABATT = np.array([0.30, 0.18, 0.35, 0.12]) # hoechstmoeglicher Rabatt
RABATT_SKALA = np.array([120.0, 260.0, 90.0, 300.0]) # wie schnell er greift
KAPAZITAET = np.array([400.0, 400.0, 400.0, 400.0])
BEDARF = 700.0
RNG = np.random.default_rng(0)
def gesamtkosten(menge: np.ndarray) -> float:
"""Einkaufskosten bei mengenabhaengigem Stueckpreis.
Der Rabatt waechst mit der Bestellmenge und laeuft gegen MAX_RABATT.
Dadurch ist der Stueckpreis fallend - und die Gesamtkostenfunktion
nicht mehr konvex.
"""
stueckpreis = BASISPREIS * (1 - MAX_RABATT * (1 - np.exp(-menge / RABATT_SKALA)))
return float(stueckpreis @ menge)
NEBENBEDINGUNGEN = [{"type": "eq", "fun": lambda q: q.sum() - BEDARF}]
GRENZEN = [(0.0, k) for k in KAPAZITAET]
def optimiere_von(startpunkt: np.ndarray):
"""Ein Lauf von einem gegebenen Startpunkt aus."""
return minimize(gesamtkosten, startpunkt, method="SLSQP",
bounds=GRENZEN, constraints=NEBENBEDINGUNGEN)
def zufaelliger_start() -> np.ndarray:
"""Zufaellige Aufteilung, die den Bedarf bereits erfuellt."""
anteil = RNG.random(len(NAMEN))
return anteil / anteil.sum() * BEDARF
def zeige_plan(titel: str, menge: np.ndarray, kosten: float) -> None:
print(f"\n{titel}")
for name, m in zip(NAMEN, menge):
anteil = m / BEDARF * 100
print(f" {name:<10} {m:7.1f} t ({anteil:4.1f} %)")
print(f" {'Gesamtkosten':<10} {kosten:9,.2f} EUR")
if __name__ == "__main__":
print("=" * 78)
print(" NICHT-KONVEX: DERSELBE CODE, VERSCHIEDENE ERGEBNISSE")
print("=" * 78)
print(f"{BEDARF:.0f} t Rohstoff auf {len(NAMEN)} Lieferanten mit Mengenrabatt.")
# --- 1. Ein einziger Lauf, so wie man es zuerst schreibt --------------
erster = optimiere_von(np.full(len(NAMEN), BEDARF / len(NAMEN)))
print(f"\n[1] EIN Lauf, Startpunkt 'gleichmaessig verteilt'")
print(f" scipy meldet: success={erster.success}, "
f"'{erster.message}'")
zeige_plan(" Ergebnis:", erster.x, erster.fun)
print("\n Nichts an dieser Ausgabe deutet darauf hin, dass etwas fehlt.")
# --- 1b. Der kaufmaennisch naheliegende Startpunkt --------------------
# "Kaufe bei den beiden Lieferanten mit dem guenstigsten Basispreis" -
# West SE (45) und Ost GmbH (47), jeweils bis zur Kapazitaetsgrenze.
guenstigste = np.argsort(BASISPREIS)[:2]
kaufmaennisch = np.zeros(len(NAMEN))
rest = BEDARF
for i in guenstigste:
kaufmaennisch[i] = min(KAPAZITAET[i], rest)
rest -= kaufmaennisch[i]
zweiter = optimiere_von(kaufmaennisch)
print(f"\n[1b] EIN Lauf, Startpunkt 'die zwei mit dem guenstigsten Basispreis'")
print(f" scipy meldet: success={zweiter.success}")
zeige_plan(" Ergebnis:", zweiter.x, zweiter.fun)
print(f"\n Dasselbe Programm, derselbe Aufruf, ein anderer Startpunkt -")
print(f" und {zweiter.fun - erster.fun:,.2f} EUR Unterschied "
f"({(zweiter.fun / erster.fun - 1) * 100:.1f} %).")
# --- 2. Multistart: dasselbe Problem, viele Startpunkte ---------------
laeufe = []
for _ in range(200):
ergebnis = optimiere_von(zufaelliger_start())
if ergebnis.success:
laeufe.append((float(ergebnis.fun), ergebnis.x))
# Ergebnisse, die sich um weniger als 1 Cent unterscheiden, sind dasselbe
# lokale Optimum - zusammenfassen, sonst zaehlt man Rundungsrauschen.
optima: list[tuple[float, np.ndarray]] = []
for wert, plan in sorted(laeufe, key=lambda t: t[0]):
if not optima or abs(wert - optima[-1][0]) > 0.01:
optima.append((wert, plan))
print("\n" + "=" * 78)
print(f"[2] 200 zufaellige Startpunkte -> {len(laeufe)} erfolgreiche Laeufe")
print(f" darunter {len(optima)} VERSCHIEDENE lokale Optima:")
print()
print(f" {'Rang':>5} {'Kosten':>13} {'Abstand zum besten':>20} Aufteilung (t)")
print(" " + "-" * 70)
bester = optima[0][0]
for rang, (wert, plan) in enumerate(optima, start=1):
abstand = (wert / bester - 1) * 100
aufteilung = " ".join(f"{m:5.0f}" for m in plan)
print(f" {rang:>5} {wert:>13,.2f} {abstand:>19.2f} % {aufteilung}")
# --- 3. Was das kostet ------------------------------------------------
schlechtester = optima[-1]
print("\n" + "=" * 78)
print(" WAS AUF DEM SPIEL STEHT")
print("=" * 78)
zeige_plan("Bester gefundener Plan:", optima[0][1], optima[0][0])
zeige_plan("Schlechtestes lokales Optimum:", schlechtester[1], schlechtester[0])
unterschied = schlechtester[0] - optima[0][0]
print(f"\n Unterschied: {unterschied:,.2f} EUR "
f"({unterschied / optima[0][0] * 100:.1f} %)")
print(f"\n Lauf [1] (gleichmaessiger Start): {erster.fun:>10,.2f} EUR")
print(f" Lauf [1b] (kaufmaennischer Start): {zweiter.fun:>10,.2f} EUR"
f" <- {(zweiter.fun / optima[0][0] - 1) * 100:.1f} % ueber dem besten")
print()
print(" Bemerkenswert: Der kaufmaennisch NAHELIEGENDE Startpunkt fuehrt in")
print(" das schlechteste Ergebnis von allen - schlechter als jedes der 200")
print(" zufaellig gefundenen lokalen Optima. Wer beim guenstigsten")
print(" Basispreis anfaengt, uebersieht, dass hier der Mengenrabatt")
print(" entscheidet und nicht der Listenpreis.")
print()
print("Drei Konsequenzen fuer die Praxis:")
print(" 1. 'success: True' heisst bei nicht-konvexen Problemen NICHT 'optimal'.")
print(" Es heisst nur: 'Ich bin an einer Stelle angekommen, an der es in")
print(" keine Richtung mehr bergab geht.'")
print(" 2. Ein einzelner Lauf ist wertlos. Nehmen Sie viele Startpunkte und")
print(" berichten Sie die STREUUNG mit - sie ist Ihre einzige Auskunft")
print(" darueber, wie zerklueftet die Landschaft ist.")
print(" 3. Auch Multistart liefert KEINE Garantie. Dass hier nichts unter")
print(f" {bester:,.2f} EUR gefunden wurde, beweist nicht, dass es nichts gibt.")
print("=" * 78)

View file

@ -0,0 +1,203 @@
#!/usr/bin/env python3
# MILP_Portfolio_Fixgebuehren.py
"""
Kapitel MILP: MILP-Portfolio-Selektion mit Fixkosten und Kardinalitaet.
CP-SAT-freie, gut lesbare Formulierung ueber scipy/HiGHS (kein manueller
CSR-Matrixaufbau, deutlich leichter nachvollziehbar), mit Vergleich gegen die
Loesung OHNE Restriktionen.
Die Auswertung laeuft ueber SolverStatus und das Loesung-Objekt aus
or_kern.py: Alle Statusfaelle werden behandelt, und der MIP-Gap steht im
Bericht - statt eines blossen 'success', das nicht verraet, ob der Solver
fertig geworden ist oder nur aufgegeben hat.
Benoetigt: numpy, pandas, scipy, pydantic (ueber or_kern)
"""
from __future__ import annotations
import time
import numpy as np
import pandas as pd
from scipy.optimize import linprog
from or_kern import Loesung, SolverStatus, status_von_scipy
ANLAGEN = ["US-Aktien", "EU-Aktien", "Emerging-Markets",
"Staatsanleihen", "Unternehmensanleihen", "Rohstoffe"]
RENDITE = np.array([0.11, 0.08, 0.13, 0.03, 0.05, 0.07]) # erwartet, p.a.
BUDGET = 100_000.0
MIN_POSITION = 10_000.0 # L
MAX_POSITION = 40_000.0 # U (dient zugleich als Big-M!)
GEBUEHR = 50.0 # F, je aktivierter Position
MAX_POSITIONEN = 3 # K
N = len(ANLAGEN)
def baue_und_loese(zeitlimit: float | None = None) -> Loesung:
"""
Variablenreihenfolge: [x_0..x_{N-1}, y_0..y_{N-1}]
Zielfunktion (Maximierung -> fuer linprog negiert):
max sum(rendite_i * x_i) - GEBUEHR * sum(y_i)
"""
c = np.concatenate([-RENDITE, np.full(N, GEBUEHR)]) # negiert = Minimierung
# --- Gleichungsnebenbedingung: Budget vollstaendig investiert -----------
A_eq = np.zeros((1, 2 * N))
A_eq[0, :N] = 1.0
b_eq = np.array([BUDGET])
zeilen, grenzen = [], []
# --- Kardinalitaet: sum(y_i) <= K --------------------------------------
zeile = np.zeros(2 * N)
zeile[N:] = 1.0
zeilen.append(zeile)
grenzen.append(MAX_POSITIONEN)
# --- Obergrenze (Big-M): x_i - U*y_i <= 0 -----------------------------
for i in range(N):
zeile = np.zeros(2 * N)
zeile[i] = 1.0
zeile[N + i] = -MAX_POSITION
zeilen.append(zeile)
grenzen.append(0.0)
# --- Untergrenze: L*y_i - x_i <= 0 (entspricht x_i >= L*y_i) --------
for i in range(N):
zeile = np.zeros(2 * N)
zeile[i] = -1.0
zeile[N + i] = MIN_POSITION
zeilen.append(zeile)
grenzen.append(0.0)
A_ub = np.array(zeilen)
b_ub = np.array(grenzen)
schranken = [(0.0, MAX_POSITION)] * N + [(0.0, 1.0)] * N
ganzzahligkeit = np.concatenate([np.zeros(N), np.ones(N)]) # y binaer
t0 = time.perf_counter()
ergebnis = linprog(c=c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq,
bounds=schranken, integrality=ganzzahligkeit,
method="highs",
options={"time_limit": zeitlimit} if zeitlimit else None)
laufzeit = time.perf_counter() - t0
status = status_von_scipy(ergebnis)
if not status.brauchbar:
return Loesung(status=status, laufzeit=laufzeit)
# Zurueck in die Maximierungswelt: Zielwert UND Schranke negieren.
# Aus beiden zusammen rechnet das Loesung-Objekt den MIP-Gap aus.
x, y = ergebnis.x[:N], np.round(ergebnis.x[N:])
return Loesung(
status=status,
werte={**{name: float(w) for name, w in zip(ANLAGEN, x)},
**{f"aktiv:{name}": float(w) for name, w in zip(ANLAGEN, y)}},
zielwert=float(-ergebnis.fun),
schranke=float(-ergebnis.mip_dual_bound),
laufzeit=laufzeit)
def ohne_restriktionen() -> tuple[float, np.ndarray]:
"""Vergleichsfall: nur Budget, keine Gebuehren/Kardinalitaet/Mindestgroesse."""
ergebnis = linprog(c=-RENDITE, A_eq=[np.ones(N)], b_eq=[BUDGET],
bounds=[(0, None)] * N, method="highs")
status = status_von_scipy(ergebnis)
if not status.brauchbar:
raise SystemExit(f"Vergleichsfall nicht loesbar: {status.value}")
return -ergebnis.fun, ergebnis.x
def pruefe_portfolio(loesung: Loesung, toleranz: float = 1e-6) -> list[str]:
"""Prueft die Loesung gegen die Anforderungen - ohne den Solver zu fragen.
Bewusst kein assert: Eine Beanstandungsliste laesst sich protokollieren,
weiterreichen und testen. Ein assert verschwindet ausserdem, sobald
jemand Python mit -O startet.
"""
x = np.array([loesung.werte[name] for name in ANLAGEN])
y = np.array([loesung.werte[f"aktiv:{name}"] for name in ANLAGEN])
beanstandungen: list[str] = []
if abs(x.sum() - BUDGET) > 1e-4:
beanstandungen.append(f"Budget nicht exakt investiert: {x.sum():,.2f}")
if y.sum() > MAX_POSITIONEN + toleranz:
beanstandungen.append(f"{y.sum():.0f} Positionen statt hoechstens "
f"{MAX_POSITIONEN}")
for i, name in enumerate(ANLAGEN):
if abs(y[i] - round(y[i])) > toleranz:
beanstandungen.append(f"{name}: y = {y[i]!r} ist nicht ganzzahlig")
elif y[i] and not (MIN_POSITION - toleranz <= x[i]
<= MAX_POSITION + toleranz):
beanstandungen.append(f"{name}: {x[i]:,.2f} EUR verletzt die "
f"Groessengrenzen")
elif not y[i] and x[i] > toleranz:
beanstandungen.append(f"{name}: inaktiv, aber {x[i]:,.2f} EUR "
f"investiert")
return beanstandungen
if __name__ == "__main__":
loesung = baue_und_loese()
print("=" * 88)
print(" OPTIMALE MILP-PORTFOLIO-ALLOKATION MIT FIXGEBUEHREN")
print("=" * 88)
print(f"Budget: {BUDGET:,.0f} EUR | max. {MAX_POSITIONEN} Positionen | "
f"je {MIN_POSITION:,.0f}-{MAX_POSITION:,.0f} EUR | Gebuehr {GEBUEHR:.0f} EUR\n")
# Zuerst der Status - erst danach interessieren die Zahlen.
if loesung.status.modellfehler:
raise SystemExit(f"Das Modell ist nicht loesbar ({loesung.status.value}). "
f"Naechster Schritt: Anhang Fehlerdiagnose.")
if not loesung.status.brauchbar:
raise SystemExit(f"Keine Loesung erhalten ({loesung.status.value}). "
f"Zeitlimit erhoehen oder Modell vereinfachen.")
if loesung.status is SolverStatus.ZULAESSIG:
print(f"ACHTUNG: nicht beweisbar optimal - Gap {loesung.gap:.2%}\n")
x = np.array([loesung.werte[name] for name in ANLAGEN])
y = np.array([loesung.werte[f"aktiv:{name}"] for name in ANLAGEN], dtype=int)
tabelle = pd.DataFrame({
"Anlage": ANLAGEN,
"Aktiv": ["JA" if y[i] else "-" for i in range(N)],
"Investition (EUR)": [f"{x[i]:,.0f}" for i in range(N)],
"Anteil": [f"{x[i]/BUDGET*100:5.1f} %" for i in range(N)],
"Erw. Rendite": [f"{RENDITE[i]*100:4.1f} %" for i in range(N)],
"Erw. Ertrag (EUR)": [f"{x[i]*RENDITE[i]:,.0f}" for i in range(N)],
})
print(tabelle.to_string(index=False))
brutto = float(RENDITE @ x)
gebuehren = float(GEBUEHR * y.sum())
print("-" * 88)
print(f"Erwarteter Bruttoertrag: {brutto:>12,.2f} EUR")
print(f"Ordergebuehren: {-gebuehren:>12,.2f} EUR ({y.sum()} Positionen)")
print(f"Netto-Erwartungswert: {loesung.zielwert:>12,.2f} EUR")
print(f"\n{loesung.als_bericht()}")
# --- Vergleich mit dem unbeschraenkten Fall ---------------------------
z_frei, x_frei = ohne_restriktionen()
print("-" * 88)
print(f"Zum Vergleich ohne jede Restriktion (alles in den Bestwert): "
f"{z_frei:,.2f} EUR")
print(f"Kosten der Realitaet (Gebuehren, Streuung, Mindestgroessen): "
f"{z_frei - loesung.zielwert:,.2f} EUR "
f"({(1 - loesung.zielwert/z_frei)*100:.2f} %)")
# --- Alle Nebenbedingungen nachpruefen --------------------------------
beanstandungen = pruefe_portfolio(loesung)
print("-" * 88)
if beanstandungen:
raise SystemExit("Abnahmepruefung fehlgeschlagen:\n - "
+ "\n - ".join(beanstandungen))
print("Abnahmepruefung: alle Nebenbedingungen geprueft und eingehalten.")
print("=" * 88)

View file

@ -0,0 +1,263 @@
#!/usr/bin/env python3
# Markowitz_CVXPY.py
"""
Kapitel Markowitz: Markowitz-Mean-Variance-Optimierung mit CVXPY.
Enthaelt GMV, Maximum Sharpe (Korn-Transformation), Effizienzgrenze
und institutionelle Restriktionen.
Eigenschaften:
* Spaltenreihenfolge erzwungen; Sektoren ueber NAMEN statt Indizes
* Effizienzgrenze bis zur TATSAECHLICH erreichbaren Maximalrendite
(nicht etwa max(mu)*0.95 - das waere unter Restriktionen oft
unerreichbar und die Kurve wuerde stillschweigend vorzeitig abbrechen)
* Pruefung aller Restriktionen nach dem Loesen
* Vergleich mit Gleichgewichtung als Realitaetscheck
"""
import os
import cvxpy as cp
import numpy as np
import pandas as pd
import yfinance as yf
from sklearn.covariance import LedoitWolf
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
os.makedirs(OUTPUT_DIR, exist_ok=True)
TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
# Sektoren ueber Namen definiert, nicht ueber Positionen
SEKTOREN = {
"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"],
"Gesundheit": ["JNJ", "PFE"],
"Finanzen": ["JPM", "GS"],
"Energie": ["XOM", "CVX"],
}
SEKTORGRENZEN = {"Technologie": 0.35, "Gesundheit": 0.40,
"Finanzen": 0.40, "Energie": 0.40}
MAX_GEWICHT = 0.20 # hoechstens 20 % je Einzeltitel
RISIKOFREI = 0.03 # 3 % p.a.
HANDELSTAGE = 252
def lade_daten():
"""Laedt Kurse und garantiert die Spaltenreihenfolge."""
ende = pd.Timestamp.today().normalize()
start = ende - pd.DateOffset(years=2)
roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
if roh.empty:
raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
if isinstance(roh.columns, pd.MultiIndex):
kurse = roh["Close"][TICKER].dropna() # [TICKER] erzwingt die Reihenfolge
else:
kurse = roh[["Close"]].dropna()
kurse.columns = TICKER
assert list(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
renditen = kurse.pct_change().dropna()
mu = renditen.mean().values * HANDELSTAGE
sigma = LedoitWolf().fit(renditen.values).covariance_ * HANDELSTAGE
return kurse, renditen, mu, sigma
def sektor_indizes():
"""Uebersetzt Sektornamen einmalig in Positionsindizes - mit Pruefung."""
ergebnis = {}
for sektor, titel in SEKTOREN.items():
fehlend = [t for t in titel if t not in TICKER]
if fehlend:
raise ValueError(f"Sektor '{sektor}': Ticker {fehlend} nicht im Universum.")
ergebnis[sektor] = [TICKER.index(t) for t in titel]
return ergebnis
def basis_restriktionen(w, skala=None):
"""
Standardrestriktionen. Bei der Korn-Transformation muessen ALLE Grenzen
mit kappa mitskaliert werden - dafuer dient der Parameter 'skala'.
"""
eins = 1.0 if skala is None else skala
idx = sektor_indizes()
bedingungen = [w >= 0, w <= MAX_GEWICHT * eins]
for sektor, positionen in idx.items():
bedingungen.append(cp.sum(w[positionen]) <= SEKTORGRENZEN[sektor] * eins)
return bedingungen
def loese_gmv(sigma):
"""Global Minimum Variance: minimiere Risiko, ignoriere Rendite."""
w = cp.Variable(len(sigma))
problem = cp.Problem(cp.Minimize(0.5 * cp.quad_form(w, sigma)),
[cp.sum(w) == 1] + basis_restriktionen(w))
problem.solve()
if problem.status not in ("optimal", "optimal_inaccurate"):
raise SystemExit(f"GMV nicht loesbar: {problem.status}")
return w.value
def loese_max_sharpe(mu, sigma, r_f):
"""Maximum Sharpe Ratio ueber die Korn-Transformation."""
n = len(mu)
ueberrendite = mu - r_f
if np.all(ueberrendite <= 0):
raise SystemExit("Kein Titel schlaegt den risikofreien Zins - "
"Max-Sharpe-Portfolio existiert nicht.")
y = cp.Variable(n)
kappa = cp.Variable(nonneg=True)
bedingungen = ([ueberrendite @ y == 1, cp.sum(y) == kappa]
+ basis_restriktionen(y, skala=kappa))
problem = cp.Problem(cp.Minimize(0.5 * cp.quad_form(y, sigma)), bedingungen)
problem.solve()
if problem.status not in ("optimal", "optimal_inaccurate"):
raise SystemExit(f"Max-Sharpe nicht loesbar: {problem.status}")
return y.value / kappa.value
def max_erreichbare_rendite(mu, sigma):
"""
Achtung: max(mu)*0.95 als obere Grenze der Frontier anzunehmen, ist unter
Positions- und Sektorgrenzen oft unerreichbar; die Kurve wuerde dann
stillschweigend abbrechen. Hier wird die tatsaechliche Obergrenze berechnet.
"""
w = cp.Variable(len(mu))
problem = cp.Problem(cp.Maximize(mu @ w), [cp.sum(w) == 1] + basis_restriktionen(w))
problem.solve()
return float(problem.value)
def berechne_frontier(mu, sigma, ret_min, ret_max, punkte=40):
"""Effizienzgrenze durch Variation der Mindestrendite."""
n = len(mu)
w = cp.Variable(n)
ziel_rendite = cp.Parameter()
problem = cp.Problem(
cp.Minimize(0.5 * cp.quad_form(w, sigma)),
[cp.sum(w) == 1, mu @ w >= ziel_rendite] + basis_restriktionen(w))
volas, renditen, uebersprungen = [], [], 0
for ziel in np.linspace(ret_min, ret_max, punkte):
ziel_rendite.value = ziel
problem.solve()
if problem.status in ("optimal", "optimal_inaccurate"):
volas.append(float(np.sqrt(w.value @ sigma @ w.value)))
renditen.append(float(mu @ w.value))
else:
uebersprungen += 1
if uebersprungen:
print(f" Hinweis: {uebersprungen} Zielrenditen waren nicht erreichbar.")
return np.array(volas), np.array(renditen)
def pruefe_restriktionen(w, bezeichnung):
"""Nach dem Loesen: haelt die Loesung wirklich alle Regeln ein?"""
assert abs(w.sum() - 1) < 1e-6, f"{bezeichnung}: Summe != 1"
assert w.min() > -1e-6, f"{bezeichnung}: negatives Gewicht"
assert w.max() < MAX_GEWICHT + 1e-6, f"{bezeichnung}: Positionsgrenze verletzt"
for sektor, positionen in sektor_indizes().items():
anteil = w[positionen].sum()
assert anteil < SEKTORGRENZEN[sektor] + 1e-6, \
f"{bezeichnung}: Sektor {sektor} bei {anteil:.3f} ueber Grenze"
def kennzahlen(w, mu, sigma, r_f):
rendite = float(mu @ w)
vola = float(np.sqrt(w @ sigma @ w))
return rendite, vola, (rendite - r_f) / vola
if __name__ == "__main__":
kurse, renditen, mu, sigma = lade_daten()
n = len(TICKER)
w_gmv = loese_gmv(sigma)
w_sharpe = loese_max_sharpe(mu, sigma, RISIKOFREI)
w_gleich = np.ones(n) / n
pruefe_restriktionen(w_gmv, "GMV")
pruefe_restriktionen(w_sharpe, "Max Sharpe")
print("=" * 88)
print(" ERGEBNISSE DER MEAN-VARIANCE-OPTIMIERUNG")
print("=" * 88)
print(f"Datenbasis: {len(renditen)} Handelstage, {n} Titel, "
f"risikofreier Zins {RISIKOFREI*100:.1f} %")
print(f"Restriktionen: max. {MAX_GEWICHT*100:.0f} % je Titel, "
f"Sektorgrenzen {SEKTORGRENZEN}\n")
print(f"{'Portfolio':<26} {'Rendite':>10} {'Volatilitaet':>13} {'Sharpe':>9}")
print("-" * 88)
for name, w in [("Global Minimum Variance", w_gmv),
("Maximum Sharpe Ratio", w_sharpe),
("Gleichgewichtung (1/N)", w_gleich)]:
r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
print(f"{name:<26} {r*100:>9.2f} % {v*100:>12.2f} % {sr:>9.2f}")
# --- Gewichte je Titel ------------------------------------------------
print("\n--- Optimierte Portfoliogewichte ---")
sektor_je_titel = {t: s for s, titel in SEKTOREN.items() for t in titel}
tabelle = pd.DataFrame({
"Ticker": TICKER,
"Sektor": [sektor_je_titel[t] for t in TICKER],
"Rendite p.a.": [f"{r*100:+6.1f} %" for r in mu],
"Vola p.a.": [f"{np.sqrt(sigma[i, i])*100:5.1f} %" for i in range(n)],
"GMV": [f"{w*100:5.1f} %" for w in w_gmv],
"Max Sharpe": [f"{w*100:5.1f} %" for w in w_sharpe],
})
print(tabelle.to_string(index=False))
print("\n--- Sektoraufteilung (Kontrolle) ---")
print(f"{'Sektor':<14} {'Grenze':>8} {'GMV':>9} {'Max Sharpe':>12}")
for sektor, positionen in sektor_indizes().items():
print(f"{sektor:<14} {SEKTORGRENZEN[sektor]*100:>7.0f} % "
f"{w_gmv[positionen].sum()*100:>8.1f} % "
f"{w_sharpe[positionen].sum()*100:>11.1f} %")
# --- Effizienzgrenze --------------------------------------------------
ret_gmv = float(mu @ w_gmv)
ret_max = max_erreichbare_rendite(mu, sigma)
print(f"\nEffizienzgrenze von {ret_gmv*100:.2f} % bis {ret_max*100:.2f} % "
f"(unter Restriktionen tatsaechlich erreichbar)")
print(f" Zum Vergleich: bester Einzeltitel {mu.max()*100:.2f} % - "
f"durch die Grenzen nicht erreichbar.")
volas, rets = berechne_frontier(mu, sigma, ret_gmv, ret_max)
# --- Diagramm ---------------------------------------------------------
plt.figure(figsize=(11, 6.5))
plt.plot(volas * 100, rets * 100, "b-", lw=2.5, label="Effizienzgrenze (restringiert)")
for w, farbe, marker, groesse, name in [
(w_gmv, "green", "o", 150, "GMV"),
(w_sharpe, "red", "*", 260, "Max Sharpe"),
(w_gleich, "purple", "D", 110, "Gleichgewichtung")]:
r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
plt.scatter([v * 100], [r * 100], color=farbe, marker=marker, s=groesse,
zorder=5, label=f"{name} (SR={sr:.2f})")
# Kapitalmarktlinie durch den risikofreien Zins und das Tangentialportfolio
r_s, v_s, _ = kennzahlen(w_sharpe, mu, sigma, RISIKOFREI)
x_linie = np.array([0, v_s * 1.25])
plt.plot(x_linie * 100, (RISIKOFREI + (r_s - RISIKOFREI) / v_s * x_linie) * 100,
color="orange", ls=":", lw=2, label="Kapitalmarktlinie")
for i, t in enumerate(TICKER):
plt.scatter(np.sqrt(sigma[i, i]) * 100, mu[i] * 100, color="gray", alpha=0.5, s=40)
plt.annotate(t, (np.sqrt(sigma[i, i]) * 100 + 0.4, mu[i] * 100), fontsize=8)
plt.title("Markowitz-Effizienzgrenze mit Sektor- und Positionsgrenzen", fontsize=12)
plt.xlabel("Annualisierte Volatilitaet [%]")
plt.ylabel("Annualisierte erwartete Rendite [%]")
plt.grid(True, linestyle=":", alpha=0.6)
plt.legend(loc="best")
plt.tight_layout()
ziel = os.path.join(OUTPUT_DIR, "markowitz_efficient_frontier.png")
plt.savefig(ziel, dpi=150)
print(f"\nDiagramm gespeichert unter '{ziel}'")
print("=" * 88)

View file

@ -0,0 +1,54 @@
#!/usr/bin/env python3
# Matrixform.py
"""
Kapitel Fundament: Von der ausgeschriebenen Form zur Matrixform - und zurück.
Zeigt, dass beide Schreibweisen dasselbe Modell beschreiben.
"""
import numpy as np
from scipy.optimize import linprog
# --- Modell in Matrixform -------------------------------------------------
# max 3*x1 + 5*x2 u.d.N. x1 <= 4, 2*x2 <= 12, 3*x1 + 2*x2 <= 18, x >= 0
c = np.array([3.0, 5.0]) # Ertragsvektor (Maximierung)
A = np.array([[1.0, 0.0], # Zeile 1: nur x1 kommt vor
[0.0, 2.0], # Zeile 2: nur x2 kommt vor
[3.0, 2.0]]) # Zeile 3: beide
b = np.array([4.0, 12.0, 18.0])
namen = ["Rohstoff A", "Rohstoff B", "Maschinenzeit"]
# --- Ausgeschriebene Form maschinell erzeugen ------------------------------
def zeige_ausgeschrieben(c, A, b, namen):
"""Druckt die Matrixform als lesbares Ungleichungssystem."""
terme = " + ".join(f"{c[j]:g}*x{j+1}" for j in range(len(c)))
print(f"max {terme}")
print("u.d.N.")
for i in range(A.shape[0]):
summanden = " + ".join(f"{A[i, j]:g}*x{j+1}"
for j in range(A.shape[1]) if A[i, j] != 0)
print(f" {summanden:<24} <= {b[i]:>5g} ({namen[i]})")
print(f" x1, ..., x{len(c)} >= 0")
zeige_ausgeschrieben(c, A, b, namen)
# --- Zulässigkeit eines Punktes prüfen ------------------------------------
def ist_zulaessig(x, A, b, toleranz=1e-9):
"""Prüft A x <= b und x >= 0 komponentenweise."""
verbrauch = A @ x # Matrix-Vektor-Produkt: alle Zeilen auf einmal
return bool(np.all(verbrauch <= b + toleranz) and np.all(x >= -toleranz))
for kandidat in [np.array([2.0, 6.0]), np.array([4.0, 3.0]), np.array([4.0, 6.0])]:
zulaessig = ist_zulaessig(kandidat, A, b)
zielwert = c @ kandidat
verbrauch = A @ kandidat
print(f"\nx = {kandidat} -> A x = {verbrauch} "
f"{'zulaessig' if zulaessig else 'UNZULAESSIG'}, Z = {zielwert:g}")
# --- Lösen: linprog minimiert, also c negieren -----------------------------
ergebnis = linprog(c=-c, A_ub=A, b_ub=b, bounds=[(0, None)] * len(c), method="highs")
print("\n" + "-" * 60)
print(f"Optimale Loesung: x* = {np.round(ergebnis.x, 4)}")
print(f"Optimaler Wert: Z* = {-ergebnis.fun:g}")
print("Hinweis: linprog minimiert, deshalb wurde c negiert und das")
print(" Ergebnis am Ende wieder mit -1 multipliziert.")

View file

@ -0,0 +1,211 @@
#!/usr/bin/env python3
# Mehrperiodige_Order_Execution.py
"""
Kapitel Dynamische Programmierung: Dynamische Programmierung fuer optimale Orderausfuehrung
(Almgren-Chriss-Rahmen, geloest per Rueckwaertsinduktion).
Eigenschaften:
* Risikoterm sauber hergeleitet ueber den Aktienkurs P0 (Einheiten: EUR),
ohne undokumentierte Skalierungsfaktoren
* Vergleich mit der analytischen Almgren-Chriss-Loesung
* Vergleich mit naiven Strategien (alles sofort / gleichmaessig)
* Sensitivitaet gegenueber der Risikoaversion
"""
import os
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
os.makedirs(OUTPUT_DIR, exist_ok=True)
# --- Parameter -------------------------------------------------------------
GESAMTBESTAND = 100_000 # X_0, zu verkaufende Aktien
PERIODEN = 5 # T Handelsperioden
KURS = 50.0 # P_0 in EUR, zur Skalierung des Risikoterms
ETA = 2.5e-6 # EUR je Stueck^2 (Slippage-Koeffizient)
VOLA_JAHR = 0.30 # 30 % p.a.
HANDELSSTUNDEN_JAHR = 252 * 6.5
RISIKOAVERSION = 1e-5 # 1/EUR; kalibriert, siehe Kommentar unten
SCHRITTWEITE = 1000 # Diskretisierung des Zustandsraums
# Kalibrierungshinweis: Die dimensionslose Kennzahl des Modells ist
# kappa_tilde^2 = lambda * sigma_periode^2 * P0^2 / eta
# Sie entscheidet ueber den Charakter der Loesung:
# << 1 -> praktisch gleichmaessige Aufteilung (Risiko spielt keine Rolle)
# ~ 1 -> ausgewogener Kompromiss <- hier: 0.55
# >> 1 -> fast alles sofort verkaufen
# Genau diese Interpretierbarkeit geht mit einem undokumentierten
# Skalierungsfaktor verloren.
VOLA_PERIODE = VOLA_JAHR / np.sqrt(HANDELSSTUNDEN_JAHR)
def periodenkosten(verkauf: float, restbestand: float) -> float:
"""
Sofortkosten einer Periode in EUR:
(1) Marktauswirkung: eta * n^2
(2) Risiko des Restbestands: lambda/2 * sigma^2 * P0^2 * X^2
-> P0^2 macht aus "Stueck^2" einen EUR^2-Wert; lambda hat damit
die Einheit 1/EUR und ist interpretierbar.
"""
marktauswirkung = ETA * verkauf ** 2
wertvarianz = (VOLA_PERIODE ** 2) * (KURS ** 2) * (restbestand ** 2)
risiko = 0.5 * RISIKOAVERSION * wertvarianz
return marktauswirkung + risiko
def loese_dp():
"""Rueckwaertsinduktion ueber den diskretisierten Zustandsraum."""
zustaende = np.arange(0, GESAMTBESTAND + SCHRITTWEITE, SCHRITTWEITE)
anzahl = len(zustaende)
V = np.full((PERIODEN + 1, anzahl), np.inf)
politik = np.zeros((PERIODEN, anzahl), dtype=int)
V[PERIODEN, 0] = 0.0 # am Ende muss alles verkauft sein
for t in range(PERIODEN - 1, -1, -1):
for idx, bestand in enumerate(zustaende):
if t == PERIODEN - 1:
moegliche = [bestand] # letzte Periode: Rest muss weg
else:
moegliche = zustaende[zustaende <= bestand]
bester_wert, beste_aktion = np.inf, 0
for verkauf in moegliche:
rest = bestand - verkauf
rest_idx = int(round(rest / SCHRITTWEITE))
gesamt = periodenkosten(verkauf, rest) + V[t + 1, rest_idx]
if gesamt < bester_wert:
bester_wert, beste_aktion = gesamt, int(verkauf)
V[t, idx] = bester_wert
politik[t, idx] = beste_aktion
return zustaende, V, politik
def analytische_loesung():
"""
Geschlossene Almgren-Chriss-Loesung fuer den kontinuierlichen Fall.
Der optimale Pfad ist X_t = X_0 * sinh(kappa*(T-t)) / sinh(kappa*T)
mit kappa = arccosh(tilde_kappa^2/2 + 1), tilde_kappa^2 = lambda*sigma^2*P0^2/eta.
Dient hier als unabhaengige Kontrolle des DP-Ergebnisses.
"""
kappa_tilde_quadrat = (RISIKOAVERSION * (VOLA_PERIODE ** 2) * (KURS ** 2)) / ETA
kappa = np.arccosh(kappa_tilde_quadrat / 2.0 + 1.0)
if kappa < 1e-12: # Grenzfall: risikoneutral -> linear
return np.linspace(GESAMTBESTAND, 0, PERIODEN + 1)
t = np.arange(PERIODEN + 1)
return GESAMTBESTAND * np.sinh(kappa * (PERIODEN - t)) / np.sinh(kappa * PERIODEN)
def bewerte_pfad(bestaende):
"""Gesamtkosten eines beliebigen Bestandspfades."""
summe = 0.0
for t in range(len(bestaende) - 1):
verkauf = bestaende[t] - bestaende[t + 1]
summe += periodenkosten(verkauf, bestaende[t + 1])
return summe
if __name__ == "__main__":
zustaende, V, politik = loese_dp()
# --- Vorwaertspfad der optimalen Politik -----------------------------
bestand = GESAMTBESTAND
verlauf = [bestand]
verkaeufe = []
for t in range(PERIODEN):
idx = int(round(bestand / SCHRITTWEITE))
verkauf = politik[t, idx]
verkaeufe.append(verkauf)
bestand -= verkauf
verlauf.append(bestand)
print("=" * 84)
print(" OPTIMALE MEHRPERIODIGE ORDER-EXECUTION (BELLMAN DP)")
print("=" * 84)
print(f"Gesamtvolumen: {GESAMTBESTAND:,} Stueck zu {KURS:.2f} EUR "
f"= {GESAMTBESTAND*KURS:,.0f} EUR Positionswert")
print(f"Zeithorizont: {PERIODEN} Handelsperioden")
print(f"Volatilitaet: {VOLA_JAHR*100:.0f} % p.a. "
f"= {VOLA_PERIODE*100:.3f} % je Periode")
print(f"Slippage eta: {ETA:.2e} EUR/Stueck^2")
print(f"Risikoaversion: {RISIKOAVERSION:.2e} 1/EUR")
print(f"Erwartete Gesamtreibung: {V[0, -1]:,.2f} EUR "
f"({V[0, -1]/(GESAMTBESTAND*KURS)*10000:.1f} Basispunkte)\n")
plan = pd.DataFrame([{
"Periode": f"t = {t} -> {t+1}",
"Startbestand": f"{verlauf[t]:,}",
"Verkauf n_t": f"{verkaeufe[t]:,}",
"Restbestand": f"{verlauf[t+1]:,}",
"Anteil": f"{verkaeufe[t]/GESAMTBESTAND*100:5.1f} %",
"Kosten (EUR)": f"{periodenkosten(verkaeufe[t], verlauf[t+1]):,.0f}",
} for t in range(PERIODEN)])
print(plan.to_string(index=False))
# --- Vergleich mit Alternativen und der analytischen Loesung ---------
sofort = [GESAMTBESTAND] + [0] * PERIODEN
gleichmaessig = [GESAMTBESTAND * (1 - t / PERIODEN) for t in range(PERIODEN + 1)]
analytisch = analytische_loesung()
print("\n" + "-" * 84)
print(f"{'Strategie':<34} {'Kosten (EUR)':>15} {'Basispunkte':>13} "
f"{'ggue. Optimum':>16}")
print("-" * 84)
optimum = V[0, -1]
for name, pfad in [("DP-Optimum", verlauf),
("Analytisch (Almgren-Chriss)", list(analytisch)),
("Gleichmaessig (TWAP)", gleichmaessig),
("Alles sofort", sofort)]:
kosten = bewerte_pfad(pfad)
bp = kosten / (GESAMTBESTAND * KURS) * 10000
print(f"{name:<34} {kosten:>15,.0f} {bp:>12.1f} "
f"{kosten - optimum:>+15,.0f}")
print("-" * 84)
abweichung = abs(bewerte_pfad(list(analytisch)) - optimum) / optimum
print(f"Abweichung DP zur analytischen Loesung: {abweichung*100:.3f} % "
f"(Diskretisierung: {SCHRITTWEITE} Stueck)")
# --- Sensitivitaet gegenueber der Risikoaversion ---------------------
print("\n--- Wie wirkt die Risikoaversion? ---")
print(f"{'lambda':>10} | {'Verkauf in Periode 0':>22} | {'Charakter':<28}")
print("-" * 70)
for lam in [1e-7, 1e-6, 1e-5, 1e-4, 1e-3]:
globals()["RISIKOAVERSION"] = lam
_, V_l, pol_l = loese_dp()
erste = pol_l[0, -1]
anteil = erste / GESAMTBESTAND * 100
charakter = ("nahezu gleichmaessig" if anteil < 25 else
"front-loaded" if anteil < 60 else "fast alles sofort")
print(f"{lam:>10.0e} | {erste:>13,} ({anteil:5.1f} %) | {charakter:<28}")
globals()["RISIKOAVERSION"] = 1e-5 # zuruecksetzen
# --- Diagramm ---------------------------------------------------------
plt.figure(figsize=(10, 5.5))
plt.plot(range(PERIODEN + 1), verlauf, "o-", lw=2.5, label="DP-Optimum")
plt.plot(range(PERIODEN + 1), analytisch, "s--", lw=1.8, alpha=0.8,
label="Analytisch (Almgren-Chriss)")
plt.plot(range(PERIODEN + 1), gleichmaessig, "^:", lw=1.8, alpha=0.8,
label="Gleichmaessig (TWAP)")
plt.bar(range(PERIODEN), verkaeufe, alpha=0.25, color="orange", width=0.45,
label="Verkaufstranche $n_t$")
plt.title("Optimaler Liquidationspfad ueber diskrete Perioden", fontsize=12)
plt.xlabel("Handelsperiode $t$")
plt.ylabel("Verbleibender Bestand $X_t$")
plt.xticks(range(PERIODEN + 1))
plt.grid(True, linestyle=":", alpha=0.6)
plt.legend()
plt.tight_layout()
ziel = os.path.join(OUTPUT_DIR, "optimal_execution_dp.png")
plt.savefig(ziel, dpi=150)
print(f"\nDiagramm gespeichert unter '{ziel}'")
print("=" * 84)

View 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)

View file

@ -0,0 +1,232 @@
#!/usr/bin/env python3
# Metaheuristik_vs_Exakt.py
"""
Kapitel Metaheuristiken: Ab wann lohnt sich eine Metaheuristik? Die Antwort ist eine Zahl.
Die verbreitete Regel lautet: "Kleine Probleme exakt, grosse mit
Metaheuristiken." Sie ist richtig - aber voellig nutzlos, solange niemand sagt,
wo 'gross' anfaengt. Dieses Programm misst es fuer die Ruestzeitenaufgabe aus
Simulated_Annealing.py nach, und das Ergebnis ueberrascht in beide Richtungen:
* Bei 60 Auftraegen loest CP-SAT BEWEISBAR optimal, in wenigen Sekunden.
Die Metaheuristik ist hier schlicht die schlechtere Wahl - sie liefert ein
schwaecheres Ergebnis und weiss nicht einmal, wie schwach.
* Bei 200 Auftraegen liegt CP-SAT ohne Optimalitaetsbeweis vorne.
* Erst bei 500 Auftraegen kippt es: Dort gibt CP-SAT im Zeitlimit eine
Loesung aus, die SCHLECHTER ist als die Faustregel eines Meisters.
Der Punkt des Kapitels steht in der letzten Spalte: Die Schranke. Nur der
exakte Solver sagt, wie gut die Loesung sein KANN. Eine Metaheuristik allein
liefert eine Zahl ohne Massstab - man weiss nie, ob man 2 % oder 40 % neben dem
Optimum liegt.
ZUR REPRODUZIERBARKEIT: Die Spalte 'Annealing' ist auf jeder Maschine gleich -
sie laeuft ueber ein festes ZUGbudget. Die CP-SAT-Spalten sind es nicht: Ein
Zeitlimit ist ein Wanduhr-Limit, und wie weit der Solver in 30 Sekunden kommt,
haengt von der Maschine ab. CP-SAT kennt zwar ein deterministisches Zeitmass
(max_deterministic_time), das aber in einer Groessenordnung rechnet, die fuer
ein Buchbeispiel unbrauchbar langsam ist. Die Zahlen unten sind also
hardwareabhaengig - die AUSSAGE der Tabelle ist es nicht, sie ist auf
verschiedenen Groessen und in mehreren Laeufen stabil.
WICHTIG: Hier wird nur ortools importiert, nicht highspy (Kapitel Oekosystem).
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import time
import numpy as np
from ortools.sat.python import cp_model
ZUEGE = 400_000 # Zugbudget der Metaheuristik (wie Simulated_Annealing.py)
ZEITLIMIT = 30.0 # Zeitbudget des exakten Solvers
GROESSEN = (60, 200, 500)
# --- Instanz, Faustregel und Suche: identisch zu Simulated_Annealing.py -----
def erzeuge_ruestmatrix(n: int, saat: int = 11) -> 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]]
+ 3 * max(0, farbe[i] - farbe[j]))
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, matrix, von, nach):
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
def annealing(matrix: np.ndarray, zuege: int = ZUEGE,
start_temperatur: float = 1.0, saat: int = 1) -> tuple[int, float]:
"""Dieselbe Suche wie in Simulated_Annealing.py, hier nur als Vergleichswert."""
import math
rng = np.random.default_rng(saat)
n = len(matrix)
reihe = faustregel(matrix)
kosten = gesamtruestzeit(reihe, matrix)
beste_kosten = kosten
t0 = time.perf_counter()
for zug in range(zuege):
temperatur = start_temperatur * (0.05 / 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 None:
continue
aenderung, rest, stelle = ergebnis
if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):
reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
kosten += aenderung
beste_kosten = min(beste_kosten, kosten)
return beste_kosten, time.perf_counter() - t0
# --- Der exakte Weg: CP-SAT mit AddCircuit ---------------------------------
def exakt(matrix: np.ndarray, zeitlimit: float = ZEITLIMIT):
"""Reihenfolge als Rundreise mit einem kostenlosen Hilfsknoten.
AddCircuit verlangt einen geschlossenen Kreis. Unsere Aufgabe ist aber eine
offene Kette: Der erste Auftrag hat keinen Vorgaenger, der letzte keinen
Nachfolger. Der uebliche Kniff ist ein zusaetzlicher Hilfsknoten n, dessen
Kanten nichts kosten. Der Kreis laeuft dann Hilfsknoten -> Kette ->
Hilfsknoten, und die Kosten des Kreises sind genau die der Kette.
AddCircuit ist der Grund, warum CP-SAT hier so lange mithaelt: Die
Nebenbedingung sorgt selbst dafuer, dass keine Teilkreise entstehen. Wer
dasselbe als MILP formuliert, braucht dafuer entweder exponentiell viele
Ungleichungen oder die schwache MTZ-Formulierung (Kapitel Graphen).
"""
n = len(matrix)
modell = cp_model.CpModel()
kanten, ziel = [], []
for i in range(n + 1):
for j in range(n + 1):
if i == j:
continue
aktiv = modell.NewBoolVar(f"kante_{i}_{j}")
kanten.append((i, j, aktiv))
if i < n and j < n: # Hilfsknoten kostet nichts
ziel.append(int(matrix[i, j]) * aktiv)
modell.AddCircuit(kanten)
modell.Minimize(sum(ziel))
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = zeitlimit
loeser.parameters.num_workers = 8
loeser.parameters.random_seed = 1
t0 = time.perf_counter()
status = loeser.Solve(modell)
dauer = time.perf_counter() - t0
return (loeser.StatusName(status), int(loeser.ObjectiveValue()),
int(loeser.BestObjectiveBound()), dauer)
if __name__ == "__main__":
print("=" * 88)
print(" AB WANN LOHNT SICH DIE METAHEURISTIK?")
print("=" * 88)
print(f"Dieselbe Aufgabe in drei Groessen. Metaheuristik: {ZUEGE:,} Zuege. "
f"Exakt: {ZEITLIMIT:.0f} s Zeitlimit.\n")
print(f"{'Auftraege':>10} {'Faustregel':>11} {'Annealing':>10} "
f"{'CP-SAT':>9} {'Status':>10} {'Schranke':>9} {'CP-Zeit':>9}")
print("-" * 88)
zeilen = []
for n in GROESSEN:
matrix = erzeuge_ruestmatrix(n)
start = gesamtruestzeit(faustregel(matrix), matrix)
heuristisch, _ = annealing(matrix)
status, wert, schranke, dauer = exakt(matrix)
zeilen.append((n, start, heuristisch, wert, status, schranke, dauer))
print(f"{n:>10} {start:>11,} {heuristisch:>10,} {wert:>9,} "
f"{status:>10} {schranke:>9,} {dauer:>8.1f}s")
print("-" * 88)
print("\nWas in jeder Zeile steht:\n")
for n, start, heur, wert, status, schranke, _ in zeilen:
if status == "OPTIMAL":
urteil = (f"CP-SAT beweist das Optimum. Annealing liegt "
f"{(heur - wert) / wert * 100:.1f} % daneben - und haette")
zweite = " das ohne den exakten Lauf nicht erfahren."
elif wert <= heur:
urteil = (f"CP-SAT liegt {(heur - wert) / heur * 100:.1f} % vor Annealing, "
f"ohne Beweis (Gap {(wert - schranke) / wert * 100:.1f} %).")
zweite = " Auch ohne Optimalitaetsbeweis ist es die bessere Wahl."
else:
urteil = (f"UMSCHLAGPUNKT: CP-SAT ist {(wert - heur) / heur * 100:.1f} % "
f"SCHLECHTER als Annealing")
zweite = (f" und sogar {(wert - start) / start * 100:.1f} % schlechter "
f"als die Faustregel des Meisters.")
print(f" {n:>4} Auftraege: {urteil}")
print(zweite)
n, start, heur, wert, status, schranke, _ = zeilen[-1]
print("\n" + "=" * 88)
print(" DIE SPALTE, DIE MAN NICHT WEGLASSEN DARF")
print("=" * 88)
print("Die Schranke ist der eigentliche Ertrag des exakten Solvers - auch dann,")
print("wenn seine LOESUNG unbrauchbar ist.")
print()
print(f"Bei {n} Auftraegen liefert Annealing {heur:,} Minuten. Klingt gut: "
f"{(start - heur) / start * 100:.1f} %")
print("besser als die Faustregel. Die Schranke aus demselben CP-SAT-Lauf, dessen")
print(f"Loesung wir gerade verworfen haben, sagt aber: Unter {schranke:,} Minuten geht")
print(f"es nicht. Zwischen {schranke:,} und {heur:,} liegen {(heur - schranke) / heur * 100:.0f} % - so viel Luft")
print("kann noch nach oben sein.")
print()
print("Das ist der Unterschied zwischen 'besser als vorher' und 'gut'. Eine")
print("Metaheuristik allein kann nur das Erste. Deshalb gehoert auch dann ein")
print("exakter Lauf dazu, wenn man am Ende die heuristische Loesung einsetzt:")
print("nicht wegen seiner Loesung, sondern wegen seiner Schranke.")
print("=" * 88)

View file

@ -0,0 +1,97 @@
#!/usr/bin/env python3
# Min_Cost_Flow.py
"""
Kapitel Graphen: Kostenminimaler Fluss durch ein Netzwerk.
Loest dasselbe Problem zweimal:
(1) als allgemeines LP mit scipy -> zeigt die Modellstruktur
(2) mit dem spezialisierten Netzwerk-Solver von OR-Tools -> zeigt den
Geschwindigkeitsvorteil eines Verfahrens, das die Struktur ausnutzt
Beide laufen in eigenen Prozessen nicht noetig: scipy und ortools vertragen
sich (nur ortools + highspy kollidieren, siehe Kapitel Oekosystem).
"""
import numpy as np
from scipy.optimize import linprog
# --- Netzwerk definieren ---------------------------------------------------
KNOTEN = ["Werk_A", "Werk_B", "Umschlag", "Kunde_1", "Kunde_2"]
# (von, nach, Kosten je Einheit, Kapazitaet)
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),
]
# Angebot (+) bzw. Bedarf (-) je Knoten
SALDO = {"Werk_A": 20, "Werk_B": 10, "Umschlag": 0, "Kunde_1": -15, "Kunde_2": -15}
def loese_als_lp():
"""Flussproblem als allgemeines lineares Programm."""
n_kanten = len(KANTEN)
knoten_index = {k: i for i, k in enumerate(KNOTEN)}
# Zielfunktion: Summe der Transportkosten
kosten = np.array([k[2] for k in KANTEN])
# Flusserhaltung als Gleichungssystem: A_eq @ x = b_eq
A_eq = np.zeros((len(KNOTEN), n_kanten))
for e, (von, nach, _, _) in enumerate(KANTEN):
A_eq[knoten_index[von], e] = +1.0 # fliesst hinaus
A_eq[knoten_index[nach], e] = -1.0 # fliesst hinein
b_eq = np.array([SALDO[k] for k in KNOTEN], dtype=float)
schranken = [(0, k[3]) for k in KANTEN] # 0 <= x_ij <= u_ij
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 ergebnis.fun, ergebnis.x, ergebnis.eqlin.marginals
if __name__ == "__main__":
# Vorabpruefung: Angebot muss Bedarf entsprechen
gesamt = sum(SALDO.values())
print("=" * 78)
print(" KOSTENMINIMALER FLUSS DURCH EIN TRANSPORTNETZ")
print("=" * 78)
print(f"Angebot gesamt: {sum(v for v in SALDO.values() if v > 0)} | "
f"Bedarf gesamt: {-sum(v for v in SALDO.values() if v < 0)} | "
f"Saldo: {gesamt}")
if gesamt != 0:
raise SystemExit("Angebot und Bedarf stimmen nicht ueberein - unloesbar!")
kosten_gesamt, fluss, knotenpreise = loese_als_lp()
print(f"\nMinimale Transportkosten: {kosten_gesamt:,.2f} EUR\n")
print(f"{'Kante':<24} {'Fluss':>7} {'Kapazitaet':>11} {'Kosten/E':>9} {'Kosten':>9}")
print("-" * 78)
for e, (von, nach, c, u) in enumerate(KANTEN):
menge = fluss[e] + 0.0 if abs(fluss[e]) > 1e-9 else 0.0 # vermeidet "-0.0"
ausgelastet = " (VOLL)" if abs(menge - u) < 1e-6 else ""
print(f"{von + ' -> ' + nach:<24} {menge:>7.1f} {u:>11} "
f"{c:>9.2f} {menge * c:>9.2f}{ausgelastet}")
# --- Flusserhaltung nachpruefen --------------------------------------
print("\n--- Pruefung der Flusserhaltung je Knoten ---")
for k in KNOTEN:
hinaus = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if v == k)
hinein = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if n == k)
netto = hinaus - hinein
art = "Quelle" if SALDO[k] > 0 else ("Senke" if SALDO[k] < 0 else "Umschlag")
print(f" {k:<10} ({art:<8}): hinaus {hinaus:5.1f} - hinein {hinein:5.1f} "
f"= {netto:+6.1f} (gefordert: {SALDO[k]:+d})")
assert abs(netto - SALDO[k]) < 1e-6, f"Flusserhaltung verletzt bei {k}!"
# --- Knotenpreise (Dualwerte) interpretieren -------------------------
print("\n--- Knotenpreise (Dualwerte der Flusserhaltung) ---")
print(" Differenz zweier Knotenpreise = Grenzkosten einer zusaetzlichen Einheit")
print(" auf dem guenstigsten Weg zwischen ihnen.")
for k, preis in zip(KNOTEN, knotenpreise):
print(f" {k:<10}: {preis:7.2f}")
print("=" * 78)

View file

@ -0,0 +1,170 @@
#!/usr/bin/env python3
# Modellierungsschichten.py
"""
Kapitel Oekosystem: Pyomo und Linopy - zwei Modellierungsschichten fuer grosse Modelle.
Geloest wird dasselbe Produktionsproblem wie im Vierfach-Vergleich:
max 10*x1 + 15*x2 + 25*x3
u.d.N. x1 + x2 + 2*x3 <= 40
2*x1 + 3*x2 + x3 <= 50
x >= 0
Handrechnung: Z* = 530 bei x = (0, 12, 14).
Der Vergleich zeigt die beiden Denkweisen:
* Pyomo - algebraisch, indexbasiert, Industriestandard fuer Grossmodelle,
trennt Modellstruktur sauber von den Daten (AbstractModel).
* Linopy - beschriftete Arrays (xarray): eine Zeile Code erzeugt Tausende
Nebenbedingungen auf einmal, ohne Python-Schleife.
Beide bringen KEINEN eigenen Solver mit; hier rechnet in beiden Faellen HiGHS.
WICHTIG: ortools wird in diesem Programm bewusst NICHT importiert - es
vertraegt sich nicht mit der HiGHS-Kopie, die Pyomo und Linopy laden
(siehe die Stolperfalle im Abschnitt 'Ein System - vier Programmieransaetze').
Benoetigt: pyomo, linopy, xarray, pandas, highspy, numpy
"""
from __future__ import annotations
import time
import numpy as np
import pandas as pd
import pyomo.environ as pyo
import xarray as xr
import linopy
ERWARTET = 530.0 # Ergebnis der Handrechnung
PRODUKTE = ["Standard", "Komfort", "Premium"]
RESSOURCEN = ["Material", "Montage"]
DECKUNGSBEITRAG = np.array([10.0, 15.0, 25.0])
VERBRAUCH = np.array([[1.0, 1.0, 2.0], # Material je Produkt
[2.0, 3.0, 1.0]]) # Montage je Produkt
VORRAT = np.array([40.0, 50.0])
# --- Pyomo: algebraisch und indexbasiert ------------------------------------
def loese_mit_pyomo() -> tuple[float, list[float], float]:
"""Pyomo denkt in Mengen und Indizes, wie ein Mathematiker es aufschreibt.
`Constraint(RESSOURCEN, rule=...)` erzeugt fuer JEDES Element der Menge
eine Nebenbedingung - das ist das 'fuer alle i' der Formelsprache,
unmittelbar in Code uebersetzt.
"""
t0 = time.perf_counter()
modell = pyo.ConcreteModel(name="Produktionsprogramm")
modell.P = pyo.Set(initialize=PRODUKTE)
modell.R = pyo.Set(initialize=RESSOURCEN)
modell.db = pyo.Param(modell.P, initialize=dict(zip(PRODUKTE, DECKUNGSBEITRAG)))
modell.a = pyo.Param(modell.R, modell.P, initialize={
(r, p): VERBRAUCH[i, j]
for i, r in enumerate(RESSOURCEN) for j, p in enumerate(PRODUKTE)})
modell.vorrat = pyo.Param(modell.R, initialize=dict(zip(RESSOURCEN, VORRAT)))
modell.x = pyo.Var(modell.P, domain=pyo.NonNegativeReals)
modell.ziel = pyo.Objective(
expr=sum(modell.db[p] * modell.x[p] for p in modell.P),
sense=pyo.maximize)
def kapazitaet(m, r):
return sum(m.a[r, p] * m.x[p] for p in m.P) <= m.vorrat[r]
modell.kapazitaet = pyo.Constraint(modell.R, rule=kapazitaet)
ergebnis = pyo.SolverFactory("appsi_highs").solve(modell)
dauer = time.perf_counter() - t0
status = ergebnis.solver.termination_condition
if status != pyo.TerminationCondition.optimal:
raise RuntimeError(f"Pyomo meldet Status: {status}")
return (float(pyo.value(modell.ziel)),
[float(pyo.value(modell.x[p])) for p in PRODUKTE],
dauer)
# --- Linopy: beschriftete Arrays --------------------------------------------
def loese_mit_linopy() -> tuple[float, list[float], float]:
"""Linopy denkt in beschrifteten Arrays (xarray).
Der entscheidende Unterschied: `(verbrauch * x).sum("produkt") <= vorrat`
ist EINE Zeile und erzeugt so viele Nebenbedingungen, wie die Dimension
'ressource' Eintraege hat. Bei 2 Ressourcen faellt das nicht auf, bei
200 000 schon - dort entstehen sie als Matrixoperation statt in einer
Python-Schleife.
"""
t0 = time.perf_counter()
# Benannte Indizes statt blosser Listen: Dadurch heissen die Achsen
# 'produkt' und 'ressource', und xarray fuehrt sie beim Rechnen von allein
# richtig zusammen. Ohne Namen vergibt linopy 'dim_0', und man muss
# spaeter umbenennen - eine haeufige Stolperstelle.
produkt = pd.Index(PRODUKTE, name="produkt")
ressource = pd.Index(RESSOURCEN, name="ressource")
modell = linopy.Model()
modell.add_variables(lower=0, coords=[produkt], name="menge")
x = modell.variables["menge"]
db = xr.DataArray(DECKUNGSBEITRAG, coords=[produkt])
verbrauch = xr.DataArray(VERBRAUCH, coords=[ressource, produkt])
vorrat = xr.DataArray(VORRAT, coords=[ressource])
# EINE Zeile - sie erzeugt so viele Nebenbedingungen, wie die Achse
# 'ressource' Eintraege hat. Genau das ist der Punkt.
modell.add_constraints((verbrauch * x).sum("produkt") <= vorrat,
name="kapazitaet")
modell.add_objective((db * x).sum(), sense="max")
modell.solve(solver_name="highs", output_flag=False)
dauer = time.perf_counter() - t0
if modell.termination_condition != "optimal":
raise RuntimeError(f"Linopy meldet Status: {modell.termination_condition}")
loesung = modell.variables["menge"].solution.to_series()
return (float(modell.objective.value),
[float(loesung[p]) for p in PRODUKTE],
dauer)
if __name__ == "__main__":
print("=" * 80)
print(" MODELLIERUNGSSCHICHTEN FUER GROSSE MODELLE")
print("=" * 80)
print(f"{'Schicht':<14} {'Z*':>10} {'Standard':>10} {'Komfort':>10} "
f"{'Premium':>10} {'Zeit':>10}")
print("-" * 80)
ergebnisse = []
for name, loeser in [("Pyomo", loese_mit_pyomo), ("Linopy", loese_mit_linopy)]:
ziel, mengen, dauer = loeser()
ergebnisse.append(ziel)
print(f"{name:<14} {ziel:>10.2f} {mengen[0]:>10.2f} {mengen[1]:>10.2f} "
f"{mengen[2]:>10.2f} {dauer:>8.2f} s")
print("-" * 80)
for ziel in ergebnisse:
assert abs(ziel - ERWARTET) < 1e-6, \
f"Abweichung von der Handrechnung: {ziel} statt {ERWARTET}"
print(f"Beide stimmen mit der Handrechnung ueberein (Z* = {ERWARTET:.0f}).")
print()
print("Wann welche Schicht?")
print(" Pyomo -> wenn Modellstruktur und Daten getrennt bleiben sollen,")
print(" wenn nichtlineare Terme oder MINLP dazukommen koennen,")
print(" wenn spaeter ein kommerzieller Solver angebunden wird.")
print(" Linopy -> wenn die Daten ohnehin als beschriftete Arrays vorliegen")
print(" (Energiesystem-, Netz- und Zeitreihenmodelle) und das")
print(" Modell zehntausende gleichartige Nebenbedingungen hat.")
print("=" * 80)

View file

@ -0,0 +1,92 @@
#!/usr/bin/env python3
# Monte_Carlo.py
"""
Kapitel Unsicherheit: Monte-Carlo-Bewertung von Kapazitaetsplaenen.
Monte Carlo OPTIMIERT nicht - es BEWERTET. Der Nutzen liegt darin, dass man
beliebige Kennzahlen ablesen kann: Erwartungswert, Quantile, Ausfallwahr-
scheinlichkeit, Worst Case. Genau diese Groessen braucht man, um zwischen
Plaenen zu entscheiden.
"""
import numpy as np
KOSTEN_VORAB = 40.0 # EUR je Einheit, im Voraus gekauft
KOSTEN_SPOT = 120.0 # EUR je Einheit, kurzfristig zugekauft
KOSTEN_LEERLAUF = 5.0 # EUR je ungenutzter Einheit
ANZAHL_ZIEHUNGEN = 100_000
def ziehe_bedarf(rng, ziehungen):
"""
Bedarfsmodell: Mischverteilung aus Normalbetrieb und seltenen Lastspitzen.
Realistischer als eine reine Normalverteilung - Krisen sind selten,
aber extrem (fat tail).
"""
normal = rng.normal(loc=150, scale=40, size=ziehungen)
spitze = rng.normal(loc=450, scale=80, size=ziehungen)
ist_spitze = rng.random(ziehungen) < 0.15 # 15 % Lastspitzen
return np.maximum(np.where(ist_spitze, spitze, normal), 0.0)
def kosten_fuer(kapazitaet, bedarf):
"""Gesamtkosten je Szenario fuer eine gegebene Vorabkapazitaet."""
unterdeckung = np.maximum(bedarf - kapazitaet, 0.0)
ueberdeckung = np.maximum(kapazitaet - bedarf, 0.0)
return (KOSTEN_VORAB * kapazitaet
+ KOSTEN_SPOT * unterdeckung
+ KOSTEN_LEERLAUF * ueberdeckung)
if __name__ == "__main__":
rng = np.random.default_rng(2026)
bedarf = ziehe_bedarf(rng, ANZAHL_ZIEHUNGEN)
print("=" * 88)
print(f" MONTE-CARLO-BEWERTUNG ({ANZAHL_ZIEHUNGEN:,} Szenarien)")
print("=" * 88)
print(f"Bedarfsverteilung: Mittelwert {bedarf.mean():.1f} | "
f"Median {np.median(bedarf):.1f} | "
f"95%-Quantil {np.percentile(bedarf, 95):.1f} | "
f"Maximum {bedarf.max():.1f}")
print("Der Median liegt deutlich unter dem Mittelwert - die Verteilung ist")
print("rechtsschief. Genau hier fuehrt Planung mit dem Mittelwert in die Irre.\n")
print(f"{'Kapazitaet':>10} | {'Erw. Kosten':>12} | {'Median':>10} | "
f"{'95%-Quantil':>12} | {'Unterdeckung':>12}")
print("-" * 88)
kandidaten = [150, 200, 225, 250, 300, 350, 400]
ergebnisse = []
for kapazitaet in kandidaten:
kosten = kosten_fuer(kapazitaet, bedarf)
p_unterdeckung = float(np.mean(bedarf > kapazitaet))
ergebnisse.append((kapazitaet, kosten.mean(), p_unterdeckung))
print(f"{kapazitaet:>10} | {kosten.mean():>12,.0f} | "
f"{np.median(kosten):>10,.0f} | {np.percentile(kosten, 95):>12,.0f} | "
f"{p_unterdeckung*100:>11.1f} %")
beste = min(ergebnisse, key=lambda t: t[1])
print("-" * 88)
print(f"Bester Kandidat: Kapazitaet {beste[0]} mit erwarteten Kosten "
f"{beste[1]:,.0f} EUR")
# --- Feinsuche ueber ein Raster ---------------------------------------
raster = np.arange(100, 500, 5)
erwartete = np.array([kosten_fuer(k, bedarf).mean() for k in raster])
optimum = raster[int(np.argmin(erwartete))]
print(f"Feinsuche (Raster 100..500): Optimum bei Kapazitaet {optimum}, "
f"Kosten {erwartete.min():,.0f} EUR")
# --- Vergleich mit der naiven Mittelwertplanung ----------------------
naiv = int(round(bedarf.mean()))
kosten_naiv = kosten_fuer(naiv, bedarf).mean()
kosten_opt = kosten_fuer(optimum, bedarf).mean()
print("\n--- Fluch des Durchschnitts, gemessen ---")
print(f" Planung mit Mittelwert ({naiv}): {kosten_naiv:,.0f} EUR")
print(f" Monte-Carlo-Optimum ({optimum}): {kosten_opt:,.0f} EUR")
print(f" Mehrkosten der naiven Planung: {kosten_naiv - kosten_opt:,.0f} EUR "
f"({(kosten_naiv/kosten_opt - 1)*100:.1f} %)")
print("=" * 88)

View file

@ -0,0 +1,179 @@
#!/usr/bin/env python3
# Mutationstest.py
"""
Kapitel Testen: Wer testet die Tests?
`test_or_kern.py` besteht aus 31 Tests, und alle sind gruen. Das ist noch kein
Ergebnis. Eine Testsuite, die nie rot war, ist keine Pruefung, sondern eine
Vermutung - sie koennte aus lauter `assert True` bestehen und saehe genauso aus.
Dieses Programm prueft die Pruefung. Es baut nacheinander KLEINE, gezielte
Fehler in `or_kern.py` ein - jeder einzelne ist ein Fehler, den man beim
Programmieren tatsaechlich macht - und laesst die Testsuite darauf los. Fuer
jede Mutation gibt es genau zwei moegliche Ausgaenge:
GETOETET Mindestens ein Test wird rot. Die Suite haette diesen Fehler
in der Praxis gefunden.
UEBERLEBT Alle Tests bleiben gruen. Die Suite haette diesen Fehler
DURCHGEHEN LASSEN - hier fehlt ein Test.
Ueberlebende Mutationen sind das eigentliche Ergebnis: Sie zeigen die Luecken,
und zwar ohne dass man sie erraten muss.
Das Verfahren heisst Mutationstest und ist alt; neu ist nur, wie gut es zu
Optimierungsmodellen passt. Dort kann man den Zielwert meist nicht gegen einen
bekannten Sollwert pruefen - also weiss man ohne so ein Experiment nicht, ob
die Ersatzpruefungen (Eigenschaften, Invarianten) ueberhaupt greifen.
WICHTIG: Es wird nichts veraendert. Jede Mutation laeuft in einem eigenen
temporaeren Verzeichnis mit einer Kopie von or_kern.py.
Benoetigt: pytest; im selben Verzeichnis or_kern.py und test_or_kern.py
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
# (Name, gesuchter Text, Ersatz) - jede Zeile ein realistischer Fehler.
MUTATIONEN = [
("Nebenbedingung umgedreht (<= wird >=)",
"<= problem.kapazitaeten[ressource], name=ressource)",
">= problem.kapazitaeten[ressource], name=ressource)"),
("Maximierung wird Minimierung",
"solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag",
"solver.Minimize(sum(menge[p.name] * p.deckungsbeitrag"),
("Vorzeichen der Dualwerte vergessen",
"schattenpreise={r: float(-m) for r, m in",
"schattenpreise={r: float(m) for r, m in"),
("Abnahmepruefung aufgeweicht (Toleranz 1e-6 -> 1.0)",
"toleranz: float = 1e-6) -> list[str]:",
"toleranz: float = 1.0) -> list[str]:"),
("Kapazitaetspruefung uebersprungen",
"if ist > grenze + toleranz:",
"if False:"),
("Pruefung auf doppelte Produktnamen entfernt",
"if len(set(namen)) != len(namen):",
"if False:"),
("Ganzzahligkeitspruefung entfernt",
"if abs(wert - round(wert)) > toleranz:",
"if False:"),
("Zielwertabgleich entfernt",
"if abs(nachgerechnet - loesung.zielwert) > toleranz * max(1.0, abs(nachgerechnet)):",
"if False:"),
("ZEITLIMIT gilt faelschlich als brauchbar",
"return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)",
"return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG,\n"
" SolverStatus.ZEITLIMIT)"),
("Ressourcenreihenfolge nicht mehr stabil",
"return sorted(self.kapazitaeten)",
"return list(self.kapazitaeten)"),
]
def fuehre_suite_aus(verzeichnis: str) -> tuple[int, int, str]:
"""Laesst pytest im Verzeichnis laufen; liefert (bestanden, fehlgeschlagen)."""
ergebnis = subprocess.run(
[sys.executable, "-m", "pytest", "test_or_kern.py", "-q",
"--tb=no", "-p", "no:cacheprovider"],
cwd=verzeichnis, capture_output=True, text=True, timeout=600)
letzte = [z for z in ergebnis.stdout.strip().splitlines() if z.strip()]
zeile = letzte[-1] if letzte else ""
bestanden = fehlgeschlagen = 0
for teil in zeile.replace("=", " ").split(","):
for wort in teil.split():
if wort.isdigit():
zahl = int(wort)
elif wort.startswith("passed"):
bestanden = zahl
elif wort.startswith("failed") or wort.startswith("error"):
fehlgeschlagen += zahl
return bestanden, fehlgeschlagen, zeile
def mutiere_und_pruefe(quelle: str, ziel: str, suche: str,
ersatz: str) -> tuple[int, int, str] | None:
"""Legt eine mutierte Kopie an und laesst die Suite darauf laufen."""
with tempfile.TemporaryDirectory() as verzeichnis:
text = open(quelle, encoding="utf-8").read()
if text.count(suche) != 1:
return None # Muster passt nicht (mehr)
open(os.path.join(verzeichnis, "or_kern.py"), "w",
encoding="utf-8").write(text.replace(suche, ersatz))
shutil.copy(ziel, verzeichnis)
return fuehre_suite_aus(verzeichnis)
if __name__ == "__main__":
hier = os.path.dirname(os.path.abspath(__file__))
quelle = os.path.join(hier, "or_kern.py")
suite = os.path.join(hier, "test_or_kern.py")
for pfad in (quelle, suite):
if not os.path.exists(pfad):
raise SystemExit(f"Nicht gefunden: {pfad}\n"
f"Beide Dateien muessen im selben Verzeichnis liegen.")
print("=" * 84)
print(" MUTATIONSTEST: WUERDE DIE SUITE DIESE FEHLER FINDEN?")
print("=" * 84)
with tempfile.TemporaryDirectory() as v:
shutil.copy(quelle, v)
shutil.copy(suite, v)
bestanden, fehlgeschlagen, _ = fuehre_suite_aus(v)
print(f"Ausgangslage: {bestanden} Tests, {fehlgeschlagen} rot.\n")
if fehlgeschlagen:
raise SystemExit("Die Suite ist schon ohne Mutation rot - erst das reparieren.")
print(f"{'eingebauter Fehler':<48} {'rote Tests':>11} {'Urteil':>12}")
print("-" * 84)
getoetet, ueberlebt = [], []
for name, suche, ersatz in MUTATIONEN:
ergebnis = mutiere_und_pruefe(quelle, suite, suche, ersatz)
if ergebnis is None:
print(f"{name:<48} {'-':>11} {'nicht anwendbar':>12}")
continue
_, rot, _ = ergebnis
urteil = "GETOETET" if rot else "UEBERLEBT"
(getoetet if rot else ueberlebt).append(name)
print(f"{name:<48} {rot:>11} {urteil:>12}")
print("-" * 84)
quote = len(getoetet) / max(1, len(getoetet) + len(ueberlebt)) * 100
print(f"Getoetet: {len(getoetet)} von {len(getoetet) + len(ueberlebt)} "
f"({quote:.0f} %)")
print("\n" + "=" * 84)
print(" WAS DAS ERGEBNIS BEDEUTET")
print("=" * 84)
if ueberlebt:
print("Diese Fehler haette die Suite DURCHGEHEN LASSEN:\n")
for name in ueberlebt:
print(f" * {name}")
print("\nJeder ueberlebende Eintrag ist eine Testluecke - und zwar eine")
print("gefundene, keine vermutete. Das ist der ganze Zweck des Verfahrens:")
print("Es sagt einem, welchen Test man als naechstes schreiben sollte,")
print("statt dass man raten muss.")
else:
print("Alle eingebauten Fehler wurden gefunden. Das heisst nicht, dass die")
print("Suite vollstaendig ist - nur, dass sie diese zehn Fehler faengt.")
print()
print("Die Quote selbst ist keine Kennzahl fuer ein Dashboard. Zehn von Hand")
print("gewaehlte Mutationen sind keine Stichprobe aus der Menge aller")
print("moeglichen Fehler. Was zaehlt, ist die LISTE der Ueberlebenden.")
print("=" * 84)

View file

@ -0,0 +1,315 @@
#!/usr/bin/env python3
# Optimierungsdienst.py
"""
Kapitel Testen: Das Modell als Dienst - und warum es kein normaler Endpunkt ist.
Ein Optimierungsmodell hinter eine HTTP-Schnittstelle zu haengen sieht nach
einer Fingeruebung aus. Es gibt aber einen Unterschied, der alles bestimmt:
Eine gewoehnliche Anfrage dauert Millisekunden.
Eine Optimierung dauert Sekunden bis Minuten - und manchmal laenger,
als jemand warten will.
Damit scheidet die naheliegende Bauform aus. Wer den Solver direkt im
Request-Handler aufruft, baut einen Dienst, der bei der ersten grossen Instanz
in einen Timeout des Reverse Proxy laeuft, und bei zehn gleichzeitigen Anfragen
alle Arbeiter blockiert.
Die tragfaehige Bauform ist deshalb ZWEISTUFIG:
POST /plaene nimmt den Auftrag an, prueft ihn, gibt sofort
202 Accepted und eine Auftragsnummer zurueck
GET /plaene/{id} sagt, wie weit es ist - und liefert am Ende die
Loesung samt Status und Gap
Drei Dinge, die dabei leicht untergehen und hier bewusst sichtbar sind:
1. Das Domaenenmodell aus or_kern.py ist zugleich das API-Schema. Eine
unsinnige Anfrage wird von FastAPI mit 422 abgewiesen, bevor irgendein
Solver startet - dieselbe Pruefung wie beim Excel-Import.
2. Die Antwort enthaelt STATUS und GAP, nicht nur Zahlen. Ein Aufrufer, der
nur die Mengen bekommt, kann nicht unterscheiden, ob er ein bewiesenes
Optimum oder einen Zeitlimit-Abbruch in der Hand haelt.
3. Jeder Auftrag hat ein ZEITLIMIT. Ohne das belegt eine einzige unguenstige
Instanz einen Arbeiter auf unbestimmte Zeit.
Dieses Programm laeuft ohne Server: Der Selbsttest unten benutzt den
TestClient von FastAPI und spricht die Anwendung direkt an. Fuer den echten
Betrieb steht am Ende, was sich aendert.
Aufruf:
python3 Optimierungsdienst.py # Selbsttest, kein Server noetig
uvicorn Optimierungsdienst:app # echter Server auf Port 8000
Benoetigt: fastapi, httpx (fuer den Selbsttest), pydantic, ortools (ueber or_kern)
"""
from __future__ import annotations
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from enum import Enum
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from or_kern import (Loesung, Produktionsproblem, loese_mit_glop,
pruefe_loesung)
ZEITLIMIT_SEKUNDEN = 30.0
ARBEITER = 2
class Auftragsstand(str, Enum):
WARTET = "wartet"
LAEUFT = "laeuft"
FERTIG = "fertig"
GESCHEITERT = "gescheitert"
class Auftragsantwort(BaseModel):
"""Was der Aufrufer beim Abholen bekommt.
Bewusst NICHT nur die Mengen: 'stand' und die Felder aus 'loesung'
(Status, Gap) sind der Unterschied zwischen einer Zahl und einer
belastbaren Auskunft.
"""
id: str
stand: Auftragsstand
eingegangen: float
laufzeit: float | None = None
loesung: Loesung | None = None
beanstandungen: list[str] = Field(default_factory=list)
fehler: str | None = None
app = FastAPI(title="Optimierungsdienst",
summary="Produktionsplanung als Auftrag, nicht als Abfrage")
# Fuer das Buchbeispiel: Auftragsbuch im Speicher, Arbeiter im selben Prozess.
# Was daran im echten Betrieb nicht reicht, steht unten.
AUFTRAEGE: dict[str, Auftragsantwort] = {}
POOL = ThreadPoolExecutor(max_workers=ARBEITER)
def rechne(auftrags_id: str, problem: Produktionsproblem) -> None:
"""Laeuft im Arbeiterthread - nie im Request-Handler."""
antwort = AUFTRAEGE[auftrags_id]
antwort.stand = Auftragsstand.LAEUFT
start = time.perf_counter()
try:
loesung = loese_mit_glop(problem)
antwort.loesung = loesung
# Dieselbe Abnahmepruefung wie ueberall sonst. Ein Dienst, der sie
# weglaesst, liefert Fehler schneller aus als ein Mensch sie faende.
antwort.beanstandungen = pruefe_loesung(problem, loesung)
antwort.stand = (Auftragsstand.FERTIG if loesung.status.brauchbar
and not antwort.beanstandungen
else Auftragsstand.GESCHEITERT)
if not loesung.status.brauchbar:
antwort.fehler = f"Solverstatus: {loesung.status.value}"
elif antwort.beanstandungen:
antwort.fehler = "Abnahmepruefung fehlgeschlagen"
except Exception as fehler: # noqa: BLE001
antwort.stand = Auftragsstand.GESCHEITERT
antwort.fehler = f"{type(fehler).__name__}: {fehler}"
finally:
antwort.laufzeit = time.perf_counter() - start
@app.post("/plaene", status_code=202, response_model=Auftragsantwort)
def auftrag_annehmen(problem: Produktionsproblem) -> Auftragsantwort:
"""Nimmt an, prueft, gibt sofort zurueck.
Der Typ 'Produktionsproblem' im Parameter ist der ganze Trick: FastAPI
validiert die Anfrage damit gegen das Domaenenmodell und antwortet bei
Unsinn mit 422, ohne dass hier eine Zeile Pruefcode steht.
"""
auftrags_id = str(uuid.uuid4())
AUFTRAEGE[auftrags_id] = Auftragsantwort(
id=auftrags_id, stand=Auftragsstand.WARTET, eingegangen=time.time())
POOL.submit(rechne, auftrags_id, problem)
return AUFTRAEGE[auftrags_id]
@app.get("/plaene/{auftrags_id}", response_model=Auftragsantwort)
def auftrag_abholen(auftrags_id: str) -> Auftragsantwort:
if auftrags_id not in AUFTRAEGE:
raise HTTPException(status_code=404, detail="Unbekannter Auftrag")
return AUFTRAEGE[auftrags_id]
@app.get("/gesundheit")
def gesundheit() -> dict[str, object]:
"""Was ein Ueberwachungssystem abfragt - siehe Betriebsueberwachung.py."""
offen = sum(1 for a in AUFTRAEGE.values()
if a.stand in (Auftragsstand.WARTET, Auftragsstand.LAEUFT))
return {"zustand": "bereit", "auftraege_gesamt": len(AUFTRAEGE),
"offen": offen, "arbeiter": ARBEITER}
# --- Selbsttest ohne Server -------------------------------------------------
SCHREINEREI = {
"produkte": [
{"name": "Tisch", "deckungsbeitrag": 240.0,
"verbrauch": {"Montagestunden": 3.0, "Plattenmaterial": 6.0}},
{"name": "Stuhl", "deckungsbeitrag": 60.0,
"verbrauch": {"Montagestunden": 1.0, "Plattenmaterial": 1.0}},
],
"kapazitaeten": {"Montagestunden": 150.0, "Plattenmaterial": 240.0},
}
DOCKERFILE = """\
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY or_kern.py Optimierungsdienst.py .
# Ein Arbeiter je CPU-Kern - der Solver rechnet selbst schon parallel,
# mehr Prozesse machen ihn nicht schneller, sondern langsamer.
CMD ["uvicorn", "Optimierungsdienst:app", "--host", "0.0.0.0", "--port", "8000"]
"""
def warte_auf_ergebnis(klient, auftrags_id: str, grenze: float = 30.0) -> dict:
ende = time.time() + grenze
while time.time() < ende:
antwort = klient.get(f"/plaene/{auftrags_id}").json()
if antwort["stand"] in ("fertig", "gescheitert"):
return antwort
time.sleep(0.02)
raise TimeoutError("Auftrag wurde nicht fertig")
if __name__ == "__main__":
from fastapi.testclient import TestClient
print("=" * 80)
print(" DAS MODELL ALS DIENST")
print("=" * 80)
with TestClient(app) as klient:
# 1. Gueltiger Auftrag
print("\n1. Auftrag einreichen")
angenommen = klient.post("/plaene", json=SCHREINEREI)
auftrags_id = angenommen.json()["id"]
print(f" POST /plaene -> {angenommen.status_code} "
f"{'Accepted' if angenommen.status_code == 202 else ''}")
print(f" Auftragsnummer -> UUID, {len(auftrags_id)} Zeichen")
# Der entscheidende Punkt, und er ist pruefbar: Die Antwort ist da,
# BEVOR es eine Loesung gibt. Genau dafuer ist 202 gedacht.
print(f" Loesung schon dabei? -> "
f"{'ja' if angenommen.json()['loesung'] else 'nein - genau so soll es sein'}")
fertig = warte_auf_ergebnis(klient, auftrags_id)
loesung = fertig["loesung"]
print("\n2. Ergebnis abholen")
print(f" Stand -> {fertig['stand']}")
print(f" Solverstatus -> {loesung['status']}")
print(f" Zielwert -> {loesung['zielwert']:,.2f} EUR")
print(f" Mengen -> " + ", ".join(
f"{name} {wert:.0f}" for name, wert in loesung["werte"].items()))
print(f" Schattenpreise -> " + ", ".join(
f"{name} {wert:.0f}" for name, wert in loesung["schattenpreise"].items()))
print(f" Abnahmepruefung -> "
f"{'bestanden' if not fertig['beanstandungen'] else fertig['beanstandungen']}")
# 2. Unsinnige Anfrage - muss abgewiesen werden, BEVOR gerechnet wird
print("\n3. Unsinnige Anfragen (der Solver startet gar nicht erst)")
for beschreibung, aenderung in [
("Kapazitaet 0", {"kapazitaeten": {"Montagestunden": 0.0}}),
("keine Produkte", {"produkte": []}),
("Ressource ohne Kapazitaet",
{"produkte": [{"name": "Regal", "deckungsbeitrag": 130.0,
"verbrauch": {"Lackieren": 2.0}}]})]:
anfrage = {**SCHREINEREI, **aenderung}
antwort = klient.post("/plaene", json=anfrage)
print(f" {beschreibung:<24} -> {antwort.status_code} "
f"{'Unprocessable Content' if antwort.status_code == 422 else ''}")
print("\n4. Unbekannter Auftrag")
print(f" GET /plaene/gibtsnicht -> "
f"{klient.get('/plaene/gibtsnicht').status_code} Not Found")
print("\n5. Gesundheitsabfrage")
print(f" GET /gesundheit -> {klient.get('/gesundheit').json()}")
# --- Warum ein Thread je Auftrag genuegt -----------------------------
print("\n" + "-" * 80)
print("6. Warum Threads hier reichen (und wann nicht)\n")
from concurrent.futures import ThreadPoolExecutor as Pool
# Wichtig: eine Rechnung mit ECHTER Last. Ein Modell, das in zehn
# Millisekunden fertig ist, misst nur den Aufwand fuers Threadstarten -
# der erste Entwurf dieser Messung ist genau daran gescheitert und zeigte
# eine Verlangsamung, wo in Wirklichkeit eine Beschleunigung steht.
def eine_rechnung() -> None:
import numpy as np
from ortools.linear_solver import pywraplp
rng = np.random.default_rng(3)
n = 260
solver = pywraplp.Solver.CreateSolver("SCIP")
x = [solver.IntVar(0, 1, f"x{i}") for i in range(n)]
gewicht = rng.integers(10, 60, n)
wert = rng.integers(10, 60, n)
solver.Add(sum(int(gewicht[i]) * x[i] for i in range(n))
<= int(gewicht.sum() * 0.5))
for _ in range(30):
auswahl = rng.choice(n, 40, replace=False)
solver.Add(sum(x[int(i)] for i in auswahl) <= 12)
solver.Maximize(sum(int(wert[i]) * x[i] for i in range(n)))
solver.Solve()
t0 = time.perf_counter()
for _ in range(4):
eine_rechnung()
seriell = time.perf_counter() - t0
t0 = time.perf_counter()
with Pool(max_workers=4) as p:
list(p.map(lambda _: eine_rechnung(), range(4)))
parallel = time.perf_counter() - t0
print(f" vier Rechnungen nacheinander -> {seriell:6.2f} s")
print(f" vier Rechnungen in Threads -> {parallel:6.2f} s "
f"(Faktor {seriell / parallel:.1f})")
print()
if parallel < seriell * 0.75:
print(" Die Laeufe ueberlappen sich. ortools rechnet in C++ und gibt")
print(" den GIL waehrend Solve() frei - deshalb genuegt hier ein")
print(" Threadpool, es braucht keine eigenen Prozesse.")
else:
print(" Keine Ueberlappung - hier waeren Prozesse noetig.")
print()
print(" ACHTUNG, das gilt nicht allgemein: Eine in reinem Python")
print(" geschriebene Heuristik (Kapitel Metaheuristiken) haelt den GIL")
print(" die ganze Zeit. Fuer sie braucht derselbe Dienst einen")
print(" ProcessPoolExecutor statt eines Threadpools.")
print("\n" + "=" * 80)
print(" WAS SICH IM ECHTEN BETRIEB AENDERT")
print("=" * 80)
print("Dieses Beispiel haelt das Auftragsbuch im Speicher und rechnet in")
print("Threads desselben Prozesses. Das reicht zum Zeigen und fuer einen")
print("einzelnen Rechner - nicht darueber hinaus:")
print()
print(" * NEUSTART LOESCHT ALLES. Auftragsbuch in eine Datenbank oder eine")
print(" Warteschlange (Redis, RabbitMQ), nicht in ein dict.")
print(" * ZWEI INSTANZEN KENNEN EINANDER NICHT. Sobald der Dienst mehr als")
print(" einmal laeuft, muss die Warteschlange ausserhalb liegen -")
print(" typischerweise Celery mit Redis als Vermittler.")
print(" * DIE WAHL THREADS/PROZESSE HAENGT AM SOLVER - siehe die Messung")
print(" unter Punkt 6. Sie gehoert gemessen, nicht angenommen.")
print(" * OHNE ZEITLIMIT KEIN DIENST. Jede Instanz bekommt eines, und der")
print(" Aufrufer erfaehrt im Status, ob es gegriffen hat.")
print()
print("Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:")
print()
for zeile in DOCKERFILE.splitlines():
print(f" {zeile}")
print()
print("(Es wird hier nicht gebaut - das Buch setzt keine laufende")
print(" Docker-Installation voraus.)")
print("=" * 80)

View file

@ -0,0 +1,230 @@
#!/usr/bin/env python3
# Predict_then_Optimize.py
"""
Kapitel Prognose: Die bessere Prognose trifft die schlechtere Entscheidung.
Eine Baeckerei muss jeden Abend entscheiden, wie viel sie fuer den naechsten Tag
ansetzt. Zu wenig kostet die Marge des entgangenen Verkaufs, zu viel kostet den
Einkaufspreis der Retoure. Das ist das Newsvendor-Problem aus dem Kapitel
Unsicherheit - nur dass die Nachfrage diesmal nicht aus einer Verteilung kommt,
sondern PROGNOSTIZIERT werden muss: aus Wochentag, Temperatur und Aktionstagen.
Damit zerfaellt die Aufgabe in zwei Schritte, und genau an der Naht entsteht der
Fehler, um den es hier geht:
PREDICT ein Modell schaetzt die Nachfrage
OPTIMIZE daraus wird eine Bestellmenge
Der Prognostiker optimiert seinen Modellfehler, meist den MSE. Der Planer traegt
die Kosten. Beide messen etwas anderes - und die beiden Masse widersprechen
einander. Das Programm zeigt:
1. Vier Verfahren, verglichen nach MSE UND nach Entscheidungskosten. Das
Verfahren mit dem BESTEN MSE hat die HOECHSTEN Kosten.
2. Warum ein pauschaler Sicherheitszuschlag zu kurz greift - die Streuung der
Nachfrage haengt selbst von den Merkmalen ab.
3. Eine Messfalle, in die der Autor dieses Programms zuerst selbst getappt
ist: Bei kurzen Testzeitraeumen ist der MSE-Vergleich nicht stabil.
Benoetigt: numpy, scipy, scikit-learn
"""
from __future__ import annotations
import numpy as np
from scipy.stats import norm
from sklearn.linear_model import LinearRegression, QuantileRegressor
VERKAUFSPREIS = 9.0
EINKAUFSPREIS = 3.0
KOSTEN_FEHLMENGE = VERKAUFSPREIS - EINKAUFSPREIS # entgangene Marge: 6 EUR
KOSTEN_UEBERHANG = EINKAUFSPREIS # Retoure: 3 EUR
KRITISCHES_VERHAELTNIS = KOSTEN_FEHLMENGE / (KOSTEN_FEHLMENGE + KOSTEN_UEBERHANG)
TAGE = 5000 # Simulation, siehe Hinweis unten
TRAINING = 1000
SAAT = 11
def erzeuge_daten(tage: int = TAGE, saat: int = SAAT):
"""Taegliche Nachfrage mit Wochentag, Temperatur und Aktionstagen.
Die entscheidende Eigenschaft steckt in 'streuung': An Aktionstagen ist die
Nachfrage nicht nur hoeher, sondern auch viel UNSICHERER. Solche
heteroskedastischen Daten sind der Normalfall - und der Grund, warum ein
pauschaler Sicherheitszuschlag nicht genuegt.
"""
rng = np.random.default_rng(saat)
wochentag = np.arange(tage) % 7
temperatur = (12 + 10 * np.sin(2 * np.pi * np.arange(tage) / 365)
+ rng.normal(0, 3, tage))
aktion = (rng.random(tage) < 0.15).astype(float)
merkmale = np.column_stack([np.eye(7)[wochentag][:, 1:], temperatur, aktion])
erwartung = (120
+ np.eye(7)[wochentag] @ np.array([0, 10, 12, 14, 18, 35, -40])
+ 1.8 * temperatur + 45 * aktion)
streuung = 8 + 22 * aktion
nachfrage = np.maximum(0.0, erwartung + rng.normal(0, 1, tage) * streuung)
return merkmale, nachfrage, aktion
def tageskosten(bestellung: np.ndarray, nachfrage: np.ndarray) -> float:
"""Die Zahl, auf die es ankommt - und die kein Prognosemass kennt."""
fehlmenge = np.maximum(0.0, nachfrage - bestellung)
ueberhang = np.maximum(0.0, bestellung - nachfrage)
return float((KOSTEN_FEHLMENGE * fehlmenge
+ KOSTEN_UEBERHANG * ueberhang).mean())
if __name__ == "__main__":
merkmale, nachfrage, aktion = erzeuge_daten()
lernen = slice(0, TRAINING)
pruefen = slice(TRAINING, TAGE)
print("=" * 84)
print(" DIE BESSERE PROGNOSE TRIFFT DIE SCHLECHTERE ENTSCHEIDUNG")
print("=" * 84)
print(f"Verkaufspreis {VERKAUFSPREIS:.0f} EUR, Einkauf {EINKAUFSPREIS:.0f} EUR.")
print(f"Fehlmenge kostet {KOSTEN_FEHLMENGE:.0f} EUR, Ueberhang "
f"{KOSTEN_UEBERHANG:.0f} EUR je Stueck.")
print(f"Kritisches Verhaeltnis: {KRITISCHES_VERHAELTNIS:.3f} - der Planer sollte "
f"also das")
print(f"{KRITISCHES_VERHAELTNIS:.1%}-Quantil der Nachfrage bestellen, nicht ihren "
f"Erwartungswert.")
print(f"\nTraining: Tag 1 bis {TRAINING}. Bewertung: die restlichen "
f"{TAGE - TRAINING} Tage.\n")
# --- Die vier Verfahren ----------------------------------------------
kleinste_quadrate = LinearRegression().fit(merkmale[lernen], nachfrage[lernen])
punktprognose = kleinste_quadrate.predict(merkmale[pruefen])
restfehler = nachfrage[lernen] - kleinste_quadrate.predict(merkmale[lernen])
pauschalzuschlag = norm.ppf(KRITISCHES_VERHAELTNIS) * restfehler.std()
# Ein Zuschlag, der nicht aus der Normalverteilung kommt, sondern direkt
# auf den Trainingsdaten die Kosten minimiert.
kandidaten = np.linspace(-10.0, 30.0, 401)
trainingsprognose = kleinste_quadrate.predict(merkmale[lernen])
kostenzuschlag = float(kandidaten[np.argmin(
[tageskosten(trainingsprognose + z, nachfrage[lernen]) for z in kandidaten])])
# Und das Verfahren, das von vornherein das richtige Quantil schaetzt.
quantilmodell = QuantileRegressor(quantile=KRITISCHES_VERHAELTNIS,
alpha=0.0, solver="highs")
quantilmodell.fit(merkmale[lernen], nachfrage[lernen])
quantilprognose = quantilmodell.predict(merkmale[pruefen])
verfahren = [
("bestelle die Punktprognose", punktprognose, punktprognose),
("+ Zuschlag aus der Normalverteilung",
punktprognose, punktprognose + pauschalzuschlag),
("+ Zuschlag auf Kosten trainiert",
punktprognose, punktprognose + kostenzuschlag),
("Quantilregression aufs kritische Quantil",
quantilprognose, quantilprognose),
]
print(f" {'Verfahren':<42} {'MSE':>9} {'Kosten/Tag':>12} {'gegen Zeile 1':>14}")
print(" " + "-" * 80)
ergebnisse = {}
for name, prognose, bestellung in verfahren:
mse = float(((prognose - nachfrage[pruefen]) ** 2).mean())
kosten = tageskosten(bestellung, nachfrage[pruefen])
ergebnisse[name] = (mse, kosten)
basis = ergebnisse[verfahren[0][0]][1]
vergleich = "" if name == verfahren[0][0] else f"{(kosten - basis) / basis:+13.1%}"
print(f" {name:<42} {mse:>9.1f} {kosten:>10.2f} EUR {vergleich:>14}")
bester_mse = min(ergebnisse, key=lambda k: ergebnisse[k][0])
beste_kosten = min(ergebnisse, key=lambda k: ergebnisse[k][1])
print(f"\n bester MSE: {bester_mse}")
print(f" beste Kosten: {beste_kosten}")
print(f"\n Das Verfahren mit dem besten MSE hat die HOECHSTEN Kosten, und das")
print(f" Verfahren mit den besten Kosten hat einen um "
f"{(ergebnisse[beste_kosten][0] / ergebnisse[bester_mse][0] - 1):.0%} SCHLECHTEREN MSE.")
print(" Wer Prognosemodelle nach MSE auswaehlt, waehlt hier das falsche.")
# --- Warum der pauschale Zuschlag zu kurz greift ---------------------
print("\n" + "-" * 84)
print("Warum ein pauschaler Zuschlag nicht genuegt\n")
ist_aktion = aktion[pruefen] > 0.5
print(f" {'Verfahren':<42} {'normale Tage':>14} {'Aktionstage':>14}")
print(" " + "-" * 74)
for name, _, bestellung in verfahren:
normal = tageskosten(bestellung[~ist_aktion], nachfrage[pruefen][~ist_aktion])
aktionstag = tageskosten(bestellung[ist_aktion], nachfrage[pruefen][ist_aktion])
print(f" {name:<42} {normal:>10.2f} EUR {aktionstag:>10.2f} EUR")
# Nachgerechnet statt behauptet: Welcher Zuschlag waere je Tagesart richtig?
aktion_training = aktion[lernen] > 0.5
z = norm.ppf(KRITISCHES_VERHAELTNIS)
richtig_normal = z * restfehler[~aktion_training].std()
richtig_aktion = z * restfehler[aktion_training].std()
print(f"\n Der pauschale Zuschlag betraegt {pauschalzuschlag:.1f} Stueck. Aus den")
print(f" Trainingsresten getrennt nach Tagesart waere richtig:")
print(f" normale Tage : {richtig_normal:5.1f} Stueck")
print(f" Aktionstage : {richtig_aktion:5.1f} Stueck")
print(f" Ein Zuschlag fuer alle Tage kann nur einen Mittelweg treffen - hier")
print(f" ist er an normalen Tagen {pauschalzuschlag / richtig_normal:.1f}-mal zu gross und an")
print(f" Aktionstagen nur {pauschalzuschlag / richtig_aktion:.0%} dessen, was noetig waere.")
print(f"\n Die Quantilregression schaetzt das {KRITISCHES_VERHAELTNIS:.1%}-Quantil "
f"direkt aus den")
print(f" Merkmalen und darf deshalb an verschiedenen Tagen verschieden weit")
print(f" ueber dem Erwartungswert liegen. Genau das ist der Unterschied")
print(f" zwischen 'ein Modell und danach eine Formel' und 'ein Modell, das")
print(f" weiss, wofuer es gebraucht wird'.")
# --- Die Messfalle ---------------------------------------------------
print("\n" + "-" * 84)
print("Eine Messfalle, in die der Autor zuerst selbst getappt ist\n")
print(" Der erste Entwurf dieses Programms bewertete auf 230 Testtagen - ein")
print(" realistischer Zeitraum. Dort hatte die Quantilregression den BESSEREN")
print(" MSE, und die ganze Aussage des Kapitels stand auf dem Kopf.")
print("\n Wie oft das passiert, laesst sich ausmessen:\n")
rng = np.random.default_rng(0)
print(f" {'Testfenster':>14} {'QR sieht MSE-besser aus':>26}")
print(" " + "-" * 42)
for fenster in (180, 365, 730, 2000):
treffer = 0
versuche = 400
for _ in range(versuche):
start = int(rng.integers(TRAINING, TAGE - fenster))
ausschnitt = slice(start, start + fenster)
mse_punkt = ((kleinste_quadrate.predict(merkmale[ausschnitt])
- nachfrage[ausschnitt]) ** 2).mean()
mse_quantil = ((quantilmodell.predict(merkmale[ausschnitt])
- nachfrage[ausschnitt]) ** 2).mean()
treffer += mse_quantil < mse_punkt
print(f" {fenster:>10} Tage {treffer / versuche:>24.1%}")
print("\n Bei einem halben Jahr Testdaten sieht das schlechtere Modell in gut")
print(" jedem zehnten Fall besser aus. Das ist keine grosse Zahl - aber wer")
print(" EINMAL misst, hat genau eine Ziehung aus dieser Verteilung.")
print("\n Die Lehre ist nicht 'nimm 4.000 Testtage' - die hat niemand. Sie")
print(" lautet: Ein Kennzahlenvergleich ohne Angabe seiner Streuung ist keine")
print(" Aussage. Bei kurzen Zeitraeumen gehoert eine Kreuzvalidierung dazu.")
print("\n" + "=" * 84)
print(" WAS MAN DARAUS MITNIMMT")
print("=" * 84)
print("Der Prognostiker optimiert den MSE, der Planer traegt die Kosten - und")
print("die beiden Masse zeigen hier in verschiedene Richtungen. Drei Saetze:")
print()
print(" 1. Sagen Sie nicht den Erwartungswert vorher, sondern die Groesse, die")
print(" in die Entscheidung eingeht. Beim Newsvendor ist das das kritische")
print(" Quantil - und das kann man direkt schaetzen.")
print(" 2. Bewerten Sie Prognosemodelle an den ENTSCHEIDUNGSKOSTEN. Die sind")
print(" in Euro und damit vergleichbar; ein MSE ist es nicht.")
print(" 3. Ein pauschaler Sicherheitszuschlag ist besser als nichts und")
print(" schlechter als ein Modell, das die Unsicherheit selbst aus den")
print(" Merkmalen liest.")
print()
print("Der naechste Schritt - Prognosemodelle so zu trainieren, dass sie die")
print("Entscheidungskosten direkt minimieren (Smart Predict-then-Optimize,")
print("differenzierbare Optimierungsschichten) - ist Forschungsstand und")
print("erfordert Bibliotheken wie cvxpylayers. Die dritte Zeile der Tabelle")
print("oben ist seine einfachste denkbare Form: ein einziger Parameter, auf")
print("Kosten statt auf Fehler trainiert.")
print("=" * 84)

View file

@ -0,0 +1,69 @@
#!/usr/bin/env python3
# Propagation_Demo.py
"""
Kapitel CP-SAT: Propagation sichtbar machen.
Vergleicht die Zahl der geprueften Kombinationen bei roher Aufzaehlung
mit der Zahl der Verzweigungen, die CP-SAT tatsaechlich braucht.
"""
import itertools
from ortools.sat.python import cp_model
def rohe_aufzaehlung(n: int, obergrenze: int) -> tuple[int, int]:
"""Zaehlt ALLE Kombinationen und prueft jede einzeln (Brute Force)."""
geprueft = 0
loesungen = 0
for kombination in itertools.product(range(1, obergrenze + 1), repeat=n):
geprueft += 1
if len(set(kombination)) == n and sum(kombination) == 3 * n:
loesungen += 1
return geprueft, loesungen
def mit_cp_sat(n: int, obergrenze: int) -> tuple[int, int, float]:
"""Dasselbe Problem deklarativ: alle verschieden, Summe = 3n."""
modell = cp_model.CpModel()
x = [modell.NewIntVar(1, obergrenze, f"x{i}") for i in range(n)]
modell.AddAllDifferent(x) # globales Constraint
modell.Add(sum(x) == 3 * n)
loeser = cp_model.CpSolver()
loeser.parameters.enumerate_all_solutions = True
class Zaehler(cp_model.CpSolverSolutionCallback):
def __init__(self):
super().__init__()
self.anzahl = 0
def on_solution_callback(self):
self.anzahl += 1
zaehler = Zaehler()
loeser.Solve(modell, zaehler)
return zaehler.anzahl, loeser.NumBranches(), loeser.WallTime()
if __name__ == "__main__":
print("=" * 84)
print(" PROPAGATION: WIE VIEL ARBEIT SPART SICH DER SOLVER?")
print(" Aufgabe: n Zahlen aus 1..G, alle verschieden, Summe = 3n")
print("=" * 84)
print(f"{'n':>3} {'G':>4} | {'Brute Force':>14} | {'CP-SAT':>10} | "
f"{'Ersparnis':>11} | {'Loesungen':>10}")
print("-" * 84)
for n, grenze in [(4, 8), (5, 10), (6, 12), (7, 14)]:
kombis, treffer_bf = rohe_aufzaehlung(n, grenze)
treffer_cp, verzweigungen, dauer = mit_cp_sat(n, grenze)
assert treffer_bf == treffer_cp, "Beide Verfahren muessen gleich viele finden!"
ersparnis = kombis / max(verzweigungen, 1)
print(f"{n:>3} {grenze:>4} | {kombis:>14,} | {verzweigungen:>10,} | "
f"{ersparnis:>10.0f}x | {treffer_cp:>10,}")
print("-" * 84)
print("'Brute Force' = alle Kombinationen. 'CP-SAT' = tatsaechliche Verzweigungen.")
print("Der Rest wurde durch Propagation ausgeschlossen, ohne ihn anzusehen.")
print("=" * 84)

View file

@ -0,0 +1,55 @@
#!/usr/bin/env python3
# QP_Grundlagen.py
"""
Kapitel QP/NLP: Die drei Faelle aus der Konvexitaets-Tabelle (Abschnitt
'Das quadratische Programm') an
einem Mini-QP demonstriert: P positiv definit, P (singulaer) semidefinit,
P mit negativem Eigenwert.
"""
import numpy as np
import cvxpy as cp
def loese_qp(P, q, name):
n = len(q)
w = cp.Variable(n)
ziel = cp.Minimize(0.5 * cp.quad_form(w, P) + q @ w)
bedingungen = [cp.sum(w) == 1, w >= 0]
problem = cp.Problem(ziel, bedingungen)
print(f"\n--- {name} ---")
eigenwerte = np.linalg.eigvalsh(P)
print(f"Eigenwerte von P: {np.round(eigenwerte, 4)}")
print(f"DCP-konvex (CVXPY-Pruefung)? {problem.is_dcp()}")
if not problem.is_dcp():
print("-> CVXPY lehnt das Problem ab, BEVOR ueberhaupt ein Solver laeuft.")
return
problem.solve()
print(f"Status: {problem.status}")
print(f"w* = {np.round(w.value, 4)}")
print(f"Zielwert = {problem.value:.6f}")
if __name__ == "__main__":
q = np.zeros(2)
# Fall 1: P positiv definit -> eindeutiges Minimum
P_definit = np.array([[2.0, 0.5], [0.5, 1.0]])
loese_qp(P_definit, q, "P positiv definit")
# Fall 2: P singulaer/semidefinit (zwei "identische" Assets) -> unendlich viele Minima
P_semidefinit = np.array([[1.0, 1.0], [1.0, 1.0]])
loese_qp(P_semidefinit, q, "P positiv semidefinit (singulaer)")
# Fall 3: P mit negativem Eigenwert -> nicht konvex
P_indefinit = np.array([[1.0, 2.0], [2.0, 1.0]])
loese_qp(P_indefinit, q, "P indefinit (negativer Eigenwert)")
print("\n--- Nachweis: 'unendlich viele Minima' im semidefiniten Fall ---")
for punkt in [np.array([1.0, 0.0]), np.array([0.0, 1.0]), np.array([0.3, 0.7])]:
wert = 0.5 * punkt @ P_semidefinit @ punkt
print(f" w = {punkt} -> Zielwert = {wert:.4f} (identisch, obwohl w verschieden)")

View file

@ -0,0 +1,305 @@
#!/usr/bin/env python3
# QuantitativeTradingEngine.py
"""
Kapitel Handelsmaschine: Vollstaendige quantitative Handelsmaschine mit
Walk-Forward-Backtest und OR-Optimierungsschicht.
Eigenschaften:
* Rebalancing-Termine sind garantiert Handelstage; die Zahl der
tatsaechlich ausgefuehrten Umschichtungen wird ausgewiesen
* Spaltenreihenfolge erzwungen
* Einheiten konsistent (taegliche Groessen im Modell)
* Szenario-Nebenbedingungen vektorisiert
* Lookahead-Selbsttest eingebaut
* Zusaetzliche Kennzahlen: Calmar, Trefferquote, Turnover, Kostenanteil
"""
import os
import cvxpy as cp
import numpy as np
import pandas as pd
import yfinance as yf
from sklearn.covariance import LedoitWolf
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
os.makedirs(OUTPUT_DIR, exist_ok=True)
HANDELSTAGE = 252
class QuantitativeHandelsmaschine:
"""Walk-Forward-Backtest mit CVaR-Optimierung und Transaktionskosten."""
def __init__(self, universum, benchmark="SPY", jahre=5,
startkapital=100_000.0, gebuehrensatz=0.0015,
max_gewicht=0.20, alpha=0.95, risikoaversion=1.2,
warmup=HANDELSTAGE):
self.universum = list(universum)
self.benchmark = benchmark
self.jahre = jahre
self.startkapital = startkapital
self.gebuehrensatz = gebuehrensatz
self.max_gewicht = max_gewicht
self.alpha = alpha
self.risikoaversion = risikoaversion
self.warmup = warmup
self.kurse = None
self.benchmark_kurse = None
self.renditen = None
self.rebalancing_termine = None
# -- 1. Datenpipeline ------------------------------------------------
def lade_daten(self):
ende = pd.Timestamp.today().normalize()
start = ende - pd.DateOffset(years=self.jahre)
alle = self.universum + [self.benchmark]
print(f"Lade {len(self.universum)} Titel + Benchmark ({self.benchmark}) "
f"ueber {self.jahre} Jahre ...")
roh = yf.download(alle, start=start, end=ende, auto_adjust=True, progress=False)
if roh.empty:
raise SystemExit("Download fehlgeschlagen (Netz/Ticker/Rate-Limit pruefen).")
if isinstance(roh.columns, pd.MultiIndex):
daten = roh["Close"][alle].dropna() # erzwingt eigene Spaltenreihenfolge
else:
daten = roh[["Close"]].dropna()
daten.columns = alle
assert list(daten.columns) == alle, "Spaltenreihenfolge weicht ab!"
self.kurse = daten[self.universum]
self.benchmark_kurse = daten[self.benchmark]
self.renditen = self.kurse.pct_change().dropna()
# --- erster HANDELStag je Monat, nicht Kalendermonatsanfang -----
monat = self.kurse.index.to_period("M")
self.rebalancing_termine = self.kurse.index[~monat.duplicated()]
assert self.rebalancing_termine.isin(self.kurse.index).all(), \
"Rebalancing-Termine sind keine Handelstage!"
print(f" {len(self.kurse)} Handelstage, "
f"{len(self.rebalancing_termine)} Rebalancing-Termine "
f"(alle sind Handelstage)")
# -- 2. Signal --------------------------------------------------------
def alpha_signal(self, stichtag, rueckblick=HANDELSTAGE):
"""
12-1-Momentum: Kursentwicklung der letzten 12 Monate unter Auslassung
des letzten Monats (kurzfristige Umkehr herausrechnen).
Rueckgabe: TAEGLICHE Renditeerwartung.
"""
historie = self.kurse.loc[:stichtag]
if len(historie) < rueckblick:
return np.zeros(len(self.universum))
p_alt = historie.iloc[-rueckblick]
p_neu = historie.iloc[-21] # vor ca. einem Monat
momentum = (p_neu / p_alt - 1.0).values
# Z-Score, damit das Signal skalenfrei wird
streuung = momentum.std()
score = (momentum - momentum.mean()) / (streuung + 1e-9)
# In eine taegliche Renditeerwartung uebersetzen:
# Basis 4 % p.a. plus 6 % p.a. je Standardabweichung Momentum.
mu_jaehrlich = np.maximum(0.04 + 0.06 * score, 0.0)
return mu_jaehrlich / HANDELSTAGE
# -- 3./4. Risikomodell und Optimierung -------------------------------
def optimiere(self, stichtag, w_alt, rueckblick=HANDELSTAGE):
historie = self.renditen.loc[:stichtag].iloc[-rueckblick:]
if len(historie) < 100:
return w_alt, False
R = historie.values
S, N = R.shape
mu_taeglich = self.alpha_signal(stichtag, rueckblick)
w = cp.Variable(N, nonneg=True)
gamma = cp.Variable()
u = cp.Variable(S, nonneg=True)
cvar = gamma + (1.0 / (S * (1.0 - self.alpha))) * cp.sum(u)
kosten = self.gebuehrensatz * cp.norm1(w - w_alt)
ziel = cp.Maximize(mu_taeglich @ w - self.risikoaversion * cvar - kosten)
bedingungen = [cp.sum(w) == 1,
w <= self.max_gewicht,
u >= -(R @ w) - gamma] # vektorisiert
problem = cp.Problem(ziel, bedingungen)
try:
problem.solve(solver=cp.CLARABEL)
except cp.error.SolverError:
return w_alt, False
if problem.status not in ("optimal", "optimal_inaccurate") or w.value is None:
return w_alt, False
gewichte = np.maximum(w.value, 0.0)
return gewichte / gewichte.sum(), True
# -- 5. Backtest -------------------------------------------------------
def backtest(self):
print("Starte Walk-Forward-Backtest ...")
N = len(self.universum)
gewichte = np.ones(N) / N
werte = [self.startkapital]
datumsliste = [self.renditen.index[self.warmup]]
gewichtshistorie = []
geplant = versucht = ausgefuehrt = 0
kosten_gesamt = 0.0
turnover_gesamt = 0.0
for t in range(self.warmup, len(self.renditen) - 1):
heute = self.renditen.index[t]
morgen = self.renditen.index[t + 1]
ist_termin = heute in self.rebalancing_termine or t == self.warmup
if ist_termin:
geplant += 1
versucht += 1
neue_gewichte, erfolg = self.optimiere(heute, gewichte)
if erfolg:
ausgefuehrt += 1
turnover = float(np.abs(neue_gewichte - gewichte).sum())
gebuehr = werte[-1] * turnover * self.gebuehrensatz
werte[-1] -= gebuehr
kosten_gesamt += gebuehr
turnover_gesamt += turnover
gewichte = neue_gewichte
gewichtshistorie.append((heute, gewichte.copy()))
# Rendite von MORGEN auf die HEUTE festgelegten Gewichte anwenden
tagesrenditen = self.renditen.iloc[t + 1].values
portfoliorendite = float(gewichte @ tagesrenditen)
werte.append(werte[-1] * (1.0 + portfoliorendite))
datumsliste.append(morgen)
# Gewichte driften mit den Kursen weiter
gedriftet = gewichte * (1.0 + tagesrenditen)
gewichte = gedriftet / gedriftet.sum()
print(f" Rebalancing: {geplant} geplant, {ausgefuehrt} ausgefuehrt "
f"({ausgefuehrt/max(geplant,1)*100:.0f} %)")
if ausgefuehrt < geplant:
print(f" WARNUNG: {geplant - ausgefuehrt} Optimierungen sind "
f"fehlgeschlagen - Ursache pruefen!")
print(f" Kumulierter Turnover: {turnover_gesamt*100:.0f} % | "
f"Transaktionskosten insgesamt: {kosten_gesamt:,.2f} EUR")
ergebnis = pd.DataFrame({"Portfolio": werte}, index=datumsliste)
benchmark = self.benchmark_kurse.loc[datumsliste]
ergebnis["Benchmark"] = benchmark / benchmark.iloc[0] * self.startkapital
return ergebnis, gewichtshistorie, kosten_gesamt
# -- 6. Auswertung -----------------------------------------------------
@staticmethod
def kennzahlen(reihe, risikofrei=0.02):
renditen = reihe.pct_change().dropna()
jahre = (reihe.index[-1] - reihe.index[0]).days / 365.25
cagr = (reihe.iloc[-1] / reihe.iloc[0]) ** (1 / jahre) - 1
vola = renditen.std() * np.sqrt(HANDELSTAGE)
sharpe = (cagr - risikofrei) / vola if vola > 0 else np.nan
drawdown = ((reihe - reihe.cummax()) / reihe.cummax()).min()
calmar = cagr / abs(drawdown) if drawdown < 0 else np.nan
trefferquote = float((renditen > 0).mean())
return {"CAGR": cagr, "Volatilitaet": vola, "Sharpe": sharpe,
"Max Drawdown": drawdown, "Calmar": calmar,
"Trefferquote": trefferquote, "Jahre": jahre}
def bericht(self, ergebnis, kosten_gesamt):
k_port = self.kennzahlen(ergebnis["Portfolio"])
k_bench = self.kennzahlen(ergebnis["Benchmark"])
print("\n" + "=" * 84)
print(" LEISTUNGSREPORT DER QUANTITATIVEN ENGINE")
print("=" * 84)
print(f"Zeitraum: {ergebnis.index[0]:%Y-%m-%d} bis {ergebnis.index[-1]:%Y-%m-%d} "
f"({k_port['Jahre']:.1f} Jahre) | Startkapital "
f"{self.startkapital:,.0f} EUR\n")
zeilen = [
("Endkapital", f"{ergebnis['Portfolio'].iloc[-1]:,.0f} EUR",
f"{ergebnis['Benchmark'].iloc[-1]:,.0f} EUR"),
("CAGR (Jahresrendite)", f"{k_port['CAGR']*100:6.2f} %",
f"{k_bench['CAGR']*100:6.2f} %"),
("Volatilitaet p.a.", f"{k_port['Volatilitaet']*100:6.2f} %",
f"{k_bench['Volatilitaet']*100:6.2f} %"),
("Sharpe Ratio (rf=2 %)", f"{k_port['Sharpe']:6.2f}",
f"{k_bench['Sharpe']:6.2f}"),
("Maximum Drawdown", f"{k_port['Max Drawdown']*100:6.2f} %",
f"{k_bench['Max Drawdown']*100:6.2f} %"),
("Calmar Ratio", f"{k_port['Calmar']:6.2f}", f"{k_bench['Calmar']:6.2f}"),
("Trefferquote (Tage)", f"{k_port['Trefferquote']*100:6.1f} %",
f"{k_bench['Trefferquote']*100:6.1f} %"),
]
print(pd.DataFrame(zeilen, columns=["Kennzahl", "OR-Strategie", "Benchmark"])
.to_string(index=False))
anteil = kosten_gesamt / self.startkapital
print(f"\nTransaktionskosten: {kosten_gesamt:,.0f} EUR "
f"= {anteil*100:.2f} % des Startkapitals "
f"= {anteil/k_port['Jahre']*100:.2f} % p.a.")
print("Ohne diese Kosten waere die ausgewiesene Rendite entsprechend hoeher -")
print("genau deshalb gehoeren sie in den Backtest und nicht daneben.")
print("=" * 84)
return k_port, k_bench
def zeichne(self, ergebnis):
plt.figure(figsize=(11, 6))
plt.plot(ergebnis.index, ergebnis["Portfolio"], "b-", lw=2.2,
label="OR-Handelsmaschine")
plt.plot(ergebnis.index, ergebnis["Benchmark"], "k--", lw=1.5, alpha=0.75,
label=f"Benchmark ({self.benchmark})")
plt.title("Walk-Forward-Backtest: OR-Strategie gegen Benchmark", fontsize=12)
plt.xlabel("Datum")
plt.ylabel("Depotwert in EUR")
plt.grid(True, linestyle=":", alpha=0.6)
plt.legend(loc="upper left")
plt.tight_layout()
ziel = os.path.join(OUTPUT_DIR, "trading_engine_backtest.png")
plt.savefig(ziel, dpi=150)
print(f"Chart gespeichert unter '{ziel}'")
def lookahead_selbsttest(maschine):
"""
Prueft, dass das Signal zum Zeitpunkt t NUR Daten bis t verwendet.
Methode: kuenstlich die Zukunft veraendern und pruefen, ob sich das
Signal von heute dadurch aendert. Tut es das, liegt Lookahead vor.
"""
stichtag = maschine.kurse.index[len(maschine.kurse) // 2]
signal_original = maschine.alpha_signal(stichtag)
original_kurse = maschine.kurse.copy()
zukunft = maschine.kurse.index > stichtag
maschine.kurse.loc[zukunft] *= 3.0 # Zukunft massiv veraendern
signal_manipuliert = maschine.alpha_signal(stichtag)
maschine.kurse = original_kurse # zuruecksetzen
identisch = np.allclose(signal_original, signal_manipuliert)
print(f"Lookahead-Selbsttest: Signal bleibt bei manipulierter Zukunft "
f"{'UNVERAENDERT (gut)' if identisch else 'VERAENDERT - LOOKAHEAD-BIAS!'}")
assert identisch, "Das Signal verwendet Zukunftsdaten!"
if __name__ == "__main__":
UNIVERSUM = ["AAPL", "MSFT", "NVDA", "GOOGL",
"JNJ", "UNH", "PFE",
"JPM", "BAC", "GS",
"XOM", "CVX",
"PG", "KO", "COST"]
maschine = QuantitativeHandelsmaschine(UNIVERSUM, benchmark="SPY", jahre=5)
maschine.lade_daten()
lookahead_selbsttest(maschine)
ergebnis, gewichte, kosten = maschine.backtest()
maschine.bericht(ergebnis, kosten)
maschine.zeichne(ergebnis)

View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
# Renditen_Vergleich.py
"""
Kapitel Finanzdaten: Diskrete vs. logarithmische Renditen.
Zeigt an einem simulierten Kursverlauf, welche Eigenschaft wo gilt -
und warum das arithmetische Mittel diskreter Renditen in die Irre fuehrt.
"""
import numpy as np
import pandas as pd
if __name__ == "__main__":
kurse = np.array([100.0, 150.0, 100.0, 120.0, 90.0, 135.0])
tage = [f"t{i}" for i in range(len(kurse))]
diskret = kurse[1:] / kurse[:-1] - 1.0
logarithmisch = np.log(kurse[1:] / kurse[:-1])
print("=" * 74)
print(" DISKRETE UND LOGARITHMISCHE RENDITEN IM VERGLEICH")
print("=" * 74)
tabelle = pd.DataFrame({
"Periode": [f"{tage[i]} -> {tage[i+1]}" for i in range(len(diskret))],
"Kurs von": kurse[:-1],
"Kurs bis": kurse[1:],
"diskret R": [f"{r*100:+7.2f} %" for r in diskret],
"log r": [f"{r:+8.4f}" for r in logarithmisch],
})
print(tabelle.to_string(index=False))
# --- Zeitliche Aggregation ------------------------------------------
gesamt_wahr = kurse[-1] / kurse[0] - 1.0
summe_log = logarithmisch.sum()
aus_log_zurueck = np.exp(summe_log) - 1.0
summe_diskret = diskret.sum()
print("\n--- Aggregation ueber die Zeit ---")
print(f" Tatsaechliche Gesamtrendite: {gesamt_wahr*100:+8.2f} %")
print(f" Summe der Log-Renditen -> exp()-1: {aus_log_zurueck*100:+8.2f} % "
f"{'KORREKT' if abs(aus_log_zurueck - gesamt_wahr) < 1e-9 else 'falsch'}")
print(f" Summe der diskreten Renditen: {summe_diskret*100:+8.2f} % FALSCH")
# --- Mittelwerte ------------------------------------------------------
arithmetisch = diskret.mean()
geometrisch = np.prod(1 + diskret) ** (1 / len(diskret)) - 1
aus_log = np.exp(logarithmisch.mean()) - 1
print("\n--- Welcher Mittelwert ist der richtige? ---")
print(f" Arithmetisches Mittel (diskret): {arithmetisch*100:+8.2f} % "
f"-> ueberschaetzt")
print(f" Geometrisches Mittel: {geometrisch*100:+8.2f} % -> korrekt")
print(f" exp(Mittel der Log-Renditen) - 1: {aus_log*100:+8.2f} % "
f"-> identisch zum geometrischen")
# Probe: Endkapital mit dem jeweiligen Mittelwert hochgerechnet
n = len(diskret)
print(f"\n Probe - Startkapital 100 EUR ueber {n} Perioden:")
print(f" tatsaechlich: {kurse[-1]:8.2f} EUR")
print(f" mit arithm. Mittel hochgerechnet: {100*(1+arithmetisch)**n:8.2f} EUR")
print(f" mit geom. Mittel hochgerechnet: {100*(1+geometrisch)**n:8.2f} EUR")
print("\n--- Annualisierung (252 Handelstage) ---")
print(f" Volatilitaet aus Log-Renditen: "
f"{logarithmisch.std(ddof=1)*np.sqrt(252)*100:.2f} % p.a.")
print(" (Die Wurzel-Zeit-Regel gilt nur bei unabhaengigen Renditen -")
print(" fuer reale Maerkte ist sie eine Naeherung.)")
print("=" * 74)

View file

@ -0,0 +1,143 @@
#!/usr/bin/env python3
# Renditeschaetzung_Falle.py
"""
Kapitel Markowitz: Warum geschaetzte Renditen noch gefaehrlicher sind als geschaetzte
Kovarianzen.
Das Kapitel Finanzdaten hat gezeigt, was eine schlecht geschaetzte Kovarianzmatrix
anrichtet. Bei den erwarteten RENDITEN ist es schlimmer - aus einem einfachen
statistischen Grund: Um eine Rendite von 0,04 % pro Tag von null zu
unterscheiden, braucht man bei 1,2 % Tagesschwankung ueber tausend
Beobachtungen. Kovarianzen konvergieren dagegen deutlich schneller.
Das Experiment ist bewusst so gebaut, dass die Antwort feststeht: ALLE zwoelf
Anlagen haben exakt dieselbe wahre Rendite und dieselbe Schwankung. Ein
korrektes Ergebnis waere also Gleichgewichtung. Was der Optimierer stattdessen
tut, zeigt der Lauf.
Wiederholt wird 200 mal, damit das Ergebnis nicht von einer Zufallsstichprobe
abhaengt.
Benoetigt: numpy, cvxpy
"""
from __future__ import annotations
import numpy as np
import cvxpy as cp
ANZAHL_ANLAGEN = 12
BEOBACHTUNGEN = 250 # ein Jahr Tagesdaten
BEOBACHTUNGEN_SPAETER = 2000 # der "spaetere Verlauf"
WIEDERHOLUNGEN = 200
# Die Wahrheit, die der Optimierer nicht kennt: alle Anlagen sind gleich.
WAHRE_RENDITE = 0.0004 # 0,04 % pro Tag
WAHRE_SCHWANKUNG = 0.012 # 1,2 % pro Tag
RISIKOAVERSION = 10.0
RNG = np.random.default_rng(5)
def baue_optimierer() -> tuple[cp.Problem, cp.Variable, cp.Parameter, cp.Parameter]:
"""Baut das Markowitz-Problem EINMAL mit Parametern.
Bei 200 Wiederholungen ist das der Unterschied zwischen Sekunden und
Minuten: cp.Parameter erlaubt es, nur die Daten zu tauschen, statt den
Ausdrucksbaum jedes Mal neu zu kompilieren (siehe Kapitel Oekosystem).
"""
gewichte = cp.Variable(ANZAHL_ANLAGEN, nonneg=True)
renditen = cp.Parameter(ANZAHL_ANLAGEN)
kovarianz = cp.Parameter((ANZAHL_ANLAGEN, ANZAHL_ANLAGEN), PSD=True)
problem = cp.Problem(
cp.Maximize(renditen @ gewichte
- RISIKOAVERSION * cp.quad_form(gewichte, kovarianz)),
[cp.sum(gewichte) == 1])
return problem, gewichte, renditen, kovarianz
def erzeuge(perioden: int) -> np.ndarray:
"""Renditen, bei denen alle Anlagen identisch verteilt und unabhaengig sind."""
return RNG.normal(WAHRE_RENDITE, WAHRE_SCHWANKUNG,
(perioden, ANZAHL_ANLAGEN))
if __name__ == "__main__":
problem, gewichte, renditen, kovarianz = baue_optimierer()
im_zeitraum, spaeter, spaeter_gleich = [], [], []
groesstes_gewicht, schaetzspanne = [], []
for _ in range(WIEDERHOLUNGEN):
schaetzdaten = erzeuge(BEOBACHTUNGEN)
spaetere_daten = erzeuge(BEOBACHTUNGEN_SPAETER)
geschaetzte_rendite = schaetzdaten.mean(axis=0)
geschaetzte_kovarianz = np.cov(schaetzdaten, rowvar=False)
renditen.value = geschaetzte_rendite
# Symmetrisieren und minimal anheben: numerisches Rauschen kann die
# Matrix sonst knapp unter die PSD-Grenze druecken (Kapitel Fundament).
kovarianz.value = ((geschaetzte_kovarianz + geschaetzte_kovarianz.T) / 2
+ 1e-10 * np.eye(ANZAHL_ANLAGEN))
problem.solve()
w = np.array(gewichte.value).ravel()
im_zeitraum.append(float((schaetzdaten @ w).mean()))
spaeter.append(float((spaetere_daten @ w).mean()))
spaeter_gleich.append(float(spaetere_daten.mean()))
groesstes_gewicht.append(float(w.max()))
schaetzspanne.append(float(geschaetzte_rendite.max()
- geschaetzte_rendite.min()))
prozent = lambda werte: float(np.mean(werte)) * 100
print("=" * 78)
print(" WENN DER OPTIMIERER GESCHAETZTE RENDITEN GLAUBT")
print("=" * 78)
print(f"{ANZAHL_ANLAGEN} Anlagen, {WIEDERHOLUNGEN} Wiederholungen, "
f"je {BEOBACHTUNGEN} Beobachtungen zur Schaetzung.")
print(f"In Wahrheit haben ALLE dieselbe Tagesrendite von "
f"{WAHRE_RENDITE * 100:.4f} % und dieselbe Schwankung.")
print("Die richtige Antwort waere also: gleichgewichten.")
print()
print(f"Mittlere Spanne der geschaetzten Renditen: "
f"{prozent(schaetzspanne):.4f} Prozentpunkte")
print(f" -> Das ist das {prozent(schaetzspanne) / (WAHRE_RENDITE * 100):.1f}-Fache "
f"des wahren Werts. Aus reinem Rauschen.")
print(f"Mittleres groesstes Einzelgewicht: "
f"{prozent(groesstes_gewicht):.1f} % "
f"(bei Gleichgewichtung waeren es {100 / ANZAHL_ANLAGEN:.1f} %)")
print()
print(f"{'':<38} {'Tagesrendite':>14}")
print("-" * 78)
print(f"{'Wahrheit (alle Anlagen)':<38} {WAHRE_RENDITE * 100:>13.4f} %")
print(f"{'Optimierer IM Schaetzzeitraum':<38} {prozent(im_zeitraum):>13.4f} %"
f" <- Illusion")
print(f"{'Optimierer AUSSERHALB':<38} {prozent(spaeter):>13.4f} %")
print(f"{'Gleichgewichtung AUSSERHALB':<38} {prozent(spaeter_gleich):>13.4f} %")
print("-" * 78)
faktor = prozent(im_zeitraum) / (WAHRE_RENDITE * 100)
vorsprung = prozent(spaeter) - prozent(spaeter_gleich)
print(f"\nIm Schaetzzeitraum verspricht der Optimierer das "
f"{faktor:.1f}-Fache der wahren Rendite.")
print(f"Ausserhalb bleibt davon nichts: Er liegt {abs(vorsprung):.4f} "
f"Prozentpunkte")
print(f"{'schlechter' if vorsprung < 0 else 'besser'} als blosse Gleichgewichtung.")
print()
print("Der Grund ist statistisch, nicht finanzwirtschaftlich: Um eine")
print(f"Rendite von {WAHRE_RENDITE*100:.2f} % taeglich von null zu unterscheiden,")
print(f"braucht man bei {WAHRE_SCHWANKUNG*100:.1f} % Schwankung ueber tausend")
print("Beobachtungen. Mit 250 misst man ueberwiegend Rauschen - und der")
print("Optimierer nimmt jedes Rauschen fuer bare Muenze.")
print()
print("Drei praktische Konsequenzen:")
print(" 1. Verzichten Sie auf Renditeschaetzungen, wo es geht. Das")
print(" Minimum-Varianz-Portfolio braucht gar keine.")
print(" 2. Wenn Sie welche brauchen: schrumpfen Sie sie stark zur Mitte")
print(" (James-Stein, Black-Litterman) - viel staerker als Kovarianzen.")
print(" 3. Begrenzen Sie Einzelgewichte. Was der Optimierer nicht darf,")
print(" kann er auch nicht auf Rauschen setzen.")
print("=" * 78)

View file

@ -0,0 +1,99 @@
#!/usr/bin/env python3
# Robuste_Optimierung.py
"""
Kapitel Unsicherheit: Robuste Portfolio-Optimierung.
Vergleicht drei Haltungen zur Unsicherheit:
(a) nominal - vertraut den Punktschaetzungen blind
(b) robust - sichert gegen Box-Unsicherheit ab (Worst Case)
(c) stochastisch - optimiert den Erwartungswert ueber Szenarien
und misst den "Preis der Robustheit": Wie viel Ertrag kostet die Absicherung
im Normalfall - und wie viel Verlust erspart sie im Ernstfall?
"""
import cvxpy as cp
import numpy as np
ASSETS = ["Aktien Welt", "Anleihen", "Rohstoffe", "Immobilien"]
MU_SCHAETZUNG = np.array([0.085, 0.030, 0.055, 0.060]) # Punktschaetzung
UNSICHERHEIT = np.array([0.040, 0.008, 0.045, 0.025]) # +/- delta je Titel
VOLA = np.array([0.17, 0.05, 0.22, 0.12])
KORR = np.array([
[1.00, -0.15, 0.35, 0.55],
[-0.15, 1.00, -0.05, 0.10],
[0.35, -0.05, 1.00, 0.25],
[0.55, 0.10, 0.25, 1.00],
])
SIGMA = np.diag(VOLA) @ KORR @ np.diag(VOLA)
LAMBDA = 4.0 # Risikoaversion
N = len(ASSETS)
def optimiere(mu_effektiv):
"""Standard-Mean-Variance mit vorgegebenem Renditevektor."""
w = cp.Variable(N, nonneg=True)
ziel = cp.Maximize(mu_effektiv @ w - 0.5 * LAMBDA * cp.quad_form(w, SIGMA))
problem = cp.Problem(ziel, [cp.sum(w) == 1])
problem.solve()
return w.value
def kennzahlen(w, mu):
ertrag = float(mu @ w)
risiko = float(np.sqrt(w @ SIGMA @ w))
return ertrag, risiko
if __name__ == "__main__":
print("=" * 88)
print(" ROBUSTE vs. NOMINALE PORTFOLIO-OPTIMIERUNG")
print("=" * 88)
print(f"{'Asset':<14} {'Erw. Rendite':>14} {'Unsicherheit':>14} "
f"{'Worst Case':>12} {'Volatilitaet':>13}")
print("-" * 88)
for i, name in enumerate(ASSETS):
print(f"{name:<14} {MU_SCHAETZUNG[i]*100:>13.1f} % "
f"{'+/- ' + format(UNSICHERHEIT[i]*100, '.1f') + ' %':>14} "
f"{(MU_SCHAETZUNG[i]-UNSICHERHEIT[i])*100:>11.1f} % "
f"{VOLA[i]*100:>12.1f} %")
# (a) nominal: vertraut den Schaetzungen
w_nominal = optimiere(MU_SCHAETZUNG)
# (b) robust: rechnet mit dem Worst Case der Box-Unsicherheitsmenge
w_robust = optimiere(MU_SCHAETZUNG - UNSICHERHEIT)
print("\n" + "-" * 88)
print(f"{'':<14} {'nominal':>22} {'robust':>22}")
print("-" * 88)
for i, name in enumerate(ASSETS):
print(f"{name:<14} {w_nominal[i]*100:>21.1f} % {w_robust[i]*100:>21.1f} %")
# --- Bewertung in beiden Welten --------------------------------------
mu_worst = MU_SCHAETZUNG - UNSICHERHEIT
e_nom_gut, r_nom = kennzahlen(w_nominal, MU_SCHAETZUNG)
e_rob_gut, r_rob = kennzahlen(w_robust, MU_SCHAETZUNG)
e_nom_schlecht, _ = kennzahlen(w_nominal, mu_worst)
e_rob_schlecht, _ = kennzahlen(w_robust, mu_worst)
print("\n" + "-" * 88)
print(f"{'Bewertung':<34} {'nominales Portfolio':>22} {'robustes Portfolio':>22}")
print("-" * 88)
print(f"{'Ertrag, wenn Schaetzung stimmt':<34} {e_nom_gut*100:>21.2f} % "
f"{e_rob_gut*100:>21.2f} %")
print(f"{'Ertrag im Worst Case':<34} {e_nom_schlecht*100:>21.2f} % "
f"{e_rob_schlecht*100:>21.2f} %")
print(f"{'Volatilitaet':<34} {r_nom*100:>21.2f} % {r_rob*100:>21.2f} %")
print("\n" + "-" * 88)
print(f"Preis der Robustheit (Ertragsverzicht im Normalfall): "
f"{(e_nom_gut - e_rob_gut)*100:+.2f} Prozentpunkte")
print(f"Nutzen der Robustheit (Vorteil im Worst Case): "
f"{(e_rob_schlecht - e_nom_schlecht)*100:+.2f} Prozentpunkte")
verhaeltnis = ((e_rob_schlecht - e_nom_schlecht)
/ max(e_nom_gut - e_rob_gut, 1e-9))
print(f"Verhaeltnis Nutzen/Preis: {verhaeltnis:.2f}")
print(" -> Werte > 1 bedeuten: Die Absicherung bringt im Ernstfall mehr,")
print(" als sie im Normalfall kostet.")
print("=" * 88)

View file

@ -0,0 +1,58 @@
#!/usr/bin/env python3
# Rucksack.py
"""
Kapitel MILP: Das Rucksackproblem (Knapsack).
Zeigt LP-Relaxation, Branch-and-Bound-Ergebnis und den Preis der Ganzzahligkeit
an einem Beispiel, das man vollstaendig im Kopf nachvollziehen kann.
"""
import numpy as np
from scipy.optimize import linprog
GEGENSTAENDE = ["Zelt", "Schlafsack", "Kocher", "Kamera", "Buch", "Wasserfilter", "Seil"]
NUTZEN = np.array([40.0, 35.0, 20.0, 30.0, 8.0, 25.0, 12.0])
GEWICHT = np.array([ 6.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])
KAPAZITAET = 11.0 # kg
def loese(ganzzahlig: bool):
n = len(NUTZEN)
ergebnis = linprog(
c=-NUTZEN, A_ub=[GEWICHT], b_ub=[KAPAZITAET],
bounds=[(0, 1)] * n, # jedes Teil hoechstens einmal
integrality=np.ones(n) if ganzzahlig else None,
method="highs")
return -ergebnis.fun, ergebnis.x
if __name__ == "__main__":
z_lp, x_lp = loese(ganzzahlig=False)
z_ip, x_ip = loese(ganzzahlig=True)
print("=" * 72)
print(f" RUCKSACKPROBLEM (Kapazitaet {KAPAZITAET:.0f} kg)")
print("=" * 72)
print(f"{'Gegenstand':<14} {'Nutzen':>7} {'kg':>5} {'Nutzen/kg':>10} "
f"{'LP':>7} {'MILP':>6}")
print("-" * 72)
for i, name in enumerate(GEGENSTAENDE):
# int(round(...)) statt Format "%.0f": vermeidet die Ausgabe "-0"
print(f"{name:<14} {NUTZEN[i]:>7.0f} {GEWICHT[i]:>5.0f} "
f"{NUTZEN[i]/GEWICHT[i]:>10.2f} {x_lp[i]:>7.2f} {int(round(x_ip[i])):>6d}")
print("-" * 72)
print(f"{'Gesamtnutzen':<14} {'':<7} {'':<5} {'':<10} {z_lp:>7.2f} {z_ip:>6.0f}")
print(f"{'Gesamtgewicht':<14} {'':<7} {'':<5} {'':<10} "
f"{GEWICHT @ x_lp:>7.2f} {GEWICHT @ x_ip:>6.0f}")
print("-" * 72)
print(f"Obere Schranke aus der LP-Relaxation: {z_lp:.2f}")
print(f"Bestes ganzzahliges Ergebnis: {z_ip:.0f}")
print(f"Preis der Ganzzahligkeit: {z_lp - z_ip:.2f} "
f"({(1 - z_ip/z_lp)*100:.1f} %)")
gebrochen = [GEGENSTAENDE[i] for i in range(len(NUTZEN)) if 1e-6 < x_lp[i] < 1 - 1e-6]
print(f"\nIn der LP-Loesung nur teilweise eingepackt: {gebrochen}")
print("Genau hier wuerde Branch-and-Bound verzweigen:")
print(f" Ast 1: {gebrochen[0]} bleibt ganz zuhause (x=0)")
print(f" Ast 2: {gebrochen[0]} kommt ganz mit (x=1)")
print("=" * 72)

View file

@ -0,0 +1,91 @@
#!/usr/bin/env python3
# Runden_Gegenbeispiel.py
"""
Kapitel MILP: Wie schlecht ist Runden wirklich?
Ersetzt die unbelegte Behauptung "20-50 % Verlust" durch eine Messung ueber
viele Zufallsinstanzen.
"""
import numpy as np
from scipy.optimize import linprog
def erzeuge_instanz(n, m, rng):
"""Zufaelliges Rucksack-aehnliches MILP mit kleinen Zahlen."""
c = rng.integers(3, 20, size=n).astype(float) # Ertraege
A = rng.integers(1, 9, size=(m, n)).astype(float) # Verbraeuche
b = (A.sum(axis=1) * rng.uniform(0.25, 0.45)).round() # knappe Kapazitaeten
return c, A, b
def loese_lp(c, A, b, ganzzahlig=False):
"""LP-Relaxation oder exaktes MILP ueber HiGHS."""
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):
"""Naive Strategie: abrunden, dann gierig auffuellen, solange zulaessig."""
x = np.floor(x_lp + 1e-9)
verbessert = True
while verbessert: # gierig auffuellen
verbessert = False
for j in np.argsort(-c): # bester Ertrag zuerst
kandidat = x.copy()
kandidat[j] += 1
if np.all(A @ kandidat <= b + 1e-9):
x = kandidat
verbessert = True
break
return c @ x, x
if __name__ == "__main__":
rng = np.random.default_rng(2026)
print("=" * 82)
print(" WIE TEUER IST RUNDEN? (200 Zufallsinstanzen je Groesse)")
print("=" * 82)
print(f"{'n x m':>8} | {'Aufrunden unzul.':>17} | {'Abrunden: mittl.':>17} | "
f"{'schlimmster':>12} | {'gierig':>8}")
print(f"{'':>8} | {'':>17} | {'Verlust':>17} | {'Fall':>12} | {'Verlust':>8}")
print("-" * 82)
for n, m in [(5, 2), (10, 3), (20, 5), (40, 8)]:
unzulaessig = 0
verluste_ab, verluste_gierig = [], []
for _ in range(200):
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
# Variante 1: aufrunden
x_auf = np.ceil(x_lp - 1e-9)
if np.any(A @ x_auf > b + 1e-9):
unzulaessig += 1
# Variante 2: abrunden
x_ab = np.floor(x_lp + 1e-9)
verluste_ab.append(1.0 - (c @ x_ab) / z_ip)
# Variante 3: abrunden + gierig auffuellen
z_gierig, _ = abrunden_und_reparieren(x_lp, c, A, b)
verluste_gierig.append(1.0 - z_gierig / z_ip)
print(f"{n:>3} x {m:<3} | {unzulaessig/2:>15.1f} % | "
f"{np.mean(verluste_ab)*100:>15.1f} % | "
f"{np.max(verluste_ab)*100:>10.1f} % | "
f"{np.mean(verluste_gierig)*100:>6.1f} %")
print("-" * 82)
print("Lesart: 'Aufrunden unzul.' = Anteil der Faelle, in denen die aufgerundete")
print("Loesung eine Nebenbedingung verletzt. 'Verlust' = Abstand zum exakten Optimum.")
print("=" * 82)

View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
# Schaetzrauschen_Demo.py
"""
Kapitel Finanzdaten: Der Error-Maximizer-Effekt im Experiment.
Aufbau: Wir KENNEN die wahre Kovarianzmatrix, weil wir die Daten selbst
erzeugen. Dann schaetzen wir sie aus endlich vielen Beobachtungen und
vergleichen, wie gut das daraus optimierte Portfolio in der WAHREN Welt
abschneidet - mit und ohne Shrinkage.
Das ist der einzige saubere Weg, Schaetzfehler zu messen: Man braucht eine
Wahrheit zum Vergleich, und die gibt es nur in der Simulation.
"""
import numpy as np
from sklearn.covariance import LedoitWolf
def erzeuge_wahre_kovarianz(n, rng):
"""Ein-Faktor-Modell: gemeinsamer Marktfaktor plus titelspezifisches Rauschen."""
beta = rng.uniform(0.6, 1.4, size=n) # Marktsensitivitaeten
var_markt = 0.04 # Marktvarianz p.a.
var_spezifisch = rng.uniform(0.01, 0.09, size=n) # idiosynkratische Varianz
return np.outer(beta, beta) * var_markt + np.diag(var_spezifisch)
def minimum_varianz_gewichte(sigma):
"""Analytische GMV-Loesung: w = Sigma^-1 1 / (1' Sigma^-1 1). Ohne Restriktionen."""
eins = np.ones(len(sigma))
try:
loesung = np.linalg.solve(sigma, eins)
except np.linalg.LinAlgError:
loesung = np.linalg.pinv(sigma) @ eins # falls singulaer
return loesung / loesung.sum()
def portfoliovolatilitaet(w, sigma):
return float(np.sqrt(w @ sigma @ w))
if __name__ == "__main__":
rng = np.random.default_rng(2026)
N = 40 # Anzahl Titel
WIEDERHOLUNGEN = 200
sigma_wahr = erzeuge_wahre_kovarianz(N, rng)
w_ideal = minimum_varianz_gewichte(sigma_wahr)
vola_ideal = portfoliovolatilitaet(w_ideal, sigma_wahr)
print("=" * 92)
print(" ERROR-MAXIMIZER: WIE TEUER IST SCHAETZRAUSCHEN?")
print("=" * 92)
print(f"Aufbau: {N} Titel, wahre Kovarianz bekannt (Ein-Faktor-Modell).")
print(f"Bestmoegliche Volatilitaet bei perfektem Wissen: "
f"{vola_ideal*100:.2f} % p.a.\n")
print(f"{'T (Tage)':>9} | {'T/N':>5} | {'Stichprobe':>22} | {'Ledoit-Wolf':>22} | "
f"{'Shrink':>7}")
print(f"{'':>9} | {'':>5} | {'Vola Aufschlag':>22} | "
f"{'Vola Aufschlag':>22} | {'delta':>7}")
print("-" * 92)
for T in [60, 120, 252, 504, 1260]:
vola_stichprobe, vola_lw, deltas = [], [], []
for _ in range(WIEDERHOLUNGEN):
# Daten aus der WAHREN Verteilung ziehen (taeglich)
daten = rng.multivariate_normal(np.zeros(N), sigma_wahr / 252, size=T)
# (a) Stichproben-Kovarianz
s_stich = np.cov(daten, rowvar=False, ddof=1) * 252
w_stich = minimum_varianz_gewichte(s_stich)
# (b) Ledoit-Wolf-Shrinkage
lw = LedoitWolf().fit(daten)
s_lw = lw.covariance_ * 252
w_lw = minimum_varianz_gewichte(s_lw)
deltas.append(lw.shrinkage_)
# Bewertung IMMER mit der wahren Kovarianz - das ist der Punkt
vola_stichprobe.append(portfoliovolatilitaet(w_stich, sigma_wahr))
vola_lw.append(portfoliovolatilitaet(w_lw, sigma_wahr))
m_stich, m_lw = np.mean(vola_stichprobe), np.mean(vola_lw)
print(f"{T:>9} | {T/N:>5.1f} | {m_stich*100:>8.2f} % "
f"{(m_stich/vola_ideal-1)*100:>+9.1f} % | "
f"{m_lw*100:>8.2f} % {(m_lw/vola_ideal-1)*100:>+9.1f} % | "
f"{np.mean(deltas):>7.3f}")
print("-" * 92)
print("'Aufschlag' = wie viel mehr Risiko das Portfolio in der WAHREN Welt traegt,")
print("verglichen mit dem Portfolio bei perfektem Wissen.")
print("Lesart: Je kleiner T/N, desto teurer das Schaetzrauschen - und desto mehr")
print("schrumpft Ledoit-Wolf (delta steigt).")
print("=" * 92)

View file

@ -0,0 +1,110 @@
#!/usr/bin/env python3
# Sensitivitaetsanalyse.py
"""
Kapitel LP: Schattenpreis- und Sensitivitätsanalyse mit SciPy und HiGHS.
Achtung: Ohne Vorzeichenumkehr der Dualwerte waere die Handlungsempfehlung
strukturell immer "kein Zukauf noetig" - selbst bei harten Engpaessen. Dieses
Programm zeigt die korrekte Vorzeichenbehandlung.
"""
import numpy as np
from scipy.optimize import linprog
# --- Modell: Maximiere Deckungsbeitrag aus 3 Produkten ---------------------
# max 40*x1 + 30*x2 + 50*x3
DECKUNGSBEITRAG = np.array([40.0, 30.0, 50.0])
# Ressourcenverbrauch je Produkt (Zeile = Ressource, Spalte = Produkt)
VERBRAUCH = np.array([
[2.0, 1.0, 3.0], # Montagezeit
[1.0, 2.0, 1.0], # Lackierzeit
[1.0, 0.5, 2.0], # Qualitätsprüfung
])
KAPAZITAET = np.array([120.0, 80.0, 50.0]) # Stunden
RESSOURCEN = ["Montage", "Lackieren", "Qualitätsprüfung"]
PRODUKTE = ["Produkt 1", "Produkt 2", "Produkt 3"]
ANGEBOTSPREIS_PRUEFSTUNDE = 18.0 # EUR/h - lohnt sich der Zukauf?
def analysiere():
# linprog MINIMIERT -> Zielfunktion negieren
ergebnis = linprog(c=-DECKUNGSBEITRAG, A_ub=VERBRAUCH, b_ub=KAPAZITAET,
bounds=[(0, None)] * len(DECKUNGSBEITRAG), method="highs")
if not ergebnis.success:
raise SystemExit(f"Kein Optimum gefunden: {ergebnis.message}")
max_gewinn = -ergebnis.fun
mengen = ergebnis.x
schlupf = ergebnis.slack
# ------------------------------------------------------------------
# DER ENTSCHEIDENDE PUNKT:
# Weil wir zur Maximierung negiert haben, sind die Dualwerte aus
# linprog fuer "<="-Bedingungen <= 0. Zurueckdrehen!
# ------------------------------------------------------------------
schattenpreise = -ergebnis.ineqlin.marginals
print("=" * 74)
print(" PRIMALE UND DUALE ERGEBNISANALYSE (SENSITIVITAET)")
print("=" * 74)
print(f"Maximaler Deckungsbeitrag: {max_gewinn:,.2f} EUR\n")
print("--- Primalloesung: optimale Produktionsmengen ---")
for name, menge in zip(PRODUKTE, mengen):
print(f" * {name}: {menge:8.2f} Stueck")
print("\n--- Duale Analyse: Schattenpreise und Auslastung ---")
for i, name in enumerate(RESSOURCEN):
kapazitaet = KAPAZITAET[i]
genutzt = kapazitaet - schlupf[i]
auslastung = genutzt / kapazitaet * 100
preis = schattenpreise[i]
bindend = abs(schlupf[i]) < 1e-9
print(f"\nRessource '{name}':")
print(f" Auslastung: {genutzt:6.1f} / {kapazitaet:6.1f} h ({auslastung:5.1f} %)"
f" -> {'ENGPASS' if bindend else 'Reserve: %.1f h' % schlupf[i]}")
print(f" Schattenpreis: {preis:6.2f} EUR je zusaetzlicher Stunde")
if preis > 1e-9:
print(f" >> Zusaetzliche Stunden lohnen sich bis zu einem Preis von "
f"{preis:.2f} EUR/h.")
else:
print(f" >> Kein Zukauf noetig - die Kapazitaet ist nicht erschoepft.")
# --- Konkrete Kaufentscheidung ---------------------------------------
preis_pruefung = schattenpreise[RESSOURCEN.index("Qualitätsprüfung")]
marge = preis_pruefung - ANGEBOTSPREIS_PRUEFSTUNDE
print("\n" + "-" * 74)
print(f"ENTSCHEIDUNG: Pruefstunden werden fuer "
f"{ANGEBOTSPREIS_PRUEFSTUNDE:.2f} EUR/h angeboten.")
print(f" Schattenpreis: {preis_pruefung:6.2f} EUR/h")
print(f" Angebotspreis: {ANGEBOTSPREIS_PRUEFSTUNDE:6.2f} EUR/h")
print(f" Marge: {marge:+6.2f} EUR je zugekaufter Stunde")
print(f" >> {'ZUKAUFEN' if marge > 0 else 'NICHT ZUKAUFEN'}")
# --- Numerische Gegenprobe: Kapazitaet wirklich um 1 erhoehen --------
kapazitaet_plus = KAPAZITAET.copy()
kapazitaet_plus[RESSOURCEN.index("Qualitätsprüfung")] += 1.0
gegenprobe = linprog(c=-DECKUNGSBEITRAG, A_ub=VERBRAUCH, b_ub=kapazitaet_plus,
bounds=[(0, None)] * 3, method="highs")
tatsaechlicher_zuwachs = -gegenprobe.fun - max_gewinn
print("\n--- Gegenprobe: Modell mit +1 Pruefstunde neu geloest ---")
print(f" Vorhergesagt (Schattenpreis): {preis_pruefung:8.4f} EUR")
print(f" Tatsaechlich gemessen: {tatsaechlicher_zuwachs:8.4f} EUR")
assert abs(tatsaechlicher_zuwachs - preis_pruefung) < 1e-6, \
"Schattenpreis stimmt nicht mit der Messung ueberein!"
print(" -> Der Schattenpreis ist bestaetigt.")
# --- Komplementaerer Schlupf pruefen --------------------------------
for s, y in zip(schlupf, schattenpreise):
assert abs(s * y) < 1e-6, "Komplementaerer Schlupf verletzt!"
print("\nPruefung: komplementaerer Schlupf fuer alle Ressourcen erfuellt.")
print("=" * 74)
if __name__ == "__main__":
analysiere()

View file

@ -0,0 +1,160 @@
#!/usr/bin/env python3
# Simplex_Tableau_LP.py
"""
Kapitel LP: Vollständige Implementierung des Simplex-Algorithmus (Tableau-Methode).
GRENZEN DIESER IMPLEMENTIERUNG (bewusst, aus didaktischen Gründen):
* nur Maximierung
* nur "<="-Nebenbedingungen
* alle b_i >= 0 (sonst wäre der Ursprung keine zulässige Startecke und man
bräuchte eine Phase-1-Rechnung mit künstlichen Variablen)
Für den produktiven Einsatz nimmt man HiGHS - dieser Code dient dem Verständnis.
Voraussetzungen werden geprüft statt stillschweigend angenommen;
Iterationsprotokoll und Schattenpreise werden ausgegeben.
"""
import numpy as np
class SimplexTableauSolver:
"""Maximierungs-Standardform: max c^T x u.d.N. A x <= b, x >= 0, b >= 0."""
def __init__(self, c, A, b, variablennamen=None, restriktionsnamen=None):
self.c = np.asarray(c, dtype=float)
self.A = np.asarray(A, dtype=float)
self.b = np.asarray(b, dtype=float)
self.n = len(self.c) # Anzahl Originalvariablen
self.m = len(self.b) # Anzahl Nebenbedingungen
# --- Voraussetzungen pruefen, statt sie stillschweigend anzunehmen ---
if self.A.shape != (self.m, self.n):
raise ValueError(f"A hat Form {self.A.shape}, erwartet ({self.m}, {self.n}).")
if np.any(self.b < 0):
raise ValueError(
"Mindestens ein b_i ist negativ. Dann ist der Ursprung keine zulässige "
"Startecke; dieser Solver benötigt eine Phase-1-Rechnung, die hier "
"bewusst nicht implementiert ist. Nutzen Sie scipy.optimize.linprog."
)
self.var_namen = variablennamen or [f"x{j+1}" for j in range(self.n)]
self.restr_namen = restriktionsnamen or [f"R{i+1}" for i in range(self.m)]
self.tableau = None
self.basis = None # welche Variable ist in welcher Zeile Basis?
self._baue_starttableau()
def _baue_starttableau(self):
"""Zeilen: m Nebenbedingungen + Zielfunktionszeile.
Spalten: n Variablen + m Schlupfvariablen + rechte Seite."""
m, n = self.m, self.n
self.tableau = np.zeros((m + 1, n + m + 1))
self.tableau[:m, :n] = self.A # Koeffizienten
self.tableau[:m, n:n + m] = np.eye(m) # Schlupfvariablen
self.tableau[:m, -1] = self.b # rechte Seite
self.tableau[-1, :n] = -self.c # Zielzeile: -c (Maximierung)
self.basis = list(range(n, n + m)) # Start: alle Schlupf in der Basis
def _spaltenname(self, index):
return self.var_namen[index] if index < self.n else f"s{index - self.n + 1}"
def solve(self, max_iterationen=100, protokoll=True):
m, n = self.m, self.n
if protokoll:
print(f"{'Iter':>4} | {'eintritt':>9} | {'austritt':>9} | "
f"{'Pivot':>8} | {'Z':>12}")
print("-" * 58)
for iteration in range(1, max_iterationen + 1):
zielzeile = self.tableau[-1, :-1]
# 1. Optimalitätsprüfung: alle Koeffizienten >= 0 ?
if np.all(zielzeile >= -1e-9):
if protokoll:
print("-" * 58)
print(f"Optimum nach {iteration - 1} Pivotschritten erreicht.")
return self._loesung_auslesen()
# 2. Pivotspalte: negativster Eintrag (Dantzig-Regel)
pivot_spalte = int(np.argmin(zielzeile))
# 3. Pivotzeile: minimaler Quotient über POSITIVE Spalteneinträge
spalte = self.tableau[:m, pivot_spalte]
rechte_seite = self.tableau[:m, -1]
quotienten = np.where(spalte > 1e-9, rechte_seite / np.where(spalte > 1e-9, spalte, 1),
np.inf)
pivot_zeile = int(np.argmin(quotienten))
if not np.isfinite(quotienten[pivot_zeile]):
raise ValueError(
f"Problem ist unbeschraenkt: Variable {self._spaltenname(pivot_spalte)} "
"kann beliebig wachsen, ohne eine Bedingung zu verletzen. "
"Meist fehlt eine Kapazitaetsbeschraenkung."
)
if protokoll:
print(f"{iteration:>4} | {self._spaltenname(pivot_spalte):>9} | "
f"{self._spaltenname(self.basis[pivot_zeile]):>9} | "
f"{self.tableau[pivot_zeile, pivot_spalte]:>8.3f} | "
f"{self.tableau[-1, -1]:>12,.2f}")
# 4. Pivotoperation (Gauß-Jordan)
pivot_wert = self.tableau[pivot_zeile, pivot_spalte]
self.tableau[pivot_zeile, :] /= pivot_wert
for zeile in range(m + 1):
if zeile != pivot_zeile:
faktor = self.tableau[zeile, pivot_spalte]
self.tableau[zeile, :] -= faktor * self.tableau[pivot_zeile, :]
self.basis[pivot_zeile] = pivot_spalte
raise RuntimeError("Maximale Iterationszahl ueberschritten (moeglicherweise Zyklus).")
def _loesung_auslesen(self):
"""Basisvariablen tragen den RHS-Wert ihrer Zeile, Nichtbasisvariablen sind 0."""
x = np.zeros(self.n + self.m)
for zeile, spalte in enumerate(self.basis):
x[spalte] = self.tableau[zeile, -1]
return x[:self.n], x[self.n:], self.tableau[-1, -1]
def schattenpreise(self):
"""Die Zielzeile unter den Schlupfspalten enthält direkt die Dualwerte."""
return self.tableau[-1, self.n:self.n + self.m].copy()
if __name__ == "__main__":
# Modell aus der Simplex-Handrechnung (Bot-Beispiel, Kapitel Einfuehrung)
ertraege = [150.0, 250.0]
matrix = [[2.0, 5.0],
[4.0, 6.0],
[1.0, 0.0]]
kapazitaeten = [40.0, 60.0, 8.0]
var_namen = ["x_A", "x_B"]
restr_namen = ["vCPU", "RAM", "Marktlimit"]
print("=" * 58)
print(" SIMPLEX-TABLEAU: ITERATIONSPROTOKOLL")
print("=" * 58)
solver = SimplexTableauSolver(ertraege, matrix, kapazitaeten, var_namen, restr_namen)
x_opt, schlupf, z_opt = solver.solve()
print("\n" + "=" * 58)
print(" ERGEBNIS")
print("=" * 58)
for name, wert in zip(var_namen, x_opt):
print(f" {name:<12} = {wert:8.4f}")
print(f" {'Zielwert Z':<12} = {z_opt:8.2f} EUR")
print("\n Ressourcenanalyse:")
print(f" {'Ressource':<12} {'Schlupf':>9} {'Status':>22} {'Schattenpreis':>15}")
print(" " + "-" * 60)
for name, s, y in zip(restr_namen, schlupf, solver.schattenpreise()):
status = "ENGPASS (bindend)" if abs(s) < 1e-9 else "Reserve vorhanden"
print(f" {name:<12} {s:>9.3f} {status:>22} {y:>12.2f} EUR")
# Selbstkontrolle: komplementaerer Schlupf muss gelten
for s, y in zip(schlupf, solver.schattenpreise()):
assert abs(s * y) < 1e-6, "Komplementaerer Schlupf verletzt - Rechenfehler!"
print("\n Pruefung: komplementaerer Schlupf (s_i * y_i = 0) fuer alle i erfuellt.")
print("=" * 58)

View file

@ -0,0 +1,299 @@
#!/usr/bin/env python3
# Simulated_Annealing.py
"""
Kapitel Metaheuristiken: Simulated Annealing an der Lackieranlage.
Aufgabe: n Auftraege in eine Reihenfolge bringen. Zwischen zwei Auftraegen
faellt eine Ruestzeit an - die Anlage muss gereinigt werden. Wie lange das
dauert, haengt von BEIDEN Auftraegen ab: Ein Wechsel innerhalb derselben
Produktfamilie kostet fast nichts, ein Wechsel von Dunkel nach Hell ist teuer,
und zwischen manchen Familien ist der Reinigungsaufwand schlicht hoch. Gesucht
ist die Reihenfolge mit der kleinsten Summe der Ruestzeiten.
Das Programm zeigt drei Dinge:
1. Warum die Faustregel "immer der billigste naechste Auftrag" in eine
Sackgasse laeuft - und wie weit man sie mit lokaler Suche verbessert.
2. Wie gross der Beitrag der Verschlechterungen WIRKLICH ist. Die Antwort
faellt bescheidener aus als das Lehrbuch verspricht, und genau das ist
der Grund, den Vergleich immer mitzurechnen.
3. Dass die Starttemperatur kein Beiwerk ist: zu heiss macht die Suche
nutzlos, und man sieht es an einer einzigen Kennzahl kommen.
Zum Budget: Der Abkuehlplan laeuft ueber eine feste ZUGZAHL, nicht ueber eine
Sekundenzahl. Im Betrieb hat man zwar ein Zeitbudget - fuer einen VERGLEICH ist
die Zugzahl aber das ehrlichere Mass: Sie ist auf jeder Maschine dieselbe, und
die abgedruckten Zahlen unten lassen sich damit nachrechnen. Die gemessene
Laufzeit steht trotzdem dabei.
Benoetigt: numpy
"""
from __future__ import annotations
import math
import time
import numpy as np
ZUEGE = 400_000 # Zugbudget je Lauf (statt Sekunden: reproduzierbar)
SAAT = 11 # feste Saat -> reproduzierbare Instanz
# --- 1. Die Instanz ---------------------------------------------------------
def erzeuge_ruestmatrix(n: int, saat: int = SAAT) -> np.ndarray:
"""Ruestzeiten in Minuten zwischen je zwei Auftraegen.
Die Zahl der Produktfamilien waechst mit der Auftragszahl (eine Familie je
zehn Auftraege). Sonst wuerde das Problem mit wachsendem n LEICHTER: Bei
fester Familienzahl haette der Plan irgendwann nur noch ein paar grosse
Bloecke, und jede Faustregel faende ihn.
"""
rng = np.random.default_rng(saat)
familien = max(6, n // 10)
# Reinigungsaufwand zwischen den Familien - unregelmaessig und asymmetrisch,
# so wie im Betrieb: Von Klarlack auf Rot ist etwas anderes als umgekehrt.
zwischen = rng.integers(8, 60, (familien, familien))
np.fill_diagonal(zwischen, 2)
familie = rng.integers(0, familien, n)
farbe = rng.integers(0, 10, n) # 0 = weiss ... 9 = schwarz
matrix = np.zeros((n, n), dtype=np.int64)
for i in range(n):
for j in range(n):
if i != j:
# Dunkel -> hell kostet zusaetzlich: die Anlage muss heller
# werden, das braucht mehr Spuelgaenge.
matrix[i, j] = (zwischen[familie[i], familie[j]]
+ 3 * max(0, farbe[i] - farbe[j]))
return matrix
def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
"""Summe der Ruestzeiten einer Reihenfolge (offene Kette, keine Rundreise)."""
return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
def faustregel(matrix: np.ndarray) -> list[int]:
"""Immer der billigste noch offene Auftrag - 'naechster Nachbar'.
So plant ein Meister von Hand, und es ist keine schlechte Regel. Ihr Fehler
ist die Kurzsichtigkeit: Sie spart am Anfang und laesst die teuren Wechsel
fuer das Ende uebrig, wo keine Wahl mehr bleibt.
"""
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
# --- 2. Der Zug und seine Kostenaenderung -----------------------------------
def delta_verschieben(reihe: list[int], matrix: np.ndarray,
von: int, nach: int):
"""Auftrag von Position 'von' an Position 'nach' versetzen.
Liefert (Kostenaenderung, Restliste, Einfuegeposition) oder None, wenn der
Zug nichts aendert.
DAS IST DIE WICHTIGSTE FUNKTION DES PROGRAMMS. Sie berechnet die
Kostenaenderung aus HOECHSTENS SECHS Matrixeintraegen, statt die
Reihenfolge neu durchzusummieren. Der Unterschied ist nicht kosmetisch:
Die volle Summe kostet O(n) je Zug, diese Rechnung O(1). Bei 500 Auftraegen
sind das rund 500-mal mehr geprueste Zuege im selben Zeitbudget - und eine
Metaheuristik lebt von der Zahl der Zuege.
Herausgerissen wird der Auftrag aus zwei Kanten, seine Nachbarn ruecken
zusammen (eine neue Kante). Eingefuegt wird er zwischen zwei andere
Nachbarn, deren bisherige Kante dadurch verschwindet.
"""
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: # die Nachbarn ruecken zusammen
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): # die aufgetrennte Kante faellt weg
hinein -= matrix[rest[stelle - 1], rest[stelle]]
return int(hinein - heraus), rest, stelle
# --- 3. Die Suche -----------------------------------------------------------
class Verlauf:
"""Was ein Lauf ausser dem Ergebnis noch verraet."""
def __init__(self) -> None:
self.schritte = 0
self.verschlechterungen_geprueft = 0
self.verschlechterungen_genommen = 0
self.schlechtester_zustand = 0 # wie weit die Suche abgedriftet ist
self.sekunden = 0.0 # nur zur Information, nicht zum Vergleich
@property
def annahmequote(self) -> float:
if not self.verschlechterungen_geprueft:
return 0.0
return self.verschlechterungen_genommen / self.verschlechterungen_geprueft
def suche(matrix: np.ndarray, zuege: int, start_temperatur: float,
end_temperatur: float = 0.05, saat: int = 1,
bergsteigen: bool = False) -> tuple[list[int], int, Verlauf]:
"""Simulated Annealing (oder reines Bergsteigen, wenn bergsteigen=True).
Der einzige Unterschied zwischen beiden steht in der Annahmezeile: Das
Bergsteigen nimmt nur Verbesserungen, SA nimmt Verschlechterungen mit einer
Wahrscheinlichkeit an, die mit der Zeit gegen null geht.
"""
rng = np.random.default_rng(saat)
n = len(matrix)
reihe = faustregel(matrix)
kosten = gesamtruestzeit(reihe, matrix)
beste, beste_kosten = reihe[:], kosten
verlauf = Verlauf()
verlauf.schlechtester_zustand = kosten
start = time.perf_counter()
for zug in range(zuege):
verlauf.schritte += 1
# Geometrischer Abkuehlplan ueber das Zugbudget.
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 None:
continue
aenderung, rest, stelle = ergebnis
if aenderung <= 0:
annehmen = True
else:
verlauf.verschlechterungen_geprueft += 1
# Metropolis-Kriterium: je groesser die Verschlechterung und je
# kaelter es ist, desto unwahrscheinlicher.
annehmen = (not bergsteigen
and rng.random() < math.exp(-aenderung / temperatur))
verlauf.verschlechterungen_genommen += annehmen
if annehmen:
reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
kosten += aenderung
verlauf.schlechtester_zustand = max(verlauf.schlechtester_zustand,
kosten)
if kosten < beste_kosten:
beste, beste_kosten = reihe[:], kosten
verlauf.sekunden = time.perf_counter() - start
return beste, beste_kosten, verlauf
if __name__ == "__main__":
N = 200
matrix = erzeuge_ruestmatrix(N)
start_reihe = faustregel(matrix)
start_kosten = gesamtruestzeit(start_reihe, matrix)
print("=" * 78)
print(" SIMULATED ANNEALING AN DER LACKIERANLAGE")
print("=" * 78)
print(f"{N} Auftraege, {max(6, N // 10)} Produktfamilien, "
f"Zugbudget {ZUEGE:,} je Lauf.\n")
print(f"Faustregel 'billigster naechster Auftrag': "
f"{start_kosten:,} Minuten Ruestzeit")
# --- Wie gross sind die Zuege ueberhaupt? ---------------------------
# Ohne diese Zahl kann man die Temperatur nicht waehlen: Das
# Metropolis-Kriterium vergleicht die Verschlechterung MIT der Temperatur.
rng = np.random.default_rng(0)
stichprobe = []
for _ in range(5000):
e = delta_verschieben(start_reihe, matrix,
int(rng.integers(0, N)), int(rng.integers(0, N + 1)))
if e:
stichprobe.append(abs(e[0]))
print(f"Typische Zuggroesse |Delta|: Median {np.median(stichprobe):.0f}, "
f"90 %-Quantil {np.quantile(stichprobe, 0.9):.0f} Minuten")
# --- Bergsteigen gegen Annealing -------------------------------------
print("\n" + "-" * 78)
print("Nur Verbesserungen annehmen (Bergsteigen) gegen Annealing:\n")
_, berg, berg_verlauf = suche(matrix, ZUEGE, 1.0, bergsteigen=True)
_, gluehen, gluehen_verlauf = suche(matrix, ZUEGE, 1.0)
print(f" {'Faustregel (Start)':<34} {start_kosten:>7,} Minuten")
print(f" {'Bergsteigen':<34} {berg:>7,} Minuten "
f"({(start_kosten - berg) / start_kosten * 100:5.1f} % besser)")
print(f" {'Simulated Annealing':<34} {gluehen:>7,} Minuten "
f"({(start_kosten - gluehen) / start_kosten * 100:5.1f} % besser)")
print(f"\n Beide pruefen genau {berg_verlauf.schritte:,} Zuege "
f"({berg_verlauf.sekunden:.1f} bzw. {gluehen_verlauf.sekunden:.1f} Sekunden).")
print(f" Annealing nimmt davon {gluehen_verlauf.annahmequote * 100:.2f} % der "
f"VERSCHLECHTERUNGEN an, Bergsteigen keine.")
print("\n Bemerkenswert ist, wie klein der Abstand ist: Das simple")
print(f" Bergsteigen holt hier bereits {(start_kosten - berg) / (start_kosten - gluehen) * 100:.0f} % dessen, was Annealing")
print(" schafft. Wer eine Metaheuristik einsetzt, sollte diesen Vergleich")
print(" IMMER mitrechnen - sonst schreibt man einer aufwendigen Methode")
print(" gut, was schon die einfache geliefert haette.")
# --- Die Temperatur ist kein Beiwerk ---------------------------------
print("\n" + "-" * 78)
print("Und warum die Starttemperatur ausgemessen gehoert:\n")
print(f" {'Start-T':>8} {'Ruestzeit':>11} {'gg. Faustregel':>15} "
f"{'angenommene':>12} {'schlechtester':>14}")
print(f" {'':>8} {'':>11} {'':>15} {'Verschlecht.':>12} {'Zwischenwert':>14}")
print(" " + "-" * 64)
ergebnisse, verlaeufe = {}, {}
for temperatur in (0.5, 1.0, 2.0, 4.0, 8.0):
_, wert, verlauf = suche(matrix, ZUEGE, temperatur)
ergebnisse[temperatur] = wert
verlaeufe[temperatur] = verlauf
gewinn = (start_kosten - wert) / start_kosten * 100
print(f" {temperatur:>8.1f} {wert:>11,} {gewinn:>13.1f} % "
f"{verlauf.annahmequote * 100:>11.2f} % "
f"{verlauf.schlechtester_zustand:>14,}")
beste_temperatur = min(ergebnisse, key=ergebnisse.get)
heiss = verlaeufe[8.0]
print(f"\n Bester Wert bei T0 = {beste_temperatur}.")
print("\n Die letzten beiden Spalten erklaeren, warum es nach oben kippt.")
print(f" Bei T0 = 8 gehen nur {heiss.annahmequote * 100:.2f} % der Verschlechterungen")
print(" durch - wenig genug, dass man es fuer harmlos halten koennte. Es")
print(f" genuegt aber: Die Suche driftet bis auf {heiss.schlechtester_zustand:,} Minuten ab, das")
print(f" {heiss.schlechtester_zustand / start_kosten:.1f}-fache der Startloesung, und findet im Zugbudget nicht")
print(" mehr zurueck. Vom Ergebnis bleibt fast nichts uebrig: Die Suche hat")
print(f" in {ZUEGE:,} Zuegen nie etwas GESEHEN, das mehr als")
print(f" {(start_kosten - ergebnisse[8.0]) / start_kosten * 100:.1f} % unter der Startloesung lag.")
print("\n Bemerkenswert ist die andere Richtung: Die besten Werte entstehen")
print(" bei Annahmequoten nahe null. Die Lehrbuchregel 'anfangs sollen")
print(" 20 bis 50 Prozent der Verschlechterungen durchgehen' fuehrt hier in")
print(" die Irre - sie stammt aus Problemen mit sehr kleinen Zuggroessen.")
print(" Bei Ruestzeiten in Minuten ist ein einziger schlechter Zug teuer.")
print("\n Was stattdessen traegt: Den SCHLECHTESTEN Zwischenwert protokollieren")
print(" und T0 so waehlen, dass er die Startloesung nicht wesentlich")
print(" ueberschreitet. Diese eine Zahl haette hier auf Anhieb gezeigt, dass")
print(" T0 = 8 unbrauchbar ist - ohne dass man das Endergebnis abwarten muss.")
print("=" * 78)

View file

@ -0,0 +1,206 @@
#!/usr/bin/env python3
# Skalierung_Kondition.py
"""
Kapitel Fundament: Was die Konditionszahl kappa(A) praktisch bedeutet - und wie man
ein schlecht skaliertes Modell wieder gesund rechnet.
Drei Experimente:
1. Fehlerverstaerkung: Wie stark schlaegt eine winzige Datenunsicherheit auf
die Loesung durch? kappa(A) ist genau die Obergrenze dieses Faktors.
2. Ruiz-Equilibrierung: Ein Modell, in dem Euro-Betraege (1e7) und
Tonnen-Angaben (1e-3) in derselben Matrix stehen, wird durch Zeilen- und
Spaltenskalierung um Groessenordnungen besser konditioniert.
3. Toleranzen: Warum eine Binaervariable mit dem Wert 0.99999998 niemals
mit int() gerundet werden darf.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import linprog
RNG = np.random.default_rng(42)
# --- Experiment 1: kappa(A) als Fehlerverstaerker ---------------------------
def fehlerverstaerkung(A: np.ndarray, versuche: int = 200,
stoerung: float = 1e-10) -> tuple[float, float]:
"""Stoert die rechte Seite b relativ um 'stoerung' in zufaellige Richtungen
und misst, um welchen Faktor sich der Fehler in der Loesung x vergroessert.
Liefert (Median, Maximum) der Verstaerkung. Die Theorie sagt: Das Maximum
kann bis kappa(A) betragen - und nur bis dahin.
"""
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 zeige_experiment_1() -> None:
print("=" * 74)
print(" 1. KONDITIONSZAHL ALS FEHLERVERSTAERKER")
print("=" * 74)
print("Zwei Gleichungssysteme, beide exakt loesbar mit x = (1, 1).")
print("Die rechte Seite wird um relativ 1e-10 gestoert - so viel Unsicherheit")
print("steckt in JEDER gemessenen Betriebszahl allemal.\n")
modelle = {
"gut konditioniert": np.array([[2.0, 1.0], [1.0, 3.0]]),
"fast parallele Zeilen": np.array([[1.0, 1.0], [1.0, 1.0 + 1e-8]]),
}
print(f"{'Matrix':<24} {'kappa(A)':>12} {'Verst. median':>14} {'Verst. max':>12}")
print("-" * 74)
for name, A in modelle.items():
median, maximum = fehlerverstaerkung(A)
print(f"{name:<24} {np.linalg.cond(A):>12.2e} {median:>14.2e} {maximum:>12.2e}")
print("\nLesart: Bei der zweiten Matrix wird aus einem Datenfehler in der")
print("10. Nachkommastelle ein Loesungsfehler in der 2. Nachkommastelle.")
print("Das Modell ist mathematisch korrekt - und praktisch wertlos.")
# --- Experiment 2: Ruiz-Equilibrierung --------------------------------------
def ruiz_equilibrierung(A: np.ndarray, durchlaeufe: int = 20
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Skaliert A iterativ so, dass alle Zeilen- und Spaltenmaxima nahe 1
liegen (Ruiz 2001).
In jedem Durchlauf wird jede Zeile durch die Wurzel ihres Betragsmaximums
geteilt, danach jede Spalte. Das Verfahren konvergiert schnell und braucht
keinerlei Wissen ueber die Bedeutung der Zahlen - genau deshalb steckt es
in jedem ernsthaften Solver als Vorverarbeitung.
Liefert (A_skaliert, zeilenfaktor r, spaltenfaktor c) mit
A_skaliert = diag(r) @ A @ diag(c)
"""
m, n = A.shape
r = np.ones(m)
c = np.ones(n)
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
d_r = 1.0 / np.sqrt(zeilen_max)
A_s = d_r[:, None] * A_s
r *= d_r
spalten_max = np.abs(A_s).max(axis=0)
spalten_max[spalten_max == 0] = 1.0
d_c = 1.0 / np.sqrt(spalten_max)
A_s = A_s * d_c[None, :]
c *= d_c
return A_s, r, c
def zeige_experiment_2() -> None:
print("\n" + "=" * 74)
print(" 2. RUIZ-EQUILIBRIERUNG: EINHEITEN GERADERUECKEN")
print("=" * 74)
print("Ein Produktionsmodell, in dem vier Ressourcen in voellig")
print("verschiedenen Einheiten gemessen werden:")
print(" Zeile 1: Kapitalbindung in Euro (Groessenordnung 1e7)")
print(" Zeile 2: Katalysatorverbrauch in Tonnen (Groessenordnung 1e-3)")
print(" Zeile 3: Energie in Wattsekunden (Groessenordnung 1e5)")
print(" Zeile 4: Ausschussquote als Anteil (Groessenordnung 1e-2)\n")
# Quadratisch gewaehlt, damit die Loesung eindeutig ist und die
# Ruecktransformation unten wirklich etwas beweist.
n = 4
grundmatrix = RNG.uniform(0.5, 2.0, size=(n, n))
einheiten = np.array([1e7, 1e-3, 1e5, 1e-2])
A = grundmatrix * einheiten[:, None]
A_s, r, c = ruiz_equilibrierung(A)
print(f"{'':<28} {'kappa(A)':>12} {'groesster Eintrag':>18} "
f"{'kleinster Eintrag':>18}")
print("-" * 74)
for name, matrix in [("vor der Skalierung", A), ("nach Ruiz-Equilibrierung", A_s)]:
betraege = np.abs(matrix)
print(f"{name:<28} {np.linalg.cond(matrix):>12.2e} "
f"{betraege.max():>18.2e} {betraege.min():>18.2e}")
# Gegenprobe: Das skalierte Modell beschreibt dasselbe Problem. Wer x_s
# loest, erhaelt die urspruengliche Loesung durch x = c * x_s.
x_wahr = RNG.uniform(1.0, 5.0, size=n)
b = A @ x_wahr
b_s = r * b
x_s = np.linalg.solve(A_s, b_s)
x_zurueck = c * x_s
print(f"\nRuecktransformation x = c * x_s: groesste Abweichung zur wahren "
f"Loesung {np.abs(x_zurueck - x_wahr).max():.2e}")
print("Die Skalierung ist also verlustfrei - sie aendert nur die Zahlen,")
print("nicht das Problem.")
# --- Experiment 3: Toleranzen und der int()-Fehler --------------------------
def zeige_experiment_3() -> None:
print("\n" + "=" * 74)
print(" 3. TOLERANZEN: WARUM int() DIE FALSCHE RUNDUNG IST")
print("=" * 74)
# Ein LP, dessen Optimum bei x = 1 liegt, aber vom Solver nur bis auf
# seine Toleranz getroffen wird.
ergebnis = linprog(c=[-1.0], A_ub=[[1.0]], b_ub=[1.0],
bounds=[(0, None)], method="highs")
wert = float(ergebnis.x[0])
print(f"Solver liefert x = {wert!r}")
# Typische Werte, wie sie aus MILP-Solvern zurueckkommen.
beispiele = [0.99999998, 1.00000002, 0.49999999, 2.9999999]
print(f"\n{'Solverwert':>14} {'int()':>8} {'round()':>9} {'Kommentar'}")
print("-" * 74)
kommentare = {
0.99999998: "int() macht aus einer JA- eine NEIN-Entscheidung",
1.00000002: "hier ginge int() zufaellig gut - Verlass ist keiner",
0.49999999: "echt unentschieden: Modell oder Toleranz pruefen!",
2.9999999: "3 Maschinen werden zu 2 - der Plan geht nicht auf",
}
for wert_b in beispiele:
print(f"{wert_b:>14.8f} {int(wert_b):>8} {round(wert_b):>9} "
f"{kommentare[wert_b]}")
print("\nRichtige Vorgehensweise: gegen die Solver-Toleranz pruefen,")
print("dann erst runden - und den Zweifelsfall melden statt still zu raten.")
def sichere_ganzzahl(wert: float, toleranz: float = 1e-6) -> int:
naechste = round(wert)
if abs(wert - naechste) > toleranz:
raise ValueError(
f"{wert} ist {abs(wert - naechste):.2e} von der naechsten ganzen "
f"Zahl entfernt - das ist mehr als die Toleranz {toleranz}. "
"Ganzzahligkeit im Modell pruefen.")
return naechste
for wert_b in beispiele:
try:
print(f" sichere_ganzzahl({wert_b}) = {sichere_ganzzahl(wert_b)}")
except ValueError as fehler:
print(f" sichere_ganzzahl({wert_b}) -> ValueError: {fehler}")
if __name__ == "__main__":
zeige_experiment_1()
zeige_experiment_2()
zeige_experiment_3()
print("\n" + "=" * 74)
print("Merksatz: Skalieren Sie Ihre Daten, BEVOR der Solver sie sieht -")
print("und runden Sie Solver-Ergebnisse NIE ohne Toleranzpruefung.")
print("=" * 74)

View file

@ -0,0 +1,73 @@
#!/usr/bin/env python3
# Solver_Wahl.py
"""
Kapitel Oekosystem: Entscheidungshilfe zur Solverwahl.
Beantwortet drei Fragen und empfiehlt Bibliothek + Backend.
"""
from dataclasses import dataclass
@dataclass
class Problem:
diskrete_entscheidungen: bool # Ja/Nein-Variablen, Zuordnungen, Reihenfolgen?
zielfunktion: str # "linear" | "quadratisch" | "konvex" | "beliebig"
routing: bool = False # Fahrzeuge, Depots, Zeitfenster?
groesse: str = "klein" # "klein" (<10^3 Var.) | "gross"
wiederholte_laeufe: bool = False
def empfehle(p: Problem) -> tuple[str, str, str]:
"""Gibt (Bibliothek, Backend, Begründung) zurück."""
if p.routing:
return ("ortools.constraint_solver (Routing)", "Routing Engine",
"Spezialisierte Heuristiken für VRP/TSP schlagen jedes selbstgebaute Modell.")
if p.diskrete_entscheidungen:
if p.zielfunktion in ("linear",) and not p.routing:
if p.groesse == "gross":
return ("highspy", "HiGHS Branch-and-Cut",
"MILP mit ökonomischer Struktur (Fixkosten, Kardinalität).")
return ("ortools.sat (CP-SAT)", "CP-SAT",
"Logische Regeln und Zuweisungen: CP-SAT propagiert sehr effizient.")
return ("ortools.sat (CP-SAT)", "CP-SAT",
"Diskrete Struktur dominiert; CP-SAT verarbeitet auch nichtlineare Logik.")
if p.zielfunktion == "linear":
if p.wiederholte_laeufe or p.groesse == "gross":
return ("highspy", "HiGHS Dual Simplex",
"Modell einmal aufbauen, Parameter ändern, wiederholt lösen.")
return ("scipy.optimize.linprog", "HiGHS",
"Kleinstes Setup, keine zusätzliche Abhängigkeit.")
if p.zielfunktion in ("quadratisch", "konvex"):
return ("cvxpy", "Clarabel / OSQP",
"Konvexität wird automatisch geprüft; Portfolio-Standard.")
return ("scipy.optimize.minimize", "SLSQP / trust-constr",
"Nicht konvex: nur lokales Optimum, Startpunkt variieren und vergleichen!")
BEISPIELE = {
"Vertretungsplan Schule": Problem(True, "linear", groesse="klein"),
"Produktionsplanung (LP)": Problem(False, "linear", groesse="klein"),
"Produktionsplanung, 50k Var.": Problem(False, "linear", groesse="gross"),
"Portfolio Markowitz": Problem(False, "quadratisch"),
"Portfolio mit max. 5 Titeln": Problem(True, "quadratisch"),
"Liefertouren mit Zeitfenstern": Problem(True, "linear", routing=True),
"Entropie-Allokation (NLP)": Problem(False, "beliebig"),
"Backtest, 60x neu optimieren": Problem(False, "konvex", wiederholte_laeufe=True),
}
if __name__ == "__main__":
print("=" * 92)
print(" SOLVER-EMPFEHLUNG")
print("=" * 92)
for name, p in BEISPIELE.items():
lib, backend, grund = empfehle(p)
print(f"\n{name}")
print(f" -> Bibliothek: {lib}")
print(f" Backend: {backend}")
print(f" Grund: {grund}")
print("\n" + "=" * 92)

View file

@ -0,0 +1,216 @@
#!/usr/bin/env python3
# Solverstatus_und_Gap.py
"""
Kapitel MILP: Was tun, wenn der Solver nicht fertig wird?
Bei einem LP kommt entweder eine optimale Loesung oder eine klare Absage.
Bei einem MILP ist der haeufigste Ausgang im Betrieb ein dritter: "Ich habe
eine Loesung, ich weiss aber nicht, ob sie die beste ist - und die Zeit ist
um." Dieses Programm zeigt, wie man mit diesem Fall umgeht.
1. Alle Statusfaelle explizit behandeln, statt OPTIMAL vorauszusetzen.
2. Den MIP-Gap lesen: Wie weit kann ich hoechstens danebenliegen?
3. Messen, was zusaetzliche Rechenzeit ueberhaupt noch bringt.
4. Warm-Start ausprobieren - und ehrlich messen, ob er etwas bringt.
Beispiel: Standortplanung, 45 moegliche Lager, 120 Kunden (5445 Variablen,
davon 45 binaer).
Benoetigt: numpy, highspy
"""
from __future__ import annotations
import time
from dataclasses import dataclass
import numpy as np
import highspy
RNG = np.random.default_rng(7)
N_LAGER, N_KUNDE = 45, 120
FIXKOSTEN = RNG.uniform(3000, 9000, N_LAGER)
TRANSPORT = RNG.uniform(5, 60, (N_LAGER, N_KUNDE))
BEDARF = RNG.uniform(10, 60, N_KUNDE)
KAPAZITAET = np.full(N_LAGER, BEDARF.sum() * 0.22)
@dataclass
class Ergebnis:
"""Alles, was nach einem Solverlauf ausgewertet werden muss - nicht nur
der Zielwert."""
status: str
brauchbar: bool # Gibt es ueberhaupt eine zulaessige Loesung?
beweisbar_optimal: bool
zielwert: float # bester gefundener Wert (Incumbent)
schranke: float # beste bewiesene Schranke (Dual Bound)
gap: float # relativer Abstand zwischen beiden
knoten: int
dauer: float
loesung: np.ndarray
def loese(zeitlimit: float, startloesung: np.ndarray | None = None) -> Ergebnis:
"""Loest das Standortmodell mit Zeitlimit und wertet ALLE Statusfaelle aus."""
hochschule = highspy.Highs()
hochschule.setOptionValue("output_flag", False)
hochschule.setOptionValue("time_limit", zeitlimit)
anzahl_x = N_LAGER * N_KUNDE
unendlich = highspy.kHighsInf
hochschule.addVars(anzahl_x, np.zeros(anzahl_x), np.full(anzahl_x, unendlich))
hochschule.addVars(N_LAGER, np.zeros(N_LAGER), np.ones(N_LAGER))
for i in range(N_LAGER):
hochschule.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
hochschule.changeColCost(anzahl_x + i, FIXKOSTEN[i])
for j in range(N_KUNDE):
hochschule.changeColCost(i * N_KUNDE + j, TRANSPORT[i, j])
for j in range(N_KUNDE):
index = np.array([i * N_KUNDE + j for i in range(N_LAGER)], dtype=np.int32)
hochschule.addRow(BEDARF[j], BEDARF[j], len(index), index, np.ones(len(index)))
for i in range(N_LAGER):
index = np.array([i * N_KUNDE + j for j in range(N_KUNDE)] + [anzahl_x + i],
dtype=np.int32)
werte = np.concatenate([np.ones(N_KUNDE), [-KAPAZITAET[i]]])
hochschule.addRow(-unendlich, 0.0, len(index), index, werte)
if startloesung is not None:
hochschule.setSolution(len(startloesung),
np.arange(len(startloesung), dtype=np.int32),
startloesung)
t0 = time.perf_counter()
hochschule.run()
dauer = time.perf_counter() - t0
status = hochschule.modelStatusToString(hochschule.getModelStatus())
info = hochschule.getInfo()
# Der Kern der Sache: Aus dem Status folgt, WAS man mit dem Ergebnis
# ueberhaupt anfangen darf.
brauchbar = status in ("Optimal", "Time limit reached", "Solution limit reached")
beweisbar_optimal = status == "Optimal"
if status in ("Infeasible", "Unbounded", "Primal infeasible or unbounded"):
brauchbar = False
return Ergebnis(
status=status,
brauchbar=brauchbar and info.objective_function_value < unendlich,
beweisbar_optimal=beweisbar_optimal,
zielwert=info.objective_function_value,
schranke=info.mip_dual_bound,
gap=info.mip_gap,
knoten=info.mip_node_count,
dauer=dauer,
loesung=np.array(hochschule.getSolution().col_value),
)
def gieriger_startplan() -> tuple[np.ndarray, float]:
"""Eine Faustregel-Loesung, wie sie ein Disponent von Hand erstellen wuerde:
die guenstigsten Lager oeffnen (Fixkosten je Kapazitaetseinheit), dann
jeden Kunden dem naechstgelegenen offenen Lager mit Restkapazitaet
zuordnen. Kein Solver noetig - und in Sekunden fertig.
"""
reihenfolge = np.argsort(FIXKOSTEN / KAPAZITAET)
offen: list[int] = []
for i in reihenfolge:
offen.append(int(i))
if KAPAZITAET[offen].sum() >= BEDARF.sum() * 1.05:
break
rest = KAPAZITAET.copy()
x = np.zeros((N_LAGER, N_KUNDE))
for j in np.argsort(-BEDARF): # groesste Kunden zuerst
for i in sorted(offen, key=lambda i: TRANSPORT[i, j]):
menge = min(rest[i], BEDARF[j] - x[:, j].sum())
if menge > 1e-9:
x[i, j] += menge
rest[i] -= menge
if abs(x[:, j].sum() - BEDARF[j]) < 1e-9:
break
y = np.zeros(N_LAGER)
y[offen] = 1.0
kosten = float((FIXKOSTEN * y).sum() + (TRANSPORT * x).sum())
return np.concatenate([x.ravel(), y]), kosten
def zeige(titel: str, e: Ergebnis) -> None:
print(f"\n{titel}")
print(f" Status {e.status}")
if not e.brauchbar:
print(" -> KEINE verwertbare Loesung. Nicht weiterrechnen!")
return
print(f" bester Plan (Incumbent) {e.zielwert:>12,.2f} EUR")
print(f" bewiesene Schranke {e.schranke:>12,.2f} EUR")
print(f" MIP-Gap {e.gap * 100:>12.3f} %")
print(f" Knoten / Zeit {e.knoten:>12,} / {e.dauer:.2f} s")
if e.beweisbar_optimal:
print(" -> beweisbar optimal")
else:
print(f" -> zulaessig, aber nicht bewiesen optimal. Der wahre Bestwert")
print(f" liegt zwischen {e.schranke:,.2f} und {e.zielwert:,.2f} EUR.")
if __name__ == "__main__":
print("=" * 78)
print(" MIP-GAP UND ZEITLIMIT: STANDORTPLANUNG, 45 LAGER, 120 KUNDEN")
print("=" * 78)
print(f"{N_LAGER * N_KUNDE + N_LAGER:,} Variablen, davon {N_LAGER} binaer.")
kurz = loese(2.0)
zeige("[1] Zeitlimit 2 Sekunden", kurz)
lang = loese(60.0)
zeige("[2] Zeitlimit 60 Sekunden", lang)
print("\n" + "=" * 78)
print(" WAS BRINGT MEHR RECHENZEIT?")
print("=" * 78)
verbesserung = kurz.zielwert - lang.zielwert
print(f"Nach 2 Sekunden: {kurz.zielwert:,.2f} EUR, Gap {kurz.gap * 100:.2f} %")
print(f"Nach {lang.dauer:.1f} Sekunden: {lang.zielwert:,.2f} EUR, bewiesen optimal")
print(f"Gewinn durch {lang.dauer - kurz.dauer:.1f} Sekunden mehr Rechenzeit: "
f"{verbesserung:,.2f} EUR "
f"({verbesserung / kurz.zielwert * 100:.2f} %)")
print()
print("Das ist die Frage, die im Betrieb wirklich zaehlt: Der Gap von")
print(f"{kurz.gap * 100:.1f} % nach 2 Sekunden ist eine GARANTIE - schlechter als")
print("dieser Wert kann die Loesung nicht sein. Ob sich die restliche")
print("Rechenzeit lohnt, entscheidet nicht der Solver, sondern die Anwendung:")
print("Ein naechtlicher Tourenplan darf eine Stunde rechnen, eine Umplanung")
print("bei Maschinenausfall hat 30 Sekunden.")
print("\n" + "=" * 78)
print(" BRINGT EIN WARM-START ETWAS?")
print("=" * 78)
start, start_kosten = gieriger_startplan()
print(f"Faustregel-Startplan (ohne Solver): {start_kosten:,.2f} EUR")
print(f"Das sind {(start_kosten / lang.zielwert - 1) * 100:.1f} % ueber dem Optimum.\n")
warm = loese(60.0, startloesung=start)
print(f"{'':26} {'Zeit':>9} {'Knoten':>9} {'Ziel':>13}")
print("-" * 78)
print(f"{'ohne Warm-Start':<26} {lang.dauer:>8.2f}s {lang.knoten:>9,} "
f"{lang.zielwert:>13,.2f}")
print(f"{'mit Warm-Start':<26} {warm.dauer:>8.2f}s {warm.knoten:>9,} "
f"{warm.zielwert:>13,.2f}")
print("-" * 78)
print("Ergebnis: praktisch kein Unterschied. Der Grund ist nicht, dass")
print("Warm-Starts nichts taugen - sondern dass HiGHS' eigene Heuristiken")
print("innerhalb der ersten Sekunde bereits eine BESSERE Loesung finden als")
print("unsere Faustregel. Ein Startwert hilft nur, wenn er besser ist als")
print("das, was der Solver von allein in derselben Zeit findet.")
print()
print("Warm-Starts lohnen sich damit vor allem in zwei Faellen:")
print(" * Sie haben Domaenenwissen, das der Solver nicht hat (siehe")
print(" Warmstart_Effekt.py - dort halbiert ein Heuristik-Hinweis die Zeit).")
print(" * Sie planen laufend neu und der gestrige Plan ist fast noch gueltig.")
print("In beiden Faellen gilt: MESSEN, nicht glauben.")
print("=" * 78)

View file

@ -0,0 +1,320 @@
#!/usr/bin/env python3
# Solverwechsel_CPSAT_HiGHS.py
"""
Kapitel Praxisfallen: Denselben Fall einmal mit CP-SAT und einmal mit HiGHS rechnen.
Die Trennung aus or_kern.py behauptet, ein Solverwechsel koste genau EINEN
Baustein. Dieses Programm loest diese Behauptung ein. Aufgabe ist eine
Standortplanung: Welche Lager oeffnen wir, und wer beliefert welchen Kunden?
Standortproblem (Pydantic, geprueft) <- gemeinsam
baue_und_loese_mit_cpsat() <- der EINE Baustein
baue_und_loese_mit_highs() <- ... in zwei Ausfuehrungen
Loesung (DTO) <- gemeinsam
pruefe_zuordnung() <- gemeinsam
berichte() <- gemeinsam
Die beiden Modellbauer sind 39 und 52 Zeilen lang - sie sind der gesamte
solverabhaengige Teil des Programms. Alles andere wird zweimal benutzt und
einmal geschrieben.
WARUM ZWEI PROZESSE? ortools und highspy bringen beide eine eigene
HiGHS-Kopie mit und lassen sich auf vielen Systemen nicht gemeinsam
importieren (Kapitel Oekosystem). Das Hauptprogramm startet deshalb fuer jeden
Solver einen eigenen Python-Prozess und laesst sich die Loesung als JSON
zurueckgeben - das DTO ist nicht nur eine Sprachregelung, sondern ein
Datenformat, das eine Prozessgrenze ueberlebt.
Aufruf:
python3 Solverwechsel_CPSAT_HiGHS.py # beide, mit Vergleich
python3 Solverwechsel_CPSAT_HiGHS.py cpsat # nur der Kindprozess
python3 Solverwechsel_CPSAT_HiGHS.py highs
Benoetigt: numpy, pydantic, ortools, highspy (jeweils im eigenen Prozess)
"""
from __future__ import annotations
import subprocess
import sys
import time
import numpy as np
from pydantic import BaseModel, Field, model_validator
from or_kern import Loesung, SolverStatus, status_von_cpsat, status_von_highs
ZEITLIMIT = 30.0
# --- 1. Domaenenmodell: dieselben Fakten fuer beide Solver -------------------
class Standortproblem(BaseModel):
"""Kapazitierte Standortplanung mit Einzelbelieferung.
Nach dem Muster von Produktionsproblem in or_kern.py: Die Pruefungen
stehen im Konstruktor, nicht im Solvercode - sie gelten damit fuer beide
Solver, und sie schlagen beim Einlesen zu.
Alle Kosten sind ganzzahlig (Euro). Das ist keine Bequemlichkeit, sondern
Voraussetzung: CP-SAT rechnet ausschliesslich ganzzahlig.
"""
lager: list[str] = Field(min_length=1)
kunden: list[str] = Field(min_length=1)
fixkosten: list[int] # je Lager, faellt bei Eroeffnung an
kapazitaet: list[int] # je Lager, in Paletten
bedarf: list[int] # je Kunde, in Paletten
transport: list[list[int]] # [Lager][Kunde], Kosten der Belieferung
@model_validator(mode="after")
def pruefe_masse(self) -> "Standortproblem":
n, m = len(self.lager), len(self.kunden)
if len(self.fixkosten) != n or len(self.kapazitaet) != n:
raise ValueError(f"fixkosten/kapazitaet muessen {n} Eintraege haben")
if len(self.bedarf) != m:
raise ValueError(f"bedarf muss {m} Eintraege haben")
if len(self.transport) != n or any(len(z) != m for z in self.transport):
raise ValueError(f"transport muss {n} x {m} sein")
if sum(self.kapazitaet) < sum(self.bedarf):
raise ValueError(f"Gesamtkapazitaet {sum(self.kapazitaet)} deckt den "
f"Gesamtbedarf {sum(self.bedarf)} nicht")
return self
def schluessel(self, i: int, j: int) -> str:
"""Variablenname im Loesung-DTO - beide Modellbauer benutzen ihn."""
return f"{self.lager[i]}->{self.kunden[j]}"
def beispielproblem(saat: int = 11) -> Standortproblem:
"""Sechs moegliche Lager, zwoelf Kunden - klein genug fuer beide Solver."""
rng = np.random.default_rng(saat)
lager = [f"Lager_{k}" for k in "ABCDEF"]
kunden = [f"Kunde_{k:02d}" for k in range(1, 13)]
bedarf = rng.integers(10, 60, len(kunden))
return Standortproblem(
lager=lager,
kunden=kunden,
fixkosten=rng.integers(3000, 9000, len(lager)).tolist(),
kapazitaet=[int(bedarf.sum() * 0.45)] * len(lager),
bedarf=bedarf.tolist(),
transport=rng.integers(200, 1800, (len(lager), len(kunden))).tolist(),
)
# --- 2. Der eine Baustein, der sich aendert: der Modellbauer -----------------
def baue_und_loese_mit_cpsat(problem: Standortproblem) -> Loesung:
"""CP-SAT: Bool-Variablen, ganzzahlige Koeffizienten, Minimize."""
from ortools.sat.python import cp_model
n, m = len(problem.lager), len(problem.kunden)
modell = cp_model.CpModel()
y = [modell.NewBoolVar(f"offen_{i}") for i in range(n)]
x = {(i, j): modell.NewBoolVar(f"liefert_{i}_{j}")
for i in range(n) for j in range(m)}
for j in range(m): # jeder Kunde genau einmal
modell.AddExactlyOne(x[i, j] for i in range(n))
for i in range(n):
for j in range(m): # nur aus offenen Lagern
modell.AddImplication(x[i, j], y[i])
modell.Add(sum(problem.bedarf[j] * x[i, j] for j in range(m))
<= problem.kapazitaet[i] * y[i]) # Kapazitaet
modell.Minimize(
sum(problem.fixkosten[i] * y[i] for i in range(n))
+ sum(problem.transport[i][j] * x[i, j] for i in range(n) for j in range(m)))
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = ZEITLIMIT
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1
status = status_von_cpsat(loeser.Solve(modell))
if not status.brauchbar:
return Loesung(status=status, laufzeit=loeser.WallTime())
return Loesung(
status=status,
werte={problem.schluessel(i, j): float(loeser.Value(x[i, j]))
for i in range(n) for j in range(m)},
zielwert=loeser.ObjectiveValue(),
schranke=loeser.BestObjectiveBound(),
laufzeit=loeser.WallTime())
def baue_und_loese_mit_highs(problem: Standortproblem) -> Loesung:
"""HiGHS: dieselben Restriktionen als Ungleichungszeilen einer Matrix."""
import highspy
n, m = len(problem.lager), len(problem.kunden)
anzahl_x = n * m # Spalten 0..n*m-1
spalte_y = lambda i: anzahl_x + i # danach die y_i # noqa: E731
modell = highspy.Highs()
modell.setOptionValue("output_flag", False)
modell.setOptionValue("time_limit", ZEITLIMIT)
modell.addVars(anzahl_x + n, np.zeros(anzahl_x + n), np.ones(anzahl_x + n))
for spalte in range(anzahl_x + n):
modell.changeColIntegrality(spalte, highspy.HighsVarType.kInteger)
for i in range(n):
modell.changeColCost(spalte_y(i), float(problem.fixkosten[i]))
for j in range(m):
modell.changeColCost(i * m + j, float(problem.transport[i][j]))
for j in range(m): # jeder Kunde genau einmal
index = np.array([i * m + j for i in range(n)], dtype=np.int32)
modell.addRow(1.0, 1.0, n, index, np.ones(n))
for i in range(n):
for j in range(m): # x_ij - y_i <= 0
modell.addRow(-highspy.kHighsInf, 0.0, 2,
np.array([i * m + j, spalte_y(i)], dtype=np.int32),
np.array([1.0, -1.0]))
index = np.array([i * m + j for j in range(m)] + [spalte_y(i)],
dtype=np.int32) # Kapazitaet
werte = np.concatenate([np.array(problem.bedarf, dtype=float),
[-float(problem.kapazitaet[i])]])
modell.addRow(-highspy.kHighsInf, 0.0, m + 1, index, werte)
t0 = time.perf_counter()
modell.run()
laufzeit = time.perf_counter() - t0
status = status_von_highs(modell.modelStatusToString(modell.getModelStatus()))
if not status.brauchbar:
return Loesung(status=status, laufzeit=laufzeit)
loesungswerte = modell.getSolution().col_value
info = modell.getInfo()
return Loesung(
status=status,
werte={problem.schluessel(i, j): float(loesungswerte[i * m + j])
for i in range(n) for j in range(m)},
zielwert=info.objective_function_value,
schranke=info.mip_dual_bound,
laufzeit=laufzeit)
MODELLBAUER = {"cpsat": baue_und_loese_mit_cpsat, "highs": baue_und_loese_mit_highs}
# --- 3. Alles Weitere ist wieder gemeinsam -----------------------------------
def pruefe_zuordnung(problem: Standortproblem, loesung: Loesung,
toleranz: float = 1e-6) -> list[str]:
"""Prueft die Loesung gegen die Anforderung - ohne Solver, ohne Modell."""
if not loesung.status.brauchbar:
return [f"kein verwertbares Ergebnis ({loesung.status.value})"]
n, m = len(problem.lager), len(problem.kunden)
zuordnung = np.array([[loesung.werte[problem.schluessel(i, j)]
for j in range(m)] for i in range(n)])
beanstandungen: list[str] = []
if (np.abs(zuordnung - np.round(zuordnung)) > toleranz).any():
beanstandungen.append("Zuordnungen sind nicht 0/1")
zuordnung = np.round(zuordnung)
for j, kunde in enumerate(problem.kunden):
if abs(zuordnung[:, j].sum() - 1.0) > toleranz:
beanstandungen.append(f"{kunde} wird {zuordnung[:, j].sum():.0f}-mal beliefert")
beliefert = zuordnung @ np.array(problem.bedarf, dtype=float)
for i, lagername in enumerate(problem.lager):
if beliefert[i] > problem.kapazitaet[i] + toleranz:
beanstandungen.append(f"{lagername}: {beliefert[i]:.0f} Paletten ueber "
f"Kapazitaet {problem.kapazitaet[i]}")
if loesung.zielwert is not None:
offen = beliefert > toleranz
nachgerechnet = (np.array(problem.fixkosten, dtype=float) @ offen
+ (np.array(problem.transport, dtype=float) * zuordnung).sum())
if abs(nachgerechnet - loesung.zielwert) > 0.5:
beanstandungen.append(f"Zielwert {loesung.zielwert:,.0f} passt nicht zur "
f"Zuordnung (nachgerechnet {nachgerechnet:,.0f})")
return beanstandungen
def geoeffnete_lager(problem: Standortproblem, loesung: Loesung) -> list[str]:
m = len(problem.kunden)
return [name for i, name in enumerate(problem.lager)
if any(loesung.werte[problem.schluessel(i, j)] > 0.5 for j in range(m))]
def loese_in_eigenem_prozess(name: str) -> Loesung:
"""Startet dieses Programm noch einmal - mit genau einem Solverimport."""
ergebnis = subprocess.run([sys.executable, __file__, name],
capture_output=True, text=True, timeout=300)
if ergebnis.returncode != 0:
raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])
# Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.
return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])
if __name__ == "__main__":
problem = beispielproblem()
# --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------
if len(sys.argv) > 1:
print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())
sys.exit(0)
# --- Hauptprozess: beide Solver anstossen und vergleichen -------------
print("=" * 82)
print(" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE")
print("=" * 82)
print(f"Standortplanung: {len(problem.lager)} moegliche Lager, "
f"{len(problem.kunden)} Kunden, {sum(problem.bedarf)} Paletten Bedarf.")
print(f"Kapazitaet je Lager: {problem.kapazitaet[0]} Paletten "
f"-> mindestens 3 Lager noetig.\n")
loesungen: dict[str, Loesung] = {}
for name, beschriftung in [("cpsat", "OR-Tools CP-SAT"),
("highs", "HiGHS (highspy)")]:
loesung = loesungen[name] = loese_in_eigenem_prozess(name)
beanstandungen = pruefe_zuordnung(problem, loesung)
print(f"{beschriftung}")
print(f" {loesung.als_bericht()}")
print(f" eroeffnete Lager: {', '.join(geoeffnete_lager(problem, loesung))}")
print(f" Abnahmepruefung: "
f"{'bestanden' if not beanstandungen else beanstandungen}")
# --- Was der Vergleich zeigt -----------------------------------------
zielwerte = [loesung.zielwert for loesung in loesungen.values()]
print("-" * 82)
print(f"Zielwertdifferenz: {abs(zielwerte[0] - zielwerte[1]):.6f} EUR")
gleich_belegt = all(
round(loesungen["cpsat"].werte[s]) == round(loesungen["highs"].werte[s])
for s in loesungen["cpsat"].werte)
print(f"Identische Zuordnung: {'ja' if gleich_belegt else 'nein'}")
assert abs(zielwerte[0] - zielwerte[1]) < 0.5, "Die Solver widersprechen sich!"
assert all(l.status is SolverStatus.OPTIMAL for l in loesungen.values())
print("\n" + "=" * 82)
print(" WAS DER WECHSEL GEKOSTET HAT")
print("=" * 82)
print("Ausgetauscht wurde EINE Funktion. Domaenenmodell, Abnahmepruefung und")
print("Bericht sind woertlich dieselben - sie sehen den Solver nie.")
print()
print("Nicht umsonst ist der Wechsel trotzdem:")
print(" * CP-SAT rechnet ausschliesslich GANZZAHLIG. Alle Kosten sind hier")
print(" deshalb int. Wer in Euro und Cent rechnet, skaliert vorher auf Cent -")
print(" und muss das im Bericht wieder zuruecknehmen.")
print(" * HiGHS braucht die Restriktionen als Matrixzeilen, CP-SAT nimmt sie")
print(" als Ausdruecke. Das ist der Grund, warum der HiGHS-Modellbauer")
print(" laenger ist, obwohl er dasselbe Modell beschreibt.")
print(" * Beide Bibliotheken bringen eine eigene HiGHS-Kopie mit und lassen")
print(" sich nicht gemeinsam importieren - daher die zwei Prozesse.")
print()
print("Der Ertrag: Beide beweisen denselben optimalen Zielwert, und die")
print("Entscheidung zwischen ihnen ist eine Frage der Laufzeit geworden -")
print("nicht eine Frage, wie viel Code man neu schreiben muss.")
print()
print("Verglichen wird deshalb der ZIELWERT, nicht der Plan: Gibt es mehrere")
print("gleich teure Loesungen, darf jeder Solver eine andere davon liefern.")
print("Hier stimmen sie zufaellig ueberein - darauf zu testen waere trotzdem")
print("ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).")
print("=" * 82)

View file

@ -0,0 +1,292 @@
#!/usr/bin/env python3
# Spaltengenerierung.py
"""
Kapitel Dekomposition: Das Modell umbauen, statt die Loesung zu raten.
Eine Papierfabrik liefert Rollen von 5.600 mm Breite. Die Kunden bestellen
schmalere Breiten, und aus jeder Mutterrolle werden mehrere davon geschnitten.
Gesucht ist der Schnittplan, der mit den wenigsten Mutterrollen auskommt.
Der naheliegende Modellansatz - "welches Stueck kommt auf welche Rolle?" -
fuehrt in eine Sackgasse: Er braucht eine Binaervariable je Stueck-und-Rolle,
und weil alle Rollen gleich sind, ist er hochgradig symmetrisch. Der Solver
probiert dieselbe Loesung in tausend Umbenennungen durch.
Die Spaltengenerierung dreht die Frage um:
Nicht "welches Stueck auf welche Rolle",
sondern "wie oft schneide ich welches MUSTER".
Damit verschwindet die Symmetrie - aber es entsteht ein neues Problem: Die Zahl
der moeglichen Muster waechst kombinatorisch. Man kann sie nicht aufschreiben.
Der Trick besteht darin, sie auch nicht aufzuschreiben, sondern nur die wenigen
zu erzeugen, die tatsaechlich gebraucht werden - und zwar mit Hilfe der
Schattenpreise aus dem Kapitel LP.
Das Programm zeigt vier Dinge:
1. Die beiden Formulierungen desselben Problems und ihre Groesse.
2. Wie schnell die Musterzahl waechst - und wie wenige davon genuegen.
3. Die Schleife selbst: Master-LP, Dualwerte, Rucksack, neue Spalte.
4. Wann sich der Aufwand lohnt und wann nicht. Die Antwort haengt an einer
einzigen Kennzahl der Instanz, und sie erklaert nebenbei, warum eine
Faustregel bei manchen Zuschnittproblemen schon optimal ist.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import linprog
ROLLENBREITE = 5600
# Zwei Instanzen mit demselben Bedarf, aber verschiedenen Breiten. Der
# Unterschied ist die eine Kennzahl, um die es in Teil 4 geht.
BEDARF = [22, 25, 12, 14, 18, 18, 20, 10, 12, 14, 16, 18, 20]
BREIT = [1380, 1520, 1560, 1710, 1820, 1880, 1930, 2000, 2050, 2100, 2140,
2150, 2200]
SCHMAL = [380, 520, 560, 710, 820, 880, 930, 1000, 1050, 1100, 1140,
1150, 1200]
# --- Die Musterzahl ---------------------------------------------------------
def zaehle_muster(breiten, rollenbreite: int = ROLLENBREITE) -> int:
"""Wie viele zulaessige Schnittmuster gibt es ueberhaupt?
Nur zum Zeigen - in einem produktiven Programm wuerde man das nie
ausrechnen, weil die Zahl bei realistischen Instanzen jede Vorstellung
sprengt. Genau das ist der Punkt.
"""
speicher: dict[tuple[int, int], int] = {}
def ab(i: int, rest: int) -> int:
if i == len(breiten):
return 1
if (i, rest) in speicher:
return speicher[(i, rest)]
summe = sum(ab(i + 1, rest - anzahl * breiten[i])
for anzahl in range(rest // breiten[i] + 1))
speicher[(i, rest)] = summe
return summe
return ab(0, rollenbreite)
# --- Die beiden Bausteine der Schleife --------------------------------------
def loese_master(muster, bedarf, ganzzahlig: bool = False):
"""Restringiertes Master: Wie oft schneide ich jedes bekannte Muster?
min sum_p x_p unter sum_p a_ip * x_p >= bedarf_i
Als LP gerechnet liefert es zusaetzlich die DUALWERTE - und die sind der
eigentliche Ertrag: Der Dualwert zu Breite i sagt, was ein zusaetzliches
Stueck dieser Breite an Rollen kostet. Es ist derselbe Schattenpreis wie
im Kapitel LP, nur dass er hier nicht berichtet, sondern weiterverarbeitet
wird.
"""
matrix = -np.array(muster, dtype=float).T # >= wird zu <= mit Minus
ergebnis = linprog(np.ones(len(muster)),
A_ub=matrix, b_ub=-np.array(bedarf, dtype=float),
bounds=(0, None),
integrality=(1 if ganzzahlig else 0), method="highs")
if not ergebnis.success:
raise SystemExit(f"Master nicht loesbar: {ergebnis.message}")
dual = None if ganzzahlig else -ergebnis.ineqlin.marginals
return ergebnis.fun, ergebnis.x, dual
def bestes_neues_muster(dual, breiten, rollenbreite: int = ROLLENBREITE):
"""Pricing: Gibt es ein Muster, das sich noch lohnt?
Gesucht ist das Muster mit dem groessten Gesamtwert zu den aktuellen
Schattenpreisen - unter der Bedingung, dass es in die Rolle passt. Das ist
ein RUCKSACKPROBLEM (Kapitel MILP, Rucksack.py): Preise sind der Nutzen,
Breiten das Gewicht, die Rollenbreite die Kapazitaet. Hier mit dynamischer
Programmierung geloest, weil die Breiten ganzzahlig sind.
Bei Gleichstand wird bewusst der KLEINSTE Index bevorzugt (>-Vergleich mit
Toleranz statt >=). Sonst haengt das erzeugte Muster von der Reihenfolge
der Gleitkommaoperationen ab, und das Programm liefert von Lauf zu Lauf
verschiedene Ausgaben.
"""
wert = np.zeros(rollenbreite + 1)
herkunft = [-1] * (rollenbreite + 1)
for platz in range(rollenbreite + 1):
for i, breite in enumerate(breiten):
if breite <= platz and wert[platz - breite] + dual[i] > wert[platz] + 1e-9:
wert[platz] = wert[platz - breite] + dual[i]
herkunft[platz] = i
muster = [0] * len(breiten)
platz = rollenbreite
while herkunft[platz] >= 0:
i = herkunft[platz]
muster[i] += 1
platz -= breiten[i]
return float(wert[rollenbreite]), muster
def spaltengenerierung(breiten, bedarf, rollenbreite: int = ROLLENBREITE):
"""Die Schleife. Startbasis: je ein Muster mit nur einer Breite.
Abbruch, wenn kein Muster mehr einen Wert ueber 1 hat: Eine zusaetzliche
Rolle kostet 1, also lohnt sich ein neues Muster nur, wenn es zu den
aktuellen Preisen mehr als 1 wert ist. Das ist das Kriterium der
reduzierten Kosten.
"""
anzahl = len(breiten)
muster = [[0] * anzahl for _ in range(anzahl)]
for i in range(anzahl):
muster[i][i] = rollenbreite // breiten[i]
runden = 0
while True:
zielwert, _, dual = loese_master(muster, bedarf)
wert, neu = bestes_neues_muster(dual, breiten, rollenbreite)
runden += 1
if wert <= 1 + 1e-6:
return muster, zielwert, runden
muster.append(neu)
def first_fit(breiten, bedarf, rollenbreite: int = ROLLENBREITE) -> int:
"""Die Faustregel: groesstes Stueck zuerst, auf die erste passende Rolle."""
stuecke = sorted([b for b, menge in zip(breiten, bedarf)
for _ in range(menge)], reverse=True)
rollen: list[list[int]] = []
for stueck in stuecke:
for rolle in rollen:
if sum(rolle) + stueck <= rollenbreite:
rolle.append(stueck)
break
else:
rollen.append([stueck])
return len(rollen)
def auswerten(name: str, breiten, bedarf) -> dict:
muster, schranke, runden = spaltengenerierung(breiten, bedarf)
ganz, _, _ = loese_master(muster, bedarf, ganzzahlig=True)
return {"name": name, "muster_gesamt": zaehle_muster(breiten),
"muster_erzeugt": len(muster), "runden": runden,
"schranke": schranke, "ganzzahlig": int(round(ganz)),
"first_fit": first_fit(breiten, bedarf),
"stuecke_je_rolle": ROLLENBREITE / np.mean(breiten)}
if __name__ == "__main__":
print("=" * 84)
print(" SPALTENGENERIERUNG: NUR DIE MUSTER ERZEUGEN, DIE MAN BRAUCHT")
print("=" * 84)
print(f"Mutterrolle {ROLLENBREITE:,} mm, {len(BREIT)} bestellte Breiten, "
f"{sum(BEDARF)} Zuschnitte.\n")
# --- 1. Zwei Formulierungen ------------------------------------------
stuecke = sum(BEDARF)
# Wie viele Rollen braucht man hoechstens? Die Faustregel liefert eine
# brauchbare Obergrenze - mehr Rollen als das wird niemand benoetigen.
rollen_obergrenze = first_fit(BREIT, BEDARF)
print("1. Zwei Modelle fuer dieselbe Aufgabe\n")
print(f" {'Formulierung':<34} {'Binaervariablen':>16} {'symmetrisch?':>16}")
print(" " + "-" * 70)
print(f" {'Stueck -> Rolle (naheliegend)':<34} "
f"{stuecke * rollen_obergrenze:>16,} {'ja':>16}")
print(f" {'wie oft welches Muster':<34} "
f"{'eine je Muster':>16} {'nein':>16}")
print(f"\n Der naheliegende Ansatz braucht eine Variable je Stueck und Rolle:")
print(f" {stuecke} Zuschnitte x {rollen_obergrenze} Rollen (Obergrenze aus der Faustregel)")
print(f" = {stuecke * rollen_obergrenze:,} Binaervariablen.")
print("\n Schlimmer als die Zahl ist die Symmetrie: Alle Mutterrollen sind")
print(" gleich, also beschreibt jede Loesung dieselbe Schnittvorschrift in")
print(" unzaehligen Umbenennungen. Branch-and-Bound probiert sie einzeln")
print(" durch und kommt nicht voran - der Suchbaum waechst, ohne dass sich")
print(" die Schranke bewegt.")
print("\n Das Mustermodell hat je Muster genau eine Variable und kennt keine")
print(" einzelnen Rollen mehr. Damit ist die Symmetrie weg. Sein Problem ist")
print(" ein anderes - und zwar das folgende.")
# --- 2. Die Musterzahl ------------------------------------------------
print("\n" + "-" * 84)
print("2. Warum man die Muster nicht aufschreiben kann\n")
gross = auswerten("breite Zuschnitte (1.380-2.200 mm)", BREIT, BEDARF)
schmal = auswerten("schmale Zuschnitte (380-1.200 mm)", SCHMAL, BEDARF)
print(f" {'Instanz':<36} {'Muster':>12} {'davon erzeugt':>14} {'Anteil':>9}")
print(" " + "-" * 76)
for fall in (gross, schmal):
anteil = fall["muster_erzeugt"] / fall["muster_gesamt"]
print(f" {fall['name']:<36} {fall['muster_gesamt']:>12,} "
f"{fall['muster_erzeugt']:>14} {anteil:>8.1%}")
print(f"\n Schon das Halbieren der Breiten laesst die Musterzahl von "
f"{gross['muster_gesamt']:,} auf")
print(f" {schmal['muster_gesamt']:,} springen - Faktor "
f"{schmal['muster_gesamt'] / gross['muster_gesamt']:.0f}. Bei einer echten "
f"Papierfabrik mit")
print(" vierzig Breiten und Millimeterschritten sind es mehr, als sich")
print(" speichern liesse.")
print(f"\n Gebraucht werden davon {schmal['muster_erzeugt']} - "
f"{schmal['muster_erzeugt'] / schmal['muster_gesamt']:.2%} der Gesamtzahl.")
# --- 3. Die Schleife --------------------------------------------------
print("\n" + "-" * 84)
print("3. Die Schleife an der breiten Instanz\n")
print(f" Startbasis: {len(BREIT)} triviale Muster (je Rolle nur eine Breite)")
print(f" Runden bis kein Muster mehr lohnt: {gross['runden']}")
print(f" Muster am Ende: {gross['muster_erzeugt']}")
print(f" LP-Schranke: {gross['schranke']:.4f} Rollen")
print(f"\n Die LP-Schranke ist eine ZUSAGE: Weniger als "
f"{np.ceil(gross['schranke'] - 1e-9):.0f} Rollen sind")
print(" nicht moeglich - unabhaengig davon, wie clever man weiterschneidet.")
print(" Genau diese Aussage fehlt einer Heuristik (Kapitel Metaheuristiken).")
# --- 4. Wann es sich lohnt --------------------------------------------
print("\n" + "-" * 84)
print("4. Wann sich der Aufwand lohnt - und wann nicht\n")
print(f" {'Instanz':<36} {'Faustregel':>11} {'exakt':>8} {'Schranke':>10} "
f"{'Ersparnis':>11}")
print(" " + "-" * 80)
for fall in (gross, schmal):
ersparnis = (fall["first_fit"] - fall["ganzzahlig"]) / fall["first_fit"]
print(f" {fall['name']:<36} {fall['first_fit']:>11} "
f"{fall['ganzzahlig']:>8} {fall['schranke']:>10.2f} "
f"{ersparnis:>10.0%}")
print(f"\n Bei den breiten Zuschnitten spart die Spaltengenerierung "
f"{gross['first_fit'] - gross['ganzzahlig']} von")
print(f" {gross['first_fit']} Rollen. Bei den schmalen spart sie NICHTS - dort ist die")
print(" Faustregel bereits optimal.")
print(f"\n Der Unterschied haengt an einer einzigen Kennzahl:\n")
print(f" {'Instanz':<36} {'Stuecke je Rolle (etwa)':>24}")
print(" " + "-" * 62)
for fall in (gross, schmal):
print(f" {fall['name']:<36} {fall['stuecke_je_rolle']:>24.1f}")
print("\n Passen nur zwei bis drei Stuecke auf eine Rolle, entscheidet jede")
print(" einzelne Zuordnung viel, und eine kurzsichtige Regel verschenkt")
print(" ganze Rollen. Passen sechs oder mehr darauf, gleichen sich die")
print(" Fehler aus - die Reste sind klein gegen die Rollenbreite, und die")
print(" Faustregel trifft es fast immer.")
print("\n" + "=" * 84)
print(" WAS MAN DARAUS MITNIMMT")
print("=" * 84)
print("1. Der Perspektivwechsel ist die eigentliche Arbeit: nicht 'welches")
print(" Stueck wohin', sondern 'wie oft welches Muster'. Damit verschwindet")
print(" die Symmetrie, die das naheliegende Modell unloesbar macht.")
print("2. Der Preis dafuer ist eine unaufschreibbare Zahl von Variablen. Die")
print(" Spaltengenerierung zahlt ihn nicht, sondern erzeugt nur die wenigen")
print(" Spalten, die die Dualwerte als lohnend ausweisen.")
print("3. Das Teilproblem ist ein Rucksack - ein Verfahren, das die Leser")
print(" dieses Buchs schon kennen. Neu ist allein die Schleife.")
print("4. Und die unbequeme Erkenntnis: Ob sich das alles lohnt, entscheidet")
print(" die Instanz, nicht die Methode. Bei sechs Stuecken je Rolle ist die")
print(" Faustregel so gut wie das Optimum - und der Ertrag der")
print(" Spaltengenerierung liegt dann allein in der SCHRANKE, die beweist,")
print(" dass man aufhoeren kann zu suchen.")
print("=" * 84)

View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
# Stochastische_Optimierung.py
"""
Kapitel Unsicherheit: Two-Stage Stochastic Programming mit CVXPY.
Eigenschaften:
* Die Analyse am Ende wird BERECHNET statt fest verdrahtet
* Vergleich gegen drei Alternativen: Mittelwert, Worst Case, perfekte Voraussicht
* Kennzahl EVPI (Wert perfekter Information) wird ausgewiesen
"""
import cvxpy as cp
import numpy as np
import pandas as pd
SZENARIEN = ["Ruhig", "Volatil", "Crash"]
WAHRSCHEINLICHKEIT = np.array([0.50, 0.30, 0.20])
BEDARF = np.array([100.0, 250.0, 500.0])
KOSTEN_VORAB = 40.0
KOSTEN_SPOT = 120.0
KOSTEN_LEERLAUF = 5.0
S = len(SZENARIEN)
def loese_stochastisch():
"""Zweistufiges Modell: eine Vorabentscheidung, szenarioabhaengige Korrektur."""
x = cp.Variable(nonneg=True, name="Basiskapazitaet") # Stufe 1
y_spot = cp.Variable(S, nonneg=True, name="Spot_Zukauf") # Stufe 2
y_leer = cp.Variable(S, nonneg=True, name="Leerlauf") # Stufe 2
# Kopplung: Basis + Zukauf - Leerlauf == Bedarf (je Szenario)
nebenbedingungen = [x + y_spot[s] - y_leer[s] == BEDARF[s] for s in range(S)]
erwartete_korrektur = sum(
WAHRSCHEINLICHKEIT[s] * (KOSTEN_SPOT * y_spot[s] + KOSTEN_LEERLAUF * y_leer[s])
for s in range(S))
problem = cp.Problem(cp.Minimize(KOSTEN_VORAB * x + erwartete_korrektur),
nebenbedingungen)
problem.solve()
return problem, x, y_spot, y_leer
def kosten_bei(kapazitaet):
"""Erwartete Gesamtkosten fuer eine fest vorgegebene Kapazitaet."""
unter = np.maximum(BEDARF - kapazitaet, 0.0)
ueber = np.maximum(kapazitaet - BEDARF, 0.0)
je_szenario = KOSTEN_VORAB * kapazitaet + KOSTEN_SPOT * unter + KOSTEN_LEERLAUF * ueber
return float(WAHRSCHEINLICHKEIT @ je_szenario)
if __name__ == "__main__":
problem, x, y_spot, y_leer = loese_stochastisch()
kapazitaet = float(x.value)
mittelwert = float(WAHRSCHEINLICHKEIT @ BEDARF)
print("=" * 82)
print(" STOCHASTISCHE TWO-STAGE OPTIMIERUNG (KAPAZITAETSPLANUNG)")
print("=" * 82)
print(f"Status: {problem.status}")
print(f"Erwarteter Bedarf (Mittelwert): {mittelwert:.1f} Einheiten")
print(f"Optimale Stufe-1-Kapazitaet x*: {kapazitaet:.1f} Einheiten")
print(f"Minimale erwartete Gesamtkosten: {problem.value:,.2f} EUR\n")
tabelle = pd.DataFrame({
"Szenario": SZENARIEN,
"Wahrsch.": [f"{p*100:.0f} %" for p in WAHRSCHEINLICHKEIT],
"Bedarf": BEDARF,
"Basis genutzt": [min(kapazitaet, b) for b in BEDARF],
"Spot-Zukauf": np.round(y_spot.value, 1),
"Leerlauf": np.round(y_leer.value, 1),
"Kosten (EUR)": [f"{KOSTEN_VORAB*kapazitaet + KOSTEN_SPOT*y_spot.value[s] + KOSTEN_LEERLAUF*y_leer.value[s]:,.0f}"
for s in range(S)],
})
print(tabelle.to_string(index=False))
# --- Vergleich mit Alternativstrategien (berechnet, nicht behauptet) --
print("\n" + "-" * 82)
print("Vergleich verschiedener Planungsstrategien:")
print(f"{'Strategie':<34} {'Kapazitaet':>11} {'Erw. Kosten':>14} {'Mehrkosten':>13}")
print("-" * 82)
optimal = problem.value
strategien = [
("Stochastisch optimal", kapazitaet),
("Naiv: Mittelwert einsetzen", mittelwert),
("Vorsichtig: Worst Case abdecken", float(BEDARF.max())),
("Optimistisch: Bestfall", float(BEDARF.min())),
]
for name, kap in strategien:
kosten = kosten_bei(kap)
print(f"{name:<34} {kap:>11.1f} {kosten:>14,.0f} "
f"{kosten - optimal:>+13,.0f}")
# --- EVPI: Was waere perfekte Voraussicht wert? ----------------------
# Bei perfekter Information wuerde man je Szenario genau den Bedarf kaufen.
kosten_perfekt = float(WAHRSCHEINLICHKEIT @ (KOSTEN_VORAB * BEDARF))
evpi = optimal - kosten_perfekt
print("-" * 82)
print(f"Kosten bei perfekter Voraussicht: {kosten_perfekt:>10,.0f} EUR")
print(f"Wert perfekter Information (EVPI): {evpi:>10,.0f} EUR "
f"({evpi/optimal*100:.1f} % der Kosten)")
print(" -> So viel duerfte eine perfekte Bedarfsprognose hoechstens kosten.")
# --- Automatische Interpretation --------------------------------------
print("-" * 82)
if kapazitaet > mittelwert + 1e-6:
print(f"Analyse: Der Solver waehlt {kapazitaet:.0f} Einheiten und damit MEHR als")
print(f"den Mittelwert ({mittelwert:.0f}), weil Unterdeckung ({KOSTEN_SPOT:.0f} EUR)")
print(f"deutlich teurer ist als Leerlauf ({KOSTEN_LEERLAUF:.0f} EUR).")
elif kapazitaet < mittelwert - 1e-6:
print(f"Analyse: Der Solver waehlt {kapazitaet:.0f} und damit WENIGER als den")
print(f"Mittelwert ({mittelwert:.0f}) - Leerlauf ist hier teurer als Zukauf.")
else:
print("Analyse: Kapazitaet entspricht dem Mittelwert (symmetrische Kosten).")
print("=" * 82)

View file

@ -0,0 +1,132 @@
#!/usr/bin/env python3
# Strafgewichte.py
"""
Kapitel CP-SAT: Warum ein Strafgewicht keine Wichtigkeit ausdrueckt, sondern
einen Wechselkurs.
Weiche Regeln kommen als Strafterm in die Zielfunktion. Die Frage, die dabei
regelmaessig falsch beantwortet wird, lautet: "Wie gross muss die Strafe sein?"
Die uebliche Antwort - "je wichtiger, desto groesser" - klingt vernuenftig und
fuehrt zuverlaessig zu Plaenen, die niemand unterschreiben will.
Der Grund: Zwei Strafgewichte legen nicht Wichtigkeiten fest, sondern einen
UMRECHNUNGSKURS. Steht die Serienregel bei 1 und der Wunschfrei-Tag bei 10,
dann hat man dem Solver woertlich gesagt: "Zehn Verstoesse gegen die
Serienregel sind so schlimm wie ein abgelehnter Wunsch." Er nimmt das
ernst - und handelt danach.
Szenario: Schichtplan, 6 Personen, 14 Tage, 4 Besetzungen pro Tag.
HART jeder Tag genau 4 Personen; hoechstens 11 Dienste je Person
WEICH keine drei Dienste in Folge (Betriebsvereinbarung)
WEICH Wunschfrei-Tage einhalten (26 Antraege)
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import numpy as np
from ortools.sat.python import cp_model
PERSONEN, TAGE, PRO_TAG = 6, 14, 4
MAX_DIENSTE = 11
STRAFE_WUNSCH = 10 # bleibt fest - variiert wird die Serienstrafe
RNG = np.random.default_rng(2)
WUNSCHFREI = sorted({(p, t) for p in range(PERSONEN) for t in range(TAGE)
if RNG.random() < 0.35})
def plane(strafe_serie: int, zeitlimit: float = 25.0) -> dict:
"""Baut den Schichtplan mit dem angegebenen Gewicht fuer die Serienregel."""
modell = cp_model.CpModel()
x = {(p, t): modell.NewBoolVar(f"x_{p}_{t}")
for p in range(PERSONEN) for t in range(TAGE)}
# --- harte Regeln --------------------------------------------------
for t in range(TAGE):
modell.Add(sum(x[p, t] for p in range(PERSONEN)) == PRO_TAG)
for p in range(PERSONEN):
modell.Add(sum(x[p, t] for t in range(TAGE)) <= MAX_DIENSTE)
# --- weiche Regel 1: keine drei Dienste in Folge --------------------
# serie[p, t] = 1 <=> Person p arbeitet an t, t+1 UND t+2.
# Die Ungleichung erzwingt das nur in eine Richtung; das genuegt, weil
# der Solver serie minimiert - er wuerde die Variable nie freiwillig
# auf 1 setzen.
strafterme = []
serien = []
for p in range(PERSONEN):
for t in range(TAGE - 2):
serie = modell.NewBoolVar(f"serie_{p}_{t}")
modell.Add(x[p, t] + x[p, t + 1] + x[p, t + 2] <= 2 + serie)
serien.append(serie)
strafterme.append(serie * strafe_serie)
# --- weiche Regel 2: Wunschfrei ------------------------------------
wunschverstoesse = []
for p, t in WUNSCHFREI:
wunschverstoesse.append(x[p, t])
strafterme.append(x[p, t] * STRAFE_WUNSCH)
modell.Minimize(sum(strafterme))
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = zeitlimit
loeser.parameters.num_workers = 1 # reproduzierbare Buchausgabe
loeser.parameters.random_seed = 1
status = loeser.Solve(modell)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
raise RuntimeError(f"Kein Plan: {loeser.StatusName(status)}")
return {
"status": loeser.StatusName(status),
"zielwert": loeser.ObjectiveValue(),
"serien": sum(loeser.Value(v) for v in serien),
"wunsch_verletzt": sum(loeser.Value(v) for v in wunschverstoesse),
}
if __name__ == "__main__":
print("=" * 78)
print(" STRAFGEWICHTE SIND WECHSELKURSE, KEINE WICHTIGKEITEN")
print("=" * 78)
print(f"{PERSONEN} Personen, {TAGE} Tage, {PRO_TAG} Besetzungen pro Tag "
f"= {TAGE * PRO_TAG} Dienste")
print(f"Das sind {TAGE * PRO_TAG / PERSONEN:.1f} Dienste je Person - der Plan ist eng.")
print(f"{len(WUNSCHFREI)} Wunschfrei-Antraege, Strafe je abgelehntem Wunsch: "
f"{STRAFE_WUNSCH}\n")
print(f"{'Strafe je 3er-Serie':>20} {'3er-Serien':>12} {'Wunsch verletzt':>17} "
f"{'Zielwert':>10}")
print("-" * 78)
ergebnisse = {}
for strafe in (1, 2, 5, 10, 30, 100):
e = plane(strafe)
ergebnisse[strafe] = e
print(f"{strafe:>20} {e['serien']:>12} {e['wunsch_verletzt']:>17} "
f"{e['zielwert']:>10.0f}")
print("-" * 78)
niedrig, hoch = ergebnisse[1], ergebnisse[30]
print(f"\nBei Strafe 1 baut der Solver {niedrig['serien']} Drei-Tage-Serien ein.")
print("Die Betriebsvereinbarung steht im Modell - und wird trotzdem")
print("systematisch verletzt. Das ist kein Fehler des Solvers: Bei einem")
print(f"Gewicht von 1 gegen {STRAFE_WUNSCH} lohnt sich jede Serie, solange sie")
print("auch nur einen Zehntel-Wunsch rettet.")
print(f"\nBei Strafe 30 sind es {hoch['serien']} Serien - dafuer werden")
print(f"{hoch['wunsch_verletzt']} statt {niedrig['wunsch_verletzt']} Wuensche abgelehnt.")
print("Beide Plaene sind optimal. Sie beantworten nur verschiedene Fragen.")
print()
print("Die Frage lautet also nie 'wie wichtig ist mir diese Regel?', sondern:")
print(" 'Wie viele abgelehnte Wuensche bin ich bereit zu akzeptieren,")
print(" um eine Drei-Tage-Serie zu vermeiden?'")
print("Wer darauf keine Zahl nennen kann, hat das Problem noch nicht")
print("verstanden - und sollte diese Tabelle dem Auftraggeber vorlegen,")
print("statt ein Gewicht zu raten.")
print()
print("Und die wichtigste Konsequenz: Eine Regel, die NIE gebrochen werden")
print("darf, gehoert nicht in die Zielfunktion, sondern unter die harten")
print("Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.")
print("=" * 78)

View file

@ -0,0 +1,275 @@
#!/usr/bin/env python3
# Strukturbruecke.py
"""
Kapitel Bruecke: Derselbe Code, zwei Welten - der Beweis statt der Behauptung.
Der Teil-Auftakt stellt eine Tabelle auf: "Ressourcen auf Produkte verteilen"
entspreche "Kapital auf Anlagen verteilen", "gegen den Worst Case absichern"
entspreche "Absicherung gegen Kursabstuerze". Solche Tabellen stehen in vielen
Buechern. Sie sind billig - und man kann sie pruefen.
Dieses Programm prueft sie. Es fuettert zweimal DENSELBEN Code mit Daten aus
zwei Welten und zeigt die Ergebnisse nebeneinander:
1. Die Allokation als LP. Das Domaenenmodell aus or_kern.py, einmal mit einer
Schreinerei (Montagestunden, Plattenmaterial) und einmal mit einem Depot
(Kapital, Risikobudget). Gleiche Klasse, gleicher Modellbauer, gleiche
Abnahmepruefung - und der Schattenpreis heisst in der einen Welt
"Wert einer zusaetzlichen Montagestunde" und in der anderen "Preis des
Risikos".
2. Die Absicherung gegen den schlechtesten Fall als CVaR. EINE Funktion,
einmal mit Kursrenditen und einmal mit Lieferverzuegen.
3. Was NICHT hinueberreicht. Der ehrliche Teil: Drei Unterschiede, die die
Analogie begrenzen - und die man kennen muss, bevor man Methoden aus
der einen Welt in die andere traegt.
ZUR SOLVERWAHL: Teil 1 benutzt 'loese_mit_scipy' und nicht 'loese_mit_glop'.
Das ist kein Zufall - CVXPY laedt fuer Teil 2 highspy, und ortools vertraegt
sich damit nicht im selben Prozess (Kapitel Oekosystem). Wer hier GLOP nimmt,
bekommt beim cvxpy-Import eine Fehlermeldung ueber ein 'undefined symbol'.
Genau deshalb laedt or_kern.py seine Solver erst beim Aufruf.
Benoetigt: numpy, cvxpy, scipy und pydantic (ueber or_kern)
"""
from __future__ import annotations
import numpy as np
from or_kern import (Produkt, Produktionsproblem, loese_mit_scipy,
pruefe_loesung)
SAAT = 7
SZENARIEN = 500
ALPHA = 0.95 # die schlechtesten 5 % der Faelle
MAX_ANTEIL = 0.40 # Streuungsgebot in beiden Welten
# --- Teil 1: Dieselbe Klasse, zwei Welten ----------------------------------
def schreinerei() -> Produktionsproblem:
"""Montagestunden und Plattenmaterial auf Tische und Stuehle verteilen."""
return Produktionsproblem(
produkte=[
Produkt(name="Tisch", deckungsbeitrag=240.0,
verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
Produkt(name="Stuhl", deckungsbeitrag=60.0,
verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
Produkt(name="Regal", deckungsbeitrag=130.0,
verbrauch={"Montagestunden": 2.0, "Plattenmaterial": 4.0}),
],
kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
def depot() -> Produktionsproblem:
"""Kapital und Risikobudget auf Anlageklassen verteilen.
Eine "Einheit" ist hier 1.000 EUR Anlagesumme. Der Deckungsbeitrag ist der
erwartete Jahresertrag dieser Einheit, der Verbrauch die beanspruchte
Kapital- und Risikomenge. Das ist keine Analogie, sondern buchstaeblich
dasselbe Modell - deshalb passt es in dieselbe Klasse.
"""
return Produktionsproblem(
produkte=[
Produkt(name="Aktien Welt", deckungsbeitrag=75.0,
verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 1.00}),
Produkt(name="Anleihen", deckungsbeitrag=28.0,
verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.22}),
Produkt(name="Immobilienfonds", deckungsbeitrag=46.0,
verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.55}),
],
kapazitaeten={"Kapital (Tsd. EUR)": 150.0, "Risikobudget": 90.0})
def berichte_allokation(titel: str, problem: Produktionsproblem,
einheit: str, ertragsname: str) -> None:
"""Ein Bericht fuer beide Welten - nur die Beschriftung wechselt."""
loesung = loese_mit_scipy(problem)
beanstandungen = pruefe_loesung(problem, loesung)
print(f" {titel}")
print(f" {loesung.als_bericht()}")
for produkt in problem.produkte:
print(f" {produkt.name:<18} {loesung.werte[produkt.name]:8.2f} {einheit}")
print(f" {ertragsname:<18} {loesung.zielwert:8.2f} EUR")
for ressource, preis in loesung.schattenpreise.items():
print(f" Schattenpreis {ressource:<24} {preis:7.2f} EUR")
print(f" Abnahmepruefung: "
f"{'bestanden' if not beanstandungen else beanstandungen}")
# --- Teil 2: Eine CVaR-Funktion, zwei Welten -------------------------------
def optimiere_cvar(verluste: np.ndarray, ertrag: np.ndarray,
mindestertrag: float, alpha: float = ALPHA,
max_anteil: float = MAX_ANTEIL):
"""Minimiert den CVaR der Verluste unter einer Mindestertragsbedingung.
'verluste' hat die Form (Szenarien x Optionen) und enthaelt, was in
Szenario s passiert, wenn eine Einheit in Option j steckt. Was ein
"Verlust" ist, entscheidet allein die Einheit der Matrix: Prozentpunkte
Kursverlust oder Tage Lieferverzug - die Formel sieht keinen Unterschied.
Das ist die Rockafellar-Uryasev-Formulierung aus dem Kapitel CVaR, hier
ohne jede Aenderung wiederverwendet.
"""
import cvxpy as cp
anzahl_szenarien, anzahl_optionen = verluste.shape
anteil = cp.Variable(anzahl_optionen, nonneg=True)
schwelle = cp.Variable() # wird im Optimum zum VaR
ueberschuss = cp.Variable(anzahl_szenarien, nonneg=True)
cvar = schwelle + (1.0 / (anzahl_szenarien * (1 - alpha))) * cp.sum(ueberschuss)
problem = cp.Problem(
cp.Minimize(cvar),
[ueberschuss >= verluste @ anteil - schwelle,
cp.sum(anteil) == 1,
anteil <= max_anteil,
ertrag @ anteil >= mindestertrag])
problem.solve()
if problem.status not in ("optimal", "optimal_inaccurate"):
raise SystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
return anteil.value, float(cvar.value), float(schwelle.value)
def kursszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:
"""Taegliche Verluste (negative Renditen) von sechs Anlageklassen."""
namen = ["Aktien Welt", "Aktien EU", "Schwellenlaender",
"Staatsanleihen", "Unternehmensanl.", "Rohstoffe"]
rendite_pa = np.array([0.080, 0.065, 0.110, 0.025, 0.045, 0.070])
schwankung = np.array([0.180, 0.160, 0.260, 0.040, 0.075, 0.210])
taeglich = rng.normal(rendite_pa / 252, schwankung / np.sqrt(252),
(SZENARIEN, len(namen)))
return -taeglich * 100.0, rendite_pa * 100.0, namen
def lieferszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:
"""Lieferverzug in Tagen bei sechs Lieferanten.
Der Aufbau ist bewusst anders als bei den Kursen: Hier gibt es einen
normalen Verzug UND seltene Totalausfaelle, die 14 Tage kosten. Das ist
genau die Art fetter Raender, wegen der man in beiden Welten CVaR statt
Standardabweichung benutzt.
"""
namen = ["Nordwerk", "Sued-Metall", "Fernost A", "Lokalzulieferer",
"Fernost B", "Osteuropa"]
zuverlaessigkeit = np.array([0.94, 0.97, 0.90, 0.99, 0.92, 0.95])
verzugsstreuung = np.array([3.5, 1.8, 5.0, 0.9, 4.2, 2.8])
normaler_verzug = np.maximum(0.0, rng.normal(0.5, 1.0, (SZENARIEN, len(namen)))
* verzugsstreuung)
totalausfall = (rng.random((SZENARIEN, len(namen))) > zuverlaessigkeit) * 14.0
return normaler_verzug + totalausfall, zuverlaessigkeit, namen
def berichte_cvar(titel: str, verluste, ertrag, namen, mindestertrag,
einheit: str, ertragsname: str):
anteile, cvar, var = optimiere_cvar(verluste, ertrag, mindestertrag)
mittel = float((verluste @ anteile).mean())
print(f" {titel}")
print(f" VaR {ALPHA:.0%}: {var:8.3f} {einheit}")
print(f" CVaR {ALPHA:.0%}: {cvar:8.3f} {einheit} "
f"(Mittelwert ueber alle Szenarien: {mittel:.3f})")
print(f" {ertragsname}: {float(ertrag @ anteile):.3f}")
print(" Aufteilung:")
for name, anteil in zip(namen, anteile):
balken = "#" * int(round(anteil * 40))
grenze = " <- an der Streuungsgrenze" if anteil > MAX_ANTEIL - 1e-4 else ""
print(f" {name:<18} {anteil:6.1%} {balken}{grenze}")
return anteile
if __name__ == "__main__":
rng = np.random.default_rng(SAAT)
print("=" * 80)
print(" DIESELBE STRUKTUR, ZWEI WELTEN")
print("=" * 80)
# --- Teil 1 ----------------------------------------------------------
print("\n1. Allokation als LP - EINE Klasse, EIN Modellbauer\n")
berichte_allokation("Werkstatt: Produktionsprogramm", schreinerei(),
"Stueck", "Deckungsbeitrag")
print()
berichte_allokation("Depot: Anlageaufteilung", depot(),
"Tsd. ", "Erwarteter Ertrag")
print("\n Beide Ausgaben stammen aus derselben Funktion "
"'berichte_allokation'.")
print(" Ausgetauscht wurden nur die Daten und die Beschriftungen -")
print(" keine Zeile Modellcode.")
print()
print(" Lesen Sie die Schattenpreise nebeneinander: In der Werkstatt sagt")
print(" er, was eine zusaetzliche Montagestunde wert waere. Im Depot sagt")
print(" dieselbe Zahl, was eine zusaetzliche Einheit Risikobudget wert")
print(" waere - der PREIS DES RISIKOS. Das ist kein Sprachbild, sondern")
print(" derselbe Dualwert derselben Nebenbedingung.")
# --- Teil 2 ----------------------------------------------------------
print("\n" + "-" * 80)
print("2. Absicherung gegen den schlechtesten Fall - EINE CVaR-Funktion\n")
kurse, rendite, anlagen = kursszenarien(rng)
anteile_depot = berichte_cvar(
"Depot: die schlechtesten 5 % der Handelstage",
kurse, rendite, anlagen, 5.5,
"% je Tag ", "Erwartete Jahresrendite (%)")
print()
lieferung, zuverlaessig, lieferanten = lieferszenarien(rng)
anteile_einkauf = berichte_cvar("Einkauf: die schlechtesten 5 % der Bestellungen",
lieferung, zuverlaessig, lieferanten, 0.945,
"Tage Verzug ", "Mittlere Zuverlaessigkeit")
print("\n Auch hier: eine Funktion, zwei Aufrufe. Die Zielfunktion")
print(" interessiert sich nicht dafuer, ob in der Matrix Prozentpunkte")
print(" oder Tage stehen.")
print()
print(f" Interessant ist, WO die Streuungsgrenze von {MAX_ANTEIL:.0%} bindet:")
print(f" Depot : {(anteile_depot > MAX_ANTEIL - 1e-4).sum()} von "
f"{len(anteile_depot)} Posten am Anschlag")
print(f" Einkauf : {(anteile_einkauf > MAX_ANTEIL - 1e-4).sum()} von "
f"{len(anteile_einkauf)} Posten am Anschlag")
print()
print(" Im Einkauf zieht es die Loesung an den sicheren Lokalzulieferer,")
print(" bis die Grenze sie stoppt. Im Depot nicht - dort verhindert die")
print(" Mindestrendite, dass alles in Anleihen wandert. Zwei verschiedene")
print(" Bremsen also, und sie stehen an verschiedenen Stellen des Modells:")
print(" einmal in einer Nebenbedingung ueber die Anteile, einmal in einer")
print(" ueber den Ertrag. Wer eine Struktur uebertraegt, uebertraegt eben")
print(" nicht automatisch mit, WELCHE Bedingung am Ende bindet.")
# --- Teil 3: Der ehrliche Teil ---------------------------------------
print("\n" + "=" * 80)
print(" WAS NICHT HINUEBERREICHT")
print("=" * 80)
print("Die Struktur traegt. Drei Unterschiede tragen NICHT mit, und wer sie")
print("uebersieht, macht aus einer nuetzlichen Analogie einen Fehler:")
print()
print("1. WOHER DIE ZAHLEN KOMMEN.")
print(" In der Werkstatt ist der Verbrauch je Tisch gemessen - drei")
print(" Montagestunden sind drei Montagestunden. Im Depot ist die")
print(" erwartete Rendite GESCHAETZT, und zwar mit einem Fehler, der")
print(" groesser sein kann als die Unterschiede zwischen den Anlagen")
print(" (Kapitel Markowitz, Renditeschaetzung_Falle.py). Dieselbe")
print(" Optimierung ist im einen Fall Planung und im anderen")
print(" Fehlerverstaerkung.")
print()
print("2. OB DIE VERGANGENHEIT ETWAS UEBER DIE ZUKUNFT SAGT.")
print(" Lieferzeiten haben physikalische Ursachen: Entfernung, Zoll,")
print(" Kapazitaet. Sie aendern sich langsam und nachvollziehbar.")
print(" Kursrenditen entstehen aus dem Verhalten von Marktteilnehmern,")
print(" die selbst auf Modelle reagieren - dort verschwindet ein")
print(" erkanntes Muster oft genau deshalb, weil es erkannt wurde")
print(" (Kapitel Handelsmaschine, Data_Snooping.py).")
print()
print("3. OB TEILBARKEIT ERLAUBT IST.")
print(" 37,4 % eines Aktienfonds sind ein normaler Auftrag. 37,4 % eines")
print(" Lieferanten sind es nicht - Vertraege, Mindestabnahmen und")
print(" Ruestzeiten machen Einkaufsentscheidungen ganzzahlig. Genau")
print(" deshalb ist Teil II voller MILP und Teil V fast frei davon.")
print()
print("Die Bruecke traegt also die MODELLE, nicht die Annahmen. Wer sie")
print("benutzt, spart sich das Lernen der Methoden - nicht das Nachdenken")
print("ueber die Daten.")
print("=" * 80)

View file

@ -0,0 +1,179 @@
#!/usr/bin/env python3
# Toleranzen_und_Entartung.py
"""
Kapitel LP: Zwei Faelle, in denen man Schattenpreisen NICHT trauen darf.
1. Entartung (Degeneriertheit): Mehr Nebenbedingungen sind aktiv, als das
Problem Variablen hat. Dann ist der Schattenpreis nicht eindeutig - zwei
korrekte Solver liefern voellig verschiedene Werte fuer dasselbe Optimum.
2. Toleranzen: "ausgelastet" heisst nie 'schlupf == 0', sondern immer
'schlupf < toleranz'. Wer auf exakte Gleichheit prueft, baut Berichte,
die zufaellig mal stimmen und mal nicht.
Teil 3 zeigt die professionelle Antwort auf Fall 1: Statt EINEN Schattenpreis
zu melden, berechnet man seine SPANNE ueber alle optimalen Dualloesungen.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import linprog
# Ein bewusst entartetes Beispiel: drei Geraden schneiden sich in EINEM Punkt.
# max x1 + x2
# u.d.N. x1 + 2*x2 <= 4 (A)
# 2*x1 + x2 <= 4 (B)
# x1 + x2 <= 8/3 (C) - laeuft genau durch die Ecke (4/3, 4/3)
# In zwei Dimensionen legen schon zwei Geraden eine Ecke fest. Hier sind drei
# aktiv - eine zu viel. Genau das ist Entartung.
C_ZIEL = np.array([-1.0, -1.0]) # linprog minimiert -> negiert
A_UB = np.array([[1.0, 2.0],
[2.0, 1.0],
[1.0, 1.0]])
B_UB = np.array([4.0, 4.0, 8.0 / 3.0])
NAMEN = ["A: Fraeszeit", "B: Schleifzeit", "C: Pruefzeit"]
def zeige_entartung() -> float:
"""Loest dasselbe LP mit zwei Verfahren und vergleicht die Dualwerte."""
print("=" * 78)
print(" 1. ENTARTUNG: DERSELBE PLAN, GEGENSAETZLICHE SCHATTENPREISE")
print("=" * 78)
print(f"{'Verfahren':<26} {'x1':>7} {'x2':>7} {'Z*':>9} Schattenpreise")
print("-" * 78)
dualwerte = {}
for verfahren, beschreibung in [("highs-ds", "Dual Simplex"),
("highs-ipm", "Innere-Punkte-Verfahren")]:
ergebnis = linprog(C_ZIEL, A_ub=A_UB, b_ub=B_UB, bounds=(0, None),
method=verfahren)
if not ergebnis.success:
raise RuntimeError(f"{verfahren}: {ergebnis.message}")
y = -ergebnis.ineqlin.marginals
dualwerte[verfahren] = y
print(f"{beschreibung:<26} {ergebnis.x[0]:>7.3f} {ergebnis.x[1]:>7.3f} "
f"{-ergebnis.fun:>9.4f} {np.round(y, 4)}")
print("-" * 78)
print("Beide Zeilen sind RICHTIG: gleicher Plan, gleicher Zielwert, und beide")
print("Dualvektoren erfuellen die Optimalitaetsbedingungen. Trotzdem sagen sie")
print("das Gegenteil:")
print(f" Dual Simplex : {NAMEN[2]} ist wertlos, A und B sind je 0,33 EUR wert.")
print(f" Innere Punkte: {NAMEN[0]} und {NAMEN[1]} sind wertlos, C ist 1,00 EUR wert.")
print()
print("Wer auf dieser Grundlage eine Maschine kauft, hat eine 50:50-Chance -")
print("abhaengig davon, welches Verfahren der Solver zufaellig gewaehlt hat.")
return float(-linprog(C_ZIEL, A_ub=A_UB, b_ub=B_UB, bounds=(0, None)).fun)
def entartung_erkennen() -> None:
"""Der Test, der in jedes Auswertungsskript gehoert."""
print("\n" + "=" * 78)
print(" 2. ENTARTUNG ERKENNEN - UND WARUM '== 0' DABEI VERSAGT")
print("=" * 78)
ergebnis = linprog(C_ZIEL, A_ub=A_UB, b_ub=B_UB, bounds=(0, None))
schlupf = B_UB - A_UB @ ergebnis.x
print(f"{'Nebenbedingung':<18} {'Schlupf':>16} {'== 0 ?':>9} "
f"{'< 1e-7 ?':>10}")
print("-" * 78)
for name, s in zip(NAMEN, schlupf):
print(f"{name:<18} {s:>16.3e} {str(s == 0.0):>9} {str(abs(s) < 1e-7):>10}")
aktiv = int((np.abs(schlupf) < 1e-7).sum())
variablen = A_UB.shape[1]
print("-" * 78)
print(f"Aktive Nebenbedingungen: {aktiv}, Variablen: {variablen}")
if aktiv > variablen:
print(f"=> ENTARTET. {aktiv} aktive Restriktionen bei nur {variablen} "
"Variablen bedeuten:")
print(" Der Schattenpreis ist nicht eindeutig. Melden Sie eine Spanne,")
print(" keinen Einzelwert (siehe Teil 3).")
else:
print("=> nicht entartet, die Dualwerte sind eindeutig.")
print("\nBeachten Sie die Spalte '== 0': Ein Schlupf von 4.44e-16 ist")
print("rechnerisch null, aber nicht gleich 0.0. Wer mit '==' prueft,")
print("uebersieht genau die Engpaesse, die er sucht.")
def schattenpreis_spanne(zielwert: float, toleranz: float = 1e-9
) -> list[tuple[float, float]]:
"""Berechnet fuer jede Nebenbedingung die Spanne ihres Schattenpreises
ueber ALLE optimalen Dualloesungen.
Die Menge der optimalen Dualloesungen ist selbst ein Polyeder:
A^T y >= c, y >= 0, b^T y = Z*
(Dualzulaessigkeit plus starker Dualitaetssatz.) Minimiert und maximiert
man darauf y_i, erhaelt man die exakten Grenzen. Das ist die ehrliche
Auskunft an das Management: nicht 'die Stunde ist 0,33 EUR wert', sondern
'zwischen 0,00 und 0,33 EUR - der Wert ist aus dem Modell nicht bestimmbar'.
"""
m = A_UB.shape[0]
# A^T y >= c <=> -A^T y <= -c ; Ziel war max c^T x, in linprog-Notation
# steckt c mit negativem Vorzeichen in C_ZIEL.
c_original = -C_ZIEL
A_dual_ub = -A_UB.T
b_dual_ub = -c_original
spannen = []
for i in range(m):
richtung = np.zeros(m)
richtung[i] = 1.0
grenzen = []
for vorzeichen in (1.0, -1.0): # 1 = minimieren, -1 = maximieren
ergebnis = linprog(
vorzeichen * richtung,
A_ub=A_dual_ub, b_ub=b_dual_ub,
A_eq=B_UB.reshape(1, -1), b_eq=[zielwert],
bounds=(0, None))
if not ergebnis.success:
raise RuntimeError(f"Spannenberechnung fehlgeschlagen: "
f"{ergebnis.message}")
grenzen.append(float(ergebnis.x[i]))
spannen.append((min(grenzen), max(grenzen)))
return spannen
def zeige_spanne(zielwert: float) -> None:
print("\n" + "=" * 78)
print(" 3. DIE EHRLICHE AUSKUNFT: SCHATTENPREIS-SPANNEN")
print("=" * 78)
spannen = schattenpreis_spanne(zielwert)
print(f"{'Nebenbedingung':<18} {'von':>10} {'bis':>10} Aussage")
print("-" * 78)
for name, (unten, oben) in zip(NAMEN, spannen):
if oben - unten < 1e-7:
aussage = f"eindeutig {oben:.2f} EUR"
elif oben < 1e-7:
aussage = "sicher wertlos (kein Engpass)"
else:
aussage = "NICHT bestimmbar - Spanne melden!"
print(f"{name:<18} {unten:>10.4f} {oben:>10.4f} {aussage}")
print("-" * 78)
print("So berichtet man an Entscheider: 'Eine zusaetzliche Fraesstunde ist")
print("zwischen 0,00 und 0,33 EUR wert - das Modell kann es nicht genauer")
print("sagen, weil drei Engpaesse exakt gleichzeitig binden.' Das ist eine")
print("brauchbare Aussage. Ein erfundener Einzelwert ist es nicht.")
if __name__ == "__main__":
zielwert = zeige_entartung()
entartung_erkennen()
zeige_spanne(zielwert)
print("\n" + "=" * 78)
print("Merksatz: Pruefen Sie VOR jeder Sensitivitaetsaussage auf Entartung -")
print("und vergleichen Sie Schlupfwerte nie mit '== 0', sondern mit einer")
print("Toleranz.")
print("=" * 78)

View file

@ -0,0 +1,165 @@
#!/usr/bin/env python3
# VRP_Flotten_Routing.py
"""
Kapitel Graphen: Capacitated Vehicle Routing Problem with Time Windows (CVRPTW)
mit der Routing-Bibliothek von Google OR-Tools.
Eigenschaften:
* Eingabedaten werden vorab auf Plausibilitaet geprueft (Kapazitaet
ausreichend? Zeitfenster erreichbar?)
* Fahrzeit und Servicezeit werden getrennt ausgewiesen
* Ausgabe als lesbarer Tourenplan mit Ankunftszeiten
* Kennzahlen: Auslastung, Leerfahrten, Wartezeit
"""
import numpy as np
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
SERVICEZEIT = 10 # Minuten je Kundenstopp
WARTEZEIT_MAX = 60 # zulaessige Wartezeit bei zu frueher Ankunft
SCHICHTLAENGE = 600 # Minuten
def erzeuge_daten(seed: int = 42):
"""Synthetische, aber reproduzierbare Instanz: 1 Depot + 16 Kunden."""
anzahl_orte = 17
rng = np.random.default_rng(seed)
koordinaten = rng.random((anzahl_orte, 2)) * 100 # 100 x 100 km Raster
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 {
"distanzmatrix": distanz.tolist(),
"zeitfenster": [
(0, SCHICHTLAENGE), # 0: Depot
(30, 120), (60, 180), (100, 240), (150, 300), # Kunden 1-4
(60, 180), (120, 240), (200, 360), (300, 450), # Kunden 5-8
(180, 300), (240, 360), (300, 480), (360, 500), # Kunden 9-12
(60, 200), (120, 300), (240, 400), (300, 550), # Kunden 13-16
],
"bedarfe": [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2],
"kapazitaeten": [10, 10, 10, 10],
"anzahl_fahrzeuge": 4,
"depot": 0,
}
def pruefe_daten(daten) -> None:
"""Vorabdiagnose - fangt die haeufigsten Ursachen fuer 'keine Loesung' ab."""
gesamtbedarf = sum(daten["bedarfe"])
gesamtkapazitaet = sum(daten["kapazitaeten"])
print(f"Gesamtbedarf {gesamtbedarf} Einheiten | "
f"Flottenkapazitaet {gesamtkapazitaet} Einheiten | "
f"Auslastung {gesamtbedarf / gesamtkapazitaet * 100:.0f} %")
if gesamtbedarf > gesamtkapazitaet:
raise SystemExit("UNLOESBAR: Der Bedarf uebersteigt die Flottenkapazitaet.")
d = daten["distanzmatrix"]
for kunde, (fruehestens, spaetestens) in enumerate(daten["zeitfenster"]):
if kunde == 0:
continue
direktfahrt = d[0][kunde]
if direktfahrt > spaetestens:
raise SystemExit(
f"UNLOESBAR: Kunde {kunde} ist erst nach {direktfahrt} min erreichbar, "
f"sein Zeitfenster endet aber bei {spaetestens} min.")
print("Vorabpruefung bestanden: Kapazitaet und Zeitfenster sind grundsaetzlich machbar.")
def loese_cvrptw(zeitlimit_s: int = 5):
daten = erzeuge_daten()
pruefe_daten(daten)
manager = pywrapcp.RoutingIndexManager(
len(daten["distanzmatrix"]), daten["anzahl_fahrzeuge"], daten["depot"])
routing = pywrapcp.RoutingModel(manager)
# --- Fahrzeit + Servicezeit als Kantengewicht ------------------------
def zeit_callback(von_index, nach_index):
von = manager.IndexToNode(von_index)
nach = manager.IndexToNode(nach_index)
service = SERVICEZEIT if von != daten["depot"] else 0
return daten["distanzmatrix"][von][nach] + service
zeit_index = routing.RegisterTransitCallback(zeit_callback)
routing.SetArcCostEvaluatorOfAllVehicles(zeit_index)
# --- Kapazitaetsdimension ---------------------------------------------
def bedarf_callback(von_index):
return daten["bedarfe"][manager.IndexToNode(von_index)]
bedarf_index = routing.RegisterUnaryTransitCallback(bedarf_callback)
routing.AddDimensionWithVehicleCapacity(
bedarf_index, 0, daten["kapazitaeten"], True, "Kapazitaet")
# --- Zeitdimension mit Zeitfenstern -----------------------------------
routing.AddDimension(zeit_index, WARTEZEIT_MAX, SCHICHTLAENGE, False, "Zeit")
zeit_dimension = routing.GetDimensionOrDie("Zeit")
for ort, (fruehestens, spaetestens) in enumerate(daten["zeitfenster"]):
zeit_dimension.CumulVar(manager.NodeToIndex(ort)).SetRange(fruehestens, spaetestens)
# --- Suchparameter -----------------------------------------------------
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 = zeitlimit_s
loesung = routing.SolveWithParameters(parameter)
if not loesung:
print("Keine zulaessige Routenfuehrung gefunden.")
return
# --- Auswertung --------------------------------------------------------
print("\n" + "=" * 84)
print(" OPTIMIERTER TOURENPLAN (CVRPTW)")
print("=" * 84)
gesamtzeit = gesamtfracht = gesamtdistanz = 0
kapazitaet = daten["kapazitaeten"]
for fahrzeug in range(daten["anzahl_fahrzeuge"]):
index = routing.Start(fahrzeug)
if routing.IsEnd(loesung.Value(routing.NextVar(index))):
print(f"\nFahrzeug {fahrzeug + 1}: nicht eingesetzt")
continue
stationen, fracht, distanz = [], 0, 0
while not routing.IsEnd(index):
knoten = manager.IndexToNode(index)
ankunft = loesung.Min(zeit_dimension.CumulVar(index))
fracht += daten["bedarfe"][knoten]
bezeichnung = "Depot" if knoten == 0 else f"K{knoten}"
stationen.append(f"{bezeichnung}@{ankunft}")
naechster = loesung.Value(routing.NextVar(index))
distanz += daten["distanzmatrix"][knoten][manager.IndexToNode(naechster)]
index = naechster
endzeit = loesung.Min(zeit_dimension.CumulVar(index))
stationen.append(f"Depot@{endzeit}")
gesamtzeit += endzeit
gesamtfracht += fracht
gesamtdistanz += distanz
print(f"\nFahrzeug {fahrzeug + 1}:")
print(" " + " -> ".join(stationen))
print(f" Schichtzeit {endzeit} min | Fahrstrecke {distanz} km | "
f"Fracht {fracht}/{kapazitaet[fahrzeug]} "
f"({fracht / kapazitaet[fahrzeug] * 100:.0f} % Auslastung)")
print("\n" + "-" * 84)
print(f"Summe Schichtzeiten: {gesamtzeit} min")
print(f"Summe Fahrstrecken: {gesamtdistanz} km")
print(f"Transportierte Fracht: {gesamtfracht} von {sum(daten['bedarfe'])} Einheiten")
assert gesamtfracht == sum(daten["bedarfe"]), "Nicht alle Kunden wurden beliefert!"
print("Alle Kunden wurden innerhalb ihrer Zeitfenster beliefert.")
print("=" * 84)
if __name__ == "__main__":
loese_cvrptw()

View file

@ -0,0 +1,171 @@
#!/usr/bin/env python3
# VRP_Kapazitaetsfalle.py
"""
Kapitel Graphen: Die vergessene Dimension.
Die Routing-Bibliothek von OR-Tools kennt keine "Kapazitaet" von sich aus.
Sie kennt nur DIMENSIONEN - benannte Groessen, die sich entlang einer Tour
aufsummieren und begrenzt werden koennen. Distanz ist eine, Zeit ist eine,
Ladung ist eine. Wer eine davon nicht anlegt, bekommt trotzdem eine Loesung:
eine schoene, kurze, guenstige - und unfahrbare.
Dieses Programm loest dieselbe Instanz zweimal und prueft beide Ergebnisse
gegen die tatsaechlichen Lademengen.
Instanz: 1 Depot, 16 Kunden, 4 Fahrzeuge zu je 10 Paletten.
Gesamtbedarf 37 Paletten bei 40 Paletten Flottenkapazitaet - es ist also
knapp, aber machbar.
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import numpy as np
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
# Dieselbe Instanz wie VRP_Flotten_Routing.py
BEDARFE = [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2]
KAPAZITAETEN = [10, 10, 10, 10]
ANZAHL_FAHRZEUGE = 4
DEPOT = 0
def distanzmatrix(seed: int = 42) -> list[list[int]]:
rng = np.random.default_rng(seed)
koordinaten = rng.random((len(BEDARFE), 2)) * 100 # 100 x 100 km
n = len(BEDARFE)
return [[int(np.linalg.norm(koordinaten[i] - koordinaten[j]))
for j in range(n)] for i in range(n)]
def plane(mit_kapazitaet: bool, zeitlimit: int = 5) -> dict:
"""Loest die Tourenplanung - wahlweise mit oder ohne Ladungsdimension."""
distanz = distanzmatrix()
manager = pywrapcp.RoutingIndexManager(len(distanz), ANZAHL_FAHRZEUGE, DEPOT)
routing = pywrapcp.RoutingModel(manager)
def entfernung(von_index, nach_index):
return distanz[manager.IndexToNode(von_index)][manager.IndexToNode(nach_index)]
kosten_id = routing.RegisterTransitCallback(entfernung)
routing.SetArcCostEvaluatorOfAllVehicles(kosten_id)
# DIE entscheidende Stelle. Ohne diesen Block existiert im Modell keine
# Ladung - die Fahrzeuge sind dann unendlich gross.
if mit_kapazitaet:
def bedarf(index):
return BEDARFE[manager.IndexToNode(index)]
bedarf_id = routing.RegisterUnaryTransitCallback(bedarf)
routing.AddDimensionWithVehicleCapacity(
bedarf_id,
0, # kein Zwischenpuffer
KAPAZITAETEN, # Obergrenze je Fahrzeug
True, # Ladung startet bei 0
"Ladung")
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.FromSeconds(zeitlimit)
loesung = routing.SolveWithParameters(parameter)
if loesung is None:
raise RuntimeError("Keine Loesung gefunden")
touren, strecken, ladungen = [], [], []
for fahrzeug in range(ANZAHL_FAHRZEUGE):
index = routing.Start(fahrzeug)
tour, strecke, ladung = [], 0, 0
while not routing.IsEnd(index):
knoten = manager.IndexToNode(index)
tour.append(knoten)
ladung += BEDARFE[knoten]
vorher = index
index = loesung.Value(routing.NextVar(index))
strecke += routing.GetArcCostForVehicle(vorher, index, fahrzeug)
tour.append(manager.IndexToNode(index))
touren.append(tour)
strecken.append(strecke)
ladungen.append(ladung)
return {"touren": touren, "strecken": strecken, "ladungen": ladungen,
"gesamtstrecke": sum(strecken)}
def pruefe(ergebnis: dict) -> list[str]:
"""Prueft den Plan gegen die Wirklichkeit - unabhaengig vom Modell.
Genau diese Trennung ist der Punkt: Die Pruefung darf nicht dieselben
Annahmen benutzen wie das Modell, sonst prueft sie nichts.
"""
beanstandungen = []
for fahrzeug, (ladung, kapazitaet) in enumerate(
zip(ergebnis["ladungen"], KAPAZITAETEN)):
if ladung > kapazitaet:
beanstandungen.append(
f"Fahrzeug {fahrzeug + 1}: {ladung} Paletten geladen, "
f"Kapazitaet {kapazitaet} ({ladung - kapazitaet} zu viel)")
beliefert = sorted(k for tour in ergebnis["touren"] for k in tour[1:-1])
erwartet = list(range(1, len(BEDARFE)))
if beliefert != erwartet:
fehlend = set(erwartet) - set(beliefert)
if fehlend:
beanstandungen.append(f"nicht beliefert: {sorted(fehlend)}")
return beanstandungen
def zeige(titel: str, ergebnis: dict) -> None:
print(f"\n{titel}")
print(f" Gesamtstrecke {ergebnis['gesamtstrecke']} km")
print(f" {'Fahrzeug':<10} {'Stopps':>7} {'Strecke':>9} {'Ladung':>8} "
f"{'Kapazitaet':>11}")
for i, (tour, strecke, ladung) in enumerate(
zip(ergebnis["touren"], ergebnis["strecken"], ergebnis["ladungen"])):
markierung = " <-- ueberladen" if ladung > KAPAZITAETEN[i] else ""
print(f" {i + 1:<10} {len(tour) - 2:>7} {strecke:>8} km {ladung:>8} "
f"{KAPAZITAETEN[i]:>11}{markierung}")
beanstandungen = pruefe(ergebnis)
if beanstandungen:
print(" PRUEFUNG: DURCHGEFALLEN")
for text in beanstandungen:
print(f" - {text}")
else:
print(" PRUEFUNG: bestanden")
if __name__ == "__main__":
print("=" * 78)
print(" DIE VERGESSENE DIMENSION")
print("=" * 78)
print(f"16 Kunden, Gesamtbedarf {sum(BEDARFE)} Paletten, "
f"{ANZAHL_FAHRZEUGE} Fahrzeuge zu je {KAPAZITAETEN[0]} "
f"= {sum(KAPAZITAETEN)} Paletten Flottenkapazitaet.")
ohne = plane(mit_kapazitaet=False)
zeige("[1] Ohne Ladungsdimension", ohne)
mit = plane(mit_kapazitaet=True)
zeige("[2] Mit AddDimensionWithVehicleCapacity", mit)
print("\n" + "=" * 78)
mehr = mit["gesamtstrecke"] - ohne["gesamtstrecke"]
print(f"Der korrekte Plan ist {mehr} km laenger "
f"({mehr / ohne['gesamtstrecke'] * 100:.1f} %).")
print()
print("Und genau darin liegt die Gefahr: Lauf [1] sieht BESSER aus. Wer")
print("beide Zahlen nebeneinander legt, ohne die Ladung zu pruefen, haelt")
print("die unfahrbare Loesung fuer die bessere Optimierung - und den")
print("korrekten Plan fuer schlechte Arbeit.")
print()
print("Die Routing-Bibliothek kennt keine 'Kapazitaet'. Sie kennt nur")
print("Dimensionen, die man ihr anlegt. Was nicht als Dimension existiert,")
print("wird nicht begrenzt - und faellt niemandem auf, weil das Ergebnis")
print("plausibel aussieht.")
print("=" * 78)

View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
# VaR_CVaR_Demo.py
"""
Kapitel CVaR: Fat Tails, VaR und CVaR anschaulich.
Teil 1: Wie oft treten "unmoegliche" Tage wirklich auf?
Teil 2: Warum ist der VaR nicht subadditiv - ein Gegenbeispiel zum Nachrechnen.
"""
import numpy as np
from scipy import stats
def var_quantil(verluste: np.ndarray, alpha: float = 0.95) -> float:
"""VaR = Quantil der Verlustverteilung (Verluste positiv, Gewinne negativ)."""
return float(np.quantile(verluste, alpha))
def cvar_rockafellar(verluste: np.ndarray, alpha: float = 0.95) -> float:
"""
CVaR ueber die Rockafellar-Uryasev-Formel:
CVaR = min_gamma { gamma + 1/(1-alpha) * E[max(Verlust - gamma, 0)] }
WICHTIG: Der naheliegende Weg "Mittelwert aller Werte >= VaR" ist FALSCH,
sobald die Verteilung Atome hat (z. B. genau zwei moegliche Verluste).
Dann liegt der VaR selbst auf einem Atom, und der Vergleich '>=' erfasst
zu viel Wahrscheinlichkeitsmasse. Die Formel unten behandelt das korrekt -
und ist zugleich genau der Ausdruck, den wir im Abschnitt 'Value at Risk
und Conditional Value at Risk' optimieren.
"""
kandidaten = np.unique(verluste) # Optimum liegt immer auf einem Datenpunkt
return float(min(g + np.mean(np.maximum(verluste - g, 0.0)) / (1.0 - alpha)
for g in kandidaten))
if __name__ == "__main__":
rng = np.random.default_rng(2026)
# --- Teil 1: Fat Tails (analytisch, nicht simuliert) ------------------
print("=" * 88)
print(" TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?")
print("=" * 88)
print("Vergleich: Normalverteilung gegen t-Verteilung mit 3 Freiheitsgraden")
print("(beide auf Standardabweichung 1 normiert).\n")
t_verteilung = stats.t(df=3)
skalierung = t_verteilung.std() # auf Varianz 1 bringen
print(f"{'Ereignis':<22} {'Normal':>14} {'t (df=3)':>14} {'Faktor':>11} "
f"{'Normal: 1 Tag in':>18}")
print("-" * 88)
for k in [3, 4, 5, 6]:
p_normal = 2 * stats.norm.sf(k) # beidseitig
p_t = 2 * t_verteilung.sf(k * skalierung)
print(f"Abweichung > {k} Sigma {p_normal*100:>13.6f} % {p_t*100:>13.6f} % "
f"{p_t/p_normal:>10.1f}x {1/p_normal/252:>15,.0f} Jahre")
print("\nDeutung: Ein 5-Sigma-Tag ist unter Normalverteilung ein Ereignis von")
print("etwa einmal in 6.900 Jahren. Reale Aktienmaerkte liefern solche Tage")
print("mehrfach pro Jahrzehnt. Wer allein mit Varianz steuert, plant fuer")
print("eine Welt, in der Crashs praktisch nicht vorkommen.")
# --- Teil 2: VaR ist nicht subadditiv ---------------------------------
print("\n" + "=" * 88)
print(" TEIL 2: WARUM DER VaR KEIN KOHAERENTES RISIKOMASS IST")
print("=" * 88)
print("Zwei unabhaengige Anleihen, je 100 EUR Nominal.")
print("Jede faellt mit 4 % Wahrscheinlichkeit aus (Verlust 100),")
print("sonst zahlt sie 2 EUR Kupon (Verlust -2).\n")
ziehungen = 2_000_000
verlust_a = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)
verlust_b = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)
verlust_ab = verlust_a + verlust_b
print(f"{'':<28} {'VaR 95%':>12} {'CVaR 95%':>12}")
print("-" * 88)
werte = {}
for name, v in [("Anleihe A allein", verlust_a),
("Anleihe B allein", verlust_b),
("Portfolio A+B", verlust_ab)]:
werte[name] = (var_quantil(v), cvar_rockafellar(v))
print(f"{name:<28} {werte[name][0]:>12.2f} {werte[name][1]:>12.2f}")
var_summe = werte["Anleihe A allein"][0] + werte["Anleihe B allein"][0]
cvar_summe = werte["Anleihe A allein"][1] + werte["Anleihe B allein"][1]
print(f"{'Summe der Einzelwerte':<28} {var_summe:>12.2f} {cvar_summe:>12.2f}")
var_port, cvar_port = werte["Portfolio A+B"]
print("-" * 88)
print(f"VaR: Portfolio {var_port:7.2f} vs. Summe {var_summe:7.2f} -> "
f"{'VERLETZT die Subadditivitaet!' if var_port > var_summe else 'subadditiv'}")
print(f"CVaR: Portfolio {cvar_port:7.2f} vs. Summe {cvar_summe:7.2f} -> "
f"{'subadditiv (kohaerent)' if cvar_port <= cvar_summe + 1e-6 else 'verletzt'}")
print("\nDeutung: Einzeln betrachtet meldet der VaR fuer jede Anleihe einen")
print("GEWINN von 2 EUR - denn mit 96 % Wahrscheinlichkeit passiert nichts,")
print("und 4 % liegen unterhalb der 5-%-Schwelle. Im Portfolio steigt die")
print("Wahrscheinlichkeit mindestens eines Ausfalls auf 7,8 % und damit UEBER")
print("die Schwelle - der VaR springt auf 98. Er behauptet also, Streuung")
print("habe das Risiko erhoeht. Das ist oekonomisch unsinnig und der Grund,")
print("warum die Bankenaufsicht mit Basel III auf den Expected Shortfall")
print("umgestellt hat.")
print("=" * 88)

View file

@ -0,0 +1,240 @@
#!/usr/bin/env python3
# Vektorisierte_Modellgenerierung.py
"""
Kapitel Oekosystem: Warum der Solver oft gar nicht der Engpass ist.
In realen Projekten geht ein grosser Teil der Rechenzeit nicht ins Loesen,
sondern ins AUFBAUEN des Modells. Dieses Programm misst das an einem
Transportproblem wachsender Groesse in vier Stufen:
A Modellierungsschicht, Nebenbedingung fuer Nebenbedingung (OR-Tools)
B Matrix direkt, aber mit Python-Schleifen ueber die Eintraege (COO)
C Matrix vektorisiert ueber Kronecker-Produkte (NumPy/SciPy)
D Daten kommen als lange Tabelle, aufbereitet mit Polars
Alle Varianten loesen dasselbe Problem und muessen denselben Zielwert
liefern - das wird am Ende geprueft.
Benoetigt: numpy, scipy, ortools; Variante D zusaetzlich polars (optional).
"""
from __future__ import annotations
import importlib.util
import time
import numpy as np
import scipy.sparse as sp
from ortools.linear_solver import pywraplp
from scipy.optimize import linprog
HAT_POLARS = importlib.util.find_spec("polars") is not None
def erzeuge_daten(m: int, n: int, saat: int = 3
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Transportproblem: m Werke, n Kunden.
Liefert (kosten[m, n], angebot[m], bedarf[n]). Das Gesamtangebot liegt
20 % ueber dem Gesamtbedarf, damit das Modell sicher loesbar ist.
"""
rng = np.random.default_rng(saat)
kosten = rng.uniform(1.0, 20.0, size=(m, n))
bedarf = rng.uniform(10.0, 50.0, size=n)
angebot = np.full(m, 1.2 * bedarf.sum() / m)
return kosten, angebot, bedarf
# --- Variante A: Modellierungsschicht, Bedingung fuer Bedingung -------------
def loese_mit_modellierungsschicht(kosten: np.ndarray, angebot: np.ndarray,
bedarf: np.ndarray) -> tuple[float, float, float]:
"""So schreibt man ein Transportproblem zuerst hin - gut lesbar, nah an
der mathematischen Formulierung, jede Nebenbedingung ein eigener Aufruf.
Jedes `s.Add(sum(...))` baut in Python einen Ausdrucksbaum aus m bzw. n
Termen auf und uebergibt ihn einzeln an die C++-Schicht. Das ist der
Preis der Bequemlichkeit - und er waechst linear mit der Modellgroesse.
"""
m, n = kosten.shape
t0 = time.perf_counter()
s = pywraplp.Solver.CreateSolver("GLOP")
x = [[s.NumVar(0, s.infinity(), f"x_{i}_{j}") for j in range(n)]
for i in range(m)]
for i in range(m):
s.Add(sum(x[i][j] for j in range(n)) <= angebot[i])
for j in range(n):
s.Add(sum(x[i][j] for i in range(m)) >= bedarf[j])
s.Minimize(sum(kosten[i][j] * x[i][j] for i in range(m) for j in range(n)))
t_aufbau = time.perf_counter() - t0
t0 = time.perf_counter()
status = s.Solve()
t_loesen = time.perf_counter() - t0
if status != pywraplp.Solver.OPTIMAL:
raise RuntimeError(f"Solver-Status: {status}")
return t_aufbau, t_loesen, s.Objective().Value()
# --- Varianten B bis D: Matrix selbst bauen, dann SciPy/HiGHS ---------------
def baue_mit_schleifen(kosten: np.ndarray, angebot: np.ndarray,
bedarf: np.ndarray):
"""Die Nebenbedingungsmatrix als COO-Tripel (Zeile, Spalte, Wert), erzeugt
in verschachtelten Python-Schleifen.
Schon deutlich naeher am Blech als Variante A - es entsteht kein
Ausdrucksbaum mehr. Die Schleife selbst bleibt aber Python.
"""
m, n = kosten.shape
zeilen: list[int] = []
spalten: list[int] = []
werte: list[float] = []
rechte_seite: list[float] = []
for i in range(m): # Angebot je Werk
for j in range(n):
zeilen.append(i)
spalten.append(i * n + j)
werte.append(1.0)
rechte_seite.append(float(angebot[i]))
for j in range(n): # Bedarf je Kunde, als -x <= -bedarf
for i in range(m):
zeilen.append(m + j)
spalten.append(i * n + j)
werte.append(-1.0)
rechte_seite.append(-float(bedarf[j]))
A_ub = sp.csr_matrix((werte, (zeilen, spalten)), shape=(m + n, m * n))
return A_ub, np.array(rechte_seite), kosten.ravel()
def baue_vektorisiert(kosten: np.ndarray, angebot: np.ndarray,
bedarf: np.ndarray):
"""Dieselbe Matrix ohne eine einzige Schleife - ueber Kronecker-Produkte.
Die Angebotsmatrix ist kron(I_m, 1_n^T): je Werk eine Zeile mit Einsen
an genau den n Spalten dieses Werks. Die Bedarfsmatrix ist
kron(1_m^T, I_n). Beide entstehen in je einem Aufruf und sind sofort
duennbesetzt.
"""
m, n = kosten.shape
angebots_matrix = sp.kron(sp.identity(m, format="csr"), np.ones((1, n)))
bedarfs_matrix = sp.kron(np.ones((1, m)), sp.identity(n, format="csr"))
A_ub = sp.vstack([angebots_matrix, -bedarfs_matrix], format="csr")
b_ub = np.concatenate([angebot, -bedarf])
return A_ub, b_ub, kosten.ravel()
def baue_mit_polars(kosten: np.ndarray, angebot: np.ndarray, bedarf: np.ndarray):
"""Der realistische Fall: Die Kosten kommen als LANGE Tabelle
(werk, kunde, kosten) aus Datenbank, Data Lake oder CSV-Datei.
Polars berechnet den Spaltenindex jeder Variablen in einem einzigen
Spaltenausdruck - ohne Python-Schleife ueber die Zeilen. Genau so baut man
Modelle aus Millionen Tabellenzeilen.
"""
import polars as pl
m, n = kosten.shape
tabelle = pl.DataFrame({
"werk": np.repeat(np.arange(m), n),
"kunde": np.tile(np.arange(n), m),
"kosten": kosten.ravel(),
}).with_columns(
(pl.col("werk") * n + pl.col("kunde")).alias("var_index")
)
var_index = tabelle["var_index"].to_numpy()
werk = tabelle["werk"].to_numpy()
kunde = tabelle["kunde"].to_numpy()
eins = np.ones(var_index.size)
angebots_matrix = sp.csr_matrix((eins, (werk, var_index)), shape=(m, m * n))
bedarfs_matrix = sp.csr_matrix((eins, (kunde, var_index)), shape=(n, m * n))
A_ub = sp.vstack([angebots_matrix, -bedarfs_matrix], format="csr")
b_ub = np.concatenate([angebot, -bedarf])
return A_ub, b_ub, tabelle["kosten"].to_numpy()
def messe_matrixvariante(bauer, kosten, angebot, bedarf
) -> tuple[float, float, float]:
"""Liefert (Aufbauzeit, Loesezeit, Zielwert) fuer die Varianten B bis D."""
t0 = time.perf_counter()
A_ub, b_ub, c = bauer(kosten, angebot, bedarf)
t_aufbau = time.perf_counter() - t0
t0 = time.perf_counter()
ergebnis = linprog(c, A_ub=A_ub, b_ub=b_ub, bounds=(0, None), method="highs")
t_loesen = time.perf_counter() - t0
if not ergebnis.success:
raise RuntimeError(f"Solver-Status: {ergebnis.message}")
return t_aufbau, t_loesen, float(ergebnis.fun)
if __name__ == "__main__":
print("=" * 88)
print(" MODELLAUFBAU: WO DIE ZEIT WIRKLICH HINGEHT")
print("=" * 88)
print("Transportproblem mit m Werken und n Kunden -> m*n Variablen.\n")
varianten: list[tuple[str, object]] = [
("A: OR-Tools, Add() je NB", None), # Sonderfall, eigener Messpfad
("B: COO in Schleifen", baue_mit_schleifen),
("C: NumPy vektorisiert", baue_vektorisiert),
]
if HAT_POLARS:
varianten.append(("D: Polars-Tabelle", baue_mit_polars))
else:
print("Hinweis: polars nicht installiert - Variante D wird uebersprungen.\n")
# Aufwaermlauf: Der erste Aufruf bezahlt Importe und einmalige
# Initialisierungen. Wer den mitmisst, vergleicht Startkosten statt
# Rechenarbeit - ein klassischer Benchmark-Fehler.
aufwaerm = erzeuge_daten(10, 10)
loese_mit_modellierungsschicht(*aufwaerm)
for _, bauer in varianten[1:]:
bauer(*aufwaerm)
print(f"{'Groesse':<26} {'Variante':<26} {'Aufbau':>9} {'Loesen':>9} "
f"{'Aufbauanteil':>13}")
print("-" * 88)
for m, n in [(40, 40), (120, 120), (250, 250)]:
kosten, angebot, bedarf = erzeuge_daten(m, n)
zielwerte = []
for nummer, (name, bauer) in enumerate(varianten):
if bauer is None:
t_aufbau, t_loesen, ziel = loese_mit_modellierungsschicht(
kosten, angebot, bedarf)
else:
t_aufbau, t_loesen, ziel = messe_matrixvariante(
bauer, kosten, angebot, bedarf)
zielwerte.append(ziel)
anteil = 100.0 * t_aufbau / (t_aufbau + t_loesen)
groesse = f"{m}x{n} = {m*n:,} Variablen" if nummer == 0 else ""
print(f"{groesse:<26} {name:<26} {t_aufbau:>8.3f}s {t_loesen:>8.3f}s "
f"{anteil:>12.0f} %")
# Alle Varianten muessen dasselbe Problem beschreiben.
spanne = max(zielwerte) - min(zielwerte)
assert spanne < 1e-6 * max(abs(z) for z in zielwerte), \
f"Varianten widersprechen sich: {zielwerte}"
print(f"{'':<26} {'-> Zielwert (alle gleich)':<26} {zielwerte[0]:>9.2f}"
f" Spanne {spanne:.1e}")
print("-" * 88)
print("\nZwei Lehren aus der Tabelle:")
print("1. Bei Variante A geht mehr Zeit in den AUFBAU als ins Loesen. Wer hier")
print(" einen schnelleren Solver kauft, beschleunigt den kleineren Teil.")
print("2. Zwischen B und C liegt keine andere Mathematik, nur eine andere")
print(" Schreibweise derselben Matrix - Schleife gegen Kronecker-Produkt.")
print("\nDie Lesbarkeit von Variante A ist trotzdem viel wert: Fangen Sie dort an,")
print("und vektorisieren Sie erst, wenn die Messung es verlangt.")
print("=" * 88)

View file

@ -0,0 +1,140 @@
#!/usr/bin/env python3
# Visualisierung_Loesungsraum.py
"""
Kapitel Fundament: Geometrische Visualisierung eines 2D-Optimierungsraums.
Ecken werden berechnet, auf Zulässigkeit geprüft, bewertet und eingezeichnet.
Zusätzlich wird das ganzzahlige Optimum systematisch bestimmt statt behauptet.
"""
import itertools
import os
import numpy as np
import matplotlib
matplotlib.use("Agg") # kein Bildschirm nötig
import matplotlib.pyplot as plt
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
os.makedirs(OUTPUT_DIR, exist_ok=True)
# --- Modell (identisch zum Kapitel Einfuehrung) ----------------------------
# max 150*xA + 250*xB
# u.d.N. 2*xA + 5*xB <= 40 (vCPU)
# 4*xA + 6*xB <= 60 (RAM)
# 1*xA + 0*xB <= 8 (Marktliquidität)
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])
restriktionsnamen = ["vCPU", "RAM", "Marktlimit"]
def ist_zulaessig(punkt, tol=1e-7):
return np.all(A @ punkt <= b + tol) and np.all(punkt >= -tol)
def berechne_ecken():
"""
Ecken = Schnittpunkte je zweier Begrenzungsgeraden, die zulässig sind.
Begrenzungen sind die 3 Restriktionen plus die beiden Achsen xA=0, xB=0.
"""
geraden = [(A[i], b[i]) for i in range(len(b))]
geraden.append((np.array([1.0, 0.0]), 0.0)) # xA = 0
geraden.append((np.array([0.0, 1.0]), 0.0)) # xB = 0
ecken = []
for (n1, d1), (n2, d2) in itertools.combinations(geraden, 2):
M = np.array([n1, n2])
if abs(np.linalg.det(M)) < 1e-9: # parallel -> kein Schnittpunkt
continue
p = np.linalg.solve(M, np.array([d1, d2]))
if ist_zulaessig(p) and not any(np.allclose(p, e) for e in ecken):
ecken.append(p)
return np.array(ecken)
def bestes_ganzzahliges():
"""Vollständige Suche über das kleine Gitter - hier zulässig, weil winzig."""
bester_wert, bester_punkt = -np.inf, None
for xa in range(0, 21):
for xb in range(0, 21):
p = np.array([float(xa), float(xb)])
if ist_zulaessig(p) and c @ p > bester_wert:
bester_wert, bester_punkt = c @ p, p
return bester_punkt, bester_wert
# --- Analyse ---------------------------------------------------------------
ecken = berechne_ecken()
werte = ecken @ c
reihenfolge = np.argsort(-werte)
print("=" * 62)
print(" ECKEN DES ZULÄSSIGEN POLYEDERS (nach Zielwert sortiert)")
print("=" * 62)
print(f"{'x_A':>8} {'x_B':>8} {'Z = 150 xA + 250 xB':>24}")
print("-" * 62)
for i in reihenfolge:
print(f"{ecken[i, 0]:>8.2f} {ecken[i, 1]:>8.2f} {werte[i]:>24,.2f} EUR")
lp_punkt, lp_wert = ecken[reihenfolge[0]], werte[reihenfolge[0]]
ip_punkt, ip_wert = bestes_ganzzahliges()
print("-" * 62)
print(f"Kontinuierliches Optimum (LP): x = ({lp_punkt[0]:.2f}, {lp_punkt[1]:.2f}), "
f"Z = {lp_wert:,.2f} EUR")
print(f"Ganzzahliges Optimum (IP): x = ({ip_punkt[0]:.0f}, {ip_punkt[1]:.0f}), "
f"Z = {ip_wert:,.2f} EUR")
print(f"Preis der Ganzzahligkeit: {lp_wert - ip_wert:,.2f} EUR "
f"({(1 - ip_wert / lp_wert) * 100:.2f} %)")
print("=" * 62)
# --- Zeichnung -------------------------------------------------------------
gitter = np.linspace(0, 15, 400)
xa_gitter, xb_gitter = np.meshgrid(gitter, gitter)
plt.figure(figsize=(10, 8))
# Restriktionsgeraden
plt.plot(gitter, (40 - 2 * gitter) / 5, color="tab:blue", lw=2,
label=r"$2x_A + 5x_B \leq 40$ (vCPU)")
plt.plot(gitter, (60 - 4 * gitter) / 6, color="tab:green", lw=2,
label=r"$4x_A + 6x_B \leq 60$ (RAM)")
plt.axvline(x=8, color="tab:orange", lw=2, label=r"$x_A \leq 8$ (Marktlimit)")
# Zulässiger Bereich
maske = ((2 * xa_gitter + 5 * xb_gitter <= 40) & (4 * xa_gitter + 6 * xb_gitter <= 60)
& (xa_gitter <= 8) & (xa_gitter >= 0) & (xb_gitter >= 0))
plt.imshow(maske.astype(int), extent=(0, 15, 0, 15), origin="lower",
cmap="Greys", alpha=0.25, aspect="auto")
# Höhenlinien der Zielfunktion
Z = 150 * xa_gitter + 250 * xb_gitter
hoehen = plt.contour(xa_gitter, xb_gitter, Z, levels=[500, 1000, 1500, 2000, 2375],
colors="purple", linestyles="--", alpha=0.7)
plt.clabel(hoehen, inline=True, fontsize=9, fmt="Z = %1.0f EUR")
# Ecken einzeichnen - jetzt werden sie tatsächlich benutzt
plt.scatter(ecken[:, 0], ecken[:, 1], s=70, facecolors="white",
edgecolors="black", zorder=4, label="Ecken des Polyeders")
for e, w in zip(ecken, werte):
plt.annotate(f"({e[0]:.1f}, {e[1]:.1f})\nZ={w:,.0f}", (e[0], e[1]),
textcoords="offset points", xytext=(6, 6), fontsize=8)
plt.scatter([lp_punkt[0]], [lp_punkt[1]], color="purple", marker="D", s=110, zorder=5,
label=f"LP-Optimum ({lp_punkt[0]:.1f}, {lp_punkt[1]:.1f})")
plt.scatter([ip_punkt[0]], [ip_punkt[1]], color="red", s=170, zorder=6,
label=f"Ganzzahliges Optimum ({ip_punkt[0]:.0f}, {ip_punkt[1]:.0f})")
plt.xlim(0, 12)
plt.ylim(0, 10)
plt.xlabel("Anzahl Arbitrage-Bots ($x_A$)", fontsize=11)
plt.ylabel("Anzahl Trendfolge-Bots ($x_B$)", fontsize=11)
plt.title("Polyeder des zulässigen Bereichs mit Niveaulinien der Zielfunktion", fontsize=13)
plt.grid(True, linestyle=":", alpha=0.6)
plt.legend(loc="upper right", framealpha=0.9)
plt.tight_layout()
ziel = os.path.join(OUTPUT_DIR, "feasible_region_2d.png")
plt.savefig(ziel, dpi=150)
print(f"Visualisierung gespeichert unter '{ziel}'")

View file

@ -0,0 +1,243 @@
#!/usr/bin/env python3
# Vom_Wunsch_zum_Modell.py
"""
Kapitel Modellierung: Derselbe Datensatz, fuenf zulaessige Modelle, fuenf andere Plaene.
Eine Lohnfertigung kann nicht alle Anfragen annehmen: 14 Auftraege verlangen
zusammen 800 Maschinenstunden, verfuegbar sind 300. Es muss also ausgewaehlt
werden. Die Daten stehen fest - was FEHLT, ist die Frage.
Das Programm zeigt in vier Teilen, was daran haengt:
1. Drei naheliegende Ziele aus dem Managementgespraech ("Umsatz", "Auslastung",
"Deckungsbeitrag") - und wie weit die Plaene auseinanderlaufen.
2. Der Satz "Stammkunden duerfen wir nicht verlieren" in drei Lesarten. Alle
drei sind vertretbar, alle drei kosten unterschiedlich viel.
3. Was ein Strafgewicht daraus macht - und ab wann es dasselbe tut wie eine
harte Regel.
4. Was die harte Lesart ein Quartal spaeter anrichtet.
Der Punkt des Kapitels: Nicht die Daten bestimmen das Modell, sondern die
Frage. Und die steht selten in der Anforderung.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import linprog
# (Nummer, Kunde, Stammkunde?, Maschinenstunden, Umsatz, Materialkosten)
AUFTRAEGE = [
("A-101", "Weber GmbH", True, 40, 28_000, 19_000),
("A-102", "Weber GmbH", True, 25, 15_000, 9_500),
("A-103", "Kranz AG", True, 60, 33_000, 24_000),
("A-104", "Kranz AG", True, 35, 19_000, 13_500),
("A-105", "Obermeier KG", True, 20, 12_000, 7_000),
("A-106", "Siedler & Co", False, 120, 84_000, 71_000),
("A-107", "Siedler & Co", False, 110, 74_000, 63_000),
("A-108", "Novatek", False, 95, 61_000, 52_000),
("A-109", "Novatek", False, 30, 31_000, 17_000),
("A-110", "Hellwig", False, 15, 17_000, 8_000),
("A-111", "Hellwig", False, 55, 41_000, 30_000),
("A-112", "Pfeiffer Werke", False, 80, 46_000, 39_000),
("A-113", "Obermeier KG", True, 45, 26_000, 17_000),
("A-114", "Pfeiffer Werke", False, 70, 52_000, 36_000),
]
KAPAZITAET = 300 # Maschinenstunden im Planungszeitraum
STUNDEN = np.array([a[3] for a in AUFTRAEGE], dtype=float)
UMSATZ = np.array([a[4] for a in AUFTRAEGE], dtype=float)
DECKUNGSBEITRAG = np.array([a[4] - a[5] for a in AUFTRAEGE], dtype=float)
IST_STAMM = np.array([a[2] for a in AUFTRAEGE])
STAMMKUNDEN = sorted({a[1] for a in AUFTRAEGE if a[2]})
def waehle_aus(ziel: np.ndarray, zusatz_ub=None, zusatz_b_ub=None,
zusatz_eq=None, zusatz_b_eq=None, auftraege=AUFTRAEGE,
stunden=STUNDEN, kapazitaet=KAPAZITAET):
"""Ein Auftrag wird angenommen oder nicht - mehr Modell ist das nicht.
Der ganze Unterschied zwischen den fuenf Varianten unten steckt in 'ziel'
und in den Zusatzbedingungen. Das Modell selbst bleibt dasselbe.
"""
ergebnis = linprog(
-ziel,
A_ub=[stunden] + (zusatz_ub or []),
b_ub=[kapazitaet] + (zusatz_b_ub or []),
A_eq=zusatz_eq, b_eq=zusatz_b_eq,
bounds=(0, 1), integrality=1, method="highs")
if not ergebnis.success:
return None
return np.round(ergebnis.x).astype(int)
def kennzahlen(plan, auftraege=AUFTRAEGE, stunden=STUNDEN, umsatz=UMSATZ,
db=DECKUNGSBEITRAG, ist_stamm=IST_STAMM, kapazitaet=KAPAZITAET):
"""Immer dieselben fuenf Zahlen - nur so sind die Varianten vergleichbar."""
abgelehnte_stammauftraege = int(((1 - plan) & ist_stamm.astype(int)).sum())
leer_ausgegangen = sum(
1 for kunde in sorted({a[1] for a in auftraege if a[2]})
if not any(plan[i] for i, a in enumerate(auftraege) if a[1] == kunde))
return {"umsatz": float(umsatz @ plan), "db": float(db @ plan),
"auslastung": float(stunden @ plan) / kapazitaet * 100,
"stamm_abgelehnt": abgelehnte_stammauftraege,
"kunden_leer": leer_ausgegangen, "angenommen": int(plan.sum())}
def zeile(name: str, plan) -> None:
if plan is None:
print(f" {name:<34} {'UNZULAESSIG - kein Plan moeglich':>44}")
return
k = kennzahlen(plan)
print(f" {name:<34} {k['umsatz']:>10,.0f} {k['db']:>9,.0f} "
f"{k['auslastung']:>6.0f}% {k['stamm_abgelehnt']:>7} "
f"{k['kunden_leer']:>6} {k['angenommen']:>7}")
def kopfzeile() -> None:
print(f" {'':<34} {'Umsatz':>10} {'DB':>9} {'Aus-':>7} {'Stamm-':>7} "
f"{'Kunden':>6} {'ange-':>7}")
print(f" {'':<34} {'':>10} {'':>9} {'lastung':>7} {'auftr.':>7} "
f"{'ohne':>6} {'nommen':>7}")
print(" " + "-" * 78)
if __name__ == "__main__":
print("=" * 84)
print(" VOM MANAGEMENT-WUNSCH ZUM MODELL")
print("=" * 84)
print(f"{len(AUFTRAEGE)} Anfragen verlangen {STUNDEN.sum():.0f} Maschinenstunden, "
f"verfuegbar sind {KAPAZITAET}.")
print(f"Es muss also ausgewaehlt werden. {len(STAMMKUNDEN)} der Kunden sind "
f"Stammkunden:")
print(f" {', '.join(STAMMKUNDEN)}\n")
# --- 1. Drei Saetze aus dem Gespraech, drei Ziele ---------------------
print("-" * 84)
print("1. Was im Gespraech gesagt wurde - drei naheliegende Ziele\n")
kopfzeile()
plaene = {}
for name, ziel in [("\"Umsatz maximieren\"", UMSATZ),
("\"Maschinen besser auslasten\"", STUNDEN),
("\"Deckungsbeitrag maximieren\"", DECKUNGSBEITRAG)]:
plaene[name] = waehle_aus(ziel)
zeile(name, plaene[name])
umsatzplan = kennzahlen(plaene["\"Umsatz maximieren\""])
auslastungsplan = kennzahlen(plaene["\"Maschinen besser auslasten\""])
dbplan = kennzahlen(plaene["\"Deckungsbeitrag maximieren\""])
mehr_umsatz = umsatzplan["umsatz"] - dbplan["umsatz"]
weniger_db = dbplan["db"] - umsatzplan["db"]
print(f"\n Der Umsatzplan bringt {mehr_umsatz:,.0f} EUR mehr Umsatz und "
f"{weniger_db:,.0f} EUR WENIGER")
print(f" Deckungsbeitrag. Fuer {mehr_umsatz:,.0f} EUR Umsatz werden also "
f"{weniger_db:,.0f} EUR Ergebnis")
print(f" aufgegeben - und {umsatzplan['stamm_abgelehnt']} von "
f"{int(IST_STAMM.sum())} Stammkundenauftraegen dazu.")
print()
print(" \"Auslastung\" ist der haeufigste Wunsch und der schaedlichste. Der")
print(f" darauf optimierte Plan bringt {dbplan['db'] - auslastungsplan['db']:,.0f} EUR weniger "
f"Deckungsbeitrag und")
print(f" {dbplan['umsatz'] - auslastungsplan['umsatz']:,.0f} EUR weniger Umsatz als der "
f"DB-Plan - und erreicht dabei")
print(f" nicht einmal mehr Auslastung: alle drei liegen bei "
f"{dbplan['auslastung']:.0f} %.")
print(" Eine volle Maschine ist eben kein Ziel, sondern ein Nebenprodukt.")
# --- 2. Ein Satz, drei Lesarten --------------------------------------
print("\n" + "-" * 84)
print("2. \"Stammkunden duerfen wir nicht verlieren\" - aber was heisst das?\n")
kopfzeile()
zeile("ohne die Regel (DB maximieren)", waehle_aus(DECKUNGSBEITRAG))
# Lesart A: jeder Stammkundenauftrag wird angenommen
alle_stamm = [[1.0 if IST_STAMM[i] else 0.0 for i in range(len(AUFTRAEGE))]]
lesart_a = waehle_aus(DECKUNGSBEITRAG, zusatz_eq=alle_stamm,
zusatz_b_eq=[float(IST_STAMM.sum())])
zeile("A: jeden Stammauftrag annehmen", lesart_a)
# Lesart B: kein Stammkunde geht leer aus (mindestens ein Auftrag je Kunde)
je_kunde_ub, je_kunde_b = [], []
for kunde in STAMMKUNDEN:
je_kunde_ub.append([-1.0 if a[1] == kunde else 0.0 for a in AUFTRAEGE])
je_kunde_b.append(-1.0)
lesart_b = waehle_aus(DECKUNGSBEITRAG, zusatz_ub=je_kunde_ub,
zusatz_b_ub=je_kunde_b)
zeile("B: kein Stammkunde geht leer aus", lesart_b)
a, b = kennzahlen(lesart_a), kennzahlen(lesart_b)
ohne = kennzahlen(waehle_aus(DECKUNGSBEITRAG))
print("\n Beide Lesarten erreichen dasselbe: kein Stammkunde geht leer aus.")
print(f" Lesart A kostet dafuer {ohne['db'] - a['db']:,.0f} EUR Deckungsbeitrag, "
f"Lesart B nur {ohne['db'] - b['db']:,.0f} EUR -")
print(f" ein Unterschied von {b['db'] - a['db']:,.0f} EUR fuer denselben Satz aus derselben")
print(" Besprechung. Wer nicht nachfragt, entscheidet das selbst, ohne es")
print(" zu merken.")
# --- 3. Die weiche Variante ------------------------------------------
print("\n" + "-" * 84)
print("3. Dieselbe Regel als Strafkosten je abgelehntem Stammauftrag\n")
kopfzeile()
for strafe in (3_000, 6_000, 10_000):
ziel = DECKUNGSBEITRAG + np.where(IST_STAMM, float(strafe), 0.0)
zeile(f"C: Strafe {strafe:>6,} EUR je Auftrag", waehle_aus(ziel))
print("\n Bei 3.000 EUR aendert sich nichts: Die Strafe liegt unter dem, was")
print(" die Auftraege an Deckungsbeitrag verdraengen wuerden. Bei 10.000 EUR")
print(" kommt genau Lesart A heraus. Eine harte Regel ist also nichts")
print(" anderes als eine weiche mit hinreichend grosser Strafe - was ein")
print(" Strafgewicht sonst noch bedeutet, steht im Kapitel CP-SAT.")
# --- 4. Was die harte Lesart ein Quartal spaeter anrichtet -----------
print("\n" + "-" * 84)
print("4. Ein Quartal spaeter: Weber fragt einen Grossauftrag an\n")
erweitert = AUFTRAEGE + [("A-115", "Weber GmbH", True, 320, 210_000, 150_000)]
stunden_neu = np.array([a[3] for a in erweitert], dtype=float)
db_neu = np.array([a[4] - a[5] for a in erweitert], dtype=float)
stamm_neu = np.array([a[2] for a in erweitert])
print(f" A-115: Weber GmbH, {erweitert[-1][3]} Maschinenstunden - allein mehr,")
print(f" als der ganze Zeitraum hergibt ({KAPAZITAET} h).\n")
alle_stamm_neu = [[1.0 if stamm_neu[i] else 0.0 for i in range(len(erweitert))]]
hart = waehle_aus(db_neu, zusatz_eq=alle_stamm_neu,
zusatz_b_eq=[float(stamm_neu.sum())],
auftraege=erweitert, stunden=stunden_neu)
weich = waehle_aus(db_neu + np.where(stamm_neu, 10_000.0, 0.0),
auftraege=erweitert, stunden=stunden_neu)
print(f" Lesart A (hart): "
f"{'UNZULAESSIG - der Nachtlauf bricht ab' if hart is None else 'loesbar'}")
if weich is not None:
k = kennzahlen(weich, erweitert, stunden_neu,
np.array([a[4] for a in erweitert], dtype=float),
db_neu, stamm_neu)
print(f" Lesart C (weich): loesbar - {k['db']:,.0f} EUR Deckungsbeitrag, "
f"{k['stamm_abgelehnt']} Stammauftrag abgelehnt")
print("\n" + "=" * 84)
print(" WORAUF ES ANKOMMT")
print("=" * 84)
print("Die Daten waren in allen vier Teilen dieselben. Was sich geaendert hat,")
print("war nur die Frage - und mit ihr der Plan, der Deckungsbeitrag und die")
print("Kundenbeziehung.")
print()
print("Drei Dinge, die deshalb vor dem ersten Modell geklaert gehoeren:")
print()
print(" 1. WORAN wird der Erfolg gemessen? Nicht 'was ist Ihnen wichtig',")
print(" sondern: Welche Zahl steht am Jahresende im Bericht? Umsatz,")
print(" Auslastung und Deckungsbeitrag fuehren hier zu drei")
print(" verschiedenen Plaenen.")
print(" 2. WAS GENAU heisst der Satz? 'Stammkunden nicht verlieren' hat")
print(f" mindestens drei Lesarten mit {b['db'] - a['db']:,.0f} EUR Unterschied. Die Frage")
print(" 'meinen Sie jeden Auftrag oder jeden Kunden?' dauert zehn")
print(" Sekunden.")
print(" 3. WAS PASSIERT, WENN ES NICHT GEHT? Eine harte Regel, die niemand")
print(" hinterfragt hat, macht das System eines Tages unloesbar - und")
print(" zwar genau dann, wenn ein besonders grosser Auftrag hereinkommt.")
print(" Weiche Regeln antworten auch dann noch.")
print("=" * 84)

View file

@ -0,0 +1,147 @@
#!/usr/bin/env python3
# Warmstart_Effekt.py
"""
Kapitel MILP: Wann ein Warm-Start wirklich etwas bringt.
Solverstatus_und_Gap.py zeigt einen Fall, in dem ein Startwert NICHTS
bringt - HiGHS findet von allein schneller etwas Besseres. Hier der
Gegenfall: eine Heuristik, die dem Solver echtes Domaenenwissen liefert.
Problem: Lastverteilung. n Auftraege mit bekannter Dauer sind auf m
gleichartige Maschinen zu verteilen, sodass die zuletzt fertige Maschine
so frueh wie moeglich fertig wird (Makespan-Minimierung).
Die Heuristik: LPT (Longest Processing Time first) - laengste Auftraege
zuerst, jeder auf die momentan am wenigsten belastete Maschine. Sie ist
Jahrzehnte alt, in zwei Zeilen geschrieben und beweisbar nie schlechter
als 4/3 des Optimums.
Ueber model.AddHint() bekommt CP-SAT diese Loesung als Startpunkt.
WICHTIG: highspy wird hier bewusst NICHT importiert - es vertraegt sich
nicht mit ortools im selben Prozess (siehe Kapitel Oekosystem).
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import time
import numpy as np
from ortools.sat.python import cp_model
RNG = np.random.default_rng(4)
def lpt_heuristik(dauer: np.ndarray, n_maschinen: int) -> tuple[np.ndarray, int]:
"""Longest Processing Time first.
Laengste Auftraege zuerst auf die jeweils freieste Maschine legen. Zwei
Zeilen, keine Bibliothek, Ergebnis in Mikrosekunden - und erstaunlich
nah am Optimum.
"""
zuordnung = np.zeros(len(dauer), dtype=int)
belegung = np.zeros(n_maschinen)
for auftrag in np.argsort(-dauer): # laengster zuerst
maschine = int(np.argmin(belegung)) # freieste Maschine
zuordnung[auftrag] = maschine
belegung[maschine] += dauer[auftrag]
return zuordnung, int(belegung.max())
def loese(dauer: np.ndarray, n_maschinen: int, zeitlimit: float,
hinweis: np.ndarray | None = None) -> tuple[str, int, float]:
"""Exaktes Modell mit CP-SAT, optional mit Startloesung als Hinweis."""
n_auftraege = len(dauer)
obergrenze = int(dauer.sum())
modell = cp_model.CpModel()
# x[i][k] = 1 <=> Auftrag i laeuft auf Maschine k
x = [[modell.NewBoolVar(f"x_{i}_{k}") for k in range(n_maschinen)]
for i in range(n_auftraege)]
for i in range(n_auftraege):
modell.AddExactlyOne(x[i]) # jeder Auftrag genau einmal
belegung = [modell.NewIntVar(0, obergrenze, f"last_{k}")
for k in range(n_maschinen)]
for k in range(n_maschinen):
modell.Add(belegung[k] == sum(int(dauer[i]) * x[i][k]
for i in range(n_auftraege)))
makespan = modell.NewIntVar(0, obergrenze, "makespan")
modell.AddMaxEquality(makespan, belegung) # das Maximum ueber alle Maschinen
modell.Minimize(makespan)
# Der Warm-Start: ein Hinweis pro Variable. CP-SAT muss ihn nicht
# befolgen - er nutzt ihn als erste Loesung, wenn er zulaessig ist.
if hinweis is not None:
for i in range(n_auftraege):
for k in range(n_maschinen):
modell.AddHint(x[i][k], 1 if hinweis[i] == k else 0)
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = zeitlimit
# Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.
# Im Produktivbetrieb laesst man beides auf den Standardwerten.
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1
t0 = time.perf_counter()
status = loeser.Solve(modell)
dauer_s = time.perf_counter() - t0
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
raise RuntimeError(f"Kein Plan gefunden: {loeser.StatusName(status)}")
return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer_s
if __name__ == "__main__":
print("=" * 78)
print(" WARM-START: WENN DIE HEURISTIK MEHR WEISS ALS DER SOLVER")
print("=" * 78)
print("Lastverteilung: Auftraege auf gleichartige Maschinen verteilen,")
print("sodass die letzte Maschine so frueh wie moeglich fertig wird.\n")
print(f"{'Instanz':<22} {'Variante':<22} {'Makespan':>9} {'Zeit':>9} "
f"{'Faktor':>8}")
print("-" * 78)
for n_auftraege, n_maschinen in [(60, 7), (80, 9)]:
dauer = RNG.integers(10, 90, n_auftraege)
start, lpt_wert = lpt_heuristik(dauer, n_maschinen)
untere_schranke = dauer.sum() / n_maschinen
instanz = f"{n_auftraege} Auftr., {n_maschinen} Masch."
print(f"{instanz:<22} {'LPT-Heuristik':<22} {lpt_wert:>9} "
f"{'< 0.001s':>9} {'':>8}")
_, ziel_kalt, zeit_kalt = loese(dauer, n_maschinen, 60.0)
print(f"{'':<22} {'CP-SAT kalt':<22} {ziel_kalt:>9} "
f"{zeit_kalt:>8.3f}s {'1,0x':>8}")
_, ziel_warm, zeit_warm = loese(dauer, n_maschinen, 60.0, hinweis=start)
print(f"{'':<22} {'CP-SAT + LPT-Hinweis':<22} {ziel_warm:>9} "
f"{zeit_warm:>8.3f}s {zeit_kalt / zeit_warm:>7.1f}x")
assert ziel_kalt == ziel_warm, \
"Der Hinweis darf das Optimum nicht veraendern - nur den Weg dorthin!"
print(f"{'':<22} {'untere Schranke':<22} {untere_schranke:>9.1f}")
print("-" * 78)
print("\nDrei Beobachtungen:")
print("1. Der Hinweis aendert das ERGEBNIS nicht - beide Laeufe finden")
print(" dasselbe Optimum. Er aendert nur, wie lange der Beweis dauert.")
print(" Genau deshalb ist ein Warm-Start ungefaehrlich: Ein schlechter")
print(" Hinweis kostet Zeit, er verfaelscht aber nie die Loesung.")
print("2. Die LPT-Heuristik liegt schon sehr nah am Optimum. Ihr Wert fuer")
print(" den Solver liegt weniger in der Qualitaet als darin, dass sie")
print(" SOFORT da ist - der Solver kann von Beginn an alles verwerfen,")
print(" was schlechter ist.")
print("3. Der Faktor schwankt von Instanz zu Instanz - oben 2,2x und 1,4x -")
print(" und laesst sich NICHT aus der Problemgroesse ableiten. Er haengt")
print(" davon ab, wie schnell der Solver von allein eine vergleichbar gute")
print(" Loesung findet. Das ist die eigentliche Lehre: Ein Warm-Start ist")
print(" eine Messung wert, keine Glaubensfrage.")
print("=" * 78)

View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
# Zuordnung_Ungarisch.py
"""
Kapitel Graphen: Das Zuordnungsproblem, dreifach geloest.
(1) Ungarischer Algorithmus (scipy.optimize.linear_sum_assignment) - O(n^3)
(2) als LP OHNE Ganzzahligkeitsforderung -> liefert trotzdem 0/1 (Birkhoff)
(3) als MILP MIT Ganzzahligkeitsforderung -> gleiches Ergebnis, mehr Aufwand
Zeigt damit die praktische Bedeutung der totalen Unimodularitaet.
"""
import time
import numpy as np
from scipy.optimize import linear_sum_assignment, linprog
def erzeuge_kosten(n, seed=11):
rng = np.random.default_rng(seed)
return rng.integers(10, 99, size=(n, n)).astype(float)
def loese_ungarisch(kosten):
zeilen, spalten = linear_sum_assignment(kosten)
return kosten[zeilen, spalten].sum(), spalten
def baue_lp(kosten):
"""Gemeinsame LP-Struktur fuer Variante 2 und 3."""
n = len(kosten)
c = kosten.flatten() # x_ij in Zeilenreihenfolge
A_eq = np.zeros((2 * n, n * n))
for i in range(n): # jede Person genau eine Aufgabe
A_eq[i, i * n:(i + 1) * n] = 1.0
for j in range(n): # jede Aufgabe genau einer Person
A_eq[n + j, j::n] = 1.0
b_eq = np.ones(2 * n)
return c, A_eq, b_eq
def loese_lp(kosten, ganzzahlig):
n = len(kosten)
c, A_eq, b_eq = baue_lp(kosten)
ergebnis = linprog(c=c, A_eq=A_eq, b_eq=b_eq, bounds=[(0, 1)] * (n * n),
integrality=np.ones(n * n) if ganzzahlig else None,
method="highs")
x = ergebnis.x.reshape(n, n)
return ergebnis.fun, x
if __name__ == "__main__":
print("=" * 84)
print(" ZUORDNUNGSPROBLEM: DREI WEGE ZUM SELBEN ERGEBNIS")
print("=" * 84)
# --- Kleines Beispiel zum Nachvollziehen ------------------------------
kosten = np.array([[82., 83., 69., 92.],
[77., 37., 49., 92.],
[11., 69., 5., 86.],
[8., 9., 98., 23.]])
namen = ["Anna", "Ben", "Carla", "David"]
aufgaben = ["Auftrag W", "Auftrag X", "Auftrag Y", "Auftrag Z"]
print("\nKostenmatrix (wer bearbeitet was zu welchen Kosten?):")
print(f"{'':<8}" + "".join(f"{a:>12}" for a in aufgaben))
for i, name in enumerate(namen):
print(f"{name:<8}" + "".join(f"{kosten[i, j]:>12.0f}" for j in range(4)))
wert, zuordnung = loese_ungarisch(kosten)
print(f"\nOptimale Zuordnung (Gesamtkosten {wert:.0f}):")
for i, j in enumerate(zuordnung):
print(f" {namen[i]:<8} -> {aufgaben[j]:<12} ({kosten[i, j]:.0f} EUR)")
# --- Nachweis: LP ohne Ganzzahligkeit liefert trotzdem 0/1 -----------
wert_lp, x_lp = loese_lp(kosten, ganzzahlig=False)
ist_binaer = np.all((np.abs(x_lp) < 1e-9) | (np.abs(x_lp - 1) < 1e-9))
print(f"\nLP OHNE Ganzzahligkeitsforderung: Kosten {wert_lp:.0f}, "
f"Loesung ist {'0/1-wertig' if ist_binaer else 'GEBROCHEN'}")
print(" -> Satz von Birkhoff/von Neumann bestaetigt: Die Ecken sind Permutationen.")
# --- Laufzeitvergleich bei wachsender Groesse ------------------------
print("\n" + "-" * 84)
print(f"{'n':>4} | {'Ungarisch':>12} | {'LP (kontinuierlich)':>21} | "
f"{'MILP (ganzzahlig)':>19} | {'gleich?':>8}")
print("-" * 84)
for n in [10, 25, 50, 100]:
k = erzeuge_kosten(n)
t0 = time.perf_counter(); w1, _ = loese_ungarisch(k); t1 = time.perf_counter() - t0
t0 = time.perf_counter(); w2, _ = loese_lp(k, False); t2 = time.perf_counter() - t0
if n <= 50:
t0 = time.perf_counter(); w3, _ = loese_lp(k, True); t3 = time.perf_counter() - t0
t3_text, gleich = f"{t3*1000:>16.1f} ms", abs(w1 - w3) < 1e-6
else:
t3_text, gleich = f"{'uebersprungen':>19}", abs(w1 - w2) < 1e-6
print(f"{n:>4} | {t1*1000:>9.1f} ms | {t2*1000:>18.1f} ms | {t3_text} | "
f"{'ja' if gleich else 'NEIN':>8}")
print("-" * 84)
print("Fazit: Der spezialisierte Ungarische Algorithmus ist um Groessenordnungen")
print("schneller. Nutzen Sie fuer reine Zuordnungen NIE einen MILP-Solver.")
print("=" * 84)

View file

@ -0,0 +1,504 @@
#!/usr/bin/env python3
# or_kern.py
"""
Kapitel Praxisfallen: Der gemeinsame Unterbau fuer produktionsreife OR-Modelle.
Alle Beispiele dieses Buchs loesen dieselben vier Aufgaben immer wieder:
Daten einlesen und pruefen, ein Modell bauen, den Solverstatus auswerten, die
Loesung gegen die Wirklichkeit kontrollieren. Dieses Modul zieht diese vier
Aufgaben aus den Einzelprogrammen heraus.
Rohdaten (Excel/CSV)
-> Domaenenmodell (Pydantic, geprueft)
-> Modellbauer (solverabhaengig)
-> Loesung (DTO, solverunabhaengig)
-> Abnahmepruefung
Der Sinn dieser Trennung: Die Domaenenschicht weiss nichts von Solvern, die
Loesungsschicht weiss nichts vom Modellaufbau. Wer den Solver wechselt, tauscht
genau EINEN Baustein aus - der Rest bleibt.
WICHTIG ZUM IMPORT: Dieses Modul importiert von sich aus KEINE Solver-
bibliothek. Der Grund steht im Kapitel Oekosystem: ortools und highspy vertragen sich
nicht im selben Prozess. Die Statusuebersetzer laden ihre Bibliothek erst,
wenn sie aufgerufen werden - so bleibt or_kern.py mit jedem Solver benutzbar.
Benoetigt: pydantic (v2), numpy; optional pandas und openpyxl fuer Excel.
"""
from __future__ import annotations
from enum import Enum
from typing import Annotated, Any, Sequence
import numpy as np
from pydantic import BaseModel, Field, model_validator
# Wiederverwendbare Feldtypen. Sie tragen die Pruefung im Typ, nicht im Code -
# damit gilt sie ueberall, wo der Typ verwendet wird.
PositiveZahl = Annotated[float, Field(gt=0)]
NichtNegativ = Annotated[float, Field(ge=0)]
# --- 1. Solverstatus: eine Sprache fuer fuenf Bibliotheken -------------------
class SolverStatus(str, Enum):
"""Was ein Solverlauf ergeben hat - unabhaengig davon, wer gerechnet hat.
Die fuenf im Buch verwendeten Bibliotheken benennen dasselbe Ergebnis
unterschiedlich: 'Optimal', 'OPTIMAL', 2, 'optimal'. Wer darauf direkt
prueft, bindet seinen Auswertungscode an eine Bibliothek.
"""
OPTIMAL = "optimal" # beweisbar bestmoeglich
ZULAESSIG = "zulaessig" # brauchbare Loesung, Beweis fehlt
UNZULAESSIG = "unzulaessig" # es gibt keine Loesung (Aussage ueber das Modell)
UNBESCHRAENKT = "unbeschraenkt" # Ziel waechst ins Unendliche
ZEITLIMIT = "zeitlimit" # abgebrochen, nichts Brauchbares gefunden
FEHLERHAFT = "fehlerhaft" # das Modell ist kein gueltiges Modell
UNBEKANNT = "unbekannt" # alles Uebrige
@property
def brauchbar(self) -> bool:
"""Habe ich etwas in der Hand, das ich ausfuehren kann?"""
return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)
@property
def modellfehler(self) -> bool:
"""Liegt die Ursache im Modell (und nicht in der Rechenzeit)?"""
return self in (SolverStatus.UNZULAESSIG, SolverStatus.UNBESCHRAENKT,
SolverStatus.FEHLERHAFT)
def status_von_pywraplp(rohstatus: int) -> SolverStatus:
"""OR-Tools linear_solver (GLOP, SCIP, CBC)."""
from ortools.linear_solver import pywraplp
zuordnung = {
pywraplp.Solver.OPTIMAL: SolverStatus.OPTIMAL,
pywraplp.Solver.FEASIBLE: SolverStatus.ZULAESSIG,
pywraplp.Solver.INFEASIBLE: SolverStatus.UNZULAESSIG,
pywraplp.Solver.UNBOUNDED: SolverStatus.UNBESCHRAENKT,
pywraplp.Solver.ABNORMAL: SolverStatus.FEHLERHAFT,
pywraplp.Solver.NOT_SOLVED: SolverStatus.ZEITLIMIT,
}
return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
def status_von_cpsat(rohstatus: int) -> SolverStatus:
"""OR-Tools CP-SAT (siehe Kapitel CP-SAT, alle fuenf Faelle)."""
from ortools.sat.python import cp_model
zuordnung = {
cp_model.OPTIMAL: SolverStatus.OPTIMAL,
cp_model.FEASIBLE: SolverStatus.ZULAESSIG,
cp_model.INFEASIBLE: SolverStatus.UNZULAESSIG,
cp_model.MODEL_INVALID: SolverStatus.FEHLERHAFT,
cp_model.UNKNOWN: SolverStatus.ZEITLIMIT,
}
return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
def status_von_highs(statustext: str) -> SolverStatus:
"""HiGHS ueber highspy - hier kommt der Status als Text."""
zuordnung = {
"Optimal": SolverStatus.OPTIMAL,
"Time limit reached": SolverStatus.ZULAESSIG, # meist mit Loesung
"Solution limit reached": SolverStatus.ZULAESSIG,
"Infeasible": SolverStatus.UNZULAESSIG,
"Unbounded": SolverStatus.UNBESCHRAENKT,
"Primal infeasible or unbounded": SolverStatus.UNZULAESSIG,
"Model error": SolverStatus.FEHLERHAFT,
}
return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
def status_von_scipy(ergebnis: Any) -> SolverStatus:
"""scipy.optimize.linprog und milp liefern ein Ergebnisobjekt mit .status."""
zuordnung = {
0: SolverStatus.OPTIMAL,
1: SolverStatus.ZEITLIMIT, # Iterations-/Zeitgrenze
2: SolverStatus.UNZULAESSIG,
3: SolverStatus.UNBESCHRAENKT,
4: SolverStatus.FEHLERHAFT, # numerische Schwierigkeiten
}
return zuordnung.get(int(ergebnis.status), SolverStatus.UNBEKANNT)
def status_von_cvxpy(statustext: str) -> SolverStatus:
"""CVXPY - Statuszeichenketten wie 'optimal' oder 'infeasible'."""
zuordnung = {
"optimal": SolverStatus.OPTIMAL,
"optimal_inaccurate": SolverStatus.ZULAESSIG,
"infeasible": SolverStatus.UNZULAESSIG,
"infeasible_inaccurate": SolverStatus.UNZULAESSIG,
"unbounded": SolverStatus.UNBESCHRAENKT,
"unbounded_inaccurate": SolverStatus.UNBESCHRAENKT,
}
return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
# --- 2. Die Loesung als solverunabhaengiges Datenobjekt ----------------------
class Loesung(BaseModel):
"""Das Ergebnis eines Solverlaufs, so wie es weiterverarbeitet wird.
Bewusst OHNE Referenz auf Solver, Modell oder Variablen: Ein Bericht, ein
Test oder eine Weiterverarbeitung soll nicht wissen muessen, womit
gerechnet wurde.
"""
status: SolverStatus
werte: dict[str, float] = Field(default_factory=dict)
zielwert: float | None = None
schranke: float | None = None
laufzeit: float | None = None
# Schattenpreise je Nebenbedingung, sofern der Solver sie liefert
# (nur bei kontinuierlichen Problemen, siehe Kapitel LP). Auch das ist
# ein solverunabhaengiger Begriff und gehoert deshalb hierher.
schattenpreise: dict[str, float] = Field(default_factory=dict)
@property
def gap(self) -> float | None:
"""Relativer Abstand zwischen gefundener Loesung und bewiesener Schranke.
Das ist die Zahl fuer den Bericht: keine Schaetzung, sondern eine
Zusage - schlechter als das kann die Loesung nicht sein.
"""
if self.zielwert is None or self.schranke is None:
return None
nenner = max(abs(self.zielwert), 1e-9)
return abs(self.zielwert - self.schranke) / nenner
def als_bericht(self) -> str:
"""Eine Zeile fuers Betriebsprotokoll - siehe Betriebsueberwachung.py."""
teile = [f"Status: {self.status.value}"]
if self.zielwert is not None:
teile.append(f"Zielwert: {self.zielwert:,.2f}")
if self.gap is not None:
teile.append(f"Gap: {self.gap:.2%}")
if self.laufzeit is not None:
teile.append(f"Zeit: {self.laufzeit:.2f}s")
return " | ".join(teile)
# --- 3. Domaenenmodell: die Fakten, bevor ein Solver sie sieht ---------------
class Produkt(BaseModel):
"""Ein Produkt mit Deckungsbeitrag und Ressourcenverbrauch."""
name: str = Field(min_length=1)
deckungsbeitrag: float
verbrauch: dict[str, NichtNegativ]
model_config = {"frozen": True} # Stammdaten aendern sich nicht im Lauf
class Produktionsproblem(BaseModel):
"""Produktionsprogrammplanung - das Modell aus Kapitel Einfuehrung.
Die Pruefungen stehen hier und nicht im Solvercode. Damit gelten sie
unabhaengig davon, mit welcher Bibliothek spaeter gerechnet wird - und sie
schlagen beim EINLESEN zu, wo der Fehler noch zuzuordnen ist.
"""
produkte: list[Produkt] = Field(min_length=1)
kapazitaeten: dict[str, PositiveZahl]
@model_validator(mode="after")
def pruefe_ressourcen(self) -> "Produktionsproblem":
namen = [p.name for p in self.produkte]
if len(set(namen)) != len(namen):
doppelt = sorted({n for n in namen if namen.count(n) > 1})
raise ValueError(f"Produktnamen kommen mehrfach vor: {doppelt}")
for produkt in self.produkte:
unbekannt = set(produkt.verbrauch) - set(self.kapazitaeten)
if unbekannt:
raise ValueError(
f"Produkt '{produkt.name}' verbraucht Ressourcen ohne "
f"Kapazitaetsangabe: {sorted(unbekannt)}")
return self
@property
def ressourcen(self) -> list[str]:
"""Feste Reihenfolge - siehe die Spaltenfalle in Kapitel Finanzdaten."""
return sorted(self.kapazitaeten)
def verbrauchsmatrix(self) -> np.ndarray:
"""Zeilen = Ressourcen, Spalten = Produkte (Matrixform, Kapitel Fundament)."""
return np.array([[p.verbrauch.get(r, 0.0) for p in self.produkte]
for r in self.ressourcen])
def kapazitaetsvektor(self) -> np.ndarray:
return np.array([self.kapazitaeten[r] for r in self.ressourcen])
def deckungsbeitragsvektor(self) -> np.ndarray:
return np.array([p.deckungsbeitrag for p in self.produkte])
# --- 4. Abnahmepruefung: Loesung gegen Anforderung, ohne Solver --------------
def pruefe_loesung(problem: Produktionsproblem, loesung: Loesung,
ganzzahlig: Sequence[str] = (),
toleranz: float = 1e-6) -> list[str]:
"""Prueft eine Loesung gegen die Anforderungen - OHNE den Solver zu fragen.
Diese Funktion darf ausdruecklich KEIN Solverobjekt und keine
Modellvariable benutzen. Eine Pruefung aus denselben Bausteinen wie das
Modell prueft das Modell gegen sich selbst und findet nichts (siehe die
Denkfehler in den Kapiteln Graphen und MILP).
Liefert eine Liste von Beanstandungen; leer heisst bestanden.
"""
beanstandungen: list[str] = []
if not loesung.status.brauchbar:
return [f"Kein verwertbares Ergebnis (Status: {loesung.status.value})"]
fehlend = [p.name for p in problem.produkte if p.name not in loesung.werte]
if fehlend:
return [f"Loesung enthaelt keine Werte fuer: {fehlend}"]
mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
if (mengen < -toleranz).any():
negativ = [p.name for p, m in zip(problem.produkte, mengen) if m < -toleranz]
beanstandungen.append(f"negative Mengen bei: {negativ}")
verbrauch = problem.verbrauchsmatrix() @ mengen
for ressource, ist, grenze in zip(problem.ressourcen, verbrauch,
problem.kapazitaetsvektor()):
if ist > grenze + toleranz:
beanstandungen.append(
f"{ressource}: Verbrauch {ist:,.3f} ueber Kapazitaet {grenze:,.3f}")
for name in ganzzahlig:
wert = loesung.werte[name]
if abs(wert - round(wert)) > toleranz:
beanstandungen.append(f"'{name}' = {wert!r} ist nicht ganzzahlig")
if loesung.zielwert is not None:
nachgerechnet = float(problem.deckungsbeitragsvektor() @ mengen)
if abs(nachgerechnet - loesung.zielwert) > toleranz * max(1.0, abs(nachgerechnet)):
beanstandungen.append(
f"Zielwert {loesung.zielwert:,.4f} passt nicht zu den Mengen "
f"(nachgerechnet {nachgerechnet:,.4f})")
return beanstandungen
# --- 5. Ein Modellbauer je Solver - austauschbar ----------------------------
def loese_mit_glop(problem: Produktionsproblem) -> Loesung:
"""Modellbauer fuer OR-Tools GLOP (kontinuierlich)."""
import time
from ortools.linear_solver import pywraplp
solver = pywraplp.Solver.CreateSolver("GLOP")
menge = {p.name: solver.NumVar(0, solver.infinity(), p.name)
for p in problem.produkte}
# Die Nebenbedingung wird gemerkt - ohne die Referenz gibt es spaeter
# keinen Schattenpreis (siehe Kapitel LP).
bedingung = {}
for ressource in problem.ressourcen:
bedingung[ressource] = solver.Add(
sum(menge[p.name] * p.verbrauch.get(ressource, 0.0)
for p in problem.produkte)
<= problem.kapazitaeten[ressource], name=ressource)
solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag for p in problem.produkte))
t0 = time.perf_counter()
rohstatus = solver.Solve()
laufzeit = time.perf_counter() - t0
status = status_von_pywraplp(rohstatus)
if not status.brauchbar:
return Loesung(status=status, laufzeit=laufzeit)
return Loesung(status=status,
werte={name: v.solution_value() for name, v in menge.items()},
zielwert=solver.Objective().Value(),
schranke=solver.Objective().Value(),
schattenpreise={r: b.dual_value() for r, b in bedingung.items()},
laufzeit=laufzeit)
def loese_mit_scipy(problem: Produktionsproblem) -> Loesung:
"""Derselbe Fall mit scipy.optimize.linprog - anderer Solver, gleiche Loesung.
Beachten Sie, wie wenig hier steht: Die Daten kommen fertig geprueft aus
dem Domaenenmodell, die Auswertung geht ans gemeinsame Loesung-Objekt.
Genau das ist der Ertrag der Trennung.
"""
import time
from scipy.optimize import linprog
t0 = time.perf_counter()
ergebnis = linprog(c=-problem.deckungsbeitragsvektor(), # linprog minimiert
A_ub=problem.verbrauchsmatrix(),
b_ub=problem.kapazitaetsvektor(),
bounds=(0, None), method="highs")
laufzeit = time.perf_counter() - t0
status = status_von_scipy(ergebnis)
if not status.brauchbar:
return Loesung(status=status, laufzeit=laufzeit)
# linprog rechnet minimierend mit negierten Kosten - die Dualwerte
# muessen entsprechend zurueckgedreht werden.
return Loesung(status=status,
werte={p.name: float(x)
for p, x in zip(problem.produkte, ergebnis.x)},
zielwert=float(-ergebnis.fun),
schranke=float(-ergebnis.fun),
schattenpreise={r: float(-m) for r, m in
zip(problem.ressourcen,
ergebnis.ineqlin.marginals)},
laufzeit=laufzeit)
# --- 6. Ein- und Ausgabe: Tabellen sind die Wirklichkeit ---------------------
#
# In den meisten Betrieben liegen die Zahlen in einer Tabellenkalkulation
# (Kapitel Einfuehrung). Diese beiden Funktionen sind die einzige Stelle im
# Modul, die das weiss - alles andere arbeitet mit dem Domaenenmodell.
#
# pandas und openpyxl werden bewusst erst hier importiert: Wer or_kern nur
# fuer Statusauswertung und Pruefung benutzt, soll sie nicht installieren
# muessen.
STAMMSPALTEN = ("Produkt", "Deckungsbeitrag")
def lade_produktionsproblem(pfad: str, blatt_produkte: str = "Produkte",
blatt_kapazitaeten: str = "Kapazitaeten"
) -> Produktionsproblem:
"""Liest eine Excel-Mappe und liefert ein geprueftes Domaenenmodell.
Alle Spalten ausser 'Produkt' und 'Deckungsbeitrag' gelten als
Verbrauchsspalten - eine neue Ressource ist damit eine neue SPALTE in der
Mappe und erfordert keine Codeaenderung.
Die eigentliche Pruefung passiert nicht hier, sondern im Konstruktor von
Produktionsproblem. Diese Funktion uebersetzt nur Tabelle in Objekt; was
ein gueltiges Problem ist, steht an genau einer Stelle.
"""
import pandas as pd
produkte_tabelle = pd.read_excel(pfad, sheet_name=blatt_produkte)
kapazitaeten_tabelle = pd.read_excel(pfad, sheet_name=blatt_kapazitaeten)
fehlende = [s for s in STAMMSPALTEN if s not in produkte_tabelle.columns]
if fehlende:
raise ValueError(f"Blatt '{blatt_produkte}': Spalten fehlen: {fehlende}")
if produkte_tabelle.isna().any().any():
zeilen = produkte_tabelle[produkte_tabelle.isna().any(axis=1)].index.tolist()
raise ValueError(f"Blatt '{blatt_produkte}': leere Zellen in Zeile(n) {zeilen}")
ressourcen = [s for s in produkte_tabelle.columns if s not in STAMMSPALTEN]
produkte = [
Produkt(name=str(zeile.Produkt),
deckungsbeitrag=float(zeile.Deckungsbeitrag),
verbrauch={r: float(getattr(zeile, r)) for r in ressourcen})
for zeile in produkte_tabelle.itertuples()
]
kapazitaeten = {str(z.Ressource): float(z.Verfuegbar)
for z in kapazitaeten_tabelle.itertuples()}
return Produktionsproblem(produkte=produkte, kapazitaeten=kapazitaeten)
def schreibe_ergebnis(pfad: str, problem: Produktionsproblem,
loesung: Loesung) -> None:
"""Schreibt Plan und Kennzahlen als zwei Blaetter zurueck nach Excel.
Die Fachabteilung bekommt das Ergebnis im Format, das sie ohnehin benutzt.
Akzeptanz entscheidet ueber Projekterfolg (Kapitel Praxisfallen).
"""
import pandas as pd
if not loesung.status.brauchbar:
raise ValueError(f"Nichts zu schreiben - Status: {loesung.status.value}")
mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
plan = pd.DataFrame({
"Produkt": [p.name for p in problem.produkte],
"Menge": mengen,
"Deckungsbeitrag": mengen * problem.deckungsbeitragsvektor(),
})
verbrauch = problem.verbrauchsmatrix() @ mengen
kennzahlen: dict[str, float | str] = {
"Status": loesung.status.value,
"Gesamtdeckungsbeitrag": loesung.zielwert or 0.0,
}
for ressource, ist, grenze in zip(problem.ressourcen, verbrauch,
problem.kapazitaetsvektor()):
kennzahlen[f"{ressource}: Verbrauch"] = float(ist)
kennzahlen[f"{ressource}: Auslastung %"] = float(100.0 * ist / grenze)
if ressource in loesung.schattenpreise:
kennzahlen[f"{ressource}: Schattenpreis"] = \
loesung.schattenpreise[ressource]
kennzahlen_tabelle = pd.DataFrame({
"Kennzahl": list(kennzahlen),
"Wert": [round(w, 2) if isinstance(w, float) else w
for w in kennzahlen.values()],
})
with pd.ExcelWriter(pfad, engine="openpyxl") as mappe:
plan.round(2).to_excel(mappe, sheet_name="Plan", index=False)
kennzahlen_tabelle.to_excel(mappe, sheet_name="Kennzahlen", index=False)
if __name__ == "__main__":
# Die Schreinerei aus Kapitel Einfuehrung - jetzt als Domaenenmodell.
schreinerei = Produktionsproblem(
produkte=[
Produkt(name="Tisch", deckungsbeitrag=240.0,
verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
Produkt(name="Stuhl", deckungsbeitrag=60.0,
verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
],
kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
)
print("=" * 78)
print(" DERSELBE FALL, ZWEI SOLVER, EIN AUSWERTUNGSCODE")
print("=" * 78)
for name, bauer in [("OR-Tools GLOP", loese_mit_glop),
("scipy / HiGHS", loese_mit_scipy)]:
loesung = bauer(schreinerei)
beanstandungen = pruefe_loesung(schreinerei, loesung)
print(f"\n{name}")
print(f" {loesung.als_bericht()}")
for produkt, menge in loesung.werte.items():
print(f" {produkt:<10} {menge:8.2f}")
print(f" Abnahmepruefung: "
f"{'bestanden' if not beanstandungen else beanstandungen}")
print("\n" + "=" * 78)
print(" WAS DIE PRUEFUNGEN ABFANGEN")
print("=" * 78)
faelle = [
("Kapazitaet 0", dict(
produkte=schreinerei.produkte,
kapazitaeten={"Montagestunden": 0.0, "Plattenmaterial": 240.0})),
("Ressource ohne Kapazitaet", dict(
produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
verbrauch={"Lackieren": 2.0})],
kapazitaeten={"Montagestunden": 150.0})),
("doppelter Produktname", dict(
produkte=[schreinerei.produkte[0], schreinerei.produkte[0]],
kapazitaeten=schreinerei.kapazitaeten)),
]
for beschreibung, daten in faelle:
try:
Produktionsproblem(**daten)
print(f" {beschreibung:<28} NICHT erkannt (!)")
except Exception as fehler:
meldung = str(fehler).splitlines()
kern = next((z.strip() for z in meldung
if "Value error" in z or "greater than" in z), meldung[-1])
print(f" {beschreibung:<28} abgefangen: {kern[:44]}")
print("\nAlle drei scheitern beim EINLESEN - nicht erst beim Loesen und")
print("schon gar nicht erst im Bericht. Das ist der ganze Zweck der")
print("Domaenenschicht.")
print("=" * 78)

View file

@ -0,0 +1,309 @@
#!/usr/bin/env python3
# test_or_kern.py
"""
Kapitel Testen: Eine Testsuite fuer ein Optimierungsmodell.
Das Grundproblem beim Testen von Optimierungsmodellen: Man kennt die richtige
Antwort nicht. Bei einer Funktion `steuer(brutto)` schreibt man den erwarteten
Wert hin. Bei "der beste Produktionsplan fuer 400 Auftraege" gibt es niemanden,
der ihn unabhaengig ausrechnen koennte - sonst braeuchte man den Solver nicht.
Deshalb testet man nicht den WERT, sondern vier andere Dinge:
1. EIGENSCHAFTEN Die Loesung haelt jede Nebenbedingung ein, und der
ausgewiesene Zielwert passt zu den Mengen.
2. INVARIANTEN Was das Ergebnis NICHT aendern darf: die Reihenfolge der
Produkte, die Waehrungseinheit, ein zusaetzliches Produkt
mit Deckungsbeitrag 0.
3. REGRESSION Eine kleine Instanz mit von Hand nachgerechnetem Optimum.
4. FEHLERFAELLE Unsinnige Eingaben werden abgewiesen, unloesbare Modelle
als unloesbar erkannt - und die Abnahmepruefung schlaegt
tatsaechlich an, wenn man ihr eine kaputte Loesung gibt.
Der vierte Punkt ist der wichtigste und wird am haeufigsten vergessen: Eine
Pruefung, die noch nie etwas gefunden hat, ist keine Pruefung, sondern eine
Vermutung. Die Tests unten fuettern `pruefe_loesung()` deshalb absichtlich mit
falschen Loesungen und verlangen, dass sie anschlaegt.
Aufruf:
pytest test_or_kern.py -v
python3 test_or_kern.py # ruft pytest selbst auf
Benoetigt: pytest, numpy, pydantic, ortools, scipy (ueber or_kern)
"""
from __future__ import annotations
import pytest
from or_kern import (Loesung, Produkt, Produktionsproblem, SolverStatus,
loese_mit_glop, loese_mit_scipy, pruefe_loesung,
status_von_scipy)
# --- Die Instanz, gegen die getestet wird ----------------------------------
def schreinerei() -> Produktionsproblem:
"""Der Fall aus Kapitel Einfuehrung - klein genug fuer die Handrechnung.
Optimum: 30 Tische, 60 Stuehle, Deckungsbeitrag 10.800 EUR.
Beide Ressourcen sind voll ausgelastet (3*30 + 1*60 = 150,
6*30 + 1*60 = 240).
"""
return Produktionsproblem(
produkte=[
Produkt(name="Tisch", deckungsbeitrag=240.0,
verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
Produkt(name="Stuhl", deckungsbeitrag=60.0,
verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
],
kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
)
# Beide Modellbauer durchlaufen JEDEN Test. Ein Test, der nur mit einem Solver
# laeuft, prueft die Bibliothek mit - nicht das Modell.
MODELLBAUER = [loese_mit_glop, loese_mit_scipy]
NAMEN = ["glop", "scipy"]
@pytest.fixture(params=MODELLBAUER, ids=NAMEN)
def bauer(request):
return request.param
# --- 1. Eigenschaften ------------------------------------------------------
def test_loesung_haelt_alle_nebenbedingungen_ein(bauer):
"""Die Abnahmepruefung darf nichts zu beanstanden haben."""
problem = schreinerei()
loesung = bauer(problem)
assert loesung.status is SolverStatus.OPTIMAL
assert pruefe_loesung(problem, loesung) == []
def test_zielwert_passt_zu_den_mengen(bauer):
"""Der ausgewiesene Zielwert wird unabhaengig nachgerechnet.
Klingt trivial, ist es nicht: Wer die Zielfunktion im Modell anders
zusammensetzt als im Bericht (etwa Gebuehren einmal abgezogen, einmal
nicht), merkt es sonst nie.
"""
problem = schreinerei()
loesung = bauer(problem)
nachgerechnet = sum(p.deckungsbeitrag * loesung.werte[p.name]
for p in problem.produkte)
assert loesung.zielwert == pytest.approx(nachgerechnet, abs=1e-6)
def test_beide_solver_liefern_dasselbe():
"""Der Kern der Architektur aus Kapitel Praxisfallen, als Test."""
problem = schreinerei()
glop, scipy_ = loese_mit_glop(problem), loese_mit_scipy(problem)
assert glop.zielwert == pytest.approx(scipy_.zielwert, abs=1e-6)
for ressource in problem.ressourcen:
assert glop.schattenpreise[ressource] == pytest.approx(
scipy_.schattenpreise[ressource], abs=1e-6)
# --- 2. Invarianten --------------------------------------------------------
def test_produktreihenfolge_aendert_nichts(bauer):
"""Dieselben Daten in anderer Zeilenreihenfolge - gleiches Ergebnis.
Das faengt die Spaltenvertauschungsfalle aus Kapitel Finanzdaten ab: ein
Modell, das Positionen statt Namen benutzt, faellt hier durch.
"""
problem = schreinerei()
gedreht = Produktionsproblem(produkte=list(reversed(problem.produkte)),
kapazitaeten=problem.kapazitaeten)
assert bauer(problem).zielwert == pytest.approx(bauer(gedreht).zielwert)
def test_waehrungseinheit_skaliert_linear(bauer):
"""Deckungsbeitraege in Cent statt Euro: Zielwert mal 100, Mengen gleich.
Der Test prueft nicht die Mathematik - die ist offensichtlich -, sondern
die NUMERIK. Wer schlecht skalierte Modelle baut (Kapitel Fundament),
bekommt hier Abweichungen weit ueber der Toleranz.
"""
problem = schreinerei()
in_cent = Produktionsproblem(
produkte=[Produkt(name=p.name, deckungsbeitrag=p.deckungsbeitrag * 100,
verbrauch=p.verbrauch) for p in problem.produkte],
kapazitaeten=problem.kapazitaeten)
basis, skaliert = bauer(problem), bauer(in_cent)
assert skaliert.zielwert == pytest.approx(basis.zielwert * 100, rel=1e-9)
for p in problem.produkte:
assert skaliert.werte[p.name] == pytest.approx(basis.werte[p.name],
abs=1e-6)
def test_produkt_ohne_deckungsbeitrag_aendert_das_optimum_nicht(bauer):
"""Ein Produkt, das nichts einbringt, darf den Zielwert nicht heben."""
problem = schreinerei()
mit_nullprodukt = Produktionsproblem(
produkte=problem.produkte + [
Produkt(name="Muster", deckungsbeitrag=0.0,
verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0})],
kapazitaeten=problem.kapazitaeten)
assert bauer(mit_nullprodukt).zielwert <= bauer(problem).zielwert + 1e-6
# --- 3. Regression ---------------------------------------------------------
def test_bekanntes_optimum(bauer):
"""Von Hand nachgerechnet (Kapitel Einfuehrung, Handrechnung).
Das ist der einzige Test, der einen ZAHLENWERT festschreibt - und er darf
es, weil dieser Wert von Hand belegt ist. Fuer grosse Instanzen gibt es
diesen Test nicht; dort tragen die Eigenschaften und Invarianten.
"""
loesung = bauer(schreinerei())
assert loesung.zielwert == pytest.approx(10_800.0, abs=1e-6)
assert loesung.werte["Tisch"] == pytest.approx(30.0, abs=1e-6)
assert loesung.werte["Stuhl"] == pytest.approx(60.0, abs=1e-6)
def test_schattenpreise_bekannt(bauer):
"""40 EUR je Montagestunde, 20 EUR je Einheit Plattenmaterial."""
loesung = bauer(schreinerei())
assert loesung.schattenpreise["Montagestunden"] == pytest.approx(40.0, abs=1e-6)
assert loesung.schattenpreise["Plattenmaterial"] == pytest.approx(20.0, abs=1e-6)
# --- 4. Fehlerfaelle: schlaegt die Pruefung ueberhaupt an? -----------------
def test_pruefung_findet_kapazitaetsverletzung():
"""Eine Loesung, die zu viel verbraucht, MUSS beanstandet werden."""
problem = schreinerei()
kaputt = Loesung(status=SolverStatus.OPTIMAL,
werte={"Tisch": 50.0, "Stuhl": 60.0}, # 3*50+60 = 210 > 150
zielwert=50 * 240.0 + 60 * 60.0)
beanstandungen = pruefe_loesung(problem, kaputt)
assert any("Montagestunden" in b for b in beanstandungen)
def test_pruefung_findet_falschen_zielwert():
"""Mengen und Zielwert passen nicht zusammen - der haeufigste stille Fehler."""
problem = schreinerei()
kaputt = Loesung(status=SolverStatus.OPTIMAL,
werte={"Tisch": 30.0, "Stuhl": 60.0},
zielwert=99_999.0)
assert any("Zielwert" in b for b in pruefe_loesung(problem, kaputt))
def test_pruefung_findet_negative_mengen():
problem = schreinerei()
kaputt = Loesung(status=SolverStatus.OPTIMAL,
werte={"Tisch": -5.0, "Stuhl": 60.0},
zielwert=-5 * 240.0 + 60 * 60.0)
assert any("negativ" in b for b in pruefe_loesung(problem, kaputt))
def test_pruefung_findet_gebrochene_ganzzahligkeit():
"""Die Falle aus Kapitel MILP: 0,99999998 ist nicht 1."""
problem = schreinerei()
fast_ganz = Loesung(status=SolverStatus.OPTIMAL,
werte={"Tisch": 29.4, "Stuhl": 61.8},
zielwert=29.4 * 240.0 + 61.8 * 60.0)
beanstandungen = pruefe_loesung(problem, fast_ganz, ganzzahlig=["Tisch"])
assert any("ganzzahlig" in b for b in beanstandungen)
def test_pruefung_verweigert_unbrauchbaren_status():
"""Ohne verwertbares Ergebnis wird gar nicht erst gerechnet."""
problem = schreinerei()
ohne = Loesung(status=SolverStatus.UNZULAESSIG)
beanstandungen = pruefe_loesung(problem, ohne)
assert len(beanstandungen) == 1 and "unzulaessig" in beanstandungen[0]
@pytest.mark.parametrize("daten, stichwort", [
(dict(produkte=[Produkt(name="Tisch", deckungsbeitrag=240.0,
verbrauch={"Montagestunden": 3.0})],
kapazitaeten={"Montagestunden": 0.0}), "greater than"),
(dict(produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
verbrauch={"Lackieren": 2.0})],
kapazitaeten={"Montagestunden": 150.0}), "Kapazitaetsangabe"),
(dict(produkte=[], kapazitaeten={"Montagestunden": 150.0}), "at least 1"),
])
def test_unsinnige_eingaben_werden_beim_einlesen_abgewiesen(daten, stichwort):
"""Drei Fehler, die NICHT erst beim Loesen auffallen duerfen."""
with pytest.raises(Exception) as fehler:
Produktionsproblem(**daten)
assert stichwort in str(fehler.value)
def test_doppelter_produktname_wird_abgewiesen():
produkt = Produkt(name="Tisch", deckungsbeitrag=240.0,
verbrauch={"Montagestunden": 3.0})
with pytest.raises(Exception) as fehler:
Produktionsproblem(produkte=[produkt, produkt],
kapazitaeten={"Montagestunden": 150.0})
assert "mehrfach" in str(fehler.value)
@pytest.mark.parametrize("rohstatus, erwartet", [
(0, SolverStatus.OPTIMAL),
(1, SolverStatus.ZEITLIMIT),
(2, SolverStatus.UNZULAESSIG),
(3, SolverStatus.UNBESCHRAENKT),
(4, SolverStatus.FEHLERHAFT),
(99, SolverStatus.UNBEKANNT),
])
def test_statusuebersetzung_scipy(rohstatus, erwartet):
"""Die Uebersetzertabelle selbst - inklusive des unbekannten Falls."""
class Ergebnis:
status = rohstatus
assert status_von_scipy(Ergebnis()) is erwartet
def test_status_eigenschaften_sind_konsistent():
"""brauchbar und modellfehler duerfen sich nie ueberschneiden."""
for status in SolverStatus:
assert not (status.brauchbar and status.modellfehler)
# Die beiden folgenden Tests gab es zuerst nicht. Sie sind entstanden, weil
# Mutationstest.py zwei eingebaute Fehler UEBERLEBEN liess - siehe den
# Abschnitt "Wer testet die Tests?".
def test_nur_optimal_und_zulaessig_gelten_als_brauchbar():
"""ZEITLIMIT ist NICHT brauchbar - der Solver hat nichts gefunden.
Der Unterschied entscheidet, ob ein Nachtjob einen Plan ausliefert oder
Alarm schlaegt. Ein aufgeweichtes 'brauchbar' faellt sonst nirgends auf.
"""
assert SolverStatus.OPTIMAL.brauchbar
assert SolverStatus.ZULAESSIG.brauchbar
for status in (SolverStatus.ZEITLIMIT, SolverStatus.UNZULAESSIG,
SolverStatus.UNBESCHRAENKT, SolverStatus.FEHLERHAFT,
SolverStatus.UNBEKANNT):
assert not status.brauchbar, f"{status.value} darf nicht brauchbar sein"
def test_ressourcenreihenfolge_haengt_nicht_an_der_eingabe():
"""Zwei Mappings mit gleichem Inhalt, andere Einfuegereihenfolge.
Die Verbrauchsmatrix muss zeilenweise identisch sein. Ohne diese
Zusicherung passt die Matrix irgendwann nicht mehr zum
Kapazitaetsvektor - die Vertauschungsfalle aus Kapitel Finanzdaten,
nur eine Ebene tiefer.
"""
produkte = schreinerei().produkte
vorwaerts = Produktionsproblem(
produkte=produkte,
kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
rueckwaerts = Produktionsproblem(
produkte=produkte,
kapazitaeten={"Plattenmaterial": 240.0, "Montagestunden": 150.0})
assert vorwaerts.ressourcen == rueckwaerts.ressourcen
assert (vorwaerts.verbrauchsmatrix() == rueckwaerts.verbrauchsmatrix()).all()
assert (vorwaerts.kapazitaetsvektor() == rueckwaerts.kapazitaetsvektor()).all()
if __name__ == "__main__":
import sys
sys.exit(pytest.main([__file__, "-v", "--tb=short", "-p", "no:cacheprovider"]))