operations_research/bilder_04/erzeuge_bnb_geometrie.py
2026-09-09 07:37:07 +02:00

189 lines
7.7 KiB
Python

#!/usr/bin/env python3
# erzeuge_bnb_geometrie.py
"""
Erzeugt die geometrische Tafel zum Branch-and-Bound im MILP-Kapitel:
bilder_04/kap_milp_bnb_geometrie.svg
ERGAENZT den Suchbaum kap_milp_suchbaum.svg, ersetzt ihn nicht: Der Baum zeigt,
WELCHE Teilprobleme entstehen. Diese Tafel zeigt, WAS Verzweigen mit dem
Loesungsraum macht - und warum es erlaubt ist.
Links das Polyeder der LP-Relaxation mit seinem Optimum (3; 1,5). Rechts
dasselbe Polyeder, aus dem der Streifen 1 < x2 < 2 herausgeschnitten ist; uebrig
bleiben die Aeste A (x2 <= 1) und B (x2 >= 2) mit ihren eigenen Optima.
Der Punkt, auf den es ankommt: In dem herausgeschnittenen Streifen liegt KEIN
einziger ganzzahliger Punkt - es geht also nichts verloren. Der Streifen
enthaelt aber das Optimum der Relaxation, und deshalb sinkt die Schranke von 21
auf 20,67 bzw. 18. Verzweigen heisst, wertlosen Raum wegzuschneiden, bis der
beste verbleibende Punkt ganzzahlig ist.
Modell und LP-Loeser stammen aus erzeuge_branch_and_bound.py, damit Baum und
Geometrie nicht auseinanderlaufen koennen. pruefe_befund() rechnet nach, dass im
Streifen wirklich kein ganzzahliger Punkt liegt und dass die drei Zielwerte die
des Baums sind.
Aufruf (aus dem Repository-Wurzelverzeichnis):
python3 bilder_04/erzeuge_bnb_geometrie.py
Benoetigt: numpy, scipy, matplotlib
"""
from __future__ import annotations
import itertools
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from stil_04 import FARBEN, speichere # noqa: E402
import matplotlib.pyplot as plt # noqa: E402
from erzeuge_branch_and_bound import A, B, C, loese # noqa: E402
# Die Verzweigung, die der Baum als Erstes vornimmt: x2 <= 1 gegen x2 >= 2.
GRENZE = 1.0
# Was der Suchbaum ausweist; nur zum Gegenpruefen.
SCHRANKEN_IM_BAUM = {"P0": 21.0, "A": 62.0 / 3.0, "B": 18.0}
def gitter(masche: int = 420):
x1 = np.linspace(0, 4.6, masche)
x2 = np.linspace(0, 3.4, masche)
return np.meshgrid(x1, x2)
def zulaessig(xx1, xx2, unten=None, oben=None):
"""Maske des zulaessigen Bereichs, wahlweise mit zusaetzlichen Schranken."""
maske = np.ones_like(xx1, dtype=bool)
for zeile, grenze in zip(A, B):
maske &= (zeile[0] * xx1 + zeile[1] * xx2 <= grenze + 1e-9)
if unten is not None:
maske &= (xx2 >= unten - 1e-9)
if oben is not None:
maske &= (xx2 <= oben + 1e-9)
return maske
def ganzzahlige_punkte():
"""Alle ganzzahligen zulaessigen Punkte - fuer die Tafel und den Test."""
punkte = []
for p1, p2 in itertools.product(range(0, 6), range(0, 5)):
if np.all(A @ np.array([p1, p2], dtype=float) <= B + 1e-9):
punkte.append((p1, p2))
return punkte
def pruefe_befund(loesungen, punkte) -> None:
"""Die beiden Aussagen der Tafel muessen stimmen."""
im_streifen = [p for p in punkte if GRENZE < p[1] < GRENZE + 1]
if im_streifen:
raise SystemExit(
f"B&B-Geometrie: Im herausgeschnittenen Streifen liegen die "
f"ganzzahligen Punkte {im_streifen}. Dann waere das Verzweigen "
f"nicht verlustfrei, und die Tafel behauptete etwas Falsches.")
for name, soll in SCHRANKEN_IM_BAUM.items():
ist = loesungen[name][1]
if abs(ist - soll) > 1e-6:
raise SystemExit(
f"B&B-Geometrie: {name} hat den Zielwert {ist:.4f}, der "
f"Suchbaum weist {soll:.4f} aus.")
def zahl(wert: float) -> str:
return f"{wert:.2f}".rstrip("0").rstrip(".").replace(".", ",")
def zeichne(loesungen, punkte) -> None:
figur, achsen = plt.subplots(1, 2, figsize=(10.0, 4.5))
xx1, xx2 = gitter()
for achse in achsen:
for p1, p2 in punkte:
achse.plot(p1, p2, "o", color=FARBEN["gedaempft"], markersize=4.5,
zorder=5)
for zeile, grenze in zip(A, B):
x = np.linspace(0, 4.6, 100)
achse.plot(x, (grenze - zeile[0] * x) / zeile[1], "--",
color=FARBEN["linie"], linewidth=1.1, zorder=3)
achse.set_xlim(0, 4.6)
achse.set_ylim(0, 3.4)
achse.set_xlabel("$x_1$")
achse.grid(linestyle=":", alpha=0.4)
achsen[0].set_ylabel("$x_2$")
# --- links: die Relaxation, ungeschnitten -----------------------------
achsen[0].contourf(xx1, xx2, zulaessig(xx1, xx2).astype(float),
levels=[0.5, 1.5], colors=[FARBEN["haupt"]],
alpha=0.20, zorder=1)
x_p0, z_p0 = loesungen["P0"]
achsen[0].plot(*x_p0, "*", color=FARBEN["fehler"], markersize=17, zorder=6)
achsen[0].annotate(f"LP-Optimum ({zahl(x_p0[0])}; {zahl(x_p0[1])})\n"
f"Z = {zahl(z_p0)} — nicht ganzzahlig",
xy=x_p0, xytext=(12, 12), textcoords="offset points",
fontsize=8.5, color=FARBEN["fehler"],
fontweight="bold", zorder=7)
achsen[0].set_title("$P_0$ — die Relaxation", fontsize=10.5,
color=FARBEN["text"])
# --- rechts: derselbe Bereich, der Streifen heraus ---------------------
for name, unten, oben, farbe, beschriftung in (
("A", None, GRENZE, FARBEN["zweit"], f"Ast A: $x_2 \\leq 1$"),
("B", GRENZE + 1, None, FARBEN["warnung"],
f"Ast B: $x_2 \\geq 2$")):
achsen[1].contourf(xx1, xx2,
zulaessig(xx1, xx2, unten, oben).astype(float),
levels=[0.5, 1.5], colors=[farbe], alpha=0.22,
zorder=1)
x_ast, z_ast = loesungen[name]
achsen[1].plot(*x_ast, "*", color=farbe, markersize=15, zorder=6)
# Ast A liegt an der Schnittkante; sein Text gehoert nach unten in die
# eigene Flaeche, sonst laeuft er in den Streifen hinein.
achsen[1].annotate(f"{beschriftung}\nZ = {zahl(z_ast)}",
xy=x_ast,
xytext=(-12, -30) if name == "A" else (12, 14),
textcoords="offset points", fontsize=8.5,
ha="right" if name == "A" else "left",
color=farbe, fontweight="bold", zorder=7)
achsen[1].axhspan(GRENZE, GRENZE + 1, color=FARBEN["fehler"], alpha=0.11,
zorder=2)
achsen[1].axhline(GRENZE, color=FARBEN["fehler"], linewidth=1.3, zorder=4)
achsen[1].axhline(GRENZE + 1, color=FARBEN["fehler"], linewidth=1.3,
zorder=4)
achsen[1].text(4.5, GRENZE + 0.5,
"herausgeschnitten:\n$1 < x_2 < 2$\nkein ganzzahliger "
"Punkt darin", fontsize=8, color=FARBEN["fehler"],
ha="right", va="center", zorder=7)
achsen[1].set_title("nach dem Verzweigen über $x_2$", fontsize=10.5,
color=FARBEN["text"])
figur.suptitle(f"Verzweigen schneidet weg, wo kein ganzzahliger Punkt "
f"liegt — die Schranke sinkt von {zahl(z_p0)} auf "
f"{zahl(max(loesungen['A'][1], loesungen['B'][1]))}",
fontsize=10.5, color=FARBEN["text"], y=1.02)
speichere(figur, "kap_milp_bnb_geometrie")
if __name__ == "__main__":
unendlich = float("inf")
loesungen = {
"P0": loese([0.0, 0.0], [unendlich, unendlich]),
"A": loese([0.0, 0.0], [unendlich, GRENZE]),
"B": loese([0.0, GRENZE + 1], [unendlich, unendlich]),
}
punkte = ganzzahlige_punkte()
for name, (x, z) in loesungen.items():
print(f" {name:<3} x = ({x[0]:.4f}; {x[1]:.4f}) Z = {z:.4f}")
print(f" {len(punkte)} ganzzahlige zulässige Punkte")
pruefe_befund(loesungen, punkte)
print(" Streifen 1 < x₂ < 2 enthält keinen ganzzahligen Punkt")
print(" Zielwerte stimmen mit dem Suchbaum überein")
zeichne(loesungen, punkte)