MDA文本预测房企风险:源代码、模型与数据全流程
发布时间:2026/9/25 15:51:06来源:尧图网络
简介基于管理层讨论与分析文本信息与房地产企业潜在风险预测的研究项目是一套面向高校计算机专业毕业设计及期末大作业的完整实战源码包。项目从真实财务文本语料出发通过风险种子词筛选、文本聚类、word2vec词向量和LDA主题建模构建文本指标再结合债券主体评级、控制变量等财务数据完成回归分析形成从数据清洗到风险预测的完整实验链路。资源包共37个文件以ipynb分析脚本、txt文本语料与词典、xlsx和xls财务数据表为主体另含训练完成的模型、向量文件与可视化图片压缩包约102MB。所有源代码均经本地编译调试并可运行项目获得98分评审评分难度适中且已通过助教审定。已有44人学习下载目录结构清晰便于按步骤复现结果适合需要完整综合项目参考的在校学生。1. MDA 文本预测房企风险源代码、模型、数据一个都不能少年报季里只盯着房企三大报表很容易漏掉最前置的信号。真正值得逐字读的是“管理层讨论与分析”也就是 MDA。房企的融资口径、开工计划、存货跌价准备的说法全藏在这段文字措辞的细微变化里。把 MDA 文本当作预测房地产企业潜在风险的输入已经被不少买方和评级团队验证过难的不是选哪个算法而是把文本干净地变成训练数据、把模型稳定地跑出概率、把预测结果解释给业务方听。一套可复现的研究项目至少由源代码、模型、数据三块拼成。源代码负责从公告原文里提取 MDA 段落并组装训练集模型负责把文本编码成风险概率数据则决定你换一个年份或换一批房企时还能不能继续验证。三者缺一个项目就只能停在自己的电脑上别人拿过去也复现不出同样的实验结论更谈不上在生产环境里持续跑。下面这套流程按我实际做文本风险预测的顺序展开先把数据加工管线讲透再比较词频基线与中文预训练模型两条路线接着落到训练时的划分方式、不均衡处理和阈值选择最后用 SHAP 把模型输出翻回成可读的风险信号。适合已经在做财务数据风控、想引入文本信号的分析师或算法工程师也适合刚接触这个方向、想照着一套最小工程自己跑通的研究者。你会看到每一步的代码形态、参数边界和容易翻车的地方。2. 数据准备把 MDA 原文变成能喂给模型的结构化样本“模型决定上限数据决定下限”是文本建模的老话在房地产这种样本量不大的场景里尤其真实。MDA 文本来自年报的固定章节风险标签来自公司后续的经营结果把这两类来源对齐并清洗成统一格式才是这套源代码里最耗时的部分。数据准备做得不扎实后面换任何模型都救不回来。2.1 从年报正文里切 MDA一段可复用的截取代码房企年报的章节标题并不完全统一。有的叫“管理层讨论与分析”有的叫“经营情况讨论与分析”个别年份的格式还会微调。PDF 转出来的文本经常混入页眉页脚和目录页如果只写死一个标题字符串去匹配很容易切出空文本或把下一章内容卷进来。我一般会先维护一组起始标题和一组结束锚点用正则做多模式匹配。起始标题匹配成功后从匹配位置往后找第一个稳定的结束锚点例如“重要事项”“公司治理”“监事会报告”然后取两者之间的正文。import re def extract_mda(report_text: str) - str: # 起始标题不同年份/不同房企存在多种叫法统一做多模式匹配 start_pattern re.compile( r((三|四|五)[、\.]\s*)?(管理层讨论与分析|经营情况讨论与分析|董事会报告), re.MULTILINE ) match start_pattern.search(report_text) if not match: return start match.end() # 结束锚点MDA 通常后面是重要事项、公司治理或监事会报告 end_anchors [ 重要事项, 公司治理, 监事会报告, 备查文件目录, ] end_pos len(report_text) for anchor in end_anchors: pos report_text.find(anchor, start) if pos ! -1 and pos end_pos: end_pos pos return report_text[start:end_pos].strip()这段代码的关键在于用一个列表去兜底不同的章节写法而不是只匹配“管理层讨论与分析”。end_anchors 的顺序也有讲究我会把“重要事项”放最前面因为它在年报里位置稳定且几乎不会缺失。匹配结束锚点时用find取第一个出现位置防止翻到备查文件这种靠后的章节导致文本过长。跑完这段代码需要立刻做一个质量抽检随机抽 30 到 50 条截取结果人工看开头和结尾是否干净。常见问题是把“第四节 董事、监事、高级管理人员和员工情况”也卷了进来因为有些年报的 MDA 标题前面有编号且编号不是“三、”而是“第三节”。碰到这种情况就把编号模式放宽成\d正则改成r((\d|(三|四|五))[、\.]\s*)?(管理层讨论与分析|...)然后重新抽检。2.2 风险标签怎么定违约、财务困境和审计意见三个口径标签决定了模型在学什么。房地产企业的“潜在风险”没有唯一的公开标注实务里常见三个口径债券违约、财务困境和审计意见类型。三者的获取难度和噪声程度完全不同要按你手里的数据源选一个作为主标签。债券违约是最强信号噪声低但样本量很少而且违约事件大多集中分布在少数年份。财务困境一般用“是否被 ST 或发生债务展期”来定义覆盖范围更广但 ST 的判断本身带有交易所规则的人为成分。审计意见则分成标准无保留、保留意见、无法表示意见等几档颗粒度适中缺点是它反映的是审计师态度不完全等于经营风险。我一般把事件数据整理成一张表每条记录包含公司代码、事件日期和事件类型然后用一份固定的时间窗口去和财报报告期匹配。下面是生成标签的代码窗口取 720 天也就是报告日后大约 24 个月内有没有发生风险事件。import pandas as pd def build_label( report_data: pd.DataFrame, event_data: pd.DataFrame, horizon_days: int 720 ) - pd.DataFrame: report_data 必须包含 company_id, report_date event_data 必须包含 company_id, event_date, event_type rows [] for _, r in report_data.iterrows(): sid r[company_id] report_date r[report_date] mask ( (event_data[company_id] sid) (event_data[event_date] report_date) (event_data[event_date] report_date pd.Timedelta(dayshorizon_days)) ) future_events event_data[mask] rows.append({ company_id: sid, report_date: report_date, label: int(len(future_events) 0), }) return pd.DataFrame(rows)horizon_days是最需要谨慎对待的参数。窗口太短样本正例不足窗口太长报告期与事件之间的因果关系会被拉散。常见做法是先用 720 天跑一版再用 365 天和 1080 天做敏感性分析看模型结论是否稳定。还要注意一个前提未来发生事件的样本直接标为 1没有发生事件的样本标为 0这种做法隐含一个假设——没爆雷的公司就是安全的。实际上有些公司只是还没爆雷。更严谨的做法是把“在窗口内发生过其他负面舆情但没有实质违约”的公司单拎出来作为模糊样本暂时不进训练集或者用软标签处理。如果数据量不大我会先在代码里加一个event_type过滤字段把可确认的违约和债务展期事件作为正例来源。2.3 把文本和标签拼成数据集建议存成 Parquet有了 MDA 文本和标签接下来就是把两者对齐成一个 DataFrame。这里有个容易忽略的坑一家公司一年可能有多份报告比如年报和半年报都含 MDA而同一报告期在不同数据源里可能被写成“2023-12-31”和“2023 年年度报告”两种格式。我会先把report_date统一成YYYY-MM-DD的 pandas 日期类型再执行合并。import pandas as pd report_text pd.read_csv(mda_text.csv, parse_dates[report_date]) label_df build_label(report_text, event_data) df pd.merge( report_text[[company_id, report_date, mda_text]], label_df, on[company_id, report_date], howinner, ) # 过滤空文本切出来不足 100 字的样本基本是解析失败 df[mda_len] df[mda_text].str.len() df df[(df[mda_len] 100)] # 按公司代码和报告日期去重 df df.drop_duplicates(subset[company_id, report_date]) df.to_parquet(mda_dataset.parquet, indexFalse)存成 Parquet 是因为它保留了字段类型且压缩后读取速度快后续用 pandas 直接读回来report_date依然是日期类型不用二次转换。如果环境里没有 pyarrow退而求其次存 UTF-8 编码的 CSV但读取时记得加parse_datesTrue。这个阶段完成后的数据集文件应该至少包含company_id、report_date、mda_text、label、mda_len这几个字段。文本字段保持原文不要在这里做停用词删除和分词清洗动作留给模型前的特征编码阶段。因为不同模型对文本预处理的要求不一样数据层做得越原始后面换模型时返工越少。3. 模型选型词频基线与中文预训练模型之间的实际取舍文本已经备好选模型前要先回答两个问题MDA 平均有多长房地产行业样本量有多少。这两个约束直接决定模型路线而不是“哪个新用哪个”。3.1 长文本是 MDA 建模的第一个硬约束一份房企年报的 MDA 部分少则五千字多则两万字。即使按 BERT 类模型常见的 512 token 上限来算单篇文本也大概率超过这个长度。如果直接把全文截断取前 512 token就会丢掉后半段的风险表述而房企的风险描述往往写在靠后的“资金需求、融资计划”小节里。另一个约束是样本量。房地产行业几年内的半年报和年报样本加在一起通常只有几百到一千条级别远小于通用文本分类任务的数据规模。在这个量级上直接微调一个十几亿参数的模型很容易过拟合。因此我默认的方案是两条腿走路一条是 TF-IDF 特征加 LightGBM作为可解释且不容易翻车的基线另一条是中文预训练模型但必须用分窗或分段策略把长文本切成模型能处理的长度。3.2 TF-IDF LightGBM 基线先跑通再谈精度TF-IDF 虽然看起来老但对中文文本风险预测任务依然是最稳妥的起点。它不需要 GPU训练时间短特征重要性可以直接看也能方便地和后续 SHAP 解释对接。我用它先建立一版结果用来判断文本里是否真的存在可学习的风险信号。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split import lightgbm as lgb vectorizer TfidfVectorizer( max_features30000, ngram_range(1, 2), min_df2, sublinear_tfTrue, ) X_text vectorizer.fit_transform(df[mda_text]) y df[label].values X_train, X_test, y_train, y_test train_test_split( X_text, y, test_size0.2, stratifyy, random_state42 ) clf lgb.LGBMClassifier( n_estimators300, learning_rate0.05, num_leaves31, max_depth-1, class_weightbalanced, random_state42, ) clf.fit(X_train, y_train) print(clf.score(X_test, y_test))max_features30000是为了控制特征矩阵的规模避免因为低频词记忆训练集噪声。ngram_range(1, 2)可以把“融资成本上升”“去化放缓”这类短语当作特征而不是只看到孤立的词。min_df2表示至少在两篇文本里出现过才算特征过滤掉手滑打错的生僻词。sublinear_tfTrue使用 TF 的 log 变换避免高频词压过其他词。这段代码里有一个在基线阶段可接受的偷懒直接按样本随机划分没有按时间切分。因为现在的目的是验证文本信号是否存在不是验证模型是否能在未来年份上泛化。后面进入正式评估时必须改成时间顺序切分这个区别在下一章会展开。3.3 中文预训练模型分窗编码再池化绕开 512 长度限制如果基线表现已经高于随机下一步值得试中文预训练模型。常见做法是拿中文 RoBERTa 或 BERT 类模型做初始化例如hfl/chinese-roberta-wwm-ext。这类模型对中文长文本的编码能力明显强于直接用词向量平均但直接输入会超长截断。我的做法是把 MDA 按 512 token 的窗口切段相邻窗口之间留 128 或 256 token 的重叠然后分别编码最后把各窗口的[CLS]向量取平均作为整篇文本的表示向量。这样不会丢掉中间和尾部内容代价是计算量变大而且每篇文本会生成多个窗口。from transformers import AutoTokenizer, AutoModel import torch import numpy as np tokenizer AutoTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) model AutoModel.from_pretrained(hfl/chinese-roberta-wwm-ext) model.eval() def encode_long_text(text: str, max_len: int 512, stride: int 256) - np.ndarray: # return_overflowing_tokens 会把长文本切成长度为 max_len 的多个窗口 inputs tokenizer( text, max_lengthmax_len, stridestride, truncationTrue, return_overflowing_tokensTrue, return_tensorspt, ) all_cls [] with torch.no_grad(): for window_input_ids in inputs[input_ids]: window {input_ids: window_input_ids.unsqueeze(0), attention_mask: torch.ones_like(window_input_ids.unsqueeze(0))} output model(**window) # [CLS] 向量放在 hidden_state 的第一行 cls_vec output.last_hidden_state[:, 0, :].squeeze(0) all_cls.append(cls_vec) # 把多个窗口的 [CLS] 平均得到整篇文档的表示向量 doc_vec torch.stack(all_cls).mean(dim0) return doc_vec.numpy()这里的stride256控制相邻窗口的重叠长度。重叠越大跨窗口上下文保留越多但计算量翻倍。我建议先用 256 跑一版如果发现风险词汇总出现在两段边界上再改成 384。return_overflowing_tokensTrue是这段代码的支点没有它长文本只会保留前 512 个 token。拿到整篇文档的向量后可以直接接一个逻辑回归或 LightGBM 做分类。这样预训练模型更像一个特征提取器训练噪声比直接微调全连接分类头小。如果数据量充足再把model解冻微调几个 epoch效果一般会有提升但房地产样本量通常撑不起这种训练方式容易在验证集上反复波动。4. 训练与评估参数怎么调模型才不白训模型结构定了下一步是把训练流程打磨成一套别人拿过去能复现的实验。这里最容易被忽略的是数据切分方式、类别不平衡处理和阈值选择。这三件事不做好理论上再漂亮的模型放到真实时间线上也会露馅。4.1 时间顺序切分是硬红线不能用随机切分风险预测天然是时间序列问题拿前几年的年报训练去预测后面年份的风险。如果用随机切分同一时期的文本会被同时分到训练集和测试集模型会记住当年的措辞风格测试分数虚高。正确的做法是按report_date排序后切分。例如把时间最靠前的 70% 作为训练集接着 10% 作为验证集最后 20% 作为测试集。df df.sort_values(report_date).reset_index(dropTrue) train_end int(len(df) * 0.7) val_end int(len(df) * 0.8) train_df df.iloc[:train_end] val_df df.iloc[train_end:val_end] test_df df.iloc[val_end:] # 特征编码器必须在训练集上先拟合再transform验证集和测试集 vectorizer TfidfVectorizer(max_features30000, ngram_range(1, 2)) X_train_text vectorizer.fit_transform(train_df[mda_text]) X_val_text vectorizer.transform(val_df[mda_text]) X_test_text vectorizer.transform(test_df[mda_text])上面代码的顺序很关键vectorizer只fit在训练集上验证集和测试集一律transform。这样做是为了防止信息泄漏因为如果让特征编码器见过全部文本再去做切分验证集里词的分布就已经参与了特征词典的构建模型评估结果会被高估。在预训练模型路线上也需要同样处理。不过预训练模型本身在无监督语料上训练过它的词表是固定的不存在“见没见过验证集文本”的问题。真正要注意的是在模型微调阶段保证验证集不会出现在fit过程里这一点靠训练循环里的 batch 随机抽样本实现只要在构造 DataLoader 时不把验证集混进训练集即可。4.2 不均衡样本class_weight 和阈值一起调房地产风险事件是低频事件正例占比经常不到 10%。直接用默认阈值 0.5 做预测模型会把绝大多数样本判成负例准确率很高但毫无实际用途。处理不均衡我一般分成两段训练时给少数类更高权重预测前重新找阈值。from sklearn.metrics import precision_recall_curve clf lgb.LGBMClassifier( n_estimators300, learning_rate0.05, num_leaves31, class_weightbalanced, random_state42, ) clf.fit(X_train_text, train_df[label]) proba clf.predict_proba(X_test_text)[:, 1] precision, recall, thresholds precision_recall_curve(test_df[label], proba) # thresholds 长度比 precision / recall 少一个前一项对应阈值0 f1 2 * precision[:-1] * recall[:-1] / (precision[:-1] recall[:-1] 1e-8) best_idx f1.argmax() best_threshold thresholds[best_idx] print(fbest_threshold: {best_threshold:.4f}, f1: {f1[best_idx]:.4f}) y_pred (proba best_threshold).astype(int)class_weightbalanced会让 LightGBM 在训练时自动加大少数类的损失权重但这只是第一步。precision_recall_curve和默认准确率曲线不同它不关心负例数量正适合高度不均衡的风险预测。最佳阈值取 PR 曲线上 F1 最高的点这里我加了一个1e-8防止precision recall 0时除零报错。这个阈值通常不会落在 0.5 附近而是一个靠近 0.2 甚至 0.1 的数值。它代表的意思很明确宁可把一部分正常公司先标成风险也要尽量留住真实风险样本。实际操作中阈值还要结合业务代价调比如漏掉一家房企的风险可能造成千万级损失那我会在 F1 最高的阈值基础上再下调一点换取更高召回。4.3 评估指标别只报 AUC要看业务关心的召回文本风险预测项目只要数据一公布很多人会先看 AUC。AUC 在类别不均衡时依然稳定但它不能告诉你“在 10% 的召回率下精确率是多少”。对信用风险这类任务我建议把 PR 曲线、F1、以及特定召回率下的精确率一起报出来。下面是一份我常用的结果汇总格式方便放在实验记录里对比不同模型版本。指标含义在这个任务里的用法AUC随机正样本排在随机负样本前面的概率用于判断模型整体区分度不随阈值变化F1精确率与召回率的调和平均用于横向对比不同模型在同一阈值策略下的表现召回率20%精确率精确率约为 20% 时的召回率贴近业务允许一定误报但要抓住更多风险命中率预测为风险且实际出险的比例用于向业务方解释模型预测的准确程度我不会只看测试集上的一个数字就决定模型上线。换个年份的测试集跑一遍如果 AUC 波动超过 0.1说明模型过度依赖当前年份的措辞特征需要回到数据层面检查文本源是否稳定。这些波动往往来源于某个年份里出现的新政策词汇比如“三道红线”开始在 MDA 中高频出现模型如果学的是这个词而不是背后的经营逻辑到下一年就会失效。5. 避坑房地产 MDA 文本建模里我踩过的五个坑这套方案真正难的不是算法选型而是那些数据切边和标签定义里的灰色地带。下面五条是我实际踩过、也帮别人排查过的坑每一条都按现象到原因的路径讲清楚。5.1 标签泄漏把出险后的年报拿来当正样本现象模型在训练集上 AUC 接近 0.99测试集分数也很高但业务方一用就发现预测的都是已经出险的公司完全没有提前量。原因是标签和文本时间没有对齐。如果你直接让“2023 年下半年的违约状态”成为“2022 年年报 MDA”的标签模型学到的其实是已经出险后的公司年报措辞。解决在构造标签时严格检查事件日期代表的含义。如果事件是债券违约公告事件日期应该当作风险爆发点只有事件日期晚于财报报告日才允许把这条样本标为正例。我在build_label里用了event_date report_date这个条件就是为了杜绝把过去式风险当成未来风险。还要再检查事件记录里是否混了“公司被 ST”这类带状态持续性的标签这类标签会从被 ST 起一直存在时间窗口怎么切都容易泄漏。5.2 MDA 标题不统一切出整段空文本现象一批公司抽检时发现 MDA 文本长度等于 0模型把空文本当成一个特征后居然能分类因为空文本背后是解析失败产生的固定格式模型学会了用“有没有文本”做预测。原因是正则里只写了“管理层讨论与分析”一个模式遇到“经营情况讨论与分析”的公告就匹配不到。解决起始标题要维护一个别名集合至少包含“经营情况讨论与分析”“董事会报告”两个常见变体。匹配成功后要对每条样本统计mda_len把小于 100 字的样本单独抽出来看原文格式而不是直接删除。我一般会打印 10 条异常样本的开头 200 个字符确认是 PDF 解析乱码还是标题不匹配然后决定改正则还是换数据源。5.3 只取前 512 token把风险信息全丢在后面现象用 BERT 类模型直接输入文本AUC 比 TF-IDF 基线还低。原因是 MDA 的关键内容不一定在前面房企偿债能力和融资计划往往在后面章节截断后只剩下开头的行业描述和公司简介。解决放弃首截断策略改成 3.3 节的分窗编码。如果硬件条件有限至少有两条折中做法一是取开头 256 token 加结尾 256 token 拼接因为结尾部分经常放风险提示二是用 TextRank 或 TF-IDF 抽取出包含“风险”“融资”“债务”“回款”等关键词的句子组合后再编码。折中方案会损失部分上下文但跑一个实验验证基线效果还是够用的。5.4 默认阈值 0.5 导致模型一个风险样本都报不出来现象验证集 F1 纸上很高但把预测结果展开后正样本预测概率普遍低于 0.5模型几乎给出全负。原因是正负样本比例严重失衡模型学到的最优概率输出本来就偏向负类直接拿 0.5 当决策边界不符合数据分布。解决按 4.2 节的方法用验证集上的 PR 曲线重新选阈值。要注意阈值也应该做时间切分用验证集选不能拿测试集来选否则阈值本身也被测试集泄漏了。选定阈值后把阈值和 F1 一起记录到实验日志里下次换数据时重新算不要沿用上一次的 0.3。5.5 数据文件版本混乱同一个模型在不同机器上复现不出同一结果现象同事从网盘上拿到同一份数据跑出来 AUC 差了 0.06反复查后发现 PDF 解析工具不一样导致文本里的“0”和“O”被互换。原因是数据源经过多级传递没有记录原始公告来源和转换工具的版本。解决把文本解析脚本和数据文件封装成同一个发布包数据文件至少包含source_file、parser_version、extract_time三个元数据字段。每次重新生成数据集时先对随机抽样的 20 条文本做 diff确认与上一次版本文本一致再继续。项目标题里既然强调了“源代码模型数据”就应该把数据的版本管理当成一等公民否则代码再完整也很难复现。6. 进阶验证用 SHAP 把风险预测结果翻译回管理层语言模型跑出概率只是第一步真正让业务方信服的环节是把预测结果解释成“模型到底看中了 MDA 里的哪些话”。对词频类模型SHAP 是最直接的工具对预训练模型则要退一步用输入归因近似方法。以 LightGBM 基线为例加载测试集特征后用shap.TreeExplainer计算特征贡献然后把每个词的 SHAP 值排序挑出对风险预测贡献最大的词条。import shap explainer shap.TreeExplainer(clf) shap_values explainer.shap_values(X_test_text) feature_names vectorizer.get_feature_names_out() sample_shap shap_values[0] top_idx sample_shap.argsort()[::-1][:20] for i in top_idx: print(f{feature_names[i]}: {sample_shap[i]:.4f})sample_shap[0]是测试集第一条样本里每个特征的 SHAP 值正数表示把模型推向风险方向负数表示推向安全方向。输出结果里如果出现“净负债率上升”“预售监管资金”“回款不及预期”这类和房地产经营强相关的词说明模型学的方向是对的如果出现在这里的是“公司”“报告”“我们”这类中性词那就要怀疑训练样本不够或者标签有噪声。对预训练模型SHAP 计算成本太高。我一般改用更轻量的方法把固定长度的文档表示向量作为特征单独训练一个逻辑回归再用回归系数近似看哪些窗口的内容对预测贡献大。这样虽然损失了细粒度词级解释但能把“哪一段话让公司被判为高风险”定位到窗口级别业务方也能接受。我做这类文本风险项目时有一个固定习惯模型训练完不是终点而是先把预测概率最高的前 30 条样本拉回来逐条读模型关注的句子看它是不是抓到了真正的原因。如果读下来发现模型在靠“2022 年市场下行”这种通用表述做判断那说明它学到的是年份噪声不是公司特质风险。用这个习惯挡住过很多次看起来分数很高、实际不可用的模型。如果你照着这套流程走最后手里应该有一条能独立跑通的文本风险预测管线从年报切出 MDA到生成标签到模型训练再到 SHAP 验证。拿到源代码、模型和数据之后建议先复现基线结果再逐步换模型和调阈值不要一上来就堆参数量。希望这份完整链路能帮你少走一段弯路也希望你的风险模型真正能在下一次年报季里跑出让人信服的结果。本文还有配套的精品资源点击获取
网站建设高端定制企业官网