本文是 Natural Language Processing in Action by Hobson Lane, 2019 的读书笔记, 相关代码见 my fork of the official repo。
Environment Setup
git clone git@github.com:slm-bj/nlpia.git
cd nlpia
python -m venv env
. env/bin/activate
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple --upgrade pip
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -e .
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pynvim vaderSentiment
pip
的 -e
选项使得 pip 以 development mode 安装本地包(这里是 nlpia),
也就是在开发路径下新建一个以 .egg-info 结尾的目录(这里是 src/nlpia.egg-info)
作为本地包的安装路径(而不是安装到 PYTHON_HOME/site-packages 下),
另外安装的包可以不完全遵循 requirements.txt 的要求,
比如 tensorflow 实际安装的版本是 2.3.0,与 requirements.txt 指定的版本不符,
并且 requirements.txt 中的 AIML-Bot, Keras-Applications 等包都没有装。
Preface
如果思维是用自然语言表达的, NLP 就是实现通用人工智能 AGI 的关键步骤。
NLP 已经在影响社会和自我训练方面形成了闭环,发挥了很大作用, 未来的影响会越来越大。
About this book
Roadmap
运行代码:将数据文件(csv)放入 src/nlpia/data 目录,
然后运行 nlpia.data.loaders.get_data()
.
Part 1: 使用算数方法构建基本的 NLP 算法,比如 email spam filter;
Part 2: 使用神经网络构建 NLP 算法;
Part 3: 使用前面学到的方法构造真实的聊天机器人。
Chapter 1
1.4.3
bag-of-words 是输入文本去掉了 stop word(例如 a, of 等)和 rare word(例如人名) 后的词频统计表,见 p18 图示和下面的 Python 代码。 可以用 Python 的 collections.Counter 对象实现。
使用 bag-of-words 代表一句话(比如用户的提问), 再用 bag-of-words 代表一篇文档(最能回答用户提问的文档), 寻找这两个 vector 之间的关系。
1.5
第一种实用的 NLP 编码方法:one-hot encoded vectors (p20)
1.6
第一段谈到前面的 bag-of-words 方法虽然丢弃了单词顺序,但处理比较短的文本仍然效果良好, 我觉得原因在于英语、德语这类语言对语法成分的顺序不敏感, 但对于中文这样的分析型语言,顺序是特别重要的,采用 bag-of-words 效果大概不会好。
Chapter 2
2.2
bag-of-words vector = word frequency vector 只包含 word frequency,不包含词的顺序,每个向量长度为词汇表长度。 见 p39.
2.2.1
p40:
df = pd.DataFrame(pd.Series(dict([(token, 1) for token in sentence.split()])), columns=['sent']).T
注意这里如何用 pd.DataFrame
的 T
属性实现 dataframe 的行列对调。
p41: dot product 相当于 数据库的 inner join,相当于 矩阵乘法, 一个行向量 dot product 一个列向量得到一个标量。
2.2.2
Listing 2.6:
采用 dot 计算重合度的原理是:如果其中有一个向量在某个词下频率为0,则乘积为0:
sent0 sent1 sent2 sent3
Thomas 1 0 0 0
Jefferson 1 0 0 0
began 1 0 0 0
building 1 0 0 0
Monticello 1 0 0 1
at 1 0 0 0
the 1 0 1 0
age 1 0 0 0
of 1 0 0 0
26. 1 0 0 0
Construction 0 1 0 0
was 0 1 0 1
done 0 1 0 0
mostly 0 1 0 0
by 0 1 0 0
local 0 1 0 0
masons 0 1 0 0
and 0 1 0 0
carpenters. 0 1 0 0
He 0 0 1 0
moved 0 0 1 0
into 0 0 1 1
South 0 0 1 0
Pavilion 0 0 1 0
in 0 0 1 0
1770. 0 0 1 0
Turning 0 0 0 1
a 0 0 0 1
neoclassical 0 0 0 1
masterpiece 0 0 0 1
Jefferson's 0 0 0 1
obsession 0 0 0 1
2.2.3
p49. n-gram: 长度为n的词组,这里的词指的是空格分开的连续英文字母。 例如 "ice cream" 是 2-gram,"beyond the pale" 是 3-gram。
n-gram 可以有效的解决 tokenize 丢失词序导致的意思错误, 比如 "was not" 是一个 2-gram,整体作为一个 token, 比单个的 "was"、"not" 含义更准确。
p52, Stop Words
当资源重复,词汇表又比较大时,不要忽略 stop words. 由于 NLTK 的 stop words 列表随版本变化, 忽略 stop words 可能会导致程序运行结果随 NLTK 版本变化而变化。
2.2.5
缩小词汇表有助于降低 overfitting 的可能性。
Case Folding
将不同大小写的单词当成相同的单词。
最简单的方法:全部转小写。
会丢失信息,例如 Doctor
和 doctor
。
可以只转换句首的大写字母,
仍然会丢失信息,要在效率和准确性之间权衡。
Stemming
去掉名词复数、所有格 (possessive words)、动行变形(时态等)产生的变化。
p58(拷贝自 2020.7.15 日记):
import re
>>> re.findall('^(.*ss|.*?)(s)?$', "housees")
[('housee', 's')]
|
的优先级比较低,所以正则表达式的意思是:
要么匹配 ss
结尾,要么以 non-greedy 方式匹配任何单词。
在 .*ss
情形下,由于采用贪婪模式,即使有多于两个的 s,
也仍然匹配最后的两个 s,所以第二组 (s)?
总为空。
如果不是以两个或两个以上 s 结尾,则由于采用 non-greedy 模式(星号后面的问好将星号转为非贪婪模式), 第二组能匹配则尽量匹配,也就是说,只要是一个 s 结尾,则一定被分配到第二组。
p59:Porter 一生心血打造的 stemmer 算法,就在那 300 行 Python 代码里, 让人好生感慨。
Lemmatization
lemma: 词根
Lemmatization 也会导致单词含义被改变,从而造成分析失真。
Lemmatizer 在 NLP pipeline 中的位置比 stmmer 靠前, 因为 lemmatizer 处理后的 token 仍然是正常的单词, 可以被 stemmer 使用。
POS: part of speech,大致相当于一个单词在句子中的角色,或者词性, 比如是名词、形容词还是动词。
p61: Princeton WordNet 居然连 best 和 good 的联系都没有,实在有失水准。
Use Case
是否使用 normalization 技术要随算法目标而定。 信息搜索场景中,如果需要找到尽可能多的结果,容忍一定的假阳性,则使用 normalization, 而聊天机器人对准确性的要求更高,所以一般不用 normalization。
Bottom line:除非特殊情况(比如研究对象是充满专业术语的论文等), 不要使用 normalization。
2.3
计算机技术中,heuristic 是人类通过经验编写的 if-else 式的算法, 与之相对的是基于机器学习,或者数学公式的算法。
Colins Cobuild 的解释是:
A heuristic computer program uses rules based on previous experience in order to solve a problem, rather than using a mathematical procedure.
Vader 使用 heuristic 方法做 sentiment 分析。
Naive Bayes 是有监督学习,首先基于一个打好标记的语料库上建立模型。
2.3.2
p67:
movies['predicted_sentiment'] = nb.predict_proba(df_bows) * 8 - 4
应改为
movies['predicted_sentiment'] = nb.predict_proba(df_bows)[:, 1] * 8 - 4
nb.predict_proba(df_bows)
的计算结果是一个形状为 (10605, 2) 的 numpy array,
行数与 movies 的观测数相等,每行包含两个值,分别是为 0 (代表文档有否定倾向)
和 为 1 (代表文档有肯定倾向)的概率,和为 1(没有其他情况)。
所以这里只取每个文档肯定倾向的概率作为 movies 的预测值。
赞同的概率再 * 8 - 4
的原因是赞同概率原来分布范围是 [0, 1],
但 movies.sentiment 分布范围是 [-4, 4](基于 movies.sentiment.describe()
)。
pandas.DataFrame.append() 可以将两个列不一样的 dataframe 以并集的方式连在一起:
In [10]: d1 = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
In [12]: d2 = pd.DataFrame({"c": [11, 12, 5], "d": [13, 14, 18]})
In [13]: d1.append(d2)
Out[13]:
a b c d
0 1.0 3.0 NaN NaN
1 2.0 4.0 NaN NaN
0 NaN NaN 11.0 13.0
1 NaN NaN 12.0 14.0
2 NaN NaN 5.0 18.0
In [14]: d3 = pd.DataFrame({"c": [11, 12, 5], "b": [13, 14, 18]})
In [16]: d1.append(d3)
Out[16]:
a b c
0 1.0 3 NaN
1 2.0 4 NaN
0 NaN 13 11.0
1 NaN 14 12.0
2 NaN 18 5.0
In [20]: df_bows.shape[1] + df_pbows.shape[1] - df_all_bows.shape[1]
Out[20]: 3141
In [48]: df_pbows.shape[1] - 3141
Out[48]: 2546
表明 df_bows
和 df_pbows
中有 3141 个 token 是重合的,
从 products 总词汇表容量为 df_pbows.shape[1]
,
去掉重合的 3141 个,剩下正好是书中所说的,有 2546 个 token 不在 movies 的词汇表里,
被下面的操作去掉了:
df_product_bows = df_all_bows.iloc[len(movies):][df_bows.columns]
改成在 products 上拟合,而不是使用 movies 上的拟合模型 nb:
products = get_data('hutto_products')
bows = []
for text in products.text:
bows.append(Counter(casual_tokenize(text)))
df_pbows = pd.DataFrame.from_records(bows)
df_pbows = df_pbows.fillna(0).astype(int)
nbp = MultinomialNB().fit(df_pbows, products.sentiment > 0) # 在 products 上拟合
products['predicted_sentiment'] = nbp.predict_proba(df_pbows)[:, 1] * 8 - 4
products['sentiment_ispositive'] = (products.sentiment > 0).astype(int)
products['predicted_ispositive'] = (products.predicted_sentiment
> 0).astype(int)
(products.predicted_ispositive ==
products.sentiment_ispositive).sum() / len(products)
准确率 0.8847,远好于示例代码 0.5572 的准确率。
Chapter 3
本章讲如何用数值表示一个 token 的重要性,使用的三种方法都是基于频率的。 第 4 章讲如何用数值表示一个 token 的含义。
3.1
上一章 one-hot 编码中,dataframe 的结构是: 每行代表一个文档(例如一条twitter,或者一条电影评论), 没列是一个词,每个 cell 代表当前词(列)在当前文档(行)中出现的次数。
term frequency, TF: the number of times a word occurs in a document.
p74 代码:
tf = times_harry_appears / num_unique_words
对单词 harry
的 TF 做了 normalization,也就是除以文档总词汇量。
A is tempered by B
表示 A 被 B 温和化,去极端化,
这里用 A 除以 B,从而避免 B 不同时,只用 A 比较导致的不准确。
3.2
Lexicon: 词汇表,所有 doc 中词汇的并集。
p77 代码分析:
doc_tokens
是一个元素为 list (对应一个 doc,元素是 token,即单词)的 list,
all_doc_tokens = sum(doc_tokens, [])
将嵌套结构打平,
不改变顺序直接连在一起。
lexicon
是一个排序后长度为 18 的 list,代表 3 个文档组成的完整单词表。
zero_vector
是一个 lexicon 中所有 token 对应 TF 全为 0 的字典,
后面每轮循环处理一个 doc,在 zero_vector 中,当前文档包含的单词上更新 TF,
而没有被更新到的 token(lexicon 里有,但当前文档里没有的 token)仍然保持 TF 为 0。
3.2.1
一般用 K 表示 lexicon 的大小,有些论文里用 [V]
表示。
上面 p77 3 个文档的例子里,K = len(lexicon) = 18
。
Fig 3.2 和 Fig 3.3 中图例标注错误,应为 doc_0, doc_1, doc_2, 而不是两个 doc_0 和一个 doc_2。
p82 第2行 Python 表达式有误,应为
a.dot(b) = norm(a) * norm(b) * cos(theta)
而非
a.dot(b) = norm(a) * norm(b) / cos(theta)
。
下面这句话成立的前提是 A 和 B 长度相同(p82 倒数第2段倒数第2句话):
A、B (假设 A 比 B 长)夹角的 cos 值,相当于 B 在 A 上的投影长度与 A 长度的比值。
如果两个 doc 的 TF cos 相似度为 1,说明二者使用相同的词,且这选词在文档中的比例相同, 从而证明这两个文档高度类似。 cos 相似度为 0 不一定表明两个文档讨论的不是一个话题, 只是表示两个文档使用的词完全不同。 由于词出现在文档中的频率不可能是负数,导致 cos 相似度最小为 0,而不会小于 0。
3.3
Zipf(p 不发音)定律:一篇文档,按单词出现频率对所有单词排序,比如 "the" 排第一,"a" 排第二, "of" 排第三,则每个单词在整个文档中所占的比重是其排名的倒数。 排名第一的作为标杆(1 的倒数还是 1),第二名("a")出现的次数是它("the")的 1/2, 第三名是它的 1/3,等等。
详见 Zipf's law 第2段的例子。
获取 nltk_data 方法
nltk 使用 nltk.data.path
寻找数据位置,这是一个标准的 Python string list,
目录最后必须是 nltk_data,例如 ~/Documents/machineLearningDatasets/nltk_data,
将原始 brown.zip 文件保存到 .../nltk_data/corpora 下,
并在这个目录下解压 zip 文件,得到 .../nltk_data/corpora/brown 目录,
下面包括许多文本文件。
下载原始数据:
git clone https://github.com/nltk/nltk_data.git
zip 文件在 nltk_data/packages/corpora 下。
使用方法:在需要使用 nltk_data 的 Python 代码里添加:
nltk.data.path.append('/home/leo/Documents/machineLearningDatasets/nltk_data')
然后就可以正常使用了:
from nltk.corpus import brown
3.4
IDF