AnemiaLens / backend /scripts /evaluate_runtime_stack.py
asnannp's picture
sync: sync backend code, models, schemas, and API routers to Hugging Face Space cleanly
f559cc0
Raw
History Blame Contribute Delete
7.09 kB
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import torch
from sklearn.metrics import accuracy_score, f1_score, mean_absolute_error, precision_score, recall_score, roc_auc_score
from app.config import (
DEFAULT_ARCHIVE_MODEL_PATH,
DEFAULT_EFFICIENTNET_MODEL_PATH,
DEFAULT_RUNTIME_STACK_REPORT_PATH,
)
from app.ml.archive_model import load_archive_model, predict_with_archive_model
from app.ml.efficientnet_model import load_efficientnet_checkpoint
from app.ml.features import extract_eye_features
from app.ml.runtime_stack import (
DEFAULT_SOURCE_THRESHOLDS,
RUNTIME_STACK_VERSION,
build_runtime_stack_prediction,
decision_threshold_for_source,
)
from app.services.conjunctiva_roi import ConjunctivaRoiExtractor
from train_efficientnet import ARCHIVE_ROOT, _balanced_group_split, _build_records, _load_image_with_fallback
def main() -> None:
records = _build_records(ARCHIVE_ROOT)
if not records:
raise RuntimeError(f"No evaluation records found in {ARCHIVE_ROOT}.")
_, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
archive_model = load_archive_model(DEFAULT_ARCHIVE_MODEL_PATH)
efficientnet_bundle = (
load_efficientnet_checkpoint(DEFAULT_EFFICIENTNET_MODEL_PATH)
if Path(DEFAULT_EFFICIENTNET_MODEL_PATH).exists()
else None
)
roi_extractor = ConjunctivaRoiExtractor()
runtime_rows: list[dict[str, float | int | str]] = []
full_rows: list[dict[str, float | int | str]] = []
prepared_images: list[object] = []
prepared_sources: list[str] = []
prepared_archive_predictions: list[dict[str, float]] = []
prepared_records = []
for record in val_records:
image = _load_image_with_fallback(record.image_path)
source_hint = record.source
if record.source == "roi_original":
image = roi_extractor.extract(image).image
image = image.convert("RGB")
archive_prediction = predict_with_archive_model(
archive_model,
extract_eye_features(image),
source_hint=source_hint,
)
prepared_records.append(record)
prepared_images.append(image)
prepared_sources.append(source_hint)
prepared_archive_predictions.append(archive_prediction)
efficientnet_predictions = _predict_efficientnet_batch(efficientnet_bundle, prepared_images)
for record, source_hint, archive_prediction, efficientnet_prediction in zip(
prepared_records,
prepared_sources,
prepared_archive_predictions,
efficientnet_predictions,
strict=True,
):
runtime_prediction = build_runtime_stack_prediction(
archive_prediction,
efficientnet_prediction=efficientnet_prediction,
source_hint=source_hint, # type: ignore[arg-type]
)
row = {
"label": int(record.label),
"source": str(record.source),
"risk": float(runtime_prediction["anemia_risk"]),
"predicted_hb": float(runtime_prediction["predicted_hemoglobin"]),
"target_hb": float(record.hb),
}
full_rows.append(row)
if record.source == "roi_original":
runtime_rows.append(row)
runtime_metrics = _evaluate_rows(runtime_rows, source_aware=False)
full_metrics = _evaluate_rows(full_rows, source_aware=True)
report = {
"primary_model": RUNTIME_STACK_VERSION,
"record_count": len(records),
"subject_count": len({record.subject_id for record in records}),
"selected_mode": "archive_evidence_fusion_runtime",
"source_thresholds": DEFAULT_SOURCE_THRESHOLDS,
"metrics": runtime_metrics,
"full_validation": full_metrics,
}
DEFAULT_RUNTIME_STACK_REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
print("\nRuntime stack metrics (ROI-gated uploads)")
for key in ("accuracy", "precision", "recall", "f1", "auc", "hb_mae"):
print(f"{key}: {runtime_metrics[key]:.4f}")
print("\nFull validation metrics (all sources)")
for key in ("accuracy", "precision", "recall", "f1", "auc", "hb_mae"):
print(f"{key}: {full_metrics[key]:.4f}")
def _evaluate_rows(
rows: list[dict[str, float | int | str]],
*,
source_aware: bool,
) -> dict[str, float | int | str]:
labels = np.asarray([int(row["label"]) for row in rows], dtype=np.int32)
probabilities = np.asarray([float(row["risk"]) for row in rows], dtype=np.float32)
predicted_hb = np.asarray([float(row["predicted_hb"]) for row in rows], dtype=np.float32)
target_hb = np.asarray([float(row["target_hb"]) for row in rows], dtype=np.float32)
if source_aware:
predictions = np.asarray(
[
1
if float(row["risk"]) >= decision_threshold_for_source(str(row["source"])) # type: ignore[arg-type]
else 0
for row in rows
],
dtype=np.int32,
)
split_strategy = "group-shuffle-balance-select: source-aware"
else:
threshold = decision_threshold_for_source("roi_original")
predictions = (probabilities >= threshold).astype(np.int32)
split_strategy = "group-shuffle-balance-select: roi_original"
return {
"accuracy": round(float(accuracy_score(labels, predictions)), 4),
"precision": round(float(precision_score(labels, predictions, zero_division=0)), 4),
"recall": round(float(recall_score(labels, predictions, zero_division=0)), 4),
"f1": round(float(f1_score(labels, predictions, zero_division=0)), 4),
"auc": round(float(roc_auc_score(labels, probabilities)), 4),
"hb_mae": round(float(mean_absolute_error(target_hb, predicted_hb)), 4),
"validation_size": int(len(rows)),
"split_strategy": split_strategy,
}
def _predict_efficientnet_batch(
bundle: dict[str, object] | None,
images: list[object],
) -> list[dict[str, float] | None]:
if bundle is None:
return [None] * len(images)
transform = bundle["transform"]
model = bundle["model"]
hb_mean = float(bundle.get("hb_mean", 0.0))
hb_std = max(float(bundle.get("hb_std", 1.0)), 1e-6)
tensors = torch.stack([transform(image) for image in images], dim=0)
with torch.no_grad():
output = model(tensors)
probabilities = torch.sigmoid(output[:, 0]).cpu().numpy()
hemoglobin = ((output[:, 1].cpu().numpy()) * hb_std) + hb_mean
results: list[dict[str, float]] = []
for probability, hb_value in zip(probabilities, hemoglobin, strict=True):
margin_uncertainty = 1.0 - min(1.0, abs(float(probability) - 0.5) * 2.0)
results.append(
{
"anemia_risk": float(probability),
"predicted_hemoglobin": float(hb_value),
"uncertainty": float(np.clip((margin_uncertainty * 0.2) + 0.05, 0.05, 0.95)),
}
)
return results
if __name__ == "__main__":
main()