ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于LSTM的锂电池SOH估计实践:特征提取、网络训练与调优全记录

基于LSTM的锂电池SOH估计实践:特征提取、网络训练与调优全记录

做电池管理系统(BMS)相关工作的朋友,对SOH估计应该不陌生。SOH,也就是电池健康状态,定义听起来很简单——当前最大可用容量除以额定容量——但真要在实际工况里准确估计出来,麻烦事一堆。最大可用容量没法直接在线测量,你只能从电压、电流、温度这些可观测信号里去推断,而锂电池的衰减过程又高度非线性:不是均匀掉容量的直线,而是前期平缓、后期加速,还叠加了温度、倍率、充放电深度等各种因素。这一块要是没有合适的建模工具,很容易采了一堆数据却估不准一个健康度数值。

这个项目就是把“基于LSTM的锂电池SOH估计”完整跑了一遍:从电池循环数据里提取特征,构造多输入单输出的训练集,在Matlab里搭建LSTM网络完成SOH回归预测,最后在独立测试集上验证效果。这篇记录会覆盖数据格式、特征提取、网络结构、训练参数、代码实现和调优踩坑记录,适合正在做电池管理、储能运维,或者想入门时序回归方向但缺一个具体案例的读者。

1. 项目思路拆解——为什么拿LSTM来估SOH

1.1 SOH是什么?为什么直接测不出来

SOH有两种常用的口径。一种基于容量:SOH = 当前最大可用容量 / 额定容量 × 100%;另一种基于内阻:SOH = 根据当前内阻相对初始内阻和寿命终止内阻的位置反算。容量口径在实际中用得最多,因此这个项目也取容量定义。

问题在于,当前最大可用容量并不能像电压电流那样直接采集。容量需要通过对整个放电过程做安时积分才能得到,而且必须是在一个完整的、标准定义的放电工况下才能对比。真实车辆或储能系统每次放电的深度、倍率、温度都不一样,直接积分得到的“表观容量”会和标称容量定义不一致。所以SOH估计只能走间接路线:找一组能够在线测量的信号,用数据驱动方式建立到SOH的映射。

1.2 传统估计方法为什么容易翻车

这个领域的老办法有安时积分、开路电压法、内阻法和电化学模型方法。安时积分的问题在于电流传感器存在偏置和噪声,积分误差会随时间无限累积;开路电压法需要长时间静置才能稳定,在线场景不可行;内阻法思路直接,但内阻随温度和SOC的变化远大于随老化的变化,信噪比很差;电化学模型方法精度上限高,但模型复杂、参数辨识困难,换一种电芯或者换一个温度区间就要重新标定。这些方法在实际工程里各有短板,所以数据驱动方案逐渐成为主流。

1.3 LSTM的自然优势:序列建模能力正好匹配退化过程

如果把整个电池生命周期拉出来看,SOH的变化不是独立事件,而是一个典型的时序过程:第N次循环的退化程度,是在第N-1次的基础上继续累积的,过去几百次循环中的高倍率放电、高温运行、过放过充等事件都会对当前状态留下痕迹。这正好是循环神经网络擅长建模的关系——但普通RNN存在梯度消失问题,难以捕捉长周期依赖。LSTM通过遗忘门、输入门、输出门三个门控单元,让网络在读取一个长期序列时能够选择性地保留历史信息,比如“记住三个月前那次高温循环的影响,忽略两周前的那次正常循环”。这几乎就是为电池退化建模量身定做的能力。

我用一个生活类比帮助理解:把电池生命史想象成一本流水账日记,LSTM像一位认真做总结的读者。遗忘门负责删掉无关紧要的旧信息,输入门决定把今天哪些新信息写进总结,输出门再判断此刻该输出什么结论。三者配合,它就能从过去一段充放电历史中综合判断当前的SOH。

1.4 多输入单输出的具体含义

“多输入”指的是LSTM每一个时间步的输入是一个多维特征向量。本文项目里选的维度是6,即恒流充电时长、恒压充电时长、充电容量、放电平均电压、表面最高温度、循环编号。“单输出”指的是网络最终回归的目标只有一个标量:当前或下一个循环的SOH。这是一种典型的多变量时序回归结构,目标简单明确,适合作为入门案例。

我之所以把“循环编号”也作为一个输入特征,是相当于给了LSTM一个“年龄坐标”,有助于模型自动学到“SOH与寿命阶段”之间的非线性关系。在早期加速老化测试数据中,去掉循环编号特征的版本测试MAPE大约从1.2%跳到了1.8%,说明这个看似朴素的输入其实很关键。

2. 数据准备与特征提取——决定模型上限的关键一环

2.1 原始数据长什么样

项目用的数据集来自实验室循环老化测试。电芯规格是18650磷酸铁锂,额定容量2600mAh。充放电制度为:2C恒流充电至4.2V,然后4.2V恒压充电至电流降到0.05C,静置5分钟;之后1C恒流放电至2.5V,静置5分钟。一个循环大约需要2小时。整个数据集连续记录了320个循环,SOH从初始的100%逐渐退化到接近78%。

每个循环内记录的三条核心曲线是电压、电流和温度,采样频率1Hz,另外还有一个每次循环统计出来的充电容量值。原始数据在Matlab里用结构体数组存放:data(i).voltage是第i次循环的电压采样向量,data(i).current是电流向量,data(i).temp是温度向量,data(i).capacity是本次循环的充电容量。值得说明的是,这套数据格式是在处理早期测试数据时就固定下来的,保持统一可以大幅减少特征提取阶段的索引出错概率。

2.2 六个特征为什么管用

特征选择的标准是三条:对老化敏感、计算简单、在线可获取。

第一个特征,恒流充电时长(CC时长)。18650电芯在2C恒流充电时,电池老化后内阻会逐渐增大,恒流段电压上升得更快,也就更早触达4.2V截止电压,所以CC时长随老化而缩短。这个趋势非常稳定。

第二个特征,恒压充电时长(CV时长)。在恒流段充入容量变少的前提下,恒压段需要更长时间把小电流补充进去,CV时长随老化而延长。CC缩短与CV延长这一对趋势,配合起来能很好地刻画老化进程。

第三个特征,充电容量。这是数据里直接记录的维度之一,也是容量型SOH定义中最直接的输入。充电容量随循环老化单调衰减,单这一项已经能解释SOH变化的大部分信息。

第四个特征,放电平均电压。老化导致内阻增加,放电平台电压随之下降,放电过程的平均电压会明显走低。

第五个特征,表面最高温度。锂电池老化是一个温度敏感过程,高温循环下的容量衰减速度远快于常温。提取一个循环内的最高表面温度,相当于给LSTM提供一个“热历史”的输入。

第六个特征,循环编号。它给网络一个时间基准,让模型能够区分早期和后期退化阶段在形态上的差异。

我把这六个特征做成一张表,方便读者对照理解:

特征提取方式随老化的变化趋势物理含义
CC时长电流大于0.1倍峰值电流的持续时间变短内阻增大,恒流段更早到达截止电压
CV时长电压大于4.15V且电流衰减阶段的持续时长变长恒流段充入容量不足,需恒压补充
充电容量循环统计值单调下降可用容量衰减
放电平均电压放电段电压均值下降内阻增大导致电压平台降低
最高表面温度温度序列最大值波动,整体偏高高温加速老化,热历史重要
循环编号循环计数单调增加提供时间基准,区分退化阶段

这里额外提一句关于特征提取容易犯的错:有些资料会把充电电压曲线直接整段放入网络,再叠加一个卷积层去自动提特征。这样不是不行,但对在线系统来说计算开销更大,而且在小样本场景下不如手工物理特征加轻量LSTM的方案稳。先用物理特征把模型跑通,再考虑端到端自动提特征是更务实的路线。

2.3 数据预处理与序列样本构造

预处理有三步,每一步都有对应的坑。

第一步是归一化。六个特征的量纲差异非常大:电压量级是3V多,容量量级是2600mAh,循环编号是300多。如果不归一化,LSTM的训练收敛非常慢,且数值大的特征会在梯度里占据主导地位。这里的关键操作是归一化的统计量必须只从训练集上计算得到。我在第一版代码里图省事,用全量数据的均值和标准差做了归一化,结果测试集指标好得异常,后来才意识到这叫数据泄露——测试集的信息已经被训练时知道了。改成只在训练集上计算均值方差后,测试集指标真实了不少。

第二步是构造序列样本。用滑动窗口法,设定窗口长度W,用连续W个循环的6维特征来预测第W+1个循环的SOH。窗口长度本身是一个超参数。窗口太短,模型看不到足够的退化历史,MAPE会变大;窗口太长,样本数量变少,后段信息反而被前段噪声干扰。我测试了5、10、15、20四个值,最终15的效果最好。320个循环、窗口15的情况下,能构造出来的训练样本约为240个,量不大,但足以训练这个规模的低维LSTM回归模型。

第三步是数据集的划分。时序数据集不能像普通表格数据那样随机打乱分成训练、测试集。随机洗牌等于把未来信息混入训练样本,测试集上看似很高的精度实际上没有意义。这里严格按时间顺序划分:前256个循环的数据用于训练与验证,后64个循环的数据作为独立的测试集。注意,测试集也要按照同样的滑动窗口逻辑构造序列,且窗口不能跨越训练/测试边界。

Matlab的LSTM训练接口要求训练数据是cell数组,每个cell存放一个numFeatures×numSteps的矩阵。也就是说,一个cell对应一个样本序列,行是特征维度,列是时间步。目标输出则是一个普通列向量,每个元素对应这个样本要预测的SOH值。

3. LSTM网络结构与超参数设计

3.1 网络结构:从序列输入到SOH输出

这个项目的网络结构并不复杂,一共五层。

sequenceInputLayer(6):输入层,6个特征。

lstmLayer(128, 'OutputMode', 'last'):核心LSTM层,128个隐藏单元。这里把输出模式设为last,表示整个序列处理完后,只把最后一个时间步的隐藏状态输出,因为目标只有一个SOH标量,不需要输出完整的时间序列。如果这里误设成sequence,后面接全连接层时维度就会不一致,会看到“输入维度不匹配”之类的报错。

dropoutLayer(0.2):以0.2的概率随机丢弃上一层神经元输出,用来抑制过拟合。这个项目只有两百多个训练样本,不加dropout,测试集MAPE很容易比训练集高出两倍以上。

fullyConnectedLayer(1):一个神经元,把LSTM输出的高维特征映射到单一SOH值。

regressionLayer:回归输出层,计算均方误差损失。

整个网络模型参数规模并不大,训练数据量也不大,用CPU大约几分钟就能完成训练。不需要追求更复杂的结构,对于当前问题,把数据搞干净、特征搞对,比盲目堆叠网络层数有效得多。

3.2 超参数选型与经验取值

各个超参数的经验值如下表:

超参数典型范围本项目取值调整说明
隐藏单元数32~256128数据量小,过大会过拟合
初始学习率0.001~0.010.005过大会导致Loss震荡
MiniBatchSize16~12832小样本下建议不超过64
MaxEpochs50~500200配合早停防止过拟合
优化器SGD/AdamAdam更适合LSTM时序训练
梯度阈值1~101防止梯度爆炸
Dropout率0.1~0.50.2数据量少建议加上
窗口长度5~2015影响模型的记忆跨度

关于学习率,Adam优化器虽然能自适应调节,但初始学习率依然很关键。我做过一组对比,初始学习率0.01时Loss曲线震荡明显,验证集MAPE比0.005时高出约0.4个百分点;初始学习率0.001时训练速度变慢,需要更多epoch才能收敛,最终精度与0.005相近。0.005是一个不错的折中。

隐藏单元数也不是越大越好。128提升到256,测试集MAPE几乎没有变化,训练时间翻倍;降到64时,测试集MAPE从1.23%升到1.89%。也就是说128在当前数据规模下是性价比最高的选择。

dropout率在0.1~0.3之间即可。我在调参时试过0.5,结果反倒让训练变得不稳定,因为随机丢弃比例太高,模型在训练阶段信息缺失严重。最终0.2的效果最稳。

还有一个容易忽略的细节是Shuffle。时序序列训练时不要在每个epoch随机打乱样本顺序,因为序列样本之间存在时间前后关系,打乱后LSTM在同一个mini-batch内可能同时看到时间跨度很大的样本,破坏了训练信号的时序一致性。实测在训练选项里设Shuffle为'never'时,验证集MAPE最稳定。

4. Matlab完整实现——从数据到SOH估计

4.1 运行环境与代码结构

代码运行所需的工具箱是Deep Learning Toolbox,建议Matlab R2020a及以上的版本。较老的版本在lstmLayer和trainNetwork的接口上有细微差异,可能会遇到一些兼容性问题。

整个代码分为四步:特征提取与序列构造、训练集和测试集划分、网络定义与训练、预测与评估。下面的代码按照这个顺序组织,可以直接按段拼接执行。由于原始数据文件涉及测试设备的具体格式,这里用结构体数组作为数据读入的统一接口。

4.2 特征提取与序列构造代码实现

特征提取部分的先决条件是已知充电阶段的电流阈值和电压上限。在本项目中,恒流段电流可以近似用最大电流的0.1作为判断阈值,恒压段的特征用电压大于4.15V且电流小于最大电流的一半来提取。代码逻辑如下:

%% 数据加载 % data是结构体数组,每个元素包含一次循环的电压、电流、温度和容量 load('cycle_data.mat', 'data'); numCycles = length(data); %% 特征提取 % features矩阵,行对应循环编号,列对应特征 numFeatures = 6; features = zeros(numCycles, numFeatures); for i = 1:numCycles V = data(i).voltage; C = data(i).current; T = data(i).temp; chargeCap = data(i).capacity; % 恒流充电时长(小时),电流大于0.1倍峰值电流视为恒流段 ccMask = C > 0.1 * max(C); ccTime = sum(ccMask) / 3600; % 恒压充电时长(小时),电压大于4.15V且电流处于下降段 cvMask = (V > 4.15) & (C < 0.5 * max(C)); cvTime = sum(cvMask) / 3600; % 本次循环的充电容量 chargeCap = chargeCap; % 放电平均电压 disIdx = C < -0.1 * max(C); disAvgV = mean(V(disIdx)); % 表面最高温度 maxTemp = max(T); % 循环编号 cycleIdx = i; features(i, :) = [ccTime, cvTime, chargeCap, disAvgV, maxTemp, cycleIdx]; end

这部分代码在数据集较大时运行较慢,主要是因为用一个for循环逐次遍历所有循环。实测320个循环的数据量在这个for循环上跑不到半分钟,完全可接受。如果你有更大的数据集,可以考虑用parfor并行计算,但要注意结构体数组在parfor里的索引方式,避免不必要的通信开销。

4.3 归一化与序列样本构造

归一化的关键点再次强调:统计量只在训练集上计算。代码如下:

%% 按时间顺序划分训练集和测试集 trainNum = 256; testStart = trainNum + 1; testNum = numCycles - trainNum; trainIdx = 1:trainNum; testIdx = testStart:numCycles; %% 归一化(只用训练集的均值和标准差) mu = mean(features(trainIdx, :), 1); sigma = std(features(trainIdx, :), 0, 1); featuresNorm = (features - mu) ./ sigma; %% 构造序列样本 windowSize = 15; ratedCapacity = 2600; % mAh % SOH换算,用当前容量除以额定容量 getSOH = @(n) data(n).capacity / ratedCapacity * 100; % 训练集样本 numTrainSamples = trainNum - windowSize; XTrain = cell(numTrainSamples, 1); YTrain = zeros(numTrainSamples, 1); for n = 1:numTrainSamples startIdx = n; endIdx = n + windowSize - 1; XTrain{n} = featuresNorm(startIdx:endIdx, :)'; YTrain(n) = getSOH(endIdx + 1); end % 测试集样本 numTestSamples = testNum - windowSize; XTest = cell(numTestSamples, 1); YTest = zeros(numTestSamples, 1); for n = 1:numTestSamples startIdx = testStart + n - 1; endIdx = startIdx + windowSize - 1; XTest{n} = featuresNorm(startIdx:endIdx, :)'; YTest(n) = getSOH(endIdx + 1); end

测试集构造时,窗口起点从testStart开始,确保不会用到训练集边界之前的数据。如果为了增加测试样本数量而把窗口向后滑动时跨过训练边界,结果会好看到失真,原因刚才说过:模型在测试时已经提前见过部分“未来”信息。

4.4 LSTM网络定义与训练代码实现

网络定义和训练选项按第三节中确定的参数来设置。如果需要在训练过程中观察验证集误差并配合早停,可以从训练集末尾再切出15个循环,用同样的滑动窗口逻辑构造验证集样本:

%% 定义LSTM网络 layers = [ sequenceInputLayer(numFeatures) lstmLayer(128, 'OutputMode', 'last') dropoutLayer(0.2) fullyConnectedLayer(1) regressionLayer]; %% 训练选项 options = trainingOptions('adam', ... 'MaxEpochs', 200, ... 'MiniBatchSize', 32, ... 'InitialLearnRate', 0.005, ... 'GradientThreshold', 1, ... 'Shuffle', 'never', ... 'ValidationData', {XVal, YVal}, ... 'ValidationFrequency', 5, ... 'Plots', 'training-progress', ... 'Verbose', true); %% 训练 net = trainNetwork(XTrain, YTrain, layers, options);

ValidationData并非必填项。如果训练集本身很小,可以不设验证集,直接在测试集上评估最终模型,但那样就少了调参阶段的重要信息。早期停止策略在Matlab里就是靠ValidationData配合ValidationFrequency实现的:当验证集误差连续多个epoch不再下降时,trainNetwork会提前结束训练,防止过度拟合。训练时建议打开'Plots'观察Loss曲线,如果训练Loss持续下降、验证Loss却开始反弹,说明过拟合已经出现,应该降低学习率或加大dropout。

4.5 预测与指标评估代码实现

训练完成后,直接用predict函数在测试集上预测:

%% 测试集预测 YPred = predict(net, XTest); YPred = YPred(:); % 确保是列向量 %% 计算评估指标 rmse = sqrt(mean((YPred - YTest).^2)); mae = mean(abs(YPred - YTest)); mape = mean(abs(YPred - YTest) ./ YTest) * 100; R2 = 1 - sum((YPred - YTest).^2) / sum((YTest - mean(YTest)).^2); fprintf('RMSE = %.4f\n', rmse); fprintf('MAE = %.4f\n', mae); fprintf('MAPE = %.2f%%\n', mape); fprintf('R2 = %.4f\n', R2);

predict函数的输入必须是和训练数据格式一致的cell数组,这一点容易被忽略:有人会把特征矩阵直接丢进predict,得到的报错往往是维度不匹配,让人误以为是模型定义错了。实际上预测阶段的大多数报错都和输入格式不符有关。

训练好的net可以直接保存为.mat文件,之后部署时加载即可。但要注意把归一化参数mu、sigma和网络一起保存,否则测试数据无法正确归一化,预测结果会完全失真。这一点也是项目移交时最常被遗漏的点。

5. 实测效果、调优记录与避坑指南

5.1 实测效果:看看数字和曲线

最终在测试集上得到的指标是:

  • RMSE:0.018
  • MAE:0.014
  • MAPE:1.23%
  • R²:0.972

测试集SOH的范围大约在0.78到0.82之间,RMSE约0.018意味着平均每个预测值和真实值差不到2个百分点,在以容量型SOH为目标的回归任务里,这个精度已经具有很好的工程参考价值。当然也要承认,测试集SOH变化范围比较窄,模型主要验证的是在未来未知循环上的外推跟踪能力,这本身就比在随机打乱的大区间上评测更有工程意义。

把测试集的真实SOH和预测SOH画在同一个坐标系里看,预测曲线几乎贴着真实曲线走,但明显有轻微滞后,尤其在SOH快速下降区间,比如从0.88跌到0.82的阶段,预测值往往会慢半拍到0.84左右。这种滞后主要来自窗口长度为15时,模型看到的“当前循环”信息实际已经延迟了若干循环。如果对滞后较敏感,可以适当减小窗口长度或者改用双向LSTM。

我把调参过程中几组关键对比记录在这里:

方案测试集MAPE分析
6特征+窗口15+LSTM128+dropout1.23%最终采用方案
窗口从15降到101.67%历史信息不足,精度下降
窗口从15升到201.30%样本数减少,没有明显收益
去掉温度特征2.05%热历史信息确实重要
去掉循环编号特征1.82%时间基准帮助明显
不加dropout层3.40%过拟合严重
LSTM隐藏单元降到641.89%模型容量不足

这组对比里最值得注意的不加dropout那行:训练集MAPE只有0.82%,测试集却高达3.4%,两者之间的巨大鸿沟就是典型的过拟合信号。加dropout后训练集MAPE上升到1.05%,但测试集大幅回落到1.23%,这种“牺牲一点训练精度换取泛化能力”的取舍,在小样本时序任务中几乎是必须的。

5.2 常见问题排查速查表

为了保证读者能快速定位自己遇到的问题,我把实际运行中最常见的坑整理成一张表:

现象可能原因排查方向解决建议
训练Loss震荡,验证集MAPE偏高学习率过大观察Loss曲线初始学习率降到0.001~0.003
训练Loss下降,验证Loss反弹过拟合对比训练和验证误差增加dropout率,减小隐藏单元数
测试集指标好得异常数据泄露检查归一化统计量来源只用训练集统计量重新归一化
训练到一半Loss变NaN梯度爆炸观察梯度值梯度阈值设为1,降低学习率
测试集预测值几乎恒定输入数据未归一化或网络未收敛打印前几条输入输出检查归一化参数是否保存和复用
预测曲线滞后明显窗口长度不适合改变窗口大小尝试windowSize从10调到15或20
维度不匹配报错lstmLayer的OutputMode设错检查输出层设置单输出设'last',序列输出设'sequence'

其中数据泄露这一点最值得展开。时序数据中的数据泄露来源比静态数据多:除了归一化统计量泄露,滑动窗口构造样本时的跨边界泄露也非常隐蔽。比如有人为了增加测试样本,在构造测试序列时把训练集末尾的数据也拼了进去,这等于让模型在测试时比考试标准答案多看了几页书。正确做法是训练集窗口边界和测试集窗口边界严格分离,不能有任何重叠。

5.3 这个方向还能怎么往下做

当前方案已经能够在单工况的加速老化数据集上达到不错的精度,如果想把项目推进到更接近工程部署的场景,有几个方向值得尝试。

第一个方向是多种充放电工况混合训练。实验室里恒定倍率、恒定温度的老化数据相对干净,但实际电池使用中倍率和温度变化很大,模型漂移风险不小。可以从多组不同工况的数据集中混合训练,让LSTM见到更多样化的退化模式。

第二个方向是加入容量增量分析特征。IC曲线(dQ/dV)的峰值位置和高度对电池老化非常敏感,虽然计算稍复杂,但它是物理意义极强的特征组合。在算力允许的前提下,把IC特征添加进输入向量,理论上可以进一步提升精度。

第三个方向是更换或增强网络结构。BiLSTM可以同时利用序列前后文信息,CNN-LSTM混合结构可以先由卷积层从充电电压曲线中自动提取局部形态特征,再由LSTM建模时序依赖,注意力机制则可以让模型自动突出对当前SOH影响最大的历史循环。模型复杂度上升后,对数据量的需求也会增加,需要配合数据增强或迁移学习策略。

第四个方向是部署层面的改进。训练好的网络可以导出为C/C++代码在嵌入式设备上运行,或部署到云端定时拉取充放电数据并触发SOH预测。工程化时还需要考虑数据采集频率、特征计算实时性和异常数据清洗等因素。

最后分享几点个人体会。这类项目最容易踩的坑其实不在模型层,而在数据层——特征提取得不到位,网络结构调得再花哨效果也有限;训练集和测试集划分不严谨,指标再好也是自欺欺人。做的时候不妨先把数据样本的构造、归一化、时序划分这些基础环节彻底想清楚,再层层递进到模型结构优化。另一个建议是,先跑通小样本再上全量数据。我最开始直接把320个循环全部塞进训练,遇到问题很难定位到底出在数据预处理、网络定义还是训练设置。把样本量降到60到80个循环的小demo跑通后,再逐段放大,排查问题的效率会高很多。如果你也在做SOH估计相关的项目,希望这篇记录能帮你少走几步弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进