第 31 章 · 保存处理结果
这一章要解决什么:把清洗好的数据落盘,让下游(建模、报告、同事)能用。 打掉一个低级但高频的错误:忘了
index=False,读回来多一列无名索引。
本章你需要完成的代码:demos/ch31-save-result.py
31.1 先做一次清洗
d = pd.read_csv(DATA / "dirty.csv")
d = d.sort_values(["cell_id", "cycle"]).copy()
d[NUM] = d.groupby("cell_id")[NUM].transform(
lambda g: g.interpolate(limit_direction="both"))这就是第 30 章的核心结论(先排序、再按组插值),四行代码。
真实输出:清洗后 shape = (13967, 9),剩余缺失 0 格
31.2 to_csv:最通用
d.to_csv("outputs/cycle_clean.csv", index=False)一行搞定。(739 KB)
⚠ index=False 不是可选项
d.to_csv("bad.csv") # 不写 index=False
back = pd.read_csv("bad.csv")
print(back.shape) # (13967, 10) ← 多了一列!
print(back.columns[0]) # 'Unnamed: 0'真实输出对比:
正常 : 9 列,列名 ['cell_id', 'batch', ...]
忘了 index=False: 10 列,多出列名 'Unnamed: 0'点开:为什么 Pandas 默认要写索引?什么时候该写?
为什么默认 index=True:
因为索引可能携带信息。比如第 28 章的汇总表:
per_cell = df.groupby("cell_id").agg(...)
# 索引是 cell_id —— 这是有价值的信息,必须保存
per_cell.to_csv("per_cell.csv") # ✅ 保留索引(cell_id 变成第一列)
per_cell.to_csv("per_cell.csv", index=False) # ❌ 丢掉 cell_id!什么时候该写 index=False:
索引只是 0..N-1 的行号时 —— 那是 Pandas 的"默认编号", 没有任何信息量,写出去只是多一列垃圾。
判断方法:
# 索引是默认的 RangeIndex 吗?
print(isinstance(df.index, pd.RangeIndex)) # True → 用 index=False
print((df.index == np.arange(len(df))).all()) # True → 同上两种情况的对照:
| 索引 | 例 | index= |
|---|---|---|
RangeIndex(0,1,2...) | pd.read_csv 读进来的表 | False |
| 有意义的标签 | groupby 的结果、set_index("timestamp") | True(默认) |
★ 本书的约定:
# 逐行明细数据(索引是行号)→ index=False
df.to_csv(path, index=False)
# 分组汇总(索引是分组键)→ 保留索引,或者先 reset_index()
per_cell.reset_index().to_csv(path, index=False) # 推荐:明确把分组键变成列最后一种写法最清楚 —— 它明确说明"索引变成了一列", 读者看代码就知道输出会有什么列。
31.3 中文乱码:utf-8-sig
d.head(100).to_csv("plain.csv", index=False, encoding="utf-8")
d.head(100).to_csv("sig.csv", index=False, encoding="utf-8-sig")真实输出:
utf-8 前 3 字节 = b'cel'
utf-8-sig 前 3 字节 = b'\xef\xbb\xbf'(BOM)点开:为什么 Excel 打开中文 CSV 会乱码?
原因:Excel 打开 CSV 时不问编码,直接按"本地默认编码"猜。
- 中文 Windows 的本地编码是 GBK
- 你的 CSV 是 UTF-8
- 用 GBK 去解读 UTF-8 的字节 → 乱码
utf-8-sig 的解决办法:在文件开头写 3 个字节 EF BB BF(BOM,Byte Order Mark)。
Excel 看到 BOM 就知道"这是 UTF-8",于是正确解码。
实测:
with open("plain.csv", "rb") as f:
print(f.read(3)) # b'cel' ← 直接进内容
with open("sig.csv", "rb") as f:
print(f.read(3)) # b'\xef\xbb\xbf' ← BOM什么时候用 utf-8-sig:
| 场景 | 编码 |
|---|---|
| 给 Excel 打开 | utf-8-sig |
| 给程序读(Pandas、Python) | utf-8 |
| 给 Linux/数据平台 | utf-8 |
⚠ 但 utf-8-sig 有一个坑:
# 其他程序读到 BOM 可能会当成内容
with open("sig.csv", encoding="utf-8") as f:
print(repr(f.readline()[:20]))
# '\ufeffcell_id,batch,...' ← 第一个列名前多了 \ufeff!这会导致列名匹配失败:
df = pd.read_csv("sig.csv")
print(df.columns[0]) # '\ufeffcell_id' ← 不是 'cell_id'!Pandas 的 read_csv 会自动处理 BOM(它默认用 utf-8-sig 的变体), 但其他语言的 CSV 解析器可能不会。
★ 所以规则是:
给人(Excel)看的文件 → utf-8-sig
给程序读的中间产物 → utf-8(或 Parquet)本书的 cycle_clean.csv 用 utf-8(它是给下游程序读的), 如果需要给同事用 Excel 看,就另外导一份 utf-8-sig 的。
31.4 to_excel:只适合"给人看的预览"
with pd.ExcelWriter("preview.xlsx", engine="openpyxl") as writer:
d.head(200).to_excel(writer, sheet_name="数据预览", index=False)
per_cell.to_excel(writer, sheet_name="电芯汇总")真实输出(装了 openpyxl 时):
已写 ch31_preview.xlsx(XX KB,两个 sheet)
读回验证:
sheet '数据预览': shape=(200, 9)
sheet '电芯汇总': shape=(12, 3)点开:为什么不该整表存 Excel?
三个硬限制:
| 限制 | 具体数字 |
|---|---|
| 行数上限 | 1,048,576 行(Excel 的物理限制) |
| 体积 | 同样数据,xlsx 通常是 CSV 的 2~5 倍 |
| 速度 | 写入和读取都比 CSV 慢几倍 |
本数据 13,967 行还没到上限,但真实产线数据动辄几百万行 —— 超了就直接报错。
约定:
CSV / Parquet → 存全量数据(给程序)
XLSX → 只存预览和汇总(给人看)正确用法:
with pd.ExcelWriter(path, engine="openpyxl") as writer:
# 前 200 行:让人看看"数据长什么样"
d.head(200).to_excel(writer, sheet_name="数据预览", index=False)
# 汇总表:这才是人真正会看的
per_cell.to_excel(writer, sheet_name="电芯汇总")
# 关键统计量
d.describe().to_excel(writer, sheet_name="统计摘要")三个 sheet 加起来不到 100 KB,但一个人打开就能看懂全部关键信息。
⚠ 如果没装 openpyxl:
d.to_excel("x.xlsx")
# ImportError: Missing optional dependency 'openpyxl'. Use pip or conda to install openpyxl.修复:pip install openpyxl(本书的 requirements.txt 没包含它, 因为它不是核心依赖 —— 用到再装)。
本书的示例代码会优雅地跳过:
try:
with pd.ExcelWriter(p3, engine="openpyxl") as writer:
...
except ImportError as exc:
print(f" 跳过 xlsx(缺少 openpyxl:{exc})→ pip install openpyxl")这个 try/except ImportError 模式值得学 —— 它让脚本在"可选依赖缺失"时降级而不是崩溃。
31.5 Parquet:数据量大了就该换它
d.to_parquet("cycle_clean.parquet", index=False)
back = pd.read_parquet("cycle_clean.parquet")真实输出(装了 pyarrow 时):
已写 cycle_clean.parquet(约 XX KB)
体积对比:CSV 739 KB → Parquet XXX KB(压到 XX%)
读回形状一致:True
Parquet 还会完整保留 dtype(CSV 读回来要重新推断)点开:Parquet 为什么更好?什么时候不能用?
Parquet 是"列式存储"格式。它和 CSV 的本质区别:
CSV(行式):
C001,A,1,2023-01-01,2.5092,0.9999,40.33,6.0,0.998
C001,A,2,2023-01-02,2.4800,0.9998,39.47,6.01,0.998
↑ 每一行连续存放
Parquet(列式):
capacity_ah 列: 2.5092, 2.4800, 2.4862, ... ← 连续存放
soh 列: 0.9999, 0.9998, 0.9997, ...
↑ 每一列连续存放三个好处:
① 压缩率高(同类型数据放一起,压缩算法效果更好)
CSV 739 KB
Parquet 通常 100~300 KB(压到 20~40%)② 只读需要的列时快得多
# 从 Parquet 只读两列
df = pd.read_parquet("big.parquet", columns=["soh", "cycle"])
# → 只解压这两列的数据,其他列不读对 100 列的大表,这能快 10 倍以上。CSV 做不到(必须读完再丢)。
③ 完整保留 dtype 和元数据
# CSV:类型信息全丢,读回来靠推断
d.to_csv("x.csv", index=False)
back = pd.read_csv("x.csv")
print(str(back["cycle"].dtype)) # int64(碰巧对了)
# 如果有缺失,int 列会变成 float!
d_dirty.to_csv("y.csv", index=False)
back2 = pd.read_csv("y.csv")
print(str(back2["cycle"].dtype)) # 可能变成 float64!
# Parquet:原样保留
d.to_parquet("x.parquet", index=False)
back3 = pd.read_parquet("x.parquet")
print(back3.dtypes.equals(d.dtypes)) # True什么时候不能用 Parquet:
| 场景 | 原因 |
|---|---|
| 要给 Excel 打开 | Excel 不认 parquet |
| 要给不装 pyarrow 的同事 | 他读不了 |
| 要与外部系统交换 | 很多系统只认 CSV |
| 人要看一眼内容 | 二进制文件打不开 |
★ 格式选择速查表
| 用途 | 格式 | 编码/引擎 |
|---|---|---|
| 临时交换、给 Excel 看 | CSV | utf-8-sig |
| 中间产物、大数据、要快 | Parquet | 需要 pyarrow |
| 给人看的报表 | XLSX | 需要 openpyxl,只放预览 |
| 与外部系统对接 | CSV / JSON | |
| 配置、嵌套结构 | JSON | |
| 数据库 | SQL |
本书的 outputs/ 里同时产出 CSV 和(如果环境支持)Parquet —— 让读者看到两者的差别。
31.6 ★ 写完必须读回来校验
这是本章最重要的一条纪律。
back = pd.read_csv(p1)
print(f"① 形状一致 : {back.shape == d.shape}")
print(f"② 列名一致 : {list(back.columns) == list(d.columns)}")
print(f"③ 没有缺失 : {int(back[NUM].isna().sum().sum()) == 0}")
print(f"④ 行数一致 : {len(back) == len(d)}")
assert back.shape == d.shape
assert list(back.columns) == list(d.columns)
assert back[NUM].isna().sum().sum() == 0
assert len(back) == len(d)真实输出:
四项校验:
① 形状一致 : True (13967, 9)
② 列名一致 : True
③ 没有缺失 : True
④ 行数一致 : True
四项断言全过 ✅为什么这四行值得写:它是"写出去的文件真的能用"的最基本保证。
四项校验各自挡什么 bug:
| 校验 | 挡住的 bug |
|---|---|
| ① 形状 | index=False 忘了写(列数 +1) |
| ② 列名 | 列名被 BOM 污染(\ufeffcell_id) |
| ③ 缺失 | 清洗没做干净,或者写的时候丢了数据 |
| ④ 行数 | 写的过程中被截断(磁盘满、权限问题) |
再加两项"更严格"的校验
# ⑤ dtype 一致
num_ok = all(str(back[c].dtype) == str(d[c].dtype) for c in d.columns)
# ⑥ 数值一致
exact = bool((back[NUM].to_numpy() == d[NUM].to_numpy()).all())
close = bool(np.allclose(back[NUM].to_numpy(), d[NUM].to_numpy(), rtol=1e-12))真实输出:
⑤ dtype 一致 : True
⑥ 数值用 == 比较 : False ← 浮点数别用 ==!
数值用 allclose : True (最大绝对差 7.11e-15)点开:为什么 == 是 False,而 allclose 是 True?
这就是第 04 章讲的浮点误差。
CSV 是文本格式,写出去的时候浮点数被转成字符串,读回来再转回浮点数:
x = 0.9999
s = str(x) # '0.9999' (实际可能是 '0.99989999999999997')
y = float(s) # 0.99989999999999997
print(x == y) # 可能 False!实测最大绝对差是 7.11e-15 —— 这是正常的浮点表示误差。
所以正确做法:
np.allclose(a, b, rtol=1e-12, atol=1e-12)np.allclose 的容差参数:
| 参数 | 含义 | 默认 |
|---|---|---|
rtol | 相对容差 | 1e-5 |
atol | 绝对容差 | 1e-8 |
判断条件:|a - b| <= atol + rtol * |b|
rtol=1e-12 是非常严格的要求(比默认严 7 个数量级), 对 CSV 往返完全够用。
★ 这个对比本身就是一堂课:
用 == 比较 → False → 你可能会以为"数据写坏了"
用 allclose → True → 其实数据完全正常,只是浮点表示有微小差异如果你不知道浮点误差这件事,可能会花几个小时去查一个不存在的问题。
第 04 章讲过这个坑,这里是一个真实的应用场景。
31.7 浮点精度与体积
d.to_csv(p5, index=False, float_format="%.4f")真实输出:
默认精度(pandas 3.0 会去掉浮点数末尾多余的 0): 738.6 KB
强制 %.4f : 799.9 KB(108%,即变大了 8%)点开:为什么强制 4 位小数反而变大?
反直觉的结果,但原因很简单:
Pandas 3.0 默认写的是"最短可信表示" —— 它会去掉末尾多余的 0:
默认: 0.998 (3 个字符)
%.4f: 0.9980 (5 个字符) ← 多了 2 个字符对 13967 行 × 5 个数值列 = 约 7 万个数字,每个多 2 个字符就是 140 KB。
所以 float_format 的用途不是"压缩体积",而是"控制可读性和一致性":
| 用途 | 用什么 |
|---|---|
| 给人看的报表(固定小数位更整齐) | float_format="%.4f" |
| 给程序读的中间产物 | 默认(更小、精度更高) |
★ 真正能大幅压缩体积的是 Parquet,不是 float_format。
精度取用原则(第 04 章讲过):
① 中间计算保持满精度
② 只在最后输出时舍入
③ 舍入到"比测量精度略高"就够了本数据的 SOH 保留 4 位小数 —— 而传感器精度只有 ±0.4%, 所以 4 位已经远远够用(甚至 3 位都够)。
什么时候需要高精度:
| 场景 | 精度要求 |
|---|---|
| 传感器数据 | 匹配传感器精度(通常 3~5 位有效数字) |
| 累加/统计的中间结果 | 尽量满精度(避免误差累积) |
| 模型权重 | float32(7 位有效数字)够用 |
| 金额 | 用 decimal,不用 float(第 04 章) |
31.8 目录约定
本章的示例在 code/outputs/ 下产出这些文件:
outputs/
├─ cycle_clean.csv 清洗后的逐圈数据(下游建模的输入)
├─ cycle_clean.parquet 同上(如果装了 pyarrow)
├─ ch28_per_cell_summary.csv 每支电芯的汇总(第 28 章产出)
├─ ch31_preview.xlsx 给人看的双 sheet 预览
├─ ch31_utf8sig.csv utf-8-sig 编码演示
├─ ch34_cleaning_log.csv 清洗日志(第 34 章产出)
├─ outlier_cases.csv 异常核查清单(第 34 章产出)
└─ *.png 所有图表(第 35~38 章)⚠ 写文件前先建目录:
OUT.mkdir(parents=True, exist_ok=True)to_csv 不会自动创建目录,会直接 FileNotFoundError。
这个坑在 CI/服务器上特别容易遇到 —— 因为本地目录已存在(之前跑过), 而干净环境里没有。
31.9 完整示例与输出
cd code
python demos/ch31-save-result.py真实输出(节选):
=== ② to_csv:最通用 ===
已写 cycle_clean.csv(739 KB)
对比:不写 index=False → 读回来 shape = (13967, 10)(列名多出 'Unnamed: 0')
=== ③ 中文乱码:Excel 打开 CSV 的正确姿势 ===
已写 ch31_utf8sig.csv,文件头 3 字节 = b'\xef\xbb\xbf'(EF BB BF 就是 BOM)
=== ⑥ ★ 写完必须读回来校验(四项) ===
① 形状一致 : True (13967, 9)
② 列名一致 : True
③ 没有缺失 : True
④ 行数一致 : True
四项断言全过 ✅
⑥ 数值用 == 比较 : False ← 浮点数别用 ==!
数值用 allclose : True (最大绝对差 7.11e-15)
=== ⑦ 浮点精度与体积 ===
默认精度: 738.6 KB
强制 %.4f: 799.9 KB(108%,即变大了 8%)本章练习
练习 1:清洗结果落盘并读回校验
- 读
dirty.csv,做按电芯分组插值清洗; - 存成 CSV(
index=False); - 读回来做四项校验并用
assert断言; - 演示不写
index=False会多出什么列; - 对比
utf-8与utf-8-sig的文件头字节。
参考答案
from pathlib import Path
import pandas as pd
CODE_DIR = Path(__file__).resolve().parent.parent.parent
DATA = CODE_DIR / "data" / "battery"
OUT = CODE_DIR / "outputs"
OUT.mkdir(exist_ok=True) # to_csv 不会自动建目录!
NUM = ["capacity_ah", "soh", "resistance_mohm", "temp_rise_c", "coulombic_eff"]
# ① 清洗(第 30 章的结论)
d = pd.read_csv(DATA / "dirty.csv")
d = d.sort_values(["cell_id", "cycle"]).copy()
d[NUM] = d.groupby("cell_id")[NUM].transform(
lambda g: g.interpolate(limit_direction="both"))
assert d[NUM].isna().sum().sum() == 0
# ② 落盘
path = OUT / "ch31_cycle_clean.csv"
d.to_csv(path, index=False)
print(f"已写 {path.name}({path.stat().st_size / 1024:.0f} KB)")
# ③ 读回校验
back = pd.read_csv(path)
assert back.shape == d.shape, "形状不一致"
assert list(back.columns) == list(d.columns), "列名不一致"
assert back[NUM].isna().sum().sum() == 0, "还有缺失"
assert len(back) == len(d), "行数不一致"
print("四项校验全过 ✅")
# ④ 不写 index=False 的后果
d.head(10).to_csv(OUT / "_bad.csv")
bad = pd.read_csv(OUT / "_bad.csv")
print(f"正常: {d.shape[1]} 列;忘了 index=False: {bad.shape[1]} 列"
f"(多出 {bad.columns[0]!r})")
(OUT / "_bad.csv").unlink()
# ⑤ 编码对比
d.head(10).to_csv(OUT / "_p.csv", index=False, encoding="utf-8")
d.head(10).to_csv(OUT / "_s.csv", index=False, encoding="utf-8-sig")
print(f"utf-8 前 3 字节 = {(OUT / '_p.csv').read_bytes()[:3]!r}")
print(f"utf-8-sig 前 3 字节 = {(OUT / '_s.csv').read_bytes()[:3]!r}")
(OUT / "_p.csv").unlink()
(OUT / "_s.csv").unlink()三个要点:
index=False只在"索引是行号"时用。 如果是groupby的结果(索引是cell_id),应该保留索引, 或者先reset_index()把cell_id变成列。meta.mkdir(exist_ok=True)不能省 —— 干净环境里目录不存在会直接报错。- 四项校验 +
assert是数据管道的基本纪律。 每一条只花一行代码,但能挡住一整类"静默错误"。
完整练习:exercises/ex31-01-save-result
练习 2:index=False 到底该不该写?
对下面四个场景,判断要不要写 index=False:
# ① 从 CSV 读进来的原始数据,清洗后导出
df = pd.read_csv("raw.csv")
df.to_csv("clean.csv", index=?)
# ② 每支电芯的汇总表
per = df.groupby("cell_id").agg(n=("cycle", "size"))
per.to_csv("summary.csv", index=?)
# ③ 把 timestamp 设为索引后的时间序列
ts = df.set_index("timestamp").sort_index()
ts.to_csv("timeseries.csv", index=?)
# ④ 训练特征矩阵(行号就是样本编号)
X.to_csv("features.csv", index=?)参考答案
| # | index= | 理由 |
|---|---|---|
| ① | False | 索引是 RangeIndex(0,1,2...),只是行号,没有信息 |
| ② | True(默认)或先 reset_index() | 索引是 cell_id,有信息,不能丢 |
| ③ | True(默认) | 索引是时间戳,有信息,读回来还要用它做时间操作 |
| ④ | False | 样本编号可以重新生成,不必存进文件 |
② 的两种正确写法:
# 写法一:保留索引(cell_id 成为第一列,但没有列名)
per.to_csv("summary.csv") # CSV 里第一列是无名的 cell_id
# 写法二:先 reset_index(推荐 —— 明确把分组键变成有名字的列)
per.reset_index().to_csv("summary.csv", index=False)
# CSV 第一列叫 'cell_id',读回来 df.columns[0] == 'cell_id'为什么推荐写法二:读回来的人一眼知道第一列是什么。 写法一读回来会得到 Unnamed: 0(虽然内容对,但列名丢了语义)。
③ 的验证:
ts = df.set_index("timestamp").sort_index()
ts.to_csv("ts.csv") # 保留时间索引
back = pd.read_csv("ts.csv", index_col=0, parse_dates=True)
print(back.index.dtype) # datetime64[ns] ✅
print(back.index.is_monotonic_increasing) # True ✅注意 pd.read_csv 的两个参数:
index_col=0:把第 0 列当成索引(否则会变成普通列);parse_dates=True:把索引解析成日期(否则还是字符串)。
★ 一个实用的判断规则
def to_csv_smart(df, path):
"""自动判断要不要写 index。"""
is_default_index = isinstance(df.index, pd.RangeIndex)
if is_default_index:
df.to_csv(path, index=False)
print(f" {path}:索引是行号 → index=False")
else:
df.reset_index().to_csv(path, index=False)
print(f" {path}:索引有信息 → reset_index() 后写出")这个函数的思路:
- 索引是行号 → 丢掉(
index=False); - 索引有信息 → 变成列(
reset_index()+index=False)。
这样输出的 CSV 永远是"纯列"的 —— 读回来不会出现 Unnamed: 0, 也不会丢信息。这是最省心的约定。
练习 3:为什么 == 比不过,allclose 能过?
import numpy as np
import pandas as pd
d = pd.DataFrame({"x": [0.9999, 0.9998, 0.9997]})
d.to_csv("t.csv", index=False)
back = pd.read_csv("t.csv")
print((back["x"].to_numpy() == d["x"].to_numpy()).all()) # ?
print(np.allclose(back["x"], d["x"])) # ?要求:说出两个输出,并解释原因。
参考答案
可能为 False ← 用 == 比较浮点
True ← 用 allclose注意我这里说"可能为 False" —— 因为结果取决于具体的值和 Pandas 的写入精度。 但你要理解的是"为什么可能不同"。
原因:CSV 是文本格式,浮点数要经历"写→文本→读"的往返。
内存里的 float64: 0.9999 (二进制的近似值)
↓ str() 或格式化
文本: '0.9999'
↓ float() 解析
内存里的 float64: 0.9999 (可能是另一个二进制近似值!)关键:0.9999 这个十进制数无法用二进制精确表示(第 04 章讲过)。 所以:
- 原始值 = 与
0.9999最近的 float64 - 写出的文本 = 某个十进制表示(可能是
0.9999或0.99990000000000001) - 读回来的值 = 与该文本最近的 float64
如果文本不是"最短可信表示",读回来就可能和原值差 1 ULP(最低位)。
验证:
import numpy as np
x = 0.9999
print(repr(x)) # 0.9999
print(repr(float(str(x)))) # 0.9999
print(x == float(str(x))) # True ← Python 的 repr 是"最短可信表示"
# 但 17 位精度的写法会暴露差异
print(f"{x:.17f}") # 0.99989999999999997
print(x == float(f"{x:.17f}")) # True
print(x - float(f"{x:.17f}")) # 0.0在 Pandas 里,写入精度由 float_format 控制(默认是"最短可信表示"), 所以大多数情况下往返是精确的。但你不能假设它永远如此。
★ 正确的比较方式
# ① NumPy 数组
np.allclose(a, b, rtol=1e-12, atol=1e-12)
# ② Pandas Series
import pandas as pd
pd.testing.assert_series_equal(s1, s2, rtol=1e-12)
# ③ Pandas DataFrame(最严格)
pd.testing.assert_frame_equal(df1, df2, rtol=1e-12, check_dtype=True)pd.testing.assert_frame_equal 是"验证两种处理等价"的终极工具:
# 验证"两种清洗流程结果一致"
pd.testing.assert_frame_equal(result_a, result_b, rtol=1e-10)
# 不一致时它会打印出"哪一列、哪一行、差多少"—— 非常有用★ 一条通用纪律
在数据处理里,永远不要用
==比较浮点数。 用np.allclose/pd.testing.assert_*/abs(a-b) < eps。
这条纪律在本书里出现过三次:
| 章节 | 场景 |
|---|---|
| 第 04 章 | 0.1 + 0.2 != 0.3(基础认知) |
| 第 20 章 | 用 np.allclose 验证"向量化与循环结果一致" |
| 第 31 章 | 验证"写出去再读回来数据没坏" |
同一个坑,三种不同的表现形式。
面向 AI 开发:数据落盘是"可复现性"的最后一环
在 AI 项目里,"把数据存好"不只是"方便下次用",而是可复现性的基础。
三个层次的实践:
① 中间产物要能"一键重建"。
# ❌ 手工操作:这次清洗用了中位数,下次忘了改成插值
df.to_csv("clean.csv")
# ✅ 代码化:清洗脚本 + 输出文件
# src/clean.py
def main():
raw = pd.read_csv("data/battery/dirty.csv")
cleaned, log = clean(raw)
cleaned.to_csv("outputs/cycle_clean.csv", index=False)
pd.Series(log).to_csv("outputs/cleaning_log.csv")★ 纪律:outputs/ 里的任何文件都应该能被脚本重新生成。
推论:
outputs/应该加进.gitignore(它是产物,不是源码);- 只要有
data/和src/,就能重建全部结果; - 别人拿到你的仓库,跑一条命令就能复现你的全部结论。
② 每个产物都要能追溯到"用哪个版本的代码生成的"。
import subprocess
import hashlib
def data_provenance(df, source, code_version=None):
"""记录数据来源信息 —— 附在输出文件旁边。"""
if code_version is None:
try:
code_version = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"], text=True).strip()
except Exception:
code_version = "unknown"
return {
"source": str(source),
"rows": len(df),
"columns": list(df.columns),
"code_version": code_version,
"fingerprint": hashlib.md5(
pd.util.hash_pandas_object(df, index=False).values.tobytes()
).hexdigest()[:12],
}fingerprint 的用途:验证"这份数据和我上次用的是同一份吗"。
meta = data_provenance(df, "data/battery/dirty.csv")
print(meta["fingerprint"]) # 'a3f8b2c1d4e5'下次读同一份数据,算出来的 fingerprint 应该一样。 如果不一样,说明数据变了 —— 你的结论可能需要重新验证。
这个技巧在"多人协作、数据频繁更新"的项目里极其有用。
③ 用 Parquet 做中间格式,用 CSV 做最终交付。
# 中间产物(读取频繁、体积大)→ Parquet
cleaned.to_parquet("outputs/cycle_clean.parquet", index=False)
# 最终交付(给别人、要能用 Excel 打开)→ CSV
cleaned.to_csv("outputs/cycle_clean.csv", index=False, encoding="utf-8-sig")一条典型的 AI 项目数据目录:
project/
├─ data/
│ ├─ raw/ 原始数据(只读,永不修改)
│ │ └─ cycle_raw.csv
│ ├─ interim/ 中间产物(Parquet,可重建)
│ │ └─ cleaned.parquet
│ └─ processed/ 最终特征表(喂给模型)
│ └─ features.parquet
├─ src/
│ ├─ clean.py 清洗 → 产出 outputs/
│ ├─ clean.py 清洗 → interim/
│ └─ features.py 特征工程 → processed/
├─ outputs/
│ ├─ models/ 模型文件
│ ├─ figures/ 图表
│ └─ reports/ 报告
└─ .gitignore 排除 data/interim、outputs为什么 data/raw/ 是"只读":
原始数据永远不该被修改。 所有处理都在它之上派生出新文件。
这条纪律的价值:任何一次错误的处理,都能通过"回到 raw 重跑"来撤销。
★ 本章最实用的一句话
"跑一遍脚本,就能从原始数据得到全部结论" —— 这是数据项目的及格线。
如果你的项目需要"手工做几步",那它就不能被复现, 也就不能被信任。
第 39 章的"一条命令产出完整体检报告",就是这个标准的实践。