17  日期和时间

17.1 引言

本章将向你展示如何在 R 中处理日期和时间。乍一看,日期和时间似乎很简单。你在日常生活中一直在使用它们,并且它们似乎没有引起太多困惑。然而,你对日期和时间了解得越多,它们似乎就变得越复杂!

为了热身,请思考一下一年有多少天,一天有多少小时。你可能记得大多数年份有 365 天,但闰年有 366 天。你知道判断一年是否为闰年的完整规则吗1?一天中的小时数则不那么明显:大多数日子有 24 小时,但在使用夏令时 (Daylight Saving Time, DST) 的地方,每年有一天是 23 小时,另一天是 25 小时。

日期和时间之所以困难,是因为它们必须调和两种物理现象(地球的自转和它围绕太阳的公转)与一系列地缘政治现象,包括月份、时区和夏令时。本章不会教你关于日期和时间的每一个细节,但它会为你提供坚实的实践技能基础,帮助你应对常见的数据分析挑战。

我们将首先向你展示如何从各种输入创建日期时间,然后一旦你有了一个日期时间,你如何可以提取年、月、日等组成部分。接着,我们将深入探讨处理时间跨度的棘手话题,根据你的不同需求,时间跨度有多种形式。最后,我们将简要讨论时区带来的额外挑战。

17.1.1 前提条件

本章将重点介绍 lubridate 包,它使得在 R 中处理日期和时间变得更加容易。自最新的 tidyverse 版本发布以来,lubridate 已成为核心 tidyverse 的一部分。我们还需要 nycflights13 来获取练习数据。

17.2 创建日期/时间

有三种类型的日期/时间数据指代一个时间点:

  • 日期 (date)。Tibble 将其打印为 <date>

  • 一天中的时间 (time)。Tibble 将其打印为 <time>

  • 日期时间 (date-time) 是日期加上时间:它唯一地标识了一个时间点(通常精确到秒)。Tibble 将其打印为 <dttm>。基础 R 称之为 POSIXct,但这名字并不上口。

在本章中,我们将重点关注日期和日期时间,因为 R 没有用于存储时间的原生类。如果你需要一个,可以使用 hms 包。

你应该始终使用能满足你需求的最简单的数据类型。这意味着如果你可以使用日期而不是日期时间,你就应该这样做。日期时间要复杂得多,因为需要处理时区问题,我们将在本章末尾再回到这个问题。

要获取当前日期或日期时间,你可以使用 today()now()

today()
#> [1] "2025-07-10"
now()
#> [1] "2025-07-10 11:34:09 CST"

此外,以下各节描述了你可能创建日期/时间的四种方式:

  • 使用 readr 读取文件时。
  • 从字符串。
  • 从单独的日期时间组件。
  • 从现有的日期/时间对象。

17.2.1 在导入期间

如果你的 CSV 文件包含 ISO8601 格式的日期或日期时间,你无需做任何事情;readr 会自动识别它:

csv <- "
  date,datetime
  2022-01-02,2022-01-02 05:12
"
read_csv(csv)
#> # A tibble: 1 × 2
#>   date       datetime           
#>   <date>     <dttm>             
#> 1 2022-01-02 2022-01-02 05:12:00

如果你之前没有听说过 ISO8601,它是一个书写日期的国际标准2,其中日期的各个部分从大到小排列,并用 - 分隔。例如,在 ISO8601 中,2022 年 5 月 3 日写作 2022-05-03。ISO8601 日期也可以包含时间,其中小时、分钟和秒用 : 分隔,日期和时间部分用 T 或空格分隔。例如,你可以将 2022 年 5 月 3 日下午 4:26 写成 2022-05-03 16:262022-05-03T16:26

对于其他日期时间格式,你需要使用 col_types 加上 col_date()col_datetime() 以及一个日期时间格式。readr 使用的日期时间格式是许多编程语言通用的标准,用一个 % 后跟一个字符来描述日期组件。例如,%Y-%m-%d 指定一个由年、-、月(数字)、-、日组成的日期。表 Table 17.1 列出了所有选项。

Table 17.1: readr 能理解的所有日期格式
类型 代码 含义 示例
%Y 4 位数年份 2021
%y 2 位数年份 21
%m 数字 2
%b 缩写名称 Feb
%B 完整名称 February
%d 一位或两位数 2
%e 两位数 02
时间 %H 24 小时制小时 13
%I 12 小时制小时 1
%p AM/PM pm
%M 分钟 35
%S 45
%OS 带小数的秒 45.35
%Z 时区名称 America/Chicago
%z 与 UTC 的偏移量 +0800
其他 %. 跳过一个非数字字符 :
%* 跳过任意数量的非数字字符

下面的代码展示了几个应用于一个非常模糊的日期的选项:

csv <- "
  date
  01/02/15
"

read_csv(csv, col_types = cols(date = col_date("%m/%d/%y")))
#> # A tibble: 1 × 1
#>   date      
#>   <date>    
#> 1 2015-01-02

read_csv(csv, col_types = cols(date = col_date("%d/%m/%y")))
#> # A tibble: 1 × 1
#>   date      
#>   <date>    
#> 1 2015-02-01

read_csv(csv, col_types = cols(date = col_date("%y/%m/%d")))
#> # A tibble: 1 × 1
#>   date      
#>   <date>    
#> 1 2001-02-15

请注意,无论你如何指定日期格式,一旦将其导入 R,它总是以相同的方式显示。

如果你使用 %b%B 并且处理非英语日期,你还需要提供一个 locale()。请参阅 date_names_langs() 中的内置语言列表,或使用 date_names() 创建你自己的。

17.2.2 从字符串

日期时间格式规范语言功能强大,但需要仔细分析日期格式。另一种方法是使用 lubridate 的辅助函数,这些函数在你指定了组件顺序后会尝试自动确定格式。要使用它们,请确定年、月、日在你的日期中出现的顺序,然后按相同的顺序排列 “y”、“m” 和 “d”。这就得到了将解析你日期的 lubridate 函数的名称。例如:

ymd("2017-01-31")
#> [1] "2017-01-31"
mdy("January 31st, 2017")
#> [1] "2017-01-31"
dmy("31-Jan-2017")
#> [1] "2017-01-31"

ymd() 和类似的函数创建日期。要创建一个日期时间,请在解析函数名称的末尾加上一个下划线和 “h”、“m”、“s” 中的一个或多个:

ymd_hms("2017-01-31 20:11:59")
#> [1] "2017-01-31 20:11:59 UTC"
mdy_hm("01/31/2017 08:01")
#> [1] "2017-01-31 08:01:00 UTC"

你也可以通过提供一个时区来强制从一个日期创建一个日期时间:

ymd("2017-01-31", tz = "UTC")
#> [1] "2017-01-31 UTC"

这里我使用了 UTC3 时区,你可能也知道它是 GMT,即格林尼治标准时间 (Greenwich Mean Time),是 0° 经度的时间4。它不使用夏令时,这使得计算起来稍微容易一些。

17.2.3 从单个组件

有时,你不会有一个单一的字符串,而是将日期时间的各个组件分布在多个列中。这就是我们在 flights 数据中遇到的情况:

flights |> 
  select(year, month, day, hour, minute)
#> # A tibble: 336,776 × 5
#>    year month   day  hour minute
#>   <int> <int> <int> <dbl>  <dbl>
#> 1  2013     1     1     5     15
#> 2  2013     1     1     5     29
#> 3  2013     1     1     5     40
#> 4  2013     1     1     5     45
#> 5  2013     1     1     6      0
#> 6  2013     1     1     5     58
#> # ℹ 336,770 more rows

要从这类输入创建一个日期/时间,对日期使用 make_date(),对日期时间使用 make_datetime()

flights |> 
  select(year, month, day, hour, minute) |> 
  mutate(departure = make_datetime(year, month, day, hour, minute))
#> # A tibble: 336,776 × 6
#>    year month   day  hour minute departure          
#>   <int> <int> <int> <dbl>  <dbl> <dttm>             
#> 1  2013     1     1     5     15 2013-01-01 05:15:00
#> 2  2013     1     1     5     29 2013-01-01 05:29:00
#> 3  2013     1     1     5     40 2013-01-01 05:40:00
#> 4  2013     1     1     5     45 2013-01-01 05:45:00
#> 5  2013     1     1     6      0 2013-01-01 06:00:00
#> 6  2013     1     1     5     58 2013-01-01 05:58:00
#> # ℹ 336,770 more rows

让我们对 flights 中的四个时间列做同样的事情。这些时间的表示格式有点奇特,所以我们使用模运算来提取小时和分钟组件。一旦我们创建了日期时间变量,我们就专注于本章其余部分将要探讨的变量。

make_datetime_100 <- function(year, month, day, time) {
  make_datetime(year, month, day, time %/% 100, time %% 100)
}

flights_dt <- flights |> 
  filter(!is.na(dep_time), !is.na(arr_time)) |> 
  mutate(
    dep_time = make_datetime_100(year, month, day, dep_time),
    arr_time = make_datetime_100(year, month, day, arr_time),
    sched_dep_time = make_datetime_100(year, month, day, sched_dep_time),
    sched_arr_time = make_datetime_100(year, month, day, sched_arr_time)
  ) |> 
  select(origin, dest, ends_with("delay"), ends_with("time"))

flights_dt
#> # A tibble: 328,063 × 9
#>   origin dest  dep_delay arr_delay dep_time            sched_dep_time     
#>   <chr>  <chr>     <dbl>     <dbl> <dttm>              <dttm>             
#> 1 EWR    IAH           2        11 2013-01-01 05:17:00 2013-01-01 05:15:00
#> 2 LGA    IAH           4        20 2013-01-01 05:33:00 2013-01-01 05:29:00
#> 3 JFK    MIA           2        33 2013-01-01 05:42:00 2013-01-01 05:40:00
#> 4 JFK    BQN          -1       -18 2013-01-01 05:44:00 2013-01-01 05:45:00
#> 5 LGA    ATL          -6       -25 2013-01-01 05:54:00 2013-01-01 06:00:00
#> 6 EWR    ORD          -4        12 2013-01-01 05:54:00 2013-01-01 05:58:00
#> # ℹ 328,057 more rows
#> # ℹ 3 more variables: arr_time <dttm>, sched_arr_time <dttm>, …

有了这些数据,我们可以可视化一年中出发时间的分布情况:

flights_dt |> 
  ggplot(aes(x = dep_time)) + 
  geom_freqpoly(binwidth = 86400) # 86400 秒 = 1 天

一个频率多边形图,x 轴是出发时间(2013 年 1 月至 12 月), y 轴是航班数量(0-1000)。该频率多边形按天分箱,因此你看到的是按天计算的航班数量时间序列。 该模式主要由周模式主导;周末的航班较少。 在 2 月初、7 月初、11 月末和 12 月末有几天航班数量出奇地少。

或者在一天之内:

flights_dt |> 
  filter(dep_time < ymd(20130102)) |> 
  ggplot(aes(x = dep_time)) + 
  geom_freqpoly(binwidth = 600) # 600 秒 = 10 分钟

一个频率多边形图,x 轴是出发时间(1 月 1 日早上 6 点到午夜), y 轴是航班数量(0-17),按 10 分钟增量分箱。 由于变异性高,很难看出太多模式, 但大多数箱中有 8-12 个航班,并且在早上 6 点前和晚上 8 点后航班明显较少。

请注意,当你在数值上下文中使用日期时间(如在直方图中),1 表示 1 秒,所以 binwidth 为 86400 意味着一天。对于日期,1 表示 1 天。

17.2.4 从其他类型

你可能想要在日期时间和日期之间切换。这是 as_datetime()as_date() 的工作:

as_datetime(today())
#> [1] "2025-07-10 UTC"
as_date(now())
#> [1] "2025-07-10"

有时你会得到以距离“Unix 纪元”(1970-01-01)的数值偏移量表示的日期/时间。如果偏移量以秒为单位,使用 as_datetime();如果以天为单位,使用 as_date()

as_datetime(60 * 60 * 10)
#> [1] "1970-01-01 10:00:00 UTC"
as_date(365 * 10 + 2)
#> [1] "1980-01-01"

17.2.5 练习

  1. 如果你解析一个包含无效日期的字符串会发生什么?

    ymd(c("2010-10-10", "bananas"))
  2. today()tzone 参数有什么作用?为什么它很重要?

  3. 对于以下每一个日期时间,展示你将如何使用 readr 的列规范和 lubridate 函数来解析它。

    d1 <- "January 1, 2010"
    d2 <- "2015-Mar-07"
    d3 <- "06-Jun-2017"
    d4 <- c("August 19 (2015)", "July 1 (2015)")
    d5 <- "12/30/14" # 2014 年 12 月 30 日
    t1 <- "1705"
    t2 <- "11:15:10.12 PM"

17.3 日期时间组件

既然你知道了如何将日期时间数据导入 R 的日期时间数据结构中,让我们来探索一下你可以用它们做什么。本节将重点介绍让你获取和设置单个组件的访问器函数。下一节将探讨日期时间的算术运算如何工作。

17.3.1 获取组件

你可以使用访问器函数 year()month()mday() (月中的天)、yday() (年中的天)、wday() (周中的天)、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] 7月
#> 12 Levels: 1月 < 2月 < 3月 < 4月 < 5月 < 6月 < 7月 < 8月 < 9月 < ... < 12月
wday(datetime, label = TRUE, abbr = FALSE)
#> [1] 星期三
#> 7 Levels: 星期日 < 星期一 < 星期二 < 星期三 < 星期四 < ... < 星期六

我们可以使用 wday() 看到,工作日起飞的航班比周末多:

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

一个条形图,x 轴是星期几,y 轴是航班数量。 周一至周五的航班数量大致相同,约为 48,000 次,在一周中略有下降。 周日稍低(约 45,000 次),周六则低得多(约 38,000 次)。

我们还可以查看一小时内按分钟划分的平均出发延误。有一个有趣的模式:在 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()

一个折线图,x 轴是实际出发的分钟数(0-60),y 轴是平均延误时间(4-20)。 平均延误从 (0, 12) 开始,稳步增加到 (18, 20),然后急剧下降,在约 23 分钟时达到最小值,延误为 9 分钟。 然后再次增加到 (17, 35),并急剧减少到 (55, 4)。最后以增加到 (60, 9) 结束。

有趣的是,如果我们看计划出发时间,我们没有看到这么强的模式:

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

一个折线图,x 轴是计划出发的分钟数(0-60),y 轴是平均延误时间(4-16)。 模式相对不明显,只有一个小小的迹象表明,平均延误在一小时内可能从 10 分钟减少到 8 分钟。

那么为什么我们在实际出发时间上看到了那种模式呢?嗯,就像许多由人类收集的数据一样,航班倾向于在“整点”出发时间起飞,存在着强烈的偏好,如 Figure 17.1 所示。每当你处理涉及人类判断的数据时,都要警惕这种模式!

一个折线图,x 轴是出发分钟(0-60),y 轴是航班数量(0-60000)。 大多数航班计划在整点(约 60,000)或半点(约 35,000)出发。 此外,几乎所有航班都计划在五的倍数分钟出发, 在 15、45 和 55 分钟时略多一些。
Figure 17.1: 一个频率多边形图,显示了每小时计划起飞的航班数量。 你可以看到对像 0 和 30 这样的整数时间点的强烈偏好,以及通常对五的倍数的时间点的偏好。

17.3.2 取整

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

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

一个折线图,x 轴是周(2013 年 1 月至 12 月),y 轴是航班数量(2,000-7,000)。 从 2 月到 11 月,模式相当平稳,每周约有 7,000 个航班。 在年的第一周(约 4,500 个航班)和最后一周(约 2,500 个航班),航班数量要少得多。

你可以通过计算 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.

一个折线图,x 轴是出发时间。单位是自午夜以来的秒数,所以很难解释。

计算一对日期时间之间的差值会得到一个 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)

一个折线图,x 轴是出发时间(午夜到午夜),y 轴是航班数量(0 到 15,000)。 在凌晨 5 点之前几乎没有航班(<100)。然后航班数量迅速上升 到每小时 12,000 次,在上午 9 点达到峰值 15,000 次,然后在上午 10 点到下午 2 点之间下降到 每小时约 8,000 次。然后航班数量增加到 每小时约 12,000 次,直到晚上 8 点,之后再次迅速下降。

17.3.3 修改组件

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

(datetime <- ymd_hms("2026-07-08 12:34:56"))
#> [1] "2026-07-08 12:34:56 UTC"

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() 创建一个新的日期时间。这也允许你一步设置多个值:

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_timesched_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(周期),表示人类的单位,如周和月。
  • intervals(区间),表示一个起点和一个终点。

你如何在时长 (duration)、周期 (period) 和区间 (interval) 之间选择?一如既往,选择能解决你问题的最简单的数据结构。如果你只关心物理时间,使用时长;如果你需要加上人类的时间单位,使用周期;如果你需要计算一个跨度在人类单位中有多长,使用区间。

17.4.1 时长 (Durations)

在 R 中,当你用一个日期减去另一个日期时,你会得到一个 difftime 对象:

# Hadley 多大了?
h_age <- today() - ymd("1979-10-14")
h_age
#> Time difference of 16706 days

一个 difftime 类的对象记录了一个以秒、分钟、小时、天或周为单位的时间跨度。这种模糊性可能会让 difftime 的使用有些痛苦,所以 lubridate 提供了一个总是使用秒的替代方案:duration(时长)。

as.duration(h_age)
#> [1] "1443398400s (~45.74 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)"

时长总是以秒为单位记录时间跨度。更大的单位是通过将分钟、小时、天、周和年转换为秒来创建的:1 分钟 60 秒,1 小时 60 分钟,1 天 24 小时,1 周 7 天。更大的时间单位问题更多。一年使用一年中的“平均”天数,即 365.25 天。没有办法将一个月转换为时长,因为变数太多了。

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

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

你可以将时长加到日期上或从日期中减去:

tomorrow <- today() + ddays(1)
last_year <- today() - dyears(1)

然而,因为时长代表的是一个精确的秒数,有时你可能会得到一个意想不到的结果:

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(周期)。周期也是时间跨度,但没有固定的秒数长度,而是使用“人类”的时间单位,比如天和月。这使得它们能以更直观的方式工作:

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

和时长一样,周期也可以通过一些方便的构造函数创建。

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"

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

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

当然,还可以把它们加到日期上。与时长相比,周期更可能做到你所期望的:

# 闰年
ymd("2024-01-01") + dyears(1)
#> [1] "2024-12-31 06:00:00 UTC"
ymd("2024-01-01") + years(1)
#> [1] "2025-01-01"

# 夏令时
one_am + ddays(1)
#> [1] "2026-03-09 02:00:00 EDT"
one_am + days(1)
#> [1] "2026-03-09 01:00:00 EDT"

让我们用周期来修正一个与我们航班日期有关的奇怪现象。一些飞机似乎在从纽约市起飞之前就到达了目的地。

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 (区间)。一个区间是一对开始和结束的日期时间,或者你可以把它看作是一个有起点的时长。

你可以通过 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,即东部标准时间 (Eastern Standard Time)。然而,澳大利亚和加拿大也有 EST!为了避免混淆,R 使用国际标准的 IANA 时区。这些时区使用一致的命名方案 {地区}/{位置},通常形式为 {大洲}/{城市}{大洋}/{城市}。例如 “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] "Asia/Shanghai"

(如果 R 不知道,你会得到一个 NA。)

并用 OlsonNames() 查看所有时区名称的完整列表:

length(OlsonNames())
#> [1] 597
head(OlsonNames())
#> [1] "Africa/Abidjan"     "Africa/Accra"       "Africa/Addis_Ababa"
#> [4] "Africa/Algiers"     "Africa/Asmara"      "Africa/Asmera"

在 R 中,时区是日期时间的一个属性,只控制打印显示。例如,下面这三个对象代表同一时刻:

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(格林尼治标准时间)。它没有夏令时,这使其成为一个方便的计算表示。结合日期时间的运算,比如 c(),通常会丢掉时区。在这种情况下,日期时间将以第一个元素的时区显示:

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 提供的帮助你处理日期时间数据的工具。处理日期和时间似乎比必要的要困难,但希望本章能帮助你理解原因——日期时间比初看起来要复杂得多,处理每一种可能的情况都增加了复杂性。即使你的数据从未跨越夏令时边界或涉及闰年,函数也需要能够处理这些情况。

下一章将总结缺失值。你已经在一些地方看到过它们,并且无疑在自己的分析中遇到过,现在是时候提供一系列处理它们的有用技巧了。


  1. 如果一个年份能被 4 整除,那么它就是闰年,除非它也能被 100 整除,但如果它同时能被 400 整除,那它仍然是闰年。换句话说,每 400 年中,有 97 个闰年。↩︎

  2. https://xkcd.com/1179/↩︎

  3. 你可能会想 UTC 代表什么。它是英语 “Coordinated Universal Time”(协调世界时)和法语 “Temps Universel Coordonné” 之间的一个妥协。↩︎

  4. 猜猜是哪个国家提出了经度系统,没有奖品。↩︎