Codex commited on
Commit
24baa75
·
1 Parent(s): 907c528

feat: gentler fine-tune + diversified synth data + interpretation engine + before/after eval

Browse files

- modal_finetune: lower LR (2e-5) + warmup, 1 epoch, tunable hyperparams; re-runnable merge.
- synth_reports: section-grouped/varied layouts + ref-range/theme/demographic diversity to fix overfitting that regressed real-report extraction.
- modal_eval: Modal before/after eval (base vs fine-tuned) on the labeled reports.
- kb/knowledge_base + interpretation: grounded per-marker insight + cross-marker patterns (Phase 3).

kb/knowledge_base.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grounded interpretation knowledge base (Phase 3).
2
+
3
+ The model does NOT invent medical facts. It extracts values (vision) and then *phrases* the facts
4
+ stored here. Every interpretation the app shows is grounded in this file, which is in turn based on
5
+ the reference material under kb/references/ (psap_reference_values.pdf, nbme_reference_values.pdf)
6
+ and standard general-population clinical references.
7
+
8
+ This is an EDUCATIONAL tool, not a diagnosis. Entries describe common, well-established
9
+ associations only ("a high value may be associated with ..."), never a diagnosis or treatment.
10
+ Canonical marker names + reference ranges live in src/markers.py (single source of truth).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ from src.markers import Marker, resolve
18
+
19
+ DISCLAIMER = (
20
+ "This is general educational information, not a medical diagnosis. Only a qualified clinician "
21
+ "can interpret your results in the context of your history, symptoms, and other tests."
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class MarkerKB:
27
+ """What a high/low value commonly relates to, plus questions to bring to a doctor."""
28
+
29
+ high: str
30
+ low: str
31
+ questions: tuple[str, ...] = field(default_factory=tuple)
32
+
33
+
34
+ # Per-marker grounded facts. "" for a direction means it is not typically clinically flagged there
35
+ # (e.g. a low LDL is generally favorable). Keep statements associative and educational.
36
+ KB: dict[str, MarkerKB] = {
37
+ # --- Complete blood count ---
38
+ "Hemoglobin": MarkerKB(
39
+ high="May be associated with dehydration, smoking, living at high altitude, or less commonly the body making too many red cells.",
40
+ low="A low hemoglobin is the hallmark of anemia, which can stem from iron, B12 or folate deficiency, blood loss, or chronic disease.",
41
+ questions=("Could this explain my tiredness or shortness of breath?", "Should we check iron, B12, or folate?"),
42
+ ),
43
+ "Hematocrit": MarkerKB(
44
+ high="Often tracks with hemoglobin; may reflect dehydration or increased red-cell production.",
45
+ low="Usually moves with hemoglobin and points toward anemia or recent blood loss.",
46
+ questions=("Does this match my hemoglobin result?",),
47
+ ),
48
+ "White Blood Cell Count": MarkerKB(
49
+ high="A high white count commonly accompanies infection or inflammation, physical stress, or certain medications; rarely it reflects a blood disorder.",
50
+ low="A low white count can follow viral infections, some medications, or affect the immune system's ability to fight infection.",
51
+ questions=("Could a recent infection explain this?", "Do we need to recheck it once I'm well?"),
52
+ ),
53
+ "Platelet Count": MarkerKB(
54
+ high="May rise with inflammation, infection, iron deficiency, or after the spleen is removed.",
55
+ low="A low platelet count can increase bruising or bleeding and may relate to infections, medications, or the bone marrow.",
56
+ questions=("Should I avoid anything that thins my blood until this is checked?",),
57
+ ),
58
+ "Red Blood Cell Count": MarkerKB(
59
+ high="May reflect dehydration, smoking, or increased red-cell production.",
60
+ low="Often part of the anemia picture alongside low hemoglobin/hematocrit.",
61
+ questions=("Does this fit with my hemoglobin and MCV?",),
62
+ ),
63
+ "MCV": MarkerKB(
64
+ high="A high MCV (large red cells) is classically associated with B12 or folate deficiency, alcohol, or thyroid issues.",
65
+ low="A low MCV (small red cells) is classically associated with iron deficiency or thalassemia.",
66
+ questions=("Given my MCV, should we look for an iron or a B12 cause?",),
67
+ ),
68
+ # --- Metabolic panel ---
69
+ "Glucose": MarkerKB(
70
+ high="An elevated fasting glucose can indicate prediabetes or diabetes, or simply that the sample was not fasting.",
71
+ low="A low glucose can cause shakiness or lightheadedness and may relate to fasting, medications, or how the sample was handled.",
72
+ questions=("Was this a fasting sample?", "Should we confirm with an HbA1c?"),
73
+ ),
74
+ "Creatinine": MarkerKB(
75
+ high="A high creatinine suggests the kidneys are filtering less efficiently; muscle mass, dehydration, and some medications also raise it.",
76
+ low="A low creatinine is usually not a concern and can reflect lower muscle mass.",
77
+ questions=("What does this mean for my kidney function (eGFR)?",),
78
+ ),
79
+ "eGFR": MarkerKB(
80
+ high="A higher eGFR generally indicates better kidney filtration.",
81
+ low="A low eGFR indicates reduced kidney filtration and is usually interpreted together with creatinine over time.",
82
+ questions=("Is this a one-off or a trend?", "Should any of my medications be dose-adjusted?"),
83
+ ),
84
+ "Blood Urea Nitrogen": MarkerKB(
85
+ high="BUN can rise with dehydration, a high-protein diet, or reduced kidney function.",
86
+ low="A low BUN is rarely significant; it can reflect low protein intake or overhydration.",
87
+ questions=("Does my BUN-to-creatinine ratio suggest dehydration?",),
88
+ ),
89
+ "Sodium": MarkerKB(
90
+ high="High sodium usually reflects dehydration or fluid balance, not dietary salt alone.",
91
+ low="Low sodium is one of the most common electrolyte abnormalities and relates to fluid balance, medications, or hormones.",
92
+ questions=("Could my medications or fluid intake be affecting this?",),
93
+ ),
94
+ "Potassium": MarkerKB(
95
+ high="High potassium can affect heart rhythm and may relate to kidney function or medications; it can also be falsely high from the blood draw.",
96
+ low="Low potassium can cause muscle weakness or cramps and often relates to fluid loss or diuretics.",
97
+ questions=("Should this be rechecked given how it affects the heart?",),
98
+ ),
99
+ "Chloride": MarkerKB(
100
+ high="Usually moves with sodium and acid-base balance.",
101
+ low="Usually moves with sodium and acid-base balance.",
102
+ questions=("Does this fit with my sodium and bicarbonate?",),
103
+ ),
104
+ "Calcium": MarkerKB(
105
+ high="High calcium can relate to the parathyroid glands, vitamin D, or other conditions and is worth following up.",
106
+ low="Low calcium can relate to vitamin D, the parathyroid glands, or low albumin (which carries calcium).",
107
+ questions=("Should we check vitamin D or parathyroid hormone?",),
108
+ ),
109
+ "Albumin": MarkerKB(
110
+ high="A high albumin most often reflects dehydration.",
111
+ low="A low albumin can reflect nutrition, inflammation, or liver/kidney conditions.",
112
+ questions=("Could this be related to my diet or another result?",),
113
+ ),
114
+ "Total Protein": MarkerKB(
115
+ high="May reflect dehydration or increased production of certain proteins.",
116
+ low="May reflect nutrition, liver, or kidney factors.",
117
+ questions=("Does this fit with my albumin level?",),
118
+ ),
119
+ # --- Liver enzymes ---
120
+ "ALT": MarkerKB(
121
+ high="ALT is fairly liver-specific; elevations can follow fatty liver, alcohol, medications, or viral hepatitis.",
122
+ low="A low ALT is not typically a concern.",
123
+ questions=("Could a medication or fatty liver explain this?", "Should we recheck in a few weeks?"),
124
+ ),
125
+ "AST": MarkerKB(
126
+ high="AST rises with liver stress but also comes from muscle; the AST/ALT pattern helps point to a cause.",
127
+ low="A low AST is not typically a concern.",
128
+ questions=("Does the AST/ALT ratio suggest a specific cause?",),
129
+ ),
130
+ "ALP": MarkerKB(
131
+ high="A high alkaline phosphatase can come from the liver/bile ducts or from bone; growth and pregnancy also raise it.",
132
+ low="A low ALP is uncommon and rarely significant.",
133
+ questions=("Is this from my liver or my bones?",),
134
+ ),
135
+ "GGT": MarkerKB(
136
+ high="GGT is sensitive to bile-duct issues and alcohol; it helps clarify whether a high ALP is from the liver.",
137
+ low="A low GGT is not a concern.",
138
+ questions=("Does my GGT help explain my ALP?",),
139
+ ),
140
+ "Total Bilirubin": MarkerKB(
141
+ high="A mildly high bilirubin is often benign (e.g. Gilbert's syndrome) but can relate to the liver or red-cell breakdown.",
142
+ low="A low bilirubin is not a concern.",
143
+ questions=("Is this mild and stable, or does it need follow-up?",),
144
+ ),
145
+ # --- Lipid panel ---
146
+ "Total Cholesterol": MarkerKB(
147
+ high="A high total cholesterol contributes to cardiovascular risk and is best read alongside LDL, HDL, and your overall risk.",
148
+ low="A low total cholesterol is generally not a concern.",
149
+ questions=("What is my overall cardiovascular risk?", "Diet/lifestyle first, or is medication warranted?"),
150
+ ),
151
+ "LDL Cholesterol": MarkerKB(
152
+ high="LDL ('bad' cholesterol) is the main driver of plaque buildup; targets depend on your personal risk.",
153
+ low="A low LDL is generally favorable.",
154
+ questions=("What LDL target is right for my risk level?",),
155
+ ),
156
+ "HDL Cholesterol": MarkerKB(
157
+ high="A higher HDL ('good' cholesterol) is generally protective.",
158
+ low="A low HDL is associated with higher cardiovascular risk; exercise and not smoking help raise it.",
159
+ questions=("Would lifestyle changes help raise my HDL?",),
160
+ ),
161
+ "Triglycerides": MarkerKB(
162
+ high="High triglycerides relate to diet, alcohol, weight, and blood-sugar control, and add to cardiovascular risk.",
163
+ low="A low triglyceride level is generally not a concern.",
164
+ questions=("Was this fasting?", "Would diet changes help?"),
165
+ ),
166
+ # --- Thyroid ---
167
+ "TSH": MarkerKB(
168
+ high="A high TSH usually signals an underactive thyroid (the body asking for more hormone).",
169
+ low="A low TSH usually signals an overactive thyroid.",
170
+ questions=("Should we confirm with a Free T4?", "Could symptoms like fatigue or weight change relate to this?"),
171
+ ),
172
+ "Free T4": MarkerKB(
173
+ high="A high Free T4 supports an overactive thyroid picture.",
174
+ low="A low Free T4 supports an underactive thyroid picture.",
175
+ questions=("How does this fit with my TSH?",),
176
+ ),
177
+ # --- Vitamins / iron ---
178
+ "Vitamin D": MarkerKB(
179
+ high="A very high vitamin D is uncommon and usually from supplements.",
180
+ low="Low vitamin D is common and relates to bone and immune health; sunlight and diet/supplements affect it.",
181
+ questions=("Should I supplement, and at what dose?",),
182
+ ),
183
+ "Vitamin B12": MarkerKB(
184
+ high="A high B12 is usually from supplements and rarely a concern on its own.",
185
+ low="Low B12 can cause fatigue, nerve symptoms, and large red cells (high MCV); diet and absorption matter.",
186
+ questions=("Could this explain my tiredness or tingling?", "Do we need to check absorption?"),
187
+ ),
188
+ "Ferritin": MarkerKB(
189
+ high="Ferritin rises with inflammation as well as iron overload, so a high value is read in context.",
190
+ low="A low ferritin is the most specific sign of low iron stores and a common cause of anemia.",
191
+ questions=("Does my ferritin explain my hemoglobin and MCV?", "Should we look for a source of iron loss?"),
192
+ ),
193
+ "HbA1c": MarkerKB(
194
+ high="A high HbA1c reflects higher average blood sugar over ~3 months and is used to screen for prediabetes and diabetes.",
195
+ low="A low HbA1c is generally not a concern.",
196
+ questions=("Am I in the prediabetes range?", "What changes would lower this?"),
197
+ ),
198
+ }
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class Pattern:
203
+ """A cross-marker pattern the interpretation layer can surface (Phase 3.3)."""
204
+
205
+ name: str
206
+ when: str # human-readable trigger description (logic lives in the interpretation module)
207
+ note: str
208
+
209
+
210
+ # Cross-marker patterns: clusters that mean more together than any single value.
211
+ PATTERNS: tuple[Pattern, ...] = (
212
+ Pattern(
213
+ "Anemia picture",
214
+ "low Hemoglobin with low Hematocrit (and often low RBC)",
215
+ "These low red-cell values together suggest anemia; MCV and ferritin help point to the cause (iron vs B12/folate).",
216
+ ),
217
+ Pattern(
218
+ "Iron-deficiency pattern",
219
+ "low Ferritin with low MCV (microcytic) and low Hemoglobin",
220
+ "Small red cells plus low iron stores are a classic iron-deficiency pattern worth discussing with a clinician.",
221
+ ),
222
+ Pattern(
223
+ "B12/folate pattern",
224
+ "high MCV (macrocytic) with low Vitamin B12",
225
+ "Large red cells alongside low B12 point toward a B12 or folate cause rather than iron.",
226
+ ),
227
+ Pattern(
228
+ "Liver cluster",
229
+ "elevated ALT and/or AST, sometimes with ALP and GGT",
230
+ "Several liver enzymes elevated together raise the question of liver stress; the AST/ALT and ALP/GGT patterns help localize it.",
231
+ ),
232
+ Pattern(
233
+ "Lipid / cardiovascular risk",
234
+ "high LDL or Triglycerides with low HDL",
235
+ "This lipid combination raises cardiovascular risk and is best interpreted with your overall risk profile.",
236
+ ),
237
+ Pattern(
238
+ "Kidney-function pattern",
239
+ "high Creatinine with low eGFR (and sometimes high BUN)",
240
+ "Together these suggest reduced kidney filtration; trend over time matters more than a single reading.",
241
+ ),
242
+ Pattern(
243
+ "Thyroid pattern",
244
+ "high TSH with low Free T4 (underactive) or low TSH with high Free T4 (overactive)",
245
+ "TSH and Free T4 read together indicate whether the thyroid is under- or over-active.",
246
+ ),
247
+ Pattern(
248
+ "Glycemic pattern",
249
+ "high Glucose with high HbA1c",
250
+ "A high spot glucose backed by a high HbA1c is a stronger signal of impaired blood-sugar control than either alone.",
251
+ ),
252
+ )
253
+
254
+
255
+ def interpret(marker_name: str, status: str) -> str | None:
256
+ """Return the grounded educational note for a marker at a given status, or None.
257
+
258
+ `status` is one of 'high', 'low', 'normal' (as produced by Marker.status_for / extraction).
259
+ Returns None when there is nothing flagged to say (normal, or no benign-direction note).
260
+ """
261
+ marker = resolve(marker_name)
262
+ if marker is None:
263
+ return None
264
+ entry = KB.get(marker.name)
265
+ if entry is None:
266
+ return None
267
+ if status == "high":
268
+ return entry.high or None
269
+ if status == "low":
270
+ return entry.low or None
271
+ return None
272
+
273
+
274
+ def questions_for(marker_name: str) -> tuple[str, ...]:
275
+ """Doctor-questions to surface for a flagged marker (Phase 3.4)."""
276
+ marker = resolve(marker_name)
277
+ if marker is None:
278
+ return ()
279
+ entry = KB.get(marker.name)
280
+ return entry.questions if entry else ()
281
+
282
+
283
+ def coverage() -> tuple[int, int]:
284
+ """(markers with KB entries, total canonical markers) — for a quick completeness check."""
285
+ from src.markers import MARKERS
286
+
287
+ covered = sum(1 for m in MARKERS if m.name in KB)
288
+ return covered, len(MARKERS)
src/interpretation.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interpretation engine (Phase 3.2–3.4): turn extracted values into grounded insight.
2
+
3
+ The model extracts the numbers; THIS module decides what is worth saying, and every word of
4
+ medical content comes from the knowledge base (kb/knowledge_base.py), never invented. Output:
5
+ - per-marker insight cards for flagged (high/low) markers, with doctor-questions [3.2, 3.4]
6
+ - cross-marker patterns that mean more together than alone [3.3]
7
+ - an educational disclaimer
8
+
9
+ Pure functions + plain dataclasses so the app can render them and the eval can test them.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass, field
16
+
17
+ from kb.knowledge_base import DISCLAIMER, PATTERNS, interpret, questions_for
18
+ from src.markers import resolve
19
+
20
+ _PATTERN_NOTE = {p.name: p.note for p in PATTERNS}
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class MarkerInsight:
25
+ marker: str # canonical name
26
+ value: str
27
+ unit: str | None
28
+ status: str # "low" | "high"
29
+ reference_range: str
30
+ note: str | None # grounded educational note from the KB
31
+ questions: tuple[str, ...] = field(default_factory=tuple)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class PatternInsight:
36
+ name: str
37
+ note: str
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Interpretation:
42
+ flagged: tuple[MarkerInsight, ...] # abnormal markers only
43
+ normal_count: int # how many recognized markers were in range
44
+ patterns: tuple[PatternInsight, ...]
45
+ disclaimer: str = DISCLAIMER
46
+
47
+ @property
48
+ def has_findings(self) -> bool:
49
+ return bool(self.flagged or self.patterns)
50
+
51
+
52
+ def _num(value: object) -> float | None:
53
+ """Pull the first numeric token out of a value like '12.3', '12.3 *', or '< 0.5'."""
54
+ if value is None:
55
+ return None
56
+ m = re.search(r"-?\d+(?:\.\d+)?", str(value))
57
+ return float(m.group(0)) if m else None
58
+
59
+
60
+ def _status_of(test: dict, marker) -> str:
61
+ """Trust the model's status when valid, else compute it from value vs the marker's range."""
62
+ status = str(test.get("status") or "").strip().lower()
63
+ if status in {"low", "normal", "high"}:
64
+ return status
65
+ value = _num(test.get("value"))
66
+ if value is not None:
67
+ return marker.status_for(value)
68
+ return "unknown"
69
+
70
+
71
+ def build_interpretation(tests: list[dict]) -> Interpretation:
72
+ """Build the grounded interpretation from a list of extracted test dicts."""
73
+ flagged: list[MarkerInsight] = []
74
+ normal = 0
75
+ status_by_marker: dict[str, str] = {}
76
+
77
+ for test in tests:
78
+ marker = resolve(str(test.get("marker") or ""))
79
+ if marker is None:
80
+ continue # unknown marker -> we don't invent meaning for it
81
+ status = _status_of(test, marker)
82
+ status_by_marker[marker.name] = status
83
+ if status in {"low", "high"}:
84
+ flagged.append(
85
+ MarkerInsight(
86
+ marker=marker.name,
87
+ value=str(test.get("value") or "").strip(),
88
+ unit=(test.get("unit") or marker.unit),
89
+ status=status,
90
+ reference_range=marker.ref_range_text(),
91
+ note=interpret(marker.name, status),
92
+ questions=questions_for(marker.name),
93
+ )
94
+ )
95
+ elif status == "normal":
96
+ normal += 1
97
+
98
+ return Interpretation(
99
+ flagged=tuple(flagged),
100
+ normal_count=normal,
101
+ patterns=tuple(_detect_patterns(status_by_marker)),
102
+ )
103
+
104
+
105
+ def _detect_patterns(s: dict[str, str]) -> list[PatternInsight]:
106
+ """Apply the cross-marker pattern logic (the human-readable triggers live in the KB)."""
107
+ out: list[PatternInsight] = []
108
+
109
+ def at(name: str, *statuses: str) -> bool:
110
+ return s.get(name) in statuses
111
+
112
+ liver_high = sum(at(m, "high") for m in ("ALT", "AST", "ALP", "GGT"))
113
+
114
+ if at("Hemoglobin", "low") and at("Hematocrit", "low"):
115
+ out.append(_pattern("Anemia picture"))
116
+ if at("Ferritin", "low") and at("MCV", "low"):
117
+ out.append(_pattern("Iron-deficiency pattern"))
118
+ if at("MCV", "high") and at("Vitamin B12", "low"):
119
+ out.append(_pattern("B12/folate pattern"))
120
+ if liver_high >= 2:
121
+ out.append(_pattern("Liver cluster"))
122
+ if (at("LDL Cholesterol", "high") or at("Triglycerides", "high")) and at("HDL Cholesterol", "low"):
123
+ out.append(_pattern("Lipid / cardiovascular risk"))
124
+ if at("Creatinine", "high") and at("eGFR", "low"):
125
+ out.append(_pattern("Kidney-function pattern"))
126
+ if (at("TSH", "high") and at("Free T4", "low")) or (at("TSH", "low") and at("Free T4", "high")):
127
+ out.append(_pattern("Thyroid pattern"))
128
+ if at("Glucose", "high") and at("HbA1c", "high"):
129
+ out.append(_pattern("Glycemic pattern"))
130
+
131
+ return out
132
+
133
+
134
+ def _pattern(name: str) -> PatternInsight:
135
+ return PatternInsight(name=name, note=_PATTERN_NOTE[name])
train/modal_eval.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Before/after extraction eval on Modal — the OpenBMB proof.
2
+
3
+ Runs the labeled reports through the BASE and the FINE-TUNED model on a GPU and reports the
4
+ field-level accuracy jump. The model runs through the same ZeroGPU/Transformers backend the Space
5
+ uses (here `@spaces.GPU` is a no-op because the `spaces` package isn't installed, so generation
6
+ runs directly on the Modal GPU).
7
+
8
+ modal run train/modal_eval.py::compare --finetuned-id dimitriskalligaridis/blood-test-minicpmv-4_6
9
+
10
+ Writes eval/before_after.json locally; render the chart with: python eval/make_chart.py
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import modal
16
+
17
+ app = modal.App("blood-test-eval")
18
+
19
+ image = (
20
+ modal.Image.debian_slim(python_version="3.11")
21
+ .apt_install("git")
22
+ .pip_install(
23
+ "torch",
24
+ "torchvision",
25
+ "transformers[torch]>=5.7.0",
26
+ "accelerate",
27
+ "pillow",
28
+ "pymupdf",
29
+ "av",
30
+ "requests",
31
+ "json-repair",
32
+ )
33
+ # NOTE: deliberately no `spaces` package -> @spaces.GPU is a no-op -> runs on the Modal GPU.
34
+ .add_local_dir("src", "/root/app/src")
35
+ .add_local_dir("kb", "/root/app/kb")
36
+ .add_local_dir("eval", "/root/app/eval")
37
+ )
38
+
39
+ hf_cache = modal.Volume.from_name("blood-test-hf-cache", create_if_missing=True)
40
+
41
+
42
+ @app.function(
43
+ image=image,
44
+ gpu="A100",
45
+ timeout=60 * 60,
46
+ volumes={"/root/.cache/huggingface": hf_cache},
47
+ secrets=[modal.Secret.from_name("huggingface-secret")],
48
+ )
49
+ def eval_model(model_id: str, labels_rel: str = "eval/data/real/labels.jsonl") -> dict:
50
+ """Run the configured model over the labeled reports and return field-level metrics."""
51
+ import json
52
+ import os
53
+ import sys
54
+ from pathlib import Path
55
+
56
+ sys.path.insert(0, "/root/app")
57
+ os.environ["ZEROGPU_QUANTIZE"] = "0" # bf16 — a clean, representative eval
58
+
59
+ from src.eval_scoring import format_metrics, score
60
+ # Import the ZeroGPU backend directly (not via the factory) so we don't pull in the llama.cpp
61
+ # backend, which isn't installed in this eval image.
62
+ from src.extraction.zerogpu_transformers import ZeroGPUTransformersExtractor
63
+
64
+ labels_path = Path("/root/app") / labels_rel
65
+ gold = [json.loads(ln) for ln in labels_path.read_text(encoding="utf-8").splitlines() if ln.strip()]
66
+ extractor = ZeroGPUTransformersExtractor(model_id=model_id)
67
+ base_dir = labels_path.parent
68
+
69
+ preds: list[dict] = []
70
+ for i, row in enumerate(gold):
71
+ image_path = str((base_dir / row["image"]).resolve())
72
+ try:
73
+ result = extractor.extract(image_path, max_pages=3)
74
+ preds.append({"tests": result.tests})
75
+ print(f"[{i}] {row['image']}: {len(result.tests)} markers")
76
+ except Exception as error: # a failed report is a miss, keep going
77
+ print(f"[{i}] {row['image']}: FAILED — {error}")
78
+ preds.append({"tests": []})
79
+
80
+ m = score(gold, preds)
81
+ print(f"\n=== {model_id} ===\n{format_metrics(m)}\n")
82
+ return {
83
+ "model": model_id,
84
+ "n": len(gold),
85
+ "precision": m.precision,
86
+ "recall": m.recall,
87
+ "f1": m.f1,
88
+ "value_acc": m.value_acc,
89
+ "unit_acc": m.unit_acc,
90
+ "status_acc": m.status_acc,
91
+ "tp": m.tp,
92
+ "fp": m.fp,
93
+ "fn": m.fn,
94
+ "matched": m.matched,
95
+ }
96
+
97
+
98
+ @app.local_entrypoint()
99
+ def compare(
100
+ finetuned_id: str,
101
+ base_id: str = "openbmb/MiniCPM-V-4.6",
102
+ labels_rel: str = "eval/data/real/labels.jsonl",
103
+ ) -> None:
104
+ """Eval base vs fine-tuned and write the before/after numbers for the chart."""
105
+ import json
106
+ from pathlib import Path
107
+
108
+ base = eval_model.remote(base_id, labels_rel)
109
+ fine = eval_model.remote(finetuned_id, labels_rel)
110
+
111
+ metrics = ("f1", "recall", "precision", "value_acc", "unit_acc", "status_acc")
112
+ print(f"\n Extraction before/after — {base['n']} labeled reports\n")
113
+ print(f" {'metric':<12}{'base':>9}{'fine-tuned':>14}{'delta':>10}")
114
+ for key in metrics:
115
+ b, f = base[key], fine[key]
116
+ print(f" {key:<12}{b:>9.3f}{f:>14.3f}{('+' if f >= b else '') + f'{f - b:.3f}':>10}")
117
+
118
+ out = Path("eval/before_after.json")
119
+ out.write_text(json.dumps({"base": base, "finetuned": fine}, indent=2), encoding="utf-8")
120
+ print(f"\n wrote {out} -> render the chart with: python eval/make_chart.py\n")
train/modal_finetune.py CHANGED
@@ -57,7 +57,7 @@ hf_cache = modal.Volume.from_name("blood-test-hf-cache", create_if_missing=True)
57
  timeout=6 * 60 * 60,
58
  volumes={"/adapters": adapters, "/root/.cache/huggingface": hf_cache},
59
  )
60
- def train(n: int = 4000, epochs: int = 2, seed: int = 13) -> str:
61
  import os
62
  import subprocess
63
  import sys
@@ -87,7 +87,11 @@ def train(n: int = 4000, epochs: int = 2, seed: int = 13) -> str:
87
  "--num_train_epochs", str(epochs),
88
  "--lora_rank", "16",
89
  "--lora_alpha", "32",
90
- "--learning_rate", "1e-4",
 
 
 
 
91
  "--per_device_train_batch_size", "2",
92
  "--gradient_accumulation_steps", "8",
93
  "--max_length", "2048",
@@ -105,8 +109,8 @@ def train(n: int = 4000, epochs: int = 2, seed: int = 13) -> str:
105
 
106
 
107
  @app.local_entrypoint()
108
- def main(n: int = 4000, epochs: int = 2) -> None:
109
- path = train.remote(n=n, epochs=epochs)
110
  print(f"\nLoRA adapters saved to Modal volume 'blood-test-adapters' at {path}")
111
  print("Next: merge the adapter into the base model and push it to the Hub:")
112
  print(" modal run train/modal_finetune.py::merge --repo-id <owner>/<model-name>")
@@ -137,16 +141,18 @@ def merge_and_push(repo_id: str, adapter_dir: str = "/adapters/minicpmv-lab-lora
137
  if not checkpoints:
138
  raise RuntimeError(f"No LoRA checkpoints found under {adapter_dir}")
139
  latest = checkpoints[-1]
140
- print(f"Merging LoRA checkpoint: {latest}")
141
-
142
- env = {**os.environ, "USE_HF": "1"}
143
- # ms-swift merges the adapter into the base model; the merged weights land in <ckpt>-merged.
144
- subprocess.run(["swift", "export", "--adapters", latest, "--merge_lora", "true"], check=True, env=env)
145
-
146
- merged = sorted(glob.glob(f"{adapter_dir}/v*/checkpoint-*-merged"), key=os.path.getmtime)
147
- if not merged:
148
- raise RuntimeError("swift export did not produce a *-merged directory")
149
- merged_dir = merged[-1]
 
 
150
  print(f"Merged model directory: {merged_dir}")
151
 
152
  from huggingface_hub import HfApi
 
57
  timeout=6 * 60 * 60,
58
  volumes={"/adapters": adapters, "/root/.cache/huggingface": hf_cache},
59
  )
60
+ def train(n: int = 2000, epochs: int = 1, lr: float = 2e-5, seed: int = 13) -> str:
61
  import os
62
  import subprocess
63
  import sys
 
87
  "--num_train_epochs", str(epochs),
88
  "--lora_rank", "16",
89
  "--lora_alpha", "32",
90
+ # Gentle LoRA: a low LR + warmup nudges output format without overwriting the base model's
91
+ # vision-extraction ability (the previous 1e-4 / 2-epoch run memorized the synthetic data
92
+ # and regressed on real reports). Tune via --lr.
93
+ "--learning_rate", str(lr),
94
+ "--warmup_ratio", "0.1",
95
  "--per_device_train_batch_size", "2",
96
  "--gradient_accumulation_steps", "8",
97
  "--max_length", "2048",
 
109
 
110
 
111
  @app.local_entrypoint()
112
+ def main(n: int = 2000, epochs: int = 1, lr: float = 2e-5) -> None:
113
+ path = train.remote(n=n, epochs=epochs, lr=lr)
114
  print(f"\nLoRA adapters saved to Modal volume 'blood-test-adapters' at {path}")
115
  print("Next: merge the adapter into the base model and push it to the Hub:")
116
  print(" modal run train/modal_finetune.py::merge --repo-id <owner>/<model-name>")
 
141
  if not checkpoints:
142
  raise RuntimeError(f"No LoRA checkpoints found under {adapter_dir}")
143
  latest = checkpoints[-1]
144
+ merged_dir = f"{latest}-merged"
145
+
146
+ if os.path.isdir(merged_dir):
147
+ # Re-run after a push failure: reuse the already-merged weights (swift export refuses to
148
+ # overwrite an existing dir), so we go straight to the upload.
149
+ print(f"Reusing already-merged model: {merged_dir}")
150
+ else:
151
+ print(f"Merging LoRA checkpoint: {latest}")
152
+ env = {**os.environ, "USE_HF": "1"}
153
+ subprocess.run(["swift", "export", "--adapters", latest, "--merge_lora", "true"], check=True, env=env)
154
+ if not os.path.isdir(merged_dir):
155
+ raise RuntimeError(f"swift export did not produce {merged_dir}")
156
  print(f"Merged model directory: {merged_dir}")
157
 
158
  from huggingface_hub import HfApi
train/synth_reports.py CHANGED
@@ -87,11 +87,57 @@ _LAYOUTS = [
87
  {"cols": ["test", "result", "unit", "ref"]},
88
  {"cols": ["test", "result", "ref", "flag"]}, # unit folded into result
89
  {"cols": ["test", "flag", "result", "unit", "ref"]},
 
 
90
  ]
91
  _COL_LABEL = {"test": "Test", "result": "Result", "unit": "Units",
92
  "ref": "Reference Range", "flag": "Flag"}
93
  _FLAG_TEXT = {"low": "L", "high": "H", "normal": ""}
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  def _make_report(rng: random.Random) -> tuple[list[dict], dict]:
97
  """Pick markers + values; return (gold tests, layout/style config)."""
@@ -102,22 +148,26 @@ def _make_report(rng: random.Random) -> tuple[list[dict], dict]:
102
  v = sample_value(rng, m)
103
  status = m.status_for(v)
104
  # Alias variety: sometimes use an alias as the printed name.
105
- printed = rng.choice((m.name,) + m.aliases) if (m.aliases and rng.random() < 0.4) else m.name
106
  tests.append({
107
  "marker": printed,
108
  "canonical": m.name,
 
109
  "value": _round_for(m, v),
110
  "unit": m.unit,
111
- "reference_range": m.ref_range_text(),
112
  "status": status,
113
  })
114
  style = {
115
  "layout": rng.choice(_LAYOUTS),
116
  "lab": rng.choice(LAB_NAMES),
117
- "stripe": rng.random() < 0.6,
118
  "grid": rng.random() < 0.5,
119
- "fold_unit": False,
120
- "base": rng.randint(15, 17),
 
 
 
121
  }
122
  return tests, style
123
 
@@ -142,6 +192,21 @@ def _fmt_num(v) -> str:
142
  return str(int(f)) if f.is_integer() else f"{f:g}"
143
 
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  def render(tests: list[dict], style: dict) -> tuple[Image.Image, list[dict]]:
146
  """Render the report to an image; return (image, gold tests with source_text)."""
147
  cols = list(style["layout"]["cols"])
@@ -149,30 +214,31 @@ def render(tests: list[dict], style: dict) -> tuple[Image.Image, list[dict]]:
149
  W = 1000
150
  pad = 48
151
  base = style["base"]
 
152
  f_h1, f_h2, f_th, f_td = _font(30, True), _font(15), _font(14, True), _font(base)
 
153
 
154
  # Column widths (proportional, tuned per column type).
155
  weight = {"test": 0.34, "result": 0.18, "unit": 0.14, "ref": 0.26, "flag": 0.08}
156
  avail = W - 2 * pad
157
  widths = {c: int(avail * weight[c]) for c in cols}
158
- # Normalize to fill width.
159
  scale = avail / sum(widths.values())
160
  widths = {c: int(w * scale) for c, w in widths.items()}
161
 
 
162
  row_h = base + 16
163
  header_h = 150
164
  table_top = header_h + 30
165
- H = table_top + row_h * (len(tests) + 1) + pad
166
 
167
  img = Image.new("RGB", (W, H), "white")
168
  d = ImageDraw.Draw(img)
169
 
170
  # Header band.
171
- d.rectangle([0, 0, W, header_h], fill=(243, 246, 250))
172
- d.text((pad, 40), style["lab"], font=f_h1, fill=(20, 28, 40))
173
- d.text((pad, 88), "Patient: [SAMPLE] DOB: [SAMPLE] Collected: 2026-03-14",
174
- font=f_h2, fill=(90, 100, 115))
175
- d.text((pad, 112), "Comprehensive Metabolic & Hematology Panel", font=f_h2, fill=(90, 100, 115))
176
 
177
  # Column header row.
178
  x = pad
@@ -182,11 +248,16 @@ def render(tests: list[dict], style: dict) -> tuple[Image.Image, list[dict]]:
182
  x += widths[c]
183
  d.line([pad, y + row_h - 4, W - pad, y + row_h - 4], fill=(190, 200, 212), width=2)
184
 
185
- # Rows.
186
  gold = []
187
- for i, t in enumerate(tests):
188
- y = table_top + row_h * (i + 1)
189
- if style["stripe"] and i % 2 == 1:
 
 
 
 
 
190
  d.rectangle([pad, y, W - pad, y + row_h], fill=(248, 250, 252))
191
  x = pad
192
  row_pieces = []
 
87
  {"cols": ["test", "result", "unit", "ref"]},
88
  {"cols": ["test", "result", "ref", "flag"]}, # unit folded into result
89
  {"cols": ["test", "flag", "result", "unit", "ref"]},
90
+ {"cols": ["test", "result", "ref"]}, # minimal, no flag
91
+ {"cols": ["test", "result", "unit", "flag", "ref"]},
92
  ]
93
  _COL_LABEL = {"test": "Test", "result": "Result", "unit": "Units",
94
  "ref": "Reference Range", "flag": "Flag"}
95
  _FLAG_TEXT = {"low": "L", "high": "H", "normal": ""}
96
 
97
+ # Palette themes: (header-band fill, accent text color) — diversity across "labs".
98
+ _THEMES = [
99
+ ((243, 246, 250), (20, 28, 40)),
100
+ ((237, 244, 238), (24, 54, 36)),
101
+ ((245, 240, 248), (52, 28, 60)),
102
+ ((250, 245, 238), (70, 44, 16)),
103
+ ((255, 255, 255), (15, 15, 15)), # plain / scanned look
104
+ ]
105
+ _PANEL_TITLES = [
106
+ "Comprehensive Metabolic & Hematology Panel", "Laboratory Report",
107
+ "Blood Test Results", "Clinical Chemistry & CBC", "Pathology Report",
108
+ ]
109
+ # Section headers (by category) are decorations the model must NOT extract as markers — this is
110
+ # exactly the real-report failure where "BLOOD INDICES" got read as a marker.
111
+ _SECTION_LABEL = {
112
+ "CBC": "Complete Blood Count (CBC)", "Metabolic": "Metabolic Panel",
113
+ "Liver": "Liver Function Tests", "Lipid": "Lipid Profile",
114
+ "Thyroid": "Thyroid Function", "Vitamin": "Vitamins & Iron Studies",
115
+ }
116
+
117
+
118
+ def _fmt_ref(marker: Marker, rng: random.Random) -> str:
119
+ """Reference ranges as real labs print them (varied separators / bounds / brackets)."""
120
+ lo, hi = marker.ref_low, marker.ref_high
121
+ if lo is not None and hi is not None:
122
+ sep = rng.choice([" - ", "-", " – ", " to "])
123
+ s = f"{_fmt_num(lo)}{sep}{_fmt_num(hi)}"
124
+ return f"[{s}]" if rng.random() < 0.12 else s
125
+ if hi is not None:
126
+ return rng.choice([f"< {_fmt_num(hi)}", f"<{_fmt_num(hi)}", f"Up to {_fmt_num(hi)}", f"0 - {_fmt_num(hi)}"])
127
+ if lo is not None:
128
+ return rng.choice([f"> {_fmt_num(lo)}", f">{_fmt_num(lo)}", f">= {_fmt_num(lo)}"])
129
+ return ""
130
+
131
+
132
+ def _demo_line(rng: random.Random) -> str:
133
+ age, sex = rng.randint(19, 84), rng.choice(["Male", "Female", "M", "F"])
134
+ return rng.choice([
135
+ f"Patient: [SAMPLE] Age/Sex: {age}/{sex} Collected: 2026-03-14",
136
+ f"Name: [SAMPLE] Age: {age} Years Sex: {sex}",
137
+ f"[SAMPLE] · {age}{'M' if sex in ('Male', 'M') else 'F'} · Specimen: Serum",
138
+ "Patient: [SAMPLE] DOB: [SAMPLE] Collected: 2026-03-14",
139
+ ])
140
+
141
 
142
  def _make_report(rng: random.Random) -> tuple[list[dict], dict]:
143
  """Pick markers + values; return (gold tests, layout/style config)."""
 
148
  v = sample_value(rng, m)
149
  status = m.status_for(v)
150
  # Alias variety: sometimes use an alias as the printed name.
151
+ printed = rng.choice((m.name,) + m.aliases) if (m.aliases and rng.random() < 0.45) else m.name
152
  tests.append({
153
  "marker": printed,
154
  "canonical": m.name,
155
+ "category": m.category,
156
  "value": _round_for(m, v),
157
  "unit": m.unit,
158
+ "reference_range": _fmt_ref(m, rng),
159
  "status": status,
160
  })
161
  style = {
162
  "layout": rng.choice(_LAYOUTS),
163
  "lab": rng.choice(LAB_NAMES),
164
+ "stripe": rng.random() < 0.55,
165
  "grid": rng.random() < 0.5,
166
+ "sectioned": rng.random() < 0.55,
167
+ "theme": rng.choice(_THEMES),
168
+ "title": rng.choice(_PANEL_TITLES),
169
+ "demo": _demo_line(rng),
170
+ "base": rng.randint(14, 18),
171
  }
172
  return tests, style
173
 
 
192
  return str(int(f)) if f.is_integer() else f"{f:g}"
193
 
194
 
195
+ def _ordered_rows(tests: list[dict], sectioned: bool) -> list[tuple[str, object]]:
196
+ """Display rows. When sectioned, group markers by category under a section header (a
197
+ decoration that is NOT in the gold), teaching the model to skip such headers."""
198
+ if not sectioned:
199
+ return [("data", t) for t in tests]
200
+ by_cat: dict[str, list[dict]] = {}
201
+ for t in tests:
202
+ by_cat.setdefault(t["category"], []).append(t)
203
+ rows: list[tuple[str, object]] = []
204
+ for cat, items in by_cat.items():
205
+ rows.append(("section", _SECTION_LABEL.get(cat, cat)))
206
+ rows.extend(("data", t) for t in items)
207
+ return rows
208
+
209
+
210
  def render(tests: list[dict], style: dict) -> tuple[Image.Image, list[dict]]:
211
  """Render the report to an image; return (image, gold tests with source_text)."""
212
  cols = list(style["layout"]["cols"])
 
214
  W = 1000
215
  pad = 48
216
  base = style["base"]
217
+ band, accent = style["theme"]
218
  f_h1, f_h2, f_th, f_td = _font(30, True), _font(15), _font(14, True), _font(base)
219
+ f_sec = _font(base + 1, True)
220
 
221
  # Column widths (proportional, tuned per column type).
222
  weight = {"test": 0.34, "result": 0.18, "unit": 0.14, "ref": 0.26, "flag": 0.08}
223
  avail = W - 2 * pad
224
  widths = {c: int(avail * weight[c]) for c in cols}
 
225
  scale = avail / sum(widths.values())
226
  widths = {c: int(w * scale) for c, w in widths.items()}
227
 
228
+ display = _ordered_rows(tests, style.get("sectioned", False))
229
  row_h = base + 16
230
  header_h = 150
231
  table_top = header_h + 30
232
+ H = table_top + row_h * (len(display) + 1) + pad
233
 
234
  img = Image.new("RGB", (W, H), "white")
235
  d = ImageDraw.Draw(img)
236
 
237
  # Header band.
238
+ d.rectangle([0, 0, W, header_h], fill=band)
239
+ d.text((pad, 40), style["lab"], font=f_h1, fill=accent)
240
+ d.text((pad, 88), style["demo"], font=f_h2, fill=(90, 100, 115))
241
+ d.text((pad, 112), style["title"], font=f_h2, fill=(90, 100, 115))
 
242
 
243
  # Column header row.
244
  x = pad
 
248
  x += widths[c]
249
  d.line([pad, y + row_h - 4, W - pad, y + row_h - 4], fill=(190, 200, 212), width=2)
250
 
251
+ # Rows (data + section decorations).
252
  gold = []
253
+ for di, (kind, payload) in enumerate(display, start=1):
254
+ y = table_top + row_h * di
255
+ if kind == "section":
256
+ d.rectangle([pad, y, W - pad, y + row_h], fill=(232, 237, 243))
257
+ d.text((pad + 6, y + 3), str(payload), font=f_sec, fill=accent)
258
+ continue
259
+ t = payload
260
+ if style["stripe"] and di % 2 == 1:
261
  d.rectangle([pad, y, W - pad, y + row_h], fill=(248, 250, 252))
262
  x = pad
263
  row_pieces = []