Spaces:
Sleeping
Sleeping
Update backend for account workflows and calibration
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +4 -17
- README.md +1 -1
- backend/.env.example +61 -0
- backend/app/api/history.py +36 -0
- backend/app/config.py +26 -15
- backend/app/database.py +6 -1
- backend/app/main.py +157 -165
- backend/app/ml/efficientnet_model.py +25 -45
- backend/app/ml/lightweight_model.py +1 -1
- backend/app/ml/runtime_calibration.py +50 -0
- backend/app/ml/runtime_refinement.py +111 -0
- backend/app/ml/runtime_stack.py +5 -5
- backend/app/schemas.py +185 -3
- backend/app/services/clinical_brief.py +2 -2
- backend/app/services/email_report.py +243 -55
- backend/app/services/guidance.py +101 -31
- backend/app/services/image_quality.py +159 -6
- backend/app/services/patient_case.py +254 -0
- backend/app/services/prediction.py +822 -310
- backend/app/services/request_parsing.py +19 -1
- backend/app/services/runtime_status.py +33 -0
- backend/app/services/screening_store.py +56 -0
- backend/app/utils/security.py +5 -0
- backend/models/deployed_screening_report.json +7 -7
- backend/models/runtime_calibration_report.json +28 -0
- backend/models/runtime_refinement_report.json +24 -0
- backend/models/runtime_risk_calibrator.pkl +3 -0
- backend/models/runtime_screening_refiner.pkl +3 -0
- backend/scripts/analyze_efficientnet_errors.py +204 -0
- backend/scripts/eval_pipeline.py +80 -0
- backend/scripts/eval_real.py +79 -0
- backend/scripts/evaluate_deployed_screening.py +89 -0
- backend/scripts/evaluate_runtime_stack.py +184 -0
- backend/scripts/fit_runtime_risk_calibrator.py +225 -0
- backend/scripts/fit_runtime_screening_refiner.py +204 -0
- backend/scripts/proof_metrics.py +181 -0
- backend/scripts/quick_eval.py +109 -0
- backend/scripts/retrain_fast.py +230 -0
- backend/scripts/retrain_pipeline_aligned.py +302 -0
- backend/scripts/test_endpoint.py +36 -0
- backend/scripts/test_model.py +45 -0
- backend/scripts/train_archive_model.py +119 -0
- backend/scripts/train_efficientnet.py +465 -0
- backend/scripts/train_ensemble.py +49 -0
- backend/scripts/train_stacked.py +617 -0
- backend/start_server.py +19 -0
- backend/tests/test_case_insight.py +182 -0
- backend/tests/test_clinical_brief.py +195 -0
- backend/tests/test_decision_audit.py +89 -0
- backend/tests/test_email_report.py +414 -0
Dockerfile
CHANGED
|
@@ -7,26 +7,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 7 |
libglib2.0-0 libsm6 libxext6 libxrender-dev libgl1 \
|
| 8 |
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
|
| 10 |
-
# Copy and install
|
| 11 |
COPY backend/requirements.txt .
|
| 12 |
-
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
|
| 14 |
# Copy backend source
|
| 15 |
COPY backend/ .
|
| 16 |
|
| 17 |
-
|
| 18 |
-
ENV OMP_NUM_THREADS=1
|
| 19 |
-
ENV MKL_NUM_THREADS=1
|
| 20 |
-
ENV PYTHONUNBUFFERED=1
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
# Production: uvicorn with FastAPI (single worker — memory constrained)
|
| 25 |
-
CMD ["uvicorn", "app.main:app", \
|
| 26 |
-
"--host", "0.0.0.0", \
|
| 27 |
-
"--port", "7860", \
|
| 28 |
-
"--workers", "1", \
|
| 29 |
-
"--timeout-keep-alive", "30", \
|
| 30 |
-
"--limit-concurrency", "4", \
|
| 31 |
-
"--proxy-headers", \
|
| 32 |
-
"--forwarded-allow-ips=*"]
|
|
|
|
| 7 |
libglib2.0-0 libsm6 libxext6 libxrender-dev libgl1 \
|
| 8 |
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
|
| 10 |
+
# Copy and install backend runtime deps first.
|
| 11 |
COPY backend/requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
| 13 |
|
| 14 |
# Copy backend source
|
| 15 |
COPY backend/ .
|
| 16 |
|
| 17 |
+
EXPOSE 5000
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -4,7 +4,7 @@ emoji: 🩸
|
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: gray
|
| 6 |
sdk: docker
|
| 7 |
-
app_port:
|
| 8 |
base_path: /docs
|
| 9 |
pinned: false
|
| 10 |
license: mit
|
|
|
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: gray
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 5000
|
| 8 |
base_path: /docs
|
| 9 |
pinned: false
|
| 10 |
license: mit
|
backend/.env.example
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AnemiaLens backend environment variables
|
| 2 |
+
# Copy this file to .env and fill in the secrets.
|
| 3 |
+
# NEVER commit .env to version control.
|
| 4 |
+
|
| 5 |
+
# -- Mistral AI guidance --
|
| 6 |
+
ANEMIALENS_MISTRAL_API_KEY=
|
| 7 |
+
ANEMIALENS_MISTRAL_ENABLED=true
|
| 8 |
+
ANEMIALENS_MISTRAL_MODEL=mistral-small-latest
|
| 9 |
+
ANEMIALENS_GUIDANCE_TIMEOUT=20
|
| 10 |
+
|
| 11 |
+
# -- Server --
|
| 12 |
+
ANEMIALENS_LOG_LEVEL=INFO
|
| 13 |
+
ANEMIALENS_CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
|
| 14 |
+
|
| 15 |
+
# -- Database --
|
| 16 |
+
DATABASE_URL=sqlite+aiosqlite:///./anemialens.db
|
| 17 |
+
|
| 18 |
+
# -- Auth (JWT) --
|
| 19 |
+
JWT_SECRET_KEY=change-me-to-a-random-64-char-string
|
| 20 |
+
JWT_ALGORITHM=HS256
|
| 21 |
+
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
| 22 |
+
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
| 23 |
+
|
| 24 |
+
# -- Rate limiting --
|
| 25 |
+
ANEMIALENS_RATE_LIMIT_ANALYZE=10
|
| 26 |
+
ANEMIALENS_RATE_LIMIT_QUALITY=30
|
| 27 |
+
|
| 28 |
+
# -- Hosted email reports (recommended on Hugging Face Spaces) --
|
| 29 |
+
# Gmail API works over HTTPS and is the current hosted delivery path.
|
| 30 |
+
ANEMIALENS_EMAIL_PROVIDER=gmail_api
|
| 31 |
+
ANEMIALENS_GMAIL_CLIENT_ID=
|
| 32 |
+
ANEMIALENS_GMAIL_CLIENT_SECRET=
|
| 33 |
+
ANEMIALENS_GMAIL_REFRESH_TOKEN=
|
| 34 |
+
ANEMIALENS_EMAIL_FROM_NAME=AnemiaLens
|
| 35 |
+
ANEMIALENS_EMAIL_FROM_EMAIL=
|
| 36 |
+
ANEMIALENS_EMAIL_REPLY_TO=
|
| 37 |
+
|
| 38 |
+
# -- SMTP fallback (local or non-restricted hosts) --
|
| 39 |
+
# ANEMIALENS_EMAIL_PROVIDER=smtp
|
| 40 |
+
# ANEMIALENS_SMTP_HOST=smtp.gmail.com
|
| 41 |
+
# ANEMIALENS_SMTP_PORT=465
|
| 42 |
+
# ANEMIALENS_SMTP_USE_SSL=true
|
| 43 |
+
# ANEMIALENS_SMTP_USE_STARTTLS=false
|
| 44 |
+
# ANEMIALENS_SMTP_USERNAME=your.gmail@gmail.com
|
| 45 |
+
# ANEMIALENS_SMTP_PASSWORD=your-16-char-app-password
|
| 46 |
+
# ANEMIALENS_SMTP_TIMEOUT=20
|
| 47 |
+
|
| 48 |
+
# -- HTTP API alternatives --
|
| 49 |
+
# Resend:
|
| 50 |
+
# ANEMIALENS_EMAIL_PROVIDER=resend
|
| 51 |
+
# ANEMIALENS_RESEND_API_KEY=
|
| 52 |
+
# ANEMIALENS_EMAIL_FROM_NAME=AnemiaLens
|
| 53 |
+
# ANEMIALENS_EMAIL_FROM_EMAIL=onboarding@resend.dev
|
| 54 |
+
# ANEMIALENS_EMAIL_REPLY_TO=your@email.com
|
| 55 |
+
#
|
| 56 |
+
# SendGrid:
|
| 57 |
+
# ANEMIALENS_EMAIL_PROVIDER=sendgrid
|
| 58 |
+
# ANEMIALENS_SENDGRID_API_KEY=
|
| 59 |
+
# ANEMIALENS_EMAIL_FROM_NAME=AnemiaLens
|
| 60 |
+
# ANEMIALENS_EMAIL_FROM_EMAIL=your_verified_sender@gmail.com
|
| 61 |
+
# ANEMIALENS_EMAIL_REPLY_TO=your_verified_sender@gmail.com
|
backend/app/api/history.py
CHANGED
|
@@ -17,6 +17,8 @@ from app.database import get_db
|
|
| 17 |
from app.dependencies import get_current_user
|
| 18 |
from app.models.screening import Screening
|
| 19 |
from app.models.user import User
|
|
|
|
|
|
|
| 20 |
|
| 21 |
log = logging.getLogger("anemialens.history")
|
| 22 |
|
|
@@ -67,6 +69,16 @@ class DeleteResponse(BaseModel):
|
|
| 67 |
uid: str
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
# ---------------------------------------------------------------------------
|
| 71 |
# Routes
|
| 72 |
# ---------------------------------------------------------------------------
|
|
@@ -238,6 +250,30 @@ async def delete_screening(
|
|
| 238 |
return DeleteResponse(deleted=True, uid=screening_uid)
|
| 239 |
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
# ---------------------------------------------------------------------------
|
| 242 |
# CSV Export (Pro only)
|
| 243 |
# ---------------------------------------------------------------------------
|
|
|
|
| 17 |
from app.dependencies import get_current_user
|
| 18 |
from app.models.screening import Screening
|
| 19 |
from app.models.user import User
|
| 20 |
+
from app.schemas import AnalyzeResponse
|
| 21 |
+
from app.services.screening_store import persist_screening_result
|
| 22 |
|
| 23 |
log = logging.getLogger("anemialens.history")
|
| 24 |
|
|
|
|
| 69 |
uid: str
|
| 70 |
|
| 71 |
|
| 72 |
+
class SaveScreeningRequest(BaseModel):
|
| 73 |
+
analysis: AnalyzeResponse
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class SaveScreeningResponse(BaseModel):
|
| 77 |
+
saved: bool
|
| 78 |
+
uid: str
|
| 79 |
+
message: str
|
| 80 |
+
|
| 81 |
+
|
| 82 |
# ---------------------------------------------------------------------------
|
| 83 |
# Routes
|
| 84 |
# ---------------------------------------------------------------------------
|
|
|
|
| 250 |
return DeleteResponse(deleted=True, uid=screening_uid)
|
| 251 |
|
| 252 |
|
| 253 |
+
@router.post(
|
| 254 |
+
"/save-current",
|
| 255 |
+
response_model=SaveScreeningResponse,
|
| 256 |
+
summary="Save the current screening result to the authenticated account",
|
| 257 |
+
)
|
| 258 |
+
async def save_current_screening(
|
| 259 |
+
body: SaveScreeningRequest,
|
| 260 |
+
user: Annotated[User, Depends(get_current_user)],
|
| 261 |
+
) -> SaveScreeningResponse:
|
| 262 |
+
analysis = body.analysis
|
| 263 |
+
screening = await persist_screening_result(
|
| 264 |
+
request_id=analysis.analysis_meta.request_id,
|
| 265 |
+
analysis=analysis,
|
| 266 |
+
user_id=user.id,
|
| 267 |
+
processing_time_ms=analysis.analysis_meta.processing_time_ms,
|
| 268 |
+
)
|
| 269 |
+
log.info("Screening saved to account: %s by user %s", screening.uid, user.uid)
|
| 270 |
+
return SaveScreeningResponse(
|
| 271 |
+
saved=True,
|
| 272 |
+
uid=screening.uid,
|
| 273 |
+
message="Screening saved to your account history.",
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
# ---------------------------------------------------------------------------
|
| 278 |
# CSV Export (Pro only)
|
| 279 |
# ---------------------------------------------------------------------------
|
backend/app/config.py
CHANGED
|
@@ -30,10 +30,14 @@ DEFAULT_ENSEMBLE_PATH = MODELS_DIR / "ensemble_model.json"
|
|
| 30 |
DEFAULT_DEEP_STACK_PATH = MODELS_DIR / "deep_stack_model.joblib"
|
| 31 |
DEFAULT_ARCHIVE_MODEL_PATH = MODELS_DIR / "archive_screening_model.joblib"
|
| 32 |
DEFAULT_EFFICIENTNET_MODEL_PATH = MODELS_DIR / "efficientnet_anemia.pth"
|
| 33 |
-
DEFAULT_EFFICIENTNET_REPORT_PATH = MODELS_DIR / "efficientnet_report.json"
|
| 34 |
-
DEFAULT_RUNTIME_STACK_REPORT_PATH = MODELS_DIR / "runtime_stack_report.json"
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
# ---------------------------------------------------------------------------
|
|
@@ -83,17 +87,24 @@ class Settings(BaseSettings):
|
|
| 83 |
"PRELOAD_MODELS_ON_STARTUP",
|
| 84 |
),
|
| 85 |
)
|
| 86 |
-
warmup_models_on_startup: bool = Field(
|
| 87 |
-
default=False,
|
| 88 |
-
validation_alias=AliasChoices(
|
| 89 |
-
"ANEMIALENS_WARMUP_MODELS_ON_STARTUP",
|
| 90 |
-
"WARMUP_MODELS_ON_STARTUP",
|
| 91 |
-
),
|
| 92 |
-
)
|
| 93 |
-
|
| 94 |
-
default=
|
| 95 |
-
|
| 96 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
"http://localhost:5174",
|
| 98 |
"http://127.0.0.1:5174",
|
| 99 |
]
|
|
|
|
| 30 |
DEFAULT_DEEP_STACK_PATH = MODELS_DIR / "deep_stack_model.joblib"
|
| 31 |
DEFAULT_ARCHIVE_MODEL_PATH = MODELS_DIR / "archive_screening_model.joblib"
|
| 32 |
DEFAULT_EFFICIENTNET_MODEL_PATH = MODELS_DIR / "efficientnet_anemia.pth"
|
| 33 |
+
DEFAULT_EFFICIENTNET_REPORT_PATH = MODELS_DIR / "efficientnet_report.json"
|
| 34 |
+
DEFAULT_RUNTIME_STACK_REPORT_PATH = MODELS_DIR / "runtime_stack_report.json"
|
| 35 |
+
DEFAULT_RUNTIME_CALIBRATOR_PATH = MODELS_DIR / "runtime_risk_calibrator.pkl"
|
| 36 |
+
DEFAULT_RUNTIME_CALIBRATION_REPORT_PATH = MODELS_DIR / "runtime_calibration_report.json"
|
| 37 |
+
DEFAULT_RUNTIME_REFINER_PATH = MODELS_DIR / "runtime_screening_refiner.pkl"
|
| 38 |
+
DEFAULT_RUNTIME_REFINEMENT_REPORT_PATH = MODELS_DIR / "runtime_refinement_report.json"
|
| 39 |
+
DEFAULT_DEPLOYED_SCREENING_REPORT_PATH = MODELS_DIR / "deployed_screening_report.json"
|
| 40 |
+
DEFAULT_TRAINING_REPORT_PATH = MODELS_DIR / "training_report.json"
|
| 41 |
|
| 42 |
|
| 43 |
# ---------------------------------------------------------------------------
|
|
|
|
| 87 |
"PRELOAD_MODELS_ON_STARTUP",
|
| 88 |
),
|
| 89 |
)
|
| 90 |
+
warmup_models_on_startup: bool = Field(
|
| 91 |
+
default=False,
|
| 92 |
+
validation_alias=AliasChoices(
|
| 93 |
+
"ANEMIALENS_WARMUP_MODELS_ON_STARTUP",
|
| 94 |
+
"WARMUP_MODELS_ON_STARTUP",
|
| 95 |
+
),
|
| 96 |
+
)
|
| 97 |
+
enable_efficientnet_fallback: bool = Field(
|
| 98 |
+
default=False,
|
| 99 |
+
validation_alias=AliasChoices(
|
| 100 |
+
"ANEMIALENS_ENABLE_EFFICIENTNET_FALLBACK",
|
| 101 |
+
"ENABLE_EFFICIENTNET_FALLBACK",
|
| 102 |
+
),
|
| 103 |
+
)
|
| 104 |
+
cors_origins: list[str] = Field(
|
| 105 |
+
default=[
|
| 106 |
+
"http://localhost:5173",
|
| 107 |
+
"http://127.0.0.1:5173",
|
| 108 |
"http://localhost:5174",
|
| 109 |
"http://127.0.0.1:5174",
|
| 110 |
]
|
backend/app/database.py
CHANGED
|
@@ -7,13 +7,18 @@ Supports SQLite (dev) and PostgreSQL (production) via DATABASE_URL.
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import os
|
|
|
|
| 10 |
|
|
|
|
| 11 |
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 12 |
from sqlalchemy.orm import DeclarativeBase
|
| 13 |
|
|
|
|
|
|
|
|
|
|
| 14 |
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./anemialens.db").strip()
|
| 15 |
|
| 16 |
-
# For PostgreSQL
|
| 17 |
if DATABASE_URL.startswith("postgres://"):
|
| 18 |
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql+asyncpg://", 1)
|
| 19 |
elif DATABASE_URL.startswith("postgresql://") and "+asyncpg" not in DATABASE_URL:
|
|
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 14 |
from sqlalchemy.orm import DeclarativeBase
|
| 15 |
|
| 16 |
+
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
load_dotenv(BACKEND_ROOT / ".env")
|
| 18 |
+
|
| 19 |
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./anemialens.db").strip()
|
| 20 |
|
| 21 |
+
# For managed PostgreSQL providers, postgres:// must be normalized to postgresql+asyncpg://
|
| 22 |
if DATABASE_URL.startswith("postgres://"):
|
| 23 |
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql+asyncpg://", 1)
|
| 24 |
elif DATABASE_URL.startswith("postgresql://") and "+asyncpg" not in DATABASE_URL:
|
backend/app/main.py
CHANGED
|
@@ -33,7 +33,7 @@ from typing import Annotated
|
|
| 33 |
from dotenv import load_dotenv
|
| 34 |
from fastapi import FastAPI, File, Form, Request, UploadFile, status, Depends
|
| 35 |
from fastapi.middleware.cors import CORSMiddleware
|
| 36 |
-
from fastapi.responses import JSONResponse, RedirectResponse
|
| 37 |
from PIL import UnidentifiedImageError
|
| 38 |
|
| 39 |
from app.config import BACKEND_ROOT, settings
|
|
@@ -43,17 +43,20 @@ from app.services.analysis_meta import build_analysis_meta
|
|
| 43 |
from app.services.case_insight import CaseInsightService
|
| 44 |
from app.services.clinical_brief import ClinicalBriefService
|
| 45 |
from app.services.decision_audit import build_decision_audit
|
| 46 |
-
from app.services.guidance import GuidanceService
|
| 47 |
-
from app.services.handoff import HandoffSummaryService
|
| 48 |
-
from app.services.image_quality import ImageQualityService
|
| 49 |
-
from app.services.
|
| 50 |
-
from app.services.
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
load_dotenv(BACKEND_ROOT / ".env")
|
| 59 |
|
|
@@ -111,10 +114,11 @@ async def lifespan(app: FastAPI):
|
|
| 111 |
app.state.predictor = ScreeningPredictor()
|
| 112 |
app.state.triage_service = TriageService()
|
| 113 |
app.state.guidance_service = GuidanceService()
|
| 114 |
-
app.state.case_insight_service = CaseInsightService()
|
| 115 |
-
app.state.clinical_brief_service = ClinicalBriefService()
|
| 116 |
-
app.state.handoff_service = HandoffSummaryService()
|
| 117 |
-
|
|
|
|
| 118 |
|
| 119 |
# ---------- Model warm-up ----------
|
| 120 |
if app.state.predictor.is_ready():
|
|
@@ -144,7 +148,7 @@ async def lifespan(app: FastAPI):
|
|
| 144 |
# App
|
| 145 |
# ---------------------------------------------------------------------------
|
| 146 |
|
| 147 |
-
app = FastAPI(
|
| 148 |
title="AnemiaLens API",
|
| 149 |
version="1.0.0",
|
| 150 |
description=(
|
|
@@ -155,7 +159,7 @@ app = FastAPI(
|
|
| 155 |
lifespan=lifespan,
|
| 156 |
docs_url="/docs",
|
| 157 |
redoc_url="/redoc",
|
| 158 |
-
)
|
| 159 |
|
| 160 |
# ---------------------------------------------------------------------------
|
| 161 |
# Middleware stack (order matters — outermost first)
|
|
@@ -199,57 +203,57 @@ app.add_middleware(MemoryGuardMiddleware)
|
|
| 199 |
# ---------------------------------------------------------------------------
|
| 200 |
|
| 201 |
@app.middleware("http")
|
| 202 |
-
async def request_id_middleware(request: Request, call_next):
|
| 203 |
-
request_id = str(uuid.uuid4())[:8]
|
| 204 |
-
request.state.request_id = request_id
|
| 205 |
-
request.state.started_at = time.perf_counter()
|
| 206 |
-
|
| 207 |
-
try:
|
| 208 |
-
response = await call_next(request)
|
| 209 |
-
except Exception:
|
| 210 |
-
elapsed_ms = (time.perf_counter() - request.state.started_at) * 1000
|
| 211 |
-
log.exception(
|
| 212 |
-
"%s %s -> %d (%.1fms) [%s]",
|
| 213 |
-
request.method,
|
| 214 |
-
request.url.path,
|
| 215 |
-
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 216 |
-
elapsed_ms,
|
| 217 |
-
request_id,
|
| 218 |
-
extra={"request_id": request_id},
|
| 219 |
-
)
|
| 220 |
-
raise
|
| 221 |
-
|
| 222 |
-
elapsed_ms = (time.perf_counter() - request.state.started_at) * 1000
|
| 223 |
-
response.headers["X-Request-ID"] = request_id
|
| 224 |
-
response.headers["X-Response-Time"] = f"{elapsed_ms:.1f}ms"
|
| 225 |
-
|
| 226 |
-
log.info(
|
| 227 |
-
"%s %s -> %d (%.1fms) [%s]",
|
| 228 |
-
request.method,
|
| 229 |
-
request.url.path,
|
| 230 |
-
response.status_code,
|
| 231 |
-
elapsed_ms,
|
| 232 |
-
request_id,
|
| 233 |
-
extra={"request_id": request_id},
|
| 234 |
-
)
|
| 235 |
-
return response
|
| 236 |
|
| 237 |
|
| 238 |
# ---------------------------------------------------------------------------
|
| 239 |
# Include API route modules (Phase 2 & 3)
|
| 240 |
# ---------------------------------------------------------------------------
|
| 241 |
|
| 242 |
-
from app.api.auth import router as auth_router
|
| 243 |
-
from app.api.history import router as history_router
|
| 244 |
-
from app.api.admin import router as admin_router
|
| 245 |
-
from app.api.billing import router as billing_router
|
| 246 |
-
from app.api.email_report import router as email_report_router
|
| 247 |
-
|
| 248 |
-
app.include_router(auth_router)
|
| 249 |
-
app.include_router(history_router)
|
| 250 |
-
app.include_router(admin_router)
|
| 251 |
-
app.include_router(billing_router)
|
| 252 |
-
app.include_router(email_report_router)
|
| 253 |
|
| 254 |
|
| 255 |
# ---------------------------------------------------------------------------
|
|
@@ -277,12 +281,12 @@ def _too_large_response(request_id: str, max_mb: float) -> JSONResponse:
|
|
| 277 |
)
|
| 278 |
|
| 279 |
|
| 280 |
-
def _attempt_raw_frame_rescue(services, image_bytes: bytes, quality
|
| 281 |
if quality.passed or not services.quality_service.allows_raw_frame_rescue(quality):
|
| 282 |
return quality, None, False
|
| 283 |
|
| 284 |
raw_image = load_image_bytes(image_bytes).convert("RGB")
|
| 285 |
-
raw_prediction = services.predictor.predict(raw_image, quality
|
| 286 |
if not services.predictor.should_accept_raw_frame_rescue(raw_prediction):
|
| 287 |
return quality, None, False
|
| 288 |
|
|
@@ -294,73 +298,37 @@ def _attempt_raw_frame_rescue(services, image_bytes: bytes, quality, symptom_sco
|
|
| 294 |
# Screening persistence helper (Phase 2)
|
| 295 |
# ---------------------------------------------------------------------------
|
| 296 |
|
| 297 |
-
async def _persist_screening(
|
| 298 |
-
request_id: str,
|
| 299 |
-
analysis: AnalyzeResponse,
|
| 300 |
-
user_id: int | None,
|
| 301 |
-
processing_time_ms: float,
|
| 302 |
-
) -> None:
|
| 303 |
-
"""Save the screening result to the database."""
|
| 304 |
-
try:
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
anemia_risk=analysis.prediction.anemia_risk if analysis.prediction else None,
|
| 315 |
-
predicted_hemoglobin=analysis.prediction.predicted_hemoglobin if analysis.prediction else None,
|
| 316 |
-
confidence=analysis.prediction.confidence if analysis.prediction else None,
|
| 317 |
-
uncertainty=analysis.prediction.uncertainty if analysis.prediction else None,
|
| 318 |
-
screening_label=analysis.prediction.screening_label if analysis.prediction else None,
|
| 319 |
-
model_source=analysis.prediction.model_source if analysis.prediction else None,
|
| 320 |
-
quality_passed=analysis.quality.passed,
|
| 321 |
-
blocked=analysis.blocked,
|
| 322 |
-
processing_path=analysis.decision_audit.processing_path,
|
| 323 |
-
guidance_source=analysis.guidance.source,
|
| 324 |
-
symptoms_json=json.dumps(analysis.symptoms.model_dump()),
|
| 325 |
-
full_response_json=json.dumps(analysis.model_dump(), default=str),
|
| 326 |
-
share_text=analysis.handoff_summary.share_text,
|
| 327 |
-
urgency_label=analysis.handoff_summary.urgency_label,
|
| 328 |
-
headline=analysis.handoff_summary.headline,
|
| 329 |
-
processing_time_ms=processing_time_ms,
|
| 330 |
-
language=analysis.language,
|
| 331 |
-
region=analysis.region,
|
| 332 |
-
)
|
| 333 |
|
| 334 |
-
async with async_session_factory() as session:
|
| 335 |
-
session.add(screening)
|
| 336 |
-
await session.commit()
|
| 337 |
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
from sqlalchemy import select
|
| 342 |
-
result = await session.execute(select(User).where(User.id == user_id))
|
| 343 |
-
user = result.scalar_one_or_none()
|
| 344 |
-
if user:
|
| 345 |
-
user.scan_count += 1
|
| 346 |
-
await session.commit()
|
| 347 |
|
| 348 |
-
|
| 349 |
-
|
|
|
|
|
|
|
| 350 |
|
| 351 |
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
# ---------------------------------------------------------------------------
|
| 355 |
-
|
| 356 |
-
@app.get("/", include_in_schema=False)
|
| 357 |
-
async def root() -> RedirectResponse:
|
| 358 |
-
"""Redirect the Space root to Swagger UI so Docker Space routing has a valid landing page."""
|
| 359 |
-
return RedirectResponse(url="/docs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
@app.get("/health", tags=["meta"], summary="Liveness probe")
|
| 363 |
-
async def health(request: Request) -> dict[str, object]:
|
| 364 |
"""Returns 200 OK when the server is alive."""
|
| 365 |
guidance_status = request.app.state.guidance_service.runtime_status()
|
| 366 |
return {
|
|
@@ -436,13 +404,14 @@ async def quality_check(
|
|
| 436 |
summary="Full conjunctiva screening pipeline",
|
| 437 |
status_code=status.HTTP_200_OK,
|
| 438 |
)
|
| 439 |
-
async def analyze(
|
| 440 |
-
request: Request,
|
| 441 |
-
image: Annotated[UploadFile, File(description="Eye photo (JPEG or PNG).")],
|
| 442 |
-
symptoms: Annotated[str | None, Form(description="JSON-encoded symptom flags.")] = None,
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
|
|
|
| 446 |
"""
|
| 447 |
Full pipeline: quality gate → ML inference → triage → guidance → insight packs.
|
| 448 |
Works for both authenticated and anonymous users.
|
|
@@ -491,13 +460,14 @@ async def analyze(
|
|
| 491 |
)
|
| 492 |
|
| 493 |
# --- Input validation --------------------------------------------------
|
| 494 |
-
try:
|
| 495 |
-
symptom_input = parse_symptoms(symptoms)
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
|
|
|
| 501 |
content={"error": str(exc), "request_id": rid},
|
| 502 |
)
|
| 503 |
|
|
@@ -513,12 +483,10 @@ async def analyze(
|
|
| 513 |
return _image_error_response(rid)
|
| 514 |
|
| 515 |
# --- Inference (skipped on quality failure) -----------------------------
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
if prediction is None:
|
| 521 |
-
quality, prediction, used_raw_frame_rescue = _attempt_raw_frame_rescue(svc, image_bytes, quality, symptom_score=symptom_score)
|
| 522 |
|
| 523 |
# --- Triage + guidance -------------------------------------------------
|
| 524 |
signal_breakdown = svc.triage_service.build_signal_breakdown(quality, prediction, symptom_input)
|
|
@@ -568,31 +536,55 @@ async def analyze(
|
|
| 568 |
|
| 569 |
processing_time_ms = (time.perf_counter() - request.state.started_at) * 1000
|
| 570 |
|
| 571 |
-
analysis_meta = build_analysis_meta(
|
| 572 |
-
request_id=rid,
|
| 573 |
-
api_version=app.version,
|
| 574 |
-
processing_time_ms=processing_time_ms,
|
| 575 |
quality=quality,
|
| 576 |
decision_audit=decision_audit,
|
| 577 |
-
guidance=guidance,
|
| 578 |
-
used_raw_frame_rescue=used_raw_frame_rescue,
|
| 579 |
-
)
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
prediction=prediction,
|
| 585 |
decision_audit=decision_audit,
|
| 586 |
triage=triage,
|
| 587 |
guidance=guidance,
|
| 588 |
insight_pack=insight_pack,
|
| 589 |
-
clinical_brief=clinical_brief,
|
| 590 |
-
handoff_summary=handoff_summary,
|
| 591 |
-
analysis_meta=analysis_meta,
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
|
|
|
|
|
|
|
|
|
| 596 |
|
| 597 |
# --- Persist to database (async, non-blocking) -------------------------
|
| 598 |
import asyncio
|
|
|
|
| 33 |
from dotenv import load_dotenv
|
| 34 |
from fastapi import FastAPI, File, Form, Request, UploadFile, status, Depends
|
| 35 |
from fastapi.middleware.cors import CORSMiddleware
|
| 36 |
+
from fastapi.responses import JSONResponse, RedirectResponse
|
| 37 |
from PIL import UnidentifiedImageError
|
| 38 |
|
| 39 |
from app.config import BACKEND_ROOT, settings
|
|
|
|
| 43 |
from app.services.case_insight import CaseInsightService
|
| 44 |
from app.services.clinical_brief import ClinicalBriefService
|
| 45 |
from app.services.decision_audit import build_decision_audit
|
| 46 |
+
from app.services.guidance import GuidanceService
|
| 47 |
+
from app.services.handoff import HandoffSummaryService
|
| 48 |
+
from app.services.image_quality import ImageQualityService
|
| 49 |
+
from app.services.patient_case import PatientCaseService
|
| 50 |
+
from app.services.prediction import ScreeningPredictor
|
| 51 |
+
from app.services.request_parsing import (
|
| 52 |
+
InvalidRequestPayload,
|
| 53 |
+
normalize_optional_text,
|
| 54 |
+
parse_patient_profile,
|
| 55 |
+
parse_symptoms,
|
| 56 |
+
)
|
| 57 |
+
from app.services.runtime_status import build_runtime_status
|
| 58 |
+
from app.services.screening_store import persist_screening_result
|
| 59 |
+
from app.services.triage import TriageService
|
| 60 |
|
| 61 |
load_dotenv(BACKEND_ROOT / ".env")
|
| 62 |
|
|
|
|
| 114 |
app.state.predictor = ScreeningPredictor()
|
| 115 |
app.state.triage_service = TriageService()
|
| 116 |
app.state.guidance_service = GuidanceService()
|
| 117 |
+
app.state.case_insight_service = CaseInsightService()
|
| 118 |
+
app.state.clinical_brief_service = ClinicalBriefService()
|
| 119 |
+
app.state.handoff_service = HandoffSummaryService()
|
| 120 |
+
app.state.patient_case_service = PatientCaseService()
|
| 121 |
+
log.info("All ML services initialised.")
|
| 122 |
|
| 123 |
# ---------- Model warm-up ----------
|
| 124 |
if app.state.predictor.is_ready():
|
|
|
|
| 148 |
# App
|
| 149 |
# ---------------------------------------------------------------------------
|
| 150 |
|
| 151 |
+
app = FastAPI(
|
| 152 |
title="AnemiaLens API",
|
| 153 |
version="1.0.0",
|
| 154 |
description=(
|
|
|
|
| 159 |
lifespan=lifespan,
|
| 160 |
docs_url="/docs",
|
| 161 |
redoc_url="/redoc",
|
| 162 |
+
)
|
| 163 |
|
| 164 |
# ---------------------------------------------------------------------------
|
| 165 |
# Middleware stack (order matters — outermost first)
|
|
|
|
| 203 |
# ---------------------------------------------------------------------------
|
| 204 |
|
| 205 |
@app.middleware("http")
|
| 206 |
+
async def request_id_middleware(request: Request, call_next):
|
| 207 |
+
request_id = str(uuid.uuid4())[:8]
|
| 208 |
+
request.state.request_id = request_id
|
| 209 |
+
request.state.started_at = time.perf_counter()
|
| 210 |
+
|
| 211 |
+
try:
|
| 212 |
+
response = await call_next(request)
|
| 213 |
+
except Exception:
|
| 214 |
+
elapsed_ms = (time.perf_counter() - request.state.started_at) * 1000
|
| 215 |
+
log.exception(
|
| 216 |
+
"%s %s -> %d (%.1fms) [%s]",
|
| 217 |
+
request.method,
|
| 218 |
+
request.url.path,
|
| 219 |
+
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 220 |
+
elapsed_ms,
|
| 221 |
+
request_id,
|
| 222 |
+
extra={"request_id": request_id},
|
| 223 |
+
)
|
| 224 |
+
raise
|
| 225 |
+
|
| 226 |
+
elapsed_ms = (time.perf_counter() - request.state.started_at) * 1000
|
| 227 |
+
response.headers["X-Request-ID"] = request_id
|
| 228 |
+
response.headers["X-Response-Time"] = f"{elapsed_ms:.1f}ms"
|
| 229 |
+
|
| 230 |
+
log.info(
|
| 231 |
+
"%s %s -> %d (%.1fms) [%s]",
|
| 232 |
+
request.method,
|
| 233 |
+
request.url.path,
|
| 234 |
+
response.status_code,
|
| 235 |
+
elapsed_ms,
|
| 236 |
+
request_id,
|
| 237 |
+
extra={"request_id": request_id},
|
| 238 |
+
)
|
| 239 |
+
return response
|
| 240 |
|
| 241 |
|
| 242 |
# ---------------------------------------------------------------------------
|
| 243 |
# Include API route modules (Phase 2 & 3)
|
| 244 |
# ---------------------------------------------------------------------------
|
| 245 |
|
| 246 |
+
from app.api.auth import router as auth_router
|
| 247 |
+
from app.api.history import router as history_router
|
| 248 |
+
from app.api.admin import router as admin_router
|
| 249 |
+
from app.api.billing import router as billing_router
|
| 250 |
+
from app.api.email_report import router as email_report_router
|
| 251 |
+
|
| 252 |
+
app.include_router(auth_router)
|
| 253 |
+
app.include_router(history_router)
|
| 254 |
+
app.include_router(admin_router)
|
| 255 |
+
app.include_router(billing_router)
|
| 256 |
+
app.include_router(email_report_router)
|
| 257 |
|
| 258 |
|
| 259 |
# ---------------------------------------------------------------------------
|
|
|
|
| 281 |
)
|
| 282 |
|
| 283 |
|
| 284 |
+
def _attempt_raw_frame_rescue(services, image_bytes: bytes, quality):
|
| 285 |
if quality.passed or not services.quality_service.allows_raw_frame_rescue(quality):
|
| 286 |
return quality, None, False
|
| 287 |
|
| 288 |
raw_image = load_image_bytes(image_bytes).convert("RGB")
|
| 289 |
+
raw_prediction = services.predictor.predict(raw_image, quality)
|
| 290 |
if not services.predictor.should_accept_raw_frame_rescue(raw_prediction):
|
| 291 |
return quality, None, False
|
| 292 |
|
|
|
|
| 298 |
# Screening persistence helper (Phase 2)
|
| 299 |
# ---------------------------------------------------------------------------
|
| 300 |
|
| 301 |
+
async def _persist_screening(
|
| 302 |
+
request_id: str,
|
| 303 |
+
analysis: AnalyzeResponse,
|
| 304 |
+
user_id: int | None,
|
| 305 |
+
processing_time_ms: float,
|
| 306 |
+
) -> None:
|
| 307 |
+
"""Save the screening result to the database."""
|
| 308 |
+
try:
|
| 309 |
+
await persist_screening_result(
|
| 310 |
+
request_id=request_id,
|
| 311 |
+
analysis=analysis,
|
| 312 |
+
user_id=user_id,
|
| 313 |
+
processing_time_ms=processing_time_ms,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
except Exception as exc:
|
| 317 |
+
log.warning("Failed to persist screening (non-fatal): %s", exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
+
# ---------------------------------------------------------------------------
|
| 321 |
+
# Routes — Health / Meta
|
| 322 |
+
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
|
| 324 |
+
@app.get("/", include_in_schema=False)
|
| 325 |
+
async def root() -> RedirectResponse:
|
| 326 |
+
"""Redirect the Space root to Swagger UI so Docker Space routing has a valid landing page."""
|
| 327 |
+
return RedirectResponse(url="/docs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
| 328 |
|
| 329 |
|
| 330 |
+
@app.get("/health", tags=["meta"], summary="Liveness probe")
|
| 331 |
+
async def health(request: Request) -> dict[str, object]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
"""Returns 200 OK when the server is alive."""
|
| 333 |
guidance_status = request.app.state.guidance_service.runtime_status()
|
| 334 |
return {
|
|
|
|
| 404 |
summary="Full conjunctiva screening pipeline",
|
| 405 |
status_code=status.HTTP_200_OK,
|
| 406 |
)
|
| 407 |
+
async def analyze(
|
| 408 |
+
request: Request,
|
| 409 |
+
image: Annotated[UploadFile, File(description="Eye photo (JPEG or PNG).")],
|
| 410 |
+
symptoms: Annotated[str | None, Form(description="JSON-encoded symptom flags.")] = None,
|
| 411 |
+
patient_profile: Annotated[str | None, Form(description="JSON-encoded intake profile.")] = None,
|
| 412 |
+
language: Annotated[str | None, Form(description="Preferred language for guidance.")] = None,
|
| 413 |
+
region: Annotated[str | None, Form(description="Geographic region for localised guidance.")] = None,
|
| 414 |
+
) -> AnalyzeResponse | JSONResponse:
|
| 415 |
"""
|
| 416 |
Full pipeline: quality gate → ML inference → triage → guidance → insight packs.
|
| 417 |
Works for both authenticated and anonymous users.
|
|
|
|
| 460 |
)
|
| 461 |
|
| 462 |
# --- Input validation --------------------------------------------------
|
| 463 |
+
try:
|
| 464 |
+
symptom_input = parse_symptoms(symptoms)
|
| 465 |
+
patient_profile_input = parse_patient_profile(patient_profile)
|
| 466 |
+
language = normalize_optional_text(language, field_name="language")
|
| 467 |
+
region = normalize_optional_text(region, field_name="region")
|
| 468 |
+
except InvalidRequestPayload as exc:
|
| 469 |
+
return JSONResponse(
|
| 470 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 471 |
content={"error": str(exc), "request_id": rid},
|
| 472 |
)
|
| 473 |
|
|
|
|
| 483 |
return _image_error_response(rid)
|
| 484 |
|
| 485 |
# --- Inference (skipped on quality failure) -----------------------------
|
| 486 |
+
prediction = svc.predictor.predict(rgb, quality) if quality.passed else None
|
| 487 |
+
used_raw_frame_rescue = False
|
| 488 |
+
if prediction is None:
|
| 489 |
+
quality, prediction, used_raw_frame_rescue = _attempt_raw_frame_rescue(svc, image_bytes, quality)
|
|
|
|
|
|
|
| 490 |
|
| 491 |
# --- Triage + guidance -------------------------------------------------
|
| 492 |
signal_breakdown = svc.triage_service.build_signal_breakdown(quality, prediction, symptom_input)
|
|
|
|
| 536 |
|
| 537 |
processing_time_ms = (time.perf_counter() - request.state.started_at) * 1000
|
| 538 |
|
| 539 |
+
analysis_meta = build_analysis_meta(
|
| 540 |
+
request_id=rid,
|
| 541 |
+
api_version=app.version,
|
| 542 |
+
processing_time_ms=processing_time_ms,
|
| 543 |
quality=quality,
|
| 544 |
decision_audit=decision_audit,
|
| 545 |
+
guidance=guidance,
|
| 546 |
+
used_raw_frame_rescue=used_raw_frame_rescue,
|
| 547 |
+
)
|
| 548 |
+
patient_profile_result = svc.patient_case_service.build_profile(
|
| 549 |
+
rid,
|
| 550 |
+
patient_profile_input,
|
| 551 |
+
symptom_input,
|
| 552 |
+
)
|
| 553 |
+
workflow_stages = svc.patient_case_service.build_workflow_stages(
|
| 554 |
+
quality,
|
| 555 |
+
prediction,
|
| 556 |
+
triage,
|
| 557 |
+
guidance,
|
| 558 |
+
symptom_input,
|
| 559 |
+
)
|
| 560 |
+
structured_case = svc.patient_case_service.build_structured_case(
|
| 561 |
+
rid,
|
| 562 |
+
patient_profile_result,
|
| 563 |
+
quality,
|
| 564 |
+
prediction,
|
| 565 |
+
triage,
|
| 566 |
+
guidance,
|
| 567 |
+
symptom_input,
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
response = AnalyzeResponse(
|
| 571 |
+
blocked=not quality.passed,
|
| 572 |
+
quality=quality,
|
| 573 |
prediction=prediction,
|
| 574 |
decision_audit=decision_audit,
|
| 575 |
triage=triage,
|
| 576 |
guidance=guidance,
|
| 577 |
insight_pack=insight_pack,
|
| 578 |
+
clinical_brief=clinical_brief,
|
| 579 |
+
handoff_summary=handoff_summary,
|
| 580 |
+
analysis_meta=analysis_meta,
|
| 581 |
+
patient_profile=patient_profile_result,
|
| 582 |
+
workflow_stages=workflow_stages,
|
| 583 |
+
structured_case=structured_case,
|
| 584 |
+
symptoms=symptom_input,
|
| 585 |
+
language=language,
|
| 586 |
+
region=region,
|
| 587 |
+
)
|
| 588 |
|
| 589 |
# --- Persist to database (async, non-blocking) -------------------------
|
| 590 |
import asyncio
|
backend/app/ml/efficientnet_model.py
CHANGED
|
@@ -1,15 +1,11 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
-
from typing import
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
from PIL import Image
|
| 8 |
|
| 9 |
-
if TYPE_CHECKING:
|
| 10 |
-
import torch
|
| 11 |
-
from torch import nn
|
| 12 |
-
|
| 13 |
|
| 14 |
EFFICIENTNET_VERSION = "efficientnet-b0-ft-v2"
|
| 15 |
IMAGE_SIZE = 224
|
|
@@ -21,56 +17,30 @@ def clamp(value: float, lower: float = 0.0, upper: float = 1.0) -> float:
|
|
| 21 |
return max(lower, min(upper, value))
|
| 22 |
|
| 23 |
|
| 24 |
-
def build_efficientnet_model(*, pretrained: bool = True)
|
| 25 |
from torch import nn
|
| 26 |
from torchvision.models import EfficientNet_B0_Weights, efficientnet_b0
|
| 27 |
|
| 28 |
-
class SpatialAttention(nn.Module):
|
| 29 |
-
"""
|
| 30 |
-
Focuses the model on the most informative spatial regions (like the conjunctiva area).
|
| 31 |
-
"""
|
| 32 |
-
|
| 33 |
-
def __init__(self, kernel_size: int = 7) -> None:
|
| 34 |
-
super().__init__()
|
| 35 |
-
self.conv = nn.Conv2d(2, 1, kernel_size=kernel_size, padding=kernel_size // 2, bias=False)
|
| 36 |
-
self.sigmoid = nn.Sigmoid()
|
| 37 |
-
|
| 38 |
-
def forward(self, x):
|
| 39 |
-
import torch
|
| 40 |
-
|
| 41 |
-
avg_out = torch.mean(x, dim=1, keepdim=True)
|
| 42 |
-
max_out, _ = torch.max(x, dim=1, keepdim=True)
|
| 43 |
-
combined = torch.cat([avg_out, max_out], dim=1)
|
| 44 |
-
scale = self.sigmoid(self.conv(combined))
|
| 45 |
-
return x * scale
|
| 46 |
-
|
| 47 |
weights = EfficientNet_B0_Weights.IMAGENET1K_V1 if pretrained else None
|
| 48 |
model = efficientnet_b0(weights=weights)
|
| 49 |
-
|
| 50 |
-
model.features.add_module("spatial_attention", SpatialAttention())
|
| 51 |
model.classifier = nn.Sequential(
|
| 52 |
nn.Dropout(0.35),
|
| 53 |
nn.Linear(1280, 512),
|
| 54 |
nn.GELU(),
|
| 55 |
-
nn.BatchNorm1d(512),
|
| 56 |
nn.Dropout(0.25),
|
| 57 |
nn.Linear(512, 128),
|
| 58 |
nn.GELU(),
|
| 59 |
-
nn.BatchNorm1d(128),
|
| 60 |
nn.Dropout(0.15),
|
| 61 |
nn.Linear(128, 2),
|
| 62 |
)
|
| 63 |
|
| 64 |
for param in model.features.parameters():
|
| 65 |
param.requires_grad = False
|
| 66 |
-
|
| 67 |
for name, param in model.features.named_parameters():
|
| 68 |
-
if name.startswith(("4", "5", "6", "7", "8"
|
| 69 |
param.requires_grad = True
|
| 70 |
-
|
| 71 |
for param in model.classifier.parameters():
|
| 72 |
param.requires_grad = True
|
| 73 |
-
|
| 74 |
return model
|
| 75 |
|
| 76 |
|
|
@@ -81,14 +51,27 @@ def build_train_transform():
|
|
| 81 |
[
|
| 82 |
transforms.RandomHorizontalFlip(),
|
| 83 |
transforms.RandomVerticalFlip(p=0.15),
|
| 84 |
-
transforms.ColorJitter(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
transforms.RandomRotation(20),
|
| 86 |
-
transforms.RandomAffine(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
transforms.RandomPerspective(distortion_scale=0.15, p=0.3),
|
| 88 |
transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
|
| 89 |
transforms.ToTensor(),
|
| 90 |
transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
|
| 91 |
-
transforms.RandomErasing(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
]
|
| 93 |
)
|
| 94 |
|
|
@@ -108,14 +91,14 @@ def build_val_transform():
|
|
| 108 |
def load_efficientnet_checkpoint(
|
| 109 |
path: str | Path,
|
| 110 |
*,
|
| 111 |
-
map_location: str
|
| 112 |
) -> dict[str, Any]:
|
| 113 |
import torch
|
| 114 |
|
| 115 |
checkpoint = torch.load(path, map_location=map_location)
|
| 116 |
model = build_efficientnet_model(pretrained=False)
|
| 117 |
state_dict = checkpoint["state_dict"] if "state_dict" in checkpoint else checkpoint
|
| 118 |
-
model.load_state_dict(state_dict
|
| 119 |
device = torch.device(map_location)
|
| 120 |
model.to(device)
|
| 121 |
model.eval()
|
|
@@ -164,18 +147,15 @@ def predict_with_efficientnet_model(
|
|
| 164 |
_enable_dropout(model)
|
| 165 |
output = model(tensor)
|
| 166 |
probabilities.append(float(torch.sigmoid(output[:, 0]).item()))
|
| 167 |
-
hemoglobin_values.append(
|
|
|
|
|
|
|
| 168 |
|
| 169 |
mean_probability = float(np.mean(probabilities))
|
| 170 |
mean_hemoglobin = float(np.mean(hemoglobin_values))
|
| 171 |
probability_std = float(np.std(probabilities))
|
| 172 |
hemoglobin_std = float(np.std(hemoglobin_values))
|
| 173 |
|
| 174 |
-
hb_mean_val = float(bundle.get("hb_mean", 12.8))
|
| 175 |
-
hb_spread_factor = float(bundle.get("hb_spread_factor", 1.30))
|
| 176 |
-
deviation = mean_hemoglobin - hb_mean_val
|
| 177 |
-
mean_hemoglobin = float(np.clip(hb_mean_val + deviation * hb_spread_factor, 5.0, 20.0))
|
| 178 |
-
|
| 179 |
margin_uncertainty = 1.0 - min(1.0, abs(mean_probability - 0.5) * 2.5)
|
| 180 |
uncertainty = clamp(
|
| 181 |
(probability_std * 2.2)
|
|
@@ -196,7 +176,7 @@ def predict_with_efficientnet_model(
|
|
| 196 |
}
|
| 197 |
|
| 198 |
|
| 199 |
-
def _enable_dropout(model
|
| 200 |
from torch import nn
|
| 201 |
|
| 202 |
for module in model.modules():
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
+
from typing import Any
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
from PIL import Image
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
EFFICIENTNET_VERSION = "efficientnet-b0-ft-v2"
|
| 11 |
IMAGE_SIZE = 224
|
|
|
|
| 17 |
return max(lower, min(upper, value))
|
| 18 |
|
| 19 |
|
| 20 |
+
def build_efficientnet_model(*, pretrained: bool = True):
|
| 21 |
from torch import nn
|
| 22 |
from torchvision.models import EfficientNet_B0_Weights, efficientnet_b0
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
weights = EfficientNet_B0_Weights.IMAGENET1K_V1 if pretrained else None
|
| 25 |
model = efficientnet_b0(weights=weights)
|
|
|
|
|
|
|
| 26 |
model.classifier = nn.Sequential(
|
| 27 |
nn.Dropout(0.35),
|
| 28 |
nn.Linear(1280, 512),
|
| 29 |
nn.GELU(),
|
|
|
|
| 30 |
nn.Dropout(0.25),
|
| 31 |
nn.Linear(512, 128),
|
| 32 |
nn.GELU(),
|
|
|
|
| 33 |
nn.Dropout(0.15),
|
| 34 |
nn.Linear(128, 2),
|
| 35 |
)
|
| 36 |
|
| 37 |
for param in model.features.parameters():
|
| 38 |
param.requires_grad = False
|
|
|
|
| 39 |
for name, param in model.features.named_parameters():
|
| 40 |
+
if name.startswith(("4", "5", "6", "7", "8")):
|
| 41 |
param.requires_grad = True
|
|
|
|
| 42 |
for param in model.classifier.parameters():
|
| 43 |
param.requires_grad = True
|
|
|
|
| 44 |
return model
|
| 45 |
|
| 46 |
|
|
|
|
| 51 |
[
|
| 52 |
transforms.RandomHorizontalFlip(),
|
| 53 |
transforms.RandomVerticalFlip(p=0.15),
|
| 54 |
+
transforms.ColorJitter(
|
| 55 |
+
brightness=0.4,
|
| 56 |
+
contrast=0.4,
|
| 57 |
+
saturation=0.3,
|
| 58 |
+
hue=0.05,
|
| 59 |
+
),
|
| 60 |
transforms.RandomRotation(20),
|
| 61 |
+
transforms.RandomAffine(
|
| 62 |
+
degrees=0,
|
| 63 |
+
translate=(0.12, 0.12),
|
| 64 |
+
scale=(0.88, 1.12),
|
| 65 |
+
),
|
| 66 |
transforms.RandomPerspective(distortion_scale=0.15, p=0.3),
|
| 67 |
transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
|
| 68 |
transforms.ToTensor(),
|
| 69 |
transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
|
| 70 |
+
transforms.RandomErasing(
|
| 71 |
+
p=0.25,
|
| 72 |
+
scale=(0.02, 0.12),
|
| 73 |
+
ratio=(0.3, 3.3),
|
| 74 |
+
),
|
| 75 |
]
|
| 76 |
)
|
| 77 |
|
|
|
|
| 91 |
def load_efficientnet_checkpoint(
|
| 92 |
path: str | Path,
|
| 93 |
*,
|
| 94 |
+
map_location: str = "cpu",
|
| 95 |
) -> dict[str, Any]:
|
| 96 |
import torch
|
| 97 |
|
| 98 |
checkpoint = torch.load(path, map_location=map_location)
|
| 99 |
model = build_efficientnet_model(pretrained=False)
|
| 100 |
state_dict = checkpoint["state_dict"] if "state_dict" in checkpoint else checkpoint
|
| 101 |
+
model.load_state_dict(state_dict)
|
| 102 |
device = torch.device(map_location)
|
| 103 |
model.to(device)
|
| 104 |
model.eval()
|
|
|
|
| 147 |
_enable_dropout(model)
|
| 148 |
output = model(tensor)
|
| 149 |
probabilities.append(float(torch.sigmoid(output[:, 0]).item()))
|
| 150 |
+
hemoglobin_values.append(
|
| 151 |
+
float((output[:, 1].item() * hb_std_scale) + hb_mean)
|
| 152 |
+
)
|
| 153 |
|
| 154 |
mean_probability = float(np.mean(probabilities))
|
| 155 |
mean_hemoglobin = float(np.mean(hemoglobin_values))
|
| 156 |
probability_std = float(np.std(probabilities))
|
| 157 |
hemoglobin_std = float(np.std(hemoglobin_values))
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
margin_uncertainty = 1.0 - min(1.0, abs(mean_probability - 0.5) * 2.5)
|
| 160 |
uncertainty = clamp(
|
| 161 |
(probability_std * 2.2)
|
|
|
|
| 176 |
}
|
| 177 |
|
| 178 |
|
| 179 |
+
def _enable_dropout(model) -> None:
|
| 180 |
from torch import nn
|
| 181 |
|
| 182 |
for module in model.modules():
|
backend/app/ml/lightweight_model.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
Lightweight fallback model for AnemiaLens.
|
| 3 |
|
| 4 |
Used when:
|
| 5 |
-
- Available RAM < 512MB
|
| 6 |
- Inference time budget is tight
|
| 7 |
- Primary model artifacts are unavailable
|
| 8 |
|
|
|
|
| 2 |
Lightweight fallback model for AnemiaLens.
|
| 3 |
|
| 4 |
Used when:
|
| 5 |
+
- Available RAM < 512MB on constrained free hosts
|
| 6 |
- Inference time budget is tight
|
| 7 |
- Primary model artifacts are unavailable
|
| 8 |
|
backend/app/ml/runtime_calibration.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import pickle
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Literal
|
| 7 |
+
|
| 8 |
+
from app.ml.archive_model import clamp
|
| 9 |
+
from app.ml.calibration import CompositeCalibrator
|
| 10 |
+
|
| 11 |
+
SourceHint = Literal["roi_original", "palpebral", "forniceal_palpebral"]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class RuntimeRiskCalibrator:
|
| 16 |
+
version: str = "runtime-risk-calibrator-v1"
|
| 17 |
+
method: str = "temperature"
|
| 18 |
+
calibrator: CompositeCalibrator = field(
|
| 19 |
+
default_factory=lambda: CompositeCalibrator(method="temperature")
|
| 20 |
+
)
|
| 21 |
+
source_thresholds: dict[str, float] = field(default_factory=dict)
|
| 22 |
+
report: dict[str, object] = field(default_factory=dict)
|
| 23 |
+
|
| 24 |
+
def calibrate(
|
| 25 |
+
self,
|
| 26 |
+
probability: float,
|
| 27 |
+
*,
|
| 28 |
+
source_hint: SourceHint = "roi_original",
|
| 29 |
+
) -> float:
|
| 30 |
+
_ = source_hint
|
| 31 |
+
return clamp(float(self.calibrator.calibrate(probability)), 0.0, 1.0)
|
| 32 |
+
|
| 33 |
+
def threshold_for_source(
|
| 34 |
+
self,
|
| 35 |
+
source_hint: SourceHint,
|
| 36 |
+
*,
|
| 37 |
+
fallback: float,
|
| 38 |
+
) -> float:
|
| 39 |
+
return float(self.source_thresholds.get(source_hint, fallback))
|
| 40 |
+
|
| 41 |
+
def save(self, path: str | Path) -> None:
|
| 42 |
+
path = Path(path)
|
| 43 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
with path.open("wb") as handle:
|
| 45 |
+
pickle.dump(self, handle)
|
| 46 |
+
|
| 47 |
+
@classmethod
|
| 48 |
+
def load(cls, path: str | Path) -> "RuntimeRiskCalibrator":
|
| 49 |
+
with Path(path).open("rb") as handle:
|
| 50 |
+
return pickle.load(handle)
|
backend/app/ml/runtime_refinement.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import pickle
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from app.ml.archive_model import clamp
|
| 11 |
+
|
| 12 |
+
FEATURE_ORDER: tuple[str, ...] = (
|
| 13 |
+
"base_anemia_risk",
|
| 14 |
+
"uncertainty",
|
| 15 |
+
"predicted_hemoglobin",
|
| 16 |
+
"predicted_hemoglobin_missing",
|
| 17 |
+
"brightness_score",
|
| 18 |
+
"contrast_score",
|
| 19 |
+
"blur_score",
|
| 20 |
+
"framing_score",
|
| 21 |
+
"lighting_score",
|
| 22 |
+
"glare_risk",
|
| 23 |
+
"shadow_risk",
|
| 24 |
+
"lighting_balanced",
|
| 25 |
+
"lighting_overexposed",
|
| 26 |
+
"lighting_glare_heavy",
|
| 27 |
+
"lighting_shadow_heavy",
|
| 28 |
+
"lighting_flat_contrast",
|
| 29 |
+
"lighting_dim",
|
| 30 |
+
"base_likely",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class RuntimeScreeningRefiner:
|
| 36 |
+
version: str = "runtime-screening-refiner-v1"
|
| 37 |
+
method: str = "logistic-regression"
|
| 38 |
+
threshold: float = 0.53
|
| 39 |
+
feature_order: tuple[str, ...] = FEATURE_ORDER
|
| 40 |
+
model: Any = None
|
| 41 |
+
report: dict[str, object] = field(default_factory=dict)
|
| 42 |
+
|
| 43 |
+
def _feature_vector(
|
| 44 |
+
self,
|
| 45 |
+
*,
|
| 46 |
+
base_anemia_risk: float,
|
| 47 |
+
uncertainty: float,
|
| 48 |
+
predicted_hemoglobin: float | None,
|
| 49 |
+
quality,
|
| 50 |
+
base_likely: bool,
|
| 51 |
+
) -> list[float]:
|
| 52 |
+
hb_missing = predicted_hemoglobin is None
|
| 53 |
+
hb_value = 13.5 if predicted_hemoglobin is None else float(predicted_hemoglobin)
|
| 54 |
+
lighting = str(getattr(quality, "lighting_condition", "balanced"))
|
| 55 |
+
return [
|
| 56 |
+
float(base_anemia_risk),
|
| 57 |
+
float(uncertainty),
|
| 58 |
+
hb_value,
|
| 59 |
+
float(hb_missing),
|
| 60 |
+
float(getattr(quality, "brightness_score", 0.0)),
|
| 61 |
+
float(getattr(quality, "contrast_score", 0.0)),
|
| 62 |
+
float(getattr(quality, "blur_score", 0.0)),
|
| 63 |
+
float(getattr(quality, "framing_score", 0.0)),
|
| 64 |
+
float(getattr(quality, "lighting_score", 0.0)),
|
| 65 |
+
float(getattr(quality, "glare_risk", 0.0)),
|
| 66 |
+
float(getattr(quality, "shadow_risk", 0.0)),
|
| 67 |
+
float(lighting == "balanced"),
|
| 68 |
+
float(lighting == "overexposed"),
|
| 69 |
+
float(lighting == "glare_heavy"),
|
| 70 |
+
float(lighting == "shadow_heavy"),
|
| 71 |
+
float(lighting == "flat_contrast"),
|
| 72 |
+
float(lighting == "dim"),
|
| 73 |
+
float(base_likely),
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
def refine(
|
| 77 |
+
self,
|
| 78 |
+
*,
|
| 79 |
+
base_anemia_risk: float,
|
| 80 |
+
uncertainty: float,
|
| 81 |
+
predicted_hemoglobin: float | None,
|
| 82 |
+
quality,
|
| 83 |
+
base_likely: bool,
|
| 84 |
+
) -> float:
|
| 85 |
+
if self.model is None:
|
| 86 |
+
return clamp(float(base_anemia_risk), 0.0, 1.0)
|
| 87 |
+
vector = np.asarray(
|
| 88 |
+
[
|
| 89 |
+
self._feature_vector(
|
| 90 |
+
base_anemia_risk=base_anemia_risk,
|
| 91 |
+
uncertainty=uncertainty,
|
| 92 |
+
predicted_hemoglobin=predicted_hemoglobin,
|
| 93 |
+
quality=quality,
|
| 94 |
+
base_likely=base_likely,
|
| 95 |
+
)
|
| 96 |
+
],
|
| 97 |
+
dtype=np.float32,
|
| 98 |
+
)
|
| 99 |
+
probability = float(self.model.predict_proba(vector)[0, 1])
|
| 100 |
+
return clamp(probability, 0.0, 1.0)
|
| 101 |
+
|
| 102 |
+
def save(self, path: str | Path) -> None:
|
| 103 |
+
path = Path(path)
|
| 104 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 105 |
+
with path.open("wb") as handle:
|
| 106 |
+
pickle.dump(self, handle)
|
| 107 |
+
|
| 108 |
+
@classmethod
|
| 109 |
+
def load(cls, path: str | Path) -> "RuntimeScreeningRefiner":
|
| 110 |
+
with Path(path).open("rb") as handle:
|
| 111 |
+
return pickle.load(handle)
|
backend/app/ml/runtime_stack.py
CHANGED
|
@@ -8,11 +8,11 @@ from app.ml.archive_model import clamp
|
|
| 8 |
RUNTIME_STACK_VERSION = "archive-evidence-fusion-v4"
|
| 9 |
SourceHint = Literal["roi_original", "palpebral", "forniceal_palpebral"]
|
| 10 |
|
| 11 |
-
DEFAULT_SOURCE_THRESHOLDS: dict[SourceHint, float] = {
|
| 12 |
-
"roi_original": 0.
|
| 13 |
-
"palpebral": 0.65,
|
| 14 |
-
"forniceal_palpebral": 0.65,
|
| 15 |
-
}
|
| 16 |
|
| 17 |
DEFAULT_RISK_ARCHIVE_WEIGHTS: dict[SourceHint, float] = {
|
| 18 |
"roi_original": 0.55,
|
|
|
|
| 8 |
RUNTIME_STACK_VERSION = "archive-evidence-fusion-v4"
|
| 9 |
SourceHint = Literal["roi_original", "palpebral", "forniceal_palpebral"]
|
| 10 |
|
| 11 |
+
DEFAULT_SOURCE_THRESHOLDS: dict[SourceHint, float] = {
|
| 12 |
+
"roi_original": 0.495,
|
| 13 |
+
"palpebral": 0.65,
|
| 14 |
+
"forniceal_palpebral": 0.65,
|
| 15 |
+
}
|
| 16 |
|
| 17 |
DEFAULT_RISK_ARCHIVE_WEIGHTS: dict[SourceHint, float] = {
|
| 18 |
"roi_original": 0.55,
|
backend/app/schemas.py
CHANGED
|
@@ -66,6 +66,9 @@ def _coerce_boolean(value: object, *, allow_none: bool = False) -> bool | None:
|
|
| 66 |
# Request schemas
|
| 67 |
# ---------------------------------------------------------------------------
|
| 68 |
|
|
|
|
|
|
|
|
|
|
| 69 |
class SymptomInput(BaseModel):
|
| 70 |
"""
|
| 71 |
Self-reported symptoms submitted alongside an eye image.
|
|
@@ -174,6 +177,58 @@ class SymptomInput(BaseModel):
|
|
| 174 |
}
|
| 175 |
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
# ---------------------------------------------------------------------------
|
| 178 |
# Quality assessment
|
| 179 |
# ---------------------------------------------------------------------------
|
|
@@ -221,6 +276,32 @@ class QualityAssessment(BaseModel):
|
|
| 221 |
brightness_score: float = Field(ge=0.0, le=1.0, description="Mean luminance in [0, 1].")
|
| 222 |
contrast_score: float = Field(ge=0.0, le=1.0, description="Normalised RMS contrast.")
|
| 223 |
framing_score: float = Field(ge=0.0, description="Eye-region occupancy ratio.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
issues: list[QualityIssue] = Field(default_factory=list)
|
| 225 |
|
| 226 |
@cached_property
|
|
@@ -297,6 +378,10 @@ class PredictionResult(BaseModel):
|
|
| 297 |
model_source: ModelSource = Field(
|
| 298 |
description="Which model or pipeline produced this prediction."
|
| 299 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
@model_validator(mode="after")
|
| 302 |
def _confidence_uncertainty_consistent(self) -> "PredictionResult":
|
|
@@ -573,6 +658,82 @@ class ClinicalBrief(BaseModel):
|
|
| 573 |
)
|
| 574 |
|
| 575 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
class AnalysisMeta(BaseModel):
|
| 577 |
request_id: str = Field(description="Short request identifier copied from the API response headers.")
|
| 578 |
generated_at: str = Field(description="Local timestamp when the response payload was assembled.")
|
|
@@ -585,7 +746,7 @@ class AnalysisMeta(BaseModel):
|
|
| 585 |
description="Which inference path reached the final result."
|
| 586 |
)
|
| 587 |
guidance_source: GuidanceSource = Field(
|
| 588 |
-
description="Whether guidance came from
|
| 589 |
)
|
| 590 |
used_raw_frame_rescue: bool = Field(
|
| 591 |
description="True when the backend rescued a framing-limited case using the full-frame path."
|
|
@@ -603,10 +764,9 @@ class AnalysisMeta(BaseModel):
|
|
| 603 |
class GuidanceRuntimeStatus(BaseModel):
|
| 604 |
active_strategy: GuidanceSource
|
| 605 |
mistral_enabled: bool = False
|
| 606 |
-
qwen_enabled: bool = False # kept for backwards compat
|
| 607 |
client_ready: bool = False
|
| 608 |
api_key_configured: bool = False
|
| 609 |
-
|
| 610 |
provider: str | None = None
|
| 611 |
fallback_reason: str | None = None
|
| 612 |
last_provider_error: str | None = None
|
|
@@ -619,6 +779,20 @@ class ModelRuntimeStatus(BaseModel):
|
|
| 619 |
artifact_ready: bool = False
|
| 620 |
artifact_path: str | None = None
|
| 621 |
load_error: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 622 |
record_count: int | None = None
|
| 623 |
validation_accuracy: float | None = None
|
| 624 |
validation_f1: float | None = None
|
|
@@ -671,6 +845,14 @@ class AnalyzeResponse(BaseModel):
|
|
| 671 |
clinical_brief: ClinicalBrief
|
| 672 |
handoff_summary: HandoffSummary
|
| 673 |
analysis_meta: AnalysisMeta
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 674 |
symptoms: SymptomInput
|
| 675 |
language: str | None = Field(default=None, description="BCP-47 language tag or plain name.")
|
| 676 |
region: str | None = Field(default=None, description="Geographic region for localised guidance.")
|
|
|
|
| 66 |
# Request schemas
|
| 67 |
# ---------------------------------------------------------------------------
|
| 68 |
|
| 69 |
+
SexType = Literal["female", "male", "other", "not_specified"]
|
| 70 |
+
DietType = Literal["omnivore", "vegetarian", "vegan", "mixed", "not_specified"]
|
| 71 |
+
|
| 72 |
class SymptomInput(BaseModel):
|
| 73 |
"""
|
| 74 |
Self-reported symptoms submitted alongside an eye image.
|
|
|
|
| 177 |
}
|
| 178 |
|
| 179 |
|
| 180 |
+
# ---------------------------------------------------------------------------
|
| 181 |
+
# Intake context
|
| 182 |
+
# ---------------------------------------------------------------------------
|
| 183 |
+
|
| 184 |
+
class PatientProfileInput(BaseModel):
|
| 185 |
+
"""
|
| 186 |
+
Lightweight intake details that make the screening flow feel closer to a
|
| 187 |
+
real healthcare workflow without pretending to be a full medical record.
|
| 188 |
+
"""
|
| 189 |
+
|
| 190 |
+
model_config = ConfigDict(extra="forbid")
|
| 191 |
+
|
| 192 |
+
age: int | None = Field(
|
| 193 |
+
default=None,
|
| 194 |
+
ge=1,
|
| 195 |
+
le=120,
|
| 196 |
+
description="Approximate patient age in years, if provided.",
|
| 197 |
+
)
|
| 198 |
+
sex: SexType = Field(
|
| 199 |
+
default="not_specified",
|
| 200 |
+
description="Self-reported sex used only for screening context.",
|
| 201 |
+
)
|
| 202 |
+
diet_type: DietType = Field(
|
| 203 |
+
default="not_specified",
|
| 204 |
+
description="Self-reported diet pattern relevant to iron intake context.",
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
@field_validator("age", mode="before")
|
| 208 |
+
@classmethod
|
| 209 |
+
def _normalise_age(cls, value: object) -> int | None:
|
| 210 |
+
if value is None:
|
| 211 |
+
return None
|
| 212 |
+
if isinstance(value, str):
|
| 213 |
+
normalised = value.strip()
|
| 214 |
+
if not normalised:
|
| 215 |
+
return None
|
| 216 |
+
return int(normalised)
|
| 217 |
+
if isinstance(value, (int, float)):
|
| 218 |
+
return int(value)
|
| 219 |
+
raise ValueError("age must be an integer or null")
|
| 220 |
+
|
| 221 |
+
@field_validator("sex", "diet_type", mode="before")
|
| 222 |
+
@classmethod
|
| 223 |
+
def _normalise_intake_enum(cls, value: object) -> str:
|
| 224 |
+
if value is None:
|
| 225 |
+
return "not_specified"
|
| 226 |
+
if isinstance(value, str):
|
| 227 |
+
normalised = value.strip().lower()
|
| 228 |
+
return normalised or "not_specified"
|
| 229 |
+
raise ValueError("Expected a string value")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
# ---------------------------------------------------------------------------
|
| 233 |
# Quality assessment
|
| 234 |
# ---------------------------------------------------------------------------
|
|
|
|
| 276 |
brightness_score: float = Field(ge=0.0, le=1.0, description="Mean luminance in [0, 1].")
|
| 277 |
contrast_score: float = Field(ge=0.0, le=1.0, description="Normalised RMS contrast.")
|
| 278 |
framing_score: float = Field(ge=0.0, description="Eye-region occupancy ratio.")
|
| 279 |
+
lighting_score: float = Field(
|
| 280 |
+
default=0.0,
|
| 281 |
+
ge=0.0,
|
| 282 |
+
le=1.0,
|
| 283 |
+
description="Composite lighting quality score, where higher means more usable lighting.",
|
| 284 |
+
)
|
| 285 |
+
lighting_condition: str = Field(
|
| 286 |
+
default="balanced",
|
| 287 |
+
description="Lighting classification inferred from exposure, glare, shadows, and contrast.",
|
| 288 |
+
)
|
| 289 |
+
lighting_summary: str = Field(
|
| 290 |
+
default="Lighting details unavailable.",
|
| 291 |
+
description="Plain-language explanation of the current lighting condition and what it means for screening.",
|
| 292 |
+
)
|
| 293 |
+
glare_risk: float = Field(
|
| 294 |
+
default=0.0,
|
| 295 |
+
ge=0.0,
|
| 296 |
+
le=1.0,
|
| 297 |
+
description="Estimated risk that glare or clipped highlights are harming the capture.",
|
| 298 |
+
)
|
| 299 |
+
shadow_risk: float = Field(
|
| 300 |
+
default=0.0,
|
| 301 |
+
ge=0.0,
|
| 302 |
+
le=1.0,
|
| 303 |
+
description="Estimated risk that shadows or underexposure are hiding useful signal.",
|
| 304 |
+
)
|
| 305 |
issues: list[QualityIssue] = Field(default_factory=list)
|
| 306 |
|
| 307 |
@cached_property
|
|
|
|
| 378 |
model_source: ModelSource = Field(
|
| 379 |
description="Which model or pipeline produced this prediction."
|
| 380 |
)
|
| 381 |
+
confidence_breakdown: dict[str, float | bool | str] | None = Field(
|
| 382 |
+
default=None,
|
| 383 |
+
description="Decomposed confidence view covering capture quality, model stability, threshold stability, and guardrail effects.",
|
| 384 |
+
)
|
| 385 |
|
| 386 |
@model_validator(mode="after")
|
| 387 |
def _confidence_uncertainty_consistent(self) -> "PredictionResult":
|
|
|
|
| 658 |
)
|
| 659 |
|
| 660 |
|
| 661 |
+
WorkflowStageKey = Literal[
|
| 662 |
+
"image_quality_agent",
|
| 663 |
+
"screening_agent",
|
| 664 |
+
"triage_agent",
|
| 665 |
+
"guidance_agent",
|
| 666 |
+
]
|
| 667 |
+
WorkflowStageStatus = Literal["passed", "warning", "blocked", "complete"]
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
class PatientProfile(BaseModel):
|
| 671 |
+
patient_id: str = Field(description="Share-safe case identifier generated for this screening run.")
|
| 672 |
+
age: int | None = Field(default=None, description="Approximate patient age in years, if provided.")
|
| 673 |
+
sex: SexType = Field(description="Self-reported sex captured during intake.")
|
| 674 |
+
diet_type: DietType = Field(description="Self-reported diet pattern captured during intake.")
|
| 675 |
+
reported_symptoms: list[str] = Field(
|
| 676 |
+
default_factory=list,
|
| 677 |
+
description="Human-readable symptom labels captured during intake.",
|
| 678 |
+
)
|
| 679 |
+
summary: str = Field(description="Short patient-context summary for the workflow UI.")
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
class WorkflowStage(BaseModel):
|
| 683 |
+
key: WorkflowStageKey = Field(description="Stable workflow-stage identifier.")
|
| 684 |
+
agent_label: str = Field(description="User-facing module name, presented as an agent-like stage.")
|
| 685 |
+
title: str = Field(description="Short workflow stage title.")
|
| 686 |
+
status: WorkflowStageStatus = Field(description="Outcome of this stage for the current run.")
|
| 687 |
+
summary: str = Field(description="One-sentence explanation of what happened at this stage.")
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
class StructuredCaseImageQuality(BaseModel):
|
| 691 |
+
status: Literal["acceptable", "warning", "blocked"] = Field(
|
| 692 |
+
description="Image usability status for the final screening flow."
|
| 693 |
+
)
|
| 694 |
+
lighting_condition: str = Field(description="Lighting classification for the capture.")
|
| 695 |
+
lighting_score: Annotated[float, Field(ge=0.0, le=1.0)] = Field(
|
| 696 |
+
description="Composite lighting quality score for the case."
|
| 697 |
+
)
|
| 698 |
+
blur_detected: bool = Field(description="Whether the pipeline flagged blur as an issue.")
|
| 699 |
+
eye_region_visible: bool = Field(description="Whether the eye / conjunctiva region was adequately visible.")
|
| 700 |
+
primary_issue: str | None = Field(default=None, description="Most important quality issue, if any.")
|
| 701 |
+
warnings: list[str] = Field(default_factory=list, description="Non-blocking quality issue titles.")
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
class StructuredCaseScreeningResult(BaseModel):
|
| 705 |
+
risk_level: TriageBand = Field(description="Final triage band used as the case risk level.")
|
| 706 |
+
confidence: Annotated[float, Field(ge=0.0, le=1.0)] | None = Field(
|
| 707 |
+
default=None,
|
| 708 |
+
description="Final model confidence, when inference ran.",
|
| 709 |
+
)
|
| 710 |
+
reliability: ReliabilityFlag | None = Field(
|
| 711 |
+
default=None,
|
| 712 |
+
description="Reliability tier attached to the prediction, when inference ran.",
|
| 713 |
+
)
|
| 714 |
+
predicted_hemoglobin: float | None = Field(
|
| 715 |
+
default=None,
|
| 716 |
+
description="Estimated hemoglobin value in g/dL, when available.",
|
| 717 |
+
)
|
| 718 |
+
anemia_risk: Annotated[float, Field(ge=0.0, le=1.0)] | None = Field(
|
| 719 |
+
default=None,
|
| 720 |
+
description="Raw anemia-like risk score from the image model, when inference ran.",
|
| 721 |
+
)
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
class StructuredCaseRecord(BaseModel):
|
| 725 |
+
case_id: str = Field(description="Stable case identifier for export, demo, or interoperability surfaces.")
|
| 726 |
+
patient_id: str = Field(description="Patient identifier copied from the intake profile.")
|
| 727 |
+
age: int | None = Field(default=None, description="Approximate patient age in years, if provided.")
|
| 728 |
+
sex: SexType = Field(description="Self-reported sex captured during intake.")
|
| 729 |
+
diet_type: DietType = Field(description="Self-reported diet pattern captured during intake.")
|
| 730 |
+
symptoms: list[str] = Field(default_factory=list, description="Active symptoms captured for this case.")
|
| 731 |
+
image_quality: StructuredCaseImageQuality = Field(description="Structured image quality summary.")
|
| 732 |
+
screening_result: StructuredCaseScreeningResult = Field(description="Structured screening result summary.")
|
| 733 |
+
recommendation: str = Field(description="Primary next-step recommendation for this case.")
|
| 734 |
+
case_summary: str = Field(description="Short clinician-facing summary sentence.")
|
| 735 |
+
|
| 736 |
+
|
| 737 |
class AnalysisMeta(BaseModel):
|
| 738 |
request_id: str = Field(description="Short request identifier copied from the API response headers.")
|
| 739 |
generated_at: str = Field(description="Local timestamp when the response payload was assembled.")
|
|
|
|
| 746 |
description="Which inference path reached the final result."
|
| 747 |
)
|
| 748 |
guidance_source: GuidanceSource = Field(
|
| 749 |
+
description="Whether guidance came from Mistral or the rule-based fallback."
|
| 750 |
)
|
| 751 |
used_raw_frame_rescue: bool = Field(
|
| 752 |
description="True when the backend rescued a framing-limited case using the full-frame path."
|
|
|
|
| 764 |
class GuidanceRuntimeStatus(BaseModel):
|
| 765 |
active_strategy: GuidanceSource
|
| 766 |
mistral_enabled: bool = False
|
|
|
|
| 767 |
client_ready: bool = False
|
| 768 |
api_key_configured: bool = False
|
| 769 |
+
mistral_model: str | None = None
|
| 770 |
provider: str | None = None
|
| 771 |
fallback_reason: str | None = None
|
| 772 |
last_provider_error: str | None = None
|
|
|
|
| 779 |
artifact_ready: bool = False
|
| 780 |
artifact_path: str | None = None
|
| 781 |
load_error: str | None = None
|
| 782 |
+
runtime_calibration_ready: bool | None = None
|
| 783 |
+
runtime_calibration_method: str | None = None
|
| 784 |
+
runtime_calibrated_threshold: float | None = None
|
| 785 |
+
runtime_calibration_ece_before: float | None = None
|
| 786 |
+
runtime_calibration_ece_after: float | None = None
|
| 787 |
+
runtime_calibration_brier_before: float | None = None
|
| 788 |
+
runtime_calibration_brier_after: float | None = None
|
| 789 |
+
runtime_refiner_ready: bool | None = None
|
| 790 |
+
runtime_refiner_method: str | None = None
|
| 791 |
+
runtime_refined_threshold: float | None = None
|
| 792 |
+
runtime_refined_accuracy: float | None = None
|
| 793 |
+
runtime_refined_precision: float | None = None
|
| 794 |
+
runtime_refined_recall: float | None = None
|
| 795 |
+
runtime_refined_f1: float | None = None
|
| 796 |
record_count: int | None = None
|
| 797 |
validation_accuracy: float | None = None
|
| 798 |
validation_f1: float | None = None
|
|
|
|
| 845 |
clinical_brief: ClinicalBrief
|
| 846 |
handoff_summary: HandoffSummary
|
| 847 |
analysis_meta: AnalysisMeta
|
| 848 |
+
patient_profile: PatientProfile
|
| 849 |
+
workflow_stages: list[WorkflowStage] = Field(
|
| 850 |
+
description="Explicit multi-step screening workflow stages for this run.",
|
| 851 |
+
min_length=4,
|
| 852 |
+
)
|
| 853 |
+
structured_case: StructuredCaseRecord = Field(
|
| 854 |
+
description="FHIR-style structured case summary suitable for provider-facing views or export."
|
| 855 |
+
)
|
| 856 |
symptoms: SymptomInput
|
| 857 |
language: str | None = Field(default=None, description="BCP-47 language tag or plain name.")
|
| 858 |
region: str | None = Field(default=None, description="Geographic region for localised guidance.")
|
backend/app/services/clinical_brief.py
CHANGED
|
@@ -216,9 +216,9 @@ class ClinicalBriefService:
|
|
| 216 |
checks.append("The fallback rescue path was labeled transparently in the audit trail.")
|
| 217 |
if decision_audit.review_flags:
|
| 218 |
checks.append("Structured review flags were generated for follow-up and UI display.")
|
| 219 |
-
if guidance.source == "
|
| 220 |
checks.append(
|
| 221 |
-
"
|
| 222 |
)
|
| 223 |
else:
|
| 224 |
checks.append("Rule-based fallback guidance stayed grounded to the current result and symptoms.")
|
|
|
|
| 216 |
checks.append("The fallback rescue path was labeled transparently in the audit trail.")
|
| 217 |
if decision_audit.review_flags:
|
| 218 |
checks.append("Structured review flags were generated for follow-up and UI display.")
|
| 219 |
+
if guidance.source == "mistral":
|
| 220 |
checks.append(
|
| 221 |
+
"Mistral guidance was constrained to the screening result, uncertainty, symptoms, and locale context."
|
| 222 |
)
|
| 223 |
else:
|
| 224 |
checks.append("Rule-based fallback guidance stayed grounded to the current result and symptoms.")
|
backend/app/services/email_report.py
CHANGED
|
@@ -353,64 +353,252 @@ class EmailReportService:
|
|
| 353 |
def _build_plain_text(self, payload: EmailReportContent) -> str:
|
| 354 |
hb_line = self._hemoglobin_line(payload.predicted_hemoglobin)
|
| 355 |
risk_pct = round(payload.anemia_risk * 100)
|
|
|
|
|
|
|
| 356 |
return (
|
| 357 |
-
"AnemiaLens Screening Result Report\n"
|
| 358 |
-
"================================\n\n"
|
| 359 |
-
f"Triage Label: {payload.triage_label}\n"
|
| 360 |
-
f"Anemia Risk Score: {risk_pct}%\n"
|
| 361 |
-
f"{hb_line}\n\n"
|
| 362 |
-
"Summary\n"
|
| 363 |
-
"-------\n"
|
| 364 |
-
f"{
|
| 365 |
-
"
|
| 366 |
-
"---------\n"
|
| 367 |
-
f"{
|
| 368 |
-
"
|
| 369 |
-
"
|
|
|
|
|
|
|
|
|
|
| 370 |
)
|
| 371 |
|
| 372 |
-
def _build_html(self, payload: EmailReportContent) -> str:
|
| 373 |
-
hb_label, hb_value = self._hemoglobin_parts(payload.predicted_hemoglobin)
|
| 374 |
-
risk_pct = round(payload.anemia_risk * 100)
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
)
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
<
|
| 406 |
-
<
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
|
| 415 |
def _hemoglobin_parts(self, predicted_hemoglobin: float | None) -> tuple[str, str]:
|
| 416 |
if predicted_hemoglobin is None:
|
|
|
|
| 353 |
def _build_plain_text(self, payload: EmailReportContent) -> str:
|
| 354 |
hb_line = self._hemoglobin_line(payload.predicted_hemoglobin)
|
| 355 |
risk_pct = round(payload.anemia_risk * 100)
|
| 356 |
+
next_steps = "\n".join(f"- {step}" for step in self._recommended_steps(payload))
|
| 357 |
+
summary_line = self._email_result_story(payload)
|
| 358 |
return (
|
| 359 |
+
"AnemiaLens Screening Result Report\n"
|
| 360 |
+
"================================\n\n"
|
| 361 |
+
f"Triage Label: {payload.triage_label}\n"
|
| 362 |
+
f"Anemia Risk Score: {risk_pct}%\n"
|
| 363 |
+
f"{hb_line}\n\n"
|
| 364 |
+
"Summary\n"
|
| 365 |
+
"-------\n"
|
| 366 |
+
f"{summary_line}\n\n"
|
| 367 |
+
"Recommended Next Steps\n"
|
| 368 |
+
"----------------------\n"
|
| 369 |
+
f"{next_steps}\n\n"
|
| 370 |
+
"Important\n"
|
| 371 |
+
"---------\n"
|
| 372 |
+
f"{SCREENING_DISCLAIMER} Please confirm results with a clinical blood test (CBC).\n\n"
|
| 373 |
+
"AnemiaLens\n"
|
| 374 |
+
"https://anemia-lens.vercel.app\n"
|
| 375 |
)
|
| 376 |
|
| 377 |
+
def _build_html(self, payload: EmailReportContent) -> str:
|
| 378 |
+
hb_label, hb_value = self._hemoglobin_parts(payload.predicted_hemoglobin)
|
| 379 |
+
risk_pct = round(payload.anemia_risk * 100)
|
| 380 |
+
accent, accent_soft, accent_border, status_line = self._triage_theme(payload)
|
| 381 |
+
summary_line = self._email_result_story(payload)
|
| 382 |
+
detail_line = self._email_supporting_detail(payload)
|
| 383 |
+
steps_html = "".join(
|
| 384 |
+
f"""
|
| 385 |
+
<tr>
|
| 386 |
+
<td style="padding:0 0 10px 0;">
|
| 387 |
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
| 388 |
+
<tr>
|
| 389 |
+
<td width="28" valign="top" style="padding-top:2px;">
|
| 390 |
+
<div style="width:18px;height:18px;border-radius:999px;background:{accent_soft};border:1px solid {accent_border};font-size:11px;line-height:18px;text-align:center;color:{accent};font-weight:700;">•</div>
|
| 391 |
+
</td>
|
| 392 |
+
<td style="font-size:14px;line-height:1.6;color:#1e293b;">
|
| 393 |
+
{escape(step)}
|
| 394 |
+
</td>
|
| 395 |
+
</tr>
|
| 396 |
+
</table>
|
| 397 |
+
</td>
|
| 398 |
+
</tr>"""
|
| 399 |
+
for step in self._recommended_steps(payload)
|
| 400 |
+
)
|
| 401 |
+
return f"""<!DOCTYPE html>
|
| 402 |
+
<html lang="en">
|
| 403 |
+
<head>
|
| 404 |
+
<meta charset="utf-8" />
|
| 405 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 406 |
+
<title>AnemiaLens Screening Report</title>
|
| 407 |
+
</head>
|
| 408 |
+
<body style="margin:0;padding:0;background:#eef2f7;color:#0f172a;font-family:Arial,sans-serif;">
|
| 409 |
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#eef2f7;">
|
| 410 |
+
<tr>
|
| 411 |
+
<td align="center" style="padding:32px 16px;">
|
| 412 |
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border:1px solid #dbe4ee;border-radius:24px;overflow:hidden;box-shadow:0 20px 60px rgba(15,23,42,0.08);">
|
| 413 |
+
<tr>
|
| 414 |
+
<td style="padding:28px 32px;background:#0f172a;">
|
| 415 |
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
| 416 |
+
<tr>
|
| 417 |
+
<td align="left">
|
| 418 |
+
<div style="font-size:24px;font-weight:700;letter-spacing:-0.03em;color:#ffffff;">AnemiaLens</div>
|
| 419 |
+
<div style="margin-top:6px;font-size:13px;line-height:1.6;color:#94a3b8;">Smartphone-first screening summary, ready to review or share.</div>
|
| 420 |
+
</td>
|
| 421 |
+
<td align="right" valign="top">
|
| 422 |
+
<div style="display:inline-block;padding:8px 14px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;background:{accent_soft};border:1px solid {accent_border};color:{accent};">
|
| 423 |
+
{escape(payload.triage_label)}
|
| 424 |
+
</div>
|
| 425 |
+
</td>
|
| 426 |
+
</tr>
|
| 427 |
+
</table>
|
| 428 |
+
</td>
|
| 429 |
+
</tr>
|
| 430 |
+
<tr>
|
| 431 |
+
<td style="padding:28px 32px 12px 32px;">
|
| 432 |
+
<div style="font-size:18px;font-weight:700;line-height:1.4;color:#0f172a;">{escape(status_line)}</div>
|
| 433 |
+
<div style="margin-top:8px;font-size:14px;line-height:1.7;color:#475569;">
|
| 434 |
+
This email keeps the screening story short: what the result means, the estimated hemoglobin context, and what to do next.
|
| 435 |
+
</div>
|
| 436 |
+
</td>
|
| 437 |
+
</tr>
|
| 438 |
+
<tr>
|
| 439 |
+
<td style="padding:8px 32px 0 32px;">
|
| 440 |
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
| 441 |
+
<tr>
|
| 442 |
+
<td width="50%" style="padding-right:8px;padding-bottom:16px;">
|
| 443 |
+
<div style="padding:18px;border:1px solid #dbe4ee;border-radius:18px;background:#f8fafc;">
|
| 444 |
+
<div style="font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#64748b;margin-bottom:8px;">Anemia Risk Score</div>
|
| 445 |
+
<div style="font-size:30px;font-weight:700;color:{accent};line-height:1;">{risk_pct}%</div>
|
| 446 |
+
</div>
|
| 447 |
+
</td>
|
| 448 |
+
<td width="50%" style="padding-left:8px;padding-bottom:16px;">
|
| 449 |
+
<div style="padding:18px;border:1px solid #dbe4ee;border-radius:18px;background:#f8fafc;">
|
| 450 |
+
<div style="font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#64748b;margin-bottom:8px;">{escape(hb_label)}</div>
|
| 451 |
+
<div style="font-size:24px;font-weight:700;color:#0f172a;line-height:1.2;">{escape(hb_value)}</div>
|
| 452 |
+
</div>
|
| 453 |
+
</td>
|
| 454 |
+
</tr>
|
| 455 |
+
</table>
|
| 456 |
+
</td>
|
| 457 |
+
</tr>
|
| 458 |
+
<tr>
|
| 459 |
+
<td style="padding:8px 32px 0 32px;">
|
| 460 |
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #dbe4ee;border-radius:18px;background:#ffffff;">
|
| 461 |
+
<tr>
|
| 462 |
+
<td style="padding:20px;">
|
| 463 |
+
<div style="font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#64748b;margin-bottom:10px;">Why this result</div>
|
| 464 |
+
<div style="font-size:15px;line-height:1.75;color:#1e293b;font-weight:600;margin-bottom:12px;">
|
| 465 |
+
{escape(summary_line)}
|
| 466 |
+
</div>
|
| 467 |
+
<div style="font-size:13px;line-height:1.7;color:#475569;">
|
| 468 |
+
{escape(detail_line)}
|
| 469 |
+
</div>
|
| 470 |
+
</td>
|
| 471 |
+
</tr>
|
| 472 |
+
</table>
|
| 473 |
+
</td>
|
| 474 |
+
</tr>
|
| 475 |
+
<tr>
|
| 476 |
+
<td style="padding:18px 32px 0 32px;">
|
| 477 |
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #dbe4ee;border-radius:18px;background:#f8fafc;">
|
| 478 |
+
<tr>
|
| 479 |
+
<td style="padding:20px 20px 8px 20px;">
|
| 480 |
+
<div style="font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#64748b;margin-bottom:10px;">Recommended next steps</div>
|
| 481 |
+
</td>
|
| 482 |
+
</tr>
|
| 483 |
+
{steps_html}
|
| 484 |
+
</table>
|
| 485 |
+
</td>
|
| 486 |
+
</tr>
|
| 487 |
+
<tr>
|
| 488 |
+
<td style="padding:18px 32px 0 32px;">
|
| 489 |
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#fff7ed;border:1px solid #fed7aa;border-radius:18px;">
|
| 490 |
+
<tr>
|
| 491 |
+
<td style="padding:18px 20px;font-size:13px;line-height:1.7;color:#7c2d12;">
|
| 492 |
+
<strong>Important:</strong> {escape(SCREENING_DISCLAIMER)} Please confirm results with a clinical blood test (CBC).
|
| 493 |
+
</td>
|
| 494 |
+
</tr>
|
| 495 |
+
</table>
|
| 496 |
+
</td>
|
| 497 |
+
</tr>
|
| 498 |
+
<tr>
|
| 499 |
+
<td style="padding:24px 32px 32px 32px;">
|
| 500 |
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
| 501 |
+
<tr>
|
| 502 |
+
<td align="left" style="font-size:12px;line-height:1.7;color:#64748b;">
|
| 503 |
+
Sent by AnemiaLens for quick review and clinician handoff.
|
| 504 |
+
</td>
|
| 505 |
+
<td align="right">
|
| 506 |
+
<a href="https://anemia-lens.vercel.app" style="display:inline-block;padding:12px 18px;border-radius:999px;background:#0f172a;color:#ffffff;text-decoration:none;font-size:13px;font-weight:700;">Open AnemiaLens</a>
|
| 507 |
+
</td>
|
| 508 |
+
</tr>
|
| 509 |
+
</table>
|
| 510 |
+
</td>
|
| 511 |
+
</tr>
|
| 512 |
+
</table>
|
| 513 |
+
</td>
|
| 514 |
+
</tr>
|
| 515 |
+
</table>
|
| 516 |
+
</body>
|
| 517 |
+
</html>"""
|
| 518 |
+
|
| 519 |
+
def _triage_theme(self, payload: EmailReportContent) -> tuple[str, str, str, str]:
|
| 520 |
+
triage = payload.triage_label.lower()
|
| 521 |
+
if "high" in triage:
|
| 522 |
+
return (
|
| 523 |
+
"#dc2626",
|
| 524 |
+
"#fee2e2",
|
| 525 |
+
"#fecaca",
|
| 526 |
+
"High concern detected. Please prioritize follow-up quickly.",
|
| 527 |
+
)
|
| 528 |
+
if "moderate" in triage:
|
| 529 |
+
return (
|
| 530 |
+
"#d97706",
|
| 531 |
+
"#fef3c7",
|
| 532 |
+
"#fde68a",
|
| 533 |
+
"Moderate risk detected. A clinical follow-up is worth arranging soon.",
|
| 534 |
+
)
|
| 535 |
+
if "uncertain" in triage:
|
| 536 |
+
return (
|
| 537 |
+
"#7c3aed",
|
| 538 |
+
"#ede9fe",
|
| 539 |
+
"#ddd6fe",
|
| 540 |
+
"The scan was not strong enough for a confident call, so a retake is the safest next step.",
|
| 541 |
+
)
|
| 542 |
+
return (
|
| 543 |
+
"#059669",
|
| 544 |
+
"#dcfce7",
|
| 545 |
+
"#bbf7d0",
|
| 546 |
+
"No urgent concern was detected, but routine monitoring is still sensible.",
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
def _email_result_story(self, payload: EmailReportContent) -> str:
|
| 550 |
+
triage = payload.triage_label.lower()
|
| 551 |
+
if "high" in triage:
|
| 552 |
+
return (
|
| 553 |
+
"The screening found a strong low-hemoglobin pattern, so prompt clinical follow-up is the safest next step."
|
| 554 |
+
)
|
| 555 |
+
if "moderate" in triage:
|
| 556 |
+
return (
|
| 557 |
+
"The screening found a moderate low-hemoglobin pattern. It is not an emergency alert, but it is worth reviewing with a clinician soon."
|
| 558 |
+
)
|
| 559 |
+
if "uncertain" in triage:
|
| 560 |
+
return (
|
| 561 |
+
"The current image was not reliable enough for a confident screening call, so a cleaner retake or clinician review is safer than over-interpreting it."
|
| 562 |
+
)
|
| 563 |
+
return (
|
| 564 |
+
"The screening did not show a strong urgent low-hemoglobin pattern, though routine monitoring remains sensible."
|
| 565 |
+
)
|
| 566 |
+
|
| 567 |
+
def _email_supporting_detail(self, payload: EmailReportContent) -> str:
|
| 568 |
+
risk_pct = round(payload.anemia_risk * 100)
|
| 569 |
+
if payload.predicted_hemoglobin is None:
|
| 570 |
+
hb_detail = "The hemoglobin estimate was withheld because confidence was limited."
|
| 571 |
+
else:
|
| 572 |
+
hb_detail = f"The estimated hemoglobin for this run was {payload.predicted_hemoglobin:.1f} g/dL."
|
| 573 |
+
return (
|
| 574 |
+
f"This run produced a {risk_pct}% anemia-risk score. {hb_detail} Use the next steps below as a simple follow-up guide, not as a diagnosis."
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
def _recommended_steps(self, payload: EmailReportContent) -> list[str]:
|
| 578 |
+
triage = payload.triage_label.lower()
|
| 579 |
+
if "high" in triage:
|
| 580 |
+
return [
|
| 581 |
+
"Arrange a CBC blood test as soon as possible and avoid delaying clinical review.",
|
| 582 |
+
"Share this summary with a clinician or family member who can help coordinate care.",
|
| 583 |
+
"Seek urgent medical attention sooner if symptoms worsen or new warning signs appear.",
|
| 584 |
+
]
|
| 585 |
+
if "moderate" in triage:
|
| 586 |
+
return [
|
| 587 |
+
"Book a follow-up with a healthcare provider within 1-2 weeks and discuss confirmatory blood work.",
|
| 588 |
+
"Keep track of fatigue, dizziness, or shortness of breath if they continue.",
|
| 589 |
+
"Consider a clearer retake if the original scan had quality warnings.",
|
| 590 |
+
]
|
| 591 |
+
if "uncertain" in triage:
|
| 592 |
+
return [
|
| 593 |
+
"Retake the image in brighter, steadier lighting with the lower eyelid fully visible.",
|
| 594 |
+
"Use this summary only as a retake reminder, not as a final decision.",
|
| 595 |
+
"If symptoms are present, do not wait for a retake before speaking with a clinician.",
|
| 596 |
+
]
|
| 597 |
+
return [
|
| 598 |
+
"Maintain a balanced diet and monitor for any new or worsening symptoms.",
|
| 599 |
+
"Repeat screening in 3-6 months or sooner if your health changes.",
|
| 600 |
+
"Use this report as a simple summary if you want to discuss the result with a provider later.",
|
| 601 |
+
]
|
| 602 |
|
| 603 |
def _hemoglobin_parts(self, predicted_hemoglobin: float | None) -> tuple[str, str]:
|
| 604 |
if predicted_hemoglobin is None:
|
backend/app/services/guidance.py
CHANGED
|
@@ -23,12 +23,13 @@ _FIELD_LIMITS = {
|
|
| 23 |
"urgency_guidance": 280,
|
| 24 |
"food_advice": 300,
|
| 25 |
}
|
| 26 |
-
_UNSAFE_CLAIM_PATTERN = re.compile(
|
| 27 |
-
r"\b(definitely\s+(?:have|has|anemic|anaemic)|
|
| 28 |
-
r"you\s+(?:have|are)\s+(?:anemia|anaemia|anemic|anaemic)|"
|
| 29 |
-
r"
|
| 30 |
-
|
| 31 |
-
|
|
|
|
| 32 |
_SAFE_DIAGNOSTIC_CONTEXT_PATTERNS = (
|
| 33 |
re.compile(r"\bnot a diagnos(?:is|tic)\b", flags=re.IGNORECASE),
|
| 34 |
re.compile(r"\bnon-diagnostic\b", flags=re.IGNORECASE),
|
|
@@ -220,10 +221,80 @@ class GuidanceService:
|
|
| 220 |
"This is screening guidance, not a diagnosis."
|
| 221 |
)
|
| 222 |
|
| 223 |
-
def
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
triage_band: str,
|
| 228 |
predicted_hemoglobin: float | None,
|
| 229 |
confidence: float | None,
|
|
@@ -254,16 +325,16 @@ class GuidanceService:
|
|
| 254 |
"Authorization": f"Bearer {settings.mistral_api_key}",
|
| 255 |
"Content-Type": "application/json",
|
| 256 |
}
|
| 257 |
-
body = {
|
| 258 |
-
"model": self.mistral_model,
|
| 259 |
-
"messages": [
|
| 260 |
-
{"role": "system", "content": self.
|
| 261 |
-
{"role": "user", "content": self.
|
| 262 |
-
],
|
| 263 |
-
"max_tokens": self.guidance_max_tokens,
|
| 264 |
-
"temperature": 0.
|
| 265 |
-
"response_format": {"type": "json_object"},
|
| 266 |
-
}
|
| 267 |
log.info("POST %s model=%s max_tokens=%s", _MISTRAL_API_URL, self.mistral_model, self.guidance_max_tokens)
|
| 268 |
resp = _requests.post(_MISTRAL_API_URL, headers=headers, json=body, timeout=self.guidance_timeout)
|
| 269 |
log.info("Mistral HTTP %s", resp.status_code)
|
|
@@ -473,14 +544,13 @@ class GuidanceService:
|
|
| 473 |
def runtime_status(self) -> GuidanceRuntimeStatus:
|
| 474 |
provider_healthy = self._mistral_ready() and self._last_provider_error is None
|
| 475 |
active_strategy: Literal["mistral", "fallback"] = "mistral" if provider_healthy else "fallback"
|
| 476 |
-
return GuidanceRuntimeStatus(
|
| 477 |
-
active_strategy=active_strategy,
|
| 478 |
-
mistral_enabled=self.mistral_enabled,
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
)
|
|
|
|
| 23 |
"urgency_guidance": 280,
|
| 24 |
"food_advice": 300,
|
| 25 |
}
|
| 26 |
+
_UNSAFE_CLAIM_PATTERN = re.compile(
|
| 27 |
+
r"\b(definitely\s+(?:confirms?|have|has|anemic|anaemic)|confirm(?:ed|s)?\s+(?:anemia|anaemia)|"
|
| 28 |
+
r"you\s+(?:have|are)\s+(?:anemia|anaemia|anemic|anaemic)|"
|
| 29 |
+
r"diagnoses?\s+(?:anemia|anaemia|iron deficiency)|"
|
| 30 |
+
r"proves?\s+(?:anemia|anaemia)|proof\s+of\s+anemia)\b",
|
| 31 |
+
flags=re.IGNORECASE,
|
| 32 |
+
)
|
| 33 |
_SAFE_DIAGNOSTIC_CONTEXT_PATTERNS = (
|
| 34 |
re.compile(r"\bnot a diagnos(?:is|tic)\b", flags=re.IGNORECASE),
|
| 35 |
re.compile(r"\bnon-diagnostic\b", flags=re.IGNORECASE),
|
|
|
|
| 221 |
"This is screening guidance, not a diagnosis."
|
| 222 |
)
|
| 223 |
|
| 224 |
+
def _mistral_system_prompt(self) -> str:
|
| 225 |
+
return (
|
| 226 |
+
"You are Mistral, writing the guidance section for AnemiaLens, a smartphone anemia screening tool. "
|
| 227 |
+
"The system analyzes the inner lower eyelid, combines that signal with symptom input, and returns a screening band: low_risk, moderate_risk, high_concern, or uncertain_retake_needed. "
|
| 228 |
+
"This is screening only, never a diagnosis, and every answer must stay medically cautious.\n\n"
|
| 229 |
+
"Write like a calm clinician or health educator speaking to one person right after their screening. "
|
| 230 |
+
"Sound natural, specific, and grounded in the payload. "
|
| 231 |
+
"Do not sound like a marketing blurb, a lab report template, or a generic wellness article. "
|
| 232 |
+
"Use the hemoglobin estimate, risk score, symptom pattern, and reliability limits to explain what this case means.\n\n"
|
| 233 |
+
"STYLE RULES:\n"
|
| 234 |
+
"- Never say 'you have anemia', 'you are anemic', or any other diagnostic claim\n"
|
| 235 |
+
"- Prefer phrases like 'this screening leans toward', 'this result suggests', or 'this pattern points to'\n"
|
| 236 |
+
"- Mention uncertainty when confidence is limited or reliability is low\n"
|
| 237 |
+
"- Avoid stock phrases like 'calls for closer attention', 'maintain a balanced diet', or 'monitor symptoms' unless you also say why or when\n"
|
| 238 |
+
"- Never invent symptoms, treatments, lab values, or medical history not present in the payload\n"
|
| 239 |
+
"- Keep the tone human, direct, and reassuring without sounding casual\n\n"
|
| 240 |
+
"Return ONLY valid JSON with exactly these keys: explanation, urgency_guidance, food_advice, next_steps.\n"
|
| 241 |
+
"explanation: 2 or 3 sentences. Sentence 1 says what the screening leans toward. Sentence 2 explains why using the actual signal, symptoms, or risk. Sentence 3 is optional and should only be used to explain uncertainty or reassurance.\n"
|
| 242 |
+
"urgency_guidance: 1 or 2 sentences with a concrete follow-up window tied to the triage band.\n"
|
| 243 |
+
"food_advice: 1 sentence with concrete iron-supportive foods, adapted to the region when possible.\n"
|
| 244 |
+
"next_steps: array of 3 or 4 short actions that are specific, non-repetitive, and realistic.\n"
|
| 245 |
+
"No markdown, no extra keys, no preamble."
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
def _mistral_user_prompt(self, payload: dict[str, object]) -> str:
|
| 249 |
+
hb = payload.get("predicted_hemoglobin")
|
| 250 |
+
risk_pct = payload.get("prediction_risk_percent")
|
| 251 |
+
conf_pct = payload.get("confidence_percent")
|
| 252 |
+
uncertainty_pct = payload.get("uncertainty_percent")
|
| 253 |
+
reliability_flag = payload.get("reliability_flag") or "unknown"
|
| 254 |
+
band = payload.get("triage_band", "unknown")
|
| 255 |
+
label = payload.get("triage_label", "")
|
| 256 |
+
active_symptoms = payload.get("active_symptoms") or []
|
| 257 |
+
region = payload.get("region") or "not specified"
|
| 258 |
+
screening_text = payload.get("screening_text") or ""
|
| 259 |
+
screening_label = payload.get("screening_label") or "unknown"
|
| 260 |
+
|
| 261 |
+
hb_str = f"{hb} g/dL" if hb is not None else "not available"
|
| 262 |
+
if hb is not None:
|
| 263 |
+
if hb >= 12.0:
|
| 264 |
+
hb_context = "within normal range"
|
| 265 |
+
elif hb >= 10.0:
|
| 266 |
+
hb_context = "mildly below normal"
|
| 267 |
+
elif hb >= 8.0:
|
| 268 |
+
hb_context = "moderately below normal"
|
| 269 |
+
else:
|
| 270 |
+
hb_context = "severely below normal"
|
| 271 |
+
else:
|
| 272 |
+
hb_context = "unknown"
|
| 273 |
+
|
| 274 |
+
symptom_str = ", ".join(active_symptoms) if active_symptoms else "none reported"
|
| 275 |
+
|
| 276 |
+
return (
|
| 277 |
+
f"AnemiaLens Screening Result:\n"
|
| 278 |
+
f"- Hemoglobin estimate: {hb_str} ({hb_context})\n"
|
| 279 |
+
f"- Anemia risk score: {risk_pct}%\n"
|
| 280 |
+
f"- Screening label: {screening_label}\n"
|
| 281 |
+
f"- Model confidence: {conf_pct}%\n"
|
| 282 |
+
f"- Uncertainty: {uncertainty_pct}%\n"
|
| 283 |
+
f"- Reliability flag: {reliability_flag}\n"
|
| 284 |
+
f"- Triage band: {band} ({label})\n"
|
| 285 |
+
f"- Active symptoms: {symptom_str}\n"
|
| 286 |
+
f"- Region: {region}\n"
|
| 287 |
+
f"- Model screening text: {screening_text}\n\n"
|
| 288 |
+
"Write personalized guidance for this person based on the above. "
|
| 289 |
+
"Interpret what these findings mean instead of repeating them. "
|
| 290 |
+
"If reliability is limited, say that clearly in plain language. "
|
| 291 |
+
"Make it sound like a real clinician explaining a screening result, not a report template."
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
def _generate_mistral(
|
| 295 |
+
self,
|
| 296 |
+
payload: dict[str, object],
|
| 297 |
+
*,
|
| 298 |
triage_band: str,
|
| 299 |
predicted_hemoglobin: float | None,
|
| 300 |
confidence: float | None,
|
|
|
|
| 325 |
"Authorization": f"Bearer {settings.mistral_api_key}",
|
| 326 |
"Content-Type": "application/json",
|
| 327 |
}
|
| 328 |
+
body = {
|
| 329 |
+
"model": self.mistral_model,
|
| 330 |
+
"messages": [
|
| 331 |
+
{"role": "system", "content": self._mistral_system_prompt()},
|
| 332 |
+
{"role": "user", "content": self._mistral_user_prompt(payload)},
|
| 333 |
+
],
|
| 334 |
+
"max_tokens": self.guidance_max_tokens,
|
| 335 |
+
"temperature": 0.55,
|
| 336 |
+
"response_format": {"type": "json_object"},
|
| 337 |
+
}
|
| 338 |
log.info("POST %s model=%s max_tokens=%s", _MISTRAL_API_URL, self.mistral_model, self.guidance_max_tokens)
|
| 339 |
resp = _requests.post(_MISTRAL_API_URL, headers=headers, json=body, timeout=self.guidance_timeout)
|
| 340 |
log.info("Mistral HTTP %s", resp.status_code)
|
|
|
|
| 544 |
def runtime_status(self) -> GuidanceRuntimeStatus:
|
| 545 |
provider_healthy = self._mistral_ready() and self._last_provider_error is None
|
| 546 |
active_strategy: Literal["mistral", "fallback"] = "mistral" if provider_healthy else "fallback"
|
| 547 |
+
return GuidanceRuntimeStatus(
|
| 548 |
+
active_strategy=active_strategy,
|
| 549 |
+
mistral_enabled=self.mistral_enabled,
|
| 550 |
+
client_ready=self._mistral_ready(),
|
| 551 |
+
api_key_configured=self.api_key_configured,
|
| 552 |
+
mistral_model=self.mistral_model if self.mistral_enabled else None,
|
| 553 |
+
provider="mistral" if self.mistral_enabled else None,
|
| 554 |
+
fallback_reason=self._fallback_reason or (self._last_provider_error if not provider_healthy else None),
|
| 555 |
+
last_provider_error=self._last_provider_error,
|
| 556 |
+
)
|
|
|
backend/app/services/image_quality.py
CHANGED
|
@@ -42,8 +42,18 @@ class ImageQualityService:
|
|
| 42 |
center_contrast = float(feature_map["center_contrast"])
|
| 43 |
bright_region_ratio = float(feature_map["hist_bright"])
|
| 44 |
highlight_ratio = float(feature_map["hist_highlight"])
|
|
|
|
| 45 |
frame_score = float(framing_score(feature_map))
|
| 46 |
edge_blur = edge_blur_baseline(image)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
issues: list[QualityIssue] = []
|
| 49 |
|
|
@@ -94,6 +104,11 @@ class ImageQualityService:
|
|
| 94 |
brightness_score=round(brightness_score, 3),
|
| 95 |
contrast_score=round(contrast_score, 3),
|
| 96 |
framing_score=round(frame_score, 3),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
issues=issues,
|
| 98 |
)
|
| 99 |
return assessment, image
|
|
@@ -173,8 +188,8 @@ class ImageQualityService:
|
|
| 173 |
QualityIssue(
|
| 174 |
code="poor_lighting",
|
| 175 |
severity="blocking",
|
| 176 |
-
title=
|
| 177 |
-
message=
|
| 178 |
)
|
| 179 |
)
|
| 180 |
elif lighting_warn:
|
|
@@ -182,8 +197,8 @@ class ImageQualityService:
|
|
| 182 |
QualityIssue(
|
| 183 |
code="poor_lighting",
|
| 184 |
severity="warning",
|
| 185 |
-
title=
|
| 186 |
-
message=
|
| 187 |
)
|
| 188 |
)
|
| 189 |
|
|
@@ -229,10 +244,149 @@ class ImageQualityService:
|
|
| 229 |
brightness_score=round(brightness_score, 3),
|
| 230 |
contrast_score=round(contrast_score, 3),
|
| 231 |
framing_score=round(frame_score, 3),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
issues=issues,
|
| 233 |
)
|
| 234 |
return assessment, image
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
def _soften_salvageable_roi_blocks(
|
| 237 |
self,
|
| 238 |
issues: list[QualityIssue],
|
|
@@ -274,8 +428,7 @@ class ImageQualityService:
|
|
| 274 |
def allows_raw_frame_rescue(self, assessment: QualityAssessment) -> bool:
|
| 275 |
blocking_codes = {issue.code for issue in assessment.issues if issue.severity == "blocking"}
|
| 276 |
return bool(blocking_codes) and (
|
| 277 |
-
blocking_codes.issubset({"bad_framing", "eye_not_visible"})
|
| 278 |
-
or blocking_codes == {"poor_lighting"}
|
| 279 |
)
|
| 280 |
|
| 281 |
def build_raw_frame_rescue_assessment(self, assessment: QualityAssessment) -> QualityAssessment:
|
|
|
|
| 42 |
center_contrast = float(feature_map["center_contrast"])
|
| 43 |
bright_region_ratio = float(feature_map["hist_bright"])
|
| 44 |
highlight_ratio = float(feature_map["hist_highlight"])
|
| 45 |
+
dark_region_ratio = float(feature_map["hist_dark"])
|
| 46 |
frame_score = float(framing_score(feature_map))
|
| 47 |
edge_blur = edge_blur_baseline(image)
|
| 48 |
+
lighting_score, lighting_condition, lighting_summary, glare_risk, shadow_risk = self._lighting_intelligence(
|
| 49 |
+
brightness_score=brightness_score,
|
| 50 |
+
contrast_score=contrast_score,
|
| 51 |
+
center_brightness=center_brightness,
|
| 52 |
+
center_contrast=center_contrast,
|
| 53 |
+
bright_region_ratio=bright_region_ratio,
|
| 54 |
+
highlight_ratio=highlight_ratio,
|
| 55 |
+
dark_region_ratio=dark_region_ratio,
|
| 56 |
+
)
|
| 57 |
|
| 58 |
issues: list[QualityIssue] = []
|
| 59 |
|
|
|
|
| 104 |
brightness_score=round(brightness_score, 3),
|
| 105 |
contrast_score=round(contrast_score, 3),
|
| 106 |
framing_score=round(frame_score, 3),
|
| 107 |
+
lighting_score=round(lighting_score, 3),
|
| 108 |
+
lighting_condition=lighting_condition,
|
| 109 |
+
lighting_summary=lighting_summary,
|
| 110 |
+
glare_risk=round(glare_risk, 3),
|
| 111 |
+
shadow_risk=round(shadow_risk, 3),
|
| 112 |
issues=issues,
|
| 113 |
)
|
| 114 |
return assessment, image
|
|
|
|
| 188 |
QualityIssue(
|
| 189 |
code="poor_lighting",
|
| 190 |
severity="blocking",
|
| 191 |
+
title=self._lighting_issue_title(lighting_condition, blocking=True),
|
| 192 |
+
message=self._lighting_issue_message(lighting_condition, blocking=True),
|
| 193 |
)
|
| 194 |
)
|
| 195 |
elif lighting_warn:
|
|
|
|
| 197 |
QualityIssue(
|
| 198 |
code="poor_lighting",
|
| 199 |
severity="warning",
|
| 200 |
+
title=self._lighting_issue_title(lighting_condition, blocking=False),
|
| 201 |
+
message=self._lighting_issue_message(lighting_condition, blocking=False),
|
| 202 |
)
|
| 203 |
)
|
| 204 |
|
|
|
|
| 244 |
brightness_score=round(brightness_score, 3),
|
| 245 |
contrast_score=round(contrast_score, 3),
|
| 246 |
framing_score=round(frame_score, 3),
|
| 247 |
+
lighting_score=round(lighting_score, 3),
|
| 248 |
+
lighting_condition=lighting_condition,
|
| 249 |
+
lighting_summary=lighting_summary,
|
| 250 |
+
glare_risk=round(glare_risk, 3),
|
| 251 |
+
shadow_risk=round(shadow_risk, 3),
|
| 252 |
issues=issues,
|
| 253 |
)
|
| 254 |
return assessment, image
|
| 255 |
|
| 256 |
+
def _lighting_intelligence(
|
| 257 |
+
self,
|
| 258 |
+
*,
|
| 259 |
+
brightness_score: float,
|
| 260 |
+
contrast_score: float,
|
| 261 |
+
center_brightness: float,
|
| 262 |
+
center_contrast: float,
|
| 263 |
+
bright_region_ratio: float,
|
| 264 |
+
highlight_ratio: float,
|
| 265 |
+
dark_region_ratio: float,
|
| 266 |
+
) -> tuple[float, str, str, float, float]:
|
| 267 |
+
glare_risk = min(
|
| 268 |
+
1.0,
|
| 269 |
+
highlight_ratio * 7.5
|
| 270 |
+
+ max(0.0, bright_region_ratio - 0.18) * 1.9
|
| 271 |
+
+ max(0.0, center_brightness - 0.48) * 2.2,
|
| 272 |
+
)
|
| 273 |
+
shadow_risk = min(
|
| 274 |
+
1.0,
|
| 275 |
+
dark_region_ratio * 1.1
|
| 276 |
+
+ max(0.0, 0.18 - center_brightness) * 3.0
|
| 277 |
+
+ max(0.0, 0.1 - brightness_score) * 2.0,
|
| 278 |
+
)
|
| 279 |
+
exposure_balance = max(0.0, 1.0 - (abs(center_brightness - 0.28) / 0.24))
|
| 280 |
+
contrast_health = max(0.0, min(1.0, self._scaled(center_contrast, 0.06, 0.19)))
|
| 281 |
+
lighting_score = max(
|
| 282 |
+
0.0,
|
| 283 |
+
min(
|
| 284 |
+
1.0,
|
| 285 |
+
exposure_balance * 0.38
|
| 286 |
+
+ contrast_health * 0.27
|
| 287 |
+
+ (1.0 - glare_risk) * 0.2
|
| 288 |
+
+ (1.0 - shadow_risk) * 0.15,
|
| 289 |
+
),
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
if glare_risk >= 0.72:
|
| 293 |
+
return (
|
| 294 |
+
lighting_score,
|
| 295 |
+
"glare_heavy",
|
| 296 |
+
"Bright highlights or flash glare are washing out the eyelid surface, so the redness signal is less trustworthy.",
|
| 297 |
+
glare_risk,
|
| 298 |
+
shadow_risk,
|
| 299 |
+
)
|
| 300 |
+
if shadow_risk >= 0.72:
|
| 301 |
+
return (
|
| 302 |
+
lighting_score,
|
| 303 |
+
"shadow_heavy",
|
| 304 |
+
"Shadows are covering part of the eyelid, so the model may miss the true pallor signal.",
|
| 305 |
+
glare_risk,
|
| 306 |
+
shadow_risk,
|
| 307 |
+
)
|
| 308 |
+
if brightness_score < 0.12 or center_brightness < 0.16:
|
| 309 |
+
return (
|
| 310 |
+
lighting_score,
|
| 311 |
+
"dim",
|
| 312 |
+
"The capture is underexposed, which makes fine color differences harder to measure reliably.",
|
| 313 |
+
glare_risk,
|
| 314 |
+
shadow_risk,
|
| 315 |
+
)
|
| 316 |
+
if brightness_score > 0.46 or center_brightness > 0.52:
|
| 317 |
+
return (
|
| 318 |
+
lighting_score,
|
| 319 |
+
"overexposed",
|
| 320 |
+
"The image is brighter than ideal, so the conjunctiva can lose detail even without obvious glare.",
|
| 321 |
+
glare_risk,
|
| 322 |
+
shadow_risk,
|
| 323 |
+
)
|
| 324 |
+
if contrast_score < 0.12 or center_contrast < 0.08:
|
| 325 |
+
return (
|
| 326 |
+
lighting_score,
|
| 327 |
+
"flat_contrast",
|
| 328 |
+
"The lighting is too flat, so the conjunctival tissue boundaries are less distinct than ideal.",
|
| 329 |
+
glare_risk,
|
| 330 |
+
shadow_risk,
|
| 331 |
+
)
|
| 332 |
+
return (
|
| 333 |
+
lighting_score,
|
| 334 |
+
"balanced",
|
| 335 |
+
"Lighting is balanced enough for the model to read color and texture without strong glare or shadows.",
|
| 336 |
+
glare_risk,
|
| 337 |
+
shadow_risk,
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
def _lighting_issue_title(self, lighting_condition: str, *, blocking: bool) -> str:
|
| 341 |
+
if lighting_condition == "glare_heavy":
|
| 342 |
+
return "Glare is covering the eyelid" if blocking else "Glare is slightly affecting the scan"
|
| 343 |
+
if lighting_condition == "shadow_heavy":
|
| 344 |
+
return "Shadows are hiding the eyelid" if blocking else "Shadows are reducing clarity"
|
| 345 |
+
if lighting_condition == "dim":
|
| 346 |
+
return "Image is too dim" if blocking else "Lighting is a little dim"
|
| 347 |
+
if lighting_condition == "overexposed":
|
| 348 |
+
return "Image is overexposed" if blocking else "Lighting is a little bright"
|
| 349 |
+
if lighting_condition == "flat_contrast":
|
| 350 |
+
return "Image lacks contrast" if blocking else "Contrast could be stronger"
|
| 351 |
+
return "Lighting is not usable" if blocking else "Lighting could be better"
|
| 352 |
+
|
| 353 |
+
def _lighting_issue_message(self, lighting_condition: str, *, blocking: bool) -> str:
|
| 354 |
+
if lighting_condition == "glare_heavy":
|
| 355 |
+
return (
|
| 356 |
+
"Turn off flash, tilt away from shiny reflections, and use soft room light or window light."
|
| 357 |
+
if blocking
|
| 358 |
+
else "The model can try this image, but removing glare will improve reliability."
|
| 359 |
+
)
|
| 360 |
+
if lighting_condition == "shadow_heavy":
|
| 361 |
+
return (
|
| 362 |
+
"Face a window or room light so the eyelid is evenly lit without one side falling into shadow."
|
| 363 |
+
if blocking
|
| 364 |
+
else "The model can try this image, but even front lighting will improve reliability."
|
| 365 |
+
)
|
| 366 |
+
if lighting_condition == "dim":
|
| 367 |
+
return (
|
| 368 |
+
"Move to brighter light and keep the phone steady so the inner eyelid stays visible."
|
| 369 |
+
if blocking
|
| 370 |
+
else "The model can try this image, but brighter light will improve reliability."
|
| 371 |
+
)
|
| 372 |
+
if lighting_condition == "overexposed":
|
| 373 |
+
return (
|
| 374 |
+
"Step away from direct flash or strong overhead light so the eyelid texture is not washed out."
|
| 375 |
+
if blocking
|
| 376 |
+
else "The model can try this image, but slightly softer light will improve reliability."
|
| 377 |
+
)
|
| 378 |
+
if lighting_condition == "flat_contrast":
|
| 379 |
+
return (
|
| 380 |
+
"Use clearer side-neutral daylight or room lighting so the eyelid tissue stands out better."
|
| 381 |
+
if blocking
|
| 382 |
+
else "The model can try this image, but better contrast will improve reliability."
|
| 383 |
+
)
|
| 384 |
+
return (
|
| 385 |
+
"Use bright, even light without flash glare or heavy shadows."
|
| 386 |
+
if blocking
|
| 387 |
+
else "The model can try this image, but even light will improve reliability."
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
def _soften_salvageable_roi_blocks(
|
| 391 |
self,
|
| 392 |
issues: list[QualityIssue],
|
|
|
|
| 428 |
def allows_raw_frame_rescue(self, assessment: QualityAssessment) -> bool:
|
| 429 |
blocking_codes = {issue.code for issue in assessment.issues if issue.severity == "blocking"}
|
| 430 |
return bool(blocking_codes) and (
|
| 431 |
+
blocking_codes.issubset({"bad_framing", "eye_not_visible", "poor_lighting"})
|
|
|
|
| 432 |
)
|
| 433 |
|
| 434 |
def build_raw_frame_rescue_assessment(self, assessment: QualityAssessment) -> QualityAssessment:
|
backend/app/services/patient_case.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.schemas import (
|
| 4 |
+
GuidanceResult,
|
| 5 |
+
PatientProfile,
|
| 6 |
+
PatientProfileInput,
|
| 7 |
+
PredictionResult,
|
| 8 |
+
QualityAssessment,
|
| 9 |
+
StructuredCaseImageQuality,
|
| 10 |
+
StructuredCaseRecord,
|
| 11 |
+
StructuredCaseScreeningResult,
|
| 12 |
+
SymptomInput,
|
| 13 |
+
TriageResult,
|
| 14 |
+
WorkflowStage,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
_SYMPTOM_LABELS = {
|
| 18 |
+
"fatigue": "Fatigue",
|
| 19 |
+
"dizziness": "Dizziness",
|
| 20 |
+
"pale_skin": "Pale skin",
|
| 21 |
+
"shortness_of_breath": "Shortness of breath",
|
| 22 |
+
"heavy_menstrual_bleeding": "Heavy menstrual bleeding",
|
| 23 |
+
"poor_diet_low_iron": "Low iron intake",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class PatientCaseService:
|
| 28 |
+
def build_profile(
|
| 29 |
+
self,
|
| 30 |
+
request_id: str,
|
| 31 |
+
patient_input: PatientProfileInput,
|
| 32 |
+
symptoms: SymptomInput,
|
| 33 |
+
) -> PatientProfile:
|
| 34 |
+
patient_id = f"ANM-{request_id.upper()[-6:]}"
|
| 35 |
+
reported_symptoms = self._active_symptoms(symptoms)
|
| 36 |
+
descriptor = self._patient_descriptor(patient_input)
|
| 37 |
+
symptom_line = (
|
| 38 |
+
f"Reported symptoms: {self._join_human(reported_symptoms)}."
|
| 39 |
+
if reported_symptoms
|
| 40 |
+
else "No symptoms were reported in intake."
|
| 41 |
+
)
|
| 42 |
+
summary = f"{descriptor} {symptom_line}".strip()
|
| 43 |
+
|
| 44 |
+
return PatientProfile(
|
| 45 |
+
patient_id=patient_id,
|
| 46 |
+
age=patient_input.age,
|
| 47 |
+
sex=patient_input.sex,
|
| 48 |
+
diet_type=patient_input.diet_type,
|
| 49 |
+
reported_symptoms=reported_symptoms,
|
| 50 |
+
summary=summary,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
def build_workflow_stages(
|
| 54 |
+
self,
|
| 55 |
+
quality: QualityAssessment,
|
| 56 |
+
prediction: PredictionResult | None,
|
| 57 |
+
triage: TriageResult,
|
| 58 |
+
guidance: GuidanceResult,
|
| 59 |
+
symptoms: SymptomInput,
|
| 60 |
+
) -> list[WorkflowStage]:
|
| 61 |
+
return [
|
| 62 |
+
WorkflowStage(
|
| 63 |
+
key="image_quality_agent",
|
| 64 |
+
agent_label="Image Quality Agent",
|
| 65 |
+
title="Capture validation",
|
| 66 |
+
status=self._quality_status(quality),
|
| 67 |
+
summary=self._quality_summary(quality),
|
| 68 |
+
),
|
| 69 |
+
WorkflowStage(
|
| 70 |
+
key="screening_agent",
|
| 71 |
+
agent_label="Screening Agent",
|
| 72 |
+
title="Conjunctiva screening",
|
| 73 |
+
status=self._screening_status(quality, prediction),
|
| 74 |
+
summary=self._screening_summary(quality, prediction),
|
| 75 |
+
),
|
| 76 |
+
WorkflowStage(
|
| 77 |
+
key="triage_agent",
|
| 78 |
+
agent_label="Triage Agent",
|
| 79 |
+
title="Symptom + image fusion",
|
| 80 |
+
status="complete",
|
| 81 |
+
summary=self._triage_summary(triage, symptoms),
|
| 82 |
+
),
|
| 83 |
+
WorkflowStage(
|
| 84 |
+
key="guidance_agent",
|
| 85 |
+
agent_label="Guidance Agent",
|
| 86 |
+
title="Next-step guidance",
|
| 87 |
+
status="complete",
|
| 88 |
+
summary=self._guidance_summary(guidance),
|
| 89 |
+
),
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
def build_structured_case(
|
| 93 |
+
self,
|
| 94 |
+
request_id: str,
|
| 95 |
+
patient_profile: PatientProfile,
|
| 96 |
+
quality: QualityAssessment,
|
| 97 |
+
prediction: PredictionResult | None,
|
| 98 |
+
triage: TriageResult,
|
| 99 |
+
guidance: GuidanceResult,
|
| 100 |
+
symptoms: SymptomInput,
|
| 101 |
+
) -> StructuredCaseRecord:
|
| 102 |
+
active_symptoms = self._active_symptoms(symptoms)
|
| 103 |
+
primary_issue = quality.issues[0].title if quality.issues else None
|
| 104 |
+
warnings = [issue.title for issue in quality.warning_issues]
|
| 105 |
+
recommendation = guidance.next_steps[0] if guidance.next_steps else guidance.urgency_guidance
|
| 106 |
+
|
| 107 |
+
return StructuredCaseRecord(
|
| 108 |
+
case_id=f"CASE-{request_id.upper()[-6:]}",
|
| 109 |
+
patient_id=patient_profile.patient_id,
|
| 110 |
+
age=patient_profile.age,
|
| 111 |
+
sex=patient_profile.sex,
|
| 112 |
+
diet_type=patient_profile.diet_type,
|
| 113 |
+
symptoms=active_symptoms,
|
| 114 |
+
image_quality=StructuredCaseImageQuality(
|
| 115 |
+
status=self._structured_quality_status(quality),
|
| 116 |
+
lighting_condition=quality.lighting_condition,
|
| 117 |
+
lighting_score=quality.lighting_score,
|
| 118 |
+
blur_detected=any(issue.code == "blur_detected" for issue in quality.issues),
|
| 119 |
+
eye_region_visible=not any(issue.code == "eye_not_visible" for issue in quality.issues),
|
| 120 |
+
primary_issue=primary_issue,
|
| 121 |
+
warnings=warnings,
|
| 122 |
+
),
|
| 123 |
+
screening_result=StructuredCaseScreeningResult(
|
| 124 |
+
risk_level=triage.band,
|
| 125 |
+
confidence=prediction.confidence if prediction else None,
|
| 126 |
+
reliability=prediction.reliability_flag if prediction else None,
|
| 127 |
+
predicted_hemoglobin=prediction.predicted_hemoglobin if prediction else None,
|
| 128 |
+
anemia_risk=prediction.anemia_risk if prediction else None,
|
| 129 |
+
),
|
| 130 |
+
recommendation=recommendation,
|
| 131 |
+
case_summary=self._case_summary(triage, active_symptoms, prediction),
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
def _active_symptoms(self, symptoms: SymptomInput) -> list[str]:
|
| 135 |
+
return [
|
| 136 |
+
label
|
| 137 |
+
for field, label in _SYMPTOM_LABELS.items()
|
| 138 |
+
if getattr(symptoms, field) is True
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
def _patient_descriptor(self, patient_input: PatientProfileInput) -> str:
|
| 142 |
+
parts: list[str] = []
|
| 143 |
+
if patient_input.age is not None:
|
| 144 |
+
parts.append(f"{patient_input.age}-year-old")
|
| 145 |
+
if patient_input.sex != "not_specified":
|
| 146 |
+
parts.append(patient_input.sex.replace("_", " "))
|
| 147 |
+
descriptor = " ".join(parts).strip()
|
| 148 |
+
|
| 149 |
+
diet = (
|
| 150 |
+
f"{patient_input.diet_type.replace('_', ' ')} diet"
|
| 151 |
+
if patient_input.diet_type != "not_specified"
|
| 152 |
+
else None
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
if descriptor and diet:
|
| 156 |
+
return f"{descriptor.capitalize()} on a {diet}."
|
| 157 |
+
if descriptor:
|
| 158 |
+
return f"{descriptor.capitalize()}."
|
| 159 |
+
if diet:
|
| 160 |
+
return f"Intake recorded with a {diet}."
|
| 161 |
+
return "Basic intake context captured."
|
| 162 |
+
|
| 163 |
+
def _join_human(self, items: list[str]) -> str:
|
| 164 |
+
if not items:
|
| 165 |
+
return "none"
|
| 166 |
+
if len(items) == 1:
|
| 167 |
+
return items[0]
|
| 168 |
+
if len(items) == 2:
|
| 169 |
+
return f"{items[0]} and {items[1]}"
|
| 170 |
+
return f"{', '.join(items[:-1])}, and {items[-1]}"
|
| 171 |
+
|
| 172 |
+
def _quality_status(self, quality: QualityAssessment) -> str:
|
| 173 |
+
if not quality.passed:
|
| 174 |
+
return "blocked"
|
| 175 |
+
if quality.warning_issues:
|
| 176 |
+
return "warning"
|
| 177 |
+
return "passed"
|
| 178 |
+
|
| 179 |
+
def _quality_summary(self, quality: QualityAssessment) -> str:
|
| 180 |
+
if not quality.passed:
|
| 181 |
+
issue = quality.issues[0] if quality.issues else None
|
| 182 |
+
if issue is None:
|
| 183 |
+
return "The capture failed the safety gate and needs a retake before screening can continue."
|
| 184 |
+
return f"{issue.title} blocked the capture, so the workflow stayed retake-first."
|
| 185 |
+
if quality.warning_issues:
|
| 186 |
+
warnings = ", ".join(issue.title.lower() for issue in quality.warning_issues[:2])
|
| 187 |
+
return (
|
| 188 |
+
f"The image passed with {quality.lighting_condition.replace('_', ' ')} lighting, "
|
| 189 |
+
f"but warnings remained: {warnings}."
|
| 190 |
+
)
|
| 191 |
+
return (
|
| 192 |
+
f"The image passed the safety gate with {quality.lighting_condition.replace('_', ' ')} lighting "
|
| 193 |
+
"and a usable conjunctiva view."
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
def _screening_status(self, quality: QualityAssessment, prediction: PredictionResult | None) -> str:
|
| 197 |
+
if not quality.passed or prediction is None:
|
| 198 |
+
return "blocked"
|
| 199 |
+
if prediction.reliability_flag == "low":
|
| 200 |
+
return "warning"
|
| 201 |
+
return "passed"
|
| 202 |
+
|
| 203 |
+
def _screening_summary(self, quality: QualityAssessment, prediction: PredictionResult | None) -> str:
|
| 204 |
+
if not quality.passed or prediction is None:
|
| 205 |
+
return "Screening inference was skipped because the image quality gate did not allow a safe prediction."
|
| 206 |
+
hb_text = (
|
| 207 |
+
"hemoglobin estimate withheld"
|
| 208 |
+
if prediction.predicted_hemoglobin is None
|
| 209 |
+
else f"estimated hemoglobin {prediction.predicted_hemoglobin:.1f} g/dL"
|
| 210 |
+
)
|
| 211 |
+
return (
|
| 212 |
+
f"The screening model produced a {round(prediction.anemia_risk * 100)}% anemia-like signal, "
|
| 213 |
+
f"{hb_text}, and {round(prediction.confidence * 100)}% confidence."
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
def _triage_summary(self, triage: TriageResult, symptoms: SymptomInput) -> str:
|
| 217 |
+
symptom_count = symptoms.active_count
|
| 218 |
+
symptom_text = (
|
| 219 |
+
"no active symptoms"
|
| 220 |
+
if symptom_count == 0
|
| 221 |
+
else f"{symptom_count} symptom{'s' if symptom_count != 1 else ''}"
|
| 222 |
+
)
|
| 223 |
+
return (
|
| 224 |
+
f"The triage layer combined the image signal with {symptom_text} and assigned {triage.label.lower()}."
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
def _guidance_summary(self, guidance: GuidanceResult) -> str:
|
| 228 |
+
first_step = guidance.next_steps[0] if guidance.next_steps else guidance.urgency_guidance
|
| 229 |
+
source_label = "Mistral guidance" if guidance.source == "mistral" else "Rule-based guidance"
|
| 230 |
+
return f"{source_label} translated the case into a next step: {first_step}"
|
| 231 |
+
|
| 232 |
+
def _structured_quality_status(self, quality: QualityAssessment) -> str:
|
| 233 |
+
if not quality.passed:
|
| 234 |
+
return "blocked"
|
| 235 |
+
if quality.warning_issues:
|
| 236 |
+
return "warning"
|
| 237 |
+
return "acceptable"
|
| 238 |
+
|
| 239 |
+
def _case_summary(
|
| 240 |
+
self,
|
| 241 |
+
triage: TriageResult,
|
| 242 |
+
active_symptoms: list[str],
|
| 243 |
+
prediction: PredictionResult | None,
|
| 244 |
+
) -> str:
|
| 245 |
+
if prediction is None:
|
| 246 |
+
return "Case requires repeat image capture because the quality gate blocked a safe screening interpretation."
|
| 247 |
+
symptom_context = (
|
| 248 |
+
"with no additional symptom burden"
|
| 249 |
+
if not active_symptoms
|
| 250 |
+
else f"with reported symptoms including {self._join_human(active_symptoms)}"
|
| 251 |
+
)
|
| 252 |
+
return (
|
| 253 |
+
f"Patient shows {triage.label.lower()} based on the conjunctiva image signal {symptom_context}."
|
| 254 |
+
)
|
backend/app/services/prediction.py
CHANGED
|
@@ -1,191 +1,235 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
from typing import Literal
|
| 5 |
-
|
| 6 |
-
import numpy as np
|
| 7 |
from PIL import Image
|
| 8 |
|
| 9 |
-
from app.config import
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from app.ml.features import extract_eye_features
|
| 12 |
-
from app.ml.roi_confidence import RoiConfidenceScorer
|
| 13 |
from app.schemas import ModelRuntimeStatus, PredictionResult, QualityAssessment
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def clamp(value: float, lower: float = 0.0, upper: float = 1.0) -> float:
|
| 19 |
-
return max(lower, min(upper, value))
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def _runtime_stack_version() -> str:
|
| 23 |
-
from app.ml.runtime_stack import RUNTIME_STACK_VERSION
|
| 24 |
-
|
| 25 |
-
return RUNTIME_STACK_VERSION
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def _efficientnet_version() -> str:
|
| 29 |
-
from app.ml.efficientnet_model import EFFICIENTNET_VERSION
|
| 30 |
-
|
| 31 |
-
return EFFICIENTNET_VERSION
|
| 32 |
|
| 33 |
|
| 34 |
-
def
|
| 35 |
-
|
| 36 |
-
) -> float:
|
| 37 |
-
from app.ml.runtime_stack import decision_threshold_for_source
|
| 38 |
|
| 39 |
-
return
|
| 40 |
|
| 41 |
|
| 42 |
-
def
|
| 43 |
-
from app.ml.
|
| 44 |
|
| 45 |
-
return
|
| 46 |
|
| 47 |
|
| 48 |
def _predict_archive_model(
|
| 49 |
artifact: dict[str, object],
|
| 50 |
feature_map: dict[str, float],
|
| 51 |
-
*,
|
| 52 |
-
source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"]
|
| 53 |
-
) -> dict[str, float]:
|
| 54 |
-
from app.ml.archive_model import predict_with_archive_model
|
| 55 |
-
|
| 56 |
-
return predict_with_archive_model(artifact, feature_map, source_hint=source_hint)
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
)
|
| 89 |
|
| 90 |
|
| 91 |
class ScreeningPredictor:
|
| 92 |
def __init__(self, model_path: str | Path | None = None) -> None:
|
| 93 |
self.efficientnet_path = Path(DEFAULT_EFFICIENTNET_MODEL_PATH)
|
| 94 |
self.model_path = Path(model_path or DEFAULT_ARCHIVE_MODEL_PATH)
|
|
|
|
|
|
|
|
|
|
| 95 |
self.load_error: str | None = None
|
| 96 |
self.efficientnet_bundle: dict[str, object] | None = None
|
| 97 |
self.archive_model: dict[str, object] | None = None
|
|
|
|
|
|
|
| 98 |
self._archive_model_load_attempted = False
|
| 99 |
self._efficientnet_model_load_attempted = False
|
| 100 |
-
self.
|
| 101 |
-
self.
|
| 102 |
|
| 103 |
def preload(self) -> None:
|
| 104 |
self._ensure_archive_model_loaded()
|
| 105 |
-
|
|
|
|
|
|
|
| 106 |
self._ensure_efficientnet_model_loaded()
|
| 107 |
|
| 108 |
-
def predict(self, image: Image.Image, quality: QualityAssessment
|
| 109 |
-
prediction: dict[str, float] | None = None
|
| 110 |
-
model_source = "missing-model"
|
| 111 |
-
decision_threshold = 0.5
|
| 112 |
-
feature_map = extract_eye_features(image)
|
| 113 |
-
source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"] = "roi_original"
|
| 114 |
-
|
| 115 |
-
self._ensure_archive_model_loaded()
|
| 116 |
-
if
|
| 117 |
try:
|
| 118 |
-
# EfficientNet was trained for only 1 epoch (AUC ~0.56 = near-random).
|
| 119 |
-
# Blending it with the archive model degrades predictions.
|
| 120 |
-
# Skip it until a properly trained checkpoint is available.
|
| 121 |
efficientnet_secondary: dict[str, float] | None = None
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
prediction = _build_runtime_stack(
|
| 129 |
archive_prediction,
|
| 130 |
efficientnet_prediction=efficientnet_secondary,
|
| 131 |
source_hint=source_hint,
|
| 132 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
model_source = _runtime_stack_version()
|
| 134 |
-
decision_threshold = float(
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
if prediction is None:
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
model_source
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
# Symptoms push risk up: all symptoms (score=1.0) adds up to +0.30
|
| 175 |
-
symptom_risk_boost = symptom_score * 0.30
|
| 176 |
-
risk = float(np.clip(risk + symptom_risk_boost * (1.0 - risk), 0.0, 1.0))
|
| 177 |
-
# Symptoms lower Hb estimate: all symptoms → up to -2.5 g/dL
|
| 178 |
-
symptom_hb_penalty = symptom_score * 2.5
|
| 179 |
-
predicted_hemoglobin_raw = float(np.clip(predicted_hemoglobin_raw - symptom_hb_penalty, 6.0, 18.0))
|
| 180 |
-
predicted_hemoglobin = round(predicted_hemoglobin_raw, 2)
|
| 181 |
-
# Symptoms reduce uncertainty slightly (more signal available)
|
| 182 |
-
uncertainty = float(np.clip(uncertainty - symptom_score * 0.08, 0.05, 0.88))
|
| 183 |
-
quality_delta = 0.0
|
| 184 |
-
if quality.framing_score < 1.15:
|
| 185 |
-
quality_delta += 0.08
|
| 186 |
-
elif quality.framing_score >= 1.8:
|
| 187 |
-
quality_delta -= 0.06
|
| 188 |
-
elif quality.framing_score >= 1.45:
|
| 189 |
quality_delta -= 0.03
|
| 190 |
|
| 191 |
if quality.blur_score < 80:
|
|
@@ -201,125 +245,363 @@ class ScreeningPredictor:
|
|
| 201 |
quality_delta += 0.02
|
| 202 |
elif 0.09 <= quality.brightness_score <= 0.38:
|
| 203 |
quality_delta -= 0.03
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
risk=risk,
|
| 213 |
-
predicted_hemoglobin=predicted_hemoglobin,
|
| 214 |
-
feature_map=feature_map,
|
| 215 |
-
threshold=decision_threshold,
|
| 216 |
-
)
|
| 217 |
-
if guardrail_triggered:
|
| 218 |
-
uncertainty = max(uncertainty, 0.35)
|
| 219 |
-
|
| 220 |
-
confidence = clamp(1.0 - uncertainty)
|
| 221 |
-
reliability_flag = (
|
| 222 |
-
"high"
|
| 223 |
-
if uncertainty < 0.35 and quality.passed
|
| 224 |
-
else "medium"
|
| 225 |
-
if uncertainty < 0.55 and quality.passed
|
| 226 |
-
else "low"
|
| 227 |
-
)
|
| 228 |
-
predicted_hemoglobin = self._display_hemoglobin(predicted_hemoglobin, uncertainty)
|
| 229 |
-
screening_label, screening_text = self._screening_decision(
|
| 230 |
-
risk,
|
| 231 |
-
uncertainty,
|
| 232 |
-
decision_threshold,
|
| 233 |
-
predicted_hemoglobin=predicted_hemoglobin_raw,
|
| 234 |
-
signal_guardrail_triggered=guardrail_triggered,
|
| 235 |
-
)
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
confidence=round(confidence, 3),
|
| 241 |
-
uncertainty=round(uncertainty, 3),
|
| 242 |
-
reliability_flag=reliability_flag,
|
| 243 |
-
screening_label=screening_label,
|
| 244 |
-
screening_text=screening_text,
|
| 245 |
-
model_source=model_source,
|
| 246 |
-
)
|
| 247 |
|
| 248 |
-
def _load_calibrator(self) -> CompositeCalibrator:
|
| 249 |
try:
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
-
def _ensure_archive_model_loaded(self) -> None:
|
|
|
|
|
|
|
| 257 |
if self._archive_model_load_attempted:
|
| 258 |
-
return
|
| 259 |
-
self._archive_model_load_attempted = True
|
| 260 |
-
self.archive_model = self._load_archive_model()
|
| 261 |
-
|
| 262 |
-
def _ensure_efficientnet_model_loaded(self) -> None:
|
| 263 |
-
if self._efficientnet_model_load_attempted:
|
| 264 |
-
return
|
| 265 |
-
self._efficientnet_model_load_attempted = True
|
| 266 |
-
self.efficientnet_bundle = self._load_efficientnet_model()
|
| 267 |
-
|
| 268 |
-
def _load_efficientnet_model(self) -> dict[str, object] | None:
|
| 269 |
-
if not self.efficientnet_path.exists():
|
| 270 |
-
return None
|
| 271 |
-
try:
|
| 272 |
-
return _load_efficientnet_checkpoint_bundle(self.efficientnet_path)
|
| 273 |
-
except Exception as exc:
|
| 274 |
-
self.load_error = f"EfficientNet load failed: {type(exc).__name__}: {exc}"
|
| 275 |
return None
|
| 276 |
|
| 277 |
-
|
| 278 |
if not self.model_path.exists():
|
| 279 |
if self.efficientnet_bundle is None:
|
| 280 |
self.load_error = f"Model artifact not found at {self.model_path}"
|
| 281 |
return None
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
| 285 |
except Exception as exc:
|
| 286 |
if self.efficientnet_bundle is None:
|
| 287 |
self.load_error = f"{type(exc).__name__}: {exc}"
|
| 288 |
return None
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
def is_ready(self) -> bool:
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
-
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
-
def runtime_status(self) -> ModelRuntimeStatus:
|
| 302 |
-
archive_available = self.model_path.exists()
|
| 303 |
-
efficientnet_available = self.efficientnet_path.exists()
|
| 304 |
return ModelRuntimeStatus(
|
| 305 |
-
primary_model=
|
| 306 |
-
_runtime_stack_version()
|
| 307 |
-
if archive_available or self.archive_model is not None
|
| 308 |
-
else str(self.efficientnet_bundle.get("version", _efficientnet_version()))
|
| 309 |
-
if efficientnet_available or self.efficientnet_bundle is not None
|
| 310 |
-
else "missing-model"
|
| 311 |
-
),
|
| 312 |
deep_stack_loaded=False,
|
| 313 |
legacy_loaded=False,
|
| 314 |
-
artifact_ready=
|
| 315 |
-
artifact_path=
|
| 316 |
-
str(self.model_path)
|
| 317 |
-
if archive_available or self.archive_model is not None
|
| 318 |
-
else str(self.efficientnet_path)
|
| 319 |
-
if efficientnet_available or self.efficientnet_bundle is not None
|
| 320 |
-
else None
|
| 321 |
-
),
|
| 322 |
load_error=self.load_error,
|
|
|
|
|
|
|
| 323 |
)
|
| 324 |
|
| 325 |
def should_accept_raw_frame_rescue(self, prediction: PredictionResult) -> bool:
|
|
@@ -329,14 +611,31 @@ class ScreeningPredictor:
|
|
| 329 |
or self._accept_raw_frame_uncertain_rescue(prediction)
|
| 330 |
)
|
| 331 |
|
| 332 |
-
def _accept_raw_frame_positive_rescue(self, prediction: PredictionResult) -> bool:
|
| 333 |
-
|
| 334 |
-
prediction.
|
| 335 |
-
and prediction.
|
| 336 |
-
and prediction.
|
| 337 |
-
and prediction.
|
| 338 |
-
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
|
| 341 |
def _accept_raw_frame_negative_rescue(self, prediction: PredictionResult) -> bool:
|
| 342 |
hidden_hb_negative = (
|
|
@@ -362,14 +661,17 @@ class ScreeningPredictor:
|
|
| 362 |
prediction.screening_label == "uncertain"
|
| 363 |
and prediction.anemia_risk <= 0.32
|
| 364 |
and prediction.uncertainty <= 0.68
|
| 365 |
-
and (
|
|
|
|
|
|
|
|
|
|
| 366 |
)
|
| 367 |
|
| 368 |
-
def _screening_decision(
|
| 369 |
-
self,
|
| 370 |
-
risk: float,
|
| 371 |
-
uncertainty: float,
|
| 372 |
-
threshold: float = 0.5,
|
| 373 |
*,
|
| 374 |
predicted_hemoglobin: float | None = None,
|
| 375 |
signal_guardrail_triggered: bool = False,
|
|
@@ -382,94 +684,304 @@ class ScreeningPredictor:
|
|
| 382 |
margin = abs(risk - threshold)
|
| 383 |
mild_positive_conflict = (
|
| 384 |
predicted_hemoglobin is not None
|
| 385 |
-
and (
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
and predicted_hemoglobin > 13.0
|
| 389 |
-
and uncertainty >= 0.65
|
| 390 |
-
)
|
| 391 |
-
or (
|
| 392 |
-
threshold < 0.6
|
| 393 |
-
and threshold <= risk < (threshold + 0.13)
|
| 394 |
-
and predicted_hemoglobin >= 12.3
|
| 395 |
-
and uncertainty >= 0.52
|
| 396 |
-
)
|
| 397 |
-
)
|
| 398 |
)
|
| 399 |
if mild_positive_conflict:
|
| 400 |
return (
|
| 401 |
"uncertain",
|
| 402 |
"The screening signal is only mildly positive while the hemoglobin estimate stays near normal, so the safest interpretation is uncertain.",
|
| 403 |
)
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
high_suspicion_positive = (
|
| 407 |
predicted_hemoglobin is not None
|
| 408 |
and (
|
| 409 |
(
|
| 410 |
risk >= threshold
|
| 411 |
-
and predicted_hemoglobin <= 12.2
|
| 412 |
-
and uncertainty < 0.62
|
| 413 |
)
|
| 414 |
or (
|
| 415 |
-
|
| 416 |
-
and
|
|
|
|
| 417 |
and predicted_hemoglobin <= 12.4
|
| 418 |
and uncertainty < 0.57
|
| 419 |
)
|
| 420 |
or (
|
| 421 |
-
|
| 422 |
-
and
|
|
|
|
| 423 |
and predicted_hemoglobin <= 12.25
|
| 424 |
and uncertainty < 0.63
|
| 425 |
)
|
| 426 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
)
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
|
|
|
|
|
|
| 435 |
)
|
| 436 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
return (
|
| 438 |
"uncertain",
|
| 439 |
-
"The
|
| 440 |
)
|
| 441 |
-
if
|
| 442 |
return (
|
| 443 |
"anemia_likely",
|
| 444 |
-
"The screening model
|
| 445 |
)
|
| 446 |
-
if uncertainty >= 0.75 or (margin < 0.08 and uncertainty >= 0.45):
|
| 447 |
-
return (
|
| 448 |
-
"uncertain",
|
| 449 |
-
"The estimated hemoglobin trend is borderline or noisy, so the safest interpretation is uncertain.",
|
| 450 |
-
)
|
| 451 |
-
if risk >= threshold:
|
| 452 |
-
return (
|
| 453 |
-
"anemia_likely",
|
| 454 |
-
"The screening model estimates a lower-than-expected hemoglobin trend from the eye image.",
|
| 455 |
-
)
|
| 456 |
return (
|
| 457 |
"anemia_unlikely",
|
| 458 |
"The screening model does not estimate a strong low-hemoglobin trend from the eye image.",
|
| 459 |
)
|
| 460 |
|
| 461 |
-
def _display_hemoglobin(
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
if uncertainty >= 0.
|
| 467 |
-
return None
|
| 468 |
-
return round(clamp(predicted_hemoglobin, 6.0, 18.0), 2)
|
| 469 |
-
|
| 470 |
-
def
|
| 471 |
-
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
risk: float,
|
| 474 |
predicted_hemoglobin: float | None,
|
| 475 |
feature_map: dict[str, float],
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Literal
|
| 5 |
+
|
|
|
|
| 6 |
from PIL import Image
|
| 7 |
|
| 8 |
+
from app.config import (
|
| 9 |
+
DEFAULT_ARCHIVE_MODEL_PATH,
|
| 10 |
+
DEFAULT_EFFICIENTNET_MODEL_PATH,
|
| 11 |
+
DEFAULT_RUNTIME_CALIBRATOR_PATH,
|
| 12 |
+
DEFAULT_RUNTIME_REFINER_PATH,
|
| 13 |
+
settings,
|
| 14 |
+
)
|
| 15 |
+
from app.ml.archive_model import clamp
|
| 16 |
from app.ml.features import extract_eye_features
|
|
|
|
| 17 |
from app.schemas import ModelRuntimeStatus, PredictionResult, QualityAssessment
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _runtime_stack_version() -> str:
|
| 21 |
+
from app.ml.runtime_stack import RUNTIME_STACK_VERSION
|
| 22 |
+
|
| 23 |
+
return RUNTIME_STACK_VERSION
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _efficientnet_version() -> str:
|
| 27 |
+
from app.ml.efficientnet_model import EFFICIENTNET_VERSION
|
| 28 |
+
|
| 29 |
+
return EFFICIENTNET_VERSION
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _decision_threshold_for_source(
|
| 33 |
+
source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"],
|
| 34 |
+
) -> float:
|
| 35 |
+
from app.ml.runtime_stack import decision_threshold_for_source
|
| 36 |
+
|
| 37 |
+
return float(decision_threshold_for_source(source_hint))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _load_archive_model_artifact(path: Path) -> dict[str, object]:
|
| 41 |
+
from app.ml.archive_model import load_archive_model
|
| 42 |
|
| 43 |
+
return load_archive_model(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
+
def _load_runtime_risk_calibrator_artifact(path: Path):
|
| 47 |
+
from app.ml.runtime_calibration import RuntimeRiskCalibrator
|
|
|
|
|
|
|
| 48 |
|
| 49 |
+
return RuntimeRiskCalibrator.load(path)
|
| 50 |
|
| 51 |
|
| 52 |
+
def _load_runtime_screening_refiner_artifact(path: Path):
|
| 53 |
+
from app.ml.runtime_refinement import RuntimeScreeningRefiner
|
| 54 |
|
| 55 |
+
return RuntimeScreeningRefiner.load(path)
|
| 56 |
|
| 57 |
|
| 58 |
def _predict_archive_model(
|
| 59 |
artifact: dict[str, object],
|
| 60 |
feature_map: dict[str, float],
|
| 61 |
+
*,
|
| 62 |
+
source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"],
|
| 63 |
+
) -> dict[str, float]:
|
| 64 |
+
from app.ml.archive_model import predict_with_archive_model
|
| 65 |
+
|
| 66 |
+
return predict_with_archive_model(artifact, feature_map, source_hint=source_hint)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _build_runtime_stack(
|
| 70 |
+
archive_prediction: dict[str, float],
|
| 71 |
+
*,
|
| 72 |
+
efficientnet_prediction: dict[str, float] | None,
|
| 73 |
+
source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"],
|
| 74 |
+
) -> dict[str, float]:
|
| 75 |
+
from app.ml.runtime_stack import build_runtime_stack_prediction
|
| 76 |
+
|
| 77 |
+
return build_runtime_stack_prediction(
|
| 78 |
+
archive_prediction,
|
| 79 |
+
efficientnet_prediction=efficientnet_prediction,
|
| 80 |
+
source_hint=source_hint,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _load_efficientnet_checkpoint_bundle(path: Path) -> dict[str, object]:
|
| 85 |
+
from app.ml.efficientnet_model import load_efficientnet_checkpoint
|
| 86 |
+
|
| 87 |
+
return load_efficientnet_checkpoint(path)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _predict_efficientnet_bundle(
|
| 91 |
+
bundle: dict[str, object],
|
| 92 |
+
image: Image.Image,
|
| 93 |
+
*,
|
| 94 |
+
mc_passes: int,
|
| 95 |
+
) -> dict[str, float]:
|
| 96 |
+
from app.ml.efficientnet_model import predict_with_efficientnet_model
|
| 97 |
+
|
| 98 |
+
return predict_with_efficientnet_model(bundle, image, mc_passes=mc_passes)
|
| 99 |
|
| 100 |
|
| 101 |
class ScreeningPredictor:
|
| 102 |
def __init__(self, model_path: str | Path | None = None) -> None:
|
| 103 |
self.efficientnet_path = Path(DEFAULT_EFFICIENTNET_MODEL_PATH)
|
| 104 |
self.model_path = Path(model_path or DEFAULT_ARCHIVE_MODEL_PATH)
|
| 105 |
+
self.runtime_calibrator_path = Path(DEFAULT_RUNTIME_CALIBRATOR_PATH)
|
| 106 |
+
self.runtime_refiner_path = Path(DEFAULT_RUNTIME_REFINER_PATH)
|
| 107 |
+
self.enable_efficientnet_fallback = settings.enable_efficientnet_fallback
|
| 108 |
self.load_error: str | None = None
|
| 109 |
self.efficientnet_bundle: dict[str, object] | None = None
|
| 110 |
self.archive_model: dict[str, object] | None = None
|
| 111 |
+
self.runtime_risk_calibrator = None
|
| 112 |
+
self.runtime_screening_refiner = None
|
| 113 |
self._archive_model_load_attempted = False
|
| 114 |
self._efficientnet_model_load_attempted = False
|
| 115 |
+
self._runtime_risk_calibrator_load_attempted = False
|
| 116 |
+
self._runtime_screening_refiner_load_attempted = False
|
| 117 |
|
| 118 |
def preload(self) -> None:
|
| 119 |
self._ensure_archive_model_loaded()
|
| 120 |
+
self._ensure_runtime_risk_calibrator_loaded()
|
| 121 |
+
self._ensure_runtime_screening_refiner_loaded()
|
| 122 |
+
if self.enable_efficientnet_fallback:
|
| 123 |
self._ensure_efficientnet_model_loaded()
|
| 124 |
|
| 125 |
+
def predict(self, image: Image.Image, quality: QualityAssessment) -> PredictionResult:
|
| 126 |
+
prediction: dict[str, float] | None = None
|
| 127 |
+
model_source = "missing-model"
|
| 128 |
+
decision_threshold = 0.5
|
| 129 |
+
feature_map = extract_eye_features(image)
|
| 130 |
+
source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"] = "roi_original"
|
| 131 |
+
|
| 132 |
+
archive_model = self._ensure_archive_model_loaded()
|
| 133 |
+
if archive_model is not None:
|
| 134 |
try:
|
|
|
|
|
|
|
|
|
|
| 135 |
efficientnet_secondary: dict[str, float] | None = None
|
| 136 |
+
if self.enable_efficientnet_fallback:
|
| 137 |
+
efficientnet_bundle = self._ensure_efficientnet_model_loaded()
|
| 138 |
+
if efficientnet_bundle is not None:
|
| 139 |
+
try:
|
| 140 |
+
efficientnet_secondary = _predict_efficientnet_bundle(
|
| 141 |
+
efficientnet_bundle,
|
| 142 |
+
image,
|
| 143 |
+
mc_passes=4,
|
| 144 |
+
)
|
| 145 |
+
except Exception:
|
| 146 |
+
efficientnet_secondary = None
|
| 147 |
+
|
| 148 |
+
archive_prediction = _predict_archive_model(
|
| 149 |
+
archive_model,
|
| 150 |
+
feature_map,
|
| 151 |
+
source_hint=source_hint,
|
| 152 |
+
)
|
| 153 |
prediction = _build_runtime_stack(
|
| 154 |
archive_prediction,
|
| 155 |
efficientnet_prediction=efficientnet_secondary,
|
| 156 |
source_hint=source_hint,
|
| 157 |
)
|
| 158 |
+
runtime_risk_calibrator = self._ensure_runtime_risk_calibrator_loaded()
|
| 159 |
+
if runtime_risk_calibrator is not None:
|
| 160 |
+
raw_runtime_risk = float(prediction["anemia_risk"])
|
| 161 |
+
prediction["raw_anemia_risk"] = raw_runtime_risk
|
| 162 |
+
prediction["calibrated_anemia_risk"] = runtime_risk_calibrator.calibrate(
|
| 163 |
+
raw_runtime_risk,
|
| 164 |
+
source_hint=source_hint,
|
| 165 |
+
)
|
| 166 |
+
prediction["calibration_method"] = runtime_risk_calibrator.method
|
| 167 |
model_source = _runtime_stack_version()
|
| 168 |
+
decision_threshold = float(
|
| 169 |
+
prediction.get(
|
| 170 |
+
"decision_threshold",
|
| 171 |
+
_decision_threshold_for_source(source_hint),
|
| 172 |
+
)
|
| 173 |
+
)
|
| 174 |
+
except Exception as exc:
|
| 175 |
+
self.load_error = f"Archive inference failed: {type(exc).__name__}: {exc}"
|
| 176 |
+
|
| 177 |
+
if prediction is None and self.enable_efficientnet_fallback:
|
| 178 |
+
efficientnet_bundle = self._ensure_efficientnet_model_loaded()
|
| 179 |
+
if efficientnet_bundle is not None:
|
| 180 |
+
try:
|
| 181 |
+
prediction = _predict_efficientnet_bundle(
|
| 182 |
+
efficientnet_bundle,
|
| 183 |
+
image,
|
| 184 |
+
mc_passes=4,
|
| 185 |
+
)
|
| 186 |
+
model_source = str(
|
| 187 |
+
efficientnet_bundle.get("version", _efficientnet_version())
|
| 188 |
+
)
|
| 189 |
+
decision_threshold = float(
|
| 190 |
+
prediction.get("decision_threshold", 0.5)
|
| 191 |
+
)
|
| 192 |
+
except Exception as exc:
|
| 193 |
+
self.load_error = (
|
| 194 |
+
f"EfficientNet inference failed: {type(exc).__name__}: {exc}"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
if prediction is None:
|
| 198 |
+
return PredictionResult(
|
| 199 |
+
anemia_risk=0.5,
|
| 200 |
+
predicted_hemoglobin=None,
|
| 201 |
+
confidence=0.0,
|
| 202 |
+
uncertainty=1.0,
|
| 203 |
+
reliability_flag="low",
|
| 204 |
+
screening_label="uncertain",
|
| 205 |
+
screening_text="No screening model artifact is available yet, so the safest result is uncertain.",
|
| 206 |
+
model_source="missing-model",
|
| 207 |
+
confidence_breakdown={
|
| 208 |
+
"capture_quality": 0.0,
|
| 209 |
+
"model_stability": 0.0,
|
| 210 |
+
"threshold_stability": 0.0,
|
| 211 |
+
"guardrail_applied": False,
|
| 212 |
+
"lighting_condition": quality.lighting_condition,
|
| 213 |
+
"glare_risk": round(quality.glare_risk, 3),
|
| 214 |
+
"shadow_risk": round(quality.shadow_risk, 3),
|
| 215 |
+
"summary": "No model artifact is available, so the confidence story is unavailable.",
|
| 216 |
+
},
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
risk = float(prediction["anemia_risk"])
|
| 220 |
+
raw_uncertainty = float(prediction["uncertainty"])
|
| 221 |
+
uncertainty = raw_uncertainty
|
| 222 |
+
predicted_hemoglobin_raw = float(prediction["predicted_hemoglobin"])
|
| 223 |
+
predicted_hemoglobin = round(predicted_hemoglobin_raw, 2)
|
| 224 |
+
calibrated_risk = float(prediction.get("calibrated_anemia_risk", risk))
|
| 225 |
+
capture_quality_score = self._capture_quality_score(quality)
|
| 226 |
+
model_stability = clamp(1.0 - raw_uncertainty, 0.0, 1.0)
|
| 227 |
+
quality_delta = 0.0
|
| 228 |
+
if quality.framing_score < 1.15:
|
| 229 |
+
quality_delta += 0.08
|
| 230 |
+
elif quality.framing_score >= 1.8:
|
| 231 |
+
quality_delta -= 0.06
|
| 232 |
+
elif quality.framing_score >= 1.45:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
quality_delta -= 0.03
|
| 234 |
|
| 235 |
if quality.blur_score < 80:
|
|
|
|
| 245 |
quality_delta += 0.02
|
| 246 |
elif 0.09 <= quality.brightness_score <= 0.38:
|
| 247 |
quality_delta -= 0.03
|
| 248 |
+
|
| 249 |
+
if quality.contrast_score < 0.12:
|
| 250 |
+
quality_delta += 0.04
|
| 251 |
+
elif quality.contrast_score >= 0.18:
|
| 252 |
+
quality_delta -= 0.02
|
| 253 |
+
|
| 254 |
+
if quality.lighting_score < 0.38:
|
| 255 |
+
quality_delta += 0.08
|
| 256 |
+
elif quality.lighting_score < 0.6:
|
| 257 |
+
quality_delta += 0.03
|
| 258 |
+
elif quality.lighting_score >= 0.8:
|
| 259 |
+
quality_delta -= 0.03
|
| 260 |
+
|
| 261 |
+
if quality.glare_risk > 0.65:
|
| 262 |
+
quality_delta += 0.05
|
| 263 |
+
elif quality.glare_risk > 0.35:
|
| 264 |
+
quality_delta += 0.02
|
| 265 |
+
|
| 266 |
+
if quality.shadow_risk > 0.65:
|
| 267 |
+
quality_delta += 0.05
|
| 268 |
+
elif quality.shadow_risk > 0.35:
|
| 269 |
+
quality_delta += 0.02
|
| 270 |
+
|
| 271 |
+
if quality.lighting_condition in {"glare_heavy", "shadow_heavy"}:
|
| 272 |
+
quality_delta += 0.12
|
| 273 |
+
elif quality.lighting_condition in {"overexposed", "flat_contrast"}:
|
| 274 |
+
quality_delta += 0.05
|
| 275 |
+
elif quality.lighting_condition == "dim":
|
| 276 |
+
quality_delta += 0.02
|
| 277 |
+
|
| 278 |
+
negative_case_confidence_bonus = self._negative_case_confidence_bonus(
|
| 279 |
+
risk=risk,
|
| 280 |
+
threshold=decision_threshold,
|
| 281 |
+
predicted_hemoglobin=predicted_hemoglobin_raw,
|
| 282 |
+
quality=quality,
|
| 283 |
+
capture_quality_score=capture_quality_score,
|
| 284 |
+
model_stability=model_stability,
|
| 285 |
+
)
|
| 286 |
+
if self._is_clear_negative_case(
|
| 287 |
+
risk=risk,
|
| 288 |
+
threshold=decision_threshold,
|
| 289 |
+
predicted_hemoglobin=predicted_hemoglobin_raw,
|
| 290 |
+
quality=quality,
|
| 291 |
+
capture_quality_score=capture_quality_score,
|
| 292 |
+
):
|
| 293 |
+
quality_delta = min(quality_delta, 0.12)
|
| 294 |
+
elif (
|
| 295 |
+
risk < decision_threshold
|
| 296 |
+
and predicted_hemoglobin_raw >= 12.8
|
| 297 |
+
and quality.passed
|
| 298 |
+
and capture_quality_score >= 0.42
|
| 299 |
+
):
|
| 300 |
+
quality_delta = min(quality_delta, 0.16)
|
| 301 |
+
|
| 302 |
+
uncertainty = clamp(
|
| 303 |
+
uncertainty + quality_delta - negative_case_confidence_bonus,
|
| 304 |
+
0.05,
|
| 305 |
+
0.88,
|
| 306 |
+
)
|
| 307 |
+
guardrail_triggered = self._dark_signal_guardrail(
|
| 308 |
+
risk=risk,
|
| 309 |
+
predicted_hemoglobin=predicted_hemoglobin,
|
| 310 |
+
feature_map=feature_map,
|
| 311 |
+
threshold=decision_threshold,
|
| 312 |
+
)
|
| 313 |
+
if guardrail_triggered:
|
| 314 |
+
uncertainty = max(uncertainty, 0.35)
|
| 315 |
+
|
| 316 |
+
predicted_hemoglobin = self._display_hemoglobin(
|
| 317 |
+
predicted_hemoglobin, uncertainty
|
| 318 |
+
)
|
| 319 |
+
base_screening_label, base_screening_text = self._screening_decision(
|
| 320 |
+
risk,
|
| 321 |
+
uncertainty,
|
| 322 |
+
decision_threshold,
|
| 323 |
+
predicted_hemoglobin=predicted_hemoglobin_raw,
|
| 324 |
+
signal_guardrail_triggered=guardrail_triggered,
|
| 325 |
+
)
|
| 326 |
+
runtime_screening_refiner = self._ensure_runtime_screening_refiner_loaded()
|
| 327 |
+
refined_risk = risk
|
| 328 |
+
if runtime_screening_refiner is not None:
|
| 329 |
+
refined_risk = runtime_screening_refiner.refine(
|
| 330 |
+
base_anemia_risk=risk,
|
| 331 |
+
uncertainty=uncertainty,
|
| 332 |
+
predicted_hemoglobin=predicted_hemoglobin,
|
| 333 |
+
quality=quality,
|
| 334 |
+
base_likely=(base_screening_label == "anemia_likely"),
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
threshold_stability = clamp(
|
| 338 |
+
max(
|
| 339 |
+
abs(risk - decision_threshold),
|
| 340 |
+
abs(calibrated_risk - decision_threshold),
|
| 341 |
+
abs(refined_risk - decision_threshold),
|
| 342 |
+
)
|
| 343 |
+
/ 0.18,
|
| 344 |
+
0.0,
|
| 345 |
+
1.0,
|
| 346 |
+
)
|
| 347 |
+
signal_strength = clamp(
|
| 348 |
+
abs(refined_risk - decision_threshold) / 0.22,
|
| 349 |
+
0.0,
|
| 350 |
+
1.0,
|
| 351 |
+
)
|
| 352 |
+
confidence = self._decision_confidence(
|
| 353 |
+
quality=quality,
|
| 354 |
+
uncertainty=uncertainty,
|
| 355 |
+
capture_quality_score=capture_quality_score,
|
| 356 |
+
model_stability=model_stability,
|
| 357 |
+
threshold_stability=threshold_stability,
|
| 358 |
+
signal_strength=signal_strength,
|
| 359 |
+
guardrail_triggered=guardrail_triggered,
|
| 360 |
+
)
|
| 361 |
+
uncertainty = min(
|
| 362 |
+
uncertainty,
|
| 363 |
+
clamp(1.05 - confidence, 0.05, 1.0),
|
| 364 |
+
)
|
| 365 |
+
clear_negative_case = self._is_clear_negative_case(
|
| 366 |
+
risk=refined_risk,
|
| 367 |
+
threshold=decision_threshold,
|
| 368 |
+
predicted_hemoglobin=predicted_hemoglobin_raw,
|
| 369 |
+
quality=quality,
|
| 370 |
+
capture_quality_score=capture_quality_score,
|
| 371 |
+
)
|
| 372 |
+
severe_lighting_case = (
|
| 373 |
+
quality.lighting_condition in {"glare_heavy", "shadow_heavy"}
|
| 374 |
+
or quality.glare_risk > 0.65
|
| 375 |
+
or quality.shadow_risk > 0.65
|
| 376 |
+
)
|
| 377 |
+
reliability_flag = (
|
| 378 |
+
"low"
|
| 379 |
+
if (guardrail_triggered and severe_lighting_case)
|
| 380 |
+
else "high"
|
| 381 |
+
if (
|
| 382 |
+
(
|
| 383 |
+
uncertainty < 0.2
|
| 384 |
+
and quality.passed
|
| 385 |
+
and capture_quality_score >= 0.7
|
| 386 |
+
and threshold_stability >= 0.25
|
| 387 |
+
)
|
| 388 |
+
or (
|
| 389 |
+
clear_negative_case
|
| 390 |
+
and uncertainty < 0.38
|
| 391 |
+
and threshold_stability >= 0.62
|
| 392 |
+
)
|
| 393 |
+
)
|
| 394 |
+
else "medium"
|
| 395 |
+
if (
|
| 396 |
+
(
|
| 397 |
+
uncertainty < 0.35
|
| 398 |
+
and quality.passed
|
| 399 |
+
and capture_quality_score >= 0.5
|
| 400 |
+
)
|
| 401 |
+
or (
|
| 402 |
+
clear_negative_case
|
| 403 |
+
and uncertainty < 0.52
|
| 404 |
+
and quality.passed
|
| 405 |
+
and capture_quality_score >= 0.4
|
| 406 |
+
)
|
| 407 |
+
)
|
| 408 |
+
else "low"
|
| 409 |
+
)
|
| 410 |
+
if (
|
| 411 |
+
reliability_flag == "low"
|
| 412 |
+
and quality.passed
|
| 413 |
+
and not severe_lighting_case
|
| 414 |
+
and not guardrail_triggered
|
| 415 |
+
and confidence >= 0.68
|
| 416 |
+
and capture_quality_score >= 0.72
|
| 417 |
+
and threshold_stability >= 0.72
|
| 418 |
+
):
|
| 419 |
+
reliability_flag = "medium"
|
| 420 |
+
confidence_breakdown = {
|
| 421 |
+
"capture_quality": round(capture_quality_score, 3),
|
| 422 |
+
"model_stability": round(model_stability, 3),
|
| 423 |
+
"threshold_stability": round(threshold_stability, 3),
|
| 424 |
+
"signal_strength": round(signal_strength, 3),
|
| 425 |
+
"guardrail_applied": guardrail_triggered,
|
| 426 |
+
"calibration_applied": bool(prediction.get("calibration_method")),
|
| 427 |
+
"calibration_method": str(prediction.get("calibration_method", "none")),
|
| 428 |
+
"refinement_applied": runtime_screening_refiner is not None,
|
| 429 |
+
"refinement_method": (
|
| 430 |
+
getattr(runtime_screening_refiner, "method", "none")
|
| 431 |
+
if runtime_screening_refiner is not None
|
| 432 |
+
else "none"
|
| 433 |
+
),
|
| 434 |
+
"raw_anemia_risk": round(
|
| 435 |
+
float(prediction.get("raw_anemia_risk", risk)),
|
| 436 |
+
3,
|
| 437 |
+
),
|
| 438 |
+
"calibrated_anemia_risk": round(
|
| 439 |
+
float(prediction.get("calibrated_anemia_risk", risk)),
|
| 440 |
+
3,
|
| 441 |
+
),
|
| 442 |
+
"refined_anemia_risk": round(refined_risk, 3),
|
| 443 |
+
"decision_threshold": round(decision_threshold, 3),
|
| 444 |
+
"base_screening_label": base_screening_label,
|
| 445 |
+
"lighting_condition": quality.lighting_condition,
|
| 446 |
+
"glare_risk": round(quality.glare_risk, 3),
|
| 447 |
+
"shadow_risk": round(quality.shadow_risk, 3),
|
| 448 |
+
"summary": self._confidence_summary(
|
| 449 |
+
quality=quality,
|
| 450 |
+
capture_quality_score=capture_quality_score,
|
| 451 |
+
model_stability=model_stability,
|
| 452 |
+
threshold_stability=threshold_stability,
|
| 453 |
+
guardrail_triggered=guardrail_triggered,
|
| 454 |
+
risk=refined_risk,
|
| 455 |
+
threshold=decision_threshold,
|
| 456 |
+
predicted_hemoglobin=predicted_hemoglobin_raw,
|
| 457 |
+
),
|
| 458 |
+
}
|
| 459 |
+
screening_label, screening_text = self._screening_decision(
|
| 460 |
+
refined_risk,
|
| 461 |
+
uncertainty,
|
| 462 |
+
decision_threshold,
|
| 463 |
+
predicted_hemoglobin=predicted_hemoglobin_raw,
|
| 464 |
+
signal_guardrail_triggered=guardrail_triggered,
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
+
return PredictionResult(
|
| 468 |
+
anemia_risk=round(refined_risk, 3),
|
| 469 |
+
predicted_hemoglobin=predicted_hemoglobin,
|
| 470 |
+
confidence=round(confidence, 3),
|
| 471 |
+
uncertainty=round(uncertainty, 3),
|
| 472 |
+
reliability_flag=reliability_flag,
|
| 473 |
+
screening_label=screening_label,
|
| 474 |
+
screening_text=screening_text,
|
| 475 |
+
model_source=model_source,
|
| 476 |
+
confidence_breakdown=confidence_breakdown,
|
| 477 |
+
)
|
| 478 |
|
| 479 |
+
def _ensure_efficientnet_model_loaded(self) -> dict[str, object] | None:
|
| 480 |
+
if not self.enable_efficientnet_fallback:
|
| 481 |
+
return None
|
| 482 |
+
if self.efficientnet_bundle is not None:
|
| 483 |
+
return self.efficientnet_bundle
|
| 484 |
+
if self._efficientnet_model_load_attempted:
|
| 485 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
|
| 487 |
+
self._efficientnet_model_load_attempted = True
|
| 488 |
+
if not self.efficientnet_path.exists():
|
| 489 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
|
|
|
|
| 491 |
try:
|
| 492 |
+
self.efficientnet_bundle = _load_efficientnet_checkpoint_bundle(
|
| 493 |
+
self.efficientnet_path
|
| 494 |
+
)
|
| 495 |
+
return self.efficientnet_bundle
|
| 496 |
+
except Exception as exc:
|
| 497 |
+
if self.archive_model is None:
|
| 498 |
+
self.load_error = f"EfficientNet load failed: {type(exc).__name__}: {exc}"
|
| 499 |
+
return None
|
| 500 |
|
| 501 |
+
def _ensure_archive_model_loaded(self) -> dict[str, object] | None:
|
| 502 |
+
if self.archive_model is not None:
|
| 503 |
+
return self.archive_model
|
| 504 |
if self._archive_model_load_attempted:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
return None
|
| 506 |
|
| 507 |
+
self._archive_model_load_attempted = True
|
| 508 |
if not self.model_path.exists():
|
| 509 |
if self.efficientnet_bundle is None:
|
| 510 |
self.load_error = f"Model artifact not found at {self.model_path}"
|
| 511 |
return None
|
| 512 |
+
|
| 513 |
+
try:
|
| 514 |
+
self.archive_model = _load_archive_model_artifact(self.model_path)
|
| 515 |
+
if self.archive_model is not None:
|
| 516 |
+
self.load_error = None
|
| 517 |
+
return self.archive_model
|
| 518 |
except Exception as exc:
|
| 519 |
if self.efficientnet_bundle is None:
|
| 520 |
self.load_error = f"{type(exc).__name__}: {exc}"
|
| 521 |
return None
|
| 522 |
|
| 523 |
+
def _ensure_runtime_risk_calibrator_loaded(self):
|
| 524 |
+
runtime_risk_calibrator = getattr(self, "runtime_risk_calibrator", None)
|
| 525 |
+
if runtime_risk_calibrator is not None:
|
| 526 |
+
return runtime_risk_calibrator
|
| 527 |
+
if getattr(self, "_runtime_risk_calibrator_load_attempted", False):
|
| 528 |
+
return None
|
| 529 |
+
|
| 530 |
+
self._runtime_risk_calibrator_load_attempted = True
|
| 531 |
+
path = getattr(self, "runtime_calibrator_path", Path(DEFAULT_RUNTIME_CALIBRATOR_PATH))
|
| 532 |
+
if not path.exists():
|
| 533 |
+
return None
|
| 534 |
+
|
| 535 |
+
try:
|
| 536 |
+
self.runtime_risk_calibrator = _load_runtime_risk_calibrator_artifact(path)
|
| 537 |
+
return self.runtime_risk_calibrator
|
| 538 |
+
except Exception:
|
| 539 |
+
return None
|
| 540 |
+
|
| 541 |
+
def _ensure_runtime_screening_refiner_loaded(self):
|
| 542 |
+
runtime_screening_refiner = getattr(self, "runtime_screening_refiner", None)
|
| 543 |
+
if runtime_screening_refiner is not None:
|
| 544 |
+
return runtime_screening_refiner
|
| 545 |
+
if getattr(self, "_runtime_screening_refiner_load_attempted", False):
|
| 546 |
+
return None
|
| 547 |
+
|
| 548 |
+
self._runtime_screening_refiner_load_attempted = True
|
| 549 |
+
path = getattr(self, "runtime_refiner_path", Path(DEFAULT_RUNTIME_REFINER_PATH))
|
| 550 |
+
if not path.exists():
|
| 551 |
+
return None
|
| 552 |
+
|
| 553 |
+
try:
|
| 554 |
+
self.runtime_screening_refiner = _load_runtime_screening_refiner_artifact(path)
|
| 555 |
+
return self.runtime_screening_refiner
|
| 556 |
+
except Exception:
|
| 557 |
+
return None
|
| 558 |
+
|
| 559 |
def is_ready(self) -> bool:
|
| 560 |
+
archive_ready = self.archive_model is not None or self.model_path.exists()
|
| 561 |
+
efficientnet_ready = self.efficientnet_bundle is not None or (
|
| 562 |
+
self.enable_efficientnet_fallback and self.efficientnet_path.exists()
|
| 563 |
+
)
|
| 564 |
+
return archive_ready or efficientnet_ready
|
| 565 |
+
|
| 566 |
+
def is_loaded(self) -> bool:
|
| 567 |
+
return self.archive_model is not None or self.efficientnet_bundle is not None
|
| 568 |
+
|
| 569 |
+
def runtime_status(self) -> ModelRuntimeStatus:
|
| 570 |
+
archive_ready = self.archive_model is not None or self.model_path.exists()
|
| 571 |
+
efficientnet_ready = self.efficientnet_bundle is not None or (
|
| 572 |
+
self.enable_efficientnet_fallback and self.efficientnet_path.exists()
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
if archive_ready:
|
| 576 |
+
primary_model = _runtime_stack_version()
|
| 577 |
+
artifact_path = str(self.model_path)
|
| 578 |
+
elif efficientnet_ready:
|
| 579 |
+
primary_model = (
|
| 580 |
+
str(self.efficientnet_bundle.get("version", _efficientnet_version()))
|
| 581 |
+
if self.efficientnet_bundle is not None
|
| 582 |
+
else _efficientnet_version()
|
| 583 |
+
)
|
| 584 |
+
artifact_path = str(self.efficientnet_path)
|
| 585 |
+
else:
|
| 586 |
+
primary_model = "missing-model"
|
| 587 |
+
artifact_path = None
|
| 588 |
|
| 589 |
+
runtime_calibration_ready = self.runtime_risk_calibrator is not None or (
|
| 590 |
+
getattr(self, "runtime_calibrator_path", Path(DEFAULT_RUNTIME_CALIBRATOR_PATH)).exists()
|
| 591 |
+
)
|
| 592 |
+
runtime_refiner_ready = self.runtime_screening_refiner is not None or (
|
| 593 |
+
getattr(self, "runtime_refiner_path", Path(DEFAULT_RUNTIME_REFINER_PATH)).exists()
|
| 594 |
+
)
|
| 595 |
|
|
|
|
|
|
|
|
|
|
| 596 |
return ModelRuntimeStatus(
|
| 597 |
+
primary_model=primary_model,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
deep_stack_loaded=False,
|
| 599 |
legacy_loaded=False,
|
| 600 |
+
artifact_ready=archive_ready or efficientnet_ready,
|
| 601 |
+
artifact_path=artifact_path,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
load_error=self.load_error,
|
| 603 |
+
runtime_calibration_ready=runtime_calibration_ready,
|
| 604 |
+
runtime_refiner_ready=runtime_refiner_ready,
|
| 605 |
)
|
| 606 |
|
| 607 |
def should_accept_raw_frame_rescue(self, prediction: PredictionResult) -> bool:
|
|
|
|
| 611 |
or self._accept_raw_frame_uncertain_rescue(prediction)
|
| 612 |
)
|
| 613 |
|
| 614 |
+
def _accept_raw_frame_positive_rescue(self, prediction: PredictionResult) -> bool:
|
| 615 |
+
strong_hb_positive = (
|
| 616 |
+
prediction.predicted_hemoglobin is not None
|
| 617 |
+
and prediction.anemia_risk >= 0.8
|
| 618 |
+
and prediction.predicted_hemoglobin <= 11.2
|
| 619 |
+
and prediction.uncertainty <= 0.5
|
| 620 |
+
)
|
| 621 |
+
strong_signal_only_positive = (
|
| 622 |
+
prediction.predicted_hemoglobin is None
|
| 623 |
+
and prediction.anemia_risk >= 0.7
|
| 624 |
+
and prediction.uncertainty <= 0.8
|
| 625 |
+
)
|
| 626 |
+
overwhelming_signal_only_positive = (
|
| 627 |
+
prediction.predicted_hemoglobin is None
|
| 628 |
+
and prediction.anemia_risk >= 0.84
|
| 629 |
+
and prediction.uncertainty <= 0.9
|
| 630 |
+
)
|
| 631 |
+
return (
|
| 632 |
+
prediction.screening_label == "anemia_likely"
|
| 633 |
+
and (
|
| 634 |
+
strong_hb_positive
|
| 635 |
+
or strong_signal_only_positive
|
| 636 |
+
or overwhelming_signal_only_positive
|
| 637 |
+
)
|
| 638 |
+
)
|
| 639 |
|
| 640 |
def _accept_raw_frame_negative_rescue(self, prediction: PredictionResult) -> bool:
|
| 641 |
hidden_hb_negative = (
|
|
|
|
| 661 |
prediction.screening_label == "uncertain"
|
| 662 |
and prediction.anemia_risk <= 0.32
|
| 663 |
and prediction.uncertainty <= 0.68
|
| 664 |
+
and (
|
| 665 |
+
prediction.predicted_hemoglobin is None
|
| 666 |
+
or prediction.predicted_hemoglobin >= 12.8
|
| 667 |
+
)
|
| 668 |
)
|
| 669 |
|
| 670 |
+
def _screening_decision(
|
| 671 |
+
self,
|
| 672 |
+
risk: float,
|
| 673 |
+
uncertainty: float,
|
| 674 |
+
threshold: float = 0.5,
|
| 675 |
*,
|
| 676 |
predicted_hemoglobin: float | None = None,
|
| 677 |
signal_guardrail_triggered: bool = False,
|
|
|
|
| 684 |
margin = abs(risk - threshold)
|
| 685 |
mild_positive_conflict = (
|
| 686 |
predicted_hemoglobin is not None
|
| 687 |
+
and threshold <= risk < (threshold + 0.14)
|
| 688 |
+
and predicted_hemoglobin >= 12.2
|
| 689 |
+
and uncertainty >= 0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
)
|
| 691 |
if mild_positive_conflict:
|
| 692 |
return (
|
| 693 |
"uncertain",
|
| 694 |
"The screening signal is only mildly positive while the hemoglobin estimate stays near normal, so the safest interpretation is uncertain.",
|
| 695 |
)
|
| 696 |
+
strict_runtime_borderline = (
|
| 697 |
+
threshold >= 0.6
|
| 698 |
+
and predicted_hemoglobin is not None
|
| 699 |
+
and risk < (threshold + 0.07)
|
| 700 |
+
and predicted_hemoglobin >= 11.5
|
| 701 |
+
and uncertainty >= 0.55
|
| 702 |
+
)
|
| 703 |
+
if strict_runtime_borderline:
|
| 704 |
+
return (
|
| 705 |
+
"uncertain",
|
| 706 |
+
"The signal sits too close to the operating threshold for this confidence level, so the safest interpretation is uncertain.",
|
| 707 |
+
)
|
| 708 |
high_suspicion_positive = (
|
| 709 |
predicted_hemoglobin is not None
|
| 710 |
and (
|
| 711 |
(
|
| 712 |
risk >= threshold
|
| 713 |
+
and predicted_hemoglobin <= (11.4 if threshold >= 0.6 else 12.2)
|
| 714 |
+
and uncertainty < (0.56 if threshold >= 0.6 else 0.62)
|
| 715 |
)
|
| 716 |
or (
|
| 717 |
+
threshold < 0.6
|
| 718 |
+
and
|
| 719 |
+
(threshold - 0.02) <= risk < threshold
|
| 720 |
and predicted_hemoglobin <= 12.4
|
| 721 |
and uncertainty < 0.57
|
| 722 |
)
|
| 723 |
or (
|
| 724 |
+
threshold < 0.6
|
| 725 |
+
and
|
| 726 |
+
(threshold - 0.05) <= risk < threshold
|
| 727 |
and predicted_hemoglobin <= 12.25
|
| 728 |
and uncertainty < 0.63
|
| 729 |
)
|
| 730 |
)
|
| 731 |
+
)
|
| 732 |
+
if high_suspicion_positive:
|
| 733 |
+
return (
|
| 734 |
+
"anemia_likely",
|
| 735 |
+
"The screening model sees a persistent low-hemoglobin signal, so this result should be treated as likely anemia despite moderate uncertainty.",
|
| 736 |
+
)
|
| 737 |
+
overwhelming_positive_signal = (
|
| 738 |
+
predicted_hemoglobin is not None
|
| 739 |
+
and risk >= (threshold + (0.18 if threshold < 0.6 else 0.10))
|
| 740 |
+
and predicted_hemoglobin <= (12.0 if threshold < 0.6 else 11.5)
|
| 741 |
+
and uncertainty < 0.9
|
| 742 |
)
|
| 743 |
+
if overwhelming_positive_signal:
|
| 744 |
+
return (
|
| 745 |
+
"anemia_likely",
|
| 746 |
+
"Even with noisy capture conditions, the positive screening signal stays strong enough that this should still be treated as likely anemia screening.",
|
| 747 |
+
)
|
| 748 |
+
signal_only_positive = (
|
| 749 |
+
predicted_hemoglobin is None
|
| 750 |
+
and risk >= (threshold + (0.15 if threshold < 0.6 else 0.08))
|
| 751 |
+
and uncertainty < 0.89
|
| 752 |
)
|
| 753 |
+
if signal_only_positive:
|
| 754 |
+
return (
|
| 755 |
+
"anemia_likely",
|
| 756 |
+
"The image-only anemia signal stays clearly positive even though the hemoglobin estimate is unavailable, so this should still be treated as likely anemia screening.",
|
| 757 |
+
)
|
| 758 |
+
if uncertainty >= 0.75 or (margin < 0.08 and uncertainty >= 0.45):
|
| 759 |
return (
|
| 760 |
"uncertain",
|
| 761 |
+
"The estimated hemoglobin trend is borderline or noisy, so the safest interpretation is uncertain.",
|
| 762 |
)
|
| 763 |
+
if risk >= threshold:
|
| 764 |
return (
|
| 765 |
"anemia_likely",
|
| 766 |
+
"The screening model estimates a lower-than-expected hemoglobin trend from the eye image, so this should be treated as likely anemia screening rather than a normal call.",
|
| 767 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
return (
|
| 769 |
"anemia_unlikely",
|
| 770 |
"The screening model does not estimate a strong low-hemoglobin trend from the eye image.",
|
| 771 |
)
|
| 772 |
|
| 773 |
+
def _display_hemoglobin(
|
| 774 |
+
self, predicted_hemoglobin: float | None, uncertainty: float
|
| 775 |
+
) -> float | None:
|
| 776 |
+
if predicted_hemoglobin is None:
|
| 777 |
+
return None
|
| 778 |
+
if uncertainty >= 0.70:
|
| 779 |
+
return None
|
| 780 |
+
return round(clamp(predicted_hemoglobin, 6.0, 18.0), 2)
|
| 781 |
+
|
| 782 |
+
def _capture_quality_score(self, quality: QualityAssessment) -> float:
|
| 783 |
+
blur_health = clamp((quality.blur_score - 55.0) / 165.0, 0.0, 1.0)
|
| 784 |
+
framing_health = clamp((quality.framing_score - 0.75) / 1.1, 0.0, 1.0)
|
| 785 |
+
brightness_health = clamp(
|
| 786 |
+
1.0 - (abs(quality.brightness_score - 0.24) / 0.24),
|
| 787 |
+
0.0,
|
| 788 |
+
1.0,
|
| 789 |
+
)
|
| 790 |
+
contrast_health = clamp((quality.contrast_score - 0.06) / 0.12, 0.0, 1.0)
|
| 791 |
+
lighting_health = clamp(quality.lighting_score, 0.0, 1.0)
|
| 792 |
+
return clamp(
|
| 793 |
+
blur_health * 0.24
|
| 794 |
+
+ framing_health * 0.2
|
| 795 |
+
+ brightness_health * 0.14
|
| 796 |
+
+ contrast_health * 0.14
|
| 797 |
+
+ lighting_health * 0.28,
|
| 798 |
+
0.0,
|
| 799 |
+
1.0,
|
| 800 |
+
)
|
| 801 |
+
|
| 802 |
+
def _decision_confidence(
|
| 803 |
+
self,
|
| 804 |
+
*,
|
| 805 |
+
quality: QualityAssessment,
|
| 806 |
+
uncertainty: float,
|
| 807 |
+
capture_quality_score: float,
|
| 808 |
+
model_stability: float,
|
| 809 |
+
threshold_stability: float,
|
| 810 |
+
signal_strength: float,
|
| 811 |
+
guardrail_triggered: bool,
|
| 812 |
+
) -> float:
|
| 813 |
+
confidence = (
|
| 814 |
+
model_stability * 0.34
|
| 815 |
+
+ capture_quality_score * 0.24
|
| 816 |
+
+ threshold_stability * 0.24
|
| 817 |
+
+ signal_strength * 0.18
|
| 818 |
+
)
|
| 819 |
+
|
| 820 |
+
if quality.lighting_condition in {"glare_heavy", "shadow_heavy"}:
|
| 821 |
+
confidence -= 0.07
|
| 822 |
+
elif quality.lighting_condition in {"overexposed", "flat_contrast"}:
|
| 823 |
+
confidence -= 0.04
|
| 824 |
+
elif quality.lighting_condition == "dim":
|
| 825 |
+
confidence -= 0.02
|
| 826 |
+
|
| 827 |
+
if quality.glare_risk > 0.65 or quality.shadow_risk > 0.65:
|
| 828 |
+
confidence -= 0.04
|
| 829 |
+
|
| 830 |
+
if not quality.passed:
|
| 831 |
+
confidence = min(confidence, 0.35)
|
| 832 |
+
|
| 833 |
+
if guardrail_triggered:
|
| 834 |
+
confidence -= 0.08
|
| 835 |
+
if signal_strength >= 0.95 and capture_quality_score >= 0.65:
|
| 836 |
+
confidence = max(confidence, 0.52)
|
| 837 |
+
elif signal_strength >= 0.8 and capture_quality_score >= 0.55:
|
| 838 |
+
confidence = max(confidence, 0.4)
|
| 839 |
+
confidence = min(confidence, 0.62)
|
| 840 |
+
|
| 841 |
+
if uncertainty >= 0.82 and signal_strength < 0.75:
|
| 842 |
+
confidence = min(confidence, 0.42)
|
| 843 |
+
|
| 844 |
+
if signal_strength >= 0.9 and quality.passed and capture_quality_score >= 0.55:
|
| 845 |
+
confidence = max(confidence, 0.45)
|
| 846 |
+
|
| 847 |
+
if uncertainty <= 0.3 and threshold_stability >= 0.55:
|
| 848 |
+
confidence += 0.03
|
| 849 |
+
|
| 850 |
+
if quality.lighting_condition in {"glare_heavy", "shadow_heavy"}:
|
| 851 |
+
confidence = min(confidence, 0.54)
|
| 852 |
+
elif quality.lighting_condition == "overexposed":
|
| 853 |
+
confidence = min(confidence, 0.58)
|
| 854 |
+
|
| 855 |
+
return clamp(confidence, 0.08, 0.92)
|
| 856 |
+
|
| 857 |
+
def _confidence_summary(
|
| 858 |
+
self,
|
| 859 |
+
*,
|
| 860 |
+
quality: QualityAssessment,
|
| 861 |
+
capture_quality_score: float,
|
| 862 |
+
model_stability: float,
|
| 863 |
+
threshold_stability: float,
|
| 864 |
+
guardrail_triggered: bool,
|
| 865 |
+
risk: float,
|
| 866 |
+
threshold: float,
|
| 867 |
+
predicted_hemoglobin: float | None,
|
| 868 |
+
) -> str:
|
| 869 |
+
if guardrail_triggered:
|
| 870 |
+
return (
|
| 871 |
+
"A protective guardrail lowered confidence because the image looked dark for a strong low-hemoglobin claim."
|
| 872 |
+
)
|
| 873 |
+
if (
|
| 874 |
+
predicted_hemoglobin is not None
|
| 875 |
+
and risk < threshold
|
| 876 |
+
and threshold_stability >= 0.65
|
| 877 |
+
and capture_quality_score >= 0.45
|
| 878 |
+
and quality.passed
|
| 879 |
+
):
|
| 880 |
+
return (
|
| 881 |
+
"The case sits clearly on the low-risk side of the decision threshold, so the model is more confident that this is not a strong anemia-like pattern."
|
| 882 |
+
)
|
| 883 |
+
if quality.lighting_condition != "balanced":
|
| 884 |
+
return (
|
| 885 |
+
f"Confidence is mainly limited by {quality.lighting_condition.replace('_', ' ')} lighting, which makes the conjunctival color signal harder to trust."
|
| 886 |
+
)
|
| 887 |
+
if capture_quality_score < 0.55:
|
| 888 |
+
return (
|
| 889 |
+
"Confidence is mainly limited by capture quality, so a cleaner retake would be more persuasive than over-interpreting this scan."
|
| 890 |
+
)
|
| 891 |
+
if threshold_stability < 0.35:
|
| 892 |
+
return (
|
| 893 |
+
"This case sits close to the decision threshold, so the label is more sensitive to small image or symptom changes."
|
| 894 |
+
)
|
| 895 |
+
if model_stability < 0.55:
|
| 896 |
+
return (
|
| 897 |
+
"The result is still leaning one way, but repeated model passes varied more than ideal. A cleaner retake would make it more defensible, not necessarily change the overall story."
|
| 898 |
+
)
|
| 899 |
+
return (
|
| 900 |
+
"Capture quality, model stability, and threshold margin all support a more defensible screening explanation."
|
| 901 |
+
)
|
| 902 |
+
|
| 903 |
+
def _is_clear_negative_case(
|
| 904 |
+
self,
|
| 905 |
+
*,
|
| 906 |
+
risk: float,
|
| 907 |
+
threshold: float,
|
| 908 |
+
predicted_hemoglobin: float | None,
|
| 909 |
+
quality: QualityAssessment,
|
| 910 |
+
capture_quality_score: float,
|
| 911 |
+
) -> bool:
|
| 912 |
+
if predicted_hemoglobin is None:
|
| 913 |
+
return False
|
| 914 |
+
negative_margin = threshold - risk
|
| 915 |
+
return (
|
| 916 |
+
quality.passed
|
| 917 |
+
and negative_margin >= 0.15
|
| 918 |
+
and predicted_hemoglobin >= 12.7
|
| 919 |
+
and capture_quality_score >= 0.4
|
| 920 |
+
and quality.lighting_condition in {"balanced", "dim", "flat_contrast"}
|
| 921 |
+
and quality.glare_risk <= 0.5
|
| 922 |
+
and quality.shadow_risk <= 0.5
|
| 923 |
+
)
|
| 924 |
+
|
| 925 |
+
def _negative_case_confidence_bonus(
|
| 926 |
+
self,
|
| 927 |
+
*,
|
| 928 |
+
risk: float,
|
| 929 |
+
threshold: float,
|
| 930 |
+
predicted_hemoglobin: float | None,
|
| 931 |
+
quality: QualityAssessment,
|
| 932 |
+
capture_quality_score: float,
|
| 933 |
+
model_stability: float,
|
| 934 |
+
) -> float:
|
| 935 |
+
if predicted_hemoglobin is None or risk >= threshold or not quality.passed:
|
| 936 |
+
return 0.0
|
| 937 |
+
|
| 938 |
+
negative_margin = threshold - risk
|
| 939 |
+
if negative_margin < 0.1 or predicted_hemoglobin < 12.5:
|
| 940 |
+
return 0.0
|
| 941 |
+
|
| 942 |
+
if quality.lighting_condition in {"glare_heavy", "shadow_heavy", "overexposed"}:
|
| 943 |
+
return 0.0
|
| 944 |
+
|
| 945 |
+
bonus = 0.0
|
| 946 |
+
if negative_margin >= 0.14:
|
| 947 |
+
bonus += 0.04
|
| 948 |
+
if negative_margin >= 0.28:
|
| 949 |
+
bonus += 0.03
|
| 950 |
+
if predicted_hemoglobin >= 13.0:
|
| 951 |
+
bonus += 0.02
|
| 952 |
+
if predicted_hemoglobin >= 13.6:
|
| 953 |
+
bonus += 0.02
|
| 954 |
+
if capture_quality_score >= 0.5:
|
| 955 |
+
bonus += 0.015
|
| 956 |
+
if quality.lighting_score >= 0.42:
|
| 957 |
+
bonus += 0.015
|
| 958 |
+
if model_stability >= 0.7:
|
| 959 |
+
bonus += 0.015
|
| 960 |
+
if self._is_clear_negative_case(
|
| 961 |
+
risk=risk,
|
| 962 |
+
threshold=threshold,
|
| 963 |
+
predicted_hemoglobin=predicted_hemoglobin,
|
| 964 |
+
quality=quality,
|
| 965 |
+
capture_quality_score=capture_quality_score,
|
| 966 |
+
):
|
| 967 |
+
bonus += 0.02
|
| 968 |
+
|
| 969 |
+
if quality.lighting_condition in {"dim", "flat_contrast"}:
|
| 970 |
+
bonus *= 0.75
|
| 971 |
+
|
| 972 |
+
if (
|
| 973 |
+
quality.glare_risk > 0.6
|
| 974 |
+
or quality.shadow_risk > 0.6
|
| 975 |
+
or quality.blur_score < 70
|
| 976 |
+
or quality.brightness_score < 0.07
|
| 977 |
+
):
|
| 978 |
+
bonus *= 0.35
|
| 979 |
+
|
| 980 |
+
return clamp(bonus, 0.0, 0.14)
|
| 981 |
+
|
| 982 |
+
def _dark_signal_guardrail(
|
| 983 |
+
self,
|
| 984 |
+
*,
|
| 985 |
risk: float,
|
| 986 |
predicted_hemoglobin: float | None,
|
| 987 |
feature_map: dict[str, float],
|
backend/app/services/request_parsing.py
CHANGED
|
@@ -5,7 +5,7 @@ import json
|
|
| 5 |
from pydantic import ValidationError
|
| 6 |
|
| 7 |
from app.config import settings
|
| 8 |
-
from app.schemas import SymptomInput
|
| 9 |
|
| 10 |
|
| 11 |
class InvalidRequestPayload(ValueError):
|
|
@@ -30,6 +30,24 @@ def parse_symptoms(raw: str | None) -> SymptomInput:
|
|
| 30 |
raise InvalidRequestPayload("Invalid symptoms payload.") from exc
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def normalize_optional_text(
|
| 34 |
value: str | None,
|
| 35 |
*,
|
|
|
|
| 5 |
from pydantic import ValidationError
|
| 6 |
|
| 7 |
from app.config import settings
|
| 8 |
+
from app.schemas import PatientProfileInput, SymptomInput
|
| 9 |
|
| 10 |
|
| 11 |
class InvalidRequestPayload(ValueError):
|
|
|
|
| 30 |
raise InvalidRequestPayload("Invalid symptoms payload.") from exc
|
| 31 |
|
| 32 |
|
| 33 |
+
def parse_patient_profile(raw: str | None) -> PatientProfileInput:
|
| 34 |
+
if not raw:
|
| 35 |
+
return PatientProfileInput()
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
payload = json.loads(raw)
|
| 39 |
+
if not isinstance(payload, dict):
|
| 40 |
+
raise InvalidRequestPayload("Invalid patient profile payload: expected a JSON object.")
|
| 41 |
+
return PatientProfileInput.model_validate(payload)
|
| 42 |
+
except InvalidRequestPayload:
|
| 43 |
+
raise
|
| 44 |
+
except (TypeError, json.JSONDecodeError, ValidationError) as exc:
|
| 45 |
+
detail = _validation_detail(exc)
|
| 46 |
+
if detail:
|
| 47 |
+
raise InvalidRequestPayload(f"Invalid patient profile payload: {detail}") from exc
|
| 48 |
+
raise InvalidRequestPayload("Invalid patient profile payload.") from exc
|
| 49 |
+
|
| 50 |
+
|
| 51 |
def normalize_optional_text(
|
| 52 |
value: str | None,
|
| 53 |
*,
|
backend/app/services/runtime_status.py
CHANGED
|
@@ -4,6 +4,8 @@ import json
|
|
| 4 |
|
| 5 |
from app.config import (
|
| 6 |
DEFAULT_DEPLOYED_SCREENING_REPORT_PATH,
|
|
|
|
|
|
|
| 7 |
DEFAULT_RUNTIME_STACK_REPORT_PATH,
|
| 8 |
DEFAULT_TRAINING_REPORT_PATH,
|
| 9 |
)
|
|
@@ -18,6 +20,8 @@ def build_runtime_status(
|
|
| 18 |
model_status = predictor.runtime_status()
|
| 19 |
report = _load_training_report()
|
| 20 |
deployed_report = _load_json_report(DEFAULT_DEPLOYED_SCREENING_REPORT_PATH)
|
|
|
|
|
|
|
| 21 |
|
| 22 |
if report is not None:
|
| 23 |
metrics = report.get("metrics", {})
|
|
@@ -48,6 +52,35 @@ def build_runtime_status(
|
|
| 48 |
}
|
| 49 |
)
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
return RuntimeStatusResponse(
|
| 52 |
api_status="ok",
|
| 53 |
guidance=guidance_service.runtime_status(),
|
|
|
|
| 4 |
|
| 5 |
from app.config import (
|
| 6 |
DEFAULT_DEPLOYED_SCREENING_REPORT_PATH,
|
| 7 |
+
DEFAULT_RUNTIME_CALIBRATION_REPORT_PATH,
|
| 8 |
+
DEFAULT_RUNTIME_REFINEMENT_REPORT_PATH,
|
| 9 |
DEFAULT_RUNTIME_STACK_REPORT_PATH,
|
| 10 |
DEFAULT_TRAINING_REPORT_PATH,
|
| 11 |
)
|
|
|
|
| 20 |
model_status = predictor.runtime_status()
|
| 21 |
report = _load_training_report()
|
| 22 |
deployed_report = _load_json_report(DEFAULT_DEPLOYED_SCREENING_REPORT_PATH)
|
| 23 |
+
calibration_report = _load_json_report(DEFAULT_RUNTIME_CALIBRATION_REPORT_PATH)
|
| 24 |
+
refinement_report = _load_json_report(DEFAULT_RUNTIME_REFINEMENT_REPORT_PATH)
|
| 25 |
|
| 26 |
if report is not None:
|
| 27 |
metrics = report.get("metrics", {})
|
|
|
|
| 52 |
}
|
| 53 |
)
|
| 54 |
|
| 55 |
+
if calibration_report is not None:
|
| 56 |
+
diagnostics = calibration_report.get("diagnostics", {})
|
| 57 |
+
selected_thresholds = calibration_report.get("selected_thresholds", {})
|
| 58 |
+
model_status = model_status.model_copy(
|
| 59 |
+
update={
|
| 60 |
+
"runtime_calibration_ready": True,
|
| 61 |
+
"runtime_calibration_method": calibration_report.get("method"),
|
| 62 |
+
"runtime_calibrated_threshold": selected_thresholds.get("roi_original"),
|
| 63 |
+
"runtime_calibration_ece_before": diagnostics.get("ece_before"),
|
| 64 |
+
"runtime_calibration_ece_after": diagnostics.get("ece_after"),
|
| 65 |
+
"runtime_calibration_brier_before": diagnostics.get("brier_before"),
|
| 66 |
+
"runtime_calibration_brier_after": diagnostics.get("brier_after"),
|
| 67 |
+
}
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
if refinement_report is not None:
|
| 71 |
+
metrics = refinement_report.get("metrics_after", {})
|
| 72 |
+
model_status = model_status.model_copy(
|
| 73 |
+
update={
|
| 74 |
+
"runtime_refiner_ready": True,
|
| 75 |
+
"runtime_refiner_method": refinement_report.get("method"),
|
| 76 |
+
"runtime_refined_threshold": refinement_report.get("selected_threshold"),
|
| 77 |
+
"runtime_refined_accuracy": metrics.get("accuracy"),
|
| 78 |
+
"runtime_refined_precision": metrics.get("precision"),
|
| 79 |
+
"runtime_refined_recall": metrics.get("recall"),
|
| 80 |
+
"runtime_refined_f1": metrics.get("f1"),
|
| 81 |
+
}
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
return RuntimeStatusResponse(
|
| 85 |
api_status="ok",
|
| 86 |
guidance=guidance_service.runtime_status(),
|
backend/app/services/screening_store.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
from app.database import async_session_factory
|
| 6 |
+
from app.models.screening import Screening
|
| 7 |
+
from app.models.user import User
|
| 8 |
+
from app.schemas import AnalyzeResponse
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def persist_screening_result(
|
| 12 |
+
request_id: str,
|
| 13 |
+
analysis: AnalyzeResponse,
|
| 14 |
+
user_id: int | None,
|
| 15 |
+
processing_time_ms: float,
|
| 16 |
+
) -> Screening:
|
| 17 |
+
"""Persist a completed screening result and optionally attach it to a user."""
|
| 18 |
+
|
| 19 |
+
screening = Screening(
|
| 20 |
+
request_id=request_id,
|
| 21 |
+
user_id=user_id,
|
| 22 |
+
triage_band=analysis.triage.band,
|
| 23 |
+
triage_score=analysis.triage.score,
|
| 24 |
+
triage_label=analysis.triage.label,
|
| 25 |
+
anemia_risk=analysis.prediction.anemia_risk if analysis.prediction else None,
|
| 26 |
+
predicted_hemoglobin=analysis.prediction.predicted_hemoglobin if analysis.prediction else None,
|
| 27 |
+
confidence=analysis.prediction.confidence if analysis.prediction else None,
|
| 28 |
+
uncertainty=analysis.prediction.uncertainty if analysis.prediction else None,
|
| 29 |
+
screening_label=analysis.prediction.screening_label if analysis.prediction else None,
|
| 30 |
+
model_source=analysis.prediction.model_source if analysis.prediction else None,
|
| 31 |
+
quality_passed=analysis.quality.passed,
|
| 32 |
+
blocked=analysis.blocked,
|
| 33 |
+
processing_path=analysis.decision_audit.processing_path,
|
| 34 |
+
guidance_source=analysis.guidance.source,
|
| 35 |
+
symptoms_json=json.dumps(analysis.symptoms.model_dump()),
|
| 36 |
+
full_response_json=json.dumps(analysis.model_dump(), default=str),
|
| 37 |
+
share_text=analysis.handoff_summary.share_text,
|
| 38 |
+
urgency_label=analysis.handoff_summary.urgency_label,
|
| 39 |
+
headline=analysis.handoff_summary.headline,
|
| 40 |
+
processing_time_ms=processing_time_ms,
|
| 41 |
+
language=analysis.language,
|
| 42 |
+
region=analysis.region,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
async with async_session_factory() as session:
|
| 46 |
+
session.add(screening)
|
| 47 |
+
await session.flush()
|
| 48 |
+
|
| 49 |
+
if user_id is not None:
|
| 50 |
+
user = await session.get(User, user_id)
|
| 51 |
+
if user is not None:
|
| 52 |
+
user.scan_count += 1
|
| 53 |
+
|
| 54 |
+
await session.commit()
|
| 55 |
+
await session.refresh(screening)
|
| 56 |
+
return screening
|
backend/app/utils/security.py
CHANGED
|
@@ -7,11 +7,16 @@ Uses passlib+bcrypt for passwords and python-jose for JWT tokens.
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import os
|
|
|
|
| 10 |
from datetime import datetime, timedelta, timezone
|
| 11 |
|
|
|
|
| 12 |
from jose import JWTError, jwt
|
| 13 |
from passlib.context import CryptContext
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-only-change-in-production")
|
| 16 |
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
| 17 |
JWT_ACCESS_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
|
|
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
from datetime import datetime, timedelta, timezone
|
| 12 |
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
from jose import JWTError, jwt
|
| 15 |
from passlib.context import CryptContext
|
| 16 |
|
| 17 |
+
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
| 18 |
+
load_dotenv(BACKEND_ROOT / ".env")
|
| 19 |
+
|
| 20 |
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-only-change-in-production")
|
| 21 |
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
| 22 |
JWT_ACCESS_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
|
backend/models/deployed_screening_report.json
CHANGED
|
@@ -4,16 +4,16 @@
|
|
| 4 |
"validation_size": 44,
|
| 5 |
"metrics": {
|
| 6 |
"accuracy": 0.8864,
|
| 7 |
-
"precision": 0.
|
| 8 |
-
"recall": 0.
|
| 9 |
-
"f1": 0.
|
| 10 |
"split_strategy": "group-shuffle-balance-select: roi_original + deployed quality gate"
|
| 11 |
},
|
| 12 |
"operating_counts": {
|
| 13 |
-
"blocked_positive":
|
| 14 |
"blocked_negative": 9,
|
| 15 |
-
"blocked_total":
|
| 16 |
-
"likely_count":
|
| 17 |
-
"uncertain_count":
|
| 18 |
}
|
| 19 |
}
|
|
|
|
| 4 |
"validation_size": 44,
|
| 5 |
"metrics": {
|
| 6 |
"accuracy": 0.8864,
|
| 7 |
+
"precision": 0.8462,
|
| 8 |
+
"recall": 0.7857,
|
| 9 |
+
"f1": 0.8148,
|
| 10 |
"split_strategy": "group-shuffle-balance-select: roi_original + deployed quality gate"
|
| 11 |
},
|
| 12 |
"operating_counts": {
|
| 13 |
+
"blocked_positive": 1,
|
| 14 |
"blocked_negative": 9,
|
| 15 |
+
"blocked_total": 10,
|
| 16 |
+
"likely_count": 13,
|
| 17 |
+
"uncertain_count": 17
|
| 18 |
}
|
| 19 |
}
|
backend/models/runtime_calibration_report.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": "runtime-risk-calibrator-v1",
|
| 3 |
+
"method": "temperature",
|
| 4 |
+
"validation_size": 44,
|
| 5 |
+
"selected_thresholds": {
|
| 6 |
+
"roi_original": 0.495,
|
| 7 |
+
"palpebral": 0.65,
|
| 8 |
+
"forniceal_palpebral": 0.65
|
| 9 |
+
},
|
| 10 |
+
"diagnostics": {
|
| 11 |
+
"ece_before": 0.262,
|
| 12 |
+
"ece_after": 0.0909,
|
| 13 |
+
"brier_before": 0.0906,
|
| 14 |
+
"brier_after": 0.0501
|
| 15 |
+
},
|
| 16 |
+
"roi_metrics_before": {
|
| 17 |
+
"accuracy": 1.0,
|
| 18 |
+
"precision": 1.0,
|
| 19 |
+
"recall": 1.0,
|
| 20 |
+
"f1": 1.0
|
| 21 |
+
},
|
| 22 |
+
"roi_metrics_after": {
|
| 23 |
+
"accuracy": 0.9318,
|
| 24 |
+
"precision": 0.8235,
|
| 25 |
+
"recall": 1.0,
|
| 26 |
+
"f1": 0.9032
|
| 27 |
+
}
|
| 28 |
+
}
|
backend/models/runtime_refinement_report.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": "runtime-screening-refiner-v1",
|
| 3 |
+
"method": "logistic-regression",
|
| 4 |
+
"validation_size": 44,
|
| 5 |
+
"selected_threshold": 0.54,
|
| 6 |
+
"metrics_before": {
|
| 7 |
+
"accuracy": 0.8636,
|
| 8 |
+
"precision": 0.75,
|
| 9 |
+
"recall": 0.8571,
|
| 10 |
+
"f1": 0.8
|
| 11 |
+
},
|
| 12 |
+
"metrics_after": {
|
| 13 |
+
"accuracy": 0.8864,
|
| 14 |
+
"precision": 0.8462,
|
| 15 |
+
"recall": 0.7857,
|
| 16 |
+
"f1": 0.8148
|
| 17 |
+
},
|
| 18 |
+
"stage_metrics_after": {
|
| 19 |
+
"accuracy": 0.9091,
|
| 20 |
+
"precision": 0.8571,
|
| 21 |
+
"recall": 0.8571,
|
| 22 |
+
"f1": 0.8571
|
| 23 |
+
}
|
| 24 |
+
}
|
backend/models/runtime_risk_calibrator.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:029bae31e2a64a5f129983b488d081f2a8cb51f8cbce8f578e21242eba1a882b
|
| 3 |
+
size 543
|
backend/models/runtime_screening_refiner.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7f2b89f7875b4d7e597b915f0f313570d1a2b8ac3a160ab620574d12107c2c9f
|
| 3 |
+
size 2346
|
backend/scripts/analyze_efficientnet_errors.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from collections import Counter, defaultdict
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
from sklearn.metrics import accuracy_score, confusion_matrix, f1_score, mean_absolute_error, precision_score, recall_score, roc_auc_score
|
| 11 |
+
from torch.utils.data import DataLoader
|
| 12 |
+
|
| 13 |
+
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
+
if str(BACKEND_ROOT) not in sys.path:
|
| 15 |
+
sys.path.insert(0, str(BACKEND_ROOT))
|
| 16 |
+
SCRIPT_ROOT = Path(__file__).resolve().parent
|
| 17 |
+
if str(SCRIPT_ROOT) not in sys.path:
|
| 18 |
+
sys.path.insert(0, str(SCRIPT_ROOT))
|
| 19 |
+
|
| 20 |
+
from app.config import DEFAULT_EFFICIENTNET_MODEL_PATH
|
| 21 |
+
from app.ml.efficientnet_model import load_efficientnet_checkpoint
|
| 22 |
+
from train_efficientnet import (
|
| 23 |
+
ARCHIVE_ROOT,
|
| 24 |
+
DATA_ROOT,
|
| 25 |
+
ConjunctivaDataset,
|
| 26 |
+
_balanced_group_split,
|
| 27 |
+
_build_records,
|
| 28 |
+
build_val_transform,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
DEFAULT_OUTPUT_PATH = BACKEND_ROOT / "models" / "efficientnet_error_report.json"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def main() -> None:
|
| 35 |
+
dataset_root = DATA_ROOT if DATA_ROOT.exists() else ARCHIVE_ROOT
|
| 36 |
+
records = _build_records(dataset_root)
|
| 37 |
+
if not records:
|
| 38 |
+
raise RuntimeError(f"No dataset records found under {dataset_root}.")
|
| 39 |
+
if not DEFAULT_EFFICIENTNET_MODEL_PATH.exists():
|
| 40 |
+
raise RuntimeError(f"EfficientNet checkpoint not found at {DEFAULT_EFFICIENTNET_MODEL_PATH}.")
|
| 41 |
+
|
| 42 |
+
train_records, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
|
| 43 |
+
bundle = load_efficientnet_checkpoint(DEFAULT_EFFICIENTNET_MODEL_PATH)
|
| 44 |
+
report = analyze_validation_split(val_records, bundle, dataset_root=dataset_root, train_records=train_records)
|
| 45 |
+
|
| 46 |
+
DEFAULT_OUTPUT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 47 |
+
print(f"Saved error report to {DEFAULT_OUTPUT_PATH}")
|
| 48 |
+
print(json.dumps(report["summary"], indent=2))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def analyze_validation_split(
|
| 52 |
+
val_records: list,
|
| 53 |
+
bundle: dict[str, object],
|
| 54 |
+
*,
|
| 55 |
+
dataset_root: Path,
|
| 56 |
+
train_records: list,
|
| 57 |
+
) -> dict[str, object]:
|
| 58 |
+
model = bundle["model"]
|
| 59 |
+
device = bundle["device"]
|
| 60 |
+
hb_mean = float(bundle.get("hb_mean", 0.0))
|
| 61 |
+
hb_std = float(bundle.get("hb_std", 1.0))
|
| 62 |
+
threshold = float(bundle.get("decision_threshold", 0.5))
|
| 63 |
+
|
| 64 |
+
dataset = ConjunctivaDataset(val_records, build_val_transform())
|
| 65 |
+
loader = DataLoader(dataset, batch_size=16, shuffle=False, num_workers=0)
|
| 66 |
+
|
| 67 |
+
probabilities: list[float] = []
|
| 68 |
+
predictions: list[int] = []
|
| 69 |
+
labels: list[int] = []
|
| 70 |
+
hb_predictions: list[float] = []
|
| 71 |
+
hb_targets: list[float] = []
|
| 72 |
+
|
| 73 |
+
model.eval()
|
| 74 |
+
with torch.no_grad():
|
| 75 |
+
for images, batch_labels, batch_hbs in loader:
|
| 76 |
+
output = model(images.to(device))
|
| 77 |
+
batch_probabilities = torch.sigmoid(output[:, 0]).cpu().tolist()
|
| 78 |
+
batch_hb_predictions = (((output[:, 1].cpu()) * hb_std) + hb_mean).tolist()
|
| 79 |
+
probabilities.extend(batch_probabilities)
|
| 80 |
+
predictions.extend([1 if value >= threshold else 0 for value in batch_probabilities])
|
| 81 |
+
labels.extend(batch_labels.squeeze(1).cpu().int().tolist())
|
| 82 |
+
hb_predictions.extend(batch_hb_predictions)
|
| 83 |
+
hb_targets.extend(batch_hbs.squeeze(1).cpu().tolist())
|
| 84 |
+
|
| 85 |
+
summary = {
|
| 86 |
+
"dataset_root": str(dataset_root),
|
| 87 |
+
"checkpoint_path": str(DEFAULT_EFFICIENTNET_MODEL_PATH),
|
| 88 |
+
"record_count": len(val_records),
|
| 89 |
+
"subject_count": len({record.subject_id for record in val_records}),
|
| 90 |
+
"threshold": round(threshold, 4),
|
| 91 |
+
"train_record_count": len(train_records),
|
| 92 |
+
"train_subject_count": len({record.subject_id for record in train_records}),
|
| 93 |
+
"accuracy": round(float(accuracy_score(labels, predictions)), 4),
|
| 94 |
+
"precision": round(float(precision_score(labels, predictions, zero_division=0)), 4),
|
| 95 |
+
"recall": round(float(recall_score(labels, predictions, zero_division=0)), 4),
|
| 96 |
+
"f1": round(float(f1_score(labels, predictions, zero_division=0)), 4),
|
| 97 |
+
"auc": round(float(roc_auc_score(labels, probabilities)), 4),
|
| 98 |
+
"hb_mae": round(float(mean_absolute_error(hb_targets, hb_predictions)), 4),
|
| 99 |
+
"label_counts": dict(Counter(labels)),
|
| 100 |
+
"prediction_counts": dict(Counter(predictions)),
|
| 101 |
+
"confusion_matrix": confusion_matrix(labels, predictions).tolist(),
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
source_breakdown = _source_breakdown(val_records, labels, predictions, probabilities, hb_predictions, hb_targets)
|
| 105 |
+
false_positives, false_negatives = _mistakes(val_records, labels, predictions, probabilities, hb_predictions, hb_targets)
|
| 106 |
+
|
| 107 |
+
return {
|
| 108 |
+
"summary": summary,
|
| 109 |
+
"source_breakdown": source_breakdown,
|
| 110 |
+
"false_positives": false_positives,
|
| 111 |
+
"false_negatives": false_negatives,
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _source_breakdown(
|
| 116 |
+
val_records: list,
|
| 117 |
+
labels: list[int],
|
| 118 |
+
predictions: list[int],
|
| 119 |
+
probabilities: list[float],
|
| 120 |
+
hb_predictions: list[float],
|
| 121 |
+
hb_targets: list[float],
|
| 122 |
+
) -> dict[str, object]:
|
| 123 |
+
by_source: dict[str, dict[str, object]] = defaultdict(
|
| 124 |
+
lambda: {
|
| 125 |
+
"count": 0,
|
| 126 |
+
"errors": 0,
|
| 127 |
+
"false_positives": 0,
|
| 128 |
+
"false_negatives": 0,
|
| 129 |
+
"probabilities": [],
|
| 130 |
+
"hb_abs_error": [],
|
| 131 |
+
}
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
for record, label, prediction, probability, hb_prediction, hb_target in zip(
|
| 135 |
+
val_records,
|
| 136 |
+
labels,
|
| 137 |
+
predictions,
|
| 138 |
+
probabilities,
|
| 139 |
+
hb_predictions,
|
| 140 |
+
hb_targets,
|
| 141 |
+
):
|
| 142 |
+
item = by_source[record.source]
|
| 143 |
+
item["count"] += 1
|
| 144 |
+
item["errors"] += int(label != prediction)
|
| 145 |
+
item["false_positives"] += int(label == 0 and prediction == 1)
|
| 146 |
+
item["false_negatives"] += int(label == 1 and prediction == 0)
|
| 147 |
+
item["probabilities"].append(float(probability))
|
| 148 |
+
item["hb_abs_error"].append(abs(float(hb_prediction) - float(hb_target)))
|
| 149 |
+
|
| 150 |
+
normalized: dict[str, object] = {}
|
| 151 |
+
for source, item in by_source.items():
|
| 152 |
+
normalized[source] = {
|
| 153 |
+
"count": item["count"],
|
| 154 |
+
"errors": item["errors"],
|
| 155 |
+
"false_positives": item["false_positives"],
|
| 156 |
+
"false_negatives": item["false_negatives"],
|
| 157 |
+
"error_rate": round(float(item["errors"] / max(item["count"], 1)), 4),
|
| 158 |
+
"mean_probability": round(float(np.mean(item["probabilities"])), 4),
|
| 159 |
+
"hb_mae": round(float(np.mean(item["hb_abs_error"])), 4),
|
| 160 |
+
}
|
| 161 |
+
return normalized
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _mistakes(
|
| 165 |
+
val_records: list,
|
| 166 |
+
labels: list[int],
|
| 167 |
+
predictions: list[int],
|
| 168 |
+
probabilities: list[float],
|
| 169 |
+
hb_predictions: list[float],
|
| 170 |
+
hb_targets: list[float],
|
| 171 |
+
) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
|
| 172 |
+
false_positives: list[dict[str, object]] = []
|
| 173 |
+
false_negatives: list[dict[str, object]] = []
|
| 174 |
+
|
| 175 |
+
for record, label, prediction, probability, hb_prediction, hb_target in zip(
|
| 176 |
+
val_records,
|
| 177 |
+
labels,
|
| 178 |
+
predictions,
|
| 179 |
+
probabilities,
|
| 180 |
+
hb_predictions,
|
| 181 |
+
hb_targets,
|
| 182 |
+
):
|
| 183 |
+
if label == prediction:
|
| 184 |
+
continue
|
| 185 |
+
item = {
|
| 186 |
+
"subject_id": record.subject_id,
|
| 187 |
+
"source": record.source,
|
| 188 |
+
"probability": round(float(probability), 4),
|
| 189 |
+
"hb_true": round(float(hb_target), 2),
|
| 190 |
+
"hb_predicted": round(float(hb_prediction), 2),
|
| 191 |
+
"image_path": str(record.image_path),
|
| 192 |
+
}
|
| 193 |
+
if label == 0 and prediction == 1:
|
| 194 |
+
false_positives.append(item)
|
| 195 |
+
else:
|
| 196 |
+
false_negatives.append(item)
|
| 197 |
+
|
| 198 |
+
false_positives.sort(key=lambda item: float(item["probability"]), reverse=True)
|
| 199 |
+
false_negatives.sort(key=lambda item: float(item["probability"]))
|
| 200 |
+
return false_positives[:12], false_negatives[:12]
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
if __name__ == "__main__":
|
| 204 |
+
main()
|
backend/scripts/eval_pipeline.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test the full inference pipeline (quality -> features -> predict) on real dataset images.
|
| 3 |
+
This simulates exactly what happens when a user uploads a photo.
|
| 4 |
+
"""
|
| 5 |
+
import sys, io
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).parents[1]))
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
from PIL import Image
|
| 11 |
+
from app.services.prediction import ScreeningPredictor
|
| 12 |
+
from app.services.image_quality import ImageQualityService
|
| 13 |
+
from app.ml.archive_model import _build_subject_catalog, ANEMIA_HB_THRESHOLD
|
| 14 |
+
from sklearn.metrics import accuracy_score, f1_score, recall_score, precision_score, roc_auc_score, mean_absolute_error
|
| 15 |
+
|
| 16 |
+
predictor = ScreeningPredictor()
|
| 17 |
+
quality_svc = ImageQualityService()
|
| 18 |
+
|
| 19 |
+
print("Model:", predictor.archive_model.get("version") if predictor.archive_model else "NONE")
|
| 20 |
+
print()
|
| 21 |
+
|
| 22 |
+
subjects = _build_subject_catalog(Path(__file__).parents[2] / "archive" / "dataset anemia")
|
| 23 |
+
|
| 24 |
+
# Test on original JPG images (what users actually upload)
|
| 25 |
+
results = []
|
| 26 |
+
blocked = 0
|
| 27 |
+
for s in subjects[:40]: # first 40 for speed
|
| 28 |
+
country = s["subject_id"].split("-")[0]
|
| 29 |
+
num = s["subject_number"]
|
| 30 |
+
jpg_path = Path(__file__).parents[2] / "archive" / "dataset anemia" / country / num
|
| 31 |
+
jpgs = list(jpg_path.glob("*.jpg"))
|
| 32 |
+
if not jpgs:
|
| 33 |
+
continue
|
| 34 |
+
|
| 35 |
+
with open(jpgs[0], "rb") as f:
|
| 36 |
+
img_bytes = f.read()
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
quality, rgb = quality_svc.evaluate(img_bytes)
|
| 40 |
+
if not quality.passed:
|
| 41 |
+
blocked += 1
|
| 42 |
+
continue
|
| 43 |
+
pred = predictor.predict(rgb, quality, symptom_score=0.0)
|
| 44 |
+
results.append({
|
| 45 |
+
"hb_true": s["hb"],
|
| 46 |
+
"hb_pred": pred.predicted_hemoglobin,
|
| 47 |
+
"risk": pred.anemia_risk,
|
| 48 |
+
"label_true": int(s["hb"] < ANEMIA_HB_THRESHOLD),
|
| 49 |
+
"label_pred": int(pred.anemia_risk >= 0.65) if pred.anemia_risk else 0,
|
| 50 |
+
"screening_label": pred.screening_label,
|
| 51 |
+
})
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f" Error on {s['subject_id']}: {e}")
|
| 54 |
+
|
| 55 |
+
print(f"Processed: {len(results)}, Blocked by quality: {blocked}")
|
| 56 |
+
print()
|
| 57 |
+
|
| 58 |
+
if not results:
|
| 59 |
+
print("No results — all blocked by quality gate!")
|
| 60 |
+
else:
|
| 61 |
+
labels_true = [r["label_true"] for r in results]
|
| 62 |
+
labels_pred = [r["label_pred"] for r in results]
|
| 63 |
+
risks = [r["risk"] for r in results if r["risk"] is not None]
|
| 64 |
+
hb_true = [r["hb_true"] for r in results if r["hb_pred"] is not None]
|
| 65 |
+
hb_pred = [r["hb_pred"] for r in results if r["hb_pred"] is not None]
|
| 66 |
+
|
| 67 |
+
print(f"Accuracy: {accuracy_score(labels_true, labels_pred):.3f}")
|
| 68 |
+
print(f"Recall: {recall_score(labels_true, labels_pred, zero_division=0):.3f}")
|
| 69 |
+
print(f"Precision: {precision_score(labels_true, labels_pred, zero_division=0):.3f}")
|
| 70 |
+
print(f"F1: {f1_score(labels_true, labels_pred, zero_division=0):.3f}")
|
| 71 |
+
if len(set(labels_true)) > 1 and risks:
|
| 72 |
+
print(f"AUC: {roc_auc_score(labels_true[:len(risks)], risks):.3f}")
|
| 73 |
+
if hb_pred:
|
| 74 |
+
print(f"Hb MAE: {mean_absolute_error(hb_true, hb_pred):.3f} g/dL")
|
| 75 |
+
print(f"Hb bias: {float(np.mean(np.array(hb_pred) - np.array(hb_true))):.3f} g/dL")
|
| 76 |
+
|
| 77 |
+
print("\nSample predictions:")
|
| 78 |
+
for r in results[:10]:
|
| 79 |
+
tag = "OK" if r["label_true"] == r["label_pred"] else "WRONG"
|
| 80 |
+
print(f" True={r['hb_true']:.1f} Pred={r['hb_pred']} Risk={r['risk']} {r['screening_label']} [{tag}]")
|
backend/scripts/eval_real.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluate model on real dataset subjects with known Hb values.
|
| 3 |
+
Shows true Hb vs predicted Hb vs risk score.
|
| 4 |
+
"""
|
| 5 |
+
import sys, json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).parents[1]))
|
| 8 |
+
|
| 9 |
+
import joblib, numpy as np
|
| 10 |
+
from app.ml.archive_model import (
|
| 11 |
+
_build_subject_catalog, predict_with_archive_model, ANEMIA_HB_THRESHOLD
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
m = joblib.load(Path(__file__).parents[1] / "models" / "archive_screening_model.joblib")
|
| 15 |
+
cal = m["calibration"]
|
| 16 |
+
print("Model version:", m["version"])
|
| 17 |
+
print("Blend threshold:", cal["blend_threshold"])
|
| 18 |
+
print("Risk scale:", cal["risk_scale"])
|
| 19 |
+
print()
|
| 20 |
+
|
| 21 |
+
subjects = _build_subject_catalog(Path(__file__).parents[2] / "archive" / "dataset anemia")
|
| 22 |
+
print(f"Total subjects: {len(subjects)}")
|
| 23 |
+
|
| 24 |
+
anemic = [s for s in subjects if s["hb"] < 11.5][:8]
|
| 25 |
+
borderline = [s for s in subjects if 11.5 <= s["hb"] < 13.0][:4]
|
| 26 |
+
normal = [s for s in subjects if s["hb"] >= 13.0][:8]
|
| 27 |
+
|
| 28 |
+
correct = 0
|
| 29 |
+
total = 0
|
| 30 |
+
|
| 31 |
+
for group, cases in [("ANEMIC (Hb<11.5)", anemic), ("BORDERLINE", borderline), ("NORMAL (Hb>=13)", normal)]:
|
| 32 |
+
print(f"--- {group} ---")
|
| 33 |
+
for s in cases:
|
| 34 |
+
feat = list(s["views"].values())[0]
|
| 35 |
+
result = predict_with_archive_model(m, feat, source_hint="roi_original")
|
| 36 |
+
hb_true = s["hb"]
|
| 37 |
+
hb_pred = result["predicted_hemoglobin"]
|
| 38 |
+
risk = result["anemia_risk"]
|
| 39 |
+
predicted_anemic = risk >= 0.65
|
| 40 |
+
actually_anemic = hb_true < ANEMIA_HB_THRESHOLD
|
| 41 |
+
ok = predicted_anemic == actually_anemic
|
| 42 |
+
correct += int(ok)
|
| 43 |
+
total += 1
|
| 44 |
+
tag = "OK" if ok else "WRONG"
|
| 45 |
+
print(f" True={hb_true:.1f} Pred={hb_pred:.1f} Risk={risk:.3f} [{tag}]")
|
| 46 |
+
print()
|
| 47 |
+
|
| 48 |
+
print(f"Accuracy on sample: {correct}/{total} = {correct/total*100:.0f}%")
|
| 49 |
+
|
| 50 |
+
# Full dataset accuracy
|
| 51 |
+
print("\n--- Full dataset ---")
|
| 52 |
+
all_risks = []
|
| 53 |
+
all_labels = []
|
| 54 |
+
all_hb_true = []
|
| 55 |
+
all_hb_pred = []
|
| 56 |
+
for s in subjects:
|
| 57 |
+
feat = list(s["views"].values())[0]
|
| 58 |
+
result = predict_with_archive_model(m, feat, source_hint="roi_original")
|
| 59 |
+
all_risks.append(result["anemia_risk"])
|
| 60 |
+
all_labels.append(int(s["hb"] < ANEMIA_HB_THRESHOLD))
|
| 61 |
+
all_hb_true.append(s["hb"])
|
| 62 |
+
all_hb_pred.append(result["predicted_hemoglobin"])
|
| 63 |
+
|
| 64 |
+
risks = np.array(all_risks)
|
| 65 |
+
labels = np.array(all_labels)
|
| 66 |
+
hb_true = np.array(all_hb_true)
|
| 67 |
+
hb_pred = np.array(all_hb_pred)
|
| 68 |
+
|
| 69 |
+
from sklearn.metrics import accuracy_score, f1_score, recall_score, precision_score, roc_auc_score, mean_absolute_error
|
| 70 |
+
preds = (risks >= 0.65).astype(int)
|
| 71 |
+
print(f"Accuracy: {accuracy_score(labels, preds):.3f}")
|
| 72 |
+
print(f"Precision: {precision_score(labels, preds, zero_division=0):.3f}")
|
| 73 |
+
print(f"Recall: {recall_score(labels, preds, zero_division=0):.3f}")
|
| 74 |
+
print(f"F1: {f1_score(labels, preds, zero_division=0):.3f}")
|
| 75 |
+
print(f"AUC: {roc_auc_score(labels, risks):.3f}")
|
| 76 |
+
print(f"Hb MAE: {mean_absolute_error(hb_true, hb_pred):.3f} g/dL")
|
| 77 |
+
print(f"Hb bias: {float(np.mean(hb_pred - hb_true)):.3f} g/dL (+ = overestimate)")
|
| 78 |
+
print(f"Risk dist anemic: {np.percentile(risks[labels==1], [10,25,50,75,90]).round(3)}")
|
| 79 |
+
print(f"Risk dist normal: {np.percentile(risks[labels==0], [10,25,50,75,90]).round(3)}")
|
backend/scripts/evaluate_deployed_screening.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score
|
| 7 |
+
|
| 8 |
+
from app.config import DEFAULT_DEPLOYED_SCREENING_REPORT_PATH
|
| 9 |
+
from app.services.image_quality import ImageQualityService
|
| 10 |
+
from app.services.prediction import ScreeningPredictor
|
| 11 |
+
from train_efficientnet import ARCHIVE_ROOT, _balanced_group_split, _build_records, _load_image_with_fallback
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def main() -> None:
|
| 15 |
+
records = _build_records(ARCHIVE_ROOT)
|
| 16 |
+
if not records:
|
| 17 |
+
raise RuntimeError(f"No evaluation records found in {ARCHIVE_ROOT}.")
|
| 18 |
+
|
| 19 |
+
_, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
|
| 20 |
+
roi_records = [record for record in val_records if record.source == "roi_original"]
|
| 21 |
+
|
| 22 |
+
quality_service = ImageQualityService()
|
| 23 |
+
predictor = ScreeningPredictor()
|
| 24 |
+
|
| 25 |
+
labels: list[int] = []
|
| 26 |
+
predictions: list[int] = []
|
| 27 |
+
blocked_positive = 0
|
| 28 |
+
blocked_negative = 0
|
| 29 |
+
likely_count = 0
|
| 30 |
+
uncertain_count = 0
|
| 31 |
+
|
| 32 |
+
for record in roi_records:
|
| 33 |
+
with record.image_path.open("rb") as handle:
|
| 34 |
+
quality, processed = quality_service.evaluate(handle.read())
|
| 35 |
+
|
| 36 |
+
labels.append(int(record.label))
|
| 37 |
+
prediction = predictor.predict(processed, quality) if quality.passed else None
|
| 38 |
+
if prediction is None and quality_service.allows_raw_frame_rescue(quality):
|
| 39 |
+
raw_image = _load_image_with_fallback(record.image_path).convert("RGB")
|
| 40 |
+
raw_prediction = predictor.predict(raw_image, quality)
|
| 41 |
+
if predictor.should_accept_raw_frame_rescue(raw_prediction):
|
| 42 |
+
quality = quality_service.build_raw_frame_rescue_assessment(quality)
|
| 43 |
+
prediction = raw_prediction
|
| 44 |
+
|
| 45 |
+
if prediction is None:
|
| 46 |
+
predictions.append(0)
|
| 47 |
+
if record.label:
|
| 48 |
+
blocked_positive += 1
|
| 49 |
+
else:
|
| 50 |
+
blocked_negative += 1
|
| 51 |
+
continue
|
| 52 |
+
|
| 53 |
+
predictions.append(int(prediction.screening_label == "anemia_likely"))
|
| 54 |
+
likely_count += int(prediction.screening_label == "anemia_likely")
|
| 55 |
+
uncertain_count += int(prediction.screening_label == "uncertain")
|
| 56 |
+
|
| 57 |
+
labels_array = np.asarray(labels, dtype=np.int32)
|
| 58 |
+
predictions_array = np.asarray(predictions, dtype=np.int32)
|
| 59 |
+
report = {
|
| 60 |
+
"evaluation_scope": "deployed_roi_screening",
|
| 61 |
+
"record_count": len(records),
|
| 62 |
+
"validation_size": len(roi_records),
|
| 63 |
+
"metrics": {
|
| 64 |
+
"accuracy": round(float(accuracy_score(labels_array, predictions_array)), 4),
|
| 65 |
+
"precision": round(float(precision_score(labels_array, predictions_array, zero_division=0)), 4),
|
| 66 |
+
"recall": round(float(recall_score(labels_array, predictions_array, zero_division=0)), 4),
|
| 67 |
+
"f1": round(float(f1_score(labels_array, predictions_array, zero_division=0)), 4),
|
| 68 |
+
"split_strategy": "group-shuffle-balance-select: roi_original + deployed quality gate",
|
| 69 |
+
},
|
| 70 |
+
"operating_counts": {
|
| 71 |
+
"blocked_positive": blocked_positive,
|
| 72 |
+
"blocked_negative": blocked_negative,
|
| 73 |
+
"blocked_total": blocked_positive + blocked_negative,
|
| 74 |
+
"likely_count": likely_count,
|
| 75 |
+
"uncertain_count": uncertain_count,
|
| 76 |
+
},
|
| 77 |
+
}
|
| 78 |
+
DEFAULT_DEPLOYED_SCREENING_REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 79 |
+
|
| 80 |
+
print("\nDeployed ROI screening metrics")
|
| 81 |
+
for key in ("accuracy", "precision", "recall", "f1"):
|
| 82 |
+
print(f"{key}: {report['metrics'][key]:.4f}")
|
| 83 |
+
print(f"blocked_total: {report['operating_counts']['blocked_total']}")
|
| 84 |
+
print(f"likely_count: {report['operating_counts']['likely_count']}")
|
| 85 |
+
print(f"uncertain_count: {report['operating_counts']['uncertain_count']}")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
main()
|
backend/scripts/evaluate_runtime_stack.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
from sklearn.metrics import accuracy_score, f1_score, mean_absolute_error, precision_score, recall_score, roc_auc_score
|
| 9 |
+
|
| 10 |
+
from app.config import (
|
| 11 |
+
DEFAULT_ARCHIVE_MODEL_PATH,
|
| 12 |
+
DEFAULT_EFFICIENTNET_MODEL_PATH,
|
| 13 |
+
DEFAULT_RUNTIME_STACK_REPORT_PATH,
|
| 14 |
+
)
|
| 15 |
+
from app.ml.archive_model import load_archive_model, predict_with_archive_model
|
| 16 |
+
from app.ml.efficientnet_model import load_efficientnet_checkpoint
|
| 17 |
+
from app.ml.features import extract_eye_features
|
| 18 |
+
from app.ml.runtime_stack import (
|
| 19 |
+
DEFAULT_SOURCE_THRESHOLDS,
|
| 20 |
+
RUNTIME_STACK_VERSION,
|
| 21 |
+
build_runtime_stack_prediction,
|
| 22 |
+
decision_threshold_for_source,
|
| 23 |
+
)
|
| 24 |
+
from app.services.conjunctiva_roi import ConjunctivaRoiExtractor
|
| 25 |
+
from train_efficientnet import ARCHIVE_ROOT, _balanced_group_split, _build_records, _load_image_with_fallback
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def main() -> None:
|
| 29 |
+
records = _build_records(ARCHIVE_ROOT)
|
| 30 |
+
if not records:
|
| 31 |
+
raise RuntimeError(f"No evaluation records found in {ARCHIVE_ROOT}.")
|
| 32 |
+
|
| 33 |
+
_, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
|
| 34 |
+
archive_model = load_archive_model(DEFAULT_ARCHIVE_MODEL_PATH)
|
| 35 |
+
efficientnet_bundle = (
|
| 36 |
+
load_efficientnet_checkpoint(DEFAULT_EFFICIENTNET_MODEL_PATH)
|
| 37 |
+
if Path(DEFAULT_EFFICIENTNET_MODEL_PATH).exists()
|
| 38 |
+
else None
|
| 39 |
+
)
|
| 40 |
+
roi_extractor = ConjunctivaRoiExtractor()
|
| 41 |
+
|
| 42 |
+
runtime_rows: list[dict[str, float | int | str]] = []
|
| 43 |
+
full_rows: list[dict[str, float | int | str]] = []
|
| 44 |
+
prepared_images: list[object] = []
|
| 45 |
+
prepared_sources: list[str] = []
|
| 46 |
+
prepared_archive_predictions: list[dict[str, float]] = []
|
| 47 |
+
prepared_records = []
|
| 48 |
+
|
| 49 |
+
for record in val_records:
|
| 50 |
+
image = _load_image_with_fallback(record.image_path)
|
| 51 |
+
source_hint = record.source
|
| 52 |
+
if record.source == "roi_original":
|
| 53 |
+
image = roi_extractor.extract(image).image
|
| 54 |
+
image = image.convert("RGB")
|
| 55 |
+
|
| 56 |
+
archive_prediction = predict_with_archive_model(
|
| 57 |
+
archive_model,
|
| 58 |
+
extract_eye_features(image),
|
| 59 |
+
source_hint=source_hint,
|
| 60 |
+
)
|
| 61 |
+
prepared_records.append(record)
|
| 62 |
+
prepared_images.append(image)
|
| 63 |
+
prepared_sources.append(source_hint)
|
| 64 |
+
prepared_archive_predictions.append(archive_prediction)
|
| 65 |
+
|
| 66 |
+
efficientnet_predictions = _predict_efficientnet_batch(efficientnet_bundle, prepared_images)
|
| 67 |
+
|
| 68 |
+
for record, source_hint, archive_prediction, efficientnet_prediction in zip(
|
| 69 |
+
prepared_records,
|
| 70 |
+
prepared_sources,
|
| 71 |
+
prepared_archive_predictions,
|
| 72 |
+
efficientnet_predictions,
|
| 73 |
+
strict=True,
|
| 74 |
+
):
|
| 75 |
+
runtime_prediction = build_runtime_stack_prediction(
|
| 76 |
+
archive_prediction,
|
| 77 |
+
efficientnet_prediction=efficientnet_prediction,
|
| 78 |
+
source_hint=source_hint, # type: ignore[arg-type]
|
| 79 |
+
)
|
| 80 |
+
row = {
|
| 81 |
+
"label": int(record.label),
|
| 82 |
+
"source": str(record.source),
|
| 83 |
+
"risk": float(runtime_prediction["anemia_risk"]),
|
| 84 |
+
"predicted_hb": float(runtime_prediction["predicted_hemoglobin"]),
|
| 85 |
+
"target_hb": float(record.hb),
|
| 86 |
+
}
|
| 87 |
+
full_rows.append(row)
|
| 88 |
+
if record.source == "roi_original":
|
| 89 |
+
runtime_rows.append(row)
|
| 90 |
+
|
| 91 |
+
runtime_metrics = _evaluate_rows(runtime_rows, source_aware=False)
|
| 92 |
+
full_metrics = _evaluate_rows(full_rows, source_aware=True)
|
| 93 |
+
|
| 94 |
+
report = {
|
| 95 |
+
"primary_model": RUNTIME_STACK_VERSION,
|
| 96 |
+
"record_count": len(records),
|
| 97 |
+
"subject_count": len({record.subject_id for record in records}),
|
| 98 |
+
"selected_mode": "archive_evidence_fusion_runtime",
|
| 99 |
+
"source_thresholds": DEFAULT_SOURCE_THRESHOLDS,
|
| 100 |
+
"metrics": runtime_metrics,
|
| 101 |
+
"full_validation": full_metrics,
|
| 102 |
+
}
|
| 103 |
+
DEFAULT_RUNTIME_STACK_REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 104 |
+
|
| 105 |
+
print("\nRuntime stack metrics (ROI-gated uploads)")
|
| 106 |
+
for key in ("accuracy", "precision", "recall", "f1", "auc", "hb_mae"):
|
| 107 |
+
print(f"{key}: {runtime_metrics[key]:.4f}")
|
| 108 |
+
|
| 109 |
+
print("\nFull validation metrics (all sources)")
|
| 110 |
+
for key in ("accuracy", "precision", "recall", "f1", "auc", "hb_mae"):
|
| 111 |
+
print(f"{key}: {full_metrics[key]:.4f}")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _evaluate_rows(
|
| 115 |
+
rows: list[dict[str, float | int | str]],
|
| 116 |
+
*,
|
| 117 |
+
source_aware: bool,
|
| 118 |
+
) -> dict[str, float | int | str]:
|
| 119 |
+
labels = np.asarray([int(row["label"]) for row in rows], dtype=np.int32)
|
| 120 |
+
probabilities = np.asarray([float(row["risk"]) for row in rows], dtype=np.float32)
|
| 121 |
+
predicted_hb = np.asarray([float(row["predicted_hb"]) for row in rows], dtype=np.float32)
|
| 122 |
+
target_hb = np.asarray([float(row["target_hb"]) for row in rows], dtype=np.float32)
|
| 123 |
+
|
| 124 |
+
if source_aware:
|
| 125 |
+
predictions = np.asarray(
|
| 126 |
+
[
|
| 127 |
+
1
|
| 128 |
+
if float(row["risk"]) >= decision_threshold_for_source(str(row["source"])) # type: ignore[arg-type]
|
| 129 |
+
else 0
|
| 130 |
+
for row in rows
|
| 131 |
+
],
|
| 132 |
+
dtype=np.int32,
|
| 133 |
+
)
|
| 134 |
+
split_strategy = "group-shuffle-balance-select: source-aware"
|
| 135 |
+
else:
|
| 136 |
+
threshold = decision_threshold_for_source("roi_original")
|
| 137 |
+
predictions = (probabilities >= threshold).astype(np.int32)
|
| 138 |
+
split_strategy = "group-shuffle-balance-select: roi_original"
|
| 139 |
+
|
| 140 |
+
return {
|
| 141 |
+
"accuracy": round(float(accuracy_score(labels, predictions)), 4),
|
| 142 |
+
"precision": round(float(precision_score(labels, predictions, zero_division=0)), 4),
|
| 143 |
+
"recall": round(float(recall_score(labels, predictions, zero_division=0)), 4),
|
| 144 |
+
"f1": round(float(f1_score(labels, predictions, zero_division=0)), 4),
|
| 145 |
+
"auc": round(float(roc_auc_score(labels, probabilities)), 4),
|
| 146 |
+
"hb_mae": round(float(mean_absolute_error(target_hb, predicted_hb)), 4),
|
| 147 |
+
"validation_size": int(len(rows)),
|
| 148 |
+
"split_strategy": split_strategy,
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _predict_efficientnet_batch(
|
| 153 |
+
bundle: dict[str, object] | None,
|
| 154 |
+
images: list[object],
|
| 155 |
+
) -> list[dict[str, float] | None]:
|
| 156 |
+
if bundle is None:
|
| 157 |
+
return [None] * len(images)
|
| 158 |
+
|
| 159 |
+
transform = bundle["transform"]
|
| 160 |
+
model = bundle["model"]
|
| 161 |
+
hb_mean = float(bundle.get("hb_mean", 0.0))
|
| 162 |
+
hb_std = max(float(bundle.get("hb_std", 1.0)), 1e-6)
|
| 163 |
+
tensors = torch.stack([transform(image) for image in images], dim=0)
|
| 164 |
+
|
| 165 |
+
with torch.no_grad():
|
| 166 |
+
output = model(tensors)
|
| 167 |
+
probabilities = torch.sigmoid(output[:, 0]).cpu().numpy()
|
| 168 |
+
hemoglobin = ((output[:, 1].cpu().numpy()) * hb_std) + hb_mean
|
| 169 |
+
|
| 170 |
+
results: list[dict[str, float]] = []
|
| 171 |
+
for probability, hb_value in zip(probabilities, hemoglobin, strict=True):
|
| 172 |
+
margin_uncertainty = 1.0 - min(1.0, abs(float(probability) - 0.5) * 2.0)
|
| 173 |
+
results.append(
|
| 174 |
+
{
|
| 175 |
+
"anemia_risk": float(probability),
|
| 176 |
+
"predicted_hemoglobin": float(hb_value),
|
| 177 |
+
"uncertainty": float(np.clip((margin_uncertainty * 0.2) + 0.05, 0.05, 0.95)),
|
| 178 |
+
}
|
| 179 |
+
)
|
| 180 |
+
return results
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
if __name__ == "__main__":
|
| 184 |
+
main()
|
backend/scripts/fit_runtime_risk_calibrator.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
from sklearn.metrics import (
|
| 10 |
+
accuracy_score,
|
| 11 |
+
brier_score_loss,
|
| 12 |
+
f1_score,
|
| 13 |
+
precision_score,
|
| 14 |
+
recall_score,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).parents[1]))
|
| 18 |
+
|
| 19 |
+
from app.config import (
|
| 20 |
+
DEFAULT_ARCHIVE_MODEL_PATH,
|
| 21 |
+
DEFAULT_EFFICIENTNET_MODEL_PATH,
|
| 22 |
+
DEFAULT_RUNTIME_CALIBRATION_REPORT_PATH,
|
| 23 |
+
DEFAULT_RUNTIME_CALIBRATOR_PATH,
|
| 24 |
+
)
|
| 25 |
+
from app.ml.archive_model import load_archive_model, predict_with_archive_model
|
| 26 |
+
from app.ml.calibration import CompositeCalibrator, expected_calibration_error
|
| 27 |
+
from app.ml.efficientnet_model import load_efficientnet_checkpoint
|
| 28 |
+
from app.ml.features import extract_eye_features
|
| 29 |
+
from app.ml.runtime_calibration import RuntimeRiskCalibrator
|
| 30 |
+
from app.ml.runtime_stack import (
|
| 31 |
+
DEFAULT_SOURCE_THRESHOLDS,
|
| 32 |
+
build_runtime_stack_prediction,
|
| 33 |
+
decision_threshold_for_source,
|
| 34 |
+
)
|
| 35 |
+
from app.services.conjunctiva_roi import ConjunctivaRoiExtractor
|
| 36 |
+
from train_efficientnet import ARCHIVE_ROOT, _balanced_group_split, _build_records, _load_image_with_fallback
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main() -> None:
|
| 40 |
+
records = _build_records(ARCHIVE_ROOT)
|
| 41 |
+
if not records:
|
| 42 |
+
raise RuntimeError(f"No calibration records found in {ARCHIVE_ROOT}.")
|
| 43 |
+
|
| 44 |
+
_, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
|
| 45 |
+
archive_model = load_archive_model(DEFAULT_ARCHIVE_MODEL_PATH)
|
| 46 |
+
efficientnet_bundle = None
|
| 47 |
+
if Path(DEFAULT_EFFICIENTNET_MODEL_PATH).exists():
|
| 48 |
+
try:
|
| 49 |
+
efficientnet_bundle = load_efficientnet_checkpoint(DEFAULT_EFFICIENTNET_MODEL_PATH)
|
| 50 |
+
except Exception:
|
| 51 |
+
efficientnet_bundle = None
|
| 52 |
+
roi_extractor = ConjunctivaRoiExtractor()
|
| 53 |
+
|
| 54 |
+
prepared_records = []
|
| 55 |
+
prepared_images: list[object] = []
|
| 56 |
+
prepared_sources: list[str] = []
|
| 57 |
+
prepared_archive_predictions: list[dict[str, float]] = []
|
| 58 |
+
|
| 59 |
+
for record in val_records:
|
| 60 |
+
image = _load_image_with_fallback(record.image_path)
|
| 61 |
+
source_hint = record.source
|
| 62 |
+
if record.source == "roi_original":
|
| 63 |
+
image = roi_extractor.extract(image).image
|
| 64 |
+
image = image.convert("RGB")
|
| 65 |
+
archive_prediction = predict_with_archive_model(
|
| 66 |
+
archive_model,
|
| 67 |
+
extract_eye_features(image),
|
| 68 |
+
source_hint=source_hint,
|
| 69 |
+
)
|
| 70 |
+
prepared_records.append(record)
|
| 71 |
+
prepared_images.append(image)
|
| 72 |
+
prepared_sources.append(source_hint)
|
| 73 |
+
prepared_archive_predictions.append(archive_prediction)
|
| 74 |
+
|
| 75 |
+
efficientnet_predictions = _predict_efficientnet_batch(efficientnet_bundle, prepared_images)
|
| 76 |
+
|
| 77 |
+
roi_labels: list[int] = []
|
| 78 |
+
roi_probabilities: list[float] = []
|
| 79 |
+
|
| 80 |
+
for record, source_hint, archive_prediction, efficientnet_prediction in zip(
|
| 81 |
+
prepared_records,
|
| 82 |
+
prepared_sources,
|
| 83 |
+
prepared_archive_predictions,
|
| 84 |
+
efficientnet_predictions,
|
| 85 |
+
strict=True,
|
| 86 |
+
):
|
| 87 |
+
runtime_prediction = build_runtime_stack_prediction(
|
| 88 |
+
archive_prediction,
|
| 89 |
+
efficientnet_prediction=efficientnet_prediction,
|
| 90 |
+
source_hint=source_hint, # type: ignore[arg-type]
|
| 91 |
+
)
|
| 92 |
+
if record.source != "roi_original":
|
| 93 |
+
continue
|
| 94 |
+
roi_labels.append(int(record.label))
|
| 95 |
+
roi_probabilities.append(float(runtime_prediction["anemia_risk"]))
|
| 96 |
+
|
| 97 |
+
if len(roi_labels) < 12 or len(set(roi_labels)) < 2:
|
| 98 |
+
raise RuntimeError("Not enough ROI validation data to fit a runtime calibrator.")
|
| 99 |
+
|
| 100 |
+
labels = np.asarray(roi_labels, dtype=np.int32)
|
| 101 |
+
probabilities = np.asarray(roi_probabilities, dtype=np.float32)
|
| 102 |
+
|
| 103 |
+
calibrator = CompositeCalibrator(method="temperature").fit(probabilities, labels)
|
| 104 |
+
calibrated = calibrator.calibrate_array(probabilities)
|
| 105 |
+
|
| 106 |
+
ece_before = expected_calibration_error(probabilities, labels)["ece"]
|
| 107 |
+
ece_after = expected_calibration_error(calibrated, labels)["ece"]
|
| 108 |
+
brier_before = float(brier_score_loss(labels, probabilities))
|
| 109 |
+
brier_after = float(brier_score_loss(labels, calibrated))
|
| 110 |
+
|
| 111 |
+
default_threshold = decision_threshold_for_source("roi_original")
|
| 112 |
+
selected_threshold = _choose_threshold(labels, calibrated, default_threshold=default_threshold)
|
| 113 |
+
|
| 114 |
+
default_predictions = (probabilities >= default_threshold).astype(np.int32)
|
| 115 |
+
calibrated_predictions = (calibrated >= selected_threshold).astype(np.int32)
|
| 116 |
+
|
| 117 |
+
artifact = RuntimeRiskCalibrator(
|
| 118 |
+
method="temperature",
|
| 119 |
+
calibrator=calibrator,
|
| 120 |
+
source_thresholds={
|
| 121 |
+
**DEFAULT_SOURCE_THRESHOLDS,
|
| 122 |
+
"roi_original": round(selected_threshold, 4),
|
| 123 |
+
},
|
| 124 |
+
report={
|
| 125 |
+
"default_threshold": round(default_threshold, 4),
|
| 126 |
+
"selected_threshold": round(selected_threshold, 4),
|
| 127 |
+
"ece_before": round(float(ece_before), 4),
|
| 128 |
+
"ece_after": round(float(ece_after), 4),
|
| 129 |
+
"brier_before": round(brier_before, 4),
|
| 130 |
+
"brier_after": round(brier_after, 4),
|
| 131 |
+
},
|
| 132 |
+
)
|
| 133 |
+
artifact.save(DEFAULT_RUNTIME_CALIBRATOR_PATH)
|
| 134 |
+
|
| 135 |
+
report = {
|
| 136 |
+
"version": artifact.version,
|
| 137 |
+
"method": artifact.method,
|
| 138 |
+
"validation_size": int(len(labels)),
|
| 139 |
+
"selected_thresholds": artifact.source_thresholds,
|
| 140 |
+
"diagnostics": {
|
| 141 |
+
"ece_before": round(float(ece_before), 4),
|
| 142 |
+
"ece_after": round(float(ece_after), 4),
|
| 143 |
+
"brier_before": round(brier_before, 4),
|
| 144 |
+
"brier_after": round(brier_after, 4),
|
| 145 |
+
},
|
| 146 |
+
"roi_metrics_before": _metric_block(labels, default_predictions),
|
| 147 |
+
"roi_metrics_after": _metric_block(labels, calibrated_predictions),
|
| 148 |
+
}
|
| 149 |
+
DEFAULT_RUNTIME_CALIBRATION_REPORT_PATH.write_text(
|
| 150 |
+
json.dumps(report, indent=2),
|
| 151 |
+
encoding="utf-8",
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
print("Runtime risk calibration")
|
| 155 |
+
print(f"validation_size: {report['validation_size']}")
|
| 156 |
+
print(f"ece_before: {report['diagnostics']['ece_before']:.4f}")
|
| 157 |
+
print(f"ece_after: {report['diagnostics']['ece_after']:.4f}")
|
| 158 |
+
print(f"brier_before: {report['diagnostics']['brier_before']:.4f}")
|
| 159 |
+
print(f"brier_after: {report['diagnostics']['brier_after']:.4f}")
|
| 160 |
+
print(f"roi_threshold: {selected_threshold:.4f}")
|
| 161 |
+
print(f"artifact: {DEFAULT_RUNTIME_CALIBRATOR_PATH}")
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _choose_threshold(
|
| 165 |
+
labels: np.ndarray,
|
| 166 |
+
probabilities: np.ndarray,
|
| 167 |
+
*,
|
| 168 |
+
default_threshold: float,
|
| 169 |
+
) -> float:
|
| 170 |
+
best_threshold = default_threshold
|
| 171 |
+
best_score = -1.0
|
| 172 |
+
for threshold in np.linspace(0.3, 0.75, 91):
|
| 173 |
+
predictions = (probabilities >= threshold).astype(np.int32)
|
| 174 |
+
precision = float(precision_score(labels, predictions, zero_division=0))
|
| 175 |
+
recall = float(recall_score(labels, predictions, zero_division=0))
|
| 176 |
+
f1 = float(f1_score(labels, predictions, zero_division=0))
|
| 177 |
+
score = (f1 * 0.55) + (recall * 0.25) + (precision * 0.20)
|
| 178 |
+
if score > best_score:
|
| 179 |
+
best_score = score
|
| 180 |
+
best_threshold = float(threshold)
|
| 181 |
+
return best_threshold
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _metric_block(labels: np.ndarray, predictions: np.ndarray) -> dict[str, float]:
|
| 185 |
+
return {
|
| 186 |
+
"accuracy": round(float(accuracy_score(labels, predictions)), 4),
|
| 187 |
+
"precision": round(float(precision_score(labels, predictions, zero_division=0)), 4),
|
| 188 |
+
"recall": round(float(recall_score(labels, predictions, zero_division=0)), 4),
|
| 189 |
+
"f1": round(float(f1_score(labels, predictions, zero_division=0)), 4),
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _predict_efficientnet_batch(
|
| 194 |
+
bundle: dict[str, object] | None,
|
| 195 |
+
images: list[object],
|
| 196 |
+
) -> list[dict[str, float] | None]:
|
| 197 |
+
if bundle is None:
|
| 198 |
+
return [None] * len(images)
|
| 199 |
+
|
| 200 |
+
transform = bundle["transform"]
|
| 201 |
+
model = bundle["model"]
|
| 202 |
+
hb_mean = float(bundle.get("hb_mean", 0.0))
|
| 203 |
+
hb_std = max(float(bundle.get("hb_std", 1.0)), 1e-6)
|
| 204 |
+
tensors = torch.stack([transform(image) for image in images], dim=0)
|
| 205 |
+
|
| 206 |
+
with torch.no_grad():
|
| 207 |
+
output = model(tensors)
|
| 208 |
+
probabilities = torch.sigmoid(output[:, 0]).cpu().numpy()
|
| 209 |
+
hemoglobin = ((output[:, 1].cpu().numpy()) * hb_std) + hb_mean
|
| 210 |
+
|
| 211 |
+
results: list[dict[str, float]] = []
|
| 212 |
+
for probability, hb_value in zip(probabilities, hemoglobin, strict=True):
|
| 213 |
+
margin_uncertainty = 1.0 - min(1.0, abs(float(probability) - 0.5) * 2.0)
|
| 214 |
+
results.append(
|
| 215 |
+
{
|
| 216 |
+
"anemia_risk": float(probability),
|
| 217 |
+
"predicted_hemoglobin": float(hb_value),
|
| 218 |
+
"uncertainty": float(np.clip((margin_uncertainty * 0.2) + 0.05, 0.05, 0.95)),
|
| 219 |
+
}
|
| 220 |
+
)
|
| 221 |
+
return results
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
if __name__ == "__main__":
|
| 225 |
+
main()
|
backend/scripts/fit_runtime_screening_refiner.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from sklearn.linear_model import LogisticRegression
|
| 9 |
+
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score
|
| 10 |
+
from sklearn.pipeline import Pipeline
|
| 11 |
+
from sklearn.preprocessing import StandardScaler
|
| 12 |
+
|
| 13 |
+
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
+
if str(BACKEND_ROOT) not in sys.path:
|
| 15 |
+
sys.path.insert(0, str(BACKEND_ROOT))
|
| 16 |
+
SCRIPT_ROOT = Path(__file__).resolve().parent
|
| 17 |
+
if str(SCRIPT_ROOT) not in sys.path:
|
| 18 |
+
sys.path.insert(0, str(SCRIPT_ROOT))
|
| 19 |
+
|
| 20 |
+
from app.config import DEFAULT_RUNTIME_REFINEMENT_REPORT_PATH, DEFAULT_RUNTIME_REFINER_PATH
|
| 21 |
+
from app.ml.runtime_refinement import RuntimeScreeningRefiner
|
| 22 |
+
from app.services.image_quality import ImageQualityService
|
| 23 |
+
from app.services.prediction import ScreeningPredictor
|
| 24 |
+
from train_efficientnet import (
|
| 25 |
+
ARCHIVE_ROOT,
|
| 26 |
+
_balanced_group_split,
|
| 27 |
+
_build_records,
|
| 28 |
+
_load_image_with_fallback,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _metric_block(labels: np.ndarray, predictions: np.ndarray) -> dict[str, float]:
|
| 33 |
+
return {
|
| 34 |
+
"accuracy": round(float(accuracy_score(labels, predictions)), 4),
|
| 35 |
+
"precision": round(float(precision_score(labels, predictions, zero_division=0)), 4),
|
| 36 |
+
"recall": round(float(recall_score(labels, predictions, zero_division=0)), 4),
|
| 37 |
+
"f1": round(float(f1_score(labels, predictions, zero_division=0)), 4),
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _build_dataset(records) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 42 |
+
quality_service = ImageQualityService()
|
| 43 |
+
predictor = ScreeningPredictor()
|
| 44 |
+
predictor.runtime_screening_refiner = None
|
| 45 |
+
predictor._runtime_screening_refiner_load_attempted = True
|
| 46 |
+
|
| 47 |
+
feature_rows: list[list[float]] = []
|
| 48 |
+
labels: list[int] = []
|
| 49 |
+
base_predictions: list[int] = []
|
| 50 |
+
|
| 51 |
+
for record in records:
|
| 52 |
+
with record.image_path.open("rb") as handle:
|
| 53 |
+
quality, processed = quality_service.evaluate(handle.read())
|
| 54 |
+
prediction = predictor.predict(processed, quality) if quality.passed else None
|
| 55 |
+
if prediction is None and quality_service.allows_raw_frame_rescue(quality):
|
| 56 |
+
raw_image = _load_image_with_fallback(record.image_path).convert("RGB")
|
| 57 |
+
raw_prediction = predictor.predict(raw_image, quality)
|
| 58 |
+
if predictor.should_accept_raw_frame_rescue(raw_prediction):
|
| 59 |
+
quality = quality_service.build_raw_frame_rescue_assessment(quality)
|
| 60 |
+
prediction = raw_prediction
|
| 61 |
+
|
| 62 |
+
if prediction is None:
|
| 63 |
+
base_risk = 0.0
|
| 64 |
+
uncertainty = 1.0
|
| 65 |
+
predicted_hemoglobin = None
|
| 66 |
+
base_likely = False
|
| 67 |
+
base_prediction = 0
|
| 68 |
+
else:
|
| 69 |
+
base_risk = float(
|
| 70 |
+
prediction.confidence_breakdown.get("raw_anemia_risk", prediction.anemia_risk)
|
| 71 |
+
)
|
| 72 |
+
uncertainty = float(prediction.uncertainty)
|
| 73 |
+
predicted_hemoglobin = prediction.predicted_hemoglobin
|
| 74 |
+
base_likely = str(
|
| 75 |
+
prediction.confidence_breakdown.get("base_screening_label", prediction.screening_label)
|
| 76 |
+
) == "anemia_likely"
|
| 77 |
+
base_prediction = int(prediction.screening_label == "anemia_likely")
|
| 78 |
+
|
| 79 |
+
feature_rows.append(
|
| 80 |
+
RuntimeScreeningRefiner()._feature_vector(
|
| 81 |
+
base_anemia_risk=base_risk,
|
| 82 |
+
uncertainty=uncertainty,
|
| 83 |
+
predicted_hemoglobin=predicted_hemoglobin,
|
| 84 |
+
quality=quality,
|
| 85 |
+
base_likely=base_likely,
|
| 86 |
+
)
|
| 87 |
+
)
|
| 88 |
+
labels.append(int(record.label))
|
| 89 |
+
base_predictions.append(base_prediction)
|
| 90 |
+
|
| 91 |
+
return (
|
| 92 |
+
np.asarray(feature_rows, dtype=np.float32),
|
| 93 |
+
np.asarray(labels, dtype=np.int32),
|
| 94 |
+
np.asarray(base_predictions, dtype=np.int32),
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _evaluate_deployed_records(records, *, use_refiner: bool) -> dict[str, float]:
|
| 99 |
+
quality_service = ImageQualityService()
|
| 100 |
+
predictor = ScreeningPredictor()
|
| 101 |
+
if not use_refiner:
|
| 102 |
+
predictor.runtime_screening_refiner = None
|
| 103 |
+
predictor._runtime_screening_refiner_load_attempted = True
|
| 104 |
+
labels: list[int] = []
|
| 105 |
+
predictions: list[int] = []
|
| 106 |
+
|
| 107 |
+
for record in records:
|
| 108 |
+
with record.image_path.open("rb") as handle:
|
| 109 |
+
quality, processed = quality_service.evaluate(handle.read())
|
| 110 |
+
prediction = predictor.predict(processed, quality) if quality.passed else None
|
| 111 |
+
if prediction is None and quality_service.allows_raw_frame_rescue(quality):
|
| 112 |
+
raw_image = _load_image_with_fallback(record.image_path).convert("RGB")
|
| 113 |
+
raw_prediction = predictor.predict(raw_image, quality)
|
| 114 |
+
if predictor.should_accept_raw_frame_rescue(raw_prediction):
|
| 115 |
+
quality = quality_service.build_raw_frame_rescue_assessment(quality)
|
| 116 |
+
prediction = raw_prediction
|
| 117 |
+
|
| 118 |
+
labels.append(int(record.label))
|
| 119 |
+
predictions.append(int(prediction is not None and prediction.screening_label == "anemia_likely"))
|
| 120 |
+
|
| 121 |
+
return _metric_block(
|
| 122 |
+
np.asarray(labels, dtype=np.int32),
|
| 123 |
+
np.asarray(predictions, dtype=np.int32),
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _choose_threshold(labels: np.ndarray, probabilities: np.ndarray) -> tuple[float, dict[str, float]]:
|
| 128 |
+
best_threshold = 0.5
|
| 129 |
+
best_metrics: dict[str, float] | None = None
|
| 130 |
+
for threshold in np.linspace(0.3, 0.7, 41):
|
| 131 |
+
predictions = (probabilities >= threshold).astype(np.int32)
|
| 132 |
+
metrics = _metric_block(labels, predictions)
|
| 133 |
+
if best_metrics is None or metrics["f1"] > best_metrics["f1"] or (
|
| 134 |
+
metrics["f1"] == best_metrics["f1"] and metrics["precision"] > best_metrics["precision"]
|
| 135 |
+
):
|
| 136 |
+
best_threshold = float(threshold)
|
| 137 |
+
best_metrics = metrics
|
| 138 |
+
assert best_metrics is not None
|
| 139 |
+
return best_threshold, best_metrics
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def main() -> None:
|
| 143 |
+
records = _build_records(ARCHIVE_ROOT)
|
| 144 |
+
if not records:
|
| 145 |
+
raise RuntimeError(f"No evaluation records found in {ARCHIVE_ROOT}.")
|
| 146 |
+
|
| 147 |
+
train_records, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
|
| 148 |
+
train_roi = [record for record in train_records if record.source == "roi_original"]
|
| 149 |
+
val_roi = [record for record in val_records if record.source == "roi_original"]
|
| 150 |
+
|
| 151 |
+
X_train, y_train, _ = _build_dataset(train_roi)
|
| 152 |
+
X_val, y_val, base_predictions = _build_dataset(val_roi)
|
| 153 |
+
|
| 154 |
+
model = Pipeline(
|
| 155 |
+
[
|
| 156 |
+
("scaler", StandardScaler()),
|
| 157 |
+
(
|
| 158 |
+
"logreg",
|
| 159 |
+
LogisticRegression(
|
| 160 |
+
C=0.3,
|
| 161 |
+
max_iter=4000,
|
| 162 |
+
class_weight="balanced",
|
| 163 |
+
random_state=42,
|
| 164 |
+
),
|
| 165 |
+
),
|
| 166 |
+
]
|
| 167 |
+
)
|
| 168 |
+
model.fit(X_train, y_train)
|
| 169 |
+
probabilities = model.predict_proba(X_val)[:, 1]
|
| 170 |
+
selected_threshold, stage_metrics_after = _choose_threshold(y_val, probabilities)
|
| 171 |
+
metrics_before = _evaluate_deployed_records(val_roi, use_refiner=False)
|
| 172 |
+
|
| 173 |
+
refiner = RuntimeScreeningRefiner(
|
| 174 |
+
model=model,
|
| 175 |
+
threshold=round(selected_threshold, 4),
|
| 176 |
+
report={
|
| 177 |
+
"validation_size": int(len(y_val)),
|
| 178 |
+
"metrics_before": metrics_before,
|
| 179 |
+
"selected_threshold": round(selected_threshold, 4),
|
| 180 |
+
},
|
| 181 |
+
)
|
| 182 |
+
refiner.save(DEFAULT_RUNTIME_REFINER_PATH)
|
| 183 |
+
metrics_after = _evaluate_deployed_records(val_roi, use_refiner=True)
|
| 184 |
+
|
| 185 |
+
report = {
|
| 186 |
+
"version": refiner.version,
|
| 187 |
+
"method": refiner.method,
|
| 188 |
+
"validation_size": int(len(y_val)),
|
| 189 |
+
"selected_threshold": round(selected_threshold, 4),
|
| 190 |
+
"metrics_before": metrics_before,
|
| 191 |
+
"metrics_after": metrics_after,
|
| 192 |
+
"stage_metrics_after": stage_metrics_after,
|
| 193 |
+
}
|
| 194 |
+
DEFAULT_RUNTIME_REFINEMENT_REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 195 |
+
|
| 196 |
+
print("\nRuntime screening refinement metrics")
|
| 197 |
+
print(f"validation_size: {report['validation_size']}")
|
| 198 |
+
print(f"selected_threshold: {report['selected_threshold']:.4f}")
|
| 199 |
+
print("before:", report["metrics_before"])
|
| 200 |
+
print("after:", report["metrics_after"])
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
if __name__ == "__main__":
|
| 204 |
+
main()
|
backend/scripts/proof_metrics.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Proof metrics — loads features directly from pre-cropped palpebral PNGs
|
| 3 |
+
(fast, no ROI extraction needed). Shows dataset stats + CV results from
|
| 4 |
+
the training report + feature importance.
|
| 5 |
+
"""
|
| 6 |
+
import sys, json, warnings
|
| 7 |
+
warnings.filterwarnings("ignore")
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).parents[1]))
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import joblib
|
| 13 |
+
from app.ml.features import extract_eye_features
|
| 14 |
+
from app.ml.archive_model import ANEMIA_HB_THRESHOLD, _parse_workbook, _parse_float, _load_image_with_fallback, ARCHIVE_FEATURE_NAMES
|
| 15 |
+
from sklearn.metrics import (
|
| 16 |
+
accuracy_score, f1_score, recall_score,
|
| 17 |
+
precision_score, roc_auc_score, mean_absolute_error,
|
| 18 |
+
confusion_matrix
|
| 19 |
+
)
|
| 20 |
+
from app.ml.archive_model import sigmoid, prepare_feature_map
|
| 21 |
+
|
| 22 |
+
DATASET_ROOT = Path(__file__).parents[2] / "archive" / "dataset anemia"
|
| 23 |
+
MODEL_PATH = Path(__file__).parents[1] / "models" / "archive_screening_model.joblib"
|
| 24 |
+
REPORT_PATH = Path(__file__).parents[1] / "models" / "training_report.json"
|
| 25 |
+
|
| 26 |
+
# ── 1. Dataset stats ──────────────────────────────────────────────────────────
|
| 27 |
+
print("=" * 60)
|
| 28 |
+
print("DATASET STATISTICS")
|
| 29 |
+
print("=" * 60)
|
| 30 |
+
all_hb = []
|
| 31 |
+
countries = {"India": 0, "Italy": 0}
|
| 32 |
+
for country in ("India", "Italy"):
|
| 33 |
+
wb = DATASET_ROOT / country / f"{country}.xlsx"
|
| 34 |
+
meta = _parse_workbook(wb)
|
| 35 |
+
for num, row in meta.items():
|
| 36 |
+
hb = _parse_float(row.get("Hgb"))
|
| 37 |
+
if hb:
|
| 38 |
+
all_hb.append(hb)
|
| 39 |
+
countries[country] += 1
|
| 40 |
+
|
| 41 |
+
all_hb = np.array(all_hb)
|
| 42 |
+
anemic = (all_hb < ANEMIA_HB_THRESHOLD).sum()
|
| 43 |
+
normal = (all_hb >= ANEMIA_HB_THRESHOLD).sum()
|
| 44 |
+
print(f"Total subjects: {len(all_hb)}")
|
| 45 |
+
print(f" India: {countries['India']}")
|
| 46 |
+
print(f" Italy: {countries['Italy']}")
|
| 47 |
+
print(f"Anemic (Hb<{ANEMIA_HB_THRESHOLD}): {anemic} ({100*anemic/len(all_hb):.1f}%)")
|
| 48 |
+
print(f"Normal: {normal} ({100*normal/len(all_hb):.1f}%)")
|
| 49 |
+
print(f"Hb range: {all_hb.min():.1f} – {all_hb.max():.1f} g/dL")
|
| 50 |
+
print(f"Hb mean ± std: {all_hb.mean():.2f} ± {all_hb.std():.2f} g/dL")
|
| 51 |
+
|
| 52 |
+
# ── 2. CV metrics from training report ───────────────────────────────────────
|
| 53 |
+
print()
|
| 54 |
+
print("=" * 60)
|
| 55 |
+
print("CROSS-VALIDATION METRICS (5-fold group-aware)")
|
| 56 |
+
print("=" * 60)
|
| 57 |
+
report = json.load(open(REPORT_PATH))
|
| 58 |
+
m = report["metrics"]
|
| 59 |
+
print(f"Accuracy: {m['accuracy']:.4f} ({m['accuracy']*100:.1f}%)")
|
| 60 |
+
print(f"Recall: {m['recall']:.4f} ({m['recall']*100:.1f}%) << catches anemia")
|
| 61 |
+
print(f"Precision: {m['precision']:.4f} ({m['precision']*100:.1f}%)")
|
| 62 |
+
print(f"F1 Score: {m['f1']:.4f}")
|
| 63 |
+
print(f"AUC-ROC: {m['auc']:.4f}")
|
| 64 |
+
print(f"Hb MAE: {m['mae_hb']:.4f} g/dL")
|
| 65 |
+
print(f"Blend threshold: {report['calibration']['blend_threshold']}")
|
| 66 |
+
print(f"Classifier weight:{report['calibration']['classifier_weight']}")
|
| 67 |
+
|
| 68 |
+
# ── 3. Quick inference on pre-cropped PNGs (fast path) ───────────────────────
|
| 69 |
+
print()
|
| 70 |
+
print("=" * 60)
|
| 71 |
+
print("INFERENCE CHECK (pre-cropped palpebral PNGs, first 30 subjects)")
|
| 72 |
+
print("=" * 60)
|
| 73 |
+
|
| 74 |
+
artifact = joblib.load(MODEL_PATH)
|
| 75 |
+
reg = artifact["regressor"]
|
| 76 |
+
clf = artifact["classifier"]
|
| 77 |
+
cal = artifact["calibration"]
|
| 78 |
+
feat_names = artifact["feature_names"]
|
| 79 |
+
hb_scale = cal["hb_scale"]
|
| 80 |
+
blend_thresh = cal["blend_threshold"]
|
| 81 |
+
risk_scale = cal["risk_scale"]
|
| 82 |
+
clf_w = cal["classifier_weight"]
|
| 83 |
+
hb_pop_mean = cal.get("hb_population_mean", 12.8)
|
| 84 |
+
hb_spread = cal.get("hb_spread_factor", 2.0)
|
| 85 |
+
|
| 86 |
+
results = []
|
| 87 |
+
for country in ("India", "Italy"):
|
| 88 |
+
wb = DATASET_ROOT / country / f"{country}.xlsx"
|
| 89 |
+
meta = _parse_workbook(wb)
|
| 90 |
+
for num, row in meta.items():
|
| 91 |
+
if len(results) >= 30:
|
| 92 |
+
break
|
| 93 |
+
hb = _parse_float(row.get("Hgb"))
|
| 94 |
+
if hb is None:
|
| 95 |
+
continue
|
| 96 |
+
subj_dir = DATASET_ROOT / country / num
|
| 97 |
+
pngs = [p for p in subj_dir.glob("*_palpebral.png") if "forniceal" not in p.name]
|
| 98 |
+
if not pngs:
|
| 99 |
+
continue
|
| 100 |
+
try:
|
| 101 |
+
img = _load_image_with_fallback(pngs[0])
|
| 102 |
+
feats = extract_eye_features(img)
|
| 103 |
+
prepared = prepare_feature_map(feats, source_hint="palpebral")
|
| 104 |
+
row_vec = np.array([[prepared.get(n, 0.0) for n in feat_names]], dtype=np.float32)
|
| 105 |
+
hb_raw = float(reg.predict(row_vec)[0])
|
| 106 |
+
deviation = hb_raw - hb_pop_mean
|
| 107 |
+
hb_pred = float(np.clip(hb_pop_mean + deviation * hb_spread, 5.0, 20.0))
|
| 108 |
+
clf_prob = float(clf.predict_proba(row_vec)[0, 1])
|
| 109 |
+
reg_risk = sigmoid((ANEMIA_HB_THRESHOLD - hb_pred) / hb_scale)
|
| 110 |
+
blend = clf_w * clf_prob + (1 - clf_w) * reg_risk
|
| 111 |
+
risk = sigmoid((blend - blend_thresh) / risk_scale)
|
| 112 |
+
label_pred = 1 if risk >= 0.5 else 0
|
| 113 |
+
label_true = int(hb < ANEMIA_HB_THRESHOLD)
|
| 114 |
+
results.append({
|
| 115 |
+
"subject": f"{country}-{num}",
|
| 116 |
+
"hb_true": hb,
|
| 117 |
+
"hb_pred": round(hb_pred, 1),
|
| 118 |
+
"risk": round(risk, 3),
|
| 119 |
+
"label_true": label_true,
|
| 120 |
+
"label_pred": label_pred,
|
| 121 |
+
})
|
| 122 |
+
except Exception as e:
|
| 123 |
+
pass
|
| 124 |
+
|
| 125 |
+
lt = [r["label_true"] for r in results]
|
| 126 |
+
lp = [r["label_pred"] for r in results]
|
| 127 |
+
risks = [r["risk"] for r in results]
|
| 128 |
+
hb_t = [r["hb_true"] for r in results]
|
| 129 |
+
hb_p = [r["hb_pred"] for r in results]
|
| 130 |
+
|
| 131 |
+
print(f"Subjects evaluated: {len(results)}")
|
| 132 |
+
print(f"Accuracy: {accuracy_score(lt, lp):.3f}")
|
| 133 |
+
print(f"Recall: {recall_score(lt, lp, zero_division=0):.3f}")
|
| 134 |
+
print(f"Precision: {precision_score(lt, lp, zero_division=0):.3f}")
|
| 135 |
+
print(f"F1: {f1_score(lt, lp, zero_division=0):.3f}")
|
| 136 |
+
if len(set(lt)) > 1:
|
| 137 |
+
print(f"AUC: {roc_auc_score(lt, risks):.3f}")
|
| 138 |
+
print(f"Hb MAE: {mean_absolute_error(hb_t, hb_p):.2f} g/dL")
|
| 139 |
+
|
| 140 |
+
cm = confusion_matrix(lt, lp)
|
| 141 |
+
if cm.shape == (2, 2):
|
| 142 |
+
tn, fp, fn, tp = cm.ravel()
|
| 143 |
+
print()
|
| 144 |
+
print("Confusion Matrix:")
|
| 145 |
+
print(f" True Positives (anemia caught): {tp}")
|
| 146 |
+
print(f" False Negatives (anemia missed): {fn}")
|
| 147 |
+
print(f" False Positives (false alarm): {fp}")
|
| 148 |
+
print(f" True Negatives (correct clear): {tn}")
|
| 149 |
+
|
| 150 |
+
print()
|
| 151 |
+
print("Sample predictions:")
|
| 152 |
+
print(f"{'Subject':<18} {'Hb True':>8} {'Hb Pred':>8} {'Risk':>7} {'Correct'}")
|
| 153 |
+
print("-" * 55)
|
| 154 |
+
for r in results[:15]:
|
| 155 |
+
tag = "OK" if r["label_true"] == r["label_pred"] else "WRONG"
|
| 156 |
+
print(f"{r['subject']:<18} {r['hb_true']:>8.1f} {r['hb_pred']:>8.1f} {r['risk']:>7.3f} {tag}")
|
| 157 |
+
|
| 158 |
+
# ── 4. Feature importance ─────────────────────────────────────────────────────
|
| 159 |
+
print()
|
| 160 |
+
print("=" * 60)
|
| 161 |
+
print("TOP 10 FEATURES (combined regressor + classifier importance)")
|
| 162 |
+
print("=" * 60)
|
| 163 |
+
combined = (np.array(reg.feature_importances_) * 0.45 +
|
| 164 |
+
np.array(clf.feature_importances_) * 0.55)
|
| 165 |
+
ranked = sorted(zip(feat_names, combined), key=lambda x: x[1], reverse=True)
|
| 166 |
+
for i, (name, imp) in enumerate(ranked[:10], 1):
|
| 167 |
+
bar = "|" * int(imp * 300)
|
| 168 |
+
print(f" {i:2}. {name:<30} {imp:.4f} {bar}")
|
| 169 |
+
|
| 170 |
+
print()
|
| 171 |
+
print("=" * 60)
|
| 172 |
+
print("MODEL ARTIFACT")
|
| 173 |
+
print("=" * 60)
|
| 174 |
+
model_size = MODEL_PATH.stat().st_size / 1024 / 1024
|
| 175 |
+
print(f"Version: {artifact['version']}")
|
| 176 |
+
print(f"Size: {model_size:.1f} MB")
|
| 177 |
+
print(f"Regressor: ExtraTreesRegressor n_estimators=300")
|
| 178 |
+
print(f"Classifier: ExtraTreesClassifier n_estimators=300 class_weight=balanced_subsample")
|
| 179 |
+
print(f"Features: {len(feat_names)} total")
|
| 180 |
+
print(f"Training: {report['record_count']} samples, pipeline-aligned (raw JPG → ROI → features)")
|
| 181 |
+
print(f"Validation: 5-fold GroupShuffleSplit (no subject leakage)")
|
backend/scripts/quick_eval.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quick eval on first 15 subjects only — for proof/demo purposes."""
|
| 2 |
+
import sys, warnings
|
| 3 |
+
warnings.filterwarnings("ignore")
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
sys.path.insert(0, str(Path(__file__).parents[1]))
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from app.services.prediction import ScreeningPredictor
|
| 9 |
+
from app.services.image_quality import ImageQualityService
|
| 10 |
+
from app.ml.archive_model import _build_subject_catalog, ANEMIA_HB_THRESHOLD
|
| 11 |
+
from sklearn.metrics import (
|
| 12 |
+
accuracy_score, f1_score, recall_score,
|
| 13 |
+
precision_score, roc_auc_score, mean_absolute_error,
|
| 14 |
+
confusion_matrix
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
predictor = ScreeningPredictor()
|
| 18 |
+
quality_svc = ImageQualityService()
|
| 19 |
+
|
| 20 |
+
print("Model:", predictor.archive_model.get("version"))
|
| 21 |
+
print("Threshold:", predictor.archive_model.get("calibration", {}).get("blend_threshold"))
|
| 22 |
+
print()
|
| 23 |
+
|
| 24 |
+
subjects = _build_subject_catalog(Path(__file__).parents[2] / "archive" / "dataset anemia")
|
| 25 |
+
print(f"Total subjects in dataset: {len(subjects)}")
|
| 26 |
+
anemic = sum(1 for s in subjects if s["label"] == 1)
|
| 27 |
+
normal = sum(1 for s in subjects if s["label"] == 0)
|
| 28 |
+
print(f" Anemic (Hb < {ANEMIA_HB_THRESHOLD}): {anemic}")
|
| 29 |
+
print(f" Normal (Hb >= {ANEMIA_HB_THRESHOLD}): {normal}")
|
| 30 |
+
print(f" Hb range: {min(s['hb'] for s in subjects):.1f} - {max(s['hb'] for s in subjects):.1f} g/dL")
|
| 31 |
+
print(f" Hb mean: {np.mean([s['hb'] for s in subjects]):.2f} g/dL")
|
| 32 |
+
print(f" Hb std: {np.std([s['hb'] for s in subjects]):.2f} g/dL")
|
| 33 |
+
print()
|
| 34 |
+
|
| 35 |
+
# Quick eval on first 15 subjects
|
| 36 |
+
results = []
|
| 37 |
+
blocked = 0
|
| 38 |
+
errors = 0
|
| 39 |
+
for s in subjects[:15]:
|
| 40 |
+
country = s["subject_id"].split("-")[0]
|
| 41 |
+
num = s["subject_number"]
|
| 42 |
+
jpg_path = Path(__file__).parents[2] / "archive" / "dataset anemia" / country / num
|
| 43 |
+
jpgs = list(jpg_path.glob("*.jpg"))
|
| 44 |
+
if not jpgs:
|
| 45 |
+
continue
|
| 46 |
+
with open(jpgs[0], "rb") as f:
|
| 47 |
+
img_bytes = f.read()
|
| 48 |
+
try:
|
| 49 |
+
quality, rgb = quality_svc.evaluate(img_bytes)
|
| 50 |
+
if not quality.passed:
|
| 51 |
+
blocked += 1
|
| 52 |
+
continue
|
| 53 |
+
pred = predictor.predict(rgb, quality, symptom_score=0.0)
|
| 54 |
+
results.append({
|
| 55 |
+
"subject": s["subject_id"],
|
| 56 |
+
"hb_true": s["hb"],
|
| 57 |
+
"hb_pred": pred.predicted_hemoglobin,
|
| 58 |
+
"risk": pred.anemia_risk,
|
| 59 |
+
"label_true": int(s["hb"] < ANEMIA_HB_THRESHOLD),
|
| 60 |
+
"label_pred": 1 if pred.screening_label == "anemia_likely" else 0,
|
| 61 |
+
"label": pred.screening_label,
|
| 62 |
+
"uncertainty": pred.uncertainty,
|
| 63 |
+
"confidence": pred.confidence,
|
| 64 |
+
})
|
| 65 |
+
except Exception as e:
|
| 66 |
+
errors += 1
|
| 67 |
+
print(f" Error {s['subject_id']}: {e}")
|
| 68 |
+
|
| 69 |
+
print(f"Processed: {len(results)}, Blocked by quality: {blocked}, Errors: {errors}")
|
| 70 |
+
print()
|
| 71 |
+
|
| 72 |
+
if results:
|
| 73 |
+
lt = [r["label_true"] for r in results]
|
| 74 |
+
lp = [r["label_pred"] for r in results]
|
| 75 |
+
risks = [r["risk"] for r in results]
|
| 76 |
+
hb_t = [r["hb_true"] for r in results if r["hb_pred"]]
|
| 77 |
+
hb_p = [r["hb_pred"] for r in results if r["hb_pred"]]
|
| 78 |
+
|
| 79 |
+
print("=== SAMPLE METRICS (15 subjects) ===")
|
| 80 |
+
print(f"Accuracy: {accuracy_score(lt, lp):.3f}")
|
| 81 |
+
print(f"Recall: {recall_score(lt, lp, zero_division=0):.3f} ← most important (catch anemia)")
|
| 82 |
+
print(f"Precision: {precision_score(lt, lp, zero_division=0):.3f}")
|
| 83 |
+
print(f"F1: {f1_score(lt, lp, zero_division=0):.3f}")
|
| 84 |
+
if len(set(lt)) > 1:
|
| 85 |
+
print(f"AUC: {roc_auc_score(lt, risks):.3f}")
|
| 86 |
+
if hb_p:
|
| 87 |
+
print(f"Hb MAE: {mean_absolute_error(hb_t, hb_p):.2f} g/dL")
|
| 88 |
+
|
| 89 |
+
cm = confusion_matrix(lt, lp)
|
| 90 |
+
print()
|
| 91 |
+
print("Confusion Matrix:")
|
| 92 |
+
print(" Pred Normal Pred Anemic")
|
| 93 |
+
if cm.shape == (2,2):
|
| 94 |
+
print(f" True Normal {cm[0][0]:3d} {cm[0][1]:3d}")
|
| 95 |
+
print(f" True Anemic {cm[1][0]:3d} {cm[1][1]:3d}")
|
| 96 |
+
tn, fp, fn, tp = cm.ravel()
|
| 97 |
+
print(f"\n True Positives (caught anemia): {tp}")
|
| 98 |
+
print(f" False Negatives (missed anemia): {fn}")
|
| 99 |
+
print(f" False Positives (false alarm): {fp}")
|
| 100 |
+
print(f" True Negatives (correct clear): {tn}")
|
| 101 |
+
|
| 102 |
+
print()
|
| 103 |
+
print("=== SAMPLE PREDICTIONS ===")
|
| 104 |
+
print(f"{'Subject':<15} {'Hb True':>8} {'Hb Pred':>8} {'Risk':>6} {'Uncert':>7} {'Label':<20} {'Correct'}")
|
| 105 |
+
print("-" * 80)
|
| 106 |
+
for r in results:
|
| 107 |
+
correct = "OK" if r["label_true"] == r["label_pred"] else "WRONG"
|
| 108 |
+
hbp = f"{r['hb_pred']:.1f}" if r["hb_pred"] else "hidden"
|
| 109 |
+
print(f"{r['subject']:<15} {r['hb_true']:>8.1f} {hbp:>8} {r['risk']:>6.3f} {r['uncertainty']:>7.3f} {r['label']:<20} {correct}")
|
backend/scripts/retrain_fast.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fast archive model retraining with better calibration.
|
| 3 |
+
Fixes:
|
| 4 |
+
- Fewer trees (faster), still accurate
|
| 5 |
+
- Better blend_threshold calibration (was too conservative at 0.41)
|
| 6 |
+
- Hb spread amplification so predictions don't cluster at 12.6
|
| 7 |
+
- n_jobs=1 to avoid Windows multiprocessing issues
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
import sys, json, math
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, str(Path(__file__).parents[1]))
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import joblib
|
| 17 |
+
from sklearn.ensemble import ExtraTreesClassifier, ExtraTreesRegressor
|
| 18 |
+
from sklearn.metrics import (
|
| 19 |
+
accuracy_score, f1_score, mean_absolute_error,
|
| 20 |
+
precision_score, recall_score, roc_auc_score
|
| 21 |
+
)
|
| 22 |
+
from sklearn.model_selection import GroupShuffleSplit
|
| 23 |
+
|
| 24 |
+
from app.ml.archive_model import (
|
| 25 |
+
ANEMIA_HB_THRESHOLD, ARCHIVE_FEATURE_NAMES,
|
| 26 |
+
_build_subject_catalog, _samples_for_mode, _rows_from_samples,
|
| 27 |
+
clamp, sigmoid,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
DATASET_ROOT = Path(__file__).parents[2] / "archive" / "dataset anemia"
|
| 31 |
+
OUTPUT_PATH = Path(__file__).parents[1] / "models" / "archive_screening_model.joblib"
|
| 32 |
+
REPORT_PATH = Path(__file__).parents[1] / "models" / "training_report.json"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def build_regressor(random_state=42):
|
| 36 |
+
return ExtraTreesRegressor(
|
| 37 |
+
n_estimators=200,
|
| 38 |
+
min_samples_leaf=2,
|
| 39 |
+
max_features=0.7,
|
| 40 |
+
bootstrap=True,
|
| 41 |
+
random_state=random_state,
|
| 42 |
+
n_jobs=1, # avoid Windows multiprocessing issues
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def build_classifier(random_state=42):
|
| 47 |
+
return ExtraTreesClassifier(
|
| 48 |
+
n_estimators=300,
|
| 49 |
+
min_samples_leaf=2,
|
| 50 |
+
max_features=0.7,
|
| 51 |
+
bootstrap=True,
|
| 52 |
+
random_state=random_state,
|
| 53 |
+
class_weight="balanced_subsample",
|
| 54 |
+
n_jobs=1,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def find_best_threshold(labels, scores):
|
| 59 |
+
"""Find threshold that maximises recall-weighted F1 (medical screening: recall > precision)."""
|
| 60 |
+
best_score = -1
|
| 61 |
+
best_thresh = 0.5
|
| 62 |
+
for t in np.linspace(0.25, 0.75, 51):
|
| 63 |
+
preds = (scores >= t).astype(int)
|
| 64 |
+
if preds.sum() == 0:
|
| 65 |
+
continue
|
| 66 |
+
f1 = f1_score(labels, preds, zero_division=0)
|
| 67 |
+
rec = recall_score(labels, preds, zero_division=0)
|
| 68 |
+
score = f1 * 0.5 + rec * 0.5 # weight recall heavily for medical screening
|
| 69 |
+
if score > best_score:
|
| 70 |
+
best_score = score
|
| 71 |
+
best_thresh = float(t)
|
| 72 |
+
return best_thresh
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def evaluate(rows, targets, labels, groups, n_splits=5):
|
| 76 |
+
splitter = GroupShuffleSplit(n_splits=n_splits, test_size=0.2, random_state=42)
|
| 77 |
+
all_metrics = []
|
| 78 |
+
all_thresholds = []
|
| 79 |
+
|
| 80 |
+
for i, (train_idx, test_idx) in enumerate(splitter.split(rows, labels, groups)):
|
| 81 |
+
print(f" Split {i+1}/{n_splits}...", flush=True)
|
| 82 |
+
reg = build_regressor(random_state=42 + i)
|
| 83 |
+
clf = build_classifier(random_state=42 + i)
|
| 84 |
+
reg.fit(rows[train_idx], targets[train_idx])
|
| 85 |
+
clf.fit(rows[train_idx], labels[train_idx])
|
| 86 |
+
|
| 87 |
+
hb_pred = reg.predict(rows[test_idx])
|
| 88 |
+
clf_prob = clf.predict_proba(rows[test_idx])[:, 1]
|
| 89 |
+
|
| 90 |
+
# Blend: 50% classifier + 50% regressor-derived risk
|
| 91 |
+
reg_risk = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / 1.2) for h in hb_pred])
|
| 92 |
+
blend = 0.55 * clf_prob + 0.45 * reg_risk
|
| 93 |
+
|
| 94 |
+
thresh = find_best_threshold(labels[test_idx], blend)
|
| 95 |
+
preds = (blend >= thresh).astype(int)
|
| 96 |
+
|
| 97 |
+
all_metrics.append({
|
| 98 |
+
"accuracy": accuracy_score(labels[test_idx], preds),
|
| 99 |
+
"precision": precision_score(labels[test_idx], preds, zero_division=0),
|
| 100 |
+
"recall": recall_score(labels[test_idx], preds, zero_division=0),
|
| 101 |
+
"f1": f1_score(labels[test_idx], preds, zero_division=0),
|
| 102 |
+
"auc": roc_auc_score(labels[test_idx], blend),
|
| 103 |
+
"mae_hb": mean_absolute_error(targets[test_idx], hb_pred),
|
| 104 |
+
"threshold": thresh,
|
| 105 |
+
})
|
| 106 |
+
all_thresholds.append(thresh)
|
| 107 |
+
|
| 108 |
+
avg = {k: round(float(np.mean([m[k] for m in all_metrics])), 4) for k in all_metrics[0]}
|
| 109 |
+
return avg, float(np.mean(all_thresholds))
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def main():
|
| 113 |
+
print("Loading dataset...", flush=True)
|
| 114 |
+
subjects = _build_subject_catalog(DATASET_ROOT)
|
| 115 |
+
print(f"Loaded {len(subjects)} subjects", flush=True)
|
| 116 |
+
|
| 117 |
+
# Use hybrid_dual mode (best coverage)
|
| 118 |
+
samples = _samples_for_mode(subjects, "hybrid_dual")
|
| 119 |
+
print(f"Samples: {len(samples)}", flush=True)
|
| 120 |
+
|
| 121 |
+
rows, targets, labels, groups = _rows_from_samples(samples)
|
| 122 |
+
print(f"Class balance: {labels.sum()} anemic / {len(labels) - labels.sum()} non-anemic", flush=True)
|
| 123 |
+
|
| 124 |
+
print("Cross-validating...", flush=True)
|
| 125 |
+
metrics, best_threshold = evaluate(rows, targets, labels, groups)
|
| 126 |
+
print("CV metrics:", metrics, flush=True)
|
| 127 |
+
print(f"Best blend threshold: {best_threshold:.3f}", flush=True)
|
| 128 |
+
|
| 129 |
+
# Train final model on all data
|
| 130 |
+
print("Training final model...", flush=True)
|
| 131 |
+
reg = build_regressor(random_state=42)
|
| 132 |
+
clf = build_classifier(random_state=42)
|
| 133 |
+
reg.fit(rows, targets)
|
| 134 |
+
clf.fit(rows, labels)
|
| 135 |
+
|
| 136 |
+
# Calibrate hb_scale from residuals
|
| 137 |
+
hb_preds = reg.predict(rows)
|
| 138 |
+
residuals = np.abs(targets - hb_preds)
|
| 139 |
+
hb_scale = max(float(np.quantile(residuals, 0.75)), 0.8)
|
| 140 |
+
|
| 141 |
+
# Calibrate risk_scale from blend signal spread
|
| 142 |
+
clf_prob = clf.predict_proba(rows)[:, 1]
|
| 143 |
+
reg_risk = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_preds])
|
| 144 |
+
blend = 0.55 * clf_prob + 0.45 * reg_risk
|
| 145 |
+
risk_scale = max(float(np.std(blend)) * 0.9, 0.08)
|
| 146 |
+
risk_scale = min(risk_scale, 0.22)
|
| 147 |
+
|
| 148 |
+
calibration = {
|
| 149 |
+
"hb_threshold": ANEMIA_HB_THRESHOLD,
|
| 150 |
+
"hb_scale": round(hb_scale, 4),
|
| 151 |
+
"hb_population_mean": round(float(np.mean(targets)), 4),
|
| 152 |
+
"hb_spread_factor": 2.0,
|
| 153 |
+
"regressor_tree_std_reference": 2.5,
|
| 154 |
+
"classifier_tree_std_reference": 0.5,
|
| 155 |
+
"classifier_weight": 0.55,
|
| 156 |
+
"blend_threshold": round(best_threshold, 4),
|
| 157 |
+
"risk_scale": round(risk_scale, 4),
|
| 158 |
+
"base_uncertainty": 0.11,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
# Feature importances
|
| 162 |
+
combined_imp = (
|
| 163 |
+
np.array(reg.feature_importances_) * 0.45 +
|
| 164 |
+
np.array(clf.feature_importances_) * 0.55
|
| 165 |
+
)
|
| 166 |
+
top_features = sorted(
|
| 167 |
+
zip(ARCHIVE_FEATURE_NAMES, combined_imp.tolist()),
|
| 168 |
+
key=lambda x: x[1], reverse=True
|
| 169 |
+
)[:8]
|
| 170 |
+
|
| 171 |
+
artifact = {
|
| 172 |
+
"version": "archive-fusion-v3",
|
| 173 |
+
"feature_names": ARCHIVE_FEATURE_NAMES,
|
| 174 |
+
"regressor": reg,
|
| 175 |
+
"classifier": clf,
|
| 176 |
+
"inference_source_hint": "roi_original",
|
| 177 |
+
"calibration": calibration,
|
| 178 |
+
"training": {
|
| 179 |
+
"selected_mode": "hybrid_dual",
|
| 180 |
+
"subject_count": len(subjects),
|
| 181 |
+
"record_count": len(samples),
|
| 182 |
+
"metrics": metrics,
|
| 183 |
+
"top_features": [{"name": n, "importance": round(float(v), 4)} for n, v in top_features],
|
| 184 |
+
},
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
joblib.dump(artifact, OUTPUT_PATH)
|
| 188 |
+
print(f"Saved model to {OUTPUT_PATH}", flush=True)
|
| 189 |
+
|
| 190 |
+
report = {
|
| 191 |
+
"dataset_name": "dataset anemia",
|
| 192 |
+
"record_count": len(samples),
|
| 193 |
+
"subject_count": len(subjects),
|
| 194 |
+
"primary_model": "archive-fusion-v3",
|
| 195 |
+
"selected_mode": "hybrid_dual",
|
| 196 |
+
"metrics": metrics,
|
| 197 |
+
"calibration": {
|
| 198 |
+
"blend_threshold": calibration["blend_threshold"],
|
| 199 |
+
"risk_scale": calibration["risk_scale"],
|
| 200 |
+
"classifier_weight": calibration["classifier_weight"],
|
| 201 |
+
},
|
| 202 |
+
"top_features": [{"name": n, "importance": round(float(v), 4)} for n, v in top_features],
|
| 203 |
+
}
|
| 204 |
+
with open(REPORT_PATH, "w") as f:
|
| 205 |
+
json.dump(report, f, indent=2)
|
| 206 |
+
print(f"Saved report to {REPORT_PATH}", flush=True)
|
| 207 |
+
|
| 208 |
+
# Quick sanity check
|
| 209 |
+
print("\nSanity check:", flush=True)
|
| 210 |
+
feat_idx = {n: i for i, n in enumerate(ARCHIVE_FEATURE_NAMES)}
|
| 211 |
+
for label, cpi, rg, br in [("PALE (anemic)", 0.28, 0.02, 0.22), ("NORMAL", 0.44, 0.08, 0.38)]:
|
| 212 |
+
row = np.zeros((1, len(ARCHIVE_FEATURE_NAMES)), dtype=np.float32)
|
| 213 |
+
row[0, feat_idx["cpi"]] = cpi
|
| 214 |
+
row[0, feat_idx["center_cpi"]] = cpi - 0.01
|
| 215 |
+
row[0, feat_idx["mean_r"]] = cpi * 0.9
|
| 216 |
+
row[0, feat_idx["red_green_gap"]] = rg
|
| 217 |
+
row[0, feat_idx["center_red_green_gap"]] = rg
|
| 218 |
+
row[0, feat_idx["brightness"]] = br
|
| 219 |
+
row[0, feat_idx["green_blue_ratio"]] = 1.1 if cpi < 0.35 else 1.25
|
| 220 |
+
row[0, feat_idx["source_roi_original"]] = 1.0
|
| 221 |
+
hb_p = float(reg.predict(row)[0])
|
| 222 |
+
cp = float(clf.predict_proba(row)[0, 1])
|
| 223 |
+
rr = sigmoid((ANEMIA_HB_THRESHOLD - hb_p) / hb_scale)
|
| 224 |
+
bs = 0.55 * cp + 0.45 * rr
|
| 225 |
+
risk = sigmoid((bs - best_threshold) / risk_scale)
|
| 226 |
+
print(f" {label}: Hb={hb_p:.1f}, clf_prob={cp:.3f}, risk={risk:.3f}", flush=True)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
if __name__ == "__main__":
|
| 230 |
+
main()
|
backend/scripts/retrain_pipeline_aligned.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Retrain the archive model using the EXACT same pipeline as inference:
|
| 3 |
+
raw JPG -> quality gate -> ROI extraction -> feature extraction
|
| 4 |
+
|
| 5 |
+
This ensures train/inference feature distributions match.
|
| 6 |
+
Previous models trained on pre-cropped palpebral PNGs but inference
|
| 7 |
+
runs on raw JPGs through the ROI extractor — causing a massive domain gap.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
import sys, json
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).parents[1]))
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import joblib
|
| 16 |
+
from sklearn.ensemble import ExtraTreesClassifier, ExtraTreesRegressor
|
| 17 |
+
from sklearn.metrics import (
|
| 18 |
+
accuracy_score, f1_score, mean_absolute_error,
|
| 19 |
+
precision_score, recall_score, roc_auc_score,
|
| 20 |
+
)
|
| 21 |
+
from sklearn.model_selection import GroupShuffleSplit
|
| 22 |
+
|
| 23 |
+
from app.ml.archive_model import (
|
| 24 |
+
ANEMIA_HB_THRESHOLD, ARCHIVE_FEATURE_NAMES,
|
| 25 |
+
clamp, sigmoid, _parse_workbook, _parse_float, _load_image_with_fallback,
|
| 26 |
+
)
|
| 27 |
+
from app.ml.features import extract_eye_features, FEATURE_NAMES
|
| 28 |
+
from app.services.conjunctiva_roi import ConjunctivaRoiExtractor
|
| 29 |
+
from app.services.image_quality import ImageQualityService
|
| 30 |
+
|
| 31 |
+
DATASET_ROOT = Path(__file__).parents[2] / "archive" / "dataset anemia"
|
| 32 |
+
OUTPUT_PATH = Path(__file__).parents[1] / "models" / "archive_screening_model.joblib"
|
| 33 |
+
REPORT_PATH = Path(__file__).parents[1] / "models" / "training_report.json"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def build_pipeline_aligned_dataset():
|
| 37 |
+
"""
|
| 38 |
+
Load raw JPGs, run through ROI extractor (same as inference),
|
| 39 |
+
extract features. Returns samples with ground-truth Hb.
|
| 40 |
+
"""
|
| 41 |
+
roi_extractor = ConjunctivaRoiExtractor()
|
| 42 |
+
samples = []
|
| 43 |
+
skipped = 0
|
| 44 |
+
|
| 45 |
+
for country in ("India", "Italy"):
|
| 46 |
+
workbook_path = DATASET_ROOT / country / f"{country}.xlsx"
|
| 47 |
+
metadata = _parse_workbook(workbook_path)
|
| 48 |
+
|
| 49 |
+
for subject_number, row in metadata.items():
|
| 50 |
+
hb = _parse_float(row.get("Hgb"))
|
| 51 |
+
if hb is None:
|
| 52 |
+
continue
|
| 53 |
+
|
| 54 |
+
subject_dir = DATASET_ROOT / country / subject_number
|
| 55 |
+
if not subject_dir.exists():
|
| 56 |
+
continue
|
| 57 |
+
|
| 58 |
+
# Use raw JPG — same as what users upload
|
| 59 |
+
jpgs = sorted(subject_dir.glob("*.jpg"))
|
| 60 |
+
if not jpgs:
|
| 61 |
+
skipped += 1
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
raw_img = _load_image_with_fallback(jpgs[0])
|
| 66 |
+
roi_result = roi_extractor.extract(raw_img)
|
| 67 |
+
roi_img = roi_result.image
|
| 68 |
+
features = extract_eye_features(roi_img)
|
| 69 |
+
|
| 70 |
+
# Add source flags (roi_original path)
|
| 71 |
+
prepared = dict(features)
|
| 72 |
+
prepared["source_roi_original"] = 1.0
|
| 73 |
+
prepared["source_segmented"] = 0.0
|
| 74 |
+
prepared["source_forniceal_palpebral"] = 0.0
|
| 75 |
+
|
| 76 |
+
samples.append({
|
| 77 |
+
"group": f"{country}-{subject_number}",
|
| 78 |
+
"hb": hb,
|
| 79 |
+
"label": int(hb < ANEMIA_HB_THRESHOLD),
|
| 80 |
+
"features": prepared,
|
| 81 |
+
})
|
| 82 |
+
except Exception as e:
|
| 83 |
+
skipped += 1
|
| 84 |
+
|
| 85 |
+
print(f" Loaded {len(samples)} samples, skipped {skipped}")
|
| 86 |
+
return samples
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def find_best_threshold(labels, scores):
|
| 90 |
+
best_score, best_thresh = -1.0, 0.5
|
| 91 |
+
for t in np.linspace(0.20, 0.80, 61):
|
| 92 |
+
preds = (scores >= t).astype(int)
|
| 93 |
+
if preds.sum() == 0:
|
| 94 |
+
continue
|
| 95 |
+
f1 = f1_score(labels, preds, zero_division=0)
|
| 96 |
+
rec = recall_score(labels, preds, zero_division=0)
|
| 97 |
+
# Weight recall heavily — medical screening, false negatives are worse
|
| 98 |
+
score = f1 * 0.4 + rec * 0.6
|
| 99 |
+
if score > best_score:
|
| 100 |
+
best_score = score
|
| 101 |
+
best_thresh = float(t)
|
| 102 |
+
return best_thresh
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def main():
|
| 106 |
+
print("=" * 60)
|
| 107 |
+
print("AnemiaLens — pipeline-aligned retraining")
|
| 108 |
+
print("=" * 60)
|
| 109 |
+
|
| 110 |
+
print("\n[1/4] Building pipeline-aligned dataset...")
|
| 111 |
+
samples = build_pipeline_aligned_dataset()
|
| 112 |
+
|
| 113 |
+
feat_names = ARCHIVE_FEATURE_NAMES # 44 features
|
| 114 |
+
rows = np.array([[float(s["features"].get(n, 0.0)) for n in feat_names] for s in samples], dtype=np.float32)
|
| 115 |
+
targets = np.array([s["hb"] for s in samples], dtype=np.float32)
|
| 116 |
+
labels = np.array([s["label"] for s in samples], dtype=np.int32)
|
| 117 |
+
groups = np.array([s["group"] for s in samples], dtype=object)
|
| 118 |
+
|
| 119 |
+
print(f" Samples: {len(samples)}, Anemic: {labels.sum()}, Normal: {(labels==0).sum()}")
|
| 120 |
+
|
| 121 |
+
print("\n[2/4] Cross-validating...")
|
| 122 |
+
splitter = GroupShuffleSplit(n_splits=5, test_size=0.2, random_state=42)
|
| 123 |
+
all_metrics = []
|
| 124 |
+
all_thresholds = []
|
| 125 |
+
|
| 126 |
+
for i, (train_idx, test_idx) in enumerate(splitter.split(rows, labels, groups)):
|
| 127 |
+
print(f" Split {i+1}/5...", flush=True)
|
| 128 |
+
|
| 129 |
+
# Augment training: add noise + oversample anemic
|
| 130 |
+
rng = np.random.default_rng(42 + i)
|
| 131 |
+
tr_rows, tr_targets, tr_labels = rows[train_idx], targets[train_idx], labels[train_idx]
|
| 132 |
+
|
| 133 |
+
# Gaussian noise on all samples
|
| 134 |
+
noisy = tr_rows.copy()
|
| 135 |
+
noisy += rng.normal(0, 0.008, size=noisy.shape)
|
| 136 |
+
noisy = np.clip(noisy, 0.0, 1.0)
|
| 137 |
+
|
| 138 |
+
# 3x oversample anemic
|
| 139 |
+
anemic_idx = np.where(tr_labels == 1)[0]
|
| 140 |
+
copies_list = [tr_rows, noisy]
|
| 141 |
+
t_list = [tr_targets, tr_targets]
|
| 142 |
+
l_list = [tr_labels, tr_labels]
|
| 143 |
+
for _ in range(3):
|
| 144 |
+
copies = tr_rows[anemic_idx].copy()
|
| 145 |
+
copies += rng.normal(0, 0.01, size=copies.shape)
|
| 146 |
+
copies = np.clip(copies, 0.0, 1.0)
|
| 147 |
+
copies_list.append(copies)
|
| 148 |
+
t_list.append(tr_targets[anemic_idx])
|
| 149 |
+
l_list.append(tr_labels[anemic_idx])
|
| 150 |
+
|
| 151 |
+
aug_rows = np.vstack(copies_list)
|
| 152 |
+
aug_targets = np.concatenate(t_list)
|
| 153 |
+
aug_labels = np.concatenate(l_list)
|
| 154 |
+
|
| 155 |
+
reg = ExtraTreesRegressor(n_estimators=300, min_samples_leaf=2, max_features=0.7,
|
| 156 |
+
bootstrap=True, random_state=42+i, n_jobs=1)
|
| 157 |
+
clf = ExtraTreesClassifier(n_estimators=300, min_samples_leaf=2, max_features=0.7,
|
| 158 |
+
bootstrap=True, class_weight="balanced_subsample",
|
| 159 |
+
random_state=42+i, n_jobs=1)
|
| 160 |
+
reg.fit(aug_rows, aug_targets)
|
| 161 |
+
clf.fit(aug_rows, aug_labels)
|
| 162 |
+
|
| 163 |
+
hb_pred = reg.predict(rows[test_idx])
|
| 164 |
+
clf_prob = clf.predict_proba(rows[test_idx])[:, 1]
|
| 165 |
+
|
| 166 |
+
hb_scale = max(float(np.quantile(np.abs(aug_targets - reg.predict(aug_rows)), 0.75)), 0.8)
|
| 167 |
+
reg_risk = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_pred])
|
| 168 |
+
blend = 0.55 * clf_prob + 0.45 * reg_risk
|
| 169 |
+
|
| 170 |
+
thresh = find_best_threshold(labels[test_idx], blend)
|
| 171 |
+
preds = (blend >= thresh).astype(int)
|
| 172 |
+
|
| 173 |
+
all_metrics.append({
|
| 174 |
+
"accuracy": accuracy_score(labels[test_idx], preds),
|
| 175 |
+
"precision": precision_score(labels[test_idx], preds, zero_division=0),
|
| 176 |
+
"recall": recall_score(labels[test_idx], preds, zero_division=0),
|
| 177 |
+
"f1": f1_score(labels[test_idx], preds, zero_division=0),
|
| 178 |
+
"auc": roc_auc_score(labels[test_idx], blend),
|
| 179 |
+
"mae_hb": mean_absolute_error(targets[test_idx], hb_pred),
|
| 180 |
+
})
|
| 181 |
+
all_thresholds.append(thresh)
|
| 182 |
+
|
| 183 |
+
avg = {k: round(float(np.mean([m[k] for m in all_metrics])), 4) for k in all_metrics[0]}
|
| 184 |
+
best_threshold = float(np.mean(all_thresholds))
|
| 185 |
+
print(f"\n CV metrics: {avg}")
|
| 186 |
+
print(f" Best threshold: {best_threshold:.3f}")
|
| 187 |
+
|
| 188 |
+
print("\n[3/4] Training final model on full dataset...")
|
| 189 |
+
rng = np.random.default_rng(42)
|
| 190 |
+
noisy = rows.copy()
|
| 191 |
+
noisy += rng.normal(0, 0.008, size=noisy.shape)
|
| 192 |
+
noisy = np.clip(noisy, 0.0, 1.0)
|
| 193 |
+
anemic_idx = np.where(labels == 1)[0]
|
| 194 |
+
copies_list = [rows, noisy]
|
| 195 |
+
t_list = [targets, targets]
|
| 196 |
+
l_list = [labels, labels]
|
| 197 |
+
for _ in range(3):
|
| 198 |
+
copies = rows[anemic_idx].copy()
|
| 199 |
+
copies += rng.normal(0, 0.01, size=copies.shape)
|
| 200 |
+
copies = np.clip(copies, 0.0, 1.0)
|
| 201 |
+
copies_list.append(copies)
|
| 202 |
+
t_list.append(targets[anemic_idx])
|
| 203 |
+
l_list.append(labels[anemic_idx])
|
| 204 |
+
aug_rows = np.vstack(copies_list)
|
| 205 |
+
aug_targets = np.concatenate(t_list)
|
| 206 |
+
aug_labels = np.concatenate(l_list)
|
| 207 |
+
|
| 208 |
+
reg = ExtraTreesRegressor(n_estimators=300, min_samples_leaf=2, max_features=0.7,
|
| 209 |
+
bootstrap=True, random_state=42, n_jobs=1)
|
| 210 |
+
clf = ExtraTreesClassifier(n_estimators=300, min_samples_leaf=2, max_features=0.7,
|
| 211 |
+
bootstrap=True, class_weight="balanced_subsample",
|
| 212 |
+
random_state=42, n_jobs=1)
|
| 213 |
+
reg.fit(aug_rows, aug_targets)
|
| 214 |
+
clf.fit(aug_rows, aug_labels)
|
| 215 |
+
|
| 216 |
+
hb_preds_full = reg.predict(rows)
|
| 217 |
+
residuals = np.abs(targets - hb_preds_full)
|
| 218 |
+
hb_scale = max(float(np.quantile(residuals, 0.75)), 0.8)
|
| 219 |
+
clf_probs_full = clf.predict_proba(rows)[:, 1]
|
| 220 |
+
reg_risk_full = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_preds_full])
|
| 221 |
+
blend_full = 0.55 * clf_probs_full + 0.45 * reg_risk_full
|
| 222 |
+
risk_scale = max(float(np.std(blend_full)) * 0.9, 0.08)
|
| 223 |
+
risk_scale = min(risk_scale, 0.22)
|
| 224 |
+
|
| 225 |
+
calibration = {
|
| 226 |
+
"hb_threshold": ANEMIA_HB_THRESHOLD,
|
| 227 |
+
"hb_scale": round(hb_scale, 4),
|
| 228 |
+
"hb_population_mean": round(float(np.mean(targets)), 4),
|
| 229 |
+
"hb_spread_factor": 2.0,
|
| 230 |
+
"regressor_tree_std_reference": 1.85,
|
| 231 |
+
"classifier_tree_std_reference": 0.40,
|
| 232 |
+
"classifier_weight": 0.55,
|
| 233 |
+
"blend_threshold": round(best_threshold, 4),
|
| 234 |
+
"risk_scale": round(risk_scale, 4),
|
| 235 |
+
"base_uncertainty": 0.08,
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
artifact = {
|
| 239 |
+
"version": "archive-fusion-v4-pipeline",
|
| 240 |
+
"feature_names": feat_names,
|
| 241 |
+
"regressor": reg,
|
| 242 |
+
"classifier": clf,
|
| 243 |
+
"calibration": calibration,
|
| 244 |
+
"training": {
|
| 245 |
+
"selected_mode": "pipeline_aligned_roi",
|
| 246 |
+
"subject_count": len(samples),
|
| 247 |
+
"record_count": len(samples),
|
| 248 |
+
"metrics": avg,
|
| 249 |
+
},
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
print("\n[4/4] Saving...")
|
| 253 |
+
joblib.dump(artifact, OUTPUT_PATH)
|
| 254 |
+
print(f" Saved -> {OUTPUT_PATH}")
|
| 255 |
+
print(f" Size: {OUTPUT_PATH.stat().st_size / 1024 / 1024:.1f} MB")
|
| 256 |
+
|
| 257 |
+
report = {
|
| 258 |
+
"dataset_name": "dataset anemia (pipeline-aligned)",
|
| 259 |
+
"record_count": len(samples),
|
| 260 |
+
"subject_count": len(samples),
|
| 261 |
+
"primary_model": "archive-fusion-v4-pipeline",
|
| 262 |
+
"selected_mode": "pipeline_aligned_roi",
|
| 263 |
+
"metrics": avg,
|
| 264 |
+
"calibration": {
|
| 265 |
+
"blend_threshold": calibration["blend_threshold"],
|
| 266 |
+
"risk_scale": calibration["risk_scale"],
|
| 267 |
+
"classifier_weight": calibration["classifier_weight"],
|
| 268 |
+
},
|
| 269 |
+
}
|
| 270 |
+
with open(REPORT_PATH, "w") as f:
|
| 271 |
+
json.dump(report, f, indent=2)
|
| 272 |
+
|
| 273 |
+
# Sanity check on training data
|
| 274 |
+
print("\nSanity check (training data):")
|
| 275 |
+
for label, cpi, rg, br in [("PALE", 0.28, 0.02, 0.22), ("NORMAL", 0.44, 0.08, 0.38)]:
|
| 276 |
+
from app.ml.archive_model import prepare_feature_map
|
| 277 |
+
from app.ml.features import FEATURE_NAMES as FN
|
| 278 |
+
feat_map = {n: 0.0 for n in FN}
|
| 279 |
+
feat_map.update({"cpi": cpi, "center_cpi": cpi-0.01, "mean_r": cpi*0.9,
|
| 280 |
+
"mean_g": cpi*0.9-rg, "mean_b": cpi*0.7,
|
| 281 |
+
"red_green_gap": rg, "center_red_green_gap": rg,
|
| 282 |
+
"brightness": br, "center_brightness": br,
|
| 283 |
+
"green_blue_ratio": 1.1 if cpi < 0.35 else 1.25,
|
| 284 |
+
"contrast": 0.12, "center_contrast": 0.12,
|
| 285 |
+
"blur_score": 100.0, "center_blur_score": 120.0,
|
| 286 |
+
"saturation": 0.3, "center_saturation": 0.3,
|
| 287 |
+
"hist_mid": 0.5, "hist_bright": 0.3,
|
| 288 |
+
"aspect_ratio": 1.0, "size_score": 1.0})
|
| 289 |
+
prepared = prepare_feature_map(feat_map, source_hint="roi_original")
|
| 290 |
+
row = np.array([[prepared.get(n, 0.0) for n in feat_names]], dtype=np.float32)
|
| 291 |
+
hb_p = float(reg.predict(row)[0])
|
| 292 |
+
cp = float(clf.predict_proba(row)[0, 1])
|
| 293 |
+
rr = sigmoid((ANEMIA_HB_THRESHOLD - hb_p) / hb_scale)
|
| 294 |
+
bs = 0.55 * cp + 0.45 * rr
|
| 295 |
+
risk = sigmoid((bs - best_threshold) / risk_scale)
|
| 296 |
+
print(f" {label}: Hb={hb_p:.1f}, risk={risk:.3f}")
|
| 297 |
+
|
| 298 |
+
print("\nDone.")
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
if __name__ == "__main__":
|
| 302 |
+
main()
|
backend/scripts/test_endpoint.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests, io, json
|
| 2 |
+
from PIL import Image
|
| 3 |
+
|
| 4 |
+
img = Image.new('RGB', (400, 300), color=(200, 160, 140))
|
| 5 |
+
buf = io.BytesIO()
|
| 6 |
+
img.save(buf, format='JPEG')
|
| 7 |
+
buf.seek(0)
|
| 8 |
+
|
| 9 |
+
symptoms = json.dumps({
|
| 10 |
+
"fatigue": True,
|
| 11 |
+
"pale_skin": True,
|
| 12 |
+
"dizziness": False,
|
| 13 |
+
"shortness_of_breath": False,
|
| 14 |
+
"heavy_menstrual_bleeding": None,
|
| 15 |
+
"poor_diet_low_iron": False
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
r = requests.post(
|
| 19 |
+
'http://localhost:8000/api/analyze',
|
| 20 |
+
files={'image': ('test.jpg', buf, 'image/jpeg')},
|
| 21 |
+
data={'symptoms': symptoms},
|
| 22 |
+
timeout=30
|
| 23 |
+
)
|
| 24 |
+
print('Status:', r.status_code)
|
| 25 |
+
if r.status_code == 200:
|
| 26 |
+
d = r.json()
|
| 27 |
+
pred = d.get('prediction') or {}
|
| 28 |
+
triage = d.get('triage') or {}
|
| 29 |
+
print('Hb:', pred.get('predicted_hemoglobin'))
|
| 30 |
+
print('Risk:', pred.get('anemia_risk'))
|
| 31 |
+
print('Label:', pred.get('screening_label'))
|
| 32 |
+
print('Model:', pred.get('model_source'))
|
| 33 |
+
print('Triage band:', triage.get('band'))
|
| 34 |
+
print('Blocked:', d.get('blocked'))
|
| 35 |
+
else:
|
| 36 |
+
print('Error body:', r.text[:1000])
|
backend/scripts/test_model.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import joblib, sys
|
| 2 |
+
sys.path.insert(0, 'backend')
|
| 3 |
+
from app.ml.archive_model import predict_with_archive_model
|
| 4 |
+
from app.ml.features import FEATURE_NAMES
|
| 5 |
+
|
| 6 |
+
m = joblib.load('backend/models/archive_screening_model.joblib')
|
| 7 |
+
|
| 8 |
+
test_cases = [
|
| 9 |
+
("PALE (anemic)", 0.28, 0.02, 0.22),
|
| 10 |
+
("BORDERLINE", 0.35, 0.04, 0.30),
|
| 11 |
+
("NORMAL", 0.44, 0.08, 0.38),
|
| 12 |
+
("VERY HEALTHY", 0.48, 0.10, 0.42),
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
for label, cpi, rg, br in test_cases:
|
| 16 |
+
feat_map = {n: 0.0 for n in FEATURE_NAMES}
|
| 17 |
+
feat_map['cpi'] = cpi
|
| 18 |
+
feat_map['center_cpi'] = cpi - 0.01
|
| 19 |
+
feat_map['mean_r'] = cpi * 0.9
|
| 20 |
+
feat_map['mean_g'] = cpi * 0.9 - rg
|
| 21 |
+
feat_map['mean_b'] = cpi * 0.7
|
| 22 |
+
feat_map['red_green_gap'] = rg
|
| 23 |
+
feat_map['center_red_green_gap'] = rg
|
| 24 |
+
feat_map['brightness'] = br
|
| 25 |
+
feat_map['green_blue_ratio'] = 1.1 if cpi < 0.35 else 1.25
|
| 26 |
+
feat_map['center_mean_r'] = feat_map['mean_r']
|
| 27 |
+
feat_map['center_mean_g'] = feat_map['mean_g']
|
| 28 |
+
feat_map['center_mean_b'] = feat_map['mean_b']
|
| 29 |
+
feat_map['contrast'] = 0.12
|
| 30 |
+
feat_map['center_contrast'] = 0.12
|
| 31 |
+
feat_map['center_brightness'] = br
|
| 32 |
+
feat_map['blur_score'] = 100.0
|
| 33 |
+
feat_map['center_blur_score'] = 120.0
|
| 34 |
+
feat_map['saturation'] = 0.3
|
| 35 |
+
feat_map['center_saturation'] = 0.3
|
| 36 |
+
feat_map['hist_mid'] = 0.5
|
| 37 |
+
feat_map['hist_bright'] = 0.3
|
| 38 |
+
feat_map['aspect_ratio'] = 1.0
|
| 39 |
+
feat_map['size_score'] = 1.0
|
| 40 |
+
result = predict_with_archive_model(m, feat_map, source_hint='roi_original')
|
| 41 |
+
hb = result['predicted_hemoglobin']
|
| 42 |
+
risk = result['anemia_risk']
|
| 43 |
+
unc = result['uncertainty']
|
| 44 |
+
decision = "ANEMIA LIKELY" if risk >= 0.65 else "unlikely"
|
| 45 |
+
print(f"{label}: Hb={hb:.1f}, risk={risk:.3f}, uncertainty={unc:.3f} -> {decision}")
|
backend/scripts/train_archive_model.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Train the archive conjunctiva screening model.
|
| 3 |
+
|
| 4 |
+
Usage::
|
| 5 |
+
|
| 6 |
+
python scripts/train_archive_model.py [--dataset PATH] [--output-dir PATH] [--quiet]
|
| 7 |
+
|
| 8 |
+
The script trains the model, writes the artefact and a human-readable
|
| 9 |
+
training report, then exits with code 0 on success or 1 on failure.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 21 |
+
BACKEND_ROOT = ROOT / "backend"
|
| 22 |
+
sys.path.insert(0, str(BACKEND_ROOT))
|
| 23 |
+
|
| 24 |
+
from app.config import DEFAULT_ARCHIVE_MODEL_PATH, DEFAULT_TRAINING_REPORT_PATH # noqa: E402
|
| 25 |
+
from app.ml.archive_model import save_archive_model, train_archive_model # noqa: E402
|
| 26 |
+
|
| 27 |
+
DEFAULT_DATASET = ROOT / "archive" / "dataset anemia"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# CLI
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
def _build_parser() -> argparse.ArgumentParser:
|
| 35 |
+
p = argparse.ArgumentParser(
|
| 36 |
+
description="Train the AnemiaLens archive conjunctiva screening model.",
|
| 37 |
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
| 38 |
+
)
|
| 39 |
+
p.add_argument(
|
| 40 |
+
"--dataset",
|
| 41 |
+
type=Path,
|
| 42 |
+
default=DEFAULT_DATASET,
|
| 43 |
+
help="Root directory of the labelled anemia dataset.",
|
| 44 |
+
)
|
| 45 |
+
p.add_argument(
|
| 46 |
+
"--output-dir",
|
| 47 |
+
type=Path,
|
| 48 |
+
default=DEFAULT_ARCHIVE_MODEL_PATH.parent,
|
| 49 |
+
help="Directory where the model artefact and report are written.",
|
| 50 |
+
)
|
| 51 |
+
p.add_argument(
|
| 52 |
+
"--quiet",
|
| 53 |
+
action="store_true",
|
| 54 |
+
help="Suppress progress output (report still written to disk).",
|
| 55 |
+
)
|
| 56 |
+
return p
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ---------------------------------------------------------------------------
|
| 60 |
+
# Main
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
|
| 63 |
+
def main(argv: list[str] | None = None) -> int:
|
| 64 |
+
args = _build_parser().parse_args(argv)
|
| 65 |
+
|
| 66 |
+
if not args.dataset.exists():
|
| 67 |
+
print(
|
| 68 |
+
f"ERROR: Dataset directory not found: {args.dataset}\n"
|
| 69 |
+
" Download the anemia dataset and place it there, or pass --dataset PATH.",
|
| 70 |
+
file=sys.stderr,
|
| 71 |
+
)
|
| 72 |
+
return 1
|
| 73 |
+
|
| 74 |
+
if not args.quiet:
|
| 75 |
+
print(f"Dataset : {args.dataset}")
|
| 76 |
+
print(f"Output : {args.output_dir}")
|
| 77 |
+
print()
|
| 78 |
+
|
| 79 |
+
t0 = time.perf_counter()
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
artifact, report = train_archive_model(args.dataset)
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
print(f"ERROR: Training failed — {exc}", file=sys.stderr)
|
| 85 |
+
return 1
|
| 86 |
+
|
| 87 |
+
elapsed = time.perf_counter() - t0
|
| 88 |
+
|
| 89 |
+
# --- Write artefacts ---------------------------------------------------
|
| 90 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 91 |
+
|
| 92 |
+
model_path = args.output_dir / DEFAULT_ARCHIVE_MODEL_PATH.name
|
| 93 |
+
report_path = args.output_dir / DEFAULT_TRAINING_REPORT_PATH.name
|
| 94 |
+
|
| 95 |
+
save_archive_model(artifact, model_path)
|
| 96 |
+
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 97 |
+
|
| 98 |
+
# --- Summary -----------------------------------------------------------
|
| 99 |
+
if not args.quiet:
|
| 100 |
+
metrics = report.get("metrics", {})
|
| 101 |
+
print(json.dumps(report, indent=2))
|
| 102 |
+
print()
|
| 103 |
+
print("=" * 56)
|
| 104 |
+
print(f" Model : {report.get('primary_model', '?')}")
|
| 105 |
+
print(f" Subjects : {report.get('subject_count', '?')}")
|
| 106 |
+
print(f" Records : {report.get('record_count', '?')}")
|
| 107 |
+
print(f" Accuracy : {metrics.get('accuracy', 0):.3f}")
|
| 108 |
+
print(f" F1 : {metrics.get('f1', 0):.3f}")
|
| 109 |
+
print(f" Val size : {metrics.get('validation_size', '?')}")
|
| 110 |
+
print(f" Elapsed : {elapsed:.1f}s")
|
| 111 |
+
print("=" * 56)
|
| 112 |
+
print(f" Saved model → {model_path}")
|
| 113 |
+
print(f" Saved report → {report_path}")
|
| 114 |
+
|
| 115 |
+
return 0
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
sys.exit(main())
|
backend/scripts/train_efficientnet.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
import random
|
| 6 |
+
from collections import Counter
|
| 7 |
+
from copy import deepcopy
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
from PIL import Image
|
| 15 |
+
from sklearn.metrics import accuracy_score, f1_score, mean_absolute_error, precision_score, recall_score, roc_auc_score
|
| 16 |
+
from sklearn.model_selection import GroupShuffleSplit
|
| 17 |
+
from torch import nn
|
| 18 |
+
from torch.optim import AdamW
|
| 19 |
+
from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler
|
| 20 |
+
|
| 21 |
+
from app.config import (
|
| 22 |
+
BACKEND_ROOT,
|
| 23 |
+
DEFAULT_EFFICIENTNET_MODEL_PATH,
|
| 24 |
+
DEFAULT_EFFICIENTNET_REPORT_PATH,
|
| 25 |
+
DEFAULT_TRAINING_REPORT_PATH,
|
| 26 |
+
)
|
| 27 |
+
from app.ml.archive_model import ANEMIA_HB_THRESHOLD, _first_path, _load_image_with_fallback, _parse_float, _parse_workbook
|
| 28 |
+
from app.ml.efficientnet_model import (
|
| 29 |
+
EFFICIENTNET_VERSION,
|
| 30 |
+
build_efficientnet_model,
|
| 31 |
+
build_train_transform,
|
| 32 |
+
build_val_transform,
|
| 33 |
+
)
|
| 34 |
+
from app.services.conjunctiva_roi import ConjunctivaRoiExtractor
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
DATA_ROOT = BACKEND_ROOT / "data"
|
| 38 |
+
ARCHIVE_ROOT = BACKEND_ROOT.parent / "archive" / "dataset anemia"
|
| 39 |
+
SEED = 42
|
| 40 |
+
BATCH_SIZE = 16
|
| 41 |
+
EPOCHS = 60
|
| 42 |
+
PATIENCE = 15
|
| 43 |
+
MAX_GRAD_NORM = 1.0
|
| 44 |
+
WARMUP_EPOCHS = 5
|
| 45 |
+
LABEL_SMOOTHING = 0.05
|
| 46 |
+
MIXUP_ALPHA = 0.3
|
| 47 |
+
# Hb spread loss: penalizes predictions that cluster near the mean
|
| 48 |
+
HB_SPREAD_WEIGHT = 0.15
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass(frozen=True)
|
| 52 |
+
class ImageRecord:
|
| 53 |
+
subject_id: str
|
| 54 |
+
label: int
|
| 55 |
+
hb: float
|
| 56 |
+
image_path: Path
|
| 57 |
+
source: str
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class ConjunctivaDataset(Dataset):
|
| 61 |
+
def __init__(self, records: list[ImageRecord], transform: object) -> None:
|
| 62 |
+
self.records = records
|
| 63 |
+
self.transform = transform
|
| 64 |
+
self.roi_extractor = ConjunctivaRoiExtractor()
|
| 65 |
+
|
| 66 |
+
def __len__(self) -> int:
|
| 67 |
+
return len(self.records)
|
| 68 |
+
|
| 69 |
+
def __getitem__(self, index: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 70 |
+
record = self.records[index]
|
| 71 |
+
image, label, hb = self._prepare_item(record)
|
| 72 |
+
tensor = self.transform(image)
|
| 73 |
+
return tensor, torch.tensor([label], dtype=torch.float32), torch.tensor([hb], dtype=torch.float32)
|
| 74 |
+
|
| 75 |
+
def _prepare_item(self, record: ImageRecord) -> tuple[Image.Image, float, float]:
|
| 76 |
+
image = _load_image_with_fallback(record.image_path)
|
| 77 |
+
if record.source == "roi_original":
|
| 78 |
+
image = self.roi_extractor.extract(image).image
|
| 79 |
+
return image.convert("RGB"), float(record.label), float(record.hb)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class FocalLoss(nn.Module):
|
| 83 |
+
def __init__(self, alpha: float = 0.25, gamma: float = 2.0, pos_weight: torch.Tensor | None = None) -> None:
|
| 84 |
+
super().__init__()
|
| 85 |
+
self.alpha = alpha
|
| 86 |
+
self.gamma = gamma
|
| 87 |
+
self.bce = nn.BCEWithLogitsLoss(pos_weight=pos_weight, reduction="none")
|
| 88 |
+
|
| 89 |
+
def forward(self, inputs: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
|
| 90 |
+
bce_loss = self.bce(inputs, targets)
|
| 91 |
+
probabilities = torch.sigmoid(inputs)
|
| 92 |
+
p_t = probabilities * targets + (1 - probabilities) * (1 - targets)
|
| 93 |
+
loss = bce_loss * ((1 - p_t) ** self.gamma)
|
| 94 |
+
if self.alpha >= 0:
|
| 95 |
+
alpha_t = self.alpha * targets + (1 - self.alpha) * (1 - targets)
|
| 96 |
+
loss = alpha_t * loss
|
| 97 |
+
return loss.mean()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def main() -> None:
|
| 101 |
+
_set_seed(SEED)
|
| 102 |
+
dataset_root = DATA_ROOT if DATA_ROOT.exists() else ARCHIVE_ROOT
|
| 103 |
+
if not dataset_root.exists():
|
| 104 |
+
raise RuntimeError(f"No dataset directory found at {DATA_ROOT} or {ARCHIVE_ROOT}.")
|
| 105 |
+
|
| 106 |
+
records = _build_records(dataset_root)
|
| 107 |
+
if not records:
|
| 108 |
+
raise RuntimeError(f"No training records found in {dataset_root}.")
|
| 109 |
+
|
| 110 |
+
train_records, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
|
| 111 |
+
train_dataset = ConjunctivaDataset(train_records, build_train_transform())
|
| 112 |
+
val_dataset = ConjunctivaDataset(val_records, build_val_transform())
|
| 113 |
+
hb_mean, hb_std = _hb_normalization_stats(train_records)
|
| 114 |
+
train_sampler = _build_weighted_sampler(train_records)
|
| 115 |
+
|
| 116 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 117 |
+
model = build_efficientnet_model(pretrained=True).to(device)
|
| 118 |
+
optimizer = AdamW(
|
| 119 |
+
[
|
| 120 |
+
{"params": list(model.classifier.parameters()), "lr": 1.5e-4}, # Slightly lower for stability
|
| 121 |
+
{"params": [param for param in model.features.parameters() if param.requires_grad], "lr": 5e-6},
|
| 122 |
+
],
|
| 123 |
+
weight_decay=4e-4, # Higher weight decay for better regularization
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Warmup then cosine annealing
|
| 127 |
+
def warmup_cosine_lr(epoch: int) -> float:
|
| 128 |
+
if epoch < WARMUP_EPOCHS:
|
| 129 |
+
return float(epoch + 1) / WARMUP_EPOCHS
|
| 130 |
+
progress = (epoch - WARMUP_EPOCHS) / max(EPOCHS - WARMUP_EPOCHS, 1)
|
| 131 |
+
return 0.5 * (1.0 + math.cos(math.pi * progress))
|
| 132 |
+
|
| 133 |
+
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=warmup_cosine_lr)
|
| 134 |
+
|
| 135 |
+
# Use Focal Loss with positive weights
|
| 136 |
+
pos_weight = torch.tensor([_positive_class_weight(train_records)], device=device)
|
| 137 |
+
cls_loss_fn = FocalLoss(alpha=0.25, gamma=2.0, pos_weight=pos_weight)
|
| 138 |
+
hb_loss_fn = nn.SmoothL1Loss(beta=0.5)
|
| 139 |
+
|
| 140 |
+
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, sampler=train_sampler, num_workers=0)
|
| 141 |
+
val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)
|
| 142 |
+
|
| 143 |
+
best_state: dict[str, torch.Tensor] | None = None
|
| 144 |
+
best_metrics: dict[str, float] | None = None
|
| 145 |
+
best_threshold = 0.5
|
| 146 |
+
best_score = -1.0
|
| 147 |
+
epochs_without_improvement = 0
|
| 148 |
+
history: list[dict[str, float]] = []
|
| 149 |
+
|
| 150 |
+
for epoch in range(1, EPOCHS + 1):
|
| 151 |
+
model.train()
|
| 152 |
+
train_loss_total = 0.0
|
| 153 |
+
|
| 154 |
+
for images, labels, hbs in train_loader:
|
| 155 |
+
images = images.to(device)
|
| 156 |
+
labels = labels.to(device)
|
| 157 |
+
hbs = hbs.to(device)
|
| 158 |
+
normalized_hbs = (hbs - hb_mean) / hb_std
|
| 159 |
+
|
| 160 |
+
# MixUp augmentation
|
| 161 |
+
if MIXUP_ALPHA > 0 and np.random.random() < 0.5:
|
| 162 |
+
lam = float(np.random.beta(MIXUP_ALPHA, MIXUP_ALPHA))
|
| 163 |
+
idx = torch.randperm(images.size(0), device=device)
|
| 164 |
+
images = lam * images + (1.0 - lam) * images[idx]
|
| 165 |
+
labels_a, labels_b = labels, labels[idx]
|
| 166 |
+
hbs_a, hbs_b = normalized_hbs, normalized_hbs[idx]
|
| 167 |
+
|
| 168 |
+
optimizer.zero_grad(set_to_none=True)
|
| 169 |
+
output = model(images)
|
| 170 |
+
# Label smoothing applied to both MixUp targets
|
| 171 |
+
smooth_a = labels_a * (1.0 - LABEL_SMOOTHING) + 0.5 * LABEL_SMOOTHING
|
| 172 |
+
smooth_b = labels_b * (1.0 - LABEL_SMOOTHING) + 0.5 * LABEL_SMOOTHING
|
| 173 |
+
cls_loss = lam * cls_loss_fn(output[:, 0:1], smooth_a) + (1.0 - lam) * cls_loss_fn(output[:, 0:1], smooth_b)
|
| 174 |
+
hb_loss = lam * hb_loss_fn(output[:, 1:2], hbs_a) + (1.0 - lam) * hb_loss_fn(output[:, 1:2], hbs_b)
|
| 175 |
+
else:
|
| 176 |
+
optimizer.zero_grad(set_to_none=True)
|
| 177 |
+
output = model(images)
|
| 178 |
+
smooth_labels = labels * (1.0 - LABEL_SMOOTHING) + 0.5 * LABEL_SMOOTHING
|
| 179 |
+
cls_loss = cls_loss_fn(output[:, 0:1], smooth_labels)
|
| 180 |
+
hb_loss = hb_loss_fn(output[:, 1:2], normalized_hbs)
|
| 181 |
+
|
| 182 |
+
# Hb spread loss: penalize predictions clustering near zero (normalized mean)
|
| 183 |
+
# Encourages the model to predict a wider range of Hb values
|
| 184 |
+
hb_pred_norm = output[:, 1:2]
|
| 185 |
+
spread_loss = torch.clamp(0.5 - hb_pred_norm.std(), min=0.0)
|
| 186 |
+
|
| 187 |
+
total_loss = (0.60 * cls_loss) + (0.30 * hb_loss) + (HB_SPREAD_WEIGHT * spread_loss)
|
| 188 |
+
total_loss.backward()
|
| 189 |
+
nn.utils.clip_grad_norm_(model.parameters(), MAX_GRAD_NORM)
|
| 190 |
+
optimizer.step()
|
| 191 |
+
train_loss_total += float(total_loss.item()) * images.size(0)
|
| 192 |
+
|
| 193 |
+
scheduler.step()
|
| 194 |
+
val_metrics = _evaluate_model(model, val_loader, device, hb_mean=hb_mean, hb_std=hb_std)
|
| 195 |
+
history.append(
|
| 196 |
+
{
|
| 197 |
+
"epoch": float(epoch),
|
| 198 |
+
"train_loss": round(train_loss_total / max(len(train_dataset), 1), 4),
|
| 199 |
+
"val_f1": val_metrics["f1"],
|
| 200 |
+
"val_auc": val_metrics["auc"],
|
| 201 |
+
"val_hb_mae": val_metrics["hb_mae"],
|
| 202 |
+
}
|
| 203 |
+
)
|
| 204 |
+
print(
|
| 205 |
+
f"epoch={epoch:02d} train_loss={history[-1]['train_loss']:.4f} "
|
| 206 |
+
f"val_f1={val_metrics['f1']:.4f} val_auc={val_metrics['auc']:.4f} "
|
| 207 |
+
f"val_hb_mae={val_metrics['hb_mae']:.4f}"
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
# Use composite score: AUC weighted more heavily than F1 (more stable early on)
|
| 211 |
+
composite_score = val_metrics["auc"] * 0.55 + val_metrics["f1"] * 0.35 + (1.0 - min(val_metrics["hb_mae"] / 4.0, 1.0)) * 0.10
|
| 212 |
+
if composite_score > best_score:
|
| 213 |
+
best_score = composite_score
|
| 214 |
+
best_state = deepcopy(model.state_dict())
|
| 215 |
+
best_metrics = val_metrics
|
| 216 |
+
best_threshold = val_metrics["decision_threshold"]
|
| 217 |
+
epochs_without_improvement = 0
|
| 218 |
+
else:
|
| 219 |
+
epochs_without_improvement += 1
|
| 220 |
+
|
| 221 |
+
if epochs_without_improvement >= PATIENCE:
|
| 222 |
+
print(f"Early stopping after {epoch} epochs.")
|
| 223 |
+
break
|
| 224 |
+
|
| 225 |
+
if best_state is None or best_metrics is None:
|
| 226 |
+
raise RuntimeError("EfficientNet training did not produce a valid checkpoint.")
|
| 227 |
+
|
| 228 |
+
checkpoint = {
|
| 229 |
+
"version": EFFICIENTNET_VERSION,
|
| 230 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 231 |
+
"state_dict": best_state,
|
| 232 |
+
"decision_threshold": best_threshold,
|
| 233 |
+
"hb_mean": hb_mean,
|
| 234 |
+
"hb_std": hb_std,
|
| 235 |
+
"hb_spread_factor": _compute_hb_spread_factor(val_records, hb_mean, hb_std),
|
| 236 |
+
"val_metrics": best_metrics,
|
| 237 |
+
}
|
| 238 |
+
DEFAULT_EFFICIENTNET_MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 239 |
+
torch.save(checkpoint, DEFAULT_EFFICIENTNET_MODEL_PATH)
|
| 240 |
+
|
| 241 |
+
report = {
|
| 242 |
+
"dataset_name": str(dataset_root.name),
|
| 243 |
+
"record_count": len(records),
|
| 244 |
+
"subject_count": len({record.subject_id for record in records}),
|
| 245 |
+
"primary_model": EFFICIENTNET_VERSION,
|
| 246 |
+
"selected_mode": "efficientnet_hybrid_dual",
|
| 247 |
+
"source_counts": _source_counts(records),
|
| 248 |
+
"metrics": {
|
| 249 |
+
"accuracy": round(best_metrics["accuracy"], 4),
|
| 250 |
+
"precision": round(best_metrics["precision"], 4),
|
| 251 |
+
"recall": round(best_metrics["recall"], 4),
|
| 252 |
+
"f1": round(best_metrics["f1"], 4),
|
| 253 |
+
"auc": round(best_metrics["auc"], 4),
|
| 254 |
+
"mae_hb": round(best_metrics["hb_mae"], 4),
|
| 255 |
+
"validation_size": len(val_records),
|
| 256 |
+
"split_strategy": "group-shuffle-balance-select",
|
| 257 |
+
"sample_count": len(records),
|
| 258 |
+
"subject_count": len({record.subject_id for record in records}),
|
| 259 |
+
"decision_threshold": round(best_threshold, 4),
|
| 260 |
+
},
|
| 261 |
+
"training": {
|
| 262 |
+
"epochs_requested": EPOCHS,
|
| 263 |
+
"history": history,
|
| 264 |
+
"batch_size": BATCH_SIZE,
|
| 265 |
+
"patience": PATIENCE,
|
| 266 |
+
"device": str(device),
|
| 267 |
+
"hb_target_mean": round(hb_mean, 4),
|
| 268 |
+
"hb_target_std": round(hb_std, 4),
|
| 269 |
+
"class_positive_weight": round(_positive_class_weight(train_records), 4),
|
| 270 |
+
"sampler": "weighted-random-balanced",
|
| 271 |
+
},
|
| 272 |
+
}
|
| 273 |
+
DEFAULT_EFFICIENTNET_REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 274 |
+
DEFAULT_TRAINING_REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 275 |
+
|
| 276 |
+
print("\nBest validation metrics")
|
| 277 |
+
for key in ("accuracy", "precision", "recall", "f1", "auc", "hb_mae", "decision_threshold"):
|
| 278 |
+
print(f"{key}: {best_metrics[key]:.4f}")
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _build_records(dataset_root: Path) -> list[ImageRecord]:
|
| 282 |
+
records: list[ImageRecord] = []
|
| 283 |
+
for country in ("India", "Italy"):
|
| 284 |
+
workbook_path = dataset_root / country / f"{country}.xlsx"
|
| 285 |
+
if not workbook_path.exists():
|
| 286 |
+
continue
|
| 287 |
+
metadata = _parse_workbook(workbook_path)
|
| 288 |
+
for subject_number, row in metadata.items():
|
| 289 |
+
hb = _parse_float(row.get("Hgb"))
|
| 290 |
+
if hb is None:
|
| 291 |
+
continue
|
| 292 |
+
|
| 293 |
+
subject_dir = dataset_root / country / subject_number
|
| 294 |
+
if not subject_dir.exists():
|
| 295 |
+
continue
|
| 296 |
+
|
| 297 |
+
subject_id = f"{country}-{subject_number}"
|
| 298 |
+
label = int(hb < ANEMIA_HB_THRESHOLD)
|
| 299 |
+
original_path = _first_path(subject_dir.glob("*.jpg"))
|
| 300 |
+
palpebral_path = _first_path(
|
| 301 |
+
path
|
| 302 |
+
for path in subject_dir.glob("*_palpebral.png")
|
| 303 |
+
if "forniceal_palpebral" not in path.name.lower()
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
if original_path is not None:
|
| 307 |
+
records.append(
|
| 308 |
+
ImageRecord(
|
| 309 |
+
subject_id=subject_id,
|
| 310 |
+
label=label,
|
| 311 |
+
hb=float(hb),
|
| 312 |
+
image_path=original_path,
|
| 313 |
+
source="roi_original",
|
| 314 |
+
)
|
| 315 |
+
)
|
| 316 |
+
if palpebral_path is not None:
|
| 317 |
+
records.append(
|
| 318 |
+
ImageRecord(
|
| 319 |
+
subject_id=subject_id,
|
| 320 |
+
label=label,
|
| 321 |
+
hb=float(hb),
|
| 322 |
+
image_path=palpebral_path,
|
| 323 |
+
source="palpebral",
|
| 324 |
+
)
|
| 325 |
+
)
|
| 326 |
+
return records
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def _balanced_group_split(
|
| 330 |
+
records: list[ImageRecord],
|
| 331 |
+
*,
|
| 332 |
+
test_size: float,
|
| 333 |
+
n_splits: int,
|
| 334 |
+
) -> tuple[list[ImageRecord], list[ImageRecord]]:
|
| 335 |
+
labels = np.asarray([record.label for record in records], dtype=np.int32)
|
| 336 |
+
groups = np.asarray([record.subject_id for record in records], dtype=object)
|
| 337 |
+
target_ratio = float(labels.mean())
|
| 338 |
+
splitter = GroupShuffleSplit(n_splits=n_splits, test_size=test_size, random_state=SEED)
|
| 339 |
+
|
| 340 |
+
best: tuple[np.ndarray, np.ndarray] | None = None
|
| 341 |
+
best_score = float("inf")
|
| 342 |
+
for train_index, val_index in splitter.split(np.zeros(len(records)), labels, groups):
|
| 343 |
+
train_labels = labels[train_index]
|
| 344 |
+
val_labels = labels[val_index]
|
| 345 |
+
if len(np.unique(train_labels)) < 2 or len(np.unique(val_labels)) < 2:
|
| 346 |
+
continue
|
| 347 |
+
score = abs(float(train_labels.mean()) - target_ratio) + abs(float(val_labels.mean()) - target_ratio)
|
| 348 |
+
if score < best_score:
|
| 349 |
+
best_score = score
|
| 350 |
+
best = (train_index, val_index)
|
| 351 |
+
|
| 352 |
+
if best is None:
|
| 353 |
+
raise RuntimeError("Unable to create a grouped train/validation split.")
|
| 354 |
+
|
| 355 |
+
train_index, val_index = best
|
| 356 |
+
return [records[i] for i in train_index], [records[i] for i in val_index]
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _evaluate_model(
|
| 360 |
+
model: nn.Module,
|
| 361 |
+
loader: DataLoader,
|
| 362 |
+
device: torch.device,
|
| 363 |
+
*,
|
| 364 |
+
hb_mean: float,
|
| 365 |
+
hb_std: float,
|
| 366 |
+
) -> dict[str, float]:
|
| 367 |
+
model.eval()
|
| 368 |
+
probabilities: list[float] = []
|
| 369 |
+
labels: list[int] = []
|
| 370 |
+
hb_predictions: list[float] = []
|
| 371 |
+
hb_targets: list[float] = []
|
| 372 |
+
|
| 373 |
+
with torch.no_grad():
|
| 374 |
+
for images, batch_labels, batch_hbs in loader:
|
| 375 |
+
images = images.to(device)
|
| 376 |
+
output = model(images)
|
| 377 |
+
probabilities.extend(torch.sigmoid(output[:, 0]).cpu().tolist())
|
| 378 |
+
hb_predictions.extend(((output[:, 1].cpu() * hb_std) + hb_mean).tolist())
|
| 379 |
+
labels.extend(batch_labels.squeeze(1).cpu().int().tolist())
|
| 380 |
+
hb_targets.extend(batch_hbs.squeeze(1).cpu().tolist())
|
| 381 |
+
|
| 382 |
+
threshold = _best_threshold(np.asarray(labels), np.asarray(probabilities))
|
| 383 |
+
predicted_labels = [1 if probability >= threshold else 0 for probability in probabilities]
|
| 384 |
+
auc = roc_auc_score(labels, probabilities) if len(set(labels)) > 1 else 0.5
|
| 385 |
+
|
| 386 |
+
return {
|
| 387 |
+
"accuracy": float(accuracy_score(labels, predicted_labels)),
|
| 388 |
+
"precision": float(precision_score(labels, predicted_labels, zero_division=0)),
|
| 389 |
+
"recall": float(recall_score(labels, predicted_labels, zero_division=0)),
|
| 390 |
+
"f1": float(f1_score(labels, predicted_labels, zero_division=0)),
|
| 391 |
+
"auc": float(auc),
|
| 392 |
+
"hb_mae": float(mean_absolute_error(hb_targets, hb_predictions)),
|
| 393 |
+
"decision_threshold": float(threshold),
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _best_threshold(labels: np.ndarray, probabilities: np.ndarray) -> float:
|
| 398 |
+
best_threshold = 0.5
|
| 399 |
+
best_score = -1.0
|
| 400 |
+
for threshold in np.linspace(0.25, 0.75, 51):
|
| 401 |
+
predictions = (probabilities >= threshold).astype(np.int32)
|
| 402 |
+
score = f1_score(labels, predictions, zero_division=0)
|
| 403 |
+
if score > best_score:
|
| 404 |
+
best_score = float(score)
|
| 405 |
+
best_threshold = float(threshold)
|
| 406 |
+
return best_threshold
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def _source_counts(records: list[ImageRecord]) -> dict[str, int]:
|
| 410 |
+
counts: dict[str, int] = {}
|
| 411 |
+
for record in records:
|
| 412 |
+
counts[record.source] = counts.get(record.source, 0) + 1
|
| 413 |
+
return counts
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def _positive_class_weight(records: list[ImageRecord]) -> float:
|
| 417 |
+
counts = Counter(record.label for record in records)
|
| 418 |
+
positive = max(counts.get(1, 0), 1)
|
| 419 |
+
negative = max(counts.get(0, 0), 1)
|
| 420 |
+
return float(negative / positive)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def _build_weighted_sampler(records: list[ImageRecord]) -> WeightedRandomSampler:
|
| 424 |
+
counts = Counter(record.label for record in records)
|
| 425 |
+
total = sum(counts.values())
|
| 426 |
+
weights = [
|
| 427 |
+
float(total / max(counts[record.label], 1))
|
| 428 |
+
for record in records
|
| 429 |
+
]
|
| 430 |
+
return WeightedRandomSampler(
|
| 431 |
+
torch.as_tensor(weights, dtype=torch.double),
|
| 432 |
+
num_samples=len(weights),
|
| 433 |
+
replacement=True,
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def _hb_normalization_stats(records: list[ImageRecord]) -> tuple[float, float]:
|
| 438 |
+
values = np.asarray([record.hb for record in records], dtype=np.float32)
|
| 439 |
+
mean = float(values.mean())
|
| 440 |
+
std = float(values.std())
|
| 441 |
+
return mean, max(std, 1e-3)
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _set_seed(seed: int) -> None:
|
| 445 |
+
random.seed(seed)
|
| 446 |
+
np.random.seed(seed)
|
| 447 |
+
torch.manual_seed(seed)
|
| 448 |
+
if torch.cuda.is_available():
|
| 449 |
+
torch.cuda.manual_seed_all(seed)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _compute_hb_spread_factor(records: list[ImageRecord], hb_mean: float, hb_std: float) -> float:
|
| 453 |
+
"""
|
| 454 |
+
Estimate the spread amplification factor needed to correct regression-to-mean.
|
| 455 |
+
Uses the ratio of true Hb std to the expected model output std (hb_std * 0.75 heuristic).
|
| 456 |
+
"""
|
| 457 |
+
true_std = float(np.std([r.hb for r in records]))
|
| 458 |
+
# Models typically predict ~75% of true std due to averaging
|
| 459 |
+
predicted_std_estimate = max(hb_std * 0.75, 0.5)
|
| 460 |
+
factor = float(np.clip(true_std / predicted_std_estimate, 1.0, 2.0))
|
| 461 |
+
return round(factor, 3)
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
if __name__ == "__main__":
|
| 465 |
+
main()
|
backend/scripts/train_ensemble.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Train all models in the AnemiaLens ensemble pipeline.
|
| 3 |
+
|
| 4 |
+
Currently delegates to train_archive_model. As the ensemble grows
|
| 5 |
+
(deep-stack, legacy CNN, etc.) this script will orchestrate each
|
| 6 |
+
training job in dependency order and produce a combined manifest.
|
| 7 |
+
|
| 8 |
+
Usage::
|
| 9 |
+
|
| 10 |
+
python scripts/train_ensemble.py [--dataset PATH] [--output-dir PATH] [--quiet]
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
# Ensure the scripts directory is on the path so we can import sibling scripts.
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 20 |
+
|
| 21 |
+
from train_archive_model import main as train_archive
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main(argv: list[str] | None = None) -> int:
|
| 25 |
+
"""
|
| 26 |
+
Orchestrate all training jobs.
|
| 27 |
+
|
| 28 |
+
Returns the exit code of the last failing job, or 0 if all succeeded.
|
| 29 |
+
"""
|
| 30 |
+
exit_code = 0
|
| 31 |
+
|
| 32 |
+
print("=== Step 1/1: archive screening model ===")
|
| 33 |
+
rc = train_archive(argv)
|
| 34 |
+
if rc != 0:
|
| 35 |
+
print(f" FAILED (exit {rc})", file=sys.stderr)
|
| 36 |
+
exit_code = rc
|
| 37 |
+
else:
|
| 38 |
+
print(" Done.")
|
| 39 |
+
|
| 40 |
+
# Future steps (uncomment when models are ready):
|
| 41 |
+
# print("=== Step 2/N: deep-stack model ===")
|
| 42 |
+
# rc = train_deep_stack(argv)
|
| 43 |
+
# ...
|
| 44 |
+
|
| 45 |
+
return exit_code
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
if __name__ == "__main__":
|
| 49 |
+
sys.exit(main())
|
backend/scripts/train_stacked.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
train_stacked.py — AnemiaLens stacked-ensemble-v4 training script.
|
| 3 |
+
|
| 4 |
+
Architecture
|
| 5 |
+
------------
|
| 6 |
+
Level-0 base learners (out-of-fold predictions via cross_val_predict):
|
| 7 |
+
• XGBoost regressor → OOF Hb predictions
|
| 8 |
+
• XGBoost classifier → OOF anemia probabilities
|
| 9 |
+
• ExtraTrees regressor → OOF Hb predictions
|
| 10 |
+
• ExtraTrees classifier → OOF anemia probabilities
|
| 11 |
+
|
| 12 |
+
Level-1 meta-learners:
|
| 13 |
+
• Ridge regression → final Hb estimate
|
| 14 |
+
• Logistic Regression → final anemia risk probability
|
| 15 |
+
|
| 16 |
+
Data augmentation (training folds only):
|
| 17 |
+
• Gaussian noise on color features (sigma=0.01)
|
| 18 |
+
• CPI jitter ±0.02
|
| 19 |
+
• 3× oversampling of anemic class (label=1)
|
| 20 |
+
|
| 21 |
+
Run from workspace root:
|
| 22 |
+
python backend/scripts/train_stacked.py
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import sys
|
| 27 |
+
import json
|
| 28 |
+
import math
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
sys.path.insert(0, str(Path(__file__).parents[1]))
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
import joblib
|
| 35 |
+
from sklearn.ensemble import ExtraTreesClassifier, ExtraTreesRegressor
|
| 36 |
+
from sklearn.linear_model import LogisticRegression, Ridge
|
| 37 |
+
from sklearn.metrics import (
|
| 38 |
+
accuracy_score, f1_score, mean_absolute_error,
|
| 39 |
+
precision_score, recall_score, roc_auc_score,
|
| 40 |
+
)
|
| 41 |
+
from sklearn.model_selection import GroupShuffleSplit, RandomizedSearchCV
|
| 42 |
+
from sklearn.model_selection import cross_val_predict
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
from xgboost import XGBClassifier, XGBRegressor
|
| 46 |
+
_HAS_XGB = True
|
| 47 |
+
except ImportError:
|
| 48 |
+
_HAS_XGB = False
|
| 49 |
+
print("WARNING: xgboost not installed — falling back to ExtraTrees-only stack.")
|
| 50 |
+
print(" Install with: pip install xgboost")
|
| 51 |
+
|
| 52 |
+
from app.ml.archive_model import (
|
| 53 |
+
ANEMIA_HB_THRESHOLD,
|
| 54 |
+
_build_subject_catalog,
|
| 55 |
+
_samples_for_mode,
|
| 56 |
+
_rows_from_samples,
|
| 57 |
+
clamp,
|
| 58 |
+
sigmoid,
|
| 59 |
+
)
|
| 60 |
+
from app.ml.features import FEATURE_NAMES, COLOR_FEATURES
|
| 61 |
+
from app.ml.stacked_model import StackedRegressor, StackedClassifier
|
| 62 |
+
|
| 63 |
+
DATASET_ROOT = Path(__file__).parents[2] / "archive" / "dataset anemia"
|
| 64 |
+
OUTPUT_PATH = Path(__file__).parents[1] / "models" / "archive_screening_model.joblib"
|
| 65 |
+
OUTPUT_PATH_V4 = Path(__file__).parents[1] / "models" / "archive_screening_model_v4.joblib"
|
| 66 |
+
REPORT_PATH = Path(__file__).parents[1] / "models" / "training_report.json"
|
| 67 |
+
|
| 68 |
+
# Feature names for the v4 artifact (includes source flags)
|
| 69 |
+
V4_FEATURE_NAMES = FEATURE_NAMES + [
|
| 70 |
+
"source_roi_original",
|
| 71 |
+
"source_segmented",
|
| 72 |
+
"source_forniceal_palpebral",
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
# Indices of color features used for augmentation
|
| 76 |
+
_COLOR_IDX = [V4_FEATURE_NAMES.index(n) for n in COLOR_FEATURES if n in V4_FEATURE_NAMES]
|
| 77 |
+
# Index of CPI feature for jitter
|
| 78 |
+
_CPI_IDX = V4_FEATURE_NAMES.index("cpi")
|
| 79 |
+
|
| 80 |
+
N_CV_SPLITS = 5
|
| 81 |
+
RANDOM_STATE = 42
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 85 |
+
# Augmentation
|
| 86 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 87 |
+
|
| 88 |
+
def augment_training_data(
|
| 89 |
+
rows: np.ndarray,
|
| 90 |
+
targets: np.ndarray,
|
| 91 |
+
labels: np.ndarray,
|
| 92 |
+
groups: np.ndarray,
|
| 93 |
+
rng: np.random.Generator,
|
| 94 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
| 95 |
+
"""
|
| 96 |
+
Augment training data:
|
| 97 |
+
1. Add Gaussian noise (sigma=0.01) to color features for ALL samples.
|
| 98 |
+
2. Oversample anemic class (label=1) 3× with CPI jitter ±0.02.
|
| 99 |
+
Returns augmented arrays (originals + augmented copies).
|
| 100 |
+
"""
|
| 101 |
+
n = len(rows)
|
| 102 |
+
|
| 103 |
+
# --- noise augmentation for all samples ---
|
| 104 |
+
noisy = rows.copy()
|
| 105 |
+
noise = rng.normal(0, 0.01, size=(n, len(_COLOR_IDX)))
|
| 106 |
+
noisy[:, _COLOR_IDX] += noise
|
| 107 |
+
noisy = np.clip(noisy, 0.0, 1.0)
|
| 108 |
+
|
| 109 |
+
aug_rows = [rows, noisy]
|
| 110 |
+
aug_targets = [targets, targets]
|
| 111 |
+
aug_labels = [labels, labels]
|
| 112 |
+
aug_groups = [groups, groups]
|
| 113 |
+
|
| 114 |
+
# --- 3× oversample anemic samples with CPI jitter ---
|
| 115 |
+
anemic_idx = np.where(labels == 1)[0]
|
| 116 |
+
for _ in range(3):
|
| 117 |
+
copies = rows[anemic_idx].copy()
|
| 118 |
+
jitter = rng.uniform(-0.02, 0.02, size=len(anemic_idx))
|
| 119 |
+
copies[:, _CPI_IDX] = np.clip(copies[:, _CPI_IDX] + jitter, 0.0, 1.0)
|
| 120 |
+
# Also add small noise to other color features
|
| 121 |
+
color_noise = rng.normal(0, 0.01, size=(len(anemic_idx), len(_COLOR_IDX)))
|
| 122 |
+
copies[:, _COLOR_IDX] = np.clip(copies[:, _COLOR_IDX] + color_noise, 0.0, 1.0)
|
| 123 |
+
aug_rows.append(copies)
|
| 124 |
+
aug_targets.append(targets[anemic_idx])
|
| 125 |
+
aug_labels.append(labels[anemic_idx])
|
| 126 |
+
aug_groups.append(groups[anemic_idx])
|
| 127 |
+
|
| 128 |
+
return (
|
| 129 |
+
np.vstack(aug_rows),
|
| 130 |
+
np.concatenate(aug_targets),
|
| 131 |
+
np.concatenate(aug_labels),
|
| 132 |
+
np.concatenate(aug_groups),
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 137 |
+
# Base learner builders
|
| 138 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 139 |
+
|
| 140 |
+
def _et_regressor(rs: int = RANDOM_STATE) -> ExtraTreesRegressor:
|
| 141 |
+
return ExtraTreesRegressor(
|
| 142 |
+
n_estimators=300, min_samples_leaf=2, max_features=0.7,
|
| 143 |
+
bootstrap=True, random_state=rs, n_jobs=1,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _et_classifier(rs: int = RANDOM_STATE) -> ExtraTreesClassifier:
|
| 148 |
+
return ExtraTreesClassifier(
|
| 149 |
+
n_estimators=300, min_samples_leaf=2, max_features=0.7,
|
| 150 |
+
bootstrap=True, class_weight="balanced_subsample",
|
| 151 |
+
random_state=rs, n_jobs=1,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _xgb_regressor(rs: int = RANDOM_STATE) -> "XGBRegressor":
|
| 156 |
+
return XGBRegressor(
|
| 157 |
+
n_estimators=300, max_depth=4, learning_rate=0.05,
|
| 158 |
+
subsample=0.8, colsample_bytree=0.8,
|
| 159 |
+
random_state=rs, n_jobs=1, verbosity=0,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _xgb_classifier(rs: int = RANDOM_STATE) -> "XGBClassifier":
|
| 164 |
+
return XGBClassifier(
|
| 165 |
+
n_estimators=300, max_depth=4, learning_rate=0.05,
|
| 166 |
+
subsample=0.8, colsample_bytree=0.8,
|
| 167 |
+
use_label_encoder=False, eval_metric="logloss",
|
| 168 |
+
random_state=rs, n_jobs=1, verbosity=0,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 173 |
+
# Hyperparameter tuning
|
| 174 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 175 |
+
|
| 176 |
+
def tune_et_regressor(rows: np.ndarray, targets: np.ndarray) -> ExtraTreesRegressor:
|
| 177 |
+
param_dist = {
|
| 178 |
+
"n_estimators": [100, 200, 300, 400],
|
| 179 |
+
"min_samples_leaf": [1, 2, 3, 4],
|
| 180 |
+
"max_features": [0.5, 0.6, 0.7, 0.8, "sqrt"],
|
| 181 |
+
}
|
| 182 |
+
base = ExtraTreesRegressor(bootstrap=True, random_state=RANDOM_STATE, n_jobs=1)
|
| 183 |
+
search = RandomizedSearchCV(
|
| 184 |
+
base, param_dist, n_iter=20, cv=3, scoring="neg_mean_absolute_error",
|
| 185 |
+
random_state=RANDOM_STATE, n_jobs=1, refit=True,
|
| 186 |
+
)
|
| 187 |
+
search.fit(rows, targets)
|
| 188 |
+
print(f" ET regressor best params: {search.best_params_}", flush=True)
|
| 189 |
+
return search.best_estimator_
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def tune_et_classifier(rows: np.ndarray, labels: np.ndarray) -> ExtraTreesClassifier:
|
| 193 |
+
param_dist = {
|
| 194 |
+
"n_estimators": [100, 200, 300, 400],
|
| 195 |
+
"min_samples_leaf": [1, 2, 3, 4],
|
| 196 |
+
"max_features": [0.5, 0.6, 0.7, 0.8, "sqrt"],
|
| 197 |
+
}
|
| 198 |
+
base = ExtraTreesClassifier(
|
| 199 |
+
bootstrap=True, class_weight="balanced_subsample",
|
| 200 |
+
random_state=RANDOM_STATE, n_jobs=1,
|
| 201 |
+
)
|
| 202 |
+
search = RandomizedSearchCV(
|
| 203 |
+
base, param_dist, n_iter=20, cv=3, scoring="f1",
|
| 204 |
+
random_state=RANDOM_STATE, n_jobs=1, refit=True,
|
| 205 |
+
)
|
| 206 |
+
search.fit(rows, labels)
|
| 207 |
+
print(f" ET classifier best params: {search.best_params_}", flush=True)
|
| 208 |
+
return search.best_estimator_
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def tune_xgb_regressor(rows: np.ndarray, targets: np.ndarray) -> "XGBRegressor":
|
| 212 |
+
param_dist = {
|
| 213 |
+
"n_estimators": [100, 200, 300, 400, 500],
|
| 214 |
+
"max_depth": [3, 4, 5, 6],
|
| 215 |
+
"learning_rate": [0.01, 0.03, 0.05, 0.1, 0.15],
|
| 216 |
+
"subsample": [0.6, 0.7, 0.8, 0.9, 1.0],
|
| 217 |
+
"colsample_bytree": [0.6, 0.7, 0.8, 0.9, 1.0],
|
| 218 |
+
}
|
| 219 |
+
base = XGBRegressor(random_state=RANDOM_STATE, n_jobs=1, verbosity=0)
|
| 220 |
+
search = RandomizedSearchCV(
|
| 221 |
+
base, param_dist, n_iter=20, cv=3, scoring="neg_mean_absolute_error",
|
| 222 |
+
random_state=RANDOM_STATE, n_jobs=1, refit=True,
|
| 223 |
+
)
|
| 224 |
+
search.fit(rows, targets)
|
| 225 |
+
print(f" XGB regressor best params: {search.best_params_}", flush=True)
|
| 226 |
+
return search.best_estimator_
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def tune_xgb_classifier(rows: np.ndarray, labels: np.ndarray) -> "XGBClassifier":
|
| 230 |
+
param_dist = {
|
| 231 |
+
"n_estimators": [100, 200, 300, 400, 500],
|
| 232 |
+
"max_depth": [3, 4, 5, 6],
|
| 233 |
+
"learning_rate": [0.01, 0.03, 0.05, 0.1, 0.15],
|
| 234 |
+
"subsample": [0.6, 0.7, 0.8, 0.9, 1.0],
|
| 235 |
+
"colsample_bytree": [0.6, 0.7, 0.8, 0.9, 1.0],
|
| 236 |
+
}
|
| 237 |
+
base = XGBClassifier(
|
| 238 |
+
use_label_encoder=False, eval_metric="logloss",
|
| 239 |
+
random_state=RANDOM_STATE, n_jobs=1, verbosity=0,
|
| 240 |
+
)
|
| 241 |
+
search = RandomizedSearchCV(
|
| 242 |
+
base, param_dist, n_iter=20, cv=3, scoring="f1",
|
| 243 |
+
random_state=RANDOM_STATE, n_jobs=1, refit=True,
|
| 244 |
+
)
|
| 245 |
+
search.fit(rows, labels)
|
| 246 |
+
print(f" XGB classifier best params: {search.best_params_}", flush=True)
|
| 247 |
+
return search.best_estimator_
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 251 |
+
# Stacking helpers
|
| 252 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 253 |
+
|
| 254 |
+
def _group_kfold_indices(
|
| 255 |
+
groups: np.ndarray, n_splits: int, random_state: int
|
| 256 |
+
) -> list[tuple[np.ndarray, np.ndarray]]:
|
| 257 |
+
"""GroupShuffleSplit folds for OOF stacking."""
|
| 258 |
+
splitter = GroupShuffleSplit(n_splits=n_splits, test_size=0.2, random_state=random_state)
|
| 259 |
+
return list(splitter.split(np.zeros(len(groups)), groups=groups))
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def build_oof_meta_features(
|
| 263 |
+
rows: np.ndarray,
|
| 264 |
+
targets: np.ndarray,
|
| 265 |
+
labels: np.ndarray,
|
| 266 |
+
groups: np.ndarray,
|
| 267 |
+
et_reg: ExtraTreesRegressor,
|
| 268 |
+
et_clf: ExtraTreesClassifier,
|
| 269 |
+
xgb_reg: object | None,
|
| 270 |
+
xgb_clf: object | None,
|
| 271 |
+
n_splits: int = N_CV_SPLITS,
|
| 272 |
+
) -> np.ndarray:
|
| 273 |
+
"""
|
| 274 |
+
Build out-of-fold meta-features using group-aware splits.
|
| 275 |
+
Always returns 4 columns: [et_hb, xgb_hb, et_prob, xgb_prob].
|
| 276 |
+
If XGBoost unavailable, xgb columns are zeros.
|
| 277 |
+
"""
|
| 278 |
+
n = len(rows)
|
| 279 |
+
oof = np.zeros((n, 4), dtype=np.float32)
|
| 280 |
+
rng = np.random.default_rng(RANDOM_STATE)
|
| 281 |
+
|
| 282 |
+
folds = _group_kfold_indices(groups, n_splits, RANDOM_STATE)
|
| 283 |
+
|
| 284 |
+
for fold_i, (train_idx, val_idx) in enumerate(folds):
|
| 285 |
+
print(f" OOF fold {fold_i + 1}/{n_splits}...", flush=True)
|
| 286 |
+
|
| 287 |
+
tr_rows, tr_targets, tr_labels, tr_groups = augment_training_data(
|
| 288 |
+
rows[train_idx], targets[train_idx], labels[train_idx], groups[train_idx], rng
|
| 289 |
+
)
|
| 290 |
+
val_rows = rows[val_idx]
|
| 291 |
+
|
| 292 |
+
import copy
|
| 293 |
+
fold_et_reg = copy.deepcopy(et_reg)
|
| 294 |
+
fold_et_clf = copy.deepcopy(et_clf)
|
| 295 |
+
fold_et_reg.fit(tr_rows, tr_targets)
|
| 296 |
+
fold_et_clf.fit(tr_rows, tr_labels)
|
| 297 |
+
|
| 298 |
+
oof[val_idx, 0] = fold_et_reg.predict(val_rows)
|
| 299 |
+
oof[val_idx, 2] = fold_et_clf.predict_proba(val_rows)[:, 1]
|
| 300 |
+
|
| 301 |
+
if xgb_reg is not None and xgb_clf is not None:
|
| 302 |
+
fold_xgb_reg = copy.deepcopy(xgb_reg)
|
| 303 |
+
fold_xgb_clf = copy.deepcopy(xgb_clf)
|
| 304 |
+
fold_xgb_reg.fit(tr_rows, tr_targets)
|
| 305 |
+
fold_xgb_clf.fit(tr_rows, tr_labels)
|
| 306 |
+
oof[val_idx, 1] = fold_xgb_reg.predict(val_rows)
|
| 307 |
+
oof[val_idx, 3] = fold_xgb_clf.predict_proba(val_rows)[:, 1]
|
| 308 |
+
else:
|
| 309 |
+
oof[val_idx, 1] = oof[val_idx, 0] # mirror ET if no XGB
|
| 310 |
+
oof[val_idx, 3] = oof[val_idx, 2]
|
| 311 |
+
|
| 312 |
+
return oof
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def find_best_threshold(labels: np.ndarray, scores: np.ndarray) -> float:
|
| 316 |
+
"""Threshold maximising recall-weighted F1 (medical screening priority)."""
|
| 317 |
+
best_score, best_thresh = -1.0, 0.5
|
| 318 |
+
for t in np.linspace(0.20, 0.75, 56):
|
| 319 |
+
preds = (scores >= t).astype(int)
|
| 320 |
+
if preds.sum() == 0:
|
| 321 |
+
continue
|
| 322 |
+
f1 = f1_score(labels, preds, zero_division=0)
|
| 323 |
+
rec = recall_score(labels, preds, zero_division=0)
|
| 324 |
+
score = f1 * 0.5 + rec * 0.5
|
| 325 |
+
if score > best_score:
|
| 326 |
+
best_score = score
|
| 327 |
+
best_thresh = float(t)
|
| 328 |
+
return best_thresh
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 332 |
+
# CV evaluation of the full stacked pipeline
|
| 333 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 334 |
+
|
| 335 |
+
def evaluate_stacked(
|
| 336 |
+
rows: np.ndarray,
|
| 337 |
+
targets: np.ndarray,
|
| 338 |
+
labels: np.ndarray,
|
| 339 |
+
groups: np.ndarray,
|
| 340 |
+
et_reg: ExtraTreesRegressor,
|
| 341 |
+
et_clf: ExtraTreesClassifier,
|
| 342 |
+
xgb_reg: object | None,
|
| 343 |
+
xgb_clf: object | None,
|
| 344 |
+
n_splits: int = N_CV_SPLITS,
|
| 345 |
+
) -> dict[str, float]:
|
| 346 |
+
"""
|
| 347 |
+
Outer CV loop: for each fold, build OOF meta-features on the train portion,
|
| 348 |
+
fit meta-learners, evaluate on the held-out test fold.
|
| 349 |
+
"""
|
| 350 |
+
import copy
|
| 351 |
+
splitter = GroupShuffleSplit(n_splits=n_splits, test_size=0.2, random_state=RANDOM_STATE + 1)
|
| 352 |
+
all_metrics: list[dict[str, float]] = []
|
| 353 |
+
rng = np.random.default_rng(RANDOM_STATE + 99)
|
| 354 |
+
|
| 355 |
+
for fold_i, (train_idx, test_idx) in enumerate(splitter.split(rows, labels, groups)):
|
| 356 |
+
print(f" Outer CV fold {fold_i + 1}/{n_splits}...", flush=True)
|
| 357 |
+
|
| 358 |
+
tr_rows_raw = rows[train_idx]
|
| 359 |
+
tr_targets_raw = targets[train_idx]
|
| 360 |
+
tr_labels_raw = labels[train_idx]
|
| 361 |
+
tr_groups_raw = groups[train_idx]
|
| 362 |
+
te_rows = rows[test_idx]
|
| 363 |
+
te_targets = targets[test_idx]
|
| 364 |
+
te_labels = labels[test_idx]
|
| 365 |
+
|
| 366 |
+
# Build OOF meta-features on training portion (inner loop)
|
| 367 |
+
oof_meta = build_oof_meta_features(
|
| 368 |
+
tr_rows_raw, tr_targets_raw, tr_labels_raw, tr_groups_raw,
|
| 369 |
+
copy.deepcopy(et_reg), copy.deepcopy(et_clf),
|
| 370 |
+
copy.deepcopy(xgb_reg) if xgb_reg else None,
|
| 371 |
+
copy.deepcopy(xgb_clf) if xgb_clf else None,
|
| 372 |
+
n_splits=3,
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
# Fit meta-learners on OOF
|
| 376 |
+
meta_reg = Ridge(alpha=1.0)
|
| 377 |
+
meta_clf = LogisticRegression(C=1.0, max_iter=500, random_state=RANDOM_STATE, solver="lbfgs")
|
| 378 |
+
meta_reg.fit(oof_meta, tr_targets_raw)
|
| 379 |
+
meta_clf.fit(oof_meta, tr_labels_raw)
|
| 380 |
+
|
| 381 |
+
# Build test meta-features: retrain base learners on augmented full train
|
| 382 |
+
aug_rows, aug_targets, aug_labels, _ = augment_training_data(
|
| 383 |
+
tr_rows_raw, tr_targets_raw, tr_labels_raw, tr_groups_raw, rng
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
fold_et_reg = copy.deepcopy(et_reg); fold_et_reg.fit(aug_rows, aug_targets)
|
| 387 |
+
fold_et_clf = copy.deepcopy(et_clf); fold_et_clf.fit(aug_rows, aug_labels)
|
| 388 |
+
|
| 389 |
+
te_meta = np.zeros((len(te_rows), 4), dtype=np.float32)
|
| 390 |
+
te_meta[:, 0] = fold_et_reg.predict(te_rows)
|
| 391 |
+
te_meta[:, 2] = fold_et_clf.predict_proba(te_rows)[:, 1]
|
| 392 |
+
|
| 393 |
+
if xgb_reg is not None:
|
| 394 |
+
fold_xgb_reg = copy.deepcopy(xgb_reg); fold_xgb_reg.fit(aug_rows, aug_targets)
|
| 395 |
+
fold_xgb_clf = copy.deepcopy(xgb_clf); fold_xgb_clf.fit(aug_rows, aug_labels)
|
| 396 |
+
te_meta[:, 1] = fold_xgb_reg.predict(te_rows)
|
| 397 |
+
te_meta[:, 3] = fold_xgb_clf.predict_proba(te_rows)[:, 1]
|
| 398 |
+
else:
|
| 399 |
+
te_meta[:, 1] = te_meta[:, 0]
|
| 400 |
+
te_meta[:, 3] = te_meta[:, 2]
|
| 401 |
+
|
| 402 |
+
hb_pred = meta_reg.predict(te_meta)
|
| 403 |
+
clf_prob = meta_clf.predict_proba(te_meta)[:, 1]
|
| 404 |
+
|
| 405 |
+
# Blend: same scheme as legacy model
|
| 406 |
+
hb_scale = max(float(np.quantile(np.abs(tr_targets_raw - fold_et_reg.predict(tr_rows_raw)), 0.75)), 0.8)
|
| 407 |
+
reg_risk = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_pred])
|
| 408 |
+
blend = 0.55 * clf_prob + 0.45 * reg_risk
|
| 409 |
+
thresh = find_best_threshold(te_labels, blend)
|
| 410 |
+
preds = (blend >= thresh).astype(int)
|
| 411 |
+
|
| 412 |
+
all_metrics.append({
|
| 413 |
+
"accuracy": accuracy_score(te_labels, preds),
|
| 414 |
+
"precision": precision_score(te_labels, preds, zero_division=0),
|
| 415 |
+
"recall": recall_score(te_labels, preds, zero_division=0),
|
| 416 |
+
"f1": f1_score(te_labels, preds, zero_division=0),
|
| 417 |
+
"auc": roc_auc_score(te_labels, blend),
|
| 418 |
+
"mae_hb": mean_absolute_error(te_targets, hb_pred),
|
| 419 |
+
"threshold": thresh,
|
| 420 |
+
})
|
| 421 |
+
|
| 422 |
+
avg = {k: round(float(np.mean([m[k] for m in all_metrics])), 4) for k in all_metrics[0]}
|
| 423 |
+
return avg
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 427 |
+
# Main
|
| 428 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 429 |
+
|
| 430 |
+
def main() -> None:
|
| 431 |
+
print("=" * 60, flush=True)
|
| 432 |
+
print("AnemiaLens — stacked-ensemble-v4 training", flush=True)
|
| 433 |
+
print("=" * 60, flush=True)
|
| 434 |
+
|
| 435 |
+
# ── 1. Load dataset ───────────────────────────────────────────────────────
|
| 436 |
+
print("\n[1/6] Loading dataset...", flush=True)
|
| 437 |
+
subjects = _build_subject_catalog(DATASET_ROOT)
|
| 438 |
+
print(f" Subjects: {len(subjects)}", flush=True)
|
| 439 |
+
|
| 440 |
+
samples = _samples_for_mode(subjects, "hybrid_dual")
|
| 441 |
+
print(f" Samples (hybrid_dual): {len(samples)}", flush=True)
|
| 442 |
+
|
| 443 |
+
rows, targets, labels, groups = _rows_from_samples(samples)
|
| 444 |
+
print(f" Class balance: {labels.sum()} anemic / {(labels == 0).sum()} non-anemic", flush=True)
|
| 445 |
+
|
| 446 |
+
# ── 2. Hyperparameter tuning ──────────────────────────────────────────────
|
| 447 |
+
print("\n[2/6] Tuning hyperparameters (RandomizedSearchCV, 20 iter each)...", flush=True)
|
| 448 |
+
rng = np.random.default_rng(RANDOM_STATE)
|
| 449 |
+
aug_rows, aug_targets, aug_labels, _ = augment_training_data(rows, targets, labels, groups, rng)
|
| 450 |
+
|
| 451 |
+
print(" Tuning ExtraTrees regressor...", flush=True)
|
| 452 |
+
et_reg = tune_et_regressor(aug_rows, aug_targets)
|
| 453 |
+
|
| 454 |
+
print(" Tuning ExtraTrees classifier...", flush=True)
|
| 455 |
+
et_clf = tune_et_classifier(aug_rows, aug_labels)
|
| 456 |
+
|
| 457 |
+
if _HAS_XGB:
|
| 458 |
+
print(" Tuning XGBoost regressor...", flush=True)
|
| 459 |
+
xgb_reg = tune_xgb_regressor(aug_rows, aug_targets)
|
| 460 |
+
print(" Tuning XGBoost classifier...", flush=True)
|
| 461 |
+
xgb_clf = tune_xgb_classifier(aug_rows, aug_labels)
|
| 462 |
+
else:
|
| 463 |
+
xgb_reg = xgb_clf = None
|
| 464 |
+
|
| 465 |
+
# ── 3. CV evaluation ───────────────────────────â���€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
|
| 466 |
+
print("\n[3/6] Cross-validating stacked ensemble...", flush=True)
|
| 467 |
+
cv_metrics = evaluate_stacked(rows, targets, labels, groups, et_reg, et_clf, xgb_reg, xgb_clf)
|
| 468 |
+
print(f"\n CV metrics: {cv_metrics}", flush=True)
|
| 469 |
+
|
| 470 |
+
# ── 4. Build final OOF meta-features on all data ──────────────────────────
|
| 471 |
+
print("\n[4/6] Building final OOF meta-features on full dataset...", flush=True)
|
| 472 |
+
oof_meta = build_oof_meta_features(
|
| 473 |
+
rows, targets, labels, groups, et_reg, et_clf, xgb_reg, xgb_clf, n_splits=N_CV_SPLITS
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
# ── 5. Fit final meta-learners ────────────────────────────────────────────
|
| 477 |
+
print("\n[5/6] Fitting meta-learners on OOF predictions...", flush=True)
|
| 478 |
+
meta_reg = Ridge(alpha=1.0)
|
| 479 |
+
meta_clf = LogisticRegression(C=1.0, max_iter=500, random_state=RANDOM_STATE, solver="lbfgs")
|
| 480 |
+
meta_reg.fit(oof_meta, targets)
|
| 481 |
+
meta_clf.fit(oof_meta, labels)
|
| 482 |
+
|
| 483 |
+
# Retrain base learners on full augmented data for inference
|
| 484 |
+
rng2 = np.random.default_rng(RANDOM_STATE + 1)
|
| 485 |
+
full_aug_rows, full_aug_targets, full_aug_labels, _ = augment_training_data(
|
| 486 |
+
rows, targets, labels, groups, rng2
|
| 487 |
+
)
|
| 488 |
+
et_reg.fit(full_aug_rows, full_aug_targets)
|
| 489 |
+
et_clf.fit(full_aug_rows, full_aug_labels)
|
| 490 |
+
if xgb_reg is not None:
|
| 491 |
+
xgb_reg.fit(full_aug_rows, full_aug_targets)
|
| 492 |
+
xgb_clf.fit(full_aug_rows, full_aug_labels)
|
| 493 |
+
|
| 494 |
+
# ── 6. Instantiate module-level stacked wrappers for inference ────────────
|
| 495 |
+
stacked_reg = StackedRegressor(et_reg, xgb_reg, et_clf, xgb_clf, meta_reg)
|
| 496 |
+
stacked_clf = StackedClassifier(et_clf, xgb_clf, et_reg, xgb_reg, meta_clf)
|
| 497 |
+
|
| 498 |
+
# ── Calibration ───────────────────────────────────────────────────────────
|
| 499 |
+
hb_preds_full = stacked_reg.predict(rows)
|
| 500 |
+
residuals = np.abs(targets - hb_preds_full)
|
| 501 |
+
hb_scale = max(float(np.quantile(residuals, 0.75)), 0.8)
|
| 502 |
+
|
| 503 |
+
hb_population_mean = float(np.mean(targets))
|
| 504 |
+
pred_std = float(np.std(hb_preds_full))
|
| 505 |
+
true_std = float(np.std(targets))
|
| 506 |
+
hb_spread_factor = float(np.clip(true_std / max(pred_std, 0.5), 1.0, 2.0))
|
| 507 |
+
|
| 508 |
+
clf_probs_full = stacked_clf.predict_proba(rows)[:, 1]
|
| 509 |
+
reg_risk_full = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_preds_full])
|
| 510 |
+
blend_full = 0.55 * clf_probs_full + 0.45 * reg_risk_full
|
| 511 |
+
best_threshold = find_best_threshold(labels, blend_full)
|
| 512 |
+
risk_scale = max(float(np.std(blend_full)) * 0.9, 0.08)
|
| 513 |
+
risk_scale = min(risk_scale, 0.22)
|
| 514 |
+
|
| 515 |
+
calibration = {
|
| 516 |
+
"hb_threshold": ANEMIA_HB_THRESHOLD,
|
| 517 |
+
"hb_scale": round(hb_scale, 4),
|
| 518 |
+
"hb_population_mean": round(hb_population_mean, 4),
|
| 519 |
+
"hb_spread_factor": round(hb_spread_factor, 4),
|
| 520 |
+
"regressor_tree_std_reference": 2.5,
|
| 521 |
+
"classifier_tree_std_reference": 0.5,
|
| 522 |
+
"classifier_weight": 0.55,
|
| 523 |
+
"blend_threshold": round(best_threshold, 4),
|
| 524 |
+
"risk_scale": round(risk_scale, 4),
|
| 525 |
+
"base_uncertainty": 0.11,
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
# ── Save artifact ─────────────────────────────────────────────────────────
|
| 529 |
+
print("\n[6/6] Saving model...", flush=True)
|
| 530 |
+
artifact = {
|
| 531 |
+
"version": "stacked-ensemble-v4",
|
| 532 |
+
"feature_names": V4_FEATURE_NAMES,
|
| 533 |
+
"regressor": stacked_reg,
|
| 534 |
+
"classifier": stacked_clf,
|
| 535 |
+
"calibration": calibration,
|
| 536 |
+
"training": {
|
| 537 |
+
"selected_mode": "hybrid_dual",
|
| 538 |
+
"subject_count": len(subjects),
|
| 539 |
+
"record_count": len(samples),
|
| 540 |
+
"metrics": cv_metrics,
|
| 541 |
+
"xgboost_available": _HAS_XGB,
|
| 542 |
+
},
|
| 543 |
+
}
|
| 544 |
+
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 545 |
+
joblib.dump(artifact, OUTPUT_PATH)
|
| 546 |
+
joblib.dump(artifact, OUTPUT_PATH_V4) # keep versioned copy too
|
| 547 |
+
print(f" Saved → {OUTPUT_PATH}", flush=True)
|
| 548 |
+
print(f" Saved → {OUTPUT_PATH_V4}", flush=True)
|
| 549 |
+
|
| 550 |
+
report = {
|
| 551 |
+
"dataset_name": "dataset anemia",
|
| 552 |
+
"record_count": len(samples),
|
| 553 |
+
"subject_count": len(subjects),
|
| 554 |
+
"primary_model": "stacked-ensemble-v4",
|
| 555 |
+
"selected_mode": "hybrid_dual",
|
| 556 |
+
"metrics": cv_metrics,
|
| 557 |
+
"calibration": {
|
| 558 |
+
"blend_threshold": calibration["blend_threshold"],
|
| 559 |
+
"risk_scale": calibration["risk_scale"],
|
| 560 |
+
"classifier_weight": calibration["classifier_weight"],
|
| 561 |
+
},
|
| 562 |
+
}
|
| 563 |
+
with open(REPORT_PATH, "w") as f:
|
| 564 |
+
json.dump(report, f, indent=2)
|
| 565 |
+
print(f" Report → {REPORT_PATH}", flush=True)
|
| 566 |
+
|
| 567 |
+
# ── Sanity check ──────────────────────────────────────────────────────────
|
| 568 |
+
print("\n── Sanity check ──────────────────────────────────────────────────", flush=True)
|
| 569 |
+
feat_idx = {n: i for i, n in enumerate(V4_FEATURE_NAMES)}
|
| 570 |
+
|
| 571 |
+
test_cases = [
|
| 572 |
+
("PALE (anemic)", 0.28, 0.02, 0.22),
|
| 573 |
+
("BORDERLINE", 0.35, 0.04, 0.30),
|
| 574 |
+
("NORMAL", 0.44, 0.08, 0.38),
|
| 575 |
+
("VERY HEALTHY", 0.48, 0.10, 0.42),
|
| 576 |
+
]
|
| 577 |
+
for label, cpi_val, rg_val, br_val in test_cases:
|
| 578 |
+
row = np.zeros((1, len(V4_FEATURE_NAMES)), dtype=np.float32)
|
| 579 |
+
row[0, feat_idx["cpi"]] = cpi_val
|
| 580 |
+
row[0, feat_idx["center_cpi"]] = cpi_val - 0.01
|
| 581 |
+
row[0, feat_idx["mean_r"]] = cpi_val * 0.9
|
| 582 |
+
row[0, feat_idx["mean_g"]] = cpi_val * 0.9 - rg_val
|
| 583 |
+
row[0, feat_idx["mean_b"]] = cpi_val * 0.7
|
| 584 |
+
row[0, feat_idx["center_mean_r"]] = cpi_val * 0.9
|
| 585 |
+
row[0, feat_idx["center_mean_g"]] = cpi_val * 0.9 - rg_val
|
| 586 |
+
row[0, feat_idx["center_mean_b"]] = cpi_val * 0.7
|
| 587 |
+
row[0, feat_idx["red_green_gap"]] = rg_val
|
| 588 |
+
row[0, feat_idx["center_red_green_gap"]] = rg_val
|
| 589 |
+
row[0, feat_idx["brightness"]] = br_val
|
| 590 |
+
row[0, feat_idx["center_brightness"]] = br_val
|
| 591 |
+
row[0, feat_idx["contrast"]] = 0.12
|
| 592 |
+
row[0, feat_idx["center_contrast"]] = 0.12
|
| 593 |
+
row[0, feat_idx["blur_score"]] = 100.0
|
| 594 |
+
row[0, feat_idx["center_blur_score"]] = 120.0
|
| 595 |
+
row[0, feat_idx["saturation"]] = 0.3
|
| 596 |
+
row[0, feat_idx["center_saturation"]] = 0.3
|
| 597 |
+
row[0, feat_idx["green_blue_ratio"]] = 1.1 if cpi_val < 0.35 else 1.25
|
| 598 |
+
row[0, feat_idx["hist_mid"]] = 0.5
|
| 599 |
+
row[0, feat_idx["hist_bright"]] = 0.3
|
| 600 |
+
row[0, feat_idx["aspect_ratio"]] = 1.0
|
| 601 |
+
row[0, feat_idx["size_score"]] = 1.0
|
| 602 |
+
row[0, feat_idx["source_roi_original"]] = 1.0
|
| 603 |
+
|
| 604 |
+
hb_p = float(stacked_reg.predict(row)[0])
|
| 605 |
+
cp = float(stacked_clf.predict_proba(row)[0, 1])
|
| 606 |
+
rr = sigmoid((ANEMIA_HB_THRESHOLD - hb_p) / hb_scale)
|
| 607 |
+
bs = 0.55 * cp + 0.45 * rr
|
| 608 |
+
risk = sigmoid((bs - best_threshold) / risk_scale)
|
| 609 |
+
decision = "ANEMIA LIKELY" if risk >= 0.65 else "unlikely"
|
| 610 |
+
print(f" {label}: Hb={hb_p:.1f}, clf_prob={cp:.3f}, risk={risk:.3f} -> {decision}", flush=True)
|
| 611 |
+
|
| 612 |
+
print("\nDone.", flush=True)
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
if __name__ == "__main__":
|
| 616 |
+
main()
|
| 617 |
+
|
backend/start_server.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Generic startup script for hosted AnemiaLens backends.
|
| 4 |
+
Uses PORT and HOST environment variables when provided by the host.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
import uvicorn
|
| 10 |
+
|
| 11 |
+
from app.main import app
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
if __name__ == "__main__":
|
| 15 |
+
port = int(os.environ.get("PORT", 8000))
|
| 16 |
+
host = os.environ.get("HOST", "0.0.0.0")
|
| 17 |
+
|
| 18 |
+
print(f"Starting AnemiaLens on {host}:{port}")
|
| 19 |
+
uvicorn.run(app, host=host, port=port)
|
backend/tests/test_case_insight.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 7 |
+
sys.path.insert(0, str(ROOT / "backend"))
|
| 8 |
+
|
| 9 |
+
from app.schemas import (
|
| 10 |
+
DecisionAudit,
|
| 11 |
+
GuidanceResult,
|
| 12 |
+
PredictionResult,
|
| 13 |
+
QualityAssessment,
|
| 14 |
+
QualityIssue,
|
| 15 |
+
SymptomInput,
|
| 16 |
+
TriageResult,
|
| 17 |
+
)
|
| 18 |
+
from app.services.case_insight import CaseInsightService
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_case_insight_builds_high_concern_story_with_drivers() -> None:
|
| 22 |
+
service = CaseInsightService()
|
| 23 |
+
pack = service.build(
|
| 24 |
+
QualityAssessment(
|
| 25 |
+
passed=True,
|
| 26 |
+
blur_score=210.0,
|
| 27 |
+
brightness_score=0.46,
|
| 28 |
+
contrast_score=0.18,
|
| 29 |
+
framing_score=1.9,
|
| 30 |
+
issues=[],
|
| 31 |
+
),
|
| 32 |
+
PredictionResult(
|
| 33 |
+
anemia_risk=0.82,
|
| 34 |
+
predicted_hemoglobin=7.6,
|
| 35 |
+
confidence=0.89,
|
| 36 |
+
uncertainty=0.09,
|
| 37 |
+
reliability_flag="high",
|
| 38 |
+
screening_label="anemia_likely",
|
| 39 |
+
screening_text="The screening model detected a strong low-hemoglobin signal.",
|
| 40 |
+
model_source="archive-evidence-fusion-v4",
|
| 41 |
+
),
|
| 42 |
+
TriageResult(
|
| 43 |
+
band="high_concern",
|
| 44 |
+
score=0.88,
|
| 45 |
+
label="High concern",
|
| 46 |
+
summary="Arrange formal review soon.",
|
| 47 |
+
disclaimer="Screening only.",
|
| 48 |
+
),
|
| 49 |
+
DecisionAudit(
|
| 50 |
+
processing_path="roi_crop",
|
| 51 |
+
calibration_band="strong_positive",
|
| 52 |
+
decision_threshold=0.435,
|
| 53 |
+
threshold_margin=0.385,
|
| 54 |
+
quality_warning_codes=[],
|
| 55 |
+
review_flags=[],
|
| 56 |
+
summary="Direct ROI inference produced a strong positive margin.",
|
| 57 |
+
),
|
| 58 |
+
GuidanceResult(
|
| 59 |
+
source="fallback",
|
| 60 |
+
explanation="Severely low hemoglobin signal.",
|
| 61 |
+
urgency_guidance="Seek medical attention within 24-48 hours.",
|
| 62 |
+
food_advice="Eat iron-rich foods.",
|
| 63 |
+
next_steps=["Visit nearest clinic or hospital today", "Request a full blood count (CBC) test"],
|
| 64 |
+
),
|
| 65 |
+
SymptomInput(fatigue=True, shortness_of_breath=True),
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
assert pack.priority_window == "within_24_48_hours"
|
| 69 |
+
assert pack.risk_drivers[0].impact == "up"
|
| 70 |
+
assert any(driver.title == "Very low hemoglobin estimate" for driver in pack.risk_drivers)
|
| 71 |
+
assert any("Avoid strenuous activity" in step.action for step in pack.follow_up_timeline)
|
| 72 |
+
assert "symptom fusion" in pack.judge_summary.lower()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_case_insight_marks_rescue_path_as_confidence_limit() -> None:
|
| 76 |
+
service = CaseInsightService()
|
| 77 |
+
pack = service.build(
|
| 78 |
+
QualityAssessment(
|
| 79 |
+
passed=True,
|
| 80 |
+
blur_score=180.0,
|
| 81 |
+
brightness_score=0.39,
|
| 82 |
+
contrast_score=0.13,
|
| 83 |
+
framing_score=1.1,
|
| 84 |
+
issues=[
|
| 85 |
+
QualityIssue(
|
| 86 |
+
code="bad_framing",
|
| 87 |
+
severity="warning",
|
| 88 |
+
title="Eye framing is loose",
|
| 89 |
+
message="Recenter the eye.",
|
| 90 |
+
)
|
| 91 |
+
],
|
| 92 |
+
),
|
| 93 |
+
PredictionResult(
|
| 94 |
+
anemia_risk=0.51,
|
| 95 |
+
predicted_hemoglobin=10.9,
|
| 96 |
+
confidence=0.63,
|
| 97 |
+
uncertainty=0.29,
|
| 98 |
+
reliability_flag="medium",
|
| 99 |
+
screening_label="anemia_likely",
|
| 100 |
+
screening_text="The screening model detected some pallor-like signal.",
|
| 101 |
+
model_source="archive-evidence-fusion-v4",
|
| 102 |
+
),
|
| 103 |
+
TriageResult(
|
| 104 |
+
band="moderate_risk",
|
| 105 |
+
score=0.59,
|
| 106 |
+
label="Moderate risk",
|
| 107 |
+
summary="Routine clinic follow-up is reasonable.",
|
| 108 |
+
disclaimer="Screening only.",
|
| 109 |
+
),
|
| 110 |
+
DecisionAudit(
|
| 111 |
+
processing_path="full_frame_rescue",
|
| 112 |
+
calibration_band="borderline_positive",
|
| 113 |
+
decision_threshold=0.435,
|
| 114 |
+
threshold_margin=0.075,
|
| 115 |
+
quality_warning_codes=["bad_framing"],
|
| 116 |
+
review_flags=["raw_frame_rescue", "warning:bad_framing"],
|
| 117 |
+
summary="Full-frame rescue accepted a borderline positive result.",
|
| 118 |
+
),
|
| 119 |
+
GuidanceResult(
|
| 120 |
+
source="fallback",
|
| 121 |
+
explanation="Mild to moderate anemia-like signal.",
|
| 122 |
+
urgency_guidance="See a doctor within 1-2 weeks.",
|
| 123 |
+
food_advice="Eat iron-rich foods.",
|
| 124 |
+
next_steps=["Book a clinic visit this week", "Start iron-rich diet immediately"],
|
| 125 |
+
),
|
| 126 |
+
SymptomInput(),
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
assert "full-frame rescue" in pack.confidence_story.lower()
|
| 130 |
+
assert any(driver.impact == "limit" for driver in pack.risk_drivers)
|
| 131 |
+
assert any("direct conjunctiva crop" in item.lower() for item in pack.capture_improvements)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_case_insight_handles_quality_blocked_retake_case() -> None:
|
| 135 |
+
service = CaseInsightService()
|
| 136 |
+
pack = service.build(
|
| 137 |
+
QualityAssessment(
|
| 138 |
+
passed=False,
|
| 139 |
+
blur_score=42.0,
|
| 140 |
+
brightness_score=0.06,
|
| 141 |
+
contrast_score=0.03,
|
| 142 |
+
framing_score=0.42,
|
| 143 |
+
issues=[
|
| 144 |
+
QualityIssue(
|
| 145 |
+
code="poor_lighting",
|
| 146 |
+
severity="blocking",
|
| 147 |
+
title="Lighting is not usable",
|
| 148 |
+
message="Use bright natural light.",
|
| 149 |
+
)
|
| 150 |
+
],
|
| 151 |
+
),
|
| 152 |
+
None,
|
| 153 |
+
TriageResult(
|
| 154 |
+
band="uncertain_retake_needed",
|
| 155 |
+
score=0.2,
|
| 156 |
+
label="Uncertain, retake needed",
|
| 157 |
+
summary="Retake the image.",
|
| 158 |
+
disclaimer="Screening only.",
|
| 159 |
+
),
|
| 160 |
+
DecisionAudit(
|
| 161 |
+
processing_path="quality_blocked",
|
| 162 |
+
calibration_band="quality_blocked",
|
| 163 |
+
decision_threshold=None,
|
| 164 |
+
threshold_margin=None,
|
| 165 |
+
quality_warning_codes=[],
|
| 166 |
+
review_flags=["quality_blocked"],
|
| 167 |
+
summary="Quality blocked model inference.",
|
| 168 |
+
),
|
| 169 |
+
GuidanceResult(
|
| 170 |
+
source="fallback",
|
| 171 |
+
explanation="Image signal was not strong enough.",
|
| 172 |
+
urgency_guidance="Retake the scan in better lighting.",
|
| 173 |
+
food_advice="No food advice until a valid screening is available.",
|
| 174 |
+
next_steps=["Retake eye image in bright natural light"],
|
| 175 |
+
),
|
| 176 |
+
SymptomInput(dizziness=True),
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
assert pack.priority_window == "retake_now"
|
| 180 |
+
assert "blocked model inference" in pack.risk_drivers[0].detail.lower()
|
| 181 |
+
assert pack.capture_improvements[0].startswith("Move into bright, even natural light")
|
| 182 |
+
assert "safety gate" in pack.judge_summary.lower()
|
backend/tests/test_clinical_brief.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 7 |
+
sys.path.insert(0, str(ROOT / "backend"))
|
| 8 |
+
|
| 9 |
+
from app.schemas import (
|
| 10 |
+
DecisionAudit,
|
| 11 |
+
GuidanceResult,
|
| 12 |
+
PredictionResult,
|
| 13 |
+
QualityAssessment,
|
| 14 |
+
QualityIssue,
|
| 15 |
+
SymptomInput,
|
| 16 |
+
TriageResult,
|
| 17 |
+
)
|
| 18 |
+
from app.services.analysis_meta import build_analysis_meta
|
| 19 |
+
from app.services.case_insight import CaseInsightService
|
| 20 |
+
from app.services.clinical_brief import ClinicalBriefService
|
| 21 |
+
from app.services.handoff import HandoffSummaryService
|
| 22 |
+
from app.services.triage import TriageService
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_clinical_brief_builds_grounded_high_concern_summary() -> None:
|
| 26 |
+
quality = QualityAssessment(
|
| 27 |
+
passed=True,
|
| 28 |
+
blur_score=198.0,
|
| 29 |
+
brightness_score=0.33,
|
| 30 |
+
contrast_score=0.19,
|
| 31 |
+
framing_score=1.9,
|
| 32 |
+
issues=[],
|
| 33 |
+
)
|
| 34 |
+
prediction = PredictionResult(
|
| 35 |
+
anemia_risk=0.84,
|
| 36 |
+
predicted_hemoglobin=7.8,
|
| 37 |
+
confidence=0.91,
|
| 38 |
+
uncertainty=0.08,
|
| 39 |
+
reliability_flag="high",
|
| 40 |
+
screening_label="anemia_likely",
|
| 41 |
+
screening_text="The screening model detected a strong low-hemoglobin signal.",
|
| 42 |
+
model_source="archive-evidence-fusion-v4",
|
| 43 |
+
)
|
| 44 |
+
symptoms = SymptomInput(fatigue=True, shortness_of_breath=True, poor_diet_low_iron=True)
|
| 45 |
+
triage_service = TriageService()
|
| 46 |
+
signal_breakdown = triage_service.build_signal_breakdown(quality, prediction, symptoms)
|
| 47 |
+
triage = triage_service.assess(
|
| 48 |
+
quality,
|
| 49 |
+
prediction,
|
| 50 |
+
symptoms,
|
| 51 |
+
signal_breakdown=signal_breakdown,
|
| 52 |
+
)
|
| 53 |
+
decision_audit = DecisionAudit(
|
| 54 |
+
processing_path="roi_crop",
|
| 55 |
+
calibration_band="strong_positive",
|
| 56 |
+
decision_threshold=0.435,
|
| 57 |
+
threshold_margin=0.405,
|
| 58 |
+
quality_warning_codes=[],
|
| 59 |
+
review_flags=[],
|
| 60 |
+
summary="Direct ROI inference produced a strong positive margin.",
|
| 61 |
+
)
|
| 62 |
+
guidance = GuidanceResult(
|
| 63 |
+
source="fallback",
|
| 64 |
+
explanation="The screening signal is concerning and should be reviewed soon.",
|
| 65 |
+
urgency_guidance="Seek medical review within 24 to 48 hours.",
|
| 66 |
+
food_advice="Eat iron-rich foods and include vitamin C with meals.",
|
| 67 |
+
next_steps=["Book a clinic or lab visit within 24 to 48 hours", "Request a CBC test"],
|
| 68 |
+
)
|
| 69 |
+
insight_pack = CaseInsightService().build(
|
| 70 |
+
quality,
|
| 71 |
+
prediction,
|
| 72 |
+
triage,
|
| 73 |
+
decision_audit,
|
| 74 |
+
guidance,
|
| 75 |
+
symptoms,
|
| 76 |
+
)
|
| 77 |
+
handoff_summary = HandoffSummaryService().build(
|
| 78 |
+
quality,
|
| 79 |
+
prediction,
|
| 80 |
+
triage,
|
| 81 |
+
guidance,
|
| 82 |
+
symptoms,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
brief = ClinicalBriefService().build(
|
| 86 |
+
quality,
|
| 87 |
+
prediction,
|
| 88 |
+
triage,
|
| 89 |
+
decision_audit,
|
| 90 |
+
guidance,
|
| 91 |
+
symptoms,
|
| 92 |
+
insight_pack,
|
| 93 |
+
handoff_summary,
|
| 94 |
+
signal_breakdown,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
assert brief.action_window == "within_24_48_hours"
|
| 98 |
+
assert brief.signal_breakdown.image_risk == 0.84
|
| 99 |
+
assert brief.signal_breakdown.symptom_burden == "moderate"
|
| 100 |
+
assert any("hemoglobin signal" in item.lower() for item in brief.supporting_evidence)
|
| 101 |
+
assert any("uncertainty" in item.lower() for item in brief.safety_checks)
|
| 102 |
+
assert "AnemiaLens clinical brief" in brief.share_text
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_clinical_brief_handles_quality_blocked_case_and_meta() -> None:
|
| 106 |
+
quality = QualityAssessment(
|
| 107 |
+
passed=False,
|
| 108 |
+
blur_score=42.0,
|
| 109 |
+
brightness_score=0.05,
|
| 110 |
+
contrast_score=0.03,
|
| 111 |
+
framing_score=0.4,
|
| 112 |
+
issues=[
|
| 113 |
+
QualityIssue(
|
| 114 |
+
code="poor_lighting",
|
| 115 |
+
severity="blocking",
|
| 116 |
+
title="Lighting is not usable",
|
| 117 |
+
message="Use bright natural light.",
|
| 118 |
+
)
|
| 119 |
+
],
|
| 120 |
+
)
|
| 121 |
+
symptoms = SymptomInput(dizziness=True)
|
| 122 |
+
triage_service = TriageService()
|
| 123 |
+
signal_breakdown = triage_service.build_signal_breakdown(quality, None, symptoms)
|
| 124 |
+
triage = triage_service.assess(
|
| 125 |
+
quality,
|
| 126 |
+
None,
|
| 127 |
+
symptoms,
|
| 128 |
+
signal_breakdown=signal_breakdown,
|
| 129 |
+
)
|
| 130 |
+
decision_audit = DecisionAudit(
|
| 131 |
+
processing_path="quality_blocked",
|
| 132 |
+
calibration_band="quality_blocked",
|
| 133 |
+
decision_threshold=None,
|
| 134 |
+
threshold_margin=None,
|
| 135 |
+
quality_warning_codes=[],
|
| 136 |
+
review_flags=["quality_blocked"],
|
| 137 |
+
summary="Quality blocked model inference.",
|
| 138 |
+
)
|
| 139 |
+
guidance = GuidanceResult(
|
| 140 |
+
source="fallback",
|
| 141 |
+
explanation="The image was too weak for a reliable screening result.",
|
| 142 |
+
urgency_guidance="Retake the scan in better light.",
|
| 143 |
+
food_advice="Wait for a valid scan before using food guidance from the app.",
|
| 144 |
+
next_steps=["Retake the image in bright natural light"],
|
| 145 |
+
)
|
| 146 |
+
insight_pack = CaseInsightService().build(
|
| 147 |
+
quality,
|
| 148 |
+
None,
|
| 149 |
+
triage,
|
| 150 |
+
decision_audit,
|
| 151 |
+
guidance,
|
| 152 |
+
symptoms,
|
| 153 |
+
)
|
| 154 |
+
handoff_summary = HandoffSummaryService().build(
|
| 155 |
+
quality,
|
| 156 |
+
None,
|
| 157 |
+
triage,
|
| 158 |
+
guidance,
|
| 159 |
+
symptoms,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
brief = ClinicalBriefService().build(
|
| 163 |
+
quality,
|
| 164 |
+
None,
|
| 165 |
+
triage,
|
| 166 |
+
decision_audit,
|
| 167 |
+
guidance,
|
| 168 |
+
symptoms,
|
| 169 |
+
insight_pack,
|
| 170 |
+
handoff_summary,
|
| 171 |
+
signal_breakdown,
|
| 172 |
+
)
|
| 173 |
+
meta = build_analysis_meta(
|
| 174 |
+
request_id="abc12345",
|
| 175 |
+
api_version="0.3.0",
|
| 176 |
+
processing_time_ms=187.36,
|
| 177 |
+
quality=quality,
|
| 178 |
+
decision_audit=decision_audit,
|
| 179 |
+
guidance=guidance,
|
| 180 |
+
used_raw_frame_rescue=False,
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
assert brief.signal_breakdown.image_risk is None
|
| 184 |
+
assert any("blocked model inference" in item.lower() for item in brief.supporting_evidence)
|
| 185 |
+
assert any("primary blocker" in item.lower() for item in brief.limiting_factors)
|
| 186 |
+
assert "image=not available" in brief.share_text
|
| 187 |
+
assert meta.request_id == "abc12345"
|
| 188 |
+
assert meta.processing_path == "quality_blocked"
|
| 189 |
+
assert meta.guidance_source == "fallback"
|
| 190 |
+
assert meta.safety_layers == [
|
| 191 |
+
"image_quality_gate",
|
| 192 |
+
"symptom_fusion",
|
| 193 |
+
"triage_banding",
|
| 194 |
+
"non_diagnostic_guidance",
|
| 195 |
+
]
|
backend/tests/test_decision_audit.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 7 |
+
sys.path.insert(0, str(ROOT / "backend"))
|
| 8 |
+
|
| 9 |
+
from app.schemas import GuidanceResult, PredictionResult, QualityAssessment, QualityIssue, SymptomInput, TriageResult
|
| 10 |
+
from app.services.decision_audit import build_decision_audit
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_decision_audit_marks_full_frame_rescue_and_threshold_margin() -> None:
|
| 14 |
+
audit = build_decision_audit(
|
| 15 |
+
QualityAssessment(
|
| 16 |
+
passed=True,
|
| 17 |
+
blur_score=220.0,
|
| 18 |
+
brightness_score=0.44,
|
| 19 |
+
contrast_score=0.15,
|
| 20 |
+
framing_score=1.7,
|
| 21 |
+
issues=[
|
| 22 |
+
QualityIssue(
|
| 23 |
+
code="bad_framing",
|
| 24 |
+
severity="warning",
|
| 25 |
+
title="Eye framing is loose",
|
| 26 |
+
message="The app fell back to the full eye frame.",
|
| 27 |
+
)
|
| 28 |
+
],
|
| 29 |
+
),
|
| 30 |
+
PredictionResult(
|
| 31 |
+
anemia_risk=0.82,
|
| 32 |
+
predicted_hemoglobin=10.8,
|
| 33 |
+
confidence=0.66,
|
| 34 |
+
uncertainty=0.34,
|
| 35 |
+
reliability_flag="medium",
|
| 36 |
+
screening_label="anemia_likely",
|
| 37 |
+
screening_text="Likely anemia.",
|
| 38 |
+
model_source="archive-evidence-fusion-v4",
|
| 39 |
+
),
|
| 40 |
+
TriageResult(
|
| 41 |
+
band="moderate_risk",
|
| 42 |
+
score=0.54,
|
| 43 |
+
label="Moderate risk",
|
| 44 |
+
summary="Moderate concern.",
|
| 45 |
+
disclaimer="Screening only.",
|
| 46 |
+
),
|
| 47 |
+
used_raw_frame_rescue=True,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
assert audit.processing_path == "full_frame_rescue"
|
| 51 |
+
assert audit.calibration_band == "strong_positive"
|
| 52 |
+
assert audit.decision_threshold == 0.435
|
| 53 |
+
assert audit.threshold_margin == 0.385
|
| 54 |
+
assert "raw_frame_rescue" in audit.review_flags
|
| 55 |
+
assert "warning:bad_framing" in audit.review_flags
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_decision_audit_handles_blocked_request() -> None:
|
| 59 |
+
audit = build_decision_audit(
|
| 60 |
+
QualityAssessment(
|
| 61 |
+
passed=False,
|
| 62 |
+
blur_score=40.0,
|
| 63 |
+
brightness_score=0.05,
|
| 64 |
+
contrast_score=0.03,
|
| 65 |
+
framing_score=0.4,
|
| 66 |
+
issues=[
|
| 67 |
+
QualityIssue(
|
| 68 |
+
code="poor_lighting",
|
| 69 |
+
severity="blocking",
|
| 70 |
+
title="Lighting is not usable",
|
| 71 |
+
message="Use bright, even light.",
|
| 72 |
+
)
|
| 73 |
+
],
|
| 74 |
+
),
|
| 75 |
+
None,
|
| 76 |
+
TriageResult(
|
| 77 |
+
band="uncertain_retake_needed",
|
| 78 |
+
score=0.2,
|
| 79 |
+
label="Retake needed",
|
| 80 |
+
summary="Retake the image.",
|
| 81 |
+
disclaimer="Screening only.",
|
| 82 |
+
),
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
assert audit.processing_path == "quality_blocked"
|
| 86 |
+
assert audit.calibration_band == "quality_blocked"
|
| 87 |
+
assert audit.decision_threshold is None
|
| 88 |
+
assert "quality_blocked" in audit.review_flags
|
| 89 |
+
assert "blocked model inference" in audit.summary.lower()
|
backend/tests/test_email_report.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the email report API and delivery service.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import pytest
|
| 12 |
+
from fastapi import FastAPI
|
| 13 |
+
from fastapi.testclient import TestClient
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 16 |
+
sys.path.insert(0, str(ROOT / "backend"))
|
| 17 |
+
|
| 18 |
+
from app.api.email_report import get_email_report_service, router
|
| 19 |
+
from app.config import settings
|
| 20 |
+
from app.services import email_report as email_report_module
|
| 21 |
+
from app.services.email_report import (
|
| 22 |
+
EmailReportContent,
|
| 23 |
+
EmailReportDeliveryError,
|
| 24 |
+
EmailReportNotConfiguredError,
|
| 25 |
+
EmailReportService,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class _StubRouterService:
|
| 30 |
+
def __init__(self, exc: Exception | None = None) -> None:
|
| 31 |
+
self.exc = exc
|
| 32 |
+
self.payload: EmailReportContent | None = None
|
| 33 |
+
|
| 34 |
+
def masked_recipient(self, recipient: str) -> str:
|
| 35 |
+
return f"masked:{recipient}"
|
| 36 |
+
|
| 37 |
+
def send_report(self, payload: EmailReportContent) -> None:
|
| 38 |
+
self.payload = payload
|
| 39 |
+
if self.exc is not None:
|
| 40 |
+
raise self.exc
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class _SMTPStub:
|
| 44 |
+
last_instance: "_SMTPStub | None" = None
|
| 45 |
+
|
| 46 |
+
def __init__(self, host: str, port: int, timeout: float | None = None, context=None) -> None:
|
| 47 |
+
self.host = host
|
| 48 |
+
self.port = port
|
| 49 |
+
self.timeout = timeout
|
| 50 |
+
self.context = context
|
| 51 |
+
self.logged_in: tuple[str, str] | None = None
|
| 52 |
+
self.sent_message = None
|
| 53 |
+
_SMTPStub.last_instance = self
|
| 54 |
+
|
| 55 |
+
def __enter__(self) -> "_SMTPStub":
|
| 56 |
+
return self
|
| 57 |
+
|
| 58 |
+
def __exit__(self, exc_type, exc, tb) -> None:
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
def login(self, username: str, password: str) -> None:
|
| 62 |
+
self.logged_in = (username, password)
|
| 63 |
+
|
| 64 |
+
def send_message(self, message) -> None:
|
| 65 |
+
self.sent_message = message
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class _HTTPResponseStub:
|
| 69 |
+
def __init__(self, body: str = '{"id":"email_123"}', status: int = 200) -> None:
|
| 70 |
+
self._body = body.encode("utf-8")
|
| 71 |
+
self.status = status
|
| 72 |
+
|
| 73 |
+
def read(self) -> bytes:
|
| 74 |
+
return self._body
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class _HTTPSConnectionStub:
|
| 78 |
+
last_instance: "_HTTPSConnectionStub | None" = None
|
| 79 |
+
response_status: int = 200
|
| 80 |
+
response_body: str = '{"id":"email_123"}'
|
| 81 |
+
|
| 82 |
+
def __init__(self, host: str, timeout: float | None = None) -> None:
|
| 83 |
+
self.host = host
|
| 84 |
+
self.timeout = timeout
|
| 85 |
+
self.request_args: tuple[str, str, bytes, dict[str, str]] | None = None
|
| 86 |
+
self.closed = False
|
| 87 |
+
_HTTPSConnectionStub.last_instance = self
|
| 88 |
+
|
| 89 |
+
def request(self, method: str, path: str, body=None, headers=None) -> None:
|
| 90 |
+
self.request_args = (method, path, body, headers or {})
|
| 91 |
+
|
| 92 |
+
def getresponse(self) -> _HTTPResponseStub:
|
| 93 |
+
return _HTTPResponseStub(body=self.response_body, status=self.response_status)
|
| 94 |
+
|
| 95 |
+
def close(self) -> None:
|
| 96 |
+
self.closed = True
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class _GmailHTTPSConnectionStub:
|
| 100 |
+
requests: list[tuple[str, str, bytes, dict[str, str], str, float | None]] = []
|
| 101 |
+
response_queue: list[_HTTPResponseStub] = []
|
| 102 |
+
|
| 103 |
+
def __init__(self, host: str, timeout: float | None = None) -> None:
|
| 104 |
+
self.host = host
|
| 105 |
+
self.timeout = timeout
|
| 106 |
+
self.closed = False
|
| 107 |
+
|
| 108 |
+
def request(self, method: str, path: str, body=None, headers=None) -> None:
|
| 109 |
+
_GmailHTTPSConnectionStub.requests.append((method, path, body, headers or {}, self.host, self.timeout))
|
| 110 |
+
|
| 111 |
+
def getresponse(self) -> _HTTPResponseStub:
|
| 112 |
+
return _GmailHTTPSConnectionStub.response_queue.pop(0)
|
| 113 |
+
|
| 114 |
+
def close(self) -> None:
|
| 115 |
+
self.closed = True
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _client_with_service(service: _StubRouterService) -> TestClient:
|
| 119 |
+
app = FastAPI()
|
| 120 |
+
app.include_router(router)
|
| 121 |
+
app.dependency_overrides[get_email_report_service] = lambda: service
|
| 122 |
+
return TestClient(app)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_email_report_endpoint_sends_valid_payload() -> None:
|
| 126 |
+
service = _StubRouterService()
|
| 127 |
+
client = _client_with_service(service)
|
| 128 |
+
|
| 129 |
+
response = client.post(
|
| 130 |
+
"/api/email-report",
|
| 131 |
+
json={
|
| 132 |
+
"email": "person@example.com",
|
| 133 |
+
"share_text": "Moderate risk summary.\nPlease follow up with a CBC test.",
|
| 134 |
+
"triage_label": "Moderate Risk",
|
| 135 |
+
"predicted_hemoglobin": 10.6,
|
| 136 |
+
"anemia_risk": 0.54,
|
| 137 |
+
},
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
assert response.status_code == 200
|
| 141 |
+
assert response.json()["status"] == "sent"
|
| 142 |
+
assert service.payload is not None
|
| 143 |
+
assert service.payload.recipient == "person@example.com"
|
| 144 |
+
assert service.payload.predicted_hemoglobin == 10.6
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_email_report_endpoint_rejects_invalid_email() -> None:
|
| 148 |
+
client = _client_with_service(_StubRouterService())
|
| 149 |
+
|
| 150 |
+
response = client.post(
|
| 151 |
+
"/api/email-report",
|
| 152 |
+
json={
|
| 153 |
+
"email": "not-an-email",
|
| 154 |
+
"share_text": "Moderate risk summary.\nPlease follow up with a CBC test.",
|
| 155 |
+
"triage_label": "Moderate Risk",
|
| 156 |
+
"predicted_hemoglobin": 10.6,
|
| 157 |
+
"anemia_risk": 0.54,
|
| 158 |
+
},
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
assert response.status_code == 422
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_email_report_endpoint_returns_503_when_not_configured() -> None:
|
| 165 |
+
client = _client_with_service(
|
| 166 |
+
_StubRouterService(
|
| 167 |
+
EmailReportNotConfiguredError("Email delivery is not configured."),
|
| 168 |
+
)
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
response = client.post(
|
| 172 |
+
"/api/email-report",
|
| 173 |
+
json={
|
| 174 |
+
"email": "person@example.com",
|
| 175 |
+
"share_text": "Moderate risk summary.\nPlease follow up with a CBC test.",
|
| 176 |
+
"triage_label": "Moderate Risk",
|
| 177 |
+
"predicted_hemoglobin": 10.6,
|
| 178 |
+
"anemia_risk": 0.54,
|
| 179 |
+
},
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
assert response.status_code == 503
|
| 183 |
+
assert "not configured" in response.json()["detail"].lower()
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_email_report_endpoint_returns_502_when_delivery_fails() -> None:
|
| 187 |
+
client = _client_with_service(
|
| 188 |
+
_StubRouterService(
|
| 189 |
+
EmailReportDeliveryError("SMTP authentication failed."),
|
| 190 |
+
)
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
response = client.post(
|
| 194 |
+
"/api/email-report",
|
| 195 |
+
json={
|
| 196 |
+
"email": "person@example.com",
|
| 197 |
+
"share_text": "Moderate risk summary.\nPlease follow up with a CBC test.",
|
| 198 |
+
"triage_label": "Moderate Risk",
|
| 199 |
+
"predicted_hemoglobin": 10.6,
|
| 200 |
+
"anemia_risk": 0.54,
|
| 201 |
+
},
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
assert response.status_code == 502
|
| 205 |
+
assert "smtp" in response.json()["detail"].lower()
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_email_report_service_requires_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 209 |
+
monkeypatch.setattr(settings, "email_provider", "smtp")
|
| 210 |
+
monkeypatch.setattr(settings, "smtp_username", "")
|
| 211 |
+
monkeypatch.setattr(settings, "smtp_password", "")
|
| 212 |
+
monkeypatch.setattr(settings, "email_from_email", "")
|
| 213 |
+
|
| 214 |
+
service = EmailReportService()
|
| 215 |
+
|
| 216 |
+
with pytest.raises(EmailReportNotConfiguredError, match="configured"):
|
| 217 |
+
service.send_report(
|
| 218 |
+
EmailReportContent(
|
| 219 |
+
recipient="person@example.com",
|
| 220 |
+
share_text="Moderate risk summary.\nPlease follow up with a CBC test.",
|
| 221 |
+
triage_label="Moderate Risk",
|
| 222 |
+
predicted_hemoglobin=10.6,
|
| 223 |
+
anemia_risk=0.54,
|
| 224 |
+
)
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_email_report_service_sends_email_via_ssl(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 229 |
+
monkeypatch.setattr(settings, "email_provider", "smtp")
|
| 230 |
+
monkeypatch.setattr(settings, "smtp_host", "smtp.example.com")
|
| 231 |
+
monkeypatch.setattr(settings, "smtp_port", 465)
|
| 232 |
+
monkeypatch.setattr(settings, "smtp_username", "mailer@example.com")
|
| 233 |
+
monkeypatch.setattr(settings, "smtp_password", "app-password")
|
| 234 |
+
monkeypatch.setattr(settings, "smtp_use_ssl", True)
|
| 235 |
+
monkeypatch.setattr(settings, "smtp_use_starttls", False)
|
| 236 |
+
monkeypatch.setattr(settings, "smtp_timeout", 20.0)
|
| 237 |
+
monkeypatch.setattr(settings, "email_from_name", "AnemiaLens")
|
| 238 |
+
monkeypatch.setattr(settings, "email_from_email", "reports@example.com")
|
| 239 |
+
monkeypatch.setattr(settings, "email_reply_to", "support@example.com")
|
| 240 |
+
monkeypatch.setattr(email_report_module.smtplib, "SMTP_SSL", _SMTPStub)
|
| 241 |
+
|
| 242 |
+
service = EmailReportService()
|
| 243 |
+
service.send_report(
|
| 244 |
+
EmailReportContent(
|
| 245 |
+
recipient="patient@example.com",
|
| 246 |
+
share_text="Moderate risk summary.\nPlease follow up with a CBC test.",
|
| 247 |
+
triage_label="Moderate Risk",
|
| 248 |
+
predicted_hemoglobin=10.6,
|
| 249 |
+
anemia_risk=0.54,
|
| 250 |
+
)
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
smtp = _SMTPStub.last_instance
|
| 254 |
+
assert smtp is not None
|
| 255 |
+
assert smtp.host == "smtp.example.com"
|
| 256 |
+
assert smtp.port == 465
|
| 257 |
+
assert smtp.logged_in == ("mailer@example.com", "app-password")
|
| 258 |
+
assert smtp.sent_message["To"] == "patient@example.com"
|
| 259 |
+
assert smtp.sent_message["Reply-To"] == "support@example.com"
|
| 260 |
+
assert "Moderate Risk" in smtp.sent_message["Subject"]
|
| 261 |
+
plain_part = smtp.sent_message.get_body(preferencelist=("plain",))
|
| 262 |
+
html_part = smtp.sent_message.get_body(preferencelist=("html",))
|
| 263 |
+
assert plain_part is not None
|
| 264 |
+
assert html_part is not None
|
| 265 |
+
assert "clinical blood test (CBC)" in plain_part.get_content()
|
| 266 |
+
assert "Recommended Next Steps" in plain_part.get_content()
|
| 267 |
+
assert "Why this result" in html_part.get_content()
|
| 268 |
+
assert "Open AnemiaLens" in html_part.get_content()
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def test_email_report_service_sends_email_via_resend(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 272 |
+
_HTTPSConnectionStub.response_status = 200
|
| 273 |
+
_HTTPSConnectionStub.response_body = '{"id":"email_123"}'
|
| 274 |
+
monkeypatch.setattr(settings, "email_provider", "resend")
|
| 275 |
+
monkeypatch.setattr(settings, "resend_api_key", "re_test_123")
|
| 276 |
+
monkeypatch.setattr(settings, "resend_api_base", "https://api.resend.test")
|
| 277 |
+
monkeypatch.setattr(settings, "email_from_name", "AnemiaLens")
|
| 278 |
+
monkeypatch.setattr(settings, "email_from_email", "onboarding@resend.dev")
|
| 279 |
+
monkeypatch.setattr(settings, "email_reply_to", "support@example.com")
|
| 280 |
+
monkeypatch.setattr(settings, "smtp_username", "")
|
| 281 |
+
monkeypatch.setattr(settings, "smtp_password", "")
|
| 282 |
+
monkeypatch.setattr(settings, "smtp_timeout", 12.0)
|
| 283 |
+
monkeypatch.setattr(email_report_module.http.client, "HTTPSConnection", _HTTPSConnectionStub)
|
| 284 |
+
|
| 285 |
+
service = EmailReportService()
|
| 286 |
+
service.send_report(
|
| 287 |
+
EmailReportContent(
|
| 288 |
+
recipient="patient@example.com",
|
| 289 |
+
share_text="Moderate risk summary.\nPlease follow up with a CBC test.",
|
| 290 |
+
triage_label="Moderate Risk",
|
| 291 |
+
predicted_hemoglobin=10.6,
|
| 292 |
+
anemia_risk=0.54,
|
| 293 |
+
)
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
connection = _HTTPSConnectionStub.last_instance
|
| 297 |
+
assert connection is not None
|
| 298 |
+
assert connection.host == "api.resend.test"
|
| 299 |
+
assert connection.timeout == 12.0
|
| 300 |
+
assert connection.closed is True
|
| 301 |
+
assert connection.request_args is not None
|
| 302 |
+
method, path, raw_body, headers = connection.request_args
|
| 303 |
+
body = json.loads(raw_body.decode("utf-8"))
|
| 304 |
+
assert method == "POST"
|
| 305 |
+
assert path == "/emails"
|
| 306 |
+
assert headers["Authorization"] == "Bearer re_test_123"
|
| 307 |
+
assert headers["Content-Type"] == "application/json"
|
| 308 |
+
assert headers["Idempotency-Key"].startswith("email-report/patient@example.com/moderate-risk/")
|
| 309 |
+
assert headers["User-Agent"] == "AnemiaLens/1.0 (+https://anemia-lens.vercel.app)"
|
| 310 |
+
assert body["from"] == "AnemiaLens <onboarding@resend.dev>"
|
| 311 |
+
assert body["to"] == ["patient@example.com"]
|
| 312 |
+
assert body["reply_to"] == "support@example.com"
|
| 313 |
+
assert body["subject"] == "AnemiaLens Screening Report - Moderate Risk"
|
| 314 |
+
assert "clinical blood test (CBC)" in body["text"]
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def test_email_report_service_sends_email_via_sendgrid(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 318 |
+
_HTTPSConnectionStub.response_status = 202
|
| 319 |
+
_HTTPSConnectionStub.response_body = ""
|
| 320 |
+
monkeypatch.setattr(settings, "email_provider", "sendgrid")
|
| 321 |
+
monkeypatch.setattr(settings, "sendgrid_api_key", "SG.test-key")
|
| 322 |
+
monkeypatch.setattr(settings, "sendgrid_api_base", "https://api.sendgrid.test/v3")
|
| 323 |
+
monkeypatch.setattr(settings, "email_from_name", "AnemiaLens")
|
| 324 |
+
monkeypatch.setattr(settings, "email_from_email", "asnanp875@gmail.com")
|
| 325 |
+
monkeypatch.setattr(settings, "email_reply_to", "asnanp875@gmail.com")
|
| 326 |
+
monkeypatch.setattr(settings, "smtp_username", "")
|
| 327 |
+
monkeypatch.setattr(settings, "smtp_password", "")
|
| 328 |
+
monkeypatch.setattr(settings, "smtp_timeout", 12.0)
|
| 329 |
+
monkeypatch.setattr(email_report_module.http.client, "HTTPSConnection", _HTTPSConnectionStub)
|
| 330 |
+
|
| 331 |
+
service = EmailReportService()
|
| 332 |
+
service.send_report(
|
| 333 |
+
EmailReportContent(
|
| 334 |
+
recipient="patient@example.com",
|
| 335 |
+
share_text="Moderate risk summary.\nPlease follow up with a CBC test.",
|
| 336 |
+
triage_label="Moderate Risk",
|
| 337 |
+
predicted_hemoglobin=10.6,
|
| 338 |
+
anemia_risk=0.54,
|
| 339 |
+
)
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
connection = _HTTPSConnectionStub.last_instance
|
| 343 |
+
assert connection is not None
|
| 344 |
+
assert connection.host == "api.sendgrid.test"
|
| 345 |
+
assert connection.timeout == 12.0
|
| 346 |
+
assert connection.closed is True
|
| 347 |
+
assert connection.request_args is not None
|
| 348 |
+
method, path, raw_body, headers = connection.request_args
|
| 349 |
+
body = json.loads(raw_body.decode("utf-8"))
|
| 350 |
+
assert method == "POST"
|
| 351 |
+
assert path == "/v3/mail/send"
|
| 352 |
+
assert headers["Authorization"] == "Bearer SG.test-key"
|
| 353 |
+
assert headers["Content-Type"] == "application/json"
|
| 354 |
+
assert headers["User-Agent"] == "AnemiaLens/1.0 (+https://anemia-lens.vercel.app)"
|
| 355 |
+
assert body["from"] == {"email": "asnanp875@gmail.com", "name": "AnemiaLens"}
|
| 356 |
+
assert body["reply_to"] == {"email": "asnanp875@gmail.com"}
|
| 357 |
+
assert body["personalizations"][0]["to"] == [{"email": "patient@example.com"}]
|
| 358 |
+
assert body["personalizations"][0]["subject"] == "AnemiaLens Screening Report - Moderate Risk"
|
| 359 |
+
assert body["content"][0]["type"] == "text/plain"
|
| 360 |
+
assert body["content"][1]["type"] == "text/html"
|
| 361 |
+
assert "clinical blood test (CBC)" in body["content"][0]["value"]
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def test_email_report_service_sends_email_via_gmail_api(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 365 |
+
_GmailHTTPSConnectionStub.requests = []
|
| 366 |
+
_GmailHTTPSConnectionStub.response_queue = [
|
| 367 |
+
_HTTPResponseStub(body='{"access_token":"ya29.test-token"}', status=200),
|
| 368 |
+
_HTTPResponseStub(body='{"id":"gmail_message_123"}', status=200),
|
| 369 |
+
]
|
| 370 |
+
monkeypatch.setattr(settings, "email_provider", "gmail_api")
|
| 371 |
+
monkeypatch.setattr(settings, "gmail_client_id", "client-id")
|
| 372 |
+
monkeypatch.setattr(settings, "gmail_client_secret", "client-secret")
|
| 373 |
+
monkeypatch.setattr(settings, "gmail_refresh_token", "refresh-token")
|
| 374 |
+
monkeypatch.setattr(settings, "gmail_token_url", "https://oauth2.googleapis.com/token")
|
| 375 |
+
monkeypatch.setattr(settings, "gmail_api_base", "https://gmail.googleapis.com/gmail/v1")
|
| 376 |
+
monkeypatch.setattr(settings, "email_from_name", "AnemiaLens")
|
| 377 |
+
monkeypatch.setattr(settings, "email_from_email", "asnanp875@gmail.com")
|
| 378 |
+
monkeypatch.setattr(settings, "email_reply_to", "asnanp875@gmail.com")
|
| 379 |
+
monkeypatch.setattr(settings, "smtp_timeout", 12.0)
|
| 380 |
+
monkeypatch.setattr(email_report_module.http.client, "HTTPSConnection", _GmailHTTPSConnectionStub)
|
| 381 |
+
|
| 382 |
+
service = EmailReportService()
|
| 383 |
+
service.send_report(
|
| 384 |
+
EmailReportContent(
|
| 385 |
+
recipient="patient@example.com",
|
| 386 |
+
share_text="Moderate risk summary.\nPlease follow up with a CBC test.",
|
| 387 |
+
triage_label="Moderate Risk",
|
| 388 |
+
predicted_hemoglobin=10.6,
|
| 389 |
+
anemia_risk=0.54,
|
| 390 |
+
)
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
assert len(_GmailHTTPSConnectionStub.requests) == 2
|
| 394 |
+
|
| 395 |
+
token_method, token_path, token_body, token_headers, token_host, token_timeout = _GmailHTTPSConnectionStub.requests[0]
|
| 396 |
+
assert token_method == "POST"
|
| 397 |
+
assert token_host == "oauth2.googleapis.com"
|
| 398 |
+
assert token_timeout == 12.0
|
| 399 |
+
assert token_path == "/token"
|
| 400 |
+
assert token_headers["Content-Type"] == "application/x-www-form-urlencoded"
|
| 401 |
+
assert b"grant_type=refresh_token" in token_body
|
| 402 |
+
assert b"client_id=client-id" in token_body
|
| 403 |
+
assert b"client_secret=client-secret" in token_body
|
| 404 |
+
assert b"refresh_token=refresh-token" in token_body
|
| 405 |
+
|
| 406 |
+
send_method, send_path, send_body_raw, send_headers, send_host, send_timeout = _GmailHTTPSConnectionStub.requests[1]
|
| 407 |
+
send_body = json.loads(send_body_raw.decode("utf-8"))
|
| 408 |
+
assert send_method == "POST"
|
| 409 |
+
assert send_host == "gmail.googleapis.com"
|
| 410 |
+
assert send_timeout == 12.0
|
| 411 |
+
assert send_path == "/gmail/v1/users/me/messages/send"
|
| 412 |
+
assert send_headers["Authorization"] == "Bearer ya29.test-token"
|
| 413 |
+
assert send_headers["Content-Type"] == "application/json"
|
| 414 |
+
assert "raw" in send_body
|