Compare commits

..
Author SHA1 Message Date
dschlueter
3e5c6fc627 PROGRESS.md Abschnitt 7 auf den heutigen Stand bringen
Der Wiederaufnahme-Anker nannte noch 74 Programme, 32 Diagramme und "Phase 6
hat begonnen" - alles ueberholt. Jetzt steht dort: der erreichte Stand, die
drei Schritte, die von aussen kommen muessen (Remote-Repository,
COLAB_BASIS_URL, Veroeffentlichen), die fuenf bewusst zurueckgestellten
Pakete mit Begruendung, der eine rein redaktionelle Punkt (53 gekuerzte
Loesungstitel) und vier Dinge, die einer spaeteren Sitzung auffallen und
keine Fehler sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:26:40 +02:00
dschlueter
35cc0da166 Achter Fund (Uebung-Verweise) und Phase 8.3: Dockerfile
Zwei Dinge in einem Commit, weil beide den Plan abschliessen.

ACHTER FUND: sieben Saetze der Bauart "Uebung 8.5 laesst Sie diese Balance
untersuchen" - und sechs davon standen in alter Zaehlung. "8" war in Version
03 das QP/NLP-Kapitel, heute ist es Kapitel 11. Dieselbe Familie wie die
Denkfehler-Verweise aus 6.1a, nur mit einem Wort, das keine der bestehenden
Pruefungen kannte.

Aufgaben haben kein eigenes Label, ein {ref:} auf eine einzelne Aufgabe ist
also nicht moeglich. Verwiesen wird stattdessen auf Abschnitt plus
Aufgabentitel - und der ist stabil. Jedes Ziel wurde einzeln ueber die alte
Zaehlung bestimmt und am Zusammenhang geprueft.

Ein Fall war knifflig: "Uebung 6.7 (Wochendienstplan)" meinte die siebte
CP-SAT-Aufgabe der alten Zaehlung, also "Eigener Dienstplan" - die heute an
achter Stelle steht, weil in Phase 6.3 eine Aufgabe davor eingefuegt wurde.
Wer nur die Kapitelnummer angepasst haette, waere bei der falschen Aufgabe
gelandet.

--check kennt jetzt auch "Uebung"/"Übung". Gegengetestet.

PHASE 8.3: Dockerfile, zweistufig. Die erste Stufe uebersetzt die
Abhaengigkeiten in eine virtuelle Umgebung und braucht dafuer einen
Compiler, die zweite kopiert nur /opt/venv. Installiert werden die Gruppen
finance, large-scale, api und dev aus pyproject.toml; figures fehlt bewusst,
weil es zusaetzlich Graphviz verlangt.

Es wurde nicht behauptet, sondern gebaut. Ergebnis: 1,31 GB, und darin der
Installationstest mit allen drei Solver-Funktionstests bestanden, die 33
pytest-Tests bestanden und alle 69 netzfreien Programme fehlerfrei -
einschliesslich der drei aus 8.2, deren spawn-Isolation im Container ebenso
traegt wie ausserhalb.

Zwei Dinge, die der Bau gelehrt hat: libgomp1 fehlt im python:3.12-slim-Image
und wird von OR-Tools und HiGHS zur Laufzeit gebraucht (sonst
"libgomp.so.1: cannot open shared object file"). Und ein eigener Fehler:
USER kurs stand vor dem mkdir /buch/output, /buch gehoert root, der Bau
brach in der letzten Zeile ab. Beides steht jetzt als Kommentar im
Dockerfile.

Das Image fuehrt die Programme aus und baut das Buch nicht. Ein
.dockerignore haelt Website, PDF und Notebooks aus dem Build-Kontext. Und es
enthaelt ortools UND highspy, obwohl sie sich nicht gemeinsam importieren
lassen - der Konflikt wird zur Laufzeit durch getrennte Prozesse geloest,
nicht durch Weglassen.

Damit ist Phase 8 abgeschlossen und der Plan abgearbeitet.

Stand: 825 Querverweise, 76 Programme, 33 pytest-Tests, PDF 760 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:18:05 +02:00
dschlueter
862e92bc7b Phase 8.2: Solver-Isolation ohne subprocess-Codestrings
Setzt den Isolationsteil von Paket 1 aus Verbesserungen_02.md um. Der Plan
nannte zwei Programme; beim Suchen kam ein drittes dazu, das dasselbe Muster
verwendete.

Ein_System_Vier_Ansaetze.py und Benchmark_Skalierung.py hielten ihre vier
Solvervarianten als Zeichenketten in einem Dictionary und gaben sie an
"python -c" weiter - bei Benchmark_Skalierung.py sogar mit
.format()-Platzhaltern fuer die Instanzgroesse. Aus jeder Variante ist jetzt
eine gewoehnliche Funktion mit lokalem Import geworden.
Solverwechsel_CPSAT_HiGHS.py rief sich selbst ueber sys.argv erneut auf;
auch das entfaellt.

Ausgefuehrt wird ueber einen ProcessPoolExecutor mit zwei Einstellungen, die
beide noetig sind: mp_context "spawn" (frischer Interpreter statt geerbtem
Speicher - unter Linux ist fork der Standard) und max_tasks_per_child=1 (ein
neuer Prozess je Aufgabe; ohne das verwendet der Pool seinen Arbeiter
wieder, und beim zweiten Solver ist der Konflikt zurueck). Nachgemessen:
vier Aufgaben, vier verschiedene PIDs.

Der zweite Punkt hat einen eigenen Warnkasten bekommen, weil der Fehler
leicht zu machen und schwer zu finden ist: Der Absturz kaeme nicht beim
ersten Solver, sondern beim zweiten - und saehe aus wie ein Problem des
zweiten.

Regel 4, dreifach geprueft. Ein_System_Vier_Ansaetze.py: identisch bis auf
die Zeitspalte, einschliesslich der Spannweite 2,41e-08, auf die sich der
Merksatz des Kapitels beruft. Benchmark_Skalierung.py: alle zwoelf
Zielwerte und alle drei Spannweiten bitgleich; Zeiten und Speicher haben
sich verschoben, beide sind im Abdruck seit jeher als hardwareabhaengig
gekennzeichnet. Solverwechsel_CPSAT_HiGHS.py: Ausgabe ohne Zeiten
unveraendert.

Bewusst subprocess bleibt Mutationstest.py: Dort wird pytest auf einer
mutierten Kopie in einem temporaeren Verzeichnis gestartet - ein externes
Werkzeug auf veraenderten Dateien, nicht die Isolation eines Imports.

Neu im Kapitel Oekosystem: ein Abschnitt "Wie die Isolation aussieht, wenn
sie tragen soll" - warum ein Codestring die schlechteste Umsetzung von
"eigener Prozess" ist. Anhang C nennt jetzt ebenfalls ProcessPoolExecutor.

Ein eigener Fehler, gefunden und abgesichert: Ich hatte dem neuen ### ein
{#sec:...}-Label gegeben. ABSCHNITT_RE erkennt nur "## " - das Label waere
nie registriert worden und jeder Verweis darauf ins Leere gelaufen, ohne
Warnung. Label entfernt, --check meldet den Fall jetzt. Gegengetestet.

Stand: 818 Querverweise, 76 Programme, 33 pytest-Tests, PDF 760 Seiten, 69
netzfreie Programme fehlerfrei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:39:34 +02:00
dschlueter
9c83716095 Phase 8.1: Synthese-Seiten je Teil - und die gebrochene Lesekette
Setzt Paket 5 aus Verbesserungen_02.md um (den Teil, der nicht
zurueckgestellt wurde). Fuenf neue Dateien 19_/29_/39_/49_/52_Synthese_*.md,
je eine am Ende eines Teils, mit eigener Website-Seite ueber SONDERSEITEN -
sie tragen bewusst keine "# Kapitel:"-Ueberschrift, weil sie keine Kapitel
sind, sondern der Rueckblick auf einen Teil.

Der Entwurf musste sich abgrenzen: Die Teil-Einleitungen haben bereits
Entscheidungsdiagramme. Eine zweite Matrix am Teil-Ende waere eine Dopplung
gewesen. Die Synthesen leisten deshalb, was eine Einleitung nicht kann - den
Vergleich ueber die Kapitel hinweg (Verfahren nebeneinander, mit der Spalte
"wo es aufhoert"), eine Tabelle "was dieser Teil gemessen hat" (Behauptung
gegen Messung gegen Fundstelle) und drei Fehler, die der Teil verhindert.
Zitiert wird ausschliesslich, was im Buch tatsaechlich gerechnet wird.

Drei Funde beim Einbau:

* Teil III sagte "die drei Kapitel dieses Teils", hat aber fuenf. Phase 3
  hatte Mehrziel und Predict-then-Optimize hinzugefuegt, die Einleitung
  blieb stehen.
* 50_Praxis.md verwies auf die Projektwerkstatt mit "acht eigene
  Anwendungen" - sie hat elf.
* Und der eigentliche Fund: Die Lesekette der Quelldateien fuehrte an ACHT
  Kapiteln vorbei. 12_Python_Oekosystem zeigte direkt auf
  20_Lineare_Programmierung, 23_Graphen direkt auf 30_QP, 32_Dynamische
  direkt auf 40_Finanzdaten, 50_Praxis direkt auf die Projektwerkstatt. Wer
  der Kette folgte, uebersprang acht von 23 Kapiteln - darunter
  Metaheuristiken, Spaltengenerierung, Strukturbruecke, Supply-Chain und das
  ganze Testing-Kapitel. Zehn weitere Dateien hatten gar keine
  Navigationszeile.

Zur Reichweite, damit sie nicht ueberschaetzt wird: Diese Zeilen stehen nur
in den Quelldateien. entferne_navigation() streicht sie aus dem
Gesamtdokument, und die Website baut ihre Vor/Zurueck-Knoepfe selbst aus
DATEIEN. PDF und Website waren nie betroffen - wohl aber jeder, der die
Markdown-Dateien im Repository liest, und das wird nach der
Veroeffentlichung der Normalfall sein.

Die Kette ist jetzt ueber alle 35 Uebergaenge geschlossen, und --check
bewacht sie: Fehlt eine Zeile oder zeigt sie an der in DATEIEN folgenden
Datei vorbei, ist der Lauf rot. Gegengetestet mit beiden Bruchformen.

Stand: 36 Dateien, 296 Abschnitte, 815 Querverweise, 328 Indexmarken, 76
Programme (unveraendert), 33 pytest-Tests, PDF 758 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:10:02 +02:00
dschlueter
8ded88ac38 Phase 7.3: Glossar "Deutsch - OR" - und die Gegenrichtung
Setzt Paket 7 aus Verbesserungen_03.md um. Neuer Abschnitt im Kapitel "Vom
Management-Wunsch zum Modell", unmittelbar hinter dem Gespraechsleitfaden:
Die zwoelf Fragen liefern Saetze - hier werden sie uebersetzt.

Die Abgrenzung zu Anhang B war die eigentliche Entwurfsfrage. Der
Musterkatalog hat laengst eine Spalte "Umgangssprachlich", laeuft aber in
die andere Richtung: Muster -> Formulierung. Wer aus einer Besprechung
kommt, hat den Satz und sucht den Baustein. Das Lexikon ist genau so herum
gebaut (21 Eintraege, jeweils mit B-Nummer), und Anhang B bekam einen
Rueckverweis. Kein Duplikat, sondern die fehlende Gegenrichtung.

Der Teil, der den Abschnitt traegt, ist aber nicht die Tabelle, sondern was
danach kommt: fuenf Wendungen, bei denen die Uebersetzung eine Entscheidung
erzwingt - "moeglichst", "nicht mehr als drei pro Woche", "im Durchschnitt",
"so schnell wie moeglich", "fair". Bei jeder gibt es zwei bis drei
vertretbare Modelle, und die Wahl gehoert dem Betrieb, nicht dem
Modellierer. Das ist die These des Kapitels, auf die Alltagssprache
angewandt.

Dazu die Gegenrichtung OR -> Deutsch: acht Saetze fuer die Erklaerung eines
Ergebnisses, einschliesslich der Formulierung fuer INFEASIBLE ("Ihre Regeln
widersprechen sich", nicht "der Computer schafft es nicht").

Die neue Uebungsaufgabe laesst drei Zielfunktionen fuer "gleichmaessig
ausgelastet" aufstellen; die Loesung nennt je einen Fall, in dem die Wahl
den Plan sichtbar aendert - und weist darauf hin, dass zwei der drei einen
Halbtagsmonteur bestrafen.

Mitgezogen: Lernziel, Zusammenfassung, Rueckverweis aus Anhang B, Pfad E im
Vorwort (er fuehrte bisher direkt zum Musterkatalog, jetzt ueber das
Lexikon).

Damit ist Phase 7 abgeschlossen. 296 Abschnitte, 766 Querverweise, 141
Aufgaben mit 141 Loesungen, PDF 749 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:46:17 +02:00
dschlueter
a0691e1919 Phase 7.2: Wasserfalldiagramm - wofuer der Erwartungswert-Plan bezahlt
Setzt den umsetzbaren Teil von Paket 3 aus Verbesserungen_03.md um
(Vorher-Nachher-Wasserfall, Business Impact). Neuer Generator
bilder_04/erzeuge_wirkung.py; das Supply-Chain-Kapitel bekommt damit sein
erstes Diagramm ueberhaupt.

Der Anlass: Die Tabelle nannte nur die Summen - 874.870 EUR gegen
351.356 EUR, 149 % mehr. Wofuer genau, stand nirgends.

Der Generator rechnet die Instanz erneut, wie es die Konvention verlangt.
Die Probe fiel gut aus: Der unabhaengige Nachbau reproduziert beide
abgedruckten Summen exakt, dazu 178,7 MWh Fehlmenge und 28/40 Szenarien mit
Abwurf. Kein Auseinanderlaufen - der erste Generator dieser Reihe, der
nichts findet.

Was die Zerlegung zeigt und die Tabelle nicht: Die Anfahrkosten sind in
beiden Plaenen gleich (58.000 EUR). Der Erwartungswert-Plan spart also nicht
dort, wo man es vermuten wuerde. Er spart 12.725 EUR Brennstoff und bezahlt
dafuer 536.239 EUR Lastabwurf - das 42-fache. "149 % mehr" ist eine Zahl,
ueber die man streiten kann; "wir sparen 12.725 EUR und riskieren
536.239 EUR" ist eine, ueber die man entscheidet.

Die Trennung von Brennstoff- und Abwurfkosten ist der einzige Zusatz
gegenueber bewerte() im Buchprogramm; dort wird sie nicht gebraucht.

Ein eigener Fehler unterwegs: Ein .replace(",", ".") fuer deutsche
Tausenderpunkte lag auf dem ganzen Titelsatz und machte aus "Brennstoff,
Preis" ein "Brennstoff. Preis". Jetzt gibt es euro(), das nur auf Zahlen
angewandt wird.

SVG byteidentisch ueber zwei Laeufe. Diagramme jetzt 33, davon 19 mit
Generator (16 Skripte). PDF unveraendert 746 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:41:07 +02:00
dschlueter
5a645a3944 Phase 7.1: Vier Zielgruppen-One-Pager im Vorwort
Setzt Paket 4 aus Verbesserungen_03.md um. Das Titelblatt wirbt seit dem
Umbau mit Entscheidern, Ingenieuren und Investoren - die Lernpfade waren
aber nach Themen geschnitten, nicht nach Rollen. Der neue Abschnitt "Fuer
wen dieses Buch geschrieben ist" steht zwischen "Wie dieses Buch aufgebaut
ist" und den Lernpfaden: vier Einstiege, jeder mit Schmerzpunkt, drei
Belegen, dem passenden Pfad und der Frage, an der es in dieser Rolle
scheitert.

Die Entscheidung, die den Abschnitt traegt: nur gemessene Zahlen. Jeder
Beleg ist ein Ergebnis, das im Buch gerechnet wird - der 149-%-Aufschlag des
Erwartungswert-Plans, der Schattenpreis, der nur bis +15 Stunden gilt, VaR
1,86 % gegen CVaR 2,99 % bei einem schlechtesten Tag von -23,0 %, das Modell
mit dem schlechteren MSE und der besseren Entscheidung.

Und eine Erwartung wird ausdruecklich enttaeuscht: Verbesserungen_03
verlangt fuer die Managementseite "typischer ROI und Amortisationszeit". Das
Buch hat diese Zahlen nicht, und jede hier genannte waere erfunden. Der
One-Pager sagt das offen und nennt stattdessen die beiden Werkzeuge, mit
denen der Leser seine eigene Zahl ausrechnet: den EVPI als Obergrenze fuer
jedes Prognoseprojekt und die Kostenzurechnung aus Constraint Attribution.

Kein neues Programm, keine neue Aufgabe - der Abschnitt buendelt
Vorhandenes. Querverweise 730 -> 755, PDF 744 -> 746 Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:29:00 +02:00
dschlueter
293370b7a1 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
dschlueter
dd4acda129 Phase 6.2: Constraint Attribution und Managementbericht
Neuer Abschnitt im Kapitel Praxisfallen plus Constraint_Attribution.py (75.
Programm). Setzt den fehlenden Teil von Paket 3 aus Verbesserungen_02.md um.

Die Luecke, die es schliesst: Erklaerbarkeit.py beantwortet "warum DIESE
Zuweisung?", Konfliktsuche.py "warum geht es GAR NICHT?". Der haeufigste Fall
der Praxis lag dazwischen und hatte kein Werkzeug - das Modell rechnet, der
Plan ist zulaessig, und trotzdem enttaeuschend, nur weiss niemand woran es
liegt.

Gerechnet wird auf derselben Fabrik wie die Konfliktsuche, nach der
Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des
Widerspruchs (2*40 + 3*30 = 170 > 150), eine zweite Schicht bringt sie auf
210. Damit laesst sich dieselbe Instanz ueber zwei Kapitel verfolgen - erst
"warum nicht?", dann "was kostet was?".

Drei gemessene Befunde:

* Bindend heisst nicht teuer: Fuenf Bedingungen binden, "Lackierbudget
  Schicht 2" mit einem Schattenpreis von 0,00 EUR.
* Der Schattenpreis ist eine Momentaufnahme: Fuer die Lackiererei verspricht
  die Hochrechnung 4,00 * 30 = 120 EUR, gemessen kommen 60 EUR; der Preis
  gilt exakt bis +15 Stunden. Die Ranglisten drehen sich dabei um - nach
  Schattenpreis fuehrt der Traegervertrag (18 EUR/Stueck), nach
  tatsaechlicher Wirkung die Montage-Sonderschicht (450 EUR).
* Ein Wunsch kann unmoeglich statt teuer sein: "mindestens 55 Rahmen" ist
  INFEASIBLE, blockiert von drei Bedingungen gemeinsam. Mit +15 Stunden
  Lackierkapazitaet wird er erfuellbar und kostet dann nichts - der Plan
  waehlt diese Menge von sich aus. Der Konflikt bestand nie zwischen
  Vertrieb und Produktion.

Der Managementbericht entsteht aus genau diesen Zahlen; kein Satz enthaelt
eine Zahl, die nicht vorher gerechnet wurde. Voraussetzung ist der
sprechende Name jeder Bedingung - aus A_ub[7] wird kein Satz.

Dazu die Grenze, als Warnkasten und als Uebungsaufgabe: Der Bericht erklaert
das Modell, nicht die Wirklichkeit. Steigt der Deckungsbeitrag von Deckel
von 9 auf 20 EUR, kippt der Plan (Rahmen auf das Vertragsminimum 40, Deckel
auf die Marktgrenze 120), "Kapazitaet Lackieren" hoert auf zu binden,
"Liefervertrag Rahmen" faengt damit an - und der Satz "Teuerste Bindung ist
Liefervertrag Traeger" steht woertlich unveraendert im Bericht, waehrend die
Zahl dahinter von 18 auf 58 EUR springt.

Die Zaehlprobe aus 6.1a hat sich sofort bewaehrt: Nach dem Einbau der
Uebungsaufgabe meldete --check "6 Loesungen, das Kapitel aber 7 Aufgaben",
bevor die Loesung geschrieben war.

Mitgezogen: Kapitelkopf, Lernziele, Selbsttest, Zusammenfassung,
Vorwort-Programmverzeichnis, Vorwaertsverweis aus Falle 2, Uebungsaufgabe
und Loesung in Anhang A.

Stand: 294 Abschnitte, 725 Querverweise, 328 Indexmarken, 75 Programme,
139 Aufgaben mit 139 Loesungen, 33 pytest-Tests, PDF 737 Seiten, 68
netzfreie Programme fehlerfrei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 10:30:33 +02:00
dschlueter
cc990c2998 Sechster und siebter Fund: die letzten handgeschriebenen Nummern
Der Loesungsanhang trug 98 Marken der Bauart "**9.2 — Ansatz waehlen.**",
saemtlich aus Version 03, wo Unsicherheit Kapitel 9 war. Heute ist es
Kapitel 12, und die Aufgabe wird korrekt als "Aufgabe 12.2" gesetzt.

Das war keine Schoenheitsfrage: "Aufgabe 9.2" existiert wirklich - sie
gehoert zum Kapitel Metaheuristiken ("Zuggroesse und Temperatur"). Wer die
Loesung zu 9.2 nachschlug, landete beim falschen Thema. Beide bisherigen
Pruefungen liefen vorbei: Vor der Zahl steht kein Schluesselwort, und es ist
keine Tabellenzelle.

Zwei Messungen machten die Reparatur billig. In allen 15 nummerierten
Abschnitten gab es exakt so viele Loesungen wie Aufgaben, lueckenlos 1..n -
nur der Kapitelteil war falsch. Und die acht in Phase 3 ergaenzten Kapitel
machten es laengst richtig (**Titel.** ohne Nummer); der Anhang wurde also
nicht auf etwas Neues umgestellt, sondern auf das, was seine neueren Teile
schon taten.

nummeriere_marken() vergibt die Marken jetzt selbst. Der Praefix kommt NICHT
aus "# Anhang A:" - der Anhang ist eine Ueberschrift, seine Abschnitte
gehoeren aber zu 23 verschiedenen Kapiteln. Er kommt aus
{#sec:loesungen-X} -> {#kap:X}; diese Zuordnung gilt geprueft fuer alle 23.
Im Quelltext steht "**{loesung} — Titel.**".

Siebter Fund unterwegs: neun Verweise auf Denkfehler-Nummern. Saetze wie
"siehe Denkfehler 8.1" standen in alter Zaehlung - "8" war in Version 03 das
QP/NLP-Kapitel, heute ist es Graphen. Eine blosse Umnummerierung haette sie
stillschweigend woanders hin zeigen lassen; das Ziel wurde deshalb fuer jeden
einzelnen aus dem Zusammenhang bestimmt und auf {ref:sec:<kapitel>-denkfehler}
umgestellt. Einer steckte in einem Programm-Docstring und bekam nach Regel 12
den Kapitelnamen statt eines Verweises.

Eine bewusste Abweichung vom Plan: Der Denkfehler bekommt GAR KEINE Nummer.
Es gibt je Kapitel genau einen, die zweite Stelle waere immer .1, und er
steht ohnehin unter einer nummerierten Ueberschrift. Eine Nummer, die nie
variiert, holte nur die Fragilitaet zurueck, die hier beseitigt wird. Auch
das folgt den acht neuen Kapiteln, die ihren Denkfehler schon vorher nur mit
dem Titel des Raetsels ankuendigen.

--check bewacht jetzt alle Familien (Denkfehler N.M, Micro-Quiz N, **N.M —
im Anhang) und zaehlt zusaetzlich ab, dass jeder Loesungsabschnitt so viele
{loesung}-Marken hat wie sein Kapitel Aufgaben - das faengt eine vergessene
oder doppelte Loesung, was keine Textsuche leisten kann. Gegengetestet mit
vier kuenstlichen Fehlern auf einmal: alle vier gemeldet, nach dem Rueckbau
null.

Gegenprobe der Umstellung: Gesamtdokument vorher gesichert, nachher
verglichen. 168 geaenderte Zeilen, restlos einer Familie zuzuordnen - 118
Loesungsmarken (138 gesamt minus 20, die in den Kapiteln 1 bis 3 zufaellig
schon stimmten), 15 Anhang-Ueberschriften, 15 Kapitel-Callouts, 12
Micro-Quiz, 7 Zeilen mit Verweisen, 1 Docstring. Keine verschobene oder
inhaltlich veraenderte Zeile.

Ergebnis: 138 Aufgaben, 138 Loesungen, keine ohne Gegenstueck. Micro-Quiz
tragen die echten Kapitelnummern. 293 Abschnitte, 720 Querverweise, 327
Indexmarken, PDF unveraendert 725 Seiten, 33 pytest-Tests, 67 netzfreie
Programme fehlerfrei (die vier yfinance-Programme scheiterten am Rate-Limit
des Anbieters, nicht an dieser Aenderung).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:52:41 +02:00
116 changed files with 27721 additions and 13705 deletions

28
.dockerignore Normal file
View file

@ -0,0 +1,28 @@
# Was NICHT in den Build-Kontext gehoert. Ohne diese Datei schickt Docker das
# ganze Verzeichnis an den Daemon - mitsamt Website (rund 50 MB), PDF und
# Notebooks, die im Image nichts zu suchen haben.
# Erzeugte Ausgaben
OR_HTML_04/
Notebooks_04/
*.pdf
Operations_Research_mit_Python_Version_04.md
# Quellen des Buchsatzes - das Image fuehrt die Programme aus, es baut das
# Buch nicht. Wer das PDF bauen will, braucht pandoc, xelatex und inkscape
# und ist mit einer lokalen Installation besser bedient.
bilder_04/
pandoc/
pandoc-defaults-*.yaml
Kritik_und_Verbesserungsvorschlaege/
PLAN.md
PROGRESS.md
CLAUDE.md
# Von Laeufen erzeugte Dateien
**/__pycache__/
**/output/
*.xlsx
.git/
.venv/
venv/

View file

@ -23,7 +23,7 @@ Dieses Verzeichnis ist die Wurzel; alle Skripte leiten ihre Pfade daraus ab
(`BASIS = dirname(dirname(__file__))` bzw. `dirname(HIER)`).
```
Operations_Research_mit_Python_Version_04/ Quelle: 31 Kapiteldateien + Build-Skripte
Operations_Research_mit_Python_Version_04/ Quelle: 36 Kapiteldateien + Build-Skripte
bilder_04/ Quelle: Diagramme + erzeuge_*.py-Generatoren
Operations_Research_mit_Python_Version_04.md generiert: Gesamtdokument
Operations_Research_mit_Python_Version_04.pdf generiert: PDF (xelatex)
@ -33,6 +33,7 @@ Notebooks_04/ generiert: ein .ipynb je Kapitel
Kritik_und_Verbesserungsvorschlaege/ Quelle: Rezension, Verbesserungsvorschläge,
NEUER_TITEL.md (Vorlage des Titelblatts)
pyproject.toml Quelle: Abhängigkeiten in Gruppen
Dockerfile, .dockerignore Quelle: Kurs-Image (nur Programme)
pandoc-defaults-basis.yaml, pandoc/, pandoc-defaults-buch.yaml PDF-Konfiguration
```
@ -66,15 +67,24 @@ sonst fälschlich als sichtbaren Umbruch dar.
## Die wichtigsten Konventionen
**Keine abgeleiteten Zahlen im Quelltext.** Das ist die Regel, an der dieses Projekt am
häufigsten gescheitert ist — sie wurde vier Mal an verschiedenen Stellen verletzt gefunden
(Anhang A, 66 Programm-Docstrings, die Übersichtstabellen der Anhänge, `CLAUDE.md` selbst).
Konkret:
häufigsten gescheitert ist — **sieben** Mal an verschiedenen Stellen verletzt gefunden
(Anhang A, 66 Programm-Docstrings, die Übersichtstabellen der Anhänge, `CLAUDE.md` selbst,
eine Tabellenzelle, die 98 Lösungsmarken des Anhangs, neun Denkfehler-Verweise).
Seit dem letzten Fund trägt **keine** Marke mehr eine handgeschriebene Nummer, und `--check`
bewacht jede Familie. Konkret:
* Überschriften: `# Kapitel: <Titel> {#kap:<label>}`, `## Titel {#sec:<label>}` — die Nummer
vergibt der Build (`resolve_numbering()`, `nummeriere_abschnitte()`).
* Aufgaben, Handrechnungen, Abbildungen: `**Aufgabe ⭐ — Titel.**`,
`> **✏️ Handrechnung: Titel**`, `![Bildunterschrift](bilder_04/…)` — Nummern von
`nummeriere_marken()`.
* Aufgaben, Handrechnungen, Abbildungen, Micro-Quiz: `**Aufgabe ⭐ — Titel.**`,
`> **✏️ Handrechnung: Titel**`, `![Bildunterschrift](bilder_04/…)`,
`> **❓ Micro-Quiz: Titel**` — Nummern von `nummeriere_marken()`.
* **Lösungen in Anhang A: `**{loesung} — Titel.**`** Der Präfix kommt nicht aus
`# Anhang A:`, sondern aus dem Kapitel des umgebenden `{#sec:loesungen-<X>}`-Abschnitts —
die Zuordnung `sec:loesungen-<X>``kap:<X>` gilt für alle 23. `--check` zählt zusätzlich
ab, dass es je Kapitel so viele Lösungen wie Aufgaben gibt.
* **Der Denkfehler bekommt bewusst gar keine Nummer.** Es gibt je Kapitel genau einen, unter
einer bereits nummerierten Überschrift. Verwiesen wird auf
`{ref:sec:<kapitel>-denkfehler}`.
* Im Fließtext `{ref:<label>}`, nie eine Literalzahl.
* In Code-Kommentaren und Docstrings der Kapitel**name** („Kapitel Metaheuristiken:"), nie
die Nummer. Auch nicht im Dateinamen.

79
Dockerfile Normal file
View file

@ -0,0 +1,79 @@
# Kurs-Image zu "Optimierte Entscheidungsfindung mit Python".
#
# Was es ist: eine reproduzierbare Umgebung, in der alle Beispielprogramme
# laufen - ohne dass jemand Solver, BLAS-Bibliotheken und Python-Versionen von
# Hand zusammensuchen muss.
#
# Was es NICHT ist: eine Bauumgebung fuer das Buch. Das PDF braucht pandoc,
# xelatex und inkscape, zusammen ueber ein Gigabyte. Wer das Buch selbst baut,
# ist mit einer lokalen Installation besser bedient (siehe README.md).
#
# docker build -t or-mit-python .
# docker run --rm or-mit-python Installationstest.py
# docker run --rm -it or-mit-python # Python-Eingabe
# docker run --rm -v "$PWD/ausgabe:/buch/output" or-mit-python Excel_Bruecke.py
#
# Zwei Stufen: Die erste baut die Abhaengigkeiten in eine virtuelle Umgebung
# (und braucht dafuer Compiler), die zweite kopiert nur das Ergebnis. Das
# spart im fertigen Image die gesamte Bauumgebung.
# --- Stufe 1: Abhaengigkeiten uebersetzen und einsammeln ---------------------
FROM python:3.12-slim AS bau
# build-essential wird von Paketen gebraucht, die kein passendes Wheel
# mitbringen. Bleibt in dieser Stufe und landet nie im fertigen Image.
RUN apt-get update && apt-get install --no-install-recommends -y \
build-essential \
&& rm -rf /var/lib/apt/lists/*
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Nur die Abhaengigkeitsbeschreibung kopieren, nicht den Quelltext: So bleibt
# diese Schicht im Cache, solange sich die Abhaengigkeiten nicht aendern.
WORKDIR /buch
COPY pyproject.toml README.md ./
# Die Gruppen aus pyproject.toml. 'figures' fehlt bewusst - es dient dem
# Neuerzeugen der Diagramme, wofuer zusaetzlich Graphviz noetig waere.
#
# ACHTUNG, gewollt: Das Image enthaelt ortools UND highspy, obwohl beide eine
# eigene HiGHS-Kopie mitbringen und sich nicht gemeinsam importieren lassen.
# Der Konflikt wird nicht durch Weglassen geloest, sondern zur Laufzeit durch
# getrennte Prozesse (Kapitel Oekosystem). Ein Image, das nur eines von beiden
# enthaelt, koennte die Haelfte der Beispiele nicht ausfuehren.
RUN pip install ".[finance,large-scale,api,dev]"
# --- Stufe 2: das eigentliche Image -----------------------------------------
FROM python:3.12-slim AS laufzeit
# libgomp1 wird von OR-Tools und HiGHS zur Laufzeit gebraucht (OpenMP) und ist
# im slim-Image nicht enthalten. Ohne es scheitert der erste Solveraufruf mit
# einem 'libgomp.so.1: cannot open shared object file'.
RUN apt-get update && apt-get install --no-install-recommends -y \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=bau /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
MPLBACKEND=Agg
# Kein root. Die Programme brauchen keine Rechte, und ein Kurs-Image, das als
# root laeuft, ist eine schlechte Angewohnheit, die man weitergibt.
RUN useradd --create-home --uid 1000 kurs
WORKDIR /buch
COPY --chown=kurs:kurs Operations_Research_mit_Python_Version_04_Programme/ ./
# Verzeichnis fuer erzeugte Dateien - als Volume einhaengbar, damit
# Excel-Ausgaben und Diagramme den Container ueberleben. Angelegt wird es
# VOR dem Benutzerwechsel, denn /buch gehoert root.
RUN mkdir -p /buch/output && chown kurs:kurs /buch/output
USER kurs
# Ohne Argument eine Python-Eingabeaufforderung, mit Argument das genannte
# Programm: 'docker run --rm or-mit-python Rucksack.py'
ENTRYPOINT ["python"]
CMD []

View file

@ -496,6 +496,218 @@
" loese_jobshop()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Parallele Suche: was `num_workers` wirklich bewirkt\n",
"\n",
"`Parallele_Suche.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Parallele_Suche.py\n",
"\"\"\"\n",
"Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.\n",
"\n",
"Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte\n",
"Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele\n",
"Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute\n",
"Loesungen findet. Beides wird hier gemessen statt geglaubt.\n",
"\n",
"Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross\n",
"genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,\n",
"jeder Auftrag besucht jede Maschine.\n",
"\n",
"Zwei Messungen:\n",
" 1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.\n",
" 2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt\n",
" derselbe Plan heraus?\n",
"\n",
"Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.\n",
"\n",
"Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen\n",
"Plaene ist es auch - und genau das ist die Aussage.\n",
"\n",
"Benoetigt: numpy, ortools\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import collections\n",
"import time\n",
"\n",
"import numpy as np\n",
"from ortools.sat.python import cp_model\n",
"\n",
"AUFTRAEGE = 12\n",
"MASCHINEN = 10\n",
"INSTANZ_SEED = 20260908\n",
"SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt\n",
"ARBEITERZAHLEN = (1, 2, 4, 8)\n",
"WIEDERHOLUNGEN = 4\n",
"ZEITLIMIT = 120.0\n",
"\n",
"\n",
"def baue_instanz() -> list[list[tuple[int, int]]]:\n",
" \"\"\"Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,\n",
" in einer eigenen zufaelligen Reihenfolge.\"\"\"\n",
" rng = np.random.default_rng(INSTANZ_SEED)\n",
" return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]\n",
" for _ in range(AUFTRAEGE)]\n",
"\n",
"\n",
"def loese(auftraege, arbeiter: int):\n",
" \"\"\"Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan\n",
" zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe\n",
" vergleichen lassen.\"\"\"\n",
" horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)\n",
" modell = cp_model.CpModel()\n",
" Gang = collections.namedtuple(\"Gang\", \"start ende intervall\")\n",
" plaene: dict[tuple[int, int], Gang] = {}\n",
" je_maschine = collections.defaultdict(list)\n",
"\n",
" for a, auftrag in enumerate(auftraege):\n",
" for g, (maschine, dauer) in enumerate(auftrag):\n",
" start = modell.NewIntVar(0, horizont, f\"start_{a}_{g}\")\n",
" ende = modell.NewIntVar(0, horizont, f\"ende_{a}_{g}\")\n",
" intervall = modell.NewIntervalVar(start, dauer, ende, f\"iv_{a}_{g}\")\n",
" plaene[a, g] = Gang(start, ende, intervall)\n",
" je_maschine[maschine].append(intervall)\n",
"\n",
" for maschine in range(MASCHINEN):\n",
" modell.AddNoOverlap(je_maschine[maschine])\n",
" for a, auftrag in enumerate(auftraege):\n",
" for g in range(len(auftrag) - 1):\n",
" modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)\n",
"\n",
" makespan = modell.NewIntVar(0, horizont, \"makespan\")\n",
" modell.AddMaxEquality(\n",
" makespan, [plaene[a, len(auftrag) - 1].ende\n",
" for a, auftrag in enumerate(auftraege)])\n",
" modell.Minimize(makespan)\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.num_workers = arbeiter\n",
" loeser.parameters.random_seed = SOLVER_SEED\n",
" loeser.parameters.max_time_in_seconds = ZEITLIMIT\n",
"\n",
" beginn = time.perf_counter()\n",
" status = loeser.Solve(modell)\n",
" dauer = time.perf_counter() - beginn\n",
"\n",
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
" return loeser.StatusName(status), None, dauer, None\n",
" plan = tuple(loeser.Value(plaene[a, g].start)\n",
" for a, auftrag in enumerate(auftraege)\n",
" for g in range(len(auftrag)))\n",
" return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" auftraege = baue_instanz()\n",
" gaenge = sum(len(a) for a in auftraege)\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" WAS MEHRERE ARBEITER WIRKLICH BRINGEN\")\n",
" print(\"=\" * 78)\n",
" print(f\"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, \"\n",
" f\"{gaenge} Arbeitsgaenge.\")\n",
" print(f\"random_seed = {SOLVER_SEED} in JEDEM Lauf, \"\n",
" f\"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\\n\")\n",
"\n",
" # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.\n",
" ergebnisse: dict[int, list[tuple]] = {}\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" ergebnisse[arbeiter] = [loese(auftraege, arbeiter)\n",
" for _ in range(WIEDERHOLUNGEN)]\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" (1) Laufzeit: was die Arbeiter bringen\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} \"\n",
" f\"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}\")\n",
" print(\"-\" * 78)\n",
" basis_zeit = min(d for _, _, d, _ in ergebnisse[1])\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" laeufe = ergebnisse[arbeiter]\n",
" zeiten = [d for _, _, d, _ in laeufe]\n",
" ziele = {o for _, o, _, _ in laeufe}\n",
" status = {s for s, _, _, _ in laeufe}\n",
" print(f\"{arbeiter:>9} {'/'.join(sorted(status)):>9} \"\n",
" f\"{'/'.join(str(z) for z in sorted(ziele)):>9} \"\n",
" f\"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s \"\n",
" f\"{basis_zeit / min(zeiten):>14.1f}x\")\n",
"\n",
" beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))\n",
" faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])\n",
" print(f\"\\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber\")\n",
" print(f\" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.\")\n",
" print(f\" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche\")\n",
" print(\" laufen, sondern verschiedene Strategien nebeneinander, die einander\")\n",
" print(\" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der\")\n",
" print(\" 'zweite Kern', sondern ein anderes Verfahren.\")\n",
" letzte = ARBEITERZAHLEN[-1]\n",
" if beste != letzte:\n",
" zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])\n",
" print(f\"\\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen \"\n",
" f\"{zeit_letzte:.2f}s\")\n",
" print(f\" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei \"\n",
" f\"{beste}. Ab einer gewissen Zahl kosten Abstimmung\")\n",
" print(\" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} \"\n",
" f\"{'Makespan':>10}\")\n",
" print(\"-\" * 78)\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}\n",
" ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}\n",
" print(f\"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} \"\n",
" f\"{'/'.join(str(z) for z in sorted(ziele)):>10}\")\n",
"\n",
" einer = len({p for _, _, _, p in ergebnisse[1]})\n",
" # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die\n",
" # eigentliche Zahl - nicht, was acht Arbeiter anrichten.\n",
" ab = next((w for w in ARBEITERZAHLEN\n",
" if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)\n",
" print(f\"\\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.\")\n",
" if ab is not None:\n",
" viele = len({p for _, _, _, p in ergebnisse[ab]})\n",
" print(f\" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus \"\n",
" f\"{WIEDERHOLUNGEN} Laeufen -\")\n",
" print(\" bei identischem random_seed und identischem Zielwert.\")\n",
" print(f\" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit\")\n",
" print(f\" zu verlieren. {ab} genuegen.\")\n",
" print(\"\\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt\")\n",
" print(\" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von\")\n",
" print(\" mehreren zuerst fertig wird. Das entscheidet die Uhr.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (3) Was daraus fuer Tests folgt\")\n",
" print(\"=\" * 78)\n",
" print(\" Ein Test der Form assert plan == erwarteter_plan besteht mal und\")\n",
" print(\" scheitert mal, ohne dass sich am Code etwas geaendert haette.\")\n",
" print(\" Zu pruefen sind stattdessen:\")\n",
" print(\" * der ZIELWERT (hier in allen Laeufen gleich),\")\n",
" print(\" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),\")\n",
" print(\" * und der Status - nicht die Gestalt der Loesung.\")\n",
" print(\"\\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte\")\n",
" print(\" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.\")\n",
" print(f\" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau\")\n",
" print(\" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.\")\n",
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},

View file

@ -455,7 +455,8 @@
" exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so\n",
" gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im\n",
" Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man\n",
" nachgehen muss (siehe Denkfehler 8.1).\n",
" nachgehen muss (siehe den Denkfehler im Kapitel Quadratische und\n",
" nichtlineare Optimierung).\n",
" \"\"\"\n",
" gewichte = cp.Variable(ANZAHL_ANLAGEN)\n",
" problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),\n",

View file

@ -757,7 +757,8 @@
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = zeitlimit\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist -\n",
" # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).\n",
" # Im Produktivbetrieb laesst man beides auf den Standardwerten.\n",
" loeser.parameters.num_workers = 1\n",
" loeser.parameters.random_seed = 1\n",

View file

@ -115,7 +115,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Ein System — vier Programmieransätze\n",
"## Wie die Isolation aussieht, wenn sie tragen soll\n",
"\n",
"`Ein_System_Vier_Ansaetze.py`\n"
]
@ -136,85 +136,104 @@
" 2*x1 + 3*x2 + x3 <= 50\n",
" x >= 0\n",
"\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich am Ende.\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich\n",
"am Ende.\n",
"\n",
"WICHTIG: Jeder Solver läuft in einem EIGENEN Prozess, weil sich ortools und\n",
"WICHTIG: Jeder Solver laeuft in einem EIGENEN Prozess, weil sich ortools und\n",
"highspy auf vielen Systemen nicht gemeinsam importieren lassen (beide bringen\n",
"eine eigene HiGHS-Kopie mit -> Symbolkonflikt).\n",
"\n",
"Die Isolation besorgt ein ProcessPoolExecutor. Drei Einstellungen ergeben\n",
"zusammen die Garantie:\n",
"\n",
" mp_context \"spawn\" Der Kindprozess startet mit einem FRISCHEN\n",
" Interpreter, statt den Speicher des Elternprozesses\n",
" zu erben. Was hier schon importiert ist, ist dort\n",
" nicht importiert. Mit dem Standard \"fork\" auf Linux\n",
" waere das nicht so.\n",
" max_tasks_per_child=1 Jede Aufgabe bekommt einen NEUEN Prozess. Ohne das\n",
" wuerde der Pool seinen Arbeiter wiederverwenden - und\n",
" beim zweiten Solver waere der Konflikt zurueck.\n",
" max_workers=1 Haelt die vier Laeufe nacheinander. Nicht aus\n",
" Vorsicht, sondern damit die gemessenen Zeiten\n",
" vergleichbar bleiben.\n",
"\n",
"Jeder Solver steht in einer eigenen Funktion mit LOKALEM Import. Das ist der\n",
"Unterschied zu einem Codestring, den man an 'python -c' uebergibt: Die\n",
"Funktion laesst sich einzeln aufrufen, testen und vom Editor pruefen - ein\n",
"String nicht.\n",
"\n",
"Benoetigt: scipy, highspy, cvxpy, ortools\n",
"\"\"\"\n",
"\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"import multiprocessing\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"ERWARTET = 530.0 # Ergebnis der Handrechnung zum Produktionsprogramm\n",
"\n",
"# Jeder Eintrag ist ein eigenständiges Miniprogramm, das sein Ergebnis als\n",
"# JSON auf stdout ausgibt. So bleibt jeder Import in seinem eigenen Prozess.\n",
"ANSAETZE: dict[str, str] = {\n",
"# Die Instanz - einmal notiert, von allen vier Funktionen benutzt.\n",
"ZIEL = [10.0, 15.0, 25.0]\n",
"MATRIX = [[1, 1, 2], [2, 3, 1]]\n",
"KAPAZITAET = [40.0, 50.0]\n",
"\n",
" \"scipy.optimize.linprog\": \"\"\"\n",
" from scipy.optimize import linprog\n",
" res = linprog(c=[-10.0, -15.0, -25.0], # linprog MINIMIERT -> negieren\n",
" A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50],\n",
" bounds=[(0, None)] * 3, method=\"highs\")\n",
" ausgabe = (-res.fun, list(res.x))\n",
" \"\"\",\n",
"\n",
" \"highspy (natives HiGHS)\": \"\"\"\n",
" import numpy as np, highspy\n",
" h = highspy.Highs()\n",
" h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))\n",
" h.changeObjectiveSense(highspy.ObjSense.kMaximize)\n",
" for j, wert in enumerate([10.0, 15.0, 25.0]):\n",
" h.changeColCost(j, wert)\n",
" # CSR-Format: starts[i] = Beginn von Zeile i in indices/values\n",
" h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,\n",
" np.array([0, 3], dtype=np.int32),\n",
" np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),\n",
" np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0]))\n",
" h.run()\n",
" ausgabe = (h.getInfo().objective_function_value,\n",
" list(h.getSolution().col_value[:3]))\n",
" \"\"\",\n",
"def loese_mit_scipy() -> tuple[float, list[float]]:\n",
" from scipy.optimize import linprog\n",
" ergebnis = linprog(c=[-w for w in ZIEL], # linprog MINIMIERT -> negieren\n",
" A_ub=MATRIX, b_ub=KAPAZITAET,\n",
" bounds=[(0, None)] * 3, method=\"highs\")\n",
" return -ergebnis.fun, list(ergebnis.x)\n",
"\n",
" \"cvxpy\": \"\"\"\n",
" import numpy as np, cvxpy as cp\n",
" x = cp.Variable(3, nonneg=True)\n",
" problem = cp.Problem(cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x),\n",
" [np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40, 50])])\n",
" problem.solve()\n",
" ausgabe = (float(problem.value), [float(v) for v in x.value])\n",
" \"\"\",\n",
"\n",
" \"ortools / GLOP\": \"\"\"\n",
" from ortools.linear_solver import pywraplp\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [s.NumVar(0, s.infinity(), f\"x{j+1}\") for j in range(3)]\n",
" A = [[1, 1, 2], [2, 3, 1]]\n",
" for i, kap in enumerate([40, 50]):\n",
" s.Add(sum(A[i][j] * x[j] for j in range(3)) <= kap)\n",
" s.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])\n",
" s.Solve()\n",
" ausgabe = (s.Objective().Value(), [v.solution_value() for v in x])\n",
" \"\"\",\n",
"def loese_mit_highspy() -> tuple[float, list[float]]:\n",
" import highspy\n",
" import numpy as np\n",
" h = highspy.Highs()\n",
" h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))\n",
" h.changeObjectiveSense(highspy.ObjSense.kMaximize)\n",
" for j, wert in enumerate(ZIEL):\n",
" h.changeColCost(j, wert)\n",
" # CSR-Format: starts[i] = Beginn von Zeile i in indices/values\n",
" h.addRows(2, np.full(2, -highspy.kHighsInf), np.array(KAPAZITAET), 6,\n",
" np.array([0, 3], dtype=np.int32),\n",
" np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),\n",
" np.array([float(w) for zeile in MATRIX for w in zeile]))\n",
" h.run()\n",
" return (h.getInfo().objective_function_value,\n",
" list(h.getSolution().col_value[:3]))\n",
"\n",
"\n",
"def loese_mit_cvxpy() -> tuple[float, list[float]]:\n",
" import cvxpy as cp\n",
" import numpy as np\n",
" x = cp.Variable(3, nonneg=True)\n",
" problem = cp.Problem(cp.Maximize(np.array(ZIEL) @ x),\n",
" [np.array(MATRIX) @ x <= np.array(KAPAZITAET)])\n",
" problem.solve()\n",
" return float(problem.value), [float(v) for v in x.value]\n",
"\n",
"\n",
"def loese_mit_ortools() -> tuple[float, list[float]]:\n",
" from ortools.linear_solver import pywraplp\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [s.NumVar(0, s.infinity(), f\"x{j+1}\") for j in range(3)]\n",
" for i, kapazitaet in enumerate(KAPAZITAET):\n",
" s.Add(sum(MATRIX[i][j] * x[j] for j in range(3)) <= kapazitaet)\n",
" s.Maximize(sum(ZIEL[j] * x[j] for j in range(3)))\n",
" s.Solve()\n",
" return s.Objective().Value(), [v.solution_value() for v in x]\n",
"\n",
"\n",
"ANSAETZE = {\n",
" \"scipy.optimize.linprog\": loese_mit_scipy,\n",
" \"highspy (natives HiGHS)\": loese_mit_highspy,\n",
" \"cvxpy\": loese_mit_cvxpy,\n",
" \"ortools / GLOP\": loese_mit_ortools,\n",
"}\n",
"\n",
"\n",
"def fuehre_in_eigenem_prozess_aus(quelltext: str) -> tuple[float, list[float]]:\n",
" \"\"\"Startet den Codeschnipsel als separaten Python-Prozess und liest das Ergebnis.\"\"\"\n",
" programm = textwrap.dedent(quelltext) + \"\\nimport json; print(json.dumps(ausgabe))\\n\"\n",
" ergebnis = subprocess.run([sys.executable, \"-c\", programm],\n",
" capture_output=True, text=True, timeout=120)\n",
" if ergebnis.returncode != 0:\n",
" raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])\n",
" wert, loesung = json.loads(ergebnis.stdout.strip().splitlines()[-1])\n",
" return wert, loesung\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" EIN SYSTEM - VIER ANSAETZE (je eigener Prozess)\")\n",
@ -223,17 +242,23 @@
" print(\"-\" * 78)\n",
"\n",
" werte = []\n",
" for name, quelltext in ANSAETZE.items():\n",
" t0 = time.perf_counter()\n",
" try:\n",
" wert, x = fuehre_in_eigenem_prozess_aus(quelltext)\n",
" except RuntimeError as fehler:\n",
" print(f\"{name:<26} nicht verfuegbar: {fehler[:40]}\")\n",
" continue\n",
" dauer = time.perf_counter() - t0\n",
" werte.append(wert)\n",
" print(f\"{name:<26} {wert:>10.2f} {x[0]:>7.2f} {x[1]:>7.2f} {x[2]:>7.2f} \"\n",
" f\"{dauer:>8.2f} s\")\n",
" # Ein Pool, vier Aufgaben, vier frische Prozesse. Der Kontext muss\n",
" # \"spawn\" sein - siehe Modulkommentar.\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" for name, funktion in ANSAETZE.items():\n",
" beginn = time.perf_counter()\n",
" try:\n",
" wert, x = pool.submit(funktion).result(timeout=120)\n",
" except Exception as fehler: # Bibliothek fehlt o. Ae.\n",
" print(f\"{name:<26} nicht verfuegbar: {str(fehler)[:40]}\")\n",
" continue\n",
" dauer = time.perf_counter() - beginn\n",
" werte.append(wert)\n",
" print(f\"{name:<26} {wert:>10.2f} {x[0]:>7.2f} {x[1]:>7.2f} {x[2]:>7.2f} \"\n",
" f\"{dauer:>8.2f} s\")\n",
"\n",
" print(\"-\" * 78)\n",
" spanne = max(werte) - min(werte)\n",
@ -243,8 +268,8 @@
" assert spanne < 1e-6, \"Die Bibliotheken widersprechen sich!\"\n",
" assert abs(werte[0] - ERWARTET) < 1e-6, \"Ergebnis weicht von der Handrechnung ab!\"\n",
" print(\"Alle Wege fuehren zum selben, von Hand bestaetigten Optimum.\")\n",
" print(\"(Die Zeiten enthalten den Prozessstart und den Import - sie messen\")\n",
" print(\" NICHT die reine Solverleistung, siehe Uebung 3.5.)\")\n",
" print(\"(Die Zeiten enthalten Prozessstart und Import - sie messen NICHT die\")\n",
" print(\" reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)\")\n",
" print(\"=\" * 78)"
]
},

View file

@ -252,6 +252,328 @@
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Constraint Attribution: welche Bedingung kostet wie viel?\n",
"\n",
"`Constraint_Attribution.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Constraint_Attribution.py\n",
"\"\"\"\n",
"Kapitel Praxisfallen: Welche Bedingung kostet wie viel - und was sagt man\n",
"dem Management?\n",
"\n",
"Zwischen den beiden bekannten Faellen klafft eine Luecke:\n",
"\n",
" * Erklaerbarkeit.py beantwortet \"warum diese eine Zuweisung?\"\n",
" * Konfliktsuche.py (Anhang Fehlerdiagnose) beantwortet \"warum geht es gar\n",
" nicht?\"\n",
"\n",
"Der haeufigste Fall in der Praxis liegt dazwischen: Das Modell rechnet, der\n",
"Plan ist zulaessig - und trotzdem unbefriedigend. Niemand weiss, WORAN es\n",
"liegt. Genau das beantwortet Constraint Attribution.\n",
"\n",
"Gerechnet wird auf derselben Fabrik wie in Konfliktsuche.py, nur nach der\n",
"Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des\n",
"Widerspruchs (2*40 + 3*30 = 170 > 150). Eine zweite Schicht hat sie auf 210\n",
"gebracht, und der Mindestumsatz wurde auf 10.000 EUR gesenkt. Jetzt ist das\n",
"Modell loesbar - und die Frage lautet nicht mehr \"warum nicht?\", sondern\n",
"\"was kostet uns was?\".\n",
"\n",
"Gezeigt werden vier Dinge:\n",
" 1. Welche Bedingungen binden - und dass \"bindend\" nicht \"teuer\" heisst.\n",
" 2. Dass der Schattenpreis eine MOMENTAUFNAHME ist: hochgerechnet auf eine\n",
" realistische Sonderschicht verspricht er hier das Doppelte des\n",
" tatsaechlichen Nutzens.\n",
" 3. Dass ein Wunsch aus dem Vertrieb nicht teuer, sondern unmoeglich sein\n",
" kann - und welche Bedingungen ihn gemeinsam blockieren.\n",
" 4. Wie aus alledem ein Bericht in Alltagssprache wird.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import textwrap\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"PRODUKTE = [\"Rahmen\", \"Gehaeuse\", \"Deckel\", \"Traeger\", \"Halter\"]\n",
"DECKUNGSBEITRAG = np.array([35.0, 28.0, 9.0, 22.0, 6.0]) # EUR je Stueck\n",
"\n",
"# Dieselben Bedingungen wie in Konfliktsuche.py, mit den beiden Reparaturen.\n",
"# Der sprechende Name ist keine Kosmetik: Ohne ihn ist kein Bericht moeglich,\n",
"# der jemandem ausserhalb der Modellierung etwas sagt.\n",
"BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [\n",
" (\"Kapazitaet Montage\", [3, 2, 1, 4, 2], \"<=\", 400),\n",
" (\"Kapazitaet Lackieren\", [2, 3, 0, 1, 0], \"<=\", 210),\n",
" (\"Kapazitaet Pruefung\", [1, 1, 1, 1, 1], \"<=\", 300),\n",
" (\"Liefervertrag Rahmen\", [1, 0, 0, 0, 0], \">=\", 40),\n",
" (\"Liefervertrag Gehaeuse\", [0, 1, 0, 0, 0], \">=\", 30),\n",
" (\"Liefervertrag Deckel\", [0, 0, 1, 0, 0], \">=\", 20),\n",
" (\"Liefervertrag Traeger\", [0, 0, 0, 1, 0], \">=\", 25),\n",
" (\"Marktgrenze Rahmen\", [1, 0, 0, 0, 0], \"<=\", 120),\n",
" (\"Marktgrenze Gehaeuse\", [0, 1, 0, 0, 0], \"<=\", 90),\n",
" (\"Marktgrenze Deckel\", [0, 0, 1, 0, 0], \"<=\", 150),\n",
" (\"Marktgrenze Halter\", [0, 0, 0, 0, 1], \"<=\", 200),\n",
" (\"Mindestumsatz\", [90, 70, 30, 60, 20], \">=\", 10000),\n",
" (\"Sortimentsbreite\", [0, 0, 1, 1, 1], \">=\", 100),\n",
" (\"Lackierbudget Schicht 2\", [0, 1, 0, 1, 0], \"<=\", 55),\n",
"]\n",
"\n",
"# Was liesse sich im Ernstfall wirklich bewegen - und um wie viel? Nicht \"eine\n",
"# Einheit\", sondern der Schritt, den ein Planer tatsaechlich gehen kann: eine\n",
"# Sonderschicht, ein nachverhandelter Vertrag. Genau an dieser Groesse\n",
"# scheitert das blosse Hochrechnen des Schattenpreises.\n",
"STELLSCHRAUBEN: dict[str, tuple[float, str]] = {\n",
" \"Kapazitaet Montage\": (+50, \"eine Sonderschicht Montage\"),\n",
" \"Kapazitaet Lackieren\": (+30, \"eine Sonderschicht Lackieren\"),\n",
" \"Kapazitaet Pruefung\": (+50, \"eine Sonderschicht Pruefung\"),\n",
" \"Lackierbudget Schicht 2\": (+10, \"10 Stunden mehr Budget in Schicht 2\"),\n",
" \"Liefervertrag Traeger\": (-10, \"10 Stueck weniger Abnahmepflicht Traeger\"),\n",
" \"Liefervertrag Gehaeuse\": (-10, \"10 Stueck weniger Abnahmepflicht Gehaeuse\"),\n",
"}\n",
"\n",
"WUNSCH_MENGE = 55.0 # der Vertrieb haette gern mehr Rahmen\n",
"WUNSCH_PRODUKT = \"Rahmen\"\n",
"\n",
"\n",
"def loese(bedingungen: list[tuple[str, list[float], str, float]]):\n",
" \"\"\"Maximiert den Deckungsbeitrag. linprog minimiert, daher das Vorzeichen.\"\"\"\n",
" matrix, rechte_seite = [], []\n",
" for _, koeffizienten, richtung, grenze in bedingungen:\n",
" zeile = np.array(koeffizienten, dtype=float)\n",
" matrix.append(zeile if richtung == \"<=\" else -zeile)\n",
" rechte_seite.append(grenze if richtung == \"<=\" else -grenze)\n",
" return linprog(-DECKUNGSBEITRAG,\n",
" A_ub=np.array(matrix), b_ub=np.array(rechte_seite),\n",
" bounds=[(0, None)] * len(PRODUKTE), method=\"highs\")\n",
"\n",
"\n",
"def geaendert(name: str, delta: float,\n",
" basis=None) -> list[tuple[str, list[float], str, float]]:\n",
" \"\"\"Dieselben Bedingungen, eine davon um delta verschoben.\"\"\"\n",
" return [(n, k, r, g + delta if n == name else g)\n",
" for n, k, r, g in (basis or BEDINGUNGEN)]\n",
"\n",
"\n",
"def auswertung(ergebnis, bedingungen=None):\n",
" \"\"\"Schlupf und Schattenpreis je Bedingung.\"\"\"\n",
" x = ergebnis.x\n",
" dual = ergebnis.ineqlin.marginals\n",
" zeilen = []\n",
" for i, (name, koeff, richtung, grenze) in enumerate(bedingungen or BEDINGUNGEN):\n",
" links = float(np.array(koeff, dtype=float) @ x)\n",
" schlupf = (grenze - links) if richtung == \"<=\" else (links - grenze)\n",
" zeilen.append((name, links, richtung, grenze, schlupf, abs(dual[i])))\n",
" return zeilen\n",
"\n",
"\n",
"def blockierer(bedingungen, zusatz) -> list[str]:\n",
" \"\"\"Welche Bedingungen verhindern 'zusatz' - einzeln weggelassen.\n",
"\n",
" Dasselbe Vorgehen wie der Deletion Filter in Konfliktsuche.py, nur mit\n",
" umgekehrtem Vorzeichen: Dort wird gesucht, was den Widerspruch AUSMACHT,\n",
" hier, was einen Wunsch VERHINDERT. Wird das Modell ohne eine Bedingung\n",
" loesbar, gehoert sie zum Konflikt.\n",
" \"\"\"\n",
" gefunden = []\n",
" for i, (name, _, _, _) in enumerate(bedingungen):\n",
" rest = [c for j, c in enumerate(bedingungen) if j != i] + [zusatz]\n",
" if loese(rest).success:\n",
" gefunden.append(name)\n",
" return gefunden\n",
"\n",
"\n",
"def euro(betrag: float) -> str:\n",
" return f\"{betrag:,.2f} EUR\"\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" basis = loese(BEDINGUNGEN)\n",
" db_basis = -basis.fun\n",
" plan = dict(zip(PRODUKTE, basis.x))\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?\")\n",
" print(\"=\" * 78)\n",
" print(f\"Status: {basis.message.split('(')[0].strip()}\")\n",
" print(f\"Deckungsbeitrag des Plans: {euro(db_basis)}\\n\")\n",
" print(\" \" + \" \".join(f\"{p}: {m:.1f}\" for p, m in plan.items()))\n",
"\n",
" # --- 1. Wer begrenzt den Plan? -----------------------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (1) Welche Bedingungen binden - und was kostet die naechste Einheit?\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Bedingung':<24} {'genutzt':>9} {'Grenze':>7} {'Schlupf':>8} \"\n",
" f\"{'Schattenpreis':>11}\")\n",
" print(\"-\" * 78)\n",
" bindend = []\n",
" for name, links, richtung, grenze, schlupf, dual in auswertung(basis):\n",
" marke = \"\"\n",
" if abs(schlupf) < 1e-6:\n",
" bindend.append((name, dual))\n",
" marke = \" <-- bindend\"\n",
" print(f\"{name:<24} {links:>9.1f} {grenze:>7.0f} {schlupf:>8.1f} \"\n",
" f\"{dual:>11.2f}{marke}\")\n",
"\n",
" ohne_preis = [n for n, d in bindend if d < 1e-9]\n",
" print(f\"\\n {len(bindend)} Bedingungen binden. Aber: \"\n",
" f\"{', '.join(ohne_preis)} bindet\")\n",
" print(\" mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn\")\n",
" print(\" zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.\")\n",
"\n",
" # --- 2. Der Schattenpreis als Momentaufnahme ---------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (2) Was der Schattenpreis verschweigt\")\n",
" print(\"=\" * 78)\n",
" print(\"Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber\")\n",
" print(\"keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:\\n\")\n",
" preise = {name: dual for name, *_, dual in auswertung(basis)}\n",
" print(f\"{'Stellschraube':<24} {'Preis':>9} {'Schritt':>7} \"\n",
" f\"{'hochgerechnet':>13} {'gemessen':>9} {'Abw.':>9}\")\n",
" print(\"-\" * 78)\n",
" wirkung = {}\n",
" for name, (delta, _) in STELLSCHRAUBEN.items():\n",
" ergebnis = loese(geaendert(name, delta))\n",
" echt = (-ergebnis.fun) - db_basis if ergebnis.success else 0.0\n",
" linear = preise[name] * abs(delta)\n",
" wirkung[name] = echt\n",
" print(f\"{name:<24} {preise[name]:>9.2f} {delta:>+7.0f} \"\n",
" f\"{linear:>13.2f} {echt:>9.2f} {echt - linear:>+9.2f}\")\n",
"\n",
" # Wo endet der Gueltigkeitsbereich? Schrittweise nachfahren.\n",
" abweichler = max(STELLSCHRAUBEN,\n",
" key=lambda n: preise[n] * abs(STELLSCHRAUBEN[n][0]) - wirkung[n])\n",
" delta_max = STELLSCHRAUBEN[abweichler][0]\n",
" grenze_gueltig = 0.0\n",
" schritt = delta_max / 60.0\n",
" vorher = db_basis\n",
" for i in range(1, 61):\n",
" d = schritt * i\n",
" e = loese(geaendert(abweichler, d))\n",
" db = -e.fun if e.success else vorher\n",
" if db - vorher > 1e-9:\n",
" grenze_gueltig = d\n",
" vorher = db\n",
" print(f\"\\n '{abweichler}': hochgerechnet \"\n",
" f\"{preise[abweichler] * abs(delta_max):.2f} EUR, tatsaechlich \"\n",
" f\"{wirkung[abweichler]:.2f} EUR.\")\n",
" print(f\" Der Preis von {preise[abweichler]:.2f} EUR gilt nur bis \"\n",
" f\"+{grenze_gueltig:.0f} Stunden. Jede weitere Stunde\")\n",
" print(\" dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.\")\n",
"\n",
" nach_preis = sorted(STELLSCHRAUBEN, key=lambda n: -preise[n])[0]\n",
" nach_wirkung = max(wirkung, key=wirkung.get)\n",
" print(f\"\\n Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt\")\n",
" print(f\" '{nach_preis}' ({preise[nach_preis]:.2f} EUR je Einheit),\")\n",
" print(f\" nach tatsaechlicher Wirkung '{nach_wirkung}' \"\n",
" f\"({wirkung[nach_wirkung]:.2f} EUR).\")\n",
" print(\" Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele\")\n",
" print(\" Einheiten zu haben sind.\")\n",
"\n",
" # --- 3. Der Wunsch aus dem Vertrieb ------------------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(f\" (3) Was-waere-wenn: mindestens {WUNSCH_MENGE:.0f} \"\n",
" f\"{WUNSCH_PRODUKT} statt {plan[WUNSCH_PRODUKT]:.1f}\")\n",
" print(\"=\" * 78)\n",
" wunsch = (f\"Wunsch Vertrieb {WUNSCH_PRODUKT}\",\n",
" [1 if p == WUNSCH_PRODUKT else 0 for p in PRODUKTE], \">=\", WUNSCH_MENGE)\n",
" mit_wunsch = loese(BEDINGUNGEN + [wunsch])\n",
" schuldige: list[str] = []\n",
" engpass = noetig = None\n",
" wunsch_gratis = False\n",
"\n",
" if mit_wunsch.success:\n",
" print(f\"Erfuellbar, kostet {euro(db_basis - (-mit_wunsch.fun))}.\")\n",
" else:\n",
" schuldige = blockierer(BEDINGUNGEN, wunsch)\n",
" print(\"Der Wunsch ist nicht teuer - er ist UNMOEGLICH.\\n\")\n",
" print(f\"Blockiert wird er von {len(schuldige)} Bedingungen gemeinsam. \"\n",
" f\"Faellt eine davon\")\n",
" print(\"weg, ist er erfuellbar; keine allein ist 'der' Grund:\")\n",
" for name in schuldige:\n",
" e = loese([c for c in BEDINGUNGEN if c[0] != name] + [wunsch])\n",
" print(f\" ohne '{name}': moeglich, Deckungsbeitrag {euro(-e.fun)}\")\n",
"\n",
" engpass = next((n for n in schuldige if n in STELLSCHRAUBEN), schuldige[0])\n",
" for schritt in range(1, 61):\n",
" if loese(geaendert(engpass, schritt) + [wunsch]).success:\n",
" noetig = schritt\n",
" break\n",
" if noetig is not None:\n",
" e = loese(geaendert(engpass, noetig) + [wunsch])\n",
" ohne = loese(geaendert(engpass, noetig))\n",
" wunsch_gratis = abs((-e.fun) - (-ohne.fun)) < 1e-6\n",
" print(f\"\\n Mit +{noetig} bei '{engpass}' wird der Wunsch erfuellbar:\")\n",
" print(f\" Deckungsbeitrag {euro(-e.fun)} \"\n",
" f\"({-e.fun - db_basis:+,.2f} gegenueber heute).\")\n",
" if wunsch_gratis:\n",
" print(f\" Und ohne den Wunsch ergaebe dieselbe Lockerung \"\n",
" f\"{euro(-ohne.fun)} bei\")\n",
" print(f\" {ohne.x[PRODUKTE.index(WUNSCH_PRODUKT)]:.0f} \"\n",
" f\"{WUNSCH_PRODUKT} - der Wunsch kostet also NICHTS.\")\n",
" print(\" Er war nie das Problem. Der Engpass war es.\")\n",
"\n",
" # --- 4. Der Bericht ----------------------------------------------------\n",
" # Aus denselben Zahlen, die oben in Tabellen stehen, wird hier Prosa. Kein\n",
" # Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde - das ist der\n",
" # ganze Trick und zugleich die Bedingung dafuer, dass der Bericht nicht\n",
" # irgendwann leise falsch wird.\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde\")\n",
" print(\"=\" * 78)\n",
"\n",
" teuerste = sorted(((n, d) for n, d in bindend if d > 0), key=lambda t: -t[1])\n",
" gross = STELLSCHRAUBEN[nach_wirkung][1]\n",
" klein = STELLSCHRAUBEN[abweichler][1]\n",
"\n",
" absaetze = [\n",
" f\"Der Plan bringt {euro(db_basis)} Deckungsbeitrag. Begrenzt wird er von \"\n",
" f\"{len(bindend)} Bedingungen, von denen {len(teuerste)} tatsaechlich Geld \"\n",
" f\"kosten; \\\"{ohne_preis[0]}\\\" beruehrt den Plan, ohne ihn zu verteuern.\",\n",
"\n",
" f\"Teuerste Bindung ist \\\"{teuerste[0][0]}\\\" mit \"\n",
" f\"{teuerste[0][1]:.2f} EUR je Einheit, gefolgt von \\\"{teuerste[1][0]}\\\" \"\n",
" f\"mit {teuerste[1][1]:.2f} EUR.\",\n",
"\n",
" f\"Die groesste einzelne Verbesserung bringt {gross}: \"\n",
" f\"{euro(wirkung[nach_wirkung])} mehr Deckungsbeitrag. {klein[0].upper()}{klein[1:]} \"\n",
" f\"dagegen bringt nur {euro(wirkung[abweichler])} statt der rechnerischen \"\n",
" f\"{euro(preise[abweichler] * abs(delta_max))} - ab +{grenze_gueltig:.0f} \"\n",
" f\"Stunden begrenzt uns etwas anderes. Wer die ganze Schicht bezahlt, \"\n",
" f\"bezahlt die zweite Haelfte umsonst.\",\n",
" ]\n",
"\n",
" if schuldige and noetig is not None:\n",
" namen = \", \".join(f'\"{s}\"' for s in schuldige)\n",
" satz = (f\"Zum Wunsch des Vertriebs ({WUNSCH_MENGE:.0f} {WUNSCH_PRODUKT} statt \"\n",
" f\"{plan[WUNSCH_PRODUKT]:.1f}): Er ist mit der heutigen Ausstattung \"\n",
" f\"nicht erfuellbar - nicht aus Kostengruenden, sondern weil {namen} \"\n",
" f\"ihn gemeinsam blockieren. Mit +{noetig} Stunden bei \"\n",
" f'\"{engpass}\" wird er moeglich')\n",
" satz += (\", und er kostet dann nichts: Der Plan waehlt diese Menge von \"\n",
" \"sich aus.\" if wunsch_gratis else \".\")\n",
" absaetze.append(satz)\n",
"\n",
" for absatz in absaetze:\n",
" print()\n",
" print(textwrap.fill(absatz, width=76))\n",
" print(\"\\n\" + \"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
@ -826,9 +1148,9 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import subprocess\n",
"import sys\n",
"import multiprocessing\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"from pydantic import BaseModel, Field, model_validator\n",
@ -1031,25 +1353,29 @@
" if any(loesung.werte[problem.schluessel(i, j)] > 0.5 for j in range(m))]\n",
"\n",
"\n",
"def loese_in_eigenem_prozess(name: str) -> Loesung:\n",
" \"\"\"Startet dieses Programm noch einmal - mit genau einem Solverimport.\"\"\"\n",
" ergebnis = subprocess.run([sys.executable, __file__, name],\n",
" capture_output=True, text=True, timeout=300)\n",
" if ergebnis.returncode != 0:\n",
" raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])\n",
" # Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.\n",
" return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])\n",
"def loese_in_eigenem_prozess(name: str, problem: Standortproblem) -> Loesung:\n",
" \"\"\"Laesst genau einen Modellbauer in einem frischen Prozess rechnen.\n",
"\n",
" 'spawn' statt des Linux-Standards 'fork': Der Kindprozess startet mit\n",
" einem leeren Interpreter und importiert nur den Solver, den SEIN\n",
" Modellbauer braucht. max_tasks_per_child=1 sorgt dafuer, dass der Pool\n",
" seinen Arbeiter nicht wiederverwendet - sonst saessen beim zweiten Aufruf\n",
" wieder beide Bibliotheken im selben Prozess.\n",
"\n",
" Hin und zurueck wandert das Domaenenmodell bzw. das Loesungs-DTO. Beide\n",
" kennen keinen Solver, sind also serialisierbar - genau dafuer sind sie da.\n",
" \"\"\"\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" return pool.submit(MODELLBAUER[name], problem).result(timeout=300)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" problem = beispielproblem()\n",
"\n",
" # --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------\n",
" if len(sys.argv) > 1:\n",
" print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())\n",
" sys.exit(0)\n",
"\n",
" # --- Hauptprozess: beide Solver anstossen und vergleichen -------------\n",
" # --- Beide Solver anstossen und vergleichen ---------------------------\n",
" print(\"=\" * 82)\n",
" print(\" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE\")\n",
" print(\"=\" * 82)\n",
@ -1061,7 +1387,7 @@
" loesungen: dict[str, Loesung] = {}\n",
" for name, beschriftung in [(\"cpsat\", \"OR-Tools CP-SAT\"),\n",
" (\"highs\", \"HiGHS (highspy)\")]:\n",
" loesung = loesungen[name] = loese_in_eigenem_prozess(name)\n",
" loesung = loesungen[name] = loese_in_eigenem_prozess(name, problem)\n",
" beanstandungen = pruefe_zuordnung(problem, loesung)\n",
"\n",
" print(f\"{beschriftung}\")\n",

View file

@ -593,113 +593,124 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"import multiprocessing\n",
"import resource\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"\n",
"GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen\n",
"\n",
"\n",
"# Jeder Eintrag ist ein eigenstaendiges Programm: Instanz aufbauen, loesen,\n",
"# Ergebnis als JSON ausgeben. Die Instanz wird in jedem Kindprozess aus\n",
"# derselben Saat neu erzeugt - so reist nichts ueber die Prozessgrenze,\n",
"# was das Ergebnis verfaelschen koennte.\n",
"VORSPANN = \"\"\"\n",
"import json, time, resource\n",
"import numpy as np\n",
"# Instanz und Speichermessung stehen als gewoehnliche Funktionen hier - nicht\n",
"# in einem String, den ein Kindprozess ausfuehrt. Jede Messfunktion baut die\n",
"# Instanz aus derselben Saat neu auf, damit ueber die Prozessgrenze nichts\n",
"# reist, was das Ergebnis verfaelschen koennte.\n",
"\n",
"def instanz(m, n):\n",
"def instanz(m: int, n: int):\n",
" rng = np.random.default_rng(20)\n",
" kosten = rng.integers(5, 95, (m, n)).astype(float)\n",
" angebot = rng.integers(50, 150, m).astype(float)\n",
" bedarf = angebot.sum() * rng.dirichlet(np.ones(n))\n",
" return kosten, angebot, bedarf\n",
"\n",
"def speicher_mb():\n",
" # ru_maxrss ist unter Linux in Kilobyte\n",
"\n",
"def speicher_mb() -> float:\n",
" # ru_maxrss ist unter Linux in Kilobyte. Gemessen wird der Kindprozess -\n",
" # deshalb muss jede Messung einen eigenen bekommen.\n",
" return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024\n",
"\n",
"M, N = {m}, {n}\n",
"kosten, angebot, bedarf = instanz(M, N)\n",
"\"\"\"\n",
"\n",
"ANSAETZE = {\n",
" \"scipy.linprog\": \"\"\"\n",
" from scipy.optimize import linprog\n",
" t0 = time.perf_counter()\n",
" c = kosten.reshape(-1)\n",
" A_ub = np.zeros((M, M * N)); A_eq = np.zeros((N, M * N))\n",
" for i in range(M):\n",
" A_ub[i, i * N:(i + 1) * N] = 1.0\n",
" for j in range(N):\n",
" A_eq[j, j::N] = 1.0\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter()\n",
" r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,\n",
" bounds=(0, None), method=\"highs\")\n",
" loesen = time.perf_counter() - t0\n",
" ausgabe = (float(r.fun), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
"\n",
" \"highspy\": \"\"\"\n",
" import highspy\n",
" t0 = time.perf_counter()\n",
" h = highspy.Highs(); h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(M * N, np.zeros(M * N), np.full(M * N, highspy.kHighsInf))\n",
" for k in range(M * N):\n",
" h.changeColCost(k, float(kosten.reshape(-1)[k]))\n",
" for i in range(M):\n",
" idx = np.arange(i * N, (i + 1) * N, dtype=np.int32)\n",
" h.addRow(-highspy.kHighsInf, float(angebot[i]), N, idx, np.ones(N))\n",
" for j in range(N):\n",
" idx = np.arange(j, M * N, N, dtype=np.int32)\n",
" h.addRow(float(bedarf[j]), float(bedarf[j]), M, idx, np.ones(M))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0\n",
" ausgabe = (h.getInfo().objective_function_value, aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
"\n",
" \"ortools/GLOP\": \"\"\"\n",
" from ortools.linear_solver import pywraplp\n",
" t0 = time.perf_counter()\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [[s.NumVar(0, s.infinity(), f\"x{i}_{j}\") for j in range(N)]\n",
" for i in range(M)]\n",
" for i in range(M):\n",
" s.Add(sum(x[i]) <= float(angebot[i]))\n",
" for j in range(N):\n",
" s.Add(sum(x[i][j] for i in range(M)) == float(bedarf[j]))\n",
" s.Minimize(sum(float(kosten[i, j]) * x[i][j]\n",
" for i in range(M) for j in range(N)))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0\n",
" ausgabe = (s.Objective().Value(), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
"\n",
" \"cvxpy\": \"\"\"\n",
" import cvxpy as cp\n",
" t0 = time.perf_counter()\n",
" x = cp.Variable((M, N), nonneg=True)\n",
" problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),\n",
" [cp.sum(x, axis=1) <= angebot,\n",
" cp.sum(x, axis=0) == bedarf])\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0\n",
" ausgabe = (float(problem.value), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
"}\n",
"def messe_scipy(m: int, n: int):\n",
" from scipy.optimize import linprog\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" c = kosten.reshape(-1)\n",
" A_ub = np.zeros((m, m * n)); A_eq = np.zeros((n, m * n))\n",
" for i in range(m):\n",
" A_ub[i, i * n:(i + 1) * n] = 1.0\n",
" for j in range(n):\n",
" A_eq[j, j::n] = 1.0\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter()\n",
" r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,\n",
" bounds=(0, None), method=\"highs\")\n",
" loesen = time.perf_counter() - t0\n",
" return float(r.fun), aufbau, loesen, speicher_mb()\n",
"\n",
"\n",
"def messe(name: str, quelltext: str, m: int, n: int):\n",
" \"\"\"Fuehrt einen Ansatz in einem eigenen Prozess aus.\"\"\"\n",
" programm = (VORSPANN.format(m=m, n=n) + textwrap.dedent(quelltext)\n",
" + \"\\nprint(json.dumps(ausgabe))\\n\")\n",
" ergebnis = subprocess.run([sys.executable, \"-c\", programm],\n",
" capture_output=True, text=True, timeout=600)\n",
" if ergebnis.returncode != 0:\n",
" return None, ergebnis.stderr.strip().splitlines()[-1][:60]\n",
" return json.loads(ergebnis.stdout.strip().splitlines()[-1]), None\n",
"def messe_highspy(m: int, n: int):\n",
" import highspy\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" h = highspy.Highs(); h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(m * n, np.zeros(m * n), np.full(m * n, highspy.kHighsInf))\n",
" for k in range(m * n):\n",
" h.changeColCost(k, float(kosten.reshape(-1)[k]))\n",
" for i in range(m):\n",
" idx = np.arange(i * n, (i + 1) * n, dtype=np.int32)\n",
" h.addRow(-highspy.kHighsInf, float(angebot[i]), n, idx, np.ones(n))\n",
" for j in range(n):\n",
" idx = np.arange(j, m * n, n, dtype=np.int32)\n",
" h.addRow(float(bedarf[j]), float(bedarf[j]), m, idx, np.ones(m))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0\n",
" return h.getInfo().objective_function_value, aufbau, loesen, speicher_mb()\n",
"\n",
"\n",
"def messe_ortools(m: int, n: int):\n",
" from ortools.linear_solver import pywraplp\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [[s.NumVar(0, s.infinity(), f\"x{i}_{j}\") for j in range(n)]\n",
" for i in range(m)]\n",
" for i in range(m):\n",
" s.Add(sum(x[i]) <= float(angebot[i]))\n",
" for j in range(n):\n",
" s.Add(sum(x[i][j] for i in range(m)) == float(bedarf[j]))\n",
" s.Minimize(sum(float(kosten[i, j]) * x[i][j]\n",
" for i in range(m) for j in range(n)))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0\n",
" return s.Objective().Value(), aufbau, loesen, speicher_mb()\n",
"\n",
"\n",
"def messe_cvxpy(m: int, n: int):\n",
" import cvxpy as cp\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" x = cp.Variable((m, n), nonneg=True)\n",
" problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),\n",
" [cp.sum(x, axis=1) <= angebot,\n",
" cp.sum(x, axis=0) == bedarf])\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0\n",
" return float(problem.value), aufbau, loesen, speicher_mb()\n",
"\n",
"\n",
"ANSAETZE = {\"scipy.linprog\": messe_scipy, \"highspy\": messe_highspy,\n",
" \"ortools/GLOP\": messe_ortools, \"cvxpy\": messe_cvxpy}\n",
"\n",
"\n",
"def messe(funktion, m: int, n: int):\n",
" \"\"\"Fuehrt eine Messfunktion in einem FRISCHEN Prozess aus.\n",
"\n",
" 'spawn' und max_tasks_per_child=1 zusammen garantieren, was Regel 1\n",
" verlangt: Jede Messung sieht einen leeren Interpreter. Ohne das\n",
" zweite wuerde der Pool seinen Arbeiter wiederverwenden - dann waere\n",
" der Speicherwert der zweiten Bibliothek um die erste zu hoch, und\n",
" ortools und highspy saessen im selben Prozess.\n",
" \"\"\"\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" try:\n",
" return pool.submit(funktion, m, n).result(timeout=600), None\n",
" except Exception as fehler:\n",
" return None, str(fehler).strip().splitlines()[-1][:60]\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
@ -717,8 +728,8 @@
" f\"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}\")\n",
" print(\" \" + \"-\" * 72)\n",
" zielwerte = {}\n",
" for name, quelltext in ANSAETZE.items():\n",
" werte, fehler = messe(name, quelltext, m, n)\n",
" for name, funktion in ANSAETZE.items():\n",
" werte, fehler = messe(funktion, m, n)\n",
" if werte is None:\n",
" print(f\" {name:<16} nicht verfuegbar: {fehler}\")\n",
" continue\n",

View file

@ -496,6 +496,218 @@
" loese_jobshop()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Parallele Suche: was `num_workers` wirklich bewirkt\n",
"\n",
"`Parallele_Suche.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Parallele_Suche.py\n",
"\"\"\"\n",
"Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.\n",
"\n",
"Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte\n",
"Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele\n",
"Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute\n",
"Loesungen findet. Beides wird hier gemessen statt geglaubt.\n",
"\n",
"Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross\n",
"genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,\n",
"jeder Auftrag besucht jede Maschine.\n",
"\n",
"Zwei Messungen:\n",
" 1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.\n",
" 2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt\n",
" derselbe Plan heraus?\n",
"\n",
"Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.\n",
"\n",
"Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen\n",
"Plaene ist es auch - und genau das ist die Aussage.\n",
"\n",
"Benoetigt: numpy, ortools\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import collections\n",
"import time\n",
"\n",
"import numpy as np\n",
"from ortools.sat.python import cp_model\n",
"\n",
"AUFTRAEGE = 12\n",
"MASCHINEN = 10\n",
"INSTANZ_SEED = 20260908\n",
"SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt\n",
"ARBEITERZAHLEN = (1, 2, 4, 8)\n",
"WIEDERHOLUNGEN = 4\n",
"ZEITLIMIT = 120.0\n",
"\n",
"\n",
"def baue_instanz() -> list[list[tuple[int, int]]]:\n",
" \"\"\"Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,\n",
" in einer eigenen zufaelligen Reihenfolge.\"\"\"\n",
" rng = np.random.default_rng(INSTANZ_SEED)\n",
" return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]\n",
" for _ in range(AUFTRAEGE)]\n",
"\n",
"\n",
"def loese(auftraege, arbeiter: int):\n",
" \"\"\"Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan\n",
" zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe\n",
" vergleichen lassen.\"\"\"\n",
" horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)\n",
" modell = cp_model.CpModel()\n",
" Gang = collections.namedtuple(\"Gang\", \"start ende intervall\")\n",
" plaene: dict[tuple[int, int], Gang] = {}\n",
" je_maschine = collections.defaultdict(list)\n",
"\n",
" for a, auftrag in enumerate(auftraege):\n",
" for g, (maschine, dauer) in enumerate(auftrag):\n",
" start = modell.NewIntVar(0, horizont, f\"start_{a}_{g}\")\n",
" ende = modell.NewIntVar(0, horizont, f\"ende_{a}_{g}\")\n",
" intervall = modell.NewIntervalVar(start, dauer, ende, f\"iv_{a}_{g}\")\n",
" plaene[a, g] = Gang(start, ende, intervall)\n",
" je_maschine[maschine].append(intervall)\n",
"\n",
" for maschine in range(MASCHINEN):\n",
" modell.AddNoOverlap(je_maschine[maschine])\n",
" for a, auftrag in enumerate(auftraege):\n",
" for g in range(len(auftrag) - 1):\n",
" modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)\n",
"\n",
" makespan = modell.NewIntVar(0, horizont, \"makespan\")\n",
" modell.AddMaxEquality(\n",
" makespan, [plaene[a, len(auftrag) - 1].ende\n",
" for a, auftrag in enumerate(auftraege)])\n",
" modell.Minimize(makespan)\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.num_workers = arbeiter\n",
" loeser.parameters.random_seed = SOLVER_SEED\n",
" loeser.parameters.max_time_in_seconds = ZEITLIMIT\n",
"\n",
" beginn = time.perf_counter()\n",
" status = loeser.Solve(modell)\n",
" dauer = time.perf_counter() - beginn\n",
"\n",
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
" return loeser.StatusName(status), None, dauer, None\n",
" plan = tuple(loeser.Value(plaene[a, g].start)\n",
" for a, auftrag in enumerate(auftraege)\n",
" for g in range(len(auftrag)))\n",
" return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" auftraege = baue_instanz()\n",
" gaenge = sum(len(a) for a in auftraege)\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" WAS MEHRERE ARBEITER WIRKLICH BRINGEN\")\n",
" print(\"=\" * 78)\n",
" print(f\"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, \"\n",
" f\"{gaenge} Arbeitsgaenge.\")\n",
" print(f\"random_seed = {SOLVER_SEED} in JEDEM Lauf, \"\n",
" f\"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\\n\")\n",
"\n",
" # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.\n",
" ergebnisse: dict[int, list[tuple]] = {}\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" ergebnisse[arbeiter] = [loese(auftraege, arbeiter)\n",
" for _ in range(WIEDERHOLUNGEN)]\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" (1) Laufzeit: was die Arbeiter bringen\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} \"\n",
" f\"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}\")\n",
" print(\"-\" * 78)\n",
" basis_zeit = min(d for _, _, d, _ in ergebnisse[1])\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" laeufe = ergebnisse[arbeiter]\n",
" zeiten = [d for _, _, d, _ in laeufe]\n",
" ziele = {o for _, o, _, _ in laeufe}\n",
" status = {s for s, _, _, _ in laeufe}\n",
" print(f\"{arbeiter:>9} {'/'.join(sorted(status)):>9} \"\n",
" f\"{'/'.join(str(z) for z in sorted(ziele)):>9} \"\n",
" f\"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s \"\n",
" f\"{basis_zeit / min(zeiten):>14.1f}x\")\n",
"\n",
" beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))\n",
" faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])\n",
" print(f\"\\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber\")\n",
" print(f\" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.\")\n",
" print(f\" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche\")\n",
" print(\" laufen, sondern verschiedene Strategien nebeneinander, die einander\")\n",
" print(\" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der\")\n",
" print(\" 'zweite Kern', sondern ein anderes Verfahren.\")\n",
" letzte = ARBEITERZAHLEN[-1]\n",
" if beste != letzte:\n",
" zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])\n",
" print(f\"\\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen \"\n",
" f\"{zeit_letzte:.2f}s\")\n",
" print(f\" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei \"\n",
" f\"{beste}. Ab einer gewissen Zahl kosten Abstimmung\")\n",
" print(\" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} \"\n",
" f\"{'Makespan':>10}\")\n",
" print(\"-\" * 78)\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}\n",
" ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}\n",
" print(f\"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} \"\n",
" f\"{'/'.join(str(z) for z in sorted(ziele)):>10}\")\n",
"\n",
" einer = len({p for _, _, _, p in ergebnisse[1]})\n",
" # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die\n",
" # eigentliche Zahl - nicht, was acht Arbeiter anrichten.\n",
" ab = next((w for w in ARBEITERZAHLEN\n",
" if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)\n",
" print(f\"\\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.\")\n",
" if ab is not None:\n",
" viele = len({p for _, _, _, p in ergebnisse[ab]})\n",
" print(f\" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus \"\n",
" f\"{WIEDERHOLUNGEN} Laeufen -\")\n",
" print(\" bei identischem random_seed und identischem Zielwert.\")\n",
" print(f\" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit\")\n",
" print(f\" zu verlieren. {ab} genuegen.\")\n",
" print(\"\\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt\")\n",
" print(\" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von\")\n",
" print(\" mehreren zuerst fertig wird. Das entscheidet die Uhr.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (3) Was daraus fuer Tests folgt\")\n",
" print(\"=\" * 78)\n",
" print(\" Ein Test der Form assert plan == erwarteter_plan besteht mal und\")\n",
" print(\" scheitert mal, ohne dass sich am Code etwas geaendert haette.\")\n",
" print(\" Zu pruefen sind stattdessen:\")\n",
" print(\" * der ZIELWERT (hier in allen Laeufen gleich),\")\n",
" print(\" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),\")\n",
" print(\" * und der Status - nicht die Gestalt der Loesung.\")\n",
" print(\"\\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte\")\n",
" print(\" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.\")\n",
" print(f\" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau\")\n",
" print(\" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.\")\n",
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},

View file

@ -455,7 +455,8 @@
" exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so\n",
" gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im\n",
" Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man\n",
" nachgehen muss (siehe Denkfehler 8.1).\n",
" nachgehen muss (siehe den Denkfehler im Kapitel Quadratische und\n",
" nichtlineare Optimierung).\n",
" \"\"\"\n",
" gewichte = cp.Variable(ANZAHL_ANLAGEN)\n",
" problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),\n",

View file

@ -757,7 +757,8 @@
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = zeitlimit\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist -\n",
" # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).\n",
" # Im Produktivbetrieb laesst man beides auf den Standardwerten.\n",
" loeser.parameters.num_workers = 1\n",
" loeser.parameters.random_seed = 1\n",

View file

@ -115,7 +115,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Ein System — vier Programmieransätze\n",
"## Wie die Isolation aussieht, wenn sie tragen soll\n",
"\n",
"`Ein_System_Vier_Ansaetze.py`\n"
]
@ -136,85 +136,104 @@
" 2*x1 + 3*x2 + x3 <= 50\n",
" x >= 0\n",
"\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich am Ende.\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich\n",
"am Ende.\n",
"\n",
"WICHTIG: Jeder Solver läuft in einem EIGENEN Prozess, weil sich ortools und\n",
"WICHTIG: Jeder Solver laeuft in einem EIGENEN Prozess, weil sich ortools und\n",
"highspy auf vielen Systemen nicht gemeinsam importieren lassen (beide bringen\n",
"eine eigene HiGHS-Kopie mit -> Symbolkonflikt).\n",
"\n",
"Die Isolation besorgt ein ProcessPoolExecutor. Drei Einstellungen ergeben\n",
"zusammen die Garantie:\n",
"\n",
" mp_context \"spawn\" Der Kindprozess startet mit einem FRISCHEN\n",
" Interpreter, statt den Speicher des Elternprozesses\n",
" zu erben. Was hier schon importiert ist, ist dort\n",
" nicht importiert. Mit dem Standard \"fork\" auf Linux\n",
" waere das nicht so.\n",
" max_tasks_per_child=1 Jede Aufgabe bekommt einen NEUEN Prozess. Ohne das\n",
" wuerde der Pool seinen Arbeiter wiederverwenden - und\n",
" beim zweiten Solver waere der Konflikt zurueck.\n",
" max_workers=1 Haelt die vier Laeufe nacheinander. Nicht aus\n",
" Vorsicht, sondern damit die gemessenen Zeiten\n",
" vergleichbar bleiben.\n",
"\n",
"Jeder Solver steht in einer eigenen Funktion mit LOKALEM Import. Das ist der\n",
"Unterschied zu einem Codestring, den man an 'python -c' uebergibt: Die\n",
"Funktion laesst sich einzeln aufrufen, testen und vom Editor pruefen - ein\n",
"String nicht.\n",
"\n",
"Benoetigt: scipy, highspy, cvxpy, ortools\n",
"\"\"\"\n",
"\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"import multiprocessing\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"ERWARTET = 530.0 # Ergebnis der Handrechnung zum Produktionsprogramm\n",
"\n",
"# Jeder Eintrag ist ein eigenständiges Miniprogramm, das sein Ergebnis als\n",
"# JSON auf stdout ausgibt. So bleibt jeder Import in seinem eigenen Prozess.\n",
"ANSAETZE: dict[str, str] = {\n",
"# Die Instanz - einmal notiert, von allen vier Funktionen benutzt.\n",
"ZIEL = [10.0, 15.0, 25.0]\n",
"MATRIX = [[1, 1, 2], [2, 3, 1]]\n",
"KAPAZITAET = [40.0, 50.0]\n",
"\n",
" \"scipy.optimize.linprog\": \"\"\"\n",
" from scipy.optimize import linprog\n",
" res = linprog(c=[-10.0, -15.0, -25.0], # linprog MINIMIERT -> negieren\n",
" A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50],\n",
" bounds=[(0, None)] * 3, method=\"highs\")\n",
" ausgabe = (-res.fun, list(res.x))\n",
" \"\"\",\n",
"\n",
" \"highspy (natives HiGHS)\": \"\"\"\n",
" import numpy as np, highspy\n",
" h = highspy.Highs()\n",
" h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))\n",
" h.changeObjectiveSense(highspy.ObjSense.kMaximize)\n",
" for j, wert in enumerate([10.0, 15.0, 25.0]):\n",
" h.changeColCost(j, wert)\n",
" # CSR-Format: starts[i] = Beginn von Zeile i in indices/values\n",
" h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,\n",
" np.array([0, 3], dtype=np.int32),\n",
" np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),\n",
" np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0]))\n",
" h.run()\n",
" ausgabe = (h.getInfo().objective_function_value,\n",
" list(h.getSolution().col_value[:3]))\n",
" \"\"\",\n",
"def loese_mit_scipy() -> tuple[float, list[float]]:\n",
" from scipy.optimize import linprog\n",
" ergebnis = linprog(c=[-w for w in ZIEL], # linprog MINIMIERT -> negieren\n",
" A_ub=MATRIX, b_ub=KAPAZITAET,\n",
" bounds=[(0, None)] * 3, method=\"highs\")\n",
" return -ergebnis.fun, list(ergebnis.x)\n",
"\n",
" \"cvxpy\": \"\"\"\n",
" import numpy as np, cvxpy as cp\n",
" x = cp.Variable(3, nonneg=True)\n",
" problem = cp.Problem(cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x),\n",
" [np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40, 50])])\n",
" problem.solve()\n",
" ausgabe = (float(problem.value), [float(v) for v in x.value])\n",
" \"\"\",\n",
"\n",
" \"ortools / GLOP\": \"\"\"\n",
" from ortools.linear_solver import pywraplp\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [s.NumVar(0, s.infinity(), f\"x{j+1}\") for j in range(3)]\n",
" A = [[1, 1, 2], [2, 3, 1]]\n",
" for i, kap in enumerate([40, 50]):\n",
" s.Add(sum(A[i][j] * x[j] for j in range(3)) <= kap)\n",
" s.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])\n",
" s.Solve()\n",
" ausgabe = (s.Objective().Value(), [v.solution_value() for v in x])\n",
" \"\"\",\n",
"def loese_mit_highspy() -> tuple[float, list[float]]:\n",
" import highspy\n",
" import numpy as np\n",
" h = highspy.Highs()\n",
" h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))\n",
" h.changeObjectiveSense(highspy.ObjSense.kMaximize)\n",
" for j, wert in enumerate(ZIEL):\n",
" h.changeColCost(j, wert)\n",
" # CSR-Format: starts[i] = Beginn von Zeile i in indices/values\n",
" h.addRows(2, np.full(2, -highspy.kHighsInf), np.array(KAPAZITAET), 6,\n",
" np.array([0, 3], dtype=np.int32),\n",
" np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),\n",
" np.array([float(w) for zeile in MATRIX for w in zeile]))\n",
" h.run()\n",
" return (h.getInfo().objective_function_value,\n",
" list(h.getSolution().col_value[:3]))\n",
"\n",
"\n",
"def loese_mit_cvxpy() -> tuple[float, list[float]]:\n",
" import cvxpy as cp\n",
" import numpy as np\n",
" x = cp.Variable(3, nonneg=True)\n",
" problem = cp.Problem(cp.Maximize(np.array(ZIEL) @ x),\n",
" [np.array(MATRIX) @ x <= np.array(KAPAZITAET)])\n",
" problem.solve()\n",
" return float(problem.value), [float(v) for v in x.value]\n",
"\n",
"\n",
"def loese_mit_ortools() -> tuple[float, list[float]]:\n",
" from ortools.linear_solver import pywraplp\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [s.NumVar(0, s.infinity(), f\"x{j+1}\") for j in range(3)]\n",
" for i, kapazitaet in enumerate(KAPAZITAET):\n",
" s.Add(sum(MATRIX[i][j] * x[j] for j in range(3)) <= kapazitaet)\n",
" s.Maximize(sum(ZIEL[j] * x[j] for j in range(3)))\n",
" s.Solve()\n",
" return s.Objective().Value(), [v.solution_value() for v in x]\n",
"\n",
"\n",
"ANSAETZE = {\n",
" \"scipy.optimize.linprog\": loese_mit_scipy,\n",
" \"highspy (natives HiGHS)\": loese_mit_highspy,\n",
" \"cvxpy\": loese_mit_cvxpy,\n",
" \"ortools / GLOP\": loese_mit_ortools,\n",
"}\n",
"\n",
"\n",
"def fuehre_in_eigenem_prozess_aus(quelltext: str) -> tuple[float, list[float]]:\n",
" \"\"\"Startet den Codeschnipsel als separaten Python-Prozess und liest das Ergebnis.\"\"\"\n",
" programm = textwrap.dedent(quelltext) + \"\\nimport json; print(json.dumps(ausgabe))\\n\"\n",
" ergebnis = subprocess.run([sys.executable, \"-c\", programm],\n",
" capture_output=True, text=True, timeout=120)\n",
" if ergebnis.returncode != 0:\n",
" raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])\n",
" wert, loesung = json.loads(ergebnis.stdout.strip().splitlines()[-1])\n",
" return wert, loesung\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" EIN SYSTEM - VIER ANSAETZE (je eigener Prozess)\")\n",
@ -223,17 +242,23 @@
" print(\"-\" * 78)\n",
"\n",
" werte = []\n",
" for name, quelltext in ANSAETZE.items():\n",
" t0 = time.perf_counter()\n",
" try:\n",
" wert, x = fuehre_in_eigenem_prozess_aus(quelltext)\n",
" except RuntimeError as fehler:\n",
" print(f\"{name:<26} nicht verfuegbar: {fehler[:40]}\")\n",
" continue\n",
" dauer = time.perf_counter() - t0\n",
" werte.append(wert)\n",
" print(f\"{name:<26} {wert:>10.2f} {x[0]:>7.2f} {x[1]:>7.2f} {x[2]:>7.2f} \"\n",
" f\"{dauer:>8.2f} s\")\n",
" # Ein Pool, vier Aufgaben, vier frische Prozesse. Der Kontext muss\n",
" # \"spawn\" sein - siehe Modulkommentar.\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" for name, funktion in ANSAETZE.items():\n",
" beginn = time.perf_counter()\n",
" try:\n",
" wert, x = pool.submit(funktion).result(timeout=120)\n",
" except Exception as fehler: # Bibliothek fehlt o. Ae.\n",
" print(f\"{name:<26} nicht verfuegbar: {str(fehler)[:40]}\")\n",
" continue\n",
" dauer = time.perf_counter() - beginn\n",
" werte.append(wert)\n",
" print(f\"{name:<26} {wert:>10.2f} {x[0]:>7.2f} {x[1]:>7.2f} {x[2]:>7.2f} \"\n",
" f\"{dauer:>8.2f} s\")\n",
"\n",
" print(\"-\" * 78)\n",
" spanne = max(werte) - min(werte)\n",
@ -243,8 +268,8 @@
" assert spanne < 1e-6, \"Die Bibliotheken widersprechen sich!\"\n",
" assert abs(werte[0] - ERWARTET) < 1e-6, \"Ergebnis weicht von der Handrechnung ab!\"\n",
" print(\"Alle Wege fuehren zum selben, von Hand bestaetigten Optimum.\")\n",
" print(\"(Die Zeiten enthalten den Prozessstart und den Import - sie messen\")\n",
" print(\" NICHT die reine Solverleistung, siehe Uebung 3.5.)\")\n",
" print(\"(Die Zeiten enthalten Prozessstart und Import - sie messen NICHT die\")\n",
" print(\" reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)\")\n",
" print(\"=\" * 78)"
]
},

View file

@ -252,6 +252,328 @@
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Constraint Attribution: welche Bedingung kostet wie viel?\n",
"\n",
"`Constraint_Attribution.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Constraint_Attribution.py\n",
"\"\"\"\n",
"Kapitel Praxisfallen: Welche Bedingung kostet wie viel - und was sagt man\n",
"dem Management?\n",
"\n",
"Zwischen den beiden bekannten Faellen klafft eine Luecke:\n",
"\n",
" * Erklaerbarkeit.py beantwortet \"warum diese eine Zuweisung?\"\n",
" * Konfliktsuche.py (Anhang Fehlerdiagnose) beantwortet \"warum geht es gar\n",
" nicht?\"\n",
"\n",
"Der haeufigste Fall in der Praxis liegt dazwischen: Das Modell rechnet, der\n",
"Plan ist zulaessig - und trotzdem unbefriedigend. Niemand weiss, WORAN es\n",
"liegt. Genau das beantwortet Constraint Attribution.\n",
"\n",
"Gerechnet wird auf derselben Fabrik wie in Konfliktsuche.py, nur nach der\n",
"Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des\n",
"Widerspruchs (2*40 + 3*30 = 170 > 150). Eine zweite Schicht hat sie auf 210\n",
"gebracht, und der Mindestumsatz wurde auf 10.000 EUR gesenkt. Jetzt ist das\n",
"Modell loesbar - und die Frage lautet nicht mehr \"warum nicht?\", sondern\n",
"\"was kostet uns was?\".\n",
"\n",
"Gezeigt werden vier Dinge:\n",
" 1. Welche Bedingungen binden - und dass \"bindend\" nicht \"teuer\" heisst.\n",
" 2. Dass der Schattenpreis eine MOMENTAUFNAHME ist: hochgerechnet auf eine\n",
" realistische Sonderschicht verspricht er hier das Doppelte des\n",
" tatsaechlichen Nutzens.\n",
" 3. Dass ein Wunsch aus dem Vertrieb nicht teuer, sondern unmoeglich sein\n",
" kann - und welche Bedingungen ihn gemeinsam blockieren.\n",
" 4. Wie aus alledem ein Bericht in Alltagssprache wird.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import textwrap\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"PRODUKTE = [\"Rahmen\", \"Gehaeuse\", \"Deckel\", \"Traeger\", \"Halter\"]\n",
"DECKUNGSBEITRAG = np.array([35.0, 28.0, 9.0, 22.0, 6.0]) # EUR je Stueck\n",
"\n",
"# Dieselben Bedingungen wie in Konfliktsuche.py, mit den beiden Reparaturen.\n",
"# Der sprechende Name ist keine Kosmetik: Ohne ihn ist kein Bericht moeglich,\n",
"# der jemandem ausserhalb der Modellierung etwas sagt.\n",
"BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [\n",
" (\"Kapazitaet Montage\", [3, 2, 1, 4, 2], \"<=\", 400),\n",
" (\"Kapazitaet Lackieren\", [2, 3, 0, 1, 0], \"<=\", 210),\n",
" (\"Kapazitaet Pruefung\", [1, 1, 1, 1, 1], \"<=\", 300),\n",
" (\"Liefervertrag Rahmen\", [1, 0, 0, 0, 0], \">=\", 40),\n",
" (\"Liefervertrag Gehaeuse\", [0, 1, 0, 0, 0], \">=\", 30),\n",
" (\"Liefervertrag Deckel\", [0, 0, 1, 0, 0], \">=\", 20),\n",
" (\"Liefervertrag Traeger\", [0, 0, 0, 1, 0], \">=\", 25),\n",
" (\"Marktgrenze Rahmen\", [1, 0, 0, 0, 0], \"<=\", 120),\n",
" (\"Marktgrenze Gehaeuse\", [0, 1, 0, 0, 0], \"<=\", 90),\n",
" (\"Marktgrenze Deckel\", [0, 0, 1, 0, 0], \"<=\", 150),\n",
" (\"Marktgrenze Halter\", [0, 0, 0, 0, 1], \"<=\", 200),\n",
" (\"Mindestumsatz\", [90, 70, 30, 60, 20], \">=\", 10000),\n",
" (\"Sortimentsbreite\", [0, 0, 1, 1, 1], \">=\", 100),\n",
" (\"Lackierbudget Schicht 2\", [0, 1, 0, 1, 0], \"<=\", 55),\n",
"]\n",
"\n",
"# Was liesse sich im Ernstfall wirklich bewegen - und um wie viel? Nicht \"eine\n",
"# Einheit\", sondern der Schritt, den ein Planer tatsaechlich gehen kann: eine\n",
"# Sonderschicht, ein nachverhandelter Vertrag. Genau an dieser Groesse\n",
"# scheitert das blosse Hochrechnen des Schattenpreises.\n",
"STELLSCHRAUBEN: dict[str, tuple[float, str]] = {\n",
" \"Kapazitaet Montage\": (+50, \"eine Sonderschicht Montage\"),\n",
" \"Kapazitaet Lackieren\": (+30, \"eine Sonderschicht Lackieren\"),\n",
" \"Kapazitaet Pruefung\": (+50, \"eine Sonderschicht Pruefung\"),\n",
" \"Lackierbudget Schicht 2\": (+10, \"10 Stunden mehr Budget in Schicht 2\"),\n",
" \"Liefervertrag Traeger\": (-10, \"10 Stueck weniger Abnahmepflicht Traeger\"),\n",
" \"Liefervertrag Gehaeuse\": (-10, \"10 Stueck weniger Abnahmepflicht Gehaeuse\"),\n",
"}\n",
"\n",
"WUNSCH_MENGE = 55.0 # der Vertrieb haette gern mehr Rahmen\n",
"WUNSCH_PRODUKT = \"Rahmen\"\n",
"\n",
"\n",
"def loese(bedingungen: list[tuple[str, list[float], str, float]]):\n",
" \"\"\"Maximiert den Deckungsbeitrag. linprog minimiert, daher das Vorzeichen.\"\"\"\n",
" matrix, rechte_seite = [], []\n",
" for _, koeffizienten, richtung, grenze in bedingungen:\n",
" zeile = np.array(koeffizienten, dtype=float)\n",
" matrix.append(zeile if richtung == \"<=\" else -zeile)\n",
" rechte_seite.append(grenze if richtung == \"<=\" else -grenze)\n",
" return linprog(-DECKUNGSBEITRAG,\n",
" A_ub=np.array(matrix), b_ub=np.array(rechte_seite),\n",
" bounds=[(0, None)] * len(PRODUKTE), method=\"highs\")\n",
"\n",
"\n",
"def geaendert(name: str, delta: float,\n",
" basis=None) -> list[tuple[str, list[float], str, float]]:\n",
" \"\"\"Dieselben Bedingungen, eine davon um delta verschoben.\"\"\"\n",
" return [(n, k, r, g + delta if n == name else g)\n",
" for n, k, r, g in (basis or BEDINGUNGEN)]\n",
"\n",
"\n",
"def auswertung(ergebnis, bedingungen=None):\n",
" \"\"\"Schlupf und Schattenpreis je Bedingung.\"\"\"\n",
" x = ergebnis.x\n",
" dual = ergebnis.ineqlin.marginals\n",
" zeilen = []\n",
" for i, (name, koeff, richtung, grenze) in enumerate(bedingungen or BEDINGUNGEN):\n",
" links = float(np.array(koeff, dtype=float) @ x)\n",
" schlupf = (grenze - links) if richtung == \"<=\" else (links - grenze)\n",
" zeilen.append((name, links, richtung, grenze, schlupf, abs(dual[i])))\n",
" return zeilen\n",
"\n",
"\n",
"def blockierer(bedingungen, zusatz) -> list[str]:\n",
" \"\"\"Welche Bedingungen verhindern 'zusatz' - einzeln weggelassen.\n",
"\n",
" Dasselbe Vorgehen wie der Deletion Filter in Konfliktsuche.py, nur mit\n",
" umgekehrtem Vorzeichen: Dort wird gesucht, was den Widerspruch AUSMACHT,\n",
" hier, was einen Wunsch VERHINDERT. Wird das Modell ohne eine Bedingung\n",
" loesbar, gehoert sie zum Konflikt.\n",
" \"\"\"\n",
" gefunden = []\n",
" for i, (name, _, _, _) in enumerate(bedingungen):\n",
" rest = [c for j, c in enumerate(bedingungen) if j != i] + [zusatz]\n",
" if loese(rest).success:\n",
" gefunden.append(name)\n",
" return gefunden\n",
"\n",
"\n",
"def euro(betrag: float) -> str:\n",
" return f\"{betrag:,.2f} EUR\"\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" basis = loese(BEDINGUNGEN)\n",
" db_basis = -basis.fun\n",
" plan = dict(zip(PRODUKTE, basis.x))\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?\")\n",
" print(\"=\" * 78)\n",
" print(f\"Status: {basis.message.split('(')[0].strip()}\")\n",
" print(f\"Deckungsbeitrag des Plans: {euro(db_basis)}\\n\")\n",
" print(\" \" + \" \".join(f\"{p}: {m:.1f}\" for p, m in plan.items()))\n",
"\n",
" # --- 1. Wer begrenzt den Plan? -----------------------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (1) Welche Bedingungen binden - und was kostet die naechste Einheit?\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Bedingung':<24} {'genutzt':>9} {'Grenze':>7} {'Schlupf':>8} \"\n",
" f\"{'Schattenpreis':>11}\")\n",
" print(\"-\" * 78)\n",
" bindend = []\n",
" for name, links, richtung, grenze, schlupf, dual in auswertung(basis):\n",
" marke = \"\"\n",
" if abs(schlupf) < 1e-6:\n",
" bindend.append((name, dual))\n",
" marke = \" <-- bindend\"\n",
" print(f\"{name:<24} {links:>9.1f} {grenze:>7.0f} {schlupf:>8.1f} \"\n",
" f\"{dual:>11.2f}{marke}\")\n",
"\n",
" ohne_preis = [n for n, d in bindend if d < 1e-9]\n",
" print(f\"\\n {len(bindend)} Bedingungen binden. Aber: \"\n",
" f\"{', '.join(ohne_preis)} bindet\")\n",
" print(\" mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn\")\n",
" print(\" zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.\")\n",
"\n",
" # --- 2. Der Schattenpreis als Momentaufnahme ---------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (2) Was der Schattenpreis verschweigt\")\n",
" print(\"=\" * 78)\n",
" print(\"Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber\")\n",
" print(\"keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:\\n\")\n",
" preise = {name: dual for name, *_, dual in auswertung(basis)}\n",
" print(f\"{'Stellschraube':<24} {'Preis':>9} {'Schritt':>7} \"\n",
" f\"{'hochgerechnet':>13} {'gemessen':>9} {'Abw.':>9}\")\n",
" print(\"-\" * 78)\n",
" wirkung = {}\n",
" for name, (delta, _) in STELLSCHRAUBEN.items():\n",
" ergebnis = loese(geaendert(name, delta))\n",
" echt = (-ergebnis.fun) - db_basis if ergebnis.success else 0.0\n",
" linear = preise[name] * abs(delta)\n",
" wirkung[name] = echt\n",
" print(f\"{name:<24} {preise[name]:>9.2f} {delta:>+7.0f} \"\n",
" f\"{linear:>13.2f} {echt:>9.2f} {echt - linear:>+9.2f}\")\n",
"\n",
" # Wo endet der Gueltigkeitsbereich? Schrittweise nachfahren.\n",
" abweichler = max(STELLSCHRAUBEN,\n",
" key=lambda n: preise[n] * abs(STELLSCHRAUBEN[n][0]) - wirkung[n])\n",
" delta_max = STELLSCHRAUBEN[abweichler][0]\n",
" grenze_gueltig = 0.0\n",
" schritt = delta_max / 60.0\n",
" vorher = db_basis\n",
" for i in range(1, 61):\n",
" d = schritt * i\n",
" e = loese(geaendert(abweichler, d))\n",
" db = -e.fun if e.success else vorher\n",
" if db - vorher > 1e-9:\n",
" grenze_gueltig = d\n",
" vorher = db\n",
" print(f\"\\n '{abweichler}': hochgerechnet \"\n",
" f\"{preise[abweichler] * abs(delta_max):.2f} EUR, tatsaechlich \"\n",
" f\"{wirkung[abweichler]:.2f} EUR.\")\n",
" print(f\" Der Preis von {preise[abweichler]:.2f} EUR gilt nur bis \"\n",
" f\"+{grenze_gueltig:.0f} Stunden. Jede weitere Stunde\")\n",
" print(\" dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.\")\n",
"\n",
" nach_preis = sorted(STELLSCHRAUBEN, key=lambda n: -preise[n])[0]\n",
" nach_wirkung = max(wirkung, key=wirkung.get)\n",
" print(f\"\\n Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt\")\n",
" print(f\" '{nach_preis}' ({preise[nach_preis]:.2f} EUR je Einheit),\")\n",
" print(f\" nach tatsaechlicher Wirkung '{nach_wirkung}' \"\n",
" f\"({wirkung[nach_wirkung]:.2f} EUR).\")\n",
" print(\" Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele\")\n",
" print(\" Einheiten zu haben sind.\")\n",
"\n",
" # --- 3. Der Wunsch aus dem Vertrieb ------------------------------------\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(f\" (3) Was-waere-wenn: mindestens {WUNSCH_MENGE:.0f} \"\n",
" f\"{WUNSCH_PRODUKT} statt {plan[WUNSCH_PRODUKT]:.1f}\")\n",
" print(\"=\" * 78)\n",
" wunsch = (f\"Wunsch Vertrieb {WUNSCH_PRODUKT}\",\n",
" [1 if p == WUNSCH_PRODUKT else 0 for p in PRODUKTE], \">=\", WUNSCH_MENGE)\n",
" mit_wunsch = loese(BEDINGUNGEN + [wunsch])\n",
" schuldige: list[str] = []\n",
" engpass = noetig = None\n",
" wunsch_gratis = False\n",
"\n",
" if mit_wunsch.success:\n",
" print(f\"Erfuellbar, kostet {euro(db_basis - (-mit_wunsch.fun))}.\")\n",
" else:\n",
" schuldige = blockierer(BEDINGUNGEN, wunsch)\n",
" print(\"Der Wunsch ist nicht teuer - er ist UNMOEGLICH.\\n\")\n",
" print(f\"Blockiert wird er von {len(schuldige)} Bedingungen gemeinsam. \"\n",
" f\"Faellt eine davon\")\n",
" print(\"weg, ist er erfuellbar; keine allein ist 'der' Grund:\")\n",
" for name in schuldige:\n",
" e = loese([c for c in BEDINGUNGEN if c[0] != name] + [wunsch])\n",
" print(f\" ohne '{name}': moeglich, Deckungsbeitrag {euro(-e.fun)}\")\n",
"\n",
" engpass = next((n for n in schuldige if n in STELLSCHRAUBEN), schuldige[0])\n",
" for schritt in range(1, 61):\n",
" if loese(geaendert(engpass, schritt) + [wunsch]).success:\n",
" noetig = schritt\n",
" break\n",
" if noetig is not None:\n",
" e = loese(geaendert(engpass, noetig) + [wunsch])\n",
" ohne = loese(geaendert(engpass, noetig))\n",
" wunsch_gratis = abs((-e.fun) - (-ohne.fun)) < 1e-6\n",
" print(f\"\\n Mit +{noetig} bei '{engpass}' wird der Wunsch erfuellbar:\")\n",
" print(f\" Deckungsbeitrag {euro(-e.fun)} \"\n",
" f\"({-e.fun - db_basis:+,.2f} gegenueber heute).\")\n",
" if wunsch_gratis:\n",
" print(f\" Und ohne den Wunsch ergaebe dieselbe Lockerung \"\n",
" f\"{euro(-ohne.fun)} bei\")\n",
" print(f\" {ohne.x[PRODUKTE.index(WUNSCH_PRODUKT)]:.0f} \"\n",
" f\"{WUNSCH_PRODUKT} - der Wunsch kostet also NICHTS.\")\n",
" print(\" Er war nie das Problem. Der Engpass war es.\")\n",
"\n",
" # --- 4. Der Bericht ----------------------------------------------------\n",
" # Aus denselben Zahlen, die oben in Tabellen stehen, wird hier Prosa. Kein\n",
" # Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde - das ist der\n",
" # ganze Trick und zugleich die Bedingung dafuer, dass der Bericht nicht\n",
" # irgendwann leise falsch wird.\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde\")\n",
" print(\"=\" * 78)\n",
"\n",
" teuerste = sorted(((n, d) for n, d in bindend if d > 0), key=lambda t: -t[1])\n",
" gross = STELLSCHRAUBEN[nach_wirkung][1]\n",
" klein = STELLSCHRAUBEN[abweichler][1]\n",
"\n",
" absaetze = [\n",
" f\"Der Plan bringt {euro(db_basis)} Deckungsbeitrag. Begrenzt wird er von \"\n",
" f\"{len(bindend)} Bedingungen, von denen {len(teuerste)} tatsaechlich Geld \"\n",
" f\"kosten; \\\"{ohne_preis[0]}\\\" beruehrt den Plan, ohne ihn zu verteuern.\",\n",
"\n",
" f\"Teuerste Bindung ist \\\"{teuerste[0][0]}\\\" mit \"\n",
" f\"{teuerste[0][1]:.2f} EUR je Einheit, gefolgt von \\\"{teuerste[1][0]}\\\" \"\n",
" f\"mit {teuerste[1][1]:.2f} EUR.\",\n",
"\n",
" f\"Die groesste einzelne Verbesserung bringt {gross}: \"\n",
" f\"{euro(wirkung[nach_wirkung])} mehr Deckungsbeitrag. {klein[0].upper()}{klein[1:]} \"\n",
" f\"dagegen bringt nur {euro(wirkung[abweichler])} statt der rechnerischen \"\n",
" f\"{euro(preise[abweichler] * abs(delta_max))} - ab +{grenze_gueltig:.0f} \"\n",
" f\"Stunden begrenzt uns etwas anderes. Wer die ganze Schicht bezahlt, \"\n",
" f\"bezahlt die zweite Haelfte umsonst.\",\n",
" ]\n",
"\n",
" if schuldige and noetig is not None:\n",
" namen = \", \".join(f'\"{s}\"' for s in schuldige)\n",
" satz = (f\"Zum Wunsch des Vertriebs ({WUNSCH_MENGE:.0f} {WUNSCH_PRODUKT} statt \"\n",
" f\"{plan[WUNSCH_PRODUKT]:.1f}): Er ist mit der heutigen Ausstattung \"\n",
" f\"nicht erfuellbar - nicht aus Kostengruenden, sondern weil {namen} \"\n",
" f\"ihn gemeinsam blockieren. Mit +{noetig} Stunden bei \"\n",
" f'\"{engpass}\" wird er moeglich')\n",
" satz += (\", und er kostet dann nichts: Der Plan waehlt diese Menge von \"\n",
" \"sich aus.\" if wunsch_gratis else \".\")\n",
" absaetze.append(satz)\n",
"\n",
" for absatz in absaetze:\n",
" print()\n",
" print(textwrap.fill(absatz, width=76))\n",
" print(\"\\n\" + \"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
@ -826,9 +1148,9 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import subprocess\n",
"import sys\n",
"import multiprocessing\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"from pydantic import BaseModel, Field, model_validator\n",
@ -1031,25 +1353,29 @@
" if any(loesung.werte[problem.schluessel(i, j)] > 0.5 for j in range(m))]\n",
"\n",
"\n",
"def loese_in_eigenem_prozess(name: str) -> Loesung:\n",
" \"\"\"Startet dieses Programm noch einmal - mit genau einem Solverimport.\"\"\"\n",
" ergebnis = subprocess.run([sys.executable, __file__, name],\n",
" capture_output=True, text=True, timeout=300)\n",
" if ergebnis.returncode != 0:\n",
" raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])\n",
" # Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.\n",
" return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])\n",
"def loese_in_eigenem_prozess(name: str, problem: Standortproblem) -> Loesung:\n",
" \"\"\"Laesst genau einen Modellbauer in einem frischen Prozess rechnen.\n",
"\n",
" 'spawn' statt des Linux-Standards 'fork': Der Kindprozess startet mit\n",
" einem leeren Interpreter und importiert nur den Solver, den SEIN\n",
" Modellbauer braucht. max_tasks_per_child=1 sorgt dafuer, dass der Pool\n",
" seinen Arbeiter nicht wiederverwendet - sonst saessen beim zweiten Aufruf\n",
" wieder beide Bibliotheken im selben Prozess.\n",
"\n",
" Hin und zurueck wandert das Domaenenmodell bzw. das Loesungs-DTO. Beide\n",
" kennen keinen Solver, sind also serialisierbar - genau dafuer sind sie da.\n",
" \"\"\"\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" return pool.submit(MODELLBAUER[name], problem).result(timeout=300)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" problem = beispielproblem()\n",
"\n",
" # --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------\n",
" if len(sys.argv) > 1:\n",
" print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())\n",
" sys.exit(0)\n",
"\n",
" # --- Hauptprozess: beide Solver anstossen und vergleichen -------------\n",
" # --- Beide Solver anstossen und vergleichen ---------------------------\n",
" print(\"=\" * 82)\n",
" print(\" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE\")\n",
" print(\"=\" * 82)\n",
@ -1061,7 +1387,7 @@
" loesungen: dict[str, Loesung] = {}\n",
" for name, beschriftung in [(\"cpsat\", \"OR-Tools CP-SAT\"),\n",
" (\"highs\", \"HiGHS (highspy)\")]:\n",
" loesung = loesungen[name] = loese_in_eigenem_prozess(name)\n",
" loesung = loesungen[name] = loese_in_eigenem_prozess(name, problem)\n",
" beanstandungen = pruefe_zuordnung(problem, loesung)\n",
"\n",
" print(f\"{beschriftung}\")\n",

View file

@ -593,113 +593,124 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"import multiprocessing\n",
"import resource\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"\n",
"GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen\n",
"\n",
"\n",
"# Jeder Eintrag ist ein eigenstaendiges Programm: Instanz aufbauen, loesen,\n",
"# Ergebnis als JSON ausgeben. Die Instanz wird in jedem Kindprozess aus\n",
"# derselben Saat neu erzeugt - so reist nichts ueber die Prozessgrenze,\n",
"# was das Ergebnis verfaelschen koennte.\n",
"VORSPANN = \"\"\"\n",
"import json, time, resource\n",
"import numpy as np\n",
"# Instanz und Speichermessung stehen als gewoehnliche Funktionen hier - nicht\n",
"# in einem String, den ein Kindprozess ausfuehrt. Jede Messfunktion baut die\n",
"# Instanz aus derselben Saat neu auf, damit ueber die Prozessgrenze nichts\n",
"# reist, was das Ergebnis verfaelschen koennte.\n",
"\n",
"def instanz(m, n):\n",
"def instanz(m: int, n: int):\n",
" rng = np.random.default_rng(20)\n",
" kosten = rng.integers(5, 95, (m, n)).astype(float)\n",
" angebot = rng.integers(50, 150, m).astype(float)\n",
" bedarf = angebot.sum() * rng.dirichlet(np.ones(n))\n",
" return kosten, angebot, bedarf\n",
"\n",
"def speicher_mb():\n",
" # ru_maxrss ist unter Linux in Kilobyte\n",
"\n",
"def speicher_mb() -> float:\n",
" # ru_maxrss ist unter Linux in Kilobyte. Gemessen wird der Kindprozess -\n",
" # deshalb muss jede Messung einen eigenen bekommen.\n",
" return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024\n",
"\n",
"M, N = {m}, {n}\n",
"kosten, angebot, bedarf = instanz(M, N)\n",
"\"\"\"\n",
"\n",
"ANSAETZE = {\n",
" \"scipy.linprog\": \"\"\"\n",
" from scipy.optimize import linprog\n",
" t0 = time.perf_counter()\n",
" c = kosten.reshape(-1)\n",
" A_ub = np.zeros((M, M * N)); A_eq = np.zeros((N, M * N))\n",
" for i in range(M):\n",
" A_ub[i, i * N:(i + 1) * N] = 1.0\n",
" for j in range(N):\n",
" A_eq[j, j::N] = 1.0\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter()\n",
" r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,\n",
" bounds=(0, None), method=\"highs\")\n",
" loesen = time.perf_counter() - t0\n",
" ausgabe = (float(r.fun), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
"\n",
" \"highspy\": \"\"\"\n",
" import highspy\n",
" t0 = time.perf_counter()\n",
" h = highspy.Highs(); h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(M * N, np.zeros(M * N), np.full(M * N, highspy.kHighsInf))\n",
" for k in range(M * N):\n",
" h.changeColCost(k, float(kosten.reshape(-1)[k]))\n",
" for i in range(M):\n",
" idx = np.arange(i * N, (i + 1) * N, dtype=np.int32)\n",
" h.addRow(-highspy.kHighsInf, float(angebot[i]), N, idx, np.ones(N))\n",
" for j in range(N):\n",
" idx = np.arange(j, M * N, N, dtype=np.int32)\n",
" h.addRow(float(bedarf[j]), float(bedarf[j]), M, idx, np.ones(M))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0\n",
" ausgabe = (h.getInfo().objective_function_value, aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
"\n",
" \"ortools/GLOP\": \"\"\"\n",
" from ortools.linear_solver import pywraplp\n",
" t0 = time.perf_counter()\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [[s.NumVar(0, s.infinity(), f\"x{i}_{j}\") for j in range(N)]\n",
" for i in range(M)]\n",
" for i in range(M):\n",
" s.Add(sum(x[i]) <= float(angebot[i]))\n",
" for j in range(N):\n",
" s.Add(sum(x[i][j] for i in range(M)) == float(bedarf[j]))\n",
" s.Minimize(sum(float(kosten[i, j]) * x[i][j]\n",
" for i in range(M) for j in range(N)))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0\n",
" ausgabe = (s.Objective().Value(), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
"\n",
" \"cvxpy\": \"\"\"\n",
" import cvxpy as cp\n",
" t0 = time.perf_counter()\n",
" x = cp.Variable((M, N), nonneg=True)\n",
" problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),\n",
" [cp.sum(x, axis=1) <= angebot,\n",
" cp.sum(x, axis=0) == bedarf])\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0\n",
" ausgabe = (float(problem.value), aufbau, loesen, speicher_mb())\n",
" \"\"\",\n",
"}\n",
"def messe_scipy(m: int, n: int):\n",
" from scipy.optimize import linprog\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" c = kosten.reshape(-1)\n",
" A_ub = np.zeros((m, m * n)); A_eq = np.zeros((n, m * n))\n",
" for i in range(m):\n",
" A_ub[i, i * n:(i + 1) * n] = 1.0\n",
" for j in range(n):\n",
" A_eq[j, j::n] = 1.0\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter()\n",
" r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,\n",
" bounds=(0, None), method=\"highs\")\n",
" loesen = time.perf_counter() - t0\n",
" return float(r.fun), aufbau, loesen, speicher_mb()\n",
"\n",
"\n",
"def messe(name: str, quelltext: str, m: int, n: int):\n",
" \"\"\"Fuehrt einen Ansatz in einem eigenen Prozess aus.\"\"\"\n",
" programm = (VORSPANN.format(m=m, n=n) + textwrap.dedent(quelltext)\n",
" + \"\\nprint(json.dumps(ausgabe))\\n\")\n",
" ergebnis = subprocess.run([sys.executable, \"-c\", programm],\n",
" capture_output=True, text=True, timeout=600)\n",
" if ergebnis.returncode != 0:\n",
" return None, ergebnis.stderr.strip().splitlines()[-1][:60]\n",
" return json.loads(ergebnis.stdout.strip().splitlines()[-1]), None\n",
"def messe_highspy(m: int, n: int):\n",
" import highspy\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" h = highspy.Highs(); h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(m * n, np.zeros(m * n), np.full(m * n, highspy.kHighsInf))\n",
" for k in range(m * n):\n",
" h.changeColCost(k, float(kosten.reshape(-1)[k]))\n",
" for i in range(m):\n",
" idx = np.arange(i * n, (i + 1) * n, dtype=np.int32)\n",
" h.addRow(-highspy.kHighsInf, float(angebot[i]), n, idx, np.ones(n))\n",
" for j in range(n):\n",
" idx = np.arange(j, m * n, n, dtype=np.int32)\n",
" h.addRow(float(bedarf[j]), float(bedarf[j]), m, idx, np.ones(m))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0\n",
" return h.getInfo().objective_function_value, aufbau, loesen, speicher_mb()\n",
"\n",
"\n",
"def messe_ortools(m: int, n: int):\n",
" from ortools.linear_solver import pywraplp\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [[s.NumVar(0, s.infinity(), f\"x{i}_{j}\") for j in range(n)]\n",
" for i in range(m)]\n",
" for i in range(m):\n",
" s.Add(sum(x[i]) <= float(angebot[i]))\n",
" for j in range(n):\n",
" s.Add(sum(x[i][j] for i in range(m)) == float(bedarf[j]))\n",
" s.Minimize(sum(float(kosten[i, j]) * x[i][j]\n",
" for i in range(m) for j in range(n)))\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0\n",
" return s.Objective().Value(), aufbau, loesen, speicher_mb()\n",
"\n",
"\n",
"def messe_cvxpy(m: int, n: int):\n",
" import cvxpy as cp\n",
" kosten, angebot, bedarf = instanz(m, n)\n",
" t0 = time.perf_counter()\n",
" x = cp.Variable((m, n), nonneg=True)\n",
" problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),\n",
" [cp.sum(x, axis=1) <= angebot,\n",
" cp.sum(x, axis=0) == bedarf])\n",
" aufbau = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0\n",
" return float(problem.value), aufbau, loesen, speicher_mb()\n",
"\n",
"\n",
"ANSAETZE = {\"scipy.linprog\": messe_scipy, \"highspy\": messe_highspy,\n",
" \"ortools/GLOP\": messe_ortools, \"cvxpy\": messe_cvxpy}\n",
"\n",
"\n",
"def messe(funktion, m: int, n: int):\n",
" \"\"\"Fuehrt eine Messfunktion in einem FRISCHEN Prozess aus.\n",
"\n",
" 'spawn' und max_tasks_per_child=1 zusammen garantieren, was Regel 1\n",
" verlangt: Jede Messung sieht einen leeren Interpreter. Ohne das\n",
" zweite wuerde der Pool seinen Arbeiter wiederverwenden - dann waere\n",
" der Speicherwert der zweiten Bibliothek um die erste zu hoch, und\n",
" ortools und highspy saessen im selben Prozess.\n",
" \"\"\"\n",
" with ProcessPoolExecutor(\n",
" max_workers=1,\n",
" mp_context=multiprocessing.get_context(\"spawn\"),\n",
" max_tasks_per_child=1) as pool:\n",
" try:\n",
" return pool.submit(funktion, m, n).result(timeout=600), None\n",
" except Exception as fehler:\n",
" return None, str(fehler).strip().splitlines()[-1][:60]\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
@ -717,8 +728,8 @@
" f\"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}\")\n",
" print(\" \" + \"-\" * 72)\n",
" zielwerte = {}\n",
" for name, quelltext in ANSAETZE.items():\n",
" werte, fehler = messe(name, quelltext, m, n)\n",
" for name, funktion in ANSAETZE.items():\n",
" werte, fehler = messe(funktion, m, n)\n",
" if werte is None:\n",
" print(f\" {name:<16} nicht verfuegbar: {fehler}\")\n",
" continue\n",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
window.OR_PROGRAMME = ["Backtest_Fallen.py", "Bausteine_Vorlage.py", "Bellman_Minimalbeispiel.py", "Benchmark_Skalierung.py", "Betriebsueberwachung.py", "Big_M_Falle.py", "Bot_Allokation.py", "Brute_Force_Vergleich.py", "CP_SAT_Statusfaelle.py", "CP_SAT_Vertretungssystem.py", "CVaR_Portfolio.py", "Chance_Constraints.py", "Data_Snooping.py", "Diversifikation_Demo.py", "Dualitaet_Nachweis.py", "Ein_System_Vier_Ansaetze.py", "Entropie_Maximierte_Allokation.py", "Erklaerbarkeit.py", "Excel_Bruecke.py", "Finanzdaten_Ledoit_Wolf.py", "Fluch_des_Durchschnitts.py", "Infeasibility_Diagnose.py", "Installationstest.py", "JobShop_Intervalle.py", "KKT_Nachweis.py", "Konfliktsuche.py", "Konvexitaet_Demo.py", "Kovarianz_Falle.py", "Kraftwerkseinsatz.py", "Large_Neighborhood_Search.py", "Lokale_Optima_Multistart.py", "MILP_Portfolio_Fixgebuehren.py", "Markowitz_CVXPY.py", "Matrixform.py", "Mehrperiodige_Order_Execution.py", "Mehrziel_Pareto.py", "Metaheuristik_vs_Exakt.py", "Min_Cost_Flow.py", "Modellierungsschichten.py", "Monte_Carlo.py", "Mutationstest.py", "Optimierungsdienst.py", "Predict_then_Optimize.py", "Propagation_Demo.py", "QP_Grundlagen.py", "QuantitativeTradingEngine.py", "Renditen_Vergleich.py", "Renditeschaetzung_Falle.py", "Robuste_Optimierung.py", "Rucksack.py", "Runden_Gegenbeispiel.py", "Schaetzrauschen_Demo.py", "Sensitivitaetsanalyse.py", "Simplex_Tableau_LP.py", "Simulated_Annealing.py", "Skalierung_Kondition.py", "Solver_Wahl.py", "Solverstatus_und_Gap.py", "Solverwechsel_CPSAT_HiGHS.py", "Spaltengenerierung.py", "Stochastische_Optimierung.py", "Strafgewichte.py", "Strukturbruecke.py", "Toleranzen_und_Entartung.py", "VRP_Flotten_Routing.py", "VRP_Kapazitaetsfalle.py", "VaR_CVaR_Demo.py", "Vektorisierte_Modellgenerierung.py", "Visualisierung_Loesungsraum.py", "Vom_Wunsch_zum_Modell.py", "Warmstart_Effekt.py", "Zuordnung_Ungarisch.py", "or_kern.py", "test_or_kern.py"];
window.OR_PROGRAMME = ["Backtest_Fallen.py", "Bausteine_Vorlage.py", "Bellman_Minimalbeispiel.py", "Benchmark_Skalierung.py", "Betriebsueberwachung.py", "Big_M_Falle.py", "Bot_Allokation.py", "Brute_Force_Vergleich.py", "CP_SAT_Statusfaelle.py", "CP_SAT_Vertretungssystem.py", "CVaR_Portfolio.py", "Chance_Constraints.py", "Constraint_Attribution.py", "Data_Snooping.py", "Diversifikation_Demo.py", "Dualitaet_Nachweis.py", "Ein_System_Vier_Ansaetze.py", "Entropie_Maximierte_Allokation.py", "Erklaerbarkeit.py", "Excel_Bruecke.py", "Finanzdaten_Ledoit_Wolf.py", "Fluch_des_Durchschnitts.py", "Infeasibility_Diagnose.py", "Installationstest.py", "JobShop_Intervalle.py", "KKT_Nachweis.py", "Konfliktsuche.py", "Konvexitaet_Demo.py", "Kovarianz_Falle.py", "Kraftwerkseinsatz.py", "Large_Neighborhood_Search.py", "Lokale_Optima_Multistart.py", "MILP_Portfolio_Fixgebuehren.py", "Markowitz_CVXPY.py", "Matrixform.py", "Mehrperiodige_Order_Execution.py", "Mehrziel_Pareto.py", "Metaheuristik_vs_Exakt.py", "Min_Cost_Flow.py", "Modellierungsschichten.py", "Monte_Carlo.py", "Mutationstest.py", "Optimierungsdienst.py", "Parallele_Suche.py", "Predict_then_Optimize.py", "Propagation_Demo.py", "QP_Grundlagen.py", "QuantitativeTradingEngine.py", "Renditen_Vergleich.py", "Renditeschaetzung_Falle.py", "Robuste_Optimierung.py", "Rucksack.py", "Runden_Gegenbeispiel.py", "Schaetzrauschen_Demo.py", "Sensitivitaetsanalyse.py", "Simplex_Tableau_LP.py", "Simulated_Annealing.py", "Skalierung_Kondition.py", "Solver_Wahl.py", "Solverstatus_und_Gap.py", "Solverwechsel_CPSAT_HiGHS.py", "Spaltengenerierung.py", "Stochastische_Optimierung.py", "Strafgewichte.py", "Strukturbruecke.py", "Toleranzen_und_Entartung.py", "VRP_Flotten_Routing.py", "VRP_Kapazitaetsfalle.py", "VaR_CVaR_Demo.py", "Vektorisierte_Modellgenerierung.py", "Visualisierung_Loesungsraum.py", "Vom_Wunsch_zum_Modell.py", "Warmstart_Effekt.py", "Zuordnung_Ungarisch.py", "or_kern.py", "test_or_kern.py"];

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 63 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -33,113 +33,124 @@ Benoetigt: numpy; in den Kindprozessen scipy, highspy, ortools, cvxpy
from __future__ import annotations
import json
import subprocess
import sys
import textwrap
import multiprocessing
import resource
import time
from concurrent.futures import ProcessPoolExecutor
import numpy as np
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
# Instanz und Speichermessung stehen als gewoehnliche Funktionen hier - nicht
# in einem String, den ein Kindprozess ausfuehrt. Jede Messfunktion baut die
# Instanz aus derselben Saat neu auf, damit ueber die Prozessgrenze nichts
# reist, was das Ergebnis verfaelschen koennte.
def instanz(m, n):
def instanz(m: int, n: int):
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
def speicher_mb() -> float:
# ru_maxrss ist unter Linux in Kilobyte. Gemessen wird der Kindprozess -
# deshalb muss jede Messung einen eigenen bekommen.
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_scipy(m: int, n: int):
from scipy.optimize import linprog
kosten, angebot, bedarf = instanz(m, n)
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
return float(r.fun), 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
def messe_highspy(m: int, n: int):
import highspy
kosten, angebot, bedarf = instanz(m, n)
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
return h.getInfo().objective_function_value, aufbau, loesen, speicher_mb()
def messe_ortools(m: int, n: int):
from ortools.linear_solver import pywraplp
kosten, angebot, bedarf = instanz(m, n)
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
return s.Objective().Value(), aufbau, loesen, speicher_mb()
def messe_cvxpy(m: int, n: int):
import cvxpy as cp
kosten, angebot, bedarf = instanz(m, n)
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
return float(problem.value), aufbau, loesen, speicher_mb()
ANSAETZE = {"scipy.linprog": messe_scipy, "highspy": messe_highspy,
"ortools/GLOP": messe_ortools, "cvxpy": messe_cvxpy}
def messe(funktion, m: int, n: int):
"""Fuehrt eine Messfunktion in einem FRISCHEN Prozess aus.
'spawn' und max_tasks_per_child=1 zusammen garantieren, was Regel 1
verlangt: Jede Messung sieht einen leeren Interpreter. Ohne das
zweite wuerde der Pool seinen Arbeiter wiederverwenden - dann waere
der Speicherwert der zweiten Bibliothek um die erste zu hoch, und
ortools und highspy saessen im selben Prozess.
"""
with ProcessPoolExecutor(
max_workers=1,
mp_context=multiprocessing.get_context("spawn"),
max_tasks_per_child=1) as pool:
try:
return pool.submit(funktion, m, n).result(timeout=600), None
except Exception as fehler:
return None, str(fehler).strip().splitlines()[-1][:60]
if __name__ == "__main__":
@ -157,8 +168,8 @@ if __name__ == "__main__":
f"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}")
print(" " + "-" * 72)
zielwerte = {}
for name, quelltext in ANSAETZE.items():
werte, fehler = messe(name, quelltext, m, n)
for name, funktion in ANSAETZE.items():
werte, fehler = messe(funktion, m, n)
if werte is None:
print(f" {name:<16} nicht verfuegbar: {fehler}")
continue

View file

@ -0,0 +1,305 @@
#!/usr/bin/env python3
# Constraint_Attribution.py
"""
Kapitel Praxisfallen: Welche Bedingung kostet wie viel - und was sagt man
dem Management?
Zwischen den beiden bekannten Faellen klafft eine Luecke:
* Erklaerbarkeit.py beantwortet "warum diese eine Zuweisung?"
* Konfliktsuche.py (Anhang Fehlerdiagnose) beantwortet "warum geht es gar
nicht?"
Der haeufigste Fall in der Praxis liegt dazwischen: Das Modell rechnet, der
Plan ist zulaessig - und trotzdem unbefriedigend. Niemand weiss, WORAN es
liegt. Genau das beantwortet Constraint Attribution.
Gerechnet wird auf derselben Fabrik wie in Konfliktsuche.py, nur nach der
Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des
Widerspruchs (2*40 + 3*30 = 170 > 150). Eine zweite Schicht hat sie auf 210
gebracht, und der Mindestumsatz wurde auf 10.000 EUR gesenkt. Jetzt ist das
Modell loesbar - und die Frage lautet nicht mehr "warum nicht?", sondern
"was kostet uns was?".
Gezeigt werden vier Dinge:
1. Welche Bedingungen binden - und dass "bindend" nicht "teuer" heisst.
2. Dass der Schattenpreis eine MOMENTAUFNAHME ist: hochgerechnet auf eine
realistische Sonderschicht verspricht er hier das Doppelte des
tatsaechlichen Nutzens.
3. Dass ein Wunsch aus dem Vertrieb nicht teuer, sondern unmoeglich sein
kann - und welche Bedingungen ihn gemeinsam blockieren.
4. Wie aus alledem ein Bericht in Alltagssprache wird.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import textwrap
import numpy as np
from scipy.optimize import linprog
PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]
DECKUNGSBEITRAG = np.array([35.0, 28.0, 9.0, 22.0, 6.0]) # EUR je Stueck
# Dieselben Bedingungen wie in Konfliktsuche.py, mit den beiden Reparaturen.
# Der sprechende Name ist keine Kosmetik: Ohne ihn ist kein Bericht moeglich,
# der jemandem ausserhalb der Modellierung etwas sagt.
BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
("Kapazitaet Montage", [3, 2, 1, 4, 2], "<=", 400),
("Kapazitaet Lackieren", [2, 3, 0, 1, 0], "<=", 210),
("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], ">=", 10000),
("Sortimentsbreite", [0, 0, 1, 1, 1], ">=", 100),
("Lackierbudget Schicht 2", [0, 1, 0, 1, 0], "<=", 55),
]
# Was liesse sich im Ernstfall wirklich bewegen - und um wie viel? Nicht "eine
# Einheit", sondern der Schritt, den ein Planer tatsaechlich gehen kann: eine
# Sonderschicht, ein nachverhandelter Vertrag. Genau an dieser Groesse
# scheitert das blosse Hochrechnen des Schattenpreises.
STELLSCHRAUBEN: dict[str, tuple[float, str]] = {
"Kapazitaet Montage": (+50, "eine Sonderschicht Montage"),
"Kapazitaet Lackieren": (+30, "eine Sonderschicht Lackieren"),
"Kapazitaet Pruefung": (+50, "eine Sonderschicht Pruefung"),
"Lackierbudget Schicht 2": (+10, "10 Stunden mehr Budget in Schicht 2"),
"Liefervertrag Traeger": (-10, "10 Stueck weniger Abnahmepflicht Traeger"),
"Liefervertrag Gehaeuse": (-10, "10 Stueck weniger Abnahmepflicht Gehaeuse"),
}
WUNSCH_MENGE = 55.0 # der Vertrieb haette gern mehr Rahmen
WUNSCH_PRODUKT = "Rahmen"
def loese(bedingungen: list[tuple[str, list[float], str, float]]):
"""Maximiert den Deckungsbeitrag. linprog minimiert, daher das Vorzeichen."""
matrix, rechte_seite = [], []
for _, koeffizienten, richtung, grenze in bedingungen:
zeile = np.array(koeffizienten, dtype=float)
matrix.append(zeile if richtung == "<=" else -zeile)
rechte_seite.append(grenze if richtung == "<=" else -grenze)
return linprog(-DECKUNGSBEITRAG,
A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
bounds=[(0, None)] * len(PRODUKTE), method="highs")
def geaendert(name: str, delta: float,
basis=None) -> list[tuple[str, list[float], str, float]]:
"""Dieselben Bedingungen, eine davon um delta verschoben."""
return [(n, k, r, g + delta if n == name else g)
for n, k, r, g in (basis or BEDINGUNGEN)]
def auswertung(ergebnis, bedingungen=None):
"""Schlupf und Schattenpreis je Bedingung."""
x = ergebnis.x
dual = ergebnis.ineqlin.marginals
zeilen = []
for i, (name, koeff, richtung, grenze) in enumerate(bedingungen or BEDINGUNGEN):
links = float(np.array(koeff, dtype=float) @ x)
schlupf = (grenze - links) if richtung == "<=" else (links - grenze)
zeilen.append((name, links, richtung, grenze, schlupf, abs(dual[i])))
return zeilen
def blockierer(bedingungen, zusatz) -> list[str]:
"""Welche Bedingungen verhindern 'zusatz' - einzeln weggelassen.
Dasselbe Vorgehen wie der Deletion Filter in Konfliktsuche.py, nur mit
umgekehrtem Vorzeichen: Dort wird gesucht, was den Widerspruch AUSMACHT,
hier, was einen Wunsch VERHINDERT. Wird das Modell ohne eine Bedingung
loesbar, gehoert sie zum Konflikt.
"""
gefunden = []
for i, (name, _, _, _) in enumerate(bedingungen):
rest = [c for j, c in enumerate(bedingungen) if j != i] + [zusatz]
if loese(rest).success:
gefunden.append(name)
return gefunden
def euro(betrag: float) -> str:
return f"{betrag:,.2f} EUR"
if __name__ == "__main__":
basis = loese(BEDINGUNGEN)
db_basis = -basis.fun
plan = dict(zip(PRODUKTE, basis.x))
print("=" * 78)
print(" CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?")
print("=" * 78)
print(f"Status: {basis.message.split('(')[0].strip()}")
print(f"Deckungsbeitrag des Plans: {euro(db_basis)}\n")
print(" " + " ".join(f"{p}: {m:.1f}" for p, m in plan.items()))
# --- 1. Wer begrenzt den Plan? -----------------------------------------
print("\n" + "=" * 78)
print(" (1) Welche Bedingungen binden - und was kostet die naechste Einheit?")
print("=" * 78)
print(f"{'Bedingung':<24} {'genutzt':>9} {'Grenze':>7} {'Schlupf':>8} "
f"{'Schattenpreis':>11}")
print("-" * 78)
bindend = []
for name, links, richtung, grenze, schlupf, dual in auswertung(basis):
marke = ""
if abs(schlupf) < 1e-6:
bindend.append((name, dual))
marke = " <-- bindend"
print(f"{name:<24} {links:>9.1f} {grenze:>7.0f} {schlupf:>8.1f} "
f"{dual:>11.2f}{marke}")
ohne_preis = [n for n, d in bindend if d < 1e-9]
print(f"\n {len(bindend)} Bedingungen binden. Aber: "
f"{', '.join(ohne_preis)} bindet")
print(" mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn")
print(" zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.")
# --- 2. Der Schattenpreis als Momentaufnahme ---------------------------
print("\n" + "=" * 78)
print(" (2) Was der Schattenpreis verschweigt")
print("=" * 78)
print("Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber")
print("keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:\n")
preise = {name: dual for name, *_, dual in auswertung(basis)}
print(f"{'Stellschraube':<24} {'Preis':>9} {'Schritt':>7} "
f"{'hochgerechnet':>13} {'gemessen':>9} {'Abw.':>9}")
print("-" * 78)
wirkung = {}
for name, (delta, _) in STELLSCHRAUBEN.items():
ergebnis = loese(geaendert(name, delta))
echt = (-ergebnis.fun) - db_basis if ergebnis.success else 0.0
linear = preise[name] * abs(delta)
wirkung[name] = echt
print(f"{name:<24} {preise[name]:>9.2f} {delta:>+7.0f} "
f"{linear:>13.2f} {echt:>9.2f} {echt - linear:>+9.2f}")
# Wo endet der Gueltigkeitsbereich? Schrittweise nachfahren.
abweichler = max(STELLSCHRAUBEN,
key=lambda n: preise[n] * abs(STELLSCHRAUBEN[n][0]) - wirkung[n])
delta_max = STELLSCHRAUBEN[abweichler][0]
grenze_gueltig = 0.0
schritt = delta_max / 60.0
vorher = db_basis
for i in range(1, 61):
d = schritt * i
e = loese(geaendert(abweichler, d))
db = -e.fun if e.success else vorher
if db - vorher > 1e-9:
grenze_gueltig = d
vorher = db
print(f"\n '{abweichler}': hochgerechnet "
f"{preise[abweichler] * abs(delta_max):.2f} EUR, tatsaechlich "
f"{wirkung[abweichler]:.2f} EUR.")
print(f" Der Preis von {preise[abweichler]:.2f} EUR gilt nur bis "
f"+{grenze_gueltig:.0f} Stunden. Jede weitere Stunde")
print(" dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.")
nach_preis = sorted(STELLSCHRAUBEN, key=lambda n: -preise[n])[0]
nach_wirkung = max(wirkung, key=wirkung.get)
print(f"\n Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt")
print(f" '{nach_preis}' ({preise[nach_preis]:.2f} EUR je Einheit),")
print(f" nach tatsaechlicher Wirkung '{nach_wirkung}' "
f"({wirkung[nach_wirkung]:.2f} EUR).")
print(" Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele")
print(" Einheiten zu haben sind.")
# --- 3. Der Wunsch aus dem Vertrieb ------------------------------------
print("\n" + "=" * 78)
print(f" (3) Was-waere-wenn: mindestens {WUNSCH_MENGE:.0f} "
f"{WUNSCH_PRODUKT} statt {plan[WUNSCH_PRODUKT]:.1f}")
print("=" * 78)
wunsch = (f"Wunsch Vertrieb {WUNSCH_PRODUKT}",
[1 if p == WUNSCH_PRODUKT else 0 for p in PRODUKTE], ">=", WUNSCH_MENGE)
mit_wunsch = loese(BEDINGUNGEN + [wunsch])
schuldige: list[str] = []
engpass = noetig = None
wunsch_gratis = False
if mit_wunsch.success:
print(f"Erfuellbar, kostet {euro(db_basis - (-mit_wunsch.fun))}.")
else:
schuldige = blockierer(BEDINGUNGEN, wunsch)
print("Der Wunsch ist nicht teuer - er ist UNMOEGLICH.\n")
print(f"Blockiert wird er von {len(schuldige)} Bedingungen gemeinsam. "
f"Faellt eine davon")
print("weg, ist er erfuellbar; keine allein ist 'der' Grund:")
for name in schuldige:
e = loese([c for c in BEDINGUNGEN if c[0] != name] + [wunsch])
print(f" ohne '{name}': moeglich, Deckungsbeitrag {euro(-e.fun)}")
engpass = next((n for n in schuldige if n in STELLSCHRAUBEN), schuldige[0])
for schritt in range(1, 61):
if loese(geaendert(engpass, schritt) + [wunsch]).success:
noetig = schritt
break
if noetig is not None:
e = loese(geaendert(engpass, noetig) + [wunsch])
ohne = loese(geaendert(engpass, noetig))
wunsch_gratis = abs((-e.fun) - (-ohne.fun)) < 1e-6
print(f"\n Mit +{noetig} bei '{engpass}' wird der Wunsch erfuellbar:")
print(f" Deckungsbeitrag {euro(-e.fun)} "
f"({-e.fun - db_basis:+,.2f} gegenueber heute).")
if wunsch_gratis:
print(f" Und ohne den Wunsch ergaebe dieselbe Lockerung "
f"{euro(-ohne.fun)} bei")
print(f" {ohne.x[PRODUKTE.index(WUNSCH_PRODUKT)]:.0f} "
f"{WUNSCH_PRODUKT} - der Wunsch kostet also NICHTS.")
print(" Er war nie das Problem. Der Engpass war es.")
# --- 4. Der Bericht ----------------------------------------------------
# Aus denselben Zahlen, die oben in Tabellen stehen, wird hier Prosa. Kein
# Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde - das ist der
# ganze Trick und zugleich die Bedingung dafuer, dass der Bericht nicht
# irgendwann leise falsch wird.
print("\n" + "=" * 78)
print(" (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde")
print("=" * 78)
teuerste = sorted(((n, d) for n, d in bindend if d > 0), key=lambda t: -t[1])
gross = STELLSCHRAUBEN[nach_wirkung][1]
klein = STELLSCHRAUBEN[abweichler][1]
absaetze = [
f"Der Plan bringt {euro(db_basis)} Deckungsbeitrag. Begrenzt wird er von "
f"{len(bindend)} Bedingungen, von denen {len(teuerste)} tatsaechlich Geld "
f"kosten; \"{ohne_preis[0]}\" beruehrt den Plan, ohne ihn zu verteuern.",
f"Teuerste Bindung ist \"{teuerste[0][0]}\" mit "
f"{teuerste[0][1]:.2f} EUR je Einheit, gefolgt von \"{teuerste[1][0]}\" "
f"mit {teuerste[1][1]:.2f} EUR.",
f"Die groesste einzelne Verbesserung bringt {gross}: "
f"{euro(wirkung[nach_wirkung])} mehr Deckungsbeitrag. {klein[0].upper()}{klein[1:]} "
f"dagegen bringt nur {euro(wirkung[abweichler])} statt der rechnerischen "
f"{euro(preise[abweichler] * abs(delta_max))} - ab +{grenze_gueltig:.0f} "
f"Stunden begrenzt uns etwas anderes. Wer die ganze Schicht bezahlt, "
f"bezahlt die zweite Haelfte umsonst.",
]
if schuldige and noetig is not None:
namen = ", ".join(f'"{s}"' for s in schuldige)
satz = (f"Zum Wunsch des Vertriebs ({WUNSCH_MENGE:.0f} {WUNSCH_PRODUKT} statt "
f"{plan[WUNSCH_PRODUKT]:.1f}): Er ist mit der heutigen Ausstattung "
f"nicht erfuellbar - nicht aus Kostengruenden, sondern weil {namen} "
f"ihn gemeinsam blockieren. Mit +{noetig} Stunden bei "
f'"{engpass}" wird er moeglich')
satz += (", und er kostet dann nichts: Der Plan waehlt diese Menge von "
"sich aus." if wunsch_gratis else ".")
absaetze.append(satz)
for absatz in absaetze:
print()
print(textwrap.fill(absatz, width=76))
print("\n" + "=" * 78)

View file

@ -8,85 +8,104 @@ Kapitel Oekosystem: Dasselbe LP in vier Bibliotheken.
2*x1 + 3*x2 + x3 <= 50
x >= 0
Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich am Ende.
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
WICHTIG: Jeder Solver laeuft in einem EIGENEN Prozess, weil sich ortools und
highspy auf vielen Systemen nicht gemeinsam importieren lassen (beide bringen
eine eigene HiGHS-Kopie mit -> Symbolkonflikt).
Die Isolation besorgt ein ProcessPoolExecutor. Drei Einstellungen ergeben
zusammen die Garantie:
mp_context "spawn" Der Kindprozess startet mit einem FRISCHEN
Interpreter, statt den Speicher des Elternprozesses
zu erben. Was hier schon importiert ist, ist dort
nicht importiert. Mit dem Standard "fork" auf Linux
waere das nicht so.
max_tasks_per_child=1 Jede Aufgabe bekommt einen NEUEN Prozess. Ohne das
wuerde der Pool seinen Arbeiter wiederverwenden - und
beim zweiten Solver waere der Konflikt zurueck.
max_workers=1 Haelt die vier Laeufe nacheinander. Nicht aus
Vorsicht, sondern damit die gemessenen Zeiten
vergleichbar bleiben.
Jeder Solver steht in einer eigenen Funktion mit LOKALEM Import. Das ist der
Unterschied zu einem Codestring, den man an 'python -c' uebergibt: Die
Funktion laesst sich einzeln aufrufen, testen und vom Editor pruefen - ein
String nicht.
Benoetigt: scipy, highspy, cvxpy, ortools
"""
import json
import subprocess
import sys
import textwrap
import multiprocessing
import time
from concurrent.futures import ProcessPoolExecutor
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] = {
# Die Instanz - einmal notiert, von allen vier Funktionen benutzt.
ZIEL = [10.0, 15.0, 25.0]
MATRIX = [[1, 1, 2], [2, 3, 1]]
KAPAZITAET = [40.0, 50.0]
"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]))
""",
def loese_mit_scipy() -> tuple[float, list[float]]:
from scipy.optimize import linprog
ergebnis = linprog(c=[-w for w in ZIEL], # linprog MINIMIERT -> negieren
A_ub=MATRIX, b_ub=KAPAZITAET,
bounds=[(0, None)] * 3, method="highs")
return -ergebnis.fun, list(ergebnis.x)
"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 loese_mit_highspy() -> tuple[float, list[float]]:
import highspy
import numpy as np
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(ZIEL):
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(KAPAZITAET), 6,
np.array([0, 3], dtype=np.int32),
np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),
np.array([float(w) for zeile in MATRIX for w in zeile]))
h.run()
return (h.getInfo().objective_function_value,
list(h.getSolution().col_value[:3]))
def loese_mit_cvxpy() -> tuple[float, list[float]]:
import cvxpy as cp
import numpy as np
x = cp.Variable(3, nonneg=True)
problem = cp.Problem(cp.Maximize(np.array(ZIEL) @ x),
[np.array(MATRIX) @ x <= np.array(KAPAZITAET)])
problem.solve()
return float(problem.value), [float(v) for v in x.value]
def loese_mit_ortools() -> tuple[float, list[float]]:
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)]
for i, kapazitaet in enumerate(KAPAZITAET):
s.Add(sum(MATRIX[i][j] * x[j] for j in range(3)) <= kapazitaet)
s.Maximize(sum(ZIEL[j] * x[j] for j in range(3)))
s.Solve()
return s.Objective().Value(), [v.solution_value() for v in x]
ANSAETZE = {
"scipy.optimize.linprog": loese_mit_scipy,
"highspy (natives HiGHS)": loese_mit_highspy,
"cvxpy": loese_mit_cvxpy,
"ortools / GLOP": loese_mit_ortools,
}
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)")
@ -95,17 +114,23 @@ if __name__ == "__main__":
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")
# Ein Pool, vier Aufgaben, vier frische Prozesse. Der Kontext muss
# "spawn" sein - siehe Modulkommentar.
with ProcessPoolExecutor(
max_workers=1,
mp_context=multiprocessing.get_context("spawn"),
max_tasks_per_child=1) as pool:
for name, funktion in ANSAETZE.items():
beginn = time.perf_counter()
try:
wert, x = pool.submit(funktion).result(timeout=120)
except Exception as fehler: # Bibliothek fehlt o. Ae.
print(f"{name:<26} nicht verfuegbar: {str(fehler)[:40]}")
continue
dauer = time.perf_counter() - beginn
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)
@ -115,6 +140,6 @@ if __name__ == "__main__":
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("(Die Zeiten enthalten Prozessstart und Import - sie messen NICHT die")
print(" reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)")
print("=" * 78)

View file

@ -55,7 +55,8 @@ def minimales_risiko(kovarianz: np.ndarray) -> tuple[np.ndarray, float]:
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).
nachgehen muss (siehe den Denkfehler im Kapitel Quadratische und
nichtlineare Optimierung).
"""
gewichte = cp.Variable(ANZAHL_ANLAGEN)
problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),

View file

@ -0,0 +1,195 @@
#!/usr/bin/env python3
# Parallele_Suche.py
"""
Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.
Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte
Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele
Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute
Loesungen findet. Beides wird hier gemessen statt geglaubt.
Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross
genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,
jeder Auftrag besucht jede Maschine.
Zwei Messungen:
1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.
2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt
derselbe Plan heraus?
Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.
Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen
Plaene ist es auch - und genau das ist die Aussage.
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import collections
import time
import numpy as np
from ortools.sat.python import cp_model
AUFTRAEGE = 12
MASCHINEN = 10
INSTANZ_SEED = 20260908
SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt
ARBEITERZAHLEN = (1, 2, 4, 8)
WIEDERHOLUNGEN = 4
ZEITLIMIT = 120.0
def baue_instanz() -> list[list[tuple[int, int]]]:
"""Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,
in einer eigenen zufaelligen Reihenfolge."""
rng = np.random.default_rng(INSTANZ_SEED)
return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]
for _ in range(AUFTRAEGE)]
def loese(auftraege, arbeiter: int):
"""Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan
zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe
vergleichen lassen."""
horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)
modell = cp_model.CpModel()
Gang = collections.namedtuple("Gang", "start ende intervall")
plaene: dict[tuple[int, int], Gang] = {}
je_maschine = collections.defaultdict(list)
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}")
intervall = modell.NewIntervalVar(start, dauer, ende, f"iv_{a}_{g}")
plaene[a, g] = Gang(start, ende, intervall)
je_maschine[maschine].append(intervall)
for maschine in range(MASCHINEN):
modell.AddNoOverlap(je_maschine[maschine])
for a, auftrag in enumerate(auftraege):
for g in range(len(auftrag) - 1):
modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)
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.num_workers = arbeiter
loeser.parameters.random_seed = SOLVER_SEED
loeser.parameters.max_time_in_seconds = ZEITLIMIT
beginn = time.perf_counter()
status = loeser.Solve(modell)
dauer = time.perf_counter() - beginn
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
return loeser.StatusName(status), None, dauer, None
plan = tuple(loeser.Value(plaene[a, g].start)
for a, auftrag in enumerate(auftraege)
for g in range(len(auftrag)))
return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan
if __name__ == "__main__":
auftraege = baue_instanz()
gaenge = sum(len(a) for a in auftraege)
print("=" * 78)
print(" WAS MEHRERE ARBEITER WIRKLICH BRINGEN")
print("=" * 78)
print(f"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, "
f"{gaenge} Arbeitsgaenge.")
print(f"random_seed = {SOLVER_SEED} in JEDEM Lauf, "
f"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\n")
# Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.
ergebnisse: dict[int, list[tuple]] = {}
for arbeiter in ARBEITERZAHLEN:
ergebnisse[arbeiter] = [loese(auftraege, arbeiter)
for _ in range(WIEDERHOLUNGEN)]
print("=" * 78)
print(" (1) Laufzeit: was die Arbeiter bringen")
print("=" * 78)
print(f"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} "
f"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}")
print("-" * 78)
basis_zeit = min(d for _, _, d, _ in ergebnisse[1])
for arbeiter in ARBEITERZAHLEN:
laeufe = ergebnisse[arbeiter]
zeiten = [d for _, _, d, _ in laeufe]
ziele = {o for _, o, _, _ in laeufe}
status = {s for s, _, _, _ in laeufe}
print(f"{arbeiter:>9} {'/'.join(sorted(status)):>9} "
f"{'/'.join(str(z) for z in sorted(ziele)):>9} "
f"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s "
f"{basis_zeit / min(zeiten):>14.1f}x")
beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))
faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])
print(f"\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber")
print(f" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.")
print(f" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche")
print(" laufen, sondern verschiedene Strategien nebeneinander, die einander")
print(" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der")
print(" 'zweite Kern', sondern ein anderes Verfahren.")
letzte = ARBEITERZAHLEN[-1]
if beste != letzte:
zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])
print(f"\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen "
f"{zeit_letzte:.2f}s")
print(f" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei "
f"{beste}. Ab einer gewissen Zahl kosten Abstimmung")
print(" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.")
print("\n" + "=" * 78)
print(" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?")
print("=" * 78)
print(f"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} "
f"{'Makespan':>10}")
print("-" * 78)
for arbeiter in ARBEITERZAHLEN:
plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}
ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}
print(f"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} "
f"{'/'.join(str(z) for z in sorted(ziele)):>10}")
einer = len({p for _, _, _, p in ergebnisse[1]})
# Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die
# eigentliche Zahl - nicht, was acht Arbeiter anrichten.
ab = next((w for w in ARBEITERZAHLEN
if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)
print(f"\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.")
if ab is not None:
viele = len({p for _, _, _, p in ergebnisse[ab]})
print(f" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus "
f"{WIEDERHOLUNGEN} Laeufen -")
print(" bei identischem random_seed und identischem Zielwert.")
print(f" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit")
print(f" zu verlieren. {ab} genuegen.")
print("\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt")
print(" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von")
print(" mehreren zuerst fertig wird. Das entscheidet die Uhr.")
print("\n" + "=" * 78)
print(" (3) Was daraus fuer Tests folgt")
print("=" * 78)
print(" Ein Test der Form assert plan == erwarteter_plan besteht mal und")
print(" scheitert mal, ohne dass sich am Code etwas geaendert haette.")
print(" Zu pruefen sind stattdessen:")
print(" * der ZIELWERT (hier in allen Laeufen gleich),")
print(" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),")
print(" * und der Status - nicht die Gestalt der Loesung.")
print("\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte")
print(" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.")
print(f" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau")
print(" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.")
print("=" * 78)

View file

@ -36,9 +36,9 @@ Benoetigt: numpy, pydantic, ortools, highspy (jeweils im eigenen Prozess)
from __future__ import annotations
import subprocess
import sys
import multiprocessing
import time
from concurrent.futures import ProcessPoolExecutor
import numpy as np
from pydantic import BaseModel, Field, model_validator
@ -241,25 +241,29 @@ def geoeffnete_lager(problem: Standortproblem, loesung: Loesung) -> list[str]:
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])
def loese_in_eigenem_prozess(name: str, problem: Standortproblem) -> Loesung:
"""Laesst genau einen Modellbauer in einem frischen Prozess rechnen.
'spawn' statt des Linux-Standards 'fork': Der Kindprozess startet mit
einem leeren Interpreter und importiert nur den Solver, den SEIN
Modellbauer braucht. max_tasks_per_child=1 sorgt dafuer, dass der Pool
seinen Arbeiter nicht wiederverwendet - sonst saessen beim zweiten Aufruf
wieder beide Bibliotheken im selben Prozess.
Hin und zurueck wandert das Domaenenmodell bzw. das Loesungs-DTO. Beide
kennen keinen Solver, sind also serialisierbar - genau dafuer sind sie da.
"""
with ProcessPoolExecutor(
max_workers=1,
mp_context=multiprocessing.get_context("spawn"),
max_tasks_per_child=1) as pool:
return pool.submit(MODELLBAUER[name], problem).result(timeout=300)
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 -------------
# --- Beide Solver anstossen und vergleichen ---------------------------
print("=" * 82)
print(" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE")
print("=" * 82)
@ -271,7 +275,7 @@ if __name__ == "__main__":
loesungen: dict[str, Loesung] = {}
for name, beschriftung in [("cpsat", "OR-Tools CP-SAT"),
("highs", "HiGHS (highspy)")]:
loesung = loesungen[name] = loese_in_eigenem_prozess(name)
loesung = loesungen[name] = loese_in_eigenem_prozess(name, problem)
beanstandungen = pruefe_zuordnung(problem, loesung)
print(f"{beschriftung}")

View file

@ -83,7 +83,8 @@ def loese(dauer: np.ndarray, n_maschinen: int, zeitlimit: float,
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = zeitlimit
# Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.
# Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist -
# der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).
# Im Produktivbetrieb laesst man beides auf den Standardwerten.
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -334,6 +334,134 @@ können Sie gezielt springen.
---
## Für wen dieses Buch geschrieben ist {-}
Die Lernpfade weiter unten sind nach **Themen** geschnitten. Dieser Abschnitt ist nach
**Rollen** geschnitten: vier Einstiege für vier Arten, mit Optimierung zu tun zu haben.
Jeder nennt den Schmerzpunkt, drei Dinge, die das Buch dafür wirklich liefert — mit den
Zahlen, die es dazu misst —, den passenden Lernpfad und die Frage, an der es in dieser
Rolle am häufigsten scheitert.
Suchen Sie sich einen aus. Sie können auch alle vier überspringen; nötig sind sie nicht.
### Für Entscheider und Manager — der Geschäftsfall {-}
> *„Wir planen seit Jahren mit Erfahrungswerten, und es funktioniert. Warum sollte ein
> Modell das besser können — und was kostet mich der Irrtum, wenn ich es nicht tue?“*
* **Was Planen mit Durchschnitten kostet, wird hier beziffert, nicht behauptet.** Im
Kraftwerkseinsatz aus {ref:kap:supplychain} kostet ein Plan, der mit dem
Wind-**Erwartungswert** rechnet, in der Wirklichkeit **149 % mehr** als einer, der
40 Szenarien berücksichtigt — 874 870 € statt 351 356 € — und führt in **28 von
40 Szenarien** zum Lastabwurf statt in keinem.
* **„Wie sicher soll es sein?“ wird eine Preisfrage.**
{ref:sec:unsicherheit-chance-constraints} rechnet aus, was ein Prozentpunkt
Versorgungssicherheit kostet: rund 56 000 € auf dem Weg zu 80 %, rund 254 000 € zwischen
95 und 99 %. Das **4,5-fache**. Wer 99,9 % fordert, ohne diese Kurve gesehen zu haben,
fordert ins Blaue.
* **Für die Sitzung gibt es einen Bericht, keine Tabelle.**
{ref:sec:praxisfallen-attribution} beziffert je Nebenbedingung, was sie den Plan kostet,
und formuliert daraus Sätze in Alltagssprache. Für die Frage des Betriebsrats — *„warum
ausgerechnet ich?“* — liefert {ref:kap:praxisfallen} den Constraint-Trace, der die
einzelne Zuweisung begründet.
**Ihr Weg.** Zuerst Pfad E (24 Stunden): {ref:sec:einfuehrung-historischer-kontext-und-evolution}
und {ref:anhang:modellierungsmuster}. Wenn Sie danach wissen wollen, was in Ihrem Betrieb
geht, Pfad B und Projekt **P1** oder **P2**.
**Woran es scheitert.** Nicht am Modell, sondern an der Einführung — das ist der
meistgenannte Befund in {ref:kap:praxisfallen}. Und an einer Verwechslung, die dieses Buch
Ihnen nicht abnehmen kann: Ein Bericht erklärt das **Modell**, nicht die **Wirklichkeit**.
Ist ein Deckungsbeitrag falsch geschätzt, ist er überzeugend **und** falsch. Fragen Sie
deshalb jedes Mal, worauf eine Zahl beruht.
> **Eine Erwartung, die dieses Buch enttäuscht:** Es nennt keine ROI-Kennzahl und keine
> Amortisationsdauer. Beide hängen an Ihrem Betrieb, und jede Zahl hier wäre erfunden.
> Was es stattdessen liefert, ist das Werkzeug, Ihre eigene auszurechnen: den **EVPI** in
> {ref:kap:unsicherheit} (was eine perfekte Prognose überhaupt wert wäre — eine
> Obergrenze für jedes Prognoseprojekt) und die Kostenzurechnung aus
> {ref:sec:praxisfallen-attribution}.
### Für Ingenieure und Produktionsplaner — die Physik des Systems {-}
> *„Der Engpass wandert. Kaum ist eine Maschine entlastet, steht die nächste — und niemand
> kann mir vorher sagen, welche.“*
* **Der Engpass lässt sich benennen und bepreisen.**
{ref:sec:praxisfallen-attribution} rechnet für jede Kapazität aus, was die nächste Stunde
wert ist — und wie weit dieser Wert trägt. Im Beispiel gilt der Schattenpreis der
Lackiererei exakt bis **+15 Stunden**; eine ganze Sonderschicht von 30 Stunden bringt
deshalb 60 € statt der rechnerischen 120 €. **Die zweite Hälfte wäre bezahlt und
wirkungslos.**
* **Rüstzeiten, Reihenfolgen, Taktzeiten sind der Kern, nicht die Ausnahme.**
{ref:kap:cpsat} modelliert Maschinenbelegung mit Intervallvariablen,
{ref:kap:metaheuristiken} zeigt am selben Fall das Gesamtbild: Faustregel 2 497 Minuten
Rüstzeit, CP-SAT allein 2 628, Simulated Annealing 2 343, Annealing plus LNS **2 289**
und die untere Schranke bei 1 768, damit sichtbar bleibt, wie viel Luft noch da ist.
* **Wenn der Solver zu lange braucht, gibt es Zahlen statt Ratschläge.**
{ref:sec:cpsat-parallele-suche} misst, was mehrere Suchstränge bringen (hier Faktor 12,3)
— und ab wo mehr Arbeiter wieder schaden.
**Ihr Weg.** Pfad B bis {ref:kap:cpsat}, dann Projekt **P5** (Produktionsplanung mit
Rüstkosten). Wenn Ihre echten Instanzen den Solver stehen lassen: {ref:kap:metaheuristiken}
und Projekt **P9**.
**Woran es scheitert.** An der Verwechslung von *bindend* und *teuer*. Eine Bedingung kann
den Plan berühren und trotzdem **nichts** kosten — im Beispiel von
{ref:sec:praxisfallen-attribution} gilt das für eine von fünf bindenden. Wer sie
nachverhandelt, gewinnt nichts und hält das Verfahren für nutzlos.
### Für Investoren und Finanzanalysten — Risiko und Ertrag {-}
> *„Die Optimierung liefert mir Gewichte, die kein Mensch halten würde: 60 % in einem
> Titel, null in fünf anderen. Und im nächsten Quartal sieht sie völlig anders aus.“*
* **Der Grund dafür steht in {ref:kap:finanzdaten}, nicht in {ref:kap:markowitz}.** Eine
aus kurzen Zeitreihen geschätzte Kovarianzmatrix ist schlecht konditioniert; der
Optimierer setzt dann auf Schätzrauschen. Shrinkage nach Ledoit-Wolf ist das Gegenmittel;
ihre Wirkung wird an **Eigenwertspektrum und Konditionszahl** gemessen, nicht behauptet.
* **Der VaR verschweigt genau das, worauf es ankommt.** In der Stichprobe aus
{ref:kap:cvar} sagt der VaR: „an 95 % der Tage höchstens **1,86 %** Verlust“. Der CVaR
sagt: „und wenn doch, dann im Mittel **2,99 %**“. Der schlechteste Tag der Stichprobe
liegt bei **23,0 %** — am VaR ändert dieser eine Tag **nichts**, am CVaR sehr wohl.
Deshalb ist der CVaR die Größe, die man optimiert.
* **Transaktionskosten und Rebalancing sind Teil des Modells, nicht ein Abzug danach.**
{ref:kap:cvar} nimmt sie in die Zielfunktion, {ref:kap:handelsmaschine} setzt den
Backtest daneben — samt der Fallen, die einen guten Backtest wertlos machen.
**Ihr Weg.** Pfad D, und zwar **mit** {ref:kap:finanzdaten}. Danach Projekt **P6**
(Portfolio-Rebalancer) oder **P7** (Risikoreport mit CVaR und Stresstests).
**Woran es scheitert.** An der Reihenfolge. Wer bei Markowitz einsteigt und die
Datenkapitel überspringt, bekommt ein Modell, das seine eigenen Schätzfehler maximiert —
und merkt es erst im Echtgeldbetrieb.
### Für Studierende und Data Scientists — die Methodik {-}
> *„Ich kann ein Modell trainieren und den Fehler senken. Aber wie wird aus einer Prognose
> eine Entscheidung — und woher weiß ich, dass sie gut ist?“*
* **Die unbequemste Erkenntnis zuerst.** {ref:kap:prognose} zeigt an einem gerechneten
Fall, dass das Modell mit dem **schlechteren** MSE die **günstigere** Entscheidung
trifft. Prognosegüte und Entscheidungsqualität sind zwei verschiedene Größen; wer die
eine optimiert, bekommt die andere nicht geschenkt.
* **Dieselbe Mathematik, zwei Welten.** {ref:kap:bruecke} lässt **denselben Code** einmal
über eine Werkstatt und einmal über ein Depot laufen — und benennt die drei Stellen, an
denen die Analogie endet. Struktur zu erkennen ist die eigentliche Fähigkeit, nicht
Bibliothekskenntnis; zu wissen, wo eine Analogie aufhört, gehört dazu.
* **Es wird gerechnet, nicht nacherzählt.** Zu jedem Kapitel gehören Aufgaben mit
vollständigen Lösungen, Handrechnungen und ein „Finde den Denkfehler“ — und jedes
abgedruckte Ergebnis stammt aus einem echten Lauf des danebenstehenden Programms.
**Ihr Weg.** Pfad A, wenn Sie Zeit haben. Sonst Pfad B oder D bis zum ersten Projekt und
von dort zurück in die Grundlagen. Für eigene Fragestellungen: Projekt **P8**.
**Woran es scheitert.** An der Annahme, ein Solver sei eine Bibliothek wie jede andere.
Die schwierige Arbeit steckt in der Formulierung — {ref:kap:modellierung} ist deshalb kein
Vorgeplänkel, sondern das Kapitel, auf das alle anderen aufbauen.
---
## Lernpfade {-}
Sie müssen nicht alles lesen, um etwas Nützliches bauen zu können.
@ -376,7 +504,8 @@ bei Markowitz einsteigt, optimiert Schätzrauschen und wundert sich über absurd
### Pfad E — Ich habe morgen ein konkretes Problem (24 Stunden) {-}
Lesen Sie {ref:sec:einfuehrung-historischer-kontext-und-evolution} (die vier Bausteine), dann {ref:anhang:modellierungsmuster}
Lesen Sie {ref:sec:einfuehrung-historischer-kontext-und-evolution} (die vier Bausteine) und
{ref:sec:modellierung-glossar} (welcher Satz welcher Baustein ist), dann {ref:anhang:modellierungsmuster}
([Modellierungsmuster](91_Anhang_Modellierungsmuster.md)) und suchen Sie dort das Muster,
das zu Ihrem Problem passt. Von jedem Muster führt ein Verweis in das zuständige Kapitel.
Wie die gewählte Bibliothek es schreibt, steht in {ref:anhang:spickzettel}
@ -431,6 +560,7 @@ Pfad E oder um gezielt nach einem Thema zu suchen:
| `Propagation_Demo.py` | Propagation messbar | {ref:kap:cpsat} |
| `CP_SAT_Vertretungssystem.py` | Vertretungsplan | {ref:kap:cpsat} |
| `JobShop_Intervalle.py` | Job-Shop-Scheduling | {ref:kap:cpsat} |
| `Parallele_Suche.py` | num_workers: Tempo gegen Reproduzierbarkeit | {ref:kap:cpsat} |
| `CP_SAT_Statusfaelle.py` | Die fünf Solver-Antworten | {ref:kap:cpsat} |
| `Strafgewichte.py` | Gewichte als Wechselkurse | {ref:kap:cpsat} |
| `Min_Cost_Flow.py` | Netzwerkfluss | {ref:kap:graphen} |
@ -470,6 +600,7 @@ Pfad E oder um gezielt nach einem Thema zu suchen:
| `Data_Snooping.py` | Bestes aus N Versuchen auf Rauschen | {ref:kap:handelsmaschine} |
| `Infeasibility_Diagnose.py` | Notfallplan statt Fehler | {ref:kap:praxisfallen} |
| `Erklaerbarkeit.py` | Constraint-Trace, Was-wäre-wenn | {ref:kap:praxisfallen} |
| `Constraint_Attribution.py` | Was kostet welche Bedingung? Managementbericht | {ref:kap:praxisfallen} |
| `Betriebsueberwachung.py` | Status, Gap und Zeitausschöpfung | {ref:kap:praxisfallen} |
| `or_kern.py` | Gemeinsamer Unterbau: Domäne, Status, Prüfung | {ref:kap:praxisfallen} |
| `Solverwechsel_CPSAT_HiGHS.py` | Derselbe Fall in zwei Solvern | {ref:kap:praxisfallen} |

View file

@ -528,7 +528,8 @@ ausfüllen können — vor der ersten Codezeile.
> $4x_1 + 3x_2 \le 600$ (Ofen); $x_1 \ge 40$ (Vertrag).
> * **Weiche Bedingungen:** keine.
>
> Dieses Problem lösen Sie in Übung 1.5 selbst.
> Dieses Problem lösen Sie in der Aufgabe *Die Bäckerei programmieren*
> ({ref:sec:einfuehrung-uebungsaufgaben}) selbst.
Die Vorlage lässt sich fast wörtlich in Code übersetzen — wer die fünf Zeilen ausgefüllt
hat, hat den größten Teil der Modellierungsarbeit schon erledigt:
@ -1142,7 +1143,7 @@ Ein Programm, das abstürzt, ist harmlos — Sie sehen sofort, dass etwas nicht
Gefährlich sind Programme, die fehlerfrei durchlaufen, einen Status `OPTIMAL` melden und
eine Zahl liefern, die betriebswirtschaftlich Unsinn ist. Genau solche Fälle üben wir hier.
> **🐛 Finde den Denkfehler 1.1: Die Schreinerei verdoppelt ihren Gewinn**
> **🐛 Finde den Denkfehler: Die Schreinerei verdoppelt ihren Gewinn**
>
> Eine Kollegin hat das Schreinerei-Modell aus dem Kapitelanfang leicht umgebaut — sie
> fand die Schleife übersichtlicher als zwei einzelne `Add`-Aufrufe. Das Programm läuft,
@ -1191,7 +1192,7 @@ eine Zahl liefern, die betriebswirtschaftlich Unsinn ist. Genau solche Fälle ü
## Micro-Quiz {#sec:einfuehrung-quiz}
> **❓ Micro-Quiz 1: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-einfuehrung).

View file

@ -1243,7 +1243,7 @@ Prüfen Sie das Ergebnis und vergleichen Sie es mit dem Original.
## Finde den Denkfehler {#sec:fundament-denkfehler}
> **🐛 Finde den Denkfehler 2.1: Die unauffällige Transposition**
> **🐛 Finde den Denkfehler: Die unauffällige Transposition**
>
> Ein Kollege überträgt ein Produktionsmodell in Matrixform. Zwei Produkte, zwei
> Ressourcen — die Technologiematrix ist quadratisch:
@ -1288,7 +1288,7 @@ Prüfen Sie das Ergebnis und vergleichen Sie es mit dem Original.
## Micro-Quiz {#sec:fundament-quiz}
> **❓ Micro-Quiz 2: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-fundament).

View file

@ -308,6 +308,34 @@ $$
> prüft `highspy` und `cvxpy` in der Paketübersicht nur auf Anwesenheit
> (`importlib.util.find_spec`) und importiert CVXPY erst im Funktionstest.
### Wie die Isolation aussieht, wenn sie tragen soll
„Eigener Prozess" ist schnell gesagt. Die naheliegende Umsetzung — ein Codeschnipsel als
Zeichenkette an `python -c` übergeben — funktioniert und ist trotzdem die schlechteste:
Der Schnipsel ist für Editor, Linter und Testwerkzeug unsichtbar, ein Tippfehler darin
fällt erst zur Laufzeit auf, und übergeben lassen sich nur Zeichenketten.
Tragfähig ist stattdessen: **jeder Solver eine gewöhnliche Funktion mit lokalem Import**,
ausgeführt von einem `ProcessPoolExecutor` mit zwei Einstellungen, die zusammen die
Garantie ergeben:
| Einstellung | Wozu |
| --- | --- |
| `mp_context=multiprocessing.get_context("spawn")` | Der Kindprozess startet mit einem **frischen** Interpreter, statt den Speicher des Elternprozesses zu erben. Unter Linux ist `fork` der Standard — und damit wäre alles, was hier schon importiert ist, auch dort importiert. |
| `max_tasks_per_child=1` | Jede Aufgabe bekommt einen **neuen** Prozess. Ohne das verwendet der Pool seinen Arbeiter wieder, und beim zweiten Solver ist der Konflikt zurück. Genau dieser Fehler ist leicht zu machen und schwer zu finden. |
> **⚠️ `max_tasks_per_child=1` ist nicht optional**
> Ein Pool ohne diese Angabe ist der **Normalfall** — er soll seine Arbeiter ja
> wiederverwenden. Wer die Isolation über einen Pool herstellt und das vergisst, hat einen
> Prozesswechsel programmiert, aber keine Isolation gewonnen: Die zweite Aufgabe landet im
> selben Interpreter wie die erste. Der Absturz kommt dann nicht beim ersten Solver,
> sondern beim zweiten — und sieht aus wie ein Problem des zweiten.
Denselben Aufbau verwenden `Solverwechsel_CPSAT_HiGHS.py` ({ref:kap:praxisfallen}) und
`Benchmark_Skalierung.py` ({ref:kap:testing}). Dort wandern zusätzlich **Datenobjekte** über
die Prozessgrenze statt Zeichenketten — möglich, weil Domänenmodell und Lösungs-DTO keinen
Solver kennen ({ref:sec:praxisfallen-or-kern}).
```python
#!/usr/bin/env python3
@ -319,85 +347,104 @@ Kapitel Oekosystem: Dasselbe LP in vier Bibliotheken.
2*x1 + 3*x2 + x3 <= 50
x >= 0
Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich am Ende.
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
WICHTIG: Jeder Solver laeuft in einem EIGENEN Prozess, weil sich ortools und
highspy auf vielen Systemen nicht gemeinsam importieren lassen (beide bringen
eine eigene HiGHS-Kopie mit -> Symbolkonflikt).
Die Isolation besorgt ein ProcessPoolExecutor. Drei Einstellungen ergeben
zusammen die Garantie:
mp_context "spawn" Der Kindprozess startet mit einem FRISCHEN
Interpreter, statt den Speicher des Elternprozesses
zu erben. Was hier schon importiert ist, ist dort
nicht importiert. Mit dem Standard "fork" auf Linux
waere das nicht so.
max_tasks_per_child=1 Jede Aufgabe bekommt einen NEUEN Prozess. Ohne das
wuerde der Pool seinen Arbeiter wiederverwenden - und
beim zweiten Solver waere der Konflikt zurueck.
max_workers=1 Haelt die vier Laeufe nacheinander. Nicht aus
Vorsicht, sondern damit die gemessenen Zeiten
vergleichbar bleiben.
Jeder Solver steht in einer eigenen Funktion mit LOKALEM Import. Das ist der
Unterschied zu einem Codestring, den man an 'python -c' uebergibt: Die
Funktion laesst sich einzeln aufrufen, testen und vom Editor pruefen - ein
String nicht.
Benoetigt: scipy, highspy, cvxpy, ortools
"""
import json
import subprocess
import sys
import textwrap
import multiprocessing
import time
from concurrent.futures import ProcessPoolExecutor
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] = {
# Die Instanz - einmal notiert, von allen vier Funktionen benutzt.
ZIEL = [10.0, 15.0, 25.0]
MATRIX = [[1, 1, 2], [2, 3, 1]]
KAPAZITAET = [40.0, 50.0]
"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]))
""",
def loese_mit_scipy() -> tuple[float, list[float]]:
from scipy.optimize import linprog
ergebnis = linprog(c=[-w for w in ZIEL], # linprog MINIMIERT -> negieren
A_ub=MATRIX, b_ub=KAPAZITAET,
bounds=[(0, None)] * 3, method="highs")
return -ergebnis.fun, list(ergebnis.x)
"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 loese_mit_highspy() -> tuple[float, list[float]]:
import highspy
import numpy as np
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(ZIEL):
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(KAPAZITAET), 6,
np.array([0, 3], dtype=np.int32),
np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),
np.array([float(w) for zeile in MATRIX for w in zeile]))
h.run()
return (h.getInfo().objective_function_value,
list(h.getSolution().col_value[:3]))
def loese_mit_cvxpy() -> tuple[float, list[float]]:
import cvxpy as cp
import numpy as np
x = cp.Variable(3, nonneg=True)
problem = cp.Problem(cp.Maximize(np.array(ZIEL) @ x),
[np.array(MATRIX) @ x <= np.array(KAPAZITAET)])
problem.solve()
return float(problem.value), [float(v) for v in x.value]
def loese_mit_ortools() -> tuple[float, list[float]]:
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)]
for i, kapazitaet in enumerate(KAPAZITAET):
s.Add(sum(MATRIX[i][j] * x[j] for j in range(3)) <= kapazitaet)
s.Maximize(sum(ZIEL[j] * x[j] for j in range(3)))
s.Solve()
return s.Objective().Value(), [v.solution_value() for v in x]
ANSAETZE = {
"scipy.optimize.linprog": loese_mit_scipy,
"highspy (natives HiGHS)": loese_mit_highspy,
"cvxpy": loese_mit_cvxpy,
"ortools / GLOP": loese_mit_ortools,
}
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)")
@ -406,17 +453,23 @@ if __name__ == "__main__":
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")
# Ein Pool, vier Aufgaben, vier frische Prozesse. Der Kontext muss
# "spawn" sein - siehe Modulkommentar.
with ProcessPoolExecutor(
max_workers=1,
mp_context=multiprocessing.get_context("spawn"),
max_tasks_per_child=1) as pool:
for name, funktion in ANSAETZE.items():
beginn = time.perf_counter()
try:
wert, x = pool.submit(funktion).result(timeout=120)
except Exception as fehler: # Bibliothek fehlt o. Ae.
print(f"{name:<26} nicht verfuegbar: {str(fehler)[:40]}")
continue
dauer = time.perf_counter() - beginn
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)
@ -426,8 +479,8 @@ if __name__ == "__main__":
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("(Die Zeiten enthalten Prozessstart und Import - sie messen NICHT die")
print(" reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)")
print("=" * 78)
```
@ -439,16 +492,16 @@ if __name__ == "__main__":
==============================================================================
Bibliothek Z* x1 x2 x3 Zeit
------------------------------------------------------------------------------
scipy.optimize.linprog 530.00 0.00 12.00 14.00 0.55 s
highspy (natives HiGHS) 530.00 0.00 12.00 14.00 0.17 s
cvxpy 530.00 0.00 12.00 14.00 1.52 s
ortools / GLOP 530.00 0.00 12.00 14.00 0.09 s
scipy.optimize.linprog 530.00 0.00 12.00 14.00 0.59 s
highspy (natives HiGHS) 530.00 0.00 12.00 14.00 0.12 s
cvxpy 530.00 0.00 12.00 14.00 1.24 s
ortools / GLOP 530.00 0.00 12.00 14.00 0.33 s
------------------------------------------------------------------------------
Spannweite zwischen den Bibliotheken: 2.41e-08
Abweichung zur Handrechnung (530): 0.00e+00
Alle Wege fuehren zum selben, von Hand bestaetigten Optimum.
(Die Zeiten enthalten den Prozessstart und den Import - sie messen
NICHT die reine Solverleistung, siehe Uebung 3.5.)
(Die Zeiten enthalten Prozessstart und Import - sie messen NICHT die
reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)
==============================================================================
```
@ -1162,7 +1215,7 @@ passende Empfehlungen.
## Finde den Denkfehler {#sec:oekosystem-denkfehler}
> **🐛 Finde den Denkfehler 3.1: Der Solver, der angeblich dreimal schneller ist**
> **🐛 Finde den Denkfehler: Der Solver, der angeblich dreimal schneller ist**
>
> Ein Team vergleicht zwei Bibliotheken für sein Zuordnungsproblem und schreibt ins
> Protokoll: *„CVXPY braucht 1,9 s, SciPy nur 0,6 s — wir setzen auf SciPy.“* Gemessen
@ -1199,7 +1252,7 @@ passende Empfehlungen.
## Micro-Quiz {#sec:oekosystem-quiz}
> **❓ Micro-Quiz 3: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-oekosystem).
@ -1264,4 +1317,4 @@ Schattenpreise kommen — und was sie wirtschaftlich bedeuten.
---
*Weiter mit:* [{ref:kap:lp} — Lineare Programmierung](20_Lineare_Programmierung.md)
*Weiter mit:* [{ref:kap:modellierung} — Vom Management-Wunsch zum Modell](13_Vom_Wunsch_zum_Modell.md)

View file

@ -99,6 +99,9 @@ Nach diesem Kapitel können Sie …
4. … begründen, wann eine Regel hart und wann sie weich sein muss — und was eine harte Regel
im Betrieb anrichtet, wenn sie einmal nicht erfüllbar ist.
5. … einen Kennzahlen-Fehlanreiz benennen, bevor er in die Zielfunktion gerät.
6. … einen Satz aus einer Besprechung in den passenden Modellierungsbaustein
übersetzen — und die fünf Wendungen erkennen, bei denen genau das nicht geht,
ohne vorher nachzufragen.
---
@ -614,6 +617,75 @@ stellt, bevor eine Zeile Code entsteht:
---
## Deutsch — OR: ein Übersetzungslexikon {#sec:modellierung-glossar}
Die zwölf Fragen liefern Sätze. Sätze sind noch kein Modell — aber die meisten von ihnen
haben eine **Standardübersetzung**, und wer sie kennt, spart sich das Nachdenken für die
Fälle, in denen es keine gibt.
Die Tabelle liest sich von links nach rechts: Was in der Besprechung **gesagt** wird, was
es **bedeutet**, und welcher **Baustein** daraus wird. Die B-Nummern verweisen auf
{ref:anhang:modellierungsmuster}, wo jedes Muster mit Formulierung und Code steht.
| Was gesagt wird | Was gemeint ist | Baustein |
| --- | --- | --- |
| „Wir haben nur 400 Stunden." | eine Obergrenze, die nicht überschritten werden darf | harte Kapazitätsgrenze $\le b$ ({ref:kap:lp}) |
| „Mehr als zwei Millionen gibt es nicht." | dasselbe, auf Geld | Budgetlimit (B27) |
| „Möglichst nicht über 40 Stunden." | eine Grenze, die im Notfall gebrochen werden darf | weiche Grenze mit Strafkosten (B8) |
| „Wir können nur ganze Kisten liefern." | die Menge ist nicht teilbar | Ganzzahligkeitsbedingung ({ref:kap:milp}) |
| „Wenn wir die Anlage nutzen, fallen Fixkosten an." | Kosten, die erst ab der ersten Einheit anfallen | Aktivierungsschalter (B1) |
| „Entweder gar nicht oder mindestens 50." | kein Betrieb im Kleinen | semikontinuierliche Variable (B2) |
| „Wenn wir A bauen, brauchen wir auch B." | eine Folgeverpflichtung | Implikation (B3) |
| „Höchstens drei Positionen im Depot." | eine Obergrenze auf die **Anzahl**, nicht die Menge | Kardinalität (B6) |
| „Der Anteil muss mindestens 30 % betragen." | eine Bedingung auf ein **Verhältnis** | Verhältnis-Bedingung (B12) |
| „Jeder Auftrag genau einem Mitarbeiter." | eine vollständige, eindeutige Zuordnung | Zuordnung 1:1 (B23) |
| „Eine Maschine kann nur eines gleichzeitig." | zeitliche Ausschließlichkeit | Nichtüberlappung (B14) |
| „B kann erst nach A." | eine Reihenfolge | Vorrangbeziehung (B13) |
| „Höchstens fünf Tage am Stück." | eine Bedingung über ein **gleitendes Fenster** | gleitendes Fenster (B16) |
| „Umstellen kostet uns eine halbe Stunde." | reihenfolgeabhängige Kosten | Umrüstkosten (B17), Rüstzeit als Kapazität (B26) |
| „Was reinkommt, muss auch wieder raus." | Erhaltung an jedem Knoten | Flusserhaltung (B22) |
| „Notfalls brechen wir die Regel." | die Bedingung ist weich, aber teuer | Schlupfvariablen (B18) |
| „Erst Termintreue, dann Kosten." | eine Rangfolge zwischen Zielen | hierarchische Ziele (B19) |
| „Es soll gerecht zugehen." | den Schlechtestgestellten verbessern | Min/Max in der Zielfunktion (B11) |
| „Was brächte uns eine zusätzliche Stunde?" | die Frage nach dem Grenzwert einer Ressource | Schattenpreis ({ref:kap:lp}, {ref:sec:praxisfallen-attribution}) |
| „Mit 95 % Sicherheit muss es halten." | eine Zusage auf eine **Quote**, nicht auf einen Mittelwert | Chance Constraint ({ref:sec:unsicherheit-chance-constraints}) |
| „Wir sichern uns gegen Schätzfehler ab." | gegen die ungünstigste Parameterlage planen | Worst-Case-Abzug (B21) |
### Fünf Wendungen, bei denen die Übersetzung eine Entscheidung erzwingt
Die Tabelle oben tut so, als sei die Zuordnung eindeutig. Bei den folgenden fünf ist sie es
nicht — und **das Nachfragen ist die eigentliche Modellierungsarbeit**:
| Wendung | Die Frage, die dahinter steckt |
| --- | --- |
| **„möglichst"** | Hart oder weich? Und wenn weich: Was darf ein Verstoß kosten? Ohne Zahl ist „möglichst" keine Bedingung, sondern eine Stimmung. |
| **„nicht mehr als drei pro Woche"** | Je Person oder über alle zusammen? Kalenderwoche oder gleitende sieben Tage? Beide Lesarten sind vertretbar und ergeben verschiedene Modelle. |
| **„im Durchschnitt"** | Fast immer eine Falle. Der Durchschnittskunde kauft nie — siehe {ref:sec:unsicherheit-der-fluch-des-durchschnitts}. Gemeint ist meist ein Quantil („in 9 von 10 Fällen"). |
| **„so schnell wie möglich"** | Welche Zahl? Ende des letzten Auftrags, Summe aller Fertigstellungszeiten oder größte Verspätung? Drei Zielfunktionen, drei verschiedene Pläne. |
| **„fair"** | Den Schlechtesten verbessern, die Spannweite verkleinern oder gleiche Anteile erzwingen? Erst die Wahl macht daraus ein Modell — und sie gehört dem Betrieb, nicht dem Modellierer. |
> **🎯 Der Nutzen dieser Liste**
> Sie ersetzt kein Nachdenken. Sie sorgt dafür, dass das Nachdenken an den **richtigen** vier
> oder fünf Stellen stattfindet, statt an allen zwanzig gleichzeitig.
### Und die Gegenrichtung: OR — Deutsch
Dieselbe Barriere gibt es rückwärts. Diese Sätze haben sich bewährt, wenn ein Ergebnis
jemandem erklärt werden muss, der das Modell nicht kennt:
| Fachbegriff | Was Sie in der Besprechung sagen |
| --- | --- |
| Schattenpreis / Dualwert | „Die nächste Stunde auf dieser Maschine wäre uns 9 € wert." |
| bindende Bedingung mit Dualwert 0 | „Die Regel berührt den Plan, kostet uns aber nichts — dort zu verhandeln bringt nichts." |
| `INFEASIBLE` | „Ihre Regeln widersprechen sich." **Nicht:** „Der Computer schafft es nicht." |
| Optimalitätslücke von 2 % | „Besser als das hier geht es höchstens um 2 % — und die liegen unter unserer Datenungenauigkeit." |
| CVaR | „Wenn es schiefgeht, verlieren wir im Mittel so viel." (Der VaR sagt nur, **ob** es schiefgeht.) |
| EVPI | „Selbst eine perfekte Prognose wäre uns höchstens so viel wert." |
| Relaxation | „Wir haben die Regel probeweise gelockert, um zu sehen, was sie kostet." |
| Chance Constraint | „Der Plan hält in 95 von 100 Fällen — und der nächste Prozentpunkt kostet …" |
---
## Übungsaufgaben {#sec:modellierung-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-modellierung}.
@ -637,6 +709,16 @@ Maximieren Sie $\text{DB} + \lambda \cdot \text{Umsatz}$ für $\lambda \in \{0;
und tragen Sie die Ergebnisse als Tabelle auf. Ab welchem $\lambda$ kippt der Plan von der
DB- in die Umsatzlösung? Was sagt dieser Wert dem Betrieb?
**Aufgabe ⭐⭐ — Übersetzen, wo es nicht eindeutig ist.**
Übersetzen Sie mit {ref:sec:modellierung-glossar}:
(a) „Die Monteure sollen möglichst gleichmäßig ausgelastet sein." Nennen Sie **drei**
Zielfunktionen, die alle „gleichmäßig" bedeuten können, und je einen Fall, in dem die
Wahl den Plan sichtbar ändert.
(b) „Kein Kunde darf länger als drei Tage warten." Formulieren Sie die Bedingung einmal
hart und einmal weich. Welche Frage müssen Sie dem Betrieb stellen, um zu entscheiden?
(c) Suchen Sie in einer echten Anforderung aus Ihrem Umfeld einen Satz, der nach der
Tabelle eindeutig aussieht, es aber nicht ist.
**Aufgabe ⭐⭐⭐ — Die Anforderung schreiben.**
Formulieren Sie für den Fall dieses Kapitels eine vollständige, prüfbare Anforderung: Ziel,
harte Regeln, weiche Regeln mit Strafgewichten, Verhalten bei Unlösbarkeit, auszuweisende
@ -720,6 +802,14 @@ Kennzahlen. Zwei Seiten genügen — sie sind mehr wert als zwei Wochen Programm
* **Hart ist nur, was rechtlich oder physikalisch unmöglich ist.** Eine harte Regel ist eine
weiche mit hinreichend großer Strafe; der Unterschied zeigt sich erst, wenn sie einmal
nicht erfüllbar ist — dann liefert die harte Variante gar nichts mehr.
* **Die meisten Sätze haben eine Standardübersetzung** — das Lexikon in
{ref:sec:modellierung-glossar} nennt sie. Sein Wert liegt aber in den fünf Wendungen, bei
denen es keine gibt: „möglichst", „nicht mehr als", „im Durchschnitt", „so schnell wie
möglich", „fair". Dort ist Nachfragen die Modellierungsarbeit.
* Die vier Fragen — worüber wird entschieden, woran gemessen, was ist unverhandelbar, was
bei Unlösbarkeit — gehören in die erste Besprechung. Der Leitfaden in
{ref:sec:modellierung-leitfaden} macht zwölf daraus.
---
*Weiter mit:* [Synthese Teil I](19_Synthese_Grundlagen.md)

View file

@ -0,0 +1,44 @@
# Synthese Teil I — Grundlagen auf einen Blick
Vier Kapitel, eine Frage: **Was muss vorliegen, bevor ein Solver überhaupt sinnvoll
gestartet werden kann?** Die Antwort ist unbequem, weil das meiste davon nichts mit
Programmieren zu tun hat.
## Was welches Kapitel klärt
| Frage | Kapitel | Was Sie danach können |
| --- | --- | --- |
| Was ist überhaupt ein Optimierungsmodell? | {ref:kap:einfuehrung} | Die vier Bausteine benennen — Variablen, Zielfunktion, Nebenbedingungen, Daten — und sie in einem fremden Problem wiedererkennen |
| Warum rechnet der Solver falsch, obwohl das Modell stimmt? | {ref:kap:fundament} | Konvexität beurteilen, den zulässigen Bereich als Polyeder sehen, schlechte Konditionierung erkennen und beheben |
| Welche Bibliothek, und warum vertragen sie sich nicht? | {ref:kap:oekosystem} | Solver, Bindings und Modellierungsschichten unterscheiden — und den HiGHS-Symbolkonflikt zwischen `ortools` und `highspy` umgehen |
| Wie wird aus einem Satz ein Modell? | {ref:kap:modellierung} | Die vier Fragen stellen, harte von weichen Regeln trennen, einen Besprechungssatz übersetzen ({ref:sec:modellierung-glossar}) |
## Die Reihenfolge ist die Aussage
Dieser Teil steht bewusst **vor** den Verfahren. Die drei häufigsten Gründe, aus denen ein
OR-Projekt scheitert, liegen alle hier — und keiner davon ist ein Solverproblem:
1. **Die falsche Zielgröße.** Sie ist die folgenreichste Entscheidung des ganzen Projekts,
und sie wird meist in den ersten zehn Minuten nebenbei getroffen. Auslastung ist ein
Ergebnis, kein Ziel; „Umsatz" wird gesagt, wenn „Deckungsbeitrag" gemeint ist.
2. **Zu viele harte Regeln.** Jede unnötig harte Bedingung ist eine Zeitbombe: Irgendwann
ist sie nicht erfüllbar, und das System antwortet gar nicht mehr. Hart ist nur, was
rechtlich oder physikalisch unmöglich ist.
3. **Ein Satz, der mehrere Lesarten hat.** „Stammkunden dürfen wir nicht verlieren" lässt
sich auf mindestens drei Arten modellieren, und keine ist aus dem Satz ableitbar. Die
Wahl trifft man — im Zweifel unbewusst.
## Wenn Sie nur eines mitnehmen
> **🎯** Ein Modell rechnet aus, was **aufgeschrieben** wurde, nicht was gemeint war. Die
> Arbeit, die darüber entscheidet, ob am Ende etwas Brauchbares herauskommt, findet vor der
> ersten Zeile Code statt — und sie besteht aus Fragen an andere Menschen, nicht aus
> Mathematik.
**Vor {ref:teil:kernverfahren}** sollten Sie an einem eigenen Beispiel die vier Fragen aus
{ref:sec:modellierung-vier-fragen} durchgespielt haben. Ohne das bleiben die Verfahren
Technik ohne Anwendung.
---
*Weiter mit:* [{ref:kap:lp} — Lineare Programmierung](20_Lineare_Programmierung.md)

View file

@ -933,7 +933,8 @@ Deckungsbeitrag, also 15,33 € Reingewinn.
> Kapazität. Kauft man 200 Prüfstunden zu, wird irgendwann eine andere Ressource zum
> Engpass, und der Schattenpreis springt auf einen neuen Wert (oder auf null). Wer große
> Kapazitätsänderungen bewerten will, muss das Modell **neu rechnen** — nicht linear
> hochrechnen. Übung 4.6 macht diesen Effekt sichtbar.
> hochrechnen. Die Aufgabe *Gültigkeitsbereich des Schattenpreises*
> ({ref:sec:lp-uebungsaufgaben}) macht diesen Effekt sichtbar.
---
@ -1317,7 +1318,7 @@ $$\max\ 3x_1+2x_2 \quad\text{u. d. N.}\quad x_1+x_2 \ge 4,\quad x_1+3x_2 \le 12,
## Finde den Denkfehler {#sec:lp-denkfehler}
> **🐛 Finde den Denkfehler 4.1: Die 380 000-Euro-Maschine**
> **🐛 Finde den Denkfehler: Die 380 000-Euro-Maschine**
>
> Ein Werksleiter lässt die Engpässe seiner Fertigung analysieren. Das Skript liefert
> für den Lackierofen einen Schattenpreis von **50 €/Stunde**. Er rechnet:
@ -1356,7 +1357,7 @@ $$\max\ 3x_1+2x_2 \quad\text{u. d. N.}\quad x_1+x_2 \ge 4,\quad x_1+3x_2 \le 12,
## Micro-Quiz {#sec:lp-quiz}
> **❓ Micro-Quiz 4: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-lp).

View file

@ -1308,7 +1308,8 @@ def loese(dauer: np.ndarray, n_maschinen: int, zeitlimit: float,
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = zeitlimit
# Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.
# Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist -
# der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).
# Im Produktivbetrieb laesst man beides auf den Standardwerten.
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1
@ -1471,7 +1472,7 @@ eröffnete Lager hat Kapazität 80. Minimieren Sie Fix- plus Transportkosten.
Die Warnung „$M$ so klein wie möglich“ wird meist mit der Laufzeit begründet: schwache
Relaxation, mehr Knoten. Das stimmt — und ist die **harmlosere** Hälfte der Wahrheit.
> **🐛 Finde den Denkfehler 5.1: Elf Lager, die keine Fixkosten kosten**
> **🐛 Finde den Denkfehler: Elf Lager, die keine Fixkosten kosten**
>
> Ein Team plant Standorte: 12 mögliche Lager, 40 Kunden, Fixkosten je eröffnetem Lager.
> Für das Big-M in der Kopplung
@ -1758,7 +1759,7 @@ funktioniert immer.
## Micro-Quiz {#sec:milp-quiz}
> **❓ Micro-Quiz 5: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-milp).

View file

@ -18,6 +18,7 @@
> `Propagation_Demo.py`\
> `CP_SAT_Vertretungssystem.py`\
> `JobShop_Intervalle.py`\
> `Parallele_Suche.py`\
> `CP_SAT_Statusfaelle.py`\
> `Strafgewichte.py`
@ -101,7 +102,9 @@ Nach diesem Kapitel können Sie …
6. … die fünf CP-SAT-Statusfälle unterscheiden — insbesondere `INFEASIBLE` (es gibt keine
Lösung) von `UNKNOWN` (es wurde keine gefunden) und `MODEL_INVALID` (Ihr Code ist
fehlerhaft).
7. … begründen, warum ein Strafgewicht ein **Wechselkurs** ist, und welche Regeln deshalb
7. … begründen, warum `num_workers = 1` für einen reproduzierbaren Lauf nötig ist und ein
fester `random_seed` allein nicht genügt — und was ein Test deshalb prüfen darf.
8. … begründen, warum ein Strafgewicht ein **Wechselkurs** ist, und welche Regeln deshalb
niemals in die Zielfunktion gehören.
---
@ -794,6 +797,9 @@ Zusammenspiel von Reihenfolge- und Maschinenbedingungen, nicht aus einer schlech
> abgedruckte Ausgabe reproduzierbar. **Im Produktivbetrieb lässt man beides weg** — mehrere
> Arbeiter sind deutlich schneller.
>
> Wie viel schneller, und warum der Seed allein **nicht** genügt, misst
> {ref:sec:cpsat-parallele-suche} an genau diesem Modell.
>
> Die Lehre für Ihre eigenen Tests: Prüfen Sie bei mehrdeutigen Optima nie auf einen
> **bestimmten Plan**, sondern immer auf den **Zielwert** und auf die Einhaltung aller
> Regeln. Ein Test wie `assert plan == erwarteter_plan` besteht sonst mal und scheitert mal,
@ -817,6 +823,336 @@ theoretischen Ideal entfernt sein können, noch bevor der Solver fertig war.
---
## Parallele Suche: was `num_workers` wirklich bewirkt {#sec:cpsat-parallele-suche}
An mehreren Stellen dieses Buchs steht `loeser.parameters.num_workers = 1`, mit dem
Kommentar „für eine reproduzierbare Ausgabe“. Darin stecken zwei Behauptungen: dass
parallele Suche **schneller** ist, und dass sie **von Lauf zu Lauf andere, gleich gute**
Lösungen liefert. Beides lässt sich messen — und beim Messen kommt mehr heraus als erwartet.
Gerechnet wird auf demselben Modell wie
{ref:sec:cpsat-intervallvariablen-job-shop-scheduling}, nur groß genug, dass die Suche
wirklich arbeitet: 12 Aufträge auf 10 Maschinen, jeder Auftrag besucht jede Maschine.
Entscheidend ist, dass `random_seed` in **jedem** Lauf denselben Wert hat.
```python
#!/usr/bin/env python3
# Parallele_Suche.py
"""
Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.
Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte
Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele
Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute
Loesungen findet. Beides wird hier gemessen statt geglaubt.
Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross
genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,
jeder Auftrag besucht jede Maschine.
Zwei Messungen:
1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.
2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt
derselbe Plan heraus?
Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.
Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen
Plaene ist es auch - und genau das ist die Aussage.
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import collections
import time
import numpy as np
from ortools.sat.python import cp_model
AUFTRAEGE = 12
MASCHINEN = 10
INSTANZ_SEED = 20260908
SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt
ARBEITERZAHLEN = (1, 2, 4, 8)
WIEDERHOLUNGEN = 4
ZEITLIMIT = 120.0
def baue_instanz() -> list[list[tuple[int, int]]]:
"""Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,
in einer eigenen zufaelligen Reihenfolge."""
rng = np.random.default_rng(INSTANZ_SEED)
return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]
for _ in range(AUFTRAEGE)]
def loese(auftraege, arbeiter: int):
"""Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan
zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe
vergleichen lassen."""
horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)
modell = cp_model.CpModel()
Gang = collections.namedtuple("Gang", "start ende intervall")
plaene: dict[tuple[int, int], Gang] = {}
je_maschine = collections.defaultdict(list)
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}")
intervall = modell.NewIntervalVar(start, dauer, ende, f"iv_{a}_{g}")
plaene[a, g] = Gang(start, ende, intervall)
je_maschine[maschine].append(intervall)
for maschine in range(MASCHINEN):
modell.AddNoOverlap(je_maschine[maschine])
for a, auftrag in enumerate(auftraege):
for g in range(len(auftrag) - 1):
modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)
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.num_workers = arbeiter
loeser.parameters.random_seed = SOLVER_SEED
loeser.parameters.max_time_in_seconds = ZEITLIMIT
beginn = time.perf_counter()
status = loeser.Solve(modell)
dauer = time.perf_counter() - beginn
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
return loeser.StatusName(status), None, dauer, None
plan = tuple(loeser.Value(plaene[a, g].start)
for a, auftrag in enumerate(auftraege)
for g in range(len(auftrag)))
return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan
if __name__ == "__main__":
auftraege = baue_instanz()
gaenge = sum(len(a) for a in auftraege)
print("=" * 78)
print(" WAS MEHRERE ARBEITER WIRKLICH BRINGEN")
print("=" * 78)
print(f"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, "
f"{gaenge} Arbeitsgaenge.")
print(f"random_seed = {SOLVER_SEED} in JEDEM Lauf, "
f"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\n")
# Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.
ergebnisse: dict[int, list[tuple]] = {}
for arbeiter in ARBEITERZAHLEN:
ergebnisse[arbeiter] = [loese(auftraege, arbeiter)
for _ in range(WIEDERHOLUNGEN)]
print("=" * 78)
print(" (1) Laufzeit: was die Arbeiter bringen")
print("=" * 78)
print(f"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} "
f"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}")
print("-" * 78)
basis_zeit = min(d for _, _, d, _ in ergebnisse[1])
for arbeiter in ARBEITERZAHLEN:
laeufe = ergebnisse[arbeiter]
zeiten = [d for _, _, d, _ in laeufe]
ziele = {o for _, o, _, _ in laeufe}
status = {s for s, _, _, _ in laeufe}
print(f"{arbeiter:>9} {'/'.join(sorted(status)):>9} "
f"{'/'.join(str(z) for z in sorted(ziele)):>9} "
f"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s "
f"{basis_zeit / min(zeiten):>14.1f}x")
beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))
faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])
print(f"\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber")
print(f" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.")
print(f" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche")
print(" laufen, sondern verschiedene Strategien nebeneinander, die einander")
print(" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der")
print(" 'zweite Kern', sondern ein anderes Verfahren.")
letzte = ARBEITERZAHLEN[-1]
if beste != letzte:
zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])
print(f"\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen "
f"{zeit_letzte:.2f}s")
print(f" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei "
f"{beste}. Ab einer gewissen Zahl kosten Abstimmung")
print(" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.")
print("\n" + "=" * 78)
print(" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?")
print("=" * 78)
print(f"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} "
f"{'Makespan':>10}")
print("-" * 78)
for arbeiter in ARBEITERZAHLEN:
plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}
ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}
print(f"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} "
f"{'/'.join(str(z) for z in sorted(ziele)):>10}")
einer = len({p for _, _, _, p in ergebnisse[1]})
# Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die
# eigentliche Zahl - nicht, was acht Arbeiter anrichten.
ab = next((w for w in ARBEITERZAHLEN
if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)
print(f"\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.")
if ab is not None:
viele = len({p for _, _, _, p in ergebnisse[ab]})
print(f" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus "
f"{WIEDERHOLUNGEN} Laeufen -")
print(" bei identischem random_seed und identischem Zielwert.")
print(f" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit")
print(f" zu verlieren. {ab} genuegen.")
print("\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt")
print(" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von")
print(" mehreren zuerst fertig wird. Das entscheidet die Uhr.")
print("\n" + "=" * 78)
print(" (3) Was daraus fuer Tests folgt")
print("=" * 78)
print(" Ein Test der Form assert plan == erwarteter_plan besteht mal und")
print(" scheitert mal, ohne dass sich am Code etwas geaendert haette.")
print(" Zu pruefen sind stattdessen:")
print(" * der ZIELWERT (hier in allen Laeufen gleich),")
print(" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),")
print(" * und der Status - nicht die Gestalt der Loesung.")
print("\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte")
print(" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.")
print(f" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau")
print(" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.")
print("=" * 78)
```
**Erwartete Ausgabe** (Laufzeiten *und* die Zahl der verschiedenen Pläne sind
hardwareabhängig — genau das ist die Aussage; der Zielwert ist es nicht):
```
==============================================================================
WAS MEHRERE ARBEITER WIRKLICH BRINGEN
==============================================================================
Job-Shop: 12 Auftraege, 10 Maschinen, 120 Arbeitsgaenge.
random_seed = 1 in JEDEM Lauf, 4 Wiederholungen je Arbeiterzahl.
==============================================================================
(1) Laufzeit: was die Arbeiter bringen
==============================================================================
Arbeiter Status Makespan schnellste langsamste Beschleunigung
------------------------------------------------------------------------------
1 OPTIMAL 183 5.62s 5.82s 1.0x
2 OPTIMAL 183 1.43s 2.04s 3.9x
4 OPTIMAL 183 0.60s 0.66s 9.4x
8 OPTIMAL 183 0.46s 0.65s 12.3x
Am schnellsten sind 8 Arbeiter: Faktor 12.3 gegenueber
einem einzigen - also MEHR, als 8 Kerne hergeben sollten.
Das ist kein Messfehler. CP-SAT laesst nicht 8-mal dieselbe Suche
laufen, sondern verschiedene Strategien nebeneinander, die einander
ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der
'zweite Kern', sondern ein anderes Verfahren.
==============================================================================
(2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?
==============================================================================
Arbeiter Laeufe verschiedene Plaene Makespan
------------------------------------------------------------------------------
1 4 1 183
2 4 3 183
4 4 3 183
8 4 4 183
Mit einem Arbeiter: 1 Plan aus 4 Laeufen.
Schon mit 2 Arbeitern: 3 verschiedene Plaene aus 4 Laeufen -
bei identischem random_seed und identischem Zielwert.
Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit
zu verlieren. 2 genuegen.
Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt
fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von
mehreren zuerst fertig wird. Das entscheidet die Uhr.
==============================================================================
(3) Was daraus fuer Tests folgt
==============================================================================
Ein Test der Form assert plan == erwarteter_plan besteht mal und
scheitert mal, ohne dass sich am Code etwas geaendert haette.
Zu pruefen sind stattdessen:
* der ZIELWERT (hier in allen Laeufen gleich),
* die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),
* und der Status - nicht die Gestalt der Loesung.
Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte
Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.
Das kostet hier den Faktor 12.3 an Laufzeit und ist genau
deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.
==============================================================================
```
### Was die Messung zeigt
**Erstens: Die Beschleunigung ist überlinear.** Acht Arbeiter sind im abgedruckten Lauf
nicht achtmal, sondern **gut zwölfmal** schneller als einer. Das sieht nach Messfehler aus, ist aber die
korrekte Beschreibung dessen, was CP-SAT tut: Es lässt **nicht** achtmal dieselbe Suche auf
verschiedenen Kernen laufen, sondern **verschiedene Strategien nebeneinander** — Suche mit
LP-Relaxation, Suche mit anderen Verzweigungsregeln, lokale Suche —, und die teilen
einander ihre Schranken mit. Der zweite Arbeiter ist deshalb nicht „der zweite Kern“,
sondern ein anderes Verfahren, das zufällig auch rechnet.
Daraus folgt zweierlei. Ein Speedup über der Kernzahl ist bei CP-SAT normal und kein Grund
zum Misstrauen. Und der Zugewinn flacht ab: Zwischen vier und acht Arbeitern lag in unseren
Wiederholungen mal das eine, mal das andere vorn — ab einer gewissen Zahl kosten Abstimmung
und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt. Auch hier gilt: messen,
nicht schätzen.
**Zweitens — und das ist der Befund, der die Testpraxis bestimmt: Der Seed genügt nicht.**
Bei einem Arbeiter liefern vier Läufe **einen** Plan. Schon bei **zwei** Arbeitern liefern
vier Läufe **drei verschiedene** — bei identischem `random_seed` und identischem Zielwert
183.
Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit zu verlieren. Zwei genügen.
Der Grund ist einfach, sobald man ihn ausspricht: Der Seed legt fest, wie ein **einzelner**
Suchstrang würfelt. Er legt nicht fest, **welcher von mehreren zuerst fertig wird** — das
entscheidet die Uhr, und die ist bei jedem Lauf anders.
> **⚠️ `random_seed` allein macht nichts reproduzierbar**
> Ein häufiger Irrtum: „Ich habe doch einen Seed gesetzt.“ Für einen Zufallsgenerator
> stimmt das. Für einen **parallelen** Solver nicht — dort ist die zweite Zufallsquelle die
> Ausführungsreihenfolge, und die lässt sich nicht mit einem Parameter festnageln.
> Reproduzierbar wird der Lauf erst mit `num_workers = 1` **und** Seed. Wer nur eines von
> beidem setzt, hat einen Test, der gelegentlich grundlos rot wird.
### Was daraus für Tests folgt
Der Zielwert war in **allen** sechzehn Läufen derselbe: 183. Die Gestalt der Lösung war es
nicht. Genau daran entscheidet sich, was ein Test prüfen darf:
| Prüfen | Nicht prüfen |
| --- | --- |
| den **Zielwert** — er ist eindeutig, wenn das Modell optimal gelöst wurde | die **Gestalt** des Plans (`assert plan == erwarteter_plan`) |
| die **Einhaltung aller Regeln** über eine Abnahmeprüfung ({ref:sec:praxisfallen-or-kern}) | die Reihenfolge, in der gleichwertige Aufträge zugewiesen wurden |
| den **Status** (`OPTIMAL`, `FEASIBLE`, `INFEASIBLE`) | die Laufzeit als feste Zahl |
Wer doch einen bestimmten Plan braucht — für eine abgedruckte Ausgabe, für einen
Regressionstest —, setzt `num_workers = 1`. Das kostet hier den Faktor 12 an Laufzeit, und
genau deshalb ist es eine Entscheidung **für den Test**, nicht für den Betrieb. In
Produktion lässt man beide Parameter auf ihren Standardwerten.
> **🎯 Merksatz**
> Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr. Nur die erste lässt
> sich setzen. Wer Reproduzierbarkeit braucht, muss die zweite abschalten — und das heißt
> `num_workers = 1`.
---
## Die fünf Antworten von CP-SAT {#sec:cpsat-statusfaelle}
{ref:sec:milp-gap} hat gezeigt, warum `OPTIMAL` bei ganzzahligen Problemen nicht der
@ -1180,6 +1516,16 @@ Lösen Sie ein 9×9-Sudoku mit CP-SAT. Sie brauchen nur `NewIntVar(1, 9, ...)` u
`AddAllDifferent` für Zeilen, Spalten und die neun 3×3-Blöcke. Wie viele Zeilen Modellcode
benötigen Sie?
**Aufgabe ⭐⭐ — Die Grenze der Parallelität finden.**
`Parallele_Suche.py` misst 1, 2, 4 und 8 Arbeiter.
(a) Ergänzen Sie 16 und 24 (bzw. die Kernzahl Ihres Rechners). Ab wo bringt ein weiterer
Arbeiter nichts mehr — und wird es irgendwann wieder schlechter?
(b) Setzen Sie `random_seed` bei jedem Lauf auf einen **anderen** Wert, aber
`num_workers = 1`. Wie viele verschiedene Pläne kommen jetzt heraus? Was folgt daraus
für die Frage, welcher der beiden Parameter die Reproduzierbarkeit sichert?
(c) Vergrößern Sie die Instanz auf 15 Aufträge. Ab welcher Größe erreicht der Lauf mit
einem Arbeiter das Zeitlimit, während acht noch `OPTIMAL` melden?
**Aufgabe ⭐⭐⭐ — Job-Shop erweitern.**
Ergänzen Sie `JobShop_Intervalle.py` um:
(a) Rüstzeiten von 1 Zeiteinheit zwischen zwei Aufträgen auf derselben Maschine.
@ -1196,7 +1542,7 @@ Wünsche als weiche Ziele. Geben Sie den Plan als Wochentabelle aus.
## Finde den Denkfehler {#sec:cpsat-denkfehler}
> **🐛 Finde den Denkfehler 6.1: Die Betriebsvereinbarung, die niemanden interessiert**
> **🐛 Finde den Denkfehler: Die Betriebsvereinbarung, die niemanden interessiert**
>
> Ein Klinikteam lässt seinen Schichtplan optimieren. Zwei Wünsche kommen aus dem Betrieb:
>
@ -1428,7 +1774,7 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
## Micro-Quiz {#sec:cpsat-quiz}
> **❓ Micro-Quiz 6: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-cpsat).
@ -1466,6 +1812,10 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
3. Was ist eine Intervallvariable, und welche Bedingung garantiert sie automatisch?
4. Warum sollten möglichst wenige Bedingungen hart formuliert werden?
5. Warum ist die Aufschlüsselung der Strafkosten in der Praxis wichtiger als der Zielwert
6. Sie setzen `random_seed = 1`, bekommen aber trotzdem bei jedem Lauf einen anderen Plan.
Woran liegt das, und was hilft?
7. Warum kann ein CP-SAT-Lauf mit acht Arbeitern **mehr** als achtmal schneller sein als
mit einem?
selbst?
---
@ -1491,10 +1841,17 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
* **Bei mehrdeutigen Optima nie auf einen bestimmten Plan testen** — nur auf den Zielwert
und die Einhaltung aller Regeln.
* **Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr.** Nur die erste
lässt sich setzen. Reproduzierbar wird ein Lauf erst mit `num_workers = 1` **und** Seed —
gemessen in {ref:sec:cpsat-parallele-suche}: schon zwei Arbeiter liefern bei identischem
Seed drei verschiedene Pläne zum selben Zielwert.
* **Parallele Suche kann überlinear beschleunigen**, weil CP-SAT nicht dieselbe Suche
vervielfacht, sondern verschiedene Strategien nebeneinander laufen lässt, die einander
ihre Schranken mitteilen.
**Ausblick.** {ref:kap:graphen} behandelt Probleme, die sich am besten als **Graph** begreifen
lassen: Flüsse durch Netzwerke, Zuordnungen und die Tourenplanung mit Fahrzeugen und
Zeitfenstern.
---
*Weiter mit:* [{ref:kap:graphen} — Graphen, Flüsse, Touren](23_Graphen_Fluesse_Touren.md)
*Weiter mit:* [{ref:kap:graphen} — Graphen, Flüsse und Touren](23_Graphen_Fluesse_Touren.md)

View file

@ -751,7 +751,7 @@ OR-Tools. Was beobachten Sie ab 12 Städten?
## Finde den Denkfehler {#sec:graphen-denkfehler}
> **🐛 Finde den Denkfehler 7.1: Die vergessene Dimension**
> **🐛 Finde den Denkfehler: Die vergessene Dimension**
>
> Eine Spedition lässt ihre Tagestouren optimieren: 16 Kunden, 4 Fahrzeuge zu je 10
> Paletten, Gesamtbedarf 37 Paletten. Das erste Ergebnis begeistert alle — **326 km**.
@ -1018,7 +1018,7 @@ plausibel aussieht.
## Micro-Quiz {#sec:graphen-quiz}
> **❓ Micro-Quiz 7: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-graphen).
@ -1084,4 +1084,4 @@ und die KKT-Bedingungen ein — das mathematische Fundament der Portfoliooptimie
---
*Weiter mit:* [{ref:kap:qp-nlp} — Quadratische und nichtlineare Optimierung](30_QP_und_NLP.md)
*Weiter mit:* [{ref:kap:metaheuristiken} — Metaheuristiken](24_Metaheuristiken.md)

View file

@ -1526,3 +1526,7 @@ liegt es an der Strategie oder an der Zahl der Runden, die sie schafft?
könnte.
* **LNS** kombiniert beide: zerstören, exakt reparieren, übernehmen. Die Fenstergröße hat
ein Optimum in der Mitte und gehört gemessen.
---
*Weiter mit:* [{ref:kap:dekomposition} — Spaltengenerierung](25_Spaltengenerierung.md)

View file

@ -750,3 +750,7 @@ Spalten verzweigen kann.
Verfahren 11 %, bei sechs nichts. Diese Kennzahl lässt sich vor dem Projekt ausrechnen.
* Auch wo es nichts spart, liefert es die **Schranke** — und damit die Erlaubnis,
aufzuhören.
---
*Weiter mit:* [Synthese Teil II](29_Synthese_Kernverfahren.md)

View file

@ -0,0 +1,50 @@
# Synthese Teil II — Die Kernverfahren nebeneinander
Sechs Kapitel, sechs Werkzeuge. Jedes einzelne wurde im Zusammenhang gezeigt; hier stehen
sie zum ersten Mal **nebeneinander**, mit der Frage, die vor jedem Projekt zu beantworten
ist: Welches nehme ich, und was bekomme ich dafür?
## Die Entscheidungsmatrix
| Verfahren | Wofür es gebaut ist | Was Sie bekommen | Wo es aufhört | Kapitel |
| --- | --- | --- | --- | --- |
| **LP** | teilbare Mengen, lineare Zusammenhänge | beweisbares Optimum **plus Schattenpreise** | sobald etwas ganzzahlig sein muss | {ref:kap:lp} |
| **MILP** | Ja/Nein-Entscheidungen, Fixkosten, Logik | beweisbares Optimum, Gap als Fortschrittsmaß | Laufzeit wächst mit der Zahl der Binärvariablen | {ref:kap:milp} |
| **CP-SAT** | Zuweisung, Reihenfolge, Kalender, harte Logik | beweisbares Optimum, sehr ausdrucksstarke Bedingungen | keine Schattenpreise, keine stetigen Größen | {ref:kap:cpsat} |
| **Graphenalgorithmen** | Flüsse, Zuordnung, Touren | oft **polynomiell** statt exponentiell | nur, wenn die Struktur wirklich passt | {ref:kap:graphen} |
| **Metaheuristiken** | wenn der exakte Solver aussteigt | eine gute Lösung in **fester Zeit** | keine Garantie, kein Beweis, keine Schranke | {ref:kap:metaheuristiken} |
| **Spaltengenerierung** | Modelle mit astronomisch vielen Variablen | beweisbares Optimum über nie erzeugte Spalten | lohnt nur bei bestimmter Instanzstruktur | {ref:kap:dekomposition} |
**Die Spalte, die am meisten wert ist, ist die vierte.** Ein Verfahren zu kennen heißt zu
wissen, wo es aufhört — nicht, wofür es gedacht ist.
## Was dieser Teil gemessen hat
| Behauptung | Gemessen | Wo |
| --- | --- | --- |
| „Der exakte Solver ist immer besser." | Bei 500 Aufträgen liefert CP-SAT eine Lösung, die **5,2 % schlechter** ist als die Faustregel eines Meisters | {ref:kap:metaheuristiken} |
| „Metaheuristiken sind ungenau." | Faustregel 2 497 → Annealing 2 343 → Annealing plus LNS **2 289** Minuten Rüstzeit; untere Schranke 1 768 | {ref:kap:metaheuristiken} |
| „Man muss alle Variablen aufschreiben." | 28 erzeugte Muster von **46 408** möglichen — und die Lösung ist beweisbar optimal | {ref:kap:dekomposition} |
| „Dekomposition lohnt sich immer." | Bei drei Stücken je Rolle **11 %** Ersparnis, bei sechs **nichts** | {ref:kap:dekomposition} |
| „Mit Seed ist der Lauf reproduzierbar." | Schon **zwei** Arbeiter liefern bei identischem Seed drei verschiedene Pläne zum selben Zielwert | {ref:sec:cpsat-parallele-suche} |
## Drei Fehler, die dieser Teil verhindert
1. **Runden.** Die LP-Lösung ist keine Näherung der ganzzahligen Lösung — sie kann beliebig
weit danebenliegen, und gerundet sogar unzulässig werden.
2. **Ein zu großes Big-M.** Es macht das Modell nicht falsch, sondern die Suche langsam und
die Schranken wertlos. So klein wie zulässig, nie „sicherheitshalber groß".
3. **Zu früh heuristisch werden.** Der Umschlagpunkt, ab dem eine Metaheuristik den exakten
Solver schlägt, ist eine Eigenschaft **des Problems** und lässt sich messen. Wer ihn
nicht misst, verzichtet auf Optimalitätsgarantien, die er hätte haben können.
## Wenn Sie nur eines mitnehmen
> **🎯** Die Frage lautet nie „welcher Solver ist der beste", sondern „welche Garantie
> brauche ich, und was bin ich bereit, dafür an Laufzeit zu zahlen". Ein Verfahren ohne
> Schranke liefert eine Lösung; ein Verfahren mit Schranke liefert die Erlaubnis,
> aufzuhören.
---
*Weiter mit:* [{ref:kap:qp-nlp} — Quadratische und nichtlineare Optimierung](30_QP_und_NLP.md)

View file

@ -5,7 +5,7 @@ in der Praxis oft nicht: Risiko wächst **quadratisch** mit dem Einsatz, Nachfra
morgen bekannt, und eine Entscheidung heute verändert, welche Möglichkeiten übermorgen noch
offenstehen.
Die drei Kapitel dieses Teils behandeln genau diese drei Abweichungen. Welches Werkzeug Sie
Drei Kapitel dieses Teils behandeln genau diese drei Abweichungen. Welches Werkzeug Sie
brauchen, hängt davon ab, **welche** davon bei Ihnen vorliegt:
![Entscheidungsdiagramm: Welches Werkzeug passt zu meinem Problem? (Teil III)](bilder_04/teil3_solverwahl.svg)
@ -21,6 +21,11 @@ brauchen, hängt davon ab, **welche** davon bei Ihnen vorliegt:
> und dann deterministisch zu rechnen. Warum das systematisch danebengeht, zeigt
> {ref:kap:unsicherheit} unter dem Namen *Fluch des Durchschnitts*.
Zwei weitere Kapitel schließen sich an, weil sie dieselbe Voraussetzung aufgeben, nur an
einer anderen Stelle: {ref:kap:mehrziel} gibt das **eine Ziel** auf, {ref:kap:prognose} die
Annahme, die **Eingabedaten** seien gegeben statt selbst geschätzt. Was dieser Teil
insgesamt leistet, fasst die Synthese an seinem Ende zusammen.
---
# Kapitel: Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität {#kap:qp-nlp}
@ -778,7 +783,8 @@ unverändert gelassen. **Der weiche Term verteilt, die harte Schranke kappt.**
>
> **Zur Skalierung:** Mit $\alpha = 1$ ist der Ertragsterm ($\approx 0{,}1$) zehnmal größer
> als der Varianzterm ($\approx 0{,}01$). Das Modell ist also stark renditegetrieben.
> Übung 8.5 lässt Sie diese Balance untersuchen — sie ist eine fachliche Entscheidung, keine
> Die Aufgabe *Effekt der Gewichtung untersuchen* ({ref:sec:qp-nlp-uebungsaufgaben}) lässt
> Sie diese Balance untersuchen — sie ist eine fachliche Entscheidung, keine
> technische.
---
@ -1211,7 +1217,7 @@ groß ist der Unterschied zwischen bestem und schlechtestem Ergebnis? Kann die
## Finde den Denkfehler {#sec:qp-nlp-denkfehler}
> **🐛 Finde den Denkfehler 8.1: Die Kovarianzmatrix aus dem Controlling**
> **🐛 Finde den Denkfehler: Die Kovarianzmatrix aus dem Controlling**
>
> Ein Analyst soll das Risiko eines Portfolios aus drei Anlagen minimieren. Die
> Korrelationen hat er aus drei verschiedenen Quartalsberichten zusammengetragen — jede
@ -1279,7 +1285,7 @@ groß ist der Unterschied zwischen bestem und schlechtestem Ergebnis? Kann die
## Micro-Quiz {#sec:qp-nlp-quiz}
> **❓ Micro-Quiz 8: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-qp-nlp).

View file

@ -428,7 +428,7 @@ Feinsuche (Raster 100..500): Optimum bei Kapazitaet 180, Kosten 12,810 EUR
> allgemeine Antwort — **rechnen Sie es aus.** Genau dafür ist Monte-Carlo da. Und beachten
> Sie die Spalte „Unterdeckung“: Bei Kapazität 200 wird in **24 % der Fälle** nachgekauft.
> Ob das akzeptabel ist, entscheidet kein Optimierer, sondern ein Servicelevel-Ziel
> (Übung 9.5).
> (Aufgabe *Monte-Carlo erweitern*, {ref:sec:unsicherheit-uebungsaufgaben}).
> **💡 Wann Monte-Carlo das richtige Werkzeug ist**
> Immer dann, wenn Sie **eine Kennzahl brauchen, die keine Formel hat**: die
@ -642,7 +642,8 @@ auch nicht optimal.
Der **EVPI von 7 375 €** ist bemerkenswert hoch: 45 % der Gesamtkosten entstehen allein
daraus, dass man die Zukunft nicht kennt. In so einem Fall lohnt sich Investition in bessere
Prognosen tatsächlich — anders als in dem Beispiel aus Übung 9.4.
Prognosen tatsächlich — anders als in dem Beispiel aus der Aufgabe *EVPI interpretieren*
({ref:sec:unsicherheit-uebungsaufgaben}).
> **💡 Was ist der EVPI?**
> Der **Expected Value of Perfect Information**{idx:EVPI} beziffert, wie viel eine perfekte Prognose
@ -1278,7 +1279,7 @@ das lässt sich in CVXPY mit `cp.sum_largest` formulieren.)
## Finde den Denkfehler {#sec:unsicherheit-denkfehler}
> **🐛 Finde den Denkfehler 9.1: Warum jedes Projekt zu spät fertig wird**
> **🐛 Finde den Denkfehler: Warum jedes Projekt zu spät fertig wird**
>
> Ein Projektleiter plant eine Umbaumaßnahme. Fünf Gewerke arbeiten **parallel** und
> unabhängig voneinander; fertig ist der Umbau, wenn das letzte fertig ist. Für jedes
@ -1334,7 +1335,7 @@ das lässt sich in CVXPY mit `cp.sum_largest` formulieren.)
## Micro-Quiz {#sec:unsicherheit-quiz}
> **❓ Micro-Quiz 9: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-unsicherheit).

View file

@ -739,7 +739,7 @@ Bei dynamischer Programmierung entscheidet eine einzige Frage über Erfolg oder
**Was gehört in den Zustand?** Ist er zu klein, rechnet das Verfahren völlig korrekt — nur
eben an einem anderen Problem.
> **🐛 Finde den Denkfehler 10.1: Der Zustand, der zu wenig weiß**
> **🐛 Finde den Denkfehler: Der Zustand, der zu wenig weiß**
>
> Eine Werkstatt plant vier Perioden. Der Bedarf beträgt 3, 1, 4, 2 Stück; produziert werden
> können höchstens 5 je Periode, gelagert höchstens 6 Stück.
@ -796,7 +796,7 @@ eben an einem anderen Problem.
## Micro-Quiz {#sec:dynamische-programmierung-quiz}
> **❓ Micro-Quiz 10: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-dynamische-programmierung).
@ -862,4 +862,4 @@ Marktdaten — und lernen zuerst, warum diese Daten trügerisch sind.
---
*Weiter mit:* [{ref:kap:finanzdaten} — Finanzdaten und Kovarianz-Shrinkage](40_Finanzdaten.md)
*Weiter mit:* [{ref:kap:mehrziel} — Mehrere Ziele](33_Mehrziel_und_Pareto.md)

View file

@ -757,3 +757,7 @@ einer Fläche. Wie ändert sich das ε-Verfahren, und warum wächst der Aufwand
dafür so sauber wie möglich" ist eine Frage, die ein Kaufmann beantworten kann.
* Die Front wird erst entscheidbar durch die Spalte **€ je kg**: Sie ist mit dem internen
CO₂-Preis vergleichbar und verwandelt eine Wertefrage in eine Rechnung.
---
*Weiter mit:* [{ref:kap:prognose} — Predict-then-Optimize](34_Predict_then_Optimize.md)

View file

@ -700,3 +700,7 @@ und zwar **in unterschiedlichem Ausmaß**? Was sagt das über den Wert eines Mer
* Ein Kennzahlenvergleich braucht eine **Streuungsangabe**. Bei 180 Testtagen sah das
schlechtere Modell in gut jedem zehnten Fall besser aus — und wer einmal misst, hat genau
eine Ziehung.
---
*Weiter mit:* [Synthese Teil III](39_Synthese_Nichtlinear.md)

View file

@ -735,3 +735,7 @@ trägt — und schreiben Sie auf, welche der drei Zeilen im Weg steht.
gegen reagierende Marktteilnehmer) und ob Teilbarkeit erlaubt ist (MILP gegen LP).
* Die erste dieser Grenzen ist die gefährlichste: Dieselbe Optimierung ist in der Werkstatt
Planung und im Depot Fehlerverstärkung.
---
*Weiter mit:* [{ref:kap:supplychain} — Supply-Chain und Energieeinsatz unter Unsicherheit](36_Supply_Chain_Energie.md)

View file

@ -595,6 +595,26 @@ Was Versorgungssicherheit kostet
Der Erwartungswert-Plan kostet in der Wirklichkeit **149 % mehr**, und sein schlimmster Tag
ist sechsmal so teuer.
**Wofür genau?** Die Summendifferenz von 523 514 € sagt noch nicht, wo sie entsteht. Zerlegt
man sie nach Kostenarten, wird aus dem Befund eine Aussage, die man vorlegen kann:
![Wofür der Erwartungswert-Plan 149 % mehr bezahlt](bilder_04/kap_supplychain_wirkung.svg)
Drei Dinge stehen darin, die die Tabelle nicht zeigt. Die **Anfahrkosten sind in beiden
Plänen gleich** (58 000 €) — der Erwartungswert-Plan spart also nicht dort, wo man es
vermuten würde. Er spart **12 725 € Brennstoff**, weil er weniger Blockstunden vorhält. Und
er bezahlt dafür **536 239 € Lastabwurf**: das **42-fache** seiner Ersparnis.
Genau diese Gegenüberstellung ist es, die eine Investitionsentscheidung trägt. „149 % mehr"
ist eine Zahl, über die man streiten kann; „wir sparen 12 725 € und riskieren dafür 536 239 €"
ist eine, über die man entscheidet.
> **📌 Zum Diagramm**
> Es wird von `bilder_04/erzeuge_wirkung.py` erzeugt, das die Instanz aus
> `Kraftwerkseinsatz.py` **erneut rechnet** statt Zahlen zu übernehmen — und dabei die
> beiden abgedruckten Summen exakt reproduziert. Die Trennung von Brennstoff- und
> Abwurfkosten ist der einzige Zusatz; das Buchprogramm braucht sie nicht.
Der Unterschied zwischen den beiden Plänen ist dabei erstaunlich klein:
```
@ -765,3 +785,7 @@ und welches Verfahren aus {ref:kap:metaheuristiken} würden Sie einsetzen?
der erwarteten Kosten die Absicherung von selbst.
* Wo sie greift, liefert sie den **Preis der Vorgabe**: 46 € je vermiedener MWh — eine Zahl,
über die man verhandeln kann.
---
*Weiter mit:* [{ref:kap:finanzdaten} — Finanzdaten-Modellierung](40_Finanzdaten.md)

View file

@ -0,0 +1,52 @@
# Synthese Teil III — Wenn die Idealwelt nicht gilt
{ref:teil:kernverfahren} setzte voraus: feste Daten, lineare Zusammenhänge, ein Ziel, eine
Periode. Dieser Teil nimmt jede dieser vier Voraussetzungen einzeln weg. Die Tabelle ordnet
die Kapitel danach, **welche** Voraussetzung bei Ihnen verletzt ist.
## Welche Annahme fällt weg?
| Was nicht gilt | Woran Sie es merken | Werkzeug | Kapitel |
| --- | --- | --- | --- |
| **Linearität** | Risiko wächst quadratisch, Kosten degressiv, Sättigung | QP, konvexe Optimierung, KKT | {ref:kap:qp-nlp} |
| **Feste Daten** | Nachfrage, Wind, Rendite stehen erst morgen fest | Monte-Carlo, Zweistufigkeit, Robustheit, Chance Constraints | {ref:kap:unsicherheit} |
| **Eine Periode** | die Entscheidung heute verändert, was übermorgen möglich ist | Bellman-Gleichung, Rückwärtsinduktion | {ref:kap:dynamische-programmierung} |
| **Ein Ziel** | zwei Kennzahlen sollen gleichzeitig stimmen | Pareto-Front, ε-Constraint | {ref:kap:mehrziel} |
| **Daten sind gegeben** | die Eingabe ist selbst eine Prognose | Predict-then-Optimize, entscheidungsorientiertes Lernen | {ref:kap:prognose} |
Die letzte Zeile ist die unbequemste: Dort ist nicht das Modell unsicher, sondern seine
**Eingabe** — und die kommt aus einem zweiten Modell, das nach anderen Kriterien gebaut
wurde.
## Was dieser Teil gemessen hat
| Behauptung | Gemessen | Wo |
| --- | --- | --- |
| „Mit dem Mittelwert zu rechnen ist eine brauchbare Näherung." | Der Mittelwertplan hält seine Zusage in **50,08 %** der Fälle — per Definition | {ref:sec:unsicherheit-chance-constraints} |
| „Versorgungssicherheit kostet linear." | 56 289 € je Prozentpunkt auf dem Weg zu 80 %, **253 848 €** zwischen 95 und 99 % — das 4,5-fache | {ref:sec:unsicherheit-chance-constraints} |
| „Eine Zusage aus dem Modell hält." | Der 95-%-Plan hält **87,44 %**, sobald die Testverteilung einen Fall enthält, in dem alles zugleich ausfällt | {ref:sec:unsicherheit-chance-constraints} |
| „Die bessere Prognose führt zur besseren Entscheidung." | Das Modell mit dem **schlechteren** MSE trifft die günstigere Entscheidung | {ref:kap:prognose} |
## Drei Fehler, die dieser Teil verhindert
1. **Unsicherheit durch Mittelwerte ersetzen.** Der Durchschnittskunde kauft nie. Bei
asymmetrischen Kosten liegt das Optimum systematisch neben dem Mittelwert, und eine
Terminzusage ist ein **Quantil**, kein Erwartungswert.
2. **Konvexität voraussetzen, ohne sie zu prüfen.** Ohne sie gibt es keine
Optimalitätsgarantie, sondern nur ein lokales Ergebnis — und der Solver sagt das nicht
von selbst.
3. **Zwei Ziele mit einem Gewicht verrechnen, bevor die Front bekannt ist.** Ein Gewicht
ist eine Antwort auf eine Frage, die noch niemand gestellt hat. Die Pareto-Front stellt
sie zuerst.
## Wenn Sie nur eines mitnehmen
> **🎯** Jede der vier Annahmen lässt sich einzeln aufgeben — aber jede kostet etwas
> Bestimmtes: Linearität kostet die Optimalitätsgarantie, feste Daten kosten Rechenzeit
> oder Vorsicht, eine Periode kostet Zustandsraum, ein Ziel kostet eine Entscheidung, die
> nicht der Modellierer treffen darf. Wer weiß, **welche** Annahme bei ihm fällt, weiß
> auch, welchen Preis er zahlt.
---
*Weiter mit:* [{ref:kap:bruecke} — Die Strukturbrücke](35_Strukturbruecke.md)

View file

@ -448,7 +448,8 @@ beide und macht transparent, welches wo zum Einsatz kommt.
> unkorreliert sind — eine starke, aber sehr stabile Annahme. Das
> **Konstant-Korrelations-Ziel** behält die geschätzten Einzelvarianzen und glättet nur die
> Korrelationen; das ist bei Aktien meist realistischer, weil Titel tatsächlich sehr
> unterschiedliche Volatilitäten haben. In Übung 11.5 implementieren Sie es selbst und
> unterschiedliche Volatilitäten haben. In der Aufgabe *Beide Shrinkage-Ziele vergleichen*
> ({ref:sec:finanzdaten-uebungsaufgaben}) implementieren Sie es selbst und
> vergleichen.
Das Ergebnis ist in jedem Fall garantiert **positiv definit**, wohlkonditioniert und stabil
@ -680,7 +681,7 @@ Erweitern Sie `Schaetzrauschen_Demo.py` um eine dritte Variante: die
## Finde den Denkfehler {#sec:finanzdaten-denkfehler}
> **🐛 Finde den Denkfehler 11.1: Das risikofreie Portfolio**
> **🐛 Finde den Denkfehler: Das risikofreie Portfolio**
>
> Ein Analyst stellt ein Portfolio aus **30 Titeln** zusammen. Für die Kovarianzmatrix
> nimmt er das letzte Quartal — bei Wochendaten sind das **20 Beobachtungen**. Dann
@ -760,7 +761,8 @@ def minimales_risiko(kovarianz: np.ndarray) -> tuple[np.ndarray, float]:
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).
nachgehen muss (siehe den Denkfehler im Kapitel Quadratische und
nichtlineare Optimierung).
"""
gewichte = cp.Variable(ANZAHL_ANLAGEN)
problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),
@ -885,7 +887,7 @@ e 0: \mathbf{w}^ op\mathbf{S}\mathbf{w} = 0$ | „In genau diesen Richtungen mis
## Micro-Quiz {#sec:finanzdaten-quiz}
> **❓ Micro-Quiz 11: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-finanzdaten).
@ -950,4 +952,4 @@ Markowitz-Effizienzgrenze mit realistischen institutionellen Restriktionen.
---
*Weiter mit:* [{ref:kap:markowitz} — Markowitz-Portfoliotheorie](41_Markowitz.md)
*Weiter mit:* [{ref:kap:markowitz} — Die moderne Portfoliotheorie nach Markowitz](41_Markowitz.md)

View file

@ -763,7 +763,7 @@ zweiten. Vergleichen Sie GMV, Max Sharpe und 1/N.
anrichtet. Bei den erwarteten **Renditen** ist es schlimmer — und weil sie in der
Zielfunktion stehen statt in der Nebenbedingung, fällt es schwerer auf.
> **🐛 Finde den Denkfehler 12.1: Zwölf gleiche Anlagen, ein sehr ungleiches Portfolio**
> **🐛 Finde den Denkfehler: Zwölf gleiche Anlagen, ein sehr ungleiches Portfolio**
>
> Ein Analyst optimiert ein Portfolio aus zwölf Anlagen. Erwartete Renditen und
> Kovarianzen schätzt er aus einem Jahr Tagesdaten — 250 Beobachtungen, in der Praxis
@ -986,7 +986,7 @@ Drei praktische Konsequenzen:
## Micro-Quiz {#sec:markowitz-quiz}
> **❓ Micro-Quiz 12: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-markowitz).

View file

@ -720,7 +720,7 @@ $\tau \in \{0{,}05;\ 0{,}1;\ 0{,}25;\ 0{,}5;\ 1{,}0\}$.
## Finde den Denkfehler {#sec:cvar-denkfehler}
> **🐛 Finde den Denkfehler 13.1: Das Risikobudget, das durch Diversifikation stieg**
> **🐛 Finde den Denkfehler: Das Risikobudget, das durch Diversifikation stieg**
>
> Eine Bank vergibt Risikobudgets je Abteilung. Zwei Kreditabteilungen halten je eine
> Unternehmensanleihe über 100 € Nominal mit **4 % Ausfallwahrscheinlichkeit** (bei
@ -779,7 +779,7 @@ $\tau \in \{0{,}05;\ 0{,}1;\ 0{,}25;\ 0{,}5;\ 1{,}0\}$.
## Micro-Quiz {#sec:cvar-quiz}
> **❓ Micro-Quiz 13: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-cvar).
@ -846,4 +846,4 @@ Rebalancing und ein Walk-Forward-Backtest ohne Lookahead-Bias.
---
*Weiter mit:* [{ref:kap:handelsmaschine} — Die vollständige Handelsmaschine](43_Handelsmaschine.md)
*Weiter mit:* [{ref:kap:handelsmaschine} — Die vollständige quantitative Handelsmaschine](43_Handelsmaschine.md)

View file

@ -754,7 +754,7 @@ sondern in der **Arbeitsweise davor** — und hinterlassen im Programm keine Spu
Backtest, der zwanzigmal nachjustiert wurde, sieht am Ende genauso sauber aus wie einer,
der beim ersten Versuch funktioniert hat.
> **🐛 Finde den Denkfehler 14.1: Die Strategie mit dem hochsignifikanten Ergebnis**
> **🐛 Finde den Denkfehler: Die Strategie mit dem hochsignifikanten Ergebnis**
>
> Eine Analystin entwickelt über mehrere Wochen eine Handelsstrategie. Sie probiert
> verschiedene Signalfenster, Schwellenwerte, Filter und Haltedauern durch — insgesamt
@ -980,7 +980,7 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
## Micro-Quiz {#sec:handelsmaschine-quiz}
> **❓ Micro-Quiz 14: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-handelsmaschine).
@ -1047,4 +1047,4 @@ der Weg vom Skript zum produktiven System.
---
*Weiter mit:* [{ref:kap:praxisfallen} — Praxisfallen und produktiver Einsatz](50_Praxis.md)
*Weiter mit:* [Synthese Teil IV](49_Synthese_Anwendungen.md)

View file

@ -0,0 +1,50 @@
# Synthese Teil IV — Zwei Domänen, dieselbe Mathematik
Sechs Kapitel, zwei Branchen, die sich für unvergleichbar halten. Die Werkstatt plant
Maschinen, das Depot plant Titel — und beide lösen dasselbe Problem: **knappe Mittel auf
konkurrierende Verwendungen verteilen, unter Unsicherheit.**
## Dieselbe Struktur, andere Namen
| Werkstatt und Kraftwerk | Depot und Handel | Was mathematisch dahintersteht |
| --- | --- | --- |
| Produkt, Maschinenstunde | Titel, Kapital | Entscheidungsvariable |
| Deckungsbeitrag | erwartete Rendite | linearer Zielterm |
| Kapazität, Liefervertrag | Positionsgrenze, Sektorlimit | Nebenbedingung |
| Rüstkosten, Anfahrkosten | Transaktionskosten | Fixkosten mit Binärvariable (B1) |
| Lastabwurf, Konventionalstrafe | Tail-Verlust | Straf- bzw. Risikoterm |
| Wind- und Nachfrageszenarien | Renditeszenarien | Szenariomenge |
**Und wo die Analogie endet**, benennt {ref:kap:bruecke} an drei Stellen ausdrücklich — das
ist der wertvollere Teil. Eine Analogie, die man nicht begrenzen kann, ist keine Erkenntnis,
sondern eine Redewendung.
## Was dieser Teil gemessen hat
| Behauptung | Gemessen | Wo |
| --- | --- | --- |
| „Planen mit der Prognose ist gut genug." | Der Erwartungswert-Plan kostet **149 % mehr** (874 870 € statt 351 356 €) und wirft in **28 von 40** Szenarien Last ab | {ref:kap:supplychain} |
| „Dann sparen wir eben beim Brennstoff." | Genau das tut er: 12 725 € gespart, **536 239 €** Lastabwurf bezahlt — das 42-fache. Die Anfahrkosten sind in beiden Plänen gleich | {ref:kap:supplychain} |
| „Der VaR sagt, wie riskant es ist." | VaR **1,86 %**, CVaR **2,99 %** — und der schlechteste Tag der Stichprobe (**23,0 %**) ändert am VaR **nichts** | {ref:kap:cvar} |
| „Die Kovarianzmatrix ist eine Eingabe wie jede andere." | Aus kurzen Reihen geschätzt ist sie schlecht konditioniert; die Optimierung setzt dann auf Schätzrauschen | {ref:kap:finanzdaten} |
## Drei Fehler, die dieser Teil verhindert
1. **Bei Markowitz einsteigen.** Wer {ref:kap:finanzdaten} überspringt, optimiert seine
eigenen Schätzfehler und wundert sich über Gewichte, die niemand halten würde.
2. **Den VaR für ein Risikomaß halten.** Er sagt, **ob** die Schwelle gerissen wird, nicht
**wie schlimm** es dahinter aussieht — und er ist nicht kohärent. Optimiert wird der
CVaR.
3. **Trägheit unterschätzen.** Im Kraftwerkspark ist nicht die Prognosegüte das Problem,
sondern dass sich ein Block mit acht Stunden Mindestlaufzeit um 18 Uhr nicht mehr
herbeirufen lässt. Dieselbe Trägheit heißt im Depot Transaktionskosten.
## Wenn Sie nur eines mitnehmen
> **🎯** Wer die Struktur erkennt, kann sein Werkzeug mitnehmen, wenn er die Branche
> wechselt. Wer nur die Bibliothek kennt, fängt jedes Mal von vorn an — und wer die Grenzen
> der Analogie nicht kennt, überträgt irgendwann auch das, was nicht überträgt.
---
*Weiter mit:* [{ref:kap:praxisfallen} — Praxisfallen und der Weg zum produktiven Einsatz](50_Praxis.md)

View file

@ -20,6 +20,7 @@
> **Programme:**\
> `Infeasibility_Diagnose.py`\
> `Erklaerbarkeit.py`\
> `Constraint_Attribution.py`\
> `Betriebsueberwachung.py`\
> `or_kern.py`\
> `Solverwechsel_CPSAT_HiGHS.py`
@ -89,15 +90,17 @@ Nach diesem Kapitel können Sie …
1. … die fünf typischen Praxisfallen benennen und je ein Gegenmittel nennen.
2. … ein unlösbares Modell in einen „am wenigsten schlechten“ Plan verwandeln.
3. … Solver-Ergebnisse erklärbar machen (*Explainable OR*).
4. … Zeitlimits und Optimalitätslücken sinnvoll setzen.
5. … die Architektur einer produktionsreifen OR-Plattform skizzieren.
6. … eine **modellunabhängige Abnahmeprüfung** schreiben, die eine Lösung gegen die
4. … für jede Nebenbedingung beziffern, was sie den Plan kostet — und begründen, warum
der Schattenpreis diese Frage nur für die *nächste* Einheit beantwortet.
5. … Zeitlimits und Optimalitätslücken sinnvoll setzen.
6. … die Architektur einer produktionsreifen OR-Plattform skizzieren.
7. … eine **modellunabhängige Abnahmeprüfung** schreiben, die eine Lösung gegen die
Anforderungen prüft statt gegen das Modell.
7. … Status, Gap und Zeitausschöpfung protokollieren und begründen, warum eine konstante
8. … Status, Gap und Zeitausschöpfung protokollieren und begründen, warum eine konstante
Laufzeit bei Zeitlimit ein Warnsignal ist.
8. … ein Modell in Domänenschicht, Modellbauer und Lösungs-DTO trennen — und begründen,
9. … ein Modell in Domänenschicht, Modellbauer und Lösungs-DTO trennen — und begründen,
warum sich das nicht wegen des Solverwechsels lohnt, sondern wegen der Prüfbarkeit.
9. … die Statuswerte verschiedener Solverbibliotheken auf eine gemeinsame Sprache
10. … die Statuswerte verschiedener Solverbibliotheken auf eine gemeinsame Sprache
abbilden.
---
@ -373,6 +376,10 @@ technisch aus, aber selbst wenn er dürfte, wäre er wegen seiner Vorbelastung i
teurer als Anna. **Eine bindende Bedingung zu lockern hilft nur, wenn sie tatsächlich die
kostenrelevante ist** — sonst ist die Mühe umsonst.
Derselbe Unterschied kehrt auf Modellebene wieder, und dort lässt er sich in Euro
beziffern: {ref:sec:praxisfallen-attribution} misst für jede Bedingung, was sie den
ganzen Plan kostet — und baut daraus einen Bericht, den man vorlegen kann.
### Falle 3 — Regimewechsel und Schätzfehler
Ein Modell, das auf einer mehrjährigen Aufwärtsphase kalibriert wurde, kennt keine
@ -415,6 +422,500 @@ wächst nicht linear mit der Problemgröße.
---
## Constraint Attribution: welche Bedingung kostet wie viel? {#sec:praxisfallen-attribution}
Der Constraint-Trace beantwortet „warum **diese** Zuweisung?“. Der Deletion Filter in
{ref:anhang:fehlerdiagnose} beantwortet „warum geht es **gar nicht**?“. Der häufigste Fall
in der Praxis liegt dazwischen, und für ihn hat bisher niemand ein Werkzeug bekommen:
> *Das Modell rechnet. Der Plan ist zulässig. Und trotzdem ist er enttäuschend — nur weiß
> niemand, woran es liegt.*
Die Antwort heißt **Constraint Attribution**{idx:Constraint Attribution}: Jede
Bedingungsgruppe wird einzeln gelockert, und gemessen wird, was sich am Zielwert ändert. Das
ist dasselbe Verfahren wie der Deletion Filter — nur mit **Kosten** statt Zulässigkeit als
Kriterium.
Gerechnet wird auf derselben Fabrik wie die Konfliktsuche in {ref:anhang:fehlerdiagnose},
aber **nach der Reparatur**: Dort war die Lackierkapazität von 150 Stunden der Kern des
Widerspruchs ($2 \cdot 40 + 3 \cdot 30 = 170 > 150$). Eine zweite Schicht hat sie auf 210
gebracht, der Mindestumsatz wurde auf 10 000 € gesenkt. Jetzt ist das Modell lösbar — und
die Frage lautet nicht mehr „warum nicht?“, sondern „was kostet uns was?“.
```python
#!/usr/bin/env python3
# Constraint_Attribution.py
"""
Kapitel Praxisfallen: Welche Bedingung kostet wie viel - und was sagt man
dem Management?
Zwischen den beiden bekannten Faellen klafft eine Luecke:
* Erklaerbarkeit.py beantwortet "warum diese eine Zuweisung?"
* Konfliktsuche.py (Anhang Fehlerdiagnose) beantwortet "warum geht es gar
nicht?"
Der haeufigste Fall in der Praxis liegt dazwischen: Das Modell rechnet, der
Plan ist zulaessig - und trotzdem unbefriedigend. Niemand weiss, WORAN es
liegt. Genau das beantwortet Constraint Attribution.
Gerechnet wird auf derselben Fabrik wie in Konfliktsuche.py, nur nach der
Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des
Widerspruchs (2*40 + 3*30 = 170 > 150). Eine zweite Schicht hat sie auf 210
gebracht, und der Mindestumsatz wurde auf 10.000 EUR gesenkt. Jetzt ist das
Modell loesbar - und die Frage lautet nicht mehr "warum nicht?", sondern
"was kostet uns was?".
Gezeigt werden vier Dinge:
1. Welche Bedingungen binden - und dass "bindend" nicht "teuer" heisst.
2. Dass der Schattenpreis eine MOMENTAUFNAHME ist: hochgerechnet auf eine
realistische Sonderschicht verspricht er hier das Doppelte des
tatsaechlichen Nutzens.
3. Dass ein Wunsch aus dem Vertrieb nicht teuer, sondern unmoeglich sein
kann - und welche Bedingungen ihn gemeinsam blockieren.
4. Wie aus alledem ein Bericht in Alltagssprache wird.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import textwrap
import numpy as np
from scipy.optimize import linprog
PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]
DECKUNGSBEITRAG = np.array([35.0, 28.0, 9.0, 22.0, 6.0]) # EUR je Stueck
# Dieselben Bedingungen wie in Konfliktsuche.py, mit den beiden Reparaturen.
# Der sprechende Name ist keine Kosmetik: Ohne ihn ist kein Bericht moeglich,
# der jemandem ausserhalb der Modellierung etwas sagt.
BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
("Kapazitaet Montage", [3, 2, 1, 4, 2], "<=", 400),
("Kapazitaet Lackieren", [2, 3, 0, 1, 0], "<=", 210),
("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], ">=", 10000),
("Sortimentsbreite", [0, 0, 1, 1, 1], ">=", 100),
("Lackierbudget Schicht 2", [0, 1, 0, 1, 0], "<=", 55),
]
# Was liesse sich im Ernstfall wirklich bewegen - und um wie viel? Nicht "eine
# Einheit", sondern der Schritt, den ein Planer tatsaechlich gehen kann: eine
# Sonderschicht, ein nachverhandelter Vertrag. Genau an dieser Groesse
# scheitert das blosse Hochrechnen des Schattenpreises.
STELLSCHRAUBEN: dict[str, tuple[float, str]] = {
"Kapazitaet Montage": (+50, "eine Sonderschicht Montage"),
"Kapazitaet Lackieren": (+30, "eine Sonderschicht Lackieren"),
"Kapazitaet Pruefung": (+50, "eine Sonderschicht Pruefung"),
"Lackierbudget Schicht 2": (+10, "10 Stunden mehr Budget in Schicht 2"),
"Liefervertrag Traeger": (-10, "10 Stueck weniger Abnahmepflicht Traeger"),
"Liefervertrag Gehaeuse": (-10, "10 Stueck weniger Abnahmepflicht Gehaeuse"),
}
WUNSCH_MENGE = 55.0 # der Vertrieb haette gern mehr Rahmen
WUNSCH_PRODUKT = "Rahmen"
def loese(bedingungen: list[tuple[str, list[float], str, float]]):
"""Maximiert den Deckungsbeitrag. linprog minimiert, daher das Vorzeichen."""
matrix, rechte_seite = [], []
for _, koeffizienten, richtung, grenze in bedingungen:
zeile = np.array(koeffizienten, dtype=float)
matrix.append(zeile if richtung == "<=" else -zeile)
rechte_seite.append(grenze if richtung == "<=" else -grenze)
return linprog(-DECKUNGSBEITRAG,
A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
bounds=[(0, None)] * len(PRODUKTE), method="highs")
def geaendert(name: str, delta: float,
basis=None) -> list[tuple[str, list[float], str, float]]:
"""Dieselben Bedingungen, eine davon um delta verschoben."""
return [(n, k, r, g + delta if n == name else g)
for n, k, r, g in (basis or BEDINGUNGEN)]
def auswertung(ergebnis, bedingungen=None):
"""Schlupf und Schattenpreis je Bedingung."""
x = ergebnis.x
dual = ergebnis.ineqlin.marginals
zeilen = []
for i, (name, koeff, richtung, grenze) in enumerate(bedingungen or BEDINGUNGEN):
links = float(np.array(koeff, dtype=float) @ x)
schlupf = (grenze - links) if richtung == "<=" else (links - grenze)
zeilen.append((name, links, richtung, grenze, schlupf, abs(dual[i])))
return zeilen
def blockierer(bedingungen, zusatz) -> list[str]:
"""Welche Bedingungen verhindern 'zusatz' - einzeln weggelassen.
Dasselbe Vorgehen wie der Deletion Filter in Konfliktsuche.py, nur mit
umgekehrtem Vorzeichen: Dort wird gesucht, was den Widerspruch AUSMACHT,
hier, was einen Wunsch VERHINDERT. Wird das Modell ohne eine Bedingung
loesbar, gehoert sie zum Konflikt.
"""
gefunden = []
for i, (name, _, _, _) in enumerate(bedingungen):
rest = [c for j, c in enumerate(bedingungen) if j != i] + [zusatz]
if loese(rest).success:
gefunden.append(name)
return gefunden
def euro(betrag: float) -> str:
return f"{betrag:,.2f} EUR"
if __name__ == "__main__":
basis = loese(BEDINGUNGEN)
db_basis = -basis.fun
plan = dict(zip(PRODUKTE, basis.x))
print("=" * 78)
print(" CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?")
print("=" * 78)
print(f"Status: {basis.message.split('(')[0].strip()}")
print(f"Deckungsbeitrag des Plans: {euro(db_basis)}\n")
print(" " + " ".join(f"{p}: {m:.1f}" for p, m in plan.items()))
# --- 1. Wer begrenzt den Plan? -----------------------------------------
print("\n" + "=" * 78)
print(" (1) Welche Bedingungen binden - und was kostet die naechste Einheit?")
print("=" * 78)
print(f"{'Bedingung':<24} {'genutzt':>9} {'Grenze':>7} {'Schlupf':>8} "
f"{'Schattenpreis':>11}")
print("-" * 78)
bindend = []
for name, links, richtung, grenze, schlupf, dual in auswertung(basis):
marke = ""
if abs(schlupf) < 1e-6:
bindend.append((name, dual))
marke = " <-- bindend"
print(f"{name:<24} {links:>9.1f} {grenze:>7.0f} {schlupf:>8.1f} "
f"{dual:>11.2f}{marke}")
ohne_preis = [n for n, d in bindend if d < 1e-9]
print(f"\n {len(bindend)} Bedingungen binden. Aber: "
f"{', '.join(ohne_preis)} bindet")
print(" mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn")
print(" zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.")
# --- 2. Der Schattenpreis als Momentaufnahme ---------------------------
print("\n" + "=" * 78)
print(" (2) Was der Schattenpreis verschweigt")
print("=" * 78)
print("Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber")
print("keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:\n")
preise = {name: dual for name, *_, dual in auswertung(basis)}
print(f"{'Stellschraube':<24} {'Preis':>9} {'Schritt':>7} "
f"{'hochgerechnet':>13} {'gemessen':>9} {'Abw.':>9}")
print("-" * 78)
wirkung = {}
for name, (delta, _) in STELLSCHRAUBEN.items():
ergebnis = loese(geaendert(name, delta))
echt = (-ergebnis.fun) - db_basis if ergebnis.success else 0.0
linear = preise[name] * abs(delta)
wirkung[name] = echt
print(f"{name:<24} {preise[name]:>9.2f} {delta:>+7.0f} "
f"{linear:>13.2f} {echt:>9.2f} {echt - linear:>+9.2f}")
# Wo endet der Gueltigkeitsbereich? Schrittweise nachfahren.
abweichler = max(STELLSCHRAUBEN,
key=lambda n: preise[n] * abs(STELLSCHRAUBEN[n][0]) - wirkung[n])
delta_max = STELLSCHRAUBEN[abweichler][0]
grenze_gueltig = 0.0
schritt = delta_max / 60.0
vorher = db_basis
for i in range(1, 61):
d = schritt * i
e = loese(geaendert(abweichler, d))
db = -e.fun if e.success else vorher
if db - vorher > 1e-9:
grenze_gueltig = d
vorher = db
print(f"\n '{abweichler}': hochgerechnet "
f"{preise[abweichler] * abs(delta_max):.2f} EUR, tatsaechlich "
f"{wirkung[abweichler]:.2f} EUR.")
print(f" Der Preis von {preise[abweichler]:.2f} EUR gilt nur bis "
f"+{grenze_gueltig:.0f} Stunden. Jede weitere Stunde")
print(" dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.")
nach_preis = sorted(STELLSCHRAUBEN, key=lambda n: -preise[n])[0]
nach_wirkung = max(wirkung, key=wirkung.get)
print(f"\n Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt")
print(f" '{nach_preis}' ({preise[nach_preis]:.2f} EUR je Einheit),")
print(f" nach tatsaechlicher Wirkung '{nach_wirkung}' "
f"({wirkung[nach_wirkung]:.2f} EUR).")
print(" Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele")
print(" Einheiten zu haben sind.")
# --- 3. Der Wunsch aus dem Vertrieb ------------------------------------
print("\n" + "=" * 78)
print(f" (3) Was-waere-wenn: mindestens {WUNSCH_MENGE:.0f} "
f"{WUNSCH_PRODUKT} statt {plan[WUNSCH_PRODUKT]:.1f}")
print("=" * 78)
wunsch = (f"Wunsch Vertrieb {WUNSCH_PRODUKT}",
[1 if p == WUNSCH_PRODUKT else 0 for p in PRODUKTE], ">=", WUNSCH_MENGE)
mit_wunsch = loese(BEDINGUNGEN + [wunsch])
schuldige: list[str] = []
engpass = noetig = None
wunsch_gratis = False
if mit_wunsch.success:
print(f"Erfuellbar, kostet {euro(db_basis - (-mit_wunsch.fun))}.")
else:
schuldige = blockierer(BEDINGUNGEN, wunsch)
print("Der Wunsch ist nicht teuer - er ist UNMOEGLICH.\n")
print(f"Blockiert wird er von {len(schuldige)} Bedingungen gemeinsam. "
f"Faellt eine davon")
print("weg, ist er erfuellbar; keine allein ist 'der' Grund:")
for name in schuldige:
e = loese([c for c in BEDINGUNGEN if c[0] != name] + [wunsch])
print(f" ohne '{name}': moeglich, Deckungsbeitrag {euro(-e.fun)}")
engpass = next((n for n in schuldige if n in STELLSCHRAUBEN), schuldige[0])
for schritt in range(1, 61):
if loese(geaendert(engpass, schritt) + [wunsch]).success:
noetig = schritt
break
if noetig is not None:
e = loese(geaendert(engpass, noetig) + [wunsch])
ohne = loese(geaendert(engpass, noetig))
wunsch_gratis = abs((-e.fun) - (-ohne.fun)) < 1e-6
print(f"\n Mit +{noetig} bei '{engpass}' wird der Wunsch erfuellbar:")
print(f" Deckungsbeitrag {euro(-e.fun)} "
f"({-e.fun - db_basis:+,.2f} gegenueber heute).")
if wunsch_gratis:
print(f" Und ohne den Wunsch ergaebe dieselbe Lockerung "
f"{euro(-ohne.fun)} bei")
print(f" {ohne.x[PRODUKTE.index(WUNSCH_PRODUKT)]:.0f} "
f"{WUNSCH_PRODUKT} - der Wunsch kostet also NICHTS.")
print(" Er war nie das Problem. Der Engpass war es.")
# --- 4. Der Bericht ----------------------------------------------------
# Aus denselben Zahlen, die oben in Tabellen stehen, wird hier Prosa. Kein
# Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde - das ist der
# ganze Trick und zugleich die Bedingung dafuer, dass der Bericht nicht
# irgendwann leise falsch wird.
print("\n" + "=" * 78)
print(" (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde")
print("=" * 78)
teuerste = sorted(((n, d) for n, d in bindend if d > 0), key=lambda t: -t[1])
gross = STELLSCHRAUBEN[nach_wirkung][1]
klein = STELLSCHRAUBEN[abweichler][1]
absaetze = [
f"Der Plan bringt {euro(db_basis)} Deckungsbeitrag. Begrenzt wird er von "
f"{len(bindend)} Bedingungen, von denen {len(teuerste)} tatsaechlich Geld "
f"kosten; \"{ohne_preis[0]}\" beruehrt den Plan, ohne ihn zu verteuern.",
f"Teuerste Bindung ist \"{teuerste[0][0]}\" mit "
f"{teuerste[0][1]:.2f} EUR je Einheit, gefolgt von \"{teuerste[1][0]}\" "
f"mit {teuerste[1][1]:.2f} EUR.",
f"Die groesste einzelne Verbesserung bringt {gross}: "
f"{euro(wirkung[nach_wirkung])} mehr Deckungsbeitrag. {klein[0].upper()}{klein[1:]} "
f"dagegen bringt nur {euro(wirkung[abweichler])} statt der rechnerischen "
f"{euro(preise[abweichler] * abs(delta_max))} - ab +{grenze_gueltig:.0f} "
f"Stunden begrenzt uns etwas anderes. Wer die ganze Schicht bezahlt, "
f"bezahlt die zweite Haelfte umsonst.",
]
if schuldige and noetig is not None:
namen = ", ".join(f'"{s}"' for s in schuldige)
satz = (f"Zum Wunsch des Vertriebs ({WUNSCH_MENGE:.0f} {WUNSCH_PRODUKT} statt "
f"{plan[WUNSCH_PRODUKT]:.1f}): Er ist mit der heutigen Ausstattung "
f"nicht erfuellbar - nicht aus Kostengruenden, sondern weil {namen} "
f"ihn gemeinsam blockieren. Mit +{noetig} Stunden bei "
f'"{engpass}" wird er moeglich')
satz += (", und er kostet dann nichts: Der Plan waehlt diese Menge von "
"sich aus." if wunsch_gratis else ".")
absaetze.append(satz)
for absatz in absaetze:
print()
print(textwrap.fill(absatz, width=76))
print("\n" + "=" * 78)
```
**Ausgabe:**
```
==============================================================================
CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?
==============================================================================
Status: Optimization terminated successfully.
Deckungsbeitrag des Plans: 3,930.00 EUR
Rahmen: 47.5 Gehaeuse: 30.0 Deckel: 97.5 Traeger: 25.0 Halter: 0.0
==============================================================================
(1) Welche Bedingungen binden - und was kostet die naechste Einheit?
==============================================================================
Bedingung genutzt Grenze Schlupf Schattenpreis
------------------------------------------------------------------------------
Kapazitaet Montage 400.0 400 0.0 9.00 <-- bindend
Kapazitaet Lackieren 210.0 210 0.0 4.00 <-- bindend
Kapazitaet Pruefung 200.0 300 100.0 0.00
Liefervertrag Rahmen 47.5 40 7.5 0.00
Liefervertrag Gehaeuse 30.0 30 0.0 2.00 <-- bindend
Liefervertrag Deckel 97.5 20 77.5 0.00
Liefervertrag Traeger 25.0 25 0.0 18.00 <-- bindend
Marktgrenze Rahmen 47.5 120 72.5 0.00
Marktgrenze Gehaeuse 30.0 90 60.0 0.00
Marktgrenze Deckel 97.5 150 52.5 0.00
Marktgrenze Halter 0.0 200 200.0 0.00
Mindestumsatz 10800.0 10000 800.0 0.00
Sortimentsbreite 122.5 100 22.5 0.00
Lackierbudget Schicht 2 55.0 55 0.0 0.00 <-- bindend
5 Bedingungen binden. Aber: Lackierbudget Schicht 2 bindet
mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn
zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.
==============================================================================
(2) Was der Schattenpreis verschweigt
==============================================================================
Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber
keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:
Stellschraube Preis Schritt hochgerechnet gemessen Abw.
------------------------------------------------------------------------------
Kapazitaet Montage 9.00 +50 450.00 450.00 +0.00
Kapazitaet Lackieren 4.00 +30 120.00 60.00 -60.00
Kapazitaet Pruefung 0.00 +50 0.00 0.00 +0.00
Lackierbudget Schicht 2 0.00 +10 0.00 0.00 +0.00
Liefervertrag Traeger 18.00 -10 180.00 180.00 +0.00
Liefervertrag Gehaeuse 2.00 -10 20.00 18.00 -2.00
'Kapazitaet Lackieren': hochgerechnet 120.00 EUR, tatsaechlich 60.00 EUR.
Der Preis von 4.00 EUR gilt nur bis +15 Stunden. Jede weitere Stunde
dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.
Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt
'Liefervertrag Traeger' (18.00 EUR je Einheit),
nach tatsaechlicher Wirkung 'Kapazitaet Montage' (450.00 EUR).
Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele
Einheiten zu haben sind.
==============================================================================
(3) Was-waere-wenn: mindestens 55 Rahmen statt 47.5
==============================================================================
Der Wunsch ist nicht teuer - er ist UNMOEGLICH.
Blockiert wird er von 3 Bedingungen gemeinsam. Faellt eine davon
weg, ist er erfuellbar; keine allein ist 'der' Grund:
ohne 'Kapazitaet Lackieren': moeglich, Deckungsbeitrag 3,990.00 EUR
ohne 'Liefervertrag Gehaeuse': moeglich, Deckungsbeitrag 3,948.00 EUR
ohne 'Liefervertrag Traeger': moeglich, Deckungsbeitrag 4,320.00 EUR
Mit +15 bei 'Kapazitaet Lackieren' wird der Wunsch erfuellbar:
Deckungsbeitrag 3,990.00 EUR (+60.00 gegenueber heute).
Und ohne den Wunsch ergaebe dieselbe Lockerung 3,990.00 EUR bei
55 Rahmen - der Wunsch kostet also NICHTS.
Er war nie das Problem. Der Engpass war es.
==============================================================================
(4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde
==============================================================================
Der Plan bringt 3,930.00 EUR Deckungsbeitrag. Begrenzt wird er von 5
Bedingungen, von denen 4 tatsaechlich Geld kosten; "Lackierbudget Schicht 2"
beruehrt den Plan, ohne ihn zu verteuern.
Teuerste Bindung ist "Liefervertrag Traeger" mit 18.00 EUR je Einheit,
gefolgt von "Kapazitaet Montage" mit 9.00 EUR.
Die groesste einzelne Verbesserung bringt eine Sonderschicht Montage: 450.00
EUR mehr Deckungsbeitrag. Eine Sonderschicht Lackieren dagegen bringt nur
60.00 EUR statt der rechnerischen 120.00 EUR - ab +15 Stunden begrenzt uns
etwas anderes. Wer die ganze Schicht bezahlt, bezahlt die zweite Haelfte
umsonst.
Zum Wunsch des Vertriebs (55 Rahmen statt 47.5): Er ist mit der heutigen
Ausstattung nicht erfuellbar - nicht aus Kostengruenden, sondern weil
"Kapazitaet Lackieren", "Liefervertrag Gehaeuse", "Liefervertrag Traeger"
ihn gemeinsam blockieren. Mit +15 Stunden bei "Kapazitaet Lackieren" wird er
moeglich, und er kostet dann nichts: Der Plan waehlt diese Menge von sich
aus.
==============================================================================
```
### Drei Befunde, und keiner steht im Solverergebnis
**Erstens: bindend heißt nicht teuer.** Fünf Bedingungen binden, aber „Lackierbudget
Schicht 2“ tut es mit einem Schattenpreis von **0,00 €**. Sie berührt den Plan, ohne ihn zu
verteuern — wer sie nachverhandelt, gewinnt nichts. Das ist derselbe Unterschied, den
`Erklaerbarkeit.py` oben an einer einzelnen Zuweisung zeigt, hier im Dualwert eines LP.
**Zweitens: Der Schattenpreis ist eine Momentaufnahme.** Er gilt für die **nächste**
Einheit. Ein Planer kauft aber keine Einheit, sondern eine Schicht. Für die Lackiererei
verspricht die Hochrechnung $4{,}00 \cdot 30 = 120$ €; gemessen kommen **60 €**. Der Preis
von 4,00 € gilt exakt bis **+15 Stunden**, danach begrenzt etwas anderes — **die zweite
Hälfte der Sonderschicht wäre bezahlt und wirkungslos.**
Und die Ranglisten drehen sich dabei um: Nach Schattenpreis führt der Trägervertrag mit
18,00 € je Stück, nach tatsächlicher Wirkung die Montage-Sonderschicht mit 450 €. Der
Schattenpreis sagt, was **eine** Einheit wert ist — nicht, wie viele Einheiten zu haben
sind. Für die Frage „welchen Hebel ziehen wir?“ ist das der entscheidende Unterschied.
**Drittens: Ein Wunsch kann unmöglich statt teuer sein.** Der Vertrieb hätte gern
55 Rahmen statt 47,5. Das Modell antwortet nicht mit einem Preis, sondern mit
`INFEASIBLE` — und drei Bedingungen blockieren den Wunsch **gemeinsam**: Fällt eine davon
weg, ist er erfüllbar, keine allein ist „der“ Grund. Genau die Struktur, die
{ref:anhang:fehlerdiagnose} für den unlösbaren Fall beschreibt, hier auf einen Wunsch
angewandt.
Die Pointe steht am Ende: Mit +15 Stunden Lackierkapazität wird der Wunsch erfüllbar — und
kostet dann **nichts**. Der Plan wählt diese Menge von sich aus, sobald der Engpass weg ist.
Der Konflikt bestand nie zwischen Vertrieb und Produktion. Er bestand zwischen dem Vertrieb
und der Lackiererei, und niemand wusste das.
> **🎯 Die drei Fragen, jetzt auf Modellebene**
> Die Faustregel oben stellt sie für eine einzelne Zuweisung. Constraint Attribution stellt
> dieselben drei für den **ganzen Plan**:
> 1. **Warum dieser Zielwert?** — welche Bedingungen binden, und welche davon kosten Geld.
> 2. **Warum nicht besser?** — was eine realistische Lockerung wirklich bringt, gemessen
> statt hochgerechnet.
> 3. **Was ist mit meinem Wunsch?** — erfüllbar und zu welchem Preis, oder unmöglich und
> warum.
### Vom Zahlenwerk zur Vorlage
Alle drei Antworten stehen bis hierhin in Tabellen. Tabellen liest niemand in einer
Abteilungsleiterrunde. Teil (4) des Programms baut aus **denselben** Zahlen einen Text —
und das ist weniger trivial, als es aussieht:
* **Kein Satz enthält eine Zahl, die nicht vorher gerechnet wurde.** Auch nicht die
Rangfolge, auch nicht die Namen der blockierenden Bedingungen. Sobald sich das Modell
ändert, ändert sich der Bericht mit — er kann nicht leise falsch werden.
* **Der sprechende Name jeder Bedingung ist die Voraussetzung dafür.** `A_ub[7]` ergibt
keinen Satz. Deshalb tragen die Bedingungen in beiden Programmen einen Namen, und deshalb
ist das keine Kosmetik.
> **⚠️ Was ein solcher Bericht nicht leistet**
> Er erklärt das **Modell**, nicht die **Wirklichkeit**. „Liefervertrag Träger kostet 18 €
> je Stück“ heißt: *in diesem Modell, mit diesen Deckungsbeiträgen*. Ist ein
> Deckungsbeitrag falsch geschätzt, ist der Bericht überzeugend **und** falsch — die
> gefährlichste Kombination. Ein generierter Text macht ein Modell nicht richtiger, nur
> schwerer anzuzweifeln. Deshalb gehört zu jedem solchen Bericht die Angabe, worauf er
> beruht.
---
## Architektur einer produktionsreifen OR-Plattform {#sec:praxisfallen-architektur-einer-produktionsreifen-or-plattform}
![Architektur einer produktionsreifen OR-Plattform](bilder_04/kap15_or_plattform.svg)
@ -1165,9 +1666,9 @@ Benoetigt: numpy, pydantic, ortools, highspy (jeweils im eigenen Prozess)
from __future__ import annotations
import subprocess
import sys
import multiprocessing
import time
from concurrent.futures import ProcessPoolExecutor
import numpy as np
from pydantic import BaseModel, Field, model_validator
@ -1370,25 +1871,29 @@ def geoeffnete_lager(problem: Standortproblem, loesung: Loesung) -> list[str]:
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])
def loese_in_eigenem_prozess(name: str, problem: Standortproblem) -> Loesung:
"""Laesst genau einen Modellbauer in einem frischen Prozess rechnen.
'spawn' statt des Linux-Standards 'fork': Der Kindprozess startet mit
einem leeren Interpreter und importiert nur den Solver, den SEIN
Modellbauer braucht. max_tasks_per_child=1 sorgt dafuer, dass der Pool
seinen Arbeiter nicht wiederverwendet - sonst saessen beim zweiten Aufruf
wieder beide Bibliotheken im selben Prozess.
Hin und zurueck wandert das Domaenenmodell bzw. das Loesungs-DTO. Beide
kennen keinen Solver, sind also serialisierbar - genau dafuer sind sie da.
"""
with ProcessPoolExecutor(
max_workers=1,
mp_context=multiprocessing.get_context("spawn"),
max_tasks_per_child=1) as pool:
return pool.submit(MODELLBAUER[name], problem).result(timeout=300)
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 -------------
# --- Beide Solver anstossen und vergleichen ---------------------------
print("=" * 82)
print(" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE")
print("=" * 82)
@ -1400,7 +1905,7 @@ if __name__ == "__main__":
loesungen: dict[str, Loesung] = {}
for name, beschriftung in [("cpsat", "OR-Tools CP-SAT"),
("highs", "HiGHS (highspy)")]:
loesung = loesungen[name] = loese_in_eigenem_prozess(name)
loesung = loesungen[name] = loese_in_eigenem_prozess(name, problem)
beanstandungen = pruefe_zuordnung(problem, loesung)
print(f"{beschriftung}")
@ -1607,7 +2112,8 @@ Echtzeit, (b) Wochendienstplan, freitags erstellt, (c) Jahresproduktionsplanung,
(d) Portfolio-Rebalancing monatlich.
**Aufgabe ⭐⭐ — Relaxation einbauen.**
Nehmen Sie Ihr Modell aus Übung 6.7 (Wochendienstplan) und machen Sie es
Nehmen Sie Ihr Modell aus der Aufgabe *Eigener Dienstplan*
({ref:sec:cpsat-uebungsaufgaben}) und machen Sie es
`INFEASIBLE`-sicher: Schlupfvariablen für unbesetzte Schichten, gestaffelte Strafen. Testen
Sie mit einem bewusst überlasteten Szenario.
@ -1615,6 +2121,17 @@ Sie mit einem bewusst überlasteten Szenario.
Erweitern Sie ein beliebiges Modell aus dem Kurs um einen `erklaere(loesung)`-Report, der
die drei Fragen aus {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen} beantwortet.
**Aufgabe ⭐⭐ — Den Bericht in die Irre führen.**
`Constraint_Attribution.py` erzeugt seinen Bericht aus gerechneten Zahlen — richtig ist er
deshalb noch nicht.
(a) Setzen Sie den Deckungsbeitrag von *Deckel* von 9 € auf 20 €. Wie ändern sich Plan,
Rangliste und Bericht? Welche Sätze bleiben wörtlich stehen, obwohl sie jetzt etwas
anderes bedeuten?
(b) Der Bericht nennt „Liefervertrag Träger" als teuerste Bindung. Formulieren Sie den
Satz so um, dass ein Leser erkennt, **worauf** die Aussage beruht.
(c) Welche Angabe müsste das Programm zusätzlich ausgeben, damit ein Empfänger die
Belastbarkeit selbst einschätzen kann?
**Aufgabe ⭐⭐⭐ — Gap gegen Laufzeit.**
Nehmen Sie das MILP-Portfolio aus {ref:kap:milp} und vergrößern Sie es auf 50 Anlagen. Messen
Sie Laufzeit und Zielwert bei Gap-Vorgaben von 10 %, 5 %, 2 %, 1 %, 0,1 % und 0 %.
@ -1637,7 +2154,7 @@ eigene Projekte ab?
Falle 5 — die Laufzeitexplosion — zeigt sich im Betrieb selten als Absturz. Sie zeigt
sich als **gar nichts**.
> **🐛 Finde den Denkfehler 15.1: Das Modell, das seit einem Jahr nicht mehr optimiert**
> **🐛 Finde den Denkfehler: Das Modell, das seit einem Jahr nicht mehr optimiert**
>
> Ein Standortplanungsmodell geht 2024 in Betrieb. Es läuft jede Nacht, hat drei Sekunden
> Zeitbudget und liefert zuverlässig die Kosten des Tagesplans. Das Betriebsteam
@ -1924,7 +2441,7 @@ sichtbar wird, und gibt Zeit zum Handeln.
## Micro-Quiz {#sec:praxisfallen-quiz}
> **❓ Micro-Quiz 15: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-praxisfallen).
@ -1962,6 +2479,10 @@ sichtbar wird, und gibt Zeit zum Handeln.
3. Warum ist ein MIP-Gap von 2 % in der Praxis meist ausreichend?
4. Was ist das Snapshot-Prinzip und wozu dient es?
5. Was ist der häufigste Grund, aus dem OR-Projekte scheitern?
6. Eine Bedingung bindet, ihr Schattenpreis ist 0. Was heißt das — und was folgt daraus für
eine Nachverhandlung?
7. Warum kann die Rangfolge nach Schattenpreis eine andere sein als die Rangfolge nach
tatsächlichem Nutzen einer Lockerung?
---
@ -1971,6 +2492,16 @@ sichtbar wird, und gibt Zeit zum Handeln.
erhält irgendwann eine Fehlermeldung statt eines Plans.
* **Erklärbarkeit entscheidet über Akzeptanz.** Schattenpreise, Kostenzerlegung und
Constraint-Traces sind keine Kür.
* **Bindend heißt nicht teuer.** Eine Bedingung kann den Plan berühren und trotzdem
0,00 € kosten. Nur die kostenrelevanten lohnen eine Nachverhandlung.
* **Der Schattenpreis gilt für die nächste Einheit, nicht für die nächste Schicht.**
Hochgerechnet auf einen realistischen Schritt überschätzt er den Nutzen — im Beispiel um
das Doppelte. Wer wissen will, was ein Hebel bringt, **misst** die Lockerung, statt zu
multiplizieren; die beiden Ranglisten können sich dabei umdrehen.
* **Ein Wunsch kann unmöglich statt teuer sein** — und dann ist die richtige Antwort nicht
ein Preis, sondern die Liste der Bedingungen, die ihn gemeinsam blockieren.
* **Ein generierter Bericht macht ein Modell nicht richtiger, nur schwerer anzuzweifeln.**
Er erklärt das Modell, nicht die Wirklichkeit — deshalb gehört dazu, worauf er beruht.
* **Optimieren Sie nicht genauer als Ihre Daten.** Ein Gap von 2 % ist bei ±10 %
Datenunsicherheit irrelevant.
* **Der Optimierer schlägt vor, der Mensch entscheidet** — jedenfalls anfangs.
@ -1989,4 +2520,4 @@ sichtbar wird, und gibt Zeit zum Handeln.
---
*Weiter mit:* [Projektwerkstatt — acht eigene Anwendungen](59_Projektwerkstatt.md)
*Weiter mit:* [{ref:kap:testing} — Testen, Messen, Ausliefern](51_Testing_und_Deployment.md)

View file

@ -787,113 +787,124 @@ Benoetigt: numpy; in den Kindprozessen scipy, highspy, ortools, cvxpy
from __future__ import annotations
import json
import subprocess
import sys
import textwrap
import multiprocessing
import resource
import time
from concurrent.futures import ProcessPoolExecutor
import numpy as np
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
# Instanz und Speichermessung stehen als gewoehnliche Funktionen hier - nicht
# in einem String, den ein Kindprozess ausfuehrt. Jede Messfunktion baut die
# Instanz aus derselben Saat neu auf, damit ueber die Prozessgrenze nichts
# reist, was das Ergebnis verfaelschen koennte.
def instanz(m, n):
def instanz(m: int, n: int):
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
def speicher_mb() -> float:
# ru_maxrss ist unter Linux in Kilobyte. Gemessen wird der Kindprozess -
# deshalb muss jede Messung einen eigenen bekommen.
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_scipy(m: int, n: int):
from scipy.optimize import linprog
kosten, angebot, bedarf = instanz(m, n)
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
return float(r.fun), 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
def messe_highspy(m: int, n: int):
import highspy
kosten, angebot, bedarf = instanz(m, n)
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
return h.getInfo().objective_function_value, aufbau, loesen, speicher_mb()
def messe_ortools(m: int, n: int):
from ortools.linear_solver import pywraplp
kosten, angebot, bedarf = instanz(m, n)
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
return s.Objective().Value(), aufbau, loesen, speicher_mb()
def messe_cvxpy(m: int, n: int):
import cvxpy as cp
kosten, angebot, bedarf = instanz(m, n)
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
return float(problem.value), aufbau, loesen, speicher_mb()
ANSAETZE = {"scipy.linprog": messe_scipy, "highspy": messe_highspy,
"ortools/GLOP": messe_ortools, "cvxpy": messe_cvxpy}
def messe(funktion, m: int, n: int):
"""Fuehrt eine Messfunktion in einem FRISCHEN Prozess aus.
'spawn' und max_tasks_per_child=1 zusammen garantieren, was Regel 1
verlangt: Jede Messung sieht einen leeren Interpreter. Ohne das
zweite wuerde der Pool seinen Arbeiter wiederverwenden - dann waere
der Speicherwert der zweiten Bibliothek um die erste zu hoch, und
ortools und highspy saessen im selben Prozess.
"""
with ProcessPoolExecutor(
max_workers=1,
mp_context=multiprocessing.get_context("spawn"),
max_tasks_per_child=1) as pool:
try:
return pool.submit(funktion, m, n).result(timeout=600), None
except Exception as fehler:
return None, str(fehler).strip().splitlines()[-1][:60]
if __name__ == "__main__":
@ -911,8 +922,8 @@ if __name__ == "__main__":
f"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}")
print(" " + "-" * 72)
zielwerte = {}
for name, quelltext in ANSAETZE.items():
werte, fehler = messe(name, quelltext, m, n)
for name, funktion in ANSAETZE.items():
werte, fehler = messe(funktion, m, n)
if werte is None:
print(f" {name:<16} nicht verfuegbar: {fehler}")
continue
@ -968,28 +979,28 @@ die Zielwerte und ihr Verhaeltnis zueinander nicht.
--- 10 Lager x 10 Kunden = 100 Variablen ---------------------------------------------------
Bibliothek Zielwert Aufbau Loesen Anteil Speicher
------------------------------------------------------------------------
scipy.linprog 13,509.48 0.000s 0.004s 1% 78 MB
highspy 13,509.48 0.001s 0.002s 34% 41 MB
ortools/GLOP 13,509.48 0.002s 0.001s 80% 54 MB
scipy.linprog 13,509.48 0.000s 0.004s 1% 79 MB
highspy 13,509.48 0.001s 0.002s 36% 44 MB
ortools/GLOP 13,509.48 0.003s 0.001s 80% 56 MB
cvxpy 13,509.48 0.001s 0.009s 9% 229 MB
Spannweite der Zielwerte: 1.33e-06 (relativ 9.8e-11)
--- 32 Lager x 32 Kunden = 1,024 Variablen -------------------------------------------------
Bibliothek Zielwert Aufbau Loesen Anteil Speicher
------------------------------------------------------------------------
scipy.linprog 35,744.25 0.000s 0.009s 4% 80 MB
highspy 35,744.25 0.004s 0.005s 47% 42 MB
ortools/GLOP 35,744.25 0.015s 0.002s 85% 55 MB
cvxpy 35,744.25 0.001s 0.016s 5% 230 MB
scipy.linprog 35,744.25 0.000s 0.009s 3% 81 MB
highspy 35,744.25 0.004s 0.005s 44% 45 MB
ortools/GLOP 35,744.25 0.014s 0.002s 85% 58 MB
cvxpy 35,744.25 0.001s 0.017s 5% 231 MB
Spannweite der Zielwerte: 9.54e-05 (relativ 2.7e-09)
--- 100 Lager x 100 Kunden = 10,000 Variablen ----------------------------------------------
Bibliothek Zielwert Aufbau Loesen Anteil Speicher
------------------------------------------------------------------------
scipy.linprog 72,220.34 0.002s 0.070s 3% 120 MB
highspy 72,220.34 0.029s 0.034s 45% 47 MB
ortools/GLOP 72,220.34 0.123s 0.036s 77% 66 MB
cvxpy 72,220.34 0.001s 0.103s 1% 242 MB
scipy.linprog 72,220.34 0.004s 0.072s 5% 122 MB
highspy 72,220.34 0.026s 0.032s 45% 50 MB
ortools/GLOP 72,220.34 0.141s 0.043s 77% 68 MB
cvxpy 72,220.34 0.001s 0.108s 1% 242 MB
Spannweite der Zielwerte: 3.65e-04 (relativ 5.0e-09)
============================================================================================
@ -1636,3 +1647,7 @@ Test, der eine Instanz einreicht, die das Limit überschreitet.
* Ein **Optimierungsdienst** ist zweistufig: annehmen und prüfen sofort, rechnen im
Hintergrund, Ergebnis mit Status und Gap abholen. Die Frage Threads oder Prozesse gehört
gemessen — bei C++-Solvern genügen Threads, bei Python-Heuristiken nicht.
---
*Weiter mit:* [Synthese Teil V](52_Synthese_Praxis.md)

View file

@ -0,0 +1,48 @@
# Synthese Teil V — Vom rechnenden Modell zum benutzten System
Zwei Kapitel, ein Übergang: Das Modell rechnet — und muss jetzt jemand anderem übergeben
werden. Was dabei schiefgeht, hat selten mit Mathematik zu tun.
## Was schiefgeht, und was dagegen hilft
| Was in Produktion passiert | Gegenmittel | Wo |
| --- | --- | --- |
| `INFEASIBLE` um 3 Uhr nachts, niemand weiß warum | Relaxation mit gestaffelten Strafkosten (B18); Deletion Filter für die Diagnose | {ref:kap:praxisfallen}, {ref:anhang:fehlerdiagnose} |
| Der Disponent lehnt den Plan ab, weil er ihn nicht versteht | Constraint-Trace für die einzelne Zuweisung; Kostenzurechnung für den ganzen Plan | {ref:kap:praxisfallen}, {ref:sec:praxisfallen-attribution} |
| Der Solver läuft ins Zeitlimit und keiner merkt es | Status, Gap und Zeitausschöpfung protokollieren — konstante Laufzeit ist ein Warnsignal | {ref:kap:praxisfallen} |
| Ein Test wird grundlos mal rot, mal grün | auf Zielwert und Regeln prüfen, nicht auf die Gestalt der Lösung; `num_workers = 1` **und** Seed | {ref:sec:cpsat-parallele-suche}, {ref:kap:testing} |
| Ein Ergebnis lässt sich später nicht mehr nachvollziehen | Snapshot-Prinzip: unveränderlicher Datenstand mit ID je Lauf | {ref:kap:praxisfallen} |
| Das Modell prüft sich selbst | Abnahmeprüfung als eigener Baustein, ohne Solver und ohne Modellvariable | {ref:sec:praxisfallen-or-kern} |
## Was dieser Teil gemessen hat
| Behauptung | Gemessen | Wo |
| --- | --- | --- |
| „Der Solver nennt schon den Grund für `INFEASIBLE`." | Er nennt gar nichts. Der Deletion Filter findet einen kleinsten Konflikt — dieses Modell enthält **neun** verschiedene, und welchen man sieht, steuert die Prüfreihenfolge | {ref:anhang:fehlerdiagnose} |
| „Bindende Bedingungen sind die teuren." | Von fünf bindenden Bedingungen kostet eine **0,00 €** | {ref:sec:praxisfallen-attribution} |
| „Der Schattenpreis sagt, was der Hebel bringt." | Hochgerechnet 120 €, gemessen **60 €** — er gilt nur bis +15 Stunden. Die Ranglisten nach Preis und nach Wirkung drehen sich um | {ref:sec:praxisfallen-attribution} |
| „Mehr Arbeiter sind proportional schneller." | **12,3-fach** bei acht Arbeitern — überlinear, weil verschiedene Strategien statt derselben Suche laufen | {ref:sec:cpsat-parallele-suche} |
## Drei Fehler, die dieser Teil verhindert
1. **Alles hart formulieren.** Wer keine Regel brechen lässt, bekommt irgendwann eine
Fehlermeldung statt eines Plans — und zwar nachts, im Batchlauf.
2. **Genauer optimieren als die Daten sind.** Ein Gap von 2 % ist bei ±10 %
Datenunsicherheit bedeutungslos. Die Rechenzeit dafür ist verschenkt.
3. **Einen Bericht für einen Beweis halten.** Ein generierter Text erklärt das **Modell**,
nicht die **Wirklichkeit**. Ist eine Eingabe falsch geschätzt, ist er überzeugend **und**
falsch — die gefährlichste Kombination.
## Wenn Sie nur eines mitnehmen
> **🎯** Ein Modell, das nur auf Ihrem Rechner und nur mit Ihren Daten läuft, ist ein
> Prototyp — kein System. Der Unterschied besteht aus drei Dingen, die alle nichts mit dem
> Solver zu tun haben: einer Abnahmeprüfung, einer Erklärung und einem Protokoll.
**Danach:** die Projektwerkstatt. Elf Aufträge, jeder mit Datenquellen, Modellskizze,
Abnahmekriterien und Stolperfallen — zugeschnitten auf 10 bis 25 Stunden bis zu einem
vorzeigbaren Ergebnis.
---
*Weiter mit:* [Projektwerkstatt](59_Projektwerkstatt.md)

View file

@ -367,4 +367,4 @@ Viel Erfolg.
---
*Weiter mit:* [{ref:anhang:loesungen} — Lösungen](90_Anhang_Loesungen.md)
*Weiter mit:* [{ref:anhang:loesungen} — Lösungen zu allen Übungsaufgaben](90_Anhang_Loesungen.md)

Some files were not shown because too many files have changed in this diff Show more