{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Kapitel 1: Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung\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": [ "## Warum Ausprobieren scheitert — mit eigener Rechnung\n", "\n", "`Brute_Force_Vergleich.py`\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#!/usr/bin/env python3\n", "\n", "# Brute_Force_Vergleich.py\n", "\"\"\"\n", "Kapitel Einfuehrung: Warum Ausprobieren scheitert.\n", "Vergleicht vollständige Enumeration mit einem Constraint-Solver an einem\n", "Zuordnungsproblem wachsender Größe (n Mitarbeiter auf n Aufgaben).\n", "\"\"\"\n", "\n", "import itertools\n", "import math\n", "import time\n", "\n", "import numpy as np\n", "from ortools.sat.python import cp_model\n", "\n", "\n", "def erzeuge_kostenmatrix(n: int, seed: int = 7) -> np.ndarray:\n", " \"\"\"Zufällige, aber reproduzierbare Kosten: Wer bearbeitet welche Aufgabe wie teuer?\"\"\"\n", " rng = np.random.default_rng(seed)\n", " return rng.integers(low=10, high=100, size=(n, n))\n", "\n", "\n", "def loese_brute_force(kosten: np.ndarray) -> tuple[float, tuple, int]:\n", " \"\"\"\n", " Probiert ALLE n! Zuordnungen durch und behält die beste.\n", " Rückgabe: (bester Kostenwert, beste Permutation, Anzahl geprüfter Kombinationen)\n", " \"\"\"\n", " n = len(kosten)\n", " bester_wert = math.inf\n", " beste_zuordnung = None\n", " geprueft = 0\n", "\n", " # itertools.permutations(range(n)) liefert nacheinander jede Reihenfolge\n", " for zuordnung in itertools.permutations(range(n)):\n", " # zuordnung[i] = Aufgabe, die Mitarbeiter i übernimmt\n", " wert = sum(kosten[i][zuordnung[i]] for i in range(n))\n", " geprueft += 1\n", " if wert < bester_wert:\n", " bester_wert = wert\n", " beste_zuordnung = zuordnung\n", "\n", " return bester_wert, beste_zuordnung, geprueft\n", "\n", "\n", "def loese_mit_solver(kosten: np.ndarray) -> tuple[float, tuple]:\n", " \"\"\"Dasselbe Problem als Constraint-Programm — der Solver probiert NICHT alles durch.\"\"\"\n", " n = len(kosten)\n", " modell = cp_model.CpModel()\n", "\n", " # x[i][j] = 1 <=> Mitarbeiter i übernimmt Aufgabe j\n", " x = [[modell.NewBoolVar(f\"x_{i}_{j}\") for j in range(n)] for i in range(n)]\n", "\n", " for i in range(n):\n", " modell.AddExactlyOne(x[i][j] for j in range(n)) # jeder genau eine Aufgabe\n", " for j in range(n):\n", " modell.AddExactlyOne(x[i][j] for i in range(n)) # jede Aufgabe genau einmal\n", "\n", " modell.Minimize(sum(int(kosten[i][j]) * x[i][j] for i in range(n) for j in range(n)))\n", "\n", " loeser = cp_model.CpSolver()\n", " loeser.parameters.max_time_in_seconds = 30.0\n", " status = loeser.Solve(modell)\n", " if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n", " raise RuntimeError(\"Solver fand keine Lösung.\")\n", "\n", " zuordnung = tuple(\n", " next(j for j in range(n) if loeser.Value(x[i][j]) == 1) for i in range(n)\n", " )\n", " return loeser.ObjectiveValue(), zuordnung\n", "\n", "\n", "if __name__ == \"__main__\":\n", " print(\"=\" * 72)\n", " print(\" VOLLSTÄNDIGES DURCHPROBIEREN vs. OPERATIONS RESEARCH\")\n", " print(\"=\" * 72)\n", " print(f\"{'n':>3} | {'Kombinationen':>18} | {'Brute Force':>14} | {'Solver':>10} | gleich?\")\n", " print(\"-\" * 72)\n", "\n", " for n in [4, 6, 8, 9, 10]: # n = 11 dauert bereits ~2 Minuten\n", " kosten = erzeuge_kostenmatrix(n)\n", "\n", " t0 = time.perf_counter()\n", " wert_bf, zuord_bf, geprueft = loese_brute_force(kosten)\n", " zeit_bf = time.perf_counter() - t0\n", "\n", " t0 = time.perf_counter()\n", " wert_or, zuord_or = loese_mit_solver(kosten)\n", " zeit_or = time.perf_counter() - t0\n", "\n", " gleich = \"ja\" if abs(wert_bf - wert_or) < 1e-9 else \"NEIN!\"\n", " print(f\"{n:>3} | {geprueft:>18,} | {zeit_bf:>11.4f} s | {zeit_or:>7.4f} s | {gleich}\")\n", "\n", " print(\"-\" * 72)\n", " # Hochrechnung: Wie lange bräuchte Brute Force bei n = 20?\n", " kombis_20 = math.factorial(20)\n", " # Messbasis: Prüfungen pro Sekunde aus dem letzten Lauf schätzen\n", " pro_sekunde = geprueft / max(zeit_bf, 1e-9)\n", " jahre = kombis_20 / pro_sekunde / (60 * 60 * 24 * 365.25)\n", " print(f\"Hochrechnung für n = 20: {kombis_20:,} Kombinationen\")\n", " print(f\"Bei gemessenen {pro_sekunde:,.0f} Prüfungen/s wären das {jahre:,.0f} Jahre.\")\n", " print(\"Der Solver löst dieselbe Instanz in Sekundenbruchteilen.\")\n", " print(\"=\" * 72)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Die Bausteine in der Praxis: eine Vorlage\n", "\n", "`Bausteine_Vorlage.py`\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#!/usr/bin/env python3\n", "\n", "# Bausteine_Vorlage.py\n", "\"\"\"\n", "Kapitel Einfuehrung: Die Bausteine-Vorlage als ausfuellbares Python-Geruest.\n", "\n", "Uebersetzt die Vier-Bausteine-Vorlage aus dem Abschnitt 'Die vier\n", "universellen Bausteine jedes OR-Problems' direkt in Code: Wer sie\n", "auf Papier ausgefuellt hat, kann sie fast unveraendert in ein loesbares\n", "Modell umsetzen. Angewendet auf das Baeckerei-Beispiel der Handrechnung.\n", "\"\"\"\n", "\n", "from dataclasses import dataclass\n", "from ortools.sat.python import cp_model\n", "\n", "\n", "@dataclass\n", "class Bausteine:\n", " \"\"\"Die vier Bausteine jedes OR-Problems - wortwoertlich aus der Vorlage\n", " im Abschnitt 'Die vier universellen Bausteine' uebernommen.\"\"\"\n", " variablen: str\n", " parameter: str\n", " zielfunktion: str\n", " harte_bedingungen: str\n", " weiche_bedingungen: str\n", "\n", " def zeige(self) -> None:\n", " print(\"=\" * 78)\n", " print(\" AUSGEFUELLTE BAUSTEINE-VORLAGE\")\n", " print(\"=\" * 78)\n", " for feld, wert in [\n", " (\"Entscheidungsvariablen\", self.variablen),\n", " (\"Parameter\", self.parameter),\n", " (\"Zielfunktion\", self.zielfunktion),\n", " (\"Harte Bedingungen\", self.harte_bedingungen),\n", " (\"Weiche Bedingungen\", self.weiche_bedingungen),\n", " ]:\n", " print(f\"{feld:<24}: {wert}\")\n", " print(\"=\" * 78)\n", "\n", "\n", "# --- Baeckerei-Beispiel aus der Handrechnung ---------------------------------\n", "VORLAGE = Bausteine(\n", " variablen=\"x1 = Anzahl Brote, x2 = Anzahl Broetchen-Zehnerpackungen \"\n", " \"(beide ganzzahlig >= 0)\",\n", " parameter=\"Deckungsbeitraege (2,50; 3,00) EUR; Mehlbedarf (0,5; 0,6) kg; \"\n", " \"Ofenzeit (4; 3) min; Kapazitaeten (90 kg, 600 min); Mindestmenge 40 Brote\",\n", " zielfunktion=\"max 2,5*x1 + 3,0*x2 (Einheit: Euro Deckungsbeitrag)\",\n", " harte_bedingungen=\"0,5*x1 + 0,6*x2 <= 90 (Mehl); 4*x1 + 3*x2 <= 600 (Ofen); \"\n", " \"x1 >= 40 (Vertrag)\",\n", " weiche_bedingungen=\"keine\",\n", ")\n", "\n", "\n", "def loese_baeckerei():\n", " \"\"\"Baut aus der ausgefuellten Vorlage mechanisch ein CP-SAT-Modell.\"\"\"\n", " modell = cp_model.CpModel()\n", " x1 = modell.NewIntVar(40, 1000, \"Brote\") # Vertrag: mindestens 40\n", " x2 = modell.NewIntVar(0, 1000, \"Broetchen_Zehner\")\n", "\n", " modell.Add(5 * x1 + 6 * x2 <= 900) # Mehl, x10 fuer Ganzzahligkeit\n", " modell.Add(4 * x1 + 3 * x2 <= 600) # Ofenzeit\n", "\n", " modell.Maximize(25 * x1 + 30 * x2) # Deckungsbeitrag x10\n", "\n", " loeser = cp_model.CpSolver()\n", " status = loeser.Solve(modell)\n", " if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n", " raise SystemExit(f\"Keine Loesung: {loeser.StatusName(status)}\")\n", " return loeser.Value(x1), loeser.Value(x2), loeser.ObjectiveValue() / 10\n", "\n", "\n", "if __name__ == \"__main__\":\n", " VORLAGE.zeige()\n", "\n", " print(\"\\n--- Aus der Vorlage mechanisch abgeleitetes Modell ---\")\n", " brote, broetchen, gewinn = loese_baeckerei()\n", " print(f\"Optimale Brote: {brote}\")\n", " print(f\"Optimale Broetchen-Zehner: {broetchen}\")\n", " print(f\"Maximaler Deckungsbeitrag: {gewinn:.2f} EUR\")\n", " print()\n", " print(\"Genau diese drei Codezeilen (Variablen, Add, Maximize) entstehen\")\n", " print(\"direkt aus den fuenf Zeilen der Vorlage - das ist der ganze Trick.\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Umsetzung mit Google OR-Tools\n", "\n", "`Bot_Allokation.py`\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#!/usr/bin/env python3\n", "\n", "# Bot_Allokation.py\n", "\"\"\"\n", "Kapitel Einfuehrung: Erstes Optimierungsmodell mit Google OR-Tools (CP-SAT).\n", "Problem: Server-Allokation für Trading-Bots.\n", "\n", "Modell mit expliziter Nebenbedingung statt versteckter Variablengrenze,\n", "vollständiger Statusauswertung und Ergebnisprüfung.\n", "\"\"\"\n", "\n", "from ortools.sat.python import cp_model\n", "\n", "# --- Parameter (die \"Fakten\" des Problems) ---------------------------------\n", "GEWINN_A, GEWINN_B = 150, 250 # Euro pro Bot und Tag\n", "CPU_A, CPU_B = 2, 5 # vCPU-Bedarf je Bot\n", "RAM_A, RAM_B = 4, 6 # GB Arbeitsspeicher je Bot\n", "CPU_GESAMT, RAM_GESAMT = 40, 60 # verfügbare Kapazitäten\n", "MAX_ARBITRAGE = 8 # Marktliquiditätsgrenze\n", "\n", "\n", "def loese_bot_allokation(zeitlimit_s: float = 10.0):\n", " \"\"\"Baut das Modell, löst es und gibt eine Auswertung aus.\"\"\"\n", "\n", " # 1. Modell instanziieren\n", " modell = cp_model.CpModel()\n", "\n", " # 2. Entscheidungsvariablen anlegen\n", " # NewIntVar(untere_grenze, obere_grenze, name)\n", " # Die obere Grenze ist bewusst großzügig; die echte Beschränkung\n", " # formulieren wir unten als Nebenbedingung, damit das Modell die\n", " # mathematische Formulierung 1:1 abbildet.\n", " x_a = modell.NewIntVar(0, 100, \"Arbitrage_Bots\")\n", " x_b = modell.NewIntVar(0, 100, \"Trend_Bots\")\n", "\n", " # 3. Nebenbedingungen definieren\n", " c_cpu = modell.Add(CPU_A * x_a + CPU_B * x_b <= CPU_GESAMT) # vCPU-Limit\n", " c_ram = modell.Add(RAM_A * x_a + RAM_B * x_b <= RAM_GESAMT) # RAM-Limit\n", " c_liq = modell.Add(x_a <= MAX_ARBITRAGE) # Marktliquidität\n", "\n", " # 4. Zielfunktion: Maximiere den Tagesgewinn\n", " modell.Maximize(GEWINN_A * x_a + GEWINN_B * x_b)\n", "\n", " # 5. Solver konfigurieren und ausführen\n", " loeser = cp_model.CpSolver()\n", " loeser.parameters.max_time_in_seconds = zeitlimit_s\n", " status = loeser.Solve(modell)\n", "\n", " # 6. Status auswerten -- IMMER alle Fälle behandeln\n", " status_text = loeser.StatusName(status)\n", " if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n", " print(f\"Keine verwertbare Lösung. Solver-Status: {status_text}\")\n", " if status == cp_model.INFEASIBLE:\n", " print(\"Das Modell ist unlösbar - die Bedingungen widersprechen sich.\")\n", " return None\n", "\n", " a, b = loeser.Value(x_a), loeser.Value(x_b)\n", " gewinn = loeser.ObjectiveValue()\n", "\n", " # 7. Ergebnis ausgeben\n", " print(\"=\" * 58)\n", " print(\" OPTIMALE BOT-ALLOKATION\")\n", " print(\"=\" * 58)\n", " print(f\"Solver-Status: {status_text}\"\n", " f\"{' (beweisbar optimal)' if status == cp_model.OPTIMAL else ' (zulässig, nicht bewiesen)'}\")\n", " print(f\"Arbitrage-Bots (x_A): {a} Instanzen\")\n", " print(f\"Trendfolge-Bots (x_B): {b} Instanzen\")\n", " print(f\"Täglicher Max-Gewinn: {gewinn:,.2f} EUR\")\n", "\n", " genutzt_cpu = CPU_A * a + CPU_B * b\n", " genutzt_ram = RAM_A * a + RAM_B * b\n", " print(\"-\" * 58)\n", " print(f\"CPU-Auslastung: {genutzt_cpu} / {CPU_GESAMT} vCPUs \"\n", " f\"({genutzt_cpu / CPU_GESAMT * 100:.1f} %)\")\n", " print(f\"RAM-Auslastung: {genutzt_ram} / {RAM_GESAMT} GB \"\n", " f\"({genutzt_ram / RAM_GESAMT * 100:.1f} %)\")\n", " print(f\"Arbitrage-Limit: {a} / {MAX_ARBITRAGE}\")\n", "\n", " # 8. Selbstkontrolle: Sind wirklich alle Bedingungen eingehalten?\n", " assert genutzt_cpu <= CPU_GESAMT, \"vCPU-Limit verletzt!\"\n", " assert genutzt_ram <= RAM_GESAMT, \"RAM-Limit verletzt!\"\n", " assert a <= MAX_ARBITRAGE, \"Arbitrage-Limit verletzt!\"\n", "\n", " # 9. Zum Vergleich: die naive Strategie \"nur der lukrativere Bot\"\n", " naiv_b = min(CPU_GESAMT // CPU_B, RAM_GESAMT // RAM_B)\n", " naiv_gewinn = GEWINN_B * naiv_b\n", " print(\"-\" * 58)\n", " print(f\"Naive Strategie (nur Trendfolge): {naiv_b} Bots -> {naiv_gewinn:,.2f} EUR\")\n", " print(f\"Vorteil der Optimierung: {gewinn - naiv_gewinn:,.2f} EUR pro Tag \"\n", " f\"({(gewinn / naiv_gewinn - 1) * 100:.1f} %)\")\n", " print(\"=\" * 58)\n", " return a, b, gewinn\n", "\n", "\n", "if __name__ == \"__main__\":\n", " loese_bot_allokation()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Von Excel zu Python: Ihre Daten liegen schon da\n", "\n", "`Excel_Bruecke.py`\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#!/usr/bin/env python3\n", "\n", "# Excel_Bruecke.py\n", "\"\"\"\n", "Kapitel Einfuehrung: Vom Excel-Solver zu Python - dieselbe Rechnung, dieselbe Datei.\n", "\n", "Das Programm bildet den vollstaendigen Arbeitsweg ab, den ein Excel-Modell im\n", "Betrieb sonst von Hand geht:\n", "\n", " Tabellenblatt lesen -> Modell bauen -> loesen -> Ergebnis zurueckschreiben\n", "\n", "Damit es ohne Vorbereitung laeuft, legt es die Eingabedatei beim ersten Start\n", "selbst an. Ersetzen Sie sie durch Ihre eigene Datei - solange die Spalten\n", "gleich heissen, aendert sich am Code nichts.\n", "\n", "Die vier Arbeitsschritte kommen aus or_kern.py (Kapitel Praxisfallen). Was\n", "hier steht, ist nur noch das, was diesen Fall ausmacht - der Rest ist\n", "gemeinsamer Unterbau. Wer das Modul nicht kennt: Es ist im Buch vollstaendig\n", "abgedruckt und liegt im selben Verzeichnis.\n", "\n", "Erzeugte Dateien (im Arbeitsverzeichnis):\n", " produktionsmix.xlsx Eingabe: Blaetter 'Produkte' und 'Kapazitaeten'\n", " produktionsmix_ergebnis.xlsx Ausgabe: Blaetter 'Plan' und 'Kennzahlen'\n", "\n", "Benoetigt: pandas, openpyxl, ortools, pydantic (ueber or_kern)\n", "\"\"\"\n", "\n", "from __future__ import annotations\n", "\n", "import os\n", "\n", "import pandas as pd\n", "\n", "from or_kern import (lade_produktionsproblem, loese_mit_glop, pruefe_loesung,\n", " schreibe_ergebnis)\n", "\n", "EINGABE = \"produktionsmix.xlsx\"\n", "AUSGABE = \"produktionsmix_ergebnis.xlsx\"\n", "\n", "\n", "def lege_beispieldatei_an(pfad: str) -> None:\n", " \"\"\"Schreibt die Eingabemappe, wie sie auch aus dem Controlling kaeme.\"\"\"\n", " produkte = pd.DataFrame({\n", " \"Produkt\": [\"Tisch\", \"Stuhl\", \"Regal\"],\n", " \"Deckungsbeitrag\": [240.0, 60.0, 130.0],\n", " \"Montagestunden\": [3.0, 1.0, 2.0],\n", " \"Plattenmaterial\": [6.0, 1.0, 4.0],\n", " })\n", " kapazitaeten = pd.DataFrame({\n", " \"Ressource\": [\"Montagestunden\", \"Plattenmaterial\"],\n", " \"Verfuegbar\": [150.0, 240.0],\n", " })\n", " with pd.ExcelWriter(pfad, engine=\"openpyxl\") as mappe:\n", " produkte.to_excel(mappe, sheet_name=\"Produkte\", index=False)\n", " kapazitaeten.to_excel(mappe, sheet_name=\"Kapazitaeten\", index=False)\n", "\n", "\n", "if __name__ == \"__main__\":\n", " if not os.path.exists(EINGABE):\n", " lege_beispieldatei_an(EINGABE)\n", " print(f\"Beispiel-Eingabedatei angelegt: {EINGABE}\")\n", "\n", " # 1. Lesen und pruefen. Faellt hier etwas auf, ist der Fehler noch\n", " # zuzuordnen - spaeter waere er nur noch eine merkwuerdige Zahl.\n", " problem = lade_produktionsproblem(EINGABE)\n", "\n", " # 2. Loesen. Einzige solverabhaengige Zeile des ganzen Programms -\n", " # 'loese_mit_scipy' waere ein Einzeiler-Wechsel.\n", " loesung = loese_mit_glop(problem)\n", "\n", " # 3. Abnahmepruefung gegen die Anforderungen, ohne den Solver zu fragen.\n", " beanstandungen = pruefe_loesung(problem, loesung)\n", " if beanstandungen:\n", " raise SystemExit(\"Abnahmepruefung fehlgeschlagen:\\n - \"\n", " + \"\\n - \".join(beanstandungen))\n", "\n", " # 4. Zurueckschreiben - im Format, das die Fachabteilung ohnehin benutzt.\n", " schreibe_ergebnis(AUSGABE, problem, loesung)\n", "\n", " print(\"=\" * 66)\n", " print(\" PRODUKTIONSPLAN AUS DER EXCEL-MAPPE\")\n", " print(\"=\" * 66)\n", " for produkt in problem.produkte:\n", " menge = loesung.werte[produkt.name]\n", " print(f\"{produkt.name:<18} {menge:8.1f} Stueck \"\n", " f\"{menge * produkt.deckungsbeitrag:12,.2f} EUR\")\n", " print(\"-\" * 66)\n", " print(loesung.als_bericht())\n", " print(\"Abnahmepruefung: bestanden\\n\")\n", "\n", " # Die Schattenpreise beantworten die Frage, die der Plan nicht stellt:\n", " # Was waere eine zusaetzliche Einheit dieser Ressource wert?\n", " for ressource, preis in loesung.schattenpreise.items():\n", " print(f\" eine Einheit {ressource:<18} mehr waere wert: {preis:7.2f} EUR\")\n", " print(\"=\" * 66)\n", " print(f\"Ergebnis geschrieben nach: {AUSGABE}\")" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.11" } }, "nbformat": 4, "nbformat_minor": 5 }