MidПрактика10 min

Вероятностные структуры

HyperLogLog, Count-Min Sketch, MinHash: cardinality, frequency, set similarity в log памяти

Зачем они нужны

Точные ответы на аналитические вопросы дороги при миллиардах событий:

  • "Сколько уникальных посетителей за день?" -- точный ответ требует HashSet размером с кардинальность (гигабайты).
  • "Как часто встречался товар X в логах?" -- точный счётчик на каждый товар -- миллионы записей.
  • "Похожи ли два документа на 90%?" -- Jaccard requires intersection и union, O(n+m).

Вероятностные структуры дают приближённые ответы при sublinear памяти. Ошибка контролируемая (обычно 1-5%), и это приемлемо для analytics/monitoring сценариев.

HyperLogLog: cardinality estimation

Задача: сколько уникальных элементов в потоке (кардинальность)?

Интуиция

Возьмём хеш каждого элемента. Хеш -- равномерно случайная последовательность бит. Посчитаем, сколько ведущих нулей в каждом хеше. Если мы видели хеш с k ведущими нулями -- это событие с вероятностью 1/2^k. Значит, ожидаем увидеть такое после ~2^k разных элементов.

Флажоле (Flajolet) и коллеги предложили HyperLogLog (2007) на основе этой идеи:

  • Делим хеш на b префиксных бит (которые задают "бакет") и остальные биты.
  • В каждом бакете запоминаем максимум ведущих нулей среди хешей, попавших в бакет.
  • Кардинальность = harmonic mean × корректирующие константы.

Формула

При m = 2^b бакетов:

$$\hat{n} = \alpha_m \cdot m^2 \cdot \left(\sum_{j=1}^{m} 2^{-M_j}\right)^{-1}$$

где M_j -- max leading zeros в бакете j, α_m -- поправочная константа.

Память

  • m = 2^14 = 16384 бакетов × 6 бит = 12 KB.
  • Стандартная ошибка ≈ 1.04 / √m ≈ 0.81%.

То есть 12 KB памяти -- на миллиарды уникальных значений с 1% ошибкой. Для сравнения, хеш-сет = O(n) байт на n элементов.

Объединение

Два HLL можно объединить поэлементным максимумом бакетов. Это означает, что можно:

  • Поддерживать HLL на каждом сервере, мержить раз в N секунд.
  • Считать HLL за час/день и затем суммировать по периодам.

Где используется

  • Redis PFADD / PFCOUNT: встроенный HLL, 12 KB на ключ.
  • Presto / Trino: функция approx_distinct() через HLL.
  • Google BigQuery: APPROX_COUNT_DISTINCT().
  • Amazon Redshift: APPROX COUNT(DISTINCT ...).
  • Druid, ClickHouse: HLL для метрик uniques.

Минимальная реализация

<?php

declare(strict_types=1);

/**
 * HyperLogLog with 2^b registers.
 * Standard error ≈ 1.04 / sqrt(m).
 */
final class HyperLogLog
{
    private const B = 14; // 16384 registers
    private const M = 1 << self::B; // number of registers
    private const ALPHA = 0.7213 / (1 + 1.079 / self::M);

    /** @var list<int> */
    private array $registers;

    public function __construct()
    {
        $this->registers = array_fill(0, self::M, 0);
    }

    public function add(string $item): void
    {
        // 64-bit hash (xxh3 or similar would be better; using fnv1a + seed)
        $hash = hexdec(substr(hash('sha256', $item), 0, 16)); // 64 bits

        // First B bits → register index
        $idx = $hash >> (64 - self::B);

        // Remaining bits, count leading zeros + 1
        $remaining = $hash & ((1 << (64 - self::B)) - 1);
        $w = $this->leadingZeros($remaining, 64 - self::B) + 1;

        if ($w > $this->registers[$idx]) {
            $this->registers[$idx] = $w;
        }
    }

    public function count(): int
    {
        $sum = 0.0;
        foreach ($this->registers as $r) {
            $sum += 2 ** (-$r);
        }
        $estimate = self::ALPHA * self::M * self::M / $sum;

        // Small range correction
        if ($estimate <= 2.5 * self::M) {
            $zeros = count(array_filter($this->registers, fn(int $r): bool => $r === 0));
            if ($zeros > 0) {
                $estimate = self::M * log(self::M / $zeros);
            }
        }

        return (int) round($estimate);
    }

    /** Count leading zeros in a number of $width bits. */
    private function leadingZeros(int $n, int $width): int
    {
        if ($n === 0) {
            return $width;
        }
        $lz = 0;
        for ($i = $width - 1; $i >= 0; $i--) {
            if (($n & (1 << $i)) !== 0) {
                break;
            }
            $lz++;
        }
        return $lz;
    }

    /** Merge another HLL into this one (element-wise max). */
    public function merge(self $other): void
    {
        for ($i = 0; $i < self::M; $i++) {
            if ($other->registers[$i] > $this->registers[$i]) {
                $this->registers[$i] = $other->registers[$i];
            }
        }
    }
}
## Count-Min Sketch: приближённая частота

Задача: как часто встречался элемент x в потоке? Нужно отвечать на "top-K", "частоты запросов", "heavy hitters".

Идея

  • Матрица счётчиков d × w.
  • d хеш-функций h_1, ..., h_d, каждая мапит x → [0, w).
  • add(x): для каждого i увеличить M[i][h_i(x)] на 1.
  • count(x): вернуть min по i от M[i][h_i(x)].

Min -- потому что коллизии могут только увеличить счётчик, никогда не уменьшить. Берём минимальное значение -- оно ближе всего к истинному.

Ошибка

При d = ⌈ln(1/δ)⌉ и w = ⌈e/ε⌉:

  • Ошибка ≤ ε × N с вероятностью 1 - δ, где N -- общее число событий.
  • Пример: w = 2720, d = 7 → ε = 0.001, δ = 0.001 → ~20 KB памяти.

Важно: CMS завышает, но не занижает. Он никогда не скажет "элемент встречался меньше, чем на самом деле".

Use cases

  • Top-K queries: трекать "самые популярные товары/запросы".
  • Heavy hitters detection: network anomaly (IPs с аномальной активностью).
  • Frequency-based caching: Caffeine / Redis LFU используют CMS для оценки частоты перед вытеснением.

Минимальная реализация

<?php

declare(strict_types=1);

/**
 * Count-Min Sketch: d rows × w columns of counters.
 * Queries return an upper bound on true frequency.
 */
final class CountMinSketch
{
    /** @var list<list<int>> */
    private array $counters;

    /** @var list<int> */
    private array $seeds;

    public function __construct(
        public readonly int $d,
        public readonly int $w,
    ) {
        $this->counters = array_fill(0, $d, array_fill(0, $w, 0));
        $this->seeds = [];
        for ($i = 0; $i < $d; $i++) {
            $this->seeds[] = (int) hexdec(substr(hash('sha256', "seed:$i"), 0, 8));
        }
    }

    public static function forAccuracy(float $epsilon, float $delta): self
    {
        $w = (int) ceil(M_E / $epsilon);
        $d = (int) ceil(log(1 / $delta));
        return new self($d, $w);
    }

    public function add(string $item, int $count = 1): void
    {
        for ($i = 0; $i < $this->d; $i++) {
            $idx = $this->hash($item, $this->seeds[$i]);
            $this->counters[$i][$idx] += $count;
        }
    }

    public function estimate(string $item): int
    {
        $min = PHP_INT_MAX;
        for ($i = 0; $i < $this->d; $i++) {
            $idx = $this->hash($item, $this->seeds[$i]);
            if ($this->counters[$i][$idx] < $min) {
                $min = $this->counters[$i][$idx];
            }
        }
        return $min;
    }

    private function hash(string $item, int $seed): int
    {
        $h = hexdec(substr(hash('sha256', $item . ':' . $seed), 0, 15));
        return $h % $this->w;
    }
}
## MinHash: set similarity

Задача: оценить Jaccard similarity между двумя большими множествами.

$$J(A, B) = \frac{|A \cap B|}{|A \cup B|}$$

Точное вычисление требует перечисления всех элементов. MinHash даёт оценку за константу memory и O(k) сравнения.

Идея

  • Фиксируем k хеш-функций h_1, ..., h_k.
  • Для множества S сигнатура sig_i(S) = min_{x ∈ S} h_i(x).
  • Jaccard(A, B) ≈ (число i, где sig_i(A) = sig_i(B)) / k.

Почему работает: для случайной перестановки элементов A ∪ B, вероятность того, что min совпадёт, равна |A ∩ B| / |A ∪ B| = J(A, B).

Параметры

  • k = 100-200 обычно достаточно для ошибки 5-10%.
  • Signature size: k × 8 байт = 800-1600 байт на множество любого размера.

LSH (Locality-Sensitive Hashing)

Для поиска near-duplicates среди миллиардов документов: бьём signature на b bands по r hashes. Документы, у которых хоть один band совпадает полностью, -- кандидаты. Это превращает O(n²) пары в O(n × кандидатов).

Use cases

  • Near-duplicate detection: Google crawler находит плагиат в вебе.
  • Рекомендации: пользователи с похожим набором просмотренного.
  • Genomics: similarity геномов на уровне k-mer'ов.
  • Plagiarism: сравнение документов в Turnitin.

Память vs точность (сводная таблица)

Структура Задача Память Типичная ошибка
HLL Cardinality 12 KB ~1%
CMS Frequency 20 KB ~0.1% × N
MinHash Similarity 1-2 KB / set ~5-10%
Bloom Set membership ~1.2 MB / 10⁶ items @ 1% 1% FP
Cuckoo Set membership + delete ~1 MB / 10⁶ items 1% FP

Практические советы

  1. Выбирайте хеш тщательно: MurmurHash3 / xxHash / BLAKE3. Не CRC32 (слишком слабый), не SHA-256 (избыточен).
  2. Комбинируйте: HLL для uniques + CMS для frequency = полноценный monitoring-stack.
  3. Слияние: все три структуры мержатся за O(size). Это позволяет распределённый подсчёт.
  4. Redis модули: RedisBloom включает CMS и TopK команды; HLL встроен (PFCOUNT).
  5. ClickHouse, Druid, Presto: поддерживают HLL/CMS как нативные agg-функции.

Когда НЕ использовать

  • Нужен exact count -- считайте точно (hash set, sorted set).
  • Нужно перечислять элементы -- вероятностные не хранят их.
  • Малое n (< 10⁴) -- оверхед не оправдан.
  • Финансовые/биллинг расчёты -- ошибка даже 0.1% недопустима.

Выводы

  • HyperLogLog: cardinality в 12 KB при 1% ошибке на миллиарды элементов.
  • Count-Min Sketch: приближённая частота в ~20 KB при 0.1% ошибке.
  • MinHash: set similarity / Jaccard в 1-2 KB на set; с LSH находит near-duplicates за sublinear time.
  • Все три мержатся, что делает их удобными для distributed analytics.
  • Используются: Redis (PFCOUNT, BF/CMS модули), Presto, ClickHouse, BigQuery, Druid.
  • Всегда идут в ансамбле: HLL для uniques, CMS для heavy hitters, MinHash для similarity.