R数据科学,第二部分:数据处理


数据处理非常重要:没有这个过程,就无法使用数据进行工作!数据处理包括三个重要环节。

  • 导入
  • 整理
  • 转换

使用tibble实现简单数据框 介绍数据框的一种变体:tibble。你将会了解 tibble 和普通数据框的区别,以及如何“手工构造”tibble。
使用readr进行数据导入 介绍如何从磁盘读取数据并导入 R。重点关注矩阵格式的纯文本文件,但会给出一些指示,表明哪些包可以处理其他类型的数据。
使用dplyr处理关系数据 介绍一些工具,以处理具有多种相关关系的数据集。
使用stringr处理字符串 介绍正则表达式,即处理字符串的一种强大工具。
使用forcats处理因子 展示 R 如何保存分类数据。当一个变量的取值范围是有限集合,或者我们不想按字母顺序排列字符串时,就可以使用分类数据。
*使用lubridate处理日期和时间 介绍处理日期和日期时间型数据的核心工具。

使用tibble实现简单数据框

tibble 是一种简单数据框,它对传统数据框的功能进行了一些修改,以便更易于使用。
R 是一门古老的语言,其中有些功能在10年或20年前是适用的,但现在已经过时。
在不破坏现有代码的前提下,很难修改 R的基础功能,很多革新都是以拓展包的方式出现的。  

tibble 包所提供的简单数据框更易于 tidyverse 中使用。多数情况下,我们会交替使用 tibble 和 数据框,如果想要特别强调 R 内置的传统数据框,会使用 data.frame 来表示。

library(tibble)
Warning message:
"package 'tibble' was built under R version 4.0.5"

本书中使用的所有函数几乎都可以创建tibble,因为tibble是tidyverse的标准功能之一。
由于多数其他R包使用的是标准数据框,可以使用 as_tibble() 函数来完成转换:

as_tibble(iris)[1:2,]
A tibble: 2 × 5
Sepal.LengthSepal.WidthPetal.LengthPetal.WidthSpecies
<dbl><dbl><dbl><dbl><fct>
5.13.51.40.2setosa
4.93.01.40.2setosa

可以使用 tibble() 函数使用一个向量来创建 tibble。tibble() 会自动重复长度为 1 的输入,并可以使用刚刚创建的新变量。

tibble(
    x = 1:5,
    y = 1,
    z = x^2 + y
)
A tibble: 5 × 3
xyz
<int><dbl><dbl>
11 2
21 5
3110
4117
5126

相比于 data.frame,tibble() 函数的功能少得多:

  • 不能改变输入的类型(如,不能将字符串转换为因子)、变量的名称
  • 不能创建行名称

创建 tibble 的另一种方法是使用 tribble() 函数,tribble 是 transposed tibble(转置 tibble)的缩写。tribble() 是定制化的,可以对数据按行进行编码:列标题由公式(以 ~ 开头)定义,数据条目以逗号分隔,这样就可以用易读的方式对少量数据进行布局。

tribble(
    ~x, ~y, ~z,
    #--|--|---- 可选,这样写能明确指出标题行的位置
    "a",2,3,
    "b",1,8
)
A tibble: 2 × 3
xyz
<chr><dbl><dbl>
a23
b18

对比 tibble 与 data.frame

tibble 和传统 data.frame 的使用方法主要有两处不同:打印和取子集。

打印

tibble 打印方法进行了优化,只显示前 10 行结果(Rstudio中,在 jupyter 中会全部显示),并且列也是适合屏幕的,这种方式非常适合大数据集。除了打印列名,tibble 还会打印出列的类型。这个功能借鉴于str() 函数。

tibble(
    a = lubridate::now() + runif(1e3) * 86400,
    b = lubridate::today() + runif(1e3) * 30
)

取子集

$(按名称提取变量) 和 [[ (可以按名称或位置提取变量)

df <- tibble(
    x = runif(5),
    y = rnorm(5))
# 按名称提取
df$x
df[['y']]
  1. 0.976251509739086
  2. 0.131041357060894
  3. 0.876212562201545
  4. 0.795781070599332
  5. 0.97147882077843
  1. -0.822334674490723
  2. 0.457416425847197
  3. 0.764743684096069
  4. 0.190165831982873
  5. 0.147661380523245
#按位置提取
df[[1]]
  1. 0.976251509739086
  2. 0.131041357060894
  3. 0.876212562201545
  4. 0.795781070599332
  5. 0.97147882077843

如果使用管道,需要借用一个占位符.

df %>% .$x
  1. 0.976251509739086
  2. 0.131041357060894
  3. 0.876212562201545
  4. 0.795781070599332
  5. 0.97147882077843

使用 readr 进行数据导入

这里学习使用 tidyverse 中的 readr 包进行导入。

readr 的多数函数用于将平面文件转换为数据框

  • read_csv() 读取逗号分隔文件、read_csv2() 读取分号分隔文件、read_tsv() 读取制表符分隔文件、read_delim() 可以读取使用任意分隔符的文件
  • read_fwf() 读取固定宽度的文件,既可以使用 fwf_widths() 函数宽度设定域,也可以使用 fwf_position() 函数按照位置来设定域。read_table() 读取固定宽度文件的一种常用变体,其中使用空白字符来分隔列。
  • read_log() 读取 Apache 风格的文件。

这些函数都有同样的语法

这里主要介绍 read_csv()

因为熟悉基础包中 read.table,并且暂未有不适症状,所以目前这一部分略过了。

使用 dplyr 处理关系数据

这一部分已在 part1 探索部分谈到,请跳转R数据科学,第一部分:探索

使用stringr处理字符串

本章重点是正则表达式。这里的正则表达式,和其他命令有很多共同之处,如 awkgrep,所以如果你掌握了其他的正则表达式,会轻松很多。

suppressMessages(library(tidyverse))
suppressMessages(library(stringr))

字符串基础

  • '" 没有区别
  • 用反斜杠\进行转义
  • 使用 ?'"'?"'" 查看完整的特殊字符列表
  • 多个字符串通常保存在一个字符向量中,可以使用 c() 函数来创建字符向量

字符串长度

R 基础包中包含了很多字符串处理函数,但尽量不使用这些函数,因为它们的使用方法都不一致,很难记忆。相反,使用 stringr 中的函数,这些函数的名称更直观,都是以 str_ 开头的。例如,str_length() 函数可以返回字符串中的字符数量:

str_length(c('a','R for data science', NA))
  1. 1
  2. 18
  3. <NA>

并且大多数都是向量化的

字符串组合

想要组合两个或更多字符串,可以使用 str_c() 函数:

str_c("x", "y", sep = ' ')

‘x y’

字符串取子串

使用 str_sub() 函数来提供字符串的一部分。除了字符串参数外,还有 start 和 end 参数,它们给出了子串的位置(包括 start 和 end 在内,符数表示从后往前数):

x <- c('apple','banana','pear')
str_sub(x, 1, 3)
  1. 'app'
  2. 'ban'
  3. 'pea'

可以通过赋值形式修改字符串

str_sub(x, 1, 1) <- str_to_upper(str_sub(x,1,1))
x
  1. 'Apple'
  2. 'Banana'
  3. 'Pear'

使用正则表达式进行模式匹配

通过str_view()str_view_all() 函数来学习正则表达式,这两个函数接受一个字符向量和一个正则表达式,并显示出它们是如何匹配的。

基础匹配

x <- c('apple','banana','pear')
# 精确匹配
str_view(x,'an')

. 匹配任意字符(除了换行符)

str_view(x, '.a.')

为了匹配字符.,需要一个\ 进行转义。
这样有个问题:因为使用字符串表示正则表达式,而且 \ 在字符串中也用作转义字符,所以正则表达式\.的字符串形式应该是\\.

更复杂的是,如何匹配\这个字符呢?还是要去除\本身的特殊意义,建立形式为\\的表达式。实际上,匹配一个\,需要四个反斜杠\\\\

实际上就是第一个\转义了第二个\,告诉正则表达式这里确实是一个\,而不是一个转义字符

x <- 'a\\b'
writeLines(x)

str_view(x, '\\\\')
a\b

锚点

  • ^ 表示开头
  • $ 表示结尾

字符类与字符选项

  • \d 匹配任意数字
  • \s 匹配任意空白字符(如空格、制表符和换行符)
  • [abc] 可以匹配 a、b 或 c
  • [^abc] 可以匹配除 a、b、c以外的任意字符

请牢记,要想创建包含 \d\s 的正则表达式,你需要在字符串中对\转义,因此需要输入\\d\\s

你还可以使用字符选项创建多个可选模式。例如,abc|d..f 可以匹配 abc 或 deaf。
❗注意:因为 |的优先级很低,所以 abc|xyz 匹配的是 abc 或 xyz,而不是 abcyz 或 adxyz。与数据表达式一样,如果优先级令人困惑,可以使用括号使表达式更清晰一些:

str_view(c('grey','gray'), 'gr(e|a)y')

重复

  • ? 0次或1次
  • + 1次或多次
  • * 0次或多次

注意这些运算符的优先级非常高,因此使用 colou?r 既可以匹配 color,也可以匹配 colour。这意味着很多时候需要使用括号,比如 bana(na)+。

还可以精确设置匹配次数:

  • {n} 匹配n次
  • {n,} 匹配n次或更多次
  • {,m}最多匹配m次
  • {n, m} 匹配n到m次
x = "1888 is the longest year in Roman numerals: MDCCCLXXXVIII"
str_view(x, "C{2}")
str_view(x, "C{2,}")
str_view(x, "C{2,3}")

默认的匹配方式是“贪婪的”:正则表达式会匹配尽量长的字符串。通过在正则表达式后面添加一个?,将匹配方式改为“懒惰的”,及匹配尽量短的字符串。

str_view(x, "C{2,3}?")
str_view(x, "C[LX]+?")

分组与回溯引用

括号除了用于消除歧义,还可以定义“分组”,你可以通过“回溯引用”(如\1、\2等)来引用这些分组。例如,以下的正则表达式可以找出名称中有重复的一堆字母的所有水果:

str_view(fruit, "(..)\\1",match = TRUE)

工具

  • 确定与某种模式相匹配的字符串
  • 找出匹配的位置
  • 提取出匹配的内容
  • 使用新值替换匹配内容
  • 基于匹配拆分字符串

匹配检测

想要确定一个字符向量能否匹配一种模式,可以使用 str_detect() 函数。它返回一个与输入向量具有同样长度的逻辑向量:

x <- c("apple", "banana", "pear")
str_detect(x, "e")
  1. TRUE
  2. FALSE
  3. TRUE

记住,从数学意义上来说,逻辑向量中的 FALSE 为 0,TRUE 为 1.这使得在匹配特别大的向量时,sum() 和 mean() 函数能够发挥更大的作用

# 有多少个以 t 开头的常用单词
sum(str_detect(words, "^t"))

65

# 以元音字母结尾的常用单词的比例是多少
mean(str_detect(words, "[aeiou]$"))

0.276530612244898

当逻辑条件非常复杂时(如,匹配 a 或 b,但不匹配 c,除非 d 成立),一般来说,相对于创建单个正则表达式,使用逻辑运算符将多个 str_detect() 调用组合起来会更容易。
例如,以下两种方法均可找出不包含原因字母的所有单词:

# 找出至少包含一个元音字母的所有单词,然后取反
no_vowels_1 <- !str_detect(words, "[aeiou]")
# 找出仅包含辅音字母的所有单词
no_vowels_2 <- str_detect(words, "^[^aeiou]+$")
identical(no_vowels_1, no_vowels_2)

TRUE

两种方法的结果是一样的,但第一种方法明显更容易理解。如果正则表达式过于复杂,则应该将其分解为几个更小的子表达式,将每个子表达式的匹配结果赋值给一个变量,并使用逻辑运算组合起来

字符串通常是数据框的一列,也可以使用filter 操作

df <- tibble(
    word = words,
    i = seq_along(word)
)
df %>% filter(str_detect(words, "x$"))
A tibble: 4 × 2
wordi
<chr><int>
box108
sex747
six772
tax841

str_count() 返回字符串中匹配的数量

x <- c("apple", "banana", "pear")
str_count(x, "a")
  1. 1
  2. 3
  3. 1

注意匹配从来不会重叠。例如在”abababa”中,模式”aba”会匹配多少次?正则表达式会告诉你是2次,而不是3次:

str_count("abababa", "aba")

2

str_view_all("abababa", "aba")

很多 stringr 函数都是成对出现的: 一个函数用于单个匹配,另一个函数用于全部匹配,后者会有后缀 _all

提取匹配内容

想要提取匹配的实际文本,我们可以使用 str_extract() 函数。
使用维基百科上的 Harvard sentences,这个数据集是用来测试 VOIP 系统的,但也可以用来练习正则表达式。

length(sentences)

720

head(sentences)
  1. 'The birch canoe slid on the smooth planks.'
  2. 'Glue the sheet to the dark blue background.'
  3. 'It\'s easy to tell the depth of a well.'
  4. 'These days a chicken leg is a rare dish.'
  5. 'Rice is often served in round bowls.'
  6. 'The juice of lemons makes fine punch.'

假设我们想要找出包含一种颜色的所有句子。首先,创建一个颜色名称向量,然后转为一个正则表达式:

colors <- c("red","orange", "yellow", "green", "blue", "purple")
color_match <- str_c(colors, collapse = "|")
color_match

‘red|orange|yellow|green|blue|purple’

has_color <- str_subset(sentences, color_match)
matches <- str_extract(has_color, color_match)
head(matches)
  1. 'blue'
  2. 'blue'
  3. 'red'
  4. 'red'
  5. 'red'
  6. 'blue'

注意:str_extract() 只会提取第一个匹配。使用 str_extract_all() 得到所有匹配,它会返回一个列表;如果设置了 simplify = TRUE,那么 str_extract_all() 会返回一个矩阵,其中较短的匹配会拓展到与最长的匹配具有同样的长度:

head(str_extract_all(has_color, color_match, simplify = TRUE),5)
A matrix: 5 × 2 of type chr
blue
blue
red
red
red

分组匹配

我们在本章前面讨论了括号在正则表达式中的用法,它可以阐明优先级,还能对正则表达式进行分组,分组可以在匹配时回溯引用。你还可以使用括号来提取一个复杂匹配的各个部分。举例来说,假设我们想从句子中提取出名词。
我们先进行一种启发式实验,找出跟在 a 或 the 后面的所有单词。
因为使用正则表达式定义“单词”有一点难度,所以我们使用简单的近似定义,至少有1个非空格字符的字符序列:

noun <- "(a|the) ([^ ]+)"

sentences %>% 
 str_subset(noun) %>% 
 head(10) %>%
 str_extract(noun)
  1. 'the smooth'
  2. 'the sheet'
  3. 'the depth'
  4. 'a chicken'
  5. 'the parked'
  6. 'the sun'
  7. 'the huge'
  8. 'the ball'
  9. 'the woman'
  10. 'a helps'

替换匹配内容

str_replace()str_replace_all()

通过提供一个命名向量,使用 str_replace_all() 函数可以同时执行多个替换:

x <- c("1 house", "2 cars", "3 people")
str_replace_all(x, c("1" = "one", "2" = "two", "3" = "three"))
  1. 'one house'
  2. 'two cars'
  3. 'three people'

除了使用固定字符串替换匹配内容,你还可以使用回溯引用来插入匹配中的分组。在下面的代码中,我们交换了第二个单词和第三个单词的顺序:

sentences %>%
 str_replace("([^ ]+) ([^ ]+) ([^ ]+)", "\\1 \\3 \\2") %>%
 head
  1. 'The canoe birch slid on the smooth planks.'
  2. 'Glue sheet the to the dark blue background.'
  3. 'It\'s to easy tell the depth of a well.'
  4. 'These a days chicken leg is a rare dish.'
  5. 'Rice often is served in round bowls.'
  6. 'The of juice lemons makes fine punch.'

拆分

str_split() 函数可以按字符串拆分为多个片段。例如,我们可以将句子拆分成单词:

sentences %>% head(5) %>% str_split(" ")
    1. 'The'
    2. 'birch'
    3. 'canoe'
    4. 'slid'
    5. 'on'
    6. 'the'
    7. 'smooth'
    8. 'planks.'
    1. 'Glue'
    2. 'the'
    3. 'sheet'
    4. 'to'
    5. 'the'
    6. 'dark'
    7. 'blue'
    8. 'background.'
    1. 'It\'s'
    2. 'easy'
    3. 'to'
    4. 'tell'
    5. 'the'
    6. 'depth'
    7. 'of'
    8. 'a'
    9. 'well.'
    1. 'These'
    2. 'days'
    3. 'a'
    4. 'chicken'
    5. 'leg'
    6. 'is'
    7. 'a'
    8. 'rare'
    9. 'dish.'
    1. 'Rice'
    2. 'is'
    3. 'often'
    4. 'served'
    5. 'in'
    6. 'round'
    7. 'bowls.'

因为字符向量的每个分量会包含不同数量的片段,所以 str_split() 会返回一个列表。如果你拆分的是长度为1的向量,那么只要简单地提取列表地第一个元素即可:

"a|b|c|d" %>% str_split("\\|") %>%
 .[[1]]
  1. 'a'
  2. 'b'
  3. 'c'
  4. 'd'

和其他 stringr 函数一样,可以设置 simplify = TRUE 返回一个矩阵:

sentences %>%
 head(5) %>%
 str_split(" ", simplify = TRUE)
A matrix: 5 × 9 of type chr
The birchcanoeslid on the smoothplanks.
Glue the sheetto thedark blue background.
It's easy to tell thedepthof a well.
Thesedays a chickenlegis a rare dish.
Rice is oftenserved in roundbowls.

除了模式,还可以通过字母、行、句子和单词边界(boundary() 函数)来拆分字符串:

x <- "this is a sentence. this is another sentence."
str_view_all(x, boundary("word"))

定位匹配内容

str_locate()str_locate_all() 函数可以给出每个匹配的开始位置和结束位置。当没有其他函数能够精确地满足需求时,这两个函数特别有用。

其他类型的模式

当使用一个字符串作为模式时,R会自动调用 regex() 函数对齐进行包装:

# 正常调用:
str_view(fruit, “nana”)
# 上面是以下形式的简写
str_view(fruit, regex(“nana”))

可以使用 regex() 函数的其他参数来控制具体的匹配方式。

  • ignore_case = TRUE
  • multiline = TRUE 可以使得 ^ 和 $ 从每行的开头和末尾开始匹配,而不是完整字符串的开头和末尾开始匹配
  • comments = TRUE 可以让你在复杂的正则表达式中加入注释和空白字符,以便容易理解。匹配时会忽略空格 和 # 后面的内容。如果想要匹配一个空格,需要对其进行转义:”\ “
  • dotall = TRUE 可以使得 . 匹配 \n 在内的所有字符
phone <- regex("
\\(?     # 可选的开头括号
(\\d{3}) # 地区编码
[)- ]?   # 可选的闭括号、短划线或空格
(\\d{3}) # 另外3个数字
",comments = TRUE)
str_match("514-791-456", phone)
A matrix: 1 × 3 of type chr
514-791514791

除了 regex() ,还可以使用其他函数

  • fixed() 按照字符串的字节形式进行精确匹配,它会忽略正则表达式中的所有特殊字符,并在非常低的层次上进行操作。这样可以让你不用进行那些复杂的转义操作,而且速度比普通正则表达式要快很多。
  • 可以用 boundary() 函数来匹配边界。

正则表达式的其他应用

R基础包中有两个常用函数,它们也可以使用正则表达式。

  • apropos() 可以在全局环境中搜索所有可用对象。当不能确切想起函数名称时,这个函数特别有用:
apropos("replace")
  1. '%+replace%'
  2. 'replace'
  3. 'replace_na'
  4. 'setReplaceMethod'
  5. 'str_replace'
  6. 'str_replace_all'
  7. 'str_replace_na'
  8. 'theme_replace'
  • dir() 函数可以列出一个目录下的所有文件。dir() 函数的pattern参数可以是一个正则表达式,此时它只返回与这个模式相匹配的文件名。
dir(pattern = "\\.Rmd$")

使用forcats处理因子

因子(又称分类变量)

library(tidyverse)
library(forcats)

创建因子

month_levels <- c("Jan", "Feb", "Mar","Apr","May","Jun", "Jul","Aug","Sep","Oct","Nov","Dec")
x1 <- c( "Apr","May","Oct","Nov","Jan")
y1 <- factor(x1, levels = month_levels)
y1
  1. Jan
  2. Apr
  3. May
  4. Oct
  5. Nov
Levels:
  1. 'Jan'
  2. 'Feb'
  3. 'Mar'
  4. 'Apr'
  5. 'May'
  6. 'Jun'
  7. 'Jul'
  8. 'Aug'
  9. 'Sep'
  10. 'Oct'
  11. 'Nov'
  12. 'Dec'

不在有效水平集合内的所有值都会自动转换为 NA:

x2 <- c("Jam", "Apr","May","Oct","Nov")
factor(x2, levels = month_levels)
  1. <NA>
  2. Apr
  3. May
  4. Oct
  5. Nov
Levels:
  1. 'Jan'
  2. 'Feb'
  3. 'Mar'
  4. 'Apr'
  5. 'May'
  6. 'Jun'
  7. 'Jul'
  8. 'Aug'
  9. 'Sep'
  10. 'Oct'
  11. 'Nov'
  12. 'Dec'

有时你会想让因子的顺序与初始数据的顺序一致。在创建因子时,将水平设置为 unique(x),或者在创建因子后再对其使用 fct_inorder() 函数,就可以达到这个目的:

f1 <- factor(x1, levels = unique(x1))
f1
  1. Apr
  2. May
  3. Oct
  4. Nov
  5. Jan
Levels:
  1. 'Apr'
  2. 'May'
  3. 'Oct'
  4. 'Nov'
  5. 'Jan'
f2 <- x1 %>% factor() %>% fct_inorder()
f2
  1. Apr
  2. May
  3. Oct
  4. Nov
  5. Jan
Levels:
  1. 'Apr'
  2. 'May'
  3. 'Oct'
  4. 'Nov'
  5. 'Jan'
# 访问因子的有效水平集合
levels(f2)
  1. 'Apr'
  2. 'May'
  3. 'Oct'
  4. 'Nov'
  5. 'Jan'

修改因子水平

比修改因子水平顺序更强大的操作是修改水平的值。最常用、最大强的工具是 fct_recode() 函数,它可以对每个水平进行修改或重新编码。

gss_cat %>% count(partyid)
A tibble: 10 × 2
partyidn
<fct><int>
No answer 154
Don't know 1
Other party 393
Strong republican 2314
Not str republican3032
Ind,near rep 1791
Independent 4119
Ind,near dem 2499
Not str democrat 3690
Strong democrat 3490
gss_cat %>% mutate(partyid = fct_recode(partyid,
    "No response" = "No answer",
    "No idea" = "Don't know"
)) %>% count(partyid)
A tibble: 10 × 2
partyidn
<fct><int>
No response 154
No idea 1
Other party 393
Strong republican 2314
Not str republican3032
Ind,near rep 1791
Independent 4119
Ind,near dem 2499
Not str democrat 3690
Strong democrat 3490

fct_recode() 会让没有明确提及的水平保持原样,如果一不小心修改了一个不存在的水平,它会给出警告。

可以将多个原水平赋给同一个新水平,这样就可以合并原来的分类:

gss_cat %>% mutate(partyid = fct_recode(partyid,
    "Ind" = "Ind,near rep",
    "Ind" = "Independent",
    "Ind" = "Ind,near dem"
)) %>% count(partyid)
A tibble: 8 × 2
partyidn
<fct><int>
No answer 154
Don't know 1
Other party 393
Strong republican 2314
Not str republican3032
Ind 8409
Not str democrat 3690
Strong democrat 3490

使用这种操作时一定要小心,如果合并了原本不同的分类,那么就会产生误导性的结果。

如果想要合并多个水平,那么可以使用 fct_collapse() 函数。对于每个新水平,提供一个包含原水平的向量:

gss_cat %>% mutate(partyid = fct_collapse(partyid,
                                          other = c("No answer", "Don't know", "Other party"),
                                          rep = c("Strong republican", "Not str republican"),
                                          ind = c("Ind,near rep", "Independent", "Ind,near dem"),
                                          dem = c("Not str democrat","Strong democrat")
)) %>% count(partyid)
A tibble: 4 × 2
partyidn
<fct><int>
other 548
rep 5346
ind 8409
dem 7180

使用 lubridate 处理日期和时间

需求不大,这里省略了。


文章作者: 梁绍波
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 梁绍波 !
评论
  目录