ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

新能源汽车电池数据集实战:从SOC估算到SOH预测全流程

新能源汽车电池数据集实战:从SOC估算到SOH预测全流程

简介:面向新能源汽车电池健康管理与寿命预测研究,这套项目代码可快速对接智能汽车安全技术全国重点实验室发布的三元锂离子电池运行数据集。数据集采集自300辆运营车辆,覆盖0-50万公里里程与0.5-4年周期,包含SOC、SOH、电压、电流等13个关键字段,总量约8.5亿帧,适用于电池健康状态估计、剩余寿命预测与BMS算法优化等机器学习、深度学习课题。代码包共3个文件,整体仅6KB:HTML可视化页面为数据特征预览提供清晰入口,inscode脚本可一键拉起项目环境并引导数据读取与分析流程,gitignore配置则规范了工程版本管理。目前已有183人学习使用。借助该代码,研究者可省去环境配置与接口适配成本,快速进入100MB样例数据或72GB全量CSV数据的分析流程,将主要精力放在模型构建与实验验证上,尤其适合入门及进阶的电池数据分析开发人员。

1. 新能源汽车电池数据集:先别急着搭模型,把数据底座打好再跑

我接过不少电池算法需求,SOC 和 SOH 估计在论文里都很漂亮,真落地的瓶颈往往不是模型,而是数据集和配套的项目代码。新能源汽车电池数据集,本质上是一份记录了电池从新到衰的“体检档案”:每一次充放电的电压、电流、温度、容量、循环次数,再加上能把这些字段串起来的清洗与特征代码,才构成一份可训练的资产。它的价值在于让电池的健康状态可以量化、可复现、可验证,适合做电池预警、BMS 云端监控、剩余寿命预测的工程师和算法团队。我一般建议先花一周把数据和代码底座打好,再碰模型,否则后面每个结果都是黑匣子。

2. 新能源汽车电池数据集怎么选:先分清三条技术路线,再决定要不要自己采

选数据集的第一步不是找下载链接,而是先把任务类型定下来:你是要做 SOC(荷电状态)实时估算,做 SOH(健康状态)衰减评估,还是做热失控这种异常预警?不同任务对数据的需求完全不同。我见过直接拿实验室循环老化数据去训实车 SOC 模型的,结果实车一到充电桩边上就飘,根本原因是数据分布对不上。所以先把技术路线列清楚,再谈“数据集”三个字。

2.1 实验室循环老化数据 vs 实车 BMS 工况数据:选型对照

我给自己常用的三类数据源做过一张对照表,几乎每个项目都会先过一遍这张表再动手:

数据路线典型来源适合任务主要成本
实验室循环老化18650 或软包电池在恒温箱里反复充放电,记录每圈的电压流、电流和容量SOH 估计、剩余寿命预测、衰减机理分析采集周期长,单批次跑几百个循环要数周
实车 BMS 工况整车控制器里的 BMS 报文,含 SOC、电流、电压、温度、充电起始时间SOC 实时估算、充电异常检测、车队级健康管理数据量大且脏,工况不完整,标签缺失多
仿真半实物用等效电路模型(ECM)或电化学模型生成虚拟充放电曲线算法开发初期、无数据时验证流程模型参数不准时,迁移到实车效果打折

这里有一条选型经验:SOH/RUL 类任务优先找带“循环号 + 容量”的老化数据,因为标签天然递进;SOC 类任务需要完整工况段,实验室恒流恒压数据往往不够;而云端电池预警,必须有同一车辆很长时间跨度的历史数据,才能建模“和过去比有什么异常”。市面上的公开数据集里,像 NASA PCoE 老化数据、牛津电池老化数据都是常见选择,但许可和使用方式各有差异,先进去把 readme 和结构读一遍再决定。和自动驾驶数据集不同,电池数据几乎没有通用标注工具,字段含义每个源都不同,所以读懂数据结构比会下模型重要得多。

2.2 最少需要保留的 9 个字段,以及一个最小读取脚本

不管从哪种数据源拿到原始文件,我建议你先检查这些字段是否齐备:record_id、cycle、timestamp、voltage、current、capacity、soc、temperature、cell_id。其中cell_id特别容易被忽略,如果一条数据里混了多颗电池而你没有这个字段,后面所有按电池分组的交叉验证都做不了。下面是我常用的最小读取脚本,把多种 CSV 文件统一成标准 pandas DataFrame:

import pandas as pd import os def load_battery_csv(filepath: str) -> pd.DataFrame: """ 读取原始电池数据 CSV 并统一成标准字段。 要求文件至少包含:time、voltage、current, 其他字段可有可无。 """ df = pd.read_csv(filepath) # 常见列名归一化,兼容不同采集设备命名 rename_map = { "time_s": "timestamp", "cycle_index": "cycle", "voltage_v": "voltage", "current_a": "current", "capacity_ah": "capacity", "temp_c": "temperature", "soc_percent": "soc" } df = df.rename(columns=rename_map) # 如果文件里没有 cycle 字段,按充电/放电切换点补一个 if "cycle" not in df.columns: df["cycle"] = (df["current"].diff().abs() > 0.5).cumsum() return df

这段脚本的逻辑并不复杂,核心是两件事:一是把不同设备的不同列名映射成统一命名,二是当原始文件没有cycle时,用电流跳变来切分充放电周期。current.diff().abs() > 0.5的含义是找电流突变超过 0.5A 的位置,每次突变视为一个阶段的开始。参数 0.5 不是固定的:如果你手里的电池是 18650(约 2A 满放电),0.5A 够用;如果是电动大巴的大容量电池,电流突变可能到几十安,这个阈值要放大到 5A 甚至 10A,否则会把一个循环切成几十段。如果发现切出来的 cycle 少了一半,去检查是不是阈值过小,把正常波动也当成切点。

3. 把原始充放电曲线变成训练集:滑窗切片与 IC/DV 特征的项目代码

有了标准格式的数据,下一步不是直接扔给模型。原始电压电流曲线是每个循环几千行数据,模型直接吃会有两个问题:一是维度太宽导致过拟合;二是电池退化最敏感的信号不在绝对电压,而在电压平台的变化上,必须显式提取出来。这一章讲我常用的两条特征工程路径:增量容量 IC(Incremental Capacity)和基于循环序号的滑窗特征。

3.1 为什么要做增量容量(IC)和滑窗,而不是直接把曲线喂进模型

先讲原理。磷酸铁锂电池的充电曲线有个典型特征:3.2V 到 3.4V 之间有一段很宽的平台,肉眼看上去电压几乎不变,但容量一直在涨。如果只看电压序列,模型很难区分这节电池是新的还是已经衰减了;但把横轴从时间换成电压,纵轴从电流换成 dQ/dV(单位电压变化对应的容量变化),平台区就会出现一个明显的峰。随着电池老化,这个峰会往低压方向移动、高度下降。这个“峰的位置和高度”就是 SOH 预测里最有辨识度的特征。

滑窗则是为了把“趋势”纳入样本。SOH 不是只看某一个循环的容量,而是看“近 N 个循环容量下降的速度”。同一节电池在第 2 圈和第 200 圈的容量绝对值相差不大,但斜率完全不同。所以我一般会以循环为单位,取最近 20 个循环的 IC 峰值、容量衰减斜率、平均内阻作为特征,这样模型看到的不是一张瞬态快照,而是一段退化历史。滑窗这个思路,和社交推荐、时序预测里用的滑窗是一样的,但在电池数据里要特别注意窗口不要跨过“容量回升”点,否则会把短期的虚假回升当成恢复能力。

3.2 一个可复用的特征工程代码块:CC 段、电压分箱与容量衰减斜率

下面这段代码,是我多个项目里复制修改过很多次的通用模板。它把每个循环的充放电数据压缩成一组数值特征:

import pandas as pd import numpy as np def extract_ic_curve(df_cycle: pd.DataFrame, voltage_bin: float = 0.02): """ 从单个循环的充电段提取 IC 曲线。 步骤:筛选充电段 -> 按电压分箱 -> 聚合容量增量。 voltage_bin: 电压分箱宽度,单位 V。 """ # 只保留充电段,电流阈值 0.1A 用于排除静置和放电 chg = df_cycle[(df_cycle["current"] > 0.1)].copy() if len(chg) < 5: return None # 给电压分箱,0.02V 是 18650 常用经验值 chg["voltage_bin"] = (chg["voltage"] / voltage_bin).round() * voltage_bin # 每个电压箱内累计容量增量,得到 dQ grouped = chg.groupby("voltage_bin").agg( dQ=("capacity", "last") ).reset_index() # 用相邻箱差求 dQ/dV grouped["dV"] = grouped["voltage_bin"].diff().fillna(voltage_bin) grouped["ic"] = grouped["dQ"].diff() / grouped["dV"] return grouped.dropna(subset=["ic"]) def build_cycle_features(df: pd.DataFrame, window: int = 20): """ 按电池和循环序号滑窗,生成 SOH 预测样本。 window: 用最近 N 个循环的特征拼成一条样本。 """ records = [] for cell_id in df["cell_id"].unique(): cell_df = df[df["cell_id"] == cell_id].sort_values("cycle") for i in range(window, len(cell_df)): hist = cell_df.iloc[i - window:i] feat = { "cell_id": cell_id, "cycle": cell_df.iloc[i]["cycle"], "capacity_slope": np.polyfit(hist["cycle"], hist["capacity"], 1)[0], "ic_peak": hist["ic"].max(), "mean_temp": hist["temperature"].mean(), "label_soh": cell_df.iloc[i]["capacity"] / cell_df.iloc[0]["capacity"] } records.append(feat) return pd.DataFrame(records)

两个函数各有分工:extract_ic_curve负责把一个循环压成 IC 曲线,build_cycle_features负责把所有循环的历史拼成训练样本。参数里voltage_bin是最需要调的那个:0.02V 适合 18650,如果是三元锂电池(电压范围 3.0V 到 4.2V),通常 0.02V 还是可用;如果是磷酸铁锂(平台区宽),建议放大到 0.05V,否则单个电压箱内采样点太少,dQ/dV 会抖成一条毛刺。window我一般取 10 到 30,取太大了训练样本少,取太小了捕捉不到退化趋势。这段代码跑完之后,label_soh是 0 到 1 之间的比值,后续训练直接当回归标签。

4. 用项目代码训一个 SOH 回归基线:XGBoost 与电池级交叉验证

特征工程做完,训练部分并不复杂。我的经验是,电池 SOH 预测的基线模型用梯度提升树(如 XGBoost)通常比深度学习更稳:样本量往往只有几百到几千条,树模型不容易过拟合,调参也少。在这一章里,我要强调两个初学者最容易栽的环节:SOH 标签怎么定义,以及交叉验证怎么分组。

4.1 SOH 标签怎么标:额定容量、循环容量与容量参考点的选择

SOH 最常见的定义是当前充满容量除以额定容量,但这里有个数据坑:同一节电池的充电容量和放电容量往往不相等,因为库仑效率和温度都在影响。用谁当分子,直接决定模型学出来的物理含义。

我一般坚持用“标准工况放电容”作为当前容量。实验室数据里,放电段通常是恒流恒阻,条件固定,容量值可比性好;充电段则受充电策略(CC/CV 比例)影响大,如果你的数据里有快充和慢充混在一起,充电容量根本不具备可比性。所以特征可以从充电段里提(如 IC 曲线),标签一定优先从同温度下的放电段取。若项目里只有充电数据,另一个可行做法是取充满电压后静置阶段的容量作为近似标签,但要清楚这是妥协,换来的 SOH 会有 1% 到 2% 的偏差,且对恒流恒压切换不敏感。

还有一个参考系问题:cell_df.iloc[0]["capacity"]作为额定容量,在数据不完整时会失真。比如第一批次数据从第 5 个循环才记录,第 5 圈的容量已经不是 100% 了。这种情况下,我会先做一次健康度初筛:取前 5 个循环的容量最大值作为额定容量,而不是第一个循环的记录值。代码如下:

rated_capacity = cell_df["capacity"].iloc[:5].max() cell_df["soh"] = cell_df["capacity"] / rated_capacity

这段代码的逻辑是抵抗退化曲线的早期异常点。电池放电容量在最初几次循环内可能先升后降(化成效应),如果直接拿第一圈当基准,SOH 会超过 100% 甚至出现“越用越健康”的假象,用户体验很差,模型也会掉进这个陷阱。

4.2 训练脚本:GroupKFold 防泄漏、参数与评估输出

接下来是训练。我用一个最小可复现的 XGBoost 脚本,关键是交叉验证方式:

import xgboost as xgb import pandas as pd from sklearn.model_selection import GroupKFold from sklearn.metrics import mean_absolute_error, mean_squared_error import numpy as np def train_soh_model(features: pd.DataFrame): # 特征列排除 cell_id、cycle、label feat_cols = ["capacity_slope", "ic_peak", "mean_temp"] X = features[feat_cols] y = features["label_soh"] groups = features["cell_id"] # 同一电池的所有循环不能跨组泄漏 gkf = GroupKFold(n_splits=5) y_true_all, y_pred_all = [], [] for train_idx, val_idx in gkf.split(X, y, groups): X_train, X_val = X.iloc[train_idx], X.iloc[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] model = xgb.XGBRegressor( n_estimators=500, learning_rate=0.05, max_depth=4, subsample=0.8, colsample_bytree=0.8, early_stopping_rounds=30, eval_metric="mae", random_state=42 ) model.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False) y_pred_all.extend(model.predict(X_val)) y_true_all.extend(y_val) # 整体评估,输出 SOH 误差 mae = mean_absolute_error(y_true_all, y_pred_all) rmse = mean_squared_error(y_true_all, y_pred_all, squared=False) mape = np.mean(np.abs(np.array(y_true_all) - np.array(y_pred_all)) / np.array(y_true_all)) * 100 print(f"MAE={mae:.4f} RMSE={rmse:.4f} MAPE={mape:.2f}%") return model

这段代码里有三个刻意的小设计。第一,groups=features["cell_id"]是最关键的一行:它保证同一节电池的所有循环样本只在折内或折外,不会被拆散到训练和验证两侧。如果不加这一行,模型会把同一颗电池的相邻循环既当训练又当验证,结果会虚高,我见过有人因此把 SOH 误差报到 1% 以内,换车数据后直接翻车到 6%。第二,early_stopping_rounds=30配合eval_set用验证集选迭代轮数,防止树模型在几百个特征上钻太久。第三,eval_metric="mae"在电池场景比默认的 RMSE 更稳,因为 SOH 标签里偶发的传感器毛刺会放大 RMSE,使早停过早,MAE 对异常值更钝。

参数上,learning_rate=0.05和max_depth=4是我试过很多电池表格数据后比较稳的组合,树深超过 6 就容易把 IC 峰值的噪声吃进去,建议不用动。如果特征是几百维,可以把colsample_bytree降到 0.6 增加随机性;如果样本量只有几百条,n_estimators配合早停会自动收敛,不需要手动调大。

5. 电池数据集项目代码的 4 个常见坑:从容量回升到标签噪声的排查方案

这一章是血泪经验的汇总。我在前几个项目里每个坑都填过,列出来希望你能跳过这些弯路。

5.1 容量回升导致 SOH 序列锯齿化,模型学到“回光返照”假规律

现象:训练曲线的 loss 反复震荡,验证集误差忽高忽低,把预测结果画出来发现模型在某些循环交界处会预测出多余的“容量上升”。

原因:锂离子电池静置一段时间后,内部极化电压消散,可恢复容量会小幅回升,同一颗电池在第 120 圈的容量比第 119 圈还高 0.5%,这在物理上是正常的。但滑窗特征会把这种回升当成真实趋势,模型就会学到“容量会波动上升”的错误模式。

解决:在特征工程里对容量序列做一次指数加权平滑(EWMA),把短于 3 个循环的异常波动滤掉。具体做法是对capacity做ewm(span=5).mean(),再拿平滑后的容量算斜率。注意平滑只用于特征,不用于标签,标签还是得用原始放电容,否则会掩盖真实健康度。

5.2 随机划分交叉验证导致“信息泄漏”,线上测试误差翻倍

现象:用train_test_split随机划分时,MAE 在 2% 以内,换到新电池或新的生产批次后误差直接涨到 5% 以上。

原因:同一次循环数据的两个相邻样本(第 19 圈和第 20 圈)几乎一模一样,随机划分时它们一个进了训练集、一个进了验证集,模型实际上记住了这条曲线的结构,而不是学会了泛化的退化规律。

解决:用GroupKFold并且按cell_id分组,严格保证一节电池只出现在一侧。更进一步,做最终评估时要留出 1 到 2 节全新的电池完全不参与调参,模拟“车上新装一节电池”的场景。如果你的项目是对接 BMS 的增量更新,这个留出集比任何分数都有说服力。

5.3 充电策略混用:把 CC 段和 CV 段的容量都算进 IC 特征

现象:同一颗电池在慢充和快充下提取的 IC 峰值差异很大,模型在训练时学到的峰值“仿佛跟 SOH 无关”,特征重要性里ic_peak排在最后。

原因:恒流(CC)段电流稳定,电压从低到高,IC 曲线形态清晰;恒压(CV)段电流持续下降,电压不动,按电压分箱后全部落在同一个箱子里,dQ/dV 被拉出巨大的假峰。把两段混在一起,就像把苹果和橙子放一个榨汁机。

解决:在extract_ic_curve里对充电段分两步过滤。先筛current > 0.1A,再筛掉电压接近满充上限且电流持续下降的尾部。更稳的做法是按电流绝对值把 CC/CV 段分开:如果当前电流小于起始充电电流的 20%,视为 CV 段并截断。这样 IC 曲线更干净,峰值也稳定。

5.4 标签噪声来自容量读取基准不一致,同一颗电池 SOH 忽高忽低

现象:模型 MAPE 一直降不下去,排查特征发现capacity字段本身有跳变,相邻两个循环容量差 2% 以上。

原因:采集系统记录容量时,有的按库仑积分从电池启动算起,有的只算当前循环,还有的会在温度变化时修正容量。混用这些口径会让标签本身带噪声,模型再聪明也学不出来。

解决:做一个数据体检函数,检查每个cell_id的capacity序列是否有超过 3% 的单次跳变,打印出来人工核对。另一个常用做法是同时记录charge_capacity和discharge_capacity,两个字段交叉验证:若两者相差超过 1%、且不是温度剧烈变化段,标记该循环为可疑样本。宁可删掉 5% 的可疑循环,也别让它们污染模型。

6. 电池级验证怎么才算数:把项目代码收敛成能复现的基线

最后一章,我不聊更多新模型,而是分享一个我在每个电池项目里都会做的验证闭环。这个做法保证:无论谁拿到你的代码和数据集,跑出来的数字都跟你报的数字一致。

先建立一个规则:所有评估必须按电池分组输出,而不是只看总表。单看 MAE 会被多数正常电池“平均”掉,几节异常电池的坏成绩被掩盖。我一般这样输出:

import pandas as pd def evaluate_by_cell(pred_df: pd.DataFrame): # pred_df 至少包含 cell_id, y_true, y_pred pred_df["abs_err"] = (pred_df["y_true"] - pred_df["y_pred"]).abs() summary = pred_df.groupby("cell_id").agg( mae=("abs_err", "mean"), p90_ae=("abs_err", lambda x: x.quantile(0.9)), max_ae=("abs_err", "max") ) return summary.sort_values("p90_ae", ascending=False)

这段代码的输出能迅速暴露问题:如果所有电池的 p90 误差都比 MAE 高出一倍,说明同一节电池在低频循环时误差大,需要检查是不是低温段样本不足;如果个别电池的 max_ae 明显高,多半是标签噪声,回到第 5 章去查容量跳变。我现在的习惯是每个电池项目都把这个evaluate_by_cell输出保存成 CSV,跟数据集版本放在一起,上面写清楚用的原始数据日期、特征脚本版本、模型参数。因为电池数据会有新批次不断补充进来,没有这份记录,三个月后连你自己都说不清当初的基线是拿哪一版数据跑的。

最后提醒一点:当模型误差压到 2% 以下时,继续加大模型复杂度往往没有收益,真正的收益来自数据覆盖度——有没有低温数据?有没有快充慢充混用?有没有不同厂家电池?这些分布扩展比调参宝贵得多。这也是我这两年做电池数据项目最大的教训:先跑通一版极简基线,再花时间扩大数据多样性和验证闭环,性价比最高。希望帮到你。

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

RELATED READING

延伸阅读

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