Spaces:
Runtime error
Runtime error
| # Fine-Tuning the Rune Goblin Vision Model (MiniCPM-V 4.6) | |
| How to fine-tune a vision-language model to read hand-drawn RuneLang glyphs from | |
| a canvas image + game state and emit `visual_reading` + `spell` JSON. | |
| > **TL;DR** | |
| > 1. The GGUF you downloaded is **inference-only** — fine-tuning needs the full | |
| > safetensors model `openbmb/MiniCPM-V-4.6` (~2.6 GB). | |
| > 2. Convert the dataset to absolute image paths before training; the Modal | |
| > notebook includes the conversion cell. | |
| > 3. Train LoRA with **ms-swift** (one command) or **LLaMA-Factory** (ready config). | |
| > 4. (Optional) merge LoRA → convert to GGUF to serve in the game via llama.cpp. | |
| > **Running on Modal?** Use the ready notebook | |
| > [`notebooks/MODAL-run-p1Jun2026.ipynb`](../notebooks/MODAL-run-p1Jun2026.ipynb) | |
| > — a single Run-All notebook (ms-swift based) for a Modal Notebook GPU, with an | |
| > appendix `modal run` script for unattended jobs. This doc is the framework-agnostic | |
| > reference behind it. | |
| --- | |
| ## 0. Critical: GGUF cannot be fine-tuned | |
| | Artifact | What it's for | | |
| |---|---| | |
| | `models/MiniCPM-V-4.6-gguf/MiniCPM-V-4_6-Q8_0.gguf` + `mmproj-model-f16.gguf` | **Serving / inference** with llama.cpp / Ollama. Quantized — **not trainable.** | | |
| | `openbmb/MiniCPM-V-4.6` (safetensors, 2.6 GB) | **The fine-tuning base.** This is what you train. | | |
| So the flow is: **train on the safetensors model → (optionally) export back to GGUF | |
| for fast game-time inference.** | |
| Download the trainable base into `models/`: | |
| ```bash | |
| cd /home/ashu/github/goblin | |
| source .env # HF_TOKEN | |
| uv run python - <<'PY' | |
| import os | |
| from huggingface_hub import snapshot_download | |
| snapshot_download("openbmb/MiniCPM-V-4.6", | |
| local_dir="models/MiniCPM-V-4.6", | |
| token=os.environ.get("HF_TOKEN"), | |
| ignore_patterns=["*.pth","*.onnx"]) | |
| PY | |
| ``` | |
| ### Model facts (drives VRAM planning) | |
| `openbmb/MiniCPM-V-4.6` is the **lightweight / on-device** member of the family: | |
| - Total ≈ **2.6 B params** (single `model.safetensors`, 2.6 GB bf16) | |
| - Text backbone: `qwen3_5_text` — hidden 1024, 24 layers, vocab 248k | |
| - Vision encoder: SigLIP-style — hidden 1152, 27 layers, max image 980px | |
| - Arch class: `MiniCPMV4_6ForConditionalGeneration` (needs `trust_remote_code`) | |
| **This easily fits LoRA in bf16 on your RTX 4070 Ti SUPER (16 GB)** — no 4-bit | |
| required. Our canvases are only 256×256, so each image produces few vision | |
| tokens, keeping memory low. (Full fine-tuning is also feasible with care.) | |
| --- | |
| ## 1. The dataset | |
| Location: `data/rune_goblin_visual_dataset_5000/rune_goblin_visual_dataset/` | |
| - **5,000 samples**, 256×256 RGB JPEG canvases of wobbly, hand-drawn ink runes | |
| on a parchment background (plus scattered dot-noise to mimic real canvas mess). | |
| - Train/val already split **4,500 / 500**. Assistant JSON is **100 % valid**. | |
| - Category mix: `basic_clean` 1000, `messy_handdrawn` 1200, `combo_rules` 1100, | |
| `cursed_broken_mark` 700, `enemy_specific` 700, `invalid_ambiguous` 300. | |
| ### Task | |
| ``` | |
| canvas image + game-state text → { visual_reading, spell } (JSON only) | |
| ``` | |
| ### Provided formats (pick one; image paths are **relative**) | |
| | File | Shape | | |
| |---|---| | |
| | `*_full.jsonl` | everything: `id, category, image, runes_ground_truth, game_state, messages` | | |
| | `train/validation_messages.jsonl` | `{image, messages}` with `<image>` token in the user turn | | |
| | `train/validation_hf_vision_messages.jsonl` | HF/TRL content-parts: `{images:[...], messages:[{content:[{type:image},{type:text}]}]}` | | |
| | `train/validation_llava_style.jsonl` | `{image, conversations:[{from,value}], system}` | | |
| ### Output JSON the model must learn | |
| ```json | |
| { | |
| "visual_reading": { | |
| "detected_runes": ["spiral","eye","broken_mark"], | |
| "ambiguous_runes": [], | |
| "drawing_style": "wobbly ink marks", | |
| "layout": "left-to-right chain", | |
| "confidence": 0.91, | |
| "notes": ["broken_mark_adds_cursed_side_effect"] | |
| }, | |
| "spell": { | |
| "spell_name": "Cursed Foresight Loop", | |
| "spell_type": "prophecy_loop_curse", | |
| "flavor": "...", | |
| "effect": "Deals 2 damage to Mirror Fungus; confuses for 1 turn.", | |
| "side_effect": "player loses a little courage.", | |
| "enemy_hp_delta": -2, // range -4..0 | |
| "player_hp_delta": -1, // range -3..2 | |
| "status_effects": ["enemy_confused","spell_cursed"], | |
| "chaos": 8 // range 1..10 | |
| } | |
| } | |
| ``` | |
| Game-design signal baked into the data: **clearer drawings → higher `confidence`, | |
| lower `chaos`; messy/ambiguous drawings still cast but raise chaos and can turn | |
| cursed.** Bad player art is part of the game loop. | |
| ### Step 1 — convert to absolute paths + framework formats | |
| Frameworks need absolute image paths. The Modal notebook includes a conversion | |
| cell that writes: | |
| | Output | Purpose | | |
| |---|---| | |
| | `vision_swift_{train,val}.jsonl` | ms-swift | | |
| | `vision_sharegpt_{train,val}.jsonl` | LLaMA-Factory ShareGPT | | |
| If you run outside Modal, use the same mapping: resolve each record's `image` | |
| field under the dataset root, skip or repair any missing image, and emit the | |
| framework-specific JSONL with absolute image paths. | |
| --- | |
| ## 2. Option A — ms-swift (recommended, simplest) | |
| MiniCPM-V 4.6 has **native** ms-swift support. One CLI, handles the vision | |
| plumbing and LoRA target modules for you. | |
| ```bash | |
| uv pip install "ms-swift>=3.0" transformers accelerate peft # into the project venv | |
| # (or: pip install ms-swift in a dedicated env) | |
| ``` | |
| Train LoRA: | |
| ```bash | |
| source .env | |
| swift sft \ | |
| --model models/MiniCPM-V-4.6 \ | |
| --model_type minicpm-v-4_6 \ | |
| --dataset data/vision_prepared/vision_swift_train.jsonl \ | |
| --val_dataset data/vision_prepared/vision_swift_val.jsonl \ | |
| --train_type lora \ | |
| --lora_rank 16 --lora_alpha 32 \ | |
| --torch_dtype bfloat16 \ | |
| --num_train_epochs 3 \ | |
| --per_device_train_batch_size 2 \ | |
| --gradient_accumulation_steps 8 \ | |
| --learning_rate 1e-4 \ | |
| --freeze_vit true \ | |
| --max_length 2048 \ | |
| --gradient_checkpointing true \ | |
| --eval_steps 200 --save_steps 200 \ | |
| --output_dir models/rune-goblin-vision-lora | |
| ``` | |
| Notes: | |
| - `--freeze_vit true` trains only the LLM + resampler adapters (faster, lighter, | |
| and enough since the glyph vocabulary is small). Set `false` to also adapt the | |
| vision encoder if recognition of messy drawings underperforms. | |
| - Effective batch = 2 × 8 = 16. Drop `per_device_train_batch_size` to 1 (and | |
| raise grad-accum) if you hit OOM. | |
| - Inference after training: | |
| ```bash | |
| swift infer --adapters models/rune-goblin-vision-lora \ | |
| --load_data_args true --val_dataset data/vision_prepared/vision_swift_val.jsonl | |
| ``` | |
| - Merge LoRA into the base: | |
| ```bash | |
| swift export --adapters models/rune-goblin-vision-lora --merge_lora true | |
| ``` | |
| --- | |
| ## 2. Option B — LLaMA-Factory (ready-made config) | |
| OpenBMB ships a MiniCPM-V 4.6 LoRA recipe for LLaMA-Factory. | |
| ```bash | |
| git clone https://github.com/hiyouga/LLaMA-Factory && cd LLaMA-Factory | |
| pip install -e ".[torch,metrics]" | |
| ``` | |
| 1. Register the dataset — add to `data/dataset_info.json`: | |
| ```json | |
| "rune_goblin_vision": { | |
| "file_name": "/home/ashu/github/goblin/data/vision_prepared/vision_sharegpt_train.jsonl", | |
| "formatting": "sharegpt", | |
| "columns": { "messages": "conversations", "images": "images", "system": "system" }, | |
| "tags": { "role_tag": "from", "content_tag": "value", | |
| "user_tag": "human", "assistant_tag": "gpt" } | |
| } | |
| ``` | |
| 2. Train (start from the official example and point `dataset` at `rune_goblin_vision`): | |
| ```bash | |
| llamafactory-cli train \ | |
| --stage sft --do_train true \ | |
| --model_name_or_path /home/ashu/github/goblin/models/MiniCPM-V-4.6 \ | |
| --trust_remote_code true \ | |
| --dataset rune_goblin_vision \ | |
| --template minicpm_v \ | |
| --finetuning_type lora --lora_rank 16 --lora_target all \ | |
| --freeze_vision_tower true \ | |
| --cutoff_len 2048 \ | |
| --per_device_train_batch_size 2 --gradient_accumulation_steps 8 \ | |
| --learning_rate 1e-4 --num_train_epochs 3 \ | |
| --bf16 true --gradient_checkpointing true \ | |
| --output_dir models/rune-goblin-vision-lora-lf | |
| ``` | |
| Reference config: <https://github.com/OpenSQZ/MiniCPM-V-CookBook/blob/main/finetune/llamafactory_minicpmv46.md> | |
| --- | |
| ## 3. VRAM guidance (16 GB) | |
| | Setup | Fits 16 GB? | Notes | | |
| |---|---|---| | |
| | LoRA bf16, ViT frozen, bs=2, len=2048 | ✅ comfortable | recommended starting point | | |
| | LoRA bf16, ViT trainable | ✅ likely | a bit more memory; helps messy-drawing recall | | |
| | QLoRA 4-bit | ✅ easy | only needed if you also raise batch/seq a lot | | |
| | Full fine-tune | ⚠️ tight | possible for a ~2.6B model with bs=1 + grad-ckpt | | |
| If you OOM: `per_device_train_batch_size=1`, raise `gradient_accumulation_steps`, | |
| keep `gradient_checkpointing true`, keep `freeze_vit true`. | |
| --- | |
| ## 4. Evaluate like a game engine | |
| Beyond loss, the metrics that matter (mirrors `rune_goblin.evaluate`): | |
| - **Valid JSON rate** (must parse to the `visual_reading`+`spell` schema) — target > 95 % | |
| - **Rune-recognition accuracy** — compare `visual_reading.detected_runes` to | |
| `runes_ground_truth` in `*_full.jsonl` | |
| - **Delta-range validity** — `enemy_hp_delta ∈ [-4,0]`, `player_hp_delta ∈ [-3,2]`, | |
| `chaos ∈ [1,10]` | |
| - **Weakness usage** — weakness-hitting runes should produce meaningful effects | |
| Quick recognition check after training (sketch): | |
| ```python | |
| import json | |
| from rune_goblin.schema import _extract_json # reuse the JSON repair | |
| # load *_full.jsonl for ground-truth runes, run model.infer(image, state), | |
| # parse visual_reading.detected_runes, compare to runes_ground_truth. | |
| ``` | |
| --- | |
| ## 5. Serve the fine-tune in the game | |
| Two paths: | |
| 1. **transformers (simplest):** load `models/MiniCPM-V-4.6` + the LoRA adapter | |
| (or the merged model) with `trust_remote_code=True` and call its chat method | |
| with the canvas image + state prompt. Wire this into a vision variant of | |
| `rune_goblin.inference`. | |
| 2. **llama.cpp / Ollama (fast, matches your GGUF download):** after | |
| `swift export --merge_lora true`, convert the merged HF model to GGUF with | |
| `llama.cpp/convert_hf_to_gguf.py`, regenerate the `mmproj` projector, then | |
| serve. This produces a fine-tuned analogue of the Q8_0 + mmproj pair you | |
| already have in `models/MiniCPM-V-4.6-gguf/`. | |
| The game prompt must match training exactly (see the system + user strings in | |
| the dataset): system = "You are Rune Goblin, a tiny vision spell engine…", | |
| user = `<image>\nSTATE: player_hp=… enemy=… …\nLook at the drawn RuneLang spell…`. | |
| --- | |
| ## 6. Pitfalls | |
| - **Don't** point training at the GGUF — use `openbmb/MiniCPM-V-4.6` safetensors. | |
| - **Relative image paths** in the raw dataset will fail mid-training; always | |
| convert them to absolute paths first. The Modal notebook's preparation cell | |
| does this and skips records with missing images. | |
| - **Prompt drift:** keep the exact system/user template from the dataset at game | |
| time or accuracy drops. | |
| - The dataset is **synthetic** — after launch, log real Gradio-canvas drawings | |
| and append them as a second-stage dataset (the README suggests this). | |
| - `trust_remote_code=True` is required (custom `MiniCPMV4_6` architecture). | |
| ``` | |