Compare commits

..
116 changed files with 13723 additions and 27739 deletions

View file

@ -1,28 +0,0 @@
# 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: 36 Kapiteldateien + Build-Skripte
Operations_Research_mit_Python_Version_04/ Quelle: 31 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,7 +33,6 @@ 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
```
@ -67,24 +66,15 @@ 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 — **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:
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:
* Überschriften: `# Kapitel: <Titel> {#kap:<label>}`, `## Titel {#sec:<label>}` — die Nummer
vergibt der Build (`resolve_numbering()`, `nummeriere_abschnitte()`).
* 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}`.
* Aufgaben, Handrechnungen, Abbildungen: `**Aufgabe ⭐ — Titel.**`,
`> **✏️ Handrechnung: Titel**`, `![Bildunterschrift](bilder_04/…)` — Nummern von
`nummeriere_marken()`.
* 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.

View file

@ -1,79 +0,0 @@
# 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,218 +496,6 @@
" 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,8 +455,7 @@
" 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 den Denkfehler im Kapitel Quadratische und\n",
" nichtlineare Optimierung).\n",
" nachgehen muss (siehe Denkfehler 8.1).\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,8 +757,7 @@
"\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",
" # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.\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": [
"## Wie die Isolation aussieht, wenn sie tragen soll\n",
"## Ein System — vier Programmieransätze\n",
"\n",
"`Ein_System_Vier_Ansaetze.py`\n"
]
@ -136,104 +136,85 @@
" 2*x1 + 3*x2 + x3 <= 50\n",
" x >= 0\n",
"\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich\n",
"am Ende.\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich am Ende.\n",
"\n",
"WICHTIG: Jeder Solver laeuft in einem EIGENEN Prozess, weil sich ortools und\n",
"WICHTIG: Jeder Solver läuft 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 multiprocessing\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"ERWARTET = 530.0 # Ergebnis der Handrechnung zum Produktionsprogramm\n",
"\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",
"# 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",
"\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",
"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",
" \"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",
"\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",
"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",
" \"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",
"}\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",
@ -242,23 +223,17 @@
" print(\"-\" * 78)\n",
"\n",
" werte = []\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",
" 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",
"\n",
" print(\"-\" * 78)\n",
" spanne = max(werte) - min(werte)\n",
@ -268,8 +243,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 Prozessstart und Import - sie messen NICHT die\")\n",
" print(\" reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)\")\n",
" print(\"(Die Zeiten enthalten den Prozessstart und den Import - sie messen\")\n",
" print(\" NICHT die reine Solverleistung, siehe Uebung 3.5.)\")\n",
" print(\"=\" * 78)"
]
},

View file

@ -252,328 +252,6 @@
" 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": {},
@ -1148,9 +826,9 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import multiprocessing\n",
"import subprocess\n",
"import sys\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"from pydantic import BaseModel, Field, model_validator\n",
@ -1353,29 +1031,25 @@
" 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, 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",
"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",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" problem = beispielproblem()\n",
"\n",
" # --- Beide Solver anstossen und vergleichen ---------------------------\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",
" print(\"=\" * 82)\n",
" print(\" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE\")\n",
" print(\"=\" * 82)\n",
@ -1387,7 +1061,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, problem)\n",
" loesung = loesungen[name] = loese_in_eigenem_prozess(name)\n",
" beanstandungen = pruefe_zuordnung(problem, loesung)\n",
"\n",
" print(f\"{beschriftung}\")\n",

View file

@ -593,124 +593,113 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import multiprocessing\n",
"import resource\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"\n",
"GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen\n",
"\n",
"\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",
"# 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",
"\n",
"def instanz(m: int, n: int):\n",
"def instanz(m, n):\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",
"\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",
"def speicher_mb():\n",
" # ru_maxrss ist unter Linux in Kilobyte\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",
"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",
"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",
"\n",
"\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",
"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",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
@ -728,8 +717,8 @@
" f\"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}\")\n",
" print(\" \" + \"-\" * 72)\n",
" zielwerte = {}\n",
" for name, funktion in ANSAETZE.items():\n",
" werte, fehler = messe(funktion, m, n)\n",
" for name, quelltext in ANSAETZE.items():\n",
" werte, fehler = messe(name, quelltext, m, n)\n",
" if werte is None:\n",
" print(f\" {name:<16} nicht verfuegbar: {fehler}\")\n",
" continue\n",

View file

@ -496,218 +496,6 @@
" 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,8 +455,7 @@
" 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 den Denkfehler im Kapitel Quadratische und\n",
" nichtlineare Optimierung).\n",
" nachgehen muss (siehe Denkfehler 8.1).\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,8 +757,7 @@
"\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",
" # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.\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": [
"## Wie die Isolation aussieht, wenn sie tragen soll\n",
"## Ein System — vier Programmieransätze\n",
"\n",
"`Ein_System_Vier_Ansaetze.py`\n"
]
@ -136,104 +136,85 @@
" 2*x1 + 3*x2 + x3 <= 50\n",
" x >= 0\n",
"\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich\n",
"am Ende.\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich am Ende.\n",
"\n",
"WICHTIG: Jeder Solver laeuft in einem EIGENEN Prozess, weil sich ortools und\n",
"WICHTIG: Jeder Solver läuft 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 multiprocessing\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"ERWARTET = 530.0 # Ergebnis der Handrechnung zum Produktionsprogramm\n",
"\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",
"# 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",
"\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",
"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",
" \"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",
"\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",
"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",
" \"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",
"}\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",
@ -242,23 +223,17 @@
" print(\"-\" * 78)\n",
"\n",
" werte = []\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",
" 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",
"\n",
" print(\"-\" * 78)\n",
" spanne = max(werte) - min(werte)\n",
@ -268,8 +243,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 Prozessstart und Import - sie messen NICHT die\")\n",
" print(\" reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)\")\n",
" print(\"(Die Zeiten enthalten den Prozessstart und den Import - sie messen\")\n",
" print(\" NICHT die reine Solverleistung, siehe Uebung 3.5.)\")\n",
" print(\"=\" * 78)"
]
},

View file

@ -252,328 +252,6 @@
" 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": {},
@ -1148,9 +826,9 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import multiprocessing\n",
"import subprocess\n",
"import sys\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"from pydantic import BaseModel, Field, model_validator\n",
@ -1353,29 +1031,25 @@
" 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, 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",
"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",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" problem = beispielproblem()\n",
"\n",
" # --- Beide Solver anstossen und vergleichen ---------------------------\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",
" print(\"=\" * 82)\n",
" print(\" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE\")\n",
" print(\"=\" * 82)\n",
@ -1387,7 +1061,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, problem)\n",
" loesung = loesungen[name] = loese_in_eigenem_prozess(name)\n",
" beanstandungen = pruefe_zuordnung(problem, loesung)\n",
"\n",
" print(f\"{beschriftung}\")\n",

View file

@ -593,124 +593,113 @@
"\n",
"from __future__ import annotations\n",
"\n",
"import multiprocessing\n",
"import resource\n",
"import time\n",
"from concurrent.futures import ProcessPoolExecutor\n",
"\n",
"import numpy as np\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"\n",
"GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen\n",
"\n",
"\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",
"# 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",
"\n",
"def instanz(m: int, n: int):\n",
"def instanz(m, n):\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",
"\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",
"def speicher_mb():\n",
" # ru_maxrss ist unter Linux in Kilobyte\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",
"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",
"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",
"\n",
"\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",
"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",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
@ -728,8 +717,8 @@
" f\"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}\")\n",
" print(\" \" + \"-\" * 72)\n",
" zielwerte = {}\n",
" for name, funktion in ANSAETZE.items():\n",
" werte, fehler = messe(funktion, m, n)\n",
" for name, quelltext in ANSAETZE.items():\n",
" werte, fehler = messe(name, quelltext, 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", "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"];
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"];

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

File diff suppressed because it is too large Load diff

Before

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,124 +33,113 @@ Benoetigt: numpy; in den Kindprozessen scipy, highspy, ortools, cvxpy
from __future__ import annotations
import multiprocessing
import resource
import time
from concurrent.futures import ProcessPoolExecutor
import numpy as np
import json
import subprocess
import sys
import textwrap
GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen
# 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.
# Jeder Eintrag ist ein eigenstaendiges Programm: Instanz aufbauen, loesen,
# Ergebnis als JSON ausgeben. Die Instanz wird in jedem Kindprozess aus
# derselben Saat neu erzeugt - so reist nichts ueber die Prozessgrenze,
# was das Ergebnis verfaelschen koennte.
VORSPANN = """
import json, time, resource
import numpy as np
def instanz(m: int, n: int):
def instanz(m, n):
rng = np.random.default_rng(20)
kosten = rng.integers(5, 95, (m, n)).astype(float)
angebot = rng.integers(50, 150, m).astype(float)
bedarf = angebot.sum() * rng.dirichlet(np.ones(n))
return kosten, angebot, bedarf
def speicher_mb() -> float:
# ru_maxrss ist unter Linux in Kilobyte. Gemessen wird der Kindprozess -
# deshalb muss jede Messung einen eigenen bekommen.
def speicher_mb():
# ru_maxrss ist unter Linux in Kilobyte
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
M, N = {m}, {n}
kosten, angebot, bedarf = instanz(M, N)
"""
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()
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_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]
def messe(name: str, quelltext: str, m: int, n: int):
"""Fuehrt einen Ansatz in einem eigenen Prozess aus."""
programm = (VORSPANN.format(m=m, n=n) + textwrap.dedent(quelltext)
+ "\nprint(json.dumps(ausgabe))\n")
ergebnis = subprocess.run([sys.executable, "-c", programm],
capture_output=True, text=True, timeout=600)
if ergebnis.returncode != 0:
return None, ergebnis.stderr.strip().splitlines()[-1][:60]
return json.loads(ergebnis.stdout.strip().splitlines()[-1]), None
if __name__ == "__main__":
@ -168,8 +157,8 @@ if __name__ == "__main__":
f"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}")
print(" " + "-" * 72)
zielwerte = {}
for name, funktion in ANSAETZE.items():
werte, fehler = messe(funktion, m, n)
for name, quelltext in ANSAETZE.items():
werte, fehler = messe(name, quelltext, m, n)
if werte is None:
print(f" {name:<16} nicht verfuegbar: {fehler}")
continue

View file

@ -1,305 +0,0 @@
#!/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,104 +8,85 @@ 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 laeuft in einem EIGENEN Prozess, weil sich ortools und
WICHTIG: Jeder Solver läuft in einem EIGENEN Prozess, weil sich ortools und
highspy auf vielen Systemen nicht gemeinsam importieren lassen (beide bringen
eine eigene HiGHS-Kopie mit -> Symbolkonflikt).
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 multiprocessing
import json
import subprocess
import sys
import textwrap
import time
from concurrent.futures import ProcessPoolExecutor
ERWARTET = 530.0 # Ergebnis der Handrechnung zum Produktionsprogramm
# 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]
# Jeder Eintrag ist ein eigenständiges Miniprogramm, das sein Ergebnis als
# JSON auf stdout ausgibt. So bleibt jeder Import in seinem eigenen Prozess.
ANSAETZE: dict[str, str] = {
"scipy.optimize.linprog": """
from scipy.optimize import linprog
res = linprog(c=[-10.0, -15.0, -25.0], # linprog MINIMIERT -> negieren
A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50],
bounds=[(0, None)] * 3, method="highs")
ausgabe = (-res.fun, list(res.x))
""",
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)
"highspy (natives HiGHS)": """
import numpy as np, highspy
h = highspy.Highs()
h.setOptionValue("output_flag", False)
h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))
h.changeObjectiveSense(highspy.ObjSense.kMaximize)
for j, wert in enumerate([10.0, 15.0, 25.0]):
h.changeColCost(j, wert)
# CSR-Format: starts[i] = Beginn von Zeile i in indices/values
h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,
np.array([0, 3], dtype=np.int32),
np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),
np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0]))
h.run()
ausgabe = (h.getInfo().objective_function_value,
list(h.getSolution().col_value[:3]))
""",
"cvxpy": """
import numpy as np, cvxpy as cp
x = cp.Variable(3, nonneg=True)
problem = cp.Problem(cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x),
[np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40, 50])])
problem.solve()
ausgabe = (float(problem.value), [float(v) for v in x.value])
""",
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,
"ortools / GLOP": """
from ortools.linear_solver import pywraplp
s = pywraplp.Solver.CreateSolver("GLOP")
x = [s.NumVar(0, s.infinity(), f"x{j+1}") for j in range(3)]
A = [[1, 1, 2], [2, 3, 1]]
for i, kap in enumerate([40, 50]):
s.Add(sum(A[i][j] * x[j] for j in range(3)) <= kap)
s.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])
s.Solve()
ausgabe = (s.Objective().Value(), [v.solution_value() for v in x])
""",
}
def fuehre_in_eigenem_prozess_aus(quelltext: str) -> tuple[float, list[float]]:
"""Startet den Codeschnipsel als separaten Python-Prozess und liest das Ergebnis."""
programm = textwrap.dedent(quelltext) + "\nimport json; print(json.dumps(ausgabe))\n"
ergebnis = subprocess.run([sys.executable, "-c", programm],
capture_output=True, text=True, timeout=120)
if ergebnis.returncode != 0:
raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])
wert, loesung = json.loads(ergebnis.stdout.strip().splitlines()[-1])
return wert, loesung
if __name__ == "__main__":
print("=" * 78)
print(" EIN SYSTEM - VIER ANSAETZE (je eigener Prozess)")
@ -114,23 +95,17 @@ if __name__ == "__main__":
print("-" * 78)
werte = []
# 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")
for name, quelltext in ANSAETZE.items():
t0 = time.perf_counter()
try:
wert, x = fuehre_in_eigenem_prozess_aus(quelltext)
except RuntimeError as fehler:
print(f"{name:<26} nicht verfuegbar: {fehler[:40]}")
continue
dauer = time.perf_counter() - t0
werte.append(wert)
print(f"{name:<26} {wert:>10.2f} {x[0]:>7.2f} {x[1]:>7.2f} {x[2]:>7.2f} "
f"{dauer:>8.2f} s")
print("-" * 78)
spanne = max(werte) - min(werte)
@ -140,6 +115,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 Prozessstart und Import - sie messen NICHT die")
print(" reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)")
print("(Die Zeiten enthalten den Prozessstart und den Import - sie messen")
print(" NICHT die reine Solverleistung, siehe Uebung 3.5.)")
print("=" * 78)

View file

@ -55,8 +55,7 @@ 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 den Denkfehler im Kapitel Quadratische und
nichtlineare Optimierung).
nachgehen muss (siehe Denkfehler 8.1).
"""
gewichte = cp.Variable(ANZAHL_ANLAGEN)
problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),

View file

@ -1,195 +0,0 @@
#!/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 multiprocessing
import subprocess
import sys
import time
from concurrent.futures import ProcessPoolExecutor
import numpy as np
from pydantic import BaseModel, Field, model_validator
@ -241,29 +241,25 @@ 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, 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)
def loese_in_eigenem_prozess(name: str) -> Loesung:
"""Startet dieses Programm noch einmal - mit genau einem Solverimport."""
ergebnis = subprocess.run([sys.executable, __file__, name],
capture_output=True, text=True, timeout=300)
if ergebnis.returncode != 0:
raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])
# Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.
return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])
if __name__ == "__main__":
problem = beispielproblem()
# --- Beide Solver anstossen und vergleichen ---------------------------
# --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------
if len(sys.argv) > 1:
print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())
sys.exit(0)
# --- Hauptprozess: beide Solver anstossen und vergleichen -------------
print("=" * 82)
print(" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE")
print("=" * 82)
@ -275,7 +271,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, problem)
loesung = loesungen[name] = loese_in_eigenem_prozess(name)
beanstandungen = pruefe_zuordnung(problem, loesung)
print(f"{beschriftung}")

View file

@ -83,8 +83,7 @@ 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 -
# der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).
# Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.
# Im Produktivbetrieb laesst man beides auf den Standardwerten.
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1

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,134 +334,6 @@ 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.
@ -504,8 +376,7 @@ 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) und
{ref:sec:modellierung-glossar} (welcher Satz welcher Baustein ist), dann {ref:anhang:modellierungsmuster}
Lesen Sie {ref:sec:einfuehrung-historischer-kontext-und-evolution} (die vier Bausteine), 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}
@ -560,7 +431,6 @@ 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} |
@ -600,7 +470,6 @@ 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,8 +528,7 @@ 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 der Aufgabe *Die Bäckerei programmieren*
> ({ref:sec:einfuehrung-uebungsaufgaben}) selbst.
> Dieses Problem lösen Sie in Übung 1.5 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:
@ -1143,7 +1142,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: Die Schreinerei verdoppelt ihren Gewinn**
> **🐛 Finde den Denkfehler 1.1: 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,
@ -1192,7 +1191,7 @@ eine Zahl liefern, die betriebswirtschaftlich Unsinn ist. Genau solche Fälle ü
## Micro-Quiz {#sec:einfuehrung-quiz}
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 1: 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: Die unauffällige Transposition**
> **🐛 Finde den Denkfehler 2.1: 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: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 2: 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,34 +308,6 @@ $$
> 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
@ -347,104 +319,85 @@ 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 laeuft in einem EIGENEN Prozess, weil sich ortools und
WICHTIG: Jeder Solver läuft in einem EIGENEN Prozess, weil sich ortools und
highspy auf vielen Systemen nicht gemeinsam importieren lassen (beide bringen
eine eigene HiGHS-Kopie mit -> Symbolkonflikt).
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 multiprocessing
import json
import subprocess
import sys
import textwrap
import time
from concurrent.futures import ProcessPoolExecutor
ERWARTET = 530.0 # Ergebnis der Handrechnung zum Produktionsprogramm
# 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]
# Jeder Eintrag ist ein eigenständiges Miniprogramm, das sein Ergebnis als
# JSON auf stdout ausgibt. So bleibt jeder Import in seinem eigenen Prozess.
ANSAETZE: dict[str, str] = {
"scipy.optimize.linprog": """
from scipy.optimize import linprog
res = linprog(c=[-10.0, -15.0, -25.0], # linprog MINIMIERT -> negieren
A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50],
bounds=[(0, None)] * 3, method="highs")
ausgabe = (-res.fun, list(res.x))
""",
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)
"highspy (natives HiGHS)": """
import numpy as np, highspy
h = highspy.Highs()
h.setOptionValue("output_flag", False)
h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))
h.changeObjectiveSense(highspy.ObjSense.kMaximize)
for j, wert in enumerate([10.0, 15.0, 25.0]):
h.changeColCost(j, wert)
# CSR-Format: starts[i] = Beginn von Zeile i in indices/values
h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,
np.array([0, 3], dtype=np.int32),
np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),
np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0]))
h.run()
ausgabe = (h.getInfo().objective_function_value,
list(h.getSolution().col_value[:3]))
""",
"cvxpy": """
import numpy as np, cvxpy as cp
x = cp.Variable(3, nonneg=True)
problem = cp.Problem(cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x),
[np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40, 50])])
problem.solve()
ausgabe = (float(problem.value), [float(v) for v in x.value])
""",
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,
"ortools / GLOP": """
from ortools.linear_solver import pywraplp
s = pywraplp.Solver.CreateSolver("GLOP")
x = [s.NumVar(0, s.infinity(), f"x{j+1}") for j in range(3)]
A = [[1, 1, 2], [2, 3, 1]]
for i, kap in enumerate([40, 50]):
s.Add(sum(A[i][j] * x[j] for j in range(3)) <= kap)
s.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])
s.Solve()
ausgabe = (s.Objective().Value(), [v.solution_value() for v in x])
""",
}
def fuehre_in_eigenem_prozess_aus(quelltext: str) -> tuple[float, list[float]]:
"""Startet den Codeschnipsel als separaten Python-Prozess und liest das Ergebnis."""
programm = textwrap.dedent(quelltext) + "\nimport json; print(json.dumps(ausgabe))\n"
ergebnis = subprocess.run([sys.executable, "-c", programm],
capture_output=True, text=True, timeout=120)
if ergebnis.returncode != 0:
raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])
wert, loesung = json.loads(ergebnis.stdout.strip().splitlines()[-1])
return wert, loesung
if __name__ == "__main__":
print("=" * 78)
print(" EIN SYSTEM - VIER ANSAETZE (je eigener Prozess)")
@ -453,23 +406,17 @@ if __name__ == "__main__":
print("-" * 78)
werte = []
# 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")
for name, quelltext in ANSAETZE.items():
t0 = time.perf_counter()
try:
wert, x = fuehre_in_eigenem_prozess_aus(quelltext)
except RuntimeError as fehler:
print(f"{name:<26} nicht verfuegbar: {fehler[:40]}")
continue
dauer = time.perf_counter() - t0
werte.append(wert)
print(f"{name:<26} {wert:>10.2f} {x[0]:>7.2f} {x[1]:>7.2f} {x[2]:>7.2f} "
f"{dauer:>8.2f} s")
print("-" * 78)
spanne = max(werte) - min(werte)
@ -479,8 +426,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 Prozessstart und Import - sie messen NICHT die")
print(" reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)")
print("(Die Zeiten enthalten den Prozessstart und den Import - sie messen")
print(" NICHT die reine Solverleistung, siehe Uebung 3.5.)")
print("=" * 78)
```
@ -492,16 +439,16 @@ if __name__ == "__main__":
==============================================================================
Bibliothek Z* x1 x2 x3 Zeit
------------------------------------------------------------------------------
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
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
------------------------------------------------------------------------------
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 Prozessstart und Import - sie messen NICHT die
reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)
(Die Zeiten enthalten den Prozessstart und den Import - sie messen
NICHT die reine Solverleistung, siehe Uebung 3.5.)
==============================================================================
```
@ -1215,7 +1162,7 @@ passende Empfehlungen.
## Finde den Denkfehler {#sec:oekosystem-denkfehler}
> **🐛 Finde den Denkfehler: Der Solver, der angeblich dreimal schneller ist**
> **🐛 Finde den Denkfehler 3.1: 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
@ -1252,7 +1199,7 @@ passende Empfehlungen.
## Micro-Quiz {#sec:oekosystem-quiz}
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 3: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-oekosystem).
@ -1317,4 +1264,4 @@ Schattenpreise kommen — und was sie wirtschaftlich bedeuten.
---
*Weiter mit:* [{ref:kap:modellierung} — Vom Management-Wunsch zum Modell](13_Vom_Wunsch_zum_Modell.md)
*Weiter mit:* [{ref:kap:lp} — Lineare Programmierung](20_Lineare_Programmierung.md)

View file

@ -99,9 +99,6 @@ 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.
---
@ -617,75 +614,6 @@ 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}.
@ -709,16 +637,6 @@ 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
@ -802,14 +720,6 @@ 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

@ -1,44 +0,0 @@
# 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,8 +933,7 @@ 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. Die Aufgabe *Gültigkeitsbereich des Schattenpreises*
> ({ref:sec:lp-uebungsaufgaben}) macht diesen Effekt sichtbar.
> hochrechnen. Übung 4.6 macht diesen Effekt sichtbar.
---
@ -1318,7 +1317,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: Die 380 000-Euro-Maschine**
> **🐛 Finde den Denkfehler 4.1: 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:
@ -1357,7 +1356,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: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 4: 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,8 +1308,7 @@ 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 -
# der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).
# Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.
# Im Produktivbetrieb laesst man beides auf den Standardwerten.
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1
@ -1472,7 +1471,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: Elf Lager, die keine Fixkosten kosten**
> **🐛 Finde den Denkfehler 5.1: 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
@ -1759,7 +1758,7 @@ funktioniert immer.
## Micro-Quiz {#sec:milp-quiz}
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 5: 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,7 +18,6 @@
> `Propagation_Demo.py`\
> `CP_SAT_Vertretungssystem.py`\
> `JobShop_Intervalle.py`\
> `Parallele_Suche.py`\
> `CP_SAT_Statusfaelle.py`\
> `Strafgewichte.py`
@ -102,9 +101,7 @@ 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 `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
7. … begründen, warum ein Strafgewicht ein **Wechselkurs** ist, und welche Regeln deshalb
niemals in die Zielfunktion gehören.
---
@ -797,9 +794,6 @@ 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,
@ -823,336 +817,6 @@ 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
@ -1516,16 +1180,6 @@ 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.
@ -1542,7 +1196,7 @@ Wünsche als weiche Ziele. Geben Sie den Plan als Wochentabelle aus.
## Finde den Denkfehler {#sec:cpsat-denkfehler}
> **🐛 Finde den Denkfehler: Die Betriebsvereinbarung, die niemanden interessiert**
> **🐛 Finde den Denkfehler 6.1: Die Betriebsvereinbarung, die niemanden interessiert**
>
> Ein Klinikteam lässt seinen Schichtplan optimieren. Zwei Wünsche kommen aus dem Betrieb:
>
@ -1774,7 +1428,7 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
## Micro-Quiz {#sec:cpsat-quiz}
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 6: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-cpsat).
@ -1812,10 +1466,6 @@ 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?
---
@ -1841,17 +1491,10 @@ 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 und Touren](23_Graphen_Fluesse_Touren.md)
*Weiter mit:* [{ref:kap:graphen} — Graphen, Flüsse, 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: Die vergessene Dimension**
> **🐛 Finde den Denkfehler 7.1: 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: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 7: 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:metaheuristiken} — Metaheuristiken](24_Metaheuristiken.md)
*Weiter mit:* [{ref:kap:qp-nlp} — Quadratische und nichtlineare Optimierung](30_QP_und_NLP.md)

View file

@ -1526,7 +1526,3 @@ 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,7 +750,3 @@ 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

@ -1,50 +0,0 @@
# 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.
Drei Kapitel dieses Teils behandeln genau diese drei Abweichungen. Welches Werkzeug Sie
Die 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,11 +21,6 @@ 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}
@ -783,8 +778,7 @@ 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.
> Die Aufgabe *Effekt der Gewichtung untersuchen* ({ref:sec:qp-nlp-uebungsaufgaben}) lässt
> Sie diese Balance untersuchen — sie ist eine fachliche Entscheidung, keine
> Übung 8.5 lässt Sie diese Balance untersuchen — sie ist eine fachliche Entscheidung, keine
> technische.
---
@ -1217,7 +1211,7 @@ groß ist der Unterschied zwischen bestem und schlechtestem Ergebnis? Kann die
## Finde den Denkfehler {#sec:qp-nlp-denkfehler}
> **🐛 Finde den Denkfehler: Die Kovarianzmatrix aus dem Controlling**
> **🐛 Finde den Denkfehler 8.1: 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
@ -1285,7 +1279,7 @@ groß ist der Unterschied zwischen bestem und schlechtestem Ergebnis? Kann die
## Micro-Quiz {#sec:qp-nlp-quiz}
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 8: 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
> (Aufgabe *Monte-Carlo erweitern*, {ref:sec:unsicherheit-uebungsaufgaben}).
> (Übung 9.5).
> **💡 Wann Monte-Carlo das richtige Werkzeug ist**
> Immer dann, wenn Sie **eine Kennzahl brauchen, die keine Formel hat**: die
@ -642,8 +642,7 @@ 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 der Aufgabe *EVPI interpretieren*
({ref:sec:unsicherheit-uebungsaufgaben}).
Prognosen tatsächlich — anders als in dem Beispiel aus Übung 9.4.
> **💡 Was ist der EVPI?**
> Der **Expected Value of Perfect Information**{idx:EVPI} beziffert, wie viel eine perfekte Prognose
@ -1279,7 +1278,7 @@ das lässt sich in CVXPY mit `cp.sum_largest` formulieren.)
## Finde den Denkfehler {#sec:unsicherheit-denkfehler}
> **🐛 Finde den Denkfehler: Warum jedes Projekt zu spät fertig wird**
> **🐛 Finde den Denkfehler 9.1: 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
@ -1335,7 +1334,7 @@ das lässt sich in CVXPY mit `cp.sum_largest` formulieren.)
## Micro-Quiz {#sec:unsicherheit-quiz}
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 9: 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: Der Zustand, der zu wenig weiß**
> **🐛 Finde den Denkfehler 10.1: 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: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 10: 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:mehrziel} — Mehrere Ziele](33_Mehrziel_und_Pareto.md)
*Weiter mit:* [{ref:kap:finanzdaten} — Finanzdaten und Kovarianz-Shrinkage](40_Finanzdaten.md)

View file

@ -757,7 +757,3 @@ 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,7 +700,3 @@ 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,7 +735,3 @@ 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,26 +595,6 @@ 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:
```
@ -785,7 +765,3 @@ 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

@ -1,52 +0,0 @@
# 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,8 +448,7 @@ 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 der Aufgabe *Beide Shrinkage-Ziele vergleichen*
> ({ref:sec:finanzdaten-uebungsaufgaben}) implementieren Sie es selbst und
> unterschiedliche Volatilitäten haben. In Übung 11.5 implementieren Sie es selbst und
> vergleichen.
Das Ergebnis ist in jedem Fall garantiert **positiv definit**, wohlkonditioniert und stabil
@ -681,7 +680,7 @@ Erweitern Sie `Schaetzrauschen_Demo.py` um eine dritte Variante: die
## Finde den Denkfehler {#sec:finanzdaten-denkfehler}
> **🐛 Finde den Denkfehler: Das risikofreie Portfolio**
> **🐛 Finde den Denkfehler 11.1: 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
@ -761,8 +760,7 @@ 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 den Denkfehler im Kapitel Quadratische und
nichtlineare Optimierung).
nachgehen muss (siehe Denkfehler 8.1).
"""
gewichte = cp.Variable(ANZAHL_ANLAGEN)
problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),
@ -887,7 +885,7 @@ e 0: \mathbf{w}^ op\mathbf{S}\mathbf{w} = 0$ | „In genau diesen Richtungen mis
## Micro-Quiz {#sec:finanzdaten-quiz}
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 11: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-finanzdaten).
@ -952,4 +950,4 @@ Markowitz-Effizienzgrenze mit realistischen institutionellen Restriktionen.
---
*Weiter mit:* [{ref:kap:markowitz} — Die moderne Portfoliotheorie nach Markowitz](41_Markowitz.md)
*Weiter mit:* [{ref:kap:markowitz} — Markowitz-Portfoliotheorie](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: Zwölf gleiche Anlagen, ein sehr ungleiches Portfolio**
> **🐛 Finde den Denkfehler 12.1: 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: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 12: 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: Das Risikobudget, das durch Diversifikation stieg**
> **🐛 Finde den Denkfehler 13.1: 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: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 13: 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 quantitative Handelsmaschine](43_Handelsmaschine.md)
*Weiter mit:* [{ref:kap:handelsmaschine} — Die vollständige 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: Die Strategie mit dem hochsignifikanten Ergebnis**
> **🐛 Finde den Denkfehler 14.1: 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: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 14: 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:* [Synthese Teil IV](49_Synthese_Anwendungen.md)
*Weiter mit:* [{ref:kap:praxisfallen} — Praxisfallen und produktiver Einsatz](50_Praxis.md)

View file

@ -1,50 +0,0 @@
# 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,7 +20,6 @@
> **Programme:**\
> `Infeasibility_Diagnose.py`\
> `Erklaerbarkeit.py`\
> `Constraint_Attribution.py`\
> `Betriebsueberwachung.py`\
> `or_kern.py`\
> `Solverwechsel_CPSAT_HiGHS.py`
@ -90,17 +89,15 @@ 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. … 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
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
Anforderungen prüft statt gegen das Modell.
8. … Status, Gap und Zeitausschöpfung protokollieren und begründen, warum eine konstante
7. … Status, Gap und Zeitausschöpfung protokollieren und begründen, warum eine konstante
Laufzeit bei Zeitlimit ein Warnsignal ist.
9. … ein Modell in Domänenschicht, Modellbauer und Lösungs-DTO trennen — und begründen,
8. … 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.
10. … die Statuswerte verschiedener Solverbibliotheken auf eine gemeinsame Sprache
9. … die Statuswerte verschiedener Solverbibliotheken auf eine gemeinsame Sprache
abbilden.
---
@ -376,10 +373,6 @@ 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
@ -422,500 +415,6 @@ 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)
@ -1666,9 +1165,9 @@ Benoetigt: numpy, pydantic, ortools, highspy (jeweils im eigenen Prozess)
from __future__ import annotations
import multiprocessing
import subprocess
import sys
import time
from concurrent.futures import ProcessPoolExecutor
import numpy as np
from pydantic import BaseModel, Field, model_validator
@ -1871,29 +1370,25 @@ 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, 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)
def loese_in_eigenem_prozess(name: str) -> Loesung:
"""Startet dieses Programm noch einmal - mit genau einem Solverimport."""
ergebnis = subprocess.run([sys.executable, __file__, name],
capture_output=True, text=True, timeout=300)
if ergebnis.returncode != 0:
raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])
# Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.
return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])
if __name__ == "__main__":
problem = beispielproblem()
# --- Beide Solver anstossen und vergleichen ---------------------------
# --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------
if len(sys.argv) > 1:
print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())
sys.exit(0)
# --- Hauptprozess: beide Solver anstossen und vergleichen -------------
print("=" * 82)
print(" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE")
print("=" * 82)
@ -1905,7 +1400,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, problem)
loesung = loesungen[name] = loese_in_eigenem_prozess(name)
beanstandungen = pruefe_zuordnung(problem, loesung)
print(f"{beschriftung}")
@ -2112,8 +1607,7 @@ Echtzeit, (b) Wochendienstplan, freitags erstellt, (c) Jahresproduktionsplanung,
(d) Portfolio-Rebalancing monatlich.
**Aufgabe ⭐⭐ — Relaxation einbauen.**
Nehmen Sie Ihr Modell aus der Aufgabe *Eigener Dienstplan*
({ref:sec:cpsat-uebungsaufgaben}) und machen Sie es
Nehmen Sie Ihr Modell aus Übung 6.7 (Wochendienstplan) und machen Sie es
`INFEASIBLE`-sicher: Schlupfvariablen für unbesetzte Schichten, gestaffelte Strafen. Testen
Sie mit einem bewusst überlasteten Szenario.
@ -2121,17 +1615,6 @@ 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 %.
@ -2154,7 +1637,7 @@ eigene Projekte ab?
Falle 5 — die Laufzeitexplosion — zeigt sich im Betrieb selten als Absturz. Sie zeigt
sich als **gar nichts**.
> **🐛 Finde den Denkfehler: Das Modell, das seit einem Jahr nicht mehr optimiert**
> **🐛 Finde den Denkfehler 15.1: 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
@ -2441,7 +1924,7 @@ sichtbar wird, und gibt Zeit zum Handeln.
## Micro-Quiz {#sec:praxisfallen-quiz}
> **❓ Micro-Quiz: Drei Fragen zum Selbstcheck**
> **❓ Micro-Quiz 15: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-praxisfallen).
@ -2479,10 +1962,6 @@ 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?
---
@ -2492,16 +1971,6 @@ 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.
@ -2520,4 +1989,4 @@ sichtbar wird, und gibt Zeit zum Handeln.
---
*Weiter mit:* [{ref:kap:testing} — Testen, Messen, Ausliefern](51_Testing_und_Deployment.md)
*Weiter mit:* [Projektwerkstatt — acht eigene Anwendungen](59_Projektwerkstatt.md)

View file

@ -787,124 +787,113 @@ Benoetigt: numpy; in den Kindprozessen scipy, highspy, ortools, cvxpy
from __future__ import annotations
import multiprocessing
import resource
import time
from concurrent.futures import ProcessPoolExecutor
import numpy as np
import json
import subprocess
import sys
import textwrap
GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen
# 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.
# Jeder Eintrag ist ein eigenstaendiges Programm: Instanz aufbauen, loesen,
# Ergebnis als JSON ausgeben. Die Instanz wird in jedem Kindprozess aus
# derselben Saat neu erzeugt - so reist nichts ueber die Prozessgrenze,
# was das Ergebnis verfaelschen koennte.
VORSPANN = """
import json, time, resource
import numpy as np
def instanz(m: int, n: int):
def instanz(m, n):
rng = np.random.default_rng(20)
kosten = rng.integers(5, 95, (m, n)).astype(float)
angebot = rng.integers(50, 150, m).astype(float)
bedarf = angebot.sum() * rng.dirichlet(np.ones(n))
return kosten, angebot, bedarf
def speicher_mb() -> float:
# ru_maxrss ist unter Linux in Kilobyte. Gemessen wird der Kindprozess -
# deshalb muss jede Messung einen eigenen bekommen.
def speicher_mb():
# ru_maxrss ist unter Linux in Kilobyte
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
M, N = {m}, {n}
kosten, angebot, bedarf = instanz(M, N)
"""
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()
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_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]
def messe(name: str, quelltext: str, m: int, n: int):
"""Fuehrt einen Ansatz in einem eigenen Prozess aus."""
programm = (VORSPANN.format(m=m, n=n) + textwrap.dedent(quelltext)
+ "\nprint(json.dumps(ausgabe))\n")
ergebnis = subprocess.run([sys.executable, "-c", programm],
capture_output=True, text=True, timeout=600)
if ergebnis.returncode != 0:
return None, ergebnis.stderr.strip().splitlines()[-1][:60]
return json.loads(ergebnis.stdout.strip().splitlines()[-1]), None
if __name__ == "__main__":
@ -922,8 +911,8 @@ if __name__ == "__main__":
f"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}")
print(" " + "-" * 72)
zielwerte = {}
for name, funktion in ANSAETZE.items():
werte, fehler = messe(funktion, m, n)
for name, quelltext in ANSAETZE.items():
werte, fehler = messe(name, quelltext, m, n)
if werte is None:
print(f" {name:<16} nicht verfuegbar: {fehler}")
continue
@ -979,28 +968,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% 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
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
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 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
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
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.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
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
Spannweite der Zielwerte: 3.65e-04 (relativ 5.0e-09)
============================================================================================
@ -1647,7 +1636,3 @@ 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

@ -1,48 +0,0 @@
# 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 zu allen Übungsaufgaben](90_Anhang_Loesungen.md)
*Weiter mit:* [{ref:anhang:loesungen} — Lösungen](90_Anhang_Loesungen.md)

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