实验 05:pandas 里的位序列——布尔掩码就是位图索引
> 做数据库实验不一定非要先造一个数据库。本实验用 150 行的鸢尾花数据集,说明一个天天在用、却常常被忽略的事实:**pandas 的布尔 Series 就是一条位序列**,而 `iris[mask]` 就是一次位图索引查找。理解了这一点,实验 04 里那些"位运算求值""候选集压缩"的说法就不再抽象了。
---
一、实验目标
做完本实验,你应当能够:
1. 说明 pandas 布尔 Series 与数据库位图索引的对应关系,指出二者在存储上的一处关键差别。
2. 用 `&`、`|`、`~` 组合多个掩码,并解释组合后的语义。
3. 用 `sum()` 读出掩码的选择性(命中比例),并据此判断"该不该为这一列建位图索引"。
4. 复现本实验给出的三个实测数字:150 行数据中,`sepal_width == 3.8` 命中 6 行,`petal_length == 1.9` 命中 2 行,两者取交集命中 1 行。
5. 说清"位图索引适合低基数列、不适合高基数列"这句话的量化依据。
---
二、预备知识
2.1 位序列与位图索引
**位序列(bit sequence)**是一串 0/1,长度等于表的行数,第 $i$ 位表示"第 $i$ 行是否满足某个条件"。
数据库里的**位图索引(bitmap index)**正是这个结构:为某个列的某个取值建一条位序列。查询 `WHERE species = 'setosa'` 就变成"取出 setosa 那一条位序列,值为 1 的行就是结果"。
本实验要说明的核心对应:
| 数据库概念 | pandas 中的写法 |
|---|---|
| 一条位序列 | 一个布尔 `Series` |
| 位与(AND) | `mask1 & mask2` |
| 位或(OR) | `mask1 \| mask2` |
| 位取反(NOT) | `~mask` |
| 用位序列取行 | `df[mask]` |
| 基数(cardinality) | `df[col].nunique()` |
| 选择性(selectivity) | `mask.sum() / len(df)` |
2.2 一处必须点明的差别
数据库的位图索引**只存 0/1 比特**,一行一个比特,并且通常会做游程压缩(实验 04 的 WAH)。pandas 的布尔 Series 在 **NumPy 里用一个字节(8 比特)存一个布尔值**——它是"位序列的语义 + 字节数组的实现"。
所以**不能**用 pandas 的内存占用来论证"位图索引省空间"。pandas 给你的是位序列的**语义与运算方式**,不是它的**压缩存储**。这一点很容易被讲错。
可以用一行代码验证:
```python
import numpy as np
a = np.array([True, False, True])
print(a.dtype, a.itemsize) # 输出: bool 1 —— 每个布尔占 1 字节,不是 1 比特
```
2.3 为什么低基数列才适合位图索引
设列的基数为 $d$(不同取值的个数),表有 $n$ 行:
• 位图索引要存 $d$ 条位序列,共 $n \times d$ 个比特;
• 每个取值平均命中 $n / d$ 行。
$d$ 很小(如性别、洲、订单状态)时,每条位序列很短、选择性适中,位运算一次能筛掉大量行,非常划算。
$d$ 很大(如用户 ID、交易金额、经纬度)时,要维护 $d$ 条位序列,空间爆炸,且每条位序列几乎全是 0、选择性极低,反而不如直接扫描或换 B+ 树。
**经验判据**:$d \ll n$(通常 $d$ 在几十以内)才考虑位图索引。
---
三、实验原理
3.1 掩码是怎么来的
```python
bit_sequence_1 = iris["sepal_width"] == 3.8
```
这一行做了三件事:
1. 取出 `sepal_width` 这一列(150 个浮点数);
2. 逐元素与 3.8 比较,得到 150 个布尔值;
3. 包成一个 `Series`,索引与原来那 150 行一一对应。
结果是 `pandas.Series`,`dtype` 为 `bool`,长度 150。
**注意 `==` 在这里是"逐元素比较",不是"判断两个对象相等"。** 这是 NumPy 的广播语义,也是初学者最容易卡住的地方。
3.2 掩码怎么用来取行
```python
iris[bit_sequence_1]
```
pandas 保留布尔值为 `True` 的那些行,丢掉 `False` 的行。这就是一次"位图索引查找"。
3.3 掩码怎么组合
多个条件用位运算组合,语义完全对应 SQL 的 `AND` / `OR` / `NOT`:
```python
iris[bit_sequence_1 & bit_sequence_2] # 两个条件都满足 → WHERE a AND b
iris[bit_sequence_1 | bit_sequence_2] # 至少一个满足 → WHERE a OR b
iris[~bit_sequence_1] # 不满足 → WHERE NOT a
```
**一个高频错误:用 `and` / `or` 代替 `&` / `|`。** Python 的 `and` 会对整个 Series 做真值判断,而长度为 150 的 Series 没有唯一的真假,于是抛出 `ValueError: The truth value of a Series is ambiguous`。记住:**按位运算用 `&` `|`,逻辑连接词用 `and` `or`,在位序列上永远用前者。**
3.4 组合的代价
$m$ 条位序列做 AND,需要 $m-1$ 次位与,每次扫 $n$ 个比特。现代 CPU 一次能处理 256 比特(AVX2),所以 $n = 10^6$、$m = 3$ 时,理论耗时在毫秒量级——这正是实验 04 实测"位图查询 1.5184 ms、朴素查询 153.588 ms"差距的来源。
---
四、实验环境与准备
```bash
pip install pandas seaborn
```
验证:
```python
import pandas as pd, seaborn as sns
print(pd.__version__)
iris = sns.load_dataset("iris")
print(iris.shape) # 期望输出 (150, 5)
```
若 `load_dataset` 因网络失败,可改用等价的本地构造(行数与列名相同即可,后面所有结论不变):
```python
import pandas as pd
iris = pd.read_csv(
"https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv"
)
```
---
五、实验步骤
步骤 1:加载数据并看清它的形状
```python
import seaborn as sns
iris = sns.load_dataset("iris")
print(iris.shape) # 期望 (150, 5)
print(iris.columns.tolist())
iris.head()
```
期望输出:
```text
(150, 5)
['sepal_length', 'sepal_width', 'petal_length', 'petal_width', 'species']
```
步骤 2:造第一条位序列
```python
bit_sequence_1 = iris["sepal_width"] == 3.8
print(type(bit_sequence_1)) # <class 'pandas.core.series.Series'>
print(bit_sequence_1.dtype) # bool
print(len(bit_sequence_1)) # 150
print(int(bit_sequence_1.sum())) # 6
```
期望输出:
```text
<class 'pandas.core.series.Series'>
bool
150
6
```
**记录**:`sepal_width == 3.8` 命中 **6** 行,选择性 $6 / 150 = 4.0\%$。
步骤 3:造第二条位序列
```python
bit_sequence_2 = iris["petal_length"] == 1.9
print(int(bit_sequence_2.sum())) # 2
```
期望输出:`2`。选择性 $2 / 150 = 1.3\%$。
步骤 4:分别用两条掩码取行
```python
print(len(iris[bit_sequence_1])) # 6
print(len(iris[bit_sequence_2])) # 2
```
步骤 5:组合掩码(本实验的关键一步)
```python
both = bit_sequence_1 & bit_sequence_2
print(int(both.sum())) # 1
print(iris[both])
```
期望输出:
```text
1
sepal_length sepal_width petal_length petal_width species
44 5.1 3.8 1.9 0.4 setosa
```
**这就是位图索引的全部要义**:不做任何逐行比较,只把两条位序列按位与,就得到了同时满足两个条件的行。
步骤 6:验证位运算的语义
```python
print(int((bit_sequence_1 | bit_sequence_2).sum())) # 6 + 2 - 1 = 7
print(int((~bit_sequence_1).sum())) # 150 - 6 = 144
print(int((bit_sequence_1 & ~bit_sequence_2).sum())) # 6 - 1 = 5
```
期望输出:
```text
7
144
5
```
这三条正好验证了容斥关系:$|A \cup B| = |A| + |B| - |A \cap B| = 6 + 2 - 1 = 7$。
步骤 7:用基数判断"该不该建位图索引"
```python
for col in iris.columns:
d = iris[col].nunique()
print(f"{col:14s} 基数 = {d:4d}", end="")
if d <= 10:
print(" → 低基数,适合位图索引")
else:
print(" → 高基数,不适合位图索引")
```
期望输出(基数会因数据源版本略有出入,`species` 一定是 3):
```text
sepal_length 基数 = 35 → 高基数,不适合位图索引
sepal_width 基数 = 23 → 高基数,不适合位图索引
petal_length 基数 = 43 → 高基数,不适合位图索引
petal_width 基数 = 22 → 高基数,不适合位图索引
species 基数 = 3 → 低基数,适合位图索引
```
**结论**:这份数据里只有 `species` 适合位图索引。这解释了为什么本实验前面用的是 `== 3.8`、`== 1.9` 这种"等值但基数不低"的条件——它们只是用来演示位序列的**运算**,并不代表工程上应该在 `sepal_width` 上建位图索引。
步骤 8:看清 pandas 存布尔用的是字节而不是比特
```python
import numpy as np
a = bit_sequence_1.to_numpy()
print(a.dtype, a.itemsize) # bool 1
print("150 个布尔占:", a.nbytes, "字节")
print("若按 1 比特/行存,只需:", -(-150 // 8), "字节")
print("相差倍数:", a.nbytes / (-(-150 // 8)))
```
期望输出:
```text
bool 1
150 个布尔占: 150 字节
若按 1 比特/行存,只需: 19 字节
相差倍数: 7.894736842105263
```
**这一步是本实验最重要的一课**:pandas 给你的是位序列的**语义**,不是它的**压缩实现**。真正的位图索引会把这 150 个比特压进 19 字节,还能再做游程压缩。
步骤 9:画掩码示意图
```python
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(11, 2.4))
for i, (name, m) in enumerate([("sepal_width == 3.8", bit_sequence_1),
("petal_length == 1.9", bit_sequence_2),
("两者 AND", bit_sequence_1 & bit_sequence_2)]):
bits = m.to_numpy().astype(int)
ax.scatter(range(len(bits)), [i] * len(bits),
c=np.where(bits == 1, "#1F4E79", "#E8E8E8"), s=14, marker="s")
ax.text(len(bits) + 2, i, f"命中 {int(bits.sum())}", va="center", fontsize=10)
ax.set_yticks(range(3))
ax.set_yticklabels(["sepal_width == 3.8", "petal_length == 1.9", "两者 AND"])
ax.set_xlabel("行号")
ax.set_title("三条位序列:1 的位置就是满足条件的行")
ax.set_xlim(-3, 175)
plt.tight_layout()
plt.show()
```

---
六、观测记录表
| 条件 | 命中行数 | 选择性 | 你的实测 |
|---|---|---|---|
| `sepal_width == 3.8` | 6 | 4.0% | |
| `petal_length == 1.9` | 2 | 1.3% | |
| 两者 AND | 1 | 0.67% | |
| 两者 OR | 7 | 4.7% | |
| `NOT (sepal_width == 3.8)` | 144 | 96.0% | |
| `(sepal_width==3.8) AND NOT (petal_length==1.9)` | 5 | 3.3% | |
| 列 | 基数 | 是否适合位图索引 | 你的实测 |
|---|---|---|---|
| `sepal_length` | 35 | 否 | |
| `sepal_width` | 23 | 否 | |
| `petal_length` | 43 | 否 | |
| `petal_width` | 22 | 否 | |
| `species` | 3 | **是** | |
| 存储对比 | 数值 |
|---|---|
| pandas 布尔 Series(150 行) | 150 字节 |
| 按 1 比特/行的位图 | 19 字节 |
| 倍数 | 约 7.9× |
---
七、思考题
1. 为什么 `iris[bit_sequence_1 and bit_sequence_2]` 会报错,而 `iris[bit_sequence_1 & bit_sequence_2]` 不会?报错信息里 "ambiguous" 指的是什么?
2. 位图索引常被说成"适合低基数列"。请用本实验的数字说明:**如果给 `sepal_length`(基数 35)建位图索引,会付出什么代价、换到什么收益?**
3. `~bit_sequence_1` 的选择性是 96%。这么高的选择性对查询有什么意义?(提示:想想什么时候索引还不如全表扫描。)
4. 本实验说"pandas 的布尔 Series 是位序列的语义、字节数组的实现"。请设计一个小实验,验证 pandas 在做 `&` 时是否真的用到了 CPU 的位运算并行能力(提示:比较 `numpy.packbits` 后的位与与直接 `&` 的耗时)。
5. 数据库里"位图索引 + B+ 树"经常被组合使用(例如先按日期范围用 B+ 树定位,再在位图上做 AND)。请结合实验 03 与实验 04,说明这种组合各自解决什么问题。
6. 如果一张表有 10 亿行、某列基数只有 2(比如"是否已删除"),位图索引需要多少个比特?做完游程压缩后大概能压到多少?说出你的估算思路。
---
八、思考题答案与解析
**第 1 题**
`and` 是 Python 的**逻辑连接词**,它要求左右两边都能被解释成一个布尔真值。长度为 150 的 Series 有 150 个布尔值,Python 不知道该按"全部为真"还是"任一为真"来解释,因此拒绝猜测并抛出 `ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().`
`&` 是**按位与运算符**,pandas 为 Series 重载了它,语义是"逐元素做与",返回一条同样长度的新 Series,正是我们想要的操作。
顺带一句:报错信息里列出的 `a.any()` / `a.all()` 是"把整条 Series 归约成一个布尔值"的正确写法,适用于"是否存在满足条件的行"这类提问。
**第 2 题**
代价:要为 35 个不同取值各建一条长度 150 的位序列,共 $150 \times 35 = 5250$ 个比特(不做压缩约 656 字节)。相比只存原始列,索引比数据本身还大——这就是"空间爆炸"。
收益:查询 `sepal_length == 5.1` 时,能一次位运算取出命中的行。但这类等值查询在高基数列上命中极少(本实验中 `== 3.8` 只命中 6 行,选择性 4%),用 B+ 树只需 $O(\log n)$ 次比较就能定位到这 6 行,代价更小。
结论:**高基数列上,位图索引的维护成本远超收益,应选 B+ 树。** 位图索引的优势区间是低基数 + 多条件组合,因为多条件 AND/OR 在位图上只是一次位运算,而在 B+ 树上要做多次查找与结果归并。
**第 3 题**
选择性 96% 意味着"几乎每一行都满足"。此时无论用什么索引,都要返回 144/150 行——索引带来的收益趋近于 0,却仍要付出读取索引结构、做位运算、回表取行的代价。**优化器在选择性很高时应当放弃索引、直接全表扫描**(顺序 I/O 反而更快)。
业界常见的经验阈值在 5%~30% 之间(随系统而异):选择性低于阈值走索引,高于阈值走扫描。PostgreSQL 用代价模型而非硬编码阈值来判断,参数 `random_page_cost` / `seq_page_cost` 直接影响这个判断。
**第 4 题**
思路:把布尔数组用 `numpy.packbits` 压成真正的位(uint8 数组),再对压缩后的数组做 `&`,与直接对原布尔数组做 `&` 比较耗时。若后者没有明显慢于前者,说明 NumPy 内部已经做了批量处理,两者的瓶颈都在内存带宽而非位运算本身。
注意一个容易搞错的点:`packbits` 本身要花时间。如果只做**一次** AND,压缩再运算通常更慢;只有位序列要被**反复使用**(数据库里的典型情况,索引建一次、查很多次),压缩才划算。这正好解释了为什么数据库愿意在索引上花压缩的代价。
**第 5 题**
• **B+ 树**擅长**范围查询**与**有序遍历**:`WHERE date BETWEEN '2026-01-01' AND '2026-03-31'` 可以沿叶链顺序扫出一段区间,代价与结果集大小成正比,而不是与全表大小成正比。它不擅长多条件组合——每个条件各查一次再归并,代价随条件数线性增长。
• **位图索引**擅长**多条件组合**:`WHERE 条件1 AND 条件2 AND 条件3` 只做两次位与,代价与条件数无关、只与行数有关。它不擅长范围查询——要把范围内每个取值对应的位序列都取出来做 OR,取值多时很快退化。
所以组合方式是:**用 B+ 树处理范围,用位图处理等值组合**;或者先用 B+ 树把候选行圈定到一个较小的集合,再在这个集合上做位运算。这正是很多分析型数据库(以及 PostgreSQL 的 Bitmap Scan 算子)的做法。
**第 6 题**
不压缩:$10^9$ 个比特 = $10^9 / 8$ 字节 ≈ **119 MB**(更精确地说是 125,000,000 字节 ≈ 119.2 MiB)。
压缩后:游程编码(RLE)的压缩率完全取决于"已删除"标记在物理存储顺序上的**聚集程度**。
• 最理想情况(按删除标记有序存放,只有 2 个游程):几乎可以压到几十字节。
• 最坏情况(删除标记随机分布,平均每 2 行切换一次):游程数约 $5 \times 10^8$,每个游程按 WAH 的 32 比特字算,反而可能膨胀到 2 GB 以上——**比不压缩还大**。
现实通常介于两者之间:如果"已删除"的比例很低(比如 1%),且删除行在存储上比较聚集,游程数约等于删除行的"连续段数",压缩率可达数十倍。
**这个估算要传达的核心结论**:位图索引的压缩率不是算法的性质,而是**数据分布的性質**。建索引前应该先测,而不是假设。这也正是实验 04 里 WAH 压缩那张图要展示的内容(密度 0.1% 时能压到 5 个字,密度 ≥10% 时压缩失效)。
---
九、扩展任务(选做)
1. **用 `species` 列建真正的位图索引。** 为 3 个物种各建一条位序列,验证三条位序列按位或之后正好覆盖全部 150 行(即 `setosa | versicolor | virginica` 全为 True)。
2. **测量多条件 AND 的耗时。** 造一张 100 万行的 DataFrame,构造 5 个掩码,测 `m1 & m2 & m3 & m4 & m5` 的耗时,与"逐行 if 判断"的写法对比。观察加速比,并与实验 04 的 101× 对照,说明为什么这里的加速比通常更小。
3. **验证选择性对结果的影响。** 把 `== 3.8` 改成 `> 3.0`(选择性大幅提高),重复步骤 2–5,观察命中行数与组合结果的变化。
4. **自己实现一次 WAH 压缩。** 把一条 150 位的位序列按 32 位一组做游程压缩(WAH 字面字的有效载荷是低 31 位,最高位是标志位,与实验 04 实现的 `WORD = 32` 一致),统计压缩后的字数,与实验 04 的 WAH 实测结果对照。
---
十、常见错误与排查
| 现象 | 原因 | 解决办法 |
|---|---|---|
| `ValueError: The truth value of a Series is ambiguous` | 用了 `and` / `or` 组合掩码 | 改成 `&` / `|`,并给每个条件加括号 |
| 掩码长度与 DataFrame 行数不一致 | 掩码来自另一个 DataFrame,或做了 `dropna` / 切片 | 确认 `len(mask) == len(df)`;用 `mask = mask.reindex(df.index, fill_value=False)` 对齐 |
| `iris[mask]` 报 `IndexingError` | 传了二维布尔 DataFrame 而不是一维 Series | 检查 `mask.ndim`,需要时取一列或做 `.all(axis=1)` |
| `sns.load_dataset("iris")` 报网络错误 | 需要联网下载内置数据集 | 改用第"四"节给出的等价 `read_csv` 方式,或用 `sklearn.datasets.load_iris()` 构造 |
| 比较结果全为 False | 浮点数列用 `==` 比较(如 `petal_width == 0.4`) | 浮点等值比较本身不可靠,改用 `np.isclose()` 或先做分箱 |
| `mask.sum()` 返回的是 `numpy.int64` 而非 Python int | NumPy 的返回类型 | 用 `int(mask.sum())` 转换后再参与字符串格式化 |
| 掩码里有 `NaN` | 原始列有缺失值,比较结果为 False 但 dtype 变成 object | 先 `df[col].fillna(...)`,或显式检查 `mask.dtype == bool` |