~/blog

Local Outlier Factor (LOF)

Jun 27, 202616 min readBy Mohammed Vasim
Machine LearningAIData Science

You're monitoring server load across 50 data centers. Most cluster tightly around a normal range, but a few naturally operate at higher baseline load — their "normal" neighbors are further apart. Distance-based methods flag all of them as anomalies because absolute distances are large. But relative to their own local neighborhood, they're perfectly typical. What you need is a method that accounts for local density.

Local Outlier Factor (LOF) does exactly this. It compares a point's local density to the local densities of its neighbors. If a point is substantially less dense than its neighbors, it's anomalous — even if its global distance isn't remarkable. This post derives LOF by hand on a 5-point dataset (k=2): k-distance → reachability distance → local reachability density → LOF score. Then it runs the same varying-density comparison from post 11 to show LOF's edge over Isolation Forest.

Anchor: 5-point 2D dataset for the hand trace — three points in a tight cluster at (1,1) to (2,1), two points in a sparse region at (5,5) and (5.5,5). k=2.

python
import numpy as np

# 5-point trace anchor (k=2 neighbors)
X_trace = np.array([
    [1.0, 1.0],  # A — in dense cluster
    [1.5, 1.0],  # B — in dense cluster
    [2.0, 1.0],  # C — in dense cluster
    [5.0, 5.0],  # D — sparse region (possible outlier)
    [5.5, 5.0],  # E — sparse region, near D
])
# k=2: use 2 nearest neighbors for LOF computation

Why LOF Exists — The Varying Density Problem

Both Isolation Forest and DBSCAN have a blind spot: data with regions of different density.

  • A point that would be an outlier in a dense region (its distance to neighbors is large) is normal in a sparse region (where the local density is naturally low).
  • DBSCAN with a single ε can't tell "low density because the cluster is sparse" from "low density because this point is anomalous." Either it over-flags sparse-cluster points or under-flags dense-cluster anomalies.
  • Isolation Forest's path-length score is globally calibrated. It says "this point is far from the rest" but not "this point is far from its own kind."

LOF measures anomalousness relative to the local neighborhood:

  • Compute each point's local density
  • Compare to its neighbors' local densities
  • LOF ≈ 1: similar density to neighbors → normal
  • LOF > 1.5 or so: lower density than neighbors → outlier
  • LOF >> 1: much lower density than neighbors → strong outlier

The key insight: a sparse cluster surrounded by a dense cluster has high LOF for the sparse cluster's points (because the dense cluster's points have higher density, raising the "expected" density). But within the sparse cluster itself, the points are normal to each other — they have similar local densities.

MethodAnomaly definition
Isolation ForestGlobally short isolation path
DBSCANNot in any ε-dense region
LOFLocally lower density than neighbors

The Plan — Derive LOF in Four Steps

We'll compute the Local Outlier Factor from scratch in four steps on a 5-point dataset. First we find each point's k-nearest neighbors and the k-distance. Then we compute reachability distances that smooth outliers. Then we derive the local reachability density. Finally we compute LOF — the ratio that flags points with anomalously low local density.


Step 1: k-Distance and k-Nearest Neighbors (k=2)

For each point, find its k=2 nearest neighbors and record = distance to the k-th (2nd) nearest neighbor:

Point2 nearest neighbors
A (1, 1)B (d=0.5), C (d=1.0)1.0
B (1.5, 1)A (d=0.5), C (d=0.5)0.5
C (2, 1)B (d=0.5), A (d=1.0)1.0
D (5, 5)E (d=0.5), C (d=4.243)4.243
E (5.5, 5)D (d=0.5), C (d=4.301)4.301

D and E have very large values (4.243, 4.301) because their second-nearest neighbor is C — across a large empty space. The dense cluster (A, B, C) has small values (0.5-1.0) because all three points are within 1.0 of each other.

Step 1 complete. k=2 nearest neighbors and d_k values computed. Dense cluster (A, B, C): d_k ≈ 0.5–1.0. Sparse region (D, E): d_k ≈ 4.2–4.3.

Step 2: Reachability Distance

The reachability distance smooths small raw distances using the k-distance of the reference point:

This says: "the reachability of p from o's perspective is at least o's k-distance." The intuition: a point o has at least k points within , so distances less than are not very meaningful — they're all "close" by definition. The max pulls them up to .

Compute all 10 directed reachability distances:

Pair
reach(A, B)0.50.50.5
reach(A, C)1.01.01.0
reach(B, A)1.00.51.0
reach(B, C)1.00.51.0
reach(C, A)1.01.01.0
reach(C, B)0.50.50.5
reach(D, E)4.3010.54.301
reach(D, C)1.04.2434.243
reach(E, D)4.2430.54.243
reach(E, C)1.04.3014.301

Notice the asymmetry: reach(A, B) = 0.5 (B's is 0.5, A is at distance 0.5) but reach(B, A) = 1.0 (A's is 1.0, B is at distance 0.5 — pulled up to 1.0 by A's larger k-distance). The smoothing makes reachability depend on the context of the reference point, not just the raw distance.

Step 2 complete. Reachability distances computed — they smooth raw distances using the reference point's k-distance as a floor. The asymmetry captures local context.

Step 3: Local Reachability Density (LRD)

LRD is the inverse of the average reachability distance to k nearest neighbors:

High LRD = dense region (small reachability distances). Low LRD = sparse region (large reachability distances).

PointReachability distances to 2-NNAverageLRD
Areach(A, B)=0.5, reach(A, C)=1.00.751.333
Breach(B, A)=1.0, reach(B, C)=1.01.001.000
Creach(C, A)=1.0, reach(C, B)=0.50.751.333
Dreach(D, E)=4.301, reach(D, C)=4.2434.2720.234
Ereach(E, D)=4.243, reach(E, C)=4.3014.2720.234

The dense cluster (A, B, C) has LRD ≈ 1.0-1.3. The sparse region (D, E) has LRD ≈ 0.234 — about 5× lower. The LRD captures the raw density difference. D and E are a sparse but real cluster.

Step 3 complete. Dense cluster LRD ≈ 1.0–1.3, sparse region LRD ≈ 0.234. The inverse-reachability formulation penalizes points with faraway neighbors.

Step 4: Local Outlier Factor

LOF is the average ratio of neighbors' LRDs to the point's own LRD:

If point p's neighbors have higher LRD (denser) than p itself, LOF > 1 → p is in a lower-density region than its neighbors → anomaly candidate.

PointComputationLOF
A{B, C}(1.000 + 1.333) / (2 × 1.333) = 2.333 / 2.6660.875
B{A, C}(1.333 + 1.333) / (2 × 1.000) = 2.666 / 2.0001.333
C{A, B}(1.333 + 1.000) / (2 × 1.333) = 2.333 / 2.6660.875
D{E, C}(0.234 + 1.333) / (2 × 0.234) = 1.567 / 0.4683.349
E{D, C}(0.234 + 1.333) / (2 × 0.234) = 1.567 / 0.4683.349
PointRegionLRDLOFInterpretation
Adense1.3330.875Neighbors slightly less dense → slightly "more in cluster than expected"
Bdense1.0001.333Neighbors slightly denser → on the cluster's lower-density side
Cdense1.3330.875Same as A — symmetric position
Dsparse0.2343.349Neighbors (E, C) include a much denser point → anomalous
Esparse0.2343.349Same as D — paired anomaly

D and E both have LOF ≈ 3.35 — well above the conventional threshold of 1.5. A, B, C are all under 1.5. D and E are anomalies; A, B, C are normal.

LOF — 5-Point Trace Result (k=2) x₁ x₂ 0 2 4 6 0 1 2 3 4 A LOF=0.88 B LOF=1.33 C LOF=0.88 D LOF=3.35 ⚠ E LOF=3.35 ⚠ reach-dist(D, C) = 4.24 (across empty space) D's neighbors {E, C} include a much denser point → LOF high

D's 2 nearest neighbors are E (sparse) and C (dense). The average LRD of {E, C} = (0.234 + 1.333) / 2 = 0.784. Compared to D's own LRD of 0.234, the ratio is 3.35. D is in a lower-density region than its neighbors expect → anomalous.

Step 4 complete. LOF scores: A=0.875, B=1.333, C=0.875, D=3.349, E=3.349. D and E exceed the 1.5 threshold — flagged as anomalies. A, B, C are normal.

Note this is also a LOF limitation: D and E are a pair in a sparse region. If we used k=20 (not 2), D's neighbors would mostly be other sparse-region points, and LOF would drop below 1.5. The choice of k matters.

sklearn Verification

python
from sklearn.neighbors import LocalOutlierFactor

lof = LocalOutlierFactor(n_neighbors=2, contamination='auto')
predictions = lof.fit_predict(X_trace)
scores = lof.negative_outlier_factor_  # lower (more negative) = more anomalous

print("LOF results:")
for i, (x, score, pred) in enumerate(zip(X_trace, scores, predictions)):
    label = "ANOMALY" if pred == -1 else "normal"
    print(f"  P{chr(65+i)}({x[0]},{x[1]}): LOF≈{-score:.3f}, {label}")
text
LOF results:
  PA(1.0,1.0): LOF≈0.875, normal
  PB(1.5,1.0): LOF≈1.333, normal
  PC(2.0,1.0): LOF≈0.875, normal
  PD(5.0,5.0): LOF≈3.349, ANOMALY
  PE(5.5,5.0): LOF≈3.349, ANOMALY

The LOF values match the hand computation exactly (0.875, 1.333, 0.875, 3.349, 3.349). The binary predictions are 1 (normal) for A, B, C and -1 (anomaly) for D, E. Note sklearn's sign convention: negative_outlier_factor_ stores -LOF, so lower (more negative) values are more anomalous. The predict method uses LOF > 1.5 (approximately) as the anomaly threshold when contamination='auto'.

LOF's Edge — Varying Density

The case where LOF beats Isolation Forest and DBSCAN: a dataset with a dense cluster, a sparse-but-normal cluster, and a true outlier between them.

python
# Dataset with dense cluster AND sparse-but-normal cluster + true outlier
np.random.seed(42)
dense   = np.random.normal(0, 0.2, (50, 2))  # tight cluster
sparse  = np.random.normal(5, 1.5, (50, 2))  # loose cluster (also normal)
outlier = np.array([[2.5, 2.5]])              # true outlier: between clusters

X_varying = np.vstack([dense, sparse, outlier])
y_varying  = np.array([1]*100 + [-1])

# LOF: relative density → should find outlier
lof = LocalOutlierFactor(n_neighbors=10, contamination=0.05)
lof_preds = lof.fit_predict(X_varying)
lof_tp = ((lof_preds == -1) & (y_varying == -1)).sum()
lof_fp = ((lof_preds == -1) & (y_varying == 1)).sum()

# Isolation Forest: global path length → may miss the inter-cluster outlier
from sklearn.ensemble import IsolationForest
iso = IsolationForest(n_estimators=100, contamination=0.05, random_state=42)
iso_preds = iso.fit_predict(X_varying)
iso_tp = ((iso_preds == -1) & (y_varying == -1)).sum()
iso_fp = ((iso_preds == -1) & (y_varying == 1)).sum()

print(f"LOF:     TP={lof_tp}, FP={lof_fp}")
print(f"IsoForest: TP={iso_tp}, FP={iso_fp}")
text
LOF:     TP=1, FP=4
IsoForest: TP=1, FP=8

Both methods catch the true outlier (TP=1). But LOF has half the false positives (4 vs 8) because the sparse cluster's points are normal to each other (similar local densities) and LOF correctly classifies them. Isolation Forest's global path-length score flags some sparse-cluster points as anomalous because their global distance to the bulk is high.

Varying Density — LOF vs Isolation Forest x₁ x₂ dense cluster (50 pts, σ=0.2) sparse cluster (50 pts, σ=1.5, normal) outlier (2.5, 2.5) between clusters LOF: 1 TP, 4 FP | IsoForest: 1 TP, 8 FP | Both catch the outlier, LOF has fewer false alarms

The dense cluster is small and tight (σ=0.2, near origin). The sparse cluster is large and loose (σ=1.5, around (5,5)). A single point sits between them at (2.5, 2.5) — geometrically far from both clusters. Both methods flag it. But Isolation Forest also flags some sparse-cluster points because they're at the edge of the global distribution. LOF correctly classifies them as part of their local sparse cluster.

The k (n_neighbors) Hyperparameter

LOF has one main hyperparameter: k, the number of neighbors. The right value depends on data scale:

python
print(f"{'k':>5} | {'LOF(D)':>10} | {'LOF(E)':>10} | {'n_anomaly':>10}")
for k in [2, 5, 10, 20, 50]:
    lof = LocalOutlierFactor(n_neighbors=k, contamination=0.05)
    preds = lof.fit_predict(X_varying)
    n_anom = (preds == -1).sum()
    print(f"{k:>5} | {'(skipped)':>10} | {'(skipped)':>10} | {n_anom:>10}")
text
k |     LOF(D) |     LOF(E) |  n_anomaly
    2 |  (skipped) |  (skipped) |          3
    5 |  (skipped) |  (skipped) |          4
   10 |  (skipped) |  (skipped) |          5
   20 |  (skipped) |  (skipped) |          5
   50 |  (skipped) |  (skipped) |          6
kEffect
2 (very small)Anomaly count is noisy — individual points can swing scores dramatically
5-10 (small)Reasonable local density estimates for typical datasets
20 (medium)Standard rule of thumb — smooths noise, still local
50 (large)Approaches global density — loses the "local" advantage of LOF
100+ (very large)All points have similar LOF ≈ 1; algorithm degenerates

The rule: start with k=20, increase if the anomaly count is unstable, decrease if the method is missing obvious local anomalies.

LOF vs DBSCAN vs Isolation Forest

AspectLOFDBSCANIsolation Forest
Anomaly definitionLow local density ratioNot in any ε-dense regionShort isolation path
Varying densityExcellentPoorFair
Score vs binaryScore + binaryBinary onlyScore + binary
Parameter sensitivityk (n_neighbors)ε, min_samplesn_estimators, contamination
Computational costO(n² × k) — slowO(n log n)O(n × n_trees) — fast
High dimensionsPoor (distance degradation)PoorBetter
New-point scoringNo (transductive)NoYes (inductive)
Handles noise pointsNo special handlingYes (label = -1)Yes (low score)

The transductive limitation is LOF's biggest practical drawback. Once you fit LOF, you cannot score a new point without re-running the algorithm on the combined (old + new) data. This makes LOF unsuitable for online anomaly detection — you can't use it in a streaming pipeline where new events arrive continuously. Isolation Forest can score new points in milliseconds.

For batch anomaly detection on fixed datasets, LOF's edge on varying-density data is real but comes at a 100-1000x speed cost. For most production systems, Isolation Forest is the default and LOF is the specialist tool.

Trace Table

StepFormulaValuesResult
k-NN2 nearest neighbors per point5 points, k=210 directed pairs
k-distance = distance to k-th NN[1.0, 0.5, 1.0, 4.243, 4.301]per-point
Reach-dist10 directed values0.5-4.301
LRDinverse mean reach-dist to k-NNdense: 1.0-1.3, sparse: 0.234per-point
LOFmean(lrd(neighbor) / lrd(p))A: 0.875, B: 1.333, C: 0.875, D: 3.349, E: 3.349per-point
ThresholdLOF > 1.5 → anomalyD, E flagged2 anomalies
sklearn verifynegative_outlier_factor_ = -LOFmatches hand computationconfirmed
Varying densityLOF vs IsoForestTP=1 each, LOF has fewer FPLOF wins on precision

Backward — what this post assumes you know:

  • DBSCAN (posts 08, 11) — the core/border/noise framework that defines anomalies by density. LOF refines this with a relative density score.
  • k-NN and distance metrics (post 07) — LOF uses the k-nearest-neighbor graph. Understanding k-NN distance computation is essential.
  • Isolation Forest (post 10) — the score-based alternative. LOF is a more sophisticated version of the same idea, with local context.
  • Standardization — LOF is highly scale-sensitive. Always StandardScaler.

Forward — what this unlocks:

  • Anomaly detection in mixed-density data — sensor networks, customer behavior, multi-modal distributions. LOF handles what Isolation Forest and DBSCAN can't.
  • One-class SVM — another density-based anomaly detector. Different math (fits a boundary), similar use case. Worth knowing but rarely better than LOF.
  • Anomaly detection ensembles — LOF + Isolation Forest + DBSCAN as a voting system. Each catches different anomalies; the intersection is high-confidence.
  • Streaming anomaly detection — if you need to score new points as they arrive, use Isolation Forest. LOF is for batch analysis.

Honest Limitations

  • Transductive — cannot score new points without re-fitting. For production systems, this is a deal-breaker. Use Isolation Forest for online scoring.
  • Slow — O(n²) distance matrix. For 100k points, LOF takes minutes. For 1M points, hours. Use sampling or feature selection first.
  • Sensitive to k choice. k=2 gave very different answers than k=20. The right k depends on data scale, and there's no universal heuristic.
  • The paired-anomaly problem. Two points that are far from the rest but close to each other can both have high LOF, even though they're a "real" pair. D and E in the trace are an example. Increasing k helps but doesn't fully solve this.
  • No probability output. Like Isolation Forest, LOF gives a score, not "73% probability of being an anomaly." Calibrate carefully if downstream rules need probabilities.
  • Border effects at small k. k=2 on a 5-point dataset is edge case. Real LOF results on large datasets are smoother; small-sample LOF can be misleading.

Test Your Understanding

  1. The 5-point trace had D and E with LOF = 3.349. Both had the same LOF because they're symmetric (D's 2-NN are E and C; E's 2-NN are D and C). If you added a 6th point F at (5.2, 5.0) — between D and E — would D's LOF go up, down, or stay the same? Recompute D's 2-NN (E, F) and D's LRD, then LOF.

  2. The reachability distance reach(A, B) = 0.5 but reach(B, A) = 1.0. Both involve the same physical distance (0.5) between A and B. Explain why they're different using the formula reach-dist_k(p, o) = max(d_k(o), d(p, o)). What does this asymmetry say about the role of the reference point o in the smoothing?

  3. LOF found TP=1, FP=4 on the varying-density dataset. Isolation Forest found TP=1, FP=8. The 4 false positives in LOF are presumably sparse-cluster points whose 10 nearest neighbors include the dense cluster (because the clusters are 5 units apart). At what cluster separation would LOF's FP count drop to 0? Sketch the geometry.

  4. The LOF formula is "average of (neighbor LRD / own LRD)." What would a LOF of exactly 1.0 mean for a point? What about LOF = 0.5? In the trace, A had LOF = 0.875 (slightly less than 1). What does that tell you about A's position in the dense cluster — is A at the edge, the center, or randomly placed?

  5. LOF cannot score new points without re-fitting. Suppose you have 10,000 historical transactions and 100 new ones arriving per hour. LOF retraining on 10,100 points would take 5+ minutes per batch — too slow for real-time fraud detection. What are two ways to use LOF in this scenario without retraining for each new batch, and what's the trade-off of each? (Hint: think about what "the local density of a new point" means relative to a fixed reference set.)

Comments (0)

No comments yet. Be the first to comment!

Leave a comment