第 28 章 · 分组聚合:每支电芯的统计
这一章要解决什么:把 13,967 行按电芯分组,算出每支电芯的汇总指标。 掌握
groupby的切-算-合心智模型,以及agg与transform的关键区别。
本章你需要完成的代码:demos/ch28-groupby.py
28.1 心智模型:切 → 算 → 合
groupby 只有三步:
split :按 cell_id 把 13967 行切成 12 组
apply :每组各自算统计量
combine :把 12 个结果拼回一张新表(每支电芯一行)用一张图理解:
原始表(13967 行) 结果表(12 行)
┌──────┬───────┬──────┐
│ cell │ cycle │ soh │ ┌──────┬──────────┐
├──────┼───────┼──────┤ │ cell │ soh_mean │
│ C001 │ 1 │0.9999│ ┐ ├──────┼──────────┤
│ C001 │ 2 │0.9998│ │ C001 │ C001 │ 0.9069 │
│ ... │ ... │ ... │ │ │ C002 │ 0.9141 │
│ C001 │ 862 │0.8000│ ┘ │ ... │ ... │
├──────┼───────┼──────┤ │ C012 │ 0.9084 │
│ C002 │ 1 │0.9999│ ┐ └──────┴──────────┘
│ ... │ ... │ ... │ │ C002
│ C002 │ 1342 │0.8000│ ┘
├──────┼───────┼──────┤
│ ... │ ... │ ... │
└──────┴───────┴──────┘这个模型的价值:它让你能回答"这个操作会让表变长还是变短"。
| 操作 | 表的变化 |
|---|---|
groupby().agg() | 变短(每组一行) |
groupby().transform() | 不变(结果广播回每一行) |
groupby().filter() | 变短(按组的条件筛行) |
groupby().apply() | 不确定(取决于你的函数) |
28.2 最简单形式
s = df.groupby("cell_id")["soh"].mean()
print(type(s)) # Series
print(s)cell_id
C001 0.9069
C002 0.9141
C003 0.9074
C004 0.9040
C005 0.8957
C006 0.8956
C007 0.9134
C008 0.9196
C009 0.9199
C010 0.9089
C011 0.8918
C012 0.9084
Name: soh, dtype: float64注意结果是个 Series(不是 DataFrame),索引是分组键(cell_id)。
读法:
df.groupby("cell_id") ← 先"切"(此时还是个"分组对象",没算)
["soh"] ← 指定要算哪一列
.mean() ← 现在才真的"算"⚠ 中间那个 groupby("cell_id") 的结果不是 DataFrame:
g = df.groupby("cell_id")
print(type(g)) # <class 'pandas.core.groupby.generic.DataFrameGroupBy'>
print(g) # <pandas.core.groupby...> ← 不是数据它是个"待算"的对象。你必须接一个聚合函数(.mean() / .sum() / .agg())才能得到结果。
这解释了一个常见困惑:
df.groupby("cell_id") # 什么都不打印(或者打印一个对象描述)
df.groupby("cell_id").mean() # ✅ 这才有结果28.3 完整形式:命名聚合 agg(强烈推荐)
per_cell = df.groupby("cell_id").agg(
n_cycles=("cycle", "max"), # 语法:新列名=("原列名", "函数名")
soh_mean=("soh", "mean"),
soh_min=("soh", "min"),
r_max=("resistance_mohm", "max"),
temp_max=("temp_rise_c", "max"),
)真实输出:
n_cycles soh_mean soh_min r_max temp_max
cell_id
C001 862 0.9069 0.8 86.00 16.87
C002 1342 0.9141 0.8 85.62 16.82
C003 859 0.9074 0.8 82.71 16.85
...
C012 879 0.9084 0.8 74.12 17.04一行代码得到 5 个指标 × 12 支电芯。
点开:为什么推荐命名聚合?看反例
❌ 不推荐的写法:
per_cell = df.groupby("cell_id").agg(("soh", "mean"))
print(per_cell.columns)
# MultiIndex([('soh', 'mean')], ) ← 多层列名,取用很别扭要用的时候:
per_cell[("soh", "mean")] # 得写元组,很难看
per_cell["soh"] # 得到的是 DataFrame 不是 Series❌ 另一种不推荐的写法:多次 agg 再拼
a = df.groupby("cell_id")["cycle"].max()
b = df.groupby("cell_id")["soh"].mean()
c = df.groupby("cell_id")["resistance_mohm"].max()
per_cell = pd.concat([a.rename("n_cycles"), b.rename("soh_mean"), c.rename("r_max")], axis=1)六行代码,做了三次分组(每次都要重新扫一遍数据)。
✅ 命名聚合:
per_cell = df.groupby("cell_id").agg(
n_cycles=("cycle", "max"),
soh_mean=("soh", "mean"),
r_max=("resistance_mohm", "max"),
)四行,一次分组,列名直接可读。
命名聚合的通用形式:
df.groupby(分组键).agg(
新列名1=("原列名1", "聚合函数1"),
新列名2=("原列名2", "聚合函数2"),
...
)可用的聚合函数(字符串名):sum / mean / median / std / var / min / max / count / size / nunique / first / last / prod
自定义函数也能用:
per_cell = df.groupby("cell_id").agg(
soh_range=("soh", lambda s: s.max() - s.min()),
r_p90=("resistance_mohm", lambda s: s.quantile(0.9)),
)28.4 守恒校验:聚合不能凭空多出/少掉数据
print(f"各支电芯 n_cycles 之和 = {per_cell['n_cycles'].sum():,d}") # 13,967
print(f"原始表行数 = {len(df):,d}") # 13,967
assert int(per_cell["n_cycles"].sum()) == len(df)为什么这条校验有意义:
per_cell["n_cycles"] 是"每支电芯的最大循环次数"。 在本书的数据里,"最大 cycle"恰好等于"这支电芯的行数" (因为数据从 cycle=1 开始,每圈一行,不断档)。
所以"各支的 n_cycles 之和"应该等于"总行数"。
如果不等,说明:
- 有的电芯在中间缺了几圈(数据断档);
- 或者
cycle不是从 1 连续开始的; - 或者上游做了某种过滤。
这就是守恒校验的价值:用一个独立的路径验证"数据没变形"。
点开:更通用的守恒校验(不依赖"cycle 连续"这个前提)
上面那条校验用了一个隐含前提:max(cycle) == 行数。 如果数据中间断档,这条校验就会失败(但那不是 bug)。
更通用的写法是用 size():
# 各组行数之和,必须等于总行数(这是恒等式,永远成立)
assert df.groupby("cell_id").size().sum() == len(df)
# 或者直接对比每组的行数和 max(cycle)
check = df.groupby("cell_id").agg(n=("cycle", "size"), max_cycle=("cycle", "max"))
print((check["n"] == check["max_cycle"]).all()) # True → 说明无断档size() 和 count() 的区别(值得记):
size() | count() | |
|---|---|---|
| 数什么 | 行数(含 NaN) | 非空值数(不含 NaN) |
| 返回 | 每组行数 | 每组非空值数 |
| 用途 | 检查数据完整性 | 检查缺失 |
dirty = pd.read_csv(DATA / "dirty.csv")
print(dirty.groupby("cell_id").size().sum()) # 13967 行数不变
print(dirty.groupby("cell_id")["soh"].count().sum()) # 13912 少了 55 个(有缺失)
print(dirty["soh"].isna().sum()) # 55 ✅ 吻合这三个数字互相印证 —— 这就是"守恒校验"的思路: 用不同的方式算同一个量,看是否一致。
28.5 排序看结果
# 最差的 5 支
print(per_cell.sort_values("soh_mean").head(5))
# 最好的 3 支
print(per_cell.sort_values("soh_mean", ascending=False).head(3))真实输出:
最差的 5 支:
n_cycles soh_mean soh_min r_max temp_max
cell_id
C011 1030 0.8918 0.8 68.09 16.71
C006 1392 0.8956 0.8 82.54 17.09
C005 1323 0.8957 0.8 79.34 16.89
C004 1153 0.9040 0.8 70.86 16.84
C001 862 0.9069 0.8 86.00 16.87
最好的 3 支:
n_cycles soh_mean soh_min r_max temp_max
cell_id
C009 1146 0.9199 0.8 69.04 16.87
C008 1143 0.9196 0.8 74.49 17.16
C002 1342 0.9141 0.8 85.62 16.82
soh_mean 范围:0.8918 ~ 0.9199
差距只有 0.0281soh_mean 的差距只有 0.0281 —— 12 支电芯来自同一批工艺,差异很小。
⚠ 但 soh_mean 不能直接当"质量好坏"
点开:这是一个很容易犯的统计错误
soh_mean 低,可能有两个完全不同的原因:
原因 A:这支电芯真的差(衰减快)
原因 B:这支电芯跑得久(寿命长,后期低 SOH 的样本更多)看具体数字:
C011:n_cycles=1030(第 9 名) soh_mean=0.8918(第 12 名,最差)
C010:n_cycles=1423(第 1 名) soh_mean=0.9089(第 5 名)C010 跑了 1423 圈,比 C011 多跑 393 圈,但它的平均 SOH 反而更好。
为什么:soh_mean 是所有圈的平均。 跑得久的电芯在后期(低 SOH)停留的记录更多,所以平均会被拉低。
这就是"指标的定义会影响排名"。
用排名一起看:
rank = per_cell.assign(
rank_soh=per_cell["soh_mean"].rank(ascending=False).astype(int),
rank_life=per_cell["n_cycles"].rank(ascending=False).astype(int),
).sort_values("soh_mean") n_cycles soh_mean r_max rank_soh rank_life
cell_id
C011 1030 0.8918 68.09 12 9
C006 1392 0.8956 82.54 11 3
C005 1323 0.8957 79.34 10 5
C004 1153 0.9040 70.86 9 6
C001 862 0.9069 86.00 8 11
C003 859 0.9074 82.71 7 12
C012 879 0.9084 74.12 6 10
C010 1423 0.9089 74.22 5 1
C007 1415 0.9134 87.57 4 2
C002 1342 0.9141 85.62 3 4
C008 1143 0.9196 74.49 2 8
C009 1146 0.9199 69.04 1 7读法:
soh_mean最低的是 C011,但它只跑了 1030 圈(life 排名 9);- C010 跑了 1423 圈(life 排名 1,最久),
soh_mean却排在第 5; rank_soh和rank_life的差异,才是"真正的质量信号"。
★ 一个更好的指标:用"寿命"评质量
如果我们关心的是"哪支电芯更耐用",正确的指标是寿命本身:
print(per_cell.sort_values("n_cycles", ascending=False)[["n_cycles", "soh_mean"]]) n_cycles soh_mean
cell_id
C010 1423 0.9089 ← 最耐用
C007 1415 0.9134
C006 1392 0.8956
C002 1342 0.9141
...
C003 859 0.9074 ← 最短命★ 教训:
"平均值"这个指标,隐含了"每个样本同等重要"这个假设。 在这个数据里,后期低 SOH 的记录天然更多(因为数据生成到 0.80 才停), 所以"平均 SOH"天然惩罚了长寿电芯。
报告里应该怎么写:
"12 支电芯的全周期平均 SOH 在 0.8918~0.9199 之间。 注意:该均值受寿命长度影响(长寿电芯的后期低 SOH 记录占比更高), 因此不宜直接用作质量排名。评价耐用性应看寿命(859~1423 圈)。"
这就是"知道一个数字怎么算出来的"的重要性 —— 同样的数据,不同的指标定义,会得出不同的结论。
28.6 多列分组与 MultiIndex
by_batch = df.groupby(["batch", "cell_id"]).agg(
n=("cycle", "size"),
soh_mean=("soh", "mean"),
)
print(by_batch)真实输出:
n soh_mean
batch cell_id
A C001 862 0.9069
C005 1323 0.8957
C009 1146 0.9199
B C002 1342 0.9141
C006 1392 0.8956
C010 1423 0.9089
...注意索引变成了两层(batch 和 cell_id)—— 这叫 MultiIndex(多层索引)。
MultiIndex 的取值:
print(by_batch.loc["A"]) # 取批次 A 的所有电芯
print(by_batch.loc[("A", "C001")]) # 取批次 A 下的 C001
print(by_batch.loc["A", "n"]) # 取批次 A 的 n 列reset_index():把索引还原成普通列
flat = by_batch.reset_index()
print(flat)batch cell_id n soh_mean
A C001 862 0.9069
A C005 1323 0.8957
...reset_index() 是 groupby 之后最常用的一个动作,因为:
- 导出 CSV 时需要(否则索引会变成一列或者丢失);
- 后续 merge/join 时需要(用普通列比用索引方便);
- 阅读时更清晰(平铺的表比多层索引好读)。
⚠ 别丢掉有信息量的索引:
per_cell.reset_index(drop=True) # ❌ drop=True 会丢掉 cell_id 这个信息!cell_id 是分组键,它携带真实信息。用 drop=True 会让结果表失去"这行是哪支电芯"。
什么时候用 drop=True:索引只是 0..N-1 的行号时(没有信息量)。
28.7 ★ transform:把组内统计量"贴回原表"
这是 groupby 最重要的一个变体。
df["soh_dev"] = df["soh"] - df.groupby("cell_id")["soh"].transform("mean")真实输出:
cell_id cycle soh soh_dev r_rank
0 C001 1 0.9999 0.0930 0.0012
1 C001 2 0.9998 0.0929 0.0035
2 C001 3 0.9997 0.0928 0.0058原表行数没变(仍然是 13967 行),但多了一列"组内偏差"。
点开:agg 和 transform 的区别(务必分清)
一句话口诀:
agg是"切完算成一个数、表变短";transform是"切完算完再贴回每一行、表不变"。
图示对比:
原始表(8 行,2 支电芯) agg 结果(2 行) transform 结果(8 行)
┌──────┬──────┐ ┌──────┬──────┐ ┌──────┬──────┬──────┐
│ cell │ soh │ │ cell │ mean │ │ cell │ soh │ mean │
├──────┼──────┤ ├──────┼──────┤ ├──────┼──────┼──────┤
│ C001 │ 0.99 │ ┐ │ C001 │ 0.92 │ │ C001 │ 0.99 │ 0.92 │
│ C001 │ 0.95 │ │ → mean 0.92 │ C002 │ 0.88 │ │ C001 │ 0.95 │ 0.92 │
│ C001 │ 0.85 │ ┘ └──────┴──────┘ │ C001 │ 0.85 │ 0.92 │
│ C001 │ 0.89 │ │ C001 │ 0.89 │ 0.92 │
├──────┼──────┤ ├──────┼──────┼──────┤
│ C002 │ 0.91 │ ┐ │ C002 │ 0.91 │ 0.88 │
│ C002 │ 0.87 │ │ → mean 0.88 │ C002 │ 0.87 │ 0.88 │
│ C002 │ 0.86 │ ┘ │ C002 │ 0.86 │ 0.88 │
└──────┴──────┘ └──────┴──────┴──────┘
8 行 8 行(等长)代码上的区别:
# agg:表变短
df.groupby("cell_id")["soh"].mean()
# → Series,12 个值(每支电芯一个)
# transform:表等长
df.groupby("cell_id")["soh"].transform("mean")
# → Series,13967 个值(每一行都得到"它所属电芯的均值")transform 的返回值长度和原表一样 —— 所以它能直接参与列运算:
df["soh_dev"] = df["soh"] - df.groupby("cell_id")["soh"].transform("mean")
# ↑ 13967 个 ↑ 13967 个(每行一个组均值)如果用 agg 会怎样:
df["soh_dev"] = df["soh"] - df.groupby("cell_id")["soh"].agg("mean")
# ❌ 长度不匹配(13967 减 12)—— Pandas 会做对齐,结果是大量 NaN验证 transform 的正确性:
dev_mean = df.groupby("cell_id")["soh_dev"].mean()
print(dev_mean.round(10).tolist())
# [0.0, -0.0, -0.0, 0.0, -0.0, 0.0, ...]
assert dev_mean.abs().max() < 1e-12 # 每组的偏差均值应该是 0这个 assert 是"组内去均值"的定义性验证: 如果 soh_dev 真的是"减去组内均值",那它每组的均值必然为 0。
transform 的三种典型用途:
# ① 组内去均值 / 标准化(最常用)
df["soh_dev"] = df["soh"] - df.groupby("cell_id")["soh"].transform("mean")
df["soh_z"] = (df["soh"] - df.groupby("cell_id")["soh"].transform("mean")) \
/ df.groupby("cell_id")["soh"].transform("std")
# ② 组内排名、组内占比
df["r_rank"] = df.groupby("cell_id")["resistance_mohm"].rank(pct=True)
df["cycle_pct"] = df["cycle"] / df.groupby("cell_id")["cycle"].transform("max")
# ③ 组内填充缺失值(★★ 第 30 章最关键的那个坑就用它!)
df[NUM] = df.groupby("cell_id")[NUM].transform(lambda g: g.interpolate(limit_direction="both"))第 ③ 个是第 30 章的核心 —— 它解决了"跨电芯插值"这个数据污染问题。
transform 也接受自定义函数
def pct_within(s):
"""组内百分位。"""
return (s - s.min()) / (s.max() - s.min())
df["soh_pct_in_cell"] = df.groupby("cell_id")["soh"].transform(pct_within)⚠ 自定义函数必须返回"和输入等长"的结果,否则会报错或得到奇怪的结果。
# ❌ 返回一个标量(长度 1)—— 这是 agg 的用法
df.groupby("cell_id")["soh"].transform(lambda s: s.mean())
# 这个其实可以(Pandas 会自动广播),但要注意语义
# ✅ 返回等长的 Series(真正的 transform)
df.groupby("cell_id")["soh"].transform(lambda s: s - s.mean())经验法则:如果函数内部有 return s.something()(返回 Series),就是 transform; 如果是 return s.something().sum()(返回标量),那更适合用 agg。
28.8 分组键的选择:不是越细越好
print(f"groupby('cell_id') → {df['cell_id'].nunique()} 组,每组 ~{len(df) // 12:,d} 行")
print(f"groupby('cycle') → {df['cycle'].nunique():,d} 组,每组 ~12 行")groupby('cell_id') → 12 组,每组 ~1164 行 ✅ 有意义
groupby('cycle') → 1423 组,每组 ~12 行 —— 分组太细,统计量没有代表性分组键的选择规则:
分组键应该对应一个「业务实体」(电芯、批次、用户、日期), 而不是随便一个数值列。
判断方法:问自己"这个分组的统计量,拿在手上能干什么?"
# ✅ 有意义:每支电芯的平均 SOH → 可以用来排名、筛选
df.groupby("cell_id")["soh"].mean()
# ⚠️ 勉强有意义:每个循环次数的平均 SOH → 可以看"全批随循环的衰减趋势"
df.groupby("cycle")["soh"].mean()
# (其实这个更该用 pivot_table 或 resample)
# ❌ 没意义:每个内阻值分一组 → 每组就几行,统计量是噪声
df.groupby("resistance_mohm")["soh"].mean()常见的分组键:
| 场景 | 分组键 | 用途 |
|---|---|---|
| 多电芯数据 | cell_id | 每支电芯的汇总、组内归一化 |
| 多批次数据 | batch | 批次对比(第 38 章) |
| 多用户数据 | user_id | 每用户的行为统计 |
| 时间序列 | pd.Grouper(freq="M") | 按月聚合(第 29 章用 resample) |
28.9 完整示例与输出
cd code
python demos/ch28-groupby.py真实输出(节选):
=== ① 心智模型:切 → 算 → 合 ===
split :按 cell_id 把 13967 行切成 12 组
=== ③ 完整形式:命名聚合 agg(强烈推荐) ===
n_cycles soh_mean soh_min r_max temp_max
cell_id
C001 862 0.9069 0.8 86.00 16.87
C011 1030 0.8918 0.8 68.09 16.71
...
=== ④ 守恒校验:聚合不能凭空多出/少掉数据 ===
各支电芯 n_cycles 之和 = 13,967
原始表行数 = 13,967
assert 通过 ✅
=== ⑤ 排序看结果:谁最差? ===
soh_mean 范围:0.8918 ~ 0.9199
差距只有 0.0281 —— 12 支电芯来自同一批工艺,差异很小。
⚠ 但注意:soh_mean 高低受「寿命长短」影响,不能直接当成「质量好坏」。
=== ⑦ 多列分组:按批次看 ===
reset_index() 把索引还原成普通列(方便导出和继续操作)
=== ⑧ transform:把组内统计量「贴回原表」(长度不变) ===
原表行数没变:13,967 == 13,967 ✅
每支电芯内 soh_dev 的均值应为 0:全为 0本章练习
练习 1:生成每支电芯的汇总表
用 groupby + 命名聚合生成汇总表,包含:循环数、平均 SOH、最大内阻。
然后:
- 按平均 SOH 升序打印前 5 支;
- 打印平均 SOH 的范围;
- 做守恒校验;
- 用
reset_index()把索引还原成列。
参考答案
from pathlib import Path
import pandas as pd
pd.set_option("display.width", 170)
CODE_DIR = Path(__file__).resolve().parent.parent.parent
df = pd.read_csv(CODE_DIR / "data" / "battery" / "cycle_raw.csv")
# 命名聚合
per_cell = df.groupby("cell_id").agg(
n_cycles=("cycle", "max"),
soh_mean=("soh", "mean"),
r_max=("resistance_mohm", "max"),
)
# ① 最差的 5 支
print(per_cell.sort_values("soh_mean").head(5).round(4))
# C011 0.8918 / C006 0.8956 / C005 0.8957 / C004 0.9040 / C001 0.9069
# ② 范围
print(f"{per_cell['soh_mean'].min():.4f} ~ {per_cell['soh_mean'].max():.4f}")
# 0.8918 ~ 0.9199
# ③ 守恒校验
assert int(per_cell["n_cycles"].sum()) == len(df)
print("守恒校验通过 ✅")
# ④ reset_index
flat = per_cell.reset_index()
print(list(flat.columns)) # ['cell_id', 'n_cycles', 'soh_mean', 'r_max']三个要点:
- 命名聚合
新名=("原列", "函数")一次给出有意义的列名, 比agg(("soh", "mean"))得到的多层列名好用得多。 n_cycles=("cycle", "max")用max而不是count—— 因为cycle从 1 连续开始,max就等于行数(这也让守恒校验成立)。reset_index()保住cell_id(它是分组键,携带信息)。 不要用drop=True,那会把cell_id丢掉。
★ 关于排名的警告(本章 28.5 详讲):
soh_mean 最低的是 C011(0.8918),但它只跑了 1030 圈。 而 C010 跑了 1423 圈,soh_mean 排在第 5。
"平均 SOH 低"不等于"这支电芯差" —— 要结合寿命一起看。
完整练习:exercises/ex28-01-groupby
练习 2:agg 还是 transform?
说出下面每段代码该用 agg 还是 transform,并预测结果的形状:
# ① 每支电芯的平均 SOH
df.groupby("cell_id")["soh"].???("mean")
# ② 每一行的 SOH 减去它所属电芯的平均 SOH
df["soh_dev"] = df["soh"] - df.groupby("cell_id")["soh"].???("mean")
# ③ 每支电芯内,每一圈的内阻在组内的百分位排名
df["rank"] = df.groupby("cell_id")["resistance_mohm"].???(pct=True)
# ④ 每支电芯的最大循环次数
df.groupby("cell_id")["cycle"].???("max")参考答案
| # | 用哪个 | 结果形状 |
|---|---|---|
| ① | agg | Series,长度 12(每支电芯一个值) |
| ② | transform | Series,长度 13967(每行一个"组均值") |
| ③ | rank | Series,长度 13967 |
| ④ | agg | Series,长度 12 |
怎么判断:
你的结果要"每组一个数"吗? → agg
你的结果要"和原表一样长"吗? → transform
你要的是"组内排名"吗? → rank(是 transform 的特例)② 的具体展开:
# transform 返回等长 Series
group_mean = df.groupby("cell_id")["soh"].transform("mean")
print(len(group_mean)) # 13967 ← 和 df 一样长
# 所以能直接做减法
df["soh_dev"] = df["soh"] - group_mean
# 验证:每组的偏差均值应该是 0
print(df.groupby("cell_id")["soh_dev"].mean().round(10).tolist())
# [0.0, -0.0, -0.0, 0.0, ...] ✅如果用 agg 会怎样:
group_mean_agg = df.groupby("cell_id")["soh"].agg("mean")
print(len(group_mean_agg)) # 12
df["soh_dev"] = df["soh"] - group_mean_agg
# Pandas 会按索引对齐:df 的索引是 0..13966,group_mean_agg 的索引是 cell_id
# 两者对不上 → 结果是 13967 个 NaN
print(df["soh_dev"].isna().sum()) # 13967这个失败很隐蔽:不报错,只是全变成 NaN。
★ 一个实用的记忆法:
agg → Aggregate(聚合)→ 把多个变成一个 → 表变短
transform → Transform(变换)→ 长度不变 → 表等长顺带记住 rank:它是"组内排名",也是等长的,所以归类为 transform 家族。
用它做"每支电芯内阻最高的是哪几圈":
df["r_rank"] = df.groupby("cell_id")["resistance_mohm"].rank(pct=True)
top1pct = df[df["r_rank"] > 0.99]
print(f"每支电芯内阻最高的 1% 记录:{len(top1pct)} 条")练习 3:为什么 soh_mean 不能用来排名?
per_cell = df.groupby("cell_id").agg(n_cycles=("cycle", "max"),
soh_mean=("soh", "mean"))
print(per_cell.sort_values("soh_mean").head(3))真实输出:
n_cycles soh_mean
cell_id
C011 1030 0.8918
C006 1392 0.8956
C005 1323 0.8957要求:解释为什么 soh_mean 最低不等于"这支电芯最差", 并给出一个更好的指标。
参考答案
原因:soh_mean 受"寿命长度"影响。
看两支电芯对比:
C011:寿命 1030 圈,soh_mean = 0.8918 ← 看起来最差
C010:寿命 1423 圈,soh_mean = 0.9089 ← 看起来中等C010 比 C011 多跑了 393 圈,但它的平均 SOH 更高。
为什么会这样:soh_mean 是所有圈的平均。
C011 的 SOH 轨迹(1030 圈):从 1.00 线性降到 0.80
C010 的 SOH 轨迹(1423 圈):从 1.00 线性降到 0.80两者的"起点和终点"完全一样,但 C010 的轨迹更长。
关键:数据生成规则是"SOH 降到 0.80 才停",所以:
- 每支电芯的 SOH 都是
1 - 0.20·c^β(c 是归一化进度) - 平均下来,跑得久的电芯在低 SOH 区域停留的记录更多吗?
其实不是 —— 因为 c 是归一化的(0→1), 所以每支电芯的 SOH 分布形状相似,只是"密度"不同(圈数不同)。
那为什么 C011 的 mean 更低? 看生成公式:
SOH = 1 - 0.20 · c^ββ 每支电芯不同(0.8~1.6):
β大(> 1):前期衰减慢、后期陡(凸函数)→ 平均 SOH 更高β小(< 1):前期衰减快、后期缓(凹函数)→ 平均 SOH 更低
查一下实际值:
cells = pd.read_csv(CODE_DIR / "data" / "battery" / "cells.csv")
print(cells.set_index("cell_id")[["beta", "n_cycles_to_eol"]].loc[["C011", "C010", "C006"]])
# beta n_cycles_to_eol
# C011 0.8496 1030
# C006 0.9162 1392
# C010 1.1968 1423基本吻合:
- C011 的
β=0.8496(小)→ 前期衰减快 → 平均 SOH 最低 - C010 的
β=1.1968(大)→ 前期衰减慢 → 平均 SOH 较高
验证整批的相关性(把 cells.csv 里的真值 β 连过来):
cells = pd.read_csv(CODE_DIR / "data" / "battery" / "cells.csv").set_index("cell_id")
merged = per_cell.join(cells[["beta"]])
print(merged[["soh_mean", "beta"]].corr().round(4))
# soh_mean beta
# soh_mean 1.0000 0.9955
# beta 0.9955 1.0000soh_mean 与 beta 的相关系数高达 0.9955 —— 几乎是同一个量。
所以 soh_mean 基本上是在度量「衰减曲线的形状」(β), 而不是「电池的质量」 —— 而 β 是生成时随机分配的,不代表质量差异。
这个数字 0.9955 值得记住:它说明"平均 SOH"这个指标 在这一批数据里几乎完全由 β 决定,和"电池好不好"没有关系。
这也直接解释了 C011 为什么垫底:它的 β=0.8496 是 12 支里最小的 (另一个偏小的是 C006 的 0.9162,排在第 2 低), 所以它前期衰减最快、平均 SOH 最低。
★ 更好的指标:看"寿命"
print(per_cell.sort_values("n_cycles", ascending=False).head(3))
# C010 1423 圈 / C007 1415 圈 / C006 1392 圈如果业务上关心"哪支更耐用",就应该直接看 n_cycles。
★ 这个练习的真正价值
它揭示了一个普遍的统计陷阱:
"平均值"隐含了"每个样本同等重要"这个假设。 当样本的构成不同(这里是"跑得久的电芯记录更多")时, 平均值会比较不可比。
报告里应该怎么表述:
"12 支电芯的全周期平均 SOH 在 0.8918~0.9199 之间,差距 0.0281。 需注意该指标受衰退指数 β 影响(不同电芯的衰减曲线形状不同), 不宜直接作为质量排名。评价耐用性应看寿命(859~1423 圈)。"
★ 一个通用的检查习惯:
当你用"平均值"排名时,问自己:
① 各组的样本量一样吗? 不一样 → 平均值不可比
② 各组的"构成"一样吗? 不一样 → 平均值不可比
③ 有没有一个更直接的指标? 有 → 用那个在本例里,三个问题的答案都是"应该换指标"。
面向 AI 开发:groupby 是"分组特征"的引擎
groupby 在 AI 项目里的地位,可以用一句话概括:
它是"把整组的统计信息变成每个样本的特征"的唯一工具。
这个思路叫「分组特征」或「目标编码的近亲」,是表格数据竞赛的必备技巧。
四个典型的应用:
① 组内归一化:让不同实体的数据可比(本章的 transform)。
# 每支电芯减去自己的均值 → 消除个体差异,只看"波动"
df["soh_dev"] = df["soh"] - df.groupby("cell_id")["soh"].transform("mean")为什么这一招强:如果每支电芯的初始容量不同(个体差异), 模型很难学到"什么因素影响衰减"(个体差异是噪声)。 组内去均值把噪声消掉,只留下"相对自己"的变化。
这和方法论里的「固定效应模型」是同一件事。
② 组级统计量作为特征(agg + merge)。
# 算出每支电芯的统计量
cell_stats = df.groupby("cell_id").agg(
cell_soh_std=("soh", "std"), # 这支电芯的波动性
cell_r_mean=("resistance_mohm", "mean"), # 这支电芯的平均内阻
cell_life=("cycle", "max"), # 这支电芯的寿命
)
# 贴回原表 → 每一行都带上了"它所属电芯的特征"
df = df.merge(cell_stats, left_on="cell_id", right_index=True)⚠ 但这有一个严重的数据泄漏风险:
cell_stats = df.groupby("cell_id").agg(cell_life=("cycle", "max"))
# ^^^^^^^^^^^^^^^^^^^^^^^^
# 这用了「整支电芯的全周期数据」——包括未来!如果任务是"在早期预测这支电芯的寿命",这个特征就是作弊。
安全的做法:只用"当前及之前"的数据。
# 对每一行,算"到目前为止"的组内统计量
df["soh_mean_so_far"] = df.groupby("cell_id")["soh"].transform(
lambda s: s.expanding().mean()
)
df["r_max_so_far"] = df.groupby("cell_id")["resistance_mohm"].transform(
lambda s: s.expanding().max()
)expanding() 的意思是"从开头累积到当前行" —— 这完全避免了看到未来。
③ 滞后与差分特征(shift / diff)。
# 上一圈的 SOH
df["soh_prev"] = df.groupby("cell_id")["soh"].shift(1)
# 相邻两圈的 SOH 变化(衰减速率)
df["soh_diff"] = df.groupby("cell_id")["soh"].diff(1)
# 最近 N 圈的平均变化
df["soh_ma10"] = df.groupby("cell_id")["soh"].transform(
lambda s: s.diff().rolling(10, min_periods=1).mean()
)这三个是时序预测里最基础也最有效的特征。
④ 分组标准化(z-score within group)。
df["soh_z"] = df.groupby("cell_id")["soh"].transform(
lambda s: (s - s.mean()) / s.std()
)和 ① 的区别:不只去均值,还除了标准差 —— 让每支电芯的数据都有"均值 0、标准差 1"的分布。
★ 一条最重要的纪律
任何用
groupby造的特征,都要问一句: "它用到了当前时刻之后的数据吗?"
一个检查清单:
| 操作 | 用到未来吗 |
|---|---|
transform("mean")(全周期) | ⚠️ 是(用了整组的所有数据) |
transform(lambda s: s.expanding().mean()) | ✅ 否(只用当前及之前) |
transform("max")(全周期) | ⚠️ 是 |
shift(1) / diff(1) | ✅ 否 |
agg("mean") + merge | ⚠️ 是 |
⚠ 但也不是"用全周期就一定错":
- 如果任务是"离线分析"(比如第 39 章的体检报告), 用全周期统计量完全没问题 —— 因为你不是在预测未来;
- 如果任务是"在线预测"(预测下一圈的 SOH), 用全周期统计量就是数据泄漏。
判断标准是"预测的场景是什么" —— 这也是第 37 章讲特征泄漏时会再次强调的。