简介:面向交通数据分析与Python可视化学习者,这份基于Python和transbigdata的出租车轨迹数据可视化分析项目,提供从数据读取、清洗、处理到可视化展示的完整流程,适合用于城市交通、热点区域及行驶效率研究。资源共26个文件,包括15个Python脚本、2个Jupyter Notebook、2个CSV与2个JSON数据文件,以及License、Git忽略规则、说明文档等,脚本负责各环节处理,Notebook承载交互式探索分析,数据文件存储上海、深圳出租车GPS轨迹,整体压缩包约70.26MB。目前已有471人学习下载。借助Jupyter环境,代码支持对经纬度轨迹、OD起讫点、网格聚合等内容的可视化,并通过geohash与可视化模块呈现空间分布,可帮助读者快速复用出租车轨迹分析与制图流程,节省从零搭建环境的时间。
1. 出租车轨迹可视化:这份源码包能帮你解决什么
城市交通研究里最不缺的就是数据,最缺的是把几百万行GPS坐标变成一张能讲故事的图。这份基于Python和transbigdata的出租车轨迹数据可视化分析源码,把「原始CSV → 数据清洗 → 格网聚合 → 热力图与OD图」整条链路串成了两个可直接运行的Jupyter Notebook,覆盖上海和深圳两份真实轨迹数据。我拆包后的感受是:它不追求炫技,而是把轨迹分析最常用的预处理、质量清洗、格网热力、起终点分析都沉淀成了可复用函数,特别适合正在做交通方向课题的学生,以及刚接触轨迹数据、想快速搭一条成熟可视化流水线的从业者。下面按数据读取、预处理、核心可视化、避坑排查的顺序展开。
2. 搭好Jupyter环境并看懂源码包结构:先跑通上海出租车数据
2.1 为什么选transbigdata而不是自己写坐标聚合
先聊选型。出租车GPS数据有典型的海量特征,上海市区的出租车一天能产生上千万条记录,这种量级下如果自己写geohash或H3聚合,要处理的边界情况非常多:坐标系转换、网格边界合并、底图偏移校正、聚合后Shapefile导出,每一步都能消耗一下午。transbigdata把这一层封装成了tbd.gps_to_grid、tbd.grid_to_shape这类函数,内部走numpy和pandas的向量化运算,几百万行点数据做格网映射基本在秒级完成。
它另一个设计优势是「一套参数走到底」。源码里的shanghai.json保存了研究区的四至边界,你只需要在Notebook里加载它并补一个cell_size(网格边长),后续的格网化、绘图、OD聚合全部复用同一份params字典。这意味着改数据时不用改逻辑,改研究区时不用改代码,只要重新生成一份边界JSON就行。相比自己维护一套经纬度投影代码,这种封装方式对探索性分析友好得多。
我拆这份源码时的实际经验是:预处理逻辑用脚本文件(preprocess.py、taxigps.py),探索性可视化用Notebook,这样重跑全量数据时不用翻单元格,写报告时又保留每一步的中间输出。项目里15个Python脚本和2个Notebook的分工,本质上就是这个思路。
2.2 源码包文件结构解读:哪些文件值得你细读
整个源码包共25个文件,我按用途分成四类:
- transbigdata目录下的15个Python脚本,包括preprocess.py(预处理)、taxigps.py(出租车GPS专用处理)、odprocess.py(OD起终点处理)、grids.py(格网化)、quality.py(数据质量)、plotmap.py(地图绘制)、ckdnearest.py(最近邻匹配)等,是库的核心实现。
- 2个Jupyter Notebook:code_shanghai_taxi_gps.ipynb和code_shenzhen_taxi_gps.ipynb,是分析主战场,建议先跑上海这个。
- 数据文件:2个CSV(shanghai_taxi_gps.csv、shenzhen_taxi_gps.csv)和2个JSON(shanghai.json、shenzhen.json),其中shanghai.json存放研究区边界参数,frame.png是可视化结果参考图。
- 工程文件:LICENSE(开源协议)、.gitignore(版本控制忽略规则)、readme.txt(使用说明)。
对普通使用者来说,最值得精读的是taxigps.py里的状态清洗函数和grids.py里的格网映射函数,因为后面所有可视化都建立在这两层之上。readme.txt和LICENSE建议先扫一眼,里面一般会写明坐标系统约定,比如数据用的是GCJ-02还是WGS84。这一点直接决定你能不能把高德底图和轨迹点对齐,后面避坑章节会专门讲。
2.3 上手第一个Notebook:读取上海出租车CSV
打开code_shanghai_taxi_gps.ipynb,第一步通常是数据检视。下面这段代码是完整流程的起点:
import pandas as pd import transbigdata as tbd # 读入上海出租车GPS数据,先取前5万行验证管道 df = pd.read_csv('data/shanghai_taxi_gps.csv', nrows=50000) print('字段列表:', df.columns.tolist()) print('总行数:', len(df)) print(df.head(3)) # 从研究区边界JSON读取四至参数 import json with open('data/shanghai.json', 'r', encoding='utf-8') as f: bounds = json.load(f) # 打印transbigdata版本,用于排查依赖版本冲突 print('transbigdata版本:', tbd.__version__)逻辑说明:nrows=50000是先抽样验证管道,避免全量数据把内存撑爆;shanghai.json里的四至边界会被tbd用来计算网格的行列索引,所以必须和CSV数据来自同一坐标系。打印字段列表的目的是确认列名,不同来源的出租车CSV列名差异很大,有的叫OpenStatus,有的叫State,有的叫status,后面清洗脚本都要按实际列名调整。
参数说明:json.load读出来的bounds是一个字典,通常包含minLng、minLat、maxLng、maxLat这几个键,对应研究区的经纬度范围。后续构造params时会用到它们。如果打印出来发现minLng大于maxLng,说明边界文件写反了,需要先修数据再往下走。
提示:Jupyter里重复跑单元格之前最好重启kernel,避免上一次运行残留的变量污染结果。数据量大的时候,内存里同时挂着原始DataFrame、清洗后DataFrame和多个中间结果,很容易把Notebook拖到无响应。我一般会跑完一个阶段就del掉临时变量并调用gc.collect()。
跑通这一步之后,就可以进入预处理环节。需要特别注意的是,read_csv默认把第一行当表头,如果CSV本身没有表头,你会损失第一行数据。判断方法很简单:print(df.head(3))看第一行是不是一条正常的GPS记录,如果第一条的经纬度接近0,说明数据文件没有表头,要用pd.read_csv(path, header=None)再手动指定列名。
3. 把GPS原始数据变成可用轨迹:预处理与质量清洗的三个关键动作
3.1 出租车GPS字段语义与载客状态位的含义
出租车GPS数据常见的字段包括车辆编号、时间戳、经度、纬度、载客状态、速度、方向角。其中载客状态位是整个分析的生命线:它决定了一条记录是「空车巡游」还是「载客行程」。很多初学者上来就直接对全部点做热力图,画出来是一团均匀的噪点,看不出任何交通结构。问题就出在没有区分状态位。
在上海出租车数据里,载客状态通常用OpenStatus或status字段表示,常见取值是0和1,0代表空车,1代表载客。要做上下客点分析(OD分析),还需要通过状态位的跳变来判断事件:从0变1是上客点,从1变0是下客点。transbigdata的taxigps.py里专门实现了这个逻辑。如果原始数据的字段名不叫status而叫State,你需要自己做一个列名映射,否则库函数取不到对应列。
3.2 用clean_taxi_status做首次状态清洗
直接拿原始数据做载客状态提取会踩一个坑:GPS设备在乘客上下车的瞬间经常出现连续跳变,比如1-0-1在几秒内反复切换,如果不过滤,一个真实行程会被切成好几段。transbigdata处理这个问题的思路是检查状态序列的连续性,把持续时间过短的片段合并或丢弃。对应的调用方式如下:
import pandas as pd import transbigdata as tbd # 假设已读入原始数据,字段含VehicleNum、Stime、Lng、Lat、OpenStatus df = pd.read_csv('data/shanghai_taxi_gps.csv', nrows=200000) # 先按车辆编号和时间排序,保证状态序列是有序的 df = df.sort_values(['VehicleNum', 'Stime']).reset_index(drop=True) # 调用库中的状态清洗函数,剔除异常的载客状态跳变 df_clean = tbd.clean_taxi_status( df, col=['VehicleNum', 'Stime', 'Lng', 'Lat', 'OpenStatus'] ) print('清洗前行数:', len(df)) print('清洗后行数:', len(df_clean))逻辑说明:核心参数col是一个长度为5的列表,顺序固定对应VehicleNum(车辆唯一标识)、Stime(时间戳)、Lng(经度)、Lat(纬度)、OpenStatus(载客状态)。函数内部先按车辆分组,再对状态序列做平滑,把时间上相邻且状态频繁翻转的记录折叠成一次有效状态变化。输出结果里每一段连续的1状态就是一段可用的载客行程。
参数说明:如果你的数据里时间列是Unix时间戳(10位秒级或13位毫秒级),Stime这一列会以数值形式传入,清洗函数不会做时间单位假设,但你在后续时间聚合时必须先统一成datetime类型。我一般会在清洗前先把Stime转成字符串或datetime,避免排序时出现数值和字符串混排的幺蛾子。
3.3 去重、去噪、时间排序:轨迹重建三件套
状态清洗之后,数据里仍然存在三类脏数据:GPS漂移点(经纬度突然跳到几百米外)、设备重复上报(同一辆车同一时刻多条记录)、时间乱序(补传数据导致时间戳倒挂)。这三类问题如果不处理,后面画轨迹线时会画出跨越半个城区的斜线,热力图也会出现孤立的红色噪点。
我的处理习惯是写一个四步管道:
# 1. 剔除经纬度明显越界的记录 df_clean = df_clean[ (df_clean['Lng'] > bounds['minLng']) & (df_clean['Lng'] < bounds['maxLng']) & (df_clean['Lat'] > bounds['minLat']) & (df_clean['Lat'] < bounds['maxLat']) ] # 2. 去掉完全重复的记录(同一车、同一时间、同一坐标) df_clean = df_clean.drop_duplicates( subset=['VehicleNum', 'Stime', 'Lng', 'Lat'] ) # 3. 按车辆和时间排序,为轨迹连线做准备 df_clean = df_clean.sort_values( ['VehicleNum', 'Stime'] ).reset_index(drop=True) # 4. 计算相邻点速度,剔除超过120km/h的漂移点 df_clean['prev_lng'] = df_clean.groupby('VehicleNum')['Lng'].shift(1) df_clean['prev_lat'] = df_clean.groupby('VehicleNum')['Lat'].shift(1) df_clean['distance'] = tbd.geodistance( df_clean['Lng'], df_clean['Lat'], df_clean['prev_lng'], df_clean['prev_lat'] ) df_clean['time_diff'] = ( pd.to_datetime(df_clean['Stime']) - pd.to_datetime(df_clean['Stime'].shift(1)) ).dt.total_seconds() df_clean = df_clean[ (df_clean['distance'] / df_clean['time_diff'].clip(lower=1) * 3.6) < 120 ]逻辑说明:第一步的bounds直接从shanghai.json读取,把超出研究区的点先挡在门外。第二步drop_duplicates的subset参数只比对三个核心字段,避免把同一辆车在不同时间经过同一坐标的有效记录误删。第四步是漂移点过滤的关键逻辑:用geodistance算出相邻两点球面距离,除以时间差得到速度,超过120km/h的记录基本可以断定是GPS跳点。
参数说明:120这个阈值不是随便定的,出租车在市区正常行驶速度很少超过80km/h,高速上也就100出头,留20%余量足够。clip(lower=1)是为了防止time_diff为0时出现除零错误,如果数据里同一秒有多条记录,这些点在第三步去重时已经处理了一部分,但不同车辆间仍可能出现极小的time_diff。速度阈值你可以根据自己的数据场景下调到80,但不要低于60,否则高峰期低速跟车会被误删。
做完这三步,数据才真正配得上「轨迹」两个字。无论是后面的格网热力图还是OD分析,都是建立在这样一套清洗管道之上的。quality.py脚本里还有更细的质量评估函数,比如按车辆统计有效记录数、计算时间覆盖率,跑完清洗后我会顺手看一眼这些指标,确认没有把某辆车的5万条记录洗成50条。
4. 从轨迹点到热点格网:transbigdata的可视化核心路径
4.1 gps_to_grid:把GPS点映射到网格的底层机制
拿到干净的轨迹数据后,下一步就是把离散的点变成有空间结构的网格。transbigdata的处理方式是三层:先构造params参数,再调用gps_to_grid把每个经纬度映射到网格编号,最后用grid_to_shape还原网格图形。核心参数cell_size决定统计粒度,也是整个可视化最敏感的参数。
先看参数构造和格网映射:
import transbigdata as tbd import json # 读取研究区边界 with open('data/shanghai.json', 'r', encoding='utf-8') as f: bounds = json.load(f) # 构造格网参数,500米一个格子 params = tbd.grid_params( bounds, cell_size=500, gridtype='rect' ) # 把清洗后的经纬度映射到网格行列号 grid_id, mapped = tbd.gps_to_grid( df_clean['Lng'], df_clean['Lat'], params ) df_clean['grid_id'] = grid_id print('唯一网格数:', df_clean['grid_id'].nunique())逻辑说明:grid_params根据bounds和cell_size算出研究区在经纬度坐标系下的行列划分,输出一个params字典。gps_to_grid接收经纬度数组和params,返回每个点所在网格的编号。gridtype有两个选项,rect表示矩形网格,geohash表示geohash网格。矩形网格的好处是边界整齐、适合热力图展示,geohash网格的好处是编码自带空间邻近关系、适合做空间索引。
参数说明:cell_size的单位是米,这里设成500意味着每格约500米见方。这个值的选择直接决定可视化效果,200米会让热力图碎成噪点,1000米以上又模糊掉街道级差异。我的经验是:展示全市范围用800到1000米,展示中心城区用300到500米。如果只关心局部商圈,甚至可以压到100米。确定最优值的办法很简单,从1000米开始往下降,同时打印nunique网格数,观察数量从缓慢爬升变成指数爆炸的那个拐点,就是当前数据量下视觉最舒服的粒度。
4.2 聚合计数与grid_to_shape还原网格边界
有了grid_id之后,统计每个网格的轨迹点数就是一次groupby的事。但直接画散点看不出网格边界,需要把网格id还原成可绘制的几何形状:
# 按网格聚合统计点数 grid_counts = df_clean.groupby('grid_id').size().reset_index(name='count') print('网格统计完成,最大格点数:', grid_counts['count'].max()) # 将网格id映射为边界图形 plot_data = tbd.grid_to_shape( grid_counts['grid_id'], params, shp_type='rect' ) # 合并统计值 plot_data = plot_data.merge(grid_counts, on='grid_id')逻辑说明:grid_to_shape接收grid_id数组和params,返回一个GeoDataFrame,每条记录是一个格子的边界多边形。shp_type必须和前面gps_to_grid里的gridtype保持一致,混用会导致行列号解析错位,图形画出来位置全偏。merge把统计值挂到边界上,这一步完成之后,热力图的每个格子都自带一个count字段,可以直接按数值赋色。
这里有个容易被忽略的细节:grid_to_shape生成的边界是基于经纬度的,如果直接用matplotlib画,横纵比不对,地图会被拉变形。常规做法是在绘图前调用tbd.plot_map设置正确的投影参数并加载底图,下面这段是完整的热力图绘制流程:
import matplotlib.pyplot as plt # 初始化绘图区域,设置中文字体兜底 fig, ax = plt.subplots(figsize=(12, 8)) # 用plot_map加载研究区范围内的底图 plot_map = tbd.plot_map( plt, bounds, zoom=11, style='highmap' ) # 把网格边界和计数传给plotgrid做分级着色 tbd.plotgrid(plot_data, plot_map, params) plt.show()逻辑说明:plot_map内部会拉取在线底图(高德或Carto样式),zoom参数控制缩放级别,市区范围一般用10到12。tbd.plotgrid把plot_data里的网格多边形按count字段分级着色,count越大颜色越深,形成热力效果。style参数决定底图风格,highmap偏深色,适合突出亮色热力;lightmap更清爽,适合论文插图。
参数说明:zoom并不是越大越好,出租车GPS存在定位误差,zoom调到15以上会看到大量点落在楼宇轮廓外,反而不利于宏观判断。我一般习惯zoom=12配cell_size=500,整体看下来既能看到延安高架、内环线的走向,又不至于被单个路口的噪声干扰。
4.3 热点区域判读:从可视化结果反推交通规律
格网热力图跑出来之后,城市交通结构会非常直观地呈现:红色高亮区域通常集中在外滩、人民广场、陆家嘴、虹桥枢纽这些客流集散地。但热力图只能告诉你「哪里人多」,要解释「为什么人多」,还需要叠加时间维度或者做OD分析。
我拆完这套源码后印象最深的一点是:transbigdata的grids.py里还封装了网格级别的OD聚合接口,也就是把每个网格视为一个交通小区,通过车辆编号和时间把载客行程整理成「上客网格 — 下客网格」的起终点对。odprocess.py脚本处理的就是这件事。具体做法是先对每辆车按时间排序,用状态位跳变切割出每段载客行程,再提取行程首尾坐标并映射到网格:
# 将连续载客状态整理为OD行程 oddata = tbd.taxi_gps_to_od( df_clean, col=['VehicleNum', 'Stime', 'Lng', 'Lat', 'OpenStatus'], params=params ) print('OD行程数量:', len(oddata)) print(oddata.head())逻辑说明:taxi_gps_to_od内部就是按车辆分组、按状态跳变切分行程、取起点终点坐标三个步骤的封装。输出结果每一行是一段载客行程,包含起点网格、终点网格、起始时间、结束时间、行程时长。这一步把几十万条GPS点压缩成了几千条有效出行记录,数据量瞬间降了几个数量级,后续无论是做OD图还是做网络分析都轻松得多。
提示:OD分析对清洗质量极其敏感。如果第3章的状态清洗没做好,这里切出来的“行程”会混杂大量虚假跳变段,OD图的连线会像蜘蛛网一样杂乱,看起来全是“全城任意点可达”的结论,那基本可以确定是清洗环节出了问题,而不是城市交通真的这么高效。
5. 避坑手册:轨迹数据分析常见的五个翻车现场
5.1 底图和轨迹点错位:坐标系不一致
现象:热力图格子分布和底图道路走向对不上,明明应该在高架上的点落在河道里,格子整体向北偏了几百米。
原因:大部分出租车GPS数据用的是WGS84坐标,而在线底图(尤其是高德底图)用的是GCJ-02坐标,两者在上海市区存在几百米的系统偏差。transbigdata自身不承担坐标转换,因为它无法判断你的数据是哪种坐标系。
解决:先看readme.txt里有没有注明坐标系统。如果数据是WGS84,用在线底图前需要先调tbd.set_gcj02()或手动做坐标纠偏。我常用的稳妥方案是直接改用WGS84底图服务(Carto light样式),或者用gcj02_to_wgs84类工具把底图反向偏移,对齐轨迹数据。判断方法很简单:跑一次全部点落在路网上的叠加图,如果偏移方向一致且有固定距离,基本可以断定是坐标系问题。
5.2 清洗后数据量被砍掉一半以上
现象:clean_taxi_status跑完,数据量直接腰斩,某些车辆只剩原来20%的记录。
原因:多数情况是OpenStatus字段的取值定义与库函数假设不一致。有的数据源用0表示载客、1表示空车,与库函数的默认约定相反;有的数据源载客状态是2、3这样的多值编码,混入了停运和签到状态。库函数按默认规则过滤后,你的有效数据自然大量丢失。另一个原因是时间列Stime未统一格式,字符串和datetime混排导致状态序列判断错乱。
解决:先做一次取值分布检查,用df['OpenStatus'].value_counts()打印所有枚举值和对应频数,再对照readme判断哪个值代表载客。如果需要反转定义,在调用clean_taxi_status前先把列值取反,或者改写传入的列映射。从那以后我每次拿新数据源,第一步永远是value_counts()看字段分布,而不是急着跑清洗函数。
5.3 时间戳解析成object,时间聚合全乱
现象:按小时聚合时,groupby(pd.Grouper(freq='H'))报错,或者画出来的24小时流量曲线全是锯齿。
原因:CSV里的Stime是2024-01-01 08:15:23这种带格式的字符串,read_csv读进来默认是object类型;如果部分行还混入2024/1/1 8:15这种变体格式,pd.to_datetime部分转换失败后整列会保留object类型。
解决:读表时指定parse_dates=['Stime'],或者在清洗前强制统一格式。转换时不要只用简单的pd.to_datetime,可以加format参数锁定模板,比如format='%Y-%m-%d %H:%M:%S',解析速度能快一个量级,还不会静默猜测错。转换完成后马上检查df['Stime'].dtype,看到datetime64[ns]再往下走。
5.4 热力图全是孤立噪点,没有结构
现象:cell_size设成200米后,热力图上到处都是单个格子的亮点,完全没有道路走向的趋势感;调大cell_size到1000米,亮点倒是少了,但细节也全糊了。
原因:cell_size和数据量不匹配。数据量只有几万行时,500米的格子里可能只有两三个点,统计结果泊松噪声占主导,视觉上就是随机噪点。另外一个常见原因是没有筛掉GPS漂移点,漂移点散布在网格里形成假热点。
解决:先按第3章管道清洗,再根据数据量调整cell_size。一个可复用的经验法则是:热力图里单个网格平均点数集中在50到200之间最合适。你可以用总点数除以当前网格数估算平均密度,如果均值小于20,就把cell_size往大了调;如果均值大于500,说明聚类过度,调小一点。网格平均密度比绝对点数更能反映可视化质量。
5.5 Notebook跑久了内存溢出直接卡死
现象:Notebook连续跑完数据读取、清洗、热力图、OD分析后,kernel无响应,或者直接OOM被杀掉。
原因:Jupyter会把每个单元格的输出对象保存在内存里,几百行DataFrame输出加上matplotlib图像对象,再加上中间变量引用计数不释放,内存就爆了。transbigdata的grid_to_shape返回的GeoDataFrame尤其占内存,一个城市级网格可能生成几万个多边形对象。
解决:养成两个习惯。第一,对大DataFrame只print形状和前五行,不要print(df)整个输出;第二,跑完一个分析阶段后删除中间变量并主动回收:del mapped, grid_counts,再import gc; gc.collect()。多边形数据用完即弃,需要保存结果时直接导出成GeoJSON或CSV,不要一直挂在内存里。
6. 进阶技巧:把一天压缩成一张动态热力图
静态热力图能回答「哪里热」,但回答不了「早高峰的热区怎么从郊区往市中心扩散、晚高峰又怎么反向退潮」。这类时序问题需要把一天切成24个时间片,逐小时生成热力帧,再合成动态图。这个技巧在源码基础上加几十行代码就能实现,效果却非常适合汇报展示。
具体做法是在4.2节的热力图流水线外面套一层时间循环。先把Stime转成datetime并抽取出小时字段,然后按小时分组,循环执行格网映射、聚合、绘图三步。注意每一帧之间保持相同的单元格大小和颜色映射范围,否则不同帧之间色标不一致,动态图会让人误判热力强度的变化幅度。用vmin和vmax锁死色标上下界,是所有时序热力图最容易忽略的细节。
生成动态图后,我还习惯把每一帧同时保存为PNG,这样既可以在汇报里放静态关键帧,又可以合成动态图。如果数据覆盖周期长,可以按30分钟甚至15分钟切片,颗粒度更细;但切片越细,单帧数据量越稀疏,噪点问题会重新出现,一般会配合更大的cell_size使用。比如15分钟切片配800米格网,相比1小时切片配500米格网,动态趋势更平滑。
从那以后,我接手任何一份出租车GPS数据,都强制自己先跑一遍这套流程:字段分布检查、状态清洗、去噪排序、格网热力、OD聚合。这套基于transbigdata的源码包把最繁琐的中间层都封装好了,留给你的核心工作就是理解参数、控制参数、排查数据问题。希望帮到你。
本文还有配套的精品资源,点击获取