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>
298 lines
15 KiB
Text
Generated
298 lines
15 KiB
Text
Generated
{
|
|
"cells": [
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"# Kapitel 4: Vom Management-Wunsch zum Modell\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": [
|
|
"## Derselbe Datensatz, fünf Modelle\n",
|
|
"\n",
|
|
"`Vom_Wunsch_zum_Modell.py`\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"#!/usr/bin/env python3\n",
|
|
"\n",
|
|
"# Vom_Wunsch_zum_Modell.py\n",
|
|
"\"\"\"\n",
|
|
"Kapitel Modellierung: Derselbe Datensatz, fuenf zulaessige Modelle, fuenf andere Plaene.\n",
|
|
"\n",
|
|
"Eine Lohnfertigung kann nicht alle Anfragen annehmen: 14 Auftraege verlangen\n",
|
|
"zusammen 800 Maschinenstunden, verfuegbar sind 300. Es muss also ausgewaehlt\n",
|
|
"werden. Die Daten stehen fest - was FEHLT, ist die Frage.\n",
|
|
"\n",
|
|
"Das Programm zeigt in vier Teilen, was daran haengt:\n",
|
|
"\n",
|
|
" 1. Drei naheliegende Ziele aus dem Managementgespraech (\"Umsatz\", \"Auslastung\",\n",
|
|
" \"Deckungsbeitrag\") - und wie weit die Plaene auseinanderlaufen.\n",
|
|
" 2. Der Satz \"Stammkunden duerfen wir nicht verlieren\" in drei Lesarten. Alle\n",
|
|
" drei sind vertretbar, alle drei kosten unterschiedlich viel.\n",
|
|
" 3. Was ein Strafgewicht daraus macht - und ab wann es dasselbe tut wie eine\n",
|
|
" harte Regel.\n",
|
|
" 4. Was die harte Lesart ein Quartal spaeter anrichtet.\n",
|
|
"\n",
|
|
"Der Punkt des Kapitels: Nicht die Daten bestimmen das Modell, sondern die\n",
|
|
"Frage. Und die steht selten in der Anforderung.\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",
|
|
"# (Nummer, Kunde, Stammkunde?, Maschinenstunden, Umsatz, Materialkosten)\n",
|
|
"AUFTRAEGE = [\n",
|
|
" (\"A-101\", \"Weber GmbH\", True, 40, 28_000, 19_000),\n",
|
|
" (\"A-102\", \"Weber GmbH\", True, 25, 15_000, 9_500),\n",
|
|
" (\"A-103\", \"Kranz AG\", True, 60, 33_000, 24_000),\n",
|
|
" (\"A-104\", \"Kranz AG\", True, 35, 19_000, 13_500),\n",
|
|
" (\"A-105\", \"Obermeier KG\", True, 20, 12_000, 7_000),\n",
|
|
" (\"A-106\", \"Siedler & Co\", False, 120, 84_000, 71_000),\n",
|
|
" (\"A-107\", \"Siedler & Co\", False, 110, 74_000, 63_000),\n",
|
|
" (\"A-108\", \"Novatek\", False, 95, 61_000, 52_000),\n",
|
|
" (\"A-109\", \"Novatek\", False, 30, 31_000, 17_000),\n",
|
|
" (\"A-110\", \"Hellwig\", False, 15, 17_000, 8_000),\n",
|
|
" (\"A-111\", \"Hellwig\", False, 55, 41_000, 30_000),\n",
|
|
" (\"A-112\", \"Pfeiffer Werke\", False, 80, 46_000, 39_000),\n",
|
|
" (\"A-113\", \"Obermeier KG\", True, 45, 26_000, 17_000),\n",
|
|
" (\"A-114\", \"Pfeiffer Werke\", False, 70, 52_000, 36_000),\n",
|
|
"]\n",
|
|
"KAPAZITAET = 300 # Maschinenstunden im Planungszeitraum\n",
|
|
"\n",
|
|
"STUNDEN = np.array([a[3] for a in AUFTRAEGE], dtype=float)\n",
|
|
"UMSATZ = np.array([a[4] for a in AUFTRAEGE], dtype=float)\n",
|
|
"DECKUNGSBEITRAG = np.array([a[4] - a[5] for a in AUFTRAEGE], dtype=float)\n",
|
|
"IST_STAMM = np.array([a[2] for a in AUFTRAEGE])\n",
|
|
"STAMMKUNDEN = sorted({a[1] for a in AUFTRAEGE if a[2]})\n",
|
|
"\n",
|
|
"\n",
|
|
"def waehle_aus(ziel: np.ndarray, zusatz_ub=None, zusatz_b_ub=None,\n",
|
|
" zusatz_eq=None, zusatz_b_eq=None, auftraege=AUFTRAEGE,\n",
|
|
" stunden=STUNDEN, kapazitaet=KAPAZITAET):\n",
|
|
" \"\"\"Ein Auftrag wird angenommen oder nicht - mehr Modell ist das nicht.\n",
|
|
"\n",
|
|
" Der ganze Unterschied zwischen den fuenf Varianten unten steckt in 'ziel'\n",
|
|
" und in den Zusatzbedingungen. Das Modell selbst bleibt dasselbe.\n",
|
|
" \"\"\"\n",
|
|
" ergebnis = linprog(\n",
|
|
" -ziel,\n",
|
|
" A_ub=[stunden] + (zusatz_ub or []),\n",
|
|
" b_ub=[kapazitaet] + (zusatz_b_ub or []),\n",
|
|
" A_eq=zusatz_eq, b_eq=zusatz_b_eq,\n",
|
|
" bounds=(0, 1), integrality=1, method=\"highs\")\n",
|
|
" if not ergebnis.success:\n",
|
|
" return None\n",
|
|
" return np.round(ergebnis.x).astype(int)\n",
|
|
"\n",
|
|
"\n",
|
|
"def kennzahlen(plan, auftraege=AUFTRAEGE, stunden=STUNDEN, umsatz=UMSATZ,\n",
|
|
" db=DECKUNGSBEITRAG, ist_stamm=IST_STAMM, kapazitaet=KAPAZITAET):\n",
|
|
" \"\"\"Immer dieselben fuenf Zahlen - nur so sind die Varianten vergleichbar.\"\"\"\n",
|
|
" abgelehnte_stammauftraege = int(((1 - plan) & ist_stamm.astype(int)).sum())\n",
|
|
" leer_ausgegangen = sum(\n",
|
|
" 1 for kunde in sorted({a[1] for a in auftraege if a[2]})\n",
|
|
" if not any(plan[i] for i, a in enumerate(auftraege) if a[1] == kunde))\n",
|
|
" return {\"umsatz\": float(umsatz @ plan), \"db\": float(db @ plan),\n",
|
|
" \"auslastung\": float(stunden @ plan) / kapazitaet * 100,\n",
|
|
" \"stamm_abgelehnt\": abgelehnte_stammauftraege,\n",
|
|
" \"kunden_leer\": leer_ausgegangen, \"angenommen\": int(plan.sum())}\n",
|
|
"\n",
|
|
"\n",
|
|
"def zeile(name: str, plan) -> None:\n",
|
|
" if plan is None:\n",
|
|
" print(f\" {name:<34} {'UNZULAESSIG - kein Plan moeglich':>44}\")\n",
|
|
" return\n",
|
|
" k = kennzahlen(plan)\n",
|
|
" print(f\" {name:<34} {k['umsatz']:>10,.0f} {k['db']:>9,.0f} \"\n",
|
|
" f\"{k['auslastung']:>6.0f}% {k['stamm_abgelehnt']:>7} \"\n",
|
|
" f\"{k['kunden_leer']:>6} {k['angenommen']:>7}\")\n",
|
|
"\n",
|
|
"\n",
|
|
"def kopfzeile() -> None:\n",
|
|
" print(f\" {'':<34} {'Umsatz':>10} {'DB':>9} {'Aus-':>7} {'Stamm-':>7} \"\n",
|
|
" f\"{'Kunden':>6} {'ange-':>7}\")\n",
|
|
" print(f\" {'':<34} {'':>10} {'':>9} {'lastung':>7} {'auftr.':>7} \"\n",
|
|
" f\"{'ohne':>6} {'nommen':>7}\")\n",
|
|
" print(\" \" + \"-\" * 78)\n",
|
|
"\n",
|
|
"\n",
|
|
"if __name__ == \"__main__\":\n",
|
|
" print(\"=\" * 84)\n",
|
|
" print(\" VOM MANAGEMENT-WUNSCH ZUM MODELL\")\n",
|
|
" print(\"=\" * 84)\n",
|
|
" print(f\"{len(AUFTRAEGE)} Anfragen verlangen {STUNDEN.sum():.0f} Maschinenstunden, \"\n",
|
|
" f\"verfuegbar sind {KAPAZITAET}.\")\n",
|
|
" print(f\"Es muss also ausgewaehlt werden. {len(STAMMKUNDEN)} der Kunden sind \"\n",
|
|
" f\"Stammkunden:\")\n",
|
|
" print(f\" {', '.join(STAMMKUNDEN)}\\n\")\n",
|
|
"\n",
|
|
" # --- 1. Drei Saetze aus dem Gespraech, drei Ziele ---------------------\n",
|
|
" print(\"-\" * 84)\n",
|
|
" print(\"1. Was im Gespraech gesagt wurde - drei naheliegende Ziele\\n\")\n",
|
|
" kopfzeile()\n",
|
|
" plaene = {}\n",
|
|
" for name, ziel in [(\"\\\"Umsatz maximieren\\\"\", UMSATZ),\n",
|
|
" (\"\\\"Maschinen besser auslasten\\\"\", STUNDEN),\n",
|
|
" (\"\\\"Deckungsbeitrag maximieren\\\"\", DECKUNGSBEITRAG)]:\n",
|
|
" plaene[name] = waehle_aus(ziel)\n",
|
|
" zeile(name, plaene[name])\n",
|
|
"\n",
|
|
" umsatzplan = kennzahlen(plaene[\"\\\"Umsatz maximieren\\\"\"])\n",
|
|
" auslastungsplan = kennzahlen(plaene[\"\\\"Maschinen besser auslasten\\\"\"])\n",
|
|
" dbplan = kennzahlen(plaene[\"\\\"Deckungsbeitrag maximieren\\\"\"])\n",
|
|
"\n",
|
|
" mehr_umsatz = umsatzplan[\"umsatz\"] - dbplan[\"umsatz\"]\n",
|
|
" weniger_db = dbplan[\"db\"] - umsatzplan[\"db\"]\n",
|
|
" print(f\"\\n Der Umsatzplan bringt {mehr_umsatz:,.0f} EUR mehr Umsatz und \"\n",
|
|
" f\"{weniger_db:,.0f} EUR WENIGER\")\n",
|
|
" print(f\" Deckungsbeitrag. Fuer {mehr_umsatz:,.0f} EUR Umsatz werden also \"\n",
|
|
" f\"{weniger_db:,.0f} EUR Ergebnis\")\n",
|
|
" print(f\" aufgegeben - und {umsatzplan['stamm_abgelehnt']} von \"\n",
|
|
" f\"{int(IST_STAMM.sum())} Stammkundenauftraegen dazu.\")\n",
|
|
" print()\n",
|
|
" print(\" \\\"Auslastung\\\" ist der haeufigste Wunsch und der schaedlichste. Der\")\n",
|
|
" print(f\" darauf optimierte Plan bringt {dbplan['db'] - auslastungsplan['db']:,.0f} EUR weniger \"\n",
|
|
" f\"Deckungsbeitrag und\")\n",
|
|
" print(f\" {dbplan['umsatz'] - auslastungsplan['umsatz']:,.0f} EUR weniger Umsatz als der \"\n",
|
|
" f\"DB-Plan - und erreicht dabei\")\n",
|
|
" print(f\" nicht einmal mehr Auslastung: alle drei liegen bei \"\n",
|
|
" f\"{dbplan['auslastung']:.0f} %.\")\n",
|
|
" print(\" Eine volle Maschine ist eben kein Ziel, sondern ein Nebenprodukt.\")\n",
|
|
"\n",
|
|
" # --- 2. Ein Satz, drei Lesarten --------------------------------------\n",
|
|
" print(\"\\n\" + \"-\" * 84)\n",
|
|
" print(\"2. \\\"Stammkunden duerfen wir nicht verlieren\\\" - aber was heisst das?\\n\")\n",
|
|
" kopfzeile()\n",
|
|
" zeile(\"ohne die Regel (DB maximieren)\", waehle_aus(DECKUNGSBEITRAG))\n",
|
|
"\n",
|
|
" # Lesart A: jeder Stammkundenauftrag wird angenommen\n",
|
|
" alle_stamm = [[1.0 if IST_STAMM[i] else 0.0 for i in range(len(AUFTRAEGE))]]\n",
|
|
" lesart_a = waehle_aus(DECKUNGSBEITRAG, zusatz_eq=alle_stamm,\n",
|
|
" zusatz_b_eq=[float(IST_STAMM.sum())])\n",
|
|
" zeile(\"A: jeden Stammauftrag annehmen\", lesart_a)\n",
|
|
"\n",
|
|
" # Lesart B: kein Stammkunde geht leer aus (mindestens ein Auftrag je Kunde)\n",
|
|
" je_kunde_ub, je_kunde_b = [], []\n",
|
|
" for kunde in STAMMKUNDEN:\n",
|
|
" je_kunde_ub.append([-1.0 if a[1] == kunde else 0.0 for a in AUFTRAEGE])\n",
|
|
" je_kunde_b.append(-1.0)\n",
|
|
" lesart_b = waehle_aus(DECKUNGSBEITRAG, zusatz_ub=je_kunde_ub,\n",
|
|
" zusatz_b_ub=je_kunde_b)\n",
|
|
" zeile(\"B: kein Stammkunde geht leer aus\", lesart_b)\n",
|
|
"\n",
|
|
" a, b = kennzahlen(lesart_a), kennzahlen(lesart_b)\n",
|
|
" ohne = kennzahlen(waehle_aus(DECKUNGSBEITRAG))\n",
|
|
" print(\"\\n Beide Lesarten erreichen dasselbe: kein Stammkunde geht leer aus.\")\n",
|
|
" print(f\" Lesart A kostet dafuer {ohne['db'] - a['db']:,.0f} EUR Deckungsbeitrag, \"\n",
|
|
" f\"Lesart B nur {ohne['db'] - b['db']:,.0f} EUR -\")\n",
|
|
" print(f\" ein Unterschied von {b['db'] - a['db']:,.0f} EUR fuer denselben Satz aus derselben\")\n",
|
|
" print(\" Besprechung. Wer nicht nachfragt, entscheidet das selbst, ohne es\")\n",
|
|
" print(\" zu merken.\")\n",
|
|
"\n",
|
|
" # --- 3. Die weiche Variante ------------------------------------------\n",
|
|
" print(\"\\n\" + \"-\" * 84)\n",
|
|
" print(\"3. Dieselbe Regel als Strafkosten je abgelehntem Stammauftrag\\n\")\n",
|
|
" kopfzeile()\n",
|
|
" for strafe in (3_000, 6_000, 10_000):\n",
|
|
" ziel = DECKUNGSBEITRAG + np.where(IST_STAMM, float(strafe), 0.0)\n",
|
|
" zeile(f\"C: Strafe {strafe:>6,} EUR je Auftrag\", waehle_aus(ziel))\n",
|
|
"\n",
|
|
" print(\"\\n Bei 3.000 EUR aendert sich nichts: Die Strafe liegt unter dem, was\")\n",
|
|
" print(\" die Auftraege an Deckungsbeitrag verdraengen wuerden. Bei 10.000 EUR\")\n",
|
|
" print(\" kommt genau Lesart A heraus. Eine harte Regel ist also nichts\")\n",
|
|
" print(\" anderes als eine weiche mit hinreichend grosser Strafe - was ein\")\n",
|
|
" print(\" Strafgewicht sonst noch bedeutet, steht im Kapitel CP-SAT.\")\n",
|
|
"\n",
|
|
" # --- 4. Was die harte Lesart ein Quartal spaeter anrichtet -----------\n",
|
|
" print(\"\\n\" + \"-\" * 84)\n",
|
|
" print(\"4. Ein Quartal spaeter: Weber fragt einen Grossauftrag an\\n\")\n",
|
|
" erweitert = AUFTRAEGE + [(\"A-115\", \"Weber GmbH\", True, 320, 210_000, 150_000)]\n",
|
|
" stunden_neu = np.array([a[3] for a in erweitert], dtype=float)\n",
|
|
" db_neu = np.array([a[4] - a[5] for a in erweitert], dtype=float)\n",
|
|
" stamm_neu = np.array([a[2] for a in erweitert])\n",
|
|
"\n",
|
|
" print(f\" A-115: Weber GmbH, {erweitert[-1][3]} Maschinenstunden - allein mehr,\")\n",
|
|
" print(f\" als der ganze Zeitraum hergibt ({KAPAZITAET} h).\\n\")\n",
|
|
"\n",
|
|
" alle_stamm_neu = [[1.0 if stamm_neu[i] else 0.0 for i in range(len(erweitert))]]\n",
|
|
" hart = waehle_aus(db_neu, zusatz_eq=alle_stamm_neu,\n",
|
|
" zusatz_b_eq=[float(stamm_neu.sum())],\n",
|
|
" auftraege=erweitert, stunden=stunden_neu)\n",
|
|
" weich = waehle_aus(db_neu + np.where(stamm_neu, 10_000.0, 0.0),\n",
|
|
" auftraege=erweitert, stunden=stunden_neu)\n",
|
|
"\n",
|
|
" print(f\" Lesart A (hart): \"\n",
|
|
" f\"{'UNZULAESSIG - der Nachtlauf bricht ab' if hart is None else 'loesbar'}\")\n",
|
|
" if weich is not None:\n",
|
|
" k = kennzahlen(weich, erweitert, stunden_neu,\n",
|
|
" np.array([a[4] for a in erweitert], dtype=float),\n",
|
|
" db_neu, stamm_neu)\n",
|
|
" print(f\" Lesart C (weich): loesbar - {k['db']:,.0f} EUR Deckungsbeitrag, \"\n",
|
|
" f\"{k['stamm_abgelehnt']} Stammauftrag abgelehnt\")\n",
|
|
"\n",
|
|
" print(\"\\n\" + \"=\" * 84)\n",
|
|
" print(\" WORAUF ES ANKOMMT\")\n",
|
|
" print(\"=\" * 84)\n",
|
|
" print(\"Die Daten waren in allen vier Teilen dieselben. Was sich geaendert hat,\")\n",
|
|
" print(\"war nur die Frage - und mit ihr der Plan, der Deckungsbeitrag und die\")\n",
|
|
" print(\"Kundenbeziehung.\")\n",
|
|
" print()\n",
|
|
" print(\"Drei Dinge, die deshalb vor dem ersten Modell geklaert gehoeren:\")\n",
|
|
" print()\n",
|
|
" print(\" 1. WORAN wird der Erfolg gemessen? Nicht 'was ist Ihnen wichtig',\")\n",
|
|
" print(\" sondern: Welche Zahl steht am Jahresende im Bericht? Umsatz,\")\n",
|
|
" print(\" Auslastung und Deckungsbeitrag fuehren hier zu drei\")\n",
|
|
" print(\" verschiedenen Plaenen.\")\n",
|
|
" print(\" 2. WAS GENAU heisst der Satz? 'Stammkunden nicht verlieren' hat\")\n",
|
|
" print(f\" mindestens drei Lesarten mit {b['db'] - a['db']:,.0f} EUR Unterschied. Die Frage\")\n",
|
|
" print(\" 'meinen Sie jeden Auftrag oder jeden Kunden?' dauert zehn\")\n",
|
|
" print(\" Sekunden.\")\n",
|
|
" print(\" 3. WAS PASSIERT, WENN ES NICHT GEHT? Eine harte Regel, die niemand\")\n",
|
|
" print(\" hinterfragt hat, macht das System eines Tages unloesbar - und\")\n",
|
|
" print(\" zwar genau dann, wenn ein besonders grosser Auftrag hereinkommt.\")\n",
|
|
" print(\" Weiche Regeln antworten auch dann noch.\")\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
|
|
}
|