ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Python实现电池SOH与RUL预测:从特征工程到模型调优

Python实现电池SOH与RUL预测:从特征工程到模型调优 简介本资源为2023年创新组赛题《基于数据驱动的动力电池健康状态评估与剩余寿命预测》的完整Python实现方案面向计算机、人工智能、自动化、电子信息等专业的在校学生、教师及企业技术人员可用于毕业设计、课程设计、竞赛备赛或项目初期立项演示。压缩包共1899个文件约245.35MB其中1094个pkl与685个csv文件构成电池特征与训练数据集43个py脚本承载数据预处理、特征提取、健康状态评估与剩余寿命预测等核心算法另有62张png结果图、6个xml配置及README说明文档目录结构清晰便于按模块检索与复现。资源代码均经本地测试运行成功答辩评审平均分达96分已有313人学习下载。读者可据此掌握从电池数据清洗、特征工程到模型训练与寿命预测的完整链路并可在现有代码基础上修改扩展实现其他功能。1. 从一份赛题说起Python 怎么把电池寿命算明白动力电池的健康状态SOH和剩余寿命RUL预测是新能源行业里少数几个「数据比模型更值钱」的方向。2023 年创新组这道赛题本质是给一批电池充放电循环数据让你用 Python 把两件事算清楚当前这块电池还剩多少健康度以及它还能撑多少次循环。听起来像回归任务但真正动手就会发现数据预处理、特征构造、模型选型每一步都能让结果差出十几个百分点。适合谁做做 BMS 算法验证的、搞储能运维数据分析的、以及想拿一个完整数据驱动项目练手的 Python 开发者。这篇笔记不聊赛题评分只讲怎么从原始循环数据走到可复现的 SOH/RUL 预测管线参数怎么设、坑在哪、哪些步骤可以抄。2. 数据驱动路线的选型为什么不用等效电路模型2.1 等效电路模型和数据驱动模型的边界在哪做电池寿命预测传统路线是建等效电路模型ECM用卡尔曼滤波估 SOC再外推 SOH。这条路在实验室恒流工况下精度不错但赛题给的是实车或加速老化台架的循环数据工况切换频繁、温度波动大ECM 的参数辨识会变得非常脆弱。数据驱动路线不依赖电化学机理直接从电压、电流、温度、容量这些可测信号里学退化模式对工况变化的鲁棒性更好。但数据驱动不是万能药。它的前提是训练集覆盖了足够的退化阶段如果测试电池的衰减机制和训练集不一致比如一种是容量跳水一种是内阻缓增模型会直接翻车。所以选型时要先看数据如果循环数少于 200、每圈只有端电压和容量那特征工程的空间很窄用简单回归加滑动窗口就够了如果有完整的充放电曲线、温度场、内阻谱那上深度时序模型才有意义。我一般会先画三条曲线再决定路线容量随循环的衰减曲线、等压升时间随循环的变化、以及温升峰值随循环的漂移。这三条线如果单调性明显说明退化信号干净数据驱动可行如果噪声大到看不出趋势先做滤波和分段别急着上模型。2.2 赛题数据常见的字段结构和读取方式这类赛题的数据通常分两类文件一类是循环汇总表每行一个循环字段包括循环序号、放电容量、内阻、最高温度、平均温度、恒流充电时间、恒压充电时间等另一类是原始时序文件每个循环一个 CSV记录电压、电流、温度随时间的变化。汇总表用来做 SOH/RUL 标签时序文件用来做特征提取。下面是一段读取和合并的代码假设汇总表是cycle_summary.csv时序文件放在raw_cycles/目录下文件名格式为cycle_001.csv。import pandas as pd import numpy as np import os import glob # 读取循环汇总表 summary pd.read_csv(cycle_summary.csv) # 常见字段cycle_id, discharge_capacity, internal_resistance, # max_temp, avg_temp, cc_time, cv_time # 计算 SOH以首圈放电容量为基准 rated_capacity summary[discharge_capacity].iloc[0] summary[SOH] summary[discharge_capacity] / rated_capacity # 计算 RUL剩余循环数 总循环数 - 当前循环序号 total_cycles summary[cycle_id].max() summary[RUL] total_cycles - summary[cycle_id] # 读取单个循环的时序文件提取统计特征 def extract_features(cycle_path): df pd.read_csv(cycle_path) feats {} feats[voltage_mean] df[voltage].mean() feats[voltage_std] df[voltage].std() feats[current_mean] df[current].mean() feats[temp_max] df[temperature].max() feats[temp_rise] df[temperature].max() - df[temperature].min() # 等压升时间充电阶段电压从 3.8V 升到 4.0V 的耗时 charge_mask df[current] 0 charge_df df[charge_mask] if len(charge_df) 0: v38 charge_df[charge_df[voltage] 3.8].index.min() v40 charge_df[charge_df[voltage] 4.0].index.min() if pd.notna(v38) and pd.notna(v40): feats[time_3v8_to_4v0] (v40 - v38) * 1.0 # 假设采样间隔 1s else: feats[time_3v8_to_4v0] np.nan else: feats[time_3v8_to_4v0] np.nan return feats # 批量提取并合并 cycle_files sorted(glob.glob(raw_cycles/cycle_*.csv)) feature_list [] for f in cycle_files: cycle_id int(os.path.basename(f).replace(cycle_, ).replace(.csv, )) feats extract_features(f) feats[cycle_id] cycle_id feature_list.append(feats) feature_df pd.DataFrame(feature_list) merged pd.merge(summary, feature_df, oncycle_id, howleft) merged.to_csv(merged_features.csv, indexFalse)这段代码的逻辑是先从汇总表拿到标签SOH 和 RUL再从每个循环的时序文件里提取统计特征和等压升时间。等压升时间是电池退化最敏感的指标之一因为内阻增加会导致充电曲线整体右移这个时间会明显缩短。参数上采样间隔假设为 1 秒如果实际数据是 0.1 秒或 10 秒time_3v8_to_4v0要乘以对应的间隔系数。另外rated_capacity用首圈容量做基准是常见做法但如果首圈有活化效应可以改用前 5 圈的平均值。注意合并后一定要检查缺失值比例。如果某个循环的时序文件缺失或电压没达到 4.0Vtime_3v8_to_4v0会是 NaN直接丢进模型会导致训练报错或预测偏移。3. 特征工程与标签构造把原始循环变成可训练矩阵3.1 从充放电曲线里挖出退化敏感特征原始时序数据不能直接喂给模型必须转成固定长度的特征向量。除了上一节的统计量还有几类特征对 SOH/RUL 特别敏感恒流充电时间、恒压充电时间、放电电压平台斜率、温升速率、内阻增量。恒压充电时间在电池老化后期会显著变长因为内阻增大导致极化加剧恒压阶段需要更长时间才能充满。下面这段代码在原有基础上补充了恒压充电时间和放电电压平台斜率。def extract_advanced_features(cycle_path): df pd.read_csv(cycle_path) feats {} # 恒压充电时间电流为正且电压接近上限时电流逐渐减小 charge_mask df[current] 0 charge_df df[charge_mask].copy() if len(charge_df) 0: v_max charge_df[voltage].max() cv_mask charge_df[voltage] v_max * 0.98 feats[cv_time] cv_mask.sum() * 1.0 # 采样间隔 1s # 恒流充电时间电压低于上限且电流稳定 cc_mask (charge_df[voltage] v_max * 0.98) (charge_df[current] 0.1) feats[cc_time] cc_mask.sum() * 1.0 else: feats[cv_time] np.nan feats[cc_time] np.nan # 放电电压平台斜率取放电中期电压对时间的线性拟合斜率 discharge_mask df[current] 0 discharge_df df[discharge_mask] if len(discharge_df) 10: mid len(discharge_df) // 2 segment discharge_df.iloc[mid-50:mid50] if len(segment) 5: x np.arange(len(segment)) y segment[voltage].values slope np.polyfit(x, y, 1)[0] feats[discharge_slope] slope else: feats[discharge_slope] np.nan else: feats[discharge_slope] np.nan # 温升速率温度从最低升到最高的平均速率 temp df[temperature].values if len(temp) 1: feats[temp_rate] (temp.max() - temp.min()) / len(temp) else: feats[temp_rate] np.nan return feats恒压充电时间的计算逻辑是先找到充电阶段电压最大值然后统计电压在最大值 98% 以上的采样点数。这个阈值可以根据数据噪声调整如果电压波动大降到 95% 更稳。放电电压平台斜率用多项式拟合取放电中期 100 个点因为首尾段受极化和截止电压影响大斜率不能代表真实退化。温升速率是粗略指标更细的做法是分段计算升温段和降温段的速率。3.2 滑动窗口构造 RUL 训练样本RUL 预测不能直接用单圈特征因为剩余寿命是一个累积退化过程单圈信息不足以判断趋势。常见做法是用滑动窗口把连续多圈的特征拼成一个样本窗口长度一般取 10 到 30 圈。窗口太短趋势信息不足窗口太长早期和晚期的退化模式混在一起模型学不准。def create_sliding_window(feature_df, window_size20, targetRUL): X, y [], [] feature_cols [c for c in feature_df.columns if c not in [cycle_id, SOH, RUL, discharge_capacity]] data feature_df[feature_cols].values targets feature_df[target].values for i in range(len(data) - window_size): X.append(data[i:iwindow_size].flatten()) y.append(targets[iwindow_size]) return np.array(X), np.array(y) # 使用示例 X, y create_sliding_window(merged, window_size20, targetRUL) print(f样本数: {X.shape[0]}, 特征维度: {X.shape[1]})窗口大小 20 是一个经验值对应 20 个充放电循环。如果数据总循环数只有 100 左右窗口可以降到 10如果超过 500可以加到 30。展平后特征维度是window_size * n_features如果特征有 15 个窗口 20那每个样本就是 300 维。维度太高容易过拟合可以在展平前先做 PCA 或者用 LSTM 直接处理序列。提示构造滑动窗口时训练集和测试集必须按电池个体划分不能随机打乱。同一块电池的相邻窗口高度相关随机划分会导致测试集泄漏评估结果虚高。4. 模型训练与验证从随机森林到 LSTM 的取舍4.1 基线模型随机森林和 XGBoost 怎么调数据驱动项目的第一步永远是建基线。对 SOH 和 RUL 这种回归任务随机森林和 XGBoost 是最稳的起点训练快、可解释、对特征缩放不敏感。下面是一个完整的训练和评估流程。from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import GroupKFold from sklearn.metrics import mean_absolute_error, mean_squared_error import numpy as np # 假设 merged 里有 battery_id 字段标识不同电池 groups merged[battery_id].values[:len(X)] # 与滑动窗口对齐 gkf GroupKFold(n_splits5) mae_scores, rmse_scores [], [] for train_idx, test_idx in gkf.split(X, y, groups): X_train, X_test X[train_idx], X[test_idx] y_train, y_test y[train_idx], y[test_idx] model RandomForestRegressor( n_estimators300, max_depth12, min_samples_leaf3, max_featuressqrt, random_state42, n_jobs-1 ) model.fit(X_train, y_train) y_pred model.predict(X_test) mae_scores.append(mean_absolute_error(y_test, y_pred)) rmse_scores.append(np.sqrt(mean_squared_error(y_test, y_pred))) print(fMAE: {np.mean(mae_scores):.2f} ± {np.std(mae_scores):.2f}) print(fRMSE: {np.mean(rmse_scores):.2f} ± {np.std(rmse_scores):.2f})参数上n_estimators300是精度和速度的平衡点超过 500 提升很小但训练时间翻倍。max_depth12防止树太深记住噪声如果特征维度高可以降到 8。min_samples_leaf3保证叶子节点有足够样本避免对个别循环过拟合。max_featuressqrt是回归任务的常用设置也可以试0.5看效果。GroupKFold 按电池个体分组确保同一块电池不会同时出现在训练和测试集里。这是电池寿命预测最容易踩的坑如果用普通 KFold同一块电池的早期循环在训练集、晚期在测试集模型实际上见过这块电池的退化轨迹评估结果会好得不真实。4.2 LSTM 时序模型输入形状和训练技巧如果数据量足够单块电池循环数超过 300电池数量超过 20可以上 LSTM 直接处理序列不用手动展平。LSTM 的优势是能捕捉退化趋势的长期依赖但训练成本高调参空间大。import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset class BatteryLSTM(nn.Module): def __init__(self, input_dim, hidden_dim64, num_layers2, dropout0.2): super().__init__() self.lstm nn.LSTM( input_sizeinput_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout ) self.fc nn.Sequential( nn.Linear(hidden_dim, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, 1) ) def forward(self, x): # x: (batch, seq_len, input_dim) out, _ self.lstm(x) out out[:, -1, :] # 取最后一个时间步 return self.fc(out).squeeze(-1) # 构造序列数据不展平保持 (样本数, 窗口长度, 特征数) def create_sequence_data(feature_df, window_size20, targetRUL): feature_cols [c for c in feature_df.columns if c not in [cycle_id, SOH, RUL, discharge_capacity]] data feature_df[feature_cols].values targets feature_df[target].values X, y [], [] for i in range(len(data) - window_size): X.append(data[i:iwindow_size]) y.append(targets[iwindow_size]) return np.array(X, dtypenp.float32), np.array(y, dtypenp.float32) X_seq, y_seq create_sequence_data(merged, window_size20) print(f序列形状: {X_seq.shape}) # (样本数, 20, 特征数) # 标准化按特征维度计算均值和标准差 mean X_seq.mean(axis(0, 1), keepdimsTrue) std X_seq.std(axis(0, 1), keepdimsTrue) 1e-8 X_seq (X_seq - mean) / std # 训练循环 device torch.device(cuda if torch.cuda.is_available() else cpu) model BatteryLSTM(input_dimX_seq.shape[2]).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-5) criterion nn.HuberLoss() dataset TensorDataset(torch.from_numpy(X_seq), torch.from_numpy(y_seq)) loader DataLoader(dataset, batch_size32, shuffleTrue) for epoch in range(100): model.train() total_loss 0 for xb, yb in loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() pred model(xb) loss criterion(pred, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() if (epoch 1) % 20 0: print(fEpoch {epoch1}, Loss: {total_loss/len(loader):.4f})LSTM 的关键参数hidden_dim64对大多数电池数据够用数据量大可以加到 128num_layers2兼顾表达能力和训练稳定性超过 3 层容易梯度消失dropout0.2防止过拟合。损失函数用 HuberLoss 而不是 MSE因为 RUL 在后期变化快MSE 会对大误差过度惩罚Huber 更稳。梯度裁剪clip_grad_norm_是 LSTM 训练的后悔药防止梯度爆炸导致 loss 变 NaN。标准化按特征维度计算不能用全局均值否则不同量纲的特征会被混在一起。标准化参数只能从训练集计算然后应用到测试集否则会泄漏测试集信息。5. 避坑与排查电池寿命预测里最容易翻车的五件事5.1 标签泄漏SOH 和 RUL 的计算用了未来信息现象模型在训练集上 MAE 很低但测试集误差突然翻倍。原因计算 SOH 时用了全局首圈容量而测试电池的首圈容量参与了训练集的统计。解决SOH 的基准容量必须按电池个体计算每块电池用自己的首圈或前几圈均值不能跨电池共用。5.2 特征穿越滑动窗口里混入了未来循环现象RUL 预测在早期循环就给出很准的结果看起来好得不真实。原因构造窗口时特征和标签的时间对齐错了比如用第 i 到 i20 圈的特征预测第 i10 圈的 RUL。解决标签必须是窗口最后一圈之后的 RUL即y[i] RUL[i window_size]不能取窗口中间的值。5.3 温度特征被工况掩盖温升速率和退化无关现象温升速率特征在特征重要性里排名很低甚至为负相关。原因不同循环的环境温度不同温升速率主要反映工况而非退化。解决用相对温升即当前循环温升减去前 10 圈平均温升或者直接用温度对容量的偏相关分析筛选特征。5.4 过拟合到个别电池模型在测试电池上完全失效现象交叉验证 MAE 很小但换一块新电池预测误差超过 20%。原因训练集里电池数量太少模型记住了个体特征而非通用退化模式。解决增加电池数量或者用元学习、迁移学习做跨电池适配。如果数据有限至少保证训练集有 5 块以上不同电池。5.5 评估指标选错MAE 好看但 RUL 预测不可用现象MAE 只有 5 个循环但预测的 RUL 曲线在后期严重偏离。原因MAE 对所有样本一视同仁但 RUL 后期误差的代价远大于早期。解决用加权 MAE后期样本权重更高或者用 NASA 的评分函数对晚期高估和低估分别惩罚。6. 把模型跑稳之后一个验证技巧和我的习惯模型训练完别急着看 MAE。我一般会做一件事把预测的 SOH 曲线和真实 SOH 曲线画在同一张图上然后看三条东西——早期是否贴合、中期是否平行、后期是否收敛。早期贴合说明特征提取没问题中期平行说明退化速率学对了后期收敛说明模型没有在末期发散。如果中期出现交叉大概率是窗口大小不合适如果后期发散检查 RUL 标签的截断方式很多赛题把 RUL 截断在某个上限模型学到的是截断值而不是真实趋势。另一个验证技巧是「留一电池交叉验证」每次留一块电池做测试其余全部训练。这个比 GroupKFold 更严格能直接看出模型跨电池的泛化能力。如果留一验证的 MAE 比 GroupKFold 高很多说明模型对电池个体特征依赖过强需要加正则化或者换更简单的模型。# 留一电池交叉验证示例 battery_ids merged[battery_id].unique() loo_mae [] for bid in battery_ids: train_mask merged[battery_id] ! bid test_mask merged[battery_id] bid # 重新构造滑动窗口确保窗口不跨电池 train_df merged[train_mask].reset_index(dropTrue) test_df merged[test_mask].reset_index(dropTrue) X_train, y_train create_sliding_window(train_df, window_size20) X_test, y_test create_sliding_window(test_df, window_size20) if len(X_test) 0: continue model RandomForestRegressor(n_estimators300, max_depth12, min_samples_leaf3, random_state42) model.fit(X_train, y_train) pred model.predict(X_test) loo_mae.append(mean_absolute_error(y_test, pred)) print(f留一验证 MAE: {np.mean(loo_mae):.2f} ± {np.std(loo_mae):.2f})这个验证跑完如果 MAE 在 10 个循环以内说明方案基本可用如果在 20 以上先回去查特征和标签对齐别急着换模型。我自己的习惯是任何电池寿命预测项目先跑通随机森林基线再上 LSTM最后用留一验证卡一遍。基线不过关深度模型只会把问题藏得更深。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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