将含XGBoost的VotingClassifier转ONNX时特征名错误的解决方法
解决VotingClassifier(含XGBoost)转ONNX时的特征名格式错误问题
问题场景
加载.sav格式的VotingClassifier模型(由XGBoost和NaiveBayes混合组成),目标转换为ONNX格式,但无数据集无法重训,转换时触发以下错误:
RuntimeError: Unable to interpret 'ABC', feature names should follow pattern 'f%d'.
手动修改model.feature_names_in_为['f0', 'f1', ..., 'f11']后错误仍存在,查看XGBoost模型的JSON结构发现,树节点的split字段仍保留旧特征名(如'ABC'、'BLK'):
{'nodeid': 0, 'depth': 0, 'split': 'ABC', 'split_condition': 6.25, 'yes': 1, 'no': 2, 'missing': 1, 'gain': 78.1462402, 'cover': 259.75, 'children': [{'nodeid': 1, 'depth': 1, 'split': 'BLK', 'split_condition': 0.550000012, 'yes': 3, 'no': 4, 'missing': 3, 'gain': 21.2281971, 'cover': 171.75, 'children': [{'nodeid': 3, 'depth': 2, 'split': 'ABC', 'split_condition': 4.55000019, 'yes': 7, 'no': 8, 'missing': 7, 'gain': 14.1838226, 'cover': 147.5, 'children': [{'nodeid': 7, 'depth': 3, 'split': 'BLK', 'split_condition': 0.25, 'yes': 11, 'no': 12, 'missing': 11, 'gain': 3.78608131, 'cover': 106.5, 'children': [{'nodeid': 11, 'depth': 4, 'split': 'BLK', 'split_condition': 0.150000006, 'yes': 15, 'no': 16, 'missing': 15, 'gain': 5.14517879, 'cover': 78.5, 'children': [{'nodeid': 15, 'depth': 5, 'split': 'MIN', 'split_condition': 8.64999962, 'yes': 17, 'no': 18, 'missing': 17, 'gain': 4.04689026, 'cover': 60, 'children': [{'nodeid': 17, 'leaf': -0.018082479, 'cover': 23.25}, {'nodeid': 18, 'leaf': -0.00116446253, 'cover': 36.75}]}, {'nodeid': 16, 'leaf': -0.0269777905, 'cover': 18.5}]}, {'nodeid': 12, 'leaf': 0.000966416614, 'cover': 28}]}, {'nodeid': 8, 'depth': 3, 'split': 'MIN', 'split_condition': 16.2000008, 'yes': 13, 'no': 14, 'missing': 13, 'gain': 1.36819792, 'cover': 41, 'children': [{'nodeid': 13, 'leaf': 0.00639292412, 'cover': 19}, {'nodeid': 14, 'leaf': 0.0183946956, 'cover': 22}]}]}, {'nodeid': 4, 'leaf': 0.029015895, 'cover': 24.25}]}, {'nodeid': 2, 'depth': 1, 'split': 'MIN', 'split_condition': 23.9500008, 'yes': 5, 'no': 6, 'missing': 5, 'gain': 8.23132324, 'cover': 88, 'children': [{'nodeid': 5, 'leaf': 0.0257856827, 'cover': 34}, {'nodeid': 6, 'depth': 2, 'split': 'BLK', 'split_condition': 0.350000024, 'yes': 9, 'no': 10, 'missing': 9, 'gain': 2.95942688, 'cover': 54, 'children': [{'nodeid': 9, 'leaf': 0.0367497504, 'cover': 23}, {'nodeid': 10, 'leaf': 0.0526438914, 'cover': 31}]}]}]}
用户原转换代码:
from skl2onnx import convert_sklearn from skl2onnx.common.data_types import FloatTensorType from skl2onnx import get_latest_tested_opset_version from onnxmltools.utils import save_model import joblib from skl2onnx import convert_sklearn, to_onnx, update_registered_converter from skl2onnx.common.data_types import FloatTensorType from skl2onnx.common.shape_calculator import ( calculate_linear_classifier_output_shapes, calculate_linear_regressor_output_shapes, ) from onnxmltools.convert.xgboost.operator_converters.XGBoost import convert_xgboost from onnxmltools.convert import convert_xgboost as convert_xgboost_booster from xgboost import XGBClassifier model = joblib.load("model.sav") model = model.set_params(flatten_transform=False) features_name_fixed = ['f0', 'f1', 'f2', 'f3' ,'f4', 'f5', 'f6', 'f7', 'f8' ,'f9' ,'f10', 'f11'] # print(model.feature_names_in_) model.feature_names_in_ = features_name_fixed n_features = 12 target_opset = get_latest_tested_opset_version() update_registered_converter( XGBClassifier, "XGBoostXGBClassifier", calculate_linear_classifier_output_shapes, convert_xgboost, options={"nocl": [True, False], "zipmap": [True, False, "columns"]}, ) onnx_model = convert_sklearn(model,"gbdt_model", initial_types=[("input", FloatTensorType([None, n_features]))], target_opset={"": target_opset, "ai.onnx.ml": 1}) save_model(onnx_model, 'model_converted.onnx')
原始模型使用XGBoost 1.4.2版本训练。
解决方案
问题核心是XGBoost的booster内部树结构存储了旧特征名,仅修改feature_names_in_无法同步更新树节点的split字段。需要递归修改XGBoost模型的树结构,再同步修改特征名字段:
1. 构建旧特征名到新格式的映射
从模型中提取原始特征名,生成旧特征名→f%d的映射字典:
# 获取原始特征名 old_feature_names = model.feature_names_in_ # 生成映射字典 feature_map = {name: f'f{i}' for i, name in enumerate(old_feature_names)}
2. 递归修改XGBoost树节点的split字段
定义递归函数遍历树节点,替换split字段的旧特征名:
import json def replace_split_names(node): if 'split' in node: # 替换特征名 node['split'] = feature_map[node['split']] # 递归处理子节点 if 'children' in node: for child in node['children']: replace_split_names(child)
3. 遍历VotingClassifier中的XGBoost模型,更新booster
遍历model.estimators_,对每个XGBClassifier实例执行修改:
from xgboost import Booster for est in model.estimators_: if isinstance(est, XGBClassifier): # 获取booster的JSON结构 booster_json = json.loads(est.get_booster().get_dump(dump_format='json')[0]) # 递归替换特征名 replace_split_names(booster_json) # 将修改后的JSON重新加载为booster new_booster = Booster() new_booster.load_model_from_string(json.dumps(booster_json)) # 替换原模型的booster est._Booster = new_booster # 最后同步修改顶层模型的特征名字段 model.feature_names_in_ = list(feature_map.values())
4. 完整修改后的转换代码
将上述步骤整合到原代码中,替换原model.feature_names_in_ = features_name_fixed部分:
from skl2onnx import convert_sklearn from skl2onnx.common.data_types import FloatTensorType from skl2onnx import get_latest_tested_opset_version from onnxmltools.utils import save_model import joblib import json from skl2onnx import convert_sklearn, to_onnx, update_registered_converter from skl2onnx.common.data_types import FloatTensorType from skl2onnx.common.shape_calculator import ( calculate_linear_classifier_output_shapes, calculate_linear_regressor_output_shapes, ) from onnxmltools.convert.xgboost.operator_converters.XGBoost import convert_xgboost from onnxmltools.convert import convert_xgboost as convert_xgboost_booster from xgboost import XGBClassifier, Booster model = joblib.load("model.sav") model = model.set_params(flatten_transform=False) # -------------------- 新增特征名修改逻辑 -------------------- # 获取原始特征名并生成映射 old_feature_names = model.feature_names_in_ feature_map = {name: f'f{i}' for i, name in enumerate(old_feature_names)} def replace_split_names(node): if 'split' in node: node['split'] = feature_map[node['split']] if 'children' in node: for child in node['children']: replace_split_names(child) # 更新每个XGBoost estimator的booster for est in model.estimators_: if isinstance(est, XGBClassifier): booster_json = json.loads(est.get_booster().get_dump(dump_format='json')[0]) replace_split_names(booster_json) new_booster = Booster() new_booster.load_model_from_string(json.dumps(booster_json)) est._Booster = new_booster # 同步修改模型的特征名字段 model.feature_names_in_ = list(feature_map.values()) # -------------------- 特征名修改逻辑结束 -------------------- n_features = 12 target_opset = get_latest_tested_opset_version() update_registered_converter( XGBClassifier, "XGBoostXGBClassifier", calculate_linear_classifier_output_shapes, convert_xgboost, options={"nocl": [True, False], "zipmap": [True, False, "columns"]}, ) onnx_model = convert_sklearn(model,"gbdt_model", initial_types=[("input", FloatTensorType([None, n_features]))], target_opset={"": target_opset, "ai.onnx.ml": 1}) save_model(onnx_model, 'model_converted.onnx')
说明
- NaiveBayes模型不依赖特征名格式,无需修改,只要特征数量与输入一致即可正常转换。
- 该方法直接修改XGBoost booster的内部结构,无需重新训练模型,适合无数据集的场景。
内容的提问来源于stack exchange,提问作者dev_ds
相关产品推荐
相关产品推荐

