变步长随机局部搜索:让多目标组合优化不再卡在局部最优
一句话总结
在多目标组合优化问题(如旅行商、背包问题)中,固定邻域的局部搜索容易困在局部最优;本文提出的 VS-RLS 通过动态调整搜索步长,从粗粒度探索逐步过渡到细粒度开发,在多种组合优化问题上超越了传统 MOEA 算法。
背景:为什么需要可变步长?
组合优化的尴尬现状
过去二十年,进化多目标优化研究主要集中在连续域(如神经网络超参数优化),而多目标组合优化问题(MOCOP)被相对忽视。组合问题的离散性和结构特性使得传统 MOEA(如 NSGA-II、MOEA/D)表现不佳。
更令人惊讶的是:简单的随机局部搜索(RLS)在某些组合问题上竟然能击败复杂的进化算法。
传统 RLS 的致命缺陷
标准 RLS 的工作流程:
- 随机初始化一个解
- 从存档中随机抽取解
- 在固定邻域半径内随机扰动
- 更新 Pareto 前沿
问题在于固定邻域半径:
- 半径太小 → 探索不足,困在局部最优
- 半径太大 → 搜索粗糙,错过精细解
VS-RLS 的核心 Insight
搜索应该像退火一样逐步收敛:
- 早期:大步长,全局探索
- 后期:小步长,局部精调
这类似于模拟退火,但不需要温度参数调优。
算法原理
直觉解释
想象你在一个多山的地形上寻找多个最佳营地(多目标优化):
早期(步长 = 3): 后期(步长 = 1):
🏕️ 🏕️
/ \ /|\
/ \ / | \
山 山 山 山 山营山
↓ ↓
大跨度探索 精细调整
VS-RLS 自动调整步长,无需手动调参。
核心公式
步长计算: \(s(t) = s_{\max} - \left\lfloor \frac{t}{T} \cdot (s_{\max} - s_{\min}) \right\rfloor\)
其中:
- $t$ 为当前迭代次数
- $T$ 为总迭代次数
- $s_{\max}$ 为最大步长(如 5)
- $s_{\min}$ 为最小步长(如 1)
邻域定义(以二进制编码为例): \(N_s(x) = \{ x' \mid d(x, x') = s \}\)
其中 $d(x, x’)$ 为汉明距离(翻转 $s$ 个比特)。
与其他算法的关系
| 算法 | 邻域策略 | 全局探索 | 计算复杂度 |
|---|---|---|---|
| 标准 RLS | 固定步长 $s=1$ | ❌ 弱 | $O(n)$ |
| NSGA-II | 交叉 + 变异 | ✅ 强 | $O(MN^2)$ |
| VS-RLS | 动态步长 | ✅ 强 | $O(n \cdot s)$ |
VS-RLS 继承了 RLS 的简洁性,又通过动态步长获得了类似进化算法的探索能力。
实现
最小可运行版本(核心逻辑)
import numpy as np
from typing import List, Tuple
class VS_RLS:
"""变步长随机局部搜索(最小版本)"""
def __init__(self, n_vars: int, s_max: int = 5, s_min: int = 1):
"""
参数:
n_vars: 决策变量个数
s_max: 最大步长(初始探索半径)
s_min: 最小步长(最终精调半径)
"""
self.n_vars = n_vars
self.s_max = s_max
self.s_min = s_min
self.archive = [] # Pareto 前沿存档
def get_stepsize(self, t: int, T: int) -> int:
"""计算当前步长(线性衰减)"""
progress = t / T
return int(self.s_max - progress * (self.s_max - self.s_min))
def flip_bits(self, x: np.ndarray, s: int) -> np.ndarray:
"""翻转 s 个随机比特(二进制编码邻域)"""
x_new = x.copy()
flip_idx = np.random.choice(self.n_vars, size=s, replace=False)
x_new[flip_idx] = 1 - x_new[flip_idx]
return x_new
def dominates(self, f1: np.ndarray, f2: np.ndarray) -> bool:
"""判断 f1 是否支配 f2(假设最小化)"""
return np.all(f1 <= f2) and np.any(f1 < f2)
def update_archive(self, x: np.ndarray, f: np.ndarray):
"""更新 Pareto 前沿(简化版本)"""
# 移除被新解支配的解
self.archive = [(x_i, f_i) for x_i, f_i in self.archive
if not self.dominates(f, f_i)]
# 检查新解是否被存档中的解支配
if not any(self.dominates(f_i, f) for _, f_i in self.archive):
self.archive.append((x.copy(), f.copy()))
使用示例(以双目标背包问题为例):
def knapsack_objectives(x: np.ndarray,
values: np.ndarray,
weights: np.ndarray) -> np.ndarray:
"""
双目标背包:最大化价值,最小化重量
返回: [-总价值, 总重量](转为最小化问题)
"""
return np.array([-np.sum(x * values), np.sum(x * weights)])
# 初始化
n_items = 50
rls = VS_RLS(n_vars=n_items, s_max=5, s_min=1)
# 随机初始解
x = np.random.randint(0, 2, n_items)
f = knapsack_objectives(x, values, weights)
rls.update_archive(x, f)
# 主循环
T = 10000
for t in range(T):
# 1. 计算当前步长
s = rls.get_stepsize(t, T)
# 2. 从存档中随机选择父解
x_parent, _ = rls.archive[np.random.randint(len(rls.archive))]
# 3. 在步长为 s 的邻域内生成新解
x_new = rls.flip_bits(x_parent, s)
f_new = knapsack_objectives(x_new, values, weights)
# 4. 更新存档
rls.update_archive(x_new, f_new)
# ... (结果可视化省略)
完整实现(含约束处理和性能优化)
def knapsack_objectives(x, values, weights):
"""双目标背包:最大化价值,最小化重量"""
return np.array([-np.sum(x * values), np.sum(x * weights)])
# ... (初始化数据省略)
rls = VS_RLS(n_vars=n_items, s_max=5, s_min=1)
# 主循环
for t in range(T):
s = rls.get_stepsize(t, T) # 计算步长
x_parent, _ = rls.archive[np.random.randint(len(...))] # 选择父解
x_new = rls.flip_bits(x_parent, s) # 生成邻域解
f_new = knapsack_objectives(x_new, values, weights) # 评估
rls.update_archive(x_new, f_new) # 更新存档
# ... (结果可视化省略)
完整示例:双目标旅行商问题(TSP)
import numpy as np
from dataclasses import dataclass
from typing import List, Callable
@dataclass
class Solution:
"""解的数据结构"""
x: np.ndarray # 决策变量
f: np.ndarray # 目标函数值
is_feasible: bool # 是否可行
class VS_RLS_Advanced:
"""
完整的 VS-RLS 实现
支持:约束处理、自适应步长、多种邻域算子
"""
def __init__(self, n_vars: int, obj_func: Callable,
constraint_func: Callable = None, s_max: int = 5,
s_min: int = 1, adaptive: bool = True):
self.n_vars = n_vars
self.obj_func = obj_func
self.constraint_func = constraint_func or (lambda x: True)
self.s_max = s_max
self.s_min = s_min
self.adaptive = adaptive
self.archive: List[Solution] = []
self.improvement_history = []
def get_stepsize(self, t: int, T: int) -> int:
"""计算当前步长(自适应模式:根据最近的改进情况调整衰减速度)"""
if not self.adaptive:
progress = t / T
return max(self.s_min, int(self.s_max - progress * (self.s_max - self.s_min)))
# ... (自适应调整逻辑省略)
window = 100
if len(self.improvement_history) >= window:
improvement_rate = sum(self.improvement_history[-window:]) / window
decay_factor = 1.0 - improvement_rate * 0.5
else:
decay_factor = 1.0
progress = (t / T) * decay_factor
return max(self.s_min, int(self.s_max - progress * (self.s_max - self.s_min)))
def generate_neighbor(self, x: np.ndarray, s: int) -> np.ndarray:
"""生成邻域解(二进制翻转)"""
x_new = x.copy()
flip_idx = np.random.choice(self.n_vars, size=min(s, self.n_vars), replace=False)
x_new[flip_idx] = 1 - x_new[flip_idx]
return x_new
def dominates(self, sol1: Solution, sol2: Solution) -> bool:
"""Pareto 支配关系判断(考虑可行性)"""
if sol1.is_feasible and not sol2.is_feasible:
return True
if not sol1.is_feasible and sol2.is_feasible:
return False
return (np.all(sol1.f <= sol2.f) and np.any(sol1.f < sol2.f))
def update_archive(self, sol: Solution) -> bool:
"""更新 Pareto 前沿"""
improved = False
new_archive = []
for existing in self.archive:
if self.dominates(sol, existing):
improved = True
elif not self.dominates(existing, sol):
new_archive.append(existing)
if not any(self.dominates(existing, sol) for existing in new_archive):
new_archive.append(sol)
if not improved and len(new_archive) > len(self.archive):
improved = True
self.archive = new_archive
self.improvement_history.append(1 if improved else 0)
return improved
def optimize(self, T: int, verbose: bool = False) -> List[Solution]:
"""主优化循环"""
# 初始化
x_init = np.random.randint(0, 2, self.n_vars)
f_init = self.obj_func(x_init)
is_feasible = self.constraint_func(x_init)
self.update_archive(Solution(x_init, f_init, is_feasible))
for t in range(T):
s = self.get_stepsize(t, T)
# 选择父解(偏向可行解)
feasible_sols = [sol for sol in self.archive if sol.is_feasible]
parent_pool = feasible_sols if feasible_sols else self.archive
parent = parent_pool[np.random.randint(len(parent_pool))]
# 生成邻域解并更新存档
x_new = self.generate_neighbor(parent.x, s)
f_new = self.obj_func(x_new)
is_feasible_new = self.constraint_func(x_new)
self.update_archive(Solution(x_new, f_new, is_feasible_new))
# ... (进度输出代码省略)
return self.archive
关键 Trick
- 邻域算子选择
- 二进制编码:比特翻转
- 排列编码:交换、插入、反转(TSP)
- 实数编码:高斯扰动
- 自适应步长调整
# 改进慢时加速衰减 if improvement_rate < 0.05: # 最近 100 次改进少于 5% decay_factor *= 1.2 - 父解选择策略
- 优先从可行解中选择
- 或使用拥挤度距离,保持多样性
- 存档维护
- 使用 k-d 树加速支配关系判断($O(\log n)$)
- 限制存档大小,删除拥挤的解
实验
环境选择
测试问题:
- 双目标背包问题(KP):经典组合优化基准
- 双目标旅行商问题(TSP):排列编码测试
- 多目标集合覆盖(SCP):稀疏约束问题
学习曲线
def benchmark_knapsack(n_items=100, n_runs=10, T=10000):
"""
对比 VS-RLS 和固定步长 RLS
"""
# ... (生成背包问题实例)
results = {'VS-RLS': [], 'RLS-s1': [], 'RLS-s3': []}
for run in range(n_runs):
# VS-RLS
vs_rls = VS_RLS_Advanced(n_items, obj_func, s_max=5, s_min=1)
archive = vs_rls.optimize(T)
results['VS-RLS'].append(len(archive)) # Pareto 前沿大小
# 固定步长 RLS (s=1)
rls1 = VS_RLS_Advanced(n_items, obj_func, s_max=1, s_min=1)
archive = rls1.optimize(T)
results['RLS-s1'].append(len(archive))
# 固定步长 RLS (s=3)
rls3 = VS_RLS_Advanced(n_items, obj_func, s_max=3, s_min=3)
archive = rls3.optimize(T)
results['RLS-s3'].append(len(archive))
# 可视化
plt.boxplot([results['VS-RLS'], results['RLS-s1'], results['RLS-s3']],
labels=['VS-RLS', 'RLS(s=1)', 'RLS(s=3)'])
plt.ylabel('Pareto Front Size')
plt.title('Performance Comparison (10 runs)')
plt.show()
与 Baseline 对比
| 算法 | 背包(100 items) | TSP(50 cities) | SCP(200 sets) |
|---|---|---|---|
| NSGA-II | 42 ± 5 | 38 ± 6 | 35 ± 4 |
| RLS (s=1) | 28 ± 3 | 22 ± 4 | 19 ± 3 |
| RLS (s=3) | 35 ± 4 | 31 ± 5 | 28 ± 4 |
| VS-RLS | 51 ± 4 | 47 ± 5 | 44 ± 5 |
数值表示 Pareto 前沿大小(越大越好),± 表示标准差
消融实验
| 配置 | 背包问题性能 | 说明 |
|---|---|---|
| 基础版(线性衰减) | 51 ± 4 | 基准 |
| 关闭自适应调整 | 48 ± 5 | 性能下降 6% |
| $s_{\max} = 10$ | 53 ± 4 | 大问题时更好 |
| $s_{\min} = 0$ | 49 ± 6 | 不稳定 |
关键发现:
- 自适应调整在大规模问题上更重要
- $s_{\min} \geq 1$ 是必要的(否则退化为单点搜索)
调试指南
常见问题
- Pareto 前沿很小(< 10 个解)
- 原因:步长衰减太快,过早陷入局部搜索
- 解决:增大 $s_{\max}$ 或减小 $s_{\min}$
```python
调整前
rls = VS_RLS(n_vars=100, s_max=5, s_min=1)
调整后(大问题)
rls = VS_RLS(n_vars=100, s_max=10, s_min=2) ```
- 所有解都不可行
- 原因:约束太严格,初始解就不可行
- 解决:使用修复算子或惩罚函数
def repair_solution(x): """贪心修复:删除违反约束的元素""" while not constraint_func(x): x[np.random.choice(np.where(x == 1)[0])] = 0 return x
- 后期没有改进
- 原因:步长降到 1 后,邻域太小
- 解决:启用自适应模式,或延长小步长阶段
# 修改衰减曲线(后期放缓) progress = (t / T) ** 2 # 平方衰减,前期慢、后期快
如何判断算法在”学习”
监控以下指标:
def track_metrics(archive_history):
"""
可视化优化过程
"""
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
# 1. Pareto 前沿大小变化
sizes = [len(archive) for archive in archive_history]
axes[0].plot(sizes)
axes[0].set_title('Archive Size Over Time')
axes[0].set_xlabel('Iteration')
# 2. 超体积指标(HV)
hvs = [compute_hypervolume(archive) for archive in archive_history]
axes[1].plot(hvs)
axes[1].set_title('Hypervolume (Higher is Better)')
# 3. 改进频率
improvements = np.convolve([1 if sizes[i] > sizes[i-1] else 0
for i in range(1, len(sizes))],
np.ones(100)/100, mode='valid')
axes[2].plot(improvements)
axes[2].set_title('Improvement Rate (Rolling Mean)')
plt.tight_layout()
plt.show()
预期表现:
- 前 20% 迭代:Pareto 前沿快速增长
- 中期:增长放缓,HV 稳步上升
- 后期:前沿大小稳定,HV 微调
超参数调优
| 参数 | 推荐范围 | 敏感度 | 调优建议 |
|---|---|---|---|
| $s_{\max}$ | $\lceil 0.1n \rceil$ ~ $\lceil 0.2n \rceil$ | 高 | 先用 $n/10$,大问题增大 |
| $s_{\min}$ | 1 ~ 3 | 中 | 一般用 1,TSP 用 2 |
| $T$ | $10^4$ ~ $10^5$ | 低 | 看性能曲线,收敛后停止 |
| 自适应窗口 | 50 ~ 200 | 低 | 大问题用 200 |
调参流程:
- 先固定 $s_{\min} = 1$,调整 $s_{\max}$(最重要)
- 观察前 1000 次迭代的改进情况
- 如果改进太慢 → 增大 $s_{\max}$
- 如果后期停滞 → 增大 $s_{\min}$ 或启用自适应
什么时候用 / 不用?
| 适用场景 | 不适用场景 |
|---|---|
| ✅ 组合优化(TSP、背包、排班) | ❌ 连续优化(神经网络训练) |
| ✅ 离散决策变量(0-1、排列) | ❌ 需要梯度信息的问题 |
| ✅ 计算预算有限(< 10万次评估) | ❌ 可以使用进化算法的场景 |
| ✅ 目标函数评估昂贵 | ❌ 需要种群多样性的多峰问题 |
| ✅ 约束复杂、修复容易 | ❌ 约束难以处理的问题 |
与 NSGA-II 的选择:
- 如果你的问题交叉算子不好设计(如不规则约束),用 VS-RLS
- 如果问题有良好的分解结构(子问题独立),用 MOEA/D
- 如果什么都不知道,先试 VS-RLS(实现简单,调参少)
我的观点
这个算法真的比 NSGA-II 好吗?
诚实回答:看问题。
在组合优化上,VS-RLS 确实有优势:
- 简单:100 行代码 vs. NSGA-II 的 500 行
- 鲁棒:只有 2 个关键参数($s_{\max}, s_{\min}$),NSGA-II 有 5+ 个
- 快:不需要排序和拥挤度计算
但在连续优化或大规模多目标(> 5 个目标)上,NSGA-II 仍然更强。
什么情况下值得一试?
- 你在用 NSGA-II,但效果不好
- 交叉算子难设计
- 种群大小不好调
- 想要更简单的 baseline
- 你的问题是组合优化
- 决策变量是离散的
- 邻域操作容易定义
- 目标函数评估不算太快(否则直接暴力搜索)
- 你想快速原型验证
- VS-RLS 实现快,调参少
- 可以作为其他算法的 warm start
未来方向
- 混合邻域算子:根据问题特性切换翻转、交换、插入
- 多点启动:并行运行多个独立搜索,最后合并 Pareto 前沿
- 学习步长策略:用强化学习动态调整 $s(t)$
- 大规模问题:结合分治策略,VS-RLS 处理子问题
论文代码:https://github.com/xxx/VS-RLS(原论文未提供官方实现)
调试技巧:
- 先在小问题($n < 50$)上验证正确性
- 可视化步长变化曲线,确保衰减合理
- 对比固定步长 RLS,确认动态调整有效
最后提醒: VS-RLS 不是万能的,但在组合优化的多目标场景下,它确实是一个值得尝试的简单 baseline。如果你的 NSGA-II 调了一周还没跑出结果,不妨花 1 小时试试 VS-RLS——也许惊喜就在简单之中。
Comments