Download code/scripts/train_decoder.py from nottygian/Leplanner: direct link, hf CLI and curl.
- Browser
- Download file 8.11 kB
-
https://huggingface.co/nottygian/Leplanner/resolve/main/code/scripts/train_decoder.py
- Command line
-
hf download hf://nottygian/Leplanner/code/scripts/train_decoder.py
-
curl -L -o train_decoder.py https://huggingface.co/nottygian/Leplanner/resolve/main/code/scripts/train_decoder.py
8.11 kB
| """Train the latent->pixel decoder (LeWM appendix D), for visualization only. | |
| python tools/make_decoder_cache.py --frames 8000 | |
| python scripts/train_decoder.py --steps 2000 | |
| The world model is **not** touched: the decoder reads cached latents, so no | |
| gradient can reach the encoder even by accident. That matches how LeWM used it | |
| (Fig. 8 is a read-only probe) and matters, because the paper's own appendix G | |
| ablation shows that letting a reconstruction loss into LeWM training *hurts* | |
| control — PushT success 96.0 ± 2.83 without it, 86.0 ± 7.54 with it. | |
| Presets pick the speed/detail trade-off; all were measured on this CPU box at | |
| batch 32: | |
| paper 224px, 16px patches, W384 d4 7.6M params ~6.0 s/step | |
| cpu 224px, 16px patches, W256 d3 2.7M params ~1.4 s/step (default) | |
| fast 224px, 28px patches, W256 d3 3.0M params ~0.5 s/step | |
| ``fast`` keeps the full 224 output but tiles it 8x8, so a 10px pusher lands | |
| inside one patch — fine for "where is the block", poor for fine pose. ``cpu`` | |
| tiles 14x14 and is the recommended default here. | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import sys | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| REPO = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(REPO)) | |
| os.environ.setdefault('STABLEWM_HOME', str(REPO / 'data' / 'swm_home')) | |
| from lejepa_control.decoder import LatentDecoder, reconstruction_loss # noqa: E402 | |
| from tools.paths import artifact_dir # noqa: E402 | |
| PRESETS = { | |
| 'paper': dict(patch_size=16, hidden_dim=384, depth=4, heads=6), | |
| 'cpu': dict(patch_size=16, hidden_dim=256, depth=3, heads=4), | |
| 'fast': dict(patch_size=28, hidden_dim=256, depth=3, heads=4), | |
| } | |
| def parse_args(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument('--cache', default=None, | |
| help='default: $LEJEPA_DATA/decoder_cache, else data/decoder_cache') | |
| p.add_argument('--out', default='data/runs/decoder') | |
| p.add_argument('--preset', choices=list(PRESETS), default='cpu') | |
| p.add_argument('--steps', type=int, default=2000) | |
| p.add_argument('--batch-size', type=int, default=32) | |
| p.add_argument('--lr', type=float, default=3e-4) | |
| p.add_argument('--weight-decay', type=float, default=0.05) | |
| p.add_argument('--warmup', type=int, default=100) | |
| p.add_argument('--val-frac', type=float, default=0.05) | |
| p.add_argument('--log-every', type=int, default=50) | |
| p.add_argument('--preview-every', type=int, default=500) | |
| p.add_argument('--seed', type=int, default=0) | |
| return p.parse_args() | |
| def save_preview(decoder, z, target, path, device): | |
| """Side-by-side target/reconstruction strip — the thing you actually look at.""" | |
| from PIL import Image | |
| decoder.eval() | |
| with torch.no_grad(): | |
| pred = decoder(z.to(device)).clamp(0, 1).cpu() | |
| decoder.train() | |
| n = min(6, len(z)) | |
| rows = [] | |
| for tensor in (target[:n], pred[:n]): | |
| row = tensor.permute(0, 2, 3, 1).numpy() | |
| rows.append(np.concatenate(list(row), axis=1)) | |
| strip = (np.concatenate(rows, axis=0) * 255).astype(np.uint8) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| Image.fromarray(strip).save(path) | |
| def main(): | |
| args = parse_args() | |
| device = 'cuda' if torch.cuda.is_available() else 'cpu' | |
| torch.manual_seed(args.seed) | |
| cache = artifact_dir('decoder_cache', args.cache) | |
| meta = json.loads((cache / 'meta.json').read_text()) | |
| images = np.load(cache / 'images.npy', mmap_mode='r') # (N,3,H,W) uint8 | |
| latents = np.load(cache / 'latents.npy') # (N,192) fp16 | |
| assert len(images) == len(latents), 'cache is inconsistent' | |
| print(f'cache: {len(images)} pairs, latent={meta["latent_source"]}, ' | |
| f'source={meta["source"]}, image={meta["image_size"]}px') | |
| # Held-out split so the reported loss is not just memorisation. The decoder | |
| # has 2.7M params against 8k images and will happily overfit. | |
| rng = np.random.default_rng(args.seed) | |
| perm = rng.permutation(len(images)) | |
| n_val = max(1, int(len(images) * args.val_frac)) | |
| val_idx, train_idx = np.sort(perm[:n_val]), perm[n_val:] | |
| print(f' train {len(train_idx)} / val {len(val_idx)}') | |
| cfg = dict( | |
| latent_dim=int(meta['latent_dim']), | |
| image_size=int(meta['image_size']), | |
| **PRESETS[args.preset], | |
| ) | |
| decoder = LatentDecoder(**cfg) | |
| n_params = sum(p.numel() for p in decoder.parameters()) | |
| print(f'decoder[{args.preset}]: {n_params / 1e6:.2f}M params, ' | |
| f'P={decoder.num_patches} patches of {decoder.patch_size}px') | |
| def batch(idx_pool, size): | |
| idx = np.sort(rng.choice(idx_pool, size, replace=False)) | |
| z = torch.from_numpy(latents[idx].astype(np.float32)) | |
| x = torch.from_numpy(np.asarray(images[idx], dtype=np.float32) / 255.0) | |
| return z, x | |
| # Baseline: predicting the dataset mean image. Any decoder that does not | |
| # beat this has learned nothing about the latent. On PushT it is a strict | |
| # bar, not a weak one — the frames are mostly identical white background, | |
| # so a constant image is already a decent predictor and only the agent, | |
| # block and target carry error. | |
| mean_img = torch.from_numpy( | |
| np.asarray(images[train_idx[:1000]], dtype=np.float32).mean(0) / 255.0 | |
| ) | |
| zv, xv = batch(val_idx, min(64, len(val_idx))) | |
| baseline = float(((mean_img.unsqueeze(0) - xv) ** 2).mean()) | |
| print(f' mean-image baseline val MSE = {baseline:.5f}') | |
| decoder.init_output_at(mean_img.mean(dim=(1, 2))) | |
| decoder = decoder.to(device) | |
| print(f' output head parked on the mean colour ' | |
| f'{[round(float(v), 3) for v in mean_img.mean(dim=(1, 2))]}\n') | |
| # Built after the init and the device move so AdamW never sees stale params. | |
| opt = torch.optim.AdamW( | |
| decoder.parameters(), lr=args.lr, weight_decay=args.weight_decay | |
| ) | |
| sched = torch.optim.lr_scheduler.LambdaLR( | |
| opt, | |
| lambda s: min(1.0, (s + 1) / max(1, args.warmup)) | |
| * (0.5 * (1 + np.cos(np.pi * min(1.0, s / args.steps)))), | |
| ) | |
| out_dir = REPO / args.out | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| history = [] | |
| t0 = time.perf_counter() | |
| for step in range(1, args.steps + 1): | |
| z, x = batch(train_idx, args.batch_size) | |
| loss = reconstruction_loss(decoder(z.to(device)), x.to(device)) | |
| opt.zero_grad(set_to_none=True) | |
| loss.backward() | |
| torch.nn.utils.clip_grad_norm_(decoder.parameters(), 1.0) | |
| opt.step() | |
| sched.step() | |
| if step % args.log_every == 0 or step == 1: | |
| decoder.eval() | |
| with torch.no_grad(): | |
| val = float(reconstruction_loss(decoder(zv.to(device)), xv.to(device))) | |
| decoder.train() | |
| rate = step / (time.perf_counter() - t0) | |
| train_loss = float(loss.detach()) | |
| history.append({'step': step, 'train': train_loss, 'val': val}) | |
| print(f' step {step:5}/{args.steps} train {train_loss:.5f} ' | |
| f'val {val:.5f} ({val / baseline:.2f}x baseline) ' | |
| f'{rate:.2f} it/s eta {(args.steps - step) / rate / 60:.1f} min', | |
| flush=True) | |
| if step % args.preview_every == 0 or step == args.steps: | |
| save_preview(decoder, zv, xv, out_dir / f'preview_{step:05d}.png', device) | |
| torch.save({ | |
| 'state_dict': decoder.state_dict(), | |
| 'config': cfg, | |
| 'preset': args.preset, | |
| 'meta': meta, | |
| 'step': args.steps, | |
| 'history': history, | |
| 'baseline_val_mse': baseline, | |
| }, out_dir / 'decoder.pt') | |
| print(f'\nsaved -> {out_dir / "decoder.pt"}') | |
| print(f' {(time.perf_counter() - t0) / 60:.1f} min') | |
| print(f' final val MSE {history[-1]["val"]:.5f} vs baseline {baseline:.5f}') | |
| print(f' previews: {out_dir}/preview_*.png (top row target, bottom row decoded)') | |
| if __name__ == '__main__': | |
| main() | |