Skip to content

Commit 2c0e54c

Browse files
committed
Add history_ attribute to DNN models for training loss tracking
Fixes #636 - Add self.history_ dict ({'loss': [...]}) to BaseDeepLearningDetector and all standalone DNN-based detectors (AutoEncoder, VAE, ALAD, AnoGAN, DeepSVDD, DevNet, LUNAR, MO_GAAL, SO_GAAL, TS-LSTM, TS-AnomalyTransformer, AE1SVM) - Update docstrings to document the new attribute - Add unit tests verifying history_['loss'] is populated per epoch - No changes to existing verbose printing behavior
1 parent 4b4fbbb commit 2c0e54c

21 files changed

Lines changed: 118 additions & 5 deletions

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,4 @@ v<3.6.0>, <06/04/2026> -- Add audio as a first-class anomaly detection modality.
232232
v<3.6.1>, <06/16/2026> -- Maintenance and contributor PRs since v3.6.0 (no breaking API changes). PyThresh v1 support (#684, Daniel Kulik): the pyod.models.thresholds wrappers and the BaseDetector threshold path use the pythresh v1 API (.fit()/.labels_/.predict() instead of .eval()), and the dependency pin moves to pythresh>=1.0.0. EmbeddingOD air-gapped and pre-instantiated encoder support (#696, Sunny Guntuka): a pre-loaded SentenceTransformer instance can be passed directly as the encoder, and a local filesystem path is loaded with local_files_only=True (no Hub call) for offline use; this also fixes a resolver-order bug where a SentenceTransformer instance was wrapped as a CallableEncoder (calling model(X) instead of model.encode(X)). DataFrame feature-name warning fix (#692, eferhire ugbotu; closes #540): GMM, IForest, LOF, and OCSVM run check_array in decision_function, so scoring a pandas DataFrame after fitting no longer emits the scikit-learn feature-name UserWarning; the predict/predict_proba/predict_confidence paths route through decision_function, so the single-point fix covers them. Audio docs: AudioAE and the audio modality are now in the README and docs algorithm tables with a new pyod.models.audio API page (v3.6.0 shipped AudioAE without a table row). Tests: regression tests for the DataFrame-warning fix (GMM/OCSVM/LOF) and a no-download EmbeddingOD resolver test using SentenceTransformer(modules=[]). Docs: PyPI download figure refreshed to 46M+. Reviewed via /implement-review (Codex gatekeeper): #696 and #692 merged after dual review, with the gatekeeper-flagged test gaps closed in a follow-up. Buildable detector count unchanged at 61.
233233
v<3.6.2>, <07/20/2026> -- Security hardening for model persistence (CVE-2026-15529): both `pyod.utils.persistence.load()` and `pyod.utils.persistence.compat_load()` now refuse to deserialize pickle/joblib artifacts unless callers pass `trusted=True`, making the trust boundary explicit before `joblib.load()` can execute pickle reducers. This is a breaking change: existing `save()` then `load()` round trips, and any direct `compat_load()` calls, must add `trusted=True`, because both functions now refuse even artifacts you saved yourself until the caller acknowledges the source is trusted. The internal `load()` fall-through to `compat_load()` forwards the acknowledgement automatically, so `load(path, trusted=True)` still recovers legacy dtype-mismatched artifacts in a single call. `strict=True` remains a dependency-version policy and is documented as not making untrusted pickle files safe. Adds regression tests verifying that both the default `load()` path and a direct `compat_load()` call reject before any unpickling side effects occur. Updates the model persistence guide, decision tree, troubleshooting table, and save/load example to pass `trusted=True` for trusted artifacts. Closes #697.
234234
v<3.6.3>, <08/01/2026> -- DeepSVDD correctness fixes, contributor bug fixes, and project infrastructure. DeepSVDD hypersphere-collapse fix (builds on #704 by Devashish Moghe; closes #606 and #641): the training loop had `loss.backward()` commented out, so no gradient ever reached the weights and scores came from the randomly initialized network. #704 restored the backward pass, recomputing the L2 term `w_d` inside the batch loop so each step gets a fresh graph, moving the best-model bookkeeping out of the batch loop, and deep-copying `state_dict()` (which otherwise aliases the live parameters). Enabling training then exposed three pre-existing defects that the commented-out line had been masking. First, `fit()` assigned the estimator's center `self.c = 0.0` and let `_init_c()` write the computed center to the inner module, which the loss and scoring never read; for a bias-free ReLU network the all-zero weights map every input to 0, so `c = 0` is exactly the trivial solution of Ruff et al., ICML 2018, Proposition 1, and training converged to a collapsed hypersphere. On a 17-dataset ODDS benchmark (3 seeds, 30 epochs) the collapsed configuration returned a single distinct score on 10 of 17 datasets -- a detector that is silently useless while still meeting a ROC floor, because ordering can be induced by floating-point noise as small as 1e-24. The center is now taken from `_init_c()`, detached, and stored where the objective reads it. Second, the optimizer was constructed without a learning rate (Adam's default 1e-3) and passed `l2_regularizer=0.1` as `weight_decay`, five orders of magnitude above the 5e-7 used by the reference implementation; that decay drives the weights toward zero and compounds the collapse. `l2_regularizer` now defaults to 5e-7 and a new `learning_rate` parameter defaults to 1e-4 (both match the reference implementation; the `l2_regularizer` default change is API-visible). Third, `best_model_dict` was stored but never loaded, so scoring used final-epoch weights; the snapshot is now applied before scoring. Fourth, `fit()` wrote the computed center into the constructor parameter `c`, which leaked fitted state through `get_params()`, made `clone()` start pre-seeded, and made a refit train a newly built network against the previous fit's center; the fitted center now lives in `c_`, `c` stays configuration, and a user-supplied center is validated before training: a scalar is expanded to the network output width, a vector must match that width (`hidden_neurons[-1]`, or `n_features` when `use_ae=True`), non-finite values are rejected, an all-zero center is rejected with a `ValueError` because it is exactly the Proposition 1 trivial-solution condition, and the tensor is copied so a later mutation of the caller's array cannot change the fitted center. Measured effect on the 17-dataset benchmark: mean ROC AUC rises from 0.601 (collapsed) to 0.748, with score collapse eliminated on all 17 datasets. Calibration note: 0.748 is level with the 0.746 obtained by the untrained network, so these changes restore DeepSVDD to its baseline rather than improving on it. Deep SVDD assumes a clean one-class training set, while PyOD fits it on contaminated data unsupervised; in a controlled comparison, training on genuinely normal samples only reaches 0.879 and trimming the most anomalous 10% before training reaches 0.800, so a substantial part of the gap tracks the mismatch between the method's one-class assumption and unsupervised use on contaminated data. These two diagnostics do not rule out further defects or tuning gains. Tests: `test_scores_are_not_constant` asserts score diversity on both the standard and autoencoder paths and `test_center_is_valid` asserts the center is a detached, finite, non-scalar tensor; both fail on the collapsed code. `test_fit_changes_parameters` pins the original backward-pass defect only, and does not detect collapse. The LOCI and generate_data contributor PRs remain under review and are not included here. Contributor PRs: local encoder paths now fall back to the HuggingFace backend when sentence-transformers is absent (#701, Sunny Guntuka; follow-up to #696), so a `pyod[huggingface]`-only install no longer raises `ImportError` before the existing fallback can run; save/load round-trip coverage now spans 23 detectors (#708, Jayesh Suryavanshi; closes #269), up from 2, while 21 of them also gain clone assertions for unfitted equivalence and refit score reproduction, strengthening per-detector `test_model_clone` methods that previously only checked that `clone()` did not raise. Test fix (#710): `test_resolve_st_instance_no_download` built its no-download model with `SentenceTransformer(modules=[])`, which sentence-transformers 5.6 rejects; it now passes a single trivial `torch.nn.Identity` module instead, which keeps the test network-free without depending on `sentence_transformers.models` keyword arguments that have been renamed across 5.x. Project infrastructure (#705): pyod.dev website badge in the README (using an absolute image URL so it renders on the PyPI project page), brand assets under `brand/` plus Sphinx `docs/_static/` copies, and a `SECURITY.md` vulnerability-reporting policy.
235+
v<3.6.4>, <08/01/2026> -- Add history_ attribute (dict containing 'loss') to deep-learning-based detectors (BaseDeepLearningDetector models including AutoEncoder and VAE, as well as DeepSVDD, AE1SVM, DevNet, LUNAR, AnomalyTransformer, and TSLSTM) for retrieving epoch training loss post-fit (issue #636).

pyod/models/ae1svm.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ def fit(self, X, y=None):
275275
"""
276276
X = check_array(X)
277277
self._set_n_classes(y)
278+
self.history_ = {'loss': []}
278279

279280
n_samples, n_features = X.shape
280281
if self.preprocessing:
@@ -338,12 +339,14 @@ def _train_autoencoder(self, train_loader):
338339
loss.backward()
339340
optimizer.step()
340341
overall_loss.append(loss.item())
342+
epoch_loss = float(np.mean(overall_loss))
343+
self.history_['loss'].append(epoch_loss)
341344
if (epoch + 1) % 10 == 0:
342345
print(
343-
f'Epoch {epoch + 1}/{self.epochs}, Loss: {np.mean(overall_loss)}')
346+
f'Epoch {epoch + 1}/{self.epochs}, Loss: {epoch_loss}')
344347

345-
if np.mean(overall_loss) < self.best_loss:
346-
self.best_loss = np.mean(overall_loss)
348+
if epoch_loss < self.best_loss:
349+
self.best_loss = epoch_loss
347350
self.best_model_dict = self.model.state_dict()
348351

349352
def decision_function(self, X):

pyod/models/alad.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ class ALAD(BaseDetector):
128128
The binary labels of the training data. 0 stands for inliers
129129
and 1 for outliers/anomalies. It is generated by applying
130130
``threshold_`` on ``decision_scores_``.
131+
132+
history_ : dict
133+
Training history containing GAN loss histories ('discriminator_loss', 'generator_loss').
131134
"""
132135

133136
def __init__(self, activation_hidden_gen='tanh',
@@ -257,6 +260,8 @@ def create_discriminator(layers, input_dim):
257260

258261
self.hist_loss_disc = []
259262
self.hist_loss_gen = []
263+
self.history_ = {'discriminator_loss': self.hist_loss_disc,
264+
'generator_loss': self.hist_loss_gen}
260265

261266
def train_step(self, data):
262267
x_real, z_real = data
@@ -354,6 +359,8 @@ def fit(self, X, y=None, noise_std=0.1):
354359
# Get number of sampels and features from train set
355360
self.n_samples_, self.n_features_ = X.shape[0], X.shape[1]
356361
self._build_model()
362+
self.history_ = {'discriminator_loss': self.hist_loss_disc,
363+
'generator_loss': self.hist_loss_gen}
357364

358365
# Apply data scaling or not
359366
if self.preprocessing:

pyod/models/anogan.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ class AnoGAN(BaseDetector):
186186
The binary labels of the training data. 0 stands for inliers
187187
and 1 for outliers/anomalies. It is generated by applying
188188
``threshold_`` on ``decision_scores_``.
189+
190+
history_ : dict
191+
Training history containing GAN loss histories ('generator_loss', 'discriminator_loss').
189192
"""
190193

191194
def __init__(self, activation_hidden='tanh', dropout_rate=0.2,
@@ -217,6 +220,8 @@ def __init__(self, activation_hidden='tanh', dropout_rate=0.2,
217220

218221
self.hist_loss_generator = []
219222
self.hist_loss_discriminator = []
223+
self.history_ = {'generator_loss': self.hist_loss_generator,
224+
'discriminator_loss': self.hist_loss_discriminator}
220225

221226
self.device = device
222227

@@ -261,9 +266,12 @@ def fit(self, X, y=None):
261266
self : object
262267
Fitted estimator.
263268
"""
264-
# validate inputs X and y (optional)
265269
X = check_array(X)
266270
self._set_n_classes(y)
271+
self.hist_loss_generator = []
272+
self.hist_loss_discriminator = []
273+
self.history_ = {'generator_loss': self.hist_loss_generator,
274+
'discriminator_loss': self.hist_loss_discriminator}
267275

268276
# Verify and construct the hidden units
269277
self.n_samples_, self.n_features_ = X.shape

pyod/models/auto_encoder.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ class AutoEncoder(BaseDeepLearningDetector):
100100
criterion : torch.nn.modules
101101
The loss function used to train the model.
102102
103+
history_ : dict
104+
Training history containing 'loss' (list of loss values per epoch).
105+
103106
decision_scores_ : numpy array of shape (n_samples,)
104107
The outlier scores of the training data.
105108
The higher, the more abnormal. Outliers tend to have higher

pyod/models/base_dl.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ class BaseDeepLearningDetector(BaseDetector):
9999
criterion_params : dict, optional (default=None)
100100
Additional parameters for the criterion.
101101
For example, `criterion_params={'reduction': 'sum'}`.
102+
103+
Attributes
104+
----------
105+
history_ : dict
106+
Training history containing 'loss' (list of loss values per epoch).
102107
"""
103108

104109
def __init__(self,
@@ -130,6 +135,7 @@ def __init__(self,
130135
self.X_std = None
131136
self.data_num = None
132137
self.feature_size = None
138+
self.history_ = {'loss': []}
133139

134140
if (isinstance(contamination, (float, int))):
135141
if not (0. < contamination <= 0.5):
@@ -178,6 +184,7 @@ def fit(self, X, y=None):
178184
# validate inputs X and y (optional)
179185
X = check_array(X)
180186
self._set_n_classes(y)
187+
self.history_ = {'loss': []}
181188

182189
self.data_num, self.feature_size = X.shape
183190
self.build_model()
@@ -240,6 +247,8 @@ def train(self, train_loader):
240247
else:
241248
overall_loss = np.mean(overall_loss)
242249

250+
self.history_['loss'].append(overall_loss)
251+
243252
# loss could be a tuple or a single value
244253
if self.verbose == 2:
245254
if isinstance(loss, (tuple, list)):

pyod/models/deep_svdd.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ class DeepSVDD(BaseDetector):
234234
235235
Attributes
236236
----------
237+
history_ : dict
238+
Training history containing 'loss' (list of loss values per epoch).
239+
237240
decision_scores_ : numpy array of shape (n_samples,)
238241
The outlier scores of the training data.
239242
The higher, the more abnormal. Outliers tend to have higher
@@ -312,6 +315,7 @@ def fit(self, X, y=None):
312315
# validate inputs X and y (optional)
313316
X = check_array(X)
314317
self._set_n_classes(y)
318+
self.history_ = {'loss': []}
315319

316320
# Verify and construct the hidden units
317321
self.n_samples_, self.n_features_ = X.shape[0], X.shape[1]
@@ -413,6 +417,7 @@ def fit(self, X, y=None):
413417
loss.backward()
414418
optimizer.step()
415419
epoch_loss += loss.item()
420+
self.history_['loss'].append(epoch_loss)
416421
# keep the best performing model across epochs (epoch_loss is the
417422
# accumulated loss over the whole epoch, so this must run after
418423
# the batch loop, not inside it)

pyod/models/devnet.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ def __init__(self,
234234
"cuda:0" if torch.cuda.is_available() else "cpu")
235235

236236
def fit(self, X, y):
237+
self.history_ = {'loss': []}
237238
outlier_indices = np.where(y == 1)[0]
238239
inlier_indices = np.where(y == 0)[0]
239240
n_outliers = len(outlier_indices)
@@ -257,6 +258,7 @@ def fit(self, X, y):
257258
def train_model(model, data_loader, epochs):
258259
model.train()
259260
for epoch in range(epochs):
261+
overall_loss = []
260262
for data, labels in data_loader:
261263
data, labels = data.to(torch.float32), labels.to(
262264
torch.float32) # Ensure data types
@@ -265,7 +267,10 @@ def train_model(model, data_loader, epochs):
265267
loss = deviation_loss(outputs, labels)
266268
loss.backward()
267269
optimizer.step()
268-
print(f'Epoch {epoch + 1}, Loss: {loss.item()}')
270+
overall_loss.append(loss.item())
271+
epoch_loss = float(np.mean(overall_loss))
272+
self.history_['loss'].append(epoch_loss)
273+
print(f'Epoch {epoch + 1}, Loss: {epoch_loss}')
269274

270275
# Training the model
271276
train_model(self.model, train_loader, epochs=self.epochs)

pyod/models/lunar.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ def fit(self, X, y=None):
379379
weight_decay=self.wd)
380380
# for early stopping
381381
best_val_score = 0
382+
self.history_ = {'loss': []}
382383
# model training
383384
for epoch in range(self.n_epochs):
384385

@@ -418,6 +419,7 @@ def fit(self, X, y=None):
418419
loss = criterion(out, train_y).sum()
419420
loss.backward()
420421
optimizer.step()
422+
self.history_['loss'].append(loss.item())
421423

422424
# print best model after training
423425
if self.verbose == 1:

pyod/models/mo_gaal.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ class MO_GAAL(BaseDetector):
7878
The binary labels of the training data. 0 stands for inliers
7979
and 1 for outliers/anomalies. It is generated by applying
8080
``threshold_`` on ``decision_scores_``.
81+
82+
Attributes
83+
----------
84+
history_ : dict
85+
Training history containing GAN loss histories (e.g. 'discriminator_loss',
86+
'generator_loss', and sub-generator losses).
8187
"""
8288

8389
def __init__(self, k=10, stop_epochs=20, lr_d=0.01, lr_g=0.0001,
@@ -112,6 +118,7 @@ def fit(self, X, y=None):
112118
X = check_array(X)
113119
self._set_n_classes(y)
114120
self.train_history = defaultdict(list)
121+
self.history_ = self.train_history
115122
names = locals()
116123
epochs = self.stop_epochs * 3
117124
latent_size = X.shape[1]

0 commit comments

Comments
 (0)