This is the first post in a series on model-based outlier detection. In the coming posts I shall discuss Isolation Forest, Local Outlier Factor and One-Class SVM. Before that, I want to build the foundation: what exactly an outlier is, why the simple rules we learned first fail, and how scikit-learn thinks about the problem.
What is an outlier?
An outlier is an observation that deviates so much from the rest of the data that it seems to come from a different mechanism. Sometimes it is an error (a mistyped price). Sometimes it is the most valuable record in the dataset (a fraudulent transaction, a failing machine, a market crash day). The method does not decide which, so a domain expert should review whatever is flagged.
Three types are worth separating:
- Point anomalies: a single observation is unusual on its own, such as a transaction of ₹50 lakh in an account that normally spends ₹5,000.
- Contextual anomalies: a value is normal in general but unusual in context. A temperature of 35°C is fine in May but strange in January.
- Collective anomalies: each point looks fine, but together they are abnormal, such as a flat line in a sensor signal that should oscillate.
Why the z-score rule fails
The usual rule flags a point when \(|z_i| > 3\), where
$$z_i = \frac{x_i - \bar{x}}{s}$$There are two problems.
Masking. The mean \(\bar{x}\) and standard deviation \(s\) are themselves computed from the data, including the outliers. Outliers pull the mean toward them and inflate \(s\), so they hide each other.
A hard ceiling. For a sample of size \(n\), no point can have a z-score larger than
(using \(s\) with the \(n-1\) divisor). For \(n = 9\) this is about 2.67, so the "3-sigma rule" can never flag anything in such a sample.
Consider this tiny dataset: 10, 11, 9, 10, 12, 11, 10, 45, 50. Two values are clearly far from the rest, yet their z-scores are only about 1.6 and 1.9.
A robust alternative: median and MAD
Replace the mean with the median and the standard deviation with the median absolute deviation, \(\text{MAD} = \text{median}(|x_i - \tilde{x}|)\). The modified z-score is
A common rule (Iglewicz and Hoaglin) flags points with \(|M_i| > 3.5\). The median and MAD barely move when a few outliers are present, so masking largely disappears.
import numpy as np
x = np.array([10, 11, 9, 10, 12, 11, 10, 45, 50])
z = (x - x.mean()) / x.std(ddof=1)
med = np.median(x)
mad = np.median(np.abs(x - med))
mz = 0.6745 * (x - med) / mad
print("z-score :", np.round(z, 2))
print("modified:", np.round(mz, 2))
The z-scores of 45 and 50 come out at 1.60 and 1.91, while their modified z-scores are 22.93 and 26.31. The robust version separates them from the rest without any difficulty.
The multivariate case: Mahalanobis distance
With several features, an observation can be unremarkable on every single axis yet unusual in combination (for example, a very tall and very light person). The Mahalanobis distance accounts for the correlation between features:
If the data are approximately multivariate normal in \(p\) dimensions, \(D^2\) follows a \(\chi^2_p\) distribution, so a natural cutoff is the 97.5th percentile \(\chi^2_{p,\,0.975}\).
The same masking problem returns, because \({\mu}\) and \({\Sigma}\) are estimated from contaminated data. The fix is the Minimum Covariance Determinant (MCD) estimator. It searches for the subset of \(h\) observations whose sample covariance matrix has the smallest determinant, and computes the mean and covariance from that subset alone. It can tolerate up to nearly 50% contamination, and scikit-learn implements it in MinCovDet.
The code below plants a small cluster of outliers in a correlated dataset and compares the classical and robust estimates.
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import chi2
from sklearn.covariance import EmpiricalCovariance, MinCovDet
rng = np.random.RandomState(42)
n_in, n_out = 200, 30
cov = [[1.0, 0.8], [0.8, 1.0]]
X_in = rng.multivariate_normal([0, 0], cov, size=n_in)
X_out = rng.multivariate_normal([4, -3], [[0.3, 0], [0, 0.3]], size=n_out)
X = np.vstack([X_in, X_out])
is_out = np.r_[np.zeros(n_in, bool), np.ones(n_out, bool)]
classical = EmpiricalCovariance().fit(X)
robust = MinCovDet(random_state=0).fit(X)
d2_classical = classical.mahalanobis(X) # squared distances
d2_robust = robust.mahalanobis(X)
cutoff = chi2.ppf(0.975, df=X.shape[1])
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5), sharex=True, sharey=True)
for ax, (name, d2) in zip(axes, [("Classical", d2_classical),
("Robust (MCD)", d2_robust)]):
flagged = d2 > cutoff
tp = (flagged & is_out).sum()
fp = (flagged & ~is_out).sum()
print(f"{name}: flagged={flagged.sum()}, "
f"true outliers caught={tp}/{n_out}, false alarms={fp}")
ax.scatter(X[~flagged, 0], X[~flagged, 1], s=18, alpha=0.7, label="not flagged")
ax.scatter(X[flagged, 0], X[flagged, 1], s=28, c="red", label="flagged")
ax.set_title(name)
ax.set_xlabel("x1")
axes[0].set_ylabel("x2")
axes[0].legend()
plt.tight_layout()
plt.show()
Running this gives the following counts.
- Classical: 13 points flagged, 11 of the 30 planted outliers caught, 2 false alarms.
- Robust (MCD): 36 points flagged, all 30 planted outliers caught, 6 false alarms.
The classical estimate misses most of the outliers, because they drag the mean and covariance toward themselves. The robust version catches all of them, at the cost of a few false alarms. This is the usual trade-off with a 97.5% cutoff, and it is worth keeping in mind when choosing a threshold. The exact counts can change slightly with the random seed and library version.
Outlier detection versus novelty detection
scikit-learn distinguishes two tasks, and it is important to choose the right one:
- Outlier detection: the training data itself contains outliers, and the goal is to find them. The methods are unsupervised, and you typically call
fit_predict. - Novelty detection: the training data is assumed clean, and the goal is to decide whether a new observation looks like the training data. You call
fiton clean data and thenpredicton new data.
In scikit-learn, predictions use the convention +1 for inliers and −1 for outliers. The estimators I shall cover in this series are IsolationForest, LocalOutlierFactor (which needs novelty=True for the novelty setting) and OneClassSVM (designed for novelty detection and sensitive to contamination in the training set).
Conclusion
In this post I discussed what outliers are, why mean-and-standard-deviation rules fail through masking, how median/MAD and the MCD-based Mahalanobis distance give robust alternatives, and how outlier detection differs from novelty detection. In the next post I shall discuss Isolation Forest, an elegant method that isolates anomalies with random splits and works well without any distributional assumption.
The code in this post was tested with Python 3 and scikit-learn 1.8.0.
