Python Statistics NLP Open Source

ZipfFilter

Frequency-aware outlier filtering using the Zipf-Mandelbrot law.
A Python library that finds what doesn't belong in a sequence — by learning what should be there.

0. A Strange Regularity

In 1935, linguist George Kingsley Zipf made a striking observation about the English language. He counted how often every word appeared in a large corpus of text, ranked them from most frequent to least frequent, and plotted the result. What he found was not random. It was not even close to uniform. It followed a precise mathematical law:

$$f(r) \propto \frac{1}{r^{\,s}}$$
Zipf's Law — the $r$-th most common element appears with frequency proportional to $1/r^s$
(1)

The most common word ("the") appears roughly twice as often as the second most common, three times as often as the third, and so on. The second word appears twice as often as the fourth. The pattern holds with eerie consistency — not just for English, but for every language ever studied, and far beyond linguistics.

Zipf's Law appears everywhere: word frequencies in any language · city populations · website traffic rankings · income distributions · earthquake magnitudes · protein lengths · GitHub repository stars · social media follower counts. Any time a complex system self-organizes through preferential attachment or feedback loops, Zipf's Law tends to emerge.
Fig. 1 — Rank-frequency plot of a synthetic word corpus. Bars show observed frequencies sorted by rank. The violet curve is the theoretical Zipf prediction. On a log-log scale, this curve becomes a straight line — the signature of a power law. Red bars are outliers: elements whose frequency deviates significantly from the Zipf expectation.

The key insight: if you know a dataset follows Zipf's Law, you can predict what frequency each rank should have. Any element whose frequency deviates dramatically from that prediction is an anomaly — statistically unexpected given the structure of the rest of the data. That is the core idea behind ZipfFilter.

Why This Matters for Filtering

Most outlier detection methods ask: "is this value unusually large or small compared to all values?" That works when data is roughly symmetric. But language data, log data, web analytics, and most real-world count data are not symmetric. They are power-law distributed. A word appearing 5,000 times is not an outlier if it ranks first. A word appearing 5,000 times when it ranks 200th is deeply suspicious.

ZipfFilter filters relative to the expected frequency at each rank, not relative to a global mean or standard deviation. This makes it the right tool for any domain where Zipf's Law is a reasonable prior.

1. The Zipf-Mandelbrot Extension

Pure Zipf's Law has one problem: it breaks down for the most frequent items. The theoretical curve predicts that rank-1 should have infinite frequency as the dataset grows, which is physically impossible. Benoit Mandelbrot (1953) fixed this with a two-parameter extension:

$$f(r) = \frac{k}{(r + b)^{\,a}}$$
The Zipf-Mandelbrot law
(2)

Three parameters, three distinct roles:

ParameterRoleTypical rangeEffect
$k$Normalization constant$> 0$, scales with corpus sizeShifts the entire curve up or down. Usually set to the frequency of the most common element.
$a$Decay exponent$0.5$ – $2.0$Controls how fast frequency drops with rank. Higher $a$ → steeper drop → more concentrated on top elements.
$b$Frequency offset$0$ – $5$Shifts the curve right, smoothing the top-rank behavior. Mandelbrot's fix for the singularity at $r = 0$.

When $b = 0$ and $a = 1$, the law reduces to the original Zipf formula $f(r) = k/r$. The Zipf-Mandelbrot generalization fits real data far better, especially at the extremes of the rank distribution.

Fig. 2 — The Zipf-Mandelbrot curve $f(r) = k/(r+b)^a$ as parameters vary. Watch how the decay exponent $a$ (animating) changes the steepness of the curve. Solid violet: current curve. Dashed grey: pure Zipf reference ($a=1, b=0$). Log-log inset confirms the power-law structure.

Fitting the Model to Data

Given a real sequence, ZipfFilter fits the three parameters $(k, a, b)$ using nonlinear least squares via SciPy's curve_fit. The fitting minimizes the sum of squared residuals between observed frequencies and the model prediction:

$$\min_{k,\,a,\,b} \sum_{r=1}^{R} \left[ f_r - \frac{k}{(r+b)^a} \right]^2$$
(3)

The initial guess is $k = f_1$ (the highest observed frequency), $a = 1.0$, $b = 2.7$ — a reasonable starting point for most natural language and count data. If fitting fails (too few data points, degenerate distribution), the library falls back gracefully to the initial guess.

2. The Filtering Algorithm

Once the model is fit, outlier detection is a matter of measuring how far each element's observed frequency deviates from what the model predicts for its rank.

Deviation Metrics

Two modes are supported. Absolute deviation:

$$d_r^{\text{abs}} = \left| f_r - \hat{f}_r \right|$$
Raw difference between observed and expected frequency

And the default relative deviation, which is scale-invariant and preferred for most use cases:

$$d_r^{\text{rel}} = \frac{\left| f_r - \hat{f}_r \right|}{\hat{f}_r}$$
Fractional deviation from the model — normalizes for the wide dynamic range of Zipfian data
(4)
Why relative deviation? In a Zipf distribution, the top-ranked element might appear 10,000 times and the 100th-ranked element 100 times. An absolute deviation of 50 is negligible at rank 1 but enormous at rank 100. Relative deviation treats both ranks fairly, flagging anomalies proportional to their expected frequency.

An element is classified as an outlier if its deviation exceeds the sensitivity threshold $\tau$:

$$\text{outlier}(r) = \mathbf{1}\!\left[ d_r > \tau \right]$$
(5)

Step-by-Step

Fig. 3 — The four-step ZipfFilter algorithm, animated. Step 1: count frequencies. Step 2: sort by rank and fit the Zipf-Mandelbrot curve. Step 3: compute per-element deviation from the fitted model. Step 4: flag elements exceeding the sensitivity threshold and remove them from the sequence.

Complexity

OperationComplexityNotes
Frequency counting$O(n)$Single pass via collections.Counter
Sorting by rank$O(V \log V)$$V$ = vocabulary size (unique elements)
Model fitting$O(V \cdot I)$$I$ = iterations of curve_fit, typically small
Outlier detection$O(V)$Vectorized NumPy comparison
Sequence filtering$O(n)$Set-based $O(1)$ lookup per element
Total$O(n + V \log V)$$V \ll n$ in practice

3. The Sensitivity Parameter

The sensitivity parameter $\tau$ is the single most important knob. It controls how strict the filter is: how far an element's frequency can deviate from the Zipf-Mandelbrot model before being flagged.

ValueEffectBest for
$\tau = 0.05$Very strict — flags small deviationsClean, large corpora; aggressive denoising
$\tau = 0.2$ (default)Balanced — catches clear outliers onlyGeneral purpose
$\tau = 0.5$Lenient — only flags gross anomaliesNoisy data; conservative cleaning
$\tau > 1.0$Almost no filteringExploration / diagnostics only
Fig. 4 — Effect of sensitivity on the same dataset. The threshold band (shaded region) marks the accepted deviation range around the fitted curve. Elements outside the band are flagged as outliers. As sensitivity increases, the band widens and fewer elements are flagged.
Rule of thumb: start with the default $\tau = 0.2$. If legitimate rare words are being filtered, increase toward $0.5$. If noise is slipping through, decrease toward $0.05$. For very short sequences (under 20 unique elements) the fit is unreliable — inspect results manually.

4. Using ZipfFilter

Installation

git clone https://github.com/huolter/zipffilter
pip install -r requirements.txt   # numpy ≥ 1.19, scipy ≥ 1.5

Basic Usage

from zipffilter import ZipfFilter

sequence = [
    "the", "of", "and", "to", "a",
    "the", "the", "of", "and", "the",
    "xyz123", "xyz123", "xyz123", "xyz123",  # anomaly: too frequent for its rank
    "in", "is", "it", "of",
]

zf = ZipfFilter(sensitivity=0.2, use_relative_deviation=True)
filtered, outliers = zf.filter_outliers(sequence)

print("Outliers:", outliers)
# Outliers: ['xyz123']

print("Filtered sequence:", filtered)
# Filtered sequence: ['the', 'of', 'and', 'to', 'a', 'the', ...]

Inspecting the Fit

metrics = zf.get_metrics()

print(metrics)
# {
#   'k': 4.82,          # normalization constant (≈ max frequency)
#   'a': 0.93,          # decay exponent (close to 1 = near-Zipf)
#   'b': 2.61,          # rank offset (Mandelbrot correction)
#   'sensitivity': 0.2,
#   'outliers': ['xyz123'],
#   'num_outliers': 1
# }

Processing Without Filtering

# Just fit the model and get the frequency distribution
freq_dict, sorted_freqs = zf.process_sequence(sequence)

print(freq_dict)
# Counter({'the': 4, 'of': 3, 'and': 2, 'xyz123': 4, ...})

print(sorted_freqs)
# [4, 4, 3, 2, 1, 1, 1, ...]  ← sorted descending by frequency

Use Case: Text Cleaning Pipeline

import re
from zipffilter import ZipfFilter

def clean_tokens(text, sensitivity=0.15):
    tokens = re.findall(r'\b\w+\b', text.lower())
    zf = ZipfFilter(sensitivity=sensitivity)
    cleaned, removed = zf.filter_outliers(tokens)
    return cleaned, removed

text = "... large document text ..."
tokens, noise = clean_tokens(text)
print(f"Removed {len(set(noise))} anomalous token types")

When to Use ZipfFilter

Good fitPoor fit
Natural language token cleaning
Web server log analysis
Event sequence denoising
API call frequency auditing
Any large count dataset with power-law structure
Very small sequences (< 20 unique elements)
Uniform or near-uniform distributions
Continuous numerical data
Multi-modal distributions
Datasets where Zipf's Law is not a reasonable prior

5. What ZipfFilter Actually Does

Most outlier detection treats data as if it were drawn from a Gaussian: flag points more than $k$ standard deviations from the mean. That assumption is wrong for almost all real-world count data, which is right-skewed, heavy-tailed, and power-law distributed.

ZipfFilter replaces the Gaussian assumption with the Zipf-Mandelbrot assumption — and for the right data, that change is the difference between a filter that works and a filter that removes everything that matters.

The fundamental operation: fit the expected structure of the data, then flag what doesn't fit the structure. This is a general principle that extends far beyond Zipf's Law. ZipfFilter is a specific instantiation of it for power-law count data.

Zipf's Law is nearly 90 years old. The Zipf-Mandelbrot extension is 70 years old. What ZipfFilter adds is a practical, pip-installable Python implementation of their implications for data cleaning — making the 1935 observation from a linguistics paper into a callable function in a modern data pipeline.

← back to walter's page