Inhalt des Skriptums seit dem Sommersemester 2025
- Startseite
- Allgemeine Informationen zur Lehrveranstaltung
- Einfaches Python Setup und Wichtige Informationen
- 01 Einführung in algorithmisches Denken & LLMs
- 02 Algorithmenanalyse und Komplexitätstheorie
- 03 Vektoren, Matrizen und Vektorisierung in Python
- 04 Differenzieren und Integrieren in Python: Analytische und Numerische Methoden
- 05 Arbeiten mit Daten: Vorbereitung, Visualisierung, Analyse
- 06 Graphalgorithmen: Darstellungen, Traversierungen, Kürzeste Wege und Spannbäume
- 07 Gradient Descent, Stochastische Optimierung, Simulated Annealing, Genetische Algorithmen
- 08 Schwärme und Emergenz
Hier finden Sie die Kapitel aus den Vergangenen Jahren (legacy):
- 0. Python Einführung und Checkup
- 1. Einführung und einfache Algorithmen
- 2. Differenzieren und Integrieren
- 3. Vektoren, Matrizen und Vektorisierung in Python
- 4. Datenanalyse bzw. Datenauswertung
- 5. Grundlagen der Optimierung und Gradient Descent
- 6. Stochastische Optimierung und Genetische Algorithmen
- 7. Monte-Carlo-Methoden – Simulation und Integration
- 8. Monte-Carlo-Methoden, Teil 2 – Monte-Carlo-Integration, Teil 2 und Random Walk
- 9. Unsupervised Machine Learning: Clustering von Daten
- 10. Supervised Machine Learning: Grundlagen
- 11. Einführung in künstliche neuronale Netzwerke
Die Jupyter-Notebooks zur Lehrveranstaltung finden Sie im zugehörigen GitHub-Repository.
7: Optimierung: Gradient Descent, Stochastische Optimierung, Simulated Annealing, Genetische Algorithmen¶
Die Optimierung ist ein zentrales mathematisches Konzept, bei dem es darum geht, die beste Lösung aus einer Menge von möglichen Lösungen zu finden. In diesem Notebook gebe ich Ihnen zunächst eine kurze generelle Einführung und zeige Ihnen dann einige wichtige und interessante Varianten.
Nach dem Studium dieser Einheit sollten Sie zumindest folgende drei der wichtigsten Punkte hier verstehen:¶
- Was Optimierung ist und was sie leistet.
- Wie man sich ein Optimierungsproblem schwer, aber auch sehr leicht machen kann.
- Welche verschiedenen Möglichkeiten der Optimierung es gibt und was diese besonders gut können.
7.1 Einführung in die Optimierung¶
Bei der Optimierung geht es darum, ein konkretes Problem zu lösen bei dem etwas optimal werden soll bzw. bei dem man die optimale Lösung des Problems finden will. Optimierung ist dabei sehr facettenreich, sowohl was die Probleme, als auch was die Lösungsmethoden betrifft.
7.1.1 Grundlegende Konzepte: Zielfunktionen, Constraints, lokale vs. globale Optima¶
Vorab müssen wir uns mit einigen grundlegenden Begriffen vertraut machen. Zunächst nennt man eine Problemstellung, bei der etwas möglichst gut, groß oder klein werden soll, ein Optimierungsproblem. Meist ist es recht einfach, irgendeine Lösung des Problems zu finden, aber man weiß dabei nicht, ob es die beste ist oder nicht oder gar besonders schlecht.
Eine Zielfunktion (auch Kostenfunktion oder Fitnessfunktion genannt) quantifiziert, wie “gut” eine Lösung ist. Wir suchen entweder ihr Minimum oder Maximum, je nachdem, ob es sich um ein Minimierungsproblem oder ein Maximierungsproblem handelt. An dieser Stelle gleich eine wichtige Information: Man kann ein Minimierungsproblem direkt in ein Maximierungsproblem überführen (und umgekehrt), indem man die Zielfunktion mit $-1$ multipliziert. Daher werden wir im Folgenden immer nur einen Fall betrachten und im Hinterkopf behalten, dass damit beide Situationen behandelbar sind.
Constraints (Nebenbedingungen) schränken den Lösungsraum ein und definieren so, welche Lösungen zulässig sind. Grundsätzlich kann es bei Optimierungsproblemen verschiedene Einschränkungen geben:
- Die erlaubten Wertebereiche der Input-Variablen können eingeschränkt sein
- Die Wertebereiche der Zielfunktion können eingeschränkt sein
- Die Werte der Input-Variablen können durch eine sogenannte Nebenbedingung zusammenhängen
- Der “Anspruch” der Optimierung kann eingeschränkt sein auf entweder lokale oder globale Optima, die man finden möchte.
Bei der Unterscheidung zwischen lokalen und globalen Optima geht es um die Frage, ob ein Optimum nur in seiner unmittelbaren Umgebung das Beste ist (lokal) oder tatsächlich die beste Lösung im gesamten Suchraum darstellt (global).
Zum Abschluss dieser Übersicht über die wichtigsten Begriffe noch ein paar Hinweise: Durch die Definition einer geeigneten Zielfunktion kann nahezu jedes Problem als Optimierungsproblem formuliert werden. Die Herausforderung besteht dann darin, eine Methode zu finden, die effizient zum globalen Optimum führt, ohne in lokalen Optima “stecken zu bleiben”. Eine visuelle Darstellung von Zielfunktionen als Landschaften mit Tälern (Minima) und Bergen (Maxima) hilft grundsätzlich dabei, ein Gefühl für das jeweilige Problem zu bekommen und die Herausforderungen der Optimierung zu verstehen. In komplexen, hochdimensionalen Räumen kann diese Landschaft allerdings extrem unübersichtlich werden.
7.1.2 Klassifizierung von Optimierungsproblemen (linear, konvex, nicht-konvex)¶
Optimierungsprobleme lassen sich in bestimmte Klassen einteilen:
Lineare Optimierungsprobleme zeichnen sich durch lineare Zielfunktionen und lineare Constraints aus. Sie sind besonders gut handhabbar, da die optimale Lösung immer an den Rändern des zulässigen Bereichs liegt. Der Simplex-Algorithmus und Interior-Point-Methoden lösen solche Probleme effizient und garantiert optimal. Hier gleich ein Beispiel. Die Code-Snippets in diesem Notebook stammen in bewährter Manier von Claude 3.7 Sonnet. Der folgende Code definiert und löst das lineare Optimierungsproblem mit der Zielfunktion $f(x, y) = 3x + 2y$, die zu maximieren ist, unter den Nebenbedingungen $2x + y \le 20$, $x + 2y \le 20$ und $x, y \gt 0$.
import os
os.environ['MKL_DISABLE_FAST_MM'] = '1'
# Rest des Codes...import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import linprog
# Definiere das Beispiel eines linearen Optimierungsproblems
# Maximiere: f(x, y) = 3x + 2y
# unter den Nebenbedingungen:
# 2x + y ≤ 20
# x + 2y ≤ 20
# x, y ≥ 0
# Für linprog müssen wir das Problem umformulieren:
# Minimiere: -3x - 2y
# unter den Nebenbedingungen:
# 2x + y ≤ 20
# x + 2y ≤ 20
# x, y ≥ 0
# Koeffizienten der Zielfunktion (negativ für Maximierung)
c = [-3, -2]
# Koeffizienten der Ungleichungsnebenbedingungen
A = [[2, 1], # 2x + y ≤ 20
[1, 2]] # x + 2y ≤ 20
# Rechte Seite der Ungleichungsnebenbedingungen
b = [20, 20]
# Löse das lineare Optimierungsproblem
res = linprog(c, A_ub=A, b_ub=b, bounds=(0, None))
# Ausgabe der Ergebnisse
print(f"Optimale Lösung gefunden: {res.success}")
print(f"Optimaler Punkt: x = {res.x[0]:.2f}, y = {res.x[1]:.2f}")
print(f"Optimaler Zielfunktionswert: {-res.fun:.2f}") # Negativ, da wir maximieren
# Visualisierung
x = np.linspace(0, 25, 1000)
# Nebenbedingungen als Funktionen von x
y1 = 20 - 2*x # 2x + y = 20 => y = 20 - 2x
y2 = (20 - x)/2 # x + 2y = 20 => y = (20 - x)/2
# Zielfunktionswerte für verschiedene Ebenen
z_levels = [0, 20, 40, 60]
z_lines = [(z - 3*x)/2 for z in z_levels] # 3x + 2y = z => y = (z - 3x)/2
# Plot erstellen
plt.figure(figsize=(10, 6))
# Zeichne den zulässigen Bereich
plt.fill_between(x, 0, np.minimum(y1, y2), where=(x >= 0) & (y1 >= 0) & (y2 >= 0),
alpha=0.2, color='skyblue', label='Zulässiger Bereich')
# Zeichne die Nebenbedingungen
plt.plot(x, y1, 'r-', label='2x + y = 20')
plt.plot(x, y2, 'g-', label='x + 2y = 20')
plt.axhline(y=0, color='k', linestyle='-', alpha=0.3, label='y = 0')
plt.axvline(x=0, color='k', linestyle='-', alpha=0.3, label='x = 0')
# Zeichne die Zielfunktionsebenen
for i, z_line in enumerate(z_lines):
plt.plot(x, z_line, 'b--', alpha=0.5, label=f'3x + 2y = versch.' if i==0 else None)
# Markiere den optimalen Punkt
plt.plot(res.x[0], res.x[1], 'ro', markersize=10, label=f'Optimum ({res.x[0]:.1f}, {res.x[1]:.1f})')
# Beschrifte den Plot
plt.xlim(0, 22)
plt.ylim(0, 15)
plt.xlabel('x')
plt.ylabel('y')
plt.title('Lineares Optimierungsproblem: Maximiere 3x + 2y')
plt.grid(True, alpha=0.3)
plt.legend(loc='upper right')
plt.tight_layout()
plt.show()
Optimale Lösung gefunden: True Optimaler Punkt: x = 6.67, y = 6.67 Optimaler Zielfunktionswert: 33.33

In diesem Plot sehen wir folgende Elemente:
- Den zulässigen Bereich (hellblau)
- Die Grenzen der Nebenbedingungen als rote und grüne Linien
- Mehrere Höhenlinien der Zielfunktion als gestrichelte blaue Linien
- Den optimalen Punkt als rotes Scheibchen
Der optimale Punkt liegt an der Schnittstelle der beiden Nebenbedingungen, was für lineare Optimierungsprobleme typisch ist (optimale Lösungen befinden sich immer an den “Ecken” des zulässigen Bereichs). Der Programmoutput vor dem Plot zeigt sowohl die Koordinaten des optimalen Punktes als auch den maximalen Wert der Zielfunktion.
Konvexe Optimierungsprobleme erweitern das Konzept der linearen Optimierung. Hier ist die Zielfunktion konvex (d.h. jede Verbindungslinie zwischen zwei Punkten auf dem Funktionsgraphen liegt zur Gänze oberhalb oder auf dem Graphen) und der zulässige Bereich bildet eine konvexe Menge (die gerade Verbindung zwischen zwei Punkten der Menge liegt zur Gänze innerhalb der Menge). Entscheidend ist, dass für konvexe Probleme jedes lokale Optimum auch global optimal ist, was die Suche erheblich vereinfacht.
Hier wieder gleich ein Beispiel. Zur Lösung solcher Probleme verwendet man Gradient Descent, den wir uns weiter unten genauer ansehen werden (hier wird eine Variante davon verwendet). Visuell sehen solche Probleme im Prinzip immer aus wie eine Art “Schüssel”.
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from scipy.optimize import minimize
# Definiere eine konvexe Funktion: f(x,y) = x^2 + 2y^2 + 2*x*y + 2x + 4y + 3
def konvexe_funktion(X):
x, y = X
return x**2 + 2*y**2 + 2*x*y + 2*x + 4*y + 3
# Gradient der Funktion (für effizientere Optimierung)
def gradient(X):
x, y = X
return np.array([2*x + 2*y + 2, 4*y + 2*x + 4])
# Erstelle Gitterpunkte für die Visualisierung
x = np.linspace(-5, 3, 100)
y = np.linspace(-4, 2, 100)
X, Y = np.meshgrid(x, y)
Z = np.zeros_like(X)
# Berechne die Funktionswerte für alle Gitterpunkte
for i in range(len(x)):
for j in range(len(y)):
Z[j, i] = konvexe_funktion([X[j, i], Y[j, i]])
# Führe die Optimierung durch - finde das Minimum der Funktion
startpunkt = np.array([0.0, 0.0]) # Startpunkt für die Optimierung
ergebnis = minimize(konvexe_funktion, startpunkt, method='BFGS', jac=gradient)
# Ausgabe der Optimierungsergebnisse
print("Optimierung erfolgreich:", ergebnis.success)
print("Anzahl der Funktionsaufrufe:", ergebnis.nfev)
print("Minimum gefunden bei x =", ergebnis.x[0], ", y =", ergebnis.x[1])
print("Funktionswert am Minimum:", ergebnis.fun)
# Zeige mathematisch, dass diese Funktion tatsächlich konvex ist
print("\nNachweis der Konvexität:")
print("Die Hesse-Matrix dieser Funktion ist:")
print("H = [[2, 2], [2, 4]]")
print("Eigenwerte der Hesse-Matrix:", np.linalg.eigvals(np.array([[2, 2], [2, 4]])))
print("Da alle Eigenwerte positiv sind, ist die Funktion streng konvex.")
# Zeichne die Funktion und das Minimum als 3D-Plot
fig = plt.figure(figsize=(12, 5))
# 3D-Oberflächenplot
ax1 = fig.add_subplot(1, 2, 1, projection='3d')
surf = ax1.plot_surface(X, Y, Z, cmap='viridis', alpha=0.8, linewidth=0, antialiased=True)
ax1.scatter(ergebnis.x[0], ergebnis.x[1], ergebnis.fun, color='red', s=50, label='Minimum')
ax1.set_xlabel('x')
ax1.set_ylabel('y')
ax1.set_zlabel('f(x,y)')
ax1.set_title('Konvexe Funktion: f(x,y) = x² + 2y² + 2xy + 2x + 4y + 3')
fig.colorbar(surf, ax=ax1, shrink=0.5, aspect=5)
# Konturplot mit Optimierungspfad
ax2 = fig.add_subplot(1, 2, 2)
contour = ax2.contour(X, Y, Z, 20, cmap='viridis')
ax2.scatter(ergebnis.x[0], ergebnis.x[1], color='red', s=50, label='Minimum')
# Zeige zwei Punkte und ihre Verbindungslinie, um die Konvexitätseigenschaft zu demonstrieren
punkt1 = np.array([-3, -2])
punkt2 = np.array([2, 1])
linie_x = np.linspace(punkt1[0], punkt2[0], 100)
linie_y = np.linspace(punkt1[1], punkt2[1], 100)
linie_z = np.zeros_like(linie_x)
# Berechne Funktionswerte entlang der Linie
for i in range(len(linie_x)):
linie_z[i] = konvexe_funktion([linie_x[i], linie_y[i]])
# Zeichne die Punkte und die Linie im 3D-Plot
ax1.scatter(punkt1[0], punkt1[1], konvexe_funktion(punkt1), color='blue', s=50)
ax1.scatter(punkt2[0], punkt2[1], konvexe_funktion(punkt2), color='blue', s=50)
ax1.plot(linie_x, linie_y, linie_z, 'b-', linewidth=2)
# Zeichne die Punkte auch im Konturplot
ax2.scatter(punkt1[0], punkt1[1], color='blue', s=50)
ax2.scatter(punkt2[0], punkt2[1], color='blue', s=50)
ax2.plot(linie_x, linie_y, 'b-', linewidth=2, label='Verbindungslinie')
ax2.set_xlabel('x')
ax2.set_ylabel('y')
ax2.set_title('Höhenlinien und Minimum')
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Demonstration der Konvexitätseigenschaft
# Berechne einige Punkte auf der Verbindungslinie und vergleiche mit der tatsächlichen Funktion
print("\nDemonstration der Konvexitätseigenschaft:")
print("Für zwei Punkte auf dem Funktionsgraphen:")
print(f"Punkt 1: ({punkt1[0]}, {punkt1[1]}) mit f(Punkt1) = {konvexe_funktion(punkt1):.2f}")
print(f"Punkt 2: ({punkt2[0]}, {punkt2[1]}) mit f(Punkt2) = {konvexe_funktion(punkt2):.2f}")
# Wähle einen Punkt auf der Verbindungslinie (z.B. bei t=0.4)
t = 0.4
punkt_mitte = t * punkt1 + (1-t) * punkt2
f_mitte = konvexe_funktion(punkt_mitte)
f_linie = t * konvexe_funktion(punkt1) + (1-t) * konvexe_funktion(punkt2)
print("\nFür t =", t, "gilt:")
print(f"Punkt auf der Verbindungsstrecke: ({punkt_mitte[0]:.2f}, {punkt_mitte[1]:.2f})")
print(f"Tatsächlicher Funktionswert: f(Punkt) = {f_mitte:.2f}")
print(f"Wert auf der Verbindungslinie: {f_linie:.2f}")
print(f"Konvexitätsbedingung: f(Punkt) ≤ Wert auf Verbindungslinie: {f_mitte:.2f} ≤ {f_linie:.2f}")
print(f"Die Konvexitätsbedingung ist {'erfüllt' if f_mitte <= f_linie else 'nicht erfüllt'}")
Optimierung erfolgreich: True Anzahl der Funktionsaufrufe: 7 Minimum gefunden bei x = 1.228951805089533e-07 , y = -1.0000000682610473 Funktionswert am Minimum: 1.0000000000000075 Nachweis der Konvexität: Die Hesse-Matrix dieser Funktion ist: H = [[2, 2], [2, 4]] Eigenwerte der Hesse-Matrix: [0.76393202 5.23606798] Da alle Eigenwerte positiv sind, ist die Funktion streng konvex.

Demonstration der Konvexitätseigenschaft: Für zwei Punkte auf dem Funktionsgraphen: Punkt 1: (-3, -2) mit f(Punkt1) = 18.00 Punkt 2: (2, 1) mit f(Punkt2) = 21.00 Für t = 0.4 gilt: Punkt auf der Verbindungsstrecke: (-0.00, -0.20) Tatsächlicher Funktionswert: f(Punkt) = 2.28 Wert auf der Verbindungslinie: 19.80 Konvexitätsbedingung: f(Punkt) ≤ Wert auf Verbindungslinie: 2.28 ≤ 19.80 Die Konvexitätsbedingung ist erfüllt
Hier sieht man gut den Punkt für die optimale Lösung am tiefsten Punkt der “Schüssel”. Aufgrund des Zusammenhangs von globalen und lokalen Optima bei konvexen Optimierungsproblemen muss man höchstens auf die Aufgabenstellung achten. Wäre im obigen Beispiel nämlich das Maximum und nicht das Minimum gefragt, und wäre der Parameterraum auf den in der Graphik sichtbaren Bereich eingeschränkt, so müsste man das Optimum am Rand des erlaubten Bereichs suchen!
Nicht-konvexe Probleme stellen die größte Herausforderung dar. Ihre Zielfunktionen haben multiple lokale Optima, die nicht notwendigerweise global sind (denn nur eines kann global sein). Deterministische Verfahren wie Gradient Descent bleiben hier gern in lokalen Optima hängen. Oft weiß man auch gar nicht, ob man überhaupt ein globales Optimum gefunden hat oder finden kann. Für diese Problemklasse werden oft stochastische Methoden wie Simulated Annealing oder genetische Algorithmen eingesetzt, die durch kontrollierte Zufallselemente lokale Optima überwinden können. Wir werden uns diese beiden Algorithmen daher weiter unten ebenfalls ansehen. Die meisten echten und bereits auch nur ein Bisschen komplexeren Probleme fallen in diese Kategorie.
7.1.3 Der Gradient und seine Bedeutung für die Optimierung¶
Der Gradient einer Funktion ist ein Vektor, der in Richtung des steilsten Anstiegs zeigt. Seine Bedeutung für die Optimierung ist fundamental: Er gibt uns für jeden Punkt im Suchraum die Richtung an, in der wir uns bewegen sollten, um die Zielfunktion am schnellsten zu minimieren (durch Bewegung entgegen dem Gradienten) oder zu maximieren (durch Bewegung in Richtung des Gradienten). In differenzierbaren Funktionen ist der Gradient am Optimum gleich Null – ein wichtiges Kriterium zur Identifikation potenzieller Optima.
Die Verbindung zwischen Gradient und Konvexität bestimmt maßgeblich die Wahl des Optimierungsalgorithmus. Bei konvexen Problemen reicht es, dem Gradienten zu folgen, um das globale Optimum zu finden. Bei nicht-konvexen Problemen hingegen kann der Gradient wie gesagt in die Irre führen, weil er ganz einfach zum nächsten (oder zum ersten, das im Pfad des Gradienten vorkommt) lokalen statt dem globalen Optimum navigiert. An dieser Problematik können auch zusätzliche Techniken wie Momentum, adaptive Lernraten oder sogar völlig andere Ansätze ohne Gradienteninformation benötigt.
7.2 Der Gradient-Descent-Algorithmus¶
Gradient Descent ist der Einsteiger-Algorithmus in der numerischen Optimierung von Problemen. Das Prinzip ist einfach, aber der Teufel steckt oft im Detail, wie wir zum Teil bereits besprochen haben. Hier kommen noch ein paar Details dazu.
7.2.1 Grundprinzip: Iterative Annäherung an das Minimum¶
Gradient Descent ist ein Optimierungsverfahren, das schrittweise dem negativen Gradienten der Zielfunktion folgt, um ein Minimum zu finden. Der Algorithmus startet an einem zufälligen Punkt und bewegt sich iterativ in Richtung des steilsten Abstiegs, welcher durch den negativen Gradienten angegeben wird. Die mathematische Update-Regel lautet: $\theta_{i+1} = \theta_{i} – \eta \nabla f(\theta_{i})$, wobei $\eta$ die Lernrate ist und $\nabla f$ den Gradienten der Zielfunktion $f$ darstellt.
Der Algorithmus funktioniert, weil der negative Gradient lokal die Richtung der schnellsten Abnahme der Zielfunktion angibt. Bei jedem Schritt bewegt sich der Algorithmus ein Stück in diese Richtung, berechnet an der neuen Position erneut den Gradienten und passt die Richtung entsprechend an. Dieser Prozess wird wiederholt, bis ein Abbruchkriterium erfüllt ist – typischerweise wenn der Gradient nahezu verschwindet (wir sind am Minimum) oder eine maximale Anzahl von Iterationen erreicht wurde.
Die Konvergenz von Gradient Descent hängt stark von der Landschaft der Zielfunktion ab. Bei konvexen Funktionen ist die Sache relativ geradlinig. Bei nicht-konvexen Funktionen kann der Algorithmus in lokalen Minima stecken bleiben. Die Visualisierung des Verfahrens als “Abstieg in einer Gebirgslandschaft” vermittelt ein intuitive Verständnis: Der Algorithmus folgt stets dem steilsten Pfad ins Tal, kann aber in einer lokalen Senke stecken bleiben, wenn er keinen Weg sieht, um über einen Hügel oder einen Pass zu einem tieferen Tal zu gelangen.
7.2.2 Wahl der Lernrate und ihr Einfluss auf die Konvergenz¶
Die Lernrate $\eta$ bestimmt die Schrittweite bei jedem Update-Schritt des Gradient-Descent-Verfahrens. Sie ist ein kritischer Hyperparameter mit enormem Einfluss auf die Konvergenzgeschwindigkeit und -garantie. Eine zu große Lernrate kann zu Oszillationen oder sogar Divergenz führen, da der Algorithmus über das Minimum hinausschießt. Eine zu kleine Lernrate hingegen führt zu unnötig langsamer Konvergenz und erhöht die Gefahr, in flachen Plateaus der Zielfunktion stecken zu bleiben.
Die optimale Lernrate hängt von den Eigenschaften der Zielfunktion ab, insbesondere von ihrer Krümmung. Bei stark unterschiedlichen Krümmungen in verschiedenen Richtungen entsteht folgendes Problem: Der Algorithmus oszilliert quer zu schmalen Tälern, während er sich nur langsam entlang des Tals bewegt. Adaptive Lernratenverfahren wie AdaGrad, RMSprop oder Adam wurden entwickelt, um diese Problematik zu adressieren, indem sie die Lernrate dynamisch für jede Dimension separat anpassen.
In der Praxis werden Lernraten oft durch empirische Suche optimiert, beginnend mit einer groben Schätzung (z.B. 0.1, 0.01, 0.001) und anschließender Verfeinerung. Alternativ können Lernraten-Schedules verwendet werden, bei denen die Lernrate im Laufe der Optimierung systematisch reduziert wird. Dies ermöglicht zu Beginn große Schritte für schnellen Fortschritt und später kleinere Schritte für eine präzise Annäherung an das Optimum.
Hier noch eine kurze Veranschaulichung dieser Problematik anhand der konvexen Funktion von oben. Hier finden Sie somit auch eine einfache explizite Implementierung von Gradient Descent. Wir demonstrieren den Effekt, indem wir drei Werte für die Lernrate einsetzen.
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from mpl_toolkits.mplot3d import Axes3D
# Definiere die konvexe Funktion: f(x,y) = x^2 + 2y^2 + 2*x*y + 2x + 4y + 3
def konvexe_funktion(x, y):
return x**2 + 2*y**2 + 2*x*y + 2*x + 4*y + 3
# Gradient der Funktion
def gradient(x, y):
df_dx = 2*x + 2*y + 2
df_dy = 4*y + 2*x + 4
return np.array([df_dx, df_dy])
# Implementierung des Gradient Descent Algorithmus
def gradient_descent(start_punkt, lernrate, max_iterationen, toleranz=1e-6):
# Startpunkt
punkt = np.array(start_punkt, dtype=float)
# Liste zur Speicherung der Punktfolge für die Visualisierung
punkte_historie = [punkt.copy()]
funktionswerte = [konvexe_funktion(punkt[0], punkt[1])]
# Iteratives Update
for i in range(max_iterationen):
# Berechne den Gradienten am aktuellen Punkt
grad = gradient(punkt[0], punkt[1])
# Berechne die Norm des Gradienten (für Abbruchkriterium)
grad_norm = np.linalg.norm(grad)
# Abbruchkriterium: Wenn der Gradient nahe Null ist, haben wir ein Minimum gefunden
if grad_norm < toleranz:
print(f"Konvergenz nach {i+1} Iterationen!")
break
# Update-Regel: Bewegung in Richtung des negativen Gradienten
punkt = punkt - lernrate * grad
# Speichere für die Visualisierung
punkte_historie.append(punkt.copy())
funktionswerte.append(konvexe_funktion(punkt[0], punkt[1]))
# Wenn die maximale Anzahl an Iterationen erreicht wurde
else:
print(f"Maximale Anzahl von {max_iterationen} Iterationen erreicht ohne Konvergenz.")
return np.array(punkte_historie), np.array(funktionswerte)
# Optimales analytisches Minimum berechnen
# Für f(x,y) = x^2 + 2y^2 + 2*x*y + 2x + 4y + 3
# Löse das lineare Gleichungssystem:
# df/dx = 2x + 2y + 2 = 0
# df/dy = 4y + 2x + 4 = 0
# Aus der ersten Gleichung: x = -y - 1
# In die zweite einsetzen: 4y + 2(-y - 1) + 4 = 0
# 4y - 2y - 2 + 4 = 0
# 2y + 2 = 0
# y = -1
# Dann x = -y - 1 = -(-1) - 1 = 0
optimum = np.array([0, -1])
optimum_wert = konvexe_funktion(optimum[0], optimum[1])
print(f"Analytisches Optimum bei x = {optimum[0]}, y = {optimum[1]}")
print(f"Optimaler Funktionswert: {optimum_wert}")
# Gitterpunkte für die Visualisierung erstellen
x = np.linspace(-5, 5, 100)
y = np.linspace(-5, 5, 100)
X, Y = np.meshgrid(x, y)
Z = konvexe_funktion(X, Y)
# Startpunkt für alle drei Versuche
startpunkt = np.array([4.0, 3.0])
# Drei verschiedene Lernraten ausprobieren
# 1. Zu groß (führt zu Divergenz oder Oszillation)
lernrate_zu_gross = 0.9
# 2. Zu klein (zu langsame Konvergenz)
lernrate_zu_klein = 0.01
# 3. Optimal (schnelle Konvergenz)
lernrate_optimal = 0.1
# Anzahl der maximalen Iterationen
max_iter = 50
# Durchführung der Optimierung mit drei verschiedenen Lernraten
punkte_zu_gross, werte_zu_gross = gradient_descent(startpunkt, lernrate_zu_gross, max_iter)
punkte_zu_klein, werte_zu_klein = gradient_descent(startpunkt, lernrate_zu_klein, max_iter)
punkte_optimal, werte_optimal = gradient_descent(startpunkt, lernrate_optimal, max_iter)
# Erstelle Plots für die Visualisierung
fig, axs = plt.subplots(2, 2, figsize=(16, 12))
# 3D-Plot der Funktion (oben links)
ax_3d = fig.add_subplot(2, 2, 1, projection='3d')
surf = ax_3d.plot_surface(X, Y, Z, cmap='viridis', alpha=0.7, linewidth=0)
ax_3d.set_xlabel('x')
ax_3d.set_ylabel('y')
ax_3d.set_zlabel('f(x,y)')
ax_3d.set_title('Konvexe Funktion und optimale Gradient-Descent-Trajektorie')
# Optimale Trajektorie im 3D-Plot
ax_3d.plot(punkte_optimal[:, 0], punkte_optimal[:, 1], werte_optimal, 'r.-', linewidth=2, markersize=8)
ax_3d.plot([optimum[0]], [optimum[1]], [optimum_wert], 'mo', markersize=10, label='Globales Minimum')
# Contour-Plot mit zu großer Lernrate (oben rechts)
axs[0, 1].contour(X, Y, Z, levels=50, cmap='viridis')
axs[0, 1].set_title(f'Lernrate zu groß (η = {lernrate_zu_gross})')
axs[0, 1].plot(punkte_zu_gross[:, 0], punkte_zu_gross[:, 1], 'r.-', linewidth=2, markersize=8)
axs[0, 1].plot(optimum[0], optimum[1], 'mo', markersize=10)
axs[0, 1].set_xlabel('x')
axs[0, 1].set_ylabel('y')
axs[0, 1].grid(True)
# Contour-Plot mit zu kleiner Lernrate (unten links)
axs[1, 0].contour(X, Y, Z, levels=50, cmap='viridis')
axs[1, 0].set_title(f'Lernrate zu klein (η = {lernrate_zu_klein})')
axs[1, 0].plot(punkte_zu_klein[:, 0], punkte_zu_klein[:, 1], 'r.-', linewidth=2, markersize=8)
axs[1, 0].plot(optimum[0], optimum[1], 'mo', markersize=10)
axs[1, 0].set_xlabel('x')
axs[1, 0].set_ylabel('y')
axs[1, 0].grid(True)
# Contour-Plot mit optimaler Lernrate (unten rechts)
axs[1, 1].contour(X, Y, Z, levels=50, cmap='viridis')
axs[1, 1].set_title(f'Lernrate optimal (η = {lernrate_optimal})')
axs[1, 1].plot(punkte_optimal[:, 0], punkte_optimal[:, 1], 'r.-', linewidth=2, markersize=8)
axs[1, 1].plot(optimum[0], optimum[1], 'mo', markersize=10)
axs[1, 1].set_xlabel('x')
axs[1, 1].set_ylabel('y')
axs[1, 1].grid(True)
# Legende für das gesamte Figure
fig.legend(['Gradient Descent Pfad', 'Globales Minimum'], loc='upper center', bbox_to_anchor=(0.5, 0.05), ncol=2)
plt.tight_layout()
# Plot der Konvergenz (Funktionswert über Iterationen)
plt.figure(figsize=(10, 6))
plt.plot(werte_zu_gross, 'r-', linewidth=2, label=f'Zu große Lernrate (η = {lernrate_zu_gross})')
plt.plot(werte_zu_klein, 'g-', linewidth=2, label=f'Zu kleine Lernrate (η = {lernrate_zu_klein})')
plt.plot(werte_optimal, 'b-', linewidth=2, label=f'Optimale Lernrate (η = {lernrate_optimal})')
plt.axhline(y=optimum_wert, color='m', linestyle='--', label='Optimaler Wert')
plt.xlabel('Iteration')
plt.ylabel('Funktionswert')
plt.title('Konvergenzverhalten bei verschiedenen Lernraten')
plt.grid(True)
plt.legend()
plt.yscale('log') # Logarithmische Skala für bessere Sichtbarkeit
plt.tight_layout()
plt.show()
Analytisches Optimum bei x = 0, y = -1 Optimaler Funktionswert: 1 Maximale Anzahl von 50 Iterationen erreicht ohne Konvergenz. Maximale Anzahl von 50 Iterationen erreicht ohne Konvergenz. Maximale Anzahl von 50 Iterationen erreicht ohne Konvergenz.


In den oberen Subplots sieht man hier das Konvergenzverhalten für zu große, zu kleine und gut geeignete Lernrate. Danach sehen Sie im Plot, wie sich die Werte der Zielfunktion über die Interationsschritte verändern.
7.2.3 Anwendungsbereiche von Gradient Descent, Vorteile und Nachteile¶
Gradient Descent findet breite Anwendung in Bereichen, wo differenzierbare Zielfunktionen optimiert werden müssen. Besonders prominent ist der Einsatz beim Training von Machine-Learning-Modellen, von der linearen und logistischen Regression bis hin zu komplexen neuronalen Netzen. Auch in der Signalverarbeitung, Steuerungstechnik und vielen Simulationsmodellen ist Gradient Descent ein Standardwerkzeug.
Die Vorteile von Gradient Descent liegen in seiner konzeptionellen Einfachheit, mathematischen Fundiertheit und effizienten Implementierbarkeit. Für konvexe Probleme bietet es garantierte Konvergenz zum globalen Optimum. Moderne Varianten wie stochastischer Gradient Descent (SGD) ermöglichen die Skalierung auf sehr große Datensätze, indem sie Gradienten auf Teilmengen (Mini-Batches) der Daten berechnen. Erweiterungen wie Momentum, Nesterov-Beschleunigung und andere adaptive Lernratenverfahren verbessern die Konvergenzgeschwindigkeit und Robustheit erheblich.
Typische Nachteile sind die bereits erwähnte und offensichtliche Anfälligkeit für lokale Minima in nicht-konvexen Landschaften, die Notwendigkeit differenzierbarer Zielfunktionen (bzw. numerischer Approximationen für den Gradienten bei Daten auf einem Grid) und die Schwierigkeit der Hyperparameter-Wahl. Die Leistung hängt stark von der Initialisierung ab, und schlecht konditionierte Probleme können zu sehr langsamer Konvergenz führen. Zudem ist Gradient Descent nicht direkt auf diskrete oder kombinatorische Probleme anwendbar. Für diese Fälle sind alternative Verfahren wie stochastische Suche, Simulated Annealing oder genetische Algorithmen oft besser geeignet.
7.3 Stochastische Optimierung¶
Stochastische Optimierung ist eine Alternative zu deterministischen Methoden wie Gradient Descent. Sie überwindet jene Probleme, die wir bereits kennen gelernt haben, auch wenn dabei etwas Unsicherheit ins Spiel kommt. Trotzdem hat man ausreichend Kontrollmöglichkeiten und kann mit stochastischen Methoden teilweise (zumindest näherungsweise) Probleme lösen, die auf direktem oder traditionellem Weg de facto unlösbar wären.
7.3.1 (Pseudo-)Zufallszahlen¶
Alle stochastischen Methoden arbeiten auf der Grundlage von Zufallszahlen. Da es (vielleicht etwas überraschenderweise) schwierig ist, echte Zufallszahlen zu erzeugen, begnügt man sich mit sogenannten Pseudozufallszahlen. Pseudozufallszahlen sind deterministisch generierte Zahlenwerte, die statistische Tests auf Zufälligkeit bestehen, obwohl sie vollständig vorhersagbar sind, wenn man den Generierungsalgorithmus und dessen Anfangszustand (Seed) kennt.
In der wissenschaftlichen Programmierung ist die Kontrolle des Zufallsgenerator-Seeds entscheidend für die Reproduzierbarkeit von Ergebnissen. In Python wird dies typischerweise mit np.random.seed()
umgesetzt. Die Qualität von Pseudozufallszahlengeneratoren wird anhand verschiedener statistischer Eigenschaften beurteilt, darunter Periodenlänge, korrekte Verteilung (z.B. Gleichverteilung) und Unabhängigkeit aufeinanderfolgender Werte.
Für die stochastische Optimierung werden im Allgemeinen verschiedene Verteilungen benötigt, z.B. Gleichverteilungen oder Normalverteilungen. Sehen wir uns das hier gleich einmal konkret an:
import numpy as np
import matplotlib.pyplot as plt
# Für Reproduzierbarkeit einen Seed setzen
np.random.seed(42)
# Anzahl der zu generierenden Punkte
n_samples = 2000
# 1. Generiere gleichverteilte Zufallszahlen im Intervall [0,1] x [0,1]
uniform_samples = np.random.uniform(0, 1, size=(n_samples, 2))
# 2. Generiere normalverteilte Zufallszahlen mit Mittelwert 0 und Standardabweichung 1
gaussian_samples = np.random.normal(0, 1, size=(n_samples, 2))
# Erstelle eine Figur mit zwei Subplots
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# --- PLOT 1: Gleichverteilung ---
axes[0].scatter(
uniform_samples[:, 0],
uniform_samples[:, 1],
s=10, # Punktgröße
alpha=0.6, # Transparenz
c='teal' # Farbe
)
# Beschriftung des ersten Plots
axes[0].set_title('Gleichverteilung im Einheitsquadrat [0,1] × [0,1]')
axes[0].set_xlabel('X ~ U(0,1)')
axes[0].set_ylabel('Y ~ U(0,1)')
axes[0].grid(True, alpha=0.3)
axes[0].set_xlim(-0.05, 1.05)
axes[0].set_ylim(-0.05, 1.05)
# --- PLOT 2: Normalverteilung ---
axes[1].scatter(
gaussian_samples[:, 0],
gaussian_samples[:, 1],
s=10,
alpha=0.6,
c='navy'
)
# Beschriftung des zweiten Plots
axes[1].set_title('Normalverteilung mit Mittelwert 0 und Standardabweichung 1')
axes[1].set_xlabel('X ~ N(0,1)')
axes[1].set_ylabel('Y ~ N(0,1)')
axes[1].grid(True, alpha=0.3)
axes[1].set_xlim(-4, 4)
axes[1].set_ylim(-4, 4)
# Konzentrische Kreise für die Wahrscheinlichkeitsmasse (1, 2 und 3 Standardabweichungen)
circles = [1, 2, 3]
for r in circles:
circle = plt.Circle((0, 0), r, fill=False, color='red', linestyle='--', alpha=0.7)
axes[1].add_patch(circle)
plt.tight_layout()
plt.show()

7.3.2 Zufallsgesteuerte Suche im Lösungsraum¶
Die zufallsgesteuerte Suche bildet das Grundprinzip stochastischer Optimierungsverfahren. Im einfachsten Fall, der reinen Zufallssuche (Pure Random Search), werden Lösungskandidaten völlig zufällig im Suchraum generiert und evaluiert. Diese naive Strategie ist ineffizient, aber robust und kann als Baseline dienen. Fortgeschrittenere Methoden wie die iterative Zufallssuche beschränken die Exploration zunehmend auf vielversprechende Regionen, indem sie Informationen aus vorherigen Evaluationen nutzen.
Die Balance zwischen Exploration (Erkundung neuer Bereiche des Suchraums) und Exploitation (Ausnutzung bereits gefundener vielversprechender Regionen) ist ein zentrales Konzept in der stochastischen Optimierung. Zu starke Exploration verschwendet Rechenressourcen, während zu starke Exploitation zur Konvergenz in suboptimalen lokalen Optima führen kann (vergleichbar mit dem bekannten Problem beim Gradient Descent). Hier kann man auch stochastische mit konventionellen Suchalgorithmen kombinieren:
Multistart-Methoden kombinieren deterministische lokale Suche mit stochastischer globaler Exploration. Sie initialisieren mehrere lokale Suchalgorithmen (z.B. Gradient Descent) von verschiedenen zufälligen Startpunkten aus und wählen die beste gefundene Lösung. Diese Hybridmethode nutzt die Effizienz deterministischer Verfahren zur lokalen Optimierung, während sie durch die zufällige Komponente eine breitere Abdeckung des Suchraums erreicht. Im Kontext neuronaler Netze entspricht dies dem Training mit verschiedenen zufälligen Initialisierungen.
7.3.3 Akzeptanzkriterien für neue Lösungen, Konvergenzverhalten und theoretische Garantien¶
Akzeptanzkriterien bestimmen, wann eine neu generierte Lösung die aktuelle Lösung ersetzt. Das einfachste Kriterium akzeptiert nur Verbesserungen (Greedy-Strategie), was jedoch über kurz oder lang in erster Linie zu lokalen Optima führt. Fortgeschrittene stochastische Methoden verwenden probabilistische Akzeptanzkriterien, die auch Verschlechterungen mit bestimmten Wahrscheinlichkeiten zulassen. Diese Wahrscheinlichkeit kann von der Größe der Verschlechterung, der verstrichenen Zeit oder anderen Faktoren abhängen. Simulated Annealing beispielsweise (siehe unten) nutzt eine temperaturabhängige Boltzmann-Verteilung für die Akzeptanzwahrscheinlichkeit.
Das Konvergenzverhalten stochastischer Algorithmen unterscheidet sich fundamental von deterministischen Verfahren. Während deterministische Algorithmen oft monoton konvergieren, zeigen stochastische Verfahren typischerweise nicht-monotones Verhalten mit gelegentlichen Verschlechterungen. Die Konvergenzanalyse erfolgt daher meist probabilistisch: Man betrachtet die Wahrscheinlichkeit, mit der der Algorithmus eine Lösung innerhalb eines bestimmten Abstands vom Optimum findet, als Funktion der Iterationsanzahl oder Rechenzeit.
Hier ein Beispiel für die Funktion, die wir bereits von weiter oben kennen:
import numpy as np
import matplotlib.pyplot as plt
# Wir verwenden die gleiche konvexe Funktion wie zuvor:
# f(x,y) = x^2 + 2y^2 + 2*x*y + 2x + 4y + 3
def konvexe_funktion(x, y):
return x**2 + 2*y**2 + 2*x*y + 2*x + 4*y + 3
# Einfache stochastische Optimierung: Random Search
def random_search(max_iterationen, suchbereich):
# Analytisches Optimum (zum Vergleich)
optimum = np.array([0.0, -1.0])
optimum_wert = konvexe_funktion(optimum[0], optimum[1])
# Startpunkt zufällig wählen
beste_lösung = np.random.uniform(suchbereich[0], suchbereich[1], size=2)
bester_wert = konvexe_funktion(beste_lösung[0], beste_lösung[1])
# Historie für die Visualisierung
historie_lösungen = [beste_lösung.copy()]
historie_werte = [bester_wert]
for i in range(max_iterationen):
# Erzeuge einen zufälligen Punkt im Suchbereich
kandidat = np.random.uniform(suchbereich[0], suchbereich[1], size=2)
kandidat_wert = konvexe_funktion(kandidat[0], kandidat[1])
# Speichere den Kandidaten für die Visualisierung
historie_lösungen.append(kandidat.copy())
historie_werte.append(kandidat_wert)
# Wenn der Kandidat besser ist, aktualisiere die beste Lösung
if kandidat_wert < bester_wert:
beste_lösung = kandidat.copy()
bester_wert = kandidat_wert
return beste_lösung, bester_wert, np.array(historie_lösungen), np.array(historie_werte), optimum, optimum_wert
# Parameter
max_iter = 1000
suchbereich = [-5, 5] # Suchbereich für x und y: [-5, 5] x [-5, 5]
# Führe die stochastische Optimierung durch
np.random.seed(42) # Für Reproduzierbarkeit
beste_lösung, bester_wert, historie_lösungen, historie_werte, optimum, optimum_wert = random_search(max_iter, suchbereich)
# Ergebnisse ausgeben
print(f"Beste gefundene Lösung: x = {beste_lösung[0]:.4f}, y = {beste_lösung[1]:.4f}")
print(f"Bester gefundener Wert: {bester_wert:.4f}")
print(f"Analytisches Optimum: x = {optimum[0]:.4f}, y = {optimum[1]:.4f}")
print(f"Optimaler Wert: {optimum_wert:.4f}")
print(f"Abweichung vom Optimum: {abs(bester_wert - optimum_wert):.4f}")
# Visualisierung der Konvergenz
plt.figure(figsize=(15, 6))
# Plot 1: Alle getesteten Punkte im Suchraum
plt.subplot(1, 2, 1)
# Gitterpunkte für den Hintergrund (Konturen der Funktion)
x = np.linspace(suchbereich[0], suchbereich[1], 100)
y = np.linspace(suchbereich[0], suchbereich[1], 100)
X, Y = np.meshgrid(x, y)
Z = np.zeros_like(X)
for i in range(len(x)):
for j in range(len(y)):
Z[j, i] = konvexe_funktion(X[j, i], Y[j, i])
# Zeichne Konturen
plt.contour(X, Y, Z, 20, cmap='viridis', alpha=0.7)
# Zeige alle getesteten Punkte
plt.scatter(historie_lösungen[:, 0], historie_lösungen[:, 1],
c=np.arange(len(historie_lösungen)), cmap='autumn',
alpha=0.6, s=10, label='Getestete Punkte')
# Markiere das gefundene und das analytische Optimum
plt.scatter(beste_lösung[0], beste_lösung[1],
color='red', s=100, marker='*', label='Gefundenes Optimum')
plt.scatter(optimum[0], optimum[1],
color='blue', s=100, marker='o', label='Analytisches Optimum')
plt.xlim(suchbereich)
plt.ylim(suchbereich)
plt.title('Stochastische Optimierung: Random Search')
plt.xlabel('x')
plt.ylabel('y')
plt.grid(True, alpha=0.3)
plt.legend()
# Plot 2: Konvergenzverlauf des besten Funktionswerts
plt.subplot(1, 2, 2)
# Berechne den besten Wert bis zur i-ten Iteration
beste_werte_bis_i = np.minimum.accumulate(historie_werte)
# Zeichne den Konvergenzverlauf
plt.plot(beste_werte_bis_i, 'r-', linewidth=2, label='Bester Wert')
plt.axhline(y=optimum_wert, color='blue', linestyle='--', label='Optimaler Wert')
plt.title('Konvergenzverlauf')
plt.xlabel('Iteration')
plt.ylabel('Funktionswert')
plt.grid(True, alpha=0.3)
plt.legend()
plt.yscale('log') # Logarithmische Skala für bessere Sichtbarkeit
plt.tight_layout()
plt.show()
Beste gefundene Lösung: x = -0.2842, y = -0.8816 Bester gefundener Wert: 1.0415 Analytisches Optimum: x = 0.0000, y = -1.0000 Optimaler Wert: 1.0000 Abweichung vom Optimum: 0.0415

Hier sieht man rechts die Konvergenz des niedrigsten gefundenen Werts der Zielfunktion mit der Anzahl der überprüften Punkte. Links sieht man die überprüften Punkte (bunt) und den stochastisch gefunenen Optimalen Wert (Stern) im Vergleich zum bekannten exakten Optimum (blaues Scheibchen).
Theoretische Garantien für stochastische Optimierungsverfahren sind oft schwächer als für deterministische Methoden, aber dennoch wertvoll. Für Simulated Annealing oder auch genetische Algorithmen existieren gewisse probabilistische Garantien, auf die wir hier nicht weiter eingehen. In der Praxis sind diese theoretischen Garantien nämlich oft weniger entscheidend als die empirische Leistung auf konkreten Probleminstanzen.
7.3.4 Anwendungsbereiche für stochastische Optimierung, Vorteile und Nachteile¶
Stochastische Optimierungsverfahren eignen sich besonders für nicht-konvexe, multimodale Probleme mit vielen lokalen Optima, wo deterministische Verfahren scheitern. Sie kommen z.B. bei der Hyperparameter-Optimierung in Machine Learning, kombinatorischen Optimierungsproblemen wie dem Traveling Salesman Problem, im Design und der Strukturoptimierung (z.B. Antennenentwurf), in der Finanzmodellierung (Portfoliooptimierung) und bei Scheduling-Problemen zum Einsatz.
Die Vorteile stochastischer Verfahren liegen in ihrer Robustheit gegenüber lokalen Optima, der Anwendbarkeit auf nicht-differenzierbare oder diskrete Funktionen, und der häufig einfachen Implementierung. Sie benötigen typischerweise weniger Problemwissen und können direkt auf Black-Box-Funktionen angewendet werden. Zudem lassen sie sich leicht parallelisieren, was auf modernen Multi-Core-Systemen oder GPU-Clustern erhebliche Beschleunigungen des Optimierungsprozesses ermöglicht.
Nachteile sind die oft recht langsame Konvergenz, der möglicherweise hohe Rechenaufwand durch zahlreiche Funktionsauswertungen, und die schwierige theoretische Analyse. Die Ergebnisse sind nicht deterministisch, was unter Umständen die Reproduzierbarkeit erschwert. Zudem benötigen viele stochastische Verfahren problemspezifische Anpassungen und Tuning ihrer Hyperparameter (wie Temperaturpläne oder Mutationsraten), was spezifisches Expertenwissen erfordern kann. Im Vergleich zu Gradient-basierten Verfahren nutzen sie weniger Strukturinformationen des Problems, was bei gut strukturierten Problemen ineffizient sein kann.
7.4 Simulated Annealing¶
Nach diesen eher generellen Anmerkungen zur stochastischen Optimierung wenden wir uns jetzt konkreten leistungsfähigen Beispiel-Algorithmen zu.
7.4.1 Wie funktioniert Simulated Annealing?¶
Simulated Annealing (SA) ist ein naturinspiriertes Optimierungsverfahren, das den physikalischen Prozess des kontrollierten Abkühlens von Metallen nachahmt. Beim Tempern (Annealing) wird ein Metall erhitzt und langsam abgekühlt, wodurch die Atome zunächst energiereiche Zustände einnehmen können, um schließlich in einer energiearmen, stabilen Kristallstruktur (das enspricht einem optimierten Zustand des Materials) zu erstarren. Übertragen auf die Optimierung bedeutet dies: Bei hoher “Temperatur” akzeptiert der Algorithmus auch Verschlechterungen mit hoher Wahrscheinlichkeit (Exploration), während bei niedriger Temperatur hauptsächlich Verbesserungen akzeptiert werden (Exploitation). Um das besser zu verstehen, sehen wir uns die Sache im Detail an.
Der Algorithmus beginnt mit einer zufälligen Lösung und einer hohen Anfangstemperatur T. In jeder Iteration wird eine zufällige Nachbarlösung generiert und ihre Qualität (Energieniveau) mit der aktuellen Lösung verglichen. Verbesserungen werden immer akzeptiert. Verschlechterungen werden mit einer Wahrscheinlichkeit nach der folgenden Regel akzeptiert: $P(accept) = \exp(-\Delta E/T)$, wobei $\Delta E$ die Verschlechterung der Zielfunktion ist. Nach einer festgelegten Anzahl von Iterationen wird die Temperatur gemäß einem Abkühlungsplan reduziert, wodurch die Wahrscheinlichkeit für die Akzeptanz von Verschlechterungen sinkt.
Die Implementierung erfordert also vier Hauptkomponenten:
- eine Methode zur Generierung von Nachbarlösungen
- eine Zielfunktion zur Bewertung der Lösungen
- einen Abkühlungsplan für die Temperatur und
- ein Abbruchkriterium.
Die Definition der Nachbarschaft ist dabei problemspezifisch und teilweise recht knifflig. Für kontinuierliche Probleme nimmt man oft einfach eine kleine zufällige Änderung aller Variablen, für kombinatorische Probleme wie TSP typischerweise einfache Operationen wie das Vertauschen zweier Städte in der Route (siehe unten).
7.4.2 Temperaturplanung und ihre Auswirkungen auf die Konvergenz¶
Der Temperaturplan (cooling schedule) ist entscheidend für die Leistung von Simulated Annealing. Er bestimmt, wie schnell der Algorithmus von der explorativen Phase (hohe Temperatur) zur exploitativen Phase (niedrige Temperatur) übergeht. Die theoretische Analyse zeigt, dass eine logarithmische Abkühlung $(T(k) \sim 1/\log(k)$, wobei $k$ der Index für die Iteration ist) die Konvergenz zum globalen Optimum garantiert – allerdings mit unpraktisch langsamer Geschwindigkeit.
In der Praxis werden oft schnellere Abkühlungspläne verwendet, wie die geometrische Abkühlung $(T_{i+1} = \alpha T_i$, mit typischerweise $0.9 \le \alpha \lt 1)$, die lineare Abkühlung $(T_{i+1} = T_i – \beta)$, oder adaptivere Schemata, die die Temperatur basierend auf beobachteten Akzeptanzraten oder anderen Fortschrittsmetriken anpassen. Ein zu schneller Temperaturabfall kann zum Einfrieren in lokalen Optima führen, während ein zu langsamer Abfall unnötig Rechenzeit verschwendet.
Neben dem grundlegenden Abkühlungsplan spielen weitere Parameter eine wichtige Rolle: Die Anfangstemperatur sollte hoch genug sein, um anfänglich fast alle Verschlechterungen zu akzeptieren (typischerweise kalibriert, um eine initiale Akzeptanzrate von rund 80% zu erreichen). Die Anzahl der Iterationen pro Temperaturstufe sollte ausreichend sein, um einen quasistationären Zustand bei jeder Temperatur zu erreichen. Einige Implementierungen beinhalten auch Reheating-Strategien, die die Temperatur zeitweise wieder erhöhen, wenn der Algorithmus längere Zeit keine Verbesserungen findet – ähnliche Restart-Strategien findet man in anderen Optimierungsverfahren.
Sehen wir uns hier einmal ein einfaches Beispiel an (anhand der sogenannten Rastrigin-Funktion):
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
# Rastrigin-Funktion: eine bekannte Testfunktion mit vielen lokalen Minima
# f(x,y) = 20 + x^2 + y^2 - 10*cos(2π*x) - 10*cos(2π*y)
# Das globale Minimum ist bei (0,0) mit f(0,0) = 0
def rastrigin(x, y):
return 20 + x**2 + y**2 - 10*np.cos(2*np.pi*x) - 10*np.cos(2*np.pi*y)
# Implementierung von Simulated Annealing mit verschiedenen Abkühlungsstrategien
def simulated_annealing(cooling_schedule, max_iter, suchbereich, init_temp):
# Analytisches Optimum (zum Vergleich)
optimum = np.array([0.0, 0.0])
optimum_wert = rastrigin(optimum[0], optimum[1])
# Startpunkt zufällig wählen
aktuelle_lösung = np.random.uniform(suchbereich[0], suchbereich[1], size=2)
aktueller_wert = rastrigin(aktuelle_lösung[0], aktuelle_lösung[1])
# Beste gefundene Lösung speichern
beste_lösung = aktuelle_lösung.copy()
bester_wert = aktueller_wert
# Historie für die Visualisierung
historie_lösungen = [aktuelle_lösung.copy()]
historie_werte = [aktueller_wert]
historie_beste_werte = [bester_wert]
historie_temperatur = [init_temp]
# Initiale Temperatur
temperatur = init_temp
# Schleife über alle Iterationen
for i in range(max_iter):
# Aktualisiere die Temperatur je nach Abkühlungsstrategie
if cooling_schedule == "logarithmisch":
# Logarithmische Abkühlung: T(i) = T_0 / ln(i+2)
temperatur = init_temp / np.log(i + 2)
elif cooling_schedule == "geometrisch":
# Geometrische Abkühlung: T(i) = T_0 * alpha^i
alpha = 0.99 # Abkühlungsrate
temperatur = init_temp * (alpha ** i)
elif cooling_schedule == "linear":
# Lineare Abkühlung: T(i) = T_0 * (1 - i/max_iter)
temperatur = init_temp * (1 - i/max_iter)
# Speichere die aktuelle Temperatur
historie_temperatur.append(temperatur)
# Erzeuge eine neue Nachbarlösung durch Gaußsche Mutation
# Die Standardabweichung der Mutation hängt von der Temperatur ab
sigma = 0.1 + temperatur * 0.5 # Größere Schritte bei höherer Temperatur
nachbar = aktuelle_lösung + np.random.normal(0, sigma, size=2)
# Beschränke die Nachbarlösung auf den Suchbereich
nachbar = np.clip(nachbar, suchbereich[0], suchbereich[1])
nachbar_wert = rastrigin(nachbar[0], nachbar[1])
# Speichere den Nachbarn für die Visualisierung
historie_lösungen.append(nachbar.copy())
historie_werte.append(nachbar_wert)
# Entscheide, ob der Nachbar akzeptiert wird
if nachbar_wert < aktueller_wert:
# Wenn der Nachbar besser ist, akzeptiere ihn immer
aktuelle_lösung = nachbar.copy()
aktueller_wert = nachbar_wert
# Aktualisiere ggf. die beste gefundene Lösung
if aktueller_wert < bester_wert:
beste_lösung = aktuelle_lösung.copy()
bester_wert = aktueller_wert
else:
# Wenn der Nachbar schlechter ist, akzeptiere ihn mit einer gewissen Wahrscheinlichkeit
# Die Wahrscheinlichkeit hängt von der Verschlechterung und der Temperatur ab
delta = nachbar_wert - aktueller_wert
p = np.exp(-delta / temperatur)
if np.random.random() < p:
aktuelle_lösung = nachbar.copy()
aktueller_wert = nachbar_wert
# Speichere den besten Wert bis zur aktuellen Iteration
historie_beste_werte.append(bester_wert)
return beste_lösung, bester_wert, np.array(historie_lösungen), np.array(historie_werte), np.array(historie_beste_werte), np.array(historie_temperatur), optimum, optimum_wert
# Parameter
max_iter = 10000
suchbereich = [-5.12, 5.12] # Typischer Suchbereich für die Rastrigin-Funktion
init_temp = 10.0 # Initiale Temperatur
# Liste der zu testenden Abkühlungsstrategien
cooling_schedules = ["logarithmisch", "geometrisch", "linear"]
farben = ["blue", "green", "red"]
# Visualisierung der Rastrigin-Funktion
x = np.linspace(suchbereich[0], suchbereich[1], 100)
y = np.linspace(suchbereich[0], suchbereich[1], 100)
X, Y = np.meshgrid(x, y)
Z = rastrigin(X, Y)
fig = plt.figure(figsize=(12, 9))
ax1 = fig.add_subplot(2, 2, 1, projection='3d')
surf = ax1.plot_surface(X, Y, Z, cmap='viridis', alpha=0.8, linewidth=0, antialiased=True)
ax1.set_xlabel('x')
ax1.set_ylabel('y')
ax1.set_zlabel('f(x,y)')
ax1.set_title('Rastrigin-Funktion')
# Konturplot
ax2 = fig.add_subplot(2, 2, 2)
contour = ax2.contour(X, Y, Z, 20, cmap='viridis')
ax2.set_xlabel('x')
ax2.set_ylabel('y')
ax2.set_title('Höhenlinien der Rastrigin-Funktion')
fig.colorbar(contour, ax=ax2)
# Konvergenzplot für verschiedene Abkühlungsstrategien
ax3 = fig.add_subplot(2, 2, 3)
# Führe Simulated Annealing mit jeder Abkühlungsstrategie durch
for i, schedule in enumerate(cooling_schedules):
# Setze zufälligen Seed für Reproduzierbarkeit
np.random.seed(42 + i)
# Führe Simulated Annealing durch
beste_lösung, bester_wert, historie_lösungen, historie_werte, historie_beste_werte, historie_temperatur, optimum, optimum_wert = simulated_annealing(schedule, max_iter, suchbereich, init_temp)
# Ergebnisse ausgeben
print(f"\nAbkühlungsstrategie: {schedule}")
print(f"Beste gefundene Lösung: x = {beste_lösung[0]:.4f}, y = {beste_lösung[1]:.4f}")
print(f"Bester gefundener Wert: {bester_wert:.4f}")
print(f"Analytisches Optimum: x = {optimum[0]:.4f}, y = {optimum[1]:.4f}")
print(f"Optimaler Wert: {optimum_wert:.4f}")
print(f"Abweichung vom Optimum: {abs(bester_wert - optimum_wert):.4f}")
# Zeichne Konvergenzverlauf
ax3.plot(historie_beste_werte, color=farben[i], linewidth=2, label=f'{schedule}')
# Zeichne Endpunkt in den Konturplot
ax2.scatter(beste_lösung[0], beste_lösung[1], color=farben[i], s=100, marker='*', label=f'{schedule}')
# Füge Referenzlinie für das Optimum hinzu
ax3.axhline(y=optimum_wert, color='black', linestyle='--', label='Globales Optimum')
ax3.set_xlabel('Iteration')
ax3.set_ylabel('Bester Funktionswert')
ax3.set_ylim(.02,100)
ax3.set_title('Konvergenzverhalten')
ax3.set_xscale('log') # Logarithmische Skala für bessere Sichtbarkeit
ax3.set_yscale('log') # Logarithmische Skala für bessere Sichtbarkeit
ax3.grid(True, alpha=0.3)
ax3.legend()
# Temperaturverlauf für verschiedene Abkühlungsstrategien
ax4 = fig.add_subplot(2, 2, 4)
# Setze Zufallsseed für Reproduzierbarkeit zurück
np.random.seed(42)
# Berechne Temperaturverläufe ohne tatsächliche Optimierung durchzuführen
temperatur = init_temp
temp_log = [temperatur]
temp_geo = [temperatur]
temp_lin = [temperatur]
for i in range(1, max_iter + 1):
# Logarithmische Abkühlung
temp_log.append(init_temp / np.log(i + 2))
# Geometrische Abkühlung
alpha = 0.99
temp_geo.append(init_temp * (alpha ** i))
# Lineare Abkühlung
temp_lin.append(init_temp * (1 - i/max_iter))
# Zeichne Temperaturverläufe
ax4.plot(temp_log, color='blue', linewidth=2, label='Logarithmisch')
ax4.plot(temp_geo, color='green', linewidth=2, label='Geometrisch')
ax4.plot(temp_lin, color='red', linewidth=2, label='Linear')
ax4.set_xlabel('Iteration')
ax4.set_ylabel('Temperatur')
ax4.set_title('Temperaturverlauf')
ax4.set_xscale('log') # Logarithmische Skala für bessere Sichtbarkeit
ax4.grid(True, alpha=0.3)
ax4.legend()
plt.tight_layout()
plt.show()
Abkühlungsstrategie: logarithmisch Beste gefundene Lösung: x = 0.0175, y = -0.0043 Bester gefundener Wert: 0.0646 Analytisches Optimum: x = 0.0000, y = 0.0000 Optimaler Wert: 0.0000 Abweichung vom Optimum: 0.0646 Abkühlungsstrategie: geometrisch Beste gefundene Lösung: x = -0.0009, y = -0.9948 Bester gefundener Wert: 0.9951 Analytisches Optimum: x = 0.0000, y = 0.0000 Optimaler Wert: 0.0000 Abweichung vom Optimum: 0.9951 Abkühlungsstrategie: linear Beste gefundene Lösung: x = 0.0199, y = -0.0155 Bester gefundener Wert: 0.1260 Analytisches Optimum: x = 0.0000, y = 0.0000 Optimaler Wert: 0.0000 Abweichung vom Optimum: 0.1260

Hier sieht man deutlich die Unterschiede im Temperaturverlauf zwischen den verschiedenen Strategien und auch, wie sich das auf die Lösung des Problems auswirkt. Tatsächlich schneidet hier auch die logarithmische Strategie am besten ab, zumindest bei bis zu 10k Iterationen.
7.4.3 Anwendungsbereiche für Simulated Annealing, Vorteile und Nachteile¶
Simulated Annealing eignet sich besonders für kombinatorische Optimierungsprobleme wie das Traveling Salesman Problem, Facility Location, Job-Shop-Scheduling oder VLSI-Design (Chip-Layout). Es findet auch Anwendung in der Bildverarbeitung (z.B. Bildsegmentierung), der statistischen Physik (Simulation von Spingläsern) und beim Materialdesign. In den Biowissenschaften wird es für Proteinstrukturvorhersage und andere molekulare Optimierungsprobleme eingesetzt.
Zu den Vorteilen zählt die Fähigkeit, lokale Optima dank der probabilistischen Akzeptanzregel zu überwinden. SA ist relativ einfach zu implementieren und benötigt nur irgendeine Methode zur Generierung von Nachbarlösungen ohne dass man dazu z.B. den Gradienten der Zielfunktion kennen muss. Es ist daher vielseitig auf verschiedene Problemtypen anwendbar, einschließlich nicht-differenzierbarer und diskreter Probleme.
Typische Nachteile sind möglicherweise langsame Konvergenz, insbesondere bei komplexen Problemen mit vielen Variablen. Die Leistung hängt stark von der richtigen Wahl der Hyperparameter ab, insbesondere des Temperaturplans. Für hochdimensionale Probleme kann die effiziente Exploration des Suchraums schwierig werden. Dazu kommt noch der stochastische Character, der grundsätzlich die Reproduzierbarkeit erschwert.
7.5 Genetische Algorithmen¶
Als nächste stochastische Algorithmen wenden wir uns jetzt den sogenannten Genetischen Algorithmen zu.
7.5.1 Begriffe und Grundprinzipien: Population, Selektion, Rekombination, Mutation¶
Zunächst die Klärung einiger Begriffe und Konzepte:
Genetische Algorithmen (GA) sind evolutionäre Optimierungsverfahren, die Prinzipien der natürlichen Selektion nachahmen. Die Population besteht aus mehreren Lösungskandidaten (Individuen), die parallel evaluiert und weiterentwickelt werden. Jedes Individuum wird durch sein Genom repräsentiert – eine kodierte Form der Lösungsvariablen, traditionell als Binärstring, heute oft auch als reelle Zahlen, Permutationen oder komplexere Datenstrukturen je nach Problemstellung. Ein Beispiel für so ein Genom ist
$$(1,0,0,0,1,1,0,1,1,0,0,1)$$
Die entsprechende Population ist dann ganz einfach ein binäres Array, in dem die Zeilen den Genomen der Individuen entsprechen.
Der Evolutionsprozess verläuft iterativ über mehrere Generationen. In jeder Generation werden die Individuen anhand ihrer Fitness (Qualität bezüglich der Zielfunktion) bewertet. Durch Selektion werden bevorzugt fittere Individuen als “Eltern” für die nächste Generation ausgewählt. Die Rekombination (Crossover) kombiniert das genetische Material zweier Eltern, um neue Nachkommen zu erzeugen, während die Mutation zufällige kleine Änderungen im Genom einführt, um neue Variabilität zu schaffen. Um bereits erfolgreich getestete Individuen nicht aus der Population zu verlieren, kann man zusätzlich auch die besten Individuen unverändert in die nächste Generation übernehmen, sofern sie fitter als ihre Nachkommen sind.
Die Stärke genetischer Algorithmen liegt in ihrer Parallelität und dem Populationsansatz. Sie erkunden gleichzeitig verschiedene Regionen des Suchraums und kombinieren gefundene Teillösungen durch Rekombination. Dies ermöglicht die Identifikation und Kombination von “Building Blocks” – vorteilhaften Teilstrukturen im Genom. Bei einem konkreten Run muss man in der Regel entweder ein Konvergenzkriterium festlegen oder eine maximale Anzahl von Generationen definieren, damit der Algorithmus nicht ewig weiter läuft.
7.5.2 Selektionsmechanismen: Rouletterad, Turnierselektion, Rangbasierte Selektion¶
Für die Selektion der Eltern der nächsten Generation von Nachkommen aus den aktuellen Individuen der Population gibt es mehrere Möglichkeiten. Die meisten davon nehmen darauf Rücksicht, wie fit die einzelnen Individuen der Population sind. Die folgenden Beispiele sind ein paar der wichtigsten Möglichkeiten:
Die Rouletterad-Selektion (Fitness-proportionale Selektion) wählt Individuen mit einer Wahrscheinlichkeit proportional zu ihrer Fitness. Dies lässt sich als Drehen eines Rouletterads visualisieren, bei dem fittere Individuen größere Segmente erhalten. Diese Methode kann jedoch problematisch sein, wenn die Fitnessverteilung sehr ungleichmäßig ist: Bei zu großen Unterschieden dominieren wenige Superindividuen, während bei zu geringen Unterschieden die Selektion nahezu zufällig wird. Skalierungstechniken können diese Probleme mildern.
Die Turnierselektion wählt für jede Elternposition $k$ (z.B. 5) zufällige Individuen aus und nimmt das fitteste davon. Durch Variation der Turniergröße $k$ lässt sich der Selektionsdruck präzise einstellen: Größere Turniere führen zu stärkerem Selektionsdruck. Die Turnierselektion ist einfach zu implementieren, effizient zu berechnen (keine Sortierung der gesamten Population nötig) und robust gegenüber verschiedenen Fitnessverteilungen. Sie gehört zu den am häufigsten verwendeten Selektionsmethoden in modernen GA-Implementierungen.
Die Rangbasierte Selektion sortiert alle Individuen nach ihrer Fitness und weist ihnen Selektionswahrscheinlichkeiten basierend auf ihrem Rang zu, nicht auf dem absoluten Fitnesswert. Dies eliminiert das Problem extremer Fitnessunterschiede und sorgt für einen konstanteren Selektionsdruck über den Verlauf der Evolution. Verschiedene Funktionen zur Zuweisung von Wahrscheinlichkeiten basierend auf dem Rang sind möglich, von linearen bis zu exponentiellen Abhängigkeiten. Diese Methode ist allerdings rechenintensiver, da sie eine vollständige Sortierung der Population erfordert.
7.5.3 Crossover-Operatoren für verschiedene Problemtypen¶
Bevor wir uns ein konkretes Beispiel ansehen können, brauchen wir noch eine weitere Mechanik zusätzlich zur Selektion, nämlich die des Crossovers. Crossover-Operatoren variieren allerdings je nach Problemtyp und Genomrepräsentation. Das wird unmittelbar einsichtig, wenn man sich im Detail überlegt, was es eigentlich bedeutet, zwei Genome von zwei Individuen überhaupt mischen zu können, sodass wieder ein sinnvolles bzw. erlaubtes Genom für ein neues Individuum entsteht.
Für binäre oder reellwertige Genome ist der Ein-Punkt-Crossover am einfachsten: Das Genom wird an einer zufälligen Stelle geschnitten und die resultierenden Teilstücke zwischen den Eltern ausgetauscht. Der Zwei-Punkt-Crossover bzw. N-Punkt-Crossover erweitert dieses Konzept auf mehrere Schnittstellen. Der uniforme Crossover entscheidet für jedes Gen einzeln und unabhängig, von welchem Elternteil es übernommen wird, typischerweise mit einer 50:50-Wahrscheinlichkeit.
Für Situationen, bei denen Zusammenhänge nicht so klar sind, z.B. permutationsbasierte Probleme wie das Traveling Salesman Problem, sind spezielle Operatoren nötig, die die Gültigkeit des Genoms erhalten. Wenn es z.B. darum geht, aus einer Permutation einer Menge eine andere Permutation zu machen, muss man also darauf achten, die Permutationseigenschaft zu erhalten. Man kann sich leicht davon überzeugen, dass die einfache Schnittmethode von oben nicht funktioniert. Z.B. könnte aus den beiden Eltern $$(0, 1, 2, 3, 4, 5, 6, 7)\quad \mathrm{und}\quad (0, 7, 4, 3, 1, 6, 2, 5)$$ durch einen Schnitt an der Stelle 4 (in der Mitte) die neue Zahlenfolge $(0, 1, 2, 3, 1, 6, 2, 5)$ entstehen, die aber keine gültige Permuation ist, weil z.B. die Zahl 1 doppelt vorkommt. Hier muss man also etwas cleverer sein und die die Crossovers geeignet konstruieren.
Die Wahl des Crossover-Operators hat erheblichen Einfluss auf die Leistung des genetischen Algorithmus. Ein guter Operator sollte nach Möglichkeit wertvolle Eigenschaften beider Eltern bewahren, neue Bereiche des Suchraums erschließen und die geforderten Constraints einhalten, also nur gültige Lösungen erzeugen. In der Praxis werden daher problemspezifische Operatoren entwickelt, die Domänenwissen einbeziehen.
7.5.4 Beispiel: Das binäre Rucksackproblem, gelöst mit einem genetischen Algorithmus¶
Nun haben Sie sich ein Beispiel verdient. Wir betrachten das sogenannte “binäre Rucksackproblem”, wo es darum geht, in einem Rucksack mit einem maximalen Fassungsvermögen die ideale Kombination von verschieden wertvollen Dingen mitzunehmen oder nicht mitzunehmen, sodass der wertvollste Inhalt für den Rucksack gefunden wird, der das Limit des Fassungsvermögens (Gewichts) nicht übersteigt. Das ist ein kombinatorisches Optimierungsproblem, an dessen Grenzen man schnell stoßen kann.
Um einen Vergleich zu haben, lösen wir das Problem zunächst mit der Brute-Force-Methode, d.h. wir probieren einfach alle möglichen Lösungen durch und finden so heraus, welche die beste ist. Hier aber erst einmal die Definition der möglichen Inhalte des Rucksacks:
import numpy as np
import time
# Seed für Reproduzierbarkeit
np.random.seed(42)
# Anzahl der Gegenstände
n_items = 20
# Generiere zufällige Gewichte und Werte für die Gegenstände
# Gewichte zwischen, 1 und 10
weights = np.random.randint(1, 11, size=n_items)
# Werte zwischen, 1 und 100
values = np.random.randint(1, 101, size=n_items)
# Kapazität des Rucksacks (ca. 30% der Gesamtgewichte)
capacity = int(np.sum(weights) * 0.3)
# Zeige das Problem
print(f"Rucksackproblem mit {n_items} Gegenständen und Kapazität {capacity}:")
print("\nGegenstände (Index, Gewicht, Wert, Wert/Gewicht-Verhältnis):")
for i in range(n_items):
print(f"{i:2d}: Gewicht = {weights[i]:2d}, Wert = {values[i]:3d}, Verhältnis = {values[i]/weights[i]:5.2f}")
print(f"\nGesamtgewicht aller Gegenstände: {np.sum(weights)}")
print(f"Gesamtwert aller Gegenstände: {np.sum(values)}")
print(f"Rucksackkapazität: {capacity}")
# Save the problem definition for later reference
problem_size = n_items
problem_capacity = capacity
problem_weights = weights.copy()
problem_values = values.copy()
Rucksackproblem mit 20 Gegenständen und Kapazität 34: Gegenstände (Index, Gewicht, Wert, Wert/Gewicht-Verhältnis): 0: Gewicht = 7, Wert = 64, Verhältnis = 9.14 1: Gewicht = 4, Wert = 60, Verhältnis = 15.00 2: Gewicht = 8, Wert = 21, Verhältnis = 2.62 3: Gewicht = 5, Wert = 33, Verhältnis = 6.60 4: Gewicht = 7, Wert = 76, Verhältnis = 10.86 5: Gewicht = 10, Wert = 58, Verhältnis = 5.80 6: Gewicht = 3, Wert = 22, Verhältnis = 7.33 7: Gewicht = 7, Wert = 89, Verhältnis = 12.71 8: Gewicht = 8, Wert = 49, Verhältnis = 6.12 9: Gewicht = 5, Wert = 91, Verhältnis = 18.20 10: Gewicht = 4, Wert = 59, Verhältnis = 14.75 11: Gewicht = 8, Wert = 42, Verhältnis = 5.25 12: Gewicht = 8, Wert = 92, Verhältnis = 11.50 13: Gewicht = 3, Wert = 60, Verhältnis = 20.00 14: Gewicht = 6, Wert = 80, Verhältnis = 13.33 15: Gewicht = 5, Wert = 15, Verhältnis = 3.00 16: Gewicht = 2, Wert = 62, Verhältnis = 31.00 17: Gewicht = 8, Wert = 62, Verhältnis = 7.75 18: Gewicht = 6, Wert = 47, Verhältnis = 7.83 19: Gewicht = 2, Wert = 62, Verhältnis = 31.00 Gesamtgewicht aller Gegenstände: 116 Gesamtwert aller Gegenstände: 1144 Rucksackkapazität: 34
Nun machen wir uns an die Brute-Force-Lösung:
import numpy as np
import itertools
import time
# Brute-Force-Lösung für das Rucksackproblem
def brute_force_knapsack(weights, values, capacity):
n = len(weights)
max_value = 0
best_combination = None
# Starte die Zeitmessung
start_time = time.time()
# Evaluiere alle möglichen Kombinationen (2^n)
total_combinations = 2**n
evaluated = 0
for i in range(1, total_combinations):
# Binäre Repräsentation der aktuellen Kombination
binary = bin(i)[2:].zfill(n)
selection = [int(bit) for bit in binary]
# Berechne Gesamtgewicht und -wert
total_weight = sum(weights[j] for j in range(n) if selection[j] == 1)
total_value = sum(values[j] for j in range(n) if selection[j] == 1)
evaluated += 1
# Prüfe, ob diese Kombination gültig ist und besser als die bisherige beste
if total_weight <= capacity and total_value > max_value:
max_value = total_value
best_combination = selection
# Ende der Zeitmessung
end_time = time.time()
execution_time = end_time - start_time
# Zeige Details der optimalen Lösung
if best_combination is not None:
selected_items = [i for i in range(n) if best_combination[i] == 1]
total_weight = sum(weights[i] for i in selected_items)
print(f"Brute-Force-Lösung für {n} Gegenstände:")
print(f"Ausgewählte Gegenstände: {selected_items}")
print(f"Gewählte Gegenstände: {len(selected_items)} von {n}")
print(f"Gesamtgewicht: {total_weight} von {capacity} ({total_weight/capacity*100:.1f}%)")
print(f"Optimaler Wert: {max_value}")
print(f"Ausgeführt in {execution_time:.6f} Sekunden")
print(f"Evaluierte Kombinationen: {evaluated:,} von {total_combinations:,} ({evaluated/total_combinations*100:.2f}%)")
else:
print("Keine gültige Lösung gefunden!")
return best_combination, max_value, execution_time
# Rufe die globalen Problemdefinitionen ab
weights = problem_weights
values = problem_values
capacity = problem_capacity
n_items = problem_size
# Nur ausführen, wenn die Anzahl der Gegenstände nicht zu groß ist
if n_items <= 25: # Bei größerer Anzahl wird Brute-Force unpraktisch
optimale_lösung, optimaler_wert, ausführungszeit_bf = brute_force_knapsack(weights, values, capacity)
else:
print(f"ACHTUNG: Brute-Force für {n_items} Gegenstände ist zu rechenintensiv!")
print(f"Das würde {2**n_items:,} Kombinationen erfordern.")
print("Reduziere die Anzahl der Gegenstände auf <= 25 oder verwende nur den GA.")
Brute-Force-Lösung für 20 Gegenstände: Ausgewählte Gegenstände: [1, 9, 10, 12, 13, 14, 16, 19] Gewählte Gegenstände: 8 von 20 Gesamtgewicht: 34 von 34 (100.0%) Optimaler Wert: 566 Ausgeführt in 7.001990 Sekunden Evaluierte Kombinationen: 1,048,575 von 1,048,576 (100.00%)
Und nun zum Vergleich die Lösung per GA mit binärem Genom-Vektor:
import numpy as np
import matplotlib.pyplot as plt
import time
class GeneticAlgorithmKnapsack:
def __init__(self, weights, values, capacity, population_size=100, generations=100,
crossover_rate=0.8, mutation_rate=0.1, elitism=True, tournament_size=3):
self.weights = weights
self.values = values
self.capacity = capacity
self.population_size = population_size
self.generations = generations
self.crossover_rate = crossover_rate
self.mutation_rate = mutation_rate
self.elitism = elitism
self.tournament_size = tournament_size
self.item_count = len(weights)
self.best_solution = None
self.best_fitness = 0
self.fitness_history = []
def initialize_population(self):
"""Erstelle eine zufällige Startpopulation"""
population = np.random.randint(0, 2, size=(self.population_size, self.item_count))
return population
def calculate_fitness(self, individual):
"""Berechne die Fitness eines Individuums (= Gesamtwert, wenn gültig)"""
total_weight = np.sum(individual * self.weights)
total_value = np.sum(individual * self.values)
# Wenn das Gewicht die Kapazität überschreitet, setze Fitness auf 0
if total_weight > self.capacity:
return 0
return total_value
def evaluate_population(self, population):
"""Berechne die Fitness für die gesamte Population"""
fitness_values = np.zeros(self.population_size)
for i in range(self.population_size):
fitness_values[i] = self.calculate_fitness(population[i])
return fitness_values
def tournament_selection(self, population, fitness_values):
"""Wähle ein Individuum durch Turnierselektion"""
# Wähle zufällig tournament_size Individuen
tournament_indices = np.random.choice(self.population_size, self.tournament_size, replace=False)
tournament_fitness = fitness_values[tournament_indices]
# Wähle das Individuum mit der höchsten Fitness
winner_index = tournament_indices[np.argmax(tournament_fitness)]
return population[winner_index].copy()
def crossover(self, parent1, parent2):
"""Ein-Punkt-Crossover zwischen zwei Eltern"""
if np.random.random() < self.crossover_rate:
# Wähle zufälligen Crossover-Punkt
crossover_point = np.random.randint(1, self.item_count)
# Erzeuge Kinder durch Austausch der Gene ab dem Crossover-Punkt
child1 = np.concatenate([parent1[:crossover_point], parent2[crossover_point:]])
child2 = np.concatenate([parent2[:crossover_point], parent1[crossover_point:]])
return child1, child2
else:
# Keine Kreuzung, gib Eltern unverändert zurück
return parent1.copy(), parent2.copy()
def mutate(self, individual):
"""Bit-Flip-Mutation für ein Individuum"""
for i in range(self.item_count):
if np.random.random() < self.mutation_rate:
individual[i] = 1 - individual[i] # Flip 0->1 oder 1->0
return individual
def evolve(self, verbose=True):
"""Führe den genetischen Algorithmus aus"""
start_time = time.time()
# Initialisiere Population
population = self.initialize_population()
# Evaluiere initiale Population
fitness_values = self.evaluate_population(population)
# Finde das beste Individuum
best_idx = np.argmax(fitness_values)
self.best_solution = population[best_idx].copy()
self.best_fitness = fitness_values[best_idx]
# Sammle Fitness-Historie für die Visualisierung
self.fitness_history = [self.best_fitness]
# Evolutionsschleife
for generation in range(self.generations):
# Erzeuge neue Population
new_population = np.zeros((self.population_size, self.item_count), dtype=int)
# Elitismus: Bewahre das beste Individuum
elite_offset = 0
if self.elitism:
new_population[0] = self.best_solution
elite_offset = 1
# Fülle die neue Population
for i in range(elite_offset, self.population_size, 2):
# Selektion
parent1 = self.tournament_selection(population, fitness_values)
parent2 = self.tournament_selection(population, fitness_values)
# Crossover
if i + 1 < self.population_size:
child1, child2 = self.crossover(parent1, parent2)
# Mutation
child1 = self.mutate(child1)
child2 = self.mutate(child2)
new_population[i] = child1
new_population[i+1] = child2
else:
# Wenn ungerade Populationsgröße, füge nur ein Kind hinzu
child1 = self.mutate(parent1)
new_population[i] = child1
# Ersetze alte Population
population = new_population
# Evaluiere neue Population
fitness_values = self.evaluate_population(population)
# Aktualisiere beste Lösung
current_best_idx = np.argmax(fitness_values)
current_best_fitness = fitness_values[current_best_idx]
if current_best_fitness > self.best_fitness:
self.best_solution = population[current_best_idx].copy()
self.best_fitness = current_best_fitness
# Füge zur Historie hinzu
self.fitness_history.append(self.best_fitness)
# Ausgabe des Fortschritts (optional)
if verbose and (generation + 1) % 10 == 0:
print(f"Generation {generation + 1}/{self.generations}, Beste Fitness: {self.best_fitness}")
end_time = time.time()
execution_time = end_time - start_time
# Ergebnisse ausgeben
if verbose:
selected_items = [i for i in range(self.item_count) if self.best_solution[i] == 1]
total_weight = sum(self.weights[i] for i in selected_items)
print(f"\nGA-Lösung für {self.item_count} Gegenstände:")
print(f"Ausgewählte Gegenstände: {selected_items}")
print(f"Gewählte Gegenstände: {len(selected_items)} von {self.item_count}")
print(f"Gesamtgewicht: {total_weight} von {self.capacity} ({total_weight/self.capacity*100:.1f}%)")
print(f"Bester gefundener Wert: {self.best_fitness}")
print(f"Ausgeführt in {execution_time:.6f} Sekunden")
return self.best_solution, self.best_fitness, execution_time
def plot_progress(self):
"""Visualisiere den Fortschritt des GA über die Generationen"""
plt.figure(figsize=(10, 6))
plt.plot(range(len(self.fitness_history)), self.fitness_history, 'b-')
plt.title('Fortschritt des Genetischen Algorithmus')
plt.xlabel('Generation')
plt.ylabel('Beste Fitness (Wert)')
plt.grid(True, alpha=0.3)
# Zeichne Endwert als roten Punkt
plt.plot(len(self.fitness_history)-1, self.fitness_history[-1], 'ro', markersize=8)
plt.annotate(f'Finaler Wert: {self.fitness_history[-1]}',
xy=(len(self.fitness_history)-1, self.fitness_history[-1]),
xytext=(10, -20), textcoords='offset points',
bbox=dict(boxstyle='round,pad=0.5', fc='yellow', alpha=0.5))
plt.tight_layout()
plt.show()
# Rufe die globalen Problemdefinitionen ab
weights = problem_weights
values = problem_values
capacity = problem_capacity
n_items = problem_size
# Parameter für den GA
population_size = 100
generations = 100
crossover_rate = 0.8
mutation_rate = 0.1
elitism = True
tournament_size = 3
# Führe den GA aus
print(f"Löse das Rucksackproblem mit {n_items} Gegenständen durch einen Genetischen Algorithmus...")
ga = GeneticAlgorithmKnapsack(
weights, values, capacity,
population_size=population_size,
generations=generations,
crossover_rate=crossover_rate,
mutation_rate=mutation_rate,
elitism=elitism,
tournament_size=tournament_size
)
beste_lösung, bester_wert, ausführungszeit = ga.evolve(verbose=True)
# Visualisiere den Fortschritt
ga.plot_progress()
# Vergleiche mit Brute-Force, falls verfügbar
if n_items <= 25 and 'optimaler_wert' in globals():
print("\nVergleich mit Brute-Force:")
print(f"Brute-Force-Wert: {optimaler_wert}")
print(f"GA-Wert: {bester_wert}")
print(f"Unterschied: {abs(optimaler_wert - bester_wert)} ({abs(optimaler_wert - bester_wert)/optimaler_wert*100:.2f}%)")
print(f"Brute-Force-Zeit: {ausführungszeit_bf:.6f} Sekunden")
print(f"GA-Zeit: {ausführungszeit:.6f} Sekunden")
print(f"Zeitverhältnis (Brute-Force/GA): {ausführungszeit_bf/ausführungszeit:.2f}")
Löse das Rucksackproblem mit 20 Gegenständen durch einen Genetischen Algorithmus... Generation 10/100, Beste Fitness: 506.0 Generation 20/100, Beste Fitness: 559.0 Generation 30/100, Beste Fitness: 559.0 Generation 40/100, Beste Fitness: 559.0 Generation 50/100, Beste Fitness: 566.0 Generation 60/100, Beste Fitness: 566.0 Generation 70/100, Beste Fitness: 566.0 Generation 80/100, Beste Fitness: 566.0 Generation 90/100, Beste Fitness: 566.0 Generation 100/100, Beste Fitness: 566.0 GA-Lösung für 20 Gegenstände: Ausgewählte Gegenstände: [1, 9, 10, 12, 13, 14, 16, 19] Gewählte Gegenstände: 8 von 20 Gesamtgewicht: 34 von 34 (100.0%) Bester gefundener Wert: 566.0 Ausgeführt in 0.323067 Sekunden

Vergleich mit Brute-Force: Brute-Force-Wert: 566 GA-Wert: 566.0 Unterschied: 0.0 (0.00%) Brute-Force-Zeit: 7.001990 Sekunden GA-Zeit: 0.323067 Sekunden Zeitverhältnis (Brute-Force/GA): 21.67
Insbesondere bei der verbrauchten Zeit wird die Effizienz des GA deutlich. Noch wichtiger ist aber, dass dieser Algorithmus auch dann noch schnell und gut funktioniert, wenn wir 42 mögliche Gegenstände haben, wofür unsere Brute-Force-Lösung $2^{42}$ Möglichkeiten durchgehen müsste, was laut unserer Zeitmessung etwa ein Jahr dauern würde.
7.5.5 Vorteile und Nachteile genetischer Algorithmen gegenüber traditioneller Optimierung¶
Genetische Algorithmen bieten mehrere Vorteile gegenüber klassischen Optimierungsverfahren. Sie sind äußerst robust gegenüber lokalen Optima und zwar sowohl durch ihre populationsbasierte Natur als auch die verwendeten stochastischen Operatoren von Mutation und Crossover. GA können wieder ohne Kenntnis des Gradienten arbeiten, was sie für nicht-differenzierbare, diskrete oder Black-Box-Funktionen geeignet macht. Bereits durch ihre inhärente Parallelität über die Population erkunden sie gleichzeitig verschiedene Regionen des Suchraums. Sie sind vielseitig einsetzbar für unterschiedlichste Problemtypen, von kombinatorischen über kontinuierliche bis zu gemischten Problemen, und lassen sich einfach an Probleme mit mehreren Zielfunktionen anpassen.
Ein weiterer Vorteil ist die einfache Anpassbarkeit an verschiedene Problemrepräsentationen. Ob Binärstrings, reelle Zahlen, Permutationen oder komplexe Datenstrukturen – es lassen sich dazu jeweils passende genetische Operatoren definieren. GA können auch mit verrauschten Zielfunktionen gut umgehen, da sie auf der Basis relativer Fitness und nicht absoluter Werte arbeiten. Bei sehr komplexen Problemen liefern sie oft akzeptable Lösungen in angemessener Zeit, auch wenn keine optimale Lösung garantiert werden kann. Mit “akzeptabel” ist dabei gemeint, dass die gefundene beste Lösung nahe genug (für den praktischen Zweck) an der tatsächlich optimalen Lösung liegt.
Nachteile sind die möglicherweise langsame Konvergenz und der dadurch entstehende hohe Rechenaufwand durch die Evaluierung vieler Individuen über viele Generationen. GA bieten außerdem keine Optimalitätsgarantien – sie finden typischerweise gute, aber nicht notwendigerweise optimale Lösungen. Die Leistung ist stark von der richtigen Parametereinstellung abhängig (Populationsgröße, Mutations- und Crossover-Raten), die oft empirisch ermittelt werden muss. Auch die Wahl einer geeigneten Genomkodierung ist entscheidend und erfordert Domänenwissen bzw. kann eine beträchtliche Herausforderung darstellen.
Ein weiteres Problem ist der Genetic Drift – der Verlust genetischer Vielfalt durch zufällige Fixierung bestimmter Genomteile, was zu vorzeitiger Konvergenz führen kann. Bei komplexen Constraints kann die Erzeugung gültiger Lösungen schwierig sein, was Reparaturmechanismen oder spezielle Constraint-Handling-Techniken erfordert. Im Vergleich zu spezialisierten Algorithmen (z.B. für lineare Probleme) sind GA für einfachere, gut strukturierte Probleme oft übermäßig komplex und ineffizient.
7.6 Übungsaufgabe: Lösen eines Traveling Salesman Problems mit einem genetischen Algorithmus¶
In dieser Übungsaufgabe werden wir uns mit dem klassischen Traveling Salesman Problem (TSP) befassen, einem der bekanntesten kombinatorischen Optimierungsprobleme. Ein Handelsreisender muss eine Reihe von Städten besuchen und zu seinem Ausgangspunkt zurückkehren, wobei jede Stadt genau einmal besucht werden soll. Die Herausforderung besteht darin, die kürzestmögliche Route zu finden. Dieses Problem eignet sich hervorragend, um die Stärken genetischer Algorithmen zu demonstrieren, da es für größere Anzahlen von Städten mit rein analytischen Methoden nicht effizient lösbar ist.
Im der folgenden Code-Zelle habe ich für Sie eine Instanz mit fantasievollen Namen und Städtekoordinaten generiert und visualisiert. Sie sollen nun in Zusammenarbeit mit dem LLM Ihrer Wahl einen genetischen Algorithmus mit geeigneter Genomrepräsentation (typischerweise eine Permutation der Städteindizes), passenden Crossover-Operatoren (wie z.B. Order Crossover oder Partially Mapped Crossover – diskutieren Sie diese Möglichkeiten mit dem LLM) und Mutationsoperatoren (wie Swap Mutation oder Inversion Mutation) implementieren. Die Fitnessfunktion entspricht dabei der inversen Gesamtlänge der Route – kürzere Routen erhalten höhere Fitnesswerte. Die Distanzen zwischen allen Städte-Paaren können (und sollten) Sie vorab in einer sogenannten “Distanzmatrix” einmal berechnen, damit Sie das nicht jedes Mal aufs Neue tun müssen.
Hier zunächst einmal die Daten, danach finden Sie eine Liste der nächsten Schritte.
import numpy as np
import matplotlib.pyplot as plt
from math import factorial
# Seed für Reproduzierbarkeit
np.random.seed(42)
# Anzahl der Städte
n_cities = 15
# Erstelle Fantasienamen für die Städte
city_names = [
"Bergheim", "Talblick", "Waldkirchen", "Seehausen", "Flussbach",
"Sonnental", "Mondberg", "Sternfeld", "Nebeldorf", "Windhaven",
"Regenbogen", "Donnerfels", "Blitzhügel", "Schneegipfel", "Feuertal"
]
# Koordinaten der Städte generieren (in einem Bereich von 0 bis 100)
x_coords = np.random.uniform(0, 100, n_cities)
y_coords = np.random.uniform(0, 100, n_cities)
# Erstelle eine Liste von Stadt-Dictionaries mit Namen und Koordinaten
cities = []
for i in range(n_cities):
cities.append({
"name": city_names[i],
"x": x_coords[i],
"y": y_coords[i]
})
# Zeige die Städte und ihre Koordinaten an
print(f"TSP-Problem mit {n_cities} Städten:")
print("\nStädte und ihre Koordinaten:")
for i, city in enumerate(cities):
print(f"{i}: {city['name']} ({city['x']:.2f}, {city['y']:.2f})")
# Erstelle eine Karte der Städte
plt.figure(figsize=(10, 8))
plt.scatter(x_coords, y_coords, c='darkblue', s=100, zorder=5)
# Beschrifte die Städte
for i, city in enumerate(cities):
plt.annotate(
city['name'],
(city['x'], city['y']),
xytext=(5, 5),
textcoords='offset points',
fontsize=9,
bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="gray", alpha=0.8)
)
plt.title('Fantasiekarte für das TSP-Problem')
plt.xlabel('x-Koordinate')
plt.ylabel('y-Koordinate')
plt.grid(alpha=0.3)
plt.xlim(-5, 105)
plt.ylim(-5, 105)
plt.tight_layout()
plt.show()
# Information über das Problem und die optimale Lösung
print(f"Die Gesamtzahl möglicher Routen beträgt (n-1)!/2 = {factorial(n_cities-1)/2:.2e}")
# Speichern der Daten für spätere Verwendung
tsp_n_cities = n_cities
tsp_city_names = city_names
tsp_x_coords = x_coords
tsp_y_coords = y_coords
TSP-Problem mit 15 Städten: Städte und ihre Koordinaten: 0: Bergheim (37.45, 18.34) 1: Talblick (95.07, 30.42) 2: Waldkirchen (73.20, 52.48) 3: Seehausen (59.87, 43.19) 4: Flussbach (15.60, 29.12) 5: Sonnental (15.60, 61.19) 6: Mondberg (5.81, 13.95) 7: Sternfeld (86.62, 29.21) 8: Nebeldorf (60.11, 36.64) 9: Windhaven (70.81, 45.61) 10: Regenbogen (2.06, 78.52) 11: Donnerfels (96.99, 19.97) 12: Blitzhügel (83.24, 51.42) 13: Schneegipfel (21.23, 59.24) 14: Feuertal (18.18, 4.65)

Die Gesamtzahl möglicher Routen beträgt (n-1)!/2 = 4.36e+10
Nächste Schritte:
- Implementieren Sie das Grundgerüst Ihres Genetischen Algorithmus für dieses Problem, basierend auf Permutationen als Genomrepräsentation.
- Lassen Sie den Algorithmus einige Male laufen, um ein Gefühl für die Konvergenz in Abhängigkeit von der Anzahl der Generationen, der Größe der Population, sowie der Mutations- und Crossover-Rate zu bekommen.
- Schärfen Sie Ihre Intuition durch Visualisierungen
- Wenn alles läuft, erhöhen Sie die Anzahl der Städte und versuchen Sie noch einmal, eine gute und möglichst verlässliche Lösung zu erhalten.