记载学习《手动学习深度学习》这本书的笔记(十一)

[复制链接]
发表于 2025-9-8 15:54:04 | 显示全部楼层 |阅读模式
接下来学习第15章:自然语言处理惩罚:应用.
近来正好也在做NLP相干的实验,正勤学习一下。趁便一提我们做的实验是呆板人对话方面的,需要用到文本对。
第十五章:自然语言处理惩罚:应用

NLP的下游任务侧重于如何用深度语言表征学习来办理自然语言处理惩罚题目,上游预训练已经在上一章给出,本章探究的是两种盛行且具有代表性的下游自然语言处理惩罚任务:感情分析自然语言推断,分别分析单个文本文本对直接的关系

具体来说如图所示,我们将在这一章设计差别的深度学习架构实现两种应用,比如对于感情分类利用卷积神经网络和循环神经网络架构,对于自然语言推断利用多层感知机和留意力机制架构。
最后,本章还会介绍如何为更多更广泛的NLP任务对预训练BERT进行微调。(上一章说过对差别任务预训练BERT模子只需要最小的调整,但其实还要对许多下游参数微调)
15.1 感情分析及数据集

感情分析任务我们选取斯坦福大学大型影戏评论数据集,它由训练集和测试集组成,此中“悲观”和“积极”标签数量相同。
  1. #@save
  2. d2l.DATA_HUB['aclImdb'] = (
  3.     'http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz',
  4.     '01ada507287d82875905620988597833ad4e0903')
  5. data_dir = d2l.download_extract('aclImdb', 'aclImdb')
复制代码
读取训练集和测试集,每一条数据都由内容和标签组成,标签1表现积极,0表现悲观。
  1. #@save
  2. def read_imdb(data_dir, is_train):
  3.     """读取IMDb评论数据集文本序列和标签"""
  4.     data, labels = [], []
  5.     for label in ('pos', 'neg'):
  6.         folder_name = os.path.join(data_dir, 'train' if is_train else 'test',
  7.                                    label)
  8.         for file in os.listdir(folder_name):
  9.             with open(os.path.join(folder_name, file), 'rb') as f:
  10.                 review = f.read().decode('utf-8').replace('\n', '')
  11.                 data.append(review)
  12.                 labels.append(1 if label == 'pos' else 0)
  13.     return data, labels
  14. train_data = read_imdb(data_dir, is_train=True)
  15. print('训练集数目:', len(train_data[0]))
  16. for x, y in zip(train_data[0][:3], train_data[1][:3]):
  17.     print('标签:', y, 'review:', x[0:60])
复制代码
(这里只读取了训练集,之后再读取测试集。)
接着将每个词作为一个词元并过滤掉出现次数小于5的词元,构建词典:
  1. train_tokens = d2l.tokenize(train_data[0], token='word')
  2. vocab = d2l.Vocab(train_tokens, min_freq=5, reserved_tokens=['<pad>'])
复制代码
为了确定最大长度,我们查看评论长度直方图:
  1. d2l.set_figsize()
  2. d2l.plt.xlabel('# tokens per review')
  3. d2l.plt.ylabel('count')
  4. d2l.plt.hist([len(line) for line in train_tokens], bins=range(0, 1000, 50));
复制代码

于是设置从500截断。将最大长度和填充长度设为500,并将单词转化为词典中的索引,将句子列表转化为张量:
  1. num_steps = 500  # 序列长度
  2. train_features = torch.tensor([d2l.truncate_pad(
  3.     vocab[line], num_steps, vocab['<pad>']) for line in train_tokens])
  4. print(train_features.shape)
复制代码
如许就得到了所有评论的张量。
接着就可以创建迭代器了,每次迭代中有一小批量样本:
  1. train_iter = d2l.load_array((train_features,
  2.     torch.tensor(train_data[1])), 64)
  3. for X, y in train_iter:
  4.     print('X:', X.shape, ', y:', y.shape)
  5.     break
  6. print('小批量数目:', len(train_iter))
复制代码
最后整合代码,将所有代码封装起来,代表着从下载数据集到将其变成可以用于训练的小批量的全过程:
  1. #@save
  2. def load_data_imdb(batch_size, num_steps=500):
  3.     """返回数据迭代器和IMDb评论数据集的词表"""
  4.     data_dir = d2l.download_extract('aclImdb', 'aclImdb')
  5.     train_data = read_imdb(data_dir, True)
  6.     test_data = read_imdb(data_dir, False)
  7.     train_tokens = d2l.tokenize(train_data[0], token='word')
  8.     test_tokens = d2l.tokenize(test_data[0], token='word')
  9.     vocab = d2l.Vocab(train_tokens, min_freq=5)
  10.     train_features = torch.tensor([d2l.truncate_pad(
  11.         vocab[line], num_steps, vocab['<pad>']) for line in train_tokens])
  12.     test_features = torch.tensor([d2l.truncate_pad(
  13.         vocab[line], num_steps, vocab['<pad>']) for line in test_tokens])
  14.     train_iter = d2l.load_array((train_features, torch.tensor(train_data[1])),
  15.                                 batch_size)
  16.     test_iter = d2l.load_array((test_features, torch.tensor(test_data[1])),
  17.                                batch_size,
  18.                                is_train=False)
  19.     return train_iter, test_iter, vocab
复制代码
只需要输入批量大小,就可以返回大型影戏评论数据集可用于训练的训练迭代器、测试迭迭代器和词表。
15.2 感情分析:利用循环神经网络

本节构建一个基于循环神经网络的感情分类模子。

想法是构建一个双向循环神经网络用于得到文本序列表现,接着通过线性层转化为感情分析输出。
模子代码并不难,利用编码器-解码器架构,编码器通过双向LSTM实现,解码器通过简单的线性层实现,输入双向LSTM的第一个和最后一个隐状态,输出两个类别。
  1. class BiRNN(nn.Module):
  2.     def __init__(self, vocab_size, embed_size, num_hiddens,
  3.                  num_layers, **kwargs):
  4.         super(BiRNN, self).__init__(**kwargs)
  5.         self.embedding = nn.Embedding(vocab_size, embed_size)
  6.         # 将bidirectional设置为True以获取双向循环神经网络
  7.         self.encoder = nn.LSTM(embed_size, num_hiddens, num_layers=num_layers,
  8.                                 bidirectional=True)
  9.         self.decoder = nn.Linear(4 * num_hiddens, 2)
  10.     def forward(self, inputs):
  11.         # inputs的形状是(批量大小,时间步数)
  12.         # 因为长短期记忆网络要求其输入的第一个维度是时间维,
  13.         # 所以在获得词元表示之前,输入会被转置。
  14.         # 输出形状为(时间步数,批量大小,词向量维度)
  15.         embeddings = self.embedding(inputs.T)
  16.         self.encoder.flatten_parameters()
  17.         # 返回上一个隐藏层在不同时间步的隐状态,
  18.         # outputs的形状是(时间步数,批量大小,2*隐藏单元数)
  19.         outputs, _ = self.encoder(embeddings)
  20.         # 连结初始和最终时间步的隐状态,作为全连接层的输入,
  21.         # 其形状为(批量大小,4*隐藏单元数)
  22.         encoding = torch.cat((outputs[0], outputs[-1]), dim=1)
  23.         outs = self.decoder(encoding)
  24.         return outs
复制代码
需要留意,双向LSTM的输出output是将双向隐状态拼接在一起,前num_hiddens是前向,后num_hiddens是反向。
本来每个时间步都会输出双向隐状态,这里只需要取第一个时间步和最后一个时间步。
  1. embed_size, num_hiddens, num_layers = 100, 100, 2
  2. devices = d2l.try_all_gpus()
  3. net = BiRNN(len(vocab), embed_size, num_hiddens, num_layers)
  4. def init_weights(m):
  5.     if type(m) == nn.Linear:
  6.         nn.init.xavier_uniform_(m.weight)
  7.     if type(m) == nn.LSTM:
  8.         for param in m._flat_weights_names:
  9.             if "weight" in param:
  10.                 nn.init.xavier_uniform_(m._parameters[param])
  11. net.apply(init_weights);
复制代码
初始化权重,偏置一样平常不需要初始化,默认全为0。
加载预训练(GloVe嵌入),设置为嵌入层的权重,并且训练时不需要计算梯度:
  1. glove_embedding = d2l.TokenEmbedding('glove.6b.100d')
  2. embeds = glove_embedding[vocab.idx_to_token]
  3. net.embedding.weight.data.copy_(embeds)
  4. net.embedding.weight.requires_grad = False
复制代码
训练模子:
  1. lr, num_epochs = 0.01, 5
  2. trainer = torch.optim.Adam(net.parameters(), lr=lr)
  3. loss = nn.CrossEntropyLoss(reduction="none")
  4. d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs,
  5.     devices)
复制代码
定义猜测函数,将输入句子切分为词元,转换为id张量输入到模子中,选取输出中两个数最大的那个作为猜测标签:
  1. #@save
  2. def predict_sentiment(net, vocab, sequence):
  3.     """预测文本序列的情感"""
  4.     sequence = torch.tensor(vocab[sequence.split()], device=d2l.try_gpu())
  5.     label = torch.argmax(net(sequence.reshape(1, -1)), dim=1)
  6.     return 'positive' if label == 1 else 'negative'
复制代码
一个简单的示例:
  1. predict_sentiment(net, vocab, 'this movie is so great')
  2. predict_sentiment(net, vocab, 'this movie is so bad')
复制代码

15.3 感情分析:利用卷积神经网络

本节构建一个基于卷积神经网络的感情分类模子。

由于语言是一维的,所以这里利用的一维卷积和之前介绍的二维卷积有些区别,不外现实上也只是二维卷积的特例。
卷积窗口只在左右滑动,和二维卷积一样,卷积窗口每个位置和卷积查对应元素相乘,最后相加,每次将卷积窗口右移一位,得到的结果排成一行。

写一个一维卷积:
  1. def corr1d(X, K):
  2.     w = K.shape[0]
  3.     Y = np.zeros((X.shape[0] - w + 1))
  4.     for i in range(Y.shape[0]):
  5.         Y[i] = (X[i: i + w] * K).sum()
  6.     return Y
复制代码
再写一个多通道一维卷积:
  1. def corr1d_multi_in(X, K):
  2.     # 首先,遍历'X'和'K'的第0维(通道维)。然后,把它们加在一起
  3.     return sum(corr1d(x, k) for x, k in zip(X, K))
复制代码
多通道一维卷积有多个X和K,对于每个通道,计算它X和K的卷积,最后再将每个通道计算的卷积结果相加。

这看起来很熟悉,其实就相称于卷积核高度等于输入张量高度的二维卷积。

至于汇聚层,在textCNN中,一样平常利用最大时间汇聚层(类似一维全局汇聚层),也就是每个通道取这个通道最大的值。留意,最大时间汇聚层答应差别通道上利用差别数量的时间步。
还要思考输入的用d维向量表现的 n 个词元,此中 d 和 n 分别作为什么,通常向量维度 d 视作通道数,词元数目 n 作为输入张量的宽度。
实现步调:

  • 定义多个卷积核,分别输入输入张量,用于提取差别特性,卷积核长度可以差别,捕获差别数目的相邻词元的局部特性。
  • 所有输出通道毗连最大时间汇聚层,然后将所有通道的标量结果毗连成向量。
  • 利用全毗连层将毗连后的向量转化为输出类别,可以利用暂退法淘汰过拟合。
利用图和例子直观描述:

接下来就可以定义textCNN模子,这里留意和前面利用循环神经网络进行感情分析差别,这里嵌入层定义了两个,一个是本来的GloVe预训练(这里无需训练),一个是可训练嵌入层。
  1. class TextCNN(nn.Module):
  2.     def __init__(self, vocab_size, embed_size, kernel_sizes, num_channels,
  3.                  **kwargs):
  4.         super(TextCNN, self).__init__(**kwargs)
  5.         self.embedding = nn.Embedding(vocab_size, embed_size)
  6.         # 这个嵌入层不需要训练
  7.         self.constant_embedding = nn.Embedding(vocab_size, embed_size)
  8.         self.dropout = nn.Dropout(0.5)
  9.         self.decoder = nn.Linear(sum(num_channels), 2)
  10.         # 最大时间汇聚层没有参数,因此可以共享此实例
  11.         self.pool = nn.AdaptiveAvgPool1d(1)
  12.         self.relu = nn.ReLU()
  13.         # 创建多个一维卷积层
  14.         self.convs = nn.ModuleList()
  15.         for c, k in zip(num_channels, kernel_sizes):
  16.             self.convs.append(nn.Conv1d(2 * embed_size, c, k))
  17.     def forward(self, inputs):
  18.         # 沿着向量维度将两个嵌入层连结起来,
  19.         # 每个嵌入层的输出形状都是(批量大小,词元数量,词元向量维度)连结起来
  20.         embeddings = torch.cat((
  21.             self.embedding(inputs), self.constant_embedding(inputs)), dim=2)
  22.         # 根据一维卷积层的输入格式,重新排列张量,以便通道作为第2维
  23.         embeddings = embeddings.permute(0, 2, 1)
  24.         # 每个一维卷积层在最大时间汇聚层合并后,获得的张量形状是(批量大小,通道数,1)
  25.         # 删除最后一个维度并沿通道维度连结
  26.         encoding = torch.cat([
  27.             torch.squeeze(self.relu(self.pool(conv(embeddings))), dim=-1)
  28.             for conv in self.convs], dim=1)
  29.         outputs = self.decoder(self.dropout(encoding))
  30.         return outputs
复制代码
两个嵌入层的维度归并,之后将归并后的维度作为通道数,对其进行差别的卷积操作、池化操作、激活函数操作,最后将所有卷积块的结果归并,最后用全毗连层输出结果。
仍旧是创建实例、初始化参数:
  1. embed_size, kernel_sizes, nums_channels = 100, [3, 4, 5], [100, 100, 100]
  2. devices = d2l.try_all_gpus()
  3. net = TextCNN(len(vocab), embed_size, kernel_sizes, nums_channels)
  4. def init_weights(m):
  5.     if type(m) in (nn.Linear, nn.Conv1d):
  6.         nn.init.xavier_uniform_(m.weight)
  7. net.apply(init_weights);
复制代码
加载GloVe嵌入层、训练:
  1. glove_embedding = d2l.TokenEmbedding('glove.6b.100d')
  2. embeds = glove_embedding[vocab.idx_to_token]
  3. net.embedding.weight.data.copy_(embeds)
  4. net.constant_embedding.weight.data.copy_(embeds)
  5. net.constant_embedding.weight.requires_grad = False
复制代码
然后就是训练,和前面差不多:
  1. lr, num_epochs = 0.001, 5
  2. trainer = torch.optim.Adam(net.parameters(), lr=lr)
  3. loss = nn.CrossEntropyLoss(reduction="none")
  4. d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs, devices)
复制代码
15.4 自然语言推断与数据集

先介绍一下自然语言推断任务。
它又被称为辨认文本蕴含任务,一对文本(假设和条件)间大概有如下关系:

  • 蕴含:假设可以从条件中推断出来。
  • 抵牾:假设的否定可以从条件中推断出来。
  • 中性:其他所有情况。
自然语言推断就是判断文本对属于哪种情况。
自然语言推断一直是理解自然语言的中央话题,为了研究这个题目,首先肯定是要一个合适的数据集。
这里选用斯坦福自然语言推断数据集,它由许多带标签的英文句子对组成。
  1. #@save
  2. d2l.DATA_HUB['SNLI'] = (
  3.     'https://nlp.stanford.edu/projects/snli/snli_1.0.zip',
  4.     '9fcde07509c7e87ec61c640c1b2753d9041758e4')
  5. data_dir = d2l.download_extract('SNLI')
复制代码
和之前一样,读取数据集(数据集本来的标签非常多,这里只需要之条件到的三种):
  1. #@save
  2. def read_snli(data_dir, is_train):
  3.     """将SNLI数据集解析为前提、假设和标签"""
  4.     def extract_text(s):
  5.         # 删除我们不会使用的信息
  6.         s = re.sub('\\(', '', s)
  7.         s = re.sub('\\)', '', s)
  8.         # 用一个空格替换两个或多个连续的空格
  9.         s = re.sub('\\s{2,}', ' ', s)
  10.         return s.strip()
  11.     label_set = {'entailment': 0, 'contradiction': 1, 'neutral': 2}
  12.     file_name = os.path.join(data_dir, 'snli_1.0_train.txt'
  13.                              if is_train else 'snli_1.0_test.txt')
  14.     with open(file_name, 'r') as f:
  15.         rows = [row.split('\t') for row in f.readlines()[1:]]
  16.     premises = [extract_text(row[1]) for row in rows if row[0] in label_set]
  17.     hypotheses = [extract_text(row[2]) for row in rows if row[0] \
  18.                 in label_set]
  19.     labels = [label_set[row[0]] for row in rows if row[0] in label_set]
  20.     return premises, hypotheses, labels
复制代码
终极返回的列表分别是条件、假设、标签。
调用这个函数分别导入训练集和测试集:
  1. train_data = read_snli(data_dir, is_train=True)
  2. test_data = read_snli(data_dir, is_train=False)
复制代码
接着定义加载数据集的类,包括构建词表(假如没有的话)、将词元转化为索引、填充或切断文本使其都为指定长度:
  1. #@save
  2. class SNLIDataset(torch.utils.data.Dataset):
  3.     """用于加载SNLI数据集的自定义数据集"""
  4.     def __init__(self, dataset, num_steps, vocab=None):
  5.         self.num_steps = num_steps
  6.         all_premise_tokens = d2l.tokenize(dataset[0])
  7.         all_hypothesis_tokens = d2l.tokenize(dataset[1])
  8.         if vocab is None:
  9.             self.vocab = d2l.Vocab(all_premise_tokens + \
  10.                 all_hypothesis_tokens, min_freq=5, reserved_tokens=['<pad>'])
  11.         else:
  12.             self.vocab = vocab
  13.         self.premises = self._pad(all_premise_tokens)
  14.         self.hypotheses = self._pad(all_hypothesis_tokens)
  15.         self.labels = torch.tensor(dataset[2])
  16.         print('read ' + str(len(self.premises)) + ' examples')
  17.     def _pad(self, lines):
  18.         return torch.tensor([d2l.truncate_pad(
  19.             self.vocab[line], self.num_steps, self.vocab['<pad>'])
  20.                          for line in lines])
  21.     def __getitem__(self, idx):
  22.         return (self.premises[idx], self.hypotheses[idx]), self.labels[idx]
  23.     def __len__(self):
  24.         return len(self.premises)
复制代码
最后整合所有代码,构建一个从下载数据集到最后返回数据迭代器和词典的函数:
  1. #@save
  2. def load_data_snli(batch_size, num_steps=50):
  3.     """下载SNLI数据集并返回数据迭代器和词表"""
  4.     num_workers = d2l.get_dataloader_workers()
  5.     data_dir = d2l.download_extract('SNLI')
  6.     train_data = read_snli(data_dir, True)
  7.     test_data = read_snli(data_dir, False)
  8.     train_set = SNLIDataset(train_data, num_steps)
  9.     test_set = SNLIDataset(test_data, num_steps, train_set.vocab)
  10.     train_iter = torch.utils.data.DataLoader(train_set, batch_size,
  11.                                              shuffle=True,
  12.                                              num_workers=num_workers)
  13.     test_iter = torch.utils.data.DataLoader(test_set, batch_size,
  14.                                             shuffle=False,
  15.                                             num_workers=num_workers)
  16.     return train_iter, test_iter, train_set.vocab
复制代码
这里与前面感情分析的数据迭代器差别,利用了pytorch自带的迭代器函数。
并且iter内部有两个维度,多了一个代表假设和条件的维度。
  1. train_iter, test_iter, vocab = load_data_snli(128, 50)
  2. for X, Y in train_iter:
  3.     print(X[0].shape)
  4.     print(X[1].shape)
  5.     print(Y.shape)
  6.     break
复制代码

15.5 自然语言推断:利用留意力

有人提出用留意力机制办理自然语言推断题目,称为“可分解留意力模子”。这种模子没有循环层或卷积层,只利用留意力机制和多层感知机,以较少的参数实现了很好的效果。

由于要查询两个文本序列之间的对应关系,所以这里实现的留意力机制和之前的留意力机制有些区别,在之前的章节,留意力的 qkv 都是从同一个序列中提取,这里为了捕获文本对之间的关系,可以从两个序列中分别提取 qkv ,用一个序列的 q 查询另一个序列的 kv 。

关键步调:留意比较聚合
1. 留意

第一步是将两个文本对齐,这种对齐是利用加权平均的“软”对齐,将关联较大的词给予较大的权重。
比如上图的对齐是“硬”对齐,将 I 和 I 对齐,sleep 和 tired 对齐。
详细解释软对齐:假设两个文本 
 , 
 ,一个小写字母代表一个词元向量,留意力权重 
 则为:
 ,理想是关联越强的俩词元权重越大。
此中 
 是多层感知机,由下面的mlp函数定义:
  1. def mlp(num_inputs, num_hiddens, flatten):
  2.     net = []
  3.     net.append(nn.Dropout(0.2))
  4.     net.append(nn.Linear(num_inputs, num_hiddens))
  5.     net.append(nn.ReLU())
  6.     if flatten:
  7.         net.append(nn.Flatten(start_dim=1))
  8.     net.append(nn.Dropout(0.2))
  9.     net.append(nn.Linear(num_hiddens, num_hiddens))
  10.     net.append(nn.ReLU())
  11.     if flatten:
  12.         net.append(nn.Flatten(start_dim=1))
  13.     return nn.Sequential(*net)
复制代码
并且,
只需要分别计算每一个 
 和 
 ,不是直接计算 
 这种分解方式使得对长度分别为 m 和 n 的文本对只需要计算 m+n 次而不是 mn 次。
对留意力权重进行规范化,计算所有词元的加权平均值:
比如对于条件中词元 
 在假设中的查询: 
 相称于加权(留意力权重)求和。
同样,
 代表假设中词元
 在条件中的查询。
下面定义一个Attention类计算两个查询:
  1. class Attend(nn.Module):
  2.     def __init__(self, num_inputs, num_hiddens, **kwargs):
  3.         super(Attend, self).__init__(**kwargs)
  4.         self.f = mlp(num_inputs, num_hiddens, flatten=False)
  5.     def forward(self, A, B):
  6.         # A/B的形状:(批量大小,序列A/B的词元数,embed_size)
  7.         # f_A/f_B的形状:(批量大小,序列A/B的词元数,num_hiddens)
  8.         f_A = self.f(A)
  9.         f_B = self.f(B)
  10.         # e的形状:(批量大小,序列A的词元数,序列B的词元数)
  11.         e = torch.bmm(f_A, f_B.permute(0, 2, 1))
  12.         # beta的形状:(批量大小,序列A的词元数,embed_size),
  13.         # 意味着序列B被软对齐到序列A的每个词元(beta的第1个维度)
  14.         beta = torch.bmm(F.softmax(e, dim=-1), B)
  15.         # beta的形状:(批量大小,序列B的词元数,embed_size),
  16.         # 意味着序列A被软对齐到序列B的每个词元(alpha的第1个维度)
  17.         alpha = torch.bmm(F.softmax(e.permute(0, 2, 1), dim=-1), A)
  18.         return beta, alpha
复制代码
这个阶段和传统留意力机制非常像,觉得有点难理解的话可以将留意力权重
中 
看作查询 Q ,
 看作键 K , 
 中 
 看作值 V ,这个 
 相称于将假设 B 中所有词元与条件 A 中的词元 
 求留意力,返回的结果是 B 中所有词元的加权求和,
 的值与假设中与 
 关系最紧密的那个词元
 最靠近。
 和
 外形相同。
2. 比较

第二步,将两个文本序列进行比较。
上一步我们将两个文本中的词元对齐,这一步我们将对齐的词元进行比较。
意思是需要将对齐的词元进行毗连(运算符 
 ),之后被送入多层感知机中。


此中 
 和 
 是之前的查询,相称于查询毗连对应词元。
其实还是有点难懂,书里是这么解释的: 
 是假设假设中所有词元都与条件中的词元 
 对齐,再与词元 
 作比较; 
 同理,假设条件中所有词元都与假设中的词元 
 对齐,再与词元 
 作比较。
定义Compare类计算比较:
  1. class Compare(nn.Module):
  2.     def __init__(self, num_inputs, num_hiddens, **kwargs):
  3.         super(Compare, self).__init__(**kwargs)
  4.         self.g = mlp(num_inputs, num_hiddens, flatten=False)
  5.     def forward(self, A, B, beta, alpha):
  6.         V_A = self.g(torch.cat([A, beta], dim=2))
  7.         V_B = self.g(torch.cat([B, alpha], dim=2))
  8.         return V_A, V_B
复制代码
3. 聚合

第三步,聚合两组比较向量。
上一步我们得到两组比较向量,分别代表条件 A 和假设 B 。
在这一步,首先对两组比较向量求和:

接着将两个求和结果拼接并放入多层感知机中,得到最后的分类结果:

下面实现聚合Aggregate的类:
  1. class Aggregate(nn.Module):
  2.     def __init__(self, num_inputs, num_hiddens, num_outputs, **kwargs):
  3.         super(Aggregate, self).__init__(**kwargs)
  4.         self.h = mlp(num_inputs, num_hiddens, flatten=True)
  5.         self.linear = nn.Linear(num_hiddens, num_outputs)
  6.     def forward(self, V_A, V_B):
  7.         # 对两组比较向量分别求和
  8.         V_A = V_A.sum(dim=1)
  9.         V_B = V_B.sum(dim=1)
  10.         # 将两个求和结果的连结送到多层感知机中
  11.         Y_hat = self.linear(self.h(torch.cat([V_A, V_B], dim=1)))
  12.         return Y_hat
复制代码

最后整合三个步调的代码,变成一个大类:
  1. class DecomposableAttention(nn.Module):
  2.     def __init__(self, vocab, embed_size, num_hiddens, num_inputs_attend=100,
  3.                  num_inputs_compare=200, num_inputs_agg=400, **kwargs):
  4.         super(DecomposableAttention, self).__init__(**kwargs)
  5.         self.embedding = nn.Embedding(len(vocab), embed_size)
  6.         self.attend = Attend(num_inputs_attend, num_hiddens)
  7.         self.compare = Compare(num_inputs_compare, num_hiddens)
  8.         # 有3种可能的输出:蕴涵、矛盾和中性
  9.         self.aggregate = Aggregate(num_inputs_agg, num_hiddens, num_outputs=3)
  10.     def forward(self, X):
  11.         premises, hypotheses = X
  12.         A = self.embedding(premises)
  13.         B = self.embedding(hypotheses)
  14.         beta, alpha = self.attend(A, B)
  15.         V_A, V_B = self.compare(A, B, beta, alpha)
  16.         Y_hat = self.aggregate(V_A, V_B)
  17.         return Y_hat
复制代码
此中num_inputs_attend=100,num_inputs_compare=200, num_inputs_agg=400其实是由于embed_size=100。

接着就可以开始构建模子实例并训练了。
下载数据集:
  1. batch_size, num_steps = 256, 50
  2. train_iter, test_iter, vocab = d2l.load_data_snli(batch_size, num_steps)
复制代码
创建模子实例,将预训练好的GloVe模子直接用于模子嵌入层:
  1. embed_size, num_hiddens, devices = 100, 200, d2l.try_all_gpus()
  2. net = DecomposableAttention(vocab, embed_size, num_hiddens)
  3. glove_embedding = d2l.TokenEmbedding('glove.6b.100d')
  4. embeds = glove_embedding[vocab.idx_to_token]
  5. net.embedding.weight.data.copy_(embeds);
复制代码
训练:
  1. lr, num_epochs = 0.001, 4
  2. trainer = torch.optim.Adam(net.parameters(), lr=lr)
  3. loss = nn.CrossEntropyLoss(reduction="none")
  4. d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs,
  5.     devices)
复制代码
定义猜测函数:
  1. #@save
  2. def predict_snli(net, vocab, premise, hypothesis):
  3.     """预测前提和假设之间的逻辑关系"""
  4.     net.eval()
  5.     premise = torch.tensor(vocab[premise], device=d2l.try_gpu())
  6.     hypothesis = torch.tensor(vocab[hypothesis], device=d2l.try_gpu())
  7.     label = torch.argmax(net([premise.reshape((1, -1)),
  8.                            hypothesis.reshape((1, -1))]), dim=1)
  9.     return 'entailment' if label == 0 else 'contradiction' if label == 1 \
  10.             else 'neutral'
复制代码
利用例子(这里书里的premise和hypothesis都需要传入单独的词汇,我觉得可以利用premise = torch.tensor(vocab[premise.split()], device=d2l.try_gpu()),将传入的句子分割):
  1. predict_snli(net, vocab, ['he', 'is', 'good', '.'], ['he', 'is', 'bad', '.'])
复制代码

15.6 针对序列级和词元级应用微调BERT

这一节学习如何微调BERT将其用在各种自然语言处理惩罚任务上,将其用于差别下游任务。
下面举了四种任务的例子,重要学习根据任务微调模子的思想。
任务又分为序列级(前两个)和词元级(后两个),序列级是对整个文本序列做出判断,词元级则是针对每个词元进行判断,总之看了就懂了。
1. 单文本分类

将单个文本进行输入,输出分类结果。
比如判断句子语法合不合理,在之前说过用<cls>、<sep>分别作为句子开头和末端以及俩文本间隔的标记,这里也可以。
这里对BERT模子的微调是,将<cls>的词元表现设定为表现整个文本的序列信息,最后只用这个标记的词表式放入分类模子,对整个文本序列进行分类。

(<cls>:终是我一个人承担了所有…………)
2. 文本对分类或回归

对文本对进行分类,如之前学到的自然语言推断,这里利用一连值输出代替之前的离散输出,输出两个句子的关联水平。
和单文本分类类似,将两个文本合在一起(用<sep>隔开),用第一个文本开头的<cls>表现两个序列的文本信息。

假如是回归任务的话,下游稠密层就应该进行细微的更改,比如输出一连标签值、利用均方误差。
3. 文本标注

将一个文本中每个词元进行词性标注,留意需要对每一个词元。
词元级任务需要对每个词元输出,微调BERT是个很好的选择。思绪是将每个词元的词表式放入稠密层,分类,输出各自标签。

其实和前两个序列级的任务还是有点像的,只不外这里需要将每个词元表现放入稠密层进行输出。
4. 问答

输入段落文本和题目文本,输出段落中能找到的题目的答案。
听着大概很神奇,我看到这个任务时的思绪是和前一个任务类似,每个词元输出标签,最后选取标签为正的词元输出,但现实上并不是。
书里利用的方法是对每个词元进行两次判断,一次判断开始字符,一次判断末端字符,最后输出中间的词元段。
如许的话相称于回归任务,第一次判断起始词元,求出每个词元是开始词元的概率;第二次判断停止词元,求出每个词元是结束词元的概率。最后得出最大的 
 ( 
 )。


如许四种任务就介绍完了。
留意,在下游任务的监督学习期间,预训练BERT模子的参数只需要微调,而下游的全毗连层那些额外层是从零开始训练的。
15.7 自然语言推断:微调BERT

这一节我们将利用15.6节学习的新知识对15.5的任务中BERT模子进行微调,为了简便,采用15.6中简单的多层感知机模子。

要进行微调,首先需要一个已经预训练好的BERT,这里加载现有BERT模子:
  1. d2l.DATA_HUB['bert.base'] = (d2l.DATA_URL + 'bert.base.torch.zip',
  2.                              '225d66f04cae318b841a13d32af3acc165f253ac')
  3. d2l.DATA_HUB['bert.small'] = (d2l.DATA_URL + 'bert.small.torch.zip',
  4.                               'c72329e68a732bef0452e4b96a1c341c8910f81f')
复制代码
此中 bert.small 是个相对较小的版本,利用它进行演示。它的内部包罗一个vocab.json词典和一个BERT参数文件pretrained.params。
首先定义加载模子的函数,直接将现有模子参数加载到之前定义的BERT模子中:
  1. def load_pretrained_model(pretrained_model, num_hiddens, ffn_num_hiddens,
  2.                           num_heads, num_layers, dropout, max_len, devices):
  3.     data_dir = d2l.download_extract(pretrained_model)
  4.     # 定义空词表以加载预定义词表
  5.     vocab = d2l.Vocab()
  6.     vocab.idx_to_token = json.load(open(os.path.join(data_dir,
  7.         'vocab.json')))
  8.     vocab.token_to_idx = {token: idx for idx, token in enumerate(
  9.         vocab.idx_to_token)}
  10.     bert = d2l.BERTModel(len(vocab), num_hiddens, norm_shape=[256],
  11.                          ffn_num_input=256, ffn_num_hiddens=ffn_num_hiddens,
  12.                          num_heads=4, num_layers=2, dropout=0.2,
  13.                          max_len=max_len, key_size=256, query_size=256,
  14.                          value_size=256, hid_in_features=256,
  15.                          mlm_in_features=256, nsp_in_features=256)
  16.     # 加载预训练BERT参数
  17.     bert.load_state_dict(torch.load(os.path.join(data_dir,
  18.                                                  'pretrained.params')))
  19.     return bert, vocab
复制代码
返回BERT中自带的词典和BERT嵌入层模子,供我们直接利用。
  1. devices = d2l.try_all_gpus()
  2. bert, vocab = load_pretrained_model(
  3.     'bert.small', num_hiddens=256, ffn_num_hiddens=512, num_heads=4,
  4.     num_layers=2, dropout=0.1, max_len=512, devices=devices)
复制代码
如许就加载好词典和模子了。
接着处理惩罚数据集,我们的任务是自然语言推断,需要条件和假设两个文本序列和一个标签。
  1. class SNLIBERTDataset(torch.utils.data.Dataset):
  2.     def __init__(self, dataset, max_len, vocab=None):
  3.         all_premise_hypothesis_tokens = [[
  4.             p_tokens, h_tokens] for p_tokens, h_tokens in zip(
  5.             *[d2l.tokenize([s.lower() for s in sentences])
  6.               for sentences in dataset[:2]])]
  7.         self.labels = torch.tensor(dataset[2])
  8.         self.vocab = vocab
  9.         self.max_len = max_len
  10.         (self.all_token_ids, self.all_segments,
  11.          self.valid_lens) = self._preprocess(all_premise_hypothesis_tokens)
  12.         print('read ' + str(len(self.all_token_ids)) + ' examples')
  13.     def _preprocess(self, all_premise_hypothesis_tokens):
  14.         pool = multiprocessing.Pool(4)  # 使用4个进程
  15.         out = pool.map(self._mp_worker, all_premise_hypothesis_tokens)
  16.         all_token_ids = [
  17.             token_ids for token_ids, segments, valid_len in out]
  18.         all_segments = [segments for token_ids, segments, valid_len in out]
  19.         valid_lens = [valid_len for token_ids, segments, valid_len in out]
  20.         return (torch.tensor(all_token_ids, dtype=torch.long),
  21.                 torch.tensor(all_segments, dtype=torch.long),
  22.                 torch.tensor(valid_lens))
  23.     def _mp_worker(self, premise_hypothesis_tokens):
  24.         p_tokens, h_tokens = premise_hypothesis_tokens
  25.         self._truncate_pair_of_tokens(p_tokens, h_tokens)
  26.         tokens, segments = d2l.get_tokens_and_segments(p_tokens, h_tokens)
  27.         token_ids = self.vocab[tokens] + [self.vocab['<pad>']] \
  28.                              * (self.max_len - len(tokens))
  29.         segments = segments + [0] * (self.max_len - len(segments))
  30.         valid_len = len(tokens)
  31.         return token_ids, segments, valid_len
  32.     def _truncate_pair_of_tokens(self, p_tokens, h_tokens):
  33.         # 为BERT输入中的'<CLS>'、'<SEP>'和'<SEP>'词元保留位置
  34.         while len(p_tokens) + len(h_tokens) > self.max_len - 3:
  35.             if len(p_tokens) > len(h_tokens):
  36.                 p_tokens.pop()
  37.             else:
  38.                 h_tokens.pop()
  39.     def __getitem__(self, idx):
  40.         return (self.all_token_ids[idx], self.all_segments[idx],
  41.                 self.valid_lens[idx]), self.labels[idx]
  42.     def __len__(self):
  43.         return len(self.all_token_ids)
复制代码
包括了加载数据集,将每个文本序列长度控制在max_len,长的切断短的填充。
实例化这个类,加载为迭代器:
  1. # 如果出现显存不足错误,请减少“batch_size”。在原始的BERT模型中,max_len=512
  2. batch_size, max_len, num_workers = 512, 128, d2l.get_dataloader_workers()
  3. data_dir = d2l.download_extract('SNLI')
  4. train_set = SNLIBERTDataset(d2l.read_snli(data_dir, True), max_len, vocab)
  5. test_set = SNLIBERTDataset(d2l.read_snli(data_dir, False), max_len, vocab)
  6. train_iter = torch.utils.data.DataLoader(train_set, batch_size, shuffle=True,
  7.                                    num_workers=num_workers)
  8. test_iter = torch.utils.data.DataLoader(test_set, batch_size,
  9.                                   num_workers=num_workers)
复制代码
接下来就到了微调BERT的环节了。
为了方便演示,我们建立一个模子,包括BERT中模子和我们自己构建的用于分类的稠密层。
  1. class BERTClassifier(nn.Module):
  2.     def __init__(self, bert):
  3.         super(BERTClassifier, self).__init__()
  4.         self.encoder = bert.encoder
  5.         self.hidden = bert.hidden
  6.         self.output = nn.Linear(256, 3)
  7.     def forward(self, inputs):
  8.         tokens_X, segments_X, valid_lens_x = inputs
  9.         encoded_X = self.encoder(tokens_X, segments_X, valid_lens_x)
  10.         return self.output(self.hidden(encoded_X[:, 0, :]))
复制代码
这里演示了整个模子的过程:首先BERT中的encoder将文本转化为向量表现,接着取文本的开头<cls>的词表式放入多层感知机用于分类。
所以说之前用于猜测下一句和遮蔽语言模子的参数在这个任务中不需要,所以不会更新那两个模子的参数。
在这里,BERT的两层(encoder和hidden)都只进行微调,而output是要从零开始训练。
接下来就可以创建模子实例:
  1. net = BERTClassifier(bert)
复制代码
训练:
  1. lr, num_epochs = 1e-4, 5
  2. trainer = torch.optim.Adam(net.parameters(), lr=lr)
  3. loss = nn.CrossEntropyLoss(reduction='none')
  4. d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs,
  5.     devices)
复制代码
如许就完成了!
总之我们可以针对下游任务对BERT进行微调,相称于预训练BERT模子在微调过程中已经成为了下游模子的一部分。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
继续阅读请点击广告

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
回复

使用道具 举报

×
登录参与点评抽奖,加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表