PyTorch 笔记第二弹!继续整理之前的学习笔记。这篇笔记记录了如何用 PyTorch 进行 NLP 学习和研究~

1. 常用 NLP 应用

常用 NLP 应用:文本分类任务。包括:意图分类,情感分析(回归任务,给定文本的负面程度),命名实体识别,关键字提取(找到文本中最重要的单词作为标签),文本摘要(提取文本片段为用户提供压缩文本),问答。

2. 将文本表示为张量

文本表示为张量可以分为两种类型:字符级和单词级。由于英语的字符本身没有什么含义,所以单词级可能更好一些。但是单词级可能会造成向量稀疏,所以需要降维。

import torch
import torchtext
import os
import collections

os.makedirs("./data", exist_ok = True)
# 获取数据集。获取到的是迭代器,如果需要重复利用数据,就需要将其转换为列表
train_dataset, test_dataset = torchtext.datasets.AG_NEWS(root = "./data")
classes = ["World", "Sports", "Business", "Sci/Tech"]

train_dataset = list(train_dataset)
test_dataset = list(test_dataset)

2.1 分词

分词:如果想采用单词级的表示,就需要首先对文本进行分词,随后才能构建单词级表示。用 分词器 tokenizer 划分文本,划分后的内容叫 分词 token(Use tokenizer to split text into tokens)。

# 获取分词器 tokenizer
tokenizer = torchtext.data.utils.get_tokenizer("basic_english")

# 构建词典。对训练数据集的每行进行分词,并统计频次,最后交给 torchtext.vocab.Vocab 进行设置词典
counter = collections.Counter()
for (label, line) in train_dataset:
    counter.update(tokenizer(line))
vocab = torchtext.vocab.Vocab(counter, min_freq = 1)
print(len(vocab))

# 设置编码器函数,对输入到其中的字符串采用 tokenizer 进行分词,并通过 vocab.stoi 获取对应数值
def encode(x):
    return [vocab.stoi[s] for s in tokenizer(x)]
encode('I love to play with my words')

image-20211206174021467

2.2 词袋文本表示

词袋文本表示:由于单词具有含义,因此有时可以不考虑单词的顺序,只看单词含义实现目标。例如天气,财经的词对应的文本类别是不同的。

可以用 sklearn 生成词袋表示:

from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer()
corpus = [
    'I like hot dogs.',
    'The dog ran fast.',
    'Its hot outside.',
]
vectorizer.fit_transform(corpus)
vectorizer.transform(['My dog likes hot dogs on a hot day.']).toarray()

也可以自己构建词袋表示:

vocab_size = len(vocab)

def to_bow(text, bow_vocab_size = vocab_size):
    res = torch.zeros(bow_vocab_size, dtype = torch.float32)
    for i in encode(text):
        if i < bow_vocab_size:
            res[i] += 1
    return res

print(to_bow(train_dataset[0][1]))

这里设置的 vocab_size 是全局大小。但是也可以设置成常用词的词袋,使得效果不会下降太多的同时提高性能。

2.3 训练 BoW 分类器

训练 BoW 分类器:首先将数据转换为 DataLoader 对应的 collate_fn 的参数需要的形式,随后进行简单分类。

from torch.utils.data import DataLoader
import numpy as np

def bowify(b):
    return (
        torch.LongTensor([t[0] - 1 for t in b]),
        torch.stack([to_bow(t[1]) for t in b])
    )

train_loader = DataLoader(train_dataset, batch_size = 16, collate_fn = bowify, shuffle = True)
test_loader = DataLoader(test_dataset, batch_size = 16, collate_fn = bowify, shuffle = True)

net = torch.nn.Sequential(
    torch.nn.Linear(vocab, 4),
    torch.nn.LogSoftmax(dim = 1)
)

def train_epoch(net, dataloader, lr = 0.01, optimizer = None, loss_fn = torch.nn.NLLLoss(), epoch_size = None, report_freq = 200):
    net.train()
    total_loss, acc, count, i = 0, 0, 0, 0
    for labels, features in dataloader:
        optimizer.zero_grad()
        out = net(features)
        loss = loss_fn(out, labels)
        loss.backward()
        optimizer.step()
        total_loss += loss
        _, predicted = torch.max(out, 1)
        acc += (predicted == labels).sum()
        count += len(labels)
        i += 1
        if i % report_freq == 0:
            print(f"{count}: acc = {acc.item() / count}")
        if epoch_size and count > epoch_size:
            break
    return total_loss.item() / count, acc.item() / count

train_epoch(net, train_loader, epoch_size = 200)

image-20211206173941777

2.4 BiGrams, TriGrams 和 N-Grams

BiGrams, TriGrams 和 N-Grams:词袋方法有一个重要限制就是某些词的多词表达。例如 "hot dog" 是组合词,词袋模型不能表达。所以我们需要采用 N-Grams。例如,BiGrams 表示除了原始单词之外,还会将所有单词对加入词汇表中。

采用 Scikit Learn 生成词的二元词汇表:min_df 是词汇要出现的最少次数,可以调高以减少无意义词组。

from sklearn.feature_extraction.text import CountVectorizer
bigram_vectorizer = CountVectorizer(ngram_range = (1, 2), token_pattern = "\b\w+\b", min_df = 1)
corpus = [
        'I like hot dogs.',
        'The dog ran fast.',
        'Its hot outside.',
    ]
bigram_vectorizer.fit_transform(corpus)
print("Vocabulary:\n", bigram_vectorizer.vocabulary_)
bigram_vectorizer.transform(['My dog likes hot dogs on a hot day.']).toarray()

image-20211206173929626

采用普通方法构建词汇表的方法:

import torchtext
counter = collections.Counter()
for (label, line) in train_dataset:
    l = tokenizer(line)
    counter.update(torchtext.data.utils.ngrams_iterator(l, ngrams = 2))
bi_vocab = torchtext.vocab.Vocab(counter, min_freq = 1)
print(len(bi_vocab))

N-Grams 的一个缺点是词汇量增长特别快,因此在实践中需要将 N-Grams 与一些降维方法相结合,例如嵌入。另一个方法是调整 min_df 和 min_freq,让词汇表中的词汇必须达到一定频率才能被保存。

2.5 词频逆文档频率 TF-IDF

词频逆文档频率 TF-IDF:在 BoW 表示中,无论单词本身如何,单词出现的权重都是均匀的。但是明显的是,频繁出现的词如 a, is, the 对于分类任务的重要性比专业术语低得多。TF-IDF 是词频逆文档频率,是 BoW 的一种变体,不用二进制来表示单词在文档中的出现,而是用浮点数,数的大小与语料库中单词出现的频率相关。

image-20211206180949543

TF-IDF 和词汇出现次数成正比,同时会被包含该单词的文档数抵消。例如一个单词在语料库的所有文档中都出现,那么 df = N, w = 0,也就是这种单词的浮点数为 0,会被忽略掉。

通过 Scikit Learn 轻松创建文本的 TF-IDF 张量:

from sklearn.feature_extraction.text import TFidfVectorizer
vectorizer = TFidfVectorizer(ngram_range = (1, 2))
vectorizer.fit_transform(corpus)
vectorizer.transform(['My dog likes hot dogs on a hot day.']).toarray()

image-20211206181601035

TF-IDF 可以为不同单词提供频率权重,但是仍然无法表示含义和顺序。一个词的完整意义总是和语境相关的。所以后面需要研究嵌入方法。

3. 用嵌入表示单词

BoW 方法中,每个词都是独立处理的,编码的向量间不表达词之间的语义相似性。采用嵌入方法则不同,嵌入方法采用一个词作为输入,并输出 embedding_size 大小的输出向量。嵌入方法其实和线性层非常相似,但它不是采用 one-hot 编码向量作为输入,而是将一个词作为输入。为了实现文本分类,可以用嵌入层作为第一层,随后用一个聚合函数(sum, average 或 max),最后用一个线性映射层作为输出。

image-20211207101410996

class EmbedClassifier(torch.nn.Module):
    def __init__(self, vocab_size, embed_dim, num_class):
        super().__init__()
        self.embedding = torch.nn.Embedding(vocab_size, embed_dim)
        self.fc = torch.nn.Linear(embed_dim, num_class)

    def forward(self, x):
        x = self.embedding(x)
        x = torch.mean(x, dim = 1)
        return self.fc(x)

3.1 处理可变序列大小

处理可变序列大小:minibatch 的 BoW 向量都是有相同的大小。但是词向量则大小不同,因此用词向量组成 minibatch 时,就需要进行填充。可以用 collate_fn 进行填充。

def padify(b):
    v = [encode(x[1]) for x in b]
    l = max(map(len, v))
    return (
        torch.LongTensor([t[0] - 1 for t in b]),
        torch.stack([torch.nn.functional.pad(torch.tensor(t), (0, l - len(t)), mode = "constant", value = 0) for t in v])
    )

train_loader = torch.utils.data.DataLoader(tran_dataset, batch_size = 16, collate_fn = padify, shuffle = True)

随后,即可进行模型训练。

net = EmbedClassifier(vocab_size, 32, len(classes)).to(device)
train_epoch(net, train_loader, lr = 1, epoch_size = 25000)

3.2 EmbeddingBad 层和变长序列表示

EmbeddingBad 层和变长序列表示:在上一节的架构中,需要将所有序列填充到相同长度,从而将他们放到一个小批量中,这不是处理可变长度序列的好方法,相比之下,另一种方法是使用偏移向量,保存一个大向量中所有序列的偏移向量。

image-20211207103804291

为了使用偏移表示,可以使用 EmbeddingBag 层。它类似于 Embedding 层,但是以内容向量和偏移向量为输入,还提供了平均层,可以是 mean, sum 和 max。

class EmbedClassifier(torch.nn.Module):
    def __init__(self, vocab_size, embed_dim, num_class):
        super().__init__()
        self.embedding = torch.nn.EmbeddingBag(vocab_size, embed_dim)
        self.fc = torch.nn.Linear(embed_dim, num_class)

    def forward(self, text, off):
        x = self.embedding(text, off)
        return self.fc(x)

为了构建训练函数,需要准备偏移量。

def offsetify(b):
    x = [torch.tensor(encode(t[1])) for t in b]
    # 计算偏移量
    o = [0] + [len(t) for t in x]
    o = torch.tensor(o[:-1]).cumsum(dim = 0)
    return (
        torch.LongTensor([t[0] - 1 for t in b]), # Labels
        torch.cat(x), # text
        o # offset
    )

train_loader = torch.utils.data.DataLoader(train_dataset, batch_size = 16, collate_fn = offsetify, shuffle = True)

现在很有趣的是,网络接收两个参数:数据向量和偏移量,它们的大小不同。同时,数据加载器需要三个值,而不是以前的两个值,其中的文本和偏移向量都作为特征提供。因此,训练函数也需要稍微调整一下。

net = EmbedClassifier(vocab_size, 32, len(classes)).to(device)

def train_epoch_emb(net, dataloader, lr = 0.01, optimizer = None, loss_fn = torch.nn.CrossEntropyLoss(), epoch_size = None, report_freq = 2000):
    loss_fn = loss_fn.to(device)
    net.train()
    total_loss, acc, count, i = 0, 0, 0, 0
    for labels, text, off in dataloader:
        optimizer.zero_grad()
        labels, text, off = labels.to(device), text.to(device), off.to(device)
        out = net(text, off)
        loss = loss_fn(out, labels)
        loss.backward()
        optimizer.step()
        total_loss += loss
        _, predicted = torch.max(out, 1)
        acc += (predicted == labels).sum()
        count += len(labels)
        i += 1
        if i % report_freq == 0:
            print(f"{count}: acc={acc.item()/count}")
        if epoch_size and count > epoch_size:
            break
    return total_loss.item() / count, acc.item() / count

train_epoch_emb(net, train_loader, lr = 4, epoch_size = 25000)

3.3 语义嵌入 Word2Vec

语义嵌入 Word2Vec:前面的例子中模型学会了将单词映射到向量表示,但是这种表示没有太多的语义信息。学习带有语义向量的表示会让模型学会将相似的词或符号在某些向量距离(如欧几里得距离)方面更接近。

因此,需要以特定方式在大量文本上预训练嵌入模型。Word2Vec 是这样的训练方法,基于两种主要架构:

  • 连续词袋 CBoW:模型根据窗口大小的上下文预测一个词。
  • 连续 skip-gram:与 CBoW 相反,模型根据输入词预测窗口大小的上下文。

Word2Vec 模型中,CBoW 比较快,skip-gram 比较慢,但是 skip-gram 在表示不常用词(低频词)方面效果更好。所以具体选用哪种模型的预训练 Word2Vec 向量要看具体要求。

image-20211207112724891

可以用 gensim 库获取预训练的 Word2Vec 嵌入。可以用 most_similar() 方法获取相似的词。也可以用 word_vec 方法获取嵌入,用于下游任务(如分类)。还可以获取与 A 相近但与 B 相远离的词。

import gensim.downloader as api
w2v = api.load("word2vec-google-news-300")

# 获取与 neural 相似的词
for w, p in w2v.most_similar("neural"):
    print(f"{w} -> {p}")

# 获取向量嵌入,用于下游任务
w2v.word_vec("play")[:20]

# 获取与 king 和 woman 相近,但与 man 远离的词
w2v.most_similar(positive = ["king", "woman"], negative = ["man"])[0]

image-20211207113137347

image-20211207113129866

image-20211207113402530

3.4 其他语义嵌入

其他语义嵌入:CBoW 和 skip-gram 都属于”预测性“嵌入,因为它们只考虑本地上下文,而不考虑全文。因此不适合用 Word2Vec 处理全局上下文。

FastText 则是在 Word2Vec 的基础上,通过学习每个单词的向量表示和单词对应的 n-gram 词组,并在每次训练中将向量值进行平均,从而得到预训练向量。相当于一个 n-gram 版的 Word2Vec。

GloVe 则是利用同现矩阵的思想,用神经网络方法将共现矩阵分解为表现力更强的非线性词向量。

这些方法都可以用 gensim 进行实现。

3.5 在 PyTorch 中使用预训练嵌入

在 PyTorch 中使用预训练嵌入:可以用 Word2Vec 和 GloVe 模型,获取词汇表内单词的嵌入表示。由于有的单词在模型里面也没有,所以有的单词无法得到结果(如程序里面的 w2v.get_vector() 可能没有结果),这时可以返回一个随机值张量。

embed_size = len(w2v.get_vector("hello"))
net = EmbedClassifier(vocab_size, embed_size, len(classes))

found, notFound = 0, 0
for i, v in enumerate(vocab.itos):
    try:
        net.embedding.weight[i].data = torch.tensor(w2v.get_vector(w))
        found += 1
    except:
        net.embedding.weight[i].data = torch.normal(0.0, 1.0, (embed_size,))
        notFound += 1

print(f"found: {found}, not found: {notFound}")
net = net.to(device)
train_epoch_emb(net, train_loader, lr = 4, epoch_size = 25000)

image-20211207162049332

# vocab.itos 是序号转字符串,stoi 是字符串转序号,vocab.vectors 是获取序号对应的嵌入向量
vocab = torchtext.vocab.GloVe(name = "6B", dim = 50)
qvec = vocab.vectors[vocab.stoi["king"]] - vocab.vectors[vocab.stoi["man"]] + 1.3 * vocab.vectors[vocab.stoi["woman"]]
d = torch.sum((vocab.vectors - qvec) ** 2, dim = 1)
min_idx = torch.argmin(d)
vocab.itos[min_idx]
# 返回的自然就是 queen

net = EmbedClassifier(len(vocab), len(vocab.vectors[0]), len(classes))
net.embedding.weight.data = vocab.vectors
net = net.to(device)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size = 16, collate_fn = offsetify, shuffle = True)
train_epoch_emb(net, train_loader, lr = 4, epoch_size = 25000)

因为预训练模型可能缺少对应单词,所以可能有缺失词的情况。为了克服这个限制,可以采用基于语言模型的嵌入,例如 BERT。相比于 Word2Vec 和 GloVe,BERT 就在大量文本语料库中进行训练,并知道如何在不同上下文中将单词进行组合,是上下文嵌入。Word2Vec 和 GloVe 还只是 n-grams 嵌入类型,只能有限的上下文嵌入。

4. 循环神经网络

循环神经网络可以考虑单词的顺序问题,因此能够解决更复杂的语言问题,如文本生成或问答。

image-20211207164343466

4.1 简单的循环神经网络分类器

简单的循环神经网络分类器:这里用的是未经训练的嵌入层,但是可以替换为预训练嵌入层。这里采用的是填充数据的加载器,也就是用的是 Embedding 而不是 EmbeddingBag。RNN 训练比较困难,因为 RNN 结构较长,反向传播涉及的层数较多。因此学习率需要选择比较少的,并且在比较大的数据集上进行训练,效果才会比较好。建议使用 GPU 进行训练。

class RNNClassifier(torch.nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):
        super().__init__()
        self.hidden_dim = hidden_dim
        self.embedding = torch.nn.Embedding(vocab_size, embed_dim)
        self.rnn = torch.nn.RNN(embed_dim, hidden_dim)
        self.fc = torch.nn.Linear(hidden_dim, num_class)

    def forward(self, x):
        batch_size = x.size(0)
        x = self.embedding(x)
        x = self.rnn(x)
        return self.fc(x.mean(dim = 1))

4.2 长短期记忆神经网络 LSTM

长短期记忆神经网络 LSTM:简单循环神经网络的主要问题之一是梯度消失问题。由于 RNN 是在一次反向传播中端到端训练的,因此很难将错误传播到网络的第一层,导致网络无法学到远距离标记间的关系。解决问题的方法是引入门来进行显示状态管理。两种著名的此类架构:长短期记忆 LSTM 和门控中继单元 GRU。

image-20211207170020498

image-20211207170245061

LSTM 和 RNN 结构类似,但是有两种状态在层之间传递:实际状态 C 和隐藏状态 H。每个门都是 sigmoid 激活的神经网络,输出范围是 [0, 1],当乘以状态向量时,可以将其视为按位掩码。按照上面图从左到右的顺序,三个门分别是:

  • 遗忘门:确定隐藏状态向量的哪些分量需要忘记。
  • 输入门:从隐藏状态向量中获取一些信息,并加入到实际状态向量中。
  • 输出门:实际状态向量通过 tanh 激活函数,与隐藏状态向量中的分量进行组合,产生新的隐藏状态向量。
class LSTMClassifer(torch.nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):
        super().__init__()
        self.hidden_dim = hidden_dim
        self.embedding = torch.nn.Embedding(vocab_size, embed_dim)
        self.embedding.weight.data = torch.randn_like(self.embedding.weight.data) - 0.5
        self.rnn = torch.nn.LSMT(embed_dim, hidden_dim, batch_first = True)
        self.fc = torch.nn.Linear(hidden_dim, num_class)

    def forward(self, x):
        batch_size = x.size(0)
        x = self.embedding(x)
        x, (h, c) = self.rnn(x)
        return self.fc(h[-1])

4.3 打包序列

打包序列:上面的代码中都是用零向量填充 minibatch 中的序列,这会导致 RNN 训练数据中有大量无意义数据,影响效果。因此,应该用一种方法避免这种零向量填充。PyTorch 提供的方法是打包序列。例如,输入为 [[1,2,3,4,5], [6,7,8,0,0], [9,0,0,0,0]],则实际大小为 [5, 3, 1]。通过记录长度,可以实现打包序列。

产生打包序列可以用 torch.nn.utils.rnn.pack_padded_sequence 函数。所有循环层包括 RNN, LSTM 和 GRU 都支持打包序列。

为了生成打包序列,需要将长度向量传递给网络,因此需要新建一个函数来计算长度。

def pad_length(b):
    v = [encode(x[1]) for x in b]
    len_seq = list(map(len, v))
    l = max(len_seq)
    return (
        torch.LongTensor([t[0] - 1 for t in b]),
        torch.stack([torch.nn.functional.pad(torch.tensor(t),(0, l - len(t)),mode = 'constant',value = 0) for t in v]),
        torch.tensor(len_seq)
    )

train_loader_len = torch.utils.data.DataLoader(train_dataset, batch_size = 16, collate_fn = pad_length, shuffle = True)

class LSTMPackClassifier(torch.nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_class):
        super().__init__()
        self.hidden_dim = hidden_dim
        self.embedding = torch.nn.Embedding(vocab_size, embed_dim)
        self.embedding.weight.data = torch.randn_like(self.embedding.weight.data)-0.5
        self.rnn = torch.nn.LSTM(embed_dim,hidden_dim,batch_first=True)
        self.fc = torch.nn.Linear(hidden_dim, num_class)

    def forward(self, x, lengths):
        batch_size = x.size(0)
        x = self.embedding(x)
        pad_x = torch.nn.utils.rnn.pack_padded_sequence(x,lengths,batch_first=True,enforce_sorted=False)
        pad_x,(h,c) = self.rnn(pad_x)
        x, _ = torch.nn.utils.rnn.pad_packed_sequence(pad_x,batch_first=True)
        return self.fc(h[-1])

net = LSTMPackClassifier(vocab_size,64,32,len(classes)).to(device)
train_epoch_emb(net,train_loader_len, lr=0.001,use_pack_sequence=True)

4.4 双向和多层 LSTM

双向和多层 LSTM:双向 LSTM 可以捕获两个方向的信息。同时和卷积神经网络一样,可以在一个 RNN 上构建另一个 RNN 以捕获更高级别的模式。

image-20211207172413706

双向 LSTM 可以通过向 RNN/LSTM/GRU 传递 bidirectional = True 参数实现。

多层 LSTM 可以通过向 RNN/LSTM/GRU 传递 num_layers = n 参数实现。

5. 用循环网络生成文本

循环神经网络可以学习词序并预测下一个单词,从而可以进行文本生成任务。

image-20211208075737240

一对一是传统一对一神经网络。

一对多是生成架构,例如生成图片的描述文本。

多对一是分类架构。

多对多是序列对序列任务,例如机器翻译任务。

import torch
import torchtext
import numpy as np
from torchnlp import *
train_dataset, test_dataset, classes, vocab = load_dataset()

def char_tokenizer(words):
    return list(words)

counter = collections.Counter()
for (label, line) in train_dataset:
    counter.update(char_tokenizer(line))
vocab = torchtext.vocab.Vocab(counter)
vocab_size = len(vocab)
print(f"Vocabulary size = {vocab_size}")
print(f"Encoding of 'a' is {vocab.stoi['a']}")
print(f"Character with code 13 is {vocab.itos[13]}")

def enc(x):
    return torch.LongTensor(encode(x,voc=vocab,tokenizer=char_tokenizer))

enc(train_dataset[0][1])

5.1 训练生成式 RNN

训练生成式 RNN:设置输入为长度为 n 的字符序列,要求网络对每个字符输出下一个可能的字符,同时要包含诸如结束字符 <eos> 等字符。每个训练数据都包含 n 个输入和 n 个输出字符。由于输入是字符,所以不需要嵌入表示,只需要独热编码就可以,可以调用 one_hot 函数直接获取。

image-20211208080527375

n = 100

def get_batch(s, nchars = n):
    ins = torch.zeros(len(s) - nchars, nchars, dtype = torch.long, device = device)
    outs = torch.zeros(len(s) - nchars, nchars, dtype = torch.long, device = device)
    for i in range(len(s) - nchars):
        ins[i] = enc(s[i : i + nchars])
        outs[i] = enc(s[i + 1 : i + nchars + 1])
    return ins, outs

get_batch(train_dataset[0][1])

class LSTMGenerator(torch.nn.Module):
    def __init__(self, vocab_size, hidden_dim):
        super().__init__()
        self.rnn = torch.nn.LSTM(vocab_size, hidden_dim, batch_first = True)
        self.fc = torch.nn.Linear(hidden_dim, vocab_size)

    def forward(self, x, s = None):
        x = torch.nn.functional.one_hot(x, vocab_size).to(torch.float32)
        x, s = self.rnn(x, s)
        return self.fc(x), s

net = LSTMGenerator(vocab_size, 64).to(device)

def generate(net, size = 100, start = "today "):
    chars = list(start)
    out, s = net(enc(chars).view(1, -1).to(device))
    for i in range(size):
        nc = torch.argmax(out[0][-1])
        chars.append(vocab.itos[nc])
        out, s = net(nc.view(1, -1), s)
    return ''.join(chars)

samples_to_train = 10000
optimizer = torch.optim.Adam(net.parameters(), 0.01)
loss_fn = torch.nn.CrossEntropyLoss()
net.train()
for i, x in enumerate(train_dataset):
    if (len(x[1]) - nchars < 10):
        continue
    samples_to_train -= 1
    if not samples_to_train:
        continue
    text_in, text_out = get_batch(x[1])
    optimizer.zero_grad()
    out, s = net(text_in)
    loss = torch.nn.functional.cross_entropy(out.view(-1, vocab_size), text_out.flatten())
    loss.backward()
    optimizer.step()
    if i % 1000 == 0:
        print(f"Current loss = {loss.item()}")
        print(generate(net))

image-20211208085250104

可以对上面的方法进行进一步改进:

  • 调整 minibatch:目前是一个一个生成 minibatch。可以调整成一下读取所有样本,然后统一生成 minibatches.
  • 多层 LSTM:多层 LSTM 可以捕获更高级别特征。可以用一层 LSTM 捕获字符,多层 LSTM 捕获单词。
  • 调整隐藏层:太大的隐藏层会过拟合,太小的效果不是很好。

5.2 软文本生成和温度

软文本生成和温度:在之前定义的 generate 中总是将概率最高的字符定为生成文本的下一个字符,这导致文本经常在相同字符序列之间循环。但是如果查看下一个字符的概率分布,会发现前几个字符的概率都差不多高,这说明总选择概率最高的是不公平的。

从网络输出的概率中进行采样更加公平。这种采样可以通过 multinomial 实现软文本生成。其中的温度 temperature 参数,为 0 时容易选择概率最高的而进入循环,为 1 时进行公平的多项式采样,为无穷时随机选择下一个字符,文本变得毫无意义。

def soft_generate(net, size = 100, start = "today ", temperature = 1.0):
    chars = list(start)
    out, s = net(enc(chars).view(1, -1).to(device))
    for i in range(size):
        out_dist = out[0][-1].div(temperature).exp()
        #nc = torch.argmax(out[0][-1])
        nc = torch.multinomial(out_dist, 1)[0]
        chars.append(vocab.itos[nc])
        out, s = net(nc.view(1, -1), s)
    return ''.join(chars)

for i in [0.3, 0.8, 1.0, 1.3, 1.8]:
    print(f"--- Temperature = {i}\n{generate_soft(net,size=300,start='Today ',temperature=i)}\n")

image-20211208091825456

6. 注意力模型和转换器

循环神经网络的一个主要问题是,所有单词对结果都有相同影响,这导致标准 LSTM 编码器-解码器模型在序列到序列任务(如命名实体识别和机器翻译)中的性能欠佳。实际上,输入中的某些特定词通常比其他词对输出的影响更大。同时,序列对序列模型例如机器翻译,由两个 RNN 实现,一个编码器将序列变为隐藏状态,另一个解码器将隐藏状态展开为翻译结果,这样就导致网络很难记住句子开头,导致模型在长句子上效果很差。

注意力机制提供了一种 RNN 输入向量对每个输出预测的加权方法,实现方法是在输入 RNN 和输出 RNN 的中间状态之间构建一个上下文向量。这样,生成输出时,就会考虑所有的隐藏状态,各个隐藏状态有不同权重,集合到一个上下文向量中,从而提高模型整体的效果。

image-20211208100135558

image-20211208100508771

注意力机制极大地提高了模型的效果,使得大部分模型能够达到今天的效果。然而,注意力机制会大大增加模型参数数量,从而导致 RNN 的伸缩性问题。另一个 RNN 的伸缩性问题就是模型是序列化的,需要按顺序处理,就使得批处理和并行化训练具有难度。

6.1 Transformer 模型

Transformer 模型:与 RNN 不同,Transformer 模型不是序列输入,而是将位置进行编码,随后将文本嵌入和位置嵌入一起作为输入嵌入,输入到 Attention 层,随后进行输出。这样就能够比 RNN 更好地并行化,实现更大、表现力更强的语言模型。每个注意力头都能用于学习单词间的不同关系,从而改进下游任务效果。

image-20211208101738012

BERT(Bidirectional Encoder Representations from Transformers)也就是采用 Transformer 的双向编码器表示,是一个非常大的多层 Transformer 网络,BERT-base 用了12层 Transformer,BERT-large 用了24层 Transformer。

该模型首先用无监督训练,预测句子中的掩码词,在大量文本数据(维基百科+书籍)中进行预训练。在预训练期间,模型吸收了大量语言知识,然后通过微调就可以和其他数据集一起使用。这个过程称为迁移学习

image-20211208102412736

除了 BERT 之外,Transformer 架构还有许多变体,包括 DistilBERT, BigBird, OpenGPT3 等。HuggingFace 提供了许多基于 PyTorch 的预训练语言模型。

6.2 使用 BERT 进行文本分类

使用 BERT 进行文本分类:现在都推荐使用 HuggingFace 的 Transformers 获取预训练模型进行训练。模型需要使用 BERT 的 tokenizer,可以通过 transformers.BertTokenizer.from_pretrained() 获取。

import torch
import torchtext
from torchnlp import *
import transformers
train_dataset, test_dataset, classes, vocab = load_dataset()
vocab_len = len(vocab)

bert_model = "bert-base-uncased"
bert_model = "./bert"
tokenizer = transformers.BertTokenizer.from_pretrained(bert_model)
MAX_SEQ_LEN = 128
PAD_INDEX = tokenizer.convert_tokens_to_ids(tokenizer.pad_token)
UNK_INDEX = tokenizer.convert_tokens_to_ids(tokenizer.unk_token)

tokenizer.encode("PyTorch is a good framework.")

随后需要创建迭代器,训练期间用它来访问数据。BERT 采用自己的编码函数,所以需要定义一个填充函数。

def pad_bert(b):
    v = [tokenizer.encode(x[1]) for x in b]
    l = max(map(len, v))
    return (
        torch.LongTensor([t[0] for t in b]),
        torch.stack([torch.nn.functional.pad(torch.tensor(t), (0, l - len(t)), mode = "constant", value = 0) for t in v])
    )

train_loader = torch.utils.data.DataLoader(train_dataset, batch_size = 8, collate_fn = pad_bert, shuffle = True)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size = 8, collate_fn = pad_bert)

随后可以调用封装好的函数 transformers.BertForSequenceClassification.from_pretrained(),得到分类模型。

model = transformers.BertForSequenceClassification.from_pretrained(bert_model, num_labels = 4).to(device)

随后可以开始训练了。由于模型已经被预训练过,所以学习率要调整的小一些。复杂工作都交给 BertForSequenceClassification 完成。输入数据,输出 minibatch 的损失和网络输出。用损失函数进行参数优化 loss.backward(),并通过 argmax(out) 比较输出和标签,计算准确度。

optimizer = torch.nn.Adam(model.parameters(), lr = 2e-5)
report_freq = 50
iterations = 500

model.train()

i, c = 0, 0
acc_loss = 0
acc_acc = 0

for label, texts in train_loader:
    labels = labels.to(device) - 1 # 让 label 是 0123 而不是 1234
    texts = texts.to(device)
    # 只保留前两个
    loss, out = model(texts, labels = labels)[:2]
    labs = out.argmax(dim = 1) # out 是概率,取 out 最大时的坐标就是分类
    acc = torch.mean((labs == labels).type(torch.float32))
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    acc_loss += loss
    acc_acc += acc
    i += 1
    c += 1
    if i % report_freq == 0:
        print(f"Loss = {acc_loss.item() / c}, Accuracy = {acc_acc.item() / c}")
        c = 0
        acc_loss = 0
        acc_acc = 0
    iterations -= 1
    if not iterations:
        break

image-20211208105104932

模型效果非常好,因为模型很好地理解了语言结构,只需要微调最终分类器就行。但是,由于 BERT 是大模型,整体训练时间比较长,同时对计算能力要求比较高。

6.3 评估模型性能

评估模型性能:可以在测试数据集上评估模型性能。评估循环和训练循环类似,但是需要将模型调整到训练模式 model.eval()

model.eval()
iterations = 100
acc = 0
i = 0
for labels, texts in test_loader:
    labels = labels.to(device) - 1
    texts = texts.to(device)
    _, out = model(texts, labels = labels)[:2]
    labs = out.argmax(dim = 1)
    acc += torch.mean((labs == labels).type(torch.float32))
    i += 1
    if i > iterations:
        break
print(f"Final accuracy: {acc.item()/i}")

image-20211208105752794

Transformer 模型代表了当前 NLP 的最先进技术。大多数情况下应该是尝试 NLP 问题的第一个方案。但如果想构建高级神经网络模型,了解 RNN 十分重要。

7. 参考内容:

  1. Microsoft Learn PyTorch NLP