做区域分析的人大概都有过这种经历:手头有一堆统计表格,按省按市按县分得清清楚楚,可一旦想把它画到地图上,或者算个面积、做个空间叠加,立马卡在第一步——边界数据从哪来?网上搜一圈,要么是几个残缺的矢量文件,要么是精度可疑、层级混乱的半成品,属性表里字段名还是拼音缩写加数字编号,看得人一头雾水。我最近在处理一套开放数据平台上的行政区划shp数据,从省到县几个层级都有,属性字段也相对规整,正好借这个由头,把这类数据的读法、洗法、用法聊透。不管你是做数据可视化、区域统计、选址分析,还是纯粹想把表格数据挂到地图上看看空间分布,这套流程都能直接套用。基础薄弱也没关系,我会把shapefile是什么、坐标系怎么选、代码怎么写一步步拆开讲。
1. 先搞清楚这份数据到底是什么形态
1.1 shapefile从来不是"一个文件"
很多人第一次下载shp数据,解压出来看到一堆同名不同后缀的文件,下意识只把.shp那个拖进软件,然后发现属性表空了、中文乱码了、或者干脆打不开,回头就骂数据源有问题。其实shapefile是一套强制绑定的文件组合,缺一个就残废。最核心的四个是:.shp存几何形状(点线面坐标),.shx存索引(快速定位几何对象),.dbf存属性表(就是你看到的名称、代码那些字段),.prj存坐标系定义。此外还可能带.cpg(编码声明)、.sbn/.sbx(空间索引)、.qix(另一种索引)、.xml(元数据)。
这里头最容易被忽略的是.cpg和.prj。没有.prj,软件不知道你的坐标是经纬度还是米,会当成"未知坐标系"处理,后续所有空间计算都是错的;没有.cpg,软件只能猜.dbf里的中文是用什么编码写的,猜错就是一堆问号。我见过太多次因为解压时只勾选了.shp导致坐标系丢失的案例,所以第一条经验就是:下载完先确认文件成套,压缩包原样解压,别手动挑文件。
还有一个容易被忽视的点:shapefile的.dbf字段名有10个字符的硬限制。数据提供方为了塞进更多信息,往往把字段名压缩成"NAME""CODE""PAC"这类缩写,肉眼看不出含义。拿到数据第一件事应该是打开属性表,对照官方字段说明文档把人话翻译过来,别急着往下做。
1.2 层级结构与属性表长什么样
行政区划数据的组织逻辑是树状的,从高到低一般是省级、地市级、县级、乡镇级,部分数据源还会做到村级。每一级通常做成一个独立的图层文件,比如省界一个shp、市界一个shp、县界一个shp,各自独立但通过代码字段关联。这种设计的好处是单层数据量可控,需要哪层用哪层;坏处是跨层关联时得自己写逻辑。
属性表里最有价值的字段是行政区划代码。国内现行的编码规则是六位数字:前两位对应省级单元,中间两位对应地市级,后两位对应县级;如果做到乡镇和村,会扩展到九位、十二位。这套编码是全国统一的,意味着你可以用它跟统计年鉴、经济数据、人口普查数据做精确挂接,比用名称匹配靠谱得多——名称会改,代码相对稳定。
除了代码和名称,好的数据集还会带上级代码字段(方便做层级筛选)、几何面积字段、中心点坐标、以及数据版本时间。这几个字段看着不起眼,实际用起来能省很多事。比如上级代码字段,你想单独提取某个地市下面的所有区县,一行筛选就搞定,不用做空间相交运算;再比如版本时间,行政区划每年都有调整,撤县设区、合并乡镇都是常事,不清楚数据年份就做分析,结论很可能对不上现实。
提示:名称字段是最不可靠的关联依据。同一个地方可能有简称、全称、旧称、拼音、英文多种写法,代码才是唯一标识。
1.3 什么场景该用它,什么场景别硬上
这套数据的适用面其实比想象中宽。做区域经济分析,把GDP、人口、财政数据挂到县级单元上做分级设色图,是最经典的用法;做商业选址,用地市级边界圈定服务半径、算覆盖人口,也是常规操作;做环境或气象研究,把监测站点数据按行政区聚合统计,同样离不开边界数据。甚至写报告需要一张干净的区位示意图,拿它出图也比网上随便找的图靠谱。
但有几个场景我得劝退。第一是超精细的地图制图,这类公开数据的边界精度通常在百米级到千米级,海岸线、岛屿、界河都做过简化处理,拿去印大比例尺地图会露怯;第二是权属边界相关的严肃用途,公开数据的行政边界是示意性的,不具备法律效力;第三是需要实时性的场景,行政区划调整有滞后,最新一轮调整可能还没反映到数据里。
另外提醒一句,如果你的分析单元是网格、商圈、邮编区这种非行政单元,硬套行政区划边界反而会引入误差。这时候应该找对应的格网数据或者自己做空间插值,别拿行政边界凑合。
2. 上手前必须处理的三件事
2.1 中文乱码:成因、判断与两种解法
乱码是这套数据最高频的坑,没有之一。根源在于.dbf文件本身不携带编码信息(除非有.cpg),而中文字符在不同编码下字节序列完全不同。老一些的数据集常用GBK或GB2312写入,新数据集多用UTF-8,软件默认假设可能是Latin-1或者系统本地编码,一错位就全乱。
判断方法很简单:读进来打印前几行属性,如果中文显示成"???"或者"锟斤拷"这类怪字符,就是编码问题。注意这两种表现还不一样,"???"通常是读取端无法映射字符,乱码成方块或问号;"锟斤拷"是UTF-8字节被当成GBK解读的典型产物。看到后者,基本可以断定真实编码是UTF-8。
解法有两条路。软路线是在读取时显式指定编码,Python里给read_file加encoding参数,QGIS里在图层属性中改数据源编码;硬路线是用QGIS或GDAL把数据另存一次,转成UTF-8并生成.cpg文件,一劳永逸。我一般选后者,因为转存一次之后团队里所有人都不用再重复处理,而且顺带能修掉一些几何问题。
import geopandas as gpd # 先探测编码:UTF-8 报错就退回 GBK for enc in ("utf-8", "gbk", "gb18030"): try: gdf = gpd.read_file("county.shp", encoding=enc) print(f"编码 {enc} 读取成功,记录数 {len(gdf)}") break except Exception as e: print(f"{enc} 失败: {e}")这段探测逻辑我用了很久,gb18030是GBK的超集,兼容性更好,放在最后兜底能覆盖绝大多数国产老数据。
2.2 坐标系:地理坐标和投影坐标别混为一谈
第二个高频坑是坐标系。地理坐标系(比如WGS84,EPSG:4326)用经纬度表示位置,单位是度;投影坐标系把球面展开到平面上,单位是米。两者混用会导致两类灾难:用经纬度直接算面积,得到的是"平方度"这种没有物理意义的数字;把投影坐标误当地理坐标,画出来的图会跑到赤道附近的太平洋上。
怎么判断?看坐标值的量级。经度范围在-180到180,纬度在-90到90,如果你的x字段是116、y字段是39这种,那是经纬度;如果是50万、400万这种七位数,基本是投影坐标(国内常用高斯克吕格或UTM,东坐标通常加了500公里偏移)。再直接一点,读.prj文件或者打印crs属性,一目了然。
print(gdf.crs) # 有值时说明坐标系明确 print(gdf.crs.is_geographic) # True 表示经纬度 print(gdf.geometry.total_bounds) # 用范围辅助判断国内行政区划公开数据最常见的两种是WGS84(EPSG:4326)和CGCS2000(EPSG:4490)。两者在米级精度上差异极小,日常分析可以视作等价,但严格来说基准不同,做高精度测绘时需要区分。如果你的数据是CGCS2000,转WGS84时用标准转换参数即可,不要随意套用七参数,那是有地方坐标参与时才需要的。
2.3 完整性体检:空几何、重叠、缝隙
数据拿到手,别急着做分析,先做三项体检。第一项是空几何和无效几何检查,有些数据集在边界裁剪时会产生自相交的多边形或者零面积的碎片,这类几何在做缓冲区、相交运算时会直接报错中断;第二项是重复记录检查,同一个行政区代码出现两条,多半是数据合并时没去重;第三项是覆盖完整性,相邻单元之间应该严丝合缝,不该有大面积空白或明显重叠。
print("空几何数量:", gdf.geometry.is_empty.sum()) print("无效几何数量:", (~gdf.geometry.is_valid).sum()) print("代码重复:", gdf["adcode"].duplicated().sum()) # 修复无效几何,buffer(0) 是最省事的办法 gdf["geometry"] = gdf.geometry.buffer(0)buffer(0)这个技巧值得记住,它对自相交多边形有奇效,原理是把几何先向外扩0米再收回来,重建拓扑关系。但它不是万能的,遇到严重损坏的几何还是得手工处理或者换数据源。
注意:体检结果要留档。如果你要反复用这套数据,把清洗后的版本另存一份,别每次都从头修。
3. 核心操作:从加载到可用数据
3.1 环境准备与依赖选择
工具链上我推荐Python + geopandas这套组合,理由很实在:geopandas底层是GDAL/OGR,能读的格式覆盖shapefile、GeoJSON、GPKG、KML几乎所有常见矢量格式;数据结构是pandas DataFrame的扩展,属性表操作直接复用pandas的生态,merge、groupby、pivot这些熟面孔都能用;出图可以接matplotlib,也可以接folium做交互地图。R语言用户用sf包是同一套思路,逻辑完全对应。
安装环节容易出问题的是GDAL依赖。用conda装最省心,一条命令解决所有底层库;用pip的话建议装预编译轮子,别自己编译源码。
conda install -c conda-forge geopandas matplotlib pyproj shapely依赖版本上有个细节:geopandas 1.0之后对空几何的处理逻辑有变化,如果你的代码在老版本跑得好好的,升级后突然报错,先去查版本变更说明,别急着改业务逻辑。另外shapely 2.0重写了几何运算的底层实现,速度提升明显,能升级尽量升。
3.2 读数据、看结构、认字段
加载只是第一步,读懂结构才是关键。我习惯用一套固定的探查动作:看记录数、看字段列表、看字段类型、看几何类型、看范围、抽样看几行。这套动作下来,对数据的底细就摸清了,后面写筛选和关联逻辑心里有数。
import geopandas as gpd gdf = gpd.read_file("county.shp", encoding="utf-8") print("要素数量:", len(gdf)) print("字段列表:", gdf.columns.tolist()) print("字段类型:\n", gdf.dtypes) print("几何类型:", gdf.geom_type.unique()) print("空间范围:", gdf.total_bounds) print(gdf.head(3).drop(columns="geometry"))字段类型这一项特别值得看。行政区划代码必须是字符串类型,如果是整数,前导零会丢——代码"010101"读进来变成"10101",后面跟外部数据关联全对不上。遇到这种情况,读取时显式指定类型,或者读完后用zfill补齐位数。
gdf = gpd.read_file("county.shp", encoding="utf-8", dtype={"adcode": str, "parent_code": str}) gdf["adcode"] = gdf["adcode"].str.zfill(6)这个前导零问题,我自己踩过至少三次,每次都是关联完发现匹配率只有个位数,排查半天才想起来是类型问题。现在养成习惯,看到代码字段一律先转字符串再补零,不再赌运气。
3.3 坐标转换:为什么转、转到哪、参数怎么定
坐标转换这件事,核心是回答一个问题:你要做的是"展示"还是"计算"。展示用Web墨卡托(EPSG:3857)最省事,各种网页地图底图都是这个坐标系,直接叠不会有偏移;计算必须用等积投影,因为墨卡托在高纬度地区会把面积放大好几倍,拿它算面积得出的结论毫无意义。
国内做面积统计,通用的选择是Albers等积圆锥投影,标准参数是中央经线105度、标准纬线25度和47度。这套参数的来历是让整个国土范围内的面积变形尽可能小。双标准纬线的作用是:在这两条纬线上面积变形为零,两条纬线之间的区域变形也很小,整体误差控制在可接受范围。
# 展示:转 Web 墨卡托 gdf_web = gdf.to_crs(epsg=3857) # 计算:转 Albers 等积投影 albers = ("+proj=aea +lat_1=25 +lat_2=47 +lat_0=0 " "+lon_0=105 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs") gdf_area = gdf.to_crs(albers) # 面积单位换算:平方米 -> 平方公里 gdf_area["area_km2"] = gdf_area.geometry.area / 1e6这里有个参数选择上的经验:如果你的研究区集中在某个省份,用全国参数反而不如用局部参数准。可以把中央经线换成研究区中心经度,标准纬线取研究区南北跨度的大致范围,变形能进一步压低。参数不是死的,跟着研究区走。
还有一点,转换前一定要确认原始坐标系是对的。如果数据本来标着WGS84但实际是别的坐标系,转完的结果会整体偏移几百米。验证办法是拿几个已知地标的经纬度,跟数据里对应单元的中心点比对,差值在百米以内算正常,差出几公里就是坐标系定义错了,得先纠正再转换。
3.4 属性表清洗:代码、名称与层级字段
几何没问题了,接下来收拾属性表。首要任务是代码字段规范化,前面说过要转字符串补零。第二是名称字段去空格、统一全半角,中文名称里偶尔会混进半角括号或者多余空格,看着不起眼,做匹配时就是不相等。第三是层级字段构建,从代码里截取前缀生成省、市、县三级标识,方便分层筛选。
gdf["prov_code"] = gdf["adcode"].str[:2] gdf["city_code"] = gdf["adcode"].str[:4] gdf["county_code"] = gdf["adcode"].str[:6] # 名称清洗:去空格、全角转半角 import re def clean_name(s): if not isinstance(s, str): return s s = s.strip().replace(" ", "") s = s.replace("(", "(").replace(")", ")") return s gdf["name"] = gdf["name"].apply(clean_name)层级字段建好之后,做筛选就非常顺手了。要省级数据,按prov_code去重后dissolve合并;要某个市下面的区县,按city_code筛选;要做市级的统计,按city_code做dissolve把区县合并成市。dissolve这个操作值得单独说,它按指定字段做分组并合并几何,是构建多层级数据的标准手段。
province = gdf.dissolve(by="prov_code", as_index=False, aggfunc={"name": "first"})dissolve的坑在于,如果同一分组内的几何有重叠或缝隙,合并结果可能出现内部孔洞或者多余碎片。做之前先确认几何质量,做完之后检查面积是不是等于各分块之和,差太多就说明有问题。
4. 三个真实感强的应用场景拆法
4.1 场景一:把统计表格挂到地图上
这是最普遍的用法。假设手头有一份按行政区代码统计的指标表,想画成分级设色图。流程是:读边界数据、读统计表、按代码merge、处理匹配失败、出图。
import pandas as pd import matplotlib.pyplot as plt stats = pd.read_csv("indicator.csv", dtype={"adcode": str}) stats["adcode"] = stats["adcode"].str.zfill(6) merged = gdf.merge(stats, on="adcode", how="left") print("匹配成功:", merged["指标值"].notna().sum(), "/", len(merged)) fig, ax = plt.subplots(figsize=(12, 9)) merged.plot(column="指标值", cmap="YlOrRd", legend=True, missing_kwds={"color": "lightgrey"}, ax=ax) ax.set_axis_off() plt.savefig("choropleth.png", dpi=300, bbox_inches="tight")匹配失败是这一步的常态,别慌。原因通常有三类:代码位数不一致(一边6位一边9位)、代码体系不同(用了旧的区划代码)、名称代替代码做关联。排查方法是对不上的记录单独拉出来,看代码差在哪一位,通常是补零或者截断能解决。
出图上还有个小技巧:missing_kwds这个参数能把没匹配上的单元标成灰色,避免它们在图上看起来像零值。这个细节在正式报告里很重要,否则读者会把"无数据"误读成"数值为零"。
4.2 场景二:空间关系计算
空间计算比属性关联更有意思。常见需求包括:判断某个点落在哪个行政区、算两个区域之间的距离、统计缓冲区覆盖了哪些单元、计算某条线路穿越的行政区列表。这些操作geopandas都有对应方法,但用之前要注意坐标系。
from shapely.geometry import Point # 点在面内判断:先统一坐标系 pts = gpd.GeoDataFrame( {"name": ["站点A", "站点B"]}, geometry=[Point(116.4, 39.9), Point(121.5, 31.2)], crs="EPSG:4326" ).to_crs(gdf.crs) result = gpd.sjoin(pts, gdf[["name", "geometry"]], how="left", predicate="within") print(result[["name_left", "name_right"]])sjoin这个函数是空间连接的核心,predicate参数决定连接条件,within表示点完全落在面内,intersects表示有接触就算。用within还是intersects要看你关心什么:统计站点归属用within,判断线路经过用intersects。
缓冲区分析也是一样,先在投影坐标系下生成缓冲区再转回去,在经纬度下直接buffer会得到一个形状怪异的椭圆。这个坑我在做服务半径分析时踩过,半径写5000以为是5公里,结果在经纬度下变成了5000度,直接报错。
4.3 场景三:多层数据合成一张图
有时候需要一张图里同时体现省界、市界、县界,或者把行政区划跟河流、道路叠在一起。这时候图层顺序和样式设计就变得重要。基本原则是:面积大的、颜色浅的放底层,线条细的放上层,重点要素用高对比度颜色突出。
fig, ax = plt.subplots(figsize=(14, 10)) county.plot(ax=ax, color="#f5f5f5", edgecolor="#cccccc", linewidth=0.3) city.boundary.plot(ax=ax, color="#888888", linewidth=1.0) province.boundary.plot(ax=ax, color="#333333", linewidth=1.8) ax.set_axis_off() plt.savefig("layers.png", dpi=300, bbox_inches="tight")boundary方法只画边界线不填充,是叠加多层的利器。用plot画会填充,用boundary画只描边,两个方法配合能做出层次感很强的图。线宽上建议遵循一个比例,上下级之间差2到3倍,太多会显得杂乱。
中文标注是个附加需求,matplotlib默认字体不支持中文,需要手动指定字体文件。Windows下用系统自带的黑体或微软雅黑,Linux和Mac上要确认字体路径。
5. 踩坑实录与速查手册
5.1 那些不看书学不到的经验
第一个坑关于文件大小。shapefile格式本身有2GB的上限,超过就写不进去。做到乡镇级或者村级的时候,要素数量动辄几十万,很容易触线。解决办法是换成GeoPackage(.gpkg)格式,它是单文件、无大小限制、支持UTF-8、支持空间索引,各方面都优于shapefile。现在我做项目,中间数据一律存gpkg,只在交付时按需导出shp。
第二个坑关于字段名截断。前面提过.dbf字段名10字符限制,但很多人不知道的是,截断之后的字段名可能重名,导出去再读回来就变成"NAME_1""NAME_2",语义全丢了。这就是我推荐gpkg的另一个理由——它没有这个限制。
第三个坑关于内存。全国县级数据大概三千条要素,几何复杂度中等,内存占用几百兆,普通机器扛得住。但如果是乡镇级或者高精度边界,单个文件几个G,read_file直接吃满内存。这时候要用分块读或者按需筛选字段,bbox参数可以只读某个范围内的要素。
gdf = gpd.read_file("town.shp", bbox=(112, 30, 118, 35))第四个坑关于数据现势性。行政区划每年都在变,撤县设区、合并乡镇、新设街道,每隔一段时间就有一批调整。用三年前的数据做分析,结论可能已经跟现实脱节。判断办法是看数据里有没有时间字段,没有的话就对照官方公布的区划代码表抽查几条,看看最新的调整有没有反映进去。我发现过一份数据里某个县还挂在已经撤销的市下面,这种数据用之前必须修。
第五个坑关于面积复核。投影转换加面积计算这套流程走完,别急着信结果。随便挑几个形状规整的县,用官方公布的面积对一下,误差在1%到3%之间属于正常(不同投影参数和边界精度都会带来差异),差出10%以上就要回头查坐标系和投影参数了。
5.2 问题速查表
| 现象 | 可能原因 | 排查动作 | 处理办法 |
|---|---|---|---|
| 属性表中文显示问号或方块 | .dbf编码与读取端不一致 | 试utf-8、gbk、gb18030三种编码 | 读取时指定encoding,或转存并生成.cpg |
| 地图跑到海上或空白处 | 坐标系定义错或误用经纬度当投影 | 打印crs和total_bounds看数值量级 | 重新定义crs后再转换 |
| 面积值大得离谱或小得离谱 | 在经纬度坐标系下直接算面积 | 检查crs.is_geographic | 转到等积投影再算 |
| merge后大量空值 | 代码字段类型不一致(int vs str) | 对比两边字段类型和位数 | 统一转字符串并zfill补位 |
| 代码前面少了零 | 被当成整数读取 | 打印首几行看值 | read时指定dtype为str |
| buffer操作报错或形状怪异 | 几何无效或坐标系是经纬度 | 检查is_valid和crs | buffer(0)修复,转投影后再buffer |
| 保存shp字段名变乱码 | 超过10字符被截断 | 检查输出字段列表 | 改用gpkg格式保存 |
| dissolve后出现孔洞 | 分块几何有重叠或缝隙 | 算合并面积与分块面积之和对比 | 先做几何修复再合并 |
| 大文件读取内存溢出 | 要素太多或几何太复杂 | 看文件体积和要素数 | 用bbox参数局部读,或分块处理 |
| 区划名称与最新情况不符 | 数据版本较旧 | 抽查几个近期调整过的单元 | 换新版数据或手工修正 |
这张表里的每一行,我基本都在实际项目里遇到过至少一次。最省时间的做法不是出问题再查,而是在开始分析前把体检流程走一遍:编码、坐标系、几何有效性、代码类型、数据版本,五道关卡过完,后面就顺了。
我个人在实际操作中的体会是,行政区划shp数据的价值不在于数据本身有多复杂,而在于它是一层"骨架",所有跟地理位置相关的业务数据都能往上挂。骨架干净,后面的分析才站得住。所以别嫌前期的清洗琐碎,那点时间投进去,能省掉后面反复调试的麻烦。另外一个建议是,不管从哪个渠道拿到的数据,用过一次之后就把清洗好的版本连同处理脚本一起归档,下次换项目直接复用,这比每次重新走一遍流程划算得多。