NLP应用:情感分析和自然语言推断

0 序言

  • 回顾:

    • 如何在文本序列中表示词元
    • 训练了词元的表示
    • 这样的预训练文本可通过不同的模型架构,放入不同的下游NLP任务
    • 之前的提到的NLP应用没有使用 预训练
  • 本章:

    • 重点:如何应用 DL表征学习 来解决NLP问题
    • 讨论两种经典的 NLP任务:情感分析(针对单个文本) 和 自然语言推断(针对文本对)
  • 架构:
    在这里插入图片描述

  • 本章选取了一些具有代表性的组合:

    • 情感分析:基于 rnncnn
    • 自然语言推断:使用 att、MLP 分析文本对
    • 自然语言推断:对BERT进行微调。如在序列级(单文本分类和文本对分类)和词元级(文本标注和问答)
  • 需要微调 下游应用 的 大量bert参数

  • 当空间或时间有限时,基于这些架构的模型更具可行性。(现实场景对模型大小和推理速度都有限制)

15.1 情感分析及数据集

  • 评论数据 用于 支持决策过程
  • 应用场景:
    • 产品评论、博客评论 和 论坛评论
    • 政治:公众对政策的情绪分析
    • 金融:市场情绪分析
    • 营销:产品研究 和 品牌管理
  • 情感被分为 离散的极性或尺度(如消极的和积极的),本质为文本分类任务
  • 本章数据集:斯坦福大学 的 大型电影评论数据集,包含IMDb下载的25000个电影评论。训练集合测试集中“积极”和“消极”的数量相同,表示不同的情感极性。
import os, torch
from torch import nn
from d2l import torch as d2l

15.1.1 读取数据集

#@save
d2l.DATA_HUB['aclImdb'] = (
'http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz',
'01ada507287d82875905620988597833ad4e0903')
# data_dir = d2l.download_extract('aclImdb', 'aclImdb')
# 读取 训练和测试数据集,每个样本都是一个评论及其标签
def read_imdb(data_dir, is_train):
    """读取imdb评论数据集 文本序列和标签"""
    data, labels = [], []
    for label in ('pos', 'neg'):
        cate = 'train' if is_train else 'test'
        folder_name = 'aclImdb/' + cate + '/' + label + '/'
        for file in os.listdir(folder_name):
            with open(folder_name+file, 'rb') as f:
                review = f.read().decode('utf-8').replace('\n', '')
                data.append(review)
                labels.append(1 if label == 'pos' else 0)
    return data, labels

train_data = read_imdb(1, is_train=True)
print('训练集数⽬:', len(train_data[0]))
for x, y in zip(train_data[0][:3], train_data[1][:3]):
    print('标签:', y, 'review:', x[0:60])

15.1.2 预处理数据集

# 将每个单词作为一个次元。过滤出现不到五次的单词,从训练数据集中创建一个词表
train_tokens = d2l.tokenize(train_data[0], token='word') # 分词
vocab = d2l.Vocab(train_tokens, min_freq=5, reserved_tokens=['<pad>'])
# 词元化后,绘制评论词元长度的直方图
d2l.set_figsize()
d2l.plt.xlabel('# tokens per review')
d2l.plt.ylabel('count')
d2l.plt.hist([len(line) for line in train_tokens], bins=range(0, 1000, 50))
# 通过 截断和填充 将 每个评论的长度 设置为500
num_steps = 500 # 序列长度
train_features = torch.tensor([d2l.truncate_pad(
    vocab[line], num_steps, vocab['<pad>']) for line in train_tokens])
print(train_features.shape)

15.1.3 创建数据迭代器

train_iter = d2l.load_array((train_features, 
                            torch.tensor(train_data[1])), 64)
for x, y in train_iter: # 每次迭代都会返回 一小批量样本
    print('x:', x.shape, ', y:', y.shape)
    break
print('小批量数目:', len(train_iter))

15.1.4 整合代码

# 将 上述步骤 封装到 一个函数中;返回 训练和测试数据迭代器 以及 IMDb评论数据集的词表
def load_data_imdb(batch_size, num_steps=500):
    """返回 数据迭代器 和 IMDB评论数据集的词表"""
    # 下载和拿到训练和测试数据
    data_dir = 1
    train_data = read_imdb(data_dir, True)
    test_data = read_imdb(data_dir, False)
    # 将这些数据转化为数字输入序列
    train_tokens = d2l.tokenize(train_data[0], token='word')
    test_tokens = d2l.tokenize(test_data[0], token='word')
    # 生成词典
    vocab = d2l.Vocab(train_tokens, min_freq=5)
    # 生成格式化数据
    train_features = torch.tensor([d2l.truncate_pad(
        vocab[line], num_steps, vocab['<pad>']) for line in train_tokens])
    test_features = torch.tensor([d2l.truncate_pad(
        vocab[line], num_steps, vocab['<pad>']) for line in test_tokens])
    # 产生迭代器
    train_iter = d2l.load_array((train_features, torch.tensor(train_data[1])),
                                batch_size)
    test_iter = d2l.load_array((test_features, torch.tensor(test_data[1])),
                               batch_size, is_train=False)
    return train_iter, test_iter, vocab
  • 小结
    • 情感分析任务 本质上是 文本分类问题
    • 预处理后,可使用 词表 将IMDb评论数据集 加载到 数据迭代器
    • 另外数据集:Amazon reviews https://snap.stanford.edu/data/web-Amazon.html

15.2 情感分析:使用RNN

  • 将 预先训练的词向量 应用于情感分析
  • 整个模型的架构:在这里插入图片描述
import torch
from torch import nn
from d2l import torch as d2l

batch_size = 64
train_iter, test_iter, vocab = load_data_imdb(batch_size)

15.2.1 使用RNN表示 单个文本

  • 可变长度的文本序列 转换为 固定长度的类别
  • biLSTM网络 在初始和最终时间步的隐状态(在最后一层) 被连接起来作为 文本序列的表示
  • 最后通过 一个具有两个输出(消极和积极)的FC层,将此单一文本表示转换为输出类别
class BiRNN(nn.Module):
    def __init__(self, vocab_size, embed_size, num_hiddens,
                 num_layers, **kwargs):
        super(BiRNN, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)
        # 将bi-directional设置为True以 获取 biLSTM网络
        self.encoder = nn.LSTM(embed_size, num_hiddens, num_layers=num_layers,
                               bidirectional=True)
        self.decoder = nn.Linear(4 * num_hiddens, 2)
        
    
    def forward(self, inputs):
        # inputs: (批量大小,时间步数)
        # 因为LSTM要求 其输入的第一个维度是 时间维,
        # 所以在 获得词元表示 之前,输入会被转置
        # 输出:(时间步数,批量大小,词向量维度)
        embeddings = self.embedding(inputs.T)
        self.encoder.flatten_parameters()
        # 返回 上一个隐藏层 在 不同时间步的隐状态
        # outputs: (时间步数,批量大小,2*隐藏单元数)
        outputs, _ = self.encoder(embeddings)
        # 连结 初始和最终时间步的隐状态
        # 其形状为:(批量大小,4*隐藏单元数)
        encoding = torch.cat((outputs[0], outputs[-1]),dim=1)
        outs = self.decoder(encoding)
        return outs
# 构造 一个具有两个隐藏层的Bi-RNN来 表示单个文本 以进行 情感分析
embed_size, num_hiddens, num_layers = 100, 100, 2
devices = d2l.try_all_gpus()
net = BiRNN(len(vocab), embed_size, num_hiddens, num_layers)
# 初始化权重
def init_weights(m):
    if type(m) == nn.Linear:
        nn.init.xavier_uniform_(m.weight)
    if type(m) == nn.LSTM:
        for param in m._flat_weights_names:
            if 'weight' in param:
                nn.init.xavier_normal_(m._parameters[param])
net.apply(init_weights)

15.2.2 加载预训练的词向量

class TokenEmbedding:
    """Token Embedding."""
    def __init__(self, embedding_name):
        """Defined in :numref:`sec_synonyms`"""
        self.idx_to_token, self.idx_to_vec = self._load_embedding(
            embedding_name)
        self.unknown_idx = 0
        self.token_to_idx = {token: idx for idx, token in
                             enumerate(self.idx_to_token)}

    def _load_embedding(self, embedding_name):
        idx_to_token, idx_to_vec = ['<unk>'], []
#         data_dir = d2l.download_extract(embedding_name)
        # GloVe website: https://nlp.stanford.edu/projects/glove/
        # fastText website: https://fasttext.cc/
        with open('./glove.6B.100d.txt', 'r', encoding='utf-8') as f:
            for line in f:
                elems = line.rstrip().split(' ')
                token, elems = elems[0], [float(elem) for elem in elems[1:]]
                # Skip header information, such as the top row in fastText
                if len(elems) > 1:
                    idx_to_token.append(token)
                    idx_to_vec.append(elems)
        idx_to_vec = [[0] * len(idx_to_vec[0])] + idx_to_vec
        return idx_to_token, d2l.tensor(idx_to_vec)

    def __getitem__(self, tokens):
        indices = [self.token_to_idx.get(token, self.unknown_idx)
                   for token in tokens]
        vecs = self.idx_to_vec[d2l.tensor(indices)]
        return vecs

    def __len__(self):
        return len(self.idx_to_token)
# 为 词表中的单词 加载 预训练的100维(需要与embed_size一致)的GloVe嵌入
glove_embedding = TokenEmbedding('./glove.6B.100d')
# 打印词表中 所有词元向量的形状
embeds = glove_embedding[vocab.idx_to_token]
embeds.shape # 49346个词,100个维度
# 使用 这些预训练词向量 来表示 评论中的词元,在 训练期间 不要更新 这些向量
net.embedding.weight.data.copy_(embeds)
net.embedding.weight.requires_grad = False

15.2.3 训练和评估模型

# 训练biRNN网络 进行 情感分析
lr, num_epochs = 0.01, 5
trainer = torch.optim.Adam(net.parameters(), lr=lr)
loss = nn.CrossEntropyLoss(reduction='none')
d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs, devices)
# 定义 以下函数 来使用 训练好的模型net 预测 文本序列的情感
def predict_sentiment(net, vocab, sequence):
    """预测文本序列的情感"""
    sequence = torch.tensor(vocab[sequence.split()], device=d2l.try_gpu())
    label = torch.argmax(net(sequence.reshape(1, -1)), dim=1)
    return 'positive' if label == 1 else 'negative', net(sequence.reshape(1, -1))
# 测试 (可能要去掉标点符号)
s = 'this movie is so great !'
predict_sentiment(net, vocab, s)
s = 'this movie is so bad'
predict_sentiment(net, vocab, s)
  • 小结
    • 也可以使用spacy词元化来提高分类进度,短语标记的不同形式(如Glove的“new-york”和spacy的“new york”)
    • 预训练模型表示各个词元
    • bi-rnn表示文本序列

15.3 情感分析:使用CNN

  • CNN可以用于NLP,将文本序列想象成一维图像即可。
  • 一维CNN 可以处理 文本的局部特征,如n元语法。
  • 本节使用 textCNN模型 进行情感分析
import torch
from torch import nn
from d2l import torch as d2l

batch_size = 64
train_iter, test_iter, vocab = load_data_imdb(batch_size)

15.3.1 一维卷积

  • 一维卷积的运作方式(这只是基于互相关运算的二维卷积的特例):
    在这里插入图片描述
# 下面的函数实现了 一维互相关
def corr1d(x, k):
    w = k.shape[0]
    y = torch.zeros((x.shape[0] - w + 1)) # (输入的维度 - 核维度 + pad*2)/stride + 1
    for i in range(y.shape[0]):
        y[i] = (x[i: i + w] * k).sum()
    return y 
# 测试
x, k = torch.tensor([0, 1, 2, 3, 4, 5, 6]), torch.tensor([1, 2])
corr1d(x, k)
  • 具有 3个输入通道的一维互相关运算
    在这里插入图片描述
# 实现 多个输入通道的一维互相关运算
def corr1d_multi_in(X, K):
    # 遍历x和k的第0维(通道维),然后把它们加在一起
    return sum(corr1d(x, k) for x, k in zip(X, K))
# 测试:
X = torch.tensor([[0, 1, 2, 3, 4, 5, 6],
                  [1, 2, 3, 4, 5, 6, 7],
                  [2, 3, 4, 5, 6, 7, 8]])
K = torch.tensor([[1, 2], [3, 4], [-1, -3]])
corr1d_multi_in(X, K)
  • 多输入通道的一维互相关 等价于 单输入通道的二维互相关(注意:看待问题的角度不同,前者是多个一维向量,后者是看成一个二维向量)
    在这里插入图片描述

15.3.2 最大时间汇聚层

  • 使用汇聚层——pooling从序列表示中提取最大值,作为跨时间步的最重要的特征
  • textCNN中使用的 最大时间汇聚层的工作原理 一维全局汇聚
  • 对于每个通道 在不同时间步 存储 值的多通道输入,每个通道的输出 是 该通道的最大值
  • 注:最大时间汇聚 允许 在不同通道上使用不同数量的时间步。

15.3.3 textCNN模型

  • textCNN模型 将 单个预训练的词元表示 作为输入,然后使用 一维卷积和最大时间汇聚

  • textCNN模型的输入:张量的宽度、高度和通道数分别为 n , 1 , d n,1,d n,1,d

  • textCNN模型步骤为:
    在这里插入图片描述

  • textCNN模型的架构为:
    在这里插入图片描述

定义模型

# 实现textCNN模型,与之前的模型相比,
# 此处使用两个嵌入层,一个是 可训练权重,另一个是 固定权重
class TextCNN(nn.Module):
    def __init__(self, vocab_size, embed_size, kernel_sizes, num_channels, **kwargs):
        super(TextCNN, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)
        # 该嵌入层 不需要训练
        self.constant_embedding = nn.Embedding(vocab_size, embed_size)
        self.dropout = nn.Dropout(0.5)
        self.decoder = nn.Linear(sum(num_channels), 2) # FC
        # 最大时间汇聚层 没有参数,因此可以共享 此实例
        self.pool = nn.AdaptiveAvgPool1d(1)
        self.relu = nn.ReLU()
        # 创建多个一维卷积层
        self.convs = nn.ModuleList()
        for c, k in zip(num_channels, kernel_sizes):
            self.convs.append(nn.Conv1d(2 * embed_size, c, k))
            
    def forward(self, inputs):
        # 沿着 向量维度 将 两个嵌入层 连结起来
        # 每个嵌入层的输出形状:(批量大小,词元数量,词元向量维度)连结起来
        embeddings = torch.cat((
            self.embedding(inputs), self.constant_embedding(inputs)), dim=2)
        # 根据 一维卷积层的输入格式,重新排列张量,以便通道作为第2维
        embeddings = embeddings.permute(0, 2, 1)
        # 每个一维卷积层 在 最大时间汇聚层 合并后,获得张量:(批量大小,通道数,1)
        # 删除最后一个维度并沿 通道维度 连结
        encoding = torch.cat([
            torch.squeeze(self.relu(self.pool(conv(embeddings))), dim=-1)
            for conv in self.convs], dim=1)
        outputs = self.decoder(self.dropout(encoding))
        return outputs
# 创建一个textCNN实例,3个卷积层,卷积核宽度分别为3,4,5,均有100个输出通道
embed_size, kernel_sizes, nums_channels = 100, [3, 4, 5], [100, 100, 100]
devices = d2l.try_all_gpus()
net = TextCNN(len(vocab), embed_size, kernel_sizes, nums_channels)

def init_weights(m):
    if type(m) in (nn.Linear, nn.Conv1d):
        nn.init.xavier_uniform_(m.weight)
        
net.apply(init_weights)

加载预训练词向量

  • 加载 预训练的100维GloVe嵌入 作为 初始化的词元表示
  • 这些词元(嵌入权重)在embedding中被训练,在constant_embedding中被固定
glove_embedding = TokenEmbedding('glove.6B.100d.txt')
embeds = glove_embedding[vocab.idx_to_token]
net.embedding.weight.data.copy_(embeds)
net.constant_embedding.weight.data.copy_(embeds)
net.constant_embedding.weight.requires_grad = False

训练和评估模型

lr, num_epochs = 0.001, 10
trainer = torch.optim.Adam(net.parameters(), lr=lr)
loss = nn.CrossEntropyLoss(reduction='none')
d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs, devices)
# 测试
d2l.predict_sentiment(net, vocab, 'this movie is so great')
d2l.predict_sentiment(net, vocab, 'this movie is so bad')
  • 小结
    • 一维CNN 可以处理 文本中的局部特征,例如 n n n元语法
    • 多输入通道的一维互相关 等价于 单输入通道的二维互相关
    • 最大时间汇聚层(pooling) 允许在 不同通道上 使用 不同数量的时间步长
    • textCNN模型 使用 一维卷积层和最大时间汇聚层 将 单个词元表示 转换为 下游应用输出。
    • CNN更快,更精确

15.4 自然语言推断 与 数据集

  • 回顾 情感分析 目的:将 单个文本序列 分类到 预定义的类别 中
  • 还需要对 文本序列 进行推断:确定 一个句子是否可以从另一个句子推断出来;或者需要通过识别语义等价的句子来消除句子间冗余。

15.4.1 自然语言推断(又:识别文本蕴涵任务)

  • 概念:研究 假设(hypothesis) 是否可以从 前提(premise) 中推断出来(两者都是文本序列)。即决定了一对文本序列之间的逻辑关系,有三种类型:

    • 蕴涵(entailment):假设 可以从 前提 中推断出来。 A → B A\rightarrow B AB
    • ⽭盾(contradiction):假设的否定 可以从 前提 中推断出来。 A → ¬ A A\rightarrow \neg A A¬A
    • 中性(neutral):所有其他情况。
      在这里插入图片描述
  • 自然语言推断 是 理解自然语言的中心话题。从 信息检索 到 开放领域的问答。

  • 本节研究 一个流行的自然语言推断基准数据集。

15.4.2 斯坦福自然语言推断(SNLI)数据集

  • 斯坦福⾃然语⾔推断语料库(Stanford Natural Language Inference,SNLI)是由 500000多个带标签的英语句⼦对 组成的集合。
import os, re, torch
from torch import nn
from d2l import torch as d2l
#@save
d2l.DATA_HUB['SNLI'] = (
'https://nlp.stanford.edu/projects/snli/snli_1.0.zip',
'9fcde07509c7e87ec61c640c1b2753d9041758e4')
# data_dir = d2l.download_extract('SNLI')

读取数据集

  • 原始的SNLI数据集 更加 丰富
  • 以下函数 提取 数据集的一部分,然后返回 前提、假设 及其 标签的列表
def read_snli(data_dir, is_train):
    """将SNLI数据集解析为前提、假设和标签"""
    def extract_text(s):
        # 删除我们不会使用的信息
        s = re.sub('\\(', '', s)
        s = re.sub('\\)', '', s)
        # 用 一个空格替换 两个或多个连续的空格
        s = re.sub('\\s{2,}', ' ', s)
        return s.strip()
    label_set = {'entailment': 0, 'contradiction': 1, 'neutral': 2}
    file_name = os.path.join(data_dir, 'snli_1.0_train.txt'
                             if is_train else 'snli_1.0_test.txt')
    with open(file_name, 'r') as f:
        rows = [row.split('\t') for row in f.readlines()[1:]]
    premises = [extract_text(row[1]) for row in rows if row[0] in label_set]
    hypotheses = [extract_text(row[2]) for row in rows if row[0] in label_set]
    labels = [label_set[row[0]] for row in rows if row[0] in label_set]
    return premises, hypotheses, labels
  • 现在让我们打印前3对 前提和假设,以及 它们的标签(“0”、“1”和“2”分别对应于“蕴涵”、“⽭盾”和“中
    性”)。
data_dir = './snli_1.0/'
train_data = read_snli(data_dir, is_train=True)
for x0, x1, y in zip(train_data[0][:3], train_data[1][:3], train_data[2][:3]):
    print('前提:', x0)
    print('假设:', x1)
    print('标签:', y)

# omelette	英[ˈɒmlət]
# 美[ˈɑːmlət]
# n.	煎蛋; 煎蛋卷; 摊鸡蛋; 鸡蛋饼(常加入奶酪、肉和蔬菜等);
  • 训练集约有550000对,测试集约有10000对。下⾯显⽰了训练集和测试集中的三个标签“蕴涵”、“⽭盾”和“中性”是平衡的。
test_data = read_snli(data_dir, is_train=False)
for data in [train_data, test_data]:
    print([[row for row in data[2]].count(i) for i in range(3)])

定义 用于加载数据集的类

  • 下⾯我们来定义⼀个⽤于加载SNLI数据集的类。
  • 类变量num_steps指定⽂本序列的⻓度,使得每个⼩批量序列将具有相同的形状。换句话说,在较⻓序列中的前num_steps个标记之后的标记被截断,⽽特殊标记<pad>被附加到较短的序列后,直到它们的⻓度变为num_steps。
  • 通过实现__getitem__功能,可以任意访问带有索引idx的前提、假设和标签。
class SNLIDataset(torch.utils.data.Dataset):
    """用于加载SNLI数据集的自定义数据集"""
    def __init__(self, dataset, num_steps, vocab=None):
        self.num_steps = num_steps
        all_premise_tokens = d2l.tokenize(dataset[0]) # 分词
        all_hypothesis_tokens = d2l.tokenize(dataset[1])
        if vocab is None:
            self.vocab = d2l.Vocab(all_premise_tokens + \
                all_hypothesis_tokens, min_freq=5, reserved_tokens=['<pad>'])
        else:
            self.vocab = vocab
        self.premises = self._pad(all_premise_tokens) # 填充一下
        self.hypotheses = self._pad(all_hypothesis_tokens)
        self.labels = torch.tensor(dataset[2])
        print('read ' + str(len(self.premises)) + ' examples')
        
    def _pad(self, lines):
        return torch.tensor([d2l.truncate_pad(
            self.vocab[line], self.num_steps, self.vocab['<pad>'])
                            for line in lines])
    def __getitem__(self, idx):
        # 这里括号括错了,酿成大错!!!
        return (self.premises[idx], self.hypotheses[idx]), self.labels[idx]
    
    def __len__(self):
        return len(self.premises)

整合代码

  • 现在可调⽤read_snli函数和SNLIDataset类来 下载SNLI数据集
  • 并返回训练集和测试集的DataLoader实例,以及训练集的词表
  • 注: 必须使⽤ 从训练集构造的词表 作为 测试集的词表。因此,在训练集中训练的模型将不知道来⾃测试集的任何新词元
def load_data_snli(batch_size, num_steps=50):
    """下载SNLI数据集并返回 数据迭代器和词表"""
    num_workers = d2l.get_dataloader_workers()
    num_workers = 0
#     data_dir = d2l.download_extract('SNLI')
    data_dir = './snli_1.0/'
    train_data = read_snli(data_dir, True)
    test_data = read_snli(data_dir, False)
    train_set = SNLIDataset(train_data, num_steps)
    test_set = SNLIDataset(test_data, num_steps, train_set.vocab)
    train_iter = torch.utils.data.DataLoader(train_set, batch_size, shuffle=True,
                                            num_workers=num_workers)
    test_iter = torch.utils.data.DataLoader(test_set, batch_size, shuffle=False,
                                            num_workers=num_workers)
    return train_iter, test_iter, train_set.vocab
# 设置批量大小为128,序列长度为50,调用上述函数来获取 数据迭代器和词表
train_iter, test_iter, vocab = load_data_snli(128, 50)
len(vocab)
# 打印 第一个小批量的形状。
# 与情感分析相反,我们有分别代表 前提和假设的两个输入x[0]和y[1]
for X, Y in train_iter:
    print(X[0].shape)
    print(X[1].shape)
    print(Y.shape)
    break
  • 小结
    • ⾃然语⾔推断研究“假设”是否可以从“前提”推断出来,其中两者都是⽂本序列。
    • 在⾃然语⾔推断中,前提和假设之间的关系包括蕴涵关系、⽭盾关系和中性关系。
    • 斯坦福⾃然语⾔推断(SNLI)语料库是⼀个⽐较流⾏的⾃然语⾔推断基准数据集。
    • 可以尝试使用 自然语言推断 来评估 机器翻译结果。
    • 减小词表的方法:
      • 调大min_freq参数
      • 调小num_steps参数

15.5 自然语言推断:使用注意力

  • 可分解att模型:Parikh等人提出用 att机制 解决 自然语言推断问题
  • 模型中 没有 卷积层或循环层:在SNLI数据集上以 更少的参数 实现了 当时的最佳结果
  • 本节中,描述并使用这种 基于att的自然语言推断方法(使用MLP)
  • 模型架构图:
    在这里插入图片描述

15.5.1 模型

  • 与保留 前提和假设中词元的顺序 相⽐,我们可以将 ⼀个⽂本序列中的词元 与 另⼀个⽂本序列中的每个词元 对⻬,然后 ⽐较和聚合 这些信息,以预测 前提和假设之间的逻辑关系。
  • 与机器翻译中 源句和⽬标句之间的词元对⻬ 类似,前提和假设之间的词元对⻬ 可以通过注意⼒机制灵活地完成。
  • 图示:在这里插入图片描述
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l

注意(Attending)

  • 使用 加权平均 的“软”对齐:两个文本序列的词元对齐

  • 理想情况下:较大的权重 与 要对齐的词元 相关联

  • 软对齐中吗,att权重的计算方式为:
    在这里插入图片描述

  • 这一分解技巧导致 f f f只有 m + n m+n m+n次计算(线性复杂度,只需计算出 m + n m+n m+n个不同的 f f f值),而不是 m n mn mn次计算(二次复杂度,是将它们⼀对放在⼀起作为输⼊)

  • 在这里插入图片描述

  • 在这里插入图片描述

def mlp(num_inputs, num_hiddens, flatten):
    net = []
    net.append(nn.Dropout(0.2))
    net.append(nn.Linear(num_inputs, num_hiddens))
    net.append(nn.ReLU())
    if flatten:
        net.append(nn.Flatten(start_dim=1))
    net.append(nn.Dropout(0.2))
    net.append(nn.Linear(num_hiddens, num_hiddens))
    net.append(nn.ReLU())
    if flatten:
        net.append(nn.Flatten(start_dim=1))
    return nn.Sequential(*net)
# 该Attend类计算 假设与输入前提A的软对齐(beta) 及 前提与输入假设B的软对齐(alpha) 
# 原书此处的描述应该是错的,翻译有问题。
class Attend(nn.Module):
    def __init__(self, num_inputs, num_hiddens, **kwargs):
        super(Attend, self).__init__(**kwargs)
        self.f = mlp(num_inputs, num_hiddens, flatten=False)
        
    def forward(self, A, B):
#         print(A.shape, B.shape)
#         A = A.unsqueeze(0)
#         B = B.unsqueeze(0)
        # A或B的形状:(批量大小,序列A或B的词元数,embed_size)
        # f_A/f_B的形状:(批量大小,序列A/B的词元数,num_hiddens)
        f_A = self.f(A)
        f_B = self.f(B)
#         print(f_A.shape, f_B.shape)
        
        # e:(批量大小,序列A的词元数,embed_size)
        e = torch.bmm(f_A, f_B.permute(0, 2, 1))
        # beta:(批量大小,序列A的词元数,embed_size),
        # 意味着序列B被 软对齐 到序列A的每个词元(beta的第1个维度)
        beta = torch.bmm(F.softmax(e, dim=-1), B)
        # Alpha:(批量大小,序列B的词元数,embed_size),
        # 意味着序列A被 软对齐 到序列B的每个词元(alpha的第1个维度) 
        alpha = torch.bmm(F.softmax(e.permute(0, 2, 1), dim=-1), A)
        return beta, alpha

比较

  • 将 一个序列中的词元 与 该词元软对齐的另一个序列 进行比较
  • 在软对⻬中,一个序列中的所有词元(尽管可能具有不同的注意⼒权重)将与 另⼀个序列中的词元 进⾏⽐较。
  • 在这里插入图片描述
class Compare(nn.Module):
    def __init__(self, num_inputs, num_hiddens, **kwargs):
        super(Compare, self).__init__(**kwargs)
        self.g = mlp(num_inputs, num_hiddens, flatten=False)
        
    def forward(self, A, B, beta, alpha):
#         A = A.unsqueeze(0)
#         B = B.unsqueeze(0)
        V_A = self.g(torch.cat([A, beta], dim=2))
        V_B = self.g(torch.cat([B, alpha], dim=2))
        return V_A, V_B

聚合

  • 在这里插入图片描述
class Aggregate(nn.Module):
    def __init__(self, num_inputs, num_hiddens, num_outputs, **kwargs):
        super(Aggregate, self).__init__(**kwargs)
        self.h = mlp(num_inputs, num_hiddens, flatten=True)
        self.linear = nn.Linear(num_hiddens, num_outputs)
        
    def forward(self, V_A, V_B):
        # 对 两组比较向量分别 求和
        V_A = V_A.sum(dim=1)
        V_B = V_B.sum(dim=1)
        # 将 两个求和结果的连结 送到 MLP 中
        Y_hat = self.linear(self.h(torch.cat([V_A, V_B], dim=1)))
        return Y_hat

整合代码

  • 定义 可分解att模型来联合训练这三个步骤(注意、比较和聚合)
class DecomposableAttention(nn.Module):
    def __init__(self, vocab, embed_size, num_hiddens, num_inputs_attend=100,
                 num_inputs_compare=200, num_inputs_agg=400, **kwargs):
        super(DecomposableAttention, self).__init__(**kwargs)
        self.embedding = nn.Embedding(len(vocab), embed_size)
        self.attend = Attend(num_inputs_attend, num_hiddens)
        self.compare = Compare(num_inputs_compare, num_hiddens)
        # 有三种可能:蕴涵,矛盾和中性
        self.aggregate = Aggregate(num_inputs_agg, num_hiddens, num_outputs=3)
    
    def forward(self, x):
        premises, hypotheses = x
        A = self.embedding(premises)
        B = self.embedding(hypotheses)
        beta, alpha = self.attend(A, B)
        V_A, V_B = self.compare(A, B, beta, alpha)
        Y_hat = self.aggregate(V_A, V_B)
        return Y_hat

15.5.2 训练和评估模型

  • 在 SNLI数据集 上对 定义好的可分解att模型 进行 训练和评估

读取数据集

  • 下载并读取 SNLI数据集,批量大小为256,序列长度为50
batch_size, num_steps = 256, 50
train_iter, test_iter, vocab = load_data_snli(batch_size, num_steps)

创建模型

  • 使用 预训练好的100维GloVe 来表示 输入词元
  • 在这里插入图片描述
embed_size, num_hiddens, devices = 100, 200, d2l.try_all_gpus()
net = DecomposableAttention(vocab, embed_size, num_hiddens)
glove_embedding = TokenEmbedding('glove.6B.100d.txt')
embeds = glove_embedding[vocab.idx_to_token]
net.embedding.weight.data.copy_(embeds) # 加载glove

训练和评估模型

  • 在这里插入图片描述
lr, num_epochs = 0.001, 4
trainer = torch.optim.Adam(net.parameters(), lr=lr)
loss = nn.CrossEntropyLoss(reduction='none')
d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs, devices)

使用模型

# 最后,定义预测函数,输出 一对前提和假设之间的逻辑关系
def predict_snli(net, vocab, premise, hypothesis):
    """预测 前提和假设之间的逻辑关系"""
    net.eval()
    premise = torch.tensor(vocab[premise], device=d2l.try_gpu())
    hypothesis = torch.tensor(vocab[hypothesis], device=d2l.try_gpu())
    label = torch.argmax(net([premise.reshape((1, -1)),
                             hypothesis.reshape((1, -1))]), dim=1)
    return 'entailment' if label == 0 else 'contradiction' if label == 1 \
            else 'neutral'
a = 'he is good .'
b = 'he is bad .'
predict_snli(net, vocab, a.split(), b.split())
  • 小结

    • 可分解注意模型包括三个步骤来预测前提和假设之间的逻辑关系:注意⽐较聚合
    • 通过注意⼒机制,我们可以将⼀个⽂本序列中的词元与另⼀个⽂本序列中的每个词元对⻬,反之亦然。这种对⻬是使⽤加权平均的软对⻬,其中理想情况下较⼤的权重与要对⻬的词元相关联。
    • 在计算注意⼒权重时,分解技巧会带来⽐⼆次复杂度更理想的线性复杂度。
    • 我们可以使⽤预训练好的词向量作为下游⾃然语⾔处理任务(如⾃然语⾔推断)的输⼊表⽰。
  • 缺点:

    • 需要对齐?

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

  • 在空间和时间限制的情况下,为NLP应用设计了不同的模型,例如RNN,CNN,att和MLP
  • 但为每个NLP任务 精心设计 一个特定的模型 是不可行的。
  • BERT预训练模型:
    • 对 广泛的NLP任务 进行最小的架构更改
    • 提出时,改进了各种NLP任务的技术水平
    • 原始BERT的两个版本参数量为1.1亿和3.4亿个参数,因此当有足够的计算资源时,我们可以考虑为下游NLP应用微调BERT
  • NLP应用的子集——序列级 和 词元级
    • 序列层次上,有 单文本分类任务和文本对分类(或回归)任务,介绍了如何将输入的BERT表示 转换为 输出标签
    • 词元级别,新的应用——文本标注和问答,并说明bert如何表示 它们的输入 并转换为 输出标签
    • 在微调期间,不同应用之间的bert所需的“最小架构更改”是额外的FC层。在下游应用的监督学习期间,额外层的参数是从开始学习的,而预训练bert模型中的所有参数都是微调的。

15.6.1 单文本分类

  • 概述:输入单个文本,输出其分类结果。如COLA数据集,判定给定的句子在语法上是否可以接受。

  • 模型架构:在这里插入图片描述

  • BERT输⼊序列明确地表⽰单个⽂本和⽂本对,特殊分类标记<cls>⽤于序列分类,⽽特殊分类标记<sep>则标记单个⽂本的结束或分隔成对⽂本。

  • 在单⽂本分类应⽤中,特殊分类标记<cls>的BERT表⽰ 对 整个输⼊⽂本序列的信息 进⾏编码。作为 输⼊单个⽂本的表⽰,它将被送⼊到由 FC(Dense)层组成的⼩MLP 中,以输出 所有离散标签值的分布。

15.6.2 文本对分类或回归

  • 本章的 自然语言推断 属于 文本对分类

  • 输入 一对文本,输出 连续值,如 语义文本相似度任务(一个流行的“文本对回归”任务,数据集为语义⽂本相似度基准数据集(Semantic Textual Similarity Benchmark))

  • 模型架构:在这里插入图片描述

  • 与 单⽂本分类相⽐,⽂本对分类的BERT微调在输入表示上有所不同。对于⽂本对回归任务(如语义⽂本相似性),可以应⽤细微的更改,例如输出连续的标签值使用均方损失:它们在回归中很常⻅。

15.6.3 文本标注

  • 词元级任务:每个词元都被分配了一个标签。

  • 在⽂本标注任务中,词性标注为 每个单词 分配词性标记(例如,形容词和限定词)。

  • 根据单词在句⼦中的作⽤。如,在Penn树库II标注集中,句⼦“John Smith‘s car is new”应该被标记为“NNP(名词,专有单数)NNP POS(所有格结尾)NN(名词,单数或质量)VB(动词,基本形式)JJ(形容词)”。

  • 模型架构:在这里插入图片描述

  • ⽂本标记应⽤的BERT微调。与单文本分类应用相比,唯⼀的区别在于,在⽂本标注中,输⼊⽂本的每个词元的BERT表⽰ 被送到 相同的额外FC层中,以输出词元的标签,例如词性标签。

15.6.4 问答

  • 词元级应用:问答 反应 阅读理解能力

  • 例如,斯坦福问答数据集(Stanford Question Answering Dataset,SQuAD v1.1)由阅读段落问题组成,其中每个问题的答案只是段落中的⼀段⽂本(⽂本⽚段)[Rajpurkar et al., 2016]。

  • SQuAD v1.1的⽬标 是在 给定问题和段落的情况下 预测 段落中⽂本⽚段的开始和结束。

  • 模型架构:在这里插入图片描述

  • 为了微调BERT进⾏问答,在BERT的输⼊中,将问题段落分别作为第⼀个第⼆个⽂本序列。

  • 为了预测⽂本⽚段开始的位置,相同的额外的FC层将把 来⾃位置 i i i的任何词元的BERT表⽰ 转换成 标量分数 s i s_i si

  • ⽂章中所有词元的分数 还通过softmax转换成 概率分布,从⽽为⽂章中的每个词元位置 i i i分配作为⽂本⽚段开始的概率 p i p_i pi

  • 预测 ⽂本⽚段的结束 与上⾯相同,只是 其额外的FC层中的参数 与 ⽤于预测开始位置的参数 ⽆关。当预测结束时,位置 i i i的词元由 相同的FC层 变换成 标量分数 e i e_i ei

  • 对于问答,监督学习的训练⽬标 就像 最⼤化真实值的开始和结束位置的对数似然 ⼀样简单。当预测⽚段时,我们可以计算 从位置 i i i到位置 j j j的有效⽚段的分数 s i + e j ( i ≤ j ) s_i + e_j(i ≤ j) si+ejij,并输出分数最高的跨度。

  • 小结

    • 对于序列级词元级NLP应⽤,BERT只需要最小的架构改变(额外的FC层),如单个⽂本分类(例如,情感分析和测试语⾔可接受性)、⽂本对分类或回归(例如,⾃然语⾔推断和语义⽂本相似性)、⽂本标记(例如,词性标记)和问答。
    • 在下游应⽤的监督学习期间,额外层的参数是从开始学习的,⽽预训练BERT模型中的所有参数都是微调的
    • 让我们为新闻⽂章设计⼀个搜索引擎算法。当系统接收到查询(例如,“冠状病毒爆发期间的⽯油⾏业”)时,它应该返回与该查询最相关的新闻⽂章的排序列表。假设我们有⼀个巨⼤的新闻⽂章池和⼤量的查询。为了简化问题,假设为每个查询标记了最相关的⽂章。如何在算法设计中应⽤负采样和BERT?
    • BERT如何训练 LM 或 MT ?

15.7 自然语言推断:微调BERT

  • 之前,我们为 SNLI数据集上的⾃然语⾔推断任务 设计了⼀个基于att的结构。

  • 现在,通过 微调BERT 来重新审视这项任务。正如在 上一节中讨论的那样,⾃然语⾔推断 是⼀个序列级别的⽂本对分类问题,⽽微调BERT只需要⼀个额外的基于MLP的架构,如下图:
    在这里插入图片描述

  • 本节将下载 ⼀个预训练好的小版本的BERT,然后对其进⾏微调,以便 在SNLI数据集上 进⾏ ⾃然语⾔推断。

import json, multiprocessing, os, torch, re
from torch import nn
from d2l import torch as d2l

15.7.1 加载预训练的BERT

  • 在此之前,我们已经在WikiText-2数据集上预训练BERT(原始的BERT模型是在更⼤的语料库上预训练的)。原始的BERT模型有数以亿计的参数。
  • 在下⾯,我们提供了两个版本的预训练的BERT:“bert.base”与原始的BERT基础模型⼀样⼤,需要⼤量的计算资源才能进⾏微调,⽽“bert.small”是⼀个版本,以便于演⽰。
d2l.DATA_HUB['bert.base'] = (d2l.DATA_URL + 'bert.base.torch.zip',
'225d66f04cae318b841a13d32af3acc165f253ac')
d2l.DATA_HUB['bert.small'] = (d2l.DATA_URL + 'bert.small.torch.zip',
'c72329e68a732bef0452e4b96a1c341c8910f81f')
  • 两 个 预 训 练 好 的BERT模 型 都 包 含
    • ⼀ 个 定 义 词 表 的 “vocab.json” ⽂ 件
    • ⼀ 个 预 训 练 参 数 的“pretrained.params”⽂件。
  • 实现了 以下load_pretrained_model函数 来加载 预先训练好的BERT参数:
def load_pretrained_model(pretrained_model, num_hiddens, ffn_num_hiddens,
                          num_heads, num_layers, dropout, max_len, devices):
#     data_dir = d2l.download_extract(pretrained_model)
    # 定义空词表 以 加载预定义词表
    vocab = d2l.Vocab()
    vocab.idx_to_token = json.load(open('./'+ pretrained_model + '.torch/vocab.json'))
    vocab.token_to_idx = {token: idx for idx, token in enumerate(vocab.idx_to_token)}
    bert = d2l.BERTModel(len(vocab), num_hiddens, norm_shape=[256],
                         ffn_num_input=256, ffn_num_hiddens=ffn_num_hiddens,
                         num_heads=4, num_layers=2, dropout=0.2,
                         max_len=max_len, key_size=256, query_size=256,
                         value_size=256, hid_in_features=256,
                         mlm_in_features=256, nsp_in_features=256)
    # 加载预训练BERT参数
    bert.load_state_dict(torch.load('./' + pretrained_model + '.torch/pretrained.params'))
    return bert, vocab
  • 为了便于在⼤多数机器上演⽰,我们将在本节中加载微调 经过预训练BERT的⼩版本(“bert.mall”)。
  • 在练习中,我们将展⽰如何微调⼤得多的“bert.base”以显著提高测试精度
devices = d2l.try_all_gpus()
bert, vocab = load_pretrained_model(
    'bert.small', num_hiddens=256, ffn_num_hiddens=512, num_heads=4,
    num_layers=2, dropout=0.1, max_len=512, devices=devices)

15.7.2 微调BERT的数据集

  • 对于SNLI数据集的下游任务⾃然语⾔推断,我们定义了⼀个定制的数据集类SNLIBERTDataset

  • 在每个样本中,前提假设形成⼀对⽂本序列,并被打包成 ⼀个BERT输⼊序列,如下图所⽰:
    在这里插入图片描述

  • 片段索引⽤于区分BERT输⼊序列中的前提和假设。利⽤ 预定义的BERT输⼊序列的最⼤⻓度(max_len),持续移除输⼊⽂本对中较⻓⽂本的最后⼀个标记,直到满⾜max_len。

  • 为了加速 ⽣成⽤于微调BERT的SNLI数据集,我们使⽤4个⼯作进程并行⽣成训练或测试样本。

class SNLIBERTDataset(torch.utils.data.Dataset):
    def __init__(self, dataset, max_len, vocab=None):
        all_premise_hypothesis_tokens = [[
            p_tokens, h_tokens] for p_tokens, h_tokens in zip(
                *[d2l.tokenize([s.lower() for s in sentences])
                 for sentences in dataset[:2]])]
            
        self.labels = torch.tensor(dataset[2])
        self.vocab = vocab
        self.max_len = max_len
        (self.all_token_ids, self.all_segments,
         self.valid_lens) = self._preprocess(all_premise_hypothesis_tokens)
        print('read ' + str(len(self.all_token_ids)) + ' examples')
        
    def _preprocess(self, all_premise_hypothesis_tokens):
        pool = multiprocessing.Pool(1) # 使用四个进程
        out = pool.map(self._mp_worder, all_premise_hypothesis_tokens)
#         out = all_premise_hypothesis_tokens
        all_token_ids = [
            token_ids for token_ids, segments, valid_len in out]
        all_segments = [segments for token_ids, segments, valid_len in out]
        valid_lens = [valid_len for token_ids, segments, valid_len in out]
        return (torch.tensor(all_token_ids, dtype=torch.long),
                torch.tensor(all_segments, dtype=torch.long),
                torch.tensor(valid_lens))
    
    def _mp_worder(self, premise_hypothesis_tokens):
        p_tokens, h_tokens = premise_hypothesis_tokens
        self._truncate_pair_of_tokens(p_tokens, h_tokens)
        tokens, segments = d2l.get_tokens_and_segments(p_tokens, h_tokens)
        token_ids = self.vacab[tokens] + [self.vocab['<pad>']] \
                            * (self.max_len - len(tokens))
        segments = segments + [0] * (self.max_len - len(segments))
        valid_len = len(tokens)
        return token_ids, segments, valid_len
    
    def _truncate_pair_of_tokens(self, p_tokens, h_tokens):
        # 为BERT输入中的`<CLS>`, `<SEP>` 和 `<SEP>`词元保留位置
        while len(p_tokens) + len(h_tokens) > self.max_len - 3:
            if len(p_tokens) > len(h_tokens):
                p_tokens.pop()
            else:
                h_tokens.pop()
                
    def __getitem__(self, idx):
        return (self.all_token_ids[idx], self.all_segments[idx],
                self.valid_lens[idx], self.labels[idx])
    
    def __len__(self):
        return len(self.all_token_ids)
# 实例化上述类,生成 训练和测试样本。
# 这些样本在 自然语言推断的训练和测试期间 进行 小批量读取
if __name__ == "__main__":
    batch_size, max_len, num_workers = 128, 128, d2l.get_dataloader_workers()
    num_workers = 0
    data_dir = './snli_1.0/'
    train_set = SNLIBERTDataset(read_snli(data_dir, True), max_len, vocab)
    test_set = SNLIBERTDataset(read_snli(data_dir, False), max_len, vocab)
    train_iter = torch.utils.data.DataLoader(train_set, batch_size, shuffle=True,
                                            num_workers=num_workers)
    test_iter = torch.utils.data.DataLoader(test_set, batch_size, num_workers=num_workers)

15.7.3 微调BERT

  • 如下图所⽰,⽤于⾃然语⾔推断的微调BERT只需要⼀个额外的MLP,该MLP由两个FC层组成(请参⻅下⾯BERTClassifier类中的self.hidden和self.output)。
  • 这个MLP将特殊的<cls>词元的BERT表⽰进⾏了转换,该词元同时编码 前提假设的信息 为⾃然语⾔推断的三个输出:蕴涵、⽭盾和中性。
    在这里插入图片描述
class BERTClassifier(nn.Module):
    def __init__(self, bert):
        super(BERTClassifier, self).__init__()
        self.encoder = bert.encoder
        self.hidden = bert.hidden
        self.output = nn.Linear(256, 3)
    
    def forward(self, inputs):
        tokens_x, segments_x, valid_lens_x = inputs
        encoded_x = self.encoder(tokens_x, segments_x, valid_lens_x)
        return self.output(self.hidden(encoded_x[:, 0, :]))
  • 在下⽂中,预训练的BERT模型bert被送到⽤于下游应⽤的BERTClassifier实例net中。
  • 在BERT微调的常⻅实现中,只有 额外的多层感知机(net.output)的输出层的参数 将从开始学习。
  • 预训练BERT编码器(net.encoder)和额外的多层感知机的隐藏层(net.hidden)的所有参数 都将进⾏微调
# 只有额外的MLP(net.output)的输出层的参数 将 从零开始学习
net = BERTClassifier(bert)
  • 回想⼀下,在 14.8节中,MaskLM类和NextSentencePred类 在其使⽤的MLP中都有⼀些参数。这些参数是 预训练BERT模型bert中参数的⼀部分,因此是net中的参数的⼀部分。然⽽,这些参数仅⽤于计算预训练过程中的masked-LM损失和NSP损失。
  • 这两个损失函数与微调下游应⽤无关,因此当BERT微调时,MaskLM和NSP中采⽤的MLP的参数不会更新(陈旧的,staled)。
  • 为了允许具有陈旧梯度的参数,标志ignore_stale_grad=True在step函数d2l.train_batch_ch13中被设置。我们通过该函数使⽤SNLI的训练集(train_iter)和测试集(test_iter)对net模型进⾏训练和评估。
  • 由于计算资源有限,训练和测试精度 可以进⼀步提⾼:我们把对它的讨论留在练习中。
lr, num_epochs = 1e-4, 5
trainer = torch.optim.Adam(net.parameters(), lr=lr)
loss = nn.CrossEntropyLoss(reduction='none')
d2l.train_batch_ch13(net, train_iter, test_iter, loss, trainer, num_epochs, devices)
  • 小结
    • 我们可以针对下游应⽤对 预训练的BERT模型 进⾏微调,例如在SNLI数据集上进⾏⾃然语⾔推断。
    • 在微调过程中,BERT模型成为下游应⽤模型的⼀部分。仅与训练前损失相关的参数在微调期间不会更新。
    • 如果您的计算资源允许,请微调⼀个更⼤的预训练BERT模型,该模型与原始的BERT基础模型⼀样⼤。修改load_pretrained_model函数中的参数设置:将“bert.mall”替换为“bert.base” ,将num_hiddens=256、ffn_num_hiddens=512、num_heads=4和num_layers=2的值分别增加到768、3072、12和12。通过增加微调迭代轮数(可能还会调优其他超参数),你可以获得⾼于0.86的测试精度吗?
    • 如何根据⼀对序列的⻓度⽐值截断它们?将此对截断⽅法与SNLIBERTDataset类中使⽤的⽅法进⾏⽐较。它们的利弊是什么?计算复杂度?对短文本不利?
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值