4 lubridate

本章将介绍如何在 R 中处理日期和时间。乍看起来,日期和时间非常容易,但是随着对他们的了解越来越多,我们就会越来越发现其复杂之处。思考一下下面三个看似很简单的问题:

  1. 确定闰年的完整规则是什么?

“四年一闰,百年不闰,四百年再闰”

  1. 每一天都是24小时吗?

世界上很多地区使用夏时制(不包括中国),因此很多天是23个小时,有些天则是25个小时。

  1. 每分钟都是60秒吗?

因为地球自转正在逐渐变慢,所以有时候要增加一个“闰秒”,有些分钟就变成了61秒。目前,全球已经进行了27次闰秒,均为正闰秒。最近一次闰秒在北京时间2017年1月1日7时59分59秒(时钟显示07:59:60)出现。这也是本世纪的第五次闰秒。

日期和时间非常复杂,因为它们要兼顾两种物理现象(地球的自转以及围绕太阳的公转)和一系列地理政治现象(包括月份、市区和夏时制)。本章主要讨论 lubridate 包,它可以使得R对日期和时间的处理更加容易。lubridate 不是 tidyverse 的核心 R 包,故需要手动加载。此外还需要 nycflights 作为练习数据。

4.1 创建日期和时间

表示日期或时间的数据有3种类型:

  • 日期: 用年月日表示,在tibble中显示为<date>
  • 时间: 一天中的某个时刻,用24小时制表示,在tibble中显示为<time>
  • 日期时间: 可以唯一标识某个时刻(通常精确到秒)的日期+时间,在tibble中显示为<dttm>。而这种类型在R语言的其他地方被称作POSIXct

如果能够满足需要,就应该使用最简单的数据类型。这意味着只要能够使用日期型数据,那么就不应该使用日期时间型数据。日期时间型数据要复杂很多,因为要处理时期,我们会在本章末尾继续讨论这个问题。

要想得到当前日期或当前时期时间,可以使用 today()now() 函数:

除此之外,以下3种方法也可以创建日期或时间:

  • 通过字符串创建
  • 通过日期时间的各个成分创建
  • 通过现有的日期时间对象创建

4.1.1 通过字符串创建

日期时间数据经常用字符串表示。在事先知晓各个组成部分顺序的前提下,通过 lubridate 中的一些辅助函数,可以轻松将字符串转换为日期时间格式。因为要想使用函数,需要先确定年、月、日在日期数据中的顺序,然后按照同样的顺讯排列字母 y、m、d,这样就可以组成能够创建日期格式的 lubridate 函数名称,例如:

这些函数也可以接受不带引号的数值,这是创建单个时期时间对象的最简单的方法。在筛选日期时间数据时,就可以使用这种方法:

ymd()和其他类似函数可以创建日期数据。想要创建日期时间型数据,可以在后面加一个下划线,以及h、m、s之中的一个或多个字母(依然要遵循顺序),这样就可以得到解析日期时间数据的函数了:

如果用类似函数尝试解析包含无效内容的字符串,将会返回 NA :

通过添加一个时区参数,可以将一个时期强制转换为日期时间:

4.1.2 通过各个成分创建

除了单个字符串,日期时间数据的各个成分还经常分布在表格的多个列中。flights 数据就是这样的:

想要用这样的多个变量创建一个完整的日期或时间数据,可以使用make_date(year,month.day,hour,min,sec,tz)(创建日期)或make_datetime(year,month.day,hour,min,sec,tz)(创建日期时间)函数:

因为这里没有给出分钟,所以 make_datetime() 默认其 为0.

flights 数据集中的 hourtime 均是航班起飞时间的预计值。为了算出实际起飞、到达时间,我们需要使用dep_timearr_time这两个变量,不过,它们同时包括了小时和分钟数:

为了创建出表示实际出发和到达时间的日期时间型数据,我们首先编写一个函数以使make_datetime函数适应dep_timearr_time这种比较奇怪的表示方式,思想是使用模运算将小时成分与分钟成分分离。一旦创建了日期时间变量,我们就在本章剩余部分使用这些变量进行讨论:

我们还可以使用这些数据做出一年间出发时间或某一天内出发时间的可视化分布(精确到分钟)。注意,当将日期时间型数据当做数值使用时(比如在直方图中),1 表示一秒,因此分箱宽度 86400 才能够表示一天。对于日期型数据(通过 make_date()创建),1则表示一天。

4.1.3 日期时间型和日期型数据的相互转换

有时候需要在日期时间和日期型数据之间进行转换,这正是as_datetime()as_date()函数的功能:

有时人们会使用距离”Unix时间戳“(即1970-01-01)的偏移量来表示日期时间。如果偏移量单位是秒,就用as_datetime()函数来转换 ; 如果偏移量单位是天,就用as_date()函数来转换:

4.2 日期时间成分

现在我们知道了如何将日期时间型数据保存在 R 的相应数据结构中。接下来我们研究一下能够对这些数据进行何种处理。本节将重点关注如何获取日期时间型或者日期型数据中的成分,例如如何从一个日期中获得相应的年、月、日。

4.2.1 获取成分

如果想要提取出日其中的独立成分,可以使用以下访问器函数(accessor function): year()month()mday()(一个月中的第几天)、yday()(一年中的第几天)、wday()(一周中的第几天,即星期几)、hour()minute()second()

对于wday()month()函数,可以设置 label = T 来返回月份名称和星期数的缩写,还可以设置abbr = F来返回全名 ; 这样做还有一个重要意义,它将返回的字符串变为有序因子, 否则 ggplot2 将其作为连续型变量对待:

通过wday()函数,我们可以知道在工作日出发的航班要多于周末出发的航班:

再看一个使用minute()函数获取分钟成分的例子。比如我们想知道出发时间的分钟数与平均到达延误时间的关系:

我们可以发现一个有趣的趋势,似乎在2030分钟和第5060分钟出发的航班的到达延误时间远远低于其他时间出发的航班。

4.2.2 舍入(Rouding)

另一种获取日期成分的办法是将日期时间型数据近似到一个邻近的时间单位上,这要通过 round_date()、floor_date()、ceiling_date() 等函数。这些函数的参数都包括一个待调整的日期时间型数据(可以是向量),以及希望近似到的时间单位。函数会将这个日期时间型数据舍下 floor_date()、入上ceiling_date()或者四舍五入 round_date() 到这个时间单位。例如,以下代码可以绘制出每周的航班数量:

下面的例子可以更深入地了解这个函数族的用法:

4.2.3 设置成分

还可以使用访问器函数来指定日期时间型数据中的成分:

除了直接修改,还可以通过update()函数来更新一个日期时间型数据,只需要在参数中指定各个成分的新值。这样也可以同时设置多个成分的更改:

如果修改yday,相当于同时修改mdaymonth:

update()函数还有一种比较巧妙的用法,比如我们想可视化一年中所有航班的的出发时间在一天中的分布:

如果不用 update() 函数,我们可能需要先用hour()、minute()、second()获取三种成分,然后再用make_datetime()对这三种成分进行合并。

4.3 时间间隔(Time Span)

接下来我们将讨论如何对时间进行数学运算,其中包括减法、加法和除法。我们可以把用于进行数学运算的时间称为时间间隔,它表示一种跨度,而不是某个静态的时间。本节将介绍3种用于表示时间间隔的重要类:

  • 时期(Durations):以秒为单位表示一段精确的时间
  • 阶段(Periods): 用人类单位定义的时间间隔,如几周或几个月
  • 区间(Intervals):由起点和终点定义的一段时间

4.3.1 时期 Durations

默认情况下,如果我们将两个日期相间,将得到一个 difftime 类对象:

difftime对象的单位可以是秒、分钟、小时、日或周。这种模棱两可的对象处理起来非常困难,,所以 lubridate提供了总是以秒为单位的另一种时间间隔:时期

可以用很多方便的函数来构造时期,它们有统一的格式d + 时间单位(复数)

时期 Durations 总是以秒为单位来记录时间间隔。使用标准比率(1 分钟为 60 秒,1 小时为 60 分钟,1 天为 24 小时,1 周为 7 天,一年为 365 天)将分钟、小时、周和年转换为秒,从而建立具有更大值的对象。出于相同的原因,没有dmonths()函数, 因为一个月可能有 31 天、30 天、29 天或 28 天,所以 lubridate 不能将它转换为一个确切的秒数。

可以对时期进行加法和乘法操作:

最重要的,时期可以和日期时间型数据进行运算

然而,因为时期表示的是秒为单位的一个精确数值,有时我们会得到意想不到的结果:

为什么3月12日下午1点加上一天后变成了下午2点?如果仔细观察,就会发现时区发生了变化。因为夏时制,3月12日只有23个小时,但我们告诉R“加上24个小时代表的秒数”,所以得到了一个不正确的时间。

4.3.2 阶段 Periods

为了解决时期对象的问题,lubridate 提供了 阶段 对象。阶段也是一种 time span,但是它不以秒为单位 ; 相反,它使用“人工”时间,比如日和月。这使得阶段使用起来更加符合习惯

one_pm + days(1)告诉 R,加上一天,而不是加上多少秒。

创建阶段对象的函数与时期很类似,只是前面少个“d”,不要把创建阶段的函数与获取时间日期成分的函数搞混了,创建 Periods 的函数都是复数形式:

可以对阶段进行加法和乘法操作:

当然,阶段也可以和日期时间型数据进行运算。与 Durations 相比,使用 Periods 得到的计算结果更符合我们的预期:

下面我们使用 Periods 来解决与航班日期有关的一个怪现象。有些飞机似乎从纽约市起飞前就到达了目的地:

这些都是过夜航班。我们使用了同一种日期来表示出发时间和到达时间,但这些航班是在第二天到达的。将每个过夜航班的到达时间加上一个days(1),就可以解决这个问题了:

4.3.3 区间 Intervals

显然,dyears(1)/ddays(365)应该返回1,因为时期总是以秒来表示的,表示1年的时间就定义为相当于365天的秒数。
那么years(1) / days(1)应该返回什么呢?如果年份 是 2015 年,那么结果就是 365,但如果年份是 2016 年,那么结果就是 366!没有足够的信息让 lubridate 返回一个明确的结果。lubridate 的做法是给出一个估计值,同时给出一条警告:

如果需要更精确的测量方式,那么就必须使用区间。区间是带有明确起点和终点的时期,这使得它非常精确,可以用interval()来创建一个区间:

一种更简单的创建区间的方式是使用操作符%--%

要想知道一个区间内有多少个阶段,需要使用整数除法。利用区间进行精确计算:

4.3.4 小结

如何在时期、阶段和区间中进行选择呢?只要能够解决问题,我们就应该选择最简单的数据结构。如果只关心物理时间,那么就使用时期 Durations ; 如果还需要考虑人工时间,那么就使用阶段 Periods ; 如果需要找出人工时间范围内有多长的时间间隔,那么就使用区间。

下图总结了不同数据类型之间可以进行的数学运算: