用DQN强化学习优化交通信号灯相位:SUMO仿真与Python实战
简介基于Python与SUMO仿真环境构建的交通信号灯相位优化系统采用深度Q网络DQN强化学习算法实现相位动态调控面向高校毕业设计、课程作业和工程研发场景。系统状态空间涵盖车道车辆密度、排队长度等指标动作空间对应相位时长调整策略并结合经验回放与目标网络机制提升收敛稳定性。源码包共39个文件压缩包大小约626KB含5个Python核心脚本、16个XML路网与流量配置文件、6个OSM地图文件以及md说明文档、xlsx数据表格和sumocfg仿真配置zbak备份文件用于对照排错。已有76人浏览学习适合需要快速搭建强化学习交通仿真实验的开发者。包内模块遵循模块化设计主控制器、环境接口与学习算法相互独立附训练结果检测数据与原始数据记录可调整奖励函数权重和状态特征以适配不同路口也可将代码迁移至自定义SUMO场景继续迭代。1. 为什么要用 DQN 重做交通信号灯相位优化城市路口的信号灯控制绝大多数还在跑固定配时——早上、晚上各一套时间表车多车少都按同一个周期轮转。遇到早晚高峰、突发拥堵、潮汐车流固定配时就是让车辆在红灯前干等。基于 Python 与 SUMO 平台的 DQN 强化学习信号灯相位优化就是用深度强化学习替代固定配时让信号灯根据实时队列长度、等待时间、车流量动态决定“下一个相位放哪边、放多久”。这个方向的价值很直接不新增硬件、不改路网结构只换控制策略就能在仿真里拿到 15%30% 的平均等待时间下降。项目名字里三个关键词对应三个落地模块SUMO 负责建路口模型和跑车流Python 通过 traci 接口跟 SUMO 实时通信DQN 这个强化学习算法负责决策相位切换。适合的人群是已经会一点 Python、想入门强化学习但不想在 gym 里玩 CartPole 的从业者以及做交通仿真但受够了固定配时的工程师。整个过程不需要真实路口数据SUMO 自带的路网生成工具就能造出可复现的测试场景。这个方案能做出来的核心原因是SUMO 提供了秒级的仿真控制和相位状态读取接口而 DQN 恰好能处理这种“状态连续、动作离散、反馈延迟”的控制问题。信号灯相位切换天然就是离散动作奖励信号可以从车辆等待时间算出来跑起来就是一个标准强化学习闭环。2. 先把仿真环境跑起来SUMO 网络、traci 连接与最小步进2.1 环境搭建Python、SUMO 与 traci 的版本匹配创建 conda 环境conda create -n sumo_dqn python3.9 conda activate sumo_dqn pip install traci sumo这里用 Python 3.9 是因为 traci 在 3.10 以后的版本里偶发 socket 超时问题3.9 最省心。pip install traci sumo装的是 PyPI 上的 traci 客户端和 sumo 工具包但注意这只包含 Python 侧的 traci 库SUMO 仿真引擎本体需要单独安装。SUMO 本体下载地址在 sumo 官网安装时把安装目录里的tools路径加到环境变量。验证 traci 能不能连上 SUMO 的最快办法python -c import traci; print(traci.version)能打印出版本号就说明 Python 侧没问题。接下来用 sumo 自带的netedit或者写一个简单的.net.xml路口文件来建立测试路网。一个最简单的十字路口网络文件可以手工写也可以用netgenerate生成netgenerate --grid --grid.number1 --grid.length200 --output-filegrid.net.xml这条命令生成一个 200 米见方的单网格路网正好是一个十字路口。--grid.number1控制网格数量--grid.length控制每条路长度单位是米。生成后用sumo-gui grid.net.xml打开能看见四条路交叉的形状路名默认是gneE0、gneE1这样的编号。2.2 用 traci 把一个信号灯跑成一个可控制的仿真真正的路口仿真还需要流量定义和路由文件但先用一个最小的连接测试来验证 traci 能读取信号灯状态。以下是连接并读取一个红绿灯相位的最小脚本import traci sumo_binary sumo-gui sumo_cmd [sumo_binary, -c, sumo_config.sumocfg, --start] traci.start(sumo_cmd) step 0 while step 3600: traci.simulationStep() tl_id traci.trafficlight.getIDList()[0] current_phase traci.trafficlight.getPhase(tl_id) if step % 100 0: print(fstep{step}, 相位{current_phase}) step 1 traci.close()traci.start传入sumo_config.sumocfg配置文件这是 SUMO 仿真的总入口里面指定路网文件、路由文件、仿真时长。traci.simulationStep()每调用一次仿真前进一秒。getPhase返回当前信号灯处于哪个相位索引这个索引对应.net.xml里tlLogic定义的相位列表。这里需要理解 SUMO 的时间机制仿真时间和墙上时间不是 1:1simulationStep()推进的是仿真秒DQN 每次决策之间的间隔取决于你调多少次simulationStep()。2.3 从 netedit 手搓一个带信号灯的路口如果不满足于 netgenerate 的默认路口用 netedit 手动画更接近真实场景。打开netedit创建四条双向四车道道路然后在Traffic Light模式下添加信号灯相位按“南北直行→南北左转→东西直行→东西左转”设置。每个相位的绿灯时长为 30 秒黄灯 3 秒。保存后检查.net.xml中tlLogic部分type 属性默认是static要改成actuated。这个细节很重要静态信号灯不会响应 traci 的setPhase命令运行 DQN 时必须用actuated类型才能让外部控制接管相位切换。验证接管是否成功traci.trafficlight.setPhase(tl_id, 2) print(traci.trafficlight.getPhase(tl_id)) # 输出 2如果输出跟设置一致说明外部控制通路已打通。这一步出错最常见的原因是.net.xml里的 signal 定义和路口边绑定关系不对traci 会抛出traci.exceptions.TraCIException: Traffic light is not defined的错误。3. 状态、动作与奖励把信号灯优化写成强化学习问题3.1 状态空间用车道队列长度与等待时间构造观测DQN 进入迭代之前必须先定义状态。在 SUMO 里可以实时获取每一个车道的车辆 ID 列表、每辆车的等待时间、当前位置和速度。最常见的状态构造方式是取路口四个入口车道的“队列长度”和“最大等待时间”。def get_state(): state [] lane_ids traci.lane.getIDList() for lane in lane_ids: # 停在车道上速度小于0.1m/s的车辆数作为队列长度 vehicles traci.lane.getLastStepVehicleIDs(lane) queue_len 0 total_wait 0 for veh in vehicles: speed traci.vehicle.getSpeed(veh) if speed 0.1: queue_len 1 total_wait traci.vehicle.getWaitingTime(veh) state.append(queue_len) state.append(total_wait) return statetraci.lane.getLastStepVehicleIDs返回该车道当前仿真步内的车辆 ID速度小于 0.1m/s 的视为静止排队。getWaitingTime返回车辆累计等待秒数这个值会一直累加直到车辆移动超过 0.1m/s 才清零。state列表长度为车道数乘以 2四个路口入口各两条车道的话就是 16 维。这几个特征要一起用。只用队列长度会导致 DQN 只顾放行最长队列忽略了旁边已经等了很久的车只用等待时间则无法感知压力大的来车方向。两个特征拼接模型能同时学到“哪里堵”和“谁等太久”。3.2 动作空间相位切换不是单纯的换灯动作空间有两种设计。第一种是“相位保持/切换”动作 0 表示维持当前相位动作 1 表示切换到下一个相位周期固定。第二种是“直接选择相位”动作索引对应固定相位列表每次动作直接指定放行哪个相位。第二种实现简单但有一个问题任意切换会导致相位跳变比如从南北直行直接跳到东西直行中间没有过渡相位仿真里会出现大量车辆在路口中间冲突。常见做法是给每个动作关联一个相位序列强制经过黄灯相位。# 相位列表0南北直行 1南北左转 2东西直行 3东西左转 4黄灯 PHASE_SEQUENCE { 0: [0, 4], # 南北直行后跟黄灯过渡 1: [1, 4], 2: [2, 4], 3: [3, 4], }PHASE_SEQUENCE让动作先执行目标相位再强制插入黄灯相位。黄灯时长在.net.xml的tlLogic里定义traci 可以通过setPhaseDuration动态调整。实际控制时DQN 输出动作代码把PHASE_SEQUENCE[action]里的相位依次推给 SUMO。3.3 奖励函数等待时间与通行量如何平衡奖励函数是 DQN 收敛的关键。只奖励通行车辆数信号灯会学出让车流快速通过但不顾行人安全的策略只惩罚平均等待时间则可能一直放行少数等了很久的车全局效率反而下降。我在项目中用的奖励是”等待时间变化量 通行奖励“的组合import numpy as np def get_reward(prev_total_wait, current_total_wait, passed_count): # 总等待时间减少 - 正奖励增加 - 负奖励 delta_wait prev_total_wait - current_total_wait reward delta_wait * 0.5 passed_count * 2.0 return np.clip(reward, -10, 10)prev_total_wait是上一个决策时刻所有车道等待时间之和current_total_wait是当前值。passed_count是这段时间内通过停车线的车辆数通过traci.lane.getLastStepVehicleNumber在信号灯切换前后各取一次差值计算。系数0.5和2.0需要根据仿真规模测试调整。奖励值裁剪到 [-10, 10] 能防止某个极端拥堵瞬间产生过大梯度导致经验回放里单个样本主导网络更新。4. 实现 DQN 训练循环网络结构、经验回放与 epsilon 衰减4.1 DQN 网络输入层、隐藏层与输出层的设计网络结构不用堆太深状态维度是 16动作数量是 4一个两层的 MLP 足够。交通信号灯状态观测相对简单卷积网络在这里没有用武之地。import torch import torch.nn as nn class DQN(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim128): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, x): return self.net(x)state_dim就是状态向量的维度前面设计的 16 维。action_dim是可选动作数4 个。hidden_dim取 128 是为了在路口规模扩大时仍有足够容量如果再大意义不大反而增加训练耗时。输出层没有加 softmaxDQN 输出的是每个动作的 Q 值估计选择动作时用argmax或者按 epsilon 策略随机采样。4.2 经验回放与 target 网络稳定训练的两个必需品初学者最容易忽略 target 网络。如果用同一个网络计算当前 Q 值和目标 Q 值每次更新都会把目标值往当前值方向推网络会震荡甚至发散。target 网络本质上是一个冻结参数的副本每隔固定步数才同步一次让目标值在一段时间内保持稳定。import random from collections import deque class ReplayBuffer: def __init__(self, capacity20000): self.buffer deque(maxlencapacity) def push(self, state, action, reward, next_state, done): self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size): batch random.sample(self.buffer, batch_size) states torch.FloatTensor([b[0] for b in batch]) actions torch.LongTensor([b[1] for b in batch]) rewards torch.FloatTensor([b[2] for b in batch]) next_states torch.FloatTensor([b[3] for b in batch]) dones torch.FloatTensor([b[4] for b in batch]) return states, actions, rewards, next_states, dones回放容量capacity20000意味着最多保留最近两万次转换。一次完整的仿真步进产生一条经验一个 3600 秒的仿真能产生 3600 条经验20000 的容量足够覆盖几个完整回合。target 网络更新target_net DQN(state_dim, action_dim) eval_net DQN(state_dim, action_dim) target_net.load_state_dict(eval_net.state_dict()) TARGET_UPDATE_FREQ 500 # 每500步同步一次同步频率取 500 步比较合理。太频繁 target 网络退化成 eval 网络失去稳定作用太稀疏则目标值长期不更新收敛速度变慢。4.3 训练主循环SUMO 步进、决策与反向传播如何协作训练循环的核心是“仿真推进 → 获取状态 → 决策 → 执行相位 → 计算奖励 → 存入回放 → 网络更新”这个闭路循环。以下是完整训练循环骨架import traci import random import numpy as np import torch import torch.nn.functional as F # 超参数 BATCH_SIZE 64 GAMMA 0.99 EPS_START 1.0 EPS_END 0.05 EPS_DECAY 0.995 LR 1e-4 UPDATE_FREQ 200 # 网络更新间隔 eval_net DQN(state_dim16, action_dim4) target_net DQN(state_dim16, action_dim4) target_net.load_state_dict(eval_net.state_dict()) optimizer torch.optim.Adam(eval_net.parameters(), lrLR) replay_buffer ReplayBuffer(20000) epsilon EPS_START step_count 0 # 每个 episode 重载一次仿真 for episode in range(200): traci.load([sumo_config.sumocfg, --start]) state get_state() prev_total_wait np.sum(state[1::2]) # 取等待时间部分求和 total_episode_reward 0 while traci.simulation.getMinExpectedNumber() 0: # epsilon-greedy 动作选择 if random.random() epsilon: action random.randint(0, 3) else: with torch.no_grad(): q_values eval_net(torch.FloatTensor(state).unsqueeze(0)) action torch.argmax(q_values).item() # 执行相位序列 for phase in PHASE_SEQUENCE[action]: traci.trafficlight.setPhase(tl_id, phase) for _ in range(PHASE_DURATION): # 每个相位保持固定时长 traci.simulationStep() next_state get_state() current_total_wait np.sum(next_state[1::2]) passed_count traci.lane.getLastStepVehicleNumber(traci.lane.getIDList()[0]) reward get_reward(prev_total_wait, current_total_wait, passed_count) done traci.simulation.getMinExpectedNumber() 0 replay_buffer.push(state, action, reward, next_state, done) state next_state prev_total_wait current_total_wait total_episode_reward reward step_count 1 # 经验足够时开始学习 if len(replay_buffer.buffer) BATCH_SIZE: states, actions, rewards, next_states, dones replay_buffer.sample(BATCH_SIZE) q_values eval_net(states).gather(1, actions.unsqueeze(1)) next_q_values target_net(next_states).max(1)[0].detach().unsqueeze(1) target_q_values rewards.unsqueeze(1) GAMMA * next_q_values * (1 - dones.unsqueeze(1)) loss F.smooth_l1_loss(q_values, target_q_values) optimizer.zero_grad() loss.backward() optimizer.step() # 定时同步 target 网络 if step_count % TARGET_UPDATE_FREQ 0: target_net.load_state_dict(eval_net.state_dict()) epsilon max(EPS_END, epsilon * EPS_DECAY) print(fEpisode {episode1}: reward{total_episode_reward:.2f}, eps{epsilon:.3f}) traci.close()关键超参数说明BATCH_SIZE64是经验回放每次采样的样本数太小梯度噪声大太大训练慢。GAMMA0.99表示远期奖励的折扣率交通信号灯控制里未来 10 秒的状态对当前决策有参考价值0.99 较合适。EPS_START1.0到EPS_END0.05是探索率的起止值训练初期完全随机探索后期充分开发学到的策略。EPS_DECAY0.995控制探索衰减速度200 个 episode 训练结束后 epsilon 大约降到 0.37。LR1e-4学习率设置较低因为 DQN 训练不稳定学习率太高容易震荡。5. 训练避坑不收敛、相位错乱与仿真卡死的常见问题5.1 相位索引错位getPhase 返回的值和预期不一致现象是训练过程中信号灯明明应该放行南北方向但 GUI 里东西方向的车在走。查getPhase返回值发现和.net.xml里tlLogic定义的不同。原因是对tlLogic里 phase 的索引顺序理解错误。SUMO 里相位索引从 0 开始按声明顺序排列但tlLogic里的 state 字符串是按每条连接边写的如果不清楚内部边的排列顺序很容易把第 2 个 phase 当成东西直行实际上它是南北直行后的黄灯过渡态。解决方法是先跑一段开环测试打印所有相位索引对应的 statetl_id traci.trafficlight.getIDList()[0] num_phases traci.trafficlight.getPhaseNumber(tl_id) for i in range(num_phases): traci.trafficlight.setPhase(tl_id, i) state_str traci.trafficlight.getRedYellowGreenState(tl_id) print(fphase{i}, state{state_str})getRedYellowGreenState返回每个信号灯组的红黄绿状态字符串对照.net.xml里的道路方向就能写出准确的PHASE_SEQUENCE映射。5.2 训练不收敛损失函数震荡或奖励长期不变现象是 loss 曲线一直在 0.5 到 2 之间来回跳动每个 episode 的总奖励没有上升趋势。排除网络结构问题后先检查奖励是否太稀疏。交通信号灯场景的奖励密度比 CartPole 低很多。一个 30 秒的相位执行期间simulationStep()被调用了 30 次但 DQN 只在相位结束时收到一个奖励信号。如果相位时长为 30 秒奖励信号间隔太大模型很难把“上一个动作”和“现在的奖励”关联起来。解决方法是缩小决策周期。把相位持续时间从 30 秒降到 10 秒DQN 每 10 秒做一次决策奖励信号密度提升 3 倍。还能让策略更灵活地应对突发车流。代价是相位切换频率增加黄灯次数变多实际通行效率会有轻微损失。5.3 经验回放采样越界buffer 不够大导致部分经验被覆盖现象是训练到中途报错IndexError: list index out of range或者是性能骤降。原因是ReplayBuffer的capacity20000相对较大但traci.load重载仿真后旧经验还在 buffer 里。如果路网结构变化这些旧经验成了错误样本。更好的做法是每个 episode 开始时清空 buffer或者用较小的 buffer 容量。交通路网固定时旧经验只是车流分布的差异不必清空但路网文件改动后必须清空否则模型会学到两个路网下的混合策略。replay_buffer ReplayBuffer(capacity20000)traci.load重载路网后加一行replay_buffer.clear()就能解决。5.4 SUMO 仿真卡死traci 连接超时与步进速度过慢现象是训练跑到一半Python 进程卡住不输出日志任务管理器里 sumo-gui 占用 CPU 100%。原因可能是 GUI 渲染拖慢仿真速度。强化学习训练需要跑几千个仿真小时用 sumo-gui 每秒只能推进几十步用无界面模式sumo每秒能推进几百步。另一个原因是traci.start时没有设置--no-warnings大量警告信息刷屏拖慢进程。切换成命令行模式sumo_cmd [sumo_binary, -c, sumo_config.sumocfg, --no-warnings, --quit-on-end]用sumo替代sumo-gui输出日志重定向到文件训练速度能提升 3 到 5 倍。5.5 相位时长固定导致的策略僵化现象是训练后期策略稳定了但总奖励到不了期望值观察 GUI 发现信号灯每隔固定时长就切换没有真正自适应车流。原因是PHASE_DURATION在代码里写死成常数。DQN 只能选择“切换到哪个相位”但无法控制“这个相位保持多久”策略的表达能力受限。改进思路是让 DQN 的每个动作对应一个“相位保持时长区间”。比如动作 0 表示当前相位置续 15 秒动作 1 表示 25 秒动作 2 表示 35 秒。这样从 4 个动作变成 8 个动作训练难度稍微增加但策略能学到“车多时多放行一会儿”的自适应行为。6. 从能跑到可靠三个验证技巧与我的调参习惯第一个验证技巧用固定配时做 baseline。用一个同样路网、同样车流文件、固定 30 秒相位时长的静态信号灯方案跑同样的时长记录平均等待时间和排队长度。然后让 DQN 方案跑同样的车流对比两个指标。我习惯每次训练完保存一个 CSV 结果的对比表import pandas as pd # 从 SUMO 输出文件读取 tripinfo 数据 df pd.read_xml(tripinfo.xml, stylesheetflatten.xsl) df[[waitingTime, duration]].mean()tripinfo.xml是 SUMO 仿真结束后自动生成的文件里面记录每辆车的启动延迟、等待时间、行驶时长。固定配时方案和 DQN 方案各跑一次分别读取这个文件求平均就是最客观的对比。第二个验证技巧是多次随机种子训练。DQN 的结果受初始化影响很大一次训练跑得好不一定是策略好。我会把随机种子固定三个不同值每个值跑完整的 200 个 episode取三次结果的中位数作为最终指标。如果三次训练的结果差异超过 20%优先排查奖励函数和超参设置而不是继续调网络结构。第三个技巧是用sumo-gui --start在训练完成后回放最优模型。保存训练好的模型参数torch.save(eval_net.state_dict(), dqn_signal_net.pth)再用回放脚本加载模型关闭 epsilon 探索在 GUI 里观察信号灯行为是否合理。这一步能发现数值指标发现不了的问题——比如模型学到频繁切换相位来刷奖励或者在某个方向持续放行导致另一方向车辆饿死。我的调参习惯是优先调EPS_DECAY和PHASE_DURATION这两个参数对收敛速度影响最大网络结构和学习率反而放在后面调。尝到甜头之后也别急着上复杂路网先在单个路口把策略调到稳定再考虑多路口协同。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →