1. 为什么要把 PySpark 和 Sklearn 的预处理放在一起比较
做过机器学习项目的人都有一个共识:数据预处理往往占了整个项目七成以上的工作量。而当你从单机小数据集切换到分布式大数据集时,最痛苦的事情不是模型跑不动,而是原来在 Sklearn 上跑得好好的预处理代码,换到 PySpark 上几乎要重写一遍。函数名不一样、调用方式不一样、返回类型不一样,甚至连“缺失值填充”这种基础操作都有好几套写法。
这个项目标题的核心价值就在于:把两个框架里做同一件事的不同写法摆在一起,让你在迁移代码时能快速找到对应关系,而不是每次都去翻两套文档。它解决的是“我知道要做什么预处理,但不知道在另一个框架里该怎么写”这个具体问题。适合已经会用 Sklearn 做预处理、正在或即将接触 PySpark 的算法工程师和数据开发人员,也适合刚入门机器学习、想一次性建立正确预处理习惯的新手。
我自己的经历是:最早做推荐系统特征工程时全用 Sklearn 的Pipeline和ColumnTransformer,后来数据量涨到单机内存放不下,被迫迁到 PySpark。那段时间最常干的事就是打开两个浏览器标签页,左边 Sklearn 文档,右边 PySpark 文档,一行一行对照着改。改多了就发现,其实两者在“设计哲学”上有根本差异,理解了差异,对照表就不用死记了。
2. 核心设计哲学差异:惰性求值 vs 即时计算
2.1 一个生活类比:点外卖 vs 自己下厨
Sklearn 的预处理像自己下厨:你拿起刀就切菜,切完菜立刻下锅,每一步操作马上产生结果,你能看到、能摸到、能尝味道。StandardScaler().fit_transform(X)一执行,返回的就是一个实实在在的 NumPy 数组,你可以立刻打印它的形状、均值、方差。
PySpark 的预处理像点外卖:你先在 App 里把菜加进购物车(定义转换操作),但厨房并没有开始做。直到你点击“提交订单”(触发 Action 操作),整个流水线才真正跑起来。scaler.fit(df)只是记录了要计算均值和标准差这件事,scaler.transform(df)返回的还是一个 DataFrame,里面装的是“计划怎么转换”的元信息,而不是转换后的数据。只有当你调用.show()、.count()、.write()这类 Action 时,Spark 才会真正执行计算。
这个差异带来的直接影响是:在 PySpark 里,你不能像 Sklearn 那样在中间步骤随便打印数据来调试。你写了一大串StringIndexer、OneHotEncoder、VectorAssembler,如果最后忘了触发 Action,程序会“静默成功”——不报错,但什么也没算。我踩过这个坑:写了个特征工程脚本,跑完没报错,以为成功了,结果下游模型训练时发现特征列全是空的,排查半天才想起来没触发 Action。
2.2 数据形态差异:NumPy 数组 vs 分布式 DataFrame
Sklearn 的输入输出基本都是 NumPy 数组或 SciPy 稀疏矩阵,所有数据都在单机内存里,你可以随意切片、索引、转置。PySpark 的输入输出是 DataFrame,数据分散在集群多个节点上,你没法直接df[0, 1]取某个元素,也没法df.T转置。所有操作都必须通过 DataFrame API 或 SQL 表达。
这意味着在 Sklearn 里很自然的操作,在 PySpark 里需要换思路。比如“把某一列的值取出来做标准化”,Sklearn 里就是X[:, 0],PySpark 里你得用VectorAssembler先把列拼成向量,再用StandardScaler处理向量列。再比如“对类别列做 One-Hot”,Sklearn 的OneHotEncoder直接输出稀疏矩阵,PySpark 需要先StringIndexer转成数值索引,再OneHotEncoder转成稀疏向量,两步走。
2.3 状态管理差异:Fit 一次 vs Fit 多次
Sklearn 的fit和transform是分离的,但fit之后得到的统计量(均值、方差、分位数等)存在对象内部,transform时直接使用。PySpark 的fit返回的是一个Model对象,这个 Model 里同样存了统计量,但它的transform是惰性的,而且 Model 可以保存到磁盘、跨作业复用。
这里有个容易忽略的点:PySpark 的StringIndexer在fit时会根据当前数据的类别频率排序,频率最高的类别索引为 0。如果训练集和测试集的类别分布不同,测试集里可能出现训练集没见过的类别,这时StringIndexer默认会报错。你需要设置handleInvalid="skip"或handleInvalid="keep"来处理。Sklearn 的OrdinalEncoder也有类似问题,但默认行为是报错,需要显式设置handle_unknown="use_encoded_value"。两者都需要你主动考虑“新类别”这个边界情况。
3. 逐项对照:常见预处理操作的两种写法
3.1 缺失值填充
Sklearn 的写法很直接:
from sklearn.impute import SimpleImputer import numpy as np X = np.array([[1, 2], [np.nan, 3], [7, np.nan]]) imputer = SimpleImputer(strategy="mean") X_filled = imputer.fit_transform(X)PySpark 的写法需要先确定要填充哪些列,然后对每列分别处理,或者用Imputer一次性处理多列:
from pyspark.ml.feature import Imputer from pyspark.sql import SparkSession spark = SparkSession.builder.appName("preprocess").getOrCreate() df = spark.createDataFrame([(1.0, 2.0), (None, 3.0), (7.0, None)], ["a", "b"]) imputer = Imputer(inputCols=["a", "b"], outputCols=["a_filled", "b_filled"], strategy="mean") model = imputer.fit(df) df_filled = model.transform(df) df_filled.show()差异点在于:Sklearn 的SimpleImputer默认对所有列统一处理,PySpark 的Imputer必须显式指定输入列和输出列。PySpark 的Imputer只支持数值列,如果要对类别列填充众数,需要自己用groupBy+agg算出众数,再用fillna填充。Sklearn 的SimpleImputer支持strategy="most_frequent",直接处理类别列。
注意:PySpark 的
Imputer在strategy="mean"时,如果某列全是 null,会报错。Sklearn 的SimpleImputer遇到全 null 列会直接丢弃该列(除非设置keep_empty_features=True)。这个差异在真实数据里很常见,需要提前检查。
3.2 标准化与归一化
Sklearn 的StandardScaler和MinMaxScaler用法几乎一样:
from sklearn.preprocessing import StandardScaler, MinMaxScaler scaler = StandardScaler() X_scaled = scaler.fit_transform(X) minmax = MinMaxScaler() X_minmax = minmax.fit_transform(X)PySpark 的对应操作需要先把特征列拼成向量:
from pyspark.ml.feature import VectorAssembler, StandardScaler, MinMaxScaler assembler = VectorAssembler(inputCols=["a", "b"], outputCol="features") df_vec = assembler.transform(df_filled) std_scaler = StandardScaler(inputCol="features", outputCol="scaled", withMean=True, withStd=True) std_model = std_scaler.fit(df_vec) df_scaled = std_model.transform(df_vec) mm_scaler = MinMaxScaler(inputCol="features", outputCol="minmax", min=0.0, max=1.0) mm_model = mm_scaler.fit(df_vec) df_minmax = mm_model.transform(df_vec)关键差异:PySpark 的StandardScaler默认withMean=False、withStd=True,也就是只除以标准差,不减均值。如果你想要和 Sklearn 完全一致的结果,必须显式设置withMean=True。这个默认值差异我踩过坑:迁移代码时没注意,结果标准化后的数据均值不是 0,下游模型收敛慢了很多。
另外,PySpark 的MinMaxScaler默认范围是[0, 1],但可以设置成任意范围,比如[-1, 1]。Sklearn 的MinMaxScaler默认也是[0, 1],通过feature_range参数调整。两者在边界处理上也有差异:Sklearn 对常数特征(所有值相同)会输出 0,PySpark 会输出 0.5(因为(x - min) / (max - min)分母为 0 时,Spark 内部做了保护)。这个差异在特征筛选时会影响判断。
3.3 类别编码
Sklearn 的OneHotEncoder和OrdinalEncoder:
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder X_cat = np.array([["red"], ["blue"], ["green"], ["red"]]) ohe = OneHotEncoder(sparse_output=False) X_ohe = ohe.fit_transform(X_cat) oe = OrdinalEncoder() X_oe = oe.fit_transform(X_cat)PySpark 需要两步:
from pyspark.ml.feature import StringIndexer, OneHotEncoder df_cat = spark.createDataFrame([("red",), ("blue",), ("green",), ("red",)], ["color"]) indexer = StringIndexer(inputCol="color", outputCol="color_index") indexer_model = indexer.fit(df_cat) df_indexed = indexer_model.transform(df_cat) encoder = OneHotEncoder(inputCol="color_index", outputCol="color_ohe") encoder_model = encoder.fit(df_indexed) df_encoded = encoder_model.transform(df_indexed) df_encoded.show()PySpark 的StringIndexer默认按类别频率降序排列,频率最高的索引为 0。Sklearn 的OrdinalEncoder默认按字母顺序排列。如果你需要两者结果一致,PySpark 可以用StringIndexer(..., stringOrderType="alphabetAsc")改成字母升序。但注意,stringOrderType参数在不同 Spark 版本里行为可能不同,建议实测确认。
OneHotEncoder的输出格式也不同:Sklearn 输出的是 NumPy 数组或稀疏矩阵,PySpark 输出的是SparseVector类型的列。如果你要把 PySpark 的编码结果导出到 Pandas 做后续分析,需要先.toPandas(),但SparseVector不能直接转,得先用VectorAssembler或 UDF 转成数组。
3.4 分箱与离散化
Sklearn 的KBinsDiscretizer:
from sklearn.preprocessing import KBinsDiscretizer X_num = np.array([[1], [2], [3], [4], [5], [6], [7], [8], [9], [10]]) kbd = KBinsDiscretizer(n_bins=3, encode="ordinal", strategy="uniform") X_binned = kbd.fit_transform(X_num)PySpark 的Bucketizer:
from pyspark.ml.feature import Bucketizer df_num = spark.createDataFrame([(1.0,), (2.0,), (3.0,), (4.0,), (5.0,), (6.0,), (7.0,), (8.0,), (9.0,), (10.0,)], ["value"]) bucketizer = Bucketizer(splits=[0, 4, 7, 11], inputCol="value", outputCol="bucket") df_bucketed = bucketizer.transform(df_num) df_bucketed.show()差异很明显:Sklearn 的KBinsDiscretizer可以自动计算分箱边界(strategy="uniform"、"quantile"、"kmeans"),PySpark 的Bucketizer必须手动指定splits数组。这意味着在 PySpark 里做等频分箱,你需要先用approxQuantile算出分位数,再传给Bucketizer。多了一步,但也更可控。
实操心得:PySpark 的
Bucketizer要求splits严格递增,且第一个和最后一个元素要覆盖所有可能值。如果数据里有超出范围的值,会报错。建议先用df.approxQuantile(col, [0.0, 1.0], 0.01)拿到最小值和最大值,再构造splits。
3.5 文本特征提取
Sklearn 的CountVectorizer和TfidfVectorizer:
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer corpus = ["this is a test", "this is another test", "test test test"] cv = CountVectorizer() X_counts = cv.fit_transform(corpus) tfidf = TfidfVectorizer() X_tfidf = tfidf.fit_transform(corpus)PySpark 的对应操作:
from pyspark.ml.feature import Tokenizer, CountVectorizer, IDF, HashingTF df_text = spark.createDataFrame([("this is a test",), ("this is another test",), ("test test test",)], ["text"]) tokenizer = Tokenizer(inputCol="text", outputCol="words") df_words = tokenizer.transform(df_text) cv_spark = CountVectorizer(inputCol="words", outputCol="counts", vocabSize=100) cv_model = cv_spark.fit(df_words) df_counts = cv_model.transform(df_words) idf = IDF(inputCol="counts", outputCol="tfidf") idf_model = idf.fit(df_counts) df_tfidf = idf_model.transform(df_counts)PySpark 的文本处理需要先分词(Tokenizer),再向量化。Sklearn 的CountVectorizer内部自动完成了分词和向量化。PySpark 的IDF是独立的步骤,而 Sklearn 的TfidfVectorizer把词频和逆文档频率合在一起。另外,PySpark 的CountVectorizer默认按词频降序取前vocabSize个词,Sklearn 默认保留所有词。如果你需要一致的词汇表,PySpark 要显式设置vocabSize并确保足够大。
4. 完整实操流程:从原始数据到模型输入
4.1 场景设定与数据准备
假设我们有一个用户行为数据集,包含数值特征(年龄、收入)、类别特征(城市、职业)、文本特征(用户评论),目标是预测用户是否会点击某个广告。数据量约 500 万行,单机内存放不下,必须用 PySpark。
原始数据长这样:
| user_id | age | income | city | occupation | comment | clicked |
|---|---|---|---|---|---|---|
| 1 | 25 | 5000 | Beijing | Engineer | "good product" | 1 |
| 2 | null | 8000 | Shanghai | Teacher | "not bad" | 0 |
| 3 | 35 | null | Beijing | Engineer | null | 1 |
在 Sklearn 里,你会用ColumnTransformer把不同列的预处理串起来。在 PySpark 里,你需要用Pipeline把多个 Stage 串起来。下面我按步骤拆解。
4.2 第一步:缺失值处理
数值列用均值填充,类别列用众数填充,文本列用空字符串填充。
from pyspark.ml.feature import Imputer from pyspark.sql.functions import col, when, lit # 数值列填充 num_imputer = Imputer(inputCols=["age", "income"], outputCols=["age_filled", "income_filled"], strategy="mean") num_model = num_imputer.fit(df) df = num_model.transform(df) # 类别列填充众数 city_mode = df.groupBy("city").count().orderBy(col("count").desc()).first()[0] df = df.fillna({"city": city_mode}) # 文本列填充空字符串 df = df.fillna({"comment": ""})这里有个细节:PySpark 的Imputer在fit时会计算均值,但如果某列全是 null,会抛异常。所以最好先检查:
null_counts = df.select([col(c).isNull().cast("int").alias(c) for c in ["age", "income"]]).agg(*[sum(col(c)).alias(c) for c in ["age", "income"]]).collect()[0] for c in ["age", "income"]: if null_counts[c] == df.count(): df = df.drop(c)Sklearn 的SimpleImputer遇到全 null 列会直接丢弃,不需要手动检查。这是 PySpark 需要额外注意的地方。
4.3 第二步:类别编码与文本向量化
from pyspark.ml.feature import StringIndexer, OneHotEncoder, Tokenizer, CountVectorizer, IDF, VectorAssembler # 类别编码 city_indexer = StringIndexer(inputCol="city", outputCol="city_index", handleInvalid="keep") occupation_indexer = StringIndexer(inputCol="occupation", outputCol="occupation_index", handleInvalid="keep") # 文本处理 tokenizer = Tokenizer(inputCol="comment", outputCol="words") cv = CountVectorizer(inputCol="words", outputCol="comment_counts", vocabSize=5000) idf = IDF(inputCol="comment_counts", outputCol="comment_tfidf") # 组装特征 assembler = VectorAssembler( inputCols=["age_filled", "income_filled", "city_index", "occupation_index", "comment_tfidf"], outputCol="features" )注意handleInvalid="keep"的作用:当遇到训练集没见过的类别时,StringIndexer会把它放到一个特殊的索引(通常是类别数),而不是报错。这个索引对应的 One-Hot 向量是全零。Sklearn 的OneHotEncoder需要设置handle_unknown="ignore"才能达到类似效果。
4.4 第三步:特征缩放与 Pipeline 组装
from pyspark.ml.feature import StandardScaler from pyspark.ml import Pipeline scaler = StandardScaler(inputCol="features", outputCol="scaled_features", withMean=True, withStd=True) pipeline = Pipeline(stages=[ city_indexer, occupation_indexer, tokenizer, cv, idf, assembler, scaler ]) pipeline_model = pipeline.fit(df) df_final = pipeline_model.transform(df) df_final.select("scaled_features").show(5, truncate=False)这里的关键是:Pipeline的fit会依次调用每个 Stage 的fit,但整个过程中数据并没有真正被转换。只有pipeline_model.transform(df)被调用时,才会触发实际计算。而transform本身也是惰性的,直到你调用.show()或.write()才真正执行。
实操心得:在开发阶段,建议先用
df.limit(1000)取一小部分数据跑通整个 Pipeline,确认没有语法错误和逻辑问题后,再在全量数据上运行。因为 Spark 的惰性求值特性,很多错误在fit阶段不会暴露,只有触发 Action 时才报错。用小数据快速迭代能节省大量时间。
4.5 第四步:与 Sklearn 结果对比验证
为了确保 PySpark 的预处理结果和 Sklearn 一致,我通常会取一小部分数据,分别用两个框架处理,然后对比输出。比如取 1000 行数据:
# PySpark 处理 df_sample = df.limit(1000) df_sample_pd = df_sample.toPandas() # Sklearn 处理 from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.impute import SimpleImputer from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer # 构建 Sklearn Pipeline num_transformer = SimpleImputer(strategy="mean") cat_transformer = Pipeline([ ("imputer", SimpleImputer(strategy="most_frequent")), ("onehot", OneHotEncoder(handle_unknown="ignore", sparse_output=False)) ]) preprocessor = ColumnTransformer([ ("num", num_transformer, ["age", "income"]), ("cat", cat_transformer, ["city", "occupation"]) ]) X_sklearn = preprocessor.fit_transform(df_sample_pd[["age", "income", "city", "occupation"]])对比时要注意:PySpark 的StringIndexer和 Sklearn 的OneHotEncoder在类别顺序上可能不同,导致 One-Hot 向量的列顺序不一致。如果你需要严格对齐,可以在 PySpark 里用StringIndexer(stringOrderType="alphabetAsc"),在 Sklearn 里用OneHotEncoder(categories="auto")并手动排序。但实际项目中,只要模型能学到正确的权重,列顺序不影响最终效果,不必强求一致。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| PySpark 脚本跑完没报错但结果为空 | 忘记触发 Action | 检查是否有.show()、.count()、.write() | 在关键步骤后加.count()或.show() |
StringIndexer报错 “Unseen label” | 测试集出现训练集没有的类别 | 检查测试集类别分布 | 设置handleInvalid="keep"或"skip" |
StandardScaler结果均值不为 0 | 默认withMean=False | 检查参数设置 | 显式设置withMean=True |
VectorAssembler报错 “Data type mismatch” | 输入列类型不一致 | 用df.printSchema()检查列类型 | 统一转成DoubleType或Vector |
OneHotEncoder输出维度太大 | 类别数过多 | 统计每列的唯一值数量 | 设置StringIndexer的maxCategories或手动合并稀有类别 |
Imputer报错 “Empty column” | 某列全是 null | 用df.select(col(c).isNull().cast("int")).agg(sum(c))检查 | 丢弃该列或改用其他填充策略 |
| Pipeline 保存后加载报错 | Stage 版本不兼容 | 检查 Spark 版本和保存格式 | 统一用pipeline_model.write().overwrite().save(path)保存 |
5.2 独家避坑技巧
技巧一:用explain()查看执行计划。PySpark 的 DataFrame 有explain()方法,可以打印出逻辑计划和物理计划。当你发现某个操作特别慢时,explain()能帮你定位是哪个 Stage 在拖后腿。比如StringIndexer如果触发了全量数据的 Shuffle,explain()里会显示Exchange节点。这时候可以考虑先用sample()取一部分数据 fit,再 transform 全量。
技巧二:缓存中间结果。如果你的 Pipeline 很长,且某些中间结果会被多次使用,用df.cache()或df.persist()把中间 DataFrame 缓存到内存或磁盘。但注意,缓存会占用存储空间,用完记得df.unpersist()。我一般只在调试阶段缓存,生产环境谨慎使用。
技巧三:处理类别不平衡。在 Sklearn 里可以用class_weight参数处理类别不平衡,PySpark 的StringIndexer没有这个参数。你需要在fit之前手动对少数类过采样或对多数类欠采样。PySpark 的sampleBy方法可以按列的值分层采样,适合做欠采样。
技巧四:文本处理的词汇表对齐。如果你在训练集上 fit 了CountVectorizer,在测试集上 transform 时,测试集里出现的新词会被忽略(因为词汇表已经固定)。这是正确行为,但如果你发现测试集的向量维度比训练集小,说明词汇表没对齐。检查cv_model.vocabulary的长度是否一致。
技巧五:数值精度问题。PySpark 的StandardScaler在计算均值和标准差时,用的是分布式聚合,精度可能和 Sklearn 的单机计算有微小差异。如果对精度要求极高,可以在 PySpark 里用approxQuantile或summary()手动计算统计量,再自己实现缩放逻辑。但大多数场景下,这点差异不影响模型效果。
5.3 性能优化建议
PySpark 的预处理性能瓶颈通常不在计算本身,而在数据 Shuffle。StringIndexer、OneHotEncoder、VectorAssembler这些操作如果涉及跨节点数据重分布,会触发 Shuffle,速度会明显变慢。优化思路是:尽量把能放在同一个 Stage 里的操作合并,减少 Shuffle 次数。比如StringIndexer和OneHotEncoder可以放在同一个 Pipeline 里,Spark 会尝试优化执行计划。
另外,VectorAssembler的输入列如果很多(比如上百个 One-Hot 列),建议先用OneHotEncoder的dropLast=True去掉一个冗余列,减少向量维度。Sklearn 的OneHotEncoder也有drop="first"参数,作用相同。
6. 两个框架的选型建议与迁移策略
6.1 什么时候用 Sklearn,什么时候用 PySpark
这个问题没有绝对答案,但有一个简单的判断标准:数据能否放进单机内存。如果数据量在几 GB 以内,Sklearn 的预处理更快、更灵活、调试更方便。如果数据量超过单机内存,或者你需要和 Spark 生态的其他组件(如 Spark SQL、MLlib)配合,那就用 PySpark。
另一个考虑因素是部署环境。如果你的模型最终要部署到 Spark 集群上做批量预测,那预处理逻辑最好也用 PySpark 实现,避免训练和推理阶段用两套代码导致不一致。我见过太多团队因为训练用 Sklearn、推理用 PySpark,结果特征处理逻辑有细微差异,导致线上效果下降。
6.2 从 Sklearn 迁移到 PySpark 的检查清单
迁移时按这个清单逐项检查,能避免大部分坑:
- 缺失值处理:Sklearn 的
SimpleImputer默认对所有列统一处理,PySpark 的Imputer需要指定列。类别列的众数填充需要手动实现。 - 标准化:PySpark 的
StandardScaler默认withMean=False,必须显式改成True才能和 Sklearn 一致。 - 类别编码:PySpark 需要
StringIndexer+OneHotEncoder两步,Sklearn 一步到位。注意handleInvalid和handle_unknown的对应关系。 - 分箱:PySpark 的
Bucketizer需要手动指定splits,Sklearn 的KBinsDiscretizer可以自动计算。 - 文本处理:PySpark 需要
Tokenizer+CountVectorizer+IDF三步,Sklearn 的TfidfVectorizer一步到位。 - 特征组装:PySpark 必须用
VectorAssembler把所有特征拼成一个向量列,Sklearn 直接输出矩阵。 - 惰性求值:PySpark 的所有 transform 都是惰性的,必须触发 Action 才会执行。调试时多用
.show()和.count()。
6.3 持续更新的意义
这个项目标题里写了“持续更新中”,我觉得很有必要。因为 PySpark 和 Sklearn 都在不断迭代,新版本会引入新 API、废弃旧 API、改变默认行为。比如 Spark 3.0 之后OneHotEncoder的默认行为有变化,Sklearn 1.0 之后OneHotEncoder的sparse参数改成了sparse_output。如果你不持续更新对照表,过段时间代码可能就跑不通了。
我自己的做法是:每半年回顾一次两个框架的 Release Notes,把影响预处理的变化记录到自己的笔记里。同时,维护一个最小可复现的测试脚本,每次升级版本后跑一遍,确认关键操作的结果没有变化。这个习惯帮我避免了好几次线上事故。
最后分享一个我常用的调试技巧:在 PySpark 里,如果某个 Stage 的结果不符合预期,先用df.select("output_col").show(5, truncate=False)看前几行,再用df.select("output_col").describe().show()看统计量。如果统计量正常但前几行异常,可能是数据分布问题;如果统计量也异常,那就是逻辑写错了。这个排查顺序能帮你快速定位问题所在。