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