希望这篇文章能成为你前往自然语言处理世界的最佳桥樑。
自从 11 月从比利时 EMNLP 回来后,最近工作之馀都在学习自然语言处理(Natural Language Processing, 后简称为 NLP)。
上上週阴错阳差地参加了一个 Kaggle 竞赛。在该比赛中,我实际应用到不少前阵子所学的 NLP 知识,也获得不少心得。
因此我想借此机会,在文中钜细靡遗地介绍自己在这次比赛运用以及学到的 NLP 概念,希望能帮助更多对人工智慧、深度学习或是 NLP 有兴趣但却不知如何开始的你,在阅读本故事之后能得到一些启发与方向,并展开自己的 NLP 之旅。
虽然不是必备,但有点程式经验会让你比较好理解本文的内容,因为在文中有不少 Python 程式码;另外,如果你熟悉深度学习(Deep Learning)以及神经网路(Neural Network),那你可以趁机複习一些以前学过的东西。
依据维基百科,NLP 的定义为:
自然语言处理(NLP)是计算机科学以及人工智慧的子领域,专注在如何让计算机处理并分析大量(人类的)自然语言数据。NLP 常见的挑战有语音辨识、自然语言理解、机器翻译以及自然语言的生成。
在这篇文章裡头,我将描述如何利用最近学到的 NLP 知识以及深度学习框架 Keras 来教会神经网路如何辨别眼前的假新闻。
儘管此文的 NLP 任务是假新闻分类,你将可以把从此文学到的基础知识运用到如机器翻译、教机器写诗、语音辨识等大部分的 NLP 任务。我也会在文末附上推荐的学习资源以及文章供你进一步探索。
如果你已经准备好展开一趟刺激的 NLP 冒险之旅的话,就继续往下阅读吧!
本文章节
- 30 秒重要讯息
- 意料之外的 Kaggle 竞赛
- 假新闻分类任务
- 用直觉找出第一条底线
- 资料前处理:让机器能够处理文字
- 有记忆的循环神经网路
- 记忆力好的 LSTM 细胞
- 词向量:将词彙表达成有意义的向量
- 一个神经网路,两个新闻标题
- 深度学习 3 步骤
- 进行预测并提交结果
- 我们是怎麽走到这裡的
- 3 门推荐的线上课程
- 结语:从掌握基础到运用巨人之力
本文编排已将手机读者放在第一位,但我仍然建议你使用较大的萤幕阅读。
[图片上传失败...(image-8fe7f5-1589532809901)]
使用画面左侧的章节传送门能让你更轻鬆地在各章节之间跳转(目前手机不支援,抱歉)
30 秒重要讯息
没错,光看上面的章节数,你应该了解无法在 10 分钟内 KO 这篇文章,但我相信这篇文章会是你学习 NLP 基础的最短捷径之一。
针对那些时间宝贵的你,我在这边直接列出本文想传达的 3 个重要讯息:
- 深度学习发展神速,令人不知从何开始学习。但你总是要从某个地方开始好好地学习基础
- NLP 接下来的发展只会更加快速,就连一般人也能弄出厉害的语言处理模型
- 站在巨人的肩膀之上,活用前人成果与经验能让你前进地更快,更有效率
这些陈述看似陈腔滥调,但希望好奇心能让你实际阅读本文,找出构成这些结论的蛛丝马迹。
让我们开始吧!
意料之外的 Kaggle 竞赛
Kaggle 是一个资料科学家以及机器学习爱好者互相切磋的数据建模和数据分析竞赛平台。
本文提到的 Kaggle 竞赛是 WSDM - Fake News Classification。
此竞赛的目的在于想办法自动找出假新闻以节省人工检查的成本。资料集则是由中国的手机新闻应用:今日头条的母公司字节跳动所提出的。(知名的抖音也是由该公司的产品)
本文的 Kaggle 竞赛 (图片来源)
而因为我所任职的 SmartNews 主打产品也是手机新闻应用(主要针对日本与美国用户),像是这种哪个企业又办了 Kaggle 竞赛、又开发什麽新功能等等的消息都会在公司内部流动。
话虽如此,在我从同事得知这个为期一个月的竞赛时,事实上离截止时间只剩一个礼拜了!(傻眼)
今年 10 月底参加的 EMNLP (图片来源)
但心念一转,想说从 EMNLP 会议回来后也学了一些不少 NLP 知识,不仿就趁著这个机会,试著在一週内兜出个模型来解决这个问题。
名符其实的「志在参加」。
假新闻分类任务
既然决定要参加了,当然得看看资料集长的什麽样子。训练资料集(Training Set)约有 32 万笔数据、测试资料集(Test Set)则约为 8 万笔。而训练资料集一部份的内容如下所示:
要了解此资料集,让我们先专注在第一列(Row),大蒜与地沟油新闻的每一个栏位。
(部分读者可能会对简体中文表示意见,但请体谅我没有办法事先将此大量数据全部转为繁体)
第一栏位 title1_zh
代表的是「已知假新闻」 A 的中文标题:
而第二栏位 title2_zh
则是一笔新的新闻 B 的中文标题,我们还不知道它的真伪:
要判断第二栏中的新闻标题是否为真,我们可以把它跟已知的第一篇假新闻做比较,分为 3 个类别:
-
unrelated
:B 跟 A 没有关係 -
agreed
:B 同意 A 的叙述 -
disagreed
:B 不同意 A 的叙述
如果新闻 B 同意假新闻 A 的叙述的话,我们可以将 B 也视为一个假新闻;而如果 B 不同意假新闻 A 的叙述的话,我们可以放心地将 B 新闻释出给一般大众查看;如果 B 与 A 无关的话,可以考虑再进一步处理 B。(这处理不在本文讨论范畴内)
如果 B 新闻「同意」假新闻 A 的话,我们大可将 B 新闻也视为假新闻,最后将其屏除
接著看到资料集(下图)第一列最右边的 label
栏位为 agreed
,代表 B 同意 A 的叙述,则我们可以判定 B 也是假新闻。
这就是一个简单的「假新闻分类问题」:给定一个成对的新闻标题 A & B,在已知 A 为假新闻的情况下,预测 B 跟 A 之间的关係。其关係可以分为 3 个类别:
- unrelated
- agreed
- disagreed
顺带一提,上图同时包含了 3 个类别的例子供你了解不同分类的实际情况。
第 3、 4 栏位则为新闻标题的英文翻译。而因为该翻译为机器翻译,不一定能 100% 正确反映本来中文新闻想表达的意思,因此接下来的文章会忽视这两个栏位,只使用简体中文的新闻标题来训练 NLP 模型。
用直觉找出第一条底线
现在任务目标很明确了,我们就是要将有 32 万笔数据的训练资料集(Training Set)交给我们的 NLP 模型,让它「阅读」每一列裡头的假新闻 A 与新闻 B 的标题并瞭解它们之间的关係(不相关、B 同意 A、B 不同意 A)。
理想上,在看过一大堆案例以后,我们的模型就能够「学会」一些法则,让它在被给定一组从来没看过的假新闻标题 A 以及新闻标题 B 的情况下,也能正确判断新闻 A 与新闻 B 的关係。
而所谓的「模型从来没看过的数据」,指的当然就是 8 万笔的测试资料集(Test Set)了。
我们利用训练资料集教模型学习,并用测试资料集挑战模型 (图片来源)
这样的陈述是一个非常典型的机器学习(Machine Learning, ML)问题。我们当然希望不管使用什麽样的模型,该模型都能够帮我们减少人工检查的成本,并同时最大化分类的准确度。
但在开始使用任何 ML 方法之前,为了衡量我们的自动化模型能提供多少潜在价值,让我们先找出一个简单方法作为底线(Baseline)。
这张图显示了训练资料集(Training Set)里头各个分类所佔的比例。是一个常见的 Unbalanced Dataset:特定的分类佔了数据的大半比例。
我们可以看到接近 70 % 的「成对新闻」都是不相关的。这边的「成对新闻」指的是资料集裡,每一行的假新闻标题 A 以及对应的标题 B 所组成的 pairs。
现在假设测试资料集(Test Set)的数据分佈跟训练资料集相差不远,且衡量一个分类模型的指标是准确度(Accuracy):100 组成对新闻中,模型猜对几组。
这时候如果要你用一个简单法则来分类所有成对新闻,并同时最大化准确度,你会怎麽做?
对没错,就是全部猜 unrelated
就对了!
事实上,此竞赛在 Evaluation 阶段使用 Weighted Categorization Accuracy,来稍微调降猜对 unrelated
的分数。毕竟(1)能正确判断出两个新闻是 unrelated
跟(2)能判断出新闻 B disagreed
假新闻 A 的价值是不一样的。(后者的价值比较高,因为比较稀有)
但使用多数票决(Majority Votes)的简单方法还是能得到 0.666 的成绩(满分为 1):
不过当你前往该 Kaggle 排行榜的时候,却会发现不少人低于这个标准:
第一次参加 Kaggle 的人可能会觉得这现象很奇怪。
但这是由于 Kaggle 竞赛 1 天只能提交 2 次结果,因此通常不会有人浪费提交次数来上传「多数票决」的结果(儘管分数会上升,大家还是会想把仅仅 2 次的上传机会用来测试自己的 ML 模型的准确度);另外也是因为不少人是上传 1、2 次就放弃比赛了。
但如果你的 ML 或深度学习模型怎样都无法超过一个简单法则的 baseline 的话,或许最后上传该 baseline 的结果也不失为提升排名的最后手段(笑)
找出 Baseline,可以让我们判断手上训练出来的机器学习模型有多少潜在价值、值不值得再继续花费自己的研究时间与电脑计算能力。
现在我们知道,要保证做出来的模型有点价值,最少要超过 baseline 才可以。以本文来说,就是多数票决法则得到的 0.666 准确度。
( baseline 的定义依照研究目的以及比较方法而有所不同)
资料前处理:让机器能够处理文字
要让电脑或是任何 NLP 模型理解一篇新闻标题在说什麽,我们不能将自己已经非常习惯的语言文字直接扔给电脑,而是要转换成它熟悉的形式:数字。
因此这章节就是介绍一系列的数据转换步骤,来将人类熟悉的语言如:
转换成人脑不易理解,但很「机器友善」的数字序列(Sequence of Numbers):
如果你对此步骤已经非常熟悉,可以假设我们已经对数据做完必要的处理,直接跳到下一章的有记忆的循环神经网路。
这章节的数据转换步骤包含:
- 文本分词(Text Segmentation)
- 建立字典并将文本转成数字序列
- 序列的 Zero Padding
- 将正解做 One-hot Encoding
如果你现在不知道上述所有词彙的意思,别担心!
你接下来会看到文字数据在丢入机器学习 / 深度学习模型之前,通常需要经过什麽转换步骤。搭配说明,我相信你可以轻易地理解以下每个步骤的逻辑。
在这之前,先让我们用 Pandas 将训练资料集读取进来:
import pandas as pd train = pd.read_csv( TRAIN_CSV_PATH, index_col=0) train.head(3)
tid1 | tid2 | title1_zh | title2_zh | title1_en | title2_en | label | |
---|---|---|---|---|---|---|---|
id | |||||||
--- | --- | --- | --- | --- | --- | --- | --- |
0 | 0 | 1 | 2017养老保险又新增两项,农村老人人人可申领,你领到了吗 | 警方辟谣“鸟巢大会每人领5万” 仍有老人坚持进京 | There are two new old-age insurance benefits f... | Police disprove "bird's nest congress each per... | unrelated |
3 | 2 | 3 | "你不来深圳,早晚你儿子也要来",不出10年深圳人均GDP将超香港 | 深圳GDP首超香港?深圳统计局辟谣:只是差距在缩小 | "If you do not come to Shenzhen, sooner or lat... | Shenzhen's GDP outstrips Hong Kong? Shenzhen S... | unrelated |
1 | 2 | 4 | "你不来深圳,早晚你儿子也要来",不出10年深圳人均GDP将超香港 | GDP首超香港?深圳澄清:还差一点点…… | "If you do not come to Shenzhen, sooner or lat... | The GDP overtopped Hong Kong? Shenzhen clarifi... | unrelated |
跟我们在 Kaggle 预览的数据一致。不过为了画面简洁,让我们只选取 2 个中文新闻标题以及分类结果(Label)的栏位:
cols = ['title1_zh', 'title2_zh', 'label'] train = train.loc[:, cols] train.head(3)
title1_zh | title2_zh | label | |
---|---|---|---|
id | |||
--- | --- | --- | --- |
0 | 2017养老保险又新增两项,农村老人人人可申领,你领到了吗 | 警方辟谣“鸟巢大会每人领5万” 仍有老人坚持进京 | unrelated |
3 | "你不来深圳,早晚你儿子也要来",不出10年深圳人均GDP将超香港 | 深圳GDP首超香港?深圳统计局辟谣:只是差距在缩小 | unrelated |
1 | "你不来深圳,早晚你儿子也要来",不出10年深圳人均GDP将超香港 | GDP首超香港?深圳澄清:还差一点点…… | unrelated |
有了必要的栏位以后,我们可以开始进行数据的前处理了。
文本分词
文本分词(Text Segmentation)是一个将一连串文字切割成多个有意义的单位的步骤。这单位可以是
- 一个中文汉字 / 英文字母(Character)
- 一个中文词彙 / 英文单字(Word)
- 一个中文句子 / 英文句子(Sentence)
依照不同的 NLP 任务会有不同切割需求,但很常见的切法是以单字(Word)为单位,也就是 Word Segmentation。
以英文来说,Word Segmentation 十分容易。通常只要依照空白分割,就能得到一个有意义的词彙列表了(在这边让我们先无视标点符号):
text = 'I am Meng Lee, a data scientist based in Tokyo.' words = text.split(' ') words
但很明显地,中文无法这样做。这时候我们将藉助 Jieba 这个中文断词工具,来为一连串的文字做有意义的切割:
import jieba.posseg as pseg text = '我是李孟,在东京工作的数据科学家' words = pseg.cut(text) [word for word in words]
如上所示,Jieba 将我们的中文文本切成有意义的词彙列表,并为每个词彙附上对应的词性(Flag)。
假设我们不需要标点符号,则只要将 flag == x
的词彙去除即可。
我们可以写一个很简单的 Jieba 断词函式,此函式能将输入的文本 text
断词,并回传除了标点符号以外的词彙列表:
def jieba_tokenizer(text): words = pseg.cut(text) return ' '.join([ word for word, flag in words if flag != 'x'])
我们可以利用 Pandas 的 apply
函式,将 jieba_tokenizer
套用到所有新闻标题 A 以及 B 之上,做文本分词:
train['title1_tokenized'] = \ train.loc[:, 'title1_zh'] \ .apply(jieba_tokenizer) train['title2_tokenized'] = \ train.loc[:, 'title2_zh'] \ .apply(jieba_tokenizer)
新闻标题 A 的断词结果如下:
train.iloc[:, [0, 3]].head()
title1_zh | title1_tokenized | |
---|---|---|
id | ||
--- | --- | --- |
0 | 2017养老保险又新增两项,农村老人人人可申领,你领到了吗 | 2017 养老保险 又 新增 两项 农村 老人 人人 可 申领 你 领到 了 吗 |
3 | "你不来深圳,早晚你儿子也要来",不出10年深圳人均GDP将超香港 | 你 不 来 深圳 早晚 你 儿子 也 要 来 不出 10 年 深圳 人均 GDP 将 超 香港 |
1 | "你不来深圳,早晚你儿子也要来",不出10年深圳人均GDP将超香港 | 你 不 来 深圳 早晚 你 儿子 也 要 来 不出 10 年 深圳 人均 GDP 将 超 香港 |
2 | "你不来深圳,早晚你儿子也要来",不出10年深圳人均GDP将超香港 | 你 不 来 深圳 早晚 你 儿子 也 要 来 不出 10 年 深圳 人均 GDP 将 超 香港 |
9 | "用大蒜鉴别地沟油的方法,怎么鉴别地沟油 | 用 大蒜 鉴别 地沟油 的 方法 怎么 鉴别 地沟油 |
新闻标题 B 的结果则为:
train.iloc[:, [1, 4]].head()
title2_zh | title2_tokenized | |
---|---|---|
id | ||
--- | --- | --- |
0 | 警方辟谣“鸟巢大会每人领5万” 仍有老人坚持进京 | 警方 辟谣 鸟巢 大会 每人 领 5 万 仍 有 老人 坚持 进京 |
3 | 深圳GDP首超香港?深圳统计局辟谣:只是差距在缩小 | 深圳 GDP 首 超 香港 深圳 统计局 辟谣 只是 差距 在 缩小 |
1 | GDP首超香港?深圳澄清:还差一点点…… | GDP 首 超 香港 深圳 澄清 还 差 一点点 |
2 | 去年深圳GDP首超香港?深圳统计局辟谣:还差611亿 | 去年 深圳 GDP 首 超 香港 深圳 统计局 辟谣 还 差 611 亿 |
9 | 吃了30年食用油才知道,一片大蒜轻松鉴别地沟油 | 吃 了 30 年 食用油 才 知道 一片 大蒜 轻松 鉴别 地沟油 |
太棒了,将新闻标题切割成一个个有意义的词彙以后,我们就能进入下一个步骤了!
另外值得一提的是,不管最后是使用哪种切法,切完之后的每个文字片段在 NLP 领域裡头习惯上会被称之为 Token。(如上例中的警方、GDP)
建立字典并将文本转成数字序列
当我们将完整的新闻标题切成一个个有意义的词彙(Token)以后,下一步就是将这些词彙转换成一个数字序列,方便电脑处理。
这些数字是所谓的索引(Index),分别对应到特定的词彙。
为了方便你理解这小节的概念,想像个极端的例子。假设我们现在就只有一个新闻标题:「狐狸被陌生人拍照」。
这时候要怎麽将这个句子转成一个数字的序列呢?跟上一小节相同,我们首先会对此标题做断词,将句子分成多个有意义的词彙:
text = '狐狸被陌生人拍照' words = pseg.cut(text) words = [w for w, f in words] words
有了词彙的列表以后,我们可以建立一个字典 word_index
。
该 dict 里头将上面的 4 个词彙当作键值(Key),每个键值对应的值(Value)则为不重複的数字:
word_index = { word: idx for idx, word in enumerate(words) } word_index
有了这个字典以后,我们就能把该句子转成一个数字序列:
print(words) print([word_index[w] for w in words])
简单明瞭,不是吗?
如果来了一个新的句子:「陌生人被狐狸拍照」,我们也能利用手上已有的字典 word_index
如法炮製:
text = '陌生人被狐狸拍照' words = pseg.cut(text) words = [w for w, f in words] print(words) print([word_index[w] for w in words])
在这个简单的狐狸例子裡头,word_index
就是我们的字典;我们利用该字典,将 1 句话转成包含多个数字的序列,而每个数字实际上代表著一个 Token。
同理,我们可以分 4 个步骤将手上的新闻标题全部转为数字序列:
- 将已被断词的新闻标题 A 以及新闻标题 B 全部倒在一起
- 建立一个空字典
- 查看所有新闻标题,里头每出现一个字典裡头没有的词彙,就为该词彙指定一个字典裡头还没出现的索引数字,并将该词彙放入字典
- 利用建好的字典,将每个新闻标题裡头包含的词彙转换成数字
这种文字前处理步骤因为出现频率实在太过频繁,Keras 有专门的文字前处理模组来提升我们的效率:
import keras MAX_NUM_WORDS = 10000 tokenizer = keras \ .preprocessing \ .text \ .Tokenizer(num_words=MAX_NUM_WORDS)
Tokenizer 顾名思义,即是将一段文字转换成一系列的词彙(Tokens),并为其建立字典。这边的 num_words=10000
代表我们限制字典只能包含 10,000 个词彙,一旦字典达到这个大小以后,剩馀的新词彙都会被视为 Unknown,以避免字典过于庞大。
如同上述的步骤 1,我们得将新闻 A 及新闻 B 的标题全部聚集起来,为它们建立字典:
corpus_x1 = train.title1_tokenized corpus_x2 = train.title2_tokenized corpus = pd.concat([ corpus_x1, corpus_x2]) corpus.shape
因为训练集有大约 32 万列(Row)的成对新闻(每一列包含 2 笔新闻:A & B),因此将所有新闻放在一起的话,就有 2 倍的大小。而这些文本的集合在习惯上被称作语料库(Text Corpus),代表著我们有的所有文本数据。
以下是我们语料库的一小部分:
pd.DataFrame(corpus.iloc[:5], columns=['title'])
title | |
---|---|
id | |
--- | --- |
0 | 2017 养老保险 又 新增 两项 农村 老人 人人 可 申领 你 领到 了 吗 |
3 | 你 不 来 深圳 早晚 你 儿子 也 要 来 不出 10 年 深圳 人均 GDP 将 超 香港 |
1 | 你 不 来 深圳 早晚 你 儿子 也 要 来 不出 10 年 深圳 人均 GDP 将 超 香港 |
2 | 你 不 来 深圳 早晚 你 儿子 也 要 来 不出 10 年 深圳 人均 GDP 将 超 香港 |
9 | 用 大蒜 鉴别 地沟油 的 方法 怎么 鉴别 地沟油 |
有了语料库以后,接下来就是呼叫 tokenizer
为我们查看所有文本,并建立一个字典(步骤 2 & 3):
tokenizer.fit_on_texts(corpus)
以我们的语料库大小来说,这大约需时 10 秒钟。而等到 tokenizer
建好字典以后,我们可以进行上述第 4 个步骤,请 tokenizer
利用内部生成的字典分别将我们的新闻标题 A 与 新闻 B 转换成数字序列:
x1_train = tokenizer \ .texts_to_sequences(corpus_x1) x2_train = tokenizer \ .texts_to_sequences(corpus_x2)
让我们看看结果:
len(x1_train)
x1_train[:1]
x1_train
为一个 Python list
,裡头包含了每一笔假新闻标题 A 对应的数字序列。
让我们利用 tokenizer.index_word
来将索引数字对应回本来的词彙:
for seq in x1_train[:1]: print([tokenizer.index_word[idx] for idx in seq])
轻鬆写意,不是吗?
到此为止,我们已经将所有新闻标题转换成电脑容易处理的数字序列,进入下个步骤!
序列的 Zero Padding
虽然我们已经将每个新闻标题的文本转为一行行的数字序列,你会发现每篇标题的序列长度并不相同:
for seq in x1_train[:10]: print(len(seq), seq[:5], ' ...')
最长的序列甚至达到 61 个词彙:
max_seq_len = max([ len(seq) for seq in x1_train]) max_seq_len
而为了方便之后的 NLP 模型处理(见循环神经网路一章),一般会设定一个 MAX_SEQUENCE_LENGTH
来让所有序列的长度一致。
长度超过此数字的序列尾巴会被删掉;而针对原来长度不足的序列,我们则会在词彙前面补零。Keras 一样有个方便函式 pad_sequences
来帮助我们完成这件工作:
MAX_SEQUENCE_LENGTH = 20 x1_train = keras \ .preprocessing \ .sequence \ .pad_sequences(x1_train, maxlen=MAX_SEQUENCE_LENGTH) x2_train = keras \ .preprocessing \ .sequence \ .pad_sequences(x2_train, maxlen=MAX_SEQUENCE_LENGTH)
一般来说 MAX_SEQUENCE_LENGTH
可以设定成最长序列的长度(此例中的 61)。但这边为了让模型可以只看前 20 个词彙就做出判断以节省训练时间,我们先暂时使用 20 这个数字。
让我们看看经过 Zero Padding 的第一篇假新闻标题 A 变成什麽样子:
x1_train[0]
你可以清楚看到,因为该新闻标题原本的序列长度并没有达到刚刚设定的 MAX_SEQUENCE_LENGTH
,因此在总长度为 20 的序列中,前面 6 个值被 Keras 补上 0 以说明该序列中的前 6 个词彙并不存在。
我们还可以发现,所有的新闻标题都被转成长度为 20 的数字序列了:
for seq in x1_train + x2_train: assert len(seq) == 20 print("所有新闻标题的序列长度皆为 20 !")
再看一次转换后的新闻标题:
x1_train[:5]
在这边,可以看到前 5 个新闻标题都已经各自被转换为长度为 20 的数字序列,而序列裡头的每个数字则对应到字典裡头一个特定的 Token,整整齐齐。
到此为止,我们已经将原本以自然语言呈现的新闻标题转换成机器容易理解的数字序列了。很神奇,不是吗?