| """ |
| 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: |
| |
| batch_size: int = 4 |
| lr_gen: float = 2e-5 |
| lr_guidance: float = 2e-5 |
| lr_disc: float = 2e-4 |
| grad_clip: float = 1.0 |
|
|
| |
| guidance_updates: int = 5 |
| lora_rank: int = 32 |
| lora_scale_f: float = 2.0 |
| lora_scale_r: float = 0.75 |
| cfg_r: float = 2.0 |
| dynamic_decay_steps: int = 2000 |
|
|
| |
| gui_alpha: float = 4.0 |
| gui_beta: float = 1.5 |
| num_inference_steps: int = 8 |
|
|
| |
| tscd_weight: float = 0.5 |
| ema_decay: float = 0.999 |
|
|
| |
| adv_weight: float = 0.1 |
| r3gan_gamma: float = 50.0 |
|
|
| |
| cfg_aug_cold_steps: int = 200 |
|
|
|
|
| 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: |
| |
| 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 |
| |
| 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: |
| |
| 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)""" |
| |
| u = torch.rand(batch_size, device=device) |
| |
| |
| 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 |
| |
| 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) |
| |
| for eb, b in zip(self.ema_model.buffers(), model.buffers()): |
| eb.copy_(b.detach()) |
|
|
| def __call__(self, **kwargs): |
| return self.ema_model(**kwargs) |
|
|
|
|
| |
| |
| |
|
|
| class DMD2Trainer: |
| def __init__( |
| self, |
| bundle: AnimaBundle, |
| gen_transformer: nn.Module, |
| guidance_transformer: nn.Module, |
| 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 |
|
|
| |
| 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, |
| ) |
|
|
| |
| self.ema = EMA(self.gen, decay=args.ema_decay) |
|
|
| |
| 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 |
|
|
| |
| 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()} |
|
|
| |
| 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 |
|
|
| |
| 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()) |
|
|
| |
| 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) |
|
|
| |
| v_gen = AnimaBundle.dit_forward(self.gen, noisy, t, cond) |
| x0_pred = AnimaBundle.x0_from_velocity(noisy, v_gen, t) |
|
|
| |
| 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()) |
|
|
| |
| consist_loss = self._tscd_loss(x0_pred, cond, t) |
|
|
| |
| 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(), |
| } |
|
|
| |
| 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) |
|
|
| |
| 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, |
| ) |
|
|
| |
| |
| |
| 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 |
|
|
| |
| def train_step(self, batch: dict) -> dict: |
| |
| with torch.no_grad(): |
| cond = self.bundle.text_encode(batch["captions"]) |
| real_latents = self.bundle.vae_encode(batch["pixels"].to(self.device)) |
|
|
| |
| log = {} |
| for _ in range(self.args.guidance_updates): |
| log.update(self.step_guidance(real_latents, cond)) |
|
|
| |
| log.update(self.step_generator(real_latents, cond)) |
|
|
| |
| if self.args.adv_weight > 0: |
| log.update(self.step_discriminator(real_latents, cond)) |
|
|
| self.step += 1 |
| return log |
|
|