☰
Python出租车轨迹数据分析与可视化:从GPS清洗到OD矩阵与热力图大屏
2026/10/1 22:32:48 网站建设 项目流程

简介:这是一份面向计算机相关专业学生与项目实战学习者的出租车轨迹数据分析与可视化高分项目源码,评审分98分,可作为课程设计、期末大作业或简历项目参考。资源以Python为核心,结合transbigdata等工具完成上海、深圳两地出租车GPS数据的处理、分析与可视化,覆盖数据清洗、轨迹还原、时空分布统计等典型环节。压缩包共26个文件,约13.49MB,包含15个py脚本、2个ipynb交互式笔记本、2个csv数据文件、2个json配置、1个png结果图及README说明文档,结构清晰,便于按模块阅读与复现。目前已有83人学习下载。读者可从中获得完整的数据分析流程、可运行的源码与数据样例、可视化实现思路以及项目目录组织方式,适合需要快速上手轨迹数据分析或寻找高质量课程设计参考的学习者。

1. 出租车轨迹数据分析到底在分析什么:从 2000 万条 GPS 点里挖出城市脉搏

拿到一份出租车 GPS 轨迹数据,很多人第一反应是画个散点图看看车都跑在哪。但真正做过项目的人知道,原始轨迹数据直接可视化基本没法看——漂移点、重复点、时间戳乱序、坐标系不统一,这些问题不处理,出来的图就是一团糊。所谓基于 Python 的出租车轨迹数据分析与可视化,核心链路是:原始 GPS 点清洗 → 轨迹分段与驻留点识别 → 上下客事件推断 → OD 矩阵构建 → 时空热力图与路径可视化。它解决的是「城市里人和车怎么流动」这个问题,适合做交通规划、运力调度、商业选址的从业者,也适合拿它当 Python 数据分析与可视化的练手项目。下面我从数据清洗一路讲到可视化大屏,把每个环节的参数和坑都摊开说。

2. 轨迹数据清洗与预处理:坐标系、漂移点和时间戳的三重门

2.1 先搞清楚你拿到的数据长什么样

出租车轨迹数据常见的字段结构是:车辆 ID、时间戳、经度、纬度、载客状态(0/1)、瞬时速度、方向角。不同来源字段名不一样,但核心就这几个。拿到数据后第一件事不是写清洗逻辑,而是先做数据体检——看时间跨度、车辆数量、采样间隔分布、经纬度范围是否落在合理城市边界内。

import pandas as pd import numpy as np # 读取原始轨迹数据,注意编码和分隔符因数据源而异 df = pd.read_csv("taxi_traj.csv", parse_dates=["timestamp"]) # 基础体检:看规模、时间跨度、车辆数 print(f"总记录数: {len(df)}") print(f"车辆数: {df['taxi_id'].nunique()}") print(f"时间范围: {df['timestamp'].min()} ~ {df['timestamp'].max()}") print(f"采样间隔统计(秒):\n{df.groupby('taxi_id')['timestamp'].diff().dt.total_seconds().describe()}") # 经纬度范围检查——如果超出城市边界,说明有漂移或坐标系问题 print(f"经度范围: {df['lon'].min():.4f} ~ {df['lon'].max():.4f}") print(f"纬度范围: {df['lat'].min():.4f} ~ {df['lat'].max():.4f}")

这段代码的作用是先摸清数据底细。采样间隔的 describe 输出特别关键——如果中位数是 15 秒左右,说明是正常车载 GPS;如果出现大量 0 秒或超过 300 秒的间隔,后面做轨迹分段时就得针对性处理。经纬度范围如果明显偏大(比如经度跨度超过 1 度),大概率是坐标系没统一,WGS84 和 GCJ02 混在一起了。

2.2 漂移点剔除:速度阈值 + 距离阈值双保险

GPS 漂移是出租车轨迹数据里最烦人的问题。车停着不动,GPS 点却在几十米范围内跳来跳去;或者两个连续点之间算出 200 km/h 的速度,明显是信号跳变。常见做法是设速度上限和距离上限双重过滤。

def remove_drift(df, max_speed_kmh=120, max_dist_m=2000): """ 剔除漂移点: - max_speed_kmh: 城市出租车合理最高速度,超过视为漂移 - max_dist_m: 相邻两点最大合理距离(米),超过视为跳变 """ df = df.sort_values(["taxi_id", "timestamp"]).copy() # 计算相邻点时间差(小时)和 Haversine 距离 df["dt_h"] = df.groupby("taxi_id")["timestamp"].diff().dt.total_seconds() / 3600 df["dlon"] = np.radians(df.groupby("taxi_id")["lon"].diff()) df["dlat"] = np.radians(df.groupby("taxi_id")["lat"].diff()) df["a"] = np.sin(df["dlat"]/2)**2 + np.cos(np.radians(df["lat"])) * \ np.cos(np.radians(df["lat"].shift())) * np.sin(df["dlon"]/2)**2 df["dist_m"] = 6371000 * 2 * np.arcsin(np.sqrt(df["a"])) # 速度过滤:速度超过阈值或距离超过阈值的点标记为漂移 df["speed_kmh"] = df["dist_m"] / (df["dt_h"] * 1000 + 1e-9) mask = (df["speed_kmh"] <= max_speed_kmh) & (df["dist_m"] <= max_dist_m) mask |= df["dt_h"].isna() # 每组第一个点保留 removed = (~mask).sum() print(f"剔除漂移点: {removed} 条 ({removed/len(df)*100:.2f}%)") return df[mask].drop(columns=["dt_h","dlon","dlat","a","dist_m","speed_kmh"]) df_clean = remove_drift(df)

参数说明:max_speed_kmh=120对城市出租车足够宽松了,正常市区跑不到这个速度;max_dist_m=2000是防止两个采样点间隔较大时误杀正常点。如果你的数据采样间隔是 60 秒以上,距离阈值要相应放大到 3000 米。这里用 Haversine 公式而不是简单欧氏距离,是因为经纬度在不同纬度下实际距离差异很大,直接算欧氏距离在纬度 40 度附近会有约 30% 的误差。

2.3 坐标系统一与时间戳对齐

国内出租车 GPS 数据常见的坐标系有三种:WGS84(原始 GPS)、GCJ02(国测局加密)、BD09(百度加密)。如果你要叠加地图底图做可视化,必须统一到同一种坐标系。常见做法是用coord-convert这类库做转换,但要注意转换方向——WGS84 转 GCJ02 是加密,反过来是解密,别搞反了。

时间戳对齐主要是处理时区问题。有些数据存的是 UTC 时间,有些是本地时间,差 8 小时。判断方法很简单:看数据里出租车活动最密集的时间段,如果是凌晨 2-4 点最密集,那大概率是 UTC 没转过来。统一转成本地时间后再做后续分析。

3. 上下客事件推断与 OD 矩阵构建:从载客状态变化里还原出行需求

3.1 载客状态字段的清洗与事件提取

出租车数据里最有价值的字段是载客状态(通常叫status或occupied),0 表示空车,1 表示载客。状态从 0 变 1 就是上客点,从 1 变 0 就是下客点。但原始数据里这个字段经常有噪声——比如状态在短时间内反复跳变,或者整辆车全天状态都是 0。

def extract_pickup_dropoff(df): """ 从载客状态变化中提取上下客事件 返回: pickup_df(上客点), dropoff_df(下客点) """ df = df.sort_values(["taxi_id", "timestamp"]).copy() # 标记状态变化点 df["status_prev"] = df.groupby("taxi_id")["status"].shift(1) df["change"] = df["status"] - df["status_prev"] # 上客:0 -> 1 pickup = df[df["change"] == 1][["taxi_id","timestamp","lon","lat"]].copy() pickup.columns = ["taxi_id","pickup_time","pickup_lon","pickup_lat"] # 下客:1 -> 0 dropoff = df[df["change"] == -1][["taxi_id","timestamp","lon","lat"]].copy() dropoff.columns = ["taxi_id","dropoff_time","dropoff_lon","dropoff_lat"] print(f"上客事件: {len(pickup)} 次, 下客事件: {len(dropoff)} 次") return pickup, dropoff pickup_df, dropoff_df = extract_pickup_dropoff(df_clean)

逻辑很直白:对每辆车按时间排序,看 status 字段的差分。差分为 +1 就是上客,-1 就是下客。但这里有个坑——如果数据里同一秒有两条记录,或者状态字段有缺失值,差分结果会乱。所以前面清洗时要去重(按 taxi_id + timestamp 去重)并填充缺失状态。

3.2 OD 矩阵构建:把上下客点配对成出行记录

有了上下客事件,下一步是把它们配对成完整的出行记录(OD 对)。配对逻辑是:对每辆车,按时间顺序,找到一个上客事件后紧跟着的下客事件,且两者时间差在合理范围内(比如 2 分钟到 2 小时)。

def build_od_pairs(pickup, dropoff, min_dur_min=2, max_dur_min=120): """ 配对上下客事件为 OD 对 - min_dur_min: 最短行程时间(分钟),过滤误触发 - max_dur_min: 最长行程时间(分钟),过滤未匹配的下客 """ od_list = [] for taxi_id in pickup["taxi_id"].unique(): p = pickup[pickup["taxi_id"]==taxi_id].sort_values("pickup_time") d = dropoff[dropoff["taxi_id"]==taxi_id].sort_values("dropoff_time") for _, prow in p.iterrows(): # 找该上客时间之后最近的下客事件 candidates = d[d["dropoff_time"] > prow["pickup_time"]] if len(candidates) == 0: continue drow = candidates.iloc[0] dur = (drow["dropoff_time"] - prow["pickup_time"]).total_seconds() / 60 if min_dur_min <= dur <= max_dur_min: od_list.append({ "taxi_id": taxi_id, "pickup_time": prow["pickup_time"], "dropoff_time": drow["dropoff_time"], "pickup_lon": prow["pickup_lon"], "pickup_lat": prow["pickup_lat"], "dropoff_lon": drow["dropoff_lon"], "dropoff_lat": drow["dropoff_lat"], "duration_min": dur }) od_df = pd.DataFrame(od_list) print(f"构建 OD 对: {len(od_df)} 条, 平均行程 {od_df['duration_min'].mean():.1f} 分钟") return od_df od_df = build_od_pairs(pickup_df, dropoff_df)

参数min_dur_min=2是为了过滤掉那些状态误触发导致的极短行程;max_dur_min=120是防止把跨天的未匹配下客事件错误配对。实际项目中,OD 配对率(成功配对的 OD 数 / 上客事件数)能到 85% 以上就算不错了,低于 70% 说明数据质量有问题,得回头查载客状态字段。

3.3 网格化 OD 矩阵与时段划分

有了 OD 对之后,通常要把经纬度映射到网格(比如 500m × 500m 的方格),然后按小时或时段统计每个网格之间的出行量,形成 OD 矩阵。这个矩阵是后续热力图和流量分析的基础。

def od_to_grid_matrix(od_df, grid_size=0.005): """ 将 OD 对映射到网格并构建矩阵 - grid_size: 网格边长(度),0.005度约500米 """ od_df = od_df.copy() od_df["o_grid_x"] = (od_df["pickup_lon"] / grid_size).astype(int) od_df["o_grid_y"] = (od_df["pickup_lat"] / grid_size).astype(int) od_df["d_grid_x"] = (od_df["dropoff_lon"] / grid_size).astype(int) od_df["d_grid_y"] = (od_df["dropoff_lat"] / grid_size).astype(int) od_df["hour"] = od_df["pickup_time"].dt.hour # 按小时+起讫网格聚合 matrix = od_df.groupby(["hour","o_grid_x","o_grid_y","d_grid_x","d_grid_y"]).size()\ .reset_index(name="flow") print(f"OD 矩阵记录数: {len(matrix)}, 覆盖网格数: {matrix[['o_grid_x','o_grid_y']].drop_duplicates().shape[0]}") return matrix od_matrix = od_to_grid_matrix(od_df)

网格大小选择是个权衡:太小(比如 0.001 度)会导致很多网格没有数据,矩阵稀疏;太大(比如 0.01 度)会丢失空间细节。500 米左右对城市出租车分析比较合适。时段划分上,工作日通常分早高峰(7-9)、平峰(9-17)、晚高峰(17-19)、夜间(19-24),周末则整体平缓。

4. 可视化落地:从静态热力图到可交互大屏的完整链路

4.1 用 Folium 做轨迹热力图和 OD 流向图

Python 生态里做地理可视化,Folium 是最顺手的——它基于 Leaflet,能直接输出 HTML,方便嵌入大屏。热力图用HeatMap插件,OD 流向用PolyLine加箭头。

import folium from folium.plugins import HeatMap # 以城市中心为基准建图 center = [df_clean["lat"].mean(), df_clean["lon"].mean()] m = folium.Map(location=center, zoom_start=12, tiles="CartoDB dark_matter") # 上客点热力图——取前 5000 个点避免渲染卡顿 pickup_heat = pickup_df[["pickup_lat","pickup_lon"]].dropna().values.tolist()[:5000] HeatMap(pickup_heat, radius=8, blur=6, min_opacity=0.3).add_to(m) # OD 流向线——只画流量最大的前 200 条 top_od = od_matrix.nlargest(200, "flow") for _, row in top_od.iterrows(): start = [row["o_grid_y"]*0.005, row["o_grid_x"]*0.005] end = [row["d_grid_y"]*0.005, row["d_grid_x"]*0.005] folium.PolyLine([start, end], color="cyan", weight=1, opacity=0.4).add_to(m) m.save("taxi_flow.html")

参数说明:radius=8控制热力点半径,城市级别分析用 6-10 比较合适;blur=6是模糊程度,越大越平滑;min_opacity=0.3让低密度区域也能看到。OD 线只画 Top 200 是为了避免浏览器渲染上万条线卡死——这是血泪经验,第一次做的时候把全部 OD 都画上去,浏览器直接崩了。

4.2 用 Pyecharts 做可交互的时段流量大屏

如果要做可视化大屏,Pyecharts 比 Folium 更合适——它支持多图表联动、主题切换,输出也是 HTML。下面是一个时段流量热力图的例子。

from pyecharts import options as opts from pyecharts.charts import HeatMap, Bar, Grid from pyecharts.commons.utils import JsCode # 按小时和星期统计出行量 od_df["weekday"] = od_df["pickup_time"].dt.weekday od_df["hour"] = od_df["pickup_time"].dt.hour heat_data = od_df.groupby(["weekday","hour"]).size().reset_index(name="count") # 构造热力图数据格式 [x, y, value] heat_values = [[int(r["hour"]), int(r["weekday"]), int(r["count"])] for _, r in heat_data.iterrows()] heatmap = ( HeatMap(init_opts=opts.InitOpts(width="100%", height="500px", theme="dark")) .add_xaxis([str(h) for h in range(24)]) .add_yaxis("", ["周一","周二","周三","周四","周五","周六","周日"], heat_values, label_opts=opts.LabelOpts(is_show=False)) .set_global_opts( title_opts=opts.TitleOpts(title="出租车出行时段热力分布"), visualmap_opts=opts.VisualMapOpts(min_=0, max_=int(heat_data["count"].max()), is_calculable=True, orient="horizontal", pos_left="center"), xaxis_opts=opts.AxisOpts(name="小时"), yaxis_opts=opts.AxisOpts(name="星期"), ) ) heatmap.render("taxi_heatmap.html")

这段代码生成的是「星期 × 小时」的二维热力图,能一眼看出工作日早高峰和周五晚高峰的差异。visualmap_opts里的is_calculable=True让用户可以拖动滑块筛选流量范围,做汇报时特别实用。注意 Pyecharts 的 HeatMap 数据格式是[x, y, value],x 和 y 必须是字符串或数值索引,别传错。

4.3 可视化大屏的布局与实时刷新思路

如果要做成可视化大屏(比如投在监控中心),通常需要多图联动。常见做法是用 Pyecharts 的Grid、Page或Tab组合多个图表,再用 Flask 或 FastAPI 包一层 HTTP 服务,前端用 iframe 嵌入。实时刷新的话,后端定时重新计算聚合数据,前端用 WebSocket 或定时轮询更新。

from pyecharts.charts import Page page = Page(layout=Page.DraggablePageLayout, page_title="出租车轨迹分析大屏") page.add(heatmap) # 可以继续 add 其他图表:OD 流向图、车辆活跃度折线图、区域排名柱状图 page.render("dashboard.html")

Page.DraggablePageLayout允许在浏览器里拖拽调整图表位置,调好之后点保存,布局会存到本地。这个功能在做大屏时能省很多调 CSS 的时间。实时刷新场景下,建议把数据聚合逻辑放在后端定时任务里(比如每 5 分钟跑一次),前端只负责展示,别让浏览器去算聚合。

5. 避坑与排查:轨迹项目里最容易翻车的 5 个地方

5.1 坐标系混用导致地图上轨迹整体偏移

现象:Folium 底图上叠加的轨迹点整体偏移了几百米,形状对但位置不对。原因:GPS 原始数据是 WGS84,但地图底图用的是 GCJ02(国内大部分在线地图),两者差几百米。解决:统一转成 GCJ02 再可视化。用coord-convert库的wgs84_to_gcj02函数批量转换,注意转换前先确认数据原始坐标系——如果数据本身已经是 GCJ02,再转一次就偏更远了。

5.2 载客状态字段全是 0 或全是 1

现象:提取上下客事件时发现 pickup 或 dropoff 为空,或者数量极少。原因:部分数据源的载客状态字段是设备默认值,没有真实反映载客情况;或者字段名不是status而是occupied、passenger之类的。解决:先df.columns看字段名,再df["status"].value_counts()看分布。如果确实没有有效的载客状态,只能退而求其次用速度阈值推断——速度持续低于 5 km/h 且持续 2 分钟以上视为驻留(可能上下客),但精度会差很多。

5.3 大数据量下 Pandas 内存爆掉

现象:读取 2000 万条轨迹记录时内存直接飙到 16G 以上,或者 groupby 操作卡死。原因:Pandas 默认用 float64 存经纬度,用 object 存 taxi_id,内存占用是实际需要的 3-5 倍。解决:读取时指定 dtype——经纬度用 float32,taxi_id 用 category,status 用 int8。这样内存能降到原来的 1/4 左右。如果还扛不住,用 Dask 或 Polars 替代 Pandas 做分组聚合。

dtype_map = {"taxi_id": "category", "lon": "float32", "lat": "float32", "status": "int8"} df = pd.read_csv("taxi_traj.csv", dtype=dtype_map, parse_dates=["timestamp"])

5.4 热力图渲染出来是一片均匀色块

现象:Folium 热力图看起来整个城市都是红色,没有明显的热点区域。原因:radius参数设太大了,或者数据点太多导致密度差异被抹平。解决:先把radius降到 5 以下,blur降到 3,min_opacity提到 0.5。如果还是均匀,说明数据本身分布确实均匀(比如全市出租车随机巡游),这时候改用网格聚合图比热力图更合适。

5.5 OD 配对率过低导致分析结论不可信

现象:上客事件 10 万次,但成功配对的 OD 只有 3 万条,配对率 30%。原因:常见原因是下客事件缺失(设备在行程中断电或信号丢失),或者max_dur_min设太小把长途行程过滤掉了。解决:先统计行程时长分布,看被过滤掉的行程集中在哪个区间。如果大量行程超过 120 分钟,把max_dur_min调到 180 甚至 240。另外检查数据里是否有大量车辆只有上客没有下客——如果有,说明该车辆的数据不完整,考虑整辆车剔除。

6. 进阶技巧:用 H3 六边形网格替代方格网做空间聚合

方格网做空间聚合有个天然缺陷——边界效应。同样一条路上的两个点,可能因为落在不同方格而被拆开,导致热点被割裂。Uber 开源的 H3 六边形网格系统能缓解这个问题,因为六边形到相邻六边形的距离是相等的,没有方格的对角线问题。

import h3 def od_to_h3_matrix(od_df, resolution=8): """ 用 H3 六边形网格聚合 OD - resolution: H3 分辨率,8 约等于 0.46 km² 的六边形 """ od_df = od_df.copy() od_df["o_h3"] = [h3.latlng_to_cell(lat, lon, resolution) for lat, lon in zip(od_df["pickup_lat"], od_df["pickup_lon"])] od_df["d_h3"] = [h3.latlng_to_cell(lat, lon, resolution) for lat, lon in zip(od_df["dropoff_lat"], od_df["dropoff_lon"])] matrix = od_df.groupby(["o_h3","d_h3"]).size().reset_index(name="flow") print(f"H3 网格数: {od_df['o_h3'].nunique()}, OD 对: {len(matrix)}") return matrix h3_matrix = od_to_h3_matrix(od_df, resolution=8)

H3 的resolution参数从 0 到 15,8 对应约 0.46 平方公里,9 对应约 0.1 平方公里。城市出租车分析用 8 或 9 比较合适。用 H3 聚合后,可视化时可以用h3.cell_to_boundary拿到六边形的顶点坐标,再用 Folium 的Polygon画出来,效果比方格网自然很多。

验证 H3 聚合效果是否合理,有个简单方法:对比同一时段同一区域的方格网和 H3 网格的流量排名,如果 Top 10 热点区域基本一致,说明聚合结果稳定;如果差异很大,说明分辨率选得不合适,需要调整。

我自己做这类项目最大的习惯是:任何聚合结果都要用两种不同粒度交叉验证一遍,方格网和 H3 各跑一次,结果对不上就回头查数据。这个习惯帮我抓过好几次坐标系没对齐的 bug——因为坐标系偏移在单一粒度下看不出来,换个粒度就露馅了。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询