qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

日志还没读完,怎样只留三条,又让每条都有同样机会?

用蓄水池抽样在未知长度的数据流里保留固定容量;再穷举小例子,检查早来与晚来的记录有没有被偏爱。

展开本文目录
  1. 下载完整练习包
  2. 先确定公平是对记录位置说的
  3. 容量装满后,每条新记录先抽一个号码
  4. 多走一步:为什么每一组记录也同样公平
  5. 每次都抛硬币替换,会偷偷偏爱最后一条
  6. 完整程序同时检查抽样和精确分布
  7. 小例子穷举能查实现错误,但不能代替所有前提
  8. 从日志抽样走到实际任务,要保留可追溯信息
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 会写 for 循环和列表
  • 知道二分之一表示一次选择的概率
  • 会运行 Python 文件
  • 需要补基础时:先补概率与条件概率
遇到不熟悉的词,先看这 1 个解释
蓄水池抽样
只保留固定数量的候选,让总长度未知的数据流也能逐条参与公平抽样。
打开完整术语小抄 ↗

你想从一个很大的日志流里留下三条示例,但不知道最终会有多少条,也不想先把全部日志读进内存。只取最前面三条会偏爱早到的数据;每来一条就随机换掉旧记录,又可能偏爱后来的数据。

蓄水池抽样的关键,是让新记录被留下的机会随着已读条数变化。容量仍是三条,改变的是每次替换的门槛。本文用真正可执行的算法,再用分数穷举小范围的全部选择路径。

把例子带到自己的电脑上

下载完整练习包

需要 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 组检查。

展开收起代码 · 小实验python · 99 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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 条截断或固定二分之一替换会改变目标分布。
  • 对内容相同的日志去重,可能把按出现位置抽样改成按不同文本抽样。
  • 把有限次随机直方图、单条边际概率或一次幸运结果当成完整正确性证明。

自己试一小步

现在需要从带重复文字的六条记录里保留两条,你怎样证明抽的是出现位置?

  1. 给六条记录分别加独立编号,其中至少三条文字相同。
  2. 在保留编号的前提下,扩展精确分布报告,按最终编号组合列出概率。
  3. 再自行比较抽样后按文字去重与不去重的结果含义,写出两个不同的任务定义。

做到这些,就算完成

  • 容量、出现位置和文字值没有被混用。
  • 检查的是所有编号组合的概率,而不只是每个编号的出现次数。
  • 说明样本数不足与重复内容时的契约;本节不提供结果答案。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Vitter:Random Sampling with a Reservoir (新标签页)↗Jeffrey Scott Vitter / ACM TOMS;马里兰大学课程镜像 · 英文

    未知总量的单遍抽样问题,以及第 2 节 Algorithm R 的维护规则与不变式。

    2026-10-17 实际打开核对:PDF 第 1—3 页,重点第 2 节;论文的主要改进 Algorithm Z 未在本文实现,也不采用其历史机器计时作当前性能结论。

  • NIST 算法词典:Reservoir Sampling (新标签页)↗NIST Dictionary of Algorithms and Data Structures · 英文

    大小为 k 的盒子、每条记录的随机替换规则,以及每个 k 元组合等概率的目标。

    2026-10-17 实际打开核对:定义和 Note;这是 Algorithm R 的简要定义,本文代码独立编写。

  • Python 3.14:random.randrange (新标签页)↗Python Software Foundation · 英文

    从 range 范围选整数的接口,避免把浮点乘法取整等同于标准整数抽样。

    2026-10-17 实际打开核对:randrange 与文档关于均匀整数选择的说明;没有把固定种子当成概率证明。