
你可能遇到过这种情况新模型离线验证KS做到0.45样本外验证也有0.4满怀信心推到策略里三个月后贷后表现一出来坏账率比预期高了两三成。领导来问的时候你翻遍报告找不到原因。其实原因很多时候不在模型本身而在训练集里——绝大多数风控模型都只拿“通过的人”在训练“被拒绝的人”从头到尾都没出现在数据里。这不是简单的样本缺失这是选择性偏差Sample Selection Bias。解决这个偏差的一套方法在风控圈子里叫拒绝推断Reject Inference。这篇文章不绕理论直接讲清楚三件事为什么只用通过样本建模会出问题、拒绝推断的底层假设是什么以及硬截断法、模糊展开法、分段加权法这三种实战方法到底怎么落地。如果你是做信贷风控建模、评分卡开发或者策略分析的这篇文章应该能帮你省下不少自己摸索的时间。1. 只在“通过的人”身上建模偏差藏在哪里1.1 一个常规风控模型的训练集到底缺了什么信贷业务的标准流程是客户申请→跑分→决策通过/拒绝→通过的人进入贷后管理产生表现标签拒绝的人直接流失永远不会产生“如果他当时被通过了会不会逾期”这个反事实标签。问题恰恰出在这里。模型训练时标签Y是“这个客户是否逾期”但这个标签只对通过的人存在。建模时你拿到的样本集实际上是经过审批策略筛选后的一个子集而不是申请客群的全量。举个直观的例子如果审批策略把评分400分以下的人全拒了那么训练集里就永远不会出现400分以下客户的逾期情况。可你的模型上线后恰恰需要用它对未来所有申请人打分其中就包括大量分布在400分以下的人。用缺了低分段样本的数据训练出来的模型去预测包含低分段样本的客群这本质上是在用“开卷考试”的题库去考学生考试范围却是闭卷的。模型在缺失部分训练数据的区域只能靠外推来猜外推出来的分数自然不可靠。1.2 选择性偏差的三重伤害参数偏移、边界失真、样本代表性崩塌拒绝样本缺失带来的问题不只是“样本少了”这么简单。我拆成三层来看。第一层叫参数偏移。因为低分客群的还款行为完全不可见模型学到的变量系数是被压缩过的。常见的现象是评分卡里某个变量的系数被明显低估或者方向都变了。原因在于模型在拟合“经过筛选的客群”的还款规律而不是“全体申请客群”的还款规律。第二层叫决策边界失真。风控模型的核心用途是在批准线附近做判别——哪些人刚好能过哪些人刚好该拒。但恰恰是批准线附近的样本有一部分被策略切走了模型没有见过“刚好在线附近但被拒绝的人”的真实表现。这就导致模型在最重要的分界区间上训练数据密度最低判别力最弱。第三层叫样本代表性崩塌。很多机构在紧信用周期会收紧策略通过率下降训练集里的通过样本占比越来越小直到和申请客群的真实分布严重脱节。这时候模型评估出的KS、AUC都很好看但数值意义有限因为它衡量的只是“策略通过后的客群”里的区分度不是“全量申请客群”里的区分度。1.3 用一个模拟实验看偏差传导空说无凭我写个简单模拟帮助你直观感受偏差是怎么传导的。import numpy as np import pandas as pd from sklearn.linear_model import LogisticRegression np.random.seed(42) n 20000 x np.random.normal(0, 1, n) # 真实评分和逾期概率的关系 logit 0.8 * x - 0.5 y (1 / (1 np.exp(-logit)) np.random.rand(n)).astype(int) # 模拟审批策略按某个门槛拒绝 cutoff 0.0 approved x cutoff # 只有x高于门槛的人才有标签 train_biased pd.DataFrame({x: x[approved], y: y[approved]}) train_full pd.DataFrame({x: x, y: y}) lr_biased LogisticRegression().fit(train_biased[[x]], train_biased[y]) lr_full LogisticRegression().fit(train_full[[x]], train_full[y]) print(全量模型系数, lr_full.coef_[0][0]) print(筛选后模型系数, lr_biased.coef_[0][0])运行结果大致是全量模型系数接近真实的0.8而筛选后模型系数明显偏小。原因很简单——低x区间的样本被全部截断了模型在这个区间没有信息只能靠高x区间的样本去拟合结果把真实斜率拉偏。这种系数偏移在真实业务里会直接导致低分段申请人的分数被高估或低估最终让拒绝线位置跟着出错。2. 拒绝推断到底在推断什么方法背后的三条数据假设2.1 拒绝推断不是给拒绝样本“算命”很多人第一次接触拒绝推断误以为要给每一个被拒绝的样本强行贴上一个“好坏”标签这其实是错的。拒绝推断的目标是估计一个条件概率**如果这批被拒绝的客户当初被批准了他们的逾期表现会是什么样的。**换句话说我们不关心某个具体的人“本身坏不坏”我们关心的是“这一群被拒绝样本的期望坏账水平”。打个比方你在一家餐厅门口等位服务员告诉你前面还有20桌。你不需要精确知道第17桌那对情侣打算点多少钱你只需要知道“等位20桌大概还要1小时”。拒绝推断做的是人群层面的估计不是个体层面的算命。2.2 三种主流方法对应着不同的机制假设你选择哪种拒绝推断方法本质上取决于你愿意对“缺失机制”做什么假设。硬截断法Hard Cutoff背后的假设最朴素被拒绝样本中分数最高的那一小部分人如果通过了表现大概率接近批准线附近的通过客户。所以直接把这一部分人按好客户处理。这是一个很强的假设相当于认为“拒绝行为只是随机裁掉了一段尾部”。模糊展开法Fuzzy Augmentation背后是MARMissing At Random随机缺失假设在控制了可观测变量之后样本当初被拒绝与否和它未来的逾期表现不再直接相关。实操时我们用外部征信分、历史申请模型或规则引擎给拒绝样本一个预估坏账概率再用软标签参与训练。分段加权法Parceled Reclassification介于两者之间它假设不同分数段内的拒绝样本其好坏比例和该分数段内通过样本的好坏比例大致一致因此用分箱的方式给拒绝样本赋标签降低个别样本判断错误带来的噪声。此外还有理论上更完备的Heckman两阶段法——先用Probit估计“被通过的概率”再计算逆米尔斯比率放进第二阶段的回归里校正偏差。但在信贷场景中Heckman模型需要找到既影响“是否被通过”又不直接影响“是否逾期”的排他性变量这种变量在实际业务数据里极难找到所以应用远不如前面三种广泛。提示拒绝推断没有一个方法能保证绝对正确因为被拒绝样本的真实标签永远无法获得。任何方法都是在“合理假设”的前提下做估计落地时最关键的不是技巧本身而是判断你的场景更符合哪种缺失假设。2.3 什么时候不做拒绝推断反而更好拒绝推断不是万金油也不是所有模型都必须做。根据我自己的经验遇到下面几种情况不做拒绝推断可能更稳妥拒绝率很低比如低于5%拒绝样本量太小推断引入的噪声可能比偏差还大。通过客群和申请客群高度同质比如白名单客群、定向邀约场景审批策略没有产生明显筛选效果选择性偏差可以忽略。缺失标签的样本根本无法给出可靠估计比如外部征信覆盖度不足连预测拒收样本好坏的输入变量都没有。这种情况下做拒绝推断纯属硬编数字不如老老实实做模型监控。判断标准其实就一句话当“被拒绝”和“申请人真实风险”强相关时选择性偏差一定存在但当偏差影响远小于模型自身波动时拒绝推断是过度工程化。3. 三种实战方法硬截断、模糊展开、分段加权3.1 方法一硬截断法Hard Cutoff——最朴素用来做基线硬截断法的操作非常简单把拒绝样本按模型分从高到低排序取最接近批准线的一段比如Top 5%或Top 10%直接标记为“假设通过后表现正常”Y0。更粗糙的做法是直接把这些样本当作好客户丢进训练集。也有机构用“双评分卡”思路先用一个已有模型对拒绝样本打分分数高于某个阈值的拒绝样本标记为好客户低于某个阈值的标记为坏客户然后和通过样本合并训练。# 伪代码硬截断法标记拒绝样本 reject_df reject_df.sort_values(model_score, ascendingFalse) top_k int(len(reject_df) * 0.1) reject_df.loc[:top_k, y_assigned] 0 # 假设Top10%是好客户 # 其余拒绝样本不参与训练或标记为坏客户 reject_df.loc[top_k:, y_assigned] 1 if use_two_class else np.nan实操中我不建议把剩下的拒绝样本全标成坏客户。你只对最接近批准线的那一小段有相对把握更低的段位你完全不知道它“如果被通过”会怎样。硬截断法最大的问题是把软信息硬编码成0/1丢失了确定性信息并且高估了模型在低分段的判别力。所以它更适合作为baseline用来对比其他方法的效果增益。3.2 方法二模糊展开法Fuzzy Augmentation——实战里的首选模糊展开法的核心思路是不硬编码好坏而是给每个拒绝样本一个0到1之间的软标签表示“如果被通过预计逾期概率”训练时用这个软标签代替真实的0/1标签。同时由于软标签存在估计误差一般会给拒绝样本赋予一个小于1的样本权重控制它们对模型的影响。整个流程我拆成四步第一步准备数据。通过样本有真实Y拒绝样本没有Y。所有样本都保留申请时的特征变量。第二步生成拒绝样本的软标签。常见做法是用外部征信评分的换算、历史老模型打分或者直接用一个简单的逻辑回归先拟合通过样本然后外推预测拒绝样本的逾期概率。我做的时候更习惯用外部征信分做一个Platt Scaling校准让输出的概率在量纲上和真实坏账率尽量一致。第三步设置权重。给通过样本权重设为1拒绝样本权重设为一个较小的值比如0.3。这个权重不是拍脑袋定的它代表你对软标签的信任程度。如果外部征信分和内部逾期标签相关性很强权重可以设高一些如果完全没底权重就设低一些。第四步用加权数据训练模型。以LightGBM为例直接通过sample_weight参数传入即可。import pandas as pd import numpy as np import lightgbm as lgb from sklearn.model_selection import train_test_split # df_pass: 通过样本有y # df_reject: 拒绝样本无y但通过外部模型给出软标签 soft_label df_pass df_pass.copy() df_reject df_reject.copy() # 通过样本真实标签权重1 df_pass[weight] 1.0 df_pass[y_train] df_pass[y] # 拒绝样本软标签权重设为0.3 df_reject[weight] 0.3 df_reject[y_train] df_reject[soft_label] train pd.concat([df_pass, df_reject], axis0, ignore_indexTrue) features [c for c in train.columns if c not in (y, y_train, weight, soft_label)] X_train, X_val, y_train, y_val, w_train, w_val train_test_split( train[features], train[y_train], train[weight], test_size0.2, random_state42 ) d_train lgb.Dataset(X_train, labely_train, weightw_train, feature_namefeatures) d_val lgb.Dataset(X_val, labely_val, weightw_val, feature_namefeatures) params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 31, max_depth: 5, verbose: -1, } model lgb.train( params, d_train, valid_sets[d_val], num_boost_round500, )模糊展开法最关键的参数就是这个拒绝样本权重。我踩过几次坑之后总结了一套经验权重太高拒绝样本的软标签噪声会污染模型导致真实通过样本的区分度下降权重太低选择性偏差又没被拉回来多少。一般从0.2到0.5之间起步配合滚动验证来调。另外软标签分布要和通过样本的真实坏账率分布对齐如果软标签整体偏低模型会太乐观。注意sample_weight不改变模型对特征的梯度计算方向它改变的是每个样本对损失函数的贡献大小。所以拒绝样本权重本质上是“影响程度”的控制阀不是“可信度”的直接度量。3.3 方法三分段加权法Parceled Reclassification——把拒绝样本分段回收分段加权法在实操中也很常见尤其适合拒绝样本量很大的场景。它的做法是把拒绝样本和通过样本放在同一套分数体系下按分数分箱然后在每个箱内用通过样本的真实坏账率作为该箱内拒绝样本的标签。这样避免了给单个样本硬贴标签时的过大误差。比如把分数每20分一箱在680-700分这个箱子里通过样本坏账率是2%那这个箱子里所有的拒绝样本就都被赋予0.02的逾期概率。然后再按模糊展开法的思路加上样本权重训练。分段加权法有一个变体叫“双箱赋值法”不只看通过样本的坏账率还结合外部征信数据对每个箱内拒绝样本的好坏比例做一次外部校准。这样外部信息的权重更高对硬伤更小。# 伪代码分段加权法 df_all pd.concat([df_pass[[model_score, y]], df_reject[[model_score]]]) df_all[bin] pd.cut(df_all[model_score], binsrange(300, 901, 20)) # 每个分数箱内通过样本的坏账率 bin_stats ( df_pass.assign(binpd.cut(df_pass[model_score], binsrange(300, 901, 20))) .groupby(bin, observedTrue)[y] .agg([mean, count]) .reset_index() ) # 将箱内坏账率映射到拒绝样本 df_reject df_reject.assign(binpd.cut(df_reject[model_score], binsrange(300, 901, 20))) df_reject df_reject.merge(bin_stats, onbin, howleft) df_reject[y_soft] df_reject[mean].fillna(0.5)分段加权法和直接模糊展开的差别在于前者等于把“估计软标签”这一步做了平滑在样本量不足的分数段它比逐个样本预测更稳定。缺点也很明显——在分数段边界处标签会出现台阶式跳跃模型会学到一些虚假的边界效应。所以分箱宽度要合理太窄了噪声大太宽了精度损失大。我自己的习惯是先跑一版模糊展开法再用分段加权法做交叉验证如果两种方法在策略回测里给出的结论一致那这个拒绝推断结果基本可信。3.4 三种方法对比与选择建议方法缺失假设强度实现复杂度稳定性适用场景硬截断法强低中快速基线、样本量极小、外部信息匮乏模糊展开法中中中高有外部征信分或老模型可供生成软标签分段加权法中中高拒绝样本量大、分数分布宽、需要平滑处理如果说个人偏好我会在大多数情况下推荐模糊展开法因为它在估计能力和实现成本之间最平衡。分段加权法适合做稳健性检验。硬截断法我基本都是当baseline用的很少直接作为最终模型。4. 效果验证搭建“伪拒绝”实验别只拿AUC说事4.1 全量有标签数据上的截断模拟拒绝推断最尴尬的地方在于没有真实标签可验证。那怎么办一个非常实用的做法是“伪拒绝模拟”。具体来说找一段历史数据这堆样本你是有完整好坏标签的然后人为模拟一次审批筛选把低于某个门槛的样本当作“被拒绝”隐藏它们的标签。接着在这个人为截断的数据上跑拒绝推断最后用被隐藏的真实标签来检验推断效果。# 伪代码截断模拟实验 df_labeled df_labeled.sort_values(model_score, ascendingFalse) sim_cutoff 560 # 模拟审批线 sim_pass df_labeled[df_labeled[model_score] sim_cutoff].copy() sim_reject df_labeled[df_labeled[model_score] sim_cutoff].copy() # 隐藏拒绝样本标签 sim_reject.drop(columns[y], inplaceTrue) # 在sim_pass上训练模型并生成软标签但对sim_pass使用真实y # ... 跑一遍模糊展开法得到 full_model sim_reject[pred] full_model.predict_proba(sim_reject[features])[:, 1] # 对比sim_reject真实y和pred的差距如果模拟的拒绝样本预测坏账率和真实坏账率接近说明拒绝推断在当前数据下是有效的反之则需要调整方法或权重。4.2 策略回测与客群迁移分析用历史数据做“策略回测”是另一种验证思路。假设旧策略的拒绝线在580分你把新模型跑出来之后观察新模型评分落在旧拒绝线以下的那部分“新拒绝客群”如果把他们放进来逾期表现是否真的比原来高这个问题的答案不能直接观测但可以用拒绝推断的软标签粗略估计。更实际的做法是看客群迁移比较拒绝推断前后的模型分数分布变化、各分数段通过率变化、整体坏账率的预估变化。如果拒绝推断后的模型在批准线附近给出了比原模型更平滑的风险排序说明它捕捉到了原模型丢失的那部分信息。4.3 一个容易犯的错误拿全样本AUC来验收拒绝推断我见过不少团队用全量申请样本的AUC来评估拒绝推断的效果这是有问题的。拒绝推断的价值不在提高全排序能力而在于修正决策边界附近的局部准确性。实际上加入拒绝样本后模型的整体AUC有可能下降因为拒绝样本本身噪声大、难分类。但如果在批准线附近的lift值提升了或者策略回测的坏账率预估更接近实际那拒绝推断就是有效的。判断指标建议用这三类KS/AUC在全量样本上的变化参考性指标批准线附近分段Lift值核心指标模拟截断场景下的预测坏账率 vs 真实坏账率验证性指标不要只盯一个指标三个放在一起看结论才可靠。5. 落地时的四条经验越早看越好5.1 先确认“坏账定义”和“观察期”一致拒绝推断里有个容易忽略的前提通过样本的坏账标签来自“贷后表现观察期”比如M3逾期、90天逾期、或者最终坏账。但拒绝样本从来没有进入贷后流程你给它赋的软标签都不是实际观察得到的数据。所以你要在业务侧确认一件事**你推断的是“如果这批人被通过在同样的观察期内达到同一坏账定义的概率”而不是“这批人本身的社会信用好坏”。**这两者在概念上差得很远。很多模型上线后坏账率背离预期根源就在这里——业务方嘴上说的是“反事实”代码里做的却是“外部评分映射”。5.2 拒绝样本会随模型迭代而变拒绝推断不是一次性工作每次迭代模型审批策略也会跟着调被拒绝的客群构成就变了。去年被拒的人可能主要因为收入负债比高今年策略加了共债识别规则后被拒的人主要因为多头借贷。两种拒绝样本的“反事实表现”完全不同。所以你每次重训模型时拒绝推断的标签、权重、外部评分映射都要重新算一遍不能直接沿用上一轮的软标签。5.3 拒绝推断不能替代A/B测试也不需要每个模型都做拒绝推断是对缺失数据的估计再准也是估计。真正能拿到拒绝样本的真实表现在业务上只有一种方法——做随机放行实验champion-challenger中的控制组放行。如果你具备做随机放行的条件那得到的观测结果远胜过任何拒绝推断方法。但随机放行成本高、风险大大多数机构不会大规模做所以拒绝推断才成为必要补充。另外也不是每个模型都需要做拒绝推断。比如你已经有一个稳定运行两年以上的申请评分卡每次迭代只是在变量上做小改动那新模型和旧模型的选择性偏差差异很小不做拒绝推断问题不大。但如果你是从无到有搭一个全新模型、或者切了一个完全不同的算法体系拒绝推断几乎必须做。5.4 变量口径和训练窗口一致性要提前卡住拒绝推断要求训练样本里的变量都是“申请时点可得”的变量。这个要求听起来简单实际操作中经常出问题比如拿贷中行为变量近3个月还款次数、额度使用率去推断拒绝样本的逾期概率看起来拟合得很好实际上一上线就崩。因为拒绝样本根本没有贷中表现这类变量天然缺失。一旦在拒绝推断阶段混入非申请时点变量整个模型的预测结构都会失稳。我最常提醒团队的一句话把“申请时点”当成一条红线所有用于拒绝推断的特征、外部数据、规则变量都必须确保在申请时点已存在且可采集。这条红线不守住后面做多少精细调参都白搭。最后再分享一个小经验拒绝推断本身不是目的它是帮你把“决策边界附近的真实风险”看得更清楚的手段。所以每次做完拒绝推断我都会把“推断出的拒绝集坏账率曲线”和“通过集实际坏账率曲线”放在同一张图上观察两条曲线在批准线附近是否平滑衔接。如果出现明显的断层或跳变那大概率是推断方法或假设出了问题这时候不急着上线模型先回头检查数据口径比调参数重要得多。