简介:这是一套基于深度学习方法评估锂电池健康状态(SOH)的可直接运行的Python项目,面向计算机、数学、电子信息等专业学生,适用于课程设计、期末大作业或毕业设计,也适合作为新能源与AI交叉方向的学习参考。项目以NASA锂电池容量衰退数据集为对象,分析加入电池运行可监测数据对SOH评估的影响,包含1D-CNN、BiLSTM及1D-CNN-BiLSTM-Attention等模型源码,配套项目说明、依赖清单和预处理后的数据集,便于复现与二次开发。资源包共15个文件,核心为Python脚本、CSV数据文件、XML工程配置及Markdown说明文档,整体大小约1.17MB,结构精简紧凑。当前已有383人学习浏览,适合具备一定Python和深度学习基础、希望快速上手电池健康状态预测任务的开发者参考借鉴。
1. 深度学习评估锂电池SOH:不拆电池、不停机,用循环数据回归健康状态
锂电池SOH(健康状态)的评估,常规做法是测内阻、做完整容量标定,这两条路都要停机、要设备。深度学习这条路不一样:它直接从充放电循环的历史数据(电压、电流、温度、时间)回归当前容量,核心结论是——容量衰退的规律藏在循环数据的时序模式里,不需要额外的物理模型。这套资源用python源码把NASA的锂电池容量衰退数据集(B0005、B0006、B0007等18650电池的公开老化数据)喂给LSTM时序模型,训练完成后输入最近一段循环的数据就能预测下一个循环的容量,SOH估计误差基本能压到3%以内。适合做电池管理算法的人、拿时序建模当课题的在校生、想验证深度学习方法在工业数据上真实效果的从业者。它有源码有说明,但需要你懂一点时序数据的基本处理,不是开箱即用的黑匣子。
2. 先看懂NASA数据集:从.mat文件到可训练的容量序列
2.1 数据集的字段结构与容量提取:B0005到B0018怎么读
NASA的锂电池老化数据集是公开的,记录了几块18650电池在室温下的完整循环老化过程。每块电池一个.mat文件,文件里是一个同名结构体。训练SOH模型的第一步是从这个结构体里把放电容量抽出来,这是整套流程的地基。
import scipy.io as sio import numpy as np # 读取 B0005 电池的原始 .mat 文件 mat_data = sio.loadmat('B0005.mat') battery = mat_data['B0005'] # 这是一个 numpy 结构化对象 cycle = battery['cycle'][0, 0] # cycle 是 1×N 的结构数组 # 遍历每个循环,挑 type 为 discharge 的放电记录 discharge_capacity = [] for i in range(cycle.shape[1]): record = cycle[0, i] # 每个循环记录里都有 type 字段,区分 charge / discharge / impedance if str(record['type'][0]) == "discharge": # 放电段的数据里有 Capacity 字段,单位是 Ah capacity = float(record['data'].Capacity[0, 0]) discharge_capacity.append(capacity) # 转成 numpy 数组,按循环顺序排列 capacity_array = np.array(discharge_capacity) print("放电循环数:", len(capacity_array)) print("首个循环容量: %.3f Ah" % capacity_array[0]) print("末个循环容量: %.3f Ah" % capacity_array[-1])这段代码的关键在字段路径的层级:battery['cycle']拿到循环数组,每个循环元素里有type和data,放电记录的容量藏在data.Capacity里。顺手把放电容量按循环顺序存成数组,就得到了最基础的SOH标签序列。打印首末容量能快速确认数据有没有读对——正常一份完整的电池老化数据,容量应该从2Ah附近一路掉到1.4Ah以下。
参数上注意loadmat返回的是字典结构,字段名大小写敏感;如果你拿到的.mat文件不是B0005而是其他编号,先mat_data.keys()看一眼顶层变量名,别直接写死。我一般会把抽容量这段写成单独的函数,后续换电池文件直接复用。
2.2 滑动窗口构造样本:时序模型输入的常规做法
有了容量序列,还不能直接喂给模型。单点容量值只能反映当前状态,预测SOH需要看最近一段循环的退化趋势,常见做法是滑动窗口——用过去N个循环的容量序列预测下一个循环的容量。
def build_windowed_dataset(seq, window_size=30, step=1): """ 把容量序列切成窗口样本。 seq: 容量序列,shape = (T,) window_size: 用过去多少个循环做特征 step: 窗口滑动步长,通常取 1 """ X, y = [], [] for i in range(0, len(seq) - window_size, step): # 窗口内的容量值作为输入特征 X.append(seq[i:i + window_size]) # 标签是窗口结束后的下一个循环的容量 y.append(seq[i + window_size]) return np.array(X), np.array(y) # 以 30 个循环为例构造样本 X, y = build_windowed_dataset(capacity_array, window_size=30) print("X shape:", X.shape) # (样本数, 30, 1) print("y shape:", y.shape) # (样本数,)窗口大小是最需要调的参数。窗口太短,模型看不到长期衰退趋势,预测容易跟着局部波动走;窗口太长,样本数量变少,而且早期循环的数据会被浪费。对NASA这批数据,循环总量在150~200次之间,30左右的窗口比较平衡。step控制相邻样本的重叠程度,取1会让样本数量最大,训练更稳;如果想减少样本间的相关性,可以加大到5。
强调一点:这里构造出来的X是二维的(样本数, 窗口长度),后面喂给LSTM时需要reshape成(样本数, 窗口长度, 特征数),因为LSTM要求输入是三维的,最后一维是每个时间步的特征数量。单用容量序列时特征数就是1,后面加特征的时候会扩。
3. 特征工程与模型选型:LSTM凭什么能估计SOH
3.1 原始特征与派生特征:等压降时间、充电时长怎么当输入
只拿容量序列做单特征输入,模型能跑,但精度天花板很低。容量序列本身是衰退趋势的直接体现,它已经包含大部分信息;但如果想让模型在容量标签基础上多学一点物理规律,就得从放电记录里抽派生特征。业界最常用的两个特征是等压降时间和放电时长。
等压降时间指的是电池在同一个电压区间(比如从4.2V降到3.6V)内放电所消耗的时间。电池老化后内阻增大,同样压降区间耗时缩短,这个特征对SOH非常敏感,而且不需要额外设备,从放电曲线就能算。
# 从放电记录里提取等压降时间:电压从 4.2V 降到 3.6V 的耗时 def extract_equal_voltage_drop_time(record): # 放电段数据里的电压和时间序列 v = record['Voltage_measured'].flatten() t = record['Time_measured'].flatten() # 找电压落在 3.6V~4.2V 区间的索引 mask = (v >= 3.6) & (v <= 4.2) if mask.sum() == 0: return 0.0 # 该区间的首尾时间差即为等压降时间 idx = np.where(mask)[0] return t[idx[-1]] - t[idx[0]]这个特征的提取逻辑是:电压穿过的区间固定,那么耗时变化就主要由内阻和活性物质损失决定。放电时长同理——除了恒压段,恒流段的放电时间会随老化明显缩短。把这类派生特征拼到容量序列后面,构成多特征输入,维度变成(样本数, 窗口长度, 特征数),LSTM在训练时会自动学习这些特征和SOH之间的映射关系。
特征不是越多越好。NASA数据集里温度数据容易有噪声,原始电压电流曲线采样点密集但维度爆炸,我实际用下来的经验是:容量序列加等压降时间加放电时长,三个特征已经能拿到不错的精度,再加特征收益很小,反而增加过拟合风险。
3.2 模型结构对比:LSTM、CNN与MLP的选型逻辑
SOH估计本质是一个时序回归问题,输入是一段历史序列,输出是一个连续值。选模型时我对比过三种结构,结论很明确:LSTM是这个场景下性价比最高的选择。
| 模型 | 对时序的处理方式 | 参数量 | 在NASA数据上的效果 | 适用场景 |
|---|---|---|---|---|
| MLP | 把窗口展平成向量,无视顺序 | 小 | 一般,RMSE偏高 | 快速验证、特征已经高度抽象时 |
| CNN(一维卷积) | 卷积核提取局部时序模式 | 中 | 较好,但长程依赖弱 | 特征局部性强、窗口较短时 |
| LSTM | 门控机制记住长期依赖 | 中大 | 最稳,RMSE最低 | 容量衰退这种强趋势长序列 |
MLP把30个循环的序列展平后直接全连接,顺序信息完全丢失,容量序列的先后关系对SOH判断很重要,所以MLP精度最差。CNN用一维卷积核扫描窗口,能提取局部趋势但受限于感受野,窗口拉长后对早期信息的利用不足。LSTM的遗忘门和记忆单元天然适合捕捉容量从2Ah滑向1.4Ah这种长程退化过程,这就是为什么大多数锂电池SOH研究的基线模型都选LSTM。
模型选型还有一个容易被忽略的点:SOH是回归任务不是分类任务。不少人上来就用分类思路做,把容量离散化后预测区间,这是弯路。回归任务的输出层只需要一个线性神经元(无激活函数),损失函数用MSE或MAE,评估看绝对误差,不用看准确率。
4. 模型训练与SOH评估:从训练脚本到容量估计的完整链路
4.1 数据划分与归一化:按电池拆分还是按循环拆分
数据划分是SOH估计里最容易出错的一步。很多新手按循环随机拆分,同一块电池的前30个循环在训练集、后30个在测试集,结果测试集指标漂亮得吓人,一换到别的电池上就崩。原因在于同一个电池的相邻循环高度相关,随机拆分把这种相关性泄漏给了训练集。
正确的做法是按电池拆分——用B0005、B0006训练,用B0007、B0018验证,模拟"遇到没见过的新电池"的真实场景。
from sklearn.preprocessing import MinMaxScaler from sklearn.model_selection import train_test_split # 假设 X_all/y_all 是多块电池拼接后的总样本 # groups 数组记录每个样本来自哪块电池,0 表示 B0005,1 表示 B0006 # 这里示意性地把 B0005/B0006 划入训练,B0007/B0018 划入测试 # 数据归一化:先 reshape 成二维,fit 后再还原三维 scaler = MinMaxScaler() X_all_flat = X_all.reshape(-1, X_all.shape[-1]) # (样本数×窗口长, 特征数) X_all_norm = scaler.fit_transform(X_all_flat).reshape(X_all.shape) y_all_norm = y_all / y_all.max() # 标签也做归一化,方便后续结果解读 # 按电池划分配比:这里用布尔索引选择对应电池的样本 train_mask = (groups == 0) | (groups == 1) test_mask = (groups == 2) | (groups == 3) X_train, X_test = X_all_norm[train_mask], X_all_norm[test_mask] y_train, y_test = y_all_norm[train_mask], y_all_norm[test_mask] print("训练集样本数:", X_train.shape[0]) print("测试集样本数:", X_test.shape[0])这段代码里最需要记住的是MinMaxScaler只能在全量数据上fit一次还是分开fit的问题。严格来说,在按电池拆分的场景下,fit_transform用了全部电池的统计量,这其实引入了轻微泄漏。我一般会先拆分再归一化:
# 更规范的顺序:先按电池拆分,再单独对训练集 fit X_train_orig, X_test_orig = X_all[train_mask], X_all[test_mask] scaler = MinMaxScaler() X_train_flat = X_train_orig.reshape(-1, X_train_orig.shape[-1]) X_train_norm = scaler.fit_transform(X_train_flat).reshape(X_train_orig.shape) X_test_flat = X_test_orig.reshape(-1, X_test_orig.shape[-1]) X_test_norm = scaler.transform(X_test_flat).reshape(X_test_orig.shape)transform用的是训练集上算好的最大最小值,测试集完全没见过,这才符合真实推理时的数据流。标签归一化同理,如果训练时把y压到0~1区间,预测出的结果要乘以最大值才能还原成真实容量。
4.2 训练与评估:损失函数、评估指标与曲线解读
LSTM模型的搭建用Keras最省事。一个标准的双层LSTM加Dropout回归网络,在NASA数据上跑100个epoch就能收敛到不错的水平。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dropout, Dense model = Sequential([ # 第一层 LSTM,return_sequences=True 表示输出完整序列给下一层 LSTM(64, input_shape=(X_train.shape[1], X_train.shape[2]), return_sequences=True), Dropout(0.2), # 第二层 LSTM,只返回最后时刻的输出 LSTM(32, return_sequences=False), Dropout(0.1), # 回归头,单神经元线性输出 Dense(1) ]) model.compile(optimizer='adam', loss='mse', metrics=['mae']) history = model.fit( X_train_norm, y_train, epochs=100, batch_size=32, validation_data=(X_test_norm, y_test), verbose=1 )几个参数说明:第一层LSTM的64是隐藏单元数,代表网络对时序模式的记忆容量,太小拟合不足,太大容易过拟合,64到128之间比较合适。Dropout(0.2)放在LSTM输出后面,随机丢弃20%的神经元输出,抑制过拟合。loss='mse'是均方误差,回归任务的标准选择;mae作为辅助指标,方便直观理解误差量级。
训练完成后,评估指标要看三个:MAE、RMSE、R²。把预测值和真实容量画在同一个坐标系里,是最直观的验证方式。
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score y_pred = model.predict(X_test_norm).flatten() # 还原到真实容量区间 y_test_real = y_test * 2.0 # 假设额定容量 2Ah,标签归一化时除以的是2.0 y_pred_real = y_pred * 2.0 mae = mean_absolute_error(y_test_real, y_pred_real) rmse = mean_squared_error(y_test_real, y_pred_real, squared=False) r2 = r2_score(y_test_real, y_pred_real) print("MAE=%.3f Ah" % mae) print("RMSE=%.3f Ah" % rmse) print("R2=%.3f" % r2) # SOH 百分比估计:当前容量 / 额定容量 soh_pred = y_pred_real / 2.0 * 100 # 单位 %MAE在0.03~0.05Ah之间属于正常水平,换算成SOH误差大约是1.5%~2.5%。RMSE比MAE略高是正常的,它惩罚大误差。R²在0.95以上说明预测曲线与真实曲线的趋势高度吻合,但这只能说明在测试集电池上表现好,不能代表所有电池。
4.3 保存模型与推理:对新电池的循环数据做容量预测
训练好的模型要落成可用的推理链路。Keras提供了简洁的保存和加载接口,推理时输入最近30个循环的容量序列,输出就是下一循环的预测容量。
# 保存完整模型(结构+权重) model.save('soh_lstm_model.h5') # 新项目里加载模型做推理 from tensorflow.keras.models import load_model loaded_model = load_model('soh_lstm_model.h5') # 假设 new_capacity_seq 是从新电池上抽出的容量序列 # 取最近 30 个循环作为窗口,并 reshape 成模型要求的输入形状 window = new_capacity_seq[-30:].reshape(1, 30, 1) # (批次, 窗口, 特征) pred_norm = loaded_model.predict(window).flatten()[0] # 还原成真实容量和 SOH pred_capacity = pred_norm * 2.0 pred_soh = pred_capacity / 2.0 * 100 print("预测下一循环容量: %.3f Ah" % pred_capacity) print("预测 SOH: %.1f%%" % pred_soh)推理时最容易翻车的点是输入形状。训练时输入是(样本数, 窗口长度, 特征数),推理时单条样本也要保持同样的维度,reshape(1, 30, 1)里的第一个1是批次维,不能丢。另外记得推理前对输入数据做和训练时完全相同的预处理——归一化用的最大值最小值必须和训练时的一致,我通常会把这些统计量存成另一个npy文件,加载模型时一起读进来,防止不同环境跑出不同的归一化结果。
这个推理链路跑通之后,整个SOH估计闭环就完整了。新电池只需要持续记录充放电循环数据,每积累30个循环就能预测下一阶段的容量,不需要停机做容量标定。
5. 避坑笔记:五条踩出来的SOH估计经验
5.1 全量归一化导致数据泄漏,测试集指标虚高
现象:训练集和测试集的MAE都低于0.02Ah,测试曲线几乎贴着真实曲线走,但换个电池数据集来测,误差直接翻倍。
原因:代码里先对全量数据做了MinMaxScaler().fit_transform(),再划分训练测试集。测试集的统计信息(最大值、最小值)在训练阶段就被模型间接看到了,相当于考试前偷看了答案。
解决:严格按"先拆分、再fit"的顺序。归一化器只在训练集上fit,测试集和推理数据都用训练集的统计量做transform。血的教训是:凡是涉及归一化、标准化、PCA这类带统计量的预处理,一律不允许在合并数据上操作。
5.2 按循环随机划分导致数据串扰,验证集失去意义
现象:模型在验证集上R²接近0.99,但换电池后降到0.7左右。
原因:同一块电池相邻循环的容量值高度自相关,随机划分时,测试样本的"邻居"大量出现在训练集里,模型学到的其实是"抄袭邻居容量"而不是真正的衰退规律。
解决:划分数据必须按电池分组。哪怕牺牲一些训练样本量,也要保证同一块电池的循环数据要么全在训练集,要么全在测试集。交叉验证用GroupKFold按电池编号分组,不要用普通KFold。
5.3 容量回升现象被模型当成噪声,局部预测偏差大
现象:预测曲线整体贴合,但在某些循环段出现明显的持续偏差,误差集中在电池老化的中后期。
原因:锂电池在部分循环后会出现临时性容量回升,这是电化学状态重新平衡导致的正常现象。LSTM如果窗口太短,会把这种回升误判为趋势反转,预测值跟着偏离。
解决:适当加长窗口到30~50个循环,让模型看到足够长的历史,区分"短暂回升"和"真实恢复"。另一个办法是在特征里加入循环序号本身,让模型知道当前处于老化的哪个阶段,对这个阶段的波动幅度有先验判断。
5.4 .mat文件字段名不一致导致读取出错
现象:跑B0005正常,换成B0018后loadmat报KeyError或者Capacity取出来是空数组。
原因:NASA数据集不同电池的.mat文件虽然整体结构相似,但某些字段在部分文件里可能为空或者命名有细微差别。直接写死字段名就容易踩空。
解决:写读取函数时先打印record['data']._fieldnames看一眼实际字段列表,用hasattr或if field in record['data'].dtype.names做防御性判断,遇到空数据跳过或补0,而不是直接让程序崩掉。
5.5 LSTM输入维度不匹配,批量训练时直接报错
现象:模型fit时报Input 0 of layer "lstm" is incompatible with the layer,或者推理时predict报shape错误。
原因:LSTM要求输入是三维(样本数, 时间步, 特征数),很多人读取数据后直接塞二维数组,或者构造单条样本时漏掉批次维。
解决:在传给模型之前,统一加上reshape(-1, window_size, n_features)的检查;推理时单条样本用reshape(1, window_size, n_features)。我习惯在数据准备函数末尾打印一次X.shape,看到三维才往下走。
6. 落地技巧:用GroupKFold验证泛化能力,把模型迁移到同型电池
模型训练好只是第一步,真正判断这套SOH估计方案能不能用,要看它面对"从没见过的新电池"时的表现。交叉验证在普通回归任务里是常规操作,但SOH估计场景下必须按电池分组做,否则折与折之间循环数据互相串扰,验证结果就是自欺欺人。
from sklearn.model_selection import GroupKFold # X_all/y_all: 所有电池的样本和标签 # groups: 每个样本所属电池的组号,如 [0,0,0,...,1,1,1,...] groups = [] for battery_idx in range(4): groups.extend([battery_idx] * samples_per_battery[battery_idx]) groups = np.array(groups) gkf = GroupKFold(n_splits=4) fold_scores = [] for fold, (train_idx, val_idx) in enumerate(gkf.split(X_all, y_all, groups=groups)): # 每个 fold 里训练集和验证集的电池完全不重叠 X_tr, X_val = X_all[train_idx], X_all[val_idx] y_tr, y_val = y_all[train_idx], y_all[val_idx] # 复用同一套归一化和 LSTM 结构 scaler = MinMaxScaler() X_tr_norm = scaler.fit_transform(X_tr.reshape(-1, X_tr.shape[-1])).reshape(X_tr.shape) X_val_norm = scaler.transform(X_val.reshape(-1, X_val.shape[-1])).reshape(X_val.shape) model = Sequential([ LSTM(64, input_shape=(X_tr.shape[1], X_tr.shape[2]), return_sequences=True), Dropout(0.2), LSTM(32, return_sequences=False), Dense(1) ]) model.compile(optimizer='adam', loss='mse', metrics=['mae']) model.fit(X_tr_norm, y_tr, epochs=60, batch_size=32, verbose=0) y_val_pred = model.predict(X_val_norm).flatten() mae = mean_absolute_error(y_val, y_val_pred) fold_scores.append(mae) print("Fold %d MAE: %.3f Ah" % (fold, mae)) print("平均 MAE: %.3f Ah" % np.mean(fold_scores))四个fold的平均MAE如果稳定在0.05Ah以下,说明模型有跨电池泛化能力,这时再去做迁移才有意义。迁移到同型号的新电池时,我一般不会直接拿原始模型推理,而是把新电池的前几个循环数据混入训练集做微调——冻结LSTM层权重,只训练最后的Dense层,大约20个epoch就能把新电池的容量量级校准过来,比从头训练省一半以上的时间。
最直观的验证方法还是画散点图:横轴是真实容量,纵轴是预测容量,点越贴近对角线说明预测越准。我把这个图当成SOH估计的体检报告,只要大部分点落在±0.05Ah的误差带内,就敢把结果交给项目里用。从那以后,我每次做SOH评估都会强制走一遍按电池拆分的GroupKFold交叉验证,确认误差稳定在可接受范围内才出结果——深度学习模型在这类工业数据上太容易"看起来准"了,跨电池验证是最后的防线。希望帮到你。
本文还有配套的精品资源,点击获取