简介:一套基于RF随机森林算法的锂电池健康状态(SOH)估计Matlab实现,面向从事电池管理、新能源系统及故障诊断研究的学生与工程师,用于解决锂电池容量衰减预测与健康评估问题。资源包内含完整可运行的脚本主程序、已预处理的NASA B0005电池数据集(mat格式)以及SOH预测结果表格(xlsx),共3个文件,压缩包仅12KB,轻量且易部署。目前已有141人学习下载。代码在Matlab2023b环境下运行验证,结构清晰、可读性强;随机森林方法适合多维特征回归建模,读者可直接替换数据路径完成训练与测试,也可方便扩展至其他电池或健康指标预测场景。配合作者多年Matlab与深度学习算法仿真经验,整套资料适合快速上手锂电池SOH估计、复现实验或支撑毕业设计。
1. 锂电池SOH估计:为什么随机森林能把这个寿命回归问题变成工程问题
做锂电池SOH估计的人,最常遇到的不是模型不会跑,而是模型跑完没法用。SOH(State of Health)是当前最大容量与额定容量的比值,传统办法要完整充电、完整放电,放到实际BMS里根本等不起。随机森林回归算法能在中等样本量下,把充电电压、电流、温度这类时序曲线变成强特征,直接回归SOH,且不需要假设特定的退化方程。用Matlab做这件事的完整链路是:特征构造 → TreeBagger/fitrensemble 训练 → OOB 验证 → 按电池分组评估。这篇文章写给手里有电池充放电数据、想用随机森林实现SOH估计但不想只看论文的工程师。我会把能直接复现的最小代码、参数设置和最容易翻车的坑一起讲透。
2. 先做特征:从充放电曲线里抽出能描述老化的信息
2.1 容量不能直接测,随机森林被逼着看曲线形状
SOH 的物理定义很干净:当前最大可用容量除以额定容量。但工程里麻烦的是“当前最大可用容量”必须通过一次完整的恒流放电积分才能得到,放在实验室里可以,放到实际电池包上既不现实也伤寿命。所以我一般把 SOH 估计拆成另一个回归问题:用一段容易获取的充电电压、电流、温度曲线,去映射当前容量衰减状态。
随机森林在这里的优势不是它比神经网络聪明,而是它对特征工程的要求更理性。老化过程与特征的关系是非线性的,树模型天然能处理阈值和交互效应。举例来说,同一批电池,新电池的恒流充电可能在 1200 秒内从 3.0V 冲到 4.2V,老化到 80% SOH 之后,这段充电时间会明显缩短,电压平台也会发生细微移动。这些现象不必写成精确的解析公式,只要把能描述曲线形态的标量提取出来,随机森林就能自动切分阈值。
常见的做法是先从一次充电过程里提取基础统计特征:恒流充电时长、充电容量积分、电压区间跨度、平均电压、最高温度、最高电压平台出现的位置。不要小看这些简单特征,实际项目里我见过只用充电时长和平均温度两个特征,随机森林就能把 SOH 误差压到 3% 以内的案例。特征数量不是越多越好,但少了确实会让模型失去区分度。
这里要注意一个问题:很多人喜欢把电池包的电压、电流、温度整段序列直接喂给模型,这在随机森林里是不推荐的。随机森林不具备时序建模结构,它对每个特征只做独立的分裂判断,如果喂入 10000 个时间序列点,它会把计算量浪费在大量相关性极高的变量上,模型却不一定更好。关键是把曲线压缩成有物理含义的统计量。
2.2 增量容量曲线:把电压平台变成峰特征
电压平台区是锂电池老化最敏感的窗口。所谓增量容量(Incremental Capacity,IC)曲线,就是对充电电压做 dQ/dV 计算,得到的曲线会出现多个峰。每个峰对应一个电化学相变阶段,峰的位置和高度会随着锂损失、内阻增加而移动。相比直接用电压曲线观察平台的细微差异,IC 峰的变化更明显,也更适合作为随机森林的输入。
我用 Matlab 实现 IC 曲线时,通常直接在恒流充电段上做数值差分。要小心的是电压采样分辨率不够高时,differential 会把噪声放大,甚至出现 inf 和 NaN。更稳妥的做法是先对电压做平滑,再对 dQ/dV 做平滑,然后取峰特征。下面这段代码是特征构造阶段最核心的计算过程:
% 假设 runs 是循环老化实验的结构体数组 % 每个 runs(i) 有 time、voltage、current、capacity 字段 numRuns = numel(runs); icPeak = cell(numRuns, 1); % 存储每个循环的IC峰值 icLoc = cell(numRuns, 1); % 存储IC峰对应电压位置 for i = 1:numRuns t = runs(i).time; v = runs(i).voltage; I = runs(i).current; % 恒流充电,I 近似常数 dv = [0; diff(v)]; % 电压差分 dq = I .* [0; diff(t)]; % 容量差分:I * dt ic = dq ./ (dv + eps); % dQ/dV,加 eps 防止除零 % 把电压分辨率太低导致的尖刺置为 NaN ic(abs(dv) < 0.005) = NaN; % 移动平均平滑,窗口大小取决于采样率 win = 15; ic_s = movmean(ic, win, 'omitnan'); % 找峰:这里用 MinPeakProminence 排除毛刺伪峰 [pks, locs] = findpeaks(ic_s, 'MinPeakProminence', 0.02 * max(ic_s)); icPeak{i} = pks; icLoc{i} = v(locs); end这段代码的关键逻辑是:先还原 dQ/dV 曲线,再做平滑和找峰。参数win=15并不是固定值,它取决于充电数据的采样频率。如果采样率很高,电压每几个点变化很小,差分后的 dQ/dV 噪声会很明显,窗口可以加大到 30 甚至 50;如果采样率低,窗口过大反而会把真实的峰磨平。MinPeakProminence同样需要按实际曲线幅度调整,通常取最大峰值高度的 2% 到 5% 都能得到稳定结果。
我只取了每个循环 IC 曲线的前两个峰的峰高和位置作为特征,因为主峰和次峰能体现主要的相变过程。第三、第四个峰往往太微弱,受噪声影响大,放进模型里反而增加不稳定。对于磷酸铁锂电池,电压平台非常平坦,差分容易出现极端值,更要严格做好平滑和 NaN 清理,这也是磷酸铁锂和三元锂在特征工程上的明显差异。
2.3 特征矩阵长什么样:一份能喂给 TreeBagger 的表格
把曲线压缩成标量之后,整个 SOH 估计就变成一个标准表格回归问题。每一行是一个充电循环(或一个充电片段),每一列是一个特征,最后一列是对应的 SOH 标签。下面是我在项目中常用的特征集合表。
| 特征名 | 含义 | 来源 |
|---|---|---|
| chargeDur | 恒流充电段时长 | 充电结束时间减去开始时间 |
| vSpan | 充电起始电压到终止电压的跨度 | max(v) - min(v) |
| capInt | 充电容量积分 | sum(I * dt) |
| avgTemp | 充电平均温度 | mean(T) |
| icPeak1 | 第一个IC峰的峰高 | 2.2 节代码输出 |
| icLoc1 | 第一个IC峰的电压位置 | 2.2 节代码输出 |
| icPeak2 | 第二个IC峰的峰高 | 2.2 节代码输出 |
| icLoc2 | 第二个IC峰的电压位置 | 2.2 节代码输出 |
| dV_dt | 充电中段的电压变化率 | 取 50% 到 80% 电压段的斜率 |
目标变量 SOH 一般这样计算:每个循环先记录当前最大容量cap,然后用cap / ratedCapacity得到 SOH。注意 SOH 通常用小数表示(0.80 表示 80%),也可以用百分比,两者训练效果一致,只是最后的误差指标解释不同。
数据划分是整个流程里最容易被忽视的部分。随机森林建模时,如果按“所有循环里随机抽 80% 做训练、20% 做测试”,结果是典型的数据泄漏。同一个电池的第 5 次循环和第 50 次循环会被分别分到训练和测试组,模型实际上已经见过同一个电池退化轨迹的一部分,测试误差会异常低。正确做法是按电池编号分组留出。比如一共有 8 块电池老化数据,拿其中 6 块的全部循环做训练,剩下 2 块的全部循环做测试。这样能模拟“模型遇到没见过的电池”的真实场景。
下面这段代码示意了分组划分方式:
rng(42); battId = arrayfun(@(x) x.battId, runs); % 每个循环所属的电池编号 allBatts = unique(battId); nTestBatt = 2; testBatts = allBatts(randperm(numel(allBatts), nTestBatt)); isTest = ismember(battId, testBatts); X_train = X(~isTest, :); SOH_train = SOH(~isTest); X_test = X(isTest, :); SOH_test = SOH(isTest);如果后续要做模型调参,仍然要在训练组内部做交叉验证,测试组不能碰。我建议把测试组电池单独隔离,调参阶段只看 OOB 误差和训练集交叉验证误差,最后才用测试组做一次评估。这样得到的误差数字才敢写进报告。
3. 用Matlab把随机森林SOH估计跑起来:最小可运行代码
3.1 用 TreeBagger 训练回归森林
Matlab 里实现随机森林回归有两个常用入口:老牌的TreeBagger和更现代的fitrensemble。我做 SOH 估计更倾向用TreeBagger,因为它在回归模式下可以直接拿 OOB 误差曲线、特征重要性这些诊断信息,而且接口稳定,对于小样本数据更好控制。
下面这段代码是完整的最小训练流程,假设你已经通过上一节的特征工程得到了矩阵X和标签向量SOH,并且已经做了按电池分组划分。
% 数据划分结果来自上一节 % X_train: 训练特征矩阵 % SOH_train: 训练标签 % X_test: 测试特征矩阵 % SOH_test: 测试标签 ntrees = 300; rf = TreeBagger(ntrees, X_train, SOH_train, ... 'Method', 'regression', ... 'MinLeafSize', 5, ... 'NumPredictorsToSample', max(1, round(size(X_train, 2) / 3)), ... 'OOBPrediction', 'on', ... 'OOBVarImp', 'on', ... 'SampleWithReplacement', 'on');这里几个参数值得展开讲:
ntrees是决策树的数量。SOH 估计的样本量通常不大,几十到几百个循环,300 棵树是一个够用的起点。树太少会导致单棵树噪声影响大,多了虽然不会过拟合,但训练时间和模型文件体积都会上升。我一般先用 300 看 OOB 误差曲线,如果曲线在 150 棵树之后已经进入平台期,再降到 200 或 150,避免存一个又大又不讨好的模型文件。
MinLeafSize是叶子节点最少样本数。它直接影响每棵树的深度。SOH 样本少的时候,如果设成 1,每棵树会努力记住每个训练样本,虽然袋外误差通常不会太差,但模型的局部形状会非常毛躁。设成 5 到 10 可以让树更保守,测试误差往往更稳定。这是一个需要和训练误差一起权衡的参数。
NumPredictorsToSample是每次分裂时随机抽选的特征数量。回归的默认值是特征总数的三分之一。如果特征数量只有 6 到 8 个,三分之一会让每棵树的选择空间太小,这时可以改为全部特征,让每棵树都不遗漏重要变量。特征数量在 20 以上时,三分之一是比较稳妥的起点。
OOBPrediction和OOBVarImp打开后,会自动保存每个样本的袋外预测和变量重要性,方便训练结束后做诊断,建议单机训练时始终打开。
3.2 预测与误差评估:不要只看 R2
训练完成之后,用测试组数据做预测的代码非常短:
[SOH_pred, ~] = predict(rf, X_test); % 计算评估指标 rmse = sqrt(mean((SOH_test - SOH_pred).^2)); mae = mean(abs(SOH_test - SOH_pred)); ssRes = sum((SOH_test - SOH_pred).^2); ssTot = sum((SOH_test - mean(SOH_test)).^2); r2 = 1 - ssRes / ssTot; fprintf('RMSE = %.4f, MAE = %.4f, R2 = %.4f\n', rmse, mae, r2);这里必须提醒一个容易误解的点:SOH 如果以小数形式存储,RMSE 是 0.02 意味着平均误差 2%,看起来很好;但如果数据里 SOH 的变化范围只有 0.8 到 1.0,R2 可能被一个很小的绝对误差放大或缩小。所以我通常同时报告 RMSE 和 MAE,再画一张实测 SOH 和预测 SOH 的散点图:
figure; plot(SOH_test, SOH_pred, 'o'); hold on; plot([min(SOH_test), max(SOH_test)], ... [min(SOH_test), max(SOH_test)], 'r--', 'LineWidth', 1.5); xlabel('实测 SOH'); ylabel('预测 SOH'); axis equal; grid on;散点图上如果点围绕对角线均匀分布,说明模型没有明显的系统性偏差。如果点整体偏到对角线一侧,则说明模型在某些 SOH 区间有系统性低估或高估,需要回头检查特征是否缺少了某个老化敏感量。比如当电池进入中后老化期,内阻上升带来的端电压变化和容量衰减不同步,如果特征里没有温度或内阻相关量,模型就会在低 SOH 区间出现偏差。
3.3 特征重要性:随机森林自带的诊断工具
TreeBagger在打开OOBVarImp后,会计算每个特征的扰动重要性(Permuted Predictor Importance)。它的含义是:把某个特征列打乱之后重新预测,袋外误差增加多少。误差增加越多,说明模型对这个特征越依赖。
imp = rf.OOBPermutedVarDeltaError; [~, idx] = sort(imp, 'descend'); figure; bar(imp(idx)); set(gca, 'XTick', 1:numel(imp), ... 'XTickLabel', featNames(idx), ... 'XTickLabelRotation', 45); ylabel('OOB 误差增量');看这个图的时候要冷静。随机森林的重要性排序并不能直接当作因果解释:如果两个特征高度相关,重要性会在它们之间被分摊,单一特征的重要性反而显得低。比如容量积分和充电时长高度相关,二者重要性可能各占一半,但无法说明哪一个物理上更重要。我在实际项目里只用它做粗筛,再看重要性低的特征是否真的是噪声来源;如果某个特征同时重要性低、物理含义也弱,就会把它从特征表中拿掉,重新训练一次看是否影响精度。这比单纯追求所有特征都进模型更实用。
4. 参数调优与避坑:随机森林SOH估计最容易翻车的五个细节
4.1 三个必调参数:NumTrees、MinLeafSize、NumPredictorsToSample
很多第一次用随机森林做锂电池SOH估计的工程师,第一反应是按默认参数训练,发现测试误差不错,就以为大功告成。实际上默认参数只是给通用分类问题设计的,回归和样本量都不匹配时,至少要把下面三个参数过一遍。
第一个是NumTrees。树的数量不是越大越好,也不是越小越快,而是要看 OOB 误差是否收敛。训练完模型后直接画一条oobError(rf)曲线,就能看到误差随树数量下降的过程。如果曲线在 100 棵树以后已经水平,那 300 棵树就是多余的;如果曲线还在明显下降,说明计算资源可以加大到 500 或 1000。曲线进入平台期后,误差不会再显著改善,继续加树只会增加模型的体积和预测耗时。
第二个是MinLeafSize。这个参数控制树的复杂度。SOH 训练集可能只有 300 到 500 个样本,如果叶子最小样本数设成 1,树会非常深,训练集误差接近零,但测试集误差可能反而高。常见做法是从 1 到 20 做粗网格搜索,用 OOB 误差选择最佳值。我在自己的项目中,样本量在 400 左右时,MinLeafSize=5通常是最稳的;样本量少于 200 时,MinLeafSize=3更合适;样本量超过 1000 时,MinLeafSize=10可以让模型更平滑。
第三个是NumPredictorsToSample。回归默认取特征总数的三分之一。如果特征数量只有 6 个,三分之一只有 2 个,每棵树每次分裂可选变量太少,树的多样性不足,集成效果会打折扣。我建议当特征数小于 10 时直接设置为'all',让每棵树看到全部特征;特征数在 20 以上时从三分之一开始调。这个参数也可以在网格搜索里和MinLeafSize一起优化,但在小样本场景下,它的敏感性通常低于MinLeafSize。
下面是一段用 OOB 误差快速选参的代码:
minLeafCandidates = [3, 5, 10, 20]; oobErr = zeros(size(minLeafCandidates)); for i = 1:numel(minLeafCandidates) rf_tmp = TreeBagger(200, X_train, SOH_train, ... 'Method', 'regression', ... 'MinLeafSize', minLeafCandidates(i), ... 'NumPredictorsToSample', size(X_train, 2), ... 'OOBPrediction', 'on'); oobErr(i) = oobError(rf_tmp, 'last'); % 取最终树数下的OOB误差 end % 选择 OOB 误差最小的 MinLeafSize [~, bestIdx] = min(oobErr);这段代码中'last'表示只取最后一棵树时的 OOB 误差,作为该参数组合的代表。如果时间充裕,可以把网格扩大到两个参数组合,但不要让交叉验证和 OOB 一起混用,OOB 已经足够做快速筛选。
4.2 数据泄漏:随机划分会让你的测试误差“好看”到失真
这是我在锂电池SOH估计里见过最多、最隐蔽的坑。现象很一致:训练好的随机森林拿到测试集上一测,RMSE 只有 0.5%,R2 高达 0.99,发到群里大家都说好,但一旦换一批电池做盲测,误差立刻涨了五倍。
原因几乎都是数据划分方式错了。SOH 估计面对的老化数据不是独立同分布样本,同一个电池的前期循环和后期循环高度相关,容量衰减轨迹是连续的。如果按循环随机划分训练集和测试集,模型在训练里看到同一块电池的第 10 次循环,测试里遇到同一块电池的第 11 次循环,它当然能预测得很准,因为退化趋势已经被它学过了。这等于考试前已经见过标准答案,测试误差没有一点参考价值。
解决方式很明确:划分必须基于电池编号,而不是基于循环编号。每组都是完整电池的所有循环,不能让同一个电池的样本同时出现在训练集和测试集。训练集内部做交叉验证时也要同样按电池分组,不能直接cvpartition随机抽。如果电池总数少,比如只有 6 块,测试组取 1 到 2 块即可,剩余做训练。如果电池总数多,可以按 7:3 或 8:2 的比例留出。这不是为了测试集更大,而是为了保证模拟场景是“模型对陌生电池做预测”。
4.3 外推问题:随机森林只能插值,不能外推
另一个常见翻车是训练数据只覆盖了 SOH 区间 0.85 到 1.0,但实际现场希望预测到 0.7 甚至更低。随机森林的预测边界是训练数据范围:当某个特征超出训练范围时,预测值会被该特征的叶子节点均值限制住,不会继续下降。表现在结果上就是预测 SOH 在测试集低段时整体偏高,误差集中在低 SOH 区间。
这不是随机森林本身的 bug,而是所有基于树的模型的共性。树的分裂来自训练数据的分布,它没有能力推断“没见过”的曲线趋势。解决思路有三种。
第一种,尽量让训练数据的 SOH 范围覆盖目标范围。如果目标 BMS 需要监控到 0.75,那么训练数据也要包含足够的低 SOH 循环。第二种,如果实在没有低 SOH 数据,可以结合物理模型做趋势校正,比如用容量衰减的对数趋势外推出一个下限,再和随机森林的输出做加权融合。第三种,对模型做在线更新,用现场新采集到的低 SOH 数据定期微调或重新训练。无论哪种方式,都不要宣称随机森林能外推到训练区间之外,这会成为工程验收时最大的隐患。
4.4 常见问题排查清单:现象、原因、解决
下面这几条是我在实际调试中反复遇到过的,按“现象 → 原因 → 解决”的方式列出来。
特征矩阵里有 NaN 或 Inf,TreeBagger直接报错或者训练出来的模型预测全部是 NaN。原因通常是增量容量曲线在电压平台处产生了0/0或inf,虽然代码里加了eps和NaN清理,但某个特征列仍可能残留异常。解决方式是在训练前检查每一列:
assert(sum(isnan(X(:))) == 0, '特征矩阵存在 NaN,请检查 IC 曲线清洗步骤'); assert(sum(isinf(X(:))) == 0, '特征矩阵存在 Inf,请检查差分计算');模型预测出的 SOH 有超过 1 或小于 0 的值。原因是回归模型没有上下界约束,树均值在边界外仍然可能外推。解决办法是在预测后进行饱和处理:SOH_pred = min(1, max(0, SOH_pred));。虽然粗暴,但它保证输出符合物理约束,不会在展示时出现“110% SOH”的笑话。
训练数据量只有几十个循环,随机森林仍然可以跑,但训练集误差几乎为零,测试集误差却高得离谱。原因是树太多、叶子太深,模型把每个样本的独有噪声都记住了。遇到这种情况,优先调大MinLeafSize到 10 或 20,并减少树的数量到 100 以内,同时用 OOB 误差曲线判断是否过拟合。
还有一类问题是内存或时间爆掉。3000 棵树在几十个特征、几千个样本上可能还能忍,但如果把原始时间序列也作为特征喂进模型,特征维度达到上万,每棵树的分裂速度会急剧下降。解决方式是对特征矩阵做一次相关性粗筛,删除相关系数大于 0.95 的冗余特征,再把树数降到 200 以内。
5. 用OOB误差替测试集做最后验证:一个少吃哑巴亏的部署习惯
最后一章我想把重点放在一个很容易被忽略的动作上:在正式训练完模型之后,先不要急着跑测试集,而是先看袋外误差。TreeBagger 打开OOBPrediction后,每个训练样本在“完全没有被包含在那一棵树里”的条件下,都会有一个预测结果。这些 OOB 预测集合起来,实际上等于模型在“陌生化”训练样本上的一种自验证。
我刚做锂电池SOH估计时,习惯直接随机划分训练集和测试集,看到 RMSE 不到 1%,信心满满地把模型发给测试工程师。结果到了新电池上误差翻了一倍,当时我还觉得是“电池批次差异”,后来才发现是数据泄漏。从那以后,我要求自己每次训练完先看 OOB 误差曲线,再和训练集误差对比。如果训练集误差远低于 OOB 误差,说明模型在记忆样本而不是学习趋势,这时测试集误差再高也正常,因为测试样本本来就和训练样本分布不同。
一个实用的收尾技巧是绘制 OOB 误差曲线和训练集误差曲线,看两者是否收敛。如果 OOB 误差在树数增加后平稳下降,而训练集误差趋近于零,说明模型容量足够,但泛化能力只能靠 OOB 来监督。
figure; plot(oobError(rf), 'LineWidth', 1.5); hold on; plot(1:ntrees, sqrt(mean((SOH_train - predict(rf, X_train)).^2)) * ones(1, ntrees), 'r--'); legend('OOB Error', 'Training RMSE'); xlabel('Number of Trees'); ylabel('Error');如果 OOB 误差曲线与训练集误差曲线之间的间隙持续很大,优先调大MinLeafSize或减少特征数,而不是增加树数。这个习惯帮我少吃了很多“测试集选得太偏心”的哑巴亏。
还有一个我个人的习惯:在把模型部署为.mat文件之前,会再跑一遍特征重要性排序,把每棵树的NumPredictorsToSample与最终重要性排名对齐检查。如果某个物理意义很强的特征在重要性排名里垫底,我会先怀疑特征构造错了,而不是怀疑模型。保持这个怀疑习惯之后,我做特征工程的质量提升了一个台阶。
希望这份从特征到参数再到避坑的完整路线,能帮你在锂电池SOH估计上少走一段弯路。
本文还有配套的精品资源,点击获取