Implement a simplified HyperLogLog that estimates the number of distinct items in a stream, supports merging, and corrects for small cardinalities.
02Key insight
Hash each item; the first p bits pick one of m=2^p registers and each register keeps the max leading-zero rank of the remaining bits. The harmonic mean of 2^-register gives the estimate with ~1.04/sqrt(m) relative error; fall back to linear counting when many registers are empty. Merge = elementwise max, which is why APPROX_COUNT_DISTINCT parallelises.
import hashlib
import math
from typing import Hashable
classHyperLogLog:
def__init__(self, p: int = 12) -> None:
ifnot4 <= p <= 16:
raise ValueError("p must be in [4, 16]")
self.p, self.m = p, 1 << p
self.registers = bytearray(self.m)
self._alpha = 0.7213 / (1 + 1.079 / self.m)
defadd(self, item: Hashable) -> None:
x = int.from_bytes(hashlib.blake2b(repr(item).encode(), digest_size=8).digest(), "big")
idx = x >> (64 - self.p) # top p bits choose the register
rest = x & ((1 << (64 - self.p)) - 1)
rank = (64 - self.p) - rest.bit_length() + 1# position of first 1-bitif rank > self.registers[idx]:
self.registers[idx] = rank
defcount(self) -> float:
raw = self._alpha * self.m * self.m / sum(2.0 ** -r for r inself.registers)
zeros = self.registers.count(0)
if raw <= 2.5 * self.m and zeros:
returnself.m * math.log(self.m / zeros) # linear counting for small nreturn raw
defmerge(self, other: "HyperLogLog") -> None:
if other.p != self.p:
raise ValueError("precision mismatch")
self.registers = bytearray(max(a, b) for a, b inzip(self.registers, other.registers))
Try it first.
TimeO(1) per add, O(m) per count
SpaceO(m) bytes (4 KB at p=12)
tests.py · 3 passing
tests.pypython14 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
h = HyperLogLog(p=12)
for i inrange(50000):
h.add(f"user-{i}")
h.add(f"user-{i}") # duplicates do not change the estimateassertabs(h.count() - 50000) / 50000 < 0.05
small = HyperLogLog(p=12)
for i inrange(100):
small.add(i)
assertabs(small.count() - 100) < 5
x, y = HyperLogLog(10), HyperLogLog(10)
for i inrange(0, 6000): x.add(i)
for i inrange(3000, 9000): y.add(i)
x.merge(y)
assertabs(x.count() - 9000) / 9000 < 0.1
04Staff-level follow-up
Why can't you compute distinct-count of an intersection reliably with HLL? Only union merges are native; intersection via inclusion-exclusion compounds errors badly for small overlaps - use MinHash/theta sketches when set operations matter.