简介:一套围绕CCF「企业非法集资风险预测」赛题的完整算法源码与配套数据包,面向机器学习参赛者、金融风控学习者和相关毕设/项目开发人员,聚焦企业多维数据下的风险建模与预测。资源共27个文件,包含22个csv数据文件、2个Python脚本、1个Jupyter笔记本和2个Markdown说明文档,压缩包整体19.96MB;csv覆盖企业基本信息、年报、税务、新闻、变更等维度,py/ipynb用于数据探索与模型构建,md文件辅助理解赛题思路。目前已有80人学习,适合从零复现赛题流程,也可作为毕业设计或风险预测项目的实战蓝本。从数据清洗、特征工程到模型训练与结果提交,整个pipeline均有完整源码呈现,目录中将数据、代码和多次submit输出分开存放,便于横向对比不同特征或参数的效果,并支持基于源码深入研究特征处理方法、模型调优与评估策略。
1. CCF企业非法集资风险预测,比的不是模型结构而是数据边界
企业非法集资风险预测这类比赛,圈内人一看到题目就知道:特征工程决定上限,模型只是把上限兑现的工具。CCF赛题给出的企业数据通常包含工商注册、经营异常、司法涉诉、变更记录等异构信息,标签则是“是否涉及非法集资”的二分类。难点不在算法本身,而在标签稀疏、噪声大、时间穿越极易发生——很多人线下AUC刷到0.95,线上直接崩到0.7,原因就是验证集划分时把未来信息泄漏进了训练集。
这篇文章按一条完整的技术链路展开:先解决特征怎么构造才不出穿越,再给出可复现的LightGBM基线脚本,然后讲参数调整、交叉验证和排错手段,最后落到阈值寻优与模型融合这两个提分技巧上。适合准备CCF金融风控类赛题、或正在做企业风险画像的算法工程师参考。中间所有代码都是可直接复制运行的形态。
2. 企业非法集资风险预测的特征构造:先防时间穿越,再谈特征重要性
2.1 原始数据里隐含的三个信息层级
非法集资企业的行为模式有几个显著特征:注册时间短但参保人数异常、法人频繁变更、经营范围与实际业务不符、关联企业之间存在异常资金往来。这些信号不会直接出现在某张表里,需要跨表聚合。
我一般把特征分为三个层级。第一层是单表统计量,比如企业在每个时间截面上的注册资本、成立年限、参保人数、行政处罚次数。第二层是跨表关联特征,比如一家企业关联的法人名下有几家高风险企业、同一注册地址出现了多少家新注册公司。第三层是时序衍生特征,比如近12个月的变更次数斜率、司法案件的累积速度。CCF这类比赛的数据通常已经做了脱敏和初步整理,但字段语义可能被编码成匿名ID,这时要先去读数据字典,理解每一张表的主键和关联方式。
反直觉的一点是:这种比赛里,最有效的往往不是复杂图神经网络,而是基于业务规则构造的交叉特征。法人变更后6个月内是否发生经营异常、注册资本与实缴资本差额是否超过某个阈值、参保人数为0但开票金额巨大——这类“业务规则型特征”既稳定又可解释,在AUC上的增益经常超过直接堆Embedding。
2.2 用Python做时序安全的特征聚合
写特征工程代码的第一步,是确认每张表的时间字段和主键。假设数据里有 enterprise_basic(企业基础信息表)、enterprise_change(变更记录表)、enterprise_case(涉诉表),主键都是 enterprise_id,核心代码如下:
import pandas as pd import numpy as np # 读取数据,time字段一律先转成datetime类型 basic = pd.read_csv('enterprise_basic.csv', parse_dates=['reg_time']) change = pd.read_csv('enterprise_change.csv', parse_dates=['change_time']) case = pd.read_csv('enterprise_case.csv', parse_dates=['case_time']) # 训练集标签:每个企业只有一条记录,标签在另一个文件里 train = pd.read_csv('train_label.csv') label_time = pd.read_csv('train_label.csv', parse_dates=['label_date']) # 对每张行为表做时序聚合:只取标签日期之前360天的数据 def time_window_agg(df, date_col, entity_col, features, window=360): merged = train[['enterprise_id', 'label_date']].merge( df, on=entity_col, how='left' ) merged = merged[(merged[date_col] <= merged['label_date']) & (merged[date_col] >= merged['label_date'] - pd.Timedelta(days=window))] # 用groupby做计数/去重统计 agg_df = merged.groupby(entity_col).agg( change_count=('change_id', 'count'), unique_change_type=('change_type', 'nunique'), case_count=('case_id', 'count'), ).reset_index() return agg_df # 构造变更表和涉诉表的窗口特征 change_features = time_window_agg(change, 'change_time', 'enterprise_id', ['change_id', 'change_type']) case_features = time_window_agg(case, 'case_time', 'enterprise_id', ['case_id']) # 合并基础信息和聚合特征,缺失值填充-1,与正常0值区分 feature = basic.merge(change_features, on='enterprise_id', how='left') feature = feature.merge(case_features, on='enterprise_id', how='left') feature.fillna(-1, inplace=True)这段代码里最核心的逻辑是label_date与行为时间字段的比较。time_window_agg函数只保留标签日期前360天内的行为记录,这从机制上杜绝了特征泄漏。fillna(-1) 是这类比赛的标准做法——缺失值不填充0,因为0在业务上可能代表“无记录”,而-1明确表示“该窗口内没有这个实体的数据”,树模型可以学到这个分叉。
特征构造阶段的另一个要点是:窗口宽度值得反复试验。360天和720天刻画的是不同风险周期。非法集资企业的“快进快出”特征——成立后短期内大量异常行为——需要用较短窗口捕捉。如果某个特征在你的数据集上重要性始终排不进前30,不要急着删,先看看是不是时间窗口选得不合理。
2.3 构造企业关联网络特征
企业之间的关联是非法集资风险的重要信号。常见的关系有:法人相同、股东相同、注册地址相同、联系电话相同。这些关系可以构造出一个企业关联图,然后提炼每个节点的网络特征。
import networkx as nx # edge_list: 两个企业ID对,表示存在某种关联 edges = pd.read_csv('enterprise_edges.csv') G = nx.from_pandas_edgelist(edges, 'src_id', 'dst_id') # 提取每个企业的网络拓扑特征 network_features = pd.DataFrame({ 'enterprise_id': list(G.nodes()), 'degree': [d for _, d in G.degree()], 'clustering': list(nx.clustering(G).values()), # PageRank值体现了企业在关联网络中的枢纽程度 'pagerank': list(nx.pagerank(G, alpha=0.85).values()), })关联网络特征的原理是:非法集资通常是以团伙形式运作,一个高风险企业往往与多个同类企业在法人、地址、电话号码上重叠,于是它在图中会呈现高PageRank或高聚类系数的特征。不过这类特征要先评估覆盖率,假设只有30%的企业能关联到边,剩下70%需要用一个常量填充,否则模型会把这个特征当成缺失比例信号。
3. 构建CCF非法集资风险预测的LightGBM基线:损失函数与样本权重怎么设
3.1 为什么选LightGBM而不是深度模型
企业非法集资风险预测的数据形态以表格为主,特征维度通常在几百到几千的量级,样本量从几万到几十万不等。用深度模型不是不行,但需要大量调参,而且很难超过认真调过的Gradient Boosting模型。
LightGBM作为梯度提升树框架,优势在于:原生支持类别特征、训练速度快、内置正则化项、对缺失值有专门的分布策略。在结构化的风控赛题里,LightGBM和XGBoost长期占据榜首方案,原因就是它们在表格数据上对小样本、稀疏特征、非线性关系处理得足够好。
还要考虑评价指标。非法集资风险预测通常使用AUC(ROC曲线下面积)作为核心评价指标,有的赛题会用KS或F1做补充。AUC对样本不平衡相对稳健——正样本占1%时,AUC依然能给出有效区分度。LightGBM的训练目标可以选择binary:logistic,预测输出是概率值,后续可以再根据业务需要调整阈值。
3.2 一个可以直接跑的基线脚本
下面给出完整的训练脚本,包含参数配置、五折交叉验证和特征重要性输出:
import lightgbm as lgb from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score # 假设 feature 是特征矩阵,train['label'] 是标签 X = feature.drop(['enterprise_id', 'label'], axis=1) y = train['label'] params = { 'objective': 'binary', 'metric': 'auc', 'boosting_type': 'gbdt', 'learning_rate': 0.03, 'num_leaves': 63, 'max_depth': 7, 'min_child_samples': 30, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 1, 'lambda_l1': 1.0, 'lambda_l2': 1.0, 'verbose': -1, } skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) oof_pred = np.zeros(len(X)) feature_importance = np.zeros(X.shape[1]) for fold, (train_idx, valid_idx) in enumerate(skf.split(X, y)): X_train, X_valid = X.iloc[train_idx], X.iloc[valid_idx] y_train, y_valid = y.iloc[train_idx], y.iloc[valid_idx] d_train = lgb.Dataset(X_train, label=y_train) d_valid = lgb.Dataset(X_valid, label=y_valid) model = lgb.train( params, d_train, num_boost_round=3000, valid_sets=[d_valid], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(200)] ) oof_pred[valid_idx] = model.predict(X_valid, num_iteration=model.best_iteration) feature_importance += model.feature_importance(importance_type='gain') / 5 print(f'OOF AUC: {roc_auc_score(y, oof_pred):.5f}') # 输出特征重要性排前20 importance_df = pd.DataFrame({ 'feature': X.columns, 'gain': feature_importance }).sort_values('gain', ascending=False) print(importance_df.head(20))参数里几个关键点说明:num_leaves=63对应约6层深度的树容量,风控数据噪声大,太大的叶子数容易过拟合。feature_fraction=0.8每次迭代随机选择80%的特征,降低特征间共线性带来的影响。lambda_l1和lambda_l2同时设为1.0,对高维稀疏特征有压缩作用。early_stopping=100的意思是验证集AUC连续100轮不提升就停止训练,避免无效迭代。
第一次跑基线时,OOF AUC在这个数据集上通常落在0.82到0.88之间。如果低于0.8,优先检查两个地方:是不是存在时间穿越,或者类别特征的编码方式有问题。如果高于0.93,反而要警惕,去抽查一下是不是某些特征与标签存在直接映射关系,比如标签本身泄露在特征中。
3.3 样本不平衡的应对:权重调整优于过采样
非法集资企业的占比可能只有1%到5%,这种极度不平衡会让模型偏向把所有样本都预测为负类。处理方式有多种,最常见的是两种:一是设置scale_pos_weight,二是给训练样本加权重。
# 计算正负样本比例,作为scale_pos_weight参数 neg_count = (y == 0).sum() pos_count = (y == 1).sum() params['scale_pos_weight'] = neg_count / pos_countscale_pos_weight的原理是让梯度在正样本上被放大,等于正样本复制了若干份,从而让模型更关注这些少数样本。另一种做法是直接在Dataset里传入sample_weight,给正样本一个固定倍数的权重。这两个方案实际效果差异不大,建议先试scale_pos_weight,因为不需要额外维护权重列。
比采样策略更重要的是分层交叉验证。StratifiedKFold保证每一折里正负样本比例与全量一致,避免某一折全是负类导致训练不稳定。CCF赛题的复现中经常看到有人用普通K折,这对不平衡数据来说是潜在陷阱。
4. 从线下验证到线上稳定的调参顺序与排错清单
4.1 参数调整的正确顺序
LightGBM参数多,很容易陷入盲目搜索。我习惯按以下顺序调参:
- 先调
num_leaves和max_depth,确定模型的容量边界。用固定的learning_rate=0.05,在num_leaves的候选值[31, 63, 127]里交叉验证,先找到AUC不再明显提升的容量上限。 - 再调采样比例。
feature_fraction从0.6到0.9、bagging_fraction从0.7到0.9,这一步的目的是在模型容量确定后控制方差。 - 最后调正则化系数。
lambda_l1和lambda_l2从0到10做小范围网格搜索,步长可以设为1.0。正则化过强会让特征重要性分布过于均匀,不要为了降方差牺牲太多偏差。
这个方法的好处是可以减少参数组合指数爆炸。很多参赛者一上来就用网格搜索同时调八个参数,调一整天结果还不如调好学习率和叶子数。
4.2 线上与线下不一致的典型原因
CCF比赛中的常见事故是:本地五折AUC是0.91,线上只有0.83。这种偏差通常来自以下三个原因:
第一个原因,测试集的时间分布与训练集不同。训练数据覆盖2015到2020年,测试集可能是2017到2021年,经济环境变化导致特征分布漂移。排查方法是把训练集按年份分组,分别计算每个年份的AUC,看模型在最近年份上是否退化。
第二个原因是数据的缺失比例差异。测试集中的某些字段可能缺失率远高于训练集。如果用 -1 填充缺失值,模型会把 -1 当作有效信号,一旦测试集缺失模式改变,模型输出立刻崩盘。建议训练时加入一个“是否缺失”的二值特征,让模型自己决定缺失是否重要。
第三个原因是采样噪声。正样本数量少,五折划分的随机种子对结果影响大。如果换一个random_state,AUC波动超过0.02,说明数据本身不稳定。标准做法是固定随机种子,然后跑三次,取OOF AUC的中位数作为基准。下面这段代码用多次重复来估计稳定线:
auc_list = [] for seed in [42, 2024, 2025]: skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=seed) fold_auc = [] for train_idx, valid_idx in skf.split(X, y): # 训练与预测代码与第3节相同,省略 fold_auc.append(roc_auc_score(y.iloc[valid_idx], pred)) auc_list.append(np.mean(fold_auc)) print(f'AUC mean: {np.mean(auc_list):.5f}, std: {np.std(auc_list):.5f}')如果标准差大于0.015,需要回到特征工程层面,寻找更稳健的聚合特征,而不是继续调参。
4.3 用特征重要性反向检查特征工程
gain类型的特征重要性反映的是该特征在树分裂时带来的平均增益。通过查看重要性分布,可以发现两类问题:一是某个“规则型特征”重要性异常高,说明它和标签之间存在过强的线性关系,需要检查是否泄漏;二是业务上认为重要的特征(比如涉诉次数)排名靠后,说明特征构造的粒度不对,可能过度聚合丢失了信息。
一个值得尝试的动作是:把连续特征分箱后与标签做分组统计,观察单调性。
# 将涉诉次数组装成区间,查看每个区间内的正样本率,验证单调性 case_rate = feature[['case_count', 'label']].copy() case_rate['case_bin'] = pd.qcut(case_rate['case_count'], q=5, duplicates='drop') rate_group = case_rate.groupby('case_bin', observed=True)['label'].agg(['mean', 'count']) print(rate_group)特征分箱与标签的关系可视化之后会发现规律:往往是在某个特定区间内,风险率突然抬升,而不是线性递增。此时可以手工构造一个“是否超过某阈值”的二值特征,树模型会更容易捕捉这种跳变。
5. 阈值寻优与模型融合:CCF非法集资风险预测的实战提分点
5.1 用约登指数选择最优分类阈值
模型输出的是概率,不是类别。把概率默认按0.5切分,在不平衡数据上等于把所有企业都判为正常。非法集资场景更加关注召回率——漏掉一个风险企业,后果比误报严重得多。常见做法是搜索阈值,让约登指数(Youden's J)最大,也就是让敏感度 + 特异度 - 1最大化。
from sklearn.metrics import roc_curve fpr, tpr, thresholds = roc_curve(y, oof_pred) youden_index = tpr - fpr best_idx = np.argmax(youden_index) best_threshold = thresholds[best_idx] print(f'Best threshold: {best_threshold:.4f}') print(f'TPR: {tpr[best_idx]:.4f}, FPR: {fpr[best_idx]:.4f}')这个阈值是线下OOF上的最优解,放到测试集时要适当回调。因为测试集的正样本比例通常比训练集更低,过高的敏感度会带来大量误报。一个防御性做法:如果线上测评只报AUC,不关注具体误报数,那么阈值怎么选都不影响AUC;如果线上还要求给出风险名单、按准确率或F1打分,就要谨慎了。
5.2 模型融合的思路与落地写法
当单个LightGBM的AUC停在0.89上不去时,融合是拉开差距的常规手段。我用过最稳定的组合是LightGBM + XGBoost + CatBoost的简单加权平均,权重根据各自OOF AUC的比例确定,而不是等权。
import xgboost as xgb from catboost import CatBoostClassifier # 假设三个模型已训练完成,分别得到oof_lgb, oof_xgb, oof_cat auc_lgb = roc_auc_score(y, oof_lgb) auc_xgb = roc_auc_score(y, oof_xgb) auc_cat = roc_auc_score(y, oof_cat) # 权重按AUC占比归一化 total_auc = auc_lgb + auc_xgb + auc_cat w_lgb, w_xgb, w_cat = auc_lgb / total_auc, auc_xgb / total_auc, auc_cat / total_auc # 对测试集概率做加权融合 final_pred = w_lgb * test_lgb + w_xgb * test_xgb + w_cat * test_cat print(f'Weighted OOF AUC: {roc_auc_score(y, final_pred):.5f}')融合的意义在于降低模型方差,而不是提升偏差。三个同质化的模型(都是树模型、用同一套特征)融合带来的提升有限,通常只有0.002到0.005。更大提升来自特征层面的差异化:给XGBoost用标准化的数值特征,给CatBoost直接喂类别特征,让不同模型看到数据的不同投影面。
融合前可以先用相关系数矩阵检查各模型预测之间的相关性。如果两个模型的预测相关系数超过0.98,融合等于没加;目标是把每个模型训练得尽量独立。一个具体做法是给每个模型用不同随机种子和不同的特征子集,增加基尼不纯度层面的多样性。
本文还有配套的精品资源,点击获取