R语言中的数量

引言

数值向量是数据科学的基石,而你在本书前面的部分已经多次使用过它们。现在,是时候系统地了解在 R 中可以对它们做些什么了,以确保你具备良好的基础来应对未来任何涉及数值向量的问题。

我们将先向你介绍几个工具,帮助你在只有字符串时生成数字,然后再更详细地讲解 count()。接着,我们会深入介绍与 mutate() 很搭配的各种数值转换,包括一些更通用、可应用于其他类型向量的转换,不过它们通常还是与数值向量一起使用。最后,我们会介绍与 summarize() 很搭配的汇总函数,并展示它们也可以如何与 mutate() 一起使用。

生成数字

在大多数情况下,你拿到的数字已经以 R 的数值类型之一记录好了:整数或双精度数。不过,在某些情况下,你会把它们当作字符串遇到,可能是因为你通过将列标题转换生成了它们,或者是因为在数据导入过程中出了问题。

readr 提供了两个有用的函数,用于将字符串解析为数字:parse_double() 和 parse_number()。当你有以字符串形式写出的数字时,请使用 parse_double()

library(readr)
x <- c("1.2", "5.6", "1e3")
parse_double(x) # 需要readr包

当字符串包含你想忽略的非数字文本时,请使用 parse_number()。这对于货币数据和百分比尤其有用:

x <- c("$1,234", "USD 3,513", "59%")
parse_number(x)

count

令人惊讶的是,仅靠计数和一点基础算术,你就能完成很多数据科学工作,因此 dplyr 努力让使用 count() 进行计数尽可能简单。这个函数非常适合在分析过程中进行快速探索和检查:

library(nycflights13)
flights |> count(dest)

(我们通常把 count() 写在一行,因为它一般是在控制台里用来快速检查某个计算是否按预期工作的。)

如果你想查看最常见的值,请添加 sort = TRUE:

library(dplyr)
flights |> count(dest, sort = TRUE)

并且请记住,如果你想查看所有值,可以使用 |> View() 或 |> print(n = Inf)

你可以使用 group_by()、summarize() 和 n() 手动执行相同的计算。这很有用,因为它允许你同时计算其他摘要:

# 按目的地分组,统计每个目的地的航班数量,并计算平均到达延误时间
flights |>
group_by(dest) |>
summarize(
n = n(), # 计算每个目的地的航班总数
delay = mean(arr_delay, na.rm = TRUE) # 计算平均到达延误,忽略缺失值
)

n() 是一个特殊的摘要函数,它不接受任何参数,而是访问关于“当前”组的信息。这意味着它只能在 dplyr 动词内部使用:

n()

n() 和 count() 有几个变体,你可能会觉得很有用:

n_distinct(x) 计算一个或多个变量中不同(唯一)值的数量。例如,我们可以找出哪些目的地由最多的航空公司提供服务:

# 按目的地分组
flights |>
group_by(dest) |>
# 统计每个目的地对应的不同航空公司数量
summarize(carriers = n_distinct(carrier)) |>
# 按航空公司数量降序排列
arrange(desc(carriers))

加权计数就是求和。例如,你可以“统计”每架飞机飞行的英里数:

# 按飞机编号分组
flights |>
group_by(tailnum) |>
# 统计每架飞机的总飞行里程
summarize(miles = sum(distance))

加权计数是一个常见问题,因此 count() 有一个 wt 参数,可以完成同样的事情:

# 按飞机编号统计,并对飞行里程 distance 求和
flights |> count(tailnum, wt = distance)

你可以将 sum() 和 is.na() 结合起来统计缺失值。在 flights 数据集中,这表示被取消的航班:

# 按目的地分组
flights |>
group_by(dest) |>
# 统计每个目的地中出发时间缺失的航班数,即取消的航班数
summarize(n_cancelled = sum(is.na(dep_time)))

数值变换

变换函数与 mutate() 很适配,因为它们的输出长度与输入相同。绝大多数变换函数已经内置在基础 R 中。把它们全部列出来是不现实的,因此这一节只展示最有用的那些。比如,虽然 R 提供了你能想到的所有三角函数,但我们这里不列出它们,因为数据科学很少需要用到。

算术与回收规则

我们在第 2 章介绍了算术的基础知识(+-*/^),并且之后也多次使用它们。这些函数不需要太多解释,因为它们的作用就是你在小学学过的那些。不过,我们需要简单谈一下回收规则,它决定了当左侧和右侧的长度不同时时会发生什么。这一点对像 flights |> mutate(air_time = air_time / 60) 这样的操作很重要,因为 / 左边有 336,776 个数,而右边只有一个。

R 通过回收,或者重复,较短的向量来处理长度不匹配的问题。我们可以通过在数据框外创建一些向量,更容易看到这一机制的运行:

x <- c(1, 2, 10, 20)
x / 5
# is shorthand for
x / c(5, 5, 5, 5)

通常,你只想回收单个数字(即长度为 1 的向量),但 R 会回收任何较短长度的向量。一般来说(但并非总是),如果较长的向量不是较短向量长度的倍数,R 会给你一个警告:

x * c(1, 2)
x * c(1, 2, 3)

这些回收规则也适用于逻辑比较(==<<=>>=!=),如果你不小心把 %in% 写成了 ==,而数据框的行数又碰巧不合适,就可能得到一个令人意外的结果。例如,看看这段试图找出 1 月和 2 月所有航班的代码:

# 过滤出 1 月和 2 月的航班
flights |>
filter(month == c(1, 2))

这段代码运行时不会报错,但它返回的结果并不是你想要的。由于回收规则的存在,它找到了奇数行中 1 月出发的航班,以及偶数行中 2 月出发的航班。遗憾的是,因为 flights 有偶数行,所以不会出现警告。

为防止这类静默失败,大多数 tidyverse 函数使用更严格的回收方式,只回收单个值。不幸的是,这在这里并没有帮助,在许多其他情况下也一样,因为关键的计算是由基础 R 函数 == 完成的,而不是 filter()

最小值和最大值

df <- tribble(
~x, ~y,
1, 3,
5, 2,
7, NA,
)
df |>
mutate(
min = pmin(x, y, na.rm = TRUE),
max = pmax(x, y, na.rm = TRUE)
)

请注意,这些与摘要函数 min() 和 max() 不同,后者会接收多个观测值并返回单个值。若所有最小值和所有最大值都相同,你就能判断自己用了错误的形式:

df |>
mutate(
min = min(x, y, na.rm = TRUE),
max = max(x, y, na.rm = TRUE)
)

模运算

模算术是这种运算的技术名称:你在学习小数点之前就已经做过了,也就是除法会得到一个整数和一个余数。在 R 中,%/% 表示整数除法,%% 用来计算余数:

1:10 %/% 3 # 整除
1:10 %% 3 # 取余

模运算在 flights 数据集中很有用,因为我们可以用它把 sched_dep_time 变量拆解为小时和分钟:

flights |>
mutate(
hour = sched_dep_time %/% 100, # 提取计划起飞时间中的“小时”部分
minute = sched_dep_time %% 100, # 提取计划起飞时间中的“分钟”部分
.keep = "used" # 只保留本次计算中用到的原始列
)

我们可以将其与第 12.4 节中的 mean(is.na(x)) 技巧结合起来,看看取消航班的比例如何随一天中的时间变化。结果如图 13.1 所示。

library(ggplot2)
flights |>
group_by(hour = sched_dep_time %/% 100) |> # 按计划起飞时间的“小时”分组
summarize(prop_cancelled = mean(is.na(dep_time)), # 计算每小时取消航班的比例
n = n()) |> # 统计每小时的航班数量
filter(hour > 1) |> # 过滤掉 1 点及以前的数据
ggplot(aes(x = hour, y = prop_cancelled)) + # 以小时为 x 轴,取消比例为 y 轴作图
geom_line(color = "grey50") + # 绘制灰色折线
geom_point(aes(size = n)) # 绘制点,并用点大小表示航班数量

对数

对数是一种非常有用的变换,适用于处理跨越多个数量级的数据,并将指数增长转换为线性增长。在 R 中,你可以选择三种对数函数:log()(自然对数,以 e 为底)、log2()(以 2 为底)和 log10()(以 10 为底)。我们建议使用 log2() 或 log10()log2() 很容易解释,因为在对数尺度上相差 1,对应于原始尺度上翻倍;相差 -1,则对应于减半;而 log10() 则便于反向转换,因为例如 3 表示 103=1000log() 的逆函数是 exp();要计算 log2() 或 log10() 的逆函数,则需要使用 2^ 或 10^

四舍五入

使用 round(x) 将数字四舍五入到最接近的整数:

round(123.456)

你可以使用第二个参数 digits 来控制四舍五入的精度。round(x, digits) 会四舍五入到最接近的 10^-n,因此 digits = 2 时会四舍五入到最接近的 0.01。这个定义很有用,因为它意味着 round(x, -3) 会四舍五入到最接近的千位,而它确实如此:

round(123.456, 2) # two digits
round(123.456, 1) # one digit
round(123.456, -1) # round to nearest ten
round(123.456, -2) # round to nearest hundred

round() 有一个看起来一开始有些出乎意料的怪现象:

round(c(1.5, 2.5))

round() 使用的是所谓的“向偶数舍入”或“银行家舍入”:如果一个数字正好处于两个整数的中间,它会被舍入到偶数的那个整数。这是一个很好的策略,因为它能保持舍入结果不偏不倚:所有 0.5 中有一半会向上舍入,另一半会向下舍入。

round() 与 floor() 和 ceiling() 成对使用:floor() 总是向下舍入,而 ceiling() 总是向上舍入:

x <- 123.456
floor(x)
ceiling(x)

将数字按范围分组

使用 cut() 将数值向量划分(即分箱)为离散的区间:

x <- c(1, 2, 5, 10, 15, 20)
cut(x, breaks = c(0, 5, 10, 15, 20))

断点不需要均匀分布:

cut(x, breaks = c(0, 5, 10, 100))
# 将 x 按指定断点分组,并为各区间指定标签
cut(x,
breaks = c(0, 5, 10, 15, 20),
labels = c("sm", "md", "lg", "xl")
)

范围之外的任何值都会变成 NA:

y <- c(NA, -10, 5, 10, 30)
cut(y, breaks = c(0, 5, 10, 15, 20))

查看文档了解其他有用的参数,例如 right 和 include.lowest,它们用于控制区间是 [a, b) 还是 (a, b],以及是否应将最低区间设为 [a, b]

累积和滚动聚合

Base R 提供了 cumsum()cumprod()cummin()cummax(),用于计算累计和、累计积、累计最小值和累计最大值。dplyr 提供了 cummean() 用于计算累计平均值。累计和在实际中最常见:

x <- 1:10
cumsum(x)

通用变换

以下各节描述了一些常用的通用变换,这些变换通常用于数值向量,但也可应用于所有其他列类型。

排名

dplyr 提供了许多受 SQL 启发的排名函数,但你应该始终从 dplyr::min_rank() 开始。它使用处理并列项的典型方法,例如:第 1、2、2、4。

x <- c(1, 5, 5, 17, 22, NA)
min_rank(x)

请注意,最小的值会获得最低的排名;使用 desc(x) 可让最大的值获得最小的排名:

min_rank(desc(x))

如果 min_rank() 不能满足你的需求,可以看看 dplyr::row_number()dplyr::dense_rank()dplyr::percent_rank() 和 dplyr::cume_dist() 这些变体。详情请参阅文档。

# 创建包含向量 x 的 tibble
df <- tibble(x = x)
# 计算不同的排名指标
df |>
mutate(
# 按 x 的顺序生成行号排名
row_number = row_number(x),
# 密集排名:相同值共享同一名次,且名次不跳号
dense_rank = dense_rank(x),
# 百分位排名:返回每个值在分布中的相对位置
percent_rank = percent_rank(x),
# 累积分布:返回小于等于当前值的比例
cume_dist = cume_dist(x)
)

你可以通过为 base R 的 rank() 选择合适的 ties.method 参数来实现许多相同的结果;你可能还希望将 na.last = "keep" 设为保留 NA 为 NA

row_number() 也可以在 dplyr 动词内部不带任何参数使用。在这种情况下,它会给出“当前”行的编号。当它与 %% 或 %/% 结合使用时,可作为将数据划分为大小相近组别的有用工具:

# 创建一个包含 1 到 10 的 id 列的 tibble
df <- tibble(id = 1:10)
# 基于行号进行分组计算
df |>
mutate(
# 从 0 开始的行号
row0 = row_number() - 1,
# 将行号按 3 取余,得到 3 个循环分组
three_groups = row0 %% 3,
# 每 3 行划分为一组
three_in_each_group = row0 %/% 3
)

偏移量

dplyr::lead() 和 dplyr::lag() 允许你引用紧邻“当前”值之前或之后的值。它们返回与输入长度相同的向量,并在开头或结尾用 NA 填充:

# 创建一个数值向量 x
x <- c(2, 5, 11, 11, 19, 35)
# lag(x) 返回前一个值,首位用 NA 填充
lag(x)
# lead(x) 返回后一个值,末位用 NA 填充
lead(x)

x - lag(x) 给出了当前值与前一个值之间的差值。

x - lag(x)

x == lag(x) 告诉你当前值何时发生变化。

# 判断当前值是否与前一个值相同
x == lag(x)

你可以通过使用第二个参数 n 来向前或向后移动多个位置。

连续标识符

events <- tibble(
time = c(0, 1, 2, 3, 5, 10, 12, 15, 17, 19, 20, 27, 28, 30)
)

并且你已经计算出每个事件之间的时间间隔,并判断是否存在足够大的间隔来满足条件:

# 计算每个事件与前一个事件之间的时间差,并判断是否存在至少 5 分钟的间隔
events <- events |>
mutate(
diff = time - lag(time, default = first(time)),
has_gap = diff >= 5
)
events

但我们如何从这个逻辑向量得到可以用于 group_by() 的结果呢?第 13.4.7 节介绍的 cumsum() 就派上用场了:当出现间隔,也就是 has_gap 为 TRUE 时,组号就会加一(第 12.4.2 节):

# 根据 has_gap 的 TRUE 出现次数累计分组编号
events |> mutate(
group = cumsum(has_gap)
)

创建分组变量的另一种方法是使用 consecutive_id(),它会在其参数中的某个值发生变化时开始一个新的组。例如,受这个 Stack Overflow 问题的启发,假设你有一个包含一堆重复值的数据框:

df <- tibble(
x = c("a", "a", "a", "b", "c", "c", "d", "e", "a", "a", "b", "b"),
y = c(1, 2, 3, 2, 4, 1, 3, 9, 4, 8, 10, 199)
)

如果你想保留每个重复的 x 的第一行,可以使用 group_by()consecutive_id() 和 slice_head()

# 按连续相同的 x 值分组,并保留每组的第一行
df |>
group_by(id = consecutive_id(x)) |>
slice_head(n = 1)

数值摘要

仅使用我们已经介绍过的计数、均值和求和,就能让你走很远,但 R 还提供了许多其他有用的摘要函数。下面列出一些你可能会觉得有用的函数。

居中

到目前为止,我们主要使用 mean() 来概括一组数值的中心。正如我们在第 3.6 节中看到的那样,由于均值是总和除以数量,因此即使只有几个异常高或异常低的值,它也会受到影响。另一种方法是使用 median(),它会找到位于向量“中间”的一个值,也就是说,50% 的值高于它,50% 的值低于它。根据你所关注变量的分布形状,均值或中位数可能是更好的中心度量。例如,对于对称分布,我们通常报告均值;而对于偏态分布,我们通常报告中位数。

图 13.2 比较了每个目的地的平均起飞延误时间与中位起飞延误时间(单位:分钟)。中位延误总是小于平均延误,因为航班有时会晚点几个小时起飞,但从不会提前几个小时起飞。

# 按年、月、日分组,计算每天的出发延误平均值、中位数和航班数量
flights |>
group_by(year, month, day) |>
summarize(
mean = mean(dep_delay, na.rm = TRUE),
median = median(dep_delay, na.rm = TRUE),
n = n(),
.groups = "drop"
) |>
# 绘制平均延误与中位延误的散点图
ggplot(aes(x = mean, y = median)) +
# 添加斜率为 1 的参考线
geom_abline(slope = 1, intercept = 0, color = "white", linewidth = 2) +
# 绘制每一天的点
geom_point()

你可能还会想到众数,也就是最常见的值。这种摘要方法只适用于非常简单的情况(这也是你可能在中学里学过它的原因),但对许多真实数据集来说并不好用。如果数据是离散的,可能会有多个最常见值;如果数据是连续的,则可能根本不存在最常见值,因为每个值都略有不同。基于这些原因,统计学家通常不太使用众数,而且 base R 中也没有包含 mode 函数。

最小值、最大值和分位数

如果你感兴趣的是中心以外的位置呢?min() 和 max() 会分别给出最大值和最小值。另一个强大的工具是 quantile(),它是中位数的推广:quantile(x, 0.25) 会找出 x 中大于 25% 数值的那个值,quantile(x, 0.5) 等同于中位数,而 quantile(x, 0.95) 会找出大于 95% 数值的那个值。

对于 flights 数据,你可能更想查看延误的 95% 分位数,而不是最大值,因为它会忽略最严重延误的 5% 航班,而这些航班的延误可能非常极端。

# 按年、月、日分组,计算每天出发延误的最大值和 95% 分位数
flights |>
group_by(year, month, day) |>
summarize(
max = max(dep_delay, na.rm = TRUE),
q95 = quantile(dep_delay, 0.95, na.rm = TRUE),
.groups = "drop"
)

离散程度

我们可以用它来揭示 flights 数据中的一个小异常。你可能会以为起点和终点之间的距离分散程度应该为 0,因为机场总是在同一个位置。但下面的代码揭示了机场 EGE 的一个数据异常:

# 按起点和终点分组,计算每条航线的距离四分位距和航班数量
# 再筛选出距离四分位距大于 0 的航线,找出距离不一致的数据
flights |>
group_by(origin, dest) |>
summarize(
distance_iqr = IQR(distance),
n = n(),
.groups = "drop"
) |>
filter(distance_iqr > 0)

分布

值得记住的是,上面描述的所有汇总统计量,都是把分布压缩成一个单一数字的方式。这意味着它们本质上是有简化性的,如果你选错了汇总方式,就很容易忽略组间的重要差异。这就是为什么,在确定汇总统计量之前,先把分布可视化总是一个好主意。

图 13.3 展示了出发延误的整体分布。该分布严重偏斜,以至于我们不得不放大才能看清数据的大部分。这表明均值不太可能是一个好的摘要统计量,我们或许更倾向于使用中位数。

另外,检查子组的分布是否与整体相似也是个好主意。在下面的图中,叠加了 365 条 dep_delay 的频率多边形,每条对应一天。各个分布看起来遵循相同的模式,这说明对每一天使用相同的摘要统计量是可行的。

# 过滤出起飞延误小于 120 分钟的航班
# 按“月 + 日”组合分组,绘制每一天的起飞延误频率多边形
# 用较小的 binwidth 让分布更平滑,alpha 用于叠加时的透明度控制
flights |>
filter(dep_delay < 120) |>
ggplot(aes(x = dep_delay, group = interaction(day, month))) +
geom_freqpoly(binwidth = 5, alpha = 1/5)

不要害怕为你正在处理的数据探索一些专门定制的摘要统计量。在这种情况下,这可能意味着分别汇总提前起飞的航班和延误起飞的航班;或者由于这些值严重偏斜,你也可以尝试进行对数变换。最后,不要忘记你在第 3.6 节学到的内容:无论何时创建数值摘要,最好都把每个组中的观测数量一起包含进去。

位置

还有一种最后的摘要类型对数值向量很有用,但它也适用于其他任何类型的值:提取特定位置上的值:first(x)last(x) 和 nth(x, n)

例如,我们可以找出每天的第一班、第五班和最后一班出发航班:

# 按年、月、日分组
# 汇总每一天的起飞时间:
# first_dep:当天第一个起飞时间(忽略缺失值)
# fifth_dep:当天第五个起飞时间(忽略缺失值)
# last_dep:当天最后一个起飞时间(忽略缺失值)
flights |>
group_by(year, month, day) |>
summarize(
first_dep = first(dep_time, na_rm = TRUE),
fifth_dep = nth(dep_time, 5, na_rm = TRUE),
last_dep = last(dep_time, na_rm = TRUE)
)

(注意:由于 dplyr 函数使用下划线 _ 来分隔函数名和参数名,因此这些函数使用 na_rm,而不是 na.rm。)

如果你熟悉 [,我们将在第 27.2 节回到它,那么你可能会想,自己是否还需要这些函数。原因有三点:default 参数允许你在指定位置不存在时提供默认值,order_by 参数允许你在局部范围内覆盖行的顺序,而 na_rm 参数允许你删除缺失值。

按位置提取值与按排名筛选是互补的。筛选会给出所有变量,并将每个观测值放在单独的一行中:

# 按年、月、日分组
# 计算每组中计划起飞时间的排名 r
# 保留每组中排名最小(最早)和排名最大(最晚)的航班
flights |>
group_by(year, month, day) |>
mutate(r = min_rank(sched_dep_time)) |>
filter(r %in% c(1, max(r)))

使用mutate()

顾名思义,汇总函数通常与 summarize() 搭配使用。不过,由于我们在第 13.4.1 节讨论过的回收规则,它们也可以很有用地与 mutate() 搭配,尤其是在你想做某种分组标准化时。例如:

  • x / sum(x) 计算某个总量的占比。
  • (x – mean(x)) / sd(x) 计算 Z 分数(标准化为均值 0、标准差 1)。
  • (x – min(x)) / (max(x) – min(x)) 将数值标准化到 [0, 1] 范围。
  • x / first(x) 根据第一个观测值计算指数。

评论

发表评论

了解 数据控|突破是我们的每一步 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读