Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
8 KiB
Python
Generated
206 lines
8 KiB
Python
Generated
#!/usr/bin/env python3
|
|
|
|
# Skalierung_Kondition.py
|
|
"""
|
|
Kapitel Fundament: Was die Konditionszahl kappa(A) praktisch bedeutet - und wie man
|
|
ein schlecht skaliertes Modell wieder gesund rechnet.
|
|
|
|
Drei Experimente:
|
|
|
|
1. Fehlerverstaerkung: Wie stark schlaegt eine winzige Datenunsicherheit auf
|
|
die Loesung durch? kappa(A) ist genau die Obergrenze dieses Faktors.
|
|
2. Ruiz-Equilibrierung: Ein Modell, in dem Euro-Betraege (1e7) und
|
|
Tonnen-Angaben (1e-3) in derselben Matrix stehen, wird durch Zeilen- und
|
|
Spaltenskalierung um Groessenordnungen besser konditioniert.
|
|
3. Toleranzen: Warum eine Binaervariable mit dem Wert 0.99999998 niemals
|
|
mit int() gerundet werden darf.
|
|
|
|
Benoetigt: numpy, scipy
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
from scipy.optimize import linprog
|
|
|
|
RNG = np.random.default_rng(42)
|
|
|
|
|
|
# --- Experiment 1: kappa(A) als Fehlerverstaerker ---------------------------
|
|
|
|
def fehlerverstaerkung(A: np.ndarray, versuche: int = 200,
|
|
stoerung: float = 1e-10) -> tuple[float, float]:
|
|
"""Stoert die rechte Seite b relativ um 'stoerung' in zufaellige Richtungen
|
|
und misst, um welchen Faktor sich der Fehler in der Loesung x vergroessert.
|
|
|
|
Liefert (Median, Maximum) der Verstaerkung. Die Theorie sagt: Das Maximum
|
|
kann bis kappa(A) betragen - und nur bis dahin.
|
|
"""
|
|
x_wahr = np.ones(A.shape[1])
|
|
b = A @ x_wahr
|
|
faktoren = []
|
|
for _ in range(versuche):
|
|
richtung = RNG.normal(size=b.size)
|
|
richtung /= np.linalg.norm(richtung)
|
|
b_gestoert = b + stoerung * np.linalg.norm(b) * richtung
|
|
x_gestoert = np.linalg.solve(A, b_gestoert)
|
|
rel_x = np.linalg.norm(x_gestoert - x_wahr) / np.linalg.norm(x_wahr)
|
|
faktoren.append(rel_x / stoerung)
|
|
return float(np.median(faktoren)), float(np.max(faktoren))
|
|
|
|
|
|
def zeige_experiment_1() -> None:
|
|
print("=" * 74)
|
|
print(" 1. KONDITIONSZAHL ALS FEHLERVERSTAERKER")
|
|
print("=" * 74)
|
|
print("Zwei Gleichungssysteme, beide exakt loesbar mit x = (1, 1).")
|
|
print("Die rechte Seite wird um relativ 1e-10 gestoert - so viel Unsicherheit")
|
|
print("steckt in JEDER gemessenen Betriebszahl allemal.\n")
|
|
|
|
modelle = {
|
|
"gut konditioniert": np.array([[2.0, 1.0], [1.0, 3.0]]),
|
|
"fast parallele Zeilen": np.array([[1.0, 1.0], [1.0, 1.0 + 1e-8]]),
|
|
}
|
|
print(f"{'Matrix':<24} {'kappa(A)':>12} {'Verst. median':>14} {'Verst. max':>12}")
|
|
print("-" * 74)
|
|
for name, A in modelle.items():
|
|
median, maximum = fehlerverstaerkung(A)
|
|
print(f"{name:<24} {np.linalg.cond(A):>12.2e} {median:>14.2e} {maximum:>12.2e}")
|
|
|
|
print("\nLesart: Bei der zweiten Matrix wird aus einem Datenfehler in der")
|
|
print("10. Nachkommastelle ein Loesungsfehler in der 2. Nachkommastelle.")
|
|
print("Das Modell ist mathematisch korrekt - und praktisch wertlos.")
|
|
|
|
|
|
# --- Experiment 2: Ruiz-Equilibrierung --------------------------------------
|
|
|
|
def ruiz_equilibrierung(A: np.ndarray, durchlaeufe: int = 20
|
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
"""Skaliert A iterativ so, dass alle Zeilen- und Spaltenmaxima nahe 1
|
|
liegen (Ruiz 2001).
|
|
|
|
In jedem Durchlauf wird jede Zeile durch die Wurzel ihres Betragsmaximums
|
|
geteilt, danach jede Spalte. Das Verfahren konvergiert schnell und braucht
|
|
keinerlei Wissen ueber die Bedeutung der Zahlen - genau deshalb steckt es
|
|
in jedem ernsthaften Solver als Vorverarbeitung.
|
|
|
|
Liefert (A_skaliert, zeilenfaktor r, spaltenfaktor c) mit
|
|
A_skaliert = diag(r) @ A @ diag(c)
|
|
"""
|
|
m, n = A.shape
|
|
r = np.ones(m)
|
|
c = np.ones(n)
|
|
A_s = A.astype(float).copy()
|
|
|
|
for _ in range(durchlaeufe):
|
|
zeilen_max = np.abs(A_s).max(axis=1)
|
|
zeilen_max[zeilen_max == 0] = 1.0
|
|
d_r = 1.0 / np.sqrt(zeilen_max)
|
|
A_s = d_r[:, None] * A_s
|
|
r *= d_r
|
|
|
|
spalten_max = np.abs(A_s).max(axis=0)
|
|
spalten_max[spalten_max == 0] = 1.0
|
|
d_c = 1.0 / np.sqrt(spalten_max)
|
|
A_s = A_s * d_c[None, :]
|
|
c *= d_c
|
|
|
|
return A_s, r, c
|
|
|
|
|
|
def zeige_experiment_2() -> None:
|
|
print("\n" + "=" * 74)
|
|
print(" 2. RUIZ-EQUILIBRIERUNG: EINHEITEN GERADERUECKEN")
|
|
print("=" * 74)
|
|
print("Ein Produktionsmodell, in dem vier Ressourcen in voellig")
|
|
print("verschiedenen Einheiten gemessen werden:")
|
|
print(" Zeile 1: Kapitalbindung in Euro (Groessenordnung 1e7)")
|
|
print(" Zeile 2: Katalysatorverbrauch in Tonnen (Groessenordnung 1e-3)")
|
|
print(" Zeile 3: Energie in Wattsekunden (Groessenordnung 1e5)")
|
|
print(" Zeile 4: Ausschussquote als Anteil (Groessenordnung 1e-2)\n")
|
|
|
|
# Quadratisch gewaehlt, damit die Loesung eindeutig ist und die
|
|
# Ruecktransformation unten wirklich etwas beweist.
|
|
n = 4
|
|
grundmatrix = RNG.uniform(0.5, 2.0, size=(n, n))
|
|
einheiten = np.array([1e7, 1e-3, 1e5, 1e-2])
|
|
A = grundmatrix * einheiten[:, None]
|
|
|
|
A_s, r, c = ruiz_equilibrierung(A)
|
|
|
|
print(f"{'':<28} {'kappa(A)':>12} {'groesster Eintrag':>18} "
|
|
f"{'kleinster Eintrag':>18}")
|
|
print("-" * 74)
|
|
for name, matrix in [("vor der Skalierung", A), ("nach Ruiz-Equilibrierung", A_s)]:
|
|
betraege = np.abs(matrix)
|
|
print(f"{name:<28} {np.linalg.cond(matrix):>12.2e} "
|
|
f"{betraege.max():>18.2e} {betraege.min():>18.2e}")
|
|
|
|
# Gegenprobe: Das skalierte Modell beschreibt dasselbe Problem. Wer x_s
|
|
# loest, erhaelt die urspruengliche Loesung durch x = c * x_s.
|
|
x_wahr = RNG.uniform(1.0, 5.0, size=n)
|
|
b = A @ x_wahr
|
|
b_s = r * b
|
|
x_s = np.linalg.solve(A_s, b_s)
|
|
x_zurueck = c * x_s
|
|
print(f"\nRuecktransformation x = c * x_s: groesste Abweichung zur wahren "
|
|
f"Loesung {np.abs(x_zurueck - x_wahr).max():.2e}")
|
|
print("Die Skalierung ist also verlustfrei - sie aendert nur die Zahlen,")
|
|
print("nicht das Problem.")
|
|
|
|
|
|
# --- Experiment 3: Toleranzen und der int()-Fehler --------------------------
|
|
|
|
def zeige_experiment_3() -> None:
|
|
print("\n" + "=" * 74)
|
|
print(" 3. TOLERANZEN: WARUM int() DIE FALSCHE RUNDUNG IST")
|
|
print("=" * 74)
|
|
|
|
# Ein LP, dessen Optimum bei x = 1 liegt, aber vom Solver nur bis auf
|
|
# seine Toleranz getroffen wird.
|
|
ergebnis = linprog(c=[-1.0], A_ub=[[1.0]], b_ub=[1.0],
|
|
bounds=[(0, None)], method="highs")
|
|
wert = float(ergebnis.x[0])
|
|
print(f"Solver liefert x = {wert!r}")
|
|
|
|
# Typische Werte, wie sie aus MILP-Solvern zurueckkommen.
|
|
beispiele = [0.99999998, 1.00000002, 0.49999999, 2.9999999]
|
|
print(f"\n{'Solverwert':>14} {'int()':>8} {'round()':>9} {'Kommentar'}")
|
|
print("-" * 74)
|
|
kommentare = {
|
|
0.99999998: "int() macht aus einer JA- eine NEIN-Entscheidung",
|
|
1.00000002: "hier ginge int() zufaellig gut - Verlass ist keiner",
|
|
0.49999999: "echt unentschieden: Modell oder Toleranz pruefen!",
|
|
2.9999999: "3 Maschinen werden zu 2 - der Plan geht nicht auf",
|
|
}
|
|
for wert_b in beispiele:
|
|
print(f"{wert_b:>14.8f} {int(wert_b):>8} {round(wert_b):>9} "
|
|
f"{kommentare[wert_b]}")
|
|
|
|
print("\nRichtige Vorgehensweise: gegen die Solver-Toleranz pruefen,")
|
|
print("dann erst runden - und den Zweifelsfall melden statt still zu raten.")
|
|
|
|
def sichere_ganzzahl(wert: float, toleranz: float = 1e-6) -> int:
|
|
naechste = round(wert)
|
|
if abs(wert - naechste) > toleranz:
|
|
raise ValueError(
|
|
f"{wert} ist {abs(wert - naechste):.2e} von der naechsten ganzen "
|
|
f"Zahl entfernt - das ist mehr als die Toleranz {toleranz}. "
|
|
"Ganzzahligkeit im Modell pruefen.")
|
|
return naechste
|
|
|
|
for wert_b in beispiele:
|
|
try:
|
|
print(f" sichere_ganzzahl({wert_b}) = {sichere_ganzzahl(wert_b)}")
|
|
except ValueError as fehler:
|
|
print(f" sichere_ganzzahl({wert_b}) -> ValueError: {fehler}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
zeige_experiment_1()
|
|
zeige_experiment_2()
|
|
zeige_experiment_3()
|
|
print("\n" + "=" * 74)
|
|
print("Merksatz: Skalieren Sie Ihre Daten, BEVOR der Solver sie sieht -")
|
|
print("und runden Sie Solver-Ergebnisse NIE ohne Toleranzpruefung.")
|
|
print("=" * 74)
|