Skip to content

Commit 9e728c5

Browse files
Cursor Agentrlogger
andcommitted
perf+robustness: O(log n) conformal p-value, sklearn NotFittedError, unattainable-alpha warning (#21)
- ConformalAnomalyDetector.pvalue now uses jnp.searchsorted on the sorted calibration scores (O(log n)) instead of an O(n) masked sum; bit-identical results, vmaps cleanly through pvalue_batch. - HDClassifier/HDAnomalyDetector now call check_is_fitted so calling predict/predict_proba/pvalue/score_samples/decision_function before fit raises sklearn.exceptions.NotFittedError. - HDAnomalyDetector.fit warns when alpha is below the conformal resolution floor 1/(n_cal+1), where no point can ever be flagged. - Align sklearn anomaly threshold (p <= alpha) with the core ConformalAnomalyDetector decision rule. Squash-merge of cursor/anomaly-sklearn-robustness-795a (PR #21). Co-authored-by: Rajdeep Singh <rlogger@users.noreply.github.com>
1 parent a4d95f0 commit 9e728c5

3 files changed

Lines changed: 66 additions & 2 deletions

File tree

bayes_hdc/anomaly.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,12 @@ def pvalue(self, query_hv: jax.Array) -> jax.Array:
481481
"""
482482
score = self.score(query_hv)
483483
n = self.n_calibration
484-
ge_count = jnp.sum((self.calibration_scores >= score).astype(jnp.float32))
484+
# ``calibration_scores`` is stored sorted ascending (see ``fit``), so the
485+
# number of calibration scores >= ``score`` is an O(log n) binary search
486+
# rather than an O(n) scan. ``side="left"`` returns the first index whose
487+
# value is >= ``score``; everything from there to the end is the >= count.
488+
# This is exact (identical to the masked sum) and vmaps cleanly.
489+
ge_count = n - jnp.searchsorted(self.calibration_scores, score, side="left")
485490
return (1.0 + ge_count) / (n + 1.0)
486491

487492
def pvalue_batch(self, queries: jax.Array) -> jax.Array:

bayes_hdc/sklearn.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
from __future__ import annotations
3636

37+
import warnings
3738
from typing import Any
3839

3940
import jax
@@ -42,6 +43,7 @@
4243

4344
try:
4445
from sklearn.base import BaseEstimator, ClassifierMixin, OutlierMixin
46+
from sklearn.utils.validation import check_is_fitted
4547
except ImportError as exc: # pragma: no cover - exercised only without sklearn
4648
raise ImportError(
4749
"bayes_hdc.sklearn requires scikit-learn. Install it with "
@@ -137,10 +139,12 @@ def _encode(self, X: Any) -> jax.Array:
137139
return self.encoder_.encode_batch(jnp.asarray(_as_f32(X)))
138140

139141
def predict(self, X: Any) -> np.ndarray:
142+
check_is_fitted(self, "classifier_")
140143
idx = np.asarray(self.classifier_.predict(self._encode(X)))
141144
return self.classes_[idx]
142145

143146
def predict_proba(self, X: Any) -> np.ndarray:
147+
check_is_fitted(self, "classifier_")
144148
return np.asarray(self.classifier_.predict_proba(self._encode(X)))
145149

146150

@@ -216,19 +220,38 @@ def fit(self, X: Any, y: Any = None) -> HDAnomalyDetector:
216220
k_neighbors=self.k_neighbors,
217221
).fit(normal_hvs)
218222
self.detector_ = ConformalAnomalyDetector.create(scorer).fit(cal_hvs)
223+
224+
# Conformal p-values live on the grid {1/(n_cal+1), ..., 1}, so the
225+
# smallest attainable p-value is 1/(n_cal+1). If ``alpha`` is below that
226+
# floor, ``predict`` can never flag *any* point as an outlier (no matter
227+
# how extreme) and the detector silently returns all-inliers. Warn loudly
228+
# rather than letting users mistake "0 detections" for "0 anomalies".
229+
n_cal = int(self.detector_.n_calibration)
230+
floor = 1.0 / (n_cal + 1.0)
231+
if self.alpha < floor:
232+
warnings.warn(
233+
f"alpha={self.alpha:g} is below the conformal resolution floor "
234+
f"1/(n_calibration+1)={floor:g} (n_calibration={n_cal}). No point "
235+
f"can be flagged as an outlier at this alpha. Increase the amount "
236+
f"of fit data, raise calibration_fraction, or use a larger alpha "
237+
f"(alpha >= {floor:g}).",
238+
UserWarning,
239+
stacklevel=2,
240+
)
219241
return self
220242

221243
def _encode(self, X: Any) -> jax.Array:
222244
return self.encoder_.encode_batch(jnp.asarray(_as_f32(X)))
223245

224246
def pvalue(self, X: Any) -> np.ndarray:
225247
"""Split-conformal p-values; small = anomalous."""
248+
check_is_fitted(self, "detector_")
226249
return np.asarray(self.detector_.pvalue_batch(self._encode(X)))
227250

228251
def predict(self, X: Any) -> np.ndarray:
229252
"""+1 for inliers, -1 for outliers (scikit-learn convention)."""
230253
pvals = self.pvalue(X)
231-
return np.where(pvals < self.alpha, -1, 1).astype(int)
254+
return np.where(pvals <= self.alpha, -1, 1).astype(int)
232255

233256
def score_samples(self, X: Any) -> np.ndarray:
234257
"""Higher = more normal (scikit-learn convention).

tests/test_sklearn.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@
55

66
from __future__ import annotations
77

8+
import warnings
9+
810
import numpy as np
911
import pytest
1012

1113
pytest.importorskip("sklearn")
1214

1315
from sklearn.datasets import make_blobs, make_classification # noqa: E402
16+
from sklearn.exceptions import NotFittedError # noqa: E402
1417
from sklearn.model_selection import cross_val_score # noqa: E402
1518
from sklearn.pipeline import make_pipeline # noqa: E402
1619
from sklearn.preprocessing import StandardScaler # noqa: E402
@@ -172,3 +175,36 @@ def test_anomaly_get_params_roundtrip():
172175
assert det.get_params()["alpha"] == 0.05
173176
det.set_params(alpha=0.01)
174177
assert det.get_params()["alpha"] == 0.01
178+
179+
180+
def test_anomaly_warns_when_alpha_below_resolution_floor():
181+
# n=20, calibration_fraction=0.3 -> n_cal=6 -> floor = 1/7 ~= 0.143.
182+
# alpha=0.05 is below the floor, so no point can ever be flagged.
183+
X_norm, _ = make_blobs(n_samples=20, n_features=4, centers=1, random_state=0)
184+
rng = np.random.default_rng(0)
185+
X_out = rng.normal(loc=15.0, size=(10, 4))
186+
with pytest.warns(UserWarning, match="conformal resolution floor"):
187+
det = HDAnomalyDetector(alpha=0.05, dimensions=2000, random_state=0).fit(X_norm)
188+
# The warning's premise: even extreme outliers cannot be flagged at this alpha.
189+
assert (det.predict(X_out) == -1).sum() == 0
190+
191+
192+
def test_anomaly_no_warning_when_alpha_attainable():
193+
X_norm, _ = make_blobs(n_samples=300, n_features=4, centers=1, random_state=0)
194+
with warnings.catch_warnings():
195+
warnings.simplefilter("error") # any UserWarning would fail the test
196+
HDAnomalyDetector(alpha=0.05, dimensions=2000, random_state=0).fit(X_norm)
197+
198+
199+
def test_estimators_raise_not_fitted_before_fit():
200+
clf = HDClassifier(dimensions=1000)
201+
det = HDAnomalyDetector(dimensions=1000)
202+
X = np.zeros((3, 4), dtype=np.float32)
203+
with pytest.raises(NotFittedError):
204+
clf.predict(X)
205+
with pytest.raises(NotFittedError):
206+
clf.predict_proba(X)
207+
with pytest.raises(NotFittedError):
208+
det.predict(X)
209+
with pytest.raises(NotFittedError):
210+
det.pvalue(X)

0 commit comments

Comments
 (0)