简介这是一份面向数据分析初学者与地理信息可视化爱好者的Python实战资源围绕出租车GPS轨迹展开解决轨迹数据清洗、栅格统计与地图呈现等典型问题。项目基于transbigdata第三方包涵盖上海与深圳两城数据可计算各栅格内出租车数量、分析OD路径及一天中的载客轨迹并在地图上完成可视化。资源包共26个文件以15个py脚本、2个ipynb笔记本、2个csv数据、2个json配置为主另含png、md、license等辅助文件压缩包约13.13MB目录按城市与项目模块组织便于对照运行。目前已有823人学习下载适合想掌握轨迹数据处理与空间可视化流程的读者参考可从中获取数据清洗、栅格聚合、OD分析与地图绘制等完整实现思路。1. 出租车轨迹数据分析到底能挖出什么从一堆经纬度到可决策的结论拿到一份出租车 GPS 轨迹数据最常见的场景是几十个字段、上千万行、每辆车每隔十几秒上报一个点打开 CSV 一看全是vehicle_id, timestamp, lon, lat, speed, status。很多人第一反应是画个散点图结果地图上糊成一团黑什么也看不出来。这个标题要解决的核心问题就是把这堆原始点位变成能回答业务问题的东西——哪里堵、什么时段缺车、哪些区域空驶率高、司机绕没绕路。它适合有 Python 基础、想做一个完整数据分析项目的人也适合做交通、城市规划、网约车运营方向的数据从业者。整条链路是数据清洗与轨迹重建 → 时空特征提取 → 可视化与指标输出。下面按这条链路拆开讲每一步都给能直接跑的代码和参数。2. 轨迹数据预处理把脏点、漂移点和重复点清干净2.1 先搞清楚数据长什么样再动手出租车轨迹数据常见来源是车载终端字段一般包括车辆 ID、时间戳、经纬度、瞬时速度、载客状态空车/重车、方向角。原始数据的问题非常集中GPS 漂移导致点位跳到河里或楼顶、隧道里丢点导致轨迹断裂、设备重复上报导致同一秒多个点、时间戳格式不统一。动手前先用 pandas 做一次体检别急着写清洗逻辑。import pandas as pd import numpy as np # 读取时指定 dtype避免 vehicle_id 被推断成 int 后丢失前导零 df pd.read_csv( taxi_traj.csv, dtype{vehicle_id: str}, parse_dates[timestamp], encodingutf-8 ) print(df.shape) print(df.dtypes) print(df.isnull().sum()) print(df[timestamp].min(), df[timestamp].max()) # 看每辆车的点位数分布判断是否有异常车辆 print(df.groupby(vehicle_id).size().describe())这段代码的作用是先建立数据基线。dtype指定车辆 ID 为字符串是血泪经验很多城市的车牌或设备号带字母一旦被推断成数字就会出错。parse_dates直接把时间列转成 datetime后续做时间差计算不用再转换。groupby().size().describe()能快速看出有没有某辆车只有几个点设备故障或者异常多重复上报。参数上如果数据超过内存把read_csv换成chunksize500000分块读或者用dtype把经纬度指定为float32省一半内存。2.2 清洗规则与轨迹重建的具体参数清洗不是一刀切要按规则分层处理。我一般按这个顺序去重 → 去漂移 → 补断点 → 重算速度。去重按「车辆 ID 时间戳」保留第一条。去漂移用速度阈值加距离阈值双条件相邻两点算出的隐含速度超过 120 km/h 且距离超过 500 米判定为漂移点剔除。补断点只在相邻点时间差小于 120 秒时做线性插值超过就认为轨迹断开不硬补。def clean_traj(df, max_speed_kmh120, max_dist_m500, max_gap_s120): df df.sort_values([vehicle_id, timestamp]).copy() # 1. 去重 df df.drop_duplicates(subset[vehicle_id, timestamp], keepfirst) # 2. 计算相邻点时间差、距离、隐含速度 df[dt] df.groupby(vehicle_id)[timestamp].diff().dt.total_seconds() df[dlon] df.groupby(vehicle_id)[lon].diff() df[dlat] df.groupby(vehicle_id)[lat].diff() # 粗略换算1度纬度约111km经度按纬度修正 df[dist_m] np.sqrt( (df[dlat] * 111000) ** 2 (df[dlon] * 111000 * np.cos(np.radians(df[lat]))) ** 2 ) df[implied_speed] df[dist_m] / df[dt].replace(0, np.nan) * 3.6 # 3. 标记漂移点 drift (df[implied_speed] max_speed_kmh) (df[dist_m] max_dist_m) df df[~drift] # 4. 标记断点只对短断点插值 df[is_gap] df[dt] max_gap_s return df.reset_index(dropTrue) cleaned clean_traj(df) print(清洗后行数:, len(cleaned)) print(漂移点占比:, 1 - len(cleaned) / len(df))逻辑说明先排序保证 diff 有意义groupby保证不跨车辆计算。距离用等距圆柱近似在城市尺度下误差可接受比直接调 haversine 快很多。implied_speed用replace(0, np.nan)防止原地停留时除零。漂移判定用「与」而不是「或」是因为单独速度高可能是正常高速单独距离大可能是长时间间隔两者同时满足才更可能是漂移。参数max_speed_kmh按城市出租车实际情况设一般 100 到 120max_gap_s设 120 秒是因为再长的断点插值出来的轨迹没有意义宁可断开。2.3 载客状态字段的坑与处理很多轨迹数据带status字段0 表示空车1 表示载客。这个字段直接决定后面能算哪些指标但它经常有错状态跳变没有对应的上下客点、长时间不变、和速度矛盾空车却高速行驶。处理方式是先按车辆和时间排序找出状态变化的时刻作为候选上下客点再用速度做校验——载客状态开始后速度应该大于某个阈值否则可能是误触发。# 找出状态变化点 cleaned[status_change] cleaned.groupby(vehicle_id)[status].diff().fillna(0) ! 0 pickup_dropoff cleaned[cleaned[status_change]].copy() # 校验载客开始后 60 秒内平均速度应大于 5 km/h pickup_dropoff[next_speed] pickup_dropoff.groupby(vehicle_id)[speed].shift(-1) valid_events pickup_dropoff[ (pickup_dropoff[status] 1) (pickup_dropoff[next_speed] 5) ] print(有效载客事件数:, len(valid_events))这里diff()在 groupby 后对状态做差非零即变化。校验阈值 5 km/h 是经验值太低会把等红灯误判为无效太高会漏掉刚起步的订单。如果数据没有 status 字段就只能靠停留点检测来推断上下客那是另一套逻辑复杂度高很多建议优先找带状态的数据源。3. 时空特征提取从轨迹点算出能用的指标3.1 用 GeoHash 做空间聚合而不是直接画点直接画散点图在数据量大时既慢又看不清正确做法是先做空间聚合。GeoHash 把经纬度编码成字符串前缀相同的点在同一区域聚合和分组都非常快。选精度 6 位约 1.2km×0.6km适合城市级分析精度 7 位约 150m×150m适合热点识别。用pygeohash库一行搞定。import pygeohash as pgh cleaned[geohash6] cleaned.apply( lambda r: pgh.encode(r[lat], r[lon], precision6), axis1 ) # 按 geohash 统计点位数和平均速度 agg cleaned.groupby(geohash6).agg( point_count(vehicle_id, size), avg_speed(speed, mean), unique_vehicles(vehicle_id, nunique) ).reset_index() print(agg.sort_values(point_count, ascendingFalse).head(10))apply在千万行级别会慢生产环境用向量化或先抽样。agg里同时统计点位数、平均速度、独立车辆数这三个指标组合起来才能区分「车多且慢」的拥堵区和「车多且快」的主干道。参数上precision每加 1 位区域面积缩小约 32 倍按分析粒度选。3.2 时间维度切片小时、工作日、高峰段时间特征比空间特征更容易被忽略。出租车数据的时间规律非常强早高峰 7-9 点、晚高峰 17-19 点、夜间 22 点后空驶率上升。提取时间特征时注意时区很多数据存的是 UTC直接按小时分组会整体偏移。cleaned[hour] cleaned[timestamp].dt.hour cleaned[weekday] cleaned[timestamp].dt.weekday # 0周一 cleaned[is_weekend] cleaned[weekday] 5 cleaned[peak] cleaned[hour].isin([7, 8, 9, 17, 18, 19]) hourly cleaned.groupby([hour, is_weekend]).agg( avg_speed(speed, mean), vehicle_count(vehicle_id, nunique) ).reset_index() print(hourly.head(24))dt.hour依赖 timestamp 已经是 datetime 且时区正确。如果原始是 UTC先dt.tz_localize(UTC).dt.tz_convert(Asia/Shanghai)。peak用isin而不是范围判断因为高峰不是连续整数区间时更直观。hourly的结果可以直接喂给可视化横轴小时、纵轴平均速度、两条线分工作日和周末一眼就能看出差异。3.3 空驶率与载客率运营侧最关心的两个数空驶率 空车点位数 / 总点位数载客率 载客点位数 / 总点位数。这两个指标按区域和时间交叉统计能直接支撑调度决策。计算时要注意 status 字段的缺失处理缺失点不能简单归为空车否则空驶率会被高估。# 只统计 status 非空的行 valid cleaned.dropna(subset[status]) valid[is_empty] valid[status] 0 empty_rate valid.groupby([geohash6, hour]).agg( empty_rate(is_empty, mean), total(is_empty, size) ).reset_index() # 过滤掉样本太少的格子避免小样本噪声 empty_rate empty_rate[empty_rate[total] 50] print(empty_rate.sort_values(empty_rate, ascendingFalse).head(10))dropna是关键一步宁可少算也不能算错。total 50是样本量过滤低于这个数的格子统计意义不大画出来全是噪声。empty_rate结果可以做成热力图横轴小时、纵轴 geohash颜色深浅表示空驶率调度人员一看就知道哪个区域哪个时段该加车。4. 可视化落地用 Python 画出能讲清楚问题的图4.1 轨迹线图与热力图的分工轨迹可视化分两类一类是看单条轨迹的线图用于排查绕路、异常一类是看整体分布的热力图用于发现规律。线图用matplotlib的plot按车辆分组画热力图用folium或pyecharts。数据量大时线图只画抽样车辆热力图用聚合后的 geohash 结果。import matplotlib.pyplot as plt # 单条轨迹线图选一辆车的一个时间段 sample cleaned[cleaned[vehicle_id] cleaned[vehicle_id].iloc[0]] sample sample.sort_values(timestamp).head(500) plt.figure(figsize(10, 8)) plt.plot(sample[lon], sample[lat], linewidth1, marker., markersize2) plt.xlabel(经度) plt.ylabel(纬度) plt.title(单车辆轨迹示例) plt.axis(equal) plt.show()axis(equal)保证经纬度比例一致否则轨迹会被拉伸变形。head(500)限制点数太多点线图会糊。如果要看载客和空驶的区别按 status 分两组用不同颜色画。4.2 用 pyecharts 做可交互的热力图静态图讲不清空间分布交互式热力图能缩放、悬停看数值。pyecharts的HeatMap配合BMap需要地图底图离线环境用Geo加EffectScatter更稳。from pyecharts.charts import Geo from pyecharts import options as opts from pyecharts.globals import ChartType top agg.sort_values(point_count, ascendingFalse).head(100) # 需要把 geohash 解码回中心点经纬度 top[lat] top[geohash6].apply(lambda g: pgh.decode(g)[0]) top[lon] top[geohash6].apply(lambda g: pgh.decode(g)[1]) geo ( Geo() .add_schema(maptypechina) .add( 点位密度, [list(z) for z in zip(top[geohash6], top[point_count])], type_ChartType.HEATMAP, ) .set_global_opts( visualmap_optsopts.VisualMapOpts(max_int(top[point_count].max())), title_optsopts.TitleOpts(title出租车点位密度分布), ) ) geo.render(heatmap.html)decode把 geohash 还原成中心点坐标add_schema指定地图类型。visualmap_opts的max_控制颜色映射上限设太大颜色区分度低设太小大部分点都是深色。render输出 HTML浏览器打开即可交互。如果要做可视化大屏把多个这样的图用Page组合或者导出数据给前端用 ECharts 渲染。4.3 时间序列图把小时级指标画成折线时间维度的结论用折线图最直观。把前面算的hourly结果画出来工作日和周末两条线对比高峰时段一眼可见。fig, ax plt.subplots(figsize(12, 5)) for weekend, label in [(False, 工作日), (True, 周末)]: sub hourly[hourly[is_weekend] weekend] ax.plot(sub[hour], sub[avg_speed], markero, labellabel) ax.set_xlabel(小时) ax.set_ylabel(平均速度 (km/h)) ax.set_xticks(range(0, 24)) ax.legend() ax.grid(alpha0.3) plt.show()set_xticks(range(0, 24))保证横轴每个小时都有刻度不然 matplotlib 会自动跳着标。grid(alpha0.3)让网格线不抢眼。这张图放进报告里比任何文字描述都有说服力。5. 避坑与排查轨迹分析里最容易翻车的五个地方5.1 坐标系不统一导致地图整体偏移现象把点画到地图上整体偏移几百米路线和实际道路对不上。原因GPS 原始数据是 WGS84国内地图底图常用 GCJ02两者差几百米。解决先确认数据坐标系用coord-convert或pyproj做转换转换后再画。不要靠手动平移不同区域偏移量不一样。5.2 时间戳单位搞错导致时间特征全废现象按小时统计的结果完全不符合常识凌晨三点车最多。原因时间戳是毫秒或微秒pd.to_datetime默认按纳秒解析结果全错。解决先看时间戳数值量级10 位是秒、13 位是毫秒、16 位是微秒用unit参数指定pd.to_datetime(df[ts], unitms)。5.3 速度字段不可信却直接拿来用现象平均速度算出来 200 km/h或者大量点为 0。原因速度字段可能是瞬时值、可能单位是 m/s、可能设备故障时为 0。解决不要直接用原始速度用相邻点距离除以时间差重算和原始速度对比偏差大的以重算为准。单位统一用 km/h。5.4 大数据量下 apply 慢到不可用现象千万行数据跑 geohash 编码跑了半小时还没完。原因apply逐行调用 Python 函数没有向量化。解决抽样后再编码或者用geohash的向量化实现或者先按网格取整再编码。生产环境优先考虑用 DuckDB 或 Spark 做聚合Python 只做最后的小数据可视化。5.5 样本量不足的格子直接下结论现象某个 geohash 空驶率 100%实际只有 2 个点。原因没有做样本量过滤小样本噪声被当成规律。解决聚合后按total字段过滤低于 30 到 50 的格子不参与结论或者做贝叶斯平滑。这个坑在写报告时特别致命一个错误结论会毁掉整个分析的可信度。6. 进阶技巧用停留点检测反推上下客热点前面依赖 status 字段算载客率但很多数据没有这个字段或者字段不可信。这时候可以用停留点检测来反推车辆在某个小范围内停留超过阈值时间且速度接近零大概率是上下客或等客。具体做法是按车辆分组滑动窗口计算连续点的空间范围范围小于 100 米且持续时间超过 60 秒的片段标记为停留点。def detect_stops(df, radius_m100, min_duration_s60): stops [] for vid, group in df.groupby(vehicle_id): group group.sort_values(timestamp).reset_index(dropTrue) i 0 while i len(group): j i 1 while j len(group): d np.sqrt( ((group.loc[j, lat] - group.loc[i, lat]) * 111000) ** 2 ((group.loc[j, lon] - group.loc[i, lon]) * 111000 * np.cos(np.radians(group.loc[i, lat]))) ** 2 ) if d radius_m: break j 1 duration (group.loc[j-1, timestamp] - group.loc[i, timestamp]).total_seconds() if duration min_duration_s: stops.append({ vehicle_id: vid, start: group.loc[i, timestamp], end: group.loc[j-1, timestamp], lat: group.loc[i, lat], lon: group.loc[i, lon], duration_s: duration }) i j return pd.DataFrame(stops) stops detect_stops(cleaned) print(停留点数量:, len(stops))这段逻辑是双指针扫描从 i 开始向后扩展 j只要还在半径内就继续超出半径就结算一次。radius_m设 100 米是因为 GPS 精度本身有十几米误差设太小会把正常等红灯误判为移动。min_duration_s设 60 秒是经验值低于这个时长的停留更可能是等红灯而不是上下客。停留点结果按 geohash 聚合就能得到上下客热点分布和前面用 status 算的结果交叉验证两者吻合才敢下结论。参数调优上radius_m和min_duration_s是一对权衡半径放大、时长放长停留点变少但更准反过来会多但噪声大。我一般先用 100 米和 60 秒跑一遍看停留点数量是否合理城市出租车一天大概 20 到 40 单对应停留点数量应该在同一量级再微调。如果数据里车辆在路口等红灯时间很长可以把radius_m缩到 50 米来区分。验证方法上抽几辆车把停留点和轨迹线画在一起人工看几个停留点是不是在商场、小区、机场这些合理位置。这个人工校验步骤不能省参数调得再好坐标系错了或者时间戳错了结果全是错的。我自己做这个项目时第一版没做坐标系转换停留点全落在河里排查了半天才发现是 GCJ02 和 WGS84 的问题。后来养成习惯任何空间数据先画一张底图叠加看确认对齐了再往下做。希望帮到你。本文还有配套的精品资源点击获取