
2025年年初我接了一个城市交通数据分析的项目。任务听起来直白——把全市出租车、网约车每天产生的GPS轨迹数据收进来算清楚几个关键指标分时段的平均车速、主要路段的拥堵等级、跨片区出行的OD需求。但真正把数据落到本地之后我才意识到整件事的重心根本不在“算”上而在“清洗-匹配-聚合-可视化”这条完整链条上。这篇分享就是这个项目的完整复盘我会讲清楚技术栈为什么这么选、每个核心步骤的代码逻辑和参数依据、运行中踩过的坑以及最终如何把结果变成一份能拿给决策者看的东西。适合正在用Python做交通数据分析的朋友也适合准备入门这个方向、想了解真实项目长什么样的新人。1. 为什么是Python交通数据分析的技术选型逻辑1.1 交通数据的三个特点决定了技术栈做交通数据分析有个很特殊的点它不像传统的结构化业务数据按行存储、按字段查询就行交通数据天然带“时间空间”双重属性。同样是两百万行数据业务数据你只需要join几个表就能算完交通数据却要处理轨迹点之间的先后顺序、道路网络的拓扑关系、离散点与连续道路段之间的匹配关系。这套操作对语言生态的要求很高Python最匹配的地方在于它有一个完整的GIS生态链GeoPandas处理空间DataFrameOSMnx下载并计算路网movingpandas做轨迹分析Shapely搞定几何运算。如果换成其他语言要么生态不全要么每个环节都要自己造轮子开发成本完全不是一个量级。很多人一听说上千万条交通数据第一反应是上Spark或者Flink。但以我的实际经验在2025年的机器配置下——比如32GB内存的常规工作站——单纯做离线分析单机Pandas完全扛得住。一天一千万条GPS记录压缩后大概三四个G CSVPandas读取后用内存优化技巧压缩到两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帮你解析依赖或者明确安装shapely2.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], unitms) else: df[timestamp] pd.to_datetime(df[timestamp], units) # 按车辆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], markero, markersize3) ax.xaxis.set_major_locator(mdates.HourLocator(interval3)) ax.xaxis.set_major_formatter(mdates.DateFormatter(%H:%M)) plt.xticks(rotation45)除了时序图速度分布的直方图和分位数箱线图也很重要。我发现大多数城市的网约车轨迹速度分布会呈现明显的双峰一个峰对应拥堵路段一个峰对应畅通路段。这个特征决定了后续如果要建拥堵预测模型简单线性回归大概率不够需要上树模型或者引入更丰富的空间特征。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_start10) # 抽样5万个点做热力避免浏览器卡顿 heat_data clean[[lat, lon]].sample(50000).values.tolist() HeatMap(heat_data, radius12, blur8, max_zoom1).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降为float32vehicle_id从字符串改为category时间戳用datetime64[s]整体内存下降约60%。分块读取与Parquet落地初始ETL用pd.read_csv(chunksize500000)逐块清洗清洗后再合并写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只是把答案呈现出来的工具而已。