简介:这份数据集覆盖2007—2023年我国地级市层面的地方债、政府债与隐性债务关键指标,适用于研究地方政府债务风险、财政可持续性、区域金融稳定以及城市面板数据建模的学者、研究生和行业分析人员。数据按地级市口径逐年整理,时间跨度17年,原始债务数据结构完整,可支撑跨年份、跨地区比较。包内共4个文件,其中两个dta文件分别提供剔除区县后的纯净样本与合并后的完整分析表,便于直接进入Stata等统计软件操作;xlsx文件适合快速浏览年度债务明细;html文件收录数据来源说明,方便核查口径与追溯出处。压缩包整体约3.43MB,体量紧凑、下载快速。目前已有51人学习下载。数据集已初步完成清污合并,能显著减少研究者整理数据的时间,适用于债务规模测算、风险预警指标构建、地方财政压力与经济增长关系等实证场景;初学者也可借助xlsx文件快速熟悉数据结构,用作教学或调研的基础数据。
1. 打开 zip 之前,先想清楚地级市债务数据为什么难用
拿到“地级市-地方债、政府债、隐债数据(2007-2023年).zip”这类压缩包,第一反应通常是解压后直接读 Excel。但真正麻烦的不是 zip 解不开,而是包里装着横跨 17 年、三种口径的地级市面板:地方债、政府债、隐性债统计范围不同,发布节奏不同,“债”的定义还可能在某一年发生过切换。不提前设计就直接解压,只会得到一堆名字相近的表格,而不是能分析的数据。
后面按我处理这类数据资产的常规流程展开:先用 zipfile 核对文件清单,再清洗三套债务口径,然后对齐年份与地级市名称,最后校验并重打包。适合需要把历史债务数据落成结构化表格的分析师、金融从业者与数据工程师。zip 在这里不只是压缩格式,更是一份数据交付契约。
2. 用 zipfile 盘点 zip 里的债务文件清单
2.1 解压前先读 namelist,把目录当成数据库来审
拿到这种带年份区间的 zip,直接解压到桌面是失败率最高的做法。文件名里的“2007-2023 年”很容易让人以为数据已经整理好,实际包内文件往往按年份、地市、指标切碎,还带着“最终版”“核对版”这类后缀。我一般会把 zip 当成一个只读数据库,先列一遍全量文件清单,再决定后续怎么读。
import zipfile from pathlib import Path zpath = Path("地级市-地方债、政府债、隐债数据(2007-2023年).zip") with zipfile.ZipFile(zpath, "r") as zf: for info in zf.infolist(): print(f"{info.filename}\t{info.file_size}\t{info.compress_size}")infolist() 返回每个压缩条目的 ZipInfo 对象:filename 是包内路径,file_size 是解压后大小,compress_size 是压缩后大小。两者接近说明该文件本来已压过,基本不用再压缩;差异很大则说明里面是文本型内容。看这几列就能快速判断包里是一张总表还是几百个碎片文件,后续用内存读还是分批处理,从这里就能定下来。
清单确认无误后,先跑一遍完整性检查。testzip() 会逐个校验 CRC,返回第一个损坏的文件名,全部正常则返回 None。这一步放在正式读取前面,能避免读一半才发现某个年份文件损坏。
with zipfile.ZipFile(zpath, "r") as zf: bad = zf.testzip() if bad is not None: print(f"损坏条目: {bad}") else: print("全部条目 CRC 校验通过")实际排障中常见的报错 error read zip archive,多数不是密码问题,而是包体在传输中被截断。先用 testzip() 定位是哪个文件坏了,再决定是找接收方重新要一份,还是从其他镜像目录补齐,比盲目用修复工具压一遍更可靠。
2.2 中文文件名乱码与 Excel 字节流读取
Windows 上生成的 zip,文件名经常用本地代码页编码,而 ZipFile 默认按 cp437 解释,于是中文文件名变成一串乱码。这不是压缩包损坏,而是文件名解码错了。常见做法是把乱码字符串重新编码回原始字节,再用 gbk 或 utf-8 解码。
def fix_zip_name(raw: str) -> str: for enc in ("gbk", "utf-8"): try: return raw.encode("cp437").decode(enc) except (UnicodeDecodeError, UnicodeEncodeError): continue return raw with zipfile.ZipFile(zpath, "r") as zf: for info in zf.infolist(): print(fix_zip_name(info.filename))fix_zip_name 会先按 gbk 解码,失败再试 utf-8,最后回退原字符串。这套逻辑对 2007-2023 这种跨年度归档包很有效,因为不同年份的产出者可能用了不同的压缩客户端。文件名纠正后,再用 open() 配合 pandas 读内存,不需要先解压到磁盘。
import pandas as pd from io import BytesIO with zipfile.ZipFile(zpath, "r") as zf: target = "地级市债务余额.xlsx" with zf.open(target, "r") as f: df = pd.read_excel(BytesIO(f.read()), dtype=str)read_excel 的 dtype=str 是我处理债务数据时坚持的参数。地级市代码、债券编码、年份这类列如果让 pandas 按数值推断,前导零和超长编号会被截断,后续 join 时会出现一堆莫名不匹配。字符串读入后,需要参与计算的列再单独 to_numeric。
2.3 加密 zip 与文件组织方式先判断,再决定处理路径
用 zf.open 读取加密条目时,如果没传 pwd 参数,会看到 RuntimeError 提示口令错误;部分用 AES 加密的 zip 在 Python 3.12 之前根本无法读入,需要先用 7-Zip 转成传统 ZipCrypto 或直接手工解压。债务数据包绝大多数不加密,但一旦上了口令,批量处理逻辑会立刻复杂起来,我会先确认是整包口令还是单文件口令。市面上的 zip 压缩包密码破解工具只能做口令探测,无法绕过算法本身,更实际的路径是从接收渠道确认口令,或者让上游改走 7-Zip 的标准加密。
| zip 内文件组织方式 | 典型表现 | 推荐读取策略 |
|---|---|---|
| 单文件大表 | 一个 Excel 含 17 年多市 | 直接 read_excel,重点处理多级表头 |
| 按年份拆文件 | 每年一个 xlsx | 循环读取并补充年份列 |
| 按地市拆文件 | 每市一个 xlsx | 从文件名提取地级市,读入后打标签 |
| 嵌套目录多版本 | 子目录含 V1/V2/最终版 | 先按时间选版本,不全量合并 |
3. 地方债、政府债、隐性债三套口径的字段清洗
3.1 先定义三张表各算什么债
标题里三个词不是同义反复。地方债是地方政府作为发行主体发行的债券;政府债是纳入预算管理的政府债务口径,通常用限额和余额表达;隐性债则不是政府直接债务,而是通过城投平台等主体形成的负担,需要另行估算或从城投报表里取数。三者统计范围不同,口径切换点也可能落在 2015 年前后。
| 口径 | 常见字段标记 | 指标含义 |
|---|---|---|
| 地方债 | 一般债券、专项债券、新增债、再融资债 | 地方政府发行债券的余额与发行量 |
| 政府债 | 债务限额、债务余额 | 纳入预算管理的政府债务总额 |
| 隐性债 | 城投债、平台有息债务、表外债务 | 城投主体负担、需另行估算的债务 |
拿到 zip 里的原始 Excel,第一步不要着急 merge,而是先看每个文件的“指标名称”列,确认它属于哪一类。部分文件名写的是“政府债”,实际内容却是地方政府债券余额,这时以表内字段为准。
3.2 用口径映射字典一次性收拢指标名称
原始表格里对同一类债的写法往往五花八门,比如“一般债券”“一般债”“新增一般债”可能指向同一个字段。常见做法不是写一长串 if else,而是维护一张口径映射字典,用 Series.map 一次收拢,未命中的值原样保留,方便回头检查。
debt_type_map = { "一般债券": "地方债-一般", "一般债": "地方债-一般", "新增一般债": "地方债-新增一般", "专项债券": "地方债-专项", "专项债": "地方债-专项", "新增专项债": "地方债-新增专项", "再融资债券": "地方债-再融资", "再融资债": "地方债-再融资", "债务限额": "政府债-限额", "债务余额": "政府债-余额", "城投债": "隐性债-城投债", "平台有息债务": "隐性债-平台有息", } df["债务口径"] = df["指标名称"].map(debt_type_map).fillna(df["指标名称"]) unmatched = df[df["债务口径"] == df["指标名称"]] matched = df[df["债务口径"] != df["指标名称"]]map 的查找速度比 apply + if else 快,而且映射表可以作为字段字典随 zip 一起交付。fillna 保证了不在映射表里的新写法不会静默丢失,而是留下来人工补录。清洗时我习惯把 matched 和 unmatched 分开存放,等 un-matched 列表处理完再合并,避免边清洗边丢数据。
提示:如果原始 Excel 是多级表头,比如第一行是“余额/发行/限额”,第二行才是具体指标名,用 pd.read_excel(header=[0, 1]) 读入,再对列名做 join 生成单层字段名。
3.3 流量与存量、余额与限额要分开建模
清洗过程中最容易出错的不是字段匹配,而是把不同时间含义的指标混在一列里。新增债券、再融资债券是流量指标,债务余额、债务限额是存量指标,两者相加没有任何业务含义。我一般会在数据里增加一列“指标类型”,取值分为“存量”“流量”“限额”三类,后续建模时按类型分开使用。
df["指标类型"] = df["债务口径"].map({ "地方债-一般": "存量", "地方债-专项": "存量", "地方债-新增一般": "流量", "地方债-新增专项": "流量", "地方债-再融资": "流量", "政府债-限额": "限额", "政府债-余额": "存量", "隐性债-城投债": "存量", "隐性债-平台有息": "存量", })加完指标类型后可以对存量口径做一次重复检查,避免同一地级市、同一年份、同一债务口径出现多条记录。
dup_check = df[df["指标类型"] == "存量"].groupby( ["地级市", "year", "债务口径"] ).size().reset_index(name="条数") assert dup_check["条数"].max() == 1, "存量口径存在重复记录"债务余额是时点数,债务限额是预算上限,两者不能混用。做时间序列图时选余额,做合规分析时选限额,维度不一样,合并后也要保留两个字段名,不要统一改成“债务总额”。
4. 2007-2023 地级市面板的时间对齐与区划衔接
4.1 把年份从列名里拆出来,从宽表转成长表
债务数据最常见的版式是“地级市一行,2007 到 2023 各占一列”。这种宽表适合人看,不适合机器做时间序列。拿到 zip 里的 Excel 之后,第一步是把年份列收拢成一个 year 字段。
df_wide.columns = [str(c).replace("年", "").strip() for c in df_wide.columns] df_long = df_wide.melt( id_vars=["地级市", "债务口径"], value_vars=[str(y) for y in range(2007, 2024)], var_name="year", value_name="value", ) df_long["year"] = df_long["year"].astype(int)melt 的 id_vars 是保留不变的维度列,value_vars 是需要摊平的年份列,var_name 和 value_name 指定转换后的列名。先统一列名里的“2007年”为“2007”,可以避免 value_vars 列表与真实列名不一致。所有口径都转成长表后,后续才能用“地级市 + 年份”做主键合并。
4.2 区划名称统一后再做复合键合并
2007-2023 横跨 17 年,地级市的名称和范围并不是固定不变的。撤地设市、市辖区调整、县级市改区等都会让同一个城市在不同年份使用不同名称。zip 内的历史数据如果按当年名称登记,直接按“地级市”去重就会把同一城市拆成两条。
常见做法是维护一张区划映射表,列为历史名称、当前名称、生效年份。用 map 关联到主表,生成一个“地级市_当前”字段,再做合并。
region_map = pd.read_excel("区划映射表.xlsx") df_long["地级市_当前"] = df_long["地级市"].map( region_map.set_index("历史名称")["当前名称"] ).fillna(df_long["地级市"])这个逻辑有个前提:映射表本身要先按年份生效范围去维护。比如某市在 2015 年才更名,那么 2015 年之后的记录用新名称,之前的记录原样保留。把生效年份也放进映射表,会比全量替换更稳妥。名称合并后出现的重复行,多半是当年部分区县已划出、数据按新旧口径各记录了一次,需要逐条核对。
4.3 用 _merge 定位两侧缺失的年份与地级市
区划名称统一后,再把地方债表、政府债表和隐性债表按复合键合并。合并时不要直接用“年份”或“地级市”单独 merge,否则会产生笛卡尔积。
df_place = df_long[df_long["债务口径"].str.startswith("地方债")] df_gov = df_long[df_long["债务口径"].str.startswith("政府债")] merged = pd.merge( df_place, df_gov, on=["地级市_当前", "year"], how="outer", indicator=True, ) print(merged["_merge"].value_counts())how="outer" 在这里优先保证不丢城市,indicator=True 会在结果中生成 _merge 列,标记每条记录来自 left_only、right_only 还是 both。看到 left_only 或 right_only 占比过高,通常是两侧年份范围或地级市名称不一致,需要回溯第 3 章的口径映射或第 4.2 的区划映射。
| _merge 取值 | 含义 | 优先检查方向 | | left_only | 只在地方债表出现 | 政府债表缺城市或年份 | | right_only | 只在政府债表出现 | 地方债表缺城市或年份 | | both | 两侧键能对上 | 继续检查数值口径与单位 |
5. 批量校验与 zip 重打包,交付前再跑一遍完整检查
5.1 用断言校验面板主键与数值边界
清洗完成后,不要急着交付。先写一组断言把“能通过”变成硬性条件,再往下跑。
assert merged["地级市_当前"].notna().all(), "存在空地级市" assert merged["year"].between(2007, 2023).all(), "年份越界" assert merged[["地级市_当前", "year"]].duplicated().sum() == 0, "主键不唯一" assert (merged["balance"] >= 0).all(), "余额列出现负数"这些检查比随机抽查可靠:主键不唯一是最隐蔽的问题,多出现在区划调整年份;负数则是口径清洗阶段流量和存量混用的典型后遗症。断言的错误信息要写清楚,不要只抛一个 AssertionError。
5.2 重打包时设置压缩级别与可选口令
交付时直接把最终 CSV 和字段字典写回一个新的 zip,不要在原始 zip 上增删。写回时用 ZIP_DEFLATED 并控制 compresslevel,既能压缩,又不会让大文件卡死。
with zipfile.ZipFile( "地级市债务面板_清洗版.zip", "w", zipfile.ZIP_DEFLATED, compresslevel=6, ) as zf: zf.write("地级市债务_面板.csv", "data/地级市债务_面板.csv") zf.write("字段字典.csv", "doc/字段字典.csv")compresslevel=6 是压缩率与速度的默认平衡点。如果交付文件走网盘分发,还可以用 setpassword(b"your-pass") 给整个压缩包加口令。工程上我不推荐依赖 zip 密码移除工具做后处理,更常见的做法是明确口令规则、写进交付说明,避免接收方二次解压成本过高。
5.3 跑 testzip 确认交付物完整
重打包完成后再用 testzip() 自检一次,作为交付前最后一道工序。这个动作不是形式主义,因为写回过程中一旦进程被杀或磁盘占满,zip 的中央目录已经写入但部分数据未落盘,系统仍然会判定写入成功。testzip 返回 None,说明这个 zip 可以直接交给下游。
本文还有配套的精品资源,点击获取