简介:面向毕业设计与课程设计场景,资源提供了一套基于开源SUMO交通仿真平台和深度强化学习DQN算法的信号灯相位时间优化项目。整套代码采用Python编写,覆盖路网构建、仿真交互、模型训练与结果分析等关键环节,适合对智能交通和强化学习感兴趣的开发者快速上手。项目共32个文件,包含路网配置、地图数据、算法脚本、实验结果表格及说明文档,其中XML和OSM文件用于构建仿真环境,PY文件为核心控制逻辑,压缩包仅533KB,部署轻量。目前已有545人学习使用。通过阅读源码,可以理解状态特征选取、动作输出方式和奖励函数设计,并掌握将DQN应用于交通信号控制的完整流程,理解从路网搭建、仿真交互到模型训练与收敛评估的整个链路,便于在此基础上扩展优先级决策或迁移至其他路网场景。 大概半年前我准备一个交通仿真的课程项目时,盯着SUMO仿真界面里的红绿灯发呆:东西方向已经堵成一片,南北方向却一辆车都没有,可信号灯依然按着固定配时在傻傻地放行。那一刻我意识到,交通信号灯控制远比看起来复杂,它本质上是一个动态环境下的序列决策问题,而这类问题恰好是强化学习的看家本领。于是我基于Python + SUMO仿真平台,用DQN做了一套能根据实时车流调整交通信号灯相位时间的方案,整套源码也整理成了一个开源项目。这篇文章就把这套方案从环境搭建、算法设计到训练调参的完整过程拆给你看,适合正在做毕设、参加竞赛,或者想入门强化学习在交通领域应用的同学参考。
1. 信号灯控制的痛点:为什么固定配时总是差一口气
1.1 交通流本身就是"非平稳"的
传统路口信号机用的都是固定配时方案,也就是预先算好每个相位的红绿灯时长,然后按周期循环执行。这种方式在车流比较稳定的时候勉强够用,但一旦遇到早晚高峰、学校放假、突发事故、旁边路口修路,车流特征就会完全变化。固定配时没有任何感知能力,只能按部就班地放行,结果就是绿灯方向没车,红灯方向排队几百米,整个路口通行效率大幅下降。
很多同学可能会觉得"那我用多时段配时方案不就行了,高峰一套、平峰一套"。这确实比固定配时强,但本质上还是开环控制,交通流一旦出现计划之外的波动,多时段方案照样失灵。交通仿真里我们经常说一句话:交通流本身是非平稳的,每分钟的车流量都在变,所以真正有效的策略必须是闭环的、能根据实时状态做出反应的。
1.2 DQN在信号灯场景里到底做什么
用DQN解决信号灯问题,其实是把"信号灯控制器"当成一个智能体,让它通过与环境不断试错来学习一套控制策略。每个决策时刻,智能体观察当前路口的状态,比如各个方向的车队有多长、车辆等了多久,然后决定当前绿灯相位应该继续延长,还是切换到下一个相位。切换之后,环境发生改变,智能体会得到一个奖励信号,比如等待车辆减少了多少。经过成千上万次试探,DQN会逐渐学会什么情况下该延长、什么情况下该切换。
用一个生活化类比:固定配时相当于一台自动售货机,投币后永远出同一瓶饮料;DQN相当于一个有经验的老交警,他会看一眼各个方向的车流,再决定先放谁、放多久,而且随着经验积累,判断越来越准。
1.3 这个项目适合谁、能学到什么
如果你正在做智慧交通方向的毕设或竞赛项目,这个题目几乎是"标配";如果你想入门强化学习但不想跑那种玩具环境,交通信号灯也是一个很合适的实战场景。跑通这个项目你至少能收获四样东西:
- SUMO这个专业交通仿真器的基本使用,包括路网生成、车流配置、TraCI接口调用;
- 强化学习三要素——状态、动作、奖励函数——如何映射到真实工程问题里;
- DQN的完整训练链路,包括经验回放、目标网络、epsilon-greedy探索;
- 一套可以扩展的多路口信号灯控制源码框架,后续换算法(比如DQN换PPO)只需要改agent部分。
2. SUMO环境搭建与TraCI通信:先说环境再谈算法
2.1 SUMO安装与环境变量配置
SUMO全称Simulation of Urban MObility,是一款开源的微观交通仿真平台。所谓微观,就是每辆车都有独立的加速度、最大速度、换道行为,能够比较真实地模拟交通流。
安装方式取决于操作系统。Windows用户可以直接去官网下载安装包,也可以用包管理器安装;Ubuntu等Linux系统推荐命令安装,macOS用brew也能装。安装完成后需要确认SUMO_HOME环境变量指向安装目录,并且把sumo和netconvert这些可执行文件所在目录加入PATH,否则后续Python调用会找不到命令。
验证安装可以打开终端执行一句命令:
sumo --version如果能看到版本号,说明安装成功。要注意的是,SUMO更新迭代很快,不同大版本的TraCI接口函数会有差异,我这边用的是1.15.0版本,如果你用的版本比较新,个别接口名可能需要微调。
2.2 搭建一个十字路口仿真场景
训练信号灯控制,先得有一个能反复实验的路口场景。最经典的就是单十字路口,两个方向交叉,每条进口道双向各两车道,中间路口设置信号灯。SUMO里搭建场景有两种方式:一是用自带的netedit图形化编辑器画,二是用XML文件定义节点和边,然后通过netconvert命令生成路网。图形化适合微调,命令行适合自动化生成,我项目中用的是后者的思路。
路网的基本结构是nodes.xml里定义节点坐标,edges.xml里定义连接的边。一个简单十字路口可以这样描述:
<nodes> <node id="N" x="0" y="300"/> <node id="S" x="0" y="0"/> <node id="E" x="300" y="150"/> <node id="W" x="0" y="150"/> </nodes>定义好节点后,用netconvert生成路网时给中间节点加一个type="traffic_light"属性,系统就会自动为该路口分配一套两相位信号灯方案。车流文件rou.xml则定义了车辆什么时候出现、从哪个进口道到哪个出口道,可以通过flow标签灵活设置每小时车流量。
最后用sumo.sumocfg把这些文件汇总起来。训练时只需要在Python里用一行命令启动仿真:
import traci traci.start(["sumo", "-c", "config.sumocfg"])训练阶段我强烈建议不用sumo-gui,因为图形界面会拖慢仿真速度。要看可视化效果时再换回sumo-gui即可。
2.3 TraCI接口:让Python成为"交通指挥官"
TraCI(Traffic Control Interface)是SUMO提供的通信接口,基于TCP协议。Python通过import traci连接上仿真进程后,就能实时读取路网状态、控制车辆和信号灯,相当于给仿真环境开了一个后门。
日常用得最多的TraCI方法大概是这几个:
| 功能 | 方法 | 说明 |
|---|---|---|
| 获取当前仿真时间 | traci.simulation.getTime() | 单位秒 |
| 获取车道排队车辆数 | traci.lane.getLastStepVehicleNumber(laneID) | 通过具体车道ID查询 |
| 获取路段等待时间 | traci.edge.getWaitingTime(edgeID) | 返回所有车辆等待总秒数 |
| 获取当前信号灯相位 | traci.trafficlight.getPhase(tlsID) | 返回相位序号 |
| 设置当前相位剩余时间 | traci.trafficlight.setPhaseDuration(tlsID, dur) | 控制绿灯延长/缩短 |
| 切换相位序号 | traci.trafficlight.setPhase(tlsID, idx) | 直接跳转到指定相位 |
项目里为了统一获取"当前路口的综合状态",我会封装一个StateExtractor类,把所有TraCI查询集中在一起,这样训练主程序看起来更干净,后面加特征也好维护。
3. DQN建模三件套:状态、动作、奖励函数的设计逻辑
3.1 状态空间:给智能体一双能看路况的眼睛
状态空间的设计直接决定智能体能不能学会策略。如果只给一个"当前时间",DQN什么都学不会;如果给全路网几百辆车的坐标,输入维度太大,训练难度又会爆炸。需要找到一组既能描述路况、又足够精简的特征。
我最终使用的状态向量由一个路口的关键信息拼装而成:
- 四个进口道方向的排队车辆数(单位:辆);
- 四个方向的平均等待时间(单位:秒);
- 当前相位编号和当前相位已经持续的秒数;
- 路口总等待时间(单位:秒)。
之所以要把"当前相位编号"和"相位已持续时长"放进去,是因为DQN的动作决策跟当前信号灯在哪个状态密切相关。同时,排队长度和等待时间这两类特征一个体现空间拥堵,一个体现时间延误,组合起来能帮助智能体平衡"放行效率"和"公平性"。
有一点必须提醒:喂给网络的输入一定要做归一化。排队长度可能到几十辆,等待时间可能到几百秒,这些数值直接放在一起,会让神经网络初期的梯度被大数值维度主导,导致训练很不稳定。我的做法比较简单,排队车辆数除以路口最大车道容量,等待时间除以一个经验上限(比如180秒),把大部分特征压到0到1范围内。
3.2 动作空间:把"相位时间调整"变成DQN的输出
标题里的"相位时间调整"在代码上其实可以抽象成两类动作设计方式。
第一种是把绿灯时间离散成几个固定档位,比如动作0表示延长5秒,动作1表示延长10秒,动作2表示延长15秒,动作3表示立即切换相位。这种方式动作空间更细,但会让训练收敛变慢,因为几个延长时间选项产生的状态差异很小,Q值难分高下。
第二种是把动作简化为二分类:继续延长当前绿灯相位,或者结束当前相位、进入下一相位。我最终选择的是这个方案。原因很简单:单路口信号灯控制的本质决策就是"切换还是不切换",至于延长多久,可以通过决策频率来间接控制。我的决策间隔设为10秒,也就是说每10秒智能体评估一次,如果选择"保持",绿灯自动加10秒,如果选择"切换",信号灯就会在下一个仿真步进入黄灯过渡,再进入下一相位。
这个设计还有一个好处:它完全符合真实信号机的工作逻辑。现实中信号灯并不可能每秒钟都在调整,决策频率过低或过高都不合理。决策间隔太短会导致频繁切换,形成"绿灯刚亮就灭"的抖动现象;间隔太长又会让智能体反应迟钝。10秒是我在试验中觉得平衡性最好的值。
3.3 奖励函数:排队长度变化量怎么量化
奖励函数是强化学习最容易被忽略、但也最决定成败的部分。信号灯控制最常见的优化目标是减小车辆平均等待时间、减少排队、提高通行量,但这些目标直接作为奖励并不好优化,因为它们都是长期累积量,反馈稀疏且延迟严重。
我采用的奖励公式是:
R = - (L_t - L_{t-1})其中L_t是当前时刻路口所有进口道的排队车辆总数,L_{t-1}是上一决策时刻的排队车辆总数。这个公式的含义非常直观:如果这次决策让排队车辆减少了,奖励为正;让排队增加了,奖励为负。智能体最大化累积奖励,本质上就是在最小化整个仿真时段内的排队增量。
为什么不用"平均等待时间"作为直接奖励?因为等待时间的变化到决策之间有时序滞后,而且受偶发车流波动影响大,方差很高。排队长度变化量则是一个相对平滑、立竿见影的指标,SR(Stability)也更好。如果你想进一步优化,可以在奖励里加一个"切换惩罚项",比如每次切换相位时额外减一个固定值,防止智能体频繁抖动。我试验后发现,加了切换惩罚反而会导致智能体过于保守,该切换时不切换,所以最终版本里没加。
3.4 三件套的整体联动
状态、动作、奖励这三者不是孤立的,它们共同定义了马尔可夫决策过程。具体到运行流程,每个决策时刻智能体根据状态选择动作,动作改变信号灯相位时间,相位时间影响车流运行,车流运行产生新的状态和奖励,然后进入下一个决策循环。只有三者都合理,DQN才能真正学到东西。我见过不少同学跑不出效果,第一反应是改网络结构和学习率,其实问题往往出在状态特征不够或奖励函数设计不当上。
4. 训练流程与源码拆解:从经验回放到目标网络
4.1 网络结构与超参数设置
DQN的核心是用深度神经网络来逼近Q函数,也就是"在当前状态下,每个动作能带来的未来累计奖励期望"。我的网络结构非常简单,三层全连接:
import torch.nn as nn import torch.nn.functional as F class DQN(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.fc1 = nn.Linear(state_dim, 128) self.fc2 = nn.Linear(128, 128) self.fc3 = nn.Linear(128, action_dim) def forward(self, x): x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) return self.fc3(x)信号灯控制的状态维度本就不高,一两百维以内,没必要上CNN、Transformer这类复杂的结构。全连接网络加两到三层隐藏层就完全够用,隐藏层宽度设为128或256即可,参数太多反而容易过拟合。
经验里比较关键的超参数我整理成了表格,方便直接抄作业:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 学习率 | 1e-4 | 调大容易出现Q值发散 |
| 折扣因子gamma | 0.95 | 兼顾短期排队和长期效果 |
| 经验池容量 | 20000 | 太大旧样本占比过高 |
| batch size | 64 | 常用值,32偏慢128偏抖 |
| 目标网络同步步数 | 500 | 太频繁等于没有目标网络 |
| epsilon初始值 | 1.0 | 早期充分探索 |
| epsilon最小值 | 0.05 | 保留一定随机性 |
| epsilon衰减步数 | 5000 | 线性衰减 |
4.2 训练主循环的流程拆解
训练主循环的代码骨架并不复杂,关键是理解每个步骤为什么存在。最核心的逻辑如下:
for episode in range(EPISODES): traci.start(sumoCmd) state = get_state() total_reward = 0 while traci.simulation.getMinExpectedNumber() > 0: action = epsilon_greedy(state, epsilon) reward, next_state = execute_action(action) replay_buffer.push((state, action, reward, next_state, False)) if len(replay_buffer) > BATCH_SIZE: train_step() state = next_state total_reward += reward traci.close()每个episode相当于一次完整的仿真,比如模拟3600秒的早高峰交通。仿真开始后,循环不断读取状态、选动作、执行动作、攒经验、更新网络,直到所有车辆都离开路网,一个episode结束。
execute_action这个函数是关键。当智能体选择"保持当前相位"时,我会调用traci.trafficlight.setPhaseDuration(tlsID, currentRemain + 10),让当前绿灯相位继续延长10秒;当选择"切换"时,就把当前相位的剩余时间设为1秒,让SUMO自然进入黄灯过渡相位,再切换到下一相位。这样做的目的是把相位切换的过渡处理交给SUMO内部机制,避免手动跳相位导致车辆冲突。
4.3 经验回放和目标网络到底解决了什么
DQN相比传统Q-learning最大的改进就是引入了经验回放和目标网络,这两个机制都是在解决训练稳定性的问题。
经验回放是把智能体探索过的所有(state, action, reward, next_state)存进一个缓冲区,训练时按batch随机采样。为什么要随机采样?因为强化学习的样本之间存在高度时间相关性——车辆排队、放行这个过程中,相邻几步的状态几乎差不多,如果直接用连续样本训练,网络会在这段局部模式上来回震荡,学不到全局规律。随机采样打破了这个相关性,让每次梯度更新尽量来自于多样的、独立分布的经验。
目标网络是另一个稳定器。DQN的损失函数是:
Loss = (r + gamma * max_a' Q_target(s', a') - Q_online(s, a))^2注意这里计算目标值时用的是Q_target网络,而不是正在更新的Q_online网络。因为如果用同一个网络同时计算预测值和目标值,每一轮更新都会让目标和预测一起动,优化过程就会变成"追一个不断移动的靶子",很容易震荡甚至发散。目标网络的做法是定期(比如每500步)把在线网络的参数复制过来,固定一段时间,让目标值相对稳定,训练才能收敛。
4.4 源码目录结构
项目源码的组织方式尽可能清晰,核心目录如下:
project/ ├── env/ │ ├── config.sumocfg # SUMO仿真配置 │ ├── net.net.xml # 路网文件 │ └── rou.xml # 车流文件 ├── agent/ │ ├── dqn.py # DQN网络定义 │ ├── replay_buffer.py # 经验回放缓冲区 │ └── train.py # 训练主循环 ├── utils/ │ ├── state_extractor.py # 从TraCI获取状态与奖励 │ └── config.py # 超参数配置 └── evaluate.py # 训练后评测脚本源码本身并不复杂,但把它拆成环境、算法、工具三个模块,会让你后续扩展起来非常舒服。比如想换成PPO算法,只需要替换agent目录里的文件;想换成多路口场景,只需要在utils里增加一个状态聚合器。
5. 实验结果对比与参数调优:DQN比固定配时强在哪
5.1 评价指标与固定配时基线
训练完成后,需要用一套客观指标来评估DQN到底有没有用。我做了两轮实验,第一轮把DQN和固定60秒周期配时做了对比,第二轮尝试了不同的车流强度。评测用的核心指标有三个:
- 平均等待时间:所有车辆在路口前停车的平均总时长;
- 平均排队长度:每个决策时刻各进口道排队的车辆数均值;
- 路网吞吐量:仿真时段内通过路口的车辆总数。
固定配时的基线直接用SUMO默认信号机方案,不做任何优化。评测时把DQN的信号灯控制程序接到同一套路网和车流文件上,其他条件完全一致,只让信号灯决策逻辑不同。
5.2 训练曲线怎么解读
DQN训练初期通常会有一段"表现很差"的阶段。我的经验里,前20个episode奖励值甚至明显低于固定配时的对照值,因为epsilon很大,智能体在疯狂探索,经常做出不合常理的切换动作,导致路口频繁出现"放行空车道、堵住车流量大的方向"这种尴尬局面。
这其实是正常现象,不要慌。随着训练进行,epsilon逐渐衰减,经验池里累积了足够的有效样本,网络开始学到"排队长的方向优先放行"这类规律,奖励曲线会逐步上升并超过基线。大概训练到150个episode左右,奖励曲线变得平稳,这时再跑评测,DQN的每个指标都会有可观提升。
我在一次典型的对比实验里得到的数字大概是:在中等流量下,DQN比固定配时降低了约18%的平均等待时间和22%的平均排队长度;在轻流量场景下两者差别不大,因为车本来就不堵;在重流量或流量突变场景下,DQN的优势会进一步拉开,有时等待时间能降低30%以上。这说明DQN强项在于应对不均衡、不稳定的车流,而不是替代所有配时方案。
5.3 影响收敛的关键参数
训练过程中有几处参数是真正决定成败的,我单独拿出来说。
第一个是奖励的尺度。如果奖励数值波动太大,比如排队长度变化从-30到+30,网络会对梯度方向非常敏感,必须把奖励除一个缩放因子(我这里除以了最大排队长度,让奖励落在[-1,1]区间),训练稳定很多。
第二个是决策间隔。10秒是我最后选定的值,但建议你做一次敏感性分析。间隔太短,信号灯频繁变动,车流根本来不及响应;间隔太长,智能体在两次决策之间会错失很多优化机会。用不同流量脚本做几组对照,你会找到最适合自己场景的数字。
第三个是目标网络同步步数。设成太小,比如每100步同步一次,目标网络几乎等于在线网络,失去意义;设成太大,比如每5000步,目标值和当前预测偏离过大,训练早期容易不稳定。500到1000步是一个经验合理区间。
6. 踩坑记录与个人心得:十几个小时仿真换来的经验
6.1 相位切换导致的"处处红灯"问题
我第一次把"切换"动作设为直接调用traci.trafficlight.setPhase跳转到下一个绿灯相位,训练出来的效果惨不忍睹:路口经常出现四个方向全是红灯的状态,车辆全部停摆。原因是SUMO信号灯内部是有相位顺序的,直接跳转跳过了黄灯过渡阶段,破坏了信号灯状态机的内部一致性。
后来我把切换逻辑改成:设置当前相位剩余时长为极短值(1秒),让信号灯按SUMO自己的规则进入黄灯过渡相位,再进入下一个相位,问题就消失了。这里的一个经验是:不要跟仿真器内置的状态机对抗,尽量顺着它的机制去做控制,否则你会在很多莫名其妙的仿真异常上浪费时间。
6.2 奖励函数方差过大导致训练崩溃
还有一个坑出现在换用"平均等待时间变化量"作为奖励时。等待时间受单辆车极端值影响很大,偶尔一辆车等了3分钟,这个变量的变动会让奖励瞬间产生很大波动,DQN的损失函数跟着震荡,训练曲线一路发散。
后来我把奖励改成排队长度变化量,再乘一个缩放系数,训练曲线立刻稳定下来了。如果你的任务必须用等待时间做奖励,我建议对单值进行截断处理,比如最大等待时间封顶180秒,或者对奖励做clip到[-1,1],总之不要让极端值主导梯度方向。
6.3 如果打算扩展到多路口,该怎么做
单路口跑通之后,很多人会想扩展到多路口协调控制。我的建议是:不要简单粗暴地把每个路口都放一个独立DQN,那样多个智能体在共同环境中各自优化,容易出现震荡。更稳妥的方式是先做一个多路口共享参数的单智能体方案,把所有路口的状态拼接成一个大向量输入同一个网络,输出所有路口的动作,这样训练稳定且代码改动不大。再往上的图神经网络或多智能体强化学习属于进阶方向,建议先把单路口基本功打扎实再碰。
最后再分享一个小技巧:训练时每隔几个episode就把当前模型保存一份,同时跑一次固定配时基线作为对照。这样你能在训练过程中实时观察智能体是否真的超过了基线,也能在崩溃时回滚到之前效果最好的模型。我在这个项目里前前后后跑了十几个小时的仿真,大部分时间都花在调参和修bug上,只有把环境、建模、训练链路都理顺了,强化学习在信号灯上的效果才真正凸显出来。
本文还有配套的精品资源,点击获取