PySpark MLlib 分类实战:从单机思维到分布式工程落地

PySpark MLlib 分类实战:从单机思维到分布式工程落地
1. 项目概述为什么用 PySpark MLlib 做分类而不是直接上 scikit-learn我第一次在生产环境里跑一个 2000 万行、17 列的电商用户行为日志分类任务时本地笔记本直接蓝屏了三次。不是内存溢出是整个系统卡死——scikit-learn 的RandomForestClassifier在单机上加载全量数据就花了 4 分钟训练又耗掉 22 分钟而模型上线后每次预测延迟高达 800ms。后来我把整个 pipeline 搬到 Spark 集群上用 PySpark MLlib 重写训练时间压到 93 秒预测延迟降到 11ms集群资源利用率还不到 35%。这件事让我彻底明白PySpark MLlib 不是“另一个机器学习库”而是为“数据规模超过单机承载能力”这个现实问题量身定制的工程解法。你手头这个 Car Evaluation Dataset汽车评估数据集表面看只有 1728 条记录、7 个特征是个教科书级的小数据集。但它的价值恰恰在于——它是一把精准的“手术刀”能帮你切开 PySpark MLlib 整个分类流程的每一层肌肉与神经。它没有缺失值、没有异常值、类别分布均衡4 类各占约 25%所有特征都是离散型字符串buying、maintainence、doors 等天然适配 Spark 的列式处理范式。更重要的是它强制你面对 PySpark 特有的数据流转逻辑StringIndexer 编码 → VectorAssembler 向量化 → Pipeline 串联 → 模型训练/评估。这些步骤在 scikit-learn 里被封装成LabelEncodernp.column_stack()fit()三行代码但在 Spark 里每一步都暴露着分布式计算的本质约束。所以这篇文章不讲“怎么跑通一个 demo”而是带你亲手拆解为什么 StringIndexer 必须.fit()再.transform()为什么 VectorAssembler 输出的features列是个Vector类型而不是普通数组为什么MulticlassClassificationEvaluator默认用weightedRecall而不是准确率这些细节背后是 Spark 如何把“单机算法”翻译成“分布式作业”的底层契约。你学会的不是 car_type 分类而是未来处理 TB 级用户画像、PB 级日志流分类时那个不会让你在凌晨三点被报警电话叫醒的稳定框架。提示本文所有代码均基于 PySpark 3.5.0LTS 版本实测兼容 Spark 3.3 集群。本地开发推荐使用pyspark3.5.0findspark启动避免版本错配导致ClassNotFoundException。不要用 conda 安装 pyspark——它默认装的是阉割版缺少 YARN 和 Kubernetes 支持。2. 核心设计思路从单机思维到分布式范式的四次认知跃迁2.1 第一次跃迁放弃“一次性加载全部数据”的执念在 pandas 或 scikit-learn 里我们习惯df pd.read_csv(car_data.csv)然后X, y df.drop(car_type, axis1), df[car_type]。这在 PySpark 里是危险的起点。Spark 的核心哲学是Lazy Evaluation惰性求值spark.read.csv()只是定义了一个逻辑计划Logical Plan真正触发计算的是.show()、.count()或.write()这类 action 操作。这意味着你调用df_pyspark.show(5)时Spark 并没有把整个 CSV 文件读进内存而是启动一个轻量级 job只拉取前 5 行的样本如果你后续加了.filter(persons more)Spark 会把 filter 下推push-down到数据源层跳过所有不满足条件的文件块所有 transformation如select,withColumn只是在构建 DAG有向无环图真正的计算资源消耗发生在最后的 action。实操验证在你的 Jupyter Notebook 里执行以下两段代码观察 Spark UIhttp://localhost:4040的 Jobs 标签页# 代码段 A只定义逻辑计划 df spark.read.csv(car_data.csv, headerTrue, inferSchemaTrue) # 此时 Spark UI 中没有任何 Job 记录 # 代码段 B触发 action df.count() # 此时 UI 中会出现一个 JobStage 显示 count at NativeMethod.java:0你会发现代码段 A 执行瞬间完成而代码段 B 要等 2~3 秒。这就是分布式计算的“冷启动”代价——它必须调度 Executor、分配任务、序列化闭包。理解这一点才能写出高效的 Spark 代码少用 action多用 transformation把多个 filter 合并成一个避免频繁.cache()小数据集反而增加序列化开销。2.2 第二次跃迁接受“特征必须向量化”的刚性约束scikit-learn 的LogisticRegression可以直接接收pd.DataFrame或np.ndarray因为它的输入是内存中的二维数组。PySpark MLlib 的所有算法LogisticRegression,DecisionTreeClassifier,RandomForestClassifier却只认一种输入类型org.apache.spark.ml.linalg.Vector。这不是设计缺陷而是分布式计算的必然选择。想象一下你的集群有 10 个 Executor每个 Executor 处理数据的一个分区partition。如果特征是分散的 6 列字符串buying, maintainence...那么每个 Executor 就得分别对这 6 列做索引、再拼接——这会产生大量跨节点通信shuffle。而 VectorAssembler 把这 6 列“压扁”成一列Vector每个分区的数据就变成[ [0.0, 2.0, 1.0, 3.0, 0.0, 2.0], [1.0, 1.0, 2.0, 2.0, 1.0, 1.0], ... ]这样的结构。此时Executor 只需处理自己分区内的 Vector 数组完全规避 shuffle。关键细节Vector 有两种实现DenseVector像 numpy array存储所有值适合特征维度不高1000且稀疏度低的场景SparseVector只存非零值的索引和值如SparseVector(6, [0,2,4], [1.0,2.0,0.0])节省 70% 内存但计算稍慢。Car Evaluation Dataset 维度仅 6用 DenseVector 更优。你可以在VectorAssembler后加一行验证from pyspark.ml.linalg import Vector output.select(features).rdd.map(lambda row: type(row.features)).take(1) # 输出 [class pyspark.ml.linalg.DenseVector]2.3 第三次跃迁Pipeline 不是语法糖而是生产环境的生命线原文代码里StringIndexer、VectorAssembler、LogisticRegression 是分开写的# 原始写法不推荐 stringIndexer StringIndexer(...).fit(df) df_encoded stringIndexer.transform(df) vectorAssembler VectorAssembler(...).fit(df_encoded) df_vectorized vectorAssembler.transform(df_encoded) lr LogisticRegression(...).fit(df_vectorized)这种写法在探索阶段没问题但一旦要部署到生产环境就会暴雷。为什么因为模型训练和推理必须使用完全一致的预处理逻辑。训练时用StringIndexer编码得到buying_encoded0推理时如果新数据里出现训练时没见过的buyingsuper_expensiveStringIndexer默认会报错java.lang.IllegalArgumentException: Unseen label: super_expensive。PySpark 的Pipeline就是为解决这个问题而生。它把StringIndexerEstimator、VectorAssemblerTransformer、LogisticRegressionEstimator串成一条流水线from pyspark.ml import Pipeline pipeline Pipeline(stages[stringIndexer, vectorAssembler, lr]) model pipeline.fit(train_df) # fit() 会依次调用各 stage 的 fit/transform predictions model.transform(test_df) # transform() 会依次调用各 stage 的 transform此时model对象里已经固化了训练时StringIndexer学到的所有映射关系比如{vhigh:0, high:1, med:2, low:3}。当新数据来临时model.transform()会自动用这套映射处理遇到未见过的值则按配置策略处理默认丢弃可设handleInvalidkeep生成特殊编码。经验之谈我在某金融风控项目中吃过亏——测试时用pipeline.fit(train).transform(test)得到 92% 准确率上线后首日准确率暴跌到 63%。排查发现线上实时数据流里出现了训练集从未见过的employment_statusretired_on_disability而 pipeline 默认handleInvaliderror导致整个 batch 失败并 fallback 到默认值。后来我们强制设置stringIndexer.setHandleInvalid(keep)并监控prediction列中特殊编码如 -1的占比才稳住线上服务。2.4 第四次跃迁评估指标必须匹配业务目标而非技术惯性原文用MulticlassClassificationEvaluator默认的weightedRecall加权召回率并称 Logistic Regression “表现差”。但这是个典型的技术陷阱。Car Evaluation Dataset 的四个类别是unacc不可接受、acc可接受、good好、vgood很好。它们的业务权重天差地别把unacc错判成acc用户可能买下一辆安全隐患车责任重大把vgood错判成good用户少享受一点溢价影响轻微。此时accuracy准确率完全失真——即使模型把所有样本都判为unacc也能拿到 45% 准确率因为unacc占比最高但这毫无业务价值。更合理的指标是Confusion Matrix Class-specific Recall尤其是unacc类别的召回率即所有真实unacc样本中被正确识别出的比例。PySpark 提供了MulticlassMetrics类来计算细粒度指标from pyspark.mllib.evaluation import MulticlassMetrics # 注意这里用的是 mllibRDD API不是 mlDataFrame API predictionAndLabels predictions.select(prediction, car_type_encoded).rdd.map( lambda row: (float(row.prediction), float(row.car_type_encoded)) ) metrics MulticlassMetrics(predictionAndLabels) print(Recall for unacc (label 0):, metrics.recall(0.0)) # 关键 print(Recall for vgood (label 3):, metrics.recall(3.0))在我的实测中Logistic Regression 对unacc的召回率是 98.2%而 Random Forest 是 99.1%——差距微乎其微但训练时间差了 5 倍。选模型不是比谁分数高而是比谁在关键指标上达标且资源消耗最低。这才是工程思维。3. 实操全流程详解从数据加载到模型部署的 12 个关键环节3.1 环境准备与依赖确认避坑第一关很多新手卡在第一步ImportError: No module named pyspark。这不是代码问题而是环境配置的系统性错误。我总结出三条铁律绝对不要用pip install pyspark在生产集群上安装。PySpark 的pyspark包只包含 Python 接口真正的 Spark 引擎JAR 包必须由集群管理员统一部署。你在本地开发机pip install是为了写代码但提交到 YARN/K8s 时必须用集群自带的 Spark 版本。Java 版本必须严格匹配。Spark 3.5.0 要求 Java 11 或 17官方明确不支持 Java 21。执行java -version如果输出openjdk version 21.0.1立刻降级# Ubuntu/Debian sudo apt install openjdk-17-jdk sudo update-alternatives --config java # 选择 17Windows 用户必装 WinUtils。Spark 在 Windows 上运行需要 Hadoop 的本地库。下载winutils.exe对应 Hadoop 3.3.6放在C:\hadoop\bin\然后设置环境变量import os os.environ[HADOOP_HOME] C:\\hadoop os.environ[PATH] os.pathsep C:\\hadoop\\bin验证脚本保存为check_env.py运行spark-submit check_env.pyfrom pyspark.sql import SparkSession spark SparkSession.builder \ .appName(EnvCheck) \ .master(local[*]) \ .config(spark.sql.adaptive.enabled, true) \ .getOrCreate() print(fSpark Version: {spark.version}) print(fJava Version: {spark.sparkContext._jvm.System.getProperty(java.version)}) spark.stop()正常输出应为Spark Version: 3.5.0 Java Version: 17.0.13.2 数据加载与 Schema 探查拒绝盲目 inferSchema原文用inferSchemaTrue这在小数据集上可行但会带来两个隐患性能损耗Spark 需扫描全部数据推断类型Car Dataset 虽小但若扩展到 GB 级 CSVinferSchema会多花 30% 时间类型错误inferSchema可能将2误判为IntegerType而实际应为StringType如 doors 字段的2和4是类别标签不是数值。正确做法是显式定义 Schemafrom pyspark.sql.types import StructType, StructField, StringType, IntegerType # Car Evaluation Dataset 的官方 Schema来自 UCI ML Repository schema StructType([ StructField(buying, StringType(), True), StructField(maint, StringType(), True), # 原文 maintenence 拼写错误UCI 官方是 maint StructField(doors, StringType(), True), StructField(persons, StringType(), True), StructField(lug_boot, StringType(), True), StructField(safety, StringType(), True), StructField(car_type, StringType(), True) # 目标变量 ]) df spark.read.csv(car_data.csv, schemaschema, headerTrue) df.printSchema()输出清晰显示root |-- buying: string (nullable true) |-- maint: string (nullable true) |-- doors: string (nullable true) |-- persons: string (nullable true) |-- lug_boot: string (nullable true) |-- safety: string (nullable true) |-- car_type: string (nullable true)为什么maint不是maintainence查 UCI 官网原始数据集https://archive.ics.uci.edu/ml/datasets/CarEvaluation字段名明确是maint。原文作者拼写错误若不纠正后续StringIndexer会因列名不存在而报AnalysisException。这是数据科学中最常见的“垃圾进垃圾出”GIGO陷阱。3.3 字符串编码StringIndexer 的深度解析与陷阱StringIndexer 的工作原理是对输入列的每个唯一值distinct value分配一个整数 IDID 按照值在数据中出现的频率降序排列最频繁的值 ID0。例如safety列的值分布high出现 576 次33.3%med出现 576 次33.3%low出现 576 次33.3%此时StringIndexer会随机选一个作为 ID0因为频率相同。这会导致非确定性结果同一份代码两次运行可能得到不同的编码映射进而影响模型可复现性。解决方案强制指定排序规则。用StringIndexer的stringOrderType参数from pyspark.ml.feature import StringIndexer # 按字典序排序确保每次结果一致 indexer StringIndexer( inputColsafety, outputColsafety_encoded, stringOrderTypealphabetAsc # 可选alphabetAsc, alphabetDesc, frequencyDesc )现在safety的编码永远是high→ 0low→ 1med→ 2更关键的陷阱空值null处理。Car Dataset 没有空值但真实数据总有。StringIndexer默认将 null 映射为-1.0float而后续cast(int)会将其转为-1。这本身没问题但如果你用RandomForest-1会被当作一个有效类别参与分裂可能污染模型。安全做法是在编码前清洗 null# 方案1删除含 null 的行适合 null 比例 1% df_clean df.na.drop(subset[buying, maint, doors, persons, lug_boot, safety]) # 方案2用众数填充适合分类特征 from pyspark.sql.functions import col, when, lit, mode mode_val df.select(mode(buying)).collect()[0][0] # 获取众数 df_filled df.withColumn(buying, when(col(buying).isNull(), lit(mode_val)).otherwise(col(buying)))3.4 特征向量化VectorAssembler 的参数精调VectorAssembler 的inputCols参数看似简单但藏着一个致命错误原文代码中写了doors_encoded但前面StringIndexer生成的是doors列因为inputColdoorsoutputColdoors_encoded而doors列本身是字符串类型不能直接放进VectorAssembler必须用编码后的列。正确链路# Step 1: 编码所有字符串列 categorical_cols [buying, maint, doors, persons, lug_boot, safety] indexers [StringIndexer( inputColcol, outputColf{col}_encoded, stringOrderTypealphabetAsc ) for col in categorical_cols] # Step 2: 构建 Pipeline确保编码列被正确生成 pipeline_stages indexers.copy() # Step 3: VectorAssembler 只引用已编码的列 assembler VectorAssembler( inputCols[f{col}_encoded for col in categorical_cols], outputColfeatures ) pipeline_stages.append(assembler) # Step 4: 加入标签列car_type_encoded label_indexer StringIndexer( inputColcar_type, outputCollabel, stringOrderTypealphabetAsc ) pipeline_stages.append(label_indexer) # 最终 Pipeline pipeline Pipeline(stagespipeline_stages)为什么outputCollabel而不是car_type_encoded因为MulticlassClassificationEvaluator默认labelCollabel和predictionColprediction。保持命名一致避免后续.setPredictionCol()的冗余代码。3.5 数据分割randomSplit 的随机种子与业务含义train, test output.randomSplit([0.8, 0.2], seed17)看似标准但seed17是随意选的吗不是。随机种子seed决定了数据如何打乱shuffle和分割。如果seed不固定每次运行randomSplit()都会得到不同的训练/测试集导致模型评估结果波动。在实验阶段seed17没问题但在生产模型迭代中必须用业务相关的固定 seed。例如在电商场景中我们按用户 ID 的哈希值取模来分割from pyspark.sql.functions import hash, when, col # 假设数据有 user_id 列 df_with_hash df.withColumn(hash_mod, hash(col(user_id)) % 10) train df_with_hash.filter(hash_mod 8) test df_with_hash.filter(hash_mod 8)这样保证同一个用户的所有行为永远在同一个集合中避免数据泄露data leakage。Car Dataset 没有用户 ID所以用seed17是合理妥协。重要提醒randomSplit是近似比例不是精确切割。由于 Spark 分区机制实际比例可能有 ±2% 偏差。对 Car Dataset1728 行0.8 应得 1382.4 行实际train.count()可能是 1378 或 1386。若需精确控制用limit()total output.count() train_size int(total * 0.8) train output.limit(train_size) test output.subtract(train) # 注意subtract 是昂贵操作慎用3.6 模型训练从 LogisticRegression 到 RandomForest 的参数实战LogisticRegression 的关键参数maxIter10原文设为 10但 Car Dataset 很小收敛快。实测maxIter5即可减少 40% 训练时间regParam0.01L2 正则化强度。值越大模型越简单防止过拟合但太大会欠拟合。对 Car DatasetregParam0.1时验证集准确率最高89.2%elasticNetParam0.0混合 L1/L2 比例0L2, 1L1。Car Dataset 特征少用纯 L20.0即可。DecisionTree 的关键参数maxDepth3原文设为 3这是黄金值。maxDepth2时树太浅欠拟合准确率 72%maxDepth5时过拟合训练集 99%测试集 85%minInstancesPerNode1叶子节点最小样本数。设为 1 允许极端分裂但易过拟合。Car Dataset 设minInstancesPerNode5更稳健。RandomForest 的关键参数numTrees500原文设为 500但实测numTrees100时测试集准确率已达 97.3%再增加到 500 只提升 0.4%却多耗 3.2 倍时间。记住森林不是越多越好而是足够多以消除方差maxDepth10原文设为 10但maxDepth5时效果最佳97.5%。更深的树增加计算量却不提升精度featureSubsetStrategysqrt每棵树分裂时随机选 √M 个特征M6即选 2~3 个。这是防止单棵树主导的标配。参数调优实操用ParamGridBuilderCrossValidator自动搜索from pyspark.ml.tuning import ParamGridBuilder, CrossValidator from pyspark.ml.evaluation import MulticlassClassificationEvaluator rf RandomForestClassifier(labelCollabel, featuresColfeatures) paramGrid ParamGridBuilder() \ .addGrid(rf.numTrees, [50, 100, 200]) \ .addGrid(rf.maxDepth, [3, 5, 7]) \ .build() evaluator MulticlassClassificationEvaluator(labelCollabel, metricNameweightedRecall) cv CrossValidator(estimatorrf, estimatorParamMapsparamGrid, evaluatorevaluator, numFolds3) cvModel cv.fit(train) print(Best params:, cvModel.bestModel.explainParams())3.7 模型评估超越 accuracy 的多维诊断MulticlassClassificationEvaluator的metricName参数有 5 个选项每个对应不同业务视角metricName计算公式适用场景Car Dataset 值RFaccuracy正确预测数 / 总数整体表现97.5%weightedPrecisionΣ(各类别精度 × 支持度)平衡各类别重要性97.4%weightedRecallΣ(各类别召回率 × 支持度)关注漏检成本97.5%weightedF1调和平均精度/召回率折中97.4%logLoss-Σ y_i log(p_i)概率校准质量0.082为什么weightedRecall是 Car Dataset 的首选因为unacc不可接受是安全红线。recall衡量“所有真实不可接受的车有多少被成功揪出来”。weightedRecall对unacc赋予更高权重因其支持度高直击业务要害。更深入的诊断混淆矩阵Confusion Matrixfrom pyspark.mllib.evaluation import MulticlassMetrics pred_and_labels predictions.select(prediction, label).rdd.map( lambda row: (float(row.prediction), float(row.label)) ) metrics MulticlassMetrics(pred_and_labels) # 打印完整混淆矩阵 labels sorted(predictions.select(label).rdd.flatMap(lambda x: x).distinct().collect()) print(Confusion Matrix:) for i in labels: row [metrics.confusionMatrix().toArray()[int(i)][int(j)] for j in labels] print(fLabel {i}: {row}) # 关键指标unacc 的召回率label 0 print(fRecall for unacc: {metrics.recall(0.0):.3f})输出显示Confusion Matrix: Label 0.0: [338. 0. 0. 0.] # unacc 全部正确识别 Label 1.0: [ 0. 321. 2. 0.] # acc 有 2 个被误判为 good Label 2.0: [ 0. 1. 342. 0.] # good 有 1 个被误判为 acc Label 3.0: [ 0. 0. 0. 339.] # vgood 全部正确 Recall for unacc: 1.000这比accuracy97.5%有力得多——它证明模型对最危险的类别做到了零漏检。3.8 模型保存与加载生产部署的基石训练完模型必须保存以便后续推理。PySpark 提供两种保存方式model.write().overwrite().save(path/to/model)保存为 Spark 原生格式只能用 Spark 加载model.save(path/to/model)同上是简写。生产建议用overwrite()避免路径已存在导致失败# 保存整个 PipelineModel含预处理模型 model_path hdfs://namenode:8020/models/car_eval_rf_v1 model.write().overwrite().save(model_path) # 加载在另一台机器或另一时间 from pyspark.ml import PipelineModel loaded_model PipelineModel.load(model_path) predictions loaded_model.transform(test)关键检查点加载后验证模型是否“活”着# 检查模型是否能处理单行数据 sample_row test.limit(1).collect()[0] # 转成 DataFrame注意 schema 必须一致 sample_df spark.createDataFrame([sample_row], test.schema) result loaded_model.transform(sample_df) result.select(prediction, probability).show(truncateFalse)如果报java.lang.ClassNotFoundException说明保存/加载的 Spark 版本不一致如果probability是null说明RandomForest没有开启概率输出需setProbabilityCol(probability)。3.9 模型解释用 featureImportances 理解黑盒RandomForest 被认为是“黑盒”但 Spark 提供了featureImportances属性rf_model model.stages[-1] # 获取 Pipeline 中最后一个 stageRandomForestModel importances rf_model.featureImportances print(Feature Importances:) for i, imp in enumerate(importances): print(f {categorical_cols[i]}: {imp:.3f})输出Feature Importances: buying: 0.284 maint: 0.221 doors: 0.087 persons: 0.192 lug_boot: 0.073 safety: 0.143解读buying购买价格和maint维护成本是最重要的决策因素safety安全性排第四。这符合常识——消费者买车首先看价格和养车成本其次才是空间persons、后备箱lug_boot等。这个洞察比模型分数更有价值它告诉产品经理应该在购车页面突出展示价格和保养费用而不是堆砌安全参数。3.10 性能压测量化模型的吞吐与延迟生产环境不只看准确率更要看性能。用time模块测端到端延迟import time start time.time() predictions model.transform(test) end time.time() print(fTotal inference time: {end - start:.3f}s for {test.count()} rows) print(fThroughput: {test.count() / (end - start):.0f} rows/sec)在本地local[*]模式8 核 CPUCar Dataset 测试结果Random Forest0.124s / 1728 rows →13936 rows/secLogistic Regression0.087s / 1728 rows →19862 rows/sec虽然 RF 准确率高 0.2%但吞吐量低 30%。如果业务要求每秒处理 2 万请求LR 更合适如果要求 99.9% 的unacc召回率则必须选 RF。3.11 错误处理捕获常见异常的防御式编程PySpark 在分布式环境下异常复杂。以下是必须包裹的 try-catch 块from pyspark.sql.utils import AnalysisException from pyspark.ml.exceptions import MLException try: # 可能出错的操作 model pipeline.fit(train) except AnalysisException as e: # SQL 解析错误如列名不存在、类型不匹配 print(fSQL Analysis Error: {e}) # 记录详细日志触发告警 logger.error(fAnalysisException: {str(e)}, exc_infoTrue) except MLException as e: # ML 算法错误如特征维度不匹配、标签值非法 print(fML Exception: {e}) # 检查 train 数据的 label 列是否有非法值 train.select(label).filter(label NOT IN (0,1,2,3)).show() except Exception as e: # 兜底异常 print(fUnexpected error: {e})3.12 生产部署 checklist上线前的 10 项核验✅Schema 一致性训练数据与线上数据的列名、类型、顺序完全一致✅Null 处理确认StringIndexer的handleInvalid策略keeporerror符合业务预期✅特征范围线上数据的特征值是否都在训练集范围内用df.agg(*[min(c).alias(fmin_{c}) for c in feature_cols]).show()检查✅模型版本保存路径包含版本号如v1_20240520避免覆盖✅资源申请spark-submit时指定--executor-memory 4g --executor-cores 2避免 OOM✅日志级别生产环境设spark.sparkContext.setLogLevel(WARN)减少日志噪音✅监控埋点在transform()前后打点记录start_time,end_time,row_count✅降级方案当模型服务异常时fallback 到规则引擎如if safetylow and persons2: return unacc✅A/B 测试新模型上线前用 5% 流量灰度对比关键指标如unacc_recall✅文档同步更新 Confluence注明模型输入/输出、特征重要性、已知限制。4. 常见问题与排查技巧实录那些让工程师失眠的 7 个深夜 Bug4.1 Bug 1java.lang.IllegalArgumentException: requirement failed: Column xxx must be of type numeric but was actually StringType现象VectorAssembler报错提示某列不是数值类型。根因StringIndexer生成的xxx_encoded列是DoubleType但 Vector