第 23 章 · 第一个 DataFrame:读电池 CSV
这一章要解决什么:用一行代码读入 1.4 万行 CSV,理解
DataFrame是什么、和 NumPy 数组什么关系。 打掉一个误解:脏数据不是"行数变少了",而是"格子里的内容不可信了"。
本章你需要完成的代码:demos/ch23-first-dataframe.py
23.1 从 NumPy 到 Pandas:为什么要换工具
上一章我们用 np.loadtxt(..., usecols=6) 读了一列内阻。 为什么只读一列?因为 np.loadtxt 读不了字符串列。
但真实的表是这样的:
| cell_id | batch | cycle | timestamp | capacity_ah | soh | resistance_mohm | temp_rise_c | coulombic_eff |
|---|---|---|---|---|---|---|---|---|
| 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 |
数值、字符串、日期混在一起 —— 这正是 NumPy 数组的短板(第 17 章:一个数组只能有一种类型)。
Pandas 解决了三件事:
| 问题 | NumPy | Pandas |
|---|---|---|
| 混合类型 | ❌ 只能同类型 | ✅ 每列一个类型 |
| 列名 | ❌ 只有下标 | ✅ df["soh"] |
| 缺失值 | ⚠️ 只能靠 nanmean 变通 | ✅ isna() / fillna() / interpolate() |
| 日期 | ❌ 当字符串处理 | ✅ to_datetime() / resample() |
| 分组聚合 | ❌ 要手写 | ✅ groupby() |
所以本书从这一章起全面转向 Pandas。但 NumPy 一点也没白学:
df["soh"].to_numpy() # Series 底层就是 ndarray
df["soh"] * 100 # 整列运算走的就是 NumPy 的向量化Pandas 是"带标签的 NumPy" —— 你在第 17~22 章学的一切,都在下面撑着。
23.2 Series:带标签的一维数据
import pandas as pd
s = pd.Series([2.50, 2.46, 2.42], index=["C001", "C002", "C003"], name="capacity_ah")
print(s)输出:
C001 2.50
C002 2.46
C003 2.42
Name: capacity_ah, dtype: float64Series = 一维数组 + 一组标签(索引)+ 一个名字。
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…… 时,两者结果常常一样 —— 这正是危险之处。 看这个例子:
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巧合一样。再看:
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 只要一行
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 共享同一个索引。
print(type(df["soh"])) # Series ← 单列是 Series
print(type(df[["cell_id", "soh"]])) # DataFrame ← 多列是 DataFrame
print(df["soh"].index is df.index) # True ← 共享同一个索引点开:单方括号和双方括号的区别(新手第二大坑)
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 才有。
df["soh"].to_frame() # Series → DataFrame(想加列名时用)
df[["soh"]].assign(x=1) # 只有 DataFrame 有 .assign()实践中如果后面还要对结果做列操作,就用双方括号。
23.4 read_csv 自动推断类型:Pandas 最大的便利
print(df.dtypes)真实输出:
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 章那个"读进来全是字符串"的坑,到这里自动消失了:
# 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() # 直接能算点开:自动推断也会出错,什么时候需要手动指定?
三种需要手动干预的情况:
① 前导零被吃掉(最常见)。
# CSV 里是 "007"
df = pd.read_csv(path)
print(df["code"].iloc[0]) # 7 ← 前导零丢了!修复:
df = pd.read_csv(path, dtype={"code": str}) # 指定这一列是字符串② 数字里带千分位或特殊符号。
# CSV 里是 "1,234.5"
df = pd.read_csv(path) # 这一列会变成字符串!
df = pd.read_csv(path, thousands=",") # 告诉 Pandas 逗号是千分位③ 日期列。
df = pd.read_csv(path)
print(df["timestamp"].dtype) # str ← 还是字符串!修复(两种):
# 读的时候直接转
df = pd.read_csv(path, parse_dates=["timestamp"])
# 或者读完再转(推荐 —— 意图更明确,也方便处理异常)
df["timestamp"] = pd.to_datetime(df["timestamp"])第 29 章会详细讲日期处理。这里先记住:read_csv 不会自动把日期列变成日期类型。
一个实用的诊断技巧:读完后立刻 print(df.dtypes)。 如果有哪一列的类型不符预期,就用 dtype= 或 parse_dates= 指定。
# 一眼看出"本该是数字却是 object"的列
print(df.dtypes[df.dtypes == "object"])23.5 看数据:head / tail / sample
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) 是个坏主意?
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 万行。 但你会看到中间夹着 ...,看不到完整信息,也没法对照。
更好的做法:
df.head(3) # 看前几行(最有信息量)
df.shape # 看规模
df.info() # 看类型和缺失(第 24 章)
df.describe() # 看数值列分布(第 24 章)
df.sample(3, random_state=42) # 随机抽几行(防止只看到开头有偏差的数据)sample 的用处:如果数据是按时间或分组排序的,head() 只能看到开头那一段。 sample 能让你看到"全表的随机样本"。
random_state=42 让抽样可复现 —— 和第 18 章的随机种子是同一个道理。
还有两个控制显示的设置(写在脚本开头):
pd.set_option("display.width", 160) # 终端宽度,防止列被折行
pd.set_option("display.max_columns", 30) # 最多显示多少列23.6 DataFrame 从哪来:三种常见方式
# ① 从字典(手写小数据时最方便)
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 章详讲):
df.to_csv("out.csv", index=False)
df.to_excel("out.xlsx", index=False)
df.to_json("out.json", orient="records")⚠ 从 NumPy 数组构造时一定要配 columns:
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:
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) ← 完全一样!行数、列数一模一样。 但内容不一样:
print(dirty.isna().sum())cell_id 0
batch 0
cycle 0
timestamp 0
capacity_ah 48
soh 55
resistance_mohm 70
temp_rise_c 59
coulombic_eff 47total = 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 mΩ ← 干净数据上限才 87.57点开:为什么这个认知很重要?它决定了你后面每一步的做法
错误的心智模型:
"脏数据 = 数据少了一些行" → 所以要
dropna()把缺的行删掉。
正确的心智模型:
"脏数据 = 同样是这些行,但有些格子的值不能信"。
这个区别导致的后果:
| 错误模型 | 正确模型 | |
|---|---|---|
| 第一反应 | df.dropna() | df.isna().sum() 先看清缺在哪 |
| 对异常值 | 直接删 | 先标记、再决定(第 34 章) |
| 对缺失 | 删行 | 判断是"随机缺失"还是"整段缺失",再选填法(第 30 章) |
| 后果 | 可能删掉 20% 数据、丢掉真信号 | 保留可追溯性,处置有依据 |
用具体数字感受一下:这份数据只有 279/125,703 = 0.22% 的格子缺失。 如果用 dropna() 把所有含缺失的行都删掉呢?
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 个完好的值)。
这就是"缺几格"和"缺几行"两个口径的差别:
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 守恒校验:每读一次数据都该做
per = df.groupby("cell_id").size()
print(per.sum()) # 13967
print(len(df)) # 13967
assert per.sum() == len(df)"守恒校验"的意思是:用一个独立的路径重新算一遍某个量,看是否一致。
常用的四条:
# ① 行数守恒:各组行数之和 == 总行数
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 完整示例与输出
cd code
python demos/ch23-first-dataframe.py真实输出(节选):
=== ② 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,对比:
- 两者的 shape 和列名;
- 脏数据每列的缺失数、合计缺失、缺失占比;
capacity_ah最小值、resistance_mohm最大值;- 干净数据的对应值(作为对照)。
参考答案
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要看清的三件事:
- 行数列数完全一样 —— 脏数据不是"数据少了",而是"格子里的值不可信了"。 这个认知决定了后面是"删行"还是"标记/修复"(见 23.7 的详细讨论)。
df.size是"格子总数"(行 × 列 = 13967 × 9 = 125,703), 所以缺失数 / df.size得到的是按格子算的缺失率。- 物理规则是最快的异常检测器:容量为负、内阻 562 mΩ —— 这两条不需要任何统计就能判断。
顺手算一下"缺几行"这个口径:
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?
import pandas as pd
df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})要求:说出下面每个表达式的类型(Series / DataFrame),并解释 df["a", "b"] 为什么报错。
df["a"]
df[["a"]]
df[["a", "b"]]
df["a", "b"]参考答案
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"), 所以它等价于:
df[("a", "b")] # 找一个叫 ("a", "b") 的列 —— 不存在一个例外要记住:如果 DataFrame 用的是 MultiIndex 列(多层列名), 那么 df["a", "b"] 就是合法的(表示"a 组下的 b 列")。
# 多层列名的例子
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():
s = df["a"]
print(type(s.to_frame())) # DataFrame
print(s.to_frame(name="新列名")) # 顺便改列名练习 3:为什么"缺 0.22% 的格子"会导致"删掉 2% 的行"?
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 之间来回转换是常态。
# 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 # 加一列预测值,方便对比② 特征名要跟着数据走,别用位置索引。
# ❌ 危险:列顺序一变,模型输入就错了
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 不会报错,只会让模型效果莫名其妙地变差。
所以纪律是:把特征列表定义成常量,任何地方都用它取数据。
FEATURES = ["capacity_ah", "resistance_mohm", "temp_rise_c"]
TARGET = "soh"
X = df[FEATURES].to_numpy()
y = df[TARGET].to_numpy()③ 大数据集用 Parquet,不用 CSV。
df.to_parquet("features.parquet", index=False) # 快、小、保留 dtype对比(第 31 章有实测):
| CSV | Parquet | |
|---|---|---|
| 体积 | 大 | 小 3~10 倍 |
| 读取速度 | 慢 | 快 5~10 倍 |
| 保留 dtype | ❌ 要重新推断 | ✅ 完整保留 |
| 能直接用 Excel 打开 | ✅ | ❌ |
结论:中间产物用 Parquet,最终给人看的用 CSV / Excel。
下一章:第 24 章 · 数据速览三件套