资讯动态

年鉴数据自动化更新:从解析到增量比对的工程实践

发布时间:2026/10/14 3:40:20 来源:尧图企业网站定制
简介这份资源面向城市经济、社会学与环境科学等领域的研究者及数据分析开发者提供《中国城市统计年鉴》1985—2024年全套数据的配套可运行源码帮助解决年鉴数据获取、整理与二次开发的问题。压缩包共3个文件以inscode工程配置、html页面与gitignore为主整体约5KB属于轻量级代码包便于快速部署与二次利用。资源描述显示年鉴涵盖地级市与县级市的人口、资源环境、经济发展、科技创新、人民生活、公共服务等指标并区分原版数据、截面数据与面板数据需注意1997年起两类城市统计制度不同、部分指标不可比。目前已有183人学习下载读者可借助源码对年鉴数据进行结构化处理、可视化展示与统计分析为城市发展研究、政策评估和课题写作提供可复用的工具脚本与排错思路。1. 年鉴数据更新这件事卡住人的从来不是爬虫每年《中国城市统计年鉴》出新版后台总有人问同一件事去年写好的解析脚本今年跑一遍就报错字段对不上、城市名多了空格、指标列顺序变了。表面看是数据源变了实际是整条更新链路没有做「结构校验」这一层。我做过几轮城市统计数据的自动化更新最深的体会是抓取只占两成工作量剩下八成全花在字段映射、口径对齐和增量比对上面。这篇笔记就围绕「年鉴数据更新」这个场景把一套可运行的源码思路拆开讲——从目录结构怎么定、解析器怎么写、增量怎么算到每年必踩的那几个坑。适合手里已经有历史年份数据、需要每年滚动更新的数据工程同学也适合第一次接手这类统计年鉴整理任务的人。读完你应该能自己搭出一条从原始表格到可比面板数据的流水线而不是每年重新手工对一遍 Excel。2. 先想清楚年鉴数据的结构再动手写解析器2.1 年鉴表格为什么不能直接当规整表读统计年鉴里的表本质是「多层表头 合并单元格 脚注」的混合体。以城市主要指标表为例第一行往往是「指标名称 / 单位 / 地区」第二行开始才是具体城市而部分年份会把「市辖区」和「全市」拆成两组列。用 pandas 直接read_excel读进来前几行是脏的列名是Unnamed: 3这种合并单元格还会让城市名只出现在第一行、下面全是 NaN。常见做法是分两步先用headerNone把整张表当无表头矩阵读进来再自己定位表头行和数据起始行。定位逻辑不能写死行号因为不同年份可能差一两行。我一般用「某一行里同时出现『城市』『地区』这类关键词」来判断表头行用「第一列出现省级行政区名称」来判断数据区起点。import pandas as pd def locate_header_and_data(raw: pd.DataFrame, header_keywords(城市, 地区, 指标)): 在无表头矩阵里定位表头行和数据起始行 header_row None for i in range(min(8, len(raw))): # 表头一般在前 8 行内 row_values raw.iloc[i].astype(str).tolist() hit sum(1 for kw in header_keywords if any(kw in v for v in row_values)) if hit 2: # 至少命中两个关键词才算表头 header_row i break if header_row is None: raise ValueError(未定位到表头行请检查原始表结构) # 数据区起点表头行之后第一列首次出现非空且非纯数字的值 data_start header_row 1 for j in range(header_row 1, len(raw)): cell str(raw.iloc[j, 0]).strip() if cell and not cell.replace(., ).isdigit(): data_start j break return header_row, data_start这段代码的关键参数是header_keywords和hit 2。关键词要按你手头年鉴的实际用词调整有的年份写「城市名称」有的写「地区」。命中阈值设 2 是为了防止某一行恰好只有一个词就误判。data_start的判定逻辑是「第一列出现非数字文本」因为数据区第一列通常是城市名或省份名而表头区第一列往往是空的或写着「地区」。2.2 字段映射表要单独维护不要写进解析函数每年年鉴最烦人的变化是指标列顺序调整、指标名微调。如果把「第 5 列是 GDP」这种逻辑写死在解析函数里第二年必翻车。我的做法是维护一张独立的字段映射表用 YAML 或 CSV 存每年更新时只改这张表。原始列名当年标准字段名单位是否必填地区生产总值万元gdp万元是年末常住人口万人population万人是一般公共预算收入万元fiscal_revenue万元否市辖区地区生产总值gdp_urban万元否映射表里「原始列名」要写成模糊匹配模式因为年鉴里经常带全角括号、空格。匹配时先做归一化去掉所有空白、把全角括号转半角、去掉「其中」这类前缀。import re def normalize_col(name: str) - str: 列名归一化去空白、全角转半角、去前缀 name str(name) name re.sub(r\s, , name) name name.replace(, ().replace(, )) name re.sub(r^(其中[:]|市辖区[:]), , name) return name def build_column_map(raw_columns, mapping_table): 把原始列名映射到标准字段名 col_map {} normalized {normalize_col(c): c for c in raw_columns} for std_field, patterns in mapping_table.items(): for pat in patterns: pat_norm normalize_col(pat) for norm_name, orig in normalized.items(): if pat_norm in norm_name: col_map[orig] std_field break return col_mapnormalize_col里那几个正则不是拍脑袋写的是踩过坑之后加的有一年数据里「地区生产总值万元」中间夹了个换行符直接匹配死活对不上。build_column_map用「包含」而不是「相等」来判断是为了兼容年鉴在指标名后面加年份注释的情况。2.3 用配置驱动的方式跑通一次完整更新把定位、映射、清洗串起来主流程应该短到一眼能看完。配置里放文件路径、年份、映射表路径代码里只放逻辑。import yaml import pandas as pd def update_yearbook(raw_path: str, year: int, mapping_path: str) - pd.DataFrame: cfg yaml.safe_load(open(mapping_path, encodingutf-8)) raw pd.read_excel(raw_path, headerNone, dtypestr) header_row, data_start locate_header_and_data(raw) header raw.iloc[header_row].tolist() body raw.iloc[data_start:].copy() body.columns [normalize_col(h) for h in header] col_map build_column_map(body.columns, cfg[columns]) body body.rename(columnscol_map) keep [c for c in body.columns if c in cfg[columns]] body body[keep].copy() body[year] year body[city] body.iloc[:, 0].astype(str).str.strip() # 数值列统一转 float无法转换的置 NaN 并记录 for col in keep: if col ! city: body[col] pd.to_numeric(body[col], errorscoerce) return bodydtypestr很关键先全部当字符串读进来避免 pandas 自动把「1,234」解析成对象或把城市代码前导零吃掉。errorscoerce会把「—」「…」这类占位符转成 NaN这是统计年鉴里最常见的缺失表示。跑完这一步你拿到的是一张带 year 列的长表后面做增量比对就方便了。3. 增量更新与跨年比对怎么判断「哪些城市变了」3.1 用城市代码做主键别用城市名城市名是最不靠谱的主键。同一座城市有的年份写「XX市」有的年份不带「市」行政区划调整后名字直接变。我一般优先用行政区划代码做主键如果年鉴里没给代码就用「省份 城市名归一化」拼一个代理键。def make_city_key(df: pd.DataFrame, province_col: str, city_col: str) - pd.Series: 生成城市代理主键省份城市名去掉市/地区/自治州等后缀 suffix r(市|地区|自治州|盟|自治县|区)$ prov df[province_col].astype(str).str.strip() city df[city_col].astype(str).str.strip().str.replace(suffix, , regexTrue) return prov _ citysuffix那个正则要按你数据里实际出现的后缀补全。去掉后缀是为了让「XX市」和「XX地区」在区划调整前后能对上。代理键生成后建议单独存一列不要每次比对时现算否则一旦归一化规则改了历史比对结果全乱。3.2 增量比对三种变化要分开处理跨年比对不是简单merge一下就完事。实际会遇到三类变化新增城市去年没有今年有、消失城市去年有今年没有、指标值变化。前两类要人工确认是不是区划调整第三类才是正常的数值更新。def diff_two_years(old: pd.DataFrame, new: pd.DataFrame, keycity_key): old_keys set(old[key]) new_keys set(new[key]) added new_keys - old_keys removed old_keys - new_keys common old_keys new_keys old_c old[old[key].isin(common)].set_index(key) new_c new[new[key].isin(common)].set_index(key) value_cols [c for c in old.columns if c not in (key, year, city)] changed {} for col in value_cols: if col not in new_c.columns: continue mask ~old_c[col].fillna(-999999).eq(new_c[col].fillna(-999999)) changed[col] new_c.index[mask].tolist() return {added: sorted(added), removed: sorted(removed), changed: changed}fillna(-999999)是个小技巧统计年鉴里缺失值很多如果两边都是 NaN不应该算「变化」。用一个不可能出现的哨兵值填充后再比较能避免大量假阳性。changed按列返回方便你定位是哪个指标在动。3.3 把比对结果落成可复查的报告比对完不要只在控制台打印落成 CSV 或 Markdown 报告方便逐年归档。报告里至少要有新增/消失城市清单、每个指标的变化城市数、变化幅度最大的前若干条。def write_diff_report(diff: dict, out_path: str, top_n: int 20): lines [# 年鉴更新比对报告, ] lines.append(f新增城市{len(diff[added])} 个) lines.append(f消失城市{len(diff[removed])} 个) lines.append() lines.append(| 指标 | 变化城市数 |) lines.append(|---|---|) for col, cities in diff[changed].items(): lines.append(f| {col} | {len(cities)} |) with open(out_path, w, encodingutf-8) as f: f.write(\n.join(lines))报告里「变化城市数」这个指标很有用如果某个指标突然几百个城市都变了大概率是单位换了万元变亿元或者口径调整这时候要回去查年鉴的脚注而不是直接采信。4. 避坑与排查年鉴更新里最容易翻车的五件事4.1 现象数值列全是 NaN原因单位行被当成数据行有一年更新完GDP 列整列 NaN。排查发现表头下面还有一行「单位万元」被当成了第一条数据to_numeric转不动就全变 NaN。解决在data_start判定之后再加一层过滤跳过第一列是「单位」「计量单位」这类词的行。4.2 现象城市数量对不上原因脚注混进了数据区年鉴表格底部常有「注本表数据不含 XX」这类脚注读进来会多出几行「城市」。解决在清洗阶段按「第一列长度超过 10 个字符」或「包含『注』『说明』」来过滤尾部行。更稳的做法是限定数据区行数用省份名列表做白名单校验。4.3 现象跨年比对全是变化原因单位不一致去年 GDP 单位是万元今年改成亿元数值差了一万倍比对结果自然全变。解决在字段映射表里给每个指标标一个标准单位解析时统一换算。换算系数也放配置里不要写死在代码。4.4 现象合并单元格导致城市名缺失原因直接读表头多层表头里省份名往往只在第一行出现下面合并单元格是空的。解决读进来后对第一列做前向填充ffill但要注意只在省份列填充不要污染城市列。填充前先确认哪些行是省份行通常第一列非空、第二列空。4.5 现象脚本去年能跑今年报错原因表头关键词变了年鉴编辑每年可能微调用词比如「地区」改成「城市」。解决把关键词做成配置项并且加一个「定位失败时打印前 10 行原始内容」的调试分支。我一般会在locate_header_and_data失败时把raw.head(10)写进日志下次改配置就有依据。5. 让更新流程可复现的一个小习惯把每年原始文件哈希存档最后说一个我坚持了几年的习惯每次更新前先算一遍原始文件的 SHA256连同年份、文件名、行数列数一起写进一个manifest.csv。这么做有两个好处一是明年再跑时能确认「我用的确实是这一版原始文件」二是当比对结果异常时能快速排除「文件本身被换过」这种低级问题。import hashlib import csv import os def record_manifest(raw_path: str, year: int, manifest_path: str manifest.csv): h hashlib.sha256() with open(raw_path, rb) as f: for chunk in iter(lambda: f.read(8192), b): h.update(chunk) row { year: year, file: os.path.basename(raw_path), sha256: h.hexdigest(), size: os.path.getsize(raw_path), } write_header not os.path.exists(manifest_path) with open(manifest_path, a, newline, encodingutf-8) as f: writer csv.DictWriter(f, fieldnamesrow.keys()) if write_header: writer.writeheader() writer.writerow(row)chunk大小设 8192 是常规选择年鉴文件一般几十 MB分块读不会吃内存。manifest.csv建议和源码一起进版本控制这样每年更新就是一次可追溯的提交。配合前面那套配置驱动的解析流程整个年鉴更新就从「每年手工对表」变成了「改配置、跑脚本、看报告」三步。我自己踩过最狠的一次坑是某年直接覆盖了原始文件结果比对异常时连回滚的样本都没有从那以后哈希存档就成了肌肉记忆。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑