IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 人工智能 -> NLP应用:情感分析和自然语言推断 -> 正文阅读

[人工智能]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?+ej?ij,并输出分数最高的跨度。

  • 小结

    • 对于序列级词元级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类中使?的?法进??较。它们的利弊是什么?计算复杂度?对短文本不利?
  人工智能 最新文章
2022吴恩达机器学习课程——第二课(神经网
第十五章 规则学习
FixMatch: Simplifying Semi-Supervised Le
数据挖掘Java——Kmeans算法的实现
大脑皮层的分割方法
【翻译】GPT-3是如何工作的
论文笔记:TEACHTEXT: CrossModal Generaliz
python从零学(六)
详解Python 3.x 导入(import)
【答读者问27】backtrader不支持最新版本的
上一篇文章      下一篇文章      查看所有文章
加:2022-05-07 11:11:00  更:2022-05-07 11:12:23 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/26 7:24:00-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码