简介:针对企业非法集资风险预测的实战资源包,面向CCF竞赛参赛者、金融风控研究者和机器学习初学者,提供从数据处理到算法落地的完整参考。压缩包共27个文件,以22个CSV数据文件为主体,涵盖企业基本信息、财务数据、税务及新闻舆情等字段,另含2个Python源码、1个Jupyter Notebook探索分析和2份Markdown说明文档,整体约19.96MB,目录结构清晰,便于按需检索和快速上手。目前已有80人学习,适合用来熟悉真实竞赛数据流程。通过源码可深入理解特征工程、模型选择与调优、评估策略等关键环节,读后可对照复现竞赛提交方案,或基于自身数据调整特征与参数,扩展至类似风险预测任务,对提升金融领域机器学习实践能力很有帮助,也能为金融机构风险管理人员提供建模思路。注意,资源仅供学习交流,不可用于商业用途。
1. 企业非法集资风险预测:一份能跑通全流程的比赛源码包
如果你找过金融风控类的毕设案例或项目实例,应该会有同感:网上多数资源只给一个孤零零的模型文件,数据是脱敏到看不出业务的假样本,跑完连自己做了什么都说不上来。这个压缩包不一样,它是某次企业非法集资风险预测竞赛从数据理解到最终提交的完整存档——八张业务表、一个建模脚本、多轮版本化提交结果,全都在里面。它解决的是「真实比赛场景下,风险预测算法从零到上线怎么落地」的问题,适合两类人:一是拿机器学习做毕业设计但缺实战数据的学生,二是想了解企业风控表格竞赛完整技术栈的开发者。下面按我拆包后的实际操作路径展开。
2. 八张业务表一个目标:数据集结构与建模主线的拆解
2.1 主表、从表与评估集:字段职责先分清
解压后第一件事不是写代码,而是把文件清单读一遍。核心数据文件是几个独立命名的CSV:base_info.csv(企业基础信息)、entprise_info.csv(工商登记信息)、annual_report_info.csv(年报信息)、tax_info.csv(税务信息)、change_info.csv(企业变更记录)、news_info.csv(新闻舆情),外加entprise_evaluate.csv(待预测样本集)和entprise_submit.csv(提交模板)。这个命名方式很典型:主表存不变或低频变化的属性,从表存流水型记录,评估集是真正要预测的目标群体。
| 文件 | 典型字段角色 | 在建模中的用途 |
|---|---|---|
| base_info | 企业ID、注册资本、成立日期、地域 | 静态画像,所有特征的基础锚点 |
| entprise_info | 行业分类、经营状态、股东信息 | 行业与结构特征 |
| annual_report_info | 各年报的资产、负债、利润等 | 财务趋势特征,需按年聚合 |
| tax_info | 纳税额、申报记录 | 经营健康度信号 |
| change_info | 法人变更、经营范围变更、股东变更 | 异常活跃度信号 |
| news_info | 新闻标题或正文 | 舆情信号,文本处理 |
| entprise_evaluate | 只有ID和基础字段 | 推理集,不能用于训练 |
理解这批表格要抓住一个主线:所有从表都通过企业ID关联回主表,最终目标是为每个企业生成一行特征向量,预测其非法集资风险。不要一上来就想做复杂图模型,先把二维表合并做扎实。
2.2 第一轮数据体检:用脚本把缺失率和分布摸清楚
拿到新数据我习惯先跑一版全局体检,ccf_data_explore.py这个文件干的就是这件事。它的核心逻辑不复杂:遍历指定目录下所有CSV,输出行数、列数、缺失率Top字段和ID唯一值数量。我自己会在它的基础上加一段目标分布检查。
import pandas as pd import glob files = glob.glob("./ccf_data/*.csv") for f in files: df = pd.read_csv(f, nrows=5000) print(f.split("/")[-1], "shape:", df.shape) print("missing_top:", df.isnull().mean().nlargest(3).to_dict()) # 标签列通常叫label,值域0/1;如果读不到就换列名再试 train_part = pd.read_csv("./ccf_data/base_info.csv") if "label" in train_part.columns: print(train_part["label"].value_counts(normalize=True))逻辑说明:先抽样读入,避免大文件直接把内存打爆;nrows=5000只用于快速确认字段语义,后续正式统计再去掉这个参数。缺失率用均值排序输出Top3,能快速定位哪些字段基本是空的——空得过多的字段可以直接丢。标签分布打印出来是为了确认正负样本比例,防止后面用错评估指标。
参数说明:nrows是pandas读取时限制行数的参数,适合体检阶段;normalize=True让value_counts输出占比而不是频数。注意这里只是示例代码,压缩包内实际表头需要先print(df.columns)确认再对齐。
2.3 时间意识:训练集与评估集之间的隐性漂移
这类竞赛数据往往不是随机切分的,entprise_evaluate.csv里的企业在时间分布上与训练数据有明显差异——常见做法是拿历史时间窗口做训练,拿最近时间窗做预测。如果你忽略这一点,直接随机划分验证集,得到的指标会虚高,提交后被打回原形。
我的处理习惯是:从base_info.csv和change_info.csv中抽出可解析的时间字段,画出注册年份分布。注册年份集中在近两年的企业,其变更记录还没积累完整,特征天然稀疏。这部分不写复杂代码,直接pd.to_datetime转列后按年计数即可。最关键的结论是:验证集必须按时间后移切分,而不是随机切分。这一点在第4章建模时会重点用到。
3. 特征工程实战:把六张原始表合成一张训练宽表
3.1 合并策略:以企业ID为纽带,单向扩表
把所有信息塞进一张表听起来简单,实际坑很多。base_info是一行一个企业的主表,而change_info、tax_info、annual_report_info都是多行对一个企业。直接merge会制造大量重复行,让每个企业出现多次,模型把同一实体当多个样本学,特征分布全乱。
我采用的标准流程是「先聚合,再合并」:对每个从表,按企业ID做groupby,把多行压成一行统计量,得到一张企业级从表摘要,再以base_info的ID为键做left join。这样每行代表一个企业,特征列是横向扩展的。
import pandas as pd base = pd.read_csv("./ccf_data/base_info.csv") change = pd.read_csv("./ccf_data/change_info.csv") # 从表聚合:每个企业的变更次数与变更类型丰富度 change_agg = change.groupby("ent_id").agg( change_cnt=("change_type", "count"), change_type_nunique=("change_type", "nunique"), last_change_year=("change_date", "max") ).reset_index() # 主表左连从表摘要 fea = base.merge(change_agg, on="ent_id", how="left") fea["change_cnt"] = fea["change_cnt"].fillna(0)逻辑说明:groupby后的聚合操作把多行记录压缩为一个企业一行,change_type_nunique代表变更类型的多样性,通常一个企业频繁变更法人和经营范围是危险信号。left join保证主表所有企业都保留,没有变更记录的企业在聚合结果中是空值,统一回填0。
参数说明:agg里可以传多个聚合函数,只要字段名不冲突;change_date的max取出最近一次变更年份,方便构造时间差。实际表头可能叫change_dt或alt_time,先用print(change.columns)确认再替换。
3.2 时间窗口特征与聚合统计特征怎么设
风险预测和时间强相关,做特征工程时我倾向于构造多个时间窗口:近1年、近3年、成立以来。窗口特征的价值在于捕捉「异常活跃期」。一个正常经营的企业,变更记录在各年份均匀分布;而风险企业在爆雷前往往有集中的股权质押、法人变更、地址迁移动作。用窗口内变更次数除以成立年限,能得到一个归一化的活跃度指标。
import pandas as pd base = pd.read_csv("./ccf_data/base_info.csv") change = pd.read_csv("./ccf_data/change_info.csv") base["found_year"] = pd.to_datetime(base["found_date"]).dt.year change["change_year"] = pd.to_datetime(change["change_date"]).dt.year # 近3年变更次数 ref_year = 2021 # 以数据截止年份为参考 change["is_recent3"] = (change["change_year"] >= ref_year - 3).astype(int) recent3_cnt = change.groupby("ent_id")["is_recent3"].sum().rename("change_recent3_cnt") fea = base.merge(recent3_cnt, on="ent_id", how="left") fea["change_per_year"] = fea["change_cnt"] / (ref_year - fea["found_year"] + 1)逻辑说明:is_recent3是布尔转整型的窗口标记,groupby后求和得到每家企业在近3年的变更次数。change_per_year是早期变更次数除以存续年数,缓解成立时间长短对绝对次数的干扰。参考年份必须固定,不能动态取当前系统时间,否则每次跑结果不一致。
参数说明:ref_year要按比赛数据截止时间设定,不是拍脑袋选的;若有多个时间列,先用pd.to_datetime统一格式。这类窗口特征我一般同时造近1年和近3年两套,放进模型里让树自行选择分裂点。
3.3 新闻舆情文本信号:稀疏文本的特征化处理
news_info.csv是典型的长文本数据。对文本直接做TF-IDF再拼进表格模型是常见思路,但稀疏矩阵和稠密表格特征混在一起时,树模型对稀疏特征的分裂收益往往不稳定。我更推荐先做关键词命中类特征:从新闻标题或正文里统计是否出现「非法集资」「资金链断裂」「逾期兑付」「股权质押」等风险词,再统计出现的次数。
import pandas as pd news = pd.read_csv("./ccf_data/news_info.csv") risk_words = ["非法集资", "资金链", "逾期", "兑付", "质押", "停业", "欠薪"] def risk_hit_count(text): if pd.isna(text): return 0 return sum(1 for w in risk_words if w in str(text)) news["risk_word_cnt"] = news["title"].apply(risk_hit_count) news_agg = news.groupby("ent_id").agg( news_cnt=("title", "count"), risk_word_cnt_sum=("risk_word_cnt", "sum") ).reset_index()逻辑说明:risk_hit_count逐个风险词做子串匹配,命中一个加1,得到单条新闻的风险词计数。groupby后汇总出新闻总量与风险词总数。两个特征方向不同:news_cnt反映媒体关注度,risk_word_cnt_sum反映负面暴露程度。如果这个特征的作用不明显,可以继续拆分出「含风险词的新闻占比」来替代绝对次数。
参数说明:词表要根据数据集内新闻的实际用语调整——先print(news["title"].head(50))人工浏览几次,再决定词表。文本字段为空的比例高时,用pd.isna直接归零,不要用中位数填充。
3.4 特征筛选与输出:控制维度,保留解释性
特征不是越多越好。我一般先合并出全部特征,用df.corr()和目标列算相关性,把和目标相关度过低、方差接近0的字段剔除;再用feature_importances_在第一次训练后做二次筛选。这一步不是为了严格意义上降维,而是为了减少字段过多带来的过拟合风险。
4. 模型训练与调参:用LightGBM打底并盯住验证策略
4.1 为什么表格类风险预测优先选树模型
企业非法集资预测这种场景,特征以数值、类别、缺失值混合为主,样本量在万级到十万级,用深度模型容易过拟合且调参成本高。LightGBM这类梯度提升树模型对特征尺度不敏感,能直接处理缺失值,训练速度快,是比赛中的主流选择。model.py里的建模主体用的也是这个方向。
4.2 验证集切分与首批训练:先建立可复现的分数
验证集切分是建模前最关键的一步。如果你随机切分,会把未来信息混进训练集,评估指标好看但线上翻车。正确做法是按时间切分:把注册年份较新的企业作为验证集,用旧年份训练。
LightGBM原生接口只需构造Dataset并传入验证集即可,下面这段代码能直接升级替换model.py中对应的流程:
import lightgbm as lgb from sklearn.model_selection import train_test_split X = fea.drop(columns=["ent_id", "label"]) y = fea["label"] # 按时间切分:注册年份 >= 2020 的企业做验证 valid_mask = fea["found_year"] >= 2020 X_train, X_valid = X[~valid_mask], X[valid_mask] y_train, y_valid = y[~valid_mask], y[valid_mask] params = { "objective": "binary", "metric": "auc", "learning_rate": 0.05, "num_leaves": 31, "max_depth": -1, "min_child_samples": 20, "feature_fraction": 0.8, "bagging_fraction": 0.8, "bagging_freq": 1, "verbosity": -1, "seed": 42, } d_train = lgb.Dataset(X_train, label=y_train) d_valid = lgb.Dataset(X_valid, label=y_valid, reference=d_train) model = lgb.train( params, d_train, num_boost_round=500, valid_sets=[d_valid], callbacks=[lgb.early_stopping(50), lgb.log_evaluation(50)] )逻辑说明:valid_mask用成立时间做切分,保证验证集整体偏「新」,模拟真实预测时遇到的新注册企业。reference=d_train让验证集沿用训练集的特征对齐方式,避免类别编码错位。early_stopping(50)含义是50轮没有提升就停,节省调参时间。feature_fraction和bagging_fraction控制特征和样本的采样比例,这两个参数是防过拟合的主要旋钮。
参数说明:num_leaves=31是默认值,树深度不受限时叶子数决定模型复杂度;如果验证AUC和训练AUC差距大,优先调低num_leaves到15左右,而不是加正则。seed=42固定随机种子,保证同一份数据每次跑出来的结果一致,这是可复现的基础。
4.3 参数调节的粗调与精调路径
调参不要上来就网格搜索,先跑一版默认参数拿到基线分数,再按优先级调。我的顺序是:先调num_leaves和min_child_samples这对控制树复杂度的参数,再调feature_fraction和bagging_fraction,最后降learning_rate并增大num_boost_round,用更慢的学习率换取精度。
| 参数 | 基线值 | 调优方向 | 经验判断 |
|---|---|---|---|
| num_leaves | 31 | 15~63 | 验证集AUC下降时减少 |
| min_child_samples | 20 | 10~50 | 数值越大越保守 |
| feature_fraction | 0.8 | 0.6~0.9 | 特征噪声大时调小 |
| bagging_fraction | 0.8 | 0.6~0.9 | 配合bagging_freq=1 |
| learning_rate | 0.05 | 0.01~0.1 | 最终精调常用0.02 |
调参阶段最容易出现「验证集涨、测试集跌」的现象。我自己的习惯是每次只动一个参数,记录训练AUC和验证AUC的差值;如果差值超过0.03,基本是过拟合信号,优先回调树复杂度。这个阶段没有捷径,唯一能加速的是把num_boost_round和early_stopping配合好——早停轮数越小,出结果越快,但太小的早停轮数容易欠拟合,50是一个不会犯大错的起点。
4.4 评估维度:不能只盯AUC
比赛常用AUC做排名,但实际风险评估场景正样本占比通常很低,AUC可能很高而实际召回极差。我在model.py输出里专门加了预测分布检查:看模型预测分数在验证集上高分段是否真的命中更多正样本。具体做法是pd.qcut预测分数分桶,统计每个桶的正样本占比。如果最高分桶的正样本占比不到均值的2倍,说明模型没有抓到真正的风险信号,这时候调参意义不大,要回头补特征。这个习惯帮我避免过很多「指标高但业务不可用」的假象。
5. 避坑记录:从submit_1102到submit_1106的五个翻车点
5.1 特征构造阶段的泄漏、穿越与编码坑
坑位一:用全量数据做缺失值填充,验证集AUC虚高0.03以上。现象:fillna直接用df["col"].mean()填充,训练和验证一起算,信息从验证集泄漏进训练集。原因:缺失值统计量来自全量数据,包含了验证集样本的分布信息。解决:所有统计量只在训练集上计算,再transform到验证集,用train_test_split先切分再填充。
坑位二:构造「近3年变更次数」时基准年份取错。现象:特征里出现change_recent3_cnt和found_year倒挂——企业成立不到1年,近3年变更次数却很大。原因:参考年份用了运行时动态时间,不同时间跑结果不同。解决:固定死ref_year为数据截止年份,并在代码注释里写明来源。这类时间穿越问题很隐蔽,特征重要性排名靠前的字段一定要人工复查一遍逻辑。
坑位三:类别特征直接LabelEncoder,测试集出现新枚举值报错。现象:训练好的模型在评估集推理时抛「unseen label」异常。原因:某个类别在训练集样本少,没进入编码表。解决:改用频次编码,先把出现次数少于阈值的类别统一归为rare,再用编码表做映射。最粗暴但有效的兜底方案是factorize后把未知值填-1,让树模型把它当独立分支。
5.2 训练、评估与提交阶段的格式与指标坑
坑位四:正样本占比极低时只用准确率评估。现象:模型预测全为0,准确率仍然高达95%以上,看起来分数很好,其实一个风险企业都没抓出来。原因:负样本占绝对主导,全预测负样本就能拿高准确率。解决:改用AUC和PR曲线,重点看召回率@高分段。比赛排名按AUC走,但业务价值看的是预测分数Top5%内命中了多少真实风险样本。
坑位五:提交文件ID顺序错位,跑出有效分数为0。现象:entprise_submit.csv模板里的企业ID顺序与预测结果表不一致,直接to_csv提交后分数极低。原因:建模过程中索引被重排,没有按模板ID排序对齐。解决:提交前用template[["ent_id"]].merge(pred_df, on="ent_id", how="left")强制对齐,并检查pred_df行数与模板行数一致。这个坑最蠢也最伤,validate之前先跑一遍assert len(pred) == len(template)。
6. 提交文件与模型融合:多版本结果怎么合成最终答案
6.1 版本命名与结果回溯
压缩包内submit_1102.csv到submit_1106.csv多份提交文件,对应的是不同时间点的模型输出。这种命名习惯背后是一套版本管理思路:每次实验都留下可回溯的预测文件,文件名带日期可以快速定位「哪个版本用了哪套特征、哪组参数」。
我的做法是为每版提交配一份实验记录,写进note.md:包括特征版本编号、参数改动、验证AUC、提交后分数。没有这份记录,几天后回看多个submit文件根本分不清哪个对应哪个模型。你要复现这份资源时,建议直接沿用它这套命名规范。
6.2 Rank融合与概率平均的实际效果
多个版本之间的差异如果只是随机种子不同,融合价值不大;如果特征是不同思路构造的,融合往往能稳定提升。常用方法是先对每个版本的预测分数做排序标准化,再取加权平均。排序标准化比直接平均概率更稳,因为不同模型的分数分布区间不一样,直接平均会被分数膨胀的模型主导。
import pandas as pd from scipy.stats import rankdata s1 = pd.read_csv("./submit_1103.csv")["pred"] s2 = pd.read_csv("./submit_1105.csv")["pred"] rank1 = rankdata(s1) / len(s1) rank2 = rankdata(s2) / len(s2) final_pred = 0.5 * rank1 + 0.5 * rank2逻辑说明:rankdata把原始分数转换为排名,再除以样本总数归一化到[0,1],让两版结果处于同一尺度。0.5/0.5是等权融合,如果某一版验证AUC更高,可以改成0.6/0.4,但幅度不宜过大,防止过拟合验证集。
参数说明:融合的不是原始分数而是排名,等于把每版模型的预测能力当作「排序器」看待,这在AUC指标下比概率平均更贴合优化目标。如果你有两版差异较大的特征方案,可以试试这个流程,通常能稳定提升零点几个百分点的AUC。
从那以后,我每次做这类比赛都会强制保留一版「纯净特征」的baseline,不做任何花哨融合,作为判断后续实验是否有效的基准线。多版本提交不是乱开枪,而是每枪都记在账上。这份压缩包最值得你打开跑一遍的地方,恰恰就是它同时保留了两样容易被忽略的东西——能跑通的代码,和跑完留下的过程痕迹。希望帮到你。
本文还有配套的精品资源,点击获取