Count-Min Sketch: head versus tail
Simulate a modelNeeds Python with NumPy
Problem
A Count-Min sketch estimates how often each key occurs in a stream using d rows of w counters: each event increments one counter per row, and a key's estimate is the smallest of its d counters. It never underestimates, and with w = e/ε and d = ln(1/δ) its overestimate is at most ε · N with probability 1 − δ, where N is the stream length. That bound is additive: the same 10,000 is a negligible error on a key seen millions of times and an absurd one on a key seen ten times. Heavy-hitter algorithms (Misra-Gries, Space-Saving) answer a narrower question, which keys are frequent, with a guaranteed list in k counters.
Generate a skewed stream of 10 million events (numpy.random.zipf(1.3, ...) modulo 10⁶). Build a Count-Min with w = 2,719 and d = 5 (ε = 0.001, δ = 1%) and compare estimates with exact counts. Print the relative error for the top 10 keys and for keys with true counts around 10. Then run Misra-Gries and Space-Saving with 1,000 counters and check that every key with frequency above N/1,001 appears in the result, and whether the order of the top 10 is right.
The heavy-hitter algorithms run in plain Python on the first million events.
The question. How large is the Count-Min error on the head and on the tail, and do the heavy-hitter algorithms find every frequent key in the right order?
Predict
Expect the bound ε · N = 10,000 on error. The relative error for the top 10 keys should be around 1% or lower, and for keys with true counts around 10 enormous. Every key above N/1,001 should appear in the heavy-hitter result, and the order of the top 10 is sometimes wrong.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
count-min-sketch-head-versus-tail.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Count-Min sketch: the head against the tail. 10 million Zipf-distributed events over 1 million keys into a Count-Min sketch with w = 2,719 and d = 5 (epsilon = 0.001, delta = 1%). Relative error for the top 10 keys and for keys with true count near 10. Then Misra-Gries and Space-Saving with 1,000 counters. """ import argparse, json from collections import Counter import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--events", type=int, default=10_000_000, help="Events in the stream.") p.add_argument("--keys", type=int, default=1_000_000, help="Key space.") p.add_argument("--zipf", type=float, default=1.3, help="Zipf exponent.") p.add_argument("--width", type=int, default=2719, help="Count-Min width w.") p.add_argument("--depth", type=int, default=5, help="Count-Min depth d.") p.add_argument("--counters", type=int, default=1000, help="Counters for Misra-Gries and Space-Saving.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(args.seed) stream = (rng.zipf(args.zipf, args.events) % args.keys).astype(np.int64) true = np.bincount(stream, minlength=args.keys) seeds = rng.integers(1, 2**31 - 1, (args.depth, 2)) cms = np.zeros((args.depth, args.width), dtype=np.int64) cols = [((a * stream + b) % 2147483647) % args.width for a, b in seeds] for r in range(args.depth): np.add.at(cms[r], cols[r], 1) def est(keys): return np.min([cms[r][((a * keys + b) % 2147483647) % args.width] for r, (a, b) in enumerate(seeds)], axis=0) eps_n = np.e / args.width * args.events top = np.argsort(-true)[:10] tail = np.where((true >= 8) & (true <= 12))[0][:1000] top_err = (est(top) - true[top]) / true[top]; tail_err = (est(tail) - true[tail]) / true[tail] print(f"bound e/w x N = {eps_n:,.0f}; max overestimate seen {int((est(np.arange(args.keys)) - true).max()):,}") print(f"top 10 keys: median relative error {np.median(top_err):.3%}; keys with ~10 events: median relative error {np.median(tail_err):,.0%}") def misra_gries(s, k): c = {} for x in s: if x in c: c[x] += 1 elif len(c) < k: c[x] = 1 else: for y in list(c): c[y] -= 1 if c[y] == 0: del c[y] return c def space_saving(s, k): c = {} for x in s: if x in c: c[x] += 1 elif len(c) < k: c[x] = 1 else: y = min(c, key=c.get); c[x] = c.pop(y) + 1 return c sub = stream[: 1_000_000].tolist(); tsub = Counter(sub); N = len(sub) heavy = {k for k, v in tsub.items() if v > N / (args.counters + 1)} res = [] for name, fn in (("Misra-Gries", misra_gries), ("Space-Saving", space_saving)): c = fn(sub, args.counters) top_true = [k for k, _ in tsub.most_common(10)]; top_est = sorted(c, key=c.get, reverse=True)[:10] res.append({"algorithm": name, "heavy_hitters": len(heavy), "heavy_found": len(heavy & set(c)), "top10_same_order": "yes" if top_true == top_est else "no"}) print(f"{name:<12} (on the first 1M events): {res[-1]['heavy_found']} of {len(heavy)} keys above N/{args.counters + 1} found; " f"top-10 order identical: {res[-1]['top10_same_order']}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"heavy": res, "summary": [{"bound": eps_n, "top_err_pct": float(np.median(top_err)) * 100, "tail_err_pct": float(np.median(tail_err)) * 100}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run count-min-sketch-head-versus-tail.py uv run count-min-sketch-head-versus-tail.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --events10,000,000 | Events in the stream. |
| --keys1,000,000 | Key space. |
| --zipf1.3 | Zipf exponent. |
| --width2719 | Count-Min width w. |
| --depth5 | Count-Min depth d. |
| --counters1000 | Counters for Misra-Gries and Space-Saving. |
| --seed0 | Random seed. |
Our run
Heavy hitters with 1,000 counters
| Algorithm | Keys above N/1,001 | Found | Top 10 in order |
|---|---|---|---|
| Misra-Gries | 71 | 71 | yes |
| Space-Saving | 71 | 71 | yes |
bound e/w x N = 9,997; max overestimate seen 3,039
top 10 keys: median relative error 0.090%; keys with ~10 events: median relative error 2,227%
Misra-Gries (on the first 1M events): 71 of 71 keys above N/1001 found; top-10 order identical: yes
Space-Saving (on the first 1M events): 71 of 71 keys above N/1001 found; top-10 order identical: yesWhat we found
The largest overestimate seen was well inside the bound of 10K. For the top 10 keys the median relative error was 0.091%; for keys seen about ten times it was 2,230%, an estimate more than twenty times the truth. Both heavy-hitter algorithms found all 71 keys above N/1,001, and both had the top 10 in the right order (yes, yes): at a skew of 1.3 the top keys are far apart, so this stream never exercised the "sometimes wrong" case.
Read it
The bound is additive, so a Count-Min sketch is a tool for the head of a distribution. On a skewed stream that is where the questions usually are; on the tail, every estimate is mostly the collisions of the head. Report a count from a sketch only with its bound, and do not divide a tail estimate by anything.
The order of the top keys is only guaranteed when their counts differ by more than the error; with a flatter distribution, near-equal keys can swap.