Skip to content
About
Main content

Count-Min Sketch: head versus tail

Simulate a modelNeeds Python with NumPy

Problem

A 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 (, ) 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

For keys seen about ten times, how large is the Count-Min sketch's median relative error?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    count-min-sketch-head-versus-tail.py76 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Heavy hitters with 1,000 counters
AlgorithmKeys above N/1,001FoundTop 10 in order
Misra-Gries7171yes
Space-Saving7171yes
Reference runApple M2 · 8 cores · 8 GB
Output · uv run count-min-sketch-head-versus-tail.py
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: yes
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

What 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 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.