znlgis 博客

GIS开发与技术分享

第23章:地理编码(Geocoding)

地理编码是将人类可读的地址信息转换为地理坐标的过程,是地理信息系统中连接文本地址与空间位置的关键桥梁。GeoPandas 通过集成 geopy 库,提供了简洁高效的地理编码接口,使得在 Python 数据分析工作流中轻松实现地址与坐标之间的相互转换。本章将全面介绍 GeoPandas 中的地理编码功能,包括正向编码、逆向编码、批量处理、性能优化以及实际应用案例。


23.1 地理编码概述

23.1.1 什么是地理编码

地理编码(Geocoding)是地理信息处理中的一项基础技术,主要包含两个方向的转换:

正向地理编码(Forward Geocoding):将文本地址转换为地理坐标(经纬度)。例如,将”北京市海淀区中关村南大街1号”转换为经度 116.326、纬度 39.965。

逆向地理编码(Reverse Geocoding):将地理坐标(经纬度)转换为人类可读的地址信息。例如,将坐标 (116.397, 39.908) 转换为”北京市东城区天安门广场”。

# 地理编码的基本概念示意
# 正向地理编码:地址 -> 坐标
# "北京市朝阳区望京街道" -> (116.480, 40.002)

# 逆向地理编码:坐标 -> 地址
# (121.474, 31.230) -> "上海市黄浦区南京东路"

地理编码的核心原理是通过查询地理数据库,将地址字符串进行解析、匹配,最终返回对应的空间位置信息。现代地理编码服务通常基于以下技术:

  • 地址解析:将地址字符串拆分为省、市、区、街道、门牌号等结构化组件
  • 模糊匹配:处理地址中的拼写错误、缩写、别名等不规范情况
  • 空间索引:利用空间索引技术加速坐标查找过程

23.1.2 地理编码的应用场景

地理编码在众多领域有着广泛的应用:

应用领域 具体场景 说明
物流配送 订单地址转坐标 计算配送路线和距离
商业分析 门店选址分析 将客户地址转为坐标进行空间分析
城市规划 设施分布分析 将地址数据转换为可视化地图
房地产 房源位置标注 在地图上展示房源分布
公共卫生 疫情数据地理化 将病例地址转为坐标进行空间聚类分析
应急管理 事故定位 快速将报警地址转为精确坐标

23.2 GeoPandas geocoding 接口

23.2.1 依赖安装

GeoPandas 的地理编码功能依赖于 geopy 库。geopy 是一个 Python 地理编码客户端库,支持多种地理编码服务提供商。

# 安装 geopy
# pip install geopy

# 验证安装
import geopy
print(f"geopy 版本: {geopy.__version__}")

输出:

geopy 版本: 2.4.1

如果需要完整的地理编码环境,建议同时安装以下依赖:

# 完整安装命令
# pip install geopandas geopy requests

# 验证所有依赖
import geopandas as gpd
import geopy
from geopy.geocoders import Nominatim

print(f"GeoPandas 版本: {gpd.__version__}")
print(f"geopy 版本: {geopy.__version__}")
print("所有依赖安装成功!")

输出:

GeoPandas 版本: 1.0.1
geopy 版本: 2.4.1
所有依赖安装成功!

23.2.2 接口架构

GeoPandas 提供了两个核心地理编码函数,位于 geopandas.tools 模块中:

import geopandas as gpd

# GeoPandas 地理编码接口概览
# 1. 正向地理编码
# gpd.tools.geocode(strings, provider, **kwargs)

# 2. 逆向地理编码
# gpd.tools.reverse_geocode(points, provider, **kwargs)

接口架构的核心设计如下:

组件 说明
gpd.tools.geocode() 正向地理编码,地址转坐标
gpd.tools.reverse_geocode() 逆向地理编码,坐标转地址
geopy.geocoders 底层服务提供商接口
provider 参数 指定使用的编码服务提供商

GeoPandas 地理编码接口的工作流程:

# 接口调用链路示意
# 用户调用 gpd.tools.geocode()
#   -> 创建 geopy geocoder 实例
#     -> 发送 HTTP 请求到服务提供商 API
#       -> 解析返回的 JSON 结果
#         -> 构建 GeoDataFrame 返回

23.3 gpd.tools.geocode() 用法

23.3.1 基本用法

gpd.tools.geocode() 是 GeoPandas 中执行正向地理编码的核心函数:

import geopandas as gpd

# 基本地理编码示例
addresses = [
    "Beijing, China",
    "Shanghai, China",
    "Guangzhou, China"
]

# 使用 Nominatim 提供商进行地理编码
result = gpd.tools.geocode(
    addresses,
    provider="nominatim",
    user_agent="geopandas_tutorial"
)

print(type(result))
print(result)

输出:

<class 'geopandas.geodataframe.GeoDataFrame'>
                                            geometry                  address
0  POINT (116.39723 39.90750)      Beijing, China
1  POINT (121.46941 31.23170)     Shanghai, China
2  POINT (113.26436 23.12908)    Guangzhou, China

返回结果是一个标准的 GeoDataFrame,包含 geometry 列(Point 类型)和 address 列(标准化后的地址)。

23.3.2 参数详解

gpd.tools.geocode() 函数的完整参数说明:

# 函数签名
# gpd.tools.geocode(
#     strings,      # 地址列表或 Series
#     provider=None, # 服务提供商名称或 geopy geocoder 实例
#     user_agent=None, # 用户代理标识
#     timeout=None,    # 请求超时时间(秒)
#     **kwargs         # 传递给 geopy geocoder 的额外参数
# )

# 参数详解示例
addresses = ["Nanjing, China", "Hangzhou, China"]

# 使用字符串指定提供商
result1 = gpd.tools.geocode(
    strings=addresses,          # 必需:地址列表
    provider="nominatim",       # 提供商名称
    user_agent="my_application", # Nominatim 必需的用户代理
    timeout=10                  # 超时时间10秒
)
print("方式一(字符串提供商):")
print(result1[["address"]].to_string())

输出:

方式一(字符串提供商):
                                    address
0   Nanjing, Jiangsu, China
1  Hangzhou, Zhejiang, China
# 使用 geopy geocoder 实例作为提供商
from geopy.geocoders import Nominatim

# 创建自定义 geocoder 实例
geocoder = Nominatim(
    user_agent="geopandas_tutorial",
    timeout=15,
    domain="nominatim.openstreetmap.org"
)

result2 = gpd.tools.geocode(
    strings=addresses,
    provider=geocoder  # 直接传入 geocoder 实例
)
print("方式二(geocoder 实例):")
print(result2[["address"]].to_string())

输出:

方式二(geocoder 实例):
                                    address
0   Nanjing, Jiangsu, China
1  Hangzhou, Zhejiang, China

23.3.3 返回结果结构

地理编码返回的 GeoDataFrame 具有以下结构:

import geopandas as gpd

addresses = ["Chengdu, China", "Wuhan, China", "Xi'an, China"]

result = gpd.tools.geocode(
    addresses,
    provider="nominatim",
    user_agent="geopandas_tutorial"
)

# 查看结果结构
print("列名:", result.columns.tolist())
print("数据类型:")
print(result.dtypes)
print(f"\nCRS: {result.crs}")
print(f"行数: {len(result)}")

# 提取坐标信息
result["longitude"] = result.geometry.x
result["latitude"] = result.geometry.y
print("\n坐标信息:")
print(result[["address", "longitude", "latitude"]].to_string())

输出:

列名: ['geometry', 'address']
数据类型:
geometry    geometry
address       object
dtype: object

CRS: EPSG:4326
行数: 3

坐标信息:
                        address    longitude   latitude
0     Chengdu, Sichuan, China   104.06574   30.57283
1       Wuhan, Hubei, China     114.30525   30.59280
2      Xi'an, Shaanxi, China    108.94024   34.26071

注意: 返回的 GeoDataFrame 默认使用 EPSG:4326(WGS84)坐标参考系统,即经纬度坐标。如果需要进行距离计算等操作,建议先转换到合适的投影坐标系。


23.4 逆地理编码

23.4.1 gpd.tools.reverse_geocode() 用法

逆地理编码将坐标点转换为对应的地址信息:

import geopandas as gpd
from shapely.geometry import Point

# 定义坐标点(经度, 纬度)
points = [
    Point(116.397, 39.908),   # 北京天安门附近
    Point(121.474, 31.230),   # 上海外滩附近
    Point(113.264, 23.129)    # 广州市中心附近
]

# 执行逆地理编码
result = gpd.tools.reverse_geocode(
    points,
    provider="nominatim",
    user_agent="geopandas_tutorial"
)

print(result[["address"]].to_string())

输出:

                                                    address
0   Tian'anmen, Dongcheng District, Beijing, China
1   The Bund, Huangpu District, Shanghai, China
2   Yuexiu District, Guangzhou, Guangdong, China

23.4.2 从坐标获取地址

在实际工作中,坐标数据可能来自 GPS 设备、传感器或数据库:

import pandas as pd
import geopandas as gpd
from shapely.geometry import Point

# 模拟 GPS 采集数据
gps_data = pd.DataFrame({
    "device_id": ["GPS_001", "GPS_002", "GPS_003", "GPS_004"],
    "longitude": [116.397, 121.474, 113.264, 104.066],
    "latitude": [39.908, 31.230, 23.129, 30.573],
    "timestamp": [
        "2024-01-15 08:30:00",
        "2024-01-15 09:15:00",
        "2024-01-15 10:00:00",
        "2024-01-15 11:30:00"
    ]
})

# 创建 Point 对象列表
points = [
    Point(row["longitude"], row["latitude"])
    for _, row in gps_data.iterrows()
]

# 逆地理编码获取地址
geocoded = gpd.tools.reverse_geocode(
    points,
    provider="nominatim",
    user_agent="geopandas_tutorial"
)

# 合并原始数据与地理编码结果
gps_data["address"] = geocoded["address"].values
gps_data["geometry"] = geocoded["geometry"].values

print("GPS 数据地理编码结果:")
print(gps_data[["device_id", "longitude", "latitude", "address"]].to_string())

输出:

GPS 数据地理编码结果:
  device_id  longitude  latitude                                       address
0   GPS_001    116.397    39.908   Tian'anmen, Dongcheng, Beijing, China
1   GPS_002    121.474    31.230   The Bund, Huangpu, Shanghai, China
2   GPS_003    113.264    23.129   Yuexiu District, Guangzhou, Guangdong, China
3   GPS_004    104.066    30.573   Wuhou District, Chengdu, Sichuan, China

23.5 geocoding 提供商选择

23.5.1 常用提供商对比

geopy 支持多种地理编码服务提供商,以下是常用提供商的对比:

提供商 类名 费用 数据源 精度 速率限制 适用场景
Nominatim Nominatim 免费 OpenStreetMap 中等 1次/秒 学习测试、小批量
Google Maps GoogleV3 付费 Google 50次/秒 生产环境、高精度需求
Bing Maps Bing 付费 Microsoft 按配额 企业应用
高德地图 GaoDe 部分免费 高德 高(国内) 按配额 国内地址编码
百度地图 Baidu 部分免费 百度 高(国内) 按配额 国内地址编码
ArcGIS ArcGIS 部分免费 Esri 按配额 GIS专业应用

23.5.2 配置不同提供商

以下示例展示如何配置不同的地理编码提供商:

from geopy.geocoders import Nominatim, GoogleV3, ArcGIS
import geopandas as gpd

# 1. Nominatim(免费,基于 OpenStreetMap)
nominatim_geocoder = Nominatim(
    user_agent="my_geocoding_app",
    timeout=10
)

# 2. Google Maps(需要 API Key)
# google_geocoder = GoogleV3(
#     api_key="YOUR_GOOGLE_API_KEY",
#     timeout=10
# )

# 3. ArcGIS(部分功能免费)
arcgis_geocoder = ArcGIS(
    timeout=10
)

# 使用 Nominatim 进行编码
addresses = ["Tokyo, Japan", "Seoul, South Korea"]

result = gpd.tools.geocode(
    addresses,
    provider=nominatim_geocoder
)
print("Nominatim 结果:")
print(result[["address"]].to_string())

输出:

Nominatim 结果:
                              address
0        Tokyo, Japan
1   Seoul, South Korea
# 配置高德地图提供商(国内地址推荐)
# from geopy.geocoders import GaoDe
#
# gaode_geocoder = GaoDe(
#     api_key="YOUR_GAODE_API_KEY",
#     timeout=10
# )
#
# # 使用高德地图进行国内地址编码
# cn_addresses = ["北京市海淀区中关村", "上海市浦东新区陆家嘴"]
# result = gpd.tools.geocode(
#     cn_addresses,
#     provider=gaode_geocoder
# )

# 配置百度地图提供商
# from geopy.geocoders import Baidu
#
# baidu_geocoder = Baidu(
#     api_key="YOUR_BAIDU_API_KEY",
#     timeout=10
# )

注意: Nominatim 是唯一免费且无需 API Key 的提供商,适合学习和测试使用。生产环境建议使用付费服务以获得更高的精度和稳定性。使用 Nominatim 时务必设置合理的 user_agent,并遵守其使用政策(最多 1 次请求/秒)。


23.6 批量地理编码

23.6.1 批量正向地理编码

处理大量地址时,需要考虑请求频率限制和错误处理:

import pandas as pd
import geopandas as gpd
import time

# 模拟批量地址数据
address_df = pd.DataFrame({
    "id": range(1, 7),
    "name": ["总部", "分公司A", "分公司B", "仓库", "门店1", "门店2"],
    "address": [
        "Beijing, China",
        "Shanghai, China",
        "Guangzhou, China",
        "Shenzhen, China",
        "Hangzhou, China",
        "Nanjing, China"
    ]
})

# 直接批量编码
result = gpd.tools.geocode(
    address_df["address"].tolist(),
    provider="nominatim",
    user_agent="batch_geocoding_demo",
    timeout=10
)

# 合并结果
address_df["geometry"] = result["geometry"].values
address_df["geocoded_address"] = result["address"].values
address_df["lng"] = result.geometry.x.values
address_df["lat"] = result.geometry.y.values

print("批量编码结果:")
print(address_df[["name", "address", "lng", "lat"]].to_string())

输出:

批量编码结果:
     name         address        lng       lat
0     总部  Beijing, China    116.397   39.908
1  分公司A  Shanghai, China   121.469   31.232
2  分公司B  Guangzhou, China  113.264   23.129
3     仓库  Shenzhen, China   114.058   22.543
4    门店1  Hangzhou, China   120.153   30.287
5    门店2  Nanjing, China    118.797   32.061

23.6.2 批量逆地理编码

import pandas as pd
import geopandas as gpd
from shapely.geometry import Point

# 批量坐标数据
coord_df = pd.DataFrame({
    "station_id": [f"S{i:03d}" for i in range(1, 6)],
    "longitude": [116.397, 121.474, 113.264, 114.058, 120.153],
    "latitude": [39.908, 31.230, 23.129, 22.543, 30.287]
})

# 创建点列表
points = [
    Point(row["longitude"], row["latitude"])
    for _, row in coord_df.iterrows()
]

# 批量逆地理编码
result = gpd.tools.reverse_geocode(
    points,
    provider="nominatim",
    user_agent="batch_reverse_demo"
)

# 合并结果
coord_df["address"] = result["address"].values
print("批量逆编码结果:")
print(coord_df.to_string())

输出:

批量逆编码结果:
  station_id  longitude  latitude                                   address
0       S001    116.397    39.908   Dongcheng District, Beijing, China
1       S002    121.474    31.230   Huangpu District, Shanghai, China
2       S003    113.264    23.129   Yuexiu District, Guangzhou, China
3       S004    114.058    22.543   Futian District, Shenzhen, China
4       S005    120.153    30.287   Xihu District, Hangzhou, China

23.6.3 进度监控

处理大量数据时,添加进度监控可以帮助跟踪编码状态:

import pandas as pd
import geopandas as gpd
from shapely.geometry import Point
import time

def geocode_with_progress(addresses, provider="nominatim",
                          user_agent="progress_demo",
                          batch_size=5, delay=1.0):
    """
    带进度监控的批量地理编码

    参数:
        addresses: 地址列表
        provider: 服务提供商
        user_agent: 用户代理
        batch_size: 每批处理数量
        delay: 批次间延迟(秒)
    """
    all_results = []
    total = len(addresses)

    for i in range(0, total, batch_size):
        batch = addresses[i:i + batch_size]
        batch_num = i // batch_size + 1
        total_batches = (total + batch_size - 1) // batch_size

        print(f"处理第 {batch_num}/{total_batches} 批 "
              f"({i+1}-{min(i+batch_size, total)}/{total})")

        try:
            result = gpd.tools.geocode(
                batch,
                provider=provider,
                user_agent=user_agent
            )
            all_results.append(result)
            success = result["geometry"].notna().sum()
            print(f"  成功: {success}/{len(batch)}")
        except Exception as e:
            print(f"  批次编码失败: {e}")

        # 批次间延迟,避免触发限速
        if i + batch_size < total:
            time.sleep(delay)

    # 合并所有结果
    if all_results:
        final_result = pd.concat(all_results, ignore_index=True)
        return gpd.GeoDataFrame(final_result, geometry="geometry")
    return None

# 使用示例
addresses = [
    "Beijing, China", "Shanghai, China",
    "Guangzhou, China", "Shenzhen, China",
    "Chengdu, China", "Wuhan, China",
    "Hangzhou, China", "Nanjing, China"
]

result = geocode_with_progress(addresses, batch_size=3, delay=1.5)
if result is not None:
    print(f"\n总计编码: {len(result)} 条地址")
    print(f"成功率: {result['geometry'].notna().mean():.1%}")

输出:

处理第 1/3 批 (1-3/8)
  成功: 3/3
处理第 2/3 批 (4-6/8)
  成功: 3/3
处理第 3/3 批 (7-8/8)
  成功: 2/2

总计编码: 8 条地址
成功率: 100.0%

23.7 处理编码失败

23.7.1 常见错误类型

地理编码过程中可能遇到多种错误:

错误类型 原因 表现
GeocoderTimedOut 请求超时 网络延迟或服务端响应慢
GeocoderServiceError 服务端错误 服务不可用或内部错误
GeocoderQuotaExceeded 超出配额 请求次数超过限制
GeocoderUnavailable 服务不可用 服务端维护或宕机
GeocoderNotFound 地址无法解析 返回 None 或空结果
ConfigurationError 配置错误 API Key 无效或参数错误
from geopy.exc import (
    GeocoderTimedOut,
    GeocoderServiceError,
    GeocoderQuotaExceeded,
    GeocoderUnavailable
)

# 常见错误示例
print("geopy 常见异常类:")
print("- GeocoderTimedOut: 请求超时")
print("- GeocoderServiceError: 服务端错误")
print("- GeocoderQuotaExceeded: 配额超限")
print("- GeocoderUnavailable: 服务不可用")

输出:

geopy 常见异常类:
- GeocoderTimedOut: 请求超时
- GeocoderServiceError: 服务端错误
- GeocoderQuotaExceeded: 配额超限
- GeocoderUnavailable: 服务不可用

23.7.2 错误处理策略

使用 try/except 和重试机制构建健壮的编码流程:

import time
import pandas as pd
import geopandas as gpd
from shapely.geometry import Point
from geopy.exc import GeocoderTimedOut, GeocoderServiceError

def safe_geocode_single(address, geocoder, max_retries=3, delay=2.0):
    """
    安全的单条地址编码,带重试机制

    参数:
        address: 地址字符串
        geocoder: geopy geocoder 实例
        max_retries: 最大重试次数
        delay: 重试间隔(秒)

    返回:
        (location, address_str) 或 (None, None)
    """
    for attempt in range(max_retries):
        try:
            location = geocoder.geocode(address, timeout=10)
            if location:
                return (
                    Point(location.longitude, location.latitude),
                    location.address
                )
            else:
                print(f"  地址未找到: {address}")
                return (None, None)
        except GeocoderTimedOut:
            print(f"  超时(第 {attempt+1} 次): {address}")
            if attempt < max_retries - 1:
                time.sleep(delay * (attempt + 1))  # 递增延迟
        except GeocoderServiceError as e:
            print(f"  服务错误: {e}")
            if attempt < max_retries - 1:
                time.sleep(delay * (attempt + 1))
        except Exception as e:
            print(f"  未知错误: {e}")
            return (None, None)

    print(f"  重试耗尽: {address}")
    return (None, None)


def robust_batch_geocode(addresses, user_agent="robust_demo",
                         max_retries=3):
    """
    健壮的批量地理编码函数
    """
    from geopy.geocoders import Nominatim

    geocoder = Nominatim(user_agent=user_agent, timeout=10)
    results = []

    for i, addr in enumerate(addresses):
        print(f"[{i+1}/{len(addresses)}] 编码: {addr}")
        geom, resolved_addr = safe_geocode_single(
            addr, geocoder, max_retries=max_retries
        )
        results.append({
            "input_address": addr,
            "resolved_address": resolved_addr,
            "geometry": geom,
            "success": geom is not None
        })
        time.sleep(1.1)  # 遵守 Nominatim 限速

    df = pd.DataFrame(results)
    success_count = df["success"].sum()
    print(f"\n编码完成: {success_count}/{len(addresses)} 成功")
    return df

# 使用示例(包含一些可能失败的地址)
test_addresses = [
    "Beijing, China",
    "InvalidAddressXYZ12345",   # 无效地址
    "Shanghai, China"
]

result_df = robust_batch_geocode(test_addresses)
print(result_df[["input_address", "success"]].to_string())

输出:

[1/3] 编码: Beijing, China
[2/3] 编码: InvalidAddressXYZ12345
  地址未找到: InvalidAddressXYZ12345
[3/3] 编码: Shanghai, China

编码完成: 2/3 成功
     input_address  success
0   Beijing, China     True
1  InvalidAddressXYZ12345    False
2  Shanghai, China     True

23.7.3 结果质量验证

编码结果的质量验证是确保数据可靠性的关键步骤:

import pandas as pd
import geopandas as gpd
from shapely.geometry import Point, box

def validate_geocoding_results(gdf, expected_bounds=None,
                                address_col="address"):
    """
    验证地理编码结果质量

    参数:
        gdf: 编码结果 GeoDataFrame
        expected_bounds: 预期边界 (minx, miny, maxx, maxy)
        address_col: 地址列名
    """
    report = {}

    # 1. 检查空值
    null_geom = gdf["geometry"].isna().sum()
    report["空几何数量"] = null_geom
    report["编码成功率"] = f"{(1 - null_geom / len(gdf)):.1%}"

    # 2. 检查坐标范围
    valid = gdf[gdf["geometry"].notna()].copy()
    if len(valid) > 0:
        report["经度范围"] = f"[{valid.geometry.x.min():.3f}, {valid.geometry.x.max():.3f}]"
        report["纬度范围"] = f"[{valid.geometry.y.min():.3f}, {valid.geometry.y.max():.3f}]"

    # 3. 边界检查
    if expected_bounds and len(valid) > 0:
        bounds_box = box(*expected_bounds)
        within_bounds = valid.geometry.within(bounds_box).sum()
        report["边界内数量"] = f"{within_bounds}/{len(valid)}"

    # 4. 重复坐标检查(可能表示多个地址解析到同一位置)
    if len(valid) > 0:
        coords = valid.geometry.apply(lambda p: (round(p.x, 5), round(p.y, 5)))
        duplicates = coords.duplicated().sum()
        report["重复坐标数"] = duplicates

    return report

# 使用示例
sample_data = gpd.GeoDataFrame({
    "address": ["Beijing", "Shanghai", "Guangzhou", "Unknown Place"],
    "geometry": [
        Point(116.397, 39.908),
        Point(121.474, 31.230),
        Point(113.264, 23.129),
        None  # 编码失败
    ]
}, crs="EPSG:4326")

# 中国大陆边界范围
china_bounds = (73.0, 18.0, 135.0, 54.0)

report = validate_geocoding_results(sample_data, expected_bounds=china_bounds)
print("编码质量报告:")
for key, value in report.items():
    print(f"  {key}: {value}")

输出:

编码质量报告:
  空几何数量: 1
  编码成功率: 75.0%
  经度范围: [113.264, 121.474]
  纬度范围: [23.129, 39.908]
  边界内数量: 3/3
  重复坐标数: 0

23.8 性能优化与限速

23.8.1 请求限速

大多数地理编码服务都有请求频率限制,geopy 提供了内置的限速适配器:

from geopy.geocoders import Nominatim
from geopy.extra.rate_limiter import RateLimiter

# 创建 geocoder 实例
geocoder = Nominatim(user_agent="ratelimit_demo")

# 使用 RateLimiter 包装 geocode 方法
# min_delay_seconds: 每次请求间的最小间隔
geocode_with_limit = RateLimiter(
    geocoder.geocode,
    min_delay_seconds=1.0,    # 最小延迟1秒
    max_retries=3,            # 最大重试次数
    error_wait_seconds=5.0    # 错误后等待时间
)

# 同样适用于逆地理编码
reverse_with_limit = RateLimiter(
    geocoder.reverse,
    min_delay_seconds=1.0,
    max_retries=3,
    error_wait_seconds=5.0
)

# 批量使用 RateLimiter
addresses = ["Beijing, China", "Shanghai, China", "Guangzhou, China"]

results = []
for addr in addresses:
    location = geocode_with_limit(addr)
    if location:
        results.append({
            "address": addr,
            "lat": location.latitude,
            "lng": location.longitude
        })
        print(f"  已编码: {addr} -> ({location.latitude:.3f}, {location.longitude:.3f})")

print(f"\n成功编码 {len(results)}/{len(addresses)} 条")

输出:

  已编码: Beijing, China -> (39.908, 116.397)
  已编码: Shanghai, China -> (31.232, 121.469)
  已编码: Guangzhou, China -> (23.129, 113.264)

成功编码 3/3 条
# 在 pandas apply 中使用 RateLimiter
import pandas as pd

df = pd.DataFrame({
    "city": ["Beijing, China", "Shanghai, China",
             "Shenzhen, China", "Chengdu, China"]
})

# 利用 apply 结合 RateLimiter 进行批量编码
df["location"] = df["city"].apply(geocode_with_limit)
df["lat"] = df["location"].apply(
    lambda loc: loc.latitude if loc else None
)
df["lng"] = df["location"].apply(
    lambda loc: loc.longitude if loc else None
)

print(df[["city", "lat", "lng"]].to_string())

输出:

             city      lat       lng
0  Beijing, China   39.908   116.397
1  Shanghai, China  31.232   121.469
2  Shenzhen, China  22.543   114.058
3  Chengdu, China   30.573   104.066

23.8.2 缓存策略

对于重复地址,使用缓存可以显著减少 API 请求次数:

import json
import hashlib
from geopy.geocoders import Nominatim
from geopy.extra.rate_limiter import RateLimiter

class GeocodingCache:
    """简单的内存缓存地理编码器"""

    def __init__(self, user_agent="cache_demo"):
        self.geocoder = Nominatim(user_agent=user_agent, timeout=10)
        self.geocode_fn = RateLimiter(
            self.geocoder.geocode,
            min_delay_seconds=1.0
        )
        self.cache = {}
        self.stats = {"hits": 0, "misses": 0}

    def _make_key(self, address):
        """生成缓存键"""
        normalized = address.strip().lower()
        return hashlib.md5(normalized.encode()).hexdigest()

    def geocode(self, address):
        """带缓存的地理编码"""
        key = self._make_key(address)

        if key in self.cache:
            self.stats["hits"] += 1
            return self.cache[key]

        self.stats["misses"] += 1
        location = self.geocode_fn(address)

        if location:
            result = {
                "lat": location.latitude,
                "lng": location.longitude,
                "address": location.address
            }
        else:
            result = None

        self.cache[key] = result
        return result

    def get_stats(self):
        """获取缓存统计"""
        total = self.stats["hits"] + self.stats["misses"]
        hit_rate = self.stats["hits"] / total if total > 0 else 0
        return {
            "总请求": total,
            "缓存命中": self.stats["hits"],
            "缓存未命中": self.stats["misses"],
            "命中率": f"{hit_rate:.1%}"
        }

# 使用示例
cache = GeocodingCache()

# 第一次查询(缓存未命中,发送 API 请求)
addresses = ["Beijing, China", "Shanghai, China", "Beijing, China"]
for addr in addresses:
    result = cache.geocode(addr)
    if result:
        print(f"{addr} -> ({result['lat']:.3f}, {result['lng']:.3f})")

# 查看缓存统计
print("\n缓存统计:")
for k, v in cache.get_stats().items():
    print(f"  {k}: {v}")

输出:

Beijing, China -> (39.908, 116.397)
Shanghai, China -> (31.232, 121.469)
Beijing, China -> (39.908, 116.397)

缓存统计:
  总请求: 3
  缓存命中: 1
  缓存未命中: 2
  命中率: 33.3%

23.8.3 并发请求

对于支持高并发的付费服务,可以使用异步方式提高编码效率:

import asyncio
from geopy.geocoders import Nominatim
from geopy.adapters import AioHTTPAdapter

async def async_geocode_batch(addresses, user_agent="async_demo"):
    """
    异步批量地理编码(适用于支持高并发的付费服务)

    注意: Nominatim 限制为 1次/秒,此方法更适合付费服务
    """
    async with Nominatim(
        user_agent=user_agent,
        adapter_factory=AioHTTPAdapter,
        timeout=10
    ) as geocoder:
        results = []
        for addr in addresses:
            try:
                location = await geocoder.geocode(addr)
                if location:
                    results.append({
                        "address": addr,
                        "lat": location.latitude,
                        "lng": location.longitude,
                        "status": "success"
                    })
                else:
                    results.append({
                        "address": addr,
                        "lat": None,
                        "lng": None,
                        "status": "not_found"
                    })
            except Exception as e:
                results.append({
                    "address": addr,
                    "lat": None,
                    "lng": None,
                    "status": f"error: {str(e)}"
                })
            # 遵守 Nominatim 限速
            await asyncio.sleep(1.1)

        return results

# 使用示例
# 注意:在 Jupyter 中使用 await,在脚本中使用 asyncio.run()
addresses = ["Beijing, China", "Shanghai, China"]

# results = asyncio.run(async_geocode_batch(addresses))
# for r in results:
#     print(f"{r['address']}: ({r['lat']}, {r['lng']}) [{r['status']}]")

print("异步编码适用于付费高并发服务(如 Google Maps)")
print("Nominatim 因限速限制,异步优势不明显")

输出:

异步编码适用于付费高并发服务(如 Google Maps)
Nominatim 因限速限制,异步优势不明显

注意: 使用并发请求时,务必遵守各服务提供商的使用条款和速率限制。Nominatim 严格限制为每秒 1 次请求,违反可能导致 IP 被封禁。并发请求主要适用于付费服务,如 Google Maps(50 QPS)或企业版服务。


23.9 实际应用案例

23.9.1 地址数据清洗与标准化

地理编码可以帮助标准化不规范的地址数据:

import pandas as pd
import geopandas as gpd
from geopy.geocoders import Nominatim
from geopy.extra.rate_limiter import RateLimiter

# 模拟不规范的地址数据
raw_addresses = pd.DataFrame({
    "id": range(1, 6),
    "raw_address": [
        "Beijing China",         # 缺少标点
        "shanghai, CN",          # 小写,缩写
        "Guangzhou Guangdong",   # 不完整
        "ShenZhen",              # 大小写混合
        "Chengdu, Sichuan, China"  # 完整地址
    ]
})

print("原始地址数据:")
print(raw_addresses.to_string())

# 使用地理编码进行地址标准化
result = gpd.tools.geocode(
    raw_addresses["raw_address"].tolist(),
    provider="nominatim",
    user_agent="address_cleaning_demo"
)

# 合并标准化结果
raw_addresses["standardized_address"] = result["address"].values
raw_addresses["longitude"] = result.geometry.x.values
raw_addresses["latitude"] = result.geometry.y.values
raw_addresses["is_valid"] = result.geometry.notna().values

print("\n标准化后:")
print(raw_addresses[["raw_address", "standardized_address", "is_valid"]].to_string())

输出:

原始地址数据:
   id              raw_address
0   1          Beijing China
1   2           shanghai, CN
2   3    Guangzhou Guangdong
3   4              ShenZhen
4   5  Chengdu, Sichuan, China

标准化后:
              raw_address                     standardized_address  is_valid
0          Beijing China                 Beijing, China              True
1           shanghai, CN                Shanghai, China              True
2    Guangzhou Guangdong   Guangzhou, Guangdong, China              True
3              ShenZhen    Shenzhen, Guangdong, China               True
4  Chengdu, Sichuan, China  Chengdu, Sichuan, China               True
# 地址去重:识别指向同一位置的不同地址表述
import numpy as np

def find_duplicate_locations(gdf, distance_threshold=100):
    """
    查找指向同一位置的重复地址(距离阈值内视为同一位置)

    参数:
        gdf: 含 geometry 的 GeoDataFrame
        distance_threshold: 距离阈值(米)
    """
    # 投影到 UTM 坐标系进行距离计算
    projected = gdf.to_crs(epsg=3857)

    duplicates = []
    for i in range(len(projected)):
        for j in range(i + 1, len(projected)):
            if projected.geometry.iloc[i] is None or projected.geometry.iloc[j] is None:
                continue
            dist = projected.geometry.iloc[i].distance(projected.geometry.iloc[j])
            if dist < distance_threshold:
                duplicates.append((i, j, dist))

    return duplicates

# 示例:检查是否有地址指向同一位置
print("重复位置检测功能已准备就绪")
print("可用于识别数据中的重复地址记录")

输出:

重复位置检测功能已准备就绪
可用于识别数据中的重复地址记录

23.9.2 客户位置分析

结合地理编码和空间分析进行客户位置分析:

import pandas as pd
import geopandas as gpd
from shapely.geometry import Point
import numpy as np

# 模拟客户数据(假设已经过地理编码)
np.random.seed(42)
n_customers = 20

customers = gpd.GeoDataFrame({
    "customer_id": [f"C{i:03d}" for i in range(1, n_customers + 1)],
    "city": np.random.choice(
        ["Beijing", "Shanghai", "Guangzhou", "Shenzhen", "Hangzhou"],
        n_customers
    ),
    "revenue": np.random.uniform(1000, 50000, n_customers).round(2),
    "geometry": [
        Point(
            np.random.uniform(113, 122),
            np.random.uniform(22, 40)
        ) for _ in range(n_customers)
    ]
}, crs="EPSG:4326")

# 1. 按城市汇总客户统计
city_stats = customers.groupby("city").agg(
    客户数=("customer_id", "count"),
    总收入=("revenue", "sum"),
    平均收入=("revenue", "mean")
).round(2)

print("城市客户统计:")
print(city_stats.to_string())

# 2. 计算客户分布的中心点
center = customers.geometry.unary_union.centroid
print(f"\n客户分布中心点: ({center.x:.3f}, {center.y:.3f})")

# 3. 计算各客户到中心点的距离
customers_proj = customers.to_crs(epsg=3857)
center_proj = customers_proj.geometry.unary_union.centroid
customers["dist_to_center_km"] = (
    customers_proj.geometry.distance(center_proj) / 1000
).round(1)

print("\n距离中心最远的5个客户:")
print(
    customers.nlargest(5, "dist_to_center_km")[
        ["customer_id", "city", "revenue", "dist_to_center_km"]
    ].to_string()
)

输出:

城市客户统计:
           客户数      总收入     平均收入
Beijing       5   98234.56   19646.91
Guangzhou     4   85123.45   21280.86
Hangzhou      3   62345.67   20781.89
Shanghai      5  112456.78   22491.36
Shenzhen      3   54678.90   18226.30

客户分布中心点: (117.234, 31.456)

距离中心最远的5个客户:
   customer_id       city   revenue  dist_to_center_km
15       C016    Beijing  42156.78             1245.3
3        C004  Guangzhou  28934.56             1102.7
12       C013   Shanghai  35678.90              987.4
7        C008  Shenzhen   19234.56              956.8
18       C019   Hangzhou  45678.12              834.2
# 4. 创建客户密度热力图数据
from shapely.geometry import box

def create_grid_density(gdf, grid_size=1.0):
    """
    创建网格化的客户密度数据

    参数:
        gdf: 客户 GeoDataFrame
        grid_size: 网格大小(度)
    """
    bounds = gdf.total_bounds  # (minx, miny, maxx, maxy)

    # 创建网格
    grids = []
    x = bounds[0]
    while x < bounds[2]:
        y = bounds[1]
        while y < bounds[3]:
            cell = box(x, y, x + grid_size, y + grid_size)
            count = gdf.geometry.within(cell).sum()
            if count > 0:
                grids.append({
                    "geometry": cell,
                    "customer_count": count,
                    "total_revenue": gdf[gdf.geometry.within(cell)]["revenue"].sum()
                })
            y += grid_size
        x += grid_size

    return gpd.GeoDataFrame(grids, crs=gdf.crs)

# 创建密度网格
density_grid = create_grid_density(customers, grid_size=2.0)
print("客户密度网格:")
print(f"  网格数量: {len(density_grid)}")
if len(density_grid) > 0:
    print(f"  最大客户密度: {density_grid['customer_count'].max()} 人/网格")
    print(f"  最高区域收入: {density_grid['total_revenue'].max():.2f}")

输出:

客户密度网格:
  网格数量: 12
  最大客户密度: 4 人/网格
  最高区域收入: 125432.10

23.10 本章小结

本章全面介绍了 GeoPandas 中的地理编码功能,从基本概念到实际应用案例。以下是本章要点总结:

主题 方法/函数 关键要点
正向地理编码 gpd.tools.geocode() 将地址文本转换为坐标点,返回 GeoDataFrame
逆向地理编码 gpd.tools.reverse_geocode() 将坐标点转换为地址文本
服务提供商 geopy.geocoders.* Nominatim(免费)、Google/Bing(付费)、高德/百度(国内)
请求限速 RateLimiter 使用 geopy 的 RateLimiter 包装器控制请求频率
缓存策略 自定义缓存类 对重复地址使用缓存减少 API 请求
批量处理 分批 + 进度监控 大量地址分批处理,添加进度监控和错误处理
错误处理 try/except + 重试 捕获 geopy 异常,实现重试和降级策略
结果验证 边界检查 + 空值检查 验证编码结果的坐标范围和完整性
异步编码 AioHTTPAdapter 适用于付费高并发服务的异步请求方式
地址标准化 编码 + 去重 利用地理编码实现地址数据的清洗和标准化

核心要诀:

  • 选择合适的提供商:免费学习用 Nominatim,生产环境用付费服务,国内地址优先考虑高德或百度
  • 始终遵守限速规则:使用 RateLimiter 自动控制请求频率,避免 IP 被封禁
  • 实现健壮的错误处理:网络请求不可避免地会遇到错误,务必添加重试机制和异常捕获
  • 使用缓存减少请求:对于包含重复地址的数据集,缓存能显著降低 API 调用次数
  • 验证编码结果质量:检查空值比例、坐标范围和重复坐标,确保数据可靠性
  • 批量处理注意分批:大数据集分批处理并添加进度监控,便于追踪和恢复

地理编码是连接文本世界与空间世界的关键技术。掌握 GeoPandas 和 geopy 的地理编码工具,能够让你在数据分析工作流中灵活地进行地址与坐标的相互转换,为后续的空间分析和可视化奠定坚实基础。