分类 深度学习 下的文章

1、层次分类简介

本项目提供通用场景下基于预训练模型微调的层次分类端到端应用方案,打通数据标注-模型训练-模型调优-模型压缩-预测部署全流程,有效缩短开发周期,降低AI开发落地门槛。

层次文本分类任务的中数据样本具有多个标签且标签之间存在特定的层级结构,目标是预测输入句子/文本可能来自于不同级标签类别中的某一个或几个类别。下面是以图新闻文本分类为例,该新闻的一级标签为体育,二级标签为足球,体育与足球之间存在层级关系。在现实场景中,大量的数据如新闻分类、专利分类、学术论文分类等标签集合存在层次化结构,需要利用算法为文本自动标注更细粒度和更准确的标签。
image.png
数据集的标签集具有多级标签且标签之间具有层级结构关系,输入句子/文本具有一个或多个标签。在文本层次分类任务中,我们需要预测输入句子/文本可能来自于不同级标签类别中的某一个或几个类别。以上图层次分类中新闻文本为例(新闻为根节点),该新闻一级分类标签为 体育,二级分类标签为 足球。

参考飞桨选手-littlefish

以下代码fork后可以直接run
https://aistudio.baidu.com/aistudio/projectdetail/4715774

2、安装说明

AI Studio平台默认安装了Paddle和PaddleNLP,并定期更新版本。 如需手动更新,可参考如下说明:

python >= 3.6
| paddlepaddle >= 2.3
| paddlenlp >= 2.4
| scikit-learn >= 1.0.2

3、数据准备

采用AliExpress全球速卖通商品数据集

https://aistudio.baidu.com/aistudio/datasetdetail/172300

4、模型选择

以下展示了paddle采用的2020语言与智能技术竞赛:事件抽取任务抽取的多标签层次数据集的结果展示

精度评价指标:Micro F1分数、Macro F1分数

模型结构Micro F1(%)Macro F1(%)latency(ms)
ERNIE 1.0 Large Cw24-layer, 1024-hidden, 20-heads96.2494.245.59
ERNIE 3.0 Xbase20-layer, 1024-hidden, 16-heads96.2194.135.51
ERNIE 3.0 Base12-layer, 768-hidden, 12-heads95.6893.392.01
ERNIE 3.0 Medium6-layer, 768-hidden, 12-heads95.2693.221.01
ERNIE 3.0 Mini6-layer, 384-hidden, 12-heads94.7293.030.36
ERNIE 3.0 Micro4-layer, 384-hidden, 12-heads94.2493.080.24
ERNIE 3.0 Nano4-layer, 312-hidden, 12-heads93.9891.250.19
ERNIE 3.0 Medium + 裁剪(保留比例3/4)6-layer, 768-hidden, 9-heads95.4593.400.81
ERNIE 3.0 Medium + 裁剪(保留比例2/3)6-layer, 768-hidden, 8-heads95.2393.270.74
ERNIE 3.0 Medium + 裁剪(保留比例1/2)6-layer, 768-hidden, 6-heads94.9292.700.61

本项目由于数据集是英文,故采用ernie-2.0-base-en模型

  1. R-Drop:两次前向+KL loss约束
  2. Post Training: 在领域语料上用mlm进一步预训练
  3. EFL: 少样本下,把分类问题转为匹配问题,把输入构造为NSP任务形式.
  4. 混合精度fp16: 加快训练速度,提高训练精度
  5. 多卡ddp训练的时候,用到梯度累积时,可以使用no_sync减少不必要的梯度同步,加快速度
  6. 对于验证集或者测试集特别大的情况,可以尝试多卡inference,需要用的就是dist.all_gather,对于非张量的话也可以用all_gather_object
  7. PET: 少样本下,把分类转为mask位置预测,并构造verbalizer,参考EACL2021. PET
  8. ArcFaceLoss:双塔句子匹配的loss把NT-Xent loss改成arccos的形式,参考ACL2022. ArcCSE
  9. 数据增强在zero shot x-lingual transfer:code switch,machine translation..记得最后加一致性loss,参考consistency regularization for cross lingual finetuning
  10. SimCSE:继续在领域语料上做simcse的预训练
  11. Focal loss: 不平衡的处理
  12. 双塔迟交互:maxsim操作:query和doc的每个token表征算相似度,取最大相似度再求和。速度和精度都有一个很好的平衡,参考colbert
  13. 持续学习减轻遗忘:EWC方法+一个很强的预训练模型效果很不错。就是加一个正则让重要参数遗忘不太多,重要性用fisher信息度量。
  14. 对抗训练:FGM,PGD,能提点,就是训练慢,
  15. memory bank增大bsz,虽然我感觉有时候有点鸡肋

我从研一开始学习NLP自然语言处理,经常使用PyTorch框架。一开始用的时候对于PyTorch的显存机制也是一知半解,连蒙带猜的,经常来知乎上来找答案。经过两年的研究,现在回过头来看,能从大家的答案中找出不足的地方。但是两年过去了,也没有一篇很好的文章来总结PyTorch的显存机制的方方面面,那么我就吸收大家的看法,为PyTorch的显存机制做个小的总结吧。

实验环境:

OS: Window 11

python: 3.7.4

PyTorch: 1.9.1

GPU: RTX 3060

1 理论知识

1. 深度学习训练过程

开门见山的说,PyTorch在进行深度学习训练的时候,有4大部分的显存开销,分别是模型参数(parameters),模型参数的梯度(gradients),优化器状态(optimizer states)以及中间激活值(intermediate activations) 或者叫中间结果(intermediate results)。

为了后面显存分析阐述的方便,我将深度学习的训练定义4个步骤:

1. 模型定义: 定义了模型的网络结构,产生模型参数;while(你想训练):

2. 前向传播: 执行模型的前向传播,产生中间激活值;

3. 后向传播: 执行模型的后向传播,产生梯度;

4. 梯度更新: 执行模型参数的更新,第一次执行的时候产生优化器状态。

在模型定义完之后,2~4循环执行。

2. 前向传播

拿Linear层(或者叫Dense层,前馈神经网络,全连接层等等...)举例:假设他的权重矩阵为W,偏置向量为b,那么他的前向计算过程就是:

一元线性回归

这里的X为该层的输入向量,Y为输出向量(中间激活值)

3. 后向传播(反向传播)

参考了这篇文章

https://zhuanlan.zhihu.com/p/22473137

后向传播回来了一个第l+1层的输出误差矩阵out ,用以计算该层的梯度和输入误差in

反向传播

4. 梯度更新

接下来就是利用W_diff和b_diff进行更新了:

当然使用Adam优化器的时候,实际的更新过程并没有上面的这么简单。目前用的最多的是AdamW,可以看看这篇文章。

梯度更新

https://zhuanlan.zhihu.com/p/38945390

https://www.ilfishs.com/archives/80

但是使用这一类优化器,也会带来额外的显存开销。对于每一个参数,Adam都会为它准备对应的2个优化器状态,分别是动量(momentum)和方差(variance),用以加速模型的训练。

2 显存分析方法与Torch机制

1. 分析方法

(1) No Nvidia-smi

我看很多人现在还在用nvidia-smi来看pytorch的显存占用,盯着跳来跳去的torch缓存区分析真的不累吗。(贴一个Torch为什么不用Nvidia-smi看的图)。而且PyTorch是有缓存区的设置的,意思就是一个Tensor就算被释放了,进程也不会把空闲出来的显存还给GPU,而是等待下一个Tensor来填入这一片被释放的空间。有什么好处?进程不需要重新向GPU申请显存了,运行速度会快很多,有什么坏处?他不能准确地给出某一个时间点具体的Tensor占用的显存,而是显示的已经分配到的显存缓冲区和torch在创建cuda进程时所需开销的和,也就是reserved_memory和torch context显存之和。这也是令很多人在使用PyTorch时对显存占用感到困惑的罪魁祸首。

(3) torch.cuda is all you need.

在分析PyTorch的显存时候,一定要使用torch.cuda里的显存分析函数,我用的最多的是torch.cuda.memory_allocated()和torch.cuda.max_memory_allocated(),前者可以精准地反馈当前进程中torch.Tensor所占用的GPU显存(注意是只包括torch.Tensor),后者则可以告诉我们到调用函数为止所达到的最大的显存占用字节数。还有像torch.cuda.memory_reserved()这样的函数则是查看当前进程所分配的显存缓冲区是多少的。

image.png

Torch 官方文档

2. PyTorch context开销

-----之前没有提到PyTorch context的开销,做个补充...

我注意到有很多同学在做显存分析的时候是为了在训练的时候可以把卡的显存用满,这个之前没有考虑到呢。其实PyTorch context是我们在使用torch的时候的一个大头开销。

主要参考的是论坛里的这篇讨论:

https://discuss.pytorch.org/t/how-do-i-create-torch-tensor-without-any-wasted-storage-space-baggage/131134

什么是PyTorch context? 其实官方给他的称呼是CUDA context,就是在第一次执行CUDA操作,也就是使用GPU的时候所需要创建的维护设备间工作的一些相关信息。如下图所示

这个值跟CUDA的版本,pytorch的版本以及所使用的设备都是有关系的。目前我在ubuntu的torch1.9上测过RTX 3090和V100的context 开销。其中3090用的CUDA 11.4,开销为1639MB;V100用的CUDA 10.2,开销为1351MB。

感兴趣的同学可以在shell中执行下面这两行代码,然后用nvidia-smi去看看自己的环境里context的大小。然后用总大小减去context的大小再做显存分析

import torch
temp = torch.tensor([1.0]).cuda()

我估计会有人问怎么去减小这个开销...官方也给了一个办法,看看自己有哪些cuda依赖是不需要的,比如cuDNN,然后自己重新编译一遍PyTorch。编译的时候把对应的包的flag给设为false就好了。我是还没有试过,要搭编译的环境太难受了,而且还要经常和库做更新。

3. PyTorch显存分配机制

在PyTorch中,显存是按页为单位进行分配的,这可能是CUDA设备的限制。就算我们只想申请4字节的显存,pytorch也会先向CUDA设备申请2MB的显存到自己的cache区中,然后pytorch再为我们分配512字节或者1024字节的空间。这个在使用torch.cuda.memory_allocated()的时候可以看出来512字节;用torch.cuda.memory_cached()可以看出向CUDA申请的2MB。直观点来说,看图吧,PyTorch的显存管理是一个层级结构。

PyTorch 层级显存管理

然后他们又是包含于被包含的关系,即PyTorch Allocated memory使用的是PyTorch Cached Memory里的显存,PyTorch Cached Memory则用的是GPU的显存。

关于Reserved_Memory的具体的分配逻辑,在这里写的很清楚了↓

https://github.com/pytorch/pytorch/blob/master/c10/cuda/CUDACachingAllocator.cpp

总结一下:

  1. pytorch中的reserved_memory以block的形式存在。
  2. 一个allocation的显存被释放后,他所在的block可以被重新被allocate.
  3. 分配器尝试寻找能满足requested size的最小cached block,如果这个block 的大小大于requested size,那么这个block可以被split. 如果没有block了,那么分配器就会调用cudaMalloc向CUDA设备申请显存。
  4. 如果cudaMalloc失败了,分配器会先尝试释放掉一个足够大的,且没有被split的cached block,并重新尝试allocate。
  5. 大于1MB的allocation和小于等于1MB的allocation会被存储在不同的pool中。小的请求会放进2MB的buffer里,大的请求会先尝试使用最小的可用free block,或者用cudaMalloc申请一个新的block。
  6. 为了减小碎片化,在所有可用block都没有充足的大小的时候,1MB到10MB的allocation会使allocator申请一个20MB的block,并在上面进行split;为了进一步减小碎片化,大于200MB的块则不能够被split。大于200MB的超大cached blocks仍可满足小于20MB的请求。

4. PyTorch显存释放机制

感兴趣的可以去看官方文档原文:

https://pytorch.org/docs/1.10.0/notes/cuda.html

CUDA semantics - PyTorch 1.10.0 documentation

https://pytorch.org/docs/1.10.0/notes/cuda.html

简单总结一下,就是在PyTorch中,只要一个Tensor对象在后续不会再被使用,那么PyTorch就会自动回收该Tensor所占用的显存,并以缓冲区的形式继续占用显存。

要是实在看缓冲区不爽的话,也可以用torch.cuda.empty_cache()把它减少,或者加一个环境变量PYTORCH_NO_CUDA_MEMORY_CACHING=1,但是程序速度会变慢哦,(曾经试过,在我的一个实验里慢了3倍)。不得不说层级存储结构yyds。

3 训练过程显存分析

为了让大家方便理解,我这里用torch.nn.Linear(1024, 1024, bias=False) 来做例子。为了省事,loss函数则直接对输出的样本进行求和得到。没办法,想直接执行loss.backward()的话,loss得是标量才行呢。

示例代码:

import torch
model = torch.nn.Linear(1024,1024, bias=False).cuda() 
optimizer = torch.optim.AdamW(model.parameters())
inputs = torch.tensor([1.0]*1024).cuda() # shape = (1024)
outputs = model(inputs) # shape = (1024)
loss = sum(outputs) # shape = (1)
loss.backward()
optimizer.step()

1. 模型的定义

结论:显存占用量约为参数量乘以4

import torch
model = torch.nn.Linear(1024,1024, bias=False).cuda() 
print(torch.cuda.memory_allocated())

打印出来的数值为4194304,刚好等于1024×1024×4。

2. 前向传播过程

结论:显存增加等于每一层模型产生的结果的显存之和,且跟batch_size成正比。

inputs = torch.tensor([1.0]*1024).cuda() # shape = (1024)  memory + 4096
outputs = model(inputs) # memory + 4096

代码中,outputs为产生的中间激活值,同时它也恰好是该模型的输出结果。在执行完这一步之后,显存增加了4096字节。(不算inputs的显存的话)。

3. 后向传播过程

后向传播会将模型的中间激活值给消耗并释放掉掉,并为每一个模型中的参数计算其对应的梯度。在第一次执行的时候,会为模型参数分配对应的用来存储梯度的空间。

loss = sum(outputs) # memory + 512(torch allocate分配最小单位)
temp = torch.cuda.memory_allocated()
loss.backward()
print(torch.cuda.memory_allocated() - temp) # 第一次增加4194304

第一次执行时显存增加: 4194304字节 - 激活值大小

第二次以后执行显存减少: 激活值大小

Note: 由于这个中间激活值被赋给了outputs,所以后面在后向传播的时候会发现,这个outputs的显存没有被释放掉。但是当层数变深的时候,就能明显看到变化了。

为了让大家看到变化,再写一段代码~

import torch

# 模型初始化linear1 = torch.nn.Linear(1024,1024, bias=False).cuda() # + 4194304print(torch.cuda.memory_allocated())linear2 = torch.nn.Linear(1024, 1, bias=False).cuda() # + 4096print(torch.cuda.memory_allocated())
# 输入定义inputs = torch.tensor([[1.0]*1024]*1024).cuda() # shape = (1024,1024) # + 4194304print(torch.cuda.memory_allocated())
# 前向传播loss = sum(linear2(linear1(inputs))) # shape = (1) # memory + 4194304 + 512print(torch.cuda.memory_allocated())
# 后向传播loss.backward() # memory - 4194304 + 4194304 + 4096print(torch.cuda.memory_allocated())
# 再来一次~loss = sum(linear2(linear1(inputs))) # shape = (1) # memory + 4194304  (512没了,因为loss的ref还在)print(torch.cuda.memory_allocated())loss.backward() # memory - 4194304print(torch.cuda.memory_allocated())

4. 参数更新

optimizer.step() # 第一次增加8388608,第二次就不增不减了哦
print(torch.cuda.max_memory_allocated()) # = torch.memory_allocated + 8388608

第一次执行时,会为每一个参数初始化其优化器状态,对于这里的AdamW而言,每一个参数需要4*2=8个字节。

第二次开始,不会再额外分配显存。

显存开销:

第一次: 增加8388608字节

第二次及以后: 无增减

5. Note

由于计算机计算的特性,有一些计算操作在计算过程中是会带来额外的显存开销的。但是这种开销在torch.memory_allocated中是不能被察觉的。

比如在AdamW在进行某一层的更新的时候,会带来2倍该层参数量大小的临时额外开销。这个在max_memory_allocated中可以看到。在本例中就是8388608字节。

情感分类实战

词表映射Vocab

from collections import defaultdict


# 词表映射
class Vocab:
    def __init__(self, tokens=None):
        # 索引对应token的列表
        self.idx_to_token = list()
        # token和索引的词典
        self.token_to_idx = dict()

        if tokens is not None:
            # 在tokens列表后加标记<unk>来分辨未登录词
            if '<unk>' not in tokens:
                tokens = tokens + ['<unk>']
            for token in tokens:
                self.idx_to_token.append(token)
                # 下标从0开始
                self.token_to_idx[token] = len(self.idx_to_token) - 1
            self.unk = self.token_to_idx['<unk>']

    @classmethod
    def build(cls, text, min_freq=1, reserved_tokens=None):
        # 若查找不存在,返回0
        token_freqs = defaultdict(int)
        for sentence in text:
            for token in sentence:
                token_freqs[token] += 1
        # 不重复tokens列表
        uniq_tokens = ['<unk>'] + (reserved_tokens if reserved_tokens else [])
        uniq_tokens += [token for token, freq in token_freqs.items() if freq >= min_freq and token != '<unk>']
        return cls(uniq_tokens)

    def __len__(self):
        # 返回词表的大小,即此表中有多少个互不相同的标记
        return len(self.idx_to_token)

    def __getitem__(self, item):
        # 查找输入标记对应的索引值,如果该标记不存在,则返回标记<unk>的索引值(0)
        return self.token_to_idx.get(item, self.unk)

    def convert_token_to_idx(self, tokens):
        # 查找一系列输入标记对应的索引值
        return [self.token_to_idx[token] for token in tokens]

    def convert_idx_to_token(self, idxs):
        # 查找一系列索引值对应的标记
        return [self.idx_to_token[idx] for idx in idxs]

Embedding层

将一个词(或者标记)转换为一个低维、稠密、连续的词向量(也称Embed-ding)是一种基本的词表示方法,通过torch.nn包提供的Embedding层即可实现该功能。

创建Embedding对象时,需要提供两个参数,分别是num_embeddings,即词表的大小;以及embedding_dim,即Embedding向量的维度。

embedding = nn.Embedding(8,3) # 词表大小为8,Embedding向量维度为3
input = torch.tensor([[0,1,2,1],[4,6,6,7]], dtype=torch.long)
# 输入形状为(2,4)的整数张量(相当于两个长度为4的整数序列)
# 其中每个整数范围在0-7
output = embedding(input) #调用Embedding对象
print(output) # 输出结果,其中将相同的整数映射为相同的向量

融入词向量层的MLP

import torch
from torch import nn
from torch.nn import functional as F

class MLP(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_class):
        super(MLP, self).__init__()
        """
        词袋(Bag-Of-Words,BOW)模型解决该问题。
        词袋模型指的是在表示序列时,不考虑其中元素的顺序,
        而是将其简单地看成是一个集合"""
        # 词嵌入层
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        # 线性变换:词嵌入层->隐含层
        self.linear1 = nn.Linear(embedding_dim, hidden_dim)
        # 使用ReLU激活函数
        self.activate = F.relu
        # 线性变换:激活层->输出层
        self.linear2 = nn.Linear(hidden_dim, num_class)

    def forward(self, inputs):
        embeddings = self.embedding(inputs)
        # 将序列中多个embedding进行聚合(此处是求平均值)
        embedding = embeddings.mean(dim=1)
        hidden = self.activate(self.linear1(embedding))
        outputs = self.linear2(hidden)
        # 获得每个序列属于某一类别概率的对数值
        probs = F.log_softmax(outputs, dim=1)
        return probs

mlp = MLP(vocab_size=8, embedding_dim=3, hidden_dim=5, num_class=2)
# 输入为两个长度为4的整数序列
inputs = torch.tensor([[0, 1, 2, 1], [4, 6, 6, 7]], dtype=torch.long)
outputs = mlp(inputs)
# 最终的输出结果为每个序列属于某一类别概率的对数值。
print(outputs)
#tensor([[-0.9175, -0.5100],
#        [-0.8011, -0.5957]], grad_fn=<LogSoftmaxBackward>)

词表映射 -> 词向量层 -> 融入词向量层的多层感知器 -> 数据处理

EmbeddingBag

由于在一个batch中输入的文本长度往往是不固定的,因此无法像上面的代码一样简单地用一个张量存储词向量并求平均值。

在调用Embedding-Bag层时,首先需要将不定长的序列拼接起来,然后使用一个偏移向量Offsets记录每个序列的起始位置。举个例子,假设一个批次中有4个序列,长度分别为4、5、3和6,将这些长度值构成一个列表,并在前面加入0(第一个序列的偏移量),构成列表offsets=[0,4,5,3,6],然后使用语句torch.tensor(offsets [:-1])获得张量[0,4,5,3],后面紧接着执行cumsum(dim=0)方法(累加),获得新的张量[0,4,9,12],这就是最终每个序列起始位置的偏移向量。

import torch
import torch.nn as nn
input1 = torch.tensor([0, 1, 2, 1], dtype=torch.long)
input2 = torch.tensor([2, 1, 3, 7, 5], dtype=torch.long)
input3 = torch.tensor([6, 4, 2], dtype=torch.long)
input4 = torch.tensor([1, 3, 4, 3, 5, 7], dtype=torch.long)
inputs = [ input1, input2, input3, input4]
offsets = [0] + [i.shape[0] for i in inputs]
print (offsets)
# [0, 4, 5, 3, 6]
offsets = torch.tensor(offsets [:-1]).cumsum(dim=0)
print (offsets)
# tensor([ 0, 4, 9, 12])
inputs = torch.cat(inputs)
print (inputs)
# tensor([0, 1, 2, 1, 2, 1, 3, 7, 5, 6, 4, 2, 1, 3, 4, 3, 5, 7])
embeddingbag = nn.EmbeddingBag(num_embeddings=8, embedding_dim=3)
embeddings = embeddingbag (inputs, offsets)
print (embeddings)
# tensor([[ 0.6831, 0.7053, -0.5219],
# [ 1.3229, 0.2250, -0.8824],
# [-1.3862, -0.4153, -0.5707],
# [1.3530, 0.1803, -0.7379]], grad_fn=<EmbeddingBagBackward>)

数据处理

使用NLTK提供的sentence_polarity句子倾向性数据。

import torch
from vocab import Vocab

def load_sentence_polarity():
    from nltk.corpus import sentence_polarity

    vocab = Vocab.build(sentence_polarity.sents())

    train_data = [(vocab.convert_tokens_to_ids(sentence), 0)
                  for sentence in sentence_polarity.sents(categories='pos')[:4000]] \
        + [(vocab.convert_tokens_to_ids(sentence), 1)
            for sentence in sentence_polarity.sents(categories='neg')[:4000]]

    test_data = [(vocab.convert_tokens_to_ids(sentence), 0)
                 for sentence in sentence_polarity.sents(categories='pos')[4000:]] \
        + [(vocab.convert_tokens_to_ids(sentence), 1)
            for sentence in sentence_polarity.sents(categories='neg')[4000:]]

    return train_data, test_data, vocab

通过以上数据处理非常的不方便,因此PyTorch提供了DataLoader类。下面我创建一个BowDataset子类继承DataLoader,其中Bow是词袋的意思。

class BowDataset(Dataset):
    def __init__(self, data):
        self.data = data
    def __len__(self):
        return len(self.data)
    def __getitem__(self, i):
        return self.data[i]
# collate_fn参数指向一个函数,用于对一个批次的样本进行整理,如将其转换为张量等。具体代码如下。
def collate_fn(examples):
    inputs = [torch.tensor(ex[0]) for ex in examples]
    targets = torch.tensor([ex[1] for ex in examples], dtype=torch.long)
    offsets = [0] + [i.shape[0] for i in inputs]
    offsets = torch.tensor(offsets[:-1]).cumsum(dim=0)
    inputs = torch.cat(inputs)
    return inputs, offsets, targets

基于MLP的情感分类

import torch
from torch import nn, optim
from torch.nn import functional as F
from torch.utils.data import Dataset, DataLoader
from collections import defaultdict
from vocab import Vocab
from utils import load_sentence_polarity

class BowDataset(Dataset):
    def __init__(self, data):
        self.data = data
    def __len__(self):
        return len(self.data)
    def __getitem__(self, i):
        return self.data[i]

def collate_fn(examples):
    inputs = [torch.tensor(ex[0]) for ex in examples]
    targets = torch.tensor([ex[1] for ex in examples], dtype=torch.long)
    offsets = [0] + [i.shape[0] for i in inputs]
    offsets = torch.tensor(offsets[:-1]).cumsum(dim=0)
    inputs = torch.cat(inputs)
    return inputs, offsets, targets

class MLP(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_class):
        super(MLP, self).__init__()
        self.embedding = nn.EmbeddingBag(vocab_size, embedding_dim)
        self.linear1 = nn.Linear(embedding_dim, hidden_dim)
        self.activate = F.relu
        self.linear2 = nn.Linear(hidden_dim, num_class)
    def forward(self, inputs, offsets):
        embedding = self.embedding(inputs, offsets)
        hidden = self.activate(self.linear1(embedding))
        outputs = self.linear2(hidden)
        log_probs = F.log_softmax(outputs, dim=1)
        return log_probs

# tqdm是一个Python模块,能以进度条的方式显示迭代的进度
from tqdm.auto import tqdm

# 超参数设置
embedding_dim = 128
hidden_dim = 256
num_class = 2
batch_size = 32
num_epoch = 5

# 加载数据
train_data, test_data, vocab = load_sentence_polarity()
train_dataset = BowDataset(train_data)
test_dataset = BowDataset(test_data)
train_data_loader = DataLoader(train_dataset, batch_size=batch_size, collate_fn=collate_fn, shuffle=True)
test_data_loader = DataLoader(test_dataset, batch_size=1, collate_fn=collate_fn, shuffle=False)

# 加载模型
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = MLP(len(vocab), embedding_dim, hidden_dim, num_class)
model.to(device) # 将模型加载到CPU或GPU设备

#训练过程
nll_loss = nn.NLLLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001) # 使用Adam优化器

model.train()
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(train_data_loader, desc=f"Training Epoch {epoch}"):
        inputs, offsets, targets = [x.to(device) for x in batch]
        log_probs = model(inputs, offsets)
        loss = nll_loss(log_probs, targets)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Loss: {total_loss:.2f}")

# 测试过程
acc = 0
for batch in tqdm(test_data_loader, desc=f"Testing"):
    inputs, offsets, targets = [x.to(device) for x in batch]
    with torch.no_grad():
        output = model(inputs, offsets)
        acc += (output.argmax(dim=1) == targets).sum().item()

# 输出在测试集上的准确率
print(f"Acc: {acc / len(test_data_loader):.2f}")

基于CNN的情感分类

class CnnDataset(Dataset):
    def __init__(self, data):
        self.data = data
    def __len__(self):
        return len(self.data)
    def __getitem__(self, i):
        return self.data[i]

def collate_fn(examples):
    inputs = [torch.tensor(ex[0]) for ex in examples]
    targets = torch.tensor([ex[1] for ex in examples], dtype=torch.long)
    # 对batch内的样本进行padding,使其具有相同长度
    # pad_sequence函数实现补齐(Padding)功能,
    #使得一个批次中全部序列长度相同(同最大长度序列),不足的默认使用0补齐。
    inputs = pad_sequence(inputs, batch_first=True)
    return inputs, targets
# 模型不同,需要从nn.Module类派生一个CNN子类
# CNN额外需要传入的参数,filter_size: 卷积核大小, num_filter:卷积核个数
class CNN(nn.Module):
    def __init__(self, vocab_size, embedding_dim, filter_size, num_filter, num_class):
        super(CNN, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.conv1d = nn.Conv1d(embedding_dim, num_filter, filter_size, padding=1)
        self.activate = F.relu
        self.linear = nn.Linear(num_filter, num_class)
    def forward(self, inputs):
        embedding = self.embedding(inputs)
        convolution = self.activate(self.conv1d(embedding.permute(0, 2, 1)))
        pooling = F.max_pool1d(convolution, kernel_size=convolution.shape[2])
        outputs = self.linear(pooling.squeeze(dim=2))
        log_probs = F.log_softmax(outputs, dim=1)
        return log_probs

#tqdm是一个Pyth模块,能以进度条的方式显示迭代的进度
from tqdm.auto import tqdm

#超参数设置
embedding_dim = 128
hidden_dim = 256
num_class = 2
batch_size = 32
num_epoch = 5
filter_size = 3
num_filter = 100

#加载数据
train_data, test_data, vocab = load_sentence_polarity()
train_dataset = CnnDataset(train_data)
test_dataset = CnnDataset(test_data)
train_data_loader = DataLoader(train_dataset, batch_size=batch_size, collate_fn=collate_fn, shuffle=True)
test_data_loader = DataLoader(test_dataset, batch_size=1, collate_fn=collate_fn, shuffle=False)

#加载模型
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = CNN(len(vocab), embedding_dim, filter_size, num_filter, num_class)
model.to(device) #将模型加载到CPU或GPU设备

#训练过程
nll_loss = nn.NLLLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001) #使用Adam优化器

model.train()
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(train_data_loader, desc=f"Training Epoch {epoch}"):
        inputs, targets = [x.to(device) for x in batch]
        log_probs = model(inputs)
        loss = nll_loss(log_probs, targets)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Loss: {total_loss:.2f}")

#测试过程
acc = 0
for batch in tqdm(test_data_loader, desc=f"Testing"):
    inputs, targets = [x.to(device) for x in batch]
    with torch.no_grad():
        output = model(inputs)
        acc += (output.argmax(dim=1) == targets).sum().item()

#输出在测试集上的准确率
print(f"Acc: {acc / len(test_data_loader):.2f}")

基于LSTM的情感分类

class LstmDataset(Dataset):
    def __init__(self, data):
        self.data = data
    def __len__(self):
        return len(self.data)
    def __getitem__(self, i):
        return self.data[i]

def collate_fn(examples):
    lengths = torch.tensor([len(ex[0]) for ex in examples])
    inputs = [torch.tensor(ex[0]) for ex in examples]
    targets = torch.tensor([ex[1] for ex in examples], dtype=torch.long)
    # 对batch内的样本进行padding,使其具有相同长度
    inputs = pad_sequence(inputs, batch_first=True)
    return inputs, lengths, targets

class LSTM(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_class):
        super(LSTM, self).__init__()
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True)
        self.output = nn.Linear(hidden_dim, num_class)

    def forward(self, inputs, lengths):
        embeddings = self.embeddings(inputs)
        #lengths必须加载到cpu中
        x_pack = pack_padded_sequence(embeddings, lengths.cpu(), batch_first=True, enforce_sorted=False)
        hidden, (hn, cn) = self.lstm(x_pack)
        outputs = self.output(hn[-1])
        log_probs = F.log_softmax(outputs, dim=-1)
        return log_probs

基于Transformer 的情感分类

class PositionalEncoding(nn.Module):
    def __init__(self, d_model, dropout=0.1, max_len=512):
        super(PositionalEncoding, self).__init__()

        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0).transpose(0, 1)
        # 位置编码不进行参数更新
        self.register_buffer('pe', pe)

    def forward(self, x):
        x = x + self.pe[:x.size(0), :]
        return x

class Transformer(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_class,
                 dim_feedforward=512, num_head=2, num_layers=2, dropout=0.1, max_len=128, activation: str = "relu"):
        super(Transformer, self).__init__()
        # 词嵌入层
        self.embedding_dim = embedding_dim
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        # 位置编码层
        self.position_embedding = PositionalEncoding(embedding_dim, dropout, max_len)
        # 编码层:使用Transformer
        encoder_layer = nn.TransformerEncoderLayer(hidden_dim, num_head, dim_feedforward, dropout, activation)
        self.transformer = nn.TransformerEncoder(encoder_layer, num_layers)
        # 输出层
        self.output = nn.Linear(hidden_dim, num_class)


    def forward(self, inputs, lengths):
        inputs = torch.transpose(inputs, 0, 1)
        hidden_states = self.embeddings(inputs)
        hidden_states = self.position_embedding(hidden_states)
        attention_mask = length_to_mask(lengths) == False
        hidden_states = self.transformer(hidden_states, src_key_padding_mask=attention_mask)
        hidden_states = hidden_states[0, :, :]
        output = self.output(hidden_states)
        log_probs = F.log_softmax(output, dim=1)
        return log_probs
# 准确率为67%左右

词性标注实战

基于LSTM的词性标注

WEIGHT_INIT_RANGE = 0.1

class LstmDataset(Dataset):
    def __init__(self, data):
        self.data = data

    def __len__(self):
        return len(self.data)

    def __getitem__(self, i):
        return self.data[i]

def collate_fn(examples):
    lengths = torch.tensor([len(ex[0]) for ex in examples])
    inputs = [torch.tensor(ex[0]) for ex in examples]
    targets = [torch.tensor(ex[1]) for ex in examples]
    inputs = pad_sequence(inputs, batch_first=True, padding_value=vocab["<pad>"])
    targets = pad_sequence(targets, batch_first=True, padding_value=vocab["<pad>"])
    return inputs, lengths, targets, inputs != vocab["<pad>"]


def init_weights(model):
    for param in model.parameters():
        torch.nn.init.uniform_(param, a=-WEIGHT_INIT_RANGE, b=WEIGHT_INIT_RANGE)

class LSTM(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_class):
        super(LSTM, self).__init__()
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True)
        self.output = nn.Linear(hidden_dim, num_class)
        init_weights(self)

    def forward(self, inputs, lengths):
        embeddings = self.embeddings(inputs)
        #这里的lengths必须载入cpu
        x_pack = pack_padded_sequence(embeddings, lengths.cpu(), batch_first=True, enforce_sorted=False)
        hidden, (hn, cn) = self.lstm(x_pack)
        hidden, _ = pad_packed_sequence(hidden, batch_first=True)
        outputs = self.output(hidden)
        log_probs = F.log_softmax(outputs, dim=-1)
        return log_probs

embedding_dim = 128
hidden_dim = 256
batch_size = 32
num_epoch = 5

#加载数据
train_data, test_data, vocab, pos_vocab = load_treebank()
train_dataset = LstmDataset(train_data)
test_dataset = LstmDataset(test_data)
train_data_loader = DataLoader(train_dataset, batch_size=batch_size, collate_fn=collate_fn, shuffle=True)
test_data_loader = DataLoader(test_dataset, batch_size=1, collate_fn=collate_fn, shuffle=False)

num_class = len(pos_vocab)

#加载模型
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = LSTM(len(vocab), embedding_dim, hidden_dim, num_class)
model.to(device) #将模型加载到GPU中(如果已经正确安装)

#训练过程
nll_loss = nn.NLLLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001) #使用Adam优化器

model.train()
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(train_data_loader, desc=f"Training Epoch {epoch}"):
        inputs, lengths, targets, mask = [x.to(device) for x in batch]
        log_probs = model(inputs, lengths)
        loss = nll_loss(log_probs[mask], targets[mask])
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Loss: {total_loss:.2f}")

#测试过程
acc = 0
total = 0
for batch in tqdm(test_data_loader, desc=f"Testing"):
    inputs, lengths, targets, mask = [x.to(device) for x in batch]
    with torch.no_grad():
        output = model(inputs, lengths)
        acc += (output.argmax(dim=-1) == targets)[mask].sum().item()
        total += mask.sum().item()

#输出在测试集上的准确率
print(f"Acc: {acc / total:.2f}")
#准确率0.92

基于Transformer的词性标注

from utils import load_treebank
#tqdm是一个Pyth模块,能以进度条的方式显式迭代的进度
from tqdm.auto import tqdm

class TransformerDataset(Dataset):
    def __init__(self, data):
        self.data = data
    def __len__(self):
        return len(self.data)
    def __getitem__(self, i):
        return self.data[i]

def collate_fn(examples):
    lengths = torch.tensor([len(ex[0]) for ex in examples])
    inputs = [torch.tensor(ex[0]) for ex in examples]
    targets = [torch.tensor(ex[1]) for ex in examples]
    # 对batch内的样本进行padding,使其具有相同长度
    inputs = pad_sequence(inputs, batch_first=True, padding_value=vocab["<pad>"])
    targets = pad_sequence(targets, batch_first=True, padding_value=vocab["<pad>"])
    return inputs, lengths, targets, inputs != vocab["<pad>"]

class PositionalEncoding(nn.Module):
    def __init__(self, d_model, dropout=0.1, max_len=512):
        super(PositionalEncoding, self).__init__()

        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0).transpose(0, 1)
        self.register_buffer('pe', pe)

    def forward(self, x):
        x = x + self.pe[:x.size(0), :]
        return x

class Transformer(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_class,
                 dim_feedforward=512, num_head=2, num_layers=2, dropout=0.1, max_len=512, activation: str = "relu"):
        super(Transformer, self).__init__()
        # 词嵌入层
        self.embedding_dim = embedding_dim
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.position_embedding = PositionalEncoding(embedding_dim, dropout, max_len)
        # 编码层:使用Transformer
        encoder_layer = nn.TransformerEncoderLayer(hidden_dim, num_head, dim_feedforward, dropout, activation)
        self.transformer = nn.TransformerEncoder(encoder_layer, num_layers)
        # 输出层
        self.output = nn.Linear(hidden_dim, num_class)

    def forward(self, inputs, lengths):
        inputs = torch.transpose(inputs, 0, 1)
        hidden_states = self.embeddings(inputs)
        hidden_states = self.position_embedding(hidden_states)
        attention_mask = length_to_mask(lengths) == False
        hidden_states = self.transformer(hidden_states, src_key_padding_mask=attention_mask).transpose(0, 1)
        logits = self.output(hidden_states)
        log_probs = F.log_softmax(logits, dim=-1)
        return log_probs

embedding_dim = 128
hidden_dim = 128
batch_size = 32
num_epoch = 5

#加载数据
train_data, test_data, vocab, pos_vocab = load_treebank()
train_dataset = TransformerDataset(train_data)
test_dataset = TransformerDataset(test_data)
train_data_loader = DataLoader(train_dataset, batch_size=batch_size, collate_fn=collate_fn, shuffle=True)
test_data_loader = DataLoader(test_dataset, batch_size=1, collate_fn=collate_fn, shuffle=False)

num_class = len(pos_vocab)

#加载模型
# device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = Transformer(len(vocab), embedding_dim, hidden_dim, num_class)
model#将模型加载到GPU中(如果已经正确安装)

#训练过程
nll_loss = nn.NLLLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001) #使用Adam优化器

model.train()
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(train_data_loader, desc=f"Training Epoch {epoch}"):
        inputs, lengths, targets, mask = [x for x in batch]
        log_probs = model(inputs, lengths)
        loss = nll_loss(log_probs[mask], targets[mask])
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Loss: {total_loss:.2f}")

#测试过程
acc = 0
total = 0
for batch in tqdm(test_data_loader, desc=f"Testing"):
    inputs, lengths, targets, mask = [x for x in batch]
    with torch.no_grad():
        output = model(inputs, lengths)
        acc += (output.argmax(dim=-1) == targets)[mask].sum().item()
        total += mask.sum().item()

#输出在测试集上的准确率
print(f"Acc: {acc / total:.2f}")
# acc:0.82

简单的词向量预训练

预训练任务

  • 基本任务就是根据上下文预测下一时刻词: $P\left(w_{t} \mid w_{1} w_{2} \ldots w_{t-1}\right) $
  • 这种监督信号来自于数据自身,因此称为 自监督学习

前馈神经网络预训练词向量

  • 输入层→词向量层→隐含层→输出层
  • 训练后,词向量矩阵$ \boldsymbol{E} \in \mathbb{R}^{d \times|\mathbb{V}|}$即为预训练得到的静态词向量。
class NGramDataset(Dataset):
    def __init__(self, corpus, vocab, context_size=2):
        # context_size:上下文大小为2
        self.data = []
        self.bos = vocab[BOS_TOKEN] # 句首标记id
        self.eos = vocab[EOS_TOKEN] # 句尾标记id
        for sentence in tqdm(corpus, desc="Dataset Construction"):
            # 插入句首、句尾标记符
            sentence = [self.bos] + sentence + [self.eos]
            # 如句子长度小于预定义的上下文大小,则跳过
            if len(sentence) < context_size:
                continue
            for i in range(context_size, len(sentence)):
                # 模型输入:长为context_size的上文
                context = sentence[i-context_size:i]
                # 模型输出:当前词
                target = sentence[i]
                # 每个训练样本由(context,target)构成
                self.data.append((context, target))

    def __len__(self):
        return len(self.data)

    def __getitem__(self, i):
        return self.data[i]

    def collate_fn(self, examples):
        # 从独立样本集合中构建batch的输入输出,并转换为PyTorch张量类型
        inputs = torch.tensor([ex[0] for ex in examples], dtype=torch.long)
        targets = torch.tensor([ex[1] for ex in examples], dtype=torch.long)
        return (inputs, targets)

class FeedForwardNNLM(nn.Module):
    def __init__(self, vocab_size, embedding_dim, context_size, hidden_dim):
        super(FeedForwardNNLM, self).__init__()
        # 词嵌入层
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        # 线性变换:词嵌入层->隐含层
        self.linear1 = nn.Linear(context_size * embedding_dim, hidden_dim)
        # 线性变换:隐含层->输出层
        self.linear2 = nn.Linear(hidden_dim, vocab_size)
        # 使用ReLU激活函数
        self.activate = F.relu
        init_weights(self)

    def forward(self, inputs):
        embeds = self.embeddings(inputs).view((inputs.shape[0], -1))
        hidden = self.activate(self.linear1(embeds))
        output = self.linear2(hidden)
        # 根据输出层(logits)计算概率分布并取对数,以便于计算对数似然
        # 这里采用PyTorch库的log_softmax实现
        log_probs = F.log_softmax(output, dim=1)
        return log_probs

embedding_dim = 64
context_size = 2
hidden_dim = 128
batch_size = 1024
num_epoch = 10

# 读取文本数据,构建FFNNLM训练数据集(n-grams)
corpus, vocab = load_reuters()
# print(corpus[0])
# print(corpus.shape)

dataset = NGramDataset(corpus, vocab, context_size)
data_loader = get_loader(dataset, batch_size)

# 负对数似然损失函数
nll_loss = nn.NLLLoss()
# 构建FFNNLM,并加载至device
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = FeedForwardNNLM(len(vocab), embedding_dim, context_size, hidden_dim)
model.to(device)
# 使用Adam优化器
optimizer = optim.Adam(model.parameters(), lr=0.001)

model.train()
total_losses = []
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(data_loader, desc=f"Training Epoch {epoch}"):
        inputs, targets = [x.to(device) for x in batch]
        optimizer.zero_grad()
        log_probs = model(inputs)
        loss = nll_loss(log_probs, targets)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Loss: {total_loss:.2f}")
    total_losses.append(total_loss)

# 保存词向量(model.embeddings)
save_pretrained(vocab, model.embeddings.weight.data, "ffnnlm.vec")

模型在训练集上的损失随着迭代轮次的增加而不断减小。需要注意的是,由于训练的目标是获取词向量而不是语言模型本身,所以在以上训练过程中,并不需要以模型达到收敛状态(损失停止下降)作为训练终止条件。

训练过程中模型损失的变化曲线

循环神经网络预训练词向量

创建数据类RnnlmDataset

RnnlmDataset实现训练数据的构建和存取。

对于句子w1w2··· wn,循环神经网络的输入序列为<bos>w1w2··· wn1w2··· wn,输出序列为w1w2··· wn<eos>。与基于定长上下文的前馈神经网络语言模型不同,RNNLM的输入序列长度是动态变化的,因此在 构建批次时,需要对批次内样本进行补齐 ,使其长度一致。

class RnnlmDataset(Dataset):
    def __init__(self, corpus, vocab):
        self.data = []
        self.bos = vocab[BOS_TOKEN]
        self.eos = vocab[EOS_TOKEN]
        self.pad = vocab[PAD_TOKEN]
        for sentence in tqdm(corpus, desc="Dataset Construction"):
            # 模型输入:BOS_TOKEN, w_1, w_2, ..., w_n
            input = [self.bos] + sentence
            # 模型输出:w_1, w_2, ..., w_n, EOS_TOKEN
            target = sentence + [self.eos]
            self.data.append((input, target))

    def __len__(self):
        return len(self.data)

    def __getitem__(self, i):
        return self.data[i]

    def collate_fn(self, examples):
        # 从独立样本集合中构建batch输入输出
        inputs = [torch.tensor(ex[0]) for ex in examples]
        targets = [torch.tensor(ex[1]) for ex in examples]
        # 对batch内的样本进行padding,使其具有相同长度
        inputs = pad_sequence(inputs, batch_first=True, padding_value=self.pad)
        targets = pad_sequence(targets, batch_first=True, padding_value=self.pad)
        return (inputs, targets)

模型:选用LSTM

  • 输入层→词向量层→隐含层→输出层
class RNNLM(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim):
        super(RNNLM, self).__init__()
        # 词嵌入层
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        # 循环神经网络:这里使用LSTM
        self.rnn = nn.LSTM(embedding_dim, hidden_dim, batch_first=True)
        # 输出层
        self.output = nn.Linear(hidden_dim, vocab_size)

    def forward(self, inputs):
        embeds = self.embeddings(inputs)
        # 计算每一时刻的隐含层表示
        hidden, _ = self.rnn(embeds)
        output = self.output(hidden)
        log_probs = F.log_softmax(output, dim=2)
        return log_probs

训练

训练过程与前馈神经网络基本一致,由于输入输出序列可能较长,因此可以视情况调整批次大小(batchsize)。

embedding_dim = 64
context_size = 2
hidden_dim = 128
batch_size = 1024
num_epoch = 10

# 读取文本数据,构建FFNNLM训练数据集(n-grams)
corpus, vocab = load_reuters()
dataset = RnnlmDataset(corpus, vocab)
data_loader = get_loader(dataset, batch_size)

# 负对数似然损失函数,忽略pad_token处的损失
nll_loss = nn.NLLLoss(ignore_index=dataset.pad)
# 构建RNNLM,并加载至device
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = RNNLM(len(vocab), embedding_dim, hidden_dim)
model.to(device)
# 使用Adam优化器
optimizer = optim.Adam(model.parameters(), lr=0.001)

model.train()
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(data_loader, desc=f"Training Epoch {epoch}"):
        inputs, targets = [x.to(device) for x in batch]
        optimizer.zero_grad()
        log_probs = model(inputs)
        loss = nll_loss(log_probs.view(-1, log_probs.shape[-1]), targets.view(-1))
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Loss: {total_loss:.2f}")

save_pretrained(vocab, model.embeddings.weight.data, "rnnlm.vec")
  • 然后把词向量层参数和词表(一一对应)保存下来就是静态词向量

Word2Vec 词向量

由于之前的神经网络预训练词向量方法存在一个缺陷,就是当对t时刻词进行预测时,模型只利用了历史词序列作为输入,从而损失了与“未来”上下文之间的共现信息,因此引入Word2wec。

CBOW模型

给定一段文本,CBOW模型的基本思想是根据上下文对目标词进行预测。

对于$w_{t-2} w_{t-1}\quad w_{t} w_{t+1} w_{t+2}$,CBOW模型的任务是根据一定窗口大小内的上下文Ct(若取窗口大小为5,则Ct={wt−2, wt−1, wt+1, wt+2})对t时刻的词wt进行预测。与神经网络语言模型不同。

特点

CBOW模型不考虑上下文中单词的位置或者顺序,因此模型的输入实际上是一个“词袋”而非序列,这也是模型取名为“Continuous Bag-of-Words”的原因。

CBOW模型为上图所示的前馈神经网络结构。与一般的前馈神经网络相比,CBOW模型的隐含层只是执行对词向量层取平均的操作,而没有线性变换以及非线性激活的过程。所以,也可以认为CBOW模型是没有隐含层的,这也是CBOW模型具有高训练效率的主要原因。

输入层

**以大小为5的上下文窗口为例,在目标词的左右各取2个词作为模型输入。输入层由4个维度为词表长度|V|的独热表示向量构成。$e_{w_{i}}=[0 ; \ldots ; 1 ; \ldots 0] \in[[0,1]]^{\mathbb{V}_{\perp}}$

词向量层

输入层的每个词的独热表示经由局长$\pmb{E}\in\mathbb{R}^{d\times|\mathbb{V}|}$映射至词向量空间。\boldsymbol{v}_{w_{i}}=\boldsymbol{E} \boldsymbol{e}_{w_{i}}

Wi对应的词向量即为矩阵E中相应位置的列向量,E则为由所有词向量构成的矩阵或查找表。令Ct={Wt−k,···, Wt−1, Wt+1,···, Wt+k}表示Wt的上下文单词集合, 对Ct中所有词向量取平均操作 ,就得到了Wt的上下文表示:

$$ \boldsymbol{v}_{\mathcal{C}_{t}}=\frac{1}{\left|\mathcal{C}_{t}\right|} \sum_{w \in \mathcal{C}_{t}} \boldsymbol{v}_{w} $$

输出层

输出层参数 $\boldsymbol{E}^{\prime} \in \mathbb{R}^{\mathbb{V} \mid \times d}$

$$ P\left(w_{t} \mid \mathcal{C}_{t}\right)=\frac{\exp \left(\boldsymbol{v}_{\mathcal{C}_{t}} \cdot \boldsymbol{v}_{w_{t}}^{\prime}\right)}{\sum_{w^{\prime} \in \mathbb{V}} \exp \left(\boldsymbol{v}_{\mathcal{C}_{t}} \cdot \boldsymbol{v}_{w^{\prime}}^{\prime}\right)} $$

$v_{w_{i}}^{\prime}$是$E^{\prime}$与对应$w_{i}$的行向量。

词向量矩阵

$E和{E}^{\prime}$都可以作为词向量矩阵,他们分别表示了词在作为条件上下文或目标词时的不同性质。常用$E$也可以两者组合起来。

创建数据类CbowDataset

class CbowDataset(Dataset):
    def __init__(self, corpus, vocab, context_size=2):
        self.data = []
        self.bos = vocab[BOS_TOKEN]
        self.eos = vocab[EOS_TOKEN]
        for sentence in tqdm(corpus, desc="Dataset Construction"):
            sentence = [self.bos] + sentence+ [self.eos]
            if len(sentence) < context_size * 2 + 1:
                continue
            for i in range(context_size, len(sentence) - context_size):
                # 模型输入:左右分别取context_size长度的上下文
                context = sentence[i-context_size:i] + sentence[i+1:i+context_size+1]
                # 模型输出:当前词
                target = sentence[i]
                self.data.append((context, target))

    def __len__(self):
        return len(self.data)

    def __getitem__(self, i):
        return self.data[i]

    def collate_fn(self, examples):
        inputs = torch.tensor([ex[0] for ex in examples])
        targets = torch.tensor([ex[1] for ex in examples])
        return (inputs, targets)

模型:CBOW

class CbowModel(nn.Module):
    def __init__(self, vocab_size, embedding_dim):
        super(CbowModel, self).__init__()
        # 词嵌入层
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        # 线性变换:隐含层->输出层
        self.output = nn.Linear(embedding_dim, vocab_size)
        init_weights(self)

    def forward(self, inputs):
        embeds = self.embeddings(inputs)
        # 计算隐含层:对上下文词向量求平均
        hidden = embeds.mean(dim=1)
        output = self.output(hidden)
        log_probs = F.log_softmax(output, dim=1)
        return log_probs

训练

embedding_dim = 64
context_size = 2
hidden_dim = 128
batch_size = 1024
num_epoch = 10

# 读取文本数据,构建CBOW模型训练数据集
corpus, vocab = load_reuters()
dataset = CbowDataset(corpus, vocab, context_size=context_size)
data_loader = get_loader(dataset, batch_size)

nll_loss = nn.NLLLoss()
# 构建CBOW模型,并加载至device
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = CbowModel(len(vocab), embedding_dim)
model.to(device)
optimizer = optim.Adam(model.parameters(), lr=0.001)

model.train()
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(data_loader, desc=f"Training Epoch {epoch}"):
        inputs, targets = [x.to(device) for x in batch]
        optimizer.zero_grad()
        log_probs = model(inputs)
        loss = nll_loss(log_probs, targets)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Loss: {total_loss:.2f}")

# 保存词向量(model.embeddings)
save_pretrained(vocab, model.embeddings.weight.data, "cbow.vec")

skip-gram模型

**使用 Ct 中的每个词作为独立的上下文对目标词进行预测”, 即 $P\left(w_{t} \mid w_{t+j}\right)$

**原文献是$P\left(w_{t+j} \mid w_{t}\right)$两者等价

  • 隐藏层向量:$v_{w_{t}}=\boldsymbol{E}_{w_{t}}^{T}$
  • 输出层:参数为 $\boldsymbol{E}^{\prime} \in \mathbb{R}^{\mathbb{V} \times d}, P\left(c \mid w_{t}\right)=\frac{\exp \left(v_{w_{t}} \cdot v_{c}^{\prime}\right)}{\sum_{w^{\prime} \in \mathbb{V}} \exp \left(v_{w_{t}} \cdot v_{w^{\prime}}^{\prime}\right)}$
  • 其中$v_{w_{i}}^{\prime}$是$E^{\prime}$与对应$w_{i}$的行向量。
  • 词向量层:与CBOW相同

skip-gram Dataset

class SkipGramDataset(Dataset):
    def __init__(self, corpus, vocab, context_size=2):
        self.data = []
        self.bos = vocab[BOS_TOKEN]
        self.eos = vocab[EOS_TOKEN]
        for sentence in tqdm(corpus, desc="Dataset Construction"):
            sentence = [self.bos] + sentence + [self.eos]
            for i in range(1, len(sentence)-1):
                # 模型输入:当前词
                w = sentence[i]
                # 模型输出:一定上下文窗口大小内共现的词对
                left_context_index = max(0, i - context_size)
                right_context_index = min(len(sentence), i + context_size)
                context = sentence[left_context_index:i] + sentence[i+1:right_context_index+1]
                self.data.extend([(w, c) for c in context])

    def __len__(self):
        return len(self.data)

    def __getitem__(self, i):
        return self.data[i]

    def collate_fn(self, examples):
        inputs = torch.tensor([ex[0] for ex in examples])
        targets = torch.tensor([ex[1] for ex in examples])
        return (inputs, targets)

模型-skip-gram

class SkipGramModel(nn.Module):
    def __init__(self, vocab_size, embedding_dim):
        super(SkipGramModel, self).__init__()
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.output = nn.Linear(embedding_dim, vocab_size)
        init_weights(self)

    def forward(self, inputs):
        embeds = self.embeddings(inputs)
        output = self.output(embeds)
        log_probs = F.log_softmax(output, dim=1)
        return log_probs

参数估计与预训练任务

  • 需要估计的参数:$\boldsymbol{\theta}=\left\{\boldsymbol{E}, \boldsymbol{E}^{\prime}\right\}$

CBOW模型的负对数似然损失函数为:

$$ \mathcal{L}(\boldsymbol{\theta})=-\sum_{t=1}^{T} \log P\left(w_{t} \mid \mathcal{C}_{t}\right) $$

Skip-gram模型的负对数似然损失函数为:

$$ \mathcal{L}(\boldsymbol{\theta})=-\sum_{t=1}^{T} \sum_{-k \leq j \leq k, j \neq 0} \log P\left(w_{t+j} \mid w_{t}\right)$ $$

负采样

  • 输出层的归一化计算效率低(当词表很大的时候)
  • 样本 $(w,c)$正样本$c = W{t + j}$,对c进行若干次负采样得到:$\tilde{w}_{i}(i=1, \ldots, K)$
  • 给定当前词 w ww 与上下文词 c cc ,最大化两者共现概率;即简化为对于的二元分类问题
  • 共现:$P(D=1 \mid w, c)=\sigma\left(\boldsymbol{v}_{w} \cdot \boldsymbol{v}_{c}^{\prime}\right)$
  • 不共现
  • 对数似然改为

,其中

  • 负采样分布的选择:假设

表示从训练语料中统计得到的 Unigram 分布,目前通常会使用实际效果较好的负采样分布,如下:

负样本采样实现-skipgram

Skip-gram负采样Dataset

class SGNSDataset(Dataset):
    def __init__(self, corpus, vocab, context_size=2, n_negatives=5, ns_dist=None):
        self.data = []
        self.bos = vocab[BOS_TOKEN]
        self.eos = vocab[EOS_TOKEN]
        self.pad = vocab[PAD_TOKEN]
        for sentence in tqdm(corpus, desc="Dataset Construction"):
            sentence = [self.bos] + sentence + [self.eos]
            for i in range(1, len(sentence)-1):
                # 模型输入:(w, context) ;输出为0/1,表示context是否为负样本
                w = sentence[i]
                left_context_index = max(0, i - context_size)
                right_context_index = min(len(sentence), i + context_size)
                context = sentence[left_context_index:i] + sentence[i+1:right_context_index+1]
                context += [self.pad] * (2 * context_size - len(context))
                self.data.append((w, context))

        # 负样本数量
        self.n_negatives = n_negatives
        # 负采样分布:若参数ns_dist为None,则使用uniform分布
        self.ns_dist = ns_dist if ns_dist is not None else torch.ones(len(vocab))

    def __len__(self):
        return len(self.data)

    def __getitem__(self, i):
        return self.data[i]

    def collate_fn(self, examples):
        words = torch.tensor([ex[0] for ex in examples], dtype=torch.long)
        contexts = torch.tensor([ex[1] for ex in examples], dtype=torch.long)
        batch_size, context_size = contexts.shape
        neg_contexts = []
        # 对batch内的样本分别进行负采样
        for i in range(batch_size):
            # 保证负样本不包含当前样本中的context
            ns_dist = self.ns_dist.index_fill(0, contexts[i], .0)
            # 进行取样,multinomial 是均匀的,反正就是一定根据 ns_dist 取样
            neg_contexts.append(torch.multinomial(ns_dist, self.n_negatives * context_size, replacement=True))
        neg_contexts = torch.stack(neg_contexts, dim=0)
        return words, contexts, neg_contexts

对于每个训练(正)样本,需要根据某个负采样概率分布生成相应的负样本,同时需要保证负样本不包含当前上下文窗口内的词。这次采用的实现方式是,在构建训练数据的过程中就完成负样本的生成,这样在训练时直接读取负样本即可。这样做的优点是训练过程无须再进行负采样,因而效率较高;缺点是每次迭代使用的是同样的负样本,缺乏多样性。

模型

其中需要维护两个词向量:各维护一个 w_embedding 和 c_embedding , 然后 各设置一个 forward_w 和 forward_c, 然后将词向量矩阵和上下文向量矩阵合并作为最终的词向量矩阵,

<span class="ne-text">combined_embeds = model.w_embeddings.weight + model.c_embeddings.weight </span>之所以这么做,是因为每个词向量要包含该词作为目标词和作为上下文的两者的信息。

因为每个词向量要包含该词作为目标词和作为上下文的两者的信息

class SGNSModel(nn.Module):
    def __init__(self, vocab_size, embedding_dim):
        super(SGNSModel, self).__init__()
        # 词嵌入
        self.w_embeddings = nn.Embedding(vocab_size, embedding_dim)
        # 上下文嵌入
        self.c_embeddings = nn.Embedding(vocab_size, embedding_dim)

    def forward_w(self, words):
        w_embeds = self.w_embeddings(words)
        return w_embeds

    def forward_c(self, contexts):
        c_embeds = self.c_embeddings(contexts)
        return c_embeds

训练

def get_unigram_distribution(corpus, vocab_size):
    # 从给定语料中统计unigram概率分布
    token_counts = torch.tensor([0] * vocab_size)
    total_count = 0
    for sentence in corpus:
        total_count += len(sentence)
        for token in sentence:
            token_counts[token] += 1
    unigram_dist = torch.div(token_counts.float(), total_count)
    return unigram_dist

embedding_dim = 64
context_size = 2
hidden_dim = 128
batch_size = 1024
num_epoch = 10
n_negatives = 10 # 负采样样本的数量

# 读取文本数据
corpus, vocab = load_reuters()
# 计算unigram概率分布
unigram_dist = get_unigram_distribution(corpus, len(vocab))
# 根据unigram分布计算负采样分布: p(w)**0.75
negative_sampling_dist = unigram_dist ** 0.75
negative_sampling_dist /= negative_sampling_dist.sum()
# 构建SGNS训练数据集
dataset = SGNSDataset(
    corpus,
    vocab,
    context_size=context_size,
    n_negatives=n_negatives,
    ns_dist=negative_sampling_dist
)
data_loader = get_loader(dataset, batch_size)

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SGNSModel(len(vocab), embedding_dim)
model.to(device)
optimizer = optim.Adam(model.parameters(), lr=0.001)

model.train()
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(data_loader, desc=f"Training Epoch {epoch}"):
        words, contexts, neg_contexts = [x.to(device) for x in batch]
        optimizer.zero_grad()
        batch_size = words.shape[0]
        # 提取batch内词、上下文以及负样本的向量表示
        word_embeds = model.forward_w(words).unsqueeze(dim=2)
        context_embeds = model.forward_c(contexts)
        neg_context_embeds = model.forward_c(neg_contexts)
        # 正样本的分类(对数)似然
        context_loss = F.logsigmoid(torch.bmm(context_embeds, word_embeds).squeeze(dim=2))
        context_loss = context_loss.mean(dim=1)
        # 负样本的分类(对数)似然
        neg_context_loss = F.logsigmoid(torch.bmm(neg_context_embeds, word_embeds).squeeze(dim=2).neg())
        neg_context_loss = neg_context_loss.view(batch_size, -1, n_negatives).sum(dim=2)
        neg_context_loss = neg_context_loss.mean(dim=1)
        # 损失:负对数似然
        loss = -(context_loss + neg_context_loss).mean()
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Loss: {total_loss:.2f}")

# 合并词嵌入矩阵与上下文嵌入矩阵,作为最终的预训练词向量
combined_embeds = model.w_embeddings.weight + model.c_embeddings.weight
save_pretrained(vocab, combined_embeds.data, "sgns.vec")

Glove词向量

传统的词向量预训练本质上都是利用文本中词与词在局部上下文中的共现信息作为自监督学习信号,另一种是通过矩阵分解的方法,如SVD分解。

GloVe模型的基本思想是利用词向量对“词--上下文”共现矩阵进行预测(或者回归),从而实现隐式的矩阵分解。

  1. 构建贡献矩阵,但是限制在受限窗口大小内得贡献次数(即要使w和c的距离要足够小)
  2. 当w与c共现时,w与c之间的距离,Glove认为距较远的全共效次的贡献数较小
  3. 在获得矩阵M之后,利用词与上下文向量表示对M中的元素(取对数)进行回归计算。具体形式为

分别是w和c的向量表示(就是他们的Glove词向量),分别是相应的偏置项。

参数估计

式中,表示每一个 样本的权重,与共现次数有关。GloVe采用了以下的分段函数进行加权:

不超过某个阈值max时,的值单调递增且小于等于1,他的增长速率由α控制;当超过某个阈值max时,的值恒等于1.

Glove Dataset

class GloveDataset(Dataset):
    def __init__(self, corpus, vocab, context_size=2):
        # 记录词与上下文在给定语料中的共现次数
        self.cooccur_counts = defaultdict(float)
        self.bos = vocab[BOS_TOKEN]
        self.eos = vocab[EOS_TOKEN]
        for sentence in tqdm(corpus, desc="Dataset Construction"):
            sentence = [self.bos] + sentence + [self.eos]
            for i in range(1, len(sentence)-1):
                w = sentence[i]
                left_contexts = sentence[max(0, i - context_size):i]
                right_contexts = sentence[i+1:min(len(sentence), i + context_size)+1]
                # 共现次数随距离衰减: 1/d(w, c)
                for k, c in enumerate(left_contexts[::-1]):
                    self.cooccur_counts[(w, c)] += 1 / (k + 1)
                for k, c in enumerate(right_contexts):
                    self.cooccur_counts[(w, c)] += 1 / (k + 1)
        self.data = [(w, c, count) for (w, c), count in self.cooccur_counts.items()]

模型

GloVe模型与基于负采样的Skip-gram模型类似,唯一的区别在于 增加了两个偏置向量 ,具体代码如下。

class GloveModel(nn.Module):
    def __init__(self, vocab_size, embedding_dim):
        super(GloveModel, self).__init__()
        # 词嵌入及偏置向量
        self.w_embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.w_biases = nn.Embedding(vocab_size, 1)
        # 上下文嵌入及偏置向量
        self.c_embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.c_biases = nn.Embedding(vocab_size, 1)

    def forward_w(self, words):
        w_embeds = self.w_embeddings(words)
        w_biases = self.w_biases(words)
        return w_embeds, w_biases

    def forward_c(self, contexts):
        c_embeds = self.c_embeddings(contexts)
        c_biases = self.c_biases(contexts)
        return c_embeds, c_biases

训练

# 用以控制样本权重的超参数
m_max = 100
alpha = 0.75
# 从文本数据中构建GloVe训练数据集
corpus, vocab = load_reuters()
dataset = GloveDataset(
    corpus,
    vocab,
    context_size=context_size
)
data_loader = get_loader(dataset, batch_size)

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = GloveModel(len(vocab), embedding_dim)
model.to(device)
optimizer = optim.Adam(model.parameters(), lr=0.001)

model.train()
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(data_loader, desc=f"Training Epoch {epoch}"):
        words, contexts, counts = [x.to(device) for x in batch]
        # 提取batch内词、上下文的向量表示及偏置
        word_embeds, word_biases = model.forward_w(words)
        context_embeds, context_biases = model.forward_c(contexts)
        # 回归目标值:必要时可以使用log(counts+1)进行平滑
        log_counts = torch.log(counts)
        # 样本权重
        weight_factor = torch.clamp(torch.pow(counts / m_max, alpha), max=1.0)
        optimizer.zero_grad()
        # 计算batch内每个样本的L2损失
        loss = (torch.sum(word_embeds * context_embeds, dim=1) + word_biases + context_biases - log_counts) ** 2
        # 样本加权损失
        wavg_loss = (weight_factor * loss).mean()
        wavg_loss.backward()
        optimizer.step()
        total_loss += wavg_loss.item()
    print(f"Loss: {total_loss:.2f}")

# 合并词嵌入矩阵与上下文嵌入矩阵,作为最终的预训练词向量
combined_embeds = model.w_embeddings.weight + model.c_embeddings.weight
save_pretrained(vocab, combined_embeds.data, "glove.vec")

内部评价方法

词的相关性

def knn(W, x, k):
    #计算查询向量x与矩阵w中每一个行向量之间的余弦相似度,
    #并返回相似度最高的k个向量
    similarities = torch.matmul(x, W.transpose(1, 0)) / (torch.norm(W, dim=1) * torch.norm(x) + 1e-9)
    knn = similarities.topk(k=k)
    return knn.values.tolist(), knn.indices.tolist()

利用该函数,可实现在词向量空间内进行近义词检索。

def find_similar_words(embeds, vocab, query, k=5):
    knn_values, knn_indices = knn(embeds, embeds[vocab[query]], k + 1)
    knn_words = vocab.convert_ids_to_tokens(knn_indices)
    print(f">>> Query word: {query}")
    for i in range(k):
        print(f"cosine similarity={knn_values[i + 1]:.4f}: {knn_words[i + 1]}")

加载斯坦福大学发布的Glove预训练词向量,下载好词向量之后,使用 load_pretrained 函数进行加载,并返回词表与词向量对象。

def load_pretrained(load_path):
    with open(load_path, "r") as fin:
        # Optional: depending on the specific format of pretrained vector file
        n, d = map(int, fin.readline().split())
        tokens = []
        embeds = []
        for line in fin:
            line = line.rstrip().split(' ')
            token, embed = line[0], list(map(float, line[1:]))
            tokens.append(token)
            embeds.append(embed)
        vocab = Vocab(tokens)
        embeds = torch.tensor(embeds, dtype=torch.float)
    return vocab, embeds

在GloVe词向量空间内以“china”“august”为查询词进行近义词检索,可以得到以下结果。

类比性

对于语法或者语义关系相同的两个词,词向量在一定程度上满足

词向量空间内的语义和语法类比推理性质示例

这两个例子分别从词义和词法两个角度展示了词向量的类比性利用这个可以进行词与词之间的关系推理,回答诸如 “a 之于 b 相当于 c 之于 ?”等问题。对于下画线处的词,可以利用下式的词向量空间内搜索得到

def find_analogy(embeds, vocab, word_a, word_b, word_c):
    vecs = embeds[vocab.convert_tokens_to_ids([word_a, word_b, word_c])]
    x = vecs[2] + vecs[1] - vecs[0]
    knn_values, knn_indices = knn(embeds, x, k=1)
    analogies = vocab.convert_ids_to_tokens(knn_indices)
    print(f">>> Query: {word_a}, {word_b}, {word_c}")
    print(f"{analogies}")

上述结果显然效果欠佳,需要语料库的大小越大越好。

外部评价方法

根据下游任务的性能指标判断