很多人在前面两篇已经把环境搭好、把基础网络跑通了,但真正进入项目阶段就会碰到三个躲不开的问题:三个框架到底怎么选、选完之后深度学习模型怎么做得“复杂”起来,以及模型训练完之后怎么落到实际环境里跑起来。这一篇我打算把这些问题一次性讲透,围绕Keras、TensorFlow、PyTorch三个框架,从视觉和自然语言处理两个场景出发,用完整可跑的代码来讲明白“构建复杂神经网络”和“部署”这两件事。如果只看过入门教程、没完整做过一个像样项目,这篇的内容正好是从入门跨到实战最需要的那一层。
1. 一个项目里绕不开的开局:三个框架到底选哪个
先别急着写代码,框架选型这件事值得先说清楚。很多初学者会拿三个框架的API差异当重点,我反倒觉得API熟练度是最好补的,真正难的是理解每个框架背后的设计逻辑和应用场景。我在实际项目里见过太多人用错了工具,最典型的例子是拿着Keras写公司级别的分布式训练,调试性能调到怀疑人生;还有拿着PyTorch做快速原型,却被它的灵活度过高的特性拖慢了进度。
1.1 Keras、TensorFlow、PyTorch的定位拆解
一个相对靠谱的类比是这样:Keras是自动挡汽车,TensorFlow是带一堆专业改装件的越野车,PyTorch则是手动挡的性能轿车。你要跑城市通勤,自动挡最省心;要爬复杂地形,越野车是刚需;要玩车、要琢磨驾驶技术,手动挡的操控感无可替代。
放到深度学习里来理解:
- Keras的核心价值是低认知负荷。Sequential和Functional这两种API几乎把网络搭建的复杂度藏到了最低,适合快速验证idea、做教学场景、搞小型项目。一个残差网络块在Keras里只需要几行代码就能写完。
- TensorFlow的定位是生产级平台。从数据管线到模型导出再到线上服务,它都有体系化的解决方案。TensorFlow 2.x之后把Keras整合成了官方高级API,很多人以为这是把Keras吞并了,更准确的理解是TensorFlow给Keras补上了它最缺的工程化能力,比如分布式策略、TPU支持、模型签名导出这些功能。
- PyTorch的打法是自由和灵活。它的核心数据结构是张量,整个计算图是动态构建的,调试时可以随时打断点看中间数据,这体验对研究型项目和需要频繁改网络结构的任务来说极其友好。类似风格的框架Dynamo出现之后,性能和易用性的兼顾又进了一步。
1.2 具体需求下的选择逻辑
项目里怎么选,我的建议要看四个维度:
一是团队的技术背景。一个以前写Java、写业务代码为主的团队,上手Keras几乎是零门槛,因为思维模型接近,都是把逻辑搭积木式地拼起来。一个研究型团队日常需要大量改结构、做实验,PyTorch的调试体验就是生产力。
二是项目部署目标。模型最终要跑在云端、跑在移动端,还是跑在浏览器里,这个直接决定框架选型。TensorFlow生态里有一整套从训练到服务再到端侧的转换工具,PyTorch这两年虽然在服务化上也补了很多模块,但整体配套还是逊色一些。
三是社区生态。要看团队最熟悉的领域里哪个框架的现成代码最多,比如做时间序列预测、做推荐系统,TensorFlow的官方模型库更全;做视觉大模型微调、做扩散模型,PyTorch社区几乎是一骑绝尘。
四是模型能否顺利落地。很多项目死在模型部署这一步,训练时什么都好,一导出就报错。前两年PyTorch模型部署绕了一圈最后还是转成ONNX再推到生产环境,现在情况好了很多,但架构设计早期仍然要考虑这条链路通不通。
提示:不要因为某个框架在网上被吹得天花乱坠就无脑选它。框架只是手段,解决业务问题才是目的。我在实际项目里见过不止一次——团队用PyTorch做研究,业务侧用TensorFlow做服务,两边的财富无法互相复用,白白浪费大量工时。
2. 图像识别场景:用一个分类Demo串起三种写法和一个完整训练管线
这个部分直接上干货。我拿一个模拟项目做例子:给一个图片分类任务,数据集有12个类别,训练集大概1.5万张图片,每张图尺寸不统一,有一部分带噪声标签。这种场景和大多数人实际项目里遇到的情况非常像,不是网上教程那种漂亮的经典数据集,而是脏、乱、标签还不那么可靠的真实数据。
2.1 用Keras快速实现一个带残差连接的卷积网络
虽然网上随便就能下载到预训练模型,但自己动手从零搭一个带残差连接的小网络,理解价值比调用现成模型大得多。残差连接的核心价值是让梯度有一条“高速公路”可以回传,网络加深之后训练不容易退化,这也是现代深度学习网络敢动辄几百层的前提。
import tensorflow as tf from tensorflow.keras import layers, models def residual_block(x, filters, stride=1): shortcut = x x = layers.Conv2D(filters, 3, strides=stride, padding="same", kernel_initializer="he_normal")(x) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.Conv2D(filters, 3, padding="same", kernel_initializer="he_normal")(x) x = layers.BatchNormalization()(x) if shortcut.shape[-1] != filters or stride != 1: shortcut = layers.Conv2D(filters, 1, strides=stride)(shortcut) shortcut = layers.BatchNormalization()(shortcut) x = layers.Add()([x, shortcut]) x = layers.ReLU()(x) return x inputs = layers.Input(shape=(224, 224, 3)) x = layers.Conv2D(64, 7, strides=2, padding="same")(inputs) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.MaxPooling2D(3, strides=2, padding="same")(x) for filters in [64, 128, 256]: x = residual_block(x, filters, stride=2) x = layers.GlobalAveragePooling2D()(x) x = layers.Dropout(0.4)(x) outputs = layers.Dense(12, activation="softmax")(x) model = models.Model(inputs, outputs) model.compile( optimizer=tf.keras.optimizers.AdamW(learning_rate=1e-3, weight_decay=1e-4), loss="sparse_categorical_crossentropy", metrics=["accuracy"] )这段代码里有几个细节值得展开讲。
残差块中shortcut分支匹配维度这一步很多人第一次写会漏掉。当卷积步长不为1或者输出通道数变化时,shortcut和主分支的张量形状对不上,必须用1x1卷积做投影。这个操作顺手就把降维做了,而且1x1卷积参数量极小,不会给网络增加多少负担。
初始化方法的讲究是另一个容易被忽略的坑。ReLU这类激活函数对初始化很敏感,He初始化是专门为ReLU设计的,它能保证前向传播时每一层输出的方差维持在稳定范围。换成默认的glorot初始化,在浅层网络里差别不大,一旦网络加深,训练很容易陷入梯度异常。
BatchNormalization放在卷积之后、激活之前是先做归一化再做非线性变换。这个顺序是经过大量实验验证的,如果把它放到激活之后,效果会明显打折扣。BN层在训练时会累积全局统计量,保存模型时一定要把这些统计量一并存档。
2.2 PyTorch里的等价实现与动态图调试体验
同样的网络在PyTorch里写出来风格差异很明显,尤其是动态图让调试变得非常直观:
import torch import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): out = torch.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(x) return torch.relu(out)写PyTorch模型最关键的感受是前向传播过程完全透明,可以在forward里随意加print语句、打断点。同样一个残差块,Keras像一个黑盒,输入输出shape只能靠summary或者报错信息去猜,PyTorch则可以明明白白看到每一步张量怎么流转。
PyTorch训练时默认开启梯度追踪,有时测试模型时忘了关会导致显存持续上涨。推理之前务必用model.eval()切换状态,再配合torch.no_grad()关闭梯度计算,这是个所有老手都会下意识做但新手容易踩到的坑。
2.3 训练管线里的细节:数据增强、学习率策略、模型保存
训练一个复杂模型,真正决定上限的不是网络结构,而是训练管线里的细节。数据增强在Keras里可以优雅地写进模型前处理层:
data_augmentation = tf.keras.Sequential([ layers.RandomFlip("horizontal"), layers.RandomRotation(0.05), layers.RandomContrast(0.1), layers.RandomCrop(192, 192), ])在训练时把增强层接到输入后面,在验证和测试时则要关闭。如果数据集总量只有1.5万张,增强相当于免费扩充了样本的多样性,能在一定程度上抑制过拟合。需要注意的是增强强度不能太过,否则输入分布被破坏太严重,模型学习到的特征会出现失真,最终表现为训练集精度涨不上去。
学习率策略对复杂网络的收敛速度影响非常大。我常用的组合是先用Warmup让学习率从极低值逐步升到预设初始值,再配合Cosine退火让学习率在后期平滑下降。这比固定学习率硬训效果稳定得多,但代码量也就多十几行,属于性价比极高的投入。
模型保存这块有一个反复出现的标准建议:不要只保存最终训练结束时的权重,而是用回调函数监控验证集指标,保存验证指标最优的那个时刻。因为深度学习训练过程中模型会经历一个“先变好、后过拟合”的过程,最后一轮epoch训练出的权重往往不是泛化能力最强的。
3. 自然语言处理场景:从文本分类任务理解“让机器读懂语言”的门道
如果说图像识别是让模型理解像素背后的模式,自然语言处理要解决的问题就更抽象了——语言是一种离散的、有组合爆炸特性的符号系统。同一个词在不同语境里意思完全不同,一句话的字面含义和真实意图也可能完全相反。这个部分用一个情感分类任务来展开,你会看到从原始文本到模型输出一条又长又充满选择节点的链路。
3.1 文本怎么变成模型能吃的张量
文本处理的核心痛点在于神经网络只认数字,不认文字。把文字转成数字通常分两步:
第一步是分词。中文场景下最常见的方式是jieba这类工具按词典切词,英文则直接按空格切。分词粒度直接影响模型效果,比如“人工智能”这四个字,如果只按单字切,模型很难学会完整语义;如果按整词切,词表会爆炸,训练也会变得很慢。
第二步是构建词表并完成向量映射。每个词在词表里有一个唯一编号,编号之间天然有间隔,“猫”是123、“狗”是456,这种稀疏编码模型没法理解语义关系。所以还需要一个Embedding层,把每个整数映射成稠密的实数向量。这个向量的本质是一个可学习的特征表示,如果语料够多,学习到的向量会自发形成规律——语义相近的词在向量空间里互相靠近。
3.2 用一个双向LSTM模型跑通情感分类
文本建模的历史演进很有意思,循环神经网络是第一个能真正处理序列数据的结构。虽然现在Transformer已经全面碾压了RNN类模型,但LSTM里的许多概念至今仍有价值。
import tensorflow as tf from tensorflow.keras import layers, models def build_text_model(vocab_size, embedding_dim=128, max_len=64): inputs = layers.Input(shape=(max_len,), dtype="int32") x = layers.Embedding(vocab_size, embedding_dim, mask_zero=True)(inputs) x = layers.Bidirectional(layers.LSTM(units=64, return_sequences=False))(x) x = layers.Dropout(0.4)(x) outputs = layers.Dense(1, activation="sigmoid")(x) model = models.Model(inputs, outputs) return model model = build_text_model(vocab_size=50000) model.compile( optimizer=tf.keras.optimizers.Adam(1e-3), loss="binary_crossentropy", metrics=["accuracy"] )注意这里Embedding层设了mask_zero=True。当输入序列长度不够max_len时,会填充0。mask_zero=True告诉模型这些填充位没有真实信息,LSTM在计算时要跳过这些位置。如果不设置这个参数,模型会把填充的零向量也当成有效输入,产生的隐藏状态会被无意义的填充信息污染,最终导致模型在短文本上的表现异常差。
序列长度max_len的选择也很有讲究,设置太短会截断长文本里的关键信息,设置太长则浪费计算资源。我习惯先统计整个数据集的长度分布,选一个能覆盖85%以上样本的长度,这样又稳又省。
3.3 预训练模型:与其从零训练,不如站在巨人肩膀上
必须坦白地讲,在文本数据量不够大的时候,从零训练的词向量很难学习到丰富的语义信息。中文的词表动辄十几万,随便一个词要学出“健康”和“养生”的关联,需要海量共现语料。绝大多数业务场景根本没有这个数据量。
解决这类问题的方法是使用预训练语言模型。它的思路是提前在大规模通用语料上训练一个模型,让模型先理解语言的基本规律,然后在我们的小数据集上做微调。这就像招一个有十年工作经验的员工,入职后只需要做岗前培训,而不是从零开始学基本技能。
微调时大部分场景只需要在预训练模型基础上增加一个全连接分类头,然后整体训练几轮就能达到不错的效果。有个经验之谈:预训练模型的参数通常很多,在数据量有限时不要把所有层的学习率都调成一样,最好分类头学习率大一些,预训练部分学习率小一些。这能有效防止灾难性遗忘。
4. 模型部署:把训练成果变成别人能用的服务
模型在Jupyter Notebook里跑出98%的准确率只是第一步,真正要让它产生价值,必须把它做成可以被别人调用的服务或者产品。模型部署面对的核心矛盾是:训练环境和生产环境之间的差异太大,GPU环境、Python依赖、模型版本这些因素只要一个对不上,模型就起不来。
4.1 训练模型如何导出与序列化
先说最简单也最关键的一步:如何把你的模型安全地保存下来。不同框架的导出方式不同,但常见原则是相通的——不仅要保存网络结构参数,还要保存权重和训练时的超参数设置。
# Keras/TensorFlow model.save("model_v1.keras") # PyTorch(推荐同时保存结构和权重) torch.save({ "model_state_dict": model.state_dict(), "config": {"vocab_size": 50000, "embedding_dim": 128, "max_len": 64} }, "model_v1.pt")PyTorch新手常犯的错误是只保存model.state_dict()而不保存模型结构信息,导致后来加载时不知道模型长什么样,还得翻训练代码回忆结构。养成把配置和权重一并保存的习惯,能省掉很多不必要的麻烦。
TensorFlow的SavedModel格式是官方推荐的部署格式,model.save("saved_model/", save_format="tf")会生成一个包含模型结构、权重和推理函数签名完整目录,方便后续用各种服务化组件直接加载。
注意:模型导出后一定要做一次加载验证,确认输入输出符合预期。很多人导出成功后就直接部署,结果上线第一天才发现输入尺寸处理有问题或者输出缺了softmax后处理,这种低级错误在项目交付节点上非常影响交付质量。
4.2 Keras/TensorFlow模型的Web服务化部署
最直接、最通用、生产上也最常用到的方式是通过Web框架把模型包成一个HTTP接口。虽然主流的方案包括TensorFlow Serving、TorchServe这样的专业工具,但自己的个人项目或小规模业务往往用更轻量化的方案就能解决问题。
import numpy as np from flask import Flask, request, jsonify app = Flask(__name__) model = load_your_model("saved_model/") @app.route("/predict", methods=["POST"]) def predict(): data = request.get_json() image = preprocess(data["image"]) pred = model.predict(image) return jsonify({"class_id": int(np.argmax(pred[0])), "confidence": float(np.max(pred[0]))}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)这里有几个生产环境必须留意的问题。model.predict()默认会走完整的Forward计算开销较大,大批量请求并发时建议用tf.function把推理过程提前编译成静态图。另一个高频坑是Flask自带的开发服务器不支持并发,线上部署时务必用Gunicorn之类的生产级WSGI服务器。
接下来讲一下TensorFlow Serving。虽然已经稍微超出了个人项目的范畴,但既然在讲生产级部署,这是个值得了解的富矿。它的核心价值是支持模型版本管理,可以在不重启服务的情况下完成模型的平滑升级。多个版本同时加载,按流量比例切换,部署策略非常灵活。它的缺点是配置要求自定义输入输出的签名,开发调试成本稍高。
4.3 PyTorch模型的ONNX导出与跨框架部署
PyTorch训练出来的模型部署在市面上有一种更通用的做法——导出成ONNX格式。ONNX相当于深度学习领域的通用交换格式,它定义了一套标准化的算子集合,可以让你在PyTorch训练、在ONNX Runtime里完成推理。很多公司的高效率路径是先导出成ONNX,再用ONNX Runtime做推理优化,最后部署到不同的硬件平台。
import torch dummy_input = torch.randn(1, 3, 224, 224) model.eval() torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}} )导出时有一个参数需要重点关注:dynamic_axes。它指定哪些维度是动态的。如果不设置,ONNX模型会认为batch size固定为1,实际一推理更多样本就会报错。许多导出失败的情况都源于此,建议从一开始就把batch维度设为动态。
dummy_input需要给出一个符合真实输入的样例,格式、尺寸、数据类型都不能马虎。这是导出前最容易出错的点。
4.4 部署后推理性能排查
模型部署之后经常遇到“训练时够快,线上慢如老狗”的问题。排查方向和思路通常围绕以下几个方面展开:
先看有没有用GPU推理。生产环境很多模型还是跑在CPU上的,而已训练好的深度学习模型在CPU上的推理速度可能只有GPU的几十分之一。如果延迟确实是个硬指标,可以考虑模型量化和蒸馏,把模型变小也能换取速度。
再看数据预处理是否成了瓶颈。很多模型推理本身只要几毫秒,但前处理里的图片解码、缩放、归一化这些Python层操作反而消耗了大头时间。优化这些流程的办法是把前处理也用向量化手段批量完成,或者直接用TensorFlow Serving这类平台内置的前处理能力。
最后关注的是服务端并发模型。“每个请求独立加载模型做推理”这类写法在极端并发下会频繁切换上下文,让CPU缓存命中率暴跌。正确的做法是用进程池预加载模型,多个请求并发去抢同一个推理进程,从而维持稳定的吞吐量。
5. 实操中爬过的坑,整理成一张速查表
老读者应该已经习惯了,我每篇实战文最后都会把踩过的坑系统整理一遍。那些网上教程不会写、只有真正动手做项目才会遇到的情况,反而可能是最花时间的部分。
5.1 训练阶段高频问题
| 问题表现 | 根本原因 | 解决办法 |
|---|---|---|
| 损失函数收敛到0但验证集准确率低 | 数据泄漏,预处理阶段用全量数据统计信息 | 严格先切分再分别做归一化 |
| 训练损失迟迟不降 | 学习率太大导致震荡,或输入数据没有归一化 | 调低学习率、检查数据分布 |
| 模型一深就完全不能训练 | 跳连缺失或梯度消失 | 加入残差连接、使用BatchNorm |
| 不同epoch验证集指标剧烈波动 | batch size过小或学习率过大 | 适当增大batch size、用Cosine退火 |
| 验证集指标比训练集还高 | Dropout在验证阶段已被关闭,属于正常现象 | 对比时把训练指标的曲线和分析结果关联解读就好 |
5.2 部署阶段常见错误排行
模型能够训练完成只是第一步,部署阶段最常见的几个问题排行榜大概是这样的。
模型输出格式错误位居榜首。训练时直接用Softmax输出的向量做Loss计算,部署时又把整个向量一股脑输出给调用方。正确的做法是该转换成类别ID就转类别ID,该返回TopK概率就只返回TopK,接口设计越清晰越好。
第二个高频错误是模型版本混乱。AI项目更新迭代快,接手的同事可能同时跑着三个版本的模型,一旦上线出问题根本分不清是模型问题还是架构问题。建议无论大小项目都养成分目录管理模型的习惯,文件名带版本号和训练日期,并在代码里将模型路径作为配置项单独维护。
5.3 一些关于随机性与复现的操作习惯
其实这一类问题常常不是逻辑性错误,而是痴呆的差异性让人摸不着头脑。明明两次代码完全一样,一次准确率88%,另一次就掉到85%。深度学习的训练过程自带随机性,权重初始化、Dropout、数据打乱的顺序都涉及随机数生成,只要随机种子不一致,结果就会有波动。
在项目里设置好随机种子,保证实验可复现,是开展研究和调试模型的基本素养。
# TensorFlow / Keras import tensorflow as tf import random as rn rn.seed(42) tf.random.set_seed(42)PyTorch这边则需要额外设置:
import torch torch.manual_seed(42) torch.cuda.manual_seed_all(42)顺带还建议设置环境变量让cuDNN也保持确定性,代价是推理速度会轻微下降。这种做法只做实验对比时开,正式训练时一般为了性能可以不开。
一些只有动手做过才会懂的经验
这三篇文章一路写下来,正好完整覆盖了深度学习项目的全周期:从环境准备、基本网络结构,到框架选型和复杂网络构建,再到实际部署过程。老实讲,周围总有朋友来问我“学哪个框架好”“照着跑通代码是不是就算会了”。框架只是工具,跟着教程跑通也不等于真正掌握,唯有自己动手做项目、踩过坑、搞清楚背后的原理,才算真正入门了。
我个人在实际操作中的体会是:训练一个模型,最花时间的其实不是调算法,而是处理数据和排查莫名其妙的故障。这两年我越来越重视代码的模块化和配置化管理,把数据增强策略、模型结构、训练超参、路径配置都拆成独立的模块,这样每个实验只需要改对应的配置项,而不需要动核心代码,迭代效率会快很多。
最后再分享一条经验:如果你正在几个框架之间犹豫,与其花一周时间反复对比,不如定下来其中一个框架做出一个完整项目。深度学习框架之间的知识大半是相通的,重要的是完整理解“数据进模型、模型出服务”的整个链路。跑通第一个项目之后,你会发现自己比想象中成长得快得多。