【若干大作业】RNN三连

  这学期一口气选了三门 AI 课(AI、模式识别、NLP),初衷就是想深入了解以后能更有底气地说“我不喜欢AI”(x
  然后三门课内容高度重复,每个知识点平均听三遍。。。其中最近发生的重合是,人工智能实验先要写一个 RNN 做关键词提取,然后 NLP 课要用 BiLSTM+CRF 做中文分词,完了之后还要用 LSTM 做语言模型。。。
  于是这位可怜的老 C++ 选手在用 C++ 写完了 KNN、决策树、PLA、逻辑回归、BPNN 之后,不得不在一个月内从 python 语法入门摸爬打滚到机器学习带师(x

  这篇博客大概只是分享和记录,不是教程。我认为学 AI 最好的方式是在学校里上课(有老师带,有同学一起讨论),或者买本书来学。在网上找博客自学是很不靠谱的。

前置

  做这些事的前置技能大概就是:学会 python、学会神经网络的理论框架。

  一开始作为 C++ 选手也本能地纠结了一下既然 pytorch 也有 C++ API,为啥还要转 python 呢?大概考虑三个因素:

  1. 从速度来说:大家心中都有一个观念是“C++跑得快”,但是写起神经网络来,基本上都是在调库,时间瓶颈在于训练和反向传播的那些矩阵运算,线程优化和 GPU 算力才是硬道理。pytorch 底层也是 C/C++ 实现的,所以通篇都在调库的情况下,没有多大时间差。
  2. 从操作方便性来说:编程简便性是 python 优胜毫无疑问了,python 对于数据处理有更灵活的语法,还有巨多方便的库,例如一行 one-hot,例如许多课程可能要求先手写 BPNN,在 numpy 的支撑下 python 会从时间和简便性两方面吊打 C++。python 有包管理器这一点也很资瓷。
  3. 从环境来说:你周围的同学应该 98% 都会跟风用 python,这意味着如果你执意用别的语言,你将基本单打独斗,你们的讨论将变得困难,你代码出了 bug 不会有人来帮你调。这点对于学生可能才是最应该考虑的,为了获得学习环境的兼容,一起跟风吧~

  然后神经网络的数学基础,大概就是《最优化方法》一类的课程,最好手写一遍 BPNN,该造的轮子必须得造。

RNN 关键词提取

Task

  数据集是 http://alt.qcri.org/semeval2014/task4/ 的 SemEval-2014,Laptop,里面有 3000 余条英文句子,给出了每个句子的关键词(或词组)。每个句子可能有多个关键词(或词组)。
  以此训练一个模型,输入一个英文句子,可以找出句子的关键词。

模型准备

  首先要将这个任务表示为具体的数学模型。

  第一步是词嵌入。采用 100 维的 Glove 词向量(https://nlp.stanford.edu/projects/glove/),将每个单词及标点都换成相应的词向量,那么一个句子就是若干个 100 维向量组成的序列。

  第二步是序列标注。怎样使得模型的输出能够表征句子的关键词?序列标注是一种方法。参考中文分词的序列标注法,给每个字标上 B,M,E,S 中的一个标记,B 表示分词开始,M 表示词语中间字,E 表示分词结束,S 表示单字词语。这个序列唯一对应了一种分词结果。

  同理应用到关键词提取任务中,可以想到一种标注方法:N 表示非关键词,B 表示关键词开始,M 表示关键词中间词,E 表示关键词结束,S 表示单个关键词。这样就使得关键词提取变成了分类任务,输入一个句子,为每一个单词做分类。

  以“NBMES”来标注虽然十分准确,但是标注较为复杂,数据集较小时训练效果不好。
  一种改进是改为“NBM”标注,N 表示非关键词,B 表示关键词开始,M 表示关键词非开始位置。这与“NBMES”标注是等价的(二者是唯一对应的),但是化简了标注。
  另一种改进是改为“IO”标注,I 表示是关键词,O 表示不是关键词。这种标注法比上述方法更简便,且变成了二分类问题,可以使用更多评测指标(例如 F1 分数)。但缺点是无法区分关键词组与连续单个关键词。
  本次任务采用“IO”标注。检视本次数据集发现所有关键词(组)均不相邻,因此上述缺点在训练中不存在。在实际应用中,也并非一定要区分关键词组与连续单个关键词。

网络

  句子的长度是变化的,不适合使用固定大小的全连接层。循环神经网络(RNN)可以有效应对这类数据。
  (此处略去若干 RNN 原理。。。)

  RNN 可以做许多扩展,例如:

  1. 多个 RNN 垂直叠加,成为多层 RNN;
  2. 再增加一层从右影响到左的隐状态,成为双向 RNN;

  RNN 虽然理论上实现了当前状态与过往状态的联系,但对于时间相隔较长的过往状态保留的信息很少。同时,RNN 展开较深的时序时,存在梯度消失和梯度爆炸的问题。
  LSTM 是一种改进的 RNN,在其结构中加入了许多控制门:


  LSTM 新增了一层隐状态 $\mathbf C_t$。传统 RNN 通过 $\mathbf x_t$ 与 $\mathbf h_{t-1}$ 直接得到 $\mathbf h_t$;但在 LSTM 中,先通过 $\mathbf x_t$ 与 $\mathbf h_{t-1}$ 使用不同的权重和偏置分别算出遗忘门 $\mathbf f_t$、输入门 $\mathbf i_t$、输出门 $\mathbf o_t$,再使用遗忘门和输入门对 $\mathbf C_t$ 进行更新,最后用 $\mathbf C_t$ 和输出门得到 $\mathbf h_t$。
  三个控制门矩阵都经过了 sigmoid 函数,因此元素都 $\in (0,1)$,用这些矩阵对别的矩阵做对应位置相乘求和,相当于控制别的矩阵的每个元素的保留程度。因此 $\mathbf C_t = \mathbf f_t \odot \mathbf C_{t-1} + \mathbf i_t \odot \mathbf g_t$ 这一项,就相当于控制 $\mathbf C_{t-1}$ 遗忘了多少、当前 $\mathbf g_t$ 记住了多少,是一种平滑移动。

代码实现

  整份代码实现起来难度不大,主要难度在于学习如何使用 pytorch。就只放网络结构了,其实也都是调包侠。。。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import torch
import torch.nn as nn
import torch.nn.utils.rnn as tRnn

class LSTM(nn.Module):
def __init__(self, vec_dim, hidden_dim, out_dim):
super(LSTM, self).__init__()
self.lstm = nn.LSTM(vec_dim, hidden_dim, batch_first = True, dropout = 0.2, bidirectional = False)
self.fc1 = nn.Linear( hidden_dim, 64)
self.fc2 = nn.Linear(64, out_dim)
self.dropout10 = nn.Dropout(0.1)

def forward(self, x):
x, _ = self.lstm(x)
x, _ = tRnn.pad_packed_sequence(x, batch_first=True)

x = self.fc1(F.relu(x))
x = self.fc2(F.relu(x))

return x

  还有数据的预处理也是比较繁琐的,但是也只是编程实现上的难度,不是算法设计的难度。

效果

  因为序列标注用了二分类,因此结果的评价就用 $F1$ 分数了

BiLSTM+CRF 中文分词

Task

  使用 BiLSTM+CRF 分词模型,在 SIGHAN Microsoft Research 数据集上进行中文分词的训练和测试。
  已标注数据集的每一条数据是一个中文句子,词之间用两个空格隔开。

整体设计

  依然是采用序列标注的方法来做分词,为每个字标上 B,M,E,S 中的一个标记,B 表示分词开始,M 表示词语中间字,E 表示分词结束,S 表示单字词语。这个序列唯一对应了一种分词结果。

  模型的整体框架为:对于一个句子,设长度为 $l$(以 UTF8 字符数量计),依次通过以下步骤:

  1. 将每个 UTF8 字符替换成字向量,得到长度为 $l$ 的字向量序列;
  2. 通过 BiLSTM 得到长度为 $l$ 的分数向量序列(分数向量是一个 4 维向量,分别代表该字表为 B(begin),M,E,S 的分数);
  3. 通过 CRF 得到长度为 $l$ 的序列标注;
  4. 基于序列标注得到分词结果。

  采用 300 维的预训练字向量 sgns.context.word-character.char1-1.dynwin5.thr10.neg5.dim300.iter5,该向量集含有单字、词语、标点符号、英语缩写、希腊字母等诸多元素,但只取单字和标点符号用于本次任务。
  本次任务中,若句子含有未被字向量覆盖的字符,则该数据作废。经验证,训练集有 1 条数据被作废,测试集数据全部有效。

BiLSTM+CRF

  LSTM 上面已经写过了,BiLSTM 就是双向的 LSTM。
  LSTM 的输出是 4 维分数向量的序列,进行归一化可以直接得到每个字的标签预测概率分布。但是结合传统概率模型可以使上下文关联效果更好,因此 LSTM 之后采用 CRF。
  CRF 有两个任务:训练的时候求出给定序列标注的后验概率,并以其负对数作为损失函数进行梯度下降;测试的时候通过输入的分数向量序列求出概率最大的序列标注。
  但实际上许多现有的 CRF 实现方法做的并不是真正的概率模型,而是用一种标注方案的分数除以所有标注方案分数和作为该方案的“概率”。以下先按概率模型作分析,再说明现有 CRF 实现方法的分数模型。

概率模型

  训练时,设给定序列标注为 $y_1,\cdots,y_l$,CRF 的输入序列为 $\mathbf x_1,\cdots,\mathbf x_l$,则后验概率为

  求解该式只需将 $i$ 从 $1$ 到 $l$ 遍历一遍,把所有用到的概率乘起来即可。其中 $P(y_i|\mathbf x_i)$ 为发射概率,可以将向量 $\mathbf x_i$ 归一化之后取 $y_i$ 那一项;$P(y_i|y_{i-1})$ 为转移概率,可以用矩阵 $\mathbf A$ 来求,$A_{ij}$ 表示由标签 $i$ 转移到标签 $j$ 的概率,该矩阵与其他网络参数一起参与训练。
  测试时,已有输入序列 $\mathbf x_1,\cdots,\mathbf x_l$ 和标签转移矩阵 $\mathbf A$,则通过动态规划找到最大概率的标注方案。设 $dp_{i,j}$ 表示已经考虑了序列的前 $i$ 项,第 $i$ 项选择标签 $j$,的最大概率,则

  记录 DP 的转移路径,最后选最大的 $dp_{l,j}(j=1,2,3,4)$ 根据转移路径倒推即得到标注结果。
  实现的时候应当对概率取对数,将乘法变为加法运算,避免精度问题。

分数模型

  以 torchcrf 的实现为例:所有的概率都被换成分数,后验概率改为后验分数;发射概率 $P(y_i|\mathbf x_i)$ 替换为发射分数,直接取向量 $\mathbf x_i$ 中 $y_i$ 那一维的值;转移概率矩阵 $\mathbf A$ 替换为转移分数矩阵,$A_{ij}$ 表示由标签 $i$ 转移到标签 $j$ 可以获得的分数。上面两式的概率乘法改为分数相加。

  训练时还需计算一个“所有标注方案的分数和”,可以用类似递推算出。
  计算损失函数的时候,定义给定标注序列 $y_1,\cdots,y_l$ 的“概率”为

  取对数则为

代码实现

  依然是大调包,只放网络结构了。
  网络里的结构依次为:BiLSTM,全连接层,CRF。在训练的前向过程中,CRF 层是计算“概率”的对数,得到结果取反为损失函数;而测试的前向过程,则是动态规划解码,直接返回标注序列。

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
import torch
import torch.nn as nn
import torch.nn.utils.rnn as tRnn
from torchcrf import CRF

class BiLSTM_CRF(nn.Module):
def __init__(self, vec_dim, hidden_dim, out_dim):
super(BiLSTM_CRF, self).__init__()
self.lstm = nn.LSTM(vec_dim, hidden_dim//2, batch_first = True, dropout = 0.2, bidirectional = True)
self.hidden2tag = nn.Linear(hidden_dim, out_dim)
self.dropout10 = nn.Dropout(0.1)
self.crf = CRF(out_dim, batch_first = True)

def forward_train(self, x, label, mask):
x, _ = self.lstm(x)
x, _ = tRnn.pad_packed_sequence(x, batch_first=True)
# x = self.dropout10(x)
x = self.hidden2tag(x)
x = -self.crf(x, label, mask)
return x

def forward_test(self, x, mask):
x, _ = self.lstm(x)
x, _ = tRnn.pad_packed_sequence(x, batch_first=True)
x = self.hidden2tag(x)
x = self.crf.decode(x, mask)
return x

结果

  评估指标为 F1:

LSTM 语言模型

Task

  语言模型是机器对于语言本身的掌握程度,形式化地定义就是,给定一个句子,计算这个句子的概率。
  这东西的应用,比如,翻译标准“信达雅”中的“达”,即目标语言的通顺流畅,就是语言模型;比如,输入法或者百度搜索框里面输入了一个词,系统就会联想下一个词,或者联想了一个句子,这个是文本生成。
  本次任务就是文本生成。给定初始词,用这个词生成下一个词,再用新生成的词生成下一个词,再用新生成的词生成下一个词……直至生成\表示句子的结束。用 LSTM 实现。训练集仍是 SIGHAN Microsoft Research。

原理与步骤

  模型核心为一个单向 LSTM,输入是一个句子转化成的词向量序列,训练时通过 LSTM 得到相同长度的预测概率分布序列(序列的每一个元素是词表大小的向量,表示预测为词表某个词的概率分布),以与正确文本的逐词交叉熵作为损失函数进行反向传播;测试时给定初始词,不断用上一个词通过 LSTM 生成下一个词,直至生成 \句末标志。

词表

  词表构建有两种方法,一是沿用上一个 Task 的 300 维预训练字词向量 sgns.context.word-character.char1-1.dynwin5.thr10.neg5.dim300.iter5,它包含了绝大部分常用词语。但该方法有很多缺点:

  1. 词表有 63 万个词,搭建网络对显存需求很大;
  2. 训练集被词表完全覆盖的句子只有 39231 条,意味着超过一半的句子存在“未知词”;
  3. 观察词表发现 150000 以后的词并不常用(如各种网络用语),几乎不会出现在训练集里,造成浪费。

  第二种方法是仅给训练集里出现的词语进行编号,用 pytorch 的 embedding 层得到词向量,随其他网络参数一同训练。训练集的词语约 88000 条,相比上一个方法能有效减小网络大小,且完全覆盖训练集。

  本次两种方法都尝试了一遍,一是截取预训练词表前 150000 个词,且训练集只保留被词向量完全覆盖的句子(否则预测出来的句子也会大量出现“未知词”);二是给训练集的词编号并使用 embedding 层,词向量跟随网络一起训练。

实现

网络结构

  网络里的结构依次为:(embedding),LSTM,全连接层。词表大小为 vocab\_size,embedding 层维度为 vocab\_size$$\to$$embedding\_dim,LSTM 层维度为 embedding\_dim$$\to$$hidden\_dim,全连接层维度 hidden\_dim$$\to$$vocab\_size
  若采用预训练词向量则没有 embedding 层。
  由于显存的限制,hidden\_dim 只能开到 350,造成该网络是比较畸形的网络。(后面全连接层输出至少是 8 万维)

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
import torch.nn.utils.rnn as tRnn

class LSTM(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, out_dim):
super(LSTM, self).__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first = True, dropout = 0.2)
self.hidden2tag = nn.Linear(hidden_dim, out_dim)
self.dropout10 = nn.Dropout(0.1)

def forward_train(self, x, data_length):
x = self.embedding(x)
x = tRnn.pack_padded_sequence(x, data_length, batch_first=True)
x, _ = self.lstm(x)
x, _ = tRnn.pad_packed_sequence(x, batch_first=True)
x = self.dropout10(x)
x = self.hidden2tag(x)
return x

def forward_test(self, x, data_length):
x = self.embedding(x)
x = tRnn.pack_padded_sequence(x, data_length, batch_first=True)
x, _ = self.lstm(x)
x, _ = tRnn.pad_packed_sequence(x, batch_first=True)
x = self.hidden2tag(x)
_, x = torch.max(x,2)
return x

测试

  由于 pytorch 比较难实现直接通过上一个词得出下一个词,故用以下方式实现:
  设当前句子为 $sentence$(是一个词序列),初始时 $sentence$ 为输入的初始词。每次将 $sentence$ 输入网络,得到相同长度的输出 $predict$,取 $predict$ 的最后一个词添加到 $sentence$ 的末尾,若该词不为 \则重复该过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def test(initWord, myNetwork) :
if not(initWord in wordVec) :
print('This word is not in training set!')
return
sentence = [wordVec[initWord]]
myNetwork.eval()
while True:
data = torch.tensor([sentence])
predict = myNetwork.forward_test(data,[len(sentence)])[0]
lastWord = predict[-1]
sentence.append(lastWord)
if (lastWord==0) :
break
for i in sentence :
print(vecWord[i],end='')
print()

结果

  迭代 30 次,取每次 epoch 的所有句子的平均交叉熵,对比如下:

  可以看到,使用训练集作为词表的效果会略微好些,差距大约在 15 次 epoch 时被拉开。

  生成句子的测试效果如下:




  4 个图分别称作图 1 ~ 图 4,分别为预训练词向量测试效果1、预训练词向量测试效果2、训练集embedding 测试效果1、训练集embedding 测试效果2。
  整体来看,二者都能正常执行生成句子的过程,生成的句子大致通顺,但存在大量语法问题,句子成分缺失明显,句意基本都不通顺。
  相比来看,在测试集 1 上训练集 embedding 的效果(图 3)要好于预训练词向量的效果(图 1),后者出现了大量的重复词语和句子,且句子通顺程度弱于前者;测试集 2 虽然整体来看是训练集 embedding 更通顺(图 4),但预训练词向量的结果(图 2)中有“给北京军区某给水工程团记一等功。”这样完全符合语法、句意完全通顺的句子。
  以图 3 为代表,可以发现生成的句子具有如下特点:

  1. 有关党和政治的语句特别多,与党和政治相关的开头(如图 3 的前三句)效果要好于其他句子,甚至用“诗”作开头都能扯到党的建设。这是明显的训练集特征,训练集大部分句子是与党和政治相关的。
  2. 部分句子会使用训练集原文。如图3 的“美国”开头的句子,这条新闻可以在百度搜到原文。
  3. 稍有典雅性。如图 3 的后三句——“墨色生几分侵蚀”“诗才敏捷,音韵铿锵”等,尽管这完全不足以进行文学分析。