operations_research/OR_HTML_04/programme/Warmstart_Effekt.py

148 lines
6.2 KiB
Python
Raw Normal View History

Version 04 als eigenes Repository Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:20:09 +02:00
#!/usr/bin/env python3
# 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
Phase 6.3: Parallelitaet und Determinismus - gemessen statt behauptet Neuer Abschnitt im CP-SAT-Kapitel plus Parallele_Suche.py (76. Programm). Setzt Paket 2 aus Verbesserungen_02.md um. Was fehlte, war nicht die Regel, sondern die Messung: Das Buch setzt an acht Stellen num_workers = 1 mit dem Kommentar "fuer eine reproduzierbare Ausgabe", nachgeprueft hatte es nie jemand. Gerechnet wird auf demselben Job-Shop wie das Intervallvariablen-Kapitel, nur gross genug, dass die Suche arbeitet: 12 Auftraege, 10 Maschinen, 120 Arbeitsgaenge. Zwei Befunde, beide staerker als die Behauptung: * Die Beschleunigung ist ueberlinear. Acht Arbeiter waren im abgedruckten Lauf nicht achtmal, sondern 12,3-mal schneller als einer. Kein Messfehler: CP-SAT vervielfacht nicht dieselbe Suche, sondern laesst verschiedene Strategien nebeneinander laufen, die einander ihre Schranken mitteilen. * Der Seed genuegt nicht - und zwar schon ab ZWEI Arbeitern. Ein Arbeiter: 1 Plan aus 4 Laeufen. Zwei Arbeiter: 3 verschiedene Plaene aus 4 Laeufen, bei identischem random_seed und identischem Zielwert 183. Die vollstaendige Antwort kam erst ueber die Uebungsaufgabe: Mit num_workers = 1, aber fuenf verschiedenen Seeds ergeben sich ebenfalls fuenf verschiedene Plaene. Keiner der beiden Parameter sichert die Reproduzierbarkeit allein - erst die Kombination traegt. Weiter fuer die Aufgabe gemessen: Der Gewinn kehrt sich um (auf 24 Kernen Faktor 10,2 bei 8 Arbeitern, 12,3 bei 16, 9,0 bei 24) - "so viele Arbeiter wie Kerne" ist damit widerlegt. Und bei 15 Auftraegen laeuft ein Arbeiter ins 60-s-Limit (FEASIBLE, Makespan 200), waehrend acht OPTIMAL mit demselben Makespan 200 nach 26,4 s melden: Der Unterschied liegt nicht in der Loesung, sondern im Beweis, dass es keine bessere gibt. Die abgedruckte Ausgabe traegt die Kennzeichnung "Laufzeiten und die Zahl der verschiedenen Plaene sind hardwareabhaengig" - nach dem Muster, das das Testing-Kapitel fuer Benchmark_Skalierung.py schon verwendet. Der Vergleich des extrahierten Programms mit dem Abdruck weicht denn auch in genau einer Zelle ab (4 statt 3 verschiedene Plaene bei 4 Arbeitern); Zielwert und Struktur sind identisch. Hier ist die Nichtreproduzierbarkeit der abgedruckten Zahl die Aussage selbst. Mitgezogen: Kapitelkopf, Lernziele, Selbsttest, Zusammenfassung, Vorwort-Programmverzeichnis, Uebungsaufgabe und Loesung in Anhang A, ein Verweis aus dem bestehenden Callout zu mehrdeutigen Optima und einer aus Warmstart_Effekt.py im MILP-Kapitel (dort nach Regel 12 der Kapitelname). Stand: 295 Abschnitte, 730 Querverweise, 328 Indexmarken, 76 Programme, 140 Aufgaben mit 140 Loesungen, 33 pytest-Tests, PDF 744 Seiten, 69 netzfreie Programme fehlerfrei. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:19:53 +02:00
# Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist -
# der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).
Version 04 als eigenes Repository Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:20:09 +02:00
# 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)