在写这篇系列第三篇之前,我先说说心里话。前两篇把 Sklearn 的建模套路和 PyTorch 的基础张量操作捋了一遍,很多朋友私信问:到底什么时候用 Sklearn,什么时候该上 PyTorch?能不能在同一个项目里让它们俩配合干活?这篇我就专门解决这个问题。
我自己的体感是,现实里的机器学习项目很少是"纯 Sklearn"或者"纯 PyTorch"一条道走到黑的。大多数情况是:数据清洗、特征工程、基线模型用 Sklearn 快速搞定,等确认了问题规模、数据量和模型复杂度之后,再把训练环节切到 PyTorch 拿 GPU 加速,或者直接用 PyTorch 写自定义层。反过来,PyTorch 训练出来的模型要做评估、对比、网格搜索,又得回到 Sklearn 的指标体系里来。所以这篇的内容核心就一句话:让两份生态在你的项目里各干各擅长的活,并且无缝衔接。
我手头正好有个模拟项目可以拿来做全程演示——某零售业务的客户流失预测。数据不大不小,几万行,20 来列,有数值有类别。这种场景特别适合展示两套框架的协作方式:Sklearn 做特征工程和基线,PyTorch 负责深度学习部分,最后再回到 Sklearn 做对比诊断。整个流程走完,你就知道以后自己的项目该怎么切分了。
1. 为什么系列第三篇要把两份生态"焊"在一起
先聊一个现象。很多初学者要么迷信"深度学习万能"一上来就 PyTorch 梭哈,要么固守 Sklearn 不敢碰神经网络,觉得那是另一个世界。这两种极端我都在实际项目里见过,结果往往都不太好看:前者用一层简单的 MLP 去拟合线性关系,折腾半天精度还不如逻辑回归;后者遇到高维稀疏特征或者海量文本数据时,模型容量明显不够,业务指标上不去。
问题的根源在于,Sklearn 和 PyTorch 压根不是竞争关系,它们各自解决的问题域不同。我习惯把 Sklearn 看成"工业级瑞士军刀",它提供了完整的数据预处理、特征选择、模型评估、超参搜索工具链,开箱即用,而且对"中小规模表格数据"的支持极其成熟。PyTorch 则更像"自由组装的生产线",你掌控每一个训练细节,从 batch 采样、梯度计算到优化器调度,想怎么搭就怎么搭,代价是你要自己处理大量模板代码。
一个很务实的做法是:以 Sklearn 为骨架,以 PyTorch 为引擎。骨架负责管数据的流入流出、管评估口径、管基线参照,引擎负责把模型训练得足够强。两者之间的接口,就是今天要重点讲的内容。
拿我的客户流失项目举例,最初用逻辑回归跑了基线,AUC 在 0.76 左右。换成 PyTorch 的 MLP 之后,光是把数值特征标准化、类别特征嵌入化、加两层 BatchNorm 和 Dropout,AUC 就拱到了 0.81。但这个过程并不是把数据直接扔进nn.Module就完事,中间有大量的格式转换、状态切换和评估对齐问题。这些细节,就是我这篇要拆开揉碎讲的东西。
2. 数据桥接:让 DataFrame 在 Sklearn 和 PyTorch 之间无缝流转
如果你同时用过两套库,一定经历过这种尴尬:Sklearn 那边预处理得好好的 DataFrame,到了 PyTorch 这边变成要吃张量;训练完想算个混淆矩阵,又得把 GPU 上的张量搬回 CPU 再转成 numpy。这些"搬运"操作其实有固定套路,但网上教程很少系统讲清楚。
2.1 从 pandas 到 Dataset 的标准转换链路
PyTorch 喂数据的标准姿势是通过Dataset和DataLoader。Dataset负责定义"怎么从原始数据里取一个样本",DataLoader负责把样本拼成 batch、打乱顺序、多进程预读。很多新手直接用torch.tensor(df.values)一把梭,遇到分类特征就毁了——模型把类别编码当成连续数值来学,结果一团糟。
正确的做法是手动控制转换顺序。我一般分三步走:
第一步,用 Sklearn 的ColumnTransformer对 DataFrame 做预处理,把数值列标准化、类别列编码成索引。这里有个小技巧:编码后的类别索引不要直接喂网络,应该先转成torch.LongTensor,后面在模型里过nn.Embedding。如果你用LabelEncoder编完码就直接浮点张量拿去训练,模型会默认类别之间有大小关系,这通常不符合业务语义。
第二步,定义自己的Dataset子类。我习惯让它接收预处理后的 numpy 数组,而不是 DataFrame 本身,这样能省掉每次__getitem__里的索引开销:
class TabularDataset(Dataset): def __init__(self, X_num, X_cat, y): self.X_num = torch.tensor(X_num, dtype=torch.float32) self.X_cat = torch.tensor(X_cat, dtype=torch.long) self.y = torch.tensor(y.values, dtype=torch.float32) def __len__(self): return len(self.y) def __getitem__(self, idx): return self.X_num[idx], self.X_cat[idx], self.y[idx]第三步,组装DataLoader时设置batch_size=256,打开shuffle=True(训练集),num_workers=4用多进程加载数据。
这套链路跑通之后,后面换 GPU、调 batch 大小、加数据增强(表格数据一般只需要加噪声)都很顺手。
2.2 浮点精度与字段名消费陷阱
项目里最容易阴沟翻船的有两个点,我特地拿出来讲。
第一个是numpy 数组和 tensor 的 dtype 不匹配。Sklearn 的StandardScaler输出的是float64,而 PyTorch 默认模型权重是float32。如果你torch.tensor(X_scaled)不指定 dtype,张量会保留float64,随后网络前向传播时和float32的权重一碰,直接报 dtype 不匹配错误。最气人的是它平时不报错,等到验证集或者测试集上偶尔出现时才冒出来。所以我在代码里一律显式写成dtype=torch.float32,绝不偷懒。
第二个是DataLoader 取回来之后,字段顺序会打乱。Dataset的__getitem__返回的是一个元组(X_num, X_cat, y),DataLoader会自动把每个字段在 batch 维度上堆叠。但如果你在模型里写逻辑时依赖"第 0 个字段是数值、第 1 个字段是类别"的顺序,后续一旦修改Dataset返回结构,模型那边就会悄无声息地犯错误。我的经验是给每个返回值命名,或者直接用一个字典返回:
def __getitem__(self, idx): return { "num": self.X_num[idx], "cat": self.X_cat[idx], "label": self.y[idx], }这样在训练循环里写batch["num"]和batch["label"],可读性和抗变更能力都强很多。
2.3 类别特征嵌入的实际操作
类别特征进了nn.Embedding之后,有几个维度要提前定好。嵌入维度不是越大越好,我一般按照经验公式min(50, (n_categories + 1) // 2)来取,类别特别多的比如用户 ID 这种高基数列可以放宽到 64 或 128。嵌入表初始化用默认的均匀分布就行,不用特别调。
要注意的是,嵌入层的输出是三维张量:(batch, 序列长度, embedding_dim)。对于表格数据我们通常只有一个类别列或者少数几列,需要在模型里flatten或者squeeze掉中间维度,再和数值特征拼接。我在第一篇里提过一嘴,这次给个完整示例:
class MixedInputModel(nn.Module): def __init__(self, num_features, cat_embed_specs, hidden=128): super().__init__() self.embeddings = nn.ModuleList([ nn.Embedding(num_categories, embed_dim) for num_categories, embed_dim in cat_embed_specs ]) total_cat_dim = sum(embed_dim for _, embed_dim in cat_embed_specs) self.fc = nn.Sequential( nn.Linear(num_features + total_cat_dim, hidden), nn.BatchNorm1d(hidden), nn.ReLU(), nn.Dropout(0.3), nn.Linear(hidden, 1), ) def forward(self, X_num, X_cat): cat_embeds = [emb(X_cat[:, i]) for i, emb in enumerate(self.embeddings)] cat_flat = torch.cat(cat_embeds, dim=1) combined = torch.cat([X_num, cat_flat], dim=1) return self.fc(combined).squeeze(-1)这里cat_embed_specs是一个列表,每个元素是(类别数, 嵌入维度)。构建模型前先用pd.Categorical或者 Sklearn 的编码器数清楚每列有多少个类别,再传给模型。这个环节跑通之后,混合输入的处理就算彻底掌握了。
3. 把自定义 PyTorch 模型塞进 Sklearn 的网格搜索里
很多人以为网格搜索是 Sklearn 的专属功能,PyTorch 模型想用GridSearchCV就得自己写循环。实际上有个特别优雅的接口设计——只要把你的 PyTorch 模型包装成符合 Sklearn estimator 接口的类,它就能直接进GridSearchCV或者cross_val_score。这个技巧在工业界用得非常多,因为它能让你在同一个框架下完成超参对比,而不必单独写一堆 for 循环。
3.1 包装器的最低要求:fit、predict、score
Sklearn 的 estimator 协议最低要实现三个方法:fit(X, y)、predict(X)、score(X, y)。对于分类任务,最好再实现predict_proba(X)。包装器内部管理 PyTorch 模型的训练、保存和加载。
这里有个关键点:Sklearn 传入的 X 是 DataFrame 或 numpy 数组,包装器要负责把数据转成 Dataset/DataLoader。为了不让每次 fit 都重复建 Dataset,我习惯把预处理管线放在包装器外面,传进来的 X 已经是处理好的数值+类别索引拼接。实在要拆开的话,也可以在包装器里存放一个ColumnTransformer引用,但那样网格搜索不同参数时会反复 fit 预处理,效率很亏。
下面是我实际项目里用过的最小版本:
from sklearn.base import BaseEstimator, ClassifierMixin class PyTorchClassifierWrapper(BaseEstimator, ClassifierMixin): def __init__(self, hidden=64, lr=1e-3, epochs=20, batch_size=256): self.hidden = hidden self.lr = lr self.epochs = epochs self.batch_size = batch_size def fit(self, X, y): X_num = X.astype(np.float32) y_arr = y.values if hasattr(y, "values") else np.asarray(y) self.model_ = build_model(self.hidden) optimizer = torch.optim.Adam(self.model_.parameters(), lr=self.lr) loss_fn = nn.BCEWithLogitsLoss() dataset = TabularDataset(X_num, y_arr) loader = DataLoader(dataset, batch_size=self.batch_size, shuffle=True) self.model_.train() for epoch in range(self.epochs): for X_batch, y_batch in loader: optimizer.zero_grad() logits = self.model_(X_batch).squeeze(-1) loss = loss_fn(logits, y_batch) loss.backward() optimizer.step() return self def predict_proba(self, X): X_num = X.astype(np.float32) self.model_.eval() with torch.no_grad(): logits = self.model_(torch.tensor(X_num)) proba = torch.sigmoid(logits) return proba.numpy() def predict(self, X): proba = self.predict_proba(X) return (proba >= 0.5).astype(int) def score(self, X, y): proba = self.predict_proba(X) return roc_auc_score(y, proba)注意__init__里的参数全部要平铺,不要用**kwargs包一层,否则GridSearchCV的param_grid没法正确注入。另外,score我直接用了roc_auc_score,这会覆盖基类默认的 accuracy,对不平衡的流失预测更合理。
3.2 网格搜索里的 K 折陷阱:数据泄漏从哪来
当你把包装器丢进GridSearchCV之后,一个隐蔽问题就浮出来了:如果你在 fit 内部做了标准化,而标准化又用了整个训练集的统计量,那么在交叉验证里就会导致数据泄漏。
怎么说呢?GridSearchCV会把数据集分成训练折和验证折,你的包装器在每一折的训练部分调用 fit。如果 fit 里面对全量 X(包括验证折)做标准化,那么验证折的信息就已经被"看过"了,结果会虚高。这也是为什么我强烈建议:预处理必须在进入网格搜索之前完成,并且只基于训练集。
正确流程是:先用train_test_split把数据集切开,在训练集上fit一个ColumnTransformer,然后transform训练集和测试集。这样网格搜索内部的每一折,都是基于已经拟合好的转换器操作,不会有泄漏。如果你希望网格搜索里也能对预处理参数(比如标准化方式、缺失值填充策略)做搜索,那就得用Pipeline把这些预处理步骤和包装器绑在一起,让 GridSearchCV 对 Pipeline 整体做交叉验证。这才是安全且干净的打开方式。
还有一个容易忽略的点:交叉验证的每一折里,嵌入表要重新建立。如果类别编码在全局统一编号,理论上没问题;但如果你在 fit 内部对每个折单独做编码,不同折的类别编号就乱了。我建议所有类别编码都在进入网格搜索前全局完成,模型里只存类别总数,不要动态构建。
3.3 实际网格搜索效果:以小博大
拿流失项目实际跑一轮,param_grid里搜了hidden从 32 到 256、lr从 1e-4 到 1e-2、epochs从 10 到 30,一共 3×3×3=27 组,每组 5 折,总共 135 次训练。单卡 CPU 上大概跑了四十分钟,结果最优组合是hidden=128, lr=5e-3, epochs=20,验证 AUC 到了 0.812。
这个过程的收益不在于找到最优参数本身,而是你不用为每个超参组合手动写实验脚本了。Sklearn 的网格搜索会帮你记录每次的组合和 score,最后直接看cv_results_表。我对比过自己写的暴力循环和用GridSearchCV,结果一致,但后者代码量少一半,还自动带了输出格式化。这就是框架协作的价值。
4. 自定义训练循环里那些手册没写清楚的细节
如果你只用model.fit()那套高层 API,很多训练细节是黑盒。但换到 PyTorch 自己写循环,自由度上来之后,坑也跟着变多。我每次带项目都会把下面这几个问题的代码提前写好,避免临时手忙脚乱。
4.1 model.train() 与 model.eval() 的切换时机
这条听起来基础,但真翻过车的人不在少数。nn.BatchNorm1d和nn.Dropout在训练和推理时的行为完全不一样:BatchNorm 训练时用当前 batch 的均值和方差,推理时用训练阶段累计的全局统计量;Dropout 训练时随机丢神经元,推理时全保留。如果你在验证阶段忘了切到eval(),BatchNorm 和 Dropout 会继续按训练模式干活,验证指标会忽高忽低,还特别容易误导你对模型真实能力的判断。
我习惯把切换逻辑封装在训练函数里:
def train_one_epoch(model, loader, optimizer, loss_fn): model.train() total_loss = 0 for X, y in loader: optimizer.zero_grad() logits = model(X) loss = loss_fn(logits, y) loss.backward() optimizer.step() total_loss += loss.item() * len(X) return total_loss / len(loader.dataset) def evaluate(model, loader, loss_fn): model.eval() total_loss = 0 all_preds = [] all_labels = [] with torch.no_grad(): for X, y in loader: logits = model(X) loss = loss_fn(logits, y) total_loss += loss.item() * len(X) proba = torch.sigmoid(logits).numpy() all_preds.extend(proba) all_labels.extend(y.numpy()) return total_loss / len(loader.dataset), np.array(all_preds), np.array(all_labels)torch.no_grad()和model.eval()是两个独立的事:eval()影响的是 BatchNorm/Dropout 的行为,no_grad()关闭的是梯度计算和跟踪。验证时两个都要,一个改了模块状态,一个省了内存和计算量。
4.2 损失曲线的观察方法与早停策略
自己写训练循环最大的优势之一,就是可以实时看到损失曲线。我每次都让训练日志每 5 个 epoch 在验证集上算一次 AUC,并记录到一个列表里。跑完后画一条训练损失、一条验证损失的曲线。如果训练损失下降但验证损失开始回升,说明过拟合开始;如果两条都下不去,考虑学习率是否太大、模型容量是否不足。
早停(early stopping)是必须加的保护措施。我用的版本是:记录当前最佳验证 AUC,如果连续 10 个 epoch 没有刷新,就停止训练并回滚到最佳状态对应的模型权重。实现方式很直接:
best_auc = 0.0 best_state = None patience = 10 no_improve_count = 0 for epoch in range(epochs): train_loss = train_one_epoch(...) val_loss, val_preds, val_labels = evaluate(...) val_auc = roc_auc_score(val_labels, val_preds) if val_auc > best_auc: best_auc = val_auc best_state = copy.deepcopy(model.state_dict()) no_improve_count = 0 else: no_improve_count += 1 if no_improve_count >= patience: print(f"Early stop at epoch {epoch}") break model.load_state_dict(best_state)这里有个细节容易被忽略:copy.deepcopy(model.state_dict())会把权重搬到内存里,模型特别大的话会影响性能。但几 MB 的 MLP 完全没问题,我宁可用这个稳妥方案也不冒"忘保存"的风险。
4.3 学习率调度的两种流派与选择依据
训练循环里第二个常见问题是学习率怎么衰减。我试过两种流派:一种是固定学习率跑完全程,另一种是配合调度器衰减。固定学习率在简单任务上完全够用,但如果模型深、数据大,到训练后期 loss 可能会在某个值附近反复横跳——这时就该用调度器了。
PyTorch 里最常用的是ReduceLROnPlateau。它的逻辑很直观:监控一个指标(比如验证损失),如果连续若干轮不下降,学习率就乘上一个系数(默认 0.1)。我一般在第 10 个 epoch 之后开始留意它,因为太早衰减会让前期收敛变慢。
另一种是CosineAnnealingLR,按余弦曲线把学习率从初始值逐渐降到接近 0,适合训练轮数固定的场景。两者对比:
| 调度器 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 固定学习率 | 小数据、浅模型、快速验证 | 简单可靠 | 后期可能震荡 |
| ReduceLROnPlateau | 深模型、大数据、验证集充分 | 自适应衰减 | 需要耐心等待触发 |
| CosineAnnealingLR | 训练轮数固定、预算明确 | 收敛平滑 | 需要预设总轮数 |
我的建议:跑基线时固定学习率,跑正式实验时挂一个ReduceLROnPlateau。调度器参数里把patience=3, factor=0.5设得激进一点,观察一两个 epoch 就能看出效果。
5. 模型诊断:PyTorch 模型不如回归基线时,怎么定位问题
深度学习模型训练完 AUC 比逻辑回归还低,这种事我见过不止一次。很多人第一反应是"模型不行",然后疯狂加层、加神经元。实际上绝大多数时候是过程问题,按顺序排查能省很多时间。
5.1 先排除预处理和标签的锅
第一步,检查输入数据的预处理是否一致。如果 Sklearn 基线和 PyTorch 模型用的不是同一套标准化参数,两者根本没有可比性。最常见的错误是:基线用的StandardScaler是在全量数据上 fit 的,而 PyTorch 那边的数据是自己在训练集上重新 fit 的——分布自然不同,结果当然不同。规范做法是统一一个预处理对象,保存下来,两份模型共用。
第二步,检查标签对齐。DataLoader 的shuffle=True会打乱样本顺序,如果X和y在Dataset里不是同一套索引,打乱后标签就错位了。我见过有的教程直接用df.sample(frac=1)打乱 DataFrame 后再切 X 和 y,这种写法没问题,但如果你在 Dataset 内部各自打乱了索引,就会悄悄出错。
第三步,检查类别不平衡。流失预测这种场景正负样本比例可能到 1:9,直接拿原始数据训练的话模型会倾向于把所有样本都预测成多数类,AUC 可能只有 0.5 出头。这时候要考虑加权损失函数或者过采样欠采样方案。PyTorch 里做加权很简单,BCEWithLogitsLoss(pos_weight=torch.tensor([neg_count / pos_count]))就能搞定。
5.2 把预测分布拉出来看,别只看 AUC
AUC 是个单调指标,但单调升不代表预测分布合理。我每次都会把 PyTorch 模型和逻辑回归在验证集上的预测概率画成直方图并排对比。如果 PyTorch 模型输出的概率严重偏向 0 或 1,那说明模型过拟合了;如果概率分布集中在 0.4-0.6 区间,说明模型还没完全收敛。
有一次我发现 PyTorch 模型 AUC 0.80、逻辑回归 0.76,看起来赢了,但看分布图时发现 PyTorch 模型的预测均值只有 0.18,也就是说它输出的概率整体压得太低。进一步检查发现是输出层直接用了sigmoid而没有配合恰当的损失函数加权,导致梯度不稳定。之后我在输出层保持 logits、用BCEWithLogitsLoss配合pos_weight,预测分布才恢复正常。
这里多提一句:AUC 只看排序能力,不看校准度。如果你的业务需要直接解释"这个用户有多大概率流失",那概率校准比 AUC 重要得多。Sklearn 的CalibratedClassifierCV可以套在任何分类器外面做校准,PyTorch 模型也可以先用predict_proba拿到概率,再丢给校准器。这又是两套生态合作的经典案例.
5.3 回退策略:什么时候承认 PyTorch 不是最优解
折腾一圈之后,如果 PyTorch 模型依然干不过调好参的梯度提升树(比如 XGBoost 或 LightGBM),要有勇气承认这一点。深度学习在表格数据上不一定有优势,这是学界的共识,不是你的代码写得不好。我会把回退条件定成:PyTorch 模型在验证集 AUC 上没有以 0.01 以上的优势赢过基线,且训练时间超过基线 10 倍,那就果断回到 Sklearn 的模型。
这不算失败,反而是一种重要经验。我在实际项目里 80% 的表格数据最终用的都是树模型或者线性模型,PyTorch 的价值更多体现在文本、图像、序列这种非结构化数据上。把精力放在真正有回报的地方,才是工程效率的核心。
6. 从单机调试到 GPU 加速:训练性能优化清单
数据量一旦过几十万行,CPU 上跑 PyTorch 的深度模型就明显吃力了。这一节把我在性能优化上踩过的一些路整理成一份可执行的清单,按顺序做,见效最快。
6.1 先确认 GPU 真的被用上了
听起来离谱,但"代码在 GPU 上跑"和"代码真的用上了 GPU"是两码事。很多人写完cuda()以为万事大吉,结果数据 loader 忘了.to(device),模型还在 CPU 上算,或者每次前向传播都触发数据从 CPU 到 GPU 的拷贝,性能比纯 CPU 还差。
一个标准的设备管理模板这样写:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) for X, y in loader: X = X.to(device) y = y.to(device) logits = model(X)想让代码在 CPU 和 GPU 之间无缝切换,关键是把device定义好,所有数据都显式to(device)。验证阶段要以torch.no_grad()包住,并且从 GPU 拿回结果时加一句proba.cpu().numpy(),不然 numpy 转换会报错。
6.2 DataLoader 的参数调优:num_workers、pin_memory 和 batch_size
DataLoader里最影响性能的其实是num_workers。默认值是 0,意思是数据加载在主进程里同步执行,模型计算和数据读取串行,GPU 经常空转。我把num_workers调到 4 之后,同样的 epoch 时间缩短了接近三分之一。机器是 8 核的话可以再往上试,但注意太高的num_workers会带来进程切换开销,数据量小的时候反而更慢。
pin_memory=True的本意是让 CPU 数据锁页,加速到 GPU 的传输。但只有当你确实在用 GPU 训练时才有意义,纯 CPU 训练开了反而可能占用额外内存。我一般设置成pin_memory=(device.type == "cuda"),既省心又高效。
batch_size的调整直接影响显存占用和收敛速度。显存不够时优先减 batch_size 而不是减小模型,因为 batch 多了梯度更新更频繁,收敛反而更快。我的经验值:表格数据 256 起步,显存紧张就减到 128,再紧就要考虑梯度累积了。
6.3 混合精度与梯度累积:大数据量训练的最后一公里
如果数据量真的很大,混合精度训练是必须开的。PyTorch 提供了torch.autocast上下文管理器,配合GradScaler使用,大多数情况下能带来 40%-60% 的速度提升,显存占用也能降不少。关键在于:autocast只包住前向传播和损失计算,GradScaler负责放大梯度防止数值下溢:
scaler = torch.amp.GradScaler() for X, y in loader: optimizer.zero_grad() with torch.autocast(device_type="cuda", dtype=torch.float16): logits = model(X) loss = loss_fn(logits, y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()我刚开混合精度的时候犯过一个错:忘了加GradScaler,结果模型不收敛,折腾了一整天。后来才明白,autocast只负责让某些算子用低精度算,梯度下降的学习率步长还是用原始数值,如果梯度被下溢成 0 就白训练了。GradScaler就是专门解决这个问题的,别省。
梯度累积适用于显存实在不够、但 batch_size 又不想太小的情况。思路是每accumulation_steps步才optimizer.step()一次,等效于放大 batch size:
for i, (X, y) in enumerate(loader): logits = model(X) loss = loss_fn(logits, y) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()我实际测过,batch_size=128累积 2 步的效果约等于batch_size=256,但显存只多一点点。这是最后一招,不建议一开始就用,因为额外的除法会引入一点数值误差。
7. 序列化、部署与后续扩展思路
模型训完不等于项目结束。如何把训练产物保存下来、如何加载推理、如何和线上系统对接,是很多教程一笔带过但我认为必须交代清楚的部分。
7.1 保存和加载:不只是 torch.save 那么简单
PyTorch 保存模型有几种姿势。我推荐保存state_dict而不是整个模型对象,因为它只存参数,不依赖模型类的定义路径,后续部署和迁移都更稳:
torch.save({ "model_state_dict": model.state_dict(), "column_transformer": column_transformer, "cat_embed_specs": cat_embed_specs, "num_features": num_features, }, "model_checkpoint.pth")加载的时候,先构建模型结构,再load_state_dict。还要记得把column_transformer一并保存,否则上线预测时新来的数据不知道该怎么预处理。这块是 Sklearn 和 PyTorch 协作最核心的地方:预处理管线和模型权重是绑定的一对,必须一起序列化。
如果线上推理环境是纯 Python,建议导出一份 ONNX 模型。PyTorch 的torch.onnx.export可以一步生成,然后线上用 ONNX Runtime 跑推理,速度和稳定性都远好于直接加载 PyTorch 模型。这个我在之前的分享里展开讲过,这里只提一句:动态输入形状记得用dynamic_axes声明,不然 batch size 固定死了会非常难受。
7.2 在线推理时的预处理同步问题
你以为把column_transformer保存了就没问题了?还有个隐蔽的坑:线上来的数据可能有新的类别值,而ColumnTransformer里的编码器是不知道这个类别的,会直接报错或者悄悄填充成未知值。我的处理方案是给每个类别列设置一个handle_unknown="ignore"的参数,这样未知类别会被编码成某个统一的兜底值,不至于让线上服务崩溃。
同理,数值列的缺失值也要在推理前就定义好填充策略。Sklearn 的SimpleImputer如果设置成strategy="most_frequent"或者median,只会在训练时计算一次统计量并保存,线上推理时会用训练时的统计量填充,这个行为是安全的。但如果你在线上代码里临时写df.fillna(df.median()),那用的就是线上这批数据的统计量,与训练分布不再一致,预测结果会悄悄偏移。
7.3 后续还能怎么扩展
写完这篇,回看整个项目,有几个方向值得继续做下去。一个是特征重要性分析,用 Sklearn 的permutation_importance对 PyTorch 模型的预测结果做全局解释,虽然严格来说它需要模型能批量输出概率,但包装器里的predict_proba已经满足了。另一个是模型蒸馏,把 PyTorch 大模型当作 teacher,训练一个小巧的 Sklearn 模型做 student,在回归类业务上既保精度又降延迟。还有一个是在线学习,PyTorch 天然支持增量更新,可以定期拿新数据微调模型权重,这在 Sklearn 的传统模型上需要额外实现。
以我个人的实际使用体会来说,Sklearn 和 PyTorch 不是非此即彼的关系,而是互补的合作者。掌握好"以 Sklearn 为骨架、PyTorch 为引擎"这套协作逻辑,大部分表格型机器学习任务都能在不牺牲效果的前提下保持较高的工程效率。希望这篇系列第三篇能帮你把这两块拼图真正拼到一起。