darask0 commited on
Commit
77cc641
·
verified ·
1 Parent(s): 1116dcd

Initial commit: rapid-anima distillation codebase

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. LICENSE +29 -0
  3. README.md +121 -0
  4. configs/phase1_anima.toml +75 -0
  5. configs/phase1_dataset.toml +34 -0
  6. docs/distillation.md +468 -0
  7. docs/dmd2.md +115 -0
  8. docs/dmdx.md +141 -0
  9. docs/migration_log.md +583 -0
  10. docs/operations.md +158 -0
  11. docs/pcm.md +98 -0
  12. docs/setup.md +61 -0
  13. docs/workflow.md +210 -0
  14. modal_app.py +0 -0
  15. requirements.txt +4 -0
  16. samples/dmd2_step500.png +3 -0
  17. samples/dmdx_step500.png +3 -0
  18. scripts/anima_finetune_dataset_workflow.json +90 -0
  19. scripts/anima_verify_workflow.json +90 -0
  20. scripts/anima_workflow.json +82 -0
  21. scripts/anima_workflow_phase_a.json +82 -0
  22. scripts/anima_workflow_phase_a_step1000.json +32 -0
  23. scripts/anima_workflow_phase_b.json +37 -0
  24. scripts/anima_workflow_turbo.json +90 -0
  25. scripts/clean_captions.py +152 -0
  26. scripts/compare_prompts.txt +6 -0
  27. scripts/distill/__init__.py +0 -0
  28. scripts/distill/anima_ladd_disc.py +271 -0
  29. scripts/distill/anima_loader.py +275 -0
  30. scripts/distill/dataset.py +84 -0
  31. scripts/distill/dmd2_official_loss.py +244 -0
  32. scripts/distill/dmd2_trainer.py +430 -0
  33. scripts/distill/dmdx_loss.py +195 -0
  34. scripts/distill/hps_reward.py +85 -0
  35. scripts/distill/pcm_scheduler.py +107 -0
  36. scripts/distill/precompute_teacher_x0.py +155 -0
  37. scripts/distill/r3gan_disc.py +200 -0
  38. scripts/distill/shortcut_module.py +111 -0
  39. scripts/distill/sid_loss.py +209 -0
  40. scripts/distill/train_dmd2_official.py +305 -0
  41. scripts/distill/train_dmdx.py +248 -0
  42. scripts/distill/train_draftp.py +250 -0
  43. scripts/distill/train_ladd.py +293 -0
  44. scripts/distill/train_pcm.py +250 -0
  45. scripts/distill/train_reflow.py +246 -0
  46. scripts/distill/train_shortcut.py +226 -0
  47. scripts/distill/train_sid.py +236 -0
  48. scripts/distill/train_sota.py +260 -0
  49. scripts/distill/train_traj.py +363 -0
  50. scripts/distill/traj_loss.py +207 -0
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ samples/dmd2_step500.png filter=lfs diff=lfs merge=lfs -text
37
+ samples/dmdx_step500.png filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 daraskme
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ NOTE: This license covers the **code** in this repository only. Models
26
+ produced or fine-tuned using this code are governed by the upstream model
27
+ licenses (CircleStone Labs Non-Commercial License + NVIDIA Open Model License
28
+ Derivative Model terms for Anima). Such derivative models may only be used
29
+ for non-commercial purposes.
README.md ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # rapid_anima — Anima 生成速度向上 on Modal
2
+
3
+ CircleStone Labs の [Anima](https://huggingface.co/circlestone-labs/Anima)
4
+ (2B パラメータの DiT)の **生成速度を改善** するための一式。
5
+ 旧名 `darask_anima`、2026-05 にスコープを「fine-tune + 蒸留」から「速度向上特化」に転換。
6
+
7
+ > ⚠️ **ライセンス**: Anima は CircleStone Labs Non-Commercial License +
8
+ > NVIDIA Open Model License (Derivative Model 条項) で **非商用のみ**。
9
+ > 派生モデルも非商用縛り。詳細は [末尾のライセンス節](#ライセンス) 参照。
10
+
11
+ ## 主な高速化レバー
12
+
13
+ - **B200 sageattention 実 engage** — sm_100 patched wheel で silent fallback 回避
14
+ ([darask0/modal_B200_sageattetion_comfyUI](https://huggingface.co/darask0/modal_B200_sageattetion_comfyUI))
15
+ - **batch=8 並列生成** — ComfyUI EmptyLatentImage 経由で 1 submit に 8 枚
16
+ - **蒸留 LoRA** — Civitai 公式 Anima Turbo merge / 自前蒸留 (PCM, Z-Image traj, LADD, Reflow)
17
+ - **ComfyUI 推論最適化** — CapitanFlowMatch scheduler, sageattention, DPM-Solver++ 等
18
+
19
+ ### 実測 (B200, 1024² / 30 step)
20
+
21
+ | 構成 | per-image | 5000 枚 cost |
22
+ |---|---|---|
23
+ | 旧 batch=1 (no sage) | ~3.3s | ~$44 |
24
+ | **新 batch=8 + sage** | **~3.05s** | **~$26** |
25
+ | 4-step 蒸留 LoRA + sage | ~0.5-1s | (推論時のみ) |
26
+
27
+ ## 配布中の LoRA
28
+
29
+ [**huggingface.co/darask0/anima-distill-loras**](https://huggingface.co/darask0/anima-distill-loras)
30
+ に Anima v1.0 base 用の蒸留 LoRA を集約 (PEFT + ComfyUI 両形式)。
31
+
32
+ | サブディレクトリ | 手法 | step / CFG | ステータス | 詳細 |
33
+ |---|---|---|---|---|
34
+ | [`pcm/`](https://huggingface.co/darask0/anima-distill-loras/tree/main/pcm) | Phased Consistency Model | 4-step / CFG=1.0 | ✅ 配布中 | [docs/pcm.md](docs/pcm.md) |
35
+ | `dmd2/` (近日) | DMD2 + TrigFlow | 4-step / CFG=1.0 | 🟢 訓練中 (resume) | [docs/dmd2.md](docs/dmd2.md) |
36
+ | `dmdx/` (近日) | DMDX (ADM) | 4-step / CFG=1.0 | 🟢 訓練中 (新規実装) | [docs/dmdx.md](docs/dmdx.md) |
37
+
38
+ 将来追加検討: LADD / Reflow / Z-Image trajectory / SiD2 など。
39
+
40
+ **ComfyUI 使い方の最短手順**:
41
+
42
+ 1. `*_comfy.safetensors` を `ComfyUI/models/loras/` に配置
43
+ 2. Anima v1.0 base workflow に `LoraLoaderModelOnly` を挿入、`strength_model: 1.0`
44
+ 3. KSampler を **steps=4 / cfg=1.0**、推奨 sampler / scheduler を選択
45
+
46
+ 詳細な使い方・訓練詳細は各 LoRA の HF model card / docs/{pcm,dmd2,dmdx}.md を参照。
47
+
48
+ ### サンプル画像 (4-step / CFG=1.0 / er_sde + simple)
49
+
50
+ DMD2 と DMDX、それぞれ step 500 時点での生成 (warm-start: Civitai Anima Turbo)。
51
+ 同 prompt (`2girls, flandre scarlet, remilia scarlet, touhou, ...`)、同 seed=42。
52
+
53
+ | DMD2 step 500 | DMDX step 500 |
54
+ |---|---|
55
+ | ![dmd2_step500](samples/dmd2_step500.png) | ![dmdx_step500](samples/dmdx_step500.png) |
56
+
57
+ step 500 ではどちらも warm-start (Civitai Turbo) が支配的で、訓練の差異は微小。
58
+ 本番完走 (step 5000) 後に再比較予定。
59
+
60
+ ## ディレクトリ構成 (簡略)
61
+
62
+ ```
63
+ rapid_anima/
64
+ ├── modal_app.py # Modal アプリ本体 (全 function 定義)
65
+ ├── configs/ # diffusion-pipe 設定 (Phase 1 fine-tune 用)
66
+ ├── scripts/
67
+ │ ├── generate_dataset.py # ComfyUI 経由 self-distill 生成
68
+ │ ├── clean_captions.py # quality/meta タグ除去
69
+ │ ├── *_workflow.json # Anima ComfyUI workflow テンプレ
70
+ │ ├── *_prompts.txt # 各種 prompt セット
71
+ │ └── distill/ # 蒸留実装 (PCM / DMD2 / DMDX / LADD / Reflow / Z-Image 等)
72
+ ├── docs/ # 詳細ドキュメント (下記参照)
73
+ ├── samples/ # README 用検証サンプル画像
74
+ ├── requirements.txt
75
+ └── README.md (このファイル)
76
+ ```
77
+
78
+ > **重要**: `modal_app.py` は `configs/` と `scripts/` をそのままの構造で
79
+ > image に同梱する。ファイルをルート直下にフラット配置すると `add_local_dir`
80
+ > で失敗するので注意。
81
+
82
+ ## ドキュメント
83
+
84
+ **手法別** (本日 v1.0 base での新規試行):
85
+
86
+ | ドキュメント | 内容 |
87
+ |---|---|
88
+ | [docs/pcm.md](docs/pcm.md) | PCM 蒸留 (5000 step 完走、HF 配布済、$22) |
89
+ | [docs/dmd2.md](docs/dmd2.md) | DMD2 + TrigFlow 蒸留 (resume 進行中、~$50) |
90
+ | [docs/dmdx.md](docs/dmdx.md) | DMDX (ADM、新規実装、arxiv 2507.18569v1 移植、進行中、$30) |
91
+
92
+ **インフラ / セットアップ**:
93
+
94
+ | ドキュメント | 内容 |
95
+ |---|---|
96
+ | [docs/setup.md](docs/setup.md) | Modal セットアップ、HF シークレット、データセット用意 |
97
+ | [docs/workflow.md](docs/workflow.md) | Step 0-5 (モデル DL / dataset / Phase 1 / 検証 / 蒸留)、GPU 選び、コスト概算 |
98
+ | [docs/distillation.md](docs/distillation.md) | Anima 蒸留 — R3GAN 5 連続失敗の deep dive、設計理由、教訓 |
99
+ | [docs/migration_log.md](docs/migration_log.md) | 2026-05 既存実装移植 (Z-Image / DMD2 / LADD / Reflow / PCM)、比較結果、reward hacking 実証 |
100
+ | [docs/operations.md](docs/operations.md) | Modal CLI / diffusion-pipe 罠、実測コスト、トラブルシューティング |
101
+
102
+ ## 参考資料
103
+
104
+ - 公式モデルカード: https://huggingface.co/circlestone-labs/Anima
105
+ - diffusion-pipe: https://github.com/tdrussell/diffusion-pipe
106
+ - DMD2 論文 (Z-Image): https://arxiv.org/abs/2511.22677
107
+ - LCM 論文: https://arxiv.org/abs/2310.04378
108
+ - Hyper-SD: https://github.com/bytedance/hyper-sd
109
+ - RDBT-Anima (Anima 蒸留先行例): https://civitai.com/models/2364703
110
+ - PCM 元論文: [Wang et al. NeurIPS 2024](https://arxiv.org/abs/2405.18407) / [G-U-N/Phased-Consistency-Model](https://github.com/G-U-N/Phased-Consistency-Model)
111
+
112
+ ## ライセンス
113
+
114
+ このリポジトリ自体のコードは MIT で公開。生成される派生モデル(蒸留 LoRA や
115
+ fine-tune 結果)は **Anima のライセンス** に従う必要があり、**非商用のみ**:
116
+
117
+ - CircleStone Labs Non-Commercial License
118
+ - NVIDIA Open Model License(Derivative Model 条項)
119
+
120
+ 詳細・商用利用問い合わせは Anima 公式ページを参照:
121
+ https://huggingface.co/circlestone-labs/Anima
configs/phase1_anima.toml ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # Anima Phase 1: quality タグ依存性除去 (LoRA)
3
+ # 目的: "masterpiece, best quality, score_9, ..." を書かなくても base が出していた
4
+ # 品質の絵が出るようにする。**審美の方向性は変えない**(artist タグ等は維持)。
5
+ # 性質: 概念追加でなく "prior shift" → rank 小・lr 低・epochs 少で十分。
6
+ # 過剰に回すと base の汎用性を損なう (catastrophic forgetting)。
7
+ # 使用: deepspeed --num_gpus=1 train.py --deepspeed --config phase1_anima.toml
8
+ # =============================================================================
9
+
10
+ output_dir = '/output/phase1'
11
+
12
+ # データセット設定 (別ファイル)
13
+ dataset = '/workspace/configs/phase1_dataset.toml'
14
+
15
+ # 学習設定 ---------------------------------------------------------------------
16
+ # epochs: prior shift だけなら 2 で十分。3 にすると過学習リスク微増。
17
+ # 1 epoch ≈ 3-4h (A100-80GB, 5k枚) → 2 epoch ≈ 6-8h ≈ $15-20
18
+ # 10k 枚使う場合: 1 epoch ≈ 6-7h → 2 epoch ≈ 12-14h ≈ $30-35
19
+ epochs = 2
20
+ micro_batch_size_per_gpu = 1
21
+ pipeline_stages = 1 # 単 GPU は 1
22
+ gradient_accumulation_steps = 4 # 実効 batch = 4 (5k枚以下なら 2 でも可)
23
+ gradient_clipping = 1.0
24
+ warmup_steps = 100
25
+
26
+ # CPU offload (blocks_to_swap > 0 で VRAM 節約だが速度低下。A100-80GB なら 0 でOK)
27
+ blocks_to_swap = 0
28
+
29
+ # 評価/保存
30
+ eval_every_n_epochs = 1
31
+ save_every_n_epochs = 1 # 古い ckpt は cleanup_checkpoints で間引く
32
+ checkpoint_every_n_minutes = 60 # 安全保険 (途中落ち対策)
33
+ activation_checkpointing = true
34
+ save_dtype = 'bfloat16'
35
+
36
+ # モデル ----------------------------------------------------------------------
37
+ [model]
38
+ type = 'anima'
39
+ # Anima preview3-base (公式 ComfyUI 形式)
40
+ transformer_path = '/models/checkpoints/anima-preview3-base.safetensors'
41
+ vae_path = '/models/checkpoints/qwen_image_vae.safetensors'
42
+ llm_path = '/models/checkpoints/qwen_3_06b_base.safetensors'
43
+ dtype = 'bfloat16'
44
+ transformer_dtype = 'bfloat16'
45
+
46
+ # 公式必須ルール: LLM adapter は学習しない
47
+ # (テキスト埋め込み変換器で、訓練すると壊れやすい)
48
+ llm_adapter_lr = 0
49
+
50
+ # Anima は flow matching ベース。timestep サンプリングは logit_normal が安定。
51
+ timestep_sample_method = 'logit_normal'
52
+
53
+ # LoRA --------------------------------------------------------------------
54
+ [adapter]
55
+ type = 'lora'
56
+ rank = 64 # 審美 FT は概念追加でないので 32〜64 で十分
57
+ alpha = 64 # = rank だと scale 1.0
58
+ dropout = 0.0
59
+ dtype = 'bfloat16'
60
+ # 対象モジュール: DiT の attention/MLP のみ (LLM adapter は除外)
61
+ # diffusion-pipe は anima 用にデフォルトで適切な層を選んでくれる
62
+
63
+ # Optimizer ---------------------------------------------------------------
64
+ [optimizer]
65
+ type = 'AdamW8bitKahan' # bf16 で安定、VRAM 節約
66
+ lr = 2e-5 # 公式推奨。広げるなら 1e-5〜5e-5
67
+ betas = [0.9, 0.99]
68
+ weight_decay = 0.01
69
+ eps = 1e-8
70
+ stabilize = false
71
+
72
+ # Monitoring --------------------------------------------------------------
73
+ [monitoring]
74
+ log_every_n_steps = 10
75
+ enable_wandb = false # wandb 使うなら true + secret 追加
configs/phase1_dataset.toml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # Phase 1 データセット設定
3
+ # 期待構造: /dataset/cleaned/<image>.{png,jpg,webp} と同名の <image>.txt
4
+ # (clean_captions.py 実行後の出力先)
5
+ # =============================================================================
6
+
7
+ resolutions = [1024] # Anima 推奨 1MP
8
+ enable_ar_bucket = true # アスペクト比バケット
9
+ min_ar = 0.5 # 縦長 1:2 まで
10
+ max_ar = 2.0 # 横長 2:1 まで
11
+ num_ar_buckets = 9
12
+ frame_buckets = [1] # 画像のみ
13
+
14
+ # キャプション関係 ----------------------------------------------------------
15
+ # danbooru タグ風キャプションを ", " 区切りでシャッフル
16
+ shuffle_tags = true
17
+ # 学習を頑健にするため一部タグをランダムに drop
18
+ # (品質タグはすでに clean_captions.py で除去済みだが、artist 等の頑健化に有効)
19
+ caption_dropout_rate = 0.1
20
+ # プロンプトをまるごと空にする確率 (CFG 学習の暗黙化、5〜10% が定番)
21
+ text_dropout_rate = 0.05
22
+
23
+ # テキストエンコーダ出力の事前キャッシュ。
24
+ # 効果: 各 step の Qwen3 forward (~0.2s) を省略 → 全体 15-25% 高速化 ≈ $7-13/run
25
+ # 制約: 上の shuffle_tags / *_dropout_rate と相反 (キャッシュは固定文字列で計算)
26
+ # 速度優先する場合: 下記 ↑ の shuffle_tags=false, caption_dropout_rate=0,
27
+ # text_dropout_rate=0 にしてこれを true にする。
28
+ cache_text_embeddings = false
29
+
30
+ # 実データセット --------------------------------------------------------------
31
+ [[directory]]
32
+ path = '/dataset/cleaned'
33
+ # クラス重み付け (複数 dataset を混ぜる時に使う。今は 1.0)
34
+ num_repeats = 1
docs/distillation.md ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Anima 蒸留 — 実走で分かった知見
2
+
3
+ [← README に戻る](../README.md)
4
+
5
+ > **TL;DR**: 数万円予算で Anima を自前 SOTA 蒸留(DMD2+TSCD+R3GAN)するのは
6
+ > 現実的に困難。**5 回失敗で累計 $165**。実用解は **`merge_turbo_lora`**
7
+ > (公式 Anima Turbo LoRA との合成、$0.5、即動く)。自前で挑む人向けに、
8
+ > 何が起きるか・なぜ難しいかを以下に集約。
9
+ >
10
+ > 2026-05 以降の改善試行 (PCM / LADD / Reflow など既存実装の移植) は
11
+ > [migration_log.md](migration_log.md) を参照。
12
+
13
+ ---
14
+
15
+ ## 1. 概要(結論先出し)
16
+
17
+ このリポジトリでは Anima(CircleStone Labs, Cosmos-Predict2 派生 2B DiT)を
18
+ **Decoupled DMD2 + TSCD + R3GAN** で **step 削減蒸留** することを目指した。
19
+ B200 GPU(Modal)で 5000 枚 self-distillation データセットを使い、**計 5 回の失敗**:
20
+
21
+ | 試行 | 構成 | 結果 | コスト |
22
+ |---|---|---|---|
23
+ | Phase A | full DiT, R3GAN gamma=50 | 完全ノイズ(D 爆発) | $70 |
24
+ | Phase B v1 | Q,V LoRA, R3GAN OFF | 緑色化(AdaLN 不触) | $10 |
25
+ | Phase B v2 | wide LoRA, R3GAN OFF | mean collapse | $10 |
26
+ | Phase B v3a | wide LoRA, gamma=0.1 | step 30 で D 爆発 abort | $5 |
27
+ | **Phase B v3b** | wide LoRA, gamma=1.0 | **mean collapse + 後で D 爆発** | $20 |
28
+
29
+ **最終理解**: R3GAN は「D 爆発」と「無信号 → mean collapse」の間に **安定 zone が
30
+ 極めて狭く**、Anima latent(16ch × 128²)で持続的な訓練は現状の構成では困難。
31
+ DMDR / Z-Image の公式実装が **DINOv2 reward に依存** していることからも、
32
+ adv loss 単独の R3GAN だけでは Anima 系の蒸留は厳しい可能性が高い。
33
+
34
+ ### 1.1 結局どうすればいいか
35
+
36
+ | 用途 | 推奨パス | コスト | 結果 |
37
+ |---|---|---|---|
38
+ | **すぐ 8-12 step 推論したい** | `merge_turbo_lora`(公式 Civitai Turbo LoRA を Anima base に重ねる)| **$0.5** | ✅ 即動く、CFG=1 / 8-12 step |
39
+ | **どうしても自前で蒸留したい** | 本ドキュメントの試行錯誤を踏まえ、**DINOv2 reward 切替** 等を追加実装 | +$50-100 + 開発時間数日 | △ 未検証 |
40
+ | **学習目的・記録目的** | 本ドキュメントを読む | $0 | 知見だけ |
41
+
42
+ > **強い推奨**: 商用要件がなければ `merge_turbo_lora` で十分。CircleStone Labs
43
+ > 自身が遥かに多い計算資源と試行錯誤の上で作った Turbo LoRA が既に Civitai
44
+ > にある(https://civitai.com/models/2560840)、これに勝つのは数万円では困難。
45
+
46
+ ---
47
+
48
+ ## 2. 結局成功した実用パス: `merge_turbo_lora`
49
+
50
+ ```bash
51
+ # 1. Anima Turbo LoRA を Civitai からダウンロード
52
+ modal run modal_app.py::download_civitai_lora # version_id=2877687 がデフォルト
53
+
54
+ # 2. (任意) 自前 Phase 1 LoRA とマージ(なくても可)
55
+ modal run modal_app.py::merge_turbo_lora
56
+
57
+ # 3. ComfyUI で base + LoRA(strength=1.0)を組み、8 step CFG=1 で生成
58
+ ```
59
+
60
+ 実装: `modal_app.py::download_civitai_lora`, `merge_turbo_lora`, `stage_lora_to_models`。
61
+ ComfyUI workflow は `scripts/anima_workflow_turbo.json` 参照。
62
+
63
+ 実測の Turbo LoRA 出力品質: **base 30 step CFG=4.5 と同等のキャラクター品質を
64
+ 8 step CFG=1 で達成**(検証画像は `/dataset/compare/turbo_8step/` に保存)。
65
+
66
+ ---
67
+
68
+ ## 3. 自前 SOTA 蒸留(本リポジトリで実装、未完成)
69
+
70
+ `scripts/distill/` に Decoupled DMD2 + TSCD + R3GAN を実装。**コード自体は動く** が、
71
+ ハイパーパラメータの調整で 5 回失敗。以下「我々が試した推奨設定」だが、これでも
72
+ 2000 step で失敗 — **これから挑む人は次節 §4 §5 を読んでから**。
73
+
74
+ ### 3.1 試した(が安定しなかった)設定
75
+
76
+ ```text
77
+ === モデル / データ ===
78
+ base model: Anima preview3-base (Cosmos-Predict2 派生)
79
+ text encoder: Qwen3-0.6B + LLM Adapter (T5 空間ブリッジ)
80
+ VAE: WanVAE (16-channel latent, scale=[mean, 1/std])
81
+ dataset: self-distillation 5000 枚(base が 30 step CFG 4.5 で生成)
82
+ 解像度 1024px 周辺の 7 種 aspect ratio をランダム
83
+
84
+ === 学習 (LoRA-only) ===
85
+ trainable: gen + guidance ともに LoRA、base は frozen
86
+ gen LoRA: **wide** (all-linear except llm_adapter), rank=32
87
+ → AdaLN modulation を含む全 Linear に attach
88
+ guidance LoRA: Q,V LoRA, rank=32 (DMDR 互換、軽量)
89
+ optimizer: gen / guidance: AdamW lr=2e-5 β=(0.9, 0.999) wd=0.01
90
+ D: Adam lr=2e-4 β=(0, 0.99) (StyleGAN 流)
91
+ grad clip: 1.0
92
+
93
+ === Loss 構成 ===
94
+ L_dmd: DMDR gradient trick (p_real - p_fake) / norm
95
+ L_consist: TSCD MSE vs EMA generator (decay=0.999)
96
+ L_adv (gen): R3GAN relativistic RpGAN, weight=0.05
97
+ L_disc: R3GAN softplus + γ/2 · (R1 + R2)
98
+ γ = 0.05〜0.5 (grid search 必須)
99
+
100
+ === 更新比率 / スケジュール ===
101
+ guidance update: 5 回 / outer step
102
+ generator update: 1 回 / outer step
103
+ discriminator: 1 回 / outer step
104
+ CFG Augmentation: real_score を cfg=2.0 で評価 (cold_start 100 step は cfg=0)
105
+ dynamic LoRA r: real-side scale を cosine で 0 に decay (decay_steps=2000)
106
+
107
+ === Phase 構成 ===
108
+ Phase B: base 直接 + wide LoRA, num_inference_steps=4, 2000 step
109
+ Phase C: Phase B から resume または base 直接, wide LoRA, num_steps=2, 2000 step
110
+ ※ Full DiT 訓練 (Phase A) は不要 — LoRA-only で十分
111
+
112
+ === Sanity / Guard ===
113
+ sanity test: 200 step + 中間生成 1 枚で degeneration 検出
114
+ abort guards: d_r1 > 1000 / d_r2 > 1000 / loss > 1e6
115
+ checkpoint: 500 step 毎 + 都度 volume.commit()
116
+ PYTHONUNBUFFERED=1 + cwd=/workspace/diffusion-pipe
117
+
118
+ === Modal infra ===
119
+ GPU: B200 (192GB) — LoRA-only で余裕、batch=4
120
+ image: CUDA 12.4 + Python 3.11 + diffusion-pipe + torchvision
121
+ volumes: anima-models / anima-dataset / anima-outputs
122
+ secrets: hf_token, civitai_api_key
123
+
124
+ === ComfyUI 出力形式 ===
125
+ LoRA key: diffusion_model.<module>.lora_A.weight / .lora_B.weight
126
+ (PEFT の base_model.model.<...>.lora_A.default.weight から変換)
127
+ 基底 ckpt: anima ckpt 系は net. prefix 必須 (ComfyUI が arch 検出に使う)
128
+ ```
129
+
130
+ 実装は `scripts/distill/` + `modal_app.py::train_sota_distill` を参照。
131
+
132
+ > ⚠️ **重要**: 上の設定で 2000 step training を 5 回試したが、いずれも失敗。
133
+ > Anima latent では R3GAN の安定 zone が極めて狭く、長期持続が困難だった。
134
+ > **これから挑む人は §4(全失敗の詳細)+ §5(残された改善方向)を必ず読むこと**。
135
+
136
+ ### 3.2.1 なぜ LoRA-only(full DiT 訓練ではなく)
137
+
138
+ | | full DiT(Phase A) | LoRA-only(Phase B/C) |
139
+ |---|---|---|
140
+ | trainable params | 2B | 30-150M |
141
+ | step 時間 (B200) | 14 秒 | **2.8-3.0 秒**(5x 速) |
142
+ | 出力 | 4 GiB ckpt | **30-150 MiB LoRA** |
143
+ | 配布性 | 他 FT と排他 | **他 LoRA とスタック可** |
144
+ | optimizer state | 8 GB | ~200 MB |
145
+ | failure 局所化 | base 全体破壊リスク | LoRA だけ |
146
+
147
+ Anima のように既に高品質な base を蒸留する場合、LoRA-only がコスト・配布性・
148
+ 安全性のすべてで優位。Phase A 流の full DiT 訓練は不要だった。
149
+
150
+ ### 3.2.2 なぜ「wide LoRA」(Q,V LoRA ではなく)
151
+
152
+ Anima/Cosmos の DiT は **AdaLN modulation で timestep 情報を処理**する架構:
153
+
154
+ ```
155
+ block(x, t):
156
+ t_embed = t_embedder(t)
157
+ # AdaLN modulation = block_input をどう変調するかを timestep から決める
158
+ scale, shift, gate = adaln_modulation(t_embed) ← ここが timestep の入り口
159
+ x = LayerNorm(x) * (1 + scale) + shift
160
+ x = x + gate * attention(x, ...)
161
+ ```
162
+
163
+ 蒸留とは「**timestep の解釈を変える**」こと(少ない step でも denoise しきる)。
164
+ それには adaln_modulation の Linear を学習対象に含める必要がある。Q,V LoRA だと
165
+ attention pattern だけ変えられて timestep 入り口を触れない → 学習が degenerate
166
+ な「色 / スタイル shift」に逃げる(Phase B v1 の緑色化はこれ)。
167
+
168
+ 公式 Anima Turbo LoRA も同じ理由で adaln_modulation を target(1016 keys、
169
+ 我々の wide LoRA 980 keys とほぼ同等)。
170
+
171
+ ### 3.2.3 なぜ R3GAN が必要(adv loss なしではダメ)
172
+
173
+ DMD2 単体は **mean collapse** に陥る(Phase B v2 で実証)。理由:
174
+ - DMD gradient `(real_score - fake_score)` は「real distribution の方向」を示す
175
+ - LoRA capacity が大きいほど、「real の **mean** を出力するのが最も安全」という解に収束しやすい
176
+ - TSCD(EMA self との consistency)は collapse を加速こそすれ防げない
177
+
178
+ R3GAN(または同等の adv / reward 信号)があると:
179
+ - D が「real は sharp、fake は blurry」と学習
180
+ - gen は「blurry な mean を出すと D に見抜かれる」と分かり、sharp な多様性を保つ
181
+ - これが mean collapse の唯一の防御策
182
+
183
+ 実際、DMD2 / Hyper-SD / DMDR 系の全公式実装で adv loss(or reward loss)が必須。
184
+
185
+ ### 3.2.4 なぜ R3GAN gamma の grid search が必須
186
+
187
+ R3GAN の R1+R2 gradient penalty 重み `gamma` は **入力空間のスケールに比例**:
188
+
189
+ | ドメイン | 入力 channel | 解像度 | gamma |
190
+ |---|---|---|---|
191
+ | CIFAR-10 | 3 | 32x32 | **0.05** |
192
+ | FFHQ-64 | 3 | 64x64 | 2 |
193
+ | FFHQ-256 | 3 | 256x256 | 150 |
194
+ | **Anima latent** | **16** | **128x128** | **~0.05-1**(grid search 必須) |
195
+
196
+ Anima latent は per-sample 262k 要素で、典型 pixel 空間より gradient norm が
197
+ 大きい → gamma は数値が小さくないと penalty が爆発。Phase A は gamma=50 で **500x 過大**
198
+ にしたら discriminator が崩壊した(詳細は §4 Phase A 参照)。
199
+
200
+ ### 3.2.5 なぜ guard rail(`d_r1/r2 > 1000` で abort)
201
+
202
+ Phase A は **loss は全部健全に推移**(`l_dmd` 0.005, `l_adv_g` 0.69 で一定)しつつ
203
+ 裏で discriminator gradient が爆発し、completed ckpt が完全ノイズ出力という
204
+ **ステルス失敗**。後付けの分析で「step 1500 時点 d_r1 ~700 / step 2300 で 500k」と
205
+ わかったため、しきい値 1000 で abort すれば即停止できる。
206
+
207
+ しきい値 1000 の根拠: 正常上限 ~100 の 10x、致命傷 500k の 1/500 で、確実に
208
+ ステルス damage が始まる前に止まる。
209
+
210
+ ### 3.2.6 なぜ Decoupled DMD2 + TSCD(LCM / Hyper-SD ではなく)
211
+
212
+ | 手法 | 性質 | 我々の選択理由 |
213
+ |---|---|---|
214
+ | LCM | consistency 単体、シンプル | low-step 品質に限界 |
215
+ | Hyper-SD | TSCD + adv、segment-wise progressive | gamma calibration 必要、参照実装 SDXL only |
216
+ | **DMD2 / DMDR** | **分布マッチング + adv、Anima のような flow matching DiT に最適** | Z-Image(Cosmos 派生)で実証済、適応容易 |
217
+
218
+ DMD2 系は **flow matching ベース(Anima の rectified flow と同じ)** で、math
219
+ 変換不要なのが決定打。TSCD は Hyper-SD から借りた多 step 一貫性で、純粋 DMD2
220
+ の単 step 偏りを補正。
221
+
222
+ ### 3.2.7 なぜ 5000 枚 self-distillation データセット
223
+
224
+ | データソース | コスト | 利点 | 欠点 |
225
+ |---|---|---|---|
226
+ | Anima 自己生成 | $44 (10 B200 並列 40min) | base の出力分布に最適化 / ライセンス問題なし | base の癖をそのまま継承 |
227
+ | Danbooru / Pixiv | $0 + キュレーション時間 | 多様性 | ライセンス・タグ品質ばらつき |
228
+ | 公開データセット(LAION 等) | $0 + DL コスト | 多様性最大 | アニメ系少ない |
229
+
230
+ 蒸留の本質は「**base と同じ出力を低 step で**」なので、self-distillation で
231
+ 分布を base に一致させるのが理論的に正しい(distillation の定義)。
232
+
233
+ ### 3.2.8 なぜ B200(H100 / A100 ではなく)
234
+
235
+ | GPU | Modal $/h | LoRA-only step 時間 | 192GB VRAM の意味 |
236
+ |---|---|---|---|
237
+ | A100-80GB | $2.50 | 7-9 秒 | OOM 危険、batch=1 |
238
+ | H100-80GB | $3.95 | 4-6 秒 | OOM 危険、batch=1-2 |
239
+ | **B200** | **$6.25** | **2.8-3.0 秒** | **3 model 同時 + batch=4-8 余裕** |
240
+
241
+ B200 の per-hour 単価は高いが、**3 model(real/fake/gen)同時+batch 増**で
242
+ **実質的な per-experiment コストは最安**。R3GAN を入れても OOM 不安なし。
243
+
244
+ ---
245
+
246
+ ## 4. 実際の失敗(全 5 件、詳細)
247
+
248
+ | # | 試行 | 構成 | 結果 | 累計 |
249
+ |---|---|---|---|---|
250
+ | 1 | **Phase A** | full DiT + Q,V LoRA, R3GAN γ=**50**, 3000 step | 完全ノイズ(D 爆発) | $70 |
251
+ | 2 | **Phase B v1** | Q,V LoRA, R3GAN **OFF**, 2000 step | 緑色化(AdaLN 不触) | $80 |
252
+ | 3 | **Phase B v2** | wide LoRA, R3GAN **OFF**, 2000 step | mean collapse(灰緑平面)| $90 |
253
+ | 4 | **Phase B v3a** | wide LoRA, R3GAN γ=**0.1**, 2000 step | step 30 で d_r1=1648 → abort | $95 |
254
+ | 5 | **Phase B v3b** | wide LoRA, R3GAN γ=**1.0**, 2000 step | 早期 mean collapse → 後半 D 爆発 | $115 |
255
+
256
+ 各失敗が **異なる failure mode** で起きた点が重要。「1 つ直しても他で詰まる」
257
+ 直交性が、SOTA 蒸留を Anima 系で安定させる難しさの本質。
258
+
259
+ ### 4.1 Phase A の詳細 postmortem(5 層因果分析)
260
+
261
+ **ステルス失敗**: 訓練ログ上は全 loss 健全(`l_dmd` 0.003-0.008, `l_adv_g` 0.69 一定)
262
+ だったのに、完走 ckpt が完全ノイズ出力。原因は 5 層の連鎖:
263
+
264
+ **Layer 1 — gamma calibration ミス(根本)**
265
+ - Anima latent(16ch × 128x128)に対し gamma=50 は 50-500 倍過大
266
+ - Agent リサーチが「latent norm 用 grid search 必須」と明示警告していたが、
267
+ 手抜きで推測値を採用したのが発端
268
+
269
+ **Layer 2 — R3GAN gradient が gen に流入(見えない damage)**
270
+ ```
271
+ l_adv_g = softplus(-(d_fake - d_real)) ≈ log 2 = 0.6914 ← 中立点
272
+ ```
273
+ `l_adv_g` が一定で「adv 信号効いてない」と誤読。**しかし gradient norm は別物**:
274
+ ```
275
+ ∂L_adv_g/∂gen_output = (sigmoid 微分 ~0.5) · ∂(D の全層 Jacobian)
276
+ ```
277
+ D 重みが gamma penalty 最小化で異常拡大 → D の Jacobian 爆発 → gen に流れる
278
+ gradient が正常時の **数百〜数千倍**。adv_weight=0.05 で抑えても scale 負け。
279
+
280
+ > 教訓: **adv_loss の絶対値だけ見て安全と判断するな**。gradient norm を別途モニタ。
281
+
282
+ **Layer 3 — D 自体の破滅スパイラル**
283
+ ```
284
+ 最初: l_disc = 0.69 + 25·0 = 0.69
285
+ 膨張後: l_disc = 0.69 + 25·600,000 = 15,000,000
286
+ ```
287
+ gamma が大きすぎて R1+R2 penalty 項が adv 項を支配 → D は adv 学習する余地なく、
288
+ ひたすら penalty 最小化に動く → ある瞬間 scale 崩れて d_r1/r2 が指数的に膨張。
289
+
290
+ **Layer 4 — Sanity test の見落とし**
291
+ - 50 step では destabilization が緩やかすぎて d_r1/r2 が正常範囲
292
+ - 本番 step 500-1000 から急速悪化
293
+ - 後から見れば step 1000 で d_r1/r2 が 100 超え始めていた($50 救えた)
294
+
295
+ > 教訓: **Sanity は 200-500 step に延長**。緩慢な発散も検出可能に。
296
+
297
+ **Layer 5 — 監視/アラート不足**
298
+ - d_r1/r2 はログ出していたが **しきい値アラート未実装**
299
+ - NaN/Inf check はあったが「大きすぎる値」検出なし
300
+ - gen �� weight L2 norm を時系列で見れば damage 早期検出可能だった
301
+ - 中間 ckpt 毎の自動生成テストも未実装
302
+
303
+ 修正後の guard rail(実装済):
304
+ ```python
305
+ # scripts/distill/dmd2_trainer.py
306
+ if d_r1 > 1000.0 or d_r2 > 1000.0:
307
+ raise RuntimeError(f"R3GAN penalty exploded (r1={d_r1:.1f}, r2={d_r2:.1f}). "
308
+ f"Reduce r3gan_gamma. Aborting.")
309
+ ```
310
+
311
+ **Phase A から学んだ悪設計のチェックリスト**
312
+
313
+ | 問題 | 影響 |
314
+ |---|---|
315
+ | gamma を grid search せず推測 | 50-500 倍ズレ |
316
+ | adv_weight=0.05 で「安全装置」になると誤解 | gradient norm は別軸、効かなかった |
317
+ | sanity 50 step だけで本番投入 | 緩慢な発散見逃し |
318
+ | `d_r1/r2` にアラートしない | 早期 abort 機会逸失 |
319
+ | 中間 ckpt で部分検証しなかった | 完走してから初めて発覚($70 ロス) |
320
+
321
+ ### 4.2 Phase B v1 — Q,V LoRA だと AdaLN を触れない
322
+
323
+ R3GAN を外して安全側に振った構成。loss は 0.02-0.03 で安定したが、生成すると
324
+ **緑色化した anime キャラ**。LoRA strength を 1.0 → 0.3 に下げると base に戻る
325
+ (= LoRA は「均一に緑方向に押している」)。
326
+
327
+ 原因: Q,V LoRA は cross-attention / self-attention の query / value だけを変更。
328
+ **timestep modulation の入り口である AdaLN modulation Linear を触れない**。
329
+ DiT が「少ない step でちゃんと denoise する」ために必要な timestep 解釈の変更が
330
+ できず、勾配が attention の color / style shift に逃げて degenerate に収束。
331
+
332
+ → 解決: LoRA target を **「AdaLN + attention + MLP の全 Linear」(wide LoRA)** に拡張。
333
+ 公式 Anima Turbo LoRA も同じ範囲を target にしている(検証で 1016 keys 確認)。
334
+
335
+ ### 4.3 Phase B v2 — wide LoRA + R3GAN OFF で mean collapse
336
+
337
+ wide LoRA(980 keys)に拡張したら、今度は **完全に灰緑色の平面**を出力。loss は
338
+ v1 より遥かに小さく(`l_dmd` 0.001-0.007)、**訓練ログ上は最も健全に見える**
339
+ 失敗。LoRA strength を変えても改善せず。
340
+
341
+ 原因: DMD2 単体は「real distribution の方向」を示すだけ。LoRA capacity が大きい
342
+ ほど **「data distribution の mean を出力すれば DMD gradient はゼロになる」**
343
+ という degenerate な解に収束しやすい。R3GAN なしでは「sharp なものを出せ」と
344
+ gen に教える信号がないので、訓練が進むほど mean に潰れていく。
345
+
346
+ → 解決: **R3GAN(または同等の reward / classifier loss)を必ず投入**。DMD2 系の
347
+ 全公式実装(NVIDIA DMD2, Z-Image DMDR, Hyper-SD)で adv 系 loss が必須なのは
348
+ このため。
349
+
350
+ ### 4.4 Phase B v3a — gamma=0.1 で D が即爆発
351
+
352
+ grid search の 100-200 step では gamma=0.1 が安定に見えていた:
353
+ ```
354
+ [step 60/100] d_r1=0.020 d_r2=0.015 l_disc=0.017 ← 健全
355
+ ```
356
+
357
+ しかし本番(2000 step)で開始 30 step 以内に爆発:
358
+ ```
359
+ [step 10/2000] d_r1=67.5 d_r2=74.8 l_disc=7.1 ← 既に異常
360
+ [step 30/2000] d_r1=1648.0 d_r2=1821.6 ← guard 発火 abort
361
+ ```
362
+
363
+ 原因推測:
364
+ - 短時間 grid search は **ランダム初期化の運**で偶然安定 zone に居ただけ
365
+ - 本番の異なる初期化 + 長時間 → 不安定境界を越えた
366
+ - gamma=0.1 は Anima latent では **安定境界の崖際**
367
+
368
+ ### 4.5 Phase B v3b — gamma=1.0 で長期 mean collapse + 最終 D 爆発
369
+
370
+ gamma=1.0 で training は確かに **1500 step まで全 metric 健全**:
371
+ ```
372
+ [step 1000/2000] l_dmd=0.001 l_consist=0.005 d_r1=0.005 l_disc=0.004 ← 全部小さい
373
+ ```
374
+
375
+ **しかし step 500/1000/1500 の中間 ckpt で生成テストすると全部 mean collapse**:
376
+ - step 500: 白い平面
377
+ - step 1000: 白い平面
378
+ - step 1500: 青+白の blob
379
+
380
+ しかも step 1380 から D が「目覚め」始め、step ~1600 で d_r1=2656 で abort:
381
+ ```
382
+ [step 1380/2000] d_adv=3.73 d_r1=0.36 d_r2=0.22 ← D が動き始める
383
+ [step 1510/2000] d_adv=0.00 d_r1=0.67 d_r2=0.45 ← じわじわ拡大
384
+ [step ~1600] d_r1=2656.0 d_r2=3043.2 ← abort
385
+ ```
386
+
387
+ **核心の理解**:
388
+ - gamma=1.0 は D が **静か → 突然動く → 爆発** のパターン
389
+ - 静かな間(d_r1 ~ 0.005)は **D が adv 信号を gen に渡せず**、gen は mean に collapse
390
+ - D が動き始めた頃には gen は既に degenerate state
391
+ - ckpt が「健全に見える」のは loss だけ。実態は破綻
392
+
393
+ これが **「R3GAN の安定 zone が存在しない」** という最終診断の根拠。
394
+
395
+ ### 4.6 失敗パターンの直交性
396
+
397
+ | | Phase A | B v1 | B v2 | B v3a | B v3b |
398
+ |---|---|---|---|---|---|
399
+ | failure mode | gradient damage(ノイズ)| degenerate(緑) | mean collapse | D 爆発 abort | D 静止 → mean collapse → D 爆発 |
400
+ | 根本原因 | R3GAN γ 過大 | LoRA target 狭 | adv なし | γ 小すぎて D 暴走 | γ 大すぎて D が adv 信号出さず |
401
+ | 検出 | step 1000+ d_r1 | 生成テスト即 | 生成テスト即 | step 30 で guard | 中間 ckpt 生成テスト |
402
+ | 対策 | γ grid search + guard | wide LoRA | R3GAN を入れる | γ 上げる | γ 下げる(でも v3a に戻る)|
403
+
404
+ **5 つの failure mode は互いに直交かつ循環**(γ 下げると v3a、上げると v3b)。
405
+ Anima latent で R3GAN を安定させる γ は、**存在するとしても極めて狭く**、
406
+ 我々の grid 解像度では発見できなかった。
407
+
408
+ ### 4.7 周辺で踏んだ Modal / diffusion-pipe / ComfyUI 罠
409
+
410
+ 蒸留の本体とは別に、インフラ側で踏んだバグ:
411
+
412
+ **diffusion-pipe を programmatic に使う際の 8 件**
413
+
414
+ | 罠 | 症状 | 対処 |
415
+ |---|---|---|
416
+ | `models/`, `utils/` が namespace package(`__init__.py` なし)| site-packages 側の `utils` に shadow されて `from utils.common import` 失敗 | `importlib.util.spec_from_file_location` で強制ロード(`scripts/distill/anima_loader.py`)|
417
+ | `CosmosPredict2Pipeline(cfg)` の dtype が **torch.dtype 必須** | `tensor() argument 'dtype' must be torch.dtype, not str` | `cfg["model"]["dtype"] = torch.bfloat16`(文字列禁止) |
418
+ | `load_text_encoder()` / `load_vae()` メソッドが **存在しない** | AttributeError | `load_diffusion_model()` だけ呼ぶ、残りは `__init__` で完了 |
419
+ | `MiniTrainDIT.forward` は `padding_mask` 必須(None 不可) | `transforms.functional.resize(None, ...)` TypeError | `torch.zeros(B, 1, H, W)` を渡す |
420
+ | VAE input が bfloat16 必須 | `Input type (float) and bias type (BFloat16) should be the same` | `vae_encode` 内で auto-cast |
421
+ | DiT 入力 `(noisy, t, cond)` の dtype 統一必須 | `expected mat1 and mat2 same dtype` | dit_forward helper で全部 weight dtype に揃える |
422
+ | `torchvision` import するが requirements に無い | `ModuleNotFoundError` | image に明示インストール |
423
+ | save の filter で integer buffer を落とすと壊れる | ロード後ノイズ | float 限定 filter は廃止、全 tensor 保存 |
424
+
425
+ **ComfyUI LoRA 形式**
426
+
427
+ Anima/Cosmos 用 LoRA は `lora_A.weight` / `lora_B.weight`(NOT `lora_down/up`)、
428
+ `diffusion_model.` prefix 必須。PEFT 出力からの変換は `modal_app.py::_convert_peft_to_comfy_lora`。
429
+ SDXL 流 `lora_down/up` に変換すると ComfyUI が key 認識できずランダム scaling で
430
+ 適用 → 出力が緑化(これも Phase B v1 のもう 1 つの寄与因子だった可能性)。
431
+
432
+ **Modal 運用**
433
+
434
+ | 罠 | 対処 |
435
+ |---|---|
436
+ | `modal run --detach` の local CLI が早く戻る | 完了確認は `modal app list` / `modal app logs <id>` |
437
+ | `print()` の block buffering で logs 滞留 | `PYTHONUNBUFFERED=1` を env に追加 |
438
+ | Volume commit は subprocess 終了時のみ | 訓練ループ内で `modal.Volume.from_name(...).commit()` を定期発行 |
439
+ | `modal volume cp` は cross-volume 不可 | 両 volume mount した Modal function で `shutil.copy` |
440
+ | `run_in_background=true` + TaskStop で app が "detached_disconnected" に残る | 必ず `--detach` を一緒に付ける(明示 detach)|
441
+ | Anima ckpt は `net.` prefix 前提で ComfyUI が arch 検出 | 保存時に `{"net." + k: v for k, v in sd.items()}` |
442
+
443
+ ### 4.8 コスト・時間の実測値
444
+
445
+ | タスク | 時間 | コスト |
446
+ |---|---|---|
447
+ | dataset 生成(B200 × 10 並列) | 40 分 | $44 |
448
+ | Path A baseline 確認(Turbo LoRA DL + 10 枚生成) | 5 分 | ~$1 |
449
+ | **Phase A: full DiT 訓練(失敗)** | **11.6 h** | **~$70**(全損)|
450
+ | Phase B v1: Q,V LoRA(失敗) | 1.6 h | ~$10 |
451
+ | Phase B v2: wide LoRA, R3GAN OFF(失敗) | 1.6 h | ~$10 |
452
+ | Phase B v3a: wide LoRA, γ=0.1(早期 abort) | ~5 分 | ~$5 |
453
+ | Phase B v3b: wide LoRA, γ=1.0(失敗) | 4 h | ~$20 |
454
+ | R3GAN gamma grid search | 30 分 | ~$3 |
455
+ | 比較生成 30 枚 + その他 | | ~$2 |
456
+ | Modal volume(50 GB × 月)| 常時 | ~$7.5/月 |
457
+ | **累計** | | **~$165 ≈ 25,000円** |
458
+
459
+ R3GAN ON で 1 step 約 +20-30%(D forward + R1/R2 backward 追加分)。
460
+
461
+ ---
462
+
463
+ ## 5. 続き
464
+
465
+ §4 の 5 回失敗を受け、「自前で書き下す」を諦めて **既存の動く実装を最小改造で
466
+ 移植**した試行が 2026-05 から始まる。各手法 (Z-Image traj / LADD / Reflow / DMD2 /
467
+ SiD2 / Shortcut / PCM / DRaFT+) の移植ログ・コスト・結果は
468
+ [migration_log.md](migration_log.md) を参照。
docs/dmd2.md ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DMD2 (NVIDIA cosmos-predict2.5 流派) 蒸留
2
+
3
+ [← README に戻る](../README.md) — 関連: [PCM](pcm.md) / [DMDX](dmdx.md) / [蒸留全般](distillation.md)
4
+
5
+ ## 概要
6
+
7
+ NVIDIA cosmos-predict2.5 公式の **DMD2 + TrigFlow** 流派を Anima 移植。同 base に **2 つの独立 PEFT adapter** (student / fake_score) を attach し `set_adapter()` で runtime 切替、TTUR (alt 5 critic : 1 generator)。
8
+
9
+ **実装**: `scripts/distill/{dmd2_official_loss,train_dmd2_official}.py`
10
+ **Modal**: `modal_app.py::train_dmd2_official_distill`
11
+
12
+ ## 設計の鍵
13
+
14
+ - **2 adapter / single base** → memory 効率 (teacher は別 deepcopy)
15
+ - **TrigFlow time sampling**: `s ∈ shifted_uniform(shift=3-5) → t = arctan(s/(1-s))`
16
+ - **DMD2 gradient trick** (逆 KL): `grad = (x0_fake - x0_teacher) / |x_hat - x0_teacher|.mean()`
17
+ → `loss = ((x_hat - (x_hat - grad).detach())**2).mean()`
18
+ - **Critic phase**: weighted denoise with `1/sin(t)**2` factor
19
+ - **few-step rollout** (n=1..4)、grad は最終 step のみ (memory efficient)
20
+
21
+ ## 訓練設定 (実走、2026-05-18 - 19)
22
+
23
+ | 項目 | 値 |
24
+ |---|---|
25
+ | Base | `anima-base-v1.0.safetensors` |
26
+ | Method | DMD2 + TrigFlow (cosmos-predict2.5) |
27
+ | LoRA target | wide (980 keys) × 2 (student + fake_score) |
28
+ | LoRA rank | 32 |
29
+ | Total outer | 5000 (1 outer = 5 critic + 1 generator) |
30
+ | n_student_steps | 4 |
31
+ | Teacher CFG | **4.5** (Anima 公式推奨に整合、過去 preview3 試行の 3.0 から変更) |
32
+ | Student CFG | 1.0 (CFG embedded) |
33
+ | Shift | **3.0** (Anima sigma_shift と一致、過去の 5.0 から変更) |
34
+ | LR (gen / critic) | 5e-6 / 1e-5 (TTUR) |
35
+ | Warm-start | **`/models/loras/anima_turbo.safetensors`** (Civitai 公式 Turbo、968/1016 keys 一致) |
36
+ | GPU | B200 |
37
+ | 訓練時間 | step 660 まで ~40 min、resume + 4500 outer ~7h |
38
+ | コスト | 累計 ~$48-55 想定 (rate 6.2s/outer parallel 時) |
39
+
40
+ ## 試行履歴 (2026-05-18 - 19)
41
+
42
+ ### 1st run (`/output/dmd2_v1/`)
43
+ - 設定: warm-start (Anima Turbo) + 5000 outer
44
+ - 経過: step 660/5000 で `KeyboardInterrupt`
45
+ - 原因: **Modal workspace billing cycle spend limit reached** (DMDX 並列起動で予算限度超過)
46
+ - 保存: step 500 ckpt のみ無事 (volume commit 済)
47
+
48
+ ### Resume (`/output/dmd2_v1_resume/`)
49
+ - 設定: step 500 ckpt を warm-lora、残り 4500 outer
50
+ - warm-load: **980/980 keys 完全一致** (自前 LoRA 同 architecture)
51
+ - 進行中、step 500 (= 元 1000 相当) / step 1000 (= 元 1500) で ckpt 保存
52
+
53
+ ## Loss 推移 (順調)
54
+
55
+ | outer | l_dmd_critic | l_dmd_gen | 評価 |
56
+ |---|---|---|---|
57
+ | 0 | 0.0364 | 1.5780 | warm-start 直後 |
58
+ | 100 | 0.07 | 1.18 | gen 急速降下中 |
59
+ | 500 | 0.06 | 1.30 | spike あるが healthy |
60
+ | 660 (中断) | 0.0471 | 0.16 | 健全な状態で中断 |
61
+ | resume 0 | 0.0364 | 1.5771 | warm-load から再開、原 outer 0 と類似 |
62
+ | resume 500 | 0.058 | 1.29 | 期待通り |
63
+ | resume 1000 | 0.054 | 0.89 | 順調収束 |
64
+
65
+ magnitude (x_hat / teacher / fake): ~0.3-0.5 範囲で揃って distill 方向収束。
66
+
67
+ ## 検証結果 (step 500、4-step / er_sde+simple)
68
+
69
+ サンプル画像: `/dataset/ckpt_health_check/dmd2_v1_step00500_step4_cfg1.0/p0000_s000.png`
70
+
71
+ - 2 キャラ (Flandre / Remilia) 正確分離
72
+ - Civitai Turbo に近い品質 (warm-start 支配的、step 500 で大幅変化なし)
73
+ - 生成時間 10.95s (4-step、CFG 1.0、B200 sageattention)
74
+
75
+ ローカル DL 済: `~/Downloads/anima_loras/dmd2/dmd2_v1_step00500.safetensors`
76
+
77
+ ## 配布
78
+
79
+ 完走後 HF `darask0/anima-distill-loras/dmd2/` に upload 予定。
80
+
81
+ ## ComfyUI 推奨設定
82
+
83
+ ```
84
+ LoraLoaderModelOnly:
85
+ lora_name: dmd2_v1_step00500.safetensors
86
+ strength_model: 1.0
87
+
88
+ ModelSamplingAuraFlow:
89
+ shift: 3.0
90
+
91
+ KSampler:
92
+ steps: 4
93
+ cfg: 1.0
94
+ sampler_name: er_sde
95
+ scheduler: simple
96
+ ```
97
+
98
+ ## 過去の preview3 試行との対比
99
+
100
+ [migration_log.md §5.7](migration_log.md#57-実画像比較-2026-05同-prompt--seed) の preview3 DMD2 では:
101
+ - 8-step: 絵画調シフト、洗色傾向
102
+ - 4-step: シルエットのみ、ボケすぎ
103
+
104
+ → 改善要因 (v1.0 試行):
105
+ - ✅ **warm-start (Civitai Turbo)** 必須化
106
+ - ✅ **teacher_cfg 3.0 → 4.5** (Anima 公式推奨)
107
+ - ✅ **shift 5.0 → 3.0** (Anima sigma_shift 整合、sampler 互換性向上)
108
+ - ✅ v1.0 base 直接 (preview3 ベース LoRA の style drift 回避)
109
+
110
+ ## 知見
111
+
112
+ - **2 adapter peft set_adapter() で必須**: 毎 forward で明示切替しないと片方の重みが leak ([operations.md](operations.md) 参照)
113
+ - **`No module named 'modal'` の volume commit 失敗**: subprocess 内で modal lib 未参照、harmless warning (function 終了時に Modal 自動 commit)
114
+ - **billing limit による KeyboardInterrupt**: Modal の workspace spend limit に達すると **cloud function に SIGINT** が送られて停止する。ローカル CLI の interrupt とは別経路、Modal dashboard で billing を確認・上げる必要
115
+ - **parallel rate 低下**: 並列 (DMDX と) で 3.66s/outer → 6.2s/outer (~1.7x 遅い)、別 B200 アロケート時の��雑度に依存
docs/dmdx.md ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DMDX (Adversarial Distribution Matching) 蒸留
2
+
3
+ [← README に戻る](../README.md) — 関連: [PCM](pcm.md) / [DMD2](dmd2.md) / [蒸留全般](distillation.md)
4
+
5
+ ## 論文
6
+
7
+ **"Adversarial Distribution Matching for Diffusion Distillation Towards Efficient Image and Video Synthesis"**
8
+ - arxiv: [2507.18569v1](https://arxiv.org/html/2507.18569v1)
9
+ - 著者: Yanzuo Lu et al. (Sun Yat-Sen University + ByteDance Seed Vision)
10
+ - 公開コード: なし (論文のみ)
11
+
12
+ ## 核心アイデア
13
+
14
+ DMD2 の `(real_score - fake_score)` の **逆 KL 発散** はモード崩壊しがち (Phase B v2 と同症状)。DMDX は **学習可能 discriminator による hinge GAN (TVD = Total Variation Distance、対称) 最小化** に置換し、根本解決を狙う。
15
+
16
+ ### vs DMD2
17
+
18
+ | 観点 | DMD2 | DMDX (ADM) |
19
+ |---|---|---|
20
+ | 発散尺度 | 逆 KL (非対称、mean collapse 寄り) | **TVD (対称)** |
21
+ | 損失実装 | grad trick で score difference 直接最小化 | **学習可能 discriminator** で hinge loss 敵対学習 |
22
+ | GAN 役割 | optional 正則化 | **本体損失** |
23
+ | 時刻情報 | t での score 比較のみ | `t → t-Δt` の ODE 進化中間点も考慮 |
24
+ | Initialization | MSE pretrain | 論文では ADP (= adversarial pre-training)、本実装は warm-start で代替 |
25
+
26
+ ## 本実装の範囲
27
+
28
+ 論文の **ADM のみ移植**、**ADP は省略** (SAM 依存 pixel-space discriminator が重い、コスト効果が低い)。
29
+
30
+ 代替: **Civitai Anima Turbo を warm-start** で「分布レベルの良い初期化」を確保。
31
+
32
+ **実装**: `scripts/distill/{dmdx_loss,train_dmdx}.py`
33
+ **Modal**: `modal_app.py::train_dmdx_distill`
34
+
35
+ ### ADM の構造
36
+
37
+ - **Discriminator backbone** = teacher MiniTrainDIT (frozen) — **LADD の `AnimaLADDDiscriminator` を流用**
38
+ - **Trainable heads** × 5 (blocks 2/8/14/20/26 を hook、各 spectral norm + BatchNormLocal)
39
+ - **Cubic time schedule**: `u ~ U(0,1), t = 1 - u**3` (high-noise バイアス)
40
+ - **Δt evolution**: teacher で 1-step Euler で `t → t-Δt` 進化、Δt = T/64
41
+ - **Hinge loss**: G = `mean(-D(fake))`、D = `relu(1-D(real)) + relu(1+D(fake))`
42
+
43
+ ## 訓練設定 (実走、2026-05-19)
44
+
45
+ | 項目 | 値 |
46
+ |---|---|
47
+ | Base | `anima-base-v1.0.safetensors` |
48
+ | Method | DMDX ADM-only |
49
+ | Student LoRA | wide (980 keys、single adapter) |
50
+ | LoRA rank | 32 |
51
+ | Disc heads | 5 (block 2/8/14/20/26)、各 ~3.4M = 17.1M total |
52
+ | Total outer | 5000 (1 outer = 2 disc + 1 gen) |
53
+ | n_critic_per_gen | **2** (DMD2 の 5 より少なめ、hinge GAN 系は 1:1〜2:1 が一般的) |
54
+ | n_student_steps | 4 |
55
+ | Teacher CFG | 4.5 |
56
+ | Student CFG | 1.0 |
57
+ | dt_ratio | 1/64 ≈ 0.0156 |
58
+ | recon_weight | **0.0** (pure ADM、LADD の Smooth-L1 anchor なし) |
59
+ | LR (gen / disc) | 5e-6 / 1e-5 |
60
+ | Warm-start | **`/models/loras/anima_turbo.safetensors`** (Civitai 公式 Turbo) |
61
+ | GPU | B200 |
62
+ | 訓練時間 | smoke ~35s (10 outer)、本番 ~5h 想定 (3.6s/outer) |
63
+ | コスト | smoke ~$0.5、本番 ~$30 想定 |
64
+
65
+ ## Smoke 結果 (10 outer、2026-05-19)
66
+
67
+ | outer | l_d_real | l_d_fake | l_d_total | l_g_adv | 評価 |
68
+ |---|---|---|---|---|---|
69
+ | 0 | 0.717 | 1.295 | 2.012 | -0.292 | 初期 hinge equilibrium |
70
+ | 5 | 0.724 | 1.277 | 2.001 | -0.276 | 安定継続 |
71
+ | 9 | 0.740 | 1.268 | 2.008 | -0.270 | smoke 完了 |
72
+
73
+ OOM / NaN / divergence なし。setup・warm-start (968/1016)・D init・ADM loss 全部正常。
74
+
75
+ ## 本番訓練 (進行中、2026-05-19)
76
+
77
+ ### Step 500 検証
78
+
79
+ | outer | l_d_real | l_d_fake | l_d_total | l_g_adv |
80
+ |---|---|---|---|---|
81
+ | 0 | 0.717 | 1.295 | 2.012 | -0.292 |
82
+ | 90 | 0.774 | 1.227 | 2.001 | -0.218 |
83
+ | 340 | 0.938 | 1.034 | 1.972 | +0.048 (D 優勢) |
84
+ | **500** | **0.822** | **0.976** | **1.798** | **-0.146** |
85
+ | 1000 | 0.933 | 0.971 | 1.904 | -0.093 |
86
+
87
+ GAN dynamics:
88
+ - `l_d_total` 2.012 → 1.798 → 1.904 (D 学習、後半は equilibrium 周辺で振動)
89
+ - `l_g_adv` -0.292 → +0.05 (一時 D 優勢) → -0.15 → -0.09 (G 持ち直し)
90
+ - D と G の拮抗状態に到達、healthy GAN training
91
+
92
+ ### 生成検証 (step 500、4-step / er_sde+simple)
93
+
94
+ サンプル: `/dataset/ckpt_health_check/dmdx_v1_step00500_step4_cfg1.0/p0000_s000.png`
95
+
96
+ - 2 キャラ正確分離、Civitai Turbo に近い品質 (warm-start 支配)
97
+ - 生成時間 **5.77s** (PCM 8.8s / DMD2 11s より速い、B200 contention 軽い時間帯)
98
+
99
+ ## 配布
100
+
101
+ 完走後 HF `darask0/anima-distill-loras/dmdx/` に upload 予定。
102
+
103
+ ## ComfyUI 推奨設定
104
+
105
+ ```
106
+ LoraLoaderModelOnly:
107
+ lora_name: dmdx_student_final_comfy.safetensors
108
+ strength_model: 1.0
109
+
110
+ ModelSamplingAuraFlow:
111
+ shift: 3.0
112
+
113
+ KSampler:
114
+ steps: 4
115
+ cfg: 1.0
116
+ sampler_name: er_sde
117
+ scheduler: simple
118
+ ```
119
+
120
+ ## 期待品質 (paper 報告ベース)
121
+
122
+ SDXL 1-step での DMDX vs DMD2:
123
+
124
+ | metric | DMD2 | DMDX | 差 |
125
+ |---|---|---|---|
126
+ | CLIP Score | 35.22 | 35.26 | +0.04 |
127
+ | PickScore | 22.10 | 22.27 | +0.17 |
128
+ | HPSv2 | 27.45 | 27.70 | +0.25 |
129
+ | MPS | 10.69 | 11.20 | +0.51 |
130
+ | **多様性 LPIPS** | 0.6715 | **0.7156** | **+0.044** |
131
+
132
+ → **多様性 +6.5% が最大の利点**、画像品質は誤差レベル。Video (CogVideoX) では 8-step で 100-step 比 92-96% 加速の大きな成果。本実装は image only。
133
+
134
+ ## 知見
135
+
136
+ - **LADD discriminator の再利用**: spectral norm + BatchNormLocal + multi-block hook の構造はそのまま流用可能、DMDX 専用 D を新規開発する必要なし
137
+ - **cubic time schedule**: shifted uniform より extreme な high-noise バイアス、`t_mean=0.0259-0.99` の wide range (cubic の variance 大)
138
+ - **n_critic_per_gen 削減**: DMD2 の 5 から **2** に。hinge GAN は D vs G の balance が critical で、disc 過多だと G が学習機会失う
139
+ - **`recon_weight=0` で開始**: pure ADM の純粋性 verify。不安定なら LADD 流 Smooth-L1 anchor (`>0`) で stability boost 可能
140
+ - **rate 3.6s/outer**: DMD2 の 3.7s と同等以下 (n_critic 削減効果)、parallel でも slowdown 小さい
141
+ - **billing limit 再発防止**: 並列 launch 前に Modal spend cycle limit を確認 (DMD2 v1 が KeyboardInterrupt'd した先例あり)
docs/migration_log.md ADDED
@@ -0,0 +1,583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 既存実装の移植 — 2026-05 試行ログ
2
+
3
+ [← README に戻る](../README.md) — 前章: [distillation.md](distillation.md)
4
+
5
+ > ⚠️ 本ドキュメントは **2026-05-17 時点のスナップショット**を含む。Modal volume の
6
+ > 状態 (LoRA path、cache 内容など) は時間とともに変動するので、最新状態は
7
+ > `modal volume ls` で確認すること。配布済 LoRA は HF の
8
+ > [darask0/anima-distill-loras](https://huggingface.co/darask0/anima-distill-loras) に集約。
9
+
10
+ [§4 失敗分析](distillation.md#4-実際の失敗全-5-件詳細) の 5 回失敗を受け、「自前で書き下す」のを諦めて **既存の動く実装を最小改造で
11
+ Anima に移植する** 方針に転換。survey と smoke test を経て **5 手法** を実装、
12
+ 本リポジトリに収録 (`scripts/distill/`)。
13
+
14
+ ## 5.0 全体マップ
15
+
16
+ ```
17
+ ┌── Z-Image trajectory imitation (DiffSynth-Studio 由来)
18
+ ├── DMD2 + TrigFlow (NVIDIA cosmos-predict2.5 公式)
19
+ 蒸留 5 候補 ────┼── LADD (AMD Nitro-1 移植、PixArt 由来)
20
+ ├── Reflow / InstaFlow (rfpp NeurIPS 2024 知見適用)
21
+ └── PCM (G-U-N/Phased-Consistency-Model、SD3 変種)
22
+
23
+ 無料補完 ────── ComfyUI-CapitanFlowMatch / ZiT-Scheduler (推論側 scheduler)
24
+
25
+ survey で却下 ── DMDR / Hyper-SD / NitroFusion / ADD (訓練コード非公開)
26
+ ```
27
+
28
+ ## 5.1 採用 5 手法の概要
29
+
30
+ | # | 手法 | カテゴリ | 中核ファイル | 設計の鍵 |
31
+ |---|---|---|---|---|
32
+ | ① | Z-Image trajectory imitation | trajectory matching | `scripts/distill/{traj_scheduler,traj_loss,train_traj}.py` | critic なし、teacher 50-step CFG=2 trajectory を student 8-step に align |
33
+ | ② | DMD2 + TrigFlow (cosmos-predict2.5) | score matching | `scripts/distill/{dmd2_official_loss,train_dmd2_official}.py` | **同じ base に 2 LoRA adapter** (student + fake_score)、`set_adapter()` 切替、alt 5:1 |
34
+ | ③ | LADD (AMD Nitro-1) | adversarial + recon | `scripts/distill/{anima_ladd_disc,train_ladd}.py` + `precompute_teacher_x0.py` | D backbone = teacher MiniTrainDIT (frozen)、5 spectral-norm head、Smooth-L1 recon anchor |
35
+ | ④ | Reflow / InstaFlow | flow alignment | `scripts/distill/train_reflow.py` (cache は `--save-noise` 必須) | Anima は元から RF、U-shape t + Huber + 間欠 LPIPS、1 grad-through で最安 |
36
+ | ⑤ | PCM (Phased Consistency Model) | consistency | `scripts/distill/{pcm_scheduler,train_pcm}.py` | SD3-PCM 流派 (FlowMatch + v-pred)、Anima RF と math 一致、N=50 を K=4 phase に分割 |
37
+
38
+ 全手法に共通の前提:
39
+ - **wide LoRA** (`attach_wide_lora()` で AdaLN + attn + MLP 全 Linear、454 modules、~72M trainable)
40
+ - **warm-start = Civitai 公式 Anima Turbo LoRA** (matched 968/1016 keys、x_embedder/t_embedder の 12 keys のみ skip)
41
+ - **B200 GPU** + dataset は `/dataset/raw` (5000 caption、品質タグは未掃除でも蒸留には影響なし)
42
+
43
+ ## 5.2 R3GAN との決定的な違い (なぜ LADD は動く可能性が高いか)
44
+
45
+ | 失敗パターン | R3GAN | LADD |
46
+ |---|---|---|
47
+ | D が無信号 → mean collapse | CNN を一から学習、16ch latent prior なし | D backbone = **teacher MiniTrainDIT (frozen)**、Anima latent を既に理解 |
48
+ | D 爆発 | γ (gradient penalty) calibration 困難 | **spectral norm + BatchNormLocal** で D の Lipschitz 制約 |
49
+ | anchor 不在で発散 | DMD2 単独だと mean に潰れる | **Smooth-L1 recon loss** (vs teacher x0) で「絶対に teacher から離れない」床を作る |
50
+
51
+ LADD smoke (5 step、`l_g_adv ≈ ln(2) ≈ 0.69`, `l_d_total ≈ 2·ln(2) ≈ 1.39`) — R3GAN 試行で
52
+ 1 度も達成できなかった「**stable initialization**」が初手で出る。
53
+
54
+ ## 5.3 LoRA-only + warm-start + precompute cache の三位一体
55
+
56
+ 3 つを組み合わせて実コストを大幅圧縮:
57
+
58
+ 1. **LoRA-only (~72M trainable / 2B base)** — gen と D backbone は frozen、optimizer state も小さい
59
+ 2. **warm-start (Civitai Turbo LoRA)** — cold-start より convergence が早く、品質下限が保証される
60
+ 3. **precompute teacher x0 cache** — teacher の 20-step CFG=4.5 rollout を 1 度だけ実行 (~$11 / 5000 サンプル)、
61
+ その後の蒸留で **複数手法が同じ cache を共有** (LADD / PCM が利用)
62
+
63
+ ```
64
+ precompute_teacher_x0_cache (1回, $11)
65
+
66
+ ├── LADD train (cache の x0 を real として使用)
67
+ └── PCM train (cache の emb を流用、x0 は teacher rollout source)
68
+
69
+ precompute_for_reflow (--save-noise 付き、別 cache, $11)
70
+
71
+ └── Reflow train (noise + x0 + emb triplet)
72
+ ```
73
+
74
+ ## 5.4 採用しなかった手法 (survey 結果)
75
+
76
+ | 手法 | 却下理��� |
77
+ |---|---|
78
+ | **DMDR** (vvvvvjdy/dmdr) | ImageNet/SiT のクラス条件 toy のみ公開、T2I (Z-Image, SD3.5) パイプラインは未公開 |
79
+ | **Hyper-SD / Hyper-FLUX** (ByteDance) | 訓練コード非公開 (HF model card のみ)、コミュニティ port なし |
80
+ | **NitroFusion / NitroSD-Vibrant** (ChenDarYen) | 訓練コード "Coming Soon" のまま停滞、リポジトリは 6 commit で凍結 |
81
+ | **ADD / LADD-DINOv2** (StabilityAI) | 訓練コード非公開 (issues wontfix)、AMD 版で代替 |
82
+
83
+ ## 5.5 inference 側補完: AYS / TeaCache / GGUF / sageattention の適用可否
84
+
85
+ | 手法 | Anima 適用可否 | 理由 |
86
+ |---|---|---|
87
+ | **AYS** (NVIDIA Align Your Steps) | ❌ | ComfyUI 公式実装は **SD1/SDXL/SVD の sigma schedule を hardcode** で rectified flow 未対応 |
88
+ | **TeaCache** (timestep 残差 cache) | ⚠️ **base 高 step 用のみ** | 4-8 step 蒸留 LoRA とは**構造的に non-compatible** (連続 step の差分が大きすぎてスキップ不可)。base 28-50 step での **2x 速** には使える、porting ~250 LoC |
89
+ | **ComfyUI-GGUF** Q8/Q4 量子化 | ⚠️ Anima 未対応 | FLUX/SD3/Hunyuan 系のみ。Anima loader 追加 + GGUF 変換が必要 |
90
+ | **CapitanFlowMatch / CapitanZiT-Scheduler** | ✅ | rectified flow / Cosmos-Predict2 兄弟向け scheduler、ComfyUI ノード追加だけ |
91
+ | **sageattention** | ✅ | torch SDPA 置換で 1.2-1.5x 推論速、`pip install sageattention` で `_base_image` に同梱済 |
92
+ | **DPM-Solver++ 高次** / **UniPC** | ✅ | sampler ノード切替のみ、Anima 標準 `er_sde` の代替 |
93
+
94
+ **実用推奨**: 蒸留 LoRA + sageattention + CapitanFlowMatch scheduler のスタックが最も効率的。
95
+ TeaCache は「LoRA 使わず base 高品質モード」用の補完として将来 porting する価値あり (~250 LoC, ~$10 calibration)。
96
+
97
+ ## 5.6 これから挑む人へ
98
+
99
+ - **survey をサボらない**: 4 つの「有名手法」が実は訓練コード非公開で詰む。GitHub の README を見るだけでなく `train.py` / `run.sh` を実際に開いて確認
100
+ - **`scripts/distill/anima_loader.py` は再利用** — `build_anima()` + `AnimaBundle` で diffusion-pipe の罠 (namespace shadow、`padding_mask` 必須、dtype object vs str など) を全部吸収済
101
+ - **smoke test を必ず 1 step 走らせる** — sign / dtype / 配線ミスを **$1-2 で発見**、本番 $30-50 をドブに捨てるリスク回避
102
+ - **`MSYS_NO_PATHCONV=1 PYTHONIOENCODING=utf-8 PYTHONUTF8=1` を modal CLI に毎回つける** (Windows JP locale 必須)
103
+
104
+ ## 5.7 実画像比較 (2026-05、同 prompt × seed)
105
+
106
+ 5 prompt × 2 seed × {8 step, 4 step} × {base CFG=4.5, Civitai 公式 Turbo CFG=1, ① Z-Image
107
+ trajectory imitation CFG=1, ② DMD2 CFG=1} = **80 枚** を Modal `compare_distill_loras` で
108
+ 生成 + v1.0 base 上で **13 条件 1 prompt 1 seed** を `verify_completed_loras` で生成。
109
+ 同 noise seed なので 1:1 で比較可能 (実装: [modal_app.py](../modal_app.py))。
110
+
111
+ **観察された明確な順位** (8-step CFG=1、preview3 base、5 prompt × 2 seed = 80 枚):
112
+
113
+ | Rank | 手法 | 評価 |
114
+ |---|---|---|
115
+ | 🥇 | **Civitai 公式 Anima Turbo** | シャープ、彩度高、盾の紋章/松明の光まで完備、キャラ識別正確 |
116
+ | 🥈 | ① Z-Image traj imitation (本 repo) | 鮮明・安定、ディテール OK |
117
+ | 🥉 | ② DMD2 (本 repo) | 絵画調シフト、やや洗色傾向 |
118
+ | ❌ | base 30step→8step | 暗め・シンプル、品質劣化大 |
119
+
120
+ **4-step での頑健性順位**:
121
+
122
+ | Rank | 手法 | 評価 |
123
+ |---|---|---|
124
+ | 🥇 | **Civitai 公式 Anima Turbo** | 8-step とほぼ同等品質を維持 |
125
+ | 🥈 | ① Z-Image | エッジ甘くなる (8-step 訓練だったため) |
126
+ | ❌ | ② DMD2 | シルエットのみ、ボケすぎ |
127
+ | ❌ | base | ほぼ崩壊、使用不可 |
128
+
129
+ **v1.0 base での生成時間 (sageattention 有効、1024×1024、er_sde/simple/shift=3)**:
130
+
131
+ | step / 条件 | 時間 | 備考 |
132
+ |---|---|---|
133
+ | 4-step LoRA (全 6 LoRA) | **3.5-4.0s** | 公式 Turbo / ① Z-Image / ② DMD2 / ④ Reflow / ⑩ DRaFT+ 系すべて |
134
+ | 8-step LoRA (全 6 LoRA) | **4.0-4.7s** | 同上 |
135
+ | 30-step base CFG=4.5 | **10.7s** | LoRA 無効、品質基準 |
136
+
137
+ → **4-step LoRA = base 30 step の 3 倍速**、sageattention 効果込み。Modal B200 のコスト換算で
138
+ 1 画像 ~$0.006 (4-step LoRA) vs ~$0.018 (base 30 step)。
139
+
140
+ **正直な結論**: **CircleStone Labs 公式 Turbo (Civitai) に予算 ~$300 で勝つのは現状困難**。
141
+ 公式は遥かに多い GPU 時間と専門知見を投入した結果。本 repo の自前蒸留は:
142
+
143
+ - base からの **品質向上は明白** (8-step CFG=1 が実用レベル)
144
+ - 公式 Turbo に **代替/補完** できる位置 (ライセンス遵守、自前カスタマイズ可)
145
+ - **学習目的・特定スタイル特化・蒸留パイプライン理解** には十分価値
146
+
147
+ ### 5.7.1 v1.0 base 切替で観察された style drift
148
+
149
+ 2026-05-17 に base を `anima-preview3-base.safetensors` → `anima-base-v1.0.safetensors` に
150
+ 切替。**既存 LoRA はすべて preview3 で訓練済だが v1.0 でも動作する** (アーキ互換)。ただし:
151
+
152
+ - **出力 style がデフォルメ寄りに変化** — verify_v1_sage の 13 条件すべてで chibi/SD 風が強く
153
+ 出た。preview3 base での同じ LoRA の出力 (compare_z_vs_dmd2) は比較的写実寄り
154
+ - 原因仮説: v1.0 base が「young girl appearance」prompt を v1.0 の aesthetic prior でより
155
+ 強く解釈する。preview3 とは prior 分布が異なる
156
+ - LoRA を再訓練するなら v1.0 base 上で行うのが筋。preview3 ベース LoRA を v1.0 で使う場合は
157
+ style ドリフトを受け入れる前提
158
+
159
+ ## 5.8 まだ試せる方向 (諦める理由はない)
160
+
161
+ 公式 Turbo に勝てないと判明しても、以下は試す価値がある:
162
+
163
+ 1. **訓練 step を増やす** — ① Z-Image を 2000 → 8000 step に延長 (+$54)、loss はまだ
164
+ plateau の手前で振動していた
165
+ 2. ~~**蒸留 LoRA に DRaFT+ HPSv2 reward を追加学習**~~ — **検証済、推奨せず** (§5.10 参照)。
166
+ HPSv2 score は +13 上昇したが視覚的には **reward hacking で劣化** (anime → 西洋イラスト
167
+ 風にドリフト、キャラ固有要素曖昧化、prompt adherence 落下)
168
+ 3. **より高解像度 teacher rollout** — 768 → 1024 で precompute (+$22 / cache、+$20 / train)
169
+ 4. **スタイル/キャラ特化蒸留** — 公式 Turbo は汎用向け、特定ドメイン (例: 単一キャラ、
170
+ 特定 artist style) なら自前のほうが優位の可能性
171
+ 5. **LoRA rank を上げる** — 32 → 64 / 128 (`--lora-rank 64`)、より大きな容量で
172
+ teacher の細部を学べる可能性
173
+ 6. **既存実装 (LADD/Reflow/PCM/SiD2/Shortcut) の本番結果を待つ** — 進行中、Z-Image を
174
+ 超える手法が出る可能性は残る
175
+ 7. **8 手法の **アンサンブル** — 複数 LoRA を runtime で blend (`ComfyUI` で
176
+ `LoraLoaderModelOnly` を 2 個直列に挟むだけ)、相補的に効くケースあり
177
+ 8. **v1.0 base で再訓練** — 既存 LoRA は preview3 起点、v1.0 で訓練し直すと aesthetic
178
+ 一致でさらに伸びる可能性 (§5.7.1 参照)
179
+
180
+ 「公式に勝つ」のではなく「**公式とは違う強みを持つ自前 LoRA**」を作る方が現実解。
181
+ 本 repo の 7 実装は **どれもライセンス上自由に派生可能** で、warm-start の柔軟性が
182
+ あるのが公式 Turbo にはない利点。
183
+
184
+ ### 5.8.1 ファインチューニング dataset 生成 (進行中、2026-05-17)
185
+
186
+ **目的**: hakushiMixAnima_v02 base + anima-highres-aesthetic-boost LoRA を組み合わせて、
187
+ **特定 4 artist のスタイル mix LoRA** を fine-tune するための合成 dataset を生成。
188
+
189
+ **Artist tags (固定 4 名、本 repo では秘匿 ♥)**:
190
+
191
+ ```
192
+ @artist_1, @artist_2, @artist_3, @artist_4
193
+ ```
194
+
195
+ - **本ドキュメントではセキュリティ / プライバシーの観点から具体的 artist 名を伏せる**
196
+ - 実 artist tag は `scripts/finetune_prompts*.txt` (Modal volume の private prompts ファイル) と
197
+ user の脳内にのみ存在する
198
+ - **Claude (AI assistant) が処理上 artist tag の実値が必要になった場合は、必ず user に質問**
199
+ して聞き出すこと。推測や前回会話履歴からの引用で済まそうとしない (privacy 保護)
200
+ - 4 artist は毎 prompt 必ず含まれる (artist drop なし、style mix を強固に学習させる狙い)
201
+
202
+ **Dataset 構成**:
203
+
204
+ | subdir | safety | themes | seeds | 想定枚数 | 内容 |
205
+ |---|---|---|---|---|---|
206
+ | `/dataset/finetune_raw/` | `safe` | 60 | 16 | 960 | SFW v1: 単体キャラ + couple + group + 環境 |
207
+ | `/dataset/finetune_raw_v2/` | `safe` | 60 | 16 | 960 | SFW v2: animal/mytho/sports/cyberpunk/etc |
208
+ | `/dataset/finetune_raw_v3/` | `safe` | 60 | 16 | 960 | SFW v3: 職業/世界観 (idol/detective/scholar/etc) |
209
+ | `/dataset/finetune_raw_nsfw/` | `nsfw` | 30 | 16 | 480 | suggestive (bath/lingerie/bedroom/etc) |
210
+ | `/dataset/finetune_raw_explicit/` | `explicit` | 30 | 16 | 480 | explicit sex acts #1-30 |
211
+ | `/dataset/finetune_raw_explicit_v2/` | `explicit` | 60 | 16 | 960 | explicit sex acts #31-90、consensual framing 8 件修正 |
212
+ | **合計** | | **300 themes** | | **~4800** | |
213
+
214
+ 訓練時は AnimaImageCaptionDataset の rglob で全 subdir 自動取り込み (親 `/dataset/` を指定)。
215
+
216
+ ### 5.8.2 ファインチューニング訓練計画
217
+
218
+ 完成後 `train_finetune_4variants` で 4 hyperparam 並列実行 (各 B200):
219
+
220
+ | variant | rank | lr | epochs | drop_artist | 狙い |
221
+ |---|---|---|---|---|---|
222
+ | A baseline | 32 | 2e-5 | 3 | 0.0 | Anima 公式推奨そのまま |
223
+ | B higher capacity | 64 | 2e-5 | 3 | 0.0 | rank 倍で細部表現↑ |
224
+ | C safer LR | 32 | 1e-5 | 4 | 0.0 | 過学習回避 + step ↑ |
225
+ | D artist focus | 32 | 3e-5 | 3 | 0.0 | lr 強めで style 強化 |
226
+
227
+ **`llm_adapter_lr = 0` 必須** (Anima 公式既知識保護、本 repo `configs/phase1_anima.toml` 既設定)。
228
+
229
+ ## 5.9 実走中の状態スナップショット (2026-05-17 時点)
230
+
231
+ > ⚠️ このセクションは **2026-05-17 時点のスナップショット**。実際の Modal volume 上の
232
+ > path / 中身は時間と共に変わる。最新は `modal volume ls anima-outputs` で確認。
233
+ > 現在配布済の LoRA は HF [darask0/anima-distill-loras](https://huggingface.co/darask0/anima-distill-loras) に集約。
234
+
235
+ このリポジトリで現在 Modal 上に保存されている / 訓練中の LoRA とその想定用途。
236
+ 中断/再開時の参照用:
237
+
238
+ | LoRA | path on `anima-outputs` volume | source method | 状態 (当時) | コスト |
239
+ |---|---|---|---|---|
240
+ | **traj_final** | `/output/traj_full/traj_final.safetensors` | ① Z-Image trajectory imitation | ✅ 完走 (2000 step) | $18 |
241
+ | **traj_extended** | `/output/traj_extended_8000/traj_final.safetensors` | ① Z-Image 延長 (6000 step 追加) | 🟢 進行中 | ~$54 |
242
+ | **traj_rank128** | `/output/traj_rank128/traj_final.safetensors` | ① rank 32→128 cold-start | 🟢 進行中 | ~$50 |
243
+ | **dmd2_student_final** | `/output/dmd2_full/dmd2_student_final.safetensors` | ② DMD2 + TrigFlow | ✅ 完走 (3000 outer) | $19 |
244
+ | **ladd_v2_fast** | `/output/ladd_v2_fast/ladd_student_final.safetensors` | ③ LADD-v2 (bug 修正版) | 🟢 進行中 | ~$17 |
245
+ | **reflow_final** | `/output/reflow_full/reflow_final.safetensors` | ④ Reflow (rfpp) | 🟢 進行中 (~7000 step) | ~$8 |
246
+ | **pcm_final** | `/output/pcm_full/pcm_final.safetensors` | ⑤ PCM (SD3 流派) | 🟢 進行中 | ~$36 |
247
+ | **sid_student_final** | `/output/sid_full/sid_student_final.safetensors` | ⑥ SiD2 (data-free) | 🟢 進行中 | ~$18 |
248
+ | **shortcut_final** | `/output/shortcut_full/shortcut_final.safetensors` | ⑦ Shortcut Models (d-head) | 🟢 進行中 | ~$25 |
249
+ | **draftp_final** (Z-Image warm) | `/output/draftp_full/draftp_final.safetensors` | ⑧ DRaFT+ HPSv2 on ① | ✅ 完走 | $3 |
250
+ | **draftp_on_turbo** | `/output/draftp_on_turbo/draftp_final.safetensors` | ⑧ DRaFT+ HPSv2 on Civitai Turbo | ✅ 完走 | $3 |
251
+
252
+ 中間 cache:
253
+ - `/dataset/teacher_x0_cache/` — 5000 サンプル、teacher 20-step CFG=4.5 の x0 + Qwen3 emb
254
+ - `/dataset/reflow_cache/` — 同上 + `noise` も保存 (Reflow / Shortcut 用)
255
+ - `/dataset/teacher_x0_smoke/` — 50 サンプル smoke 用
256
+ - `/models/loras/anima_turbo.safetensors` — Civitai 公式 (warm-start 用)
257
+ - `/models/hpsv2/HPS_v2_compressed.pt` — DRaFT+ 用 reward model
258
+
259
+ 比較生成: `compare_distill_loras` (4 条件) と `compare_all_methods` (全 9-11 条件、final LoRA
260
+ 存在を自動判定) の 2 種を `modal_app.py` に実装済。後者は 5 残り手法の本番完了後に走らせる。
261
+
262
+ 実コスト累計 (2026-05-17 時点): **~$200** (smoke + 失敗 + 完走 + 進行中の使用済分込み)、
263
+ 予算 $300 の 67%。
264
+
265
+ ### 5.9.2 Modal volume 構成 (4 volumes)
266
+
267
+ 本リポジトリは **4 つの Modal volume** を使い分けて状態を持つ。volume mount は
268
+ `modal_app.py::VOLUMES` で定義。
269
+
270
+ #### A. `anima-models` (~30 GB) — base モデル + 全 LoRA + reward weights
271
+
272
+ mount: `/models`、create_if_missing=True
273
+
274
+ ```
275
+ checkpoints/
276
+ anima-base-v1.0.safetensors ← 現用 base (4.18 GB)
277
+ hakushiMixAnima_v02.safetensors ← (symlink, comfyui-anima-models から)
278
+ qwen_3_06b_base.safetensors ← text encoder
279
+ qwen_image_vae.safetensors ← VAE
280
+ phase_a_distilled.safetensors ← 旧 Phase A 失敗 ckpt (~$70 で失敗、残ってる)
281
+ phase_a_step1000.safetensors ← 旧 Phase A 中間
282
+ anima-preview3-base.safetensors ← ❌ 削除済 (v1.0 切替時)
283
+
284
+ loras/
285
+ anima_turbo.safetensors ← Civitai 公式 (warm-start のデファクト)
286
+ z_image_traj_final.safetensors ← ① Z-Image 蒸留 (preview3 起点、完走 LoRA)
287
+ dmd2_student_final.safetensors ← ② DMD2 (preview3 起点、完走 LoRA)
288
+ reflow_final.safetensors ← ④ Reflow (preview3 起点、完走 LoRA)
289
+ draftp_on_zimage.safetensors ← ⑩ DRaFT+ on Z-Image (品質追加学習)
290
+ draftp_on_turbo.safetensors ← B: DRaFT+ on Civitai Turbo (reward hacking 検証済、品質劣化)
291
+ ladd_v2_step500.safetensors ← ③ LADD v2 部分 (停止前 step 500)
292
+ pcm_step500.safetensors ← ⑤ PCM 部分 (停止前 step 500)
293
+ anima-highres-aesthetic-boost.safetensors ← (symlink, comfyui-anima-models から)
294
+ phase_b/d/f_4step_lora.safetensors ← 旧 R3GAN 試行の残骸 LoRA
295
+
296
+ hpsv2/
297
+ HPS_v2_compressed.pt ← DRaFT+ 用 reward model (1.97 GB)
298
+
299
+ torchhub/ ← torch.hub cache
300
+ cache/ ← misc cache (sageattention 等)
301
+ ```
302
+
303
+ #### B. `anima-dataset` (~40 GB) — 全データセット + 比較生成出力
304
+
305
+ mount: `/dataset`、create_if_missing=True
306
+
307
+ **A. 元データ / self-distillation**:
308
+ - `raw/` — Anima base 自己生成 5000 (.png + .txt)、初期 distill 用
309
+ - `samples/`, `turbo_test/`, `compare/` — ��去の動作確認生成
310
+
311
+ **B. 蒸留用 precompute cache**:
312
+ - `teacher_x0_smoke/` — 50 サンプル smoke 用
313
+ - `teacher_x0_cache/` — 5000 サンプル × (x0 + emb)、LADD/PCM/SiD2 用
314
+ - `reflow_cache/` — 同上 + noise 保存、Reflow / Shortcut 用
315
+
316
+ **C. 比較 / 検証生成**:
317
+ - `compare_z_vs_dmd2/` — 80 PNG (base / turbo / ① / ② × 8/4 step)
318
+ - `verify_completed/` — 旧 verify (workflow bug で 0 images、失敗)
319
+ - `verify_v1_sage/` — v1.0 + sageattention で 13 条件 verify (1 prompt × 13)
320
+ - `ckpt_health_check/` — quick_check_ckpt 出力 (PCM step 500 等)
321
+
322
+ **D. ファインチューニング dataset (現在進行中、~4800 枚予定)**:
323
+ - `finetune_raw/` — SFW v1 (60 themes × 16 seeds = 960)
324
+ - `finetune_raw_v2/` — SFW v2 (animal/mytho/sports 60×16 = 960)
325
+ - `finetune_raw_v3/` — SFW v3 (職業/世界観 60×16 = 960)
326
+ - `finetune_raw_nsfw/` — NSFW suggestive (30×16 = 480)
327
+ - `finetune_raw_explicit/` — EXPLICIT v1 (30×16 = 480)
328
+ - `finetune_raw_explicit_v2/` — EXPLICIT v2 #31-90 (60×16 = 960)
329
+
330
+ #### C. `anima-outputs` (~30 GB) — 訓練 LoRA / ckpt の保存先
331
+
332
+ mount: `/output`、create_if_missing=True
333
+
334
+ **完走 (preview3 起点)**:
335
+ - `traj_full/` — ① Z-Image final ($18)
336
+ - `dmd2_full/` — ② DMD2 final ($19)
337
+ - `reflow_full/` — ④ Reflow final ($13)
338
+ - `draftp_full/` — ⑩ DRaFT+ on Z final ($3)
339
+ - `draftp_on_turbo/` — B: DRaFT+ on Turbo final ($3)
340
+
341
+ **中断 / 部分 (preview3、~$80-100 浪費)**:
342
+ - `ladd_v2_fast/` — ③ LADD v2 step 500 ckpt まで
343
+ - `pcm_full/` — ⑤ PCM step 500 ckpt まで
344
+ - `sid_full/` — ⑦ SiD2 outer ~4200 まで (停止時)
345
+ - `traj_extended_8000/` — A: Z-Image extend (停止)
346
+ - `traj_rank128/` — E: rank 128 (停止)
347
+ - `shortcut_full/` — ⑦ Shortcut 異常遅延 step 500 まで ($63 損)
348
+
349
+ **旧 R3GAN 試行 (5 連続失敗の残骸)**:
350
+ - `distill/`, `distill_e/`, `distill_f/`, `distill_f_full/` — Phase A/B/C 試行
351
+
352
+ **smoke 各種**:
353
+ - `*_smoke/` — 各手法の smoke test 出力 (1-5 step、debug 用)
354
+
355
+ #### D. `comfyui-anima-models` (~5 GB) — user アップロード外部モデル
356
+
357
+ mount: `/comfyui_anima_models`、create_if_missing=**False** (user が事前アップロード)
358
+
359
+ ```
360
+ diffusion_models/
361
+ hakushiMixAnima_v02.safetensors ← ファインチューニング用 base
362
+ loras/
363
+ anima-highres-aesthetic-boost.safetensors ← ファインチューニング用 quality boost LoRA
364
+ text_encoders/, vae/ ← 空 (将来用 placeholder)
365
+ ```
366
+
367
+ ファインチューニング用 dataset 生成時に `/models/{checkpoints,loras}` へ symlink される
368
+ (`generate_finetune_chunk` 内で自動)。
369
+
370
+ #### 累計サイズ + コスト
371
+
372
+ | volume | 概算サイズ | 月額保管料 (Modal $0.15/GB/月) |
373
+ |---|---|---|
374
+ | anima-models | ~30 GB | ~$4.5 |
375
+ | anima-dataset | ~40 GB | ~$6 |
376
+ | anima-outputs | ~30 GB | ~$4.5 |
377
+ | comfyui-anima-models | ~5 GB | ~$0.75 |
378
+ | **合計** | **~105 GB** | **~$16/月** |
379
+
380
+ 不要 dir は `cleanup_checkpoints` Modal function 等で適時間引きが推奨 (古い phase_a/b/c 系、
381
+ shortcut_full、放置 smoke 各種、要らない finetune subdir は数十 GB 占有してる)。
382
+
383
+ ### 5.9.1 Anima 公式推奨 prompt / 推論設定 / ファインチューニング設定
384
+
385
+ [CircleStone Labs/Anima HF README](https://huggingface.co/circlestone-labs/Anima) から
386
+ 抜粋した推奨設定。本リポジトリの全 workflow / generate_dataset / fine-tune script は
387
+ この推奨に準拠している。
388
+
389
+ **Prompt format**:
390
+
391
+ ```
392
+ [quality/meta/year/safety] [1girl/1boy/etc] [character] [series] [@artist] [general]
393
+ ```
394
+
395
+ - Quality prefix (positive 先頭): `masterpiece, best quality, score_7, safe`
396
+ - Negative: `worst quality, low quality, score_1, score_2, score_3, artist name`
397
+ - tag は **lowercase + space 区切り** (underscore は score タグだけ: `score_7`)
398
+ - Danbooru タグより **Gelbooru タグを優先**
399
+ - artist tag は **`@` プレフィックス必須** (なしだと効果激減)、`[artist]` 位置に置く
400
+ - 多語 artist は半角 space (例: `@nnn yryr`)
401
+ - 別解釈 disambiguator がある場合は paren escape (例: `@artist_name \(circle_name\)`)
402
+ - 複数 artist を `,` 区切りで列挙可
403
+
404
+ **Recommended sampler / scheduler / CFG / steps**:
405
+
406
+ | 推奨 | 値 / 説明 |
407
+ |---|---|
408
+ | sampler | **`er_sde`** (中立、フラット彩色、シャープライン)、`euler_a` (柔らかめ、2.5D 傾向)、`dpmpp_2m_sde_gpu` (バラエティ豊か、暴走しがち) |
409
+ | scheduler | **`simple`** (デフォルト)、`beta57` (RES4LYF custom node、リアル系・絵画調用) |
410
+ | sigma_shift | **`3.0`** (`ModelSamplingAuraFlow` で必須、本リポジトリ全 workflow で設定済) |
411
+ | CFG | **4-5** |
412
+ | step | **30-50** |
413
+ | resolution | **512² から 1536²** までサポート (1024² 中心が無難) |
414
+ | VAE | `qwen_image_vae.safetensors` |
415
+ | Text encoder | `qwen_3_06b_base.safetensors` |
416
+ | Diffusion model | `anima-base-v1.0.safetensors` |
417
+
418
+ **Fine-tuning best practices** (Anima ��式抜粋):
419
+
420
+ - **`llm_adapter_lr = 0` 必須** — adapter は影響力が強く既知識を多く持つため訓練すると壊れる
421
+ (本 repo `configs/phase1_anima.toml` で設定済)
422
+ - 低 LR start: rank 32 LoRA で **`lr = 2e-5`** から
423
+ - light touch training — モデルが既に多様な visual concept を持っているので無理に押し込まない
424
+ - captioning は **Danbooru tag + 自然言語の併用** OK
425
+ - 例: 公式 [style LoRA on Civitai](https://civitai.com/models/2536147) (dataset + config 公開)
426
+
427
+ **Content policy / restrictions**:
428
+
429
+ - safety tag: `safe / sensitive / nsfw / explicit` を positive / negative 両方で使い分ける
430
+ - text rendering は弱い (長文を画像に書かせない)
431
+ - 複数キャラ: character 名 → 基本属性の順で書くと混同回避
432
+ - prompt weighting は SDXL より強めに必要 (例: `(chibi:2)`)
433
+ - 自然言語 prompt は **最低 2 文以上** が安定 (極端に短いと予測不能)
434
+ - **非商用ライセンス縛り**、商用問い合わせ `tdrussell@circlestone.ai`
435
+
436
+ ## 5.10 品質評価の方法論 (reward hacking 実証あり)
437
+
438
+ **「ディテール多い・彩度高い」≠「良い」**。蒸留 LoRA や reward fine-tuning の出力を評価
439
+ する際は、必ず以下の軸で分解して判断する:
440
+
441
+ | 評価軸 | 確認内容 |
442
+ |---|---|
443
+ | (a) **target style 維持** | anime 系を期待しているなら anime のままか? 西洋イラスト / リアル系にドリフトしていないか? |
444
+ | (b) **キャラクター固有要素の精度** | 例: Touhou Flandre Scarlet なら金髪+赤目+クリスタル翼、Remilia なら青髪+赤目+コウモリ翼 が正確か? |
445
+ | (c) **prompt adherence** | 指示したポーズ (例: hands raised pointing toward viewer with open palms)、構図、背景要素を反映しているか? |
446
+ | (d) **破綻ポイント** | 手指の本数・関節・方向、複数キャラの顔混じり、関節破綻、bad anatomy |
447
+ | (e) **生成時間** | per-image gen_time (`_summary.json` から)、step 数 × sampler × LoRA 構成で比較 |
448
+
449
+ **DRaFT+ HPSv2 reward fine-tuning の reward hacking 実証 (2026-05-17)**:
450
+
451
+ - 仮説: 公式 Anima Turbo に HPSv2 reward fine-tuning (DRaFT+ K=1 LV、kl_coeff=0.2、1500 step、
452
+ $3) を後付けすれば品質が公式超えできる?
453
+ - 結果: HPSv2 score は **25.78 → 38.99 (+13.21)** と大幅向上 (loss 順調、KL 0.2-0.3 安定)
454
+ - **しかし視覚的には劣化** (ユーザー評価): 西洋イラスト風にドリフト、Flandre/Remilia の固有
455
+ 翼が曖昧化、両キャラの衣装がほぼ同じに、prompt の手のポーズが崩れる、装飾過多 (シャンデリア
456
+ + ランプ + 飾り) でゴチャゴチャ
457
+ - 原因: HPSv2 は「人間が好きそうな generic な見栄え」preference を学習しており、Touhou anime
458
+ style や character accuracy を直接最適化していない。reward が指す方向と用途が乖離した
459
+
460
+ 教訓:
461
+ - **reward score 上昇 ≠ 品質向上**。reward 系は必ず視覚検証
462
+ - HPSv2 / PickScore / ImageReward 等は「平均的に綺麗」を上げるだけ、特定 niche style には不向き
463
+ - 蒸留 LoRA の評価では、必ず **「複数の評価軸を分解」して判定**。1 軸だけ (例: ディテール量)
464
+ で判断しない
465
+ - 「score が上がった」「ディテールが増えた」を理由に勝者宣言しない、視覚的に必ず確認する
466
+
467
+ **verify 用 prompt の設計指針** ([scripts/verify_prompts.txt](../scripts/verify_prompts.txt)):
468
+
469
+ 単純な single-character prompt では LoRA の差が出ない。**「壊れにくいもの」より「壊れやすい
470
+ もの」をテストする**:
471
+
472
+ - 複数キャラクター (face mix / 属性入れ替えが起きやすい)
473
+ - キャラ毎に異なる属性 (髪色、目色、翼種別)
474
+ - 手・指の特定ポーズ (open palms、pointing)
475
+ - 詳細背景 + foreground キャラ両立
476
+ - target style の制約 (anime style 維持を強制)
477
+
478
+ これで初めて LoRA 間の優劣が顕在化する。
479
+
480
+ ## 5.11 次セッション引き継ぎ (2026-05-17 23:xx 中断時点)
481
+
482
+ > ⚠️ 2026-05-17 時点のスナップショット。**現在は v1.0 base で PCM が cold-start 完走済**
483
+ > ([darask0/anima-distill-loras/pcm](https://huggingface.co/darask0/anima-distill-loras/tree/main/pcm))。
484
+ > 本セクションは当時の状態を歴史記録として保持。
485
+
486
+ ### 1. 完走済 (Anima preview3 base 起点、すべて使用可)
487
+
488
+ | LoRA | 出力 path | コスト |
489
+ |---|---|---|
490
+ | ① Z-Image traj imitation | `/output/traj_full/traj_final.safetensors` | $18 |
491
+ | ② DMD2 + TrigFlow | `/output/dmd2_full/dmd2_student_final.safetensors` | $19 |
492
+ | ④ Reflow rfpp | `/output/reflow_full/reflow_final.safetensors` | $13 |
493
+ | ⑩ DRaFT+ on ① Z-Image | `/output/draftp_full/draftp_final.safetensors` | $3 |
494
+ | B: DRaFT+ on Civitai Turbo | `/output/draftp_on_turbo/draftp_final.safetensors` | $3 ⚠️ reward hacking 検証で **品質劣化** 確認 |
495
+
496
+ ### 2. 中断 (preview3 起点、user が「古いから止めて」と判断し全停止 2026-05-17)
497
+
498
+ | 手法 | 部分 ckpt | 中断時 step | 浪費コスト |
499
+ |---|---|---|---|
500
+ | ③ LADD v2 | `/output/ladd_v2_fast/ladd_student_step00500.safetensors` | ~500/5000 | ~$5 |
501
+ | ⑤ PCM | `/output/pcm_full/pcm_step00500.safetensors` | ~760/5000 | ~$30 |
502
+ | ⑦ SiD2 | `/output/sid_full/` (sample_every=500 で step ~4000 までの ckpt) | ~4200/5000 | ~$15 |
503
+ | ⑧ Shortcut | `/output/shortcut_full/shortcut_step00500.safetensors` | ~560/4000 (64s/step 異常遅延で abort) | $63 |
504
+ | A: Z-Image extend | `/output/traj_extended_8000/` (step ~1360 までの ckpt) | ~1360/6000 | ~$13 |
505
+ | E: rank 128 | `/output/traj_rank128/` (step ~1440 までの ckpt) | ~1440/2000 | ~$13 |
506
+
507
+ 中断合計浪費: **~$139**。**ファイルは残してある**、user 判断で再開 or 削除。
508
+
509
+ ### 3. ファインチューニング dataset (新規、hakushi base、ほぼ完成)
510
+
511
+ | subdir | safety | 完了状況 |
512
+ |---|---|---|
513
+ | `/dataset/finetune_raw/` | safe | ✅ 960 |
514
+ | `/dataset/finetune_raw_v2/` | safe | ✅ 960 |
515
+ | `/dataset/finetune_raw_v3/` | safe | ✅ 960 |
516
+ | `/dataset/finetune_raw_nsfw/` | nsfw | ✅ 480 |
517
+ | `/dataset/finetune_raw_explicit/` | explicit | ✅ 480 |
518
+ | `/dataset/finetune_raw_explicit_v2/` | explicit | ✅ 960 |
519
+ | **合計** | | ✅ **4800 / 4800 完成** |
520
+
521
+ 訓練 prompt: `scripts/finetune_prompts*.txt`、artist tag は **秘密 ♥** (§5.8.1 参照)。
522
+
523
+ ### 4. 次セッションで user 判断待ちのこと (最重要)
524
+
525
+ ファインチューニング dataset 完成後、**どちらに進むか user 判断**:
526
+
527
+ **Path A (推奨): ファインチューニング集中**
528
+ - `train_finetune_4variants` で 4 hyperparam 並列 (A: rank 32 lr 2e-5 / B: rank 64 lr 2e-5 / C: rank 32 lr 1e-5 epochs 4 / D: rank 32 lr 3e-5)
529
+ - 各 ~3-4h、計 ~$120
530
+ - 蒸留は公式 Anima Turbo に任せる (compare_z_vs_dmd2 で Turbo 圧勝が実証済)
531
+ - 自前 fine-tune LoRA × 公式 Turbo の **スタック** が現実解
532
+
533
+ **Path B: v1.0 base で蒸留 LoRA を再訓練**
534
+ - 既存の `/dataset/raw/` (preview3 self-distill) は base 違いで不適
535
+ - v1.0 base で self-distillation dataset 新規生成必要 (~$25)
536
+ - その後 5 蒸留手法を v1.0 で再訓練 (~$80-120)
537
+ - 計 ~$120-145
538
+ - 公式 Turbo に勝てる見込み低 (前回比較で実証)
539
+
540
+ **Path C: 両方やる**
541
+ - Path A 先行で完成見て、余力あれば Path B
542
+ - 計 ~$250-280
543
+
544
+ > **2026-05-18 update**: PCM を Path B 流に v1.0 base で cold-start 完走、
545
+ > HF 配布まで完了 ([詳細は HF model card](https://huggingface.co/darask0/anima-distill-loras/tree/main/pcm))。
546
+
547
+ ### 5. 次 Claude が必ず守るルール
548
+
549
+ - **Artist tag は秘密** (§5.8.1)。Claude が prompt 生成で必要な場合、user に直接質問する。
550
+ 推測 / 履歴引用しない。`scripts/finetune_prompts*.txt` には実 tag が入ってるが README / chat
551
+ 出力では具体名を引用しない
552
+ - **Modal CLI on Windows 必須 prefix**: `MSYS_NO_PATHCONV=1 PYTHONIOENCODING=utf-8 PYTHONUTF8=1`
553
+ - **画像品質評価**: 「ディテール多い ≠ 良い」、必ず user judgment を仰ぐ (§5.10 reward hacking 実証)
554
+ - **workflow JSON node ID**: positive=5, latent=7, KSampler=8 (`generate_dataset.py::patch_workflow` が hardcode)
555
+ - **base 切替の影響**: preview3 LoRA を v1.0 で使うと style drift する (§5.7.1)
556
+ - **rank 違いの LoRA は warm-start 不可** (`load_warm_lora` で 0 keys matched エラー)、cold-start 必要
557
+ - **LADD discriminator bug fix**: `gradient_to_input` フラグ追加済、G phase は True、D phase は False
558
+ - **Modal function param は lowercase**: 大文字 `K: int` は Modal CLI で `k=1` に変換され抹消、`k_grad` などに
559
+ - **sageattention 有効**: `_base_image` に同梱、`generate_dataset.py` で auto-enable
560
+
561
+ ### 6. 現コスト累計 + 予算
562
+
563
+ - 完走 LoRA + smoke + 失敗 retry + dataset: **~$200**
564
+ - 中断蒸留訓練の浪費: **~$139**
565
+ - 当初予算 $300 → **~$339 で +$39 超過**
566
+ - 推定 ファインチューニング 4 並列: +$120 → 完成時総額 ~$460
567
+ - user が「金は OK」と承認 (実コストは全部 Modal dashboard で監視可能)
568
+
569
+ ### 7. 中断時 Modal で active な task
570
+
571
+ セッション終了時に bash background は全部止めてよい (Modal cloud は無影響)。
572
+ ただし Modal app は **app_stop コマンドで明示停止が必要** (止めないと課金続行):
573
+
574
+ ```bash
575
+ modal app list # 全 app 確認
576
+ modal app stop <ap-xxxxx> --yes # 個別停止
577
+ ```
578
+
579
+ 次セッション開始時は `modal app list` で再確認して、無駄に残った app が無いか確認推奨。
580
+ 本セッション中断時には:
581
+ - 6 訓練 (LADD v2 / PCM / SiD2 / A / E / Shortcut) はすべて停止済
582
+ - dataset 生成: ✅ **全 6 subdir 4800 枚完成** (SFW v3 も 2026-05-17 終わり際で完了)
583
+ - 残 active task なし、Modal 課金は volume 保管料 ($16/月) のみ
docs/operations.md ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 実装 / 運用 Tips + トラブルシューティング
2
+
3
+ [← README に戻る](../README.md)
4
+
5
+ [distillation.md §4.7](distillation.md#47-周辺で踏んだ-modal--diffusion-pipe--comfyui-罠) の diffusion-pipe / ComfyUI 罠に加え、
6
+ 2026-05 試行 (5 手法移植) で踏んだ追加の罠と対処、よくあるトラブルの解決策。
7
+
8
+ ## Modal CLI on Windows + JP (cp932) locale
9
+
10
+ 毎回必須:
11
+ ```bash
12
+ MSYS_NO_PATHCONV=1 PYTHONIOENCODING=utf-8 PYTHONUTF8=1 modal run ...
13
+ ```
14
+
15
+ | 罠 | 症状 | 対処 |
16
+ |---|---|---|
17
+ | cp932 codec error | Modal CLI 出力の Unicode ✓ や ● を encode できず即死 | `PYTHONIOENCODING=utf-8 PYTHONUTF8=1` |
18
+ | Git Bash MSYS path mangling | `--dataset-path /dataset/raw` が `C:/Program Files/Git/dataset/raw` に化ける | `MSYS_NO_PATHCONV=1` |
19
+ | Modal token が silent revoke | `.modal.toml` は valid、`modal config show` で見える、しかし API 呼び出しで "Token not found" | `MODAL_LOGLEVEL=DEBUG modal app list` でサーバー側のエラーを確認 → ユーザーが `modal token new` で再発行 |
20
+ | `modal app stop` が対話確認で停止 | `Are you sure...? [y/N]:` で固まる | `--yes` を必ず付ける |
21
+
22
+ ## diffusion-pipe + Anima 移植時の罠
23
+
24
+ | 罠 | 症状 | 対処 |
25
+ |---|---|---|
26
+ | `_find_blocks` ヘルパーが `llm_adapter.blocks` を選ぶ | Anima DiT は本体 28 blocks + llm_adapter 6 blocks。名前 heuristic で adapter 側を拾うと **5 head すべて学習されない** | `transformer.blocks` を direct 参照、または `"llm_adapter" not in name` で filter (`anima_ladd_disc.py::_find_blocks` 参照) |
27
+ | LPIPS reg が OOM | `compute_regularization` 内で **student rollout を grad-through で 2 回目** 走らせると 16 forward が graph に乗り 178 GiB 食い尽くす | (1) `--lpips-weight 0` で disable (2) `--lpips-every N` で間欠的に有効化 (Reflow パターン) (3) 解像度 1024 → 768 に下げる |
28
+ | PEFT LoRA 2 adapter 切替 | DMD2 で同じ base に student + fake_score を attach する場合、**毎 forward で `set_adapter(name)` を呼ばないと 片方の重みが leak する** | `make_velocity_fn(peft_model, adapter_name)` のような callable wrapper で隠蔽 (`train_dmd2_official.py` 参照) |
29
+ | Civitai Turbo LoRA → PEFT 形式変換 | warm-start 時、ComfyUI 形式 `diffusion_model.<…>.lora_A.weight` を PEFT 形式 `base_model.model.<…>.lora_A.default.weight` に変換必要 | `train_traj.py::convert_comfy_to_peft_lora` (`_convert_peft_to_comfy_lora` の逆向き) |
30
+ | warm-start key match | wide LoRA target が `x_embedder / t_embedder` を含まないため、Anima Turbo の 1016 keys のうち **968 keys のみ match** (12 keys skip、48 keys missing) | 致命的ではない、warm-start として十分機能 |
31
+ | subprocess 内 `import modal` 失敗 | 訓練 script が `modal.Volume.from_name(...).commit()` を呼ぶと `ModuleNotFoundError: No module named 'modal'` (subprocess の Python path に Modal がない) | `try/except` で囲んで warning 出力のみ。Modal は subprocess 終了時に自動で volume commit する |
32
+ | **LADD discriminator の `torch.no_grad()` + `feat.detach()` で adv 勾配が student に届かない** | G phase で adv loss を `.backward()` しても勾配が disc の heads までで止まる。実質「Smooth L1 recon distill only」になり LADD のメリット消失 | `AnimaLADDDiscriminator.forward(...)` に `gradient_to_input` フラグを足す。G phase は `True` (teacher は frozen なので weight 更新されないが activations が残り x_input → student に勾配が流れる)、D phase は `False` (heads だけ訓練、メモリ節約) |
33
+ | **LADD bs=4 × accum=4 × misaligned_pairs_d で 170 s/step (smoke の 200x 遅い)** | bs=4 で D forward の cost が線形増、accum=4 で 4 倍 micro-step、misaligned で D batch を 2 倍に → 20+ full-DiT forward/step、B200 上で 170 s | **bs=2 accum=1 + misaligned 無効** に下げる → ~2 s/step (85x 高速化)。LADD は GAN なので大 batch 不要 (small noise の方が D の安定性に貢献)。`train_ladd.py` 引数: `--batch-size 2 --grad-accum 1` (`--misaligned-pairs-d` フラグを付けない) |
34
+ | **異なる rank の LoRA を warm-start に使えない** | `--lora-rank 128` で `--warm-lora /models/loras/anima_turbo.safetensors` (rank 32) を指定 → `load_warm_lora` 内で shape mismatch、`No LoRA keys matched` で abort | rank 変更時は **cold-start** で訓練 (warm-start を省略)。または rank padding helper を書く |
35
+ | **modal CLI の uppercase arg は失敗** | function param `K: int` を CLI から `--K 1` で渡しても Modal が `k=1` に lowercase 変換、`unexpected keyword argument 'k'` で abort | function param は **必ず lowercase** にする (例 `k_grad: int`)、CLI 側 `--k-grad 1` を渡す |
36
+ | **ComfyUI workflow JSON の `_comment` キーが node 扱いされて crash** | workflow JSON を iterate して `node.get("class_type")` する処理で、`_comment` (string value) が来ると `'str' object has no attribute 'get'` | `for node_id, node in wf.items(): if not isinstance(node, dict): continue` で skip |
37
+ | **workflow JSON の node ID が `patch_workflow` の hardcode と一致しないと全 generation が HTTP 400 で fail** | `scripts/generate_dataset.py::patch_workflow` は positive prompt を node "5"、latent を "7"、KSampler を "8" に hardcode。ユーザー由来 workflow の node ID が違うと `$PROMPT/$SEED` プレースホルダが置換されず ComfyUI が prompt graph reject | 新 workflow を作る時は **必ず node ID を 5=positive, 7=latent, 8=KSampler** で構築。`anima_workflow.json` 系を雛形にする |
38
+ | **reward fine-tuning (DRaFT+) で reward score 上昇 ≠ 視覚品質向上** | HPSv2 score +13.21 上昇したが視覚的には reward hacking が起き、anime → 西洋イラスト ドリフト + キャラ固有要素曖昧化 + prompt adherence 落下 | 必ず視覚検証する、score だけで「勝った」と判断しない。kl_coeff 上げる (0.2 → 0.5) や reward 種類変更 (HPSv2 → ImageReward) で軽減可能性、ただし根本解決ではない ([migration_log §5.10](migration_log.md#510-品質評価の方法論-reward-hacking-実証あり) 参照) |
39
+ | **base model 切替で既存 LoRA の出力 style が drift する** | preview3 で訓練した LoRA を v1.0 base で使うと chibi/SD 寄りに style が変わる (LoRA は動作する、出力分布が違う) | LoRA は train 時の base と一致させて使うのが筋。base 切替時は LoRA を再訓練するか style drift を許容する |
40
+ | **sageattention は .so 不要 (Triton JIT)** | `cache_sageattention_to_volume` で `/usr/local/lib/python3.11/site-packages/sageattention/` を volume copy しても `.so` が 0 個。一見「コンパイル失敗」に見える | 仕様。sageattention は Triton で runtime JIT、import 時に kernel が compile される。動作確認は CUDA kernel smoke test (`sageattn(q,k,v)` を呼ぶ) で行う |
41
+ | **ComfyUI で sageattention を効かせる** | デフォルトは torch SDPA、sageattention 入っていても自動使用されない | ComfyUI 起動引数に `--use-sage-attention` を追加。`generate_dataset.py` は `import sageattention` 成功時に自動で付与するよう改修済 |
42
+ | **Modal volume get で Japanese 文字パスに download すると後から Bash で見つけられない** | `modal volume get anima-dataset path/ "/c/Users/micro/Downloads/新しいフォルダー/dest/"` で download は成功するが、後続の `cd` / `find` / Python `os.walk` で日本語パスが broken or 空に見える | 一次 download 先は ASCII path に (例: `./verify_v1_local/`)、必要なら後で copy で日本語フォルダに移す |
43
+ | **Modal volume `rm` は subprocess 内では使えない** | training script が訓練中に preview3 を消そうとしてもファイルハンドルが mmap で開いている (PyTorch safetensors loader) | volume cleanup は別の Modal function で実行、training 中は触らない (実害は少ないが orchestration 注意) |
44
+
45
+ ## コスト / 時間の実測値 (本リポジトリ 5 手法移植時)
46
+
47
+ すべて B200 ($6.25/h Modal)、batch=1-4、768-1024 解像度:
48
+
49
+ | 操作 | 時間 | コスト |
50
+ |---|---|---|
51
+ | Image rebuild (lpips 追加分) | ~5 分 (キャッシュ後 0 分) | ~$0.5 |
52
+ | B200 cold start | ~3 分 | ~$0.30 |
53
+ | Smoke test (1 step) | ~5-10 分 | ~$0.5-2 |
54
+ | Z-Image train 2000 step | ~3 h (5.2s/step) | **~$18** (見積もり $42 の半額) |
55
+ | DMD2 train 3000 outer (5 critic + 1 gen) | ~5.8 h (3.6s/step) | **~$19** (見積もり $36 の半額) |
56
+ | precompute_teacher_x0 5000 サンプル | ~1.5 h (0.69/s) | ~$11 |
57
+ | LADD train 5000 step bs=4 accum=4 | ~4.8 h (0.86s/step) | ~$30 |
58
+ | Reflow train 8000 step | ~3-4 h | ~$25 |
59
+ | PCM train 5000 step (1 grad + 3 no_grad) | ~6.5 h (4.7s/step) | ~$41 |
60
+ | **5 手法 + precompute × 2 トータル** | wall-clock ~8 h (並列) | **~$170** |
61
+
62
+ 実測 step 時間は **predicted の概ね 半分** — Modal の B200 は H100 比 2-3x 速いことを織り込まないと過大見積もりになる。
63
+
64
+ **推論側 (verify 段階) の実測 (v1.0 base + sageattention 有効、B200、1024×1024)**:
65
+
66
+ | 条件 | per-image | 内訳 |
67
+ |---|---|---|
68
+ | LoRA 4-step CFG=1 | **3.5-4.0s** | sage 効果あり、ほぼ全 LoRA 同等 (Z-Image / DMD2 / Turbo / Reflow / DRaFT+ 系) |
69
+ | LoRA 8-step CFG=1 | **4.0-4.7s** | sage 効果あり、step 数比例 |
70
+ | base 30-step CFG=4.5 (LoRA strength=0) | **10.7s** | 品質基準ライン |
71
+
72
+ → 蒸留 LoRA 4-step は base 30-step の **3 倍速 = $0.006/枚 vs $0.018/枚** (B200 $6.25/h 換算)。
73
+
74
+ ## トラブルシューティング
75
+
76
+ ### OOM (CUDA out of memory)
77
+ - `phase1_anima.toml` の `activation_checkpointing = true` を確認
78
+ - `[adapter].rank` を 32 まで下げる
79
+ - 解像度を 768 に落とす(`phase1_dataset.toml` の `resolutions`)
80
+ - それでもダメなら H100 80GB に上げる
81
+
82
+ ### 学習が進まない
83
+ - データセット数枚で `epochs = 1` 試走 → エラーログを見る
84
+ - `modal app logs rapid-anima` でリアルタイム監視
85
+ - `llm_adapter_lr = 0` が効いているか(消えていると壊れる)
86
+
87
+ ### 出力の絵が地味
88
+ - これは正常。Anima base は素なので、データセットを `score≥200` 等で
89
+ 絞って色彩豊かな絵を集めると改善
90
+ - artist タグを残して `@favorite_artist1, @favorite_artist2` を
91
+ caption に入れたままにすると、それらの平均が出やすくなる
92
+
93
+ ### diffusion-pipe が image build で失敗
94
+ - `requirements.txt` 内のバージョン競合が原因のことが多い
95
+ - `modal_app.py` の `.pip_install` 部分でバージョン固定済み
96
+ - それでも詰まる時は GitHub Issues 参照: https://github.com/tdrussell/diffusion-pipe/issues
97
+
98
+ ### attention をもっと速くしたい
99
+ デフォルトは torch SDPA(flash-attention 2 が built-in で使われる)。
100
+ さらに 1.2-1.5x 速くしたい場合は、`modal_app.py` の image 定義の最後に追加:
101
+
102
+ ```python
103
+ .run_commands(
104
+ # diffusion-pipe install 後にビルドして torch ABI を合わせる
105
+ "pip install --no-build-isolation sageattention",
106
+ # または flash-attn (source build, 30-40分):
107
+ # "pip install --no-build-isolation flash-attn",
108
+ )
109
+ ```
110
+
111
+ 依存解決の都合で順序が重要(diffusion-pipe より後ろ)。
112
+
113
+ ### `modal volume put` で大きいデータが上がらない
114
+ - `--force` を付けて差分上書き
115
+ - 5GB 超えるなら一旦 tar.gz → put → 中で展開する関数を作る方が安定
116
+
117
+ ### `Secret 'hf_token' not found`
118
+ Modal 上に `hf_token` という名前で secret が無い場合に出る。
119
+ [setup.md](setup.md) の Hugging Face シークレット節を参照して作成、または
120
+ `modal_app.py` の `from_name("hf_token", ...)` を既存 secret 名に書き換え。
121
+
122
+ ### HF upload で `401 Unauthorized`
123
+ `hf_token` secret が read-only token の場合。write 権限が必要 (repo create / upload):
124
+
125
+ ```bash
126
+ modal secret create HF_TOKEN_WRITE HF_TOKEN_WRITE=hf_xxxxx
127
+ ```
128
+
129
+ `modal_app.py::upload_lora_to_hf` が `HF_TOKEN_WRITE` secret を参照する。
130
+ 詳細は [setup.md](setup.md#hf-hub-への-upload-lora-配布など)。
131
+
132
+ ### Modal `workspace billing cycle spend limit reached`
133
+ 新規 app 起動時:
134
+ ```
135
+ App creation failed: workspace billing cycle spend limit reached
136
+ ```
137
+
138
+ **現在進行中の cloud function にも SIGINT が送られて停止する**(KeyboardInterrupt として cloud log に出る、ローカル CLI の Ctrl-C と区別不可)。先に並列起動した training が **両方** とも preempt される事例あり (2026-05-18 DMD2 v1 と DMDX v1 並列起動時)。
139
+
140
+ 対処:
141
+ 1. https://modal.com/settings/billing で **Spend limits** タブを確認
142
+ 2. current cycle の limit を引き上げる、または cycle reset 待ち
143
+ 3. 引き上げ後は新規 app 起動も既存 detach app も復旧
144
+
145
+ 予防:
146
+ - 並列で高負荷 training を起動する前に Modal dashboard で remaining budget 確認
147
+ - DMD2 + DMDX 並列は B200 1 つあたり ~$30 × 2 = $60+ 高速消費するので、limit に余裕が必要
148
+
149
+ ### 並列訓練時の rate 低下
150
+ 本日実測 (2026-05-19、DMD2 resume + DMDX 並列):
151
+ - DMD2 単独: 3.66s/outer
152
+ - DMD2 並列 (with DMDX): **6.2s/outer** (1.7x 遅い)
153
+ - DMDX 単独 (smoke): 3.45s/outer
154
+ - DMDX 並列: 3.6s/outer (slowdown 小さい)
155
+
156
+ 原因推定: Modal が異なる B200 instance を割当てるが、混雑度や近隣 workload で per-step rate に差。DMDX は forward 数が少ない (2 disc + 1 gen = 3 stage vs DMD2 の 5+1=6 stage) ので絶対的に slowdown 耐性が高い。
157
+
158
+ 並列予算は単独の 1.5-2x で見積もる (e.g., DMD2 単独 $30 → 並列時 $50-60)。
docs/pcm.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PCM (Phased Consistency Model) 蒸留
2
+
3
+ [← README に戻る](../README.md) — 関連: [DMD2](dmd2.md) / [DMDX](dmdx.md) / [蒸留全般](distillation.md)
4
+
5
+ ## 概要
6
+
7
+ [Wang et al. NeurIPS 2024](https://arxiv.org/abs/2405.18407)、G-U-N/Phased-Consistency-Model SD3 流派を Anima v1.0 base に移植。**FlowMatch + v-pred** で Anima の rectified flow と math 一致、ε↔v 変換不要。
8
+
9
+ **実装**: `scripts/distill/{pcm_scheduler,train_pcm}.py`
10
+ **Modal**: `modal_app.py::train_pcm_distill`
11
+
12
+ ## 設計の鍵
13
+
14
+ - num_euler_timesteps **N=50** を K=4 phase に均等分割 → 4-step inference に最適化
15
+ - ランダム index sampling → phase 終端まで Euler 1 step 進める
16
+ - pseudo-Huber loss (`c=1e-3`) で stable convergence
17
+ - 1 grad-through + 3 no_grad forward / step → ~60-80 GB on B200 (LoRA-only)
18
+ - CFG-augmentation: `w ∈ [4.0, 5.0]` を訓練に embed → 推論 CFG=1.0 で teacher CFG=4.5 効果
19
+
20
+ ## 訓練設定 (実走、2026-05-18)
21
+
22
+ | 項目 | 値 |
23
+ |---|---|
24
+ | Base | `anima-base-v1.0.safetensors` |
25
+ | Method | Phased Consistency Model |
26
+ | LoRA target | wide (AdaLN + attn + MLP の全 Linear、980 keys) |
27
+ | LoRA rank | 32 |
28
+ | Total steps | 5000 (sample_every=500) |
29
+ | Batch / grad accum | 1 / 4 |
30
+ | Resolution | 768 |
31
+ | Euler N / Phases K | 50 / 4 |
32
+ | sigma_shift | 3.0 |
33
+ | CFG-aug w range | [4.0, 5.0] |
34
+ | Huber c | 1e-3 |
35
+ | LR | 5e-6 |
36
+ | Optimizer | AdamW (wd 0.01) |
37
+ | Warm-start | **無し** (cold-start on v1.0 base) |
38
+ | GPU | B200 |
39
+ | 訓練時間 | **~3.4h** (1.72s/step、想定 6.5h の半分) |
40
+ | コスト | **~$22** (B200 $6.25/h) |
41
+
42
+ ## Loss 推移
43
+
44
+ - 早期 (step 0-100): 0.001-0.14 範囲、warm-start 無しでも安定
45
+ - 中盤 (step 1000-3500): 0.001-0.007 で healthy plateau
46
+ - 後半 (step 4000-5000): 同水準維持、divergence なし
47
+
48
+ ```
49
+ step 20 loss=0.0013 (初期 random spike 後すぐ低下)
50
+ step 130 loss=0.4468 (single spike、shifted sampling の hard spot)
51
+ step 500-4500 loss=0.001-0.007 範囲で平均化
52
+ ```
53
+
54
+ mean collapse・NaN・OOM の発生 **なし**。
55
+
56
+ ## 検証結果 (4-step 生成)
57
+
58
+ 同 prompt × seed=42、Anima v1.0 base、er_sde / simple / sigma_shift=3.0:
59
+
60
+ | ckpt | sampler / scheduler | 時間 | 評価 |
61
+ |---|---|---|---|
62
+ | step 3500 | er_sde + simple | 8.8s | ✅ 2 キャラ正確分離、softer 構図 |
63
+ | step 4000 | res_multistep + beta | 11.2s | ✅ 同等品質、anime tone 強め |
64
+ | final (step 5000) | — | — | HF model card のサンプル参照 |
65
+
66
+ 両 sampler とも 4-step CFG=1.0 で破綻なし。`er_sde + simple` を default 推奨 (Anima 公式準拠)。
67
+
68
+ ## 配布
69
+
70
+ [**HF: darask0/anima-distill-loras/pcm/**](https://huggingface.co/darask0/anima-distill-loras/tree/main/pcm)
71
+
72
+ - `pcm_final_peft.safetensors` (diffusers / peft 用)
73
+ - `pcm_final_comfy.safetensors` (ComfyUI 用、980 keys)
74
+ - README.md (使い方 + 訓練詳細)
75
+ - samples/ (step 3500 / 4000 検証画像)
76
+
77
+ ## ComfyUI 推奨設定
78
+
79
+ ```
80
+ LoraLoaderModelOnly:
81
+ lora_name: pcm_final_comfy.safetensors
82
+ strength_model: 1.0
83
+
84
+ ModelSamplingAuraFlow:
85
+ shift: 3.0
86
+
87
+ KSampler:
88
+ steps: 4
89
+ cfg: 1.0
90
+ sampler_name: er_sde (or res_multistep)
91
+ scheduler: simple (or beta with res_multistep)
92
+ ```
93
+
94
+ ## 知見
95
+
96
+ - **cold-start でも安定** — 他手法 (DMD2/LADD) は warm-start 必須だが PCM は phase consistency が anchor になる
97
+ - **rate 想定より速い** — README 試算 4.7s/step → 実測 1.72s/step (B200 sageattention 効果含む)
98
+ - **single grad-through で memory 余裕** — DMD2 の dual adapter 構成より軽量
docs/setup.md ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 事前準備
2
+
3
+ [← README に戻る](../README.md)
4
+
5
+ ## 1. Modal セットアップ
6
+
7
+ ```bash
8
+ pip install modal
9
+ modal token new
10
+ ```
11
+
12
+ ## 2. Hugging Face シークレット(必須)
13
+
14
+ `modal_app.py` は Modal 上の secret 名 `hf_token`(キー: `HF_TOKEN`)を参照する。
15
+ 既に作成済みであれば追加作業は不要。新規の場合:
16
+
17
+ ```bash
18
+ modal secret create hf_token HF_TOKEN=hf_xxxxxxxxxxxxx
19
+ ```
20
+
21
+ トークンは https://huggingface.co/settings/tokens で発行。Anima は public モデルなので
22
+ レート制限回避のために推奨。secret 名を変えたい場合は `modal_app.py` の
23
+ `from_name("hf_token", ...)` を書き換える。
24
+
25
+ ### HF Hub への upload (LoRA 配布など)
26
+
27
+ `hf_token` は read 専用のことが多い。配布 / repo 作成には別途 write 権限のトークンを
28
+ `HF_TOKEN_WRITE` secret で持たせる:
29
+
30
+ ```bash
31
+ modal secret create HF_TOKEN_WRITE HF_TOKEN_WRITE=hf_xxxxxx_write_xxxxx
32
+ ```
33
+
34
+ `modal_app.py` の `upload_lora_to_hf` がこの secret を参照する。
35
+
36
+ ## 3. データセットの用意
37
+
38
+ Modal に上げる前に手元で:
39
+
40
+ ```
41
+ my_images/
42
+ ├── 0001.png
43
+ ├── 0001.txt # 画像と同名 .txt にタグ/キャプション
44
+ ├── 0002.png
45
+ ├── 0002.txt
46
+ └── ...
47
+ ```
48
+
49
+ タグは Danbooru 風 (", " 区切り) を推奨。WD-EVA02-Tagger v3 などで
50
+ 自動付与してから手で軽くクリーンするのが現実的。**この時点では
51
+ 品質タグ・年タグ・メタタグを書いていても OK**(後で
52
+ `clean_captions.py` が全部抜く)。
53
+
54
+ **推奨枚数**: 3,000〜8,000 枚で十分。
55
+ このプロジェクトの Phase 1 は「**品質タグ依存性を消すだけ**」がゴールであり、
56
+ 審美の方向性は変えない (= base モデルが本来出していた品質を tag なしで再現)。
57
+ そのため:
58
+ - 自分の "好み" でキュレーションする必要はない
59
+ - Danbooru `score≥150` など **品質フィルタだけ** かけてランダムサンプル
60
+ - artist タグも残す (`drop_artist_prob=0` デフォルト)
61
+ - 多様性 > 美的偏向(モデルの汎用性を保つため)
docs/workflow.md ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 実行手順 + GPU + コスト概算
2
+
3
+ [← README に戻る](../README.md) — 前提: [setup.md](setup.md)
4
+
5
+ ## Step 0: モデルダウンロード(初回のみ)
6
+
7
+ ```bash
8
+ cd anima_modal
9
+ modal run modal_app.py::download_models
10
+ ```
11
+
12
+ `anima-preview3-base.safetensors` (4.18GB) + Qwen3 + VAE が
13
+ `anima-models` Volume に保存される。
14
+
15
+ ## Step 1: データセットを用意
16
+
17
+ ### 1a. self-distillation で自動生成(おすすめ、約 11h / $28)
18
+
19
+ Anima base 自身に生成させて quality タグ依存性を消す pair を作る。
20
+ [scripts/gen_prompts.txt](../scripts/gen_prompts.txt) の 100 プロンプト × 50 seed で
21
+ 5,000 枚生成。クライアント側でデータ用意不要。
22
+
23
+ ```bash
24
+ # 試走: ComfyUI 起動 + 10 枚生成 (~10分, $0.10)
25
+ modal run modal_app.py::generate_dataset --max-images 10
26
+
27
+ # 本番(シリアル A100): 5,000 枚 (12.5h, $31)
28
+ modal run --detach modal_app.py::generate_dataset
29
+
30
+ # 本番(B200 × 10 並列): 5,000 枚 (~40分, $44) ← 速度優先
31
+ modal run --detach modal_app.py::generate_dataset_parallel
32
+
33
+ # パラメータ調整可
34
+ modal run --detach modal_app.py::generate_dataset \
35
+ --seeds-per-prompt 80 --max-images 8000
36
+
37
+ # 途中から再開 (既存 .png はスキップ)
38
+ modal run --detach modal_app.py::generate_dataset --start-from 50
39
+ ```
40
+
41
+ ワークフローは [scripts/anima_workflow.json](../scripts/anima_workflow.json)、
42
+ **Anima 公式 README の推奨設定**:
43
+ - Sampler: `er_sde` / Scheduler: `simple` / Steps: 30 / CFG: 4.5
44
+ - ModelSamplingAuraFlow shift: 3.0 (公式 `anima_comparison.json` 由来)
45
+ - Positive prefix: `masterpiece, best quality, score_7, safe`
46
+ - Negative: `worst quality, low quality, score_1, score_2, score_3, artist name`
47
+ - タグ順: `[quality/meta/safety] [1girl/1boy] [character] [series] [artist] [general]`
48
+
49
+ ### 1b. ローカル画像を持ち込み
50
+
51
+ ```bash
52
+ modal volume put anima-dataset ./my_images /raw
53
+ ```
54
+
55
+ `/dataset/raw/` 以下に展開される。
56
+
57
+ ## Step 2: キャプションを掃除
58
+
59
+ このプロジェクトの Phase 1 は審美シフトではないので **artist タグは残す**:
60
+
61
+ ```bash
62
+ modal run modal_app.py::clean_captions
63
+ ```
64
+
65
+ artist タグを drop するオプションは「Anima 平均スタイル化」用で、
66
+ 今回の目的(タグ依存性除去のみ)では基本不要:
67
+
68
+ ```bash
69
+ # 必要なら確率 drop / 完全 drop も可能
70
+ modal run modal_app.py::clean_captions --drop-artist-prob 0.5
71
+ modal run modal_app.py::clean_captions --keep-artist false
72
+ ```
73
+
74
+ `/dataset/cleaned/` に処理後の画像と .txt が出る。中を確認:
75
+
76
+ ```bash
77
+ modal volume get anima-dataset cleaned/0001.txt
78
+ ```
79
+
80
+ `masterpiece, best quality, score_9, year 2025, newest, highres, safe`
81
+ が綺麗に消えていればOK。
82
+
83
+ ## Step 3: Phase 1 学習(quality タグ依存性除去)
84
+
85
+ ```bash
86
+ modal run --detach modal_app.py::train_phase1
87
+ ```
88
+
89
+ `--detach` で**バックグラウンド実行**(数時間続くため)。
90
+ Modal ダッシュボード(modal.com/apps)で進捗確認できる。
91
+
92
+ **目安時間 (A100-80GB)**:
93
+ | データセット | 1 epoch | 2 epoch | コスト |
94
+ |---|---|---|---|
95
+ | 3,000 枚 | 2 h | 4 h | **$10** |
96
+ | 5,000 枚 | 3 h | 6-7 h | **$15-18** |
97
+ | 10,000 枚 | 6-7 h | 12-14 h | **$30-35** |
98
+
99
+ デフォルトは `epochs = 2`(prior shift には十分)。H100 にすれば 1.4-1.6x 速い。
100
+
101
+ **設定変更したい時**: `configs/phase1_anima.toml` を編集して
102
+ `modal run` を再実行(Modal が image を rebuild する)。
103
+
104
+ 主な調整点:
105
+ - `epochs`: 試走 1、デフォルト 2、念のため 3 まで
106
+ (それ以上は base 性能を損なうリスク)
107
+ - `[optimizer].lr`: 1e-5 → 効きが弱ければ 2e-5
108
+ - `[adapter].rank`: 32-64 で十分(概念追加でないので大きくしても無駄)
109
+ - `gradient_accumulation_steps`: 5k 枚以下なら 2 で十分
110
+
111
+ ## Step 4: Phase 1 結果の検証
112
+
113
+ ```bash
114
+ modal run modal_app.py::generate_samples
115
+ ```
116
+
117
+ `scripts/eval_prompts.txt` の短い「品質タグなし」プロンプトで生成して
118
+ `/output/phase1_samples/` に保存。
119
+ ※ 現状は ComfyUI ベースで生成するスケルトンなので、本番では
120
+ ComfyUI を Modal の別 function で立てて API 経由で呼ぶ構成を別途追加することを想定。
121
+
122
+ ## Step 5: Phase 2 蒸留
123
+
124
+ **数万円予算なら 5a (merge) を強く推奨**。5b/5c は数十時間の試行錯誤前提。
125
+
126
+ ### 5a. Turbo LoRA との weight merge (即動く、$0.5、10分)
127
+
128
+ ```bash
129
+ # Civitai の Turbo LoRA URL を指定して 1 ファイルに合成
130
+ modal run modal_app.py::merge_turbo_lora \
131
+ --turbo-url "https://civitai.com/api/download/models/<id>"
132
+
133
+ # alpha 調整 (デフォルトはどちらも 1.0)
134
+ modal run modal_app.py::merge_turbo_lora \
135
+ --turbo-url "..." --alpha-phase1 1.0 --alpha-turbo 0.8
136
+ ```
137
+
138
+ 候補の Turbo LoRA:
139
+ - 公式 Anima Turbo: https://civitai.com/models/2560840 (CFG=1, 8-12 step)
140
+ - RDBT-Anima (DMD2 蒸留先行例): https://civitai.com/models/2364703
141
+
142
+ 出力は `/output/merged/anima_phase1_plus_turbo.safetensors` (LoRA だけの
143
+ ファイル)。ComfyUI で base + この LoRA を組めば即推論可能。
144
+
145
+ ### 5b. SOTA 自前蒸留(Decoupled DMD2 + TSCD + R3GAN、B200 — **未完成、要追加開発**)
146
+
147
+ `scripts/distill/` に実装あり。**ただし 5 回試行して全て失敗**。詳細は
148
+ [distillation.md](distillation.md) を参照。これから取り組む人は、まず
149
+ そのドキュメントの §1.1 / §4 / §5 を読むことを強く推奨。
150
+
151
+ ```bash
152
+ # (動くが収束しない) 我々が試したコマンド:
153
+ modal run --detach modal_app.py::generate_dataset_parallel # データ生成 $44
154
+ modal run --detach modal_app.py::train_sota_distill --phase b --total-steps 2000 # 失敗
155
+ ```
156
+
157
+ > **強い推奨**: 商用要件なしなら 5a で十分。Anima の Turbo LoRA は **CircleStone
158
+ > Labs 公式** が **遥かに多い計算資源と試行錯誤** で作っており、数万円の予算で
159
+ > これを超える自前蒸留は現実的に困難。
160
+
161
+ ## GPU の選び方
162
+
163
+ | GPU | Modal 料金 | 速度 | 推奨用途 |
164
+ |---|---|---|---|
165
+ | A100-40GB | $2.10/hr | 遅 | OOM のリスク。非推奨 |
166
+ | **A100-80GB** | **$2.50/hr** | 標準 | **Phase 1 デフォルト** |
167
+ | L40S 48GB | $1.95/hr | やや遅 | Phase 1 試走に使える |
168
+ | H100 80GB | $3.95/hr | 1.4-1.6x 速 | 時間最優先 / Phase 2 DMD2 |
169
+ | H200 | H100 と同額 | さらに速 | Modal が自動で割り当てる場合あり |
170
+
171
+ `modal_app.py` の `gpu="A100-80GB"` を書き換えるだけで切替可能。
172
+
173
+ ## コスト概算 (1USD ≒ 150円換算)
174
+
175
+ ### プラン A: ミニマム検証 (約 5,000円 / $33)
176
+
177
+ | 項目 | 想定 | コスト |
178
+ |---|---|---|
179
+ | Modal Volume | 10GB × 1ヶ月 | $0.90 |
180
+ | モデル DL (CPU 並列) | 5-10 分 | $0.01 |
181
+ | キャプション掃除 (CPU) | 5 分 | ~$0.00 |
182
+ | Phase 1 学習 (A100-80GB, 2,000枚 × 3 epoch) | 8 時間 | $20 |
183
+ | 検証生成 (L40S) | 10 分 | $0.30 |
184
+ | Phase 2 merge | 10 分 | $0.50 |
185
+ | 余裕枠 (失敗 retry 1 回ぶん) | | ~$10 |
186
+ | **合計** | | **~$32** |
187
+
188
+ ### プラン B: スタンダード (約 1〜1.2万円 / $70-80)
189
+
190
+ | 項目 | 想定 | コスト |
191
+ |---|---|---|
192
+ | Modal Volume | 20GB × 1ヶ月 | $1.80 |
193
+ | **Phase 1 (A100-80GB, 10,000枚 × 3 epoch)** | 18-21 時間 | **$45-53** |
194
+ | 検証 + merge + 試行 retry | | ~$5 |
195
+ | 余裕 | | ~$15 |
196
+ | **合計** | | **~$70** |
197
+
198
+ ### プラン C: 余裕プラン / 蒸留も自前 (約 3〜4万円 / $200-270)
199
+
200
+ | 項目 | 想定 | コスト |
201
+ |---|---|---|
202
+ | Phase 1 (A100-80GB, 10k × 5 epoch) | 30-35 時間 | $75-88 |
203
+ | Phase 2 LCM 自前蒸留 (A100, 試行2回) | 20 時間 | $50 |
204
+ | ↑ DMD2 にする場合 (H100, 試行1回) | 15 時間 | $60 |
205
+ | Volume + 検証 + 余裕 | | ~$30 |
206
+ | **合計** | | **$200-270** |
207
+
208
+ > ⚠️ `--detach` で背景実行中も課金は続く。`modal app stop rapid-anima` で
209
+ > 即停止できるので、想定時間を超えたら必ず確認。`cleanup_checkpoints` で
210
+ > Volume 課金 ($0.15/GB/月) も適時間引き。
modal_app.py ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Modal Image 側でインストール済み。ローカル開発時用。
2
+ modal>=0.66
3
+ huggingface_hub>=0.27
4
+ Pillow>=10.0
samples/dmd2_step500.png ADDED

Git LFS Details

  • SHA256: 276e1e914e8709cde61481d1ff8f5720264f0d55f5bc9ff0920047d5c496b0b9
  • Pointer size: 132 Bytes
  • Size of remote file: 1.5 MB
samples/dmdx_step500.png ADDED

Git LFS Details

  • SHA256: 1bca7b1a6d082fcc8920c498e954cd06dc01bbbeeeddb252d14ef5b5a1f96581
  • Pointer size: 132 Bytes
  • Size of remote file: 1.5 MB
scripts/anima_finetune_dataset_workflow.json ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Fine-tune 用データセット生成 workflow。base=hakushiMixAnima_v02 + LoRA=anima-highres-aesthetic-boost strength 1.0、Anima 公式推奨 30 step CFG=4.5 er_sde simple、ModelSamplingAuraFlow shift=3.0 込み。node ID は scripts/generate_dataset.py の patch_workflow hardcode に合わせて positive=5 latent=7 KSampler=8。",
3
+ "1": {
4
+ "class_type": "UNETLoader",
5
+ "inputs": {
6
+ "unet_name": "hakushiMixAnima_v02.safetensors",
7
+ "weight_dtype": "default"
8
+ }
9
+ },
10
+ "2": {
11
+ "class_type": "ModelSamplingAuraFlow",
12
+ "inputs": {
13
+ "model": ["1", 0],
14
+ "shift": 3.0
15
+ }
16
+ },
17
+ "2b": {
18
+ "class_type": "LoraLoaderModelOnly",
19
+ "inputs": {
20
+ "model": ["2", 0],
21
+ "lora_name": "anima-highres-aesthetic-boost.safetensors",
22
+ "strength_model": 1.0
23
+ }
24
+ },
25
+ "3": {
26
+ "class_type": "CLIPLoader",
27
+ "inputs": {
28
+ "clip_name": "qwen_3_06b_base.safetensors",
29
+ "type": "stable_diffusion",
30
+ "device": "default"
31
+ }
32
+ },
33
+ "4": {
34
+ "class_type": "CLIPTextEncode",
35
+ "inputs": {
36
+ "clip": ["3", 0],
37
+ "text": "worst quality, low quality, score_1, score_2, score_3, score_4, blurry, deformed, bad anatomy, extra limbs, fused fingers, artist name, text, watermark, lowres, jpeg artifacts, censored"
38
+ }
39
+ },
40
+ "5": {
41
+ "class_type": "CLIPTextEncode",
42
+ "inputs": {
43
+ "clip": ["3", 0],
44
+ "text": "$PROMPT"
45
+ }
46
+ },
47
+ "6": {
48
+ "class_type": "VAELoader",
49
+ "inputs": {
50
+ "vae_name": "qwen_image_vae.safetensors"
51
+ }
52
+ },
53
+ "7": {
54
+ "class_type": "EmptyLatentImage",
55
+ "inputs": {
56
+ "width": "$WIDTH",
57
+ "height": "$HEIGHT",
58
+ "batch_size": 1
59
+ }
60
+ },
61
+ "8": {
62
+ "class_type": "KSampler",
63
+ "inputs": {
64
+ "model": ["2b", 0],
65
+ "positive": ["5", 0],
66
+ "negative": ["4", 0],
67
+ "latent_image": ["7", 0],
68
+ "seed": "$SEED",
69
+ "steps": 30,
70
+ "cfg": 4.5,
71
+ "sampler_name": "er_sde",
72
+ "scheduler": "simple",
73
+ "denoise": 1.0
74
+ }
75
+ },
76
+ "9": {
77
+ "class_type": "VAEDecode",
78
+ "inputs": {
79
+ "samples": ["8", 0],
80
+ "vae": ["6", 0]
81
+ }
82
+ },
83
+ "10": {
84
+ "class_type": "SaveImage",
85
+ "inputs": {
86
+ "filename_prefix": "finetune_data",
87
+ "images": ["9", 0]
88
+ }
89
+ }
90
+ }
scripts/anima_verify_workflow.json ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "ユーザー指定 Anima_simple.json + Anima 公式必須の ModelSamplingAuraFlow shift=3.0 + LoraLoaderModelOnly 追加版。node ID は scripts/generate_dataset.py の patch_workflow が hardcode (positive=5, latent=7, sampler=8) するため、それに合わせて配置。LoRA を使わない場合は LoraLoaderModelOnly の strength_model を 0.0 にする。",
3
+ "1": {
4
+ "class_type": "UNETLoader",
5
+ "inputs": {
6
+ "unet_name": "anima-base-v1.0.safetensors",
7
+ "weight_dtype": "default"
8
+ }
9
+ },
10
+ "2": {
11
+ "class_type": "ModelSamplingAuraFlow",
12
+ "inputs": {
13
+ "model": ["1", 0],
14
+ "shift": 3.0
15
+ }
16
+ },
17
+ "2b": {
18
+ "class_type": "LoraLoaderModelOnly",
19
+ "inputs": {
20
+ "model": ["2", 0],
21
+ "lora_name": "anima_turbo.safetensors",
22
+ "strength_model": 1.0
23
+ }
24
+ },
25
+ "3": {
26
+ "class_type": "CLIPLoader",
27
+ "inputs": {
28
+ "clip_name": "qwen_3_06b_base.safetensors",
29
+ "type": "stable_diffusion",
30
+ "device": "default"
31
+ }
32
+ },
33
+ "4": {
34
+ "class_type": "CLIPTextEncode",
35
+ "inputs": {
36
+ "clip": ["3", 0],
37
+ "text": "worst quality, low quality, score_1, score_2, score_3, score_4, blurry, deformed, bad anatomy, extra limbs, fused fingers, artist name, text, watermark, lowres, jpeg artifacts, censored"
38
+ }
39
+ },
40
+ "5": {
41
+ "class_type": "CLIPTextEncode",
42
+ "inputs": {
43
+ "clip": ["3", 0],
44
+ "text": "$PROMPT"
45
+ }
46
+ },
47
+ "6": {
48
+ "class_type": "VAELoader",
49
+ "inputs": {
50
+ "vae_name": "qwen_image_vae.safetensors"
51
+ }
52
+ },
53
+ "7": {
54
+ "class_type": "EmptyLatentImage",
55
+ "inputs": {
56
+ "width": "$WIDTH",
57
+ "height": "$HEIGHT",
58
+ "batch_size": 1
59
+ }
60
+ },
61
+ "8": {
62
+ "class_type": "KSampler",
63
+ "inputs": {
64
+ "model": ["2b", 0],
65
+ "positive": ["5", 0],
66
+ "negative": ["4", 0],
67
+ "latent_image": ["7", 0],
68
+ "seed": "$SEED",
69
+ "steps": 30,
70
+ "cfg": 4.5,
71
+ "sampler_name": "er_sde",
72
+ "scheduler": "simple",
73
+ "denoise": 1.0
74
+ }
75
+ },
76
+ "9": {
77
+ "class_type": "VAEDecode",
78
+ "inputs": {
79
+ "samples": ["8", 0],
80
+ "vae": ["6", 0]
81
+ }
82
+ },
83
+ "10": {
84
+ "class_type": "SaveImage",
85
+ "inputs": {
86
+ "filename_prefix": "anima_verify",
87
+ "images": ["9", 0]
88
+ }
89
+ }
90
+ }
scripts/anima_workflow.json ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Anima ComfyUI workflow (API format). Used by generate_dataset.py. Placeholders: $PROMPT, $WIDTH, $HEIGHT, $SEED are substituted at submit time.",
3
+ "1": {
4
+ "class_type": "UNETLoader",
5
+ "inputs": {
6
+ "unet_name": "anima-base-v1.0.safetensors",
7
+ "weight_dtype": "default"
8
+ }
9
+ },
10
+ "2": {
11
+ "class_type": "ModelSamplingAuraFlow",
12
+ "inputs": {
13
+ "model": ["1", 0],
14
+ "shift": 3.0
15
+ }
16
+ },
17
+ "3": {
18
+ "class_type": "CLIPLoader",
19
+ "inputs": {
20
+ "clip_name": "qwen_3_06b_base.safetensors",
21
+ "type": "stable_diffusion",
22
+ "device": "default"
23
+ }
24
+ },
25
+ "4": {
26
+ "class_type": "VAELoader",
27
+ "inputs": {
28
+ "vae_name": "qwen_image_vae.safetensors"
29
+ }
30
+ },
31
+ "5": {
32
+ "class_type": "CLIPTextEncode",
33
+ "inputs": {
34
+ "text": "$PROMPT",
35
+ "clip": ["3", 0]
36
+ }
37
+ },
38
+ "6": {
39
+ "class_type": "CLIPTextEncode",
40
+ "inputs": {
41
+ "text": "worst quality, low quality, score_1, score_2, score_3, artist name",
42
+ "clip": ["3", 0]
43
+ }
44
+ },
45
+ "7": {
46
+ "class_type": "EmptyLatentImage",
47
+ "inputs": {
48
+ "width": "$WIDTH",
49
+ "height": "$HEIGHT",
50
+ "batch_size": 1
51
+ }
52
+ },
53
+ "8": {
54
+ "class_type": "KSampler",
55
+ "inputs": {
56
+ "model": ["2", 0],
57
+ "positive": ["5", 0],
58
+ "negative": ["6", 0],
59
+ "latent_image": ["7", 0],
60
+ "seed": "$SEED",
61
+ "steps": 30,
62
+ "cfg": 4.5,
63
+ "sampler_name": "er_sde",
64
+ "scheduler": "simple",
65
+ "denoise": 1.0
66
+ }
67
+ },
68
+ "9": {
69
+ "class_type": "VAEDecode",
70
+ "inputs": {
71
+ "samples": ["8", 0],
72
+ "vae": ["4", 0]
73
+ }
74
+ },
75
+ "10": {
76
+ "class_type": "SaveImage",
77
+ "inputs": {
78
+ "images": ["9", 0],
79
+ "filename_prefix": "anima_gen"
80
+ }
81
+ }
82
+ }
scripts/anima_workflow_phase_a.json ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Phase A 蒸留出力 (gen_final.safetensors) を base として使う workflow。LoRA 不要、CFG=1、step 数は --override-steps で切替可。",
3
+ "1": {
4
+ "class_type": "UNETLoader",
5
+ "inputs": {
6
+ "unet_name": "phase_a_distilled.safetensors",
7
+ "weight_dtype": "default"
8
+ }
9
+ },
10
+ "2": {
11
+ "class_type": "ModelSamplingAuraFlow",
12
+ "inputs": {
13
+ "model": ["1", 0],
14
+ "shift": 3.0
15
+ }
16
+ },
17
+ "3": {
18
+ "class_type": "CLIPLoader",
19
+ "inputs": {
20
+ "clip_name": "qwen_3_06b_base.safetensors",
21
+ "type": "stable_diffusion",
22
+ "device": "default"
23
+ }
24
+ },
25
+ "4": {
26
+ "class_type": "VAELoader",
27
+ "inputs": {
28
+ "vae_name": "qwen_image_vae.safetensors"
29
+ }
30
+ },
31
+ "5": {
32
+ "class_type": "CLIPTextEncode",
33
+ "inputs": {
34
+ "text": "$PROMPT",
35
+ "clip": ["3", 0]
36
+ }
37
+ },
38
+ "6": {
39
+ "class_type": "CLIPTextEncode",
40
+ "inputs": {
41
+ "text": "",
42
+ "clip": ["3", 0]
43
+ }
44
+ },
45
+ "7": {
46
+ "class_type": "EmptyLatentImage",
47
+ "inputs": {
48
+ "width": "$WIDTH",
49
+ "height": "$HEIGHT",
50
+ "batch_size": 1
51
+ }
52
+ },
53
+ "8": {
54
+ "class_type": "KSampler",
55
+ "inputs": {
56
+ "model": ["2", 0],
57
+ "positive": ["5", 0],
58
+ "negative": ["6", 0],
59
+ "latent_image": ["7", 0],
60
+ "seed": "$SEED",
61
+ "steps": 8,
62
+ "cfg": 1.0,
63
+ "sampler_name": "er_sde",
64
+ "scheduler": "simple",
65
+ "denoise": 1.0
66
+ }
67
+ },
68
+ "9": {
69
+ "class_type": "VAEDecode",
70
+ "inputs": {
71
+ "samples": ["8", 0],
72
+ "vae": ["4", 0]
73
+ }
74
+ },
75
+ "10": {
76
+ "class_type": "SaveImage",
77
+ "inputs": {
78
+ "images": ["9", 0],
79
+ "filename_prefix": "anima_phase_a"
80
+ }
81
+ }
82
+ }
scripts/anima_workflow_phase_a_step1000.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Phase A step 1000 checkpoint で動作確認用",
3
+ "1": {
4
+ "class_type": "UNETLoader",
5
+ "inputs": {
6
+ "unet_name": "phase_a_step1000.safetensors",
7
+ "weight_dtype": "default"
8
+ }
9
+ },
10
+ "2": {
11
+ "class_type": "ModelSamplingAuraFlow",
12
+ "inputs": {"model": ["1", 0], "shift": 3.0}
13
+ },
14
+ "3": {
15
+ "class_type": "CLIPLoader",
16
+ "inputs": {"clip_name": "qwen_3_06b_base.safetensors", "type": "stable_diffusion", "device": "default"}
17
+ },
18
+ "4": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen_image_vae.safetensors"}},
19
+ "5": {"class_type": "CLIPTextEncode", "inputs": {"text": "$PROMPT", "clip": ["3", 0]}},
20
+ "6": {"class_type": "CLIPTextEncode", "inputs": {"text": "", "clip": ["3", 0]}},
21
+ "7": {"class_type": "EmptyLatentImage", "inputs": {"width": "$WIDTH", "height": "$HEIGHT", "batch_size": 1}},
22
+ "8": {
23
+ "class_type": "KSampler",
24
+ "inputs": {
25
+ "model": ["2", 0], "positive": ["5", 0], "negative": ["6", 0], "latent_image": ["7", 0],
26
+ "seed": "$SEED", "steps": 8, "cfg": 1.0,
27
+ "sampler_name": "er_sde", "scheduler": "simple", "denoise": 1.0
28
+ }
29
+ },
30
+ "9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["4", 0]}},
31
+ "10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": "anima_step1000"}}
32
+ }
scripts/anima_workflow_phase_b.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Phase B 4-step distillation LoRA + Anima base. CFG=1, 4 step (Phase C で 2 step も試す予定)",
3
+ "1": {
4
+ "class_type": "UNETLoader",
5
+ "inputs": {"unet_name": "anima-base-v1.0.safetensors", "weight_dtype": "default"}
6
+ },
7
+ "2": {
8
+ "class_type": "ModelSamplingAuraFlow",
9
+ "inputs": {"model": ["1", 0], "shift": 3.0}
10
+ },
11
+ "2b": {
12
+ "class_type": "LoraLoaderModelOnly",
13
+ "inputs": {
14
+ "model": ["2", 0],
15
+ "lora_name": "phase_b_4step_lora.safetensors",
16
+ "strength_model": 1.0
17
+ }
18
+ },
19
+ "3": {
20
+ "class_type": "CLIPLoader",
21
+ "inputs": {"clip_name": "qwen_3_06b_base.safetensors", "type": "stable_diffusion", "device": "default"}
22
+ },
23
+ "4": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen_image_vae.safetensors"}},
24
+ "5": {"class_type": "CLIPTextEncode", "inputs": {"text": "$PROMPT", "clip": ["3", 0]}},
25
+ "6": {"class_type": "CLIPTextEncode", "inputs": {"text": "", "clip": ["3", 0]}},
26
+ "7": {"class_type": "EmptyLatentImage", "inputs": {"width": "$WIDTH", "height": "$HEIGHT", "batch_size": 1}},
27
+ "8": {
28
+ "class_type": "KSampler",
29
+ "inputs": {
30
+ "model": ["2b", 0], "positive": ["5", 0], "negative": ["6", 0], "latent_image": ["7", 0],
31
+ "seed": "$SEED", "steps": 4, "cfg": 1.0,
32
+ "sampler_name": "er_sde", "scheduler": "simple", "denoise": 1.0
33
+ }
34
+ },
35
+ "9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["4", 0]}},
36
+ "10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": "anima_phase_b"}}
37
+ }
scripts/anima_workflow_turbo.json ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Anima + Turbo LoRA workflow. 8 step, CFG 1 (Civitai 公式推奨)。$PROMPT/$WIDTH/$HEIGHT/$SEED は generate_dataset.py が置換。",
3
+ "1": {
4
+ "class_type": "UNETLoader",
5
+ "inputs": {
6
+ "unet_name": "anima-base-v1.0.safetensors",
7
+ "weight_dtype": "default"
8
+ }
9
+ },
10
+ "2": {
11
+ "class_type": "ModelSamplingAuraFlow",
12
+ "inputs": {
13
+ "model": ["1", 0],
14
+ "shift": 3.0
15
+ }
16
+ },
17
+ "2b": {
18
+ "class_type": "LoraLoaderModelOnly",
19
+ "inputs": {
20
+ "model": ["2", 0],
21
+ "lora_name": "anima_turbo.safetensors",
22
+ "strength_model": 1.0
23
+ }
24
+ },
25
+ "3": {
26
+ "class_type": "CLIPLoader",
27
+ "inputs": {
28
+ "clip_name": "qwen_3_06b_base.safetensors",
29
+ "type": "stable_diffusion",
30
+ "device": "default"
31
+ }
32
+ },
33
+ "4": {
34
+ "class_type": "VAELoader",
35
+ "inputs": {
36
+ "vae_name": "qwen_image_vae.safetensors"
37
+ }
38
+ },
39
+ "5": {
40
+ "class_type": "CLIPTextEncode",
41
+ "inputs": {
42
+ "text": "$PROMPT",
43
+ "clip": ["3", 0]
44
+ }
45
+ },
46
+ "6": {
47
+ "class_type": "CLIPTextEncode",
48
+ "inputs": {
49
+ "text": "",
50
+ "clip": ["3", 0]
51
+ }
52
+ },
53
+ "7": {
54
+ "class_type": "EmptyLatentImage",
55
+ "inputs": {
56
+ "width": "$WIDTH",
57
+ "height": "$HEIGHT",
58
+ "batch_size": 1
59
+ }
60
+ },
61
+ "8": {
62
+ "class_type": "KSampler",
63
+ "inputs": {
64
+ "model": ["2b", 0],
65
+ "positive": ["5", 0],
66
+ "negative": ["6", 0],
67
+ "latent_image": ["7", 0],
68
+ "seed": "$SEED",
69
+ "steps": 8,
70
+ "cfg": 1.0,
71
+ "sampler_name": "er_sde",
72
+ "scheduler": "simple",
73
+ "denoise": 1.0
74
+ }
75
+ },
76
+ "9": {
77
+ "class_type": "VAEDecode",
78
+ "inputs": {
79
+ "samples": ["8", 0],
80
+ "vae": ["4", 0]
81
+ }
82
+ },
83
+ "10": {
84
+ "class_type": "SaveImage",
85
+ "inputs": {
86
+ "images": ["9", 0],
87
+ "filename_prefix": "anima_turbo"
88
+ }
89
+ }
90
+ }
scripts/clean_captions.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ キャプション掃除スクリプト
4
+ ==========================
5
+
6
+ Anima 公式が推奨する以下のタグを ".txt" キャプションから除去:
7
+ - Quality (Human): masterpiece, best quality, good quality, normal quality, low quality, worst quality
8
+ - Quality (PonyV7 score): score_9, score_8, ..., score_1
9
+ - Time period: year 2025, year 2024, ..., newest, recent, mid, early, old
10
+ - Meta: highres, absurdres, anime screenshot, jpeg artifacts, official art,
11
+ lowres, blurry, watermark, signature, web address, artist name,
12
+ censored, uncensored 等
13
+
14
+ これらをデータから抜くことで、学習後のモデルは「タグなし = 高品質」を学ぶ。
15
+
16
+ 使い方:
17
+ python clean_captions.py --input ./raw --output ./cleaned \
18
+ [--drop-artist-prob 0.0] [--drop-all-artists]
19
+ """
20
+ import argparse
21
+ import re
22
+ import shutil
23
+ import random
24
+ from pathlib import Path
25
+
26
+
27
+ # 削除対象タグ (大文字小文字無視、前後空白許容)
28
+ QUALITY_TAGS_HUMAN = {
29
+ "masterpiece", "best quality", "good quality",
30
+ "normal quality", "low quality", "worst quality",
31
+ }
32
+
33
+ QUALITY_TAGS_SCORE = {f"score_{i}" for i in range(1, 10)}
34
+
35
+ # 公式 README より: 期間タグ
36
+ PERIOD_TAGS = {"newest", "recent", "mid", "early", "old"}
37
+
38
+ # Year タグは正規表現で吸収
39
+ YEAR_RE = re.compile(r"^year\s+(19|20)\d{2}$", re.IGNORECASE)
40
+
41
+ META_TAGS = {
42
+ "highres", "absurdres", "lowres",
43
+ "anime screenshot", "official art",
44
+ "jpeg artifacts", "blurry", "bad quality",
45
+ "watermark", "signature", "web address", "twitter username",
46
+ "artist name", "logo",
47
+ "censored", "uncensored", "mosaic censoring", "bar censor",
48
+ "safe", "sensitive", "nsfw", "explicit", # safety タグも除去 (要なら別フラグに)
49
+ }
50
+
51
+ FIXED_DROP = (
52
+ {t.lower() for t in QUALITY_TAGS_HUMAN}
53
+ | QUALITY_TAGS_SCORE
54
+ | PERIOD_TAGS
55
+ | META_TAGS
56
+ )
57
+
58
+
59
+ def should_drop(tag: str) -> bool:
60
+ t = tag.strip().lower()
61
+ if not t:
62
+ return True
63
+ if t in FIXED_DROP:
64
+ return True
65
+ if YEAR_RE.match(t):
66
+ return True
67
+ return False
68
+
69
+
70
+ def clean_caption(
71
+ text: str,
72
+ drop_artist_prob: float = 0.0,
73
+ drop_all_artists: bool = False,
74
+ rng: random.Random | None = None,
75
+ ) -> str:
76
+ rng = rng or random.Random()
77
+ # ", " 区切りを基本に、Danbooru の "_" 区切りも空白へ統一されている前提
78
+ parts = [p.strip() for p in text.split(",")]
79
+ out = []
80
+ for p in parts:
81
+ if should_drop(p):
82
+ continue
83
+ # artist タグは "@xxx" 形式 (Anima 仕様)
84
+ if p.startswith("@"):
85
+ if drop_all_artists:
86
+ continue
87
+ if drop_artist_prob > 0 and rng.random() < drop_artist_prob:
88
+ continue
89
+ out.append(p)
90
+ return ", ".join(out)
91
+
92
+
93
+ def main():
94
+ ap = argparse.ArgumentParser()
95
+ ap.add_argument("--input", required=True, type=Path)
96
+ ap.add_argument("--output", required=True, type=Path)
97
+ ap.add_argument(
98
+ "--drop-artist-prob",
99
+ type=float,
100
+ default=0.0,
101
+ help="artist タグ(@xxx)をランダムに drop する確率 (0.0〜1.0)",
102
+ )
103
+ ap.add_argument(
104
+ "--drop-all-artists",
105
+ action="store_true",
106
+ help="artist タグを全部除去 (Anima 平均スタイル化)",
107
+ )
108
+ ap.add_argument(
109
+ "--exts",
110
+ default="png,jpg,jpeg,webp",
111
+ help="画像拡張子(カンマ区切り)",
112
+ )
113
+ args = ap.parse_args()
114
+
115
+ args.output.mkdir(parents=True, exist_ok=True)
116
+ exts = tuple("." + e.lower() for e in args.exts.split(","))
117
+ rng = random.Random(42)
118
+
119
+ n_img, n_cap, n_missing = 0, 0, 0
120
+ for img in args.input.rglob("*"):
121
+ if not img.is_file() or not img.suffix.lower() in exts:
122
+ continue
123
+ n_img += 1
124
+ rel = img.relative_to(args.input)
125
+ out_img = args.output / rel
126
+ out_img.parent.mkdir(parents=True, exist_ok=True)
127
+ if not out_img.exists():
128
+ shutil.copy(img, out_img)
129
+
130
+ cap_in = img.with_suffix(".txt")
131
+ cap_out = out_img.with_suffix(".txt")
132
+ if not cap_in.exists():
133
+ n_missing += 1
134
+ cap_out.write_text("", encoding="utf-8")
135
+ continue
136
+
137
+ text = cap_in.read_text(encoding="utf-8", errors="ignore")
138
+ cleaned = clean_caption(
139
+ text,
140
+ drop_artist_prob=args.drop_artist_prob,
141
+ drop_all_artists=args.drop_all_artists,
142
+ rng=rng,
143
+ )
144
+ cap_out.write_text(cleaned, encoding="utf-8")
145
+ n_cap += 1
146
+
147
+ print(f"[clean_captions] images={n_img} captions={n_cap} missing_txt={n_missing}")
148
+ print(f"[clean_captions] output -> {args.output}")
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
scripts/compare_prompts.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Phase A 完了後の比較用 5 prompt (sample_prompts.txt から抜粋、絶対 index は本 file 内 0..4)
2
+ 1boy, short blonde hair, green eyes, knight armor, castle hallway, holding shield and sword, torchlight, noble pose
3
+ 2girls, friends, one with long brown hair other with short blonde, casual clothes, shopping together, mall interior, laughing, holding shopping bags
4
+ a fluffy white cat, curled up on windowsill, sunlight, lace curtains, peaceful sleep, soft focus background
5
+ fantasy landscape, floating islands with waterfalls, sky bridges, sunset, magical atmosphere, distant mountains
6
+ magical spellcasting scene, glowing rune circle, swirling energy, dramatic shadows, fantasy atmosphere, ethereal
scripts/distill/__init__.py ADDED
File without changes
scripts/distill/anima_ladd_disc.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anima LADD discriminator (AMD Nitro-1 流派、PixArt → Anima 移植)
3
+
4
+ 設計の根拠 (R3GAN 失敗との対比):
5
+ - R3GAN は CNN-on-latent を一から学習 → Anima 16ch latent の prior を持たず崩壊
6
+ - LADD は teacher の frozen MiniTrainDIT を **feature extractor** として再利用
7
+ teacher は Anima latent を理解済 → head は判別だけ学習すればよい
8
+ - 加えて spectral norm + BatchNormLocal で D 自体の安定性も確保
9
+
10
+ Architecture:
11
+ - D backbone = teacher MiniTrainDIT (frozen) を deepcopy
12
+ - forward 時に N 個の block 出力を hook で集める (multi-scale)
13
+ - 各 hook 出力に DiscHead を 1 個ずつ通して logits を得る
14
+ - 最終: list[Tensor] of logits を返し、loss 側で BCE
15
+ """
16
+ from __future__ import annotations
17
+ from typing import List, Sequence
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+ from torch.nn.utils import spectral_norm
23
+
24
+ from .anima_loader import AnimaBundle
25
+
26
+
27
+ # ----- BatchNormLocal -------------------------------------------------------
28
+ class BatchNormLocal(nn.Module):
29
+ """各 sample 内で per-channel normalize。BatchNorm のような分布依存性なし。
30
+ AMD Nitro-1 で D の安定化に効いた知見。"""
31
+ def __init__(self, channels: int, eps: float = 1e-5):
32
+ super().__init__()
33
+ self.eps = eps
34
+ self.weight = nn.Parameter(torch.ones(channels))
35
+ self.bias = nn.Parameter(torch.zeros(channels))
36
+
37
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
38
+ # x: (B, C, L) or (B, C)
39
+ if x.dim() == 2:
40
+ mean = x.mean(dim=1, keepdim=True)
41
+ var = x.var(dim=1, keepdim=True, unbiased=False)
42
+ else:
43
+ mean = x.mean(dim=2, keepdim=True)
44
+ var = x.var(dim=2, keepdim=True, unbiased=False)
45
+ x = (x - mean) / torch.sqrt(var + self.eps)
46
+ if x.dim() == 2:
47
+ return x * self.weight + self.bias
48
+ return x * self.weight.view(1, -1, 1) + self.bias.view(1, -1, 1)
49
+
50
+
51
+ # ----- DiscHead -------------------------------------------------------------
52
+ class DiscHead(nn.Module):
53
+ """1 つの hooked feature を scalar logit に。
54
+ SpectralConv1d → BNL → LeakyReLU → residual block → Linear projection
55
+ """
56
+ def __init__(self, in_dim: int, hidden: int = 512):
57
+ super().__init__()
58
+ # in: (B, L, D) → permute → (B, D, L)
59
+ self.conv1 = spectral_norm(nn.Conv1d(in_dim, hidden, kernel_size=1))
60
+ self.bnl1 = BatchNormLocal(hidden)
61
+ self.act1 = nn.LeakyReLU(0.2, inplace=True)
62
+
63
+ self.conv2 = spectral_norm(nn.Conv1d(hidden, hidden, kernel_size=9, padding=4, padding_mode="circular"))
64
+ self.bnl2 = BatchNormLocal(hidden)
65
+ self.act2 = nn.LeakyReLU(0.2, inplace=True)
66
+
67
+ self.proj = spectral_norm(nn.Conv1d(hidden, 1, kernel_size=1))
68
+
69
+ def forward(self, feat: torch.Tensor) -> torch.Tensor:
70
+ """
71
+ feat: (B, L, D) ※L = num tokens、D = channel dim
72
+ return: (B,) per-sample logit (mean over L)
73
+ """
74
+ x = feat.transpose(1, 2) # (B, D, L)
75
+ h = self.act1(self.bnl1(self.conv1(x)))
76
+ h = h + self.act2(self.bnl2(self.conv2(h)))
77
+ logits = self.proj(h) # (B, 1, L)
78
+ return logits.mean(dim=2).squeeze(1) # (B,)
79
+
80
+
81
+ # ----- AnimaLADDDiscriminator ----------------------------------------------
82
+ class AnimaLADDDiscriminator(nn.Module):
83
+ """teacher MiniTrainDIT を frozen で抱え、forward 時に N ブロックの出力を
84
+ hook で集めて N 個の DiscHead に通す。head の出力 (logits) を list で返す。
85
+
86
+ teacher が Anima latent (16ch × 128² → 1024 token) を理解しているので、
87
+ head は判別だけを学習。スクラッチ CNN より遥かに stable。
88
+ """
89
+ def __init__(
90
+ self,
91
+ teacher_transformer: nn.Module,
92
+ block_ids: Sequence[int] = (2, 8, 14, 20, 26),
93
+ head_hidden: int = 512,
94
+ ):
95
+ super().__init__()
96
+ # teacher 本体は frozen で保持 (forward に使う)
97
+ # 重要: deepcopy はしない。teacher_transformer はすでに caller 側で
98
+ # frozen な状態で与えられる前提 (gen の teacher と共有 OK、forward は no_grad)
99
+ self.teacher = teacher_transformer
100
+ for p in self.teacher.parameters():
101
+ p.requires_grad = False
102
+
103
+ # block_ids が teacher の block 数を超えていないか
104
+ blocks = self._find_blocks()
105
+ n_blocks = len(blocks)
106
+ self.block_ids = [bid for bid in block_ids if bid < n_blocks]
107
+ print(f"[ladd-D] teacher has {n_blocks} blocks; hooking {self.block_ids}")
108
+
109
+ # 各 hook 出力の channel 数を 1 度 dummy forward で取って head を作成
110
+ self._hook_outputs: dict[int, torch.Tensor] = {}
111
+ self._handles = []
112
+ for bid in self.block_ids:
113
+ h = blocks[bid].register_forward_hook(self._make_hook(bid))
114
+ self._handles.append(h)
115
+
116
+ # heads は dummy forward 後に lazy init
117
+ self.heads = nn.ModuleDict()
118
+ self.head_hidden = head_hidden
119
+
120
+ @torch.no_grad()
121
+ def lazy_init_heads(self, sample_x: torch.Tensor, sample_t: torch.Tensor, sample_cond: torch.Tensor):
122
+ """heads を 1 度 dummy forward して channel 数を確定させてから build。"""
123
+ self._hook_outputs.clear()
124
+ _ = self._teacher_forward(sample_x, sample_t, sample_cond)
125
+ # 各 hook の出力 shape を見て head を作る
126
+ for bid in self.block_ids:
127
+ out = self._hook_outputs[bid]
128
+ # out: (B, L, D) を想定 (MiniTrainDIT の block 出力)
129
+ if out.dim() == 4: # (B, T, L, D) など video 系
130
+ out = out.flatten(1, -2) # (B, T*L, D)
131
+ B, L, D = out.shape
132
+ print(f"[ladd-D] block {bid} feature shape: B={B} L={L} D={D}")
133
+ head = DiscHead(in_dim=D, hidden=self.head_hidden)
134
+ self.heads[str(bid)] = head
135
+ self._hook_outputs.clear()
136
+
137
+ def _find_blocks(self) -> list[nn.Module]:
138
+ """MiniTrainDIT 内部の **DiT 本体 block list** を見つける。
139
+ Anima/Cosmos の DiT blocks は ~28 個、llm_adapter.blocks は 6 個程度なので
140
+ 区別する必要がある。優先順位:
141
+ 1. transformer.blocks (top-level、direct attribute) を最優先
142
+ 2. それ以外で、llm_adapter を含まない blocks ModuleList で最大のもの
143
+ """
144
+ # 1) 最優先: top-level の `.blocks`
145
+ if hasattr(self.teacher, "blocks") and isinstance(self.teacher.blocks, nn.ModuleList):
146
+ n = len(self.teacher.blocks)
147
+ print(f"[ladd-D] found top-level teacher.blocks ({n} blocks)")
148
+ return list(self.teacher.blocks)
149
+
150
+ # 2) llm_adapter を含まない候補から最大のものを採用
151
+ candidates: list[tuple[str, list[nn.Module]]] = []
152
+ for name, module in self.teacher.named_modules():
153
+ if not isinstance(module, nn.ModuleList):
154
+ continue
155
+ if "block" not in name.lower():
156
+ continue
157
+ if "llm_adapter" in name:
158
+ continue # text encoder の bridge ブロックを除外
159
+ if len(module) >= 4:
160
+ candidates.append((name, list(module)))
161
+
162
+ if not candidates:
163
+ raise RuntimeError(
164
+ "Could not find DiT blocks ModuleList. Inspected teacher module tree but "
165
+ "found no non-llm_adapter `blocks`-named list with >=4 children."
166
+ )
167
+ # 最大数の block list を採用 (DiT 本体 28+ blocks > llm_adapter の 6 blocks)
168
+ candidates.sort(key=lambda x: -len(x[1]))
169
+ name, children = candidates[0]
170
+ print(f"[ladd-D] selected block list at: {name} ({len(children)} blocks)")
171
+ return children
172
+
173
+ def _make_hook(self, bid: int):
174
+ def _h(module, inputs, output):
175
+ # output の正規化: tuple なら最初の要素、5D なら 3D に flatten
176
+ if isinstance(output, tuple):
177
+ out = output[0]
178
+ else:
179
+ out = output
180
+ if out.dim() == 5:
181
+ # (B, T, H, W, D) → (B, T*H*W, D)
182
+ B = out.size(0)
183
+ D = out.size(-1)
184
+ out = out.reshape(B, -1, D)
185
+ elif out.dim() == 4:
186
+ # (B, H, W, D) → (B, H*W, D)
187
+ B = out.size(0)
188
+ D = out.size(-1)
189
+ out = out.reshape(B, -1, D)
190
+ self._hook_outputs[bid] = out
191
+ return _h
192
+
193
+ def _teacher_forward(self, x: torch.Tensor, t: torch.Tensor, cond: torch.Tensor):
194
+ """teacher を forward して hook を発火させる。返り値は使わない (hook が中身)。"""
195
+ return AnimaBundle.dit_forward(self.teacher, x, t, cond)
196
+
197
+ def forward(
198
+ self, x: torch.Tensor, t: torch.Tensor, cond: torch.Tensor,
199
+ gradient_to_input: bool = False,
200
+ ) -> List[torch.Tensor]:
201
+ """各 head の logit を list で返す。
202
+
203
+ gradient_to_input=True (G phase で adv loss が student を訓練するために必要):
204
+ teacher は frozen だが、forward を grad-on で走らせ activation を残す。
205
+ そうすると logits の grad が teacher backbone を通って x → student LoRA に届く。
206
+ gradient_to_input=False (D phase: heads だけ訓練、x は固定):
207
+ teacher を no_grad で走らせ、features を detach (省メモリ + 安全)。
208
+ """
209
+ self._hook_outputs.clear()
210
+ if gradient_to_input:
211
+ # teacher params は frozen なので weights は更新されないが、
212
+ # activations が残り gradient は input → student に流れる
213
+ _ = self._teacher_forward(x, t, cond)
214
+ else:
215
+ with torch.no_grad():
216
+ _ = self._teacher_forward(x, t, cond)
217
+ logits = []
218
+ for bid in self.block_ids:
219
+ feat = self._hook_outputs[bid]
220
+ if not gradient_to_input:
221
+ feat = feat.detach().requires_grad_(False)
222
+ # feat の dtype を head と揃える (head は fp32、bf16 混在を避ける)
223
+ head = self.heads[str(bid)]
224
+ head_dtype = next(head.parameters()).dtype
225
+ logits.append(head(feat.to(dtype=head_dtype)))
226
+ self._hook_outputs.clear()
227
+ return logits
228
+
229
+ def trainable_parameters(self):
230
+ return list(self.heads.parameters())
231
+
232
+
233
+ # ----- losses ---------------------------------------------------------------
234
+ def ladd_d_loss(
235
+ logits_real: List[torch.Tensor], logits_fake: List[torch.Tensor],
236
+ ) -> tuple[torch.Tensor, dict]:
237
+ """BCE on each head, sum across heads."""
238
+ loss_real, loss_fake = 0.0, 0.0
239
+ for lr in logits_real:
240
+ loss_real = loss_real + F.binary_cross_entropy_with_logits(
241
+ lr, torch.ones_like(lr)
242
+ )
243
+ for lf in logits_fake:
244
+ loss_fake = loss_fake + F.binary_cross_entropy_with_logits(
245
+ lf, torch.zeros_like(lf)
246
+ )
247
+ loss = (loss_real + loss_fake) / max(len(logits_real), 1)
248
+ return loss, {
249
+ "l_d_real": loss_real.detach() / max(len(logits_real), 1),
250
+ "l_d_fake": loss_fake.detach() / max(len(logits_fake), 1),
251
+ "l_d_total": loss.detach(),
252
+ }
253
+
254
+
255
+ def ladd_g_adv_loss(logits_fake: List[torch.Tensor]) -> tuple[torch.Tensor, dict]:
256
+ """non-saturating: BCE with label=1 (D に real と思わせる)"""
257
+ loss = 0.0
258
+ for lf in logits_fake:
259
+ loss = loss + F.binary_cross_entropy_with_logits(
260
+ lf, torch.ones_like(lf)
261
+ )
262
+ loss = loss / max(len(logits_fake), 1)
263
+ return loss, {"l_g_adv": loss.detach()}
264
+
265
+
266
+ def ladd_g_recon_loss(
267
+ x0_hat: torch.Tensor, x0_teacher: torch.Tensor,
268
+ ) -> tuple[torch.Tensor, dict]:
269
+ """smooth L1 = Huber loss、mean collapse 防止の anchor。"""
270
+ loss = F.smooth_l1_loss(x0_hat.float(), x0_teacher.detach().float())
271
+ return loss, {"l_g_recon": loss.detach()}
scripts/distill/anima_loader.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anima loader: diffusion-pipe の cosmos_predict2 Pipeline を活用して
3
+ DiT / VAE / Qwen3 text encoder / LLM adapter をロードする薄いラッパ。
4
+
5
+ diffusion-pipe 本体には sampling/inference 関数が無いので、ここで
6
+ - text_encode(): prompts -> crossattn_emb (LLM adapter 通過後)
7
+ - vae_encode(): pixels -> latents
8
+ - vae_decode(): latents -> pixels
9
+ - velocity(): DiT forward (rectified flow velocity prediction)
10
+ - add_noise(): noisy = (1-t)*latents + t*noise (rectified flow forward)
11
+ - euler_step(): inference 用 1-step 前進
12
+ までを実装する。
13
+ """
14
+ from __future__ import annotations
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+ from dataclasses import dataclass
19
+
20
+ import torch
21
+ import torch.nn.functional as F
22
+
23
+
24
+ # diffusion-pipe を import path に強制配置。
25
+ # 重要: site-packages に top-level `utils` を持つ別パッケージがある場合、
26
+ # それが namespace package 化して shadow するため、(1) /workspace/diffusion-pipe を
27
+ # 先頭に固定 (2) cached `utils` モジュールを sys.modules から除外 する。
28
+ _DPIPE = Path("/workspace/diffusion-pipe")
29
+ if _DPIPE.exists():
30
+ _dp = str(_DPIPE)
31
+ sys.path = [_dp] + [p for p in sys.path if p != _dp]
32
+ # 既にロード済みの top-level utils / models をクリア(他パッケージの shadow を排除)
33
+ for _mod in list(sys.modules.keys()):
34
+ if _mod == "utils" or _mod.startswith("utils.") \
35
+ or _mod == "models" or _mod.startswith("models."):
36
+ del sys.modules[_mod]
37
+
38
+
39
+ @dataclass
40
+ class AnimaPaths:
41
+ transformer: str = "/models/checkpoints/anima-base-v1.0.safetensors"
42
+ vae: str = "/models/checkpoints/qwen_image_vae.safetensors"
43
+ llm: str = "/models/checkpoints/qwen_3_06b_base.safetensors"
44
+
45
+
46
+ class AnimaBundle:
47
+ """Anima のモデル一式を保持。.transformer / .vae / .text_encoder / .tokenizer 等を直接触る。"""
48
+
49
+ def __init__(self, pipeline, device: str | torch.device = "cuda"):
50
+ self.pipeline = pipeline # diffusion-pipe の CosmosPredict2Pipeline
51
+ self.device = torch.device(device)
52
+
53
+ self.transformer = pipeline.transformer
54
+ self.vae = pipeline.vae
55
+ self.text_encoder = pipeline.text_encoder # Qwen3Model (inner)
56
+ self.qwen_tokenizer = pipeline.tokenizer
57
+ # LLM adapter は transformer 内部にある (Anima のみ)
58
+ self.llm_adapter = getattr(pipeline.transformer, "llm_adapter", None)
59
+ self.t5_tokenizer = getattr(pipeline, "t5_tokenizer", None)
60
+ self.is_generic_llm = getattr(pipeline, "is_generic_llm", False)
61
+ self.vae_scale = pipeline.vae.scale
62
+
63
+ # ---- text encoding (Qwen3 → LLM adapter) ------------------------------
64
+ @torch.no_grad()
65
+ def text_encode(self, prompts: list[str]) -> torch.Tensor:
66
+ """prompts -> crossattn_emb (B, 512, 1024) (DiT 用、LLM adapter 通過後)"""
67
+ # diffusion-pipe の _tokenize を再現
68
+ def _tok(tokenizer, prompts):
69
+ return tokenizer(prompts, return_tensors="pt", truncation=True,
70
+ padding="max_length", max_length=512)
71
+
72
+ qwen_enc = _tok(self.qwen_tokenizer, prompts)
73
+ input_ids = qwen_enc.input_ids.to(self.device)
74
+ attn_mask = qwen_enc.attention_mask.to(self.device)
75
+
76
+ outputs = self.text_encoder(input_ids=input_ids, attention_mask=attn_mask)
77
+ encoded = outputs.last_hidden_state
78
+ encoded = encoded.masked_fill(~attn_mask.bool().unsqueeze(-1), 0.0)
79
+
80
+ if self.llm_adapter is None or self.t5_tokenizer is None:
81
+ return encoded
82
+
83
+ t5_enc = _tok(self.t5_tokenizer, prompts)
84
+ t5_ids = t5_enc.input_ids.to(self.device)
85
+ t5_mask = t5_enc.attention_mask.to(self.device)
86
+
87
+ crossattn = self.llm_adapter(
88
+ source_hidden_states=encoded,
89
+ target_input_ids=t5_ids,
90
+ target_attention_mask=t5_mask,
91
+ source_attention_mask=attn_mask,
92
+ )
93
+ crossattn = crossattn.masked_fill(~t5_mask.bool().unsqueeze(-1), 0.0)
94
+ return crossattn
95
+
96
+ # ---- VAE --------------------------------------------------------------
97
+ @torch.no_grad()
98
+ def vae_encode(self, pixels: torch.Tensor) -> torch.Tensor:
99
+ """pixels (B, 3, H, W) in [-1, 1] -> latents (B, 16, 1, H/8, W/8)
100
+ ※ Anima は静止画でも T=1 の 3D latent を扱う"""
101
+ if pixels.dim() == 4:
102
+ pixels = pixels.unsqueeze(2) # (B, 3, 1, H, W)
103
+ # VAE の weights dtype に合わせる
104
+ vae_dtype = next(self.vae.model.parameters()).dtype
105
+ pixels = pixels.to(device=self.device, dtype=vae_dtype)
106
+ return self.vae.model.encode(pixels, self.vae_scale)
107
+
108
+ @torch.no_grad()
109
+ def vae_decode(self, latents: torch.Tensor) -> torch.Tensor:
110
+ """latents (B, 16, 1, H_lat, W_lat) -> pixels (B, 3, 1, H, W) in [-1, 1]"""
111
+ vae_dtype = next(self.vae.model.parameters()).dtype
112
+ latents = latents.to(device=self.device, dtype=vae_dtype)
113
+ return self.vae.model.decode(latents, self.vae_scale)
114
+
115
+ # ---- DiT (rectified flow velocity prediction) -------------------------
116
+ def velocity(
117
+ self,
118
+ latents: torch.Tensor, # (B, 16, T, H_lat, W_lat) - noisy
119
+ timesteps: torch.Tensor, # (B,) in [0, 1]
120
+ crossattn_emb: torch.Tensor, # (B, 512, 1024)
121
+ padding_mask: torch.Tensor | None = None,
122
+ ) -> torch.Tensor:
123
+ """Anima DiT forward. 返り値は velocity (B, 16, T, H_lat, W_lat)。"""
124
+ if padding_mask is None:
125
+ padding_mask = self.zero_padding_mask(latents)
126
+ return self.transformer(
127
+ x_B_C_T_H_W=latents,
128
+ timesteps_B_T=timesteps,
129
+ crossattn_emb=crossattn_emb,
130
+ padding_mask=padding_mask,
131
+ )
132
+
133
+ @staticmethod
134
+ def zero_padding_mask(latents: torch.Tensor) -> torch.Tensor:
135
+ """MiniTrainDIT.concat_padding_mask=True なので必須。shape=(B, 1, H_lat, W_lat)。"""
136
+ B, _, _, H, W = latents.shape
137
+ return torch.zeros(B, 1, H, W, dtype=latents.dtype, device=latents.device)
138
+
139
+ @staticmethod
140
+ def dit_forward(
141
+ transformer: "torch.nn.Module",
142
+ latents: torch.Tensor,
143
+ t: torch.Tensor,
144
+ crossattn_emb: torch.Tensor,
145
+ ) -> torch.Tensor:
146
+ """trainer から任意の transformer (gen / guidance) を呼ぶための薄い wrapper。
147
+ 全入力を transformer の weights dtype に揃え、padding_mask も自動生成。"""
148
+ # weight dtype に揃える (t は float なので broadcast で他がアップキャストされやすい)
149
+ w_dtype = next(transformer.parameters()).dtype
150
+ latents = latents.to(dtype=w_dtype)
151
+ t = t.to(dtype=w_dtype)
152
+ crossattn_emb = crossattn_emb.to(dtype=w_dtype)
153
+ padding_mask = AnimaBundle.zero_padding_mask(latents)
154
+ return transformer(
155
+ x_B_C_T_H_W=latents,
156
+ timesteps_B_T=t,
157
+ crossattn_emb=crossattn_emb,
158
+ padding_mask=padding_mask,
159
+ )
160
+
161
+ # ---- rectified-flow utilities ----------------------------------------
162
+ @staticmethod
163
+ def add_noise(
164
+ latents: torch.Tensor, noise: torch.Tensor, t: torch.Tensor
165
+ ) -> torch.Tensor:
166
+ """forward process: x_t = (1-t)*x_0 + t*noise"""
167
+ t_ = t.view(-1, *([1] * (latents.dim() - 1)))
168
+ return (1 - t_) * latents + t_ * noise
169
+
170
+ @staticmethod
171
+ def velocity_target(latents: torch.Tensor, noise: torch.Tensor) -> torch.Tensor:
172
+ """target velocity = noise - latents (rectified flow の training target)"""
173
+ return noise - latents
174
+
175
+ @staticmethod
176
+ def x0_from_velocity(
177
+ latents_t: torch.Tensor, v_pred: torch.Tensor, t: torch.Tensor
178
+ ) -> torch.Tensor:
179
+ """x_0 estimate = x_t - t * v_pred (linear FM のため)"""
180
+ t_ = t.view(-1, *([1] * (latents_t.dim() - 1)))
181
+ return latents_t - t_ * v_pred
182
+
183
+ @staticmethod
184
+ def euler_step(
185
+ latents_t: torch.Tensor,
186
+ v_pred: torch.Tensor,
187
+ t: torch.Tensor,
188
+ t_next: torch.Tensor,
189
+ ) -> torch.Tensor:
190
+ """Euler step: x_{t_next} = x_t + (t_next - t) * v_pred"""
191
+ dt = (t_next - t).view(-1, *([1] * (latents_t.dim() - 1)))
192
+ return latents_t + dt * v_pred
193
+
194
+
195
+ def build_anima(
196
+ paths: AnimaPaths | None = None,
197
+ device: str | torch.device = "cuda",
198
+ dtype: torch.dtype = torch.bfloat16,
199
+ ) -> AnimaBundle:
200
+ """diffusion-pipe の CosmosPredict2Pipeline を初期化して AnimaBundle で返す。
201
+
202
+ 内部的には toml config を最小限作って Pipeline に渡す。
203
+ """
204
+ import importlib
205
+ import importlib.util
206
+ _dp = str(_DPIPE)
207
+
208
+ # diffusion-pipe の utils/ と models/ に空 __init__.py を作成して regular package 化
209
+ for sub in ("utils", "models", "optimizers"):
210
+ sub_dir = os.path.join(_dp, sub)
211
+ init_file = os.path.join(sub_dir, "__init__.py")
212
+ if os.path.isdir(sub_dir) and not os.path.exists(init_file):
213
+ with open(init_file, "w"):
214
+ pass
215
+
216
+ # sys.path 強制 + cache クリア
217
+ sys.path = [_dp] + [p for p in sys.path if p != _dp]
218
+ for _mod in list(sys.modules.keys()):
219
+ if _mod in ("utils", "models", "optimizers") \
220
+ or _mod.startswith(("utils.", "models.", "optimizers.")):
221
+ del sys.modules[_mod]
222
+
223
+ # importlib で **明示的に** 各 package を /workspace/diffusion-pipe 配下から
224
+ # 読み込み、sys.modules に固定して shadow を完全排除する。
225
+ def _load_pkg(name: str):
226
+ pkg_dir = os.path.join(_dp, name)
227
+ init_file = os.path.join(pkg_dir, "__init__.py")
228
+ spec = importlib.util.spec_from_file_location(
229
+ name, init_file,
230
+ submodule_search_locations=[pkg_dir],
231
+ )
232
+ mod = importlib.util.module_from_spec(spec)
233
+ sys.modules[name] = mod
234
+ spec.loader.exec_module(mod)
235
+ print(f"[setup] forced load {name} from {pkg_dir}")
236
+
237
+ for _pkg in ("utils", "models", "optimizers"):
238
+ _load_pkg(_pkg)
239
+
240
+ # 動作確認: utils.common が解決できることを試す
241
+ import utils.common # noqa: F401
242
+ print("[setup] utils.common import OK")
243
+
244
+ from models import cosmos_predict2 # diffusion-pipe 側
245
+
246
+ paths = paths or AnimaPaths()
247
+
248
+ # CosmosPredict2Pipeline は config dict から **torch.dtype object** を期待する
249
+ # (TOML 読み込み時に diffusion-pipe 側で str→dtype 変換するが、ここでは直接渡す)
250
+ cfg = {
251
+ "model": {
252
+ "type": "anima",
253
+ "transformer_path": str(paths.transformer),
254
+ "vae_path": str(paths.vae),
255
+ "llm_path": str(paths.llm),
256
+ "dtype": dtype,
257
+ "transformer_dtype": dtype,
258
+ "timestep_sample_method": "logit_normal",
259
+ },
260
+ # adapter / optimizer 等は trainer 側で attach するので空でOK
261
+ "adapter": {"type": "lora", "rank": 1, "alpha": 1, "dropout": 0.0, "dtype": dtype},
262
+ }
263
+
264
+ # __init__ で VAE + text encoder + tokenizers が CPU 上に load 済み
265
+ pipeline = cosmos_predict2.CosmosPredict2Pipeline(cfg)
266
+ # DiT をロード (transformer.llm_adapter も内部で attach される)
267
+ pipeline.load_diffusion_model()
268
+
269
+ # device / dtype に配置
270
+ pipeline.transformer = pipeline.transformer.to(device=device, dtype=dtype)
271
+ pipeline.text_encoder = pipeline.text_encoder.to(device=device, dtype=dtype)
272
+ pipeline.vae.model = pipeline.vae.model.to(device=device, dtype=dtype)
273
+ # mean/std は __init__ で 'cuda' に moved 済 (cosmos_predict2.py:200-201)
274
+
275
+ return AnimaBundle(pipeline, device=device)
scripts/distill/dataset.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ キャプション付き画像データセット。/dataset/raw 以下の .png + .txt ペアを返す。
3
+ DMD2 では「real image を見る」のは fake-score 更新時 (の v-pred MSE) のみで
4
+ あって、generator/real-score 側は noise から直接サンプル → image は不要。
5
+ 従ってここでは pixels + caption を返すシンプルな実装で十分。
6
+ """
7
+ from __future__ import annotations
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import torch
12
+ from torch.utils.data import Dataset
13
+ from PIL import Image
14
+
15
+
16
+ def _resize_short_side(img: Image.Image, target: int) -> Image.Image:
17
+ """短辺を target に合わせる aspect-preserving resize"""
18
+ w, h = img.size
19
+ if min(w, h) == target:
20
+ return img
21
+ if w < h:
22
+ new_w = target
23
+ new_h = int(round(h * target / w))
24
+ else:
25
+ new_h = target
26
+ new_w = int(round(w * target / h))
27
+ return img.resize((new_w, new_h), Image.BICUBIC)
28
+
29
+
30
+ def _center_crop(img: Image.Image, size: int) -> Image.Image:
31
+ w, h = img.size
32
+ left = (w - size) // 2
33
+ top = (h - size) // 2
34
+ return img.crop((left, top, left + size, top + size))
35
+
36
+
37
+ def _to_tensor_normalize(img: Image.Image) -> torch.Tensor:
38
+ """PIL RGB -> torch (3, H, W) in [-1, 1]"""
39
+ arr = np.asarray(img, dtype=np.float32) / 127.5 - 1.0 # (H, W, 3) in [-1, 1]
40
+ return torch.from_numpy(arr).permute(2, 0, 1).contiguous()
41
+
42
+
43
+ class AnimaImageCaptionDataset(Dataset):
44
+ """
45
+ Args:
46
+ root: 画像/キャプションのルート。再帰的に *.png + 同名.txt を拾う。
47
+ resolution: 単一解像度に統一する出力サイズ(短辺合わせ → center crop)。
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ root: str | Path,
53
+ resolution: int = 1024,
54
+ exts: tuple[str, ...] = (".png", ".jpg", ".jpeg", ".webp"),
55
+ ):
56
+ self.root = Path(root)
57
+ self.resolution = resolution
58
+ self.items: list[tuple[Path, Path]] = []
59
+ for img in sorted(self.root.rglob("*")):
60
+ if not img.is_file() or img.suffix.lower() not in exts:
61
+ continue
62
+ cap = img.with_suffix(".txt")
63
+ if cap.exists():
64
+ self.items.append((img, cap))
65
+
66
+ def __len__(self) -> int:
67
+ return len(self.items)
68
+
69
+ def __getitem__(self, idx: int) -> dict:
70
+ img_path, cap_path = self.items[idx]
71
+ img = Image.open(img_path).convert("RGB")
72
+ img = _resize_short_side(img, self.resolution)
73
+ img = _center_crop(img, self.resolution)
74
+ pixels = _to_tensor_normalize(img) # (3, H, W) in [-1, 1]
75
+ caption = cap_path.read_text(encoding="utf-8").strip()
76
+ return {"pixels": pixels, "caption": caption, "path": str(img_path)}
77
+
78
+
79
+ def collate_fn(batch: list[dict]) -> dict:
80
+ """default collate と同じだが captions は list で保持。"""
81
+ pixels = torch.stack([b["pixels"] for b in batch])
82
+ captions = [b["caption"] for b in batch]
83
+ paths = [b["path"] for b in batch]
84
+ return {"pixels": pixels, "captions": captions, "paths": paths}
scripts/distill/dmd2_official_loss.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anima 用 DMD2 + TrigFlow distillation (NVIDIA cosmos-predict2.5 official 流派)
3
+
4
+ 参照: cosmos_predict2/_src/predict2/distill/models/video2world_model_distill_dmd2.py
5
+ (lines 165-310 の step_generator / step_critic を直接移植)
6
+
7
+ 要点:
8
+ - TrigFlow time sampling: s ∈ shifted_uniform(shift=5) → t = arctan(s/(1-s))
9
+ - DMD2 gradient trick: loss = ((x_hat - (x_hat - grad).detach())**2).mean()
10
+ where grad = (x0_fake - x0_teacher_cfg) / |x_hat - x0_teacher_cfg|.mean()
11
+ - Critic phase weighted denoise: loss = ((x_hat - x0_fake)**2 / sin(t)**2).mean()
12
+ - Alternation: critic × N (=5), generator × 1
13
+ - Few-step student rollout (n=1..4), **grad は最終 step のみ** (memory efficient)
14
+
15
+ Anima の差分 (vs upstream):
16
+ - upstream は video2world、condition.is_video 経路あり → image only に折り畳む (T=1)
17
+ - LoRA-only: student/fake_score それぞれに別 PEFT adapter
18
+ - rectified flow (sigma_data=1.0 想定)、本 file の sCM wrapper 計算は upstream と同じ
19
+ """
20
+ from __future__ import annotations
21
+ import math
22
+ from typing import Callable
23
+
24
+ import torch
25
+ import torch.nn.functional as F
26
+
27
+ from .anima_loader import AnimaBundle
28
+
29
+
30
+ # ----- TrigFlow / sCM coefficient helpers (sigma_data=1.0, rectified flow) ---
31
+
32
+ def trigflow_t_to_sigma(t: torch.Tensor) -> torch.Tensor:
33
+ """trigflow t ∈ (0, π/2) を rectified-flow sigma に変換。
34
+ sigma = tan(t)、sigma_data=1.0 で normalize。"""
35
+ return torch.tan(t)
36
+
37
+
38
+ def rf_s_to_trigflow_t(s: torch.Tensor) -> torch.Tensor:
39
+ """rectified-flow time s ∈ [0, 1] を trigflow t = arctan(s / (1 - s)) に変換。"""
40
+ s = s.clamp(1e-6, 1.0 - 1e-6)
41
+ return torch.atan(s / (1.0 - s))
42
+
43
+
44
+ def sample_shifted_uniform_s(B: int, shift: float = 5.0, device=None, dtype=torch.float32) -> torch.Tensor:
45
+ """shifted uniform on [0,1]: u ~ U(0,1) → s = shift*u / (1 + (shift-1)*u)。
46
+ upstream sample_shifted_uniform 相当。shift>1 で low-s (high-noise) 側にシフト。"""
47
+ u = torch.rand(B, device=device, dtype=dtype)
48
+ return shift * u / (1.0 + (shift - 1.0) * u)
49
+
50
+
51
+ def scm_coefficients(t: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
52
+ """sigma_data=1.0 の RectifiedFlow_sCMWrapper:
53
+ c_skip = cos(t)、c_out = -sin(t)、c_in = 1 / sqrt(sigma**2 + 1) = cos(t)、c_noise = t
54
+
55
+ 使い方:
56
+ x_t = c_in.recip() * x_t_raw (DiT 入力前の正規化、cos(t) スケール)
57
+ x_t_raw = c_skip * x_t + c_out * v_pred (x0 復元)
58
+ 簡単化のため Anima では x_t を直接渡し、v_pred を直接消費。
59
+ """
60
+ c_skip = torch.cos(t)
61
+ c_out = -torch.sin(t)
62
+ c_in = torch.cos(t) # 1/sqrt(tan(t)**2+1) = cos(t)
63
+ c_noise = t
64
+ return c_skip, c_out, c_in, c_noise
65
+
66
+
67
+ # ----- noise / re-noise (rectified flow native, sigma_data=1.0) -------------
68
+
69
+ def renoise_rf(x0: torch.Tensor, t_rf: torch.Tensor, noise: torch.Tensor) -> torch.Tensor:
70
+ """rectified flow forward: x_t = (1-t)*x0 + t*noise。t_rf は ∈ [0,1]。"""
71
+ t_ = t_rf.view(-1, *([1] * (x0.dim() - 1)))
72
+ return (1.0 - t_) * x0 + t_ * noise
73
+
74
+
75
+ def x0_from_velocity_rf(x_t: torch.Tensor, v: torch.Tensor, t_rf: torch.Tensor) -> torch.Tensor:
76
+ """v = noise - x0 から x0 を復元: x0 = x_t - t * v"""
77
+ t_ = t_rf.view(-1, *([1] * (x_t.dim() - 1)))
78
+ return x_t - t_ * v
79
+
80
+
81
+ # ----- few-step student rollout --------------------------------------------
82
+
83
+ def backward_simulation(
84
+ student_v_fn: Callable[..., torch.Tensor],
85
+ noise: torch.Tensor,
86
+ n_steps: int,
87
+ cond_pos: torch.Tensor,
88
+ cond_neg: torch.Tensor | None,
89
+ cfg_scale: float = 1.0,
90
+ grad_last_only: bool = True,
91
+ ) -> torch.Tensor:
92
+ """t=1 (pure noise) から t=0 (clean) へ n_steps の Euler でロールアウト。
93
+ grad_last_only=True なら最終 1 step だけ grad を通し、それ以前は no_grad。
94
+ DMD2 paper 流派、memory efficient。
95
+
96
+ Returns: x_hat at t=0 (clean estimate)
97
+ """
98
+ from .traj_loss import cfg_guided, _broadcast_t
99
+
100
+ B = noise.size(0)
101
+ device = noise.device
102
+ dtype = noise.dtype
103
+
104
+ # n_steps Euler points (t=1 → t=0)、両端含めて n+1 個
105
+ ts = torch.linspace(1.0, 0.0, n_steps + 1, device=device, dtype=torch.float32)
106
+
107
+ x = noise
108
+ for i in range(n_steps):
109
+ t_cur = ts[i]
110
+ t_next = ts[i + 1]
111
+ is_last = (i == n_steps - 1)
112
+ ctx = torch.enable_grad() if (is_last and grad_last_only) else \
113
+ (torch.no_grad() if grad_last_only else torch.enable_grad())
114
+ t_in = _broadcast_t(t_cur, B, device, dtype)
115
+ with ctx:
116
+ v = cfg_guided(student_v_fn, x, t_in, cond_pos, cond_neg, cfg_scale)
117
+ dt = (t_next - t_cur).to(device=device, dtype=dtype)
118
+ x = x + dt * v
119
+ return x
120
+
121
+
122
+ # ----- generator phase ------------------------------------------------------
123
+
124
+ def dmd2_generator_loss(
125
+ student_v_fn: Callable[..., torch.Tensor], # PEFT student (LoRA active)
126
+ teacher_v_fn: Callable[..., torch.Tensor], # frozen base, no LoRA
127
+ fake_score_v_fn: Callable[..., torch.Tensor], # PEFT fake_score (LoRA active, no grad here)
128
+ init_noise: torch.Tensor,
129
+ cond_pos: torch.Tensor,
130
+ cond_neg: torch.Tensor | None,
131
+ teacher_cfg: float = 3.0,
132
+ student_cfg: float = 1.0,
133
+ n_steps: int = 4,
134
+ shift: float = 5.0,
135
+ ) -> tuple[torch.Tensor, dict]:
136
+ """DMD2 generator step。
137
+ - student で few-step rollout、x_hat 推定 (grad は最終 step のみ)
138
+ - x_hat を t_D で re-noise
139
+ - teacher で CFG'd x0 推定
140
+ - fake_score で x0_fake 推定 (no_grad)
141
+ - DMD2 gradient trick で loss 計算
142
+ """
143
+ from .traj_loss import _broadcast_t
144
+
145
+ B = init_noise.size(0)
146
+ device = init_noise.device
147
+ dtype = init_noise.dtype
148
+
149
+ # 1) few-step student rollout (grad only last)
150
+ x_hat = backward_simulation(
151
+ student_v_fn, init_noise, n_steps, cond_pos, cond_neg,
152
+ cfg_scale=student_cfg, grad_last_only=True,
153
+ )
154
+
155
+ # 2) t_D for re-noise: shifted uniform on [0,1]
156
+ s = sample_shifted_uniform_s(B, shift=shift, device=device, dtype=torch.float32)
157
+ t_D = s # rectified flow time domain
158
+ D_eps = torch.randn_like(x_hat)
159
+ x_t = renoise_rf(x_hat, t_D, D_eps)
160
+
161
+ # 3) teacher CFG'd velocity → x0 estimate
162
+ with torch.no_grad():
163
+ t_in = t_D.to(dtype=dtype)
164
+ v_cond = teacher_v_fn(x_t, t_in, cond_pos)
165
+ if teacher_cfg > 1.0 and cond_neg is not None:
166
+ v_uncond = teacher_v_fn(x_t, t_in, cond_neg)
167
+ v_teacher = v_uncond + teacher_cfg * (v_cond - v_uncond)
168
+ else:
169
+ v_teacher = v_cond
170
+ x0_teacher = x0_from_velocity_rf(x_t, v_teacher, t_D)
171
+
172
+ # 4) fake_score velocity → x0_fake estimate (no_grad in gen phase)
173
+ v_fake = fake_score_v_fn(x_t, t_in, cond_pos)
174
+ x0_fake = x0_from_velocity_rf(x_t, v_fake, t_D)
175
+
176
+ # 5) DMD2 gradient trick
177
+ diff = x_hat - x0_teacher
178
+ # per-sample L1 normalization (sCM2 weight)
179
+ w = diff.abs().mean(dim=list(range(1, diff.dim())), keepdim=True).clamp(min=1e-5)
180
+ grad = (x0_fake.detach() - x0_teacher.detach()) / w
181
+ loss = ((x_hat - (x_hat - grad).detach()).float() ** 2).mean()
182
+
183
+ metrics = {
184
+ "l_dmd_gen": loss.detach(),
185
+ "t_D_mean": t_D.mean().detach(),
186
+ "x_hat_abs_mean": x_hat.detach().abs().mean(),
187
+ "teacher_abs_mean": x0_teacher.detach().abs().mean(),
188
+ "fake_abs_mean": x0_fake.detach().abs().mean(),
189
+ "w_mean": w.detach().mean(),
190
+ }
191
+ return loss, metrics
192
+
193
+
194
+ # ----- critic (fake_score) phase -------------------------------------------
195
+
196
+ def dmd2_critic_loss(
197
+ student_v_fn: Callable[..., torch.Tensor],
198
+ fake_score_v_fn: Callable[..., torch.Tensor],
199
+ init_noise: torch.Tensor,
200
+ cond_pos: torch.Tensor,
201
+ cond_neg: torch.Tensor | None,
202
+ student_cfg: float = 1.0,
203
+ n_steps: int = 4,
204
+ shift: float = 5.0,
205
+ ) -> tuple[torch.Tensor, dict]:
206
+ """fake_score を student 分布の denoiser として学習。
207
+ weighted denoising loss with `1 / sin(t)**2` factor (上流の trigflow weighting に等価)。"""
208
+ from .traj_loss import _broadcast_t
209
+
210
+ B = init_noise.size(0)
211
+ device = init_noise.device
212
+ dtype = init_noise.dtype
213
+
214
+ # 1) student rollout (全部 no_grad: critic phase では student は固定扱い)
215
+ with torch.no_grad():
216
+ x_hat = backward_simulation(
217
+ student_v_fn, init_noise, n_steps, cond_pos, cond_neg,
218
+ cfg_scale=student_cfg, grad_last_only=False,
219
+ )
220
+
221
+ # 2) t_D で re-noise
222
+ s = sample_shifted_uniform_s(B, shift=shift, device=device, dtype=torch.float32)
223
+ t_D = s
224
+ D_eps = torch.randn_like(x_hat)
225
+ x_t = renoise_rf(x_hat, t_D, D_eps)
226
+
227
+ # 3) fake_score forward (grad on)
228
+ t_in = t_D.to(dtype=dtype)
229
+ v_fake = fake_score_v_fn(x_t, t_in, cond_pos)
230
+ x0_fake = x0_from_velocity_rf(x_t, v_fake, t_D)
231
+
232
+ # 4) weighted denoising loss: target = x_hat (= student output)
233
+ # weight = 1 / sin(arctan(s/(1-s)))**2、small s → big weight (high-noise emphasized)
234
+ t_trig = rf_s_to_trigflow_t(t_D)
235
+ w = 1.0 / torch.sin(t_trig).clamp(min=1e-3) ** 2
236
+ w = w.view(-1, *([1] * (x_hat.dim() - 1)))
237
+ loss = (w * (x_hat.detach() - x0_fake).float() ** 2).mean()
238
+
239
+ metrics = {
240
+ "l_dmd_critic": loss.detach(),
241
+ "t_D_mean": t_D.mean().detach(),
242
+ "w_mean": w.detach().mean(),
243
+ }
244
+ return loss, metrics
scripts/distill/dmd2_trainer.py ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DMD2 trainer for Anima (DMDR pattern + R3GAN + TSCD)
3
+ ====================================================
4
+
5
+ DMDR paper の "2 モデル + LoRA on/off で real/fake 切替" 方式を踏襲。
6
+ ここに R3GAN discriminator と TSCD consistency loss を統合する。
7
+
8
+ Models:
9
+ gen_model : Anima DiT (full trainable, no LoRA)
10
+ guidance_model : Anima DiT (Q,V LoRA, scale 切替で real/fake)
11
+ discriminator : R3GAN projection D (text-conditional)
12
+ ema_gen : gen の EMA (TSCD target)
13
+
14
+ Update schedule per outer step:
15
+ - guidance × N_GUIDANCE (default 5)
16
+ - generator × 1
17
+ - discriminator × 1
18
+ """
19
+ from __future__ import annotations
20
+ import copy
21
+ import math
22
+ from dataclasses import dataclass
23
+
24
+ import torch
25
+ import torch.nn as nn
26
+ import torch.nn.functional as F
27
+ import peft
28
+
29
+ from .anima_loader import AnimaBundle
30
+ from .r3gan_disc import R3GANDiscriminator, d_loss_r3gan, g_loss_r3gan
31
+
32
+
33
+ @dataclass
34
+ class DMD2Args:
35
+ # Training
36
+ batch_size: int = 4
37
+ lr_gen: float = 2e-5
38
+ lr_guidance: float = 2e-5
39
+ lr_disc: float = 2e-4
40
+ grad_clip: float = 1.0
41
+
42
+ # DMD2 / DMDR
43
+ guidance_updates: int = 5 # 1 outer step あたりの guidance update 回数
44
+ lora_rank: int = 32
45
+ lora_scale_f: float = 2.0 # fake-score (LoRA ON)
46
+ lora_scale_r: float = 0.75 # real-score start (cosine decay → 0)
47
+ cfg_r: float = 2.0 # real-score 側 CFG (Augmentation の Spear)
48
+ dynamic_decay_steps: int = 2000 # lora_scale_r を cosine で 0 にする step
49
+
50
+ # Timestep sampling
51
+ gui_alpha: float = 4.0 # Beta(α, β) for continuous t
52
+ gui_beta: float = 1.5
53
+ num_inference_steps: int = 8 # gen の discrete grid (Phase A=8, B=4, C=2)
54
+
55
+ # TSCD
56
+ tscd_weight: float = 0.5
57
+ ema_decay: float = 0.999
58
+
59
+ # R3GAN
60
+ adv_weight: float = 0.1
61
+ r3gan_gamma: float = 50.0
62
+
63
+ # CFG-Aug schedule
64
+ cfg_aug_cold_steps: int = 200 # 最初は cfg_r=0 で wait
65
+
66
+
67
+ def attach_qv_lora(transformer: nn.Module, rank: int = 32) -> nn.Module:
68
+ """attention Q/V projection に LoRA を attach。MiniTrainDIT の実 module 名を
69
+ inspection で見つける。"""
70
+ Q_KEYS = ("to_q", "q_proj", "wq", "q")
71
+ V_KEYS = ("to_v", "v_proj", "wv", "v")
72
+ target_modules = []
73
+ for name, module in transformer.named_modules():
74
+ if not isinstance(module, nn.Linear):
75
+ continue
76
+ leaf = name.rsplit(".", 1)[-1]
77
+ if leaf in Q_KEYS or leaf in V_KEYS:
78
+ target_modules.append(name)
79
+
80
+ if not target_modules:
81
+ # Fallback 1: 名前に 'attn' が入る Linear すべて
82
+ for name, module in transformer.named_modules():
83
+ if isinstance(module, nn.Linear) and (
84
+ "attn" in name.lower() or "attention" in name.lower()
85
+ ):
86
+ target_modules.append(name)
87
+
88
+ if not target_modules:
89
+ print("[lora] WARN: no attention Q/V found, falling back to all-linear")
90
+ target_modules = "all-linear"
91
+ else:
92
+ print(f"[lora] target_modules={len(target_modules)} layers (Q/V or attn)")
93
+
94
+ cfg = peft.LoraConfig(
95
+ r=rank,
96
+ lora_alpha=rank,
97
+ lora_dropout=0.0,
98
+ bias="none",
99
+ target_modules=target_modules,
100
+ )
101
+ return peft.get_peft_model(transformer, cfg)
102
+
103
+
104
+ def attach_wide_lora(transformer: nn.Module, rank: int = 32) -> nn.Module:
105
+ """全 nn.Linear に LoRA を attach (LLM adapter 内部は除外)。
106
+ Anima/Cosmos の AdaLN modulation・attention・MLP すべてを学習対象にする。
107
+ timestep 解釈を含む蒸留タスクには Q,V LoRA では不足、これが必要。"""
108
+ target_modules = []
109
+ for name, module in transformer.named_modules():
110
+ if not isinstance(module, nn.Linear):
111
+ continue
112
+ # LLM adapter (Qwen3→T5 bridge) は触らない (壊れやすい)
113
+ if "llm_adapter" in name:
114
+ continue
115
+ target_modules.append(name)
116
+
117
+ if not target_modules:
118
+ raise RuntimeError("No nn.Linear found in transformer for wide LoRA")
119
+
120
+ # 概要
121
+ cats = {"attn": 0, "adaln": 0, "mlp": 0, "other": 0}
122
+ for n in target_modules:
123
+ nl = n.lower()
124
+ if "attn" in nl or "attention" in nl:
125
+ cats["attn"] += 1
126
+ elif "adaln" in nl or "modulation" in nl:
127
+ cats["adaln"] += 1
128
+ elif "mlp" in nl or "ffn" in nl or "feed" in nl:
129
+ cats["mlp"] += 1
130
+ else:
131
+ cats["other"] += 1
132
+ print(f"[lora] wide target: total={len(target_modules)} "
133
+ f"(attn={cats['attn']}, adaln={cats['adaln']}, mlp={cats['mlp']}, other={cats['other']})")
134
+
135
+ cfg = peft.LoraConfig(
136
+ r=rank,
137
+ lora_alpha=rank,
138
+ lora_dropout=0.0,
139
+ bias="none",
140
+ target_modules=target_modules,
141
+ )
142
+ return peft.get_peft_model(transformer, cfg)
143
+
144
+
145
+ def set_lora_scale(model: nn.Module, scale: float) -> None:
146
+ """PEFT LoRA の scaling を runtime で書き換え。real/fake 切替用。"""
147
+ for module in model.modules():
148
+ if hasattr(module, "scaling") and isinstance(module.scaling, dict):
149
+ for key in module.scaling:
150
+ # alpha/r = 1.0 を基準に scale 倍する
151
+ module.scaling[key] = scale
152
+
153
+
154
+ def sample_continuous_t(
155
+ batch_size: int, alpha: float, beta: float, device: torch.device
156
+ ) -> torch.Tensor:
157
+ """logit-normal Beta(α, β) サンプリング。返り値 t ∈ (0, 1)"""
158
+ # Beta -> logit-normal 風の重み (DMDR の sample_continue 簡略版)
159
+ u = torch.rand(batch_size, device=device)
160
+ # Beta(α, β) inverse CDF を近似:正規 logit に変換するシンプル版
161
+ # ここでは log-normal で代用 (実装簡易のため)
162
+ t = torch.distributions.Beta(alpha, beta).sample((batch_size,)).to(device)
163
+ return t.clamp(1e-3, 1 - 1e-3)
164
+
165
+
166
+ def sample_discrete_t(
167
+ batch_size: int, num_steps: int, device: torch.device
168
+ ) -> torch.Tensor:
169
+ """generator 側: 離散 grid {1/N, 2/N, ..., (N-1)/N} から uniform サンプル。"""
170
+ grid = torch.linspace(1.0 / num_steps, 1.0 - 1.0 / num_steps, num_steps - 1, device=device)
171
+ idx = torch.randint(0, num_steps - 1, (batch_size,), device=device)
172
+ return grid[idx]
173
+
174
+
175
+ def cosine_decay(step: int, total: int) -> float:
176
+ if step >= total:
177
+ return 0.0
178
+ return 0.5 * (1 + math.cos(math.pi * step / total))
179
+
180
+
181
+ class EMA:
182
+ """生成器の EMA を別 model copy として保持。TSCD target として直接 forward 可能。
183
+
184
+ only_trainable=True (default) なら trainable param のみ EMA 更新する。
185
+ LoRA-only モードでは base は frozen なので大半が skip され、EMA cost が激減。"""
186
+
187
+ def __init__(self, model: nn.Module, decay: float = 0.999,
188
+ only_trainable: bool = True):
189
+ self.decay = decay
190
+ self.only_trainable = only_trainable
191
+ # 同 device/dtype の eval-only copy
192
+ self.ema_model = copy.deepcopy(model).eval()
193
+ for p in self.ema_model.parameters():
194
+ p.requires_grad = False
195
+
196
+ @torch.no_grad()
197
+ def update(self, model: nn.Module) -> None:
198
+ for ep, p in zip(self.ema_model.parameters(), model.parameters()):
199
+ if self.only_trainable and not p.requires_grad:
200
+ continue
201
+ ep.mul_(self.decay).add_(p.detach(), alpha=1 - self.decay)
202
+ # buffers (RoPE 等) は最新を採用
203
+ for eb, b in zip(self.ema_model.buffers(), model.buffers()):
204
+ eb.copy_(b.detach())
205
+
206
+ def __call__(self, **kwargs):
207
+ return self.ema_model(**kwargs)
208
+
209
+
210
+ # ============================================================================
211
+ # Trainer
212
+ # ============================================================================
213
+
214
+ class DMD2Trainer:
215
+ def __init__(
216
+ self,
217
+ bundle: AnimaBundle,
218
+ gen_transformer: nn.Module,
219
+ guidance_transformer: nn.Module, # PEFT-wrapped (LoRA attached)
220
+ discriminator: R3GANDiscriminator,
221
+ args: DMD2Args,
222
+ ):
223
+ self.bundle = bundle
224
+ self.gen = gen_transformer
225
+ self.guidance = guidance_transformer
226
+ self.disc = discriminator
227
+ self.args = args
228
+ self.device = bundle.device
229
+ self.step = 0
230
+
231
+ # Optimizers (gen も frozen 部分があるなら filter)
232
+ self.opt_gen = torch.optim.AdamW(
233
+ [p for p in self.gen.parameters() if p.requires_grad],
234
+ lr=args.lr_gen, betas=(0.9, 0.999),
235
+ weight_decay=0.01, eps=1e-8,
236
+ )
237
+ self.opt_guidance = torch.optim.AdamW(
238
+ [p for p in self.guidance.parameters() if p.requires_grad],
239
+ lr=args.lr_guidance, betas=(0.9, 0.999), weight_decay=0.0, eps=1e-8,
240
+ )
241
+ self.opt_disc = torch.optim.Adam(
242
+ self.disc.parameters(), lr=args.lr_disc, betas=(0.0, 0.99), eps=1e-8,
243
+ )
244
+
245
+ # EMA for TSCD
246
+ self.ema = EMA(self.gen, decay=args.ema_decay)
247
+
248
+ # -- helpers ------------------------------------------------------------
249
+ def _set_train(self, gen: bool, guidance: bool, disc: bool):
250
+ self.gen.train(gen); self.guidance.train(guidance); self.disc.train(disc)
251
+
252
+ def _t_continuous(self, B: int) -> torch.Tensor:
253
+ return sample_continuous_t(B, self.args.gui_alpha, self.args.gui_beta, self.device)
254
+
255
+ def _t_discrete(self, B: int) -> torch.Tensor:
256
+ return sample_discrete_t(B, self.args.num_inference_steps, self.device)
257
+
258
+ def _cfg_r_current(self) -> float:
259
+ """cold start 中は cfg_r=0、それ以降は args.cfg_r。"""
260
+ return 0.0 if self.step < self.args.cfg_aug_cold_steps else self.args.cfg_r
261
+
262
+ def _lora_scale_r_current(self) -> float:
263
+ decay = cosine_decay(self.step, self.args.dynamic_decay_steps)
264
+ return self.args.lora_scale_r * decay
265
+
266
+ # -- guidance update ----------------------------------------------------
267
+ def step_guidance(self, real_latents: torch.Tensor, cond: torch.Tensor) -> dict:
268
+ """fake-score (LoRA ON) を v-prediction MSE で更新"""
269
+ self._set_train(gen=False, guidance=True, disc=False)
270
+ set_lora_scale(self.guidance, self.args.lora_scale_f)
271
+
272
+ B = real_latents.size(0)
273
+ noise = torch.randn_like(real_latents)
274
+ t = self._t_continuous(B)
275
+ noisy = AnimaBundle.add_noise(real_latents, noise, t)
276
+ v_target = AnimaBundle.velocity_target(real_latents, noise)
277
+
278
+ v_pred = AnimaBundle.dit_forward(self.guidance, noisy, t, cond)
279
+ loss = F.mse_loss(v_pred, v_target)
280
+ loss.backward()
281
+ torch.nn.utils.clip_grad_norm_(
282
+ [p for p in self.guidance.parameters() if p.requires_grad],
283
+ self.args.grad_clip)
284
+ self.opt_guidance.step(); self.opt_guidance.zero_grad()
285
+ return {"l_guidance": loss.detach()}
286
+
287
+ # -- CFG forward (real-score side) -------------------------------------
288
+ def _real_score_forward(
289
+ self, noisy: torch.Tensor, t: torch.Tensor, cond: torch.Tensor, cfg: float,
290
+ ) -> torch.Tensor:
291
+ """LoRA OFF / decay 状態の guidance_model で real-side velocity を取得。
292
+ CFG > 1 のときは null condition と blend。"""
293
+ set_lora_scale(self.guidance, self._lora_scale_r_current())
294
+ v_cond = AnimaBundle.dit_forward(self.guidance, noisy, t, cond)
295
+ if cfg > 1.0:
296
+ null_cond = torch.zeros_like(cond)
297
+ v_uncond = AnimaBundle.dit_forward(self.guidance, noisy, t, null_cond)
298
+ v = v_uncond + cfg * (v_cond - v_uncond)
299
+ else:
300
+ v = v_cond
301
+ return v
302
+
303
+ # -- TSCD consistency loss ---------------------------------------------
304
+ def _tscd_loss(
305
+ self, x0_pred: torch.Tensor, cond: torch.Tensor, t: torch.Tensor
306
+ ) -> torch.Tensor:
307
+ """segment-wise consistency vs EMA gen。
308
+ TSCD 簡易版: 同じ x0 ground 周辺で noisy 化 → EMA で x0 推定 → MSE。"""
309
+ with torch.no_grad():
310
+ noise = torch.randn_like(x0_pred)
311
+ noisy = AnimaBundle.add_noise(x0_pred.detach(), noise, t)
312
+ v_ema = AnimaBundle.dit_forward(self.ema.ema_model, noisy, t, cond)
313
+ x0_ema = AnimaBundle.x0_from_velocity(noisy, v_ema, t)
314
+ return F.mse_loss(x0_pred, x0_ema.detach())
315
+
316
+ # -- generator update ---------------------------------------------------
317
+ def step_generator(self, real_latents: torch.Tensor, cond: torch.Tensor) -> dict:
318
+ self._set_train(gen=True, guidance=False, disc=False)
319
+ B = real_latents.size(0)
320
+ noise = torch.randn_like(real_latents)
321
+ t = self._t_discrete(B)
322
+ noisy = AnimaBundle.add_noise(real_latents, noise, t)
323
+
324
+ # Generator forward
325
+ v_gen = AnimaBundle.dit_forward(self.gen, noisy, t, cond)
326
+ x0_pred = AnimaBundle.x0_from_velocity(noisy, v_gen, t)
327
+
328
+ # === DMD gradient ===
329
+ with torch.no_grad():
330
+ cfg = self._cfg_r_current()
331
+ v_real = self._real_score_forward(noisy, t, cond, cfg=cfg)
332
+ set_lora_scale(self.guidance, self.args.lora_scale_f)
333
+ v_fake = AnimaBundle.dit_forward(self.guidance, noisy, t, cond)
334
+ x0_real = AnimaBundle.x0_from_velocity(noisy, v_real, t)
335
+ x0_fake = AnimaBundle.x0_from_velocity(noisy, v_fake, t)
336
+ p_real = x0_pred - x0_real
337
+ p_fake = x0_pred - x0_fake
338
+ denom = p_real.abs().mean(dim=list(range(1, p_real.dim())), keepdim=True) + 1e-8
339
+ grad = (p_real - p_fake) / denom
340
+ dmd_loss = 0.5 * F.mse_loss(x0_pred.float(), (x0_pred - grad).detach().float())
341
+
342
+ # === TSCD consistency ===
343
+ consist_loss = self._tscd_loss(x0_pred, cond, t)
344
+
345
+ # === R3GAN adversarial (generator side) — adv_weight>0 のときだけ ===
346
+ if self.args.adv_weight > 0:
347
+ d_fake = self.disc(x0_pred, cond)
348
+ with torch.no_grad():
349
+ d_real_for_g = self.disc(real_latents, cond)
350
+ adv_loss = g_loss_r3gan(d_real_for_g, d_fake)
351
+ else:
352
+ adv_loss = torch.zeros((), device=self.device)
353
+
354
+ loss = (
355
+ dmd_loss
356
+ + self.args.tscd_weight * consist_loss
357
+ + self.args.adv_weight * adv_loss
358
+ )
359
+ loss.backward()
360
+ torch.nn.utils.clip_grad_norm_(self.gen.parameters(), self.args.grad_clip)
361
+ self.opt_gen.step(); self.opt_gen.zero_grad()
362
+
363
+ self.ema.update(self.gen)
364
+ return {
365
+ "l_dmd": dmd_loss.detach(),
366
+ "l_consist": consist_loss.detach(),
367
+ "l_adv_g": adv_loss.detach(),
368
+ "loss_gen_total": loss.detach(),
369
+ }
370
+
371
+ # -- discriminator update ----------------------------------------------
372
+ def step_discriminator(self, real_latents: torch.Tensor, cond: torch.Tensor) -> dict:
373
+ self._set_train(gen=False, guidance=False, disc=True)
374
+ B = real_latents.size(0)
375
+
376
+ # 生成器で fake samples を作成 (no_grad)
377
+ with torch.no_grad():
378
+ noise = torch.randn_like(real_latents)
379
+ t_init = torch.ones(B, device=self.device)
380
+ v = AnimaBundle.dit_forward(self.gen, noise, t_init, cond)
381
+ fake_latents = AnimaBundle.x0_from_velocity(noise, v, t_init)
382
+
383
+ real_samples = real_latents.detach().clone().requires_grad_(True)
384
+ fake_samples = fake_latents.detach().clone().requires_grad_(True)
385
+
386
+ d_real = self.disc(real_samples, cond)
387
+ d_fake = self.disc(fake_samples, cond)
388
+ loss, metrics = d_loss_r3gan(
389
+ d_real, d_fake, real_samples, fake_samples, gamma=self.args.r3gan_gamma,
390
+ )
391
+
392
+ # GUARD: R1/R2 が異常に大きくなったら abort
393
+ # latent 空間 (16ch × 128×128 = 262k) では正常時でも R1/R2 ~ 10-100 になる。
394
+ # Phase A は 700 -> 700k で爆発。1000 を超えたら abort。
395
+ r1_val = float(metrics["d_r1"])
396
+ r2_val = float(metrics["d_r2"])
397
+ if r1_val > 1000.0 or r2_val > 1000.0:
398
+ raise RuntimeError(
399
+ f"R3GAN penalty exploded (r1={r1_val:.1f}, r2={r2_val:.1f}). "
400
+ f"Reduce r3gan_gamma (current={self.args.r3gan_gamma}). "
401
+ f"Aborting to prevent gen damage."
402
+ )
403
+
404
+ loss.backward()
405
+ torch.nn.utils.clip_grad_norm_(self.disc.parameters(), self.args.grad_clip)
406
+ self.opt_disc.step(); self.opt_disc.zero_grad()
407
+ metrics["l_disc"] = loss.detach()
408
+ return metrics
409
+
410
+ # -- main step ----------------------------------------------------------
411
+ def train_step(self, batch: dict) -> dict:
412
+ # 1) text encode + VAE encode (no grad)
413
+ with torch.no_grad():
414
+ cond = self.bundle.text_encode(batch["captions"])
415
+ real_latents = self.bundle.vae_encode(batch["pixels"].to(self.device))
416
+
417
+ # 2) guidance × N
418
+ log = {}
419
+ for _ in range(self.args.guidance_updates):
420
+ log.update(self.step_guidance(real_latents, cond))
421
+
422
+ # 3) generator × 1
423
+ log.update(self.step_generator(real_latents, cond))
424
+
425
+ # 4) discriminator × 1 (adv_weight=0 のときは skip して R3GAN を完全 disable)
426
+ if self.args.adv_weight > 0:
427
+ log.update(self.step_discriminator(real_latents, cond))
428
+
429
+ self.step += 1
430
+ return log
scripts/distill/dmdx_loss.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anima DMDX (ADM = Adversarial Distribution Matching) distillation
3
+ =================================================================
4
+ Reference: arxiv 2507.18569v1
5
+ "Adversarial Distribution Matching for Diffusion Distillation"
6
+
7
+ DMD2 の grad trick (reverse KL) を、**学習可能 discriminator による hinge GAN
8
+ (TVD 最小化)** に置換した手法。本実装は image 用 ADM 単体 (ADP 省略)。
9
+
10
+ 要点:
11
+ - student rollout は DMD2 と同じ (few-step、grad-last-only)
12
+ - sample t (cubic schedule、high-noise バイアス)
13
+ - x_hat を t に re-noise → x_t
14
+ - **teacher 1 step で t → t-Δt 進化** (DMDX の核、時刻情報を D に与える)
15
+ - D(x_{t-Δt}, t-Δt) で hinge generator loss
16
+ - real path は teacher_x0_cache の x0 を使用 (LADD と同じ流派)
17
+ - D backbone = teacher MiniTrainDIT (frozen) + spectral norm heads (LADD と共有)
18
+
19
+ vs DMD2:
20
+ - DMD2: real_score - fake_score (逆 KL gradient trick)
21
+ - DMDX: D(x_{t-Δt}_fake) ↑ / D(x_{t-Δt}_real) ↓ (hinge、対称 TVD)
22
+ """
23
+ from __future__ import annotations
24
+ from typing import Callable, List, Optional
25
+
26
+ import torch
27
+ import torch.nn.functional as F
28
+
29
+ from .dmd2_official_loss import backward_simulation, renoise_rf
30
+
31
+
32
+ # ----- cubic time sampling -------------------------------------------------
33
+
34
+ def sample_cubic_t(B: int, device, dtype=torch.float32,
35
+ bias_high_noise: bool = True) -> torch.Tensor:
36
+ """Cubic time schedule sampling.
37
+ bias_high_noise=True: u ~ U(0,1), t = 1 - u^3 → 多くは t > 0.5 (high noise)
38
+ paper の "高ノイズレベルへのバイアス" に対応。
39
+ """
40
+ u = torch.rand(B, device=device, dtype=dtype)
41
+ if bias_high_noise:
42
+ return 1.0 - u ** 3
43
+ return u ** 3
44
+
45
+
46
+ # ----- teacher Δt evolution -------------------------------------------------
47
+
48
+ def teacher_evolve_dt(
49
+ x_t: torch.Tensor, t: torch.Tensor, dt: torch.Tensor,
50
+ teacher_v_fn: Callable, cond_pos: torch.Tensor, cond_neg: torch.Tensor,
51
+ teacher_cfg: float = 4.5,
52
+ ) -> torch.Tensor:
53
+ """teacher CFG'd 1-step Euler で x_t (at time t) → x_{t-dt} (at time t-dt) へ進化。
54
+ rectified flow: x_{t-dt} = x_t - dt * v_teacher(x_t, t, cond)
55
+ """
56
+ dtype = x_t.dtype
57
+ B = x_t.size(0)
58
+ with torch.no_grad():
59
+ t_in = t.to(dtype=dtype)
60
+ v_cond = teacher_v_fn(x_t, t_in, cond_pos)
61
+ if teacher_cfg > 1.0 and cond_neg is not None:
62
+ v_uncond = teacher_v_fn(x_t, t_in, cond_neg)
63
+ v = v_uncond + teacher_cfg * (v_cond - v_uncond)
64
+ else:
65
+ v = v_cond
66
+ dt_b = dt.view(-1, *([1] * (x_t.dim() - 1))).to(dtype=dtype)
67
+ x_next = x_t - dt_b * v
68
+ return x_next
69
+
70
+
71
+ # ----- ADM generator loss ---------------------------------------------------
72
+
73
+ def adm_generator_loss(
74
+ student_v_fn: Callable, teacher_v_fn: Callable, disc,
75
+ init_noise: torch.Tensor, cond_pos: torch.Tensor, cond_neg: torch.Tensor,
76
+ real_x0: Optional[torch.Tensor] = None,
77
+ teacher_cfg: float = 4.5, student_cfg: float = 1.0,
78
+ n_student_steps: int = 4, dt_ratio: float = 1.0 / 64,
79
+ recon_weight: float = 0.0,
80
+ ) -> tuple[torch.Tensor, dict]:
81
+ """ADM generator loss (hinge -D + optional recon anchor)。
82
+ 1. student backward simulation で x_hat 推定 (grad-last-only)
83
+ 2. cubic t sampling, t > dt_ratio
84
+ 3. x_hat を t に re-noise → x_t_fake
85
+ 4. teacher 1 step で x_{t-dt}_fake へ進化
86
+ 5. D(x_{t-dt}_fake, t-dt) hinge GAN gen loss (mean(-D))
87
+ 6. optional: recon_weight > 0 なら Smooth-L1 anchor (vs real_x0)
88
+ """
89
+ B = init_noise.size(0)
90
+ device, dtype = init_noise.device, init_noise.dtype
91
+
92
+ # 1) student rollout (grad on last step only)
93
+ x_hat = backward_simulation(
94
+ student_v_fn, init_noise, n_student_steps, cond_pos, cond_neg,
95
+ cfg_scale=student_cfg, grad_last_only=True,
96
+ )
97
+
98
+ # 2) sample t (cubic, biased high-noise)
99
+ t = sample_cubic_t(B, device=device, dtype=torch.float32)
100
+ t = t.clamp(min=dt_ratio + 1e-3, max=1.0 - 1e-3)
101
+
102
+ # 3) re-noise x_hat to t
103
+ eps = torch.randn_like(x_hat)
104
+ x_t_fake = renoise_rf(x_hat, t, eps)
105
+
106
+ # 4) teacher Δt evolution
107
+ dt_vec = torch.full_like(t, dt_ratio)
108
+ t_next = t - dt_vec
109
+ x_next_fake = teacher_evolve_dt(
110
+ x_t_fake, t, dt_vec, teacher_v_fn, cond_pos, cond_neg, teacher_cfg=teacher_cfg,
111
+ )
112
+
113
+ # 5) discriminator at t-dt; gradient_to_input=True で student に grad が届く
114
+ t_next_in = t_next.to(dtype=dtype)
115
+ logits_fake = disc(x_next_fake, t_next_in, cond_pos, gradient_to_input=True)
116
+ # hinge generator: maximize D(fake) → minimize -D(fake)
117
+ loss_g = sum(-l.mean() for l in logits_fake) / max(len(logits_fake), 1)
118
+
119
+ metrics = {
120
+ "l_g_adv": loss_g.detach(),
121
+ "t_mean": t.mean().detach(),
122
+ "x_hat_abs_mean": x_hat.detach().abs().mean(),
123
+ "x_next_fake_abs_mean": x_next_fake.detach().abs().mean(),
124
+ }
125
+
126
+ # 6) optional recon anchor (LADD 流の mean collapse 防��)
127
+ if recon_weight > 0.0 and real_x0 is not None:
128
+ recon = F.smooth_l1_loss(x_hat.float(), real_x0.detach().float())
129
+ loss_g = loss_g + recon_weight * recon
130
+ metrics["l_g_recon"] = recon.detach()
131
+
132
+ return loss_g, metrics
133
+
134
+
135
+ # ----- ADM discriminator loss -----------------------------------------------
136
+
137
+ def adm_discriminator_loss(
138
+ student_v_fn: Callable, teacher_v_fn: Callable, disc,
139
+ init_noise: torch.Tensor, real_x0: torch.Tensor,
140
+ cond_pos: torch.Tensor, cond_neg: torch.Tensor,
141
+ teacher_cfg: float = 4.5, student_cfg: float = 1.0,
142
+ n_student_steps: int = 4, dt_ratio: float = 1.0 / 64,
143
+ ) -> tuple[torch.Tensor, dict]:
144
+ """ADM discriminator hinge loss。
145
+ Fake path: student rollout (no grad) → re-noise t → teacher t-dt
146
+ Real path: real_x0 → re-noise t → teacher t-dt
147
+ Hinge: relu(1 - D(real)) + relu(1 + D(fake))
148
+ """
149
+ B = init_noise.size(0)
150
+ device, dtype = init_noise.device, init_noise.dtype
151
+
152
+ # Fake path (no student grad in D phase)
153
+ with torch.no_grad():
154
+ x_hat = backward_simulation(
155
+ student_v_fn, init_noise, n_student_steps, cond_pos, cond_neg,
156
+ cfg_scale=student_cfg, grad_last_only=False,
157
+ )
158
+
159
+ # sample t (cubic)
160
+ t = sample_cubic_t(B, device=device, dtype=torch.float32)
161
+ t = t.clamp(min=dt_ratio + 1e-3, max=1.0 - 1e-3)
162
+
163
+ # re-noise (fake / real)
164
+ eps_f = torch.randn_like(x_hat)
165
+ eps_r = torch.randn_like(real_x0)
166
+ x_t_fake = renoise_rf(x_hat, t, eps_f)
167
+ x_t_real = renoise_rf(real_x0, t, eps_r)
168
+
169
+ # Δt evolution (both)
170
+ dt_vec = torch.full_like(t, dt_ratio)
171
+ t_next = t - dt_vec
172
+ x_next_fake = teacher_evolve_dt(
173
+ x_t_fake, t, dt_vec, teacher_v_fn, cond_pos, cond_neg, teacher_cfg=teacher_cfg,
174
+ )
175
+ x_next_real = teacher_evolve_dt(
176
+ x_t_real, t, dt_vec, teacher_v_fn, cond_pos, cond_neg, teacher_cfg=teacher_cfg,
177
+ )
178
+
179
+ # D at t-dt (heads only, x detached、メモリ節約)
180
+ t_next_in = t_next.to(dtype=dtype)
181
+ logits_fake = disc(x_next_fake.detach(), t_next_in, cond_pos, gradient_to_input=False)
182
+ logits_real = disc(x_next_real.detach(), t_next_in, cond_pos, gradient_to_input=False)
183
+
184
+ # hinge: real → +1、fake → -1
185
+ loss_real = sum(F.relu(1.0 - l).mean() for l in logits_real) / max(len(logits_real), 1)
186
+ loss_fake = sum(F.relu(1.0 + l).mean() for l in logits_fake) / max(len(logits_fake), 1)
187
+ loss_d = loss_real + loss_fake
188
+
189
+ metrics = {
190
+ "l_d_real": loss_real.detach(),
191
+ "l_d_fake": loss_fake.detach(),
192
+ "l_d_total": loss_d.detach(),
193
+ "t_mean": t.mean().detach(),
194
+ }
195
+ return loss_d, metrics
scripts/distill/hps_reward.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HPSv2 (Human Preference Score v2) を differentiable reward として使うラッパ。
3
+
4
+ 公式 hpsv2 package は score を file path 経由でしか返さないので、
5
+ 内部の OpenCLIP H/14 + custom checkpoint を直接ロードして grad-through 化する。
6
+
7
+ Repo: https://github.com/tgxs002/HPSv2
8
+ Weights: HPS_v2_compressed.pt (1.97 GB、HF Space xswu/HPSv2)
9
+ """
10
+ from __future__ import annotations
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+
18
+
19
+ class HPSv2Reward(nn.Module):
20
+ """HPSv2 differentiable reward (higher is better)。
21
+
22
+ forward(images, captions) → (B,) reward
23
+ images: (B, 3, H, W) in [-1, 1] (CLIP preprocessing は内部で実施)
24
+ captions: list[str]
25
+ """
26
+ def __init__(self, weights_path: str, device="cuda", dtype=torch.float32):
27
+ super().__init__()
28
+ import open_clip
29
+ self.device = device
30
+ self.dtype = dtype
31
+ # OpenCLIP ViT-H/14 を laion2b プリトレで初期化
32
+ self.model, _, self.preprocess = open_clip.create_model_and_transforms(
33
+ "ViT-H-14", pretrained="laion2b_s32b_b79k", device=device,
34
+ )
35
+ self.tokenizer = open_clip.get_tokenizer("ViT-H-14")
36
+ # HPSv2 の preference-finetuned weights を上書き
37
+ if weights_path and os.path.exists(weights_path):
38
+ sd = torch.load(weights_path, map_location=device, weights_only=False)
39
+ if "state_dict" in sd:
40
+ sd = sd["state_dict"]
41
+ # remove 'module.' prefix if present
42
+ sd = {k.replace("module.", ""): v for k, v in sd.items()}
43
+ missing, unexpected = self.model.load_state_dict(sd, strict=False)
44
+ print(f"[hpsv2] loaded weights: missing={len(missing)} unexpected={len(unexpected)}")
45
+ else:
46
+ print(f"[hpsv2] WARNING: weights_path '{weights_path}' not found, using OpenCLIP baseline only")
47
+
48
+ # freeze
49
+ for p in self.model.parameters():
50
+ p.requires_grad = False
51
+ self.model.eval()
52
+
53
+ # CLIP normalization 定数
54
+ self.register_buffer("mean", torch.tensor([0.48145466, 0.4578275, 0.40821073]).view(1, 3, 1, 1))
55
+ self.register_buffer("std", torch.tensor([0.26862954, 0.26130258, 0.27577711]).view(1, 3, 1, 1))
56
+ # device / dtype を明示的に移動 (register_buffer は default CPU なので必須)
57
+ self.to(device=device, dtype=dtype)
58
+
59
+ def preprocess_images(self, images: torch.Tensor) -> torch.Tensor:
60
+ """images (B, 3, H, W) in [-1, 1] → 224 bicubic + CLIP normalize、grad-through。"""
61
+ # [-1, 1] → [0, 1]
62
+ x = (images + 1.0) / 2.0
63
+ x = x.clamp(0.0, 1.0)
64
+ # resize to 224
65
+ x = F.interpolate(x, size=(224, 224), mode="bicubic", align_corners=False, antialias=True)
66
+ # normalize
67
+ x = (x - self.mean) / self.std
68
+ return x
69
+
70
+ def score(self, images: torch.Tensor, captions: list[str]) -> torch.Tensor:
71
+ """grad-through HPSv2 score。"""
72
+ img = self.preprocess_images(images.to(self.device, dtype=self.dtype))
73
+ with torch.no_grad():
74
+ tokens = self.tokenizer(captions).to(self.device)
75
+ # text features (no grad)
76
+ with torch.no_grad():
77
+ text_feats = self.model.encode_text(tokens)
78
+ text_feats = text_feats / text_feats.norm(dim=-1, keepdim=True)
79
+ # image features (grad on)
80
+ image_feats = self.model.encode_image(img)
81
+ image_feats = image_feats / image_feats.norm(dim=-1, keepdim=True)
82
+ # cosine similarity scaled (HPSv2 uses temperature)
83
+ logit_scale = self.model.logit_scale.exp().detach()
84
+ scores = (image_feats * text_feats).sum(dim=-1) * logit_scale
85
+ return scores
scripts/distill/pcm_scheduler.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PCM (Phased Consistency Model) 用 Euler solver + phase snap
3
+ =========================================================
4
+
5
+ Reference: G-U-N/Phased-Consistency-Model
6
+ code/text_to_image_sd3/pcm_fm_deterministic_scheduler.py
7
+
8
+ Anima は rectified flow なので SD3 用 PCM (FlowMatchEulerDiscrete) と math 完全一致。
9
+ sigma == t、x_t = sigma*noise + (1-sigma)*x0、v = noise - x0、x_prev = x_t + dt*v
10
+ """
11
+ from __future__ import annotations
12
+ from dataclasses import dataclass
13
+
14
+ import torch
15
+ import numpy as np
16
+
17
+
18
+ @dataclass
19
+ class PCMEulerSolver:
20
+ """num_euler_timesteps の grid (1.0 → 0.0) と、K phase の境界 index を保持。
21
+
22
+ Attributes:
23
+ sigmas: (N+1,) tensor、t=1 → 0 (= sigma_max → 0)
24
+ sigmas_prev: (N,) tensor、各 sigmas[i+1]
25
+ phase_ends: (K,) int tensor、各 phase の **終端 sigmas index**
26
+ """
27
+ sigmas: torch.Tensor # (N+1,)
28
+ sigmas_prev: torch.Tensor # (N,)
29
+ phase_ends: torch.Tensor # (K,) ints in [1, N]
30
+
31
+ @property
32
+ def num_steps(self) -> int:
33
+ return len(self.sigmas_prev)
34
+
35
+
36
+ def make_pcm_solver(
37
+ num_euler_timesteps: int = 50,
38
+ num_phases: int = 4,
39
+ sigma_shift: float = 3.0,
40
+ device=None, dtype=torch.float32,
41
+ ) -> PCMEulerSolver:
42
+ """linear schedule + sigmoid shift。K 個に等分割。
43
+ phase_ends は各 phase の終端 sigma の **index** (= 次 phase の 開始 sigma index と一致)。"""
44
+ from .traj_scheduler import sigmoid_shift
45
+ ts = torch.linspace(1.0, 0.0, num_euler_timesteps + 1, dtype=torch.float64)
46
+ ts = sigmoid_shift(ts, sigma_shift)
47
+ sigmas = ts.to(device=device, dtype=dtype)
48
+ sigmas_prev = sigmas[1:]
49
+ # phase boundaries: np.linspace(0, N, K, endpoint=False) → starts
50
+ # 終端は次 phase の開始 - 1。最後の phase の終端は N (clean)。
51
+ starts = np.linspace(0, num_euler_timesteps, num_phases, endpoint=False, dtype=int)
52
+ ends = list(starts[1:]) + [num_euler_timesteps] # K 個
53
+ phase_ends = torch.tensor(ends, dtype=torch.long, device=device)
54
+ return PCMEulerSolver(sigmas=sigmas, sigmas_prev=sigmas_prev, phase_ends=phase_ends)
55
+
56
+
57
+ def euler_step_pcm(
58
+ x: torch.Tensor, v: torch.Tensor,
59
+ sigma_cur: torch.Tensor, sigma_next: torch.Tensor,
60
+ ) -> torch.Tensor:
61
+ """rectified-flow Euler 1 step: x_next = x + (sigma_next - sigma_cur) * v
62
+ (sigma 減少方向、v は noise - x0)"""
63
+ dt = (sigma_next - sigma_cur).view(-1, *([1] * (x.dim() - 1)))
64
+ return x + dt * v
65
+
66
+
67
+ def euler_multiphase(
68
+ x_t: torch.Tensor, v: torch.Tensor, index: torch.Tensor,
69
+ solver: PCMEulerSolver,
70
+ ) -> tuple[torch.Tensor, torch.Tensor]:
71
+ """index で指定した時点 sigma から、その所属 phase の終端 sigma まで 1-step Euler。
72
+
73
+ Args:
74
+ x_t: (B, ...)
75
+ v: (B, ...) velocity prediction
76
+ index: (B,) long、各サンプルの現在の sigmas grid index
77
+ solver: PCMEulerSolver
78
+ Returns:
79
+ x_at_phase_end: (B, ...)
80
+ sigma_at_phase_end: (B,)
81
+ """
82
+ B = x_t.size(0)
83
+ device = x_t.device
84
+ # 各 sample が属する phase の終端 index を引く
85
+ phase_ends = solver.phase_ends # (K,)
86
+ # index → phase index: searchsorted で「index 以上の最初の phase_end」
87
+ # ただし PCM 流は「index <= phase_end - 1 を満たす最小 phase_end」
88
+ # ここでは現在 sigmas[index] からその phase の sigmas[phase_end] まで進める
89
+ # = sigma_cur = sigmas[index], sigma_target = sigmas[phase_end]
90
+ idx_np = index.detach().cpu().numpy()
91
+ end_np = phase_ends.detach().cpu().numpy()
92
+ target_end = []
93
+ for ix in idx_np:
94
+ # 最初に phase_end >= ix+1 になる phase を選ぶ (= 現 phase の終端)
95
+ end = end_np[end_np > ix][0] if (end_np > ix).any() else end_np[-1]
96
+ target_end.append(int(end))
97
+ target_end_t = torch.tensor(target_end, dtype=torch.long, device=device)
98
+
99
+ sigma_cur = solver.sigmas[index] # (B,)
100
+ sigma_target = solver.sigmas[target_end_t] # (B,)
101
+ x_next = euler_step_pcm(x_t, v, sigma_cur, sigma_target)
102
+ return x_next, sigma_target
103
+
104
+
105
+ def pseudo_huber(diff: torch.Tensor, c: float = 1e-3) -> torch.Tensor:
106
+ """sqrt(diff^2 + c^2) - c。PCM 公式の robust loss。"""
107
+ return torch.sqrt(diff.float() ** 2 + c * c) - c
scripts/distill/precompute_teacher_x0.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Precompute teacher x0 cache for AMD LADD training.
4
+
5
+ For each caption in the dataset:
6
+ 1. Sample fresh noise
7
+ 2. Run teacher (= base Anima, no LoRA) for N steps with CFG=W
8
+ 3. Save final x0 (decoded latent stays in latent space) as .pt
9
+ 4. Save caption embedding too (avoid re-encoding during training)
10
+
11
+ Output structure:
12
+ /dataset/teacher_x0_cache/
13
+ metadata.json [{caption, x0_path, emb_path}, ...]
14
+ x0/0001.pt (B=1, 16, 1, H_lat, W_lat) bf16
15
+ emb/0001.pt (B=1, 512, 1024) bf16
16
+
17
+ LADD train ループはこの cache を読むだけで、teacher を都度 forward しないので
18
+ 1 step あたりの時間を半分以下に。
19
+ """
20
+ from __future__ import annotations
21
+ import argparse
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import torch
27
+ from torch.utils.data import DataLoader
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
30
+ from distill.anima_loader import build_anima, AnimaBundle
31
+ from distill.train_traj import TextOnlyDataset, text_collate
32
+
33
+
34
+ @torch.no_grad()
35
+ def teacher_rollout(
36
+ bundle: AnimaBundle,
37
+ cond_pos: torch.Tensor,
38
+ cond_neg: torch.Tensor,
39
+ H_lat: int, W_lat: int,
40
+ num_steps: int = 20,
41
+ cfg_scale: float = 4.5,
42
+ sigma_shift: float = 3.0,
43
+ device=None, dtype=torch.bfloat16,
44
+ init_noise: torch.Tensor | None = None,
45
+ ) -> tuple[torch.Tensor, torch.Tensor]:
46
+ """teacher (= base Anima、frozen) を N step Euler で回して x0 を返す。
47
+ sigma_shift は Anima 公式 workflow 推奨 3.0。
48
+ init_noise を渡せば外で seed 管理可能。返り値は (x0_final, noise_used)。"""
49
+ from distill.traj_scheduler import make_schedule
50
+ sched = make_schedule(num_steps, sigma_shift, device=device, dtype=torch.float32)
51
+ ts = sched.timesteps # (N+1,) t=1 → 0
52
+ B = cond_pos.size(0)
53
+ if init_noise is None:
54
+ init_noise = torch.randn(B, 16, 1, H_lat, W_lat, device=device, dtype=dtype)
55
+ x = init_noise.clone()
56
+ for i in range(num_steps):
57
+ t_cur = ts[i].expand(B).to(dtype=dtype)
58
+ v_cond = bundle.velocity(x, t_cur, cond_pos)
59
+ if cfg_scale > 1.0:
60
+ v_uncond = bundle.velocity(x, t_cur, cond_neg)
61
+ v = v_uncond + cfg_scale * (v_cond - v_uncond)
62
+ else:
63
+ v = v_cond
64
+ dt = (ts[i + 1] - ts[i]).to(device=device, dtype=dtype)
65
+ x = x + dt * v
66
+ return x, init_noise
67
+
68
+
69
+ def main():
70
+ ap = argparse.ArgumentParser()
71
+ ap.add_argument("--dataset", required=True, type=str, help="caption dir")
72
+ ap.add_argument("--out", required=True, type=str, help="cache output dir")
73
+ ap.add_argument("--num-steps", type=int, default=20)
74
+ ap.add_argument("--cfg-scale", type=float, default=4.5)
75
+ ap.add_argument("--sigma-shift", type=float, default=3.0)
76
+ ap.add_argument("--resolution", type=int, default=768)
77
+ ap.add_argument("--max-samples", type=int, default=-1, help="-1 = all")
78
+ ap.add_argument("--neg-prompt", default="")
79
+ ap.add_argument("--start-from", type=int, default=0, help="resume index")
80
+ ap.add_argument("--save-noise", action="store_true",
81
+ help="initial noise も保存 (Reflow 用 (noise, x0) pair に必須)")
82
+ ap.add_argument("--metadata-out", default="",
83
+ help="metadata 出力先 (省略時は <out>/metadata.json)。"
84
+ "並列実行時に worker ごと別ファイルにして race を回避するため。")
85
+ args = ap.parse_args()
86
+
87
+ device = torch.device("cuda")
88
+ dtype = torch.bfloat16
89
+
90
+ out_dir = Path(args.out)
91
+ (out_dir / "x0").mkdir(parents=True, exist_ok=True)
92
+ (out_dir / "emb").mkdir(parents=True, exist_ok=True)
93
+ if args.save_noise:
94
+ (out_dir / "noise").mkdir(parents=True, exist_ok=True)
95
+
96
+ print("[load] Anima bundle (teacher only)")
97
+ bundle = build_anima(device=device, dtype=dtype)
98
+
99
+ print(f"[data] {args.dataset}")
100
+ ds = TextOnlyDataset(args.dataset)
101
+ N = len(ds) if args.max_samples < 0 else min(len(ds), args.max_samples)
102
+ print(f" precomputing {N - args.start_from} samples (resolution={args.resolution})")
103
+
104
+ H_lat = args.resolution // 8
105
+ W_lat = args.resolution // 8
106
+
107
+ with torch.no_grad():
108
+ cond_neg = bundle.text_encode([args.neg_prompt or ""])
109
+
110
+ metadata = []
111
+ metadata_path = Path(args.metadata_out) if args.metadata_out else out_dir / "metadata.json"
112
+ metadata_path.parent.mkdir(parents=True, exist_ok=True)
113
+ if metadata_path.exists() and args.start_from > 0:
114
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
115
+
116
+ import time
117
+ t_start = time.time()
118
+ for i in range(args.start_from, N):
119
+ caption = ds[i]
120
+ with torch.no_grad():
121
+ cond_pos = bundle.text_encode([caption])
122
+ x0, noise = teacher_rollout(
123
+ bundle, cond_pos, cond_neg, H_lat, W_lat,
124
+ num_steps=args.num_steps, cfg_scale=args.cfg_scale,
125
+ sigma_shift=args.sigma_shift, device=device, dtype=dtype,
126
+ )
127
+ x0_p = out_dir / "x0" / f"{i:05d}.pt"
128
+ emb_p = out_dir / "emb" / f"{i:05d}.pt"
129
+ torch.save(x0.cpu(), x0_p)
130
+ torch.save(cond_pos.cpu(), emb_p)
131
+ entry = {"caption": caption, "x0_path": str(x0_p), "emb_path": str(emb_p)}
132
+ if args.save_noise:
133
+ noise_p = out_dir / "noise" / f"{i:05d}.pt"
134
+ torch.save(noise.cpu(), noise_p)
135
+ entry["noise_path"] = str(noise_p)
136
+ metadata.append(entry)
137
+
138
+ if i % 50 == 0 or i == N - 1:
139
+ metadata_path.write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8")
140
+ elapsed = time.time() - t_start
141
+ rate = (i - args.start_from + 1) / max(elapsed, 1e-3)
142
+ eta = (N - i - 1) / max(rate, 1e-3)
143
+ print(f"[precompute {i}/{N}] rate={rate:.2f}/s eta={eta/60:.1f}min", flush=True)
144
+ try:
145
+ import modal
146
+ modal.Volume.from_name("anima-dataset").commit()
147
+ except Exception:
148
+ pass
149
+
150
+ metadata_path.write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8")
151
+ print(f"[done] {len(metadata)} samples cached to {out_dir}")
152
+
153
+
154
+ if __name__ == "__main__":
155
+ main()
scripts/distill/r3gan_disc.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ R3GAN (NeurIPS 2024) discriminator + losses (RpGAN + R1 + R2)。
3
+
4
+ Anima 用に latent-space で動かす:
5
+ - 入力: VAE latents (B, 16, T, H_lat, W_lat) — T=1 (静止画)
6
+ - 条件: Qwen3+LLMAdapter の crossattn_emb を平均プールして projection D
7
+ - 出力: scalar critic value per sample
8
+
9
+ Normalization 不使用 (R3GAN の "modernized GAN" の要)、MSR init、
10
+ activation gain で variance を解析的に維持。
11
+ """
12
+ from __future__ import annotations
13
+ import math
14
+ from typing import Optional
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+
20
+
21
+ def msr_init(weight: torch.Tensor, gain: float = 1.0) -> None:
22
+ """MSR (He) initialization, fan-in 基準。"""
23
+ fan_in = weight.shape[1] * weight[0][0].numel()
24
+ std = gain / math.sqrt(fan_in)
25
+ with torch.no_grad():
26
+ weight.normal_(0, std)
27
+
28
+
29
+ class BiasedActivation(nn.Module):
30
+ """Leaky ReLU + 学習可能 bias。R3GAN の活性化代替。"""
31
+
32
+ def __init__(self, channels: int, negative_slope: float = 0.2):
33
+ super().__init__()
34
+ self.bias = nn.Parameter(torch.zeros(channels))
35
+ self.negative_slope = negative_slope
36
+
37
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
38
+ return F.leaky_relu(x + self.bias.view(1, -1, *([1] * (x.dim() - 2))),
39
+ self.negative_slope, inplace=True)
40
+
41
+
42
+ class ResidualBlock(nn.Module):
43
+ """ConvNeXt-style: 1x1 expand -> grouped depthwise -> 1x1 project, no norm."""
44
+
45
+ def __init__(self, channels: int, expansion: int = 2, cardinality: int = 1, kernel: int = 3):
46
+ super().__init__()
47
+ hidden = channels * expansion
48
+ groups = max(1, hidden // cardinality) # depthwise-ish grouping
49
+
50
+ self.expand = nn.Conv2d(channels, hidden, kernel_size=1, bias=False)
51
+ self.dw = nn.Conv2d(hidden, hidden, kernel_size=kernel, padding=kernel // 2,
52
+ groups=groups, bias=False)
53
+ self.project = nn.Conv2d(hidden, channels, kernel_size=1, bias=False)
54
+
55
+ self.act = BiasedActivation(hidden)
56
+
57
+ # MSR init, project は ActivationGain=0 で残差ゼロ初期化(安定)
58
+ msr_init(self.expand.weight, gain=1.0)
59
+ msr_init(self.dw.weight, gain=1.0)
60
+ nn.init.zeros_(self.project.weight)
61
+
62
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
63
+ h = self.expand(x)
64
+ h = self.act(h)
65
+ h = self.dw(h)
66
+ h = self.act(h)
67
+ h = self.project(h)
68
+ return x + h
69
+
70
+
71
+ class DownsampleLayer(nn.Module):
72
+ """[1,2,1] bilinear / anti-aliased downsample by factor 2."""
73
+
74
+ def __init__(self):
75
+ super().__init__()
76
+ kernel = torch.tensor([1.0, 2.0, 1.0]) / 4.0
77
+ kernel = kernel[:, None] * kernel[None, :] # (3, 3)
78
+ self.register_buffer("kernel", kernel[None, None].repeat(1, 1, 1, 1))
79
+
80
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
81
+ b, c, h, w = x.shape
82
+ k = self.kernel.expand(c, 1, 3, 3)
83
+ return F.conv2d(x, k, stride=2, padding=1, groups=c)
84
+
85
+
86
+ class DiscriminatorStage(nn.Module):
87
+ """[ResidualBlock × N] + Downsample"""
88
+
89
+ def __init__(self, channels: int, num_blocks: int = 2, downsample: bool = True):
90
+ super().__init__()
91
+ self.blocks = nn.Sequential(*[ResidualBlock(channels) for _ in range(num_blocks)])
92
+ self.down = DownsampleLayer() if downsample else nn.Identity()
93
+
94
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
95
+ return self.down(self.blocks(x))
96
+
97
+
98
+ class R3GANDiscriminator(nn.Module):
99
+ """
100
+ Anima latent-space text-conditional discriminator。
101
+
102
+ Args:
103
+ latent_channels: VAE latent channels (Anima: 16)
104
+ widths: stage 毎の channel 数 (粗→細の解像度遷移とは逆順)
105
+ blocks_per_stage: 各 stage の ResidualBlock 数
106
+ cond_dim: text embedding 次元(LLMAdapter 出力次元 = 1024)
107
+ cond_embedding_dim: projection D の中間次元
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ latent_channels: int = 16,
113
+ widths: tuple[int, ...] = (128, 256, 512, 768),
114
+ blocks_per_stage: int = 2,
115
+ cond_dim: int = 1024,
116
+ cond_embedding_dim: int = 512,
117
+ ):
118
+ super().__init__()
119
+ # Extraction: latent -> initial channels
120
+ self.extract = nn.Conv2d(latent_channels, widths[0], kernel_size=1, bias=False)
121
+ msr_init(self.extract.weight, gain=1.0)
122
+
123
+ stages = []
124
+ for i, w in enumerate(widths):
125
+ next_w = widths[i + 1] if i + 1 < len(widths) else w
126
+ stages.append(DiscriminatorStage(w, num_blocks=blocks_per_stage,
127
+ downsample=(i < len(widths) - 1)))
128
+ if i < len(widths) - 1 and next_w != w:
129
+ # channel 変換 (1x1)
130
+ stages.append(nn.Conv2d(w, next_w, kernel_size=1, bias=False))
131
+ msr_init(stages[-1].weight, gain=1.0)
132
+ self.stages = nn.Sequential(*stages)
133
+
134
+ # Discriminative basis: 空間集約 -> per-sample feature vec
135
+ # 入力サイズによっては adaptive pool
136
+ self.spatial_pool = nn.AdaptiveAvgPool2d(4)
137
+ self.final_conv = nn.Conv2d(widths[-1], cond_embedding_dim, kernel_size=4, bias=False)
138
+ msr_init(self.final_conv.weight, gain=1.0)
139
+
140
+ # Projection conditioning (Miyato & Koyama 2018)
141
+ self.cond_proj = nn.Linear(cond_dim, cond_embedding_dim, bias=False)
142
+ msr_init(self.cond_proj.weight, gain=1.0 / math.sqrt(cond_embedding_dim))
143
+
144
+ def forward(self, latents: torch.Tensor, cond: torch.Tensor) -> torch.Tensor:
145
+ """
146
+ latents: (B, 16, T, H_lat, W_lat) — T=1 想定なので squeeze
147
+ cond: (B, N_tokens, cond_dim) — 平均プールして使う
148
+ returns: (B,) critic value
149
+ """
150
+ if latents.dim() == 5:
151
+ assert latents.shape[2] == 1, "static-image distillation: T must be 1"
152
+ latents = latents.squeeze(2) # (B, 16, H, W)
153
+
154
+ # weights dtype に揃える(generator 出力が float32 で来る場合あり)
155
+ w_dtype = next(self.parameters()).dtype
156
+ latents = latents.to(dtype=w_dtype)
157
+ cond = cond.to(dtype=w_dtype)
158
+
159
+ h = self.extract(latents)
160
+ h = self.stages(h)
161
+ h = self.spatial_pool(h)
162
+ feat = self.final_conv(h).flatten(1) # (B, cond_embedding_dim)
163
+
164
+ # text embedding 平均プール -> projection
165
+ cond_mean = cond.mean(dim=1) # (B, cond_dim)
166
+ cond_emb = self.cond_proj(cond_mean) # (B, cond_embedding_dim)
167
+
168
+ # projection-D logit = <feat, cond_emb>
169
+ logit = (feat * cond_emb).sum(dim=1) # (B,)
170
+ return logit
171
+
172
+
173
+ # ---------- losses (RpGAN + R1 + R2) ----------------------------------------
174
+
175
+ def zero_centered_grad_penalty(samples: torch.Tensor, logits: torch.Tensor) -> torch.Tensor:
176
+ """∑(∂D/∂x)^2 per sample."""
177
+ grads, = torch.autograd.grad(
178
+ outputs=logits.sum(), inputs=samples, create_graph=True,
179
+ )
180
+ return grads.flatten(1).square().sum(dim=1)
181
+
182
+
183
+ def d_loss_r3gan(
184
+ d_real_logits: torch.Tensor,
185
+ d_fake_logits: torch.Tensor,
186
+ real_samples: torch.Tensor,
187
+ fake_samples: torch.Tensor,
188
+ gamma: float = 50.0,
189
+ ) -> tuple[torch.Tensor, dict]:
190
+ """Discriminator loss = RpGAN softplus + (γ/2)(R1 + R2)."""
191
+ adv = F.softplus(-(d_real_logits - d_fake_logits)).mean()
192
+ r1 = zero_centered_grad_penalty(real_samples, d_real_logits).mean()
193
+ r2 = zero_centered_grad_penalty(fake_samples, d_fake_logits).mean()
194
+ loss = adv + 0.5 * gamma * (r1 + r2)
195
+ return loss, {"d_adv": adv.detach(), "d_r1": r1.detach(), "d_r2": r2.detach()}
196
+
197
+
198
+ def g_loss_r3gan(d_real_logits: torch.Tensor, d_fake_logits: torch.Tensor) -> torch.Tensor:
199
+ """Generator loss (relativistic): softplus(-(D(fake) - D(real)))."""
200
+ return F.softplus(-(d_fake_logits - d_real_logits)).mean()
scripts/distill/shortcut_module.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shortcut Models 用の d-head injection。
3
+
4
+ 設計:
5
+ - transformer.d_head: nn.Linear(1 → cond_dim) (zero-init、初期は影響ゼロ)
6
+ - transformer の t_embedder 出力に hook で d_head(d) を加算
7
+ - 推論/訓練時に transformer._current_d = d を set してから forward
8
+ """
9
+ from __future__ import annotations
10
+ import torch
11
+ import torch.nn as nn
12
+
13
+
14
+ def find_t_embedder(transformer: nn.Module) -> tuple[str, nn.Module]:
15
+ """transformer 内部の t_embedder を発見。短い path を優先。"""
16
+ candidates = []
17
+ for name, module in transformer.named_modules():
18
+ n = name.lower()
19
+ if ("t_embedder" in n or "time_embedder" in n or "timestep_embedder" in n) and \
20
+ ("llm_adapter" not in name): # llm_adapter 内の embedder は除外
21
+ candidates.append((name, module))
22
+ if not candidates:
23
+ # fallback: find by "time" in name (more inclusive)
24
+ for name, module in transformer.named_modules():
25
+ if "time" in name.lower() and "llm_adapter" not in name and \
26
+ isinstance(module, (nn.Linear, nn.Sequential, nn.Module)) and \
27
+ len(list(module.parameters())) > 0:
28
+ candidates.append((name, module))
29
+ if not candidates:
30
+ raise RuntimeError("Could not find t_embedder module")
31
+ # shortest path
32
+ candidates.sort(key=lambda x: len(x[0].split(".")))
33
+ name, module = candidates[0]
34
+ print(f"[shortcut] found t_embedder at: {name}")
35
+ return name, module
36
+
37
+
38
+ def _detect_t_embed_dim(transformer: nn.Module, t_embedder: nn.Module) -> int:
39
+ """t_embedder の output dim を dummy forward で推定。"""
40
+ device = next(t_embedder.parameters()).device
41
+ dtype = next(t_embedder.parameters()).dtype
42
+ with torch.no_grad():
43
+ # 多くの実装は (B,) or (B,1) の timestep を受ける
44
+ for shape in [(1,), (1, 1)]:
45
+ try:
46
+ t_in = torch.tensor([[0.5]] if len(shape) == 2 else [0.5],
47
+ device=device, dtype=dtype)
48
+ out = t_embedder(t_in)
49
+ if isinstance(out, tuple):
50
+ out = out[0]
51
+ return out.shape[-1]
52
+ except Exception:
53
+ continue
54
+ raise RuntimeError("Could not detect t_embedder output dim")
55
+
56
+
57
+ def attach_shortcut_d_head(transformer: nn.Module) -> nn.Module:
58
+ """transformer に d_head を attach、t_embedder 出力に hook で加算する。
59
+ Returns: transformer (mutated)。`transformer._current_d` を set してから forward。"""
60
+ name, t_emb = find_t_embedder(transformer)
61
+ cond_dim = _detect_t_embed_dim(transformer, t_emb)
62
+ print(f"[shortcut] t_embedder output dim: {cond_dim}")
63
+
64
+ # d_head: 1 → cond_dim → cond_dim (small MLP, zero-init last layer)
65
+ device = next(t_emb.parameters()).device
66
+ dtype = next(t_emb.parameters()).dtype
67
+ d_head = nn.Sequential(
68
+ nn.Linear(1, cond_dim),
69
+ nn.SiLU(),
70
+ nn.Linear(cond_dim, cond_dim),
71
+ ).to(device=device, dtype=dtype)
72
+ # zero-init last linear so initial output is 0
73
+ nn.init.zeros_(d_head[-1].weight)
74
+ nn.init.zeros_(d_head[-1].bias)
75
+
76
+ transformer.d_head = d_head
77
+ transformer._current_d = None # set per-forward
78
+
79
+ def _hook(module, input, output):
80
+ d = getattr(transformer, "_current_d", None)
81
+ if d is None:
82
+ return output
83
+ # output shape: (B, cond_dim) or (B, 1, cond_dim) etc
84
+ d_in = d.view(-1, 1).to(device=output.device if hasattr(output, 'device') else next(d_head.parameters()).device,
85
+ dtype=next(d_head.parameters()).dtype)
86
+ d_emb = d_head(d_in) # (B, cond_dim)
87
+ # broadcast to match output shape
88
+ if isinstance(output, tuple):
89
+ base = output[0]
90
+ d_emb_b = d_emb.view(base.size(0), *([1] * (base.dim() - 2)), base.size(-1)) if base.dim() > 2 else d_emb
91
+ return (base + d_emb_b.to(dtype=base.dtype),) + output[1:]
92
+ else:
93
+ d_emb_b = d_emb.view(output.size(0), *([1] * (output.dim() - 2)), output.size(-1)) if output.dim() > 2 else d_emb
94
+ return output + d_emb_b.to(dtype=output.dtype)
95
+
96
+ handle = t_emb.register_forward_hook(_hook)
97
+ transformer._d_head_hook_handle = handle
98
+
99
+ return transformer
100
+
101
+
102
+ def set_shortcut_d(transformer: nn.Module, d: torch.Tensor | None):
103
+ """forward 前に d を set。d=None で d-head 効果無効。"""
104
+ transformer._current_d = d
105
+
106
+
107
+ def shortcut_d_head_params(transformer: nn.Module) -> list[nn.Parameter]:
108
+ """d_head の trainable params (LoRA とは別の optimizer に渡す用)。"""
109
+ if hasattr(transformer, "d_head"):
110
+ return list(transformer.d_head.parameters())
111
+ return []
scripts/distill/sid_loss.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SiD2 / SiD-DiT 流派 (Score Identity Distillation) for Anima
3
+
4
+ Reference:
5
+ - arXiv 2404.04057 (original SiD)
6
+ - arXiv 2509.25127 (SiD-DiT、RF + TrigFlow native 適応)
7
+ - github.com/mingyuanzhou/SiD-LSG (SD 用 reference)
8
+
9
+ 特徴:
10
+ - **data-free**: caption だけあれば画像不要
11
+ - critic 不要 (D 自体が存在しない)
12
+ - EMA 不要
13
+ - 2 LoRA pattern (generator + fake_score) を DMD2 と共有
14
+ - SiD identity formula で **mean collapse をスコア空間で防ぐ**
15
+ - RF (rectified flow) を直接サポート (teacher の再学習不要)
16
+
17
+ Generator loss (SiD-DiT, Eq. paper 2509.25127):
18
+ w(t) = (1 - t)
19
+ x_g = student rollout (n-step, last-step grad only)
20
+ x_t = (1-t) * x_g + t * eps
21
+ x0_φ = teacher(x_t, t, c) [+ CFG]
22
+ x0_ψ = fake_score(x_t, t, c)
23
+
24
+ diff_main = x0_φ - x0_ψ ← SiD core
25
+ diff_corr = x0_ψ - x_g ← identity correction
26
+ L_θ = (1-α) * w(t) * ||x_g - x0_φ||²
27
+ + w(t) * <diff_main, diff_corr>
28
+ (per-sample mean over spatial dims)
29
+
30
+ Score helper loss (flow-matching MSE):
31
+ L_ψ = ||fake_score(x_t, t, c) - x_g.detach()||² ← simple distillation of x_g distribution
32
+ """
33
+ from __future__ import annotations
34
+ from typing import Callable
35
+
36
+ import torch
37
+ import torch.nn.functional as F
38
+
39
+ from .anima_loader import AnimaBundle
40
+
41
+
42
+ # ----- RF helpers ----------------------------------------------------------
43
+ def renoise_rf(x0: torch.Tensor, t_rf: torch.Tensor, noise: torch.Tensor) -> torch.Tensor:
44
+ """rectified flow forward: x_t = (1-t)*x0 + t*noise"""
45
+ t_ = t_rf.view(-1, *([1] * (x0.dim() - 1)))
46
+ return (1.0 - t_) * x0 + t_ * noise
47
+
48
+
49
+ def x0_from_velocity_rf(x_t: torch.Tensor, v: torch.Tensor, t_rf: torch.Tensor) -> torch.Tensor:
50
+ """x0 = x_t - t * v (v = noise - x0)"""
51
+ t_ = t_rf.view(-1, *([1] * (x_t.dim() - 1)))
52
+ return x_t - t_ * v
53
+
54
+
55
+ # ----- time sampler --------------------------------------------------------
56
+ def sample_logit_normal_t(B: int, mu: float = 0.6931, sigma: float = 1.6,
57
+ device=None, dtype=torch.float32) -> torch.Tensor:
58
+ """LogitNormal(mu=ln 2, sigma=1.6) on t ∈ (0, 1)。
59
+ SiD-DiT 流の time sampler、mid-noise (t≈0.66) に分布が集中。"""
60
+ z = torch.randn(B, device=device, dtype=dtype) * sigma + mu
61
+ t = torch.sigmoid(z) # (0, 1)
62
+ return t.clamp(1e-3, 1.0 - 1e-3)
63
+
64
+
65
+ # ----- few-step student rollout (DMD2 と同じパターン) ----------------------
66
+ def backward_simulation_grad_last(
67
+ student_v_fn: Callable[..., torch.Tensor],
68
+ noise: torch.Tensor,
69
+ n_steps: int,
70
+ cond_pos: torch.Tensor,
71
+ cond_neg: torch.Tensor | None,
72
+ cfg_scale: float = 1.0,
73
+ ) -> torch.Tensor:
74
+ """t=1 → t=0 へ Euler n_steps、grad は last step のみ。"""
75
+ from .traj_loss import cfg_guided, _broadcast_t
76
+ B = noise.size(0)
77
+ device = noise.device
78
+ dtype = noise.dtype
79
+ ts = torch.linspace(1.0, 0.0, n_steps + 1, device=device, dtype=torch.float32)
80
+ x = noise
81
+ for i in range(n_steps):
82
+ t_cur, t_next = ts[i], ts[i + 1]
83
+ is_last = (i == n_steps - 1)
84
+ ctx = torch.enable_grad() if is_last else torch.no_grad()
85
+ t_in = _broadcast_t(t_cur, B, device, dtype)
86
+ with ctx:
87
+ v = cfg_guided(student_v_fn, x, t_in, cond_pos, cond_neg, cfg_scale)
88
+ dt = (t_next - t_cur).to(device=device, dtype=dtype)
89
+ x = x + dt * v
90
+ return x
91
+
92
+
93
+ # ----- generator loss ------------------------------------------------------
94
+ def sid_generator_loss(
95
+ student_v_fn: Callable,
96
+ teacher_v_fn: Callable,
97
+ fake_score_v_fn: Callable,
98
+ init_noise: torch.Tensor,
99
+ cond_pos: torch.Tensor,
100
+ cond_neg: torch.Tensor | None,
101
+ teacher_cfg: float = 4.5,
102
+ student_cfg: float = 1.0,
103
+ n_steps: int = 4,
104
+ alpha: float = 1.2,
105
+ mu_t: float = 0.6931,
106
+ sigma_t: float = 1.6,
107
+ ) -> tuple[torch.Tensor, dict]:
108
+ """SiD-DiT generator loss。data-free、Anima RF native。"""
109
+ B = init_noise.size(0)
110
+ device = init_noise.device
111
+ dtype = init_noise.dtype
112
+
113
+ # 1) student rollout
114
+ x_g = backward_simulation_grad_last(
115
+ student_v_fn, init_noise, n_steps, cond_pos, cond_neg,
116
+ cfg_scale=student_cfg,
117
+ )
118
+
119
+ # 2) re-noise at sampled t
120
+ t = sample_logit_normal_t(B, mu_t, sigma_t, device, dtype=torch.float32)
121
+ D_eps = torch.randn_like(x_g)
122
+ x_t = renoise_rf(x_g, t, D_eps)
123
+
124
+ # 3) teacher x0 (CFG) and fake_score x0 (no_grad both)
125
+ with torch.no_grad():
126
+ t_in = t.to(dtype=dtype)
127
+ v_t_cond = teacher_v_fn(x_t, t_in, cond_pos)
128
+ if teacher_cfg > 1.0 and cond_neg is not None:
129
+ v_t_uncond = teacher_v_fn(x_t, t_in, cond_neg)
130
+ v_teacher = v_t_uncond + teacher_cfg * (v_t_cond - v_t_uncond)
131
+ else:
132
+ v_teacher = v_t_cond
133
+ x0_phi = x0_from_velocity_rf(x_t, v_teacher, t)
134
+
135
+ v_fake = fake_score_v_fn(x_t, t_in, cond_pos)
136
+ x0_psi = x0_from_velocity_rf(x_t, v_fake, t)
137
+
138
+ # 4) SiD-DiT fused loss
139
+ # w(t) = (1 - t)、per-sample weight
140
+ w = (1.0 - t).clamp(min=0.05).view(-1, *([1] * (x_g.dim() - 1)))
141
+ diff_main = (x0_phi - x0_psi).detach()
142
+ diff_corr = (x0_psi - x_g).detach()
143
+ # term 1: baseline match (mean reverse-KL-like)
144
+ # ||x_g - x0_phi||² の grad は x_g を x0_phi に引き寄せる
145
+ term_baseline = ((x_g - x0_phi.detach()).float() ** 2)
146
+ # term 2: identity correction (negative for "push away from fake_score")
147
+ # <x_g - (x_g - identity_grad)> として書き直すと DMD2 trick と同じ形
148
+ # ここでは直接 SiD-DiT 表式: w * <diff_main, diff_corr> (定数項なので grad は 0)
149
+ # 実装上は generator が ψ - x_g 方向に動くよう、x_g に grad を流す:
150
+ grad_signal = (diff_main * diff_corr).detach() # 形状 (B, ...) 、generator に伝わる勾配は diff_main 方向
151
+ term_correction = - (x_g * (diff_main.detach())).float() # 内積の grad part (∂/∂x_g)
152
+
153
+ L_theta = (
154
+ (1.0 - alpha) * (w * term_baseline).mean()
155
+ + (w * term_correction).mean()
156
+ )
157
+
158
+ metrics = {
159
+ "l_sid_gen": L_theta.detach(),
160
+ "t_mean": t.mean(),
161
+ "x_g_abs": x_g.detach().abs().mean(),
162
+ "x0_phi_abs": x0_phi.abs().mean(),
163
+ "x0_psi_abs": x0_psi.abs().mean(),
164
+ "diff_main_abs": diff_main.abs().mean(),
165
+ "w_mean": w.mean(),
166
+ }
167
+ return L_theta, metrics
168
+
169
+
170
+ # ----- score helper loss (fake_score の更新) -------------------------------
171
+ def sid_score_helper_loss(
172
+ student_v_fn: Callable,
173
+ fake_score_v_fn: Callable,
174
+ init_noise: torch.Tensor,
175
+ cond_pos: torch.Tensor,
176
+ cond_neg: torch.Tensor | None,
177
+ student_cfg: float = 1.0,
178
+ n_steps: int = 4,
179
+ mu_t: float = 0.6931,
180
+ sigma_t: float = 1.6,
181
+ ) -> tuple[torch.Tensor, dict]:
182
+ """fake_score を student 分布の denoiser として学習。
183
+ student 側は no_grad、fake_score 側に grad。"""
184
+ B = init_noise.size(0)
185
+ device = init_noise.device
186
+ dtype = init_noise.dtype
187
+
188
+ # 1) student rollout (no_grad)
189
+ with torch.no_grad():
190
+ x_g = backward_simulation_grad_last(
191
+ # all no_grad: 内部の last-step grad はもとから no_grad コンテキストでマスク
192
+ student_v_fn, init_noise, n_steps, cond_pos, cond_neg,
193
+ cfg_scale=student_cfg,
194
+ )
195
+
196
+ # 2) re-noise
197
+ t = sample_logit_normal_t(B, mu_t, sigma_t, device, dtype=torch.float32)
198
+ D_eps = torch.randn_like(x_g)
199
+ x_t = renoise_rf(x_g, t, D_eps)
200
+
201
+ # 3) fake_score x0 prediction (grad on)
202
+ t_in = t.to(dtype=dtype)
203
+ v_psi = fake_score_v_fn(x_t, t_in, cond_pos)
204
+ x0_psi = x0_from_velocity_rf(x_t, v_psi, t)
205
+
206
+ # 4) plain MSE to x_g (denoising target)
207
+ L_psi = F.mse_loss(x0_psi.float(), x_g.detach().float())
208
+
209
+ return L_psi, {"l_sid_psi": L_psi.detach()}
scripts/distill/train_dmd2_official.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Anima DMD2 distillation (NVIDIA cosmos-predict2.5 official 流派)
4
+ ==============================================================
5
+
6
+ 特徴:
7
+ - 同じ base 2B DiT に **2 つの独立 PEFT adapter** を attach (student / fake_score)
8
+ - peft.PeftModel.add_adapter() + set_adapter() で runtime 切替
9
+ - Alternating: critic × N_critic (=5) → generator × 1
10
+ - few-step student rollout (1-4 step)、grad は最終 step だけ通す (memory efficient)
11
+ - teacher は frozen base (no LoRA、deepcopy で別 instance)
12
+
13
+ 要点:
14
+ - student / fake_score の grad は **独立した optimizer** で更新
15
+ - 各 step 開始時に明示的に set_adapter() で切替
16
+ - teacher は別 module、active adapter 概念とは無関係
17
+
18
+ 使い方 (Modal):
19
+ # Smoke (1 outer step、cycle=5 critic+1 gen)
20
+ modal run modal_app.py::train_dmd2_official_distill \\
21
+ --total-outer-steps 1 --resolution 768 --n-student-steps 4
22
+
23
+ # 本番 (5000 outer step、~$21)
24
+ modal run --detach modal_app.py::train_dmd2_official_distill \\
25
+ --total-outer-steps 5000 --resolution 768 --warm-lora /models/loras/anima_turbo.safetensors
26
+ """
27
+ from __future__ import annotations
28
+ import argparse
29
+ import copy
30
+ import json
31
+ import os
32
+ import sys
33
+ import time
34
+ from pathlib import Path
35
+
36
+ import torch
37
+ import peft
38
+ from torch.utils.data import DataLoader
39
+ from safetensors.torch import save_file, load_file
40
+
41
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
42
+ from distill.anima_loader import AnimaPaths, build_anima, AnimaBundle
43
+ from distill.dmd2_trainer import attach_wide_lora
44
+ from distill.dmd2_official_loss import (
45
+ dmd2_generator_loss, dmd2_critic_loss,
46
+ )
47
+ from distill.train_traj import (
48
+ TextOnlyDataset, text_collate,
49
+ convert_comfy_to_peft_lora, save_lora_state,
50
+ )
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # 2 adapter 管理ヘルパー
55
+ # ---------------------------------------------------------------------------
56
+ def attach_dual_lora(
57
+ transformer: torch.nn.Module,
58
+ rank: int = 32,
59
+ adapter_names: tuple[str, str] = ("student", "fake_score"),
60
+ ):
61
+ """同じ base DiT に 2 つの独立 LoRA adapter を attach する。
62
+ PEFT は LoraConfig + adapter_name で複数 adapter を保持できる。
63
+ set_adapter(name) で active adapter を切替。
64
+ """
65
+ # まず 1 つ目の adapter を attach (peft.get_peft_model がベース wrap)
66
+ target_modules = []
67
+ for name, module in transformer.named_modules():
68
+ if not isinstance(module, torch.nn.Linear):
69
+ continue
70
+ if "llm_adapter" in name:
71
+ continue
72
+ target_modules.append(name)
73
+ print(f"[lora] wide target: {len(target_modules)} linear modules")
74
+
75
+ cfg = peft.LoraConfig(
76
+ r=rank, lora_alpha=rank, lora_dropout=0.0, bias="none",
77
+ target_modules=target_modules,
78
+ )
79
+ peft_model = peft.get_peft_model(transformer, cfg, adapter_name=adapter_names[0])
80
+ # 2 つ目を追加
81
+ peft_model.add_adapter(adapter_names[1], cfg)
82
+ print(f"[lora] attached adapters: {adapter_names}")
83
+ return peft_model
84
+
85
+
86
+ def make_velocity_fn(peft_model, adapter_name: str):
87
+ """指定 adapter を active にして dit_forward する callable を返す。"""
88
+ def _v(x, t, cond):
89
+ # 毎 forward で adapter を確実に切替 (PEFT は内部で全モジュールに反映)
90
+ peft_model.set_adapter(adapter_name)
91
+ return AnimaBundle.dit_forward(peft_model, x, t, cond)
92
+ return _v
93
+
94
+
95
+ def make_teacher_velocity_fn(teacher_transformer):
96
+ """frozen teacher 用 (LoRA なし、別 module instance)。"""
97
+ def _v(x, t, cond):
98
+ return AnimaBundle.dit_forward(teacher_transformer, x, t, cond)
99
+ return _v
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # main
104
+ # ---------------------------------------------------------------------------
105
+ def main():
106
+ ap = argparse.ArgumentParser()
107
+ ap.add_argument("--dataset", required=True, type=str)
108
+ ap.add_argument("--out", required=True, type=str)
109
+ ap.add_argument("--warm-lora", default="", type=str,
110
+ help="Civitai Anima Turbo LoRA (ComfyUI 形式) を student adapter 初期値に")
111
+ ap.add_argument("--total-outer-steps", type=int, default=5000,
112
+ help="1 outer step = N_critic + 1 generator")
113
+ ap.add_argument("--n-critic-per-gen", type=int, default=5)
114
+ ap.add_argument("--n-student-steps", type=int, default=4,
115
+ help="few-step rollout の step 数 (1-4)、1=single-step distill")
116
+ ap.add_argument("--batch-size", type=int, default=1)
117
+ ap.add_argument("--resolution", type=int, default=768)
118
+ ap.add_argument("--teacher-cfg", type=float, default=3.0)
119
+ ap.add_argument("--student-cfg", type=float, default=1.0)
120
+ ap.add_argument("--shift", type=float, default=5.0,
121
+ help="shifted uniform sampling shift (high-noise 側に偏らせる)")
122
+ ap.add_argument("--lora-rank", type=int, default=32)
123
+ ap.add_argument("--lr-gen", type=float, default=5e-6)
124
+ ap.add_argument("--lr-critic", type=float, default=1e-5)
125
+ ap.add_argument("--weight-decay", type=float, default=0.01)
126
+ ap.add_argument("--grad-clip", type=float, default=1.0)
127
+ ap.add_argument("--log-every", type=int, default=10)
128
+ ap.add_argument("--sample-every", type=int, default=500)
129
+ ap.add_argument("--num-workers", type=int, default=2)
130
+ ap.add_argument("--seed", type=int, default=42)
131
+ ap.add_argument("--neg-prompt", default="")
132
+ args = ap.parse_args()
133
+
134
+ torch.manual_seed(args.seed)
135
+ device = torch.device("cuda")
136
+ dtype = torch.bfloat16
137
+
138
+ out_dir = Path(args.out)
139
+ out_dir.mkdir(parents=True, exist_ok=True)
140
+
141
+ # ----- load Anima base -----
142
+ print("[load] Anima bundle")
143
+ bundle = build_anima(device=device, dtype=dtype)
144
+
145
+ # ----- teacher = deepcopy (frozen, no LoRA) -----
146
+ print("[setup] deepcopy DiT for teacher (frozen, no LoRA)")
147
+ teacher_transformer = copy.deepcopy(bundle.transformer).to(device=device, dtype=dtype).eval()
148
+ for p in teacher_transformer.parameters():
149
+ p.requires_grad = False
150
+
151
+ # ----- 元 DiT (= bundle.transformer) に 2 adapter を attach -----
152
+ print("[setup] dual-adapter on base DiT (student + fake_score)")
153
+ peft_model = attach_dual_lora(bundle.transformer, rank=args.lora_rank,
154
+ adapter_names=("student", "fake_score"))
155
+ peft_model.to(device=device, dtype=dtype)
156
+ # base 凍結、LoRA だけ trainable
157
+ for n, p in peft_model.named_parameters():
158
+ p.requires_grad = ("lora_" in n)
159
+ bundle.transformer = peft_model
160
+
161
+ # 各 adapter の trainable params を log
162
+ student_params = [p for n, p in peft_model.named_parameters() if p.requires_grad and ".student." in n]
163
+ fake_params = [p for n, p in peft_model.named_parameters() if p.requires_grad and ".fake_score." in n]
164
+ print(f"[setup] student trainable: {sum(p.numel() for p in student_params)/1e6:.1f}M")
165
+ print(f"[setup] fake_score trainable: {sum(p.numel() for p in fake_params)/1e6:.1f}M")
166
+
167
+ # warm-start: Civitai Anima Turbo を student adapter に注入
168
+ if args.warm_lora:
169
+ print(f"[warm] loading {args.warm_lora} → student adapter")
170
+ # PEFT の 2-adapter 形式: key = base_model.model.<...>.lora_A.<adapter_name>.weight
171
+ sd_comfy = load_file(args.warm_lora)
172
+ sd_peft_default = convert_comfy_to_peft_lora(sd_comfy) # adapter_name="default" 想定
173
+ # "default" → "student" にリネーム
174
+ sd_student = {}
175
+ for k, v in sd_peft_default.items():
176
+ nk = k.replace(".lora_A.default.weight", ".lora_A.student.weight")
177
+ nk = nk.replace(".lora_B.default.weight", ".lora_B.student.weight")
178
+ sd_student[nk] = v
179
+ # filter shape mismatch
180
+ model_sd = peft_model.state_dict()
181
+ to_load = {k: v.to(dtype=model_sd[k].dtype) for k, v in sd_student.items()
182
+ if k in model_sd and model_sd[k].shape == v.shape}
183
+ print(f"[warm] matched {len(to_load)}/{len(sd_student)} keys")
184
+ peft_model.load_state_dict(to_load, strict=False)
185
+
186
+ # ----- optimizers (student / critic 独立) -----
187
+ opt_gen = torch.optim.AdamW(student_params, lr=args.lr_gen, betas=(0.9, 0.999),
188
+ weight_decay=args.weight_decay, eps=1e-8)
189
+ opt_critic = torch.optim.AdamW(fake_params, lr=args.lr_critic, betas=(0.9, 0.999),
190
+ weight_decay=args.weight_decay, eps=1e-8)
191
+
192
+ # ----- velocity functions -----
193
+ student_v = make_velocity_fn(peft_model, "student")
194
+ fake_score_v = make_velocity_fn(peft_model, "fake_score")
195
+ teacher_v = make_teacher_velocity_fn(teacher_transformer)
196
+
197
+ # ----- dataset -----
198
+ print(f"[data] loading {args.dataset}")
199
+ dataset = TextOnlyDataset(args.dataset)
200
+ print(f" {len(dataset)} captions")
201
+ loader = DataLoader(
202
+ dataset, batch_size=args.batch_size, shuffle=True,
203
+ num_workers=args.num_workers, collate_fn=text_collate, drop_last=True,
204
+ )
205
+
206
+ # ----- conditioning preencoded once for negative -----
207
+ with torch.no_grad():
208
+ cond_neg = bundle.text_encode([args.neg_prompt or ""])
209
+
210
+ H_lat = args.resolution // 8
211
+ W_lat = args.resolution // 8
212
+
213
+ # ----- training loop -----
214
+ print(f"[train] outer={args.total_outer_steps} cycle=({args.n_critic_per_gen} critic + 1 gen) "
215
+ f"n_student_steps={args.n_student_steps} res={args.resolution}")
216
+ log_path = out_dir / "dmd2_log.jsonl"
217
+ log_f = open(log_path, "a", buffering=1)
218
+ t0 = time.time()
219
+ data_iter = iter(loader)
220
+
221
+ def _next_batch():
222
+ nonlocal data_iter
223
+ try:
224
+ return next(data_iter)
225
+ except StopIteration:
226
+ data_iter = iter(loader)
227
+ return next(data_iter)
228
+
229
+ def _sample_inputs():
230
+ captions = _next_batch()
231
+ B = len(captions)
232
+ with torch.no_grad():
233
+ cond_pos = bundle.text_encode(captions)
234
+ cond_neg_b = cond_neg.expand(B, -1, -1).contiguous() if B > 1 else cond_neg
235
+ noise = torch.randn(B, 16, 1, H_lat, W_lat, device=device, dtype=dtype)
236
+ return noise, cond_pos, cond_neg_b
237
+
238
+ for outer in range(args.total_outer_steps):
239
+ # ---- N critic updates ----
240
+ critic_metrics = {}
241
+ for _ in range(args.n_critic_per_gen):
242
+ noise, cond_pos, cond_neg_b = _sample_inputs()
243
+ peft_model.train()
244
+ loss_c, m_c = dmd2_critic_loss(
245
+ student_v, fake_score_v, noise, cond_pos, cond_neg_b,
246
+ student_cfg=args.student_cfg, n_steps=args.n_student_steps,
247
+ shift=args.shift,
248
+ )
249
+ opt_critic.zero_grad()
250
+ loss_c.backward()
251
+ torch.nn.utils.clip_grad_norm_(fake_params, args.grad_clip)
252
+ opt_critic.step()
253
+ critic_metrics = {k: float(v) for k, v in m_c.items()}
254
+
255
+ # ---- 1 generator update ----
256
+ noise, cond_pos, cond_neg_b = _sample_inputs()
257
+ peft_model.train()
258
+ loss_g, m_g = dmd2_generator_loss(
259
+ student_v, teacher_v, fake_score_v, noise, cond_pos, cond_neg_b,
260
+ teacher_cfg=args.teacher_cfg, student_cfg=args.student_cfg,
261
+ n_steps=args.n_student_steps, shift=args.shift,
262
+ )
263
+ opt_gen.zero_grad()
264
+ loss_g.backward()
265
+ torch.nn.utils.clip_grad_norm_(student_params, args.grad_clip)
266
+ opt_gen.step()
267
+ gen_metrics = {k: float(v) for k, v in m_g.items()}
268
+
269
+ # ---- log ----
270
+ if outer % args.log_every == 0:
271
+ metrics = {"outer": outer, "elapsed": time.time() - t0,
272
+ **critic_metrics, **gen_metrics}
273
+ log_f.write(json.dumps(metrics) + "\n")
274
+ msg = " ".join(f"{k}={v:.4f}" for k, v in metrics.items() if k not in ("outer",))
275
+ print(f"[outer {outer}/{args.total_outer_steps}] {msg}", flush=True)
276
+
277
+ # ---- checkpoint (student adapter のみ保存) ----
278
+ if outer > 0 and outer % args.sample_every == 0:
279
+ # student adapter のみを抽出
280
+ sd = {k: v.detach().cpu() for k, v in peft_model.state_dict().items()
281
+ if "lora_" in k and ".student." in k}
282
+ from safetensors.torch import save_file as _sf
283
+ (out_dir).mkdir(parents=True, exist_ok=True)
284
+ _sf(sd, str(out_dir / f"dmd2_student_step{outer:05d}.safetensors"))
285
+ print(f"[save] dmd2_student_step{outer:05d}.safetensors", flush=True)
286
+ try:
287
+ import modal
288
+ modal.Volume.from_name("anima-outputs").commit()
289
+ except Exception as e:
290
+ print(f"[save] volume commit failed: {e}", flush=True)
291
+
292
+ # final
293
+ print("[done] saving final adapters")
294
+ sd_student_final = {k: v.detach().cpu() for k, v in peft_model.state_dict().items()
295
+ if "lora_" in k and ".student." in k}
296
+ from safetensors.torch import save_file as _sf
297
+ _sf(sd_student_final, str(out_dir / "dmd2_student_final.safetensors"))
298
+ sd_fake_final = {k: v.detach().cpu() for k, v in peft_model.state_dict().items()
299
+ if "lora_" in k and ".fake_score." in k}
300
+ _sf(sd_fake_final, str(out_dir / "dmd2_fake_score_final.safetensors"))
301
+ log_f.close()
302
+
303
+
304
+ if __name__ == "__main__":
305
+ main()
scripts/distill/train_dmdx.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Anima DMDX (ADM-only) 蒸留 — arxiv 2507.18569v1 移植
4
+ =====================================================
5
+
6
+ Pipeline:
7
+ - student: wide LoRA on Anima base (DMD2 と同じ wide target、~72M trainable)
8
+ - discriminator: LADD-style (teacher MiniTrainDIT frozen backbone + spectral norm heads)
9
+ - real: teacher_x0_cache の x0 を流用
10
+ - ADM loss: hinge GAN at t-Δt after teacher 1-step evolution
11
+ - time schedule: cubic high-noise bias (paper default)
12
+ - alternation: N_critic × disc → 1 × generator (DMD2 風)
13
+
14
+ DMD2 との違い:
15
+ - DMD2: dual adapter (student + fake_score)、reverse-KL grad trick
16
+ - DMDX: single student LoRA + 別 D heads、hinge GAN、TVD 最小化
17
+
18
+ オプション:
19
+ - --recon-weight > 0 で LADD 流 Smooth-L1 anchor 追加 (mean collapse 防止)
20
+ - --misaligned-pairs-d : LADD 流の text-alignment trick
21
+
22
+ Modal CLI 例:
23
+ modal run modal_app.py::train_dmdx_distill \\
24
+ --warm-lora /models/loras/anima_turbo.safetensors \\
25
+ --total-outer-steps 5000
26
+ """
27
+ from __future__ import annotations
28
+ import argparse
29
+ import copy
30
+ import json
31
+ import os
32
+ import sys
33
+ import time
34
+ from pathlib import Path
35
+
36
+ import torch
37
+ from torch.utils.data import DataLoader
38
+
39
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
40
+ from distill.anima_loader import AnimaPaths, build_anima, AnimaBundle
41
+ from distill.dmd2_trainer import attach_wide_lora
42
+ from distill.train_traj import save_lora_state
43
+ from distill.train_ladd import PrecomputedCacheDataset, ladd_collate
44
+ from distill.anima_ladd_disc import AnimaLADDDiscriminator
45
+ from distill.dmdx_loss import adm_generator_loss, adm_discriminator_loss
46
+
47
+
48
+ def main():
49
+ ap = argparse.ArgumentParser()
50
+ ap.add_argument("--cache-dir", required=True, type=str,
51
+ help="teacher_x0_cache (real x0 source)")
52
+ ap.add_argument("--out", required=True, type=str)
53
+ ap.add_argument("--warm-lora", default="", type=str)
54
+ ap.add_argument("--total-outer-steps", type=int, default=5000,
55
+ help="1 outer = N_critic + 1 generator")
56
+ ap.add_argument("--n-critic-per-gen", type=int, default=2,
57
+ help="DMDX paper: hinge GAN は 1:1〜2:1 が一般的 (DMD2 の 5:1 より少ない)")
58
+ ap.add_argument("--n-student-steps", type=int, default=4,
59
+ help="few-step rollout step 数")
60
+ ap.add_argument("--batch-size", type=int, default=1)
61
+ ap.add_argument("--resolution", type=int, default=768)
62
+ ap.add_argument("--teacher-cfg", type=float, default=4.5,
63
+ help="Anima 公式推奨 CFG")
64
+ ap.add_argument("--student-cfg", type=float, default=1.0)
65
+ ap.add_argument("--dt-ratio", type=float, default=1.0 / 64,
66
+ help="paper default Δt = T/64")
67
+ ap.add_argument("--recon-weight", type=float, default=0.0,
68
+ help="LADD 流 Smooth-L1 anchor (>0 で有効化、stability boost)")
69
+ ap.add_argument("--lora-rank", type=int, default=32)
70
+ ap.add_argument("--lr-gen", type=float, default=5e-6)
71
+ ap.add_argument("--lr-disc", type=float, default=1e-5)
72
+ ap.add_argument("--weight-decay", type=float, default=0.01)
73
+ ap.add_argument("--grad-clip", type=float, default=1.0)
74
+ ap.add_argument("--block-ids", type=str, default="2,8,14,20,26",
75
+ help="teacher block indices for D hooks")
76
+ ap.add_argument("--head-hidden", type=int, default=512)
77
+ ap.add_argument("--log-every", type=int, default=10)
78
+ ap.add_argument("--sample-every", type=int, default=500)
79
+ ap.add_argument("--num-workers", type=int, default=2)
80
+ ap.add_argument("--neg-prompt", default="")
81
+ ap.add_argument("--seed", type=int, default=42)
82
+ args = ap.parse_args()
83
+
84
+ torch.manual_seed(args.seed)
85
+ device = torch.device("cuda")
86
+ dtype = torch.bfloat16
87
+ out_dir = Path(args.out)
88
+ out_dir.mkdir(parents=True, exist_ok=True)
89
+
90
+ # ----- load Anima base -----
91
+ print("[load] Anima bundle")
92
+ bundle = build_anima(device=device, dtype=dtype)
93
+
94
+ # ----- teacher = deepcopy frozen -----
95
+ print("[setup] teacher = frozen deepcopy")
96
+ teacher_transformer = copy.deepcopy(bundle.transformer).to(device=device, dtype=dtype).eval()
97
+ for p in teacher_transformer.parameters():
98
+ p.requires_grad = False
99
+
100
+ # ----- student = wide LoRA on bundle.transformer -----
101
+ print("[setup] student = wide LoRA")
102
+ student_transformer = attach_wide_lora(bundle.transformer, rank=args.lora_rank)
103
+ student_transformer.to(device=device, dtype=dtype)
104
+ for n, p in student_transformer.named_parameters():
105
+ p.requires_grad = ("lora_" in n)
106
+ student_params = [p for p in student_transformer.parameters() if p.requires_grad]
107
+ print(f"[setup] student trainable: {sum(p.numel() for p in student_params)/1e6:.1f}M")
108
+ bundle.transformer = student_transformer
109
+
110
+ # warm-start
111
+ if args.warm_lora:
112
+ from distill.train_traj import load_warm_lora
113
+ load_warm_lora(student_transformer, args.warm_lora)
114
+
115
+ # ----- discriminator (LADD-style) -----
116
+ print("[setup] DMDX discriminator (teacher backbone + spectral heads)")
117
+ block_ids = [int(x) for x in args.block_ids.split(",")]
118
+ disc = AnimaLADDDiscriminator(
119
+ teacher_transformer=teacher_transformer,
120
+ block_ids=block_ids, head_hidden=args.head_hidden,
121
+ )
122
+ H_lat = args.resolution // 8
123
+ W_lat = args.resolution // 8
124
+ dummy_x = torch.randn(1, 16, 1, H_lat, W_lat, device=device, dtype=dtype)
125
+ dummy_t = torch.tensor([0.5], device=device, dtype=dtype)
126
+ with torch.no_grad():
127
+ dummy_cond = bundle.text_encode([""])
128
+ disc.lazy_init_heads(dummy_x, dummy_t, dummy_cond)
129
+ disc.to(device=device, dtype=torch.float32)
130
+ disc_params = disc.trainable_parameters()
131
+ print(f"[setup] D heads trainable: {sum(p.numel() for p in disc_params)/1e6:.1f}M")
132
+
133
+ # ----- optimizers -----
134
+ opt_gen = torch.optim.AdamW(student_params, lr=args.lr_gen, betas=(0.0, 0.999),
135
+ weight_decay=args.weight_decay, eps=1e-8)
136
+ opt_disc = torch.optim.AdamW(disc_params, lr=args.lr_disc, betas=(0.0, 0.999),
137
+ weight_decay=args.weight_decay, eps=1e-8)
138
+
139
+ # ----- velocity functions -----
140
+ def student_v(x, t, cond):
141
+ return AnimaBundle.dit_forward(student_transformer, x, t, cond)
142
+
143
+ def teacher_v(x, t, cond):
144
+ return AnimaBundle.dit_forward(teacher_transformer, x, t, cond)
145
+
146
+ # ----- conditioning (neg prompt cached once) -----
147
+ with torch.no_grad():
148
+ cond_neg = bundle.text_encode([args.neg_prompt or ""])
149
+
150
+ # ----- dataset (precompute cache provides real x0 + caption emb) -----
151
+ print(f"[data] loading cache {args.cache_dir}")
152
+ dataset = PrecomputedCacheDataset(args.cache_dir)
153
+ print(f" {len(dataset)} cached samples")
154
+ loader = DataLoader(
155
+ dataset, batch_size=args.batch_size, shuffle=True,
156
+ num_workers=args.num_workers, collate_fn=ladd_collate, drop_last=True,
157
+ pin_memory=True,
158
+ )
159
+
160
+ # ----- training loop -----
161
+ print(f"[train] outer={args.total_outer_steps} "
162
+ f"cycle=({args.n_critic_per_gen} disc + 1 gen) "
163
+ f"n_student_steps={args.n_student_steps} dt={args.dt_ratio:.4f}")
164
+ log_path = out_dir / "dmdx_log.jsonl"
165
+ log_f = open(log_path, "a", buffering=1)
166
+ t0 = time.time()
167
+ data_iter = iter(loader)
168
+
169
+ def _next():
170
+ nonlocal data_iter
171
+ try:
172
+ return next(data_iter)
173
+ except StopIteration:
174
+ data_iter = iter(loader)
175
+ return next(data_iter)
176
+
177
+ def _prep_batch():
178
+ batch = _next()
179
+ x0_teacher = batch["x0"].to(device=device, dtype=dtype)
180
+ cond_pos = batch["emb"].to(device=device, dtype=dtype)
181
+ B = x0_teacher.size(0)
182
+ cond_neg_b = cond_neg.expand(B, -1, -1).contiguous() if B > 1 else cond_neg
183
+ noise = torch.randn_like(x0_teacher)
184
+ return noise, x0_teacher, cond_pos, cond_neg_b
185
+
186
+ for outer in range(args.total_outer_steps):
187
+ # ---- N disc updates ----
188
+ disc_metrics = {}
189
+ for _ in range(args.n_critic_per_gen):
190
+ noise, x0_teacher, cond_pos, cond_neg_b = _prep_batch()
191
+ disc.train()
192
+ student_transformer.eval()
193
+ opt_disc.zero_grad()
194
+ loss_d, m_d = adm_discriminator_loss(
195
+ student_v, teacher_v, disc,
196
+ noise, x0_teacher, cond_pos, cond_neg_b,
197
+ teacher_cfg=args.teacher_cfg, student_cfg=args.student_cfg,
198
+ n_student_steps=args.n_student_steps, dt_ratio=args.dt_ratio,
199
+ )
200
+ loss_d.backward()
201
+ torch.nn.utils.clip_grad_norm_(disc_params, args.grad_clip)
202
+ opt_disc.step()
203
+ disc_metrics = {k: float(v) for k, v in m_d.items()}
204
+
205
+ # ---- 1 generator update ----
206
+ noise, x0_teacher, cond_pos, cond_neg_b = _prep_batch()
207
+ student_transformer.train()
208
+ disc.eval()
209
+ opt_gen.zero_grad()
210
+ loss_g, m_g = adm_generator_loss(
211
+ student_v, teacher_v, disc,
212
+ noise, cond_pos, cond_neg_b,
213
+ real_x0=x0_teacher,
214
+ teacher_cfg=args.teacher_cfg, student_cfg=args.student_cfg,
215
+ n_student_steps=args.n_student_steps, dt_ratio=args.dt_ratio,
216
+ recon_weight=args.recon_weight,
217
+ )
218
+ loss_g.backward()
219
+ torch.nn.utils.clip_grad_norm_(student_params, args.grad_clip)
220
+ opt_gen.step()
221
+ gen_metrics = {k: float(v) for k, v in m_g.items()}
222
+
223
+ # ---- log ----
224
+ if outer % args.log_every == 0:
225
+ metrics = {"outer": outer, "elapsed": time.time() - t0,
226
+ **disc_metrics, **gen_metrics}
227
+ log_f.write(json.dumps(metrics) + "\n")
228
+ msg = " ".join(f"{k}={v:.4f}" for k, v in metrics.items() if k != "outer")
229
+ print(f"[outer {outer}/{args.total_outer_steps}] {msg}", flush=True)
230
+
231
+ # ---- ckpt (student LoRA only) ----
232
+ if outer > 0 and outer % args.sample_every == 0:
233
+ save_lora_state(student_transformer, out_dir, f"dmdx_student_step{outer:05d}")
234
+ print(f"[save] dmdx_student_step{outer:05d}.safetensors", flush=True)
235
+ try:
236
+ import modal
237
+ modal.Volume.from_name("anima-outputs").commit()
238
+ except Exception as e:
239
+ print(f"[save] volume commit failed: {e}", flush=True)
240
+
241
+ # final
242
+ print("[done] saving final")
243
+ save_lora_state(student_transformer, out_dir, "dmdx_student_final")
244
+ log_f.close()
245
+
246
+
247
+ if __name__ == "__main__":
248
+ main()
scripts/distill/train_draftp.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Anima DRaFT+ / AlignProp training with HPSv2 reward
4
+ ====================================================
5
+
6
+ これは **品質向上** (速度向上ではない) 用の蒸留 LoRA fine-tuning。
7
+ 既に蒸留された student LoRA を warm-start として、HPSv2 (Human Preference Score)
8
+ を maximize する方向に追加学習する。
9
+
10
+ Algorithm (DRaFT-K LV with KL regularization):
11
+ 1. caption → cond_pos (no_grad)
12
+ 2. init noise → student で N step rollout
13
+ - 前 N-K step: no_grad
14
+ - 後 K step: grad on (K=1 が paper の best)
15
+ 3. final x0 → VAE decode (grad on) → image
16
+ 4. reward = HPSv2(image, prompt)
17
+ 5. KL term: ||v_pred - v_pred_base||² (LoRA disable して frozen base v_pred と比較)
18
+ 6. loss = -reward + kl_coeff * KL
19
+ """
20
+ from __future__ import annotations
21
+ import argparse
22
+ import copy
23
+ import json
24
+ import os
25
+ import sys
26
+ import time
27
+ from pathlib import Path
28
+
29
+ import torch
30
+ import torch.nn.functional as F
31
+ from torch.utils.data import DataLoader
32
+
33
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
34
+ from distill.anima_loader import build_anima, AnimaBundle
35
+ from distill.dmd2_trainer import attach_wide_lora
36
+ from distill.train_traj import (
37
+ TextOnlyDataset, text_collate, load_warm_lora, save_lora_state,
38
+ )
39
+ from distill.traj_scheduler import make_schedule
40
+
41
+
42
+ def vae_decode_with_grad(bundle: AnimaBundle, latents: torch.Tensor) -> torch.Tensor:
43
+ """grad を通す VAE decode (anima_loader の vae_decode は @no_grad なので別実装)。"""
44
+ vae_dtype = next(bundle.vae.model.parameters()).dtype
45
+ latents = latents.to(dtype=vae_dtype)
46
+ return bundle.vae.model.decode(latents, bundle.vae_scale)
47
+
48
+
49
+ def student_rollout_with_truncation(
50
+ student_v_fn,
51
+ base_v_fn_for_kl,
52
+ init_noise: torch.Tensor,
53
+ schedule_ts: torch.Tensor, # (N+1,)
54
+ cond_pos: torch.Tensor,
55
+ student_cfg: float,
56
+ K: int,
57
+ capture_kl: bool = True,
58
+ ) -> tuple[torch.Tensor, torch.Tensor]:
59
+ """rollout、最後の K step で grad on。KL 項用に最後の v_pred も別途 base で評価。
60
+
61
+ Returns: (x0_final, kl_loss)
62
+ """
63
+ B = init_noise.size(0)
64
+ device = init_noise.device
65
+ dtype = init_noise.dtype
66
+ N = len(schedule_ts) - 1
67
+ truncate_idx = N - K
68
+
69
+ x = init_noise
70
+ kl_loss = torch.zeros((), device=device)
71
+
72
+ for i in range(N):
73
+ t_cur = schedule_ts[i].expand(B).to(dtype=dtype)
74
+ t_next = schedule_ts[i + 1]
75
+ is_grad_step = (i >= truncate_idx)
76
+ ctx = torch.enable_grad() if is_grad_step else torch.no_grad()
77
+ with ctx:
78
+ v = student_v_fn(x, t_cur, cond_pos)
79
+ if is_grad_step and capture_kl and i == N - 1:
80
+ # 最後の step で KL term: student LoRA v vs frozen base v
81
+ with torch.no_grad():
82
+ v_base = base_v_fn_for_kl(x, t_cur, cond_pos)
83
+ kl_loss = ((v - v_base.detach()).float() ** 2).mean()
84
+ dt = (t_next - schedule_ts[i]).to(device=device, dtype=dtype)
85
+ x = x + dt * v
86
+ return x, kl_loss
87
+
88
+
89
+ def main():
90
+ ap = argparse.ArgumentParser()
91
+ ap.add_argument("--dataset", required=True, type=str,
92
+ help="caption-only dir (画像不要)")
93
+ ap.add_argument("--out", required=True, type=str)
94
+ ap.add_argument("--warm-lora", required=True, type=str,
95
+ help="必須: 既に蒸留された student LoRA (例 ① Z-Image の出力)")
96
+ ap.add_argument("--hps-weights", default="/models/hpsv2/HPS_v2_compressed.pt",
97
+ help="HPSv2 weights path、無ければ OpenCLIP baseline fallback")
98
+ ap.add_argument("--total-steps", type=int, default=1500)
99
+ ap.add_argument("--batch-size", type=int, default=2)
100
+ ap.add_argument("--grad-accum", type=int, default=1)
101
+ ap.add_argument("--n-student-steps", type=int, default=8)
102
+ ap.add_argument("--K", type=int, default=1, help="gradient truncation depth, paper best=1")
103
+ ap.add_argument("--n-lv-samples", type=int, default=2,
104
+ help="DRaFT-LV: extra noise samples at last step, averaged")
105
+ ap.add_argument("--resolution", type=int, default=768)
106
+ ap.add_argument("--student-cfg", type=float, default=1.0)
107
+ ap.add_argument("--sigma-shift", type=float, default=3.0)
108
+ ap.add_argument("--lr", type=float, default=1e-4)
109
+ ap.add_argument("--kl-coeff", type=float, default=0.2,
110
+ help="NeMo DRaFT+ default、reward hacking 防止")
111
+ ap.add_argument("--lora-rank", type=int, default=32)
112
+ ap.add_argument("--grad-clip", type=float, default=1.0)
113
+ ap.add_argument("--log-every", type=int, default=5)
114
+ ap.add_argument("--sample-every", type=int, default=200)
115
+ ap.add_argument("--num-workers", type=int, default=2)
116
+ ap.add_argument("--seed", type=int, default=42)
117
+ args = ap.parse_args()
118
+
119
+ torch.manual_seed(args.seed)
120
+ device = torch.device("cuda")
121
+ dtype = torch.bfloat16
122
+ out_dir = Path(args.out)
123
+ out_dir.mkdir(parents=True, exist_ok=True)
124
+
125
+ print("[load] Anima bundle")
126
+ bundle = build_anima(device=device, dtype=dtype)
127
+
128
+ # base = frozen deepcopy (KL term の reference として使う)
129
+ print("[setup] base = frozen deepcopy (for KL reference)")
130
+ base_transformer = copy.deepcopy(bundle.transformer).to(device=device, dtype=dtype).eval()
131
+ for p in base_transformer.parameters():
132
+ p.requires_grad = False
133
+
134
+ # student = wide LoRA
135
+ student_transformer = attach_wide_lora(bundle.transformer, rank=args.lora_rank)
136
+ student_transformer.to(device=device, dtype=dtype)
137
+ for n, p in student_transformer.named_parameters():
138
+ p.requires_grad = ("lora_" in n)
139
+ student_params = [p for p in student_transformer.parameters() if p.requires_grad]
140
+ print(f"[setup] student trainable: {sum(p.numel() for p in student_params)/1e6:.1f}M")
141
+ bundle.transformer = student_transformer
142
+
143
+ # warm-start 必須
144
+ load_warm_lora(student_transformer, args.warm_lora)
145
+
146
+ # HPSv2 reward
147
+ print(f"[setup] loading HPSv2 from {args.hps_weights}")
148
+ from distill.hps_reward import HPSv2Reward
149
+ hps = HPSv2Reward(args.hps_weights, device=device, dtype=torch.float32)
150
+
151
+ # schedule (固定 N step)
152
+ sched = make_schedule(args.n_student_steps, args.sigma_shift,
153
+ device=device, dtype=torch.float32)
154
+ print(f"[schedule] N={sched.num_steps} timesteps={sched.timesteps.tolist()}")
155
+
156
+ # dataset (caption-only)
157
+ print(f"[data] {args.dataset}")
158
+ dataset = TextOnlyDataset(args.dataset)
159
+ print(f" {len(dataset)} captions")
160
+ loader = DataLoader(
161
+ dataset, batch_size=args.batch_size, shuffle=True,
162
+ num_workers=args.num_workers, collate_fn=text_collate,
163
+ drop_last=True,
164
+ )
165
+
166
+ opt = torch.optim.AdamW(student_params, lr=args.lr, betas=(0.9, 0.999),
167
+ weight_decay=0.01, eps=1e-8)
168
+
169
+ def student_v_fn(x, t, cond):
170
+ return AnimaBundle.dit_forward(student_transformer, x, t, cond)
171
+
172
+ def base_v_fn(x, t, cond):
173
+ return AnimaBundle.dit_forward(base_transformer, x, t, cond)
174
+
175
+ H_lat = args.resolution // 8
176
+ W_lat = args.resolution // 8
177
+
178
+ print(f"[train] steps={args.total_steps} bs={args.batch_size} N={args.n_student_steps} "
179
+ f"K={args.K} lv={args.n_lv_samples} kl={args.kl_coeff}")
180
+ log_path = out_dir / "draftp_log.jsonl"
181
+ log_f = open(log_path, "a", buffering=1)
182
+ t0 = time.time()
183
+ data_iter = iter(loader)
184
+
185
+ def _next():
186
+ nonlocal data_iter
187
+ try:
188
+ return next(data_iter)
189
+ except StopIteration:
190
+ data_iter = iter(loader)
191
+ return next(data_iter)
192
+
193
+ for step in range(args.total_steps):
194
+ student_transformer.train()
195
+ opt.zero_grad()
196
+ metrics = {}
197
+ for _ in range(args.grad_accum):
198
+ captions = _next()
199
+ with torch.no_grad():
200
+ cond_pos = bundle.text_encode(captions)
201
+ B = cond_pos.size(0)
202
+ init_noise = torch.randn(B, 16, 1, H_lat, W_lat, device=device, dtype=dtype)
203
+
204
+ # DRaFT-LV: rollout once + n_lv_samples 個の last-step alternative を試して平均
205
+ x0_final, kl = student_rollout_with_truncation(
206
+ student_v_fn, base_v_fn, init_noise, sched.timesteps,
207
+ cond_pos, args.student_cfg, args.K, capture_kl=True,
208
+ )
209
+
210
+ # VAE decode (grad on)
211
+ img = vae_decode_with_grad(bundle, x0_final).squeeze(2) # (B,3,H,W) in [-1,1]
212
+ reward = hps.score(img, captions) # (B,)
213
+ r_mean = reward.mean()
214
+
215
+ # DRaFT+ loss: -reward + kl_coeff * kl
216
+ loss = (-r_mean + args.kl_coeff * kl) / args.grad_accum
217
+ loss.backward()
218
+ metrics = {
219
+ "reward_mean": float(r_mean.detach()),
220
+ "reward_std": float(reward.std().detach()),
221
+ "kl": float(kl.detach()),
222
+ "loss": float((-r_mean + args.kl_coeff * kl).detach()),
223
+ }
224
+ torch.nn.utils.clip_grad_norm_(student_params, args.grad_clip)
225
+ opt.step()
226
+
227
+ if step % args.log_every == 0:
228
+ metrics["step"] = step
229
+ metrics["elapsed"] = time.time() - t0
230
+ log_f.write(json.dumps(metrics) + "\n")
231
+ msg = " ".join(f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}"
232
+ for k, v in metrics.items() if k != "step")
233
+ print(f"[step {step}/{args.total_steps}] {msg}", flush=True)
234
+
235
+ if step > 0 and step % args.sample_every == 0:
236
+ save_lora_state(student_transformer, out_dir, f"draftp_step{step:05d}")
237
+ print(f"[save] draftp_step{step:05d}.safetensors", flush=True)
238
+ try:
239
+ import modal
240
+ modal.Volume.from_name("anima-outputs").commit()
241
+ except Exception:
242
+ pass
243
+
244
+ print("[done] saving final")
245
+ save_lora_state(student_transformer, out_dir, "draftp_final")
246
+ log_f.close()
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()
scripts/distill/train_ladd.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Anima LADD (Latent Adversarial Diffusion Distillation) — AMD Nitro-1 移植
4
+
5
+ R3GAN 失敗との対比:
6
+ - R3GAN: CNN-on-latent を一から学習 → Anima 16ch latent prior なし → 崩壊
7
+ - LADD: D の backbone = teacher MiniTrainDIT (frozen)、head だけ trainable
8
+ + Smooth-L1 recon anchor で mean collapse の引力を断つ
9
+ → R3GAN の "stable zone too narrow" を構造的に回避
10
+
11
+ 訓練ループ (1 step per phase, alternate G:D = 1:1):
12
+ G phase:
13
+ 1. student で 1-step rollout (t=1 → t=0)、x0_hat 取得
14
+ 2. x0_hat を t_D ∈ [0, 0.75] で re-noise
15
+ 3. D で logits_fake、adv_loss = BCE(logits_fake, 1)
16
+ 4. recon_loss = smooth_L1(x0_hat, x0_teacher_cached)
17
+ 5. G_loss = adv_loss + recon_lambda * recon_loss
18
+
19
+ D phase:
20
+ 1. student で 1-step rollout (no_grad)、x0_hat
21
+ 2. teacher x0 (cached) と x0_hat をそれぞれ別の t_D で re-noise
22
+ 3. D で logits_real / logits_fake、BCE(real, 1) + BCE(fake, 0)
23
+
24
+ precompute 必須:
25
+ modal run modal_app.py::precompute_teacher_x0_cache
26
+ """
27
+ from __future__ import annotations
28
+ import argparse
29
+ import copy
30
+ import json
31
+ import os
32
+ import sys
33
+ import time
34
+ from pathlib import Path
35
+
36
+ import torch
37
+ import torch.nn.functional as F
38
+ from torch.utils.data import DataLoader, Dataset
39
+ from safetensors.torch import save_file, load_file
40
+
41
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
42
+ from distill.anima_loader import AnimaPaths, build_anima, AnimaBundle
43
+ from distill.dmd2_trainer import attach_wide_lora
44
+ from distill.train_traj import convert_comfy_to_peft_lora, save_lora_state
45
+ from distill.dmd2_official_loss import renoise_rf, x0_from_velocity_rf
46
+ from distill.anima_ladd_disc import (
47
+ AnimaLADDDiscriminator, ladd_d_loss, ladd_g_adv_loss, ladd_g_recon_loss,
48
+ )
49
+
50
+
51
+ # ----- precomputed cache dataset -------------------------------------------
52
+ class PrecomputedCacheDataset(Dataset):
53
+ """teacher_x0_cache から (caption_emb, teacher_x0) を読む。"""
54
+ def __init__(self, cache_dir: str | Path):
55
+ self.cache_dir = Path(cache_dir)
56
+ meta_path = self.cache_dir / "metadata.json"
57
+ self.meta = json.loads(meta_path.read_text(encoding="utf-8"))
58
+ if len(self.meta) == 0:
59
+ raise RuntimeError(f"Empty metadata at {meta_path}")
60
+
61
+ def __len__(self):
62
+ return len(self.meta)
63
+
64
+ def __getitem__(self, idx):
65
+ m = self.meta[idx]
66
+ x0 = torch.load(m["x0_path"], map_location="cpu", weights_only=True)
67
+ emb = torch.load(m["emb_path"], map_location="cpu", weights_only=True)
68
+ # x0 was saved as (1, 16, 1, H, W), emb as (1, 512, 1024) → squeeze batch dim
69
+ return {"x0": x0.squeeze(0), "emb": emb.squeeze(0), "caption": m["caption"]}
70
+
71
+
72
+ def ladd_collate(batch):
73
+ x0 = torch.stack([b["x0"] for b in batch])
74
+ emb = torch.stack([b["emb"] for b in batch])
75
+ captions = [b["caption"] for b in batch]
76
+ return {"x0": x0, "emb": emb, "captions": captions}
77
+
78
+
79
+ # ----- student 1-step rollout ----------------------------------------------
80
+ def student_x0_hat(
81
+ student_v_fn, noise: torch.Tensor, cond_pos: torch.Tensor,
82
+ ) -> torch.Tensor:
83
+ """1-step distill: t=1 noise から 1 step Euler で t=0 へ。"""
84
+ B = noise.size(0)
85
+ device = noise.device
86
+ dtype = noise.dtype
87
+ t_init = torch.ones(B, device=device, dtype=dtype) # t=1
88
+ v = student_v_fn(noise, t_init, cond_pos)
89
+ # x0 = x_t - t * v = noise - 1 * v
90
+ return noise - v
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ def main():
95
+ ap = argparse.ArgumentParser()
96
+ ap.add_argument("--cache-dir", required=True, type=str)
97
+ ap.add_argument("--out", required=True, type=str)
98
+ ap.add_argument("--warm-lora", default="", type=str)
99
+ ap.add_argument("--total-steps", type=int, default=5000)
100
+ ap.add_argument("--batch-size", type=int, default=4)
101
+ ap.add_argument("--grad-accum", type=int, default=4, help="effective bs = batch_size * grad_accum")
102
+ ap.add_argument("--resolution", type=int, default=768)
103
+ ap.add_argument("--recon-lambda", type=float, default=1.0)
104
+ ap.add_argument("--lr-g", type=float, default=1e-6)
105
+ ap.add_argument("--lr-d", type=float, default=1e-6)
106
+ ap.add_argument("--t-d-max", type=float, default=0.75, help="re-noise t upper bound")
107
+ ap.add_argument("--lora-rank", type=int, default=32)
108
+ ap.add_argument("--grad-clip", type=float, default=1.0)
109
+ ap.add_argument("--block-ids", type=str, default="2,8,14,20,26",
110
+ help="teacher block indices for D hooks (Anima 28 blocks)")
111
+ ap.add_argument("--head-hidden", type=int, default=512)
112
+ ap.add_argument("--misaligned-pairs-d", action="store_true", default=True,
113
+ help="text alignment trick: D も misaligned fake (caption roll) を学習")
114
+ ap.add_argument("--log-every", type=int, default=10)
115
+ ap.add_argument("--sample-every", type=int, default=500)
116
+ ap.add_argument("--num-workers", type=int, default=2)
117
+ ap.add_argument("--seed", type=int, default=42)
118
+ args = ap.parse_args()
119
+
120
+ torch.manual_seed(args.seed)
121
+ device = torch.device("cuda")
122
+ dtype = torch.bfloat16
123
+ out_dir = Path(args.out)
124
+ out_dir.mkdir(parents=True, exist_ok=True)
125
+
126
+ # ----- load Anima base -----
127
+ print("[load] Anima bundle")
128
+ bundle = build_anima(device=device, dtype=dtype)
129
+
130
+ # ----- teacher = deepcopy frozen (D の backbone と shared instance) -----
131
+ print("[setup] teacher = frozen deepcopy")
132
+ teacher_transformer = copy.deepcopy(bundle.transformer).to(device=device, dtype=dtype).eval()
133
+ for p in teacher_transformer.parameters():
134
+ p.requires_grad = False
135
+
136
+ # ----- student = wide LoRA -----
137
+ print("[setup] student = wide LoRA on bundle.transformer")
138
+ student_transformer = attach_wide_lora(bundle.transformer, rank=args.lora_rank)
139
+ student_transformer.to(device=device, dtype=dtype)
140
+ for n, p in student_transformer.named_parameters():
141
+ p.requires_grad = ("lora_" in n)
142
+ student_params = [p for p in student_transformer.parameters() if p.requires_grad]
143
+ print(f"[setup] student trainable: {sum(p.numel() for p in student_params)/1e6:.1f}M")
144
+ bundle.transformer = student_transformer
145
+
146
+ # warm-start
147
+ if args.warm_lora:
148
+ from distill.train_traj import load_warm_lora
149
+ load_warm_lora(student_transformer, args.warm_lora)
150
+
151
+ # ----- discriminator -----
152
+ print("[setup] LADD discriminator (teacher backbone + multi-scale heads)")
153
+ block_ids = [int(x) for x in args.block_ids.split(",")]
154
+ disc = AnimaLADDDiscriminator(
155
+ teacher_transformer=teacher_transformer,
156
+ block_ids=block_ids, head_hidden=args.head_hidden,
157
+ )
158
+ # heads を lazy init するために dummy forward
159
+ H_lat = args.resolution // 8
160
+ W_lat = args.resolution // 8
161
+ dummy_x = torch.randn(1, 16, 1, H_lat, W_lat, device=device, dtype=dtype)
162
+ dummy_t = torch.tensor([0.5], device=device, dtype=dtype)
163
+ with torch.no_grad():
164
+ dummy_cond = bundle.text_encode([""])
165
+ disc.lazy_init_heads(dummy_x, dummy_t, dummy_cond)
166
+ disc.to(device=device, dtype=torch.float32) # heads は fp32
167
+ disc_params = disc.trainable_parameters()
168
+ print(f"[setup] D heads trainable: {sum(p.numel() for p in disc_params)/1e6:.1f}M")
169
+
170
+ # ----- optimizers -----
171
+ opt_g = torch.optim.AdamW(student_params, lr=args.lr_g, betas=(0.0, 0.999), eps=1e-8)
172
+ opt_d = torch.optim.AdamW(disc_params, lr=args.lr_d, betas=(0.0, 0.999), eps=1e-8)
173
+
174
+ # ----- velocity functions -----
175
+ def student_v(x, t, cond):
176
+ return AnimaBundle.dit_forward(student_transformer, x, t, cond)
177
+
178
+ # ----- dataset (precomputed cache) -----
179
+ print(f"[data] loading cache {args.cache_dir}")
180
+ dataset = PrecomputedCacheDataset(args.cache_dir)
181
+ print(f" {len(dataset)} cached samples")
182
+ loader = DataLoader(
183
+ dataset, batch_size=args.batch_size, shuffle=True,
184
+ num_workers=args.num_workers, collate_fn=ladd_collate, drop_last=True,
185
+ pin_memory=True,
186
+ )
187
+
188
+ # ----- training loop -----
189
+ print(f"[train] total={args.total_steps} bs={args.batch_size} accum={args.grad_accum} "
190
+ f"recon_lambda={args.recon_lambda}")
191
+ log_path = out_dir / "ladd_log.jsonl"
192
+ log_f = open(log_path, "a", buffering=1)
193
+ t0 = time.time()
194
+ data_iter = iter(loader)
195
+
196
+ def _next():
197
+ nonlocal data_iter
198
+ try:
199
+ return next(data_iter)
200
+ except StopIteration:
201
+ data_iter = iter(loader)
202
+ return next(data_iter)
203
+
204
+ for step in range(args.total_steps):
205
+ # ---- G phase ----
206
+ student_transformer.train()
207
+ opt_g.zero_grad()
208
+ g_metrics = {}
209
+ for _ in range(args.grad_accum):
210
+ batch = _next()
211
+ x0_teacher = batch["x0"].to(device=device, dtype=dtype)
212
+ cond_pos = batch["emb"].to(device=device, dtype=dtype)
213
+ B = x0_teacher.size(0)
214
+ noise = torch.randn_like(x0_teacher)
215
+ # student forward
216
+ x0_hat = student_x0_hat(student_v, noise, cond_pos)
217
+ # re-noise & D forward (fake side)
218
+ t_D = torch.rand(B, device=device, dtype=dtype) * args.t_d_max
219
+ D_eps = torch.randn_like(x0_hat)
220
+ x_t_fake = renoise_rf(x0_hat, t_D, D_eps)
221
+ # G phase: adv loss が student を訓練するために gradient_to_input=True
222
+ logits_fake = disc(x_t_fake, t_D, cond_pos, gradient_to_input=True)
223
+ l_adv, m_adv = ladd_g_adv_loss(logits_fake)
224
+ l_rec, m_rec = ladd_g_recon_loss(x0_hat, x0_teacher)
225
+ g_loss = (l_adv + args.recon_lambda * l_rec) / args.grad_accum
226
+ g_loss.backward()
227
+ g_metrics = {**m_adv, **m_rec, "l_g_total": (l_adv + args.recon_lambda * l_rec).detach()}
228
+ torch.nn.utils.clip_grad_norm_(student_params, args.grad_clip)
229
+ opt_g.step()
230
+
231
+ # ---- D phase ----
232
+ disc.train()
233
+ opt_d.zero_grad()
234
+ d_metrics = {}
235
+ for _ in range(args.grad_accum):
236
+ batch = _next()
237
+ x0_teacher = batch["x0"].to(device=device, dtype=dtype)
238
+ cond_pos = batch["emb"].to(device=device, dtype=dtype)
239
+ B = x0_teacher.size(0)
240
+ with torch.no_grad():
241
+ noise = torch.randn_like(x0_teacher)
242
+ x0_hat = student_x0_hat(student_v, noise, cond_pos)
243
+ t_D_real = torch.rand(B, device=device, dtype=dtype) * args.t_d_max
244
+ t_D_fake = torch.rand(B, device=device, dtype=dtype) * args.t_d_max
245
+ x_t_real = renoise_rf(x0_teacher, t_D_real, torch.randn_like(x0_teacher))
246
+ x_t_fake = renoise_rf(x0_hat, t_D_fake, torch.randn_like(x0_hat))
247
+ cond_for_real = cond_pos
248
+ cond_for_fake = cond_pos
249
+ if args.misaligned_pairs_d:
250
+ # text-alignment: misaligned fake (caption を 1 個 roll) も追加
251
+ cond_misaligned = torch.roll(cond_pos, 1, dims=0)
252
+ t_D_mis = torch.rand(B, device=device, dtype=dtype) * args.t_d_max
253
+ x_t_mis = renoise_rf(x0_teacher, t_D_mis, torch.randn_like(x0_teacher))
254
+ x_t_fake = torch.cat([x_t_fake, x_t_mis], dim=0)
255
+ t_D_fake = torch.cat([t_D_fake, t_D_mis], dim=0)
256
+ cond_for_fake = torch.cat([cond_pos, cond_misaligned], dim=0)
257
+ # D phase: heads だけ訓練、x への gradient 不要 (gradient_to_input=False、省メモリ)
258
+ logits_real = disc(x_t_real, t_D_real, cond_for_real, gradient_to_input=False)
259
+ logits_fake = disc(x_t_fake, t_D_fake, cond_for_fake, gradient_to_input=False)
260
+ d_loss, m_d = ladd_d_loss(logits_real, logits_fake)
261
+ d_loss = d_loss / args.grad_accum
262
+ d_loss.backward()
263
+ d_metrics = {k: float(v) for k, v in m_d.items()}
264
+ torch.nn.utils.clip_grad_norm_(disc_params, args.grad_clip)
265
+ opt_d.step()
266
+
267
+ # ---- log ----
268
+ if step % args.log_every == 0:
269
+ metrics = {"step": step, "elapsed": time.time() - t0,
270
+ **{k: float(v) for k, v in g_metrics.items()},
271
+ **d_metrics}
272
+ log_f.write(json.dumps(metrics) + "\n")
273
+ msg = " ".join(f"{k}={v:.4f}" for k, v in metrics.items() if k not in ("step",))
274
+ print(f"[step {step}/{args.total_steps}] {msg}", flush=True)
275
+
276
+ # ---- ckpt ----
277
+ if step > 0 and step % args.sample_every == 0:
278
+ save_lora_state(student_transformer, out_dir, f"ladd_student_step{step:05d}")
279
+ print(f"[save] ladd_student_step{step:05d}.safetensors", flush=True)
280
+ try:
281
+ import modal
282
+ modal.Volume.from_name("anima-outputs").commit()
283
+ except Exception:
284
+ pass
285
+
286
+ # final
287
+ print("[done] saving final")
288
+ save_lora_state(student_transformer, out_dir, "ladd_student_final")
289
+ log_f.close()
290
+
291
+
292
+ if __name__ == "__main__":
293
+ main()
scripts/distill/train_pcm.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Anima PCM (Phased Consistency Model) distillation
4
+ =================================================
5
+
6
+ Reference: G-U-N/Phased-Consistency-Model
7
+ code/text_to_image_sd3/train_pcm_lora_sd3.py
8
+
9
+ SD3 PCM は FlowMatch + v-pred + LoRA で、Anima の rectified flow と math 完全一致。
10
+ ε↔v 変換不要、scheduler/loss を直接移植可能。
11
+
12
+ Algorithm:
13
+ - num_euler_timesteps=N (e.g. 50) で grid を作り、K phase に等分割
14
+ - 各 step ランダムに index in [0, N) を選び、現 phase の終端まで Euler 1 step
15
+ - student の predicted x_phase と、teacher 1 step 前進 → student EMA-target の x_phase を MSE
16
+ - pseudo-Huber loss + 任意 adv loss (skip in v1)
17
+
18
+ memory: 1 grad-through student fwd + 3 no_grad fwd (teacher cond, teacher uncond, student-as-EMA)
19
+ → ~60-80 GB on B200 (LoRA-only, batch=1, 768²)
20
+
21
+ データ: LADD precompute cache の emb を流用 (prompt 再 encode 不要)
22
+ x0 は teacher rollout で online 生成するので不要 (ただし noise / cap 起点として cache を使う)
23
+ """
24
+ from __future__ import annotations
25
+ import argparse
26
+ import copy
27
+ import json
28
+ import os
29
+ import sys
30
+ import time
31
+ from pathlib import Path
32
+
33
+ import torch
34
+ import torch.nn.functional as F
35
+ from torch.utils.data import DataLoader, Dataset
36
+ from safetensors.torch import save_file, load_file
37
+
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
39
+ from distill.anima_loader import AnimaPaths, build_anima, AnimaBundle
40
+ from distill.dmd2_trainer import attach_wide_lora
41
+ from distill.train_traj import load_warm_lora, save_lora_state
42
+ from distill.pcm_scheduler import make_pcm_solver, euler_multiphase, pseudo_huber
43
+
44
+
45
+ # ----- dataset: precomputed cache の emb と任意で x0 を使う ----------------
46
+ class PCMCacheDataset(Dataset):
47
+ """LADD/Reflow cache の (caption, emb, x0) を読む。x0 は initial noise の source。"""
48
+ def __init__(self, cache_dir: str | Path):
49
+ self.cache_dir = Path(cache_dir)
50
+ self.meta = json.loads((self.cache_dir / "metadata.json").read_text(encoding="utf-8"))
51
+
52
+ def __len__(self):
53
+ return len(self.meta)
54
+
55
+ def __getitem__(self, idx):
56
+ m = self.meta[idx]
57
+ x0 = torch.load(m["x0_path"], map_location="cpu", weights_only=True).squeeze(0)
58
+ emb = torch.load(m["emb_path"], map_location="cpu", weights_only=True).squeeze(0)
59
+ return {"x0": x0, "emb": emb}
60
+
61
+
62
+ def pcm_collate(batch):
63
+ return {
64
+ "x0": torch.stack([b["x0"] for b in batch]),
65
+ "emb": torch.stack([b["emb"] for b in batch]),
66
+ }
67
+
68
+
69
+ def main():
70
+ ap = argparse.ArgumentParser()
71
+ ap.add_argument("--cache-dir", required=True, type=str,
72
+ help="LADD cache (emb と x0 を使用)")
73
+ ap.add_argument("--out", required=True, type=str)
74
+ ap.add_argument("--warm-lora", default="", type=str)
75
+ ap.add_argument("--total-steps", type=int, default=8000)
76
+ ap.add_argument("--batch-size", type=int, default=1)
77
+ ap.add_argument("--grad-accum", type=int, default=4)
78
+ ap.add_argument("--resolution", type=int, default=768)
79
+ ap.add_argument("--num-euler-timesteps", type=int, default=50)
80
+ ap.add_argument("--num-phases", type=int, default=4)
81
+ ap.add_argument("--sigma-shift", type=float, default=3.0)
82
+ ap.add_argument("--w-min", type=float, default=4.0, help="CFG-aug min")
83
+ ap.add_argument("--w-max", type=float, default=5.0, help="CFG-aug max")
84
+ ap.add_argument("--w-fixed", type=float, default=-1.0, help=">=0 で範囲指定無視して固定")
85
+ ap.add_argument("--huber-c", type=float, default=1e-3)
86
+ ap.add_argument("--lr", type=float, default=5e-6)
87
+ ap.add_argument("--weight-decay", type=float, default=0.01)
88
+ ap.add_argument("--grad-clip", type=float, default=1.0)
89
+ ap.add_argument("--lora-rank", type=int, default=32)
90
+ ap.add_argument("--neg-prompt", default="")
91
+ ap.add_argument("--log-every", type=int, default=10)
92
+ ap.add_argument("--sample-every", type=int, default=500)
93
+ ap.add_argument("--num-workers", type=int, default=2)
94
+ ap.add_argument("--seed", type=int, default=42)
95
+ args = ap.parse_args()
96
+
97
+ torch.manual_seed(args.seed)
98
+ device = torch.device("cuda")
99
+ dtype = torch.bfloat16
100
+ out_dir = Path(args.out)
101
+ out_dir.mkdir(parents=True, exist_ok=True)
102
+
103
+ # ----- load Anima -----
104
+ print("[load] Anima bundle")
105
+ bundle = build_anima(device=device, dtype=dtype)
106
+
107
+ # teacher = frozen deepcopy
108
+ print("[setup] teacher = frozen deepcopy")
109
+ teacher_transformer = copy.deepcopy(bundle.transformer).to(device=device, dtype=dtype).eval()
110
+ for p in teacher_transformer.parameters():
111
+ p.requires_grad = False
112
+
113
+ # student = wide LoRA
114
+ student_transformer = attach_wide_lora(bundle.transformer, rank=args.lora_rank)
115
+ student_transformer.to(device=device, dtype=dtype)
116
+ for n, p in student_transformer.named_parameters():
117
+ p.requires_grad = ("lora_" in n)
118
+ student_params = [p for p in student_transformer.parameters() if p.requires_grad]
119
+ print(f"[setup] student trainable: {sum(p.numel() for p in student_params)/1e6:.1f}M")
120
+ bundle.transformer = student_transformer
121
+
122
+ if args.warm_lora:
123
+ load_warm_lora(student_transformer, args.warm_lora)
124
+
125
+ # solver
126
+ solver = make_pcm_solver(
127
+ num_euler_timesteps=args.num_euler_timesteps,
128
+ num_phases=args.num_phases,
129
+ sigma_shift=args.sigma_shift,
130
+ device=device, dtype=torch.float32,
131
+ )
132
+ print(f"[schedule] N={solver.num_steps} K={len(solver.phase_ends)} "
133
+ f"phase_ends={solver.phase_ends.tolist()}")
134
+
135
+ # cond_neg
136
+ with torch.no_grad():
137
+ cond_neg = bundle.text_encode([args.neg_prompt or ""])
138
+
139
+ # optimizer
140
+ opt = torch.optim.AdamW(student_params, lr=args.lr, betas=(0.9, 0.999),
141
+ weight_decay=args.weight_decay, eps=1e-8)
142
+
143
+ # dataset (emb は cache から、x0 は initial noise の source として使う —
144
+ # 実際の noise は毎 step randn)
145
+ print(f"[data] {args.cache_dir}")
146
+ dataset = PCMCacheDataset(args.cache_dir)
147
+ print(f" {len(dataset)} captions")
148
+ loader = DataLoader(
149
+ dataset, batch_size=args.batch_size, shuffle=True,
150
+ num_workers=args.num_workers, collate_fn=pcm_collate,
151
+ drop_last=True, pin_memory=True,
152
+ )
153
+
154
+ print(f"[train] steps={args.total_steps} bs={args.batch_size} accum={args.grad_accum} "
155
+ f"N={args.num_euler_timesteps} K={args.num_phases}")
156
+ log_path = out_dir / "pcm_log.jsonl"
157
+ log_f = open(log_path, "a", buffering=1)
158
+ t0 = time.time()
159
+ data_iter = iter(loader)
160
+
161
+ def _next():
162
+ nonlocal data_iter
163
+ try:
164
+ return next(data_iter)
165
+ except StopIteration:
166
+ data_iter = iter(loader)
167
+ return next(data_iter)
168
+
169
+ for step in range(args.total_steps):
170
+ student_transformer.train()
171
+ opt.zero_grad()
172
+ metrics = {}
173
+ for _ in range(args.grad_accum):
174
+ batch = _next()
175
+ x0 = batch["x0"].to(device=device, dtype=dtype)
176
+ emb = batch["emb"].to(device=device, dtype=dtype)
177
+ B = x0.size(0)
178
+ cond_neg_b = cond_neg.expand(B, -1, -1).contiguous() if B > 1 else cond_neg
179
+
180
+ # ----- index sampling -----
181
+ index = torch.randint(0, solver.num_steps, (B,), device=device)
182
+ sigma_cur = solver.sigmas[index].to(dtype=dtype)
183
+ sigma_prev = solver.sigmas[index + 1].to(dtype=dtype)
184
+
185
+ # ----- x_t (forward process) -----
186
+ noise = torch.randn_like(x0)
187
+ sigma_cur_ = sigma_cur.view(-1, *([1] * (x0.dim() - 1)))
188
+ x_t = sigma_cur_ * noise + (1.0 - sigma_cur_) * x0
189
+
190
+ # ----- student forward (grad) -----
191
+ v_student = AnimaBundle.dit_forward(student_transformer, x_t, sigma_cur, emb)
192
+ # phase-snap: student の "予測" を当該 phase 終端まで進める
193
+ pred_phase, sigma_phase = euler_multiphase(x_t, v_student, index, solver)
194
+
195
+ # ----- target side (no_grad) -----
196
+ with torch.no_grad():
197
+ # CFG-aug w sample
198
+ if args.w_fixed >= 0:
199
+ w = torch.full((B,), args.w_fixed, device=device, dtype=dtype)
200
+ else:
201
+ w = torch.empty(B, device=device, dtype=dtype).uniform_(args.w_min, args.w_max)
202
+ v_t_cond = AnimaBundle.dit_forward(teacher_transformer, x_t, sigma_cur, emb)
203
+ v_t_uncond = AnimaBundle.dit_forward(teacher_transformer, x_t, sigma_cur, cond_neg_b)
204
+ w_ = w.view(-1, *([1] * (x_t.dim() - 1)))
205
+ v_cfg = v_t_uncond + w_ * (v_t_cond - v_t_uncond)
206
+ # 1 teacher step forward (x_t → x_prev)
207
+ dt = (sigma_prev - sigma_cur).view(-1, *([1] * (x_t.dim() - 1)))
208
+ x_prev = x_t + dt * v_cfg
209
+ # student-as-EMA target prediction at x_prev
210
+ v_tgt = AnimaBundle.dit_forward(student_transformer, x_prev, sigma_prev, emb)
211
+ target_phase, _ = euler_multiphase(x_prev, v_tgt, (index + 1).clamp(max=solver.num_steps - 1), solver)
212
+
213
+ # ----- loss -----
214
+ diff = pred_phase - target_phase.detach()
215
+ loss = pseudo_huber(diff, c=args.huber_c).mean() / args.grad_accum
216
+ loss.backward()
217
+ metrics = {
218
+ "loss": float((loss * args.grad_accum).detach()),
219
+ "pred_abs": float(pred_phase.detach().abs().mean()),
220
+ "target_abs": float(target_phase.detach().abs().mean()),
221
+ "w_mean": float(w.mean().detach()),
222
+ "sigma_cur_mean": float(sigma_cur.mean().detach()),
223
+ }
224
+ torch.nn.utils.clip_grad_norm_(student_params, args.grad_clip)
225
+ opt.step()
226
+
227
+ if step % args.log_every == 0:
228
+ metrics["step"] = step
229
+ metrics["elapsed"] = time.time() - t0
230
+ log_f.write(json.dumps(metrics) + "\n")
231
+ msg = " ".join(f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}"
232
+ for k, v in metrics.items() if k != "step")
233
+ print(f"[step {step}/{args.total_steps}] {msg}", flush=True)
234
+
235
+ if step > 0 and step % args.sample_every == 0:
236
+ save_lora_state(student_transformer, out_dir, f"pcm_step{step:05d}")
237
+ print(f"[save] pcm_step{step:05d}.safetensors", flush=True)
238
+ try:
239
+ import modal
240
+ modal.Volume.from_name("anima-outputs").commit()
241
+ except Exception:
242
+ pass
243
+
244
+ print("[done] saving final")
245
+ save_lora_state(student_transformer, out_dir, "pcm_final")
246
+ log_f.close()
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()
scripts/distill/train_reflow.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Anima Reflow distillation (InstaFlow / rfpp 流派)
4
+ ================================================
5
+
6
+ 理論的根拠:
7
+ - Anima は既に rectified flow なので「Reflow」(1 round) を当てるのが自然
8
+ - rfpp (NeurIPS 2024 "Improving the Training of Rectified Flows") の知見:
9
+ * U-shape t sampling
10
+ * LPIPS-Huber (latent Huber + 間欠的に pixel LPIPS) で 1-round で十分
11
+ * adversarial 不要 (LADD と独立カテゴリ)
12
+ - 出力: 4-step (or 1-step) で動く student LoRA
13
+
14
+ データ:
15
+ 事前に (noise, x0, emb) triplet を precompute (--save-noise) しておく前提。
16
+ /dataset/teacher_x0_cache_with_noise/{noise,x0,emb}/{idx}.pt
17
+ metadata.json に noise_path も書かれていること。
18
+
19
+ 訓練 1 step:
20
+ 1. (noise_i, x0_i, emb_i) を batch load
21
+ 2. t ~ U-shape on (0, 1)
22
+ 3. x_t = (1 - t) * x0 + t * noise
23
+ 4. v_target = noise - x0
24
+ 5. v_pred = student.dit_forward(x_t, t, emb)
25
+ 6. L_huber = huber(v_pred, v_target, delta=0.03)
26
+ 7. 任意 (lpips-every step): x0_pred = x_t - t*v_pred、VAE decode → LPIPS vs x0
27
+ 8. backward → student LoRA 更新
28
+ """
29
+ from __future__ import annotations
30
+ import argparse
31
+ import copy
32
+ import json
33
+ import os
34
+ import sys
35
+ import time
36
+ from pathlib import Path
37
+
38
+ import torch
39
+ import torch.nn.functional as F
40
+ from torch.utils.data import DataLoader, Dataset
41
+ from safetensors.torch import save_file, load_file
42
+
43
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
44
+ from distill.anima_loader import AnimaPaths, build_anima, AnimaBundle
45
+ from distill.dmd2_trainer import attach_wide_lora
46
+ from distill.train_traj import load_warm_lora, save_lora_state
47
+
48
+
49
+ class ReflowPairDataset(Dataset):
50
+ """precompute された (noise, x0, emb) triplet を読む。
51
+ metadata.json の各 entry に noise_path / x0_path / emb_path が必要。"""
52
+ def __init__(self, cache_dir: str | Path):
53
+ self.cache_dir = Path(cache_dir)
54
+ self.meta = json.loads((self.cache_dir / "metadata.json").read_text(encoding="utf-8"))
55
+ if not self.meta or "noise_path" not in self.meta[0]:
56
+ raise RuntimeError(
57
+ f"{self.cache_dir}/metadata.json has no 'noise_path' — "
58
+ "rerun precompute with --save-noise."
59
+ )
60
+
61
+ def __len__(self):
62
+ return len(self.meta)
63
+
64
+ def __getitem__(self, idx):
65
+ m = self.meta[idx]
66
+ x0 = torch.load(m["x0_path"], map_location="cpu", weights_only=True).squeeze(0)
67
+ noise = torch.load(m["noise_path"], map_location="cpu", weights_only=True).squeeze(0)
68
+ emb = torch.load(m["emb_path"], map_location="cpu", weights_only=True).squeeze(0)
69
+ return {"x0": x0, "noise": noise, "emb": emb}
70
+
71
+
72
+ def reflow_collate(batch):
73
+ return {
74
+ "x0": torch.stack([b["x0"] for b in batch]),
75
+ "noise": torch.stack([b["noise"] for b in batch]),
76
+ "emb": torch.stack([b["emb"] for b in batch]),
77
+ }
78
+
79
+
80
+ def u_shape_t(B: int, device, dtype=torch.float32) -> torch.Tensor:
81
+ """rfpp 流の U-shape sampler。両端 (t≈0, t≈1) に分布が偏る。
82
+ 実装: u ~ U(0,1) → t = 0.5 * (1 + sign(u-0.5) * |2u-1|^0.5)
83
+ (sqrt によって両端密度上昇)"""
84
+ u = torch.rand(B, device=device, dtype=dtype)
85
+ centered = 2 * u - 1
86
+ t = 0.5 * (1.0 + torch.sign(centered) * centered.abs().sqrt())
87
+ return t.clamp(1e-3, 1.0 - 1e-3)
88
+
89
+
90
+ def main():
91
+ ap = argparse.ArgumentParser()
92
+ ap.add_argument("--cache-dir", required=True, type=str,
93
+ help="--save-noise 付きで precompute した cache")
94
+ ap.add_argument("--out", required=True, type=str)
95
+ ap.add_argument("--warm-lora", default="", type=str)
96
+ ap.add_argument("--total-steps", type=int, default=8000)
97
+ ap.add_argument("--batch-size", type=int, default=4)
98
+ ap.add_argument("--grad-accum", type=int, default=2)
99
+ ap.add_argument("--resolution", type=int, default=768)
100
+ ap.add_argument("--lr", type=float, default=1e-4)
101
+ ap.add_argument("--weight-decay", type=float, default=0.01)
102
+ ap.add_argument("--grad-clip", type=float, default=1.0)
103
+ ap.add_argument("--lora-rank", type=int, default=32)
104
+ ap.add_argument("--huber-delta", type=float, default=0.03)
105
+ ap.add_argument("--lpips-weight", type=float, default=0.1)
106
+ ap.add_argument("--lpips-every", type=int, default=4,
107
+ help="N step ごとに LPIPS reg を加算 (cost 削減)")
108
+ ap.add_argument("--t-sampler", default="u_shape", choices=["u_shape", "uniform"])
109
+ ap.add_argument("--log-every", type=int, default=10)
110
+ ap.add_argument("--sample-every", type=int, default=500)
111
+ ap.add_argument("--num-workers", type=int, default=2)
112
+ ap.add_argument("--seed", type=int, default=42)
113
+ args = ap.parse_args()
114
+
115
+ torch.manual_seed(args.seed)
116
+ device = torch.device("cuda")
117
+ dtype = torch.bfloat16
118
+ out_dir = Path(args.out)
119
+ out_dir.mkdir(parents=True, exist_ok=True)
120
+
121
+ # ----- load Anima base (teacher は捨てる、cache が teacher 役) -----
122
+ print("[load] Anima bundle (student only — teacher cached on disk)")
123
+ bundle = build_anima(device=device, dtype=dtype)
124
+
125
+ # ----- student = wide LoRA -----
126
+ student_transformer = attach_wide_lora(bundle.transformer, rank=args.lora_rank)
127
+ student_transformer.to(device=device, dtype=dtype)
128
+ for n, p in student_transformer.named_parameters():
129
+ p.requires_grad = ("lora_" in n)
130
+ student_params = [p for p in student_transformer.parameters() if p.requires_grad]
131
+ print(f"[setup] student trainable: {sum(p.numel() for p in student_params)/1e6:.1f}M")
132
+ bundle.transformer = student_transformer
133
+
134
+ # warm-start
135
+ if args.warm_lora:
136
+ load_warm_lora(student_transformer, args.warm_lora)
137
+
138
+ # LPIPS (任意)
139
+ lpips_fn = None
140
+ if args.lpips_weight > 0:
141
+ import lpips as _lpips
142
+ lpips_fn = _lpips.LPIPS(net="alex").to(device).eval()
143
+ for p in lpips_fn.parameters():
144
+ p.requires_grad = False
145
+ print("[setup] LPIPS(alex) loaded")
146
+
147
+ # ----- optimizer -----
148
+ opt = torch.optim.AdamW(student_params, lr=args.lr, betas=(0.9, 0.999),
149
+ weight_decay=args.weight_decay, eps=1e-8)
150
+
151
+ # ----- dataset -----
152
+ print(f"[data] loading cache {args.cache_dir}")
153
+ dataset = ReflowPairDataset(args.cache_dir)
154
+ print(f" {len(dataset)} (noise, x0, emb) triplets")
155
+ loader = DataLoader(
156
+ dataset, batch_size=args.batch_size, shuffle=True,
157
+ num_workers=args.num_workers, collate_fn=reflow_collate,
158
+ drop_last=True, pin_memory=True,
159
+ )
160
+
161
+ # ----- training loop -----
162
+ print(f"[train] steps={args.total_steps} bs={args.batch_size} accum={args.grad_accum} "
163
+ f"lpips_w={args.lpips_weight} every={args.lpips_every}")
164
+ log_path = out_dir / "reflow_log.jsonl"
165
+ log_f = open(log_path, "a", buffering=1)
166
+ t0 = time.time()
167
+ data_iter = iter(loader)
168
+
169
+ def _next():
170
+ nonlocal data_iter
171
+ try:
172
+ return next(data_iter)
173
+ except StopIteration:
174
+ data_iter = iter(loader)
175
+ return next(data_iter)
176
+
177
+ for step in range(args.total_steps):
178
+ student_transformer.train()
179
+ opt.zero_grad()
180
+ metrics = {}
181
+ for _ in range(args.grad_accum):
182
+ batch = _next()
183
+ x0 = batch["x0"].to(device=device, dtype=dtype)
184
+ noise = batch["noise"].to(device=device, dtype=dtype)
185
+ emb = batch["emb"].to(device=device, dtype=dtype)
186
+ B = x0.size(0)
187
+
188
+ # ----- t sampling -----
189
+ if args.t_sampler == "u_shape":
190
+ t = u_shape_t(B, device, dtype=torch.float32).to(dtype=dtype)
191
+ else:
192
+ t = torch.rand(B, device=device, dtype=dtype).clamp(1e-3, 1.0 - 1e-3)
193
+
194
+ t_ = t.view(-1, *([1] * (x0.dim() - 1)))
195
+ x_t = (1 - t_) * x0 + t_ * noise
196
+ v_target = noise - x0
197
+
198
+ # ----- student forward (1 forward only) -----
199
+ v_pred = AnimaBundle.dit_forward(student_transformer, x_t, t, emb)
200
+ l_huber = F.huber_loss(v_pred.float(), v_target.float(),
201
+ delta=args.huber_delta, reduction="mean")
202
+
203
+ # ----- LPIPS reg (every N steps) -----
204
+ l_lpips = torch.zeros((), device=device)
205
+ if lpips_fn is not None and (step % args.lpips_every) == 0:
206
+ x0_pred = x_t - t_ * v_pred
207
+ vae_dtype = next(bundle.vae.model.parameters()).dtype
208
+ img_p = bundle.vae.model.decode(x0_pred.to(dtype=vae_dtype), bundle.vae_scale).squeeze(2)
209
+ with torch.no_grad():
210
+ img_t = bundle.vae.model.decode(x0.to(dtype=vae_dtype), bundle.vae_scale).squeeze(2)
211
+ l_lpips = lpips_fn(img_p.float(), img_t.float()).mean()
212
+
213
+ loss = (l_huber + args.lpips_weight * l_lpips) / args.grad_accum
214
+ loss.backward()
215
+ metrics = {
216
+ "l_huber": float(l_huber.detach()),
217
+ "l_lpips": float(l_lpips.detach()),
218
+ "loss": float((l_huber + args.lpips_weight * l_lpips).detach()),
219
+ }
220
+ torch.nn.utils.clip_grad_norm_(student_params, args.grad_clip)
221
+ opt.step()
222
+
223
+ if step % args.log_every == 0:
224
+ metrics["step"] = step
225
+ metrics["elapsed"] = time.time() - t0
226
+ log_f.write(json.dumps(metrics) + "\n")
227
+ msg = " ".join(f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}"
228
+ for k, v in metrics.items() if k != "step")
229
+ print(f"[step {step}/{args.total_steps}] {msg}", flush=True)
230
+
231
+ if step > 0 and step % args.sample_every == 0:
232
+ save_lora_state(student_transformer, out_dir, f"reflow_step{step:05d}")
233
+ print(f"[save] reflow_step{step:05d}.safetensors", flush=True)
234
+ try:
235
+ import modal
236
+ modal.Volume.from_name("anima-outputs").commit()
237
+ except Exception:
238
+ pass
239
+
240
+ print("[done] saving final")
241
+ save_lora_state(student_transformer, out_dir, "reflow_final")
242
+ log_f.close()
243
+
244
+
245
+ if __name__ == "__main__":
246
+ main()
scripts/distill/train_shortcut.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Anima Shortcut Models distillation (Frans et al., 2024)
4
+
5
+ 特徴:
6
+ - 単一 LoRA、d を入力で取って 1/2/4/8/128-step 自在に切替可能
7
+ - Flow-matching half (d=0) + Bootstrap half (d>0) を 1 step 内で混在
8
+ - PCM と違い phase 固定なし、d を連続値で扱える
9
+
10
+ データ:
11
+ Reflow cache (--save-noise 付き) を流用。(noise, x0, emb) triplet。
12
+ """
13
+ from __future__ import annotations
14
+ import argparse
15
+ import copy
16
+ import json
17
+ import math
18
+ import os
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+
23
+ import torch
24
+ import torch.nn.functional as F
25
+ from torch.utils.data import DataLoader
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
28
+ from distill.anima_loader import build_anima, AnimaBundle
29
+ from distill.dmd2_trainer import attach_wide_lora
30
+ from distill.train_traj import load_warm_lora, save_lora_state
31
+ from distill.train_reflow import ReflowPairDataset, reflow_collate
32
+ from distill.shortcut_module import attach_shortcut_d_head, set_shortcut_d, shortcut_d_head_params
33
+
34
+
35
+ def main():
36
+ ap = argparse.ArgumentParser()
37
+ ap.add_argument("--cache-dir", required=True, type=str,
38
+ help="Reflow cache (--save-noise 付き)")
39
+ ap.add_argument("--out", required=True, type=str)
40
+ ap.add_argument("--warm-lora", default="", type=str)
41
+ ap.add_argument("--total-steps", type=int, default=2000)
42
+ ap.add_argument("--batch-size", type=int, default=4)
43
+ ap.add_argument("--grad-accum", type=int, default=2)
44
+ ap.add_argument("--denoise-timesteps", type=int, default=128,
45
+ help="discrete grid for sampling t and d (2^k granularity)")
46
+ ap.add_argument("--bootstrap-every", type=int, default=8,
47
+ help="batch 内 bootstrap 比率: 1/N (paper default 8)")
48
+ ap.add_argument("--resolution", type=int, default=768)
49
+ ap.add_argument("--lr", type=float, default=2e-5)
50
+ ap.add_argument("--lr-d-head", type=float, default=5e-4,
51
+ help="d_head は zero-init なので高めの lr で立ち上げ")
52
+ ap.add_argument("--lora-rank", type=int, default=32)
53
+ ap.add_argument("--grad-clip", type=float, default=1.0)
54
+ ap.add_argument("--clip-x-bootstrap", type=float, default=4.0,
55
+ help="bootstrap 中の x_t clip range")
56
+ ap.add_argument("--log-every", type=int, default=10)
57
+ ap.add_argument("--sample-every", type=int, default=500)
58
+ ap.add_argument("--num-workers", type=int, default=2)
59
+ ap.add_argument("--seed", type=int, default=42)
60
+ args = ap.parse_args()
61
+
62
+ torch.manual_seed(args.seed)
63
+ device = torch.device("cuda")
64
+ dtype = torch.bfloat16
65
+ out_dir = Path(args.out)
66
+ out_dir.mkdir(parents=True, exist_ok=True)
67
+
68
+ print("[load] Anima bundle")
69
+ bundle = build_anima(device=device, dtype=dtype)
70
+
71
+ # student = wide LoRA + d-head
72
+ student_transformer = attach_wide_lora(bundle.transformer, rank=args.lora_rank)
73
+ student_transformer.to(device=device, dtype=dtype)
74
+ for n, p in student_transformer.named_parameters():
75
+ p.requires_grad = ("lora_" in n)
76
+ attach_shortcut_d_head(student_transformer) # adds d_head + hook + _current_d attr
77
+ # d_head の params は trainable に
78
+ d_head_params = shortcut_d_head_params(student_transformer)
79
+ for p in d_head_params:
80
+ p.requires_grad = True
81
+ student_lora_params = [p for n, p in student_transformer.named_parameters()
82
+ if p.requires_grad and "lora_" in n]
83
+ print(f"[setup] student LoRA: {sum(p.numel() for p in student_lora_params)/1e6:.1f}M")
84
+ print(f"[setup] d_head: {sum(p.numel() for p in d_head_params)/1e6:.1f}M")
85
+ bundle.transformer = student_transformer
86
+
87
+ if args.warm_lora:
88
+ load_warm_lora(student_transformer, args.warm_lora)
89
+
90
+ # optimizers (LoRA と d_head で別 lr)
91
+ opt_lora = torch.optim.AdamW(student_lora_params, lr=args.lr,
92
+ betas=(0.9, 0.999), weight_decay=0.01)
93
+ opt_d_head = torch.optim.AdamW(d_head_params, lr=args.lr_d_head,
94
+ betas=(0.9, 0.999), weight_decay=0.0)
95
+
96
+ # dataset
97
+ print(f"[data] {args.cache_dir}")
98
+ dataset = ReflowPairDataset(args.cache_dir)
99
+ print(f" {len(dataset)} triplets")
100
+ loader = DataLoader(
101
+ dataset, batch_size=args.batch_size, shuffle=True,
102
+ num_workers=args.num_workers, collate_fn=reflow_collate,
103
+ drop_last=True, pin_memory=True,
104
+ )
105
+
106
+ T = args.denoise_timesteps
107
+ log2_sections = int(math.log2(T)) # 7 for T=128
108
+
109
+ def student_v_with_d(x, t, cond, d):
110
+ set_shortcut_d(student_transformer, d)
111
+ try:
112
+ return AnimaBundle.dit_forward(student_transformer, x, t, cond)
113
+ finally:
114
+ set_shortcut_d(student_transformer, None)
115
+
116
+ print(f"[train] steps={args.total_steps} bs={args.batch_size} accum={args.grad_accum} "
117
+ f"T={T} bootstrap_every={args.bootstrap_every}")
118
+ log_path = out_dir / "shortcut_log.jsonl"
119
+ log_f = open(log_path, "a", buffering=1)
120
+ t0 = time.time()
121
+ data_iter = iter(loader)
122
+
123
+ def _next():
124
+ nonlocal data_iter
125
+ try:
126
+ return next(data_iter)
127
+ except StopIteration:
128
+ data_iter = iter(loader)
129
+ return next(data_iter)
130
+
131
+ for step in range(args.total_steps):
132
+ student_transformer.train()
133
+ opt_lora.zero_grad()
134
+ opt_d_head.zero_grad()
135
+ metrics = {}
136
+ for _ in range(args.grad_accum):
137
+ batch = _next()
138
+ x0 = batch["x0"].to(device=device, dtype=dtype)
139
+ noise = batch["noise"].to(device=device, dtype=dtype)
140
+ emb = batch["emb"].to(device=device, dtype=dtype)
141
+ B = x0.size(0)
142
+ B_boot = max(1, B // args.bootstrap_every)
143
+ B_flow = B - B_boot
144
+
145
+ # ----- Flow-matching half (d=0, infinitesimal) -----
146
+ t_fm_idx = torch.randint(0, T, (B_flow,), device=device)
147
+ t_fm = t_fm_idx.float() / T
148
+ t_fm_b = t_fm.view(-1, *([1] * (x0.dim() - 1)))
149
+ x0_fm = x0[:B_flow]; noise_fm = noise[:B_flow]; emb_fm = emb[:B_flow]
150
+ x_t_fm = (1 - t_fm_b) * x0_fm + t_fm_b * noise_fm
151
+ v_tgt_fm = noise_fm - x0_fm
152
+ d_fm = torch.zeros(B_flow, device=device, dtype=dtype)
153
+
154
+ # ----- Bootstrap half (d > 0, self-consistency) -----
155
+ if B_boot > 0:
156
+ k = torch.randint(0, log2_sections, (B_boot,), device=device)
157
+ d_b = (1.0 / (2.0 ** k.float())).to(dtype=dtype) # d ∈ {1/2^k}
158
+ # t aligned to d grid: t_b = (random int) / 2^k * 1
159
+ t_b_max = (2 ** k).float()
160
+ t_b_idx = (torch.rand(B_boot, device=device) * t_b_max).floor()
161
+ t_b = t_b_idx / t_b_max.clamp(min=1.0)
162
+ t_b_b = t_b.view(-1, *([1] * (x0.dim() - 1)))
163
+ x0_b = x0[B_flow:]; noise_b = noise[B_flow:]; emb_b = emb[B_flow:]
164
+ x_t_b = (1 - t_b_b) * x0_b + t_b_b * noise_b
165
+
166
+ # 2 sub-step bootstrap target (no_grad)
167
+ with torch.no_grad():
168
+ d_half = d_b * 0.5
169
+ v1 = student_v_with_d(x_t_b, t_b.to(dtype=dtype), emb_b, d_half)
170
+ dt_half = d_half.view(-1, *([1] * (x_t_b.dim() - 1)))
171
+ x_t2 = (x_t_b + dt_half * v1).clamp(-args.clip_x_bootstrap, args.clip_x_bootstrap)
172
+ t_b_half = (t_b + d_half.float()).clamp(0.0, 1.0)
173
+ v2 = student_v_with_d(x_t2, t_b_half.to(dtype=dtype), emb_b, d_half)
174
+ v_tgt_b = 0.5 * (v1 + v2)
175
+
176
+ # concat batch
177
+ x_cat = torch.cat([x_t_fm, x_t_b], dim=0)
178
+ t_cat = torch.cat([t_fm.to(dtype=dtype), t_b.to(dtype=dtype)], dim=0)
179
+ d_cat = torch.cat([d_fm, d_b], dim=0)
180
+ emb_cat = torch.cat([emb_fm, emb_b], dim=0)
181
+ v_tgt_cat = torch.cat([v_tgt_fm, v_tgt_b], dim=0)
182
+ else:
183
+ x_cat, t_cat, d_cat, emb_cat, v_tgt_cat = x_t_fm, t_fm.to(dtype=dtype), d_fm, emb_fm, v_tgt_fm
184
+
185
+ # 1 trainable forward
186
+ v_pred = student_v_with_d(x_cat, t_cat, emb_cat, d_cat)
187
+ loss = F.mse_loss(v_pred.float(), v_tgt_cat.detach().float()) / args.grad_accum
188
+ loss.backward()
189
+ metrics = {
190
+ "loss": float((loss * args.grad_accum).detach()),
191
+ "B_flow": B_flow, "B_boot": B_boot,
192
+ "v_pred_abs": float(v_pred.detach().abs().mean()),
193
+ "v_tgt_abs": float(v_tgt_cat.detach().abs().mean()),
194
+ }
195
+ torch.nn.utils.clip_grad_norm_(student_lora_params + d_head_params, args.grad_clip)
196
+ opt_lora.step()
197
+ opt_d_head.step()
198
+
199
+ if step % args.log_every == 0:
200
+ metrics["step"] = step
201
+ metrics["elapsed"] = time.time() - t0
202
+ log_f.write(json.dumps(metrics) + "\n")
203
+ msg = " ".join(f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}"
204
+ for k, v in metrics.items() if k != "step")
205
+ print(f"[step {step}/{args.total_steps}] {msg}", flush=True)
206
+
207
+ if step > 0 and step % args.sample_every == 0:
208
+ # LoRA + d_head 両方保存
209
+ save_lora_state(student_transformer, out_dir, f"shortcut_step{step:05d}")
210
+ torch.save(student_transformer.d_head.state_dict(),
211
+ out_dir / f"shortcut_d_head_step{step:05d}.pt")
212
+ print(f"[save] shortcut_step{step:05d}", flush=True)
213
+ try:
214
+ import modal
215
+ modal.Volume.from_name("anima-outputs").commit()
216
+ except Exception:
217
+ pass
218
+
219
+ print("[done] saving final")
220
+ save_lora_state(student_transformer, out_dir, "shortcut_final")
221
+ torch.save(student_transformer.d_head.state_dict(), out_dir / "shortcut_d_head_final.pt")
222
+ log_f.close()
223
+
224
+
225
+ if __name__ == "__main__":
226
+ main()
scripts/distill/train_sid.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Anima SiD2 / SiD-DiT distillation
4
+ =================================
5
+
6
+ DMD2 と同じ「2 LoRA adapter on shared base」パターンで実装。違いは:
7
+ - D も EMA も不要 (D は SiD identity formula に置換)
8
+ - data-free: caption のみで OK (画像は使わない)
9
+ - ψ (fake_score) を 200 step warmup してから generator を活性化
10
+ - ratio: ψ:θ = 1:1 (DMD2 の 5:1 と異なる)
11
+
12
+ 使い方:
13
+ modal run modal_app.py::train_sid_distill \\
14
+ --total-outer-steps 8000 --warm-lora /models/loras/anima_turbo.safetensors
15
+
16
+ 注意:
17
+ cache-dir は emb のみ使うので LADD cache でも reflow cache でも何でも良い。
18
+ 画像が無い caption だけのテキストファイル群でも、本質的には動く
19
+ (今回は既存 cache の emb を流用してコスト 0)。
20
+ """
21
+ from __future__ import annotations
22
+ import argparse
23
+ import copy
24
+ import json
25
+ import os
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+
30
+ import torch
31
+ from torch.utils.data import DataLoader
32
+
33
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
34
+ from distill.anima_loader import build_anima, AnimaBundle
35
+ from distill.dmd2_trainer import attach_wide_lora
36
+ from distill.train_dmd2_official import (
37
+ attach_dual_lora, make_velocity_fn, make_teacher_velocity_fn,
38
+ )
39
+ from distill.train_traj import save_lora_state, convert_comfy_to_peft_lora
40
+ from distill.sid_loss import sid_generator_loss, sid_score_helper_loss
41
+ from distill.train_ladd import PrecomputedCacheDataset, ladd_collate
42
+ from safetensors.torch import load_file
43
+
44
+
45
+ def main():
46
+ ap = argparse.ArgumentParser()
47
+ ap.add_argument("--cache-dir", required=True, type=str,
48
+ help="emb のみ使用 (画像は不要)。LADD/Reflow cache 流用 OK")
49
+ ap.add_argument("--out", required=True, type=str)
50
+ ap.add_argument("--warm-lora", default="", type=str)
51
+ ap.add_argument("--total-outer-steps", type=int, default=8000)
52
+ ap.add_argument("--psi-warmup-steps", type=int, default=200,
53
+ help="ψ を先に warmup し、それ以降 θ を活性化")
54
+ ap.add_argument("--n-student-steps", type=int, default=4)
55
+ ap.add_argument("--batch-size", type=int, default=2)
56
+ ap.add_argument("--grad-accum", type=int, default=2)
57
+ ap.add_argument("--resolution", type=int, default=768)
58
+ ap.add_argument("--teacher-cfg", type=float, default=4.5)
59
+ ap.add_argument("--student-cfg", type=float, default=1.0)
60
+ ap.add_argument("--alpha", type=float, default=1.2, help="SiD2 identity weight")
61
+ ap.add_argument("--mu-t", type=float, default=0.6931, help="ln 2")
62
+ ap.add_argument("--sigma-t", type=float, default=1.6)
63
+ ap.add_argument("--lora-rank", type=int, default=32)
64
+ ap.add_argument("--lr-gen", type=float, default=1e-5)
65
+ ap.add_argument("--lr-psi", type=float, default=2e-5)
66
+ ap.add_argument("--weight-decay", type=float, default=0.01)
67
+ ap.add_argument("--grad-clip", type=float, default=1.0)
68
+ ap.add_argument("--neg-prompt", default="")
69
+ ap.add_argument("--log-every", type=int, default=10)
70
+ ap.add_argument("--sample-every", type=int, default=500)
71
+ ap.add_argument("--num-workers", type=int, default=2)
72
+ ap.add_argument("--seed", type=int, default=42)
73
+ args = ap.parse_args()
74
+
75
+ torch.manual_seed(args.seed)
76
+ device = torch.device("cuda")
77
+ dtype = torch.bfloat16
78
+ out_dir = Path(args.out)
79
+ out_dir.mkdir(parents=True, exist_ok=True)
80
+
81
+ print("[load] Anima bundle")
82
+ bundle = build_anima(device=device, dtype=dtype)
83
+
84
+ # teacher = deepcopy
85
+ print("[setup] teacher = frozen deepcopy")
86
+ teacher_transformer = copy.deepcopy(bundle.transformer).to(device=device, dtype=dtype).eval()
87
+ for p in teacher_transformer.parameters():
88
+ p.requires_grad = False
89
+
90
+ # dual-adapter (student + fake_score / SiD-DiT は ψ と呼ぶ)
91
+ print("[setup] dual-adapter (student + psi)")
92
+ peft_model = attach_dual_lora(bundle.transformer, rank=args.lora_rank,
93
+ adapter_names=("student", "psi"))
94
+ peft_model.to(device=device, dtype=dtype)
95
+ for n, p in peft_model.named_parameters():
96
+ p.requires_grad = ("lora_" in n)
97
+ bundle.transformer = peft_model
98
+
99
+ student_params = [p for n, p in peft_model.named_parameters()
100
+ if p.requires_grad and ".student." in n]
101
+ psi_params = [p for n, p in peft_model.named_parameters()
102
+ if p.requires_grad and ".psi." in n]
103
+ print(f"[setup] student: {sum(p.numel() for p in student_params)/1e6:.1f}M")
104
+ print(f"[setup] psi: {sum(p.numel() for p in psi_params)/1e6:.1f}M")
105
+
106
+ # warm-start (student に注入)
107
+ if args.warm_lora:
108
+ print(f"[warm] loading {args.warm_lora} → student adapter")
109
+ sd_comfy = load_file(args.warm_lora)
110
+ sd_peft_default = convert_comfy_to_peft_lora(sd_comfy)
111
+ sd_student = {}
112
+ for k, v in sd_peft_default.items():
113
+ nk = k.replace(".lora_A.default.weight", ".lora_A.student.weight")
114
+ nk = nk.replace(".lora_B.default.weight", ".lora_B.student.weight")
115
+ sd_student[nk] = v
116
+ model_sd = peft_model.state_dict()
117
+ to_load = {k: v.to(dtype=model_sd[k].dtype) for k, v in sd_student.items()
118
+ if k in model_sd and model_sd[k].shape == v.shape}
119
+ print(f"[warm] matched {len(to_load)}/{len(sd_student)} keys")
120
+ peft_model.load_state_dict(to_load, strict=False)
121
+
122
+ # optimizers
123
+ opt_gen = torch.optim.AdamW(student_params, lr=args.lr_gen,
124
+ betas=(0.9, 0.999), weight_decay=args.weight_decay)
125
+ opt_psi = torch.optim.AdamW(psi_params, lr=args.lr_psi,
126
+ betas=(0.9, 0.999), weight_decay=args.weight_decay)
127
+
128
+ # velocity functions
129
+ student_v = make_velocity_fn(peft_model, "student")
130
+ psi_v = make_velocity_fn(peft_model, "psi")
131
+ teacher_v = make_teacher_velocity_fn(teacher_transformer)
132
+
133
+ # dataset (emb のみ使う、x0 は initial noise の source として扱う)
134
+ print(f"[data] {args.cache_dir}")
135
+ dataset = PrecomputedCacheDataset(args.cache_dir)
136
+ print(f" {len(dataset)} captions")
137
+ loader = DataLoader(
138
+ dataset, batch_size=args.batch_size, shuffle=True,
139
+ num_workers=args.num_workers, collate_fn=ladd_collate,
140
+ drop_last=True, pin_memory=True,
141
+ )
142
+
143
+ with torch.no_grad():
144
+ cond_neg = bundle.text_encode([args.neg_prompt or ""])
145
+
146
+ H_lat = args.resolution // 8
147
+ W_lat = args.resolution // 8
148
+
149
+ print(f"[train] outer={args.total_outer_steps} bs={args.batch_size} accum={args.grad_accum} "
150
+ f"psi_warmup={args.psi_warmup_steps} n_steps={args.n_student_steps}")
151
+ log_path = out_dir / "sid_log.jsonl"
152
+ log_f = open(log_path, "a", buffering=1)
153
+ t0 = time.time()
154
+ data_iter = iter(loader)
155
+
156
+ def _next():
157
+ nonlocal data_iter
158
+ try:
159
+ return next(data_iter)
160
+ except StopIteration:
161
+ data_iter = iter(loader)
162
+ return next(data_iter)
163
+
164
+ def _sample_inputs():
165
+ batch = _next()
166
+ cond_pos = batch["emb"].to(device=device, dtype=dtype)
167
+ B = cond_pos.size(0)
168
+ cond_neg_b = cond_neg.expand(B, -1, -1).contiguous() if B > 1 else cond_neg
169
+ noise = torch.randn(B, 16, 1, H_lat, W_lat, device=device, dtype=dtype)
170
+ return noise, cond_pos, cond_neg_b
171
+
172
+ for outer in range(args.total_outer_steps):
173
+ # ---- ψ (score helper) update (every step, including warmup) ----
174
+ peft_model.train()
175
+ opt_psi.zero_grad()
176
+ psi_metrics = {}
177
+ for _ in range(args.grad_accum):
178
+ noise, cond_pos, cond_neg_b = _sample_inputs()
179
+ L_psi, m_psi = sid_score_helper_loss(
180
+ student_v, psi_v, noise, cond_pos, cond_neg_b,
181
+ student_cfg=args.student_cfg, n_steps=args.n_student_steps,
182
+ mu_t=args.mu_t, sigma_t=args.sigma_t,
183
+ )
184
+ (L_psi / args.grad_accum).backward()
185
+ psi_metrics = {k: float(v) for k, v in m_psi.items()}
186
+ torch.nn.utils.clip_grad_norm_(psi_params, args.grad_clip)
187
+ opt_psi.step()
188
+
189
+ # ---- θ (generator) update (warmup 後のみ) ----
190
+ gen_metrics = {}
191
+ if outer >= args.psi_warmup_steps:
192
+ opt_gen.zero_grad()
193
+ for _ in range(args.grad_accum):
194
+ noise, cond_pos, cond_neg_b = _sample_inputs()
195
+ L_theta, m_theta = sid_generator_loss(
196
+ student_v, teacher_v, psi_v, noise, cond_pos, cond_neg_b,
197
+ teacher_cfg=args.teacher_cfg, student_cfg=args.student_cfg,
198
+ n_steps=args.n_student_steps, alpha=args.alpha,
199
+ mu_t=args.mu_t, sigma_t=args.sigma_t,
200
+ )
201
+ (L_theta / args.grad_accum).backward()
202
+ gen_metrics = {k: float(v) for k, v in m_theta.items()}
203
+ torch.nn.utils.clip_grad_norm_(student_params, args.grad_clip)
204
+ opt_gen.step()
205
+
206
+ if outer % args.log_every == 0:
207
+ metrics = {"outer": outer, "elapsed": time.time() - t0,
208
+ "phase": "warmup" if outer < args.psi_warmup_steps else "joint",
209
+ **psi_metrics, **gen_metrics}
210
+ log_f.write(json.dumps(metrics) + "\n")
211
+ msg = " ".join(f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}"
212
+ for k, v in metrics.items() if k not in ("outer",))
213
+ print(f"[outer {outer}/{args.total_outer_steps}] {msg}", flush=True)
214
+
215
+ if outer > 0 and outer % args.sample_every == 0:
216
+ sd = {k: v.detach().cpu() for k, v in peft_model.state_dict().items()
217
+ if "lora_" in k and ".student." in k}
218
+ from safetensors.torch import save_file as _sf
219
+ _sf(sd, str(out_dir / f"sid_student_step{outer:05d}.safetensors"))
220
+ print(f"[save] sid_student_step{outer:05d}.safetensors", flush=True)
221
+ try:
222
+ import modal
223
+ modal.Volume.from_name("anima-outputs").commit()
224
+ except Exception:
225
+ pass
226
+
227
+ print("[done] saving final")
228
+ sd_student_final = {k: v.detach().cpu() for k, v in peft_model.state_dict().items()
229
+ if "lora_" in k and ".student." in k}
230
+ from safetensors.torch import save_file as _sf
231
+ _sf(sd_student_final, str(out_dir / "sid_student_final.safetensors"))
232
+ log_f.close()
233
+
234
+
235
+ if __name__ == "__main__":
236
+ main()
scripts/distill/train_sota.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Anima SOTA distillation main entrypoint
4
+ ========================================
5
+
6
+ 3 phase progressive 蒸留:
7
+ Phase A: 8-step, DMD2 + light TSCD + light adv
8
+ Phase B: 4-step, balanced
9
+ Phase C: 2-step, full adv weight
10
+
11
+ 各 phase 完了時に LoRA を保存し、次 phase へ。
12
+
13
+ 使い方 (Modal 経由):
14
+ modal run --detach modal_app.py::train_sota_distill --phase a
15
+ modal run --detach modal_app.py::train_sota_distill --phase b --resume /output/distill/phase_a_final
16
+ modal run --detach modal_app.py::train_sota_distill --phase c --resume /output/distill/phase_b_final
17
+ """
18
+ from __future__ import annotations
19
+ import argparse
20
+ import copy
21
+ import json
22
+ import os
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+
27
+ import torch
28
+ from torch.utils.data import DataLoader
29
+ from safetensors.torch import save_file
30
+
31
+ # import-path: 同ディレクトリ前提
32
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
33
+ from distill.anima_loader import AnimaPaths, build_anima
34
+ from distill.dataset import AnimaImageCaptionDataset, collate_fn
35
+ from distill.dmd2_trainer import (
36
+ DMD2Args, DMD2Trainer, attach_qv_lora, attach_wide_lora, set_lora_scale,
37
+ )
38
+ from distill.r3gan_disc import R3GANDiscriminator
39
+
40
+
41
+ # ----- phase 設定 -----------------------------------------------------------
42
+ # Phase A postmortem の教訓: R3GAN gamma calibration ミスで gen 破壊。
43
+ # Phase B/C は **R3GAN を完全 disable** (adv_weight=0) で DMD+TSCD のみで蒸留。
44
+ # R3GAN は後付け実験(別途 phase b_r3gan 等)で導入する想定。
45
+ PHASE_CONFIGS = {
46
+ "a": DMD2Args(
47
+ num_inference_steps=8,
48
+ tscd_weight=0.3, adv_weight=0.0, r3gan_gamma=0.0, # disabled
49
+ cfg_aug_cold_steps=200, dynamic_decay_steps=3000,
50
+ ),
51
+ "b": DMD2Args(
52
+ num_inference_steps=4,
53
+ tscd_weight=0.5, adv_weight=0.0, r3gan_gamma=0.0, # disabled
54
+ cfg_aug_cold_steps=100, dynamic_decay_steps=2000,
55
+ ),
56
+ "c": DMD2Args(
57
+ num_inference_steps=2,
58
+ tscd_weight=0.5, adv_weight=0.0, r3gan_gamma=0.0, # disabled
59
+ cfg_aug_cold_steps=50, dynamic_decay_steps=1500,
60
+ ),
61
+ }
62
+
63
+
64
+ def save_lora_state(model, path: Path, name: str) -> None:
65
+ """PEFT-wrapped model から LoRA weight だけ抽出して safetensors で保存。"""
66
+ path.mkdir(parents=True, exist_ok=True)
67
+ sd = {k: v.detach().cpu() for k, v in model.state_dict().items() if "lora_" in k}
68
+ save_file(sd, str(path / f"{name}.safetensors"))
69
+
70
+
71
+ def save_full_state(state_dict: dict, path: Path) -> None:
72
+ """フル状態(gen の全 weights + buffers)を safetensors で保存。
73
+ 重要: float 限定で filter すると RoPE 等 integer buffer が落ちて model 壊れる。"""
74
+ path.parent.mkdir(parents=True, exist_ok=True)
75
+ cpu_sd = {k: v.detach().cpu().contiguous() for k, v in state_dict.items()
76
+ if isinstance(v, torch.Tensor)}
77
+ save_file(cpu_sd, str(path))
78
+
79
+
80
+ def main():
81
+ ap = argparse.ArgumentParser()
82
+ ap.add_argument("--phase", choices=["a", "b", "c"], required=True)
83
+ ap.add_argument("--dataset", required=True, type=str,
84
+ help="/dataset/raw のような image+caption ディレクトリ")
85
+ ap.add_argument("--out", required=True, type=str,
86
+ help="checkpoint 出力先ディレクトリ")
87
+ ap.add_argument("--resume", default=None, type=str,
88
+ help="前 phase の出力ディレクトリ (phase B/C で必須)")
89
+ ap.add_argument("--total-steps", type=int, default=3000)
90
+ ap.add_argument("--sample-every", type=int, default=500)
91
+ ap.add_argument("--log-every", type=int, default=10)
92
+ ap.add_argument("--resolution", type=int, default=1024)
93
+ ap.add_argument("--num-workers", type=int, default=4)
94
+ ap.add_argument("--seed", type=int, default=42)
95
+ ap.add_argument("--bf16", action="store_true", default=True)
96
+ ap.add_argument("--gen-lora-only", action="store_true", default=False,
97
+ help="gen にも Q,V LoRA を attach し base を凍結。Phase B/C 用。"
98
+ "出力は LoRA weights のみ (数百MB) になる。")
99
+ ap.add_argument("--override-adv-weight", type=float, default=-1.0,
100
+ help="PHASE_CONFIGS の adv_weight を上書き (−1 = 上書きしない)")
101
+ ap.add_argument("--override-r3gan-gamma", type=float, default=-1.0,
102
+ help="PHASE_CONFIGS の r3gan_gamma を上書き (−1 = 上書きしない)")
103
+ args = ap.parse_args()
104
+
105
+ torch.manual_seed(args.seed)
106
+ device = torch.device("cuda")
107
+ dtype = torch.bfloat16 if args.bf16 else torch.float32
108
+
109
+ out_dir = Path(args.out)
110
+ out_dir.mkdir(parents=True, exist_ok=True)
111
+
112
+ # ----- load Anima base -----
113
+ print("[load] Anima bundle (DiT + Qwen3 + WanVAE + LLMAdapter)")
114
+ bundle = build_anima(device=device, dtype=dtype)
115
+
116
+ # ----- gen / guidance を base から複製 -----
117
+ print("[setup] cloning DiT to gen + guidance")
118
+ gen_transformer = copy.deepcopy(bundle.transformer).to(device=device, dtype=dtype)
119
+ guidance_transformer = copy.deepcopy(bundle.transformer).to(device=device, dtype=dtype)
120
+ # bundle.transformer は推論用途で残してもよいが、メモリ節約のため None 化:
121
+ bundle.transformer = None
122
+ torch.cuda.empty_cache()
123
+
124
+ # ----- LoRA を guidance に attach -----
125
+ args_phase = PHASE_CONFIGS[args.phase]
126
+ print(f"[setup] attaching Q,V LoRA (rank={args_phase.lora_rank}) to guidance")
127
+ guidance_transformer = attach_qv_lora(guidance_transformer, rank=args_phase.lora_rank)
128
+ guidance_transformer.to(device=device, dtype=dtype)
129
+
130
+ # base 部分の grad を切る (LoRA だけ訓練)
131
+ for n, p in guidance_transformer.named_parameters():
132
+ p.requires_grad = ("lora_" in n)
133
+
134
+ # ----- resume (前 phase の gen 重みを新 base として引き継ぐ) -----
135
+ # Phase B/C は Phase A の蒸留済 DiT を base にする想定。LoRA-only 学習時は
136
+ # この段階で gen_transformer に Phase A の重みをロード → その後 LoRA attach + 凍結。
137
+ if args.resume:
138
+ resume_dir = Path(args.resume)
139
+ gen_ckpt = resume_dir / "gen_final.safetensors"
140
+ if gen_ckpt.exists():
141
+ print(f"[resume] loading gen from {gen_ckpt}")
142
+ from safetensors.torch import load_file
143
+ sd = load_file(str(gen_ckpt))
144
+ missing, unexpected = gen_transformer.load_state_dict(sd, strict=False)
145
+ print(f" missing={len(missing)} unexpected={len(unexpected)}")
146
+
147
+ # ----- CLI override for grid search / experimentation -----
148
+ if args.override_adv_weight >= 0:
149
+ args_phase.adv_weight = args.override_adv_weight
150
+ print(f"[override] adv_weight -> {args_phase.adv_weight}")
151
+ if args.override_r3gan_gamma >= 0:
152
+ args_phase.r3gan_gamma = args.override_r3gan_gamma
153
+ print(f"[override] r3gan_gamma -> {args_phase.r3gan_gamma}")
154
+
155
+ # ----- LoRA-only モード: gen に wide LoRA (all-linear) を attach + base 凍結 -----
156
+ # 注: Q,V LoRA だと AdaLN modulation を触れず、Anima のような timestep 依存
157
+ # architecture では蒸留信号が degenerate に収束する (Phase B v1 で失敗)。
158
+ # wide にして AdaLN + attention + MLP を全部学習対象に。
159
+ if args.gen_lora_only:
160
+ print(f"[setup] gen-lora-only mode: attaching WIDE LoRA (rank={args_phase.lora_rank}) to gen")
161
+ gen_transformer = attach_wide_lora(gen_transformer, rank=args_phase.lora_rank)
162
+ gen_transformer.to(device=device, dtype=dtype)
163
+ # base を凍結、LoRA だけ trainable
164
+ for n, p in gen_transformer.named_parameters():
165
+ p.requires_grad = ("lora_" in n)
166
+ trainable = sum(p.numel() for p in gen_transformer.parameters() if p.requires_grad)
167
+ total = sum(p.numel() for p in gen_transformer.parameters())
168
+ print(f" gen trainable params: {trainable/1e6:.1f}M / {total/1e6:.1f}M total")
169
+
170
+ # ----- discriminator -----
171
+ # adv_weight=0 のときは構築しない (Phase A 教訓: R3GAN 自体が不安定要因)
172
+ if args_phase.adv_weight > 0:
173
+ print("[setup] R3GAN discriminator (latent space, text-conditional)")
174
+ disc = R3GANDiscriminator(latent_channels=16, cond_dim=1024).to(device=device, dtype=dtype)
175
+ else:
176
+ print("[setup] R3GAN DISABLED (adv_weight=0) — DMD+TSCD のみで蒸留")
177
+ # placeholder: trainer は disc を受け取るが forward は呼ばれない
178
+ disc = R3GANDiscriminator(latent_channels=16, cond_dim=1024).to(device=device, dtype=dtype)
179
+
180
+ # ----- dataset -----
181
+ print(f"[data] loading {args.dataset}")
182
+ dataset = AnimaImageCaptionDataset(args.dataset, resolution=args.resolution)
183
+ print(f" {len(dataset)} samples")
184
+ loader = DataLoader(
185
+ dataset,
186
+ batch_size=args_phase.batch_size,
187
+ shuffle=True,
188
+ num_workers=args.num_workers,
189
+ collate_fn=collate_fn,
190
+ drop_last=True,
191
+ pin_memory=True,
192
+ )
193
+
194
+ # ----- trainer -----
195
+ trainer = DMD2Trainer(
196
+ bundle=bundle,
197
+ gen_transformer=gen_transformer,
198
+ guidance_transformer=guidance_transformer,
199
+ discriminator=disc,
200
+ args=args_phase,
201
+ )
202
+
203
+ # ----- training loop -----
204
+ print(f"[train] phase={args.phase} steps={args.total_steps} batch={args_phase.batch_size}")
205
+ log_path = out_dir / f"phase_{args.phase}_log.jsonl"
206
+ log_f = open(log_path, "a", buffering=1)
207
+ t0 = time.time()
208
+ data_iter = iter(loader)
209
+
210
+ for step in range(args.total_steps):
211
+ try:
212
+ batch = next(data_iter)
213
+ except StopIteration:
214
+ data_iter = iter(loader)
215
+ batch = next(data_iter)
216
+
217
+ metrics = trainer.train_step(batch)
218
+
219
+ if step % args.log_every == 0:
220
+ scalars = {k: float(v) for k, v in metrics.items()}
221
+ scalars["step"] = step
222
+ scalars["elapsed"] = time.time() - t0
223
+ log_f.write(json.dumps(scalars) + "\n")
224
+ msg = " ".join(f"{k}={v:.4f}" for k, v in scalars.items() if k not in ("step", "elapsed"))
225
+ print(f"[step {step}/{args.total_steps}] elapsed={scalars['elapsed']:.0f}s | {msg}")
226
+
227
+ if step > 0 and step % args.sample_every == 0:
228
+ if args.gen_lora_only:
229
+ save_lora_state(trainer.gen, out_dir, f"phase_{args.phase}_step{step:05d}_gen_lora")
230
+ print(f"[save] phase_{args.phase}_step{step:05d}_gen_lora.safetensors", flush=True)
231
+ else:
232
+ ckpt = out_dir / f"phase_{args.phase}_step{step:05d}_gen.safetensors"
233
+ save_full_state(trainer.gen.state_dict(), ckpt)
234
+ print(f"[save] {ckpt}", flush=True)
235
+ # Modal volume の commit を内部で発行(途中クラッシュからの保全)
236
+ try:
237
+ import modal
238
+ vol = modal.Volume.from_name("anima-outputs")
239
+ vol.commit()
240
+ print(f"[save] volume committed at step {step}", flush=True)
241
+ except Exception as e:
242
+ print(f"[save] volume commit failed: {e}", flush=True)
243
+
244
+ # final save
245
+ print("[done] saving final outputs")
246
+ if args.gen_lora_only:
247
+ # LoRA-only: gen の LoRA weights だけ保存(数百 MB)
248
+ save_lora_state(trainer.gen, out_dir, "gen_lora_final")
249
+ print(f" -> gen_lora_final.safetensors (LoRA-only mode)")
250
+ else:
251
+ # Full DiT: gen の全 weights を保存(~4 GB)
252
+ save_full_state(trainer.gen.state_dict(), out_dir / "gen_final.safetensors")
253
+ print(f" -> gen_final.safetensors (full DiT)")
254
+ # guidance LoRA は両モード共通で残しておく(分析・実験用)
255
+ save_lora_state(trainer.guidance, out_dir, "guidance_lora_final")
256
+ log_f.close()
257
+
258
+
259
+ if __name__ == "__main__":
260
+ main()
scripts/distill/train_traj.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Anima trajectory imitation distillation
4
+ ========================================
5
+
6
+ DiffSynth-Studio の Z-Image trajectory imitation を Anima に最小移植したもの。
7
+ **critic なし、単一ネットワーク**で前回 5 回失敗の R3GAN 不安定性を完全回避。
8
+
9
+ アルゴリズム:
10
+ - teacher = Anima base (frozen, no LoRA) を 50-step CFG=2.0 で rollout
11
+ - student = Anima base + wide LoRA (rank 32) を 8-step CFG=1.0 で rollout
12
+ - L_align = MSE(student velocity, teacher segment velocity) on 8 segments
13
+ - L_reg = LPIPS(student final image, teacher final image) (任意、--lpips-weight>0 で有効)
14
+
15
+ warm-start:
16
+ --warm-lora /models/loras/anima_turbo.safetensors で Civitai 公式 Anima Turbo LoRA を
17
+ student LoRA 初期値に注入できる。format は ComfyUI 形式 (diffusion_model.<...>.lora_A.weight)、
18
+ 内部で PEFT 形式に変換してロード。
19
+
20
+ 使い方 (Modal 経由):
21
+ # Smoke test (1 step, sanity check)
22
+ modal run modal_app.py::train_traj_imitation --total-steps 1 --batch-size 1 \\
23
+ --teacher-steps 12 --student-steps 8 --lpips-weight 0.0
24
+
25
+ # 本番 (2000 step, ~$45, B200)
26
+ modal run --detach modal_app.py::train_traj_imitation \\
27
+ --total-steps 2000 --batch-size 1 --teacher-steps 50 --student-steps 8 \\
28
+ --warm-lora /models/loras/anima_turbo.safetensors --lpips-weight 0.1
29
+ """
30
+ from __future__ import annotations
31
+ import argparse
32
+ import copy
33
+ import json
34
+ import os
35
+ import sys
36
+ import time
37
+ from pathlib import Path
38
+
39
+ import torch
40
+ import torch.nn.functional as F
41
+ from torch.utils.data import DataLoader, Dataset
42
+ from safetensors.torch import save_file, load_file
43
+
44
+ # import-path: 同ディレクトリ前提
45
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
46
+ from distill.anima_loader import AnimaPaths, build_anima, AnimaBundle
47
+ from distill.dmd2_trainer import attach_wide_lora
48
+ from distill.traj_scheduler import make_schedule, snap_to_targets
49
+ from distill.traj_loss import (
50
+ fetch_trajectory, align_trajectory, compute_regularization,
51
+ )
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Text-only dataset: caption だけ使う (trajectory imitation は init=noise なので image 不要)
56
+ # ---------------------------------------------------------------------------
57
+ class TextOnlyDataset(Dataset):
58
+ def __init__(self, root: str | Path):
59
+ self.root = Path(root)
60
+ # *.txt を再帰的に拾う。/dataset/cleaned 配下を想定。
61
+ self.files = sorted(self.root.rglob("*.txt"))
62
+ if not self.files:
63
+ raise RuntimeError(f"No .txt files under {self.root}")
64
+
65
+ def __len__(self) -> int:
66
+ return len(self.files)
67
+
68
+ def __getitem__(self, idx: int) -> str:
69
+ return self.files[idx].read_text(encoding="utf-8").strip()
70
+
71
+
72
+ def text_collate(batch: list[str]) -> list[str]:
73
+ return list(batch)
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # ComfyUI Anima LoRA -> PEFT 形式変換 (warm-start 用)
78
+ # 既存の _convert_peft_to_comfy_lora の逆向き
79
+ # ---------------------------------------------------------------------------
80
+ def convert_comfy_to_peft_lora(sd: dict) -> dict:
81
+ """ComfyUI(Anima/Cosmos)形式 LoRA を PEFT 形式に変換。
82
+ Comfy: 'diffusion_model.<module>.lora_A.weight'
83
+ PEFT: 'base_model.model.<module>.lora_A.default.weight'
84
+ """
85
+ out = {}
86
+ for k, v in sd.items():
87
+ nk = k
88
+ if nk.startswith("diffusion_model."):
89
+ nk = nk[len("diffusion_model."):]
90
+ # adapter name '.default.' を挿入
91
+ nk = nk.replace(".lora_A.weight", ".lora_A.default.weight")
92
+ nk = nk.replace(".lora_B.weight", ".lora_B.default.weight")
93
+ nk = nk.replace(".lora_A.bias", ".lora_A.default.bias")
94
+ nk = nk.replace(".lora_B.bias", ".lora_B.default.bias")
95
+ nk = "base_model.model." + nk
96
+ out[nk] = v
97
+ return out
98
+
99
+
100
+ def load_warm_lora(student_model, warm_lora_path: str) -> None:
101
+ """Civitai 形式の Anima Turbo LoRA を student の PEFT 形式に変換して注入。
102
+ shape / rank が合わない key は skip して警告。"""
103
+ print(f"[warm] loading {warm_lora_path}")
104
+ sd_comfy = load_file(warm_lora_path)
105
+ print(f"[warm] {len(sd_comfy)} comfy keys, sample: {list(sd_comfy.keys())[:3]}")
106
+ sd_peft = convert_comfy_to_peft_lora(sd_comfy)
107
+ print(f"[warm] converted to {len(sd_peft)} peft keys, sample: {list(sd_peft.keys())[:3]}")
108
+
109
+ # student の現 state_dict と shape を照合して filter
110
+ model_sd = student_model.state_dict()
111
+ matched, skipped_shape, missing = 0, 0, 0
112
+ to_load = {}
113
+ for k, v in sd_peft.items():
114
+ if k not in model_sd:
115
+ missing += 1
116
+ continue
117
+ if model_sd[k].shape != v.shape:
118
+ skipped_shape += 1
119
+ continue
120
+ to_load[k] = v.to(dtype=model_sd[k].dtype)
121
+ matched += 1
122
+ print(f"[warm] matched={matched} skipped_shape={skipped_shape} missing={missing}")
123
+ if matched == 0:
124
+ raise RuntimeError(
125
+ "No LoRA keys matched. Check format conversion & target_modules of wide LoRA."
126
+ )
127
+ missing_keys, _ = student_model.load_state_dict(to_load, strict=False)
128
+ # PEFT 内部の LoRA キーだけ載れば OK、その他 missing は base 側なので無視
129
+ lora_missing = [k for k in missing_keys if "lora_" in k]
130
+ if lora_missing:
131
+ print(f"[warm] WARN: {len(lora_missing)} lora keys not loaded (e.g. {lora_missing[:3]})")
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # save / restore
136
+ # ---------------------------------------------------------------------------
137
+ def save_lora_state(model, path: Path, name: str) -> None:
138
+ path.mkdir(parents=True, exist_ok=True)
139
+ sd = {k: v.detach().cpu() for k, v in model.state_dict().items() if "lora_" in k}
140
+ save_file(sd, str(path / f"{name}.safetensors"))
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # main
145
+ # ---------------------------------------------------------------------------
146
+ def main():
147
+ ap = argparse.ArgumentParser()
148
+ ap.add_argument("--dataset", required=True, type=str,
149
+ help="caption (*.txt) を含むディレクトリ")
150
+ ap.add_argument("--out", required=True, type=str,
151
+ help="LoRA 出力ディレクトリ")
152
+ ap.add_argument("--warm-lora", default="", type=str,
153
+ help="Civitai Anima Turbo LoRA path (ComfyUI 形式)。空なら cold-start")
154
+ ap.add_argument("--total-steps", type=int, default=2000)
155
+ ap.add_argument("--batch-size", type=int, default=1)
156
+ ap.add_argument("--teacher-steps", type=int, default=50)
157
+ ap.add_argument("--student-steps", type=int, default=8)
158
+ ap.add_argument("--teacher-cfg", type=float, default=2.0)
159
+ ap.add_argument("--student-cfg", type=float, default=1.0)
160
+ ap.add_argument("--sigma-shift", type=float, default=3.0,
161
+ help="Anima 公式 workflow は 3.0")
162
+ ap.add_argument("--lora-rank", type=int, default=32)
163
+ ap.add_argument("--lr", type=float, default=1e-4)
164
+ ap.add_argument("--weight-decay", type=float, default=0.01)
165
+ ap.add_argument("--grad-clip", type=float, default=1.0)
166
+ ap.add_argument("--lpips-weight", type=float, default=0.0,
167
+ help="LPIPS regularization weight。0 で disable (start 推奨)、後で 0.1-0.5 に")
168
+ ap.add_argument("--resolution", type=int, default=1024)
169
+ ap.add_argument("--log-every", type=int, default=10)
170
+ ap.add_argument("--sample-every", type=int, default=500)
171
+ ap.add_argument("--num-workers", type=int, default=2)
172
+ ap.add_argument("--seed", type=int, default=42)
173
+ ap.add_argument("--weight-mode", default="uniform", choices=["uniform", "inv_sigma"])
174
+ ap.add_argument("--neg-prompt", default="",
175
+ help="negative prompt for teacher CFG (空文字 = empty conditioning)")
176
+ args = ap.parse_args()
177
+
178
+ torch.manual_seed(args.seed)
179
+ device = torch.device("cuda")
180
+ dtype = torch.bfloat16
181
+
182
+ out_dir = Path(args.out)
183
+ out_dir.mkdir(parents=True, exist_ok=True)
184
+
185
+ # ----- load Anima base -----
186
+ print("[load] Anima bundle (DiT + Qwen3 + WanVAE + LLMAdapter)")
187
+ bundle = build_anima(device=device, dtype=dtype)
188
+
189
+ # ----- teacher = base のクローン (frozen, no LoRA) -----
190
+ print("[setup] cloning DiT for teacher (frozen) and student (wide LoRA)")
191
+ teacher_transformer = copy.deepcopy(bundle.transformer).to(device=device, dtype=dtype).eval()
192
+ for p in teacher_transformer.parameters():
193
+ p.requires_grad = False
194
+
195
+ # ----- student = base に wide LoRA を attach -----
196
+ student_transformer = bundle.transformer # bundle 側の方を再利用 (メモリ節約)
197
+ student_transformer = attach_wide_lora(student_transformer, rank=args.lora_rank)
198
+ student_transformer.to(device=device, dtype=dtype)
199
+ # base 凍結、LoRA だけ trainable
200
+ for n, p in student_transformer.named_parameters():
201
+ p.requires_grad = ("lora_" in n)
202
+ trainable = sum(p.numel() for p in student_transformer.parameters() if p.requires_grad)
203
+ total = sum(p.numel() for p in student_transformer.parameters())
204
+ print(f"[setup] student trainable: {trainable/1e6:.1f}M / {total/1e6:.1f}M")
205
+ # bundle.transformer は student と同じ object (LoRA wrap 済)。
206
+ # text_encode/vae_*以外には bundle.transformer 不要なので参照だけ残す。
207
+ bundle.transformer = student_transformer
208
+
209
+ # ----- warm-start (任意) -----
210
+ if args.warm_lora:
211
+ load_warm_lora(student_transformer, args.warm_lora)
212
+
213
+ # ----- LPIPS (任意) -----
214
+ lpips_fn = None
215
+ if args.lpips_weight > 0:
216
+ import lpips as _lpips
217
+ lpips_fn = _lpips.LPIPS(net="alex").to(device).eval()
218
+ for p in lpips_fn.parameters():
219
+ p.requires_grad = False
220
+ print("[setup] LPIPS(alex) loaded for regularization")
221
+
222
+ # ----- schedules -----
223
+ # student schedule: 推論時に使う 8-step grid (t=1 → 0)
224
+ student_sched = make_schedule(args.student_steps, args.sigma_shift, device=device, dtype=torch.float32)
225
+ student_ts = student_sched.timesteps # (N+1,)
226
+ print(f"[schedule] student t = {student_ts.tolist()}")
227
+
228
+ # ----- dataset -----
229
+ print(f"[data] loading {args.dataset}")
230
+ dataset = TextOnlyDataset(args.dataset)
231
+ print(f" {len(dataset)} captions")
232
+ loader = DataLoader(
233
+ dataset,
234
+ batch_size=args.batch_size,
235
+ shuffle=True,
236
+ num_workers=args.num_workers,
237
+ collate_fn=text_collate,
238
+ drop_last=True,
239
+ )
240
+
241
+ # ----- optimizer -----
242
+ opt = torch.optim.AdamW(
243
+ [p for p in student_transformer.parameters() if p.requires_grad],
244
+ lr=args.lr, betas=(0.9, 0.999), weight_decay=args.weight_decay, eps=1e-8,
245
+ )
246
+
247
+ # ----- training loop -----
248
+ print(f"[train] total_steps={args.total_steps} batch={args.batch_size} "
249
+ f"teacher={args.teacher_steps} student={args.student_steps} "
250
+ f"lpips_weight={args.lpips_weight}")
251
+ log_path = out_dir / "traj_log.jsonl"
252
+ log_f = open(log_path, "a", buffering=1)
253
+ t0 = time.time()
254
+ data_iter = iter(loader)
255
+
256
+ # student velocity_fn: バッチ次元 latents + (B,) timesteps + cond -> velocity
257
+ def _student_v(x, t, cond):
258
+ return AnimaBundle.dit_forward(student_transformer, x, t, cond)
259
+
260
+ def _teacher_v(x, t, cond):
261
+ return AnimaBundle.dit_forward(teacher_transformer, x, t, cond)
262
+
263
+ # negative cond は 1 度だけエンコード
264
+ with torch.no_grad():
265
+ cond_neg = bundle.text_encode([args.neg_prompt or ""]) # (1, 512, 1024)
266
+ # latent サイズは VAE 8x down 想定 (Anima WanVAE)
267
+ H_lat = args.resolution // 8
268
+ W_lat = args.resolution // 8
269
+
270
+ for step in range(args.total_steps):
271
+ try:
272
+ captions = next(data_iter)
273
+ except StopIteration:
274
+ data_iter = iter(loader)
275
+ captions = next(data_iter)
276
+
277
+ B = len(captions)
278
+
279
+ # text encode (positive)
280
+ with torch.no_grad():
281
+ cond_pos = bundle.text_encode(captions) # (B, 512, 1024)
282
+ # neg は B にブロードキャスト
283
+ cond_neg_b = cond_neg.expand(B, -1, -1).contiguous() if B > 1 else cond_neg
284
+
285
+ # 初期 noise (t=1 の状態)
286
+ init_noise = torch.randn(
287
+ B, 16, 1, H_lat, W_lat, device=device, dtype=dtype,
288
+ )
289
+
290
+ # ----- teacher rollout (no_grad inside fetch_trajectory) -----
291
+ teacher_sched = make_schedule(args.teacher_steps, args.sigma_shift,
292
+ device=device, dtype=torch.float32)
293
+ teacher_sched = snap_to_targets(teacher_sched, student_ts)
294
+ teacher_transformer.eval()
295
+ teacher_traj = fetch_trajectory(
296
+ _teacher_v, init_noise, teacher_sched, student_ts,
297
+ cond_pos, cond_neg_b, cfg_scale=args.teacher_cfg,
298
+ )
299
+
300
+ # ----- student align loss -----
301
+ student_transformer.train()
302
+ l_align = align_trajectory(
303
+ _student_v, teacher_traj, student_ts,
304
+ cond_pos, cond_neg_b, cfg_scale_student=args.student_cfg,
305
+ weight_mode=args.weight_mode,
306
+ )
307
+
308
+ # ----- LPIPS reg (任意) -----
309
+ if lpips_fn is not None:
310
+ l_reg = compute_regularization(
311
+ _student_v, bundle, init_noise,
312
+ teacher_final_latent=teacher_traj[-1],
313
+ student_timesteps=student_ts,
314
+ cond_pos=cond_pos, cond_neg=cond_neg_b,
315
+ lpips_fn=lpips_fn,
316
+ cfg_scale=args.student_cfg,
317
+ )
318
+ else:
319
+ l_reg = torch.zeros((), device=device)
320
+
321
+ loss = l_align + args.lpips_weight * l_reg
322
+
323
+ opt.zero_grad()
324
+ loss.backward()
325
+ torch.nn.utils.clip_grad_norm_(
326
+ [p for p in student_transformer.parameters() if p.requires_grad],
327
+ args.grad_clip,
328
+ )
329
+ opt.step()
330
+
331
+ # ----- log -----
332
+ if step % args.log_every == 0:
333
+ metrics = {
334
+ "step": step,
335
+ "elapsed": time.time() - t0,
336
+ "loss": float(loss.detach()),
337
+ "l_align": float(l_align.detach()),
338
+ "l_reg": float(l_reg.detach()),
339
+ }
340
+ log_f.write(json.dumps(metrics) + "\n")
341
+ msg = " ".join(f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}"
342
+ for k, v in metrics.items() if k != "step")
343
+ print(f"[step {step}/{args.total_steps}] {msg}", flush=True)
344
+
345
+ # ----- checkpoint -----
346
+ if step > 0 and step % args.sample_every == 0:
347
+ save_lora_state(student_transformer, out_dir, f"traj_step{step:05d}")
348
+ print(f"[save] traj_step{step:05d}.safetensors", flush=True)
349
+ try:
350
+ import modal
351
+ modal.Volume.from_name("anima-outputs").commit()
352
+ print(f"[save] volume committed at step {step}", flush=True)
353
+ except Exception as e:
354
+ print(f"[save] volume commit failed: {e}", flush=True)
355
+
356
+ # final
357
+ print("[done] saving final LoRA")
358
+ save_lora_state(student_transformer, out_dir, "traj_final")
359
+ log_f.close()
360
+
361
+
362
+ if __name__ == "__main__":
363
+ main()
scripts/distill/traj_loss.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Trajectory imitation losses for Anima distillation.
3
+
4
+ Algorithm (DiffSynth-Studio Z-Image trajectory_imitation 由来):
5
+ 1. Teacher (= base Anima, frozen, no LoRA) を CFG=2.0 で 50 step rollout し
6
+ **student の 8 timesteps と一致する点** で latent を保存
7
+ → teacher_traj = [x_at_t_1, x_at_t_2, ..., x_at_t_N] (N=8)
8
+ 2. align loss: 各 student segment [t_i → t_{i+1}] で
9
+ target_v = (x_{i+1} - x_i) / (t_{i+1} - t_i)
10
+ student_v = student(x_i, t_i, cond)
11
+ L_align = MSE(student_v, target_v)
12
+ 3. (optional) regularization: student が自前で 8-step rollout した最終画像と
13
+ teacher の最終画像を VAE decode して LPIPS で比較
14
+
15
+ R3GAN (Phase B 失敗の元凶) は**一切使わない**。critic なし、単一ネットワーク。
16
+ """
17
+ from __future__ import annotations
18
+ from typing import Callable
19
+
20
+ import torch
21
+ import torch.nn.functional as F
22
+
23
+ from .anima_loader import AnimaBundle
24
+ from .traj_scheduler import TrajSchedule
25
+
26
+
27
+ # ----- helpers -------------------------------------------------------------
28
+
29
+ def _broadcast_t(t: torch.Tensor, B: int, device, dtype) -> torch.Tensor:
30
+ """scalar timestep を (B,) に膨らませる。"""
31
+ if t.dim() == 0:
32
+ t = t.expand(B)
33
+ return t.to(device=device, dtype=dtype)
34
+
35
+
36
+ def cfg_guided(
37
+ model_fn: Callable[..., torch.Tensor],
38
+ x: torch.Tensor,
39
+ t: torch.Tensor,
40
+ cond_pos: torch.Tensor,
41
+ cond_neg: torch.Tensor | None,
42
+ cfg_scale: float,
43
+ ) -> torch.Tensor:
44
+ """classifier-free guidance を 1 行で。cfg_scale<=1 なら neg を呼ばない。"""
45
+ v_pos = model_fn(x, t, cond_pos)
46
+ if cfg_scale <= 1.0 or cond_neg is None:
47
+ return v_pos
48
+ v_neg = model_fn(x, t, cond_neg)
49
+ return v_neg + cfg_scale * (v_pos - v_neg)
50
+
51
+
52
+ # ----- teacher rollout (= "fetch trajectory") -------------------------------
53
+
54
+ @torch.no_grad()
55
+ def fetch_trajectory(
56
+ teacher_velocity_fn: Callable[..., torch.Tensor],
57
+ init_noise: torch.Tensor, # (B, 16, 1, H, W) at t=1
58
+ teacher_schedule: TrajSchedule, # snapped to student timesteps
59
+ student_timesteps: torch.Tensor, # (N+1,) student grid points
60
+ cond_pos: torch.Tensor,
61
+ cond_neg: torch.Tensor | None,
62
+ cfg_scale: float = 2.0,
63
+ ) -> list[torch.Tensor]:
64
+ """Teacher を CFG'd Euler で rollout し、student_timesteps と一致する点で
65
+ latent を保存して返す。
66
+
67
+ Returns:
68
+ list of len(student_timesteps) tensors、それぞれ shape = init_noise.shape。
69
+ i 番目の要素が `latents at t == student_timesteps[i]`。
70
+ """
71
+ B = init_noise.size(0)
72
+ device = init_noise.device
73
+ dtype = init_noise.dtype
74
+
75
+ ts = teacher_schedule.timesteps # (M+1,) — student 点を含む snap 済
76
+ student_set = {float(t.item()) for t in student_timesteps}
77
+
78
+ latents = init_noise
79
+ saved: dict[float, torch.Tensor] = {}
80
+
81
+ # 初期点 (t=1) が student に含まれていれば保存
82
+ if float(ts[0].item()) in student_set:
83
+ saved[float(ts[0].item())] = latents.detach().clone()
84
+
85
+ for i in range(len(ts) - 1):
86
+ t_cur = ts[i]
87
+ t_next = ts[i + 1]
88
+ t_in = _broadcast_t(t_cur, B, device, dtype)
89
+ v = cfg_guided(teacher_velocity_fn, latents, t_in, cond_pos, cond_neg, cfg_scale)
90
+ # Anima: euler step は forward 方向 (t は減少)
91
+ dt = (t_next - t_cur).to(device=device, dtype=dtype)
92
+ latents = latents + dt * v
93
+ if float(t_next.item()) in student_set:
94
+ saved[float(t_next.item())] = latents.detach().clone()
95
+
96
+ # student_timesteps の順序で並べて返す
97
+ return [saved[float(t.item())] for t in student_timesteps]
98
+
99
+
100
+ # ----- align loss (segment-wise velocity match) -----------------------------
101
+
102
+ def align_trajectory(
103
+ student_velocity_fn: Callable[..., torch.Tensor],
104
+ teacher_traj: list[torch.Tensor], # length N+1
105
+ student_timesteps: torch.Tensor, # (N+1,)
106
+ cond_pos: torch.Tensor,
107
+ cond_neg: torch.Tensor | None,
108
+ cfg_scale_student: float = 1.0,
109
+ weight_mode: str = "uniform",
110
+ ) -> torch.Tensor:
111
+ """各 segment ごとに student velocity を target velocity と MSE で揃える。
112
+
113
+ Anima 蒸留の核。grad は student の LoRA に流す前提なので no_grad で囲まない。
114
+ teacher_traj は detach 済 (fetch_trajectory が clone してある) なので
115
+ target に grad は乗らない。
116
+ """
117
+ from .traj_scheduler import training_weight as _w
118
+ losses = []
119
+ weights = []
120
+ B = teacher_traj[0].size(0)
121
+ device = teacher_traj[0].device
122
+ dtype = teacher_traj[0].dtype
123
+
124
+ for i in range(len(student_timesteps) - 1):
125
+ t_cur = student_timesteps[i]
126
+ t_next = student_timesteps[i + 1]
127
+ x_cur = teacher_traj[i]
128
+ x_next = teacher_traj[i + 1]
129
+ dt = (t_next - t_cur).to(device=device, dtype=dtype)
130
+ # target velocity for this segment (teacher's effective velocity averaged over segment)
131
+ target_v = (x_next - x_cur) / dt
132
+ # student prediction at t_cur (greedy: assume student sees teacher's noisy state)
133
+ t_in = _broadcast_t(t_cur, B, device, dtype)
134
+ pred_v = cfg_guided(
135
+ student_velocity_fn, x_cur, t_in, cond_pos, cond_neg, cfg_scale_student
136
+ )
137
+ loss_i = F.mse_loss(pred_v.float(), target_v.detach().float())
138
+ losses.append(loss_i)
139
+ weights.append(_w(t_cur.unsqueeze(0).cpu(), mode=weight_mode).item())
140
+
141
+ losses_t = torch.stack(losses)
142
+ weights_t = torch.tensor(weights, device=losses_t.device, dtype=losses_t.dtype)
143
+ weights_t = weights_t / weights_t.sum().clamp(min=1e-8)
144
+ return (losses_t * weights_t).sum()
145
+
146
+
147
+ # ----- regularization (student own rollout + LPIPS vs teacher final) -------
148
+
149
+ def student_rollout(
150
+ student_velocity_fn: Callable[..., torch.Tensor],
151
+ init_noise: torch.Tensor,
152
+ student_timesteps: torch.Tensor,
153
+ cond_pos: torch.Tensor,
154
+ cond_neg: torch.Tensor | None,
155
+ cfg_scale: float = 1.0,
156
+ ) -> torch.Tensor:
157
+ """student が自前で N-step rollout し最終 latent を返す。**grad は通す**。"""
158
+ B = init_noise.size(0)
159
+ device = init_noise.device
160
+ dtype = init_noise.dtype
161
+ latents = init_noise
162
+
163
+ for i in range(len(student_timesteps) - 1):
164
+ t_cur = student_timesteps[i]
165
+ t_next = student_timesteps[i + 1]
166
+ t_in = _broadcast_t(t_cur, B, device, dtype)
167
+ v = cfg_guided(
168
+ student_velocity_fn, latents, t_in, cond_pos, cond_neg, cfg_scale
169
+ )
170
+ dt = (t_next - t_cur).to(device=device, dtype=dtype)
171
+ latents = latents + dt * v
172
+ return latents
173
+
174
+
175
+ def compute_regularization(
176
+ student_velocity_fn: Callable[..., torch.Tensor],
177
+ bundle: AnimaBundle,
178
+ init_noise: torch.Tensor,
179
+ teacher_final_latent: torch.Tensor,
180
+ student_timesteps: torch.Tensor,
181
+ cond_pos: torch.Tensor,
182
+ cond_neg: torch.Tensor | None,
183
+ lpips_fn,
184
+ cfg_scale: float = 1.0,
185
+ ) -> torch.Tensor:
186
+ """LPIPS(VAE_decode(student_final), VAE_decode(teacher_final))。
187
+ student の rollout は grad-through。VAE は no_grad で固定 (decode 時のみ)。
188
+ """
189
+ student_final = student_rollout(
190
+ student_velocity_fn, init_noise, student_timesteps,
191
+ cond_pos, cond_neg, cfg_scale,
192
+ )
193
+ # VAE decode: Anima は (B, 3, T=1, H, W) を返す → T を squeeze
194
+ # 注: vae_decode は @torch.no_grad で囲まれているため student grad が VAE 入力で切れる
195
+ # → LPIPS reg を活かすには別 forward が必要。ここでは bundle.vae.model.decode を直接
196
+ # 呼んで grad を通す (VAE 自体の weight は frozen のままだが入力勾配は流れる)。
197
+ vae_dtype = next(bundle.vae.model.parameters()).dtype
198
+ img_student = bundle.vae.model.decode(student_final.to(dtype=vae_dtype), bundle.vae_scale)
199
+ with torch.no_grad():
200
+ img_teacher = bundle.vae.model.decode(
201
+ teacher_final_latent.to(dtype=vae_dtype), bundle.vae_scale
202
+ )
203
+ # (B, 3, 1, H, W) -> (B, 3, H, W)、[-1, 1] のまま LPIPS に渡せる
204
+ img_student = img_student.squeeze(2)
205
+ img_teacher = img_teacher.squeeze(2)
206
+ # LPIPS は float32 を要求するので cast
207
+ return lpips_fn(img_student.float(), img_teacher.float()).mean()