机器学习特征工程:日期时间变量处理与时间特征提取实战
2026/9/9 17:12:33 网站建设 项目流程

作为一个做机器学习项目的开发者,你可能有过这样的经历:拿到一份数据,发现里面有“下单时间”“注册日期”“最后一次登录时间”这几列,心里第一个反应是“这有什么用?直接删掉算了”,或者干脆把它们当成普通字符串丢给模型去训练。等模型跑完,效果不理想,又不知道问题出在哪里。

处理日期和时间变量,是特征工程里最容易被低估、也最容易做错的环节之一。它不像归一化、独热编码那样有明确套路,也不像缺失值填充那样一个问题对应一个方案。很多教程只是轻描淡写一句“把日期拆成年月日”,但如果真按这个思路去做,你会发现:拆完之后模型没变好,反而引入了大量无用特征;或者你精心构造的时间特征,在训练集和测试集上分布完全不一致,模型上线后效果崩盘。

这篇教程不打算重复“把日期拆成年月日”这种泛泛而谈的内容。我会从日期时间变量的本质出发,讲清楚它为什么对机器学习重要,在什么场景下必须处理,以及真正在工程里可落地的一套处理流程。内容对应 100 天机器学习 CampusX 课程第 34 天的主题,面向正在学习特征工程的初学者,也适合建模时经常被时间变量坑到的进阶读者。

读完这篇文章,你会知道:如何用 Python 正确解析日期时间数据,如何从时间戳中提取真正有用的特征,为什么“时间差”往往比“绝对时间”更有价值,以及循环时间特征如何处理才能被线性模型和树模型同时接受。

1. 这篇文章真正要解决的问题

先说一个很多人没有意识到的判断:日期时间变量不是普通特征,它是一类同时包含“连续性”“周期性”“事件性”三种信息形态的高密度变量。

这句话什么意思?我们拆开看。

普通数值特征,比如“用户年龄”“商品价格”,它们的取值是连续的,数值大小本身有意义,模型可以直接拿去做切分点和距离计算。普通类别特征,比如“商品分类”“用户城市”,它们的取值是离散的,需要通过独热编码或标签编码转成模型能懂的形式。

日期时间变量比这两类都复杂。它首先是一个数值,比如2024-01-15 14:30:00,转成时间戳之后是一个很大的整数;但它又不是普通数值,因为“2024年1月15日”和“2024年1月16日”之间只差一天,而在时间戳数值上这两者相差 86400,这个差距的意义取决于你的业务场景。它有周期性,一年 12 个月循环、一周 7 天循环、一天 24 小时循环,单纯的数值编码无法表达“周一和周日是同类、周二和周六也是同类”这种关系。它还有事件性,比如“下单时间距离上一个促销活动过去了多少小时”,这是一种由领域知识驱动的特征,无法靠自动特征工程生成。

正因为它有这么多种信息形态,处理起来才特别容易出问题。如果什么都不做,直接把原始时间戳丢给模型,大部分模型会把它当成一个大数值,结果就是:模型把 2015 年之前的数据全部判为一类,2015 年之后的数据判为另一类,完全学不到时间背后的规律。如果只是简单拆成年月日,又会遇到一个新问题:日期特征在模型训练集和测试集之间的分布可能差异巨大——训练集全部在上半年,测试集在下半年,拆出来的“月”特征在训练集上只有 1 到 6,测试集上却有 7 到 12,模型根本没法处理这种分布外取值。

所以这篇文章要帮你解决的核心问题是:在 Python 中建立一套从原始日期时间到可用机器学习特征的完整处理流程,并理解每一步背后的原因,而不是给你二十个零散的代码片段。

什么样的读者最应该认真读这篇文章?

  • 正在做 Kaggle 比赛或课程项目,数据里有时间字段但不知道怎么用的人。
  • 做推荐系统、风控模型、销量预测、用户行为分析,需要从日志时间戳里构造特征的人。
  • 学完基础特征工程,发现时间变量这块总是“道理都懂,一上手就乱”的人。

如果你只是随便跑几个 sklearn 内置数据集练手,感觉时间变量暂时用不上,那这篇文章也可以先收藏,等你第一次在真实项目里遇到“时间字段到底要不要进模型”这个问题时再翻出来看。

2. 日期时间变量的基础概念与内在属性

在进入代码之前,我们必须先把基本概念理清楚。机器学习的特征工程不是写业务代码,你对数据形态的理解直接决定后面怎么做特征。

2.1 日期(Date)、时间(Time)和时间戳(Timestamp)

这三个词在中文里经常混用,但在数据处理层面有严格区别。

日期(Date)指的是“年-月-日”,比如2024-03-15,颗粒度是天。时间(Time)指的是“时:分:秒”,比如14:30:00,它通常和日期绑定在一起才有业务含义。时间戳(Timestamp)是完整的时间点,比如2024-03-15 14:30:00,在很多数据库和日志系统里,它可能以字符串形式存在,也可能以 Unix 时间戳(从 1970 年 1 月 1 日 0 时 0 分 0 秒起计算的总秒数)形式存在。

在 Python 里,这三者有对应的数据类型:

  • datetime.date:只存储日期。
  • datetime.time:只存储时间。
  • datetime.datetime:日期加时间。
  • pandas.Timestamp:pandas 中的时间标量类型,功能比datetime.datetime更丰富,是表格数据处理的主力。

一个很容易犯的错误是:用字符串类型直接存储时间。比如 CSV 文件里读进来,某些列的类型是object,里面全是"2024-03-15 14:30:00"这样的文本。在字符串状态下,你没法做任何时间运算,也没法比较大小,只能把它当成类别特征,这对模型毫无帮助。

2.2 为什么时间变量不能直接当数值用

很多人会有这样的疑问:时间戳本质是一个数字,为什么不能直接把它当作数值特征喂给机器学习模型?

这个问题的答案取决于模型类型。对于线性回归、逻辑回归这类模型,特征是连续数值才能参与加权求和,时间戳确实可以被当成数值,但它的问题在于:时间戳的真实物理含义是“从某个基准点算起的秒数”,这个数值的尺度和变化幅度,与业务目标的数值尺度很可能不在同一个量级。

举个例子,一个用户行为数据集的记录时间为 2021 年到 2024 年,对应的时间戳数值是 16 亿到 17 亿这个量级。如果你直接把这个数字和用户年龄、点击次数等特征一起喂给线性模型,模型会把几乎所有权重都花在时间戳这个天文数字上,其他特征全部被压制。就算你做了标准化,时间戳变成 0 到 1 之间的数值,模型学习到的也只是“记录越晚的用户行为模式越不一样”这个线性假设,可时间对业务的影响往往是周期性的、非线性的,这种假设过于粗糙。

对于决策树、随机森林、XGBoost、LightGBM 这类树模型,情况稍微好一些,因为树模型对特征尺度不敏感,只需要找到一个切分点。但问题依然存在:树模型只能做“大于某个时间戳”和“小于某个时间戳”的切分,它学到的边界是某个具体时间点。这个时间点的选择完全由训练数据的分布决定,如果训练数据和线上数据的时间范围不一致,这个切分点就没有意义。

所以,把原始时间戳直接喂给模型,本质上是在强迫模型用最原始的方式去理解时间。更好的做法是:我们人先把时间背后的规律提取出来,再用特征的形式交给模型。

2.3 时间变量的三个基本属性

我把它归纳成三个属性,方便你后面做特征工程时对照着思考。

第一个是连续性。时间是一个从过去指向未来的箭头,所有事件在时间轴上都有先后顺序。这种属性意味着“最近 X 天下单次数”“距上次登录的小时数”这类差值特征是有意义的,它们本身就是连续变量。

第二个是周期性。地球绕着太阳转一圈是 365 天,这是一个循环;一天 24 小时,也是一个循环。很多业务行为天然带有周期模式,比如工作日和周末的流量差异、早晚高峰的下单差异、节假日和普通日的销量差异。周期属性不能用简单的数值编码表达,因为“23 点和 1 点在数值上差得很远,但在业务上非常接近”。

第三个是事件性。某些时间点本身带有事件含义,比如“2024-01-01”是元旦,“2024-02-10”是春节,“2024-11-11”是大促日。这种事件属性是领域知识的一部分,需要业务经验来标注,单纯靠数据解析无法获得。

3. 环境准备与前置条件

实操部分我们统一使用 Python 生态,需要准备的环境和库如下:

  • Python 3.8 及以上版本(版本请以你的实际环境为准,本文代码在 Python 3.10 下验证可用)。
  • pandas 库,版本 1.5 及以上,用于表格数据读取和日期时间处理。
  • numpy 库,用于数值计算。
  • matplotlib 库,用于可视化时间特征分布。
  • scikit-learn 库,用于特征编码和模型训练验证。

如果你还没有安装相关依赖,可以创建一个干净的虚拟环境,然后执行以下命令:

pip install pandas numpy matplotlib scikit-learn

为了演示,我们使用一份模拟的电商订单数据。这个数据集的字段包括:

字段名含义
order_id订单编号
user_id用户编号
order_time下单时间(字符串格式)
delivery_time送达时间(字符串格式)
order_amount订单金额(元)

原始数据以 CSV 格式存储,内容大致如下:

order_id,user_id,order_time,delivery_time,order_amount 1001,U001,2024-01-03 09:24:15,2024-01-04 18:30:42,156.80 1002,U002,2024-01-03 10:12:07,2024-01-03 21:45:12,89.50 1003,U001,2024-01-05 20:36:51,2024-01-06 15:20:08,230.00 1004,U003,2024-01-07 08:15:33,2024-01-07 12:40:29,45.90 1005,U002,2024-01-10 23:58:10,2024-01-12 10:05:00,312.40

请注意,这里order_timedelivery_time在 CSV 中是以字符串形式存储的,需要先用 pandas 解析成真正的日期时间类型,才能做后续处理。

4. 日期时间变量的读取与预处理

这一节是整个处理流程的第一步,也是最容易出错的一步。如果原始时间数据没有正确解析,后面所有特征工程都是空中楼阁。

4.1 用 pandas 读取并识别日期时间列

首先读取 CSV 文件,然后查看每一列的数据类型:

import pandas as pd df = pd.read_csv("order_data.csv") print(df.dtypes)

输出结果中,order_timedelivery_time应该是object类型,也就是字符串。这意味着我们无法直接进行时间运算,比如计算配送时长、比较先后顺序、提取星期几等等。

接下来用pd.to_datetime()对这两列进行转换:

df["order_time"] = pd.to_datetime(df["order_time"]) df["delivery_time"] = pd.to_datetime(df["delivery_time"]) print(df.dtypes)

转换后可以看到order_timedelivery_time的数据类型变为datetime64[ns],这才是 pandas 真正识别的时间类型。

这里有一个常见坑:to_datetime()默认使用%Y-%m-%d %H:%M:%S这样的格式来解析字符串。如果 CSV 文件里的时间格式不是这种标准格式,比如2024/03/15 14:3015-03-2024 14:30:00,直接调用to_datetime()可能报错或解析失败。

推荐的做法是在调用时显式指定format参数:

df["order_time"] = pd.to_datetime( df["order_time"], format="%Y-%m-%d %H:%M:%S" )

指定format有两个好处:一是解析速度比自动推断快得多;二是遇到不符合格式的数据会立刻报错,不会悄悄地把错误数据解析成缺失值。

如果遇到多种格式混杂的情况,可以先看一下到底有哪几种格式,再逐个处理,不要试图用一个format参数解决所有问题。

4.2 时间索引的真实作用

有些教程会建议你把日期列设为 DataFrame 的索引,理由是“时间序列数据处理更方便”。但这个建议并不总是适用于机器学习建模。

在机器学习任务中,日期列通常是特征而不是索引。设置成索引之后,如果你做特征工程时不小心用了reset_index()或按索引对齐,很可能会引入不易察觉的 bug。

更稳妥的工程实践是:保留原始时间列作为普通列,同时复制一份作为显式特征列来使用。这样既能追溯原始数据,又能避免索引操作带来的风险。

4.3 非法值、缺失时区和时间精度问题

处理时间变量时最隐蔽的错误是非法值和时区问题。

非法值指的是类似"2024-02-30"这样的日期,它是不存在的日期。pd.to_datetime()默认遇到这种值会抛出异常。如果你希望解析时不要中断整个流程,可以在to_datetime()中加上errors="coerce"参数,这样非法值会被转换成NaT(Not a Time,pandas 中时间缺失值的表示)。

df["order_time"] = pd.to_datetime( df["order_time"], format="%Y-%m-%d %H:%M:%S", errors="coerce" )

之后要检查是否存在NaT

print(df["order_time"].isna().sum())

如果确实存在缺失值,需要根据业务场景决定是删除还是填充。比如下单时间缺失,这属于核心字段,建议删除该行;如果是某些辅助字段,可以考虑用其他时间列填充。

另外是时区问题。如果你的数据来自多个地区,或者涉及跨国业务,时间列可能带有UTC+08:00这样的后缀。pandas 在解析带时区的时间字符串时,需要先统一时区,否则会出现不同类型不能直接比较的报错。

df["order_time"] = pd.to_datetime(df["order_time"], utc=True)

统一成 UTC 后,再根据业务需求转换为目标时区:

df["order_time"] = df["order_time"].dt.tz_convert("Asia/Shanghai")

需要特别提醒:时区转换不是简单的字符串替换,它涉及夏令时、历史时区变化等复杂规则,建议优先使用成熟的库来处理,不要自己写时区偏移逻辑。

5. 从日期时间变量中提取特征

处理好基础解析之后,进入整个内容的核心部分:特征提取。处理日期和时间变量的核心任务,就是把这些时间点转化为对模型有区分能力的数值或类别特征。

5.1 时间顺序特征:时间戳特征

在某些模型中可以保留时间戳的原始数值形式,但需要经过恰当处理。

最直接的做法是把时间戳转换成 Unix 时间戳(从 1970 年 1 月 1 日开始算起的秒数):

df["order_timestamp"] = df["order_time"].astype("int64") // 10**9

但正如前文讨论的,这个特征的尺度太大,一般需要做缩放或标准化后再使用。

从特征工程的角度看,更推荐的方式是设定一个参考时间,计算“距离参考时间经过了多少天”。参考时间可以是数据集中最早的时间,也可以是某个有业务含义的时间点:

df["days_from_start"] = (df["order_time"] - df["order_time"].min()).dt.days

这样得到的特征分布在 0 到几百的范围内,对线性模型友好得多,同时保留了“事件发生先后顺序”的信息。

这种特征适合的场景是:业务规律随时间线性变化,比如用户忠诚度随时间稳步增长、平台活跃度随时间缓慢上升等。

5.2 时间成分特征:年、月、日、时等

这是最常用的日期特征提取方式。从日期时间中提取出年月日、时分秒等时间成分,让模型能够感知到时间模式。

df["year"] = df["order_time"].dt.year df["month"] = df["order_time"].dt.month df["day"] = df["order_time"].dt.day df["hour"] = df["order_time"].dt.hour df["minute"] = df["order_time"].dt.minute df["dayofweek"] = df["order_time"].dt.dayofweek df["day_name"] = df["order_time"].dt.day_name()

其中:

  • dt.year返回年份,比如 2024。
  • dt.month返回月份,取值范围 1 到 12。
  • dt.day返回日期,取值范围 1 到 31。
  • dt.hour返回小时,取值范围 0 到 23。
  • dt.minute返回分钟,取值范围 0 到 59。
  • dt.dayofweek返回星期几,0 表示周一,6 表示周日。
  • dt.day_name()返回星期的英文名称,比如 “Monday”,这个特征适合作为类别特征做标签编码或独热编码。

关键问题来了:这些成分特征,应该当数值特征用还是当类别特征用?

我的建议是:

  • year包含趋势信息,可以当数值特征使用,但要注意年份跨度如果很大,模型可能过拟合某一年数据。
  • monthhourdayofweek这类周期性较强的成分,最好不要单纯当数值特征。原因很简单:如果把hour当数值,模型会学到“凌晨 0 点和 1 点差别不大,23 点和 0 点差别最大”,但真实业务是“0 点和 23 点都是深夜时段,用户行为很接近”。数值编码扭曲了这种周期性。
  • month可以当类别特征使用,也可以配合后面的循环特征使用;day单独使用意义不大,除非你有明确理由相信月初、月中、月末有不同业务模式。

5.3 时间差特征:绝对时间没有意义,相对时间才有

这是日期时间特征工程中价值最高的一类特征。在很多实际业务中,“某个时间点本身”并不重要,重要的是“两个时间点之间的差值”。

最典型的例子就是配送时长:从下单到送达用了多少个小时。这个特征是用户非常关心的指标,也是物流公司优化运营的核心目标。

df["delivery_duration_hours"] = ( df["delivery_time"] - df["order_time"] ).dt.total_seconds() / 3600.0

这里用total_seconds()得到秒数,再除以 3600 换算成小时。值得特别注意的是,dt.daysdt.total_seconds()是不同的。当两个时间差跨度较大时,dt.days只返回天数的整数部分,如果差值不是整天数,dt.days会忽略小数部分,这在你需要精确时长时不适用。

再比如,如果数据中有同一用户的多笔订单,我们可以计算该用户当前订单距离上一笔订单的时间间隔:

df.sort_values("order_time", inplace=True) df["previous_order_time"] = df.groupby("user_id")["order_time"].shift(1) df["gap_from_previous_order_hours"] = ( df["order_time"] - df["previous_order_time"] ).dt.total_seconds() / 3600.0 df.loc[df["previous_order_time"].isna(), "gap_from_previous_order_hours"] = -1

这段代码的逻辑是:先按时间排序,然后按user_id分组,把同一用户上一笔订单的下单时间移到当前行,再计算两个时间的差。

这里有一个容易被忽视的坑:gap_from_previous_order_hours对于每个用户的第一个订单是没有值的。很多人在这一步直接填 0,这是不对的。0 表示“距离上一笔订单 0 小时”,有“上一笔订单和当前订单同时发生”的含义,对模型是一种错误引导。更合理的做法是填一个特殊的负数比如 -1,或者单独用一个布尔特征 “是否为该用户的首笔订单” 来标记:

df["is_first_order"] = df["previous_order_time"].isna().astype(int) df.fillna({"gap_from_previous_order_hours": -1}, inplace=True)

这种先分组、再排序、再计算差值的模式,在用户行为分析、推荐系统、反欺诈模型中非常实用。

5.4 循环时间特征:用 sin 和 cos 编码时间周期性

现在回到之前提到过的周期性问题。我们把hour直接当数值特征使用时,模型学到的是“1 点和 23 点在数值上相距很远”,但从业务角度来看,这两者都是深夜,行为模式非常相似。有没有办法在数值上体现这种“循环接近”的关系?

答案是循环时间特征。核心思想是:把时间映射到单位圆上,用正弦和余弦函数来表示时间的周期性。

具体做法:

import numpy as np def encode_cyclical_feature(values, period): """将具有周期性的数值特征转换为 sin/cos 两列特征。""" values = np.asarray(values, dtype=float) sin_feat = np.sin(2 * np.pi * values / period) cos_feat = np.cos(2 * np.pi * values / period) return sin_feat, cos_feat df["hour_sin"], df["hour_cos"] = encode_cyclical_feature(df["hour"], 24) df["month_sin"], df["month_cos"] = encode_cyclical_feature(df["month"], 12) df["dayofweek_sin"], df["dayofweek_cos"] = encode_cyclical_feature(df["dayofweek"], 7)

转换之后,原来的hour一个特征变成了hour_sinhour_cos两个特征。这两个特征形成一个二维坐标,落在单位圆上。0 点对应 (0, 1),6 点对应 (1, 0),12 点对应 (0, -1),18 点对应 (-1, 0)。

通过这种转换,“1 点和 23 点”在二维平面上会落在几乎相同的位置,因为两者的角度非常接近。这个属性对线性模型和神经网络非常有价值,因为它们可以学习到这两个特征的加权组合,从而表达出圆形空间中的距离关系。

树模型也能从这个编码中受益,最直接的体现在于:树模型需要找到一个切分点来区分“白天”和“夜晚”,如果只有原始hour值,切分节点可能是hour <= 7;有了sincos之后,树模型可以组合“hour_sin小于某个值且hour_cos大于某个值”这种落在二维空间的手段,能更精细地捕捉时间段。

不过有一点要提醒:加入循环特征不代表原始数值特征必须废弃。在很多比赛中,同时保留原始hour数值特征和sin/cos特征,树模型反而获得了更多信息。

5.5 业务时间特征:是否周末、是否节假日、是否工作时间

日期时间变量中的第三类特征是事件性,也就是业务时间特征。这一类特征完全由领域知识驱动。

最简单的两个特征是:

df["is_weekend"] = df["dayofweek"].isin([5, 6]).astype(int) df["is_night"] = df["hour"].isin([22, 23, 0, 1, 2, 3, 4, 5]).astype(int)

is_weekend表示是否周末,适合电商、外卖、娱乐类业务。工作日和周末的用户行为模式差异通常非常大。

is_night表示是否夜间时段。比如生鲜配送、外卖服务等,夜间的订单规模会大幅下降,但这个特征对这些业务的供需预测非常重要。

更复杂的业务特征需要结合外部数据。比如节假日特征,简单的办法是维护一个节假日列表:

holidays = pd.to_datetime([ "2024-01-01", # 元旦 "2024-02-10", # 春节 "2024-04-04", # 清明节 "2024-05-01", # 劳动节 "2024-06-10", # 端午节 "2024-09-17", # 中秋节 "2024-10-01", # 国庆节 ]) df["is_holiday"] = df["order_time"].dt.normalize().isin(holidays).astype(int)

这里用dt.normalize()把时间归一化到当天零点,再去匹配节假日列表。

如果有精力,还可以计算“距离最近节假日的天数”,这个特征对销量预测非常有用。比如距离大促还有 3 天,用户开始提前加购;大促结束了 2 天,销量开始回落。

# 简化示例:取 2024 年全年日期,计算与最近节假日的间隔 all_days = pd.date_range("2024-01-01", "2024-12-31", freq="D") holiday_flags = all_days.isin(holidays).astype(int) # 找到最近一个节假日的偏移天数(正数表示距离之后最近的节假日) nearest_holiday_diff = [] for day in all_days: diff = (holidays - day).days future_diffs = diff[diff >= 0] past_diffs = abs(diff[diff < 0]) if len(future_diffs) > 0: nearest_future = future_diffs.min() else: nearest_future = 999 if len(past_diffs) > 0: nearest_past = past_diffs.min() else: nearest_past = 999 nearest_holiday_diff.append(min(nearest_future, nearest_past)) holiday_offset_map = dict(zip(all_days, nearest_holiday_diff)) df["days_to_nearest_holiday"] = df["order_time"].dt.normalize().map(holiday_offset_map)

这类特征需要你对自己的业务场景有足够理解,不能机械套用。盲目的做法是试图把所有节假日都做成特征,结果导致特征爆炸。

5.6 聚合时间特征:按用户或类别统计时间段特征

最后一类时间特征是通过聚合方法生成的。不是对单条时间记录做变换,而是对某类对象在某个时间段内的行为做统计。

比如,计算每个用户在“最近 7 天”的下单次数:

df.sort_values("order_time", inplace=True) df["user_recent_7d_orders"] = ( df.groupby("user_id")["order_time"] .rolling("7D", min_periods=1) .count() .reset_index(level=0, drop=True) )

这里的.rolling("7D")是 pandas 的一种时间窗口滚动功能,只能在索引是日期时间类型时使用。如果你没有把order_time设为索引,会报错;如果设了索引,groupby之后的 rolling 操作又会变得复杂,容易出错。

需要提醒的是,类似这种“最近 7 天下单次数”的特征,在训练集和测试集之间要特别注意数据泄漏问题。如果测试集的时间范围紧跟训练集之后,用训练集最后 7 天数据计算出的聚合特征,无法直接用于测试集第一天的预测,因为你还没有那天的“未来数据”。在工程上,通常需要构建一个离线特征库,按时间窗口定时更新统计结果。

6. 完整示例:从订单时间到可建模特征集

现在我们用一个完整的示例,把前面的内容串起来。这个示例的目标是:输入一份带有原始时间字段的订单表,输出一份可以被机器学习模型直接使用的特征表。

假设我们的任务是预测“订单配送时长”,也就是从下单到送达的小时数。用户特征和订单特征中都有时间字段,我们要把它们统一转换成模型特征。

完整代码如下:

import pandas as pd import numpy as np # 1. 读取数据,解析时间列 df = pd.read_csv("order_data.csv", parse_dates=["order_time", "delivery_time"]) # 2. 基础时间成分特征 df["year"] = df["order_time"].dt.year df["month"] = df["order_time"].dt.month df["day"] = df["order_time"].dt.day df["hour"] = df["order_time"].dt.hour df["dayofweek"] = df["order_time"].dt.dayofweek # 3. 循环特征 def encode_cyclical_feature(values, period): values = np.asarray(values, dtype=float) return ( np.sin(2 * np.pi * values / period), np.cos(2 * np.pi * values / period), ) df["hour_sin"], df["hour_cos"] = encode_cyclical_feature(df["hour"], 24) df["month_sin"], df["month_cos"] = encode_cyclical_feature(df["month"], 12) df["dayofweek_sin"], df["dayofweek_cos"] = encode_cyclical_feature(df["dayofweek"], 7) # 4. 业务时间特征 df["is_weekend"] = df["dayofweek"].isin([5, 6]).astype(int) df["is_night"] = df["hour"].isin([22, 23, 0, 1, 2, 3, 4, 5]).astype(int) # 5. 时间差特征 df["delivery_duration_hours"] = ( df["delivery_time"] - df["order_time"] ).dt.total_seconds() / 3600.0 # 6. 用户维度时间特征:每个用户的历史平均配送时长(作为示例特征) df.sort_values("order_time", inplace=True) df["prev_delivery_duration"] = df.groupby("user_id")["delivery_duration_hours"].shift(1) df["user_avg_delivery_duration"] = ( df.groupby("user_id")["delivery_duration_hours"] .transform(lambda x: x.expanding().mean().shift(1)) ) # 7. 整理最终特征 feature_cols = [ "order_amount", "year", "month", "day", "hour", "dayofweek", "hour_sin", "hour_cos", "month_sin", "month_cos", "dayofweek_sin", "dayofweek_cos", "is_weekend", "is_night", "prev_delivery_duration", "user_avg_delivery_duration", ] X = df[feature_cols].copy() y = df["delivery_duration_hours"].copy() print(X.head()) print(y.head())

这段代码的关键逻辑说明如下:

  • parse_datesread_csv的参数,可以在读取阶段就把指定列解析为datetime64类型,省去后续to_datetime的步骤。但如果你需要统一format或处理非法值,建议还是用显式的to_datetime
  • 第 6 步中,expanding().mean().shift(1)计算的是“到当前订单之前,该用户所有历史订单的平均配送时长”。shift(1)非常关键,它保证我们只使用历史数据,不会把当前订单本身的信息泄漏到特征中。
  • 这种特征构造方式在机器学习中称为滞后特征(Lag Feature),在时间序列预测和用户行为预测中非常常用。

运行之后,X是一个包含 16 个特征的 DataFrame,y是目标变量配送时长。此时数据已经可以送入机器学习模型进行训练。当然,如果目标变量存在明显的长尾分布,可以考虑做对数变换,但那是特征工程之后的话题。

7. 运行结果与效果验证

7.1 验证特征构造是否正确

运行完上面的代码后,你第一件要确认的事情不是“模型效果好不好”,而是“特征构造得对不对”。

需要检查的内容包括:

  • df["delivery_time"] - df["order_time"]得到的时间差应该全部是正数,如果出现负数,说明源数据中送达时间早于下单时间,这种数据需要排查。
  • prev_delivery_duration对于每个用户的第一条订单应该是NaN,这是正常的。
  • user_avg_delivery_duration的第一条订单也应该是NaN
  • 查看X.isna().sum(),确认哪些特征存在缺失值。
print(X.isna().sum()) print(df["delivery_duration_hours"].describe())

如果prev_delivery_durationuser_avg_delivery_duration存在较多缺失值,需要采取填充策略。常见做法是用全量数据的均值或中位数填充:

X["prev_delivery_duration"].fillna(X["prev_delivery_duration"].median(), inplace=True) X["user_avg_delivery_duration"].fillna(X["user_avg_delivery_duration"].median(), inplace=True)

7.2 用简单的线性模型做基线验证

接下来,用这些特征跑一个最简单的线性回归模型,验证特征的有效性。

from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) model = LinearRegression() model.fit(X_train, y_train) y_pred = model.predict(X_test) rmse = mean_squared_error(y_test, y_pred, squared=False) print(f"RMSE: {rmse:.3f} 小时")

如果 RMSE 比直接使用“全量平均配送时长”作为预测结果要低,说明我们构造的特征确实包含了对目标的预测信息。

更稳妥的做法是同时计算一个基线结果:

baseline_pred = np.full_like(y_test, y_train.mean()) baseline_rmse = mean_squared_error(y_test, baseline_pred, squared=False) print(f"Baseline RMSE: {baseline_rmse:.3f} 小时")

如果特征工程的 RMSE 明显低于基线 RMSE,你就有底气说:处理日期和时间变量是有价值的。

7.3 训练集和测试集的时间一致性检查

这是时间特征工程中最重要、也最容易忽略的一步。

如果原始数据集是按时间顺序排列的,你又用了train_test_split默认的随机拆分方式,那么训练集和测试集会混有不同时间段的数据,这会掩盖真正的时间分布不一致问题。

在做时间相关特征时,强烈建议手动按时间先后顺序拆分数据,而不是随机拆分:

X = X.sort_index() split_time = df["order_time"].quantile(0.8) train_mask = df["order_time"] <= split_time test_mask = df["order_time"] > split_time X_train, X_test = X[train_mask], X[test_mask] y_train, y_test = y[train_mask], y[test_mask]

这样拆分之后,训练集的时间范围都在测试集之前,模拟的是真实场景:用历史数据训练,预测未来数据。

为什么要特别强调这一点?因为时间特征和普通特征的一个根本区别是:普通特征在训练集和测试集中的分布是可迁移的,时间特征的分布往往不可迁移。如果你用随机拆分的方式,模型可能在“训练集里有 6 月数据,测试集里也有 6 月数据”的条件下表现很好,但线上部署时,模型面对的几乎全是未来的数据,分布完全不同,效果很容易崩盘。

7.4 树模型中的特征重要性验证

除了线性模型的 RMSE 外,还可以用树模型输出特征重要性,判断时间相关特征对模型预测的贡献。

from sklearn.ensemble import RandomForestRegressor rf_model = RandomForestRegressor(n_estimators=100, random_state=42) rf_model.fit(X_train, y_train) importance_df = pd.DataFrame({ "feature": feature_cols, "importance": rf_model.feature_importances_, }).sort_values("importance", ascending=False) print(importance_df)

运行后,你可以直观看到哪些时间特征被模型认为最重要。通常来说,prev_delivery_durationuser_avg_delivery_durationorder_amount这类行为性特征的重要性会比单独的monthday高。如果后发现year的重要性异常高,大概率是数据里混入了未来的信息或者特征泄漏,需要警惕。

8. 常见问题与排查思路

处理日期和时间变量时,以下这些坑是我在实际项目里遇到最频繁的,整理成一张表,方便你直接对照排查。

问题现象可能原因排查方式解决方案
pd.to_datetime()报错无法解析字符串格式不是标准 ISO 格式打印出列中不相同的字符串样例配合errors="coerce"参数查看哪些行变成NaT,再针对特殊格式显式指定format
解析后出现大量NaT数据中混入缺失值或非法日期检查isna().sum()并打印NaT的前几行根据业务决定删除或填充,不要放任缺失值进入模型
时间差计算出来是负数源数据中的结束时间早于开始时间查看这些记录对应的业务含义根据业务规则判断是时间记录错乱还是跨时区问题,必要时删除或修正
时间字段被当作字符串显示读取 CSV 时没有指定parse_dates查看df.dtypes使用pd.to_datetime()显式转换
rolling("7D")报错时间列未设置为索引,或者索引不是datetime64类型检查df.index的类型使用df.set_index("order_time")后再做滚动窗口操作,或者改用groupby加自定义窗口逻辑
模型特征重要性中年份占比异常数据泄漏:目标变量间接包含未来信息检查特征构造过程是否使用到了未来数据在构造滞后特征时使用shift(),并用时间顺序拆分训练集和测试集
周末/节假日特征全部是 0节假日列表数据范围不匹配打印holidays.dtypeorder_time.dtype确认两者都是datetime64类型,且时区一致
加了大量时间特征后模型效果反而下降特征冗余或引入噪声查看特征相关性矩阵和特征重要性先做简单时间特征,再逐步增加复杂特征,通过验证集效果决定保留或删除

其中我特别想强调第一个问题。很多初学者遇到to_datetime报错,第一反应是“我的数据格式是不是不对”,然后去改数据,而不是改解析逻辑。这方向反了。工程上更多时候是数据格式不够规范,正确的做法是:先不指定format,调用to_datetime,让它自动解析;如果报错,用errors="coerce"定位哪些行无法解析;然后针对无法解析的行,单独检查格式,再写对应的解析规则。千万不要为了让代码跑通,把所有时间列都强制转为字符串,那样后面的特征工程全都没有意义。

9. 最佳实践与工程建议

9.1 先从“绝对时间”与“相对时间”两个维度思考

处理日期时间变量时,不妨先把所有时间字段按照“绝对时间”和“相对时间”两个维度分类。

绝对时间指的是订单时间、注册时间、登录时间本身,承载的是“事件发生在什么时刻”的信息。相对时间指的是两次事件之间的间隔、距离当前时间的时长,承载的是“事件之间的先后顺序和间隔关系”。

在大多数业务模型中,相对时间特征比绝对时间特征更稳定。原因是:绝对时间的分布会随训练数据的变化而变化,比如今年训练的数据和明年线上数据的年份、月份分布完全不同;而相对时间只要业务规则不变,特征分布就不会发生剧烈变化。做特征工程时,优先挖掘相对时间特征几乎总是正确的策略。

9.2 日期时间解析阶段不要用函数式串联

常见的错误写法是在读取数据时写这样的代码:

df = pd.read_csv("data.csv") df["order_time"] = df["order_time"].apply(lambda x: pd.to_datetime(x))

apply循环解析时间,性能非常差。pandas 的to_datetime是向量化操作,应该直接传入整个 Series。正确写法是:

df["order_time"] = pd.to_datetime(df["order_time"])

如果担心格式问题,显式指定format参数即可。

9.3 时间特征命名规范

建议统一使用英文小写加下划线的风格,并且要能看出特征的时间口径。比如:

  • order_time_year:订单时间的年份。
  • delivery_duration_hours:配送时长(小时)。
  • user_first_order_time:用户首单时间。
  • days_since_last_order:距离上一笔订单的天数。

这种命名方式有两个好处:一是团队协作时其他人能一眼看出特征的含义和单位;二是在做特征监控时,能快速定位到异常特征对应的业务含义。

9.4 所有特征构造都要考虑时间泄漏

时间泄漏是时间特征工程中最致命的问题。它指的是:你在构造训练特征时,不小心用到了当前时间点之后的数据。

最常见的泄漏场景是:按用户分组计算统计值时,没有使用shift(),导致当前订单的统计特征包含了当前订单自己的信息,模型性能虚高。

判断是否存在时间泄漏的方法是:用时间顺序拆分训练集和测试集,然后检查模型在测试集上的效果和随机拆分的差异。如果随机拆分的效果远好于时间顺序拆分的效果,一定要怀疑是否存在时间泄漏。

9.5 训练集和测试集的拆分规则

在包含时间特征的建模任务中,强烈建议按时间拆分,而不是随机拆分。这一点在之前已经反复强调。

具体做法是:

cutoff_date = df["order_time"].quantile(0.8) train_df = df[df["order_time"] < cutoff_date].copy() test_df = df[df["order_time"] >= cutoff_date].copy()

之后再做特征工程时,只能使用train_df的数据来拟合编码器、计算聚合统计值和填充缺失值,否则还是会造成信息泄漏。

例如,如果你用全量数据的中位数来填充缺失值,再按时间拆分训练集和测试集,测试集中缺失值的信息其实已经被“泄漏”到了填充值中。正确做法是:

fill_value = train_df["delivery_duration_hours"].median() train_df["delivery_duration_hours"].fillna(fill_value, inplace=True) test_df["delivery_duration_hours"].fillna(fill_value, inplace=True)

9.6 保留原始时间列,不要覆盖

最后一条工程建议:无论做多少时间特征,原始时间列一定要保留。原因有两个。

第一,随时可能需要回溯检查原始时间数据和某个特征之间的对应关系。如果一开始就把order_time覆盖成了年份特征,后面再想验证“2024 年上半年是否有异常值”,就得重新读取原始数据。

第二,后续调试时,可能需要重新构造特征,这时最方便的做法是回到原始时间列重新开始。

10. 总结与后续学习方向

这一节不打算做那种“本文主要介绍了……”的流水账总结,只说两件重要的事。

第一,处理日期和时间变量没有“万能模板”,但有一条清晰的思维主线:先确认时间数据的类型和格式,再解析成datetime64;然后根据业务场景判断需要提取“绝对时间特征”“相对时间特征”还是“周期特征”;最后验证特征是否存在泄漏、在训练集和测试集之间分布是否稳定。你只要沿着这条主线走,就不会出现“拿到时间列不知道该怎么办”的情况。

第二,对于 100 天机器学习这条学习路径来说,第 34 天真正想让你建立的不是某个具体的日期特征提取函数,而是“时间变量是一种需要单独对待的特殊数据类型”这个意识。它和普通数值、类别变量的处理思路完全不同。掌握了这一点,你再看那些使用时间特征拿到高分的数据科学比赛方案,会觉得思路清晰很多。

接下来可以深入研究的方向包括:时间序列建模中的滞后特征和滚动统计特征、外部数据源(天气、节假日、经济指标)的时间特征融合、以及 pyspark 等大数据环境下如何对海量时间特征做分布式处理。如果你正在系统学习机器学习,建议把这些内容作为第 34 天之后的延伸练习,找一份真实数据集,按照本文的流程完整跑一遍效果验证,然后再去对比“完全不处理时间变量”和“手工构造时间特征”的模型效果差异。只有亲手跑过一遍,这些方法才能真正变成你自己的技能。

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

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

立即咨询