amobionovo commited on
Commit
4a71291
·
verified ·
1 Parent(s): ed7e4f6

Update handler.py

Browse files
Files changed (1) hide show
  1. handler.py +356 -302
handler.py CHANGED
@@ -1,302 +1,356 @@
1
- # handler.py — Quantium insights Inference Endpoint (Residence_type canonicalized)
2
- import os
3
- import json
4
- import traceback
5
- from typing import Any, Dict, List, Tuple
6
-
7
- import joblib
8
- import numpy as np
9
- import pandas as pd
10
-
11
-
12
- # =========================
13
- # Feature schema (canonical)
14
- # =========================
15
- NUMERIC_COLS = ["age", "avg_glucose_level", "bmi", "hypertension", "heart_disease"]
16
- # Canonical Residence key uses capital R
17
- CAT_COLS = ["gender", "ever_married", "work_type", "smoking_status", "Residence_type"]
18
- ALL_CANON = NUMERIC_COLS + CAT_COLS
19
-
20
- # For explain UI ordering (match canonical names)
21
- EXPLAIN_ORDER = [
22
- "age", "avg_glucose_level", "bmi", "hypertension", "heart_disease",
23
- "gender", "ever_married", "work_type", "smoking_status", "Residence_type"
24
- ]
25
-
26
-
27
- # =========================
28
- # Utility: dtype coercion
29
- # =========================
30
- def _to_int01(x: Any) -> int:
31
- if isinstance(x, (bool, np.bool_)):
32
- return int(bool(x))
33
- try:
34
- if isinstance(x, str):
35
- s = x.strip().lower()
36
- if s in {"1", "true", "t", "yes", "y"}:
37
- return 1
38
- if s in {"0", "false", "f", "no", "n"}:
39
- return 0
40
- return int(float(x))
41
- except Exception:
42
- return 0
43
-
44
-
45
- def _coerce_dataframe(rows: List[Dict[str, Any]]) -> pd.DataFrame:
46
- """
47
- Build a clean DataFrame:
48
- - Canonical Residence key is 'Residence_type' (capital R).
49
- - Accept 'residence_type' and map it to 'Residence_type' if needed.
50
- - Ensure numerics are float64 and 0/1 flags are ints then float64.
51
- - Ensure categoricals are plain Python strings (object), no NA.
52
- - Also mirror lowercase 'residence_type' for legacy models.
53
- """
54
- norm_rows: List[Dict[str, Any]] = []
55
- for r in rows:
56
- r = dict(r or {})
57
- # Normalize residence key to capitalized canonical
58
- if "Residence_type" not in r and "residence_type" in r:
59
- r["Residence_type"] = r["residence_type"]
60
- # Keep only canonical columns
61
- entry = {k: r.get(k, None) for k in ALL_CANON}
62
- norm_rows.append(entry)
63
-
64
- df = pd.DataFrame(norm_rows, columns=ALL_CANON)
65
-
66
- # binary flags first
67
- for col in ["hypertension", "heart_disease"]:
68
- df[col] = df[col].map(_to_int01)
69
-
70
- # strong numeric coercion
71
- for col in ["age", "avg_glucose_level", "bmi"]:
72
- df[col] = pd.to_numeric(df[col], errors="coerce")
73
-
74
- # final cast to float64
75
- df[NUMERIC_COLS] = df[NUMERIC_COLS].astype("float64")
76
-
77
- # categoricals as plain strings, no NA
78
- for col in CAT_COLS:
79
- df[col] = df[col].where(df[col].notna(), "Unknown")
80
- df[col] = df[col].map(lambda v: "Unknown" if v is None else str(v)).astype(object)
81
-
82
- # Mirror lowercase 'residence_type' for backward compatibility
83
- df["residence_type"] = df["Residence_type"].astype(object)
84
-
85
- return df
86
-
87
-
88
- # =========================
89
- # Safety patches for OHE
90
- # =========================
91
- def _iter_estimators(est):
92
- yield est
93
- # Pipelines
94
- if hasattr(est, "named_steps"):
95
- for step in est.named_steps.values():
96
- yield from _iter_estimators(step)
97
- # ColumnTransformer
98
- if hasattr(est, "transformers"):
99
- for _, tr, _ in est.transformers:
100
- yield from _iter_estimators(tr)
101
-
102
-
103
- def _numeric_like(x) -> bool:
104
- if x is None:
105
- return True
106
- if isinstance(x, (int, np.integer, float, np.floating)):
107
- return True
108
- if isinstance(x, str):
109
- try:
110
- float(x)
111
- return True
112
- except Exception:
113
- return False
114
- return False
115
-
116
-
117
- def _sanitize_onehot_categories(model):
118
- """Coerce OneHotEncoder.categories_ to consistent dtypes to avoid np.isnan crashes."""
119
- try:
120
- from sklearn.preprocessing import OneHotEncoder # type: ignore
121
- except Exception:
122
- OneHotEncoder = None
123
-
124
- if OneHotEncoder is None:
125
- return
126
-
127
- for node in _iter_estimators(model):
128
- if isinstance(node, OneHotEncoder) and hasattr(node, "categories_"):
129
- new_cats = []
130
- for cats in node.categories_:
131
- arr = np.asarray(cats, dtype=object)
132
- if all(_numeric_like(v) for v in arr):
133
- vals = []
134
- for v in arr:
135
- try:
136
- vals.append(np.nan if v is None else float(v))
137
- except Exception:
138
- vals.append(np.nan)
139
- new_cats.append(np.asarray(vals, dtype=float))
140
- else:
141
- strs = ["Unknown" if (v is None or (isinstance(v, float) and np.isnan(v))) else str(v) for v in arr]
142
- new_cats.append(np.asarray(strs, dtype=object))
143
- node.categories_ = new_cats
144
- if hasattr(node, "handle_unknown"):
145
- node.handle_unknown = "ignore"
146
-
147
-
148
- def _patch_check_unknown():
149
- """
150
- Monkey-patch sklearn.utils._encode._check_unknown to avoid np.isnan on object/string arrays
151
- on certain sklearn builds.
152
- """
153
- try:
154
- from sklearn.utils import _encode # type: ignore
155
- _orig = _encode._check_unknown
156
-
157
- def _safe_check_unknown(values, known_values, return_mask=False):
158
- try:
159
- return _orig(values, known_values, return_mask=return_mask)
160
- except TypeError:
161
- vals = np.asarray(values, dtype=object)
162
- known = np.asarray(known_values, dtype=object)
163
- mask = np.isin(vals, known, assume_unique=False)
164
- diff = vals[~mask]
165
- if return_mask:
166
- return diff, mask
167
- return diff
168
-
169
- _encode._check_unknown = _safe_check_unknown # type: ignore[attr-defined]
170
- print("[handler] Patched sklearn.utils._encode._check_unknown", flush=True)
171
- except Exception as e:
172
- print(f"[handler] Patch for _check_unknown not applied: {e}", flush=True)
173
-
174
-
175
- # =========================
176
- # Model introspection (debug)
177
- # =========================
178
- def _introspect_model(model) -> Dict[str, Any]:
179
- info: Dict[str, Any] = {"type": str(type(model))}
180
- try:
181
- if hasattr(model, "named_steps"):
182
- info["pipeline_steps"] = list(model.named_steps.keys())
183
- for name, step in model.named_steps.items():
184
- if step.__class__.__name__ == "ColumnTransformer":
185
- info["column_transformer"] = str(step)
186
- try:
187
- info["transformers_"] = [(n, str(t.__class__), cols) for (n, t, cols) in step.transformers]
188
- except Exception:
189
- pass
190
- except Exception:
191
- pass
192
- try:
193
- info["feature_names_in_"] = list(getattr(model, "feature_names_in_", []))
194
- except Exception:
195
- pass
196
- return info
197
-
198
-
199
- # =========================
200
- # Handler
201
- # =========================
202
- class EndpointHandler:
203
- def __init__(self, path: str = "/repository") -> None:
204
- _patch_check_unknown() # apply safety patch early
205
-
206
- model_path = os.path.join(path, "model.joblib")
207
- self.model = joblib.load(model_path)
208
-
209
- # Threshold (UI also reads this if present in response)
210
- try:
211
- self.threshold = float(os.getenv("THRESHOLD", "0.38"))
212
- except Exception:
213
- self.threshold = 0.38
214
-
215
- # Optional explainer (for old models); XGB wrapper may provide .top_contrib instead
216
- self.explainer = getattr(self.model, "explainer_", None)
217
-
218
- # Sanitize OneHotEncoder categories (if present)
219
- _sanitize_onehot_categories(self.model)
220
-
221
- print("[handler] Model loaded", flush=True)
222
- print(f"[handler] Using threshold: {self.threshold}", flush=True)
223
-
224
- def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
225
- debug = bool(data.get("debug", False))
226
- explain = bool(data.get("explain", False))
227
-
228
- rows = data.get("inputs") or []
229
- if isinstance(rows, dict):
230
- rows = [rows]
231
- if not isinstance(rows, list) or not rows:
232
- return {"error": "inputs must be a non-empty list of records", "threshold": self.threshold}
233
-
234
- df = _coerce_dataframe(rows)
235
-
236
- debug_info = {
237
- "columns": list(df.columns),
238
- "dtypes": {c: str(df[c].dtype) for c in df.columns},
239
- "threshold": self.threshold,
240
- "model": _introspect_model(self.model),
241
- "head": df.head(1).to_dict(orient="records"),
242
- }
243
-
244
- # Predict
245
- try:
246
- if hasattr(self.model, "predict_proba"):
247
- proba = self.model.predict_proba(df)[:, 1].astype(float)
248
- else:
249
- # e.g., model exposes only decision_function
250
- raw = self.model.predict(df).astype(float)
251
- proba = 1.0 / (1.0 + np.exp(-raw))
252
- except Exception as e:
253
- return {
254
- "error": f"model.predict failed: {e}",
255
- "trace": traceback.format_exc(),
256
- "debug": debug_info,
257
- "threshold": self.threshold,
258
- }
259
-
260
- p = float(proba[0])
261
- label = int(p >= self.threshold)
262
-
263
- resp: Dict[str, Any] = {
264
- "risk_probability": p,
265
- "risk_label": label,
266
- "threshold": self.threshold, # echo for the UI
267
- }
268
-
269
- # Explanations
270
- if explain:
271
- # Preferred path: XGB wrapper implements top_contrib()
272
- if hasattr(self.model, "top_contrib"):
273
- try:
274
- names, vals = self.model.top_contrib(df, k=5)
275
- if names:
276
- resp["shap"] = {"feature_names": names, "values": vals}
277
- except Exception as e:
278
- resp["shap_error"] = f"top_contrib failed: {e}"
279
- # Fallback: use stored explainer_ if present
280
- elif self.explainer is not None:
281
- try:
282
- shap_vals = self.explainer(df)
283
- vals = shap_vals.values[0] if hasattr(shap_vals, "values") else shap_vals[0]
284
- contrib = []
285
- for feat in EXPLAIN_ORDER:
286
- if feat in df.columns:
287
- idx = list(df.columns).index(feat)
288
- contrib.append({"feature": feat, "effect": float(vals[idx])})
289
- resp["shap"] = {"contrib": contrib}
290
- except Exception as e:
291
- resp["shap_error"] = f"explainer failed: {e}"
292
-
293
- if debug:
294
- resp["debug"] = debug_info
295
-
296
- # Optional console log (visible in Endpoint Logs)
297
- try:
298
- print(f"[handler] prob={p:.4f} label={label}", flush=True)
299
- except Exception:
300
- pass
301
-
302
- return resp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # handler.py — Quantium insights Inference Endpoint (fixes XGBWrappedModel unpickle + Residence_type)
2
+ import os
3
+ import sys
4
+ import types
5
+ import json
6
+ import traceback
7
+ from typing import Any, Dict, List, Tuple
8
+
9
+ import joblib
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ # =========================
14
+ # Re-declare the custom wrapper class and register it where pickle expects it
15
+ # =========================
16
+ class XGBWrappedModel:
17
+ """
18
+ Wrapper saved in model.joblib:
19
+ - preprocessor_: sklearn ColumnTransformer
20
+ - model_: XGBClassifier (or similar exposing predict_proba)
21
+ - explainer_: optional SHAP explainer
22
+ - feature_names_out_: names after preprocessing
23
+ Provides:
24
+ - predict_proba(X_df)
25
+ - top_contrib(X_df, k)
26
+ """
27
+ def __init__(self, preprocessor=None, booster=None, explainer=None,
28
+ feat_names_out=None, cat_prefix="cat__", num_prefix="num__"):
29
+ self.preprocessor_ = preprocessor
30
+ self.model_ = booster
31
+ self.explainer_ = explainer
32
+ self.feature_names_out_ = np.array(feat_names_out).astype(str) if feat_names_out is not None else None
33
+ self.cat_prefix = cat_prefix
34
+ self.num_prefix = num_prefix
35
+
36
+ def predict_proba(self, X_df: pd.DataFrame):
37
+ Z = self.preprocessor_.transform(X_df)
38
+ # XGBoost exposes predict_proba for binary: shape (n, 2)
39
+ return self.model_.predict_proba(Z)
40
+
41
+ def top_contrib(self, X_df: pd.DataFrame, k: int = 5) -> Tuple[List[str], List[float]]:
42
+ if self.explainer_ is None:
43
+ return [], []
44
+ Z = self.preprocessor_.transform(X_df)
45
+ try:
46
+ sv = self.explainer_.shap_values(Z)
47
+ if isinstance(sv, list):
48
+ sv = sv[1] if len(sv) > 1 else sv[0]
49
+ except Exception:
50
+ res = self.explainer_(Z)
51
+ sv = res.values
52
+ sv_row = np.array(sv[0], dtype=float)
53
+
54
+ def to_orig(name: str) -> str:
55
+ if name.startswith(self.cat_prefix):
56
+ return name[len(self.cat_prefix):].split("_", 1)[0]
57
+ if name.startswith(self.num_prefix):
58
+ return name[len(self.num_prefix):]
59
+ return name.split("_", 1)[0]
60
+
61
+ if self.feature_names_out_ is None:
62
+ names_out = [f"f{i}" for i in range(len(sv_row))]
63
+ else:
64
+ names_out = list(self.feature_names_out_)
65
+
66
+ orig_names = [to_orig(n) for n in names_out]
67
+ abs_sum: Dict[str, float] = {}
68
+ signed_sum: Dict[str, float] = {}
69
+ for n, v in zip(orig_names, sv_row):
70
+ abs_sum[n] = abs_sum.get(n, 0.0) + abs(float(v))
71
+ signed_sum[n] = signed_sum.get(n, 0.0) + float(v)
72
+
73
+ ranked = sorted(abs_sum.items(), key=lambda kv: kv[1], reverse=True)[:k]
74
+ names = [n for n, _ in ranked]
75
+ values = [signed_sum[n] for n, _ in ranked]
76
+ return names, values
77
+
78
+ # Register class under the module names pickle may look for
79
+ # (your training run saved it from __main__; sometimes from 'train_export_xgb')
80
+ sys.modules['__main__'].__dict__['XGBWrappedModel'] = XGBWrappedModel
81
+ if 'train_export_xgb' not in sys.modules:
82
+ sys.modules['train_export_xgb'] = types.ModuleType('train_export_xgb')
83
+ sys.modules['train_export_xgb'].__dict__['XGBWrappedModel'] = XGBWrappedModel
84
+
85
+
86
+ # =========================
87
+ # Feature schema (canonical)
88
+ # =========================
89
+ NUMERIC_COLS = ["age", "avg_glucose_level", "bmi", "hypertension", "heart_disease"]
90
+ # Canonical Residence key uses capital R
91
+ CAT_COLS = ["gender", "ever_married", "work_type", "smoking_status", "Residence_type"]
92
+ ALL_CANON = NUMERIC_COLS + CAT_COLS
93
+
94
+ EXPLAIN_ORDER = [
95
+ "age", "avg_glucose_level", "bmi", "hypertension", "heart_disease",
96
+ "gender", "ever_married", "work_type", "smoking_status", "Residence_type"
97
+ ]
98
+
99
+
100
+ # =========================
101
+ # Utility: dtype coercion
102
+ # =========================
103
+ def _to_int01(x: Any) -> int:
104
+ if isinstance(x, (bool, np.bool_)):
105
+ return int(bool(x))
106
+ try:
107
+ if isinstance(x, str):
108
+ s = x.strip().lower()
109
+ if s in {"1", "true", "t", "yes", "y"}:
110
+ return 1
111
+ if s in {"0", "false", "f", "no", "n"}:
112
+ return 0
113
+ return int(float(x))
114
+ except Exception:
115
+ return 0
116
+
117
+
118
+ def _coerce_dataframe(rows: List[Dict[str, Any]]) -> pd.DataFrame:
119
+ """
120
+ Build a clean DataFrame:
121
+ - Canonical Residence key is 'Residence_type' (capital R).
122
+ - Accept 'residence_type' and map it to 'Residence_type' if needed.
123
+ - Ensure numerics are float64 and 0/1 flags are ints then float64.
124
+ - Ensure categoricals are plain strings (object), no NA.
125
+ - Also mirror lowercase 'residence_type' for legacy models.
126
+ """
127
+ norm_rows: List[Dict[str, Any]] = []
128
+ for r in rows:
129
+ r = dict(r or {})
130
+ if "Residence_type" not in r and "residence_type" in r:
131
+ r["Residence_type"] = r["residence_type"]
132
+ entry = {k: r.get(k, None) for k in ALL_CANON}
133
+ norm_rows.append(entry)
134
+
135
+ df = pd.DataFrame(norm_rows, columns=ALL_CANON)
136
+
137
+ for col in ["hypertension", "heart_disease"]:
138
+ df[col] = df[col].map(_to_int01)
139
+
140
+ for col in ["age", "avg_glucose_level", "bmi"]:
141
+ df[col] = pd.to_numeric(df[col], errors="coerce")
142
+
143
+ df[NUMERIC_COLS] = df[NUMERIC_COLS].astype("float64")
144
+
145
+ for col in CAT_COLS:
146
+ df[col] = df[col].where(df[col].notna(), "Unknown")
147
+ df[col] = df[col].map(lambda v: "Unknown" if v is None else str(v)).astype(object)
148
+
149
+ # Mirror lowercase for backward compatibility
150
+ df["residence_type"] = df["Residence_type"].astype(object)
151
+
152
+ return df
153
+
154
+
155
+ # =========================
156
+ # Safety patches for OHE
157
+ # =========================
158
+ def _iter_estimators(est):
159
+ yield est
160
+ if hasattr(est, "named_steps"):
161
+ for step in est.named_steps.values():
162
+ yield from _iter_estimators(step)
163
+ if hasattr(est, "transformers"):
164
+ for _, tr, _ in est.transformers:
165
+ yield from _iter_estimators(tr)
166
+
167
+
168
+ def _numeric_like(x) -> bool:
169
+ if x is None:
170
+ return True
171
+ if isinstance(x, (int, np.integer, float, np.floating)):
172
+ return True
173
+ if isinstance(x, str):
174
+ try:
175
+ float(x)
176
+ return True
177
+ except Exception:
178
+ return False
179
+ return False
180
+
181
+
182
+ def _sanitize_onehot_categories(model):
183
+ """Coerce OneHotEncoder.categories_ to consistent dtypes to avoid np.isnan crashes."""
184
+ try:
185
+ from sklearn.preprocessing import OneHotEncoder # type: ignore
186
+ except Exception:
187
+ OneHotEncoder = None
188
+
189
+ if OneHotEncoder is None:
190
+ return
191
+
192
+ for node in _iter_estimators(model):
193
+ if isinstance(node, OneHotEncoder) and hasattr(node, "categories_"):
194
+ new_cats = []
195
+ for cats in node.categories_:
196
+ arr = np.asarray(cats, dtype=object)
197
+ if all(_numeric_like(v) for v in arr):
198
+ vals = []
199
+ for v in arr:
200
+ try:
201
+ vals.append(np.nan if v is None else float(v))
202
+ except Exception:
203
+ vals.append(np.nan)
204
+ new_cats.append(np.asarray(vals, dtype=float))
205
+ else:
206
+ strs = ["Unknown" if (v is None or (isinstance(v, float) and np.isnan(v))) else str(v) for v in arr]
207
+ new_cats.append(np.asarray(strs, dtype=object))
208
+ node.categories_ = new_cats
209
+ if hasattr(node, "handle_unknown"):
210
+ node.handle_unknown = "ignore"
211
+
212
+
213
+ def _patch_check_unknown():
214
+ """Patch sklearn _check_unknown to avoid np.isnan on object arrays (older builds)."""
215
+ try:
216
+ from sklearn.utils import _encode # type: ignore
217
+ _orig = _encode._check_unknown
218
+
219
+ def _safe_check_unknown(values, known_values, return_mask=False):
220
+ try:
221
+ return _orig(values, known_values, return_mask=return_mask)
222
+ except TypeError:
223
+ vals = np.asarray(values, dtype=object)
224
+ known = np.asarray(known_values, dtype=object)
225
+ mask = np.isin(vals, known, assume_unique=False)
226
+ diff = vals[~mask]
227
+ if return_mask:
228
+ return diff, mask
229
+ return diff
230
+
231
+ _encode._check_unknown = _safe_check_unknown # type: ignore[attr-defined]
232
+ print("[handler] Patched sklearn.utils._encode._check_unknown", flush=True)
233
+ except Exception as e:
234
+ print(f"[handler] Patch for _check_unknown not applied: {e}", flush=True)
235
+
236
+
237
+ # =========================
238
+ # Model introspection (debug)
239
+ # =========================
240
+ def _introspect_model(model) -> Dict[str, Any]:
241
+ info: Dict[str, Any] = {"type": str(type(model))}
242
+ try:
243
+ if hasattr(model, "named_steps"):
244
+ info["pipeline_steps"] = list(model.named_steps.keys())
245
+ for name, step in model.named_steps.items():
246
+ if step.__class__.__name__ == "ColumnTransformer":
247
+ info["column_transformer"] = str(step)
248
+ try:
249
+ info["transformers_"] = [(n, str(t.__class__), cols) for (n, t, cols) in step.transformers]
250
+ except Exception:
251
+ pass
252
+ except Exception:
253
+ pass
254
+ try:
255
+ info["feature_names_in_"] = list(getattr(model, "feature_names_in_", []))
256
+ except Exception:
257
+ pass
258
+ return info
259
+
260
+
261
+ # =========================
262
+ # Handler
263
+ # =========================
264
+ class EndpointHandler:
265
+ def __init__(self, path: str = "/repository") -> None:
266
+ _patch_check_unknown() # apply safety patch early
267
+
268
+ model_path = os.path.join(path, "model.joblib")
269
+ self.model = joblib.load(model_path)
270
+
271
+ try:
272
+ self.threshold = float(os.getenv("THRESHOLD", "0.38"))
273
+ except Exception:
274
+ self.threshold = 0.38
275
+
276
+ self.explainer = getattr(self.model, "explainer_", None)
277
+
278
+ _sanitize_onehot_categories(self.model)
279
+
280
+ print("[handler] Model loaded", flush=True)
281
+ print(f"[handler] Using threshold: {self.threshold}", flush=True)
282
+
283
+ def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
284
+ debug = bool(data.get("debug", False))
285
+ explain = bool(data.get("explain", False))
286
+
287
+ rows = data.get("inputs") or []
288
+ if isinstance(rows, dict):
289
+ rows = [rows]
290
+ if not isinstance(rows, list) or not rows:
291
+ return {"error": "inputs must be a non-empty list of records", "threshold": self.threshold}
292
+
293
+ df = _coerce_dataframe(rows)
294
+
295
+ debug_info = {
296
+ "columns": list(df.columns),
297
+ "dtypes": {c: str(df[c].dtype) for c in df.columns},
298
+ "threshold": self.threshold,
299
+ "model": _introspect_model(self.model),
300
+ "head": df.head(1).to_dict(orient="records"),
301
+ }
302
+
303
+ # Predict
304
+ try:
305
+ if hasattr(self.model, "predict_proba"):
306
+ proba = self.model.predict_proba(df)[:, 1].astype(float)
307
+ else:
308
+ raw = self.model.predict(df).astype(float)
309
+ proba = 1.0 / (1.0 + np.exp(-raw))
310
+ except Exception as e:
311
+ return {
312
+ "error": f"model.predict failed: {e}",
313
+ "trace": traceback.format_exc(),
314
+ "debug": debug_info,
315
+ "threshold": self.threshold,
316
+ }
317
+
318
+ p = float(proba[0])
319
+ label = int(p >= self.threshold)
320
+
321
+ resp: Dict[str, Any] = {
322
+ "risk_probability": p,
323
+ "risk_label": label,
324
+ "threshold": self.threshold,
325
+ }
326
+
327
+ if explain:
328
+ if hasattr(self.model, "top_contrib"):
329
+ try:
330
+ names, vals = self.model.top_contrib(df, k=5)
331
+ if names:
332
+ resp["shap"] = {"feature_names": names, "values": vals}
333
+ except Exception as e:
334
+ resp["shap_error"] = f"top_contrib failed: {e}"
335
+ elif self.explainer is not None:
336
+ try:
337
+ shap_vals = self.explainer(df)
338
+ vals = shap_vals.values[0] if hasattr(shap_vals, "values") else shap_vals[0]
339
+ contrib = []
340
+ for feat in EXPLAIN_ORDER:
341
+ if feat in df.columns:
342
+ idx = list(df.columns).index(feat)
343
+ contrib.append({"feature": feat, "effect": float(vals[idx])})
344
+ resp["shap"] = {"contrib": contrib}
345
+ except Exception as e:
346
+ resp["shap_error"] = f"explainer failed: {e}"
347
+
348
+ if debug:
349
+ resp["debug"] = debug_info
350
+
351
+ try:
352
+ print(f"[handler] prob={p:.4f} label={label}", flush=True)
353
+ except Exception:
354
+ pass
355
+
356
+ return resp