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, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), 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来打印中间层的输出,如果直接调forward,hook是不会被触发的。正确的做法始终是用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', train=True, download=True, transform=transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) # 用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(), lr=1e-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(f'Epoch {epoch+1}, 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_dim=28*28, hidden_dim=256, num_classes=10): 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]是Flatten,model[1]是第一个Linear,这种索引在层数多了之后非常容易搞混。而在class里,你可以用model.fc1、model.dropout1这样的名字直接访问,可读性高很多。
更重要的是,forward方法给了你完全的控制权。比如你想在第一个全连接层之后加一个残差连接,只需要改forward:
def 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_classes=10): super().__init__() # 特征提取部分:简单的卷积堆叠,用Sequential self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), 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_dim=784, hidden_dim=256, num_classes=10): 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, mode='fan_out', nonlinearity='relu') 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(f'Input: {x.shape}') x = self.flatten(x) print(f'After flatten: {x.shape}') x = self.fc1(x) print(f'After fc1: {x.shape}') # ... 以此类推 return x跑一个batch的数据,看哪一层的输出shape和下一层的期望输入对不上。常见的原因有:Flatten之后维度算错了、卷积层的padding和stride设置导致特征图尺寸变化、view或reshape用错了维度顺序。
对于卷积层,输出尺寸的计算公式是:
H_out = (H_in + 2 * padding - kernel_size) / stride + 1比如输入28x28,卷积核3x3,padding=1,stride=1,输出就是(28 + 2 - 3) / 1 + 1 = 28,尺寸不变。如果stride=2,输出就是(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无法表达,必须用class:
class 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手动写forward:
class 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, padding=1) self.branch3 = nn.Conv2d(in_channels, 32, 5, padding=2) def forward(self, x): b1 = self.branch1(x) b2 = self.branch2(x) b3 = self.branch3(x) return torch.cat([b1, b2, b3], dim=1)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_hook:
def 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。这个渐进式的过程比一上来就设计一个复杂的类要高效得多,也更容易定位问题。毕竟,能跑通的代码才是好代码,能快速迭代的代码才是好工程。