语言模型
- 语言模型是 NLP 中最经典的模型,假设给定长度为 T 的文本序列 x1,x2, ... ,xT(可能是词序列,也可能是字符序列),xt (1 <= t <= T)可以被认为是文本序列在时间步 t 处的观测或者标签。语言模型的目标是估计序列的联合概率 p(x1,x2, ... xT)
- 序列模型的核心就是整个序列文本所出现的概率
语言模型
- 目前所面对的问题是如何对一个文档,甚至是一个词元序列进行建模
- 为了训练语言模型,需要计算单词的概率以及给定前面几个单词后出现某个单词的条件概率(这些概率本质上就是语言模型的参数)
假设训练数据集是一个大型的文本语料库,训练集中词的概率可以根据给定词的相对词频来计算:一种稍微不太精确的方法是统计单词在数据集中的出现次数,然后将其除以整个语料库中的单词总数
- 由于连续单词对的出现频率出现频率低得多,特别是对于一些不常见的单词组合,要想找到足够的出现次数来获得准确的估计可能都不容易
- 对于三个或者更多的单词组合,情况可能会变得更糟,许多合理的三个单词所组成的单词组合可能是存在的,但是在数据集中可能找不到
- 对于上述情况,如果数据集很小,或者单词非常罕见,那么这类单词出现一次的机会可能都找不到,除非能够提供某种解决方案将这些单词组合指定为非零计数,否则将无法在语言模型中使用它们
- 一种常见的策略是执行某种形式的拉普拉斯平滑(Laplace smoothing),在所有的计数中添加一个小常量。但是这样的模型很容易变得无效,因为需要存储所有的计数,而且完全忽略了单词的意思,最后,长单词序列大部分是没出现过的,因此一个模型如果只是简单地统计先前看到的单词序列频率,那么模型面对这种问题肯定是表现不佳的
语言模型的应用
- 做预训练模型(如 BERT,GPT-3):给定大量的文本做预训练,然后训练模型预测整个文本出现的概率,因此能够得到比较多的训练数据(文本不需要进行标注,因此会比图像便宜,只需要拿出一定量的文本即可)来做比较大的模型
- 生成文本,给定前面几个词,不断使用 xt ~ p( xt | x1,x2, ... x(t-1) ) 来生成后续文本(给定前面几个词,不断地采样下一个词,然后一直预测下去),对模型的要求比较高,否则误差会不断累积
- 判断多个序列中哪个更常见:使用语言模型判断哪一个序列出现的概率更高(在打字的时候,输入法自动补全也是根据语言模型来判断句子出现的概率,还可以针对特定用户打字的习惯来进行个性化定制,根据用户之前的使用习惯来进行补全和纠错)
使用计数来建模
- 语言模型可以使用计数来进行建模
假设序列长度为 2 ,可以预测
- n:总词数(或者 token 的个数),也就是采集到的所有样本
- n(x):x 在整个词中出现的个数
- n(x,x'):连续单词对的出现次数
- P(x):统计 x 在数据集中的出现次数,然后将其除以整个语料库中的总词数(这种方法对于频繁出现的单词效果还是不错的)
拓展到序列长度为 3 的情况
N 元语法
- 当序列很长时,因为文本量不够大,很可能 n( x1, ... ,xT) <= 1
- 在序列长度比较长的情况下,可以使用马尔科夫假设
- 对于 N 元语法来说,所要看的子序列的长度是固定的,N 越大,对应的依赖关系越长,精度越高,但是空间复杂度比较大
- 二元语法、三元语法比较常见
如果
成立,则序列上的分布满足一阶马尔科夫性质,且阶数越高,对应的依赖关系就越长
一元语法(unigram)
- 马尔科夫假设中的 τ 为 0 ,也就是说每次计算 xt 的概率时,不用考虑 xt 之前的数据
- 使用一元语法计算 p( x1,x2,x3,x4):可以认为这个序列中每个词是独立的
二元语法(bigram)
- 马尔科夫假设中的 τ 为 1 ,也就是说每次计算 xt 的概率时,只依赖于 x( t-1 ),也就是说每一个词和前面一个词是相关的
三元语法(trigram)
- 马尔科夫假设中的 τ 为 2 ,也就是说每次计算 xt 的概率时,只依赖于 x( t - 1 ) 和 x( t - 2 ),也就是说每一个词和前面两个词是相关的
N 元语法的好处
- 最大的好处是可以处理比较长的序列。如果序列很长的话,很难把它存下来,不可能将序列中任何长度的序列都存下来,这是一个指数级的复杂度
- 所以对于任意长度的序列,N 元语法所扫描的子序列长度是固定的:比如说对于二元语法来说,每次只看长为 2 的子序列,首先将长度为 2 的任何一个词 n(x1,x2)(都来自序列中,假设整个字典中有 1000 个词,则长为 2 的词有 1000*1000=1000,000 种可能性)存下来,然后将每一个词和另外一个词组成的词在文本中出现的概率 n(x1,x2) 全部存起来,再把一个词 n(x1) 出现的概率存起来,最后把 n (也就是1000)存起来
- 查询一个任意长度的序列的时间复杂度为 o(T),T 是序列长度
- N 元语法和 N 是一个指数关系,随着 N 的增大,需要存的东西就会变得很大,所以一元语法使用的不多(一元语法完全忽略掉了时序信息),二元语法、三元语法使用的比较多
- 使用马尔科夫假设的 N 元语法的好处:如果将词存起来,就可以使得计算复杂度变成 o(T)而不是 o(N) ,o(T) 很重要,因为语料库通常会很大,判断一个句子的概率的情况下,每秒钟可能需要做一百万次左右,在语音识别或者是输入法补全的时候需要进行实时的计算,因此计算复杂度非常关键,对于 N 元语法来讲,N 越大,精度越高,但是随着 N 的增大,空间复杂度也会增大,即使是这样,二元语法、三元语法也是非常常见的模型
自然语言统计
停用词(stop words)
- 停用词是指在信息检索中,为节省存储空间和提高搜索效率,在处理自然语言数据(或文本)之前或之后会自动过滤掉某些字或词
读取长序列数据
- 序列数据本质上是连续的,因此当序列变得太长而不能被模型一次性全部处理时,就希望对序列进行拆分以方便模型的读取
总体策略
假设使用神经网络来训练语言模型,模型中的网络一次处理具有预定义长度(例如 n 个时间步)的一个小批量序列
如何随机生成一个小批量数据的特征和标签以供读取?
1、文本序列可以是任意长的,因此任意长的序列可以被划分为具有相同时间步数的子序列
2、训练神经网络时,将这些具有相同步数的小批量子序列输入到模型中
- 假设神经网络一次处理具有 n 个时间步的子序列
- 上图展示了从原始文本序列中获得子序列的所有不同的方式,其中 n 等于 5 ,并且每个时间步的词元对应于一个字符
3、由于可以选择任意偏移量来指示初始位置,因此具有相当大的自由度
- 如果只选择一个偏移量,那么用于训练网络的、所有可能的子序列的覆盖范围将是有限的。因此可以从随机偏移量开始划分序列,来同时获得覆盖性(coverage)和随机性(randomness)
随机采样(random sampling)
- 随机采样中,每个样本都是在原始的长序列上任意捕获的子序列
- 在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻
- 语言建模的目标是基于当前所看到的词元预测下一个词元,因此标签是移位了一个词元的原始序列
顺序分区(sequential partitioning)
- 顺序分区:迭代过程中,除了对原始序列可以随机抽样外,还可以保证两个相邻的小批量中的子序列在原始序列上也是相邻的,这种策略在基于小批量的迭代过程中保留了拆分的子序列的顺序
总结
1、语言模型是自然语言处理的关键,语言模型其实就是估计文本序列的联合概率,也是 NLP 领域最常见的应用
2、使用统计方法时通常采用 n元语法,每次看一个长为 n 的子序列来进行计数,对于给定的长序列拆分成很多个连续的长度为 N 的子序列,就能够计算文本序列的联合概率了。n元语法通过截断相关性,为处理长序列提供了一种实用的模型(长序列的问题在于它们很少出现或者从不出现)
3、齐普夫定律(Zipf's law)支配着单词的分布,这个分布不仅适用于一元语法,还适用于其他 n 元语法
- 想要通过计数统计和平滑来建模单词是不可行的,因为这样建模会大大高估尾部单词(即不常用的单词)的频率
4、通过拉普拉斯平滑法可以有效地处理结构丰富而频率不足的低频词词组
5、读取长序列的主要方式是随机采样和顺序分区。在迭代过程中,后者可以保证来自两个相邻的小批量中的子序列在原始序列上也是相邻的
代码:
import random
import torch
from d2l import torch as d2l
tokens = d2l.tokenize(d2l.read_time_machine())
corpus = [token for line in tokens for token in line]
vocab = d2l.Vocab(corpus)
vocab.token_freqs[:10]
# 最流行的词被称为停用词画出的词频图
freqs = [freq for token, freq in vocab.token_freqs]
d2l.plot(freqs, xlabel='token: x', ylabel='frequency: n(x)', xscale='log', yscale='log')
# 其他的词元组合,比如二元语法、三元语法等等,又会如何呢?
bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])] # 二元语法
bigram_vocab = d2l.Vocab(bigram_tokens)
bigram_vocab.token_freqs[:10]
# 三元语法
trigram_tokens = [triple for triple in zip(corpus[:-2],corpus[1:-1],corpus[2:])]
trigram_vocab = d2l.Vocab(trigram_tokens)
trigram_vocab.token_freqs[:10]
# 直观地对比三种模型中的标记频率
bigram_freqs = [freq for token, freq in bigram_vocab.token_freqs]
trigram_freqs = [freq for token, freq in trigram_vocab.token_freqs]
d2l.plot([freqs, bigram_freqs, trigram_freqs], xlabel='token: x',
ylabel='frequency: n(x)', xscale='log', yscale='log',
legend=['unigram','bigram','trigram'])
# 随即生成一个小批量数据的特征和标签以供读取
# 在随即采样中,每个样本都是在原始的长序列上任意捕获的子序列
# 给一段很长的序列,连续切成很多段长为T的子序列
# 一开始加了一点随即,使得每次切的都不一样
# 取随即批量的时候,再随即把它们取出来
def seq_data_iter_random(corpus, batch_size, num_steps):
"""使用随即抽样生成一个小批量子序列"""
corpus = corpus[random.randint(0, num_steps - 1):]
num_subseqs = (len(corpus) - 1) // num_steps
initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
random.shuffle(initial_indices)
def data(pos):
return corpus[pos:pos + num_steps]
num_batches = num_subseqs // batch_size
for i in range(0, batch_size * num_batches, batch_size):
initial_indices_per_batch = initial_indices[i:i + batch_size]
X = [data(j) for j in initial_indices_per_batch]
Y = [data(j+1) for j in initial_indices_per_batch]
yield torch.tensor(X), torch.tensor(Y)
# 生成一个从0到34的序列
my_seq = list(range(35))
for X, Y in seq_data_iter_random(my_seq, batch_size=2, num_steps=5):
print('X: ', X, '\nY: ', Y) # Y是X长度的后一个,X里面的两个不一定是连续的
# 保证两个相邻的小批量中的子序列在原始序列上也是相邻的
def seq_data_iter_sequential(corpus, batch_size, num_steps):
"""使用顺序分区生成一个小批量子序列"""
offset = random.randint(0, num_steps)
num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
Xs = torch.tensor(corpus[offset:offset + num_tokens])
Ys = torch.tensor(corpus[offset + 1:offset + 1 + num_tokens])
Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
num_batches = Xs.shape[1] // num_steps
for i in range(0, num_steps * num_batches, num_steps):
X = Xs[:, i:i + num_steps]
Y = Ys[:, i:i + num_steps]
yield X, Y
# 读取每个小批量的子序列的特征X和标签Y
for X, Y in seq_data_iter_sequential(my_seq, batch_size=2, num_steps=5):
print('X: ', X, '\nY: ', Y) # 第二个mini-batch[9-13]是接着第一个mini-batch[3-7]后面