Kapitel 3: Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten
📌 Kapitel auf einen Blick
Worum geht es? Warum es für Optimierung in Python mehrere konkurrierende Bibliotheken gibt, was sie unterscheidet, und wie Sie in unter einer Minute die richtige auswählen.
Voraussetzungen: Kapitel 1 und Kapitel 2.
Danach können Sie: Für ein gegebenes Problem begründet einen Solver wählen, dasselbe Modell in verschiedenen Bibliotheken formulieren, die Ergebnisse gegeneinander prüfen — und messen, ob Ihre Laufzeit überhaupt im Solver entsteht oder schon davor.
Zeitbedarf: ca. 4 Stunden.
Programme:
Ein_System_Vier_Ansaetze.py
Solver_Wahl.py
Modellierungsschichten.py
Vektorisierte_Modellgenerierung.pyNotebook: oekosystem.ipynb — herunterladen und in Jupyter öffnen, in Colab hochladen oder mit dem Kurs-Image starten
3.1 In 5 Minuten gelöst
🚀 In 5 Minuten gelöst: Ein LP ohne jede Installation
Ein Futtermittelhersteller mischt zwei Rohstoffe zu möglichst geringen Kosten. Jeder Kilogramm Mischung muss mindestens 20 g Protein und 5 g Fett enthalten.
Rohstoff Preis je kg Protein je kg Fett je kg Weizenschrot 0,42 € 12 g 2 g Sojaschrot 0,88 € 44 g 15 g Sie brauchen dafür keine zusätzliche Bibliothek — SciPy genügt, und SciPy ist in jeder wissenschaftlichen Python-Installation schon da:
from scipy.optimize import linprog # linprog MINIMIERT und kennt nur "<=". Mindestgehalte werden also negiert. res = linprog(c=[0.42, 0.88], # Kosten je kg A_ub=[[-12, -44], [-2, -15]], # -Protein, -Fett b_ub=[-20, -5], # >= 20 g bzw. >= 5 g A_eq=[[1, 1]], b_eq=[1], # ergibt zusammen 1 kg bounds=[(0, 1), (0, 1)]) print(res.message) print(f"Weizen {res.x[0]:.3f} kg, Soja {res.x[1]:.3f} kg -> {res.fun:.4f} EUR/kg")Ausgabe:
Optimization terminated successfully. (HiGHS Status 7: Optimal) Weizen 0.750 kg, Soja 0.250 kg -> 0.5350 EUR/kg
Sie haben soeben HiGHS benutzt — denselben C++-Solver, der auch hinter highspy, hinter CVXPY und in vielen kommerziellen Systemen steckt. scipy.optimize.linprog ist nur die dünnste denkbare Hülle darum.
Das ist die zentrale Botschaft dieses Kapitels: Modellierungsschicht und Solver sind zwei verschiedene Dinge. Die Bibliothek, in der Sie Ihr Modell hinschreiben, bestimmt, wie angenehm die Arbeit ist. Der Solver dahinter bestimmt, wie schnell gerechnet wird. Beide lassen sich unabhängig voneinander tauschen — und genau davon handelt der Rest des Kapitels.
⚠️ Zwei Stolpersteine stecken schon in diesen sechs Zeilen
linprogminimiert immer. Wer maximieren will, negiert die Zielfunktion — und darf nicht vergessen, das Ergebnis zurückzudrehen.linprogkennt nur<=. Ein Mindestgehalt „\ge 20“ wird zu „-12x_1 - 44x_2 \le -20“. Ein Vorzeichenfehler an dieser Stelle liefert eine perfekt aussehende Lösung, die das Gegenteil des Gewollten erfüllt.Beide Fallen verschwinden, sobald man eine Modellierungsschicht wie CVXPY oder Pyomo benutzt, in der
>=einfach>=heißt. Das ist ein Hauptgrund, warum es sie gibt.
3.2 Lernziele
Nach diesem Kapitel können Sie …
- … die Zwei-Schichten-Architektur (Modellierung vs. Solver) erklären.
- … anhand von drei Fragen entscheiden, welche Bibliothek zu Ihrem Problem passt.
- … dasselbe lineare Programm in
scipy.optimize,highspy,ortoolsundcvxpyformulieren. - … Ergebnisse verschiedener Solver gegeneinander validieren (Cross-Check).
- … einschätzen, wann sich der Aufwand einer Low-Level-Schnittstelle lohnt und wann nicht.
- … Pyomo und Linopy einordnen und begründen, wann sich der Umstieg auf sie lohnt.
- … Aufbau- und Lösezeit getrennt messen und einen Modellaufbau mit NumPy oder Polars vektorisieren, statt ihn in Python-Schleifen zu erzeugen.
3.3 Die Zwei-Schichten-Architektur
Im modernen Operations Research schreibt man Optimierungsalgorithmen nicht selbst. Man nutzt eine zweischichtige Architektur:
- Modellierungsschicht (Frontend, DSL): Python-Bibliotheken, mit denen Entscheidungsvariablen, Zielfunktion und Nebenbedingungen in mathematiknaher Syntax formuliert werden. Hier arbeiten Sie.
- Solver-Schicht (Backend, Engine): Hochoptimierte C++- oder Fortran-Bibliotheken, die das Modell in Matrixstrukturen übersetzen und mit spezialisierten Algorithmen lösen (Dual Simplex, Interior-Point, Branch-and-Cut, CDCL-SAT-Suche).
🎯 Merksatz Die Modellierungsschicht bestimmt, wie angenehm Sie arbeiten. Die Solver-Schicht bestimmt, wie schnell gerechnet wird. Beides ist entkoppelt — man kann dieselbe CVXPY-Formulierung mit fünf verschiedenen Solvern lösen.
Warum diese Trennung nützlich ist: Ein CVXPY-Modell, das heute mit dem freien Solver Clarabel läuft, läuft morgen ohne Codeänderung mit dem kommerziellen Gurobi — man tauscht ein Argument. Das schützt vor Herstellerbindung und erlaubt, im Projekt klein anzufangen.
3.4 Die Werkzeuge im Vergleich
| Bibliothek | Stärken & Problemklassen | Wann einsetzen? | Typische Backends |
|---|---|---|---|
scipy.optimize |
Standard-LPs, kontinuierliche nichtlineare Optimierung, Wurzelsuche | Für elementare LPs und schnelle Prototypen ohne zusätzliche schwere Abhängigkeiten | HiGHS (highs-ds, highs-ipm) |
ortools (Google) |
Diskrete Planung, Schichten, Vertretungspläne, Routing (VRP/TSP), kombinatorische Logik | Wenn diskrete Ja/Nein-Entscheidungen, Zeitfenster und Wenn-Dann-Regeln dominieren | CP-SAT, GLOP, Routing Engine |
cvxpy |
Konvexe Optimierung, quadratische Programme, Risikomodelle, Portfolios | Erste Wahl für Finanzoptimierung, Markowitz, CVaR, L_1-Transaktionskosten | Clarabel, OSQP, ECOS, SCS, HiGHS |
highspy |
Direkte Solver-Steuerung für LP/MILP, maximale Kontrolle | Wenn Sie Modelle wiederholt und schnell aufbauen oder Solver-Details steuern müssen | HiGHS |
Pyomo |
Universelle Modellierungssprache (LP, MILP, MINLP) | Industrielle Großmodelle mit strikter Trennung von Daten und Modell | HiGHS, Gurobi, CPLEX, SCIP, IPOPT |
Linopy |
LP/MILP über beschriftete Arrays (xarray) |
Modelle mit zehntausenden gleichartigen Nebenbedingungen (Energie, Netze, Zeitreihen) | HiGHS, GLPK, CBC, Gurobi |
Die Entscheidung in drei Fragen
Statt die Tabelle auswendig zu lernen, beantworten Sie drei Fragen:
Frage 1: Gibt es Ja/Nein-Entscheidungen oder Reihenfolgen? → Ja: OR-Tools (CP-SAT) bei Zuweisung/Scheduling, oder MILP über HiGHS bei ökonomischen Fixkostenmodellen. → Nein: weiter zu Frage 2.
Frage 2: Ist die Zielfunktion linear? → Ja:
scipy.optimize.linprog(klein) oderhighspy(groß, wiederholt). → Nein, aber konvex (Quadrate, Normen,log,expin konvexer Kombination): CVXPY. → Nein und nicht konvex:scipy.optimize.minimize— im Bewusstsein, dass nur ein lokales Optimum herauskommt.Frage 3: Ist es ein Routing-Problem mit Fahrzeugen, Depots und Zeitfenstern? → Ja: OR-Tools Routing Library (nicht CP-SAT von Hand nachbauen).
Das folgende kleine Programm gießt diese Logik in Code — als Nachschlagehilfe für den eigenen Gebrauch.
#!/usr/bin/env python3
# Solver_Wahl.py
"""
Kapitel Oekosystem: Entscheidungshilfe zur Solverwahl.
Beantwortet drei Fragen und empfiehlt Bibliothek + Backend.
"""
from dataclasses import dataclass
@dataclass
class Problem:
diskrete_entscheidungen: bool # Ja/Nein-Variablen, Zuordnungen, Reihenfolgen?
zielfunktion: str # "linear" | "quadratisch" | "konvex" | "beliebig"
routing: bool = False # Fahrzeuge, Depots, Zeitfenster?
groesse: str = "klein" # "klein" (<10^3 Var.) | "gross"
wiederholte_laeufe: bool = False
def empfehle(p: Problem) -> tuple[str, str, str]:
"""Gibt (Bibliothek, Backend, Begründung) zurück."""
if p.routing:
return ("ortools.constraint_solver (Routing)", "Routing Engine",
"Spezialisierte Heuristiken für VRP/TSP schlagen jedes selbstgebaute Modell.")
if p.diskrete_entscheidungen:
if p.zielfunktion in ("linear",) and not p.routing:
if p.groesse == "gross":
return ("highspy", "HiGHS Branch-and-Cut",
"MILP mit ökonomischer Struktur (Fixkosten, Kardinalität).")
return ("ortools.sat (CP-SAT)", "CP-SAT",
"Logische Regeln und Zuweisungen: CP-SAT propagiert sehr effizient.")
return ("ortools.sat (CP-SAT)", "CP-SAT",
"Diskrete Struktur dominiert; CP-SAT verarbeitet auch nichtlineare Logik.")
if p.zielfunktion == "linear":
if p.wiederholte_laeufe or p.groesse == "gross":
return ("highspy", "HiGHS Dual Simplex",
"Modell einmal aufbauen, Parameter ändern, wiederholt lösen.")
return ("scipy.optimize.linprog", "HiGHS",
"Kleinstes Setup, keine zusätzliche Abhängigkeit.")
if p.zielfunktion in ("quadratisch", "konvex"):
return ("cvxpy", "Clarabel / OSQP",
"Konvexität wird automatisch geprüft; Portfolio-Standard.")
return ("scipy.optimize.minimize", "SLSQP / trust-constr",
"Nicht konvex: nur lokales Optimum, Startpunkt variieren und vergleichen!")
BEISPIELE = {
"Vertretungsplan Schule": Problem(True, "linear", groesse="klein"),
"Produktionsplanung (LP)": Problem(False, "linear", groesse="klein"),
"Produktionsplanung, 50k Var.": Problem(False, "linear", groesse="gross"),
"Portfolio Markowitz": Problem(False, "quadratisch"),
"Portfolio mit max. 5 Titeln": Problem(True, "quadratisch"),
"Liefertouren mit Zeitfenstern": Problem(True, "linear", routing=True),
"Entropie-Allokation (NLP)": Problem(False, "beliebig"),
"Backtest, 60x neu optimieren": Problem(False, "konvex", wiederholte_laeufe=True),
}
if __name__ == "__main__":
print("=" * 92)
print(" SOLVER-EMPFEHLUNG")
print("=" * 92)
for name, p in BEISPIELE.items():
lib, backend, grund = empfehle(p)
print(f"\n{name}")
print(f" -> Bibliothek: {lib}")
print(f" Backend: {backend}")
print(f" Grund: {grund}")
print("\n" + "=" * 92)3.5 Ein System — vier Programmieransätze
Nichts macht die Unterschiede so deutlich wie dasselbe Problem, viermal gelöst. Wir nehmen:
\max\ 10x_1 + 15x_2 + 25x_3
\text{u. d. N.}\quad x_1 + x_2 + 2x_3 \le 40,\qquad 2x_1 + 3x_2 + x_3 \le 50, \qquad x_1, x_2, x_3 \ge 0
✏️ Handrechnung 3.1: Erwarten Sie das Ergebnis
Bevor Sie das Programm laufen lassen: Welches Produkt ist am attraktivsten?
Produkt 3 bringt den höchsten Ertrag (25) und verbraucht wenig von Ressource 2 (nur 1). Es verbraucht aber doppelt so viel von Ressource 1. Rechnen wir den Ertrag pro Einheit Engpassressource:
Produkt Ertrag pro Einheit Ressource 1 pro Einheit Ressource 2 x_1 10 10/1 = 10 10/2 = 5 x_2 15 15/1 = 15 15/3 = 5 x_3 25 25/2 = 12{,}5 25/1 = 25 Kein Produkt ist in beiden Spalten der Sieger — deshalb braucht es überhaupt einen Optimierer. Probieren wir zwei Randlösungen: * Nur x_3: Ressource 1 erlaubt 40/2 = 20 Stück, Ressource 2 erlaubt 50. Also x_3 = 20, Ertrag = 500. Ressource 2 ist dann nur zu 20/50 ausgelastet. * x_3 = 20 plus x_2? Ressource 1 ist bereits voll (40), es geht nichts mehr. * Mischung x_2 und x_3: x_2 + 2x_3 = 40 und 3x_2 + x_3 = 50 ⟹ x_2 = 12, x_3 = 14, Ertrag = 15\cdot12 + 25\cdot14 = 180 + 350 = 530. Besser!
Erwartetes Ergebnis: Z^* = 530 mit x_1 = 0, x_2 = 12, x_3 = 14.
⚠️ Vorab: eine Stolperfalle der Installation
Auf vielen Systemen lassen sich
ortoolsundhighspynicht im selben Python-Prozess importieren. Beide Pakete bringen ihre eigene, unterschiedlich kompilierte Kopie des HiGHS-Solvers mit; der dynamische Linker löst die Symbole dann falsch auf. Die Fehlermeldung sieht so aus:ImportError: .../highspy/_core...so: undefined symbol: _ZN5Highs13releaseMemoryEvoder umgekehrt
ImportError: .../ortools/.libs/libortools.so.9: undefined symbol: _Z19setLocalOptionValue...Das ist kein Fehler in Ihrem Code, und bei
ortoolsundhighspydirekt hilft auch die Reihenfolge der Importe nicht — es trifft beide Richtungen.cvxpyverträgt sich mit beiden, importiert aber ein installierteshighspybei der Solver-Erkennung selbst mit (siehe*).
Kombination in einem Prozess Funktioniert? ortools+highspy❌ nein (beide Richtungen) ortools+cvxpy✅ ja* highspy+cvxpy✅ ja scipy+ beliebig✅ ja * ist
highspyinstalliert, gilt dieortools+highspy-Zeile entsprechend: erstcvxpy, dannortoolsimportiert → Crash; erstortools, danncvxpy→ läuft, CVXPY nur ohne HIGHS-Interface.Abhilfe: Jeden Solver in einem eigenen Prozess ausführen — genau das tut das folgende Programm. Alternativ: getrennte virtuelle Umgebungen, oder auf
highspyverzichten und HiGHS überscipy.optimize.linprogbzw. CVXPY ansprechen (dort ist es ohnehin als Backend verfügbar).Der Installationstest im Vorspann umgeht die Falle bereits: Er lädt
ortoolszuerst, prüfthighspyundcvxpyin der Paketübersicht nur auf Anwesenheit (importlib.util.find_spec) und importiert CVXPY erst im Funktionstest.
Wie die Isolation aussieht, wenn sie tragen soll
„Eigener Prozess” ist schnell gesagt. Die naheliegende Umsetzung — ein Codeschnipsel als Zeichenkette an python -c übergeben — funktioniert und ist trotzdem die schlechteste: Der Schnipsel ist für Editor, Linter und Testwerkzeug unsichtbar, ein Tippfehler darin fällt erst zur Laufzeit auf, und übergeben lassen sich nur Zeichenketten.
Tragfähig ist stattdessen: jeder Solver eine gewöhnliche Funktion mit lokalem Import, ausgeführt von einem ProcessPoolExecutor mit zwei Einstellungen, die zusammen die Garantie ergeben:
| Einstellung | Wozu |
|---|---|
mp_context=multiprocessing.get_context("spawn") |
Der Kindprozess startet mit einem frischen Interpreter, statt den Speicher des Elternprozesses zu erben. Unter Linux ist fork der Standard — und damit wäre alles, was hier schon importiert ist, auch dort importiert. |
max_tasks_per_child=1 |
Jede Aufgabe bekommt einen neuen Prozess. Ohne das verwendet der Pool seinen Arbeiter wieder, und beim zweiten Solver ist der Konflikt zurück. Genau dieser Fehler ist leicht zu machen und schwer zu finden. |
⚠️
max_tasks_per_child=1ist nicht optional Ein Pool ohne diese Angabe ist der Normalfall — er soll seine Arbeiter ja wiederverwenden. Wer die Isolation über einen Pool herstellt und das vergisst, hat einen Prozesswechsel programmiert, aber keine Isolation gewonnen: Die zweite Aufgabe landet im selben Interpreter wie die erste. Der Absturz kommt dann nicht beim ersten Solver, sondern beim zweiten — und sieht aus wie ein Problem des zweiten.
Denselben Aufbau verwenden Solverwechsel_CPSAT_HiGHS.py (Kapitel 22) und Benchmark_Skalierung.py (Kapitel 23). Dort wandern zusätzlich Datenobjekte über die Prozessgrenze statt Zeichenketten — möglich, weil Domänenmodell und Lösungs-DTO keinen Solver kennen (Abschnitt 22.6).
#!/usr/bin/env python3
# Ein_System_Vier_Ansaetze.py
"""
Kapitel Oekosystem: Dasselbe LP in vier Bibliotheken.
max 10*x1 + 15*x2 + 25*x3
u.d.N. x1 + x2 + 2*x3 <= 40
2*x1 + 3*x2 + x3 <= 50
x >= 0
Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich
am Ende.
WICHTIG: Jeder Solver laeuft in einem EIGENEN Prozess, weil sich ortools und
highspy auf vielen Systemen nicht gemeinsam importieren lassen (beide bringen
eine eigene HiGHS-Kopie mit -> Symbolkonflikt).
Die Isolation besorgt ein ProcessPoolExecutor. Drei Einstellungen ergeben
zusammen die Garantie:
mp_context "spawn" Der Kindprozess startet mit einem FRISCHEN
Interpreter, statt den Speicher des Elternprozesses
zu erben. Was hier schon importiert ist, ist dort
nicht importiert. Mit dem Standard "fork" auf Linux
waere das nicht so.
max_tasks_per_child=1 Jede Aufgabe bekommt einen NEUEN Prozess. Ohne das
wuerde der Pool seinen Arbeiter wiederverwenden - und
beim zweiten Solver waere der Konflikt zurueck.
max_workers=1 Haelt die vier Laeufe nacheinander. Nicht aus
Vorsicht, sondern damit die gemessenen Zeiten
vergleichbar bleiben.
Jeder Solver steht in einer eigenen Funktion mit LOKALEM Import. Das ist der
Unterschied zu einem Codestring, den man an 'python -c' uebergibt: Die
Funktion laesst sich einzeln aufrufen, testen und vom Editor pruefen - ein
String nicht.
Benoetigt: scipy, highspy, cvxpy, ortools
"""
import multiprocessing
import time
from concurrent.futures import ProcessPoolExecutor
ERWARTET = 530.0 # Ergebnis der Handrechnung zum Produktionsprogramm
# Die Instanz - einmal notiert, von allen vier Funktionen benutzt.
ZIEL = [10.0, 15.0, 25.0]
MATRIX = [[1, 1, 2], [2, 3, 1]]
KAPAZITAET = [40.0, 50.0]
def loese_mit_scipy() -> tuple[float, list[float]]:
from scipy.optimize import linprog
ergebnis = linprog(c=[-w for w in ZIEL], # linprog MINIMIERT -> negieren
A_ub=MATRIX, b_ub=KAPAZITAET,
bounds=[(0, None)] * 3, method="highs")
return -ergebnis.fun, list(ergebnis.x)
def loese_mit_highspy() -> tuple[float, list[float]]:
import highspy
import numpy as np
h = highspy.Highs()
h.setOptionValue("output_flag", False)
h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))
h.changeObjectiveSense(highspy.ObjSense.kMaximize)
for j, wert in enumerate(ZIEL):
h.changeColCost(j, wert)
# CSR-Format: starts[i] = Beginn von Zeile i in indices/values
h.addRows(2, np.full(2, -highspy.kHighsInf), np.array(KAPAZITAET), 6,
np.array([0, 3], dtype=np.int32),
np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),
np.array([float(w) for zeile in MATRIX for w in zeile]))
h.run()
return (h.getInfo().objective_function_value,
list(h.getSolution().col_value[:3]))
def loese_mit_cvxpy() -> tuple[float, list[float]]:
import cvxpy as cp
import numpy as np
x = cp.Variable(3, nonneg=True)
problem = cp.Problem(cp.Maximize(np.array(ZIEL) @ x),
[np.array(MATRIX) @ x <= np.array(KAPAZITAET)])
problem.solve()
return float(problem.value), [float(v) for v in x.value]
def loese_mit_ortools() -> tuple[float, list[float]]:
from ortools.linear_solver import pywraplp
s = pywraplp.Solver.CreateSolver("GLOP")
x = [s.NumVar(0, s.infinity(), f"x{j+1}") for j in range(3)]
for i, kapazitaet in enumerate(KAPAZITAET):
s.Add(sum(MATRIX[i][j] * x[j] for j in range(3)) <= kapazitaet)
s.Maximize(sum(ZIEL[j] * x[j] for j in range(3)))
s.Solve()
return s.Objective().Value(), [v.solution_value() for v in x]
ANSAETZE = {
"scipy.optimize.linprog": loese_mit_scipy,
"highspy (natives HiGHS)": loese_mit_highspy,
"cvxpy": loese_mit_cvxpy,
"ortools / GLOP": loese_mit_ortools,
}
if __name__ == "__main__":
print("=" * 78)
print(" EIN SYSTEM - VIER ANSAETZE (je eigener Prozess)")
print("=" * 78)
print(f"{'Bibliothek':<26} {'Z*':>10} {'x1':>7} {'x2':>7} {'x3':>7} {'Zeit':>10}")
print("-" * 78)
werte = []
# Ein Pool, vier Aufgaben, vier frische Prozesse. Der Kontext muss
# "spawn" sein - siehe Modulkommentar.
with ProcessPoolExecutor(
max_workers=1,
mp_context=multiprocessing.get_context("spawn"),
max_tasks_per_child=1) as pool:
for name, funktion in ANSAETZE.items():
beginn = time.perf_counter()
try:
wert, x = pool.submit(funktion).result(timeout=120)
except Exception as fehler: # Bibliothek fehlt o. Ae.
print(f"{name:<26} nicht verfuegbar: {str(fehler)[:40]}")
continue
dauer = time.perf_counter() - beginn
werte.append(wert)
print(f"{name:<26} {wert:>10.2f} {x[0]:>7.2f} {x[1]:>7.2f} {x[2]:>7.2f} "
f"{dauer:>8.2f} s")
print("-" * 78)
spanne = max(werte) - min(werte)
print(f"Spannweite zwischen den Bibliotheken: {spanne:.2e}")
print(f"Abweichung zur Handrechnung ({ERWARTET:.0f}): "
f"{abs(werte[0] - ERWARTET):.2e}")
assert spanne < 1e-6, "Die Bibliotheken widersprechen sich!"
assert abs(werte[0] - ERWARTET) < 1e-6, "Ergebnis weicht von der Handrechnung ab!"
print("Alle Wege fuehren zum selben, von Hand bestaetigten Optimum.")
print("(Die Zeiten enthalten Prozessstart und Import - sie messen NICHT die")
print(" reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)")
print("=" * 78)Erwartete Ausgabe (Zeiten hardwareabhängig):
==============================================================================
EIN SYSTEM - VIER ANSAETZE (je eigener Prozess)
==============================================================================
Bibliothek Z* x1 x2 x3 Zeit
------------------------------------------------------------------------------
scipy.optimize.linprog 530.00 0.00 12.00 14.00 0.59 s
highspy (natives HiGHS) 530.00 0.00 12.00 14.00 0.12 s
cvxpy 530.00 0.00 12.00 14.00 1.24 s
ortools / GLOP 530.00 0.00 12.00 14.00 0.33 s
------------------------------------------------------------------------------
Spannweite zwischen den Bibliotheken: 2.41e-08
Abweichung zur Handrechnung (530): 0.00e+00
Alle Wege fuehren zum selben, von Hand bestaetigten Optimum.
(Die Zeiten enthalten Prozessstart und Import - sie messen NICHT die
reine Solverleistung. Die Uebungsaufgabe 'Laufzeitvergleich' trennt beides.)
==============================================================================
🎯 Merksatz zur Spannweite Die vier Bibliotheken stimmen nicht auf die letzte Stelle überein, sondern nur bis auf 2{,}4 \times 10^{-8}. Das ist normal: Solver arbeiten mit endlicher Genauigkeit und brechen ab, sobald ihre eigene Toleranz erreicht ist. Vergleichen Sie Solver-Ergebnisse deshalb nie mit
==, sondern immer mit einer Toleranz —abs(a - b) < 1e-6odernp.isclose(). Wer auf exakte Gleichheit prüft, baut sich Tests, die zufällig mal bestehen und mal nicht.
💻 Code-Durchgang
Ansatz Zeilen für das Modell Charakter linprog3 Matrizen direkt übergeben. Kürzeste Variante, aber man muss selbst negieren und die Matrixform von Hand herstellen. highspy~15 Alles explizit, inklusive CSR-Format der dünnbesetzten Matrix. Aufwendig — dafür volle Kontrolle und kein Overhead beim wiederholten Lösen. cvxpy4 Liest sich wie die mathematische Formulierung. Prüft zusätzlich automatisch, ob das Problem konvex ist. Höchster Startaufwand pro Lauf (Kompilierung des Ausdrucksbaums). ortools/GLOP~6 Bedingungen einzeln mit Add()— gut lesbar bei wenigen, mühsam bei vielen Restriktionen.Der wichtigste Teil des Programms sind die letzten fünf Zeilen: der Kreuzvergleich. Vier unabhängige Implementierungen, die auf 13 Nachkommastellen übereinstimmen und mit einer Handrechnung zusammenpassen, sind ein starkes Indiz für Korrektheit. Bei einem einzelnen Solver-Ergebnis haben Sie diese Sicherheit nicht.
Was das CSR-Format bedeutet
highspy erwartet die Nebenbedingungsmatrix im CSR-Format (Compressed Sparse Row, komprimierte Zeilendarstellung). Statt der vollen Matrix speichert man nur die Einträge ungleich null:
\mathbf{A} = \begin{pmatrix} 1 & 1 & 2 \\ 2 & 3 & 1 \end{pmatrix}
| Array | Inhalt | Bedeutung |
|---|---|---|
values |
[1, 1, 2, 2, 3, 1] |
die Zahlen selbst, zeilenweise |
indices |
[0, 1, 2, 0, 1, 2] |
zu welcher Spalte gehört jeder Wert |
starts |
[0, 3] |
Zeile 0 beginnt bei Position 0, Zeile 1 bei Position 3 |
Bei kleinen Modellen wirkt das umständlich. Bei realen Modellen mit 100 000 Variablen und nur 0,1 % Nicht-Null-Einträgen spart es Faktor 1000 an Speicher — und ist der Grund, warum große LPs überhaupt lösbar sind.
⚠️ Typische Fehler
- Vergessen, dass
linprogminimiert. Der häufigste Fehler überhaupt. Symptom: Der „optimale“ Gewinn ist erstaunlich niedrig oder null.- CVXPY für ein nicht-konvexes Problem verwenden. CVXPY lehnt das ab mit
DCPError: Problem does not follow DCP rules. Das ist ein Feature, keine Einschränkung: Der Fehler sagt Ihnen, dass Ihre Formulierung keine Optimalitätsgarantie hätte.- CP-SAT mit kontinuierlichen Variablen füttern. CP-SAT kennt nur ganze Zahlen. Wer Euro-Beträge modelliert, rechnet in Cent (Ganzzahl) — oder nimmt einen LP-Solver.
- Für jeden Lauf ein neues Modell bauen. Bei 60 Backtest-Rebalancings kostet das Aufbauen mehr Zeit als das Lösen.
highspyund CVXPY-Parametererlauben es, das Modell einmal zu bauen und nur Daten zu tauschen (siehe Kapitel 19 und Kapitel 21).
3.6 Wann lohnt sich welche Ebene?
| Situation | Empfehlung | Begründung |
|---|---|---|
| Einmaliges kleines LP, Prototyp | scipy.optimize.linprog |
Keine zusätzliche Abhängigkeit, 3 Zeilen |
| Portfolio, Risiko, alles Konvexe | CVXPY | Lesbarkeit + automatische Konvexitätsprüfung |
| Dienstpläne, Zuordnung, Reihenfolge | CP-SAT | Globale Constraints, Konfliktlernen |
| Fahrzeugtouren | OR-Tools Routing | Fertige Metaheuristiken, jahrzehntelang optimiert |
| MILP mit Fixkosten, Kardinalität | highspy oder CP-SAT |
Branch-and-Cut auf ökonomischer Struktur |
| 50 000+ Variablen, wiederholte Läufe | highspy oder Pyomo |
Modellaufbau wird sonst zum Engpass |
| Nichtkonvexes NLP | scipy.optimize.minimize |
Bewusst mit mehreren Startpunkten arbeiten |
🎯 Merksatz Wählen Sie den Solver nach der Struktur des Modells, nicht nach Gewohnheit. Ein Zuweisungsproblem in CVXPY zu quälen oder ein Portfolio mit CP-SAT nachzubauen kostet Laufzeit und Nerven — und meist auch Lösungsqualität.
3.7 Modellierungsschichten für große Modelle: Pyomo und Linopy
Die vier Bibliotheken aus dem Vierfach-Vergleich decken den Alltag weitgehend ab. Sobald Modelle industrielle Größe erreichen — zehntausende Variablen, Daten aus mehreren Systemen, mehrere Jahre Lebensdauer — treten zwei weitere Werkzeuge in den Vordergrund.
Pyomo: die algebraische Denkweise
Pyomo ist im deutschsprachigen Raum der De-facto-Standard für große LP- und MILP-Modelle in Energiewirtschaft, Chemie und Logistik. Sein Kennzeichen: Es denkt in Mengen und Indizes, so wie die mathematische Formulierung selbst.
Der entscheidende Satz ist dieser:
modell.kapazitaet = pyo.Constraint(modell.R, rule=kapazitaet)Das ist das \forall i \in I aus Kapitel 1, unmittelbar in Code übersetzt: eine Regel, angewandt auf jedes Element einer Menge. Pyomo kann außerdem, was CVXPY und OR-Tools nicht können — nichtlineare und gemischt-ganzzahlig-nichtlineare Modelle (MINLP) an Solver wie Ipopt oder BONMIN übergeben (siehe Kapitel 11).
Linopy: eine Zeile, zehntausend Nebenbedingungen
Linopy verfolgt einen anderen Ansatz: Variablen sind beschriftete Arrays (xarray), keine indizierten Einzelobjekte. Damit wird aus
modell.add_constraints((verbrauch * x).sum("produkt") <= vorrat, name="kapazitaet")nicht eine Nebenbedingung, sondern so viele, wie die Achse ressource Einträge hat — erzeugt als Matrixoperation, ohne dass je eine Python-Schleife läuft. In Energiesystem- und Netzmodellen mit den Achsen Region × Technologie × Stunde des Jahres ist das der Unterschied zwischen Minuten und Sekunden beim Modellaufbau.
Die Schichten im Überblick
| Schicht | Denkweise | Stärke | Grenze |
|---|---|---|---|
scipy.optimize |
rohe Matrizen | keine Zusatzabhängigkeit, minimaler Start | Vorzeichen und Matrixform von Hand; nur LP |
highspy |
rohe Matrizen, volle Solversteuerung | schnellster wiederholter Aufbau, alle HiGHS-Optionen | CSR-Format selbst herstellen |
ortools |
Objekte, Bedingung für Bedingung | CP-SAT und Routing; sehr gut lesbar | Aufbau wird bei 10^5 Variablen zum Engpass |
cvxpy |
mathematiknahe Ausdrücke | automatische Konvexitätsprüfung, Risikomodelle | keine Ganzzahligkeit in großem Stil; Kompilierung kostet Zeit |
Pyomo |
Mengen und Indizes | Industriestandard, Daten/Modell getrennt, MINLP-fähig | mehr Zeremonie; eigene Lernkurve |
Linopy |
beschriftete Arrays (xarray) | extrem schneller Aufbau bei Millionen Nebenbedingungen | nur LP/MILP; Daten müssen zu Arrays passen |
🎯 Merksatz Keine dieser Schichten rechnet selbst. Alle sechs geben dasselbe Modell am Ende an dieselbe Handvoll C++-Solver weiter — hier fast immer an HiGHS. Die Wahl der Schicht entscheidet über Ihre Produktivität, nicht über die des Rechners.
Das folgende Programm löst mit beiden Schichten dasselbe Produktionsproblem wie der Vierfach-Vergleich und prüft das Ergebnis gegen dieselbe Handrechnung.
#!/usr/bin/env python3
# Modellierungsschichten.py
"""
Kapitel Oekosystem: Pyomo und Linopy - zwei Modellierungsschichten fuer grosse Modelle.
Geloest wird dasselbe Produktionsproblem wie im Vierfach-Vergleich:
max 10*x1 + 15*x2 + 25*x3
u.d.N. x1 + x2 + 2*x3 <= 40
2*x1 + 3*x2 + x3 <= 50
x >= 0
Handrechnung: Z* = 530 bei x = (0, 12, 14).
Der Vergleich zeigt die beiden Denkweisen:
* Pyomo - algebraisch, indexbasiert, Industriestandard fuer Grossmodelle,
trennt Modellstruktur sauber von den Daten (AbstractModel).
* Linopy - beschriftete Arrays (xarray): eine Zeile Code erzeugt Tausende
Nebenbedingungen auf einmal, ohne Python-Schleife.
Beide bringen KEINEN eigenen Solver mit; hier rechnet in beiden Faellen HiGHS.
WICHTIG: ortools wird in diesem Programm bewusst NICHT importiert - es
vertraegt sich nicht mit der HiGHS-Kopie, die Pyomo und Linopy laden
(siehe die Stolperfalle im Abschnitt 'Ein System - vier Programmieransaetze').
Benoetigt: pyomo, linopy, xarray, pandas, highspy, numpy
"""
from __future__ import annotations
import time
import numpy as np
import pandas as pd
import pyomo.environ as pyo
import xarray as xr
import linopy
ERWARTET = 530.0 # Ergebnis der Handrechnung
PRODUKTE = ["Standard", "Komfort", "Premium"]
RESSOURCEN = ["Material", "Montage"]
DECKUNGSBEITRAG = np.array([10.0, 15.0, 25.0])
VERBRAUCH = np.array([[1.0, 1.0, 2.0], # Material je Produkt
[2.0, 3.0, 1.0]]) # Montage je Produkt
VORRAT = np.array([40.0, 50.0])
# --- Pyomo: algebraisch und indexbasiert ------------------------------------
def loese_mit_pyomo() -> tuple[float, list[float], float]:
"""Pyomo denkt in Mengen und Indizes, wie ein Mathematiker es aufschreibt.
`Constraint(RESSOURCEN, rule=...)` erzeugt fuer JEDES Element der Menge
eine Nebenbedingung - das ist das 'fuer alle i' der Formelsprache,
unmittelbar in Code uebersetzt.
"""
t0 = time.perf_counter()
modell = pyo.ConcreteModel(name="Produktionsprogramm")
modell.P = pyo.Set(initialize=PRODUKTE)
modell.R = pyo.Set(initialize=RESSOURCEN)
modell.db = pyo.Param(modell.P, initialize=dict(zip(PRODUKTE, DECKUNGSBEITRAG)))
modell.a = pyo.Param(modell.R, modell.P, initialize={
(r, p): VERBRAUCH[i, j]
for i, r in enumerate(RESSOURCEN) for j, p in enumerate(PRODUKTE)})
modell.vorrat = pyo.Param(modell.R, initialize=dict(zip(RESSOURCEN, VORRAT)))
modell.x = pyo.Var(modell.P, domain=pyo.NonNegativeReals)
modell.ziel = pyo.Objective(
expr=sum(modell.db[p] * modell.x[p] for p in modell.P),
sense=pyo.maximize)
def kapazitaet(m, r):
return sum(m.a[r, p] * m.x[p] for p in m.P) <= m.vorrat[r]
modell.kapazitaet = pyo.Constraint(modell.R, rule=kapazitaet)
ergebnis = pyo.SolverFactory("appsi_highs").solve(modell)
dauer = time.perf_counter() - t0
status = ergebnis.solver.termination_condition
if status != pyo.TerminationCondition.optimal:
raise RuntimeError(f"Pyomo meldet Status: {status}")
return (float(pyo.value(modell.ziel)),
[float(pyo.value(modell.x[p])) for p in PRODUKTE],
dauer)
# --- Linopy: beschriftete Arrays --------------------------------------------
def loese_mit_linopy() -> tuple[float, list[float], float]:
"""Linopy denkt in beschrifteten Arrays (xarray).
Der entscheidende Unterschied: `(verbrauch * x).sum("produkt") <= vorrat`
ist EINE Zeile und erzeugt so viele Nebenbedingungen, wie die Dimension
'ressource' Eintraege hat. Bei 2 Ressourcen faellt das nicht auf, bei
200 000 schon - dort entstehen sie als Matrixoperation statt in einer
Python-Schleife.
"""
t0 = time.perf_counter()
# Benannte Indizes statt blosser Listen: Dadurch heissen die Achsen
# 'produkt' und 'ressource', und xarray fuehrt sie beim Rechnen von allein
# richtig zusammen. Ohne Namen vergibt linopy 'dim_0', und man muss
# spaeter umbenennen - eine haeufige Stolperstelle.
produkt = pd.Index(PRODUKTE, name="produkt")
ressource = pd.Index(RESSOURCEN, name="ressource")
modell = linopy.Model()
modell.add_variables(lower=0, coords=[produkt], name="menge")
x = modell.variables["menge"]
db = xr.DataArray(DECKUNGSBEITRAG, coords=[produkt])
verbrauch = xr.DataArray(VERBRAUCH, coords=[ressource, produkt])
vorrat = xr.DataArray(VORRAT, coords=[ressource])
# EINE Zeile - sie erzeugt so viele Nebenbedingungen, wie die Achse
# 'ressource' Eintraege hat. Genau das ist der Punkt.
modell.add_constraints((verbrauch * x).sum("produkt") <= vorrat,
name="kapazitaet")
modell.add_objective((db * x).sum(), sense="max")
modell.solve(solver_name="highs", output_flag=False)
dauer = time.perf_counter() - t0
if modell.termination_condition != "optimal":
raise RuntimeError(f"Linopy meldet Status: {modell.termination_condition}")
loesung = modell.variables["menge"].solution.to_series()
return (float(modell.objective.value),
[float(loesung[p]) for p in PRODUKTE],
dauer)
if __name__ == "__main__":
print("=" * 80)
print(" MODELLIERUNGSSCHICHTEN FUER GROSSE MODELLE")
print("=" * 80)
print(f"{'Schicht':<14} {'Z*':>10} {'Standard':>10} {'Komfort':>10} "
f"{'Premium':>10} {'Zeit':>10}")
print("-" * 80)
ergebnisse = []
for name, loeser in [("Pyomo", loese_mit_pyomo), ("Linopy", loese_mit_linopy)]:
ziel, mengen, dauer = loeser()
ergebnisse.append(ziel)
print(f"{name:<14} {ziel:>10.2f} {mengen[0]:>10.2f} {mengen[1]:>10.2f} "
f"{mengen[2]:>10.2f} {dauer:>8.2f} s")
print("-" * 80)
for ziel in ergebnisse:
assert abs(ziel - ERWARTET) < 1e-6, \
f"Abweichung von der Handrechnung: {ziel} statt {ERWARTET}"
print(f"Beide stimmen mit der Handrechnung ueberein (Z* = {ERWARTET:.0f}).")
print()
print("Wann welche Schicht?")
print(" Pyomo -> wenn Modellstruktur und Daten getrennt bleiben sollen,")
print(" wenn nichtlineare Terme oder MINLP dazukommen koennen,")
print(" wenn spaeter ein kommerzieller Solver angebunden wird.")
print(" Linopy -> wenn die Daten ohnehin als beschriftete Arrays vorliegen")
print(" (Energiesystem-, Netz- und Zeitreihenmodelle) und das")
print(" Modell zehntausende gleichartige Nebenbedingungen hat.")
print("=" * 80)Erwartete Ausgabe (Zeiten hardwareabhängig):
================================================================================
MODELLIERUNGSSCHICHTEN FUER GROSSE MODELLE
================================================================================
Schicht Z* Standard Komfort Premium Zeit
--------------------------------------------------------------------------------
Pyomo 530.00 0.00 12.00 14.00 0.02 s
Linopy 530.00 0.00 12.00 14.00 0.27 s
--------------------------------------------------------------------------------
Beide stimmen mit der Handrechnung ueberein (Z* = 530).
Wann welche Schicht?
Pyomo -> wenn Modellstruktur und Daten getrennt bleiben sollen,
wenn nichtlineare Terme oder MINLP dazukommen koennen,
wenn spaeter ein kommerzieller Solver angebunden wird.
Linopy -> wenn die Daten ohnehin als beschriftete Arrays vorliegen
(Energiesystem-, Netz- und Zeitreihenmodelle) und das
Modell zehntausende gleichartige Nebenbedingungen hat.
================================================================================
⚠️ Lassen Sie sich von den 0,27 s bei Linopy nicht täuschen. Bei drei Variablen misst man ausschließlich Startkosten; Linopys Stärke liegt naturgemäß dort, wo es viele gleichartige Nebenbedingungen auf einmal erzeugt. Ein Werkzeug an einem Spielzeugmodell zu bewerten ist einer der häufigsten Benchmark-Fehler — Kapitel 22 zeigt, wie man es richtig macht.
3.8 Wo die Zeit wirklich hingeht: vektorisierte Modellgenerierung
Eine der hartnäckigsten Fehlannahmen in Optimierungsprojekten lautet: „Wenn es zu langsam ist, brauchen wir einen besseren Solver.“ Messen Sie erst — oft stimmt das nicht.
Der Grund ist strukturell. Bevor der Solver auch nur eine Iteration rechnet, muss das Modell aufgebaut werden: Variablen anlegen, Ausdrücke zusammensetzen, Nebenbedingungen an die C++-Schicht übergeben. Dieser Aufbau läuft in Python, der Solver läuft in C++ — und zwischen beiden liegen leicht zwei Größenordnungen Geschwindigkeit.
🎯 Merksatz Bei jedem Optimierungsproblem gibt es zwei Laufzeiten: die zum Aufbauen und die zum Lösen. Messen Sie beide getrennt, bevor Sie irgendetwas optimieren. Wer den kleineren Anteil beschleunigt, hat viel Arbeit für wenig Wirkung.
Vier Stufen an einem Transportproblem
Wir bauen dasselbe Transportproblem (m Werke, n Kunden, m \cdot n Variablen) auf vier Arten auf:
| Stufe | Wie das Modell entsteht | Was daran teuer ist |
|---|---|---|
| A | OR-Tools, ein Add() je Nebenbedingung |
Je Aufruf entsteht in Python ein Ausdrucksbaum aus n Termen |
| B | Nebenbedingungsmatrix als COO-Tripel, in Python-Schleifen | Kein Ausdrucksbaum mehr — aber die Schleife bleibt Python |
| C | dieselbe Matrix über Kronecker-Produkte (NumPy/SciPy) | nichts: eine Handvoll Array-Operationen |
| D | Daten kommen als lange Tabelle, aufbereitet mit Polars | nichts: ein Spaltenausdruck statt einer Zeilenschleife |
Stufe D ist der realistische Fall: Kostenmatrizen liegen in der Praxis selten als m \times n-Array vor, sondern als lange Tabelle (werk, kunde, kosten) aus Datenbank oder Data Lake. Polars berechnet daraus die Spaltenindizes der dünnbesetzten Matrix in einem einzigen Ausdruck.
#!/usr/bin/env python3
# Vektorisierte_Modellgenerierung.py
"""
Kapitel Oekosystem: Warum der Solver oft gar nicht der Engpass ist.
In realen Projekten geht ein grosser Teil der Rechenzeit nicht ins Loesen,
sondern ins AUFBAUEN des Modells. Dieses Programm misst das an einem
Transportproblem wachsender Groesse in vier Stufen:
A Modellierungsschicht, Nebenbedingung fuer Nebenbedingung (OR-Tools)
B Matrix direkt, aber mit Python-Schleifen ueber die Eintraege (COO)
C Matrix vektorisiert ueber Kronecker-Produkte (NumPy/SciPy)
D Daten kommen als lange Tabelle, aufbereitet mit Polars
Alle Varianten loesen dasselbe Problem und muessen denselben Zielwert
liefern - das wird am Ende geprueft.
Benoetigt: numpy, scipy, ortools; Variante D zusaetzlich polars (optional).
"""
from __future__ import annotations
import importlib.util
import time
import numpy as np
import scipy.sparse as sp
from ortools.linear_solver import pywraplp
from scipy.optimize import linprog
HAT_POLARS = importlib.util.find_spec("polars") is not None
def erzeuge_daten(m: int, n: int, saat: int = 3
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Transportproblem: m Werke, n Kunden.
Liefert (kosten[m, n], angebot[m], bedarf[n]). Das Gesamtangebot liegt
20 % ueber dem Gesamtbedarf, damit das Modell sicher loesbar ist.
"""
rng = np.random.default_rng(saat)
kosten = rng.uniform(1.0, 20.0, size=(m, n))
bedarf = rng.uniform(10.0, 50.0, size=n)
angebot = np.full(m, 1.2 * bedarf.sum() / m)
return kosten, angebot, bedarf
# --- Variante A: Modellierungsschicht, Bedingung fuer Bedingung -------------
def loese_mit_modellierungsschicht(kosten: np.ndarray, angebot: np.ndarray,
bedarf: np.ndarray) -> tuple[float, float, float]:
"""So schreibt man ein Transportproblem zuerst hin - gut lesbar, nah an
der mathematischen Formulierung, jede Nebenbedingung ein eigener Aufruf.
Jedes `s.Add(sum(...))` baut in Python einen Ausdrucksbaum aus m bzw. n
Termen auf und uebergibt ihn einzeln an die C++-Schicht. Das ist der
Preis der Bequemlichkeit - und er waechst linear mit der Modellgroesse.
"""
m, n = kosten.shape
t0 = time.perf_counter()
s = pywraplp.Solver.CreateSolver("GLOP")
x = [[s.NumVar(0, s.infinity(), f"x_{i}_{j}") for j in range(n)]
for i in range(m)]
for i in range(m):
s.Add(sum(x[i][j] for j in range(n)) <= angebot[i])
for j in range(n):
s.Add(sum(x[i][j] for i in range(m)) >= bedarf[j])
s.Minimize(sum(kosten[i][j] * x[i][j] for i in range(m) for j in range(n)))
t_aufbau = time.perf_counter() - t0
t0 = time.perf_counter()
status = s.Solve()
t_loesen = time.perf_counter() - t0
if status != pywraplp.Solver.OPTIMAL:
raise RuntimeError(f"Solver-Status: {status}")
return t_aufbau, t_loesen, s.Objective().Value()
# --- Varianten B bis D: Matrix selbst bauen, dann SciPy/HiGHS ---------------
def baue_mit_schleifen(kosten: np.ndarray, angebot: np.ndarray,
bedarf: np.ndarray):
"""Die Nebenbedingungsmatrix als COO-Tripel (Zeile, Spalte, Wert), erzeugt
in verschachtelten Python-Schleifen.
Schon deutlich naeher am Blech als Variante A - es entsteht kein
Ausdrucksbaum mehr. Die Schleife selbst bleibt aber Python.
"""
m, n = kosten.shape
zeilen: list[int] = []
spalten: list[int] = []
werte: list[float] = []
rechte_seite: list[float] = []
for i in range(m): # Angebot je Werk
for j in range(n):
zeilen.append(i)
spalten.append(i * n + j)
werte.append(1.0)
rechte_seite.append(float(angebot[i]))
for j in range(n): # Bedarf je Kunde, als -x <= -bedarf
for i in range(m):
zeilen.append(m + j)
spalten.append(i * n + j)
werte.append(-1.0)
rechte_seite.append(-float(bedarf[j]))
A_ub = sp.csr_matrix((werte, (zeilen, spalten)), shape=(m + n, m * n))
return A_ub, np.array(rechte_seite), kosten.ravel()
def baue_vektorisiert(kosten: np.ndarray, angebot: np.ndarray,
bedarf: np.ndarray):
"""Dieselbe Matrix ohne eine einzige Schleife - ueber Kronecker-Produkte.
Die Angebotsmatrix ist kron(I_m, 1_n^T): je Werk eine Zeile mit Einsen
an genau den n Spalten dieses Werks. Die Bedarfsmatrix ist
kron(1_m^T, I_n). Beide entstehen in je einem Aufruf und sind sofort
duennbesetzt.
"""
m, n = kosten.shape
angebots_matrix = sp.kron(sp.identity(m, format="csr"), np.ones((1, n)))
bedarfs_matrix = sp.kron(np.ones((1, m)), sp.identity(n, format="csr"))
A_ub = sp.vstack([angebots_matrix, -bedarfs_matrix], format="csr")
b_ub = np.concatenate([angebot, -bedarf])
return A_ub, b_ub, kosten.ravel()
def baue_mit_polars(kosten: np.ndarray, angebot: np.ndarray, bedarf: np.ndarray):
"""Der realistische Fall: Die Kosten kommen als LANGE Tabelle
(werk, kunde, kosten) aus Datenbank, Data Lake oder CSV-Datei.
Polars berechnet den Spaltenindex jeder Variablen in einem einzigen
Spaltenausdruck - ohne Python-Schleife ueber die Zeilen. Genau so baut man
Modelle aus Millionen Tabellenzeilen.
"""
import polars as pl
m, n = kosten.shape
tabelle = pl.DataFrame({
"werk": np.repeat(np.arange(m), n),
"kunde": np.tile(np.arange(n), m),
"kosten": kosten.ravel(),
}).with_columns(
(pl.col("werk") * n + pl.col("kunde")).alias("var_index")
)
var_index = tabelle["var_index"].to_numpy()
werk = tabelle["werk"].to_numpy()
kunde = tabelle["kunde"].to_numpy()
eins = np.ones(var_index.size)
angebots_matrix = sp.csr_matrix((eins, (werk, var_index)), shape=(m, m * n))
bedarfs_matrix = sp.csr_matrix((eins, (kunde, var_index)), shape=(n, m * n))
A_ub = sp.vstack([angebots_matrix, -bedarfs_matrix], format="csr")
b_ub = np.concatenate([angebot, -bedarf])
return A_ub, b_ub, tabelle["kosten"].to_numpy()
def messe_matrixvariante(bauer, kosten, angebot, bedarf
) -> tuple[float, float, float]:
"""Liefert (Aufbauzeit, Loesezeit, Zielwert) fuer die Varianten B bis D."""
t0 = time.perf_counter()
A_ub, b_ub, c = bauer(kosten, angebot, bedarf)
t_aufbau = time.perf_counter() - t0
t0 = time.perf_counter()
ergebnis = linprog(c, A_ub=A_ub, b_ub=b_ub, bounds=(0, None), method="highs")
t_loesen = time.perf_counter() - t0
if not ergebnis.success:
raise RuntimeError(f"Solver-Status: {ergebnis.message}")
return t_aufbau, t_loesen, float(ergebnis.fun)
if __name__ == "__main__":
print("=" * 88)
print(" MODELLAUFBAU: WO DIE ZEIT WIRKLICH HINGEHT")
print("=" * 88)
print("Transportproblem mit m Werken und n Kunden -> m*n Variablen.\n")
varianten: list[tuple[str, object]] = [
("A: OR-Tools, Add() je NB", None), # Sonderfall, eigener Messpfad
("B: COO in Schleifen", baue_mit_schleifen),
("C: NumPy vektorisiert", baue_vektorisiert),
]
if HAT_POLARS:
varianten.append(("D: Polars-Tabelle", baue_mit_polars))
else:
print("Hinweis: polars nicht installiert - Variante D wird uebersprungen.\n")
# Aufwaermlauf: Der erste Aufruf bezahlt Importe und einmalige
# Initialisierungen. Wer den mitmisst, vergleicht Startkosten statt
# Rechenarbeit - ein klassischer Benchmark-Fehler.
aufwaerm = erzeuge_daten(10, 10)
loese_mit_modellierungsschicht(*aufwaerm)
for _, bauer in varianten[1:]:
bauer(*aufwaerm)
print(f"{'Groesse':<26} {'Variante':<26} {'Aufbau':>9} {'Loesen':>9} "
f"{'Aufbauanteil':>13}")
print("-" * 88)
for m, n in [(40, 40), (120, 120), (250, 250)]:
kosten, angebot, bedarf = erzeuge_daten(m, n)
zielwerte = []
for nummer, (name, bauer) in enumerate(varianten):
if bauer is None:
t_aufbau, t_loesen, ziel = loese_mit_modellierungsschicht(
kosten, angebot, bedarf)
else:
t_aufbau, t_loesen, ziel = messe_matrixvariante(
bauer, kosten, angebot, bedarf)
zielwerte.append(ziel)
anteil = 100.0 * t_aufbau / (t_aufbau + t_loesen)
groesse = f"{m}x{n} = {m*n:,} Variablen" if nummer == 0 else ""
print(f"{groesse:<26} {name:<26} {t_aufbau:>8.3f}s {t_loesen:>8.3f}s "
f"{anteil:>12.0f} %")
# Alle Varianten muessen dasselbe Problem beschreiben.
spanne = max(zielwerte) - min(zielwerte)
assert spanne < 1e-6 * max(abs(z) for z in zielwerte), \
f"Varianten widersprechen sich: {zielwerte}"
print(f"{'':<26} {'-> Zielwert (alle gleich)':<26} {zielwerte[0]:>9.2f}"
f" Spanne {spanne:.1e}")
print("-" * 88)
print("\nZwei Lehren aus der Tabelle:")
print("1. Bei Variante A geht mehr Zeit in den AUFBAU als ins Loesen. Wer hier")
print(" einen schnelleren Solver kauft, beschleunigt den kleineren Teil.")
print("2. Zwischen B und C liegt keine andere Mathematik, nur eine andere")
print(" Schreibweise derselben Matrix - Schleife gegen Kronecker-Produkt.")
print("\nDie Lesbarkeit von Variante A ist trotzdem viel wert: Fangen Sie dort an,")
print("und vektorisieren Sie erst, wenn die Messung es verlangt.")
print("=" * 88)Erwartete Ausgabe (Zeiten hardwareabhängig, Verhältnisse stabil):
========================================================================================
MODELLAUFBAU: WO DIE ZEIT WIRKLICH HINGEHT
========================================================================================
Transportproblem mit m Werken und n Kunden -> m*n Variablen.
Groesse Variante Aufbau Loesen Aufbauanteil
----------------------------------------------------------------------------------------
40x40 = 1,600 Variablen A: OR-Tools, Add() je NB 0.031s 0.005s 87 %
B: COO in Schleifen 0.001s 0.009s 12 %
C: NumPy vektorisiert 0.001s 0.007s 14 %
D: Polars-Tabelle 0.001s 0.007s 12 %
-> Zielwert (alle gleich) 2107.32 Spanne 4.5e-13
----------------------------------------------------------------------------------------
120x120 = 14,400 Variablen A: OR-Tools, Add() je NB 0.287s 0.070s 80 %
B: COO in Schleifen 0.010s 0.051s 16 %
C: NumPy vektorisiert 0.001s 0.047s 3 %
D: Polars-Tabelle 0.001s 0.047s 3 %
-> Zielwert (alle gleich) 4693.67 Spanne 3.6e-12
----------------------------------------------------------------------------------------
250x250 = 62,500 Variablen A: OR-Tools, Add() je NB 1.284s 0.569s 69 %
B: COO in Schleifen 0.043s 0.215s 17 %
C: NumPy vektorisiert 0.003s 0.205s 1 %
D: Polars-Tabelle 0.003s 0.199s 1 %
-> Zielwert (alle gleich) 8316.24 Spanne 1.8e-12
----------------------------------------------------------------------------------------
Lesen Sie die letzte Spalte. Bei 62 500 Variablen verbringt Variante A 69 % der Gesamtzeit damit, das Modell überhaupt aufzuschreiben — und nur 31 % mit Rechnen. Die vektorisierten Varianten kehren das Verhältnis um: 1 % Aufbau, 99 % Solver. Erst dort ist ein schnellerer Solver überhaupt die richtige Stellschraube.
Was Sie in der Abbildung sehen. Der rote Anteil ist die Zeit, in der der Solver noch gar nicht rechnet. Bei Variante A wächst er mit dem Modell, bei Variante C verschwindet er — dieselbe Mathematik, nur anders aufgeschrieben.
💻 Code-Durchgang
Stelle Was passiert Warum es zählt Aufwärmlauf vor der Messung jede Variante einmal mit 10×10 laufen lassen Der erste Aufruf bezahlt Importe und einmalige Initialisierungen. Wer den mitmisst, vergleicht Startkosten statt Rechenarbeit — der häufigste Benchmark-Fehler überhaupt. sp.kron(sp.identity(m), np.ones((1, n)))erzeugt die Angebotsmatrix in einem Aufruf Kronecker-Produkte sind das Werkzeug für „jede Gruppe bekommt eine Zeile“. Ein zweiter Blick lohnt: Genau dieses Muster deckt Zuordnungs-, Transport- und Schichtmodelle ab. Variante B als COO-Tripel nicht als dichte Matrix Wäre B eine dichte Matrix, würde sie bei 62 500 Variablen 250 MB belegen — der Vergleich wüde dann Speicher statt Schleifen messen. Fairness im Benchmark heißt: nur eine Sache verändern. assert spanne < 1e-6 * max(...)alle Varianten liefern denselben Zielwert Ohne diese Zeile misst man womöglich die Laufzeit von vier verschiedenen Modellen. Ein Benchmark ohne Korrektheitsprüfung ist wertlos. anteilstatt absoluter ZeitenAufbauanteil in Prozent Absolute Zeiten hängen von der Hardware ab, das Verhältnis nicht. Es ist die Zahl, die die Entscheidung trägt.
🎯 Merksatz Fangen Sie mit der lesbarsten Variante an. Vektorisieren Sie erst, wenn Sie gemessen haben, dass der Aufbau der Engpass ist — und messen Sie Aufbau und Lösen immer getrennt. Lesbarer Code, der schnell genug ist, schlägt schnellen Code, den niemand mehr versteht.
3.9 Übungsaufgaben
Lösungen: Abschnitt A.3.
Aufgabe 3.1 ⭐ — Solverwahl begründen. Welche Bibliothek würden Sie wählen? Begründen Sie mit den drei Fragen aus Abschnitt 3.4: (a) Zuteilung von 300 Prüfungen auf 40 Räume und 12 Zeitfenster. (b) Mischungsproblem: günstigstes Tierfutter aus 8 Rohstoffen, Nährwertgrenzen. (c) Portfolio aus 200 Aktien, Risiko minimieren bei Mindestrendite. (d) Wie (c), aber höchstens 15 Titel im Depot. (e) Standortwahl: Welche 5 von 40 möglichen Lagern eröffnen? (f) Kalibrierung eines Modells mit 4 Parametern an Messdaten (Fehlerquadratsumme, nicht konvex).
Aufgabe 3.2 ⭐⭐ — Modell übersetzen. Formulieren Sie das Bäckerei-Problem aus Kapitel 1 in allen vier Bibliotheken aus Abschnitt 3.5 und prüfen Sie mit assert, dass alle dasselbe Ergebnis liefern.
Aufgabe 3.3 ⭐⭐ — CSR-Format von Hand. Geben Sie values, indices und starts für folgende Matrix an: \mathbf{A}=\begin{pmatrix} 3 & 0 & 0 & 1 \\ 0 & 0 & 2 & 0 \\ 5 & 4 & 0 & 6\end{pmatrix} Wie viele Zahlen speichert CSR, wie viele die volle Matrix?
Aufgabe 3.4 ⭐⭐ — Konvexitätsprüfung erleben. Versuchen Sie, in CVXPY das Problem \min x^3 u. d. N. -2 \le x \le 2 zu lösen. Was passiert? Formulieren Sie anschließend \min x^2 mit denselben Schranken. Erklären Sie den Unterschied in eigenen Worten.
Aufgabe 3.5 ⭐⭐⭐ — Laufzeitvergleich. Erzeugen Sie zufällige LPs wachsender Größe (n = 10, 50, 100, 500, 1000 Variablen, m = n/2 Nebenbedingungen) und messen Sie die Laufzeit von linprog, highspy und cvxpy. Stellen Sie die Ergebnisse in einer Tabelle dar. (a) Ab welcher Größe zahlt sich highspy aus? (b) Wie viel Zeit entfällt bei CVXPY auf den Modellaufbau, wie viel auf das eigentliche Lösen? (Tipp: problem.solver_stats.solve_time.)
Aufgabe 3.6 ⭐⭐⭐ — Eigene Entscheidungshilfe. Erweitern Sie Solver_Wahl.py um zwei weitere Kriterien: „Ist eine kommerzielle Lizenz verfügbar?“ und „Muss das Modell für Nicht-Programmierer lesbar sein?“. Ergänzen Sie passende Empfehlungen.
3.10 Finde den Denkfehler
🐛 Finde den Denkfehler: Der Solver, der angeblich dreimal schneller ist
Ein Team vergleicht zwei Bibliotheken für sein Zuordnungsproblem und schreibt ins Protokoll: „CVXPY braucht 1,9 s, SciPy nur 0,6 s — wir setzen auf SciPy.“ Gemessen wurde so:
import time t0 = time.perf_counter() import cvxpy as cp x = cp.Variable(200, nonneg=True) problem = cp.Problem(cp.Minimize(kosten @ x), [A @ x == b]) problem.solve() print("CVXPY:", time.perf_counter() - t0) t0 = time.perf_counter() from scipy.optimize import linprog linprog(kosten, A_eq=A, b_eq=b, bounds=(0, None), method="highs") print("SciPy:", time.perf_counter() - t0)Beide finden dieselbe Lösung. Die Zeiten stimmen — auf diesem Rechner reproduzierbar. Trotzdem trägt die Entscheidung nicht.
Ihre Aufgabe: (a) Nennen Sie drei Dinge, die diese Messung mitmisst, obwohl sie nichts mit der Lösegeschwindigkeit zu tun haben. (b) Warum ist gerade bei CVXPY der gemessene Wert besonders irreführend, wenn das Modell später in einer Schleife 60-mal mit wechselnden Daten gelöst wird? (c) Wie sähe eine Messung aus, die die Frage des Teams tatsächlich beantwortet?
Auflösung: Abschnitt A.3.
3.11 Micro-Quiz
❓ Micro-Quiz 3: Drei Fragen zum Selbstcheck
Genau eine Antwort ist jeweils richtig. Auflösung in Anhang A.
1. Sie sollen 300 Prüfungen auf 40 Räume und 12 Zeitfenster verteilen, mit Regeln wie „diese beiden Klausuren nicht gleichzeitig“. Welches Werkzeug passt? (a) CVXPY — es prüft die Konvexität automatisch. (b) OR-Tools CP-SAT — diskrete Zuweisung mit logischen Regeln ist genau sein Gebiet. (c)
scipy.optimize.minimize— es kann beliebige Zielfunktionen.2. Ihr Modell mit 80 000 Variablen braucht 40 Sekunden, davon 32 Sekunden bis zum ersten Solver-Aufruf. Was ist die wirksamste Maßnahme? (a) Einen kommerziellen Solver lizenzieren. (b) Das Zeitlimit des Solvers heraufsetzen. (c) Den Modellaufbau vektorisieren — der Solver ist mit 8 Sekunden gar nicht der Engpass.
3. Was haben
scipy.optimize.linprog,highspy, CVXPY (mit Standardeinstellung) und Pyomo (mitappsi_highs) gemeinsam? (a) Sie implementieren jeweils einen eigenen Simplex-Algorithmus in Python. (b) Sie sind Modellierungsschichten über demselben C++-Solver HiGHS — die Rechenleistung ist dieselbe, nur die Schreibweise unterscheidet sich. (c) Sie können alle sowohl konvexe als auch nicht-konvexe Probleme global lösen.
3.12 Selbsttest
Antworten: Anhang A.
- Welche zwei Schichten hat eine moderne OR-Architektur, und wozu dient die Trennung?
- Warum wirft CVXPY bei \min x^3 einen Fehler,
scipy.optimize.minimizeaber nicht? - Was speichert das CSR-Format, und warum ist es bei großen Modellen entscheidend?
- Warum ist CVXPY im Laufzeitvergleich (Abschnitt 3.5) das langsamste Werkzeug — und warum ist das trotzdem kein Argument gegen seinen Einsatz?
- Nennen Sie zwei Situationen, in denen CP-SAT die falsche Wahl wäre.
3.13 Zusammenfassung
- Zwei Schichten: Sie formulieren in Python, ein C++-Solver rechnet. Beides ist austauschbar.
- Drei Fragen genügen zur Solverwahl: diskrete Entscheidungen? lineare Zielfunktion? Routing?
- Dasselbe Modell, vier Wege, ein Ergebnis — nutzen Sie das als Prüfmittel: Wenn zwei Bibliotheken verschiedene Optima melden, ist eine Ihrer Formulierungen falsch.
- CVXPYs Konvexitätsprüfung ist ein Schutzmechanismus, keine Schikane.
- Sechs Modellierungsschichten, eine Handvoll Solver. Pyomo (Mengen und Indizes) und Linopy (beschriftete Arrays) ergänzen die vier Grundwerkzeuge, sobald Modelle industrielle Größe erreichen — sie rechnen aber alle mit denselben C++-Engines.
- Messen Sie Aufbau- und Lösezeit getrennt. Bei 62 500 Variablen entfallen in der bequemen Schreibweise rund zwei Drittel der Zeit auf den Modellaufbau. Vektorisierung mit NumPy oder Polars dreht das Verhältnis um — ein schnellerer Solver hätte es nicht getan.
- Rechnen Sie das Ergebnis wenn möglich von Hand nach — bei kleinen Instanzen ist das in Minuten erledigt und deckt Modellfehler auf, die kein Solver melden kann.
Ausblick. Teil II beginnt mit dem Kernverfahren des Operations Research: der linearen Programmierung. Wir bauen den Simplex-Algorithmus selbst, um zu verstehen, woher Schattenpreise kommen — und was sie wirtschaftlich bedeuten.