Theta Sketch Tutorial

Copy Markdown View Source
Mix.install([
  {:ex_data_sketch, "~> 0.10"}
],
config: [
    ex_data_sketch: [
      backend: ExDataSketch.Backend.Rust,
      integrations: [opentelemetry: false]
    ]
  ])

Introduction

ExDataSketch.Theta keeps the k smallest hash values it has seen below a moving threshold ("theta"). Once more than k items pass the threshold, theta drops and the largest retained hashes are evicted -- so the sketch always holds a uniform random sample of the full hashed item space below theta, and cardinality ~= retained_count / theta. Because that sample is a real subset of the input's hashes, two sketches' samples can be combined directly -- that's what makes set operations possible where HLL/ULL can only merge (union):

flowchart LR
    A[item] --> B["hash(item)"]
    B --> C{"hash < theta\nthreshold?"}
    C -->|no| D[discard]
    C -->|yes| E["add to retained set"]
    E --> F{"retained > k?"}
    F -->|yes| G["lower theta,\nevict largest hashes"]
    F -->|no| H["estimate =\nretained_count / theta"]
    G --> H

Use it for: distinct-count plus set operations between sketches -- "how many users used both feature A and feature B," "how many users are in set A but not set B" -- via merge/2 (union) and the inclusion-exclusion combinations shown below, without ever materializing either full set.

Don't use it for: plain distinct-count with no set-operation need -- ExDataSketch.HLL/ExDataSketch.ULL pack far more accuracy per byte for that alone, since Theta retains a full 8-byte hash per entry rather than a compact one-byte register. Also not for exact set operations -- intersection/difference here are still estimates, with the same error budget as the underlying cardinality estimate.

What it buys you: at the default k: 4096, a sketch retains at most 4096 8-byte hashes (~32 KB fixed) with roughly 1.6% relative error (~1/sqrt(k), the standard bound for this sketch family) -- and unlike two independently-built exact sets, you never need to hold either input in full to compute the overlap:

ApproachMemory (per sketch, k=4096)Set opsError
Exact sets (two MapSets)Grows with input sizeExact0
ExDataSketch.Theta (k=4096)~32 KB, fixedunion/intersect/difference~1.6%

Sample data (cached locally)

Set A: users 1..600,000 (600K users). Set B: users 400,001..1,000,000 (600K users). True intersection: users 400,001..600,000 (200K users). True union: users 1..1,000,000 (1,000,000 users).

{users_a, users_b} = ExDataSketch.SampleData.theta_sets()
IO.puts("Set A: #{length(users_a)} users, Set B: #{length(users_b)} users")
IO.puts("True union: 1,000,000; true intersection: 200,000")

Basic usage

alias ExDataSketch.Theta

sketch_a = Theta.from_enumerable(users_a, k: 16_384)
sketch_b = Theta.from_enumerable(users_b, k: 16_384)

IO.puts("|A| estimate: #{Float.round(Theta.estimate(sketch_a), 0)} (true: 600,000)")
IO.puts("|B| estimate: #{Float.round(Theta.estimate(sketch_b), 0)} (true: 600,000)")

Set operations via inclusion-exclusion

merge/2 gives you the union directly. Intersection and difference come from combining the union estimate with the two individual estimates:

|A  B| = |A| + |B| - |A  B|      =>  |A  B| = |A| + |B| - |A  B|
|A \ B| = |A| - |A  B|
union_sketch = Theta.merge(sketch_a, sketch_b)
union_estimate = Theta.estimate(union_sketch)

a_estimate = Theta.estimate(sketch_a)
b_estimate = Theta.estimate(sketch_b)

intersection_estimate = a_estimate + b_estimate - union_estimate
a_not_b_estimate = a_estimate - intersection_estimate

IO.puts("Union estimate: #{Float.round(union_estimate, 0)} (true: 1,000,000)")
IO.puts("Intersection estimate: #{Float.round(intersection_estimate, 0)} (true: 200,000)")
IO.puts("A \\ B estimate: #{Float.round(a_not_b_estimate, 0)} (true: 400,000)")

This is approximate in both directions -- union_estimate already carries HLL-style estimation error, and the subtraction can amplify it (especially when the intersection is small relative to either set). It's still far cheaper than computing an exact MapSet.intersection/2 on two potentially enormous sets.

Precision (k) and accuracy

Like HLL's p, Theta's k trades memory for accuracy -- larger k means more retained entries and a tighter estimate:

for k <- [1024, 4096, 16_384] do
  s = Theta.from_enumerable(users_a, k: k)
  error_pct = abs(Theta.estimate(s) - 600_000) / 600_000 * 100
  IO.puts("k=#{k} (#{Theta.size_bytes(s)} bytes): error=#{Float.round(error_pct, 2)}%")
end

Serialization

binary = Theta.serialize(sketch_a)
{:ok, restored} = Theta.deserialize(binary)
IO.puts("Round-tripped estimate: #{Float.round(Theta.estimate(restored), 0)}")

Theta also has a compact/1 step and Apache DataSketches interop (serialize_datasketches/2) -- see guides/apache_interop.md for reading sketches produced by the Java/C++/Python DataSketches library and vice versa.

See also

  • ExDataSketch.Theta module documentation -- full API reference.
  • ExDataSketch.HLL/ExDataSketch.ULL -- cardinality-only estimators with no set-operation support; use these instead when you only need a single count and want the ~30% better accuracy ULL offers at the same memory. See livebooks/sketches/hll.livemd and livebooks/sketches/ull.livemd.
  • guides/distributed_merge_semantics.md, livebooks/distributed_merges.livemd -- associativity/commutativity properties merge relies on.