| """ |
| OmniDiag β Ensemble Model Loader (Stacking / Voting) |
| ====================================================== |
| Wraps multiple base models + a meta-learner for ensemble inference. |
| |
| Key Design: |
| - NEW class (does NOT modify existing ModelLoader) |
| - Each base model is loaded as a standalone sklearn-compatible estimator |
| - Feature engineering + preprocessing is applied ONCE, shared across models |
| - Supports both stacking (meta-learner) and voting (equal-weight average) |
| - SHAP explanations are weighted averages of per-model SHAP values |
| |
| Usage: |
| loader = EnsembleModelLoader(config) |
| result = loader.predict(patient_data) |
| explanation = loader.explain(patient_data) |
| """ |
|
|
| import os |
| import json |
| import logging |
| import traceback |
| import importlib |
| from typing import Dict, Any, List, Optional |
|
|
| import joblib |
| import numpy as np |
| import pandas as pd |
| import shap |
|
|
| from backend.shap_service import generate_shap_explanation |
| from backend.counterfactual_generator import CounterfactualGenerator |
|
|
| log = logging.getLogger("omnidiag.ensemble_loader") |
|
|
| |
| _PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
|
|
|
|
| class EnsembleModelLoader: |
| """ |
| Lazy-loading ensemble inference wrapper. |
| |
| Supports two modes: |
| - 'stacking': base model probabilities β Logistic Regression meta-learner |
| - 'voting': average of base model probabilities (equal weight) |
| |
| Attributes: |
| config: Full disease configuration dict with 'ensemble' section. |
| _base_models: Dict of model_name β loaded model object. |
| _meta_learner: Loaded Logistic Regression (stacking) or None (voting). |
| _feature_engineer: Cached feature engineer instance. |
| _preprocessors: Cached dict of preprocessor objects. |
| _feature_names: Cached list of feature names expected by the models. |
| _ensemble_type: 'stacking' or 'voting'. |
| """ |
|
|
| def __init__(self, config: dict): |
| """ |
| Initialize the ensemble loader with a disease config. |
| |
| Args: |
| config: Parsed YAML config dict. Must contain a 'model.ensemble' key |
| with 'base_models' list and optionally 'meta_learner'. |
| """ |
| self.config = config |
| self.ensemble_config = config.get("model", {}).get("ensemble", {}) |
| self._ensemble_type = self.ensemble_config.get("type", "stacking") |
|
|
| |
| |
| |
| |
| |
| self._inference_threshold: float = float( |
| config.get("model", {}).get("inference_threshold", 0.5) |
| ) |
|
|
| |
| self._base_models: Optional[Dict[str, object]] = None |
| self._meta_learner: Optional[object] = None |
| self._feature_engineer: Optional[object] = None |
| self._preprocessors: Optional[Dict[str, object]] = None |
| self._feature_names: Optional[List[str]] = None |
| self._meta_feature_names: Optional[List[str]] = None |
|
|
| log.info( |
| f"EnsembleModelLoader initialized: type={self._ensemble_type}, " |
| f"base_models={[m['name'] for m in self.ensemble_config.get('base_models', [])]}, " |
| f"inference_threshold={self._inference_threshold}" |
| ) |
|
|
| |
| |
| |
|
|
| @property |
| def base_models(self) -> Dict[str, object]: |
| """Lazy-load all base models from their configured paths.""" |
| if self._base_models is None: |
| self._base_models = {} |
| base_configs = self.ensemble_config.get("base_models", []) |
|
|
| for bcfg in base_configs: |
| name = bcfg["name"] |
| weights_path = self._resolve_path(bcfg["weights_path"]) |
|
|
| log.debug(f"Loading base model '{name}' from: {weights_path}") |
| if not os.path.exists(weights_path): |
| raise FileNotFoundError( |
| f"Base model '{name}' weights not found at: {weights_path}" |
| ) |
|
|
| model = joblib.load(weights_path) |
| self._base_models[name] = model |
| log.info(f"Loaded base model '{name}': {type(model).__name__}") |
|
|
| return self._base_models |
|
|
| @property |
| def meta_learner(self) -> Optional[object]: |
| """Lazy-load the meta-learner (only for stacking).""" |
| if self._ensemble_type != "stacking": |
| return None |
|
|
| if self._meta_learner is None: |
| meta_config = self.ensemble_config.get("meta_learner", {}) |
| weights_path = self._resolve_path(meta_config.get("weights_path", "")) |
|
|
| log.debug(f"Loading meta-learner from: {weights_path}") |
| if not os.path.exists(weights_path): |
| raise FileNotFoundError( |
| f"Meta-learner weights not found at: {weights_path}" |
| ) |
|
|
| self._meta_learner = joblib.load(weights_path) |
| log.info( |
| f"Loaded meta-learner: {type(self._meta_learner).__name__}" |
| ) |
|
|
| return self._meta_learner |
|
|
| @property |
| def preprocessors(self) -> Dict[str, object]: |
| """Lazy-load preprocessors (standard_scaler, label_encoders, feature_names).""" |
| if self._preprocessors is None: |
| preprocessors_path = self.config.get("model", {}).get( |
| "preprocessors_path", "" |
| ) |
| if preprocessors_path and not os.path.isabs(preprocessors_path): |
| preprocessors_path = os.path.join( |
| _PROJECT_ROOT, preprocessors_path |
| ) |
|
|
| self._preprocessors = {} |
| if preprocessors_path and os.path.isdir(preprocessors_path): |
| for filename in os.listdir(preprocessors_path): |
| if filename.endswith(".pkl"): |
| filepath = os.path.join(preprocessors_path, filename) |
| key = filename.replace(".pkl", "") |
| self._preprocessors[key] = joblib.load(filepath) |
|
|
| |
| fn_path = os.path.join(preprocessors_path, "feature_names.json") |
| if os.path.exists(fn_path): |
| with open(fn_path) as f: |
| self._feature_names = json.load(f) |
|
|
| |
| mfn_path = os.path.join( |
| preprocessors_path, "meta_feature_names.json" |
| ) |
| if os.path.exists(mfn_path): |
| with open(mfn_path) as f: |
| self._meta_feature_names = json.load(f) |
|
|
| return self._preprocessors |
|
|
| |
| |
| |
|
|
| def _get_feature_engineer(self): |
| """Lazy-load the feature engineer from config.""" |
| if self._feature_engineer is None: |
| module_path = self.config.get("features", {}).get("module", "") |
| class_name = self.config.get("features", {}).get("class", "") |
| if module_path and class_name: |
| try: |
| module = importlib.import_module(module_path) |
| engineer_class = getattr(module, class_name) |
| self._feature_engineer = engineer_class(self.config) |
| except (ImportError, AttributeError) as e: |
| raise ImportError( |
| f"Could not load feature engineer '{class_name}' " |
| f"from '{module_path}': {e}" |
| ) |
| return self._feature_engineer |
|
|
| def _engineer_features(self, df: pd.DataFrame) -> pd.DataFrame: |
| """Apply heuristic + medical feature engineering (NOT clinical).""" |
| engineer = self._get_feature_engineer() |
| if engineer: |
| df = engineer.engineer_heuristic(df) |
| df = engineer.engineer_medical(df) |
| return df |
|
|
| |
| |
| |
|
|
| def _apply_preprocessors(self, df: pd.DataFrame) -> pd.DataFrame: |
| """ |
| Apply label encoders + standard scaler. |
| Same logic as ModelLoader._apply_preprocessors(). |
| """ |
| df = df.copy() |
| preprocessors = self.preprocessors |
|
|
| |
| label_encoders = preprocessors.get("label_encoders", {}) |
| if isinstance(label_encoders, dict): |
| for col, encoder in label_encoders.items(): |
| if col in df.columns: |
| df[col] = encoder.transform(df[col].astype(str)) |
|
|
| |
| scaler = preprocessors.get("standard_scaler") |
| if scaler is not None: |
| numeric_cols = self.config.get("features", {}).get( |
| "numerical_columns", [] |
| ) |
| numeric_cols_present = [c for c in numeric_cols if c in df.columns] |
| if numeric_cols_present: |
| df[numeric_cols_present] = scaler.transform( |
| df[numeric_cols_present] |
| ) |
|
|
| return df |
|
|
| |
| |
| |
|
|
| def predict(self, patient_data: Dict[str, Any]) -> Dict[str, Any]: |
| """ |
| Run ensemble prediction on a single patient's data. |
| |
| For stacking: each base model predicts β meta-learner combines them. |
| For voting: each base model predicts β average probabilities. |
| |
| Returns: |
| Dictionary with keys: |
| - prediction: int (0 = Negative, 1 = Positive) |
| - confidence: float (probability of positive class) |
| - diagnosis: str ("Positive" or "Negative") |
| - model_contributions: Dict of model_name β probability |
| - ensemble_variance: float (std of base model probabilities) |
| - model_agreement: str ("high", "moderate", "low") |
| """ |
| |
| df = pd.DataFrame([patient_data]) |
| df = self._engineer_features(df) |
| df = self._apply_preprocessors(df) |
|
|
| |
| probas = {} |
| for name, model in self.base_models.items(): |
| try: |
| proba = float(model.predict_proba(df)[0][1]) |
| probas[name] = proba |
| except Exception as e: |
| log.warning( |
| f"Base model '{name}' prediction failed: {e}. Using 0.5" |
| ) |
| probas[name] = 0.5 |
|
|
| |
| if self._ensemble_type == "stacking" and self.meta_learner is not None: |
| |
| X_meta = np.array( |
| [probas[name] for name in self.base_models.keys()] |
| ).reshape(1, -1) |
| final_proba = float( |
| self.meta_learner.predict_proba(X_meta)[0][1] |
| ) |
| else: |
| |
| final_proba = float(np.mean(list(probas.values()))) |
|
|
| final_pred = int(final_proba >= self._inference_threshold) |
|
|
| |
| proba_values = list(probas.values()) |
| ensemble_variance = float(np.std(proba_values)) if len(proba_values) > 0 else 0.0 |
| |
| if ensemble_variance < 0.05: |
| model_agreement = "high" |
| elif ensemble_variance < 0.15: |
| model_agreement = "moderate" |
| else: |
| model_agreement = "low" |
|
|
| return { |
| "prediction": final_pred, |
| "confidence": final_proba, |
| "diagnosis": "Positive" if final_pred == 1 else "Negative", |
| "model_contributions": probas, |
| "ensemble_type": self._ensemble_type, |
| "inference_threshold": self._inference_threshold, |
| "ensemble_variance": round(ensemble_variance, 4), |
| "model_agreement": model_agreement, |
| } |
|
|
| |
| |
| |
|
|
| def generate_counterfactuals(self, patient_data: Dict[str, Any]) -> Dict[str, Any]: |
| """ |
| Generate diverse counterfactual explanations for a patient. |
| |
| Uses the DiCE-inspired CounterfactualGenerator with random sampling |
| and diversity selection to produce 3 actionable "what-if" scenarios |
| that flip Positive β Negative predictions. |
| |
| Args: |
| patient_data: Raw patient features dict (e.g., 21 BRFSS fields). |
| |
| Returns: |
| Dictionary with: |
| - counterfactuals: List of counterfactual scenario dicts |
| - status: "generated" or "not_applicable" (if already low risk) |
| - baseline_probability: Current probability of positive class |
| """ |
| |
| baseline_pred = self.predict(patient_data) |
| baseline_proba = baseline_pred.get("confidence", 0.5) |
| |
| |
| if baseline_proba < self._inference_threshold: |
| return { |
| "counterfactuals": [], |
| "status": "not_applicable", |
| "baseline_probability": baseline_proba, |
| "message": "Patient is already below the clinical threshold. " |
| "Counterfactuals focus on reducing high-risk predictions.", |
| } |
| |
| |
| def pipeline_fn(df: pd.DataFrame) -> pd.DataFrame: |
| """Apply feature engineering + preprocessing to raw DataFrame.""" |
| df = self._engineer_features(df) |
| df = self._apply_preprocessors(df) |
| return df |
| |
| |
| def predict_fn(df: pd.DataFrame) -> Dict[str, Any]: |
| """Get the ensemble's predict_proba output for a DataFrame.""" |
| |
| |
| probas = {} |
| for name, model in self.base_models.items(): |
| try: |
| proba = float(model.predict_proba(df)[0][1]) |
| probas[name] = proba |
| except Exception: |
| probas[name] = 0.5 |
| |
| if self._ensemble_type == "stacking" and self.meta_learner is not None: |
| X_meta = np.array( |
| [probas[name] for name in self.base_models.keys()] |
| ).reshape(1, -1) |
| final_proba = float( |
| self.meta_learner.predict_proba(X_meta)[0][1] |
| ) |
| else: |
| final_proba = float(np.mean(list(probas.values()))) |
| |
| return {"confidence": final_proba} |
| |
| |
| sample_df = pipeline_fn(pd.DataFrame([patient_data])) |
| feature_names = list(sample_df.columns) |
| |
| |
| raw_feature_names = [ |
| c for c in list(patient_data.keys()) |
| if c not in ( |
| "BMI_Age_Interaction", "Health_Index", "Lifestyle_Score", |
| "SES_Composite", "Diabetes_Clinical_Risk" |
| ) |
| ] |
| |
| |
| generator = CounterfactualGenerator( |
| predict_fn=predict_fn, |
| pipeline_fn=pipeline_fn, |
| feature_names=feature_names, |
| raw_feature_names=raw_feature_names, |
| n_samples=500, |
| n_counterfactuals=3, |
| random_state=42, |
| inference_threshold=self._inference_threshold, |
| ) |
| |
| counterfactuals = generator.generate( |
| patient_data=patient_data, |
| desired_class=0, |
| ) |
| |
| return { |
| "counterfactuals": counterfactuals, |
| "status": "generated" if counterfactuals else "no_valid_counterfactuals", |
| "baseline_probability": baseline_proba, |
| "message": None if counterfactuals else ( |
| "Could not find valid counterfactuals for this patient. " |
| "The patient may need more significant lifestyle or medical changes " |
| "than typical recommendations can provide." |
| ), |
| } |
| |
| |
| |
| |
|
|
| def explain(self, patient_data: Dict[str, Any]) -> Dict[str, Any]: |
| """ |
| Generate a weighted-average SHAP explanation across all base models. |
| |
| For stacking: weights = meta-learner coefficients (absolute value). |
| For voting: weights = 1 / n_models (equal). |
| |
| If a base model's TreeExplainer fails (e.g. sklearn version incompatibility), |
| that model's weight is redistributed to the working models. |
| |
| Returns: |
| Dictionary with keys: |
| - chart_data: List of {"feature": str, "shap_value": float} |
| - text_explanation: Human-readable top-3 feature impacts |
| - base_value: Weighted average base value |
| - per_model_shap: Dict of model_name β individual SHAP result |
| - shap_weights: Dict of model_name β weight used in average |
| - ensemble_variance: float (std of base model probabilities) |
| - model_agreement: str ("high", "moderate", "low") |
| """ |
| try: |
| df = pd.DataFrame([patient_data]) |
| df = self._engineer_features(df) |
| df = self._apply_preprocessors(df) |
| feature_names = list(df.columns) |
|
|
| |
| probas = {} |
| for name, model in self.base_models.items(): |
| try: |
| proba = float(model.predict_proba(df)[0][1]) |
| probas[name] = proba |
| except Exception: |
| probas[name] = 0.5 |
| proba_values = list(probas.values()) |
| ensemble_variance = float(np.std(proba_values)) if len(proba_values) > 0 else 0.0 |
| if ensemble_variance < 0.05: |
| model_agreement = "high" |
| elif ensemble_variance < 0.15: |
| model_agreement = "moderate" |
| else: |
| model_agreement = "low" |
|
|
| |
| |
| for col in df.columns: |
| if df[col].dtype == 'object': |
| df[col] = df[col].astype('category') |
| log.debug(f"Cast column '{col}' from object to category for SHAP compatibility") |
|
|
| |
| df_float = df.astype(np.float64) |
| per_model_shap = {} |
| failed_models = set() |
|
|
| |
| shap_model_paths = {} |
| for bcfg in self.ensemble_config.get("base_models", []): |
| shap_model_paths[bcfg["name"]] = self._resolve_path( |
| bcfg["weights_path"] |
| ) |
|
|
| for name in self.base_models: |
| try: |
| |
| |
| fresh_model = joblib.load(shap_model_paths[name]) |
|
|
| |
| |
| |
| import ast as _ast |
| try: |
| booster = fresh_model.get_booster() |
| cfg = json.loads(booster.save_config()) |
| raw_bs = cfg["learner"]["learner_model_param"]["base_score"] |
| if isinstance(raw_bs, str): |
| stripped = raw_bs.strip() |
| if stripped.startswith("[") and stripped.endswith("]"): |
| parsed = _ast.literal_eval(stripped) |
| if isinstance(parsed, (list, tuple)) and len(parsed) == 1: |
| clean_bs = float(parsed[0]) |
| import types as _types |
| |
| _original_save_raw = booster.save_raw |
| def _patched_save_raw(self, raw_format="ubj"): |
| raw = _original_save_raw(raw_format=raw_format) |
| raw_bytes = bytes(raw) |
| marker = b"base_score" |
| idx = raw_bytes.find(marker) |
| if idx >= 0: |
| s_pos = idx + len(marker) |
| if raw_bytes[s_pos] == 0x53 and raw_bytes[s_pos + 1] == 0x4C: |
| len_bytes = raw_bytes[s_pos + 2 : s_pos + 2 + 8] |
| old_len = int.from_bytes(len_bytes, "big") |
| content_start = s_pos + 2 + 8 |
| old_content = raw_bytes[content_start : content_start + old_len] |
| if old_content.startswith(b"[") and old_content.endswith(b"]"): |
| new_content = old_content[1:-1] |
| new_len = len(new_content) |
| patched = bytearray(raw_bytes) |
| patched[s_pos + 2 : s_pos + 2 + 8] = new_len.to_bytes(8, "big") |
| patched = patched[:content_start] + new_content + patched[content_start + old_len:] |
| return bytearray(patched) |
| return raw |
| booster.save_raw = _types.MethodType(_patched_save_raw, booster) |
| log.info( |
| "Patched XGBoost base_score via save_raw() monkey-patch " |
| "for ensemble model '%s': '%s' -> %s", |
| name, raw_bs, clean_bs, |
| ) |
| except Exception: |
| log.debug( |
| "base_score save_raw() patch skipped for ensemble model '%s' " |
| "(not XGBoost or already clean)", |
| name, |
| ) |
|
|
| explainer = shap.TreeExplainer(fresh_model) |
| sv = explainer(df_float) |
| |
| |
| vals = sv.values |
| if vals.ndim == 3: |
| vals = vals[:, :, -1] |
| |
| |
| |
| |
| bv_raw = sv.base_values if hasattr(sv, "base_values") else 0.0 |
| bv_scalar = float(np.ravel(bv_raw)[-1]) |
| per_model_shap[name] = { |
| "values": vals.flatten().tolist(), |
| "base_value": bv_scalar, |
| } |
| nz = sum(1 for v in vals.flatten() if abs(v) > 1e-10) |
| log.debug( |
| f"SHAP for '{name}' succeeded: " |
| f"non-zero={nz}/{len(feature_names)}" |
| ) |
| except Exception as e: |
| log.warning( |
| f"SHAP explanation for '{name}' failed: " |
| f"{type(e).__name__}: {e}. Skipping." |
| ) |
| log.warning(traceback.format_exc()) |
| per_model_shap[name] = { |
| "values": [0.0] * len(feature_names), |
| "base_value": 0.0, |
| } |
| failed_models.add(name) |
|
|
| |
| if ( |
| self._ensemble_type == "stacking" |
| and self.meta_learner is not None |
| ): |
| |
| raw_weights = np.abs(self.meta_learner.coef_[0]) |
| |
| model_names = list(self.base_models.keys()) |
| if len(raw_weights) == len(model_names): |
| shap_weights = { |
| name: float(w) |
| for name, w in zip(model_names, raw_weights) |
| } |
| else: |
| |
| n = len(self.base_models) |
| shap_weights = { |
| name: 1.0 / n for name in self.base_models |
| } |
| else: |
| |
| n = len(self.base_models) |
| shap_weights = {name: 1.0 / n for name in self.base_models} |
|
|
| |
| for failed in failed_models: |
| shap_weights[failed] = 0.0 |
|
|
| |
| total_weight = sum(shap_weights.values()) |
| if total_weight > 0: |
| shap_weights = { |
| k: v / total_weight for k, v in shap_weights.items() |
| } |
| else: |
| |
| n = len(self.base_models) |
| shap_weights = {name: 1.0 / n for name in self.base_models} |
|
|
| |
| n_features = len(feature_names) |
| weighted_shap_values = np.zeros(n_features) |
| weighted_base_value = 0.0 |
|
|
| for name, weight in shap_weights.items(): |
| sv = per_model_shap.get(name, {}) |
| values = np.array(sv.get("values", [0.0] * n_features)) |
| |
| if len(values) != n_features: |
| values = np.array( |
| values[:n_features] |
| if len(values) > n_features |
| else list(values) |
| + [0.0] * (n_features - len(values)) |
| ) |
| weighted_shap_values += weight * values |
| weighted_base_value += weight * sv.get("base_value", 0.0) |
|
|
| |
| |
| class ShapValuesContainer: |
| """Mimics shap.Explanation for use with generate_shap_explanation.""" |
|
|
| def __init__(self, values, base_values, feature_names): |
| self.values = np.array([values]) |
| self.base_values = np.array([base_values]) |
| self.data = None |
| self.feature_names = feature_names |
|
|
| shap_container = ShapValuesContainer( |
| weighted_shap_values, weighted_base_value, feature_names |
| ) |
|
|
| result = generate_shap_explanation(shap_container, feature_names) |
|
|
| |
| pred_result = self.predict(patient_data) |
| result["prediction"] = pred_result["prediction"] |
| result["confidence"] = pred_result["confidence"] |
| result["diagnosis"] = pred_result["diagnosis"] |
|
|
| |
| result["per_model_shap"] = per_model_shap |
| result["shap_weights"] = shap_weights |
| result["ensemble_variance"] = round(ensemble_variance, 4) |
| result["model_agreement"] = model_agreement |
|
|
| log.debug( |
| f"Ensemble SHAP generated: {len(result.get('chart_data', []))} features, " |
| f"weights={shap_weights}, " |
| f"ensemble_variance={ensemble_variance:.4f}" |
| ) |
| return result |
|
|
| except Exception as e: |
| log.error( |
| f"Ensemble explain failed: {type(e).__name__}: {e}" |
| ) |
| log.error(traceback.format_exc()) |
| raise |
|
|
| |
| |
| |
|
|
| def _resolve_path(self, path: str) -> str: |
| """Resolve a potentially relative path against the project root.""" |
| if not path: |
| return "" |
| if os.path.isabs(path): |
| return path |
| return os.path.join(_PROJECT_ROOT, path) |
|
|