Skip to content

Fix DeepSVDD not training due to disabled backward pass - #704

Merged
yzhao062 merged 2 commits into
yzhao062:masterfrom
DMZ22:fix-deepsvdd-training
Jul 31, 2026
Merged

yzhao062 merged 2 commits into
yzhao062:masterfrom
DMZ22:fix-deepsvdd-training

Conversation

@DMZ22

@DMZ22 DMZ22 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

In DeepSVDD.fit() the training loop had loss.backward() commented out (issue #606):

                # loss.backward()
                optimizer.step()

With no backward pass, gradients are never computed, so optimizer.step() is a no-op — the network never learns and the anomaly scores come from the randomly initialised weights.

Simply uncommenting the line raises the error reported in #606:

RuntimeError: Trying to backward through the graph a second time ...

because the L2 penalty term w_d was built once before the epoch loop and then added to every batch's loss, so the second backward() re-traverses its already-freed graph.

Fix

  • Recompute w_d inside the batch loop, so it belongs to the current batch's graph (resolves the double-backward error).
  • Uncomment loss.backward().
  • Move the best-model bookkeeping out of the batch loop (issue Problems about deepsvdd #641): epoch_loss accumulates over the whole epoch, so comparing it to best_loss after every batch saved an early-in-epoch state instead of the best epoch.

Verification

On synthetic data (generate_data, 10 features, 10% contamination), scoring on a held-out set:

epoch 1 epoch 5 epoch 20 epoch 40 trains?
master (backward commented) 8.79 9.05 8.83 8.91 no (flat)
this PR 4.53 0.09 0.003 0.000 yes

The training loss now decreases smoothly instead of staying flat, pyod/test/test_deepsvdd.py passes (15/15), and ROC AUC is unaffected (~0.998).

Note

best_model_dict is still not loaded back into model_ by decision_function (scoring uses the final-epoch weights). I left that behaviour unchanged so this PR stays focused on the training bug, but I'm happy to wire it in as a follow-up if you'd like.

The DeepSVDD training loop had loss.backward() commented out, so no
gradients were ever computed and optimizer.step() was a no-op: the model
never learned and the anomaly scores reflected the randomly initialised
network (issue yzhao062#606).

Simply uncommenting loss.backward() raises "Trying to backward through the
graph a second time", because the w_d L2 penalty term was built once
before the epoch loop and then reused in every batch's loss, so the second
backward re-traverses its freed graph. Recomputing w_d inside the batch
loop makes it part of each batch's own graph and resolves this.

Also move the best-model bookkeeping out of the batch loop: epoch_loss is
accumulated over the whole epoch, so comparing it to best_loss after every
batch saved an early-in-epoch state rather than the best epoch (issue yzhao062#641).

Verified on synthetic data: training loss now decreases smoothly
(4.53 -> ~0 over 40 epochs) instead of staying flat (~8.8), the existing
test_deepsvdd.py suite passes, and ROC AUC is unaffected. Note that
best_model_dict is still not loaded back into model_ by decision_function;
that is left unchanged here.

Fixes yzhao062#606. Fixes yzhao062#641.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 80e9ed85f5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread pyod/models/deep_svdd.py
loss = torch.mean(dist) + w_d

# loss.backward()
loss.backward()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard SparseAdam from dense gradients

When optimizer='sparseadam' is selected from the public optimizer map, this newly enabled backward pass populates dense gradients for the nn.Linear-only network, and torch.optim.SparseAdam.step() rejects dense gradients at runtime. Please either remove/validate this optimizer choice for DeepSVDD or route it to an optimizer that supports dense parameters, otherwise that advertised option now crashes during fit().

Useful? React with 👍 / 👎.

Comment thread pyod/models/deep_svdd.py
loss = torch.mean(dist) + w_d

# loss.backward()
loss.backward()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the initialized center before backpropagating

With the newly enabled backward pass, the default c=None path now actually optimizes against self.c, but that attribute is still the scalar 0.0 because _init_c() stores the initialized center on self.model_.c instead. For ordinary default fits this trains the linear network toward an all-zero mapping rather than the initialized SVDD center, so longer training can collapse anomaly scores instead of learning the hypersphere. Please assign/detach the initialized center onto the detector before backpropagating.

Useful? React with 👍 / 👎.

Comment thread pyod/models/deep_svdd.py Outdated
# the batch loop, not inside it)
if epoch_loss < best_loss:
best_loss = epoch_loss
best_model_dict = self.model_.state_dict()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clone the best epoch state dict

When a later epoch is worse than the one recorded here, self.model_.state_dict() does not freeze the current tensors; it returns references to the module parameters, and subsequent optimizer.step() calls mutate the tensors held by best_model_dict. As a result, in any non-monotonic training run the advertised best-model bookkeeping still ends up holding the final weights rather than the best epoch. Please deep-copy or clone the state dict at the point it is selected.

Useful? React with 👍 / 👎.

state_dict() returns references to the live parameters, so the following
epoch's optimizer.step() mutated the dict that was meant to record the
best epoch. Deep-copy it so best_model_dict holds the weights it claims to.
@DMZ22

DMZ22 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — I worked through all three of the automated suggestions. Two are real, one is real but pre-existing and unrelated, and the third turned out to be more serious than described. Details, since the last one affects how you may want to sequence this PR.

3. Cloning the best epoch's state dict — real, fixed in 3a5606b.

Confirmed: state_dict() returns references to the live parameters, so the next optimizer.step() mutated the dict in place and best_model_dict ended up holding the final weights rather than the best ones. Now deep-copied. test_deepsvdd.py still passes 15/15.

2. optimizer='sparseadam' — real, but pre-existing and not caused by this PR.

The stated mechanism isn't what happens. It never reaches step():

optimizer_dict['sparseadam'](net.parameters(), weight_decay=1e-5)
-> TypeError: SparseAdam.__init__() got an unexpected keyword argument 'weight_decay'

SparseAdam has no weight_decay parameter, and line 337 passes it unconditionally, so that option fails at construction on master too, with or without the backward pass. Happy to send a separate PR that either drops sparseadam from optimizer_dict or only forwards weight_decay to optimizers that accept it — just say which you'd prefer.

1. The centre — real, and worth pausing on.

Confirmed: _init_c() assigns to the inner module, so self.model_.c gets the initialised centre while the detector's self.c stays at the 0.0 set on line 319. Both the training loss and decision_function() read self.c, so the initialised centre is currently dead code.

That matters much more now that the backward pass actually runs. With c = 0.0 and a bias-free network, the objective's global optimum is the trivial one — map everything to zero. That is the hypersphere collapse the DeepSVDD paper warns about, and it is exactly what happens at the default epochs=100:

epochs final loss score spread AUC
20 3.3e-04 2.2e-04 1.0000
100 8.3e-10 0.0 0.5000

At 100 epochs every embedding is identically zero, so every anomaly score is zero and the detector is degenerate. My earlier "loss → ~0" validation note was, in hindsight, measuring the collapse rather than convergence — apologies, I should have caught that.

I tried the obvious fix (self.c = self.model_.c.detach(), detached because the centre is computed by a forward pass and would otherwise trigger "backward through the graph a second time" on the next batch). It stops the collapse — embeddings stay around 4e-02 and the scores keep their spread — but it is not a two-line change, because c = 0.0 was silently broadcasting and masking a shape incompatibility. With use_ae=True the whole suite fails:

RuntimeError: The size of tensor a (300) must match the size of tensor b (4)
             at non-singleton dimension 1

forward() returns the reconstruction (n_features) when use_ae=True, whereas the centre lives in the bottleneck (hidden_neurons[-1]). Making the centre real therefore requires the loss to use the bottleneck embedding explicitly, which means changing what forward() exposes — a design decision that's yours, not mine, so I've left it out of this PR.

I should also be straight about the ceiling: with the centre wired up correctly I measured mean AUC ≈ 0.50 across 5 seeds at the default 100 epochs (range 0.20–0.77), essentially unchanged whether the centre is taken pre- or post-activation. So the centre alone does not make this a good detector — there is more wrong here than one PR can responsibly fix.

Given that, how would you like to proceed?

  • Merge this as-is — it is the mechanical fix for DeepSVDD loss backward is commented #606/Problems about deepsvdd #641 (the backward pass, the autograd graph, the best-model bookkeeping) and I follow up with a separate PR for the centre and the bottleneck/reconstruction split; or
  • Hold this PR and I extend it with the centre fix so that training and a non-degenerate objective land together, at the cost of a larger and more opinionated diff.

I'd lean towards the first, with the follow-up opened immediately, but I'm happy either way. Either way it seems worth tracking the collapse separately, since it is a real consequence of turning training on.

@yzhao062
yzhao062 merged commit 7d2f712 into yzhao062:master Jul 31, 2026
14 checks passed
yzhao062 added a commit that referenced this pull request Aug 1, 2026
#704 restored the commented-out loss.backward(), which exposed three
pre-existing defects it had been masking:

- fit() set self.c = 0.0 while _init_c() wrote the computed center to the
  inner module, so the objective never saw it. For a bias-free ReLU network
  the all-zero weights map every input to 0, making c=0 exactly the trivial
  solution of Ruff et al., ICML 2018, Proposition 1. On a 17-dataset ODDS
  benchmark the merged state returned a single distinct score on 10 of 17
  datasets while still clearing a ROC floor via 1e-24 floating-point noise.
- The optimizer was built with no learning rate (Adam default 1e-3) and
  passed l2_regularizer=0.1 as weight_decay, five orders of magnitude above
  the reference 5e-7; that decay drives weights toward zero and compounds
  the collapse.
- best_model_dict was stored but never loaded back, so scoring used
  final-epoch weights.

fit() also wrote the fitted 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.

Changes: the fitted center now lives in c_ while c stays configuration; a
user-supplied center is validated (width, finiteness, not all zeros) and
copied so a later mutation cannot change the fitted estimator; l2_regularizer
defaults to 5e-7 and a new learning_rate defaults to 1e-4 (both match the
reference implementation; the l2_regularizer default change is API-visible);
the best-epoch snapshot is applied before scoring.

Measured on the 17-dataset benchmark: mean ROC AUC 0.601 -> 0.748 with score
collapse eliminated on all 17. Calibration: 0.748 is level with the 0.746 of
the untrained network, so this restores the baseline rather than improving on
it. Deep SVDD assumes a clean one-class training set while PyOD fits it
unsupervised on contaminated data; training on genuinely normal samples only
reaches 0.879. Documented in the class docstring and CHANGES.

Tests: 7 new, each verified to fail on the pre-fix code where applicable --
score-diversity (the collapse detector, both standard and AE paths), center
validity, sklearn-contract (c untouched, get_params/clone clean), refit
re-initialization, all-zero rejection, and custom-center validation/copying.
test_fit_changes_parameters is scoped to the original missing-backward defect
and documented as not detecting collapse.

Also fixes the README icon to an absolute URL so it renders on PyPI (#705).

Reviewed via /implement-review (Codex, 3 rounds: Block -> Block -> shippable).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants