AnemiaLens / backend /scripts /train_stacked.py
asnannp's picture
sync: sync backend code, models, schemas, and API routers to Hugging Face Space cleanly
f559cc0
Raw
History Blame Contribute Delete
34.3 kB
"""
train_stacked.py — AnemiaLens stacked-ensemble-v4 training script.
Architecture
------------
Level-0 base learners (out-of-fold predictions via cross_val_predict):
• XGBoost regressor → OOF Hb predictions
• XGBoost classifier → OOF anemia probabilities
• ExtraTrees regressor → OOF Hb predictions
• ExtraTrees classifier → OOF anemia probabilities
Level-1 meta-learners:
• Ridge regression → final Hb estimate
• Logistic Regression → final anemia risk probability
Data augmentation (training folds only):
• Gaussian noise on color features (sigma=0.01)
• CPI jitter ±0.02
• 3× oversampling of anemic class (label=1)
Run from workspace root:
python backend/scripts/train_stacked.py
"""
from __future__ import annotations
import sys
import json
import math
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
import numpy as np
import joblib
from sklearn.ensemble import ExtraTreesClassifier, ExtraTreesRegressor
from sklearn.linear_model import LogisticRegression, Ridge
from sklearn.metrics import (
accuracy_score, f1_score, mean_absolute_error,
precision_score, recall_score, roc_auc_score,
)
from sklearn.model_selection import GroupShuffleSplit, RandomizedSearchCV
from sklearn.model_selection import cross_val_predict
try:
from xgboost import XGBClassifier, XGBRegressor
_HAS_XGB = True
except ImportError:
_HAS_XGB = False
print("WARNING: xgboost not installed — falling back to ExtraTrees-only stack.")
print(" Install with: pip install xgboost")
from app.ml.archive_model import (
ANEMIA_HB_THRESHOLD,
_build_subject_catalog,
_samples_for_mode,
_rows_from_samples,
clamp,
sigmoid,
)
from app.ml.features import FEATURE_NAMES, COLOR_FEATURES
from app.ml.stacked_model import StackedRegressor, StackedClassifier
DATASET_ROOT = Path(__file__).parents[2] / "archive" / "dataset anemia"
OUTPUT_PATH = Path(__file__).parents[1] / "models" / "archive_screening_model.joblib"
OUTPUT_PATH_V4 = Path(__file__).parents[1] / "models" / "archive_screening_model_v4.joblib"
REPORT_PATH = Path(__file__).parents[1] / "models" / "training_report.json"
# Feature names for the v4 artifact (includes source flags)
V4_FEATURE_NAMES = FEATURE_NAMES + [
"source_roi_original",
"source_segmented",
"source_forniceal_palpebral",
]
# Indices of color features used for augmentation
_COLOR_IDX = [V4_FEATURE_NAMES.index(n) for n in COLOR_FEATURES if n in V4_FEATURE_NAMES]
# Index of CPI feature for jitter
_CPI_IDX = V4_FEATURE_NAMES.index("cpi")
N_CV_SPLITS = 5
RANDOM_STATE = 42
# ─────────────────────────────────────────────────────────────────────────────
# Augmentation
# ─────────────────────────────────────────────────────────────────────────────
def augment_training_data(
rows: np.ndarray,
targets: np.ndarray,
labels: np.ndarray,
groups: np.ndarray,
rng: np.random.Generator,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
Augment training data:
1. Add Gaussian noise (sigma=0.01) to color features for ALL samples.
2. Oversample anemic class (label=1) 3× with CPI jitter ±0.02.
Returns augmented arrays (originals + augmented copies).
"""
n = len(rows)
# --- noise augmentation for all samples ---
noisy = rows.copy()
noise = rng.normal(0, 0.01, size=(n, len(_COLOR_IDX)))
noisy[:, _COLOR_IDX] += noise
noisy = np.clip(noisy, 0.0, 1.0)
aug_rows = [rows, noisy]
aug_targets = [targets, targets]
aug_labels = [labels, labels]
aug_groups = [groups, groups]
# --- 3× oversample anemic samples with CPI jitter ---
anemic_idx = np.where(labels == 1)[0]
for _ in range(3):
copies = rows[anemic_idx].copy()
jitter = rng.uniform(-0.02, 0.02, size=len(anemic_idx))
copies[:, _CPI_IDX] = np.clip(copies[:, _CPI_IDX] + jitter, 0.0, 1.0)
# Also add small noise to other color features
color_noise = rng.normal(0, 0.01, size=(len(anemic_idx), len(_COLOR_IDX)))
copies[:, _COLOR_IDX] = np.clip(copies[:, _COLOR_IDX] + color_noise, 0.0, 1.0)
aug_rows.append(copies)
aug_targets.append(targets[anemic_idx])
aug_labels.append(labels[anemic_idx])
aug_groups.append(groups[anemic_idx])
return (
np.vstack(aug_rows),
np.concatenate(aug_targets),
np.concatenate(aug_labels),
np.concatenate(aug_groups),
)
# ─────────────────────────────────────────────────────────────────────────────
# Base learner builders
# ─────────────────────────────────────────────────────────────────────────────
def _et_regressor(rs: int = RANDOM_STATE) -> ExtraTreesRegressor:
return ExtraTreesRegressor(
n_estimators=300, min_samples_leaf=2, max_features=0.7,
bootstrap=True, random_state=rs, n_jobs=1,
)
def _et_classifier(rs: int = RANDOM_STATE) -> ExtraTreesClassifier:
return ExtraTreesClassifier(
n_estimators=300, min_samples_leaf=2, max_features=0.7,
bootstrap=True, class_weight="balanced_subsample",
random_state=rs, n_jobs=1,
)
def _xgb_regressor(rs: int = RANDOM_STATE) -> "XGBRegressor":
return XGBRegressor(
n_estimators=300, max_depth=4, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8,
random_state=rs, n_jobs=1, verbosity=0,
)
def _xgb_classifier(rs: int = RANDOM_STATE) -> "XGBClassifier":
return XGBClassifier(
n_estimators=300, max_depth=4, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8,
use_label_encoder=False, eval_metric="logloss",
random_state=rs, n_jobs=1, verbosity=0,
)
# ─────────────────────────────────────────────────────────────────────────────
# Hyperparameter tuning
# ─────────────────────────────────────────────────────────────────────────────
def tune_et_regressor(rows: np.ndarray, targets: np.ndarray) -> ExtraTreesRegressor:
param_dist = {
"n_estimators": [100, 200, 300, 400],
"min_samples_leaf": [1, 2, 3, 4],
"max_features": [0.5, 0.6, 0.7, 0.8, "sqrt"],
}
base = ExtraTreesRegressor(bootstrap=True, random_state=RANDOM_STATE, n_jobs=1)
search = RandomizedSearchCV(
base, param_dist, n_iter=20, cv=3, scoring="neg_mean_absolute_error",
random_state=RANDOM_STATE, n_jobs=1, refit=True,
)
search.fit(rows, targets)
print(f" ET regressor best params: {search.best_params_}", flush=True)
return search.best_estimator_
def tune_et_classifier(rows: np.ndarray, labels: np.ndarray) -> ExtraTreesClassifier:
param_dist = {
"n_estimators": [100, 200, 300, 400],
"min_samples_leaf": [1, 2, 3, 4],
"max_features": [0.5, 0.6, 0.7, 0.8, "sqrt"],
}
base = ExtraTreesClassifier(
bootstrap=True, class_weight="balanced_subsample",
random_state=RANDOM_STATE, n_jobs=1,
)
search = RandomizedSearchCV(
base, param_dist, n_iter=20, cv=3, scoring="f1",
random_state=RANDOM_STATE, n_jobs=1, refit=True,
)
search.fit(rows, labels)
print(f" ET classifier best params: {search.best_params_}", flush=True)
return search.best_estimator_
def tune_xgb_regressor(rows: np.ndarray, targets: np.ndarray) -> "XGBRegressor":
param_dist = {
"n_estimators": [100, 200, 300, 400, 500],
"max_depth": [3, 4, 5, 6],
"learning_rate": [0.01, 0.03, 0.05, 0.1, 0.15],
"subsample": [0.6, 0.7, 0.8, 0.9, 1.0],
"colsample_bytree": [0.6, 0.7, 0.8, 0.9, 1.0],
}
base = XGBRegressor(random_state=RANDOM_STATE, n_jobs=1, verbosity=0)
search = RandomizedSearchCV(
base, param_dist, n_iter=20, cv=3, scoring="neg_mean_absolute_error",
random_state=RANDOM_STATE, n_jobs=1, refit=True,
)
search.fit(rows, targets)
print(f" XGB regressor best params: {search.best_params_}", flush=True)
return search.best_estimator_
def tune_xgb_classifier(rows: np.ndarray, labels: np.ndarray) -> "XGBClassifier":
param_dist = {
"n_estimators": [100, 200, 300, 400, 500],
"max_depth": [3, 4, 5, 6],
"learning_rate": [0.01, 0.03, 0.05, 0.1, 0.15],
"subsample": [0.6, 0.7, 0.8, 0.9, 1.0],
"colsample_bytree": [0.6, 0.7, 0.8, 0.9, 1.0],
}
base = XGBClassifier(
use_label_encoder=False, eval_metric="logloss",
random_state=RANDOM_STATE, n_jobs=1, verbosity=0,
)
search = RandomizedSearchCV(
base, param_dist, n_iter=20, cv=3, scoring="f1",
random_state=RANDOM_STATE, n_jobs=1, refit=True,
)
search.fit(rows, labels)
print(f" XGB classifier best params: {search.best_params_}", flush=True)
return search.best_estimator_
# ─────────────────────────────────────────────────────────────────────────────
# Stacking helpers
# ─────────────────────────────────────────────────────────────────────────────
def _group_kfold_indices(
groups: np.ndarray, n_splits: int, random_state: int
) -> list[tuple[np.ndarray, np.ndarray]]:
"""GroupShuffleSplit folds for OOF stacking."""
splitter = GroupShuffleSplit(n_splits=n_splits, test_size=0.2, random_state=random_state)
return list(splitter.split(np.zeros(len(groups)), groups=groups))
def build_oof_meta_features(
rows: np.ndarray,
targets: np.ndarray,
labels: np.ndarray,
groups: np.ndarray,
et_reg: ExtraTreesRegressor,
et_clf: ExtraTreesClassifier,
xgb_reg: object | None,
xgb_clf: object | None,
n_splits: int = N_CV_SPLITS,
) -> np.ndarray:
"""
Build out-of-fold meta-features using group-aware splits.
Always returns 4 columns: [et_hb, xgb_hb, et_prob, xgb_prob].
If XGBoost unavailable, xgb columns are zeros.
"""
n = len(rows)
oof = np.zeros((n, 4), dtype=np.float32)
rng = np.random.default_rng(RANDOM_STATE)
folds = _group_kfold_indices(groups, n_splits, RANDOM_STATE)
for fold_i, (train_idx, val_idx) in enumerate(folds):
print(f" OOF fold {fold_i + 1}/{n_splits}...", flush=True)
tr_rows, tr_targets, tr_labels, tr_groups = augment_training_data(
rows[train_idx], targets[train_idx], labels[train_idx], groups[train_idx], rng
)
val_rows = rows[val_idx]
import copy
fold_et_reg = copy.deepcopy(et_reg)
fold_et_clf = copy.deepcopy(et_clf)
fold_et_reg.fit(tr_rows, tr_targets)
fold_et_clf.fit(tr_rows, tr_labels)
oof[val_idx, 0] = fold_et_reg.predict(val_rows)
oof[val_idx, 2] = fold_et_clf.predict_proba(val_rows)[:, 1]
if xgb_reg is not None and xgb_clf is not None:
fold_xgb_reg = copy.deepcopy(xgb_reg)
fold_xgb_clf = copy.deepcopy(xgb_clf)
fold_xgb_reg.fit(tr_rows, tr_targets)
fold_xgb_clf.fit(tr_rows, tr_labels)
oof[val_idx, 1] = fold_xgb_reg.predict(val_rows)
oof[val_idx, 3] = fold_xgb_clf.predict_proba(val_rows)[:, 1]
else:
oof[val_idx, 1] = oof[val_idx, 0] # mirror ET if no XGB
oof[val_idx, 3] = oof[val_idx, 2]
return oof
def find_best_threshold(labels: np.ndarray, scores: np.ndarray) -> float:
"""Threshold maximising recall-weighted F1 (medical screening priority)."""
best_score, best_thresh = -1.0, 0.5
for t in np.linspace(0.20, 0.75, 56):
preds = (scores >= t).astype(int)
if preds.sum() == 0:
continue
f1 = f1_score(labels, preds, zero_division=0)
rec = recall_score(labels, preds, zero_division=0)
score = f1 * 0.5 + rec * 0.5
if score > best_score:
best_score = score
best_thresh = float(t)
return best_thresh
# ─────────────────────────────────────────────────────────────────────────────
# CV evaluation of the full stacked pipeline
# ─────────────────────────────────────────────────────────────────────────────
def evaluate_stacked(
rows: np.ndarray,
targets: np.ndarray,
labels: np.ndarray,
groups: np.ndarray,
et_reg: ExtraTreesRegressor,
et_clf: ExtraTreesClassifier,
xgb_reg: object | None,
xgb_clf: object | None,
n_splits: int = N_CV_SPLITS,
) -> dict[str, float]:
"""
Outer CV loop: for each fold, build OOF meta-features on the train portion,
fit meta-learners, evaluate on the held-out test fold.
"""
import copy
splitter = GroupShuffleSplit(n_splits=n_splits, test_size=0.2, random_state=RANDOM_STATE + 1)
all_metrics: list[dict[str, float]] = []
rng = np.random.default_rng(RANDOM_STATE + 99)
for fold_i, (train_idx, test_idx) in enumerate(splitter.split(rows, labels, groups)):
print(f" Outer CV fold {fold_i + 1}/{n_splits}...", flush=True)
tr_rows_raw = rows[train_idx]
tr_targets_raw = targets[train_idx]
tr_labels_raw = labels[train_idx]
tr_groups_raw = groups[train_idx]
te_rows = rows[test_idx]
te_targets = targets[test_idx]
te_labels = labels[test_idx]
# Build OOF meta-features on training portion (inner loop)
oof_meta = build_oof_meta_features(
tr_rows_raw, tr_targets_raw, tr_labels_raw, tr_groups_raw,
copy.deepcopy(et_reg), copy.deepcopy(et_clf),
copy.deepcopy(xgb_reg) if xgb_reg else None,
copy.deepcopy(xgb_clf) if xgb_clf else None,
n_splits=3,
)
# Fit meta-learners on OOF
meta_reg = Ridge(alpha=1.0)
meta_clf = LogisticRegression(C=1.0, max_iter=500, random_state=RANDOM_STATE, solver="lbfgs")
meta_reg.fit(oof_meta, tr_targets_raw)
meta_clf.fit(oof_meta, tr_labels_raw)
# Build test meta-features: retrain base learners on augmented full train
aug_rows, aug_targets, aug_labels, _ = augment_training_data(
tr_rows_raw, tr_targets_raw, tr_labels_raw, tr_groups_raw, rng
)
fold_et_reg = copy.deepcopy(et_reg); fold_et_reg.fit(aug_rows, aug_targets)
fold_et_clf = copy.deepcopy(et_clf); fold_et_clf.fit(aug_rows, aug_labels)
te_meta = np.zeros((len(te_rows), 4), dtype=np.float32)
te_meta[:, 0] = fold_et_reg.predict(te_rows)
te_meta[:, 2] = fold_et_clf.predict_proba(te_rows)[:, 1]
if xgb_reg is not None:
fold_xgb_reg = copy.deepcopy(xgb_reg); fold_xgb_reg.fit(aug_rows, aug_targets)
fold_xgb_clf = copy.deepcopy(xgb_clf); fold_xgb_clf.fit(aug_rows, aug_labels)
te_meta[:, 1] = fold_xgb_reg.predict(te_rows)
te_meta[:, 3] = fold_xgb_clf.predict_proba(te_rows)[:, 1]
else:
te_meta[:, 1] = te_meta[:, 0]
te_meta[:, 3] = te_meta[:, 2]
hb_pred = meta_reg.predict(te_meta)
clf_prob = meta_clf.predict_proba(te_meta)[:, 1]
# Blend: same scheme as legacy model
hb_scale = max(float(np.quantile(np.abs(tr_targets_raw - fold_et_reg.predict(tr_rows_raw)), 0.75)), 0.8)
reg_risk = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_pred])
blend = 0.55 * clf_prob + 0.45 * reg_risk
thresh = find_best_threshold(te_labels, blend)
preds = (blend >= thresh).astype(int)
all_metrics.append({
"accuracy": accuracy_score(te_labels, preds),
"precision": precision_score(te_labels, preds, zero_division=0),
"recall": recall_score(te_labels, preds, zero_division=0),
"f1": f1_score(te_labels, preds, zero_division=0),
"auc": roc_auc_score(te_labels, blend),
"mae_hb": mean_absolute_error(te_targets, hb_pred),
"threshold": thresh,
})
avg = {k: round(float(np.mean([m[k] for m in all_metrics])), 4) for k in all_metrics[0]}
return avg
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> None:
print("=" * 60, flush=True)
print("AnemiaLens — stacked-ensemble-v4 training", flush=True)
print("=" * 60, flush=True)
# ── 1. Load dataset ───────────────────────────────────────────────────────
print("\n[1/6] Loading dataset...", flush=True)
subjects = _build_subject_catalog(DATASET_ROOT)
print(f" Subjects: {len(subjects)}", flush=True)
samples = _samples_for_mode(subjects, "hybrid_dual")
print(f" Samples (hybrid_dual): {len(samples)}", flush=True)
rows, targets, labels, groups = _rows_from_samples(samples)
print(f" Class balance: {labels.sum()} anemic / {(labels == 0).sum()} non-anemic", flush=True)
# ── 2. Hyperparameter tuning ──────────────────────────────────────────────
print("\n[2/6] Tuning hyperparameters (RandomizedSearchCV, 20 iter each)...", flush=True)
rng = np.random.default_rng(RANDOM_STATE)
aug_rows, aug_targets, aug_labels, _ = augment_training_data(rows, targets, labels, groups, rng)
print(" Tuning ExtraTrees regressor...", flush=True)
et_reg = tune_et_regressor(aug_rows, aug_targets)
print(" Tuning ExtraTrees classifier...", flush=True)
et_clf = tune_et_classifier(aug_rows, aug_labels)
if _HAS_XGB:
print(" Tuning XGBoost regressor...", flush=True)
xgb_reg = tune_xgb_regressor(aug_rows, aug_targets)
print(" Tuning XGBoost classifier...", flush=True)
xgb_clf = tune_xgb_classifier(aug_rows, aug_labels)
else:
xgb_reg = xgb_clf = None
# ── 3. CV evaluation ──────────────────────────────────────────────────────
print("\n[3/6] Cross-validating stacked ensemble...", flush=True)
cv_metrics = evaluate_stacked(rows, targets, labels, groups, et_reg, et_clf, xgb_reg, xgb_clf)
print(f"\n CV metrics: {cv_metrics}", flush=True)
# ── 4. Build final OOF meta-features on all data ──────────────────────────
print("\n[4/6] Building final OOF meta-features on full dataset...", flush=True)
oof_meta = build_oof_meta_features(
rows, targets, labels, groups, et_reg, et_clf, xgb_reg, xgb_clf, n_splits=N_CV_SPLITS
)
# ── 5. Fit final meta-learners ────────────────────────────────────────────
print("\n[5/6] Fitting meta-learners on OOF predictions...", flush=True)
meta_reg = Ridge(alpha=1.0)
meta_clf = LogisticRegression(C=1.0, max_iter=500, random_state=RANDOM_STATE, solver="lbfgs")
meta_reg.fit(oof_meta, targets)
meta_clf.fit(oof_meta, labels)
# Retrain base learners on full augmented data for inference
rng2 = np.random.default_rng(RANDOM_STATE + 1)
full_aug_rows, full_aug_targets, full_aug_labels, _ = augment_training_data(
rows, targets, labels, groups, rng2
)
et_reg.fit(full_aug_rows, full_aug_targets)
et_clf.fit(full_aug_rows, full_aug_labels)
if xgb_reg is not None:
xgb_reg.fit(full_aug_rows, full_aug_targets)
xgb_clf.fit(full_aug_rows, full_aug_labels)
# ── 6. Instantiate module-level stacked wrappers for inference ────────────
stacked_reg = StackedRegressor(et_reg, xgb_reg, et_clf, xgb_clf, meta_reg)
stacked_clf = StackedClassifier(et_clf, xgb_clf, et_reg, xgb_reg, meta_clf)
# ── Calibration ───────────────────────────────────────────────────────────
hb_preds_full = stacked_reg.predict(rows)
residuals = np.abs(targets - hb_preds_full)
hb_scale = max(float(np.quantile(residuals, 0.75)), 0.8)
hb_population_mean = float(np.mean(targets))
pred_std = float(np.std(hb_preds_full))
true_std = float(np.std(targets))
hb_spread_factor = float(np.clip(true_std / max(pred_std, 0.5), 1.0, 2.0))
clf_probs_full = stacked_clf.predict_proba(rows)[:, 1]
reg_risk_full = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_preds_full])
blend_full = 0.55 * clf_probs_full + 0.45 * reg_risk_full
best_threshold = find_best_threshold(labels, blend_full)
risk_scale = max(float(np.std(blend_full)) * 0.9, 0.08)
risk_scale = min(risk_scale, 0.22)
calibration = {
"hb_threshold": ANEMIA_HB_THRESHOLD,
"hb_scale": round(hb_scale, 4),
"hb_population_mean": round(hb_population_mean, 4),
"hb_spread_factor": round(hb_spread_factor, 4),
"regressor_tree_std_reference": 2.5,
"classifier_tree_std_reference": 0.5,
"classifier_weight": 0.55,
"blend_threshold": round(best_threshold, 4),
"risk_scale": round(risk_scale, 4),
"base_uncertainty": 0.11,
}
# ── Save artifact ─────────────────────────────────────────────────────────
print("\n[6/6] Saving model...", flush=True)
artifact = {
"version": "stacked-ensemble-v4",
"feature_names": V4_FEATURE_NAMES,
"regressor": stacked_reg,
"classifier": stacked_clf,
"calibration": calibration,
"training": {
"selected_mode": "hybrid_dual",
"subject_count": len(subjects),
"record_count": len(samples),
"metrics": cv_metrics,
"xgboost_available": _HAS_XGB,
},
}
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
joblib.dump(artifact, OUTPUT_PATH)
joblib.dump(artifact, OUTPUT_PATH_V4) # keep versioned copy too
print(f" Saved → {OUTPUT_PATH}", flush=True)
print(f" Saved → {OUTPUT_PATH_V4}", flush=True)
report = {
"dataset_name": "dataset anemia",
"record_count": len(samples),
"subject_count": len(subjects),
"primary_model": "stacked-ensemble-v4",
"selected_mode": "hybrid_dual",
"metrics": cv_metrics,
"calibration": {
"blend_threshold": calibration["blend_threshold"],
"risk_scale": calibration["risk_scale"],
"classifier_weight": calibration["classifier_weight"],
},
}
with open(REPORT_PATH, "w") as f:
json.dump(report, f, indent=2)
print(f" Report → {REPORT_PATH}", flush=True)
# ── Sanity check ──────────────────────────────────────────────────────────
print("\n── Sanity check ──────────────────────────────────────────────────", flush=True)
feat_idx = {n: i for i, n in enumerate(V4_FEATURE_NAMES)}
test_cases = [
("PALE (anemic)", 0.28, 0.02, 0.22),
("BORDERLINE", 0.35, 0.04, 0.30),
("NORMAL", 0.44, 0.08, 0.38),
("VERY HEALTHY", 0.48, 0.10, 0.42),
]
for label, cpi_val, rg_val, br_val in test_cases:
row = np.zeros((1, len(V4_FEATURE_NAMES)), dtype=np.float32)
row[0, feat_idx["cpi"]] = cpi_val
row[0, feat_idx["center_cpi"]] = cpi_val - 0.01
row[0, feat_idx["mean_r"]] = cpi_val * 0.9
row[0, feat_idx["mean_g"]] = cpi_val * 0.9 - rg_val
row[0, feat_idx["mean_b"]] = cpi_val * 0.7
row[0, feat_idx["center_mean_r"]] = cpi_val * 0.9
row[0, feat_idx["center_mean_g"]] = cpi_val * 0.9 - rg_val
row[0, feat_idx["center_mean_b"]] = cpi_val * 0.7
row[0, feat_idx["red_green_gap"]] = rg_val
row[0, feat_idx["center_red_green_gap"]] = rg_val
row[0, feat_idx["brightness"]] = br_val
row[0, feat_idx["center_brightness"]] = br_val
row[0, feat_idx["contrast"]] = 0.12
row[0, feat_idx["center_contrast"]] = 0.12
row[0, feat_idx["blur_score"]] = 100.0
row[0, feat_idx["center_blur_score"]] = 120.0
row[0, feat_idx["saturation"]] = 0.3
row[0, feat_idx["center_saturation"]] = 0.3
row[0, feat_idx["green_blue_ratio"]] = 1.1 if cpi_val < 0.35 else 1.25
row[0, feat_idx["hist_mid"]] = 0.5
row[0, feat_idx["hist_bright"]] = 0.3
row[0, feat_idx["aspect_ratio"]] = 1.0
row[0, feat_idx["size_score"]] = 1.0
row[0, feat_idx["source_roi_original"]] = 1.0
hb_p = float(stacked_reg.predict(row)[0])
cp = float(stacked_clf.predict_proba(row)[0, 1])
rr = sigmoid((ANEMIA_HB_THRESHOLD - hb_p) / hb_scale)
bs = 0.55 * cp + 0.45 * rr
risk = sigmoid((bs - best_threshold) / risk_scale)
decision = "ANEMIA LIKELY" if risk >= 0.65 else "unlikely"
print(f" {label}: Hb={hb_p:.1f}, clf_prob={cp:.3f}, risk={risk:.3f} -> {decision}", flush=True)
print("\nDone.", flush=True)
if __name__ == "__main__":
main()