homemaker-layout/experiments/warm_vs_cold.py

152 lines
5.5 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Warm vs cold inner-loop starts under topology mutation (homemaker-py-8cs).
Lamarckian inheritance (DESIGN.md §5 decision 6): a child topology's inner
loop warm-starts from the parent's optimised ratios — cuts that survive the
mutation keep their values, new cuts get 0.5. This experiment measures the
speedup against a cold start (all cuts 0.5) at equal budget:
for each corpus design: optimise geometry (parent optimum), then apply
single top-storey topology mutations (divide a leaf / undivide a leaf-pair
branch); re-optimise each child warm and cold; compare oracle evaluations
needed to reach 95% of the better final fitness, and the finals themselves.
Run under the go-forward fitness:
URB_NO_OCCLUSION=1 python3 experiments/warm_vs_cold.py [parent_budget child_budget]
"""
from __future__ import annotations
import copy
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker import dom, innerloop, solver # noqa: E402
URB = Path("/home/bruno/src/urb")
EX = URB / "examples" / "programme-house"
DESIGNS = [
"2f45907abd9accac2a124d311732f749.dom",
"candidate-002.dom",
"c964435454c459f86c3ed9a5a7621132.dom",
]
N_DIVIDE = 2 # mutations of each kind per design
N_UNDIVIDE = 2
TARGET_FRACTION = 0.95
free_with_keys = innerloop.free_with_keys # promoted into the library
def divide_leaf(leaf: dom.Node) -> None:
leaf.division = [0.5, 0.5]
leaf.left = dom.Node(type=leaf.type)
leaf.right = dom.Node(type="C") # circulation absorbs the residual
leaf.type = None
def undivide_branch(branch: dom.Node) -> None:
# keep the more programme-specific child type (generic = circulation/outside)
types = [branch.left.type, branch.right.type]
specific = [t for t in types if t and t[0].lower() not in "cos"]
branch.type = specific[0] if specific else types[0]
branch.division = None
branch.left = branch.right = None
def mutations(root: dom.Node) -> list[tuple[str, dom.Node]]:
"""Mutated deep copies of ``root`` (top storey only, so Below-inheritance
from lower storeys is never invalidated)."""
out = []
top_idx = len(dom.levels(root)) - 1
top = dom.levels(root)[top_idx]
leaf_ids = [leaf.id for leaf in top.leaves()][:N_DIVIDE]
branch_ids = [b.id for b in solver._branches(top)
if not b.left.divided and not b.right.divided][:N_UNDIVIDE]
for lid in leaf_ids:
child = copy.deepcopy(root)
divide_leaf(dom.levels(child)[top_idx].by_id(lid))
dom._link(child)
out.append((f"divide {top_idx}/{lid or 'root'}", child))
for bid in branch_ids:
child = copy.deepcopy(root)
undivide_branch(dom.levels(child)[top_idx].by_id(bid))
dom._link(child)
out.append((f"undivide {top_idx}/{bid or 'root'}", child))
return out
def optimise_traced(root: dom.Node, x0: np.ndarray, budget: int) -> tuple[innerloop.Result, list[float]]:
"""cma_search with a per-evaluation best-so-far trace."""
history: list[float] = []
with innerloop.OracleEvaluator(root, EX, URB) as ev:
inner_evaluate = ev.evaluate
def traced(xs):
scores = inner_evaluate(xs)
history.extend(s.fitness for s in scores)
return scores
ev.evaluate = traced
r = innerloop.cma_search(ev, x0, budget=budget)
ev.apply(r.x)
return r, history
def evals_to(history: list[float], target: float) -> int | None:
best = -np.inf
for i, f in enumerate(history):
best = max(best, f)
if best >= target:
return i + 1
return None
def main() -> int:
parent_budget = int(sys.argv[1]) if len(sys.argv) > 1 else 400
child_budget = int(sys.argv[2]) if len(sys.argv) > 2 else 200
speedups = []
for name in DESIGNS:
root = dom.load(str(EX / name))
with innerloop.OracleEvaluator(root, EX, URB) as ev:
x0 = ev.x_current
parent, _ = optimise_traced(root, x0, parent_budget)
parent_map = {k: b.division[0] for k, b in free_with_keys(root)}
print(f"\n{name}: parent optimum {parent.fitness:.6g} "
f"(fails {parent.n_fails}, dof {len(parent.x)})", flush=True)
for label, child in mutations(root):
keys = free_with_keys(child)
x_warm = np.array([parent_map.get(k, 0.5) for k, _ in keys])
x_cold = np.full(len(keys), 0.5)
surviving = sum(k in parent_map for k, _ in keys)
r_warm, h_warm = optimise_traced(copy.deepcopy(child), x_warm, child_budget)
r_cold, h_cold = optimise_traced(copy.deepcopy(child), x_cold, child_budget)
target = TARGET_FRACTION * max(r_warm.fitness, r_cold.fitness)
e_warm, e_cold = evals_to(h_warm, target), evals_to(h_cold, target)
if e_warm is not None and e_cold is not None:
speedups.append(e_cold / e_warm)
ratio = f"x{e_cold / e_warm:.1f}" if e_warm and e_cold else "n/a"
print(f" {label:22s} dof {len(keys):2d} ({surviving} inherited) "
f"warm: {r_warm.fitness:.6g} in {e_warm} evals "
f"cold: {r_cold.fitness:.6g} in {e_cold} evals "
f"speedup {ratio}", flush=True)
if speedups:
print(f"\nspeedup (evals to {TARGET_FRACTION:.0%} of best final): "
f"median x{np.median(speedups):.1f}, "
f"range x{min(speedups):.1f}-x{max(speedups):.1f}, "
f"n={len(speedups)}")
return 0
if __name__ == "__main__":
sys.exit(main())