
简介面向金融风控研究者与机器学习初学者的《机器学习算法在P2P网贷平台风险评级中的应用》PDF文档围绕P2P网贷平台风险评级这一互联网金融热点给出基于机器学习的分类与信用风险研究思路。文档为单文件PDF资源共1个文件、大小约2.13MB属于参考文献类资料可用于论文写作、课题研究或专业指导场景。全文从平台风险与借款人信用风险两个层面展开先采用无监督学习中的二分K-means聚类对网贷平台进行分类并划分风险等级再引入AdaBoost等有监督算法评估借款人信用风险还涉及爬虫采集指标、数据处理与算法对比等细节。目前已有131人学习浏览适合需要了解机器学习在风险评级中落地方式、借鉴实证思路或完成相关课程设计的学生与从业者。1. 机器学习算法在 P2P 风险评级里解决的是“还钱概率”问题P2P 网贷平台和银行信贷最大的区别是客群下沉借款人批量大、单笔金额小、几乎没有抵押物传统人工信审根本接不住这么大的流量。所以平台只能把“这个人会不会按时还钱”这件事交给机器学习算法去判断——这就是风险评级要做的事给每个借款申请打分分数低的分到拒绝组分数中的走人工复核分数高的直接放款。很多人误以为风险评级就是个二分类模型跑个 LightGBM 拿到 AUC 就完事了。实际上 P2P 场景的坑比教科书里写的深得多逾期标签怎么定、样本不均衡怎么办、模型在冷启动期没有坏样本怎么训练、出分之后怎么跟业务规则对表。这些才是做落地的人真正要花时间的部分。这篇文章我按自己做过的一整套流程来拆从标签构造、特征工程、模型训练到上线的验证闭环代码都可以直接复跑参数都是踩过坑之后留下来的经验值。2. 标签构造与样本划分先定义“逾期”再造数据集2.1 逾期口径的三种常见定义和适用场景P2P 平台的贷款周期通常很短常见的是 1 到 12 个月的等额本息或先息后本。定义“坏客户”时最常见的做法是“首逾 15 天”或者“连续逾期 30 天”。但这里有个很关键的问题你用的是“首逾”还是“当前逾期状态”。首逾指的是借款人在整个贷款周期里第一次出现逾期的时间点哪怕后来还上了这笔贷款也被标记为坏样本。当前逾期状态则是只看评估时点有没有逾期。我做过的项目里首逾口径更稳定因为它不会因为借款人“补交”而被重新分类。但首逾的缺点是它把短期还上的人也算坏了导致坏样本比例偏高。实际业务里我会同时算这两个字段然后在训练集里用首逾 当前逾期双条件——只要满足首逾 15 天或者当前逾期超过 30 天的都算坏样本其他算好样本。样本时间窗口的切分也很讲究。训练集、验证集、测试集不要用随机抽样要按借款起始日排序切分。原因很简单P2P 平台的风控策略一直在调整客群结构也在变随机抽样会把未来信息泄漏到训练集里上线之后效果断崖下跌。我的做法是取前 80% 时间段的借款单做训练后 20% 做测试。import pandas as pd import numpy as np from datetime import timedelta # df_loan: 借款主表含借款ID、放款日期、到期日期、计划还款明细 # df_repay: 还款流水表含借款ID、应还日期、实还日期、应还金额、实还金额 def make_label(df_loan, df_repay, first_due_days15, current_overdue_days30): # 计算每一笔借款的首逾标志 df df_loan.merge(df_repay, onloan_id, howleft) df[overdue_days] (df[actual_repay_date] - df[due_date]).dt.days # 首逾第一次出现逾期天数 0 的还款计划 df[is_first_overdue] df[overdue_days] 0 first_overdue df.groupby(loan_id)[is_first_overdue].max() first_due_flag (df.groupby(loan_id)[overdue_days].max() first_due_days) # 当前逾期状态在评估时点这里取观察日比如放款后 30 天是否有未还清逾期 eval_date df_loan[loan_date] timedelta(days30) temp df[df[due_date] eval_date].copy() temp[current_overdue] (temp[actual_repay_date] eval_date) (temp[actual_repay_date].isna()) current_overdue_flag temp.groupby(loan_id)[current_overdue].max().fillna(False) label pd.DataFrame({ loan_id: df_loan[loan_id], label: (first_due_flag | current_overdue_flag).astype(int) }) return label这段有两个关键逻辑。第一个是first_overdue的计算方式用groupby取 max 而不是取 sum因为只要有一次首逾就算坏样本重复逾期次数不重要。第二个是当前逾期状态的时间条件actual_repay_date eval_date表示在评估日还没有还上isna()是防止空值被当作正常还款处理。注意temp先过滤了due_date eval_date这是为了避免把未来未到期的还款计划算成逾期。这里的评估日取放款后 30 天如果贷款周期短可以改成 15 天。2.2 训练集 / 验证集 / 测试集的时间切分与防泄漏时间切分看起来简单但做错的人非常多。最常见的问题是先做了缺失值填充或特征缩放再做切分导致验证集的数据分布被训练集的信息“污染”。以标准化为例如果在全数据集上算均值和方差再切分那么验证集里每一行的特征值其实已经参考了训练集的统计量。def time_split(df, features, label_collabel, train_ratio0.8): df df.sort_values(loan_date).reset_index(dropTrue) split_idx int(len(df) * train_ratio) train df.iloc[:split_idx].copy() test df.iloc[split_idx:].copy() # 标准化必须在切分之后单独 fit scaler StandardScaler() train_scaled scaler.fit_transform(train[features]) test_scaled scaler.transform(test[features]) X_train pd.DataFrame(train_scaled, columnsfeatures) X_test pd.DataFrame(test_scaled, columnsfeatures) y_train train[label_col] y_test test[label_col] return X_train, X_test, y_train, y_test如果按 8:2 时间切分我一般还会把训练集中最后 20% 的时间段单独划出来做 early stopping 的验证集。因为纯粹的 8:2 切分只有一个测试集调参时反复去看测试集结果本质上又把测试集变成了训练的一部分。这个做法在 P2P 风险模型里特别容易踩业务方每次都要看测试集 AUC你每次都要调参几十轮下来模型已经在测试集上过拟合了。3. 特征工程与数据清洗P2P 特征的两条主线3.1 基本信息类特征身份、职业、收入的编码策略P2P 平台在注册和申请时通常会收集用户的身份信息、职业、学历、收入、房产车产等基本信息。这些字段的特点是缺失率高、类别杂、量纲差异大。学历字段常见取值包括高中及以下、大专、本科、硕士、博士有时还会混入“其他”和空值。职业字段更乱自由职业者、个体户、上班族、学生、待业还有不少自定义文本。职业字段我建议直接手工做归类映射不要丢给模型做 embedding因为类别太少embedding 也学不出什么泛化能力。常见做法是构建一个职业风险字典把“学生”和“待业”归为高风险把“公务员、事业单位、国企”归为低风险把“个体户、自由职业”单独拆出来因为这类人群收入不稳定但逾期率和收益也偏高要看平台的风险偏好去决定权重。对于缺失值职业和学历不建议用 “未知/other” 简单填充而是单独拆一个 is_missing 字段让模型学缺失本身是否与风险相关。3.2 行为类特征借款历史、还款准时度、多头借贷的衍生变量行为类特征是 P2P 风险模型里最有区分度的部分。比基本信息好用的多。具体包括历史借款次数、历史违约次数、历史最大逾期天数、平均还款提前天数、当前未结清借款数、近 30 天查询次数、近 90 天在多个平台的申请次数多头借贷。这些字段的衍生方式不要只做简单 sum/mean。比如“历史还款准时度”这个特征我一般会算成“过去 6 个月内按时还款次数 / 应还款总次数”的比值同时再加一个“最近一次逾期距离今天的天数”这样带时间衰减的特征。多头借贷是整个行业里防欺诈最有效的信号之一借款人 30 天内申请平台数超过 5 家坏账率是普通用户的 3 倍以上这不是个例是行业普遍统计结果。def derive_behavior_features(df_loan, df_repay): # 每笔借款的还款记录里计算准时率 df df_loan.merge(df_repay, onloan_id, howleft) df[pay_late] (df[actual_repay_date] df[due_date]).astype(int) df[repay_gap_days] (df[actual_repay_date] - df[due_date]).dt.days # 准时率按期还款次数 / 总期数 on_time_ratio df.groupby(user_id).apply( lambda x: 1 - x[pay_late].sum() / len(x) if len(x) 0 else 0 ).rename(on_time_ratio) # 最近一次逾期距今的天数越大越安全 latest_overdue df[df[pay_late] 1].groupby(user_id)[repay_gap_days].max() overdue_recency df.groupby(user_id)[due_date].max() - latest_overdue overdue_recency overdue_recency.dt.days.fillna(999) overdue_recency.name overdue_recency_days # 多头借贷平台内近30天申请次数外部数据源通过 API 接入 latest_apply df_loan.groupby(user_id)[apply_date].max() apply_30d df_loan[ df_loan[apply_date] latest_apply - timedelta(days30) ].groupby(user_id)[loan_id].count().rename(apply_count_30d) return pd.concat([on_time_ratio, overdue_recency, apply_30d], axis1)这里有一个很容易踩的细节repay_gap_days对于还没还款的计划是 NaN计算overdue_recency时必须过滤掉pay_late 1的行否则 NaN 会传播。而overdue_recency_days里的fillna(999)是对从未逾期过的用户给一个很大的值这样模型能明确区分“从未逾期”和“很久之前逾期过”两个不同量级。apply_30d这个字段如果外部数据没有可以用平台内申请次数替代效果会弱不少但总比没有强。3.3 WOE 分箱与 IV 值筛选逻辑回归和树模型的共性预处理很多做树模型的工程师直接从原始特征进 LightGBM不去做分箱这样做没错但如果你想保留逻辑回归作为 baseline或者想给业务方解释特征的方向性WOE 编码就是绕不开的一步。WOE 的本质是把连续变量分段后用每个分段里的坏样本占比与好样本占比之比取对数替换原始值。def woe_iv_bin(df, feature, target, bins10): df df[[feature, target]].copy() df[bin] pd.qcut(df[feature], qbins, duplicatesdrop) grouped df.groupby(bin, as_indexFalse).agg( total(bin, size), bad(target, sum) ) grouped[good] grouped[total] - grouped[bad] grouped[bad_rate] grouped[bad] / grouped[total] grouped[woe] np.log( (grouped[bad] / grouped[bad].sum()) / (grouped[good] / grouped[good].sum() 1e-6) ) iv ((grouped[bad] / grouped[bad].sum()) - (grouped[good] / grouped[good].sum())) * grouped[woe] return grouped, iv.sum()注意 qcut 在数据分布极度偏斜时会把大量样本挤到同一个箱子里建议检查每个箱子的total如果某个箱子样本数小于总体的 5%要么提高bins要么改用等宽分箱。iv.sum()的取值经验是小于 0.02 的特征基本没有区分度0.02 到 0.1 属于弱特征大于 0.1 可以算强特征。但 IV 高不代表一定好用比如多头借贷这个字段在平台早期样本量小的时候可能波动特别大需要结合稳定性一起看。4. 模型选型与训练调参从逻辑回归到 LightGBM 的实战权衡4.1 为什么 LightGBM 是 P2P 风险评级的主力而深度模型是备选P2P 风险评级的数据形态是典型的“表格型数据 高缺失率 类别特征多”这种场景下深度模型DNN、GBDTLR 或 EmbeddingDNN往往不是最优选择。原因有两个。第一表格数据里的特征之间很少有像图像或文本那样的局部相关性DNN 的归纳偏置发挥不出来。第二P2P 平台的特征数量通常在几十到几百维如果样本量只有几万到几十万DNN 很容易过拟合而 LightGBM 的直方图算法和带深度限制的正则化机制能更好地控制方差。这不是说深度模型不能用。如果平台已经积累了上千万笔借款记录并且把用户的行为序列每次借款的时间、金额、用途、还款行为建模成序列数据那么 LSTM 或 Transformer 结构会比树模型有优势。但在绝大多数中小平台的量级下LightGBM 以更少的调参成本换来更高的 AUC是性价比最优的选择。我一般会同时训练一个逻辑回归和一个 LightGBM逻辑回归用于上线后的监管解释性报告LightGBM 用于线上评分主模型。LightGBM 的核心参数里num_leaves比max_depth重要。数值设置上如果特征数在 50 左右num_leaves设在 31 到 63 之间、learning_rate设 0.05 配合 500 棵树是常见的起点。feature_fraction设为 0.8bagging_fraction设为 0.8可以在几乎不损失 AUC 的情况下显著降低过拟合。import lightgbm as lgb from sklearn.model_selection import train_test_split train_data lgb.Dataset(X_train, labely_train) valid_data lgb.Dataset(X_val, labely_val, referencetrain_data) params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 47, min_data_in_leaf: 120, # 叶节点最少样本数防止学噪声 feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l1: 1.0, lambda_l2: 1.0, max_bin: 255, verbose: -1, } model lgb.train( params, train_data, num_boost_round800, valid_sets[valid_data], callbacks[lgb.early_stopping(100), lgb.log_evaluation(50)] ) feature_importance pd.DataFrame({ feature: X_train.columns, gain: model.feature_importance(gain) }).sort_values(gain, ascendingFalse)这段参数里min_data_in_leaf设在 120 是防止在小样本客群上学到极端模式。P2P 场景里用户行为特征多头借贷、还款准时率通常明显存在分层结构num_leaves47比默认值 31 更深可以捕捉更细的交互作用。但由于样本量有限再往上加叶子数 AUC 增益会快速衰减甚至反降。lambda_l1和lambda_l2同时开是经验操作表格数据里同时用两个正则化项通常比只用一个更稳。4.2 样本不均衡的三种解法权重、采样、阈值P2P 场景下坏样本占比通常在 5% 到 15% 之间低于 5% 的情况也不少见。如果不做处理LightGBM 训练出来的模型会把所有样本都预测成好客户因为这样整体准确率已经很高了。这里常用的解法有三种按优先级排序。第一种是scale_pos_weight。这个参数的推荐值是坏样本比例除以好样本比例的倒数。假设坏样本占比 10%那么scale_pos_weight (1-0.1)/0.1 9。这种做法的好处是不改变训练集分布直接在损失函数上加权。第二种是下采样好样本让好样本和坏样本比例接近 2:1 或 3:1再训练这种方式适合样本量充足的情况。第三种是 SMOTE 生成合成样本但我不推荐在信贷数据上用它因为它的插值逻辑会破坏特征之间的业务语义特别是像“收入”和“负债”这种有强相关性的字段插值后产生出不符合现实的样本反而会误导模型。实际落地时我一般先调scale_pos_weight不太够再叠加阈值移动。因为阈值移动不重新训练模型只是把默认的 0.5 决策阈值往小的调比如调到 0.3。这意味着模型预测坏概率超过 30% 的样本就拒绝或转人工。这样做的直接代价是会牺牲一部分好客户的命中率但是否接受取决于业务对坏账率的容忍底线。4.3 用 KS 和 RecallPrecision 代替单一 AUC 评估线上风险AUC 指标在学术论文里是标配但一个做线上风控的模型如果只拿 AUC 出来汇报业务方和老板很难买账。原因是 AUC 衡量的是排序能力不是绝对风险水平。比如你调整阈值后整体排序没变AUC 还是 0.78但拒绝率和放款规模变了直接影响了平台的营收和坏账。所以我习惯在训练后同时打印三样东西AUC、KS 值、每个分数段的坏账率表。KS 的算法逻辑是把预测分数按升序分成 10 组或 20 组然后算每组累积好样本占比和累积坏样本占比的最大差值。KS 超过 0.4 通常被认为是可用的模型低于 0.2 基本不可用。但注意 KS 和 AUC 高度相关不要只看 KS。每个分数段的坏账率表才是真正给业务方做决策的依据。def ks_and_lift_table(y_true, y_pred, n_bins10): df pd.DataFrame({true: y_true, pred: y_pred}) df[bin] pd.qcut(df[pred], qn_bins, duplicatesdrop) grouped df.groupby(bin, as_indexFalse).agg( total(true, size), bad(true, sum) ) grouped[bad_rate] grouped[bad] / grouped[total] grouped[cum_bad_ratio] (grouped[bad].cumsum()) / grouped[bad].sum() grouped[cum_good_ratio] ( (grouped[total] - grouped[bad]).cumsum() / (grouped[total] - grouped[bad]).sum() ) grouped[ks] abs(grouped[cum_bad_ratio] - grouped[cum_good_ratio]) return grouped, grouped[ks].max()这段代码里最容易出错的地方是pd.qcut(df[pred], qn_bins, duplicatesdrop)。当模型预测概率集中在很窄的区间比如全都落在 0.1 到 0.4分位点可能重复导致实际分出的箱数变少。处理办法是先检查grouped的total列长度如果小于n_bins就把 n_bins 改成 5 或 6。在实际项目中我见过模型预测概率在 0.3 到 0.7 区间高度集中用 10 分位切开后 KS 被高估的情况所以分箱后一定要核对样本分布不能让少数极端预测值撑起整个 KS。5. 避坑与排查P2P 风险模型上线后的 5 个血泪教训5.1 特征穿越放款后信息被当成放款前信息建模现象训练集 AUC 高达 0.85测试集 AUC 掉到 0.62。排查后发现在特征表里用了“当前逾期状态”去预测“未来是否逾期”。这属于典型的时间穿越。原因特征工程时没有区分观察时点和评估时点。比如用户逾期 30 天以上才产生的催收记录在建模时被当成申请时的特征放进了模型。解决所有特征必须限定在“截至借款评估日”之前已知的信息。做法是在构造特征时统一加一个as_of_date过滤条件凡是晚于放款日期的记录一律剔除。5.2 正负样本切分用随机抽样导致验证集失真现象本地测试 AUC 0.75上线后实际坏账率比测试预估高了两个百分点。原因用train_test_split(random_state42)随机切分数据。P2P 平台的客群质量随时间波动很大早期获客渠道做活动带来了大量低质用户随机切分把后续新增的高质量用户混进了训练集模型学到的客群分布和上线时实际客群不一致。解决强制按时间排序切分同时检查训练集和测试集的坏样本率差异如果差异超过 30%就要警惕时间漂移问题。5.3 多头借贷特征缺失率太高模型直接学会了“缺失好客户”现象多头借贷字段的缺失率 50%模型训练后这个特征的 gain 排第一但坏样本占比反而在“缺失”组里异常低。原因多头借贷数据从第三方 API 获取很多老用户没有覆盖到。模型发现缺失值对应低逾期率于是给缺失一个很高的好分数导致上线后对新用户同样缺失全部放行。解决把缺失值单独编码成 -999 或 0 等于掩码同时增加一个 is_missing 维度。如果缺失率高于 30%保守做法是直接剔除这个特征改用平台自身申请频次去替代。5.4 LightGBM 的 feature_fraction 设成 1.0模型线上波动大现象验证集 AUC 很高但每周线上客群略变模型分数分布就大幅漂移。原因feature_fraction和bagging_fraction全部关掉后模型对每棵树的特征选择没有任何随机性容易建立对个别强特征的过度依赖。这些强特征如多头借贷在第三方数据源的稳定性没法保证一旦上游数据延迟或缺失整个评分体系受影响。解决feature_fraction至少降到 0.7bagging_fraction设 0.8。同时每周监控特征分布和分数的 PSIPopulation Stability IndexPSI 超过 0.25 必须触发重训。5.5 提前还款的用户被错误标成好样本现象贷款周期 12 个月的等额本息用户在第 2 个月提前结清所有费用。还款流水中这笔贷款的还款状态为“结清”没有逾期记录被标为好样本。原因提前还款在 P2P 里不一定是好事。很多用户是同时借了多笔贷款用一笔新借款去还旧借款这种行为本质上跟“借新还旧”的循环债务模式强相关坏账风险反而更高。解决标签构造时把“实际还款日期比计划提前超过 15 天”且“结清时距离放款不足 90 天”的单子单独拆出一类要么直接不要要么单独作为一个“提前还款”标志字段喂给模型。我建议直接剔除因为它们既不满足正常好样本的定义又不完全是坏样本。6. 模型监控与冷启动重训用 PSI 和坏账率表守住上线后的三道防线模型上线不是终点它只是另一段工作的开始。P2P 平台做风险模型最常见的翻车是上线后三周内坏账率上升而监控告警机制没有触发直到财务报表显示亏损才发现。所以我在上线前会强制配置三道防线。第一道是数据质量监控核心是 PSI。做法是每天计算线上跑分用户的每个特征分布和训练集特征分布的 PSI。比如“年龄”字段线上均值 32 岁训练集均值 35 岁PSI 超过 0.2 就要查原因是获客渠道变了还是广告投放覆盖了新人群。第二道是模型分数监控看每天预测概率的均值和中位数是否在合理区间浮动如果均值从 0.35 突然跳到 0.5大概率是特征源出问题。第三道是最重要的按周生成线上放款用户的实际还款表现表把它和训练时每个分数段的坏账率表对齐。def compute_psi(expected, actual, bins10): expected pd.Series(expected, nameexpected) actual pd.Series(actual, nameactual) # 按训练集的分位边界切分保证对照公平 cuts pd.qcut(expected, qbins, duplicatesdrop) exp_perc expected.groupby(cuts).size() / len(expected) act_perc actual.groupby(cuts).size() / len(actual) psi_values (act_perc - exp_perc) * np.log(act_perc / exp_perc) # 处理分母为0的情况 psi_values psi_values.replace([np.inf, -np.inf], 0).fillna(0) return psi_values.sum() psi compute_psi(train_score, online_score)实际做 PSI 监控时有个细节cut 的边界必须固定不能每天重新用线上的分位数去切。否则线上分数整体抬高的话分位边界也跟着抬高PSI 会被掩盖。我一般把训练集的切分边界序列化保存到配置文件里线上监控任务加载同一份边界去算每天的 PSI。如果 PSI 连续两天超过 0.25我会直接触发重训流程。P2P 平台的客群和行业政策变化节奏很快季度级别的固定重训根本跟不上变化。我习惯用滚动训练每个月把最近 12 个月的数据重新跑一遍特征脚本和训练脚本当前模型持续服务到新模型验证通过再切换。切换之前必须检验新模型在最近 1 个月的数据上 KS 不低于旧模型同时坏账率表在每个分数段都不恶化。模型服务接口上线时做灰度切流5% 流量跑新模型观察 7 天坏账率再放开。最后提一个大多数团队会忽略的小习惯保留每一版模型的训练数据切分版本、特征列表、特征计算代码的 commit id形成模型卡片。因为 P2P 平台跟银行不同没有长期稳定的大数据集群数据可能会因为存储过期被清理。等三个月后你想复盘某次模型效果为什么变差时发现当时的数据和特征代码都找不回来了那种感觉比模型线上翻车还要糟。我曾经因为一个上游字段口径变化导致分数分布偏移花了整整一周对比历史数据才定位到原因从那以后所有模型版本我强制要求提交一个包含数据和代码版本的 JSON每次重训前都核对。这个习惯帮我在后续的排查里省下了大量时间也希望帮到你。本文还有配套的精品资源点击获取