| |
| """ |
| Anima trajectory imitation distillation |
| ======================================== |
| |
| DiffSynth-Studio の Z-Image trajectory imitation を Anima に最小移植したもの。 |
| **critic なし、単一ネットワーク**で前回 5 回失敗の R3GAN 不安定性を完全回避。 |
| |
| アルゴリズム: |
| - teacher = Anima base (frozen, no LoRA) を 50-step CFG=2.0 で rollout |
| - student = Anima base + wide LoRA (rank 32) を 8-step CFG=1.0 で rollout |
| - L_align = MSE(student velocity, teacher segment velocity) on 8 segments |
| - L_reg = LPIPS(student final image, teacher final image) (任意、--lpips-weight>0 で有効) |
| |
| warm-start: |
| --warm-lora /models/loras/anima_turbo.safetensors で Civitai 公式 Anima Turbo LoRA を |
| student LoRA 初期値に注入できる。format は ComfyUI 形式 (diffusion_model.<...>.lora_A.weight)、 |
| 内部で PEFT 形式に変換してロード。 |
| |
| 使い方 (Modal 経由): |
| # Smoke test (1 step, sanity check) |
| modal run modal_app.py::train_traj_imitation --total-steps 1 --batch-size 1 \\ |
| --teacher-steps 12 --student-steps 8 --lpips-weight 0.0 |
| |
| # 本番 (2000 step, ~$45, B200) |
| modal run --detach modal_app.py::train_traj_imitation \\ |
| --total-steps 2000 --batch-size 1 --teacher-steps 50 --student-steps 8 \\ |
| --warm-lora /models/loras/anima_turbo.safetensors --lpips-weight 0.1 |
| """ |
| from __future__ import annotations |
| import argparse |
| import copy |
| import json |
| import os |
| import sys |
| import time |
| from pathlib import Path |
|
|
| import torch |
| import torch.nn.functional as F |
| from torch.utils.data import DataLoader, Dataset |
| from safetensors.torch import save_file, load_file |
|
|
| |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) |
| from distill.anima_loader import AnimaPaths, build_anima, AnimaBundle |
| from distill.dmd2_trainer import attach_wide_lora |
| from distill.traj_scheduler import make_schedule, snap_to_targets |
| from distill.traj_loss import ( |
| fetch_trajectory, align_trajectory, compute_regularization, |
| ) |
|
|
|
|
| |
| |
| |
| class TextOnlyDataset(Dataset): |
| def __init__(self, root: str | Path): |
| self.root = Path(root) |
| |
| self.files = sorted(self.root.rglob("*.txt")) |
| if not self.files: |
| raise RuntimeError(f"No .txt files under {self.root}") |
|
|
| def __len__(self) -> int: |
| return len(self.files) |
|
|
| def __getitem__(self, idx: int) -> str: |
| return self.files[idx].read_text(encoding="utf-8").strip() |
|
|
|
|
| def text_collate(batch: list[str]) -> list[str]: |
| return list(batch) |
|
|
|
|
| |
| |
| |
| |
| def convert_comfy_to_peft_lora(sd: dict) -> dict: |
| """ComfyUI(Anima/Cosmos)形式 LoRA を PEFT 形式に変換。 |
| Comfy: 'diffusion_model.<module>.lora_A.weight' |
| PEFT: 'base_model.model.<module>.lora_A.default.weight' |
| """ |
| out = {} |
| for k, v in sd.items(): |
| nk = k |
| if nk.startswith("diffusion_model."): |
| nk = nk[len("diffusion_model."):] |
| |
| nk = nk.replace(".lora_A.weight", ".lora_A.default.weight") |
| nk = nk.replace(".lora_B.weight", ".lora_B.default.weight") |
| nk = nk.replace(".lora_A.bias", ".lora_A.default.bias") |
| nk = nk.replace(".lora_B.bias", ".lora_B.default.bias") |
| nk = "base_model.model." + nk |
| out[nk] = v |
| return out |
|
|
|
|
| def load_warm_lora(student_model, warm_lora_path: str) -> None: |
| """Civitai 形式の Anima Turbo LoRA を student の PEFT 形式に変換して注入。 |
| shape / rank が合わない key は skip して警告。""" |
| print(f"[warm] loading {warm_lora_path}") |
| sd_comfy = load_file(warm_lora_path) |
| print(f"[warm] {len(sd_comfy)} comfy keys, sample: {list(sd_comfy.keys())[:3]}") |
| sd_peft = convert_comfy_to_peft_lora(sd_comfy) |
| print(f"[warm] converted to {len(sd_peft)} peft keys, sample: {list(sd_peft.keys())[:3]}") |
|
|
| |
| model_sd = student_model.state_dict() |
| matched, skipped_shape, missing = 0, 0, 0 |
| to_load = {} |
| for k, v in sd_peft.items(): |
| if k not in model_sd: |
| missing += 1 |
| continue |
| if model_sd[k].shape != v.shape: |
| skipped_shape += 1 |
| continue |
| to_load[k] = v.to(dtype=model_sd[k].dtype) |
| matched += 1 |
| print(f"[warm] matched={matched} skipped_shape={skipped_shape} missing={missing}") |
| if matched == 0: |
| raise RuntimeError( |
| "No LoRA keys matched. Check format conversion & target_modules of wide LoRA." |
| ) |
| missing_keys, _ = student_model.load_state_dict(to_load, strict=False) |
| |
| lora_missing = [k for k in missing_keys if "lora_" in k] |
| if lora_missing: |
| print(f"[warm] WARN: {len(lora_missing)} lora keys not loaded (e.g. {lora_missing[:3]})") |
|
|
|
|
| |
| |
| |
| def save_lora_state(model, path: Path, name: str) -> None: |
| path.mkdir(parents=True, exist_ok=True) |
| sd = {k: v.detach().cpu() for k, v in model.state_dict().items() if "lora_" in k} |
| save_file(sd, str(path / f"{name}.safetensors")) |
|
|
|
|
| |
| |
| |
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--dataset", required=True, type=str, |
| help="caption (*.txt) を含むディレクトリ") |
| ap.add_argument("--out", required=True, type=str, |
| help="LoRA 出力ディレクトリ") |
| ap.add_argument("--warm-lora", default="", type=str, |
| help="Civitai Anima Turbo LoRA path (ComfyUI 形式)。空なら cold-start") |
| ap.add_argument("--total-steps", type=int, default=2000) |
| ap.add_argument("--batch-size", type=int, default=1) |
| ap.add_argument("--teacher-steps", type=int, default=50) |
| ap.add_argument("--student-steps", type=int, default=8) |
| ap.add_argument("--teacher-cfg", type=float, default=2.0) |
| ap.add_argument("--student-cfg", type=float, default=1.0) |
| ap.add_argument("--sigma-shift", type=float, default=3.0, |
| help="Anima 公式 workflow は 3.0") |
| ap.add_argument("--lora-rank", type=int, default=32) |
| ap.add_argument("--lr", type=float, default=1e-4) |
| ap.add_argument("--weight-decay", type=float, default=0.01) |
| ap.add_argument("--grad-clip", type=float, default=1.0) |
| ap.add_argument("--lpips-weight", type=float, default=0.0, |
| help="LPIPS regularization weight。0 で disable (start 推奨)、後で 0.1-0.5 に") |
| ap.add_argument("--resolution", type=int, default=1024) |
| ap.add_argument("--log-every", type=int, default=10) |
| ap.add_argument("--sample-every", type=int, default=500) |
| ap.add_argument("--num-workers", type=int, default=2) |
| ap.add_argument("--seed", type=int, default=42) |
| ap.add_argument("--weight-mode", default="uniform", choices=["uniform", "inv_sigma"]) |
| ap.add_argument("--neg-prompt", default="", |
| help="negative prompt for teacher CFG (空文字 = empty conditioning)") |
| args = ap.parse_args() |
|
|
| torch.manual_seed(args.seed) |
| device = torch.device("cuda") |
| dtype = torch.bfloat16 |
|
|
| out_dir = Path(args.out) |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| |
| print("[load] Anima bundle (DiT + Qwen3 + WanVAE + LLMAdapter)") |
| bundle = build_anima(device=device, dtype=dtype) |
|
|
| |
| print("[setup] cloning DiT for teacher (frozen) and student (wide LoRA)") |
| teacher_transformer = copy.deepcopy(bundle.transformer).to(device=device, dtype=dtype).eval() |
| for p in teacher_transformer.parameters(): |
| p.requires_grad = False |
|
|
| |
| student_transformer = bundle.transformer |
| student_transformer = attach_wide_lora(student_transformer, rank=args.lora_rank) |
| student_transformer.to(device=device, dtype=dtype) |
| |
| for n, p in student_transformer.named_parameters(): |
| p.requires_grad = ("lora_" in n) |
| trainable = sum(p.numel() for p in student_transformer.parameters() if p.requires_grad) |
| total = sum(p.numel() for p in student_transformer.parameters()) |
| print(f"[setup] student trainable: {trainable/1e6:.1f}M / {total/1e6:.1f}M") |
| |
| |
| bundle.transformer = student_transformer |
|
|
| |
| if args.warm_lora: |
| load_warm_lora(student_transformer, args.warm_lora) |
|
|
| |
| lpips_fn = None |
| if args.lpips_weight > 0: |
| import lpips as _lpips |
| lpips_fn = _lpips.LPIPS(net="alex").to(device).eval() |
| for p in lpips_fn.parameters(): |
| p.requires_grad = False |
| print("[setup] LPIPS(alex) loaded for regularization") |
|
|
| |
| |
| student_sched = make_schedule(args.student_steps, args.sigma_shift, device=device, dtype=torch.float32) |
| student_ts = student_sched.timesteps |
| print(f"[schedule] student t = {student_ts.tolist()}") |
|
|
| |
| print(f"[data] loading {args.dataset}") |
| dataset = TextOnlyDataset(args.dataset) |
| print(f" {len(dataset)} captions") |
| loader = DataLoader( |
| dataset, |
| batch_size=args.batch_size, |
| shuffle=True, |
| num_workers=args.num_workers, |
| collate_fn=text_collate, |
| drop_last=True, |
| ) |
|
|
| |
| opt = torch.optim.AdamW( |
| [p for p in student_transformer.parameters() if p.requires_grad], |
| lr=args.lr, betas=(0.9, 0.999), weight_decay=args.weight_decay, eps=1e-8, |
| ) |
|
|
| |
| print(f"[train] total_steps={args.total_steps} batch={args.batch_size} " |
| f"teacher={args.teacher_steps} student={args.student_steps} " |
| f"lpips_weight={args.lpips_weight}") |
| log_path = out_dir / "traj_log.jsonl" |
| log_f = open(log_path, "a", buffering=1) |
| t0 = time.time() |
| data_iter = iter(loader) |
|
|
| |
| def _student_v(x, t, cond): |
| return AnimaBundle.dit_forward(student_transformer, x, t, cond) |
|
|
| def _teacher_v(x, t, cond): |
| return AnimaBundle.dit_forward(teacher_transformer, x, t, cond) |
|
|
| |
| with torch.no_grad(): |
| cond_neg = bundle.text_encode([args.neg_prompt or ""]) |
| |
| H_lat = args.resolution // 8 |
| W_lat = args.resolution // 8 |
|
|
| for step in range(args.total_steps): |
| try: |
| captions = next(data_iter) |
| except StopIteration: |
| data_iter = iter(loader) |
| captions = next(data_iter) |
|
|
| B = len(captions) |
|
|
| |
| with torch.no_grad(): |
| cond_pos = bundle.text_encode(captions) |
| |
| cond_neg_b = cond_neg.expand(B, -1, -1).contiguous() if B > 1 else cond_neg |
|
|
| |
| init_noise = torch.randn( |
| B, 16, 1, H_lat, W_lat, device=device, dtype=dtype, |
| ) |
|
|
| |
| teacher_sched = make_schedule(args.teacher_steps, args.sigma_shift, |
| device=device, dtype=torch.float32) |
| teacher_sched = snap_to_targets(teacher_sched, student_ts) |
| teacher_transformer.eval() |
| teacher_traj = fetch_trajectory( |
| _teacher_v, init_noise, teacher_sched, student_ts, |
| cond_pos, cond_neg_b, cfg_scale=args.teacher_cfg, |
| ) |
|
|
| |
| student_transformer.train() |
| l_align = align_trajectory( |
| _student_v, teacher_traj, student_ts, |
| cond_pos, cond_neg_b, cfg_scale_student=args.student_cfg, |
| weight_mode=args.weight_mode, |
| ) |
|
|
| |
| if lpips_fn is not None: |
| l_reg = compute_regularization( |
| _student_v, bundle, init_noise, |
| teacher_final_latent=teacher_traj[-1], |
| student_timesteps=student_ts, |
| cond_pos=cond_pos, cond_neg=cond_neg_b, |
| lpips_fn=lpips_fn, |
| cfg_scale=args.student_cfg, |
| ) |
| else: |
| l_reg = torch.zeros((), device=device) |
|
|
| loss = l_align + args.lpips_weight * l_reg |
|
|
| opt.zero_grad() |
| loss.backward() |
| torch.nn.utils.clip_grad_norm_( |
| [p for p in student_transformer.parameters() if p.requires_grad], |
| args.grad_clip, |
| ) |
| opt.step() |
|
|
| |
| if step % args.log_every == 0: |
| metrics = { |
| "step": step, |
| "elapsed": time.time() - t0, |
| "loss": float(loss.detach()), |
| "l_align": float(l_align.detach()), |
| "l_reg": float(l_reg.detach()), |
| } |
| log_f.write(json.dumps(metrics) + "\n") |
| msg = " ".join(f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}" |
| for k, v in metrics.items() if k != "step") |
| print(f"[step {step}/{args.total_steps}] {msg}", flush=True) |
|
|
| |
| if step > 0 and step % args.sample_every == 0: |
| save_lora_state(student_transformer, out_dir, f"traj_step{step:05d}") |
| print(f"[save] traj_step{step:05d}.safetensors", flush=True) |
| try: |
| import modal |
| modal.Volume.from_name("anima-outputs").commit() |
| print(f"[save] volume committed at step {step}", flush=True) |
| except Exception as e: |
| print(f"[save] volume commit failed: {e}", flush=True) |
|
|
| |
| print("[done] saving final LoRA") |
| save_lora_state(student_transformer, out_dir, "traj_final") |
| log_f.close() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|