{ "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 }