Leplanner / code /scripts /diagnostics.py
nottygian's picture
Push scripts
dc9f917 verified
Raw
History Blame Contribute Delete
9.38 kB
"""Diagnostics for horizon-reset procrastination and the K=5 regression.
Two questions, both answered in the world model's latent space against the
same held-out latents used for validation:
1. Under receding-horizon execution, does the closed loop have a fixed point
outside the success radius? Fitting ``D_{n+1} = c*D_n + b`` gives
``D* = b/(1-c)`` when ``0 < c < 1``. A positive intercept means each
replan removes a *fraction* of the remaining distance but adds a floor, so
the loop stalls short of the goal no matter how long it runs.
2. Does refinement keep improving predicted cost past the trained depth K=3
even as real success degrades? If predicted cost improves while success
drops, the extra refinements are exploiting the world model rather than
planning better.
Question 2 originally measured only the *imagined* side, which cannot separate
"the plan got better" from "the plan left the region where the world model is
trustworthy" — both look like falling predicted cost. The support score
``r(C, b) = -log beta(b | C) / A`` against the fitted GMM answers that directly:
if the violation fraction rises with k while predicted cost falls, the extra
refinements are buying imagined progress with out-of-distribution actions.
"""
import argparse
import json
import sys
from pathlib import Path
import numpy as np
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from lejepa_control.data import LatentGoalDataset, split_episodes # noqa: E402
from lejepa_control.losses import BehaviorDensity # noqa: E402
from lejepa_control.rollout import goal_distance, rollout_plan # noqa: E402
from lejepa_control.solver import load_controller # noqa: E402
from lejepa_control.world_model import load_lewm # noqa: E402
def parse_args():
p = argparse.ArgumentParser()
p.add_argument('--controller', default='data/runs/controller/controller.pt')
p.add_argument('--latents', default='data/latents')
p.add_argument('--density', default='data/runs/density/density.pt')
p.add_argument('--samples', type=int, default=256)
p.add_argument('--horizon', type=int, default=5)
p.add_argument('--replans', type=int, default=12)
p.add_argument('--max-k', type=int, default=8)
p.add_argument('--tag', default='main')
p.add_argument('--out', default='data/runs/diagnostics')
return p.parse_args()
@torch.no_grad()
def closed_loop_trace(controller, model, batch, device, execute, replans):
"""Latent goal distance after each replan, simulating in latent space.
The world model is its own simulator here: whatever the predictor says
the executed blocks caused becomes the next context. That is exactly the
loop the real MPC runs, minus the env, so it isolates the planner's
dynamics from simulator mismatch.
"""
ctx = batch['context'].to(device)
past = batch['past_actions'].to(device)
goal = batch['goal'].to(device)
num_context = controller.num_context
trace = []
for _ in range(replans):
out = controller(model, ctx, past, goal)
plan = out['plans'][-1]
# execute the first `execute` blocks and advance the latent state
pred, frames = rollout_plan(
model, ctx, past, plan[:, :execute], return_frames=True
)
trace.append(goal_distance(pred, goal)[:, execute - 1].cpu().numpy())
ctx = frames[:, -num_context:]
past = torch.cat([past, plan[:, :execute]], dim=1)[:, -(num_context - 1):]
return np.stack(trace, axis=1) # (B, replans)
def fit_contraction(trace):
"""Least-squares ``D_{n+1} = c*D_n + b`` over all consecutive pairs."""
x = trace[:, :-1].ravel()
y = trace[:, 1:].ravel()
A = np.stack([x, np.ones_like(x)], axis=1)
(c, b), *_ = np.linalg.lstsq(A, y, rcond=None)
resid = y - (c * x + b)
ss = 1 - resid.var() / y.var() if y.var() > 0 else float('nan')
fixed = b / (1 - c) if abs(1 - c) > 1e-9 else float('inf')
return float(c), float(b), float(fixed), float(ss)
@torch.no_grad()
def refinement_trace(controller, model, batch, device, max_k,
density=None, c95=None):
"""Predicted goal cost after each refinement, out to ``max_k``.
Also returns the support-violation fraction per refinement when a density
model is supplied — the imagined cost and the violation fraction moving in
opposite directions is the exploitation signature.
"""
saved = controller.refinements
try:
controller.refinements = max_k
out = controller(
model,
batch['context'].to(device),
batch['past_actions'].to(device),
batch['goal'].to(device),
)
q = batch['goal_offset'].to(device).clamp(1, controller.horizon)
costs, arrivals, moves, violations, scores = [], [], [], [], []
prev = None
for k, d in enumerate(out['distances']):
costs.append(float(d[:, -1].mean()))
arrivals.append(
float(d.gather(1, (q - 1).unsqueeze(1)).squeeze(1).mean())
)
plan = out['plans'][k]
moves.append(
0.0 if prev is None else float((plan - prev).abs().mean())
)
prev = plan
if density is not None and c95 is not None:
# score every block of the plan at the latent history it is
# actually proposed from, which is what the support term does
ctx = out['contexts'][k].flatten(0, 1) # (B*H, N, D)
score = density.nll_per_dim(ctx, plan.flatten(0, 1))
violations.append(float((score > c95).float().mean()))
scores.append(float(score.mean()))
return costs, arrivals, moves, violations, scores
finally:
controller.refinements = saved
def main():
args = parse_args()
device = 'cuda' if torch.cuda.is_available() else 'cpu'
torch.manual_seed(0)
stats = json.loads((Path(args.latents) / 'stats.json').read_text())
model = load_lewm(device=device)
controller, ckpt = load_controller(
args.controller, latent_dim=stats['latent_dim'], device=device
)
controller.eval()
print(f'controller step {ckpt["step"]}, K={controller.refinements}')
_, val_eps = split_episodes(stats['n_episodes'])
val = LatentGoalDataset(
args.latents, max_offset=5, episodes=val_eps, horizon=args.horizon
)
idx = np.random.default_rng(0).choice(len(val), args.samples, replace=False)
batch = {
k: torch.stack([val[int(i)][k] for i in idx])
for k in ('context', 'past_actions', 'goal', 'goal_offset')
}
density, c95 = None, None
if Path(args.density).exists():
d_ckpt = torch.load(args.density, map_location=device, weights_only=False)
density = BehaviorDensity(
latent_dim=d_ckpt['latent_dim'], components=d_ckpt['components']
)
density.load_state_dict(d_ckpt['state_dict'])
density.to(device).eval().requires_grad_(False)
c95 = float(d_ckpt['c95'])
print(f'support model loaded, c95={c95:.4f}')
else:
print(f'no support model at {args.density} — violation column disabled')
report = {'tag': args.tag, 'checkpoint': args.controller,
'step': int(ckpt['step'])}
print('\n=== 1. closed-loop contraction ===')
print(f'{"execute":>8}{"D_0":>9}{"D_final":>10}{"c":>8}{"b":>9}'
f'{"D*":>9}{"R^2":>7}')
report['contraction'] = {}
for execute in (1, 5):
trace = closed_loop_trace(
controller, model, batch, device, execute, args.replans
)
c, b, fixed, r2 = fit_contraction(trace)
m = trace.mean(axis=0)
print(f'{execute:>8}{m[0]:>9.4f}{m[-1]:>10.4f}'
f'{c:>8.4f}{b:>9.4f}{fixed:>9.4f}{r2:>7.3f}')
report['contraction'][f'exec{execute}'] = {
'c': c, 'b': b, 'fixed_point': fixed, 'r2': r2,
'mean_trace': [round(float(v), 5) for v in m],
}
print('\n=== 2. refinement past trained depth ===')
costs, arrivals, moves, violations, scores = refinement_trace(
controller, model, batch, device, args.max_k, density, c95
)
print(f'{"k":>3}{"terminal":>11}{"arrival":>10}{"dJ":>10}{"|plan chg|":>12}'
f'{"support r":>11}{"viol%":>8}')
for k in range(len(costs)):
dj = '' if k == 0 else f'{costs[k - 1] - costs[k]:+.5f}'
sup = f'{scores[k]:>11.4f}' if scores else f'{"-":>11}'
vio = f'{100 * violations[k]:>8.1f}' if violations else f'{"-":>8}'
print(f'{k:>3}{costs[k]:>11.5f}{arrivals[k]:>10.5f}{dj:>10}'
f'{moves[k]:>12.5f}{sup}{vio}')
report['refinement'] = {
'terminal': costs, 'arrival': arrivals, 'plan_change': moves,
'support_score': scores, 'violation': violations, 'c95': c95,
}
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
with (out / 'diagnostics.jsonl').open('a') as f:
f.write(json.dumps(report) + '\n')
print(f'\nwrote {out / "diagnostics.jsonl"}')
if __name__ == '__main__':
main()