1. 从零理解神经网络搭建的两条路径刚接触深度学习框架那会儿我最大的困惑不是反向传播怎么推导也不是梯度消失怎么解决而是面对一个最简单的全连接网络到底该用Sequential一把梭还是老老实实写一个继承nn.Module的类。这个问题看起来像是“用筷子还是用叉子”的口味之争但实际写多了之后你会发现两种方式背后对应的是完全不同的工程思维一种是快速验证想法的原型模式另一种是面向复杂结构、需要精细控制的工程模式。Sequential和class这两种搭建方式本质上解决的是同一个问题——把一层一层的网络结构组织起来让数据能够从前向后流动让梯度能够从后向前回传。但它们适用的场景、灵活程度、可维护性差别非常大。如果你只是想在几分钟内跑通一个MNIST分类器Sequential可能三行代码就搞定了但如果你要做一个带残差连接、多分支输入、动态路由或者自定义前向传播逻辑的网络那必须用class的方式继承nn.Module自己定义__init__和forward。这篇文章适合谁看如果你刚学完PyTorch的基础张量操作正准备动手搭第一个神经网络那这篇内容能帮你少走很多弯路。如果你已经用过Sequential但遇到复杂结构就不知道怎么下手那这里会详细拆解从Sequential过渡到class的完整思路。如果你是有一定经验的开发者想回顾一下两种方式的底层机制和选型边界也能从中找到一些实操层面的参考。我个人的经验是不要一上来就追求“高级写法”。很多教程一上来就教你写class Net(nn.Module)结果新手连层是怎么注册的、参数是怎么初始化的都没搞明白写出来的代码虽然能跑但一旦报错就完全不知道从哪查起。反过来如果一直停留在Sequential里遇到需要共享权重、多输入多输出、跳跃连接的时候就会卡死。所以正确的路径是先用Sequential建立对网络结构的基本直觉再通过class掌握对计算图的完全控制权。接下来我会从设计思路、核心机制、实操步骤、常见问题四个维度把这两种搭建方式彻底讲透。每一部分都会配上可以直接运行的代码和参数说明确保你看完就能动手复现。2. 两种搭建方式的设计思路与选型逻辑2.1 Sequential的本质一个有序的层容器Sequential的核心逻辑非常简单它就是一个有序容器你按照数据流动的顺序把层塞进去它内部维护一个ModuleList在前向传播时按顺序依次调用每一层。用生活化的类比来说它就像一条流水线原料从一头进去经过固定的几个工位加工成品从另一头出来。每个工位只负责一道工序工位之间的顺序不能乱也不能跳过。这种设计的好处是声明式的——你只需要描述“网络长什么样”不需要描述“数据怎么流”。框架帮你把前向传播的逻辑自动生成好了。对于标准的“卷积-激活-池化-全连接”这种线性堆叠结构Sequential的表达力完全够用而且代码极其简洁。但它的局限也很明显。第一所有层必须按顺序执行没办法做分支。第二层与层之间不能有复杂的交互比如某一层的输出同时送给后面两层或者两层的输出相加之后再往下传这些Sequential都做不到。第三无法自定义前向传播中的额外操作比如你在某一步需要对特征图做reshape、做掩码、做条件判断Sequential没有地方让你插入这些逻辑。2.2 class方式的本质自定义计算图的完全控制继承nn.Module写一个类核心是两件事在__init__里声明所有需要用到的层和参数在forward里定义数据如何流经这些层。框架通过nn.Module的机制自动帮你完成参数的注册、梯度的追踪、设备的迁移、模型的保存和加载。这种方式就像你自己画一张工厂的工艺流程图每个工位怎么连、物料怎么走、哪里需要分流、哪里需要合流全部由你决定。灵活性极高但代价是你必须自己保证前向传播的逻辑正确框架不会帮你检查层的连接顺序是否合理。从工程角度看class方式还有一个隐性优势它让网络结构成为一个可复用、可继承、可组合的组件。你可以写一个基础模块然后通过继承和重写forward来派生新的变体。这在研究场景中特别常见比如你想对比不同残差连接方式的效果只需要改forward里的几行代码而不需要重新搭整个网络。2.3 选型决策什么时候用哪个我整理了一个简单的决策表你在动手之前可以先对照一下判断维度优先用 Sequential优先用 class网络结构线性堆叠无分支有分支、跳跃连接、多输入多输出前向逻辑标准逐层传递需要reshape、条件判断、自定义运算开发阶段快速原型验证正式项目、需要长期维护代码复用一次性实验需要继承、组合、参数共享调试需求结构简单容易定位需要逐层打印、自定义hook团队协作个人快速实验多人协作接口需要清晰实际工作中我经常是混用的在一个大的class网络里某个子模块如果就是简单的几层堆叠直接用Sequential包起来既简洁又不影响整体灵活性。比如特征提取部分用Sequential后面的多分支融合部分用class手动写。这种“大框架用类小模块用容器”的做法在工程上非常实用。提示不要因为class更灵活就无脑用class。我见过不少代码一个简单的三层全连接网络非要写一个类结果__init__里定义了七八个属性forward里就一行x self.fc(x)这种写法除了增加阅读负担之外没有任何好处。3. 核心机制拆解与关键细节3.1 nn.Module的参数注册机制理解class方式的关键在于搞明白nn.Module是怎么管理参数的。当你在__init__里写self.fc nn.Linear(10, 5)的时候nn.Module的__setattr__方法会被触发它会检查你赋的值是不是nn.Parameter或者nn.Module的实例。如果是就自动把它注册到self._parameters或self._modules字典里。这就是为什么你不需要手动维护一个参数列表model.parameters()就能拿到所有可训练参数。这个机制有一个非常重要的推论如果你用普通的Python列表来存层参数不会被注册。比如下面这种写法就是错误的# 错误写法参数不会被注册 class BadNet(nn.Module): def __init__(self): super().__init__() self.layers [nn.Linear(10, 10) for _ in range(3)] def forward(self, x): for layer in self.layers: x layer(x) return x这段代码能跑但model.parameters()返回的是空列表优化器拿不到任何参数训练时loss根本不会下降。正确的做法是用nn.ModuleList# 正确写法使用ModuleList class GoodNet(nn.Module): def __init__(self): super().__init__() self.layers nn.ModuleList([nn.Linear(10, 10) for _ in range(3)]) def forward(self, x): for layer in self.layers: x layer(x) return xnn.ModuleList本身也是一个nn.Module它内部的每个元素都会被正确注册。类似的还有nn.ModuleDict用于需要按名称索引层的场景。这个坑我在早期项目中踩过当时loss一直不降查了半天才发现是用了普通列表。3.2 Sequential的索引与切片操作Sequential支持像列表一样的索引和切片这在调试和迁移学习中很有用。比如你可以通过model[0]拿到第一层通过model[:3]拿到前三层组成一个新的Sequential。这个特性在做特征提取的时候特别方便import torch.nn as nn backbone nn.Sequential( nn.Conv2d(3, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), ) # 只取前四层作为特征提取器 feature_extractor backbone[:4]但要注意切片返回的是一个新的Sequential对象它和原模型共享底层层的参数不是深拷贝。也就是说你修改feature_extractor里的参数原backbone对应的层也会变。这个行为在大多数场景下是符合预期的但如果你需要独立的副本得用copy.deepcopy。3.3 forward方法的调用链路当你执行output model(input)的时候Python实际上调用的是model.__call__(input)而nn.Module的__call__方法内部会做一些框架层面的处理比如hook的触发然后再调用你定义的forward方法。所以永远不要直接调用model.forward(input)虽然结果看起来一样但会跳过hook机制导致一些依赖hook的功能失效。这个细节在调试时很重要。比如你想用register_forward_hook来打印中间层的输出如果直接调forwardhook是不会被触发的。正确的做法始终是用model(input)的形式。3.4 参数初始化的时机与方式Sequential和class方式在参数初始化上没有本质区别都是在层被创建的时候用默认的初始化策略。PyTorch的nn.Linear默认用Kaiming均匀初始化nn.Conv2d也是类似的策略。但如果你需要自定义初始化两种方式的操作位置不同。对于Sequential你可以在构建完成之后遍历所有模块def init_weights(m): if isinstance(m, nn.Linear): nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias) model nn.Sequential(nn.Linear(10, 5), nn.ReLU(), nn.Linear(5, 2)) model.apply(init_weights)对于class方式你可以在__init__里定义完层之后立即调用初始化函数也可以在外面统一处理。我个人的习惯是写一个通用的_init_weights方法在__init__末尾调用这样每个模块的初始化逻辑都封装在类内部更清晰。注意model.apply()会递归地对所有子模块调用传入的函数包括Sequential内部的层。但如果你在class的__init__里已经初始化过了再调apply会覆盖之前的初始化结果。所以要么在类内部初始化要么在外面统一初始化不要两边都做。4. 完整实操从Sequential到class的渐进式搭建4.1 用Sequential快速搭建一个基准模型我们先从一个最经典的手写数字分类任务出发用Sequential搭一个三层全连接网络。这个模型虽然简单但包含了神经网络搭建的所有核心要素层定义、激活函数、前向传播、损失计算、优化器配置。import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms # 数据准备 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) # 用Sequential搭建模型 model nn.Sequential( nn.Flatten(), nn.Linear(28 * 28, 256), nn.ReLU(), nn.Dropout(0.2), nn.Linear(256, 128), nn.ReLU(), nn.Dropout(0.2), nn.Linear(128, 10) ) # 训练配置 device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) # 训练循环 for epoch in range(5): model.train() running_loss 0.0 for batch_idx, (data, target) in enumerate(train_loader): data, target data.to(device), target.to(device) optimizer.zero_grad() output model(data) loss criterion(output, target) loss.backward() optimizer.step() running_loss loss.item() print(fEpoch {epoch1}, Loss: {running_loss / len(train_loader):.4f})这段代码里有几个细节值得展开。nn.Flatten()把(batch, 1, 28, 28)的输入展平成(batch, 784)这样后面的全连接层才能处理。Dropout(0.2)在训练时随机丢弃20%的神经元输出起到正则化作用推理时自动关闭。model.train()和model.eval()的切换会影响Dropout和BatchNorm的行为这个后面会详细讲。这个模型在MNIST上跑5个epoch测试准确率大概能到97%左右。作为基准它足够简单也足够有效。4.2 用class方式重写同一个模型现在我们把上面这个Sequential模型用class方式重写一遍功能完全等价但结构上更清晰也为后续扩展留出空间。class MLP(nn.Module): def __init__(self, input_dim28*28, hidden_dim256, num_classes10): super().__init__() self.flatten nn.Flatten() self.fc1 nn.Linear(input_dim, hidden_dim) self.relu1 nn.ReLU() self.dropout1 nn.Dropout(0.2) self.fc2 nn.Linear(hidden_dim, hidden_dim // 2) self.relu2 nn.ReLU() self.dropout2 nn.Dropout(0.2) self.fc3 nn.Linear(hidden_dim // 2, num_classes) def forward(self, x): x self.flatten(x) x self.fc1(x) x self.relu1(x) x self.dropout1(x) x self.fc2(x) x self.relu2(x) x self.dropout2(x) x self.fc3(x) return x model MLP()对比一下就能发现class方式的代码量明显更多但它的优势在于每一层的命名是显式的。在Sequential里层是用索引访问的model[0]是Flattenmodel[1]是第一个Linear这种索引在层数多了之后非常容易搞混。而在class里你可以用model.fc1、model.dropout1这样的名字直接访问可读性高很多。更重要的是forward方法给了你完全的控制权。比如你想在第一个全连接层之后加一个残差连接只需要改forwarddef forward(self, x): x self.flatten(x) identity x x self.fc1(x) x self.relu1(x) x self.dropout1(x) # 假设维度匹配加一个残差 if x.shape identity.shape: x x identity x self.fc2(x) x self.relu2(x) x self.dropout2(x) x self.fc3(x) return x这种修改在Sequential里是做不到的因为Sequential没有地方让你写“把某一层的输入和输出相加”这种逻辑。4.3 混合使用大框架用class子模块用Sequential实际项目里我很少纯用某一种方式。更常见的做法是整体网络用class定义因为需要控制前向传播的流程但每个子模块如果就是简单的几层堆叠直接用Sequential包起来。这样既保留了灵活性又减少了代码量。class HybridNet(nn.Module): def __init__(self, num_classes10): super().__init__() # 特征提取部分简单的卷积堆叠用Sequential self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), ) # 分类头需要自定义逻辑用class方式 self.classifier nn.Sequential( nn.Flatten(), 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这个结构在工程上非常常见。features部分负责从输入中提取特征结构固定用Sequential简洁明了classifier部分负责最终的分类决策虽然这里也是Sequential但如果需要加多分支或者自定义逻辑随时可以改成手动写的forward。4.4 参数初始化与设备迁移的实操细节在class方式里参数初始化可以在__init__里完成也可以在外面统一处理。我推荐在类内部写一个初始化方法这样模型定义和初始化逻辑绑定在一起不容易遗漏。class MLPWithInit(nn.Module): def __init__(self, input_dim784, hidden_dim256, num_classes10): super().__init__() self.flatten nn.Flatten() self.fc1 nn.Linear(input_dim, hidden_dim) self.fc2 nn.Linear(hidden_dim, hidden_dim // 2) self.fc3 nn.Linear(hidden_dim // 2, num_classes) self.relu nn.ReLU() self.dropout nn.Dropout(0.2) self._init_weights() def _init_weights(self): for m in self.modules(): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) if m.bias is not None: nn.init.zeros_(m.bias) def forward(self, x): x self.flatten(x) x self.dropout(self.relu(self.fc1(x))) x self.dropout(self.relu(self.fc2(x))) x self.fc3(x) return x设备迁移方面model.to(device)会递归地把所有注册的参数和缓冲区搬到指定设备。但要注意如果你在forward里手动创建了新的张量比如torch.zeros(...)这些张量不会自动迁移需要显式指定device或者用x.new_zeros(...)来保证设备一致。实操心得我习惯在forward里用x.new_zeros()而不是torch.zeros()因为前者会自动继承x的设备和数据类型避免手动指定设备时出错。这个技巧在处理多GPU或者混合精度训练时特别有用。5. 常见问题与排查技巧实录5.1 维度不匹配最常见的报错及定位方法维度不匹配是新手遇到最多的错误典型报错信息是RuntimeError: mat1 and mat2 shapes cannot be multiplied。这个错误的意思是全连接层的输入维度和权重矩阵的维度对不上。排查思路很简单在forward里逐层打印张量的shape。比如def forward(self, x): print(fInput: {x.shape}) x self.flatten(x) print(fAfter flatten: {x.shape}) x self.fc1(x) print(fAfter fc1: {x.shape}) # ... 以此类推 return x跑一个batch的数据看哪一层的输出shape和下一层的期望输入对不上。常见的原因有Flatten之后维度算错了、卷积层的padding和stride设置导致特征图尺寸变化、view或reshape用错了维度顺序。对于卷积层输出尺寸的计算公式是H_out (H_in 2 * padding - kernel_size) / stride 1比如输入28x28卷积核3x3padding1stride1输出就是(28 2 - 3) / 1 1 28尺寸不变。如果stride2输出就是(28 2 - 3) / 2 1 14尺寸减半。这个公式在手动计算全连接层输入维度时非常关键。5.2 参数未注册loss不下降的隐形杀手前面提到过用普通Python列表存层会导致参数不被注册。这个问题隐蔽性很强因为代码能跑不报错只是loss不降。排查方法是打印len(list(model.parameters()))如果返回0或者数量明显不对那就是参数注册出了问题。另一个容易忽略的场景是在forward里动态创建层。比如def forward(self, x): # 错误每次前向传播都新建一个层参数不会被注册到模型 fc nn.Linear(x.shape[-1], 10).to(x.device) return fc(x)这种写法每次调用forward都会创建一个新的Linear层参数是随机的而且不会被注册到模型的参数列表里优化器根本更新不到。正确的做法是在__init__里就把层定义好。5.3 train和eval模式的切换陷阱Dropout和BatchNorm在训练和推理时的行为不同。Dropout在训练时随机置零推理时不做任何处理BatchNorm在训练时用当前batch的统计量推理时用全局的滑动平均统计量。如果你忘了切换模式推理结果会不稳定准确率明显下降。我见过一个典型的bug训练时准确率99%推理时只有10%。查了半天发现是推理时没有调model.eval()Dropout还在随机丢弃神经元导致输出完全乱掉。所以训练循环里一定要写model.train()验证和推理时一定要写model.eval()并且配合torch.no_grad()来关闭梯度计算节省显存。# 训练 model.train() for data, target in train_loader: # ... 训练逻辑 # 验证 model.eval() with torch.no_grad(): for data, target in val_loader: output model(data) # ... 计算准确率5.4 常见问题速查表问题现象可能原因排查方法解决方案loss不下降参数未注册len(list(model.parameters()))用nn.ModuleList替代普通列表维度报错层之间维度不匹配逐层打印shape检查Flatten和卷积输出尺寸推理准确率骤降未切换eval模式检查是否调用model.eval()推理前加model.eval()和no_grad显存溢出batch过大或未释放中间变量减小batch检查no_grad用torch.cuda.empty_cache()梯度为None层未参与前向传播打印各层梯度检查forward是否遗漏了某层训练速度慢数据加载瓶颈检查DataLoader的num_workers增大num_workers开启pin_memory避坑技巧在forward里加一个assert来检查张量形状比等到报错再查要高效得多。比如assert x.shape[1] self.fc1.in_features这样一旦维度不对立刻就能定位到具体是哪一层的问题。6. 进阶扩展从两种搭建方式延伸出的实用模式6.1 用Sequential实现快速层替换实验做实验的时候经常需要对比不同层配置的效果比如把ReLU换成GELU或者把Dropout的比率从0.2调到0.5。用Sequential的话可以直接通过索引替换model nn.Sequential( nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10) ) # 替换激活函数 model[1] nn.GELU()这种操作在class方式里需要重新定义整个类或者修改__init__不如Sequential灵活。所以在做超参数搜索或者消融实验的时候Sequential的简洁性优势非常明显。6.2 用class实现权重共享与多分支结构权重共享是指同一个层在网络中被多次使用。比如Siamese网络两个输入共享同一个特征提取器。这种结构用Sequential无法表达必须用classclass SiameseNet(nn.Module): def __init__(self): super().__init__() self.encoder nn.Sequential( nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 128) ) self.classifier nn.Linear(128, 1) def forward(self, x1, x2): feat1 self.encoder(x1) feat2 self.encoder(x2) diff torch.abs(feat1 - feat2) return self.classifier(diff)这里self.encoder被调用了两次参数是共享的。Sequential做不到这一点因为它只能按顺序执行一次。多分支结构也是类似。比如Inception模块一个输入同时送给多个不同尺寸的卷积核然后把结果拼接起来。这种结构必须用class手动写forwardclass InceptionBlock(nn.Module): def __init__(self, in_channels): super().__init__() self.branch1 nn.Conv2d(in_channels, 32, 1) self.branch2 nn.Conv2d(in_channels, 32, 3, padding1) self.branch3 nn.Conv2d(in_channels, 32, 5, padding2) def forward(self, x): b1 self.branch1(x) b2 self.branch2(x) b3 self.branch3(x) return torch.cat([b1, b2, b3], dim1)6.3 模型保存与加载的注意事项Sequential和class方式在保存和加载上没有本质区别都是用state_dict。但class方式有一个额外的注意点加载模型时需要先实例化类再加载参数。如果类的定义发生了变化比如改了层名加载会失败。# 保存 torch.save(model.state_dict(), model.pth) # 加载 model MLP() # 必须先实例化 model.load_state_dict(torch.load(model.pth)) model.eval()我个人的习惯是保存整个模型的结构信息比如用torch.save(model, model.pth)但这种方式在跨版本或者跨设备加载时容易出问题。更稳妥的做法是只保存state_dict然后在代码里维护模型的定义。如果模型结构经常变可以把类的定义和超参数一起保存到一个配置字典里加载时先读配置再实例化。6.4 用hook机制调试中间层输出class方式的一个隐性优势是更容易配合hook做调试。比如你想看某一层的输出分布可以注册一个forward_hookdef print_stats(module, input, output): print(f{module.__class__.__name__}: mean{output.mean():.4f}, std{output.std():.4f}) model.fc1.register_forward_hook(print_stats)这样每次前向传播经过fc1时都会自动打印输出的均值和标准差。这个技巧在排查梯度消失、激活值饱和等问题时非常有用。Sequential也支持hook但因为层是用索引访问的写起来不如class方式直观。实操心得在训练初期我习惯给每一层都注册一个hook打印输出的均值和方差。如果某一层的输出方差接近0说明激活值饱和了可能需要调整初始化或者换激活函数。如果方差爆炸式增长说明梯度可能不稳定需要加BatchNorm或者调小学习率。这个习惯帮我省了很多调参时间。7. 我个人的一些实操体会写了这么多年代码关于Sequential和class的选择我最大的体会是不要为了用而用要根据实际需求来。刚开始学的时候我总觉得写class显得更专业结果一个简单的全连接网络写了一百多行调试的时候反而更麻烦。后来做项目多了才发现真正重要的是代码的可读性和可维护性而不是用了多高级的写法。另一个体会是混合使用是常态。一个成熟的深度学习项目里往往既有Sequential也有class甚至还有nn.ModuleList、nn.ModuleDict、nn.ParameterList等各种容器。关键是要理解每种工具的设计意图在合适的场景用合适的工具。最后分享一个小技巧如果你不确定该用哪种方式可以先花五分钟用Sequential搭一个能跑通的版本然后再根据需求逐步改写成class。这个渐进式的过程比一上来就设计一个复杂的类要高效得多也更容易定位问题。毕竟能跑通的代码才是好代码能快速迭代的代码才是好工程。