ROD: the rotation reference direction is unstable on symmetric data, because RobustScaler pushes the geometric median to the origin
Summary
ROD's rotation angle is measured against the vector from the coordinate origin to the geometric median. Because ROD applies RobustScaler before computing that median, the median is pushed to within a few percent of the origin, and on symmetric data its direction does not survive resampling. The angle that drives the score is then close to noise. This is not a crash and not a regression; it is a question about whether the implementation matches the intent of the published method.
Filing it because v3.6.5 just fixed the norm_ == 0 case (#523, #722). That case turns out to be the exact limit of a regime ROD already operates in, rather than an isolated edge case.
Mechanism
In rod_3D (pyod/models/rod.py):
gm = geometric_median(x) # multivariate L1 median of the 3D subspace
norm_ = np.linalg.norm(gm) # distance from the coordinate ORIGIN to gm
_x = x - gm # each point's displacement from gm
v_norm = np.linalg.norm(_x, axis=1)
gammas = arccos(clip(dot(_x, gm) / (v_norm * norm_), -1, 1))
costs = v_norm**3 * cos(gammas) * sin(gammas)**2The reference direction is gm itself, so every angle is measured relative to the origin-to-median vector. Before this runs, mat_diff applies RobustScaler().fit_transform(X) to the full matrix, which centers each feature on its median. Every per-feature median is therefore exactly 0, and the multivariate geometric median lands near the origin. Its remaining direction is whatever separates the multivariate L1 median from the componentwise median, which on symmetric data is sampling noise.
Measurement
25 independent draws per row, n=500, d=3. The angle column is the pairwise angle between the unit gm vectors across those draws. A stable reference direction would sit near 0 degrees.
| data | median ‖gm‖ / median ‖x − gm‖ |
pairwise gm angle: median |
IQR |
|---|---|---|---|
| standard normal | 0.0246 | 86.2 deg | [59.9, 120.3] |
| normal shifted by +50 | 0.0246 | 86.2 deg | [59.9, 120.3] |
| lognormal (skewed) | 0.2300 | 7.3 deg | [4.9, 10.2] |
For uniformly random unit vectors on the 2-sphere the angle has density proportional to sin(theta), median 90 degrees and IQR [60, 120]. The symmetric-data row matches that to within a degree, so the reference direction there is statistically indistinguishable from random. The skewed row does not: skewness gives the geometric median a real offset from the componentwise median, and the direction becomes reproducible.
Two secondary observations from the same run:
- ROD is translation invariant. Scores for
X,X + 1andX + 100are identical, becauseRobustScalerremoves the shift first. The origin dependence that the formula suggests is cancelled by the preprocessing. - The shifted-normal row is identical to the unshifted one, for the same reason.
Reproduction
import numpy as np
from itertools import combinations
from sklearn.preprocessing import RobustScaler
from pyod.models.rod import geometric_median
def gm_stats(sampler, label, trials=25, n=500, d=3):
gms, ratios = [], []
for t in range(trials):
X = sampler(np.random.RandomState(t), n, d)
Xs = RobustScaler().fit_transform(X)
gm = np.asarray(geometric_median(Xs))
gms.append(gm / np.linalg.norm(gm))
ratios.append(np.linalg.norm(gm) /
np.median(np.linalg.norm(Xs - gm, axis=1)))
ang = [np.degrees(np.arccos(np.clip(np.dot(a, b), -1, 1)))
for a, b in combinations(gms, 2)]
print(f"{label:<22} ratio={np.median(ratios):.4f} "
f"angle median={np.median(ang):.1f} "
f"IQR=[{np.percentile(ang,25):.1f}, {np.percentile(ang,75):.1f}]")
gm_stats(lambda r, n, d: r.normal(size=(n, d)), "standard normal")
gm_stats(lambda r, n, d: r.lognormal(size=(n, d)), "lognormal")Why this matters
decision_scores_ depends on cos(gamma) * sin(gamma)**2. Suppose gamma is measured against a direction that changes arbitrarily between two samples from the same distribution. A meaningful fraction of the score is then sampling noise rather than signal, specifically on the symmetric data that many benchmarks use. That would also explain part of ROD's variance across seeds, relative to detectors whose scores do not depend on an estimated direction.
The norm_ == 0 branch added in v3.6.5 is the exact limit of this: the length of the reference vector reaches zero and the angle becomes undefined for every row at once. Guarding only the exact-zero point guards the single input that floating point almost never produces, while ‖gm‖ around 2 to 4 percent of the data scale is the ordinary case.
Open questions
These need a judgement about the method, not a patch:
- Is the
RobustScalerstep part of ROD as published, or was it added by this implementation? The reference is Almardeny, Boujnah and Cleary, IEEE Transactions on Knowledge and Data Engineering, 2020, recorded asalmardeny2020novelindocs/zreferences.bib. If the paper does not center the data, then PyOD's preprocessing is what creates the near-degenerate reference, and removing or changing it is on the table. - If centering is intended, what is the reference direction supposed to be? A geometric median that has been forced to the origin cannot be it.
- Should ROD warn, or refuse, when
‖gm‖is small relative to the data spread, rather than only when it is exactly zero? A relative threshold would fire on real inputs, whereas the current exact-zero test essentially never does.
pyod/models/rod.py credits Yahya Almardeny as the author, so asking him directly is probably the fastest route to question 1.
Not in scope
This is independent of #523 and PR #722, which fixed a real nan and are already released in v3.6.5. Nothing here suggests reverting them. Also unrelated to the pre-existing divide-by-zero in mad() at pyod/models/rod.py:39, where np.median(diff) is zero whenever enough costs coincide; that one is worth its own issue.
Environment for all numbers above: PyOD 3.6.5, Python 3.12.12, NumPy 2.4.4, scikit-learn 1.8.0.
Source: yzhao062/pyod