统计PDF的词频

可视化领域,有一本非常经典的书籍《The Grammar of Graphics》,一直都想仔细读一下。但是这本书似乎没有中译本,只有英文版。就我目前这么烂的英文阅读水平,啃起来绝对很吃力。因此我想先统计下这本书里面的单词词频,找到高频的生词,我先通过比如扇贝单词等APP将这些生词背下来,这样看书的时候效率肯定就上去了。

大致实现的思路就是:解析PDF文件->拆分单词->统计词频->过滤认识的单词->将剩下的高频词导入扇贝生词本。

解析PDF文件

我看英文版的PDF,一般都是用的pdfjs这个工具,方便点击查词。因此本来这次也准备通过这个工具进行PDF的解析。不过我查找后,发现有一个更方便的工具pdf2json,可以解析成json格式,那这样我处理起来就更容易了。因此最终选择了这个pdf2json。github地址在这里

解析的代码直接使用官方示例即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function convertPDF2Txt(PDFFile, textFile)
{
PDFParser = require("pdf2json");
let pdfParser = new PDFParser(this, 1);
pdfParser.on("pdfParser_dataError", errData => console.error(errData.parserError));
pdfParser.on("pdfParser_dataReady", pdfData => {
console.log('pdfParser_dataReady');
// fs.writeFileSync("./output.json", JSON.stringify(pdfData));
fs.writeFileSync(textFile, pdfParser.getRawTextContent());

});

pdfParser.loadPDF(PDFFile);
}

convertPDF2Txt("../../../book/The Grammar of Graphics.pdf", "./output.txt");

拆分单词

单词的拆分需要注意如下几点:

1、不能只根据空格拆分,还需要注意对一些特殊字符也进行拆分;

2、单词一定要转为小写,否则同一个单词会因为大小写问题,被多次统计到;

3、换行导致一个单词被拆分为2个的情况也需要考虑下(我这次暂时没有处理换行的问题)。

代码如下:

1
2
3
4
5
6
7
8
9
10
/**
* 将文本拆分为单词
* TODO:换行导致单词被截断的情况,尚未处理
* @param {*} content
*/
function splitContentToWords(content)
{
var reg = new RegExp( "\r\n|\n|,|\\.|:|’|\\)|\\(|\\+|\\-|\\*|>|<|%|=|\"" , "g" )
return content.replace(reg, " ").toLowerCase().split(' ');
}

统计词频

这一步稍微麻烦一些,因为要考虑单词形变的问题,比如复数形式、过去时、现在进行时等等,需要将其转换为单词原型来统计(像objects就应该转为object),否则一个单词就会因为形变,在统计结果中出现多次。

这一步靠我自己写逻辑明显是不行的,得引入第三方支持了。一开始我本来是想用一些在线词典API,然后尝试了有道词典的API,发现并不能获取到单词原型;后来找到了一个做自然语言解析的stanford-nlp,发现这个是可以取到原型的,因此最终采用了该工具。

这个工具是Java写的,需要安装Java环境,然后下载官方的包,解压后即可使用。

由于这个解析很耗费性能,文本长度不能太大,因此需要先对现有的单词做去重处理。我是先对当前解析PDF后获取到的单词做了去重,然后写入了一个文本中,每个单词一行;然后再通过命令行调用stanford-nlp去做解析。解析命令大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 通过stanford-nlp获取单词的原型
* 用来解决单复数、时态等导致的单词被重复识别的问题
* 这一步耗时比较久,可能需要几分钟
* 参考资料:https://stanfordnlp.github.io/CoreNLP/
*/
function getOriginOfWord(file)
{
var process = require('child_process');
let cmd = 'cd stanford-corenlp-4.0.0 && java -Xms512M -Xmx4096M -cp "*" edu.stanford.nlp.pipeline.StanfordCoreNLP -file ' + file + ' --add-modules java.se.ee -outputFormat json -outputDirectory ../ && cd ..';
process.execSync(cmd);
}

这里需要注意下,尽量把Java的堆内存设置大一些(-Xmx参数),否则可能出现内存溢出的报错。

单词形变处理好之后,后续统计词频就比较简单了,不再赘述。

注意,必须加-cp '*',否则会报错:找不到或无法加载主类 edu.stanford.nlp.pipeline.StanfordCoreNLP

(必看)关于性能

前面我用的是默认的参数,结果把所有的功能都用上了,巨慢无比。后来看了这个:

Understanding memory and time usage

才发现可以通过-annotators指定自己需要的功能。我需要的是lemma,应该这样设置-annotators "tokenize,pos,lemma"

1
2
3
4
5
6
7
cd /d/software/stanford-corenlp-4.5.5 && \
java -mx50g -cp '*' edu.stanford.nlp.pipeline.StanfordCoreNLP \
-annotators "tokenize,pos,lemma" \
-outputFormat json \
-outputDirectory /d/git/vocabulary/statFrequency/data \
-file /d/git/vocabulary/statFrequency/data/unknownWords.txt && \
cd /d/git/vocabulary

这样3000+词基本也是秒级搞定。

关于词性

一开始我以为一个单词一行是不行的,因为无法获取单词在句子中的词性:

自然语言处理(NLP)之英文单词词性还原

但是实际测试,发现是OK的。

关于词量

截取6000个单词,这样频率最低的出现18次。

经过柯林斯3-5星和我自己的easy词库处理后为3562个,上传识别到2772个,加速787个,剩余2000个左右,还不错。

晚上优化了下,去掉了认识的单词,同样截取6000个单词,这样频率最低的出现14次。经过柯林斯3-5星和我自己的easy词库处理后为3311个,上传识别到2464个,加速598个,剩余1900个左右。

过滤认识的单词

因为我也没有一个词库记录所有我认识的单词,因此只能通过一些通用的单词列表将常见单词过滤掉。我选择了柯林斯的单词列表。这个词典对单词词频做了1-5星的评分,我大致看了下,发现3-5星的单词我大部分都认识,因此提取了这部分单词,作为过滤的词表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 获取我已经认识的单词
* 这里将其转换为map,而不是数组,方便后面通过键名进行高效的匹配
* @param {*} file
*/
function getRemmemberedWordMap(file)
{
let content = fs.readFileSync(file).toString();
let words = content.split("\n");
let wordMap = {};
words.forEach(w => {
wordMap[w] = 1;
});
return wordMap;
}

另外这里还需要再次对单词做一下过滤,包括:

1、筛选掉包含数字的”单词”

2、筛选掉长度小于等于2的”单词”(比如单个字母、被换行阶段而形成的2个字符的字符串等等)

过滤完成后,需要再次进行词频统计。

导入扇贝生词本

扇贝的生词本设置了限制,每次只能导入100个单词。本来我想通过抓包、修改页面HTML元素、通过JS控制页面交互等方式,自动将几千个单词导入进去,结果发现他们的前端、数据接口加密做得挺好的,没让我得逞,因此只能作罢,老老实实手动导入进去了。

现在可以导入单词书了。