Skip to content

第 29 章 · 时间序列索引与重采样 ​

这一章要解决什么:按时间维度聚合数据(每 100 天、每月、每年)。 打掉两个静默错误:忘了 to_datetime(字符串排序会乱序);忘了 sort_index(重采样结果错乱)。

本章你需要完成的代码:demos/ch29-time-index.py

29.1 读进来的 timestamp 是字符串 ​

python
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 章提过):它不会自动把日期列转成日期类型。

点开:字符串排序会有多离谱?

问题的根源:字符串比较是按字符逐位比的(字典序)。

python
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 格式的设计巧妙之处。

但换个格式就崩了:

python
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 前面。

更危险的是"看起来没错但其实是错的":

python
# 如果日期带时间戳,格式是 "2023-01-01 08:30:00"
# 排序本身没问题,但:
# - 无法按"月"聚合(因为没有日期语义)
# - 无法计算时间差("2023-01-02" - "2023-01-01" 会报 TypeError)
# - 无法用 .dt 访问器提取年月日

结论:日期列读进来第一件事就是 pd.to_datetime。

python
df["timestamp"] = pd.to_datetime(df["timestamp"])
print(df["timestamp"].dtype)          # datetime64[us]

或者读的时候直接转:

python
df = pd.read_csv(path, parse_dates=["timestamp"])

本书推荐"读完后显式转" —— 因为那一步 print(df["timestamp"].dtype) 更容易被注意到, 而且转换失败时(比如有脏数据)你能明确知道是这一步出的问题。

29.2 .dt 访问器:从时间列派生特征 ​

转成日期类型后,才能用 .dt 访问器:

python
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

真实输出:

text
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 是必须的:

python
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

python
df["days_since_start"] = (df["timestamp"] - df["timestamp"].min()).dt.days

单调递增、有方向 —— 模型能学到"越往后 SOH 越低"。

② 周期性:month / dayofweek / hour

python
df["month"] = df["timestamp"].dt.month
df["dayofweek"] = df["timestamp"].dt.dayofweek

表示"循环往复"的模式 —— 比如"夏季温升更高"、"周一产量低"。

⚠ 周期特征的陷阱:month 的 12 和 1 是相邻的,但数值上差 11。

修复方式之一(正弦/余弦编码):

python
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 月很接近"。

③ 时间差:两个时间列相减

python
df["duration"] = (df["end_time"] - df["start_time"]).dt.total_seconds()

★ 什么时候不该把时间当特征

情况处理
时间列只是记录时间(和业务无关)不用(比如"这条数据什么时候入库的")
时间是用来切分训练/测试的不用当特征(否则泄漏)
时间本身有物理含义(老化、季节)一定要派生
时间表示**"距某个事件多久"**派生差值(比原始时间戳有用得多)

判断方法:

python
# 看目标变量是否随时间有趋势
df.groupby(df["timestamp"].dt.month)["soh"].mean().plot()
# 如果曲线有明显趋势或周期 → 时间是有用的特征

29.3 set_index + sort_index:resample 的前提 ​

python
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:按时间窗口聚合 ​

python
res = c1["soh"].resample("100D").mean()
print(res.round(4))

真实输出:

text
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: 100D

resample 的语义:把时间轴切成固定长度的窗口,每个窗口算一个聚合值。

原始(862 个点,每天一个):
1/1 1/2 1/3 ... 12/31 | 1/1 ... 4/10 | 4/11 ... | ...
└──── 前 100 天 ────┘ └─ 第 101~200 天 ─┘
       ↓ mean                ↓ mean
     0.9921                0.9731

resample = groupby 的时间版本:

groupbyresample
分组依据列的值时间窗口
前提无索引是时间且有序
例groupby("cell_id")resample("100D")

第 28 章的"切-算-合"心智模型完全适用。

常用频率字符串 ​

python
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 起):

python
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 圈",语义清楚。

读结果:膝点效应 ​

python
print(res.diff().round(4))
text
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)效应:电池老化到后期会加速下降。

对应生成公式:

SOH=1−0.20⋅cβ

当 β > 1 时,c^β 是凸函数 —— 前期增长慢、后期增长快, 所以 SOH 是"前期掉得慢、后期掉得快"。

真实电池也有这个现象,而且它是寿命预测的关键: 如果能提前发现"膝点"开始,就能准确预测剩余寿命。

最后一段(-0.0213)为什么变小了:因为最后一段不满 100 天 (C001 只有 862 圈,862 = 8×100 + 62),所以它的均值只反映了 62 天的衰减。

这是个值得注意的细节:

python
print(res.index[-1])          # 2025-03-11
print(len(c1))                # 862
print(8 * 100, 862 - 800)     # 800 62    ← 最后一段只有 62 天

报告里如果引用"最后一段的衰减速度",要说明"该段不足 100 天"。

29.5 ⚠ resample 不会"补齐"空窗口 ​

python
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())
text
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 把决策权交给你。

三种补齐方式:

python
# ① 前向填充(用上一个有效值)—— 适合"状态持续"的场景
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 章会讲一个更重要的坑:必须先分组再插值。

如果你直接对"整张表"插值:

python
# ❌ 危险:跨电芯插值
df[["soh"]].interpolate()

这会用 C001 的末圈去补 C002 的首圈 —— 数据污染。

正确做法(第 30 章):

python
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 ⚠ 静默错误:不排序就重采样 ​

这是本章最危险的一个坑 —— 它可能不报错,只是给出错误结果。

python
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"算一个窗口?
还是按时间正确分组?   → 取决于具体实现,但结果一定不是你要的

所以:无论报不报错,都要先排序。

正确的流程:

python
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()

★ 加一条断言是好习惯:

python
assert df.index.is_monotonic_increasing, "resample 前索引必须有序"

这一行代码的成本是零,收益是"不会在半夜发现结论错了"。

29.7 按日期区间筛选 ​

python
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"] 直接写年份(部分字符串索引),会匹配那一年所有的记录。

⚠ 前提同样是索引有序:

python
# 索引没排序时,区间切片会报错或返回空

这是"有序"这个前提第三次出现了(resample、区间切片、以及第 30 章的插值):

按时间操作之前,先 sort_index()。

一个实用的技巧:把"排序 + 断言"包装成一个函数。

python
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 完整示例与输出 ​

bash
cd code
python demos/ch29-time-index.py

真实输出(节选):

text
=== ① 读进来的 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 均值并打印;再算出相邻两段的衰减速度。

参考答案
python
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

三个要点:

  1. resample 要 9 段而不是 8.62 段 —— 因为 862 圈 = 8 个完整 100 天 + 62 天, 所以最后一段只有 62 天的数据。
  2. 衰减速度越来越大(0.0190 → 0.0259)—— 这是膝点效应, 对应生成公式里的 β > 1(SOH = 1 - 0.20·c^β,凸函数)。
  3. 最后一段又变小了(0.0213)—— 因为它只有 62 天,不是完整窗口。 报告里引用这个数字时必须说明。

加分:验证"每 100 圈 ≈ 每 100 天"

python
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:为什么不排序会出错?
python
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() 不是可选项。

参考答案
python
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 选择直接报错(比静默给错结果好)。

★ 但"报错"不是永远的保障

有些操作在无序索引上不报错但给错结果。比如:

python
# 区间切片
c2.loc["2023-01-01":"2023-06-30"]      # 可能返回空或报错

所以纪律不能建立在"它会不会报错"上,而应该是:

任何按时间做的操作之前,先 sort_index(),并加一条断言。

标准写法:

python
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 之后缺失值怎么处理?
python
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())  # ?

要求:说出输出,并给出两种补缺方式及其适用场景。

参考答案
python
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() 前向填充

python
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() 线性插值

python
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(原始值)

★ 但这里有一个陷阱

上面的插值发生在"单一时间序列"上,所以是对的。

如果在多实体的表上直接插值,就会出错:

python
# ❌ 危险:整张表插值(跨电芯污染)
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 里最容易泄漏信息的地方 ​

时间序列任务有一个其他任务没有的风险:你能"看到未来"。

三个典型的泄漏模式:

① 随机切分训练/测试集。

python
# ❌ 危险
train = df.sample(frac=0.8, random_state=42)
test = df.drop(train.index)

为什么危险:同一支电芯相邻两圈的 SOH 几乎一样(r = 0.998)。 随机切分后,测试集里会有大量"和训练集几乎完全相同"的样本 —— 评估结果虚高。

正确做法:按时间切分。

python
# ✅ 用过去预测未来
cut = df["timestamp"] < "2024-06-01"
train, test = df[cut], df[~cut]

更严格的是按实体切分(GroupKFold):

python
from sklearn.model_selection import GroupKFold
gkf = GroupKFold(n_splits=4)
for tr, te in gkf.split(df, groups=df["cell_id"]):
    # 保证同一支电芯的数据不跨训练/测试
    ...

② 用"全周期统计量"当特征。

python
# ❌ 泄漏:预测"第 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 章详细讲过这个区别。

③ 用未来的滞后特征。

python
# ❌ 用了下一圈的信息预测当前圈
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(真实寿命)—— 那是"上帝视角"的信息, 只能用来评价模型,绝不能当特征。

★ 时间序列验证的正确姿势

python
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 章 · 缺失值处理