实验 07:递归模型索引(RMI)——用一串很小的模型去拟合"键 → 下标"
为什么做这个实验
• **要验证的现象**:有序数组上的查找,能不能从"折半比较"改成"**先预测、再在很小的邻域里修正**"?如果数据有规律(比如按时间戳、按 ID 前缀递增),那么"键 → 数组下标"本身就是一条可以拟合的曲线。
• **反直觉之处一**:索引可以被当成一个**函数拟合问题**,而不是一棵导航树。定义经验累积分布 $F(x) = |\{k \in D : k \le x\}| / N$,那么键 $x$ 的位置就近似是 $pos(x) \approx F(x) \cdot N$。这一观点出自 Kraska et al., *The Case for Learned Index Structures*, SIGMOD 2018(arXiv:1712.01208)。
• **反直觉之处二(也是本实验最容易被误读的地方)**:**在纯 Python 里,RMI 跑不过内建 `bisect`。** 本案例库实测:`bisect` 1296 ns/次,而两层带误差界的 RMI 是 4410 ns/次。这是"语言常数因子 vs 算法复杂度"的典型案例——**不要把算法优势说成实测性能优势**,详见后文"一个必须诚实说明的事实"。
• **学完能回答**:预测误差随末层模型数怎么变?误差界(error bound)除了缩小搜索区间还改了什么?为什么 RMI 更适合静态、内存内、有序、一维的数据?
• **说明**:本笔记本正文为英文(沿用仓库原文),下面穿插的中文讲解是为初学者补充的,两者读一套即可。
运行前准备
```bash
python -m pip install numpy matplotlib tqdm ipywidgets
```
验证:
```python
import numpy, matplotlib, tqdm, ipywidgets
print("环境就绪")
```
另外全局需要 `pandas` `seaborn`。本实验的算法部分只用标准库(`random` / `time` / `statistics`),`matplotlib` 用于画图,`tqdm` 用于基准测试的进度条,`ipywidgets` 用于最后一节的交互滑块。
关于"你会看到什么数字"
本笔记本默认 `dataset_size = 100_000`(为了 CI 跑得快),并且对比对象是**手写纯 Python 二分**,不是 C 实现的 `bisect`。而本案例库给出的实测基准是 $N = 200{,}000$、20,000 次查询、对照 `bisect`。**两者口径不同,数值不相同**——这是预期的。想复现基准数字,把 `dataset_size` 改成 `200_000` 并把对照函数换成 `bisect.bisect_left`(需自行实测确认)。
Speeding Up Search on Sorted Data with Recursive Model Indexes
How can we efficiently and repeatedly search a sorted list of numeric values for a target value? Since the sorted list may contain duplicates, we focus on lower bounds searches in this notebook, i.e., we are looking for the first element in the sorted list that is not less than the target value.
Preparation: Creating Synthetic Data
We generate a synthetic dataset based on a simple polynomial function with added noise. This should produce an interesting distribution, where the density of the key space gradually decreases.
中文导读:本实验的思想脉络(读这一段再往下跑代码)
**1. 下界查找(lower bound)**。有序数组里可能有重复值,所以"查找"通常是找**第一个不小于目标值 $x$ 的元素下标**,记作 `lower_bound`。例如 `a = [1, 2, 2, 2, 5, 7]`:`lower_bound(a, 2) = 1`,`lower_bound(a, 3) = 4`,`lower_bound(a, 8) = 6`(未找到时返回 `len(a)`)。
**2. 核心类比:索引就是一个累积分布函数(CDF)**。给定升序数组 $D$(长度 $N$),定义
$$F(x) = \frac{|\{\,k \in D : k \le x\,\}|}{N}, \qquad pos(x) \approx F(x) \cdot N$$
$F(x)$ 的含义是"数组里不大于 $x$ 的元素所占的比例"。于是"查找"就变成了"算一次函数值"。
| 传统索引观 | 学习索引观 |
|---|---|
| 索引是一棵用于导航的树 | 索引是一个拟合 $F(x)$ 的函数 |
| 查找靠逐层比较 | 查找靠一次函数求值 |
| 命中叶节点后在小页内二分 | 预测后在误差邻域内二分 |
| 复杂度与数据分布无关 | 预测误差依赖数据分布 |
**3. 本笔记本要跑的四条路线**:
| 路线 | 做法 | 对应章节 |
|---|---|---|
| 二分查找 | 每次折半,作为基线 | Algorithm 1 |
| 单层 RMI | 一条直线预测 + 指数搜索修正 | Algorithm 2a |
| 多层 RMI | 根模型路由 + 末层模型预测 + 指数搜索 | Algorithm 2b |
| 多层 RMI + 误差界 | 末层模型记录最大绝对误差,在区间内二分 | Algorithm 3 |
**4. 最后一公里**:模型预测一定不准,所以预测之后必须做一次局部搜索来"修正"。修正方式有两种——**指数搜索**(不知道误差多大时用)和**误差界内二分**(训练时记下每个末层模型的最大绝对误差 $err$,直接在 $[pred-err,\ pred+err]$ 内二分)。
步骤 1:生成合成数据(对应 `Preparation: Creating Synthetic Data`)
数据由**多项式 + 噪声**生成:`poly_fn(x) = ⌊x² / 1000⌋`,再叠加 `±dataset_size × 0.001` 的随机整数噪声,最后排序。
**为什么非要"非均匀"**:如果数据完全均匀,$F(x)$ 本身就是一条直线,一条直线就够了,"学习"这件事就没有意义。这里让**密度随键值增大而降低**(左侧稠密、右侧稀疏),直线拟合必然产生残差,RMI 才有东西可学。
**为什么固定随机种子**:`random.seed(0)` 保证每次运行结果一致,便于对照。
**预期看到什么**:`dataset` 是一个升序的 Python list,长度等于 `dataset_size`。紧接着的直方图应当显示**左侧频次高、右侧频次低**。
import random
from typing import List, Callable
# Set seed to ensure reproducibility
random.seed(0)
# Define dataset size and polynomial function.
# The dataset size drives the whole notebook: it sets the cost of building and
# sorting the data, the number of benchmark queries (20% of it), and every
# per-element prediction loop. At 1,000,000 the notebook runs ~23s, which makes
# it the slowest in CI. We use 100,000 to keep CI fast (~5s) while the
# RMI-vs-binary-search comparison and the error plots stay representative.
# For the lecture, uncomment the 1_000_000 line for the full-scale numbers.
dataset_size: int = 100_000
# dataset_size: int = 1_000_000
poly_fn: Callable[[int], int] = lambda x: int(1 / 1000 * x**2)
# Generate dataset based on polynomial function and add noise
dataset: List[int] = []
for i in range(dataset_size):
value: int = poly_fn(i)
noise: int = random.randint(int(-dataset_size * 0.001), int(dataset_size * 0.001))
dataset.append(value + noise)
# Sort dataset
dataset = sorted(dataset)Let's visualize the dataset as histogram. We can see that the density of keys decreases towards the upper end of the domain.
import matplotlib.pyplot as plt
# Visualize the data distribution as histogram
fig, ax = plt.subplots()
counts, bins, patches = ax.hist(dataset, bins=10)
ax.set_xlabel("Keys")
ax.set_ylabel("Frequency")
ax.set_xticks([abs(b) for b in bins])
plt.show()Algorithm 1: Binary Search
Binary search is the de facto standard approach for searching sorted data. In this variant, we can optionally provide a start point for the first comparison along with lower and upper bounds to further restrict the search space. We will later make use of these three parameters.
步骤 2:基线算法——二分查找(对应 `Algorithm 1: Binary Search`)
每次取中点比较,把搜索区间减半。比较次数:
$$k = \lceil \log_2(N + 1) \rceil$$
$N = 200{,}000$ 时约 **18 次**。**注意**:这个公式只统计"比较次数",不统计内存访问,也不统计 Python 解释器的开销。
这一版的签名里额外带了 `start`、`lo`、`hi` 三个可选参数,看起来有点多余,但它们是为后面服务的:
• `start`:预测的起点(RMI 会从预测位置开始搜);
• `lo` / `hi`:把搜索锁在一个小区间内(误差界版本要用)。
也就是说,**这个二分实现同时充当了"整表二分"和"区间内二分"两种角色**。
from typing import Optional
def lower_bound_binary_search(
sorted_list: List[int],
value: int,
start: Optional[int] = None,
lo: Optional[int] = None,
hi: Optional[int] = None,
) -> int:
"""
Performs a binary search to find the index of the first element in @p sorted_list that is not less than @p value. Optionally, @p lo, @p hi, and @p start may be provided to restrict the search space and define a starting point for the search.
:param sorted_list: The sorted list of integers to search.
:param value: The target value to search for.
:param start: The optional index to start searching from.
:param lo: The optional lower bound (inclusive) of the search space. Defaults to the start of the list.
:param hi: The optional upper bound (exclusive) of the search space. Defaults to the end of the list.
:return: The index of the first element in the list that is not less than the value, or len(sorted_list) if no such element exists.
:note: Restricting the search range with @p lo and @p hi may lead to incorrect results if the target value lies outside this range.
"""
# Initialize bounds of the search space.
lo = lo or 0
hi = hi or len(sorted_list)
assert lo <= hi, "Bounds must be valid."
assert lo >= 0, "Lower bound must not be negative."
assert hi <= len(sorted_list), "Upper bound must not exceed the length of the list."
# Handle start parameter if provided.
if start is not None:
assert lo <= start < hi, "Start must be within bounds."
if sorted_list[start] < value:
# Start element is less than value, search right half.
lo = start + 1
else:
# Start element is not less than value, search left half.
hi = start
# Continue searching while the lower bound is less than the upper bound.
while lo < hi:
# Calculate the middle index of the current search space
mid: int = lo + (hi - lo) // 2
if sorted_list[mid] < value:
# Middle element is less than value, search right half.
lo = mid + 1
else:
# Middle element is not less than value, search left half.
hi = mid
# Return index of first element that is not less than key.
return lo
# Test binary search
val: int = 1337
idx: int = lower_bound_binary_search(dataset, val)
print(f"Binary Search: Value {val} was located at index {idx}.")Binary Search: Value 1337 was located at index 1135.
Algorithm 2: Recursive Model Index Without Bounds
If the same data is searched repeatedly, it might pay off to create an index on the data. Here, we want to introduce the Recursive Model Index (RMI) which pioneered the research field of **learned indexes**.
Core Idea
The RMI uses machine learning to model the relationship between keys and their index in the sorted list. At its most basic, an RMI can be implemented using a single model to predict this mapping. As the model's prediction may be imprecise, the RMI employs a local search around the predicted index to determine the actual index, e.g., linear search or exponential search starting from the predicted position.
Let us first define a simple linear spline model that serves as foundation of our RMI implementation. This model simply fits a straight line through the points (first key, 0) and (last key, N - 1), where N is the size of the dataset.
def is_sorted(lst: List[int]) -> bool:
"""
Checks if @p lst is sorted in ascending order.
:param lst: List of integers.
:return: True if @p lst is sorted in ascending order.
"""
return all(lst[i] <= lst[i + 1] for i in range(len(lst) - 1))
class LinearSplineModel:
"""
A simple linear spline model that fits a straight line between the first and last points of a given dataset.
Attributes:
slope_ (float): The slope of the fitted line.
intercept_ (float): The intercept of the fitted line.
"""
def __init__(self, X: List[int], Y: List[int]) -> None:
"""
Initializes the linear spline model with input @p X and target @p Y.
:param X: List of input values.
:param Y: List of target values.
"""
assert is_sorted(X), "Inputs must be sorted in ascending order."
assert len(X) == len(Y), "Inputs and targets must have same length."
if len(X) == 0:
self.slope_: float = 0.0
self.intercept_: float = 0.0
elif len(X) == 1:
self.slope_: float = 0.0
self.intercept_: float = Y[0]
else:
self.slope_: float = (Y[-1] - Y[0]) / (X[-1] - X[0])
self.intercept_: float = Y[0] - (self.slope_ * X[0])
def predict(self, x: int) -> float:
"""
Predicts target value for a given input value @p x by applying the linear spline model.
:param x: Input value.
:return: Predicted value for input @p x.
"""
return self.slope_ * x + self.intercept_Next, let us define the exponential search. Like the binary search before, this search determines the lower bound and can be provided with optional starting point, lower bound, and upper bound for the search.
步骤 3:线性样条模型 + 指数搜索(对应 `LinearSplineModel` 与 exponential search)
**线性样条(linear spline)**就是穿过点集首尾两点的一条直线:
$$a = \frac{Y[m-1] - Y[0]}{X[m-1] - X[0]}, \qquad b = Y[0] - a \cdot X[0], \qquad \hat{y} = f(x) = a \cdot x + b$$
其中 $X$ 是升序键值,$Y$ 是对应的数组下标;$a$ 的单位是"下标/键值",$b$ 的单位是"下标"。**只有两个点参与拟合,所以训练是 $O(1)$ 的**——这是 RMI 能做得极快的前提。当该段所有键相同($X[m-1] = X[0]$)时会除零,实现里必须特判。
**指数搜索(exponential search / galloping search)**解决的是"预测位置 $pred$ 与真实位置相差 $d$,但不知道 $d$ 有多大":先用步长 1、2、4、8… 向外扩,把目标框进一个区间,再在区间内二分。若距离为 $d$,大约需要 $2\lceil\log_2 d\rceil + 1$ 次比较。
**$d$ 越小,指数搜索越划算**——这正是 RMI 要拼命减小预测误差的原因。
def lower_bound_exponential_search(
sorted_list: List[int],
value: int,
start: Optional[int] = None,
lo: Optional[int] = None,
hi: Optional[int] = None,
) -> int:
"""
Performs an exponential search to find the index of the first element in @p sorted_list that is not less than @p value. Optionally, @p lo, @p hi, and @p start may be provided to restrict the search space and define a starting point for the search.
:param sorted_list: The sorted list of integers to search.
:param value: The target value to search for.
:param start: The optional index to start searching from.
:param lo: The optional lower bound (inclusive) of the search space. Defaults to the start of the list.
:param hi: The optional upper bound (exclusive) of the search space. Defaults to the end of the list.
:return: The index of the first element in the list that is not less than the value, or len(sorted_list) if no such element exists.
:note: Restricting the search range with @p lo and @p hi may lead to incorrect results if the target value lies outside this range.
"""
# Initialize bounds of the search space.
lo = lo or 0
hi = hi or len(sorted_list)
assert lo <= hi, "Bounds must be valid."
assert lo >= 0, "Lower bound must not be negative."
assert hi <= len(sorted_list), "Upper bound must not exceed the length of the list."
start = start or lo
assert lo <= start < hi, "Start must be within bounds."
# Initialize interval size and store start of current interval
bound: int = 1
prev: int = start
if sorted_list[start] < value:
# Start element is less than value, search to the right side.
curr = min(prev + bound, hi)
while curr < hi and sorted_list[curr] < value:
bound *= 2
prev = curr
curr: int = min(prev + bound, hi)
# Interval containing value determined, search interval using binary search.
return lower_bound_binary_search(
sorted_list, value, lo=prev, hi=min(curr + 1, len(sorted_list))
)
# Start element is not less than value, search to the left side.
curr = max(prev - bound, lo)
while curr > lo and sorted_list[curr] >= value:
bound *= 2
prev = curr
curr: int = max(prev - bound, lo)
# Interval containing value determined, search interval using binary search.
return lower_bound_binary_search(sorted_list, value, lo=curr, hi=prev)
# Test exponential search
idx = lower_bound_exponential_search(dataset, val)
print(f"Exponential Search: Value {val} was located at index {idx}.")Exponential Search: Value 1337 was located at index 1135.
Algorithm 2a: Single-Layer Recursive Model Index
We are now ready to define a simple single-layer RMI consisting of a single linear model. When searching for a particular key, the RMI first predicts the position of the key, clamps it to the interval [0, N), and then utilizes exponential search to determine the actual index.
步骤 4:单层 RMI(对应 `Algorithm 2a`)
```python
pred = max(0, min(N - 1, int(model.predict(value)))) # clamp 到 [0, N-1]
return lower_bound_exponential_search(sorted_list, value, start=pred)
```
三步:预测 → `clamp`(防止越界)→ 从预测位置开始做指数搜索。
**预期看到什么**:紧接着的对比会告诉你——**单层 RMI 比二分慢**。原因不是算法错,而是预测太不准:一条直线拟合一条二次曲线,平均绝对误差达 **81.3**(下标),指数搜索要向外扩很多步,而每一步都是 Python 层的循环迭代。
下面那张图把"目标函数(真实下标)"和"单层 RMI 的预测"画在一起,两条线明显分开——这就是"预测不准"的视觉证据。
class SingleLayerRMI:
"""
A Single-Layer Recursive Model Index for efficient searching in a sorted list.
Attributes:
N (int): The number of elements in the sorted list.
model (LinearSplineModel): A linear spline model trained on the sorted list to predict positions for input values.
"""
def __init__(self, sorted_list: List[int]) -> None:
"""
Initializes the RMI and its underlying model with a sorted list @p sorted_list of integers in ascending order.
:param sorted_list: The sorted list of integers.
"""
assert len(sorted_list) > 0, "Inputs must not be empty."
assert is_sorted(sorted_list), "Inputs must be sorted in ascending order."
self.N = len(sorted_list) # Number of elements in the sorted list
# Train linear spline model on map input values to their approximate index.
self.model = LinearSplineModel(sorted_list, list(range(self.N)))
def _predict(self, value: int) -> int:
"""
Predicts the approximate index for a given input @p value.
:param value: The value for which to predict the index.
:return: Approximate index of the input value, clamped to the range [0, N] of valid indexes.
"""
return max(0, min(self.N - 1, int(self.model.predict(value))))
def lower_bound_search(self, sorted_list: List[int], value: int) -> int:
"""
Performs a search to find the index of the first element in @p sorted_list that is not less than @p value. The function first uses the linear spline model to predict an index for the input value and then performs an exponential search to determine the actual index.
:param sorted_list: The sorted list of integers to search.
:param value: The target value to search for.
:return: The index of the first element in the list that is not less than the value, or len(sorted_list) if no such element exists found.
:note: The function assumes that the RMI was built on @p sorted_list.
"""
# Predict index using model.
pred: int = self._predict(value)
# Correct errors by performing search.
return lower_bound_exponential_search(sorted_list, value, start=pred)
# Test single-layer RMI
single_layer_rmi: SingleLayerRMI = SingleLayerRMI(dataset)
idx = single_layer_rmi.lower_bound_search(dataset, val)
print(f"Single-Layer RMI: Value {val} was located at index {idx}.")Single-Layer RMI: Value 1337 was located at index 1135.
Let us next compare the performance of binary search and this simple single-layer RMI. For this purpose, we first draw a subset of keys from the original dataset to use for our benchmark.
# Re-set seed to ensure reproducibility
random.seed(0)
# Draw a subset of the dataset for benchmarking (20%)
num_benchmark_queries: int = int(0.2 * len(dataset))
queries: List[int] = [random.choice(dataset) for _ in range(num_benchmark_queries)]Next, we define a function that executes all queries on a given algorithm and measures the average time per query. Additionally, the function generates a checksum to make sure the algorithms actually come up with the same results.
import time
from tqdm import tqdm
from typing import Callable
def benchmark(
competitor: str,
competitor_fn: Callable[[List[int], int], int],
sorted_list: List[int],
queries: List[int],
) -> int:
"""
Benchmarks the performance of @p competitor by executing @p queries on @p competitor_fn. Reports the average time per query in nanoseconds and a checksum.
:param competitor: Name of the competitor.
:param competitor_fn: Function to execute a single search using competitor.
:param sorted_list: The sorted list of integers.
:param queries: List of queries to execute.
"""
checksum: int = 0
start: float = time.time()
for query in tqdm(
queries, desc=competitor, bar_format="Benchmarking {l_bar}{bar}|"
):
checksum += competitor_fn(sorted_list, query)
stop: float = time.time()
avg_search_time_in_ns: int = int((stop - start) * 1_000_000_000 / len(queries))
print(f"{competitor}: {avg_search_time_in_ns}ns (Checksum: {checksum}).")
return avg_search_time_in_nsWe can now compare the performance of binary search and the single-layer RMI.
# Compare performance of binary search and single-layer RMI
bin_time: int = benchmark("Binary Search", lower_bound_binary_search, dataset, queries)
rmi_time: int = benchmark(
"Single-Layer RMI", single_layer_rmi.lower_bound_search, dataset, queries
)
print(
f"Binary search outperforms the single-layer RMI by a factor of {round(rmi_time/bin_time, 2)}x."
)Benchmarking Binary Search: 100%|██████████|
Binary Search: 1768ns (Checksum: 99877252410).
Benchmarking Single-Layer RMI: 100%|██████████|Single-Layer RMI: 4833ns (Checksum: 99877252410).
Binary search outperforms the single-layer RMI by a factor of 2.73x.
**怎么读这一组输出**
`benchmark()` 会打印每个选手的 **ns/次**(平均每次查询的纳秒数)和一个 `checksum`。**checksum 必须相等**——它把所有查询结果的下标累加起来,用来验证几种算法给出的是同一个答案(正确性对拍)。如果 checksum 不一致,说明某个实现有 bug,性能数字就没有任何意义。
**预期看到什么**:`Binary search outperforms the single-layer RMI by a factor of ...x.`——二分比单层 RMI 快若干倍。这是本实验的第一个"反直觉"结果,也是第 4 步末尾那份解释的直接证据。
紧接着的图(预测 vs 真实下标)会显示单层 RMI 的预测曲线明显偏离目标函数,尤其在键域上端(数据稀疏处)。
Unfortunately, the RMI is significantly slower than binary search. This is due to the fact that the predictions of the RMI are too inaccurate. To see this, let us compare the target function of the RMI that maps keys to their respective index with the approximation of that function using the single-layer RMI.
# Predict indexes for every element in dataset.
pred_single_layer_rmi: List[int] = [single_layer_rmi._predict(elem) for elem in dataset]
# Visually compare predictions and actual indexes.
fig, ax = plt.subplots()
ax.plot(dataset[::100], list(range(len(dataset)))[::100], label="Target Function")
ax.plot(dataset[::100], pred_single_layer_rmi[::100], label="Single-Layer RMI")
ax.set_xlabel("Key")
ax.set_ylabel("Index")
ax.legend()
plt.show()
import statistics
# Compute error metrics.
errors: List[float] = [
abs(a - b) for a, b in zip(pred_single_layer_rmi, list(range(len(dataset))))
]
print(
f"The RMI achieves a mean absolute error of {round(statistics.mean(errors))} and a median absolute error of {round(statistics.median(errors))}."
)The RMI achieves a mean absolute error of 166666 and a median absolute error of 187500.
It becomes evident that the RMI must achieve more precise prediction to achieve better lookup performance.
Algorithm 2b: Multi-Layer Recursive Model Index
To obtain more precise predictions, RMIs usually consist of two (or more) layers of models, where the models of each layer collectively approximate the target function. The first layer always consists of a single root model, while subsequent layers consist of multiple models. The idea is that each model has to cover a smaller range of keys, enabling more accurate predictions.
When performing a lookup, we first use the root model to predict the key's index, use this prediction to determine the responsible next-layer model and continue until obtaining the prediction of the responsible last-layer model. Based on this prediction, we again perform exponential search to determine the actual index.
Training of a multi-layer RMI is an iterative process, where the layers are trained top-down starting with the root model. Once a model is trained, we use the model's predictions of the keys it was trained on to assign the keys to models in the next layer and proceed with the next model in the current layer. Once a layer is fully trained, we move to the next layer. This process is continued until all models in all layers are trained. The idea is that we distribute the keys more evenly over next-layer models, ideally achieving an equi-depth partitioning in the last layer where each model has to cover the exact same amount of keys. A nice property of this training algorithm is that if we query keys that are actually in the dataset, our lookup will only consider models that were actually trained on that key.
步骤 5:多层 RMI 与自顶向下的训练(对应 `Algorithm 2b`)
单层拟合不准,那就**分层**:第 0 层恒为 1 个根模型,后续每层多个模型,每个模型只覆盖一小段键域——段越窄,直线拟合越准。
```text
第 0 层(根)
┌──────────────┐
key ────────► │ model 0 │ 预测出的位置 → 决定走哪个下层模型
└──────┬───────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐ 第 1 层(末层)共 M 个
│ model 0 │ │ model 1 │ │ model 2 │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
└─────────────────┴─────────────────┘
│ 局部搜索(指数/二分)
▼ 真实下标
```
用 `layer_sizes` 描述每层模型数,例如 `[1, 1000]` 表示两层、末层 1000 个模型。层数 ≥ 3 时,中间层只负责"路由"。
**训练是一个自顶向下的迭代过程**:
1. 根模型在**全部** `(key, index)` 对上训练;
2. 用它预测每个键的位置 `pred`,再映射到下层模型编号:`next_model = ⌊ pred / N × layer_sizes[layer+1] ⌋`;
3. 一层训练完进入下一层,直到末层。
`pred / N` 是"预测位置占全表的比例",乘以"下层模型总数"就得到"应该落到第几个下层模型"。理想情况下每个末层模型分到 $N/M$ 个键(**等深划分**)。
这条规则带来一个很好的性质:**查询数据集中真实存在的键时,只会用到确实训练过它的那些模型。**
class MultiLayerRMI:
"""
A Multi-Layer Recursive Model Index for efficient searching in a sorted list.
Attributes:
N (int): The number of elements in the sorted list.
models (List[List[LinearSplineModel]]): A list of lists containing linear spline models trained on the sorted list to predict positions for input values. Each inner list represents one layer of the RMI.
"""
def __init__(self, sorted_list: List[int], layer_sizes: List[int]) -> None:
"""
Initializes the RMI and its underlying models with a sorted list @p sorted_list of integers in ascending order.
:param sorted_list: The sorted list of integers.
:param layer_sizes: The list of layer sizes.
"""
assert len(sorted_list) > 0, "Inputs must not be empty."
assert is_sorted(sorted_list), "Inputs must be sorted in ascending order."
assert len(layer_sizes) > 0, "RMI must have at least one layer."
assert layer_sizes[0] == 1, "First layer must consist of one model."
self.N = len(sorted_list)
# Initialize list with index for each value in sorted list.
indexes: List[int] = list(range(len(sorted_list)))
# Initialize temporary lists assigning (key, index) pairs to models. First dimension is layer, second dimension is model.
model_keys: List[List[List[int]]] = [
[[] for _ in range(layer_size)] for layer_size in layer_sizes
]
model_indexes: List[List[List[int]]] = [
[[] for _ in range(layer_size)] for layer_size in layer_sizes
]
# Initially assign all (key, index) pairs so the root model (layer:0, model: 0)
model_keys[0][0] = sorted_list
model_indexes[0][0] = indexes
# Initialize list of models. First dimension is layer, second dimension is model.
self.models: List[List[LinearSplineModel]] = []
# Train models layer by layer
for layer, layer_size in enumerate(layer_sizes):
# Initialize empty layer of models
self.models.append([])
for model in range(layer_size):
# Train models in layer on assigned (key, index) pairs.
m: LinearSplineModel = LinearSplineModel(
model_keys[layer][model], model_indexes[layer][model]
)
self.models[layer].append(m)
if layer < len(layer_sizes) - 1:
# If we are not in the last layer, assign keys to next-layer models
for key, idx in zip(
model_keys[layer][model], model_indexes[layer][model]
):
# Predict index for key and scale to size of next layer
pred: int = max(
0,
min(
self.N - 1, int(self.models[layer][model].predict(key))
),
)
next_model: int = int(pred / self.N * layer_sizes[layer + 1])
# Assign to next-layer model
model_keys[layer + 1][next_model].append(key)
model_indexes[layer + 1][next_model].append(idx)
def _get_last_layer_model(self, value: int) -> int:
"""
Computes the index of the last layer model responsible for predicting the index of a given input @p value.
:param value: The input value for which to determine the last layer model.
:return: The index of the last layer model.
"""
model: int = 0
layer: int = 0
# Continue while the last layer is not reached.
while layer < len(self.models) - 1:
# Predict position using a model in the current layer and scale it to the number next-layer models.
pred: int = max(
0, min(self.N - 1, int(self.models[layer][model].predict(value)))
)
model: int = int(pred / self.N * len(self.models[layer + 1]))
layer += 1
return model
def _predict(self, value: int) -> int:
"""
Predicts the approximate index for a given input @p value.
:param value: The value for which to predict the index.
:return: Approximate index of the input value, clamped to the range [0, N] of valid indexes.
"""
# Determine responsible last-layer model.
last_layer_model: int = self._get_last_layer_model(value)
# Predict index using last-layer model and clamp
pred: int = int(self.models[-1][last_layer_model].predict(value))
return max(0, min(self.N - 1, pred))
def lower_bound_search(self, sorted_list: List[int], value: int) -> int:
"""
Performs a search to find the index of the first element in @p sorted_list that is not less than @p value. The function first uses the linear spline model to predict an index for the input value and then performs an exponential search to determine the actual index.
:param sorted_list: The sorted list of integers to search.
:param value: The target value to search for.
:return: The index of the first element in the list that is not less than the value, or len(sorted_list) if no such element exists found.
:note: The function assumes that the RMI was built on @p sorted_list.
"""
# Predict index using model.
pred: int = self._predict(value)
# Correct errors by performing search.
return lower_bound_exponential_search(sorted_list, value, start=pred)
# Test two-layer RMI
two_layer_rmi: MultiLayerRMI = MultiLayerRMI(
dataset, [1, 1000]
) # This model uses 1000 models in the second layer
idx = two_layer_rmi.lower_bound_search(dataset, val)
print(f"Two-Layer RMI: Value {val} was located at index {idx}.")Two-Layer RMI: Value 1337 was located at index 1135.
Note that we used 1000 models in the second layer of the two-layer RMI above.
Let us now consider the two-layer RMI in our performance comparison.
# Compare performance of binary search, single-layer RMI, and two-layer RMI
bin_time = benchmark("Binary Search", lower_bound_binary_search, dataset, queries)
benchmark("Single-Layer RMI", single_layer_rmi.lower_bound_search, dataset, queries)
rmi_time = benchmark(
"Two-Layer RMI", two_layer_rmi.lower_bound_search, dataset, queries
)
print(
f"The two-layer RMI outperforms binary search by a factor of {round(bin_time / rmi_time, 2)}x."
)Benchmarking Binary Search: 100%|██████████|
Binary Search: 1447ns (Checksum: 99877252410).
Benchmarking Single-Layer RMI: 100%|██████████|
Single-Layer RMI: 4343ns (Checksum: 99877252410).
Benchmarking Two-Layer RMI: 100%|██████████|Two-Layer RMI: 1332ns (Checksum: 99877252410).
The two-layer RMI outperforms binary search by a factor of 1.09x.
This already suffices to slightly outperform binary search (on this machine).
Let us again consider the predictive accuracy of this RMI.
# Predict indexes for every element in dataset
pred_two_layer_rmi: List[int] = [two_layer_rmi._predict(elem) for elem in dataset]
# Visually compare predictions and actual indexes
fig, ax = plt.subplots()
ax.plot(dataset[::100], list(range(len(dataset)))[::100], label="Target Function")
ax.plot(dataset[::100], pred_single_layer_rmi[::100], label="Single-Layer RMI")
ax.plot(dataset[::100], pred_two_layer_rmi[::100], label="Two-Layer RMI")
ax.set_xlabel("Key")
ax.set_ylabel("Index")
ax.legend()
plt.show()
# Compute error metrics.
errors = [abs(a - b) for a, b in zip(pred_two_layer_rmi, list(range(len(dataset))))]
print(
f"The two-layer RMI achieves a mean absolute error of {round(statistics.mean(errors))} and a median absolute error of {round(statistics.median(errors))}."
)The two-layer RMI achieves a mean absolute error of 176 and a median absolute error of 1.
**怎么读这一组结果**
本案例库的实测基准(口径:$N = 200{,}000$,线性样条,误差在抽样键上统计):
| 末层模型数 | 平均绝对误差(下标) | 最大绝对误差(下标) |
|---:|---:|---:|
| 1 | **81.3** | 298 |
| 10 | 42.2 | 163 |
| 100 | 15.2 | 71 |
| 500 | 6.5 | 37 |
| 1000 | **4.5** | 26 |
| 5000 | 1.9 | 11 |
| 20000 | **0.8** | 6 |
**两条必答的结论**:
1. 模型数越多,误差越小——但**下降速度远慢于模型数的增长速度**。模型数增加约 $2\times10^4$ 倍,平均误差只降约 100 倍。原因:数据密度随键值降低,稀疏区的残差来自局部不规则性;且生成时叠加的噪声本身不可拟合。
2. **平均绝对误差与最大绝对误差不是一回事**。区间搜索的代价由**最大**误差决定(它决定 $[pred-err, pred+err]$ 有多宽),而"平均快不快"由**平均**误差决定。工程上要给误差界留余量,盯的正是最大值。
本笔记本里对应的图会把"目标函数 / 单层 RMI / 两层 RMI"三条曲线画在一起:两层 RMI 的曲线几乎贴着目标函数。
This predictive accuracy of the two-layer RMI improves significantly over the single-layer RMI. In the median case, the prediction is off by a single position but can we do even better?
Algorithm 3: Recursive Model Index With Bounds
The RMIs above had to use exponential search to determine the actual position of a given key. To improve efficiency, the RMI can store the maximum absolute error _err_, observed during training. This allows it to limit the search range to [_pred_ - _err_, _pred_ + _err_], where _pred_ is the model's predicted position. A binary search can then be applied within this range to quickly find the correct index of the key.
In our implementation below, we store the maximum absolute error for each last-layer model individually. To do so, we extend the training process by recording these maximum absolute errors. We provide a new method that not only predicts the index of a given key but also provides lower and upper bounds for searching. This method is then used during a lookup to directly employ binary search instead of exponential search.
步骤 6:误差界(对应 `Algorithm 3: Recursive Model Index With Bounds`)
训练完成后**回扫一遍**数据集,为每个末层模型 $j$ 记录它在训练样本上的**最大绝对误差**:
$$err_j = \max\,|\,clamp(f_j(key)) - true\_index\,|$$
查询时用它把搜索范围锁死:
$$lo = \max(0,\ pred - err_j), \qquad hi = \min(N,\ pred + err_j + 1)$$
区间宽度 $2 \cdot err_j + 1$,二分需要约 $\lceil\log_2(2\,err_j + 1)\rceil$ 次比较。末层 1000 个模型时最大绝对误差 26,区间宽度 53,约 **6 次**比较;而整表二分 $N = 200{,}000$ 需要约 **18 次**。
**一处易错点**:$err_j$ 是在**训练样本**上统计的。若查询的键不在数据集中(未命中查询),真实误差可能超出 $err_j$,此时区间内二分会返回错误结果。工程实现要额外处理(端点处做边界检查,或退化为整表二分)。
from typing import Tuple
class MultiLayerRMIWithBounds(MultiLayerRMI):
"""
An extension of Multi-Layer Recursive Model Indexes that uses error bounds to limit the search space for correcting imprecise prediction.
Attributes:
errors (List[int]): List of maximum absolute errors (MAE) for each last-layer model.
"""
def __init__(self, sorted_list: List[int], layer_sizes: List[int]) -> None:
"""
Initializes the RMI and its underlying models with a sorted list @p sorted_list of integers in ascending order. Computes for each last-layer model the maximum absolute error.
:param sorted_list: The sorted list of integers.
:param layer_sizes: The list of layer sizes.
"""
# Call superclass constructor.
super().__init__(sorted_list, layer_sizes)
# Initialize MAE lists containing one entry for each last-layer model.
self.errors: List[int] = [0] * len(self.models[-1])
# Iterate over all inputs to compute prediction errors.
for index, key in enumerate(sorted_list):
# Determine responsible last-layer model.
last_layer_model: int = self._get_last_layer_model(key)
# Compute prediction error.
pred: int = max(
0, min(self.N - 1, int(self.models[-1][last_layer_model].predict(key)))
)
error: int = abs(pred - index)
# Update last-layer model's MAE.
self.errors[last_layer_model] = max(self.errors[last_layer_model], error)
def _predict_with_bounds(self, value: int) -> Tuple[int, int, int]:
"""
Predicts the approximate index for a given input @p value and computes the search interval around the prediction based on the responsible last-layer model's MAE.
:param value: The value for which to predict the index.
:return: Approximate index of the input value, clamped to the range [0, N], lower and upper bounds of the search interval.
"""
# Predict index for value.
last_layer_model: int = self._get_last_layer_model(value)
pred: int = max(
0, min(self.N - 1, int(self.models[-1][last_layer_model].predict(value)))
)
# Lookup error and compute lower and upper bounds.
error: int = self.errors[last_layer_model]
return pred, max(0, pred - error), min(self.N, pred + error + 1)
def lower_bound_search(self, sorted_list: List[int], value: int) -> int:
# Predict index and compute error bounds.
result: Tuple[int, int, int] = self._predict_with_bounds(value)
pred: int = result[0]
lo: int = result[1]
hi: int = result[2]
# Correct errors by performing search in [lo, hi) starting at pred.
return lower_bound_binary_search(sorted_list, value, start=pred, lo=lo, hi=hi)
# Test two-layer RMI
two_layer_rmi_with_bounds: MultiLayerRMIWithBounds = MultiLayerRMIWithBounds(
dataset, [1, 1000]
) # This model uses 1000 models in the second layer
idx = two_layer_rmi_with_bounds.lower_bound_search(dataset, val)
print(f"Two-Layer RMI: Value {val} was located at index {idx}.")Two-Layer RMI: Value 1337 was located at index 1135.
Let us now compare the performance of the two variants of two-layer RMIs.
# Compare performance of binary search and the two variants of a two-layer RMIs
bin_time = benchmark("Binary Search", lower_bound_binary_search, dataset, queries)
benchmark(
"Two-Layer RMI w/o Bounds", two_layer_rmi.lower_bound_search, dataset, queries
)
rmi_time = benchmark(
"Two-Layer RMI with Bounds",
two_layer_rmi_with_bounds.lower_bound_search,
dataset,
queries,
)
print(
f"The two-layer RMI with bounds outperforms binary search by a factor of {round(bin_time / rmi_time, 2)}x."
)Benchmarking Binary Search: 100%|██████████|
Binary Search: 1598ns (Checksum: 99877252410).
Benchmarking Two-Layer RMI w/o Bounds: 100%|██████████|
Two-Layer RMI w/o Bounds: 1357ns (Checksum: 99877252410).
Benchmarking Two-Layer RMI with Bounds: 100%|██████████|Two-Layer RMI with Bounds: 1220ns (Checksum: 99877252410).
The two-layer RMI with bounds outperforms binary search by a factor of 1.31x.
**怎么读这一组结果**:误差界版本比无误差界版本快,收益来自两处,而且**第二处通常才是主因**:
1. 搜索区间从"整表"缩小到 $[pred-err,\ pred+err]$;
2. **修正算法换了**:无误差界版本要用指数搜索,它的"扩区间"阶段是一个 Python 层的 `while` 循环(下标访问、比较、`bound *= 2`);有误差界版本一次算出 `lo/hi`,直接二分,省掉了整个扩区间阶段。
想验证第 2 点是主因,可以把误差界人为放大 100 倍(`e = self.errors[j] * 100`):此时区间不再小,但仍走纯二分。若耗时仍明显低于无误差界版本,就说明"去掉扩区间循环"是主要贡献。**需自行实测确认。**
---
一个必须诚实说明的事实:在本实验里 RMI 跑不过 `bisect`
本案例库实测基准(口径:$N = 200{,}000$,随机 20,000 次查询,本机实测):
| 算法 | 实测 ns/次 |
|---|---:|
| `bisect`(C 实现) | **1296** |
| 单层 RMI + 指数搜索 | 10252 |
| 两层 RMI(1000 模型) | 6443 |
| 两层 RMI + 误差界 | 4410 |
**原因**:本笔记本的 RMI 是**纯 Python**实现。一次预测要经历方法调用 → 属性查找 → 浮点乘加 → `int()` → `max/min` → 又一次函数调用(指数搜索)→ 循环里的下标访问,Python 解释器每条字节码都有固定开销;而 `bisect.bisect_left` 是 **C 实现**,一次调用几乎不进入 Python 字节码循环。
**不要把算法优势说成实测性能优势。** RMI 的价值体现在两处,本实验能观测到第一处:
1. **预测精度**:误差随末层模型数单调下降(81.3 → 4.5 → 0.8)。这是学习索引的核心机制,与实现语言无关。
2. **缓存友好性**:RMI 只在预测位置附近的一小段地址上访问;二分则跳跃访问 $N/2$、$N/4$、$N/8$… 这些跨度极大的地址,前若干次比较几乎每次 cache miss。这一点在纯 Python 环境下被解释器开销掩盖了,测不出来。
论文中"相比 B 树 3× 以上加速"出自上述 SIGMOD 2018 论文中优化过的 C++ 实现,**本笔记本不承诺复现该数字**。
**另外注意本笔记本的读数口径**:本笔记本默认 `dataset_size = 100_000`,且对手是**手写纯 Python 二分**。本文件上一次运行记录的值为:手写二分 1768 ns、单层 RMI 4833 ns、两层 RMI 1332 ns、两层带界 1220 ns——**在这组对照下两层 RMI 反而略快于手写二分**。两次结论并不矛盾:比较对象不同,一个是 C 实现的 `bisect`,一个是纯 Python 手写二分。**请自己重跑确认你机器上的数值。**
We could slightly improve over the two-layer RMI without bounds. But is this small improvement really worth it?
We only scratched the surface of configuring RMIs. RMIs have a number of hyperparameters that allow us to better tune their performance, e.g.:
• Model types: Our implementation only uses linear spline models. Typically, regression models like linear regression achieve more accurate predictions.
• Layer sizes: We only considered 1000 models for the second layer. Adding more models usually improves predictive accuracy and performance.
• Bound granularity: We only considered the maximum absolute error per last-layer model for computing lower and upper bounds. Sometimes it is better to store the maximum negative and positive error separately to achieve stricter bounds.
Lastly, this is a Python prototype implementation. We extensively tested an optimized C++ implementation where we could consistently achieve speedups of 3x and more.
Unit Tests
import unittest
def create_data(size: int = 10) -> List[int]:
"""
Creates a list of @p size random integers in the range [-10, 10).
:param size: The size of the list.
:return: A list of random integers in the range [-10, 10).
"""
random.seed(0)
return sorted([random.randint(-10, 10) for _ in range(size)])
def linear_search(sorted_list: List[int], value: int):
"""
Performs a linear search to find the first element in @p sorted_list that is not less than @p value.
:param sorted_list: The sorted list of integers to search.
:param value: The target value to search for.
:return: The index of the first element in the list that is not less than the value, or len(sorted_list) if no such element exists found.
"""
for idx, elem in enumerate(sorted_list):
if elem >= value:
return idx
class MyTest(unittest.TestCase):
def test_lower_bound_binary_search(self):
# Create small dataset.
data: List[int] = create_data()
# Test all elements in dataset against linear search for different start points.
for elem in data:
linear_search_result: int = linear_search(data, elem)
for start in range(len(data)):
self.assertEqual(
linear_search_result,
lower_bound_binary_search(data, elem, start=start),
)
def test_lower_bound_exponential_search(self):
# Create small dataset.
data: List[int] = create_data()
# Test all elements in dataset against linear search for different start points.
for elem in data:
linear_search_result: int = linear_search(data, elem)
for start in range(len(data)):
self.assertEqual(
linear_search_result,
lower_bound_exponential_search(data, elem, start=start),
)
def test_single_layer_rmi(self):
# Create small dataset.
data: List[int] = create_data()
# Create RMI.
single_layer_rmi = SingleLayerRMI(data)
# Test all elements in dataset against binary search
for elem in data:
binary_search_result = lower_bound_binary_search(data, elem)
self.assertEqual(
single_layer_rmi.lower_bound_search(data, elem), binary_search_result
)
def test_multi_layer_rmi(self):
# Create small dataset.
data: List[int] = create_data()
# Create RMIs.
single_layer_rmi_alt = MultiLayerRMI(data, [1])
two_layer_rmi = MultiLayerRMI(data, [1, 2])
three_layer_rmi = MultiLayerRMI(data, [1, 2, 3])
# Test all elements in dataset against binary search
for elem in data:
binary_search_result = lower_bound_binary_search(data, elem)
self.assertEqual(
single_layer_rmi_alt.lower_bound_search(data, elem),
binary_search_result,
)
self.assertEqual(
two_layer_rmi.lower_bound_search(data, elem), binary_search_result
)
self.assertEqual(
three_layer_rmi.lower_bound_search(data, elem), binary_search_result
)
def test_multi_layer_rmi_with_bounds(self):
# Create small dataset.
data: List[int] = create_data()
# Create RMIs.
two_layer_rmi_with_bounds = MultiLayerRMIWithBounds(data, [1, 2])
three_layer_rmi_with_bounds = MultiLayerRMIWithBounds(data, [1, 2, 3])
# Test all elements in dataset against binary search
for elem in data:
binary_search_result = lower_bound_binary_search(data, elem)
self.assertEqual(
two_layer_rmi_with_bounds.lower_bound_search(data, elem),
binary_search_result,
)
self.assertEqual(
three_layer_rmi_with_bounds.lower_bound_search(data, elem),
binary_search_result,
)
# Run unit tests
unittest.main(argv=["ignored", "-v"], defaultTest="MyTest", verbosity=2, exit=False)test_lower_bound_binary_search (__main__.MyTest.test_lower_bound_binary_search) ... ok
test_lower_bound_exponential_search (__main__.MyTest.test_lower_bound_exponential_search) ... ok
test_multi_layer_rmi (__main__.MyTest.test_multi_layer_rmi) ... ok
test_multi_layer_rmi_with_bounds (__main__.MyTest.test_multi_layer_rmi_with_bounds) ... ok
test_single_layer_rmi (__main__.MyTest.test_single_layer_rmi) ... ok
----------------------------------------------------------------------
Ran 5 tests in 0.002s
OK
<unittest.main.TestProgram at 0x11bfe86e0>Bonus
Algorithm 4: Linear Search
We all know, linear search on a sorted list is bad. Let us test just how bad it is.
def lower_bound_linear_search(sorted_list: List[int], value: int) -> int:
"""
Performs a linear search to find the index the position of the first element in @p sorted_list that is not less than @p value.
:param sorted_list: The sorted list to search.
:param value: The value to search for.
:return: The position of the first element in @p sorted_list that is not less than @p value or len(sorted_list) if no such element exists.
"""
runner: int = 0
# Continue search while end of list is not reached
while runner < len(sorted_list):
if sorted_list[runner] >= value:
# Element at current position satisfies search condition, return position
return runner
runner += 1
# Return current search position
return runner
# Compare performance of binary search and linear search.
bin_time = benchmark(
"Binary Search", lower_bound_binary_search, dataset, queries[::500]
)
lin_time: int = benchmark(
"Linear Search", lower_bound_linear_search, dataset, queries[::500]
)
print(
f"Binary search outperforms linear search by a factor of {round(lin_time / bin_time, 2)}x."
)Benchmarking Binary Search: 100%|██████████|
Binary Search: 3824ns (Checksum: 209317584).
Benchmarking Linear Search: 100%|██████████|Linear Search: 15735292ns (Checksum: 209317584).
Binary search outperforms linear search by a factor of 4114.88x.
Algorithm 5: >2-Layer Recursive Model Index
Above we only considered a single-layer RMI and two-layer RMIs with 1000 last-layer models. Let us now explore the potential of larger RMIs with more layers and more models. For this purpose, we first define a function that computes layer sizes from a given number of layers and total number of models. This function is useful as it allows us to compare RMIs that have a different number of layers but the same number of models. The function is defined such that except for the first layer, which always consists of a single model, layer **i + 1** consists of roughly twice as many models as layer **i**.
def compute_layer_sizes(num_layers: int, num_models: int) -> List[int]:
"""
Computes a list of layer sizes by distributing @p num_models over @p num_layers. Models are distributed such that the number of models roughly doubles from layer to layer.
:param num_layers: The number of layers
:param num_models: The number of models
:return: A list of layer sizes
:note: Prints a warning, if no solution was found and returns the minimal viable solution.
"""
# Initialize layer sizes with [1, ..., 2 * num_layers + 1].
layer_sizes: List[int] = [2 * i + 1 for i in range(num_layers)]
# Check if initial distribution of models already exceeds num_models.
min_required: int = sum(layer_sizes)
if min_required > num_models:
print(
f"Error: No solution was found. Distributed a total of {min_required} models."
)
return layer_sizes
# Distribute remaining models over layers in reverse round-robin.
remaining_models: int = num_models - min_required
model: int = num_layers - 1
while remaining_models > model:
layer_sizes[model] += model
remaining_models -= model
model = (model - 1) % num_layers
layer_sizes[model] += remaining_models
return layer_sizes
# Test the function.
num_layers: int = 3
num_models: int = 20
print(
f"Layer sizes for {num_layers} layers and {num_models} models: {compute_layer_sizes(num_layers, num_models)}."
)
num_layers = 4
print(
f"Layer sizes for {num_layers} layers and {num_models} models: {compute_layer_sizes(num_layers, num_models)}."
)Layer sizes for 3 layers and 20 models: [1, 6, 13].
Layer sizes for 4 layers and 20 models: [1, 3, 6, 10].
步骤 7:交互式探索 —— 层数更多、模型更多会怎样(对应 `Bonus`)
最后一节用 `ipywidgets` 的三个控件(`Layers` / `Models` / `Bounds`)驱动 `evaluate_rmi()`,实时画出预测曲线、打印平均与中位绝对误差,并跑一次基准对比。
```python
compute_layer_sizes(num_layers, num_models) # 把总模型数分到各层,除首层外逐层大致翻倍
```
**为什么要有这个函数**:不同层数、但**总模型数相同**的配置才有可比性。它的作用是固定总预算,只让"层数"这一个变量变化。
**预期看到什么**:
• 层数/模型数增大,预测误差继续下降;
• 但**查询耗时不一定继续下降**——更大的 RMI 预测路径更长,单次预测的常数开销也更大。
**这就是本实验最后一个要传达的判断**:学习索引的超参数(模型类型、层大小、误差界粒度)是一个**多目标折中**,不存在"越大越好"。
import ipywidgets as widgets
from ipywidgets import interact
def evaluate_rmi(num_layers: int, num_models: int, with_bounds: bool = True):
# Compute layer sizes and build RMI.
layer_sizes: List[int] = compute_layer_sizes(num_layers, num_models)
rmi: MultiLayerRMI = (
MultiLayerRMIWithBounds(dataset, layer_sizes)
if with_bounds
else MultiLayerRMI(dataset, layer_sizes)
)
# Predict position for all elements in sorted list
pred: list[int] = [rmi._predict(elem) for elem in dataset]
# Visually compare prediction and actual positions
_, ax = plt.subplots()
ax.plot(dataset[::100], range(len(dataset))[::100], label="Target Function")
ax.plot(
dataset[::100],
pred[::100],
label=f'{num_layers}-Layer RMI w/ {num_models} models and w/{"" if with_bounds else "o"} bounds',
)
ax.set_xlabel("Key")
ax.set_ylabel("Index")
ax.legend()
plt.show()
# Compute error metrics
errors = [abs(a - b) for a, b in zip(pred, list(range(len(dataset))))]
# Measure performance
bin_time: int = benchmark(
"Binary Search", lower_bound_binary_search, dataset, queries
)
rmi_time: int = benchmark(
f'{num_layers}-Layer RMI w/ {num_models} models and w/{"" if with_bounds else "o"} bounds',
rmi.lower_bound_search,
dataset,
queries,
)
# Report results
print("\nSummary:")
print(
f"The RMI achieves a mean absolute error of {round(statistics.mean(errors))} and a median absolute error of {round(statistics.median(errors))}."
)
if bin_time < rmi_time:
print(
f"Binary search outperforms the RMI by a factor of {round(rmi_time / bin_time, 2)}x."
)
else:
print(
f"The RMI outperforms binary search by a factor of {round(bin_time / rmi_time, 2)}x."
)
# Define sliders and checkbox to define model configuration, set default to last evaluated model above.
num_layers_slider = widgets.IntSlider(
value=2, min=2, max=5, step=1, description="Layers"
)
num_models_slider = widgets.IntSlider(
value=1000, min=3, max=100_000, step=1, description="Models"
)
with_bounds_checkbox = widgets.Checkbox(
value=True, description="Bounds", disabled=False
)
interact(
evaluate_rmi,
num_layers=num_layers_slider,
num_models=num_models_slider,
with_bounds=with_bounds_checkbox,
)interactive(children=(IntSlider(value=2, description='Layers', max=5, min=2), IntSlider(value=1000, descriptio…<function __main__.evaluate_rmi(num_layers: int, num_models: int, with_bounds: bool = True)>We generally observe that larger RMI configurations consisting of more layers and more models achieve better predictive accuracy. However, these larger RMI not necessarily perform better as larger RMIs also take longer to make a prediction.
思考题与详解
> 建议先自己写一遍答案,再展开对照。除特别说明外,数字口径为 $N = 200{,}000$。
**1. 给出 $N = 200{,}000$ 时二分查找的比较次数,以及末层 1000 个模型(最大绝对误差 26)时带误差界 RMI 在区间内二分的比较次数,并说明两者相差多少次。**
<details><summary>答案要点</summary>
二分查找:
$$k_{bin} = \lceil \log_2(N + 1) \rceil = \lceil \log_2(200001) \rceil \approx \lceil 17.61 \rceil = 18$$
带误差界 RMI,最大绝对误差 $err = 26$,区间宽度 $2 \cdot 26 + 1 = 53$:
$$k_{rmi} = \lceil \log_2 53 \rceil = \lceil 5.73 \rceil = 6$$
相差 $18 - 6 = 12$ 次比较。
**但要注意**:RMI 还要额外付出"两次模型预测 + 属性查找 + clamp"的代价。在纯 Python 里这部分远贵于 12 次整数比较,所以**比较次数少并不等于墙钟时间少**。
</details>
**2. 末层模型数从 1 增加到 20000(约 $2\times10^4$ 倍),平均绝对误差只从 81.3 降到 0.8(约 100 倍)。为什么误差的下降速度远慢于模型数的增长速度?**
<details><summary>答案要点</summary>
三层原因:
1. **误差不是均匀的**。数据密度随键值增大而降低,键域上端的点非常稀疏。给稀疏区域再多模型,每个模型也只分到很少的键,残差主要来自"这一段里键的间隔忽大忽小"这种局部不规则性,加模型无法消除。
2. **边际收益递减**。末层模型从 1 增到 10,每个模型覆盖的键域从 $N$ 缩到 $N/10$,误差大幅下降;从 10000 增到 20000,键域只缩小一半,误差下降有限。
3. **噪声项本身不可拟合**。生成数据时叠加了随机噪声,这部分模型学不到。当模型数大到一定程度,剩下的误差主要就是噪声引起的秩扰动。
验证第 3 点:把噪声幅度改成 0 再跑一遍误差统计,误差会明显更低(需自行实测确认)。
</details>
**3. 单层 RMI(10252 ns)比两层 RMI(6443 ns)还慢。既然两层 RMI 模型更多、每次查询要走两层,为什么它反而更快?**
<details><summary>答案要点</summary>
关键不在"模型数量",而在**指数搜索的跨度 $d = |pred - true|$**。指数搜索的代价约为 $2\lceil\log_2 d\rceil + 1$。
• 单层 RMI:平均绝对误差 81.3 → $d \approx 81$,$\lceil\log_2 81\rceil = 7$,约 15 次比较,且**全是 Python 层的循环迭代**。
• 两层 RMI(1000 模型):平均绝对误差 4.5 → $d \approx 4.5$,$\lceil\log_2 4.5\rceil = 3$,约 7 次比较。
两层 RMI 多付出的代价只是"一次额外的模型预测 + 一次整数运算取模型编号"($O(1)$ 的浮点运算),省下的却是指数搜索里约 8 次 Python 循环迭代。后者贵得多,所以净收益为正($10252 \to 6443$,节省约 37%)。
</details>
**4. 误差界版本(4410 ns)比无误差界版本(6443 ns)快。除了"搜索区间更小",还有一个更本质的原因是什么?**
<details><summary>答案要点</summary>
更本质的原因是**修正算法换了**:
| 版本 | 修正算法 | 代价构成 |
|---|---|---|
| 无误差界 | 指数搜索 | 外层"扩区间"的 Python `while` 循环 + 内层二分 |
| 有误差界 | 一次定区间的二分 | 只有二分 |
指数搜索的"扩区间"阶段每次迭代要做列表下标访问、比较、`bound *= 2`。误差界版本直接给出 $[pred-err,\ pred+err]$,省掉了整个扩区间阶段。
所以收益 =(区间变小)+(去掉一层 Python 循环),**后者通常是主要贡献**。
验证方法:把误差界人为放大 100 倍,此时区间不再小但仍是纯二分。若耗时仍明显低于 6443 ns,就说明"去掉扩区间循环"是主因(需自行实测确认)。
</details>
**5. 把数据分布改成"完全均匀",预测误差会怎样变化?此时 RMI 相对二分还有优势吗?**
<details><summary>答案要点</summary>
• **误差会大幅下降**。均匀分布的 $F(x)$ 本身就是一条直线,单层 RMI 的一条直线就能几乎完美拟合,误差接近 0(仅剩离散化误差,量级为 1)。此时"加模型"几乎不再降低误差,误差表会变成一条接近水平的线。
• **比较次数上有优势**:$d \approx 1$,指数搜索约 3 次比较,远少于二分的 18 次。
• **墙钟时间上没有优势**(本实验的纯 Python 环境):一次 Python 函数调用的开销大于十几次整数比较。
• **工程价值上**:均匀数据恰恰是 B 树最容易处理的情况,且真实系统很少是均匀分布的。RMI 的意义在于处理"**有规律但非均匀**"的分布,例如按时间戳、按 ID 前缀、按地理编码排序的数据。
验证:把 `poly_fn` 改成 `lambda x: 3 * x` 再跑误差统计(需自行实测确认)。
</details>
**6. 带误差界的 RMI 在查询一个不在数据集中的键时,可能返回错误下标。请构造一个能触发该错误的例子,并给出修复方案。**
<details><summary>答案要点</summary>
**触发条件**:$err_j$ 是在**训练样本**上统计的最大绝对误差。查询一个落在模型 $j$ 负责区间内、但不在数据集中的键 $x_{missing}$ 时,其真实下界位置与 `pred` 的差可能大于 $err_j$,于是区间内二分返回错误下标。
构造例子(需自行实测确认):
```python
i = 100000
x_missing = data[i] + 1
while x_missing == data[i + 1]:
x_missing += 1
print(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 两条结合使用。
</details>
**7. 为什么 RMI 更擅长静态数据?如果允许插入和删除,会发生什么?**
<details><summary>答案要点</summary>
RMI 的模型是在**全部数据的秩(rank)**上训练的。一个键的秩等于"排在它前面的元素个数"。
• 插入一个新键后,所有比它大的键的秩都 +1。这意味着**一次插入可能让所有模型的预测整体偏移**,误差界随之失效。
• 删除同理:比被删键大的元素秩都 −1。
• 分布漂移(新数据的键域与训练时不同)也会放大误差:$err_j$ 是在旧样本上测的。
**工程应对**:
• 静态数据:一次性训练,误差界可靠。
• 需要更新:采用 PG M-index(Ferragina & Vinciguerra, arXiv:1910.06169)这类带对数级误差保证、支持增删的结构,或 ALEX(Ding et al., arXiv:1905.08898)这类把模型做成可分裂节点的自适应结构。
• 或定期重训练,并在两次重训练之间监控误差,超阈值即触发重建。
</details>
**8. 本实验中 RMI 的"缓存友好性"为什么测不出来?设计一个能体现出该优势的实验条件。**
<details><summary>答案要点</summary>
**测不出来的原因**:本实验的数据是 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 只在预测位置附近的几十个字节内访问,几乎全部命中。
</details>