Skip to content

fix(dif): avoid double min-max normalization in fit (closes #546)#675

Merged
yzhao062 merged 1 commit into
yzhao062:developmentfrom
jbbqqf:feat/546-dif-double-normalization
May 12, 2026
Merged

fix(dif): avoid double min-max normalization in fit (closes #546)#675
yzhao062 merged 1 commit into
yzhao062:developmentfrom
jbbqqf:feat/546-dif-double-normalization

Conversation

@jbbqqf
Copy link
Copy Markdown
Contributor

@jbbqqf jbbqqf commented May 9, 2026

Summary

DIF.fit min-max-scaled X and then called self.decision_function(X)
on the already-scaled X. decision_function transforms the input
again, so decision_scores_ was computed on data that had been pushed
through MinMaxScaler.transform twice. The values therefore disagreed
with decision_function(X_train) for the very same input — a
violation of the BaseDetector contract that several downstream
utilities rely on (e.g. predict_proba's 'unify' mode).

Fixes #546DIF model: duplicate normalization

Context

The reporter traced it to three lines:

  • pyod/models/dif.py line 178 — X = self.minmax_scaler.transform(X) inside fit
  • the same fit then calls self.decision_function(X) on that scaled X
  • decision_function (line 245) re-applies self.minmax_scaler.transform(X)

That second transform shifts an already [0, 1]-mapped array by another
min-max scaling, which collapses the dynamic range and produces
ensemble scores that no other code path reproduces.

The minimal-blast-radius fix is to retain the raw input as X_raw and
hand it to decision_function, which does its own (single) transform.

Changes

  • pyod/models/dif.py
    • Keep X_raw = X before the in-place reassignment to the scaled
      view, with a comment explaining why so a future refactor cannot
      silently re-introduce the bug.
    • End of fit: self.decision_scores_ = self.decision_function(X_raw)
      (was self.decision_function(X) where X had already been scaled).
  • pyod/test/test_dif.py
    • Add test_train_scores_match_decision_function — asserts
      decision_scores_ ≈ decision_function(X_train) for the fitted
      detector. Fails on master, passes on this branch.

Reproduce BEFORE/AFTER yourself (copy-paste)

# --- one-time setup ---
git clone https://github.com/yzhao062/pyod.git /tmp/repro && cd /tmp/repro
python -m venv .venv && . .venv/bin/activate
pip install -e . torch --extra-index-url https://download.pytorch.org/whl/cpu

# --- BEFORE (origin/master) ---
git checkout origin/master
python -c "
import numpy as np
from pyod.models.dif import DIF
from pyod.utils.data import generate_data
X, _, _, _ = generate_data(n_train=400, n_test=1, n_features=5,
                            contamination=0.1, random_state=42)
clf = DIF(n_ensemble=3, n_estimators=20).fit(X)
delta = np.max(np.abs(clf.decision_scores_ - clf.decision_function(X)))
print('max |decision_scores_ - decision_function(X_train)| =', delta)
"
# Expected: a non-trivial mismatch (>= 1e-2) — the two-paths-disagree bug

# --- AFTER (this PR) ---
git fetch https://github.com/jbbqqf/pyod.git feat/546-dif-double-normalization
git checkout FETCH_HEAD
python -c "
import numpy as np
from pyod.models.dif import DIF
from pyod.utils.data import generate_data
X, _, _, _ = generate_data(n_train=400, n_test=1, n_features=5,
                            contamination=0.1, random_state=42)
clf = DIF(n_ensemble=3, n_estimators=20).fit(X)
delta = np.max(np.abs(clf.decision_scores_ - clf.decision_function(X)))
print('max |decision_scores_ - decision_function(X_train)| =', delta)
"
# Expected: ~0.0 (within float tolerance)

What I ran locally

  • pytest pyod/test/test_dif.py -q -> 16 passed (15 prior + 1 new
    regression test).
  • The new test, run against unmodified master, fails with a
    ~10⁻¹ scale mismatch (verified by git stash-ing the dif.py change).

Edge cases tested

# Scenario Input Expected Verified by
1 decision_scores_ agrees with decision_function DIF().fit(X).decision_function(X) match within 1e-5 rel tol new test_train_scores_match_decision_function
2 Ranking unchanged on test data DIF().fit(X_train).decision_function(X_test) ROC-AUC stays >= 0.8 existing test_prediction_scores
3 predict thresholding still works DIF().fit(X_train).predict(X_test) shape == y_test.shape existing test_prediction_labels

Risk / blast radius

The change only affects which X is fed to decision_function at the
tail of fit. The transform-once-then-score path is exactly the one
already exercised by all decision_function/predict callers on test
data, so no behavior change there. decision_scores_ itself now lives
on the same scale as decision_function(X_train), which is the
documented invariant of BaseDetector. predict_proba(method='unify')
becomes meaningful for DIF (it relied on that invariant); other modes
were unaffected because they renormalize.

Release note

fix(dif): `DIF.decision_scores_` now matches `decision_function(X_train)`. Previously the training-time scores were computed on doubly min-max-scaled inputs (#546).

PR template checklist (per PULL_REQUEST_TEMPLATE.md)

  • Followed CONTRIBUTING guidance (small, focused, with regression test).
  • No other open PRs for this change (searched repo).
  • Linked to a specific issue: DIF model: duplicate normalization #546.
  • Explanation included above.
  • New regression test added (test_train_scores_match_decision_function).
  • Tests run locally: pytest pyod/test/test_dif.py -q -> 16 passed.
  • CI (CircleCI / Travis / AppVeyor): will be exercised by repo workflows once the PR runs.
  • Code coverage: net-positive (one new test method, no removed executable lines).
  • Lint: comment-only and one-line change in source; no new warnings.

PR drafted with assistance from Claude Code (Anthropic). The change was
reviewed manually against pyod's source. The reproducer block above is
the one I used during development; reviewers can paste it verbatim.

)

`DIF.fit` min-max-scaled `X` then called `self.decision_function(X)` on
the already-scaled `X`. `decision_function` transforms the input again,
so `self.decision_scores_` was computed on a re-scaled (effectively
in-range constant) view of the data and disagreed with
`decision_function(X_train)` for the same `X`.

Fix: keep the raw `X` around as `X_raw` and call
`self.decision_function(X_raw)` at the end of `fit`, with a comment
explaining why so a future refactor can't re-introduce the bug.

Regression test (`test_train_scores_match_decision_function`) asserts
`decision_scores_ == decision_function(X_train)` on the fitted detector.

AI disclosure: drafted with assistance from Claude (Anthropic).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@yzhao062 yzhao062 changed the base branch from master to development May 12, 2026 05:39
@yzhao062 yzhao062 merged commit 7144394 into yzhao062:development May 12, 2026
yzhao062 added a commit that referenced this pull request May 13, 2026
Bump version to 3.5.1 and add CHANGES.txt entry summarizing the
patch release: the jbbqqf bundle (#673/#674/#675/#676/#677), the
tuanaiseo GAAL torch-optional fix (#660) with our follow-up across
mo_gaal/so_gaal/so_gaal_new, and the NSF POSE Phase II funding
acknowledgment. Issues closed: #502 #546 #635 #638 #640. No new
public API and no breaking changes.
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.

DIF model: duplicate normalization

2 participants