AnemiaLens / backend /scripts /fit_ultimate_runtime_refiner.py
asnannp's picture
sync: sync backend code, models, schemas, and API routers to Hugging Face Space cleanly
f559cc0
Raw
History Blame Contribute Delete
9.31 kB
from __future__ import annotations
import json
import sys
import warnings
from pathlib import Path
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score, roc_auc_score
BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
SCRIPT_ROOT = Path(__file__).resolve().parent
if str(SCRIPT_ROOT) not in sys.path:
sys.path.insert(0, str(SCRIPT_ROOT))
from app.config import ( # noqa: E402
DEFAULT_ARCHIVE_MODEL_PATH,
DEFAULT_ULTIMATE_REFINEMENT_REPORT_PATH,
DEFAULT_ULTIMATE_REFINER_PATH,
)
from app.ml.archive_model import load_archive_model, predict_with_archive_model # noqa: E402
from app.ml.features import extract_eye_features, extract_ultimate_clinical_features # noqa: E402
from app.ml.ultimate_runtime_refinement import UltimateRuntimeRefiner # noqa: E402
from app.services.image_quality import ImageQualityService # noqa: E402
from train_efficientnet import ARCHIVE_ROOT, _balanced_group_split, _build_records # noqa: E402
warnings.filterwarnings(
"ignore",
message="X does not have valid feature names, but StandardScaler was fitted with feature names",
)
def _metric_block(labels: np.ndarray, predictions: np.ndarray) -> dict[str, float]:
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),
}
def _choose_threshold(labels: np.ndarray, probabilities: np.ndarray) -> tuple[float, dict[str, float]]:
best_threshold = 0.35
best_metrics: dict[str, float] | None = None
best_score = -1.0
for threshold in np.linspace(0.2, 0.8, 61):
predictions = (probabilities >= threshold).astype(np.int32)
metrics = _metric_block(labels, predictions)
score = (
metrics["f1"] * 0.65
+ metrics["precision"] * 0.20
+ metrics["recall"] * 0.15
)
if score > best_score:
best_score = score
best_threshold = float(threshold)
best_metrics = metrics
assert best_metrics is not None
return best_threshold, best_metrics
def _collect_runtime_rows(records):
quality_service = ImageQualityService()
rows = []
for record in records:
with record.image_path.open("rb") as handle:
quality, processed = quality_service.evaluate(handle.read())
if not quality.passed:
continue
rows.append(
{
"record": record,
"quality": quality,
"base_features": extract_eye_features(processed),
"ultimate_features": extract_ultimate_clinical_features(processed, quality),
}
)
return rows
def _expected_scaler_stats(archive_model: dict[str, object]) -> tuple[list[str], dict[str, float], dict[str, float]]:
feature_names = archive_model.get("feature_names")
scaler = archive_model.get("scaler")
if (
not isinstance(feature_names, list)
or not feature_names
or scaler is None
or not hasattr(scaler, "mean_")
or not hasattr(scaler, "scale_")
):
raise RuntimeError("Ultimate archive artifact is missing scaler statistics.")
return (
[str(name) for name in feature_names],
{name: float(value) for name, value in zip(feature_names, scaler.mean_, strict=False)},
{
name: max(float(value), 1e-6)
for name, value in zip(feature_names, scaler.scale_, strict=False)
},
)
def _feature_stats(rows, feature_names: list[str]) -> tuple[dict[str, float], dict[str, float]]:
return (
{
name: float(np.mean([row["ultimate_features"][name] for row in rows]))
for name in feature_names
},
{
name: max(
float(np.std([row["ultimate_features"][name] for row in rows])),
1e-6,
)
for name in feature_names
},
)
def _build_dataset(
rows,
*,
archive_model: dict[str, object],
feature_names: list[str],
expected_means: dict[str, float],
expected_stds: dict[str, float],
current_means: dict[str, float],
current_stds: dict[str, float],
):
refiner = UltimateRuntimeRefiner(
feature_means=current_means,
feature_stds=current_stds,
)
X_rows: list[list[float]] = []
labels: list[int] = []
base_predictions: list[int] = []
raw_predictions: list[int] = []
for row in rows:
remapped = refiner.remap_ultimate_features(
row["ultimate_features"],
archive_feature_names=feature_names,
expected_means=expected_means,
expected_stds=expected_stds,
)
base_prediction = predict_with_archive_model(archive_model, remapped, source_hint="roi_original")
X_rows.append(
refiner._feature_vector(
base_prediction=base_prediction,
quality=row["quality"],
base_feature_map=row["base_features"],
)
)
label = int(row["record"].label)
labels.append(label)
base_predictions.append(int(base_prediction["anemia_risk"] >= 0.5))
raw_predictions.append(int(base_prediction["anemia_risk"] >= 0.5))
return (
np.asarray(X_rows, dtype=np.float32),
np.asarray(labels, dtype=np.int32),
np.asarray(base_predictions, dtype=np.int32),
np.asarray(raw_predictions, dtype=np.int32),
)
def main() -> None:
archive_model = load_archive_model(DEFAULT_ARCHIVE_MODEL_PATH)
version = str(archive_model.get("version", ""))
if not version.startswith("archive-fusion-v7-ultimate-clinical"):
raise RuntimeError(
f"Ultimate runtime refiner expects the v7 clinical artifact, got {version!r}."
)
records = _build_records(ARCHIVE_ROOT)
if not records:
raise RuntimeError(f"No evaluation records found in {ARCHIVE_ROOT}.")
train_records, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
train_roi = [record for record in train_records if record.source == "roi_original"]
val_roi = [record for record in val_records if record.source == "roi_original"]
train_rows = _collect_runtime_rows(train_roi)
val_rows = _collect_runtime_rows(val_roi)
feature_names, expected_means, expected_stds = _expected_scaler_stats(archive_model)
current_means, current_stds = _feature_stats(train_rows, feature_names)
X_train, y_train, _, _ = _build_dataset(
train_rows,
archive_model=archive_model,
feature_names=feature_names,
expected_means=expected_means,
expected_stds=expected_stds,
current_means=current_means,
current_stds=current_stds,
)
X_val, y_val, base_predictions, _ = _build_dataset(
val_rows,
archive_model=archive_model,
feature_names=feature_names,
expected_means=expected_means,
expected_stds=expected_stds,
current_means=current_means,
current_stds=current_stds,
)
model = GradientBoostingClassifier(
random_state=42,
n_estimators=150,
learning_rate=0.05,
max_depth=2,
min_samples_leaf=3,
subsample=0.9,
)
model.fit(X_train, y_train)
probabilities = model.predict_proba(X_val)[:, 1]
selected_threshold, metrics_after = _choose_threshold(y_val, probabilities)
before_metrics = _metric_block(y_val, base_predictions)
after_predictions = (probabilities >= selected_threshold).astype(np.int32)
auc = round(float(roc_auc_score(y_val, probabilities)), 4)
artifact = UltimateRuntimeRefiner(
method="gradient-boosting-compatibility",
threshold=round(selected_threshold, 4),
feature_means=current_means,
feature_stds=current_stds,
model=model,
report={
"validation_size": int(len(y_val)),
"auc": auc,
"metrics_before": before_metrics,
"selected_threshold": round(selected_threshold, 4),
},
)
artifact.save(DEFAULT_ULTIMATE_REFINER_PATH)
report = {
"version": artifact.version,
"method": artifact.method,
"validation_size": int(len(y_val)),
"selected_threshold": round(selected_threshold, 4),
"auc": auc,
"metrics_before": before_metrics,
"metrics_after": _metric_block(y_val, after_predictions),
}
DEFAULT_ULTIMATE_REFINEMENT_REPORT_PATH.write_text(
json.dumps(report, indent=2),
encoding="utf-8",
)
print("Ultimate runtime refinement")
print(f"validation_size: {report['validation_size']}")
print(f"selected_threshold: {report['selected_threshold']:.4f}")
print(f"auc: {report['auc']:.4f}")
print("before:", report["metrics_before"])
print("after:", report["metrics_after"])
if __name__ == "__main__":
main()