资讯详情

资讯详情

保险用户评估赛题解析:XGBoost多分类与QWK指标实践

简介Prudential保险用户评估模型是一套围绕Kaggle竞赛题精心打造的XGBoost分类项目重点涵盖特征工程与模型解释面向需要完成毕业设计、课程设计或竞赛实践的高校学生和数据科学爱好者既能用于快速搭建项目原型也有助于深入理解保险风控建模思路。项目源码经严格测试在多种环境下均可稳定运行并配有详细设计文档便于快速复现。压缩包共190个文件涵盖34个Python脚本、22个JS逻辑、11个JSON配置、38个图片及多个说明文档其中Python脚本承担数据预处理、特征构建与XGBoost模型训练JS与HTML构成交互式结果展示页面JSON用于保存参数包体仅2.58MB目录结构清晰。目前已有44人学习/下载。通过该项目可完整掌握从数据清洗、缺失值处理、特征构造、XGBoost参数调优到基于SHAP值的模型解释全链路方法还可直接复现竞赛提交方案并为后续扩展其他保险或金融分类任务提供可靠基础。1. 保险用户评估公开赛把一个核保问题翻译成XGBoost多分类任务保险核保里最费人力的环节就是给新申请人定风险等级。这个公开赛把核保问题简化成一个监督学习任务训练集里约6万条匿名用户档案对应一个1到8的整数风险标签要求训练模型去预测未知用户属于哪一档。真正的难点不在读取数据而在两处评估指标是QWK加权Kappa而不是准确率差1档和差5档的惩罚完全不同特征全是匿名化字段缺失率不规律只能在特征工程上要分数。这篇文章沿着“先吃透数据和指标再做特征工程然后用XGBoost建分类模型最后用SHAP解释模型”的顺序展开适合刚跑通sklearn分类、但想把一类赛题做成套路的开发者也适合在真实保险项目中需要给业务方解释模型的从业者。2. 指标与数据形态先于模型把QWK和缺失率读彻底2.1 数据长什么样匿名列名字段族的业务含义训练集和测试集是两份csv原始文件不算大加载后就是一张二维表。大概6万行训练、7.8万行待预测特征是128个匿名列带一个Id列训练集多一个Response列就是我们要预测的风险等级取值1到8。我看到这组数据的第一反应不是直接跑模型而是看列名的前缀。列名虽然是匿名化之后的编号但前缀保住了字段族含义Product_Info是产品维度Employment_Info与被保人的职业和收入相关InsuredInfo是被保人个人属性Insurance_History是历史投保情况Family_Hist是家族病史Medical_History是逐项病史还有一组Medical_Keyword开头的0/1列表示各种疾病关键词是否出现。这些前缀决定了后面特征工程的策略0/1关键词列适合做计数和交叉连续列适合看偏度和分箱而像Family_Hist这种可空列缺失本身就说明用户没填是一种信号。看目标变量分布时我习惯直接打印频数。Response不是均匀的中间档位样本多两端少。这是正常的多分类模型对少数档位天然会欠拟合后面调参和做解释时都要留意不能只看整体指标。2.2 QWK的加权逻辑为什么相邻档位不能算全错QWK的全称是Quadratic Weighted Kappa中文叫二次加权Kappa。它先构造一个8×8的混淆矩阵再给每个格子一个权重预测和真实完全一致时权重是0相差k档时权重是k²/(8-1)²。这样一来预测成相邻档只承担1/49的惩罚而预测成完全相反的档位要承担1的惩罚。这是保险业务直觉的直接算术化——核保员把风险等级估偏一档公司还能靠保费和额度调节找补估偏好几档承保决策就完全错了。公式长这样先算观察一致性比例O和期望一致性比例EE由混淆矩阵的行、列边际概率相乘得到代表“完全随机猜测”情况下的期望一致性。最后kappa 1 - Σ(W·O)/Σ(W·E)等于1是完美预测等于0是纯随机。实际编码时sklearn的cohen_kappa_score把weights参数设成quadratic就是QWK但我还是建议手写一遍一是理解后你才知道为什么模型最终输出之前要做边界修正二是当你要换线性加权、无加权等其他权重方案时手写版本改一个数组就行。2.3 加载数据与QWK的两种实现下面这段是加载和分布检查直接放在notebook开头跑。import numpy as np import pandas as pd df pd.read_csv(train.csv) print(train shape:, df.shape) print(df[Response].value_counts().sort_index())Response列是1到8的整数value_counts能立刻看清少数档位的样本量。这里要留个印象如果某些档位只有几百条那第4章做交叉验证时不能用普通的随机切分得用分层切分否则验证集里可能一个样本都分不到。QWK的实现有两种。第一种是调sklearn适合快速验证线上效果from sklearn.metrics import cohen_kappa_score # y_true和y_pred都是1~8的整数列表顺序必须一一对应 kappa cohen_kappa_score(y_true, y_pred, weightsquadratic) print(QWK:, round(kappa, 5))第二种是自己实现方便研究权重矩阵和改造成线性加权、无加权def qwk(y_true, y_pred, K8): y_true np.asarray(y_true, dtypeint) - 1 y_pred np.asarray(y_pred, dtypeint) - 1 cm np.zeros((K, K)) for t, p in zip(y_true, y_pred): cm[t, p] 1 cm cm / cm.sum() W np.zeros((K, K)) for i in range(K): for j in range(K): W[i, j] ((i - j) ** 2) / ((K - 1) ** 2) row_marg cm.sum(axis1) col_marg cm.sum(axis0) E np.outer(row_marg, col_marg) return 1.0 - (W * cm).sum() / (W * E).sum()这段代码的逻辑是先把标签和预测从1~8转到0~7方便矩阵索引然后算每个格子的占比得到观察矩阵O再按“行列边际独立”算出期望比例矩阵E。权重矩阵W用差值平方除以类别数减一的平方保证最大惩罚为1。使用时要保证两个数组长度一致并经过相同的样本顺序否则混淆矩阵会算错而不报错。K就是类别数这里是8如果你的业务变成5档、10档改这一个参数就行。2.4 缺失率的全局视角先决定保留哪些列XGBoost本身能处理缺失值但处理得“不彻底”。原因是它只在建树时把缺失样本放到增益最大的那个分支如果特征缺失率和业务含义绑定这种“放哪边增益大”的贪心决策会掩盖真实的业务逻辑。我的习惯是先统计缺失率再对三种情况分别处理缺失率超过60%且没有明显业务含义的列直接丢缺失率适中但缺失样本量大的列保留并加一个isna标志缺失率很低的列用中位数或-1填充不影响树分裂。missing df.isnull().mean().sort_values(ascendingFalse) print(missing[missing 0].head(20))跑完这行你会看到一批缺失率大于30%的列集中在某些字段族。这个时候不要急着删先看一下它们的业务前缀。如果全是Medical_History这种“逐项病史”那高缺失可能代表大部分用户在该项上没有记录缺失本身就有区分度。把这些观察记录下来后面特征工程要用。3. 特征工程把匿名列变成树模型喜欢的信号3.1 缺失标志列把“没填”变成维度处理缺失的第一原则永远先加isna列再填充原列。原因很简单填-1只能用在中位数附近填0会主动引入“这个用户该项为0”的语义而树模型对“是否缺失”这个布尔维度非常敏感。feat_cols [c for c in df.columns if c not in (Id, Response)] for col in feat_cols: if df[col].isna().sum() 0: df[col _isna] df[col].isna().astype(int) df[col] df[col].fillna(-1)这个循环会把所有缺失列都做成“原列填-1 新增_isna列”。参数上只认两件事feat_cols是你建模用的特征列表务必把Id和Response排除fillna(-1)是通用做法对树模型友好但后续如果要做归一化或分箱-1会被当成一个真实量级所以要先把isna列生成完再做其他变换。这里的一个常见误区是有人用df.isnull().sum()逐个判断效率低且容易漏列一个循环全搞定。3.2 偏度与对数变换只对全正列做log1p树模型对特征的单调变换不敏感因为分裂点只依赖排序。但高偏度特征在深树、低学习率下会反复被选择为分裂点产生不必要的深度。log变换的作用是压缩长尾让中段和头部更容易被区分。注意只对“全为正数”的列做否则log会报错或把负数变成NaN。from scipy.stats import skew log_cols [] for col in feat_cols: s df[col].dropna() if len(s) 100 and abs(skew(s)) 1.0 and (s 0).all(): log_cols.append(col) df[col] np.log1p(s) print(log transformed:, log_cols[:10])skew绝对值大于1是我常用的阈值表示分布明显偏斜。len(s) 100是经验设定样本太少算出的偏度没有意义这个阈值你可以按自己的数据调节比如500。log1p是ln(x1)保证输入为0时输出也是0这比log(x)更稳。实际使用中你会发现偏度最高的几列做完log后树的深度能降1到2层验证集QWK有小幅提升。3.3 类别型匿名特征为什么用LabelEncoder而不是OneHot赛题里有一批列取值是离散标签比如Product_Info_2、Employment_Info_4这种取值范围就几个到几十个。新手习惯性OneHot但在这里你试完就知道OneHot会产生大量稀疏列树模型每次分裂只能挑其中一个0/1列相当于把本可以一次完成的“多路分叉”拆成很多次判断训练变慢还容易过拟合。from sklearn.preprocessing import LabelEncoder cat_cols [col for col in feat_cols if df[col].dtype object] le LabelEncoder() for col in cat_cols: df[col] df[col].fillna(NaN).astype(str) df[col] le.fit_transform(df[col])这个循环把object类型的列统一转成整数编码。注意三点fillna(NaN)必须做否则NaN会被编码成一个独立类别但后续预测时测试集可能没有这个类别导致报错LabelEncoder在树模型里是安全的因为树分裂是对数值阈值进行比较多次分裂可以等效还原无序类别的关系你并不需要担心“整数编码引入错误顺序”xgb在处理类别型特征时本质上就是这么做的。对于类别数很多的列我还会用“目标编码”替代LabelEncoder但那需要折内统计容易过拟合先不用。3.4 组合特征与分箱字段族交叉才是提分主力匿名特征单独看都缺乏业务直译能力但字段族之间的交叉会产生信息。这个赛题最常用也最可靠的做法有三类一是把Medical_Keyword 48个0/1列做行求和生成“触发关键词数量”二是对连续列做分箱比如把某个年龄类特征按5岁一组离散化生成新ID列三是把两个类别字段拼接成一个新ID让XGBoost去学联合分布。# 1. 关键词计数所有以 Medical_Keyword_ 开头的列求和 kw_cols [c for c in feat_cols if Medical_Keyword in c] df[Med_Keyword_Count] df[kw_cols].sum(axis1) # 2. 分箱对连续特征按5岁一组 df[Age_Group] pd.cut(df[Ins_Age], binsrange(20, 80, 5), rightFalse) # 3. 类别交叉 df[Product_Info_2_3] ( df[Product_Info_2].astype(str) _ df[Product_Info_3].astype(str) )这里的kw_cols是动态选的保证即使列名有细微变化也不会漏。Age_Group分箱后XGBoost可以直接把这个新列当类别用如果你不想保留字符串可以再套一层LabelEncoder。Product_Info_2_3这种拼接特征本质上是让树模型以更少的分裂次数学到两个字段的联合效果对QWK的提升通常比单加十列原始特征更明显。组合特征做完后要重新跑一遍全特征相关性检查避免生成和标签高度相关的泄漏列——比如把训练集里的Response均值按某列编码进特征里那种泄漏会让交叉验证分数虚高线上直接翻车。4. XGBoost多分类建模从基线到稳定交叉验证4.1 基线模型multi:softprob与mlogloss的搭配用XGBoost做8分类先跑一个不带任何调参技巧的基线。目标函数选multi:softprob输出的是每个样本在8个类别上的概率而不是直接给出类别。softmax会保证8个概率和为1这点在做QWK的软预测时很有用。from xgboost import XGBClassifier model XGBClassifier( objectivemulti:softprob, num_class8, max_depth6, learning_rate0.1, n_estimators500, subsample0.8, colsample_bytree0.7, min_child_weight3, eval_metricmlogloss, tree_methodhist, n_jobs16, ) model.fit(X_train, y_train) y_pred model.predict(X_val) kappa cohen_kappa_score(y_val, y_pred, weightsquadratic) print(baseline QWK:, round(kappa, 5))参数含义num_class必须和y的类别总数一致这里8eval_metric用mlogloss多分类的交叉熵它的变化趋势和QWK基本同步可以用它做早停判断tree_methodhist是分桶直方图近似算法在大数据集上比exact快一倍以上精度损失可忽略n_jobs设成你机器的物理核数不要贪多。这个基线跑完QWK大概在一个能看的区间真正提分靠后面的特征和调参。4.2 调参顺序从深度、采样率到学习率的优先级调参不是玄学但要按影响力排优先级。我的顺序是先定max_depth和min_child_weight再调subsample和colsample_bytree最后降learning_rate加树的数量。原因很简单深度决定模型容量采样率决定方差控制学习率决定最终收敛精度容量没定之前调学习率等于白调。param_grid [ {max_depth: 4, min_child_weight: 1}, {max_depth: 6, min_child_weight: 3}, {max_depth: 8, min_child_weight: 5}, {max_depth: 10, min_child_weight: 8}, ] best_score 0 for p in param_grid: params dict( objectivemulti:softprob, num_class8, learning_rate0.1, n_estimators500, subsample0.8, colsample_bytree0.7, eval_metricmlogloss, tree_methodhist, ) params.update(p) m XGBClassifier(**params, n_jobs16) m.fit(X_train, y_train) s cohen_kappa_score(y_val, m.predict(X_val), weightsquadratic) print(p, QWK:, round(s, 5)) if s best_score: best_score s best_params p这轮跑完你就知道深度在6到8左右通常最优超过8开始过拟合。接下来把max_depth固定在这个区间调subsample从0.6到0.9、colsample_bytree从0.5到0.8每轮用3折交叉验证取平均不要用单次验证集判断因为QWK对类别分布很敏感一次划分的偶然性太大。这两组调完后把learning_rate降到0.02n_estimators加到2倍重新训练。4.3 StratifiedKFold交叉验证和早停多分类问题里少数类别样本少普通K折会把某些类别全部切进验证集导致那一折的模型根本没见过该类。所以必须用StratifiedKFold它会按每个类别的原始占比分层抽样保证训练集和验证集里各类别比例一致。from sklearn.model_selection import StratifiedKFold skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores [] for fold, (tr_idx, va_idx) in enumerate(skf.split(X, y)): X_tr, X_va X.iloc[tr_idx], X.iloc[va_idx] y_tr, y_va y.iloc[tr_idx], y.iloc[va_idx] m XGBClassifier( objectivemulti:softprob, num_class8, max_depth6, learning_rate0.02, n_estimators3000, subsample0.8, colsample_bytree0.6, min_child_weight3, eval_metricmlogloss, tree_methodhist, n_jobs16, ) m.fit( X_tr, y_tr, eval_set[(X_va, y_va)], early_stopping_rounds100, verboseFalse, ) y_pred m.predict(X_va) score cohen_kappa_score(y_va, y_pred, weightsquadratic) scores.append(score) print(ffold {fold 1} QWK: {score:.5f}) print(mean QWK:, round(np.mean(scores), 5))early_stopping_rounds100的含义是如果连续100轮验证集mlogloss没有下降训练就提前结束。这样n_estimators设3000也不会跑满。每个fold训练一个模型预测测试集时取5个模型的概率平均再argmax成类别。多折平均是所有树模型竞赛里的标准操作它比单模型稳定得多线上排名通常也会比local CV更高。5. 模型解释从特征重要性到SHAP的业务结论5.1 内置gain重要性排序可用数值不可读XGBoost自带feature_importances_它统计的是每个特征在所有分裂点的Gain贡献之和。这个数值只反映“该特征被用来分裂时带来的平均增益”不代表方向也不代表重要性是你想象的那个“影响大小”。换句话说它能把特征排序但排序靠前不等于数值大的样本风险更高。importance pd.Series(model.feature_importances_, indexfeat_cols) importance.sort_values(ascendingFalse).head(20)这20个特征大概率包含我们做的组合特征Med_Keyword_Count、Product_Info_2_3还有若干原始匿名列。这个列表的价值是让你知道模型确实在用关键词计数和产品交叉信息做判断而不是靠某个填了-1的缺失列硬撑。但如果你要拿去和业务方解释“为什么这个申请人被评成高风险”内置重要性就不够了它解释不了单个样本。5.2 SHAP的双视角全局排序和单样本拆解SHAP是把黑匣子打开的钥匙它的核心思想是给每个特征分配一个Shapley值表示这个特征把预测结果从“全局平均值”推向了“当前预测值”的贡献方向。正值表示把风险等级往上推负值表示往下推绝对值越大影响越强。import shap explainer shap.TreeExplainer(model) shap_values explainer.shap_values(X_val) # 多分类时shap_values是list每个类别一个二维数组 print(len:, len(shap_values), shape:, shap_values[0].shape) # 全局摘要按特征重要性排序展示所有样本的分布 shap.summary_plot(shap_values[4], X_val, max_display15)多分类时shap_values是一个list长度为类别数每个元素是(n_samples, n_features)的二维数组。上面取了索引4也就是类别5的SHAP值如果你想看类别1的风险就换成shap_values[0]。summary_plot的输出是每行一个特征按SHAP绝对值的均值排序点的颜色是特征数值横轴是SHAP值。色彩越红且分布偏右说明该特征数值越大越容易把用户推向这个类别。5.3 字段族解读业务方要的不是列名而是结论SHAP散点图看多了以后你会总结出几类规律我来归一下字段族典型形态业务解读Medical_Keyword系列48个0/1列触发关键词越多预测档位越偏高端风险Family_Hist系列稀疏可空列家族史缺失不等于无风险而可能等于未采集SHAP里_isna列会单独显示Employment_Info系列连续少量离散收入与职业等级和中低风险档位高度相关给业务方汇报时不要讲“第37个特征SHAP值最高”要讲“用户勾选医疗关键词的数量每增加一个风险档位预测平均升高0.3档”。这种结论从SHAP里可以直接算把某特征的SHAP值按特征数值分箱求平均即可。import pandas as pd temp pd.DataFrame({ feat: X_val[Med_Keyword_Count], shap: shap_values[4][:, feat_cols.index(Med_Keyword_Count)] }) temp.groupby(pd.cut(temp[feat], bins5))[shap].mean()这段代码把Med_Keyword_Count按数值分5箱看每一箱的平均SHAP贡献。输出的趋势如果单调向上你就有了一个可以直接写进模型说明文档的结论。注意shap_values[4]的列顺序必须和X_val的特征顺序完全一致否则索引错位结论全反。我一般会在一开始就把feat_cols存成list所有数据变换都按这个list顺序做。6. 避坑排查四个高频翻车点与一个长期习惯6.1 预测概率被argmax吞掉相邻档信息现象验证集QWK只有0.5但看混淆矩阵发现大部分错都落在相邻档。原因model.predict直接取概率最大的类别完全没有利用“4预测成5”和“4预测成8”的损失差异。解决改用predict_proba尝试对概率做指数平滑或温度缩放让各档预测值更平滑。y_prob model.predict_proba(X_val) # 温度缩放温度1让概率分布更平缓 scaled_prob np.exp(np.log(y_prob 1e-9) / 1.2) scaled_prob scaled_prob / scaled_prob.sum(axis1, keepdimsTrue) y_pred np.argmax(scaled_prob, axis1) 1温度1.2是我常用的起点这个参数需要单独调。加1e-9是防止log(0)。6.2 缺失填充顺序不对导致isna列失效现象模型跑完重要特征前20名里一个_isna列都没有但业务上“未填写家族史”应该很有区分度。原因我在第二步做log1p变换时把新加的_isna列也当成连续列处理了log1p后isna列变成全0全1分布完全被破坏。解决所有做变换的列先过滤掉_isna结尾的布尔列或者在特征工程最开头就把isna列单独存一份最后再拼回特征矩阵。我现在的习惯是两类列分开处理布尔列一律不进变换逻辑。6.3 交叉验证分数和线上分数对不上现象5折CV的平均QWK高得吓人提交后分数低一截。原因预处理步骤发生了数据泄漏最常见的是fillna用全量均值填充或者LabelEncoder对全量fit后再切分导致验证集的信息提前进入了训练流程。解决把一切fit过程放进交叉验证循环内部外部只允许做不依赖标签的变换。清洗、填充可以用全量算出的常数但任何fit_transform都必须在fold内部重新fit。6.4 SHAP版本API不一致导致接口报错现象shap.summary_plot(shap_values[4], X_val)直接TypeError或shap_values[4]的shape是3维。原因shap包从0.42开始TreeExplainer对XGBClassifier多分类的返回结构做了调整有的版本返回list有的版本返回三维数组。解决先打印len和shape再写后续代码不要凭记忆写API。确认是三维数组时先切片成类别再传给老接口或者改用shap.plots.beeswarm()新接口。6.5 一个长期习惯逐版本记录特征增量我做这个赛题有一个固定习惯每加一个特征或改一个参数就把交叉验证QWK和主要特征重要性排名记在一个表格里。这个习惯救了我很多次因为特征工程做多了以后你根本记不清上上版本究竟加了哪几列改回来等于重来。表格第一列是版本号第二列是特征列表快照第三列是5折CV均值第四列是备注。没有思路的时候就翻这个表看哪一步效果大哪一步是负优化。回头整理代码的时候这个表也是在复盘时最有用的资料。希望你也能用这套流程少走弯路希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →