| """LoRA fine-tune of MiniCPM5-1B as the EYEWITNESS testimony parser — on Modal. |
| |
| Trains on the synthetic (testimony -> attribute JSON) dataset produced by |
| gen_dataset.py (ground truth by construction), merges the adapter, and pushes |
| the result to the HF Hub as a PUBLIC model (Well-Tuned badge requires a |
| published fine-tune that the app actually uses). |
| |
| Run: modal run train/train_modal.py --hub-repo Fcabla/MiniCPM5-1B-eyewitness |
| Then: set EYEWITNESS_MODEL_ID=<hub-repo> in the Space variables. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| from pathlib import Path |
|
|
| import modal |
|
|
| app = modal.App("eyewitness-train") |
|
|
| image = ( |
| modal.Image.debian_slim(python_version="3.12") |
| .pip_install("torch", "transformers", "datasets", "peft", "trl", "accelerate", "huggingface_hub") |
| ) |
|
|
| DATASET_LOCAL = Path(__file__).parent / "dataset.jsonl" |
|
|
| SYSTEM = ("You are a police sketch-artist assistant. Extract ONLY what the witness " |
| "said into the attribute JSON. Use null for anything not mentioned. " |
| "Output only the JSON object.") |
|
|
|
|
| def to_chat(example: dict) -> dict: |
| return {"messages": [ |
| {"role": "system", "content": SYSTEM}, |
| {"role": "user", "content": f'Witness testimony: "{example["testimony"]}"'}, |
| {"role": "assistant", "content": json.dumps(example["labels"], ensure_ascii=False)}, |
| ]} |
|
|
|
|
| @app.function(image=image, gpu="A10G", timeout=5400, |
| secrets=[modal.Secret.from_name("huggingface-secret")]) |
| def train(dataset_jsonl: str, hub_repo: str) -> str: |
| import torch |
| from datasets import Dataset |
| from peft import LoraConfig |
| from transformers import AutoModelForCausalLM, AutoTokenizer |
| from trl import SFTConfig, SFTTrainer |
|
|
| rows = [json.loads(l) for l in dataset_jsonl.splitlines() if l.strip()] |
| ds = Dataset.from_list([to_chat(r) for r in rows]).train_test_split(test_size=0.02, seed=7) |
|
|
| base = "openbmb/MiniCPM5-1B" |
| tok = AutoTokenizer.from_pretrained(base, trust_remote_code=True) |
| model = AutoModelForCausalLM.from_pretrained( |
| base, trust_remote_code=True, torch_dtype=torch.bfloat16, device_map="cuda") |
|
|
| trainer = SFTTrainer( |
| model=model, |
| processing_class=tok, |
| train_dataset=ds["train"], |
| eval_dataset=ds["test"], |
| peft_config=LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05, |
| target_modules="all-linear", task_type="CAUSAL_LM"), |
| args=SFTConfig( |
| output_dir="/tmp/out", num_train_epochs=2, |
| per_device_train_batch_size=8, gradient_accumulation_steps=2, |
| learning_rate=1e-4, lr_scheduler_type="cosine", warmup_ratio=0.03, |
| logging_steps=20, eval_strategy="steps", eval_steps=100, |
| bf16=True, max_length=1024, report_to=[], |
| ), |
| ) |
| trainer.train() |
| metrics = trainer.evaluate() |
| print("eval:", metrics) |
|
|
| merged = trainer.model.merge_and_unload() |
| merged.push_to_hub(hub_repo, private=False) |
| tok.push_to_hub(hub_repo, private=False) |
| return f"pushed to {hub_repo} | eval_loss={metrics.get('eval_loss'):.4f}" |
|
|
|
|
| @app.local_entrypoint() |
| def main(hub_repo: str = "Fcabla/MiniCPM5-1B-eyewitness"): |
| data = DATASET_LOCAL.read_text() |
| print(train.remote(data, hub_repo)) |
|
|