时间数据处理#
时间数据在聚合和可视化里都会碰到,但它比数值列更容易出问题:同一个时刻可以用 Date、Datetime、Time、Duration 四种方式表示,精度从纳秒到年不等,还有格式和时区的问题。这一章把这几个方面讲清楚,后面处理时序数据就不容易在类型转换上反复受阻。
内容依次是:时间类型的基础、类型之间的运算、按时间轴分组(重采样),以及和字符串的相互转换。
时间类型的选择没有绝对的好坏,关键是让类型和语义对上:只要日期就用 Date,需要时刻就用 Datetime,只关心一天中的时间就用 Time,表示两个时点之间的长度就用 Duration。选错类型,后面的比较、分组和绘图都会变得别扭。
import polars as pl
from helper.jupyter import row
时间的数据类型#
Polars 为日期、时刻、时间间隔准备了 4 种类型,按用途选,不要混用:把一个“日期”存成字符串,后面所有按时间比较、分组的操作都会失效。
类型 |
含义 |
最小单位 |
零点 |
用途 |
例子 |
|---|---|---|---|---|---|
|
只有日期(无时刻) |
日 |
Unix epoch (1970-01-01) |
按天聚合 |
|
|
一天中的时刻 |
纳秒(ns) |
00:00:00 |
只处理时刻本身 |
|
|
日期与时刻的组合 |
毫秒、微秒、纳秒 |
Unix epoch (1970-01-01 00:00:00) |
处理时间戳 |
|
|
时间长度、间隔 |
毫秒、微秒、纳秒 |
- |
两个时点之差 |
|
从整数列创建时间列,有 4 个函数,分别对应上面 4 种类型。
df_numbers = pl.DataFrame(
dict(
year=[2022, 2023, 2024],
month=[11, 10, 12],
day=[4, 31, 2],
hour=[1, 6, 16],
minute=[10, 50, 34],
second=[12.2, 20.5, 21],
)
)
df_numbers = df_numbers.with_columns(
total_seconds=pl.col('hour') * 3600 + pl.col('minute') * 60 + pl.col('second')
)
下面的代码把数值列转成时间列,每个函数返回对应类型的列。
Date、Time、Datetime 的各个字段都有范围,超过上限会报错或溢出,所以创建之前先确认数据范围。非整数的值只取整数部分。❶ 秒里带小数时,要把小数部分取出来换算成微秒。
❷ Duration 没有范围限制,用最小的单位(这里是 microseconds)创建最稳妥。
df_times = df_numbers.select(
date=pl.date('year', 'month', 'day'),
time=pl.time('hour', 'minute', 'second', microsecond=pl.col('second').mod(1).mul(1e6).round()), #❶
datetime=pl.datetime('year', 'month', 'day', 'hour', 'minute', 'second',
microsecond=pl.col('second').mod(1).mul(1e6).round()),
duration=pl.duration(microseconds=pl.col('total_seconds').mul(1e6)), #❷
)
row(df_numbers, df_times)
shape: (3, 7)
|
shape: (3, 4)
|
Datetime 和 Duration 可以改内部的单位(纳秒、微秒、毫秒),用 dt.cast_time_unit() 转换。
方向要留意:转成更大的单位会截断小数部分,所以 ns → ms 会丢信息,反过来 ms → ns 只是补零,信息不丢。
df_times.select(
'datetime',
pl.col('datetime').dt.cast_time_unit('ms').alias('datetime_ms'),
pl.col('datetime').dt.cast_time_unit('ns').alias('datetime_ns'),
'duration',
pl.col('duration').dt.cast_time_unit('ms').alias('duration_ms'),
pl.col('duration').dt.cast_time_unit('ns').alias('duration_ns'),
)
| datetime | datetime_ms | datetime_ns | duration | duration_ms | duration_ns |
|---|---|---|---|---|---|
| datetime[μs] | datetime[ms] | datetime[ns] | duration[μs] | duration[ms] | duration[ns] |
| 2022-11-04 01:10:12.200 | 2022-11-04 01:10:12.200 | 2022-11-04 01:10:12.200 | 1h 10m 12s 200ms | 1h 10m 12s 200ms | 1h 10m 12s 200ms |
| 2023-10-31 06:50:20.500 | 2023-10-31 06:50:20.500 | 2023-10-31 06:50:20.500 | 6h 50m 20s 500ms | 6h 50m 20s 500ms | 6h 50m 20s 500ms |
| 2024-12-02 16:34:21 | 2024-12-02 16:34:21 | 2024-12-02 16:34:21 | 16h 34m 21s | 16h 34m 21s | 16h 34m 21s |
时间类型的表达式#
时间类型之间转换#
下面是不同时间类型之间的转换,五个标记各对应一种变形。
❶ dt.combine():把 date 列和 time 列合成 Datetime
❷ dt.date():从 datetime 取出日期部分
❸ dt.time():从 datetime 中取出时刻部分,丢弃日期。
❹ cast(pl.Duration):把 time 转成 Duration
❺ 没有直接把 Duration 转成 Time 的函数,所以先转成纳秒单位的 Duration,用 to_physical() 变成 Int64,再 cast(pl.Time) 转回来。这是“借物理表示绕过类型系统”的典型写法。
df_times.select(
datetime=pl.col('date').dt.combine(pl.col('time')), #❶
date=pl.col('datetime').dt.date(), #❷
time=pl.col('datetime').dt.time(), #❸
duration=pl.col('time').cast(pl.Duration), #❹
time2=pl.col('duration').dt.cast_time_unit('ns').to_physical().cast(pl.Time), #❺
)
| datetime | date | time | duration | time2 |
|---|---|---|---|---|
| datetime[μs] | date | time | duration[μs] | time |
| 2022-11-04 01:10:12.200 | 2022-11-04 | 01:10:12.200 | 1h 10m 12s 200ms | 01:10:12.200 |
| 2023-10-31 06:50:20.500 | 2023-10-31 | 06:50:20.500 | 6h 50m 20s 500ms | 06:50:20.500 |
| 2024-12-02 16:34:21 | 2024-12-02 | 16:34:21 | 16h 34m 21s | 16:34:21 |
时间的字段#
下面的代码用 dt 命名空间下的函数,从 Datetime 列取出各个字段。Date 和 Time 列也能用同样的函数,只是有些字段对它们没有意义,比如 Date 没有 hour。
df_times.select(
year=pl.col('datetime').dt.year(),
month=pl.col('datetime').dt.month(),
day=pl.col('datetime').dt.day(),
hour=pl.col('datetime').dt.hour(),
minute=pl.col('datetime').dt.minute(),
second=pl.col('datetime').dt.second(),
microsecond=pl.col('datetime').dt.microsecond(),
week=pl.col('datetime').dt.week(),
weekday=pl.col('datetime').dt.weekday()
)
| year | month | day | hour | minute | second | microsecond | week | weekday |
|---|---|---|---|---|---|---|---|---|
| i32 | i8 | i8 | i8 | i8 | i8 | i32 | i8 | i8 |
| 2022 | 11 | 4 | 1 | 10 | 12 | 200000 | 44 | 5 |
| 2023 | 10 | 31 | 6 | 50 | 20 | 500000 | 44 | 2 |
| 2024 | 12 | 2 | 16 | 34 | 21 | 0 | 49 | 1 |
把 Duration 换成天数、小时数时,结果是整数,小数部分被截掉,而且不四舍五入。不想丢精度就用最小的单位(微秒等)取出来,需要时再在 Python 侧换算。
df_times.select(
pl.col('duration'),
days=pl.col('duration').dt.total_days(),
hours=pl.col('duration').dt.total_hours(),
minutes=pl.col('duration').dt.total_minutes(),
seconds=pl.col('duration').dt.total_seconds(),
microseconds=pl.col('duration').dt.total_microseconds()
)
| duration | days | hours | minutes | seconds | microseconds |
|---|---|---|---|---|---|
| duration[μs] | i64 | i64 | i64 | i64 | i64 |
| 1h 10m 12s 200ms | 0 | 1 | 70 | 4212 | 4212200000 |
| 6h 50m 20s 500ms | 0 | 6 | 410 | 24620 | 24620500000 |
| 16h 34m 21s | 0 | 16 | 994 | 59661 | 59661000000 |
时间的平移#
按时、按天把时间对齐、四舍五入、前后平移,是时序预处理的常用操作,比如“每天同一时刻的活跃用户”“每周一的数据”,这里整理 Datetime 列上的几个函数。
dt.month_start() 和 dt.month_end() 返回该时间所在月份的起点和终点,适合按月做快照。
df_times.select(
'datetime',
month_start=pl.col('datetime').dt.month_start(), # 月初时刻
month_end=pl.col('datetime').dt.month_end(), # 月末时刻
)
| datetime | month_start | month_end |
|---|---|---|
| datetime[μs] | datetime[μs] | datetime[μs] |
| 2022-11-04 01:10:12.200 | 2022-11-01 01:10:12.200 | 2022-11-30 01:10:12.200 |
| 2023-10-31 06:50:20.500 | 2023-10-01 06:50:20.500 | 2023-10-31 06:50:20.500 |
| 2024-12-02 16:34:21 | 2024-12-01 16:34:21 | 2024-12-31 16:34:21 |
dt.truncate() 按指定单位把时间截断,用来给聚合创建时间轴:同一小时内的记录会落到同一个时间点上,分组键就自然形成了。
df_times.select(
'datetime',
t_1s=pl.col('datetime').dt.truncate('1s'), # 截到 1 秒
t_30s=pl.col('datetime').dt.truncate('30s'), # 截到 30 秒
t_1h=pl.col('datetime').dt.truncate('1h'), # 截到 1 小时
t_1d=pl.col('datetime').dt.truncate('1d'), # 截到 1 天
t_1mo=pl.col('datetime').dt.truncate('1mo') # 截到 1 个月
)
| datetime | t_1s | t_30s | t_1h | t_1d | t_1mo |
|---|---|---|---|---|---|
| datetime[μs] | datetime[μs] | datetime[μs] | datetime[μs] | datetime[μs] | datetime[μs] |
| 2022-11-04 01:10:12.200 | 2022-11-04 01:10:12 | 2022-11-04 01:10:00 | 2022-11-04 01:00:00 | 2022-11-04 00:00:00 | 2022-11-01 00:00:00 |
| 2023-10-31 06:50:20.500 | 2023-10-31 06:50:20 | 2023-10-31 06:50:00 | 2023-10-31 06:00:00 | 2023-10-31 00:00:00 | 2023-10-01 00:00:00 |
| 2024-12-02 16:34:21 | 2024-12-02 16:34:21 | 2024-12-02 16:34:00 | 2024-12-02 16:00:00 | 2024-12-02 00:00:00 | 2024-12-01 00:00:00 |
dt.round() 按指定单位四舍五入,和 truncate 的区别是它会把时间向前移动半个单位。想把时间就近对齐,而不是一律往下截,就用它。
df_times.select(
'datetime',
r_1s=pl.col('datetime').dt.round('1s'),
r_30s=pl.col('datetime').dt.round('30s'),
r_1h=pl.col('datetime').dt.round('1h'),
r_1d=pl.col('datetime').dt.round('1d'),
r_1mo=pl.col('datetime').dt.round('1mo'),
)
| datetime | r_1s | r_30s | r_1h | r_1d | r_1mo |
|---|---|---|---|---|---|
| datetime[μs] | datetime[μs] | datetime[μs] | datetime[μs] | datetime[μs] | datetime[μs] |
| 2022-11-04 01:10:12.200 | 2022-11-04 01:10:12 | 2022-11-04 01:10:00 | 2022-11-04 01:00:00 | 2022-11-04 00:00:00 | 2022-11-01 00:00:00 |
| 2023-10-31 06:50:20.500 | 2023-10-31 06:50:21 | 2023-10-31 06:50:30 | 2023-10-31 07:00:00 | 2023-10-31 00:00:00 | 2023-11-01 00:00:00 |
| 2024-12-02 16:34:21 | 2024-12-02 16:34:21 | 2024-12-02 16:34:30 | 2024-12-02 17:00:00 | 2024-12-03 00:00:00 | 2024-12-01 00:00:00 |
dt.offset_by() 把时间平移指定长度:正值往未来,负值往过去。它常和 truncate 配合使用,比如先截到月初,再平移一个月。
df_times.select(
'datetime',
o_1s=pl.col('datetime').dt.offset_by('1s'),
o_30s=pl.col('datetime').dt.offset_by('30s'),
o_1h=pl.col('datetime').dt.offset_by('1h'),
o_1d=pl.col('datetime').dt.offset_by('1d'),
o_1mo=pl.col('datetime').dt.offset_by('1mo'),
)
| datetime | o_1s | o_30s | o_1h | o_1d | o_1mo |
|---|---|---|---|---|---|
| datetime[μs] | datetime[μs] | datetime[μs] | datetime[μs] | datetime[μs] | datetime[μs] |
| 2022-11-04 01:10:12.200 | 2022-11-04 01:10:13.200 | 2022-11-04 01:10:42.200 | 2022-11-04 02:10:12.200 | 2022-11-05 01:10:12.200 | 2022-12-04 01:10:12.200 |
| 2023-10-31 06:50:20.500 | 2023-10-31 06:50:21.500 | 2023-10-31 06:50:50.500 | 2023-10-31 07:50:20.500 | 2023-11-01 06:50:20.500 | 2023-11-30 06:50:20.500 |
| 2024-12-02 16:34:21 | 2024-12-02 16:34:22 | 2024-12-02 16:34:51 | 2024-12-02 17:34:21 | 2024-12-03 16:34:21 | 2025-01-02 16:34:21 |
重采样#
重采样就是改变时序的时间间隔:把细粒度的数据合并成粗粒度,或者反过来补齐缺失的时间点。
降采样:并到更长的间隔(每分钟 → 每小时),信息量减少,行数也变少。
升采样:展开到更短的间隔(每小时 → 每分钟),行数变多,但新点没有值。
group_by_dynamic() 做降采样。下面的代码读 air_quality_long.csv,按地点(location)和参数(parameter)每 8 小时聚合一次。
❶ 排序:按 location、parameter、date.utc 排,让每组内部按时间有序。
❷ group_by_dynamic():以 date.utc 为基准,每 8 小时分一组。
❸ 聚合:每组算 value 的最小、平均、最大。
df = pl.read_csv('data/air_quality_long.csv', try_parse_dates=True).drop('city', 'country', 'unit')
df = df.sort('location', 'parameter', 'date.utc') #❶
df_down = df.group_by_dynamic('date.utc', every='8h', group_by=['location', 'parameter']).agg( #❷
pl.col('value').min().alias('value_min'), #❸
pl.col('value').mean().alias('value_mean'),#❸
pl.col('value').max().alias('value_max') #❸
)
row(df, df_down)
shape: (5_272, 4)
|
shape: (780, 6)
|
upsample() 反过来,把数据展开到更短的间隔。参数和 group_by_dynamic() 一样,原本不存在的时间点填 NULL。
NULL 不能直接使用,所以常见做法是用 forward_fill() 取前一个值补齐,变成连续数据。代价是新插入的点并非实测,而是由前值推断而来,画图时应明确标出。
df_up = df.upsample('date.utc', every='20m', group_by=['location', 'parameter'])
df_up_fill = df_up.with_columns(pl.col('location', 'parameter', 'value').forward_fill())
row(df_up, df_up_fill)
shape: (25_547, 4)
|
shape: (25_547, 4)
|
与其他类型的转换#
与字符串互转#
从 CSV 读进来的时间还是字符串,Datetime、Date、Time 和字符串的互转是预处理中必不可少的一步。读进来要转成时间类型才能比较、分组,写出去又要按对方要求的格式变成字符串。
从字符串到时间类型#
pl.read_csv() 指定 try_parse_dates=True 时,能当日期解释的列会自动转成 Datetime,省掉一次手动转换。
例:下面的 CSV 里,start 列符合 ISO 风格的日期时间格式,会被自动解析成时间类型。
%%writefile data/test_time.csv
start,end,category
2024-12-10 10:32:14.200,20241210_103715.1,B
2024-12-11 02:40:02.340,20241211_024628.2,A
2024-12-11 15:40:30.550,20241211_154317.6,A
2024-12-12 05:48:19.000,20241212_054905.9,B
2024-12-12 13:42:35.990,20241212_135252.98,B
Overwriting data/test_time.csv
df = pl.read_csv('data/test_time.csv', try_parse_dates=True)
df
| start | end | category |
|---|---|---|
| datetime[μs] | str | str |
| 2024-12-10 10:32:14.200 | "20241210_103715.1" | "B" |
| 2024-12-11 02:40:02.340 | "20241211_024628.2" | "A" |
| 2024-12-11 15:40:30.550 | "20241211_154317.6" | "A" |
| 2024-12-12 05:48:19 | "20241212_054905.9" | "B" |
| 2024-12-12 13:42:35.990 | "20241212_135252.98" | "B" |
像 end 列这种自定义格式(20241210_103715.1),自动解析无法识别,要用 str.to_datetime() 显式给格式,格式符采用 chrono 的 strftime 记法。
https://docs.rs/chrono/latest/chrono/format/strftime/index.html
df2 = df.with_columns(
end=pl.col('end').str.to_datetime('%Y%m%d_%H%M%S%.f')
)
row(df.select('end'), df2.select('end'))
shape: (5, 1)
|
shape: (5, 1)
|
str.to_date() 和 str.to_time() 可以转成 Date 和 Time。想得到 Duration,先转成 Time 再 cast(),没有从字符串直接到 Duration 的捷径。
从时间类型到字符串#
要把时间按指定格式输出成字符串,用 dt.strftime(),给它格式符就行。反过来读的时候用 str.to_datetime(),两个函数的格式符是同一套。
df3 = df2.select(
formatted_start=pl.col('start').dt.strftime('%Y/%m/%d %H:%M:%S')
)
row(df2.select('start'), df3)
shape: (5, 1)
|
shape: (5, 1)
|
把 Duration 变成字符串,要先换算成最小单位的值,再用 pl.format() 拼接。想自己定显示格式时很方便,比如输出“385.86 secs”而不是默认的“6m 25s 860ms”。
下面的代码把微秒换成秒,用 round(3) 保留 3 位小数后再嵌入。不四舍五入直接格式化的话,385.85999999999996 这种浮点误差会暴露出来。
(
df2.select(duration=pl.col("end") - pl.col("start"))
.select(
"duration",
pl.format(
"{} secs",
(pl.col("duration").dt.total_microseconds() / 1e6).round(3),
).alias("duration_str"),
)
)
| duration | duration_str |
|---|---|
| duration[μs] | str |
| 5m 900ms | "300.9 secs" |
| 6m 25s 860ms | "385.86 secs" |
| 2m 47s 50ms | "167.05 secs" |
| 46s 900ms | "46.9 secs" |
| 10m 16s 990ms | "616.99 secs" |
与 Epoch 的转换#
Datetime 和 epoch(从 1970-01-01 00:00:00 UTC 起的经过时间)可以互相转换。epoch 常用秒、毫秒、微秒表示,所以要指明单位,单位错了,结果会差好几个数量级。
dt.epoch():把Datetime变成 epoch,指定单位('s'、'ms'、'ns'等)。pl.from_epoch():把 epoch 变回Datetime,同样要指定单位。
df2 = df.select(
epoch_s=pl.col('start').dt.epoch('s'),
epoch_ns=pl.col('start').dt.epoch('ns'),
)
df3 = df2.select(
start_s = pl.from_epoch('epoch_s', 's'),
start_ns = pl.from_epoch('epoch_ns', 'ns')
)
row(df.select('start'), df2, df3)
shape: (5, 1)
|
shape: (5, 2)
|
shape: (5, 2)
|