PyTorch常用网络层

上一节我们使用了PyTorch的线性层(也叫全连接层)torch.nn.Linear( ),体验到了使用PyTorch封装好的函数构建神经网络的便捷。所以,这一节我们将处理一下常用的网络函数

1
2
import torch
from torch import nn

Embedding层

实现自然语言处理任务的第一个操作就是讲离散类型的单词表示为密集向量,这个过程称为“representation learning”或“embedding”:

torch.nn.Embedding(num_embeddings, embedding_dim, padding_idx=None, max_norm=None, norm_type=2, scale_grad_by_freq=False, sparse=False)

该层对应的参数其实就是一个保存了固定字典大小的简单查找表,模块的输入是一个单词id组成的列表,输出是对应的词嵌入。

1
2
3
4
5
6
7
from torch.autograd import Variable

embedding = nn.Embedding(10, 3) # 定义一个包含10个词,每个词3维的embedding层
# 输入shape=(N, w):N表示样本(句子)数,w表示一个样本(句子)中的单词数
input = Variable(torch.LongTensor([[1,2,4,5], [4,3,2,9]])) # 2个句子,每个句子4个单词(用单词id表示)
# 输出shape=(N, w, embedding_dim)
out1 = embedding(input)

全连接层

全连接层是一种最简单的网络层,它的的输入与输出形状为 [batch_size, *, size] ,即第0轴对应batch的维度,最后一个轴上的维度值对应in_features/out_features:

torch.nn.Linear(in_features, out_features, bias=True)

在初始化全连接层时,我们只需要关注输入输出的张量的最后一个轴上的维度(不用去考虑参数的形状),相当于该网络层可以自动将[batch_size, *, size=in_features]的张量变换成了[batch_size, size=out_features]的输出张量。

1
2
3
4
input = torch.from_numpy(np.array([[3.3], [4.4], [5.5], [6.71], [6.93]], dtype=np.float32))

hidden_layer = nn.Linear(1, 10) # input中数据的特征都是1维的
predict_layer = nn.Linear(10, 1) # 堆叠多个全连接层时输入输出特征维度注意对应上

Dropout层

Dropout 是在训练过程中以一定的概率的使神经元失活,即输出为0,以提高模型的泛化能力,减少过拟合。PyTorch中常用的Dropout层有如下两个:前者用于一维数据,比如 linear 的输出结果;后者主要用于二维数据,一般在卷积层后。

torch.nn.Dropout(p=0.5, inplace=False)
torch.nn.Dropout2d(p=0.5, inplace=False)

其中的两个参数,p 表示将多少数据置为 0 的概率,inplace=True 则表示进行原地操作,对输入的数据本身内存进行操作。

1
2
3
4
5
dropout = nn.Dropout(0.3)

x = hidden_layer(input)
x = dropout(torch.relu(x)) # 全连接层之后添加Dropout层
x = predict_layer(x)

在测试模型时,我们为了拿到更加确定性的结果,一般不使用Dropout层。

卷积层

常用的卷积层分为一维卷积 nn.Conv1d 和二维卷积 nn.Conv2d:

torch.nn.Conv1d(in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, groups=1, bias=True)

一维卷积多用于文本数据,只对宽度进行卷积。通常,输入大小为 word_embedding_dim * max_length,其中,word_embedding_dim 为词向量的维度,max_length 为句子的最大长度。

1
2
3
4
5
# in_channels就是词向量的维度,out_channels是卷积产生的通道数(也就是卷积核的数量),kernel_size是卷积核的尺寸(第0轴上的维度,第1轴的维度由in_channels决定)
conv1 = nn.Conv1d(in_channels=256, out_channels=100, kernel_size=2)
input = torch.randn(32, 35, 256) # batch_size=32, max_legth=35, word_embedding_dim=256
input = input.permute(0, 2, 1) # 需要进行轴变换(因为卷积操作是在最后一个轴上进行的)
output = conv1(input)

torch.nn.Conv2d(in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, groups=1, bias=True)

二维卷积nn.Conv2d用于图像数据,对宽度和高度都进行卷积。输入的尺寸为$(N, C_i, H_i, W_i)$,输出的尺寸为$(N, C_o, H_o, W_o)$:

1
2
3
4
# padding表示每一个轴方向上补零的个数,dilation控制kernel点之间的空间距离
conv2 = nn.Conv2d(16, 33, (3, 5), stride=(2, 1), padding=(4, 2), dilation=(3, 1))
input = torch.randn((20, 16, 50, 100)) # 输入的形状:[ batch_size, channels, height_1, width_1 ]
output = conv2(input)

池化层

池化层本质就是进行下采样操作的,即将 feature map 变小,常用的池化操作有最大池化平均池化

torch.nn.MaxPool1d(kernel_size, stride=None, padding=0, dilation=1, return_indices=False, ceil_mode=False)
torch.nn.AvgPool1d(kernel_size, stride=None, padding=0, ceil_mode=False, count_include_pad=True)

其中的参数和卷积层中的定义是一致的,这里不再赘述。其中 return_indices 如果等于True,会返回输出最大值的序号,对于上采样操作会有帮助;ceil_mode 如果等于True,计算输出信号大小的时候,会使用向上取整,代替默认的向下取的操作;count_include_pad 表示在计算平均值时,是否把填充值考虑在内计算。

1
2
avgpool_layer = nn.AvgPool2d((2, 2), stride=(2, 2))
img_pool = avgpool_layer(input)

池化层只是一个进行简单操作的层,没有需要学习的参数。

循环网络层

循环网络层,我们重点介绍 LSTM 和 GRU(说明部分以 LSTM 举例,GRU也是一样的):

torch.nn.LSTM(input_size, hidden_size, num_layers=1, bias=True, batch_first=False, dropout=0, bidirectional=False, proj_size=0)
torch.nn.GRU(input_size, hidden_size, num_layers=1, bias=True, batch_first=False, dropout=0, bidirectional=False)

通过设置 num_layers>1 就可以支持多层,设置 bidirectional=True 可以得到双向 LSTM。需要注意的时,默认的输入和输出形状是 (seq, batch, feature), 如果需要将 batch 维度放在第0轴,则要设置 batch_first 参数设置为 True 。我们使用一个表格来总结 LSTM 中几个变量的维度:

模型/变量 第0轴 第1轴 第2轴
torch.nn.LSTM() input_size hidden_size num_layers
$x$ seq_len batch input_size
$h_0$ num_layers×num_directions batch hidden_size
$c_0$ num_layers×num_directions batch hidden_size
$output$ seq_len batch num_directions×hidden_size
$h_n$ num_layers×num_directions batch hidden_size
$c_n$ num_layers×num_directions batch hidden_size

对句子进行 LSTM 操作,假设训练集有若干句子,每个句子里有7个词,batch_size=64,embedding_size=300,此时,各个参数为:input_size=embedding_size=300;batch=batch_size=64;seq_len=7,另外设置hidden_size=100, num_layers=1

1
2
3
4
5
6
7
8
9
lstm = nn.LSTM(300, 100, 1)
gru = nn.GRU(300, 128, 2)

x = torch.randn(7, 64, 300) # 具体维度参考上面的表格说明
h0 = torch.randn(1, 64, 100)
c0 = torch.randn(1, 64, 100)

output, (hn, cn)=lstm(x, (h0, c0)) # 如果不指定h0、c0,则使用默认全0的隐藏状态
output, h = gru(x, h0)

output 就是最后一个 layer 上,序列中每个时刻(横向)状态h的集合(若为双向则按位置拼接,输出维度 2*hidden_size ),而 $h_n$ 实际上是每个 layer 最后一个状态(纵向)输出的拼接。对于单向 LSTM 来说,$h_n[-1,:,:]$ 就是 $output[-1,:,:]$,相当于序列最后一个时间步的输出(可以用它作为整个序列的 embedding或 representation)。但双向 LSTM 推广后,每个时间步的隐层输出都可以作为当前词的一个融合了上下文的 embedding,因此 BiLSTM 可以视为一种词级别的 encoder 方法,得到的 output 既可以用于词级别的输出拼接,也可以进行融合(比如 attention 加权求和、pooling )得到序列级的输出。

通常,输入到 LSTM 或 GRU 的序列并不是等长的,需要进行 pad 补齐后才能进行批量运算,但是长短不一的文本数据单纯 pad 往往会导致最短的那句话里大量都是无用的 \ 符号,PyTorch提供了一种 压缩 pad 的机制:

torch.nn.utils.rnn.pack_padded_sequence(input, lengths, batch_first=False)

input 是我们要压缩的数据,当 batch_first 为 True,shape 的输入格式是 [ batch, seq_len, embed_dim ] ,如果 batch_first 为 False,相应的的数据格式必须是 [seq_len, batch, embed_dim ] 。lengths 是输入数据的每个序列的长度。需要注意的是,input 必须按序列长度的长短排序,长的排在前面,短的在后面。

1
2
3
4
5
6
7
8
input_seq = [[3, 5, 12, 7, 2], [4, 11, 14], [18, 7, 3, 8, 5, 4]]
lengths = [5, 3, 6] # batch中每个seq的有效长度。

# 由大到小排序
input_seq = sorted(input_seq, key = lambda tp: len(tp), reverse=True)
lengths = sorted(lengths, key = lambda tp: tp, reverse=True)

pack = nn.utils.rnn.pack_padded_sequence(embeded, lengths, batch_first=True) # 返回一个PackedSequence对象

因为有了 lengths 指示了有效长度,这样 LSTM 和 GRU 只会作用到句子的实际长度处就停止了,不会去处理无用的 \ 符号。

处理长序列时还要避免梯度消失的问题,可以调用 梯度裁剪 函数在反向传播过程中,对梯度进行裁剪,使其限制在区间 [-clipvalue, clip_value] 内(对应的函数是clip_grad_value)。或者,更柔和一些,不直接用梯度去更新参数,而是比较梯度的范数与梯度阈值,如果范数较大,则使用梯度阈值除以范数作为缩放因子乘到真实的梯度上进行更新(对应的函数是clipgrad_norm):

torch.nn.utils.clipgrad_value(parameters, clip_value)
torch.nn.utils.clipgrad_norm(parameters, max_norm, norm_type=2.0)

其中,max_norm 是梯度的最大范数;norm_type 规定范数的类型,默认为 L2 范数。

1
2
3
4
5
6
7
outputs = net(data)
loss= loss_fn(outputs, target)

optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(net.parameters(), max_norm=20, norm_type=2)
optimizer.step()

需要注意的是,PyTorch 的梯度计算是在 loss.backward() 后进行的,所以需要在 optimizer 更新参数之前,完成梯度的裁剪,最后还要记得使用 optimizer 对 net 的参数进行更新。

归一化层

归一化层最早是在图像领域引入的,常见的归一化操作包括:

  • BatchNorm:batch 方向做归一化,算 NHW 的均值,对小 batch_size 效果不好;BN 主要缺点是对 batch_size 的大小比较敏感,由于每次计算均值和方差是在一个 batch 上,所以如果 batch_size 太小,则计算的均值、方差不足以代表整个数据分布
  • LayerNorm:channel 方向做归一化,算 CHW 的均值,主要对 RNN 作用明显
  • InstanceNorm:一个channel内做归一化,算 H*W 的均值,用在风格化迁移;因为在图像风格化中,生成结果主要依赖于某个图像实例,所以对整个 batch 归一化不适合图像风格化中,因而对 HW 做归一化。可以加速模型收敛,并且保持每个图像实例之间的独立
  • GroupNorm:将 channel 方向分 group,然后每个 group 内做归一化,算 (C//G)HW 的均值;这样与 batch_size 无关,不受其约束
  • SwitchableNorm:将 BN、LN、IN 结合,赋予权重,让网络自己去学习归一化层应该使用什么方法

torch.nn.BatchNorm1d(num_features, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)

其中,num_features 来自期望输入的特征数,该期望输入的大小为 batch_size × num_features [* width];eps 是一个极小值,为保证数值稳定性(分母不能趋近或取0)给分母加上的;momentum 为动态均值和动态方差所使用的动量;affine 当设为True,给该层添加可学习的仿射变换参数;track_running_stats 当设为true,记录训练过程中的均值和方差;

torch.nn.LayerNorm(normalized_shape, eps=1e-05, elementwise_affine=True)

normalized_shape 输入尺寸为[∗ ×normalized_shape[0]×normalized_shape[1]× … ×normalized_shape[−1]];elementwise_affine 当设为true,给该层添加可学习的仿射变换参数。

1
2
3
4
5
x = torch.rand(100, 16, 784) # 随机生成一个Batch的模拟,100张16通道784像素点的数据

# Batch Normalization层,因为输入是将高度H和宽度W合成了一个维度,所以这里用1d
layer = nn.BatchNorm1d(16) # 传入通道数
out = layer(x)

需要注意的是,BN 层的统计数据更新是在每一次训练阶段的 forward( ) 方法中自动实现的,而不是在梯度计算与反向传播中更新 optim.step( ) 中完成。

Transformer层

Transformer 是一个完全基于注意力机制的模型,一个完整的 Transformer 模型是经典的 encoder-decoder 架构,采用多头自注意力实现

torch.nn.Transformer(d_model=512, nhead=8, num_encoder_layers=6, num_decoder_layers=6, dim_feedforward=2048, dropout=0.1, activation=’relu’, custom_encoder=None, custom_decoder=None)

1
2
3
4
5
6
7
transformer_model = nn.Transformer(nhead=16, num_encoder_layers=12)
src = torch.rand((10, 32, 512))
tgt = torch.rand((20, 32, 512))
out = transformer_model(src, tgt)
# forward(src, tgt, src_mask=None, tgt_mask=None, memory_mask=None, \
# src_key_padding_mask=None, tgt_key_padding_mask=None, \
# memory_key_padding_mask=None)

用作语言模型用时,对于输入的 src,还应该传入它的 mask 序列(因为从左到右做语言模型时,模型是看不到其右侧的文本的)。

torch.nn.TransformerEncoder(encoder_layer, num_layers, norm=None)
torch.nn.TransformerDecoder(decoder_layer, num_layers, norm=None)
torch.nn.TransformerEncoderLayer(d_model, nhead, dim_feedforward=2048, dropout=0.1, activation=’relu’)
torch.nn.TransformerDecoderLayer(d_model, nhead, dim_feedforward=2048, dropout=0.1, activation=’relu’)

除了完整的 Transformer 模型,PyTorch 还提供了其中的编码、解码层,方便我们灵活实现自己的网络:

1
2
3
4
5
6
7
8
9
10
11
12
13
encoder_layer = nn.TransformerEncoderLayer(d_model=512, nhead=8)
transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers=6)
src = torch.rand(10, 32, 512)
out = transformer_encoder(src)
# forward(src, src_mask=None, src_key_padding_mask=None)

decoder_layer = nn.TransformerDecoderLayer(d_model=512, nhead=8)
transformer_decoder = nn.TransformerDecoder(decoder_layer, num_layers=6)
memory = torch.rand(10, 32, 512)
tgt = torch.rand(20, 32, 512)
out = transformer_decoder(tgt, memory)
# forward(tgt, memory, tgt_mask=None, memory_mask=None, \
# tgt_key_padding_mask=None, memory_key_padding_mask=None)

这里需要重点解释一下各接口中的参数 *mask 和 *_key_padding_mask:模块出现在 encoder,* 就是 src;出现在 decoder,* 就是 tgt,decoder 每个 block 的第二层和 encoder 做 cross attention 的时候,就是 memory。*mask 对应的 API 是 attn_mask,*_key_padding_mask 对应的 API 是 key_padding_mask:

  • key_padding_mask:用来屏蔽 pad token 的 embedding 输入。形状要求:(N, S)
  • attn_mask:2维或者3维的矩阵。用来屏蔽指定位置的 embedding 输入。2维矩阵形状要求:(L, S);也支持3维矩阵输入,形状要求:(N*num_heads, L, S)

需要注意的是,使用 Transformer 模型,需要自己单独实现一个 PositionalEncoding 的操作,把位置信息加入到 token 的 embedding 中。