File size: 20,255 Bytes
6628826 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 | """Train the Stringman SkyJEPA (cablepilot/model/learned.py) on task-free data, then test 1: open-loop prediction.
(armpilot's scripts/train_arm_wm.py on the cable robot.)
python scripts/train_cdpr_wm.py --data data/cdpr_v0_playroom --out checkpoints/cdpr_wm_v0.pt
python scripts/train_cdpr_wm.py --eval-only --out checkpoints/cdpr_wm_v0.pt
Stage 1 (task-free JEPA): predict the next 2 s of state latents from 1 s of history and the commands,
loss = MSE(pred, target) + 0.02 SIGReg(pred), as SkyJEPA. Stage 2: the prober on frozen latents, loss = weighted MSE of
the integrated states (normalised) against the recorded ones, the end of the horizon weighted most. The prober's last
layer starts at zero: training starts exactly at the hand-written backbone and learns only the correction.
Test 1 (docs/MILESTONES.md M1, the bar set before training): on held-out windows, after 20 steps (2.0 s): grasp point
error <= 2 cm median (also on payload windows), pole tilt error <= 2 deg median; next to the nominal oracle (MuJoCo, no
eta, no payload) and the backbone alone (nothing learned).
"""
from __future__ import annotations
import argparse
import json
import math
import sys
import time
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from cablepilot.model.learned import JEPA, Normalizer, Prober, features, integrate # noqa: E402
H, L = 10, 20
L_SWING_DATA = 0.3089 # the data's pendulum (fw, Stringman hand); load() sets it from the manifest's hand
D_G = -0.5147 # grasp point down the pole; load() sets it from the data's manifest (SO-101 hand: -0.472)
# the machine the data came from (manifest "machine"; Stringman when absent) and its backbone priors: the person lift's
# swing runs over a 2.4x range of lengths (empty harness .. a person), its spools ramp at 0.8 m/s^2 (P1, 2026-10-12)
MACHINE, OMEGA_SPAN, A_MAX0 = "configs/stringman.yaml", 0.3, 0.25
# Stage-2 error scales per state dim, in METRES / radians: an error of one scale costs 1. In normalised units (v0) a 5 cm
# position error cost ~0.003 next to the swing and rate terms, and the grasp-point error at 2 s ROSE during stage 2
# (53 -> 68 mm) while the loss fell; near-still windows came out at 80 mm against 33 mm for "integrate the command"
# (2026-10-08). p 2 cm, v 5 cm/s, s 0.01 (~0.6 deg), s_dot 0.1 /s, wrist 0.1 rad, wrist rate 0.5, closure 0.05, its rate 0.5.
E_SCALE = torch.tensor([0.02, 0.02, 0.02, 0.05, 0.05, 0.05, 0.01, 0.01, 0.1, 0.1, 0.1, 0.5, 0.05, 0.5])
class SIGReg(nn.Module):
"""LeJEPA's Sketched Isotropic Gaussian Regularisation (copied from armpilot, from LeDrone/skyjepa)."""
def __init__(self, n_directions=64, n_knots=17):
super().__init__()
nodes, weights = np.polynomial.hermite.hermgauss(n_knots)
self.n = n_directions
self.register_buffer("nodes", torch.tensor(nodes, dtype=torch.float32))
self.register_buffer("weights", torch.tensor(weights, dtype=torch.float32))
self.register_buffer("gauss_cf", torch.tensor(np.exp(-nodes ** 2 / 2.0), dtype=torch.float32))
def forward(self, lat): # (T, B, D)
T, B, D = lat.shape
u = torch.randn(self.n, D)
u = u / (u.norm(dim=1, keepdim=True) + 1e-8)
stat = lat.new_zeros(())
for t in range(T):
arg = (lat[t] @ u.t()).unsqueeze(-1) * self.nodes.view(1, 1, -1)
C, S = torch.cos(arg).mean(0), torch.sin(arg).mean(0)
stat = stat + B * (((C - self.gauss_cf) ** 2 + S ** 2) * self.weights).sum(1).mean()
return stat / T
def load(data):
"""One dataset directory, or several joined with commas: (X, A, P, M, R, dt). M (N, 4, 3) mounts and R (N, 4) runs
per trajectory (shards from a fixed room without them: the scenario's)."""
dirs = [Path(d) for d in str(data).split(",")]
parts = []
for d in dirs:
shards = sorted(d.glob("shard_*.npz"))
X, A, P, M, R = [], [], [], [], []
man = json.loads((d / "manifest.json").read_text())
global D_G, L_SWING_DATA, MACHINE, OMEGA_SPAN, A_MAX0
D_G = float(man.get("dG", D_G))
from cablepilot.sim.scene import load_config as _lc
if man.get("machine"): # another machine (P1: the person lift): its own pendulum and spools
MACHINE = man["machine"]
mc = _lc(machine=MACHINE)
L_SWING_DATA = float(mc["body"]["swing_length_m"])
A_MAX0 = float(mc["spool"]["line_accel_mps2"])
OMEGA_SPAN = 1.0 if mc.get("hand") == "harness" else 0.3
elif man.get("hand"): # the hand's pendulum, to start the backbone's swing from (Prober l_swing)
L_SWING_DATA = float(_lc(hand=man["hand"])["body"]["swing_length_m"])
for f in shards:
z = np.load(f)
X.append(z["states"])
A.append(z["actions"])
P.append(z["payload"])
if "mounts" in z:
M.append(z["mounts"])
R.append(z["runs"])
else:
from cablepilot.sim.scene import load_config, mounts
c = load_config(machine=MACHINE, scenario=man["scenario"])
M.append(np.repeat(mounts(c)[None].astype(np.float32), len(z["states"]), 0))
R.append(np.repeat(np.asarray(c["room"]["indirect_run_m"], np.float32)[None], len(z["states"]), 0))
parts.append([np.concatenate(v) for v in (X, A, P, M, R)] + [float(np.load(shards[0])["dt"])])
if len(parts) == 1:
return tuple(parts[0])
k = min(len(p[0]) for p in parts) # interleave so that the held-out tail holds trajectories of every set
return tuple(np.stack([p[j][:k] for p in parts], 1).reshape(-1, *parts[0][j].shape[1:]) for j in range(5)) + (parts[0][5],)
def windows(X, A, idx, rng, B, M, P=None):
i = rng.choice(idx, B)
t = rng.integers(0, A.shape[1] - (H + L) + 1, B)
ar = np.arange(H + 1 + L)
out = [torch.as_tensor(X[i[:, None], t[:, None] + ar]), torch.as_tensor(A[i[:, None], t[:, None] + ar[:-1]]),
torch.as_tensor(M[i])]
if P is not None:
out += [P[i[:, None], t[:, None] + ar], i]
return out
def lr_at(step, total, peak, final=1e-4, warm=200):
if step < warm:
return peak * step / warm
p = (step - warm) / max(1, total - warm)
return final + (peak - final) * 0.5 * (1 + math.cos(math.pi * p))
def grasp_point(x):
s = x[..., 6:8]
z = np.sqrt(np.clip(1 - (s ** 2).sum(-1, keepdims=True), 1e-6, None))
return x[..., 0:3] + D_G * np.concatenate([s, z], -1)
def train(args):
X, A, P, M, R, dt = load(args.data)
n = len(X)
rng = np.random.default_rng(0)
test_idx, train_idx = np.arange(n - args.test, n), np.arange(0, n - args.test)
with torch.no_grad():
sub = features(torch.as_tensor(X[train_idx[:2000], ::5]), torch.as_tensor(M[train_idx[:2000]])[:, None]).reshape(-1, 28).numpy()
asub = A[train_idx[:2000]].reshape(-1, 5)
norm = Normalizer(sub.mean(0), sub.std(0) + 1e-3, asub.mean(0), asub.std(0) + 1e-3)
cfg = {"latent": 24, "action_latent": 12, "hidden": 48, "prober_hidden": 64, "H": H, "L": L, "dt": dt,
"data": str(args.data), "trajectories_train": int(len(train_idx)), "l_swing": L_SWING_DATA,
"omega_span": OMEGA_SPAN, "a_max0": A_MAX0, "machine": MACHINE}
torch.manual_seed(0)
jepa, prober, sig = JEPA(cfg["latent"], cfg["action_latent"], cfg["hidden"]), Prober(cfg["latent"], cfg["prober_hidden"], cfg["l_swing"], cfg["omega_span"], cfg["a_max0"]), SIGReg()
n_j, n_p = sum(p.numel() for p in jepa.parameters()), sum(p.numel() for p in prober.parameters())
print(f"{n} trajectories ({len(train_idx)} train, {len(test_idx)} test), dt {dt * 1000:.0f} ms; "
f"JEPA {n_j} params, prober {n_p}", flush=True)
t0 = time.perf_counter()
opt = torch.optim.Adam(jepa.parameters(), lr=args.lr, weight_decay=1e-5)
if args.stage1_from: # reuse stage 1 (frozen latents) from an earlier checkpoint
old = torch.load(args.stage1_from, weights_only=False)
jepa.load_state_dict(old["jepa"])
norm = Normalizer(**old["norm"])
print(f"stage 1 from {args.stage1_from}", flush=True)
for step in range(0 if args.stage1_from else args.steps1):
for g in opt.param_groups:
g["lr"] = lr_at(step, args.steps1, args.lr)
xs, acts, Mw = windows(X, A, train_idx, rng, args.batch, M)
pred, tgt = jepa(norm.s(features(xs, Mw[:, None])), norm.a(acts), H)
l_pred = ((pred - tgt) ** 2).mean()
l_sig = sig(pred.transpose(0, 1)[::4])
loss = l_pred + 0.02 * l_sig
opt.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(jepa.parameters(), 0.5)
opt.step()
if step % 250 == 0 or step == args.steps1 - 1:
std = pred.detach().reshape(-1, pred.shape[-1]).std(0).min().item()
print(f" stage 1 step {step:5d} L_pred {l_pred.item():.4f} L_sig {l_sig.item():.3f} "
f"latent min std {std:.3f} {time.perf_counter() - t0:.0f} s", flush=True)
t1 = time.perf_counter() - t0
jepa.eval()
for p in jepa.parameters():
p.requires_grad_(False)
opt = torch.optim.Adam(prober.parameters(), lr=args.lr2, weight_decay=1e-5)
tw = torch.linspace(0.3, 1.0, L)[None, :, None]
for step in range(args.steps2):
for g in opt.param_groups:
g["lr"] = lr_at(step, args.steps2, args.lr2)
xs, acts, Mw = windows(X, A, train_idx, rng, args.batch, M)
with torch.no_grad():
pred, _ = jepa(norm.s(features(xs, Mw[:, None])), norm.a(acts), H)
out = integrate(xs[:, H], acts[:, H:], acts[:, H - 1], pred, prober, dt, norm, Mw)
err = nn.functional.huber_loss((out - xs[:, H + 1:]) / E_SCALE, torch.zeros_like(out), reduction="none", delta=3.0)
loss = (err * tw).mean()
opt.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(prober.parameters(), 0.5)
opt.step()
if step % 250 == 0 or step == args.steps2 - 1:
g_err = np.linalg.norm(grasp_point(out[:, -1].detach().numpy()) - grasp_point(xs[:, -1].numpy()), axis=-1)
print(f" stage 2 step {step:5d} loss {loss.item():.4f} grasp-point error at 2 s median "
f"{1000 * np.median(g_err):.1f} mm {time.perf_counter() - t0:.0f} s", flush=True)
ckpt = {"config": cfg, "jepa": jepa.state_dict(), "prober": prober.state_dict(), "norm": norm.state_dict(),
"train_s": time.perf_counter() - t0, "stage1_s": t1, "params": {"jepa": n_j, "prober": n_p}}
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
torch.save(ckpt, args.out)
print(f"saved {args.out} ({time.perf_counter() - t0:.0f} s)")
return X, A, P, M, R, test_idx
def tilt_angle_err(a, b):
def axis(x):
s = x[..., 6:8]
return np.concatenate([s, np.sqrt(np.clip(1 - (s ** 2).sum(-1, keepdims=True), 1e-6, None))], -1)
c = np.clip((axis(a) * axis(b)).sum(-1), -1, 1)
return np.degrees(np.arccos(c))
def test1(args, data=None, test_idx=None):
from cablepilot.model.learned import LearnedCDPRModel
from cablepilot.model.oracle import OracleWorldModel
from cablepilot.sim.scene import load_config
if data is None:
data = load(args.data)
test_idx = np.arange(len(data[0]) - args.test, len(data[0]))
X, A, P, M, R, dt = data
manifest = json.loads((Path(str(args.data).split(",")[0]) / "manifest.json").read_text())
cfg = load_config(machine=manifest.get("machine", "configs/stringman.yaml"), scenario=manifest["scenario"],
hand=manifest.get("hand"))
person = cfg.get("hand") == "harness"
ck = torch.load(args.out, weights_only=False)
model = LearnedCDPRModel(ck, dt=ck["config"]["dt"])
rng = np.random.default_rng(123)
xs, acts, Mw, pay, traj = windows(X, A, test_idx, rng, args.n_eval, M, P)
xs, acts = xs.numpy(), acts.numpy()
payload_w = (pay > 1e-3).any(1)
event_w = np.ptp(pay, axis=1) > 1.0 # the person's weight coming on / going off / shifting
t0 = time.perf_counter()
learned = []
for b in range(len(xs)):
model.set_room(M[traj[b]])
learned.append(model.predict(xs[b, :H + 1], acts[b, :H], acts[b:b + 1, H:]).numpy())
learned = np.concatenate(learned)
t_l = (time.perf_counter() - t0) / len(xs)
# the person lift's stiff lines need the 2 ms step; its oracle is told 75 kg (the range's middle), never the truth
orc = OracleWorldModel(cfg, dt=ck["config"]["dt"], timestep=0.002 if person else 0.01, nthread=4,
payload_kg=75.0 if person else 0.0)
t0 = time.perf_counter()
oracle, last = [], None
for b in range(len(xs)):
j = traj[b]
if last is None or not np.allclose(M[j], last): # the oracle in this window's room
orc.set_room({"mounts_xyz": M[j].tolist(), "indirect_run_m": R[j].tolist()})
last = M[j]
oracle.append(orc.rollout(xs[b, H][None], acts[b, H - 1][None], acts[b:b + 1, H:]).numpy())
oracle = np.concatenate(oracle)
t_o = (time.perf_counter() - t0) / len(xs)
fresh = Prober(ck["config"]["latent"], ck["config"]["prober_hidden"], ck["config"].get("l_swing", 0.3089),
ck["config"].get("omega_span", 0.3), ck["config"].get("a_max0", 0.25)) # the hand-written backbone alone
with torch.no_grad():
bb = integrate(torch.as_tensor(xs[:, H]), torch.as_tensor(acts[:, H:]), torch.as_tensor(acts[:, H - 1]),
torch.zeros(len(xs), L, ck["config"]["latent"]), fresh, ck["config"]["dt"], model.norm,
torch.as_tensor(M[traj])).numpy()
truth = xs[:, -1]
res = {}
for name, pr in (("learned", learned[:, -1]), ("oracle", oracle[:, -1]), ("backbone", bb[:, -1])):
e = np.linalg.norm(grasp_point(pr) - grasp_point(truth), axis=-1) * 1000
ea = tilt_angle_err(pr, truth)
eh = np.linalg.norm(pr[:, 0:3] - truth[:, 0:3], axis=-1) * 1000
res[name] = {"g_med_mm": float(np.median(e)), "g_p90_mm": float(np.percentile(e, 90)),
"g_payload_med_mm": float(np.median(e[payload_w])), "tilt_med_deg": float(np.median(ea)),
"tilt_p90_deg": float(np.percentile(ea, 90)),
"hook_med_mm": float(np.median(eh)), "hook_event_med_mm": float(np.median(eh[event_w])) if event_w.any() else None,
"tilt_event_med_deg": float(np.median(ea[event_w])) if event_w.any() else None}
print(f"\nTEST 1 路 {args.data} 路 open loop, {len(xs)} held-out windows ({payload_w.sum()} with payload on or changing), "
f"{len(set(map(tuple, M[traj].reshape(len(traj), -1).round(3))))} rooms, after {L} steps ({L * ck['config']['dt']:.1f} s)")
print("(backbone = hand-written physics alone, nothing learned; oracle = MuJoCo in the same room, nominal physics, no payload)")
print(f"{'model':9s} {'grasp pt median':>15s} {'p90':>8s} {'payload median':>15s} {'tilt median':>12s} {'p90':>7s}")
for k, r in res.items():
print(f"{k:9s} {r['g_med_mm']:12.1f} mm {r['g_p90_mm']:5.1f} mm {r['g_payload_med_mm']:12.1f} mm "
f"{r['tilt_med_deg']:9.2f} deg {r['tilt_p90_deg']:4.2f}")
r = res["learned"]
if person: # P1 (docs/MILESTONES.md): hook <= 20 mm, also on attach / set-down windows; swing <= 1.5 deg
f1 = lambda v: "n/a" if v is None else f"{v:.1f}"
print(f"\nP1 路 {event_w.sum()} windows where the person's weight changes. At 2 s:")
for k, v in res.items():
print(f" {k:9s} hook {v['hook_med_mm']:.1f} mm (events {f1(v['hook_event_med_mm'])}), swing "
f"{v['tilt_med_deg']:.2f} deg (events {f1(v['tilt_event_med_deg'])}), person COM {v['g_med_mm']:.1f} mm")
ok = (r["hook_med_mm"] <= 20 and (r["hook_event_med_mm"] is None or r["hook_event_med_mm"] <= 20)
and r["tilt_med_deg"] <= 1.5)
print(f"P1 model bar (hook <= 20 mm, also on events; swing <= 1.5 deg): {'YES' if ok else 'NO'}")
res["weigh"] = weigh_test(args, cfg, test_idx)
else:
ok = r["g_med_mm"] <= 20 and r["g_payload_med_mm"] <= 20 and r["tilt_med_deg"] <= 2
print(f"pass (<= 20 mm, <= 20 mm on payload, <= 2 deg): {'YES' if ok else 'NO'}")
print(f"learned {t_l * 1000:.1f} ms per window (S = 1), oracle {t_o * 1000:.1f} ms")
Path(args.out).with_suffix(f".test1.{Path(str(args.data).split(',')[0]).name}.json").write_text(json.dumps(res, indent=1))
def weigh_test(args, cfg, test_idx):
"""P1's weighing bar: 1 s after every attach ends in the held-out trajectories, the lines' weighing
(perception/weigh.py: mean vertical pull over that second / g - the rig's nominal mass) within 10 % of the person."""
from cablepilot.perception.weigh import rig_mass, weigh_kg
shards = sorted(Path(str(args.data).split(",")[0]).glob("shard_*.npz"))
S, TEN, PAY, MO = [], [], [], []
for f in shards:
z = np.load(f)
S.append(z["states"]), TEN.append(z["tensions"]), PAY.append(z["payload"]), MO.append(z["mounts"])
S, TEN, PAY, MO = (np.concatenate(v) for v in (S, TEN, PAY, MO))
rig, errs = rig_mass(cfg), []
for j in test_idx:
p = PAY[j]
k = 1
while k < len(p):
if p[k - 1] < 1.0 <= p[k]: # an attach starts
e = k
while e + 1 < len(p) and abs(p[e + 1] - p[e]) > 1e-3:
e += 1 # ... and ends at e
# an attach that ENDS with a person on (2026-10-12: one attach in the held-out set was cut off by a
# set-down back to 0 kg, and the first run divided by that 0)
if e + 10 < len(p) and p[e] >= 1.0 and abs(p[e + 10] - p[e]) < 1e-3:
w = slice(e + 1, e + 11) # the second after it ends
est = weigh_kg(TEN[j, w], S[j, w, 0:3], MO[j], rig)
errs.append(abs(est - p[e + 10]) / p[e + 10])
k = e + 1
k += 1
errs = np.array(errs) * 100
if not len(errs):
print("weighing: no complete attach in the held-out set")
return None
r = {"attaches": int(len(errs)), "median_pct": float(np.median(errs)), "p90_pct": float(np.percentile(errs, 90)),
"max_pct": float(errs.max()), "within_10pct": float((errs <= 10).mean())}
print(f"P1 weighing bar (within 10 % 1 s after an attach ends): {r['attaches']} attaches, median {r['median_pct']:.1f} %, "
f"p90 {r['p90_pct']:.1f} %, max {r['max_pct']:.1f} %, within 10 %: {100 * r['within_10pct']:.0f} % "
f"{'YES' if r['within_10pct'] == 1.0 else 'NO'}")
return r
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--data", default=str(ROOT / "data/cdpr_v0_playroom"))
ap.add_argument("--out", default=str(ROOT / "checkpoints/cdpr_wm_v0.pt"))
ap.add_argument("--steps1", type=int, default=3000)
ap.add_argument("--steps2", type=int, default=3000)
ap.add_argument("--batch", type=int, default=256)
ap.add_argument("--lr", type=float, default=3e-3)
ap.add_argument("--test", type=int, default=500, help="last N trajectories held out")
ap.add_argument("--n-eval", type=int, default=500)
ap.add_argument("--test-all", action="store_true", help="eval-only: every trajectory of --data is held out (an unseen room)")
ap.add_argument("--eval-only", action="store_true")
ap.add_argument("--stage1-from", default=None, help="checkpoint whose stage 1 (JEPA, normaliser) is reused")
ap.add_argument("--lr2", type=float, default=1e-3)
args = ap.parse_args()
if args.eval_only:
if args.test_all:
d = load(args.data)
return test1(args, d, np.arange(len(d[0])))
return test1(args)
X, A, P, M, R, test_idx = train(args)
test1(args, (X, A, P, M, R, float(np.load(sorted(Path(str(args.data).split(",")[0]).glob("shard_*.npz"))[0])["dt"])), test_idx)
if __name__ == "__main__":
main()
|