Overview

互联网金融行业主要涉及以下方面:保险、理财、基金、信贷、虚拟信用卡、催收等,主要用到以下类型的模型:

  • 营销模型、召回模型、排序模型(支付宝蚂蚁财富基金推荐红包发放等)
  • 新用户反欺诈模型
  • 新老用户信用额度模型(蚂蚁借呗,腾讯微粒贷,京东金条,微博钱包,美团借钱,各类小贷等)
  • 虚拟信用卡贷中风控模型(蚂蚁花呗,京东白条等)
  • 贷后催收模型

我在互联网金融行业已经工作两年了,也积累了不少信贷数据特征挖掘和信贷模型开发经验。现在将这些经验记录一下,算是对两年时间的一个总结。总体上,这个领域和其他领域在机器学习模型构建的流程上区别不大,主要精力都是在特征挖掘和模型构建上。

1. 特征挖掘

显而易见,这个领域,用户的资产和负债数据是最有效的数据,但是除了银行或者蚂蚁金服和京东金融等大金融平台之外,我们一般情况下很难拿到用户的资产数据,我们最多能拿到用户的第三方征信数据,即用户负债数据(国内常见为同盾,百融,百行征信等),结合用户授权我们爬取的用户的通讯录,APP安装列表,淘宝京东APP订单数据等,以及用户在本公司的借贷历史行为记录和用户登录APP的埋点数据,我们可以做出很多特征,三方多头征信特征是最有效的,其次对于老用户,用户的借贷历史行为也很有效。其中,时间序列数据是特征工程的重头戏,对于和时间相关的订单类,行为类等数据,我们可以用类似的方法开发出一大批时间序列特征,其形式均为“某时间段内 + 某种行为发生的次数/金额/比例/时长间隔/持续时间 + 最值/q1/q2/q3/均值/众数/中位数”,例如"三个月内用户在本平台贷款金额序列的均值"。这部分特征由三部分组成:

  1. 时间长度
  2. 行为类型
  3. 统计量

另外,用户授权我们拿到他们的通讯录之后,我们可以通过他的一二度人脉在本公司的表现,来做关系图特征。我们取得用户的APP安装列表之后,可以将APP按照风险等级给分为3或者4类:714高炮类,小贷类,普通消费金融与正常金融类,学习以及其他正向类。另外,如果是贷后催收模型,还需要加入用户的催收表现特征。

2. 特征处理

2.1 样本不均衡问题

我们在业务上遇到的很多数据,几乎都是正负样本不平衡的,一般情况下是好样本数量远大于坏样本,用户客群质量越高,样本差异越大。小贷平台好坏样本比例一般在3:120:1之间,大平台差异更大,这样设计一个课题:样本不均衡问题
样本不均衡时,我们有以下这些处理方式(通用方式,不只信贷领域),但基本思想就是"再缩放(rescaling)"

  1. 较多类欠采样
  2. 较少类过采样
  3. 预测时阈值移动
  4. 合成数据或无label样本学习
  5. 权重缩放或惩罚权重
  6. 异常检测的方式(如果好坏样本数量悬殊,例如欺诈检测)
  7. 组合/集成学习

个人以为,我们在信贷工作当中,业务样本真实性还是很重要的,因此,不建议用SMOTE等方法进行插值产生坏样本。当然像图像,语音,文本等具象的数据,人可以轻松判断正负样本的领域除外。

2.2 空值问题

我们的特征中是有不少空值的,我们可以进行零值填充、均值填充或者众数填充,不过我们一般不会对这些空值进行填充,因为填充大概率会引入误差。另外,某些机器学习算法,其思想是计算距离来划分类别,例如SVMKNN,对于这些算法,有空值的情况是不适合的,填充空值也是不适合的,因为填充不同的数据会对结果产生较大影响。而且,我们业务上特征维度是非常高的,即便进行了特征选择和降维,因此有很多算法效率是很低的,不推荐使用,这是后话。
因此,我们原则上,保留空值不处理。

2.3 类别特征处理

类别特征大家一般都是做one-hot处理,因为大部分算法是只支持数字型特征的。但是有少部分算法是支持类别型特征,而不用做独热编码的,例如LightGBM

2.4 归一化和标准化

归一化和标准化都属于无量纲化,其主要目的就是为了避免数值问题和加快模型收敛速度。有些模型最好要进行无量纲化,例如线性回归SVMkNN逻辑回归k-Means神经网络等;而XGBoostlightGBM朴素贝叶斯决策树随机森林等算法通常是不需要无量纲化的。
归一化和标准化之前的文章数据挖掘之数据标准化(Normalization)写过,这里就不赘述了。

2.5 特征选择和降维

特征选择的主要作用是去掉冗余的特征加速学习过程和减轻过拟合,降维也有类似的目的。但是某些降维算法使用之后,得到的低维度特征和降维之前的特征的量纲就发生了变化,模型解释性会受到影响,因为我们不知道得到的特征是什么意义。这是降维和特征选择的一点区别。而且,我们在业务当中,可能同类型的三方数据会有好几种,这些数据效果相差无几,同时使用也不会有更多增益,所以我们需要选择合适的特征,以节约数据成本。
特征选择主要有以下三类:

  1. 过滤式
  2. 包裹式
  3. 嵌入式

降维有两大类:

  1. 无监督降维(PCA等)
  2. 有监督降维(LDA等)

降维这个领域,Chris博士有较深研究。

2.6 特征评估

我们如果选择好了特征,想知道哪些特征对模型作用更大,除了训练模型之后查看gainweight,也可以在训练模型之前用以下几个指标去初步判断一下:

  • WOE
  • IV
  • 单特征AUC
  • 单特征KS

3. 模型构建

3.1 数据集准备

互联网金融领域受政策,市场,经济等外部因素影响较大,因此客群变化也是很快的。例如2018年1月左右和2019年3月15日左右,受政策影响,互联网金融市场发生两次“风暴期”,导致坏账率急剧提高,因为我们选用样本的时候要考虑这些因素。
我们离线跑机器学习模型,一般情况下会将数据集按时间先后顺序划分为:训练集,验证集和测试集。此外,还要准备最新的业务数据(尚未产生表现的样本)作为回归集以作为监控模型稳定性的benchmark原则上,我们最好保持几个数据集的客群分布一致,即其间未发生重大市场震荡和较大业务调整。如果不能满足以上条件,我们至少需要满足验证集,测试集,回归集和当前线上业务的用户分布一致。且训练集可以抽样,验证集和测试集最好不要抽样。
经典的训练集,验证集和测试集的划分为7 : 1.5 : 1.5,但是如果我们有几十万到几千万条样本,再这样按比例划分就不太合适了。我们最好使验证集和测试集保证足够的正样本和负样本,有稳定的统计学表现,将偶然性降至合理范围。且原则上如果机器性能足够,我们建议保留所有训练集而不采样,这样会尽最大可能保留样本的多样性,带来的不平衡问题可以用合适的机器学习算法包来解决(例如SVMXGBoost算法工程实现中分别有class_weightscale_pos_weight等参数调整样本权重)。
另外,信贷领域label通常是这样定义的:该笔贷款逾期天数是否超过n天,n可以选择0,3,7,30等等。

3.2 机器学习算法选择

如果业务不是新开展的,那么我们一般要沿用前人使用的算法,之后再慢慢迭代尝试同类型更好的算法(XGBoost替换为lightGBMCatBoost),或者数据量积累足够大时尝试神经网络类算法。如果是新开展的业务,那我们可以这么选择:

  1. 样本规模较小,选SVM(不适合稀疏数据)
  2. 样本规模适中,XGBoostlightGBMCatBoost
  3. 样本规模较大,XGBoostlightGBMCatBoost神经网络

互联网金融领域,XGBoost还是比较流行的算法,包括蚂蚁金服,京东金融,360金融都在大规模使用。我们下面就以此算法为例,做记录。

3.3 过拟合与欠拟合

过拟合和欠拟合是建模当中经常出现又非常重要的知识点,过拟合出现更多一些。我们主要有以下手段去缓解过拟合和欠拟合的影响。

3.3.1 过拟合

过拟合就是模型能够很好的拟合训练集,但是对于验证集和测试集的泛化效果非常差,这种情况也叫高方差。

  • 更多训练样本
  • 特征选择
  • 减小模型规模,修改模型架构
  • L1L2正则化
  • early stop
  • dropout
  • 权重衰减
  • 加入噪音
  • bagging集成

3.3.2 欠拟合

欠拟合就是模型对训练集不能很好拟合,这种情况也叫高偏差。

  • 增加模型规模,修改模型架构
  • 挖掘更好的特征
  • 增加特征
  • 较小正则化

值得注意的是,欠拟合和过拟合在实际任务当中有可能会同时存在的。

3.4 XGBoost调参训练demo

demo不含任何敏感数据。

# 引入数据处理和模型训练必须的包
import numpy as np
import pandas as pd
import xgboost as xgb

# 加载特征列表
name_list = pd.read_csv('feature_list.txt', header=None, index_col=0)
my_feature_names = list(name_list.transpose())
print(len(my_feature_names))

# 加载样本
df_total = pd.read_csv('data_total.csv')
print(df_total.shape)

# 划分数据集
print(df_total.apply_time.min())
print(df_total.apply_time.max())

df_train = df_total[df_total.apply_time < '2020-01-21 00:00:00']
df_val = df_total[(df_total.apply_time >= '2020-01-21 00:00:00') & (df_total.apply_time < '2020-02-01 00:00:00')]
df_test = df_total[df_total.apply_time >= '2020-02-01 00:00:00']

df_train.label.mean()
df_val.label.mean()
df_test.label.mean()

# 数据处理
train_x = df_train[my_feature_names]
train_y = df_train.get('label')
val_x = df_val[my_feature_names]
val_y = df_val.get('label')
test_x = df_test[my_feature_names]
test_y = df_test.get('label')

class_weight = (1-train_y.mean())/train_y.mean() #负样本(0样本)与正样本(1样本)的数量比
data_train_X = train_x.astype(float)
data_val_X = val_x.astype(float)
data_test_X = test_x.astype(float)

xgb_train = xgb.DMatrix(data_train_X, label=train_y)
xgb_val = xgb.DMatrix(data_val_X, label=val_y)
xgb_test = xgb.DMatrix(data_test_X, label=test_y)

将我们需要的数据处理完成之后,就可以导入模型进行调参了。
调参方式有很多种,比如网格搜索和贝叶斯调参等,我这里就用我经常用的贝叶斯调参,因为XGBoost参数较多,用网格搜索不太划算。

import functools
from bayes_opt import BayesianOptimization

scale_pos_weight_ratio = class_weight

from sklearn.metrics import roc_curve
# 计算KS
def ks_measure(preds, label):
    fpr, tpr, thresholds = roc_curve(label, preds)
    threshold = None
    if threshold is None:
        score = np.max(np.abs(tpr - fpr))
    else:
        idx = np.digitize(threshold, thresholds) - 1
        score = np.abs(tpr[idx] - fpr[idx])
    return  score

def new_eval_xgb_ks(is_cv, metrics, X_train, y_train, X_validation, y_validation, X_test, y_test,eta, gamma, max_depth, min_child_weight,
                subsample,
                colsample_bytree, alpha,lambda_s):
    '''
    我们用验证集效果最好的参数作为最终训练的参数
    '''
    params = dict()
    params['eta'] = eta
    params['gamma'] = gamma
    params['max_depth'] = int(max_depth)
    params['min_child_weight'] = min_child_weight
    params['subsample'] = subsample
    params['colsample_bytree'] = colsample_bytree
    params['alpha'] = alpha
    params['lambda'] = lambda_s
    params['silent'] = 1
    params['seed'] = 696
    params['objective'] = 'binary:logistic'
    params['scale_pos_weight'] = scale_pos_weight_ratio
    params['eval_metric'] = ['auc']
    n_estimators = 800
    print(params)

    # 是否对这组参数做cross validation
    if is_cv:
        pass
    else:
        dtr = X_train
        dva = X_validation
        eval_list = [(dtr,"train"), (dva,"validation")]
        clf = xgb.train(params, dtr, n_estimators, eval_list, verbose_eval=30, early_stopping_rounds=20)
        y_pred = clf.predict(dva, ntree_limit=clf.best_ntree_limit)
        dtest = X_test
        y_pred_test = clf.predict(dtest, ntree_limit=clf.best_ntree_limit)
        
        # 用ks选参
        if metrics == "ks":
            result = ks_measure(y_pred, y_validation)
            print("test ks: ", ks_measure(y_pred_test, y_test))
    return result

def new_model_selection_bayes_opt(X_train, y_train, X_validation, y_validation, X_test, y_test,classifier='xgb', metrics="ks",
                              is_cv=False):
    num_iter = 150
    init_points = 50
    if classifier == 'xgb':
        eval_xgb_ks_part = functools.partial(new_eval_xgb_ks, is_cv, metrics, X_train, y_train, X_validation, y_validation, X_test, y_test)
        classifierBO = BayesianOptimization(eval_xgb_ks_part, {'eta': (0.01, 0.5),
                                                               'gamma': (0, 20),
                                                               'max_depth': (3, 5),
                                                               'min_child_weight': (1, 200),
                                                               'subsample': (0.6, 1),
                                                               'colsample_bytree': (0.6, 1),
                                                               'alpha': (1, 100),
                                                                'lambda_s':(1,200)})
        classifierBO.maximize(init_points=init_points, n_iter=num_iter)
        best_params = classifierBO.max['params']
        best_val = classifierBO.max['target']
        print(best_val)
        print(best_params)
        return classifierBO

classifier_max = new_model_selection_bayes_opt(xgb_train, train_y, xgb_val, val_y, xgb_test, test_y, classifier='xgb')

调参结束之后,我们查看验证集上最好的表现和对应的参数。

target_bst = classifier_max.max['target']
params_bst = classifier_max.max['params']

然后开始训练模型:

# 参数
params = {
    'booster': 'gbtree', 
    'objective': 'binary:logistic',
    'alpha': params_bst['alpha'],
    'colsample_bytree': params_bst['colsample_bytree'],
    'eta': params_bst['eta'],
    'gamma': params_bst['gamma'],
    'lambda': params_bst['lambda_s'],
    'max_depth': int(params_bst['max_depth']),
    'min_child_weight': params_bst['min_child_weight'],
    'subsample': params_bst['subsample'],
    'silent': 1, 
    'seed': 696, 
    'scale_pos_weight': scale_pos_weight_ratio, 
    'eval_metric': ['auc']
}
num_boost = bst_num_boost  # 从输出日志中可以找到最好的树棵数 

watchlist = [(xgb_train, 'train'), (xgb_val, 'val'), (xgb_test, 'test')]

# 训练
model = xgb.train(params, xgb_train, num_boost, watchlist)
# 保存模型
model.save_model('result_model.model')  # 用于存储训练出的模型

评估模型,我们一般用KS,其实AUC也可以,但是不如KS通用。KS的主要意义就是评估模型对好坏用户的区分度,值越高,区分度越好。相应的,我们的信用额度策略同事会利用我们的模型打分,对不同区间的用户赋予不同的额度,以实现业务利润最大化。

评估函数如此定义

import numpy as np
import pandas as pd
from sklearn.metrics import roc_curve, auc

def model_evaluation(y, y_pred):
    """
    输入真实的y和预测的y的概率
    输出ks和auc
    """
    result = {}
    result['ks']= ks_score(y, y_pred)
    result['auc'] = auc_score(y, y_pred)
    return result

def ks_score(y, y_pred, threshold=None):
    """
    计算KS
    """
    fpr, tpr, thresholds = roc_curve(y, y_pred)
    if threshold is None:
        score = np.max(np.abs(tpr - fpr))
    else:
        idx = np.digitize(threshold, thresholds) - 1
        score = np.abs(tpr[idx] - fpr[idx])
    return score

def auc_score(y, y_pred):
    """
    计算AUC
    """
    fpr, tpr, thresholds = roc_curve(y, y_pred)
    roc_auc = auc(fpr, tpr)
    return roc_auc

train_pred = model.predict(xgb_train)
val_pred = model.predict(xgb_val)
test_pred = model.predict(xgb_test)

model_evaluation(train_y, train_pred)
model_evaluation(val_y, val_pred)
model_evaluation(test_y, test_pred)

如果验证集和测试集KS接近,在0.2以上,且和训练集的KS差距不大,那么这个模型的效果就是可以接受的。当然实际工作当中,我们评估的步骤更加严格,我们需要看特征重要性,看看排名在前面的特征是不是符合我们的认知,单特征的表现以及单特征分bin表现等等。

3.5 模型预测

我们模型保存之后,在线上应用,需要这样导入并预测。

model = xgb.Booster()
model.load_model('result_model.model')
reg_pred = model.predict(xgb_reg)  # 假设xgb_reg是回归集处理过的数据
online_pred = model.predict(xgb_online)  # 假设xgb_online是上线后的数据集处理过的数据

我们可以将回归集的预测结果reg_pred保存下来,然后在线上监控模型预测的稳定性,主要指标是PSI

def psi(bench, target, group=None):
    if group is None:
        group = int(len(bench)**0.5)
    labels_q = np.percentile(bench,[(100.0//group)*i for i in range(group + 1)],interpolation = "nearest")

    ben_pct = (pd.cut(bench, bins = np.unique(labels_q),include_lowest = True).value_counts())/len(bench)
    target_pct = (pd.cut(target,bins = np.unique(labels_q),include_lowest = True).value_counts())/len(target)
    target_pct = target_pct.sort_index() 
    ben_pct = ben_pct.sort_index() 
    psi = sum((target_pct - ben_pct)*np.log(target_pct/ben_pct))
    
    return psi

评估模型稳定性:

psi(reg_pred, online_pred, group=10) # 10或者20都可以,但是得保证每个bin的数量充足

如果psi < 0.1那么表示模型稳定,一般会远小于0.1

3.6 多模型融合

实际业务当中,模型的效果还是可以有更大的提升的,这种技术手段就是多模型的融合(stacking)。关于模型融合,可以有这样两种方式:

3.6.1 不同类型的样本产生的多个模型进行融合

例如我们的业务当中,多条不同的产品线有可能会有一定的用户重合,我们可以将其他不同产品线用户样本作为不同的训练集,验证集和测试集则使用相同的本产品线的样本。这样,产生的不同的模型,对相同的测试集进行预测,生成的预测结果(概率值)作为一个新的逻辑回归模型(或者别的模型)的训练集,再进行模型训练。

3.6.2 不同类型的机器学习算法产生的多个模型进行融合

这种情况可能在kaggle等比赛当中更常见一些。相同的训练集,用不同的机器学习算法建模(最好是不同类型的算法,例如用了XGBoost之后最好就不要用LightGBM了),然后用训练好的模型对相同的测试集进行预测,生成的预测结果(概率值)作为一个新的逻辑回归模型(或者别的模型)的训练集,再进行模型训练。

当然以上两种方式可以结合使用,进一步提升模型效果。