operations_research/Notebooks_04/dekomposition.ipynb
dschlueter b7af2f1d9a Version 04 als eigenes Repository
Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python"
(Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im
uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch
Version_03 (eingefroren) verwaltet und Version_04/ ignoriert.

Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit
der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen
Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist.

Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise,
325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren,
25 Notebooks, PDF mit 715 Seiten.

Zusaetzlich in diesem Commit:

* pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api,
  figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt
  unveraendert daneben bestehen. ortools steht in der Grundausstattung,
  highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der
  schlanken Installation gar nicht erst auftreten.

* Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py
  importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo
  steht in requirements.txt, wird aber von keinem Programm importiert,
  sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in
  [empfehlungen]).

* NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es
  ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden
  Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen.

* PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den
  tatsaechlichen aus erzeuge_titelseite.py korrigiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:20:09 +02:00

347 lines
17 KiB
Text
Generated

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