
简介《2021中国不良资产管理行业改革与发展白皮书》由普华永道研究团队编写以转型为切入点面向不良资产从业者、金融机构研究员与相关专业师生系统梳理行业现状、挑战与改革路径。全文为单个PDF文件压缩包约5.49MB涵盖综述、行业发展新常态、大型资产管理公司业绩概览及行业转型专题等章节并附中国资产管理公司名录便于按模块检索。内容包含2020年不良资产整体规模升至5.21万亿元、商业银行转出债权3428亿元、成交价多在三至五折且区域分化明显等市场数据也梳理了监管新规、第五家全国性AMC入局、地方AMC增至58家等参与者变化。报告重点提出助力低碳转型、化解房地产风险、参与个贷不良转让交易三条转型赛道可帮助读者把握行业格局与转型方向。已有508人学习下载。1. 不良资产管理白皮书把它当数据集读而不是当报告读多数人拿到《2021中国不良资产管理行业改革与发展白皮书》习惯从头翻到尾合上之后只记得“5.21万亿”“三到五折”这两个数字。但这份报告真正的价值在于它带了一整套可量化的表规模拆分表、16家上市银行的核销与转出表、债权买方占比表、区域与省份分布表、监管政策时间表。这些内容全是半结构化数据抠出来就能二次计算。换个角度这是一次小型的行业数据工程练习PDF 抽取 → 结构化建模 → 派生指标 → 口径校验。做完之后你手里不只是一份报告而是一个能反复查询、能加断言、能换年份继续灌数据的库。对做金融 IT、数据中台、投研工具的人这条链路的每一环都用得上对只想搞清行业盘子有多大的人走完一遍也比读十遍原文记得牢。下面按抽取、建模、赛道量化、校验四步走代码全部可跑。2. PDF表格抽取pdfplumber 坐标裁剪还原四张核心表2.1 先分清四张表的版式差异这份白皮书的表格版式并不统一直接整页extract_table()大概率会把页眉页脚、页码、注释一起吞进来。先做一次勘察把每张表的页号、线型、列数记下来比上来就写解析代码省事得多。表名内容版式特征抽取难点不良资产规模表三类供方金额与同比增幅有完整横竖线千分位逗号、百分号需转换银行核销转出表16家银行核销金额与不良余额竖线缺失靠文字对齐需按文字 x 坐标切列买方占比表AMC 购入金额与占比只有横线饼图标注与表格混排区域分布表六大区域占比图上标注非表格只能从图注文字里取提示抽取前先确认 PDF 是文本型还是扫描型。用page.extract_text()返回空串就说明是扫描版需要先做识别再谈裁剪。2.2 用坐标裁剪把表格区域框出来crop()是这套流程里最实用的一个方法先按页面物理坐标框出一块矩形再在这块区域内抽表页眉、页码、插图自然被排除在外。import pdfplumber PDF 2021中国不良资产管理行业改革与发展白皮书.pdf def crop_table(page, box): 按坐标裁一块区域再抽表box (x0, top, x1, bottom) crop page.crop(box) return crop.extract_table({ vertical_strategy: text, # 竖线缺失时按文字对齐位置切列 horizontal_strategy: lines, # 横线是实线直接采信 intersection_tolerance: 5, # 容差数字错位时调大 text_tolerance: 2, }) with pdfplumber.open(PDF) as pdf: page pdf.pages[88] # 物理页号通常与印刷页码有固定偏移 rows crop_table(page, (60, 180, 540, 330)) for r in rows[:3]: print(r)坐标不用手工试错先用page.extract_words()打印关键词的x0/top/x1/bottom用表头文字定位上边界、用表尾合计行定位下边界。vertical_strategytext是关键参数它让 pdfplumber 依据文字块的横向间距推断列分隔线专门治这种只有横线的表。如果抽出来串列把text_tolerance调小到 1或者改用explicit_vertical_lines手动给列坐标。2.3 竖线缺失时的兜底按文字坐标聚类切列有些页面连横线都没有靠extract_table直接歇菜。这时候退一步自己按 x 坐标聚类做列边界def split_by_columns(page, box, tol3): 把区域内文字按 x0 聚类成列返回行列表 crop page.crop(box) words crop.extract_words(keep_blank_charsFalse) # 收集所有左边界容差内归为同一列 xs sorted({round(w[x0] / tol) * tol for w in words}) bounds [] for x in xs: if not bounds or x - bounds[-1][-1] 8: bounds.append([x]) else: bounds[-1].append(x) centers [sum(b) / len(b) for b in bounds] rows {} for w in words: key round(w[top] / 4) # 4pt 内视为同一行 col min(range(len(centers)), keylambda i: abs(centers[i] - w[x0])) rows.setdefault(key, {})[col] w[text] return [[r.get(i, ) for i in range(len(centers))] for _, r in sorted(rows.items())]思路是把二维文字流压回二维表格先聚类出列中心再按top分桶成行最后补空单元格。tol控制列合并的松紧数字列宽度接近时调到 23 比较稳如果表头是跨列标题聚类会把标题单独当成一列抽完记得丢掉前一两行。2.4 落盘前先做一遍量纲归一抽出来的原始字符串长这样2.70、12.50%、1,203。单位混着“万亿元”和“亿元”必须在入口处统一不然后面聚合必翻车。import re def to_float(s, unit亿): 把 1,203 / 12.50% / 2.70 统一成浮点数 if s is None: return None s s.strip().replace(,, ).replace(, ) m re.search(r-?\d(\.\d)?, s) if not m: return None v float(m.group()) if s.endswith(%): return v / 100.0 # 百分比一律转成小数 if unit 万亿: return v * 10000 # 万亿 → 亿全库统一以亿为最小单位 return v我一般把单位统一成“亿元”万亿级的数字乘 10000 存进去。百分比一律转小数展示时再乘 100。这一步做完后面所有加法、占比、分解才有意义。3. 用 pandas 与 SQLite 建不良资产数据模型3.1 表结构三张宽表够用了抽取结果别直接扔 Excel落成 SQLite 更省事——单文件、可查询、能给下游做接口。表结构按“事实表 维度表”的思路拆三类供方是事实银行和区域是维度。import sqlite3, pandas as pd con sqlite3.connect(npl.db) con.executescript( CREATE TABLE IF NOT EXISTS scale_2020 ( segment TEXT PRIMARY KEY, -- 商业银行 / 非银金融机构 / 非金融企业 amount REAL NOT NULL, -- 2020年末规模亿元 yoy REAL NOT NULL -- 同比增幅小数 ); CREATE TABLE IF NOT EXISTS bank_disposal ( bank TEXT PRIMARY KEY, writeoff REAL, -- 贷款准备金核销及转出亿元 npl_bal REAL -- 年末不良贷款余额亿元 ); CREATE TABLE IF NOT EXISTS region_share ( region TEXT PRIMARY KEY, share REAL -- 债权交易占比小数 ); )amount存亿元而不是万亿是为了和银行表的量级对齐避免后续 join 时出现数量级错位。yoy存小数不存字符串是让1 yoy能直接参与运算——这一步偷懒后面反推 2019 年基数时就得写正则。3.2 派生指标处置强度与同比增量分解原始表只给了 16 家银行的核销转出金额和年末不良余额两个数一除就是“处置强度”它比单看余额更能反映一家银行真实的不良出清节奏。bank pd.read_sql(SELECT * FROM bank_disposal, con) bank[disposal_ratio] bank.writeoff / bank.npl_bal bank bank.sort_values(disposal_ratio, ascendingFalse) print(bank[[bank, writeoff, npl_bal, disposal_ratio]].head(6)) print(合计处置强度%.2f%% % (bank.writeoff.sum() / bank.npl_bal.sum() * 100))跑出来的合计值约为 65.9%10772 / 16342接近报告口径的 66%。分机构看差异极大平安银行约 199%、光大银行约 135%说明当年核销转出规模远超年末余额属于典型的“边生成边出清”而邮储银行约 29%、中国银行约 31%处置节奏明显更缓。这个比值超过 100% 不代表不良被消灭只说明当年出清的绝对量大于年末存量两者是流量与存量关系解读时别混。低于 40% 的机构往往是不良生成压力相对可控或者核销审批节奏偏保守。再看规模表的增量分解这一步能验证白皮书自身口径是否自洽scale pd.DataFrame( [(商业银行, 2.70, 0.125), (非银金融机构, 1.14, 0.629), (非金融企业, 1.37, 0.142)], columns[segment, amount, yoy]) scale[base_2019] scale.amount / (1 scale.yoy) # 反推 2019 年基数万亿 scale[delta] scale.amount - scale.base_2019 # 当年增量 scale[share_20] scale.amount / scale.amount.sum() scale[share_19] scale.base_2019 / scale.base_2019.sum() print(scale.round(3))反推结果三类供方 2019 年基数约 2.40 / 0.70 / 1.20 万亿2020 年增量约 0.30 / 0.44 / 0.17 万亿合计增量约 0.91 万亿与报告的“增加 9,000 亿元”吻合到小数点后两位。占比也自然落到 52% / 22% / 26% 对 56% / 16% / 28%正好对上报告里那张来源占比变化图。3.3 区域占比合不上 100% 怎么处理区域表的六个占比加起来是 93.6%差 6.4 个百分点。区域占比按 3428 亿折算华东48.40%1659 亿华中15.00%514 亿西南11.10%380 亿东北9.50%326 亿华北8.10%278 亿西北1.50%51 亿未列示6.40%219 亿region pd.read_sql(SELECT * FROM region_share, con) gap 1 - region.share.sum() region[amount] region.share * 3428 # 全年商业银行转出债权 3428 亿 print(region.sort_values(share, ascendingFalse)) print(未列示占比%.2f%% % (gap * 100))常见做法是把缺口单独列成“未分类”绝不按比例摊回各区域——摊回去等于凭空造数。缺口可能来自统计口径差异也可能来自部分省份未披露做图时用一行“其他”兜住比强行归一化诚实得多。四个重点省份浙江 575、江苏 412、河南 345、山东 300 亿合计 1632 亿占全年转出量的 47.6%这个集中度比区域占比更能说明问题。4. 三条转型赛道的量化落地打分卡、个贷估值与地产现金流4.1 把三条赛道拆成可打分的字段报告提的三条赛道——绿色低碳转型、化解房地产风险、个贷不良转让——本身是定性建议。要落到系统里先得定义每个标的在每条赛道上的可观测字段否则没法比排序。赛道关键字段数据来源权重建议绿色低碳行业能耗等级、技改投入占比、可重组债权占比企业年报、环评公示0.35 / 0.35 / 0.30房地产项目区位、抵押物覆盖率、去化周期抵押登记、网签数据0.40 / 0.35 / 0.25个贷不良平均账龄、户均余额、是否已诉资产包尽调清单0.30 / 0.20 / 0.50import numpy as np import pandas as pd WEIGHTS { green: {energy: 0.35, retrofit: 0.35, restructure: 0.30}, property: {location: 0.40, coverage: 0.35, sellthrough: 0.25}, retail: {vintage: 0.30, ticket: 0.20, litigation: 0.50}, } def score(row, track): 各字段需先归一化到 0~1权重加总恒为 1 return sum(row[k] * w for k, w in WEIGHTS[track].items())权重不是拍脑袋定的绿色低碳里“可重组债权占比”直接决定能不能用债务重组而不是简单退出所以给到 0.30个贷不良里“是否已诉”对应后续司法清收的可执行性实操中影响最大给到 0.50。权重可以按机构偏好调但加总必须归一否则跨赛道分数不可比。4.2 个贷不良资产包的回收率曲线估值个贷包的定价核心是回收率曲线行业里叫账龄曲线MOB。给定一条累计回收率曲线估值就是在折现率下把未来回收减掉催收成本。import numpy as np def price_pool(balance, curve, monthly_cost, discount0.12, months36): balance: 资产包未偿本息费总额元 curve: 按月的累计回收率列表curve[t] 为第 t 个月累计回收比例 monthly_cost: 每月催收与运营成本元 discount: 年化折现率 curve list(curve[:months]) rec np.diff([0.0] curve) * balance # 各月新增回收现金流 t np.arange(1, len(rec) 1) df (1 discount) ** (t / 12) # 月现金流按年化折现指数用 t/12 pv_in (rec / df).sum() pv_cost (monthly_cost / df).sum() return float(pv_in - pv_cost), float(pv_in / balance) # 一条典型曲线前 6 个月回 60%12 个月回 78%36 个月收敛到 88% curve [0.18, 0.32, 0.44, 0.52, 0.57, 0.60] [0.60 0.18 * i / 6 for i in range(1, 7)] curve [0.78 0.10 * (1 - np.exp(-i / 8)) for i in range(1, 25)] pv, recovery price_pool(balance5e8, curvecurve, monthly_cost3e6, discount0.12) print(净现值%.2f 万元回收率%.2f%% % (pv / 1e4, recovery * 100))三个参数决定成败curve的形态必须来自同类型资产的历史数据信用类个贷和信用卡透支的曲线形状完全不同混用必亏discount一般取资金成本加风险溢价常见区间 10%15%monthly_cost包含催收佣金、诉讼费、系统分摊容易被低估。回收率对折现率不敏感、对曲线前 12 个月的形态极其敏感做尽调时优先验证前 12 个月的回收假设。4.3 困境地产处置先算现金流再谈方案地产项目处置周期长估值不能只看抵押物评估价得按重整方案的现金流入节点折现。def project_npv(cashflows, exit_value, discount, years): cashflows: 各年净现金流入万元exit_value: 退出年变现净额 flows list(cashflows) [exit_value] return float(sum(cf / (1 discount) ** i for i, cf in enumerate(flows, 1)))实际做的时候我会跑三档情景乐观、中性、悲观把去化周期、售价折让、续建成本各设三个水平交叉出一张敏感性矩阵看中性情景净现值是否覆盖债权本金。低于本金的部分才是真正需要重组的空间这个差额也是和各方谈重整方案时的量化锚点。处置周期超过五年的项目折现率的微小变化会大幅吃掉净现值所以续建能不能按期完工比售价假设更值得盯。5. 口径校验与一键复现让白皮书数字变成可回归的测试5.1 三个最容易对不上的地方单位混用。规模表用万亿、银行表用亿直接相加会得到离谱结果。全库统一到亿元后5.21 万亿应写成 52100。占比不足 100%。区域表六项合计 93.6%买方表三项合计却正好 100%2250 933 245 3428 亿占比 65.6% / 27.2% / 7.1%与报告的 66% / 27% / 7% 一致。同样是占比表一张能直接归一一张不能处理策略必须区分开。定性表述无法当数用。“成交价格多在三到五折”是区间描述不能塞进浮点字段。合理的做法是给价格区间单独建一张表存price_low / price_high查询时用BETWEEN而不是硬凑一个中位数。5.2 把口径固化成断言校验不该靠人眼看写成测试用例换成 2019 或 2022 年数据照样能跑。# test_whitepaper.py import pandas as pd def load_scale(): return pd.DataFrame( [(商业银行, 2.70, 0.125), (非银金融机构, 1.14, 0.629), (非金融企业, 1.37, 0.142)], columns[segment, amount, yoy]) def test_total_matches_header(): 三类供方合计应等于报告的 5.21 万亿 s load_scale() assert abs(s.amount.sum() - 5.21) 0.01 def test_delta_matches_narrative(): 增量分解应落在报告的 9000 亿口径内 s load_scale() base (s.amount / (1 s.yoy)).sum() assert abs((s.amount.sum() - base) - 0.90) 0.02 def test_region_share_has_gap(): 区域占比合计应有约 6.4% 缺口若为 1.0 说明抽表吞了重复行 shares [0.4840, 0.1500, 0.1110, 0.0950, 0.0810, 0.0150] assert 0.93 sum(shares) 0.94test_region_share_has_gap看起来是反直觉的断言——刻意让测试“不通过”。它的作用是抓抽取回归如果哪天换了解析配置把某一行读重了合计瞬间变成 100%这个断言会立刻报警。数据工程的测试不只在验证正确也在守住已知的“不正确”。跑起来pip install pdfplumber pandas numpy pytest pytest test_whitepaper.py -v5.3 一个能省半天的技巧把 PDF 页码偏移量单独写进配置而不是硬编码在解析函数里。印刷页码和物理页号之间通常差一个固定值封面、目录、附录各占几页在换版本时会变。做法是先扫一遍全文找到含“第一节 市场规模和交易情况”的那一页记下它的物理页号再和印刷页码相减def find_page_offset(pdf, marker市场规模和交易情况, printed14): for i, page in enumerate(pdf.pages): if marker in (page.extract_text() or ): return i - (printed - 1) # 返回偏移量供所有页面复用 raise LookupError(锚点文字未命中检查是否扫描版)偏移量算出来之后之后所有的pdf.pages[i offset]都自动对准换年份版本时只需要改锚点文字和印刷页码两个参数。同一套抽取逻辑我已经在好几份机构年度报告上直接复用过改动量基本为零。本文还有配套的精品资源点击获取