2025年年初,我接了一个城市交通数据分析的项目。任务听起来直白——把全市出租车、网约车每天产生的GPS轨迹数据收进来,算清楚几个关键指标:分时段的平均车速、主要路段的拥堵等级、跨片区出行的OD需求。但真正把数据落到本地之后,我才意识到整件事的重心根本不在“算”上,而在“清洗-匹配-聚合-可视化”这条完整链条上。这篇分享就是这个项目的完整复盘:我会讲清楚技术栈为什么这么选、每个核心步骤的代码逻辑和参数依据、运行中踩过的坑,以及最终如何把结果变成一份能拿给决策者看的东西。适合正在用Python做交通数据分析的朋友,也适合准备入门这个方向、想了解真实项目长什么样的新人。
1. 为什么是Python:交通数据分析的技术选型逻辑
1.1 交通数据的三个特点决定了技术栈
做交通数据分析有个很特殊的点:它不像传统的结构化业务数据按行存储、按字段查询就行,交通数据天然带“时间+空间”双重属性。同样是两百万行数据,业务数据你只需要join几个表就能算完,交通数据却要处理轨迹点之间的先后顺序、道路网络的拓扑关系、离散点与连续道路段之间的匹配关系。这套操作对语言生态的要求很高,Python最匹配的地方在于,它有一个完整的GIS生态链:GeoPandas处理空间DataFrame,OSMnx下载并计算路网,movingpandas做轨迹分析,Shapely搞定几何运算。如果换成其他语言,要么生态不全,要么每个环节都要自己造轮子,开发成本完全不是一个量级。
很多人一听说上千万条交通数据,第一反应是上Spark或者Flink。但以我的实际经验,在2025年的机器配置下——比如32GB内存的常规工作站——单纯做离线分析,单机Pandas完全扛得住。一天一千万条GPS记录,压缩后大概三四个G CSV,Pandas读取后用内存优化技巧压缩到两G左右,分析时间在几分钟量级。只有在需要秒级实时响应、或需要跑全城路网级时空立方体的时候,才需要考虑分布式方案。这个判断在项目里帮我省掉了大量不必要的基建成本,也让交付周期短了很多。
1.2 核心库的分工与版本注意事项
项目里真正频繁用到的不超过八个库,各自分工明确。我把实际承担的角色整理成了表:
| 库 | 承担角色 | 我实际用到的能力 |
|---|---|---|
| pandas / numpy | 数据清洗、聚合计算 | 时间重采样、分组聚合、向量化计算 |
| geopandas / shapely | 空间数据操作 | 点-多边形匹配、缓冲区、路网匹配辅助 |
| osmnx | 路网获取与建模 | 下载城市路网、计算最短路径 |
| movingpandas | 轨迹分析 | 轨迹分割、停留点识别、轨迹长度计算 |
| folium / kepler.gl | 地图可视化 | 热力图、OD流线、拥堵等级图层 |
| scikit-learn / statsmodels | 建模预测 | 拥堵回归、时段聚类、短期预测 |
这里有个2025年容易踩的版本坑:GeoPandas从0.14版本开始,要求Shapely必须升级到2.x,而Shapely 2.x对部分老的几何库做了不兼容改动。如果你直接用pip install geopandas,在个别Linux环境里可能拉下来的是旧版Shapely,导致sjoin时出现奇怪的AttributeError。我建议直接用conda创建环境,让conda帮你解析依赖,或者明确安装shapely>=2.0。另外pandas 2.x默认开启了Copy-on-Write模式,以前那种修改子集DataFrame的写法行为会变,最好在项目开始前就统一编码习惯,否则同样的代码在不同环境跑出来的结果会不一样。
1.3 环境搭建中容易翻车的三个细节
第一,Python版本别追新。2025年初Python 3.13已经发布,但GeoPandas、movingpandas这些空间库的预编译轮子更新普遍滞后,我实测3.10和3.11是最稳的组合。你不需要纠结“最新就是最好”,稳定可复现比版本新重要得多。第二,创建虚拟环境时切记用conda或venv,千万别图省事直接装在系统Python里。我之前有一个项目就是系统Python里装了一半包,后来升级系统包导致全部失效,重新配环境花了一整天。第三,如果你的机器上没有GDAL编译环境,直接pip install geopandas很可能会在构建shapely时报错。最省事的办法是用conda install -c conda-forge geopandas,它会把GDAL一起带下来,避免自己面对一堆编译错误。这套环境准备做完,后面所有代码才能顺利跑通。
2. 拿到交通数据之后的第一步:清洗与预处理
2.1 真实交通数据的四种脏数据形态
接入真实交通数据后,第一课永远是“不要相信源头数据”。哪怕数据来自正规的出租车公司、网约车平台或者交管系统,到手的CSV里也一定有至少这四类问题。
- GPS漂移:常出现在隧道、高架桥下方、商圈密集区,定位点突然跳到几百米甚至几公里外。漂移数据直接算速度会出现500km/h的离谱值,画在地图上就是一条穿越楼房的斜线。
- 重复上报:车辆静止怠速或停车时,很多车载终端还是会按固定频率上报,导致大量坐标完全相同的点。这类数据不做去重,后面统计停靠时间会严重失真。
- 时间戳格式混乱:同一个文件里可能有13位毫秒格式、10位秒格式,还有带时区偏移的ISO字符串。如果直接解析,pd.to_datetime会给你一堆NaT,数据量直接少一截。
- 行程中断:车辆进入地下车库或隧道后GPS信号丢失,轨迹出现大段空白。如果不对这种gap做处理,后续计算平均速度时会错误地认为车辆在原本不可能时间内横穿了整个城市。
2.2 数据清洗Pipeline的具体实现
清洗是整个项目最关键的一道工序,核心是写一个可复用的Pipeline。下面这段代码是我项目的初始清洗逻辑,已经精简过,但保留了关键流程:
import pandas as pd import numpy as np # 读取轨迹CSV,其中timestamp列有10位秒和13位毫秒两种格式 df = pd.read_csv("traj_2025_01.csv") if df["timestamp"].max() > 1e12: df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") else: df["timestamp"] = pd.to_datetime(df["timestamp"], unit="s") # 按车辆ID和时间排序 df = df.sort_values(["vehicle_id", "timestamp"]) # 去重:同一辆车同一时刻只保留一条 df = df.drop_duplicates(subset=["vehicle_id", "timestamp"]) # 计算相邻点的时间差和位移(用Haversine公式算球面距离) lat_prev = df.groupby("vehicle_id")["lat"].shift() lon_prev = df.groupby("vehicle_id")["lon"].shift() dlat = np.radians(df["lat"] - lat_prev) dlon = np.radians(df["lon"] - lon_prev) a = ( np.sin(dlat / 2.0) ** 2 + np.cos(np.radians(lat_prev)) * np.cos(np.radians(df["lat"])) * np.sin(dlon / 2.0) ** 2 ) df["dist_m"] = 2 * 6371000 * np.arcsin(np.sqrt(a)) df["dt_sec"] = df.groupby("vehicle_id")["timestamp"].diff().dt.total_seconds() df["speed_kmh"] = df["dist_m"] / df["dt_sec"] * 3.6 # 过滤漂移点和信号中断gap clean = df[ (df["speed_kmh"] < 200) & (df["dt_sec"] > 0) & (df["dt_sec"] < 300) ].copy()漂移过滤的阈值设置需要结合城市路况。一般城市道路限速最高120km/h,高速路最高120km/h,考虑到GPS定位误差和瞬时抖动,我把阈值定在200km/h。这个值不用太敏感,因为太严会误杀正常的超车和高速行驶,太松又会放过真正的漂移点。更精细的做法是对每个轨迹点做局部median filter,但在第一轮清洗里速度阈值已经能解决绝大多数问题。
时间中断的阈值300秒也很关键。正常车载终端上报频率在5秒到30秒之间,如果两个点间隔超过5分钟,基本能断定车辆经过了无信号区域。这类gap之间算出的速度没有意义——它可能让车辆在某条河边“飞”过,所以必须剔除,保留gap之后重新开始新的轨迹分段。
2.3 地图匹配与轨迹纠偏的理性取舍
清洗完之后,数据仍然存在一个精度问题:GPS点不一定落在道路上。你看到的坐标可能离真实道路有10到50米的偏差。如果只做宏观统计,比如算某个网格的平均速度,这种偏差可以容忍;但如果要算某条具体道路的车速、或者判断某辆车是否在某个路口左转,就必须做地图匹配。
地图匹配在学术界最经典的是基于隐马尔可夫模型的算法,实现复杂度高,对中小团队不太友好。2025年更务实的选择有三种:第一是调用现成API,比如Valhalla地图匹配API或者国内主流地图平台的纠偏接口,批量把GPS轨迹送过去拿到匹配后的路径;第二种是用开源方案自建Valhalla服务,把OSM路网灌进去,通过HTTP请求完成匹配;第三种是简化的snap方法,用GeoPandas的sjoin或Shapely的project函数,把轨迹点投影到最近的道路线段上。
我的实际建议是:如果数据量在离线分析范围内,优先使用自建Valhalla或API,因为HMM匹配不仅能把点吸附到路上,还能利用路网的拓扑一致性修正轨迹本身,比如识别出车辆是沿某条路直行而不是“跳”到旁边高架。如果你只是想画热力图,不做路段级指标,那用第三种snap方法就够了,又快又省事。项目中我选择的是先用snap做第一版,验证完指标逻辑后,再针对重点路段用更精确的匹配重算——这样能在进度和精度之间取得平衡。
3. 从原始轨迹到有用指标:核心分析算法拆解
3.1 路段车速估算的三层逻辑
交通运行分析最核心的指标就是速度。但“平均车速”这个说法在交通领域里有大学问:直接对轨迹点速度做算术平均,结果没有代表性。原因很简单,GPS点是按时间等间隔采集的,但车辆在道路上不是匀速运动的。如果一辆车在拥堵路段堵了10分钟,只产生少量低速点,又在畅通路段跑了5分钟,产生大量高速点,算术平均会把这两类点等同看待,结果严重高估了整条路的拥堵程度。正确做法是“时间加权”速度,即先按轨迹点的时间间隔划分线段,用每一线段的速度乘以该线段时间,再除以总时间。
具体实现可以从轨迹点计算出每个小线段的平均速度和耗时,然后按路段聚合。聚合逻辑对应如下代码:
# 假设clean中每个轨迹点已经关联到最近的道路road_id和time_bucket # 时间加权平均速度 = 所有线段速度 * 线段耗时 的总和 / 线段耗时总和 grouped = clean.groupby(["road_id", "time_bucket"]).apply( lambda g: pd.Series({ "wt_avg_speed": (g["speed_kmh"] * g["dt_sec"]).sum() / g["dt_sec"].sum(), "p50_speed": g["speed_kmh"].median(), "p85_speed": np.percentile(g["speed_kmh"], 85), "sample_cnt": len(g), }) )另一个常用指标是速度分位数。在做拥堵分析时,P50(中位数速度)比平均值更稳健,因为它不受少数高速车辆或低速异常值影响。P85则常被当作“自由流速度”的代理指标——用85分位速度做基准,对比当前速度和自由流速度的比值,可以得到一个比绝对阈值更科学的拥堵指数。这个思路在城市对比报告中特别好用,因为不同城市、不同道路的限速差异很大,统一用绝对速度阈值去判断“A市拥堵、B市畅通”会得到荒谬结论,但相对自由流的速度比值就有可比性。
3.2 拥堵状态判定与时段挖掘
判定拥堵的阈值不能一个城市套到底。合理做法是分层设置:限速80km/h的城市快速路,低于40km/h就可以算拥堵;而限速50km/h的次干路,低于20km/h才算严重拥堵。更通用的方式是利用上一步算出的自由流速度:当路段当前速度低于自由流速度的50%时,定义为拥堵;低于30%时定义为严重拥堵。这个阈值体系是交通工程里的常见做法,在汇报里也好解释,决策者一听就懂。
做完路段级速度后,下一步做时段分析。我是按工作日、周末分开,再按小时聚合,计算每个时段的平均速度和拥堵路段占比。这段分析用pandas非常顺手:
# 给数据加小时特征 clean["hour"] = clean["timestamp"].dt.hour clean["is_weekend"] = clean["timestamp"].dt.dayofweek >= 5 # 按小时统计速度 hourly_speed = ( clean.groupby(["is_weekend", "hour"])["speed_kmh"] .agg(["mean", "median", lambda x: np.percentile(x, 85)]) )这个表格非常直观:工作日和周末的高峰错开1到2小时,周末上午的拥堵明显晚于工作日。这些细节在给决策者看的时候,比一张高深的模型图有用得多。把每个小时的拥堵路段数量画成柱状图后,还能自然识别出早晚高峰之外“隐形拥堵”——比如周五下午15点开始提前发酵的放学和出城车流。
3.3 OD矩阵构建与出行特征提取
OD矩阵是交通需求分析的基础,全称Origin-Destination,即起讫点矩阵。用出租车和网约车轨迹构建OD矩阵,核心工作是判断一次行程的起点和终点。怎么从连续轨迹中识别一次行程?标准做法是看停留点:如果一辆车的GPS点在某位置连续停留超过5分钟,并且位置保持在200米范围内,就认为前一段轨迹结束、后一段轨迹开始。注意,这里不能只看时间,因为堵车也会让车辆长时间停留。要结合空间和时间双重条件:两个点在时空间上同时接近,才判定为停留。
停留点识别之后,进入网格化环节。把城市范围切成500米乘500米的网格,用GeoPandas的sjoin把起点终点坐标映射到网格编号。网格太大会丢失空间细节,太小会引入稀疏噪音,500米在城市尺度上是比较稳妥的折中。初版实现时为了方便,我直接用经纬度取整生成网格编号:
# 粗略的500m网格编码,实际项目中建议用UTM投影后切网格 grid_size = 0.005 # 经纬度约500m clean["o_grid"] = ( np.floor(clean["lat"] / grid_size).astype(int).astype(str) + "_" + np.floor(clean["lon"] / grid_size).astype(int).astype(str) )拿到OD矩阵之后,一些很实际的问题就能直接回答了:最热的出发片区在哪里,跨江跨河的通勤需求有多大,晚高峰和早高峰的OD结构如何颠倒。这块是项目里最好的增量产出,因为单纯的车辆轨迹数据给决策者的价值有限,但一张清晰的OD流线图能让他们立刻明白“市民从哪来、到哪去”。
4. 结果可视化:让交通状态一目了然的落地方式
4.1 静态图表先讲清楚数据
我习惯先把分析结果用matplotlib画成静态图,确认数据形态没问题,再上地图可视化。这个顺序很重要——地图可视化让人兴奋,但很难从中发现数值异常;反而是一张简单的时间序列图,一眼就能看出某一天某个时段的数据是不是“断崖式下降”。
这里有一个非常实用的技巧:画时间序列图时,x轴如果跨度较大,默认刻度会挤成一团,这就是很多人在“python画图横坐标太密集”上遇到的问题。解决方案很简单,用matplotlib.dates的HourLocator和DateFormatter控制刻度间隔和格式,X轴就不会糊成一团了:
import matplotlib.pyplot as plt import matplotlib.dates as mdates fig, ax = plt.subplots(figsize=(12, 5)) ax.plot(hourly_speed.index.get_level_values(1), hourly_speed["mean"], marker="o", markersize=3) ax.xaxis.set_major_locator(mdates.HourLocator(interval=3)) ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) plt.xticks(rotation=45)除了时序图,速度分布的直方图和分位数箱线图也很重要。我发现大多数城市的网约车轨迹速度分布会呈现明显的双峰:一个峰对应拥堵路段,一个峰对应畅通路段。这个特征决定了,后续如果要建拥堵预测模型,简单线性回归大概率不够,需要上树模型或者引入更丰富的空间特征。
4.2 地图可视化:从folium到kepler.gl
静态图确认完毕后,进入地理可视化环节。我用过的方案里,最顺手的组合是folium加kepler.gl。folium的优点是轻量、API设计简单,几行代码就能输出交互式HTML地图,适合快速出图。kepler.gl的优势则是图层堆叠方便、性能好,百万级轨迹点在浏览器里拖拽缩放都不卡,还能直接做3D柱状图、OD弧线图。
folium画热力图的核心代码非常简短:
import folium from folium.plugins import HeatMap m = folium.Map(location=[39.9, 116.4], zoom_start=10) # 抽样5万个点做热力,避免浏览器卡顿 heat_data = clean[["lat", "lon"]].sample(50000).values.tolist() HeatMap(heat_data, radius=12, blur=8, max_zoom=1).add_to(m) m.save("traffic_heatmap.html")这里要注意,HeatMap的radius和blur参数对效果影响巨大。radius太小,热点破碎;太大,全城都红。我一般先画几个缩放级别,对比确定radius值,然后固定下来。另外一个真实经验是,直接把几十万点全部塞进HeatMap会卡。先用sample抽样,比如抽5万个点,热力效果几乎不变,但浏览器加载快非常多。
4.3 一份可以交付的地图网页
地图可视化最终要做成一页可交付的HTML,而非一堆代码和截图。我建议的页面结构是这样的:底图显示城市路网,叠加一个轨迹点热力图层;再叠加一个拥堵等级图层,把路段按当前时段速度着色,红色表示拥堵、黄色表示缓行、绿色表示畅通;最后叠加OD流线图,用弧线连接热门起讫片区。四个图层做成可开关和可切换时段的形式,决策者自己就能拖动查看早高峰和晚高峰的差异。
交付时还有几个工程细节:一是离线化,把用到的底图瓦片下载到本地,或者直接使用自部署的瓦片服务,避免在汇报现场因为网络问题白屏;二是HTML文件体积控制,轨迹点采样和线数据抽稀之后,整个文件控制在50MB以内,打开速度会比较快;三是隐私处理,车牌号字段在地图展示阶段一律删除,坐标也要做网格聚合展示,防止泄露个体出行轨迹。
5. 项目复盘:真实运行中踩过的坑与优化思路
5.1 百万级数据的性能优化
第一个必须处理的坑就是性能。项目中期我手上已经有了一周的轨迹数据,总量接近8000万条,原始pandas查询和groupby开始明显变慢。我做了三件事,效果立竿见影:
- dtype瘦身:把经纬度从float64降为float32,vehicle_id从字符串改为category,时间戳用datetime64[s],整体内存下降约60%。
- 分块读取与Parquet落地:初始ETL用pd.read_csv(chunksize=500000)逐块清洗,清洗后再合并写parquet。Parquet格式不仅能压缩存储,列式读取在后续分析中比CSV快3到5倍。
- 空间索引:GeoPandas的sjoin如果数据量大,默认是全量空间比较,极慢。先建立空间索引(sindex),把匹配范围缩小到道路缓冲区内,速度能提升一个数量级以上。
这些优化没有用到任何分布式框架,纯粹是数据结构和算法层面的调整,但对离线分析来说已经足够了。我建议大家在抱怨“数据太大跑不动”之前,先看看自己的数据在内存里到底吃了多少空间、走了多少无效的全表扫描——大多数情况下,问题都出在这里。
5.2 时间粒度与空间粒度的权衡
第二个坑是粒度选择。第一次做时段分析时我用了5分钟粒度,结果凌晨时段大量路段没有数据,统计结果画出来全是毛刺。后来改成15分钟粒度,毛刺消失了,规律也更清晰。这个权衡的本质是:粒度过细,观测稀疏导致方差膨胀;粒度过粗,高峰和低谷被平均掉,看不出交通动态。具体选择要靠数据密度说话——我通常先统计每个时间-空间切片下的中位数样本量,如果少于30条,就必须调粗粒度:
samples_per_slice = clean.groupby(["grid_id", "hour"]).size() samples_per_slice.describe()如果发现大量切片的样本量只有个位数,就别硬算均值和分位数了,要么调粗粒度,要么改用贝叶斯平滑或空间插值补全。交通数据的稀疏性是一切高级分析的前提,很多模型失灵并不是模型不好,而是数据切片太碎,信息量不足。
5.3 从分析到落地的三个经验
最后聊聊落地。这次项目让我重新认识了一个道理:交通数据分析的价值,一半在算法,另一半在沟通。给决策者汇报时,我尽量把结果翻译成三个能直接回答的问题:今天比上周堵了还是畅了?早高峰最堵的三个片区是哪里?如果实施某个管理措施,预计影响哪些路段和时段?分析师习惯讲“平均速度下降了8%”,但决策者更关心“下降了之后,市民通勤时间是增加5分钟还是减少2分钟”。把指标翻译成时间和范围的表达,汇报效果完全不同。
另外,数据合规是这类项目不能省的功夫。涉及车辆轨迹的数据,一定要做脱敏处理,保留统计分析所需的汇总字段,去掉可以直接定位个体的原始坐标明细。这个环节宁可保守,也不要图省事。项目上线前,我拉了完整的字段清单和数据字典,一条条确认哪些字段允许保留、哪些必须抹掉,这件事花了两天,但让整个项目能顺利交付。
这个项目做完,我最大的体会是:交通数据分析真正难的地方不在算法,而在对数据的理解。每一辆车、每一个GPS点背后都有一条实实在在的道路,一条准确的规则比一个复杂的模型更能在汇报现场站住脚。如果你也要开始做类似项目,我的建议是,先花一半时间把数据摸透——哪些字段会丢、哪些位置容易漂移、哪些时段数据稀疏——再动模型。数据本身会告诉你答案,Python只是把答案呈现出来的工具而已。