アクセスログのユーザー数などユニークな要素数を数える際 count(distinct column_name) のようなクエリを実行することがある。データが膨大な場合、通常の count() はデータを分割して合算することでスケールさせることができるが、distinct する場合はその方法が取れずメモリ不足によって極端に処理が遅くなったり、最悪 OOM で失敗してしまいどうしようもなくなってしまうことがある。
この問題は Count-distinct problem (cardinality estimation problem) と呼ばれていて、全ての要素を保持することなくカーディナリティ(ユニーク数)を推定するアルゴリズムがいくつか提案されている。その一つが HyperLogLog で、その改良版である HyperLogLog++ は Spark の approx_count_distinct() や、Presto の approx_distinct() として実装されている。
spark.sql("""
select count(distinct id)
from test_data
""").show() // 100,818
spark.sql("""
select approx_count_distinct(id)
from test_data
""").show() // 100,598
HyperLogLog は各データをハッシュ関数に通し、その先頭 \(b\) bitsに対応する \(m = 2^b\) 個のレジスタに振り分け、レジスタごとに残りbitsの先頭から続く0の長さの最大 \(M[i]\ (1 \leqq i \leqq m)\) を求める。
これに対して次の計算を行うことでカーディナリティ \(E\) を推定する。\(\alpha\)は\(m\)のみに依存する補正係数。
\(b=2\ (m = 4)\) のとき各レジスタの先頭0の数の最大値を [1, 2, 3, 2] とすると、Eの分母部分は \((\frac{1}{2} + \frac{1}{4} + \frac{1}{8} + \frac{1}{4})\) となり、これに m を掛けたものは \(2^{M[j]}\) の調和平均となる。要素数が多くなるほど 0 が確率的に長くなることでこの値が大きくなるため \(E\) の値も大きくなることになる。
\(m\) を増やすと精度は上がりメモリの使用量も増えるといったトレードオフがあるが、なんと \(m = 2048\) 程度でもカーディナリティに依存せず標準誤差 2.3% 程度に収まってしまう。ただしレジスタの数に対してカーディナリティが低すぎると精度が下がる問題があり、HyperLogLog++ ではそのような場合に sparse representation を用いるようになっている。
便利な性質としてレジスタを merge すると和集合での結果を得られることがあり、これが並列実行の容易さにもつながっている。 BigQuery には APPROX_COUNT_DISTINCT() のほかに HLL_COUNT.INIT()/MERGE() 関数があり、集計時にレジスタを保存しておくことで、後から group by してカーディナリティを出すことができる。
SELECT HLL_COUNT.MERGE(hll_sketch) AS distinct_customers_with_open_invoice -- => 3
FROM
(
SELECT
country,
HLL_COUNT.INIT(customer_id) AS hll_sketch
FROM
UNNEST(
ARRAY<STRUCT<country STRING, customer_id STRING, invoice_id STRING>>[
('UA', 'customer_id_1', 'invoice_id_11'),
('BR', 'customer_id_3', 'invoice_id_31'),
('CZ', 'customer_id_2', 'invoice_id_22'),
('CZ', 'customer_id_2', 'invoice_id_23'),
('BR', 'customer_id_3', 'invoice_id_31'),
('UA', 'customer_id_2', 'invoice_id_24')])
GROUP BY country
);