Self-Forcing / scripts /analyze_feature_cache.py
Cccccz's picture
Upload Python scripts
bc29ee3 verified
Raw
History Blame Contribute Delete
58.4 kB
#!/usr/bin/env python3
"""Analyze timestep and cross-chunk feature reuse in Self-Forcing.
The script runs the released four-step causal checkpoint without changing its
outputs. Forward hooks capture sampled DiT hidden states, residual updates, the
final velocity, and a low-dimensional dense projection used for optical-flow
alignment. It then produces:
* within-chunk and cross-chunk feature-pair metrics;
* leave-one-prompt-out channel-wise probes for conditional chunk information;
* shuffled, wrong-step, distant-chunk, zero, and noise controls;
* motion-stratified raw/global/dense-flow alignment measurements.
Each prompt is saved independently, so interrupted generation can be resumed.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import random
import sys
import time
from collections import defaultdict
from pathlib import Path
from typing import Any, Iterable
def _preparse_gpu() -> str:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--gpu", default="2")
args, _ = parser.parse_known_args()
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
return str(args.gpu)
PHYSICAL_GPU = _preparse_gpu()
import cv2
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn.functional as F
from omegaconf import OmegaConf
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from pipeline import CausalInferencePipeline
from utils.misc import set_seed
EXPECTED_LATENT_HEIGHT = 60
EXPECTED_LATENT_WIDTH = 104
FRAME_TOKEN_HEIGHT = 30
FRAME_TOKEN_WIDTH = 52
FRAME_SEQ_LENGTH = FRAME_TOKEN_HEIGHT * FRAME_TOKEN_WIDTH
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Self-Forcing cross-chunk feature-cache analysis"
)
parser.add_argument("--gpu", default=PHYSICAL_GPU)
parser.add_argument(
"--config_path", type=Path, default=Path("configs/self_forcing_dmd.yaml")
)
parser.add_argument(
"--checkpoint_path",
type=Path,
default=Path("checkpoints/self_forcing_dmd.pt"),
)
parser.add_argument(
"--prompt_path",
type=Path,
default=Path("prompts/MovieGenVideoBench_extended.txt"),
)
parser.add_argument("--output_dir", type=Path, required=True)
parser.add_argument("--num_prompts", type=int, default=3)
parser.add_argument("--num_frames", type=int, default=21)
parser.add_argument("--seed", type=int, default=20260728)
parser.add_argument(
"--same_seed",
action="store_true",
help="Reset every prompt to --seed for paired cross-model evaluation.",
)
parser.add_argument(
"--layers", type=int, nargs="+", default=[0, 9, 19, 29]
)
parser.add_argument("--max_tokens", type=int, default=256)
parser.add_argument("--projection_dim", type=int, default=64)
parser.add_argument("--ridge", type=float, default=1e-4)
parser.add_argument("--use_ema", action="store_true", default=True)
parser.add_argument("--no_ema", action="store_false", dest="use_ema")
parser.add_argument("--overwrite", action="store_true")
parser.add_argument(
"--cosine_only",
action="store_true",
help="Generate pair metrics/heatmap only; skip probes and motion analysis.",
)
parser.add_argument(
"--analysis_only",
action="store_true",
help="Skip model loading and analyze existing per-prompt snapshots.",
)
parser.add_argument(
"--save_preview",
action="store_true",
help="Save a compact MP4 preview when torchvision video IO is available.",
)
args = parser.parse_args()
if args.num_frames % 3 != 0:
parser.error("--num_frames must be divisible by the configured 3-frame chunk")
if args.num_prompts < 3 and not args.cosine_only:
parser.error("--num_prompts must be at least 3 for held-out/shuffle controls")
return args
def resolve_path(path: Path) -> Path:
return path if path.is_absolute() else REPO_ROOT / path
def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
if not rows:
return
path.parent.mkdir(parents=True, exist_ok=True)
fields: list[str] = []
for row in rows:
for key in row:
if key not in fields:
fields.append(key)
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
def read_prompts(path: Path, count: int) -> list[str]:
with path.open("r", encoding="utf-8") as handle:
prompts = [line.strip() for line in handle if line.strip()]
if len(prompts) < count:
raise ValueError(f"Requested {count} prompts, found {len(prompts)} in {path}")
return prompts[:count]
def regular_grid_indices(
frames: int, height: int, width: int, max_tokens: int, device: torch.device
) -> tuple[torch.Tensor, torch.Tensor]:
total = frames * height * width
if max_tokens >= total:
coords = torch.cartesian_prod(
torch.arange(frames, device=device),
torch.arange(height, device=device),
torch.arange(width, device=device),
)
else:
per_frame = max(1, max_tokens // frames)
h_count = max(
1, min(height, int(round(math.sqrt(per_frame * height / width))))
)
w_count = max(1, min(width, per_frame // h_count))
while frames * h_count * w_count > max_tokens and w_count > 1:
w_count -= 1
while frames * h_count * w_count > max_tokens and h_count > 1:
h_count -= 1
hs = (
torch.linspace(0, height - 1, h_count, device=device)
.round()
.long()
.unique()
)
ws = (
torch.linspace(0, width - 1, w_count, device=device)
.round()
.long()
.unique()
)
coords = torch.cartesian_prod(
torch.arange(frames, device=device), hs, ws
)
flat = (
coords[:, 0] * height * width + coords[:, 1] * width + coords[:, 2]
)
return flat.long(), coords.long()
class FeatureRecorder:
def __init__(
self,
model: torch.nn.Module,
layers: list[int],
denoising_timesteps: Iterable[float],
num_frame_per_block: int,
max_tokens: int,
projection_dim: int,
) -> None:
self.model = model
self.layers = sorted(set(int(value) for value in layers))
self.timesteps = [float(value) for value in denoising_timesteps]
self.num_frame_per_block = int(num_frame_per_block)
self.max_tokens = int(max_tokens)
self.projection_dim = int(projection_dim)
self.projection_layer = self.layers[-1]
self.records: dict[str, dict[str, torch.Tensor]] = defaultdict(dict)
self.projected: dict[str, torch.Tensor] = {}
self.projected_by_layer: dict[str, torch.Tensor] = {}
self.sample_coords: dict[str, torch.Tensor] = {}
self.current: dict[str, Any] = {"active": False}
self.handles: list[Any] = []
self._projection_cache: dict[tuple[int, str], torch.Tensor] = {}
self._register()
def _register(self) -> None:
self.handles.append(
self.model.register_forward_pre_hook(self._model_pre_hook, with_kwargs=True)
)
self.handles.append(self.model.register_forward_hook(self._model_output_hook))
for layer in self.layers:
if layer < 0 or layer >= len(self.model.blocks):
raise ValueError(
f"Layer {layer} outside model block range 0..{len(self.model.blocks)-1}"
)
self.handles.append(
self.model.blocks[layer].register_forward_hook(
self._make_block_hook(layer)
)
)
def close(self) -> None:
for handle in self.handles:
handle.remove()
self.handles.clear()
def reset(self) -> None:
self.records = defaultdict(dict)
self.projected = {}
self.projected_by_layer = {}
self.sample_coords = {}
self.current = {"active": False}
def _model_pre_hook(
self, _module: torch.nn.Module, _args: tuple[Any, ...], kwargs: dict[str, Any]
) -> None:
timestep = kwargs.get("t")
current_start = int(kwargs.get("current_start", 0) or 0)
if not isinstance(timestep, torch.Tensor) or timestep.numel() == 0:
self.current = {"active": False}
return
value = float(timestep.detach().float().reshape(-1)[0].item())
distances = [abs(value - expected) for expected in self.timesteps]
step = int(np.argmin(distances))
if distances[step] > 0.5:
self.current = {"active": False, "timestep": value}
return
frames = int(timestep.shape[-1]) if timestep.ndim > 1 else 1
if frames != self.num_frame_per_block:
self.current = {"active": False, "timestep": value}
return
start_frame = current_start // FRAME_SEQ_LENGTH
chunk = start_frame // self.num_frame_per_block
self.current = {
"active": True,
"chunk": int(chunk),
"step": step,
"timestep": value,
"frames": frames,
}
def _key(self) -> str:
return f"{self.current['chunk']}:{self.current['step']}"
def _hidden_indices(
self, tokens: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor]:
frames = int(self.current["frames"])
if tokens.shape[1] != frames * FRAME_TOKEN_HEIGHT * FRAME_TOKEN_WIDTH:
raise ValueError(
f"Unexpected hidden token count {tokens.shape[1]} for {frames} frames"
)
return regular_grid_indices(
frames,
FRAME_TOKEN_HEIGHT,
FRAME_TOKEN_WIDTH,
self.max_tokens,
tokens.device,
)
def _projection(self, dim: int, device: torch.device) -> torch.Tensor:
cache_key = (dim, str(device))
if cache_key not in self._projection_cache:
generator = torch.Generator(device="cpu").manual_seed(20260728 + dim)
signs = torch.randint(
0,
2,
(dim, self.projection_dim),
generator=generator,
dtype=torch.int8,
)
projection = (
signs.float().mul_(2).sub_(1).div_(math.sqrt(self.projection_dim))
)
self._projection_cache[cache_key] = projection.to(device)
return self._projection_cache[cache_key]
def _make_block_hook(self, layer: int):
def hook(
_module: torch.nn.Module,
inputs: tuple[torch.Tensor, ...],
output: torch.Tensor,
) -> None:
if not self.current.get("active", False):
return
if not inputs or not isinstance(output, torch.Tensor):
return
key = self._key()
hidden_input = inputs[0]
indices, coords = self._hidden_indices(output)
hidden = output[0].index_select(0, indices)
delta = (output - hidden_input)[0].index_select(0, indices)
self.records[f"block_{layer}_hidden"][key] = (
hidden.detach().to(dtype=torch.float16, device="cpu")
)
self.records[f"block_{layer}_delta"][key] = (
delta.detach().to(dtype=torch.float16, device="cpu")
)
self.sample_coords["hidden"] = coords.detach().cpu()
if layer in self.layers:
projection = self._projection(output.shape[-1], output.device)
dense = torch.matmul(output[0].float(), projection)
frames = int(self.current["frames"])
dense = dense.reshape(
frames,
FRAME_TOKEN_HEIGHT,
FRAME_TOKEN_WIDTH,
self.projection_dim,
)
dense_cpu = dense.detach().to(dtype=torch.float16, device="cpu")
self.projected_by_layer[f"{layer}:{key}"] = dense_cpu
# Preserve the legacy key layout for existing final-layer analyses.
if layer == self.projection_layer:
self.projected[key] = dense_cpu
return hook
def _model_output_hook(
self,
_module: torch.nn.Module,
_inputs: tuple[Any, ...],
output: torch.Tensor,
) -> None:
if not self.current.get("active", False):
return
if not isinstance(output, torch.Tensor) or output.ndim != 5:
return
batch, channels, frames, height, width = output.shape
if batch != 1:
raise ValueError(f"Analysis expects batch size 1, got {batch}")
tokens = output.permute(0, 2, 3, 4, 1).reshape(
batch, frames * height * width, channels
)
indices, coords = regular_grid_indices(
frames, height, width, self.max_tokens, output.device
)
sampled = tokens[0].index_select(0, indices)
self.records["dit_output"][self._key()] = sampled.detach().to(
dtype=torch.float16, device="cpu"
)
self.sample_coords["dit_output"] = coords.detach().cpu()
def state_dict(self) -> dict[str, Any]:
return {
"timesteps": self.timesteps,
"layers": self.layers,
"projection_layer": self.projection_layer,
"projection_dim": self.projection_dim,
"records": {stage: dict(values) for stage, values in self.records.items()},
"projected": dict(self.projected),
"projected_by_layer": dict(self.projected_by_layer),
"sample_coords": dict(self.sample_coords),
}
def build_pipeline(args: argparse.Namespace) -> tuple[CausalInferencePipeline, Any]:
config = OmegaConf.load(resolve_path(args.config_path))
default_config = OmegaConf.load(REPO_ROOT / "configs/default_config.yaml")
config = OmegaConf.merge(default_config, config)
device = torch.device("cuda")
pipeline = CausalInferencePipeline(config, device=device)
checkpoint = torch.load(
resolve_path(args.checkpoint_path), map_location="cpu", weights_only=False
)
state_key = "generator_ema" if args.use_ema else "generator"
pipeline.generator.load_state_dict(checkpoint[state_key])
del checkpoint
pipeline = pipeline.to(dtype=torch.bfloat16)
pipeline.text_encoder.to(device=device)
pipeline.generator.to(device=device)
pipeline.vae.to(device=device)
pipeline.eval()
return pipeline, config
def downsample_anchors(video: torch.Tensor, latent_frames: int) -> np.ndarray:
value = video[0].detach().float().cpu()
frame_count = value.shape[0]
if frame_count == latent_frames:
indices = np.arange(latent_frames)
else:
indices = np.linspace(0, frame_count - 1, latent_frames).round().astype(int)
anchors = value[indices].permute(0, 2, 3, 1).clamp(0, 1).numpy()
result = []
for frame in anchors:
frame_u8 = np.uint8(np.round(frame * 255.0))
result.append(cv2.resize(frame_u8, (416, 240), interpolation=cv2.INTER_AREA))
return np.stack(result)
def maybe_save_preview(path: Path, anchors: np.ndarray) -> None:
try:
from torchvision.io import write_video
repeated = np.repeat(anchors, 4, axis=0)
tensor = torch.from_numpy(repeated)
write_video(str(path), tensor, fps=16)
except Exception as error:
print(f"[preview] skipped: {error}", flush=True)
@torch.inference_mode()
def generate_snapshots(args: argparse.Namespace) -> list[Path]:
output_dir = args.output_dir
runs_dir = output_dir / "runs"
runs_dir.mkdir(parents=True, exist_ok=True)
prompts = read_prompts(resolve_path(args.prompt_path), args.num_prompts)
expected_paths = [runs_dir / f"prompt_{index:02d}.pt" for index in range(len(prompts))]
missing = [
path
for path in expected_paths
if args.overwrite or not path.exists()
]
if not missing:
print("[generation] all prompt snapshots already exist", flush=True)
return expected_paths
pipeline, config = build_pipeline(args)
denoising_timesteps = [
float(value) for value in pipeline.denoising_step_list.detach().cpu().tolist()
]
recorder = FeatureRecorder(
model=pipeline.generator.model,
layers=args.layers,
denoising_timesteps=denoising_timesteps,
num_frame_per_block=pipeline.num_frame_per_block,
max_tokens=args.max_tokens,
projection_dim=args.projection_dim,
)
metadata = {
"physical_gpu": args.gpu,
"config_path": str(resolve_path(args.config_path)),
"checkpoint_path": str(resolve_path(args.checkpoint_path)),
"use_ema": args.use_ema,
"num_prompts": args.num_prompts,
"num_frames": args.num_frames,
"num_frame_per_block": pipeline.num_frame_per_block,
"denoising_timesteps": denoising_timesteps,
"layers": args.layers,
"max_tokens": args.max_tokens,
"projection_dim": args.projection_dim,
"seed": args.seed,
"dtype": "bfloat16",
}
(output_dir / "experiment_config.json").write_text(
json.dumps(metadata, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
try:
for index, (prompt, path) in enumerate(zip(prompts, expected_paths)):
if path.exists() and not args.overwrite:
print(f"[generation] skip existing {path.name}", flush=True)
continue
recorder.reset()
run_seed = args.seed if args.same_seed else args.seed + index
set_seed(run_seed)
noise = torch.randn(
1,
args.num_frames,
16,
EXPECTED_LATENT_HEIGHT,
EXPECTED_LATENT_WIDTH,
device="cuda",
dtype=torch.bfloat16,
)
torch.cuda.reset_peak_memory_stats()
torch.cuda.synchronize()
start = time.perf_counter()
print(
f"[generation] prompt {index + 1}/{len(prompts)} seed={run_seed}",
flush=True,
)
video, latents = pipeline.inference(
noise=noise,
text_prompts=[prompt],
return_latents=True,
initial_latent=None,
low_memory=False,
)
torch.cuda.synchronize()
elapsed = time.perf_counter() - start
peak_gib = torch.cuda.max_memory_allocated() / (1024**3)
anchors = downsample_anchors(video, args.num_frames)
state = {
"run_index": index,
"prompt": prompt,
"seed": run_seed,
"elapsed_s": elapsed,
"peak_gpu_gib": peak_gib,
"num_frames": args.num_frames,
"num_frame_per_block": pipeline.num_frame_per_block,
"latents": latents[0].detach().to(
dtype=torch.float16, device="cpu"
),
**recorder.state_dict(),
}
torch.save(state, path)
np.savez_compressed(path.with_suffix(".anchors.npz"), frames=anchors)
if args.save_preview:
maybe_save_preview(path.with_suffix(".mp4"), anchors)
print(
f"[generation] saved {path.name}: {elapsed:.1f}s, peak={peak_gib:.1f} GiB",
flush=True,
)
del video, latents, noise, state
pipeline.vae.model.clear_cache()
torch.cuda.empty_cache()
finally:
recorder.close()
return expected_paths
def load_runs(paths: list[Path]) -> list[dict[str, Any]]:
runs = []
for path in paths:
if not path.exists():
raise FileNotFoundError(path)
run = torch.load(path, map_location="cpu", weights_only=False)
anchor_path = path.with_suffix(".anchors.npz")
if not anchor_path.exists():
raise FileNotFoundError(anchor_path)
run["anchors"] = np.load(anchor_path, allow_pickle=False)["frames"]
run["path"] = str(path)
runs.append(run)
return runs
def feature(run: dict[str, Any], stage: str, chunk: int, step: int) -> torch.Tensor:
return run["records"][stage][f"{chunk}:{step}"].float()
def projected_feature(
run: dict[str, Any], chunk: int, step: int
) -> torch.Tensor:
return run["projected"][f"{chunk}:{step}"].float()
def available_chunks(run: dict[str, Any], stage: str) -> list[int]:
return sorted(
{int(key.split(":")[0]) for key in run["records"][stage].keys()}
)
def available_steps(run: dict[str, Any], stage: str) -> list[int]:
return sorted(
{int(key.split(":")[1]) for key in run["records"][stage].keys()}
)
def pair_metrics(
reference: torch.Tensor,
target: torch.Tensor,
compute_cka: bool = True,
) -> dict[str, float]:
if reference.shape != target.shape:
raise ValueError(f"Pair shape mismatch: {reference.shape} vs {target.shape}")
eps = 1e-8
x = reference.float()
y = target.float()
xf = x.reshape(-1)
yf = y.reshape(-1)
diff = yf - xf
cosine = F.cosine_similarity(xf[None], yf[None], dim=1, eps=eps)[0]
xc_flat = xf - xf.mean()
yc_flat = yf - yf.mean()
centered_cosine = torch.dot(xc_flat, yc_flat) / (
torch.linalg.vector_norm(xc_flat)
* torch.linalg.vector_norm(yc_flat)
+ eps
)
token_cosine = F.cosine_similarity(x, y, dim=1, eps=eps)
rel_l2 = diff.square().mean().sqrt() / (xf.square().mean().sqrt() + eps)
nmse = diff.square().mean() / (yc_flat.square().mean() + eps)
if compute_cka:
xc = x - x.mean(dim=0, keepdim=True)
yc = y - y.mean(dim=0, keepdim=True)
gram_x = xc @ xc.T
gram_y = yc @ yc.T
cka = (gram_x * gram_y).sum() / (
(gram_x.square().sum() * gram_y.square().sum()).sqrt() + eps
)
else:
cka = torch.tensor(float("nan"))
quantiles = torch.quantile(
token_cosine,
torch.tensor([0.1, 0.5, 0.9], dtype=token_cosine.dtype),
)
return {
"cosine": float(cosine),
"centered_cosine": float(centered_cosine),
"linear_cka": float(cka),
"rel_l2": float(rel_l2),
"nmse": float(nmse),
"token_cosine_mean": float(token_cosine.mean()),
"token_cosine_p10": float(quantiles[0]),
"token_cosine_p50": float(quantiles[1]),
"token_cosine_p90": float(quantiles[2]),
"reference_rms": float(xf.square().mean().sqrt()),
"target_rms": float(yf.square().mean().sqrt()),
}
def collect_pair_rows(
runs: list[dict[str, Any]], cosine_only: bool = False
) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
stages = sorted(runs[0]["records"])
if cosine_only:
stages = [
stage
for stage in stages
if stage.startswith("block_") and stage.endswith("_hidden")
]
for run_index, run in enumerate(runs):
shuffled_run = runs[(run_index + 1) % len(runs)]
for stage in stages:
chunks = available_chunks(run, stage)
steps = available_steps(run, stage)
def append(
comparison: str,
ref_run: dict[str, Any],
ref_chunk: int,
ref_step: int,
target_chunk: int,
target_step: int,
) -> None:
metrics = pair_metrics(
feature(ref_run, stage, ref_chunk, ref_step),
feature(run, stage, target_chunk, target_step),
compute_cka=not cosine_only,
)
rows.append(
{
"run": run_index,
"comparison": comparison,
"stage": stage,
"reference_chunk": ref_chunk,
"target_chunk": target_chunk,
"reference_step": ref_step,
"target_step": target_step,
**metrics,
}
)
for chunk in chunks:
for step in steps[1:]:
append(
"within_adjacent",
run,
chunk,
step - 1,
chunk,
step,
)
if chunk < 1:
continue
for step in steps:
append(
"cross_same",
run,
chunk - 1,
step,
chunk,
step,
)
if cosine_only:
continue
append(
"cross_video_shuffle",
shuffled_run,
chunk - 1,
step,
chunk,
step,
)
if step > 0:
append(
"cross_wrong_step",
run,
chunk - 1,
step - 1,
chunk,
step,
)
if chunk > 1:
append(
"cross_distant",
run,
chunk - 2,
step,
chunk,
step,
)
return rows
def predictor_columns(
name: str,
within: torch.Tensor,
cross: torch.Tensor,
distant: torch.Tensor,
wrong: torch.Tensor,
batch: torch.Tensor,
noise_seed: int,
) -> list[torch.Tensor]:
ones = torch.ones_like(within)
shifted = cross.roll(shifts=max(1, cross.shape[0] // 2), dims=0)
generator = torch.Generator(device="cpu").manual_seed(noise_seed)
noise = torch.randn(
cross.shape, generator=generator, dtype=cross.dtype
)
noise = noise * cross.std(dim=0, keepdim=True).clamp_min(1e-6)
noise = noise + cross.mean(dim=0, keepdim=True)
mapping = {
"within_affine": [within, ones],
"within_quadratic": [within, within.square(), ones],
"cross_affine": [cross, ones],
"fusion_same": [within, cross, ones],
"fusion_distant": [within, distant, ones],
"fusion_token_shift": [within, shifted, ones],
"fusion_wrong_step": [within, wrong, ones],
"fusion_batch_shuffle": [within, batch, ones],
"fusion_zero": [within, torch.zeros_like(cross), ones],
"fusion_noise": [within, noise, ones],
}
return mapping[name]
PROBE_NAMES = [
"within_affine",
"within_quadratic",
"cross_affine",
"fusion_same",
"fusion_distant",
"fusion_token_shift",
"fusion_wrong_step",
"fusion_batch_shuffle",
"fusion_zero",
"fusion_noise",
]
def gather_probe_data(
runs: list[dict[str, Any]],
run_indices: list[int],
stage: str,
step: int,
) -> dict[str, torch.Tensor]:
buckets: dict[str, list[torch.Tensor]] = defaultdict(list)
for run_index in run_indices:
run = runs[run_index]
other = runs[(run_index + 1) % len(runs)]
chunks = available_chunks(run, stage)
for chunk in chunks:
# Use c >= 2 for every probe so correct, distant, and all other
# controls are evaluated on exactly the same target tokens.
if chunk < 2:
continue
buckets["target"].append(feature(run, stage, chunk, step))
buckets["within"].append(feature(run, stage, chunk, step - 1))
buckets["cross"].append(feature(run, stage, chunk - 1, step))
buckets["distant"].append(feature(run, stage, chunk - 2, step))
buckets["wrong"].append(feature(run, stage, chunk - 1, step - 1))
buckets["batch"].append(feature(other, stage, chunk - 1, step))
if not buckets:
raise ValueError(f"No probe data for stage={stage}, step={step}")
return {key: torch.cat(values, dim=0).float() for key, values in buckets.items()}
def fit_channelwise_probe(
columns: list[torch.Tensor],
target: torch.Tensor,
ridge: float,
) -> torch.Tensor:
design = torch.stack(columns, dim=-1).double()
y = target.double()
gram = torch.einsum("ndp,ndq->dpq", design, design)
rhs = torch.einsum("ndp,nd->dp", design, y)
feature_count = gram.shape[-1]
diagonal_scale = (
gram.diagonal(dim1=-2, dim2=-1).mean(dim=-1).clamp_min(1e-8)
)
regularizer = (
torch.eye(feature_count, dtype=gram.dtype)[None]
* (ridge * diagonal_scale)[:, None, None]
)
regularizer[:, -1, -1] = 0.0
try:
weights = torch.linalg.solve(gram + regularizer, rhs.unsqueeze(-1)).squeeze(-1)
except torch.linalg.LinAlgError:
weights = (
torch.linalg.pinv(gram + regularizer) @ rhs.unsqueeze(-1)
).squeeze(-1)
return weights.float()
def apply_channelwise_probe(
columns: list[torch.Tensor], weights: torch.Tensor
) -> torch.Tensor:
design = torch.stack(columns, dim=-1).float()
return torch.einsum("ndp,dp->nd", design, weights)
def prediction_metrics(prediction: torch.Tensor, target: torch.Tensor) -> dict[str, float]:
eps = 1e-8
pred = prediction.float()
y = target.float()
error = pred - y
mse = error.square().mean()
variance = (y - y.mean()).square().mean()
nrmse = mse.sqrt() / (variance.sqrt() + eps)
r2 = 1.0 - mse / (variance + eps)
cosine = F.cosine_similarity(
pred.reshape(1, -1), y.reshape(1, -1), dim=1, eps=eps
)[0]
return {
"mse": float(mse),
"nrmse": float(nrmse),
"r2": float(r2),
"cosine": float(cosine),
}
def run_conditional_probes(
runs: list[dict[str, Any]], ridge: float
) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
stages = sorted(runs[0]["records"])
steps = available_steps(runs[0], stages[0])
for stage in stages:
for step in steps[1:]:
for held_out in range(len(runs)):
train_indices = [index for index in range(len(runs)) if index != held_out]
train = gather_probe_data(runs, train_indices, stage, step)
test = gather_probe_data(runs, [held_out], stage, step)
for probe_name in PROBE_NAMES:
train_columns = predictor_columns(
probe_name,
train["within"],
train["cross"],
train["distant"],
train["wrong"],
train["batch"],
noise_seed=1000 + held_out * 100 + step,
)
test_columns = predictor_columns(
probe_name,
test["within"],
test["cross"],
test["distant"],
test["wrong"],
test["batch"],
noise_seed=2000 + held_out * 100 + step,
)
weights = fit_channelwise_probe(
train_columns, train["target"], ridge=ridge
)
prediction = apply_channelwise_probe(test_columns, weights)
rows.append(
{
"held_out_run": held_out,
"stage": stage,
"step": step,
"probe": probe_name,
"train_tokens": int(train["target"].shape[0]),
"test_tokens": int(test["target"].shape[0]),
**prediction_metrics(prediction, test["target"]),
}
)
baseline_lookup = {
(row["held_out_run"], row["stage"], row["step"], row["probe"]): row
for row in rows
if row["probe"] in {"within_affine", "within_quadratic"}
}
for row in rows:
key = (row["held_out_run"], row["stage"], row["step"])
for baseline_name in ("within_affine", "within_quadratic"):
baseline = baseline_lookup[(*key, baseline_name)]
row[f"mse_reduction_vs_{baseline_name}"] = (
baseline["mse"] - row["mse"]
) / max(baseline["mse"], 1e-12)
row[f"r2_gain_vs_{baseline_name}"] = row["r2"] - baseline["r2"]
return rows
def gray(frame: np.ndarray) -> np.ndarray:
return cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
def farneback(source: np.ndarray, target: np.ndarray) -> np.ndarray:
return cv2.calcOpticalFlowFarneback(
gray(source),
gray(target),
None,
pyr_scale=0.5,
levels=4,
winsize=21,
iterations=5,
poly_n=7,
poly_sigma=1.5,
flags=0,
)
def resize_flow(flow: np.ndarray, height: int, width: int) -> torch.Tensor:
source_height, source_width = flow.shape[:2]
resized = cv2.resize(flow, (width, height), interpolation=cv2.INTER_AREA)
resized[..., 0] *= width / source_width
resized[..., 1] *= height / source_height
return torch.from_numpy(resized).permute(2, 0, 1).float()
def warp_feature(
source: torch.Tensor, target_to_source_flow: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor]:
height, width, _ = source.shape
yy, xx = torch.meshgrid(
torch.arange(height, dtype=torch.float32),
torch.arange(width, dtype=torch.float32),
indexing="ij",
)
sample_x = xx + target_to_source_flow[0]
sample_y = yy + target_to_source_flow[1]
grid = torch.stack(
[
2.0 * sample_x / max(width - 1, 1) - 1.0,
2.0 * sample_y / max(height - 1, 1) - 1.0,
],
dim=-1,
)[None]
value = source.permute(2, 0, 1)[None].float()
warped = F.grid_sample(
value, grid, mode="bilinear", padding_mode="zeros", align_corners=True
)[0].permute(1, 2, 0)
mask = (
(sample_x >= 0)
& (sample_x <= width - 1)
& (sample_y >= 0)
& (sample_y <= height - 1)
)
return warped, mask
def masked_cosine(
left: torch.Tensor, right: torch.Tensor, mask: torch.Tensor | None = None
) -> float:
value = F.cosine_similarity(left.float(), right.float(), dim=-1, eps=1e-8)
if mask is not None:
if not bool(mask.any()):
return float("nan")
value = value[mask]
return float(value.mean())
def collect_motion_rows(runs: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for run_index, run in enumerate(runs):
anchors = run["anchors"]
chunk_size = int(run["num_frame_per_block"])
chunk_count = int(run["num_frames"]) // chunk_size
steps = sorted(
{int(key.split(":")[1]) for key in run["projected"].keys()}
)
for chunk in range(1, chunk_count):
previous_anchor_index = chunk * chunk_size - 1
previous_frame = anchors[previous_anchor_index]
boundary_flow = farneback(
previous_frame, anchors[chunk * chunk_size]
)
median_flow = np.median(
boundary_flow.reshape(-1, 2), axis=0
)
camera_motion = float(np.linalg.norm(median_flow))
residual = boundary_flow - median_flow[None, None]
object_motion = float(
np.linalg.norm(residual, axis=-1).mean()
)
total_motion = float(
np.linalg.norm(boundary_flow, axis=-1).mean()
)
for step in steps:
source_map = projected_feature(run, chunk - 1, step)[-1]
target_map = projected_feature(run, chunk, step)
same_slot_source = projected_feature(run, chunk - 1, step)
per_slot: list[dict[str, float]] = []
for slot in range(chunk_size):
target = target_map[slot]
same_slot = same_slot_source[slot]
raw_boundary = source_map
target_frame = anchors[chunk * chunk_size + slot]
backward = farneback(target_frame, previous_frame)
feature_flow = resize_flow(
backward, FRAME_TOKEN_HEIGHT, FRAME_TOKEN_WIDTH
)
global_flow = torch.zeros_like(feature_flow)
global_flow[0].fill_(float(np.median(feature_flow[0].numpy())))
global_flow[1].fill_(float(np.median(feature_flow[1].numpy())))
global_aligned, global_mask = warp_feature(
source_map, global_flow
)
flow_aligned, flow_mask = warp_feature(source_map, feature_flow)
per_slot.append(
{
"same_slot_cosine": masked_cosine(target, same_slot),
"boundary_raw_cosine": masked_cosine(
target, raw_boundary
),
"global_aligned_cosine": masked_cosine(
target, global_aligned, global_mask
),
"flow_aligned_cosine": masked_cosine(
target, flow_aligned, flow_mask
),
"valid_flow_ratio": float(flow_mask.float().mean()),
}
)
rows.append(
{
"run": run_index,
"chunk": chunk,
"step": step,
"total_motion": total_motion,
"camera_motion": camera_motion,
"object_motion": object_motion,
**{
key: float(np.nanmean([item[key] for item in per_slot]))
for key in per_slot[0]
},
}
)
motion_values = np.asarray([row["total_motion"] for row in rows])
if len(motion_values) >= 3:
low, high = np.quantile(motion_values, [1 / 3, 2 / 3])
for row in rows:
value = row["total_motion"]
row["motion_bin"] = "low" if value <= low else "high" if value > high else "medium"
return rows
def group_mean(
rows: list[dict[str, Any]], keys: list[str], metrics: list[str]
) -> list[dict[str, Any]]:
groups: dict[tuple[Any, ...], list[dict[str, Any]]] = defaultdict(list)
for row in rows:
groups[tuple(row[key] for key in keys)].append(row)
result = []
for group, values in sorted(groups.items(), key=lambda item: tuple(map(str, item[0]))):
output = {key: value for key, value in zip(keys, group)}
output["count"] = len(values)
for metric in metrics:
finite = [
float(row[metric])
for row in values
if metric in row and math.isfinite(float(row[metric]))
]
output[metric] = float(np.mean(finite)) if finite else float("nan")
result.append(output)
return result
def paired_probe_reduction(
rows: list[dict[str, Any]],
stage: str,
reference_probe: str,
candidate_probe: str = "fusion_same",
) -> dict[str, Any]:
lookup = {
(int(row["held_out_run"]), int(row["step"]), row["probe"]): row
for row in rows
if row["stage"] == stage
and row["probe"] in {reference_probe, candidate_probe}
}
pairs = sorted(
{
(held_out, step)
for held_out, step, probe in lookup
if probe == candidate_probe
and (held_out, step, reference_probe) in lookup
}
)
reductions = []
for held_out, step in pairs:
reference = float(lookup[(held_out, step, reference_probe)]["mse"])
candidate = float(lookup[(held_out, step, candidate_probe)]["mse"])
reductions.append((reference - candidate) / max(reference, 1e-12))
return {
"reference": reference_probe,
"paired_count": len(reductions),
"mean_mse_reduction": (
float(np.mean(reductions)) if reductions else float("nan")
),
"median_mse_reduction": (
float(np.median(reductions)) if reductions else float("nan")
),
"wins": int(sum(value > 0 for value in reductions)),
}
def plot_similarity(pair_rows: list[dict[str, Any]], output_dir: Path) -> None:
stages = [
stage
for stage in sorted({row["stage"] for row in pair_rows})
if stage.endswith("_hidden")
]
comparisons = [
"within_adjacent",
"cross_same",
"cross_wrong_step",
"cross_distant",
"cross_video_shuffle",
]
steps = sorted({int(row["target_step"]) for row in pair_rows})
fig, axes = plt.subplots(
len(stages), 2, figsize=(12, max(3.2, 2.8 * len(stages))), squeeze=False
)
for stage_index, stage in enumerate(stages):
for metric_index, metric in enumerate(["cosine", "nmse"]):
matrix = np.full((len(comparisons), len(steps)), np.nan)
for row_index, comparison in enumerate(comparisons):
for col_index, step in enumerate(steps):
values = [
float(row[metric])
for row in pair_rows
if row["stage"] == stage
and row["comparison"] == comparison
and int(row["target_step"]) == step
]
if values:
matrix[row_index, col_index] = np.mean(values)
ax = axes[stage_index, metric_index]
image = ax.imshow(
matrix,
aspect="auto",
cmap="viridis_r" if metric == "nmse" else "viridis",
)
ax.set_title(f"{stage}: {metric}")
ax.set_xticks(range(len(steps)), labels=steps)
ax.set_yticks(range(len(comparisons)), labels=comparisons)
ax.set_xlabel("target denoising step")
fig.colorbar(image, ax=ax, fraction=0.03)
fig.tight_layout()
fig.savefig(output_dir / "feature_redundancy_heatmap.png", dpi=180)
plt.close(fig)
def plot_probe_gain(probe_rows: list[dict[str, Any]], output_dir: Path) -> None:
aggregate = group_mean(
probe_rows,
["stage", "step", "probe"],
["mse", "nrmse", "r2", "mse_reduction_vs_within_quadratic"],
)
stages = [
stage
for stage in sorted({row["stage"] for row in aggregate})
if stage.endswith("_hidden")
]
probes = [
"fusion_same",
"fusion_distant",
"fusion_token_shift",
"fusion_wrong_step",
"fusion_batch_shuffle",
]
steps = sorted({int(row["step"]) for row in aggregate})
fig, axes = plt.subplots(
len(stages), 1, figsize=(9, max(3.4, 3.0 * len(stages))), squeeze=False
)
for stage_index, stage in enumerate(stages):
ax = axes[stage_index, 0]
for probe in probes:
values = []
for step in steps:
match = [
row
for row in aggregate
if row["stage"] == stage
and int(row["step"]) == step
and row["probe"] == probe
]
values.append(
match[0]["mse_reduction_vs_within_quadratic"]
if match
else np.nan
)
ax.plot(steps, values, marker="o", label=probe)
ax.axhline(0, color="black", linewidth=0.8)
ax.set_title(stage)
ax.set_ylabel("MSE reduction vs within quadratic")
ax.set_xlabel("target denoising step")
ax.legend(fontsize=8)
ax.grid(alpha=0.25)
fig.tight_layout()
fig.savefig(output_dir / "conditional_chunk_gain.png", dpi=180)
plt.close(fig)
def plot_motion(motion_rows: list[dict[str, Any]], output_dir: Path) -> None:
fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
axes[0].scatter(
[row["total_motion"] for row in motion_rows],
[row["boundary_raw_cosine"] for row in motion_rows],
s=18,
alpha=0.65,
label="boundary raw",
)
axes[0].scatter(
[row["total_motion"] for row in motion_rows],
[row["flow_aligned_cosine"] for row in motion_rows],
s=18,
alpha=0.65,
label="dense-flow aligned",
)
axes[0].set_xlabel("optical-flow magnitude")
axes[0].set_ylabel("feature cosine")
axes[0].legend()
axes[0].grid(alpha=0.25)
aggregate = group_mean(
motion_rows,
["motion_bin"],
[
"same_slot_cosine",
"boundary_raw_cosine",
"global_aligned_cosine",
"flow_aligned_cosine",
],
)
bins = ["low", "medium", "high"]
metrics = [
"same_slot_cosine",
"boundary_raw_cosine",
"global_aligned_cosine",
"flow_aligned_cosine",
]
width = 0.18
x = np.arange(len(bins))
for metric_index, metric in enumerate(metrics):
values = []
for name in bins:
match = [row for row in aggregate if row["motion_bin"] == name]
values.append(match[0][metric] if match else np.nan)
axes[1].bar(
x + (metric_index - 1.5) * width,
values,
width=width,
label=metric.replace("_cosine", ""),
)
axes[1].set_xticks(x, bins)
axes[1].set_ylabel("mean feature cosine")
axes[1].set_xlabel("motion bin")
axes[1].legend(fontsize=8)
axes[1].grid(axis="y", alpha=0.25)
fig.tight_layout()
fig.savefig(output_dir / "motion_alignment_analysis.png", dpi=180)
plt.close(fig)
def markdown_table(
rows: list[dict[str, Any]], columns: list[str], digits: int = 4
) -> str:
lines = [
"| " + " | ".join(columns) + " |",
"|" + "|".join(["---"] * len(columns)) + "|",
]
for row in rows:
values = []
for column in columns:
value = row.get(column, "")
if isinstance(value, float):
values.append(f"{value:.{digits}f}")
else:
values.append(str(value))
lines.append("| " + " | ".join(values) + " |")
return "\n".join(lines)
def build_report(
runs: list[dict[str, Any]],
pair_rows: list[dict[str, Any]],
probe_rows: list[dict[str, Any]],
motion_rows: list[dict[str, Any]],
output_dir: Path,
) -> None:
final_stage = f"block_{runs[0]['projection_layer']}_hidden"
pair_summary = group_mean(
[
row
for row in pair_rows
if row["stage"] == final_stage
],
["comparison"],
["cosine", "linear_cka", "rel_l2", "nmse", "token_cosine_p10"],
)
probe_summary = group_mean(
[
row
for row in probe_rows
if row["stage"] == final_stage
and row["probe"] in set(PROBE_NAMES)
],
["probe"],
[
"nrmse",
"r2",
"mse_reduction_vs_within_affine",
"mse_reduction_vs_within_quadratic",
],
)
motion_summary = group_mean(
motion_rows,
["motion_bin"],
[
"total_motion",
"same_slot_cosine",
"boundary_raw_cosine",
"global_aligned_cosine",
"flow_aligned_cosine",
],
)
control_order = [
"within_affine",
"within_quadratic",
"fusion_distant",
"fusion_wrong_step",
"fusion_token_shift",
"fusion_batch_shuffle",
"fusion_zero",
"fusion_noise",
]
control_summary = [
paired_probe_reduction(
probe_rows,
stage=final_stage,
reference_probe=control,
)
for control in control_order
]
control_lookup = {row["reference"]: row for row in control_summary}
affine = control_lookup["within_affine"]
shuffled = control_lookup["fusion_batch_shuffle"]
conditional_statement = (
f"在主层 `{final_stage}` 上,加入正确的前一 chunk 同 timestep 特征,"
f"相对 within-only affine probe 平均降低 held-out MSE "
f"{100*affine['mean_mse_reduction']:.2f}%,并在 "
f"{affine['wins']}/{affine['paired_count']} 个 "
f"prompt–timestep 配对中取得改善。相对参数量一致的跨视频 "
f"shuffle 对照,MSE 平均降低 "
f"{100*shuffled['mean_mse_reduction']:.2f}%。"
)
pair_lookup = {row["comparison"]: row for row in pair_summary}
same_pair = pair_lookup.get("cross_same")
random_pair = pair_lookup.get("cross_video_shuffle")
pair_statement = ""
if same_pair is not None and random_pair is not None:
pair_statement = (
f"正确相邻 chunk 的平均 cosine/CKA 为 "
f"{same_pair['cosine']:.4f}/{same_pair['linear_cka']:.4f},"
f"跨视频 shuffle 为 "
f"{random_pair['cosine']:.4f}/{random_pair['linear_cka']:.4f}。"
)
total_motion = np.asarray(
[float(row["total_motion"]) for row in motion_rows], dtype=np.float64
)
raw_cosine = np.asarray(
[float(row["boundary_raw_cosine"]) for row in motion_rows],
dtype=np.float64,
)
flow_cosine = np.asarray(
[float(row["flow_aligned_cosine"]) for row in motion_rows],
dtype=np.float64,
)
motion_correlation = (
float(np.corrcoef(total_motion, raw_cosine)[0, 1])
if len(motion_rows) > 1
else float("nan")
)
flow_gain = float(np.mean(flow_cosine - raw_cosine))
flow_wins = int(np.sum(flow_cosine > raw_cosine))
motion_statement = (
f"运动强度与未对齐跨 chunk cosine 的 Pearson 相关系数为 "
f"{motion_correlation:.3f};dense-flow 对齐平均恢复 "
f"{flow_gain:.4f} cosine,并在 {flow_wins}/{len(motion_rows)} "
f"个 chunk–timestep 样本上改善。"
)
report = f"""# Self-Forcing Feature Cache 分析结果
## 实验配置
- Prompts:{len(runs)}
- 每条视频 latent frames:{runs[0]['num_frames']}
- 每个 AR chunk latent frames:{runs[0]['num_frame_per_block']}
- Denoising timesteps:{runs[0]['timesteps']}
- Hook layers:{runs[0]['layers']}
- 主分析 stage:`{final_stage}`
## 核心观察
{conditional_statement}
{pair_statement}
{motion_statement}
以下结果是 3 条 prompt 的 pilot 分析。条件 probe 按 prompt 留一测试,
统计单位是 held-out prompt 与 timestep;不能将 token 数量解释为独立视频样本数,
也不能据此声称数据集级统计显著性。
## 主层特征配对
{markdown_table(pair_summary, ['comparison', 'count', 'cosine', 'linear_cka', 'rel_l2', 'nmse', 'token_cosine_p10'])}
## 条件 Probe
Probe 仅使用 chunk index `c ≥ 2` 的目标 chunk,使 `correct`、`c-2 distant`
及其他控制组在完全相同的 token 上比较。
{markdown_table(probe_summary, ['probe', 'count', 'nrmse', 'r2', 'mse_reduction_vs_within_affine', 'mse_reduction_vs_within_quadratic'])}
### `fusion_same` 的成对控制实验
正值表示正确前一 chunk 同 timestep 输入的 MSE 更低。
{markdown_table(control_summary, ['reference', 'paired_count', 'mean_mse_reduction', 'median_mse_reduction', 'wins'])}
## 运动与对齐
{markdown_table(motion_summary, ['motion_bin', 'count', 'total_motion', 'same_slot_cosine', 'boundary_raw_cosine', 'global_aligned_cosine', 'flow_aligned_cosine'])}
这里的 `global_aligned` 是由光流中位数估计的全局平移对齐,
`flow_aligned` 是 dense optical-flow oracle;本轮未实现 homography。
## 本轮结论边界
- 已完成:四步 DMD 模型的 hidden/residual-delta 特征采集、相似度与
nMSE/CKA、linear/ridge 条件 probe、负对照、运动分桶和光流对齐。
- 尚未完成:接入用户现有的小型非线性预测网络、真实 cache 替换干预、
最终视频质量与端到端加速评估、规模化多视频置信区间。
- 因而当前结果支持“前一 chunk 提供额外且具有空间对应性的条件信息”,
但还不能单独证明最终生成质量或真实加速收益。
## 输出文件
- `feature_pair_metrics.csv`
- `feature_pair_summary.csv`
- `conditional_probe_folds.csv`
- `conditional_probe_summary.csv`
- `motion_alignment_metrics.csv`
- `motion_alignment_summary.csv`
- `feature_redundancy_heatmap.png`
- `conditional_chunk_gain.png`
- `motion_alignment_analysis.png`
"""
(output_dir / "REPORT.md").write_text(report, encoding="utf-8")
def analyze(args: argparse.Namespace, paths: list[Path]) -> None:
print("[analysis] loading snapshots", flush=True)
runs = load_runs(paths)
pair_rows = collect_pair_rows(runs, cosine_only=args.cosine_only)
if args.cosine_only:
pair_summary = group_mean(
pair_rows,
["comparison", "stage", "target_step"],
[
"cosine",
"centered_cosine",
"linear_cka",
"rel_l2",
"nmse",
"token_cosine_mean",
"token_cosine_p10",
"token_cosine_p50",
"token_cosine_p90",
],
)
write_csv(args.output_dir / "feature_pair_metrics.csv", pair_rows)
write_csv(args.output_dir / "feature_pair_summary.csv", pair_summary)
plot_similarity(pair_rows, args.output_dir)
summary = {
"runs": len(runs),
"pair_rows": len(pair_rows),
"cosine_only": True,
}
(args.output_dir / "summary.json").write_text(
json.dumps(summary, indent=2) + "\n", encoding="utf-8"
)
print(f"[analysis] cosine-only complete: {args.output_dir}", flush=True)
return
probe_rows = run_conditional_probes(runs, ridge=args.ridge)
motion_rows = collect_motion_rows(runs)
pair_summary = group_mean(
pair_rows,
["comparison", "stage", "target_step"],
[
"cosine",
"centered_cosine",
"linear_cka",
"rel_l2",
"nmse",
"token_cosine_mean",
"token_cosine_p10",
"token_cosine_p50",
"token_cosine_p90",
],
)
probe_summary = group_mean(
probe_rows,
["stage", "step", "probe"],
[
"mse",
"nrmse",
"r2",
"cosine",
"mse_reduction_vs_within_affine",
"mse_reduction_vs_within_quadratic",
"r2_gain_vs_within_affine",
"r2_gain_vs_within_quadratic",
],
)
motion_summary = group_mean(
motion_rows,
["motion_bin", "step"],
[
"total_motion",
"camera_motion",
"object_motion",
"same_slot_cosine",
"boundary_raw_cosine",
"global_aligned_cosine",
"flow_aligned_cosine",
"valid_flow_ratio",
],
)
write_csv(args.output_dir / "feature_pair_metrics.csv", pair_rows)
write_csv(args.output_dir / "feature_pair_summary.csv", pair_summary)
write_csv(args.output_dir / "conditional_probe_folds.csv", probe_rows)
write_csv(args.output_dir / "conditional_probe_summary.csv", probe_summary)
write_csv(args.output_dir / "motion_alignment_metrics.csv", motion_rows)
write_csv(args.output_dir / "motion_alignment_summary.csv", motion_summary)
plot_similarity(pair_rows, args.output_dir)
plot_probe_gain(probe_rows, args.output_dir)
plot_motion(motion_rows, args.output_dir)
build_report(runs, pair_rows, probe_rows, motion_rows, args.output_dir)
summary = {
"runs": len(runs),
"pair_rows": len(pair_rows),
"probe_rows": len(probe_rows),
"motion_rows": len(motion_rows),
"report": str(args.output_dir / "REPORT.md"),
}
(args.output_dir / "summary.json").write_text(
json.dumps(summary, indent=2) + "\n", encoding="utf-8"
)
print(f"[analysis] complete: {args.output_dir / 'REPORT.md'}", flush=True)
def main() -> None:
args = parse_args()
args.config_path = resolve_path(args.config_path)
args.checkpoint_path = resolve_path(args.checkpoint_path)
args.prompt_path = resolve_path(args.prompt_path)
args.output_dir = resolve_path(args.output_dir)
args.output_dir.mkdir(parents=True, exist_ok=True)
random.seed(args.seed)
np.random.seed(args.seed)
torch.manual_seed(args.seed)
torch.set_grad_enabled(False)
run_paths = [
args.output_dir / "runs" / f"prompt_{index:02d}.pt"
for index in range(args.num_prompts)
]
if not args.analysis_only:
run_paths = generate_snapshots(args)
analyze(args, run_paths)
if __name__ == "__main__":
main()