文本数据四处可见,例如程序运行日志文件,或博客文章、微博等。数值型的数据我们拿来可以开始做计算,但文本数据必须要先经过处理才能进行分析。本章我们先介绍一些基本的文本操作,然后专攻文本处理的必备利器:正则表达式。
在我们介绍正式内容之前,先强调一个至关重要的问题,就是文本编码。在计算机中,文本可以以不同的编码存储,这事儿主要是Windows惹的祸,给程序员带来了无尽的苦恼。在Linux世界,默认编码通常就是通用的UTF8,所以我们处理文本几乎从来不必考虑编码问题。Windows下的默认文本编码通常是各种“方言”,比如中文用一种编码方式(GB2312),韩文用另一种方式,等等。这样我们要读取一个文本文件或处理一段文本数据,就必须先了解它的编码方式,在很多R函数中都有一个
encoding
参数,就是为了对付这种情况的,例如
readLines()
。为了世界的和平和人民的安定,我们大力呼吁所有人都统一使用UTF8编码,让所有程序都能够自由对话。稍微好用一点的文本编辑器都支持设定编码,例如Windows下的Notepad++,我们存储文件尽量用UTF8编码。
文本数据当然不能只用来数一下有多少字符,这信息太粗糙了,我们还需要深入文本里面看内容。文本最常见的特征大概就是分隔符,它把文本的组成单元分开,典型的就是英文中的空格和标点,它们用来分开单词。实际上很多数值型数据在存储时也有同样的特征,例如CSV文件就是用逗号分开数据中的列,读入数据的时候我们就知道,当遇到一个逗号时就意味着开始新的一列了。为了深刻理解这种“分隔”的特征,请存一个CSV文件并用文本编辑器打开它:
## [[1]]
## [6] "Free" "Software" "Foundation," "Inc.,"
## [[2]]
## [1] "" "51" "Franklin" "Street," "Fifth"
## [6] "Floor," "Boston," "MA" "02110-1301" "USA"
函数
strsplit()
根据输入字符向量的长度返回相应长度的列表,列表里每个子元素是一个向量,对应着原来的字符向量中的拆分结果。比如上面我们拆分了两个元素,则得到长度为2的列表,里面都是单个的单词。
下面我们干点儿正事,把GPL的文本拆成单词并统计词频。前面用空格作为分隔符其实是不严格的,因为标点符号也是单词之间的分隔符,所以我们需要一个更广泛的分隔符,此时,正则表达式已经憋不住要出场了(下一节我们详细谈它);
strsplit()
的分隔符支持正则表达式,而在正则表达式中,单词之间的分隔符可以统一被表达为
\\W
(反斜杠引导大写字母W),这个特殊表达式可以匹配任意非单词的字符。R中
table()
函数可以计算一个向量中每个元素出现的频数,于是这事儿就差不多了。
Happy birthday to you
Happy birthday to you
Happy birthday dear COS
Happy birthday to you
这不仅仅是一个浪漫的函数,也深刻反映了程序员的基本素质:抽象与模块化。此时,有些看官可能心里长叹,在程序世界征战代码半辈子,还不如人家一首生日歌。
正则表达式
浪漫不浪漫,最后都得吃饭,所以你还得咬牙学习。简单的拼拆操作当然也远不够数据分析用,还有几项常见的任务:查找、替换、提取符合特定特征的字符。这些操作就得请出正则表达式了(Regular Expression),它是具有特殊含义的字符串,最大的优势在于它根据特征而不是固定的位置来处理数据。先看一个最简单的例子:前面我们提到从我的网页里提取标题字符串,在那里用的是固定位置取子字符串,而写程序的时候,凡是你看到哪里用到了具体的数字,几乎一定代表这段程序没有推广性(只有一个特定的应用场合),下面我们用更具有推广性的正则表达式来提取标题。
## [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()
版本:
## 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
中说的元字符(我说了这个页面要看八百遍)。