py-flexplot 0.8.2__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.
- py_flexplot-0.8.2.dist-info/METADATA +272 -0
- py_flexplot-0.8.2.dist-info/RECORD +16 -0
- py_flexplot-0.8.2.dist-info/WHEEL +5 -0
- py_flexplot-0.8.2.dist-info/licenses/LICENSE +21 -0
- py_flexplot-0.8.2.dist-info/top_level.txt +1 -0
- pyflexplot/__init__.py +60 -0
- pyflexplot/bluepill.py +461 -0
- pyflexplot/core.py +3410 -0
- pyflexplot/descriptives.py +287 -0
- pyflexplot/ebbr.py +148 -0
- pyflexplot/flex_nn.py +583 -0
- pyflexplot/ml.py +175 -0
- pyflexplot/quality.py +372 -0
- pyflexplot/sem.py +195 -0
- pyflexplot/stats.py +803 -0
- pyflexplot/uncertainty.py +185 -0
pyflexplot/flex_nn.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""
|
|
2
|
+
flex_nn: Neural-network visualization utilities for py-flexplot.
|
|
3
|
+
|
|
4
|
+
This module is a Python port of the spirit (not the surface) of Dustin Fife's
|
|
5
|
+
``flex_nn`` R package (https://github.com/dustinfife/flex_nn). The R package
|
|
6
|
+
extends ``flexplot`` to handle Keras/TensorFlow models in ``compare.fits()`` and
|
|
7
|
+
related calls. In Python we cover the same conceptual surface for ``torch``
|
|
8
|
+
models (default backend) and provide a thin Keras 3 shim when available.
|
|
9
|
+
|
|
10
|
+
The module deliberately does NOT fit neural networks -- that is left to the
|
|
11
|
+
caller. Its job is to wrap an already-trained network with the metadata
|
|
12
|
+
required to make it a first-class citizen of py-flexplot's visualization API:
|
|
13
|
+
|
|
14
|
+
* ``set_response_var(model, name)`` -- attach the response variable name.
|
|
15
|
+
* ``NeuralNetFit`` -- a thin wrapper bundling the network with training
|
|
16
|
+
metadata so ``compare_fits()`` and friends can call ``.predict()`` and
|
|
17
|
+
get a properly-aligned ``pandas.Series``.
|
|
18
|
+
* ``permutation_importance(fit, X, y, metric=None)`` -- variable-importance
|
|
19
|
+
via column-wise shuffling, mirroring the R implementation.
|
|
20
|
+
* ``prepare_torch_data(data, categorical_vars=None)`` -- DataFrame -> 2-D
|
|
21
|
+
float tensor, with deterministic integer encoding for categoricals.
|
|
22
|
+
|
|
23
|
+
Backend selection
|
|
24
|
+
-----------------
|
|
25
|
+
The default backend is ``torch``. ``keras`` is supported opportunistically:
|
|
26
|
+
if ``keras`` is importable, ``is_keras_model(obj)`` will recognise
|
|
27
|
+
``keras.Model`` instances, and the same ``NeuralNetFit`` class wraps them
|
|
28
|
+
transparently. No keras dependency is declared in ``pyproject.toml`` -- the
|
|
29
|
+
support is best-effort and tested only when keras is installed.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Union, cast
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
import pandas as pd
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import torch # noqa: F401 -- presence is detected by ``_torch_available``
|
|
42
|
+
_TORCH_AVAILABLE = True
|
|
43
|
+
except Exception: # pragma: no cover -- environment without torch
|
|
44
|
+
_TORCH_AVAILABLE = False
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
import keras # noqa: F401
|
|
48
|
+
_KERAS_AVAILABLE = True
|
|
49
|
+
except Exception: # pragma: no cover -- environment without keras
|
|
50
|
+
_KERAS_AVAILABLE = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"NeuralNetFit",
|
|
55
|
+
"is_keras_model",
|
|
56
|
+
"is_torch_model",
|
|
57
|
+
"set_response_var",
|
|
58
|
+
"permutation_importance",
|
|
59
|
+
"prepare_torch_data",
|
|
60
|
+
"torch_backend_available",
|
|
61
|
+
"keras_backend_available",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Backend detection helpers
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def torch_backend_available() -> bool:
|
|
70
|
+
"""Return True if a usable ``torch`` is importable."""
|
|
71
|
+
return _TORCH_AVAILABLE
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def keras_backend_available() -> bool:
|
|
75
|
+
"""Return True if a usable ``keras`` is importable."""
|
|
76
|
+
return _KERAS_AVAILABLE
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_torch_model(obj: Any) -> bool:
|
|
80
|
+
"""Return True if *obj* looks like a ``torch.nn.Module``."""
|
|
81
|
+
if not _TORCH_AVAILABLE:
|
|
82
|
+
return False
|
|
83
|
+
import torch as _torch
|
|
84
|
+
return isinstance(obj, _torch.nn.Module)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_keras_model(obj: Any) -> bool:
|
|
88
|
+
"""Return True if *obj* is a ``keras.Model`` instance."""
|
|
89
|
+
if not _KERAS_AVAILABLE:
|
|
90
|
+
return False
|
|
91
|
+
import keras as _keras
|
|
92
|
+
return isinstance(obj, _keras.Model)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# prepare_torch_data
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def prepare_torch_data(
|
|
100
|
+
data: pd.DataFrame,
|
|
101
|
+
categorical_vars: Optional[Sequence[str]] = None,
|
|
102
|
+
) -> np.ndarray:
|
|
103
|
+
"""Convert a DataFrame into the dense float matrix a network expects.
|
|
104
|
+
|
|
105
|
+
Categorical columns (in *categorical_vars* that also exist in *data*) are
|
|
106
|
+
integer-encoded starting at zero. All other columns are coerced to
|
|
107
|
+
float. Missing values raise ``ValueError`` -- imputation is the caller's
|
|
108
|
+
responsibility, mirroring the R package's strict behaviour.
|
|
109
|
+
"""
|
|
110
|
+
if not isinstance(data, pd.DataFrame):
|
|
111
|
+
raise TypeError(
|
|
112
|
+
f"data must be a pandas DataFrame, got {type(data).__name__}"
|
|
113
|
+
)
|
|
114
|
+
if data.empty:
|
|
115
|
+
raise ValueError("data must be a non-empty DataFrame")
|
|
116
|
+
|
|
117
|
+
out = data.copy()
|
|
118
|
+
if categorical_vars:
|
|
119
|
+
existing = [c for c in categorical_vars if c in out.columns]
|
|
120
|
+
for col in existing:
|
|
121
|
+
out[col] = out[col].astype("category").cat.codes.astype(float)
|
|
122
|
+
|
|
123
|
+
if out.isna().any().any():
|
|
124
|
+
missing = sorted(out.columns[out.isna().any()])
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"prepare_torch_data does not impute missing values; "
|
|
127
|
+
f"found NaN in columns: {missing}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return out.to_numpy(dtype=float)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# set_response_var
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def set_response_var(model: Any, response_var: str) -> Any:
|
|
138
|
+
"""Attach the response-variable name as an attribute on *model*.
|
|
139
|
+
|
|
140
|
+
Works for any object that supports ``setattr`` -- typically a fitted
|
|
141
|
+
``torch.nn.Module`` or ``keras.Model``. The attribute is consulted by
|
|
142
|
+
``NeuralNetFit`` and the visualization paths so the network knows which
|
|
143
|
+
column of *data* it is predicting.
|
|
144
|
+
"""
|
|
145
|
+
if not isinstance(response_var, str) or not response_var:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"response_var must be a non-empty string, got {response_var!r}"
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
setattr(model, "_pyflexplot_response_var", response_var)
|
|
151
|
+
except Exception as exc: # pragma: no cover -- pytorch modules allow this
|
|
152
|
+
raise TypeError(
|
|
153
|
+
f"Cannot set attribute on model of type {type(model).__name__}: {exc}"
|
|
154
|
+
) from exc
|
|
155
|
+
return model
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _get_response_var(model: Any) -> Optional[str]:
|
|
159
|
+
"""Internal: read back the response variable attached by ``set_response_var``."""
|
|
160
|
+
return getattr(model, "_pyflexplot_response_var", None)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# NeuralNetFit
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class NeuralNetFit:
|
|
169
|
+
"""Wrapper bundling a fitted network with the metadata flexplot needs.
|
|
170
|
+
|
|
171
|
+
Attributes
|
|
172
|
+
----------
|
|
173
|
+
model
|
|
174
|
+
The underlying fitted network (``torch.nn.Module`` or ``keras.Model``).
|
|
175
|
+
response_var
|
|
176
|
+
Name of the column the network predicts.
|
|
177
|
+
predictor_names
|
|
178
|
+
Ordered names of the input columns the network was trained on.
|
|
179
|
+
x_means, x_sds
|
|
180
|
+
Per-column mean/sd used for z-score standardisation, if any. ``None``
|
|
181
|
+
means the network was trained on raw inputs.
|
|
182
|
+
history
|
|
183
|
+
Free-form training history (e.g. a Keras ``History`` object, a list
|
|
184
|
+
of torch loss/epoch dicts, or simply ``None``).
|
|
185
|
+
backend
|
|
186
|
+
Either ``"torch"`` or ``"keras"``; inferred if not supplied.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
model: Any
|
|
190
|
+
response_var: str
|
|
191
|
+
predictor_names: List[str]
|
|
192
|
+
x_means: Optional[np.ndarray] = None
|
|
193
|
+
x_sds: Optional[np.ndarray] = None
|
|
194
|
+
history: Any = None
|
|
195
|
+
backend: Optional[str] = None
|
|
196
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
197
|
+
|
|
198
|
+
def __post_init__(self) -> None:
|
|
199
|
+
if not isinstance(self.response_var, str) or not self.response_var:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
f"response_var must be a non-empty string, got {self.response_var!r}"
|
|
202
|
+
)
|
|
203
|
+
if not isinstance(self.predictor_names, (list, tuple)):
|
|
204
|
+
raise TypeError(
|
|
205
|
+
f"predictor_names must be a list/tuple, got {type(self.predictor_names).__name__}"
|
|
206
|
+
)
|
|
207
|
+
if len(self.predictor_names) == 0:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
"predictor_names must contain at least one predictor name"
|
|
210
|
+
)
|
|
211
|
+
if len(set(self.predictor_names)) != len(self.predictor_names):
|
|
212
|
+
raise ValueError("predictor_names must not contain duplicates")
|
|
213
|
+
|
|
214
|
+
if self.backend is None:
|
|
215
|
+
if is_torch_model(self.model):
|
|
216
|
+
self.backend = "torch"
|
|
217
|
+
elif is_keras_model(self.model):
|
|
218
|
+
self.backend = "keras"
|
|
219
|
+
else:
|
|
220
|
+
raise TypeError(
|
|
221
|
+
"NeuralNetFit.model must be a torch.nn.Module or keras.Model "
|
|
222
|
+
f"(got {type(self.model).__name__}); set backend explicitly "
|
|
223
|
+
"if you are wrapping a duck-typed object."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if self.backend == "torch" and not _TORCH_AVAILABLE:
|
|
227
|
+
raise RuntimeError(
|
|
228
|
+
"backend='torch' but torch is not importable in this environment"
|
|
229
|
+
)
|
|
230
|
+
if self.backend == "keras" and not _KERAS_AVAILABLE:
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
"backend='keras' but keras is not importable in this environment"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Honour set_response_var() on the wrapped model if response_var is
|
|
236
|
+
# still default-ish, but always trust the explicit constructor arg.
|
|
237
|
+
existing = _get_response_var(self.model)
|
|
238
|
+
if existing is None:
|
|
239
|
+
set_response_var(self.model, self.response_var)
|
|
240
|
+
|
|
241
|
+
# -- prediction --------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def _prepare_matrix(self, data: pd.DataFrame) -> np.ndarray:
|
|
244
|
+
"""Slice *data* to *predictor_names* and apply stored normalisation."""
|
|
245
|
+
missing = [c for c in self.predictor_names if c not in data.columns]
|
|
246
|
+
if missing:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"data is missing predictors required by NeuralNetFit: {missing}"
|
|
249
|
+
)
|
|
250
|
+
X = data[list(self.predictor_names)].to_numpy(dtype=float)
|
|
251
|
+
if self.x_means is not None and self.x_sds is not None:
|
|
252
|
+
X = (X - self.x_means) / np.where(self.x_sds == 0, 1, self.x_sds)
|
|
253
|
+
return X
|
|
254
|
+
|
|
255
|
+
def predict(self, data: pd.DataFrame) -> pd.Series:
|
|
256
|
+
"""Return predictions for *data* aligned to its row index.
|
|
257
|
+
|
|
258
|
+
For Keras models, predictions are made with ``training=False`` so
|
|
259
|
+
dropout/batchnorm layers behave as they did at evaluation time. For
|
|
260
|
+
Torch models the computation runs under ``torch.no_grad()``.
|
|
261
|
+
"""
|
|
262
|
+
X = self._prepare_matrix(data)
|
|
263
|
+
|
|
264
|
+
if self.backend == "torch":
|
|
265
|
+
import torch as _torch
|
|
266
|
+
with _torch.no_grad():
|
|
267
|
+
tensor = _torch.as_tensor(X, dtype=_torch.float32)
|
|
268
|
+
raw = self.model(tensor)
|
|
269
|
+
arr = raw.detach().cpu().numpy()
|
|
270
|
+
|
|
271
|
+
elif self.backend == "keras":
|
|
272
|
+
arr = self._keras_predict(X)
|
|
273
|
+
|
|
274
|
+
else: # pragma: no cover -- validated in __post_init__
|
|
275
|
+
raise RuntimeError(f"unsupported backend: {self.backend!r}")
|
|
276
|
+
|
|
277
|
+
arr = np.asarray(arr)
|
|
278
|
+
if arr.ndim == 2 and arr.shape[1] == 1:
|
|
279
|
+
arr = arr.ravel()
|
|
280
|
+
elif arr.ndim > 2:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
f"Network predictions must be 1-D (or 2-D with one output column); "
|
|
283
|
+
f"got shape {arr.shape}"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return pd.Series(arr, index=data.index, name=f"{self.response_var}__pred")
|
|
287
|
+
|
|
288
|
+
def _keras_predict(self, X: np.ndarray) -> np.ndarray:
|
|
289
|
+
"""Call a Keras model in inference mode.
|
|
290
|
+
|
|
291
|
+
Keras3's ``Model.predict`` accepts a ``training=False`` argument; we
|
|
292
|
+
pass it explicitly so models with Dropout or BatchNorm behave the
|
|
293
|
+
same way they did during validation. Some custom ``Model`` subclasses
|
|
294
|
+
don't accept ``training`` as a kwarg -- we retry without it in that
|
|
295
|
+
case, after setting the model's ``training`` attribute to False if
|
|
296
|
+
available, and restoring the original value on exit.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
# Save and restore the model's training flag if it's mutable so we
|
|
300
|
+
# don't permanently side-effect a caller-owned object.
|
|
301
|
+
prior_training = getattr(self.model, "training", None)
|
|
302
|
+
if hasattr(self.model, "training"):
|
|
303
|
+
try:
|
|
304
|
+
self.model.training = False
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
return np.asarray(self.model.predict(X, verbose=0, training=False))
|
|
310
|
+
except TypeError:
|
|
311
|
+
# Fall back for custom Models whose call() doesn't accept training=.
|
|
312
|
+
return np.asarray(self.model.predict(X, verbose=0))
|
|
313
|
+
finally:
|
|
314
|
+
if prior_training is not None and hasattr(self.model, "training"):
|
|
315
|
+
try:
|
|
316
|
+
self.model.training = prior_training
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
# -- introspection -----------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def __repr__(self) -> str: # pragma: no cover -- cosmetic
|
|
323
|
+
n_params = None
|
|
324
|
+
if self.backend == "torch":
|
|
325
|
+
try:
|
|
326
|
+
n_params = sum(p.numel() for p in self.model.parameters())
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
elif self.backend == "keras":
|
|
330
|
+
try:
|
|
331
|
+
n_params = self.model.count_params()
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
head = f"NeuralNetFit(backend={self.backend!r}, response={self.response_var!r}"
|
|
335
|
+
if n_params is not None:
|
|
336
|
+
head += f", params={n_params}"
|
|
337
|
+
head += f", predictors={len(self.predictor_names)})"
|
|
338
|
+
return head
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
# permutation_importance
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
_DEFAULT_METRICS = {
|
|
346
|
+
# metrics where higher is better -- importance = baseline - permuted
|
|
347
|
+
"accuracy": "higher",
|
|
348
|
+
"auc": "higher",
|
|
349
|
+
"precision": "higher",
|
|
350
|
+
"recall": "higher",
|
|
351
|
+
"f1": "higher",
|
|
352
|
+
"r2": "higher",
|
|
353
|
+
# metrics where lower is better -- importance = permuted - baseline
|
|
354
|
+
"loss": "lower",
|
|
355
|
+
"mse": "lower",
|
|
356
|
+
"mae": "lower",
|
|
357
|
+
"mean_absolute_error": "lower",
|
|
358
|
+
"mean_squared_error": "lower",
|
|
359
|
+
"rmse": "lower",
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _default_metric(model: Any, backend: str) -> Callable[[np.ndarray, np.ndarray], float]:
|
|
364
|
+
"""Return a sensible default scorer for *model*."""
|
|
365
|
+
if backend == "torch":
|
|
366
|
+
# For torch models we default to MSE -- the most common regression
|
|
367
|
+
# loss and the analogue of the R package's mean_absolute_error default.
|
|
368
|
+
def _mse(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
|
369
|
+
return float(np.mean((np.asarray(y_true) - np.asarray(y_pred)) ** 2))
|
|
370
|
+
return _mse
|
|
371
|
+
if backend == "keras":
|
|
372
|
+
last = getattr(model, "loss", None)
|
|
373
|
+
if isinstance(last, str) and "binary" in last:
|
|
374
|
+
def _wrong(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
|
375
|
+
return float(np.mean((np.asarray(y_true) > 0.5) != (np.asarray(y_pred) > 0.5)))
|
|
376
|
+
return _wrong
|
|
377
|
+
def _mse(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
|
378
|
+
return float(np.mean((np.asarray(y_true) - np.asarray(y_pred)) ** 2))
|
|
379
|
+
return _mse
|
|
380
|
+
raise ValueError(f"unsupported backend: {backend!r}")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def permutation_importance(
|
|
384
|
+
fit: NeuralNetFit,
|
|
385
|
+
X: pd.DataFrame,
|
|
386
|
+
y: Union[pd.Series, np.ndarray, list],
|
|
387
|
+
*,
|
|
388
|
+
metric: Optional[Union[str, Callable[[np.ndarray, np.ndarray], float]]] = None,
|
|
389
|
+
n_repeats: int = 1,
|
|
390
|
+
random_state: Optional[int] = None,
|
|
391
|
+
higher_is_better: Optional[bool] = None,
|
|
392
|
+
) -> pd.DataFrame:
|
|
393
|
+
"""Permutation feature importance for a fitted neural network.
|
|
394
|
+
|
|
395
|
+
Parameters
|
|
396
|
+
----------
|
|
397
|
+
fit
|
|
398
|
+
A :class:`NeuralNetFit` wrapper.
|
|
399
|
+
X
|
|
400
|
+
Predictor matrix used to score the model.
|
|
401
|
+
y
|
|
402
|
+
True response -- either a ``pd.Series`` aligned to ``X.index`` or a
|
|
403
|
+
1-D array/list of the same length as ``X``.
|
|
404
|
+
metric
|
|
405
|
+
Either the name of a known metric (``"mse"``, ``"mae"``,
|
|
406
|
+
``"accuracy"``, ...) or a callable ``(y_true, y_pred) -> float``.
|
|
407
|
+
``None`` picks a backend-aware default.
|
|
408
|
+
n_repeats
|
|
409
|
+
How many independent permutations to average per column.
|
|
410
|
+
random_state
|
|
411
|
+
Optional seed for reproducibility.
|
|
412
|
+
|
|
413
|
+
Returns
|
|
414
|
+
-------
|
|
415
|
+
pandas.DataFrame
|
|
416
|
+
Columns ``variable``, ``importance`` (higher = more important),
|
|
417
|
+
``baseline`` (the unscored metric value), sorted by importance
|
|
418
|
+
descending.
|
|
419
|
+
"""
|
|
420
|
+
if not isinstance(fit, NeuralNetFit):
|
|
421
|
+
raise TypeError(
|
|
422
|
+
f"fit must be a NeuralNetFit, got {type(fit).__name__}"
|
|
423
|
+
)
|
|
424
|
+
if not isinstance(X, pd.DataFrame):
|
|
425
|
+
raise TypeError(
|
|
426
|
+
f"X must be a pandas DataFrame, got {type(X).__name__}"
|
|
427
|
+
)
|
|
428
|
+
if X.empty:
|
|
429
|
+
raise ValueError("X must be non-empty")
|
|
430
|
+
if n_repeats < 1:
|
|
431
|
+
raise ValueError(f"n_repeats must be >= 1, got {n_repeats}")
|
|
432
|
+
|
|
433
|
+
if isinstance(y, pd.Series):
|
|
434
|
+
if not y.index.equals(X.index):
|
|
435
|
+
y = y.reindex(X.index)
|
|
436
|
+
y_arr = y.to_numpy()
|
|
437
|
+
else:
|
|
438
|
+
y_arr = np.asarray(y)
|
|
439
|
+
if y_arr.shape[0] != len(X):
|
|
440
|
+
raise ValueError(
|
|
441
|
+
f"y must have the same length as X ({len(X)}); got {y_arr.shape[0]}"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
rng = np.random.default_rng(random_state)
|
|
445
|
+
|
|
446
|
+
scorer: Callable[[np.ndarray, np.ndarray], float]
|
|
447
|
+
direction: Optional[bool] # True = higher is better
|
|
448
|
+
if metric is None:
|
|
449
|
+
scorer = _default_metric(fit.model, cast(str, fit.backend))
|
|
450
|
+
# MSE / wrong-rate: lower is better.
|
|
451
|
+
direction = False
|
|
452
|
+
elif callable(metric):
|
|
453
|
+
if higher_is_better is None:
|
|
454
|
+
raise ValueError(
|
|
455
|
+
"When metric is a callable, higher_is_better must be True or False "
|
|
456
|
+
"so importance has a defined sign."
|
|
457
|
+
)
|
|
458
|
+
scorer = metric
|
|
459
|
+
direction = bool(higher_is_better)
|
|
460
|
+
elif isinstance(metric, str):
|
|
461
|
+
key = metric.lower()
|
|
462
|
+
if key not in _DEFAULT_METRICS:
|
|
463
|
+
raise ValueError(
|
|
464
|
+
f"Unknown metric {metric!r}. Pass a callable scorer or one of "
|
|
465
|
+
f"{sorted(_DEFAULT_METRICS)}."
|
|
466
|
+
)
|
|
467
|
+
direction = _DEFAULT_METRICS[key] == "higher"
|
|
468
|
+
if key in ("mse", "mean_squared_error"):
|
|
469
|
+
def scorer(yt, yp):
|
|
470
|
+
return float(np.mean((np.asarray(yt) - np.asarray(yp)) ** 2))
|
|
471
|
+
elif key in ("mae", "mean_absolute_error"):
|
|
472
|
+
def scorer(yt, yp):
|
|
473
|
+
return float(np.mean(np.abs(np.asarray(yt) - np.asarray(yp))))
|
|
474
|
+
elif key in ("rmse",):
|
|
475
|
+
def scorer(yt, yp):
|
|
476
|
+
return float(np.sqrt(np.mean((np.asarray(yt) - np.asarray(yp)) ** 2)))
|
|
477
|
+
elif key in ("r2",):
|
|
478
|
+
def scorer(yt, yp):
|
|
479
|
+
yt = np.asarray(yt); yp = np.asarray(yp)
|
|
480
|
+
ss_res = float(np.sum((yt - yp) ** 2))
|
|
481
|
+
ss_tot = float(np.sum((yt - np.mean(yt)) ** 2))
|
|
482
|
+
return 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
|
|
483
|
+
elif key == "accuracy":
|
|
484
|
+
def scorer(yt, yp):
|
|
485
|
+
yt = np.asarray(yt); yp = np.asarray(yp)
|
|
486
|
+
yt_bin = (yt > 0.5).astype(int) if set(np.unique(yt)).issubset({0, 1}) else yt
|
|
487
|
+
yp_bin = (yp > 0.5).astype(int) if yp.size else yp
|
|
488
|
+
return float(np.mean(yt_bin == yp_bin))
|
|
489
|
+
elif key == "loss":
|
|
490
|
+
# Generic regression loss: MSE. Lower is better.
|
|
491
|
+
def scorer(yt, yp):
|
|
492
|
+
return float(np.mean((np.asarray(yt) - np.asarray(yp)) ** 2))
|
|
493
|
+
elif key in ("precision", "recall", "f1"):
|
|
494
|
+
# Binary-classification metrics via 0.5 threshold. For binary
|
|
495
|
+
# y; raises if y is not binary, prompting the user to pass a
|
|
496
|
+
# callable scorer instead.
|
|
497
|
+
def scorer(yt, yp):
|
|
498
|
+
yt = np.asarray(yt); yp = np.asarray(yp)
|
|
499
|
+
if not set(np.unique(yt)).issubset({0, 1}):
|
|
500
|
+
raise ValueError(
|
|
501
|
+
f"metric={key!r} requires a binary 0/1 outcome; "
|
|
502
|
+
f"got unique values {sorted(np.unique(yt))[:5]}..."
|
|
503
|
+
)
|
|
504
|
+
tp = float(np.sum((yt == 1) & (yp > 0.5)))
|
|
505
|
+
fp = float(np.sum((yt == 0) & (yp > 0.5)))
|
|
506
|
+
fn = float(np.sum((yt == 1) & (yp <= 0.5)))
|
|
507
|
+
if key == "precision":
|
|
508
|
+
return tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
|
509
|
+
if key == "recall":
|
|
510
|
+
return tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
|
511
|
+
# f1
|
|
512
|
+
p = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
|
513
|
+
r = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
|
514
|
+
return 2 * p * r / (p + r) if (p + r) > 0 else 0.0
|
|
515
|
+
elif key == "auc":
|
|
516
|
+
# Wilcoxon-Mann-Whitney U statistic normalised to [0, 1]; a
|
|
517
|
+
# rank-based AUC approximation that doesn't require sklearn.
|
|
518
|
+
# Best-effort for binary classification; raises if y is not
|
|
519
|
+
# binary.
|
|
520
|
+
def scorer(yt, yp):
|
|
521
|
+
yt = np.asarray(yt); yp = np.asarray(yp)
|
|
522
|
+
if not set(np.unique(yt)).issubset({0, 1}):
|
|
523
|
+
raise ValueError(
|
|
524
|
+
"metric='auc' requires a binary 0/1 outcome; "
|
|
525
|
+
f"got unique values {sorted(np.unique(yt))[:5]}..."
|
|
526
|
+
)
|
|
527
|
+
pos = yp[yt == 1]
|
|
528
|
+
neg = yp[yt == 0]
|
|
529
|
+
if pos.size == 0 or neg.size == 0:
|
|
530
|
+
return 0.5 # undefined; return random-classifier value
|
|
531
|
+
# U statistic via pairwise comparison
|
|
532
|
+
n_pos, n_neg = len(pos), len(neg)
|
|
533
|
+
# rank all predictions together, then sum ranks of positives
|
|
534
|
+
order = np.argsort(yp)
|
|
535
|
+
ranks = np.empty_like(order, dtype=float)
|
|
536
|
+
ranks[order] = np.arange(1, len(yp) + 1)
|
|
537
|
+
pos_rank_sum = float(ranks[yt == 1].sum())
|
|
538
|
+
u = pos_rank_sum - n_pos * (n_pos + 1) / 2.0
|
|
539
|
+
return u / (n_pos * n_neg)
|
|
540
|
+
else:
|
|
541
|
+
# Should not be reachable: _DEFAULT_METRICS keys are exhaustive.
|
|
542
|
+
raise ValueError(
|
|
543
|
+
f"metric={key!r} is in _DEFAULT_METRICS but has no scorer "
|
|
544
|
+
"implementation. Pass a callable scorer instead."
|
|
545
|
+
)
|
|
546
|
+
else:
|
|
547
|
+
raise TypeError(
|
|
548
|
+
f"metric must be None, str, or callable; got {type(metric).__name__}"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Build the scored-once matrix for the baseline score.
|
|
552
|
+
baseline_pred = fit.predict(X).to_numpy()
|
|
553
|
+
baseline_score = scorer(y_arr, baseline_pred)
|
|
554
|
+
|
|
555
|
+
n_cols = X.shape[1]
|
|
556
|
+
columns = list(X.columns)
|
|
557
|
+
if len(columns) != n_cols:
|
|
558
|
+
columns = [f"x{i}" for i in range(n_cols)]
|
|
559
|
+
|
|
560
|
+
X_arr = X.to_numpy()
|
|
561
|
+
importances = np.zeros(n_cols, dtype=float)
|
|
562
|
+
|
|
563
|
+
for j in range(n_cols):
|
|
564
|
+
scores: List[float] = []
|
|
565
|
+
for _ in range(n_repeats):
|
|
566
|
+
X_perm = X_arr.copy()
|
|
567
|
+
X_perm[:, j] = X_arr[rng.permutation(X_arr.shape[0]), j]
|
|
568
|
+
perm_df = pd.DataFrame(X_perm, columns=columns, index=X.index)
|
|
569
|
+
perm_pred = fit.predict(perm_df).to_numpy()
|
|
570
|
+
scores.append(scorer(y_arr, perm_pred))
|
|
571
|
+
mean_perm = float(np.mean(scores))
|
|
572
|
+
if direction:
|
|
573
|
+
importances[j] = baseline_score - mean_perm
|
|
574
|
+
else:
|
|
575
|
+
importances[j] = mean_perm - baseline_score
|
|
576
|
+
|
|
577
|
+
out = pd.DataFrame({
|
|
578
|
+
"variable": columns,
|
|
579
|
+
"importance": importances,
|
|
580
|
+
}).sort_values("importance", ascending=False).reset_index(drop=True)
|
|
581
|
+
out.attrs["baseline_score"] = baseline_score
|
|
582
|
+
out.attrs["metric"] = getattr(metric, "__name__", metric)
|
|
583
|
+
return out
|