☰
Python实现电池SOH与RUL预测:从特征工程到模型调优
2026/10/2 6:20:31 网站建设 项目流程

简介:本资源为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, on='cycle_id', how='left') merged.to_csv('merged_features.csv', index=False)

这段代码的逻辑是:先从汇总表拿到标签(SOH 和 RUL),再从每个循环的时序文件里提取统计特征和等压升时间。等压升时间是电池退化最敏感的指标之一,因为内阻增加会导致充电曲线整体右移,这个时间会明显缩短。参数上,采样间隔假设为 1 秒,如果实际数据是 0.1 秒或 10 秒,time_3v8_to_4v0要乘以对应的间隔系数。另外,rated_capacity用首圈容量做基准是常见做法,但如果首圈有活化效应,可以改用前 5 圈的平均值。

注意:合并后一定要检查缺失值比例。如果某个循环的时序文件缺失或电压没达到 4.0V,time_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:mid+50] 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_size=20, target='RUL'): 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:i+window_size].flatten()) y.append(targets[i+window_size]) return np.array(X), np.array(y) # 使用示例 X, y = create_sliding_window(merged, window_size=20, target='RUL') 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_splits=5) 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_estimators=300, max_depth=12, min_samples_leaf=3, max_features='sqrt', random_state=42, 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(f'MAE: {np.mean(mae_scores):.2f} ± {np.std(mae_scores):.2f}') print(f'RMSE: {np.mean(rmse_scores):.2f} ± {np.std(rmse_scores):.2f}')

参数上,n_estimators=300是精度和速度的平衡点,超过 500 提升很小但训练时间翻倍。max_depth=12防止树太深记住噪声,如果特征维度高可以降到 8。min_samples_leaf=3保证叶子节点有足够样本,避免对个别循环过拟合。max_features='sqrt'是回归任务的常用设置,也可以试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_dim=64, num_layers=2, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=input_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, dropout=dropout ) 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_size=20, target='RUL'): 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:i+window_size]) y.append(targets[i+window_size]) return np.array(X, dtype=np.float32), np.array(y, dtype=np.float32) X_seq, y_seq = create_sequence_data(merged, window_size=20) print(f'序列形状: {X_seq.shape}') # (样本数, 20, 特征数) # 标准化:按特征维度计算均值和标准差 mean = X_seq.mean(axis=(0, 1), keepdims=True) std = X_seq.std(axis=(0, 1), keepdims=True) + 1e-8 X_seq = (X_seq - mean) / std # 训练循环 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = BatteryLSTM(input_dim=X_seq.shape[2]).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5) criterion = nn.HuberLoss() dataset = TensorDataset(torch.from_numpy(X_seq), torch.from_numpy(y_seq)) loader = DataLoader(dataset, batch_size=32, shuffle=True) 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_norm=1.0) optimizer.step() total_loss += loss.item() if (epoch + 1) % 20 == 0: print(f'Epoch {epoch+1}, Loss: {total_loss/len(loader):.4f}')

LSTM 的关键参数:hidden_dim=64对大多数电池数据够用,数据量大可以加到 128;num_layers=2兼顾表达能力和训练稳定性,超过 3 层容易梯度消失;dropout=0.2防止过拟合。损失函数用 HuberLoss 而不是 MSE,因为 RUL 在后期变化快,MSE 会对大误差过度惩罚,Huber 更稳。梯度裁剪clip_grad_norm_是 LSTM 训练的后悔药,防止梯度爆炸导致 loss 变 NaN。

标准化按特征维度计算,不能用全局均值,否则不同量纲的特征会被混在一起。标准化参数只能从训练集计算,然后应用到测试集,否则会泄漏测试集信息。

5. 避坑与排查:电池寿命预测里最容易翻车的五件事

5.1 标签泄漏:SOH 和 RUL 的计算用了未来信息

现象:模型在训练集上 MAE 很低,但测试集误差突然翻倍。原因:计算 SOH 时用了全局首圈容量,而测试电池的首圈容量参与了训练集的统计。解决:SOH 的基准容量必须按电池个体计算,每块电池用自己的首圈或前几圈均值,不能跨电池共用。

5.2 特征穿越:滑动窗口里混入了未来循环

现象:RUL 预测在早期循环就给出很准的结果,看起来好得不真实。原因:构造窗口时,特征和标签的时间对齐错了,比如用第 i 到 i+20 圈的特征预测第 i+10 圈的 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(drop=True) test_df = merged[test_mask].reset_index(drop=True) X_train, y_train = create_sliding_window(train_df, window_size=20) X_test, y_test = create_sliding_window(test_df, window_size=20) if len(X_test) == 0: continue model = RandomForestRegressor(n_estimators=300, max_depth=12, min_samples_leaf=3, random_state=42) 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,最后用留一验证卡一遍。基线不过关,深度模型只会把问题藏得更深。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询