hssling commited on
Commit
b96421f
·
verified ·
1 Parent(s): e9a6dbc

Deploy inference service from GitHub Actions

Browse files
Dockerfile CHANGED
@@ -3,23 +3,18 @@ FROM python:3.11-slim
3
 
4
  WORKDIR /app
5
 
6
- # System deps for OpenCV
7
  RUN apt-get update && apt-get install -y --no-install-recommends \
8
  libglib2.0-0 libsm6 libxrender1 libxext6 curl \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
- # Install Python deps
12
  COPY inference/requirements.txt requirements.txt
13
  RUN pip install --no-cache-dir -r requirements.txt
14
 
15
- # Copy training package (needed for model classes)
16
  COPY training/ training/
17
  COPY inference/ inference/
18
 
19
- # Expose port
20
  EXPOSE 7860
21
 
22
- # Health check
23
  HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
24
  CMD sh -c 'curl -f "http://localhost:${PORT:-7860}/health" || exit 1'
25
 
 
3
 
4
  WORKDIR /app
5
 
 
6
  RUN apt-get update && apt-get install -y --no-install-recommends \
7
  libglib2.0-0 libsm6 libxrender1 libxext6 curl \
8
  && rm -rf /var/lib/apt/lists/*
9
 
 
10
  COPY inference/requirements.txt requirements.txt
11
  RUN pip install --no-cache-dir -r requirements.txt
12
 
 
13
  COPY training/ training/
14
  COPY inference/ inference/
15
 
 
16
  EXPOSE 7860
17
 
 
18
  HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
19
  CMD sh -c 'curl -f "http://localhost:${PORT:-7860}/health" || exit 1'
20
 
README.md CHANGED
@@ -12,3 +12,7 @@ license: mit
12
  # AnemiaScan
13
 
14
  Research-only non-invasive anemia screening demo.
 
 
 
 
 
12
  # AnemiaScan
13
 
14
  Research-only non-invasive anemia screening demo.
15
+
16
+ Concept, design, build, training, deployment, testing by: Dr Siddalingaiah H S, Professor, Community Medicine, Shridevi Institute of Medical Sciences and Research Hospital, Tumkur, hssling@yahoo.com, 8941087719.
17
+
18
+ ORCID: 0000-0002-4771-8285
inference/Dockerfile CHANGED
@@ -3,23 +3,18 @@ FROM python:3.11-slim
3
 
4
  WORKDIR /app
5
 
6
- # System deps for OpenCV
7
  RUN apt-get update && apt-get install -y --no-install-recommends \
8
  libglib2.0-0 libsm6 libxrender1 libxext6 curl \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
- # Install Python deps
12
  COPY inference/requirements.txt requirements.txt
13
  RUN pip install --no-cache-dir -r requirements.txt
14
 
15
- # Copy training package (needed for model classes)
16
  COPY training/ training/
17
  COPY inference/ inference/
18
 
19
- # Expose port
20
  EXPOSE 7860
21
 
22
- # Health check
23
  HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
24
  CMD sh -c 'curl -f "http://localhost:${PORT:-7860}/health" || exit 1'
25
 
 
3
 
4
  WORKDIR /app
5
 
 
6
  RUN apt-get update && apt-get install -y --no-install-recommends \
7
  libglib2.0-0 libsm6 libxrender1 libxext6 curl \
8
  && rm -rf /var/lib/apt/lists/*
9
 
 
10
  COPY inference/requirements.txt requirements.txt
11
  RUN pip install --no-cache-dir -r requirements.txt
12
 
 
13
  COPY training/ training/
14
  COPY inference/ inference/
15
 
 
16
  EXPOSE 7860
17
 
 
18
  HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
19
  CMD sh -c 'curl -f "http://localhost:${PORT:-7860}/health" || exit 1'
20
 
inference/__init__.py CHANGED
@@ -0,0 +1 @@
 
 
1
+ # inference/__init__.py
inference/app.py CHANGED
@@ -1,16 +1,12 @@
1
- # inference/app.py
2
- """
3
- AnemiaScan Inference Server -- FastAPI + Gradio
4
-
5
- Routes:
6
- GET /health -> {"status": "ok", "models_loaded": [...]}
7
- POST /api/predict -> JSON prediction result
8
- GET /demo -> Gradio demo interface
9
- """
10
 
 
11
  import io
12
  import logging
13
  import os
 
 
14
 
15
  import gradio as gr
16
  import uvicorn
@@ -20,218 +16,201 @@ from fastapi.responses import RedirectResponse
20
  from PIL import Image
21
 
22
  from inference.gradcam import generate_gradcam
23
- from inference.model_loader import load_model, preload_all_models
24
  from inference.predict import preprocess_image, run_full_prediction
25
 
26
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
27
  log = logging.getLogger(__name__)
28
 
29
- app = FastAPI(
 
 
 
 
30
  title="AnemiaScan Inference API",
31
- description="Non-invasive anemia screening from conjunctival and nail-bed images",
32
- version="1.0.0",
33
  )
34
 
35
- app.add_middleware(
36
  CORSMiddleware,
37
- allow_origins=["*"], # Netlify frontend
38
  allow_methods=["GET", "POST"],
39
  allow_headers=["*"],
40
  )
41
 
42
- W_CONJ = float(os.getenv("ENSEMBLE_W_CONJ", "0.5"))
43
- W_NAIL = float(os.getenv("ENSEMBLE_W_NAIL", "0.5"))
44
-
45
- SPACE_CSS = """
46
- :root {
47
- --glass-bg: rgba(248, 243, 236, 0.7);
48
- --glass-line: rgba(36, 26, 20, 0.1);
49
- }
50
- body, .gradio-container {
51
- background:
52
- radial-gradient(circle at top left, rgba(156, 47, 31, 0.18), transparent 24%),
53
- radial-gradient(circle at bottom right, rgba(212, 169, 79, 0.16), transparent 22%),
54
- linear-gradient(180deg, #f6efe3 0%, #ecdfcf 100%);
55
- font-family: "Aptos", "Segoe UI Variable Text", "Segoe UI", sans-serif;
56
- }
57
- .gradio-container {
58
- max-width: 1100px !important;
59
- }
60
- .gr-box, .block, .gr-panel, .gr-form {
61
- border-radius: 24px !important;
62
- }
63
- .gradio-container .gr-panel,
64
- .gradio-container .gr-box,
65
- .gradio-container .block {
66
- background: var(--glass-bg) !important;
67
- backdrop-filter: blur(18px) saturate(135%);
68
- border: 1px solid var(--glass-line) !important;
69
- box-shadow: 0 22px 60px rgba(70, 44, 30, 0.12);
70
- }
71
- .gradio-container h1, .gradio-container h2, .gradio-container h3 {
72
- letter-spacing: -0.02em;
73
- }
74
- footer { display: none !important; }
75
- @media (prefers-color-scheme: dark) {
76
- :root {
77
- --glass-bg: rgba(20, 29, 41, 0.72);
78
- --glass-line: rgba(237, 241, 247, 0.08);
79
- }
80
- body, .gradio-container {
81
- background:
82
- radial-gradient(circle at top left, rgba(255, 122, 89, 0.14), transparent 24%),
83
- radial-gradient(circle at bottom right, rgba(127, 81, 255, 0.12), transparent 20%),
84
- linear-gradient(180deg, #0e141c 0%, #101922 100%);
85
- }
86
- }
87
- """
88
-
89
- _MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
90
- _MAX_IMAGE_PIXELS = 4096 * 4096 # ~16 MP
91
-
92
-
93
- def _open_image(raw: bytes) -> Image.Image:
94
- """Open image bytes with size guards to prevent OOM from malicious uploads."""
95
- if len(raw) > _MAX_UPLOAD_BYTES:
96
- raise HTTPException(status_code=413, detail="Image file too large (max 10 MB).")
97
- img = Image.open(io.BytesIO(raw))
98
- if img.width * img.height > _MAX_IMAGE_PIXELS:
99
- raise HTTPException(status_code=400, detail="Image dimensions too large (max 4096x4096).")
100
- return img.convert("RGB")
101
-
102
-
103
- @app.on_event("startup")
104
- async def startup():
105
- log.info("Preloading models ...")
106
- preload_all_models()
107
- log.info("Models ready.")
108
-
109
-
110
- @app.get("/health")
111
- def health():
112
- from inference.model_loader import _MODEL_CACHE
113
-
114
- return {"status": "ok", "models_loaded": list(_MODEL_CACHE.keys())}
115
-
116
-
117
- @app.get("/")
118
- def root():
119
- return RedirectResponse(url="/demo/")
120
-
121
-
122
- @app.post("/api/predict")
123
  async def predict(
124
  conjunctiva_image: UploadFile | None = File(default=None),
125
  nailbed_image: UploadFile | None = File(default=None),
126
- model_name: str = Form(default="ensemble"),
127
- include_gradcam: str = Form(default="false"),
128
- ):
129
- if conjunctiva_image is None and nailbed_image is None:
130
- raise HTTPException(status_code=400, detail="Provide at least one image.")
131
 
132
- conj_pil = None
133
- nail_pil = None
134
-
135
- if conjunctiva_image is not None:
136
- raw = await conjunctiva_image.read()
137
- conj_pil = _open_image(raw)
138
 
139
- if nailbed_image is not None:
140
- raw = await nailbed_image.read()
141
- nail_pil = _open_image(raw)
142
 
143
- try:
144
- conj_model = load_model("conjunctiva") if conj_pil else None
145
- nail_model = load_model("nailbed") if nail_pil else None
146
- except Exception as e:
147
- raise HTTPException(status_code=503, detail=f"Model loading failed: {e}")
148
 
149
  try:
150
  result = run_full_prediction(
151
- conj_img=conj_pil,
152
- nail_img=nail_pil,
153
  conj_model=conj_model,
154
  nail_model=nail_model,
155
  w_conj=W_CONJ,
156
  w_nail=W_NAIL,
157
  )
158
- except ValueError as e:
159
- raise HTTPException(status_code=400, detail=str(e))
160
- except Exception:
161
- log.exception("Prediction failed")
162
- raise HTTPException(status_code=500, detail="Internal prediction error.")
163
-
164
- if include_gradcam.lower() == "true":
165
- try:
166
- gc_img = conj_pil if conj_pil else nail_pil
167
- gc_site = "conjunctiva" if conj_pil else "nailbed"
168
- gc_model = load_model(gc_site)
169
- gc_tensor = preprocess_image(gc_img)
170
- result["gradcam_b64"] = generate_gradcam(gc_model, gc_tensor)
171
- except Exception as e:
172
- log.warning(f"Grad-CAM generation failed: {e}")
173
- result["gradcam_b64"] = None
174
 
175
- return result
 
176
 
177
 
178
- def gradio_predict(conj_img, nail_img):
179
- """Gradio wrapper for the prediction endpoint."""
180
  conj_pil = Image.fromarray(conj_img) if conj_img is not None else None
181
  nail_pil = Image.fromarray(nail_img) if nail_img is not None else None
182
 
183
- if conj_pil is None and nail_pil is None:
184
- return "Please upload at least one image.", {}
185
-
186
  try:
187
- conj_model = load_model("conjunctiva") if conj_pil else None
188
- nail_model = load_model("nailbed") if nail_pil else None
189
-
190
  result = run_full_prediction(
191
- conj_img=conj_pil,
192
- nail_img=nail_pil,
193
  conj_model=conj_model,
194
  nail_model=nail_model,
195
  w_conj=W_CONJ,
196
  w_nail=W_NAIL,
197
  )
198
- except Exception as e:
199
- return f"Prediction error: {e}", {}
 
200
 
201
  summary = (
202
- f"**Hb Estimate:** {result['hb_estimate']} g/dL "
203
- f"(95% CI: {result['hb_ci_95'][0]}-{result['hb_ci_95'][1]})\n\n"
204
- f"**Classification:** {result['classification'].replace('_', ' ').title()}\n\n"
205
- f"Warning: {result['disclaimer']}"
206
  )
207
- return summary, result["class_probabilities"]
 
 
 
 
 
 
 
208
 
209
 
210
  demo = gr.Interface(
211
  fn=gradio_predict,
212
  inputs=[
213
- gr.Image(label="Conjunctiva Image (optional)", type="numpy"),
214
- gr.Image(label="Nail-bed Image (optional)", type="numpy"),
 
 
 
 
 
215
  ],
216
  outputs=[
217
  gr.Markdown(label="Result"),
218
  gr.Label(label="Class Probabilities", num_top_classes=4),
 
219
  ],
220
- title="AnemiaScan -- AI Anemia Screening",
221
  description=(
222
- "Upload a palpebral conjunctiva and/or nail-bed image to estimate hemoglobin "
223
- "and classify anemia severity. **Research tool only -- not a medical device.**"
224
- ),
225
- examples=[],
226
- theme=gr.themes.Soft(
227
- primary_hue="rose",
228
- secondary_hue="amber",
229
- neutral_hue="stone",
230
  ),
231
- css=SPACE_CSS,
232
  )
233
 
234
- app = gr.mount_gradio_app(app, demo, path="/demo")
 
235
 
236
  if __name__ == "__main__":
237
- uvicorn.run("inference.app:app", host="0.0.0.0", port=8000, reload=False)
 
1
+ """FastAPI inference service with a mounted Gradio demo."""
2
+ from __future__ import annotations
 
 
 
 
 
 
 
3
 
4
+ import base64
5
  import io
6
  import logging
7
  import os
8
+ from contextlib import suppress
9
+ from typing import Any
10
 
11
  import gradio as gr
12
  import uvicorn
 
16
  from PIL import Image
17
 
18
  from inference.gradcam import generate_gradcam
19
+ from inference.model_loader import _MODEL_CACHE, load_model, preload_available_models
20
  from inference.predict import preprocess_image, run_full_prediction
21
 
22
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
23
  log = logging.getLogger(__name__)
24
 
25
+ PORT = int(os.getenv("PORT", "7860"))
26
+ W_CONJ = float(os.getenv("ENSEMBLE_W_CONJ", "1.0"))
27
+ W_NAIL = float(os.getenv("ENSEMBLE_W_NAIL", "0.0"))
28
+
29
+ base_app = FastAPI(
30
  title="AnemiaScan Inference API",
31
+ description="Non-invasive anemia screening from conjunctiva and nail-bed images",
32
+ version="0.4.0",
33
  )
34
 
35
+ base_app.add_middleware(
36
  CORSMiddleware,
37
+ allow_origins=["*"],
38
  allow_methods=["GET", "POST"],
39
  allow_headers=["*"],
40
  )
41
 
42
+
43
+ def _read_upload_as_pil(upload: UploadFile | None) -> Image.Image | None:
44
+ if upload is None:
45
+ return None
46
+ return Image.open(io.BytesIO(upload.file.read())).convert("RGB")
47
+
48
+
49
+ def _try_load_model(site: str):
50
+ with suppress(Exception):
51
+ return load_model(site)
52
+ return None
53
+
54
+
55
+ def _select_inputs_for_mode(
56
+ mode: str,
57
+ conj_img: Image.Image | None,
58
+ nail_img: Image.Image | None,
59
+ ) -> tuple[Image.Image | None, Image.Image | None]:
60
+ normalized = mode.lower().strip()
61
+ if normalized == "conjunctiva":
62
+ return conj_img, None
63
+ if normalized == "nailbed":
64
+ return None, nail_img
65
+ return conj_img, nail_img
66
+
67
+
68
+ def _augment_with_gradcam(
69
+ result: dict[str, Any],
70
+ conj_img: Image.Image | None,
71
+ nail_img: Image.Image | None,
72
+ conj_model,
73
+ nail_model,
74
+ ) -> dict[str, Any]:
75
+ primary_img = conj_img if conj_img is not None else nail_img
76
+ primary_model = conj_model if conj_img is not None else nail_model
77
+ if primary_img is None or primary_model is None:
78
+ result["gradcam_b64"] = None
79
+ return result
80
+
81
+ try:
82
+ gradcam_tensor = preprocess_image(primary_img)
83
+ result["gradcam_b64"] = generate_gradcam(primary_model, gradcam_tensor)
84
+ except Exception as exc:
85
+ log.warning("Grad-CAM generation failed: %s", exc)
86
+ result["gradcam_b64"] = None
87
+ return result
88
+
89
+
90
+ @base_app.on_event("startup")
91
+ async def startup_event() -> None:
92
+ log.info("Preloading available inference models")
93
+ preload_available_models()
94
+
95
+
96
+ @base_app.get("/")
97
+ def root() -> RedirectResponse:
98
+ return RedirectResponse(url="/demo")
99
+
100
+
101
+ @base_app.get("/health")
102
+ def health() -> dict[str, Any]:
103
+ return {
104
+ "status": "ok",
105
+ "models_loaded": sorted(_MODEL_CACHE.keys()),
106
+ "default_mode": "conjunctiva",
107
+ }
108
+
109
+
110
+ @base_app.post("/api/predict")
 
 
 
 
 
 
 
 
 
 
 
 
111
  async def predict(
112
  conjunctiva_image: UploadFile | None = File(default=None),
113
  nailbed_image: UploadFile | None = File(default=None),
114
+ model: str = Form(default="ensemble"),
115
+ ) -> dict[str, Any]:
116
+ conj_img = _read_upload_as_pil(conjunctiva_image)
117
+ nail_img = _read_upload_as_pil(nailbed_image)
 
118
 
119
+ if conj_img is None and nail_img is None:
120
+ raise HTTPException(status_code=400, detail="Provide at least one image.")
 
 
 
 
121
 
122
+ conj_img, nail_img = _select_inputs_for_mode(model, conj_img, nail_img)
123
+ conj_model = _try_load_model("conjunctiva") if conj_img is not None else None
124
+ nail_model = _try_load_model("nailbed") if nail_img is not None else None
125
 
126
+ if conj_img is not None and conj_model is None and nail_img is None:
127
+ raise HTTPException(status_code=503, detail="Conjunctiva model is unavailable.")
128
+ if nail_img is not None and nail_model is None and conj_img is None:
129
+ raise HTTPException(status_code=503, detail="Nail-bed model is unavailable.")
 
130
 
131
  try:
132
  result = run_full_prediction(
133
+ conj_img=conj_img,
134
+ nail_img=nail_img,
135
  conj_model=conj_model,
136
  nail_model=nail_model,
137
  w_conj=W_CONJ,
138
  w_nail=W_NAIL,
139
  )
140
+ except ValueError as exc:
141
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
+ result["requested_mode"] = model
144
+ return _augment_with_gradcam(result, conj_img, nail_img, conj_model, nail_model)
145
 
146
 
147
+ def gradio_predict(conj_img, nail_img, mode):
 
148
  conj_pil = Image.fromarray(conj_img) if conj_img is not None else None
149
  nail_pil = Image.fromarray(nail_img) if nail_img is not None else None
150
 
 
 
 
151
  try:
152
+ selected_conj, selected_nail = _select_inputs_for_mode(mode, conj_pil, nail_pil)
153
+ conj_model = _try_load_model("conjunctiva") if selected_conj is not None else None
154
+ nail_model = _try_load_model("nailbed") if selected_nail is not None else None
155
  result = run_full_prediction(
156
+ conj_img=selected_conj,
157
+ nail_img=selected_nail,
158
  conj_model=conj_model,
159
  nail_model=nail_model,
160
  w_conj=W_CONJ,
161
  w_nail=W_NAIL,
162
  )
163
+ result = _augment_with_gradcam(result, selected_conj, selected_nail, conj_model, nail_model)
164
+ except Exception as exc:
165
+ return f"Prediction failed: {exc}", {}, None
166
 
167
  summary = (
168
+ f"**Hb Estimate:** {result['hb_estimate']} g/dL\n\n"
169
+ f"**95% CI:** {result['hb_ci_95'][0]} to {result['hb_ci_95'][1]}\n\n"
170
+ f"**Classification:** {result['classification'].title()}\n\n"
171
+ f"**Disclaimer:** {result['disclaimer']}"
172
  )
173
+ gradcam_image = None
174
+ if result["gradcam_b64"] is not None:
175
+ gradcam_bytes = io.BytesIO()
176
+ gradcam_bytes.write(base64.b64decode(result["gradcam_b64"]))
177
+ gradcam_bytes.seek(0)
178
+ gradcam_image = Image.open(gradcam_bytes)
179
+
180
+ return summary, result["class_probabilities"], gradcam_image
181
 
182
 
183
  demo = gr.Interface(
184
  fn=gradio_predict,
185
  inputs=[
186
+ gr.Image(label="Conjunctiva Image", type="numpy"),
187
+ gr.Image(label="Nail-bed Image", type="numpy"),
188
+ gr.Radio(
189
+ choices=["ensemble", "conjunctiva", "nailbed"],
190
+ value="conjunctiva",
191
+ label="Prediction mode",
192
+ ),
193
  ],
194
  outputs=[
195
  gr.Markdown(label="Result"),
196
  gr.Label(label="Class Probabilities", num_top_classes=4),
197
+ gr.Image(label="Grad-CAM", type="pil"),
198
  ],
199
+ title="AnemiaScan",
200
  description=(
201
+ "Upload conjunctiva and/or nail-bed images for research-only Hb estimation.\n\n"
202
+ "Model summary: EfficientNet-B4 dual-head model, ImageNet-pretrained and fine-tuned on 380x380 RGB images. "
203
+ "Inference includes MC-dropout uncertainty and Grad-CAM explanations. "
204
+ "Tracked evaluation metrics include MAE, RMSE, Pearson r, AUC, F1, sensitivity, specificity, and Bland-Altman analysis. "
205
+ "Current live deployment loads conjunctiva weights first; nail-bed support follows once model weights are uploaded.\n\n"
206
+ "<sub>Concept, design, build, training, deployment, testing by: Dr Siddalingaiah H S, "
207
+ "Professor, Community Medicine, Shridevi Institute of Medical Sciences and Research "
208
+ "Hospital, Tumkur, hssling@yahoo.com, 8941087719. ORCID: 0000-0002-4771-8285.</sub>"
209
  ),
 
210
  )
211
 
212
+ app = gr.mount_gradio_app(base_app, demo, path="/demo")
213
+
214
 
215
  if __name__ == "__main__":
216
+ uvicorn.run("inference.app:app", host="0.0.0.0", port=PORT, reload=False)
inference/gradcam.py CHANGED
@@ -1,12 +1,8 @@
1
- # inference/gradcam.py
2
- """
3
- Grad-CAM heatmap generation for inference explanations.
4
- Returns a base64-encoded PNG overlay for the API response.
5
- """
6
 
7
  import base64
8
  import io
9
- import logging
10
 
11
  import cv2
12
  import numpy as np
@@ -14,7 +10,15 @@ import torch
14
  import torch.nn.functional as F
15
  from PIL import Image
16
 
17
- log = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
18
 
19
 
20
  def generate_gradcam(
@@ -22,89 +26,59 @@ def generate_gradcam(
22
  image_tensor: torch.Tensor,
23
  target_class: int | None = None,
24
  ) -> str:
25
- """
26
- Generate a Grad-CAM heatmap for the given image and model.
27
-
28
- Args:
29
- model: AnemiaModel instance
30
- image_tensor: (1, 3, H, W) preprocessed tensor
31
- target_class: class index to visualise (default: argmax of classification head)
32
-
33
- Returns:
34
- Base64-encoded PNG string of the heatmap overlay.
35
- """
36
  model.eval()
37
  activations: dict[str, torch.Tensor] = {}
38
  gradients: dict[str, torch.Tensor] = {}
39
 
40
  target_layer = _get_last_conv_layer(model)
41
 
42
- def forward_hook(_, __, output):
43
  activations["value"] = output.detach()
44
 
45
- def backward_hook(_, __, grad_output):
46
  gradients["value"] = grad_output[0].detach()
47
 
48
- fh = target_layer.register_forward_hook(forward_hook)
49
- bh = target_layer.register_full_backward_hook(backward_hook)
50
 
51
  try:
52
- image_tensor = image_tensor.requires_grad_(True)
53
- hb_pred, cls_logits = model(image_tensor)
54
-
55
  if target_class is None:
56
  target_class = int(cls_logits.argmax(dim=1).item())
57
 
58
- score = cls_logits[0, target_class]
59
- model.zero_grad()
60
  score.backward()
61
 
62
- act = activations["value"].squeeze() # (C, H, W)
63
- grad = gradients["value"].squeeze() # (C, H, W)
64
-
65
- weights = grad.mean(dim=(1, 2)) # (C,)
66
- cam = (weights[:, None, None] * act).sum(dim=0) # (H, W)
67
  cam = F.relu(cam)
68
  cam = cam - cam.min()
69
  cam = cam / (cam.max() + 1e-8)
70
  cam_np = cam.cpu().numpy()
71
 
72
- h, w = image_tensor.shape[2], image_tensor.shape[3]
73
- cam_resized = cv2.resize(cam_np, (w, h))
74
- heatmap = cv2.applyColorMap(np.uint8(255 * cam_resized), cv2.COLORMAP_JET)
 
 
75
  heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
76
 
77
- orig = image_tensor.squeeze().detach().cpu().numpy()
78
  mean = np.array([0.485, 0.456, 0.406])[:, None, None]
79
  std = np.array([0.229, 0.224, 0.225])[:, None, None]
80
- orig = np.clip((orig * std + mean) * 255, 0, 255).astype(np.uint8).transpose(1, 2, 0)
81
-
82
- overlay = cv2.addWeighted(orig, 0.6, heatmap, 0.4, 0)
83
- pil_img = Image.fromarray(overlay)
84
-
85
- buf = io.BytesIO()
86
- pil_img.save(buf, format="PNG")
87
- return base64.b64encode(buf.getvalue()).decode("utf-8")
88
-
89
  finally:
90
- fh.remove()
91
- bh.remove()
92
-
93
-
94
- def _get_last_conv_layer(model: torch.nn.Module) -> torch.nn.Module:
95
- """Return the last convolutional layer of the backbone (EfficientNet-B4)."""
96
- try:
97
- blocks = list(model.backbone.blocks)
98
- for block in reversed(blocks):
99
- for layer in reversed(list(block.modules())):
100
- if isinstance(layer, torch.nn.Conv2d):
101
- return layer
102
- except AttributeError:
103
- pass
104
- last_conv = None
105
- for layer in model.modules():
106
- if isinstance(layer, torch.nn.Conv2d):
107
- last_conv = layer
108
- if last_conv is None:
109
- raise RuntimeError("No Conv2d layer found in model")
110
- return last_conv
 
1
+ """Grad-CAM helper that returns a base64 encoded overlay."""
2
+ from __future__ import annotations
 
 
 
3
 
4
  import base64
5
  import io
 
6
 
7
  import cv2
8
  import numpy as np
 
10
  import torch.nn.functional as F
11
  from PIL import Image
12
 
13
+
14
+ def _get_last_conv_layer(model: torch.nn.Module) -> torch.nn.Module:
15
+ last_conv = None
16
+ for layer in model.modules():
17
+ if isinstance(layer, torch.nn.Conv2d):
18
+ last_conv = layer
19
+ if last_conv is None:
20
+ raise RuntimeError("No convolutional layer found for Grad-CAM.")
21
+ return last_conv
22
 
23
 
24
  def generate_gradcam(
 
26
  image_tensor: torch.Tensor,
27
  target_class: int | None = None,
28
  ) -> str:
29
+ """Generate a PNG overlay encoded as base64."""
 
 
 
 
 
 
 
 
 
 
30
  model.eval()
31
  activations: dict[str, torch.Tensor] = {}
32
  gradients: dict[str, torch.Tensor] = {}
33
 
34
  target_layer = _get_last_conv_layer(model)
35
 
36
+ def forward_hook(_module, _inputs, output):
37
  activations["value"] = output.detach()
38
 
39
+ def backward_hook(_module, _grad_input, grad_output):
40
  gradients["value"] = grad_output[0].detach()
41
 
42
+ forward_handle = target_layer.register_forward_hook(forward_hook)
43
+ backward_handle = target_layer.register_full_backward_hook(backward_hook)
44
 
45
  try:
46
+ input_tensor = image_tensor.clone().detach().requires_grad_(True)
47
+ _, cls_logits = model(input_tensor)
 
48
  if target_class is None:
49
  target_class = int(cls_logits.argmax(dim=1).item())
50
 
51
+ score = cls_logits[:, target_class].sum()
52
+ model.zero_grad(set_to_none=True)
53
  score.backward()
54
 
55
+ activation = activations["value"].squeeze(0)
56
+ gradient = gradients["value"].squeeze(0)
57
+ weights = gradient.mean(dim=(1, 2))
58
+ cam = (weights[:, None, None] * activation).sum(dim=0)
 
59
  cam = F.relu(cam)
60
  cam = cam - cam.min()
61
  cam = cam / (cam.max() + 1e-8)
62
  cam_np = cam.cpu().numpy()
63
 
64
+ height, width = input_tensor.shape[2], input_tensor.shape[3]
65
+ heatmap = cv2.applyColorMap(
66
+ np.uint8(255 * cv2.resize(cam_np, (width, height))),
67
+ cv2.COLORMAP_JET,
68
+ )
69
  heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
70
 
 
71
  mean = np.array([0.485, 0.456, 0.406])[:, None, None]
72
  std = np.array([0.229, 0.224, 0.225])[:, None, None]
73
+ original = input_tensor.squeeze(0).detach().cpu().numpy()
74
+ original = np.clip((original * std + mean) * 255.0, 0, 255).astype(np.uint8)
75
+ original = original.transpose(1, 2, 0)
76
+
77
+ overlay = cv2.addWeighted(original, 0.6, heatmap, 0.4, 0)
78
+ image = Image.fromarray(overlay)
79
+ buffer = io.BytesIO()
80
+ image.save(buffer, format="PNG")
81
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
82
  finally:
83
+ forward_handle.remove()
84
+ backward_handle.remove()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
inference/model_loader.py CHANGED
@@ -1,9 +1,5 @@
1
  # inference/model_loader.py
2
- """
3
- Download and cache model weights from HuggingFace Hub.
4
- Models are loaded once at startup and cached in memory.
5
- """
6
-
7
  import logging
8
  import os
9
 
@@ -15,42 +11,40 @@ from training.models.efficientnet_b4 import AnemiaModel
15
 
16
  log = logging.getLogger(__name__)
17
 
18
- _MODEL_CACHE: dict[str, AnemiaModel] = {}
19
 
20
  HF_REPOS = {
21
  "conjunctiva": os.getenv("HF_CONJ_MODEL_REPO", "hssling/anemia-efficientnet-b4-conjunctiva"),
22
- "nailbed": os.getenv("HF_NAIL_MODEL_REPO", "hssling/anemia-efficientnet-b4-nailbed"),
23
  }
24
- HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")
25
 
26
- DEVICE = torch.device("cpu") # HF Spaces free tier is CPU-only
27
 
28
 
29
  def load_model(site: str) -> AnemiaModel:
30
- """Load and cache model for a given site ('conjunctiva' or 'nailbed')."""
31
  if site in _MODEL_CACHE:
32
  return _MODEL_CACHE[site]
33
 
34
  repo_id = HF_REPOS.get(site)
35
  if repo_id is None:
36
- raise ValueError(f"Unknown site: {site!r}. Must be 'conjunctiva' or 'nailbed'.")
37
 
38
- log.info(f"Downloading model weights from {repo_id} ...")
39
- ckpt_path = hf_hub_download(repo_id=repo_id, filename="model.safetensors", token=HF_TOKEN)
40
  model = AnemiaModel(pretrained=False)
41
  state_dict = load_file(ckpt_path, device="cpu")
42
  model.load_state_dict(state_dict)
43
  model.to(DEVICE)
44
  model.eval()
45
  _MODEL_CACHE[site] = model
46
- log.info(f"Model loaded for site: {site}")
47
  return model
48
 
49
 
50
- def preload_all_models():
51
- """Eagerly load all models at startup to avoid cold-start delays."""
52
  for site in HF_REPOS:
53
  try:
54
  load_model(site)
55
  except Exception as e:
56
- log.warning(f"Could not preload {site} model: {e}")
 
1
  # inference/model_loader.py
2
+ """Download and cache model weights from HuggingFace Hub."""
 
 
 
 
3
  import logging
4
  import os
5
 
 
11
 
12
  log = logging.getLogger(__name__)
13
 
14
+ _MODEL_CACHE: dict = {}
15
 
16
  HF_REPOS = {
17
  "conjunctiva": os.getenv("HF_CONJ_MODEL_REPO", "hssling/anemia-efficientnet-b4-conjunctiva"),
18
+ "nailbed": os.getenv("HF_NAIL_MODEL_REPO", "hssling/anemia-efficientnet-b4-nailbed"),
19
  }
 
20
 
21
+ DEVICE = torch.device("cpu")
22
 
23
 
24
  def load_model(site: str) -> AnemiaModel:
 
25
  if site in _MODEL_CACHE:
26
  return _MODEL_CACHE[site]
27
 
28
  repo_id = HF_REPOS.get(site)
29
  if repo_id is None:
30
+ raise ValueError(f"Unknown site: {site!r}")
31
 
32
+ log.info(f"Downloading {site} model from {repo_id} ...")
33
+ ckpt_path = hf_hub_download(repo_id=repo_id, filename="model.safetensors")
34
  model = AnemiaModel(pretrained=False)
35
  state_dict = load_file(ckpt_path, device="cpu")
36
  model.load_state_dict(state_dict)
37
  model.to(DEVICE)
38
  model.eval()
39
  _MODEL_CACHE[site] = model
40
+ log.info(f"Model loaded: {site}")
41
  return model
42
 
43
 
44
+ def preload_available_models():
45
+ """Load conjunctiva model; skip nailbed if not yet on Hub."""
46
  for site in HF_REPOS:
47
  try:
48
  load_model(site)
49
  except Exception as e:
50
+ log.warning(f"Could not preload {site} model (skipping): {e}")
inference/predict.py CHANGED
@@ -1,70 +1,80 @@
1
- # inference/predict.py
2
- """
3
- Image preprocessing and MC-Dropout inference pipeline.
4
- """
5
 
6
- import logging
7
  from typing import Any
8
 
9
  import numpy as np
10
  import torch
11
  from PIL import Image
12
 
13
- log = logging.getLogger(__name__)
14
-
15
  CLASS_NAMES = ["normal", "mild", "moderate", "severe"]
16
- _IMAGENET_MEAN = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)
17
- _IMAGENET_STD = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
18
 
19
 
20
  def preprocess_image(img: Image.Image, image_size: int = 380) -> torch.Tensor:
21
- """Convert PIL Image to normalised (1, 3, H, W) float tensor."""
22
- img = img.convert("RGB").resize((image_size, image_size), Image.BICUBIC)
23
- arr = np.array(img, dtype=np.float32) / 255.0
24
- tensor = torch.from_numpy(arr).permute(2, 0, 1) # (3, H, W)
25
  tensor = (tensor - _IMAGENET_MEAN) / _IMAGENET_STD
26
- return tensor.unsqueeze(0) # (1, 3, H, W)
 
 
 
 
 
 
27
 
28
 
29
  def mc_dropout_predict(
30
  model: torch.nn.Module,
31
  image_tensor: torch.Tensor,
32
- n_samples: int = 30,
33
  ) -> dict[str, Any]:
34
- """
35
- Run MC Dropout inference.
36
- Activates dropout at inference time for n_samples forward passes.
37
- Returns Hb estimate, 95% CI, class probabilities, and classification label.
38
- """
39
- model.train() # activate dropout
40
- hb_samples = []
41
- cls_samples = []
42
-
43
- try:
44
- with torch.no_grad():
45
- for _ in range(n_samples):
46
- hb_pred, cls_logits = model(image_tensor)
47
- hb_samples.append(hb_pred.item())
48
- cls_samples.append(torch.softmax(cls_logits, dim=1).squeeze().numpy())
49
- finally:
50
- model.eval() # always restore eval mode, even on exception
51
-
52
- hb_arr = np.array(hb_samples)
53
- cls_arr = np.array(cls_samples).mean(axis=0) # (4,)
54
-
55
- hb_mean = float(np.mean(hb_arr))
56
- hb_lo = float(np.percentile(hb_arr, 2.5))
57
- hb_hi = float(np.percentile(hb_arr, 97.5))
58
- pred_class_idx = int(np.argmax(cls_arr))
59
 
60
  return {
61
- "hb_estimate": round(hb_mean, 2),
62
- "hb_ci_95": [round(hb_lo, 2), round(hb_hi, 2)],
63
- "classification": CLASS_NAMES[pred_class_idx],
 
 
 
64
  "class_probabilities": {
65
- name: round(float(cls_arr[i]), 4) for i, name in enumerate(CLASS_NAMES)
 
66
  },
67
- "_hb_samples": hb_arr.tolist(), # kept for ensemble CI computation; stripped before API response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
69
 
70
 
@@ -76,58 +86,67 @@ def run_full_prediction(
76
  w_conj: float = 0.5,
77
  w_nail: float = 0.5,
78
  image_size: int = 380,
79
- n_mc_samples: int = 30,
80
  ) -> dict[str, Any]:
81
- """
82
- Run prediction on available images, ensemble if both present.
83
- Fills 'per_model' field with individual model results.
84
- """
85
- results = {}
86
 
87
  if conj_img is not None and conj_model is not None:
88
- t = preprocess_image(conj_img, image_size)
89
- results["conjunctiva"] = mc_dropout_predict(conj_model, t, n_mc_samples)
90
 
91
  if nail_img is not None and nail_model is not None:
92
- t = preprocess_image(nail_img, image_size)
93
- results["nailbed"] = mc_dropout_predict(nail_model, t, n_mc_samples)
94
-
95
- if not results:
96
- raise ValueError("No model results — ensure at least one image and model are provided.")
97
-
98
- # Ensemble
99
- if "conjunctiva" in results and "nailbed" in results:
100
- cls_probs = {
101
- k: w_conj * results["conjunctiva"]["class_probabilities"][k]
102
- + w_nail * results["nailbed"]["class_probabilities"][k]
103
- for k in CLASS_NAMES
104
- }
105
- best_cls = max(cls_probs, key=cls_probs.get)
106
- # Compute CI from combined weighted MC samples (statistically valid)
107
- samples_c = np.array(results["conjunctiva"]["_hb_samples"])
108
- samples_n = np.array(results["nailbed"]["_hb_samples"])
109
- ensemble_samples = w_conj * samples_c + w_nail * samples_n
110
- hb_mean = float(np.mean(ensemble_samples))
111
- ci_lo = float(np.percentile(ensemble_samples, 2.5))
112
- ci_hi = float(np.percentile(ensemble_samples, 97.5))
113
- ensemble = {
114
- "hb_estimate": round(hb_mean, 2),
115
- "hb_ci_95": [round(ci_lo, 2), round(ci_hi, 2)],
116
- "classification": best_cls,
117
- "class_probabilities": {k: round(v, 4) for k, v in cls_probs.items()},
118
- }
119
- elif "conjunctiva" in results:
120
- ensemble = results["conjunctiva"]
 
 
121
  else:
122
- ensemble = results["nailbed"]
123
-
124
- # Strip internal MC samples before returning — not part of the public API contract
125
- for r in results.values():
126
- r.pop("_hb_samples", None)
 
 
 
 
 
 
127
 
128
  return {
129
- **ensemble,
130
- "per_model": results,
131
- "model_version": "v1.0.0",
 
 
 
 
132
  "disclaimer": "Research tool only. Not a certified diagnostic device. Clinical confirmation required.",
133
  }
 
1
+ """Image preprocessing and MC-dropout inference helpers."""
2
+ from __future__ import annotations
 
 
3
 
 
4
  from typing import Any
5
 
6
  import numpy as np
7
  import torch
8
  from PIL import Image
9
 
 
 
10
  CLASS_NAMES = ["normal", "mild", "moderate", "severe"]
11
+ _IMAGENET_MEAN = torch.tensor([0.485, 0.456, 0.406], dtype=torch.float32).view(3, 1, 1)
12
+ _IMAGENET_STD = torch.tensor([0.229, 0.224, 0.225], dtype=torch.float32).view(3, 1, 1)
13
 
14
 
15
  def preprocess_image(img: Image.Image, image_size: int = 380) -> torch.Tensor:
16
+ """Convert a PIL image to a normalised BCHW tensor."""
17
+ resized = img.convert("RGB").resize((image_size, image_size), Image.BICUBIC)
18
+ array = np.asarray(resized, dtype=np.float32) / 255.0
19
+ tensor = torch.from_numpy(array).permute(2, 0, 1)
20
  tensor = (tensor - _IMAGENET_MEAN) / _IMAGENET_STD
21
+ return tensor.unsqueeze(0)
22
+
23
+
24
+ def _enable_dropout(module: torch.nn.Module) -> None:
25
+ for child in module.modules():
26
+ if isinstance(child, torch.nn.Dropout):
27
+ child.train()
28
 
29
 
30
  def mc_dropout_predict(
31
  model: torch.nn.Module,
32
  image_tensor: torch.Tensor,
33
+ n_samples: int = 20,
34
  ) -> dict[str, Any]:
35
+ """Run repeated stochastic forward passes and aggregate predictions."""
36
+ model.eval()
37
+ _enable_dropout(model)
38
+
39
+ hb_samples: list[float] = []
40
+ cls_samples: list[np.ndarray] = []
41
+
42
+ with torch.no_grad():
43
+ for _ in range(n_samples):
44
+ hb_pred, cls_logits = model(image_tensor)
45
+ hb_samples.append(float(hb_pred.squeeze().item()))
46
+ cls_samples.append(torch.softmax(cls_logits, dim=1).squeeze(0).cpu().numpy())
47
+
48
+ hb_array = np.asarray(hb_samples, dtype=np.float32)
49
+ cls_array = np.asarray(cls_samples, dtype=np.float32).mean(axis=0)
50
+ class_idx = int(np.argmax(cls_array))
 
 
 
 
 
 
 
 
 
51
 
52
  return {
53
+ "hb_estimate": round(float(hb_array.mean()), 2),
54
+ "hb_ci_95": [
55
+ round(float(np.percentile(hb_array, 2.5)), 2),
56
+ round(float(np.percentile(hb_array, 97.5)), 2),
57
+ ],
58
+ "classification": CLASS_NAMES[class_idx],
59
  "class_probabilities": {
60
+ class_name: round(float(cls_array[i]), 4)
61
+ for i, class_name in enumerate(CLASS_NAMES)
62
  },
63
+ }
64
+
65
+
66
+ def _combine_weighted_probabilities(
67
+ first: dict[str, float],
68
+ second: dict[str, float],
69
+ first_weight: float,
70
+ second_weight: float,
71
+ ) -> dict[str, float]:
72
+ return {
73
+ class_name: round(
74
+ first_weight * first[class_name] + second_weight * second[class_name],
75
+ 4,
76
+ )
77
+ for class_name in CLASS_NAMES
78
  }
79
 
80
 
 
86
  w_conj: float = 0.5,
87
  w_nail: float = 0.5,
88
  image_size: int = 380,
89
+ n_mc_samples: int = 20,
90
  ) -> dict[str, Any]:
91
+ """Predict from conjunctiva and/or nail-bed images with optional ensembling."""
92
+ per_model: dict[str, dict[str, Any]] = {}
 
 
 
93
 
94
  if conj_img is not None and conj_model is not None:
95
+ tensor = preprocess_image(conj_img, image_size=image_size)
96
+ per_model["conjunctiva"] = mc_dropout_predict(conj_model, tensor, n_samples=n_mc_samples)
97
 
98
  if nail_img is not None and nail_model is not None:
99
+ tensor = preprocess_image(nail_img, image_size=image_size)
100
+ per_model["nailbed"] = mc_dropout_predict(nail_model, tensor, n_samples=n_mc_samples)
101
+
102
+ if not per_model:
103
+ raise ValueError("No usable model/image pair was provided.")
104
+
105
+ if {"conjunctiva", "nailbed"} <= set(per_model):
106
+ hb_estimate = round(
107
+ w_conj * per_model["conjunctiva"]["hb_estimate"]
108
+ + w_nail * per_model["nailbed"]["hb_estimate"],
109
+ 2,
110
+ )
111
+ hb_ci_95 = [
112
+ round(
113
+ w_conj * per_model["conjunctiva"]["hb_ci_95"][0]
114
+ + w_nail * per_model["nailbed"]["hb_ci_95"][0],
115
+ 2,
116
+ ),
117
+ round(
118
+ w_conj * per_model["conjunctiva"]["hb_ci_95"][1]
119
+ + w_nail * per_model["nailbed"]["hb_ci_95"][1],
120
+ 2,
121
+ ),
122
+ ]
123
+ class_probabilities = _combine_weighted_probabilities(
124
+ per_model["conjunctiva"]["class_probabilities"],
125
+ per_model["nailbed"]["class_probabilities"],
126
+ w_conj,
127
+ w_nail,
128
+ )
129
+ classification = max(class_probabilities, key=class_probabilities.get)
130
  else:
131
+ primary_result = next(iter(per_model.values()))
132
+ hb_estimate = primary_result["hb_estimate"]
133
+ hb_ci_95 = primary_result["hb_ci_95"]
134
+ classification = primary_result["classification"]
135
+ class_probabilities = primary_result["class_probabilities"]
136
+
137
+ warnings: list[str] = []
138
+ if conj_img is not None and conj_model is None:
139
+ warnings.append("Conjunctiva image ignored because the conjunctiva model is unavailable.")
140
+ if nail_img is not None and nail_model is None:
141
+ warnings.append("Nail-bed image ignored because the nail-bed model is unavailable.")
142
 
143
  return {
144
+ "hb_estimate": hb_estimate,
145
+ "hb_ci_95": hb_ci_95,
146
+ "classification": classification,
147
+ "class_probabilities": class_probabilities,
148
+ "per_model": per_model,
149
+ "warnings": warnings,
150
+ "model_version": "v0.4.0",
151
  "disclaimer": "Research tool only. Not a certified diagnostic device. Clinical confirmation required.",
152
  }
inference/requirements.txt CHANGED
@@ -11,3 +11,5 @@ opencv-python-headless>=4.9.0
11
  albumentations>=1.4.0
12
  numpy>=1.26.0
13
  python-multipart>=0.0.9
 
 
 
11
  albumentations>=1.4.0
12
  numpy>=1.26.0
13
  python-multipart>=0.0.9
14
+ scipy>=1.12.0
15
+ scikit-learn>=1.4.0
training/config.yaml CHANGED
@@ -1,9 +1,7 @@
1
  # training/config.yaml
2
- # All hyperparameters and paths. Override via CLI args in train.py.
3
-
4
  data:
5
  hf_dataset_repo: "hssling/anemia-conjunctiva-nailbed"
6
- image_size: 380 # EfficientNet-B4 canonical input
7
  batch_size: 32
8
  num_workers: 4
9
 
@@ -19,7 +17,7 @@ model:
19
  pretrained: true
20
  unfreeze_last_n_blocks: 3
21
  dropout_rate: 0.3
22
- mc_dropout_samples: 30 # for uncertainty (CI95) estimation
23
 
24
  training:
25
  phase1_epochs: 10
@@ -46,4 +44,4 @@ output:
46
 
47
  wandb:
48
  project: "anemiascan"
49
- entity: null # set to your W&B username if needed
 
1
  # training/config.yaml
 
 
2
  data:
3
  hf_dataset_repo: "hssling/anemia-conjunctiva-nailbed"
4
+ image_size: 380
5
  batch_size: 32
6
  num_workers: 4
7
 
 
17
  pretrained: true
18
  unfreeze_last_n_blocks: 3
19
  dropout_rate: 0.3
20
+ mc_dropout_samples: 30
21
 
22
  training:
23
  phase1_epochs: 10
 
44
 
45
  wandb:
46
  project: "anemiascan"
47
+ entity: null
training/cross_validation.py CHANGED
@@ -1,23 +1,11 @@
1
  # training/cross_validation.py
2
- """
3
- 5-fold stratified cross-validation runner.
4
-
5
- CV is used for metric estimation only.
6
- Final model is retrained on full train+val after CV.
7
-
8
- Usage:
9
- python training/cross_validation.py \
10
- --model efficientnet_b4 \
11
- --config training/config.yaml \
12
- --output-dir outputs/cv/
13
- """
14
-
15
- import argparse
16
  import json
17
  import logging
18
  import pathlib
19
 
20
  import numpy as np
 
21
  from sklearn.model_selection import StratifiedKFold
22
 
23
  from training.train import load_config, train_model
@@ -26,33 +14,21 @@ log = logging.getLogger(__name__)
26
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
27
 
28
 
29
- def run_cross_validation(
30
- rows: list,
31
- model_name: str,
32
- config: dict,
33
- output_dir: pathlib.Path,
34
- ) -> dict:
35
- """
36
- Run 5-fold stratified CV. Returns dict with mean +/- std of each metric.
37
- """
38
  n_folds = config["training"]["n_folds"]
39
- fold_metrics = []
40
-
41
  strat_labels = [r["anemia_class"] for r in rows]
42
  skf = StratifiedKFold(
43
  n_splits=n_folds, shuffle=True, random_state=config["training"]["random_seed"]
44
  )
45
-
46
  for fold, (train_idx, val_idx) in enumerate(skf.split(rows, strat_labels)):
47
  log.info(f"=== Fold {fold + 1}/{n_folds} ===")
48
- train_rows = [rows[i] for i in train_idx]
49
- val_rows = [rows[i] for i in val_idx]
50
- fold_out = output_dir / f"fold_{fold}"
51
  fold_out.mkdir(parents=True, exist_ok=True)
52
  metrics = train_model(
53
  model_name=model_name,
54
- train_rows=train_rows,
55
- val_rows=val_rows,
56
  config=config,
57
  output_dir=fold_out,
58
  fold=fold,
@@ -60,36 +36,15 @@ def run_cross_validation(
60
  )
61
  fold_metrics.append(metrics)
62
 
63
- all_keys = fold_metrics[0].keys()
64
- summary = {}
65
- for key in all_keys:
66
- vals = [m[key] for m in fold_metrics if isinstance(m.get(key), int | float)]
67
  if vals:
68
  summary[f"{key}_mean"] = float(np.mean(vals))
69
  summary[f"{key}_std"] = float(np.std(vals))
70
 
71
- summary["n_folds"] = n_folds
72
- summary["model"] = model_name
73
- out_path = output_dir / f"{model_name}_cv_summary.json"
74
  with open(out_path, "w") as f:
75
  json.dump(summary, f, indent=2)
76
- log.info(
77
- f"CV summary: MAE={summary.get('mae_mean', '?'):.3f} +/- {summary.get('mae_std', '?'):.3f}"
78
- )
79
  return summary
80
-
81
-
82
- def main():
83
- parser = argparse.ArgumentParser()
84
- parser.add_argument("--model", required=True)
85
- parser.add_argument("--config", default="training/config.yaml", type=pathlib.Path)
86
- parser.add_argument("--output-dir", default="outputs/cv", type=pathlib.Path)
87
- args = parser.parse_args()
88
-
89
- load_config(args.config)
90
- log.info(f"Cross-validation for {args.model}")
91
- log.info("Load your dataset rows and call run_cross_validation(rows, ...)")
92
-
93
-
94
- if __name__ == "__main__":
95
- main()
 
1
  # training/cross_validation.py
2
+ """5-fold stratified cross-validation runner."""
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import json
4
  import logging
5
  import pathlib
6
 
7
  import numpy as np
8
+ import yaml
9
  from sklearn.model_selection import StratifiedKFold
10
 
11
  from training.train import load_config, train_model
 
14
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
15
 
16
 
17
+ def run_cross_validation(rows, model_name, config, output_dir):
 
 
 
 
 
 
 
 
18
  n_folds = config["training"]["n_folds"]
 
 
19
  strat_labels = [r["anemia_class"] for r in rows]
20
  skf = StratifiedKFold(
21
  n_splits=n_folds, shuffle=True, random_state=config["training"]["random_seed"]
22
  )
23
+ fold_metrics = []
24
  for fold, (train_idx, val_idx) in enumerate(skf.split(rows, strat_labels)):
25
  log.info(f"=== Fold {fold + 1}/{n_folds} ===")
26
+ fold_out = pathlib.Path(output_dir) / f"fold_{fold}"
 
 
27
  fold_out.mkdir(parents=True, exist_ok=True)
28
  metrics = train_model(
29
  model_name=model_name,
30
+ train_rows=[rows[i] for i in train_idx],
31
+ val_rows=[rows[i] for i in val_idx],
32
  config=config,
33
  output_dir=fold_out,
34
  fold=fold,
 
36
  )
37
  fold_metrics.append(metrics)
38
 
39
+ summary = {"n_folds": n_folds, "model": model_name}
40
+ for key in fold_metrics[0].keys():
41
+ vals = [m[key] for m in fold_metrics if isinstance(m.get(key), (int, float))]
 
42
  if vals:
43
  summary[f"{key}_mean"] = float(np.mean(vals))
44
  summary[f"{key}_std"] = float(np.std(vals))
45
 
46
+ out_path = pathlib.Path(output_dir) / f"{model_name}_cv_summary.json"
 
 
47
  with open(out_path, "w") as f:
48
  json.dump(summary, f, indent=2)
49
+ log.info(f"CV MAE={summary.get('mae_mean', '?'):.3f} ± {summary.get('mae_std', '?'):.3f}")
 
 
50
  return summary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/evaluation/metrics.py CHANGED
@@ -1,13 +1,11 @@
1
  # training/evaluation/metrics.py
2
  """Evaluation metrics for hemoglobin regression and anemia classification."""
3
-
4
  import numpy as np
5
  from scipy import stats
6
  from sklearn.metrics import confusion_matrix, f1_score, roc_auc_score
7
 
8
 
9
  def compute_regression_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
10
- """MAE, RMSE, Pearson r for Hb regression."""
11
  mae = float(np.mean(np.abs(y_true - y_pred)))
12
  rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
13
  r, p_val = stats.pearsonr(y_true, y_pred)
@@ -15,25 +13,21 @@ def compute_regression_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
15
 
16
 
17
  def compute_classification_metrics(y_true: np.ndarray, y_pred_proba: np.ndarray) -> dict:
18
- """AUC, F1, sensitivity, specificity, confusion matrix for 4-class anemia."""
19
  y_pred = np.argmax(y_pred_proba, axis=1)
20
  cm = confusion_matrix(y_true, y_pred, labels=[0, 1, 2, 3])
21
-
22
  per_class_sens = {}
23
  per_class_spec = {}
24
- for cls in range(4):
25
- tp = cm[cls, cls]
26
- fn = cm[cls, :].sum() - tp
27
- fp = cm[:, cls].sum() - tp
28
  tn = cm.sum() - tp - fn - fp
29
- per_class_sens[cls] = tp / (tp + fn) if (tp + fn) > 0 else 0.0
30
- per_class_spec[cls] = tn / (tn + fp) if (tn + fp) > 0 else 0.0
31
-
32
  try:
33
  auc_macro = float(roc_auc_score(y_true, y_pred_proba, multi_class="ovr", average="macro"))
34
  except ValueError:
35
  auc_macro = float("nan")
36
-
37
  return {
38
  "auc_macro": auc_macro,
39
  "f1_macro": float(f1_score(y_true, y_pred, average="macro", zero_division=0)),
@@ -44,7 +38,6 @@ def compute_classification_metrics(y_true: np.ndarray, y_pred_proba: np.ndarray)
44
 
45
 
46
  def bland_altman_stats(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
47
- """Bland-Altman agreement statistics."""
48
  diff = y_true - y_pred
49
  mean_diff = float(np.mean(diff))
50
  std_diff = float(np.std(diff, ddof=1))
 
1
  # training/evaluation/metrics.py
2
  """Evaluation metrics for hemoglobin regression and anemia classification."""
 
3
  import numpy as np
4
  from scipy import stats
5
  from sklearn.metrics import confusion_matrix, f1_score, roc_auc_score
6
 
7
 
8
  def compute_regression_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
 
9
  mae = float(np.mean(np.abs(y_true - y_pred)))
10
  rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
11
  r, p_val = stats.pearsonr(y_true, y_pred)
 
13
 
14
 
15
  def compute_classification_metrics(y_true: np.ndarray, y_pred_proba: np.ndarray) -> dict:
 
16
  y_pred = np.argmax(y_pred_proba, axis=1)
17
  cm = confusion_matrix(y_true, y_pred, labels=[0, 1, 2, 3])
 
18
  per_class_sens = {}
19
  per_class_spec = {}
20
+ for c in range(4):
21
+ tp = cm[c, c]
22
+ fn = cm[c, :].sum() - tp
23
+ fp = cm[:, c].sum() - tp
24
  tn = cm.sum() - tp - fn - fp
25
+ per_class_sens[c] = tp / (tp + fn) if (tp + fn) > 0 else 0.0
26
+ per_class_spec[c] = tn / (tn + fp) if (tn + fp) > 0 else 0.0
 
27
  try:
28
  auc_macro = float(roc_auc_score(y_true, y_pred_proba, multi_class="ovr", average="macro"))
29
  except ValueError:
30
  auc_macro = float("nan")
 
31
  return {
32
  "auc_macro": auc_macro,
33
  "f1_macro": float(f1_score(y_true, y_pred, average="macro", zero_division=0)),
 
38
 
39
 
40
  def bland_altman_stats(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
 
41
  diff = y_true - y_pred
42
  mean_diff = float(np.mean(diff))
43
  std_diff = float(np.std(diff, ddof=1))
training/models/convnext_tiny.py CHANGED
@@ -1,8 +1,6 @@
1
  # training/models/convnext_tiny.py
2
  """ConvNeXt-Tiny dual-head model."""
3
-
4
  import timm
5
- import torch
6
  import torch.nn as nn
7
 
8
 
@@ -17,13 +15,10 @@ class AnemiaModel(nn.Module):
17
  nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
18
  )
19
  self.classification_head = nn.Sequential(
20
- nn.Linear(feature_dim, 256),
21
- nn.ReLU(),
22
- nn.Dropout(dropout_rate),
23
- nn.Linear(256, num_classes),
24
  )
25
 
26
- def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
27
  f = self.backbone(x)
28
  return self.regression_head(f), self.classification_head(f)
29
 
@@ -32,7 +27,6 @@ class AnemiaModel(nn.Module):
32
  p.requires_grad = False
33
 
34
  def unfreeze_last_n_blocks(self, n: int = 3):
35
- stages = list(self.backbone.stages)
36
- for stage in stages[-n:]:
37
  for p in stage.parameters():
38
  p.requires_grad = True
 
1
  # training/models/convnext_tiny.py
2
  """ConvNeXt-Tiny dual-head model."""
 
3
  import timm
 
4
  import torch.nn as nn
5
 
6
 
 
15
  nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
16
  )
17
  self.classification_head = nn.Sequential(
18
+ nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, num_classes)
 
 
 
19
  )
20
 
21
+ def forward(self, x):
22
  f = self.backbone(x)
23
  return self.regression_head(f), self.classification_head(f)
24
 
 
27
  p.requires_grad = False
28
 
29
  def unfreeze_last_n_blocks(self, n: int = 3):
30
+ for stage in list(self.backbone.stages)[-n:]:
 
31
  for p in stage.parameters():
32
  p.requires_grad = True
training/models/efficientnet_b4.py CHANGED
@@ -1,63 +1,36 @@
1
  # training/models/efficientnet_b4.py
2
  """EfficientNet-B4 dual-head model for hemoglobin regression + anemia classification."""
3
-
4
  import timm
5
  import torch
6
  import torch.nn as nn
7
 
8
 
9
  class AnemiaModel(nn.Module):
10
- """
11
- EfficientNet-B4 backbone with dual prediction heads:
12
- - Regression head: predicts Hb (g/dL)
13
- - Classification head: predicts 4-class anemia severity
14
- """
15
-
16
- def __init__(
17
- self,
18
- num_classes: int = 4,
19
- dropout_rate: float = 0.3,
20
- pretrained: bool = True,
21
- ):
22
  super().__init__()
23
  self.backbone = timm.create_model(
24
- "efficientnet_b4",
25
- pretrained=pretrained,
26
- num_classes=0, # remove classifier head
27
- global_pool="avg",
28
  )
29
  feature_dim = self.backbone.num_features
30
-
31
  self.regression_head = nn.Sequential(
32
- nn.Linear(feature_dim, 256),
33
- nn.ReLU(),
34
- nn.Dropout(dropout_rate),
35
- nn.Linear(256, 1),
36
  )
37
  self.classification_head = nn.Sequential(
38
- nn.Linear(feature_dim, 256),
39
- nn.ReLU(),
40
- nn.Dropout(dropout_rate),
41
- nn.Linear(256, num_classes),
42
  )
43
 
44
  def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
45
  features = self.backbone(x)
46
- hb_pred = self.regression_head(features)
47
- class_logits = self.classification_head(features)
48
- return hb_pred, class_logits
49
 
50
  def freeze_backbone(self):
51
  for param in self.backbone.parameters():
52
  param.requires_grad = False
53
 
54
  def unfreeze_last_n_blocks(self, n: int = 3):
55
- """Unfreeze last n blocks of the backbone for fine-tuning."""
56
- blocks = list(self.backbone.blocks)
57
- for block in blocks[-n:]:
58
  for param in block.parameters():
59
  param.requires_grad = True
60
- # Always unfreeze the final conv + bn
61
  for param in self.backbone.conv_head.parameters():
62
  param.requires_grad = True
63
  for param in self.backbone.bn2.parameters():
 
1
  # training/models/efficientnet_b4.py
2
  """EfficientNet-B4 dual-head model for hemoglobin regression + anemia classification."""
 
3
  import timm
4
  import torch
5
  import torch.nn as nn
6
 
7
 
8
  class AnemiaModel(nn.Module):
9
+ def __init__(self, num_classes: int = 4, dropout_rate: float = 0.3, pretrained: bool = True):
 
 
 
 
 
 
 
 
 
 
 
10
  super().__init__()
11
  self.backbone = timm.create_model(
12
+ "efficientnet_b4", pretrained=pretrained, num_classes=0, global_pool="avg"
 
 
 
13
  )
14
  feature_dim = self.backbone.num_features
 
15
  self.regression_head = nn.Sequential(
16
+ nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
 
 
 
17
  )
18
  self.classification_head = nn.Sequential(
19
+ nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, num_classes)
 
 
 
20
  )
21
 
22
  def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
23
  features = self.backbone(x)
24
+ return self.regression_head(features), self.classification_head(features)
 
 
25
 
26
  def freeze_backbone(self):
27
  for param in self.backbone.parameters():
28
  param.requires_grad = False
29
 
30
  def unfreeze_last_n_blocks(self, n: int = 3):
31
+ for block in list(self.backbone.blocks)[-n:]:
 
 
32
  for param in block.parameters():
33
  param.requires_grad = True
 
34
  for param in self.backbone.conv_head.parameters():
35
  param.requires_grad = True
36
  for param in self.backbone.bn2.parameters():
training/models/efficientnetv2_s.py CHANGED
@@ -1,8 +1,6 @@
1
  # training/models/efficientnetv2_s.py
2
  """EfficientNetV2-S dual-head model."""
3
-
4
  import timm
5
- import torch
6
  import torch.nn as nn
7
 
8
 
@@ -17,13 +15,10 @@ class AnemiaModel(nn.Module):
17
  nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
18
  )
19
  self.classification_head = nn.Sequential(
20
- nn.Linear(feature_dim, 256),
21
- nn.ReLU(),
22
- nn.Dropout(dropout_rate),
23
- nn.Linear(256, num_classes),
24
  )
25
 
26
- def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
27
  f = self.backbone(x)
28
  return self.regression_head(f), self.classification_head(f)
29
 
@@ -32,11 +27,9 @@ class AnemiaModel(nn.Module):
32
  p.requires_grad = False
33
 
34
  def unfreeze_last_n_blocks(self, n: int = 3):
35
- blocks = list(self.backbone.blocks)
36
- for block in blocks[-n:]:
37
  for p in block.parameters():
38
  p.requires_grad = True
39
- # Also unfreeze final conv + bn for consistent gradient flow with B4
40
  if hasattr(self.backbone, "conv_head"):
41
  for p in self.backbone.conv_head.parameters():
42
  p.requires_grad = True
 
1
  # training/models/efficientnetv2_s.py
2
  """EfficientNetV2-S dual-head model."""
 
3
  import timm
 
4
  import torch.nn as nn
5
 
6
 
 
15
  nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
16
  )
17
  self.classification_head = nn.Sequential(
18
+ nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, num_classes)
 
 
 
19
  )
20
 
21
+ def forward(self, x):
22
  f = self.backbone(x)
23
  return self.regression_head(f), self.classification_head(f)
24
 
 
27
  p.requires_grad = False
28
 
29
  def unfreeze_last_n_blocks(self, n: int = 3):
30
+ for block in list(self.backbone.blocks)[-n:]:
 
31
  for p in block.parameters():
32
  p.requires_grad = True
 
33
  if hasattr(self.backbone, "conv_head"):
34
  for p in self.backbone.conv_head.parameters():
35
  p.requires_grad = True
training/models/ensemble.py CHANGED
@@ -1,12 +1,6 @@
1
  # training/models/ensemble.py
2
- """
3
- Late-fusion dual-site ensemble.
4
-
5
- Loads a conjunctiva model and a nail-bed model.
6
- Combines predictions with learned weights (optimised on val set).
7
- Falls back gracefully if only one site image is provided.
8
- """
9
-
10
  import torch
11
  import torch.nn as nn
12
  from safetensors.torch import load_file
@@ -15,13 +9,7 @@ from training.models.efficientnet_b4 import AnemiaModel
15
 
16
 
17
  class AnemiaEnsemble(nn.Module):
18
- def __init__(
19
- self,
20
- conj_ckpt: str,
21
- nail_ckpt: str,
22
- w_conj: float = 0.5,
23
- w_nail: float = 0.5,
24
- ):
25
  super().__init__()
26
  self.conj_model = AnemiaModel(pretrained=False)
27
  self.nail_model = AnemiaModel(pretrained=False)
@@ -30,46 +18,25 @@ class AnemiaEnsemble(nn.Module):
30
  self.w_conj = w_conj
31
  self.w_nail = w_nail
32
 
33
- def forward(
34
- self,
35
- conj_img: torch.Tensor | None = None,
36
- nail_img: torch.Tensor | None = None,
37
- ) -> tuple[torch.Tensor, torch.Tensor]:
38
  if conj_img is not None and nail_img is not None:
39
  hb_c, cls_c = self.conj_model(conj_img)
40
  hb_n, cls_n = self.nail_model(nail_img)
41
- hb = self.w_conj * hb_c + self.w_nail * hb_n
42
- cls = self.w_conj * cls_c + self.w_nail * cls_n
43
  elif conj_img is not None:
44
- hb, cls = self.conj_model(conj_img)
45
  elif nail_img is not None:
46
- hb, cls = self.nail_model(nail_img)
47
- else:
48
- raise ValueError("At least one image (conjunctiva or nail-bed) must be provided")
49
- return hb, cls
50
 
51
  @classmethod
52
- def find_best_weights(
53
- cls,
54
- conj_ckpt: str,
55
- nail_ckpt: str,
56
- val_rows_conj: list,
57
- val_rows_nail: list,
58
- config: dict,
59
- ) -> tuple[float, float]:
60
- """Grid search over ensemble weights on validation set. Returns (w_conj, w_nail).
61
-
62
- IMPORTANT: val_rows_conj and val_rows_nail must be from the same patients
63
- in the same order. The ensemble MAE is evaluated against conjunctiva ground-truth
64
- (trues_c). Only valid when both sets cover the same patient population.
65
- """
66
  if len(val_rows_conj) != len(val_rows_nail):
67
  raise ValueError(
68
- f"val_rows_conj ({len(val_rows_conj)}) and val_rows_nail "
69
- f"({len(val_rows_nail)}) must have the same length for ensemble "
70
- "weight grid search. Ensure both cover the same patients."
71
  )
72
- import numpy as np
73
  from torch.utils.data import DataLoader
74
 
75
  from training.evaluation.metrics import compute_regression_metrics
@@ -101,10 +68,7 @@ class AnemiaEnsemble(nn.Module):
101
 
102
  best_mae, best_wc = float("inf"), 0.5
103
  for wc in np.arange(0.0, 1.05, 0.05):
104
- wn = 1.0 - wc
105
- ensemble_preds = wc * preds_c + wn * preds_n
106
- mae = compute_regression_metrics(trues_c, ensemble_preds)["mae"]
107
  if mae < best_mae:
108
- best_mae, best_wc = mae, wc
109
-
110
- return float(best_wc), float(1.0 - best_wc)
 
1
  # training/models/ensemble.py
2
+ """Late-fusion dual-site ensemble."""
3
+ import numpy as np
 
 
 
 
 
 
4
  import torch
5
  import torch.nn as nn
6
  from safetensors.torch import load_file
 
9
 
10
 
11
  class AnemiaEnsemble(nn.Module):
12
+ def __init__(self, conj_ckpt: str, nail_ckpt: str, w_conj: float = 0.5, w_nail: float = 0.5):
 
 
 
 
 
 
13
  super().__init__()
14
  self.conj_model = AnemiaModel(pretrained=False)
15
  self.nail_model = AnemiaModel(pretrained=False)
 
18
  self.w_conj = w_conj
19
  self.w_nail = w_nail
20
 
21
+ def forward(self, conj_img=None, nail_img=None):
 
 
 
 
22
  if conj_img is not None and nail_img is not None:
23
  hb_c, cls_c = self.conj_model(conj_img)
24
  hb_n, cls_n = self.nail_model(nail_img)
25
+ return self.w_conj * hb_c + self.w_nail * hb_n, self.w_conj * cls_c + self.w_nail * cls_n
 
26
  elif conj_img is not None:
27
+ return self.conj_model(conj_img)
28
  elif nail_img is not None:
29
+ return self.nail_model(nail_img)
30
+ raise ValueError("At least one image must be provided")
 
 
31
 
32
  @classmethod
33
+ def find_best_weights(cls, conj_ckpt, nail_ckpt, val_rows_conj, val_rows_nail, config):
34
+ """Grid search over ensemble weights. Returns (w_conj, w_nail)."""
 
 
 
 
 
 
 
 
 
 
 
 
35
  if len(val_rows_conj) != len(val_rows_nail):
36
  raise ValueError(
37
+ f"val_rows_conj ({len(val_rows_conj)}) and val_rows_nail ({len(val_rows_nail)}) "
38
+ "must have the same length for ensemble weight optimisation."
 
39
  )
 
40
  from torch.utils.data import DataLoader
41
 
42
  from training.evaluation.metrics import compute_regression_metrics
 
68
 
69
  best_mae, best_wc = float("inf"), 0.5
70
  for wc in np.arange(0.0, 1.05, 0.05):
71
+ mae = compute_regression_metrics(trues_c, wc * preds_c + (1.0 - wc) * preds_n)["mae"]
 
 
72
  if mae < best_mae:
73
+ best_mae, best_wc = mae, float(wc)
74
+ return best_wc, 1.0 - best_wc
 
training/push_model_to_hf.py CHANGED
@@ -1,6 +1,5 @@
1
  # training/push_model_to_hf.py
2
  """Push trained model weights and metrics to HuggingFace Hub."""
3
-
4
  import json
5
  import logging
6
  import pathlib
@@ -13,16 +12,7 @@ log = logging.getLogger(__name__)
13
  api = HfApi()
14
 
15
 
16
- def push_model(
17
- ckpt_path: str,
18
- repo_id: str,
19
- metrics: dict,
20
- model_name: str,
21
- site: str,
22
- config: dict,
23
- version: str = "v1.0.0",
24
- ):
25
- """Push a single model checkpoint + metrics to HF Hub."""
26
  with tempfile.TemporaryDirectory() as tmpdir:
27
  tmp = pathlib.Path(tmpdir)
28
  shutil.copy(ckpt_path, tmp / "model.safetensors")
@@ -34,33 +24,16 @@ tags:
34
  - medical-imaging
35
  - anemia
36
  - hemoglobin-estimation
37
- - image-classification
38
  pipeline_tag: image-classification
39
  ---
40
 
41
- # AnemiaScan -- {model_name} ({site})
42
 
43
  **Task:** Non-invasive hemoglobin estimation + anemia severity classification from {site} images.
44
 
45
- **Architecture:** {model_name} (ImageNet pretrained, fine-tuned)
46
-
47
- **Input:** 380x380 RGB image of the palpebral {site}
48
-
49
- **Outputs:**
50
- - `hb_estimate` (float, g/dL)
51
- - `classification` (str: normal / mild / moderate / severe)
52
-
53
- ## Performance (5-fold CV on public datasets)
54
-
55
- | Metric | Mean +/- Std |
56
- |--------|-----------|
57
- | MAE (g/dL) | {metrics.get("mae_mean", "TBD")} |
58
- | Pearson r | {metrics.get("pearson_r_mean", "TBD")} |
59
- | AUC (macro) | {metrics.get("auc_mean", "TBD")} |
60
 
61
- ## Disclaimer
62
-
63
- **Research tool only. Not a certified diagnostic device. All results require clinical confirmation.**
64
  """
65
  (tmp / "README.md").write_text(card)
66
  api.upload_folder(
@@ -73,13 +46,7 @@ pipeline_tag: image-classification
73
 
74
 
75
  def push_all_models(
76
- conj_ckpt: str,
77
- nail_ckpt: str,
78
- cv_summary_conj: dict,
79
- cv_summary_nail: dict,
80
- w_conj: float,
81
- w_nail: float,
82
- config: dict,
83
  ):
84
  push_model(
85
  conj_ckpt,
@@ -89,21 +56,20 @@ def push_all_models(
89
  "conjunctiva",
90
  config,
91
  )
92
- push_model(
93
- nail_ckpt,
94
- "hssling/anemia-efficientnet-b4-nailbed",
95
- cv_summary_nail,
96
- "efficientnet_b4",
97
- "nailbed",
98
- config,
99
- )
 
100
  ensemble_meta = {
101
  "conj_model": "hssling/anemia-efficientnet-b4-conjunctiva",
102
- "nail_model": "hssling/anemia-efficientnet-b4-nailbed",
103
  "w_conj": w_conj,
104
  "w_nail": w_nail,
105
- "mae_mean": w_conj * cv_summary_conj.get("mae_mean", 0)
106
- + w_nail * cv_summary_nail.get("mae_mean", 0),
107
  }
108
  api.upload_file(
109
  path_or_fileobj=json.dumps(ensemble_meta, indent=2).encode(),
 
1
  # training/push_model_to_hf.py
2
  """Push trained model weights and metrics to HuggingFace Hub."""
 
3
  import json
4
  import logging
5
  import pathlib
 
12
  api = HfApi()
13
 
14
 
15
+ def push_model(ckpt_path, repo_id, metrics, model_name, site, config, version="v1.0.0"):
 
 
 
 
 
 
 
 
 
16
  with tempfile.TemporaryDirectory() as tmpdir:
17
  tmp = pathlib.Path(tmpdir)
18
  shutil.copy(ckpt_path, tmp / "model.safetensors")
 
24
  - medical-imaging
25
  - anemia
26
  - hemoglobin-estimation
 
27
  pipeline_tag: image-classification
28
  ---
29
 
30
+ # AnemiaScan {model_name} ({site})
31
 
32
  **Task:** Non-invasive hemoglobin estimation + anemia severity classification from {site} images.
33
 
34
+ **Architecture:** {model_name} (ImageNet pretrained, fine-tuned on 380×380 RGB images)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ **Disclaimer:** Research tool only. Not a certified diagnostic device. Clinical confirmation required.
 
 
37
  """
38
  (tmp / "README.md").write_text(card)
39
  api.upload_folder(
 
46
 
47
 
48
  def push_all_models(
49
+ conj_ckpt, nail_ckpt, cv_summary_conj, cv_summary_nail, w_conj, w_nail, config
 
 
 
 
 
 
50
  ):
51
  push_model(
52
  conj_ckpt,
 
56
  "conjunctiva",
57
  config,
58
  )
59
+ if nail_ckpt is not None:
60
+ push_model(
61
+ nail_ckpt,
62
+ "hssling/anemia-efficientnet-b4-nailbed",
63
+ cv_summary_nail,
64
+ "efficientnet_b4",
65
+ "nailbed",
66
+ config,
67
+ )
68
  ensemble_meta = {
69
  "conj_model": "hssling/anemia-efficientnet-b4-conjunctiva",
70
+ "nail_model": "hssling/anemia-efficientnet-b4-nailbed" if nail_ckpt else None,
71
  "w_conj": w_conj,
72
  "w_nail": w_nail,
 
 
73
  }
74
  api.upload_file(
75
  path_or_fileobj=json.dumps(ensemble_meta, indent=2).encode(),
training/train.py CHANGED
@@ -1,15 +1,5 @@
1
  # training/train.py
2
- """
3
- Core training loop: two-phase training (head warmup -> backbone fine-tune).
4
-
5
- Usage:
6
- python training/train.py \
7
- --model efficientnet_b4 \
8
- --site conjunctiva \
9
- --config training/config.yaml \
10
- --output-dir outputs/
11
- """
12
-
13
  import argparse
14
  import importlib
15
  import json
@@ -23,10 +13,7 @@ import wandb
23
  import yaml
24
  from torch.utils.data import DataLoader
25
 
26
- from training.evaluation.metrics import (
27
- compute_classification_metrics,
28
- compute_regression_metrics,
29
- )
30
  from training.utils.dataset import AnemiaDataset
31
 
32
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
@@ -40,21 +27,11 @@ def load_config(path: pathlib.Path) -> dict:
40
 
41
  def get_model(model_name: str, config: dict) -> nn.Module:
42
  mod = importlib.import_module(f"training.models.{model_name}")
43
- return mod.AnemiaModel(
44
- dropout_rate=config["model"]["dropout_rate"],
45
- pretrained=True,
46
- )
47
 
48
 
49
- def multitask_loss(
50
- hb_pred: torch.Tensor,
51
- hb_true: torch.Tensor,
52
- class_logits: torch.Tensor,
53
- class_true: torch.Tensor,
54
- w_reg: float = 0.7,
55
- w_cls: float = 0.3,
56
- ) -> torch.Tensor:
57
- mse = nn.functional.mse_loss(hb_pred.squeeze(), hb_true.float())
58
  ce = nn.functional.cross_entropy(class_logits, class_true.long())
59
  return w_reg * mse + w_cls * ce
60
 
@@ -77,10 +54,10 @@ def run_epoch(model, loader, optimizer, device, training: bool, config: dict):
77
  loss.backward()
78
  optimizer.step()
79
  total_loss += loss.item()
80
- hb_preds.extend(hb_pred.squeeze(1).cpu().numpy().tolist())
81
- hb_trues.extend(hb.cpu().numpy().tolist())
82
- cls_preds.extend(torch.softmax(cls_logits, dim=1).cpu().numpy().tolist())
83
- cls_trues.extend(cls.cpu().numpy().tolist())
84
 
85
  reg_metrics = compute_regression_metrics(np.array(hb_trues), np.array(hb_preds))
86
  cls_metrics = compute_classification_metrics(np.array(cls_trues), np.array(cls_preds))
@@ -92,31 +69,27 @@ def run_epoch(model, loader, optimizer, device, training: bool, config: dict):
92
  }
93
 
94
 
95
- def train_model(
96
- model_name: str,
97
- train_rows: list,
98
- val_rows: list,
99
- config: dict,
100
- output_dir: pathlib.Path,
101
- fold: int = 0,
102
- run_name: str = "",
103
- ) -> dict:
104
- """Full two-phase training. Returns best val metrics dict."""
105
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
106
  log.info(f"Training {model_name} fold={fold} on {device}")
107
 
108
  img_size = config["data"]["image_size"]
109
- train_ds = AnemiaDataset(train_rows, image_size=img_size, augment=True)
110
- val_ds = AnemiaDataset(val_rows, image_size=img_size, augment=False)
111
  train_loader = DataLoader(
112
- train_ds,
113
  batch_size=config["data"]["batch_size"],
114
  shuffle=True,
115
  num_workers=config["data"]["num_workers"],
116
  pin_memory=True,
117
  )
118
  val_loader = DataLoader(
119
- val_ds,
120
  batch_size=config["data"]["batch_size"],
121
  shuffle=False,
122
  num_workers=config["data"]["num_workers"],
@@ -124,7 +97,6 @@ def train_model(
124
  )
125
 
126
  model = get_model(model_name, config).to(device)
127
-
128
  wandb_run = wandb.init(
129
  project=config["wandb"]["project"],
130
  name=run_name or f"{model_name}_fold{fold}",
@@ -132,14 +104,13 @@ def train_model(
132
  reinit=True,
133
  )
134
 
135
- # Phase 1: freeze backbone, train heads
136
  model.freeze_backbone()
137
  optimizer = torch.optim.AdamW(
138
  filter(lambda p: p.requires_grad, model.parameters()),
139
  lr=config["training"]["phase1_lr"],
140
  weight_decay=config["training"]["weight_decay"],
141
  )
142
- log.info("Phase 1: training heads only")
143
  for epoch in range(config["training"]["phase1_epochs"]):
144
  train_m = run_epoch(model, train_loader, optimizer, device, training=True, config=config)
145
  val_m = run_epoch(model, val_loader, optimizer, device, training=False, config=config)
@@ -150,11 +121,9 @@ def train_model(
150
  **{f"val/{k}": v for k, v in val_m.items()},
151
  }
152
  )
153
- log.info(
154
- f" Phase1 Ep{epoch + 1}: train_mae={train_m['mae']:.3f} val_mae={val_m['mae']:.3f}"
155
- )
156
 
157
- # Phase 2: unfreeze last 3 blocks
158
  arch_cfg = next(
159
  (a for a in config["model"]["architectures"] if a["name"] == model_name),
160
  config["model"]["architectures"][0],
@@ -172,9 +141,8 @@ def train_model(
172
  best_val_mae = float("inf")
173
  patience_count = 0
174
  best_metrics = {}
175
- best_ckpt_path = output_dir / f"{model_name}_fold{fold}_best.safetensors"
176
 
177
- log.info("Phase 2: fine-tuning last 3 blocks")
178
  for epoch in range(config["training"]["phase2_epochs"]):
179
  train_m = run_epoch(model, train_loader, optimizer, device, training=True, config=config)
180
  val_m = run_epoch(model, val_loader, optimizer, device, training=False, config=config)
@@ -186,7 +154,7 @@ def train_model(
186
  **{f"val/{k}": v for k, v in val_m.items()},
187
  }
188
  )
189
- log.info(f" Phase2 Ep{epoch + 1}: val_mae={val_m['mae']:.3f} val_auc={val_m['auc']:.3f}")
190
 
191
  if val_m["mae"] < best_val_mae:
192
  best_val_mae = val_m["mae"]
@@ -196,34 +164,11 @@ def train_model(
196
  else:
197
  patience_count += 1
198
  if patience_count >= config["training"]["early_stopping_patience"]:
199
- log.info(f" Early stopping at epoch {epoch + 1}")
200
  break
201
 
202
  wandb_run.finish()
203
- metrics_path = output_dir / f"{model_name}_fold{fold}_metrics.json"
204
  with open(metrics_path, "w") as f:
205
  json.dump(best_metrics, f, indent=2)
206
- log.info(f"Best val MAE: {best_val_mae:.3f} -- saved to {best_ckpt_path}")
207
  return best_metrics
208
-
209
-
210
- def _save_safetensors(model: nn.Module, path: pathlib.Path):
211
- from safetensors.torch import save_file
212
-
213
- path.parent.mkdir(parents=True, exist_ok=True)
214
- save_file({k: v.contiguous() for k, v in model.state_dict().items()}, str(path))
215
-
216
-
217
- def main():
218
- parser = argparse.ArgumentParser()
219
- parser.add_argument("--model", default="efficientnet_b4")
220
- parser.add_argument("--config", default="training/config.yaml", type=pathlib.Path)
221
- parser.add_argument("--output-dir", default="outputs/", type=pathlib.Path)
222
- args = parser.parse_args()
223
- load_config(args.config)
224
- log.info(f"Config loaded: {args.config}")
225
- log.info("Pass train_rows and val_rows to train_model() to start training.")
226
-
227
-
228
- if __name__ == "__main__":
229
- main()
 
1
  # training/train.py
2
+ """Two-phase training loop with W&B logging and safetensors checkpointing."""
 
 
 
 
 
 
 
 
 
 
3
  import argparse
4
  import importlib
5
  import json
 
13
  import yaml
14
  from torch.utils.data import DataLoader
15
 
16
+ from training.evaluation.metrics import compute_classification_metrics, compute_regression_metrics
 
 
 
17
  from training.utils.dataset import AnemiaDataset
18
 
19
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
 
27
 
28
  def get_model(model_name: str, config: dict) -> nn.Module:
29
  mod = importlib.import_module(f"training.models.{model_name}")
30
+ return mod.AnemiaModel(dropout_rate=config["model"]["dropout_rate"], pretrained=True)
 
 
 
31
 
32
 
33
+ def multitask_loss(hb_pred, hb_true, class_logits, class_true, w_reg=0.7, w_cls=0.3):
34
+ mse = nn.functional.mse_loss(hb_pred.squeeze(1), hb_true.float())
 
 
 
 
 
 
 
35
  ce = nn.functional.cross_entropy(class_logits, class_true.long())
36
  return w_reg * mse + w_cls * ce
37
 
 
54
  loss.backward()
55
  optimizer.step()
56
  total_loss += loss.item()
57
+ hb_preds.extend(hb_pred.squeeze(1).detach().cpu().numpy().tolist())
58
+ hb_trues.extend(hb.detach().cpu().numpy().tolist())
59
+ cls_preds.extend(torch.softmax(cls_logits, dim=1).detach().cpu().numpy().tolist())
60
+ cls_trues.extend(cls.detach().cpu().numpy().tolist())
61
 
62
  reg_metrics = compute_regression_metrics(np.array(hb_trues), np.array(hb_preds))
63
  cls_metrics = compute_classification_metrics(np.array(cls_trues), np.array(cls_preds))
 
69
  }
70
 
71
 
72
+ def _save_safetensors(model: nn.Module, path: pathlib.Path):
73
+ from safetensors.torch import save_file
74
+
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ save_file({k: v.contiguous() for k, v in model.state_dict().items()}, str(path))
77
+
78
+
79
+ def train_model(model_name, train_rows, val_rows, config, output_dir, fold=0, run_name=""):
 
 
80
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
81
  log.info(f"Training {model_name} fold={fold} on {device}")
82
 
83
  img_size = config["data"]["image_size"]
 
 
84
  train_loader = DataLoader(
85
+ AnemiaDataset(train_rows, image_size=img_size, augment=True),
86
  batch_size=config["data"]["batch_size"],
87
  shuffle=True,
88
  num_workers=config["data"]["num_workers"],
89
  pin_memory=True,
90
  )
91
  val_loader = DataLoader(
92
+ AnemiaDataset(val_rows, image_size=img_size, augment=False),
93
  batch_size=config["data"]["batch_size"],
94
  shuffle=False,
95
  num_workers=config["data"]["num_workers"],
 
97
  )
98
 
99
  model = get_model(model_name, config).to(device)
 
100
  wandb_run = wandb.init(
101
  project=config["wandb"]["project"],
102
  name=run_name or f"{model_name}_fold{fold}",
 
104
  reinit=True,
105
  )
106
 
107
+ # Phase 1: heads only
108
  model.freeze_backbone()
109
  optimizer = torch.optim.AdamW(
110
  filter(lambda p: p.requires_grad, model.parameters()),
111
  lr=config["training"]["phase1_lr"],
112
  weight_decay=config["training"]["weight_decay"],
113
  )
 
114
  for epoch in range(config["training"]["phase1_epochs"]):
115
  train_m = run_epoch(model, train_loader, optimizer, device, training=True, config=config)
116
  val_m = run_epoch(model, val_loader, optimizer, device, training=False, config=config)
 
121
  **{f"val/{k}": v for k, v in val_m.items()},
122
  }
123
  )
124
+ log.info(f" P1 Ep{epoch+1}: train_mae={train_m['mae']:.3f} val_mae={val_m['mae']:.3f}")
 
 
125
 
126
+ # Phase 2: unfreeze last n blocks (look up by model_name)
127
  arch_cfg = next(
128
  (a for a in config["model"]["architectures"] if a["name"] == model_name),
129
  config["model"]["architectures"][0],
 
141
  best_val_mae = float("inf")
142
  patience_count = 0
143
  best_metrics = {}
144
+ best_ckpt_path = pathlib.Path(output_dir) / f"{model_name}_fold{fold}_best.safetensors"
145
 
 
146
  for epoch in range(config["training"]["phase2_epochs"]):
147
  train_m = run_epoch(model, train_loader, optimizer, device, training=True, config=config)
148
  val_m = run_epoch(model, val_loader, optimizer, device, training=False, config=config)
 
154
  **{f"val/{k}": v for k, v in val_m.items()},
155
  }
156
  )
157
+ log.info(f" P2 Ep{epoch+1}: val_mae={val_m['mae']:.3f} val_auc={val_m['auc']:.3f}")
158
 
159
  if val_m["mae"] < best_val_mae:
160
  best_val_mae = val_m["mae"]
 
164
  else:
165
  patience_count += 1
166
  if patience_count >= config["training"]["early_stopping_patience"]:
167
+ log.info(f" Early stopping at epoch {epoch+1}")
168
  break
169
 
170
  wandb_run.finish()
171
+ metrics_path = pathlib.Path(output_dir) / f"{model_name}_fold{fold}_metrics.json"
172
  with open(metrics_path, "w") as f:
173
  json.dump(best_metrics, f, indent=2)
 
174
  return best_metrics
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/utils/augmentation.py CHANGED
@@ -1,26 +1,21 @@
1
  # training/utils/augmentation.py
2
  """Albumentations pipelines for training and validation."""
3
-
4
  import albumentations as A
5
 
6
 
7
  def get_augmentation_pipeline(image_size: int = 380) -> A.Compose:
8
- return A.Compose(
9
- [
10
- A.Resize(image_size, image_size),
11
- A.HorizontalFlip(p=0.5),
12
- A.Rotate(limit=15, p=0.7),
13
- A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.6),
14
- A.HueSaturationValue(hue_shift_limit=10, sat_shift_limit=15, val_shift_limit=10, p=0.4),
15
- A.GaussNoise(var_limit=(10, 50), p=0.2),
16
- A.CoarseDropout(max_holes=4, max_height=32, max_width=32, p=0.3),
17
- ]
18
- )
19
 
20
 
21
  def get_val_transforms(image_size: int = 380) -> A.Compose:
22
- return A.Compose(
23
- [
24
- A.Resize(image_size, image_size),
25
- ]
26
- )
 
1
  # training/utils/augmentation.py
2
  """Albumentations pipelines for training and validation."""
 
3
  import albumentations as A
4
 
5
 
6
  def get_augmentation_pipeline(image_size: int = 380) -> A.Compose:
7
+ return A.Compose([
8
+ A.Resize(image_size, image_size),
9
+ A.HorizontalFlip(p=0.5),
10
+ A.Rotate(limit=15, p=0.7),
11
+ A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.6),
12
+ A.HueSaturationValue(hue_shift_limit=10, sat_shift_limit=15, val_shift_limit=10, p=0.4),
13
+ A.GaussNoise(std_range=(0.01, 0.05), p=0.2),
14
+ A.CoarseDropout(num_holes_range=(1, 4), hole_height_range=(1, 32), hole_width_range=(1, 32), p=0.3),
15
+ ])
 
 
16
 
17
 
18
  def get_val_transforms(image_size: int = 380) -> A.Compose:
19
+ return A.Compose([
20
+ A.Resize(image_size, image_size),
21
+ ])
 
 
training/utils/dataset.py CHANGED
@@ -1,6 +1,5 @@
1
  # training/utils/dataset.py
2
  """PyTorch Dataset for anemia screening images."""
3
-
4
  from typing import Any
5
 
6
  import numpy as np
@@ -28,7 +27,8 @@ class AnemiaDataset(Dataset):
28
  self.rows = rows
29
  self.image_size = image_size
30
  self.transform = (
31
- get_augmentation_pipeline(image_size) if augment else get_val_transforms(image_size)
 
32
  )
33
 
34
  def __len__(self) -> int:
@@ -45,7 +45,6 @@ class AnemiaDataset(Dataset):
45
  transformed = self.transform(image=img_arr)
46
  img_tensor = torch.from_numpy(transformed["image"]).permute(2, 0, 1).float() / 255.0
47
 
48
- # Normalize with ImageNet stats
49
  mean = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)
50
  std = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
51
  img_tensor = (img_tensor - mean) / std
 
1
  # training/utils/dataset.py
2
  """PyTorch Dataset for anemia screening images."""
 
3
  from typing import Any
4
 
5
  import numpy as np
 
27
  self.rows = rows
28
  self.image_size = image_size
29
  self.transform = (
30
+ get_augmentation_pipeline(image_size) if augment
31
+ else get_val_transforms(image_size)
32
  )
33
 
34
  def __len__(self) -> int:
 
45
  transformed = self.transform(image=img_arr)
46
  img_tensor = torch.from_numpy(transformed["image"]).permute(2, 0, 1).float() / 255.0
47
 
 
48
  mean = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)
49
  std = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
50
  img_tensor = (img_tensor - mean) / std
training/utils/preprocessing.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # training/utils/preprocessing.py
2
+ """Image preprocessing utilities."""
3
+ from training.utils.augmentation import get_val_transforms
4
+
5
+ __all__ = ["get_val_transforms"]