多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于Python的多层网络银行系统性风险建模与传染模拟

基于Python的多层网络银行系统性风险建模与传染模拟 简介针对中国银行业系统性风险监管需求这份PDF资源完整实现了基于多层银行网络的系统性风险传染模型将DebtRank模型扩展至银行间借贷、交叉持股、持有共同资产三个渠道并提供配套Python代码及逐步解释。内容涵盖系统设计、模拟个体银行冲击/行业冲击/宏观冲击、系统重要性银行识别等核心环节适合金融研究人员、监管机构工作者及金融网络领域学者用于理解多层结构下的非线性风险叠加效应、评估不同冲击对银行体系的传染路径并为政策模拟与监管决策提供实证工具。资源为单个PDF文件压缩包大小约1002KB不含冗余附件便于集中阅读与代码复现。目前已有151人学习该资源除复现原文外资料还增强了非线性叠加效应的数学表达、引入资产价格反馈机制、优化系统重要性识别算法能够帮助读者在掌握基础模型的同时进一步拓展面向实际监管场景的分析能力。1. 多层网络为什么是银行业系统性风险建模的下一步单一银行压力测试只看杠杆率和资本充足率但这种视角天然假设“危机只来自自身资产负债表”。2008年之后的研究反复指向一个反直觉结论风险恰恰藏在银行之间的关联里而关联不止一条。银行间借贷是最显眼的一条但共同持有资产、批发性融资依赖、储户行为同步变化同样构成传染通道。只看借贷网络会系统性低估尾部风险这就是多层网络模型进入系统性风险评估的原因。把中国银行业放进这个框架里看多层结构尤其贴近现实大型国有银行与股份制银行、城商行之间的同业往来密切表内外资产重叠度不低流动性传导路径多样。单层网络模拟一次冲击只能观察到违约的“直接邻居”看不见资产抛售引起的折价反馈、看不见被迫去杠杆的连锁反应。多层网络把每一类关联单独建成一层层与层之间通过“压力传递函数”互相触发才能逼近真实传染深度。这篇文章围绕一条完整的落地方案展开三层网络同业借贷、共同资产、流动性依赖怎么定义、怎么用 Python 构建系统性风险传染怎么在多层网络上模拟整个模型如何组织成可评估、可回测、可做政策模拟的系统。文章里的代码是从零搭一个最小可运行版本不是某个现成框架的包装。适合做银行风险、监管科技、量化风控的工程师也适合想看懂“系统性风险模型从输入到输出全流程”的研究者。2. 多层网络建模为什么单层借贷网络看不全风险传导2.1 从单层到多层的关键转变直接敞口之外还有间接反馈银行业系统性风险的传统建模以银行间拆借网络为核心节点是银行边是同业敞口权重是风险暴露。这种单层网络的优点是数据相对直接、传染路径明确——A 违约导致 B 损失B 资本不足导致 C 受牵连。但它有一个结构性盲区间接传导不可见。A 违约后为了补资本抛售持有的债券债券价格下跌持有相同债券的 C 即使没和 A 发生过一笔拆借也要计提减值。这种共同资产层暴露在单层模型里完全被忽略。更现实的场景下流动性挤兑也走独立通路。一家银行即使资本充足只要短期批发性融资占比较高同业信任下降时就会出现续借困难被迫出售资产、收缩信贷进而影响依赖它拆借资金的对手方。这条流动性链条和资本链条的传导速度差着数量级——资本损失以季度报表为周期显现流动性冲击以天甚至小时为单位传播。因此多层网络的定义方式是同一组银行节点在不同的“关系维度”上分别建边。每一层表达一种独立的关联机制层与层之间共享节点但边的含义、权重、传播规则各自不同。风险在某一层爆发后通过节点违约或压力指标变化“跨层跳跃”才是系统性风险研究的重心。提示多层网络不是“把多个网络画在一起”关键在跨层耦合机制的定义。没有耦合规则的多层图只是一张静态快照无法模拟传染。2.2 三层网络的结构定义同业借贷、共同资产、流动性依赖中国银行业系统性风险评估中最常用的三层层级如下。第一层同业借贷网络。节点是银行边表示资金拆出/拆入关系方向从资金拆出方指向拆入方权重为拆借余额或净额敞口。这个层直接刻画信用传导拆入方违约拆出方承担损失。第二层共同资产持有网络。节点仍是银行但边不再是一一对应关系而是“两家银行持有同一类资产”间接建立的联系权重用共同持有的资产规模或相关性系数衡量。第三层流动性依赖网络。边反映银行之间“稳定的融资-融出关系”权重是批发性融资依存度还可以叠加上储户结构相似性作为隐性关联。落到代码层面用 NetworkX 可以很轻地把这个结构表达出来。下面是构建三层网络并合成多层图的基础代码import networkx as nx import numpy as np # 节点属性每家银行的基础指标 banks [BOC, ICBC, CCB, CMB, SPDB, CIB, BOCOM, CITIC] n len(banks) np.random.seed(42) # 每家银行的资本与总资产比例简化示意 capital_ratio np.random.uniform(0.08, 0.15, n) node_data { b: { capital: capital_ratio[i], asset: np.random.uniform(1000, 5000, 1)[0], ltcr: np.random.uniform(0.8, 1.2, 1)[0], # 流动性覆盖率 } for i, b in enumerate(banks) } # 层1同业借贷网络有向加权边 interbank nx.DiGraph() interbank.add_nodes_from(banks) for i, b in enumerate(banks): for j, c in enumerate(banks): if i ! j and np.random.random() 0.25: interbank.add_edge(b, c, weightnp.random.uniform(10, 120)) # 层2共同资产网络无向加权边权重为共同持债规模 common_asset nx.Graph() common_asset.add_nodes_from(banks) for i, b in enumerate(banks): for j, c in enumerate(banks): if i j and np.random.random() 0.35: common_asset.add_edge(b, c, weightnp.random.uniform(30, 300)) # 层3流动性依赖网络有向加权边权重为批发性融资依赖 liquidity_net nx.DiGraph() liquidity_net.add_nodes_from(banks) for i, b in enumerate(banks): for j, c in enumerate(banks): if i ! j and np.random.random() 0.15: liquidity_net.add_edge(b, c, weightnp.random.uniform(5, 60)) layer_dict { interbank: interbank, common_asset: common_asset, liquidity: liquidity_net, }代码逻辑很直接先定义银行节点和基础财务属性然后分别用 DiGraph 和 Graph 建三张图。capital是可用于吸收损失的核心资本比例asset是总资产规模单位简化处理ltcr是流动性覆盖率在后面的传染模块作为违约缓冲的判断依据。边的权重在真实场景里来自同业业务数据、持仓集中度数据、FTP 融资台账这里用随机数是为了演示结构。注意这里三张图共享同一批节点 ID这就是“多层”在数据结构上的含义不同关系矩阵的行列索引一致传染病跨层时才能通过节点 ID 快速定位“同一家银行在其他层受什么影响”。2.3 参数边界与数据来源中国银行业场景下权重怎么设模型质量在线不在算法在线在权重参数是否贴近实际。下面是三层网络在落地时最常用的权重口径建议。网络层节点含义边权重建议口径数据来源同业借贷层银行机构同业净拆出敞口 / 一级资本净额年报同业业务附注、央行金融稳定报告共同资产持有层银行机构共同持有国债/金融债市值 × 相关性系数债券托管数据、资管产品穿透持仓流动性依赖层银行机构批发性融资余额 / 总负债报表附注、同业存单发行记录权重选择有一个常见误区把同业借贷的直接敞口百分比当作唯一风险度量。但真实冲击中共同资产层往往更致命因为资产价格下跌是通过组合估值传导的多个银行同时踩雷的脆弱性罕见地高。系统设计时建议至少给共同资产层设置 30% 以上的权重把“群体性持仓集中”看成系统性风险的放大器而不是只看个别银行的风险偏好。3. 风险传染模拟从 DebtRank 到多层反馈的 Python 实现3.1 传染动力学拆解违约、资产折价、流动性枯竭的三级连锁先建立传染事件的因果链。初始冲击作用于某家银行例如底层资产违约该银行资本缓冲被侵蚀进入“压力状态”。压力状态下有两种行为抛售资产回笼资金、收缩同业融出。前一种行为直接导致共同资产层价格下跌持有同类资产的银行被迫按市值计损后一种行为抬升同业市场利率让依赖批发性融资的银行融资成本上升。这三层又互相放大资产价格下跌让更多银行资本受损资本受损触发新一轮抛售融资成本上升让部分银行即使没踩雷也面临流动性困境反过来加剧同业收缩。传染过程不再是一轮 DFS 遍历而是多轮迭代的反馈收敛过程。DebtRank 算法最初就是为解决这个反馈问题设计的——它用一个“压力值”替代简单的违约/不违约二元状态允许银行部分受损、部分传染。中国银行业场景里这一层比国际模型更需要本地化调参因为国有大行的隐性背书效应和中小银行的批发性融资依赖度差异极大。传染模拟必须在参数上体现“核心银行抗压能力强但传导半径远”的现实。3.2 基于多层 DebtRank 的传染模拟实现下面是核心模拟代码。它接收三层图、初始受冲击银行、压力传导参数输出每轮各银行压力和最终损失比例。def simulate_multilayer_contagion( layer_dict, node_data, shock_node, rounds10, default_threshold0.04, # 资本缓冲低于4%视为压力触发 fire_sale_discount0.1, # 每轮资产价格折价幅度 liquidity_penalty0.05, # 流动性冲击导致的额外资本消耗 ): # 压力值定义0正常[0,1]表示资本缓冲损耗比例 pressure {n: 0.0 for n in node_data} capital_buf {n: node_data[n][capital] for n in node_data} defaulted set() # 初始冲击源 pressure[shock_node] 1.0 defaulted.add(shock_node) for r in range(rounds): new_pressure dict(pressure) active_banks [n for n, p in pressure.items() if 0 p 1 and n not in defaulted] # 通道1同业借贷层——直接信用损失传导 interbank layer_dict[interbank] for b in active_banks: loss_ratio pressure[b] * 0.3 # 压力银行只部分偿付 for _, debtor, data in interbank.out_edges(b, dataTrue): exposure data[weight] loss exposure * loss_ratio loss_cap capital_buf[debtor] * node_data[debtor][asset] capital_buf[debtor] - min(loss / node_data[debtor][asset], loss_cap) # 通道2共同资产层——资产价格折价反馈 common layer_dict[common_asset] for b in active_banks: for neighbor, data in common.edges(b, dataTrue): overlap data[weight] # 折价损失按共同持有规模比例传递 prev_value node_data[neighbor][asset] * 0.3 # 假设三成资产暴露 loss_value prev_value * fire_sale_discount * overlap / node_data[neighbor][asset] capital_buf[neighbor] - loss_value # 通道3流动性依赖层——融资成本上升损耗利润 liq layer_dict[liquidity] for b in active_banks: for _, borrower, data in liq.out_edges(b, dataTrue): funding_ratio data[weight] / node_data[borrower][asset] capital_buf[borrower] - funding_ratio * liquidity_penalty # 更新压力状态 for n in node_data: if n in defaulted: continue if capital_buf[n] default_threshold: if pressure[n] 0: pressure[n] 0.5 # 首次触发压力状态 elif pressure[n] 0: pressure[n] 1.0 defaulted.add(n) else: if capital_buf[n] node_data[n][capital] * 0.5: pressure[n] max(pressure[n], 0.3) new_pressure pressure if sum(1 for v in new_pressure.values() if v 1) len(node_data) or \ sum(new_pressure.values()) sum(pressure.values()): break pressure new_pressure # 汇总结果 total_asset sum(node_data[n][asset] for n in node_data) loss_assets sum( node_data[n][asset] * p for n, p in pressure.items() ) return { pressure: pressure, defaulted: sorted(defaulted), loss_ratio: loss_assets / total_asset, pressure_sum: sum(pressure.values()), }这段代码的模拟逻辑分三阶段。第一同业借贷层压力银行按其压力程度部分违约对资金拆出方造成直接资本损失。第二共同资产层压力银行抛售资产推动价格下跌所有“共同持债”的邻居按暴露程度承担折价损失。第三流动性层压力银行收缩融出让依赖其资金的银行承担额外融资成本。最终统一判断各银行资本缓冲是否跌破违约阈值跌破即进入违约集合。参数default_threshold0.04的含义是资本充足率降到 4% 以下就视为风险事件触发这个值在实际项目中应参考监管最低要求与银行个体的资本规划。fire_sale_discount0.1表示每轮资产抛售导致价格折价 10%并非累计乘性折价目的在控制恐慌式抛售的边际效果递减。调用方式如simulate_multilayer_contagion(layer_dict, node_data, CMB)输出loss_ratio可以直接作为“系统性损失占银行体系总资产比例”的量化指标。3.3 蒙特卡洛与传染路径分析跑 1000 次看尾部单次模拟只能回答“这个特定冲击会产生多大范围违约”但风险评估需要回答“最坏情况在哪里”。蒙特卡洛做的是随机选择不同初始冲击银行、随机扰动网络权重、重复模拟多次、统计损失分布尾部。def monte_carlo_risk(layer_dict, node_data, n_sim1000): bank_list list(node_data.keys()) results [] for _ in range(n_sim): # 每次随机挑1-2家银行作为初始冲击 shock_size np.random.choice([1, 2], p[0.7, 0.3]) shock_nodes np.random.choice(bank_list, sizeshock_size, replaceFalse) total_loss 0.0 for s in shock_nodes: res simulate_multilayer_contagion(layer_dict, node_data, s) total_loss res[loss_ratio] results.append(min(total_loss, 1.0)) results np.array(results) tail_value np.percentile(results, 95) max_loss results.max() avg_loss results.mean() return { avg_loss: avg_loss, tail_loss_95: tail_value, max_loss: max_loss, loss_distribution: results, }这段代码的价值在输出三个关键数字平均损失代表“常态风险水平”95% 分位损失代表“系统性压力情景”最大损失是极端尾部。中国银行业场景中分析顺序建议是先看最大损失的传染源是哪家银行再看 95 分位情景里哪些银行进入违约集合最后回看共同资产层的持仓重叠度是否解释了传染半径。这种统计口径比单次“假设某大行倒闭”的压力测试更有参考意义因为它覆盖了冲击源的不确定性。4. 系统设计把传染模型变成可评估、可回测、可模拟政策的平台4.1 系统设计要点数据接入层、网络构建层、模拟引擎层、报告层模型本身跑通只是起点真正可用的系统性风险评估平台需要四个层次解耦。第一层是数据接入层对接年报数据、监管报送数据、同业业务台账输出统一格式的银行基础数据表。第二层是网络构建层负责从基础数据表计算边的权重、生成多层网络对象每个层的数据校验逻辑独立。第三层是模拟引擎层引擎不关心数据从哪来只接收网络对象与参数集合运行传染模拟与蒙特卡洛。第四层是报告层输出损失分布图、违约银行清单、压力贡献度排序供评估人员断言风险状况。这四层不能耦合在一起。常见的设计失误是把网络构建参数硬编码在模拟引擎里导致每次调整“共同资产相关性系数”都要改引擎代码。正确做法是参数外置引擎只读取参数文件网络构建和模拟引擎之间通过网络对象传递数据。4.2 核心引擎的类设计与完整示例代码下面给出一套适合直接改造成项目骨架的类设计。核心是三个类Bank表示单家银行MultiLayerNetwork管理多层图结构与构建流程SystemicRiskEngine封装传染模拟与蒙特卡洛。from dataclasses import dataclass, field from typing import Dict dataclass class Bank: bank_id: str capital: float # 核心资本充足率 asset: float # 总资产 ltcr: float # 流动性覆盖率 interbank_out: Dict[str, float] field(default_factorydict) common_holdings: Dict[str, float] field(default_factorydict) liquidity_sensitivity: float 0.1 property def capital_buffer(self): return self.capital * self.asset class MultiLayerNetwork: def __init__(self, banks: Dict[str, Bank]): self.banks banks self.interbank_graph nx.DiGraph() self.common_asset_graph nx.Graph() self.liquidity_graph nx.DiGraph() self._build_layers() def _build_layers(self): for n in self.banks: self.interbank_graph.add_node(n) self.common_asset_graph.add_node(n) self.liquidity_graph.add_node(n) # 从银行对象中读取关系并建边 for bank_id, bank in self.banks.items(): for counterpart, weight in bank.interbank_out.items(): self.interbank_graph.add_edge(bank_id, counterpart, weightweight) for counterpart, weight in bank.common_holdings.items(): self.common_asset_graph.add_edge(bank_id, counterpart, weightweight) class SystemicRiskEngine: def __init__(self, network: MultiLayerNetwork, params: dict): self.network network self.params params def run_stress_test(self, shock_bank: str): layer_dict { interbank: self.network.interbank_graph, common_asset: self.network.common_asset_graph, liquidity: self.network.liquidity_graph, } node_data { n: {capital: b.capital, asset: b.asset} for n, b in self.network.banks.items() } return simulate_multilayer_contagion( layer_dict, node_data, shock_bank, roundsself.params.get(rounds, 10), default_thresholdself.params.get(default_threshold, 0.04), )这套类设计的核心优势在于系统设计上的扩展性。如果未来要加入“跨境敞口层”只需要新增一个图对象、在_build_layers中构建边、并把新层传入传染函数一次——不用改动Bank的数据结构因为新的层可能使用完全不同的边定义方式。参数params字典中每个字段都有默认值新增参数不会破坏现有调用。4.3 参数表与结果表设计怎么存才支持回测与政策对比系统落地时最大的坑是结果不可追溯。一次风险模拟的结果必须能够回答三个问题基于哪一版数据用了哪些参数跑了多少轮因此数据库设计需要同时存数据版本、参数快照和结果明细三张表。-- 参数快照表 CREATE TABLE sim_parameters ( id INTEGER PRIMARY KEY AUTOINCREMENT, param_name TEXT NOT NULL, param_value TEXT NOT NULL, param_version TEXT NOT NULL ); -- 模拟结果表 CREATE TABLE sim_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, param_snapshot_id INTEGER NOT NULL, shock_bank TEXT NOT NULL, loss_ratio REAL NOT NULL, defaulted_banks TEXT NOT NULL, sim_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 每轮压力明细表 CREATE TABLE sim_rounds ( id INTEGER PRIMARY KEY AUTOINCREMENT, result_id INTEGER NOT NULL, round_seq INTEGER NOT NULL, bank_id TEXT NOT NULL, pressure REAL NOT NULL );sim_parameters表和sim_results表通过快照 ID 关联代表“一次完整实验配置的一次运行”。sim_rounds保存逐轮逐银行的压力值支撑传染路径的可视化回放。政策性模拟的核心价值在于反复对比运行一个基准情景再运行“提高资本要求后”的政策情景用同一套模拟引擎只改参数快照对比两个版本的损失分布。提示模拟引擎本身不需要知道“哪个参数对应哪个政策”这是策略分析人员的工作。引擎只保证一件事——同样的输入网络与参数一定产生同样的输出。5. 政策模拟技巧用反事实实验校准参数与验证模型边界反事实模拟counterfactual simulation是政策模拟系统最有价值的功能在历史数据上重跑一轮实验改变一个政策参数观测损失分布的平移。实现成本很低因为系统设计时参数已经外置。例如评估“将系统重要性银行资本缓冲提高 2 个百分点”的效果只需要在参数文件里调整对应银行的起始资本policy_params { rounds: 10, default_threshold: 0.04, capital_floor: 0.06, # 政策参数资本缓冲下限 } # 改造 bank 对象的资本数据 for bank_id, bank in engine.network.banks.items(): bank.capital max(bank.capital, policy_params.get(capital_floor, 0.0))运行两组实验一组是基准参数、一组是政策参数比较loss_distribution的均值和尾部。优秀的分析不只关心“平均损失降低了多少”更要看“最大损失和 95 分位损失是否同步下降”。如果政策只压低了平均损失尾部风险没变化说明政策工具没有切断最关键的传染通道。参数标定建议用公开历史事件做边界验证。例如 2013 年货币市场利率异常波动的时期同业拆借利率大幅走高观察这个时期前后银行的流动性和同业资产结构变化用当时可获取的报表数据重建网络调参数让模型产出的“压力银行集合”与当时实际受到显著冲击的银行类型吻合。这类验证不需要精确复现每家银行的损失金额只要传染的“先后次序”合理就说明模型的跨层触发顺序设计到位。一个马上可以实践的技巧是把参数文件做成 JSON 格式把模拟引擎做成只读配置的方式。比如将上述policy_params存成policy.json通过json.load读入每次模拟都记录配置文件的哈希值这样任何一次结果都能准确找到使用的参数集合。遇到政策讨论需要快速出数时不用改一行代码就能对比五套参数方案——这正是“系统设计”和“写一次性脚本”的分水岭。本文还有配套的精品资源点击获取
返回列表