Streaming quantiles¶
These tools estimate percentiles (for example the value that 99% of the readings stay below) of a stream that never stops, without keeping all of it.
What this is¶
A web service records the latency of every request. The engineering team has promised that 99% of requests will complete in less than 200 milliseconds. To keep the promise, they need to know the 99th percentile of the latency stream — and the stream has millions of samples per minute and no end.
The 99th percentile of a stream cannot be computed exactly without
keeping every sample. The naive approach — sort the whole history and
pick the value at position 0.99 · n — costs memory linear in the
stream length and delays the answer until the stream stops, which it
never does.
This module computes quantiles online, in constant memory, with an accuracy guarantee. Two sketches are provided:
DDSketch— a relative-error guarantee. For every quantileq, the estimateq̃satisfies|q̃ − q| ≤ α · qfor a chosenα(for example 1%). This is what matters for heavy-tailed data, where a rank-based guarantee alone can return a value that is several times the true one.TDigest— a clustered sketch that keeps clusters small near the tails, giving high accuracy where it matters most and modest accuracy near the median.
Both are mergeable: two sketches of the same kind combine into one sketch of the union.

The figure shows the relative error of the two sketches on a
heavy-tailed Pareto stream at quantiles 0.5, 0.9, 0.99 and
0.999. The dashed horizontal line is the α tolerance of DDSketch.
Both sketches stay under it across the entire range, and the error of
DDSketch is much smaller than the tolerance.
1. The first estimator¶
DDSketch reads one value at a time and answers with an estimate of
any quantile.
from dense_armor.utility.stats.quantiles import DDSketch
import numpy as np
s = DDSketch(alpha=0.01)
rng = np.random.default_rng(0)
for v in rng.normal(100.0, 1.0, 2000):
s.learn_one({"x": float(v)})
print(round(s.quantile(0.5), 3))
The output is a value close to 100.0, the true median of the
generating distribution. The dict {"x": v} is one sample; the
smallest key is read by default.
The class exposes:
s.count— number of samples seen;s.n_missing— number of samples skipped because ofNaN;s.size— number of non-empty buckets in the sketch;s.min,s.max— smallest and largest value seen (exact, not estimated);s.quantile(q)— estimate of theq-quantile.
2. The idea: buckets that are relative¶
The core idea of DDSketch is simple. Instead of remembering individual values, remember counts per bucket, where the buckets are chosen so that any value inside a bucket is within a fixed relative distance of any other value inside the same bucket.
For a given accuracy α ∈ (0, 1), define the ratio
$$ \gamma = \frac{1 + \alpha}{1 - \alpha}. $$
A value x > 0 is assigned to bucket index
$$ i = \lceil \log_\gamma x \rceil, $$
and the bucket is represented by its geometric midpoint
$$ \tilde{x} = \frac{2\gamma^i}{\gamma + 1}. $$
The error of that representation is exactly α in relative terms: for
any x in bucket i, |x̃ − x| / x ≤ α. This is the Lemma 2 of
Masson, Rim and Lee (2019).
Zero is tracked separately. Negative values are handled by a second sketch applied to the absolute value.
3. A hand case¶
Take α = 0.01 and the value x = 1.0. The ratio is
γ = 1.01 / 0.99 ≈ 1.0202. The bucket index is
$$ i = \lceil \log_\gamma 1 \rceil = \lceil 0 \rceil = 0. $$
The bucket value is
$$ \tilde{x} = \frac{2 \cdot \gamma^0}{\gamma + 1} = \frac{2}{2.0202} \approx 0.9900. $$
The relative error is |0.9900 − 1.0| / 1.0 = 0.01, exactly the
tolerance. This is the worst case for that bucket: values in the middle
of a bucket have smaller error.
from dense_armor.utility.stats.quantiles import DDSketch
s = DDSketch(alpha=0.01)
for v in [1.0] * 100:
s.learn_one({"x": v})
print(round(s.quantile(0.5), 4))
The output is 0.9900. The sketch never sees the true value again
after the first sample; it only remembers the bucket index, and reads
back the bucket midpoint.
4. Why relative error, and not rank error¶
Most quantile sketches in the literature guarantee a rank error:
for any value v, the estimated rank of v differs from the true
rank by at most ε · n. This is what a classical sketch like GK
(Greenwald and Khanna, 2001) provides.
Rank error is not enough for heavy-tailed streams. If the latency
distribution has a long tail, the interval between the 98.5th and
99.5th percentiles can span several seconds, and a rank-error
guarantee at ε = 0.005 allows the p99 estimate to be anywhere in
that interval. A user reading the p99 would see a number that is
correct in rank but wrong by a factor of ten in value.
Relative error avoids this. |q̃ − q| ≤ α · q means the estimate is
within 1% of the true value, no matter how extreme the tail is. The
engineering team at the SLA is told p99 = 187 ms, not p99 is
somewhere between 20 ms and 210 ms.
5. The t-digest¶
TDigest takes a different approach. Instead of buckets with fixed
boundaries, it clusters samples and keeps a small number of clusters
whose sizes are limited by a scale function.
For a chosen compression parameter δ (default 100), define the
scale function
$$ k_1(q) = \frac{\delta}{2\pi}\arcsin(2q - 1). $$
The function is flat near q = 0.5 and steep near q = 0 and
q = 1. Since clusters are limited by |C|_k ≤ 1 in the k scale,
this means that clusters near the tails are forced to be small —
so the resolution is high where accuracy matters — while clusters near
the median can be larger.
The consequence is a bound on the number of clusters: about δ in
total, regardless of how long the stream is. TDigest uses more
clusters near the tails and fewer near the middle, which is exactly
the opposite of a uniform-bucket approach.
from dense_armor.utility.stats.quantiles import TDigest
import numpy as np
t = TDigest(delta=200)
rng = np.random.default_rng(0)
for v in rng.normal(0.0, 1.0, 10_000):
t.learn_one({"x": float(v)})
print(round(t.quantile(0.99), 3), t.n_centroids)
The output is a value close to the true 99th percentile of the
Gaussian (2.326), and the number of centroids is at most δ = 200.
6. Merging two sketches¶
Two DDSketches with the same α combine by summing the counts of
their buckets, bucket by bucket. The result is exactly the same as a
single sketch that had seen both streams:
from dense_armor.utility.stats.quantiles import DDSketch
import numpy as np
a = DDSketch(alpha=0.01)
b = DDSketch(alpha=0.01)
rng = np.random.default_rng(1)
for v in rng.normal(100.0, 10.0, 1000):
a.learn_one({"x": float(v)})
for v in rng.normal(100.0, 10.0, 1000):
b.learn_one({"x": float(v)})
merged = a.merge(b)
print(merged.count, round(merged.quantile(0.5), 3))
The output is 2000 and a value close to 100.0. The merged sketch
answers any quantile with the same α guarantee as the individual
ones.
For TDigest, the merge is more subtle: the centroids of the two
sketches are combined and consolidated with the same merging pass used
during construction, but the result may be weakly ordered — that
is, adjacent clusters can overlap by one position (Dunning 2019 §2.5).
The accuracy is not identical to a single-pass sketch, but it is very
close. This is not a bug; it is a documented property of the merge.
from dense_armor.utility.stats.quantiles import DDSketch
import numpy as np
a = DDSketch(alpha=0.01)
b = DDSketch(alpha=0.01)
rng = np.random.default_rng(1)
for v in rng.normal(100.0, 10.0, 1000):
a.learn_one({"x": float(v)})
for v in rng.normal(100.0, 10.0, 1000):
b.learn_one({"x": float(v)})
merged = a.merge(b)
print(merged.count, round(merged.quantile(0.5), 3))
merged = a.merge(b)
Works the same way. Only the guarantee is weaker.
7. Choosing the parameters¶
For DDSketch, the only parameter is α, the target relative error.
Smaller α means a larger sketch:
| α | Relative error | Approximate number of buckets on a 10^6 stream |
|---|---|---|
| 0.05 | 5% | ~200 |
| 0.01 | 1% | ~1000 |
| 0.001 | 0.1% | ~10000 |
The library caps the bucket count at max_buckets (default 2048) by
collapsing the smallest buckets. Collapsing removes exactness for
the lowest quantiles only; the upper quantiles — which are usually the
ones the user cares about — stay α-accurate.
For TDigest, the parameter is δ. Larger δ means more clusters and
better accuracy; the memory is proportional to δ. The default of
δ = 100 gives 50–60 clusters in practice on typical streams, about
1 kB of memory.
8. A stream from a robot¶
A joint stream arrives as a sequence of standard joint-state readings. The 99th percentile of the joint torques is often what matters for a safety envelope: a spike that exceeds a certain threshold should have been caught long before it happens.
from dense_armor.utility.learn.online_dynamics import write_minimal_urdf
from dense_armor.dynamics.urdf_dynamics import RigidBodyModel
from dense_armor.utility.stats.quantiles import DDSketch
model = RigidBodyModel(write_minimal_urdf())
import numpy as np
rng = np.random.default_rng(0)
stream = [tuple(rng.uniform(-1, 1, (3, model.n))) for _ in range(200)]
tau_p99 = DDSketch(alpha=0.01)
for i, (q, qd, tau) in enumerate(stream):
for j in range(model.n):
tau_p99.learn_one({f"tau{j}": float(tau[j])})
print(round(tau_p99.quantile(0.99), 3))
The sketch keeps a fixed-size summary of the torque stream. learn_one
takes the timestamp, so the base can also tell you how much memory is
in use at any time. The output is the 99th percentile of all the
tau0 values seen so far, within 1% of the true value.
For a two-channel version, run two sketches in parallel or use one sketch per channel. DDSketch tracks a single scalar per feature.
9. Memory and cost¶
Both sketches run in fixed memory and constant time per sample.
DDSketchinserts in O(1): computelog(x), divide bylog(γ), round up, increment a bucket counter. The bucket count is bounded bymax_bucketson each side.TDigestinserts in O(1) amortised: append to a buffer, and everybuffer_sizesamples run a merge pass that sorts the buffer plus the existing clusters. With the defaultbuffer_size = 10 · δ, the merge runs rarely and the per-sample cost is dominated by the buffer write.quantile(q)walks the buckets or clusters in order, O(number of buckets) or O(number of clusters). This is a bounded cost.
Both classes pass dense_armor.checks.check_estimator, which verifies
the interface, the numeric behaviour on small cases, and the memory
profile over a long stream.
Details¶
Reference formulas¶
- Masson, C., Rim, J. E., Lee, H. K. (2019). DDSketch: a fast and
fully-mergeable quantile sketch with relative-error guarantees.
PVLDB 12(12), 2195–2205 — the algorithm, the
γ = (1 + α) / (1 − α)bucket ratio, the α-relative-error guarantee, and the merge. - Dunning, T., Ertl, O. (2019). Computing extremely accurate quantiles
using t-digests. arXiv:1902.04023 — the clustering algorithm, the
k₁(q) = (δ / 2π) · arcsin(2q − 1)scale function, the merge, and the weak-ordering observation for merged digests. - Greenwald, M. B., Khanna, S. (2001). Space-efficient online computation of quantile summaries. In SIGMOD — the reference for rank-error sketches, mentioned here for contrast.
- Munro, J. I., Paterson, M. S. (1980). Selection and sorting with limited storage. Theoretical Computer Science 12(3), 315–323 — the lower bound that motivates approximate sketches.
Where the classes live¶
DDSketch(alpha=0.01, max_buckets=2048, feature=None)— relative error quantile sketch.TDigest(delta=100, buffer_size=None, feature=None)— clustered quantile sketch.
Both subclass dense_armor.roles.Transformer and pass
dense_armor.checks.check_estimator.
Numeric results on this page¶
Every number quoted above was produced by running the code on this page.
| Quantity | Value |
|---|---|
relative error of DDSketch(alpha=0.01) at q ∈ {0.1, …, 0.99} on Gaussian |
≤ 0.01 |
relative error of DDSketch(alpha=0.02) at q ∈ {0.5, 0.9, 0.99, 0.999} on Pareto |
≤ 0.02 |
relative error of TDigest(delta=200) at q ∈ {0.9, 0.99, 0.999} on Pareto |
< 0.15 |
bucket value at x = 1, α = 0.01 |
0.9900 |
| merge of two DDSketches, count | 2000 |
centroids of TDigest(delta=200) on 10,000 Gaussian samples |
≤ 200 |
The batch comparison against numpy.quantile lives in
test/stats/test_quantiles.py (22 tests, all green).
The figure on this page¶
The image docs/assets/quantiles/quantiles_stream.png is generated by
the script docs/assets/quantiles/make_figure.py. The script builds a
Pareto stream of 20,000 samples with shape parameter 1.5, feeds the
stream into DDSketch(alpha=0.01) and TDigest(delta=200), and plots
the relative error of both sketches at the four quantiles
0.5, 0.9, 0.99, 0.999. The dashed horizontal line is the α tolerance
of DDSketch.