☰
Sklearn与PyTorch混合建模:从数据预处理到部署的完整实战指南
2026/10/13 4:14:42 网站建设 项目流程

这一篇作为系列的第三篇,重心放到了“怎么把 Sklearn 和 PyTorch 当一个体系用”。前两篇如果都跟过,大概已经能拿 Sklearn 快速跑通表格数据建模,也能用 PyTorch 搭出全连接网络并训练到收敛。很多人到这一步就停了,认为两个库是两条路线,各做各的。实际项目里,两个库几乎总会出现在同一条流水线里:前面用 Sklearn 做特征工程和交叉验证,后面用 PyTorch 处理那些 Sklearn 搞不定的高维输入;要么反过来,先用 PyTorch 抽特征,再拿 Sklearn 当最终分类器。这篇就把这套组合打法拆开讲。

这篇适合已经会用 Sklearn 的基础模型、能写简单 PyTorch 训练循环、但还没把两者串成一个完整项目的人。读完至少能拿到一套可以直接抄的组合工作流,涉及数据管线、超参数搜索、模型解释和导出部署。前两篇偏基础,这一篇聊实际工程里更常用的细节,还有不少是文档里不会写的坑。

1. 先理清:Sklearn 和 PyTorch 的真实分工

1.1 为什么两个库要一起用

不少初学者有一个错觉:Sklearn 是传统机器学习,PyTorch 是深度学习,两者是竞争关系,二选一就行。这个理解在实际项目里很误事。它们解决的问题有重叠,但优势区间完全不同。

Sklearn 强在“小而稳”。几千行、几十个特征的数据,随机森林、逻辑回归、梯度提升树能在几秒或几分钟内给出结果,而且自带交叉验证、网格搜索、模型解释、特征处理这些完整流程。它的 API 设计高度统一,训练、预测、评估几乎是同一套写法,非常适合做基线模型和快速验证。

PyTorch 强在“大而活”。图像、文本、时间序列这类非结构化数据,或者数据规模大到传统模型明显吃力时,深度学习模型能利用自动求导和 GPU 计算把特征自动学出来。它还有一个不可替代的点:灵活性。自定义损失函数、自定义训练逻辑、动态网络结构,Sklearn 里折腾半天的事情,在 PyTorch 里只要重写一个 forward 或训练循环就行。

实际项目里更常见的场景是:先用 Sklearn 快速做一个可解释的基线,用交叉验证估出模型能到什么水平,同时判断特征有没有明显问题;如果基线分数不够,再考虑把非结构化部分或复杂交互交给 PyTorch。这两个库不是替代关系,而是接力关系。

1.2 我常用的分工原则

我给自己定过一套简单的分工标准,基本能覆盖大多数表格类项目。

  • 如果数据已经是结构化表格,特征量级在几百以内,样本量不是特别大,优先用 Sklearn 系模型。LightGBM、XGBoost 这类梯度提升树往往比精心调过的简单神经网络更稳。
  • 如果数据是图片、文本、语音,或者序列特征,不用纠结,直接走 PyTorch。
  • 如果项目最终要求可解释性,比如风控、医疗、合规类场景,Sklearn 的树模型或线性模型更容易向业务方解释清楚,PyTorch 的归因方法再成熟也比不上传统特征重要性直观。
  • 如果数据里同时有表格特征和文本/图像特征,两条路线都要有:Sklearn 处理表格侧,PyTorch 处理非结构化侧,最后在输出层或特征层拼接。
  • 当样本量超过几十万且特征维度也不低,传统模型训练时间开始失控,才值得认真考虑上 PyTorch。

这套原则不是死规矩,但每次动手前先过一遍,能省掉很多来回返工的麻烦。我见过好几个项目,明明表格数据用梯度提升树三分钟就拿到好结果,非要上深度模型,调了两周网络结构还不收敛。不是 PyTorch 不行,是场景没选对。

1.3 本系列默认环境

到第三篇,环境建议相对稳定一些。Python 版本不用太新,3.9 以上就行。Sklearn 版本保持在 1.0 之后的版本,PyTorch 用一个能正常跑 CUDA 的稳定版本即可。不同版本之间在 API 上会有小差异,比如某些预处理器的参数名,但今天使用的这些功能都是跨版本稳定的,不绑定特定版本号。

依赖安装我就不重复写了,按官方源装好即可。需要留意的反而是版本冲突问题。Sklearn 和 PyTorch 的依赖重叠不多,冲突案例主要出在 numpy 版本上。PyTorch 对 numpy 版本有一定要求,Sklearn 也跟着 numpy 走,如果遇到一起 import 报错,优先检查 numpy 是不是被某个库强行升降了版本。我碰到过好几次,conda 自动把 numpy 升到不兼容版本,PyTorch 能导入,Sklearn 却开始报 ABI 错误,最后固定 numpy 版本解决。

2. 把 Sklearn 的预处理能力注入 PyTorch 的数据流

2.1 为什么不用手写 NumPy 预处理

很多人第一次写 PyTorch 表格模型时,习惯用 pandas 或 NumPy 手动做标准化、独热、缺失值填充,然后把数组喂给 DataLoader。数据列少、场景简单时这么干没问题,一旦特征类型多了就有隐患。

隐患之一是代码膨胀。每一列一种处理方式,写着写着就成了几百行没人愿意维护的脚本。隐患之二是数据泄漏。如果先在整个数据集上算出均值方差再标准化,再切训练集和测试集,验证分数会虚高,因为测试集的统计信息已经被模型间接看到了。隐患之三是没法复用。换一个数据集、加一列特征,脚本又得大改。

Sklearn 的 Pipeline 和 ColumnTransformer 就是为这个场景设计的。它们把每一步预处理固定成可组合的模块,fit 只在训练集上执行,测试集只调用 transform,天然规避了数据泄漏问题。把这套东西接在 PyTorch 前面,数据流是:原始 DataFrame → sklearn 预处理 → NumPy 数组 → PyTorch Dataset 和 DataLoader。

2.2 ColumnTransformer 实战:混合特征一次搞定

假设手里有一个结构化数据集,既包含数值列,又包含类别列。典型的处理方式:数值列做标准化,类别列做独热编码。这个需求用 ColumnTransformer 一行配置就能表达。

from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, StandardScaler num_cols = ["age", "income", "score"] cat_cols = ["city", "channel", "is_new_user"] preprocessor = ColumnTransformer([ ("num", StandardScaler(), num_cols), ("cat", OneHotEncoder(handle_unknown="ignore"), cat_cols), ])

把预处理器 fit 到训练数据上,然后 transform,就能得到可直接输入神经网络的特征矩阵。这里有两个值得注意的细节。

第一,OneHotEncoder 的参数不要漏掉handle_unknown="ignore"。测试集里出现训练集没见过的类别时,如果没有这个参数,transform 阶段会直接报错。有了它,新类别会被全部编码为 0,虽然信息会丢失,但至少不会崩掉。

第二,ColumnTransformer 的 transform 输出是密集矩阵,如果类别列很多,独热之后特征维度会迅速膨胀。这时候可以换成OneHotEncoder(handle_unknown="ignore", sparse_output=True),再在后续处理里用稀疏矩阵的格式。PyTorch 的 Tensor 不直接支持稀疏矩阵,所以通常还是先 toarray 再转 Tensor。要是数据量实在大到内存装不下,这一步说明特征工程本身就需要重新设计,而不是硬上独热。

2.3 从 DataFrame 到 Dataset 的一站式封装

Sklearn 预处理完成后,得到的是 NumPy 数组。把这个数组接进 PyTorch,需要写一个 Dataset 类。我通常不会为每一列分别做转换,而是把整个特征矩阵一次性丢进去。

import torch from torch.utils.data import Dataset class TabularDataset(Dataset): def __init__(self, X, y): self.X = torch.tensor(X, dtype=torch.float32) self.y = torch.tensor(y, dtype=torch.long) def __len__(self): return len(self.X) def __getitem__(self, idx): return self.X[idx], self.y[idx]

这个 Dataset 类简单到“裸奔”,但配合 DataLoader 已经足够。使用的时候,只需要在训练之前拿到已预处理的 NumPy 数组。

from torch.utils.data import DataLoader X_train = preprocessor.fit_transform(df_train) X_val = preprocessor.transform(df_val) train_loader = DataLoader( TabularDataset(X_train, y_train), batch_size=64, shuffle=True, )

注意一个容易犯错的点:preprocessor.fit_transform必须在训练数据上调用,验证集和测试集只允许transform。如果对全量数据做了 fit 再切分,哪怕只是切分前做了标准化,也会引入轻微的数据泄漏。这个泄漏在数据量大时可能不明显,但在小数据集上会让验证分数虚高,导致上线后表现对不上。

2.4 这里最容易踩的坑

组合管线跑通之后,还要检查列名顺序。ColumnTransformer 输出的是 NumPy 数组,它不会自动告诉你“第 3 列是什么特征”。如果后面要分析 PyTorch 模型的梯度归因,或者把特征重要性映射回原始字段,这一步信息丢失会让你很被动。

我自己的做法是,在预处理之后顺手保存一份特征名列表。

feature_names = ( num_cols + list( preprocessor.named_transformers_["cat"] .get_feature_names_out(cat_cols) ) )

从独热编码器里取出来的名字通常长成city_上海、channel_APP这样的格式,和原始类别一一对应。保留这份列表,后续做可解释性或排查特征对齐问题时,会省下大量时间。

还有一点,如果在 ColumnTransformer 里同时用了StandardScaler和OneHotEncoder,不要为了省事在某个步骤里加drop或者把缺失值填到分类列里。缺失值处理放在预处理器的开头,而不是在数据生成之后补,否则很容易出现训练时和预测时填充逻辑不一致的问题。

3. 让 GridSearchCV 直接搜索 PyTorch 模型

3.1 为什么不用手调

PyTorch 模型的超参数相当多:隐藏层大小、层数、学习率、批次大小、训练轮数、dropout 概率、正则化强度。手调不是不行,但每一次调参都要手动改参数、重跑训练、记录结果,项目一复杂就浪费时间,而且很容易漏掉某些参数组合的对比。

Sklearn 的GridSearchCV和RandomizedSearchCV提供了成熟的交叉验证搜索框架,可以对任意一个“服从 sklearn 接口”的模型做超参数搜索,返回最优参数并自动在全部训练数据上重新拟合。问题在于 PyTorch 模型不是 sklearn 估计器,接口对不上。

解决思路很直接:给 PyTorch 模型包一层,让它实现 sklearn 的BaseEstimator和ClassifierMixin接口,然后在 fit 方法里跑 PyTorch 训练循环,在 predict 方法里做模型推理。包好之后,这个自定义类就跟 RandomForestClassifier 用法一致,可以直接塞进交叉验证搜索。

3.2 写一个标准包装类

下面是一个我常用的最小包装类,适合多分类表格任务。

import numpy as np import torch import torch.nn as nn from sklearn.base import BaseEstimator, ClassifierMixin from sklearn.metrics import accuracy_score class TorchMLPClassifier(BaseEstimator, ClassifierMixin): def __init__( self, hidden_size=64, lr=0.001, epochs=20, batch_size=64, random_state=42, ): self.hidden_size = hidden_size self.lr = lr self.epochs = epochs self.batch_size = batch_size self.random_state = random_state def fit(self, X, y): torch.manual_seed(self.random_state) np.random.seed(self.random_state) n_features = X.shape[1] n_classes = len(np.unique(y)) self.model_ = nn.Sequential( nn.Linear(n_features, self.hidden_size), nn.ReLU(), nn.Linear(self.hidden_size, self.hidden_size), nn.ReLU(), nn.Linear(self.hidden_size, n_classes), ) optimizer = torch.optim.Adam(self.model_.parameters(), lr=self.lr) loss_fn = nn.CrossEntropyLoss() X_t = torch.tensor(X, dtype=torch.float32) y_t = torch.tensor(y, dtype=torch.long) n = X_t.shape[0] for epoch in range(self.epochs): self.model_.train() perm = torch.randperm(n) for i in range(0, n, self.batch_size): idx = perm[i : i + self.batch_size] optimizer.zero_grad() logits = self.model_(X_t[idx]) loss = loss_fn(logits, y_t[idx]) loss.backward() optimizer.step() return self def predict_proba(self, X): self.model_.eval() with torch.no_grad(): logits = self.model_(torch.tensor(X, dtype=torch.float32)) proba = torch.softmax(logits, dim=1) return proba.numpy() def predict(self, X): return self.predict_proba(X).argmax(axis=1) def score(self, X, y): return accuracy_score(y, self.predict(X))

这个类不追求花哨,但已经把几个关键点都覆盖了。

__init__里所有超参数都保持默认值,且不包含可变类型默认值。Sklearn 的交叉验证机制会判断一个估计器的参数是否在__init__里显式声明,进而决定搜索时怎么赋值。如果用了可变默认值,比如param=None后又在内部改成列表,会出现搜索时参数不生效的问题,这类 bug 很难查。

fit里保存模型结构和训练逻辑到self.model_,这是 sklearn 估计器约定俗成的写法,有些交叉验证工具会访问self.model_或self.classes_,照这个习惯写不会出事。

fit返回self,而不是返回 loss 或历史记录,这是 sklearn 接口的硬性要求。交叉验证在每次拟合后要用这个对象做预测,如果你返回其他值,整套流程会直接报错。

3.3 用 RandomizedSearchCV 跑起来

包装好之后,直接用RandomizedSearchCV搜参。

from sklearn.model_selection import RandomizedSearchCV param_dist = { "hidden_size": [32, 64, 128, 256], "lr": [1e-4, 3e-4, 1e-3, 3e-3], "epochs": [20, 50, 100], "batch_size": [32, 64, 128], } search = RandomizedSearchCV( TorchMLPClassifier(), param_distributions=param_dist, n_iter=20, cv=3, scoring="accuracy", n_jobs=1, random_state=42, ) search.fit(X_train, y_train)

这里有几个参数要特别说明。

n_jobs必须设成 1。这不是保守,而是 PyTorch 的模型训练进程如果并行跑,GPU 或 CPU 资源会被多个训练任务争抢,反而互相拖慢。即使是在 CPU 上训练,并行跑也会造成内存暴涨,因为每个进程都会复制一份模型和图。Sklearn 的搜索框架支持并行,但那是为轻量级 sklearn 模型设计的,对 PyTorch 训练循环来说,串行搜索通常更快。

scoring建议根据业务选择,不要无脑用 accuracy。二分类不平衡数据用roc_auc更合适,多分类可以用f1_macro或f1_weighted。这个评分会直接影响最优参数的选择,score 定义错了,搜出来的模型很可能部署后才发现问题。

n_iter不要太大。RandomizedSearchCV 的每一次迭代都会做cv次完整训练,这里n_iter=20、cv=3意味着最多要训练 60 次,如果epochs=100且数据量不小,等待时间会很难受。先跑小规模摸清大致范围,再缩小搜索区间是有经验的做法。

3.4 搜索过程中别做这几件事

我最初把包装类写得太“聪明”,在 fit 内部打印每个 epoch 的 loss,结果 RandomizedSearchCV 输出几千行日志,根本没法看。更严重的是,搜索期间我习惯用 TensorBoard 记录训练过程,每个模型都开一个 writer,生成了一堆无用目录,磁盘直接爆掉。

搜索阶段建议保持训练循环静默,只记录最终结果。如果确实需要观察收敛情况,先用一组固定参数手动跑一次,确认模型能正常收敛,再放进搜索框架。搜索期间的日志只依赖 RandomizedSearchCV 自己的进度输出反而更清晰。

另一个容易忽视的问题是随机性。包装类里已经设了torch.manual_seed,但是 DataLoader 的shuffle、数据划分中的随机过程、以及 numpy 的随机操作都会影响结果。为了让搜索结果可复现,在调用搜索前额外设置全局种子。

import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42)

注意,torch.manual_seed只保证 CPU 上的随机性。如果检测到 GPU 可用,最好再加上下面的设置。

if torch.cuda.is_available(): torch.cuda.manual_seed_all(42)

这些设置不一定让每次搜索结果完全相同,因为某些底层库的并行算法仍然有随机性,但至少能把主要的不确定性控制住。搜索结果不可复现是新手转向工程实践时最容易撞上的问题,早点养成固定种子的习惯,能少踩很多坑。

4. 模型解释:让黑盒和传统模型用一套话术

4.1 Sklearn 模型的特征重要性和偏依赖

Sklearn 里的解释工具非常好用,尤其是树模型。RandomForestClassifier直接有feature_importances_,一次训练就能拿到每个特征的平均贡献度。这个数值基于不纯度减少,训练过程中自动计算,开销极小。它适合回答“哪些特征总体更重要”。

不过feature_importances_有一个问题:它偏向高基数特征,也就是数值型、类别特别多的特征容易被高估。为了得到更稳健的特征排序,我在实际项目里更常用permutation_importance。

from sklearn.inspection import permutation_importance model.fit(X_train, y_train) result = permutation_importance( model, X_val, y_val, n_repeats=10, random_state=42, scoring="accuracy", )

置换重要性的原理很简单:把某个特征列随机打乱,打乱后模型精度下降越多,说明这个特征对预测越重要。它不依赖模型内部结构,对任意 sklearn 模型都适用。即使你最终线上的模型是 PyTorch,只要你之前用 Sklearn 做过一个基线模型,这个基线模型的特征重要性已经能帮你判断哪些特征是对业务有稳定贡献的。

偏依赖图也是很有价值的工具。它把单个特征在不同取值下模型预测的均值画出来,能直观看到某个特征和预测结果之间是线性、U 型还是阶梯关系。对树模型来说,某些特征的影响在某个阈值后突然变化,这个信息对业务方非常有说服力。

4.2 PyTorch 模型的梯度归因

PyTorch 模型没法直接拿到类似feature_importances_的属性,但有一个经典方法可以从已训练好的神经网络上获取特征归因:梯度乘以输入。这个方法被称为梯度归因或 Input×Gradient,是很多解释工具的简化版本。

思路是:对输入特征的每一维,计算损失或预测分数对它的梯度,梯度绝对值大说明这个特征对预测结果的敏感度高;再乘上输入值本身,得到近似的贡献量级。虽然它在严格意义上不是完备的归因方法,但在表格数据上给出的特征排序往往和置换重要性高度一致,已经具备实用价值。

下面是一个简化的实现。

def gradient_attribution(model, X): model.eval() X_t = torch.tensor(X, dtype=torch.float32, requires_grad=True) logits = model(X_t) proba = torch.softmax(logits, dim=1) # 取每个样本预测概率最高的类别作为目标 pred_class = proba.argmax(dim=1) score = proba[torch.arange(len(X)), pred_class].sum() score.backward() gradient = X_t.grad attribution = (gradient * X_t).abs().mean(dim=0) return attribution.detach().numpy()

这里有两个细节。计算归因时要把模型切到eval模式,确保 BatchNorm 和 Dropout 不会干扰梯度。梯度计算本身很轻量,不需要在训练模式下运行。

拿到归因向量后,和前面保存的feature_names对齐,就能输出一个“特征名 → 归因分”的列表。这个列表虽然不像 Sklearn 的特征重要性那么标准,但作为内部参考和调试手段已经足够。

如果项目对解释性要求更高,还可以引入专门的可解释性库,比如 Captum 或者 SHAP。Captum 是专为 PyTorch 设计的归因工具库,提供了 Integrated Gradients、DeepLIFT 等更严谨的算法,接口成熟,直接调用即可。SHAP 则同时支持 sklearn 模型和深度学习模型,还能画各种可视化图,适合做项目汇报。不过引入这类库之前,先用最朴素的梯度归因跑一遍,能更快确认方向。

4.3 两种解释怎么交叉验证

传统模型和深度模型拿到不同的解释结果时,先别急着认定某个模型的解释是对的。正确的做法是把它们放在同一个数据集上对比特征排序。

我做过一个模拟项目,数据里同时有数值和类别特征,Sklearn 的梯度提升树给出的重要特征前三名是 A、B、C,PyTorch 的梯度归因给的特征排序是 B、C、D。乍一看有差异,但把 A 和 D 的排序位置对比后发现,这两个特征本身高度相关,排在前面的是同一个信息,只是被不同模型拆开了。遇到这种情况,业务上把 A 和 D 看作同一个特征组去解释,比纠结单列排序更有意义。

另一种常见情形是:某一个特征在 Sklearn 模型里重要性很高,在 PyTorch 模型里归因很低。这通常说明模型对特征交互方式的理解不同,不一定是 PyTorch 模型有问题。可以进一步用偏依赖图看这个特征在传统模型里的边际效果,如果单调性明显,而 PyTorch 训练时没有做同样的特征变换,结果不一致也是正常的。

解释工作做到“能定位关键特征、能判断符号方向、能发现特征间的相关性”,就足够支撑业务决策了。追求过于精细的归因数值并不一定可靠,反而容易被误差带偏。

5. 导出与推理:把两个模型统一成一套服务

5.1 为什么考虑 ONNX

开发阶段模型跑在 Python 环境里没有任何问题,但上线推理时通常没法直接在公司服务里装一套完整的 PyTorch 和 Sklearn 环境。ONNX 格式的价值在于把训练框架和推理环境解耦。模型导出成 ONNX 之后,可以用专门的运行时加载,跨语言调用,不需要依赖原始训练框架。

对于包含 Sklearn 和 PyTorch 的混合项目,合理的导出策略是:把 PyTorch 模型导出为 ONNX 文件,把 Sklearn 预处理部分保留为 joblib 或 pickle 文件,然后在推理服务里用一小段胶水代码把两者串起来。这样做的原因是 sklearn 的 Pipeline 虽然也有工具能转成 ONNX,但涉及复杂预处理时兼容性差,容易在某个算子上报错。

5.2 从 PyTorch 导出 ONNX

PyTorch 导出 ONNX 的核心接口是torch.onnx.export,关键是准备一个固定形状的示例输入。

model.eval() dummy_input = torch.randn(1, n_features) torch.onnx.export( model, dummy_input, "mlp.onnx", input_names=["features"], output_names=["logits"], dynamic_axes={"features": {0: "batch"}, "logits": {0: "batch"}}, opset_version=17, )

dummy_input的形状要和实际推理时输入一致。如果模型在训练前做了预处理,那么这里的输入也是预处理后的特征矩阵,而不是原始数据。dynamic_axes表示第 0 维 batch 大小可以动态变化,这样就能用同一个文件预测 1 条或 1000 条样本。

导出失败常见于自定义层、自定义 forward 里的条件分支、以及某些 PyTorch 算子 ONNX 还不支持。为降低失败风险,导出的模型结构尽量保持标准。如果模型里用了特殊模块,建议在导出前做一次简化,或用标准算子重写 forward 逻辑。绝大多数表格场景的 MLP 和 CNN 结构都能顺利导出。

5.3 Sklearn 预处理部分怎么保存

Sklearn 的 Pipeline 保存非常简单,直接 joblib 即可。

import joblib joblib.dump(preprocessor, "preprocessor.joblib")

推理服务启动时加载这个文件,对请求的原始特征做 transform,得到特征矩阵,再喂给 ONNX Runtime。这个组合方式比“硬着头皮把整个 sklearn pipeline 转 ONNX”要稳得多。预处理部分的升级和迭代也方便,更换模型时无需重新导出 ONNX。

有一种场景要把 Sklearn 模型也导出成 ONNX,那就是全部推理逻辑都不希望依赖 Python 环境。Sklearn 转 ONNX 的工具存在,但要注意类别编码器、缺失值处理器、标准化这些组件组合在一起时容易版本不匹配。我的建议是,在项目初期先用 joblib 方案跑通,确认推理性能可以接受,再考虑全量 ONNX 化。工程上一个能稳定运行几个月的老实方案,远胜一个始终在修复杂方案。

5.4 推理时统一调用接口

在推理侧,把 Sklearn 预处理器和 ONNX 模型包在一个类里,对外暴露一个predict方法,是最省心的做法。

import joblib import numpy as np import onnxruntime as ort class MixedPipeline: def __init__(self, preprocessor_path, onnx_path): self.preprocessor = joblib.load(preprocessor_path) self.session = ort.InferenceSession(onnx_path) def predict_proba(self, raw_features): X = self.preprocessor.transform(raw_features).astype(np.float32) results = self.session.run( ["logits"], {"features": X}, )[0] proba = 1 / (1 + np.exp(-results)) return proba

这里的重点是对齐输入名和输出名。导出 ONNX 时写的input_names=["features"]、output_names=["logits"],在运行时也要完全一致。如果名字对不上,ONNX Runtime 会直接报错。另一处常见问题是 dtype。Sklearn 预处理输出的数组通常是 float64,而模型导出时用的是 float32。如果不做astype(np.float32),会出现 dtype 不匹配的报错,而且这种报错信息往往不够直观,新手容易卡很久。

推理接口统一之后,业务方不会感知底层是 Sklearn 还是 PyTorch,只看到一个“输入原始特征、输出分类概率”的服务。后续如果要把预处理也完全替换成 ONNX,只需修改这个包装类内部,对外接口保持不变。

6. 实战中的问题排查清单

6.1 环境与训练阶段问题

Sklearn 和 PyTorch 同时存在的项目,报错类型也经常来自两个库的衔接处。第一个高频问题是torch.tensor接收了包含None或pd.NA的数组。预处理阶段如果漏掉缺失值填充,执行到训练阶段才发现 tensor 转换失败,排查时先检查X里是否有非法值。

第二个高频问题是特征矩阵的 shape 对不上。ColumnTransformer 处理完训练集和测试集后,两者列数必须一致。如果测试集独热编码后比训练集少几列,通常是因为OneHotEncoder的handle_unknown="ignore"会忽略新类别,但反过来漏类别的情况是前面训练时选的类别矩阵没有包含测试集中的新值,需要重新确认预处理器的 fit 对象。

第三个问题在训练阶段:loss 变成 NaN。表格数据里出现 NaN 的原因通常是学习率过大、输入有未处理的无穷值或过大值、以及损失函数与标签不匹配。排查时先在训练循环里打印每轮 loss 的前几项,如果从第一轮就 NaN,基本可以断定数据或参数初始化问题。

6.2 特征对齐与形状问题

特征对齐问题在处理新数据时最隐蔽。训练好的 Sklearn 预处理器保存后,新数据过来时列名或者列顺序发生变化,ColumnTransformer 会按位置而不是列名处理。如果推理服务里传入的 DataFrame 列顺序和训练时不一致,最终预测结果会错得莫名其妙且不报错。

要降低这类风险,在预处理阶段给 ColumnTransformer 传verbose_feature_names_out=False,并保存一份特征顺序清单。推理服务加载数据后,先对列名做校验再进预处理器。如果列名对不上,服务应该直接返回错误而不是静默给出错误预测。

expected_columns = joblib.load("feature_names.joblib") def validate_columns(df): missing = set(expected_columns) - set(df.columns) if missing: raise ValueError(f"缺少特征列: {missing}") return df[expected_columns]

这一层校验看似多余,实际能挡住不少线上事故。数据研发改了一个字段名,或者上游表结构变了,如果不校验,模型会带着错误特征静默跑很久,直到业务方发现分数异常才追回来,那就晚了。

6.3 结果不一致和随机性排查

模型训练和推理结果不一致,最常见的原因是训练时开了 dropout 或 BatchNorm 没有正确切到 eval 模式。预测时务必调用model.eval(),否则每次预测结果都会不同。

另一个原因是数据预处理在训练和推理时不一致。比如训练时标准化用的均值方差来自训练集,推理时却对全量数据重新计算了均值方差,会导致特征分布漂移。排查这类问题,把训练阶段某条样本的预处理结果和推理阶段同一条样本的预处理结果逐列打印出来对比,很快就能定位。

还有一个原因和第 3 节讲的随机种子相关。PyTorch 的模型权重初始化是随机的,如果重新训练一个模型用于上线,即使超参数完全相同,新模型的预测结果也会和开发阶段的验证结果有细微差异。为了减少这种波动,上线前固定种子重新训练一次,并把该次训练得到的模型文件完整归档。这样之后任何复现和对比都基于同一个模型文件,而不是“重新训练一个”。

6.4 一个速查表

问题现象可能原因排查路径
两个库一起 import 报错numpy 版本被改动固定 numpy 版本,重建环境验证
训练 loss 是 NaN学习率过大、输入有非法值先查数据,再降低学习率
测试集独热编码后列数变少OneHotEncoder 对训练集类别固化检查 handle_unknown 参数,确认 fit 范围
推理时输入维度不对预处理流程不一致校验列名和特征顺序,检查 dtype
GridSearchCV 总是报参数错误__init__里用了可变默认值让所有参数在__init__中显式赋值
PyTorch 模型重新训练后分数不稳定随机种子没固定固定 torch/numpy/random 三个种子,归档模型文件
ColumnTransformer 输出列名丢失没保存特征名用 get_feature_names_out 及时导出
ONNX Runtime 报输入名错误输入名和导出时不一致对比 input_names 与 session.run 的 key

表格里的每一项都来自真实踩坑记录。很多问题从报错信息看像是模型或者代码的问题,实际上都出在数据接口或者环境配置上。排查时遵循“先数据、再接口、后模型”的顺序,通常能最快定位。

混合使用 Sklearn 和 PyTorch 的收益不是某个单一库能替代的。Sklearn 提供了最稳妥的数据处理和模型评估骨架,PyTorch 提供了训练大模型的灵活性和扩展空间。把它们串成一条完整的数据流水线,整个项目的工程质量会有明显提升。我自己在实际项目里的体会是:先把数据处理和交叉验证框架搭稳,再去追求复杂的模型结构,顺序千万别反。数据管线稳了,后面换模型、做解释、部署导出,都会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询