Dimitris commited on
Commit
c20268d
·
2 Parent(s): 7b07a5c6d828f1

Merge pull request #10 from r0m4k/feat/medreason-finetune

Browse files
Files changed (1) hide show
  1. train/modal_medreason.py +119 -0
train/modal_medreason.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Medical-reasoning LoRA fine-tune (Track 1) — earns Well-Tuned WITHOUT touching extraction.
2
+
3
+ Roman's idea, done as LoRA (not full FT, which would catastrophically forget the vision/extraction
4
+ ability). We freeze the vision encoder and LoRA the LLM only, on the general medical-reasoning
5
+ dataset FreedomIntelligence/medical-o1-reasoning-SFT (TEXT, no images). The result is used as the
6
+ *interpretation phraser* that speaks the KB-grounded facts fluently — extraction stays on base.
7
+
8
+ A held-out slice is used for eval (reasoning loss). Gate A also re-runs the extraction eval on the
9
+ merged model to confirm extraction did not regress.
10
+
11
+ modal run train/modal_medreason.py::main --n 100 --epochs 1 # cheap smoke test first
12
+ modal run --detach train/modal_medreason.py::main # full run (n=4000)
13
+ modal run train/modal_finetune.py::merge --adapter-dir /adapters/medreason-lora --repo-id <owner>/<name>-medreason
14
+ modal run train/modal_eval.py::compare --finetuned-id <owner>/<name>-medreason # Gate A: extraction unharmed?
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import modal
20
+
21
+ MODEL_ID = "openbmb/MiniCPM-V-4.6"
22
+
23
+ app = modal.App("blood-test-medreason")
24
+
25
+ image = (
26
+ modal.Image.debian_slim(python_version="3.11")
27
+ .apt_install("git")
28
+ .pip_install(
29
+ # Pin the exact ms-swift that recognizes MiniCPM-V 4.6 (the extraction run used this);
30
+ # an unpinned `datasets` previously dragged ms-swift down to a version that didn't. ms-swift
31
+ # brings a compatible `datasets`, so we don't add it ourselves.
32
+ "torch",
33
+ "transformers>=5.7.0",
34
+ "peft>=0.12",
35
+ "accelerate>=0.33",
36
+ "ms-swift==4.3.0",
37
+ "sentencepiece",
38
+ "timm",
39
+ )
40
+ )
41
+
42
+ adapters = modal.Volume.from_name("blood-test-adapters", create_if_missing=True)
43
+ hf_cache = modal.Volume.from_name("blood-test-hf-cache", create_if_missing=True)
44
+
45
+
46
+ @app.function(
47
+ image=image,
48
+ gpu="A100",
49
+ timeout=6 * 60 * 60,
50
+ volumes={"/adapters": adapters, "/root/.cache/huggingface": hf_cache},
51
+ )
52
+ def train_medreason(n: int = 4000, epochs: int = 1, lr: float = 1e-4, n_eval: int = 500, seed: int = 13) -> str:
53
+ import json
54
+ import os
55
+ import subprocess
56
+ from pathlib import Path
57
+
58
+ from datasets import load_dataset
59
+
60
+ os.environ["USE_HF"] = "1" # pull dataset + weights from HF (fast on Modal), not ModelScope
61
+
62
+ # 1) medical-o1 reasoning data (English) -> text chat messages (Question -> CoT + Response)
63
+ ds = load_dataset("FreedomIntelligence/medical-o1-reasoning-SFT", "en", split="train")
64
+ ds = ds.shuffle(seed=seed).select(range(min(n + n_eval, len(ds))))
65
+
66
+ def to_messages(ex: dict) -> dict:
67
+ q = (ex.get("Question") or "").strip()
68
+ cot = (ex.get("Complex_CoT") or "").strip()
69
+ resp = (ex.get("Response") or "").strip()
70
+ answer = f"{cot}\n\n{resp}".strip() if cot else resp
71
+ return {"messages": [{"role": "user", "content": q}, {"role": "assistant", "content": answer}]}
72
+
73
+ rows = [to_messages(ex) for ex in ds]
74
+ val_rows, train_rows = rows[:n_eval], rows[n_eval:]
75
+
76
+ data_dir = Path("/root/data")
77
+ data_dir.mkdir(parents=True, exist_ok=True)
78
+ train_path = data_dir / "medreason_train.jsonl"
79
+ val_path = data_dir / "medreason_val.jsonl"
80
+ train_path.write_text("\n".join(json.dumps(r, ensure_ascii=False) for r in train_rows), encoding="utf-8")
81
+ val_path.write_text("\n".join(json.dumps(r, ensure_ascii=False) for r in val_rows), encoding="utf-8")
82
+ print(f"medical-o1: {len(train_rows)} train, {len(val_rows)} held-out eval examples")
83
+
84
+ # 2) LoRA the LLM only (freeze vision) on the reasoning text — keeps extraction untouched.
85
+ out_dir = "/adapters/medreason-lora"
86
+ cmd = [
87
+ "swift", "sft",
88
+ "--model", MODEL_ID,
89
+ "--dataset", str(train_path),
90
+ "--val_dataset", str(val_path),
91
+ "--num_train_epochs", str(epochs),
92
+ "--lora_rank", "16",
93
+ "--lora_alpha", "32",
94
+ "--learning_rate", str(lr),
95
+ "--warmup_ratio", "0.05",
96
+ "--per_device_train_batch_size", "2",
97
+ "--gradient_accumulation_steps", "8",
98
+ "--max_length", "4096", # medical CoT answers are long
99
+ "--freeze_vit", "true", # do not touch the vision encoder (extraction lives there)
100
+ "--eval_steps", "50", # report held-out reasoning loss during training
101
+ "--output_dir", out_dir,
102
+ "--save_total_limit", "1",
103
+ ]
104
+ print("Running:", " ".join(cmd))
105
+ subprocess.run(cmd, check=True, env={**os.environ, "USE_HF": "1"})
106
+
107
+ adapters.commit()
108
+ return out_dir
109
+
110
+
111
+ @app.local_entrypoint()
112
+ def main(n: int = 4000, epochs: int = 1, lr: float = 1e-4) -> None:
113
+ path = train_medreason.remote(n=n, epochs=epochs, lr=lr)
114
+ print(f"\nMedical-reasoning LoRA saved to volume 'blood-test-adapters' at {path}")
115
+ print("Next:")
116
+ print(" modal run train/modal_finetune.py::merge --adapter-dir /adapters/medreason-lora \\")
117
+ print(" --repo-id dimitriskl/blood-test-minicpmv-4_6-medreason")
118
+ print(" modal run train/modal_eval.py::compare --finetuned-id dimitriskl/blood-test-minicpmv-4_6-medreason")
119
+ print(" ^ Gate A: confirms extraction did NOT regress on the reasoning-tuned model.")