operations_research/OR_HTML_04/Notebooks_04/mehrziel.ipynb

315 lines
15 KiB
Text
Raw Normal View History

Version 04 als eigenes Repository Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:20:09 +02:00
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 14: Mehrere Ziele — Pareto-Fronten statt Gewichte\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",
"`Mehrziel_Pareto.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Mehrziel_Pareto.py\n",
"\"\"\"\n",
"Kapitel Mehrziel: Kosten gegen CO2 - und warum Gewichte nicht genuegen.\n",
"\n",
"Eine Spedition vergibt zwoelf Sendungen an drei Verkehrstraeger: LKW (schnell,\n",
"teuer, schmutzig), Bahn (billig und sauber, aber nur fuenf Trassen frei) und\n",
"Kombinierten Verkehr (dazwischen). Zwei Ziele stehen gegeneinander:\n",
"Transportkosten und CO2-Ausstoss.\n",
"\n",
"Der uebliche Reflex ist, beide Ziele zu einem zusammenzuruehren:\n",
"\n",
" minimiere Kosten + w * CO2\n",
"\n",
"Das ist bequem, liefert zulaessige Loesungen - und ist unvollstaendig. Bei\n",
"ganzzahligen Entscheidungen gibt es Kompromisse, die auf diesem Weg\n",
"GRUNDSAETZLICH nicht erreichbar sind, egal welches w man waehlt. Nicht \"schwer\n",
"zu finden\", sondern beweisbar unerreichbar.\n",
"\n",
"Das Programm zeigt in vier Teilen:\n",
"\n",
" 1. Die beiden Extreme - was jedes Ziel allein kostet.\n",
" 2. Die lineare Skalarisierung ueber ein feines Gewichtsraster.\n",
" 3. Die vollstaendige Pareto-Front ueber das eps-Constraint-Verfahren.\n",
" 4. Den Nachweis, dass die Luecke keine Frage des Rasters ist, sondern\n",
" Geometrie: Die fehlenden Punkte liegen strikt oberhalb der konvexen\n",
" Huelle und koennen deshalb von keiner Geraden gestuetzt werden.\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",
"TRAEGER = [\"LKW\", \"Bahn\", \"Kombiniert\"]\n",
"BAHN_TRASSEN = 5 # so viele Sendungen passen hoechstens auf die Bahn\n",
"SAAT = 5\n",
"\n",
"\n",
"def erzeuge_sendungen(anzahl: int = 12, saat: int = SAAT):\n",
" \"\"\"Kosten und CO2 je Sendung und Verkehrstraeger.\n",
"\n",
" Die Bahn ist immer billiger und sauberer als der LKW - der Zielkonflikt\n",
" entsteht nicht zwischen den Traegern, sondern durch die KNAPPHEIT der\n",
" Trassen. Genau so sieht es in der Praxis aus.\n",
" \"\"\"\n",
" rng = np.random.default_rng(saat)\n",
" kosten = np.zeros((anzahl, 3))\n",
" co2 = np.zeros((anzahl, 3))\n",
" for i in range(anzahl):\n",
" grund_kosten = rng.integers(600, 2400)\n",
" grund_co2 = rng.integers(400, 1800)\n",
" kosten[i] = [grund_kosten,\n",
" grund_kosten * rng.uniform(0.55, 0.85),\n",
" grund_kosten * rng.uniform(0.70, 1.00)]\n",
" co2[i] = [grund_co2,\n",
" grund_co2 * rng.uniform(0.15, 0.35),\n",
" grund_co2 * rng.uniform(0.40, 0.70)]\n",
" return np.round(kosten).astype(int), np.round(co2).astype(int)\n",
"\n",
"\n",
"KOSTEN, CO2 = erzeuge_sendungen()\n",
"N = len(KOSTEN)\n",
"\n",
"\n",
"def plane(ziel: np.ndarray, co2_grenze: float | None = None,\n",
" kosten_grenze: float | None = None):\n",
" \"\"\"Jede Sendung genau einem Traeger zuordnen; Trassen sind knapp.\n",
"\n",
" 'ziel' ist die zu minimierende Matrix (Kosten, CO2 oder eine Mischung).\n",
" Die beiden Grenzen sind das Werkzeug fuer eps-Constraint und\n",
" lexikografische Optimierung - sie machen aus einem Ziel eine Schranke.\n",
" \"\"\"\n",
" gleichungen = np.zeros((N, N * 3))\n",
" for i in range(N):\n",
" gleichungen[i, i * 3:(i + 1) * 3] = 1.0 # genau ein Traeger\n",
"\n",
" ungleichungen = [[1.0 if j == 1 else 0.0 for _ in range(N) for j in range(3)]]\n",
" grenzen = [float(BAHN_TRASSEN)]\n",
" if co2_grenze is not None:\n",
" ungleichungen.append(CO2.reshape(-1).astype(float))\n",
" grenzen.append(float(co2_grenze))\n",
" if kosten_grenze is not None:\n",
" ungleichungen.append(KOSTEN.reshape(-1).astype(float))\n",
" grenzen.append(float(kosten_grenze))\n",
"\n",
" ergebnis = linprog(ziel.reshape(-1).astype(float),\n",
" A_ub=ungleichungen, b_ub=grenzen,\n",
" A_eq=gleichungen, b_eq=np.ones(N),\n",
" bounds=(0, 1), integrality=1, method=\"highs\")\n",
" if not ergebnis.success:\n",
" return None\n",
" plan = np.round(ergebnis.x).astype(int)\n",
" return (int(KOSTEN.reshape(-1) @ plan), int(CO2.reshape(-1) @ plan), plan)\n",
"\n",
"\n",
"def pareto_front():\n",
" \"\"\"Vollstaendige Front ueber das eps-Constraint-Verfahren.\n",
"\n",
" Der Ablauf ist der eigentliche Inhalt dieser Funktion: Erst das\n",
" Kostenminimum bestimmen (der eine Rand der Front), dann die CO2-Schranke\n",
" schrittweise um genau ein Kilogramm unter den zuletzt erreichten Wert\n",
" druecken. Jeder Lauf liefert den naechsten Punkt - und wenn keiner mehr\n",
" zulaessig ist, ist die Front vollstaendig.\n",
"\n",
" Das braucht so viele Solveraufrufe, wie die Front Punkte hat. Ein Raster\n",
" ueber alle moeglichen CO2-Werte braeuchte hier fast tausend.\n",
" \"\"\"\n",
" front = []\n",
" start = plane(KOSTEN)\n",
" grenze = start[1]\n",
" while True:\n",
" ergebnis = plane(KOSTEN, co2_grenze=grenze)\n",
" if ergebnis is None:\n",
" break\n",
" front.append((ergebnis[0], ergebnis[1]))\n",
" grenze = ergebnis[1] - 1\n",
" return front\n",
"\n",
"\n",
"def skalarisierung(gewichte):\n",
" \"\"\"Was 'Kosten + w * CO2' fuer viele w hergibt - als Menge von Punkten.\"\"\"\n",
" gefunden = {}\n",
" for w in gewichte:\n",
" ergebnis = plane(KOSTEN + w * CO2)\n",
" if ergebnis:\n",
" gefunden.setdefault((ergebnis[0], ergebnis[1]), []).append(w)\n",
" return gefunden\n",
"\n",
"\n",
"def untere_huelle(punkte):\n",
" \"\"\"Untere linke konvexe Huelle - genau die Punkte, die eine Gerade stuetzt.\n",
"\n",
" Der Zusammenhang, um den es geht: 'Kosten + w*CO2 minimieren' heisst\n",
" geometrisch, eine Gerade der Steigung -1/w von links unten an die\n",
" Punktwolke zu schieben. Sie beruehrt immer einen Eckpunkt der konvexen\n",
" Huelle. Punkte, die oberhalb liegen, werden nie beruehrt - fuer kein w.\n",
" \"\"\"\n",
" huelle = []\n",
" for punkt in sorted(punkte):\n",
" while len(huelle) >= 2:\n",
" (x1, y1), (x2, y2) = huelle[-2], huelle[-1]\n",
" if (x2 - x1) * (punkt[1] - y1) - (y2 - y1) * (punkt[0] - x1) <= 0:\n",
" huelle.pop()\n",
" else:\n",
" break\n",
" huelle.append(punkt)\n",
" return huelle\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 80)\n",
" print(\" KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN\")\n",
" print(\"=\" * 80)\n",
" print(f\"{N} Sendungen, {len(TRAEGER)} Verkehrstraeger, \"\n",
" f\"{BAHN_TRASSEN} freie Bahntrassen.\\n\")\n",
"\n",
" # --- 1. Die beiden Extreme -------------------------------------------\n",
" guenstigst = plane(KOSTEN)\n",
" saubersten = plane(CO2)\n",
" print(\"1. Was jedes Ziel allein ergibt\\n\")\n",
" print(f\" {'':<22} {'Kosten':>10} {'CO2':>10}\")\n",
" print(\" \" + \"-\" * 44)\n",
" print(f\" {'nur Kosten minimal':<22} {guenstigst[0]:>10,} {guenstigst[1]:>9,} kg\")\n",
" print(f\" {'nur CO2 minimal':<22} {saubersten[0]:>10,} {saubersten[1]:>9,} kg\")\n",
" print(f\"\\n Der Zielkonflikt ist echt, aber klein: \"\n",
" f\"{saubersten[0] - guenstigst[0]:,} EUR mehr\")\n",
" print(f\" ({(saubersten[0] - guenstigst[0]) / guenstigst[0] * 100:.1f} %) sparen \"\n",
" f\"{guenstigst[1] - saubersten[1]:,} kg CO2 \"\n",
" f\"({(guenstigst[1] - saubersten[1]) / guenstigst[1] * 100:.1f} %).\")\n",
" print(\" Genau solche Zahlen will die Geschaeftsfuehrung sehen - nicht ein\")\n",
" print(\" Gewicht, das niemand interpretieren kann.\")\n",
"\n",
" # --- 2. Die lineare Skalarisierung -----------------------------------\n",
" gewichte = np.concatenate([np.linspace(0.0, 3.0, 1201),\n",
" np.geomspace(3.0, 1000.0, 200)])\n",
" gefunden = skalarisierung(gewichte)\n",
" print(\"\\n\" + \"-\" * 80)\n",
" print(f\"2. Lineare Skalarisierung: 'Kosten + w * CO2' fuer \"\n",
" f\"{len(gewichte):,} Gewichte\\n\")\n",
" print(f\" {'Kosten':>10} {'CO2':>10} {'gefunden bei w':>16}\")\n",
" print(\" \" + \"-\" * 46)\n",
" for (k, c), ws in sorted(gefunden.items()):\n",
" print(f\" {k:>10,} {c:>9,} kg {min(ws):>7.3f} bis {max(ws):>7.3f}\")\n",
" print(f\"\\n {len(gefunden)} verschiedene Plaene - fuer {len(gewichte):,} Gewichte.\")\n",
" print(\" Das Gewicht ist also gar keine Feineinstellung: Weite Bereiche\")\n",
" print(\" liefern dasselbe Ergebnis, und dazwischen springt es.\")\n",
"\n",
" # --- 3. Die vollstaendige Front --------------------------------------\n",
" front = pareto_front()\n",
" print(\"\\n\" + \"-\" * 80)\n",
" print(f\"3. Die vollstaendige Pareto-Front ueber eps-Constraint \"\n",
" f\"({len(front)} Solverlaeufe)\\n\")\n",
" print(f\" {'Kosten':>10} {'CO2':>10} {'Aufpreis':>9} {'CO2-Ersparnis':>14} \"\n",
" f\"{'EUR je kg':>10}\")\n",
" print(\" \" + \"-\" * 60)\n",
" for k, c in front:\n",
" auf = k - guenstigst[0]\n",
" ersparnis = guenstigst[1] - c\n",
" preis = auf / ersparnis if ersparnis else 0.0\n",
" print(f\" {k:>10,} {c:>9,} kg {auf:>8,} {ersparnis:>13,} \"\n",
" f\"{preis:>10.2f}\")\n",
"\n",
" # --- 4. Der Nachweis --------------------------------------------------\n",
" huelle = untere_huelle(front)\n",
" unerreichbar = [p for p in front if p not in huelle]\n",
" print(\"\\n\" + \"-\" * 80)\n",
" print(\"4. Was die Skalarisierung nicht findet\\n\")\n",
" erreicht = [p for p in front if p in gefunden]\n",
" print(f\" Pareto-Punkte insgesamt: {len(front)}\")\n",
" print(f\" davon von der Skalarisierung gefunden: {len(erreicht)}\")\n",
" print(f\" nie gefunden: {len(front) - len(erreicht)}\")\n",
" print()\n",
" print(\" Diese Kompromisse sind fuer KEIN Gewicht erreichbar:\\n\")\n",
" print(f\" {'Kosten':>10} {'CO2':>10} {'Aufpreis':>9} {'CO2-Ersparnis':>14}\")\n",
" print(\" \" + \"-\" * 50)\n",
" for k, c in unerreichbar:\n",
" print(f\" {k:>10,} {c:>9,} kg {k - guenstigst[0]:>8,} \"\n",
" f\"{guenstigst[1] - c:>13,}\")\n",
"\n",
" stimmt = sorted(unerreichbar) == sorted(p for p in front if p not in gefunden)\n",
" print(f\"\\n Gegenprobe ueber die Geometrie: {'bestanden' if stimmt else 'ABWEICHUNG'}\")\n",
" print(\" Genau die Punkte, die das Gewichtsraster verfehlt, liegen strikt\")\n",
" print(\" oberhalb der unteren konvexen Huelle. Das ist kein Rasterproblem -\")\n",
" print(\" eine Gerade, die von links unten an die Wolke geschoben wird,\")\n",
" print(\" beruehrt immer einen Eckpunkt der Huelle und nie einen Punkt\")\n",
" print(\" darueber. Ein feineres Raster aendert daran nichts.\")\n",
"\n",
" # --- 5. Lexikografisch -------------------------------------------------\n",
" print(\"\\n\" + \"-\" * 80)\n",
" print(\"5. Lexikografisch: erst Kosten, dann CO2 im Rahmen eines Budgets\\n\")\n",
" print(f\" {'Kostenbudget':>14} {'Kosten':>10} {'CO2':>10} {'gegenueber Minimum':>20}\")\n",
" print(\" \" + \"-\" * 58)\n",
" for aufschlag in (0.00, 0.01, 0.02, 0.05, 0.10):\n",
" budget = guenstigst[0] * (1 + aufschlag)\n",
" ergebnis = plane(CO2, kosten_grenze=budget)\n",
" if ergebnis is None:\n",
" print(f\" {aufschlag:>13.0%} unzulaessig\")\n",
" continue\n",
" print(f\" {aufschlag:>13.0%} {ergebnis[0]:>10,} {ergebnis[1]:>9,} kg \"\n",
" f\"{guenstigst[1] - ergebnis[1]:>15,} kg weniger\")\n",
"\n",
" print(\"\\n Das ist die Form, die im Betrieb am ehesten trifft: Nicht 'wie\")\n",
" print(\" wichtig ist CO2?', sondern 'wir geben zwei Prozent mehr aus - was\")\n",
" print(\" bringt das?'. Die Frage kann ein Kaufmann beantworten.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 80)\n",
" print(\" WAS MAN DARAUS MITNIMMT\")\n",
" print(\"=\" * 80)\n",
" print(\"Ein Gewicht zu setzen heisst, die Entscheidung heimlich zu treffen -\")\n",
" print(\"und dabei einen Teil der Moeglichkeiten gar nicht erst zu sehen.\")\n",
" print()\n",
" print(\"Die Pareto-Front ist die ehrlichere Antwort: Sie legt dem Betrieb\")\n",
" print(\"alle sinnvollen Kompromisse vor und ueberlaesst ihm die Wahl. Die\")\n",
" print(\"Spalte 'EUR je kg' macht sie entscheidbar - man vergleicht sie mit\")\n",
" print(\"dem CO2-Preis, den das Unternehmen ohnehin ansetzt.\")\n",
" print(\"=\" * 80)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}