Version 04 als eigenes Repository
Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
b7af2f1d9a
468 changed files with 262141 additions and 0 deletions
96
OR_HTML_04/programme/Backtest_Fallen.py
Normal file
96
OR_HTML_04/programme/Backtest_Fallen.py
Normal 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)
|
||||
83
OR_HTML_04/programme/Bausteine_Vorlage.py
Normal file
83
OR_HTML_04/programme/Bausteine_Vorlage.py
Normal 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.")
|
||||
85
OR_HTML_04/programme/Bellman_Minimalbeispiel.py
Normal file
85
OR_HTML_04/programme/Bellman_Minimalbeispiel.py
Normal 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)
|
||||
202
OR_HTML_04/programme/Benchmark_Skalierung.py
Normal file
202
OR_HTML_04/programme/Benchmark_Skalierung.py
Normal 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)
|
||||
180
OR_HTML_04/programme/Betriebsueberwachung.py
Normal file
180
OR_HTML_04/programme/Betriebsueberwachung.py
Normal 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)
|
||||
194
OR_HTML_04/programme/Big_M_Falle.py
Normal file
194
OR_HTML_04/programme/Big_M_Falle.py
Normal 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)
|
||||
96
OR_HTML_04/programme/Bot_Allokation.py
Normal file
96
OR_HTML_04/programme/Bot_Allokation.py
Normal 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()
|
||||
103
OR_HTML_04/programme/Brute_Force_Vergleich.py
Normal file
103
OR_HTML_04/programme/Brute_Force_Vergleich.py
Normal 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)
|
||||
223
OR_HTML_04/programme/CP_SAT_Statusfaelle.py
Normal file
223
OR_HTML_04/programme/CP_SAT_Statusfaelle.py
Normal 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)
|
||||
242
OR_HTML_04/programme/CP_SAT_Vertretungssystem.py
Normal file
242
OR_HTML_04/programme/CP_SAT_Vertretungssystem.py
Normal 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()
|
||||
188
OR_HTML_04/programme/CVaR_Portfolio.py
Normal file
188
OR_HTML_04/programme/CVaR_Portfolio.py
Normal 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)
|
||||
133
OR_HTML_04/programme/Data_Snooping.py
Normal file
133
OR_HTML_04/programme/Data_Snooping.py
Normal 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)
|
||||
47
OR_HTML_04/programme/Diversifikation_Demo.py
Normal file
47
OR_HTML_04/programme/Diversifikation_Demo.py
Normal 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.")
|
||||
78
OR_HTML_04/programme/Dualitaet_Nachweis.py
Normal file
78
OR_HTML_04/programme/Dualitaet_Nachweis.py
Normal 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)
|
||||
120
OR_HTML_04/programme/Ein_System_Vier_Ansaetze.py
Normal file
120
OR_HTML_04/programme/Ein_System_Vier_Ansaetze.py
Normal 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)
|
||||
138
OR_HTML_04/programme/Entropie_Maximierte_Allokation.py
Normal file
138
OR_HTML_04/programme/Entropie_Maximierte_Allokation.py
Normal 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)
|
||||
85
OR_HTML_04/programme/Erklaerbarkeit.py
Normal file
85
OR_HTML_04/programme/Erklaerbarkeit.py
Normal 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)
|
||||
96
OR_HTML_04/programme/Excel_Bruecke.py
Normal file
96
OR_HTML_04/programme/Excel_Bruecke.py
Normal 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}")
|
||||
147
OR_HTML_04/programme/Finanzdaten_Ledoit_Wolf.py
Normal file
147
OR_HTML_04/programme/Finanzdaten_Ledoit_Wolf.py
Normal 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)
|
||||
53
OR_HTML_04/programme/Fluch_des_Durchschnitts.py
Normal file
53
OR_HTML_04/programme/Fluch_des_Durchschnitts.py
Normal 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.")
|
||||
112
OR_HTML_04/programme/Infeasibility_Diagnose.py
Normal file
112
OR_HTML_04/programme/Infeasibility_Diagnose.py
Normal 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)
|
||||
119
OR_HTML_04/programme/Installationstest.py
Normal file
119
OR_HTML_04/programme/Installationstest.py
Normal 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)
|
||||
113
OR_HTML_04/programme/JobShop_Intervalle.py
Normal file
113
OR_HTML_04/programme/JobShop_Intervalle.py
Normal 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()
|
||||
110
OR_HTML_04/programme/KKT_Nachweis.py
Normal file
110
OR_HTML_04/programme/KKT_Nachweis.py
Normal 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)
|
||||
225
OR_HTML_04/programme/Konfliktsuche.py
Normal file
225
OR_HTML_04/programme/Konfliktsuche.py
Normal 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)
|
||||
73
OR_HTML_04/programme/Konvexitaet_Demo.py
Normal file
73
OR_HTML_04/programme/Konvexitaet_Demo.py
Normal 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)
|
||||
123
OR_HTML_04/programme/Kovarianz_Falle.py
Normal file
123
OR_HTML_04/programme/Kovarianz_Falle.py
Normal 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)
|
||||
338
OR_HTML_04/programme/Kraftwerkseinsatz.py
Normal file
338
OR_HTML_04/programme/Kraftwerkseinsatz.py
Normal 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)
|
||||
286
OR_HTML_04/programme/Large_Neighborhood_Search.py
Normal file
286
OR_HTML_04/programme/Large_Neighborhood_Search.py
Normal 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)
|
||||
168
OR_HTML_04/programme/Lokale_Optima_Multistart.py
Normal file
168
OR_HTML_04/programme/Lokale_Optima_Multistart.py
Normal 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)
|
||||
203
OR_HTML_04/programme/MILP_Portfolio_Fixgebuehren.py
Normal file
203
OR_HTML_04/programme/MILP_Portfolio_Fixgebuehren.py
Normal 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)
|
||||
263
OR_HTML_04/programme/Markowitz_CVXPY.py
Normal file
263
OR_HTML_04/programme/Markowitz_CVXPY.py
Normal 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)
|
||||
54
OR_HTML_04/programme/Matrixform.py
Normal file
54
OR_HTML_04/programme/Matrixform.py
Normal 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.")
|
||||
211
OR_HTML_04/programme/Mehrperiodige_Order_Execution.py
Normal file
211
OR_HTML_04/programme/Mehrperiodige_Order_Execution.py
Normal 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)
|
||||
260
OR_HTML_04/programme/Mehrziel_Pareto.py
Normal file
260
OR_HTML_04/programme/Mehrziel_Pareto.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Mehrziel_Pareto.py
|
||||
"""
|
||||
Kapitel Mehrziel: Kosten gegen CO2 - und warum Gewichte nicht genuegen.
|
||||
|
||||
Eine Spedition vergibt zwoelf Sendungen an drei Verkehrstraeger: LKW (schnell,
|
||||
teuer, schmutzig), Bahn (billig und sauber, aber nur fuenf Trassen frei) und
|
||||
Kombinierten Verkehr (dazwischen). Zwei Ziele stehen gegeneinander:
|
||||
Transportkosten und CO2-Ausstoss.
|
||||
|
||||
Der uebliche Reflex ist, beide Ziele zu einem zusammenzuruehren:
|
||||
|
||||
minimiere Kosten + w * CO2
|
||||
|
||||
Das ist bequem, liefert zulaessige Loesungen - und ist unvollstaendig. Bei
|
||||
ganzzahligen Entscheidungen gibt es Kompromisse, die auf diesem Weg
|
||||
GRUNDSAETZLICH nicht erreichbar sind, egal welches w man waehlt. Nicht "schwer
|
||||
zu finden", sondern beweisbar unerreichbar.
|
||||
|
||||
Das Programm zeigt in vier Teilen:
|
||||
|
||||
1. Die beiden Extreme - was jedes Ziel allein kostet.
|
||||
2. Die lineare Skalarisierung ueber ein feines Gewichtsraster.
|
||||
3. Die vollstaendige Pareto-Front ueber das eps-Constraint-Verfahren.
|
||||
4. Den Nachweis, dass die Luecke keine Frage des Rasters ist, sondern
|
||||
Geometrie: Die fehlenden Punkte liegen strikt oberhalb der konvexen
|
||||
Huelle und koennen deshalb von keiner Geraden gestuetzt werden.
|
||||
|
||||
Benoetigt: numpy, scipy
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import linprog
|
||||
|
||||
TRAEGER = ["LKW", "Bahn", "Kombiniert"]
|
||||
BAHN_TRASSEN = 5 # so viele Sendungen passen hoechstens auf die Bahn
|
||||
SAAT = 5
|
||||
|
||||
|
||||
def erzeuge_sendungen(anzahl: int = 12, saat: int = SAAT):
|
||||
"""Kosten und CO2 je Sendung und Verkehrstraeger.
|
||||
|
||||
Die Bahn ist immer billiger und sauberer als der LKW - der Zielkonflikt
|
||||
entsteht nicht zwischen den Traegern, sondern durch die KNAPPHEIT der
|
||||
Trassen. Genau so sieht es in der Praxis aus.
|
||||
"""
|
||||
rng = np.random.default_rng(saat)
|
||||
kosten = np.zeros((anzahl, 3))
|
||||
co2 = np.zeros((anzahl, 3))
|
||||
for i in range(anzahl):
|
||||
grund_kosten = rng.integers(600, 2400)
|
||||
grund_co2 = rng.integers(400, 1800)
|
||||
kosten[i] = [grund_kosten,
|
||||
grund_kosten * rng.uniform(0.55, 0.85),
|
||||
grund_kosten * rng.uniform(0.70, 1.00)]
|
||||
co2[i] = [grund_co2,
|
||||
grund_co2 * rng.uniform(0.15, 0.35),
|
||||
grund_co2 * rng.uniform(0.40, 0.70)]
|
||||
return np.round(kosten).astype(int), np.round(co2).astype(int)
|
||||
|
||||
|
||||
KOSTEN, CO2 = erzeuge_sendungen()
|
||||
N = len(KOSTEN)
|
||||
|
||||
|
||||
def plane(ziel: np.ndarray, co2_grenze: float | None = None,
|
||||
kosten_grenze: float | None = None):
|
||||
"""Jede Sendung genau einem Traeger zuordnen; Trassen sind knapp.
|
||||
|
||||
'ziel' ist die zu minimierende Matrix (Kosten, CO2 oder eine Mischung).
|
||||
Die beiden Grenzen sind das Werkzeug fuer eps-Constraint und
|
||||
lexikografische Optimierung - sie machen aus einem Ziel eine Schranke.
|
||||
"""
|
||||
gleichungen = np.zeros((N, N * 3))
|
||||
for i in range(N):
|
||||
gleichungen[i, i * 3:(i + 1) * 3] = 1.0 # genau ein Traeger
|
||||
|
||||
ungleichungen = [[1.0 if j == 1 else 0.0 for _ in range(N) for j in range(3)]]
|
||||
grenzen = [float(BAHN_TRASSEN)]
|
||||
if co2_grenze is not None:
|
||||
ungleichungen.append(CO2.reshape(-1).astype(float))
|
||||
grenzen.append(float(co2_grenze))
|
||||
if kosten_grenze is not None:
|
||||
ungleichungen.append(KOSTEN.reshape(-1).astype(float))
|
||||
grenzen.append(float(kosten_grenze))
|
||||
|
||||
ergebnis = linprog(ziel.reshape(-1).astype(float),
|
||||
A_ub=ungleichungen, b_ub=grenzen,
|
||||
A_eq=gleichungen, b_eq=np.ones(N),
|
||||
bounds=(0, 1), integrality=1, method="highs")
|
||||
if not ergebnis.success:
|
||||
return None
|
||||
plan = np.round(ergebnis.x).astype(int)
|
||||
return (int(KOSTEN.reshape(-1) @ plan), int(CO2.reshape(-1) @ plan), plan)
|
||||
|
||||
|
||||
def pareto_front():
|
||||
"""Vollstaendige Front ueber das eps-Constraint-Verfahren.
|
||||
|
||||
Der Ablauf ist der eigentliche Inhalt dieser Funktion: Erst das
|
||||
Kostenminimum bestimmen (der eine Rand der Front), dann die CO2-Schranke
|
||||
schrittweise um genau ein Kilogramm unter den zuletzt erreichten Wert
|
||||
druecken. Jeder Lauf liefert den naechsten Punkt - und wenn keiner mehr
|
||||
zulaessig ist, ist die Front vollstaendig.
|
||||
|
||||
Das braucht so viele Solveraufrufe, wie die Front Punkte hat. Ein Raster
|
||||
ueber alle moeglichen CO2-Werte braeuchte hier fast tausend.
|
||||
"""
|
||||
front = []
|
||||
start = plane(KOSTEN)
|
||||
grenze = start[1]
|
||||
while True:
|
||||
ergebnis = plane(KOSTEN, co2_grenze=grenze)
|
||||
if ergebnis is None:
|
||||
break
|
||||
front.append((ergebnis[0], ergebnis[1]))
|
||||
grenze = ergebnis[1] - 1
|
||||
return front
|
||||
|
||||
|
||||
def skalarisierung(gewichte):
|
||||
"""Was 'Kosten + w * CO2' fuer viele w hergibt - als Menge von Punkten."""
|
||||
gefunden = {}
|
||||
for w in gewichte:
|
||||
ergebnis = plane(KOSTEN + w * CO2)
|
||||
if ergebnis:
|
||||
gefunden.setdefault((ergebnis[0], ergebnis[1]), []).append(w)
|
||||
return gefunden
|
||||
|
||||
|
||||
def untere_huelle(punkte):
|
||||
"""Untere linke konvexe Huelle - genau die Punkte, die eine Gerade stuetzt.
|
||||
|
||||
Der Zusammenhang, um den es geht: 'Kosten + w*CO2 minimieren' heisst
|
||||
geometrisch, eine Gerade der Steigung -1/w von links unten an die
|
||||
Punktwolke zu schieben. Sie beruehrt immer einen Eckpunkt der konvexen
|
||||
Huelle. Punkte, die oberhalb liegen, werden nie beruehrt - fuer kein w.
|
||||
"""
|
||||
huelle = []
|
||||
for punkt in sorted(punkte):
|
||||
while len(huelle) >= 2:
|
||||
(x1, y1), (x2, y2) = huelle[-2], huelle[-1]
|
||||
if (x2 - x1) * (punkt[1] - y1) - (y2 - y1) * (punkt[0] - x1) <= 0:
|
||||
huelle.pop()
|
||||
else:
|
||||
break
|
||||
huelle.append(punkt)
|
||||
return huelle
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 80)
|
||||
print(" KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN")
|
||||
print("=" * 80)
|
||||
print(f"{N} Sendungen, {len(TRAEGER)} Verkehrstraeger, "
|
||||
f"{BAHN_TRASSEN} freie Bahntrassen.\n")
|
||||
|
||||
# --- 1. Die beiden Extreme -------------------------------------------
|
||||
guenstigst = plane(KOSTEN)
|
||||
saubersten = plane(CO2)
|
||||
print("1. Was jedes Ziel allein ergibt\n")
|
||||
print(f" {'':<22} {'Kosten':>10} {'CO2':>10}")
|
||||
print(" " + "-" * 44)
|
||||
print(f" {'nur Kosten minimal':<22} {guenstigst[0]:>10,} {guenstigst[1]:>9,} kg")
|
||||
print(f" {'nur CO2 minimal':<22} {saubersten[0]:>10,} {saubersten[1]:>9,} kg")
|
||||
print(f"\n Der Zielkonflikt ist echt, aber klein: "
|
||||
f"{saubersten[0] - guenstigst[0]:,} EUR mehr")
|
||||
print(f" ({(saubersten[0] - guenstigst[0]) / guenstigst[0] * 100:.1f} %) sparen "
|
||||
f"{guenstigst[1] - saubersten[1]:,} kg CO2 "
|
||||
f"({(guenstigst[1] - saubersten[1]) / guenstigst[1] * 100:.1f} %).")
|
||||
print(" Genau solche Zahlen will die Geschaeftsfuehrung sehen - nicht ein")
|
||||
print(" Gewicht, das niemand interpretieren kann.")
|
||||
|
||||
# --- 2. Die lineare Skalarisierung -----------------------------------
|
||||
gewichte = np.concatenate([np.linspace(0.0, 3.0, 1201),
|
||||
np.geomspace(3.0, 1000.0, 200)])
|
||||
gefunden = skalarisierung(gewichte)
|
||||
print("\n" + "-" * 80)
|
||||
print(f"2. Lineare Skalarisierung: 'Kosten + w * CO2' fuer "
|
||||
f"{len(gewichte):,} Gewichte\n")
|
||||
print(f" {'Kosten':>10} {'CO2':>10} {'gefunden bei w':>16}")
|
||||
print(" " + "-" * 46)
|
||||
for (k, c), ws in sorted(gefunden.items()):
|
||||
print(f" {k:>10,} {c:>9,} kg {min(ws):>7.3f} bis {max(ws):>7.3f}")
|
||||
print(f"\n {len(gefunden)} verschiedene Plaene - fuer {len(gewichte):,} Gewichte.")
|
||||
print(" Das Gewicht ist also gar keine Feineinstellung: Weite Bereiche")
|
||||
print(" liefern dasselbe Ergebnis, und dazwischen springt es.")
|
||||
|
||||
# --- 3. Die vollstaendige Front --------------------------------------
|
||||
front = pareto_front()
|
||||
print("\n" + "-" * 80)
|
||||
print(f"3. Die vollstaendige Pareto-Front ueber eps-Constraint "
|
||||
f"({len(front)} Solverlaeufe)\n")
|
||||
print(f" {'Kosten':>10} {'CO2':>10} {'Aufpreis':>9} {'CO2-Ersparnis':>14} "
|
||||
f"{'EUR je kg':>10}")
|
||||
print(" " + "-" * 60)
|
||||
for k, c in front:
|
||||
auf = k - guenstigst[0]
|
||||
ersparnis = guenstigst[1] - c
|
||||
preis = auf / ersparnis if ersparnis else 0.0
|
||||
print(f" {k:>10,} {c:>9,} kg {auf:>8,} {ersparnis:>13,} "
|
||||
f"{preis:>10.2f}")
|
||||
|
||||
# --- 4. Der Nachweis --------------------------------------------------
|
||||
huelle = untere_huelle(front)
|
||||
unerreichbar = [p for p in front if p not in huelle]
|
||||
print("\n" + "-" * 80)
|
||||
print("4. Was die Skalarisierung nicht findet\n")
|
||||
erreicht = [p for p in front if p in gefunden]
|
||||
print(f" Pareto-Punkte insgesamt: {len(front)}")
|
||||
print(f" davon von der Skalarisierung gefunden: {len(erreicht)}")
|
||||
print(f" nie gefunden: {len(front) - len(erreicht)}")
|
||||
print()
|
||||
print(" Diese Kompromisse sind fuer KEIN Gewicht erreichbar:\n")
|
||||
print(f" {'Kosten':>10} {'CO2':>10} {'Aufpreis':>9} {'CO2-Ersparnis':>14}")
|
||||
print(" " + "-" * 50)
|
||||
for k, c in unerreichbar:
|
||||
print(f" {k:>10,} {c:>9,} kg {k - guenstigst[0]:>8,} "
|
||||
f"{guenstigst[1] - c:>13,}")
|
||||
|
||||
stimmt = sorted(unerreichbar) == sorted(p for p in front if p not in gefunden)
|
||||
print(f"\n Gegenprobe ueber die Geometrie: {'bestanden' if stimmt else 'ABWEICHUNG'}")
|
||||
print(" Genau die Punkte, die das Gewichtsraster verfehlt, liegen strikt")
|
||||
print(" oberhalb der unteren konvexen Huelle. Das ist kein Rasterproblem -")
|
||||
print(" eine Gerade, die von links unten an die Wolke geschoben wird,")
|
||||
print(" beruehrt immer einen Eckpunkt der Huelle und nie einen Punkt")
|
||||
print(" darueber. Ein feineres Raster aendert daran nichts.")
|
||||
|
||||
# --- 5. Lexikografisch -------------------------------------------------
|
||||
print("\n" + "-" * 80)
|
||||
print("5. Lexikografisch: erst Kosten, dann CO2 im Rahmen eines Budgets\n")
|
||||
print(f" {'Kostenbudget':>14} {'Kosten':>10} {'CO2':>10} {'gegenueber Minimum':>20}")
|
||||
print(" " + "-" * 58)
|
||||
for aufschlag in (0.00, 0.01, 0.02, 0.05, 0.10):
|
||||
budget = guenstigst[0] * (1 + aufschlag)
|
||||
ergebnis = plane(CO2, kosten_grenze=budget)
|
||||
if ergebnis is None:
|
||||
print(f" {aufschlag:>13.0%} unzulaessig")
|
||||
continue
|
||||
print(f" {aufschlag:>13.0%} {ergebnis[0]:>10,} {ergebnis[1]:>9,} kg "
|
||||
f"{guenstigst[1] - ergebnis[1]:>15,} kg weniger")
|
||||
|
||||
print("\n Das ist die Form, die im Betrieb am ehesten trifft: Nicht 'wie")
|
||||
print(" wichtig ist CO2?', sondern 'wir geben zwei Prozent mehr aus - was")
|
||||
print(" bringt das?'. Die Frage kann ein Kaufmann beantworten.")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print(" WAS MAN DARAUS MITNIMMT")
|
||||
print("=" * 80)
|
||||
print("Ein Gewicht zu setzen heisst, die Entscheidung heimlich zu treffen -")
|
||||
print("und dabei einen Teil der Moeglichkeiten gar nicht erst zu sehen.")
|
||||
print()
|
||||
print("Die Pareto-Front ist die ehrlichere Antwort: Sie legt dem Betrieb")
|
||||
print("alle sinnvollen Kompromisse vor und ueberlaesst ihm die Wahl. Die")
|
||||
print("Spalte 'EUR je kg' macht sie entscheidbar - man vergleicht sie mit")
|
||||
print("dem CO2-Preis, den das Unternehmen ohnehin ansetzt.")
|
||||
print("=" * 80)
|
||||
232
OR_HTML_04/programme/Metaheuristik_vs_Exakt.py
Normal file
232
OR_HTML_04/programme/Metaheuristik_vs_Exakt.py
Normal 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)
|
||||
97
OR_HTML_04/programme/Min_Cost_Flow.py
Normal file
97
OR_HTML_04/programme/Min_Cost_Flow.py
Normal 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)
|
||||
170
OR_HTML_04/programme/Modellierungsschichten.py
Normal file
170
OR_HTML_04/programme/Modellierungsschichten.py
Normal 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)
|
||||
92
OR_HTML_04/programme/Monte_Carlo.py
Normal file
92
OR_HTML_04/programme/Monte_Carlo.py
Normal 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)
|
||||
179
OR_HTML_04/programme/Mutationstest.py
Normal file
179
OR_HTML_04/programme/Mutationstest.py
Normal 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)
|
||||
315
OR_HTML_04/programme/Optimierungsdienst.py
Normal file
315
OR_HTML_04/programme/Optimierungsdienst.py
Normal 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)
|
||||
230
OR_HTML_04/programme/Predict_then_Optimize.py
Normal file
230
OR_HTML_04/programme/Predict_then_Optimize.py
Normal 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)
|
||||
69
OR_HTML_04/programme/Propagation_Demo.py
Normal file
69
OR_HTML_04/programme/Propagation_Demo.py
Normal 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)
|
||||
55
OR_HTML_04/programme/QP_Grundlagen.py
Normal file
55
OR_HTML_04/programme/QP_Grundlagen.py
Normal 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)")
|
||||
305
OR_HTML_04/programme/QuantitativeTradingEngine.py
Normal file
305
OR_HTML_04/programme/QuantitativeTradingEngine.py
Normal 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)
|
||||
68
OR_HTML_04/programme/Renditen_Vergleich.py
Normal file
68
OR_HTML_04/programme/Renditen_Vergleich.py
Normal 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)
|
||||
143
OR_HTML_04/programme/Renditeschaetzung_Falle.py
Normal file
143
OR_HTML_04/programme/Renditeschaetzung_Falle.py
Normal 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)
|
||||
99
OR_HTML_04/programme/Robuste_Optimierung.py
Normal file
99
OR_HTML_04/programme/Robuste_Optimierung.py
Normal 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)
|
||||
58
OR_HTML_04/programme/Rucksack.py
Normal file
58
OR_HTML_04/programme/Rucksack.py
Normal 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)
|
||||
91
OR_HTML_04/programme/Runden_Gegenbeispiel.py
Normal file
91
OR_HTML_04/programme/Runden_Gegenbeispiel.py
Normal 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)
|
||||
96
OR_HTML_04/programme/Schaetzrauschen_Demo.py
Normal file
96
OR_HTML_04/programme/Schaetzrauschen_Demo.py
Normal 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)
|
||||
110
OR_HTML_04/programme/Sensitivitaetsanalyse.py
Normal file
110
OR_HTML_04/programme/Sensitivitaetsanalyse.py
Normal 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()
|
||||
160
OR_HTML_04/programme/Simplex_Tableau_LP.py
Normal file
160
OR_HTML_04/programme/Simplex_Tableau_LP.py
Normal 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)
|
||||
299
OR_HTML_04/programme/Simulated_Annealing.py
Normal file
299
OR_HTML_04/programme/Simulated_Annealing.py
Normal 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)
|
||||
206
OR_HTML_04/programme/Skalierung_Kondition.py
Normal file
206
OR_HTML_04/programme/Skalierung_Kondition.py
Normal 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)
|
||||
73
OR_HTML_04/programme/Solver_Wahl.py
Normal file
73
OR_HTML_04/programme/Solver_Wahl.py
Normal 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)
|
||||
216
OR_HTML_04/programme/Solverstatus_und_Gap.py
Normal file
216
OR_HTML_04/programme/Solverstatus_und_Gap.py
Normal 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)
|
||||
320
OR_HTML_04/programme/Solverwechsel_CPSAT_HiGHS.py
Normal file
320
OR_HTML_04/programme/Solverwechsel_CPSAT_HiGHS.py
Normal 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)
|
||||
292
OR_HTML_04/programme/Spaltengenerierung.py
Normal file
292
OR_HTML_04/programme/Spaltengenerierung.py
Normal 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)
|
||||
119
OR_HTML_04/programme/Stochastische_Optimierung.py
Normal file
119
OR_HTML_04/programme/Stochastische_Optimierung.py
Normal 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)
|
||||
132
OR_HTML_04/programme/Strafgewichte.py
Normal file
132
OR_HTML_04/programme/Strafgewichte.py
Normal 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)
|
||||
275
OR_HTML_04/programme/Strukturbruecke.py
Normal file
275
OR_HTML_04/programme/Strukturbruecke.py
Normal 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)
|
||||
179
OR_HTML_04/programme/Toleranzen_und_Entartung.py
Normal file
179
OR_HTML_04/programme/Toleranzen_und_Entartung.py
Normal 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)
|
||||
165
OR_HTML_04/programme/VRP_Flotten_Routing.py
Normal file
165
OR_HTML_04/programme/VRP_Flotten_Routing.py
Normal 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()
|
||||
171
OR_HTML_04/programme/VRP_Kapazitaetsfalle.py
Normal file
171
OR_HTML_04/programme/VRP_Kapazitaetsfalle.py
Normal 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)
|
||||
105
OR_HTML_04/programme/VaR_CVaR_Demo.py
Normal file
105
OR_HTML_04/programme/VaR_CVaR_Demo.py
Normal 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)
|
||||
240
OR_HTML_04/programme/Vektorisierte_Modellgenerierung.py
Normal file
240
OR_HTML_04/programme/Vektorisierte_Modellgenerierung.py
Normal 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)
|
||||
140
OR_HTML_04/programme/Visualisierung_Loesungsraum.py
Normal file
140
OR_HTML_04/programme/Visualisierung_Loesungsraum.py
Normal 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}'")
|
||||
243
OR_HTML_04/programme/Vom_Wunsch_zum_Modell.py
Normal file
243
OR_HTML_04/programme/Vom_Wunsch_zum_Modell.py
Normal 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)
|
||||
147
OR_HTML_04/programme/Warmstart_Effekt.py
Normal file
147
OR_HTML_04/programme/Warmstart_Effekt.py
Normal 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)
|
||||
105
OR_HTML_04/programme/Zuordnung_Ungarisch.py
Normal file
105
OR_HTML_04/programme/Zuordnung_Ungarisch.py
Normal 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)
|
||||
504
OR_HTML_04/programme/or_kern.py
Normal file
504
OR_HTML_04/programme/or_kern.py
Normal 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)
|
||||
309
OR_HTML_04/programme/test_or_kern.py
Normal file
309
OR_HTML_04/programme/test_or_kern.py
Normal 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"]))
|
||||
Loading…
Reference in a new issue