每天开车到公司的那段路,只要导航地图上出现红色,我就会盯着“预计通过时间”反复盘算,到底要不要换条路。后来我发现,导航告诉我的其实是一个不太够用的信息:它告诉我前面有拥堵、通过这段路大概还要多少分钟,但从不告诉我“这个拥堵本身还会持续多久”。这两个问题听起来差不多,在工程上却是两码事。前者是当前路况快照下的通行代价,后者是一个拥堵事件的生命周期。我想要的是后者——拥堵什么时候散,而不是我在这条路上还要熬几分钟。
于是我用几个月时间,把高德地图的实时路况数据拉下来,做了一套拥堵持续时长预测的完整小系统:数据接入、拥堵事件切分、特征工程、模型选型、上线评估,全流程都走了一遍。这篇文章就是这次实践的完整记录,里面全是踩坑细节和选型理由。适合正在做数据分析想往机器学习方向转的同学,也适合智能交通领域的开发者参考。不用多新的模型,但数据口径和标签构造上的那些坑,比模型本身更值得讲。
1. 从导航的“预计通过时间”想到的:拥堵还会堵多久
1.1 “预计通过时间”和“剩余拥堵时长”不是一回事
导航App的预计通过时间,是用当前全路段的实时速度估算出来的:假如你现在出发,大概需要多久通过这一段。这个数字很实用,但它的本质是“当前照片的投影”,路况一旦变化,它就会跟着跳动。它不能回答那个更关键的问题——那股红色的堵车什么时候散。
剩余拥堵时长,是指当前正在发生的拥堵事件,从此刻起还会持续多少分钟。它更像一种“事件剩余寿命”预测。日常业务里类似的场景非常多:故障恢复还要多久、排队还要等多久、工单处理还要多久。方法论是相通的,只是这次的载体是城市交通。
1.2 这个预测到底解决什么问题
一句话说明项目目标:给定某条道路在当前时刻的快照信息,包括拥堵等级、速度、旅行时间、已经堵了多久等,预测该拥堵事件从当前时刻开始的剩余持续时长。
这不是预测“一个小时后的路况”,而是预测一个正在进行的事件的结束点。区别在于,前者是固定时间的状态推演,后者是生命周期问题。做路线决策时,知道“这个拥堵还会持续多久”,比知道“通过这段路要多久”更能决定是否绕路。网约车调度、物流排线、交通诱导发布也都需要这个信息,这是项目独立价值所在。
1.3 给自己定的目标和边界
我给自己定下的目标不是精确到分钟,而是“预测值和真实值的平均误差在10分钟以内,并且能给出一个区间”。交通系统的随机性决定了不可能精准预测,与其追求一个虚假的准确数字,不如把不确定性明确表达出来。
读这篇文章的人,建议有一定SQL和Python基础,代码细节能跟得上;就算不写代码,把数据清洗思路和特征设计看一遍,也会对“这类项目从哪下手”有个清晰认识。整个技术路线不复杂:按分钟轮询路况接口,用状态机切分拥堵事件,在每个观测点上构造特征,用LightGBM训练回归模型,最后在模拟线上环境里用真实时序数据回测。
2. 数据接入的真相:高德路况接口不是一张静态表
2.1 我拿到的原始字段
通过高德地图的开发者服务,申请Key之后可以轮询到按道路划分的路况信息。我选择的粒度是路况分段,每条记录大概包含道路编码、道路名称、行车方向、坐标串、当前速度、旅行时间、路况等级、数据更新时间。路况等级通常分为畅通、缓行、拥堵、严重拥堵几档,是后面切分拥堵事件的基础字段。
| 字段 | 含义 | 典型取值/说明 |
|---|---|---|
| road_id | 道路分段编码 | 地图数据精度变化时可能改变 |
| direction | 行车方向 | 双向路段必须区分上下行 |
| polyline | 道路坐标串 | 用于逆地理匹配和方向校验 |
| speed | 当前平均速度 | 单位km/h |
| travel_time | 通过该路段预计时间 | 随实时路况波动 |
| status_level | 路况等级 | 1畅通 2缓行 3拥堵 4严重拥堵 |
| update_time | 数据时间戳 | 注意和请求时间的区别 |
字段不算多,但真实接完数据会发现,任何看似结构化的表都藏着各种口径问题。路况接口返回的是某一时刻的全量快照,而不是事件流,它不会告诉你“这个拥堵是几点开始的、已经持续多久”。要把一堆快照还原成事件,得自己做时间和空间上的串联。
2.2 快照数据不代表事件
这是整个项目的地基。如果地基不牢,后面的标签全是错的。我的做法是:以道路编码加行车方向作为分组键,按时间排序,把连续的状态变化组装成拥堵事件。技术上不算难,但实际操作中,路段ID会因为地图更新而变化,坐标也会漂移,这些都会破坏分组。
我额外维护了一张路段主数据表,把道路名称、道路等级、坐标范围放进去,用来在校正漂移时分参考。每次从接口拉回数据,先落入原始快照表,再和主数据表做匹配,匹配不上的临时挂起人工检查,不直接参与特征计算。
2.3 清洗阶段踩过的三个坑
第一个坑是坐标漂移。GPS测出来的道路位置偶尔会跳到隔壁平行路上,导致原本属于A路段的观测被分到B路段。我的处理是拿坐标串做逆地理匹配,计算坐标点之间的距离,容差超过阈值就丢弃该条记录;同时保留一个置信度字段,后续特征计算可以跳过低置信样本。
第二个坑是方向反写。同一个道路编码下会有双向路况数据,如果两条方向混到一起,速度一会快一会慢,特征就废了。我按行车方向字段拆成两套序列,再用坐标首尾点的方位角校验一遍方向是否正确。这个步骤不能省,双向合流的数据在切分事件时会出现大量伪事件。
第三个坑是断流。接口偶发返回空数据,如果直接用前一条记录填充,就会人为拉长拥堵事件。我区分了“真的恢复畅通”和“缺数据”:缺数超过5分钟的位置打上缺失标记,进入事件切分时断开;短缺数用线性插值补。宁可多断开一个事件,也不要造出一个伪长事件。
2.4 最终的表结构和采样周期
数据最终落到三张表:原始快照表、事件表、样本表。原始快照表只做保留,不参与建模;事件表记录每次拥堵事件的开始时间、结束时间、峰值等级、持续时长;样本表是模型输入,每条样本代表“某个事件在某个观测时刻”的状态。
采样周期我选了1分钟,车辆在路上状态变化很快,1分钟能保留足够细节。但我后来发现,接口的真实更新周期未必是1分钟,很多路段可能几分钟才更新一次。这件事直接影响在线推理效果,放到后面专门说。
3. 最关键的建模决策:别用当前拥堵等级预测剩余时长
3.1 目标设定成回归,不做分类
很多人听到拥堵预测,第一反应是做分类:判断现在是畅通、缓行还是拥堵,或者预测一小时后会不会堵。但我的需求是“已经堵了,还要堵多久”,这是一个连续值回归问题。
分类结果给不下决策:告诉用户“还会拥堵”,用户已经在拥堵里了,他需要的是“预计还要13分钟结束”。只有这种连续输出才能让他决定要不要换路、要不要推迟出发。所以我从一开始就把目标定为回归,而不是分类。
3.2 拥堵事件的定义和切分参数
“拥堵事件”没有统一标准,只有合理不合理。我的定义是:道路状态等级大于等于缓行档位,并且持续15分钟以上,视为一次事件开始;当状态回落到畅通档,并持续10分钟以上,视为事件结束。
这两个参数(15和10)非常敏感,我用一版网格搜索对比过不同参数下的样本量和标签稳定性,最终才定下来。太短会把瞬时波动算成事件,太长又会漏掉短时拥堵。顺带说一句,事件切分参数会直接影响样本量,也会间接影响模型效果,这部分调整值得花时间。
3.3 剩余时长的右删失问题
这是建模路上最大的坎。在任何一个观测时刻,拥堵还在进行中,我们不知道它什么时候结束,所以“剩余时长”这个标签是缺失的。只有等事件结束后回看,才能给每个历史时间点贴上“结束时刻 - 观测时刻”的标签。
统计上这叫右删失。如果只取已结束事件训练,长拥堵天然更容易被观测到“还在进行中”,模型会在选择性偏差里打转。我的折中方案是:训练样本以已结束事件为主,最后一个月内的进行中事件保留为无标签样本,只用于模型监控;优化时给已持续较久的样本加时间衰减权重,防止模型被末尾那些极端长样本带偏。
3.4 当前拥堵等级只能当特征,不能当目标
如果拿等级做目标,等于把连续信息强行离散化。举个例子:一个拥堵已经持续50分钟、等级从严重逐渐降到缓行,模型本来应该预测“快结束了”,但分类模型看到当前缓行,大概率输出“即将结束”;另一个刚发生5分钟、等级同样缓行的拥堵,它也会输出“即将结束”。两个状态完全不同的样本在分类目标下变得不可区分。
回归目标能保留“已持续50分钟”这个关键上下文。剩余时长分布会随着已持续时长发生明显偏移,这个信息只有回归目标能学到。
4. 特征工程的胜负手:让模型“看见”拥堵的上下游和过去
4.1 时间盒特征:早晚高峰和节假日是两种世界
时间特征不能只给模型一个Unix时间戳,它学不到规律。我按小时段、星期几、是否节假日分别做特征,并且对工作日、周末、法定节假日分别统计。另外还把“上学日”和“寒暑假”单独标记,学校周边路段的拥堵模式在这两类日子里差异很大。
时间特征是降低偏差的底座,但它要和路段属性交互才有用。商圈路段周五晚上的拥堵模式和快速路周五晚上完全不同,所以我把道路等级也放进特征里,让树模型自己学交互关系,效果比手工添加时间路段交叉特征更稳定。
4.2 拥堵自身状态:已持续时长是最强信号
模型能拿到的最直接信号是事件本身的进展:已经堵了多久、当前等级、当前速度、最近5分钟速度环比变化。实验里已持续时长的特征重要性排在最前面,不意外:一个堵了40分钟的拥堵,大概率已经进入消散期;一个刚堵3分钟的拥堵,大概率还要继续发酵。
速度环比变化能区分“正在加剧”和“正在缓解”。这两个状态即使当前等级一样,剩余时长的分布也差别很大。接口快照里等级是离散的,速度是连续的,后者对消散趋势更敏感。
4.3 上下游联动:把单点预测变成路段网络预测
这是我认为本项目最重要的特征。只看当前路段本身,常常会误判。举例来说,当前路段严重拥堵,但上游路段已经大幅提速,说明拥堵点正在向下游移动,当前路段的拥堵反而可能快结束;如果下游也堵上来,说明整个走廊都饱和了,消散会慢得多。
我把当前路段前后各两个路段的等级、速度、旅行时间都拉出来作为特征。具体做法是先构建路网邻接表,再在特征构造时按拓扑查上下游状态。核心逻辑大概是这样的:
def build_features(event_obs, neighbor_map): f = {} f["already_minutes"] = event_obs["t"] - event_obs["start_time"] f["cur_level"] = event_obs["status_level"] f["speed_ratio_5min"] = event_obs["speed"] / (event_obs["speed_lag5"] + 1e-6) for offset in [-1, 1, -2, 2]: nb = neighbor_map.get(event_obs["road_id"], {}).get(offset) if nb: f[f"nb_{offset}_level"] = nb["status_level"] f[f"nb_{offset}_speed"] = nb["speed"] else: f[f"nb_{offset}_level"] = -1 f[f"nb_{offset}_speed"] = -1.0 return f加入这组特征后,验证集MAE下降了大约两分钟。在两分钟这个量级上,这已经是非常明显的提升。
4.4 历史行为规律:同一条路,过去堵多久
一条路的“脾气”是稳定的。某条穿城快速路在工作日早高峰平均堵40分钟,另一条商圈支路可能只堵15分钟。我把同路段、同时段、过去14天的拥堵时长中位数和历史事件占比做成特征,相当于给模型配了一个路段级经验值。
老路有稳定表现时,这些特征非常有效。但要注意一点:历史中位数特征在验证集测试集里可能泄漏未来信息,因为半个月滚动窗口会跨过训练集边界。我在构造时严格保证只用训练集和更早时间段的数据,验证集和测试集不参与滚动统计。
4.5 外况特征:天气和未来时刻的取舍
雨雪天气会显著拉长拥堵持续时长,所以我把逐小时天气状况、降水强度、温度作为补充特征试过。但这里有一个部署层面的坑:有些外况特征离线训练时是现成的,线上实时推理却不一定能及时拿到,尤其是天气预报的未来值。
因此我最终没有把天气作为主特征,只作为可选扩展。线上拿不到天气的时段,用“未知”类别填充,不要让模型强行假设数据存在。这个取舍在离线实验里看起来损失了一点精度,但避免了线上效果和离线效果严重脱节。
5. 模型选型实测:LightGBM 在这个问题上为什么比 LSTM 实用
5.1 实验数据与评估口径
数据来自我所在城市三条主要道路和附近二十条支路,约4个月的快照数据。训练集、验证集、测试集严格按时间切分:前3个月做训练,第4个月做验证,最后两周做测试。
时序项目不能用随机抽样,否则模型会偷看到未来的分布,评测结果虚高。主评估指标是MAE,辅助看RMSE,以及一个命中率——预测值和真值误差在5分钟以内的样本占比。这三个指标合在一起,能同时反映平均偏差、大误差惩罚和实用准确度。
5.2 从Baseline到线性回归:先看天花板
我的第一个baseline是直接输出“同路段、同时段的历史中位拥堵时长”。测试集MAE约13分钟。这个结果很有价值,它说明这条路上的拥堵规律足够强,任何模型都要优于13分钟才有实用价值。
接着试了岭回归,把等级、已持续时长、时间等数值特征放进去,MAE降到10分钟左右。岭回归的优点是稳定、快、可解释,但它对等级型特征和数值特征之间的非线性交互学习不足。比如“已持续时长和上下游等级同时作用”这类关系,线性模型只能近似,树模型可以直接切分出来。
5.3 LightGBM的训练细节和参数
树模型天然适合混合了类别、数值、交互关系的表格数据。我用LightGBM,目标函数直接设成MAE,而不是默认的MSE,因为误差在长尾分布下,MAE更贴近业务认知:被少数大误差样本拉偏,感觉上很不公平。
| 参数 | 值 | 作用说明 |
|---|---|---|
| objective | regression_l1 | 直接优化MAE |
| learning_rate | 0.05 | 小步长,配合早停 |
| max_depth | 6 | 限制单棵树深度 |
| num_leaves | 31 | 与深度匹配 |
| feature_fraction | 0.8 | 列采样防过拟合 |
| bagging_fraction | 0.8 | 行采样防过拟合 |
| min_data_in_leaf | 50 | 防止叶子节点样本过少 |
用小学习率配合早停,可以防止树模型在时间序列上过拟合。训练集表现很漂亮但没有意义,验证集和测试集的效果才是真实的。最终测试集MAE大约是7.5分钟,RMSE约11分钟,命中率约58%。比起历史中位baseline的13分钟,提升非常明显。
| 模型 | MAE(分钟) | RMSE(分钟) | 命中率 |
|---|---|---|---|
| 历史中位baseline | 13.2 | 18.5 | 35% |
| 岭回归 | 10.1 | 15.3 | 43% |
| LightGBM | 7.6 | 11.2 | 58% |
5.4 为什么没有选LSTM或复杂时序模型
我也认真考虑过时序模型。LSTM能建模完整状态序列,但有几个现实问题。第一,拥堵事件长度参差不齐,有的5分钟,有的2小时,要做定长序列就得大量补零,信息冗余。第二,在线推理要维护每个路段的序列状态,工程复杂度高。第三,这个问题的关键信息集中在最近几帧和事件统计量里,树模型拿三帧差分就能获得主要趋势。
我实测过一段轻量序列模型,效果没有明显优势。根本原因还是标签质量和特征质量决定了上限,模型的复杂性并不会自动弥补数据口径的粗糙。
5.5 模型效果和可解释性
最终测试集的误差分布大体稳定,但我发现两类情况值得注意:对持续5分钟以下的短临拥堵,预测普遍偏大,因为这类拥堵常常是接口瞬时波动造成的“假拥堵”,模型拿不到足够依据;对持续60分钟以上的长拥堵,预测普遍偏小,长拥堵往往涉及突发事件或持续高流量,光靠历史规律不够。
我还用SHAP值看过特征贡献,排前三的是已持续时长、上下游等级、历史中位时长。这个可解释性在业务沟通中很有用,能直接回答“模型到底靠什么预测”,而不是给一个说不清来源的数字。
6. 上线前才遇到的那些破事:数据延迟、路段漂移与冷启动
6.1 数据延迟会让离线指标失真
离线回测时,我的采样频率是1分钟,但线上接口某些路段更新并不那么频繁,可能3到5分钟才刷新一次。这意味着在线预测在更早的时间点拿不到最新状态,离线MAE不能直接代表线上效果。
我做了个“模拟回放”:按接口真实更新的时间戳重新模拟数据流,把预测延迟时间也作为一个特征输入模型,再做一次评估。结果MAE比离线测试差了约2分钟。这个数字并不夸张,但足以说明,数据新鲜度和模型精度是同一件事。任何一个要上线的预测项目,都应该做一次这样的模拟回放。
6.2 路段漂移和地图数据更新
系统运行到第三周,我发现一批路段ID全变了:地图数据重新切分了道路,原来的一条长路被拆成多条。如果不管,路段级特征全军覆没。我的处理办法是用道路名称加坐标串匹配新旧ID,建立映射表;同时特征里不让模型直接依赖单个道路编码做原子化记忆。
具体做法是加入道路等级、车道数、功能等级等属性特征,让模型在ID变化后仍有泛化依据。这条路段属性表要定期和地图数据做对齐,属于长期维护工作。别指望一劳永逸,地图更新频率比你想象的高。
6.3 冷启动:没有历史的新路段怎么预测
对一条新开通或新接入的路段,没有历史中位时长,也没有积累样本。我用的方案分两步:第一步,找属性相似的路段,用它们的分布做迁移预测,同时把预测区间拉大;第二步,等新路段积累至少两周事件后,再切换到自身回归模型。
冷启动阶段千万不要输出一个过于确定的数字。用户不会因为模型猜错一次就宽容,宁可给出“预计还会堵10到30分钟”这种宽区间,也不能给一个看起来很精确但实际没依据的“15分钟”。
6.4 业务输出的最终形态:分位数而不是单点
最终线上输出我改成了分位数:P25、P50、P75三个数字。用户看到“预计还剩10到20分钟,中位数15分钟”,既保留了不确定性,又给了决策一个行动基线。
如果预测P50小于5分钟,我会额外打一个“拥堵正在消散”的状态标记。这时候系统不再建议换路,而是提示“前方拥堵即将缓解,可以正常通过”。这种状态标记比冷冰冰的数字更符合实际驾驶决策。
做完这套系统,我最大的体会是:拥堵持续时长预测项目,难度不在模型,而在把路况快照还原成可学习的事件,以及让特征管线在真实地图数据持续变化中保持稳定。另一个经验是,我每天都会把预测值和事后真实结束时间做对比,偏差超过阈值就重新训练,这个反馈闭环比固定周期重训省心得多。
最后留一个小想法:如果能拿到拥堵开始前的“前兆特征”,比如速度连续三帧下降、周边路段同时变红,完全可以做一个“即将拥堵预警”,提前十分钟提示用户。这会是比“持续时长预测”更前瞻的方向。做交通数据分析的乐趣也在于此:看似导航早就告诉过你的信息,拆开之后全是值得折腾的问题。