299 lines
12 KiB
Python
299 lines
12 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
|
||
|
|
# erzeuge_metaheuristik_landschaft.py
|
||
|
|
"""
|
||
|
|
Erzeugt das Suchverlauf-Diagramm zum Kapitel Metaheuristiken:
|
||
|
|
|
||
|
|
bilder_04/kap_metaheuristiken_landschaft.svg statisch, fuer das PDF
|
||
|
|
bilder_04/kap_metaheuristiken_landschaft.png Rasterfassung
|
||
|
|
bilder_04/plotly/kap_metaheuristiken_verlauf.html interaktiv, fuer die Website
|
||
|
|
|
||
|
|
Eine Suchlandschaft laesst sich bei 200 Auftraegen nicht zeichnen - der Raum
|
||
|
|
hat 200! Punkte. Zeichnen laesst sich aber, wie die Suche sie DURCHLAEUFT, und
|
||
|
|
das zeigt dasselbe:
|
||
|
|
|
||
|
|
Links - der Zielwert waehrend der Suche. Bergsteigen faellt monoton und
|
||
|
|
bleibt liegen. Annealing bei T=1 faellt fast genauso, mit winzigen
|
||
|
|
Ausschlaegen nach oben. Bei T=8 irrt die Suche weit ueber den
|
||
|
|
Startwert hinaus und findet nicht zurueck - die Temperatur ist kein
|
||
|
|
Beiwerk.
|
||
|
|
Rechts - das Ergebnis ueber die Starttemperatur. Es gibt ein Optimum, es ist
|
||
|
|
schmal, und jenseits davon wird die Metaheuristik schlechter als die
|
||
|
|
Faustregel, mit der sie gestartet ist.
|
||
|
|
|
||
|
|
Instanz, Zug und Abkuehlplan sind wortgleich zu Simulated_Annealing.py, und
|
||
|
|
gerechnet wird hier erneut - so koennen Diagramm und Buchtext nicht
|
||
|
|
auseinanderlaufen. Der einzige Unterschied: Die Suche zeichnet ihren Verlauf
|
||
|
|
mit, was das Buchprogramm nicht braucht.
|
||
|
|
|
||
|
|
Aufruf (aus dem Repository-Wurzelverzeichnis, dauert rund eine Minute):
|
||
|
|
python3 bilder_04/erzeuge_metaheuristik_landschaft.py
|
||
|
|
|
||
|
|
Die interaktive Fassung traegt im Hover die Groesse, die das statische Bild
|
||
|
|
nicht zeigen kann: die Temperatur an dieser Stelle des Abkuehlplans - und was
|
||
|
|
sie konkret bedeutet, naemlich mit welcher Wahrscheinlichkeit dort ein Zug
|
||
|
|
typischer Groesse (Median 70 Minuten, im Kapitel gemessen) angenommen wuerde.
|
||
|
|
Damit wird an der Kurve ablesbar, warum T0 = 8 so lange braucht, um
|
||
|
|
zurueckzufinden.
|
||
|
|
|
||
|
|
Benoetigt: numpy, matplotlib, plotly
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import math
|
||
|
|
import os
|
||
|
|
|
||
|
|
import matplotlib
|
||
|
|
matplotlib.use("Agg")
|
||
|
|
import matplotlib.pyplot as plt
|
||
|
|
import numpy as np
|
||
|
|
import plotly.graph_objects as go
|
||
|
|
|
||
|
|
# Reproduzierbare SVG-Ausgabe (siehe erzeuge_titelseite.py)
|
||
|
|
plt.rcParams["svg.hashsalt"] = "or-mit-python-v04"
|
||
|
|
|
||
|
|
BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
|
|
BILDER = os.path.join(BASIS, "bilder_04")
|
||
|
|
PLOTLY_VERZ = os.path.join(BILDER, "plotly")
|
||
|
|
|
||
|
|
INDIGO, CYAN, AMBER, GRAU = "#4338ca", "#0891b2", "#b45309", "#94a3b8"
|
||
|
|
|
||
|
|
N_AUFTRAEGE = 200
|
||
|
|
ZUEGE = 400_000
|
||
|
|
SAAT = 11
|
||
|
|
STUETZSTELLEN = 800 # so viele Punkte je Verlaufskurve
|
||
|
|
MEDIAN_ZUG = 70 # typische Zuggroesse in Minuten (im Kapitel gemessen)
|
||
|
|
|
||
|
|
|
||
|
|
# --- Instanz und Zug: wortgleich zu Simulated_Annealing.py ------------------
|
||
|
|
|
||
|
|
def erzeuge_ruestmatrix(n: int, saat: int = SAAT) -> np.ndarray:
|
||
|
|
rng = np.random.default_rng(saat)
|
||
|
|
familien = max(6, n // 10)
|
||
|
|
zwischen = rng.integers(8, 60, (familien, familien))
|
||
|
|
np.fill_diagonal(zwischen, 2)
|
||
|
|
familie = rng.integers(0, familien, n)
|
||
|
|
farbe = rng.integers(0, 10, n)
|
||
|
|
matrix = np.zeros((n, n), dtype=np.int64)
|
||
|
|
for i in range(n):
|
||
|
|
for j in range(n):
|
||
|
|
if i != j:
|
||
|
|
matrix[i, j] = (zwischen[familie[i], familie[j]]
|
||
|
|
+ max(0, farbe[i] - farbe[j]) * 3)
|
||
|
|
return matrix
|
||
|
|
|
||
|
|
|
||
|
|
def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
|
||
|
|
return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
|
||
|
|
|
||
|
|
|
||
|
|
def faustregel(matrix: np.ndarray) -> list[int]:
|
||
|
|
n = len(matrix)
|
||
|
|
offen = set(range(1, n))
|
||
|
|
reihe = [0]
|
||
|
|
while offen:
|
||
|
|
naechster = min(offen, key=lambda j: matrix[reihe[-1], j])
|
||
|
|
reihe.append(naechster)
|
||
|
|
offen.discard(naechster)
|
||
|
|
return reihe
|
||
|
|
|
||
|
|
|
||
|
|
def delta_verschieben(reihe: list[int], matrix: np.ndarray, von: int, nach: int):
|
||
|
|
n = len(reihe)
|
||
|
|
if von == nach or nach == von + 1:
|
||
|
|
return None
|
||
|
|
heraus = 0
|
||
|
|
if von > 0:
|
||
|
|
heraus += matrix[reihe[von - 1], reihe[von]]
|
||
|
|
if von < n - 1:
|
||
|
|
heraus += matrix[reihe[von], reihe[von + 1]]
|
||
|
|
if 0 < von < n - 1:
|
||
|
|
heraus -= matrix[reihe[von - 1], reihe[von + 1]]
|
||
|
|
rest = reihe[:von] + reihe[von + 1:]
|
||
|
|
stelle = nach if nach < von else nach - 1
|
||
|
|
hinein = 0
|
||
|
|
if stelle > 0:
|
||
|
|
hinein += matrix[rest[stelle - 1], reihe[von]]
|
||
|
|
if stelle < len(rest):
|
||
|
|
hinein += matrix[reihe[von], rest[stelle]]
|
||
|
|
if 0 < stelle < len(rest):
|
||
|
|
hinein -= matrix[rest[stelle - 1], rest[stelle]]
|
||
|
|
return int(hinein - heraus), rest, stelle
|
||
|
|
|
||
|
|
|
||
|
|
# --- Die Suche, um die Verlaufsaufzeichnung ergaenzt ------------------------
|
||
|
|
|
||
|
|
def suche_mit_verlauf(matrix: np.ndarray, start_temperatur: float,
|
||
|
|
saat: int = 1, bergsteigen: bool = False,
|
||
|
|
end_temperatur: float = 0.05):
|
||
|
|
"""Wie suche() im Buchprogramm, protokolliert zusaetzlich den Zielwert.
|
||
|
|
|
||
|
|
Aufgezeichnet wird der AKTUELLE Zustand, nicht der beste. Genau darin liegt
|
||
|
|
die Aussage: Der beste Wert faellt bei jedem Verfahren monoton - was die
|
||
|
|
Verfahren unterscheidet, ist, wie weit sich die Suche unterwegs vom Guten
|
||
|
|
entfernen darf.
|
||
|
|
"""
|
||
|
|
rng = np.random.default_rng(saat)
|
||
|
|
n = len(matrix)
|
||
|
|
reihe = faustregel(matrix)
|
||
|
|
kosten = gesamtruestzeit(reihe, matrix)
|
||
|
|
beste_kosten = kosten
|
||
|
|
takt = max(1, ZUEGE // STUETZSTELLEN)
|
||
|
|
x, y, temperaturen = [0], [kosten], [start_temperatur]
|
||
|
|
|
||
|
|
for zug in range(ZUEGE):
|
||
|
|
temperatur = start_temperatur * (end_temperatur / start_temperatur) \
|
||
|
|
** (zug / ZUEGE)
|
||
|
|
von = int(rng.integers(0, n))
|
||
|
|
nach = int(rng.integers(0, n + 1))
|
||
|
|
ergebnis = delta_verschieben(reihe, matrix, von, nach)
|
||
|
|
if ergebnis is not None:
|
||
|
|
aenderung, rest, stelle = ergebnis
|
||
|
|
if aenderung <= 0:
|
||
|
|
annehmen = True
|
||
|
|
else:
|
||
|
|
annehmen = (not bergsteigen
|
||
|
|
and rng.random() < math.exp(-aenderung / temperatur))
|
||
|
|
if annehmen:
|
||
|
|
reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
|
||
|
|
kosten += aenderung
|
||
|
|
beste_kosten = min(beste_kosten, kosten)
|
||
|
|
if (zug + 1) % takt == 0:
|
||
|
|
x.append(zug + 1)
|
||
|
|
y.append(kosten)
|
||
|
|
temperaturen.append(temperatur)
|
||
|
|
return x, y, beste_kosten, temperaturen
|
||
|
|
|
||
|
|
|
||
|
|
# --- Zeichnen ---------------------------------------------------------------
|
||
|
|
|
||
|
|
def zeichne(verlaeufe, start, temperaturreihe) -> None:
|
||
|
|
figur, (links, rechts) = plt.subplots(
|
||
|
|
1, 2, figsize=(10.6, 4.4), gridspec_kw={"width_ratios": [1.7, 1]})
|
||
|
|
|
||
|
|
links.axhline(start, color=GRAU, linewidth=1.2, linestyle="--", zorder=1)
|
||
|
|
links.annotate(f"Faustregel: {start:,}".replace(",", "."),
|
||
|
|
(ZUEGE, start), textcoords="offset points", xytext=(-4, 6),
|
||
|
|
ha="right", fontsize=8, color="#475569")
|
||
|
|
|
||
|
|
for (name, farbe, breite), (x, y, bestes, _) in verlaeufe:
|
||
|
|
links.plot(x, y, color=farbe, linewidth=breite, alpha=0.9,
|
||
|
|
label=f"{name} → {bestes:,}".replace(",", "."))
|
||
|
|
|
||
|
|
links.set_xlabel("geprüfte Züge")
|
||
|
|
links.set_ylabel("Rüstzeit des aktuellen Plans (Minuten)")
|
||
|
|
links.set_title("Wie die Suche die Landschaft durchläuft", fontsize=10.5)
|
||
|
|
links.grid(linestyle=":", alpha=0.45)
|
||
|
|
links.legend(fontsize=8, loc="upper right", frameon=False)
|
||
|
|
links.set_xlim(0, ZUEGE)
|
||
|
|
for rand in ("top", "right"):
|
||
|
|
links.spines[rand].set_visible(False)
|
||
|
|
|
||
|
|
temperaturen = [t for t, _ in temperaturreihe]
|
||
|
|
ergebnisse = [e for _, e in temperaturreihe]
|
||
|
|
rechts.axhline(start, color=GRAU, linewidth=1.2, linestyle="--", zorder=1)
|
||
|
|
rechts.plot(temperaturen, ergebnisse, "o-", color=INDIGO, markersize=5,
|
||
|
|
linewidth=1.5, zorder=3)
|
||
|
|
bester = int(np.argmin(ergebnisse))
|
||
|
|
rechts.scatter([temperaturen[bester]], [ergebnisse[bester]], s=140,
|
||
|
|
facecolors="none", edgecolors=AMBER, linewidths=2.2, zorder=4)
|
||
|
|
rechts.annotate(f"bestes T = {temperaturen[bester]:g}",
|
||
|
|
(temperaturen[bester], ergebnisse[bester]),
|
||
|
|
textcoords="offset points", xytext=(10, -2),
|
||
|
|
fontsize=8, color=AMBER)
|
||
|
|
rechts.annotate(f"Faustregel: {start:,}".replace(",", "."),
|
||
|
|
(temperaturen[0], start), textcoords="offset points",
|
||
|
|
xytext=(2, 4), fontsize=8, color="#475569")
|
||
|
|
rechts.annotate("hier ist der ganze\nVorsprung aufgezehrt",
|
||
|
|
(temperaturen[-1], ergebnisse[-1]),
|
||
|
|
textcoords="offset points", xytext=(-8, -4),
|
||
|
|
ha="right", va="top", fontsize=8, color="#475569")
|
||
|
|
rechts.set_ylim(min(ergebnisse) - 8, start + 14)
|
||
|
|
|
||
|
|
rechts.set_xscale("log")
|
||
|
|
rechts.set_xticks(temperaturen)
|
||
|
|
rechts.set_xticklabels([f"{t:g}" for t in temperaturen])
|
||
|
|
rechts.set_xlabel("Starttemperatur $T_0$")
|
||
|
|
rechts.set_ylabel("erreichte Rüstzeit (Minuten)")
|
||
|
|
rechts.set_title("Das Optimum ist schmal", fontsize=10.5)
|
||
|
|
rechts.grid(linestyle=":", alpha=0.45)
|
||
|
|
for rand in ("top", "right"):
|
||
|
|
rechts.spines[rand].set_visible(False)
|
||
|
|
|
||
|
|
figur.tight_layout()
|
||
|
|
os.makedirs(BILDER, exist_ok=True)
|
||
|
|
for endung in ("svg", "png"):
|
||
|
|
pfad = os.path.join(BILDER, f"kap_metaheuristiken_landschaft.{endung}")
|
||
|
|
figur.savefig(pfad, format=endung, dpi=160,
|
||
|
|
metadata={"Date": None} if endung == "svg" else None)
|
||
|
|
print(f"geschrieben: {pfad}")
|
||
|
|
plt.close(figur)
|
||
|
|
|
||
|
|
|
||
|
|
def zeichne_interaktiv(verlaeufe, start) -> None:
|
||
|
|
"""Dieselben Verlaeufe, aber mit der Temperatur im Hover.
|
||
|
|
|
||
|
|
Das ist der Grund fuer die interaktive Fassung: Im statischen Bild sieht
|
||
|
|
man, DASS die T0=8-Kurve lange oben bleibt. Hier laesst sich an jeder
|
||
|
|
Stelle ablesen, WARUM - naemlich wie wahrscheinlich dort ein Zug typischer
|
||
|
|
Groesse noch angenommen wird.
|
||
|
|
"""
|
||
|
|
figur = go.Figure()
|
||
|
|
figur.add_hline(y=start, line=dict(color=GRAU, width=1.2, dash="dash"),
|
||
|
|
annotation_text=f"Faustregel: {start}",
|
||
|
|
annotation_position="bottom right")
|
||
|
|
|
||
|
|
for (name, farbe, breite), (x, y, bestes, temps) in verlaeufe:
|
||
|
|
beschriftung = []
|
||
|
|
for zug, wert, temperatur in zip(x, y, temps):
|
||
|
|
quote = math.exp(-MEDIAN_ZUG / temperatur) if temperatur > 0 else 0.0
|
||
|
|
beschriftung.append(
|
||
|
|
f"<b>{wert} Minuten</b><br>nach {zug:,} Zügen<br>"
|
||
|
|
f"Temperatur {temperatur:.3f}<br>"
|
||
|
|
f"typischer Verschlechterungszug ({MEDIAN_ZUG} min) würde<br>"
|
||
|
|
f"mit {quote:.1e} angenommen<extra></extra>".replace(",", "."))
|
||
|
|
figur.add_trace(go.Scatter(
|
||
|
|
x=x, y=y, mode="lines", name=f"{name} → {bestes}",
|
||
|
|
line=dict(color=farbe, width=breite + 0.4),
|
||
|
|
hovertemplate=beschriftung))
|
||
|
|
|
||
|
|
figur.update_layout(
|
||
|
|
title="Der Suchverlauf — mit der Temperatur im Hover",
|
||
|
|
xaxis=dict(title="geprüfte Züge"),
|
||
|
|
yaxis=dict(title="Rüstzeit des aktuellen Plans (Minuten)"),
|
||
|
|
height=430, margin=dict(l=70, r=20, t=55, b=50),
|
||
|
|
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
||
|
|
legend=dict(orientation="h", y=-0.2),
|
||
|
|
hovermode="closest")
|
||
|
|
|
||
|
|
os.makedirs(PLOTLY_VERZ, exist_ok=True)
|
||
|
|
pfad = os.path.join(PLOTLY_VERZ, "kap_metaheuristiken_verlauf.html")
|
||
|
|
with open(pfad, "w", encoding="utf-8") as datei:
|
||
|
|
datei.write(figur.to_html(full_html=False, include_plotlyjs=False,
|
||
|
|
div_id="kap-metaheuristiken-verlauf"))
|
||
|
|
print(f"geschrieben: {pfad}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
matrix = erzeuge_ruestmatrix(N_AUFTRAEGE)
|
||
|
|
start = gesamtruestzeit(faustregel(matrix), matrix)
|
||
|
|
print(f"Instanz: {N_AUFTRAEGE} Auftraege, Faustregel {start} Minuten")
|
||
|
|
|
||
|
|
verlaeufe = []
|
||
|
|
for beschriftung, kalt, temperatur in (
|
||
|
|
(("Bergsteigen", CYAN, 1.3), True, 1.0),
|
||
|
|
(("Annealing $T_0=1$", INDIGO, 1.3), False, 1.0),
|
||
|
|
(("Annealing $T_0=8$", AMBER, 1.0), False, 8.0)):
|
||
|
|
x, y, bestes, temps = suche_mit_verlauf(matrix, temperatur, bergsteigen=kalt)
|
||
|
|
print(f" {beschriftung[0]:<22} bestes {bestes}")
|
||
|
|
verlaeufe.append((beschriftung, (x, y, bestes, temps)))
|
||
|
|
|
||
|
|
temperaturreihe = []
|
||
|
|
for temperatur in (0.5, 1.0, 2.0, 4.0, 8.0):
|
||
|
|
_, _, bestes, _ = suche_mit_verlauf(matrix, temperatur)
|
||
|
|
temperaturreihe.append((temperatur, bestes))
|
||
|
|
print(f" T0={temperatur:<5} -> {bestes}")
|
||
|
|
|
||
|
|
zeichne(verlaeufe, start, temperaturreihe)
|
||
|
|
zeichne_interaktiv(verlaeufe, start)
|