第4章 文本数据

文本数据四处可见,例如程序运行日志文件,或博客文章、微博等。数值型的数据我们拿来可以开始做计算,但文本数据必须要先经过处理才能进行分析。本章我们先介绍一些基本的文本操作,然后专攻文本处理的必备利器:正则表达式。

在我们介绍正式内容之前,先强调一个至关重要的问题,就是文本编码。在计算机中,文本可以以不同的编码存储,这事儿主要是Windows惹的祸,给程序员带来了无尽的苦恼。在Linux世界,默认编码通常就是通用的UTF8,所以我们处理文本几乎从来不必考虑编码问题。Windows下的默认文本编码通常是各种“方言”,比如中文用一种编码方式(GB2312),韩文用另一种方式,等等。这样我们要读取一个文本文件或处理一段文本数据,就必须先了解它的编码方式,在很多R函数中都有一个encoding参数,就是为了对付这种情况的,例如readLines()。为了世界的和平和人民的安定,我们大力呼吁所有人都统一使用UTF8编码,让所有程序都能够自由对话。稍微好用一点的文本编辑器都支持设定编码,例如Windows下的Notepad++,我们存储文件尽量用UTF8编码。

4.1 基本操作

在R中读入一个文本文件可以用readLines()函数,它返回一个字符型向量,文件中每一行都是向量中对应的一个元素。这个文件可以是本地文件,也可以是一个网址。例如:

# R软件的许可证文件(GPL)
gpl = readLines(file.path(R.home(), "COPYING"))
head(gpl)  # GPL前几行
## [1] "\t\t    GNU GENERAL PUBLIC LICENSE"                                             
## [2] "\t\t       Version 2, June 1991"                                                
## [3] ""                                                                               
## [4] " Copyright (C) 1989, 1991 Free Software Foundation, Inc."                       
## [5] "                       51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA"
## [6] " Everyone is permitted to copy and distribute verbatim copies"
xie = readLines("https://yihui.name")  # 我的主页
head(xie)  # HTML代码
## [1] "<!DOCTYPE html>"                                                             
## [2] "<html lang=\"en-us\">"                                                       
## [3] "  <head>"                                                                    
## [4] "\t<meta name=\"generator\" content=\"Hugo 0.25.1\" />"                       
## [5] "    <meta charset=\"utf-8\">"                                                
## [6] "    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"

这个函数有个姊妹叫readline(),它支持从键盘直接输入一行文本,显然,R必须在交互式模式下运行,否则人没法输入任何东西。它有一个常用的地方,就是在代码运行过程中要求用户输入一些回答,例如:

x = readline("Answer yes or no: ")

当R运行到这一行时会停下来等待用户输入(回车表示输入结束),然后根据输入的结果,代码可以接着执行。可以想象,能读自然也能写,那么writeLines()就是用来把R对象写入文件的函数。

一条文本有一些基本的属性,比如它包含多少字符,我们可以用nchar()计算字符串中有多少字符,例:

nchar(gpl[1:10])  # GPL前10行分别有多少字符
##  [1] 32 29  0 56 79 61 58  0 15  0
sum(nchar(gpl))  # 全文一共多少字符
## [1] 17671

对字符串长度来说,学过其它语言如JavaScript或Python的忍者的第一反应可能是length()之类的函数,比如gpl.length()或者length(gpl),R语言不是这一套,在R里面length()只有一个意思,就是对象中的元素个数,比如向量里有多少个元素,所以length(gpl)返回的实际上是GPL文件有多少行(一行是向量gpl中的一个元素)。

好了,现在你已经会数数了,比水木Joke版的猪头强了,给个练习题吧,帮我统计一下我的博客文章的字数,看看我写博客这几年有什么趋势;源文件在https://github.com/rbind/yihui.name,这事儿其实已经有人做了,不过请不要偷看答案。

文本数据当然不能只用来数一下有多少字符,这信息太粗糙了,我们还需要深入文本里面看内容。文本最常见的特征大概就是分隔符,它把文本的组成单元分开,典型的就是英文中的空格和标点,它们用来分开单词。实际上很多数值型数据在存储时也有同样的特征,例如CSV文件就是用逗号分开数据中的列,读入数据的时候我们就知道,当遇到一个逗号时就意味着开始新的一列了。为了深刻理解这种“分隔”的特征,请存一个CSV文件并用文本编辑器打开它:

write.csv(iris, file = "iris.csv", row.names = FALSE)
normalizePath("iris.csv")  # 万一你找不到文件了,就看这个

你的操作系统可能在这个文件上给你显示Excel的图标,但作为忍者,你必须绕过这种表象,它跟Excel没有半毛钱关系,它只不过是一个普通的文本文件罢了,当然,不可否认,你可以用Excel查看它。当你用文本编辑器看过一次这样的文件之后,就会对神秘的数据存储有更好的理解了,原来Excel的东西也是可以看“源代码”的!

我们继续说分隔符:strsplit()函数可以按照指定的分隔符拆分一条字符串,例如上面我们读入了GPL文本,我们可以用空格拆分这些文本数据:

strsplit(gpl[4:5], " ")  # 拆分第4、5两行
## [[1]]
## [1] ""            "Copyright"   "(C)"         "1989,"       "1991"       
## [6] "Free"        "Software"    "Foundation," "Inc."       
## 
## [[2]]
##  [1] ""           ""           ""           ""           ""          
##  [6] ""           ""           ""           ""           ""          
## [11] ""           ""           ""           ""           ""          
## [16] ""           ""           ""           ""           ""          
## [21] ""           ""           ""           "51"         "Franklin"  
## [26] "St,"        "Fifth"      "Floor,"     "Boston,"    "MA"        
## [31] ""           "02110-1301" ""           "USA"

函数strsplit()根据输入字符向量的长度返回相应长度的列表,列表里每个子元素是一个向量,对应着原来的字符向量中的拆分结果。比如上面我们拆分了两个元素,则得到长度为2的列表,里面都是单个的单词。

下面我们干点儿正事,把GPL的文本拆成单词并统计词频。前面用空格作为分隔符其实是不严格的,因为标点符号也是单词之间的分隔符,所以我们需要一个更广泛的分隔符,此时,正则表达式已经憋不住要出场了(下一节我们详细谈它);strsplit()的分隔符支持正则表达式,而在正则表达式中,单词之间的分隔符可以统一被表达为\\W(反斜杠引导大写字母W),这个特殊表达式可以匹配任意非单词的字符。R中table()函数可以计算一个向量中每个元素出现的频数,于是这事儿就差不多了。

words = unlist(strsplit(gpl, "\\W"))
words = words[words != ""]  # 去掉空字符
# 频数最大的10个单词
tail(sort(table(tolower(words))), 10)
## 
##    this      is       a program     and     you      or      of      to 
##      49      53      57      71      72      76      77     104     108 
##     the 
##     194

冠词the和a出现频率高一点都不奇怪,除去它们和一些常见介词,剩下的基本上就是program这个词了。GPL是什么?它是开源软件的一种许可证,所以程序(program)这个词的词频高也就不奇怪了。

还有一种拆的方式是根据位置来拆,比如取一个字符串的第2至第5个位置上的4个字符,此时我们可以用substr()substring()函数,例如我们提取上面网页代码中的标题,也就是<title></title>之间的字符串,指定了起始位置和终止位置就可以了:

xie[8]
## [1] "    <title>Yihui Xie | 谢益辉</title>"
substr(xie[8], 12, 26)
## [1] "Yihui Xie | 谢益辉"

现在你学会了拆,会拆还得会拼才行(天下有多少苦命的娃儿因为拆了家里的电器拼不回去而挨骂呀),只拆不拼的只有……呃……咳咳,我就不说了,你自己去回忆《疯狂的石头》里“别摸我”被撞之前那个穿西服的家伙在墙上写什么就好了。R里面paste()函数可以用来拼字符串,它有两个参数:sepcollapse。据我混迹COS论坛多年的经验,这个函数是一朵奇葩,它的神奇之处在于,无数英雄豪杰只知道前一个参数而不知道后一个。于是,我数次感叹,肿么回事啊,一共就这么两个参数,肿么大家永远没有耐心把帮助文档看完呢?为了理解它们,先看一个例子:

paste(1:3, "a")
## [1] "1 a" "2 a" "3 a"
paste(1:3, "a", sep = "-")
## [1] "1-a" "2-a" "3-a"
paste(letters[1:10], collapse = "~")
## [1] "a~b~c~d~e~f~g~h~i~j"
paste(1:3, "a", sep = "-", collapse = "+")
## [1] "1-a+2-a+3-a"

看出它们的作用了吗?sep用来横向拼接向量,比如把第一个向量和第二个向量按元素顺序逐对拼起来,而collapse是把一个向量内部所有元素按一个分隔符拼接为单个字符串。按照R的自动扩展原则,如果有一个向量短,它会被首先扩展到长向量的长度,再去拼接。总结一下,sep返回的仍然是一个向量,每个元素是所有向量中的相应位置上的元素拼出来的;而collapse把字符向量“坍缩”为一个字符串。

我再偷一位网友的一个浪漫的拼接字符串例子,我看到的是写在生日蛋糕上的版本,保护隐私起见,请各位不要八卦打听原作者,也请原作者原谅我无耻抄袭创意,阿门。这是一个“唱”生日快乐的函数:

happy = function() cat("Happy birthday to you\n")
sing = function(person) {
    happy()
    happy()
    cat(paste("Happy birthday dear", person, "\n"))
    happy()
}
sing("COS")  # 对统计之都唱一嗓子吧
Happy birthday to you
Happy birthday to you
Happy birthday dear COS 
Happy birthday to you

这不仅仅是一个浪漫的函数,也深刻反映了程序员的基本素质:抽象与模块化。此时,有些看官可能心里长叹,在程序世界征战代码半辈子,还不如人家一首生日歌。

4.2 正则表达式

浪漫不浪漫,最后都得吃饭,所以你还得咬牙学习。简单的拼拆操作当然也远不够数据分析用,还有几项常见的任务:查找、替换、提取符合特定特征的字符。这些操作就得请出正则表达式了(Regular Expression),它是具有特殊含义的字符串,最大的优势在于它根据特征而不是固定的位置来处理数据。先看一个最简单的例子:前面我们提到从我的网页里提取标题字符串,在那里用的是固定位置取子字符串,而写程序的时候,凡是你看到哪里用到了具体的数字,几乎一定代表这段程序没有推广性(只有一个特定的应用场合),下面我们用更具有推广性的正则表达式来提取标题。

gsub("<title>|</title>", "", xie[8])
## [1] "    Yihui Xie | 谢益辉"
sub("<title>(.*)</title>", "\\1", xie[8])
## [1] "    Yihui Xie | 谢益辉"

上面给出了两种办法:一种是把<title></title>替换为空字符串(删掉了这两串字符剩下的就是标题了),另一种是用圆括号语法配合引用,提取这两串字符之间的所有内容。在这个特例下面,两个办法没什么区别。

R中有一系列类似的函数,这里用到的是其中两个用来替换的函数,参见?grep的帮助页面。这些函数的第一个参数是一个正则表达式,从上面简单的例子里面我们可能已经感受到它的语法了,比如竖线|表示“或者”,这和程序语言很像,而单个.代表任意单个字符,星号*是一个表示匹配任意多次的修饰符,.*一起表示匹配任意字符任意多次,默认会贪婪匹配,即“你有病啊?你有药啊?吃多少?有多少吃多少!吃多少有多少!……”,郭德纲已经把.*匹配的意思讲得很清楚了。圆括号把一组特征括起来,然后跟这一组特征能匹配上的所有字符就可以用反斜杠引导的数字引用引出来,圆括号可以使用多组,每一组匹配到的内容在后面都可以用顺序的数字(1-9)引用,因为我们这里只用了一组括号,所以后面用的是第1组引用。

现在我们把上面两句代码用普通语言“翻译”一遍:

  • 替换字符串<title>或者</title>为空字符串(即:删掉它们)
  • 搜索<title>,然后开始匹配任意字符,直到遇到</title>为止,然后把匹配到的这一段字符提出来

如此一来,我们就不必管<title></title>究竟出现在第几个字符的位置上了,正则表达式自然会去找它们。

grep这一组函数基本都有一个带g和不带g的版本,比如gsub()sub()gregexpr()regexpr()。带g的会尽量贪婪操作,而不带的只操作一次。为了看清这一点区别,我们写一个上面第一种方法的sub()版本:

sub("<title>|</title>", "", xie[8])
## [1] "    Yihui Xie | 谢益辉</title>"

可以看到,</title>没有被替换掉,这是因为sub()先看到了<title>,把它替换为空,它就认为自己的工作完成了,于是马上返回结果;而gsub()则会一直在字符串中找,凡是能找到正则表达式规定的特征,就去执行任务。

正则表达式有一些成体系的字符集,比如大小写字母、数字、标点、空格等(参见?regexp,这是你需要看八百遍的文档),它可以让我们减少不少打字的力气。正常来说,如果要匹配一组字符,我们可以用方括号[],在里面挨个写上我们想匹配的字符,例如[abc]匹配字母abc中的任意一个。方括号中如果第一个字符是^,那么它的意思是匹配不含后面那些字符的字符(它是否定操作符),比如[^defg]匹配不含defg中任意一个字母的字符。

下面的例子来自于http://cos.name/cn/topic/104126/,其实是个没事找抽的例子,但可以说明字符集的基本用法。我们的任务是从几行字符串中提取R包的名字(包名由所有大小写字母、数字和点构成),先上代码:

pkgs = readLines("04-package-names.txt")
str(pkgs)
##  chr [1:7] "[1] \"base\" \"boot\" \"class\" \"cluster\" \"codetools\"" ...
cat(pkgs, sep = "\n")  # 原始文本
## [1] "base" "boot" "class" "cluster" "codetools"
## [6] "compiler" "datasets" "fdrtool" "foreign" "graphics"
## [11] "grDevices" "grid" "KernSmooth" "lattice" "MASS"
## [16] "Matrix" "methods" "mgcv" "monreg" "nlme"
## [21] "nnet" "parallel" "rpart" "spatial" "splines"
## [26] "stats" "stats4" "survival" "tcltk" "tools"
## [31] "utils"
pkgs = gsub("([^\"]+)\"([a-zA-Z0-9\\.]+)\"", "\\2 ", pkgs)
pkgs = unlist(strsplit(pkgs, "[[:space:]]+"))
str(pkgs)  # 31个包名,搞定了
##  chr [1:31] "base" "boot" "class" "cluster" "codetools" "compiler" ...

原始数据中含有一些干扰字符需要去掉,如中括号和数字以及引号。上面的正则表达式的意思是:用了两个括号,但后面只引用了第2个括号的内容,也就是第1个括号匹配到的东西都被扔掉了;第1个括号用到了否定符^,表示匹配非双引号的任意字符,那么gsub()运行的时候就从头到尾先找不是双引号的字符,首先看到[,它不是双引号,配上,再看到一个数字,同样配上,直到走到双引号前停止,接下来的特征是双引号引起来的[a-zA-Z0-9.],这个不说你大概也能猜到了,它匹配所有小写大写字母、所有10个数字和点,凡是这样的字符统统进入第2个引用,注意2后面还有个空格,所以真正替换成为的内容是第2组内容加上空格。最后用空格字符集[:space:]拆分得到的结果就是包名的向量了。

话说这例子为什么是找抽?其实这数据是.packages(TRUE)的结果打印在R中得来的,现在又要绕回去,真心是吃饱了没事干,但生活中这种找抽的事情其实不少,比如好好的文本数据,有些人非得把它导进Excel存为二进制*.xls格式,让程序员抓耳挠腮想办法去读取它。

正则表达式使用时往往有很多路可以走,因为不同的规则对一个数据来说匹配的结果可能一样,这就需要忍者的观察力和严谨性了。一条正则表达式也许对这个数据有用,但推广到下一条数据时就不行了。所以,究竟什么是严格的特征,你需要非常仔细地考虑,老实说,我自己每次写正则表达式都要测试好半天。

由于正则表达式有些字符有特殊意义,所以如果就是要匹配这样的字符,那么我们需要用反斜杠引导,比如要匹配数据里的点.就不能直接写点,而要写\\.,这才是真正的点本身,否则它就匹配任意单个字符去了,这也是初学者最容易犯的错误之一。类似的特殊字符还有一大串,参见?regexp中说的元字符(我说了这个页面要看八百遍)。

gsub(".", "=", "a.b.c")  # 不对劲儿啊
## [1] "====="
gsub("\\.", "=", "a.b.c")  # 这才是正道
## [1] "a=b=c"