Spaces:
Running on Zero
Running on Zero
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 +288 -0
- src/interpretation.py +135 -0
- train/modal_eval.py +120 -0
- train/modal_finetune.py +20 -14
- train/synth_reports.py +87 -16
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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
| 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.
|
| 106 |
tests.append({
|
| 107 |
"marker": printed,
|
| 108 |
"canonical": m.name,
|
|
|
|
| 109 |
"value": _round_for(m, v),
|
| 110 |
"unit": m.unit,
|
| 111 |
-
"reference_range":
|
| 112 |
"status": status,
|
| 113 |
})
|
| 114 |
style = {
|
| 115 |
"layout": rng.choice(_LAYOUTS),
|
| 116 |
"lab": rng.choice(LAB_NAMES),
|
| 117 |
-
"stripe": rng.random() < 0.
|
| 118 |
"grid": rng.random() < 0.5,
|
| 119 |
-
"
|
| 120 |
-
"
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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=
|
| 172 |
-
d.text((pad, 40), style["lab"], font=f_h1, fill=
|
| 173 |
-
d.text((pad, 88),
|
| 174 |
-
|
| 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
|
| 188 |
-
y = table_top + row_h *
|
| 189 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = []
|