用Python画美国地图这件事,听起来像是个小需求,真做起来的时候却把我折腾得不轻。几年前我接到一个跨区域业务数据展示的需求,老大要求把不同州的数据差异用一张图说清楚。我一开始天真地以为,随便找个在线图表库一拼就能搞定,结果做出来的图连最基本的边界都对不齐,更别提要叠加业务数据了。后来我把GeoPandas、Folium这两条主流路线从入门到实战捋了一遍,才算真正稳定复现。
这篇文章就是我当时完整踩坑路线的记录。如果你是做数据分析、业务看板,或者想在地图上叠加自己业务数据的开发者、产品经理、运营人员,读完这篇文章基本能从零开始画出一张能用、能交互、还能展示业务数据的美国地图。全文按“需求分析—数据准备—实操画图—业务叠加—问题排查”的顺序写,涉及的关键代码我都会贴出来并解释每一行是干什么的,方便你直接复制改参数。
1. 动手之前:想清楚这张地图要解决什么问题
1.1 需求背后,其实是三种完全不同的场景
很多人搜“用Python画美国地图”这个关键词,以为找到代码就能跑。但实际上“画地图”这句话在不同人嘴里,指的东西完全不一样。我总结下来主要有三类:
第一类,静态轮廓图。只需要在报告、PPT里放一张美国本土轮廓,让人看得出是哪就行。这种需求最简单,用GeoPandas读入边界数据直接plot,几行代码即可出图。
第二类,分级着色图。需要按州或者其他行政区域展示业务指标,比如某产品的销售额、人口密度、增长率等。这类需求需要把业务数据join到地理边界上,再通过颜色深浅表达数值高低,也就是俗称的Choropleth图。
第三类,交互式网页地图。支持缩放、点击弹出详情、悬浮提示,通常还要能配合其他前端图表联动。这种需求适合用Folium或者Plotly,输出成HTML文件或者嵌入Web应用。
我见过太多人一开始就冲着交互式去,做了一半发现数据粒度根本撑不起那么细的展示,又回来改静态图。反过来也有,先做了静态图,后来发现要让业务方自己点着看,只好全部推翻。所以我建议第一步先确认到底属于哪一种,再动代码。
1.2 三个问题,动手前先回答
在找数据、装环境之前,先问自己三个问题:
- 数据粒度是什么?是按州汇总,还是按城市、县?粒度决定你最终能画到多细。拿按州汇总的数据去画县级地图,画面会很空旷,信息密度极低。
- 地图是载体还是主角?如果地图只是业务数据的背景板,那重点应该放在数据的映射方式上;如果地图本身要展示地理形态,那边界精度和投影方式就更重要。
- 成品在哪里展示?Jupyter Notebook里看、导出静态图片、还是发布成网页看板?不同的展示场景直接决定技术选型。
这三个问题没有标准答案,但它们之间是联动的。比如数据粒度已经固定到州级,那你大概率不需要用到街区和公路级别的精细边界,选择边界数据时就可以选精简版,文件体积小,渲染也快。
1.3 技术路线:选错库会白干一半
在Python生态里,画地图的常用路线我大致列一下:
| 路线 | 典型库 | 适合场景 | 产出形式 |
|---|---|---|---|
| 静态绘图 | GeoPandas + Matplotlib | 报告配图、论文插图 | PNG / PDF |
| 交互式网页 | Folium(基于leaflet) | 业务看板、网页嵌入 | HTML |
| 数据分析式交互 | Plotly Express | 数据探索、Notebook分享 | HTML / 图片 |
| 专业地理处理 | Cartopy | 气象、海洋等专业领域 | 高精度静态图 |
我的选择逻辑很简单:如果最终交付物是一张“成品图片”,直接用GeoPandas加Matplotlib,别绕弯子;如果要做成能点的网页看板,优先Folium,它上手快、中文资料多;如果本身已经天天在Pandas里做数据探索,就想快速瞄一眼空间分布,Plotly Express几行代码就能做交互图,也很香。
不建议一上来就搞Cartopy这类专业库,除非你有气象图、等值线这类硬需求。前期学习成本和依赖复杂度都会高不少,很多时候是杀鸡用牛刀。
2. 数据准备:地图画得准不准,九成看数据
2.1 官方数据源与常见格式
画地图的第一件事不是写代码,而是找到靠谱的边界数据。我在实操里最常用的格式是GeoJSON。GeoJSON是一种基于JSON的地理数据格式,既能存行政区域的边界形状,也能存区域名称、编码之类的属性字段,结构清晰,Python生态支持得也最好。
找美国各州边界数据时,优先看官方发布或权威机构维护的数据源。官方数据通常字段命名稳定、坐标系有明确标注,能少踩很多坑。网上有不少第三方镜像站也能下到GeoJSON,但有些数据是经过多次转换的,坐标精度丢失,画出来边界扭曲,或者属性字段被改得对不上号。我的原则是:优先官方源,其次用社区维护多年的稳定仓库,最后才考虑临时拼凑的下载包。
另外说一句,边界数据文件不一定越小越好。有些精简版GeoJSON把所有细节边界都简化了,缩到很小时确实画得快,但放大后整个州的轮廓会很“假”,看起来像被磨过皮。如果你的地图有缩放交互需求,还是留一点精度余量。
2.2 GeoJSON的结构,以及它在项目里的位置
GeoJSON的核心概念是FeatureCollection,可以理解成一个盒子,里面装了多个Feature(要素)。每个Feature又包含两部分:properties,用来存属性信息,比如区域名称、区域编码;geometry,用来存地理形状,比如是一个多边形Polygon,还是多个多边形拼成的MultiPolygon。
MultiPolygon这个概念很多人第一次接触容易忽略。一个州如果有离岛或者飞地,它的边界往往不是一个完整的多边形,而是由多个多边形组成的MultiPolygon。如果你用某些数据处理方法只取geometry里的第一个多边形,就会莫名其妙丢掉一块区域。这种问题最坑,因为你不放大仔细看,根本发现不了少了一块。
在项目中的操作顺序通常是:
- 读入GeoJSON得到GeoDataFrame。
- 读入业务数据(CSV或Excel)。
- 按共同字段把两份数据合并。
- 调用绘图方法出图。
逻辑上其实和普通数据分析没本质区别,只是其中一个对象带了几何信息而已。
2.3 清洗属性字段的几种常见情况
我实际处理业务数据时,遇到最多的字段问题有三类。
第一类,大小写和空格不统一。比如地理边界数据里区域名是“California”,而你的业务Excel里写的是“california”或者“California ”(末尾多了个空格),直接合并就会产生大量空值。解决办法是先对关键字段做标准化,比如统一upper(),再strip()掉空格。
第二类,简称和全称混用。有些业务系统导出的数据用的是两字母州编码,比如CA、NY,而GeoJSON的属性里可能是全称。你需要先确认手头两份数据各自用的哪种标识,然后把其中一份转换成另一份的格式,保证合并主键完全一致。
第三类,同一区域在不同数据源里名称拼写有差异。比如带不带空格、带不带州后缀,这些不亲眼去对比根本不会注意到。我习惯在合并前先打印两份数据的唯一值列表,用肉眼扫一遍,比直接merge完再查空值快得多。
很多初学者画出来的图上有大片空白区域,十有八九不是绘图代码的问题,而是数据没合并上。地图上的每个区域都对应一个多边形,如果业务数据没能匹配上这个多边形,那这块区域就只能是空的或者没颜色。
3. 从零画第一张地图:两种路线实操
3.1 环境搭建:版本选择和安装避坑
先说环境。我建议用conda建一个干净的环境,Python版本选3.9或3.10都行。不要一味追最新版本,因为GeoPandas涉及很多底层地理库,例如GDAL、Fiona、Shapely,这些库的预编译包对新版本Python的支持往往有滞后。在新版本上pip安装容易遇到编不过的情况,项目直接卡死在装环境这一步,非常不值得。
推荐用conda安装核心依赖:
conda create -n geo_env python=3.9 conda activate geo_env conda install -c conda-forge geopandas matplotlib folium plotly为什么推荐conda-forge?因为地理计算相关的一堆二进制依赖,conda-forge都会帮忙处理依赖兼容关系。如果用pip,很可能出现装好GeoPandas之后导入时报错,提示找不到某个底层动态库,然后你得自己去装GDAL,那个坑我踩过太多次了。
3.2 静态地图实操:Matplotlib + GeoPandas
第一步,读入边界数据并直接画轮廓。
import geopandas as gpd # 读入GeoJSON文件 gdf = gpd.read_file('us_states.geojson') print(gdf.head()) # 先看看有哪些字段 # 默认画图 ax = gdf.plot(figsize=(12, 8), edgecolor='black', facecolor='lightgray')这段代码做了三件事:用read_file读入GeoJSON并自动转成GeoDataFrame;通过head()查看字段结构;调用plot()方法画出所有区域的轮廓。edgecolor控制边界线颜色,facecolor控制填充色。先不急着加业务数据,能看到完整轮廓就算入门成功了。
但此时画出来的图比较简陋,坐标轴刻度、标题、图例都缺。实际操作中给图加标题和调整样式是一步到位的事情:
import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(12, 8)) gdf.boundary.plot(ax=ax, color='black', linewidth=0.5) gdf.plot(ax=ax, color='#e6e6e6') ax.set_title('US States Map', fontsize=16) ax.set_axis_off() # 关闭坐标轴,看起来更干净 plt.tight_layout() plt.savefig('us_states_map.png', dpi=200) plt.show()这里有个小技巧:先画boundary再画填充区域,可以让边界线盖在填充色上面,层次更清楚。set_axis_off()是我个人强烈建议加的一步,因为纯地图展示时坐标轴刻度基本是噪音。如果你要的是带经纬度刻度的那种地图,那就别关坐标系。
3.3 交互地图实操:Folium
如果你需要的是能缩放、能点击的网页地图,直接上Folium。Folium基于Leaflet封装的,使用体验很直观,核心就三行逻辑:创建地图对象、加载地理数据、保存成HTML。
import folium # 创建地图对象,center_lat和center_lon是初始视角的中心点 m = folium.Map(location=[39.8, -98.5], zoom_start=4) # 加边界 folium.GeoJson('us_states.geojson', name='states').add_to(m) # 保存成HTML m.save('us_states_map.html')location参数控制打开页面后的初始中心坐标,zoom_start控制缩放级别。对美国本土来说,zoom_start取4左右可以看到全貌,取5到6就能看到州界线的细节。如果你不知道中心点该填多少,可以先不加location参数,直接用m = folium.Map()生成一个默认视角,然后在地图上手动找到合适位置,再回头把坐标填进去。
Folium生成的HTML文件可以直接用浏览器打开,也可以嵌入到Flask、Django等Web框架中,对业务看板来说非常方便。
4. 在地图上叠加业务数据:实用玩法
4.1 按区域着色(Choropleth)
把业务数据叠加到地图上最常用的方式就是Choropleth,按区域着色。比如你手上有一份各州的销售金额数据,希望颜色越深代表金额越高,下面两种方案都能做。
方案一,用GeoPandas先把业务数据和地理数据合并,再利用hue参数着色。
import geopandas as gpd import pandas as pd gdf = gpd.read_file('us_states.geojson') sales = pd.read_csv('state_sales.csv') # 关键一步:合并数据,主键选区域编码或名称 merged = gdf.merge(sales, on='state_id') fig, ax = plt.subplots(figsize=(12, 8)) merged.plot(column='sales_amount', cmap='Blues', legend=True, ax=ax, edgecolor='black') ax.set_axis_off() plt.show()merge的操作是把原本分离的地理边界数据与业务数据绑定起来。如果这步没有成功匹配,那画出来的图就是一片空白或者全是一种颜色。我在字段清洗那一节里强调过的坑,主要就在这里爆发。
方案二,用Folium的Choropleth类。
import folium m = folium.Map(location=[39.8, -98.5], zoom_start=4) folium.Choropleth( geo_data='us_states.geojson', data=sales, # 业务数据DataFrame columns=['state_id', 'sales_amount'], key_on='feature.properties.state_id', # 地理数据里的字段路径 fill_color='YlGnBu', legend_name='Sales Amount' ).add_to(m) m.save('us_states_choropleth.html')key_on这个参数是Folium新手最容易搞混的地方。它填的不是业务数据里的列名,而是GeoJSON里属性字段的路径,格式一般是feature.properties.xxx。如果写错了,页面打开后地图没有任何颜色变化,控制台也不报错,排查起来很让人头痛。
4.2 把业务点画到地图上:散点与气泡
除了按区域着色,另一个常见需求是把具体的业务点画到地图上。比如门店分布、仓库位置、客户所在地,这些场景不需要整个区域着色,只需要在经纬度对应的位置画一个点或者气泡。
GeoPandas的做法是先根据经纬度构造点对象,再添加到GeoDataFrame中。
import geopandas as gpd from shapely.geometry import Point # data里有latitude和longitude字段 geometry = [Point(lon, lat) for lon, lat in zip(data['longitude'], data['latitude'])] gdf_points = gpd.GeoDataFrame(data, geometry=geometry, crs='EPSG:4326') fig, ax = plt.subplots(figsize=(12, 8)) gdf_states.boundary.plot(ax=ax, color='black', linewidth=0.5) gdf_points.plot(ax=ax, color='red', markersize=20, alpha=0.7) plt.show()这里有个容易忽略的点:Point(lon, lat)的参数顺序是经度在前、纬度在后。坐标一旦写反,所有点都会跑到完全错误的位置。我在初学阶段遇到过好几次,画出来的点要么落在海里,要么跑到另一块大陆上,就是因为这个顺序问题。
Folium里画点更简单,直接用Marker或者CircleMarker。
import folium m = folium.Map(location=[39.8, -98.5], zoom_start=4) for _, row in data.iterrows(): folium.CircleMarker( location=[row['latitude'], row['longitude']], radius=5, color='red', fill=True, fill_opacity=0.7, popup=f"{row['name']}: {row['value']}" ).add_to(m) m.save('us_states_points.html')CircleMarker的point大小不随缩放变化,所以更适合表示相对位置。如果你希望点的大小能表示数值大小,可以给radius赋一个按数值映射出来的值,就成了气泡图。
4.3 绘制线路和路径
除了点和面,还经常需要在地图上画连线,比如物流线路、迁移路径。Folium里可以用PolyLine实现:
import folium m = folium.Map(location=[39.8, -98.5], zoom_start=4) points = [ [34.05, -118.24], # 起点 [40.71, -74.00], # 途经点 [41.88, -87.63] # 终点 ] folium.PolyLine(points, color='blue', weight=3, opacity=0.8).add_to(m) m.save('us_routes.html')这里同样是经纬度顺序的问题,注意是按纬度、经度的顺序,和前面Point构造时相反。这类坐标顺序不一致的问题,是跨库使用时最容易翻车的地方。我的建议是把所有坐标统一用经纬度字符串存下来,在进入不同库时再显式转换。
4.4 配色和分箱的选择
Adjacent地图的视觉表达,关键就在配色和分箱策略。配色我用两类:
- 单色渐变色:适合表示密度或数值大小,比如Blues、YlGnBu。
- 分类离散色:适合表示类别属性,比如不同业务线用不同颜色,用colormap配合categorical=True。
分箱策略在很多时候直接用等距分箱就够了,但如果你的数据存在明显的长尾特征,比如大部分区域数值都很低,只有个别区域特别高,等距分箱会导致大多数区域颜色非常接近,看不出梯度。这时建议手动指定分箱范围,比如用分位数分箱,让颜色差异更贴近真实分布。
GeoPandas里设置分箱可以这样:
merged.plot(column='sales_amount', cmap='YlGnBu', legend=True, scheme='quantiles', k=5, edgecolor='black', ax=ax)scheme参数需要配合pysal库使用,如果没有安装,可以先降低需求,直接手动设置图例范围。分箱数量k建议5到7之间,太少区分度不够,太多看图的人又记不住含义。
5. 常见问题与排查技巧实录
5.1 安装依赖反复失败
GeoPandas的安装是很多人卡住的第一道坎。尤其是Windows环境,直接pip install geopandas之后,import时经常爆出错误,提示找不到Fiona、GDAL之类的底层库。原因在于这些库是C扩展,需要系统级的动态链接支持。
我的处理方案是先卸载干净,再全部用conda安装:
conda install -c conda-forge geopandas如果项目只能用pip,那也建议先装好PyPI上官方预编译的whl包,不要直接从源码编译。现在新版本的GeoPandas对依赖处理已经好了很多,但conda仍然是最省心的路线。
5.2 地图空白或只显示一个角落
画面空白,或者只显示地图的一个小角落,这种问题我排查过很多次,原因通常有两种。
第一种,GeoJSON缺少坐标系信息。有些旧数据文件没有写入坐标参考系,GeoPandas读入后crs是None,绘图时无法正确对齐。解决办法是给数据手动指定坐标系:
gdf = gdf.set_crs('EPSG:4326')第二种,中心点和缩放级别不匹配。Folium地图如果location设置到了完全不在数据范围内的坐标,或者zoom_start太大,打开页面就只看到一片空白,地图块完全没有加载。这时把zoom_start调小,或者把location改成数据范围内的坐标即可。
5.3 边界错位和数据对不齐
两张不同来源的地图叠在一起时,边界互相错位,这是坐标系不统一导致的。美国常用的地理坐标系有WGS84(EPSG:4326),也有各种投影坐标系。两个图层如果投影方式不同,直接叠加必然错位。
解决思路是先确认两个图层的crs,再把它们统一:
gdf_census = gdf_census.to_crs('EPSG:4326') gdf_other = gdf_other.to_crs('EPSG:4326')这个问题的隐蔽之处在于,有些数据明明坐标大致对得上,细节却差一点,不放大看不出来。我在做业务气泡叠加到边界图上时遇到过,所有点都歪东倒西,排查了一圈发现是底图和点数据一个来自官方数据源、一个来自第三方二手数据源,坐标基准不同。统一crs之后,问题立刻消失。
5.4 字体和中文显示问题
Matplotlib默认字体对中文支持很差,画出来的图里中文变成一个个方框。解决方案是显式指定一个支持中文的系统字体,例如在Windows上指定微软雅黑,在Linux上指定思源黑体或文泉驿。
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['Microsoft YaHei'] plt.rcParams['axes.unicode_minus'] = Falseaxes.unicode_minus这个配置也建议同时设一下,否则负号也会显示异常。如果你不希望在代码里写死字体名,也可以用FontProperties动态指定字体文件路径,便于团队内不同系统之间共享。
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 导入geopandas报错 | 底层依赖缺失 | conda安装geopandas |
| 地图空白 | crs未设置或中心点不对 | set_crs,调zoom_start |
| 边界错位 | 坐标系不统一 | 统一to_crs |
| 中文乱码 | 字体不支持 | 指定中文字体 |
| 数据颜色不显示 | 合并失败 | 检查主键,标准化区域名 |
| 点位置全跑偏 | 经纬度顺序写反 | Point(lon, lat)或location=[lat, lon] |
6. 把代码沉淀成自己的绘图工具
6.1 封装成函数,反复使用
项目做完之后,我把常用操作封装成了一个绘图工具模块。后续再有“给某个行政区域做业务数据展示”的需求,只用传入业务数据和主键字段,就能输出静态图或者交互式HTML。
核心思路是——人机交互的部分只保留参数口,把容易出错的数据清洗、坐标审核、合并逻辑都放在函数内部。比如合并前自动统一字段大小写,自动去除空白字符,自动转换主键类型。这些细节如果不封装起来,每次新项目都要重新处理一遍,既浪费时间又容易出新问题。
简单示意:
def plot_state_map(gdf, df, key_gdf, key_df, value_col): df[key_df] = df[key_df].str.strip().str.upper() gdf[key_gdf] = gdf[key_gdf].str.strip().str.upper() merged = gdf.merge(df, left_on=key_gdf, right_on=key_df, how='left') fig, ax = plt.subplots(figsize=(12, 8)) merged.plot(column=value_col, cmap='YlGnBu', legend=True, ax=ax, edgecolor='black') ax.set_axis_off() return fig, ax函数的注释要多写清楚参数含义、期望的数据格式。封装好之后,你自己用着顺手,团队其他人接手时也有据可依。
6.2 性能优化的小技巧
当多边形的数量很多,或者业务数据特别大的时候,绘图速度会明显下降。我常用的优化手段有三个:
- 对GeoJSON做简化。如果只需要大尺度展示,可以使用简化算法降低多边形顶点数量,图形差异不大,但渲染速度能快很多。
- 先裁剪再画。如果数据范围涵盖全国,但只在某几个区域有点位,可以把其他无关区域先过滤掉。
- 交互图用Zoom和Tile策略。Folium默认加载所有区域,但浏览器性能有限,大数据场景建议通过分级加载来处理。
6.3 导出的细节
最后提一句导出图片。Matplotlib导出PNG时,dpi我一般设置200到300,太低会糊,太高文件体积又过大。如果目标用途是打印,可以导出PDF或SVG矢量格式,文字和边界都会更清晰。另外导出的图片带不带坐标轴、带不带图例,也要在savefig之前确认好,别等导出后发现布局不对再回头调。
Folium导出没有这个问题,直接用m.save()落地HTML文件即可。如果要做嵌入,也可以把HTML片段嵌入到Web页面里,配合其他前端图表一起用。
整个项目做下来,我最大的体会是:画地图本身不难,难的是数据规范的统一和对坐标系统的敏感度。很多报错和错位,归根结底就是数据不干净、坐标系不一致。与其在出问题时东查西找,不如从头到尾只认准一套官方数据源,并在每一步都显式确认字段类型、字段名称、坐标系这三种信息。把这套习惯养成了,以后画任何区域的地图都不在话下。