第 29 章 · 时间序列索引与重采样
这一章要解决什么:按时间维度聚合数据(每 100 天、每月、每年)。 打掉两个静默错误:忘了
to_datetime(字符串排序会乱序);忘了sort_index(重采样结果错乱)。
本章你需要完成的代码:demos/ch29-time-index.py
29.1 读进来的 timestamp 是字符串
df = pd.read_csv("data/battery/cycle_raw.csv")
print(df["timestamp"].dtype) # str ← 不是日期!
print(df["timestamp"].head(3).tolist()) # ['2023-01-01', '2023-01-02', '2023-01-03']这是 read_csv 的一个常见意外(第 23 章提过):它不会自动把日期列转成日期类型。
点开:字符串排序会有多离谱?
问题的根源:字符串比较是按字符逐位比的(字典序)。
dates = pd.Series(["2023-10-01", "2023-02-01", "2023-06-01"])
print(sorted(dates.tolist()))
# ['2023-02-01', '2023-06-01', '2023-10-01'] ← 这个恰好对了(因为月份是零填充)零填充的 YYYY-MM-DD 恰好能按字典序正确排序 —— 这是 ISO 8601 格式的设计巧妙之处。
但换个格式就崩了:
dates2 = pd.Series(["2023-9-1", "2023-10-1", "2023-2-1"])
print(sorted(dates2.tolist()))
# ['2023-10-1', '2023-2-1', '2023-9-1'] ← 完全乱了!原因:比到第 6 个字符时,'2023-1' < '2023-2' < '2023-9', 所以 10 排在 2 前面。
更危险的是"看起来没错但其实是错的":
# 如果日期带时间戳,格式是 "2023-01-01 08:30:00"
# 排序本身没问题,但:
# - 无法按"月"聚合(因为没有日期语义)
# - 无法计算时间差("2023-01-02" - "2023-01-01" 会报 TypeError)
# - 无法用 .dt 访问器提取年月日结论:日期列读进来第一件事就是 pd.to_datetime。
df["timestamp"] = pd.to_datetime(df["timestamp"])
print(df["timestamp"].dtype) # datetime64[us]或者读的时候直接转:
df = pd.read_csv(path, parse_dates=["timestamp"])本书推荐"读完后显式转" —— 因为那一步 print(df["timestamp"].dtype) 更容易被注意到, 而且转换失败时(比如有脏数据)你能明确知道是这一步出的问题。
29.2 .dt 访问器:从时间列派生特征
转成日期类型后,才能用 .dt 访问器:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df["year"] = df["timestamp"].dt.year
df["month"] = df["timestamp"].dt.month
df["dayofweek"] = df["timestamp"].dt.dayofweek # 0=周一
df["day_name"] = df["timestamp"].dt.day_name() # 'Monday'
df["days_since_start"] = (df["timestamp"] - df["timestamp"].min()).dt.days真实输出:
timestamp year month dayofweek day_name days_since_start
2023-01-01 2023 1 6 Saturday 0
2023-01-02 2023 1 0 Monday 1
2023-01-03 2023 1 1 Tuesday 2注意 .dt 是必须的:
df["timestamp"].year # ❌ AttributeError(Series 没有 year 属性)
df["timestamp"].dt.year # ✅ 通过 .dt 访问器.dt 后面可以接的常用属性:
| 属性 | 含义 | 例子 |
|---|---|---|
.year / .month / .day | 年月日 | 2023 / 1 / 1 |
.hour / .minute / .second | 时分秒 | 8 / 30 / 0 |
.dayofweek | 周几(0=周一) | 6(周六) |
.day_name() | 周的英文名 | Saturday |
.quarter | 季度 | 1 |
.is_month_end | 是否月末 | False |
.days_in_month | 当月天数 | 31 |
.date | 只取日期部分 | date(2023, 1, 1) |
为什么 AI 项目爱做这些
点开:时间特征工程的思路
模型看不懂 2023-01-01 —— 对它来说那只是一串字符。
能看懂的是三种"时间语义":
① 趋势:days_since_start
df["days_since_start"] = (df["timestamp"] - df["timestamp"].min()).dt.days单调递增、有方向 —— 模型能学到"越往后 SOH 越低"。
② 周期性:month / dayofweek / hour
df["month"] = df["timestamp"].dt.month
df["dayofweek"] = df["timestamp"].dt.dayofweek表示"循环往复"的模式 —— 比如"夏季温升更高"、"周一产量低"。
⚠ 周期特征的陷阱:month 的 12 和 1 是相邻的,但数值上差 11。
修复方式之一(正弦/余弦编码):
import numpy as np
df["month_sin"] = np.sin(2 * np.pi * df["timestamp"].dt.month / 12)
df["month_cos"] = np.cos(2 * np.pi * df["timestamp"].dt.month / 12)这两个特征拼在一起,能正确表达"12 月和 1 月很接近"。
③ 时间差:两个时间列相减
df["duration"] = (df["end_time"] - df["start_time"]).dt.total_seconds()★ 什么时候不该把时间当特征
| 情况 | 处理 |
|---|---|
| 时间列只是记录时间(和业务无关) | 不用(比如"这条数据什么时候入库的") |
| 时间是用来切分训练/测试的 | 不用当特征(否则泄漏) |
| 时间本身有物理含义(老化、季节) | 一定要派生 |
| 时间表示**"距某个事件多久"** | 派生差值(比原始时间戳有用得多) |
判断方法:
# 看目标变量是否随时间有趋势
df.groupby(df["timestamp"].dt.month)["soh"].mean().plot()
# 如果曲线有明显趋势或周期 → 时间是有用的特征29.3 set_index + sort_index:resample 的前提
c1 = df[df["cell_id"] == "C001"].copy()
c1 = c1.set_index("timestamp").sort_index()
print(type(c1.index).__name__) # DatetimeIndex
print(c1.index.is_monotonic_increasing) # True两个动作缺一不可:
| 动作 | 为什么必须 |
|---|---|
set_index("timestamp") | resample 要求索引是时间类型 |
sort_index() | resample 要求索引有序 |
is_monotonic_increasing 是"索引单调递增"的检查属性 —— 值得每次都用它验证一下。
29.4 resample:按时间窗口聚合
res = c1["soh"].resample("100D").mean()
print(res.round(4))真实输出:
timestamp
2023-01-01 0.9921
2023-04-11 0.9731
2023-07-20 0.9517
2023-10-28 0.9290
2024-02-05 0.9052
2024-05-15 0.8806
2024-08-23 0.8553
2024-12-01 0.8295
2025-03-11 0.8081
Freq: 100Dresample 的语义:把时间轴切成固定长度的窗口,每个窗口算一个聚合值。
原始(862 个点,每天一个):
1/1 1/2 1/3 ... 12/31 | 1/1 ... 4/10 | 4/11 ... | ...
└──── 前 100 天 ────┘ └─ 第 101~200 天 ─┘
↓ mean ↓ mean
0.9921 0.9731resample = groupby 的时间版本:
groupby | resample | |
|---|---|---|
| 分组依据 | 列的值 | 时间窗口 |
| 前提 | 无 | 索引是时间且有序 |
| 例 | groupby("cell_id") | resample("100D") |
第 28 章的"切-算-合"心智模型完全适用。
常用频率字符串
c1["soh"].resample("7D").mean() # 按周
c1["soh"].resample("ME").mean() # 按月(月末)
c1["soh"].resample("QE").mean() # 按季度
c1["soh"].resample("YE").mean() # 按年点开:频率字符串速查(含版本变化)
| 字符串 | 含义 | 备注 |
|---|---|---|
D | 日 | |
W | 周 | 默认周日为界,W-MON 指定周一 |
ME | 月末 | 旧写法 M 已弃用 |
MS | 月初 | |
QE | 季末 | 旧写法 Q 已弃用 |
QS | 季初 | |
YE | 年末 | 旧写法 Y/A 已弃用 |
YS | 年初 | |
h / min / s | 时/分/秒 | |
100D | 任意天数 | 数字 + 单位 |
⚠ 版本变化(Pandas 2.2 起):
c1["soh"].resample("M").mean() # FutureWarning: 'M' is deprecated, use 'ME'
c1["soh"].resample("ME").mean() # ✅ 正确ME 的 E 是 End(期末),MS 的 S 是 Start(期初)。
为什么要区分:resample("M") 和 resample("MS") 会给不同的窗口边界:
ME(月末): [1/1, 1/31], [2/1, 2/28], [3/1, 3/31] ...
MS(月初): [1/1, 1/31], [2/1, 2/28], [3/1, 3/31] ... ← 看似一样实际差异在于标注的"时间点":
ME的结果索引是每个窗口的最后一天(1/31、2/28……)MS的结果索引是每个窗口的第一天(1/1、2/1……)
选哪个取决于"你想用哪个日期代表这个窗口"。
本书用 100D(任意天数),因为数据是"1 圈 = 1 天"合成的, 100D 恰好等于"每 100 圈",语义清楚。
读结果:膝点效应
print(res.diff().round(4))timestamp
2023-01-01 NaN
2023-04-11 -0.0190
2023-07-20 -0.0214
2023-10-28 -0.0228
2024-02-05 -0.0238
2024-05-15 -0.0246
2024-08-23 -0.0253
2024-12-01 -0.0259
2025-03-11 -0.0213每一段的衰减量越来越大(-0.019 → -0.026),然后最后一段又变小了。
前面越来越大 = 膝点(knee point)效应:电池老化到后期会加速下降。
对应生成公式:
当 β > 1 时,c^β 是凸函数 —— 前期增长慢、后期增长快, 所以 SOH 是"前期掉得慢、后期掉得快"。
真实电池也有这个现象,而且它是寿命预测的关键: 如果能提前发现"膝点"开始,就能准确预测剩余寿命。
最后一段(-0.0213)为什么变小了:因为最后一段不满 100 天 (C001 只有 862 圈,862 = 8×100 + 62),所以它的均值只反映了 62 天的衰减。
这是个值得注意的细节:
print(res.index[-1]) # 2025-03-11
print(len(c1)) # 862
print(8 * 100, 862 - 800) # 800 62 ← 最后一段只有 62 天报告里如果引用"最后一段的衰减速度",要说明"该段不足 100 天"。
29.5 ⚠ resample 不会"补齐"空窗口
sparse = pd.Series([1.0, 2.0, 3.0],
index=pd.to_datetime(["2023-01-01", "2023-01-02", "2023-01-10"]))
print(sparse.resample("1D").mean())2023-01-01 1.0
2023-01-02 2.0
2023-01-03 NaN ← 空窗口是 NaN
2023-01-04 NaN
...
2023-01-09 NaN
2023-01-10 3.0没有数据的窗口是 NaN,不是 0,也不会被"跳过"。
点开:为什么要这样设计?以及怎么补齐
为什么不自动补齐:
因为"补齐"是一种决策。填 0 还是插值?还是保留空缺? Pandas 把决策权交给你。
三种补齐方式:
# ① 前向填充(用上一个有效值)—— 适合"状态持续"的场景
sparse.resample("1D").ffill()
# 1.0, 2.0, 2.0, 2.0, ..., 2.0, 3.0
# ② 线性插值 —— 适合"平滑变化"的场景(电池容量就是)
sparse.resample("1D").interpolate()
# 1.0, 2.0, 2.111, 2.222, ..., 2.889, 3.0
# ③ 填固定值 —— 适合"已知缺失原因"
sparse.resample("1D").mean().fillna(0)★ 这里的 interpolate 和第 30 章是同一个工具, 但第 30 章会讲一个更重要的坑:必须先分组再插值。
如果你直接对"整张表"插值:
# ❌ 危险:跨电芯插值
df[["soh"]].interpolate()这会用 C001 的末圈去补 C002 的首圈 —— 数据污染。
正确做法(第 30 章):
df = df.sort_values(["cell_id", "cycle"])
df[NUM] = df.groupby("cell_id")[NUM].transform(
lambda g: g.interpolate(limit_direction="both"))为什么这里(resample 之后)可以直接用 interpolate: 因为 resample 的结果已经是单一时间序列(只有 C001), 不存在"跨实体"的问题。
判断标准:插值的"邻居"必须来自同一个业务实体。
29.6 ⚠ 静默错误:不排序就重采样
这是本章最危险的一个坑 —— 它可能不报错,只是给出错误结果。
shuffled = df[df["cell_id"] == "C002"].sample(frac=1.0, random_state=7)
shuffled = shuffled.set_index("timestamp")
print(shuffled.index.is_monotonic_increasing) # False
print(shuffled["soh"].resample("100D").mean())点开:不排序会发生什么?
resample 要求索引有序。Pandas 的实际行为分两种情况:
情况一:直接抛错
ValueError: index must be monotonic increasing or decreasing这是好情况 —— 你立刻就知道了。
情况二:静默算错
某些操作不会报错,但结果完全错乱 —— 因为它按"索引出现顺序"切窗口,而不是按"时间大小"。
一个直观的例子:
索引顺序:1/10, 1/1, 1/5, 1/3 ← 打乱的
resample("1M") 会按"1/10 到 1/1"算一个窗口?
还是按时间正确分组? → 取决于具体实现,但结果一定不是你要的所以:无论报不报错,都要先排序。
正确的流程:
c1 = df[df["cell_id"] == "C002"].copy()
c1 = c1.set_index("timestamp").sort_index() # ← 一定要 sort_index
assert c1.index.is_monotonic_increasing # ← 断言验证
res = c1["soh"].resample("100D").mean()★ 加一条断言是好习惯:
assert df.index.is_monotonic_increasing, "resample 前索引必须有序"这一行代码的成本是零,收益是"不会在半夜发现结论错了"。
29.7 按日期区间筛选
td = df.set_index(pd.to_datetime(df["timestamp"])).sort_index()
print(td.loc["2023-01-01":"2023-01-31"].shape) # 1 月份的记录
print(td.loc["2024"].shape) # 2024 全年切片用字符串写日期 —— Pandas 会自动解析。
td.loc["2024"] 直接写年份(部分字符串索引),会匹配那一年所有的记录。
⚠ 前提同样是索引有序:
# 索引没排序时,区间切片会报错或返回空这是"有序"这个前提第三次出现了(resample、区间切片、以及第 30 章的插值):
按时间操作之前,先
sort_index()。
一个实用的技巧:把"排序 + 断言"包装成一个函数。
def with_time_index(df, time_col="timestamp"):
"""把时间列设为有序索引,并验证。"""
out = df.copy()
out[time_col] = pd.to_datetime(out[time_col])
out = out.set_index(time_col).sort_index()
assert out.index.is_monotonic_increasing, "索引必须单调递增"
return out
td = with_time_index(df)
res = td[td["cell_id"] == "C001"]["soh"].resample("100D").mean()这个函数在真实项目里值得放进 utils.py —— 因为它一次性解决了"转类型 + 设索引 + 排序 + 验证"这一串容易漏掉的步骤。
29.8 完整示例与输出
cd code
python demos/ch29-time-index.py真实输出(节选):
=== ① 读进来的 timestamp 是字符串,不是时间 ===
dtype = str
字符串排序 vs 时间排序的区别:
字符串排序 sorted() : ['2023-02-01', '2023-06-01', '2023-10-01']
转成时间后排序 : ['2023-02-01', '2023-06-01', '2023-10-01']
=== ② to_datetime:转成 DatetimeIndex ===
转换后 dtype = datetime64[us]
=== ⑤ resample:按时间窗口聚合 ===
C001 按每 100 天(=100 圈)看 SOH 均值:
2023-01-01 0.9921
2023-04-11 0.9731
...
2025-03-11 0.8081
段数 = 9;首段 0.9921 → 末段 0.8081
观察:台阶越来越陡 —— 这就是「膝点(knee point)」效应
=== ⑧ ★ 静默错误:不排序就重采样 ===
打乱后索引单调吗? False
★ 结论:resample 前永远先 sort_index()。这不是可选项,是前提。本章练习
练习 1:按每 100 天聚合 SOH
取出 C001,把 timestamp 设为索引,按每 100 天聚合 SOH 均值并打印;再算出相邻两段的衰减速度。
参考答案
from pathlib import Path
import pandas as pd
pd.set_option("display.width", 170)
CODE_DIR = Path(__file__).resolve().parent.parent.parent
df = pd.read_csv(CODE_DIR / "data" / "battery" / "cycle_raw.csv")
# ① 取一支电芯 + 转时间类型
c1 = df[df["cell_id"] == "C001"].copy()
c1["timestamp"] = pd.to_datetime(c1["timestamp"])
# ② 设为索引 + 排序(两个都不能少)
c1 = c1.set_index("timestamp").sort_index()
assert c1.index.is_monotonic_increasing
# ③ 重采样
res = c1["soh"].resample("100D").mean()
print(res.round(4))
# 2023-01-01 0.9921
# 2023-04-11 0.9731
# 2023-07-20 0.9517
# 2023-10-28 0.9290
# 2024-02-05 0.9052
# 2024-05-15 0.8806
# 2024-08-23 0.8553
# 2024-12-01 0.8295
# 2025-03-11 0.8081
# 共 9 段
# ④ 衰减速度
print(res.diff().round(4))
# -0.0190, -0.0214, -0.0228, -0.0238, -0.0246, -0.0253, -0.0259, -0.0213三个要点:
resample要 9 段而不是 8.62 段 —— 因为 862 圈 = 8 个完整 100 天 + 62 天, 所以最后一段只有 62 天的数据。- 衰减速度越来越大(0.0190 → 0.0259)—— 这是膝点效应, 对应生成公式里的
β > 1(SOH = 1 - 0.20·c^β,凸函数)。 - 最后一段又变小了(0.0213)—— 因为它只有 62 天,不是完整窗口。 报告里引用这个数字时必须说明。
加分:验证"每 100 圈 ≈ 每 100 天"
print(c1.index[:3].tolist()) # 前 3 天
print(c1.index[-1]) # 最后一天
print((c1.index[-1] - c1.index[0]).days) # 861
print(len(c1) - 1) # 861 ← 完全吻合因为数据是"1 圈 = 1 天"合成的,所以"每 100 天"和"每 100 圈"是同一件事。
完整练习:exercises/ex29-01-time-index
练习 2:为什么不排序会出错?
c2 = df[df["cell_id"] == "C002"].copy()
c2 = c2.sample(frac=1.0, random_state=7) # 故意打乱
c2 = c2.set_index(pd.to_datetime(c2["timestamp"]))
print(c2.index.is_monotonic_increasing) # ?
print(c2["soh"].resample("100D").mean().shape) # ?要求:预测输出,运行验证,并说明为什么 sort_index() 不是可选项。
参考答案
print(c2.index.is_monotonic_increasing) # False
print(c2["soh"].resample("100D").mean().shape) # 会报错或结果不对报错信息(Pandas 3.0):
ValueError: index must be monotonic increasing or decreasing为什么 sort_index() 是硬性前提:
resample 的工作原理是"按时间切窗口"。它需要能够沿着时间轴 顺序扫描数据,判断"这个点属于哪个窗口"。
如果索引无序,扫描过程就无法确定窗口边界:
有序: 1/1, 1/2, 1/3, ..., 3/1, 3/2, ...
└── 窗口 1 ──┘ └── 窗口 2 ──┘
↑ 顺序扫描,边界清楚
无序: 3/1, 1/2, 3/2, 1/1, ...
↑ 扫描到 3/1 时,怎么知道"窗口 1 已经结束了"?所以 Pandas 选择直接报错(比静默给错结果好)。
★ 但"报错"不是永远的保障
有些操作在无序索引上不报错但给错结果。比如:
# 区间切片
c2.loc["2023-01-01":"2023-06-30"] # 可能返回空或报错所以纪律不能建立在"它会不会报错"上,而应该是:
任何按时间做的操作之前,先
sort_index(),并加一条断言。
标准写法:
def prepare_time_series(df, time_col="timestamp"):
"""转类型 → 设索引 → 排序 → 验证。四步一次做完。"""
out = df.copy()
out[time_col] = pd.to_datetime(out[time_col])
out = out.set_index(time_col).sort_index()
assert out.index.is_monotonic_increasing, f"{time_col} 索引必须单调递增"
return out
c2 = prepare_time_series(df[df["cell_id"] == "C002"])
print(c2["soh"].resample("100D").mean().shape) # (14,) 正常这个函数值得抄进你的工具库 —— 因为它把"四个容易漏的步骤"固化成一次调用。
练习 3:resample 之后缺失值怎么处理?
s = pd.Series([1.0, 2.0, 3.0],
index=pd.to_datetime(["2023-01-01", "2023-01-02", "2023-01-10"]))
r = s.resample("1D").mean()
print(r.isna().sum()) # ?
print(r.head(4).tolist()) # ?要求:说出输出,并给出两种补缺方式及其适用场景。
参考答案
print(r.isna().sum()) # 7 (1/3 到 1/9)
print(r.head(4).tolist()) # [1.0, 2.0, nan, nan]为什么是 7 个缺失:1/1 到 1/10 共 10 天,有数据的只有 3 天 (1/1、1/2、1/10),所以 7 天是 NaN。
★ 关键认知:resample 不会"跳过"空窗口,它会显式产出 NaN。
两种补缺方式:
① ffill() 前向填充
print(r.ffill().tolist())
# [1.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 3.0]适合:状态持续的场景。比如"设备开关状态"、"库存量"—— 在两次记录之间,值保持不变是合理的假设。
② interpolate() 线性插值
print(r.interpolate().round(4).tolist())
# [1.0, 2.0, 2.1111, 2.2222, 2.3333, 2.4444, 2.5556, 2.6667, 2.7778, 3.0]适合:平滑变化的场景。电池容量、温度、压力都属于这一类。
计算验证:从 2.0(1/2)到 3.0(1/10)线性插值 8 步, 每步增加 (3-2)/9 = 0.1111:
1/3: 2.0 + 0.1111 = 2.1111
1/4: 2.0 + 0.2222 = 2.2222
...
1/9: 2.0 + 0.7778 = 2.7778
1/10: 3.0(原始值)★ 但这里有一个陷阱
上面的插值发生在"单一时间序列"上,所以是对的。
如果在多实体的表上直接插值,就会出错:
# ❌ 危险:整张表插值(跨电芯污染)
df[["soh"]].interpolate()
# ✅ 正确:先按电芯分组
df = df.sort_values(["cell_id", "cycle"])
df[NUM] = df.groupby("cell_id")[NUM].transform(
lambda g: g.interpolate(limit_direction="both"))这就是第 30 章的核心坑 —— 记住这个词:邻居必须来自同一个业务实体。
面向 AI 开发:时间序列是 AI 里最容易泄漏信息的地方
时间序列任务有一个其他任务没有的风险:你能"看到未来"。
三个典型的泄漏模式:
① 随机切分训练/测试集。
# ❌ 危险
train = df.sample(frac=0.8, random_state=42)
test = df.drop(train.index)为什么危险:同一支电芯相邻两圈的 SOH 几乎一样(r = 0.998)。 随机切分后,测试集里会有大量"和训练集几乎完全相同"的样本 —— 评估结果虚高。
正确做法:按时间切分。
# ✅ 用过去预测未来
cut = df["timestamp"] < "2024-06-01"
train, test = df[cut], df[~cut]更严格的是按实体切分(GroupKFold):
from sklearn.model_selection import GroupKFold
gkf = GroupKFold(n_splits=4)
for tr, te in gkf.split(df, groups=df["cell_id"]):
# 保证同一支电芯的数据不跨训练/测试
...② 用"全周期统计量"当特征。
# ❌ 泄漏:预测"第 100 圈的 SOH",但用了整支电芯的均值
df["cell_soh_mean"] = df.groupby("cell_id")["soh"].transform("mean")
# ✅ 安全:只用当前及之前
df["soh_mean_so_far"] = df.groupby("cell_id")["soh"].transform(
lambda s: s.expanding().mean()
)第 28 章详细讲过这个区别。
③ 用未来的滞后特征。
# ❌ 用了下一圈的信息预测当前圈
df["soh_next"] = df.groupby("cell_id")["soh"].shift(-1)
# ✅ 只用过去
df["soh_prev"] = df.groupby("cell_id")["soh"].shift(1)
df["soh_diff"] = df.groupby("cell_id")["soh"].diff(1)shift(1) 取"前一行",shift(-1) 取"后一行" —— 后者在预测任务里通常是泄漏。
★ 一个自检问题
"在预测发生的那一刻,这个特征的值真的能拿到吗?"
用这个问题检查每一个特征:
| 特征 | 预测第 100 圈 SOH 时能拿到吗 |
|---|---|
cycle(=100) | ✅ 能 |
soh_mean_so_far(前 100 圈均值) | ✅ 能 |
soh_prev(第 99 圈) | ✅ 能 |
cell_soh_mean(整支电芯均值) | ❌ 不能(用了第 100 圈之后的数据) |
soh_next(第 101 圈) | ❌ 不能 |
n_cycles_to_eol(整支电芯寿命) | ❌ 不能(这是要预测的东西,或它的强相关代理) |
最后一个特别值得注意:本课程的数据里有一列 cells.csv 包含 n_cycles_to_eol(真实寿命)—— 那是"上帝视角"的信息, 只能用来评价模型,绝不能当特征。
★ 时间序列验证的正确姿势
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5)
for train_idx, test_idx in tscv.split(X):
# 每次都是"用前面的数据训练,用后面的数据测试"
...TimeSeriesSplit 的行为:
Fold 1: [Train: 1~100] [Test: 101~200]
Fold 2: [Train: 1~200] [Test: 201~300]
Fold 3: [Train: 1~300] [Test: 301~400]
...每一折都严格遵守"训练集在时间上早于测试集" —— 这才是真实的部署场景。
★ 最后的提醒
时间序列是 AI 里最容易犯"看起来很成功"的错误的领域: 泄漏会让指标漂亮得不像话,而你可能要等到上线后才知道。
所以本章的 sort_index 纪律,和上面的"三个泄漏模式", 值得在每次做时间序列任务时都过一遍。
下一章:第 30 章 · 缺失值处理