""" DMD2 trainer for Anima (DMDR pattern + R3GAN + TSCD) ==================================================== DMDR paper の "2 モデル + LoRA on/off で real/fake 切替" 方式を踏襲。 ここに R3GAN discriminator と TSCD consistency loss を統合する。 Models: gen_model : Anima DiT (full trainable, no LoRA) guidance_model : Anima DiT (Q,V LoRA, scale 切替で real/fake) discriminator : R3GAN projection D (text-conditional) ema_gen : gen の EMA (TSCD target) Update schedule per outer step: - guidance × N_GUIDANCE (default 5) - generator × 1 - discriminator × 1 """ from __future__ import annotations import copy import math from dataclasses import dataclass import torch import torch.nn as nn import torch.nn.functional as F import peft from .anima_loader import AnimaBundle from .r3gan_disc import R3GANDiscriminator, d_loss_r3gan, g_loss_r3gan @dataclass class DMD2Args: # Training batch_size: int = 4 lr_gen: float = 2e-5 lr_guidance: float = 2e-5 lr_disc: float = 2e-4 grad_clip: float = 1.0 # DMD2 / DMDR guidance_updates: int = 5 # 1 outer step あたりの guidance update 回数 lora_rank: int = 32 lora_scale_f: float = 2.0 # fake-score (LoRA ON) lora_scale_r: float = 0.75 # real-score start (cosine decay → 0) cfg_r: float = 2.0 # real-score 側 CFG (Augmentation の Spear) dynamic_decay_steps: int = 2000 # lora_scale_r を cosine で 0 にする step # Timestep sampling gui_alpha: float = 4.0 # Beta(α, β) for continuous t gui_beta: float = 1.5 num_inference_steps: int = 8 # gen の discrete grid (Phase A=8, B=4, C=2) # TSCD tscd_weight: float = 0.5 ema_decay: float = 0.999 # R3GAN adv_weight: float = 0.1 r3gan_gamma: float = 50.0 # CFG-Aug schedule cfg_aug_cold_steps: int = 200 # 最初は cfg_r=0 で wait def attach_qv_lora(transformer: nn.Module, rank: int = 32) -> nn.Module: """attention Q/V projection に LoRA を attach。MiniTrainDIT の実 module 名を inspection で見つける。""" Q_KEYS = ("to_q", "q_proj", "wq", "q") V_KEYS = ("to_v", "v_proj", "wv", "v") target_modules = [] for name, module in transformer.named_modules(): if not isinstance(module, nn.Linear): continue leaf = name.rsplit(".", 1)[-1] if leaf in Q_KEYS or leaf in V_KEYS: target_modules.append(name) if not target_modules: # Fallback 1: 名前に 'attn' が入る Linear すべて for name, module in transformer.named_modules(): if isinstance(module, nn.Linear) and ( "attn" in name.lower() or "attention" in name.lower() ): target_modules.append(name) if not target_modules: print("[lora] WARN: no attention Q/V found, falling back to all-linear") target_modules = "all-linear" else: print(f"[lora] target_modules={len(target_modules)} layers (Q/V or attn)") cfg = peft.LoraConfig( r=rank, lora_alpha=rank, lora_dropout=0.0, bias="none", target_modules=target_modules, ) return peft.get_peft_model(transformer, cfg) def attach_wide_lora(transformer: nn.Module, rank: int = 32) -> nn.Module: """全 nn.Linear に LoRA を attach (LLM adapter 内部は除外)。 Anima/Cosmos の AdaLN modulation・attention・MLP すべてを学習対象にする。 timestep 解釈を含む蒸留タスクには Q,V LoRA では不足、これが必要。""" target_modules = [] for name, module in transformer.named_modules(): if not isinstance(module, nn.Linear): continue # LLM adapter (Qwen3→T5 bridge) は触らない (壊れやすい) if "llm_adapter" in name: continue target_modules.append(name) if not target_modules: raise RuntimeError("No nn.Linear found in transformer for wide LoRA") # 概要 cats = {"attn": 0, "adaln": 0, "mlp": 0, "other": 0} for n in target_modules: nl = n.lower() if "attn" in nl or "attention" in nl: cats["attn"] += 1 elif "adaln" in nl or "modulation" in nl: cats["adaln"] += 1 elif "mlp" in nl or "ffn" in nl or "feed" in nl: cats["mlp"] += 1 else: cats["other"] += 1 print(f"[lora] wide target: total={len(target_modules)} " f"(attn={cats['attn']}, adaln={cats['adaln']}, mlp={cats['mlp']}, other={cats['other']})") cfg = peft.LoraConfig( r=rank, lora_alpha=rank, lora_dropout=0.0, bias="none", target_modules=target_modules, ) return peft.get_peft_model(transformer, cfg) def set_lora_scale(model: nn.Module, scale: float) -> None: """PEFT LoRA の scaling を runtime で書き換え。real/fake 切替用。""" for module in model.modules(): if hasattr(module, "scaling") and isinstance(module.scaling, dict): for key in module.scaling: # alpha/r = 1.0 を基準に scale 倍する module.scaling[key] = scale def sample_continuous_t( batch_size: int, alpha: float, beta: float, device: torch.device ) -> torch.Tensor: """logit-normal Beta(α, β) サンプリング。返り値 t ∈ (0, 1)""" # Beta -> logit-normal 風の重み (DMDR の sample_continue 簡略版) u = torch.rand(batch_size, device=device) # Beta(α, β) inverse CDF を近似:正規 logit に変換するシンプル版 # ここでは log-normal で代用 (実装簡易のため) t = torch.distributions.Beta(alpha, beta).sample((batch_size,)).to(device) return t.clamp(1e-3, 1 - 1e-3) def sample_discrete_t( batch_size: int, num_steps: int, device: torch.device ) -> torch.Tensor: """generator 側: 離散 grid {1/N, 2/N, ..., (N-1)/N} から uniform サンプル。""" grid = torch.linspace(1.0 / num_steps, 1.0 - 1.0 / num_steps, num_steps - 1, device=device) idx = torch.randint(0, num_steps - 1, (batch_size,), device=device) return grid[idx] def cosine_decay(step: int, total: int) -> float: if step >= total: return 0.0 return 0.5 * (1 + math.cos(math.pi * step / total)) class EMA: """生成器の EMA を別 model copy として保持。TSCD target として直接 forward 可能。 only_trainable=True (default) なら trainable param のみ EMA 更新する。 LoRA-only モードでは base は frozen なので大半が skip され、EMA cost が激減。""" def __init__(self, model: nn.Module, decay: float = 0.999, only_trainable: bool = True): self.decay = decay self.only_trainable = only_trainable # 同 device/dtype の eval-only copy self.ema_model = copy.deepcopy(model).eval() for p in self.ema_model.parameters(): p.requires_grad = False @torch.no_grad() def update(self, model: nn.Module) -> None: for ep, p in zip(self.ema_model.parameters(), model.parameters()): if self.only_trainable and not p.requires_grad: continue ep.mul_(self.decay).add_(p.detach(), alpha=1 - self.decay) # buffers (RoPE 等) は最新を採用 for eb, b in zip(self.ema_model.buffers(), model.buffers()): eb.copy_(b.detach()) def __call__(self, **kwargs): return self.ema_model(**kwargs) # ============================================================================ # Trainer # ============================================================================ class DMD2Trainer: def __init__( self, bundle: AnimaBundle, gen_transformer: nn.Module, guidance_transformer: nn.Module, # PEFT-wrapped (LoRA attached) discriminator: R3GANDiscriminator, args: DMD2Args, ): self.bundle = bundle self.gen = gen_transformer self.guidance = guidance_transformer self.disc = discriminator self.args = args self.device = bundle.device self.step = 0 # Optimizers (gen も frozen 部分があるなら filter) self.opt_gen = torch.optim.AdamW( [p for p in self.gen.parameters() if p.requires_grad], lr=args.lr_gen, betas=(0.9, 0.999), weight_decay=0.01, eps=1e-8, ) self.opt_guidance = torch.optim.AdamW( [p for p in self.guidance.parameters() if p.requires_grad], lr=args.lr_guidance, betas=(0.9, 0.999), weight_decay=0.0, eps=1e-8, ) self.opt_disc = torch.optim.Adam( self.disc.parameters(), lr=args.lr_disc, betas=(0.0, 0.99), eps=1e-8, ) # EMA for TSCD self.ema = EMA(self.gen, decay=args.ema_decay) # -- helpers ------------------------------------------------------------ def _set_train(self, gen: bool, guidance: bool, disc: bool): self.gen.train(gen); self.guidance.train(guidance); self.disc.train(disc) def _t_continuous(self, B: int) -> torch.Tensor: return sample_continuous_t(B, self.args.gui_alpha, self.args.gui_beta, self.device) def _t_discrete(self, B: int) -> torch.Tensor: return sample_discrete_t(B, self.args.num_inference_steps, self.device) def _cfg_r_current(self) -> float: """cold start 中は cfg_r=0、それ以降は args.cfg_r。""" return 0.0 if self.step < self.args.cfg_aug_cold_steps else self.args.cfg_r def _lora_scale_r_current(self) -> float: decay = cosine_decay(self.step, self.args.dynamic_decay_steps) return self.args.lora_scale_r * decay # -- guidance update ---------------------------------------------------- def step_guidance(self, real_latents: torch.Tensor, cond: torch.Tensor) -> dict: """fake-score (LoRA ON) を v-prediction MSE で更新""" self._set_train(gen=False, guidance=True, disc=False) set_lora_scale(self.guidance, self.args.lora_scale_f) B = real_latents.size(0) noise = torch.randn_like(real_latents) t = self._t_continuous(B) noisy = AnimaBundle.add_noise(real_latents, noise, t) v_target = AnimaBundle.velocity_target(real_latents, noise) v_pred = AnimaBundle.dit_forward(self.guidance, noisy, t, cond) loss = F.mse_loss(v_pred, v_target) loss.backward() torch.nn.utils.clip_grad_norm_( [p for p in self.guidance.parameters() if p.requires_grad], self.args.grad_clip) self.opt_guidance.step(); self.opt_guidance.zero_grad() return {"l_guidance": loss.detach()} # -- CFG forward (real-score side) ------------------------------------- def _real_score_forward( self, noisy: torch.Tensor, t: torch.Tensor, cond: torch.Tensor, cfg: float, ) -> torch.Tensor: """LoRA OFF / decay 状態の guidance_model で real-side velocity を取得。 CFG > 1 のときは null condition と blend。""" set_lora_scale(self.guidance, self._lora_scale_r_current()) v_cond = AnimaBundle.dit_forward(self.guidance, noisy, t, cond) if cfg > 1.0: null_cond = torch.zeros_like(cond) v_uncond = AnimaBundle.dit_forward(self.guidance, noisy, t, null_cond) v = v_uncond + cfg * (v_cond - v_uncond) else: v = v_cond return v # -- TSCD consistency loss --------------------------------------------- def _tscd_loss( self, x0_pred: torch.Tensor, cond: torch.Tensor, t: torch.Tensor ) -> torch.Tensor: """segment-wise consistency vs EMA gen。 TSCD 簡易版: 同じ x0 ground 周辺で noisy 化 → EMA で x0 推定 → MSE。""" with torch.no_grad(): noise = torch.randn_like(x0_pred) noisy = AnimaBundle.add_noise(x0_pred.detach(), noise, t) v_ema = AnimaBundle.dit_forward(self.ema.ema_model, noisy, t, cond) x0_ema = AnimaBundle.x0_from_velocity(noisy, v_ema, t) return F.mse_loss(x0_pred, x0_ema.detach()) # -- generator update --------------------------------------------------- def step_generator(self, real_latents: torch.Tensor, cond: torch.Tensor) -> dict: self._set_train(gen=True, guidance=False, disc=False) B = real_latents.size(0) noise = torch.randn_like(real_latents) t = self._t_discrete(B) noisy = AnimaBundle.add_noise(real_latents, noise, t) # Generator forward v_gen = AnimaBundle.dit_forward(self.gen, noisy, t, cond) x0_pred = AnimaBundle.x0_from_velocity(noisy, v_gen, t) # === DMD gradient === with torch.no_grad(): cfg = self._cfg_r_current() v_real = self._real_score_forward(noisy, t, cond, cfg=cfg) set_lora_scale(self.guidance, self.args.lora_scale_f) v_fake = AnimaBundle.dit_forward(self.guidance, noisy, t, cond) x0_real = AnimaBundle.x0_from_velocity(noisy, v_real, t) x0_fake = AnimaBundle.x0_from_velocity(noisy, v_fake, t) p_real = x0_pred - x0_real p_fake = x0_pred - x0_fake denom = p_real.abs().mean(dim=list(range(1, p_real.dim())), keepdim=True) + 1e-8 grad = (p_real - p_fake) / denom dmd_loss = 0.5 * F.mse_loss(x0_pred.float(), (x0_pred - grad).detach().float()) # === TSCD consistency === consist_loss = self._tscd_loss(x0_pred, cond, t) # === R3GAN adversarial (generator side) — adv_weight>0 のときだけ === if self.args.adv_weight > 0: d_fake = self.disc(x0_pred, cond) with torch.no_grad(): d_real_for_g = self.disc(real_latents, cond) adv_loss = g_loss_r3gan(d_real_for_g, d_fake) else: adv_loss = torch.zeros((), device=self.device) loss = ( dmd_loss + self.args.tscd_weight * consist_loss + self.args.adv_weight * adv_loss ) loss.backward() torch.nn.utils.clip_grad_norm_(self.gen.parameters(), self.args.grad_clip) self.opt_gen.step(); self.opt_gen.zero_grad() self.ema.update(self.gen) return { "l_dmd": dmd_loss.detach(), "l_consist": consist_loss.detach(), "l_adv_g": adv_loss.detach(), "loss_gen_total": loss.detach(), } # -- discriminator update ---------------------------------------------- def step_discriminator(self, real_latents: torch.Tensor, cond: torch.Tensor) -> dict: self._set_train(gen=False, guidance=False, disc=True) B = real_latents.size(0) # 生成器で fake samples を作成 (no_grad) with torch.no_grad(): noise = torch.randn_like(real_latents) t_init = torch.ones(B, device=self.device) v = AnimaBundle.dit_forward(self.gen, noise, t_init, cond) fake_latents = AnimaBundle.x0_from_velocity(noise, v, t_init) real_samples = real_latents.detach().clone().requires_grad_(True) fake_samples = fake_latents.detach().clone().requires_grad_(True) d_real = self.disc(real_samples, cond) d_fake = self.disc(fake_samples, cond) loss, metrics = d_loss_r3gan( d_real, d_fake, real_samples, fake_samples, gamma=self.args.r3gan_gamma, ) # GUARD: R1/R2 が異常に大きくなったら abort # latent 空間 (16ch × 128×128 = 262k) では正常時でも R1/R2 ~ 10-100 になる。 # Phase A は 700 -> 700k で爆発。1000 を超えたら abort。 r1_val = float(metrics["d_r1"]) r2_val = float(metrics["d_r2"]) if r1_val > 1000.0 or r2_val > 1000.0: raise RuntimeError( f"R3GAN penalty exploded (r1={r1_val:.1f}, r2={r2_val:.1f}). " f"Reduce r3gan_gamma (current={self.args.r3gan_gamma}). " f"Aborting to prevent gen damage." ) loss.backward() torch.nn.utils.clip_grad_norm_(self.disc.parameters(), self.args.grad_clip) self.opt_disc.step(); self.opt_disc.zero_grad() metrics["l_disc"] = loss.detach() return metrics # -- main step ---------------------------------------------------------- def train_step(self, batch: dict) -> dict: # 1) text encode + VAE encode (no grad) with torch.no_grad(): cond = self.bundle.text_encode(batch["captions"]) real_latents = self.bundle.vae_encode(batch["pixels"].to(self.device)) # 2) guidance × N log = {} for _ in range(self.args.guidance_updates): log.update(self.step_guidance(real_latents, cond)) # 3) generator × 1 log.update(self.step_generator(real_latents, cond)) # 4) discriminator × 1 (adv_weight=0 のときは skip して R3GAN を完全 disable) if self.args.adv_weight > 0: log.update(self.step_discriminator(real_latents, cond)) self.step += 1 return log