☰
神经网络搭建:Sequential与class继承方式详解与选型指南
2026/10/17 1:03:20 网站建设 项目流程

1. 从零理解神经网络搭建的两条路径

刚接触深度学习框架那会儿,我最大的困惑不是反向传播怎么推导,而是面对一个具体任务时,代码到底该怎么组织。翻开源项目,有人用几行Sequential就把网络堆完了,有人却要写一个完整的class,继承nn.Module,定义__init__和forward。两种写法都能跑通,但什么时候用哪种,里面藏着不少门道。

这篇内容就围绕神经网络搭建的两种核心方式——Sequential与class展开。前者是快速原型搭建的利器,后者是复杂结构实现的必经之路。我会把两种方式的适用场景、底层逻辑、代码细节、踩坑经验全部拆开讲清楚。无论你是刚学完全连接层、准备动手写第一个网络的新手,还是已经能跑通模型但想搞清楚工程化组织方式的进阶者,都能从中找到可以直接复用的方案。

核心关键词就三个:Sequential、class继承、神经网络搭建。这三个词串起来,本质上回答的是同一个问题——如何用代码把一堆层按照你的意图组装成一个可训练、可调试、可扩展的模型。下面我从设计思路开始,一层层往下拆。

2. 两种搭建方式的设计思路与选型逻辑

2.1 Sequential的本质:层的线性容器

Sequential在主流框架里就是一个容器,它做的事情非常单纯——把传入的层按顺序排列,数据进来后依次穿过每一层,前一层输出直接作为后一层输入。你可以把它想象成一条流水线,原料从一端进去,经过固定顺序的加工工位,成品从另一端出来。

这种线性结构决定了它的能力边界:只支持单输入单输出、逐层串联的拓扑。如果你的网络是简单的全连接堆叠、或者卷积层加池化层交替排列,Sequential完全够用,而且代码量极少。我见过不少做图像分类的入门项目,一个五六层的卷积网络用Sequential写出来不到二十行,可读性非常好。

但它的局限也很明显。一旦遇到需要跨层连接的结构,比如残差网络里的跳跃连接、密集连接网络里的特征拼接、或者多分支结构,Sequential就无能为力了。因为这些操作要求某一层的输入不仅来自前一层,还可能来自更早的层,甚至多个层的输出需要合并。Sequential的线性容器模型无法表达这种有向无环图的拓扑关系。

2.2 class继承nn.Module:灵活性的代价与回报

用class方式搭建网络,核心是继承框架提供的nn.Module基类,然后在__init__里声明所有需要用到的层和参数,在forward方法里定义数据的前向传播路径。这个forward方法就是你完全掌控的地方——你可以写条件分支、循环、跨层引用、多输入合并,任何Python能表达的逻辑都可以放进去。

我个人的经验是,class方式真正的价值不在于“更高级”,而在于它把网络结构的定义权完全交还给了开发者。Sequential是框架给你画好的轨道,你只能沿着走;class是你自己铺轨道,想怎么拐弯就怎么拐弯。

代价当然也有。代码量上去了,初学者容易在__init__里漏定义某个层,或者在forward里把变量名写错。而且class方式对调试的要求更高,因为前向传播的逻辑是你自己写的,出错时框架不会像Sequential那样给你一个清晰的层列表来排查。

2.3 选型判断:三个问题定方向

到底什么时候用Sequential,什么时候用class?我总结了一个简单的判断流程,问自己三个问题:

  • 网络是否只有一条从输入到输出的直线路径?如果是,Sequential优先。
  • 是否存在层与层之间的跳跃连接、多分支合并、或需要复用某一层的输出?如果是,必须用class。
  • 是否需要在forward中根据输入动态改变计算路径?如果是,必须用class。

还有一个实际工程中的考量:如果网络结构简单但层数很多,用Sequential配合列表推导式或循环生成层,代码会比手写class简洁得多。反过来,如果网络结构复杂但层数不多,class方式的额外代码量是可以接受的。

提示:不要因为class看起来“更专业”就无脑用class。我见过一些项目,明明就是一个线性堆叠的网络,非要用class写,结果__init__里定义了十几个层,forward里一行行手动串联,既容易出错又没有任何灵活性收益。

3. Sequential搭建方式的核心细节与实操要点

3.1 基础用法:从单层到多层堆叠

Sequential最基础的用法是直接把层作为参数传入。以全连接网络为例,假设输入维度是784,要做十分类,中间加两个隐藏层:

import torch.nn as nn model = nn.Sequential( nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, 10) )

这段代码定义了一个三层全连接网络,激活函数直接穿插在层之间。数据从784维输入,经过两次降维和激活,最后输出10维的类别得分。整个过程没有任何歧义,层的顺序就是数据流动的顺序。

这里有一个新手容易忽略的细节:激活函数也是层。在Sequential的视角下,nn.ReLU()和nn.Linear()没有本质区别,都是对输入做一次变换然后传给下一层。所以激活函数必须显式地放在它应该出现的位置,不能指望框架自动帮你加。

3.2 动态构建:用循环和列表生成层

当网络层数较多且结构重复时,手写每一层既枯燥又容易出错。这时候可以用Python列表配合解包来动态构建:

layer_dims = [784, 512, 256, 128, 10] layers = [] for i in range(len(layer_dims) - 1): layers.append(nn.Linear(layer_dims[i], layer_dims[i+1])) if i < len(layer_dims) - 2: layers.append(nn.ReLU()) model = nn.Sequential(*layers)

这段代码根据一个维度列表自动生成全连接层和激活函数。注意最后一个维度后面没有加ReLU,因为输出层通常不需要激活函数(具体取决于任务类型)。这种写法在调参时特别方便,改一下layer_dims就能快速尝试不同的网络深度和宽度。

注意:用循环生成层的时候,一定要确认激活函数的插入位置。我踩过的坑是在循环里无脑加ReLU,结果输出层也被加了ReLU,导致所有负值被截断,模型完全学不到东西。排查了半天才发现是激活函数放错了位置。

3.3 查看与修改:Sequential的层访问方式

Sequential对象支持索引访问和切片,这意味着你可以像操作列表一样查看和修改它的结构:

print(model[0]) # 查看第一层 print(model[-1]) # 查看最后一层 print(len(model)) # 查看总层数 # 修改某一层 model[0] = nn.Linear(784, 512)

这个特性在迁移学习中很有用。比如你加载了一个预训练模型,想把最后一层替换成适合自己任务的输出维度,直接通过索引替换即可。但要注意,替换后的层如果维度不匹配,前向传播时会报错,所以替换前要确认相邻层的输入输出维度是否衔接。

3.4 Sequential的适用边界与常见误用

Sequential最大的误用场景是试图用它实现带跳跃连接的网络。有人会想,我能不能在Sequential中间插入一个自定义层,让它把输入和输出相加?理论上可以写一个自定义Module来做这件事,但那个自定义Module内部已经需要访问非相邻层的信息了,这本质上已经超出了Sequential的设计意图。

另一个常见问题是把Sequential嵌套在class里面。这其实是完全合理的做法——在class的__init__里用Sequential定义一些重复的子结构,然后在forward里把这些子结构和其他层组合起来。这种混合用法在实际项目中非常常见,后面会详细讲。

4. class继承方式的核心细节与实操要点

4.1 必须实现的两个方法:__init__与forward

用class方式搭建网络,框架对你有两个硬性要求:在__init__中定义所有可学习的参数和层,在forward中定义前向传播的计算逻辑。这两个方法的分工非常明确——__init__负责“有什么”,forward负责“怎么算”。

import torch import torch.nn as nn class Net(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(784, 256) self.fc2 = nn.Linear(256, 128) self.fc3 = nn.Linear(128, 10) self.relu = nn.ReLU() def forward(self, x): x = self.relu(self.fc1(x)) x = self.relu(self.fc2(x)) x = self.fc3(x) return x

这段代码定义了一个和前面Sequential版本等价的三层全连接网络。super().__init__()这行必须放在最前面,它负责初始化父类的内部状态,漏掉这行会导致后续的层注册失败。

4.2 forward方法的编写技巧与调试要点

forward方法是你完全掌控的地方,但也是最容易出问题的地方。几个实操要点:

第一,变量复用要小心。上面代码里我反复用x来接收每一层的输出,这是常见写法,但要注意每次赋值后原来的x就被覆盖了。如果你需要在后面引用某一层的原始输出,必须用不同的变量名保存。

第二,维度变化要心中有数。每经过一层,张量的形状可能发生变化。全连接层会改变最后一维的大小,卷积层会改变空间维度,池化层会缩小特征图。在写forward的时候,最好在关键步骤后打印一下张量形状来确认:

def forward(self, x): x = self.fc1(x) print(x.shape) # 调试用,确认维度 x = self.relu(x) # ...

第三,不要在forward里定义层。这是一个高频错误。有些人图省事,在forward里直接写nn.Linear(...),这样每次前向传播都会重新初始化一个层,参数完全无法学习。层必须在__init__里定义,forward里只做调用。

4.3 复杂结构的实现:以残差块为例

class方式真正的用武之地是复杂结构。以残差块为例,它需要把输入和经过两层卷积后的输出相加:

class ResidualBlock(nn.Module): def __init__(self, channels): super().__init__() self.conv1 = nn.Conv2d(channels, channels, 3, padding=1) self.bn1 = nn.BatchNorm2d(channels) self.conv2 = nn.Conv2d(channels, channels, 3, padding=1) self.bn2 = nn.BatchNorm2d(channels) self.relu = nn.ReLU() def forward(self, x): identity = x out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out = out + identity out = self.relu(out) return out

这个残差块用Sequential是无法直接表达的,因为out + identity这个操作需要同时访问块的输入和中间输出。在forward里,identity保存了输入,out经过两层卷积后与identity相加,实现了跳跃连接。

4.4 层注册机制:为什么self.xxx能自动被识别

框架通过__setattr__的拦截机制,自动把赋值给self的Module类型属性注册到网络的参数列表中。这意味着只要你写了self.fc1 = nn.Linear(...),fc1的权重和偏置就会自动出现在model.parameters()里,优化器就能找到它们。

这个机制有一个容易踩的坑:如果你把层放在Python列表或字典里,它们不会被自动注册。比如:

# 错误写法:层不会被注册 self.layers = [nn.Linear(10, 10) for _ in range(3)] # 正确写法:使用ModuleList self.layers = nn.ModuleList([nn.Linear(10, 10) for _ in range(3)])

nn.ModuleList和nn.ModuleDict是专门用来解决这个问题的容器,它们继承自Module,内部的层会被正确注册。我在早期项目中因为这个问题导致部分层完全没有参与训练,损失下降异常缓慢,排查了很久才发现是层没有被注册到参数列表中。

提示:判断层是否被正确注册,可以在定义完模型后打印len(list(model.parameters())),对比你预期的参数数量。如果数量不对,大概率是某些层没有被正确注册。

5. 完整实操流程:从数据到可训练模型

5.1 数据准备与预处理

为了让整个流程可复现,我用一个模拟的图像分类任务来演示。假设输入是28x28的灰度图,共10个类别。数据预处理包括归一化和张量转换:

import torch from torch.utils.data import DataLoader, TensorDataset # 模拟数据:1000个样本,每个样本784维 X = torch.randn(1000, 784) y = torch.randint(0, 10, (1000,)) # 归一化 X = (X - X.mean()) / X.std() dataset = TensorDataset(X, y) dataloader = DataLoader(dataset, batch_size=32, shuffle=True)

归一化这一步很关键。如果输入特征的尺度差异很大,梯度下降会走得很不稳定。我通常的做法是计算训练集的均值和标准差,然后用同一组参数去归一化验证集和测试集,避免数据泄露。

5.2 用Sequential快速搭建基线模型

先用Sequential搭一个简单的基线,确认整个训练流程能跑通:

model_seq = nn.Sequential( nn.Linear(784, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 10) )

这里加了Dropout层来防止过拟合。Dropout在训练时随机将一部分神经元的输出置零,相当于每次迭代都在训练一个不同的子网络,能有效提升泛化能力。注意Dropout只在训练时生效,推理时会自动关闭。

5.3 用class实现同样的结构并对比

用class方式实现同样的网络:

class NetClass(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(784, 256) self.fc2 = nn.Linear(256, 128) self.fc3 = nn.Linear(128, 10) self.relu = nn.ReLU() self.dropout = nn.Dropout(0.3) def forward(self, x): x = self.dropout(self.relu(self.fc1(x))) x = self.dropout(self.relu(self.fc2(x))) x = self.fc3(x) return x

两个模型在结构上完全等价,参数量也相同。你可以分别训练它们,观察损失曲线是否一致。如果一致,说明两种搭建方式在简单结构下没有本质区别;如果不一致,大概率是某个细节写错了,比如Dropout的位置不同。

5.4 训练循环与关键参数设置

训练循环的代码框架如下:

def train_model(model, dataloader, epochs=20, lr=1e-3): criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=lr) for epoch in range(epochs): model.train() total_loss = 0 for batch_x, batch_y in dataloader: optimizer.zero_grad() output = model(batch_x) loss = criterion(output, batch_y) loss.backward() optimizer.step() total_loss += loss.item() print(f"Epoch {epoch+1}, Loss: {total_loss/len(dataloader):.4f}")

几个关键点:model.train()和model.eval()的切换很重要,它们会影响Dropout和BatchNorm的行为。optimizer.zero_grad()必须在反向传播前调用,否则梯度会累积。学习率用Adam的默认值1e-3通常是个不错的起点。

5.5 模型保存与加载的注意事项

保存模型时,推荐保存state_dict而不是整个模型对象:

torch.save(model.state_dict(), "model.pth") # 加载时需要先实例化模型结构 model = NetClass() model.load_state_dict(torch.load("model.pth"))

保存整个模型对象虽然方便,但依赖具体的类定义路径,换一个项目结构就可能加载失败。保存state_dict只包含参数张量,与代码结构解耦,更加可靠。

6. 常见问题与排查技巧实录

6.1 维度不匹配报错:定位与解决

维度不匹配是最高频的错误。典型报错信息是mat1 and mat2 shapes cannot be multiplied,意思是全连接层的输入维度和权重矩阵的维度对不上。

排查思路:从报错信息中找到是哪一层出了问题,然后检查该层定义的输入维度和实际传入的维度。常见原因包括:前一层的输出维度算错了、输入数据的形状没有展平、或者卷积层到全连接层的过渡维度计算错误。

以卷积网络为例,卷积层输出的是四维张量(batch, channels, height, width),全连接层需要二维张量(batch, features)。中间必须加一个展平操作:

x = x.view(x.size(0), -1) # 展平,保留batch维度

x.size(0)是batch大小,-1让框架自动计算剩余维度。这个操作在class的forward里很常见,Sequential里则需要用nn.Flatten()层来实现。

6.2 参数未更新:检查层是否被正确注册

如果训练时损失几乎不下降,一个可能的原因是部分层没有被注册到参数列表中。前面提到的nn.ModuleList问题是一个典型场景。另一个场景是在__init__中定义了层但忘记赋值给self:

# 错误:层没有被注册 fc = nn.Linear(10, 10) # 正确 self.fc = nn.Linear(10, 10)

排查方法:打印len(list(model.parameters())),和手动计算的参数量对比。也可以遍历model.named_parameters(),看看每一层的名字和形状是否符合预期。

6.3 训练与推理结果不一致:模式切换问题

Dropout和BatchNorm在训练和推理时的行为不同。如果忘记切换模式,推理时Dropout仍然随机置零,BatchNorm仍然使用当前batch的统计量,会导致结果不稳定。

标准做法是在训练循环开始时调用model.train(),在验证和测试时调用model.eval()。配合torch.no_grad()上下文管理器来关闭梯度计算,节省显存:

model.eval() with torch.no_grad(): for batch_x, batch_y in test_loader: output = model(batch_x) # 计算准确率等指标

6.4 常见问题速查表

问题现象可能原因排查方法
维度不匹配报错层定义维度与实际输入不符打印每层输入输出形状
损失不下降层未注册、学习率过小、数据未归一化检查parameters数量、调整学习率
训练推理结果差异大未切换train/eval模式确认eval()调用位置
显存溢出batch过大、模型过大、未关闭梯度减小batch、用no_grad推理
梯度爆炸学习率过大、网络过深降低学习率、加BatchNorm

6.5 独家避坑经验分享

第一个经验:先用小数据跑通再上全量。我习惯先用几十个样本训练几个epoch,确认模型能过拟合(损失降到接近零),然后再用全量数据训练。如果小数据都过拟合不了,说明模型结构或训练逻辑有问题,这时候调参没有意义。

第二个经验:Sequential和class可以混用。在class的__init__里用Sequential定义重复的子结构,然后在forward里组合,这样既保留了灵活性又减少了代码量。比如特征提取部分用Sequential,分类头用单独的层定义。

第三个经验:给层起有意义的名字。用class方式时,self.conv1、self.conv2这种命名在层数多了以后很难对应到具体位置。我习惯用self.encoder_conv1、self.decoder_fc1这种带功能前缀的命名,调试时一眼就能看出问题出在哪个模块。

7. 从能跑到好用:工程化组织建议

7.1 模块化拆分:把网络拆成可复用的块

当网络结构变复杂时,把所有层塞在一个class里会变得难以维护。更好的做法是把网络拆成多个小的Module,每个Module负责一个功能块,然后在一个顶层Module里组合它们。

比如一个图像分类网络可以拆成特征提取器和分类器两部分:

class FeatureExtractor(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 32, 3, padding=1) self.conv2 = nn.Conv2d(32, 64, 3, padding=1) self.pool = nn.MaxPool2d(2) self.relu = nn.ReLU() def forward(self, x): x = self.pool(self.relu(self.conv1(x))) x = self.pool(self.relu(self.conv2(x))) return x class Classifier(nn.Module): def __init__(self, in_features, num_classes): super().__init__() self.fc = nn.Linear(in_features, num_classes) def forward(self, x): return self.fc(x) class FullNet(nn.Module): def __init__(self): super().__init__() self.features = FeatureExtractor() self.classifier = Classifier(64*7*7, 10) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x

这种拆分方式的好处是每个模块可以独立测试和替换。比如你想换一个更深的特征提取器,只需要修改FeatureExtractor,分类器部分完全不用动。

7.2 参数初始化:默认值与手动调整

框架对不同类型的层有默认的初始化策略,大多数情况下够用。但如果你发现训练初期损失异常大或者梯度消失,可以尝试手动初始化:

def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.zeros_(m.bias) model.apply(init_weights)

model.apply()会递归遍历所有子模块,对符合条件的层执行初始化函数。Kaiming初始化适合ReLU激活函数,能让每层的输出方差保持一致,缓解梯度消失问题。

7.3 模型复杂度与训练效率的平衡

网络不是越深越好。层数增加会带来参数量增长、训练时间变长、过拟合风险上升。我的经验是先从浅层网络开始,确认基线效果后再逐步加深。每次加深一层,观察验证集效果是否有提升。如果没有提升甚至下降,说明当前数据量或任务复杂度不需要更深的网络。

另一个实用技巧是用全局平均池化替代全连接层来减少参数量。在卷积网络末端,与其把特征图展平后接一个巨大的全连接层,不如对每个通道取平均值直接得到类别得分。这样参数量大幅减少,过拟合风险也随之降低。

7.4 调试工具与可视化手段

调试网络结构时,除了打印张量形状,还可以用torchsummary这类工具来查看每层的输出形状和参数量。虽然需要额外安装,但在排查维度问题时非常高效。

另一个习惯是在训练过程中记录损失和准确率曲线。如果训练损失下降但验证损失上升,说明过拟合了,需要加正则化或减少模型复杂度。如果两者都下降但验证损失波动很大,可能是batch size太小或学习率太高。

提示:不要等到模型完全训练完才看结果。训练几个epoch后就应该检查损失是否在合理范围内下降。如果前几个epoch损失几乎不变,大概率是学习率设置有问题或者数据预处理有误,继续训练只是浪费时间。

8. 两种方式的混合使用与进阶思路

8.1 在class中嵌入Sequential简化代码

实际项目中,我经常在class的__init__里用Sequential来定义那些结构固定、不需要跨层访问的子模块。比如一个分类头:

class Net(nn.Module): def __init__(self): super().__init__() self.backbone = nn.Sequential( nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2) ) self.classifier = nn.Linear(128*8*8, 10) def forward(self, x): x = self.backbone(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x

backbone部分用Sequential定义,代码紧凑且一目了然。forward里只需要处理展平和分类两步,逻辑清晰。这种混合写法在工程实践中非常常见,兼顾了简洁性和灵活性。

8.2 动态网络:在forward中实现条件分支

class方式的另一个优势是可以在forward中根据输入动态改变计算路径。比如实现一个简单的门控机制:

def forward(self, x): if x.mean() > 0: x = self.branch_a(x) else: x = self.branch_b(x) return x

这种动态性在Sequential中是无法实现的。当然,实际项目中这种硬编码的条件分支很少见,更常见的是通过注意力机制或门控单元来软性地组合不同分支的输出。

8.3 从单机到部署:结构定义的一致性

无论用哪种方式搭建,最终模型都需要保存和加载。class方式定义的模型在加载时必须保证类定义可访问,否则load_state_dict会失败。一个实用的做法是把模型定义单独放在一个模块文件里,训练脚本和推理脚本都从这个模块导入,确保结构一致。

如果需要在不同项目间共享模型,可以考虑用torch.jit.script或torch.jit.trace把模型转换为TorchScript格式。这样模型结构和参数打包在一起,不依赖Python类定义,部署时更加方便。不过TorchScript对动态控制流的支持有限,复杂的forward逻辑可能需要调整。

8.4 个人经验:从Sequential起步,按需迁移到class

我自己的习惯是,任何新任务先用Sequential搭一个最简基线,确认数据管道和训练流程没问题。然后根据任务复杂度决定是否迁移到class。如果任务需要多输入、多输出、跨层连接或动态路径,就迁移;否则保持Sequential,代码更简洁,维护成本更低。

迁移的过程也很简单:把Sequential里的层逐个搬到class的__init__里,然后在forward里按原顺序调用。这个过程本身也是一次代码审查,能帮你发现一些之前忽略的结构问题。

最后分享一个小技巧:在class的forward里,可以用print或日志记录每一层的输出均值和标准差。如果某一层的输出均值接近零、标准差极小,说明该层的激活值饱和了,可能需要调整初始化或加BatchNorm。这个检查在训练初期做一次,能提前发现很多潜在问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询