简介:面向金融风控研究者与机器学习初学者的《机器学习算法在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_days=15, current_overdue_days=30): # 计算每一笔借款的首逾标志 df = df_loan.merge(df_repay, on='loan_id', how='left') 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(days=30) 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_col='label', train_ratio=0.8): df = df.sort_values('loan_date').reset_index(drop=True) 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, columns=features) X_test = pd.DataFrame(test_scaled, columns=features) 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, on='loan_id', how='left') 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(days=30) ].groupby('user_id')['loan_id'].count().rename('apply_count_30d') return pd.concat([on_time_ratio, overdue_recency, apply_30d], axis=1)这里有一个很容易踩的细节: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, bins=10): df = df[[feature, target]].copy() df['bin'] = pd.qcut(df[feature], q=bins, duplicates='drop') grouped = df.groupby('bin', as_index=False).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、GBDT+LR 或 Embedding+DNN)往往不是最优选择。原因有两个。第一,表格数据里的特征之间很少有像图像或文本那样的局部相关性,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.8,bagging_fraction设为 0.8,可以在几乎不损失 AUC 的情况下显著降低过拟合。
import lightgbm as lgb from sklearn.model_selection import train_test_split train_data = lgb.Dataset(X_train, label=y_train) valid_data = lgb.Dataset(X_val, label=y_val, reference=train_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_round=800, 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', ascending=False)这段参数里min_data_in_leaf设在 120 是防止在小样本客群上学到极端模式。P2P 场景里用户行为特征(多头借贷、还款准时率)通常明显存在分层结构,num_leaves=47比默认值 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 和 Recall@Precision 代替单一 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_bins=10): df = pd.DataFrame({'true': y_true, 'pred': y_pred}) df['bin'] = pd.qcut(df['pred'], q=n_bins, duplicates='drop') grouped = df.groupby('bin', as_index=False).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'], q=n_bins, duplicates='drop')。当模型预测概率集中在很窄的区间(比如全都落在 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_state=42)随机切分数据。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.7,bagging_fraction设 0.8。同时每周监控特征分布和分数的 PSI(Population Stability Index),PSI 超过 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, bins=10): expected = pd.Series(expected, name='expected') actual = pd.Series(actual, name='actual') # 按训练集的分位边界切分,保证对照公平 cuts = pd.qcut(expected, q=bins, duplicates='drop') 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,每次重训前都核对。这个习惯帮我在后续的排查里省下了大量时间,也希望帮到你。
本文还有配套的精品资源,点击获取