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.
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