# 实验 13：在线聚合——不等扫完就给出答案

> `SELECT max(b) FROM stuff` 要扫全表才知道确切结果，但用户往往等不了那么久：本实验实现"边扫边报数"的在线聚合（online aggregation），给运行中的估计值配上置信区间，并弄清它对哪种聚合有效、对哪种无效。

## 一、实验目标

完成本实验后，你应当能够：

1. 能解释全量聚合（full-blown aggregation）与在线聚合的区别，说出在线聚合原始论文的出处（Hellerstein、Haas、Wang，SIGMOD 1997）。
2. 能用 Python 生成器（`yield`）把一次性聚合改造成逐行产出运行结果的流式聚合。
3. 能实现带 95% 置信区间的均值型在线估计，并验证置信区间宽度按 1/√m 收缩（m 为已扫描行数）。
4. 能说明扫描顺序（随机序与有序序）对在线估计正确性的影响。
5. 能区分在线聚合与两类"长得像"的技术：DuckDB 的查询进度条（只报完成百分比与 ETA，不做统计推断）和 BigQuery 的 APPROX_* 函数（基于 sketch 的近似计算，另一条技术路线）。

## 二、预备知识

### 2.1 聚合的两副面孔

```sql
SELECT max(b) FROM stuff;
```

全量聚合的做法：从第一行扫到最后一行，维护当前最大值，扫完才返回。行数 n = 2×10⁵ 时机器毫秒级就能扫完，但数据到 10⁹ 行、或扫描本身要读盘要网络时，用户可能盯着黑屏等几分钟。在线聚合的做法：**每扫过一部分就给出当前答案，随扫描进行不断修正**，让用户扫到 10% 时就能对最终结果有个靠谱的判断——是"再等等"，还是"答案已经够用，直接停"。

### 2.2 逐行产出：Python 生成器

普通函数 `return` 一次给全部结果；**生成器函数（generator）**用 `yield` 逐个吐出中间结果，调用方拿到的是可以边循环边取的迭代器。这正是"边扫边报"的最小实现骨架：

```python
def online(xs):
    state = None
    for x in xs:
        state = update(state, x)   # 每处理一行更新一次状态
        yield state                # 立刻把当前状态交给调用方
```

聚合函数大多只需 O(1) 状态：MAX 记一个当前最大值，SUM 记一个累加和，AVG 记 (和, 计数)。所以"全量聚合"与"在线聚合"的差别不在算什么，而在**何时把结果交出去**。

### 2.3 均值型估计的置信区间

对"平均值"这类聚合，扫到第 m 行时的估计就是这 m 行的样本均值：

```text
x̄ₘ = (x₁ + x₂ + … + xₘ) / m
```

只要这 m 行是总体的一个随机样本，统计学的中心极限定理告诉我们：估计误差大致服从正态分布，标准误差（standard error）为

```text
SE = s / √m
```

其中 s 是这 m 个样本的样本标准差（总体标准差未知时用它代替），m 是已扫描行数。95% 置信区间（confidence interval，CI）取：

```text
[x̄ₘ − 1.96·SE,  x̄ₘ + 1.96·SE]
```

1.96 是标准正态分布 95% 分位数。区间宽度的近似式是本实验的核心公式：

```text
W(m) ≈ 2 × 1.96 × s / √m
```

每个符号：W 单位与数据相同；s 与数据同单位；m 是行数（无量纲）。要点：**W 与 √m 成反比**。宽度减半需要 m 扩大到 4 倍；从扫 1% 到扫 100%，宽度只缩到 1/10。误差下降是越到后面越"贵"的。

### 2.4 MAX 是另一回事

均值有 SE 公式可用的前提是"部分样本能代表总体"。MAX 没有这种代表性质：扫过的最大值永远不大于真值（只会往上爬），扫到 99% 时可能还差一个藏在最后 1% 里的巨值。不给分布假设，就无法为 MAX 写出统计意义上的置信区间。所以本实验两条线分开走：

- **MAX**：运行值是确定的下界，随扫描单调不减、最后一步跳到真值；它给出"至少有多大"，不承诺"有多大把握"。
- **AVG**：运行值 + 置信区间，可以量化"有多大的把握、误差多大"，从而支持"提前停止"。

### 2.5 扫描顺序为什么必须是随机的

CI 公式把已扫描的 m 行当作随机样本。如果表恰好按 b 降序存放（例如刚做过建索引的排序），扫前 10% 拿到的全是最大值，估计严重偏高；升序则严重偏低。所以在线聚合要求**随机顺序扫描（random-order scan）**，或在读取时打乱。物理布局不受你控制时，这一点要在心里打个问号——本实验会专门用一组对照验证它。

## 三、实验原理

### 3.1 在线聚合的三要素

按 Hellerstein、Haas、Wang 1997 年的原始论文《Online Aggregation》（SIGMOD 1997），一个在线聚合系统要提供：

1. **运行估计（running estimate）**：随扫描不断更新的当前答案。
2. **置信区间（confidence bound）**：当前答案可能偏离真值的范围及置信水平。
3. **停止规则（stopping rule）**：当区间宽度小于用户要求的精度时提前结束扫描。

本实验用生成器实现第 1 项，用 2.3 节公式实现第 2 项，第 3 项留成实验步骤里的一个判断。

### 3.2 参考实测数据

本机实测（CPython 3.13 + Windows；n = 200000，Gamma 分布数据，`default_rng(0)` 固定种子，随机顺序扫描，估计对象为均值型聚合，95% CI；由本节步骤 5–6 的代码逐位复现）：

| 扫描进度 m/n | 运行估计 | 备注 |
| --- | --- | --- |
| 10% | 50.4435 | 与真值相差 0.344 |
| 100% | 50.0996 | 即总体真值 |
| 100% | — | 95% CI 宽度 0.3102 |

由 2.3 节公式反推总体标准差：W = 2 × 1.96 × s/√m，取 W = 0.3102、m = 200000，得

```text
s ≈ 0.3102 × √200000 / 3.92 ≈ 35.4
```

均值约 50、标准差约 35 的分布，实验代码用 Gamma 分布（形状参数 shape = 2、尺度参数 scale = 25，理论均值 50、标准差 √(2×25²) ≈ 35.4）生成。上表数字由步骤 5 的固定种子（`default_rng(0)`）实测得出，逐位可复现；换随机种子或改用 notebook 的 Faker 数据后，具体数字会略有差异，判断标准是"数量级与趋势"。

区间宽度的收缩验证：W(m) ∝ 1/√m，故

```text
W(20%) ≈ W(100%) / √0.2 ≈ 0.3090 / 0.447 ≈ 0.69
W(10%) ≈ W(100%) / √0.1 ≈ 0.3090 / 0.316 ≈ 0.98
```

扫到 10% 时区间宽约 1，扫到 100% 时收窄到 0.31——比例 1/√10。把你的实测填进观测表后应能直接看出这一关系。

![在线聚合：运行估计与置信区间随扫描收缩](../figures/fig_online_agg.png)

### 3.3 与两类"长得像"的技术的边界

- **DuckDB 的进度条**：打印已完成的百分比和预计剩余时间（ETA）。它不改变估计的统计性质，也不做抽样推断——是"告诉你还要等多久"，不是"提前告诉你答案大概是什么"。在线聚合三要素里它一项都不占。
- **BigQuery 的 APPROX_* 函数**（如 APPROX_COUNT_DISTINCT）：基于 sketch（概要数据结构）的一次遍历近似，牺牲精度换速度，但**结果不随时间流式细化、通常不附带置信区间**。它回答"能不能算得更快"，在线聚合回答"能不能边算边信"。两者是不同的路线，文献里注意别混引。

### 3.4 提前停止值多少

设扫描速度恒定，扫完需要时间 T。若用户只需"95% 置信下宽度不超过 0.5"的答案，由 W(m) = W(n)·√(n/m) 反解：

```text
m = n × (W(n) / 0.5)² = 200000 × (0.3090 / 0.5)² ≈ 76000 行 ≈ 全表的 38%
```

也就是约 62% 的扫描时间被省掉，代价是答案带一个明确的误差声明。这个"精度换时间"的交易是在线聚合的实际价值所在；本实验最后一步会把它做成可运行的停止规则。

## 四、实验环境与准备

1. 进入仓库根目录 `explaindb-main`。
2. 安装依赖：

   ```bash
   pip install faker numpy matplotlib jupyter
   ```

3. 打开配套 notebook：

   ```bash
   jupyter notebook notebooks/Online-Aggregation.ipynb
   ```

4. notebook 用 40 行小数据演示两种算法与单元测试；第五章在 20 万行规模上加入置信区间与扫描顺序对照。需要 Python 3.10 及以上。

## 五、实验步骤

### 步骤 1：造数据（对应 notebook 的小规模版）

```python
from dataclasses import dataclass

@dataclass
class Stuff:
    a: int
    b: int

# 小规模演示（与 notebook 一致的规模）
import random
rng = random.Random(42)
data_small = [Stuff(rng.randrange(1000), rng.randrange(2000)) for _ in range(40)]
print(data_small[:5])
```

### 步骤 2：全量聚合（对应 notebook 算法 1）

```python
import math

def aggregate(rows, attribute):
    """一次性聚合：扫完才返回结果。"""
    current_max = -math.inf
    for el in rows:
        current_max = max(current_max, el.__dict__[attribute])
    return current_max

print(aggregate(data_small, "b"))
```

### 步骤 3：在线聚合（对应 notebook 算法 2）

```python
def online_aggregation(rows, attribute):
    """逐行产出运行最大值：每处理一行 yield 一次。"""
    current_max = -math.inf
    for el in rows:
        current_max = max(current_max, el.__dict__[attribute])
        yield current_max

snaps = online_aggregation(data_small, "b")
for i, v in enumerate(snaps, 1):
    if i in (1, 10, 20, 40):
        print(f"扫过 {i:>2} 行: max = {v}")
```

注意 `online_aggregation` 的函数体与 `aggregate` 几乎逐行相同，只把 `return` 换成了循环内的 `yield`。运行值只升不降，最后一行跳到真值。

### 步骤 4：单元测试确认两法等价

```python
import unittest

class TestAggregation(unittest.TestCase):
    def test_end_to_end(self):
        for trial in range(10):
            r = random.Random(trial)
            rows = [Stuff(r.randrange(20), r.randrange(20)) for _ in range(1000)]
            res1 = aggregate(rows, "b")
            *_, res2 = online_aggregation(rows, "b")   # 取生成器最后一个值
            self.assertEqual(res1, res2)

unittest.main(argv=["ignored", "-v"], defaultTest="TestAggregation", exit=False)
```

### 步骤 5：放大到 20 万行，构造随机顺序扫描

```python
import numpy as np

rng = np.random.default_rng(0)
N = 200_000
# Gamma(2, 25)：均值 50，标准差约 35.4，与参考实测同分布
values = rng.gamma(shape=2.0, scale=25.0, size=N)

order = rng.permutation(N)      # 随机扫描顺序：在线估计正确性的前提
scan = values[order]

true_mean = values.mean()       # 全量聚合的真值（见 3.2 节参考实测表：50.0996）
print("真值:", round(true_mean, 4))
```

### 步骤 6：带置信区间的在线均值估计

```python
import math

def online_mean_with_ci(xs, checkpoints=(0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)):
    """边扫边报：运行均值、样本标准差、95% CI 及其宽度。
    W(m) = 2 × 1.96 × s / √m，随 m 按 1/√m 收缩。"""
    n = len(xs)
    total = 0.0
    marks = {max(1, int(c * n)) for c in checkpoints}
    for m, x in enumerate(xs, 1):
        total += x
        if m in marks:
            mean = total / m
            s = xs[:m].std(ddof=1)          # 样本标准差
            half = 1.96 * s / math.sqrt(m)
            print(f"m = {m:>6} ({m/n:>4.0%})  估计 = {mean:.4f}  "
                  f"95% CI = [{mean - half:.4f}, {mean + half:.4f}]  宽度 = {2*half:.4f}")

online_mean_with_ci(scan)
```

对照 3.2 节：m = 10% 时估计与真值相差约 0.34、宽度约 1；m = 100% 时宽度收窄到 0.31 附近（参考实测：10% 估计 50.4435，100% 估计 50.0996、宽度 0.3102）。

### 步骤 7：验证 1/√m 收缩

```python
import math

widths = {}
total = 0.0
for m, x in enumerate(scan, 1):
    total += x
    if m in (2_000, 20_000, 50_000, 200_000):
        widths[m] = 2 * 1.96 * scan[:m].std(ddof=1) / math.sqrt(m)

print("m        宽度     宽度×√m")
for m, w in widths.items():
    print(f"{m:>7} {w:>8.4f} {w * math.sqrt(m):>8.2f}")
```

理论预言最后一列近似常数（都约等于 2 × 1.96 × s）。若你的四行最后一列彼此接近，就实测验证了 W ∝ 1/√m。

### 步骤 8：有序序对照（反例）

```python
bad_scan = np.sort(values)          # 升序扫描
print("升序扫描，m = 10% 时估计:", round(bad_scan[:20_000].mean(), 4), "（真值约 50）")
print("降序扫描，m = 10% 时估计:", round(np.sort(values)[::-1][:20_000].mean(), 4))
```

升序时前 10% 全是最小的值，估计远低于真值且 CI 完全失真——区间"罩不住"真值。这说明 2.5 节的随机性不是可选项。

### 步骤 9：提前停止规则

```python
import math

TARGET_WIDTH = 0.5                   # 用户要求的精度：95% CI 宽度 ≤ 0.5
total, stopped_at = 0.0, None
for m, x in enumerate(scan, 1):
    total += x
    if m % 1000 == 0:
        mean = total / m
        w = 2 * 1.96 * scan[:m].std(ddof=1) / math.sqrt(m)
        if w <= TARGET_WIDTH:
            stopped_at = m
            break

print(f"在第 {stopped_at} 行（{stopped_at/N:.0%}）停止，"
      f"估计 {total/stopped_at:.4f}，真值 {true_mean:.4f}")
```

参考 3.4 节的推算，停止点应在全表 38% 附近。省下的时间就是在线聚合卖给你的东西。

## 六、观测记录表

**表 1：MAX 的运行轨迹（把每次 yield 的值填进表格，记 5 个时点）**

| 已扫描比例 | 运行最大值 | 与真值之差 |
| --- | --- | --- |
| 10% |  |  |
| 25% |  |  |
| 50% |  |  |
| 75% |  |  |
| 100% |  |  |

**表 2：均值的在线估计（随机顺序扫描）**

| m/n | 运行估计 | 95% CI 下界 | 95% CI 上界 | CI 宽度 | 宽度 × √m |
| --- | --- | --- | --- | --- | --- |
| 10% |  |  |  |  |  |
| 20% |  |  |  |  |  |
| 50% |  |  |  |  |  |
| 100% |  |  |  |  |  |

**表 3：扫描顺序对照（m = 10% 时的估计）**

| 扫描顺序 | 运行估计 | 偏离真值多少 |
| --- | --- | --- |
| 随机顺序 |  |  |
| 升序 |  |  |
| 降序 |  |  |

**表 4：提前停止（TARGET_WIDTH 从 1.0 收紧到 0.3）**

| 目标宽度 | 停止位置 m/n | 运行估计 | 真值 |
| --- | --- | --- | --- |
| 1.0 |  |  |  |
| 0.5 |  |  |  |
| 0.3 |  |  |  |

## 七、思考题

1. `aggregate` 与 `online_aggregation` 的函数体几乎一样，只差 `yield`。从内存占用与首结果延迟（time to first result）两个角度比较两者。
2. MAX 的运行值单调不减、且永远不大于真值。为什么它天然不需要置信区间也可以用？用户看到"运行值 1937 且 5 分钟没变"能推出什么、不能推出什么？
3. 由 W(100%) = 0.3090 反推数据的样本标准差；再把宽度压到 0.1 需要扫多少行？在 n = 200000 的表上做得到吗？这说明什么？
4. 为什么升序扫描会让置信区间失效，而不是仅仅"估计偏低"？提示：CI 公式的前提是哪一条被破坏了？
5. 若把 95% 换成 99%，分位数 1.96 要换成约 2.58。同样 20 万行，CI 宽度变为多少倍？精度与把握之间你如何取舍？
6. DuckDB 查询进度条与在线聚合的本质区别是什么？如果一个系统只输出"已完成 37%"，用户能对最终结果做出什么判断、不能做出什么判断？
7. BigQuery 的 APPROX_COUNT_DISTINCT 与在线聚合都在"没扫完就要答案"，两者的交换物分别是什么？（提示：一个换的是精度不换时间结构，一个换的是时间结构。）
8. 停止规则实验里，目标宽度 0.3 时停止位置比理论推算（0.3090/0.3)² × n ≈ 106% 更晚甚至扫完了。解释为什么目标宽度接近 W(n) 时提前停止失去意义。

## 八、思考题答案与解析

1. 内存：两者都是 O(1) 状态（一个当前最大值），差别不在内存。首结果延迟：`aggregate` 必须扫完 n 行才有第一个也是唯一一个结果；`online_aggregation` 处理完第 1 行就产出第一个结果。流式接口还允许调用方在任意时点"止损"放弃扫描，这正是 3.4 节停止规则的接口基础。
2. 运行值是确定性的下界：无论剩下什么数据，真值只可能更大，所以"至少 1937"这个命题永远成立，不需要概率论。5 分钟没变只说明"没遇到更大的值"，不能推出"真值就是 1937"——除非对 b 的分布有假设（例如已知上界），否则最后 1% 藏一个巨值的可能性无法排除。
3. 由 W = 2 × 1.96 × s/√m：s = 0.3090 × √200000 / 3.92 ≈ 35.2。宽度压到 0.1 需要 m = (3.92 × 35.2 / 0.1)² ≈ 1.9 × 10⁶ 行，约为本表行数的 9.5 倍——在这张表上做不到，只能换更大的表或接受更宽的区间。这说明 1/√m 的收缩非常"贵"：精度要求每提高 10 倍，数据量要扩大 100 倍。
4. CI 公式的成立前提是"已扫描的 m 行构成随机样本"，于是样本均值围绕真值波动、波动幅度由 SE 刻画。升序扫描的前 m 行是最小的 m 个值，样本均值系统性偏低（偏差，不是方差），且这个偏差不随 m 增大而消失——扫到 99% 仍偏低。区间是以"无偏估计 ± 1.96·SE"为中心构造的，中心错了，区间整体错位，无论多宽都罩不住真值。这叫估计量有偏，偏差无法靠加宽区间弥补。
5. 宽度变为 2.58/1.96 ≈ 1.32 倍，即同样的数据把握提高 4 个百分点，代价是区间放宽三成。取舍取决于应用：报表初筛可要 90%（1.64）；涉及钱和承诺的数字要 99% 以上，或者干脆扫完拿精确值。
6. 进度条提供的是执行状态（扫到哪了），在线聚合提供的是结果的统计性质（答案大概是多少、误差多大）。看到"已完成 37%"，用户能判断"还要等多久"，不能判断"结果会不会和最终值差很多"；在线聚合的 CI 恰好补上后半句。原论文（Hellerstein、Haas、Wang，SIGMOD 1997）的三要素——运行估计、置信区间、停止规则——进度条一项都不具备。
7. APPROX_* 交换的是精度：一次遍历、常数内存给出有误差保证的近似值（sketch 路线），运行时间结构与精确计算相同（都是扫一遍），只是每步更便宜。在线聚合交换的是时间结构：扫描可以随时中断、答案随中断点越来越准（CI 越来越窄），单行成本未必更省。一句话：前者"算得快但不能边算边看"，后者"边算边看但未必算得快"。
8. W(n) = 0.3090 是全表扫完时的宽度下限。目标 0.5、1.0 都大于它，所以在扫完之前就能达标；目标 0.3 小于 W(n)，任何中途时刻都不可能达标，停止条件永不触发，退化成全表扫描。使用停止规则前要先算好理论可达精度，或让系统在"扫完仍不达标"时给出明确提示。

## 九、扩展任务（选做）

1. **Bootstrap 置信区间。** 把 2.3 节的正态近似换成 bootstrap：在每个检查点对已扫描的 m 个值有放回重采样 B = 1000 次，取样本均值的 2.5% 与 97.5% 分位数作为 CI。对比两种 CI 在 m = 10% 时的宽度与覆盖率，讨论小 m 时哪种更可信。提示：`np.random.default_rng().choice(xs[:m], size=(B, m), replace=True).mean(axis=1)`。
2. **分位数聚合的在线化。** 均值有 SE 公式，中位数没有闭式解。实现运行中位数（维护已扫描值的分位数，可用 `np.quantile`），用 bootstrap 给它配 CI，观察 m = 50% 前 CI 的行为。提示：这是"在线聚合能推广到哪类聚合函数"的入门题，天平会倒向样本量需求更大的一侧。
3. **多表连接下的在线聚合。** 原论文的核心贡献其实在连接（join）后的聚合。把两表内连接的结果流式化：一边流式产出匹配对、一边喂给步骤 6 的在线均值。观察连接顺序（小表build大表probe）对早期估计质量的影响。提示：不需要真的建哈希表，用两个 `defaultdict(list)` 模拟即可。

## 十、常见错误与排查

| 现象 | 原因 | 处理 |
| --- | --- | --- |
| 生成器第二次遍历没有内容 | 生成器只能消费一次，耗尽即空 | 需要重跑时重新调用 `online_aggregation(...)`，或改用 `list(...)` 缓存 |
| `*_, res2 = online_aggregation(...)` 报解包错误 | 数据为空，生成器一次也没 yield | 先确认输入非空；或给生成器加"空输入"分支 |
| CI 宽度不随 1/√m 收缩 | 扫描顺序没有随机化（用了有序数据） | 步骤 5 的 `rng.permutation` 必须执行；先跑步骤 8 的反例自查 |
| 估计值离真值很远且不收敛 | 数据里混入了极端值或分布不是预期分布 | 打印 `values.mean()`、`values.std()` 与理论值 50、35.4 对照 |
| 步骤 6 每个检查点都很慢 | `xs[:m].std(ddof=1)` 每次重扫前 m 个元素 | 教学规模（20 万行）可接受；提速可用 Welford 在线算法维护均值与方差 |
| 停止规则从未触发 | 目标宽度小于 W(n)（见思考题 8） | 把 `TARGET_WIDTH` 放宽到 0.5 以上，或增大 n |
| 99% CI 比参考实测宽很多 | 分位数写错（如写成 2.96） | 95% 用 1.96，99% 用约 2.58，90% 用约 1.64 |
| notebook 的 `faker` 数据与本实验数字对不上 | notebook 只有 40 行且是整数，不产生统计意义 | 40 行用于理解接口；统计实验用第五章的 20 万行 Gamma 数据 |
