Skip to content

第 31 章 · 保存处理结果 ​

这一章要解决什么:把清洗好的数据落盘,让下游(建模、报告、同事)能用。 打掉一个低级但高频的错误:忘了 index=False,读回来多一列无名索引。

本章你需要完成的代码:demos/ch31-save-result.py

31.1 先做一次清洗 ​

python
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:最通用 ​

python
d.to_csv("outputs/cycle_clean.csv", index=False)

一行搞定。(739 KB)

⚠ index=False 不是可选项 ​

python
d.to_csv("bad.csv")                     # 不写 index=False
back = pd.read_csv("bad.csv")
print(back.shape)                       # (13967, 10)   ← 多了一列!
print(back.columns[0])                  # 'Unnamed: 0'

真实输出对比:

text
正常            : 9 列,列名 ['cell_id', 'batch', ...]
忘了 index=False: 10 列,多出列名 'Unnamed: 0'
点开:为什么 Pandas 默认要写索引?什么时候该写?

为什么默认 index=True:

因为索引可能携带信息。比如第 28 章的汇总表:

python
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 的"默认编号", 没有任何信息量,写出去只是多一列垃圾。

判断方法:

python
# 索引是默认的 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(默认)

★ 本书的约定:

python
# 逐行明细数据(索引是行号)→ index=False
df.to_csv(path, index=False)

# 分组汇总(索引是分组键)→ 保留索引,或者先 reset_index()
per_cell.reset_index().to_csv(path, index=False)     # 推荐:明确把分组键变成列

最后一种写法最清楚 —— 它明确说明"索引变成了一列", 读者看代码就知道输出会有什么列。

31.3 中文乱码:utf-8-sig ​

python
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")

真实输出:

text
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",于是正确解码。

实测:

python
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 有一个坑:

python
# 其他程序读到 BOM 可能会当成内容
with open("sig.csv", encoding="utf-8") as f:
    print(repr(f.readline()[:20]))
# '\ufeffcell_id,batch,...'    ← 第一个列名前多了 \ufeff!

这会导致列名匹配失败:

python
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:只适合"给人看的预览" ​

python
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 时):

text
已写 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           → 只存预览和汇总(给人看)

正确用法:

python
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:

python
d.to_excel("x.xlsx")
# ImportError: Missing optional dependency 'openpyxl'. Use pip or conda to install openpyxl.

修复:pip install openpyxl(本书的 requirements.txt 没包含它, 因为它不是核心依赖 —— 用到再装)。

本书的示例代码会优雅地跳过:

python
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:数据量大了就该换它 ​

python
d.to_parquet("cycle_clean.parquet", index=False)
back = pd.read_parquet("cycle_clean.parquet")

真实输出(装了 pyarrow 时):

text
已写 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, ...
  ↑ 每一列连续存放

三个好处:

① 压缩率高(同类型数据放一起,压缩算法效果更好)

text
CSV     739 KB
Parquet 通常 100~300 KB(压到 20~40%)

② 只读需要的列时快得多

python
# 从 Parquet 只读两列
df = pd.read_parquet("big.parquet", columns=["soh", "cycle"])
# → 只解压这两列的数据,其他列不读

对 100 列的大表,这能快 10 倍以上。CSV 做不到(必须读完再丢)。

③ 完整保留 dtype 和元数据

python
# 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 看CSVutf-8-sig
中间产物、大数据、要快Parquet需要 pyarrow
给人看的报表XLSX需要 openpyxl,只放预览
与外部系统对接CSV / JSON
配置、嵌套结构JSON
数据库SQL

本书的 outputs/ 里同时产出 CSV 和(如果环境支持)Parquet —— 让读者看到两者的差别。

31.6 ★ 写完必须读回来校验 ​

这是本章最重要的一条纪律。

python
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)

真实输出:

text
四项校验:
① 形状一致   : True   (13967, 9)
② 列名一致   : True
③ 没有缺失   : True
④ 行数一致   : True
四项断言全过 ✅

为什么这四行值得写:它是"写出去的文件真的能用"的最基本保证。

四项校验各自挡什么 bug:

校验挡住的 bug
① 形状index=False 忘了写(列数 +1)
② 列名列名被 BOM 污染(\ufeffcell_id)
③ 缺失清洗没做干净,或者写的时候丢了数据
④ 行数写的过程中被截断(磁盘满、权限问题)

再加两项"更严格"的校验 ​

python
# ⑤ 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))

真实输出:

text
⑤ dtype 一致 : True
⑥ 数值用 == 比较     : False   ← 浮点数别用 ==!
   数值用 allclose    : True   (最大绝对差 7.11e-15)
点开:为什么 == 是 False,而 allclose 是 True?

这就是第 04 章讲的浮点误差。

CSV 是文本格式,写出去的时候浮点数被转成字符串,读回来再转回浮点数:

python
x = 0.9999
s = str(x)              # '0.9999'   (实际可能是 '0.99989999999999997')
y = float(s)            # 0.99989999999999997
print(x == y)           # 可能 False!

实测最大绝对差是 7.11e-15 —— 这是正常的浮点表示误差。

所以正确做法:

python
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 往返完全够用。

★ 这个对比本身就是一堂课:

text
用 == 比较    → False  → 你可能会以为"数据写坏了"
用 allclose   → True   → 其实数据完全正常,只是浮点表示有微小差异

如果你不知道浮点误差这件事,可能会花几个小时去查一个不存在的问题。

第 04 章讲过这个坑,这里是一个真实的应用场景。

31.7 浮点精度与体积 ​

python
d.to_csv(p5, index=False, float_format="%.4f")

真实输出:

text
默认精度(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/ 下产出这些文件:

text
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 章)

⚠ 写文件前先建目录:

python
OUT.mkdir(parents=True, exist_ok=True)

to_csv 不会自动创建目录,会直接 FileNotFoundError。

这个坑在 CI/服务器上特别容易遇到 —— 因为本地目录已存在(之前跑过), 而干净环境里没有。

31.9 完整示例与输出 ​

bash
cd code
python demos/ch31-save-result.py

真实输出(节选):

text
=== ② 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:清洗结果落盘并读回校验
  1. 读 dirty.csv,做按电芯分组插值清洗;
  2. 存成 CSV(index=False);
  3. 读回来做四项校验并用 assert 断言;
  4. 演示不写 index=False 会多出什么列;
  5. 对比 utf-8 与 utf-8-sig 的文件头字节。
参考答案
python
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()

三个要点:

  1. index=False 只在"索引是行号"时用。 如果是 groupby 的结果(索引是 cell_id),应该保留索引, 或者先 reset_index() 把 cell_id 变成列。
  2. meta.mkdir(exist_ok=True) 不能省 —— 干净环境里目录不存在会直接报错。
  3. 四项校验 + assert 是数据管道的基本纪律。 每一条只花一行代码,但能挡住一整类"静默错误"。

完整练习:exercises/ex31-01-save-result

练习 2:index=False 到底该不该写?

对下面四个场景,判断要不要写 index=False:

python
# ① 从 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样本编号可以重新生成,不必存进文件

② 的两种正确写法:

python
# 写法一:保留索引(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(虽然内容对,但列名丢了语义)。

③ 的验证:

python
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:把索引解析成日期(否则还是字符串)。

★ 一个实用的判断规则

python
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 能过?
python
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(最低位)。

验证:

python
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 控制(默认是"最短可信表示"), 所以大多数情况下往返是精确的。但你不能假设它永远如此。

★ 正确的比较方式

python
# ① 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 是"验证两种处理等价"的终极工具:

python
# 验证"两种清洗流程结果一致"
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 项目里,"把数据存好"不只是"方便下次用",而是可复现性的基础。

三个层次的实践:

① 中间产物要能"一键重建"。

python
# ❌ 手工操作:这次清洗用了中位数,下次忘了改成插值
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/,就能重建全部结果;
  • 别人拿到你的仓库,跑一条命令就能复现你的全部结论。

② 每个产物都要能追溯到"用哪个版本的代码生成的"。

python
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 的用途:验证"这份数据和我上次用的是同一份吗"。

python
meta = data_provenance(df, "data/battery/dirty.csv")
print(meta["fingerprint"])        # 'a3f8b2c1d4e5'

下次读同一份数据,算出来的 fingerprint 应该一样。 如果不一样,说明数据变了 —— 你的结论可能需要重新验证。

这个技巧在"多人协作、数据频繁更新"的项目里极其有用。

③ 用 Parquet 做中间格式,用 CSV 做最终交付。

python
# 中间产物(读取频繁、体积大)→ 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 项目数据目录:

text
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 章的"一条命令产出完整体检报告",就是这个标准的实践。


下一章:第 32 章 · Z 分数异常检测