Docs: publish LoRA trainer + add README fine-tuning story
Browse filesTask #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 +36 -2
- docs/lora_bunny.jpg +0 -0
- docs/lora_fox.jpg +0 -0
- docs/lora_unicorn.jpg +0 -0
- docs/panda_after.jpg +0 -0
- docs/panda_before.jpg +0 -0
- training/lora_trainer/README.md +50 -0
- training/lora_trainer/numzoo_klein.yaml +69 -0
- training/lora_trainer/run_modal.py +90 -0
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|  |  |
|
| 56 |
+
| photorealistic, inconsistent | cozy, on-brand, every time |
|
| 57 |
+
|
| 58 |
+
More rewards from the LoRA:
|
| 59 |
+
|
| 60 |
+
| | | |
|
| 61 |
+
|:---:|:---:|:---:|
|
| 62 |
+
|  |  |  |
|
| 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)
|