开始前,会这些就够了
- 会写 for 循环和列表
- 知道二分之一表示一次选择的概率
- 会运行 Python 文件
- 需要补基础时:先补概率与条件概率
你想从一个很大的日志流里留下三条示例,但不知道最终会有多少条,也不想先把全部日志读进内存。只取最前面三条会偏爱早到的数据;每来一条就随机换掉旧记录,又可能偏爱后来的数据。
蓄水池抽样的关键,是让新记录被留下的机会随着已读条数变化。容量仍是三条,改变的是每次替换的门槛。本文用真正可执行的算法,再用分数穷举小范围的全部选择路径。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- reservoir.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B reservoir.py解压到独立练习文件夹,在含 reservoir.py 的目录打开终端运行。原创 Algorithm R 实现,使用随机数演示和 Fraction 对小规模全部选择路径做精确核对;没有测试随机数生成器质量,也没有保证某一次样本一定覆盖罕见事件。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先确定公平是对记录位置说的
假设最后一共读到 N 条记录,要保留 k 条,且 N 至少为 k。目标是每个大小为 k 的记录组合都有相同的机会;因此单条记录进入最终样本的机会也都是 k/N。这比只要求某几条在一次运行中出现得差不多更严格。
如果两条日志文字完全一样,但出现在不同位置,它们仍是两次出现,不能在抽样后随手去重。否则就悄悄改变了抽样对象。本文用编号代表记录位置;遇到 N 小于 k 时,明确返回已有的全部 N 条。
容量装满后,每条新记录先抽一个号码
先把前 k 条放进盒子。读到第 t 条时,从 0 到 t-1 等概率抽一个整数 j;若 j 小于 k,就用新记录替换盒子第 j 个位置,否则跳过。抽到前 k 个号码的机会是 k/t,所以越晚来的记录,直接进盒子的机会越小。
这并没有偏爱早来的记录:旧记录还要面对后续多轮被替换。假设到 t-1 条时,某旧记录留在盒里的机会是 k/(t-1);这一步它被替换的条件概率是 1/t,所以继续留下的机会是 k/(t-1) × (1-1/t) = k/t,恰好和新记录相同。组合层面的均匀性由 Algorithm R 的抽样不变式保证,单条概率相同本身并不足以证明所有组合都均匀。
多走一步:为什么每一组记录也同样公平
先只看四条不同位置的记录 A、B、C、D,盒子容量是 2。读完 A、B、C 后,盒子里的组合 AB、AC、BC 各有 1/3 的机会。D 到来时有四个等概率号码:两个会替换盒内某一个位置,另外两个会跳过 D。
最终得到 AB,只有一条来源:原先已经是 AB,并且这次跳过 D。所以概率是 1/3×2/4=1/6。最终得到 AD,则有两条来源:原先是 AB,这次恰好换掉 B;或原先是 AC,这次恰好换掉 C。每条来源都是 1/3×1/4,两项相加也是 1/6。其余四个组合按相同方式计算。精确分支枚举也给出 AB、AC、AD、BC、BD、CD 各为 1/6。
把这两种情况推到一般情形。C(t,k) 表示从 t 个不同记录位置中选出 k 个、不考虑顺序的组合数量。假设读完 t−1 条后,每个 k 条组合的机会都是 1/C(t−1,k);现在读第 t 条,并固定一个想得到的最终组合 S。先装满前 k 条时只有一个组合,正好是这个推理的起点。下面考虑 t>k、k≥1;k=0 时只有空组合。
第一种:S 不含新来的第 t 条。原盒子必须恰好就是 S,而且新记录必须被跳过。原盒子是 S 的机会为 1/C(t−1,k),跳过的机会为 (t−k)/t,所以相乘得到 (t−k)/(t×C(t−1,k))。
第二种:S 含新来的第 t 条。把它暂时拿走,剩下固定的 k−1 条,记作 A。原盒子必须是 A 再加一条最后要被换掉的旧记录 x。在前 t−1 条里,排除 A 的 k−1 条,还剩 t−k 个 x 可以选。每个这样的原组合机会都是 1/C(t−1,k),而恰好抽中 x 所在槽位的机会是 1/t。把 t−k 条互斥来源相加,仍得到 (t−k)/(t×C(t−1,k))。
两种情况得到同一个数,并且它都等于 1/C(t,k)。因此从前 k 条的唯一组合出发,每加入一条记录,所有大小为 k 的组合仍然等概率。这个证明依赖每一步整数抽取独立且均匀;固定种子或一次抽样结果不能代替这个前提。
每次都抛硬币替换,会偷偷偏爱最后一条
先把容量简化为 1:第一条放进去,之后每条都用二分之一机会替换。四条读完时,最后一条的入选机会是 1/2,倒数第二条是 1/4,而前两条各只有 1/8。程序用 Fraction 逐步计算这组分数,避免用某次随机结果冒充长期概率。
如果你本来就想偏爱近期日志,这种目标可以另行设计;但它不再是对整条数据流均匀抽样。先说清要代表整个历史、最近一段时间,还是少数故障类型,再选算法。
完整程序同时检查抽样和精确分布
保存为 reservoir.py,运行 python -B reservoir.py。reservoir 函数只遍历传入迭代器一次,保存至多 k 条;演示把 20 条编号作为流输入。固定种子仅方便本次比较,输出的三个编号不是正确性的证明。
更强的核对来自 exact_distribution:对于 0≤N≤6、0≤k≤N,枚举随机分支的所有可能路径,把概率用分数加到最终组合上。N=5、k=2 时,十个不同组合的概率都必须恰好为 1/10,五条记录的入选概率都为 2/5。程序还检查空输入、样本容量超过条数、非法容量及一次遍历,共 33 组检查。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Algorithm R plus exact small-case probability checks; no third parties."""
import itertools
import json
import math
import random
from collections import defaultdict
from fractions import Fraction
def reservoir(items, k, rng):
if type(k) is not int or k < 0:
raise ValueError("k must be a nonnegative integer")
sample = []
for seen, item in enumerate(items, start=1):
if seen <= k:
sample.append(item)
else:
slot = rng.randrange(seen)
if slot < k:
sample[slot] = item
return sample
class Choices:
"""Supply an exhaustive choice path instead of a pseudorandom generator."""
def __init__(self, choices):
self.choices = iter(choices)
def randrange(self, stop):
value = next(self.choices)
if not 0 <= value < stop:
raise AssertionError("invalid exact branch")
return value
def exact_distribution(n, k):
# Every path has probability 1 / ((k+1) * ... * n).
branches = [range(seen) for seen in range(k + 1, n + 1)]
denominator = math.prod(len(branch) for branch in branches)
distribution = defaultdict(Fraction)
for choices in itertools.product(*branches):
chosen = reservoir(range(n), k, Choices(choices))
distribution[tuple(sorted(chosen))] += Fraction(1, denominator)
return dict(distribution)
def wrong_half_distribution(n):
probabilities = [Fraction(1)]
for _ in range(1, n):
probabilities = [prob / 2 for prob in probabilities] + [Fraction(1, 2)]
return [str(prob) for prob in probabilities]
def main():
checks = 0
for n in range(7):
for k in range(n + 1):
distribution = exact_distribution(n, k)
expected = {subset: Fraction(1, math.comb(n, k))
for subset in itertools.combinations(range(n), k)}
assert distribution == expected, (n, k)
checks += 1
assert reservoir([], 3, random.Random(17)) == []
checks += 1
assert reservoir(iter(["same", "same"]), 3, random.Random(17)) == ["same", "same"]
checks += 1
try:
reservoir([1], -1, random.Random(17))
raise AssertionError("negative k was accepted")
except ValueError:
checks += 1
consumed = []
def stream():
for item in range(20):
consumed.append(item)
yield item
sampled = reservoir(stream(), 3, random.Random(17))
assert consumed == list(range(20)) and len(set(sampled)) == 3
checks += 1
exact = exact_distribution(5, 2)
inclusion = {str(item): str(sum(prob for subset, prob in exact.items() if item in subset))
for item in range(5)}
wrong = wrong_half_distribution(4)
assert wrong == ["1/8", "1/8", "1/4", "1/2"]
checks += 1
report = {"checks_passed": checks,
"one_pass_seed_17_sample_from_20": sampled,
"N5_K2_subset_count": len(exact),
"probability_of_each_pair": sorted({str(prob) for prob in exact.values()}),
"inclusion_probability": inclusion,
"wrong_half_replacement_N4_K1": wrong,
"boundary": "exact enumeration checks algorithm branches, not RNG quality"}
print(json.dumps(report, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中执行 python -B reservoir.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。原创 Algorithm R 实现,使用随机数演示和 Fraction 对小规模全部选择路径做精确核对;没有测试随机数生成器质量,也没有保证某一次样本一定覆盖罕见事件。
运行后对照结果
{
"checks_passed": 33,
"one_pass_seed_17_sample_from_20": [
11,
10,
7
],
"N5_K2_subset_count": 10,
"probability_of_each_pair": [
"1/10"
],
"inclusion_probability": {
"0": "2/5",
"1": "2/5",
"2": "2/5",
"3": "2/5",
"4": "2/5"
},
"wrong_half_replacement_N4_K1": [
"1/8",
"1/8",
"1/4",
"1/2"
],
"boundary": "exact enumeration checks algorithm branches, not RNG quality"
}小例子穷举能查实现错误,但不能代替所有前提
精确枚举把每个 randrange(t) 选择当成独立且均匀的数学分支,能查出代码的上下界或替换条件错误;它不检验操作系统熵源,也不证明伪随机数实现不存在缺陷。Python random 适合这类教学与模拟,本文不把它用作令牌或密码。
Algorithm R 仍要处理全部 N 条,时间是 O(N),样本存储是 O(k)。流没有结束时,盒子表示截至当前所读前缀的样本;它无法代表尚未到来的记录。抽到的三条可能恰好全都正常,均匀抽样不保证稀有故障一定出现。
从日志抽样走到实际任务,要保留可追溯信息
真实记录建议保留唯一编号、来源以及抽样规则版本,方便回到原始上下文。若每条记录对象非常大,保存 k 条仍可能占用不少内存;可以保存能够重新定位的轻量引用,但必须先确认原始数据之后仍可读取。
如果目标是每个服务至少一条,或故障日志比普通日志更重要,就已加入分层或权重要求;不要继续宣称整个输入流上的所有组合等概率。本文只完成无权重、无放回、单遍的基线。
记住这三点
- 固定容量不要求预先知道流的总长度。
- Algorithm R 对第 t 条使用 k
/t入池机会,旧记录还会继续面临替换。 - 固定种子方便复查,组合概率与适用前提才解释公平。
别把例子用过头
- 前 k 条截断或固定二分之一替换会改变目标分布。
- 对内容相同的日志去重,可能把按出现位置抽样改成按不同文本抽样。
- 把有限次随机直方图、单条边际概率或一次幸运结果当成完整正确性证明。
自己试一小步
现在需要从带重复文字的六条记录里保留两条,你怎样证明抽的是出现位置?
- 给六条记录分别加独立编号,其中至少三条文字相同。
- 在保留编号的前提下,扩展精确分布报告,按最终编号组合列出概率。
- 再自行比较抽样后按文字去重与不去重的结果含义,写出两个不同的任务定义。
做到这些,就算完成
- 容量、出现位置和文字值没有被混用。
- 检查的是所有编号组合的概率,而不只是每个编号的出现次数。
- 说明样本数不足与重复内容时的契约;本节不提供结果答案。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Vitter:Random Sampling with a Reservoir (新标签页)↗
未知总量的单遍抽样问题,以及第 2 节 Algorithm R 的维护规则与不变式。
- NIST 算法词典:Reservoir Sampling (新标签页)↗
大小为 k 的盒子、每条记录的随机替换规则,以及每个 k 元组合等概率的目标。
- Python 3.14:random.randrange (新标签页)↗
从 range 范围选整数的接口,避免把浮点乘法取整等同于标准整数抽样。