《R for Data Science (2e)》免费中文翻译 (第17章) --- Dates and times(2)

写在前面

本系列推文为《R for Data Science (2)》的中文翻译版本。所有内容都通过开源免费的方式上传至Github,欢迎大家参与贡献,详细信息见:
Books-zh-cn 项目介绍:
Books-zh-cn:开源免费的中文书籍社区
r4ds-zh-cn Github 地址:
https://github.com/Books-zh-cn/r4ds-zh-cn
r4ds-zh-cn 网站地址:
https://books-zh-cn.github.io/r4ds-zh-cn/


目录

  • 17.3 日期-时间组件

  • 17.4 时间跨度

  • 17.5 时区

  • 17.6 总结

17.3 日期-时间组件

现在你已经知道如何将 date-time 数据导入 R 的 date-time 数据结构,接下来让我们探索可以对它们进行哪些操作。 本节将重点介绍用于获取和设置单个组件的访问器函数。 下一节将探讨 date-times 的算术运算。

17.3.1 获取组件

你可以使用访问器函数 year(), month(), mday() (day of the month), yday() (day of the year), wday() (day of the week), hour(), minute(), 和 second() 来提取日期的各个组成部分。 这些函数实际上是 make_datetime() 的反向操作。

复制代码
datetime <- ymd_hms("2026-07-08 12:34:56")

year(datetime)
#> [1] 2026
month(datetime)
#> [1] 7
mday(datetime)
#> [1] 8

yday(datetime)
#> [1] 189
wday(datetime)
#> [1] 4

对于 month()wday() 函数,你可以设置 label = TRUE 来返回月份或星期几的缩写名称。 设置 abbr = FALSE 可返回完整名称。

复制代码
month(datetime, label = TRUE)
#> [1] Jul
#> 12 Levels: Jan < Feb < Mar < Apr < May < Jun < Jul < Aug < Sep < ... < Dec
wday(datetime, label = TRUE, abbr = FALSE)
#> [1] Wednesday
#> 7 Levels: Sunday < Monday < Tuesday < Wednesday < Thursday < ... < Saturday

我们可以使用 wday() 来观察工作日出发的航班数量多于周末:

复制代码
flights_dt |> 
  mutate(wday = wday(dep_time, label = TRUE)) |> 
  ggplot(aes(x = wday)) +
  geom_bar()

我们还可以观察每小时之内分钟粒度的平均起飞延误情况。 这里有一个有趣的规律:在 20-30 分钟和 50-60 分钟起飞的航班,其延误程度远低于该小时内其他时段的航班!

复制代码
flights_dt |> 
  mutate(minute = minute(dep_time)) |> 
  group_by(minute) |> 
  summarize(
    avg_delay = mean(dep_delay, na.rm = TRUE),
    n = n()
  ) |> 
  ggplot(aes(x = minute, y = avg_delay)) +
  geom_line()

有趣的是,如果我们查看计划起飞时间,并没有观察到如此明显的规律:

复制代码
sched_dep <- flights_dt |> 
  mutate(minute = minute(sched_dep_time)) |> 
  group_by(minute) |> 
  summarize(
    avg_delay = mean(arr_delay, na.rm = TRUE),
    n = n()
  )

ggplot(sched_dep, aes(x = minute, y = avg_delay)) +
  geom_line()

那么,为什么在实际起飞时间中会出现这种规律呢? 事实上,如同许多由人工收集的数据一样,航班倾向于在"整点"时间起飞,如 Figure 17.1 所示,这导致了明显的偏差。 在处理涉及人为判断的数据时,务必警惕这类规律的存在!

Figure 17.1: 显示每小时计划出发的航班数量的频数多边形。您可以看到人们对 0 和 30 之类的整数以及通常是 5 的倍数的数字有强烈的偏好。

Rounding

另一种绘制单个日期组成部分的方法是使用 floor_date()round_date()ceiling_date() 函数,将日期四舍五入到附近的时间单位。 每个函数都接受一个待调整的日期向量,以及要向下取整(floor)、向上取整(ceiling)或四舍五入(round)的时间单位名称。 例如,这使我们能够绘制每周的航班数量:

复制代码
flights_dt |> 
  count(week = floor_date(dep_time, "week")) |> 
  ggplot(aes(x = week, y = n)) +
  geom_line() + 
  geom_point()

通过计算 dep_time 与当天最早时刻之间的差值,你可以使用四舍五入来展示航班在一天内的分布情况:

复制代码
flights_dt |> 
  mutate(dep_hour = dep_time - floor_date(dep_time, "day")) |> 
  ggplot(aes(x = dep_hour)) +
  geom_freqpoly(binwidth = 60 * 30)
#> Don't know how to automatically pick scale for object of type <difftime>.
#> Defaulting to continuous.

计算两个日期时间之间的差值会得到一个 difftime 对象(更多内容见 Section 17.4.3 节)。 我们可以将其转换为 hms 对象,以获得更有用的 x 轴:

复制代码
flights_dt |> 
  mutate(dep_hour = hms::as_hms(dep_time - floor_date(dep_time, "day"))) |> 
  ggplot(aes(x = dep_hour)) +
  geom_freqpoly(binwidth = 60 * 30)

17.3.3 修改组件

你也可以使用每个访问器函数来修改 date/time 的组成部分。 这在数据分析中不常用,但在清理日期明显错误的数据时可能很有用。

复制代码
(datetime <- ymd_hms("2026-07-08 12:34:56"))

year(datetime) <- 2030
datetime
#> [1] "2030-07-08 12:34:56 UTC"
month(datetime) <- 01
datetime
#> [1] "2030-01-08 12:34:56 UTC"
hour(datetime) <- hour(datetime) + 1
datetime
#> [1] "2030-01-08 13:34:56 UTC"

或者,你无需修改现有变量,而是可以使用 update() 创建一个新的 date-time。 这还允许你一步设置多个值:

复制代码
update(datetime, year = 2030, month = 2, mday = 2, hour = 2)
#> [1] "2030-02-02 02:34:56 UTC"

如果设置的值过大,它们会自动向前进位:

复制代码
update(ymd("2023-02-01"), mday = 30)
#> [1] "2023-03-02"
update(ymd("2023-02-01"), hour = 400)
#> [1] "2023-02-17 16:00:00 UTC"

17.3.4 练习

  1. 一天内航班时间的分布如何随一年中的时间变化?

  2. 比较 dep_time, sched_dep_timedep_delay。 它们是否一致? 请解释你的发现。

  3. 比较 air_time 与起飞和到达之间的时间间隔。 解释你的发现。 (提示:考虑机场的位置。)

  4. 一天中平均延误时间如何变化? 应该使用 dep_time 还是 sched_dep_time? 为什么?

  5. 如果希望最小化延误的可能性,你应该在一周中的哪一天出发?

  6. diamonds$caratflights$sched_dep_time 的分布有何相似之处?

  7. 验证我们的假设:20-30 分钟和 50-60 分钟起飞的航班较早出发是由于计划航班提前起飞所致。 提示:创建一个二元变量来指示航班是否延误。

17.4 时间跨度

接下来,你将学习日期算术运算的原理,包括减法、加法和除法。 在此过程中,你会了解到三种表示时间跨度的重要类别:

  • Durations, 表示精确的秒数。

  • Periods, 表示人类可理解的单位,如周 (weeks) 和月 (months)。

  • Intervals, 表示一个起始点和结束点。

如何在 duration、periods 和 intervals 之间做出选择? 一如既往,选择能解决你问题的最简单数据结构。 如果你只关心物理时间,使用 duration;如果需要添加人类可理解的时间,使用 period;如果需要计算某个跨度在人类单位中的长度,使用 interval。

17.4.1 Durations

在 R 中,当你对两个日期进行相减时,会得到一个 difftime 对象:

复制代码
# How old is Hadley?
h_age <- today() - ymd("1979-10-14")
h_age
#> Time difference of 16872 days

difftime 类对象以秒、分钟、小时、天或周为单位记录时间跨度。 这种模糊性可能使得 difftimes 对象处理起来有些棘手,因此 lubridate 提供了另一种始终以秒为单位的时间跨度表示方式:duration

复制代码
as.duration(h_age)
#> [1] "1457740800s (~46.19 years)"

Durations 附带了一系列便捷的构造函数:

复制代码
dseconds(15)
#> [1] "15s"
dminutes(10)
#> [1] "600s (~10 minutes)"
dhours(c(12, 24))
#> [1] "43200s (~12 hours)" "86400s (~1 days)"
ddays(0:5)
#> [1] "0s"                "86400s (~1 days)"  "172800s (~2 days)"
#> [4] "259200s (~3 days)" "345600s (~4 days)" "432000s (~5 days)"
dweeks(3)
#> [1] "1814400s (~3 weeks)"
dyears(1)
#> [1] "31557600s (~1 years)"

Durations 始终以秒为单位记录时间跨度。 更大的单位通过将分钟、小时、天、周和年转换为秒来创建:一分钟 60 秒,一小时 60 分钟,一天 24 小时,一周 7 天。 更大的时间单位则更具问题性。 一年使用"平均"年天数,即 365.25 天。 由于月份的变化太大,无法将其转换为 duration。

你可以对 durations 进行加减和乘法运算:

复制代码
2 * dyears(1)
#> [1] "63115200s (~2 years)"
dyears(1) + dweeks(12) + dhours(15)
#> [1] "38869200s (~1.23 years)"

你可以对日期进行 durations 的加减运算:

复制代码
tomorrow <- today() + ddays(1)
last_year <- today() - dyears(1)

然而,由于 durations 表示的是精确的秒数,有时你可能会得到意想不到的结果:

复制代码
one_am <- ymd_hms("2026-03-08 01:00:00", tz = "America/New_York")

one_am
#> [1] "2026-03-08 01:00:00 EST"
one_am + ddays(1)
#> [1] "2026-03-09 02:00:00 EDT"

为什么 3 月 8 日凌晨 1 点加上一天后是 3 月 9 日凌晨 2 点? 如果仔细观察日期,你可能还会注意到时区发生了变化。 3 月 8 日只有 23 小时,因为这是夏令时开始的时间,所以如果我们加上一整天的秒数,最终会得到一个不同的时间。

17.4.2 Periods

为了解决这个问题,lubridate 提供了 periods。 Periods 表示时间跨度,但其长度不以固定的秒数为单位,而是以"人类"时间(如 days 和 months)为单位。 这使得它们能以更直观的方式工作:

复制代码
one_am
#> [1] "2026-03-08 01:00:00 EST"
one_am + days(1)
#> [1] "2026-03-09 01:00:00 EDT"

与 durations 类似,periods 也可以通过一系列便捷的构造函数来创建。

复制代码
hours(c(12, 24))
#> [1] "12H 0M 0S" "24H 0M 0S"
days(7)
#> [1] "7d 0H 0M 0S"
months(1:6)
#> [1] "1m 0d 0H 0M 0S" "2m 0d 0H 0M 0S" "3m 0d 0H 0M 0S" "4m 0d 0H 0M 0S"
#> [5] "5m 0d 0H 0M 0S" "6m 0d 0H 0M 0S"

你可以对 periods 进行加法和乘法运算:

复制代码
10 * (months(6) + days(1))
#> [1] "60m 10d 0H 0M 0S"
days(50) + hours(25) + minutes(2)
#> [1] "50d 25H 2M 0S"

当然,也可以将它们与 dates 相加。 与 durations 相比,periods 更可能符合你的预期:

复制代码
# A leap year
ymd("2024-01-01") + dyears(1)
#> [1] "2024-12-31 06:00:00 UTC"
ymd("2024-01-01") + years(1)
#> [1] "2025-01-01"

# Daylight saving time
one_am + ddays(1)
#> [1] "2026-03-09 02:00:00 EDT"
one_am + days(1)
#> [1] "2026-03-09 01:00:00 EDT"

让我们使用 periods 来解决与航班日期相关的一个异常情况。 一些飞机似乎在其从纽约市起飞之前就已到达目的地。

复制代码
flights_dt |> 
  filter(arr_time < dep_time) 
#> # A tibble: 10,633 × 9
#>   origin dest  dep_delay arr_delay dep_time            sched_dep_time     
#>   <chr>  <chr>     <dbl>     <dbl> <dttm>              <dttm>             
#> 1 EWR    BQN           9        -4 2013-01-01 19:29:00 2013-01-01 19:20:00
#> 2 JFK    DFW          59        NA 2013-01-01 19:39:00 2013-01-01 18:40:00
#> 3 EWR    TPA          -2         9 2013-01-01 20:58:00 2013-01-01 21:00:00
#> 4 EWR    SJU          -6       -12 2013-01-01 21:02:00 2013-01-01 21:08:00
#> 5 EWR    SFO          11       -14 2013-01-01 21:08:00 2013-01-01 20:57:00
#> 6 LGA    FLL         -10        -2 2013-01-01 21:20:00 2013-01-01 21:30:00
#> # ℹ 10,627 more rows
#> # ℹ 3 more variables: arr_time <dttm>, sched_arr_time <dttm>, ...

这些是过夜航班。 我们在起飞和到达时间中使用了相同的日期信息,但这些航班实际上是在第二天抵达的。 我们可以通过为每个过夜航班的到达时间加上 days(1) 来修正这个问题。

复制代码
flights_dt <- flights_dt |> 
  mutate(
    overnight = arr_time < dep_time,
    arr_time = arr_time + days(overnight),
    sched_arr_time = sched_arr_time + days(overnight)
  )

现在,所有航班都符合物理定律了。

复制代码
flights_dt |> 
  filter(arr_time < dep_time) 
#> # A tibble: 0 × 10
#> # ℹ 10 variables: origin <chr>, dest <chr>, dep_delay <dbl>,
#> #   arr_delay <dbl>, dep_time <dttm>, sched_dep_time <dttm>, ...

17.4.3 Intervals

dyears(1) / ddays(365) 会返回什么结果? 它并不完全等于 1,因为 dyears() 被定义为每个平均年份的秒数,即 365.25 天。

那么 years(1) / days(1) 会返回什么? 如果年份是 2015,它应该返回 365;但如果是 2016,它应该返回 366! lubridate 没有足够的信息来给出一个唯一明确的答案。 它实际做的是给出一个估算值:

复制代码
years(1) / days(1)
#> [1] 365.25

如果你想要更精确的测量,就必须使用 interval。 interval 是一对起始和结束的日期时间,或者你可以将其视为带有起始点的时长。

你可以通过 start %--% end 的语法创建一个区间:

复制代码
y2023 <- ymd("2023-01-01") %--% ymd("2024-01-01")
y2024 <- ymd("2024-01-01") %--% ymd("2025-01-01")

y2023
#> [1] 2023-01-01 UTC--2024-01-01 UTC
y2024
#> [1] 2024-01-01 UTC--2025-01-01 UTC

然后你可以将其除以 days() 来找出该年份包含多少天:

复制代码
y2023 / days(1)
#> [1] 365
y2024 / days(1)
#> [1] 366

17.4.4 练习

  1. 向刚学习 R 的人解释 days(!overnight)days(overnight)。 你需要了解的关键事实是什么?

  2. 创建一个日期向量,表示 2015 年每个月的第一天。 再创建一个日期向量,表示当前年份每个月的第一天。

  3. 编写一个函数,根据你的生日(作为日期输入),返回你的年龄(以年为单位)。

  4. 为什么 (today() %--% (today() + years(1))) / months(1) 无法运行?

17.5 时区

时区是一个极其复杂的主题,因为它们与地缘政治实体相互作用。 幸运的是,我们不需要深入探究所有细节,因为并非所有细节对数据分析都至关重要,但有一些挑战我们确实需要直面应对。

第一个挑战是日常使用的时区名称往往具有歧义性。 例如,如果你是美国人,可能很熟悉 EST(东部标准时间)。 然而,澳大利亚和加拿大也都有 EST! 为了避免混淆,R 采用国际标准的 IANA 时区。 这些时区使用一致的命名规则 {area}/{location},通常形式为 {continent}/{city}{ocean}/{city}。 例如:"America/New_York"、"Europe/Paris" 和 "Pacific/Auckland"。

你可能会好奇,为什么时区使用城市名称,而通常我们认为时区与国家或国家内的区域相关联。 这是因为 IANA 数据库必须记录数十年的时区规则。 在几十年间,国家名称变更(或分裂)相当频繁,但城市名称往往保持不变。 另一个问题是,名称不仅需要反映当前行为,还需体现完整的历史。 例如,同时存在 "America/New_York" 和 "America/Detroit" 两个时区。 这两个城市目前都使用东部标准时间,但在 1969-1972 年间,密歇根州(底特律所在州)未遵循夏令时,因此需要一个不同的名称。 值得一读原始时区数据库(可在 https://www.iana.org/time-zones 获取),仅是为了了解其中的一些历史故事!

你可以通过 Sys.timezone() 查看 R 认为你当前的时区是什么:

复制代码
Sys.timezone()
#> [1] "UTC"

(如果 R 无法识别,则会返回 NA。)

可以通过 OlsonNames() 查看完整的时区名称列表:

复制代码
length(OlsonNames())
#> [1] 598
head(OlsonNames())
#> [1] "Africa/Abidjan"     "Africa/Accra"       "Africa/Addis_Ababa"
#> [4] "Africa/Algiers"     "Africa/Asmara"      "Africa/Asmera"

在 R 中,时区是 date-time 的一个属性,仅控制显示方式。 例如,以下三个对象表示同一时刻:

复制代码
x1 <- ymd_hms("2024-06-01 12:00:00", tz = "America/New_York")
x1
#> [1] "2024-06-01 12:00:00 EDT"

x2 <- ymd_hms("2024-06-01 18:00:00", tz = "Europe/Copenhagen")
x2
#> [1] "2024-06-01 18:00:00 CEST"

x3 <- ymd_hms("2024-06-02 04:00:00", tz = "Pacific/Auckland")
x3
#> [1] "2024-06-02 04:00:00 NZST"

你可以通过减法运算验证它们是同一时间:

复制代码
x1 - x2
#> Time difference of 0 secs
x1 - x3
#> Time difference of 0 secs

除非另有说明,lubridate 始终使用 UTC。 UTC(协调世界时)是科学界使用的标准时区,大致等同于 GMT(格林威治标准时间)。 它不采用夏令时,因此便于计算。 组合 date-times 的操作(如 c())通常会丢弃时区信息。 这种情况下,date-times 将按第一个元素的时区显示:

复制代码
x4 <- c(x1, x2, x3)
x4
#> [1] "2024-06-01 12:00:00 EDT" "2024-06-01 12:00:00 EDT"
#> [3] "2024-06-01 12:00:00 EDT"

你可以通过两种方式更改时区:

  • 保持时间点不变,仅改变其显示方式。 当时间点正确但希望以更自然的方式显示时使用此方法。

    复制代码
    x4a <- with_tz(x4, tzone = "Australia/Lord_Howe")
    x4a
    #> [1] "2024-06-02 02:30:00 +1030" "2024-06-02 02:30:00 +1030"
    #> [3] "2024-06-02 02:30:00 +1030"
    x4a - x4
    #> Time differences in secs
    #> [1] 0 0 0

    (这也说明了时区的另一个挑战:它们并非都是整小时的偏移量!)

  • 改变底层的时间点。 当时间点被标记了错误的时区且需要修正时使用此方法。

    复制代码
    x4b <- force_tz(x4, tzone = "Australia/Lord_Howe")
    x4b
    #> [1] "2024-06-01 12:00:00 +1030" "2024-06-01 12:00:00 +1030"
    #> [3] "2024-06-01 12:00:00 +1030"
    x4b - x4
    #> Time differences in hours
    #> [1] -14.5 -14.5 -14.5

17.6 总结

本章向你介绍了 lubridate 提供的工具,帮助你处理日期时间数据。 处理日期和时间看似比实际需要的更复杂,但希望本章能让你理解其原因------日期时间比乍看之下更为复杂,而处理各种可能的情况增加了其复杂性。 即使你的数据从未涉及夏令时变更或闰年,相关函数也必须能够应对这些情况。

下一章将系统总结缺失值的处理方法。 你已经在多个场景中遇到过缺失值,在自己的分析中也无疑会碰到它们。现 在,是时候提供一个实用技巧合集来应对这些情况了。

--------------- 本章结束 ---------------

本期翻译贡献:

  • @TigerZ生信宝库
相关推荐
青春不败 177-3266-05203 小时前
基于R语言lavaan结构方程模型(SEM)实践技术应用
python·r语言·贝叶斯·生态学·结构方程·sem
itwangyang5204 小时前
人工智能药物设计和生信常用 R 包一键全自动安装脚本
开发语言·人工智能·r语言
xiao5kou4chang6kai44 小时前
R语言的贝叶斯网络模型的实践
r语言·贝叶斯网络·统计学
JicasdC123asd7 小时前
农田杂草识别与分类:基于Faster R-CNN的优化模型实践与性能分析
分类·r语言·cnn
探序基因1 天前
R语言-使用pheatmap函数画热图
开发语言·r语言
WJSKad12352 天前
Mask R-CNN托盘完整性检测与分类实战指南_3
分类·r语言·cnn
wyw00002 天前
目标检测之Fast R-CNN
目标检测·r语言·cnn
kisshuan123963 天前
【深度学习】【目标检测】基于Mask R-CNN的鱼类尾巴检测与识别
深度学习·目标检测·r语言
CS创新实验室4 天前
AI 与编程
人工智能·编程·编程语言