《深度学习框架PyTorch入门与实践》系列:04-nn模块详解:用积木搭建神经网络
文章目录
1. 前言:从手写网络到 nn 模块
在前几篇文章中,我们了解了 Tensor 的基本操作,也接触了自动求导机制 autograd。现在假设我们要搭建一个简单的两层全连接网络,如果只使用基础 Tensor 和 autograd,代码大概是这样的:
import torch
# 手写权重与偏置
w1 = torch.randn(784, 256, requires_grad=True)
b1 = torch.zeros(256, requires_grad=True)
w2 = torch.randn(256, 10, requires_grad=True)
b2 = torch.zeros(10, requires_grad=True)
def manual_forward(x):
x = x @ w1 + b1 # 第一层线性变换
x = x.clamp(min=0) # 手动实现 ReLU
x = x @ w2 + b2 # 第二层线性变换
return x
x = torch.randn(32, 784)
out = manual_forward(x)
print(out.shape) # torch.Size([32, 10])
上面的代码虽然能跑,但问题非常明显:
- 每个权重都要手动定义,网络一深,代码量爆炸;
- 前向传播要手动实现矩阵运算,容易写错形状;
- 参数不便于统一管理,保存、加载、设备迁移都很麻烦;
- 缺少现成的层与训练组件,激活函数、损失函数、优化器都要自己造轮子。
PyTorch 为我们提供了 torch.nn 模块——一系列神经网络层的积木。同样的网络,用 nn 可以写成:
import torch.nn as nn
model = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10),
)
你可以像搭乐高一样,把「全连接层」「卷积层」「激活函数」「池化层」等积木拼装起来,构建出复杂的神经网络。nn 模块的价值不仅在于“省代码”,更在于它提供了一套统一、规范、可复用的建模方式:参数自动管理、模块任意嵌套、设备与训练模式一键切换。
本文将通过「积木搭建」的比喻,系统地讲解 nn 模块的核心组成:
2. nn.Module:所有神经网络的基类
torch.nn.Module 是 PyTorch 中所有神经网络模块的基类。任何自定义模型都应该继承它。它的核心作用有三个:
- 管理参数:自动追踪模块中所有可学习的参数(权重和偏置)。
- 组织前向传播:通过实现
forward方法定义数据如何流经网络。 - 支持嵌套与注册:子模块可以层层嵌套,模块类型会被自动识别,方便统一管理。
一个最简单的自定义网络如下:
import torch
import torch.nn as nn
class SimpleNet(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super().__init__()
# 在 __init__ 中定义“积木”
self.fc1 = nn.Linear(input_size, hidden_size)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(hidden_size, output_size)
def forward(self, x):
# 在 forward 中“拼装”积木
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x
net = SimpleNet(784, 256, 10)
print(net)
输出会展示网络的结构和每一层的参数形状,类似下面这样:
SimpleNet(
(fc1): Linear(in_features=784, out_features=256, bias=True)
(relu): ReLU()
(fc2): Linear(in_features=256, out_features=10, bias=True)
)
这里有三个必须牢记的规则:
- 必须在
__init__中先调用super().__init__(),否则nn.Module的初始化机制不会生效,参数无法被正确注册。 - 子模块必须赋值给
self的属性(如self.fc1),这样nn.Module才能通过__setattr__机制自动发现并登记它们。 - 不要直接调用
forward方法,而是通过net(x)调用。nn.Module内部实现了__call__,在调用forward前后会执行注册的钩子(hook),直接调用net.forward(x)会绕过这些机制。
2.1 查看模型的参数
模型的所有可学习参数都保存在 parameters() 中,可以配合优化器使用:
for name, param in net.named_parameters():
print(f"{name:20s} {param.shape}")
输出大致如下:
fc1.weight torch.Size([256, 784])
fc1.bias torch.Size([256])
fc2.weight torch.Size([10, 256])
fc2.bias torch.Size([10])
此外还可以统计参数量:
total_params = sum(p.numel() for p in net.parameters())
print(f"总参数量: {total_params}") # 784*256+256 + 256*10+10
除了 parameters(),还常用 named_parameters() 拿到带名字的参数,用 children() 遍历直接子模块,用 modules() 遍历所有层级的子模块(包含自身)。
2.2 train() / eval() 切换模式
模型有两种模式,对 BatchNorm、Dropout 等层影响很大:
net.train() # 训练模式:Dropout 生效,BatchNorm 用当前 batch 的统计量
net.eval() # 评估模式:Dropout 关闭,BatchNorm 用全局累计的统计量
在训练前调用 train(),在验证或推理前调用 eval(),这是保证结果正确的关键习惯。
2.3 参数迁移到 GPU
把模型和输入数据放到同一个设备上,才能正常计算:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
net.to(device) # 原位迁移所有参数
x = torch.randn(3, 784).to(device)
out = net(x)
print(out.device) # cuda:0 或 cpu
3. 常用网络层:认识你的积木
nn 模块内置了大量现成的网络层,下面介绍最常用的几种。在学习每一个层时,请重点关注三点:输入输出形状、核心参数、适用场景。
3.1 全连接层 nn.Linear
全连接层(也叫线性层)是最基础的积木,本质是对输入做线性变换:y = xW^T + b,也可以简写为 y = Wx + b。
import torch
import torch.nn as nn
# in_features=输入维度, out_features=输出维度
fc = nn.Linear(in_features=10, out_features=5)
# 模拟一个 batch 大小为 3 的输入
x = torch.randn(3, 10)
y = fc(x)
print(y.shape) # torch.Size([3, 5])
nn.Linear 内部自动创建了权重 weight(形状 [out_features, in_features],即 [5, 10])和偏置 bias(形状 [out_features],即 [5]),可以通过 fc.weight、fc.bias 访问和修改:
print(fc.weight.shape) # torch.Size([5, 10])
print(fc.bias.shape) # torch.Size([5])
# 自定义初始化
nn.init.xavier_uniform_(fc.weight)
nn.init.zeros_(fc.bias)
注意权重形状是 [输出维度, 输入维度],不要记反。全连接层要求输入至少是二维 (batch, in_features),更多维的输入需要先用 Flatten 展平。
3.2 卷积层 nn.Conv2d
卷积层是卷积神经网络(CNN)的核心积木,用于提取图像的空间特征。它通过一个可学习的卷积核在图像上滑动,捕捉局部模式(如边缘、纹理)。
import torch
import torch.nn as nn
# in_channels=输入通道数, out_channels=输出通道数, kernel_size=卷积核大小
conv = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, stride=1, padding=1)
# 模拟一张 RGB 图片:batch=1, 通道=3, 高=32, 宽=32
img = torch.randn(1, 3, 32, 32)
out = conv(img)
print(out.shape) # torch.Size([1, 16, 32, 32])
参数说明:
stride:卷积核滑动的步长,默认1。步长越大,输出尺寸越小。padding:边缘填充的像素数。padding=1配合kernel_size=3可以在stride=1时保持输出尺寸不变,这叫 “same padding”。bias:是否使用偏置,默认为True。dilation:膨胀卷积的间隔,默认1,常用于扩大感受野而不增加参数。
输出尺寸的计算公式为:
H_out = (H_in + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1
W_out = (W_in + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1
举例:输入 32×32,kernel_size=3, padding=1, stride=1,代入得 (32 + 2 - 3 - 0)/1 + 1 = 32,所以输出仍是 32×32;若把 stride 改为 2,则输出变为 (32 + 2 - 3 - 0)/2 + 1 = 16(结果向下取整前的计算为 15.5 + 1,实际由 floor 规则得到 16)。
3.3 池化层 nn.MaxPool2d
池化层用于缩小特征图的空间尺寸,保留主要特征的同时减少计算量,并增强网络对小幅平移的不敏感性。
import torch
import torch.nn as nn
pool = nn.MaxPool2d(kernel_size=2, stride=2)
# 最大池化后尺寸减半
x = torch.randn(1, 16, 32, 32)
out = pool(x)
print(out.shape) # torch.Size([1, 16, 16, 16])
最大池化在每个窗口内取最大值,相当于保留最强的响应。除了最大池化,nn.AvgPool2d(平均池化)取窗口内平均值,常用于网络末端的全局池化;nn.AdaptiveAvgPool2d 则可以把任意尺寸的输入直接映射到指定输出尺寸,在分类网络末尾格外常用:
gap = nn.AdaptiveAvgPool2d((1, 1)) # 全局平均池化
out = gap(torch.randn(2, 64, 7, 7))
print(out.shape) # torch.Size([2, 64, 1, 1])
池化层没有可学习参数,只在空间维度下采样。
3.4 归一化层 nn.BatchNorm2d
批量归一化层通过对每个通道做标准化(减均值、除标准差,再做可学习的缩放和平移),把中间层的输出分布控制在较稳定的范围,从而加速训练、稳定梯度、允许使用更大学习率,常插在卷积层和激活函数之间。
import torch
import torch.nn as nn
bn = nn.BatchNorm2d(num_features=16)
x = torch.randn(4, 16, 32, 32) # batch=4
out = bn(x)
print(out.shape) # torch.Size([4, 16, 32, 32])
注意:BatchNorm 在训练和测试阶段的行为不同——
- 训练时:使用当前 batch 的均值和方差进行归一化,同时更新全局统计量(running mean / running var)。
- 测试时:使用训练阶段累积的全局统计量进行归一化,保证推理结果稳定。
这种差异由模型的 train() / eval() 模式自动切换,所以一定不要在验证和推理时忘了调用 eval()。另外,当 batch size 很小(如 1 或 2)时,batch 统计量波动很大,BatchNorm 效果会变差,这时可以考虑 GroupNorm 或 LayerNorm,其中 LayerNorm 在 Transformer 结构中非常常见。
4. 激活函数:给网络注入非线性
如果网络中只有线性层,那么无论堆叠多少层,整体仍然等价于一个线性变换,无法拟合复杂的非线性函数。激活函数就是引入非线性的关键积木。
import torch
import torch.nn as nn
# 常用激活函数
relu = nn.ReLU(inplace=False) # 最常用,计算简单
sigmoid = nn.Sigmoid() # 输出 0~1,适合二分类
tanh = nn.Tanh() # 输出 -1~1
leaky_relu = nn.LeakyReLU(0.01) # 解决 ReLU 的“神经元死亡”问题
softmax = nn.Softmax(dim=1) # 多分类输出概率分布
x = torch.tensor([-1.0, 0.0, 2.0])
print(relu(x)) # tensor([0., 0., 2.])
print(sigmoid(x)) # 所有值在 0~1 之间
各个激活函数的公式与特点:
- ReLU:
f(x) = max(0, x)。计算简单、梯度不会饱和,是现代网络的默认选择;缺点是输入为负时梯度为 0,可能出现“神经元死亡”。 - LeakyReLU:
f(x) = x if x > 0 else 0.01x。给负半轴一个微小斜率,缓解 ReLU 的死亡问题。 - GELU:
f(x) ≈ xΦ(x)。在 Transformer 中大量使用,相比 ReLU 更平滑,效果通常略好。 - Sigmoid:
σ(x) = 1 / (1 + e^(-x))。输出压缩到 0~1,可用于二分类概率,但存在两端梯度饱和、输出不以 0 为中心的问题。 - Tanh:
tanh(x) = 2σ(2x) - 1。输出范围 -1~1,以 0 为中心,但仍会梯度饱和。 - Softmax:将一组 logits 映射为概率分布,所有输出之和为 1,用于多分类。
选择建议:
- 隐藏层默认使用
ReLU及其变体(LeakyReLU、GELU)。 - 二分类输出层用
Sigmoid。 - 多分类输出层用
Softmax(实际训练中常配合CrossEntropyLoss,它内部已包含 Softmax,因此模型最后一层不需要再单独加)。
如果你更倾向于函数式写法,也可以用 torch.relu、torch.sigmoid 等函数,效果与 nn.ReLU() 等模块一致;区别在于无参数激活函数写成模块后可以在 Sequential 中直接串联。
5. 损失函数:衡量预测与真实值的差距
损失函数告诉网络「你错得有多离谱」,是优化器更新参数的依据。不同任务需要选择不同的损失函数。
import torch
import torch.nn as nn
# 回归任务常用均方误差
mse_loss = nn.MSELoss()
pred = torch.tensor([2.5, 0.0, 2.1])
target = torch.tensor([3.0, -0.5, 2.0])
loss = mse_loss(pred, target)
print(loss)
# 多分类任务常用交叉熵
ce_loss = nn.CrossEntropyLoss()
# logits: batch=2, 类别数=3(注意:不需要先做 softmax)
logits = torch.randn(2, 3)
labels = torch.tensor([0, 2])
loss = ce_loss(logits, labels)
print(loss)
常见损失函数一览:
nn.MSELoss:均方误差,loss = mean((pred - target)^2)。适合回归任务,对离群点较敏感。nn.L1Loss:平均绝对误差,loss = mean(|pred - target|)。对离群点更鲁棒。nn.CrossEntropyLoss:交叉熵损失,多分类任务的标准选择。输入必须是未经过 Softmax 的 logits,标签是类别索引(LongTensor)。nn.BCELoss/nn.BCEWithLogitsLoss:二分类交叉熵。前者要求输入已经过 Sigmoid,后者内部自带 Sigmoid,数值更稳定,推荐后者。
关键点:CrossEntropyLoss 内部已经包含了 Softmax + 负对数似然,所以模型的最后一层不要再加 Softmax,直接输出原始分数(logits)即可。如果在输出层先做了 Softmax,再传入 CrossEntropyLoss,相当于做了两次 Softmax,不仅梯度会失真,训练效果也会变差。
另外注意标签类型:分类任务中 labels 必须是 torch.long,如果误用浮点标签会直接报错。
6. 优化器:让积木“活”起来
有了模型和损失函数,还需要优化器来根据梯度更新参数。损失函数计算出的梯度,需要由优化器按照特定算法应用到参数上。PyTorch 将优化算法封装在 torch.optim 模块中。
import torch
import torch.nn as nn
import torch.optim as optim
model = nn.Linear(10, 1)
loss_fn = nn.MSELoss()
# 传入模型参数和学习率
optimizer = optim.SGD(model.parameters(), lr=0.01)
# 也常用 Adam,收敛更快
# optimizer = optim.Adam(model.parameters(), lr=0.001)
# 模拟一个训练步骤
x = torch.randn(4, 10)
target = torch.randn(4, 1)
optimizer.zero_grad() # 1. 清空上一步的梯度
pred = model(x) # 2. 前向传播
loss = loss_fn(pred, target)
loss.backward() # 3. 反向传播,计算梯度
optimizer.step() # 4. 更新参数
print(f"loss: {loss.item():.4f}")
四个步骤缺一不可,尤其注意 zero_grad():PyTorch 的梯度默认是累加的,如果不手动清零,多次反向传播的梯度会越积越大,参数更新会出错。
常用优化器对比:
- SGD:随机梯度下降,简单稳定,配合动量(
momentum)后效果更好,但需要仔细调学习率。 - Adam:自适应学习率,收敛快,是大多数任务的默认选择,适合快速验证模型。
- AdamW:Adam 的改进版,把权重衰减与自适应学习率解耦,训练更稳定,是 Transformer 训练的标配。
- RMSprop:适合循环神经网络等非平稳目标,现在使用较少。
实际选择时可以记住两句话:快速跑通用 Adam,追求最终效果或训练大模型用 AdamW 或带动量的 SGD。
7. 容器类:Sequential 与 ModuleList
当网络结构是简单的「一层接一层」时,可以用容器类让代码更简洁;当结构出现分支、循环或动态变化时,则需要更灵活的容器。PyTorch 提供了 Sequential、ModuleList、ModuleDict 三种主要容器。
7.1 nn.Sequential:流水线式组装
import torch
import torch.nn as nn
model = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 128),
nn.ReLU(),
nn.Linear(128, 10),
)
x = torch.randn(2, 784)
out = model(x)
print(out.shape) # torch.Size([2, 10])
Sequential 会按顺序自动传递数据,非常适合构建简单的线性堆叠网络。它还可以用 OrderedDict 给每一层命名:
from collections import OrderedDict
model = nn.Sequential(OrderedDict([
("fc1", nn.Linear(784, 256)),
("relu1", nn.ReLU()),
("fc2", nn.Linear(256, 10)),
]))
print(model)
这样打印模型结构时会有更清晰的层名。Sequential 的局限是:每一层的输出只能直接作为下一层的输入,无法表达跳跃连接、多个输入或多个输出。
7.2 nn.ModuleList:可迭代的模块列表
ModuleList 本身不定义前向传播,只是一个存放模块的容器,适合动态管理多个层。
import torch
import torch.nn as nn
class MultiHeadNet(nn.Module):
def __init__(self):
super().__init__()
# 存放多个分支的全连接层
self.heads = nn.ModuleList([
nn.Linear(128, 10) for _ in range(5)
])
def forward(self, x):
outputs = []
for head in self.heads:
outputs.append(head(x))
return torch.stack(outputs, dim=1)
net = MultiHeadNet()
x = torch.randn(2, 128)
print(net(x).shape) # torch.Size([2, 5, 10])
ModuleList 里的每个模块都有自己的独立参数,适合“多个并列分支”或“层数在循环中动态生成”的场景。
注意:不要用 Python 原生列表
[]来存放网络层,否则这些层的参数不会被nn.Module自动追踪。务必使用ModuleList或ModuleDict。
7.3 nn.ModuleDict:可按键访问的模块字典
当你希望通过字符串名字动态访问子模块时,可以使用 ModuleDict:
import torch
import torch.nn as nn
class SwitchNet(nn.Module):
def __init__(self):
super().__init__()
self.branches = nn.ModuleDict({
"linear": nn.Linear(64, 10),
"mlp": nn.Sequential(nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 10)),
})
def forward(self, x, branch_name):
return self.branches[branch_name](x)
net = SwitchNet()
x = torch.randn(2, 64)
print(net(x, "linear").shape) # torch.Size([2, 10])
print(net(x, "mlp").shape) # torch.Size([2, 10])
记忆这三者最直接的方式:
Sequential:顺序执行,数据自动流动。ModuleList:像列表一样迭代,但前向传播要自己写。ModuleDict:像字典一样按键访问,前向传播也要自己写。
8. 实战:用积木搭建一个 CNN
下面把前面学到的积木拼装成一个完整的卷积神经网络,用来解决经典的 MNIST 手写数字分类(10 类)。MNIST 是 28×28 的单通道灰度图,标签为 0~9 的数字。
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
class CNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
# 特征提取部分
self.features = nn.Sequential(
nn.Conv2d(1, 32, kernel_size=3, padding=1), # 28x28 -> 28x28
nn.ReLU(),
nn.MaxPool2d(2), # 28x28 -> 14x14
nn.Conv2d(32, 64, kernel_size=3, padding=1), # 14x14 -> 14x14
nn.ReLU(),
nn.MaxPool2d(2), # 14x14 -> 7x7
)
# 分类部分
self.classifier = nn.Sequential(
nn.Flatten(), # 展平为 64*7*7
nn.Linear(64 * 7 * 7, 128),
nn.ReLU(),
nn.Dropout(0.5), # 随机失活,防止过拟合
nn.Linear(128, num_classes),
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
model = CNN()
print(model)
# 验证前向传播
dummy_input = torch.randn(2, 1, 28, 28)
print(model(dummy_input).shape) # torch.Size([2, 10])
下面逐步拆解这个网络:
- 特征提取
features:两个卷积块逐步把 1 通道的 28×28 图变成 64 通道的 7×7 特征图。padding=1让卷积前后尺寸不变,MaxPool2d(2)让尺寸减半。 - 展平
Flatten:把(batch, 64, 7, 7)展平成(batch, 3136),也就是64 * 7 * 7,为全连接层做准备。 - 分类头
classifier:两层全连接 + Dropout,最终输出 10 个 logits。 - Dropout:训练时以 0.5 的概率随机丢弃神经元,缓解过拟合。评估模式下会自动失效。
这个网络中包含了本文介绍的所有积木类型:卷积层、激活函数、池化层、全连接层、Dropout,以及 Sequential 容器。完整的训练循环如下:
def train_one_epoch(model, loader, optimizer, loss_fn):
model.train() # 切换到训练模式
total_loss = 0
correct = 0
for images, labels in loader:
optimizer.zero_grad()
outputs = model(images)
loss = loss_fn(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item() * images.size(0)
correct += (outputs.argmax(dim=1) == labels).sum().item()
return total_loss / len(loader.dataset), correct / len(loader.dataset)
def evaluate(model, loader, loss_fn):
model.eval() # 切换到评估模式
total_loss = 0
correct = 0
with torch.no_grad(): # 推理阶段不计算梯度
for images, labels in loader:
outputs = model(images)
loss = loss_fn(outputs, labels)
total_loss += loss.item() * images.size(0)
correct += (outputs.argmax(dim=1) == labels).sum().item()
return total_loss / len(loader.dataset), correct / len(loader.dataset)
# ===== 数据准备与训练入口示例 =====
# transform = transforms.Compose([
# transforms.ToTensor(),
# transforms.Normalize((0.1307,), (0.3081,))
# ])
# train_data = datasets.MNIST("./data", train=True, download=True, transform=transform)
# train_loader = DataLoader(train_data, batch_size=64, shuffle=True)
# test_data = datasets.MNIST("./data", train=False, download=True, transform=transform)
# test_loader = DataLoader(test_data, batch_size=64, shuffle=False)
#
# optimizer = optim.Adam(model.parameters(), lr=0.001)
# loss_fn = nn.CrossEntropyLoss()
#
# for epoch in range(5):
# train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, loss_fn)
# test_loss, test_acc = evaluate(model, test_loader, optimizer, loss_fn)
# print(f"epoch {epoch+1}: train acc {train_acc:.4f}, test acc {test_acc:.4f}")
几点实战建议:
- 数据归一化:MNIST 常用均值
0.1307、标准差0.3081进行标准化,能让训练更稳定、收敛更快。 - 评估时
with torch.no_grad():推理阶段不需要记录梯度,这样可以显著节省显存和计算量。 - 先小样本验证:正式训练前,可以先在几百张图片上过拟合,确认损失能下降、代码没有逻辑问题,再上全量数据。
9. 总结
本文以「积木搭建」为主线,系统梳理了 nn 模块的核心组成:
nn.Module:所有模型的基类,负责管理参数、定义前向传播、支持嵌套与train/eval模式切换。- 网络层:
Linear、Conv2d、MaxPool2d、BatchNorm2d等积木,各有明确的输入输出形状与适用场景。 - 激活函数:为网络引入非线性,
ReLU是最常用的默认选择,GELU在 Transformer 中更常见。 - 损失函数:衡量预测与真实的差距,回归用
MSELoss,分类用CrossEntropyLoss。 - 优化器:通过
zero_grad→forward→backward→step四步更新参数,快速上手用Adam。 - 容器类:
Sequential串联简单结构,ModuleList管理动态多分支,ModuleDict按键访问子模块。
理解并熟练使用这些「积木」,是之后学习更复杂网络结构(如 ResNet、Transformer)的基础。下一篇我们将深入讨论模型训练技巧,包括学习率调度、早停与模型保存等实战内容,敬请期待。
10. 思考与练习
- 修改第 8 节的 CNN,尝试将卷积核大小改为 5×5,观察参数量与输出尺寸的变化,并用输出尺寸公式验证你的结果。
- 使用
nn.Sequential搭建一个纯全连接网络,并在随机数据上验证其输出形状。 - 为什么
CrossEntropyLoss前面不需要加Softmax?查阅文档并用自己的话解释。 - 在
SimpleNet上分别调用net.train()和net.eval(),打印net.training属性,观察两者区别。 - 用 Python 原生列表
[]存两个nn.Linear,再打印net.parameters(),对比使用nn.ModuleList时的差异,思考为什么参数会“消失”。 - 给第 8 节的训练循环加上验证集,并在每个 epoch 结束后打印训练准确率和验证准确率。
更多推荐


所有评论(0)