训练电池 AI 实例:用 PyBaMM 仿真数据预测 SOH 与 RUL
训练电池 AI 实例:用 PyBaMM 仿真数据预测 SOH 与 RUL
站内搜索
直接问 AI

训练电池 AI 实例:用 PyBaMM 仿真数据预测 SOH 与 RUL

前一篇建立了用 PyBaMM 生成带物理标签的 EIS 数据的完整管线。这一篇进入下一步:训练序列模型(LSTM、Transformer)预测健康状态(SOH)与剩余使用寿命(RUL)。

需要先讲清楚一件事:SOH 和 RUL 是两个难度完全不同的任务,把它们当成一个多任务问题一起训练,是这个领域最常见的自欺。SOH 是对当前状态的估计,信息就在输入里;RUL 是对未来的外推,在电池寿命早期它本质上是不适定的。下面会展开为什么,以及怎么处理。

SOH 与 RUL 预测结果及特征重要性
Transformer 推理验证: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

这份记录把物理建模、机器学习评估和嵌入式约束连起来。缺了它,一个在随机拆分上分数漂亮的模型,很可能只是记住了合成轨迹,而不是学到了可迁移的老化规律。

References

发表回复

向下探索