Teil III: Nichtlinearität, Unsicherheit und mehrperiodige Dynamik
Teil II ging von festen Daten und linearen Zusammenhängen aus. Beides gilt in der Praxis oft nicht: Risiko wächst quadratisch mit dem Einsatz, Nachfrage ist erst morgen bekannt, und eine Entscheidung heute verändert, welche Möglichkeiten übermorgen noch offenstehen.
Drei Kapitel dieses Teils behandeln genau diese drei Abweichungen. Welches Werkzeug Sie brauchen, hängt davon ab, welche davon bei Ihnen vorliegt:
🎯 Merksatz zum Diagramm Die erste Frage trennt zwei Welten, die man nicht vermischen darf. Ist die Nichtlinearität das Problem, geht es um Konvexität — und damit darum, ob Sie eine Optimalitätsgarantie bekommen oder nur ein lokales Optimum. Ist die Unsicherheit das Problem, geht es um etwas ganz anderes: Nicht der Solver ist die Schwierigkeit, sondern die Frage, was „optimal“ überhaupt heißen soll, wenn man die Daten nicht kennt.
Der häufigste Fehler an dieser Stelle ist, Unsicherheit durch Mittelwerte zu ersetzen und dann deterministisch zu rechnen. Warum das systematisch danebengeht, zeigt Kapitel 12 unter dem Namen Fluch des Durchschnitts.
Zwei weitere Kapitel schließen sich an, weil sie dieselbe Voraussetzung aufgeben, nur an einer anderen Stelle: Kapitel 14 gibt das eine Ziel auf, Kapitel 15 die Annahme, die Eingabedaten seien gegeben statt selbst geschätzt. Was dieser Teil insgesamt leistet, fasst die Synthese an seinem Ende zusammen.
Kapitel 11: Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität
📌 Kapitel auf einen Blick
Worum geht es? Risiko ist quadratisch. Sobald Varianz ins Spiel kommt, verlässt man die Welt der Polyeder. Dieses Kapitel liefert das mathematische Fundament für die gesamte Portfoliooptimierung in Teil IV.
Voraussetzungen: Kapitel 2 (Konvexität, Eigenwerte), Kapitel 5 (Dualität, Schattenpreise). Gradienten werden in Abschnitt 11.2 wiederholt.
Danach können Sie: Ein quadratisches Programm aufstellen, die KKT-Bedingungen anwenden, den Zusammenhang zwischen Lagrange-Multiplikator und Schattenpreis erklären, eine gültige Kovarianzmatrix konstruieren — und mit nicht-konvexen Problemen umgehen, ohne ein lokales Optimum für das Optimum zu halten.
Zeitbedarf: ca. 6,5 Stunden.
Programme:
QP_Grundlagen.py
KKT_Nachweis.py
Entropie_Maximierte_Allokation.py
Lokale_Optima_Multistart.pyNotebook: qp-nlp.ipynb — herunterladen und in Jupyter öffnen, in Colab hochladen oder mit dem Kurs-Image starten
11.1 In 5 Minuten gelöst
🚀 In 5 Minuten gelöst: Warum die riskantere Anlage das Risiko senkt
Ein Betrieb legt Rücklagen an und hat zwei Möglichkeiten:
Anlage Schwankung (Standardabweichung) A — solide, wenig Bewegung 12 % B — deutlich unruhiger 28 % Die beiden bewegen sich gegenläufig: Korrelation -0{,}40. Wie viel soll in jede?
import numpy as np import cvxpy as cp sigma = np.array([0.12, 0.28]) # Schwankung je Anlage rho = -0.40 # Korrelation S = np.array([[sigma[0]**2, rho * sigma[0] * sigma[1]], [rho * sigma[0] * sigma[1], sigma[1]**2]]) w = cp.Variable(2, nonneg=True) # Anteile, keine Leerverkäufe problem = cp.Problem(cp.Minimize(cp.quad_form(w, S)), [cp.sum(w) == 1]) problem.solve() print("Anteile:", np.round(w.value, 3)) print(f"Risiko der Mischung: {np.sqrt(problem.value) * 100:.2f} %")Ausgabe:
Anteile: [0.767 0.233] Risiko der Mischung: 8.90 %
Rechnen wir die naheliegenden Alternativen dagegen:
| Aufteilung | Schwankung des Ganzen |
|---|---|
| Nur A — „die sichere Anlage“ | 12,00 % |
| 50 / 50 — „breit streuen“ | 12,84 % |
| Nur B | 28,00 % |
| Optimum: 76,7 % A, 23,3 % B | 8,90 % |
Drei Dinge stehen in dieser Tabelle, und alle drei widersprechen der Intuition:
- Die Beimischung der riskanteren Anlage senkt das Risiko — von 12 % auf 8,9 %, also um mehr als ein Viertel. Wer nur auf die 28 % schaut, würde B nie anfassen.
- Naives Streuen macht es schlechter. Die 50/50-Mischung liegt mit 12,84 % über dem Wert, den man bekäme, wenn man ausschließlich A hielte. „Diversifizieren“ ist also keine Regel, sondern eine Frage nach dem richtigen Maß.
- Das richtige Maß ist weder 0 % noch 50 %, sondern 23,3 % — eine Zahl, auf die man durch Nachdenken nicht kommt. Genau dafür gibt es dieses Kapitel.
Warum funktioniert das? Weil Risiko sich nicht addiert. Die Schwankung einer Mischung ist nicht der Durchschnitt der Einzelschwankungen, sondern folgt einer quadratischen Form:
\sigma_p^2 = w_A^2\sigma_A^2 + w_B^2\sigma_B^2 + 2\,w_A w_B \rho\,\sigma_A\sigma_B
Der letzte Term ist der entscheidende: Bei negativer Korrelation ist er negativ und zieht das Gesamtrisiko herunter. Deshalb verlässt man mit Risiko die Welt der Polyeder aus Teil II — die Zielfunktion ist kein Skalarprodukt mehr, sondern eine quadratische Form. Was das für die Lösbarkeit bedeutet und warum CVXPY das trotzdem mit Optimalitätsgarantie löst, klärt der Rest des Kapitels.
🎯 Merksatz
cp.quad_form(w, S)ist die Zeile, um die es im ganzen Kapitel geht. Sie ist genau dann harmlos, wenn \mathbf{S} positiv semidefinit ist — und genau dann ein Problem, wenn nicht. CVXPY prüft das für Sie und verweigert die Arbeit im Zweifelsfall. Diese Verweigerung ist ein Schutzmechanismus, keine Schikane.
11.2 Lernziele
Nach diesem Kapitel können Sie …
- … ein quadratisches Programm (QP) aufstellen und von einem allgemeinen nichtlinearen Programm abgrenzen.
- … die KKT-Bedingungen herleiten und auf ein Optimierungsproblem anwenden.
- … den Zusammenhang zwischen Lagrange-Multiplikator und Schattenpreis erklären.
- … zwischen konvex und streng konvex unterscheiden und eine gültige, positiv semidefinite Kovarianzmatrix konstruieren.
Auffrischung: Gradient in einer Minute
Der Gradient \nabla f(\mathbf{x}) ist der Vektor aller partiellen Ableitungen:
\nabla f(\mathbf{x}) = \left(\frac{\partial f}{\partial x_1},\ \frac{\partial f}{\partial x_2},\ \dots,\ \frac{\partial f}{\partial x_n}\right)^\top
📐 Formel-Lesehilfe \partial f / \partial x_1 bedeutet: „Wie stark ändert sich f, wenn ich nur x_1 ein winziges Stück erhöhe und alles andere festhalte?“
Ohne Formel gesagt: Der Gradient ist der Pfeil, der in die Richtung des steilsten Anstiegs zeigt. Seine Länge ist die Steilheit. Beim Minimieren geht man deshalb in Richtung -\nabla f — bergab.
Die eine Regel, die Sie brauchen: Im Inneren eines Gebiets ist an einem Minimum der Gradient null — es geht nirgendwohin mehr bergab. An einem Rand (also wenn eine Nebenbedingung bindet) gilt das nicht mehr, und genau dafür braucht man die KKT-Bedingungen.
Zwei Ableitungsregeln, die im ganzen Buch gebraucht werden:
| Ausdruck | Gradient | Merkhilfe |
|---|---|---|
| f(\mathbf{x}) = \mathbf{c}^\top\mathbf{x} | \nabla f = \mathbf{c} | wie (cx)' = c |
| f(\mathbf{x}) = \tfrac12\mathbf{x}^\top\mathbf{P}\mathbf{x} (mit \mathbf{P} symmetrisch) | \nabla f = \mathbf{P}\mathbf{x} | wie (\tfrac12 px^2)' = px |
11.3 Das quadratische Programm (QP)
Ein quadratisches Programm hat eine quadratische Zielfunktion und lineare Nebenbedingungen:
\min_{\mathbf{x}} \quad \tfrac{1}{2}\mathbf{x}^\top\mathbf{P}\mathbf{x} + \mathbf{q}^\top\mathbf{x} \qquad\text{u. d. N.}\qquad \mathbf{A}\mathbf{x}\le\mathbf{b},\quad \mathbf{F}\mathbf{x}=\mathbf{d}
📐 Formel-Lesehilfe * \mathbf{x}^\top\mathbf{P}\mathbf{x} — die quadratische Form. Ausgeschrieben: \sum_i\sum_j p_{ij}x_ix_j. In der Portfoliotheorie ist das exakt die Portfoliovarianz, wenn \mathbf{P} = \boldsymbol{\Sigma} die Kovarianzmatrix ist. * Der Faktor \tfrac12 ist reine Bequemlichkeit: Er kürzt sich beim Ableiten weg (\nabla = \mathbf{P}\mathbf{x} statt 2\mathbf{P}\mathbf{x}). * \mathbf{q}^\top\mathbf{x} — ein zusätzlicher linearer Teil, etwa die negative erwartete Rendite.
Ohne Formel gesagt: „Minimiere das Risiko (quadratisch) abzüglich des Ertrags (linear), unter Einhaltung der Budget- und Positionsgrenzen.“
Konvexität: die präzise Aussage
Eine verbreitete, aber zu stark formulierte Aussage lautet: „Wenn \mathbf{P} positiv semi-definit ist, ist das QP streng konvex; es existiert ein eindeutiges globales Minimum.“ Korrekt gilt:
| Voraussetzung | Bedeutung | Folgerung |
|---|---|---|
| \mathbf{P} \succeq 0 — positiv semidefinit, alle Eigenwerte \lambda_i \ge 0 | Die Funktion ist eine „Schüssel“, die eine flache Rinne haben darf | Problem ist konvex. Jedes lokale Minimum ist global. Es kann aber mehrere Minimalstellen geben — mit demselben Zielwert |
| \mathbf{P} \succ 0 — positiv definit, alle \lambda_i > 0 | Echte Schüssel ohne flache Richtung | Problem ist streng konvex. Die Minimalstelle ist eindeutig |
| \mathbf{P} hat einen negativen Eigenwert | Sattel oder Rinne nach unten | Problem ist nicht konvex. Lokale Optima möglich, keine Garantie |
Warum das praktisch zählt: Eine Stichproben-Kovarianzmatrix mit mehr Titeln als Beobachtungen (N > T) ist singulär — also nur semidefinit. Dann gibt es unendlich viele Portfolios mit exakt demselben minimalen Risiko, und der Solver liefert eines davon, scheinbar willkürlich. Kleine Datenänderungen führen zu völlig anderen Gewichten. Genau dieses Problem behebt die Shrinkage aus Kapitel 18.
Alle drei Zeilen der Tabelle lassen sich an einem winzigen Zwei-Variablen-QP direkt beobachten — inklusive der Stelle, an der CVXPY ein nicht konvexes Problem verweigert, bevor überhaupt ein Solver aufgerufen wird:
#!/usr/bin/env python3
# QP_Grundlagen.py
"""
Kapitel QP/NLP: Die drei Faelle aus der Konvexitaets-Tabelle (Abschnitt
'Das quadratische Programm') an
einem Mini-QP demonstriert: P positiv definit, P (singulaer) semidefinit,
P mit negativem Eigenwert.
"""
import numpy as np
import cvxpy as cp
def loese_qp(P, q, name):
n = len(q)
w = cp.Variable(n)
ziel = cp.Minimize(0.5 * cp.quad_form(w, P) + q @ w)
bedingungen = [cp.sum(w) == 1, w >= 0]
problem = cp.Problem(ziel, bedingungen)
print(f"\n--- {name} ---")
eigenwerte = np.linalg.eigvalsh(P)
print(f"Eigenwerte von P: {np.round(eigenwerte, 4)}")
print(f"DCP-konvex (CVXPY-Pruefung)? {problem.is_dcp()}")
if not problem.is_dcp():
print("-> CVXPY lehnt das Problem ab, BEVOR ueberhaupt ein Solver laeuft.")
return
problem.solve()
print(f"Status: {problem.status}")
print(f"w* = {np.round(w.value, 4)}")
print(f"Zielwert = {problem.value:.6f}")
if __name__ == "__main__":
q = np.zeros(2)
# Fall 1: P positiv definit -> eindeutiges Minimum
P_definit = np.array([[2.0, 0.5], [0.5, 1.0]])
loese_qp(P_definit, q, "P positiv definit")
# Fall 2: P singulaer/semidefinit (zwei "identische" Assets) -> unendlich viele Minima
P_semidefinit = np.array([[1.0, 1.0], [1.0, 1.0]])
loese_qp(P_semidefinit, q, "P positiv semidefinit (singulaer)")
# Fall 3: P mit negativem Eigenwert -> nicht konvex
P_indefinit = np.array([[1.0, 2.0], [2.0, 1.0]])
loese_qp(P_indefinit, q, "P indefinit (negativer Eigenwert)")
print("\n--- Nachweis: 'unendlich viele Minima' im semidefiniten Fall ---")
for punkt in [np.array([1.0, 0.0]), np.array([0.0, 1.0]), np.array([0.3, 0.7])]:
wert = 0.5 * punkt @ P_semidefinit @ punkt
print(f" w = {punkt} -> Zielwert = {wert:.4f} (identisch, obwohl w verschieden)")Erwartete Ausgabe:
--- P positiv definit ---
Eigenwerte von P: [0.7929 2.2071]
DCP-konvex (CVXPY-Pruefung)? True
Status: optimal
w* = [0.25 0.75]
Zielwert = 0.437500
--- P positiv semidefinit (singulaer) ---
Eigenwerte von P: [0. 2.]
DCP-konvex (CVXPY-Pruefung)? True
Status: optimal
w* = [0.5 0.5]
Zielwert = 0.500000
--- P indefinit (negativer Eigenwert) ---
Eigenwerte von P: [-1. 3.]
DCP-konvex (CVXPY-Pruefung)? False
-> CVXPY lehnt das Problem ab, BEVOR ueberhaupt ein Solver laeuft.
--- Nachweis: 'unendlich viele Minima' im semidefiniten Fall ---
w = [1. 0.] -> Zielwert = 0.5000 (identisch, obwohl w verschieden)
w = [0. 1.] -> Zielwert = 0.5000 (identisch, obwohl w verschieden)
w = [0.3 0.7] -> Zielwert = 0.5000 (identisch, obwohl w verschieden)
Der letzte Block ist der eigentliche Beweis: Drei völlig verschiedene Portfolios (1,0), (0,1) und (0{,}3;\,0{,}7) liefern exakt denselben Zielwert — die „Rinne” aus der Tabelle ist keine Metapher, sie ist hier eine echte Gerade im Lösungsraum.
⚠️ Prüfen Sie jede Matrix, bevor Sie sie verwenden
eigenwerte = np.linalg.eigvalsh(Sigma) # 'h' = fuer symmetrische Matrizen assert np.all(eigenwerte >= -1e-10), "Sigma ist nicht positiv semidefinit!" print(f"Konditionszahl: {eigenwerte.max() / eigenwerte.min():.1f}")Eine einzige Zeile — und sie deckt eine ungültige Kovarianzmatrix sofort auf.
11.4 Die Karush-Kuhn-Tucker-Bedingungen
Die KKT-Bedingungen verallgemeinern den Lagrange-Ansatz auf Probleme mit Ungleichungen. Sie sind notwendig für ein Optimum — und bei konvexen Problemen auch hinreichend.
Für das Problem
\min f(\mathbf{x}) \quad\text{u. d. N.}\quad g_i(\mathbf{x})\le0\ (i=1..m),\qquad h_j(\mathbf{x})=0\ (j=1..p)
lautet die Lagrange-Funktion:
\mathcal{L}(\mathbf{x},\boldsymbol{\lambda},\boldsymbol{\nu}) = f(\mathbf{x}) + \sum_{i=1}^m \lambda_i g_i(\mathbf{x}) + \sum_{j=1}^p \nu_j h_j(\mathbf{x})
📐 Formel-Lesehilfe Die Lagrange-Funktion verpackt die Nebenbedingungen in die Zielfunktion. Jede Bedingung bekommt einen Preis: \lambda_i bzw. \nu_j. Verletzt man Bedingung i, wird g_i > 0, und der Term \lambda_i g_i erhöht \mathcal{L} — es kostet also.
Ohne Formel gesagt: Statt „du darfst nicht“ sagt man „du darfst, aber es kostet \lambda_i je Einheit Überschreitung“. Bei den richtigen Preisen verhält sich der Optimierer dann von selbst regelkonform. Diese Preise sind exakt die Schattenpreise aus Kapitel 5.
Die vier KKT-Bedingungen
Am Optimum (\mathbf{x}^*, \boldsymbol{\lambda}^*, \boldsymbol{\nu}^*) gilt:
1. Stationarität — der Gradient der Lagrange-Funktion verschwindet:
\nabla f(\mathbf{x}^*) + \sum_i \lambda_i^*\nabla g_i(\mathbf{x}^*) + \sum_j \nu_j^*\nabla h_j(\mathbf{x}^*) = \mathbf{0}
Anschaulich: Die Kraft, die den Punkt bergab ziehen will (-\nabla f), wird exakt von den Nebenbedingungen aufgefangen. Wie ein Ball, der in einer Ecke liegen bleibt: Die Schwerkraft zieht, die Wände drücken dagegen, und die Summe ist null.
2. Primale Zulässigkeit — die ursprünglichen Bedingungen gelten: g_i(\mathbf{x}^*)\le0, h_j(\mathbf{x}^*)=0.
3. Duale Zulässigkeit — die Multiplikatoren der Ungleichungen sind nichtnegativ: \lambda_i^*\ge0.
Warum? Eine Wand kann nur drücken, nicht ziehen. Ein negatives \lambda hieße, die Bedingung würde die Lösung von sich wegziehen — dann wäre sie nicht bindend.
4. Komplementärer Schlupf — \lambda_i^*\cdot g_i(\mathbf{x}^*)=0 für alle i.
Bedeutung: Entweder ist die Bedingung nicht bindend (g_i<0), dann muss \lambda_i=0 sein. Oder der Multiplikator ist positiv, dann muss die Bedingung mit Gleichheit binden. Das ist derselbe Satz wie in Kapitel 5 — nur allgemeiner formuliert.
🔤 Formel-Übersetzer: die vier KKT-Bedingungen
Vier Zeilen Mathematik, vier Sätze, die man einem Betriebsleiter sagen könnte:
Mathematik Alltagssprache \nabla f(\mathbf{x}^*) + \sum_i \lambda_i^*\nabla g_i(\mathbf{x}^*) = \mathbf{0} „Der Zug ins Bessere und der Widerstand der Grenzen heben sich genau auf — sonst könnte man noch einen Schritt gehen.“ g_i(\mathbf{x}^*) \le 0 „Der Plan hält alle Regeln ein.“ \lambda_i^* \ge 0 „Eine Grenze kann nur bremsen, nie antreiben.“ Ein negativer Preis wäre widersinnig. \lambda_i^* \cdot g_i(\mathbf{x}^*) = 0 „Was Reserve hat, ist nichts wert. Was etwas wert ist, ist ausgereizt.“ Nie beides. \lambda_i^* selbst Der Schattenpreis von Bedingung i — dieselbe Größe wie im LP (Kapitel 5), nur für krumme Ränder. Alle vier zusammen in einem Satz: Wir stehen an einer Stelle, an der jede Verbesserung an eine Grenze stößt — und wir wissen für jede dieser Grenzen, was ihre Lockerung wert wäre.
Warum das praktisch zählt: Die KKT-Bedingungen sind nicht nur Theorie, sondern eine Prüfvorschrift. Bei einem konvexen Problem sind sie notwendig und hinreichend — wer sie an einer gefundenen Lösung nachrechnet, hat damit bewiesen, dass sie optimal ist. Genau das tut
KKT_Nachweis.pyweiter unten.
✏️ Handrechnung 11.1: KKT an einem Minimalbeispiel
\min\ f(x) = (x-5)^2 \qquad\text{u. d. N.}\qquad g(x) = x - 3 \le 0
Ohne Nebenbedingung läge das Minimum bei x = 5. Aber 5 > 3 — unzulässig.
Lagrange: \mathcal{L}(x,\lambda) = (x-5)^2 + \lambda(x-3).
KKT 1 (Stationarität): \dfrac{\partial\mathcal{L}}{\partial x} = 2(x-5) + \lambda = 0 \;\Rightarrow\; x = 5 - \lambda/2.
Fall A: \lambda = 0 (Bedingung nicht bindend). Dann x = 5. Prüfe KKT 2: g(5) = 2 > 0 ✗ unzulässig — dieser Fall entfällt.
Fall B: \lambda > 0 (Bedingung bindend). Aus KKT 4 folgt g(x) = 0, also x = 3. Einsetzen in KKT 1: 2(3-5) + \lambda = 0 \Rightarrow \lambda = 4. Prüfe KKT 3: \lambda = 4 > 0 ✓
Lösung: x^* = 3, \lambda^* = 4, f(x^*) = 4.
Und was bedeutet \lambda^* = 4? Es ist der Schattenpreis: Dürfte x bis 3,1 gehen, sänke f um etwa 4 \times 0{,}1 = 0{,}4. Probe: f(3{,}1) = (3{,}1-5)^2 = 3{,}61, tatsächlicher Rückgang 0{,}39 ✓ — die kleine Abweichung ist die Krümmung.
#!/usr/bin/env python3
# KKT_Nachweis.py
"""
Kapitel QP/NLP: Die KKT-Bedingungen numerisch nachpruefen.
Loest ein QP mit CVXPY, liest die Dualwerte aus und prueft alle vier
KKT-Bedingungen einzeln nach. Das ist zugleich eine Vorlage fuer die
Qualitaetssicherung eigener Modelle.
"""
import numpy as np
import cvxpy as cp
def baue_gueltige_kovarianz(vola, korrelationen, seed=0):
"""
Baut Sigma = D * C * D aus Volatilitaeten und einer Korrelationsmatrix.
Dieses Vorgehen ist konstruktionsbedingt positiv semidefinit - im
Gegensatz zum nachtraeglichen Ueberschreiben der Diagonalen, das die
positive Semidefinitheit zerstoeren kann.
"""
C = np.array(korrelationen, dtype=float)
assert np.allclose(C, C.T), "Korrelationsmatrix muss symmetrisch sein."
assert np.allclose(np.diag(C), 1.0), "Diagonale der Korrelationsmatrix muss 1 sein."
eigen = np.linalg.eigvalsh(C)
assert eigen.min() > -1e-10, (
f"Korrelationsmatrix ist nicht positiv semidefinit "
f"(kleinster Eigenwert {eigen.min():.4f}). Solche Korrelationen sind unmoeglich.")
D = np.diag(vola)
return D @ C @ D
if __name__ == "__main__":
# --- Ein kleines Portfolio-QP ----------------------------------------
vola = np.array([0.20, 0.14, 0.30]) # Volatilitaeten
korr = [[1.00, 0.30, 0.10],
[0.30, 1.00, 0.25],
[0.10, 0.25, 1.00]]
Sigma = baue_gueltige_kovarianz(vola, korr)
mu = np.array([0.09, 0.05, 0.13]) # erwartete Renditen
lam = 3.0 # Risikoaversion
eigenwerte = np.linalg.eigvalsh(Sigma)
print("=" * 74)
print(" KKT-BEDINGUNGEN AM PORTFOLIO-QP")
print("=" * 74)
print(f"Eigenwerte von Sigma: {np.round(eigenwerte, 6)}")
print(f" -> positiv definit: {bool(eigenwerte.min() > 0)} "
f"(Problem ist streng konvex, Loesung eindeutig)")
print(f" -> Konditionszahl: {eigenwerte.max() / eigenwerte.min():.2f}")
# --- Modell: min lam/2 * w'Sigma w - mu'w u.d.N. sum(w)=1, w>=0 ----
n = len(mu)
w = cp.Variable(n)
ziel = cp.Minimize(0.5 * lam * cp.quad_form(w, Sigma) - mu @ w)
budget = cp.sum(w) == 1
nichtnegativ = w >= 0
problem = cp.Problem(ziel, [budget, nichtnegativ])
problem.solve()
w_opt = w.value
nu = budget.dual_value # Multiplikator der Gleichung
lam_i = nichtnegativ.dual_value # Multiplikatoren der Ungleichungen
print(f"\nStatus: {problem.status}")
print(f"Optimale Gewichte: {np.round(w_opt, 6)}")
print(f"Zielwert: {problem.value:.6f}")
print(f"Multiplikator der Budgetgleichung (nu): {nu:.6f}")
print(f"Multiplikatoren der w>=0-Bedingungen: {np.round(lam_i, 6)}")
# --- KKT-Bedingungen einzeln pruefen ---------------------------------
print("\n--- Pruefung der vier KKT-Bedingungen ---")
# 1. Stationaritaet: grad f - lambda + nu*1 = 0
# f(w) = lam/2 w'Sigma w - mu'w -> grad f = lam*Sigma w - mu
# g_i(w) = -w_i <= 0 -> grad g_i = -e_i
# h(w) = sum(w) - 1 = 0 -> grad h = 1
grad_f = lam * (Sigma @ w_opt) - mu
stationaritaet = grad_f - lam_i + nu * np.ones(n)
print(f"1. Stationaritaet : max|Residuum| = {np.abs(stationaritaet).max():.2e}")
# 2. Primale Zulaessigkeit
print(f"2. Primal zulaessig : sum(w)-1 = {w_opt.sum()-1:.2e}, "
f"min(w) = {w_opt.min():.2e}")
# 3. Duale Zulaessigkeit
print(f"3. Dual zulaessig : min(lambda) = {lam_i.min():.2e} (muss >= 0 sein)")
# 4. Komplementaerer Schlupf: lambda_i * w_i = 0
print(f"4. Kompl. Schlupf : max|lambda_i * w_i| = "
f"{np.abs(lam_i * w_opt).max():.2e}")
alle_ok = (np.abs(stationaritaet).max() < 1e-6
and abs(w_opt.sum() - 1) < 1e-8
and w_opt.min() > -1e-8
and lam_i.min() > -1e-8
and np.abs(lam_i * w_opt).max() < 1e-6)
print(f"\nAlle vier KKT-Bedingungen erfuellt: {alle_ok}")
# --- Interpretation von nu -------------------------------------------
print("\n--- Was bedeutet nu? ---")
print("nu ist der Schattenpreis des Budgets: Um so viel aendert sich der")
print("Zielwert, wenn man statt 100 % nur 99 % investieren duerfte.")
problem2 = cp.Problem(cp.Minimize(0.5 * lam * cp.quad_form(w, Sigma) - mu @ w),
[cp.sum(w) == 1.01, w >= 0])
problem2.solve()
print(f" Vorhergesagt (nu * 0.01): {nu * 0.01:+.6f}")
print(f" Tatsaechlich gemessen: {problem2.value - problem.value:+.6f}")
print("=" * 74)11.5 Nichtlineare Optimierung mit scipy.optimize.minimize
Wenn Zielfunktion oder Nebenbedingungen weder linear noch quadratisch sind — Logarithmen, Wurzeln, Exponentialfunktionen — nutzt man gradientenbasierte Verfahren wie SLSQP (Sequential Least Squares Programming).
⚠️ Der entscheidende Unterschied zu CVXPY CVXPY prüft die Konvexität und garantiert bei Erfolg das globale Optimum.
scipy.optimize.minimizeprüft nichts und liefert ein lokales Optimum, das vom Startpunkt abhängt (siehe die Demonstration in Abschnitt 2.5). Wer SLSQP einsetzt, sollte immer mit mehreren Startpunkten rechnen und die Ergebnisse vergleichen.
Praxisfall: Entropie-maximierte Kapitalallokation
Wir optimieren ein Portfolio nicht nur nach Varianz, sondern maximieren zusätzlich die Shannon-Entropie der Gewichte, um Klumpenrisiken glatt zu bestrafen:
\min_{\mathbf{w}}\quad \underbrace{\tfrac12\mathbf{w}^\top\boldsymbol{\Sigma}\mathbf{w}}_{\text{Risiko}} \;-\;\underbrace{\alpha\,\boldsymbol{\mu}^\top\mathbf{w}}_{\text{Ertrag}} \;-\;\underbrace{\beta\Big(-\textstyle\sum_i w_i\ln w_i\Big)}_{\text{Diversifikation`\index{Diversifikation}`{=latex}}}
📐 Formel-Lesehilfe zur Entropie H(\mathbf{w}) = -\sum_i w_i \ln w_i ist ein Streuungsmaß: * Alles in einem Titel (w = (1,0,0,0)): H = 0 — minimale Streuung. * Gleichverteilt (w_i = 1/4): H = \ln 4 \approx 1{,}386 — maximale Streuung.
Ohne Formel gesagt: Die Entropie misst, wie „breit verteilt“ das Portfolio ist. Weil sie mit einem Minuszeichen in die zu minimierende Zielfunktion eingeht, wird breite Streuung belohnt — und zwar mathematisch glatt, ohne harte Obergrenzen. Der Vorteil gegenüber einer Schranke wie w_i \le 0{,}2: Der Übergang ist weich, das Ergebnis reagiert nicht sprunghaft auf kleine Datenänderungen.
#!/usr/bin/env python3
# Entropie_Maximierte_Allokation.py
"""
Kapitel QP/NLP: Nichtlineare Optimierung (NLP) mit scipy SLSQP.
Modell: Mean-Variance-Portfolio mit Shannon-Entropie-Diversifikation.
Achtung, haeufiger Fehler: Sigma per A@A.T zu erzeugen und anschliessend die
Diagonale zu ueberschreiben zerstoert die positive Semidefinitheit - die
Matrix kann dadurch einen negativen Eigenwert bekommen und unmoegliche
Korrelationen (> 1) implizieren. Dieses Programm baut Sigma stattdessen als
D * C * D aus Volatilitaeten und einer echten Korrelationsmatrix -
konstruktionsbedingt immer PSD.
Zusaetzlich: Multistart, weil SLSQP nur lokale Optima findet.
"""
import numpy as np
import pandas as pd
from scipy.optimize import minimize
ASSETS = ["Tech-Aktien", "Rohstoffe", "US-Treasuries", "Krypto"]
MU = np.array([0.14, 0.07, 0.03, 0.22]) # erwartete Jahresrenditen
VOLA = np.array([0.20, 0.14, 0.07, 0.40]) # Volatilitaeten p.a.
KORRELATION = np.array([
[1.00, 0.25, -0.10, 0.55],
[0.25, 1.00, 0.05, 0.20],
[-0.10, 0.05, 1.00, -0.15],
[0.55, 0.20, -0.15, 1.00],
])
ALPHA = 1.0 # Gewicht des Ertrags
BETA = 0.015 # Gewicht der Entropie-Diversifikation
UNTERGRENZE = 0.001
OBERGRENZE = 0.80
def baue_kovarianz() -> np.ndarray:
"""Sigma = D * C * D. Immer PSD, wenn C eine gueltige Korrelationsmatrix ist."""
eig_c = np.linalg.eigvalsh(KORRELATION)
if eig_c.min() < -1e-10:
raise ValueError(f"Korrelationsmatrix unmoeglich (Eigenwert {eig_c.min():.4f}).")
D = np.diag(VOLA)
Sigma = D @ KORRELATION @ D
eig_s = np.linalg.eigvalsh(Sigma)
assert eig_s.min() > 0, "Sigma nicht positiv definit!"
return Sigma
SIGMA = baue_kovarianz()
def zielfunktion(w, alpha=ALPHA, beta=BETA):
"""Risiko - Ertrag - Entropiepraemie (wird minimiert)."""
varianz = 0.5 * w @ SIGMA @ w
ertrag = MU @ w
entropie = -np.sum(w * np.log(w + 1e-12))
return varianz - alpha * ertrag - beta * entropie
def gradient(w, alpha=ALPHA, beta=BETA):
"""Exakter analytischer Gradient - beschleunigt und stabilisiert SLSQP."""
grad_varianz = SIGMA @ w # d/dw von 0.5 w'Sigma w
grad_ertrag = -alpha * MU
grad_entropie = beta * (np.log(w + 1e-12) + 1.0)
return grad_varianz + grad_ertrag + grad_entropie
def optimiere(startpunkt, alpha=ALPHA, beta=BETA):
"""alpha und beta werden durchgereicht - so bleibt die Funktion seiteneffektfrei."""
return minimize(
zielfunktion, startpunkt, args=(alpha, beta), jac=gradient,
method="SLSQP", bounds=[(UNTERGRENZE, OBERGRENZE)] * len(MU),
constraints=({"type": "eq",
"fun": lambda w: np.sum(w) - 1.0,
"jac": lambda w: np.ones(len(MU))}),
options={"ftol": 1e-12, "maxiter": 300})
if __name__ == "__main__":
n = len(MU)
eig = np.linalg.eigvalsh(SIGMA)
print("=" * 74)
print(" NICHTLINEARE ENTROPIE-OPTIMIERTE ASSET-ALLOKATION")
print("=" * 74)
print("Pruefung der Kovarianzmatrix:")
print(f" Eigenwerte: {np.round(eig, 6)}")
print(f" Positiv definit: {bool(eig.min() > 0)}")
print(f" Groesste Korrelation ausserhalb der Diagonale: "
f"{np.abs(KORRELATION - np.eye(n)).max():.2f} (muss <= 1 sein)")
# --- Multistart: SLSQP findet nur lokale Optima -----------------------
rng = np.random.default_rng(7)
startpunkte = [np.ones(n) / n] # Gleichgewichtung
for _ in range(9):
z = rng.random(n) + 0.05
startpunkte.append(z / z.sum())
ergebnisse = [optimiere(s) for s in startpunkte]
erfolgreich = [r for r in ergebnisse if r.success]
bestes = min(erfolgreich, key=lambda r: r.fun)
zielwerte = np.array([r.fun for r in erfolgreich])
print(f"\nMultistart mit {len(startpunkte)} Startpunkten:")
print(f" Erfolgreich konvergiert: {len(erfolgreich)}")
print(f" Spannweite der Zielwerte: {zielwerte.max() - zielwerte.min():.2e}")
print(" -> " + ("alle Startpunkte fuehren zum selben Optimum (Indiz fuer Konvexitaet)"
if zielwerte.max() - zielwerte.min() < 1e-6
else "ACHTUNG: verschiedene lokale Optima gefunden!"))
w_opt = bestes.x
rendite = MU @ w_opt
volatilitaet = np.sqrt(w_opt @ SIGMA @ w_opt)
entropie = -np.sum(w_opt * np.log(w_opt))
print(f"\nKonvergenz: {bestes.message}")
print(f"Iterationen: {bestes.nit}")
print(f"Erwartete Jahresrendite: {rendite * 100:6.2f} %")
print(f"Erwartete Volatilitaet: {volatilitaet * 100:6.2f} %")
print(f"Diversifikations-Entropie: {entropie:.4f} "
f"(Maximum bei Gleichgewichtung: {np.log(n):.4f})")
print("\n" + pd.DataFrame({
"Asset": ASSETS,
"Erw. Rendite": [f"{r*100:.1f} %" for r in MU],
"Volatilitaet": [f"{v*100:.1f} %" for v in VOLA],
"Gewicht": [f"{w*100:6.2f} %" for w in w_opt],
}).to_string(index=False))
# --- Vergleich: was passiert ohne Entropieterm? ----------------------
ohne = optimiere(np.ones(n) / n, beta=0.0)
print("\n--- Wirkung des Entropieterms ---")
print(f" Mit Entropie (beta={BETA}): Gewichte {np.round(w_opt * 100, 1)}")
print(f" Ohne Entropie (beta=0): Gewichte {np.round(ohne.x * 100, 1)}")
print(" Der Entropieterm zieht Kapital aus der Spitzenposition heraus,")
print(" ohne dass eine harte Obergrenze noetig waere.")
print("=" * 74)Erwartete Ausgabe:
==========================================================================
NICHTLINEARE ENTROPIE-OPTIMIERTE ASSET-ALLOKATION
==========================================================================
Pruefung der Kovarianzmatrix:
Eigenwerte: [0.004733 0.017389 0.026806 0.175572]
Positiv definit: True
Groesste Korrelation ausserhalb der Diagonale: 0.55 (muss <= 1 sein)
Multistart mit 10 Startpunkten:
Erfolgreich konvergiert: 10
Spannweite der Zielwerte: 4.25e-13
-> alle Startpunkte fuehren zum selben Optimum (Indiz fuer Konvexitaet)
Konvergenz: Optimization terminated successfully
Iterationen: 13
Erwartete Jahresrendite: 18.62 %
Erwartete Volatilitaet: 29.01 %
Diversifikations-Entropie: 0.7935 (Maximum bei Gleichgewichtung: 1.3863)
Asset Erw. Rendite Volatilitaet Gewicht
Tech-Aktien 14.0 % 20.0 % 35.96 %
Rohstoffe 7.0 % 14.0 % 2.75 %
US-Treasuries 3.0 % 7.0 % 0.45 %
Krypto 22.0 % 40.0 % 60.84 %
--- Wirkung des Entropieterms ---
Mit Entropie (beta=0.015): Gewichte [36. 2.8 0.5 60.8]
Ohne Entropie (beta=0): Gewichte [31.9 0.1 0.1 67.9]
Der Entropieterm zieht Kapital aus der Spitzenposition heraus,
ohne dass eine harte Obergrenze noetig waere.
==========================================================================
Vergleichen Sie diese Ausgabe mit der eingangs beschriebenen fehlerhaften Konstruktion. Die Eigenwerte sind jetzt alle positiv (kleinster: 0,0047 statt −0,084), die größte Korrelation liegt bei 0,55 statt bei unmöglichen 6,8 — und der Multistart bestätigt mit einer Spannweite von 4{,}3\times10^{-13}, dass alle zehn Startpunkte im selben Optimum landen. Das ist der empirische Beleg für Konvexität — mit einer indefiniten Matrix wäre das nicht möglich gewesen.
Interessant ist auch die Wirkung des Entropieterms: Er verschiebt nur rund 7 Prozentpunkte von Krypto weg — aber er hebt die Kleinstpositionen von 0,1 % auf 2,8 % bzw. 0,5 %. Eine harte Obergrenze hätte stattdessen genau bei der Schranke abgeschnitten und alles andere unverändert gelassen. Der weiche Term verteilt, die harte Schranke kappt.
💻 Code-Durchgang: die drei Lehren
baue_kovarianz()statt Diagonal-Überschreiben. Der Weg \boldsymbol{\Sigma} = \mathbf{D}\mathbf{C}\mathbf{D} (Volatilitäten mal Korrelationsmatrix) ist der einzige, der die Zulässigkeit garantiert — und er ist zugleich interpretierbar: Man gibt Volatilitäten und Korrelationen vor, also genau die Größen, über die man fachlich diskutiert.- Multistart. Zehn Startpunkte, und die Spannweite der Ergebnisse wird gemessen. Sind alle gleich, ist das ein starkes Indiz für Konvexität. Weichen sie ab, wissen Sie sofort, dass Sie einem lokalen Optimum aufsitzen. Diese fünf Zeilen sollten in jedem SLSQP-Projekt stehen.
- Analytischer Gradient.
jac=gradientspart nicht nur Zeit — die numerische Approximation von Ableitungen ist bei Termen wie \ln w nahe null numerisch heikel.Zur Skalierung: Mit \alpha = 1 ist der Ertragsterm (\approx 0{,}1) zehnmal größer als der Varianzterm (\approx 0{,}01). Das Modell ist also stark renditegetrieben. Die Aufgabe Effekt der Gewichtung untersuchen (Abschnitt 11.7) lässt Sie diese Balance untersuchen — sie ist eine fachliche Entscheidung, keine technische.
11.6 Jenseits der Konvexität: lokale Optima, Multistart und MINLP
Alles bisher in diesem Kapitel stand unter einer Bedingung: Konvexität. Sie ist der Grund, warum CVXPY eine Optimalitätsgarantie geben kann und warum die KKT-Bedingungen nicht nur notwendig, sondern auch hinreichend sind.
Dieser Abschnitt handelt davon, was passiert, wenn diese Bedingung fehlt — und das ist in der Praxis oft genug der Fall.
Woher Nicht-Konvexität im Alltag kommt
Nicht aus exotischer Mathematik, sondern aus ganz gewöhnlichen betriebswirtschaftlichen Effekten:
| Ursache | Beispiel | Warum nicht konvex |
|---|---|---|
| Mengenrabatte | Stückpreis fällt mit der Bestellmenge | Kostenfunktion wird konkav — Sprünge nach unten belohnen große Lose |
| Skaleneffekte | Stückkosten sinken mit der Losgröße | dasselbe Muster in der Produktion |
| Produkte von Variablen | „Menge mal Preis“, wenn beide entschieden werden | x \cdot y ist weder konvex noch konkav |
| Ja/Nein mal Menge | Anlage läuft und wie stark | ganzzahlig + nichtlinear = MINLP |
| Verhältnisse | Auslastungsgrad, Rendite je eingesetztem Euro | Quotienten sind selten konvex |
🎯 Merksatz Sobald ein Rabatt, ein Skaleneffekt oder ein Produkt zweier Entscheidungen im Modell steht, ist die Konvexität in Gefahr — und damit die Optimalitätsgarantie. Das ist kein Grund zur Panik, aber ein Grund, das Ergebnis anders zu behandeln.
Was ein lokales Optimum praktisch bedeutet
scipy.optimize.minimize verweigert nichts. Es rechnet, meldet success: True und liefert eine Zahl. Diese Meldung heißt aber nicht „das ist das Optimum“, sondern nur:
„Ich bin an einer Stelle angekommen, an der es in keine Richtung mehr bergab geht.“
Bei einem konvexen Problem ist das dasselbe. Bei einem nicht-konvexen sind es zwei völlig verschiedene Aussagen. Das folgende Programm macht den Unterschied sichtbar.
#!/usr/bin/env python3
# Lokale_Optima_Multistart.py
"""
Kapitel QP/NLP: Was passiert, wenn die Konvexitaet fehlt.
CVXPY verweigert nicht-konvexe Probleme - das ist sein Schutzmechanismus.
scipy.optimize.minimize verweigert nichts. Es rechnet, meldet 'success: True'
und liefert ein Ergebnis. Nur ist das Ergebnis dann kein Optimum, sondern
irgendein lokales Minimum, das vom Startpunkt abhaengt.
Beispiel aus dem Einkauf: 700 Tonnen Rohstoff werden auf vier Lieferanten
verteilt. Jeder gewaehrt einen MENGENRABATT - der Stueckpreis faellt, je mehr
man bei ihm bestellt:
preis_i(q) = basis_i * (1 - rabatt_i * (1 - exp(-q / skala_i)))
Genau das macht die Zielfunktion nicht-konvex: Grosse Bestellungen lohnen
sich ueberproportional, es gibt also mehrere sinnvolle "Cluster"-Loesungen -
und dazwischen schlechtere Taeler.
Das Programm zeigt drei Dinge:
1. Ein einzelner Lauf liefert ein plausibles Ergebnis - ohne jede Warnung.
2. 200 Startpunkte foerdern mehrere verschiedene lokale Optima zutage.
3. Der Unterschied zwischen bestem und schlechtestem betraegt hier 8 %.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import minimize
# Vier Lieferanten
NAMEN = ["Nord AG", "Ost GmbH", "Sued KG", "West SE"]
BASISPREIS = np.array([50.0, 47.0, 53.0, 45.0]) # EUR je Tonne ohne Rabatt
MAX_RABATT = np.array([0.30, 0.18, 0.35, 0.12]) # hoechstmoeglicher Rabatt
RABATT_SKALA = np.array([120.0, 260.0, 90.0, 300.0]) # wie schnell er greift
KAPAZITAET = np.array([400.0, 400.0, 400.0, 400.0])
BEDARF = 700.0
RNG = np.random.default_rng(0)
def gesamtkosten(menge: np.ndarray) -> float:
"""Einkaufskosten bei mengenabhaengigem Stueckpreis.
Der Rabatt waechst mit der Bestellmenge und laeuft gegen MAX_RABATT.
Dadurch ist der Stueckpreis fallend - und die Gesamtkostenfunktion
nicht mehr konvex.
"""
stueckpreis = BASISPREIS * (1 - MAX_RABATT * (1 - np.exp(-menge / RABATT_SKALA)))
return float(stueckpreis @ menge)
NEBENBEDINGUNGEN = [{"type": "eq", "fun": lambda q: q.sum() - BEDARF}]
GRENZEN = [(0.0, k) for k in KAPAZITAET]
def optimiere_von(startpunkt: np.ndarray):
"""Ein Lauf von einem gegebenen Startpunkt aus."""
return minimize(gesamtkosten, startpunkt, method="SLSQP",
bounds=GRENZEN, constraints=NEBENBEDINGUNGEN)
def zufaelliger_start() -> np.ndarray:
"""Zufaellige Aufteilung, die den Bedarf bereits erfuellt."""
anteil = RNG.random(len(NAMEN))
return anteil / anteil.sum() * BEDARF
def zeige_plan(titel: str, menge: np.ndarray, kosten: float) -> None:
print(f"\n{titel}")
for name, m in zip(NAMEN, menge):
anteil = m / BEDARF * 100
print(f" {name:<10} {m:7.1f} t ({anteil:4.1f} %)")
print(f" {'Gesamtkosten':<10} {kosten:9,.2f} EUR")
if __name__ == "__main__":
print("=" * 78)
print(" NICHT-KONVEX: DERSELBE CODE, VERSCHIEDENE ERGEBNISSE")
print("=" * 78)
print(f"{BEDARF:.0f} t Rohstoff auf {len(NAMEN)} Lieferanten mit Mengenrabatt.")
# --- 1. Ein einziger Lauf, so wie man es zuerst schreibt --------------
erster = optimiere_von(np.full(len(NAMEN), BEDARF / len(NAMEN)))
print(f"\n[1] EIN Lauf, Startpunkt 'gleichmaessig verteilt'")
print(f" scipy meldet: success={erster.success}, "
f"'{erster.message}'")
zeige_plan(" Ergebnis:", erster.x, erster.fun)
print("\n Nichts an dieser Ausgabe deutet darauf hin, dass etwas fehlt.")
# --- 1b. Der kaufmaennisch naheliegende Startpunkt --------------------
# "Kaufe bei den beiden Lieferanten mit dem guenstigsten Basispreis" -
# West SE (45) und Ost GmbH (47), jeweils bis zur Kapazitaetsgrenze.
guenstigste = np.argsort(BASISPREIS)[:2]
kaufmaennisch = np.zeros(len(NAMEN))
rest = BEDARF
for i in guenstigste:
kaufmaennisch[i] = min(KAPAZITAET[i], rest)
rest -= kaufmaennisch[i]
zweiter = optimiere_von(kaufmaennisch)
print(f"\n[1b] EIN Lauf, Startpunkt 'die zwei mit dem guenstigsten Basispreis'")
print(f" scipy meldet: success={zweiter.success}")
zeige_plan(" Ergebnis:", zweiter.x, zweiter.fun)
print(f"\n Dasselbe Programm, derselbe Aufruf, ein anderer Startpunkt -")
print(f" und {zweiter.fun - erster.fun:,.2f} EUR Unterschied "
f"({(zweiter.fun / erster.fun - 1) * 100:.1f} %).")
# --- 2. Multistart: dasselbe Problem, viele Startpunkte ---------------
laeufe = []
for _ in range(200):
ergebnis = optimiere_von(zufaelliger_start())
if ergebnis.success:
laeufe.append((float(ergebnis.fun), ergebnis.x))
# Ergebnisse, die sich um weniger als 1 Cent unterscheiden, sind dasselbe
# lokale Optimum - zusammenfassen, sonst zaehlt man Rundungsrauschen.
optima: list[tuple[float, np.ndarray]] = []
for wert, plan in sorted(laeufe, key=lambda t: t[0]):
if not optima or abs(wert - optima[-1][0]) > 0.01:
optima.append((wert, plan))
print("\n" + "=" * 78)
print(f"[2] 200 zufaellige Startpunkte -> {len(laeufe)} erfolgreiche Laeufe")
print(f" darunter {len(optima)} VERSCHIEDENE lokale Optima:")
print()
print(f" {'Rang':>5} {'Kosten':>13} {'Abstand zum besten':>20} Aufteilung (t)")
print(" " + "-" * 70)
bester = optima[0][0]
for rang, (wert, plan) in enumerate(optima, start=1):
abstand = (wert / bester - 1) * 100
aufteilung = " ".join(f"{m:5.0f}" for m in plan)
print(f" {rang:>5} {wert:>13,.2f} {abstand:>19.2f} % {aufteilung}")
# --- 3. Was das kostet ------------------------------------------------
schlechtester = optima[-1]
print("\n" + "=" * 78)
print(" WAS AUF DEM SPIEL STEHT")
print("=" * 78)
zeige_plan("Bester gefundener Plan:", optima[0][1], optima[0][0])
zeige_plan("Schlechtestes lokales Optimum:", schlechtester[1], schlechtester[0])
unterschied = schlechtester[0] - optima[0][0]
print(f"\n Unterschied: {unterschied:,.2f} EUR "
f"({unterschied / optima[0][0] * 100:.1f} %)")
print(f"\n Lauf [1] (gleichmaessiger Start): {erster.fun:>10,.2f} EUR")
print(f" Lauf [1b] (kaufmaennischer Start): {zweiter.fun:>10,.2f} EUR"
f" <- {(zweiter.fun / optima[0][0] - 1) * 100:.1f} % ueber dem besten")
print()
print(" Bemerkenswert: Der kaufmaennisch NAHELIEGENDE Startpunkt fuehrt in")
print(" das schlechteste Ergebnis von allen - schlechter als jedes der 200")
print(" zufaellig gefundenen lokalen Optima. Wer beim guenstigsten")
print(" Basispreis anfaengt, uebersieht, dass hier der Mengenrabatt")
print(" entscheidet und nicht der Listenpreis.")
print()
print("Drei Konsequenzen fuer die Praxis:")
print(" 1. 'success: True' heisst bei nicht-konvexen Problemen NICHT 'optimal'.")
print(" Es heisst nur: 'Ich bin an einer Stelle angekommen, an der es in")
print(" keine Richtung mehr bergab geht.'")
print(" 2. Ein einzelner Lauf ist wertlos. Nehmen Sie viele Startpunkte und")
print(" berichten Sie die STREUUNG mit - sie ist Ihre einzige Auskunft")
print(" darueber, wie zerklueftet die Landschaft ist.")
print(" 3. Auch Multistart liefert KEINE Garantie. Dass hier nichts unter")
print(f" {bester:,.2f} EUR gefunden wurde, beweist nicht, dass es nichts gibt.")
print("=" * 78)Erwartete Ausgabe:
==============================================================================
NICHT-KONVEX: DERSELBE CODE, VERSCHIEDENE ERGEBNISSE
==============================================================================
700 t Rohstoff auf 4 Lieferanten mit Mengenrabatt.
[1] EIN Lauf, Startpunkt 'gleichmaessig verteilt'
scipy meldet: success=True, 'Optimization terminated successfully'
Ergebnis:
Nord AG 343.5 t (49.1 %)
Ost GmbH 0.0 t ( 0.0 %)
Sued KG 356.5 t (50.9 %)
West SE 0.0 t ( 0.0 %)
Gesamtkosten 24,724.19 EUR
Nichts an dieser Ausgabe deutet darauf hin, dass etwas fehlt.
[1b] EIN Lauf, Startpunkt 'die zwei mit dem guenstigsten Basispreis'
scipy meldet: success=True
Ergebnis:
Nord AG 0.0 t ( 0.0 %)
Ost GmbH 400.0 t (57.1 %)
Sued KG 0.0 t ( 0.0 %)
West SE 300.0 t (42.9 %)
Gesamtkosten 28,618.55 EUR
Dasselbe Programm, derselbe Aufruf, ein anderer Startpunkt -
und 3,894.36 EUR Unterschied (15.8 %).
==============================================================================
[2] 200 zufaellige Startpunkte -> 200 erfolgreiche Laeufe
darunter 5 VERSCHIEDENE lokale Optima:
Rang Kosten Abstand zum besten Aufteilung (t)
----------------------------------------------------------------------
1 24,724.19 0.00 % 344 0 356 0
2 26,229.68 6.09 % 0 300 400 0
3 26,343.10 6.55 % 0 0 400 300
4 26,576.58 7.49 % 400 300 0 0
5 26,690.01 7.95 % 400 0 0 300
==============================================================================
WAS AUF DEM SPIEL STEHT
==============================================================================
Bester gefundener Plan:
Nord AG 343.5 t (49.1 %)
Ost GmbH 0.0 t ( 0.0 %)
Sued KG 356.5 t (50.9 %)
West SE 0.0 t ( 0.0 %)
Gesamtkosten 24,724.19 EUR
Schlechtestes lokales Optimum:
Nord AG 400.0 t (57.1 %)
Ost GmbH 0.0 t ( 0.0 %)
Sued KG 0.0 t ( 0.0 %)
West SE 300.0 t (42.9 %)
Gesamtkosten 26,690.01 EUR
Unterschied: 1,965.82 EUR (8.0 %)
Lauf [1] (gleichmaessiger Start): 24,724.19 EUR
Lauf [1b] (kaufmaennischer Start): 28,618.55 EUR <- 15.8 % ueber dem besten
Bemerkenswert: Der kaufmaennisch NAHELIEGENDE Startpunkt fuehrt in
das schlechteste Ergebnis von allen - schlechter als jedes der 200
zufaellig gefundenen lokalen Optima. Wer beim guenstigsten
Basispreis anfaengt, uebersieht, dass hier der Mengenrabatt
entscheidet und nicht der Listenpreis.
Drei Konsequenzen fuer die Praxis:
1. 'success: True' heisst bei nicht-konvexen Problemen NICHT 'optimal'.
Es heisst nur: 'Ich bin an einer Stelle angekommen, an der es in
keine Richtung mehr bergab geht.'
2. Ein einzelner Lauf ist wertlos. Nehmen Sie viele Startpunkte und
berichten Sie die STREUUNG mit - sie ist Ihre einzige Auskunft
darueber, wie zerklueftet die Landschaft ist.
3. Auch Multistart liefert KEINE Garantie. Dass hier nichts unter
24,724.19 EUR gefunden wurde, beweist nicht, dass es nichts gibt.
==============================================================================
💻 Code-Durchgang
Stelle Was passiert Warum es zählt 1 - MAX_RABATT * (1 - exp(-menge / SKALA))Stückpreis fällt mit der Menge Ein völlig normaler Staffelrabatt — und genau er zerstört die Konvexität. Man braucht keine exotische Mathematik dafür. Zusammenfassen mit > 0.01Rundungsrauschen von echten Optima trennen Ohne diesen Schritt zählt man 200 „verschiedene“ Optima, die sich in der zehnten Nachkommastelle unterscheiden. 200 zufällige Startpunkte Multistart Die einzige praktikable Auskunft darüber, wie zerklüftet die Landschaft ist — und trotzdem kein Beweis. Lauf [1b]der kaufmännisch naheliegende Startpunkt Die eigentliche Pointe: Er ist der schlechteste von allen.
Die Zahlen im Klartext
- Der gleichmäßige Startpunkt findet 24 724 € — zufällig das beste gefundene Optimum.
- Der kaufmännisch naheliegende Startpunkt („die zwei mit dem günstigsten Basispreis“) findet 28 619 € — 15,8 % schlechter, und schlechter als jedes der 200 zufällig gefundenen lokalen Optima.
- Beide Läufe melden
success: True. Nichts an der Ausgabe unterscheidet sie.
Der Grund für das schlechte Abschneiden des „vernünftigen“ Starts ist lehrreich: Wer beim günstigsten Listenpreis beginnt, folgt genau dem Kriterium, das hier nicht entscheidet. Ausschlaggebend ist der Rabatt — und Süd KG hat mit 35 % den höchsten, obwohl sein Basispreis der teuerste ist. Eine plausible Heuristik führt den Optimierer damit zielsicher ins falsche Tal.
⚠️ Typische Fehler bei nicht-konvexen Problemen
success: Trueals „optimal“ lesen. Es heißt nur „konvergiert“.- Einen einzigen Lauf berichten. Ohne Streuung über mehrere Startpunkte ist die Zahl nicht einordbar.
- Den „vernünftigen“ Startpunkt für den besten halten. Er ist oft der schlechteste, weil er einer Heuristik folgt, die genau das ignoriert, was das Problem schwer macht.
- Multistart für einen Beweis halten. Dass nichts Besseres gefunden wurde, beweist nicht, dass es nichts Besseres gibt.
Ausblick: MINLP und globale Solver
Wird zusätzlich noch ganzzahlig entschieden — „welche Anlage läuft überhaupt“ und „wie stark“ —, entsteht ein MINLP (Mixed-Integer Nonlinear Program), die schwierigste der in diesem Buch behandelten Klassen. Dafür gibt es eigene Werkzeuge:
| Werkzeug | Art | Was es leistet |
|---|---|---|
Ipopt (über cyipopt oder Pyomo) |
lokaler NLP-Solver | Innere-Punkte-Verfahren für große, glatte NLPs mit tausenden Variablen — deutlich leistungsfähiger als scipy, aber ebenfalls nur lokal |
| Bonmin, Couenne | MINLP | Bonmin lokal, Couenne mit globaler Garantie für viele Klassen |
| SCIP, BARON, Gurobi | global | Beweisbar globales Optimum, mit entsprechendem Rechenaufwand |
| Pyomo (Abschnitt 3.7) | Modellierungsschicht | Bindet alle oben genannten an, ohne dass das Modell umgeschrieben werden muss |
⚠️ Zu diesem Abschnitt gehört kein lauffähiges Programm. Ipopt, Bonmin und Couenne sind C++-Pakete, die über die Python-Installation hinaus systemweit eingerichtet werden müssen (
conda install -c conda-forge ipopt cyipoptoder eine Distributionspaketquelle). Anders als bei allen übrigen Programmen dieses Buches konnten wir den Code deshalb nicht auf jedem Zielsystem ausführen — und drucken hier bewusst keine Ausgabe ab, die wir nicht selbst erzeugt haben. Der folgende Ausschnitt zeigt die Anbindung; prüfen Sie das Ergebnis auf Ihrem System selbst nach.
# Ipopt ueber Pyomo - Anbindungsmuster, NICHT ausgefuehrt.
# Voraussetzung: conda install -c conda-forge ipopt
import pyomo.environ as pyo
modell = pyo.ConcreteModel()
modell.q = pyo.Var(range(4), domain=pyo.NonNegativeReals, bounds=(0, 400))
modell.bedarf = pyo.Constraint(expr=sum(modell.q[i] for i in range(4)) == 700)
modell.ziel = pyo.Objective(
expr=sum(BASISPREIS[i] * (1 - MAX_RABATT[i]
* (1 - pyo.exp(-modell.q[i] / RABATT_SKALA[i])))
* modell.q[i] for i in range(4)),
sense=pyo.minimize)
loeser = pyo.SolverFactory("ipopt")
if loeser.available(exception_flag=False):
ergebnis = loeser.solve(modell)
print(ergebnis.solver.termination_condition)
else:
print("Ipopt ist nicht installiert - siehe Hinweis oben.")Die Strategie bleibt in jedem Fall dieselbe: Ein lokaler Solver wie Ipopt löst schneller und größer als scipy — er löst aber nicht globaler. Multistart bleibt nötig. Erst ein globaler Solver (SCIP, Couenne, BARON) ersetzt ihn, und den bezahlt man mit Rechenzeit, die um Größenordnungen höher liegt.
🎯 Merksatz Bei nicht-konvexen Problemen ist die ehrliche Berichterstattung wichtiger als die letzte Nachkommastelle: „Bester gefundener Wert 24 724 € aus 200 Startpunkten; die Streuung reicht bis 26 690 €.“ Das ist eine belastbare Aussage. „Das Optimum liegt bei 24 724,19 €“ ist es nicht.
11.7 Übungsaufgaben
Lösungen: Abschnitt A.11.
Aufgabe 11.1 ⭐ — Konvexität einordnen. \mathbf{P} hat die Eigenwerte (2{,}5;\ 0{,}0;\ 1{,}3). Ist das QP konvex? Streng konvex? Ist die Lösung eindeutig? Was bedeutet der Eigenwert 0 anschaulich?
Aufgabe 11.2 ⭐ — Komplementärer Schlupf. Ein QP liefert w = (0{,}4;\ 0{,}0;\ 0{,}6) mit Bedingung w \ge 0 und Multiplikatoren \lambda = (0{,}0;\ 0{,}03;\ 0{,}0). Ist das mit KKT verträglich? Was sagt \lambda_2 = 0{,}03 wirtschaftlich?
Aufgabe 11.3 ⭐⭐ — KKT von Hand. Lösen Sie mit KKT vollständig von Hand: \min\ x_1^2 + x_2^2 \qquad\text{u. d. N.}\qquad x_1 + x_2 \ge 4 Geben Sie x^*, \lambda^* und die Interpretation von \lambda^* an. Prüfen Sie mit CVXPY.
Aufgabe 11.4 ⭐⭐ — Ungültige Kovarianzmatrix erkennen. Prüfen Sie, ob folgende Korrelationsmatrix möglich ist, und begründen Sie: \mathbf{C} = \begin{pmatrix}1{,}0&0{,}9&-0{,}9\\0{,}9&1{,}0&0{,}9\\-0{,}9&0{,}9&1{,}0\end{pmatrix} (Tipp: Wenn A stark mit B korreliert und B stark mit C, kann A dann stark negativ mit C korrelieren?)
Aufgabe 11.5 ⭐⭐⭐ — Effekt der Gewichtung untersuchen. Variieren Sie in Entropie_Maximierte_Allokation.py systematisch \alpha \in \{0{,}1;\ 0{,}5;\ 1;\ 5\} und \beta \in \{0;\ 0{,}005;\ 0{,}015;\ 0{,}05\}. Stellen Sie für jede Kombination Rendite, Volatilität und Entropie in einer Tabelle dar. (a) Wie verändert \beta die Konzentration im Krypto-Titel? (b) Ab welchem \beta nähert sich die Lösung der Gleichgewichtung? (c) Was ist Ihre Empfehlung — und mit welcher Begründung würden Sie sie einem Anlageausschuss vorlegen?
Aufgabe 11.6 ⭐⭐⭐ — Nicht-Konvexität demonstrieren. Bauen Sie bewusst eine ungültige „Kovarianzmatrix“ (per A@A.T mit anschließend überschriebener Diagonale) und lösen Sie das Modell mit 20 Startpunkten. Dokumentieren Sie: Wie viele verschiedene Optima entstehen? Wie groß ist der Unterschied zwischen bestem und schlechtestem Ergebnis? Kann die „Portfoliovarianz“ negativ werden?
11.8 Finde den Denkfehler
🐛 Finde den Denkfehler: Die Kovarianzmatrix aus dem Controlling
Ein Analyst soll das Risiko eines Portfolios aus drei Anlagen minimieren. Die Korrelationen hat er aus drei verschiedenen Quartalsberichten zusammengetragen — jede für sich plausibel:
- A und B laufen stark gleich: \rho_{AB} = 0{,}9
- B und C laufen stark gleich: \rho_{BC} = 0{,}9
- A und C laufen gegeneinander: \rho_{AC} = -0{,}9
Alle drei schwanken mit 20 %. Leerverkäufe sind in seinem Mandat erlaubt, die Gewichte dürfen also negativ werden; sie müssen sich nur zu 100 % summieren.
Sein erster Versuch mit CVXPY bricht ab:
DCPError: Problem does not follow DCP rules. The objective is not DCP. Its following subexpressions are not: QuadForm(...)Er hält das für eine Einschränkung der Bibliothek und weicht auf
scipyaus:import numpy as np from scipy.optimize import minimize R = np.array([[ 1.0, 0.9, -0.9], [ 0.9, 1.0, 0.9], [-0.9, 0.9, 1.0]]) S = 0.20**2 * R varianz = lambda w: float(w @ S @ w) ergebnis = minimize(varianz, np.array([0.4, 0.3, 0.3]), constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}], bounds=[(-2, 3)] * 3) print(np.round(ergebnis.x, 3), round(ergebnis.fun, 4), ergebnis.success)Ausgabe:
[ 1.5 -2. 1.5] -0.254 TrueEin Portfolio mit einer Varianz von -0{,}254. Der Analyst notiert erfreut ein „risikofreies Portfolio mit negativer Schwankung“.
Ihre Aufgabe: (a) Können A und B gleichlaufen, B und C gleichlaufen — und A und C zugleich gegenläufig sein? Prüfen Sie es, ohne zu rechnen, an einem anschaulichen Beispiel. (b) Berechnen Sie die Eigenwerte von \mathbf{R} mit
np.linalg.eigvalsh(R). Was sagt das Vorzeichen? (c) Warum ist die negative Varianz kein Fehler vonscipy, sondern die logische Folge der Eingabe — und warum meldet es trotzdemsuccess: True? (d) Warum war die Fehlermeldung von CVXPY die hilfreichste Zeile des ganzen Vorgangs, und was tut man stattdessen?Auflösung: Abschnitt A.11.
🎯 Merksatz Eine Kovarianzmatrix ist kein Behälter für einzeln geschätzte Zahlen. Sie ist ein geometrisches Objekt: Ihre Einträge hängen voneinander ab, und nicht jede Kombination von Korrelationen existiert überhaupt. Zusammengetragene Korrelationen aus verschiedenen Quellen sind fast nie widerspruchsfrei — das ist einer der häufigsten Fehler in der Risikomodellierung überhaupt.
11.9 Micro-Quiz
❓ Micro-Quiz 11: Drei Fragen zum Selbstcheck
Genau eine Antwort ist jeweils richtig. Auflösung in Anhang A.
1. CVXPY lehnt Ihr Modell mit
DCPError: Problem does not follow DCP rulesab. Was ist die richtige Reaktion? (a) Aufscipy.optimize.minimizeausweichen, das die Formulierung akzeptiert. (b) Die Meldung ernst nehmen: CVXPY sagt, dass es für diese Formulierung keine Optimalitätsgarantie geben kann. Entweder das Problem konvex umformulieren — oder bewusst auf ein lokales Verfahren mit Multistart wechseln. (c) Die Toleranzen lockern.2.
scipy.optimize.minimizemeldet für ein Modell mit Mengenrabattensuccess: True. Was wissen Sie damit über die gefundene Lösung? (a) Sie ist optimal. (b) Sie ist ein lokales Minimum — es geht von dort in keine Richtung mehr bergab. Ob es anderswo ein besseres gibt, ist damit offen. (c) Sie ist zulässig, aber möglicherweise nicht einmal ein lokales Minimum.3. Der Lagrange-Multiplikator einer bindenden Nebenbedingung beträgt \lambda^* = 4. Was bedeutet das? (a) Die Nebenbedingung wird viermal verletzt. (b) Vier Einheiten der Ressource sind übrig. (c) Lockert man die Bedingung um eine kleine Einheit, verbessert sich der Zielwert um etwa 4 — es ist der Schattenpreis, genau wie im LP.
11.10 Selbsttest
Antworten: Anhang A.
- Worin unterscheiden sich „positiv semidefinit“ und „positiv definit“ in ihren Folgen für die Lösung?
- Was besagt die Stationaritätsbedingung anschaulich?
- Warum müssen die Multiplikatoren von Ungleichungen nichtnegativ sein?
- Wie konstruiert man garantiert eine gültige Kovarianzmatrix?
- Warum sollte man SLSQP immer mit mehreren Startpunkten laufen lassen?
11.11 Zusammenfassung
- Quadratische Programme sind die Brücke zur Portfoliotheorie: Risiko ist eine quadratische Form \mathbf{w}^\top\boldsymbol{\Sigma}\mathbf{w}.
- Semidefinit ⟹ konvex (lokales = globales Optimum), definit ⟹ streng konvex (Optimum eindeutig). Der Unterschied ist praktisch relevant, sobald N > T.
- KKT verallgemeinert Lagrange auf Ungleichungen. Die Multiplikatoren sind die Schattenpreise aus Kapitel 5 — nur allgemeiner.
- Komplementärer Schlupf ist der beste Selbsttest für jede Optimierung.
- Kovarianzmatrizen immer prüfen und aus Volatilitäten plus Korrelationsmatrix konstruieren, nie durch Manipulation einzelner Einträge.
- SLSQP liefert nur lokale Optima. Multistart ist Pflicht, nicht Kür — und
success: Trueheißt „konvergiert“, nicht „optimal“. Im Kapitelbeispiel liegen fünf lokale Optima 8 % auseinander, und ausgerechnet der kaufmännisch naheliegende Startpunkt ist der schlechteste. - Nicht-Konvexität kommt aus dem Alltag, nicht aus der Theorie: Mengenrabatte, Skaleneffekte, Produkte zweier Entscheidungen. Wo eines davon im Modell steht, ist die Optimalitätsgarantie weg.
- Berichten Sie bei nicht-konvexen Problemen die Streuung mit, nicht die letzte Nachkommastelle. „Bester Wert aus 200 Startpunkten, Spanne bis …“ ist belastbar, ein einzelner Wert ist es nicht.
Ausblick. Kapitel 12 gibt die Annahme auf, dass die Parameter überhaupt bekannt sind. Wir lernen, mit Szenarien, Erwartungswerten und Worst Cases umzugehen.