Spaces:
Sleeping
Sleeping
| """ | |
| 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() | |