1.准备数据
在使用pytorch进行深度学习时,首先需要准备好数据。一般来说,数据需要按照一定的格式组织,例如可以使用Dataset和DataLoader将数据读入内存,并按照批次进行划分。
读写数据:(save&load)
-
torch.save(x, 'x.pt')
:将x存在文件名同为x.pt
的文件里。 -
将数据从存储的文件读回内存
x2 = torch.load('x.pt')
不仅是tensor.dict啥啥的数据都能这么存储.
无论是文件大小还是读写速度都完爆json
读写模型:
-
两个方式:仅保存和加载模型参数(
state_dict
)(推荐);保存和加载整个模型。差别就是,第一种方法在SL时候只SL参数,下次L时先建一个新model,然后把参数L进去就好了.
代码(slData模式):
保存:
1 | torch.save(model.state_dict(), PATH) # 推荐的文件后缀名是pt或pthCopy to clipboardErrorCopied |
加载:
1 | model = TheModelClass(*args, **kwargs) |
一个小问题在于不同设备(CPU与GPU,不同GPU之间.具体遇到问题再说吧)
2.创建模型
模型是深度学习的核心,它决定了最终学习的效果。在pytorch中,可以通过继承nn.Module类来创建模型,并在其中定义前向计算函数。
我的理解是,模型就是一个千层饼.每一层有个什么功能.下面就是一层饼的例子.
实际上并不是层数越多越好.
当神经网络的层数较多时,模型的数值稳定性容易变差:$0.99^ {365}=?;1.01^{365=?}$
如何解决?批量归一化
通过不停地tensor运算联立方程,是不是可以把千层饼等效为单层?
多层感知机的设计
如果千层饼被等效为单层,那么对层的设计就失去了意义.上面的问题在于.
全连接层只是对数据做仿射变换(affine transformation),而多个仿射变换的叠加仍然是一个仿射变换。
解决方法是我们加入一些非线性变换的层
例子:
-
ReLU(rectified linear unit)函数
$$
ReLU(x)=max(x,0).
$$ -
sigmoid
$$
sigmoid(x)= \frac 1 {1+exp(−x)}
$$
-
tanh函数(双曲正切)
$$
tanh(x)= \frac{1+exp(−2x)}{1−exp(−2x)}
$$
图像和sigmoid差不多,但是0附近更陡峭一些
一个flattenlayer的例子
这里有一个把(batchsize,*,*,…)的多维tensor转换为(batchsize,***)的二维tensor的例子.
1 | class FlattenLayer(nn.Module): |
一个模型的例子
1 | net = nn.Sequential( |
初始化模型参数:
构建好了模型,我们得有初始参数啊.
**太好了!**PyTorch中nn.Module
的模块参数都采取了较为合理的初始化策略,一般不用我们考虑;如果你真的想知道可参考pytorch源代码)
总结:
-
继承Module类
Module
类是nn
模块里提供的一个模型构造类,是所有神经网络模块的基类一般来说,我们重载
Module
类的__init__
函数和forward
函数。它们分别用于创建模型参数和定义前向计算。无须定义反向传播函数。系统将通过自动求梯度而自动生成反向传播所需的backward
函数。注意:重载
__init__
函数时应该首先调用父类module
的初始化函数e.g.1
2
3
4
5
6class MLP(nn.Module):
# 声明带有模型参数的层,这里声明了两个全连接层
def __init__(self, **kwargs):
# 调用MLP父类Module的构造函数来进行必要的初始化。这样在构造实例时还可以指定其他函数
# 参数,如“模型参数的访问、初始化和共享”一节将介绍的模型参数params
super(MLP, self).__init__(**kwargs) -
module的子类:还有一些
Sequential
,ModuleDict
,ModuleList
之类的东西.我感觉没什么用啊.好像只是让我把一堆Module以一种整齐的方式凑在一起.Sequential
好像还挺有用的,至少他要保证层之间输入输出维度匹配,可以直接forward. -
共享模型参数:
Module
类的forward
函数里多次调用同一个层,层之间参数共享。此外,如果我们传入Sequential
的模块是同一个Module
实例的话参数也是共享的真的有人在乎参数究竟是什么吗?
-
自定义层:见[flattenlayer](#### 一个flattenlayer的例子)的例子,这是一个不带参数的层,你当然也可以带参数.但意义是?
-
我想:module就是层的累加.一个头进一个头出.
人体蜈蚣.
3.定义损失函数
常见的损失函数有交叉熵损失函数、均方误差损失函数等,可以根据不同的任务选择不同的损失函数。
例子:
PyTorch提供了一个包括softmax运算和交叉熵损失计算的函数。它的数值稳定性更好。
1 loss = nn.CrossEntropyLoss()
4.定义优化器和学习率
在训练的过程中,需要使用优化器来更新模型参数。常见的优化器有梯度下降法、Adam等。同时,由于学习率对训练效果影响较大,所以需要定义学习率的初始值以及变化规则。
e.g.
我们使用学习率为0.1的小批量随机梯度下降作为优化算法。
1 optimizer = torch.optim.SGD(net.parameters(), lr=0.1)
5.训练模型
将数据、模型、损失函数、优化器和学习率等组合在一起,循环执行前向计算、损失计算、反向传播和参数更新等步骤,即可训练模型。在每个epoch结束后,还需要对模型进行评估。
在训练之前可以测试输入形状比如:
在训练ResNet之前,我们来观察一下输入形状在ResNet不同模块之间的变化。
1 | X = torch.rand((1, 1, 224, 224)) |
6.使用模型进行预测
在训练好模型后,可以使用它对新的数据进行预测,并输出预测结果或概率。
7.模型选择
我们怎么评价模型的好坏呢?
模型欠拟合与过拟合
简单地:
-
给定训练数据集,如果模型的复杂度过低,很容易出现欠拟合;如果模型复杂度过高,很容易出现过拟合。
-
过少的训练样本也会导致过拟合
训练误差(training error)和泛化误差(generalization error)
当模型在训练数据集上更准确时,它在测试数据集上却不一定更准确。这是为什么呢?
以高考为例来直观地解释训练误差和泛化误差这两个概念。训练误差可以认为是做往年高考试题(训练题)时的错误率,泛化误差则可以通过真正参加高考(测试题)时的答题错误率来近似。
训练的时候是根据往年题的表现来的(通过减小训练误差).所以往年题做的好不一定代表着高考考的好.(一般来说训练误差的期望$\leq$泛化误差)
但我们关注的是泛化误差
训练集\验证集\测试集
高考只能考一次,我们没有办法从训练集(看着答案做题)中知道自己的高考表现.所以我们可以拿一些测试集训练集之外的数据作为验证集(模考).
操作上,我们可以从给定的训练集中随机选取一小部分作为验证集,而将剩余部分作为真正的训练集。
K折交叉验证(K-K-fold cross-validation)
分出K个子集来.这样可以测K次
但实际上,由于数据不容易获取,测试数据极少只使用一次就丢弃。因此,实践中验证数据集和测试数据集的界限可能比较模糊.
应对过拟合:
1.权重衰减(weight decay)
可以定义多个优化器实例对不同的模型参数使用不同的迭代方法。
权重衰减等价于$L_2$范数正则化(regularization)
我的理解:就是在计算损失函数的时候加上一项$λ\times L_2$其中λ是一个超参。$L_2$:参数权重越大,$L_2$越大。这样大概保证了各参数大小都差不多,不偏科。
实现代码:
直接在构造优化器实例时通过weight_decay
参数来指定权重衰减超参数。默认下,PyTorch会对权重和偏差同时衰减。我们可以分别对权重和偏差构造优化器实例,从而只对权重衰减。
1 | optimizer_w = torch.optim.SGD(params=[net.weight], lr=lr, weight_decay=wd) # 对权重参数衰减 |
2.丢弃法(dropout)
在隐藏层随机丢弃一个单元.比如上图,隐藏层原来有$h_1,h_2,…,h_5.h_2,h_5 $被丢弃了.
这样计算时不会过度依赖某一个隐藏单元
-
(和权重衰减的目的大概是一样的),都是保证不偏科.要不然模考物理总是很难,就你一个考100,人家都是0分.到了高考一赋分大家都考90,你傻眼了.
实现:
在PyTorch中,我们只需要在全连接层后添加Dropout
层并指定丢弃概率。在训练模型时,Dropout
层将以指定的丢弃概率随机丢弃上一层的输出元素
在测试模型时(即model.eval()
后),Dropout
层并不发挥作用。
1 | net = nn.Sequential( |
丢弃法只在训练模型时使用。
CNN
卷积层的输入和输出通常是四维数组(样本,通道,高,宽),而全连接层的输入和输出则通常是二维数组(样本,特征)。
0.前置知识
在net里加上一个卷积层就形成了CNN
一个小翻译:Kernel
在pytorch的卷积计算时就叫做filter
.
CNN的学习就是学习这个kernel
,(相应的,上面在学习一个参数tensor)
那个经典的图像边缘检测实际上在互相关.kernel走一步,算一圈,下个蛋…
卷积运算与互相关运算类似。为了得到卷积运算的输出,我们只需将核数组左右翻转并上下翻转,再与输入数组做互相关运算.有什么区别?**没有区别!**反正都是学出来的,学出来的核再上下左右转换一下那么两种运算就交换了.所以根本不需要知道什么是卷积就能CNN.
优势?
考虑图像分类问题。每张图像高和宽均是28像素。我们将图像中的像素逐行展开,得到长度为784的向量,并输入进全连接层中。然而,这种分类方法有一定的局限性。
-
图像在同一列邻近的像素在这个向量中可能相距较远。它们构成的模式可能难以被模型识别。
-
对于大尺寸的输入图像,使用全连接层容易造成模型过大。假设输入是高和宽均为1000像素的彩色照片(含3个通道)。即使全连接层输出个数仍是256,该层权重参数的形状是3,000,000×2563,000,000×256:它占用了大约3 GB的内存或显存。这带来过复杂的模型和过高的存储开销。
一方面,卷积层保留输入形状,使图像的像素在高和宽两个方向上的相关性均可能被有效识别;另一方面,卷积层通过滑动窗口将同一卷积核与不同位置的输入重复计算,从而避免参数尺寸过大。
1.填充和步幅:
要想使用一个卷积层,操作上显见的问题就是我们要知道这个卷积层输入输出的形状.
一般来说,对于第i维来说,如果输入在第i维长度?有没有更好的名字
是$n_i$,核长度是$k_i$那么输出在第i维长度就是($n_i-k_i+1$),显然,$k_i$不应该超过$n_i$,否则会报错
填充padding
**太长不看:**取padding=({$\frac {kernelsize_i-1} 2$,…})可以保证输入输出形状相同
填充(padding)是指在输入高和宽的两侧填充元素(通常是0元素)。
如果在第i维的两侧一共填充$p_i$行
那么输出形状第i维将会是($n_i-k_i+p_i+1$)
所以通常可以把$p_i设置为k_i-1$来使输入输出具有相同形状
当卷积核的高和宽不同时,我们也可以通过设置高和宽上不同的填充数使输出和输入具有相同的高和宽。
1 | # 使用高为5、宽为3的卷积核。在高和宽两侧的填充数分别为2和1 |
注意:操作上padding=(2, 1)
padding的参数代表着在kernel两侧分别填充多少.也就是说$=p_i/2$,所以尽量也把核的每一维长度取奇数,这样保证了$p_i/2$是个整数.否则还得考虑两边分别取floor和ceiling,麻烦死了
步幅stride
卷积窗口从输入数组的最左上方开始,按从左往右、从上往下的顺序,依次在输入数组上滑动。我们将每次滑动的行数和列数称为步幅(stride)。
步幅可以按比例缩小形状
如果按上一步设置理想的padding,同时如果输入的高和宽能分别被高和宽上的步幅整除,那么输出形状将缩为原来的1/stride倍.其他情况自己算去,但何必为难自己.
2.多输入通道与多输出通道.
多输入通道
实际上不就是多了一维吗?比如彩色图像在高和宽2个维度外还有RGB(红、绿、蓝)3个颜色通道。就是新增了一个长度为3(r,g,b)的一维.然后我们用三个卷积核叠一块,发现在色彩维度上insize和kernersize都是3,然后这一维长度就变成1,退化了.
实现含多个输入通道的互相关运算。我们只需要对每个通道做互相关运算,然后通过add_n
函数来进行累加。
多输出通道
哎呀我一阵头晕目眩,不想知道它是怎么算的了.
1 | import torch |
直接来看示例代码吧.知道它怎么算干嘛呢?
在上面的代码中,input_tensor
是一个批次大小为 16、通道数为 3、高度为 32、宽度为 32 的输入张量,conv_layer
是一个输出通道数为 10、卷积核大小为 3x3、步幅为 1、填充为 1 的卷积层,output_tensor
是卷积层的输出张量,它的形状为 [16, 10, 32, 32]
。
这是gpt说的啊,我觉着挺对的.可以自己跑着试试.
1*1卷积层
一个长宽都是1的核
1×1卷积失去了卷积层可以识别高和宽维度上相邻元素构成的模式的功能。实际上,1×1卷积的主要计算发生在通道维上。图5.5展示了使用输入通道数为3、输出通道数为2的1×1卷积核的互相关计算。值得注意的是,输入和输出具有相同的高和宽。输出中的每个元素来自输入中在高和宽上相同位置的元素在不同通道之间的按权重累加。
假设我们将通道维当作特征维,将高和宽维度上的元素当成数据样本,那么1×1卷积层的作用与全连接层等价。
通过这个来调整参数的通道数,控制模型复杂度.e.g.语数外+理综to语数外+三门选科
2.池化
缓解卷积层对位置的过度敏感性。(想想抗锯齿操作,都是通过采样来使得过渡更加平滑??)
池化窗口形状为p×q的池化层称为p×q池化层,其中的池化运算叫作p×q池化。
-
池化层的输出通道数跟输入通道数相同。
-
默认情况下,
MaxPool2d
实例里步幅和池化窗口形状相同。当然可以自己设定.但是池化后的形状又要算一算了吗?
一些例子
下面的例子,即使是LeNet,都完全够应付作业了完全没有必要看
LeNet
卷积层块里的基本单位是卷积层后接最大池化层:卷积层用来识别图像里的空间模式,如线条和物体局部,之后的最大池化层则用来降低卷积层对位置的敏感性。卷积层块由两个这样的基本单位重复堆叠构成。
实现
1 | import time |
先conv再flatten,大概都是这么个套路
AlexNet
很多分类工作流程是:获取数据集–>获得特征–>进行分类.
之前认为DL的工作只是使用机器学习模型对特征分类。使用较干净的数据集和较有效的特征甚至比机器学习模型的选择对图像分类结果的影响更大。
特征可以学习吗?>> AlexNet
稍微简化的AlexNet
1 | import time |
真的,体验上来说,LeNet做作业就完全够了…
-
AlexNet跟LeNet结构类似,但使用了更多的卷积层和更大的参数空间来拟合大规模数据集ImageNet。它是浅层神经网络和深度神经网络的分界线。
-
虽然看上去AlexNet的实现比LeNet的实现也就多了几行代码而已,但这个观念上的转变和真正优秀实验结果的产生令学术界付出了很多年。
VGG块
VGG块的组成规律是:连续使用数个相同的填充为1、窗口形状为3×33×3的卷积层后接上一个步幅为2、窗口形状为2×22×2的最大池化层。卷积层保持输入的高和宽不变,而池化层则对其减半。我们使用vgg_block
函数来实现这个基础的VGG块,它可以指定卷积层的数量和输入输出通道数。
对于给定的感受野(与输出有关的输入图片的局部大小),采用堆积的小卷积核优于采用大的卷积核,因为可以增加网络深度来保证学习更复杂的模式,而且代价还比较小(参数更少)。例如,在VGG中,使用了3个3x3卷积核来代替7x7卷积核,使用了2个3x3卷积核来代替5*5卷积核,这样做的主要目的是在保证具有相同感知野的条件下,提升了网络的深度,在一定程度上提升了神经网络的效果。
NiN块
我们知道,卷积层的输入和输出通常是四维数组(样本,通道,高,宽),而全连接层的输入和输出则通常是二维数组(样本,特征)。如果想在全连接层后再接上卷积层,则需要将全连接层的输出变换为四维。回忆在5.3节(多输入通道和多输出通道)里介绍的1×11×1卷积层。它可以看成全连接层,其中空间维度(高和宽)上的每个元素相当于样本,通道相当于特征。因此,NiN使用1×11×1卷积层来替代全连接层,从而使空间信息能够自然传递到后面的层中去。图5.7对比了NiN同AlexNet和VGG等网络在结构上的主要区别。
上面这俩我回头再看,头晕😵💫😵💫😵💫😵💫
5.9 含并行连结的网络(GoogLeNet)
如果说上面两个是网络之间串行,那么GoogLeNet的基本块Inception块是并行的(并行里套了串行)
Inception块里有4条并行的线路。前3条线路使用窗口大小分别是1×1、3×3和5×5的卷积层来抽取不同空间尺寸下的信息,其中中间2个线路会对输入先做1×1卷积来减少输入通道数,以降低模型复杂度。第四条线路则使用3×3最大池化层,后接1×1卷积层来改变通道数。4条线路都使用了合适的填充来使输入与输出的高和宽一致。最后我们将每条线路的输出在通道维上连结,并输入接下来的层中去。
这个更复杂了😵💫😵💫😵💫😵💫具体实现抄抄链接里的吧.我真的好奇他怎么保证每次输入输出通道匹配的
批量归一化
-
在模型训练时,批量归一化利用小批量上的均值和标准差,不断调整神经网络的中间输出,从而使整个神经网络在各层的中间输出的数值更稳定。
-
对全连接层和卷积层做批量归一化的方法稍有不同。
-
批量归一化层和丢弃层一样,在训练模式和预测模式的计算结果是不一样的。
-
PyTorch提供了BatchNorm类方便使用。
Pytorch中nn
模块定义的BatchNorm1d
和BatchNorm2d
类使用起来更加简单,二者分别用于全连接层和卷积层,都需要指定输入的num_features
参数值。下面我们用PyTorch实现使用批量归一化的LeNet。
1 | net = nn.Sequential( |
可以试试把这个加到自己的module里,
它就是个插件,可以加上它,而不用对其他东西做任何修改,我觉着挺好的
残差网络ResNet
我饿了🥱🥱🥱不学了.
RNN
RNN的一个重要应用就是语言模型
假设一段长度为$T$的文本中的词依次为$w_1, w_2, \ldots, w_T$,那么在离散的时间序列中,$w_t$($1 \leq t \leq T$)可看作在时间步(time step)$t$的输出或标签。给定一个长度为$T$的词的序列$w_1, w_2, \ldots, w_T$,语言模型将计算该序列的概率:
$$
P(w_1, w_2, \ldots, w_T).
$$
假设序列$w_1, w_2, \ldots, w_T$中的每个词是依次生成的,我们有
$$
P(w_1, w_2, \ldots, w_T) = \prod_{t=1}^T P(w_t \mid w_1, \ldots, w_{t-1}).
$$
n元语法
当序列长度增加时,计算和存储多个词共同出现的概率的复杂度会呈指数级增加。$n$元语法通过马尔可夫假设(虽然并不一定成立)简化了语言模型的计算。这里的马尔可夫假设是指一个词的出现只与前面$n$个词相关,即$n$阶马尔可夫链(Markov chain of order $n$)。如果$n=1$,那么有$P(w_3 \mid w_1, w_2) = P(w_3 \mid w_2)$。如果基于$n-1$阶马尔可夫链,我们可以将语言模型改写为
$$
P(w_1, w_2, \ldots, w_T) \approx \prod_{t=1}^T P(w_t \mid w_{t-(n-1)}, \ldots, w_{t-1}) .
$$
我理解的RNN
上面的例子:把一个句子看做词序列,每个时间步产生一个词…当序列长度增加时,计算和存储多个词共同出现的概率的复杂度会呈指数级增加。但是,我们可以通过一个隐藏变量来传递前面几个时间步的信息…该隐藏变量也称为隐藏状态。由于隐藏状态在当前时间步的定义使用了上一时间步的隐藏状态,上式的计算是循环的。
例子
引入一个新的权重参数${W}_{hh} \in {R}^{h \times h}$,该参数用来描述在当前时间步如何使用上一时间步的隐藏变量。具体来说,时间步$t$的隐藏变量的计算由当前时间步的输入和上一时间步的隐藏变量共同决定:
$$
{H}t = \phi({X}t{W}{xh}+{H}{t-1}{W}_{hh}+{b}_h)
$$
其中${H}_{t-1}$就是上一个时间步的隐藏变量
构建数据集
对时序数据的采样方式有如下两种(采样就是选取batchsize个样本,如果batchsize=1,那么就不需要考虑如何采样了)
-
随机采样
在数据集中随机选取batchsize个样本,由于不同样本之间是独立的,所以不能直接把上个样本计算后的隐藏状态传给下个样本作为初始隐藏状态
-
相邻采样
字面意思,在数据集中选取相邻的batchsize个样本,这样只需在每一个迭代周期开始时初始化隐藏状态.
但是这样计算梯度时会依赖所有序列??
为了使模型参数的梯度计算只依赖一次迭代读取的小批量序列,我们可以在每次读取小批量前将隐藏状态从计算图中分离出来。
RNN的实现
我终于明白了,我们好像只需要关注不同layer的输入输出形状…
至于我的net为什么有他们,who ™ cares??
PyTorch中的nn
模块提供了循环神经网络的实现。下面构造一个含单隐藏层、隐藏单元个数为256的循环神经网络层rnn_layer
。
1 | num_hiddens = 256 |
native rnn_layer的输入
rnn_layer
的输入形状为(时间步数, 批量大小,input_size)
就是有点别扭.
-
第一个时间步数就是句子长度
-
第二个批量大小,我真无语,为什么要把批量大小放在第二个,感觉不是很直观.所以cnn和rnn的数据不能直接通用,要view一下交换一下前两个维度?还是别的什么维度
-
第三个inut_size;这里和cnn不同,如果说cnn的input单元是句子整体.那么RNN的input实际上是词向量.所以说如果是one-hot,那么就是vocab_size;如果已经word2vec后,就是词向量的长度…这里注意一下不同教程的区别.
native rnn_layer的输出
形状为(时间步数, 批量大小, 隐藏单元个数)。
前向计算后会分别返回输出和隐藏状态h,其中输出指的是隐藏层在各个时间步上计算并输出的隐藏状态,它们通常作为后续输出层的输入。参考下图(这是LSTM,和RNN本身不很一样)
门控循环单元GRU
当时间步数较大或者时间步较小时,循环神经网络的梯度较容易出现衰减或爆炸。虽然裁剪梯度可以应对梯度爆炸,但无法解决梯度衰减的问题。通常由于这个原因,循环神经网络在实际中较难捕捉时间序列中时间步距离较大的依赖关系。
GRU就是为了解决上述问题
GRU包含重置门和更新门,计算图如下
简单来说,就是如果前面时间步的输入已经和这次输出没太大关系,重置门会重置隐藏状态
如果这次输入不太影响后面内容,更新门会把之前的隐藏状态传递下去.
重置门有助于捕捉时间序列里短期的依赖关系;
更新门有助于捕捉时间序列里长期的依赖关系。
注意:上面完全不需要理解.反正有native的GRU模块
1 | lr = 1e-2 # 注意调整学习率 |
长短期记忆LSTM
另一种门控循环神经网络是LSTM(long short-term memory,长短期记忆)
你看这名字,这不是和刚才的门干了一样的事吗??
LSTM 中引入了3个门,即输入门(input gate)、遗忘门(forget gate)和输出门(output gate),以及与隐藏状态形状相同的记忆细胞,从而记录额外的信息。
同样地,可以直接调用rnn
模块中的LSTM
类。
1 | lr = 1e-2 # 注意调整学习率 |
如果你想理解,我把图放这了.
我的理解是,这其实就是显示地干了GRU的活.
如果遗忘门与记忆细胞做$\odot$趋于1,那么就会倾向于保存下之前的记忆,如果趋于0就会遗忘
如果输入门与候选记忆细胞做$\odot$趋于1,那么就会倾向于更新记忆,如果趋于0就不会更新
更复杂的模型
深度循环神经网络
目前为止介绍的循环神经网络只有一个单向的隐藏层,在深度学习应用里,我们通常会用到含有多个隐藏层的循环神经网络,也称作深度循环神经网络。下图演示了一个有L个隐藏层的深度循环神经网络,每个隐藏状态不断传递至当前层的下一时间步和当前时间步的下一层。
双向循环神经网络
之前介绍的循环神经网络模型都是假设当前时间步是由前面的较早时间步的序列决定的,因此它们都将信息通过隐藏状态从前往后传递。有时候,当前时间步也可能由后面时间步决定。例如,当我们写下一个句子时,可能会根据句子后面的词来修改句子前面的用词。双向循环神经网络通过增加从后往前传递信息的隐藏层来更灵活地处理这类信息。
上面两个我感觉挺靠谱的,但是没有示例代码,所以我就不管了
update:其实双向循环网络就是加个参数的事
1 | class BiRNN(nn.Module): |
而多层就更不用教了…