第七次人口普查公开数据出来之后我的第一反应不是去搜现成结论而是想把那套到处传的表格变成能交互点击的图。接到类似任务的同行应该有同感Excel里的数据维度很全省份、年龄、性别、受教育程度、城乡都有但打开表格一屏只能看到三五列区域之间没法直接比较结构变化也藏在一行行数字里。我最终把方案落在了 pyecharts 上从数据清洗到人口金字塔、省际地图、占比图再到多图联动页面整套流程走完之后才意识到这个活儿真正难的不是画图而是怎么把口径不一的数据变成能直接喂给图表的干净表格。下面的内容是对整个过程的完整复盘。想把公开统计数据做成可视化页面、或者准备用 pyecharts 做同类数据分析的读者可以照这条路径走一遍。1. 普查数据可视化为什么选 pyecharts1.1 表格数据看着全用起来难这类公开数据有个共同特征发布形态是分地区、分年龄、分性别的汇总表不是一行一条的微观记录。打开一个文件行是几十个地区列是总人口、男性、女性、城镇化率再打开另一个文件行变成了年龄段列变成了男女和占比。单看任何一张表都很干净但人脑不擅长从数字矩阵里直接建立空间和结构感知。A地区比B地区多多少人光看表格要心算年龄结构是年轻还是老化要来回扫很多行才有个模糊印象教育程度层级之间的差距有多大单纯读数字也很难快速形成画面。这些恰恰是可视化的价值——把数字映射成位置、长度、面积和颜色让差异变成一眼能接收的视觉信号。所以这个项目的目标从一开始就不是“画得好看”而是“让看图的人比看表格的人更快形成判断”。1.2 三套方案的取舍记录动手之前我对比过三套路线。第一套是 matplotlib。它画静态图很稳适合论文和打印场景但交互能力几乎为零放大、悬停、查看具体数值都做不到。中文字体在部分环境里还要额外设置批量出图时字体问题会反复出现。第二套是通用 BI 工具。拖拽字段就能出图初次上手很快但遇到定制需求就会绕我要做镜像的人口金字塔、要按自己的分段逻辑控制地图颜色、要把同一套数据在不同图型之间快速切换这些在 BI 产品里反而受产品本身的边界限制。而且数据更新以后重新配置一遍的流程并不比写脚本省事。第三套就是 pyecharts。它的底层是浏览器端图表库 ECharts所以交互、提示、缩放、图例都是现成的在 Python 里用链式调用组织图表数据和配置在同一个脚本里修改数据后重新运行就能整批出新图。中文场景下地图和名称匹配也是成熟的方案生成结果是一个独立的 HTML 文件浏览器打开就能用不依赖对方电脑里有没有 Python 环境。方案交互性定制自由度中文地图支持批量出图matplotlib弱中需要手动调强通用 BI 工具强受产品边界限制一般弱pyecharts强中高内置强1.3 动手前先列能力清单这个项目表面上只需要“画图”实际需要五块能力用 pandas 读取 Excel 并清洗数据把多张表按字段对齐掌握 pyecharts 的 Bar、Pie、Map、Tab 四个基础图型处理中文字体和地名匹配最后还要用一个无头浏览器把 HTML 截图成 PNG因为汇报材料经常要静态图。整条链路不需要引入重型可视化服务一个 Python 环境加少量依赖就够。2. 数据预处理把公报拆成四张可用的表2.1 公开数据长什么样坑在哪里公开渠道拿到的数据通常以 Excel 或 PDF 表格形式发布常见的有四类地区人口表、年龄构成表、受教育程度表、城乡人口表。每张表都是汇总数据好处是没那么多脏值坏处是“单位”和“口径”非常不统一。第一个坑是单位混用。有的表数值直接写“141178”有的写成“141178万人”还有的单元格里带着全角逗号比如“72,334”。直接把这一列转成 int 一定会报错。第二个坑是口径混用。常住人口与户籍人口是两种定义画图前必须确认整列数据用的是同一种口径。第三个坑是缺失值占位符不统一有的用空单元格有的用“--”有的用“*”如果直接读进来会被当成字符串。2.2 清洗流程与字段设计我的做法是先把四类文件各读成一张原始表再用一个统一的解析函数处理数值字符串。核心是处理单位和分隔符import pandas as pd RAW_FILES { area: area.xlsx, age: age.xlsx, edu: edu.xlsx, urban: urban.xlsx, } def parse_unit(value): if pd.isna(value): return 0 text str(value).replace(,, ).replace(, ).strip() if text in (--, -, *): return 0 if 万 in text: return int(float(text.replace(万, )) * 10000) return int(float(text)) df_area pd.read_excel(RAW_FILES[area]) df_area[population] df_area[population].apply(parse_unit)这里有个容易被忽略的细节一些单元格看起来是数字实际存的是文本用df.info()查看会显示 object 而不是 int64。处理时可以统一用pd.to_numeric(errorscoerce)再补缺失值避免后面计算时悄悄报错。清洗完之后我把数据整理成四张结构一致的表。这四张表对应着四个分析视角后面每张图都从对应表里取数表字段用途province_popprovince, total, male, female, urban, rural地图、Top 柱状图age_genderage_group, male, female人口金字塔edu_poplevel, people饼图、玫瑰图urban_ruralregion, people环形图、堆叠图2.3 一个关键判断哪些图能画哪些不能硬画拿到数据以后不要急着上图表先确认维度之间能不能交叉。比如我想画“分年龄段的城乡人口堆叠图”但公开数据里城乡只有总量、没有与年龄段的交叉表那这张图就不存在真实数据基础。硬画只能自己编一个权重分配那已经不是数据可视化而是数据编造了。这个红线最好在项目一开始就划清楚。我最后把能做交叉分析的四个维度固定下来地区、年龄、性别、受教育程度。城乡结构单独成图但不强行跟年龄做交叉。这样每张图都有明确的数据出处后续更新数据时也不会自相矛盾。3. 人口金字塔镜像布局的完整实现与翻车修复3.1 一眼看懂年龄结构的图形原理人口金字塔的本质是一个双向分布图纵轴是年龄段横轴是人数左侧男性、右侧女性。如果某一段特别宽说明这个年龄层的人口规模大背后通常对应一个出生高峰如果塔尖快速收窄是老年人口的自然减员。从塔身中段还能直接判断劳动年龄人口的占比这些信息靠读表格至少要来回比对十几行。把年龄和性别组合成金字塔之后老化程度、劳动力占比、潜在的人口结构问题几乎是扫一眼就能建立印象。这也是为什么公开数据的报道里金字塔永远是最常见的配图。3.2 代码实现负值、转置、双序列pyecharts 里做金字塔不采用两个方向相反的柱状图拼接的老方案。最省事的做法是使用一个 Bar、两个序列男性序列取负值女性保持正值然后调用reversal_axis()让年龄段变成纵向展示。负值的作用只是“布阵”并不是让数据真的变成负数。from pyecharts.charts import Bar from pyecharts import options as opts from pyecharts.globals import ThemeType age_groups [ 0-4岁, 5-9岁, 10-14岁, 15-19岁, 20-24岁, 25-29岁, 30-34岁, 35-39岁, 40-44岁, 45-49岁, 50-54岁, 55-59岁, 60-64岁, 65-69岁, 70-74岁, 75-79岁, 80-84岁, 85岁及以上 ] # 示例数据实际从 age_gender 表读取 male_pop [-4520, -4850, -5120, -5300, -4750, -4600, -5200, -5800, -5500, -4200, -3800, -3200, -2600, -2000, -1400, -800, -400, -300] female_pop [4280, 4600, 4890, 5100, 4700, 4550, 5150, 5750, 5650, 4700, 4100, 3600, 3000, 2400, 1700, 1000, 550, 500] bar ( Bar(init_optsopts.InitOpts(themeThemeType.LIGHT)) .add_xaxis(age_groups) .add_yaxis(男性, male_pop, gap0%) .add_yaxis(女性, female_pop, gap0%) .reversal_axis() .set_series_opts( label_optsopts.LabelOpts(is_showFalse), tooltip_optsopts.TooltipOpts( triggeritem, formatter( function(params){return params.name br/ params.seriesName : Math.abs(params.value) 万人;} ) ) ) .set_global_opts( title_optsopts.TitleOpts(title普查人口年龄金字塔万人), legend_optsopts.LegendOpts(pos_left60%), xaxis_optsopts.AxisOpts( name人口数万人, axislabel_optsopts.LabelOpts( formatterfunction(value){ return Math.abs(value) 万; } ) ) ) ) bar.render(age_pyramid.html)两个序列都设了gap0%是让男性柱和女性柱紧密挨着。如果保留默认间距金字塔中间会有一条空白缝隙看起来像两座分开的条形图不是一座完整的金字塔。3.3 坐标轴负号与提示框修复第一次渲染出来的图横轴坐标是 -5800 到 5800鼠标悬停在男性柱上显示的是“男性-4520 万人”非常误导。修复方式是在 x 轴标签和 tooltip 的 formatter 里分别用 JavaScript 的Math.abs()把负号吞掉。坐标轴那行的处理是在xaxis_opts里写一个回调函数function(value){ return Math.abs(value) 万; }提示框里也需要同样处理否则用户看到负数会以为是数据出错。同时我把每个柱条顶端的数值标签关掉了因为柱条宽度本来就有限密密麻麻的负数和正数放在一起视觉上像摔碎的盘子信息反而不清楚。3.4 数据对齐问题长度不一致不报错的坑有一个坑非常隐蔽当男性序列和女性序列的长度不一致时pyecharts 不会抛异常而是把长出来的部分直接截断。图还是能渲染但年龄段和柱条从中间开始错位看起来就像某个年龄段突然少了一块。这个问题我排查了很久最后发现是源数据里缺失了“85岁及以上”这个分组两个序列长度差了一位。现在的习惯是画图前强制对齐维度expected_groups [f{i}-{i4}岁 for i in range(0, 85, 5)] [85岁及以上] age_gender age_gender.set_index(age_group).reindex(expected_groups).reset_index()这样即使源数据漏了某个年龄段也会以空值或 0 值补齐图不会错位。数据对齐这块多花十分钟后面能省下一个小时的排查时间。4. 省际地图与 Top 柱状图的组合打法4.1 地图组件空白时的排查链路做省际地图时我遇到过两种典型故障。第一次是打开 HTML 后地图区域只有坐标轴和网格线完全没有区域轮廓。这种情况在离线环境最容易出现因为浏览器渲染地图时需要拉取地图 GeoJSON 数据网络不通就会失败。排查思路按顺序走先看浏览器开发者工具里的网络请求确认是否 404再检查 pyecharts 的本地数据集目录里是否有所需的地图资源最后实在不行就自己准备一个 GeoJSON 文件通过注册接口加载。第二次是区域轮廓出来了但所有区域都是同样的灰色鼠标点上去也没有数据。这通常是“地名匹配”问题图里数据的 key 写了“A省”但地图数据集里采用的是另一种标准写法或者数据里误带了空格、单位等多余字符。排查时先把所有 key 打印出来逐个跟地图内置名称比对通常很快就能定位。4.2 Map 搭配 VisualMap 的常用配置省际人口分布适合用连续色阶表达VisualMap 的值域范围和颜色深浅是核心。地图配置的骨架代码from pyecharts.charts import Map from pyecharts import options as opts # 示例数据实际从 province_pop 表读取 province_data [(A省, 8538), (B省, 6028), (C省, 3850)] data_max max(v for _, v in province_data) chart_map ( Map() .add( series_name常住人口万人, data_pairprovince_data, maptypechina ) .set_global_opts( title_optsopts.TitleOpts(title各省常住人口分布万人), visualmap_optsopts.VisualMapOpts( max_int(data_max * 1.1), is_piecewiseFalse, is_calculableTrue, range_text[高, 低] ) ) ) chart_map.render(province_map.html)一个很容易踩的坑是 VisualMap 的max_写死。第一次我用固定值 12000后来换了新一代数据最大值超过设定色阶上限以下的所有区域全部变成最深色图完全失真。现在我都会在取数之后先用df[col].max()动态算一遍再乘一个 1.1 的系数当作缓冲。4.3 横向柱状图的排序与标注细节地图适合看整体空间分布但要回答“哪几个地区人口最多”柱状图更直接。我用了横向柱状图把省份名称放在纵轴数值放在横轴这样省份名称有足够的宽度展示不需要旋转字体。from pyecharts.charts import Bar top10 province_pop.nlargest(10, total) bar_top ( Bar() .add_xaxis(top10[province].tolist()) .add_yaxis(常住人口, top10[total].tolist()) .reversal_axis() .set_series_opts( label_optsopts.LabelOpts(positionright) ) ) bar_top.render(top10.html)排序直接用nlargest(10, total)比sort_values再加head(10)更直观语义也更清楚。柱状图我保留了数值标签放在柱条右侧方便读者直接读具体数字。4.4 扩展到省内地图的进阶思路如果任务细化到某个地区内部的地市人口pyecharts 内置的全国地图就不够用了需要单独准备地市边界的 GeoJSON 文件再通过注册机制加载。个人建议先评估必要性如果只是展示人口数量等级全国地图加 Top 柱状图已经覆盖九成需求。省内地图只在地市之间差异很大、或者领导明确要“落到地图上的城市边界”时再做优先级不要放太高。5. 占比类图表饼图、环形图与玫瑰图的适配场景5.1 性别构成先把性别比倒推成绝对数公开数据里常见的是“总人口性别比”也就是每 100 名女性对应多少男性。但饼图需要的是男女的绝对数或占比不能直接把性别比丢进去画。如果数据表里没有直接给出男女绝对数可以倒推。假设女性为 x男性约为 1.0507 倍 x二者之和等于总人口解一个一元一次方程就能得到男女数值。虽然只有几行运算但很多人会忽略这一步直接把性别比当占比画画出来的饼图总数对不上。环形图适合表达“结构”而不是“精确对比数量”。我用了radius[42%, 68%]做内外径中间留白能放文字或主指标比实心饼图清爽不少。from pyecharts.charts import Pie sex_data [(男性, 72334), (女性, 68844)] pie_sex ( Pie() .add(, sex_data, radius[42%, 68%]) .set_global_opts( title_optsopts.TitleOpts(title总人口性别构成), legend_optsopts.LegendOpts(pos_bottom0%) ) .set_series_opts( label_optsopts.LabelOpts(formatter{b}: {c}万人 ({d}%)) ) ){d}在 formatter 里代表百分比这是把“绝对数量和占比同时展示”的最小成本写法。5.2 受教育程度层级差距大玫瑰图更直观受教育程度的层级之间数量差距非常大有的层级可能是另一层级的数倍甚至数十倍。普通饼图的外围小扇形几乎看不到标签还会互相压住。这种情况更适合用南丁格尔玫瑰图让半径反映数值大小弱化角度差异视觉上比饼图容易读。edu_data [ (初中, 48700), (高中, 21300), (大学专科, 11000), (大学本科, 9500), (研究生及以上, 1200) ] rose ( Pie() .add( , edu_data, radius[10%, 70%], center[50%, 50%], rosetyperadius ) .set_series_opts( label_optsopts.LabelOpts(formatter{b}: {c} 万人) ) ) rose.render(edu_rose.html)注意 pyecharts 1.x 的参数名是rosetype不是roseType。网上搜到的一些旧教程 API 是旧版写法直接复制到新版环境会报错。5.3 城乡结构饼图、堆叠图与口径说明城乡人口反映的是另一层结构。这类成分数据最简单的方式是环形图但我建议在图表下方明确标注使用的人口口径因为“城镇人口”在不同统计周期里的定义边界有过调整没有标注的图会在复盘时造成误解。如果数据里有分年龄段的城乡交叉表堆叠柱状图会比多个饼图更有说服力没有交叉表也不要硬拼保持城乡单维度的环形图即可。可视化的逻辑自洽永远比图表数量重要。6. 多图成报告Tab 联动、静态图导出与维护习惯6.1 用 Tab 把图片拼成一张可交付的页面单张图各自打开说服力是离散的也没有“看板”的感觉。pyecharts 的 Tab 组件可以把前面生成的地图、金字塔、饼图和玫瑰图放进同一个 HTML 页面顶部用标签切换。代码很短from pyecharts.charts import Tab tab Tab(page_title人口普查数据可视化) tab.add(chart_map, 省际地图) tab.add(bar, 年龄金字塔) tab.add(pie_sex, 性别构成) tab.add(rose, 受教育程度) tab.add(pie_urban, 城乡构成) tab.render(census_dashboard.html)生成之后就是一个可交付的 HTML 文件给对方发过去就能打开。Tab 的默认实现会在各个图之间切换不需要重新加载页面汇报场景下体验比一组静态图片顺滑得多。6.2 用无头浏览器把 HTML 转成 PNGHTML 保留交互但很多汇报材料还是需要 PNG。把 HTML 转成图片的稳定方案是用无头浏览器打开本地文件并截图。我用的是 Playwright代码很短from pathlib import Path from playwright.sync_api import sync_playwright def html_to_png(html_path: str, png_path: str): uri Path(html_path).resolve().as_uri() with sync_playwright() as p: browser p.chromium.launch() page browser.new_page(viewport{width: 1440, height: 960}) page.goto(uri) page.wait_for_timeout(1500) # 等待图表渲染 page.screenshot(pathpng_path, full_pageTrue) browser.close() html_to_png(census_dashboard.html, census_dashboard.png)路径用Path(html_path).resolve().as_uri()转成标准 URI可以避免中文路径或空格导致的打开失败。如果页面里有多个 Tab截图前最好程序化地逐个点击一遍所有标签让每个面板里的图表都完成渲染否则最后截出来的可能是空白面板。6.3 数据一更新就翻车的三个细节图表做完不等于项目结束。数据更新时最容易翻车的三个位置我都踩过第一个是 VisualMap 的max_写死新数据量级一变色阶立即失真。解法是每次从 DataFrame 里动态算最大值。第二个是输出文件名不带版本。第一次覆盖之后旧版 HTML 就再也找不回来了。我现在统一用“指标_维度_日期.html”的命名比如population_province_20240701.html宁可多存几个文件也不要在需要回溯时无图可看。第三个是多图共用一个数据源时字段名一旦改动所有图表静默出错。字段常量统一在脚本顶部定义图表函数只认参数传进来的 DataFrame这样数据清洗改一处所有图表自动跟着变。最后再分享一个个人习惯。我把每张图的生成逻辑都拆成了独立函数函数的入口统一是一张 DataFrame图只负责渲染不负责取数和清洗。这样第二版数据下来时只需要跑数据清洗脚本所有图自动更新我再逐个检查色阶范围和排序是否合理就行。整套项目做完代码量比我预想得少真正花时间的其实是在理解数据口径上。如果你也准备做这类公开统计数据的可视化建议先花半天把表结构吃透再动手画图这比调任何炫酷效果都值。