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.
- phasmanet/__init__.py +23 -0
- phasmanet/_version.py +1 -0
- phasmanet/calibration.py +96 -0
- phasmanet/estimators.py +680 -0
- phasmanet/gw/__init__.py +20 -0
- phasmanet/gw/data/h1-aligo-xphm-1m.pt +0 -0
- phasmanet/gw/snr.py +220 -0
- phasmanet/model.py +197 -0
- phasmanet/py.typed +0 -0
- phasmanet-0.1.0.dist-info/METADATA +151 -0
- phasmanet-0.1.0.dist-info/RECORD +14 -0
- phasmanet-0.1.0.dist-info/WHEEL +5 -0
- phasmanet-0.1.0.dist-info/licenses/LICENSE +21 -0
- phasmanet-0.1.0.dist-info/top_level.txt +1 -0
phasmanet/estimators.py
ADDED
|
@@ -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
|