第4章:参数编码与解码

编码,说白了就是怎么把问题的解表示成算法能理解的语言。

我刚开始做进化策略时,总觉得编码是个纯理论问题。后来踩了不少坑才明白——编码选得好,优化成功一半;编码选得烂,调参调到哭。

这一章,咱们就把二进制编码、实数编码、整数编码这三大主流方案掰开揉碎讲清楚。顺便聊聊编码长度怎么选,解码又该怎么搞。

4.1 二进制编码

二进制编码是最经典的方式。把每个参数用一串0和1表示,然后拼在一起形成个体。

举个例子,假设我们要优化一个参数x,范围是[0, 10],精度要求0.01。那需要多少位?

计算很简单:范围长度是10,精度0.01,所以需要10/0.01=1000个离散值。2^10=1024,刚好够。所以用10位二进制就够了。

# 二进制编码示例
import numpy as np

def binary_encode(value, lower, upper, bits):
    """将实数编码为二进制串"""
    # 先映射到[0, 2^bits - 1]区间
    scale = (value - lower) / (upper - lower)
    int_val = int(scale * (2**bits - 1))
    # 转二进制
    return format(int_val, f'0{bits}b')

def binary_decode(binary_str, lower, upper, bits):
    """将二进制串解码为实数"""
    int_val = int(binary_str, 2)
    scale = int_val / (2**bits - 1)
    return lower + scale * (upper - lower)

# 测试
x = 3.14
encoded = binary_encode(x, 0, 10, 10)
decoded = binary_decode(encoded, 0, 10, 10)
print(f"原始值: {x:.2f}, 编码: {encoded}, 解码: {decoded:.2f}")

核心要点:二进制编码的精度由位数决定。位数越多,精度越高,但搜索空间也越大。

4.2 实数编码

实数编码就直白多了——直接用浮点数表示参数。这在连续优化问题中特别常见。

我个人习惯用实数编码,原因有三:

  • 不需要编解码,计算效率高
  • 精度不受位数限制
  • 和问题本身的表达方式一致

但要注意,实数编码的变异和交叉操作需要特殊设计。不能像二进制那样随便翻转位。

# 实数编码示例
class RealCodedIndividual:
    def __init__(self, dim, bounds):
        self.dim = dim
        self.bounds = bounds  # [(low1, high1), (low2, high2), ...]
        self.genes = np.random.uniform(
            [b[0] for b in bounds],
            [b[1] for b in bounds],
            dim
        )
    
    def mutate(self, sigma=0.1):
        """高斯变异"""
        self.genes += np.random.normal(0, sigma, self.dim)
        # 边界处理
        for i in range(self.dim):
            self.genes[i] = np.clip(
                self.genes[i], 
                self.bounds[i][0], 
                self.bounds[i][1]
            )

实战技巧:实数编码时,边界处理一定要做好。我遇到过参数跑出边界导致目标函数报错的情况,排查了半天才发现是变异后没做clip。

4.3 整数编码

整数编码适用于离散优化问题。比如参数是整数、类别标签、或者排列顺序。

典型的应用场景:

  • 神经网络结构搜索(层数、神经元个数)
  • 调度问题(工序顺序)
  • 特征选择(选哪些特征)

整数编码有个坑——变异操作要保证结果还是整数。我常用的方法:

# 整数编码变异
def integer_mutate(value, lower, upper, mutation_rate=0.1):
    """整数变异:随机步长"""
    if np.random.random() < mutation_rate:
        step = np.random.randint(-2, 3)  # 步长[-2, -1, 0, 1, 2]
        new_val = value + step
        return np.clip(new_val, lower, upper)
    return value

# 或者用随机重置
def integer_reset_mutate(value, lower, upper, mutation_rate=0.1):
    """整数变异:随机重置"""
    if np.random.random() < mutation_rate:
        return np.random.randint(lower, upper + 1)
    return value

注意:整数编码的交叉操作也要小心。简单的单点交叉可能产生非法个体(比如重复的排列)。这时候需要特殊的交叉算子,比如PMX(部分映射交叉)。

4.4 编码长度选择

编码长度怎么定?这个问题没有标准答案,但我有几个经验法则:

编码类型 长度选择原则 我的建议
二进制 精度决定位数 先算精度需求,再加2-3位冗余
实数 等于参数维度 不需要额外长度,但注意维度灾难
整数 等于离散变量个数 每个变量独立编码,别混在一起

我曾经犯过一个错误:为了追求精度,把二进制编码设到了32位。结果搜索空间大到2^32,算法根本收敛不了。后来改成12位,精度够用,收敛速度也上来了。

经验总结:编码长度不是越长越好。够用就行,多出来的位数只会拖慢搜索速度。

4.5 解码方法

解码就是把编码后的个体还原成问题参数。不同编码的解码方式不同:

  1. 二进制解码:按位权展开,再映射到参数范围
  2. 实数解码:直接取值,不需要额外操作
  3. 整数解码:直接取值,或者按索引映射到类别

这里有个细节——多参数解码时,要注意参数之间的依赖关系。比如一个参数的范围依赖于另一个参数的值,那就得按顺序解码。

# 多参数解码示例
def decode_individual(binary_string, param_configs):
    """
    param_configs: [(lower, upper, bits), ...]
    """
    results = []
    start = 0
    for lower, upper, bits in param_configs:
        # 截取当前参数的二进制段
        segment = binary_string[start:start+bits]
        # 解码
        int_val = int(segment, 2)
        scale = int_val / (2**bits - 1)
        value = lower + scale * (upper - lower)
        results.append(value)
        start += bits
    return results

# 使用示例
configs = [
    (0, 10, 8),    # 参数1: [0,10], 8位
    (-5, 5, 8),    # 参数2: [-5,5], 8位
    (0, 1, 4)      # 参数3: [0,1], 4位
]
chromosome = "101010101010101010101010"
params = decode_individual(chromosome, configs)
print(f"解码结果: {params}")

4.6 知识体系总览

下面这张图把编码解码的核心逻辑串起来了:

参数编码与解码知识体系 二进制编码 实数编码 整数编码 精度可控 搜索空间大 经典稳定 高精度 无需编解码 连续优化首选 离散问题 排列组合 特殊算子 编码长度选择 原则:精度够用就行,不要过度编码 解码:按位权展开 → 映射到参数范围 → 处理边界

这张图把编码解码的脉络理清楚了。从上到下看:先选编码类型,再定编码长度,最后做解码。每一步都有讲究。

我的建议:刚开始做进化策略时,先用实数编码试试。简单直接,不容易出错。等熟悉了再尝试二进制编码,体验一下精度和搜索空间的权衡。

编码解码这块,说白了就是找到问题表达和算法效率之间的平衡点。没有绝对的好坏,只有合不合适。多试几种方案,你自然就能找到感觉。


公众号:蓝海资料掘金营,微信deep3321