Skip to content

第 23 章 · 第一个 DataFrame:读电池 CSV ​

这一章要解决什么:用一行代码读入 1.4 万行 CSV,理解 DataFrame 是什么、和 NumPy 数组什么关系。 打掉一个误解:脏数据不是"行数变少了",而是"格子里的内容不可信了"。

本章你需要完成的代码:demos/ch23-first-dataframe.py

23.1 从 NumPy 到 Pandas:为什么要换工具 ​

上一章我们用 np.loadtxt(..., usecols=6) 读了一列内阻。 为什么只读一列?因为 np.loadtxt 读不了字符串列。

但真实的表是这样的:

cell_idbatchcycletimestampcapacity_ahsohresistance_mohmtemp_rise_ccoulombic_eff
C001A12023-01-012.50920.999940.336.00.998
C001A22023-01-022.48000.999839.476.010.998

数值、字符串、日期混在一起 —— 这正是 NumPy 数组的短板(第 17 章:一个数组只能有一种类型)。

Pandas 解决了三件事:

问题NumPyPandas
混合类型❌ 只能同类型✅ 每列一个类型
列名❌ 只有下标✅ df["soh"]
缺失值⚠️ 只能靠 nanmean 变通✅ isna() / fillna() / interpolate()
日期❌ 当字符串处理✅ to_datetime() / resample()
分组聚合❌ 要手写✅ groupby()

所以本书从这一章起全面转向 Pandas。但 NumPy 一点也没白学:

python
df["soh"].to_numpy()          # Series 底层就是 ndarray
df["soh"] * 100                # 整列运算走的就是 NumPy 的向量化

Pandas 是"带标签的 NumPy" —— 你在第 17~22 章学的一切,都在下面撑着。

23.2 Series:带标签的一维数据 ​

python
import pandas as pd

s = pd.Series([2.50, 2.46, 2.42], index=["C001", "C002", "C003"], name="capacity_ah")
print(s)

输出:

text
C001    2.50
C002    2.46
C003    2.42
Name: capacity_ah, dtype: float64

Series = 一维数组 + 一组标签(索引)+ 一个名字。

python
print(s.index)          # Index(['C001', 'C002', 'C003'], dtype='object')
print(s.name)           # capacity_ah
print(s["C002"])        # 2.46        ← 按标签取
print(s.iloc[0])        # 2.5         ← 按位置取
print(s.to_numpy())     # [2.5  2.46 2.42]   ← 底层还是 NumPy

"索引(index)"是什么:每一行的名字。 np.array 的"下标"只能是 0、1、2……;而 Series 的索引可以是任何东西: 日期、电芯编号、用户 ID。

这就是 Pandas 表达力的来源:你说 df.loc["2023-01-01"], 比说 df.iloc[0]("第一行")要清楚得多。

点开:s["C002"] 和 s.iloc[0] 有什么区别?
写法含义结果
s["C002"]按标签(label)2.46
s.iloc[0]按位置(integer location)2.5

当索引恰好是 0、1、2…… 时,两者结果常常一样 —— 这正是危险之处。 看这个例子:

python
s2 = pd.Series([10, 20, 30], index=[1, 2, 3])     # 索引是 1、2、3
print(s2[1])          # 20   ← 按标签:找标签为 1 的那一行 → 值 20
print(s2.iloc[1])     # 20   ← 按位置:第 1 个(0 基)→ 值 20

巧合一样。再看:

python
print(s2[0])          # KeyError!标签 0 不存在
print(s2.iloc[0])     # 10    位置 0 存在

所以纪律是:

  • 按标签 → s[label] 或 s.loc[label]
  • 按位置 → s.iloc[pos]

永远不要混用。 第 25 章会把这个纪律讲透(包括 loc 和 iloc 的切片语义差别)。

23.3 DataFrame:读 CSV 只要一行 ​

python
df = pd.read_csv("data/battery/cycle_raw.csv")

print(type(df))         # <class 'pandas.core.frame.DataFrame'>
print(df.shape)         # (13967, 9)
print(df.columns)       # Index(['cell_id', 'batch', 'cycle', ...])

DataFrame = 多列 Series 共享同一个索引。

python
print(type(df["soh"]))              # Series     ← 单列是 Series
print(type(df[["cell_id", "soh"]])) # DataFrame  ← 多列是 DataFrame
print(df["soh"].index is df.index)  # True       ← 共享同一个索引
点开:单方括号和双方括号的区别(新手第二大坑)
python
df["soh"]               # Series     —— 一列
df[["soh"]]             # DataFrame  —— 还是一列,但是"一列的表"
df[["cell_id", "soh"]]  # DataFrame  —— 多列
df["cell_id", "soh"]    # ❌ KeyError: ('cell_id', 'soh')

最后一行为什么报错:df["cell_id", "soh"] 被解析成"用一个元组当列名", 也就是 df[("cell_id", "soh")] —— 而 pandas 里没有叫这个名字的列。

记忆法:

一层方括号 → 你要"一列"(Series)两层方括号 → 你要"一张表"(DataFrame)

为什么这个区别重要:很多方法只有 DataFrame 才有。

python
df["soh"].to_frame()        # Series → DataFrame(想加列名时用)
df[["soh"]].assign(x=1)     # 只有 DataFrame 有 .assign()

实践中如果后面还要对结果做列操作,就用双方括号。

23.4 read_csv 自动推断类型:Pandas 最大的便利 ​

python
print(df.dtypes)

真实输出:

text
cell_id                str
batch                  str
cycle                int64
timestamp              str
capacity_ah        float64
soh                float64
resistance_mohm    float64
temp_rise_c        float64
coulombic_eff      float64

对比一下三行代码的工作量:

工具读这一张表要写的代码
csv 模块(第 12 章)手动 open + DictReader + 对每列 float() 转换
np.loadtxt(第 22 章)必须 usecols 挑数值列,字符串列直接放弃
pd.read_csv一行

Pandas 会自动判断:

  • 全是数字的列 → int64 或 float64
  • 有小数点的 → float64
  • 其他 → 字符串(str 或 object)

第 12 章那个"读进来全是字符串"的坑,到这里自动消失了:

python
# csv 模块:读出字符串,得手动转
with open(path) as f:
    rows = list(csv.DictReader(f))
soh = float(rows[0]["soh"])            # 必须转

# Pandas:自动就是数字
df["soh"].iloc[0]                       # 直接是 float
df["soh"].mean()                        # 直接能算
点开:自动推断也会出错,什么时候需要手动指定?

三种需要手动干预的情况:

① 前导零被吃掉(最常见)。

python
# CSV 里是 "007"
df = pd.read_csv(path)
print(df["code"].iloc[0])       # 7  ← 前导零丢了!

修复:

python
df = pd.read_csv(path, dtype={"code": str})     # 指定这一列是字符串

② 数字里带千分位或特殊符号。

python
# CSV 里是 "1,234.5"
df = pd.read_csv(path)          # 这一列会变成字符串!
df = pd.read_csv(path, thousands=",")          # 告诉 Pandas 逗号是千分位

③ 日期列。

python
df = pd.read_csv(path)
print(df["timestamp"].dtype)    # str   ← 还是字符串!

修复(两种):

python
# 读的时候直接转
df = pd.read_csv(path, parse_dates=["timestamp"])

# 或者读完再转(推荐 —— 意图更明确,也方便处理异常)
df["timestamp"] = pd.to_datetime(df["timestamp"])

第 29 章会详细讲日期处理。这里先记住:read_csv 不会自动把日期列变成日期类型。

一个实用的诊断技巧:读完后立刻 print(df.dtypes)。 如果有哪一列的类型不符预期,就用 dtype= 或 parse_dates= 指定。

python
# 一眼看出"本该是数字却是 object"的列
print(df.dtypes[df.dtypes == "object"])

23.5 看数据:head / tail / sample ​

python
print(df.head(3))       # 前 3 行
print(df.tail(2))       # 后 2 行
print(df.sample(2, random_state=42))    # 随机抽 2 行(固定种子 → 可复现)

head() 是"看一眼"的标准工具: 拿到任何表,先 df.head(3).to_string(index=False) 看三行 —— 只有看到实际的列名和值,你才能确认"我读的是我以为的那个文件"。

点开:为什么 print(df) 是个坏主意?
python
print(df)

对 13,967 行的表,Pandas 会打印:

        cell_id batch  cycle  ...  temp_rise_c  coulombic_eff
0          C001     A      1  ...         6.00          0.998
1          C001     A      2  ...         6.01          0.998
2          C001     A      3  ...         6.01          0.998
...
13964      C012     D    877  ...        15.62          0.994
13965      C012     D    878  ...        15.63          0.994
13966      C012     D    879  ...        15.64          0.994

[13967 rows x 9 columns]

其实 Pandas 有显示上限(默认 60 行),所以不会真的刷 1.4 万行。 但你会看到中间夹着 ...,看不到完整信息,也没法对照。

更好的做法:

python
df.head(3)                      # 看前几行(最有信息量)
df.shape                        # 看规模
df.info()                       # 看类型和缺失(第 24 章)
df.describe()                   # 看数值列分布(第 24 章)
df.sample(3, random_state=42)   # 随机抽几行(防止只看到开头有偏差的数据)

sample 的用处:如果数据是按时间或分组排序的,head() 只能看到开头那一段。 sample 能让你看到"全表的随机样本"。

random_state=42 让抽样可复现 —— 和第 18 章的随机种子是同一个道理。

还有两个控制显示的设置(写在脚本开头):

python
pd.set_option("display.width", 160)         # 终端宽度,防止列被折行
pd.set_option("display.max_columns", 30)    # 最多显示多少列

23.6 DataFrame 从哪来:三种常见方式 ​

python
# ① 从字典(手写小数据时最方便)
df1 = pd.DataFrame({"cell_id": ["C001", "C002"], "soh": [0.95, 0.79]})

# ② 从 NumPy 数组(记得配 columns)
arr = np.arange(6).reshape(3, 2)
df2 = pd.DataFrame(arr, columns=["a", "b"])

# ③ 从文件 / 数据库
df3 = pd.read_csv("x.csv")
df3 = pd.read_excel("x.xlsx")
df3 = pd.read_json("x.json")
df3 = pd.read_sql("SELECT * FROM t", conn)

写出(第 31 章详讲):

python
df.to_csv("out.csv", index=False)
df.to_excel("out.xlsx", index=False)
df.to_json("out.json", orient="records")

⚠ 从 NumPy 数组构造时一定要配 columns:

python
pd.DataFrame(np.arange(6).reshape(3, 2))
#    0  1        ← 列名是 0 和 1,不是有意义的列名
# 0  0  1
# 1  2  3
# 2  4  5

pd.DataFrame(np.arange(6).reshape(3, 2), columns=["voltage", "temp"])
#    voltage  temp    ← 有意义了

没有列名的表,等于没有信息。 从数组造 DataFrame 时, columns= 不是可选项,是必需项。

23.7 ★ 脏数据的本质:行数没变,格子不可信了 ​

这是本章最重要的一节。我们读一下 dirty.csv:

python
raw = pd.read_csv("data/battery/cycle_raw.csv")
dirty = pd.read_csv("data/battery/dirty.csv")

print(raw.shape)        # (13967, 9)
print(dirty.shape)      # (13967, 9)   ← 完全一样!

行数、列数一模一样。 但内容不一样:

python
print(dirty.isna().sum())
text
cell_id             0
batch               0
cycle               0
timestamp           0
capacity_ah        48
soh                55
resistance_mohm    70
temp_rise_c        59
coulombic_eff      47
python
total = dirty.isna().sum().sum()
print(f"合计缺失 {total} 格,占全部格子 {total / dirty.size:.2%}")
# 合计缺失 279 格,占全部格子 0.22%

还有物理上不可能的值:

python
print(dirty["capacity_ah"].min())          # -0.5          ← 容量不可能是负数!
print(dirty["resistance_mohm"].max())      # 562.61 mΩ     ← 干净数据上限才 87.57
点开:为什么这个认知很重要?它决定了你后面每一步的做法

错误的心智模型:

"脏数据 = 数据少了一些行" → 所以要 dropna() 把缺的行删掉。

正确的心智模型:

"脏数据 = 同样是这些行,但有些格子的值不能信"。

这个区别导致的后果:

错误模型正确模型
第一反应df.dropna()df.isna().sum() 先看清缺在哪
对异常值直接删先标记、再决定(第 34 章)
对缺失删行判断是"随机缺失"还是"整段缺失",再选填法(第 30 章)
后果可能删掉 20% 数据、丢掉真信号保留可追溯性,处置有依据

用具体数字感受一下:这份数据只有 279/125,703 = 0.22% 的格子缺失。 如果用 dropna() 把所有含缺失的行都删掉呢?

python
print(len(dirty))                  # 13967
print(len(dirty.dropna()))         # 13688
print(f"丢掉了 {13967 - 13688} 行 = {(13967 - 13688) / 13967:.2%}")

丢掉了 2% 的行,但缺失只有 0.22% 的格子。 为什么?

因为缺失是随机撒在 5 个数值列上的。一行有 5 个数值格子, 只要有任意一个格子缺了,这一行就被 dropna() 删掉了。 所以"269 行被删"远多于"279 个格子缺失"的直觉感受 —— 每个缺失的格子杀掉了一整行(含那一行里其他 8 个完好的值)。

这就是"缺几格"和"缺几行"两个口径的差别:

python
print(f"缺失格数: {dirty.isna().sum().sum()}")                    # 279
print(f"含缺失的行数: {dirty.isna().any(axis=1).sum()}")          # 269
print(f"完整行数: {(~dirty.isna().any(axis=1)).sum()}")           # 13698

报告里必须说清楚用的是哪个口径。 "缺失 0.22%" 听起来很少, "但 1.9% 的行不完整,直接删会损失这些行里的全部信息" —— 这才是有决策价值的表述。

更重要的:这份数据里还有 70 条容量为负 + 68 条内阻暴增。 如果用"删掉所有异常行"的方式处理,你删掉的是这些行的全部 9 个字段, 而实际上只有 1 个字段是坏的 —— 其余 8 个字段的值可能是完好的。

所以后面的路线是:

第 24 章:看清楚(describe / info)
第 30 章:处理缺失(按电芯分组插值,而不是删行)
第 32~34 章:处理异常(标记 + 单独分析,而不是删行)
第 39 章:把处置过程写成"清洗日志"

这就是"数据不可信"这个认知带来的全部方法论。

23.8 守恒校验:每读一次数据都该做 ​

python
per = df.groupby("cell_id").size()
print(per.sum())            # 13967
print(len(df))              # 13967
assert per.sum() == len(df)

"守恒校验"的意思是:用一个独立的路径重新算一遍某个量,看是否一致。

常用的四条:

python
# ① 行数守恒:各组行数之和 == 总行数
assert df.groupby("cell_id").size().sum() == len(df)

# ② 范围校验:值在物理范围内
assert df["soh"].between(0, 1).all()

# ③ 无重复:主键唯一
assert df.duplicated(["cell_id", "cycle"]).sum() == 0

# ④ 无缺失(清洗后)
assert df[["capacity_ah", "soh"]].isna().sum().sum() == 0

为什么值得写:这四条加起来只有 4 行代码, 但它们能挡住"读错文件""上游改了数据""join 出了笛卡尔积"这一整类问题。

这类 bug 的共同特点:不报错。 你会拿到一张看起来正常、但行数悄悄翻倍(或者少了一半)的表, 然后基于它做出一堆错误的分析。

第 39 章的体检报告里,清洗日志的第一项就是这些校验结果。

23.9 完整示例与输出 ​

bash
cd code
python demos/ch23-first-dataframe.py

真实输出(节选):

text
=== ② DataFrame:读 CSV 只要一行 ===
  df.shape      = (13967, 9)   ← (13,967 行, 9 列)

=== ③ read_csv 自动推断类型(比 csv 模块省心的核心) ===
cell_id                str
cycle                int64
capacity_ah        float64
  → 数值列自动变成 int64 / float64,字符串列变成 str。
  → 上一章 np.loadtxt 必须手动 usecols 挑数值列,这里不用。

=== ④ 一张表 = 一堆 Series ===
  df['soh'] 的类型 = Series,长度 13,967
  ⚠ 单方括号取一列 → Series;双方括号取多列 → DataFrame。别少写一层!

=== ⑦ 读「脏数据」看差别 ===
  干净数据 shape = (13967, 9)
  脏数据   shape = (13967, 9)   ← 行数一样!
  变量没变,但格子里的「内容」不可信了 —— 这正是脏数据的本质。
  合计缺失 279 格,占全部格子 0.22%
    capacity_ah 最小值 = -0.5   ← 容量不可能是负数!
    resistance_mohm 最大值 = 562.61 mΩ   ← 干净数据上限才 87.57 mΩ

=== ⑧ 行列数校验:差点忘的常识 ===
  每支电芯的行数之和 = 13,967(应当等于 13,967)
  assert 通过 ✅

本章练习 ​

练习 1:读 CSV 并对比干净 / 脏数据

读 cycle_raw.csv 和 dirty.csv,对比:

  1. 两者的 shape 和列名;
  2. 脏数据每列的缺失数、合计缺失、缺失占比;
  3. capacity_ah 最小值、resistance_mohm 最大值;
  4. 干净数据的对应值(作为对照)。
参考答案
python
from pathlib import Path
import pandas as pd

CODE_DIR = Path(__file__).resolve().parent.parent.parent
DATA = CODE_DIR / "data" / "battery"

raw = pd.read_csv(DATA / "cycle_raw.csv")
dirty = pd.read_csv(DATA / "dirty.csv")

# ① shape 和列名
print(raw.shape)                        # (13967, 9)
print(dirty.shape)                      # (13967, 9)  ← 一样!
print(list(raw.columns) == list(dirty.columns))    # True

# ② 缺失分布
print(dirty.isna().sum())
total = int(dirty.isna().sum().sum())
print(f"合计 {total} 格,占 {total / dirty.size:.2%}")     # 279 格,0.22%

# ③ 异常值
print(dirty["capacity_ah"].min())            # -0.5
print(dirty["resistance_mohm"].max())        # 562.61

# ④ 对照
print(raw["capacity_ah"].min())              # 1.9781
print(raw["resistance_mohm"].max())          # 87.57

要看清的三件事:

  1. 行数列数完全一样 —— 脏数据不是"数据少了",而是"格子里的值不可信了"。 这个认知决定了后面是"删行"还是"标记/修复"(见 23.7 的详细讨论)。
  2. df.size 是"格子总数"(行 × 列 = 13967 × 9 = 125,703), 所以 缺失数 / df.size 得到的是按格子算的缺失率。
  3. 物理规则是最快的异常检测器:容量为负、内阻 562 mΩ —— 这两条不需要任何统计就能判断。

顺手算一下"缺几行"这个口径:

python
rows_with_na = dirty.isna().any(axis=1).sum()
print(f"含缺失的行数: {rows_with_na}(占 {rows_with_na / len(dirty):.2%})")
# 269 行(1.93%)

和"279 格(0.22%)"对比,感受一下口径的威力 —— 同一份数据,一个说法听起来"几乎没问题",另一个说法听起来"要处理一下"。

完整练习:exercises/ex23-01-first-dataframe

练习 2:Series 还是 DataFrame?
python
import pandas as pd
df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})

要求:说出下面每个表达式的类型(Series / DataFrame),并解释 df["a", "b"] 为什么报错。

python
df["a"]
df[["a"]]
df[["a", "b"]]
df["a", "b"]
参考答案
python
type(df["a"])            # Series      —— 一层方括号取一列
type(df[["a"]])          # DataFrame   —— 两层方括号,即使只有一列
type(df[["a", "b"]])     # DataFrame   —— 多列
df["a", "b"]             # ❌ KeyError: ('a', 'b')

df["a", "b"] 为什么报错:

Python 会把 df["a", "b"] 里的 "a", "b" 解析成一个元组 ("a", "b"), 所以它等价于:

python
df[("a", "b")]      # 找一个叫 ("a", "b") 的列 —— 不存在

一个例外要记住:如果 DataFrame 用的是 MultiIndex 列(多层列名), 那么 df["a", "b"] 就是合法的(表示"a 组下的 b 列")。

python
# 多层列名的例子
df2 = pd.DataFrame([[1, 2]], columns=pd.MultiIndex.from_tuples([("x", "a"), ("x", "b")]))
print(df2["x"])         # 取 "x" 组 → DataFrame
print(df2["x", "a"])    # 取 "x" 组下的 "a" 列 → Series

但在本书的数据里没有多层列名,所以记住这条就行:

一层方括号 = 一列(Series);两层方括号 = 一张表(DataFrame)。

顺带一个实用技巧:Series 想变成 DataFrame 用 .to_frame():

python
s = df["a"]
print(type(s.to_frame()))            # DataFrame
print(s.to_frame(name="新列名"))      # 顺便改列名
练习 3:为什么"缺 0.22% 的格子"会导致"删掉 2% 的行"?
python
dirty = pd.read_csv("data/battery/dirty.csv")
print(dirty.isna().sum().sum())                 # 279     格子数
print(len(dirty) - len(dirty.dropna()))         # ?      行数

要求:先预测第二个输出,再运行验证,然后解释两者的差距。

参考答案
279          缺失的"格子"数
269          被 dropna() 删掉的"行"数

为什么 279 个格子对应 269 行:

数据是"长表"格式 —— 每一行是"某支电芯在某次循环的 5 个测量值":

       cell_id  cycle  capacity_ah   soh    resistance_mohm  temp_rise_c  coulombic_eff
第 i 行  C001      1     2.5092     0.9999      40.33           6.00         0.998
                          ↑ 5 个数值格子里,任意一个缺失,整行就被删掉

关键:dropna() 默认 how="any" —— 只要有一个格子缺,整行都删。

所以:

279 个缺失格子  →  分布在最多 279 个不同的行上
                 →  实测落在 269 行上(有几行缺了 2 个格子)
                 →  dropna() 删掉这 269 行

每一行有 9 个字段,被删的时候 9 个字段一起没了 —— 包括那 8 个完好的字段。

这就是"用删行处理缺失"的代价:

丢了:269 行 × 9 字段 = 2,421 个字段值
其中真正有问题的:279 个格子
真正的损失:2,421 - 279 = 2,142 个本来完好的值被一起丢掉了

浪费率 = 2142 / 2421 ≈ 88%

88% 被丢掉的都是好数据。

替代方案(第 30 章详讲):

方案做什么代价
dropna()删掉含缺失的行丢掉整行(本例浪费 88%)
fillna(中位数)用列中位数填压平分布、方差变小
interpolate()用前后邻居插值时序数据首选(但要按组做!)

最后一个"按组"是第 30 章的核心坑 —— 这份数据是 12 支电芯竖着摞在一起的,跨组插值会用电芯 A 的数据补电芯 B, 是严重的数据污染。

所以本章这个练习的真正价值:让你在动手清洗之前, 先建立"删行代价很大"这个直觉。等到第 30 章就会明白, 为什么值得多写三行代码去做分组插值。

面向 AI 开发:DataFrame 是 AI 项目里数据的通用容器 ​

在真实的 AI 项目里,从"原始数据"到"模型输入"之间,几乎全是 Pandas 在干活。

一张典型的流水线图:

原始 CSV / 数据库 / 日志
        ↓  pd.read_csv / read_sql          ← 本章
   原始 DataFrame
        ↓  看数据(describe/info)          ← 第 24 章
        ↓  选择与筛选                        ← 第 25、26 章
        ↓  派生特征                          ← 第 27 章
        ↓  分组聚合                          ← 第 28 章
        ↓  处理时间                          ← 第 29 章
        ↓  处理缺失                          ← 第 30 章
   干净的 DataFrame
        ↓  to_csv / to_parquet              ← 第 31 章
        ↓  df[features].to_numpy() / .values
   NumPy 数组 / 张量
        ↓  torch.from_numpy(...)
   模型训练

"特征表"是 AI 项目的核心抽象。

无论你后面用 sklearn、XGBoost 还是 Transformer, 输入都是一个二维表(行 = 样本,列 = 特征)。 所以"怎么把原始数据变成一张好的特征表",就是 AI 工程的主要内容。

三个和 DataFrame 相关的实用认知:

① DataFrame 和 NumPy 之间来回转换是常态。

python
# DataFrame → NumPy(喂给模型)
X = df[["capacity_ah", "resistance_mohm", "temp_rise_c"]].to_numpy()
print(X.shape, X.dtype)         # (13967, 3) float64

# NumPy → DataFrame(看结果)
pred = model.predict(X)
df["pred"] = pred               # 加一列预测值,方便对比

② 特征名要跟着数据走,别用位置索引。

python
# ❌ 危险:列顺序一变,模型输入就错了
X = df.iloc[:, 4:7].to_numpy()

# ✅ 安全:按列名取,顺序由你控制
FEATURES = ["capacity_ah", "resistance_mohm", "temp_rise_c"]
X = df[FEATURES].to_numpy()

为什么这一点在 AI 里尤其重要:模型训练时用了 [A, B, C] 的顺序, 上线时如果数据表列顺序变成 [C, A, B],模型会静默地给出完全错误的预测。 这个 bug 不会报错,只会让模型效果莫名其妙地变差。

所以纪律是:把特征列表定义成常量,任何地方都用它取数据。

python
FEATURES = ["capacity_ah", "resistance_mohm", "temp_rise_c"]
TARGET = "soh"

X = df[FEATURES].to_numpy()
y = df[TARGET].to_numpy()

③ 大数据集用 Parquet,不用 CSV。

python
df.to_parquet("features.parquet", index=False)   # 快、小、保留 dtype

对比(第 31 章有实测):

CSVParquet
体积大小 3~10 倍
读取速度慢快 5~10 倍
保留 dtype❌ 要重新推断✅ 完整保留
能直接用 Excel 打开✅❌

结论:中间产物用 Parquet,最终给人看的用 CSV / Excel。


下一章:第 24 章 · 数据速览三件套