4. 搜索空间的表示方法与编码技巧

为什么同一个搜索问题,有人跑了一周没效果,有人半天就找到最优解?

嗯,差别往往不在算法本身,而在搜索空间的表示方法。说白了,你怎么把“要调的东西”编码成计算机能理解的形式,这直接决定了搜索效率。我个人习惯在动手写代码前,先花半小时画一张表示策略图,磨刀不误砍柴工。

4.1 为什么编码这么重要

搜索空间本质上是一个映射问题:把现实世界的参数、结构、条件,映射到搜索算法能处理的数学表示上。

我刚开始做超参调优时踩过一个坑。当时要优化一个模型的 learning rate 和 batch size,我直接把原始值扔进了遗传算法。结果种群收敛慢不说,还经常跳出合理区域。后来发现是编码方式不对——learning rate 应该用对数尺度表示,而不是线性。

一个好的编码要满足三点:

  • 完整性:所有合法解都能被表示
  • 紧致性:没有冗余的表示浪费搜索资源
  • 可操作性:交叉、变异等操作能产生有意义的子代

核心判断标准:如果你的编码方案中,随机改变1个基因位后,有超过30%的概率产生无效或明显劣质的解,说明编码有问题。

4.2 三种主流表示法对比

这几年我主要接触三种表示方法。每种都有它的脾气,用对了是神器,用错了是累赘。

表示法 适用场景 编码密度 操作难度 典型问题
二进制编码 离散空间、组合优化 中 低 汉明悬崖(Hamming Cliffs)
实数编码 连续参数、超参调优 高 中 收敛精度不足
排列编码 排序问题、路径规划 低-中 高 子代合法性校验

4.3 我的选择经验

我个人习惯这样选:

  1. 连续参数(学习率、温度、权重)→ 实数编码。别想太多,直接上。
  2. 离散选择(选哪个激活函数、用哪种优化器)→ 二进制或整数编码。注意保持编码距离均匀。
  3. 结构搜索(网络层数、连接方式)→ 树形或图编码。这需要专门工具,后面我会细讲。

记得有一次我做集成学习模型的超参搜索,同时要优化基学习器的数量(整数)、学习率(连续)、以及是否使用抽样(布尔值)。混合编码怎么处理?我最终把它们拼成一个定长基因序列:

# 混合编码示例(伪代码)
chromosome = [ 
    n_estimators (整数 50-500),
    learning_rate (实数 1e-4 to 1e-1,对数编码),
    subsample (布尔 0/1),
    max_depth (整数 3-15),
    min_samples_split (实数 2-20,对数编码)
]

# 实际配置
n_estimators = int(genes[0])
learning_rate = exp(genes[1] * log(0.1) + (1 - genes[1]) * log(0.0001))
subsample = genes[2] > 0.5
max_depth = int(genes[3])
min_samples_split = exp(genes[4] * log(20) + (1 - genes[4]) * log(2))

小技巧:混合编码中最容易出问题的就是尺度不一致的参数。我一般会对每个参数做归一化,让基因位的值范围始终在 0-1 或 -1 到 1 之间,这样交叉和变异的扰动幅度可控。

4.4 实战中的编码技巧

说几个我在项目里用过的实在技巧,你直接拿去用就好。

技巧1:尺度变换

你以为参数范围是 [0.1, 10.0],就真的在线性空间里均匀采样?不对。很多物理参数的敏感性是非线性的。

举个例子,learning rate 从 0.1 变到 0.05 和从 0.001 变到 0.0005,绝对变化量差100倍,但相对变化引起的模型行为差异是类似的。所以应该用对数尺度编码:

# 对数尺度编码/解码
def encode_lr(lr, min_lr=1e-5, max_lr=1e-1):
    """将学习率映射到 [0, 1]"""
    log_lr = log10(lr)
    log_min = log10(min_lr)
    log_max = log10(max_lr)
    return (log_lr - log_min) / (log_max - log_min)

def decode_lr(gene, min_lr=1e-5, max_lr=1e-1):
    """将 [0, 1] 的基因解码回学习率"""
    log_min = log10(min_lr)
    log_max = log10(max_lr)
    return 10 ** (log_min + gene * (log_max - log_min))

技巧2:约束处理

搜索空间里经常有约束条件:A 参数必须大于 B 参数,或者某些参数组合不能同时出现。

有两种处理方式:

  • 硬编码约束:在编码阶段就把约束融进去。比如要求 sum(weights) = 1,就不要编码为3个独立参数,而是编码为2个角度,用正弦余弦自动保证归一化。
  • 罚函数法:编码中允许出现无效组合,但在计算适应度时施加惩罚。

我自己更偏向硬编码约束。为什么?因为罚函数法有一个坑:你永远不知道罚多少才合适。罚轻了,搜索空间被无效解污染;罚重了,算法不敢探索边界附近的区域。

我曾经历过:一个推荐系统的排序模型搜索,硬编码一个约束条件用了1天就把效果提了5个点。而之前用罚函数法折腾了1周,效果还不稳定。编码阶段的约束越紧,搜索阶段的效率就越稳。

技巧3:动态编码

不是所有参数在搜索的整个过程中都需要相同的精度。想象一下,搜索初期你只想大致定位到“好区域”,末期才需要精确定位。

一种有效的做法是渐进式编码:

# 动态编码策略
class ProgressiveEncoder:
    def __init__(self, param_bounds, initial_bits=4, final_bits=16):
        self.bounds = param_bounds
        self.bits_per_param = initial_bits  # 从低位开始
        self.final_bits = final_bits
    
    def update_precision(self, generation, total_generations):
        """随着代数增加,逐渐提高编码精度"""
        progress = generation / total_generations
        self.bits_per_param = int(self.initial_bits + 
                                   progress * (self.final_bits - self.initial_bits))
    
    def encode(self, value):
        """使用当前精度编码"""
        min_v, max_v = self.bounds
        normalized = (value - min_v) / (max_v - min_v)
        # 使用当前bits进行二进制编码
        step = 1.0 / (2 ** self.bits_per_param)
        return int(normalized / step)

技巧4:组合编码的“相邻性”

这一点经常被新手忽略。编码的相邻位置,应该放关联度高的参数。

举个例子,你要同时优化 ConvNet 的 kernel_size 和 stride。这两个参数在物理上是强相关的——stride 一般不能大于 kernel_size。如果编码时把它们放在相邻位置,交叉操作就更容易保留这种局部依赖性。如果分散在基因的两个远端,交叉操作很容易破坏这层关系。

经验法则:编码顺序影响搜索效率。把强耦合的参数放在一起,把弱耦合或独立的参数分开。

搜索空间表示方法核心逻辑 连续空间编码 离散空间编码 混合/结构编码 尺度变换 约束处理 动态编码 对数/指数映射 硬编码 vs 罚函数 渐进精度提升 编码质量决定了搜索天花板 好的编码 = 更少的搜索代数 + 更优的解质量

4.5 常见编码错误与避坑指南

这些年我见过的编码翻车现场,基本可以归为这几类。你写代码时注意避开:

  • 码位冗余:把高精度的参数硬塞进太少的比特位。比如 learning rate 有1万种可能取值,你只给4个比特(16种状态)。这不是搜索,这是阉割。
  • 相邻冲突:二进制编码中数值相邻的两个点,在二进制表示上可能差异巨大(比如 0111→1000 需要改变所有bit)。这会导致变异操作产生不连续的跳跃。
  • 局部极值陷阱:编码的排列顺序不合理,导致搜索算法过早收敛到局部最优。比如调度问题中,把任务编号写成1-2-3-4-5,但实际最优解是5-3-1-2-4。
  • 角度环绕问题:360度的角度编码,354度和6度其实是相邻的,但在整数编码中相差了348个单位。这种情况我建议用正弦+余弦二维编码,天然消除环绕边界。

我曾经写过一篇代码,为了省几个比特,把一个1024维的超参数空间压缩到64维。结果搜索到的解质量始终上不去。后来发现是编码损失太大,相当于主动抛弃了最优解。从那以后我记住了:编码的紧致性不能以牺牲完整性为代价。

嗯,编码这一节内容其实很碎,但每一条都可能让你的搜索结果翻车。动手之前,先画张图理清楚表示逻辑,能省下后面几天的调试时间。