phasmanet 0.1.0__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.
@@ -0,0 +1,680 @@
1
+ """scikit-learn style estimators around the PhasmaNet architecture.
2
+
3
+ ``PhasmaNetRegressor`` and ``PhasmaNetClassifier`` follow the fit / predict convention,
4
+ so they work with scikit-learn tools such as ``cross_val_score``, ``GridSearchCV`` and
5
+ ``Pipeline`` when scikit-learn is installed (it is optional). Every hyperparameter of the
6
+ architecture and of the training loop is a constructor argument; the defaults are the
7
+ configuration used in the paper.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import inspect
13
+ import math
14
+ from typing import Any, Dict, Optional
15
+
16
+ import numpy as np
17
+ import torch
18
+ from torch import nn
19
+
20
+ from .calibration import MonotoneCalibrator
21
+ from .model import PhasmaNet
22
+ from ._version import __version__
23
+
24
+ try: # scikit-learn is optional
25
+ from sklearn.base import BaseEstimator as _SkBase, ClassifierMixin as _SkClf, RegressorMixin as _SkReg
26
+ except ImportError: # pragma: no cover - exercised only without scikit-learn
27
+
28
+ class _SkBase: # minimal get_params / set_params compatible with sklearn.clone
29
+ @classmethod
30
+ def _get_param_names(cls):
31
+ sig = inspect.signature(cls.__init__)
32
+ return sorted(p for p in sig.parameters if p != "self")
33
+
34
+ def get_params(self, deep: bool = True) -> Dict[str, Any]:
35
+ return {k: getattr(self, k) for k in self._get_param_names()}
36
+
37
+ def set_params(self, **params):
38
+ valid = self._get_param_names()
39
+ for k, v in params.items():
40
+ if k not in valid:
41
+ raise ValueError(f"Invalid parameter {k!r} for {type(self).__name__}")
42
+ setattr(self, k, v)
43
+ return self
44
+
45
+ class _SkReg:
46
+ _estimator_type = "regressor"
47
+
48
+ class _SkClf:
49
+ _estimator_type = "classifier"
50
+
51
+
52
+ __all__ = ["PhasmaNetRegressor", "PhasmaNetClassifier"]
53
+
54
+ _FORMAT = "phasmanet-estimator"
55
+ _ARCH_PARAMS = ("hidden_dim", "num_tokens", "num_stages", "num_feedback", "heads", "dropout",
56
+ "num_refractor_subspaces", "use_refractor", "ffn_mult", "activation", "attn_dropout")
57
+
58
+
59
+ def _resolve_device(device: Optional[str]) -> torch.device:
60
+ if device is None or device == "auto":
61
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
62
+ return torch.device(device)
63
+
64
+
65
+ def _as_2d_float(X) -> np.ndarray:
66
+ X = np.asarray(X, dtype=np.float32)
67
+ if X.ndim == 1:
68
+ X = X.reshape(-1, 1)
69
+ if X.ndim != 2:
70
+ raise ValueError(f"X must be 2-dimensional, got shape {X.shape}")
71
+ if not np.all(np.isfinite(X)):
72
+ raise ValueError("X contains NaN or infinite values")
73
+ return X
74
+
75
+
76
+ class _PhasmaNetBase(_SkBase):
77
+ """Shared training loop. Subclasses define the targets, loss and outputs.
78
+
79
+ Subclasses must store every constructor argument as an attribute of the same name.
80
+ """
81
+
82
+ # ----- hooks implemented by subclasses -----
83
+ def _prepare_targets(self, y, refit: bool = True) -> np.ndarray:
84
+ raise NotImplementedError
85
+
86
+ def _fit_target_scaler(self, Y_train: np.ndarray) -> None:
87
+ pass
88
+
89
+ def _scale_targets(self, Y: np.ndarray) -> np.ndarray:
90
+ return Y
91
+
92
+ def _scheme_weights(self, Y_train: np.ndarray) -> Optional[np.ndarray]:
93
+ """Extra per-sample weights from a weighting scheme (None for uniform)."""
94
+ return None
95
+
96
+ def _output_dim(self) -> int:
97
+ raise NotImplementedError
98
+
99
+ def _loss(self, out: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
100
+ """Per-sample loss, shape (B,)."""
101
+ raise NotImplementedError
102
+
103
+ def _after_fit(self, X_val: Optional[np.ndarray], y_val: Optional[np.ndarray]) -> None:
104
+ pass
105
+
106
+ # ----- common machinery -----
107
+ def _architecture(self) -> Dict[str, Any]:
108
+ return {k: getattr(self, k) for k in _ARCH_PARAMS}
109
+
110
+ def _build_module(self) -> PhasmaNet:
111
+ return PhasmaNet(input_dim=self.n_features_in_, output_dim=self._output_dim(), **self._architecture())
112
+
113
+ def _make_optimizer(self, params):
114
+ name = str(self.optimizer).lower()
115
+ if name == "adamw":
116
+ return torch.optim.AdamW(params, lr=self.lr, weight_decay=self.weight_decay)
117
+ if name == "adam":
118
+ return torch.optim.Adam(params, lr=self.lr, weight_decay=self.weight_decay)
119
+ if name == "sgd":
120
+ return torch.optim.SGD(params, lr=self.lr, weight_decay=self.weight_decay, momentum=self.momentum)
121
+ raise ValueError("optimizer must be 'adamw', 'adam' or 'sgd'")
122
+
123
+ def _make_scheduler(self, opt):
124
+ name = str(self.scheduler).lower()
125
+ if name == "plateau":
126
+ return torch.optim.lr_scheduler.ReduceLROnPlateau(opt, mode="min", factor=self.lr_factor,
127
+ patience=self.lr_patience)
128
+ if name == "cosine":
129
+ return torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=max(1, self.max_epochs))
130
+ if name == "none":
131
+ return None
132
+ raise ValueError("scheduler must be 'plateau', 'cosine' or 'none'")
133
+
134
+ def _check_X(self, X, reset: bool) -> np.ndarray:
135
+ names = getattr(X, "columns", None)
136
+ X = _as_2d_float(X)
137
+ if reset:
138
+ self.n_features_in_ = X.shape[1]
139
+ if names is not None:
140
+ self.feature_names_in_ = np.asarray([str(c) for c in names], dtype=object)
141
+ elif X.shape[1] != self.n_features_in_:
142
+ raise ValueError(f"X has {X.shape[1]} features, but the model was fitted with {self.n_features_in_}")
143
+ return X
144
+
145
+ def _scale_X(self, X: np.ndarray) -> np.ndarray:
146
+ return ((X - self.x_mean_) / self.x_std_).astype(np.float32)
147
+
148
+ def _check_is_fitted(self) -> None:
149
+ if not hasattr(self, "module_"):
150
+ raise RuntimeError(f"This {type(self).__name__} instance is not fitted yet; call fit first.")
151
+
152
+ def fit(self, X, y, sample_weight=None, validation_data=None):
153
+ """Train the network with early stopping on a validation set.
154
+
155
+ Args:
156
+ X: array-like of shape (n_samples, n_features).
157
+ y: targets.
158
+ sample_weight: optional per-sample loss weights (training split only).
159
+ validation_data: optional ``(X_val, y_val)``. If omitted, a random
160
+ ``validation_fraction`` of the training data is held out.
161
+ """
162
+ X = self._check_X(X, reset=True)
163
+ Y = self._prepare_targets(y)
164
+ if len(X) != len(Y):
165
+ raise ValueError("X and y have different numbers of samples")
166
+ w = None if sample_weight is None else np.asarray(sample_weight, dtype=np.float64).ravel()
167
+ if w is not None and len(w) != len(X):
168
+ raise ValueError("sample_weight has the wrong length")
169
+
170
+ rng = np.random.default_rng(self.random_state)
171
+ if self.random_state is not None:
172
+ torch.manual_seed(self.random_state)
173
+
174
+ if validation_data is not None:
175
+ X_tr, Y_tr, w_tr = X, Y, w
176
+ X_val = self._check_X(validation_data[0], reset=False)
177
+ Y_val = self._prepare_targets(validation_data[1], refit=False)
178
+ elif self.validation_fraction and self.validation_fraction > 0:
179
+ n_val = max(1, int(round(self.validation_fraction * len(X))))
180
+ if n_val >= len(X):
181
+ raise ValueError("validation_fraction leaves no training samples")
182
+ perm = rng.permutation(len(X))
183
+ val_idx, tr_idx = perm[:n_val], perm[n_val:]
184
+ X_tr, Y_tr, X_val, Y_val = X[tr_idx], Y[tr_idx], X[val_idx], Y[val_idx]
185
+ w_tr = None if w is None else w[tr_idx]
186
+ else:
187
+ X_tr, Y_tr, w_tr, X_val, Y_val = X, Y, w, None, None
188
+
189
+ # Scalers from the training split only (float64 accumulation, then stored as float32).
190
+ self.x_mean_ = X_tr.mean(axis=0, dtype=np.float64).astype(np.float32)
191
+ self.x_std_ = (X_tr.std(axis=0, dtype=np.float64) + 1e-9).astype(np.float32)
192
+ self._fit_target_scaler(Y_tr)
193
+ Y_tr_t = self._scale_targets(Y_tr)
194
+ Y_val_t = None if Y_val is None else self._scale_targets(Y_val)
195
+
196
+ scheme = self._scheme_weights(Y_tr)
197
+ if scheme is not None:
198
+ w_tr = scheme if w_tr is None else w_tr * scheme
199
+ Wt = None if w_tr is None else torch.from_numpy((w_tr / (w_tr.mean() + 1e-12)).astype(np.float32))
200
+
201
+ device = _resolve_device(self.device)
202
+ self.module_ = self._build_module().to(device)
203
+ opt = self._make_optimizer(self.module_.parameters())
204
+ sched = self._make_scheduler(opt)
205
+
206
+ Xt = torch.from_numpy(self._scale_X(X_tr))
207
+ Yt = torch.from_numpy(Y_tr_t)
208
+ n = len(Xt)
209
+
210
+ best_loss, best_state, bad = math.inf, None, 0
211
+ self.history_ = {"train_loss": [], "val_loss": [], "lr": []}
212
+ epoch = 0
213
+ for epoch in range(1, self.max_epochs + 1):
214
+ self.module_.train()
215
+ order = torch.from_numpy(rng.permutation(n))
216
+ total = 0.0
217
+ for start in range(0, n, self.batch_size):
218
+ idx = order[start:start + self.batch_size]
219
+ xb, yb = Xt[idx].to(device), Yt[idx].to(device)
220
+ opt.zero_grad()
221
+ loss_vec = self._loss(self.module_(xb), yb)
222
+ if Wt is not None:
223
+ loss_vec = loss_vec * Wt[idx].to(device)
224
+ loss = loss_vec.mean()
225
+ loss.backward()
226
+ if self.grad_clip:
227
+ nn.utils.clip_grad_norm_(self.module_.parameters(), self.grad_clip)
228
+ opt.step()
229
+ total += loss.item() * len(idx)
230
+ train_loss = total / n
231
+ self.history_["train_loss"].append(train_loss)
232
+ self.history_["lr"].append(opt.param_groups[0]["lr"])
233
+
234
+ if X_val is not None:
235
+ monitor = self._eval_loss(self._scale_X(X_val), Y_val_t, device)
236
+ self.history_["val_loss"].append(monitor)
237
+ else:
238
+ monitor = train_loss
239
+ if isinstance(sched, torch.optim.lr_scheduler.ReduceLROnPlateau):
240
+ sched.step(monitor)
241
+ elif sched is not None:
242
+ sched.step()
243
+
244
+ if self.verbose:
245
+ msg = f"epoch {epoch:4d} train {train_loss:.6f}"
246
+ if X_val is not None:
247
+ msg += f" val {monitor:.6f}"
248
+ print(msg)
249
+
250
+ if monitor + self.min_delta < best_loss:
251
+ best_loss, bad = monitor, 0
252
+ best_state = copy.deepcopy(self.module_.state_dict())
253
+ else:
254
+ bad += 1
255
+ if self.patience and bad >= self.patience:
256
+ if self.verbose:
257
+ print("early stopping")
258
+ break
259
+
260
+ if best_state is not None:
261
+ self.module_.load_state_dict(best_state)
262
+ self.module_.eval()
263
+ self.n_epochs_ = epoch
264
+ self.best_loss_ = best_loss
265
+ self._after_fit(X_val, Y_val)
266
+ return self
267
+
268
+ def _eval_loss(self, Xs: np.ndarray, Ys: np.ndarray, device: torch.device) -> float:
269
+ self.module_.eval()
270
+ total = 0.0
271
+ with torch.no_grad():
272
+ for start in range(0, len(Xs), 4096):
273
+ xb = torch.from_numpy(Xs[start:start + 4096]).to(device)
274
+ yb = torch.from_numpy(Ys[start:start + 4096]).to(device)
275
+ total += self._loss(self.module_(xb), yb).sum().item()
276
+ return total / len(Xs)
277
+
278
+ def _forward_numpy(self, X, batch_size: int = 4096) -> np.ndarray:
279
+ self._check_is_fitted()
280
+ Xs = self._scale_X(self._check_X(X, reset=False))
281
+ device = next(self.module_.parameters()).device
282
+ self.module_.eval()
283
+ outs = []
284
+ with torch.inference_mode():
285
+ for start in range(0, len(Xs), batch_size):
286
+ outs.append(self.module_(torch.from_numpy(Xs[start:start + batch_size]).to(device)).cpu().numpy())
287
+ return np.concatenate(outs, axis=0) if outs else np.zeros((0, self._output_dim()), dtype=np.float32)
288
+
289
+ # ----- persistence -----
290
+ def _extra_state(self) -> Dict[str, Any]:
291
+ return {}
292
+
293
+ def _load_extra_state(self, state: Dict[str, Any]) -> None:
294
+ pass
295
+
296
+ def save(self, path) -> None:
297
+ """Save a fitted estimator (hyperparameters, weights and scalers) to ``path``."""
298
+ self._check_is_fitted()
299
+ payload = {
300
+ "format": _FORMAT,
301
+ "phasmanet_version": __version__,
302
+ "class": type(self).__name__,
303
+ "params": self.get_params(),
304
+ "n_features_in": int(self.n_features_in_),
305
+ "feature_names_in": None if not hasattr(self, "feature_names_in_") else [str(c) for c in self.feature_names_in_],
306
+ "x_mean": torch.from_numpy(self.x_mean_),
307
+ "x_std": torch.from_numpy(self.x_std_),
308
+ "state_dict": {k: v.detach().cpu() for k, v in self.module_.state_dict().items()},
309
+ "extra": self._extra_state(),
310
+ }
311
+ torch.save(payload, path)
312
+
313
+ @classmethod
314
+ def load(cls, path, device: Optional[str] = "auto"):
315
+ """Load an estimator saved with :meth:`save`."""
316
+ payload = torch.load(path, map_location="cpu", weights_only=True)
317
+ if payload.get("format") != _FORMAT:
318
+ raise ValueError(f"{path} is not a saved PhasmaNet estimator")
319
+ if payload["class"] != cls.__name__:
320
+ raise ValueError(f"{path} holds a {payload['class']}, not a {cls.__name__}")
321
+ valid = set(inspect.signature(cls.__init__).parameters) - {"self"}
322
+ est = cls(**{k: v for k, v in payload["params"].items() if k in valid})
323
+ est.device = device
324
+ est.n_features_in_ = payload["n_features_in"]
325
+ if payload.get("feature_names_in"):
326
+ est.feature_names_in_ = np.asarray(payload["feature_names_in"], dtype=object)
327
+ est.x_mean_ = payload["x_mean"].numpy()
328
+ est.x_std_ = payload["x_std"].numpy()
329
+ est._load_extra_state(payload["extra"])
330
+ est.module_ = est._build_module()
331
+ est.module_.load_state_dict(payload["state_dict"])
332
+ est.module_.to(_resolve_device(device)).eval()
333
+ return est
334
+
335
+
336
+ _COMMON_DOC = """
337
+ Architecture:
338
+ hidden_dim (128), num_tokens (8), num_stages (3), num_feedback (2), heads (4),
339
+ dropout (0.1), num_refractor_subspaces (4), use_refractor (True), ffn_mult (4),
340
+ activation ("gelu"; also "relu", "silu", "tanh", "elu", "leaky_relu"),
341
+ attn_dropout (0.0).
342
+ Training:
343
+ optimizer ("adamw", "adam" or "sgd"), lr (1e-3), weight_decay (1e-4),
344
+ momentum (0.9, SGD only), batch_size (128), max_epochs (150), patience (20,
345
+ early stopping; 0 disables it), min_delta (1e-6), scheduler ("plateau",
346
+ "cosine" or "none"), lr_patience (5) and lr_factor (0.5) for "plateau",
347
+ grad_clip (None, max gradient norm), validation_fraction (0.15).
348
+ Other:
349
+ device ("auto", "cpu", "cuda", ...), random_state (None), verbose (0).
350
+ """
351
+
352
+
353
+ class PhasmaNetRegressor(_SkReg, _PhasmaNetBase):
354
+ """PhasmaNet for (multi-output) regression.
355
+
356
+ Targets are standardised internally. Regression options:
357
+ loss ("mse" or "huber"), huber_delta (1.0, in units of the standardised target),
358
+ weighting ("none", "by_hist" or "inverse_freq"; see below), hist_bins (20),
359
+ wmin (0.2), wmax (5.0), calibrate (False; monotonic PCHIP calibration fitted on
360
+ the validation split, single-output only), calibration_bins (60),
361
+ calibration_min_bin (20).
362
+
363
+ Weighting schemes over a histogram of the (first) training target:
364
+ "by_hist": the formula of the paper's training script, w = clip(1 / bin_count,
365
+ wmin, wmax), then normalised to mean 1.
366
+ "inverse_freq": w proportional to 1 / bin_count normalised to mean 1 first, then
367
+ clipped to [wmin, wmax] and renormalised, so rare target ranges are up-weighted.
368
+
369
+ Example:
370
+ >>> from phasmanet import PhasmaNetRegressor
371
+ >>> reg = PhasmaNetRegressor(num_stages=4, max_epochs=300, random_state=0).fit(X_train, y_train)
372
+ >>> y_pred = reg.predict(X_test)
373
+ """
374
+
375
+ def __init__(
376
+ self,
377
+ hidden_dim: int = 128,
378
+ num_tokens: int = 8,
379
+ num_stages: int = 3,
380
+ num_feedback: int = 2,
381
+ heads: int = 4,
382
+ dropout: float = 0.1,
383
+ num_refractor_subspaces: int = 4,
384
+ use_refractor: bool = True,
385
+ ffn_mult: int = 4,
386
+ activation: str = "gelu",
387
+ attn_dropout: float = 0.0,
388
+ loss: str = "mse",
389
+ huber_delta: float = 1.0,
390
+ weighting: str = "none",
391
+ hist_bins: int = 20,
392
+ wmin: float = 0.2,
393
+ wmax: float = 5.0,
394
+ calibrate: bool = False,
395
+ calibration_bins: int = 60,
396
+ calibration_min_bin: int = 20,
397
+ optimizer: str = "adamw",
398
+ lr: float = 1e-3,
399
+ weight_decay: float = 1e-4,
400
+ momentum: float = 0.9,
401
+ batch_size: int = 128,
402
+ max_epochs: int = 150,
403
+ patience: int = 20,
404
+ min_delta: float = 1e-6,
405
+ scheduler: str = "plateau",
406
+ lr_patience: int = 5,
407
+ lr_factor: float = 0.5,
408
+ grad_clip: Optional[float] = None,
409
+ validation_fraction: float = 0.15,
410
+ device: Optional[str] = "auto",
411
+ random_state: Optional[int] = None,
412
+ verbose: int = 0,
413
+ ):
414
+ self.hidden_dim = hidden_dim
415
+ self.num_tokens = num_tokens
416
+ self.num_stages = num_stages
417
+ self.num_feedback = num_feedback
418
+ self.heads = heads
419
+ self.dropout = dropout
420
+ self.num_refractor_subspaces = num_refractor_subspaces
421
+ self.use_refractor = use_refractor
422
+ self.ffn_mult = ffn_mult
423
+ self.activation = activation
424
+ self.attn_dropout = attn_dropout
425
+ self.loss = loss
426
+ self.huber_delta = huber_delta
427
+ self.weighting = weighting
428
+ self.hist_bins = hist_bins
429
+ self.wmin = wmin
430
+ self.wmax = wmax
431
+ self.calibrate = calibrate
432
+ self.calibration_bins = calibration_bins
433
+ self.calibration_min_bin = calibration_min_bin
434
+ self.optimizer = optimizer
435
+ self.lr = lr
436
+ self.weight_decay = weight_decay
437
+ self.momentum = momentum
438
+ self.batch_size = batch_size
439
+ self.max_epochs = max_epochs
440
+ self.patience = patience
441
+ self.min_delta = min_delta
442
+ self.scheduler = scheduler
443
+ self.lr_patience = lr_patience
444
+ self.lr_factor = lr_factor
445
+ self.grad_clip = grad_clip
446
+ self.validation_fraction = validation_fraction
447
+ self.device = device
448
+ self.random_state = random_state
449
+ self.verbose = verbose
450
+
451
+ def _prepare_targets(self, y, refit: bool = True) -> np.ndarray:
452
+ Y = np.asarray(y, dtype=np.float32)
453
+ if refit:
454
+ self._y_was_1d = Y.ndim == 1
455
+ Y = Y.reshape(len(Y), -1)
456
+ if not np.all(np.isfinite(Y)):
457
+ raise ValueError("y contains NaN or infinite values")
458
+ if refit:
459
+ self.n_outputs_ = Y.shape[1]
460
+ elif Y.shape[1] != self.n_outputs_:
461
+ raise ValueError("validation targets have a different number of outputs")
462
+ return Y
463
+
464
+ def _fit_target_scaler(self, Y_train: np.ndarray) -> None:
465
+ self.y_mean_ = Y_train.mean(axis=0, dtype=np.float64).astype(np.float32)
466
+ self.y_std_ = (Y_train.std(axis=0, dtype=np.float64) + 1e-9).astype(np.float32)
467
+
468
+ def _scale_targets(self, Y: np.ndarray) -> np.ndarray:
469
+ return ((Y - self.y_mean_) / self.y_std_).astype(np.float32)
470
+
471
+ def _scheme_weights(self, Y_train: np.ndarray) -> Optional[np.ndarray]:
472
+ scheme = str(self.weighting).lower()
473
+ if scheme == "none":
474
+ return None
475
+ if scheme not in ("by_hist", "inverse_freq"):
476
+ raise ValueError("weighting must be 'none', 'by_hist' or 'inverse_freq'")
477
+ y = Y_train[:, 0].astype(np.float64)
478
+ hist, edges = np.histogram(y, bins=self.hist_bins)
479
+ hist = np.maximum(hist, 1)
480
+ idx = np.clip(np.searchsorted(edges, y, side="right") - 1, 0, len(hist) - 1)
481
+ w = 1.0 / hist[idx]
482
+ if scheme == "inverse_freq":
483
+ w = w / (w.mean() + 1e-12)
484
+ w = np.clip(w, self.wmin, self.wmax)
485
+ return w / (w.mean() + 1e-12)
486
+
487
+ def fit(self, X, y, sample_weight=None, validation_data=None):
488
+ if self.loss not in ("mse", "huber"):
489
+ raise ValueError("loss must be 'mse' or 'huber'")
490
+ return super().fit(X, y, sample_weight=sample_weight, validation_data=validation_data)
491
+
492
+ def _output_dim(self) -> int:
493
+ return self.n_outputs_
494
+
495
+ def _loss(self, out, target):
496
+ if self.loss == "mse":
497
+ per = (out - target) ** 2
498
+ else:
499
+ per = nn.functional.smooth_l1_loss(out, target, beta=self.huber_delta, reduction="none")
500
+ return per.mean(dim=1)
501
+
502
+ def _after_fit(self, X_val, Y_val) -> None:
503
+ self.calibrator_ = MonotoneCalibrator()
504
+ if self.calibrate:
505
+ if X_val is None:
506
+ raise ValueError("calibrate=True needs a validation set (validation_fraction > 0 or validation_data)")
507
+ if self.n_outputs_ != 1:
508
+ raise ValueError("calibrate=True supports single-output regression only")
509
+ raw = self._predict_raw(X_val)[:, 0]
510
+ # The requested number of quantile bins, reduced for small validation sets.
511
+ bins = int(min(self.calibration_bins, max(3, len(raw) // max(1, self.calibration_min_bin))))
512
+ self.calibrator_.fit(raw, Y_val[:, 0], bins=bins, min_bin=self.calibration_min_bin)
513
+
514
+ def _predict_raw(self, X) -> np.ndarray:
515
+ out = self._forward_numpy(X)
516
+ return out * self.y_std_ + self.y_mean_
517
+
518
+ def predict(self, X, calibrated: bool = True) -> np.ndarray:
519
+ """Predict targets. Set ``calibrated=False`` to skip a fitted calibrator."""
520
+ Y = self._predict_raw(X)
521
+ cal = getattr(self, "calibrator_", None)
522
+ if calibrated and cal is not None and not cal.is_identity:
523
+ Y = cal(Y[:, 0]).reshape(-1, 1)
524
+ return Y[:, 0] if getattr(self, "_y_was_1d", True) and Y.shape[1] == 1 else Y
525
+
526
+ def score(self, X, y, sample_weight=None) -> float:
527
+ """Coefficient of determination R^2 (uniform average over outputs)."""
528
+ y = np.asarray(y, dtype=np.float64).reshape(len(np.asarray(y)), -1)
529
+ p = np.asarray(self.predict(X), dtype=np.float64).reshape(len(y), -1)
530
+ w = None if sample_weight is None else np.asarray(sample_weight, dtype=np.float64).reshape(-1, 1)
531
+ if w is None:
532
+ ss_res = ((y - p) ** 2).sum(axis=0)
533
+ ss_tot = ((y - y.mean(axis=0)) ** 2).sum(axis=0)
534
+ else:
535
+ mean = (w * y).sum(axis=0) / w.sum()
536
+ ss_res = (w * (y - p) ** 2).sum(axis=0)
537
+ ss_tot = (w * (y - mean) ** 2).sum(axis=0)
538
+ r2 = np.where(ss_tot > 0, 1.0 - ss_res / np.where(ss_tot > 0, ss_tot, 1.0), 0.0)
539
+ return float(r2.mean())
540
+
541
+ def _extra_state(self):
542
+ return {
543
+ "y_mean": torch.from_numpy(self.y_mean_),
544
+ "y_std": torch.from_numpy(self.y_std_),
545
+ "n_outputs": int(self.n_outputs_),
546
+ "y_was_1d": bool(self._y_was_1d),
547
+ "calibrator": self.calibrator_.to_dict(),
548
+ }
549
+
550
+ def _load_extra_state(self, state):
551
+ self.y_mean_ = state["y_mean"].numpy()
552
+ self.y_std_ = state["y_std"].numpy()
553
+ self.n_outputs_ = state["n_outputs"]
554
+ self._y_was_1d = state["y_was_1d"]
555
+ self.calibrator_ = MonotoneCalibrator.from_dict(state["calibrator"])
556
+
557
+
558
+ class PhasmaNetClassifier(_SkClf, _PhasmaNetBase):
559
+ """PhasmaNet for binary and multiclass classification (cross-entropy on logits).
560
+
561
+ Classification option:
562
+ class_weight (None or "balanced": weights inversely proportional to class
563
+ frequencies in the training split).
564
+
565
+ Example:
566
+ >>> from phasmanet import PhasmaNetClassifier
567
+ >>> clf = PhasmaNetClassifier(max_epochs=50, random_state=0).fit(X_train, y_train)
568
+ >>> proba = clf.predict_proba(X_test)
569
+ """
570
+
571
+ def __init__(
572
+ self,
573
+ hidden_dim: int = 128,
574
+ num_tokens: int = 8,
575
+ num_stages: int = 3,
576
+ num_feedback: int = 2,
577
+ heads: int = 4,
578
+ dropout: float = 0.1,
579
+ num_refractor_subspaces: int = 4,
580
+ use_refractor: bool = True,
581
+ ffn_mult: int = 4,
582
+ activation: str = "gelu",
583
+ attn_dropout: float = 0.0,
584
+ class_weight: Optional[str] = None,
585
+ optimizer: str = "adamw",
586
+ lr: float = 1e-3,
587
+ weight_decay: float = 1e-4,
588
+ momentum: float = 0.9,
589
+ batch_size: int = 128,
590
+ max_epochs: int = 150,
591
+ patience: int = 20,
592
+ min_delta: float = 1e-6,
593
+ scheduler: str = "plateau",
594
+ lr_patience: int = 5,
595
+ lr_factor: float = 0.5,
596
+ grad_clip: Optional[float] = None,
597
+ validation_fraction: float = 0.15,
598
+ device: Optional[str] = "auto",
599
+ random_state: Optional[int] = None,
600
+ verbose: int = 0,
601
+ ):
602
+ self.hidden_dim = hidden_dim
603
+ self.num_tokens = num_tokens
604
+ self.num_stages = num_stages
605
+ self.num_feedback = num_feedback
606
+ self.heads = heads
607
+ self.dropout = dropout
608
+ self.num_refractor_subspaces = num_refractor_subspaces
609
+ self.use_refractor = use_refractor
610
+ self.ffn_mult = ffn_mult
611
+ self.activation = activation
612
+ self.attn_dropout = attn_dropout
613
+ self.class_weight = class_weight
614
+ self.optimizer = optimizer
615
+ self.lr = lr
616
+ self.weight_decay = weight_decay
617
+ self.momentum = momentum
618
+ self.batch_size = batch_size
619
+ self.max_epochs = max_epochs
620
+ self.patience = patience
621
+ self.min_delta = min_delta
622
+ self.scheduler = scheduler
623
+ self.lr_patience = lr_patience
624
+ self.lr_factor = lr_factor
625
+ self.grad_clip = grad_clip
626
+ self.validation_fraction = validation_fraction
627
+ self.device = device
628
+ self.random_state = random_state
629
+ self.verbose = verbose
630
+
631
+ def _prepare_targets(self, y, refit: bool = True) -> np.ndarray:
632
+ y = np.asarray(y).ravel()
633
+ if refit:
634
+ self.classes_ = np.unique(y)
635
+ if len(self.classes_) < 2:
636
+ raise ValueError("need at least two classes")
637
+ idx = np.clip(np.searchsorted(self.classes_, y), 0, len(self.classes_) - 1)
638
+ if not np.all(self.classes_[idx] == y):
639
+ raise ValueError("y contains labels not seen during fit")
640
+ return idx.astype(np.int64)
641
+
642
+ def _scheme_weights(self, Y_train: np.ndarray) -> Optional[np.ndarray]:
643
+ if self.class_weight is None:
644
+ return None
645
+ if self.class_weight != "balanced":
646
+ raise ValueError("class_weight must be None or 'balanced'")
647
+ counts = np.bincount(Y_train, minlength=len(self.classes_)).astype(np.float64)
648
+ per_class = len(Y_train) / (len(self.classes_) * np.maximum(counts, 1.0))
649
+ return per_class[Y_train]
650
+
651
+ def _output_dim(self) -> int:
652
+ return len(self.classes_)
653
+
654
+ def _loss(self, out, target):
655
+ return nn.functional.cross_entropy(out, target, reduction="none")
656
+
657
+ def predict_proba(self, X) -> np.ndarray:
658
+ logits = torch.from_numpy(self._forward_numpy(X))
659
+ return torch.softmax(logits, dim=1).numpy()
660
+
661
+ def predict(self, X) -> np.ndarray:
662
+ return self.classes_[np.argmax(self._forward_numpy(X), axis=1)]
663
+
664
+ def score(self, X, y, sample_weight=None) -> float:
665
+ """Mean accuracy."""
666
+ correct = (self.predict(X) == np.asarray(y)).astype(np.float64)
667
+ if sample_weight is None:
668
+ return float(correct.mean())
669
+ w = np.asarray(sample_weight, dtype=np.float64)
670
+ return float((w * correct).sum() / w.sum())
671
+
672
+ def _extra_state(self):
673
+ return {"classes": self.classes_.tolist()}
674
+
675
+ def _load_extra_state(self, state):
676
+ self.classes_ = np.asarray(state["classes"])
677
+
678
+
679
+ PhasmaNetRegressor.__doc__ += _COMMON_DOC
680
+ PhasmaNetClassifier.__doc__ += _COMMON_DOC