☰
SUMO+DQN实现交通信号灯自适应配时:从环境搭建到训练避坑
2026/9/28 6:30:48 网站建设 项目流程

简介:基于Python的Sumo交通仿真与DQN强化学习源码,围绕交通信号灯相位时间动态调整问题设计,适合计算机、人工智能、自动化等专业学生完成课程设计、期末大作业或毕业设计。项目以Sumo为仿真环境,利用DQN算法优化信号配时,代码经调试可正常运行,答辩评分98分,具备较高参考价值。压缩包共32个文件,包含16个XML配置文件(仿真路网、流量、检测器)、6个OSM地图文件、5个Python脚本(DQN主程序、强化学习逻辑、辅助工具)以及xlsx数据表和说明文档,包体约536KB,已有80人浏览学习。项目整体结构清晰,从地图导入、路网生成到模型训练均有对应脚本,便于理解Sumo与强化学习结合的全流程;可在现有框架上修改奖励函数或网络结构,扩展自适应控制实验,附有README说明与原始数据,适合作为期末大作业或毕设起点。

1. 从固定配时到自适应配时:这个SUMO+DQN信号灯项目到底在解决什么

每天早晚高峰,固定配时信号灯把南进口排成长队、北进口放成空路的画面,几乎是城市交通里最典型的资源错配。基于Python开发、用SUMO作为仿真平台、采用强化学习DQN调整交通信号灯相位时间的源码项目,就是为这类问题而做的:信号灯不再背着一张死时间表,而是根据实时排队信息自己决定“当前相位放多久、下一相位切给谁”。

这个方向的落地链路很完整,从交通仿真到强化学习训练再到策略评估,每一步都有明确产出。适合三类人:刚入门强化学习、不想只跑CartPole这类玩具环境的新手;需要在课程设计或毕设里完整交付“环境+算法+训练+分析”流程的学生;以及想量化验证“自适应配时到底比固定配时值不值”的交通算法工程师。

先说结论:这个项目真正有价值的地方不在DQN本身,而在环境封装和奖励函数设计。模型结构三五十行就能写完,但如果你没把SUMO的相位切换逻辑、状态观测和奖励对齐,训练出来的策略很可能连最普通的固定配时都打不过。后面几章就按“环境→算法→训练→排错→评估”的顺序把这套链路拆开讲。

2. 搭建SUMO仿真环境:最小交叉口从路网建模到TraCI打通

2.1 为什么是SUMO:交通仿真平台选型的一个理由

做交通信号灯强化学习,选型时基本是VISSIM、SUMO、MATLAB/SimEvent三选一。VISSIM微观仿真能力很强,但商业授权和封闭API让它在批量训练和外部控制上都很不友好,学生项目和算法验证基本不会选它;MATLAB的SimEvents更适合排队论层面的抽象,车辆轨迹、跟驰模型、车道拓扑的精度都不够做细粒度信号控制研究。SUMO是开源的微观交通仿真器,每一辆车都有独立的加速度、跟驰模型和行驶轨迹,路网用XML描述,最关键是自带TraCI(Traffic Control Interface)协议,外部Python程序可以通过TCP端口实时读取交通状态、下发信号灯相位指令。

也就是说,强化学习智能体需要的“环境”和“交互通道”,SUMO原生就提供了,不需要自己写车辆动力学或排队模型。这也是标题里选SUMO而不是其他平台的根本原因:它能让你把精力集中在DQN算法和环境设计上,而不是从零造一个交通仿真器。SUMO从1.5到1.20的核心API都保持着比较稳定的兼容性,按新版安装即可,不用纠结旧版本。

2.2 安装和工具链:SUMO本体、Python调用接口

最常用的是apt安装,Ubuntu/Debian系一条命令搞定:

sudo apt install sumo sumo-tools

装完先验证两个东西是否可用:

sumo --version python3 -c "import traci; print(traci.__file__)"

这里有一个新手必踩的坑:SUMO的Python接口不是pip安装的,而是随SUMO本体装好后,由tools目录暴露给Python。很多人在import traci时报ModuleNotFoundError,其实是环境变量没配好。常见做法是把SUMO的tools路径加进PYTHONPATH:

export SUMO_HOME=/usr/share/sumo export PYTHONPATH=$SUMO_HOME/tools:$PYTHONPATH

Windows上装了官方安装包的话,路径通常在C:\Program Files (x86)\Eclipse SUMO\下面,同样把tools目录加进PYTHONPATH。Python版本建议用3.9到3.11之间,3.12以上偶尔会遇到TraCI依赖的兼容问题,退回3.10最省事。

提示:如果import traci失败,先不要急着折腾pip,优先检查SUMO_HOME和PYTHONPATH这两个环境变量,九成问题出在这。

2.3 用netgenerate生成最小交叉口路网:别手写XML

新手最容易犯的错是一上来手写.net.xml。手写节点和边确实可行,但SUMO路网XML里还有车道连接、交叉口内部路径、信号灯相位定义,漏一个字段路网就加载失败或者车辆不走。最稳的办法是用netgenerate命令生成。要做一个十字交叉口,先跑这句:

netgenerate --cross --junction=traffic_light -L=2 --output-file=intersection.net.xml

参数说明:--cross让netgenerate生成一个十字路口;--junction=traffic_light把交叉口强制标记为信号灯控制;-L=2表示每个方向两条车道,一组直行加一组左转;--output-file指定输出路网文件。生成后建议用netedit打开看一眼拓扑和边ID,因为后面写车流文件时要用到真实边名。

交通需求文件是另一个必须配齐的部分,我一般单独写一个rou.xml:

<!-- intersection.rou.xml --> <routes> <vType id="car" accel="2.6" decel="4.5" sigma="0.6" length="5.0" maxSpeed="13.89" color="0.9,0.2,0.2"/> <route id="WE" edges="left_to_center center_to_right"/> <route id="EW" edges="right_to_center center_to_left"/> <route id="NS" edges="bottom_to_center center_to_top"/> <route id="SN" edges="top_to_center center_to_bottom"/> <flow id="f_WE" route="WE" type="car" begin="0" end="1800" period="3.2"/> <flow id="f_NS" route="NS" type="car" begin="0" end="1800" period="5.0"/> </routes>

注意:netgenerate生成的路网里,边的ID一般会形如left_to_center、center_to_right这样,但不同SUMO版本的命名规则可能有差异。最保险的做法是先用netedit打开intersection.net.xml,在边属性面板里确认实际ID,再回填到rou.xml。这一步看起来繁琐,实际上比手写XML快得多。把路网和交通流组织进一个sumocfg配置:

<configuration> <input> <net-file value="intersection.net.xml"/> <route-files value="intersection.rou.xml"/> </input> <time> <begin value="0"/> <end value="3600"/> <step-length value="0.1"/> </time> </configuration>

这三个文件齐了,SUMO仿真就能跑起来。之后所有TraCI控制代码,只需要把intersection.sumocfg的路径传给Python启动命令。

2.4 通过TraCI把SUMO接进Python:最小可运行探测脚本

TraCI的交互模型是“Python作为客户端连上SUMO进程,每调一次traci.simulationStep()推进一个仿真步,然后在这个间隙里读写仿真状态”。先跑一个最小连接脚本验证环境是通的:

import traci SUMO_CMD = ["sumo", "-c", "intersection.sumocfg", "--no-warnings"] traci.start(SUMO_CMD) # 启动SUMO进程并建立TraCI连接 for step in range(60): # 步长0.1s,所以这一步一共跑6秒 traci.simulationStep() if step % 10 == 0: total_wait = sum( traci.edge.getWaitingTime(e) for e in ["left_to_center", "right_to_center", "top_to_center", "bottom_to_center"] ) print(f"sim_step={step}, total_waiting_time={total_wait:.2f}") traci.close()

这里有个关键点:traci.start()的参数不是配置文件路径,而是一条完整命令——[SUMO二进制, "-c", 配置路径, 其他参数]。很多人第一反应是traci.start("intersection.sumocfg"),结果SUMO报错找不到文件。另外,启动时那句--no-warnings一定要加,否则SUMO每步都可能往stderr刷警告,训练日志会被淹没。这个脚本能跑通,说明SUMO、TraCI、路网和车流文件全部就绪,可以开始封装强化学习环境了。

3. 拆解DQN三件套:状态、动作、奖励怎么映射到交通信号灯

3.1 为什么是DQN:从Q表到深度Q网络的关键跃迁

把信号灯控制写成强化学习问题很自然:智能体是信号灯,环境是交叉口和车流,动作是选择相位,奖励是通行效率。如果状态是离散且很小的,经典Q-Learning查表就能解决;但交通场景的状态是连续车流信息——左转排队长度、直行车道占有率、当前相位运行时间,组合起来状态数爆炸,Q表根本存不下。

DQN的思路是用神经网络近似Q函数Q(s,a),输入状态s,输出所有动作的Q值。它有两个论文里反复强调的组件:经验回放和目标网络。经验回放把历史转移样本存进缓冲区,训练时随机采样,打断样本之间的时间相关性,同时提高数据利用效率;目标网络单独维护一份参数,让Q_target的计算不随当前网络每一步波动,而是每固定步同步一次,缓解自举造成的发散。核心的贝尔曼目标就是:

Q(s,a) = r + γ · max(a') Q_target(s',a')

这个公式会在后面所有代码里反复出现。理解它之后再看代码,就能明白每一行在算什么。

3.2 状态空间:把路口能看见的车流信息变成向量

状态怎么设计,决定信号灯“能看到什么”。单路口控制里性价比最高的观测项是排队长度,其次是平均速度和车道占有率。下表是一组我在源码里常看到的观测映射:

状态分量TraCI读取接口归一化方式
东进口排队长度traci.edge.getWaitingTime("left_to_center")/60秒
南进口排队长度traci.edge.getWaitingTime("bottom_to_center")/60秒
西进口排队长度traci.edge.getWaitingTime("right_to_center")/60秒
北进口排队长度traci.edge.getWaitingTime("top_to_center")/60秒
当前相位编号traci.trafficlight.getPhase("0")/相位总数
相位已运行时间traci.trafficlight.getPhaseDuration("0")/最大绿灯时长

归一化是DQN训练里非常容易被忽略的一环。神经网络对输入尺度敏感,把等待时间这种0到几百的值直接喂进去,会让网络前几层的梯度被大数主导。除以一个经验阈值压到0~1区间,训练稳定性会明显改善。我习惯把阈值取成略大于“单方向最大可容忍等待”,这样正常拥堵区间基本落在0~1内。

第一版建议只用4条进道口的等待时间加当前相位编号,组成6维状态就够了。平均速度和占有率可以等基础版本跑通后再加,状态维度越高,探索难度越大,收敛越慢。

3.3 动作空间:相位选择与相位时长两种建模的区别

动作是信号灯能做的最原始决策。第一类做法把动作定义为“当前相位持续时间档位”,比如持续5秒、10秒、15秒三档;第二类做法把动作定义为“选择下一个要运行的相位”。后者更好用,因为交通信号灯的控制逻辑本来就以相位为单位,且动作空间小。四相位标准路口,动作空间就是4。

有一个常见设计是把动作退化成“保持当前相位或切换下一个相位”的二值动作,智能体每5秒选一次。这个设计动作空间最小,但收敛慢——切换动作太稀疏,模型要试很多次才能学到“什么时候该换”,而且容易陷入频繁切换或一直不动的振荡。我更推荐直接选相位,加上最小绿灯约束。实现上,相位一旦切换,至少维持decision_interval秒;决策时间粒度也等于这个最小维持时长。动作空间从2扩到4,但每个动作都保证有效绿灯时间,学习效率反而高。

3.4 奖励函数:差的奖励让DQN学到“看起来正确”的坏策略

奖励是整个工程里最玄学也最决定成败的地方。如果只给reward = -全局等待时间,模型确实会尝试降低等待,但它一个更简单的解法是——把某几个方向长期放行,另几个方向堵到天荒地老。因为惩罚按总等待算,死锁一个方向对总值影响有限,模型不会去消除堵车,只会“把车赶到一个不碍眼的角落”。

实际验证下来最好用的是差分等待奖励:

r(t) = -(W(t) - W(t-1))

其中W(t)是当前时刻所有进道口等待时间之和。这样奖励只反映“本轮相比上一轮改善了多少”,模型必须让所有方向排队持续下降才能拿到正奖,单纯切换相位的操作没有收益。再叠加一个通过车辆数的正激励,效果更好:

r(t) = α × passed_vehicles - β × (W(t) - W(t-1))

α取0.5~1,β取1~2。γ折扣因子也在这里定:信号灯控制是长期决策问题,当前放行一个方向会同时影响下一个相位周期的排队状态,γ=0.95是个好起点,再小模型会变短视,再大训练方差上升。

4. 源码架构与训练流程:从环境封装到DQN智能体的4个关键文件

4.1 文件结构与职责边界

先看整个项目怎么组织。一个能跑通的版本,文件不必多,但职责要分清楚:

文件/目录职责
sumo_files/intersection.net.xmlSUMO路网文件,netgenerate生成
sumo_files/intersection.rou.xml交通流需求文件
sumo_files/intersection.sumocfgSUMO启动配置
env/sumo_env.py把SUMO封装成Gym风格环境,负责reset/step/reward
agent/networks.pyQ网络与目标网络定义
agent/dqn.pyDQNAgent类,含ReplayBuffer和执行梯度更新
train.py主训练入口,跑episode循环

分层原则很简单:环境代码不写神经网络的逻辑,智能体代码也不直接调TraCI。环境只负责回答“我现在是什么状态、执行这个动作后世界变成什么样、给多少奖励”,智能体只负责回答“面对这个状态该选哪个动作、这条经验怎么更新参数”。边界画清楚,后面换奖励函数、换Double DQN都不需要动另一侧代码。

4.2 环境封装:把SUMO包装成Gym风格的step/reset接口

沿用Gymnasium的接口约定,reset()拉启一个全新的SUMO仿真进程,step()执行一次动作并推进n个仿真步长。代码里我把观测简化为6维,保持模型规模可控:

# env/sumo_env.py import traci import numpy as np import gymnasium as gym PHASE_NUM = 4 INLET_EDGES = ["left_to_center", "bottom_to_center", "right_to_center", "top_to_center"] class SumoTLEnv(gym.Env): def __init__(self, sumo_cfg, decision_interval=5.0, max_decisions=360): super().__init__() self.sumo_cfg = sumo_cfg self.decision_interval = decision_interval # 同时是最小绿灯时长 self.max_decisions = max_decisions # 一个episode最多决策次数 self.action_space = gym.spaces.Discrete(PHASE_NUM) self.observation_space = gym.spaces.Box(low=0, high=1, shape=(6,)) self.decisions = 0 self.prev_wait = 0.0 self.total_wait = 0.0 def reset(self): traci.start(["sumo", "-c", self.sumo_cfg, "--no-warnings", "--quit-on-end"]) self.decisions = 0 self.prev_wait = 0.0 self.total_wait = 0.0 traci.trafficlight.setPhase("0", 0) return self._get_obs() def step(self, action): traci.trafficlight.setPhase("0", int(action)) # 直接切换相位 for _ in range(int(self.decision_interval / 0.1)): traci.simulationStep() # 维持该相位一段时间 obs = self._get_obs() reward = self._compute_reward() self.decisions += 1 done = (self.decisions >= self.max_decisions) return obs, reward, done, {} def _get_obs(self): wait = [] for edge in INLET_EDGES: wait.append(traci.edge.getWaitingTime(edge) / 60.0) current_phase = traci.trafficlight.getPhase("0") / float(PHASE_NUM) return np.array(wait + [current_phase], dtype=np.float32) def _compute_reward(self): now_wait = sum(traci.edge.getWaitingTime(e) for e in INLET_EDGES) diff_wait = now_wait - self.prev_wait self.prev_wait = now_wait self.total_wait += now_wait # 等待时间增加越多惩罚越重,排队减少则给正奖 return -diff_wait / 100.0

逻辑说明:_get_obs返回6维状态,4条进道口等待时间加当前相位编号,等待时间除以60做归一化。step里先调用TraCI的setPhase切换相位,再在decision_interval内循环推进仿真步,这个循环同时实现了最小绿灯约束。_compute_reward按差分等待计算,除以100控制数值量级。max_decisions=360对应真实世界30分钟(360×5秒),足够一个episode学到路口控制的基本节奏。

4.3 DQN智能体:网络结构、经验回放、目标网络

网络用PyTorch写一个三层MLP。状态6维、动作4维,网络结构就是6→128→128→4。这个规模对单路口完全够用,不要一上来就堆几百宽度的巨型网络,交通信号灯问题没有这么高的状态复杂度,模型大了反而过拟合仿真路网:

# agent/networks.py import torch import torch.nn as nn class QNetwork(nn.Module): def __init__(self, state_dim=6, action_dim=4, hidden=128): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim), ) def forward(self, x): return self.net(x)

经验回放和更新逻辑:

# agent/dqn.py from collections import deque import random import numpy as np import torch import torch.nn.functional as F from agent.networks import QNetwork class ReplayBuffer: def __init__(self, capacity=50000): self.buffer = deque(maxlen=capacity) def push(self, s, a, r, s2, done): self.buffer.append((s, a, r, s2, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) s, a, r, s2, done = map(np.array, zip(*batch)) return s, a, r, s2, done class DQNAgent: def __init__(self, state_dim=6, action_dim=4, lr=1e-4, gamma=0.95, target_update_steps=500): self.q = QNetwork(state_dim, action_dim) self.q_target = QNetwork(state_dim, action_dim) self.q_target.load_state_dict(self.q.state_dict()) self.optimizer = torch.optim.Adam(self.q.parameters(), lr=lr) self.gamma = gamma self.buffer = ReplayBuffer() self.learn_steps = 0 self.target_update_steps = target_update_steps def act(self, obs, epsilon): if random.random() < epsilon: return random.randint(0, 3) obs_t = torch.FloatTensor(obs).unsqueeze(0) with torch.no_grad(): q_vals = self.q(obs_t) return int(q_vals.argmax(dim=1).item()) def update(self, batch_size=128): if len(self.buffer.buffer) < batch_size: return None s, a, r, s2, done = self.buffer.sample(batch_size) s = torch.FloatTensor(s) a = torch.LongTensor(a).unsqueeze(1) r = torch.FloatTensor(r).unsqueeze(1) s2 = torch.FloatTensor(s2) done = torch.FloatTensor(done).unsqueeze(1) q_sa = self.q(s).gather(1, a) with torch.no_grad(): q_next = self.q_target(s2).max(dim=1, keepdim=True).values target = r + self.gamma * q_next * (1.0 - done) loss = F.mse_loss(q_sa, target) self.optimizer.zero_grad() loss.backward() self.optimizer.step() self.learn_steps += 1 if self.learn_steps % self.target_update_steps == 0: self.q_target.load_state_dict(self.q.state_dict()) return float(loss.item())

逻辑说明:act在epsilon概率下随机探索,否则走前向网络拿argmax动作。update里最关键的一行是target = r + gamma * q_next * (1.0 - done)——done为1的终止状态会把未来收益清零,防止Q值高估。如果漏掉(1.0 - done),训练末期Q值会严重虚高,表现就是loss不降、策略振荡。

注意:done数组在目标值计算里必须参与,否则Q值高估是必然的,只是时间早晚问题。

target_update_steps如果设太小(比如50),目标网络跟着当前网络频繁波动,训练容易发散;设太大(比如5000),前期目标基本不动,学习慢。500是一个经过多个项目验证的折中值。

4.4 主训练循环:一个episode的完整生命周期

训练循环把环境和智能体接起来。每轮episode重置环境、按ε-greedy选动作、把转移样本推入缓冲、做一次网络更新:

# train.py from env.sumo_env import SumoTLEnv from agent.dqn import DQNAgent EPISODES = 200 BATCH_SIZE = 128 EPS_START, EPS_END, EPS_DECAY = 1.0, 0.05, 0.995 def train(): env = SumoTLEnv("sumo_files/intersection.sumocfg") agent = DQNAgent(state_dim=6, action_dim=4, lr=1e-4, gamma=0.95, target_update_steps=500) epsilon = EPS_START for episode in range(EPISODES): obs = env.reset() total_reward, decisions = 0.0, 0 done = False while not done: action = agent.act(obs, epsilon) obs_next, reward, done, _ = env.step(action) agent.buffer.push(obs, action, reward, obs_next, done) agent.update(BATCH_SIZE) obs = obs_next total_reward += reward decisions += 1 epsilon = max(EPS_END, epsilon * EPS_DECAY) avg_wait = env.total_wait / max(decisions, 1) print(f"ep {episode:3d} | steps {decisions:3d} | " f"reward {total_reward:8.2f} | avg_wait {avg_wait:7.2f} | " f"eps {epsilon:.3f}") if __name__ == "__main__": train()

逻辑说明:agent.update(BATCH_SIZE)在缓冲区不足时直接跳过,这是常见做法,避免训练早期强行动梯度。total_reward整体为负是正常的,因为差分等待的平均值天然为负,观察它的变化趋势比看绝对值更有意义。运行200个episode后,reward在后期应比前期更接近0,avg_wait逐步下降,说明策略在变好。

4.5 训练监控:不要只看打印,要看曲线

只打印一行数字很难判断训练质量。建议把每个episode的total_reward、avg_wait和loss追加到csv,有条件就接TensorBoard的SummaryWriter。判断收敛看三个波形特征:reward曲线整体上升后在某个区间震荡;avg_wait从几百秒降到几十秒后趋平;loss在前1万个学习步内先降,之后进入窄幅波动平台。如果loss降了但avg_wait没降,优先怀疑奖励函数设计,其次怀疑环境代码里setPhase和decision_interval是否真的生效——这两个问题在下一章用具体的翻车案例展开。

5. SUMO+DQN训练避坑与排查:五个容易让项目返工的高频问题

训练排错是最耗时间的部分。下面五条都是我在实际跑SUMO+DQN时翻过车、又逐个定位到原因的问题,每一条按现象、原因、解决三个步骤写,可以直接对照你自己的日志来判断。

5.1 现象:loss先降后涨,训练越跑越崩

前几万个学习步loss下降很漂亮,之后突然大涨,甚至出现NaN。原因基本指向目标网络更新太快或学习率过高。目标网络要给出稳定的target,如果每50步就同步一次,target跟着当前网络一起漂,模型等于在追一个会跑的靶子。学习率1e-4到3e-4对DQN是安全区间,超过1e-3基本会炸。

解决:把target_update_steps调到500~1000,学习率降到1e-4,batch_size提到128后重训。如果还想更稳,把硬同步改成软更新:每次学习都让目标网络参数朝当前网络挪一点,q_target = tau * q + (1 - tau) * q_target,tau取0.005~0.01。改动不大,却能把曲线压得平很多。

5.2 现象:所有车辆越堵越死,信号灯却没有救

训练几百个episode后,路网上出现死锁:某个方向的绿灯一直放行,其他方向排队到溢出,甚至车流完全静止。第一嫌疑是环境没加最小绿灯约束。如果智能体每0.1秒仿真步就能切换一次相位,它会学到“频繁切换能立刻改变状态、引流某个方向的压力”这种短期策略,但车辆刚起步就被切换相位,通过率极低,全局等待时间快速上升。

解决:在环境里强制最小绿灯时长。上文SumoTLEnv的decision_interval=5.0就是这个约束——相位一旦选定,至少保持5秒,仿真步才往下走。常见取值在5到10秒之间,太短(2秒)起不到通过车队的作用,太长(20秒)会让动作分辨率太粗,模型来不及响应突发车流。按城市路口饱和流率粗算,5秒大约能让2~3辆车通过,是一个经验上有效的下限。

5.3 现象:一个episode跑几分钟,训练慢到没法迭代

用sumo-gui跑训练,一个3600秒仿真、步长0.1秒意味着36000步仿真,加上画面渲染,一个episode能磨四五分钟,200个episode就是十几个小时起步,这还没算神经网络梯度计算。排查顺序:先看SUMO是不是开着图形界面,再看是不是每个0.1秒仿真步都在做决策。

解决:训练统一用不带gui的sumo命令,只在回放验证时用sumo-gui。把决策粒度从每个仿真步改成每5秒一次,本质上一个决策步少跑49个仿真步,时间开销直接降到约1/50。再把--no-warnings和--quit-on-end参数加上,避免终端IO等待和警告刷屏。如果还慢,把max_decisions缩短到360(真实30分钟),这通常足够学到路口控制的基本节奏。

5.4 现象:训练收敛了,评估时却打不过固定配时

有一种坑是测试时才发现:DQN策略的avg_wait比固定配时高出20%以上。查奖励设计,大概率是只用了全局总等待作为直接惩罚,没考虑方向均衡。模型学到的是“把车堵在一个方向,其他方向放空”,全局总等待依然很小。在差分等待基础之上,把四路等待的方差加进惩罚项,训练难度会略高,但能避免单方向饿死。另一个常见原因是决策间隔太大,比如20秒一次,模型无法响应短时车流波动。

解决:评估先和固定配时跑同一个随机种子,控制变量。奖励函数至少改成r = -diff_wait / 100,输出里同时跟踪每个方向的排队长度,不只看总平均。如果模型已经训到后期,改奖励需要整个重训,这也是为什么前期试奖励函数时要把episode数设小、快速跑几十轮看方向。

5.5 现象:在训练路口有效,换个路口就“废”

模型过拟合是强化学习项目里最隐蔽的问题。单一路口、单一流量模式下训练出的策略,把车道数、路口尺寸、流量比稍改一版,评估结果就掉下来。原因是状态特征用的是绝对值——排队秒数、车辆数,没有相对路口容量做归一化,模型学到的是“等待秒数超过X就切换”,而不是“排队占比超过容量阈值就切换”。

解决:状态里所有量都除以相应车道的最大容量或限速。排队长度除以车道可容纳车辆数,速度除以限速,把绝对值转成0~1的占用率。训练时不要只用一种流量模式,至少混合“东进口高流量”“南北高流量”“双向均衡”三种需求,每个episode随机选一种。如果目标是多路口迁移,就得换架构——用图注意力网络把路口拓扑编码进状态,像CoLight这类以图网络为骨干的多路口控制方法在泛化上明显比单点MLP强,但工程量是另一个量级了。

6. 验证与进阶:评估指标、基准对比和值得投入的后续方向

训练完别急着报数字,先定一套可重复的评估流程:固定随机种子、固定交通需求文件,跑10次取均值。先把固定配时跑出来作为baseline,再把DQN策略加载回去跑同样的需求。核心指标看四类:

指标计算方式合格参考
平均等待时间所有车辆等待时间均值比固定配时低15%以上
平均排队长度四个进道口排队长度均值下降20%~40%
吞吐量统计时间内通过路口车辆总数不低于baseline
95分位等待时间等待时间排在第95百分位的值不应出现单方向饿死

对照表格跑下来,DQN至少要赢得平均等待这一项才值得继续投入。如果只赢一条路、其他指标全输,多半是奖励函数偏科了。

进阶方向按性价比排序,我建议先做三件事:把DQN换成Double DQN,目标Q值计算从max改成“当前网络选动作、目标网络评价值”,能缓解过估计,通常等待时间还能再降几个百分点;第二是优先经验回放,把误差大的样本更频繁地采出来,训练后期提速明显;第三才是多交叉口联合控制——信号灯联动要考虑上下游协调,单点DQN天然做不到,这时图注意力网络和离线强化学习(比如IQL离线学习历史配时数据)是更值得投入的方向,但工程复杂度会从单环境封装上升到多路口环境编排。

我自己的习惯是:先跑固定配时拿基线,再跑DQN,没有10%以上的收益就不换模型。第一次做这个项目时,我把决策间隔设成2秒、还在奖励里漏了方向均衡项,结果策略越训越差,查了三天日志才定位到环境层。条件允许的话,把每一步的相位、等待时间、奖励都记录下来,回放时用sumo-gui开着看一遍车流,基本能看出模型是在学控制还是钻奖励空子。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询