{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Kapitel 9: Metaheuristiken — wenn der exakte Solver aussteigt\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": [ "## Simulated Annealing\n", "\n", "`Simulated_Annealing.py`\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#!/usr/bin/env python3\n", "\n", "# Simulated_Annealing.py\n", "\"\"\"\n", "Kapitel Metaheuristiken: Simulated Annealing an der Lackieranlage.\n", "\n", "Aufgabe: n Auftraege in eine Reihenfolge bringen. Zwischen zwei Auftraegen\n", "faellt eine Ruestzeit an - die Anlage muss gereinigt werden. Wie lange das\n", "dauert, haengt von BEIDEN Auftraegen ab: Ein Wechsel innerhalb derselben\n", "Produktfamilie kostet fast nichts, ein Wechsel von Dunkel nach Hell ist teuer,\n", "und zwischen manchen Familien ist der Reinigungsaufwand schlicht hoch. Gesucht\n", "ist die Reihenfolge mit der kleinsten Summe der Ruestzeiten.\n", "\n", "Das Programm zeigt drei Dinge:\n", "\n", " 1. Warum die Faustregel \"immer der billigste naechste Auftrag\" in eine\n", " Sackgasse laeuft - und wie weit man sie mit lokaler Suche verbessert.\n", " 2. Wie gross der Beitrag der Verschlechterungen WIRKLICH ist. Die Antwort\n", " faellt bescheidener aus als das Lehrbuch verspricht, und genau das ist\n", " der Grund, den Vergleich immer mitzurechnen.\n", " 3. Dass die Starttemperatur kein Beiwerk ist: zu heiss macht die Suche\n", " nutzlos, und man sieht es an einer einzigen Kennzahl kommen.\n", "\n", "Zum Budget: Der Abkuehlplan laeuft ueber eine feste ZUGZAHL, nicht ueber eine\n", "Sekundenzahl. Im Betrieb hat man zwar ein Zeitbudget - fuer einen VERGLEICH ist\n", "die Zugzahl aber das ehrlichere Mass: Sie ist auf jeder Maschine dieselbe, und\n", "die abgedruckten Zahlen unten lassen sich damit nachrechnen. Die gemessene\n", "Laufzeit steht trotzdem dabei.\n", "\n", "Benoetigt: numpy\n", "\"\"\"\n", "\n", "from __future__ import annotations\n", "\n", "import math\n", "import time\n", "\n", "import numpy as np\n", "\n", "ZUEGE = 400_000 # Zugbudget je Lauf (statt Sekunden: reproduzierbar)\n", "SAAT = 11 # feste Saat -> reproduzierbare Instanz\n", "\n", "\n", "# --- 1. Die Instanz ---------------------------------------------------------\n", "\n", "def erzeuge_ruestmatrix(n: int, saat: int = SAAT) -> np.ndarray:\n", " \"\"\"Ruestzeiten in Minuten zwischen je zwei Auftraegen.\n", "\n", " Die Zahl der Produktfamilien waechst mit der Auftragszahl (eine Familie je\n", " zehn Auftraege). Sonst wuerde das Problem mit wachsendem n LEICHTER: Bei\n", " fester Familienzahl haette der Plan irgendwann nur noch ein paar grosse\n", " Bloecke, und jede Faustregel faende ihn.\n", " \"\"\"\n", " rng = np.random.default_rng(saat)\n", " familien = max(6, n // 10)\n", " # Reinigungsaufwand zwischen den Familien - unregelmaessig und asymmetrisch,\n", " # so wie im Betrieb: Von Klarlack auf Rot ist etwas anderes als umgekehrt.\n", " zwischen = rng.integers(8, 60, (familien, familien))\n", " np.fill_diagonal(zwischen, 2)\n", "\n", " familie = rng.integers(0, familien, n)\n", " farbe = rng.integers(0, 10, n) # 0 = weiss ... 9 = schwarz\n", "\n", " matrix = np.zeros((n, n), dtype=np.int64)\n", " for i in range(n):\n", " for j in range(n):\n", " if i != j:\n", " # Dunkel -> hell kostet zusaetzlich: die Anlage muss heller\n", " # werden, das braucht mehr Spuelgaenge.\n", " matrix[i, j] = (zwischen[familie[i], familie[j]]\n", " + 3 * max(0, farbe[i] - farbe[j]))\n", " return matrix\n", "\n", "\n", "def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:\n", " \"\"\"Summe der Ruestzeiten einer Reihenfolge (offene Kette, keine Rundreise).\"\"\"\n", " return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))\n", "\n", "\n", "def faustregel(matrix: np.ndarray) -> list[int]:\n", " \"\"\"Immer der billigste noch offene Auftrag - 'naechster Nachbar'.\n", "\n", " So plant ein Meister von Hand, und es ist keine schlechte Regel. Ihr Fehler\n", " ist die Kurzsichtigkeit: Sie spart am Anfang und laesst die teuren Wechsel\n", " fuer das Ende uebrig, wo keine Wahl mehr bleibt.\n", " \"\"\"\n", " n = len(matrix)\n", " offen = set(range(1, n))\n", " reihe = [0]\n", " while offen:\n", " naechster = min(offen, key=lambda j: matrix[reihe[-1], j])\n", " reihe.append(naechster)\n", " offen.discard(naechster)\n", " return reihe\n", "\n", "\n", "# --- 2. Der Zug und seine Kostenaenderung -----------------------------------\n", "\n", "def delta_verschieben(reihe: list[int], matrix: np.ndarray,\n", " von: int, nach: int):\n", " \"\"\"Auftrag von Position 'von' an Position 'nach' versetzen.\n", "\n", " Liefert (Kostenaenderung, Restliste, Einfuegeposition) oder None, wenn der\n", " Zug nichts aendert.\n", "\n", " DAS IST DIE WICHTIGSTE FUNKTION DES PROGRAMMS. Sie berechnet die\n", " Kostenaenderung aus HOECHSTENS SECHS Matrixeintraegen, statt die\n", " Reihenfolge neu durchzusummieren. Der Unterschied ist nicht kosmetisch:\n", " Die volle Summe kostet O(n) je Zug, diese Rechnung O(1). Bei 500 Auftraegen\n", " sind das rund 500-mal mehr geprueste Zuege im selben Zeitbudget - und eine\n", " Metaheuristik lebt von der Zahl der Zuege.\n", "\n", " Herausgerissen wird der Auftrag aus zwei Kanten, seine Nachbarn ruecken\n", " zusammen (eine neue Kante). Eingefuegt wird er zwischen zwei andere\n", " Nachbarn, deren bisherige Kante dadurch verschwindet.\n", " \"\"\"\n", " n = len(reihe)\n", " if von == nach or nach == von + 1:\n", " return None\n", "\n", " heraus = 0\n", " if von > 0:\n", " heraus += matrix[reihe[von - 1], reihe[von]]\n", " if von < n - 1:\n", " heraus += matrix[reihe[von], reihe[von + 1]]\n", " if 0 < von < n - 1: # die Nachbarn ruecken zusammen\n", " heraus -= matrix[reihe[von - 1], reihe[von + 1]]\n", "\n", " rest = reihe[:von] + reihe[von + 1:]\n", " stelle = nach if nach < von else nach - 1\n", "\n", " hinein = 0\n", " if stelle > 0:\n", " hinein += matrix[rest[stelle - 1], reihe[von]]\n", " if stelle < len(rest):\n", " hinein += matrix[reihe[von], rest[stelle]]\n", " if 0 < stelle < len(rest): # die aufgetrennte Kante faellt weg\n", " hinein -= matrix[rest[stelle - 1], rest[stelle]]\n", "\n", " return int(hinein - heraus), rest, stelle\n", "\n", "\n", "# --- 3. Die Suche -----------------------------------------------------------\n", "\n", "class Verlauf:\n", " \"\"\"Was ein Lauf ausser dem Ergebnis noch verraet.\"\"\"\n", "\n", " def __init__(self) -> None:\n", " self.schritte = 0\n", " self.verschlechterungen_geprueft = 0\n", " self.verschlechterungen_genommen = 0\n", " self.schlechtester_zustand = 0 # wie weit die Suche abgedriftet ist\n", " self.sekunden = 0.0 # nur zur Information, nicht zum Vergleich\n", "\n", " @property\n", " def annahmequote(self) -> float:\n", " if not self.verschlechterungen_geprueft:\n", " return 0.0\n", " return self.verschlechterungen_genommen / self.verschlechterungen_geprueft\n", "\n", "\n", "def suche(matrix: np.ndarray, zuege: int, start_temperatur: float,\n", " end_temperatur: float = 0.05, saat: int = 1,\n", " bergsteigen: bool = False) -> tuple[list[int], int, Verlauf]:\n", " \"\"\"Simulated Annealing (oder reines Bergsteigen, wenn bergsteigen=True).\n", "\n", " Der einzige Unterschied zwischen beiden steht in der Annahmezeile: Das\n", " Bergsteigen nimmt nur Verbesserungen, SA nimmt Verschlechterungen mit einer\n", " Wahrscheinlichkeit an, die mit der Zeit gegen null geht.\n", " \"\"\"\n", " rng = np.random.default_rng(saat)\n", " n = len(matrix)\n", " reihe = faustregel(matrix)\n", " kosten = gesamtruestzeit(reihe, matrix)\n", " beste, beste_kosten = reihe[:], kosten\n", " verlauf = Verlauf()\n", " verlauf.schlechtester_zustand = kosten\n", "\n", " start = time.perf_counter()\n", " for zug in range(zuege):\n", " verlauf.schritte += 1\n", " # Geometrischer Abkuehlplan ueber das Zugbudget.\n", " temperatur = start_temperatur * (end_temperatur / start_temperatur) \\\n", " ** (zug / zuege)\n", "\n", " von = int(rng.integers(0, n))\n", " nach = int(rng.integers(0, n + 1))\n", " ergebnis = delta_verschieben(reihe, matrix, von, nach)\n", " if ergebnis is None:\n", " continue\n", " aenderung, rest, stelle = ergebnis\n", "\n", " if aenderung <= 0:\n", " annehmen = True\n", " else:\n", " verlauf.verschlechterungen_geprueft += 1\n", " # Metropolis-Kriterium: je groesser die Verschlechterung und je\n", " # kaelter es ist, desto unwahrscheinlicher.\n", " annehmen = (not bergsteigen\n", " and rng.random() < math.exp(-aenderung / temperatur))\n", " verlauf.verschlechterungen_genommen += annehmen\n", "\n", " if annehmen:\n", " reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]\n", " kosten += aenderung\n", " verlauf.schlechtester_zustand = max(verlauf.schlechtester_zustand,\n", " kosten)\n", " if kosten < beste_kosten:\n", " beste, beste_kosten = reihe[:], kosten\n", "\n", " verlauf.sekunden = time.perf_counter() - start\n", " return beste, beste_kosten, verlauf\n", "\n", "\n", "if __name__ == \"__main__\":\n", " N = 200\n", " matrix = erzeuge_ruestmatrix(N)\n", " start_reihe = faustregel(matrix)\n", " start_kosten = gesamtruestzeit(start_reihe, matrix)\n", "\n", " print(\"=\" * 78)\n", " print(\" SIMULATED ANNEALING AN DER LACKIERANLAGE\")\n", " print(\"=\" * 78)\n", " print(f\"{N} Auftraege, {max(6, N // 10)} Produktfamilien, \"\n", " f\"Zugbudget {ZUEGE:,} je Lauf.\\n\")\n", " print(f\"Faustregel 'billigster naechster Auftrag': \"\n", " f\"{start_kosten:,} Minuten Ruestzeit\")\n", "\n", " # --- Wie gross sind die Zuege ueberhaupt? ---------------------------\n", " # Ohne diese Zahl kann man die Temperatur nicht waehlen: Das\n", " # Metropolis-Kriterium vergleicht die Verschlechterung MIT der Temperatur.\n", " rng = np.random.default_rng(0)\n", " stichprobe = []\n", " for _ in range(5000):\n", " e = delta_verschieben(start_reihe, matrix,\n", " int(rng.integers(0, N)), int(rng.integers(0, N + 1)))\n", " if e:\n", " stichprobe.append(abs(e[0]))\n", " print(f\"Typische Zuggroesse |Delta|: Median {np.median(stichprobe):.0f}, \"\n", " f\"90 %-Quantil {np.quantile(stichprobe, 0.9):.0f} Minuten\")\n", "\n", " # --- Bergsteigen gegen Annealing -------------------------------------\n", " print(\"\\n\" + \"-\" * 78)\n", " print(\"Nur Verbesserungen annehmen (Bergsteigen) gegen Annealing:\\n\")\n", " _, berg, berg_verlauf = suche(matrix, ZUEGE, 1.0, bergsteigen=True)\n", " _, gluehen, gluehen_verlauf = suche(matrix, ZUEGE, 1.0)\n", " print(f\" {'Faustregel (Start)':<34} {start_kosten:>7,} Minuten\")\n", " print(f\" {'Bergsteigen':<34} {berg:>7,} Minuten \"\n", " f\"({(start_kosten - berg) / start_kosten * 100:5.1f} % besser)\")\n", " print(f\" {'Simulated Annealing':<34} {gluehen:>7,} Minuten \"\n", " f\"({(start_kosten - gluehen) / start_kosten * 100:5.1f} % besser)\")\n", " print(f\"\\n Beide pruefen genau {berg_verlauf.schritte:,} Zuege \"\n", " f\"({berg_verlauf.sekunden:.1f} bzw. {gluehen_verlauf.sekunden:.1f} Sekunden).\")\n", " print(f\" Annealing nimmt davon {gluehen_verlauf.annahmequote * 100:.2f} % der \"\n", " f\"VERSCHLECHTERUNGEN an, Bergsteigen keine.\")\n", " print(\"\\n Bemerkenswert ist, wie klein der Abstand ist: Das simple\")\n", " print(f\" Bergsteigen holt hier bereits {(start_kosten - berg) / (start_kosten - gluehen) * 100:.0f} % dessen, was Annealing\")\n", " print(\" schafft. Wer eine Metaheuristik einsetzt, sollte diesen Vergleich\")\n", " print(\" IMMER mitrechnen - sonst schreibt man einer aufwendigen Methode\")\n", " print(\" gut, was schon die einfache geliefert haette.\")\n", "\n", " # --- Die Temperatur ist kein Beiwerk ---------------------------------\n", " print(\"\\n\" + \"-\" * 78)\n", " print(\"Und warum die Starttemperatur ausgemessen gehoert:\\n\")\n", " print(f\" {'Start-T':>8} {'Ruestzeit':>11} {'gg. Faustregel':>15} \"\n", " f\"{'angenommene':>12} {'schlechtester':>14}\")\n", " print(f\" {'':>8} {'':>11} {'':>15} {'Verschlecht.':>12} {'Zwischenwert':>14}\")\n", " print(\" \" + \"-\" * 64)\n", " ergebnisse, verlaeufe = {}, {}\n", " for temperatur in (0.5, 1.0, 2.0, 4.0, 8.0):\n", " _, wert, verlauf = suche(matrix, ZUEGE, temperatur)\n", " ergebnisse[temperatur] = wert\n", " verlaeufe[temperatur] = verlauf\n", " gewinn = (start_kosten - wert) / start_kosten * 100\n", " print(f\" {temperatur:>8.1f} {wert:>11,} {gewinn:>13.1f} % \"\n", " f\"{verlauf.annahmequote * 100:>11.2f} % \"\n", " f\"{verlauf.schlechtester_zustand:>14,}\")\n", "\n", " beste_temperatur = min(ergebnisse, key=ergebnisse.get)\n", " heiss = verlaeufe[8.0]\n", " print(f\"\\n Bester Wert bei T0 = {beste_temperatur}.\")\n", " print(\"\\n Die letzten beiden Spalten erklaeren, warum es nach oben kippt.\")\n", " print(f\" Bei T0 = 8 gehen nur {heiss.annahmequote * 100:.2f} % der Verschlechterungen\")\n", " print(\" durch - wenig genug, dass man es fuer harmlos halten koennte. Es\")\n", " print(f\" genuegt aber: Die Suche driftet bis auf {heiss.schlechtester_zustand:,} Minuten ab, das\")\n", " print(f\" {heiss.schlechtester_zustand / start_kosten:.1f}-fache der Startloesung, und findet im Zugbudget nicht\")\n", " print(\" mehr zurueck. Vom Ergebnis bleibt fast nichts uebrig: Die Suche hat\")\n", " print(f\" in {ZUEGE:,} Zuegen nie etwas GESEHEN, das mehr als\")\n", " print(f\" {(start_kosten - ergebnisse[8.0]) / start_kosten * 100:.1f} % unter der Startloesung lag.\")\n", " print(\"\\n Bemerkenswert ist die andere Richtung: Die besten Werte entstehen\")\n", " print(\" bei Annahmequoten nahe null. Die Lehrbuchregel 'anfangs sollen\")\n", " print(\" 20 bis 50 Prozent der Verschlechterungen durchgehen' fuehrt hier in\")\n", " print(\" die Irre - sie stammt aus Problemen mit sehr kleinen Zuggroessen.\")\n", " print(\" Bei Ruestzeiten in Minuten ist ein einziger schlechter Zug teuer.\")\n", " print(\"\\n Was stattdessen traegt: Den SCHLECHTESTEN Zwischenwert protokollieren\")\n", " print(\" und T0 so waehlen, dass er die Startloesung nicht wesentlich\")\n", " print(\" ueberschreitet. Diese eine Zahl haette hier auf Anhieb gezeigt, dass\")\n", " print(\" T0 = 8 unbrauchbar ist - ohne dass man das Endergebnis abwarten muss.\")\n", " print(\"=\" * 78)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Ab wann lohnt es sich?\n", "\n", "`Metaheuristik_vs_Exakt.py`\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#!/usr/bin/env python3\n", "\n", "# Metaheuristik_vs_Exakt.py\n", "\"\"\"\n", "Kapitel Metaheuristiken: Ab wann lohnt sich eine Metaheuristik? Die Antwort ist eine Zahl.\n", "\n", "Die verbreitete Regel lautet: \"Kleine Probleme exakt, grosse mit\n", "Metaheuristiken.\" Sie ist richtig - aber voellig nutzlos, solange niemand sagt,\n", "wo 'gross' anfaengt. Dieses Programm misst es fuer die Ruestzeitenaufgabe aus\n", "Simulated_Annealing.py nach, und das Ergebnis ueberrascht in beide Richtungen:\n", "\n", " * Bei 60 Auftraegen loest CP-SAT BEWEISBAR optimal, in wenigen Sekunden.\n", " Die Metaheuristik ist hier schlicht die schlechtere Wahl - sie liefert ein\n", " schwaecheres Ergebnis und weiss nicht einmal, wie schwach.\n", " * Bei 200 Auftraegen liegt CP-SAT ohne Optimalitaetsbeweis vorne.\n", " * Erst bei 500 Auftraegen kippt es: Dort gibt CP-SAT im Zeitlimit eine\n", " Loesung aus, die SCHLECHTER ist als die Faustregel eines Meisters.\n", "\n", "Der Punkt des Kapitels steht in der letzten Spalte: Die Schranke. Nur der\n", "exakte Solver sagt, wie gut die Loesung sein KANN. Eine Metaheuristik allein\n", "liefert eine Zahl ohne Massstab - man weiss nie, ob man 2 % oder 40 % neben dem\n", "Optimum liegt.\n", "\n", "ZUR REPRODUZIERBARKEIT: Die Spalte 'Annealing' ist auf jeder Maschine gleich -\n", "sie laeuft ueber ein festes ZUGbudget. Die CP-SAT-Spalten sind es nicht: Ein\n", "Zeitlimit ist ein Wanduhr-Limit, und wie weit der Solver in 30 Sekunden kommt,\n", "haengt von der Maschine ab. CP-SAT kennt zwar ein deterministisches Zeitmass\n", "(max_deterministic_time), das aber in einer Groessenordnung rechnet, die fuer\n", "ein Buchbeispiel unbrauchbar langsam ist. Die Zahlen unten sind also\n", "hardwareabhaengig - die AUSSAGE der Tabelle ist es nicht, sie ist auf\n", "verschiedenen Groessen und in mehreren Laeufen stabil.\n", "\n", "WICHTIG: Hier wird nur ortools importiert, nicht highspy (Kapitel Oekosystem).\n", "\n", "Benoetigt: numpy, ortools\n", "\"\"\"\n", "\n", "from __future__ import annotations\n", "\n", "import time\n", "\n", "import numpy as np\n", "from ortools.sat.python import cp_model\n", "\n", "ZUEGE = 400_000 # Zugbudget der Metaheuristik (wie Simulated_Annealing.py)\n", "ZEITLIMIT = 30.0 # Zeitbudget des exakten Solvers\n", "GROESSEN = (60, 200, 500)\n", "\n", "\n", "# --- Instanz, Faustregel und Suche: identisch zu Simulated_Annealing.py -----\n", "\n", "def erzeuge_ruestmatrix(n: int, saat: int = 11) -> np.ndarray:\n", " rng = np.random.default_rng(saat)\n", " familien = max(6, n // 10)\n", " zwischen = rng.integers(8, 60, (familien, familien))\n", " np.fill_diagonal(zwischen, 2)\n", " familie = rng.integers(0, familien, n)\n", " farbe = rng.integers(0, 10, n)\n", " matrix = np.zeros((n, n), dtype=np.int64)\n", " for i in range(n):\n", " for j in range(n):\n", " if i != j:\n", " matrix[i, j] = (zwischen[familie[i], familie[j]]\n", " + 3 * max(0, farbe[i] - farbe[j]))\n", " return matrix\n", "\n", "\n", "def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:\n", " return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))\n", "\n", "\n", "def faustregel(matrix: np.ndarray) -> list[int]:\n", " n = len(matrix)\n", " offen = set(range(1, n))\n", " reihe = [0]\n", " while offen:\n", " naechster = min(offen, key=lambda j: matrix[reihe[-1], j])\n", " reihe.append(naechster)\n", " offen.discard(naechster)\n", " return reihe\n", "\n", "\n", "def delta_verschieben(reihe, matrix, von, nach):\n", " n = len(reihe)\n", " if von == nach or nach == von + 1:\n", " return None\n", " heraus = 0\n", " if von > 0:\n", " heraus += matrix[reihe[von - 1], reihe[von]]\n", " if von < n - 1:\n", " heraus += matrix[reihe[von], reihe[von + 1]]\n", " if 0 < von < n - 1:\n", " heraus -= matrix[reihe[von - 1], reihe[von + 1]]\n", " rest = reihe[:von] + reihe[von + 1:]\n", " stelle = nach if nach < von else nach - 1\n", " hinein = 0\n", " if stelle > 0:\n", " hinein += matrix[rest[stelle - 1], reihe[von]]\n", " if stelle < len(rest):\n", " hinein += matrix[reihe[von], rest[stelle]]\n", " if 0 < stelle < len(rest):\n", " hinein -= matrix[rest[stelle - 1], rest[stelle]]\n", " return int(hinein - heraus), rest, stelle\n", "\n", "\n", "def annealing(matrix: np.ndarray, zuege: int = ZUEGE,\n", " start_temperatur: float = 1.0, saat: int = 1) -> tuple[int, float]:\n", " \"\"\"Dieselbe Suche wie in Simulated_Annealing.py, hier nur als Vergleichswert.\"\"\"\n", " import math\n", "\n", " rng = np.random.default_rng(saat)\n", " n = len(matrix)\n", " reihe = faustregel(matrix)\n", " kosten = gesamtruestzeit(reihe, matrix)\n", " beste_kosten = kosten\n", "\n", " t0 = time.perf_counter()\n", " for zug in range(zuege):\n", " temperatur = start_temperatur * (0.05 / start_temperatur) ** (zug / zuege)\n", " von = int(rng.integers(0, n))\n", " nach = int(rng.integers(0, n + 1))\n", " ergebnis = delta_verschieben(reihe, matrix, von, nach)\n", " if ergebnis is None:\n", " continue\n", " aenderung, rest, stelle = ergebnis\n", " if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):\n", " reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]\n", " kosten += aenderung\n", " beste_kosten = min(beste_kosten, kosten)\n", " return beste_kosten, time.perf_counter() - t0\n", "\n", "\n", "# --- Der exakte Weg: CP-SAT mit AddCircuit ---------------------------------\n", "\n", "def exakt(matrix: np.ndarray, zeitlimit: float = ZEITLIMIT):\n", " \"\"\"Reihenfolge als Rundreise mit einem kostenlosen Hilfsknoten.\n", "\n", " AddCircuit verlangt einen geschlossenen Kreis. Unsere Aufgabe ist aber eine\n", " offene Kette: Der erste Auftrag hat keinen Vorgaenger, der letzte keinen\n", " Nachfolger. Der uebliche Kniff ist ein zusaetzlicher Hilfsknoten n, dessen\n", " Kanten nichts kosten. Der Kreis laeuft dann Hilfsknoten -> Kette ->\n", " Hilfsknoten, und die Kosten des Kreises sind genau die der Kette.\n", "\n", " AddCircuit ist der Grund, warum CP-SAT hier so lange mithaelt: Die\n", " Nebenbedingung sorgt selbst dafuer, dass keine Teilkreise entstehen. Wer\n", " dasselbe als MILP formuliert, braucht dafuer entweder exponentiell viele\n", " Ungleichungen oder die schwache MTZ-Formulierung (Kapitel Graphen).\n", " \"\"\"\n", " n = len(matrix)\n", " modell = cp_model.CpModel()\n", " kanten, ziel = [], []\n", " for i in range(n + 1):\n", " for j in range(n + 1):\n", " if i == j:\n", " continue\n", " aktiv = modell.NewBoolVar(f\"kante_{i}_{j}\")\n", " kanten.append((i, j, aktiv))\n", " if i < n and j < n: # Hilfsknoten kostet nichts\n", " ziel.append(int(matrix[i, j]) * aktiv)\n", " modell.AddCircuit(kanten)\n", " modell.Minimize(sum(ziel))\n", "\n", " loeser = cp_model.CpSolver()\n", " loeser.parameters.max_time_in_seconds = zeitlimit\n", " loeser.parameters.num_workers = 8\n", " loeser.parameters.random_seed = 1\n", " t0 = time.perf_counter()\n", " status = loeser.Solve(modell)\n", " dauer = time.perf_counter() - t0\n", " return (loeser.StatusName(status), int(loeser.ObjectiveValue()),\n", " int(loeser.BestObjectiveBound()), dauer)\n", "\n", "\n", "if __name__ == \"__main__\":\n", " print(\"=\" * 88)\n", " print(\" AB WANN LOHNT SICH DIE METAHEURISTIK?\")\n", " print(\"=\" * 88)\n", " print(f\"Dieselbe Aufgabe in drei Groessen. Metaheuristik: {ZUEGE:,} Zuege. \"\n", " f\"Exakt: {ZEITLIMIT:.0f} s Zeitlimit.\\n\")\n", "\n", " print(f\"{'Auftraege':>10} {'Faustregel':>11} {'Annealing':>10} \"\n", " f\"{'CP-SAT':>9} {'Status':>10} {'Schranke':>9} {'CP-Zeit':>9}\")\n", " print(\"-\" * 88)\n", "\n", " zeilen = []\n", " for n in GROESSEN:\n", " matrix = erzeuge_ruestmatrix(n)\n", " start = gesamtruestzeit(faustregel(matrix), matrix)\n", " heuristisch, _ = annealing(matrix)\n", " status, wert, schranke, dauer = exakt(matrix)\n", " zeilen.append((n, start, heuristisch, wert, status, schranke, dauer))\n", " print(f\"{n:>10} {start:>11,} {heuristisch:>10,} {wert:>9,} \"\n", " f\"{status:>10} {schranke:>9,} {dauer:>8.1f}s\")\n", "\n", " print(\"-\" * 88)\n", " print(\"\\nWas in jeder Zeile steht:\\n\")\n", " for n, start, heur, wert, status, schranke, _ in zeilen:\n", " if status == \"OPTIMAL\":\n", " urteil = (f\"CP-SAT beweist das Optimum. Annealing liegt \"\n", " f\"{(heur - wert) / wert * 100:.1f} % daneben - und haette\")\n", " zweite = \" das ohne den exakten Lauf nicht erfahren.\"\n", " elif wert <= heur:\n", " urteil = (f\"CP-SAT liegt {(heur - wert) / heur * 100:.1f} % vor Annealing, \"\n", " f\"ohne Beweis (Gap {(wert - schranke) / wert * 100:.1f} %).\")\n", " zweite = \" Auch ohne Optimalitaetsbeweis ist es die bessere Wahl.\"\n", " else:\n", " urteil = (f\"UMSCHLAGPUNKT: CP-SAT ist {(wert - heur) / heur * 100:.1f} % \"\n", " f\"SCHLECHTER als Annealing\")\n", " zweite = (f\" und sogar {(wert - start) / start * 100:.1f} % schlechter \"\n", " f\"als die Faustregel des Meisters.\")\n", " print(f\" {n:>4} Auftraege: {urteil}\")\n", " print(zweite)\n", "\n", " n, start, heur, wert, status, schranke, _ = zeilen[-1]\n", " print(\"\\n\" + \"=\" * 88)\n", " print(\" DIE SPALTE, DIE MAN NICHT WEGLASSEN DARF\")\n", " print(\"=\" * 88)\n", " print(\"Die Schranke ist der eigentliche Ertrag des exakten Solvers - auch dann,\")\n", " print(\"wenn seine LOESUNG unbrauchbar ist.\")\n", " print()\n", " print(f\"Bei {n} Auftraegen liefert Annealing {heur:,} Minuten. Klingt gut: \"\n", " f\"{(start - heur) / start * 100:.1f} %\")\n", " print(\"besser als die Faustregel. Die Schranke aus demselben CP-SAT-Lauf, dessen\")\n", " print(f\"Loesung wir gerade verworfen haben, sagt aber: Unter {schranke:,} Minuten geht\")\n", " print(f\"es nicht. Zwischen {schranke:,} und {heur:,} liegen {(heur - schranke) / heur * 100:.0f} % - so viel Luft\")\n", " print(\"kann noch nach oben sein.\")\n", " print()\n", " print(\"Das ist der Unterschied zwischen 'besser als vorher' und 'gut'. Eine\")\n", " print(\"Metaheuristik allein kann nur das Erste. Deshalb gehoert auch dann ein\")\n", " print(\"exakter Lauf dazu, wenn man am Ende die heuristische Loesung einsetzt:\")\n", " print(\"nicht wegen seiner Loesung, sondern wegen seiner Schranke.\")\n", " print(\"=\" * 88)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Large Neighborhood Search\n", "\n", "`Large_Neighborhood_Search.py`\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#!/usr/bin/env python3\n", "\n", "# Large_Neighborhood_Search.py\n", "\"\"\"\n", "Kapitel Metaheuristiken: Zerstoeren und exakt reparieren - der Solver im Dienst der Heuristik.\n", "\n", "Metaheuristik_vs_Exakt.py endet mit einem Widerspruch. Bei 500 Auftraegen gilt:\n", "\n", " * Der exakte Solver scheitert an der GROESSE - seine Loesung nach 30 Sekunden\n", " ist schlechter als die Faustregel eines Meisters.\n", " * Die Metaheuristik kommt weiter, laesst aber laut Schranke noch rund ein\n", " Viertel liegen. Ihre Zuege sind zu kleinteilig: Sie verschiebt jeweils\n", " EINEN Auftrag und kann eine ganze Passage nicht auf einmal umbauen.\n", "\n", "Large Neighborhood Search loest den Widerspruch, indem sie beide einsetzt -\n", "jeden fuer das, was er kann:\n", "\n", " ZERSTOEREN Ein Stueck des Plans herausbrechen (hier: ein zusammen-\n", " haengendes Fenster von 20 Auftraegen).\n", " REPARIEREN Genau dieses Stueck EXAKT neu optimieren. 20 Auftraege sind\n", " fuer CP-SAT eine Kleinigkeit, 500 sind es nicht.\n", " UEBERNEHMEN Nur behalten, wenn der Gesamtplan besser wurde.\n", "\n", "Der Solver bekommt also nicht mehr das ganze Problem, sondern immer wieder ein\n", "kleines. Das ist der ganze Trick, und er ist in der Praxis der wichtigste\n", "Baustein dieses Kapitels.\n", "\n", "ZUR REPRODUZIERBARKEIT: Alle Laeufe haben eine feste RUNDENzahl, nicht ein\n", "Zeitbudget - die Ergebniswerte sind damit auf jeder Maschine gleich. Die\n", "Laufzeiten daneben sind hardwareabhaengig.\n", "\n", "Benoetigt: numpy, ortools\n", "\"\"\"\n", "\n", "from __future__ import annotations\n", "\n", "import math\n", "import time\n", "\n", "import numpy as np\n", "from ortools.sat.python import cp_model\n", "\n", "FENSTER = 20 # so viele Auftraege werden je Runde herausgebrochen\n", "RUNDEN = 120\n", "ZUEGE = 400_000 # Zugbudget des Annealings (wie Simulated_Annealing.py)\n", "N = 500\n", "\n", "\n", "# --- Instanz und Faustregel (wie in Simulated_Annealing.py) ----------------\n", "\n", "def erzeuge_ruestmatrix(n: int, saat: int = 11) -> np.ndarray:\n", " rng = np.random.default_rng(saat)\n", " familien = max(6, n // 10)\n", " zwischen = rng.integers(8, 60, (familien, familien))\n", " np.fill_diagonal(zwischen, 2)\n", " familie = rng.integers(0, familien, n)\n", " farbe = rng.integers(0, 10, n)\n", " matrix = np.zeros((n, n), dtype=np.int64)\n", " for i in range(n):\n", " for j in range(n):\n", " if i != j:\n", " matrix[i, j] = (zwischen[familie[i], familie[j]]\n", " + 3 * max(0, farbe[i] - farbe[j]))\n", " return matrix\n", "\n", "\n", "def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:\n", " return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))\n", "\n", "\n", "def faustregel(matrix: np.ndarray) -> list[int]:\n", " n = len(matrix)\n", " offen = set(range(1, n))\n", " reihe = [0]\n", " while offen:\n", " naechster = min(offen, key=lambda j: matrix[reihe[-1], j])\n", " reihe.append(naechster)\n", " offen.discard(naechster)\n", " return reihe\n", "\n", "\n", "def delta_verschieben(reihe, matrix, von, nach):\n", " n = len(reihe)\n", " if von == nach or nach == von + 1:\n", " return None\n", " heraus = 0\n", " if von > 0:\n", " heraus += matrix[reihe[von - 1], reihe[von]]\n", " if von < n - 1:\n", " heraus += matrix[reihe[von], reihe[von + 1]]\n", " if 0 < von < n - 1:\n", " heraus -= matrix[reihe[von - 1], reihe[von + 1]]\n", " rest = reihe[:von] + reihe[von + 1:]\n", " stelle = nach if nach < von else nach - 1\n", " hinein = 0\n", " if stelle > 0:\n", " hinein += matrix[rest[stelle - 1], reihe[von]]\n", " if stelle < len(rest):\n", " hinein += matrix[reihe[von], rest[stelle]]\n", " if 0 < stelle < len(rest):\n", " hinein -= matrix[rest[stelle - 1], rest[stelle]]\n", " return int(hinein - heraus), rest, stelle\n", "\n", "\n", "def annealing(matrix: np.ndarray, zuege: int = ZUEGE,\n", " start_temperatur: float = 1.0, saat: int = 1) -> list[int]:\n", " rng = np.random.default_rng(saat)\n", " n = len(matrix)\n", " reihe = faustregel(matrix)\n", " kosten = gesamtruestzeit(reihe, matrix)\n", " beste, beste_kosten = reihe[:], kosten\n", " for zug in range(zuege):\n", " temperatur = start_temperatur * (0.05 / start_temperatur) ** (zug / zuege)\n", " von, nach = int(rng.integers(0, n)), int(rng.integers(0, n + 1))\n", " ergebnis = delta_verschieben(reihe, matrix, von, nach)\n", " if ergebnis is None:\n", " continue\n", " aenderung, rest, stelle = ergebnis\n", " if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):\n", " reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]\n", " kosten += aenderung\n", " if kosten < beste_kosten:\n", " beste, beste_kosten = reihe[:], kosten\n", " return beste\n", "\n", "\n", "# --- Der Reparaturschritt: ein kleines Problem, exakt geloest --------------\n", "\n", "def repariere_fenster(reihe: list[int], matrix: np.ndarray,\n", " a: int, b: int) -> list[int] | None:\n", " \"\"\"Ordnet reihe[a:b] optimal neu; die beiden Raender bleiben, wo sie sind.\n", "\n", " Die Raender festzuhalten ist entscheidend. Ohne sie waere das Teilproblem\n", " ein anderes als der Ausschnitt aus dem Gesamtplan: Der Uebergang vom\n", " Vorgaenger in das Fenster und aus dem Fenster in den Nachfolger gehoert\n", " zu den Kosten dazu. Genau dafuer steht der Hilfsknoten k - er vertritt\n", " beide Raender in einem.\n", " \"\"\"\n", " innen = reihe[a:b]\n", " k = len(innen)\n", " vorgaenger = reihe[a - 1] if a > 0 else None\n", " nachfolger_rand = reihe[b] if b < len(reihe) else None\n", "\n", " modell = cp_model.CpModel()\n", " kanten, ziel = [], []\n", " for i in range(k + 1):\n", " for j in range(k + 1):\n", " if i == j:\n", " continue\n", " aktiv = modell.NewBoolVar(f\"kante_{i}_{j}\")\n", " kanten.append((i, j, aktiv))\n", " if i < k and j < k:\n", " ziel.append(int(matrix[innen[i], innen[j]]) * aktiv)\n", " elif i == k and j < k and vorgaenger is not None:\n", " ziel.append(int(matrix[vorgaenger, innen[j]]) * aktiv)\n", " elif j == k and i < k and nachfolger_rand is not None:\n", " ziel.append(int(matrix[innen[i], nachfolger_rand]) * aktiv)\n", " modell.AddCircuit(kanten)\n", " modell.Minimize(sum(ziel))\n", "\n", " loeser = cp_model.CpSolver()\n", " loeser.parameters.num_workers = 1\n", " loeser.parameters.random_seed = 1\n", " loeser.parameters.max_time_in_seconds = 10.0\n", " status = loeser.Solve(modell)\n", " if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n", " # Auch das gehoert dazu: Wenn das Teilproblem nicht loest, bleibt der\n", " # Plan, wie er war. Eine LNS-Runde darf scheitern.\n", " return None\n", "\n", " nachfolger = {i: j for i, j, aktiv in kanten if loeser.Value(aktiv)}\n", " neue_folge, aktuell = [], nachfolger[k]\n", " while aktuell != k:\n", " neue_folge.append(innen[aktuell])\n", " aktuell = nachfolger[aktuell]\n", " return reihe[:a] + neue_folge + reihe[b:]\n", "\n", "\n", "def lns(reihe: list[int], matrix: np.ndarray, runden: int = RUNDEN,\n", " fenster: int = FENSTER, saat: int = 3):\n", " \"\"\"Zerstoeren, exakt reparieren, uebernehmen - so lange das Budget reicht.\"\"\"\n", " rng = np.random.default_rng(saat)\n", " kosten = gesamtruestzeit(reihe, matrix)\n", " verbesserungen = 0\n", " start = time.perf_counter()\n", " for _ in range(runden):\n", " a = int(rng.integers(0, len(reihe) - fenster))\n", " neu = repariere_fenster(reihe, matrix, a, a + fenster)\n", " if neu is None:\n", " continue\n", " neue_kosten = gesamtruestzeit(neu, matrix)\n", " if neue_kosten < kosten:\n", " reihe, kosten = neu, neue_kosten\n", " verbesserungen += 1\n", " return reihe, kosten, verbesserungen, time.perf_counter() - start\n", "\n", "\n", "if __name__ == \"__main__\":\n", " matrix = erzeuge_ruestmatrix(N)\n", " start_reihe = faustregel(matrix)\n", " start_kosten = gesamtruestzeit(start_reihe, matrix)\n", " SCHRANKE = 1768 # aus dem CP-SAT-Lauf in Metaheuristik_vs_Exakt.py\n", "\n", " print(\"=\" * 84)\n", " print(\" ZERSTOEREN UND EXAKT REPARIEREN\")\n", " print(\"=\" * 84)\n", " print(f\"{N} Auftraege. Je Runde werden {FENSTER} aufeinanderfolgende Auftraege\")\n", " print(f\"herausgebrochen und exakt neu geordnet, {RUNDEN} Runden lang.\\n\")\n", "\n", " print(\"Erst die Metaheuristik allein, dann LNS auf ihrem Ergebnis:\\n\")\n", " sa_reihe = annealing(matrix)\n", " sa_kosten = gesamtruestzeit(sa_reihe, matrix)\n", "\n", " lns_ab_faustregel = lns(start_reihe[:], matrix)\n", " lns_ab_annealing = lns(sa_reihe[:], matrix)\n", "\n", " print(f\" {'Verfahren':<34} {'Ruestzeit':>10} {'ueber Schranke':>15}\")\n", " print(\" \" + \"-\" * 62)\n", " for name, wert in [\n", " (\"Faustregel (ohne Solver)\", start_kosten),\n", " (\"CP-SAT allein, 30 s\", 2628),\n", " (\"Simulated Annealing\", sa_kosten),\n", " (\"LNS ab Faustregel\", lns_ab_faustregel[1]),\n", " (\"Annealing, dann LNS\", lns_ab_annealing[1])]:\n", " print(f\" {name:<34} {wert:>10,} {(wert - SCHRANKE) / SCHRANKE * 100:>13.1f} %\")\n", " print(f\" {'untere Schranke (CP-SAT)':<34} {SCHRANKE:>10,} {0.0:>13.1f} %\")\n", "\n", " print(f\"\\n LNS ab Faustregel: {lns_ab_faustregel[2]} von {RUNDEN} Runden brachten \"\n", " f\"eine Verbesserung ({lns_ab_faustregel[3]:.0f} s).\")\n", " print(f\" LNS ab Annealing: {lns_ab_annealing[2]} von {RUNDEN} Runden \"\n", " f\"({lns_ab_annealing[3]:.0f} s).\")\n", "\n", " # --- Die Fenstergroesse ----------------------------------------------\n", " # Verglichen wird bei ANNAEHERND GLEICHER ZEIT, nicht bei gleicher\n", " # Rundenzahl: Eine Runde mit Fenster 30 kostet ein Vielfaches einer Runde\n", " # mit Fenster 10. Wer nach Runden vergleicht, misst nur, dass groessere\n", " # Teilprobleme mehr finden - und uebersieht, was sie kosten. Die\n", " # Rundenzahlen unten sind so gewaehlt, dass alle drei Laeufe in derselben\n", " # Groessenordnung liegen.\n", " print(\"\\n\" + \"-\" * 84)\n", " print(\"Die Fenstergroesse ist die eine Stellschraube - und sie hat ein\")\n", " print(\"Optimum in der Mitte. Gleiche Zeit, verschiedene Fenster:\\n\")\n", " print(f\" {'Fenster':>8} {'Runden':>8} {'Ruestzeit':>11} {'Verbesser-':>12} {'Zeit':>8}\")\n", " print(f\" {'':>8} {'':>8} {'':>11} {'ungen':>12} {'':>8}\")\n", " print(\" \" + \"-\" * 52)\n", " fenster_ergebnis = {}\n", " for fenster, runden in ((10, 4000), (20, 150), (30, 20)):\n", " _, wert, verbesserungen, dauer = lns(sa_reihe[:], matrix,\n", " runden=runden, fenster=fenster)\n", " fenster_ergebnis[fenster] = wert\n", " print(f\" {fenster:>8} {runden:>8,} {wert:>11,} {verbesserungen:>12} \"\n", " f\"{dauer:>7.0f}s\")\n", "\n", " print(\"\\n Nach unten ist die Grenze nicht die Zeit, sondern die SAETTIGUNG:\")\n", " print(f\" Mit Fenster 10 bleiben von {4000:,} Runden nur eine Handvoll\")\n", " print(\" Verbesserungen uebrig. Zwanzig Auftraege lassen sich in einem Zug\")\n", " print(\" umbauen, zehn nicht - und was ein Fenster nicht umbauen kann, findet\")\n", " print(\" es auch in beliebig vielen Runden nicht.\")\n", " print(\"\\n Nach oben ist die Grenze die Rechenzeit: Fenster 30 findet pro Runde\")\n", " print(\" mehr, kommt in derselben Zeit aber nur auf einen Bruchteil der Runden\")\n", " print(f\" und landet bei {fenster_ergebnis[30]:,} statt {fenster_ergebnis[20]:,}. Eine Reparatur mit 30 Auftraegen\")\n", " print(\" ist eben genau das Problem, an dem der exakte Solver im Grossen\")\n", " print(\" scheitert - nur eine Nummer kleiner.\")\n", " print(\"\\n Diese Tabelle gehoert an den Anfang jedes LNS-Projekts. Sie zu raten\")\n", " print(\" statt zu messen ist der haeufigste Fehler beim Einsatz des Verfahrens.\")\n", "\n", " print(\"\\n\" + \"=\" * 84)\n", " print(\" WAS DAS HEISST\")\n", " print(\"=\" * 84)\n", " bester = lns_ab_annealing[1]\n", " print(f\"Der beste Plan kommt aus der Kombination: {bester:,} Minuten,\")\n", " print(f\"{(start_kosten - bester) / start_kosten * 100:.1f} % unter der Faustregel und \"\n", " f\"{(sa_kosten - bester) / sa_kosten * 100:.1f} % unter dem reinen Annealing.\")\n", " print()\n", " print(\"Weder der exakte Solver noch die Metaheuristik allein kommen dorthin.\")\n", " print(\"Der Solver scheitert an der Groesse, die Metaheuristik an der Kleinheit\")\n", " print(\"ihrer Zuege. LNS gibt dem Solver Teilprobleme in einer Groesse, die er\")\n", " print(\"beherrscht, und der Metaheuristik die Umbauten, die sie nicht kann.\")\n", " print()\n", " print(f\"Und die Ehrlichkeit zum Schluss: Auch {bester:,} liegt noch \"\n", " f\"{(bester - SCHRANKE) / SCHRANKE * 100:.0f} % ueber der\")\n", " print(\"Schranke. Ob dort wirklich noch so viel Luft ist oder ob die Schranke\")\n", " print(\"nur schwach ist, weiss man nicht - das ist die Lage, in der man mit\")\n", " print(\"einer Metaheuristik arbeitet. Man kennt seine Loesung, nicht ihren\")\n", " print(\"Abstand zum Optimum.\")\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 }