前一篇建立了用 PyBaMM 生成带物理标签的 EIS 数据的完整管线。这一篇进入下一步:训练序列模型(LSTM、Transformer)预测健康状态(SOH)与剩余使用寿命(RUL)。
需要先讲清楚一件事:SOH 和 RUL 是两个难度完全不同的任务,把它们当成一个多任务问题一起训练,是这个领域最常见的自欺。SOH 是对当前状态的估计,信息就在输入里;RUL 是对未来的外推,在电池寿命早期它本质上是不适定的。下面会展开为什么,以及怎么处理。

PyBaMM 电池建模系列(共 4 篇):① 架构与求解器 → ② EIS 标签 → ③ 数据集管线 → ④ 训练 SOH/RUL。本文是第 ④ 篇。
一、退化动力学的随机过程建模
电池老化是非马尔可夫过程——循环 $k$ 时的内部状态取决于整个生命周期的应力历史。在最优估计的框架下,SOH 和 RUL 是离散时间非线性动力系统中的隐状态。经典表示是扩展卡尔曼滤波:
状态转移(含 SEI 增厚与活性物质损失):
$$ x_{k+1} = f(x_k, u_k) + w_k, \quad w_k \sim \mathcal{N}(0, Q) $$
观测(EIS 谱与端电压):
$$ y_k = h(x_k, u_k) + v_k, \quad v_k \sim \mathcal{N}(0, R) $$
$x_k$ 封装了直接对应 SOH 的内部容量参数(LLI、LAM)。深度学习任务就是用可微网络 $\theta$ 替换启发式观测函数 $h(\cdot)$,最大化退化轨迹的联合对数似然。
这个框架有个常被忽略的推论:如果你的网络只吃当前循环的 EIS,它在结构上就无法表达非马尔可夫性。必须喂进历史窗口,否则再深的模型也只是在做一个有噪声的查表。
二、为什么 RUL 在早期是不适定的
考虑两块电芯,前 200 圈的容量曲线几乎重合,但一块在 400 圈发生锂析出加速(knee point),另一块平稳衰减到 800 圈。在第 100 圈时,它们的可观测量几乎无法区分,而真实 RUL 相差一倍。
这不是模型不够强,是信息在输入里根本不存在。强行让网络输出一个 RUL 点估计,它只能回归到训练集的条件均值——在早期表现为”预测值紧贴数据集平均寿命”,指标看着还行,实际毫无判别力。
三种应对,按推荐程度排序:
| 做法 | 输出 | 代价 |
|---|---|---|
| 分位数回归 | RUL 的 10%/50%/90% 分位,早期区间自然变宽 | 损失函数改为 pinball loss,几乎无额外成本 |
| 分类到寿命区间 | “还剩 >500 / 200-500 / <200 圈” | 丢失精度,但和维护决策直接对齐 |
| 只在 SOH 低于阈值后预测 RUL | 限定在信息充足的区间 | 早期无输出,业务上未必接受 |
不推荐的是直接对 RUL 做 MSE 点回归然后只报平均误差——那个数字会掩盖早期完全失效的事实。
三、一个能跑通的模型定义
下面是自包含的实现。位置编码必须自己写出来——PyTorch 没有内置的 PositionalEncoding,很多教程直接引用一个不存在的类。
import math
import torch
import torch.nn as nn
class PositionalEncoding(nn.Module):
"""标准正弦位置编码。这里的"位置"是循环序号,不是时间戳 ——
等间隔采样的循环才能这么用;如果你的 EIS 是不等间隔测的,
要改成把 Δcycle 作为特征喂进去,而不是靠位置编码。"""
def __init__(self, d_model, max_len=5000):
super().__init__()
pe = torch.zeros(max_len, d_model)
pos = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(pos * div)
pe[:, 1::2] = torch.cos(pos * div)
self.register_buffer("pe", pe.unsqueeze(0)) # buffer 而非 parameter:
# 它不参与梯度,但要随模型保存
def forward(self, x):
return x + self.pe[:, : x.size(1)]
class ImpedanceTransformer(nn.Module):
def __init__(self, input_dim=120, d_model=256, nhead=8, num_layers=4, n_quantiles=3):
super().__init__()
self.input_projection = nn.Linear(input_dim, d_model)
self.pos_encoder = PositionalEncoding(d_model)
layer = nn.TransformerEncoderLayer(
d_model=d_model, nhead=nhead,
dim_feedforward=4 * d_model,
batch_first=True, # 不写这个,输入维度约定是 [seq, batch, dim],
# 和下面的 [batch, seq, dim] 静默错位
norm_first=True, # pre-LN,深层时训练稳定得多
)
self.transformer = nn.TransformerEncoder(layer, num_layers=num_layers)
self.soh_head = nn.Linear(d_model, 1)
# RUL 输出多个分位数而不是一个点估计,见上一节
self.rul_head = nn.Sequential(
nn.Linear(d_model, 64), nn.GELU(), nn.Linear(64, n_quantiles), nn.Softplus()
)
def forward(self, eis_sequence, pad_mask=None):
# eis_sequence: [batch, seq_len, input_dim]
# pad_mask: [batch, seq_len],True 表示该位置是 padding
x = self.pos_encoder(self.input_projection(eis_sequence))
feats = self.transformer(x, src_key_padding_mask=pad_mask)
if pad_mask is None:
latest = feats[:, -1, :]
else:
# 变长序列不能直接取 [-1] —— 那是 padding。取每条序列最后一个有效位置。
lengths = (~pad_mask).sum(dim=1) - 1
latest = feats[torch.arange(feats.size(0)), lengths]
# RUL 分位数必须单调:用累加保证 q10 <= q50 <= q90
rul = torch.cumsum(self.rul_head(latest), dim=-1)
return self.soh_head(latest), rul
三个容易静默出错的点已经写在注释里:batch_first 不设会让维度约定错位(不报错,只是学不出东西);变长序列直接取 [:, -1, :] 取到的是 padding;分位数不做单调约束会出现 q90 < q10 的荒谬输出。
四、损失函数:物理约束写进去,而不是事后检查
$$ \mathcal{L}(\theta) = \lambda_1 \| \text{SOH}_{pred} - \text{SOH}_{true} \|_2^2 + \lambda_2 \mathcal{L}_{pinball}(\text{RUL}) + \lambda_3 \Phi(x) $$
import torch
import torch.nn.functional as F
QUANTILES = torch.tensor([0.1, 0.5, 0.9])
def pinball_loss(pred, target, quantiles=QUANTILES):
"""pred: [batch, n_q] target: [batch, 1]"""
q = quantiles.to(pred.device).view(1, -1)
err = target - pred
return torch.maximum(q * err, (q - 1) * err).mean()
def monotonicity_penalty(soh_seq):
"""SOH 沿循环必须非增。静置恢复会造成小幅回升,所以留一个容差带,
只惩罚超出容差的上升 —— 硬约束会把真实的恢复现象也压掉。"""
diff = soh_seq[:, 1:] - soh_seq[:, :-1]
return F.relu(diff - 0.002).pow(2).mean()
def total_loss(soh_pred, rul_pred, soh_true, rul_true, soh_seq,
l1=1.0, l2=0.5, l3=0.1):
return (l1 * F.mse_loss(soh_pred, soh_true)
+ l2 * pinball_loss(rul_pred, rul_true)
+ l3 * monotonicity_penalty(soh_seq))
单调性惩罚用 relu(diff - 0.002) 而不是 relu(diff),是因为静置后的容量恢复是真实物理现象。把它当违规压掉,模型会学出一条过度平滑的曲线,反而在膝点附近变钝。
五、对抗协变量偏移与数据泄漏
合成数据机器学习中最致命的错误是逐行随机拆分。同一条 Simulation.solve() 轨迹派生的时序数据有完全确定性的协方差;如果同一电芯的第 $N$ 和第 $N+1$ 圈分落训练与测试,Transformer 靠线性插值就能"作弊",部署到真实硬件时会灾难性失效。
import numpy as np
from sklearn.model_selection import GroupKFold
groups = metadata["cell_design_id"].values
gkf = GroupKFold(n_splits=5)
for fold, (tr, te) in enumerate(gkf.split(X, y, groups=groups)):
# 断言写进流水线。事后才发现指标好得离谱,代价高得多。
assert not (set(groups[tr]) & set(groups[te])), f"fold {fold} 组泄漏"
...
还有一个更隐蔽的泄漏:特征归一化。如果你用全量数据算 mean/std 再拆分,测试集的统计量已经渗进训练。归一化参数必须只从训练折计算,然后应用到测试折。
六、评估:一个平均误差会骗你
电池寿命预测真正难的地方在早期不确定性、膝点附近误差和跨电芯泛化。只报 RUL MAE 会把这三样全盖住。
| 指标 | 检查对象 | 为什么重要 |
|---|---|---|
| SOH MAE / RMSE | 连续健康状态误差 | 整体容量追踪精度 |
| 分寿命阶段的 RUL 误差 | 早/中/晚期分别统计 | 只看总平均会被晚期的好成绩掩盖早期的失效 |
| 分位数覆盖率 | 真值落在 [q10, q90] 内的比例 | 应接近 80%;显著偏低说明不确定性被低估 |
| Knee point error | 衰退拐点位置偏差 | 决定维护和预警窗口是否可靠 |
| Group split gap | 同电芯内测试 vs 跨电芯测试的差距 | 差距大说明存在轨迹泄漏或伪泛化 |
| Monotonicity violation | SOH 是否不合理反弹 | 检查是否违反基本退化物理 |
分位数覆盖率这一条尤其值得单独盯。一个 RUL MAE 很漂亮但 [q10,q90] 只覆盖 45% 真值的模型,在生产上比误差大一倍但区间诚实的模型危险得多——它会让你在错误的时间相信它。
七、部署到 BMS:量化不是免费的
真实 BMS 跑在算力不足 100 MHz、带 ASIL 等级的车规 MCU 上。PyTorch 里训出来的多头注意力模型必须极端压缩:训练后量化(PTQ)到 int8,配合结构化剪枝,再经 ONNX 导出,与硬件 EKF 矩阵运算模块并行部署。
但 int8 PTQ 有个必须验证的陷阱。我们在另一个项目上实测过:一个在标准验证集上几乎无损的 int8 模型,换到分布外样本上指标断崖式下跌。只在"好样本"上验证量化,会严重高估它的安全性。
对应到电池场景,验证量化模型时必须单独覆盖:
- 膝点附近的样本——那里梯度最陡,量化误差被放大得最厉害
- 低温工况——阻抗谱形态与常温差异大,激活值分布会移位
- 训练集里罕见的退化路径(比如以锂析出为主导而非 SEI 主导的电芯)
校准集必须包含这些区域,否则 PTQ 的动态范围是按常见样本定的,罕见样本直接饱和。如果 int8 在这些区域不可接受,退到 fp16——牺牲一半的体积收益,换回可预测的行为,通常是划算的。
八、实验记录模板
数据来源: PyBaMM DFN/SPMe + 实测校准集
参数集: OKane2022 (退化) / 自拟合
划分方式: GroupKFold by cell_design_id / protocol_id
归一化: 仅从训练折计算,应用到测试折
输入窗口: 最近 N 次 EIS + 温度 + DOD + C-rate
输出目标: SOH (点估计), RUL (q10/q50/q90)
主要指标: SOH MAE, 分阶段 RUL MAE, 分位数覆盖率, knee point error
泄漏检查: assert 训练/测试组无交集
量化验证: 膝点 / 低温 / 罕见退化路径单独报指标
部署约束: ONNX / int8 或 fp16 / MCU latency budget
这份记录把物理建模、机器学习评估和嵌入式约束连起来。缺了它,一个在随机拆分上分数漂亮的模型,很可能只是记住了合成轨迹,而不是学到了可迁移的老化规律。