goumsss Claude Opus 4.8 commited on
Commit
6805506
·
1 Parent(s): 35bc0e8

Docs: publish LoRA trainer + add README fine-tuning story

Browse files

Task #3 — training/lora_trainer/: the reproducible Modal + ai-toolkit setup
(run_modal.py, numzoo_klein.yaml, README with repro steps + the gotchas we hit).

Task #4 — README: new "Custom AI art: the NumZoo LoRA" section with before/after
panda (photoreal → cozy), a LoRA gallery, the Qwen-Image dataset story, training
summary, and a link to the published HF LoRA (goumsss/numzoo-flux2-klein-lora).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

README.md CHANGED
@@ -38,8 +38,42 @@ A mental math app for kids — answer questions, earn cute AI-generated animal i
38
 
39
  ## Image model
40
 
41
- Rewards generated with **FLUX.2-klein-4B** (4B params, Apache 2.0) via 🤗 Diffusers.
42
- Images start generating in the background as soon as the quiz begins.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  ## Run locally
45
 
 
38
 
39
  ## Image model
40
 
41
+ Rewards are generated with **FLUX.2-klein-4B** (4B params, Apache 2.0) via 🤗 Diffusers,
42
+ plus a **custom NumZoo style LoRA** (below). Images start generating in the background as
43
+ soon as the quiz begins, so a reward is usually ready the moment it's earned.
44
+
45
+ ## ✨ Custom AI art: the NumZoo LoRA
46
+
47
+ Out of the box, FLUX.2-klein renders the same prompt in wildly different styles — often
48
+ photorealistic — which doesn't fit a soft, cozy kids' app. So we fine-tuned a **style
49
+ LoRA** that pins every reward to the same kawaii children's-book look.
50
+
51
+ **Before → after** (same prompt: *"a cute baby panda on a snowy mountain top"*):
52
+
53
+ | Base FLUX.2-klein-4B | + NumZoo LoRA |
54
+ |:---:|:---:|
55
+ | ![before](docs/panda_before.jpg) | ![after](docs/panda_after.jpg) |
56
+ | photorealistic, inconsistent | cozy, on-brand, every time |
57
+
58
+ More rewards from the LoRA:
59
+
60
+ | | | |
61
+ |:---:|:---:|:---:|
62
+ | ![bunny](docs/lora_bunny.jpg) | ![unicorn](docs/lora_unicorn.jpg) | ![fox](docs/lora_fox.jpg) |
63
+
64
+ ### How we made it
65
+ 1. **Dataset** — 54 cozy scenes generated with **Qwen-Image** (12 animals × 10 places,
66
+ incl. multi-animal/multi-place combos), captioned `NUMZOO. <content>` with the style
67
+ left *undescribed* so the trigger word carries it. See
68
+ [`scripts/generate_dataset.py`](scripts/generate_dataset.py) and
69
+ [`training/`](training/) for the images + captions.
70
+ 2. **Training** — LoRA (rank 32, 1500 steps) on `FLUX.2-klein-base-4B` via
71
+ [ostris/ai-toolkit](https://github.com/ostris/ai-toolkit) on a **Modal** A100 (~45 min).
72
+ Reproducible setup in [`training/lora_trainer/`](training/lora_trainer/).
73
+ 3. **Inference** — the LoRA loads on the distilled 4-step klein in `image_generator.py`;
74
+ the app simply prepends the `NUMZOO` trigger to every prompt.
75
+
76
+ **Published LoRA:** 🤗 [goumsss/numzoo-flux2-klein-lora](https://huggingface.co/goumsss/numzoo-flux2-klein-lora)
77
 
78
  ## Run locally
79
 
docs/lora_bunny.jpg ADDED
docs/lora_fox.jpg ADDED
docs/lora_unicorn.jpg ADDED
docs/panda_after.jpg ADDED
docs/panda_before.jpg ADDED
training/lora_trainer/README.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NumZoo LoRA — training setup (reproducible)
2
+
3
+ These are the exact files used to train the **NumZoo style LoRA** for FLUX.2-klein-4B
4
+ on [Modal](https://modal.com) using [ostris/ai-toolkit](https://github.com/ostris/ai-toolkit).
5
+
6
+ - **Published LoRA:** https://huggingface.co/goumsss/numzoo-flux2-klein-lora
7
+ - **Base model:** `black-forest-labs/FLUX.2-klein-base-4B` (train on base, infer on distilled)
8
+ - **Trainer:** ai-toolkit · **Compute:** Modal A100 · ~45 min · LoRA rank 32, 1500 steps
9
+
10
+ ## Files
11
+ | File | Role |
12
+ |------|------|
13
+ | `run_modal.py` | Modal app (image, GPU, volume, secret) — **rewritten for Modal ≥ 1.0** |
14
+ | `numzoo_klein.yaml` | ai-toolkit training config (`arch: flux2_klein_4b`, trigger, sample prompts) |
15
+
16
+ ## The dataset
17
+ The 54 training images + captions live one level up in [`../`](../) (`image_001.jpg` +
18
+ `image_001.txt`, …). They were generated with **Qwen-Image** (see
19
+ [`../../scripts/generate_dataset.py`](../../scripts/generate_dataset.py)) and captioned
20
+ `NUMZOO. <content>` — content only, no style words, so the trigger learns the style.
21
+
22
+ ## How to reproduce
23
+ ```bash
24
+ # 1. Clone ai-toolkit and drop these in
25
+ git clone https://github.com/ostris/ai-toolkit
26
+ cp run_modal.py ai-toolkit/run_modal.py
27
+ cp numzoo_klein.yaml ai-toolkit/config/numzoo_klein.yaml
28
+ cp -r <dataset> ai-toolkit/numzoo-dataset # the 54 image+txt pairs
29
+
30
+ # 2. Modal: install, auth, and store your HF token (needs the base-model license accepted)
31
+ pip install modal && modal token new
32
+ modal secret create huggingface HF_TOKEN=hf_xxx
33
+
34
+ # 3. Train (from inside ai-toolkit/) — checkpoints + samples land on the volume
35
+ cd ai-toolkit
36
+ modal run run_modal.py --config-file-list-str=/root/ai-toolkit/config/numzoo_klein.yaml
37
+
38
+ # 4. Download results, pick the best checkpoint (we used step 1250)
39
+ modal volume get flux-lora-models numzoo_klein_lora ./lora_output
40
+ ```
41
+
42
+ ## Gotchas we hit (so you don't)
43
+ - **Modal ≥ 1.0 removed `modal.Mount`** — `run_modal.py` here uses `Image.add_local_dir`
44
+ + `add_local_file` instead, and moves heavy imports inside the container function.
45
+ - **pip `resolution-too-deep`** with unpinned deps — we install from ai-toolkit's own
46
+ pinned `requirements.txt` (which also pins the diffusers commit that supports FLUX.2),
47
+ plus `torch==2.7.1`/`torchvision`/`torchaudio` first.
48
+ - **Gated base model** — accept the license for `FLUX.2-klein-base-4B` on HF first.
49
+ - **Trigger rendered as text** at inference — append `no text` to the prompt; the
50
+ distilled 4-step klein otherwise draws the `NUMZOO` trigger as a literal sign.
training/lora_trainer/numzoo_klein.yaml ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ # NumZoo style LoRA — FLUX.2-klein-4B, trained on Modal via ai-toolkit.
3
+ # Trained on the BASE model (FLUX.2-klein-base-4B); the resulting LoRA is loaded
4
+ # on the DISTILLED FLUX.2-klein-4B at inference in the app (BFL-recommended).
5
+ job: extension
6
+ config:
7
+ name: "numzoo_klein_lora" # output folder/filename name
8
+ process:
9
+ - type: 'sd_trainer'
10
+ training_folder: "/root/ai-toolkit/modal_output" # must match MOUNT_DIR in run_modal.py
11
+ device: cuda:0
12
+ # Captions already start with "NUMZOO." so we do NOT set trigger_word here
13
+ # (avoids double-injecting it). Sample prompts below include NUMZOO explicitly.
14
+ network:
15
+ type: "lora"
16
+ linear: 32
17
+ linear_alpha: 32
18
+ save:
19
+ dtype: float16
20
+ save_every: 250 # checkpoints at 250,500,...,1500 — pick the best
21
+ max_step_saves_to_keep: 6
22
+ datasets:
23
+ - folder_path: "/root/ai-toolkit/numzoo-dataset"
24
+ caption_ext: "txt"
25
+ caption_dropout_rate: 0.05
26
+ shuffle_tokens: false
27
+ cache_latents_to_disk: true
28
+ resolution: [ 512, 768, 1024 ]
29
+ train:
30
+ batch_size: 1
31
+ steps: 1500 # 54 imgs; style LoRA peaks ~750–1500
32
+ gradient_accumulation_steps: 1
33
+ train_unet: true
34
+ train_text_encoder: false
35
+ gradient_checkpointing: true
36
+ noise_scheduler: "flowmatch"
37
+ timestep_type: "weighted" # klein-recommended (UI default)
38
+ optimizer: "adamw8bit"
39
+ lr: 1e-4
40
+ ema_config:
41
+ use_ema: true
42
+ ema_decay: 0.99
43
+ dtype: bf16
44
+ model:
45
+ name_or_path: "black-forest-labs/FLUX.2-klein-base-4B"
46
+ arch: "flux2_klein_4b" # resolves to Flux2Klein4BModel (TE=Qwen3-4B, VAE auto)
47
+ quantize: true # 8-bit to fit comfortably on the GPU
48
+ quantize_te: true
49
+ qtype: "qfloat8"
50
+ low_vram: true
51
+ sample:
52
+ sampler: "flowmatch"
53
+ sample_every: 250
54
+ width: 512 # match the app's reward image size
55
+ height: 512
56
+ guidance_scale: 4 # base (non-distilled) model uses real CFG
57
+ sample_steps: 25 # base model needs more steps than distilled
58
+ seed: 42
59
+ walk_seed: true
60
+ prompts:
61
+ - "NUMZOO. A cute bunny in an enchanted mushroom forest"
62
+ - "NUMZOO. A cute baby panda on a snowy mountain top"
63
+ - "NUMZOO. A cute puppy and kitten in a cosy cottage garden"
64
+ - "NUMZOO. A cute baby lion, baby tiger and bunny surrounded by sparkling stars"
65
+ - "NUMZOO. A cute unicorn under a rainbow and on a glowing crescent moon"
66
+ - "NUMZOO. A cute baby fox on a sunny beach with ocean waves"
67
+ meta:
68
+ name: "[name]"
69
+ version: '1.0'
training/lora_trainer/run_modal.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ '''
2
+ ostris/ai-toolkit on https://modal.com — rewritten for Modal >= 1.0 API.
3
+
4
+ Run training (from inside the ai-toolkit directory) with:
5
+ modal run run_modal.py --config-file-list-str=/root/ai-toolkit/config/numzoo_klein.yaml
6
+ '''
7
+
8
+ import os
9
+ os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "1"
10
+ os.environ["DISABLE_TELEMETRY"] = "YES"
11
+ import modal
12
+
13
+ # --- paths -----------------------------------------------------------------
14
+ LOCAL_AI_TOOLKIT = os.path.dirname(os.path.abspath(__file__)) # this repo, shipped to the container
15
+ REMOTE_AI_TOOLKIT = "/root/ai-toolkit"
16
+ MOUNT_DIR = "/root/ai-toolkit/modal_output" # volume mount (must be an empty path)
17
+
18
+ # --- persistent volume for outputs (LoRA weights, samples) -----------------
19
+ model_volume = modal.Volume.from_name("flux-lora-models", create_if_missing=True)
20
+
21
+ # --- container image: ai-toolkit deps + the repo + dataset shipped in ------
22
+ # Use ai-toolkit's OWN pinned requirements (requirements.txt -> requirements_base.txt).
23
+ # They pin a specific diffusers commit that supports FLUX.2 klein and avoid the pip
24
+ # "resolution-too-deep" blowup that unpinned deps (esp. old controlnet_aux) caused.
25
+ image = (
26
+ modal.Image.debian_slim(python_version="3.11")
27
+ .apt_install("libgl1", "libglib2.0-0", "git")
28
+ # CUDA-enabled torch first, pinned to what torchao/torchcodec expect.
29
+ # torchaudio is imported by ai-toolkit's config_modules — must match torch ver.
30
+ .pip_install("torch==2.7.1", "torchvision==0.22.1", "torchaudio==2.7.1")
31
+ # copy both requirements files into the build context, then install (pinned)
32
+ .add_local_file(os.path.join(LOCAL_AI_TOOLKIT, "requirements.txt"),
33
+ "/tmp/reqs/requirements.txt", copy=True)
34
+ .add_local_file(os.path.join(LOCAL_AI_TOOLKIT, "requirements_base.txt"),
35
+ "/tmp/reqs/requirements_base.txt", copy=True)
36
+ .run_commands("cd /tmp/reqs && pip install -r requirements.txt")
37
+ # ship the ai-toolkit repo (code + numzoo-dataset/) into the container LAST
38
+ # (copy=False runtime mount must be the final image layer).
39
+ .add_local_dir(
40
+ LOCAL_AI_TOOLKIT,
41
+ REMOTE_AI_TOOLKIT,
42
+ ignore=[
43
+ ".git", "**/.git", "**/node_modules", "**/__pycache__", "**/*.pyc",
44
+ "modal_output", "**/.venv", "venv", "ui/node_modules", "output",
45
+ ],
46
+ )
47
+ )
48
+
49
+ app = modal.App(name="numzoo-lora-training", image=image, volumes={MOUNT_DIR: model_volume})
50
+
51
+
52
+ @app.function(
53
+ gpu="A100", # 40GB — plenty for a quantized 4B LoRA (<24GB)
54
+ timeout=7200, # 2h ceiling; a 1500-step run is ~45 min
55
+ secrets=[modal.Secret.from_name("huggingface")], # provides HF_TOKEN for gated model
56
+ )
57
+ def main(config_file_list_str: str, recover: bool = False, name: str = None):
58
+ import sys
59
+ sys.path.insert(0, REMOTE_AI_TOOLKIT)
60
+ from toolkit.job import get_job # imported in-container (pulls torch etc.)
61
+
62
+ config_file_list = config_file_list_str.split(",")
63
+ jobs_completed = 0
64
+ jobs_failed = 0
65
+ print(f"Running {len(config_file_list)} job{'' if len(config_file_list) == 1 else 's'}")
66
+
67
+ for config_file in config_file_list:
68
+ try:
69
+ job = get_job(config_file, name)
70
+ job.config['process'][0]['training_folder'] = MOUNT_DIR
71
+ os.makedirs(MOUNT_DIR, exist_ok=True)
72
+ print(f"Training outputs will be saved to: {MOUNT_DIR}")
73
+ job.run()
74
+ model_volume.commit() # persist weights/samples after each job
75
+ job.cleanup()
76
+ jobs_completed += 1
77
+ except Exception as e:
78
+ print(f"Error running job: {e}")
79
+ jobs_failed += 1
80
+ if not recover:
81
+ raise
82
+
83
+ print("========================================")
84
+ print(f"Result: {jobs_completed} completed, {jobs_failed} failed")
85
+ print("========================================")
86
+
87
+
88
+ @app.local_entrypoint()
89
+ def run(config_file_list_str: str, recover: bool = False, name: str = None):
90
+ main.remote(config_file_list_str=config_file_list_str, recover=recover, name=name)