ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

xgboost导出PMML时缺失值处理丢失问题及解决方案

xgboost导出PMML时缺失值处理丢失问题及解决方案 如果你正打算把xgboost模型用sklearn2pmml导出成PMML部署我猜你迟早会踩进这个坑里。我自己是在一次风控模型上线时被它坑惨的Python里测试好好的ACU稳定各种指标都漂亮结果推到Java环境里一验证凡是带着NaN特征上来的样本预测结果和本地完全对不上。最开始我还以为是JAVA加载PMML的问题排查了半天最后定位到根因——xgboost的missing值处理在sklearn2pmml转换时被“偷偷丢掉”了。这篇文章就是把那次踩坑经过、原理分析、以及我后来怎么解决的全部记录一下给正在做模型部署、导出PMML的朋友们提个醒。适合谁来读只要你想把xgboost模型用sklearn2pmml转成PMML或者你在上线后遇到过“单条样本预测不一致”的问题这篇都应该能帮你省下至少半天排查时间。1. 先搞清楚XGBoost的missing是怎么工作的1.1 缺失值不是“当0”也不是“当均值”用过sklearn里大多数模型的同学早就默认了“模型不能吃NaN必须先填充”。逻辑回归、SVM、随机森林基本都是这个路数。但xgboost不一样它是原生支持缺失值的训练的整个过程中缺失值不需要提前填充。xgboost对缺失值的处理方式很特别在训练每一个分裂节点的时候算法会尝试把当前节点上所有缺失样本全部临时分到左子节点然后算一次分裂增益再全部临时分到右子节点再算一次增益。哪个方向让目标函数增益大就选择哪个方向作为“缺省方向”。这个方向不是全局统一的而是每个节点各自独立学出来的。所以你看xgboost不是说“这个特征缺失了就填0”或者“填充均值”它其实是把“特征是否缺失”作为一个隐含的分支信息参与训练。换句话说模型在建树过程中学到的是当某个特征看不到的时候样本大概率该往左走还是往右走。我经常用一个类比来解释这件事很多学校考试缺考的学生成绩按0分处理这是“全局填充”。xgboost的做法更像“每道题都有一次补考机会如果考生这题没答老师会根据其他题目的表现猜他如果答了这道题更可能选A还是选B”。注意每道题每个节点的老师判断可能都不一样。1.2 这个机制对模型输出意味着什么因为每个分裂节点都有自己的一套“缺失时往哪走”的逻辑一个带有缺失特征值的样本在预测时沿着树往下走路径上每个节点都可能会触发这个“缺省方向”。最终到达哪个叶子节点实际上是由整条路径上一连串节点的选择共同决定的。这就带来了一个很关键的问题如果你在训练后用一个简单策略比如填充0去复现这个模型你复现出来的大概率不是同一个模型。因为填充0之后的特征值是真实的0而xgboost在训练时脑子里想的可能是“这个人没填收入收入视为未知根据其他特征猜他收入不高所以往左走”。填充0会把“未知”伪装成“已知”完全破坏了原模型的语义。在纯Python环境里你只要训练时指定missingnp.nan预测的时候喂给它含NaN的数据xgboost自己会按照训练时学到的缺省方向走结果没问题。问题在于一旦你要把模型导出成PMML这个“缺省方向”能不能被完整地翻译过去就是另外一回事了。2. sklearn2pmml转换missing信息时到底丢在哪2.1 PMML规范对缺失值的三层约定PMML里面处理缺失值其实有非常明确的三层设计。第一层是DataDictionary定义字段类型以及该字段在原始数据里用什么值代表“缺失”比如DataField nameage optypecontinuous dataTypedouble/。第二层是MiningFieldMiningField里有两个关键属性missingValueReplacement和missingValueTreatment。前者指定当特征缺失时是否用一个固定值去替代后者指定替代策略比如as_is不替代、as_mean用均值、as_median用中位数等。第三层才是树模型节点自己的条件判断。树模型节点的分裂条件一般用SimplePredicate或CompoundPredicate表示比如SimplePredicate fieldage operatorlessThan value30/。对于缺失值来说PMML标准也支持operatorisMissing或operatorisNotMissing这样的判断。也就是说PMML其实有能力表达“当此特征缺失时走某个分支”的逻辑。听起来似乎很完美但这里有一个重要前提生成PMML的转换器必须真的把xgboost内部每棵树的“缺省方向”翻译成这些谓词。如果转换器偷懒没有把isMissing分支写出来那PMML在遇到NaN时会按照自己的默认规则处理而这个默认规则正好和xgboost的设计南辕北辙。2.2 sklearn2pmml实际生成的PMML缺失了哪一层我用过好几个版本的sklearn2pmml也翻过多次生成的PMML文件最常见的问题在第二层和第三层。第二层MiningField的问题是很多特征根本没有配置missingValueReplacement。有些时候即使你在训练前用SimpleImputer填充过缺失值如果那个填充器没有被正确放入PMML pipeline里最终生成的PMML里依然没有缺失值兜底策略。结果就是线上Java引擎一旦遇到NaN要么直接报错要么返回一个无效值。第三层的问题更隐蔽。如果你直接用.fit(X_train, y_train)训练了一个带NaN的xgboost模型然后丢给sklearn2pmml转换转换器虽然不会报错但它生成的树节点条件里经常只有lessThan、greaterOrEqual这种普通比较没有isMissing谓词。这意味着什么意味着PMML里的决策树根本不知道“特征缺失时该走哪条路”。当线上样本某个特征值是NaN时所有普通比较都返回unknown最终样本会被推到一个PMML自己的默认叶子节点上。这个默认叶子节点完全是由PMML评估器实现决定的和xgboost模型训练时学到的“缺省方向”没有半毛钱关系。我后来做过一个统计同一个样本只缺失一个特征Python端xgboost预测概率是0.83PMML端输出却是0.21。检查生成的PMML文件后发现那个特征对应的树节点完全没有isMissing缺失样本全程走的是“普通比较都不成立”的else路径自然偏到不知道什么地方去了。2.3 为什么会丢失转换链路里的暗坑这事说到底不完全是sklearn2pmpl的锅它只是Python端的一个封装底层真正干活的其实是JPMML-SkLearn和JPMML-XGBoost这一套Java库。转换器需要解析xgboost训练出的模型结构再按照PMML schema输出XML。问题在于不同版本的JPMML-XGBoost对xgboost内部missing参数的支持程度不一样。早期版本在转换时默认行为是“把缺失值当成普通数据”。也就是说如果训练数据里特征的某个取值是NaN转换器有可能直接把这个NaN当作一个普通的字符串或者浮点数来处理或者干脆忽略了NaN存在的情况。后期版本虽然开始支持isMissing分支但对missingnp.nan和missingNone的处理方式又不一样。所以这些年我形成一个习惯每次转换完成第一件事不是急着部署而是打开PMML文件用grep看一下有没有isMissing关键字。没有的话心里要有数这个模型上线纯属赌博。3. 实战复现一个含缺失的二分类模型从训练到导出全流程3.1 构造一个带缺失的模拟数据集为了方便说明我构造一个简单的二分类数据集。两个特征x1和x2x1有10%的缺失x2有20%的缺失标签y和两个特征线性相关。这个场景很接近真实业务里的用户画像特征不是纯随机缺失多少带点可学习性。import numpy as np import pandas as pd from xgboost import XGBClassifier from sklearn2pmml import PMMLPipeline, sklearn2pmml rng np.random.RandomState(42) n 2000 x1 rng.randn(n) 5 x2 rng.randn(n) * 2 x1[rng.rand(n) 0.10] np.nan x2[rng.rand(n) 0.20] np.nan y (x1 * 0.5 x2 * 0.3 rng.randn(n) * 0.1 2).astype(int) X pd.DataFrame({x1: x1, x2: x2}) print(x1缺失比例:, X[x1].isna().mean()) print(x2缺失比例:, X[x2].isna().mean())这里我用missingnp.nan来训练xgboost这是xgboost sklearn接口里最常用的写法。注意如果不写missing参数新版xgboost默认也是np.nan但为了明确语义我建议每次都显式写出来。model XGBClassifier( n_estimators50, max_depth4, learning_rate0.1, missingnp.nan, random_state42 ) model.fit(X, y)训练完成之后我取第一个样本看预测概率。这个样本的x2是一个缺失值Python端预测逻辑是有意义的因为xgboost内部会走缺省方向。sample X.iloc[[5]].copy() print(样本内容:) print(sample) print(Python预测概率:, model.predict_proba(sample)[0, 1])运行结果里你会看到类似x15.23, x2NaNPython预测概率为0.84。记住这个数字一会儿PMML的预测结果要和它对比。3.2 用sklearn2pmml导出PMML导出PMML的流程很简单重点在于pipeline的组织。最直接的方式是把训练好的模型放进PMMLPipeline。pipeline PMMLPipeline([ (classifier, model) ]) pipeline.fit(X, y) sklearn2pmml(pipeline, xgb_missing_demo.pmml)注意这里如果pipeline里只有分类器sklearn2pmml在转换时会尽量保留xgboost模型本身的结构。真正复杂一点的项目通常会加一个DataFrameMapper做特征映射和缺失值填充预处理但为了还原“缺失值直接进模型”的场景我这里不做任何填充。转换完成后我习惯先检查PMML文件里的关键信息grep -o operator[^]* xgb_missing_demo.pmml | sort | uniq -c输出大概率是这样的34 operatorlessThan 25 operatorgreaterOrEqual数了一下一个isMissing都没有。这就是问题的铁证。说明树节点层面根本没有缺失分支。再看DataDictionary和MiningField部分通常也是干干净净没有missingValue属性。这意味着PMML在定义层面缺失值没有被任何策略接管。3.3 用Java端PMML引擎预测同一批样本PMML的常用评估库是jpmml-evaluator你可以用Java写一个简单调用也可以通过Python的py4j桥接调用。我这里简化一下流程假设已经写好了PmmlPredictor这个Java类从命令行接收PMML文件和输入参数输出预测概率。对同样的那个样本x15.23, x2NaN进行预测结果往往是0.21和Python端的0.84差距巨大。把线上可能遇到的情况做成一个表格看起来更清楚样本x1x2Python概率PMML概率是否一致不含缺失5.232.010.720.72一致x2缺失5.23NaN0.840.21不一致x1缺失NaN2.010.660.43不一致两者都缺失NaNNaN0.580.12不一致不含缺失时一致含缺失时不一致这说明模型结构本身没有转换出错出错的环节就是缺失值处理。3.4 检查PMML文件定位问题根源用文本编辑器打开PMML拉到一个树节点附近你会看到类似这样的结构Node id1 score0.65 True/ Node id2 score0.82 SimplePredicate fieldx1 operatorlessThan value6.3/ Node id3 score0.88 SimplePredicate fieldx2 operatorlessThan value1.8/ ... /Node /Node /Node这里没有任何isMissing判断。xgboost训练时学到的“x2缺失时往左走”的信息根本没有体现出来。当线上样本的x2是NaN时PMML评估器会认为当前节点条件判断结果既不为真也不为假然后按照PMML的规范落入某种“无效”或“默认”路径。最终预测结果自然就偏了。4. 四种靠谱的解决方案与取舍4.1 方案一训练前填充缺失值最推荐既然xgboost的missing分支在转换时容易丢失最简单粗暴的办法就是训练之前就不要留缺失。不要误会我不是说xgboost不能处理缺失而是说在部署链路没有充分验证之前强行保留缺失值是在给自己埋雷。具体做法是给pipeline加一个SimpleImputer放在DataFrameMapper里from sklearn.impute import SimpleImputer from sklearn_pandas import DataFrameMapper mapper DataFrameMapper([ ([x1], [SimpleImputer(strategymedian)]), ([x2], [SimpleImputer(strategymedian)]) ]) pipeline PMMLPipeline([ (mapper, mapper), (classifier, XGBClassifier(n_estimators50, max_depth4, learning_rate0.1, random_state42)) ]) pipeline.fit(X, y) sklearn2pmml(pipeline, xgb_missing_filled.pmml)这样转换出来的PMML里MiningField会自动带上missingValueReplacement和对应的missingValueTreatment。线上遇到NaN时Java引擎会先按照训练集统计出的中位数填充然后送入树模型做判断。因为训练时用的也是同样的填充方案预测结果自然和Python端完全一致。这个方案的优点是稳定、可复现缺点也很明显你放弃了xgboost内置的缺失值学习能力。如果业务场景中缺失模式本身就很有信息量比如“收入字段缺失的用户往往收入极低”那纯填充方案会丢掉这层信息。4.2 方案二用“缺失指示器 填充值”组合特征如果想保留缺失模式的信息又不想在PMML里赌isMissing建议把缺失意识显式做成特征。具体做法是对每个可能缺失的特征新增一个0/1的is_missing特征原特征用0或中位数填充。def add_missing_indicator(df, features): df df.copy() for f in features: df[f _is_missing] df[f].isna().astype(int) df[f] df[f].fillna(0) return df X_expanded add_missing_indicator(X, [x1, x2]) print(X_expanded.head())然后训练时填入这些指示器特征。模型可以学到“x2缺失”这种模式对标签的影响而真正进入树模型的特征已经不存在NaNPMML转换时也就不存在缺失分支丢失的问题。这个方案在金融风控场景尤其好用。很多特征缺失本身就意味着“用户没有填这个信息”或者“数据源没有返回这个字段”属于强信号。把它变成显式特征模型信息量更大部署也更安全。4.3 方案三升级到支持原生missing的转换链如果你有明确理由必须保留xgboost原生的missing处理也不一定完全没救。关键在于确认你用的sklearn2pmml和底层JPMML-XGBoost版本是否支持isMissing分支导出。我在新版环境sklearn2pmml 0.98jpmml-xgboost 1.6下重新做过一次转换导出的PMML里能搜到isMissing谓词树结构变成了这样Node id2 score0.82 SimplePredicate fieldx2 operatorisMissing/ Node id3 score0.88 ... /Node /Node这种情况下PMML的Java端遇到NaN时会识别到该特征缺失然后走进对应分支。预测结果和Python端就能对齐。但升级不是没有代价。第一PMML文件会明显变大因为每棵树的每个节点都可能多一个缺失分支第二.pmml的加载和推理耗时也会增加第三如果你用的xgboost版本太新或太老JPMML-XGBoost可能不兼容。所以这个方案适合那些确实需要保留缺失语义、且有专人维护部署链路的团队。4.4 方案四应急时手动修改PMML如果模型已经训练好PMML已经生成又来不及重新训练临时救急的办法是手动修改PMML文件。这属于下策操作起来比较费劲而且容易出错。最简单的做法是在DataDictionary对应字段上增加missingValueNaN在MiningField上增加missingValueReplacement0。这样Java引擎遇到NaN时会先填充为0再走树模型判断。这至少能保证不再报错但预测结果依然不一定和Python端一致因为xgboost原来的缺省方向不是用0填充能复现的。如果要完全复现缺省方向就必须为每个树节点手动添加isMissing谓词。这种操作我一般交给脚本做从xgboost原模型里解析出每个节点的missing方向再自动生成对应的PMML节点相当于自己写了一个简易转换器。工作量不小不建议手工维护。只有在极端情况比如当天就要上线来不及重训我才会用这个方案。5. 常见问题排查速查现象可能原因解决方案PMML文件里搜不到isMissing转换器版本不支持缺失分支导出升级sklearn2pmml/jpmml-xgboost或者改训练前填充转换时直接报错“cannot handle missing”xgboost版本和JPMML-XGBoost不兼容确认版本兼容性改用填充方案线上PMML预测返回null或异常MiningField没配missingValueReplacement在PMML里补充缺失值策略含缺失样本预测结果和Python不一致树节点缺失分支丢失走了默认路径训练前填充或增加缺失指示器转换后的PMML文件突然变得超级大树节点里多了大量isMissing分支模型剪枝、减小max_depth、减少树的数量训练时用的不是np.nan是-999占位符xgboost会把-999当真实值而不是缺失统一缺失表示避免使用业务占位符代替NaN另外再补充两个容易踩的细节。第一个是关于missing参数的版本差异。xgboost老版本里默认missingNone如果你在训练时没有主动填missingnp.nan转换器可能根本不会认为数据里有缺失值。新版本xgboost把默认缺失值改成了np.nan但这是有前提的你的特征必须是float类型。如果特征是object类型即使里面有None或NaNxgboost可能也会折腾出奇怪的行为。第二个细节是业务占位符问题。很多实时特征服务为了绕过NaN习惯用-999或-1表示缺失。如果你在训练数据里也把这些值填成-999然后喂给xgboost那么模型会真的认为-999是真实特征取值。可到了部署阶段如果某个特征正常取值恰好是-1线上和线下就会产生明显的语义错位。我的建议是训练阶段统一用NaN表示缺失让xgboost的缺失逻辑生效如果最终决定用填充方案也最好在pipeline里清晰表达填充规则而不是散落在各个脚本里。最后再分享一点个人经验我后来给自己定了一条规矩任何xgboost模型导出PMML之前必须跑一遍“missing一致性测试”。专门构造一批包含NaN的样本同时用Python的predict_proba去跑原模型再用jpmml-evaluator跑导出的PMML两边输出差异超过1e-6就不允许上线。别嫌麻烦这十分钟的验证能省下后面几十个小时的线上排查时间。另外如果你正在设计一个新的模型训练流程我的建议是先在训练阶段就把缺失策略定清楚要么接受xgboost的缺失机制并且有办法验证PMML能完整转换要么干脆在训练前用可解释的方式填充再配合缺失指示器把缺失信息还给模型。不要两头摇摆最后训练和部署用的是两套逻辑上线之后就是灾难。最后再分享一个小技巧转换PMML之后用grep -o operator[^]* model.pmml | sort | uniq -c快速统计一下谓词类型。看到有isMissing基本可以放心一半看到只有lessThan和greaterOrEqual那就赶紧回头检查你的数据链路。这个习惯救了我好几次希望你也能用上。
返回列表