Skip to content

Make LOCI honor contamination and keep k as a parameter - #707

Open
Mohit-Ak wants to merge 1 commit into
yzhao062:developmentfrom
Mohit-Ak:fix/loci-contamination-threshold
Open

Make LOCI honor contamination and keep k as a parameter#707
Mohit-Ak wants to merge 1 commit into
yzhao062:developmentfrom
Mohit-Ak:fix/loci-contamination-threshold

Conversation

@Mohit-Ak

Copy link
Copy Markdown
Contributor

LOCI accepts a contamination argument and documents threshold_ as "based on
contamination ... the n_samples * contamination most abnormal samples", but
the value never reaches the thresholding logic. In __init__ the other parameter,
k, is written straight into self.threshold_:

def __init__(self, contamination=0.1, alpha=0.5, k=3):
    super(LOCI, self).__init__(contamination=contamination)
    self.alpha = alpha
    self.threshold_ = k        # <- k, not contamination

and fit() then labels against that same k, so contamination is dead weight.
Sweeping it changes nothing:

contamination=0.05  -> outlier frac=0.1583   threshold_=3
contamination=0.1   -> outlier frac=0.1583   threshold_=3
contamination=0.2   -> outlier frac=0.1583   threshold_=3
contamination=0.3   -> outlier frac=0.1583   threshold_=3
contamination=0.45  -> outlier frac=0.1583   threshold_=3

There's a second consequence of the same line. Because k is consumed instead of
stored, LOCI has no k attribute, so scikit-learn's get_params falls back to
None and a cloned estimator is silently broken:

>>> clf = LOCI(k=5)
>>> hasattr(clf, "k")
False
>>> clone(clf).fit(X)
TypeError: unsupported operand type(s) for *: 'NoneType' and 'float'

The existing test_model_clone doesn't catch this because it only clones, it never
fits the clone.

What this changes

Three small edits, all in loci.py:

  • __init__ stores self.k = k instead of overwriting threshold_.
  • The score loop's early-break test uses self.k — that's the outlier cutoff k
    is documented to be, and it's the only place it was ever really used.
  • fit() calls self._process_decision_scores() instead of hand-rolling
    labels_/_mu/_sigma. That's the base-class helper every other detector in
    the package uses (lof, cof, sod, ...), and it derives threshold_ from
    contamination via the documented percentile rule.

The stale k=None in the class docstring's example repr is updated to k=3 to
match the actual default.

Scores are untouched — decision_function and _calculate_decision_score compute
exactly what they did before for a given k. Only the threshold used to turn those
scores into binary labels changes, which is the reported bug.

Testing

Ran the module's suite in a clean venv (numpy/scipy/scikit-learn/numba, pip install -e .):

$ python -m pytest pyod/test/test_loci.py -q
20 passed, 7 warnings in 138.83s

That includes three new cases added to TestLOCI, each of which fails on master
before the change:

  • test_k_is_stored_as_parameterAttributeError: 'LOCI' object has no attribute 'k'
  • test_contamination_controls_labels — all three contamination values produced the
    identical outlier fraction
  • test_threshold_matches_contaminationACTUAL: 0.14 DESIRED: 0.25

After the change the same sweep tracks the requested rate:

contamination=0.05  -> outlier frac=0.0500
contamination=0.1   -> outlier frac=0.1000
contamination=0.2   -> outlier frac=0.2000
contamination=0.3   -> outlier frac=0.3000
contamination=0.45  -> outlier frac=0.4500

Detection quality is unchanged on the standard synthetic benchmark (train ROC 0.959,
test ROC 0.963 at contamination=0.1), and k still visibly drives the scores
(k=1/3/10 give mean scores 0.519/0.506/0.240), confirming it kept its real role.

Also ran pyod/test/test_base.py, test_data.py and test_utility.py (39 passed)
since fit() now routes through the shared base-class path. flake8 --max-line-length=127
on both touched files reports the same 25 pre-existing violations as on master
no new ones.

Fixes #194

LOCI stored the constructor argument k directly into self.threshold_ and
never called _process_decision_scores(), so contamination was accepted but
completely ignored when deriving labels_. Store k on the instance, use it
in the score loop where it belongs, and let the base class compute
threshold_ from contamination like every other detector.

Fixes yzhao062#194
@yzhao062

yzhao062 commented Aug 1, 2026

Copy link
Copy Markdown
Owner

I reviewed this and the fix looks correct. CI is green, and I also verified it locally with the fixture in pyod/test/test_loci.py (n_train=200, contamination=0.1, random_state=42) on the current development branch:

contamination=0.05 -> outlier fraction 0.0500
contamination=0.15 -> 0.1500
contamination=0.30 -> 0.3000
contamination=0.45 -> 0.4500     (previously 0.1400 for every value)

LOCI(k=5).k          -> 5   (previously AttributeError)
clone(LOCI(k=5)).k   -> 5   (previously AttributeError; get_params()["k"] was None)
ROC on this fixture  -> 0.9633, unchanged before and after (floor is 0.8)

The ROC being identical is expected and worth stating explicitly: this changes how labels_ are thresholded, not the ranking produced by decision_function, so there's no accuracy regression to worry about either way.

Your read of the root cause is right: self.threshold_ = k in __init__ consumed k in place of contamination, so the documented "n_samples * contamination most abnormal samples" behavior never applied, and k was never stored as an attribute for get_params / clone. Routing through _process_decision_scores() is the right fix — it's the same path the other detectors use to derive threshold_ and labels_ from contamination. This addresses the contamination half of #194.

Two things before it can be merged:

  1. The PR is still marked as a draft. Is it ready for review, or were you planning further changes?
  2. Please retarget the base branch from master to development — that's our integration branch, and master is currently behind it.

Thanks for this — it's been a latent bug since LOCI landed.

@Mohit-Ak
Mohit-Ak changed the base branch from master to development August 2, 2026 07:00
@Mohit-Ak

Mohit-Ak commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough check — good to see the contamination sweep and the clone path reproduce on your side too, and agreed that the unchanged ROC is the right thing to call out: decision_scores_ and the ranking are untouched, only the threshold_/labels_ derivation moves onto _process_decision_scores().

I've retargeted the base branch to development.

On the draft status: I'm keeping it as a draft until Mohit signs off on it — that's my standing rule for anything opened under this account, not a sign of pending work. The change is complete from my side and I'm not planning further commits. I'll flip it to ready-for-review as soon as he confirms, which should be within the day.

One note on scope while it's in front of you: this covers the contamination half of #194 plus the k/get_params breakage I hit while testing it. If there's a second part of #194 you'd want addressed in the same PR rather than a follow-up, say so and I'll fold it in before it goes up for review.

@Mohit-Ak
Mohit-Ak marked this pull request as ready for review August 3, 2026 02:30
@Mohit-Ak

Mohit-Ak commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Marked ready for review — thanks for waiting, and for taking the time to reproduce the sweep independently.

Nothing changed since your review: the base is on development, the diff is still the same two files, and Testing + CodeQL are green on 910869e. Happy to rebase or split anything out if it makes the review easier.

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.

Contamination parameter in LOCI

2 participants