这是机器学习实用指南的第三篇。前两篇我们把 sklearn 和 PyTorch 的基础用法过了一遍:sklearn 怎么做特征工程和经典建模,PyTorch 怎么搭网络、跑训练、算损失。但到了真实项目里你会发现,最卡人的往往不是某个 API 不会用,而是两个框架怎么配合、验证分数忽高忽低、训练崩了从哪一步开始查。我自己在项目里踩过的坑,几乎全集中在这些“衔接”和“调试”环节,而不是模型本身。
这篇换个思路,不入门、不拖沓,直接讲怎么把 sklearn 和 PyTorch 组合成一条靠得住的机器学习流水线。内容包括三个层次:两个框架的分工与协同方式、从数据处理到训练评估的实战套路,以及训练里常见问题的排查思路。适合已经跑通基础 Demo、正在做真实项目或者准备打比赛的读者,看完可以直接照着一套流程去改自己的代码。
1. 两个框架的定位差异与协同逻辑
好多人一开始会纠结一个问题:既然 PyTorch 这么灵活,能不能全用它?既然 sklearn 这么省事,是不是不用学深度学习?我的观点很明确:这俩不是替代关系,而是分工关系。
sklearn 擅长的是“短平快”的标准流程。数据清洗、缺失值填补、编码、缩放、特征筛选、经典分类器训练、交叉验证、指标评估,全都封装成了高度统一的对象。你只要遵循 fit/transform/predict 的接口约定,几乎可以像拼乐高一样把各个环节换着组合。它弱的地方在于,对结构化数据的建模上限受限于经典算法,而对图像、文本、序列这类非结构化数据,基本没有原生处理能力。
PyTorch 反过来,它在模型自由度上是王者。你可以自定义网络结构、控制梯度、写任意复杂的损失函数和训练逻辑,CPU/GPU 切换也极其方便。但如果一切都自己从零写,一个简单的标准化操作、一个 K 折交叉验证,都得费不少功夫,而且很容易写错。让一个团队所有成员都直接用 PyTorch 做整个流程,代价很高。
所以核心思路是:让 sklearn 负责“数据到张量之间”的所有脏活累活,让 PyTorch 只负责“张量到预测”这一步。这既保留了 sklearn 流水线的严谨性,又把模型的复杂度控制在了 PyTorch 最擅长的那一层。
1.1 为什么说它们是互补而不是竞争
拿一个典型的表格数据二分类项目举例。原始数据里面可能有数值列、类别列、缺失值,还有好几个强相关的特征。用 sklearn 一个 ColumnTransformer 加 Pipeline,就能把标准化、独热编码、简单缺失值填补一次性解决,而且这套流程可以被反复应用到训练集、验证集和未来的线上数据上,不会漏掉任何一步。
但如果你想让模型上限更高一点,比如引入一个稍微复杂些的深度网络,或者引入类别特征的嵌入层,sklearn 就没法直接支持了。这个时候用 PyTorch 写一个模型,网络结构完全可控,想加多少层、用什么激活函数、怎么处理类别嵌入都自己做主。
同样,PyTorch 在数据预处理上有torchvision.transforms等一系列工具,但那是为图像数据设计的。专注于表格数据和经典机器学习流程时,用 sklearn 的转换器明显更顺手:一个fit_transform就把均值方差记住了,接着在验证集上transform就行;PyTorch 里要手动保存和处理 mean、std,很容易出错。
从工程角度说,sklearn 的 Pipeline 还能像配置清单一样被整体 save 下来,换环境加载后直接复用,中间任何一步都不会被漏掉。这一点在生产环境里非常重要。所以我的结论是:sklearn 做数据工作台,PyTorch 做模型车间,两者之间存在明确的分工,不是谁取代谁的关系。
1.2 三种常见的协作模式
我在实际项目里见过很多种协作方式,最常用的可以归纳成三种,按复杂程度排序:
第一种是“sklearn 预处理 + PyTorch 模型”。这是最经典也最流行的组合。数据经过 sklearn 漏斗变成干净的特征矩阵,再切分成训练集、验证集,最后传到 PyTorch 的 Dataset 和 DataLoader 里训练网络。适合大多数回归、分类任务。
第二种是“sklearn 选特征 + PyTorch 嵌入训练”。先用 sklearn 的方差分析、递归特征消除或者树模型重要性系数选出一批重要特征,再把这批特征的索引传给 PyTorch,让网络用 embedding 层学习特征表示。适合类别特别多、基数特别大的特征,比如用户 ID、商品 ID 这类高基数类别,传统独热编码会撑爆内存,embedding 则能压到很小的维度。
| 协作模式 | 典型场景 | 优点 | 注意点 |
|---|---|---|---|
| sklearn预处理 + PyTorch模型 | 表格分类/回归 | 流程清晰、复用性强 | 数据切分要在预处理之前 |
| sklearn选特征 + PyTorch嵌入 | 高基数类别特征 | 省内存、容纳稀疏信息 | 需要保存特征索引,防止线上漏用 |
| PyTorch特征提取 + sklearn分类器 | 迁移学习/预训练特征 | 可复用预训练模型,分类器训练快 | 特征要标准化,防止原始logits尺度差异 |
第三种是“PyTorch 特征提取 + sklearn 分类器”。这种方式有点取巧,但效果经常出奇地好。比如用预训练网络把图像或文本转成特征向量,然后丢给 sklearn 里的逻辑回归、SVM 或者 GBDT 去分类。因为特征已经是高层的语义向量,经典分类器往往能取得很不错的基线,而且训练开销比微调整个深度网络小得多。
我特别推荐在项目早期先用第三种方式跑一个快速基线,再逐步迁移到前两种更复杂的方案里。这样既能验证数据可学习性,也能给后续模型定一个对照指标。
2. 数据预处理与建模管线衔接实战
切到代码层面。这一章是全篇的核心,我会把从原始数据到模型训练的完整链路拆开讲清楚。很多人总在“数据预处理和深度模型之间”出问题,要么数据格式不对,要么特征归一化漏了验证集,要么自定义 Dataset 写得太复杂,每篇代码都很容易出幺蛾子。
2.1 一张训练流程图到底该怎么画
拿到任务后,我习惯先在心里画一条线:原始表 → 特征工程 → 数据切分 → 标准化 → Dataset → DataLoader → PyTorch 模型 → 训练 → 评估。这条线看着简单,但有两个容易被忽略的坑。
第一,数据切分必须发生在特征工程和标准化之前。拿标准化来说,StandardScaler要先用训练集数据算出均值和方差,再用同一组均值和方差去转换验证集和测试集。如果先对全量数据做了标准化再切分,训练集和验证集之间的边界就被模糊了,验证集的信息提前泄露到了训练过程里,最后得到的评估结果会偏乐观。这个坑非常隐蔽,因为数值上差异可能不大,但每一次提交线上测试都会不稳定。
第二,Pipeline 和 PyTorch Dataset 的职责拆分要清楚。Pipeline 负责把所有数据变换步骤做成一个对象,一次性fit和transform;Dataset 只负责两件事:保存张量格式的数据,以及按索引返回样本。这样各司其职,代码会清晰很多。
2.2 从 sklearn 输出到 PyTorch 数据加载器的标准写法
下面这段代码是一套标准模板,我几乎每个表格类项目都会复用。假设X是已经整理好特征的一个 numpy 数组,y是标签数组:
import torch from torch.utils.data import Dataset, DataLoader from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler # 1. 先切分,再预处理 X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 2. 用训练集拟合标准化器,验证集只 transform scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_val_scaled = scaler.transform(X_val) # 3. 自定义 Dataset,把 numpy 数组变成 PyTorch 张量 class TabularDataset(Dataset): def __init__(self, features, labels): self.features = torch.tensor(features, dtype=torch.float32) self.labels = torch.tensor(labels, dtype=torch.long) def __len__(self): return len(self.features) def __getitem__(self, idx): return self.features[idx], self.labels[idx] train_dataset = TabularDataset(X_train_scaled, y_train) val_dataset = TabularDataset(X_val_scaled, y_val) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=256, shuffle=False)这里有三个细节值得展开。
第一个细节是dtype。TensorDataset 传入 numpy 数组时通常是 float64(也就是 numpy 默认的双精度),但 PyTorch 默认参数类型是 float32。直接转出来的张量不但会占双倍显存,还可能在你还没注意到的时候就拖慢了训练速度。上面代码里我写成torch.tensor(features, dtype=torch.float32),目的就是把数组强制转成单精度。很多人报错说 “expected dtype Float but got Double”,根源就是这里。
第二个细节是stratify=y。如果你的分类任务存在明显类别不平衡,切分时不加这个参数,很容易把某些小类全部切到验证集去,导致训练集里根本见不到那个类别。常见做法是加stratify=y让训练集和验证集的类别比例保持一致。
第三个细节是shuffle。训练集要打乱顺序,否则模型会按照固定顺序反复学习,收敛变慢;验证集不需要 shuffle,因为只是评估,顺序不影响结果。
2.3 用 Pipeline 把预处理固化下来
单一标准化还好说,真实项目里更常见的是“数值列标准化 + 类别列独热编码 + 缺失值填补”的组合操作。如果你用手写代码一步步做,每次在训练集和验证集上都容易漏掉其中一步,最后跑线上时就会崩。用 sklearn 的 Pipeline 可以一劳永逸。
from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, StandardScaler num_cols = ["age", "income", "score"] cat_cols = ["city", "education"] preprocessor = ColumnTransformer( transformers=[ ("num", StandardScaler(), num_cols), ("cat", OneHotEncoder(handle_unknown="ignore"), cat_cols) ] ) pipeline = Pipeline([ ("preprocess", preprocessor), ])这里 Pipeline 的fit_transform只会作用于训练数据,之后用同一个 pipeline 对验证集做transform,就能保证“训练集和验证集使用完全一致的处理流程”。其中handle_unknown="ignore"一定要加上,否则线上出现训练集中没见过的类别值时,OneHotEncoder 会直接报错,导致整个推理流程中断。
如果你想把 sklearn 模型和 PyTorch 模型打包到一起保存,也建议把这些都串进同一个 pipeline 或者至少统一保存。这样后面部署只需要加载一个对象,不用手动对齐一堆中间产物。
我在实际操作中还养成了一个习惯:在 pipeline 返回的特征矩阵上立刻打印一下shape和几个关键统计值,确认列顺序和数据范围。因为 ColumnTransformer 如果不指定remainder,那些没进 transformers 的列会被直接丢弃;如果有剩余列需要保留,必须显式写remainder="passthrough"。这个坑排查起来相当浪费时间,早点确认能省很多事。
3. 超参数优化的科学套路
调到模型训练这一环,很多人的下一步就是调参。但是调参的方式决定了效率差距。GridSearchCV 在 sklearn 上很好用,可到了 PyTorch 深度学习上,它的做法往往不够用。
3.1 为什么 GridSearch 在深度学习场景下不好使
GridSearch 的本质是在离散参数组合上做穷举。如果参数有 3 个,每个给 5 个候选值,就是 125 次组合,每次都要完整训练一次模型。在 sklearn 里一次随机森林训练可能只要几十秒,可接受;但一个 PyTorch 模型每次训练可能就要几分钟甚至几十分钟,穷举 125 次根本跑不动。
更关键的是,深度学习的超参数很多是连续值,比如学习率、dropout 比例、weight decay。你不可能在 1e-4 到 1e-2 之间均匀地穷举几个点,因为最优学习率可能落在 3e-4 这种“网格之外”的位置。RandomizedSearchCV 稍微好一点,能在连续空间里随机采样,但采样效率还是低,因为它不会根据历史结果去调整采样分布。
所以标准化做法是:用贝叶斯优化库。我个人最常用 Optuna,它内置了 TPE 采样器,会自动在效果好的参数区域里多采样,在效果差的区域少采样,效率明显高于随机搜索。
3.2 用 Optuna 统一调 sklearn 和 PyTorch 的实例
Optuna 的接口设计很符合直觉:你定义一个 objective 函数,里面做完一次完整的训练流程,返回验证指标;然后在主程序里调用study.optimize去搜索。下面是一个典型的 PyTorch 参数搜索示例:
import optuna import torch import torch.nn as nn from sklearn.metrics import accuracy_score def objective(trial): hidden_size = trial.suggest_int("hidden_size", 32, 256) lr = trial.suggest_float("lr", 1e-4, 1e-2, log=True) dropout = trial.suggest_float("dropout", 0.0, 0.5) model = nn.Sequential( nn.Linear(X_train_scaled.shape[1], hidden_size), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_size, n_classes) ) optimizer = torch.optim.Adam(model.parameters(), lr=lr) loss_fn = nn.CrossEntropyLoss() for epoch in range(20): model.train() for xb, yb in train_loader: optimizer.zero_grad() logits = model(xb) loss = loss_fn(logits, yb) loss.backward() optimizer.step() model.eval() preds = [] with torch.no_grad(): for xb, yb in val_loader: logits = model(xb) preds.extend(logits.argmax(dim=1).tolist()) return accuracy_score(y_val, preds) study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=30) print(study.best_params)注意,这里搜索空间里我用log=True来搜索学习率。因为学习率这种东西经常跨数量级变化,在 1e-4 到 1e-2 的区间里如果用均匀采样,大部分采样点会压在 0.005 附近的大数字空间,而接近 0.0001 的小数字区间被采到的概率极低。log 分布会把采样点在对数尺度上摊开,这样每个数量级都有机会被探索到。
如果同时调 sklearn 那边的参数,也是同样的思路:在 objective 里实例化分类器、跑 cross_val_score、返回平均分。Optuna 不在乎你用的是哪个库,它只关心最后返回的那个标量指标。所以在一个 objective 里既能调 sklearn 预处理器的参数(比如编码策略、标准化方式),又能调 PyTorch 模型的网络结构,这是它最大的优势。
3.3 搜索效率与随机种子的坑
用 Optuna 做深度模型调参时,有个问题比算法本身更常被忽视:随机性的干扰。PyTorch 训练本身有初始化、shuffle、dropout 等多处随机源,如果每次 trial 的随机种子不一致,那么同一组超参数跑两次结果可能差得很多。这样搜索过程会把“随机波动”误判成“参数好坏”,最后选出的参数很可能不稳健。
我的做法是在每个 trial 开头固定全套随机种子:
import random import numpy as np def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)然后让 Optuna 的每个 trial 拿不同的固定种子,例如seed = trial.suggest_int("seed", 0, 1000),这样既能确保同一组超参数下可复现,又能在不同 trial 之间引入合理的扰动。
还有一个效率经验:搜索空间初步设置得宽一点,先跑 10 个 trial 看看有效范围,再来第二轮收窄。直接拉满 100 个 trial 跑大范围搜索,会浪费大量时间在无效区域。我在实际项目中基本都是“先粗后细”两步走,粗搜阶段每次训练 epoch 也用更少,比如 10 个;细搜阶段再增加到 30 个 epoch。
4. 模型评估与结果分析
模型训练完了,很多人习惯看最后一个 epoch 的验证集准确率,然后直接拿去交差。但这样评估的可靠性很低,尤其当数据分布不稳定、数据量偏少的时候。这一章讲清楚我在真实项目里怎么评估模型。
4.1 交叉验证怎么做才贴近真实场景
sklearn 里的cross_val_score只能接受 sklearn 风格估计器,不能直接吃 PyTorch 模型。有第三方库 skorch 可以做桥接,但我更喜欢自己写交叉验证循环,因为过程中你能看到每个 fold 的训练情况,也能随时插入自定义逻辑,比如把每个 fold 的最佳模型单独保存下来。
标准写法是这样:
from sklearn.model_selection import StratifiedKFold import numpy as np skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) fold_scores = [] for fold, (train_idx, val_idx) in enumerate(skf.split(X, y)): X_train_fold = X[train_idx] y_train_fold = y[train_idx] X_val_fold = X[val_idx] y_val_fold = y[val_idx] scaler = StandardScaler() X_train_fold = scaler.fit_transform(X_train_fold) X_val_fold = scaler.transform(X_val_fold) # 在这里训练 PyTorch 模型,每个 fold 会得到一个验证分数 best_val_score = train_and_evaluate( X_train_fold, y_train_fold, X_val_fold, y_val_fold ) fold_scores.append(best_val_score) print(f"mean: {np.mean(fold_scores):.4f}, std: {np.std(fold_scores):.4f}")使用StratifiedKFold而不是普通KFold,是为了保证每个折里的类别比例和原始数据保持一致。如果类别不平衡 + KFold 随机切分,很可能某个折里根本没有少数类样本,导致该折准确率飘忽不定。
最后看均值和标准差两个数。均值反映模型的真实水平,标准差反映稳定性。如果均值不错但标准差很大,说明模型对数据的划分极度敏感,这时候就该去检查特征工程是否太弱,或者模型是否过拟合了某些特定分布。
4.2 训练过程监控与早停
训练 PyTorch 模型时,我最在意的不是花了多少 epoch,而是验证集指标是不是一路上涨。如果训练集损失一直降、验证集损失停在原地甚至上升,那就是典型的过拟合信号。我的标准训练循环里一定会加早停和最佳模型保存:
best_val_loss = float("inf") patience = 0 best_state = None for epoch in range(100): train_loss = run_one_epoch(train_loader, model, optimizer, loss_fn) val_loss = evaluate(val_loader, model, loss_fn) if val_loss < best_val_loss: best_val_loss = val_loss best_state = {k: v.clone() for k, v in model.state_dict().items()} patience = 0 else: patience += 1 if patience >= 10: break model.load_state_dict(best_state)这里的核心逻辑是:只在验证集 loss 创新低时保存参数;连续 10 个 epoch 不创新低就提前停止。这样能防止在过拟合之后还被保留一个后期更差的模型。best_state用clone()是为了深拷贝,否则后面训练继续更新参数,先前保存的引用也会跟着变。
实际调试时我会同时记录每个 epoch 的 train_loss、val_loss、val_metric,把它们写进 CSV 或直接画图。曲线能非常直观地暴露问题:训练 loss 和验证 loss 同步下降说明模型正在正常学习;训练 loss 降到很低但验证 loss 抬头,说明过拟合;两个都不降说明学习率可能太小或者模型表达能力不足。
4.3 指标选择与常见误区
分类任务里我见过一些人只看 accuracy,这在类别平衡时还说得过去,一旦数据不平衡就完全失真。举一个极端例子:99% 负类、1% 正类的任务里,全预测负类的准确率也有 99%,但这个模型毫无价值。所以分类任务至少要搭配 precision、recall、F1 来评估,尤其是少数类的指标。
from sklearn.metrics import classification_report print(classification_report(y_val, preds, digits=4))这份报告会把每个类别的 precision、recall、F1 都打出来,一眼就能看清模型在少数类上是不是扑街了。如果你的业务对“漏报”更敏感,比如异常检测、疾病筛查,那就更看重 recall;如果误报代价高,比如风控拦截,那就更看重 precision。具体看业务定,但别只盯着一个数字。
回归任务则要小心 MSE 和 MAE 的尺度问题。MSE 会给大误差施加二次惩罚,所以一个离群点就可能把 MSE 拉得很大;MAE 更抗离群点。实际项目里我一般同时报告 MAE 和 R²,R² 能给出“相比用均值预测好了多少”的直观感受。只看 MSE 会给那些“整体预测不错、个别点离谱”的模型打出很差的分数,掩盖了它的实际可用性。
5. 训练调试与问题排查实录
这一章是实战性最强、也最枯燥的一部分,但值得认真看看。训练模型不可能一帆风顺,我把自己在真实项目里遇到频率最高的几类问题整理如下,每个都给出了定位方法和解决方向。
5.1 数据不平衡从数据处理到采样的完整链路
遇到数据不平衡,首先要分清楚是“哪种不平衡”。如果只是类别比例差距大,常见手段有两类。
一类是数据层面的:对少数类做重采样,或者生成合成样本。简单但有效的方法是用 sklearn 的resample做上采样:
from sklearn.utils import resample # 假设 df_minor 是少数类样本 df_minor_upsampled = resample( df_minor, replace=True, n_samples=len(df_major), random_state=42 )另一类是算法层面的:训练 PyTorch 模型时给损失函数加类别权重。最简单的做法是用 sklearn 算好权重,然后传给CrossEntropyLoss:
from sklearn.utils.class_weight import compute_class_weight class_weights = compute_class_weight( class_weight="balanced", classes=np.unique(y_train), y=y_train ) weight_tensor = torch.tensor(class_weights, dtype=torch.float32) loss_fn = nn.CrossEntropyLoss(weight=weight_tensor)如果数据极其不平衡,还可以在 DataLoader 里用WeightedRandomSampler,让少数类样本以更高概率被采样到每个 batch 里,避免模型在一个 batch 里几乎看不到少数类。这个策略在图像目标检测任务里特别常用。
经验之谈:类别权重和采样器不要一起上,否则少数类出现频率过高,模型容易迁就少数类而损害整体表现。我通常先加损失权重,观察效果,不够再加采样器。
5.2 过拟合判断与治理
过拟合的经典表现是训练集指标极高、验证集指标明显偏低。治本的方法是增加更多数据和做更强特征工程,但短期能救急的有三招:降低模型容量、加正则化、提前停止。
降低模型容量可以直接减少层数或隐藏单元数;加正则化包括 weight decay、dropout 两种。PyTorch 里 weight decay 直接在优化器里配置:
optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)dropout 则是在网络中间插入nn.Dropout(p=0.3),让一部分神经元在训练时随机失活,迫使网络学习更鲁棒的特征。注意 dropout 在推理时必须关闭,PyTorch 里model.eval()会自动实现。
如果验证集 loss 降不下去但训练集 loss 也很高,那不是过拟合,而是欠拟合。这时候应该增加模型容量、调大学习率,或者把特征工程做得更足。两类问题的应对方向完全不同,别统一归到过拟合里。
5.3 高频报错速查表
| 报错信息 | 常见原因 | 解决方向 |
|---|---|---|
| shape mismatch / Sizes of tensors must match | 最后一层输出维度和标签类别数不一致,或 Dataset 返回格式不对 | 检查网络输出层神经元数是否等于类别数,打印每层输出 shape |
| expected dtype Float but got Double | numpy 数组是 float64,张量默认需要 float32 | 转成 dtype=torch.float32 |
| CUDA out of memory | batch size 太大或模型参数过多 | 调小 batch size、减少网络宽度、用梯度累积 |
| loss becomes NaN after some epochs | 学习率太大、数据有异常值、梯度爆炸 | 调小学习率、检查输入和标签里是否有 NaN、加梯度裁剪 |
| RuntimeError: Found no saved checkpoint in the checkpoint directory | 保存路径不对或目录为空 | 确认 checkpoint 路径和文件名完全一致 |
| one-hot 编码遇到未知类别报错 | 线上数据出现训练集没有见过的类别值 | 用 handle_unknown="ignore" 配置 OneHotEncoder |
其中 NaN loss 是深度学习里最让人头疼的问题之一。我一般的排查顺序是:先看输入数据是否含 NaN、Inf;再看学习率是不是高得离谱;最后看梯度有没有爆炸,加上torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)往往能解决大半问题。
6. 模型保存、转换与服务化部署
模型能跑通、能评估,还只算完成了一半。真正到了交付环节,模型的保存、加载、格式转换就成了新的拦路虎。这一章讲清楚我在部署前都会处理的几个细节。
6.1 sklearn 模型持久化:不只是 pickle
sklearn 官方推荐用 joblib 保存模型,尤其是包含 Pipeline 的模型,因为 joblib 对包含大量 numpy 数组的对象做了优化,存取速度远快于标准 pickle。
import joblib # 保存整个 pipeline(内含预处理器和 sklearn 模型) joblib.dump(pipeline, "preprocess_pipeline.joblib") joblib.dump(sklearn_model, "sklearn_model.joblib") # 加载 pipeline = joblib.load("preprocess_pipeline.joblib") sklearn_model = joblib.load("sklearn_model.joblib")如果你用了我前面说的“sklearn 预处理 + PyTorch 模型”组合,那保存的时候就要做两个动作:把 sklearn 的 pipeline 用 joblib 保存,把 PyTorch 模型的参数用torch.save保存。两者要记录版本号和特征列名,否则将来模型输入格式对不上,排查成本极高。
6.2 PyTorch 参数的保存与加载
PyTorch 里我几乎只保存模型的state_dict,不推荐直接torch.save(model)保存整个模型对象。原因是state_dict是一份纯参数字典,跨 PyTorch 版本兼容性更好、文件更小,也方便以后换模型结构时只加载参数做迁移。保存和加载的完整写法是:
# 保存 torch.save(model.state_dict(), "pytorch_model.pt") # 加载(必须实例化相同结构的模型) model = TheModel(input_dim=20, hidden_dim=64, num_classes=2) model.load_state_dict(torch.load("pytorch_model.pt")) model.eval()这里有个容易踩的坑:加载后必须记得调用model.eval()。否则模型默认处于训练模式,dropout 和 BatchNorm 的行为和推理时不一致,预测结果会忽高忽低。同样,如果要在某个库或框架中做推理,也一定要先切到 eval 模式再跑。
6.3 ONNX 统一导出:让模型走出实验室
如果要把模型接入更通用的推理服务,比如跨语言调用、边缘设备部署,只持有一份 PyTorch 的.pt文件往往不够。这时候用 ONNX 做中间格式,可以放到支持 ONNX 的推理框架里运行,绕开 Python 环境的依赖问题。PyTorch 官方提供了直接的导出接口:
import torch.onnx dummy_input = torch.randn(1, input_dim) torch.onnx.export( model, dummy_input, "model.onnx", input_names=["features"], output_names=["logits"], dynamic_axes={"features": {0: "batch"}, "logits": {0: "batch"}} )dummy_input是给导出工具看的“示例输入”,不需要真实数据,只用来推断输入形状。dynamic_axes表示 batch 维度可以动态变化,这样部署时一个请求一个请求推,或者一次性推几十个样本都可以,不会因为固定 batch size 卡住。
ONNX 导出虽然方便,但也有兼容性约束。比如自定义层、非常规的算子有时外导出会报错,解法要么换成 ONNX 支持的等价实现,要么干脆用 TorchScript 做导出。我一般优先尝试 ONNX,它生态更广;遇到不支持的算子再退回去研究 TorchScript。
我个人在使用这套流程时最大的体会是:单点技巧再多,最后都要落到一条可复现、可监控的链路上。数据处理和模型训练各自再完美,衔接不畅通,项目一样会卡壳。把 sklearn 当作数据入口、PyTorch 当作模型核心、Optuna 当参数调度器,再辅以交叉验证和保存策略,基本就能覆盖我遇到的大多数真实项目需求。后面如果有精力,我打算再整理一篇关于特征工程和高基数类别嵌入的实现细节,那里面的坑比模型训练还多。