33. Reservoir Sampling for Eval Set Construction
Problem
Building a held-out evaluation set for a speech model means pulling a uniformly random sample of utterances out of a production traffic stream for human review. That stream has unknown, effectively unbounded length — it can't be buffered into a list and sliced, so the sampler must maintain a uniformly-random sample of size k using only O(k) memory as utterances flow past one at a time. This is the classic Reservoir Sampling problem (Algorithm R).
stream is given as a list here for testability, but the algorithm must process it as if it were a one-pass stream: a single forward loop, never looking back or peeking at len(stream) to shortcut the algorithm, even though Python would let you. seed seeds a random.Random(seed) instance used for all randomness, so results are exactly reproducible.
Source: src/33_reservoir_sample_eval_set.py
def sample_eval_utterances(stream: list[str], k: int, seed: int) -> list[str]: ...
>>> sample_eval_utterances(["u1", "u2", "u3"], k=5, seed=0)
['u1', 'u2', 'u3']
>>> sample_eval_utterances([f"u{i}" for i in range(10)], k=3, seed=42)
['u4', 'u1', 'u9']
Step-by-Step Approach
- Initialize an empty reservoir list of capacity k.
- For the first k items in the stream (indices 0 through k-1), add each one to the reservoir unconditionally — there's nothing yet to weigh them against.
- For every item after that, at index
i(0-indexed, so this is the(i+1)-th item), draw a uniformly random integerjin[0, i]inclusive. - If
j < k, overwritereservoir[j]with the current item; otherwise discard the current item and move on. - This rule is exactly what keeps every item seen so far equally likely to be in the final reservoir: at the moment the
(i+1)-th item arrives, it lands in the reservoir with probabilityk/(i+1), and it can be shown by induction that every earlier item independently also survives to that point with probabilityk/(i+1). - After the stream ends (whenever that is), the reservoir holds a uniformly random sample of size
min(k, n)with no bias toward earlier or later items.
The key insight is the acceptance probability k/(i+1) combined with picking the eviction slot uniformly at random from the current reservoir (via j < k) — together they guarantee every item that has streamed past so far has exactly equal odds of surviving to the end, without the algorithm ever needing to know how many items are left or how many there were in total.
Reference solution
import random
def sample_eval_utterances(stream: list[str], k: int, seed: int) -> list[str]:
rng = random.Random(seed)
reservoir: list[str] = []
for i, item in enumerate(stream):
if i < k:
# fill the reservoir unconditionally for the first k items
reservoir.append(item)
else:
# for the (i+1)-th item, keep it with probability k/(i+1): draw
# j uniformly from [0, i] and replace reservoir[j] only if
# j < k. This is the invariant that makes every item seen so
# far equally likely to survive to the end, without knowing
# the stream's total length in advance.
j = rng.randint(0, i)
if j < k:
reservoir[j] = item
return reservoir
Key Functions & Tricks
random.Random(seed)— an isolated, seeded RNG instance rather than the module-levelrandomfunctions, so results are reproducible and don't interact with any other randomness elsewhere in the program.rng.randint(0, i)— inclusive on both ends; drawing from a growing range[0, i]asiincreases is what shrinks each new item's acceptance probability over time.if j < k: reservoir[j] = item— both the acceptance test and the eviction-slot choice in one comparison, sincejwas already drawn uniformly.- O(k) space, O(n) time, single forward pass — no buffering the full stream, no knowledge of total length required in advance.
enumerate(stream)— theiit yields is exactly the "how many items seen so far, 0-indexed" value the probability formula needs.
How to Recognize This Pattern
The signal is "uniformly random sample from a stream of unknown or unbounded length, using bounded memory" — if the total length were known and the data fit in memory, random.sample on the full list would be simpler and is the right answer instead. A common variation is weighted reservoir sampling, where items have unequal selection probabilities (e.g. sampling proportional to some priority score), which needs a different acceptance rule (commonly the "A-Res" algorithm using exponential jumps) rather than the uniform k/(i+1) rule here. A common pitfall is peeking at len(stream) to compute an exact sampling probability up front, which defeats the purpose in a true streaming setting and won't translate to a real one-pass stream (a generator, a log tailer, a Kafka consumer) where the total length genuinely isn't known until the stream ends.