pyod's CD detector scores each point by Cook's distance, the influence of the point on a fitted
regression. Cook's distance needs the leverage, the diagonal of the hat matrix of the design the model was
fit on. CD fits each per-feature regression with LinearRegression(), whose default
fit_intercept=True gives the design [1, X], but it computes the leverage from X alone. The
hat-matrix trace equals the number of fitted parameters, so the correct leverages sum to
n_predictors + 1; the ones from X sum to n_predictors, short by the intercept. Because the leverage
enters Cook's distance nonlinearly through 1/(1 - h), the scores and the outlier ranking are wrong on the
default path.
pyod, pyod/models/cd.py, _Cooks_dist, 3.6.2 and current main (identical), line 38, with the model
at line 161:
leverage = (X * np.linalg.pinv(X).T).sum(1) # X alone, no intercept column
...
residuals = y - model.predict(X) # residuals from the intercept model
residuals_studentized = residuals / np.sqrt(mse) / np.sqrt(1 - leverage)
distance_ = residuals_studentized ** 2 / X.shape[1]
distance_ *= leverage / (1 - leverage)
# cd.py:161 self.model = model if model is not None else LinearRegression() # fit_intercept=TrueThe residuals come from the intercept model, but the leverage comes from X without the intercept
column, so the two are inconsistent. The correct leverage is the diagonal of the hat matrix of [1, X].
The fix is to build the leverage (and the rank and the / X.shape[1] divisor) from the intercept-augmented
design when the model is fit with an intercept.
Cook's distance with the leverage from the model's own design or from X alone, on an off-center
predictor with one high-influence point:
leverage sum with intercept: 2.0000 (= n_predictors + 1 = 2)
leverage sum from X alone: 1.0000 (= n_predictors = 1)
point 0 Cook's D: correct 1.9321, pyod 0.1915
max abs diff over all points: 1.7406
Driving the real library against statsmodels:
leverage sum: pyod 1.0000 (= n_predictors 1) vs correct 2.0000 (= n_predictors + 1)
point 0 Cook's D: correct 1.9321, pyod 0.1915 (understated 10.1x)
real _Cooks_dist ranking [0, 10, 5, 3, 1] vs statsmodels [0, 1, 10, 2, 3], differ True
fit_intercept=False matches statsmodels: True
real CD detector top-5: [0, 6, 10, 7, 14]
The hat-matrix trace is the number of fitted parameters, so the leverages must sum to 2 for a
one-predictor model with an intercept; pyod's sum to 1. The most influential point's Cook's distance is
1.9321 by the statsmodels reference but 0.1915 from pyod, understated ten times over, and the real
_Cooks_dist ranking reorders the second through fifth most-anomalous points relative to the correct
ranking. The base case isolates the fault: with fit_intercept=False the X design is the right one and
the real function matches statsmodels exactly, so the sole defect is the intercept mismatch.
The detector runs with default parameters, a default LinearRegression, and standard multi-feature input,
and the fault fires whenever the features are not mean-centered, the normal case where the intercept is
load-bearing. The failure is silent: an anomaly score and ranking are returned, but the influence of the
actual outliers is understated and the ranking is corrupted, which for an outlier detector is the headline
output. The error is a design-matrix inconsistency, not floating-point round-off, and the leverage enters
nonlinearly so the min-max normalization does not cancel it. The fix is to compute the leverage from
[1, X] when the model is fit with an intercept.
excerpt.py: the leverage line and the model construction quoted with the flags that name the fault, and the trace invariant.cooks.py: Cook's distance with the leverage from[1, X](correct, matches statsmodels) or fromXalone (pyod), so the leverage sum isn_predictors + 1orn_predictorsand the distances differ.consequence.py: the real_Cooks_distandCDdetector against statsmodels, the leverage sum, the understated outlier, the reordered ranking, and thefit_intercept=Falsebase case that matches.test_cdintercept.py: the model leverage sums differ by the intercept and the distances disagree, the real leverage sum is short, the outlier is understated, the ranking differs, and the no-intercept case matches.
python cooks.py
python consequence.py
python test_cdintercept.py
The leverage line is quoted from current main; the distances are produced by the real pyod _Cooks_dist
and the CD detector against statsmodels. The fix is to build the leverage from the intercept-augmented
design.