ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

出租车GPS轨迹数据分析:用Python与transbigdata实现时空可视化

出租车GPS轨迹数据分析:用Python与transbigdata实现时空可视化

简介:一套基于Python和transbigdata库的出租车轨迹数据可视化分析源码,专门面向数据科学初学者、交通研究者及需要在Jupyter环境中处理GPS轨迹数据的开发者。资源将原始出租车GPS数据读取、清洗、网格化、OD分析和地图展示整合为一条可运行的完整流程,帮助使用者快速掌握空间数据可视化分析方法。

压缩包共26个文件,以15个Python脚本和2个Jupyter笔记本为主,分别承担数据处理、轨迹分析、可视化与交互展示;另含2个CSV和2个JSON示例数据文件,以及说明文档、许可证、图片等辅助资源,整体大小约70.26MB,目录结构清晰。目前已吸引471人浏览学习。

借助该源码,读者既能通过示例数据复现出租车热点区域、出行OD和轨迹路径等典型分析场景,也可替换为自己的城市数据直接复用;配合Jupyter环境的交互特性,能极大降低搭建环境和编码调试的门槛,适合学习与二次开发。

1. 出租车 GPS 轨迹不只是画点:transbigdata 如何把原始坐标变成可分析的时空数据

很多人第一次拿到出租车轨迹数据,第一反应就是直接把经纬度丢进 scatter 画一张散点图,结果出来的是一坨密不透风的黑点,什么规律都看不出来。这份基于 Python 和 transbigdata 的出租车轨迹可视化分析源码,解决的就是这个问题:它把上海、深圳两个城市的出租车 GPS 原始 CSV,经过清洗、网格划分、OD 分析和轨迹重建,最终输出能直接用于研判的热力图和轨迹图。适合交通数据分析师、城市规划研究者,以及想用 Python 做时空数据可视化的从业者。它的核心价值不在于画图本身,而在于把“数据能不能被分析模型识别”这件事前置处理好了。

2. 数据准备与清洗:从 CSV 到 transbigdata 能识别的 DataFrame

2.1 先看懂两个城市的原始数据结构:列名、坐标与采样频率

拿到这份源码,我建议你先别急着跑 Notebook,先打开shanghai_taxi_gps.csv和shenzhen_taxi_gps.csv看一眼原始字段。出租车 GPS 数据最常见的字段是车牌号、时间戳、经度、纬度、载客状态、速度、方向角,但不同城市的数据供应商给的列名完全不一样。transbigdata 这个库对 DataFrame 的列名是有隐含要求的,它内部处理轨迹时默认按lng、lat、time这类列名去索引,所以第一步往往不是分析,而是把原始列名对齐。

我一般会先做一次字段探查,用 pandas 把两个 CSV 的列名、数据类型、缺失率打印出来,确认哪些列是真正有用的。下面是这类数据的标准探查写法:

import pandas as pd df = pd.read_csv('shanghai_taxi_gps.csv', nrows=5000) print(df.columns.tolist()) print(df.dtypes) print(df.isnull().sum()) print(df.describe())

这段代码干了三件事:先看有哪些列,再看每列类型,最后看缺失情况和数值分布。nrows=5000是防止原始文件过大导致内存暴涨,先取 5000 行探路,确认结构后再全量读取。describe()对于经纬度字段特别有用——如果经度最小值是 73 而不是 120 附近,说明数据里混入了异常坐标,后面必须过滤;如果速度字段出现负数,说明采集端有脏数据。

2.2 preprocess.py 做了哪几步清洗:去重、去异常、时间戳标准化

源码里的preprocess.py是整套流程的起点,它对应 transbigdata 在实际项目里最常被调用的一组操作:清洗边界外点、剔除重复记录、过滤静止或漂移点。原始 GPS 数据里至少有四类脏数据:同一秒内重复上报的轨迹点、经纬度在城市边界之外的野点、速度为 0 但位置在几十米范围内乱跳的漂移点、以及时间戳格式不统一的记录。

transbigdata 提供了一个非常实用的函数叫clean_outofshape,它需要你先准备好研究范围的边界。这份源码里有shanghai.json和sz.json,这两个 JSON 就是上海和深圳的行政区划边界,用 GeoJSON 格式存储。清洗逻辑是这样的:

import transbigdata as tbd import geopandas as gpd # 读取研究范围边界 shanghai_boundary = gpd.read_file('shanghai.json') clean_df = tbd.clean_outofshape(df, shanghai_boundary, col=['lng', 'lat']) print(f'清洗前: {len(df)} 条,清洗后: {len(clean_df)} 条')

clean_outofshape的作用是把落在上海行政区划外的 GPS 点直接剔除,参数里col=['lng', 'lat']指定了经纬度列名,边界文件用 GeoPandas 读进来。这一步非常关键——出租车 GPS 数据里经常混入坐标漂移到几十公里外的记录,如果不先按边界裁剪,后面画热力图时会出现一片诡异的离岛热点。清洗后如果发现剔除比例超过 3%,我一般会回头检查原始数据的坐标系是不是和边界文件不一致。

2.3 taxigps.py 的加载套路:把数据读进来并初步质检

源码里的taxigps.py是出租车 GPS 数据的专属加载模块,它做的事情比 pandas 直接read_csv多一层:对经纬度做范围校验、对时间字段做解析、对载客状态做编码转换。实际项目中我发现,不同城市的出租车数据对“是否载客”这个字段的编码完全不一样,有的是 0/1,有的是 1/2,有的是布尔值,直接拿去做 OD 分析会得出完全错误的结果。

这里我给出一套通用的加载和质检模式,你可以对照源码的taxigps.py看它是不是这么处理的:

import pandas as pd def load_taxi_gps(filepath): df = pd.read_csv(filepath) # 统一列名,原数据可能是 lng/lat 也可能是 lon/lat df.columns = [c.lower() for c in df.columns] if 'lon' in df.columns and 'lng' not in df.columns: df = df.rename(columns={'lon': 'lng'}) # 经纬度范围粗校验 df = df[(df['lng'] > 73) & (df['lng'] < 135)] df = df[(df['lat'] > 18) & (df['lat'] < 54)] # 去重:同车同秒只保留一条 df = df.drop_duplicates(subset=['vehicle_id', 'time']) return df sh_df = load_taxi_gps('shanghai_taxi_gps.csv') sz_df = load_taxi_gps('shenzhen_taxi_gps.csv')

这里有个细节值得注意:drop_duplicates的subset我用了vehicle_id和time两列,而不是对所有列去重。因为 GPS 数据里即使同一辆车同一秒上报两次,两次记录的经纬度也可能有细微差别,如果对所有列去重,重复数据根本去不掉。去重之后,上下车点的提取才有意义,否则 OD 分析里会出现同一秒出发的两条一模一样的出行记录。

3. 网格划分与研究范围:用 grids_geohash 把城市切成可计算的单元

3.1 为什么用 geohash 网格而不是直接画散点

出租车 GPS 数据动辄几十万条,直接把每个点渲染在地图上是灾难,而且从散点上你看不出任何空间规律。transbigdata 的思路是把城市切分成规则的网格单元,然后统计每个网格内的点数量、上下车次数、平均速度等指标,再用网格热力图呈现。这份源码里的grids_geohash.py就是负责这件事的模块。

选择 geohash 网格而不是普通矩形网格,根源在于编码效率。geohash 把经纬度编码成一串字符串,相同前缀的格子在地理上是相邻的,这样在聚合统计时可以直接按字符串前缀分组,比传统的矩形网格边界计算快得多。尤其当数据量达到百万级时,矩形网格需要同时比较经纬度两个维度的边界,而 geohash 只需要做一次字符串切片。源码里同时有grids.py和grids_geohash.py,前者是通用网格逻辑,后者是 geohash 专属优化,实际调用时优先走后者。

3.2 栅格大小与边界参数怎么定:从 shanghai.json 和 shenzhen_taxi_gps.csv 出发

网格大小的选择直接决定分析结论的可读性。网格太大,比如 0.1 度约等于 11 公里,整个上海就十几个格子,热点区域糊成一片;网格太小,比如 0.001 度约等于 110 米,格子数量上百万,聚合计算慢,而且每个格子里的点太少,统计意义不大。我一般从 0.005 度也就是约 500 米的网格开始试,先看整体分布,再决定要不要加密。

transbigdata 提供了grid_params函数来生成网格参数,它需要你传入研究范围。源码里的shanghai.json就是用来确定这个范围的,但有时 GeoJSON 边界太碎,直接拿来做网格参数计算反而慢,我一般会手动算一个外包矩形:

import transbigdata as tbd # 根据上海出租车数据实际分布确定研究范围 [lngmin, latmin, lngmax, latmax] bounds = [120.85, 30.65, 122.00, 31.90] params = tbd.grid_params( bounds=bounds, cell_size=0.005, method='geohash' ) print(params)

bounds是研究范围的经纬度最小最大值,cell_size=0.005表示网格边长约 500 米,method='geohash'指定用 geohash 编码。grid_params返回的参数是一个包含网格编号规则的字典,后续tbd.grid、tbd.odgrid都要用到它。这里有个容易被忽略的点:bounds必须完全覆盖数据范围,否则边界外的点会被直接丢掉,我见过有人把范围设小了,结果陆家嘴的上下车热点整个消失。

3.3 简单检查网格覆盖:落在城市边界外的点怎么处理

设定好网格参数后,第一步不是直接聚合画图,而是先检查网格覆盖率和边界外的点数。transbigdata 的clean_outofshape在第二章已经用过一次,但那是按行政区划边界裁剪;网格聚合前,还需要确认研究矩形范围是否包住了所有有效点。如果bounds设置不当,一部分合法点也会被误伤。

我一般会做一个交叉验证,把网格参数作用于整个数据集,然后统计无法匹配网格编号的记录数:

import transbigdata as tbd # 为每个GPS点计算网格编号 df['LONCOL'], df['LATCOL'] = tbd.grid(df['lng'], df['lat'], params) # 检查是否有无法匹配网格的点 invalid_mask = df['LONCOL'].isnull() | df['LATCOL'].isnull() print(f'无法匹配网格的点: {invalid_mask.sum()} 条,占比 {invalid_mask.mean():.4%}')

如果占比超过 0.5%,说明bounds范围偏小,需要扩大边界重新计算grid_params。还有一种情况是原始数据里混入了坐标在海外或外省的点,这些点经clean_outofshape后应该已经不在了,如果这里还有大量点位落空,要回头检查清洗步骤是不是漏跑了。这个检查虽然没有直接产出图表,但它决定了后续所有聚合统计的完整性,值得每次换城市数据时都跑一遍。

4. OD 分析与轨迹重建:从上下车点到路径还原

4.1 odprocess.py:提取出行起点终点,构造 OD 矩阵

OD 分析是出租车轨迹数据最有价值的产出之一,它回答的问题是:乘客从哪里上车、到哪里下车,城市的职住空间结构和热门出行走廊长什么样。要构造 OD 矩阵,首先得从原始 GPS 轨迹中识别出每一次独立的出行。出租车数据的载客状态字段就是干这个用的:状态从 0 变 1 的那条记录是上车点,从 1 变 0 的那条记录是下车点。

源码里的odprocess.py处理的就是这个逻辑。实际写代码时,最稳妥的方式是按车辆分组,对时间排序,然后比较相邻记录的载客状态差异。这里我给出一个实用的提取方法:

import pandas as pd def extract_od(df, statuscol='status'): df = df.sort_values(['vehicle_id', 'time']) # 标记载客状态变化 df['status_change'] = df.groupby('vehicle_id')[statuscol].diff() # 上车点:状态从0变为1 pickup = df[df['status_change'] == 1][['vehicle_id', 'lng', 'lat', 'time']] # 下车点:状态从1变为0 dropoff = df[df['status_change'] == -1][['vehicle_id', 'lng', 'lat', 'time']] # 按车辆和时间对齐上下车点 pickup = pickup.rename(columns={'lng': 'slng', 'lat': 'slat', 'time': 'stime'}) dropoff = dropoff.rename(columns={'lng': 'elng', 'lat': 'elat', 'time': 'etime'}) od = pd.merge(pickup, dropoff, on='vehicle_id') return od

这个提取逻辑的核心是groupby + diff,diff()计算每条记录与上一条记录的状态差值,差值为 1 说明刚载客,差值为 -1 说明刚下客。排序字段time必须是标准化后的时间戳,不是字符串,否则同一辆车的前后顺序会乱。pd.merge对齐上下车点后,每条 OD 记录就包含起点经纬度、终点经纬度、上车时间、下车时间,这就是后续 OD 矩阵和出行时长分析的基础。

4.2 traj.py 与 ckdnearest.py:轨迹点排序与最近邻匹配

OD 分析告诉你出行从哪到哪,但中间经过哪些路、车速如何,需要轨迹重建。transbigdata 的traj.py负责把离散的 GPS 点按时间和车辆排序,连成一条条完整的行驶轨迹。这里有技术上的坑:GPS 点不是按车辆连续采集的,多辆车的数据交叉混在一起,必须严格按vehicle_id分组再按时间排序,否则轨迹线会乱成一团。

traj_process是 transbigdata 里处理这个问题的标准入口,它会自动完成分组排序,并且要求数据必须包含车辆 ID、时间戳和经纬度列。下面是标准的轨迹处理调用:

import transbigdata as tbd # 按时间排序并构建轨迹,nan值用于分隔不同车辆的轨迹 traj = tbd.traj_process( df, timecol='time', vehiclecol='vehicle_id', loncol='lng', latcol='lat' )

traj_process返回的轨迹数据里,不同车辆的轨迹之间会用空行分隔,这样后面画图时每一段轨迹天然是一条独立折线。而ckdnearest.py这个模块解决的是另一个问题:把 GPS 点匹配到最近的路网节点或网格中心。比如你要分析某一辆车的轨迹是否经过某个商圈,直接判断经纬度距离是不准的,因为 GPS 有漂移,需要用ckdnearest基于 KD 树做空间最近邻搜索,找到离每个轨迹点最近的道路节点,再统计经过次数。这个方法在轨迹数据里比粗暴的经纬度范围判断稳得多。

4.3 可视化呈现:plotmap.py 出图与两个城市的热点对比

轨迹和 OD 都算出来后,才轮到真正出图的环节。源码里的plotmap.py是地图可视化模块,transbigdata 的plotmap函数可以直接把 GeoDataFrame 或网格聚合结果渲染到底图上。下面是用网格热力图对比上海和深圳上下车热点的方法:

import matplotlib.pyplot as plt import transbigdata as tbd # 为OD数据添加网格编号 od['LONCOL'], od['LATCOL'] = tbd.grid(od['slng'], od['slat'], params) # 聚合统计每个网格的上车次数 ODg = tbd.odgrid(od, params) print(ODg.head()) # 绘制热力图 fig, ax = plt.subplots(figsize=(8, 8)) tbd.plotmap(ax, bounds, lgd=False) ODg.plot(column='count', cmap='OrRd', ax=ax, alpha=0.7) plt.show()

tbd.odgrid会把 OD 数据按起点网格和终点网格聚合成一个 OD 矩阵表,column='count'指定用聚合后的频数列来映射颜色,cmap='OrRd'是从橙色到红色的渐变。这张图出来后,你能非常直观地看到城市核心区的上车热点集中在哪几个网格。上海和深圳两份数据放在一起对比时,注意两个城市的bounds不同,网格参数要各算各的,不能用同一套,否则上海的格子套到深圳的经纬度上,边界完全错位。

5. 避坑记录:坐标系统一、时间字段和内存的四个实战翻车现场

5.1 底图对不上:同一份数据画出来跑到海里去

现象:用 matplotlib 出的图看起来没问题,但叠加在线地图底图上时,整个点群整体偏移了大约 300 到 500 米,有些点直接落在黄浦江里。

原因:地图底图用的是 GCJ-02 坐标,而原始 CSV 里的 GPS 坐标是 WGS-84 标准。国内绝大多数在线底图都做了坐标加密,直接用 WGS-84 的经纬度叠上去就会系统性偏移。这是我踩过最多次的坑,几乎每次换一个新的数据源都要重新确认一遍坐标系。

解决:确认数据坐标系后用坐标转换函数统一。transbigdata 里没有强制绑定的转换工具,我一般用coord_transform这类封装把 WGS-84 转成 GCJ-02 再画图。判断依据很简单:拿一个你知道准确位置的地标点,比如上海人民广场,看原始坐标和实际坐标的偏差量级,偏差在几百米就是坐标系问题。

5.2 时间字段解析失败:字符串格式混用导致排序错乱

现象:df.sort_values('time')之后轨迹线还是乱跳,同一辆车的轨迹点前后顺序完全不对,有时候车辆从浦东瞬间“瞬移”到浦西。

原因:CSV 里的时间字段有两种格式,一部分是2024-03-01 08:30:00,另一部分是2024/03/01 08:30:00,甚至还有不带日期的08:30:00。pandas 自动推断类型时,遇到混合格式会把整列读成字符串,字符串排序按字典序走,导致时间顺序错乱。

解决:读取数据时强制指定时间解析格式,或者先把所有分隔符统一替换成一种,再转datetime。从那以后我每次都会在加载函数里加一行pd.to_datetime(df['time'], format='mixed')并且用errors='coerce'把解析失败的行打上缺失标记,先处理缺失再排序,坚决不让字符串格式混进排序流程。

5.3 geohash 网格设太小:内存直接爆掉

现象:cell_size从 0.005 改成 0.001 后,grid_params计算没问题,但执行tbd.grid时内存飙升到几个 GB,Notebook 直接卡死。

原因:geohash 的精度和网格尺寸不是线性关系。cell_size=0.001大约对应 100 米级别,上海全市范围内生成的网格数量是 500 米网格的 25 倍,存储空间和计算量指数级上升,本地机器根本扛不住。

解决:先用 0.01 度的粗网格跑通全流程,确认分析逻辑没问题后再局部加密,而且只对热点区域做二次细粒度分析,不全局加密。如果一定要全城细粒度,至少先按行政区裁剪数据,缩小处理范围。

5.4 画图横坐标太密集:时间轴刻度糊成一团

现象:画一天的上下车时间分布图时,x 轴时间刻度从 0 点到 24 点全部挤在一起,标签互相重叠,图根本没法看。

原因:matplotlib 默认对时间序列的刻度间隔不够智能,当数据量覆盖全天时,它会尝试把所有整点都标出来,标签一多就重叠。

解决:手动设置刻度间隔和格式,让刻度密度匹配图的尺寸。代码很简单,plt.xticks配合pd.date_range只保留每两小时一个刻度,并且把标签旋转 45 度,图面立刻清爽。这个坑虽然不大,但很多人画第一张图就被糊掉的横坐标劝退了,其实只是一行刻度设置的事。

6. 进阶技巧:把静态热力图升级成可交互验证的地图

网格热力图和轨迹图能回答大部分空间分布问题,但有一个硬伤:静态图无法交互查询,你看到一个热点网格,想点开看这个格子到底有哪些上客点、对应的 OD 流到哪些区域,静态图做不到。这时我一般会把分析结果导到 folium 上做交互验证,这也是验证分析结论值不值得写进报告的最快方式。

import folium # 取聚合后热度最高的前50个网格 top_grids = ODg.nlargest(50, 'count') # 基于上海中心创建交互地图 m = folium.Map(location=[31.23, 121.47], zoom_start=11) for _, row in top_grids.iterrows(): folium.CircleMarker( location=[row['lat'], row['lon']], radius=8, popup=f"上车次数: {row['count']}", color='red', fill=True ).add_to(m) m.save('shanghai_hotspots.html')

交互地图的验证价值在于,你可以在浏览器里放大缩小,把热点网格和真实路网、商圈做对照。比如上海的热点网格如果恰好落在人民广场、陆家嘴、虹桥火车站附近,那结论就和常识吻合;如果热点出现在某个农田区域,那大概率是数据里有网约车刷单或虚拟定位的脏数据,需要回溯清洗。

交互验证通过后,还有一个值得做的技巧是轨迹抽稀。原始 GPS 轨迹点密度太高,直接画线会很粗很糊,我一般用 Douglas-Peucker 算法对每条轨迹做抽稀,保留形状特征的同时把坐标点数量压缩到十分之一,图面干净,渲染速度也快。transbigdata 生态里做这个操作不复杂,核心思路是对同一辆车的轨迹按距离阈值过滤中间点,阈值设为 30 米左右比较合适。

这份源码最值得学习的不是某一个图表有多漂亮,而是它的模块划分方式:预处理、网格、OD、轨迹、可视化各司其职,换一个城市的数据只需要替换 CSV 和边界 JSON,主流程完全复用。从那以后我每次拿到一份新的轨迹数据,都会强制走一遍这个流程:先确认坐标系、再清洗时间字段、然后检查网格覆盖率,最后才画图,每一步都有对应的校验输出。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进