实验 07:递归模型索引(RMI)
> 用一串很小的数学模型去拟合"键值 → 数组下标"这条曲线,把有序数组上的查找从"折半比较"改成"先预测、再在很小的邻域里修正"。
一、实验目标
完成本实验后,你应该能够:
1. 说出学习索引(learned index)与 B 树在"看待索引的方式"上的根本差别:前者把索引当成一个函数拟合问题,后者把索引当成一个"分层导航 + 最后一页精确查找"的问题。
2. 独立写出四种查找算法:二分查找、指数搜索(exponential search / galloping search)、单层 RMI、多层 RMI,以及带误差界(error bound)的 RMI。
3. 用**平均绝对误差**与**最大绝对误差**两个指标量化"模型预测得准不准",并解释为什么这两个指标关注点不同。
4. 用 ns/次 为单位测量各算法的单查询耗时,并正确解释"为什么我手写的 RMI 跑不过 `bisect`"。
5. 说明 RMI 的适用边界:静态、内存内、有序、一维。
二、预备知识
2.1 下界查找(lower bound)
有序数组里可能有重复值,所以"查找"通常不是找"有没有",而是找**第一个不小于目标值 x 的元素下标**。这个位置叫下界(lower bound)。
```
数组 a = [1, 2, 2, 2, 5, 7]
lower_bound(a, 2) = 1 # 第一个 >= 2 的元素在下标 1
lower_bound(a, 3) = 4 # 第一个 >= 3 的元素是 5,在下标 4
lower_bound(a, 8) = 6 # 没有 >= 8 的元素,返回 len(a)
```
记法:本文档用 `N` 表示数组长度,下标从 0 到 `N-1`,`len(a)` 表示"未找到"。
2.2 二分查找
每次取中点比较,把搜索区间减半。
```
初始区间 [lo, hi) 宽度 = N
第1次比较 [lo, mid) 或 [mid+1, hi) 宽度 ≈ N/2
第2次比较 ... 宽度 ≈ N/4
...
第k次比较 宽度 ≈ N / 2^k
```
比较次数:
```
k = ⌈log₂(N + 1)⌉
```
`N = 200,000` 时,`log₂(200001) ≈ 17.61`,即约 18 次比较。**注意**:这个公式只统计"比较次数",不统计内存访问。
2.3 指数搜索
二分查找的搜索区间是"整段"。如果我们事先知道目标离某个位置 `p` 很近,就先用步长 1、2、4、8… 向外扩,把目标"框"进一个小区间,再在小区间里二分。
```
预测位置 pred
|
...| | | | | | |...
+1 +2 +4 +8 +16 +32 +64
扩 扩 扩 扩 扩 扩 扩 ← 步长翻倍
└──────────── 找到区间后在此二分 ─────────────┘
```
若真实位置与预测位置的距离为 `d`,则扩区间需要约 `⌈log₂ d⌉` 步,区间内二分再需约 `⌈log₂ d⌉` 步,合计约 `2⌈log₂ d⌉ + 1` 次比较。**`d` 越小,指数搜索越划算**,这正是 RMI 要努力减小 `d` 的原因。
2.4 线性样条模型(linear spline)
一条直线,穿过点集的首尾两点。给一组输入 `X = [x₀, x₁, ..., x_{m-1}]` 和目标 `Y = [y₀, ..., y_{m-1}]`(本实验中 `Y` 就是下标):
```
斜率 a = (Y[m-1] - Y[0]) / (X[m-1] - X[0])
截距 b = Y[0] - a · X[0]
预测 ŷ = f(x) = a · x + b
```
符号含义:`X` 为按键升序排列的键值;`Y` 为对应的数组下标;`a` 的单位是"下标/键值",`b` 的单位是"下标"。当 `X[m-1] == X[0]`(该段所有键相同)时除零,实现里必须特判。
只有两个点参与拟合,所以**训练是 O(1) 的**——这是 RMI 能做得极快的前提。
2.5 关键类比:索引就是累积分布函数(CDF)
这是整个实验的思想核心。给定一个升序数组 `D`(长度 `N`),定义它的经验累积分布函数:
```
F(x) = |{ k ∈ D : k ≤ x }| / N (取值在 [0, 1] 之间)
```
`F(x)` 的含义是"数组里不大于 x 的元素所占的比例"。那么 `x` 在数组中的位置就近似是:
```
pos(x) ≈ F(x) · N
```
举例:`N = 200,000`,若 `F(x) = 0.4`,则 `pos(x) ≈ 80,000`。
于是:
| 传统索引观 | 学习索引观 |
| --- | --- |
| 索引是一棵用于导航的树 | 索引是一个拟合 `F(x)` 的函数 |
| 查找靠逐层比较 | 查找靠一次函数求值 |
| 命中叶节点后在小页内二分 | 预测后用误差邻域内二分 |
| 复杂度与数据分布无关 | 预测误差依赖数据分布 |
这一观点出自 Kraska et al., *The Case for Learned Index Structures*, SIGMOD 2018(arXiv:1712.01208)。
2.6 RMI 的分层结构
RMI(Recursive Model Index,递归模型索引)由若干"层"组成,每层有若干模型(本实验全部用线性样条)。
```
第 0 层(根)
┌──────────────┐
key ────────► │ model 0 │ 预测出的位置 → 决定走哪个下层模型
└──────┬───────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐ 第 1 层(末层)
│ model 0 │ │ model 1 │ │ model 2 │ ... 共 M 个
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
▼ ▼ ▼
预测位置 预测位置 预测位置
└─────────────────┴─────────────────┘
│
局部搜索(指数/二分)
│
▼
真实下标
```
要点:
• 第 0 层恒为 1 个模型(根模型)。
• 用 `layer_sizes` 描述每层模型数,例如 `[1, 1000]` 表示两层、末层 1000 个模型。
• 层数 ≥ 3 时,中间层只负责"路由",只有末层模型负责给出最终位置预测。
• 每个模型只覆盖一小段键域,段越窄,直线拟合越准 → 误差越小。这就是"加模型能降误差"的直觉来源。
三、实验原理
3.1 训练:自顶向下分配键
训练是一个逐层进行的过程:
1. 根模型在**全部** `(key, index)` 对上训练。
2. 用刚训练好的模型预测它所见到的每个键的位置 `pred`,再按比例映射到一个下层模型编号:
```
next_model = ⌊ pred / N × layer_sizes[layer+1] ⌋
```
3. 把该键分配给 `next_model`。
4. 一层训练完,进入下一层,直到末层。
`pred / N` 是"预测位置占全表的比例",乘以"下层模型总数"就得到"应该落到第几个下层模型"。理想情况下每个末层模型分到 `N / M` 个键(等深划分)。
这条规则带来一个很好的性质:**查询数据集中真实存在的键时,只会用到确实训练过它的那些模型**。
3.2 查询:预测 + 局部修正
```
value
│
├─ 第 0 层:pred₀ = f₀(value)
│ model = ⌊pred₀ / N × 下层模型数⌋
├─ 第 1 层:pred₁ = f₁(value) (若为末层则停止)
│ ...
└─ 末层: pred = clamp(f_last(value), 0, N-1)
│
├─ 无误差界:在 pred 处做指数搜索
└─ 有误差界:在 [pred - err, pred + err] 内做二分
```
`clamp(v, 0, N-1)` 表示 `max(0, min(N-1, v))`,防止预测越界。
3.3 误差界(error bound)
训练完成后,回扫一遍数据集,为每个末层模型 `j` 记录它训练样本上的**最大绝对误差**:
```
err_j = max | clamp(f_j(key)) − true_index | (对该模型分到的所有 key 取最大)
```
查询时用 `err_j` 把搜索范围锁死:
```
lo = max(0, pred − err_j)
hi = min(N, pred + err_j + 1)
```
区间宽度 `2·err_j + 1`,二分需要约 `⌈log₂(2·err_j + 1)⌉` 次比较。
对比:末层 1000 个模型时本数据集实测最大绝对误差为 1555(见 6.2 表),区间宽度 3111,`⌈log₂ 3111⌉ = 12` 次比较;而整表二分 `N = 200,000` 需要约 18 次,省 6 次。数据分布越平滑、误差越小,这个差距越大——这就是误差界的价值。
**注意一处易错点**:`err_j` 是在**训练样本**上统计的。如果查询的键不在数据集中(未命中查询),真实误差可能超出 `err_j`,此时区间内二分会返回错误结果。工程实现需要额外处理(例如区间端点处做边界检查,或退化为整表二分)。
3.4 为什么本实验里 RMI 跑不过 bisect
必须讲清楚,否则学生会得出错误结论:
• 本实验的 RMI 是**纯 Python**实现。一次预测要经历:方法调用 → 属性查找 → 浮点乘加 → `int()` → `max/min` → 又一次函数调用(指数搜索)→ 循环里的下标访问。Python 解释器的每条字节码都有固定开销。
• 实验里的 `bisect` 是 **C 实现**的 `bisect.bisect_left`,一次调用几乎不进入 Python 字节码循环。
• 所以"RMI 比二分快"这件事,在纯 Python 环境下测不出来。
RMI 的真实价值体现在另外两处,本实验可以观测到其中一处:
1. **预测精度**:误差随末层模型数单调下降(6.2 表),这正是学习索引的核心机制,与实现语言无关。
2. **缓存友好性**:RMI 查找时访问的内存地址是"预测位置附近的一小段",而不是二分那样跳跃访问 `N/2`、`N/4`、`N/8`… 这些跨度极大的地址。二分的前若干次比较几乎每次都触发 cache miss。这一点在本实验的纯 Python 环境下被解释器开销掩盖了。
论文中报告的"相比 B 树 3× 以上加速"来自优化过的 C++ 实现,本实验不承诺复现该数字。
3.5 参考图件
| 图件 | 内容 |
| --- | --- |
| `../figures/fig_rmi_cdf.png` | 键的累积分布曲线 `F(x)` 与 `pos ≈ F(x)·N` 的对应关系 |
| `../figures/fig_rmi_error.png` | 不同末层模型数下预测位置与真实位置的偏差 |
| `../figures/fig_rmi_bench.png` | 各查找算法的 ns/次 对比 |
四、实验环境与准备
4.1 环境要求
| 项目 | 要求 |
| --- | --- |
| Python | 3.10 及以上 |
| 必装库 | 无(只用标准库 `random` / `bisect` / `statistics` / `time`) |
| 选装库 | `matplotlib`(步骤 8 画图)、`numpy`(步骤 8 画图,可选) |
| 参考实现 | `notebooks/Recursive-Model-Index.ipynb` |
安装命令(若缺):
```bash
pip install matplotlib numpy
```
4.2 目录约定
把本指导书的全部代码放在同一个 `.py` 文件里(例如 `rmi_lab.py`)顺序执行,或在 Jupyter 中按步骤分格执行。步骤 2–7 是"搭积木",步骤 8–9 是"测量"。
4.3 关于数据集规模
本指导书统一使用 `N = 200,000`。第 6 节给出的实测数据均在此规模下取得。若机器较慢,把 `N` 改成 `20,000` 也能跑通全部流程,但**数字会与第 6 节不同**,此时请填写你自己的实测值。
五、实验步骤
步骤 1:生成合成数据
数据由多项式加噪声生成,密度随键值增大而降低(这是 RMI 能"学到东西"的前提——如果数据完全均匀,一条直线就够了)。
```python
import random
N = 200_000
def poly_fn(x: int) -> int:
"""多项式函数:值随 x 二次增长,因此键域上稀疏、键域下稠密。"""
return int(1 / 1000 * x ** 2)
random.seed(0)
data = []
for i in range(N):
noise = random.randint(int(-N * 0.001), int(N * 0.001))
data.append(poly_fn(i) + noise)
data = sorted(data) # RMI 只作用于已排序数组
print("N =", len(data))
print("min =", data[0], " max =", data[-1])
```
画直方图看分布(需要 `matplotlib`):
```python
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.hist(data, bins=20)
ax.set_xlabel("Key")
ax.set_ylabel("Frequency")
plt.show()
```
你会看到左侧频次高、右侧频次低——即"密度随键值增大而降低"。
步骤 2:基线算法——二分查找
```python
def lower_bound_binary_search(a, value, start=None, lo=None, hi=None):
"""返回 a 中第一个 >= value 的下标;不存在则返回 len(a)。
lo/hi 可限定搜索区间 [lo, hi);start 可指定第一次比较的位置。
"""
if lo is None:
lo = 0
if hi is None:
hi = len(a)
assert 0 <= lo <= hi <= len(a)
if start is not None:
assert lo <= start < hi
if a[start] < value:
lo = start + 1
else:
hi = start
while lo < hi:
mid = lo + (hi - lo) // 2
if a[mid] < value:
lo = mid + 1
else:
hi = mid
return lo
自检:与线性查找对拍
def lower_bound_linear_search(a, value):
for i, v in enumerate(a):
if v >= value:
return i
return len(a)
small = sorted([3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5])
for x in range(0, 11):
assert lower_bound_binary_search(small, x) == lower_bound_linear_search(small, x)
print("二分查找自检通过")
```
步骤 3:线性样条模型
```python
def is_sorted(lst):
return all(lst[i] <= lst[i + 1] for i in range(len(lst) - 1))
class LinearSplineModel:
"""穿过 (X[0], Y[0]) 与 (X[-1], Y[-1]) 的直线模型。"""
def __init__(self, X, Y):
assert is_sorted(X), "X 必须升序"
assert len(X) == len(Y)
if len(X) == 0:
self.slope_ = 0.0
self.intercept_ = 0.0
elif len(X) == 1:
self.slope_ = 0.0
self.intercept_ = float(Y[0])
elif X[-1] == X[0]: # 防止除零
self.slope_ = 0.0
self.intercept_ = float(Y[-1])
else:
self.slope_ = (Y[-1] - Y[0]) / (X[-1] - X[0])
self.intercept_ = Y[0] - self.slope_ * X[0]
def predict(self, x):
return self.slope_ * x + self.intercept_
m = LinearSplineModel([0, 10, 20], [0, 1, 2])
print("slope =", m.slope_, "intercept =", m.intercept_)
print("predict(5) =", m.predict(5)) # 期望 0.5
```
步骤 4:指数搜索
```python
def lower_bound_exponential_search(a, value, start=None, lo=None, hi=None):
"""从 start 出发按 1,2,4,8... 扩区间,再在区间内二分。"""
if lo is None:
lo = 0
if hi is None:
hi = len(a)
if start is None:
start = lo
assert 0 <= lo <= start < hi <= len(a)
bound = 1
prev = start
if a[start] < value: # 向右扩
curr = min(prev + bound, hi)
while curr < hi and a[curr] < value:
bound *= 2
prev = curr
curr = min(prev + bound, hi)
return lower_bound_binary_search(a, value, lo=prev, hi=min(curr + 1, len(a)))
else: # 向左扩
curr = max(prev - bound, lo)
while curr > lo and a[curr] >= value:
bound *= 2
prev = curr
curr = max(prev - bound, lo)
return lower_bound_binary_search(a, value, lo=curr, hi=prev)
for x in range(0, 11):
for s in range(len(small)):
assert lower_bound_exponential_search(small, x, start=s) == \
lower_bound_linear_search(small, x)
print("指数搜索自检通过")
```
步骤 5:单层 RMI
```python
class SingleLayerRMI:
"""一个线性样条模型 + 指数搜索修正。"""
def __init__(self, sorted_list):
assert len(sorted_list) > 0 and is_sorted(sorted_list)
self.N = len(sorted_list)
self.model = LinearSplineModel(sorted_list, list(range(self.N)))
def _predict(self, value):
return max(0, min(self.N - 1, int(self.model.predict(value))))
def lower_bound_search(self, sorted_list, value):
pred = self._predict(value)
return lower_bound_exponential_search(sorted_list, value, start=pred)
single = SingleLayerRMI(data)
v = 1337
print("二分:", lower_bound_binary_search(data, v))
print("单层RMI:", single.lower_bound_search(data, v))
assert single.lower_bound_search(data, v) == lower_bound_binary_search(data, v)
```
单次预测误差统计:
```python
import statistics
pred_single = [single._predict(k) for k in data]
err_single = [abs(p - i) for i, p in enumerate(pred_single)]
print("单层 RMI 平均绝对误差 =", round(statistics.mean(err_single), 1))
print("单层 RMI 最大绝对误差 =", max(err_single))
```
步骤 6:两层 RMI
```python
class MultiLayerRMI:
"""layer_sizes=[1, M] 表示两层:根模型 + M 个末层模型。"""
def __init__(self, sorted_list, layer_sizes):
assert len(sorted_list) > 0 and is_sorted(sorted_list)
assert len(layer_sizes) > 0 and layer_sizes[0] == 1
self.N = len(sorted_list)
indexes = list(range(self.N))
model_keys = [[[] for _ in range(s)] for s in layer_sizes]
model_idx = [[[] for _ in range(s)] for s in layer_sizes]
model_keys[0][0] = sorted_list
model_idx[0][0] = indexes
self.models = []
for layer, layer_size in enumerate(layer_sizes):
self.models.append([])
for mi in range(layer_size):
mdl = LinearSplineModel(model_keys[layer][mi], model_idx[layer][mi])
self.models[layer].append(mdl)
if layer < len(layer_sizes) - 1:
for key, idx in zip(model_keys[layer][mi], model_idx[layer][mi]):
pred = max(0, min(self.N - 1, int(mdl.predict(key))))
nxt = int(pred / self.N * layer_sizes[layer + 1])
nxt = min(nxt, layer_sizes[layer + 1] - 1)
model_keys[layer + 1][nxt].append(key)
model_idx[layer + 1][nxt].append(idx)
def _get_last_layer_model(self, value):
model, layer = 0, 0
while layer < len(self.models) - 1:
pred = max(0, min(self.N - 1, int(self.models[layer][model].predict(value))))
model = int(pred / self.N * len(self.models[layer + 1]))
model = min(model, len(self.models[layer + 1]) - 1)
layer += 1
return model
def _predict(self, value):
j = self._get_last_layer_model(value)
return max(0, min(self.N - 1, int(self.models[-1][j].predict(value))))
def lower_bound_search(self, sorted_list, value):
pred = self._predict(value)
return lower_bound_exponential_search(sorted_list, value, start=pred)
two = MultiLayerRMI(data, [1, 1000])
assert two.lower_bound_search(data, v) == lower_bound_binary_search(data, v)
print("两层 RMI 自检通过")
```
步骤 7:带误差界的两层 RMI
```python
class MultiLayerRMIWithBounds(MultiLayerRMI):
"""为每个末层模型记录训练期最大绝对误差,查询时在 [pred-err, pred+err] 内二分。"""
def __init__(self, sorted_list, layer_sizes):
super().__init__(sorted_list, layer_sizes)
self.errors = [0] * len(self.models[-1])
for index, key in enumerate(sorted_list):
j = self._get_last_layer_model(key)
pred = max(0, min(self.N - 1, int(self.models[-1][j].predict(key))))
self.errors[j] = max(self.errors[j], abs(pred - index))
def _predict_with_bounds(self, value):
j = self._get_last_layer_model(value)
pred = max(0, min(self.N - 1, int(self.models[-1][j].predict(value))))
e = self.errors[j]
return pred, max(0, pred - e), min(self.N, pred + e + 1)
def lower_bound_search(self, sorted_list, value):
pred, lo, hi = self._predict_with_bounds(value)
return lower_bound_binary_search(sorted_list, value, start=pred, lo=lo, hi=hi)
two_b = MultiLayerRMIWithBounds(data, [1, 1000])
assert two_b.lower_bound_search(data, v) == lower_bound_binary_search(data, v)
print("带误差界 RMI 自检通过;末层模型误差上界 =", max(two_b.errors))
```
步骤 8:误差随末层模型数的变化
```python
import random as _rnd
import statistics
_rnd.seed(0)
SAMPLE = 20_000
sample_idx = sorted(_rnd.sample(range(N), SAMPLE))
def error_stats(rmi):
errs = [abs(rmi._predict(data[i]) - i) for i in sample_idx]
return statistics.mean(errs), max(errs)
rows = []
for M in [1, 10, 100, 500, 1000, 5000, 20000]:
rmi = MultiLayerRMI(data, [1, M])
mean_err, max_err = error_stats(rmi)
rows.append((M, mean_err, max_err))
print(f"末层模型数 {M:>6}: 平均绝对误差 {mean_err:8.1f} 最大绝对误差 {max_err:6d}")
```
画图(需要 `matplotlib`):
```python
fig, ax = plt.subplots()
xs = [r[0] for r in rows]
ax.plot(xs, [r[1] for r in rows], "o-", label="平均绝对误差")
ax.plot(xs, [r[2] for r in rows], "s--", label="最大绝对误差")
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xlabel("末层模型数量")
ax.set_ylabel("预测误差(下标)")
ax.legend()
ax.grid(True, which="both", alpha=0.3)
plt.show()
```
> 说明:为缩短运行时间,误差在 20,000 个随机抽样的键上统计。改成 `sample_idx = range(N)` 即为全量统计,结论一致但耗时约 10 倍。
步骤 9:性能基准测试
```python
from bisect import bisect_left
import time
random.seed(0)
NUM_QUERIES = 20_000
queries = [random.choice(data) for _ in range(NUM_QUERIES)]
def benchmark(name, fn):
checksum = 0
t0 = time.perf_counter()
for q in queries:
checksum += fn(q)
t1 = time.perf_counter()
ns = (t1 - t0) * 1_000_000_000 / len(queries)
print(f"{name:<28} {ns:12.0f} ns/次 (checksum={checksum})")
return ns
t_bisect = benchmark("bisect (C 实现)", lambda q: bisect_left(data, q))
t_pybin = benchmark("二分查找 (纯 Python)", lambda q: lower_bound_binary_search(data, q))
t_1l = benchmark("单层 RMI + 指数搜索", lambda q: single.lower_bound_search(data, q))
t_2l = benchmark("两层 RMI (1000 模型)", lambda q: two.lower_bound_search(data, q))
t_2lb = benchmark("两层 RMI + 误差界", lambda q: two_b.lower_bound_search(data, q))
```
线性查找太慢,只在少量查询上测:
```python
lin_q = queries[::100] # 200 个查询
t0 = time.perf_counter()
for q in lin_q:
lower_bound_linear_search(data, q)
t1 = time.perf_counter()
print(f"线性查找: {(t1 - t0) / len(lin_q) * 1e6:.0f} μs/次")
```
六、观测记录表
6.1 基本信息
| 项目 | 你的值 |
| --- | --- |
| Python 版本 | |
| CPU 型号 | |
| `N`(数组长度) | |
| 查询条数 | |
6.2 预测误差 vs 末层模型数(实测,N = 200,000,线性样条)
| 末层模型数 | 平均绝对误差(下标) | 最大绝对误差(下标) | 你的实测值 |
| --- | --- | ---: | ---: |
| 1 | 33313.5 | 50000 | |
| 10 | 3518.1 | 15811 | |
| 100 | 355.5 | 4995 | |
| 500 | 73.0 | 2225 | |
| 1000 | 36.3 | 1555 | |
| 5000 | 7.1 | 650 | |
| 20000 | 1.7 | 248 | |
**两个"最大误差"口径**:本表按 20,000 个抽样键统计(`random.Random(0).sample`);步骤 7 的 `errors` 是对全量 200,000 个键统计的,M = 1000 时为 1558——与本表的 1555 相差 3,属抽样与全量的口径差,不是错误。
观察结论(请在横线上写一句):
• 模型数每增加 10 倍,平均绝对误差大约降到原来的 ______。
• 平均绝对误差与最大绝对误差的比值随模型数 ______(增大 / 减小 / 基本不变)。
6.3 查询耗时(实测,N = 200,000,随机 20,000 次查询)
| 算法 | 实测 ns/次 | 你的实测值 | 相对 bisect 的倍数 |
| --- | ---: | ---: | ---: |
| `bisect` 二分(C 实现) | 523 | | 1.00 |
| 单层 RMI + 指数搜索 | 5798 | | |
| 两层 RMI(1000 模型) | 1817 | | |
| 两层 RMI + 误差界 | 1672 | | |
| 线性查找 | 3,768,000(=3768 μs) | | |
填写要求:把最后一列算出来并保留两位小数。
(耗时类数字:绝对值随机器不同而变化,请以自己的实测为准;趋势——单层最慢、两层 + 误差界最快、`bisect` 与两层接近——在任何机器上都成立。)
6.4 现象判断题
在 6.3 表里,哪一个数字最可能让你意外?用一句话写下原因:
```
________________________________________________________________
```
七、思考题
1. 给出 `N = 200,000` 时二分查找的比较次数,以及末层 1000 个模型(最大绝对误差 1555)时带误差界 RMI 在区间内二分的比较次数,并说明两者相差多少次。
2. 表 6.2 中,末层模型数从 1 增加到 20000(约 2×10⁴ 倍),平均绝对误差从 33313.5 降到 1.7(也约 2 万倍)——实测误差与模型数近似成反比。为什么是 1/M,而不是分段线性插值理论给出的更快下降?
3. 单层 RMI(5798 ns)比两层 RMI(1817 ns)还慢。既然两层 RMI 的模型更多、每次查询要走两层,为什么它反而更快?
4. 误差界版本(1672 ns)比无误差界版本(1817 ns)快。除了"搜索区间更小",还有一个更本质的原因是什么?(提示:两种修正方式的算法不同。)
5. 把数据分布从"多项式 + 噪声"改成"完全均匀",预测误差会怎样变化?此时 RMI 相对二分还有优势吗?
6. 带误差界的 RMI 在查询一个**不在数据集中**的键时,可能返回错误下标。请构造一个能触发该错误的具体例子,并给出修复方案。
7. 为什么 RMI 更擅长静态数据?如果允许插入和删除,会发生什么?
8. 本实验中 RMI 的"缓存友好性"为什么测不出来?设计一个能体现出该优势的实验条件。
八、思考题答案与解析
8.1 题 1
二分查找比较次数:
```
k_bin = ⌈log₂(N + 1)⌉ = ⌈log₂(200001)⌉
log₂(200001) = ln(200001)/ln(2) ≈ 12.2061/0.6931 ≈ 17.61
k_bin = 18
```
带误差界 RMI,最大绝对误差 `err = 1555`,区间宽度 `2·1555 + 1 = 3111`:
```
k_rmi = ⌈log₂(3111)⌉ = ⌈11.60⌉ = 12
```
相差 `18 − 12 = 6` 次比较。
但要注意:RMI 还要额外付出"两次模型预测 + 属性查找 + clamp"的代价。在纯 Python 里这部分远贵于 12 次整数比较,所以比较次数少并不等于墙钟时间少。
8.2 题 2
实测规律:模型数每 ×10,平均绝对误差约 ÷10(33313.5 → 3518.1 → 355.5;1000 → 5000 → 20000 的 36.3 → 7.1 → 1.7 也吻合),即误差 ∝ 1/M。原因有三层:
1. **误差由"段长"主导**。本数据的键序(排序后的键-位置曲线)是一条 √ 形的凸曲线加局部不规则,等深分段的段长 = N/M,每段的拟合偏差与段长近似成正比,所以误差 ∝ 1/M。
2. **数据不是理想光滑的**。±200 的随机噪声与"键域上端稀疏"让误差里始终有一层与段长同阶的不规则贡献。若数据完全光滑(噪声为 0),分段线性插值误差应按段长的平方下降——比实测快一个 M 的幂次。
3. **误差降到底后,加模型只剩开销**。M = 20000 时平均误差 1.7,已接近排序噪声决定的下界;再增模型,预测与参数存储的开销开始占上风。
可以用代码验证第 2 点:把噪声幅度改为 0 再跑一遍步骤 8,误差随 M 的下降会明显快于 1/M。
8.3 题 3
关键不在"模型数量",而在**指数搜索的跨度 `d`**。
指数搜索的代价约为 `2⌈log₂ d⌉ + 1`,其中 `d = |pred − true|`,即预测误差。
• 单层 RMI:平均绝对误差 33313.5 → `d ≈ 33314`,`⌈log₂ 33314⌉ = 16`,约 `2×16+1 = 33` 次比较,且全是 Python 层的循环迭代。
• 两层 RMI(1000 模型):平均绝对误差 36.3 → `d ≈ 37`,`⌈log₂ 37⌉ = 6`,约 13 次比较。
两层 RMI 多付出的代价只是"一次额外的模型预测 + 一次整数除法取模编号"(O(1) 的浮点运算),省下的却是指数搜索里约 20 次 Python 循环迭代。后者贵得多,所以净收益为正。
对照 6.3 表:`5798 → 1817`,节省约 69%(耗时类数字随机器变化,看量级与方向)。
8.4 题 4
更本质的原因是**修正算法换了**:
| 版本 | 修正算法 | 代价构成 |
| --- | --- | --- |
| 无误差界 | 指数搜索 | 外层"扩区间"的 Python while 循环 + 内层二分 |
| 有误差界 | 一次定区间的二分 | 只有二分 |
指数搜索的"扩区间"阶段是一个 Python 层的 `while` 循环,每次迭代要做列表下标访问、比较、`bound *= 2`。这个循环本身就比一次直接算出 `lo/hi` 再二分要贵。误差界版本直接给出 `[pred−err, pred+err]`,省掉了整个扩区间阶段。
所以收益 = (区间变小)+(去掉一层 Python 循环)。后者通常是主要贡献。
验证方法:把误差界人为放大 100 倍(`e = self.errors[j] * 100`),此时区间不再小,但仍然是纯二分。若耗时仍明显低于无误差界版本(约 1817 ns),就说明"去掉扩区间循环"是主因。
8.5 题 5
数据均匀时:
• 预测误差会大幅下降。均匀分布的 `F(x)` 本身就是一条直线(严格线性),单层 RMI 的一条直线就能几乎完美拟合,误差接近 0(仅剩离散化误差,量级为 1)。
• 此时"加模型"几乎不再降低误差,6.2 表会变成一条接近水平的线。
RMI 是否还有优势:
• 在**比较次数**上会有优势:`d ≈ 1`,指数搜索约 3 次比较,远少于二分的 18 次。
• 在**墙钟时间**上(本实验的纯 Python 环境)不会有优势:一次 Python 函数调用的开销大于十几次整数比较。
• 在**工程价值**上:均匀数据恰恰是 B 树最容易处理的情况,且真实系统很少是均匀分布的。RMI 的意义在于处理"有规律但非均匀"的分布,例如按时间戳、按 ID 前缀、按地理编码排序的数据。
可以用代码验证:把步骤 1 的 `poly_fn` 改成 `lambda x: 3 * x`,重跑步骤 8。
8.6 题 6
构造:令末层某模型 `j` 在训练样本上的最大误差为 `err_j = 5`。查询一个落在模型 `j` 负责区间内、但不在数据集中的键 `x_missing`。此时 `x_missing` 的真实下界位置与 `pred` 的差可能大于 5。
具体例子:
```python
取数据集中相邻两个键之间插入一个不存在的键
i = 100000
x_missing = data[i] + 1
while x_missing == data[i + 1]:
x_missing += 1
print("不存在的键:", x_missing)
print("带误差界 RMI 结果:", two_b.lower_bound_search(data, x_missing))
print("二分查找结果 :", lower_bound_binary_search(data, x_missing))
```
两个结果不一致即触发了该错误。
修复方案(任选):
1. **边界检查**:若 `lo == 0` 或 `hi == N`,说明区间贴到了数组端点,退化到整表二分。
2. **端点哨兵检查**:检查 `a[lo-1] < value` 与 `a[hi] >= value` 是否成立,不成立则扩大区间重试。
3. **保守放大误差界**:用 `err_j` 时乘以一个安全系数(例如 2),并为未命中情形预留回退路径。真实实现通常结合 1、2 两条。
8.7 题 7
RMI 的模型是在**全部数据的秩(rank)**上训练的。一个键的秩等于"排在它前面的元素个数"。
• 插入一个新键后,所有比它大的键的秩都 +1。这意味着**一次插入可能让所有模型的预测整体偏移**,误差界随之失效。
• 删除同理,比被删键大的元素秩都 −1。
• 分布漂移(新数据的键域与训练时不同)也会放大误差:`err_j` 是在旧样本上测的,新样本的真实误差可能远超它。
工程上的应对:
• 静态数据:一次性训练,误差界可靠。
• 需要更新:采用 PG M-index(arXiv:1910.06169)这类带对数级误差保证、支持增删的结构,或 ALEX(arXiv:1905.08898)这类把模型做成可分裂节点的自适应结构。
• 或者定期重训练,并在两次重训练之间监控误差,超阈值即触发重建。
8.8 题 8
测不出来的原因:本实验的数据是 **Python 的 `list`**,每个元素是一个 Python 对象(整数对象),访问 `data[mid]` 的成本是"指针解引用 + 对象头开销",而 Python 解释器本身的字节码分派开销比这个内存访问开销还大。也就是说,cache miss 的时间被解释器开销"稀释"了。
能体现缓存优势的实验条件:
1. 用 `array` 模块或 `numpy` 的紧凑数组(连续内存、每个元素 4/8 字节)替代 Python list,去掉对象开销。
2. 数据量增大到远超 L2/L3 缓存(例如千万级、每元素 8 字节 → 80 MB)。
3. 用 C/C++/Rust 实现,或至少用 `numpy` 的向量化操作完成内层循环。
4. 对比指标改为 cache miss 次数(Linux 下用 `perf stat -e cache-misses`,Windows 下需自行实测确认可用工具)。
在这样的条件下,二分查找前 5~6 次比较每次都会 miss(跨度从 N/2 到 N/64,远超缓存行局部性),而 RMI 只在预测位置附近的几十个字节内访问,几乎全部命中。
九、扩展任务(选做)
9.1 三层及以上的 RMI
```python
def compute_layer_sizes(num_layers: int, num_models: int) -> list[int]:
"""把 num_models 个模型分到 num_layers 层,除首层外逐层大致翻倍。"""
sizes = [2 * i + 1 for i in range(num_layers)] # 最小可行解 [1,3,5,...]
need = sum(sizes)
if need > num_models:
return sizes
rest = num_models - need
j = num_layers - 1
while rest > j:
sizes[j] += j
rest -= j
j = (j - 1) % num_layers
sizes[j] += rest
return sizes
print(compute_layer_sizes(3, 20)) # 例:[1, 6, 13] 一类的结果
print(compute_layer_sizes(4, 20))
```
按同样思路构造 `MultiLayerRMI(data, compute_layer_sizes(3, 1000))`,比较"相同总模型数、不同层数"下的误差与耗时。通常层数增加会降低误差,但预测本身的路径变长,二者存在折中。
9.2 换成其他模型
把 `LinearSplineModel` 换成"段内最小二乘拟合"的线性回归(用 `numpy.polyfit(x, y, 1)`),比较误差。注意训练成本会从 O(1) 变成 O(段长)。
9.3 对照 B 树
自己实现一个最简单的 B 树(或直接用实验材料中 B 树相关 notebook 的实现),在同一数据集上测量 ns/次,与 RMI 对比。
9.4 误差界的分布
画出 `two_b.errors` 的直方图,观察 1000 个末层模型的误差是否均匀。若不均匀,思考:哪些键域区间的模型误差最大?为什么?
9.5 阅读
精读 Kraska et al., *The Case for Learned Index Structures*, SIGMOD 2018(arXiv:1712.01208),重点看第 3 节 RMI 的 staged model 设计与第 4 节关于字符串索引的讨论。
十、常见错误与排查
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| `AssertionError: Inputs must be sorted` | 传给模型的数据未排序,或在构造 RMI 后又修改了 `data` | RMI 构造前先 `sorted()`;构造后不要再改动数组 |
| `ZeroDivisionError` 于 `LinearSplineModel.__init__` | 某段内所有键相同,`X[-1] - X[0] == 0` | 在 `__init__` 中特判 `X[-1] == X[0]`,令斜率为 0 |
| 预测位置越界(`IndexError`) | `predict` 返回值超出 `[0, N-1]` | 用 `clamp`:`max(0, min(N-1, int(pred)))` |
| `IndexError: list index out of range` 于下层模型分配 | `int(pred / N * M)` 在 `pred == N` 时等于 `M`,越界 | 分配与查询时都做 `min(nxt, M - 1)` |
| 带误差界版本结果与二分不一致 | 查询了数据集中不存在的键(见题 6) | 只对存在的键查询,或按题 6 方案加边界检查与回退 |
| 构造 20000 个末层模型时内存暴涨 | 训练期 `model_keys` 保存了每个模型的键列表副本,总量约 `2N` 个整数 | 减小 `N`,或改用"记录每个模型的键区间 [start, end)"代替拷贝键列表 |
| 步骤 8 跑得极慢 | 对每个配置都在全量 `N` 个键上做预测 | 用 20,000 的抽样(`sample_idx`)先跑通,再改全量 |
| 线性查找把程序卡住 | 查询条数太多,每条 O(N) | 用 `queries[::100]` 抽样,且不要在 `N` 很大时全量跑 |
| `bisect` 与手写二分结果不同 | 两者定义一致(都是下界),若不同说明手写实现有误 | 用第 2 步的对拍代码逐值校验 |
| 每次运行的 ns/次 差异很大 | 后台进程干扰、`time.time()` 精度不足 | 用 `time.perf_counter()`;关闭其他程序;多跑几次取中位数 |
| 误以为"RMI 一定比二分快" | 见 3.4 节 | 明确区分"比较次数"与"墙钟时间",明确实现语言的影响 |
附:本实验引用的文献
| 编号 | 出处 | 本实验用到什么 |
| --- | --- | --- |
| [1] | Kraska et al., *The Case for Learned Index Structures*, SIGMOD 2018(arXiv:1712.01208) | CDF 类比、`pos ≈ F(x)·N`、RMI 分层结构 |
| [2] | Ferragina & Vinciguerra, PG M-index(arXiv:1910.06169) | 带误差保证、支持更新的学习索引(扩展阅读) |
| [3] | Ding et al., ALEX(arXiv:1905.08898) | 自适应、可更新的学习索引(扩展阅读) |
局限性归纳(出自 [1] 及其后续工作):早期 RMI 更适合**静态、内存内、有序、一维**的数据;插入删除会改变秩;分布漂移会放大误差。