ins-pricing 0.4.5__py3-none-any.whl → 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. ins_pricing/README.md +48 -22
  2. ins_pricing/__init__.py +142 -90
  3. ins_pricing/cli/BayesOpt_entry.py +58 -46
  4. ins_pricing/cli/BayesOpt_incremental.py +77 -110
  5. ins_pricing/cli/Explain_Run.py +42 -23
  6. ins_pricing/cli/Explain_entry.py +551 -577
  7. ins_pricing/cli/Pricing_Run.py +42 -23
  8. ins_pricing/cli/bayesopt_entry_runner.py +51 -16
  9. ins_pricing/cli/utils/bootstrap.py +23 -0
  10. ins_pricing/cli/utils/cli_common.py +256 -256
  11. ins_pricing/cli/utils/cli_config.py +379 -360
  12. ins_pricing/cli/utils/import_resolver.py +375 -358
  13. ins_pricing/cli/utils/notebook_utils.py +256 -242
  14. ins_pricing/cli/watchdog_run.py +216 -198
  15. ins_pricing/frontend/__init__.py +10 -10
  16. ins_pricing/frontend/app.py +132 -61
  17. ins_pricing/frontend/config_builder.py +33 -0
  18. ins_pricing/frontend/example_config.json +11 -0
  19. ins_pricing/frontend/example_workflows.py +1 -1
  20. ins_pricing/frontend/runner.py +340 -388
  21. ins_pricing/governance/__init__.py +20 -20
  22. ins_pricing/governance/release.py +159 -159
  23. ins_pricing/modelling/README.md +1 -1
  24. ins_pricing/modelling/__init__.py +147 -92
  25. ins_pricing/modelling/{core/bayesopt → bayesopt}/README.md +31 -13
  26. ins_pricing/modelling/{core/bayesopt → bayesopt}/__init__.py +64 -102
  27. ins_pricing/modelling/{core/bayesopt → bayesopt}/config_components.py +12 -0
  28. ins_pricing/modelling/{core/bayesopt → bayesopt}/config_preprocess.py +589 -552
  29. ins_pricing/modelling/{core/bayesopt → bayesopt}/core.py +987 -958
  30. ins_pricing/modelling/{core/bayesopt → bayesopt}/model_explain_mixin.py +296 -296
  31. ins_pricing/modelling/{core/bayesopt → bayesopt}/model_plotting_mixin.py +488 -548
  32. ins_pricing/modelling/{core/bayesopt → bayesopt}/models/__init__.py +27 -27
  33. ins_pricing/modelling/{core/bayesopt → bayesopt}/models/model_ft_components.py +349 -342
  34. ins_pricing/modelling/{core/bayesopt → bayesopt}/models/model_ft_trainer.py +921 -913
  35. ins_pricing/modelling/{core/bayesopt → bayesopt}/models/model_gnn.py +794 -785
  36. ins_pricing/modelling/{core/bayesopt → bayesopt}/models/model_resn.py +454 -446
  37. ins_pricing/modelling/bayesopt/trainers/__init__.py +19 -0
  38. ins_pricing/modelling/{core/bayesopt → bayesopt}/trainers/trainer_base.py +1294 -1282
  39. ins_pricing/modelling/{core/bayesopt → bayesopt}/trainers/trainer_ft.py +64 -56
  40. ins_pricing/modelling/{core/bayesopt → bayesopt}/trainers/trainer_glm.py +203 -198
  41. ins_pricing/modelling/{core/bayesopt → bayesopt}/trainers/trainer_gnn.py +333 -325
  42. ins_pricing/modelling/{core/bayesopt → bayesopt}/trainers/trainer_resn.py +279 -267
  43. ins_pricing/modelling/{core/bayesopt → bayesopt}/trainers/trainer_xgb.py +515 -313
  44. ins_pricing/modelling/bayesopt/utils/__init__.py +67 -0
  45. ins_pricing/modelling/bayesopt/utils/constants.py +21 -0
  46. ins_pricing/modelling/{core/bayesopt → bayesopt}/utils/distributed_utils.py +193 -186
  47. ins_pricing/modelling/bayesopt/utils/io_utils.py +7 -0
  48. ins_pricing/modelling/bayesopt/utils/losses.py +27 -0
  49. ins_pricing/modelling/bayesopt/utils/metrics_and_devices.py +17 -0
  50. ins_pricing/modelling/{core/bayesopt → bayesopt}/utils/torch_trainer_mixin.py +636 -623
  51. ins_pricing/modelling/{core/evaluation.py → evaluation.py} +113 -104
  52. ins_pricing/modelling/explain/__init__.py +55 -55
  53. ins_pricing/modelling/explain/metrics.py +27 -174
  54. ins_pricing/modelling/explain/permutation.py +237 -237
  55. ins_pricing/modelling/plotting/__init__.py +40 -36
  56. ins_pricing/modelling/plotting/compat.py +228 -0
  57. ins_pricing/modelling/plotting/curves.py +572 -572
  58. ins_pricing/modelling/plotting/diagnostics.py +163 -163
  59. ins_pricing/modelling/plotting/geo.py +362 -362
  60. ins_pricing/modelling/plotting/importance.py +121 -121
  61. ins_pricing/pricing/__init__.py +27 -27
  62. ins_pricing/pricing/factors.py +67 -56
  63. ins_pricing/production/__init__.py +35 -25
  64. ins_pricing/production/{predict.py → inference.py} +140 -57
  65. ins_pricing/production/monitoring.py +8 -21
  66. ins_pricing/reporting/__init__.py +11 -11
  67. ins_pricing/setup.py +1 -1
  68. ins_pricing/tests/production/test_inference.py +90 -0
  69. ins_pricing/utils/__init__.py +112 -78
  70. ins_pricing/utils/device.py +258 -237
  71. ins_pricing/utils/features.py +53 -0
  72. ins_pricing/utils/io.py +72 -0
  73. ins_pricing/utils/logging.py +34 -1
  74. ins_pricing/{modelling/core/bayesopt/utils → utils}/losses.py +125 -129
  75. ins_pricing/utils/metrics.py +158 -24
  76. ins_pricing/utils/numerics.py +76 -0
  77. ins_pricing/utils/paths.py +9 -1
  78. ins_pricing/utils/profiling.py +8 -4
  79. {ins_pricing-0.4.5.dist-info → ins_pricing-0.5.1.dist-info}/METADATA +1 -1
  80. ins_pricing-0.5.1.dist-info/RECORD +132 -0
  81. ins_pricing/modelling/core/BayesOpt.py +0 -146
  82. ins_pricing/modelling/core/__init__.py +0 -1
  83. ins_pricing/modelling/core/bayesopt/trainers/__init__.py +0 -19
  84. ins_pricing/modelling/core/bayesopt/utils/__init__.py +0 -86
  85. ins_pricing/modelling/core/bayesopt/utils/constants.py +0 -183
  86. ins_pricing/modelling/core/bayesopt/utils/io_utils.py +0 -126
  87. ins_pricing/modelling/core/bayesopt/utils/metrics_and_devices.py +0 -555
  88. ins_pricing/modelling/core/bayesopt/utils.py +0 -105
  89. ins_pricing/modelling/core/bayesopt/utils_backup.py +0 -1503
  90. ins_pricing/tests/production/test_predict.py +0 -233
  91. ins_pricing-0.4.5.dist-info/RECORD +0 -130
  92. {ins_pricing-0.4.5.dist-info → ins_pricing-0.5.1.dist-info}/WHEEL +0 -0
  93. {ins_pricing-0.4.5.dist-info → ins_pricing-0.5.1.dist-info}/top_level.txt +0 -0
@@ -1,548 +1,488 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- from typing import List, Optional
5
-
6
- try: # matplotlib is optional; avoid hard import failures in headless/minimal envs
7
- import matplotlib
8
- if os.name != "nt" and not os.environ.get("DISPLAY") and not os.environ.get("MPLBACKEND"):
9
- matplotlib.use("Agg")
10
- import matplotlib.pyplot as plt
11
- _MPL_IMPORT_ERROR: Optional[BaseException] = None
12
- except Exception as exc: # pragma: no cover - optional dependency
13
- plt = None # type: ignore[assignment]
14
- _MPL_IMPORT_ERROR = exc
15
-
16
- import numpy as np
17
- import pandas as pd
18
-
19
- from .utils import EPS, PlotUtils
20
-
21
- try:
22
- from ...plotting import curves as plot_curves
23
- from ...plotting import diagnostics as plot_diagnostics
24
- from ...plotting.common import PlotStyle, finalize_figure
25
- except Exception: # pragma: no cover - optional for legacy imports
26
- try: # best-effort for non-package imports
27
- from ins_pricing.plotting import curves as plot_curves
28
- from ins_pricing.plotting import diagnostics as plot_diagnostics
29
- from ins_pricing.plotting.common import PlotStyle, finalize_figure
30
- except Exception: # pragma: no cover
31
- plot_curves = None
32
- plot_diagnostics = None
33
- PlotStyle = None
34
- finalize_figure = None
35
-
36
-
37
- def _plot_skip(label: str) -> None:
38
- if _MPL_IMPORT_ERROR is not None:
39
- print(f"[Plot] Skip {label}: matplotlib unavailable ({_MPL_IMPORT_ERROR}).", flush=True)
40
- else:
41
- print(f"[Plot] Skip {label}: matplotlib unavailable.", flush=True)
42
-
43
-
44
- class BayesOptPlottingMixin:
45
- def plot_oneway(
46
- self,
47
- n_bins=10,
48
- pred_col: Optional[str] = None,
49
- pred_label: Optional[str] = None,
50
- pred_weighted: Optional[bool] = None,
51
- plot_subdir: Optional[str] = None,
52
- ):
53
- if plt is None and plot_diagnostics is None:
54
- _plot_skip("oneway plot")
55
- return
56
- if pred_col is not None and pred_col not in self.train_data.columns:
57
- print(
58
- f"[Oneway] Missing prediction column '{pred_col}'; skip predicted line.",
59
- flush=True,
60
- )
61
- pred_col = None
62
- if pred_weighted is None and pred_col is not None:
63
- pred_weighted = pred_col.startswith("w_pred_")
64
- if pred_weighted is None:
65
- pred_weighted = False
66
- plot_subdir = plot_subdir.strip("/\\") if plot_subdir else "oneway"
67
- plot_prefix = f"{self.model_nme}/{plot_subdir}"
68
-
69
- def _safe_tag(value: str) -> str:
70
- return (
71
- value.strip()
72
- .replace(" ", "_")
73
- .replace("/", "_")
74
- .replace("\\", "_")
75
- .replace(":", "_")
76
- )
77
-
78
- if plot_diagnostics is None:
79
- for c in self.factor_nmes:
80
- fig = plt.figure(figsize=(7, 5))
81
- if c in self.cate_list:
82
- group_col = c
83
- plot_source = self.train_data
84
- else:
85
- group_col = f'{c}_bins'
86
- bins = pd.qcut(
87
- self.train_data[c],
88
- n_bins,
89
- duplicates='drop' # Drop duplicate quantiles to avoid errors.
90
- )
91
- plot_source = self.train_data.assign(**{group_col: bins})
92
- if pred_col is not None and pred_col in plot_source.columns:
93
- if pred_weighted:
94
- plot_source = plot_source.assign(
95
- _pred_w=plot_source[pred_col]
96
- )
97
- else:
98
- plot_source = plot_source.assign(
99
- _pred_w=plot_source[pred_col] * plot_source[self.weight_nme]
100
- )
101
- plot_data = plot_source.groupby(
102
- [group_col], observed=True).sum(numeric_only=True)
103
- plot_data.reset_index(inplace=True)
104
- plot_data['act_v'] = plot_data['w_act'] / \
105
- plot_data[self.weight_nme]
106
- if pred_col is not None and "_pred_w" in plot_data.columns:
107
- plot_data["pred_v"] = plot_data["_pred_w"] / plot_data[self.weight_nme]
108
- ax = fig.add_subplot(111)
109
- ax.plot(plot_data.index, plot_data['act_v'],
110
- label='Actual', color='red')
111
- if pred_col is not None and "pred_v" in plot_data.columns:
112
- ax.plot(
113
- plot_data.index,
114
- plot_data["pred_v"],
115
- label=pred_label or "Predicted",
116
- color="tab:blue",
117
- )
118
- ax.set_title(
119
- 'Analysis of %s : Train Data' % group_col,
120
- fontsize=8)
121
- plt.xticks(plot_data.index,
122
- list(plot_data[group_col].astype(str)),
123
- rotation=90)
124
- if len(list(plot_data[group_col].astype(str))) > 50:
125
- plt.xticks(fontsize=3)
126
- else:
127
- plt.xticks(fontsize=6)
128
- plt.yticks(fontsize=6)
129
- ax2 = ax.twinx()
130
- ax2.bar(plot_data.index,
131
- plot_data[self.weight_nme],
132
- alpha=0.5, color='seagreen')
133
- plt.yticks(fontsize=6)
134
- plt.margins(0.05)
135
- plt.subplots_adjust(wspace=0.3)
136
- if pred_col is not None and "pred_v" in plot_data.columns:
137
- ax.legend(fontsize=6)
138
- pred_tag = _safe_tag(pred_label or pred_col) if pred_col else None
139
- if pred_tag:
140
- filename = f'00_{self.model_nme}_{group_col}_oneway_{pred_tag}.png'
141
- else:
142
- filename = f'00_{self.model_nme}_{group_col}_oneway.png'
143
- save_path = self._resolve_plot_path(plot_prefix, filename)
144
- plt.savefig(save_path, dpi=300)
145
- plt.close(fig)
146
- return
147
-
148
- if "w_act" not in self.train_data.columns:
149
- print("[Oneway] Missing w_act column; skip plotting.", flush=True)
150
- return
151
-
152
- for c in self.factor_nmes:
153
- is_cat = c in (self.cate_list or [])
154
- group_col = c if is_cat else f"{c}_bins"
155
- title = f"Analysis of {group_col} : Train Data"
156
- pred_tag = _safe_tag(pred_label or pred_col) if pred_col else None
157
- if pred_tag:
158
- filename = f"00_{self.model_nme}_{group_col}_oneway_{pred_tag}.png"
159
- else:
160
- filename = f"00_{self.model_nme}_{group_col}_oneway.png"
161
- save_path = self._resolve_plot_path(plot_prefix, filename)
162
- plot_diagnostics.plot_oneway(
163
- self.train_data,
164
- feature=c,
165
- weight_col=self.weight_nme,
166
- target_col="w_act",
167
- pred_col=pred_col,
168
- pred_weighted=pred_weighted,
169
- pred_label=pred_label,
170
- n_bins=n_bins,
171
- is_categorical=is_cat,
172
- title=title,
173
- save_path=save_path,
174
- show=False,
175
- )
176
-
177
-
178
- def _resolve_plot_path(self, subdir: Optional[str], filename: str) -> str:
179
- style = str(getattr(self.config, "plot_path_style", "nested") or "nested").strip().lower()
180
- if style in {"flat", "root"}:
181
- return self.output_manager.plot_path(filename)
182
- if subdir:
183
- return self.output_manager.plot_path(f"{subdir}/{filename}")
184
- return self.output_manager.plot_path(filename)
185
-
186
-
187
- def plot_lift(self, model_label, pred_nme, n_bins=10):
188
- if plt is None:
189
- _plot_skip("lift plot")
190
- return
191
- model_map = {
192
- 'Xgboost': 'pred_xgb',
193
- 'ResNet': 'pred_resn',
194
- 'ResNetClassifier': 'pred_resn',
195
- 'GLM': 'pred_glm',
196
- 'GNN': 'pred_gnn',
197
- }
198
- if str(self.config.ft_role) == "model":
199
- model_map.update({
200
- 'FTTransformer': 'pred_ft',
201
- 'FTTransformerClassifier': 'pred_ft',
202
- })
203
- for k, v in model_map.items():
204
- if model_label.startswith(k):
205
- pred_nme = v
206
- break
207
- safe_label = (
208
- str(model_label)
209
- .replace(" ", "_")
210
- .replace("/", "_")
211
- .replace("\\", "_")
212
- .replace(":", "_")
213
- )
214
- plot_prefix = f"{self.model_nme}/lift"
215
- filename = f"01_{self.model_nme}_{safe_label}_lift.png"
216
-
217
- datasets = []
218
- for title, data in [
219
- ('Lift Chart on Train Data', self.train_data),
220
- ('Lift Chart on Test Data', self.test_data),
221
- ]:
222
- if 'w_act' not in data.columns or data['w_act'].isna().all():
223
- print(
224
- f"[Lift] Missing labels for {title}; skip.",
225
- flush=True,
226
- )
227
- continue
228
- datasets.append((title, data))
229
-
230
- if not datasets:
231
- print("[Lift] No labeled data available; skip plotting.", flush=True)
232
- return
233
-
234
- if plot_curves is None:
235
- fig = plt.figure(figsize=(11, 5))
236
- positions = [111] if len(datasets) == 1 else [121, 122]
237
- for pos, (title, data) in zip(positions, datasets):
238
- if pred_nme not in data.columns or f'w_{pred_nme}' not in data.columns:
239
- print(
240
- f"[Lift] Missing prediction columns in {title}; skip.",
241
- flush=True,
242
- )
243
- continue
244
- lift_df = pd.DataFrame({
245
- 'pred': data[pred_nme].values,
246
- 'w_pred': data[f'w_{pred_nme}'].values,
247
- 'act': data['w_act'].values,
248
- 'weight': data[self.weight_nme].values
249
- })
250
- plot_data = PlotUtils.split_data(lift_df, 'pred', 'weight', n_bins)
251
- denom = np.maximum(plot_data['weight'], EPS)
252
- plot_data['exp_v'] = plot_data['w_pred'] / denom
253
- plot_data['act_v'] = plot_data['act'] / denom
254
- plot_data = plot_data.reset_index()
255
-
256
- ax = fig.add_subplot(pos)
257
- PlotUtils.plot_lift_ax(ax, plot_data, title)
258
-
259
- plt.subplots_adjust(wspace=0.3)
260
- save_path = self._resolve_plot_path(plot_prefix, filename)
261
- plt.savefig(save_path, dpi=300)
262
- plt.show()
263
- plt.close(fig)
264
- return
265
-
266
- style = PlotStyle() if PlotStyle else None
267
- fig, axes = plt.subplots(1, len(datasets), figsize=(11, 5))
268
- if len(datasets) == 1:
269
- axes = [axes]
270
-
271
- for ax, (title, data) in zip(axes, datasets):
272
- pred_vals = None
273
- if pred_nme in data.columns:
274
- pred_vals = data[pred_nme].values
275
- else:
276
- w_pred_col = f"w_{pred_nme}"
277
- if w_pred_col in data.columns:
278
- denom = np.maximum(data[self.weight_nme].values, EPS)
279
- pred_vals = data[w_pred_col].values / denom
280
- if pred_vals is None:
281
- print(
282
- f"[Lift] Missing prediction columns in {title}; skip.",
283
- flush=True,
284
- )
285
- continue
286
-
287
- plot_curves.plot_lift_curve(
288
- pred_vals,
289
- data['w_act'].values,
290
- data[self.weight_nme].values,
291
- n_bins=n_bins,
292
- title=title,
293
- pred_label="Predicted",
294
- act_label="Actual",
295
- weight_label="Earned Exposure",
296
- pred_weighted=False,
297
- actual_weighted=True,
298
- ax=ax,
299
- show=False,
300
- style=style,
301
- )
302
-
303
- plt.subplots_adjust(wspace=0.3)
304
- save_path = self._resolve_plot_path(plot_prefix, filename)
305
- if finalize_figure:
306
- finalize_figure(fig, save_path=save_path, show=True, style=style)
307
- else:
308
- plt.savefig(save_path, dpi=300)
309
- plt.show()
310
- plt.close(fig)
311
-
312
- # Double lift curve plot.
313
-
314
- def plot_dlift(self, model_comp: List[str] = ['xgb', 'resn'], n_bins: int = 10) -> None:
315
- # Compare two models across bins.
316
- # Args:
317
- # model_comp: model keys to compare (e.g., ['xgb', 'resn']).
318
- # n_bins: number of bins for lift curves.
319
- if plt is None:
320
- _plot_skip("double lift plot")
321
- return
322
- if len(model_comp) != 2:
323
- raise ValueError("`model_comp` must contain two models to compare.")
324
-
325
- model_name_map = {
326
- 'xgb': 'Xgboost',
327
- 'resn': 'ResNet',
328
- 'glm': 'GLM',
329
- 'gnn': 'GNN',
330
- }
331
- if str(self.config.ft_role) == "model":
332
- model_name_map['ft'] = 'FTTransformer'
333
-
334
- name1, name2 = model_comp
335
- if name1 not in model_name_map or name2 not in model_name_map:
336
- raise ValueError(f"Unsupported model key. Choose from {list(model_name_map.keys())}.")
337
- plot_prefix = f"{self.model_nme}/double_lift"
338
- filename = f"02_{self.model_nme}_dlift_{name1}_vs_{name2}.png"
339
-
340
- datasets = []
341
- for data_name, data in [('Train Data', self.train_data),
342
- ('Test Data', self.test_data)]:
343
- if 'w_act' not in data.columns or data['w_act'].isna().all():
344
- print(
345
- f"[Double Lift] Missing labels for {data_name}; skip.",
346
- flush=True,
347
- )
348
- continue
349
- datasets.append((data_name, data))
350
-
351
- if not datasets:
352
- print("[Double Lift] No labeled data available; skip plotting.", flush=True)
353
- return
354
-
355
- if plot_curves is None:
356
- fig, axes = plt.subplots(1, len(datasets), figsize=(11, 5))
357
- if len(datasets) == 1:
358
- axes = [axes]
359
-
360
- for ax, (data_name, data) in zip(axes, datasets):
361
- pred1_col = f'w_pred_{name1}'
362
- pred2_col = f'w_pred_{name2}'
363
-
364
- if pred1_col not in data.columns or pred2_col not in data.columns:
365
- print(
366
- f"Warning: missing prediction columns {pred1_col} or {pred2_col} in {data_name}. Skip plot.")
367
- continue
368
-
369
- lift_data = pd.DataFrame({
370
- 'pred1': data[pred1_col].values,
371
- 'pred2': data[pred2_col].values,
372
- 'diff_ly': data[pred1_col].values / np.maximum(data[pred2_col].values, EPS),
373
- 'act': data['w_act'].values,
374
- 'weight': data[self.weight_nme].values
375
- })
376
- plot_data = PlotUtils.split_data(
377
- lift_data, 'diff_ly', 'weight', n_bins)
378
- denom = np.maximum(plot_data['act'], EPS)
379
- plot_data['exp_v1'] = plot_data['pred1'] / denom
380
- plot_data['exp_v2'] = plot_data['pred2'] / denom
381
- plot_data['act_v'] = plot_data['act'] / denom
382
- plot_data.reset_index(inplace=True)
383
-
384
- label1 = model_name_map[name1]
385
- label2 = model_name_map[name2]
386
-
387
- PlotUtils.plot_dlift_ax(
388
- ax, plot_data, f'Double Lift Chart on {data_name}', label1, label2)
389
-
390
- plt.subplots_adjust(bottom=0.25, top=0.95, right=0.8, wspace=0.3)
391
- save_path = self._resolve_plot_path(plot_prefix, filename)
392
- plt.savefig(save_path, dpi=300)
393
- plt.show()
394
- plt.close(fig)
395
- return
396
-
397
- style = PlotStyle() if PlotStyle else None
398
- fig, axes = plt.subplots(1, len(datasets), figsize=(11, 5))
399
- if len(datasets) == 1:
400
- axes = [axes]
401
-
402
- label1 = model_name_map[name1]
403
- label2 = model_name_map[name2]
404
-
405
- for ax, (data_name, data) in zip(axes, datasets):
406
- weight_vals = data[self.weight_nme].values
407
- pred1 = None
408
- pred2 = None
409
-
410
- pred1_col = f"pred_{name1}"
411
- pred2_col = f"pred_{name2}"
412
- if pred1_col in data.columns:
413
- pred1 = data[pred1_col].values
414
- else:
415
- w_pred1_col = f"w_pred_{name1}"
416
- if w_pred1_col in data.columns:
417
- pred1 = data[w_pred1_col].values / np.maximum(weight_vals, EPS)
418
-
419
- if pred2_col in data.columns:
420
- pred2 = data[pred2_col].values
421
- else:
422
- w_pred2_col = f"w_pred_{name2}"
423
- if w_pred2_col in data.columns:
424
- pred2 = data[w_pred2_col].values / np.maximum(weight_vals, EPS)
425
-
426
- if pred1 is None or pred2 is None:
427
- print(
428
- f"Warning: missing pred_{name1}/pred_{name2} or w_pred columns in {data_name}. Skip plot.")
429
- continue
430
-
431
- plot_curves.plot_double_lift_curve(
432
- pred1,
433
- pred2,
434
- data['w_act'].values,
435
- weight_vals,
436
- n_bins=n_bins,
437
- title=f"Double Lift Chart on {data_name}",
438
- label1=label1,
439
- label2=label2,
440
- pred1_weighted=False,
441
- pred2_weighted=False,
442
- actual_weighted=True,
443
- ax=ax,
444
- show=False,
445
- style=style,
446
- )
447
-
448
- plt.subplots_adjust(bottom=0.25, top=0.95, right=0.8, wspace=0.3)
449
- save_path = self._resolve_plot_path(plot_prefix, filename)
450
- if finalize_figure:
451
- finalize_figure(fig, save_path=save_path, show=True, style=style)
452
- else:
453
- plt.savefig(save_path, dpi=300)
454
- plt.show()
455
- plt.close(fig)
456
-
457
- # Conversion lift curve plot.
458
-
459
- def plot_conversion_lift(self, model_pred_col: str, n_bins: int = 20):
460
- if plt is None:
461
- _plot_skip("conversion lift plot")
462
- return
463
- if not self.binary_resp_nme:
464
- print("Error: `binary_resp_nme` not provided at BayesOptModel init; cannot plot conversion lift.")
465
- return
466
-
467
- if plot_curves is None:
468
- fig, axes = plt.subplots(1, 2, figsize=(14, 6), sharey=True)
469
- datasets = {
470
- 'Train Data': self.train_data,
471
- 'Test Data': self.test_data
472
- }
473
-
474
- for ax, (data_name, data) in zip(axes, datasets.items()):
475
- if model_pred_col not in data.columns:
476
- print(f"Warning: missing prediction column '{model_pred_col}' in {data_name}. Skip plot.")
477
- continue
478
-
479
- # Sort by model prediction and compute bins.
480
- plot_data = data.sort_values(by=model_pred_col).copy()
481
- plot_data['cum_weight'] = plot_data[self.weight_nme].cumsum()
482
- total_weight = plot_data[self.weight_nme].sum()
483
-
484
- if total_weight > EPS:
485
- plot_data['bin'] = pd.cut(
486
- plot_data['cum_weight'],
487
- bins=n_bins,
488
- labels=False,
489
- right=False
490
- )
491
- else:
492
- plot_data['bin'] = 0
493
-
494
- # Aggregate by bins.
495
- lift_agg = plot_data.groupby('bin').agg(
496
- total_weight=(self.weight_nme, 'sum'),
497
- actual_conversions=(self.binary_resp_nme, 'sum'),
498
- weighted_conversions=('w_binary_act', 'sum'),
499
- avg_pred=(model_pred_col, 'mean')
500
- ).reset_index()
501
-
502
- # Compute conversion rate.
503
- lift_agg['conversion_rate'] = lift_agg['weighted_conversions'] / \
504
- lift_agg['total_weight']
505
-
506
- # Compute overall average conversion rate.
507
- overall_conversion_rate = data['w_binary_act'].sum(
508
- ) / data[self.weight_nme].sum()
509
- ax.axhline(y=overall_conversion_rate, color='gray', linestyle='--',
510
- label=f'Overall Avg Rate ({overall_conversion_rate:.2%})')
511
-
512
- ax.plot(lift_agg['bin'], lift_agg['conversion_rate'],
513
- marker='o', linestyle='-', label='Actual Conversion Rate')
514
- ax.set_title(f'Conversion Rate Lift Chart on {data_name}')
515
- ax.set_xlabel(f'Model Score Decile (based on {model_pred_col})')
516
- ax.set_ylabel('Conversion Rate')
517
- ax.grid(True, linestyle='--', alpha=0.6)
518
- ax.legend()
519
-
520
- plt.tight_layout()
521
- plt.show()
522
- return
523
-
524
- fig, axes = plt.subplots(1, 2, figsize=(14, 6), sharey=True)
525
- datasets = {
526
- 'Train Data': self.train_data,
527
- 'Test Data': self.test_data
528
- }
529
-
530
- for ax, (data_name, data) in zip(axes, datasets.items()):
531
- if model_pred_col not in data.columns:
532
- print(f"Warning: missing prediction column '{model_pred_col}' in {data_name}. Skip plot.")
533
- continue
534
-
535
- plot_curves.plot_conversion_lift(
536
- data[model_pred_col].values,
537
- data[self.binary_resp_nme].values,
538
- data[self.weight_nme].values,
539
- n_bins=n_bins,
540
- title=f'Conversion Rate Lift Chart on {data_name}',
541
- ax=ax,
542
- show=False,
543
- )
544
-
545
- plt.tight_layout()
546
- plt.show()
547
-
548
- # ========= Lightweight explainability: Permutation Importance =========
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import List, Optional
5
+
6
+ try: # matplotlib is optional; avoid hard import failures in headless/minimal envs
7
+ import matplotlib
8
+ if os.name != "nt" and not os.environ.get("DISPLAY") and not os.environ.get("MPLBACKEND"):
9
+ matplotlib.use("Agg")
10
+ import matplotlib.pyplot as plt
11
+ _MPL_IMPORT_ERROR: Optional[BaseException] = None
12
+ except Exception as exc: # pragma: no cover - optional dependency
13
+ plt = None # type: ignore[assignment]
14
+ _MPL_IMPORT_ERROR = exc
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from ins_pricing.utils import EPS, get_logger, log_print
20
+
21
+ _logger = get_logger("ins_pricing.modelling.bayesopt.model_plotting_mixin")
22
+
23
+
24
+ def _log(*args, **kwargs) -> None:
25
+ log_print(_logger, *args, **kwargs)
26
+
27
+ try:
28
+ from ins_pricing.modelling.plotting import curves as plot_curves
29
+ from ins_pricing.modelling.plotting import diagnostics as plot_diagnostics
30
+ from ins_pricing.modelling.plotting.common import PlotStyle, finalize_figure
31
+ except Exception: # pragma: no cover - optional for legacy imports
32
+ try: # best-effort for non-package imports
33
+ from ins_pricing.plotting import curves as plot_curves
34
+ from ins_pricing.plotting import diagnostics as plot_diagnostics
35
+ from ins_pricing.plotting.common import PlotStyle, finalize_figure
36
+ except Exception: # pragma: no cover
37
+ plot_curves = None
38
+ plot_diagnostics = None
39
+ PlotStyle = None
40
+ finalize_figure = None
41
+
42
+
43
+ def _plot_skip(label: str) -> None:
44
+ if _MPL_IMPORT_ERROR is not None:
45
+ _log(f"[Plot] Skip {label}: matplotlib unavailable ({_MPL_IMPORT_ERROR}).", flush=True)
46
+ else:
47
+ _log(f"[Plot] Skip {label}: matplotlib unavailable.", flush=True)
48
+
49
+
50
+ class BayesOptPlottingMixin:
51
+ def plot_oneway(
52
+ self,
53
+ n_bins=10,
54
+ pred_col: Optional[str] = None,
55
+ pred_label: Optional[str] = None,
56
+ pred_weighted: Optional[bool] = None,
57
+ plot_subdir: Optional[str] = None,
58
+ ):
59
+ if plt is None and plot_diagnostics is None:
60
+ _plot_skip("oneway plot")
61
+ return
62
+ if pred_col is not None and pred_col not in self.train_data.columns:
63
+ _log(
64
+ f"[Oneway] Missing prediction column '{pred_col}'; skip predicted line.",
65
+ flush=True,
66
+ )
67
+ pred_col = None
68
+ if pred_weighted is None and pred_col is not None:
69
+ pred_weighted = pred_col.startswith("w_pred_")
70
+ if pred_weighted is None:
71
+ pred_weighted = False
72
+ plot_subdir = plot_subdir.strip("/\\") if plot_subdir else "oneway"
73
+ plot_prefix = f"{self.model_nme}/{plot_subdir}"
74
+
75
+ def _safe_tag(value: str) -> str:
76
+ return (
77
+ value.strip()
78
+ .replace(" ", "_")
79
+ .replace("/", "_")
80
+ .replace("\\", "_")
81
+ .replace(":", "_")
82
+ )
83
+
84
+ if plot_diagnostics is None:
85
+ for c in self.factor_nmes:
86
+ fig = plt.figure(figsize=(7, 5))
87
+ if c in self.cate_list:
88
+ group_col = c
89
+ plot_source = self.train_data
90
+ else:
91
+ group_col = f'{c}_bins'
92
+ bins = pd.qcut(
93
+ self.train_data[c],
94
+ n_bins,
95
+ duplicates='drop' # Drop duplicate quantiles to avoid errors.
96
+ )
97
+ plot_source = self.train_data.assign(**{group_col: bins})
98
+ if pred_col is not None and pred_col in plot_source.columns:
99
+ if pred_weighted:
100
+ plot_source = plot_source.assign(
101
+ _pred_w=plot_source[pred_col]
102
+ )
103
+ else:
104
+ plot_source = plot_source.assign(
105
+ _pred_w=plot_source[pred_col] * plot_source[self.weight_nme]
106
+ )
107
+ plot_data = plot_source.groupby(
108
+ [group_col], observed=True).sum(numeric_only=True)
109
+ plot_data.reset_index(inplace=True)
110
+ plot_data['act_v'] = plot_data['w_act'] / \
111
+ plot_data[self.weight_nme]
112
+ if pred_col is not None and "_pred_w" in plot_data.columns:
113
+ plot_data["pred_v"] = plot_data["_pred_w"] / plot_data[self.weight_nme]
114
+ ax = fig.add_subplot(111)
115
+ ax.plot(plot_data.index, plot_data['act_v'],
116
+ label='Actual', color='red')
117
+ if pred_col is not None and "pred_v" in plot_data.columns:
118
+ ax.plot(
119
+ plot_data.index,
120
+ plot_data["pred_v"],
121
+ label=pred_label or "Predicted",
122
+ color="tab:blue",
123
+ )
124
+ ax.set_title(
125
+ 'Analysis of %s : Train Data' % group_col,
126
+ fontsize=8)
127
+ plt.xticks(plot_data.index,
128
+ list(plot_data[group_col].astype(str)),
129
+ rotation=90)
130
+ if len(list(plot_data[group_col].astype(str))) > 50:
131
+ plt.xticks(fontsize=3)
132
+ else:
133
+ plt.xticks(fontsize=6)
134
+ plt.yticks(fontsize=6)
135
+ ax2 = ax.twinx()
136
+ ax2.bar(plot_data.index,
137
+ plot_data[self.weight_nme],
138
+ alpha=0.5, color='seagreen')
139
+ plt.yticks(fontsize=6)
140
+ plt.margins(0.05)
141
+ plt.subplots_adjust(wspace=0.3)
142
+ if pred_col is not None and "pred_v" in plot_data.columns:
143
+ ax.legend(fontsize=6)
144
+ pred_tag = _safe_tag(pred_label or pred_col) if pred_col else None
145
+ if pred_tag:
146
+ filename = f'00_{self.model_nme}_{group_col}_oneway_{pred_tag}.png'
147
+ else:
148
+ filename = f'00_{self.model_nme}_{group_col}_oneway.png'
149
+ save_path = self._resolve_plot_path(plot_prefix, filename)
150
+ plt.savefig(save_path, dpi=300)
151
+ plt.close(fig)
152
+ return
153
+
154
+ if "w_act" not in self.train_data.columns:
155
+ _log("[Oneway] Missing w_act column; skip plotting.", flush=True)
156
+ return
157
+
158
+ for c in self.factor_nmes:
159
+ is_cat = c in (self.cate_list or [])
160
+ group_col = c if is_cat else f"{c}_bins"
161
+ title = f"Analysis of {group_col} : Train Data"
162
+ pred_tag = _safe_tag(pred_label or pred_col) if pred_col else None
163
+ if pred_tag:
164
+ filename = f"00_{self.model_nme}_{group_col}_oneway_{pred_tag}.png"
165
+ else:
166
+ filename = f"00_{self.model_nme}_{group_col}_oneway.png"
167
+ save_path = self._resolve_plot_path(plot_prefix, filename)
168
+ plot_diagnostics.plot_oneway(
169
+ self.train_data,
170
+ feature=c,
171
+ weight_col=self.weight_nme,
172
+ target_col="w_act",
173
+ pred_col=pred_col,
174
+ pred_weighted=pred_weighted,
175
+ pred_label=pred_label,
176
+ n_bins=n_bins,
177
+ is_categorical=is_cat,
178
+ title=title,
179
+ save_path=save_path,
180
+ show=False,
181
+ )
182
+
183
+
184
+ def _resolve_plot_path(self, subdir: Optional[str], filename: str) -> str:
185
+ style = str(getattr(self.config, "plot_path_style", "nested") or "nested").strip().lower()
186
+ if style in {"flat", "root"}:
187
+ return self.output_manager.plot_path(filename)
188
+ if subdir:
189
+ return self.output_manager.plot_path(f"{subdir}/{filename}")
190
+ return self.output_manager.plot_path(filename)
191
+
192
+
193
+ def plot_lift(self, model_label, pred_nme, n_bins=10):
194
+ if plt is None:
195
+ _plot_skip("lift plot")
196
+ return
197
+ model_map = {
198
+ 'Xgboost': 'pred_xgb',
199
+ 'ResNet': 'pred_resn',
200
+ 'ResNetClassifier': 'pred_resn',
201
+ 'GLM': 'pred_glm',
202
+ 'GNN': 'pred_gnn',
203
+ }
204
+ if str(self.config.ft_role) == "model":
205
+ model_map.update({
206
+ 'FTTransformer': 'pred_ft',
207
+ 'FTTransformerClassifier': 'pred_ft',
208
+ })
209
+ for k, v in model_map.items():
210
+ if model_label.startswith(k):
211
+ pred_nme = v
212
+ break
213
+ safe_label = (
214
+ str(model_label)
215
+ .replace(" ", "_")
216
+ .replace("/", "_")
217
+ .replace("\\", "_")
218
+ .replace(":", "_")
219
+ )
220
+ plot_prefix = f"{self.model_nme}/lift"
221
+ filename = f"01_{self.model_nme}_{safe_label}_lift.png"
222
+
223
+ datasets = []
224
+ for title, data in [
225
+ ('Lift Chart on Train Data', self.train_data),
226
+ ('Lift Chart on Test Data', self.test_data),
227
+ ]:
228
+ if 'w_act' not in data.columns or data['w_act'].isna().all():
229
+ _log(
230
+ f"[Lift] Missing labels for {title}; skip.",
231
+ flush=True,
232
+ )
233
+ continue
234
+ datasets.append((title, data))
235
+
236
+ if not datasets:
237
+ _log("[Lift] No labeled data available; skip plotting.", flush=True)
238
+ return
239
+
240
+ if plot_curves is None:
241
+ _plot_skip("lift plot")
242
+ return
243
+
244
+ style = PlotStyle() if PlotStyle else None
245
+ fig, axes = plt.subplots(1, len(datasets), figsize=(11, 5))
246
+ if len(datasets) == 1:
247
+ axes = [axes]
248
+
249
+ for ax, (title, data) in zip(axes, datasets):
250
+ pred_vals = None
251
+ if pred_nme in data.columns:
252
+ pred_vals = data[pred_nme].values
253
+ else:
254
+ w_pred_col = f"w_{pred_nme}"
255
+ if w_pred_col in data.columns:
256
+ denom = np.maximum(data[self.weight_nme].values, EPS)
257
+ pred_vals = data[w_pred_col].values / denom
258
+ if pred_vals is None:
259
+ _log(
260
+ f"[Lift] Missing prediction columns in {title}; skip.",
261
+ flush=True,
262
+ )
263
+ continue
264
+
265
+ plot_curves.plot_lift_curve(
266
+ pred_vals,
267
+ data['w_act'].values,
268
+ data[self.weight_nme].values,
269
+ n_bins=n_bins,
270
+ title=title,
271
+ pred_label="Predicted",
272
+ act_label="Actual",
273
+ weight_label="Earned Exposure",
274
+ pred_weighted=False,
275
+ actual_weighted=True,
276
+ ax=ax,
277
+ show=False,
278
+ style=style,
279
+ )
280
+
281
+ plt.subplots_adjust(wspace=0.3)
282
+ save_path = self._resolve_plot_path(plot_prefix, filename)
283
+ if finalize_figure:
284
+ finalize_figure(fig, save_path=save_path, show=True, style=style)
285
+ else:
286
+ plt.savefig(save_path, dpi=300)
287
+ plt.show()
288
+ plt.close(fig)
289
+
290
+ # Double lift curve plot.
291
+
292
+ def plot_dlift(self, model_comp: List[str] = ['xgb', 'resn'], n_bins: int = 10) -> None:
293
+ # Compare two models across bins.
294
+ # Args:
295
+ # model_comp: model keys to compare (e.g., ['xgb', 'resn']).
296
+ # n_bins: number of bins for lift curves.
297
+ if plt is None:
298
+ _plot_skip("double lift plot")
299
+ return
300
+ if len(model_comp) != 2:
301
+ raise ValueError("`model_comp` must contain two models to compare.")
302
+
303
+ model_name_map = {
304
+ 'xgb': 'Xgboost',
305
+ 'resn': 'ResNet',
306
+ 'glm': 'GLM',
307
+ 'gnn': 'GNN',
308
+ }
309
+ if str(self.config.ft_role) == "model":
310
+ model_name_map['ft'] = 'FTTransformer'
311
+
312
+ name1, name2 = model_comp
313
+ if name1 not in model_name_map or name2 not in model_name_map:
314
+ raise ValueError(f"Unsupported model key. Choose from {list(model_name_map.keys())}.")
315
+ plot_prefix = f"{self.model_nme}/double_lift"
316
+ filename = f"02_{self.model_nme}_dlift_{name1}_vs_{name2}.png"
317
+
318
+ datasets = []
319
+ for data_name, data in [('Train Data', self.train_data),
320
+ ('Test Data', self.test_data)]:
321
+ if 'w_act' not in data.columns or data['w_act'].isna().all():
322
+ _log(
323
+ f"[Double Lift] Missing labels for {data_name}; skip.",
324
+ flush=True,
325
+ )
326
+ continue
327
+ datasets.append((data_name, data))
328
+
329
+ if not datasets:
330
+ _log("[Double Lift] No labeled data available; skip plotting.", flush=True)
331
+ return
332
+
333
+ if plot_curves is None:
334
+ _plot_skip("double lift plot")
335
+ return
336
+
337
+ style = PlotStyle() if PlotStyle else None
338
+ fig, axes = plt.subplots(1, len(datasets), figsize=(11, 5))
339
+ if len(datasets) == 1:
340
+ axes = [axes]
341
+
342
+ label1 = model_name_map[name1]
343
+ label2 = model_name_map[name2]
344
+
345
+ for ax, (data_name, data) in zip(axes, datasets):
346
+ weight_vals = data[self.weight_nme].values
347
+ pred1 = None
348
+ pred2 = None
349
+
350
+ pred1_col = f"pred_{name1}"
351
+ pred2_col = f"pred_{name2}"
352
+ if pred1_col in data.columns:
353
+ pred1 = data[pred1_col].values
354
+ else:
355
+ w_pred1_col = f"w_pred_{name1}"
356
+ if w_pred1_col in data.columns:
357
+ pred1 = data[w_pred1_col].values / np.maximum(weight_vals, EPS)
358
+
359
+ if pred2_col in data.columns:
360
+ pred2 = data[pred2_col].values
361
+ else:
362
+ w_pred2_col = f"w_pred_{name2}"
363
+ if w_pred2_col in data.columns:
364
+ pred2 = data[w_pred2_col].values / np.maximum(weight_vals, EPS)
365
+
366
+ if pred1 is None or pred2 is None:
367
+ _log(
368
+ f"Warning: missing pred_{name1}/pred_{name2} or w_pred columns in {data_name}. Skip plot.")
369
+ continue
370
+
371
+ plot_curves.plot_double_lift_curve(
372
+ pred1,
373
+ pred2,
374
+ data['w_act'].values,
375
+ weight_vals,
376
+ n_bins=n_bins,
377
+ title=f"Double Lift Chart on {data_name}",
378
+ label1=label1,
379
+ label2=label2,
380
+ pred1_weighted=False,
381
+ pred2_weighted=False,
382
+ actual_weighted=True,
383
+ ax=ax,
384
+ show=False,
385
+ style=style,
386
+ )
387
+
388
+ plt.subplots_adjust(bottom=0.25, top=0.95, right=0.8, wspace=0.3)
389
+ save_path = self._resolve_plot_path(plot_prefix, filename)
390
+ if finalize_figure:
391
+ finalize_figure(fig, save_path=save_path, show=True, style=style)
392
+ else:
393
+ plt.savefig(save_path, dpi=300)
394
+ plt.show()
395
+ plt.close(fig)
396
+
397
+ # Conversion lift curve plot.
398
+
399
+ def plot_conversion_lift(self, model_pred_col: str, n_bins: int = 20):
400
+ if plt is None:
401
+ _plot_skip("conversion lift plot")
402
+ return
403
+ if not self.binary_resp_nme:
404
+ _log("Error: `binary_resp_nme` not provided at BayesOptModel init; cannot plot conversion lift.")
405
+ return
406
+
407
+ if plot_curves is None:
408
+ fig, axes = plt.subplots(1, 2, figsize=(14, 6), sharey=True)
409
+ datasets = {
410
+ 'Train Data': self.train_data,
411
+ 'Test Data': self.test_data
412
+ }
413
+
414
+ for ax, (data_name, data) in zip(axes, datasets.items()):
415
+ if model_pred_col not in data.columns:
416
+ _log(f"Warning: missing prediction column '{model_pred_col}' in {data_name}. Skip plot.")
417
+ continue
418
+
419
+ # Sort by model prediction and compute bins.
420
+ plot_data = data.sort_values(by=model_pred_col).copy()
421
+ plot_data['cum_weight'] = plot_data[self.weight_nme].cumsum()
422
+ total_weight = plot_data[self.weight_nme].sum()
423
+
424
+ if total_weight > EPS:
425
+ plot_data['bin'] = pd.cut(
426
+ plot_data['cum_weight'],
427
+ bins=n_bins,
428
+ labels=False,
429
+ right=False
430
+ )
431
+ else:
432
+ plot_data['bin'] = 0
433
+
434
+ # Aggregate by bins.
435
+ lift_agg = plot_data.groupby('bin').agg(
436
+ total_weight=(self.weight_nme, 'sum'),
437
+ actual_conversions=(self.binary_resp_nme, 'sum'),
438
+ weighted_conversions=('w_binary_act', 'sum'),
439
+ avg_pred=(model_pred_col, 'mean')
440
+ ).reset_index()
441
+
442
+ # Compute conversion rate.
443
+ lift_agg['conversion_rate'] = lift_agg['weighted_conversions'] / \
444
+ lift_agg['total_weight']
445
+
446
+ # Compute overall average conversion rate.
447
+ overall_conversion_rate = data['w_binary_act'].sum(
448
+ ) / data[self.weight_nme].sum()
449
+ ax.axhline(y=overall_conversion_rate, color='gray', linestyle='--',
450
+ label=f'Overall Avg Rate ({overall_conversion_rate:.2%})')
451
+
452
+ ax.plot(lift_agg['bin'], lift_agg['conversion_rate'],
453
+ marker='o', linestyle='-', label='Actual Conversion Rate')
454
+ ax.set_title(f'Conversion Rate Lift Chart on {data_name}')
455
+ ax.set_xlabel(f'Model Score Decile (based on {model_pred_col})')
456
+ ax.set_ylabel('Conversion Rate')
457
+ ax.grid(True, linestyle='--', alpha=0.6)
458
+ ax.legend()
459
+
460
+ plt.tight_layout()
461
+ plt.show()
462
+ return
463
+
464
+ fig, axes = plt.subplots(1, 2, figsize=(14, 6), sharey=True)
465
+ datasets = {
466
+ 'Train Data': self.train_data,
467
+ 'Test Data': self.test_data
468
+ }
469
+
470
+ for ax, (data_name, data) in zip(axes, datasets.items()):
471
+ if model_pred_col not in data.columns:
472
+ _log(f"Warning: missing prediction column '{model_pred_col}' in {data_name}. Skip plot.")
473
+ continue
474
+
475
+ plot_curves.plot_conversion_lift(
476
+ data[model_pred_col].values,
477
+ data[self.binary_resp_nme].values,
478
+ data[self.weight_nme].values,
479
+ n_bins=n_bins,
480
+ title=f'Conversion Rate Lift Chart on {data_name}',
481
+ ax=ax,
482
+ show=False,
483
+ )
484
+
485
+ plt.tight_layout()
486
+ plt.show()
487
+
488
+ # ========= Lightweight explainability: Permutation Importance =========