第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 解码方法
解码就是把编码后的个体还原成问题参数。不同编码的解码方式不同:
- 二进制解码:按位权展开,再映射到参数范围
- 实数解码:直接取值,不需要额外操作
- 整数解码:直接取值,或者按索引映射到类别
这里有个细节——多参数解码时,要注意参数之间的依赖关系。比如一个参数的范围依赖于另一个参数的值,那就得按顺序解码。
# 多参数解码示例
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