使用R进行文本处理

原文:Text Processing in R

概述

这篇教程复习了一些在 R 中进行文本处理所需要的一些基本概念和指令。R 语言并不是进行文本处理的唯一工具,也不一定是最好的方式。Python 实际上是用于文本处理的编程语言,它具有大量的内置函数可以很简单并且快速的进行操作,并且还具有大量成熟且功能全面的文本处理包,比如 NLTKtextblob . 基础的 shell 脚本也可以成数量级的提高处理极其大量文本语料集的速度,一个经典的参考可参见 Unix for Poets 。然而使用 R 语言进行文本处理还是有很好的理由的,也就是我们很方便地将 R 的分析结果应用于其他分析。我在这篇教程中主要使用了 stringr 包,安装很简单:

1
2
install.packages("stringr", dependencies = TRUE)
library(stringr)

我还成功链接过一些使用其他语言编写的用于文本处理的包(本篇教程中不涉及该步骤)。以下是我最喜欢的两个包的链接:

  • Stanford CoreNLP 包可以进行包括标记化和词性标注等在内的许多非常棒的操作,并且它比 R 中的 OpenNLP 包处理速度要更快。
  • MALLET 可以对文本进行许多有用的统计分析,包括对 LDA 的非常快速的执行。此处有一些使用案例,但下载需要点击最开始的链接。

正则表达式

正则表达式是指定描述字符串规则的一种方式(比如,每个以”a”开头的单词),它比简单地指定一个字典并从中逐一检查每个值是否满足某些规则的做法要更简单和充分。你可以从阅读这篇正则表达式的综述开始,然后阅读这篇关于在R中使用正则表达式的入门文章。重要的是理解正则表达式比单纯的字符串匹配强大得多。
如果你想开始使用正则表达式的话,可以从阅读上述的教程贴开始,但我认为直接在案例中进行试验并看它是如何工作的是一种更为有效的方法。一种很简单的方法是使用一个带高亮匹配结果的图形界面的在线应用,比如 regex101. 我个人更倾向于使用 RegExRx ,它可以运行在 OSX 和 Windows 平台,有共享软件版本,也在 Apple App Store 中提供付费版本。这个程序包含对Perl风格正则表达式的支持,这种风格的正则表达式很常见,并且在一些 R 语言包中也有使用。不管你选择哪种软件,我建议在R中使用正则表达式之前,可以先随意阅读几个小时网上检索到的相关文章。同时我也倾向于使用这些程序来规范化我在产品代码中将会用到的正则表达式。

示例代码

让我们从一个简单的字符串开始。

1
my_string <- "Example STRING, with numbers (12, 15 and also 10.2)?!"

首先我们可以先将整个字符串小写化,这通常是一个好的起点。这样做可以防止在进行字符串匹配时出现类似将 “Table” 和 “table” 当作不同单词处理的情况。

1
lower_string <- tolower(my_string)

我们还可以创建第二个字符串,并将其粘贴到第一个字符串的末尾:

1
2
second_string <- "Wow, two sentences."
my_string <- paste(my_string,second_string,sep = " ")

现在我们可以将这个字符串分离成若干个字符串,使用 stringr 中的 str_split() 函数可以实现这项操作。以下这行代码可以将上面合并的字符串按照感叹号进行分离:

1
my_string_vector <- str_split(my_string, "!")[[1]]

需要注意的是用于分隔的字符在这项操作之后被删除了,但我们现在实际上获得了两个句子,每一个被作为一个单独的字符串储存。注意这一步返回的是一个 list , 因此要获得实际包含分隔后的字符串的向量,我们需要使用 list 操作符获得第一个元素。
现在,假设我们对包含问号的句子感兴趣,我们可以在结果值 my_string_vector 中使用 rep() 指令搜索包含 “?” 的字符串。

1
grep("\\?",my_string_vector)

你也许注意到了,上面的字符串中不止包含一个 ”?”,它包含的是一个 ”\?”. 原因是因为,在正则表达式中 ”?” 是一个具有特殊含义的字符,因此我们需要通过使用一个”\”来跳过它,然而,由于字符串传递到底层函数的方式,我们实际上需要第二个 ”\” ,以确保当输入传递给 C 时,字符串中包含一个 ”\” 。你可以查阅这个特殊字符清单来看看哪些字符需要避开以获得它们的字面值。我们还需要检查 my_string_vector 中的单个字符串是否包含问号。这对于条件语句是非常有用处的,比如,如果我们在处理的是网页行,我们需要对具有标题标签 <h1> 的行和不具有标题标签的行进行不同的处理,那么使用带有逻辑 grep 的条件语句,grep(),是非常有帮助的。看一下示例:

1
grepl("\\?",my_string_vector[1])

还有两个我常用到的函数,第一个是将一些字符替换成其他字符,使用 str_replace_all() 函数可以实现,如下所示:

1
str_replace_all(my_string, "e","___")

另一个我时常用到的是使用 str_extract_all() 函数, 来提取字符串中所有数字:

1
str_extract_all(my_string,"[0-9]+")

注意在这里我们使用了第一个真正的正则表达式—— [0-9]+, 其含义是“匹配任何是一个或多个连续数字的子字符串”。这些只是用于在 R 中处理文本的许多更复杂的命令中的几个。我也只仅仅展示了一些最简单的正则表达式。在这个领域还有许多需要学习的,初学者可能会觉得有些压力,因此我建议先从工具的使用开始,然后当涉及更复杂的大块文本或文本处理任务时再利用 Google 来拓展你的能力。

文本清理

读入并清理原始文本输入文件是一项最基本的操作。实现这项操作,我们需要用到两个函数,一个是用于清洗单个字符串,删除任何不是字幕的字符,将所有字母小写化,并在对结果进行分词之前去除所有单词之间的空格,最后返回一个包含单个单词的向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Clean_String <- function(string){
# Lowercase
temp <- tolower(string)
#' Remove everything that is not a number or letter (may want to keep more
#' stuff in your actual analyses).
temp <- stringr::str_replace_all(temp,"[^a-zA-Z\\s]", " ")
# Shrink down to just one white space
temp <- stringr::str_replace_all(temp,"[\\s]+", " ")
# Split it
temp <- stringr::str_split(temp, " ")[[1]]
# Get rid of trailing "" if necessary
indexes <- which(temp == "")
if(length(indexes) > 0){
temp <- temp[-indexes]
}
return(temp)
}

让我们通过在控制台中运行一段代码来测试这个函数(从而定义函数),然后对以下句子进行清洗并分词:

1
2
3
4
5
6
7
8
9
10
11
12
13
sentence <- "The term 'data science' (originally used interchangeably with 'datalogy') has existed for over thirty years and was used initially as a substitute for computer science by Peter Naur in 1960."
clean_sentence <- Clean_String(sentence)
> print(clean_sentence)
[1] "the" "term" "data"
[4] "science" "originally" "used"
[7] "interchangeably" "with" "datalogy"
[10] "has" "existed" "for"
[13] "over" "thirty" "years"
[16] "and" "was" "used"
[19] "initially" "as" "a"
[22] "substitute" "for" "computer"
[25] "science" "by" "peter"
[28] "naur" "in"

可以看到,特殊字符都被删除了,留下的是包含单个单词的向量。现在我们要将这项操作扩展到处理整个输入文档,为此,我们将要对该文档的所有输入行进行循环,除了返回清除的文本本身之外,我们还可能想返回一些有用的元数据,诸如标记的总数,或者唯一标记的集合。我们可以使用以下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#' function to clean text
Clean_Text_Block <- function(text){
if(length(text) <= 1){
# Check to see if there is any text at all with another conditional
if(length(text) == 0){
cat("There was no text in this document! \n")
to_return <- list(num_tokens = 0, unique_tokens = 0, text = "")
}else{
# If there is , and only only one line of text then tokenize it
clean_text <- Clean_String(text)
num_tok <- length(clean_text)
num_uniq <- length(unique(clean_text))
to_return <- list(num_tokens = num_tok, unique_tokens = num_uniq, text = clean_text)
}
}else{
# Get rid of blank lines
indexes <- which(text == "")
if(length(indexes) > 0){
text <- text[-indexes]
}
# Loop through the lines in the text and use the append() function to
clean_text <- Clean_String(text[1])
for(i in 2:length(text)){
# add them to a vector
clean_text <- append(clean_text,Clean_String(text[i]))
}
# Calculate the number of tokens and unique tokens and return them in a
# named list object.
num_tok <- length(clean_text)
num_uniq <- length(unique(clean_text))
to_return <- list(num_tokens = num_tok, unique_tokens = num_uniq, text = clean_text)
}
return(to_return)
}

现在来尝试以下。你可以在这里下载到2009年2月24日巴拉克·奥巴马在弗吉尼亚大学米勒中心总统演讲档案馆的联合会议上所发表演讲的纯文本。保存了该文件之后,你需要将 R 的工作目录设置在保存该文件的目录下,然后使用以下代码在在 R 中读入该文件:

1
2
t <- readLines(con)
close(con)

现在你可以运行 Clean_Text_Block() 函数,看一下输出结果:

1
2
3
4
5
6
clean_speech <- Clean_Text_Block(text)
> str(clean_speech)
List of 3
$ num_tokens : int 6146
$ unique_tokens: int 1460
$ text : chr [1:6146] "madam" "speaker" "mr" "vice" ...

可以看到,这个文件中包含6146个单词,去重后有1460个单词。现在,你已经越过了文本分析中最大的障碍中的一个了,即将你的数据以合理的格式读入R中。

构建文档-词矩阵

对于的社会科学的文本数据分析,我们最常做的事情之一是构建文档-词矩阵。这实际上是一个相对具有挑战性的编程任务,它也通常计算量很大,所以我将使用 C++ 编写的函数来完成这个任务。在继续之前,我建议你查看我的教程使用 C++ 和 R 代码与 Rcpp 包来获得关于 C++ 编程的基础。在学会安装 Rcpp 包并运行它之前,你可能还需要按照本教程开始之前的一些步骤,尤其当你使用的是 Windows 或某些版本的 Mac OS X. 在开始之前,你应确保已安装了以下包:

1
2
3
install.packages("Rcpp",dependencies = T)
install.packages("RcppArmadillo",dependencies = T)
install.packages("BH",dependencies = T)

BH 包对于下面的功能不是必需的,但是安装以后对于以后使用 C++ 函数会有帮助。现在,让我们来看看一个 C++ 函数,它将帮助我们生成一个文档-词矩阵:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <RcppArmadillo.h>
//[[Rcpp::depends(RcppArmadillo)]]
using namespace Rcpp;
// [[Rcpp::export]]
arma::mat Generate_Document_Word_Matrix(int number_of_docs,
int number_of_unique_words,
std::vector<std::string> unique_words,
List Document_Words,
arma::vec Document_Lengths
){
arma::mat document_word_matrix = arma::zeros(number_of_docs,number_of_unique_words);
for(int n = 0; n < number_of_docs; ++n){
Rcpp::Rcout << "Current Document: " << n << std::endl;
std::vector<std::string> current = Document_Words[n];
int length = Document_Lengths[n];
for(int i = 0; i < length; ++i){
int already = 0;
int counter = 0;
while(already == 0){
if(counter == number_of_unique_words ){
already = 1;
}else{
if(unique_words[counter] == current[i]){
document_word_matrix(n,counter) += 1;
already = 1;
}
counter +=1;
}
}
}
}
return document_word_matrix;
}

你可以在此下载上述的 C++ 代码的源文件。一旦你把文件保存到你可以访问的地方(下面的例子代码假设它保存在你的工作目录下),你现在可以调用该代码,从而获得一个底层是 C++ 代码的 R 函数。

1
Rcpp::sourceCpp('Generate_Document_Word_Matrix.cpp')

现在你可以使用这个功能了,让我们在示例中进行测试。我们要做的第一件事是获取第二个文档,用来创建一个不止一个文档长的文档-词矩阵。你可以点击这里的链接下载另一个奥巴马演讲( 2010 年的国情咨文)。我们现在可以将这段文本读入并分词如下:

1
2
3
4
5
6
# Read in the file
con <- file("Obama_Speech_1-27-10.txt", "r", blocking = FALSE)
text2 <- readLines(con)
close(con)
# Clean and tokenize the text
clean_speech2 <- Clean_Text_Block(text2)

现在我们准备好设置,并使用文档-词矩阵生成器函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#' Create a list containing a vector of tokens in each document for each
#' document. These can be extracted from the cleaned text objects as follows.
doc_list <- list(clean_speech$text,clean_speech2$text)
#' Create a vector of document lengths (in tokens)
doc_lengths <- c(clean_speech$num_tokens,clean_speech2$num_tokens)
#' Generate a vector containing the unique tokens across all documents.
unique_words <- unique(c(clean_speech$text,clean_speech2$text))
#' The number of unique tokens across all documents
n_unique_words <- length(unique_words)
#' The number of documents we are dealing with.
ndoc <- 2
#' Now feed all of this information to the function as follows:
Doc_Term_Matrix <- Generate_Document_Word_Matrix(
number_of_docs = ndoc,
number_of_unique_words = n_unique_words,
unique_words = unique_words,
Document_Words = doc_list,
Document_Lengths = doc_lengths
)
#' Make sure to add column names to you Doc-Term matrix, then take a look!
colnames(Doc_Term_Matrix) <- unique_words

使用现有的文本处理包

本教程主要集中于说明一些非常基本的工具,以及在必要的底层函数下生成文档-词矩阵。但是,也有更简单的方法来做到这一点。用于在 R 中(包括在多种语言下)进行文本处理的最全功能包之一是 Quanteda 包。如果我们要使用该包,需要先进行安装:

1
install.packages("quanteda",dependencies = T)

现在我们假设需要使用上述例子中的两段讲话。我们可以使用以下代码段生成文档-词矩阵:

1
2
3
4
5
6
7
8
9
10
#' Create a vector with one string per document:
#' document. These can be extracted from the cleaned text objects as follows.
docs <- c(paste0(text,collapse = " "),paste0(text2,collapse = " "))
#' load the package and generate the document-term matrix
require(quanteda)
doc_term_matrix <- dfm(docs, stem = FALSE)
#' find the additional terms captured by quanteda
missing <- doc_term_matrix@Dimnames$features %in% colnames(Doc_Term_Matrix)
#' We can see that our document term matrix now includes terms with - and ' included.
doc_term_matrix@Dimnames$features[which(missing == 0)]

这当然比自己编写代码更容易且更有效率。一般来说,使用 Quanteda 来生成文档-词矩阵(以及 vignettes 包中许多其他用于文本统计分析的函数)对于融合大多数文本语料库很有帮助。 Quanteda 包的一个特别有用的功能是它自动将文本数据存储为稀疏矩阵对象,这往往比使用密集矩阵更加节省空间。然而,与此同时,Quanteda 要求用户能够一次性将他们希望处理的所有文档加载到R会话中的文档-词矩阵中。这通常不是什么大问题,但是对于一些非常大规模的文本处理应用,可能会有内存溢出的风险。

我最近在开发一个可以解决文本预处理时内存不足的问题的包,并且封装了 Stanford CoreNLPMALLET 包,为当前流行的技术提供了接口。这个包目前不像 Quanteda 包一样优秀,也不太可能会针对较小的语料库进行优化,但是对于对大型的 NLP 应用感兴趣,或是需要处理大型的语料库的用户来说,这将是一个不错的选择。目前该项目托管于 GitHub 上:SpeedReader.

感谢查看本教程,它将持续在2015年年底至2016年初更新,所以如果你对任何新的内容感兴趣,可以通过电子邮件联系我。如果你有兴趣尝试运行教程中的R代码,你可以在这里下载 .R 文件。