简介:一套基于指针网络与强化学习求解旅行商问题(TSP)的Python代码实现,主要面向对组合优化和深度强化学习感兴趣的开发者、研究者和竞赛学习者。项目采用简化训练策略:不单独实现critic网络,而是将最优路径长度作为critic值;训练样本通过在[0,1]×[0,1]网格上均匀采样二维点生成,并由Concorde求解器给出最优解作为监督信号,整体思路清晰,适合作为相关方向的入门实践。压缩包共14个文件,大小约4.01MB,其中包含8个Python脚本,覆盖模型、训练器、数据加载、配置与工具函数等环节;另有2张结果图片、2个测试数据文件、1份README说明文档和1个gitignore文件,目录结构简洁,便于按需阅读。已有866人学习/下载。通过这套代码,读者可以复现TSP10与TSP50的强化学习训练流程,查看经过100,000步训练后的测试结果,并使用diff指标对比强化学习解与最优解的差距。对于想动手实践深度强化学习、路径规划或组合优化的读者,这份实现提供了可运行的完整示例和清晰的代码组织方式;更详细的依赖配置与使用方法可阅读包内README说明。
1. 用指针网络给 TSP 找一条最短环游,为什么值得自己做一遍
先划定一个具体问题:给定 n 个城市的二维坐标,求一条访问每个城市恰好一次并回到起点的最短闭合路径,这就是经典的 TSP(旅行商问题)。当 n 从 20 涨到 200,精确求解器的耗时立刻变得不可接受,工程上常用的做法是 LKH、OR-Tools 这类启发式搜索,它们快,但每个实例都要重新搜索。指针网络(Pointer Networks)换了一条路:它学习的是"给定坐标,直接输出访问顺序"的映射,训练好之后一次前向传播就能给出一个可用解,不需要在推理时做任何搜索。而配合强化学习训练,模型只需要以路径长度作为奖励,连最优解标签都不用准备——这对入门深度强化学习、想快速看清"端到端组合优化"这套玩法到底行不行的工程师来说,是性价比很高的一个实验。本文会按模型结构、训练方法、完整 Python 实现、最后到验证与调参的顺序,把整条链路摊开讲。
2. 指针网络的结构:把“输出词表”换成“输入下标”
2.1 标准 seq2seq 为什么解不了 TSP:词表是固定大小的
做过机器翻译或文本生成的人对 seq2seq 都很熟:编码器把输入句子编码成一系列隐状态,解码器每一步通过注意力机制从这些隐状态里"汇总"一个上下文向量,再经过一个线性层和 softmax,从固定词表里挑一个词。这里的关键限制在于输出词表的大小是预先固定的。TSP 的输出是 n 个城市的某种排列,城市数量一变,输出维度就得跟着变,更麻烦的是这 n 个"词"的含义就是输入序列本身。你当然可以把坐标喂给编码器、让解码器从一个大词表里选城市 ID,但这个词表要么大到浪费,要么在 n 变化时完全失效。
指针网络的改动非常直接:注意力打分函数不再用来做"加权求和",而是直接输出一个在输入位置上的概率分布,然后取 argmax 或采样作为当前步的输出。换句话说,模型不再说"我选词表里的第 5 个词",而是说"我选输入序列里的第 5 个城市"。这个设计让输出空间与输入序列长度自动对齐,n 变了模型结构也不需要动。从信号处理的角度看,它本质上是 content-based 的硬匹配,比软性注意力更贴合"从一组元素中挑一个出来"这类组合问题。
2.2 最小可运行的 Pointer Network 实现(PyTorch)
指针网络的核心组件是编码器、解码器和打分函数。下面这份代码可以直接保存成 model.py,包含了完整的模型定义和前向逻辑。
import torch import torch.nn as nn import torch.nn.functional as F class PointerNetwork(nn.Module): def __init__(self, embed_dim=128, hidden_dim=256): super().__init__() self.encoder_embed = nn.Linear(2, embed_dim) # 坐标 -> 向量 self.encoder = nn.LSTM(embed_dim, hidden_dim, batch_first=True) self.decoder_embed = nn.Linear(2, embed_dim) self.decoder = nn.LSTM(embed_dim, hidden_dim, batch_first=True) # 注意力打分的三组投影参数 self.W1 = nn.Linear(hidden_dim, hidden_dim, bias=False) self.W2 = nn.Linear(hidden_dim, hidden_dim, bias=False) self.v = nn.Linear(hidden_dim, 1, bias=False) def forward(self, coords, decode_type='greedy'): # coords: [B, n, 2],欧氏坐标,范围 [0,1] B, n, _ = coords.shape enc_emb = torch.relu(self.encoder_embed(coords)) enc_outs, (h, c) = self.encoder(enc_emb) # enc_outs: [B, n, hidden] # 固定城市 0 为起点,解码器第一步从剩余城市里选 inp = torch.relu(self.decoder_embed(coords[:, 0:1, :])) mask = torch.zeros(B, n, device=coords.device) mask[:, 0] = 1 outputs, log_probs = [], [] for step in range(n - 1): _, (h, c) = self.decoder(inp, (h, c)) dec_state = h[-1] # [B, hidden] # 对每个输入位置打分,取概率最大的下标作为输出 scores = self.v(torch.tanh( self.W1(enc_outs) + self.W2(dec_state).unsqueeze(1) )).squeeze(-1) # [B, n] scores = scores.masked_fill(mask.bool(), -float('inf')) probs = F.softmax(scores, dim=-1) if decode_type == 'greedy': idx = probs.argmax(dim=-1) logp = None else: # sampling 训练模式 dist = torch.distributions.Categorical(probs) idx = dist.sample() logp = dist.log_prob(idx) mask = mask.scatter(1, idx.unsqueeze(1), 1.0) outputs.append(idx) log_probs.append(logp) # 把当前选中的城市坐标作为解码器下一步输入 picked = torch.gather(coords, 1, idx.unsqueeze(-1).expand(-1, -1, 2)) inp = torch.relu(self.decoder_embed(picked)) pi = torch.stack(outputs, dim=1) # [B, n-1],不含起点 return pi, log_probs模型前向的流程可以拆成四步看。第一步,把 [B, n, 2] 的坐标经过一个线性层映射成 embedding,再过 LSTM 得到每个城市的编码隐状态。第二步,解码器每一步把上一步选中的城市坐标作为输入,推进 LSTM 得到当前隐状态。第三步,用 W1 和 W2 分别投影编码器输出与解码器状态,相加后过 tanh,再用 v 压缩成标量,这个标量就是该城市"被选中"的分数。第四步,softmax 后直接当作选择概率,贪婪解码时取 argmax,训练时用 Categorical 采样。
这里有几个参数值得注意。embed_dim 是坐标嵌入的维度,一般 128 足够,n 超过 100 时可以升到 256;hidden_dim 是 LSTM 隐层宽度,直接决定了打分函数的表达能力,但也不是越大越好,hidden_dim 增加会让 W1 和 W2 的矩阵变大,注意力计算量随 n 线性增长,显存压力来自这里。mask 的处理方式是把已访问城市的分数置为负无穷而不是把概率置零,这一点后面会单独讲。
2.3 别忘了 mask:这是“每个城市只访问一次”的唯一保障
如果只看 2.2 的代码,最容易被忽略的就是 mask 那一行。TSP 的约束是每个城市恰好访问一次,这意味着模型每一步的输出都不能是已经选过的城市。最简单的做法是维护一个 [B, n] 的 0/1 掩码,选中某城市后通过 scatter 把对应位置置 1。关键点在于 mask 必须作用在 softmax 之前的 scores 上,用-float('inf')把这些位置屏蔽掉。如果先算 softmax 再从概率里强制置 0,这一项在反向传播时仍然会有梯度残留,模型会收到"这个城市虽然不该选,但它的分数也在被优化"的错误信号,训练很难收敛。
我在实际调试中还遇到过一种隐蔽的错误:mask 没有包含起点城市,导致解码器第一步就把起点再选一次。这在训练初期 loss 看起来正常,但最终输出一定是错的。所以代码里mask[:, 0] = 1这行虽然简单,却是整条数据流正确的关键前提。
2.4 静态欧式 TSP 的数据约定与训练/推理模式
这套实现针对的是静态欧式 TSP,即每个实例的城市坐标一次性给出,距离按欧氏距离计算,且实例之间相互独立。与此相对的是动态 TSP,城市会随智能体移动而更新,那种情况需要模型每步重新编码,指针网络的原版结构就不够用了。坐标生成通常用 [0,1] 区间上的均匀分布,这是组合优化领域最常见的 benchmark 约定,路径长度的数值范围也合适,n=20 时最短环游通常在 4 左右。如果坐标范围改成 [0, sqrt(n)],距离量级会变大,梯度尺度也随之变化,一般建议统一用 [0,1]。
这里还有一个容易踩的坑:训练集和测试集应该各自在线生成,而不是事先固定一个数据集。原因在于强化学习的目标是泛化到任意随机实例上,提前固定数据集会让模型记住局部模式。训练时每个 batch 用torch.rand(batch_size, n, 2)重新生成坐标,相当于无限训练集。推理时用 greedy 解码,这和训练时的 sampling 解码要区分开;后面会看到,rollout baseline 用的正是 greedy 解码作为策略评估方式。
3. 策略梯度训练:REINFORCE 与 rollout baseline
3.1 先想清楚:为什么不能直接用交叉熵训练
如果给指针网络配上最优路径标签,完全可以按监督学习训练,用交叉熵让模型输出逼近标签。但这么做有两个问题:一是要先用求解器生成最优标签,n 稍大标签成本就很高;二是监督学习学的是"模仿求解器的输出",而不是直接优化路径长度,求解器的偏好会被模型照单全收。强化学习的思路完全不同:把路径长度作为奖励信号,模型自己探索出比标签更好的解也是允许的。
先把问题写成 MDP。状态是当前已访问城市集合和当前所在城市;动作是选择下一个未访问城市;转移就是把该城市加入已访问集合;奖励在整条轨迹结束时给出,等于环游总距离的负值。策略就是解码器每一步输出的选择概率。轨迹长度固定为 n-1 步,每一步的合法动作由 mask 限定,这个设定让问题变成了一个标准的有限步序贯决策问题。
3.2 REINFORCE with baseline:方差要降,符号不能反
直接优化期望路径长度 J(θ) = E[L(π)],对 θ 求梯度会得到策略梯度。由于 L 是离散排列的确定性函数,不可微,梯度只能通过 log 概率来估计。在最小化路径长度的设定下,策略梯度的更新方向可以用下面这个式子理解:
∇J(θ) = E[ (L(π) - b) · ∇θ log p(π) ]这里的 b 是 baseline,它的作用是降低方差,不改变梯度的期望值。选择 b 的常见做法有两种。一种是训练一个 critic 网络回归当前状态下的期望路径长度,另一种是 rollout baseline,即维护一个稍旧版本的策略,用它的 greedy 解码结果作为 baseline。对于 n=20 到 n=50 这个规模,我建议直接用 rollout baseline,少一个 critic 网络就少一套调参负担,而且大规模实验(比如 Kool 等人后来的 Attention Model)已经验证过 rollout baseline 在小规模 TSP 上稳定且高效。
一个特别容易出错的细节是符号方向。上面公式是"最小化期望长度"的写法,所以 loss 写成log_probs * (L - b)。当采样解比 baseline 好(L 更小)时,L - b 为负,loss.backward() 会增大对应路径的 log 概率,这才是正确的鼓励方向。如果写成b - L,那就是在反向惩罚好解,训练时你会看到平均环长不降反升。
3.3 训练循环代码与核心参数
训练循环需要两个辅助函数:计算环游总长度,以及单步训练。下面是完整的实现,可直接放到 train.py 中。
def compute_tour_length(coords, pi): # pi: [B, n-1],不含起点;把起点 0 拼到头尾后计算欧氏环长 B = pi.size(0) zeros = torch.zeros(B, 1, dtype=torch.long, device=pi.device) full = torch.cat([zeros, pi], dim=1) # [B, n] nxt = torch.cat([full[:, 1:], full[:, :1]], dim=1) # 回到起点 xy1 = coords.gather(1, full.unsqueeze(-1).expand_as(coords)) xy2 = coords.gather(1, nxt.unsqueeze(-1).expand_as(coords)) return (xy1 - xy2).pow(2).sum(-1).sqrt().sum(-1) # [B]compute_tour_length 先把起点 0 拼到头部,再把 full 序列整体左移一位作为下一跳目标,这样构造出的首尾相接序列就是一条完整环游。gather 操作按行取出坐标,逐段计算欧氏距离后求和。需要注意 full 和 nxt 都是 [B, n] 的索引矩阵,full.unsqueeze(-1).expand_as(coords)会把索引广播成与 coords 相同的 [B, n, 2] 形状,gather 才能逐维对齐。
def train_step(model, optimizer, batch_size=512, n_cities=20): coords = torch.rand(batch_size, n_cities, 2, device=device) pi, log_probs = model(coords, decode_type='sampling') log_probs = torch.stack([lp for lp in log_probs], dim=1) # [B, n-1] length = compute_tour_length(coords, pi) with torch.no_grad(): pi_base, _ = model(coords, decode_type='greedy') len_base = compute_tour_length(coords, pi_base) advantage = (length - len_base).detach() loss = (log_probs * advantage.unsqueeze(1)).mean() optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() return loss.item(), length.mean().item(), len_base.mean().item()训练循环里值得关注的点有三个。第一,baseline 的计算放在torch.no_grad()里,greedy 解码本身不需要梯度,也不应该把 baseline 的梯度混入主loss。第二,advantage用了detach(),因为 advantage 只作为加权系数,不需要对长度求导。第三,梯度裁剪用clip_grad_norm_限制在 1.0,这能防止采样中偶发的极端路径造成梯度爆炸,是强化学习训练里几乎必备的一行。
下面是这份实现里最常用的参数组合,可以当作起点,再根据收敛曲线调整。
| 参数 | n=20 建议值 | n=50 建议值 | 说明 |
|---|---|---|---|
| embed_dim | 128 | 128 | 坐标嵌入维度,n 更大时升到 256 |
| hidden_dim | 256 | 256 | LSTM 隐层宽度 |
| batch_size | 512 | 256 | 大 batch 降低策略梯度方差 |
| learning_rate | 1e-3 | 1e-3 | Adam 默认 lr,过大容易震荡 |
| max_norm | 1.0 | 1.0 | 梯度裁剪阈值 |
| 训练步数 | 3000~5000 | 8000~15000 | 观察 avg_len 曲线决定 |
batch_size 对强化学习的影响比监督学习更明显。策略梯度的方差来自采样,batch 越大,advantage 均值的方差越小,训练就越稳定。但显存有限时优先降 batch 而不是降 hidden_dim,因为 batch 影响的是梯度估计质量,hidden_dim 影响的是模型表达能力,两者不可互相替代。
4. 从零跑通 Python 实现:训练、可视化与排错
4.1 工程目录与依赖准备
与其到处找源码包下载,不如按下面的文件结构把工程搭起来,每个文件职责单一,调试时也更容易定位问题。
pointer_nets_tsp/ ├── model.py # PointerNetwork 类与 compute_tour_length ├── train.py # 训练循环、日志打印 ├── eval.py # 生成实例、可视化环游、OR-Tools 对比依赖只有五个:Python 3.10 以上、PyTorch 2.0 以上、numpy、matplotlib、tqdm。验证 gap 时需要额外安装 ortools。CPU 也能跑通 n=20 的最小验证,只是 batch 建议降到 128,训练步数加到 8000 左右;如果机器上有 NVIDIA GPU,一个 4G 显存的卡就能让 n=50 的整个训练跑得很舒服。
4.2 训练入口与日志监控
train.py 的入口部分很短,核心逻辑就是循环调用 train_step。这里的关键是固定随机种子,否则强化学习训练结果很难复现。
import torch from model import PointerNetwork from train import train_step device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') torch.manual_seed(42) model = PointerNetwork().to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for step in range(5000): loss, avg_len, base_len = train_step(model, optimizer) if step % 200 == 0: print(f'step {step:5d} | loss {loss:.3f} | ' f'avg_len {avg_len:.3f} | base {base_len:.3f}')日志打印的三列分别代表策略梯度 loss、当前策略采样解的平均环长、baseline 的 greedy 环长。n=20 时,avg_len 会在大约 2000 步内从初始的 7~8 降到 5 以下,最终逼近 4.2 左右;如果训练 5000 步 avg_len 还在 6 以上,优先检查 mask 是否正确、l r 是否过大,而不是加大模型。
4.3 可视化:画一条环游出来看交叉
训练到中途就可以随机生成一个实例,用 greedy 解码画图检查,这一步对判断模型是否学到了空间结构至关重要。
import matplotlib.pyplot as plt def plot_tour(coords, pi): tour = [0] + pi + [0] # 补上起点并回到起点 x, y = coords[tour, 0], coords[tour, 1] plt.figure(figsize=(6, 6)) plt.plot(x, y, 'o-', linewidth=1.5) plt.title(f'n={len(coords)}, length={len(coords):.3f}') plt.show()画出来的环游如果存在明显的交叉线段,说明模型还在局部最优附近挣扎;一旦交叉消除、路径变得平滑,基本可以判断训练到位。可视化能和 avg_len 曲线互相印证,避免只看数字做出错误判断。
4.4 常见失败模式速查表
| 现象 | 常见原因 | 处理方法 |
|---|---|---|
| loss 在降,avg_len 不动 | 模型退化为固定偏序输出 | 调小 lr,调大 batch_size |
| 输出序列出现重复城市 | mask 没作用在 scores 上或漏掉起点 | 在 softmax 前 masked_fill |
| loss 出现 NaN | 某一行 scores 全为 -inf | 检查 mask 是否覆盖所有城市 |
| 训练震荡、avg_len 波动大 | advantage 方差过大 | batch 内标准化或提高 baseline 频率 |
| 最终环长与最优解 gap > 5% | LSTM 表达力不足 | 换 Transformer 编码器(AM 方向)或加 hidden_dim |
5. 三个让训练更稳的进阶技巧
进阶一:在 batch 内做 advantage 标准化。上面代码里length - len_base的量级随 n 不同差别很大,n=20 时方差小,n=50 时方差可能放大一个数量级,导致同一份学习率在不同 n 上表现截然不同。常见的修法是一行标准化代码:
adv = (length - len_base).detach() adv = (adv - adv.mean()) / (adv.std() + 1e-9) # 1e-9 防除零这样 advantage 变成了相对好坏的度量,梯度尺度不再依赖环长的绝对值,lr 在 n=20 和 n=50 时可以共用一组值。
进阶二:rollout baseline 不能一直不更新。训练初期策略快速变强,baseline 如果冻结太久,length - len_base持续为负且绝对值偏大,虽然方向正确但方差会升高。更新的节奏很有讲究:每 500 步在同一个固定验证集上比较当前策略和 baseline 的 greedy 环长,如果当前策略平均短 2% 以上,就把当前权重整体复制给 baseline,再继续训练。更新太频繁会让 advantage 接近零、学习信号变弱,太稀疏方差又太大,2% 阈值是一个足够保守且稳定的经验值。
进阶三:用 OR-Tools 的参考解算 gap,而不是自己感觉“看起来不错”。OR-Tools 的 RoutingModel 内置了 TSP 求解器,少量代码就能得到高精度参考解:
def ortools_tour(coords, time_limit=5): from ortools.constraint_solver import pywrapcp, routing_enums_pb2 n = len(coords) manager = pywrapcp.RoutingIndexManager(n, 1, 0) routing = pywrapcp.RoutingModel(manager) def dist(i, j): return int(round(10000 * float(torch.linalg.vector_norm(coords[i] - coords[j])))) cb = routing.RegisterTransitCallback(dist) routing.SetArcCostEvaluatorOfAllVehicles(cb) params = pywrapcp.DefaultRoutingSearchParameters() params.time_limit.seconds = time_limit sol = routing.SolveWithParameters(params) route, idx = [], routing.Start(0) while not routing.IsEnd(idx): route.append(manager.IndexToNode(idx)) idx = sol.Value(routing.NextVar(idx)) return route注意距离回调要求返回整数,乘 10000 是为了保留欧氏距离的精度。在 n=20 上,OR-Tools 默认 5 秒内给出的解已经非常接近全局最优,把它作为参考值去算(ours - ref)/ref * 100%的 gap,就是模型质量最客观的度量。n=50 之后 LSTM 类指针网络的 gap 会明显上升,这是模型容量和 attention 机制的边界问题,如果需要更高的解质量,可以把编码器替换成 Transformer 结构的 Attention Model,那已经是这篇文章之后的下一个工程了。
本文还有配套的精品资源,点击获取