File size: 6,865 Bytes
a2bbd94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
"""Lightweight approximate data structures for population statistics.

All numpy-based, mergeable (so they can be maintained per partition and reduced),
and each clearly reports whether an answer is exact or approximate.

- QuantileHistogram: fixed-bin histogram over a known range; percentile queries by
  linear interpolation inside the winning bin. Stand-in for a t-digest.
- HyperLogLog: classic HLL with 2^p registers and numpy vectorized updates.
- Reservoir: uniform reservoir sample of exemplar ids (Algorithm R, vectorized-ish).
"""

from __future__ import annotations

from typing import Dict, List, Optional

import numpy as np

# ---------------------------------------------------------------- hashing

_SPLITMIX_1 = np.uint64(0x9E3779B97F4A7C15)
_SPLITMIX_2 = np.uint64(0xBF58476D1CE4E5B9)
_SPLITMIX_3 = np.uint64(0x94D049BB133111EB)


def _hash64(values: np.ndarray) -> np.ndarray:
    """Deterministic 64-bit mix (splitmix64) of integer identifiers."""
    with np.errstate(over="ignore"):
        z = values.astype(np.uint64) + _SPLITMIX_1
        z = (z ^ (z >> np.uint64(30))) * _SPLITMIX_2
        z = (z ^ (z >> np.uint64(27))) * _SPLITMIX_3
        return z ^ (z >> np.uint64(31))


# ---------------------------------------------------------------- quantile histogram


class QuantileHistogram:
    """Fixed-bin histogram over [lo, hi]; approximate percentiles via interpolation.

    Mergeable: histograms with identical bin config add counts element-wise.
    """

    def __init__(self, lo: float = 0.0, hi: float = 1.0, bins: int = 64,
                 counts: Optional[np.ndarray] = None):
        self.lo, self.hi, self.bins = float(lo), float(hi), int(bins)
        self.counts = counts if counts is not None else np.zeros(bins, dtype=np.int64)

    def add(self, values: np.ndarray) -> None:
        if values.size == 0:
            return
        clipped = np.clip(values, self.lo, self.hi)
        idx = np.minimum(
            ((clipped - self.lo) / (self.hi - self.lo) * self.bins).astype(np.int64),
            self.bins - 1)
        self.counts += np.bincount(idx, minlength=self.bins)

    def merge(self, other: "QuantileHistogram") -> "QuantileHistogram":
        assert (self.lo, self.hi, self.bins) == (other.lo, other.hi, other.bins)
        return QuantileHistogram(self.lo, self.hi, self.bins, self.counts + other.counts)

    @property
    def total(self) -> int:
        return int(self.counts.sum())

    def percentile(self, q: float) -> Optional[float]:
        """Approximate q-th percentile (q in [0, 100])."""
        total = self.total
        if total == 0:
            return None
        target = total * (q / 100.0)
        cum = np.cumsum(self.counts)
        bin_idx = int(np.searchsorted(cum, target, side="left"))
        bin_idx = min(bin_idx, self.bins - 1)
        prev_cum = cum[bin_idx - 1] if bin_idx > 0 else 0
        in_bin = self.counts[bin_idx]
        frac = 0.5 if in_bin == 0 else float(np.clip((target - prev_cum) / in_bin, 0.0, 1.0))
        width = (self.hi - self.lo) / self.bins
        return self.lo + (bin_idx + frac) * width

    def to_dict(self) -> Dict:
        return {"lo": self.lo, "hi": self.hi, "bins": self.bins,
                "counts": self.counts.tolist(), "exact": False}

    @staticmethod
    def from_arrays(lo: float, hi: float, counts: np.ndarray) -> "QuantileHistogram":
        return QuantileHistogram(lo, hi, len(counts), counts.astype(np.int64))


# ---------------------------------------------------------------- HyperLogLog


class HyperLogLog:
    """HLL cardinality estimator with 2^p registers (approximate).

    Mergeable: element-wise max of registers.
    """

    def __init__(self, p: int = 12, registers: Optional[np.ndarray] = None):
        self.p = int(p)
        self.m = 1 << self.p
        self.registers = registers if registers is not None else np.zeros(self.m, dtype=np.uint8)

    def add_ids(self, ids: np.ndarray) -> None:
        if ids.size == 0:
            return
        h = _hash64(np.asarray(ids))
        reg_idx = (h >> np.uint64(64 - self.p)).astype(np.int64)
        rest = (h << np.uint64(self.p)) | np.uint64((1 << self.p) - 1)
        # rank = leading zeros of the remaining bits + 1
        ranks = np.zeros(ids.shape, dtype=np.uint8)
        remaining = rest.copy()
        # 64-bit leading-zero count via float log2 is unsafe; loop over 64 bits max via vector ops
        lz = np.full(ids.shape, 64, dtype=np.int64)
        nonzero = remaining != 0
        # bit_length via log2 on uint64 split into hi/lo 32-bit halves (exact for ints < 2^53 per half)
        hi = (remaining >> np.uint64(32)).astype(np.float64)
        lo = (remaining & np.uint64(0xFFFFFFFF)).astype(np.float64)
        bl = np.zeros(ids.shape, dtype=np.int64)
        hi_nz = hi > 0
        bl[hi_nz] = 32 + np.floor(np.log2(hi[hi_nz])).astype(np.int64) + 1
        lo_only = (~hi_nz) & (lo > 0)
        bl[lo_only] = np.floor(np.log2(lo[lo_only])).astype(np.int64) + 1
        lz[nonzero] = 64 - bl[nonzero]
        ranks = np.minimum(lz + 1, 64 - self.p + 1).astype(np.uint8)
        np.maximum.at(self.registers, reg_idx, ranks)

    def merge(self, other: "HyperLogLog") -> "HyperLogLog":
        assert self.p == other.p
        return HyperLogLog(self.p, np.maximum(self.registers, other.registers))

    def estimate(self) -> float:
        m = float(self.m)
        alpha = 0.7213 / (1.0 + 1.079 / m)
        inv_sum = np.sum(np.exp2(-self.registers.astype(np.float64)))
        raw = alpha * m * m / inv_sum
        zeros = int(np.sum(self.registers == 0))
        if raw <= 2.5 * m and zeros > 0:  # small-range correction
            return m * np.log(m / zeros)
        return float(raw)


# ---------------------------------------------------------------- reservoir sample


class Reservoir:
    """Uniform reservoir sample of ids (exemplars for drill-down)."""

    def __init__(self, k: int = 200, seed: int = 0):
        self.k = int(k)
        self.rng = np.random.default_rng(seed)
        self.items: List[int] = []
        self.seen = 0

    def add_ids(self, ids: np.ndarray) -> None:
        for v in np.asarray(ids).tolist():
            self.seen += 1
            if len(self.items) < self.k:
                self.items.append(int(v))
            else:
                j = int(self.rng.integers(0, self.seen))
                if j < self.k:
                    self.items[j] = int(v)

    def merge(self, other: "Reservoir") -> "Reservoir":
        merged = Reservoir(self.k, seed=self.seen + other.seen + 1)
        pool = self.items + other.items
        merged.seen = self.seen + other.seen
        if len(pool) <= self.k:
            merged.items = pool
        else:
            idx = merged.rng.choice(len(pool), size=self.k, replace=False)
            merged.items = [pool[i] for i in idx]
        return merged