先说一个我自己踩过的场景:用改进粒子群跑十二味药的复方配比,迭代三四百代,十几次跑出来几乎是同一组剂量。一开始我以为程序写错了,反复检查适应度函数才发现不是 bug,而是普通单种群算法在“中药复方优化”这种高维多峰约束问题上,确实容易早早收敛到局部最优。后来把思路换成群体协同算法,问题和代码一下就打开了。这篇就聊聊怎么把中药复方优化问题建模成可计算的搜索问题,怎么设计多子群协同框架,给出完整的 Python 实现,再讲讲我在调参和评估时踩过的几个实实在在的坑。适合正在做计算药学、组方优化,或想了解协同进化算法怎么落地的朋友参考。
1. 复方优化为什么是搜索问题,而不是经验问题
1.1 剂量配比的空间有多大
中药复方优化最常见的形式是:在一组候选药味里选出合适的药,同时确定每味药的剂量。这里有两个麻烦叠在一起。药味数量一变,问题维度就变;更麻烦的是,剂量本身是连续值,不能简单枚举。
我算过一笔账:假设有 12 味药,每味药的剂量范围是 3 到 30 克,按 0.5 克一个步长去枚举,一个维度就有 55 种选择;整体组合量大约是 55 的 12 次方。这个量级对穷举法和网格搜索来说基本是无解的。智能优化算法在这里的核心价值,就是通过群体迭代在大空间里找到较优区域,而不是靠人力或者暴力枚举。
如果问题变成“药味数量不固定,允许丢弃某些候选药”,复杂度还要上升。因为原来的连续变量空间里,叠加了 0/1 选择变量的组合爆炸。在药物信息学里,这类问题经常被表达为“组合优化 + 连续参数优化”的混合问题,也正是群智能算法擅长处理的类型。
1.2 单种群算法为什么撑不住
普通粒子群(PSO)在低维连续优化上表现不错,但问题维度一旦超过 10 维,并且目标函数存在大量局部峰,它的缺陷就很明显:粒子群靠 pbest 和 gbest 把个体不断拉向某些“明星位置”,种群内部的多样性会快速下降。你多跑几次,表面看每次都是独立初始化,实际上收敛点都挤在同一个小区域里,这就是早熟收敛。
我打个比方:一个团队里如果所有人都盯着同一位优秀同事的工作方式去模仿,时间一长,团队内部的思路就高度同质化,再遇到新问题,无论大家怎么讨论,都绕不出那个小圈子。普通单种群算法就是这个状态。
更具体地说,复方优化适应度面经常是“多峰”的。药味 A 和药味 B 搭配有不错的评分,但药味 A、B、C 一起用可能会因为禁忌冲突或剂量失衡而跌进深谷;药味 D 和 E 的组合可能是另一座更高的峰。单种群算法在迭代过程中一旦被某个中等峰吸引,就很难再跳出来。这就是为什么我在实践里基本不再用单一种群直接跑复方问题。
1.3 群体协同:把分工真正做起来
群体协同算法不是某一个具体算法,而是一类框架。核心思想是:多个子群并行搜索,子群之间通过一定拓扑交换信息,用协作替代各自为战。在复方优化里,我们通常按药味维度把问题切开,让不同子群负责不同的药味子集,再通过“协作补全”来评估完整解。
这样做有几个直观收益:
- 单个子群面对的搜索维度更小,局部搜索更精细;
- 不同子群可以维持不同的探索偏好,整体多样性下降速度变慢;
- 协作评估机制让子群之间产生信息交换,避免彻底孤立导致的次优结果。
这个框架真正要设计的问题有三个:子群怎么切、适应度怎么协作评估、信息怎么交互。后面我会逐一展开,先写成代码层面直接可用的方案。
2. 把复方优化落到数学建模:变量、目标与约束
2.1 决策变量怎么编码
我用的编码方式比较直接:候选解 x 是一个长度 2n 的实数向量,n 是候选药味的数量。前 n 维是每味药的“选择强度”,范围 0 到 1,大于 0.5 代表这味药被选入复方;后 n 维是每味药的相对剂量比例,实际剂量等于比例乘以该药允许的最大剂量。
为什么不把“选择”和“剂量”分开编码成二进制变量加连续变量?可以做,但粒子群本质是连续优化器,如果第一段用二进制,速度更新和阈值翻转之间会有一个不好调和的割裂。用连续的选择强度配合阈值化,粒子群的速度更新就能平滑地跨过“选/不选”的边界,这也是实践中比较省事的折中方案。
对应的解码逻辑很简单:
def decode(x, herbs): names = list(herbs.keys()) n = len(names) sel = x[:n] > 0.5 doses = x[n:] * np.array([herbs[name]["dmax"] for name in names]) return names, sel, doses变量范围统一压到 0 到 1,后面所有维度都用同一个上界下界,做速度限制和边界修复也更简单。
2.2 评价函数怎么设计才不吃亏
在我的虚拟数据集里,每味药有三个属性:效应评分、风险评分、成本系数。这些属性都是学术演示用的模拟数值,不构成任何医疗建议。实际使用时要替换成可信数据源,比如实验测定指标、网络药理学的活性打分等。
复方整体评价,我按以下逻辑聚合:
- 效应贡献:选中药的效应值平均值;
- 风险水平:选中药的风险值平均值;
- 成本控制:选中药的成本系数平均值。
因为最终要交给优化器,我把问题统一写成最小化形式:
min Z = -w_eff · eff_norm + w_risk · risk_norm + w_cost · cost_norm + penalty
这里 eff_norm、risk_norm、cost_norm 都是先归一化到 0 到 1 左右再参与加权。权重 w_eff、w_risk、w_cost 的意义是表达研究者对不同目标的偏好。
这一步是很多初次做复方优化的同学容易忽视的地方。效应、风险、成本三个指标的量纲和数值范围完全不同,如果直接加权相加,数值最大的那个指标会主导搜索方向,算法最后实际上只优化了其中一个维度。所以先归一化再加权,是评价函数里最基础也是最重要的一步。
2.3 约束不放进目标函数,早晚会出问题
复方优化里有一类“硬约束”,比如某些药材不宜同用,君药数量不能为 0,总药味数量要控制在一定范围内,选中药的剂量不能低于最小剂量。我统一用惩罚函数处理:
pen = ρ · violation²
ρ 是惩罚系数。这里有个关键经验:ρ 不能一上来给很大。惩罚系数过大会让算法“躺平”——搜索过程倾向于选择完全避开约束的平庸组合,药效贡献反而很差;惩罚系数过小,禁忌对会频繁出现,结果没有实用价值。
我习惯的做法是:先用一个较小的惩罚系数跑调试,观察约束违反率;如果违反率太高,每轮把 ρ 乘以 1.5 左右,重复几次,找到一个“刚好能让约束基本满足,又不至于压死药效”的量级。这个调试过程比想象中费时间,但值得做。
3. 我采用的协同框架:多子群并行加协作补全
3.1 为什么选同构多子群而不是异构算法混合
很多论文喜欢做 GA + PSO 的异构混合,看起来更强,但实际调试成本很高。两种算法的收敛速度、参数灵敏度、变异机制完全不同,凑在一起很容易出现某个阶段一个算法被另一个主导,整体效果反而不如单一算法。我做复方优化的目标是稳定、可复现,而不是炫技,所以选的是同构多子群:所有子群都用粒子群的更新规则,只是初始化和信息交互方式不同。
同构的好处是参数统一,速度更新公式只有一套,调试一个子群的经验可以直接复制到所有子群。等到协同框架跑通之后,再考虑把某个子群替换成差分进化或遗传算法做局部增强也不迟。
3.2 按维度切分与协作评估
我的做法是把 2n 维解空间切成 m 个子空间,每个子群负责一个子空间。注意一个关键点:适应度函数必须吃完整解,所以评估某个子群里某个粒子时,不能只拿这个粒子的部分维度去算适应度,而要把这个粒子的子向量和其余子群“当前最佳子向量”拼成完整解,再交给评价函数。这个机制在协同进化算法里叫“合作者”机制。
代码层面我这样实现:
def full_eval_on_sub(self, s_idx, part_vec): full = np.zeros(self.dim) for i in range(self.n_sub): if i == s_idx: full[self.parts[i]] = part_vec else: full[self.parts[i]] = self.sub_best[i] return evaluate(full)这段代码很短,但它是整个协同框架的核心。每个子群的粒子其实都在隐式地利用“其他子群当前认为最好”的信息来完成协作评估,因此优化过程不是孤立的局部搜索,而是全局信息的分布式传播。
3.3 全局档案与信息交互
每个子群内部维护自己的 pbest,全局同时维护一个 archive 数组,存放目前为止最优的完整解。每隔几代,我按环形拓扑做一次子群间的最优子向量交换。环形拓扑的意思是子群 1 的子向量传给子群 2,子群 2 传给子群 3,依此类推,最后一个子群再传回子群 1。
为什么不用全连接把所有信息直接广播?因为全连接通信下,所有子群会迅速趋同,协同的意义就没了。环形拓扑的信息传播是“局部传导”的,一个子群的优秀片段要在多轮迭代里逐渐扩散到全部子群,这给探索过程留了更长的时间窗口,全局多样性保留得更好。
3.4 对全局最优做精英扰动
每次迭代结束后,我会对 archive 里的全局最优做一次随机小步扰动:随机挑两个维度,加一个均值为 0、标准差 0.5 的噪声,然后 clip 回边界。如果扰动后的适应度更好,就替换 archive。
这个操作成本很低,但效果明显。复方优化问题的适应度面有很多陡峭的窄峰,标准粒子群经常刚好错过这些窄峰,小步扰动相当于在最优解附近做了一次精细侦察,稍微探一步就能发现更好的位置。
4. 从空想跑到落地:一套可复现的 Python 实现
4.1 模拟数据与评价函数
我构造了一份完全虚拟的药材数据。每味药有 role、effect、risk、cost、dmin、dmax、forbid 几个字段。forbid 字段表示与哪些药存在禁忌冲突。数据全部用代号,不涉及真实药材,目的只是演示算法流程。
import numpy as np HERBS = { "herb_01": {"role": "君", "effect": 0.92, "risk": 0.15, "cost": 0.62, "dmin": 3.0, "dmax": 18.0, "forbid": ["herb_05"]}, "herb_02": {"role": "臣", "effect": 0.78, "risk": 0.22, "cost": 0.45, "dmin": 3.0, "dmax": 15.0, "forbid": []}, "herb_03": {"role": "佐", "effect": 0.56, "risk": 0.10, "cost": 0.30, "dmin": 2.0, "dmax": 12.0, "forbid": ["herb_07"]}, "herb_04": {"role": "使", "effect": 0.40, "risk": 0.05, "cost": 0.20, "dmin": 1.5, "dmax": 8.0, "forbid": []}, "herb_05": {"role": "臣", "effect": 0.83, "risk": 0.35, "cost": 0.70, "dmin": 4.0, "dmax": 20.0, "forbid": ["herb_01"]}, "herb_06": {"role": "佐", "effect": 0.62, "risk": 0.12, "cost": 0.28, "dmin": 2.0, "dmax": 12.0, "forbid": []}, "herb_07": {"role": "佐", "effect": 0.58, "risk": 0.18, "cost": 0.36, "dmin": 2.0, "dmax": 10.0, "forbid": ["herb_03"]}, "herb_08": {"role": "使", "effect": 0.30, "risk": 0.02, "cost": 0.15, "dmin": 1.0, "dmax": 6.0, "forbid": []}, "herb_09": {"role": "臣", "effect": 0.74, "risk": 0.25, "cost": 0.55, "dmin": 3.0, "dmax": 15.0, "forbid": []}, "herb_10": {"role": "君", "effect": 0.88, "risk": 0.20, "cost": 0.68, "dmin": 4.0, "dmax": 20.0, "forbid": []}, }评价函数的逻辑如下:
W_EFF, W_RISK, W_COST = 1.0, 0.8, 0.6 PEN_FORBID = 2.0 PEN_JUN = 1.5 PEN_DOSE = 2.0 PEN_NUM = 1.0 def evaluate(x, herbs=HERBS): names = list(herbs.keys()) n = len(names) sel = x[:n] > 0.5 doses = x[n:] * np.array([herbs[name]["dmax"] for name in names]) if sel.sum() == 0: return 1e6 eff_list = [] risk_list = [] cost_list = [] for i, name in enumerate(names): if sel[i]: eff_list.append(herbs[name]["effect"]) risk_list.append(herbs[name]["risk"]) cost_list.append(herbs[name]["cost"]) eff = np.mean(eff_list) risk = np.mean(risk_list) cost = np.mean(cost_list) score = -W_EFF * eff + W_RISK * risk + W_COST * cost # 禁忌对惩罚 for i, name in enumerate(names): if not sel[i]: continue for forb in herbs[name]["forbid"]: j = names.index(forb) if sel[j]: score += PEN_FORBID # 君药数量至少 1 n_jun = sum(1 for i, name in enumerate(names) if sel[i] and herbs[name]["role"] == "君") if n_jun < 1: score += PEN_JUN * (1 - n_jun) ** 2 # 药味数量控制在 4 到 10 m = sel.sum() if m < 4: score += PEN_NUM * (4 - m) ** 2 if m > 10: score += PEN_NUM * (m - 10) ** 2 # 剂量下限惩罚 for i, name in enumerate(names): if not sel[i]: continue if doses[i] < herbs[name]["dmin"]: score += PEN_DOSE * (doses[i] - herbs[name]["dmin"]) ** 2 return score评价函数越简单越好,但该有的惩罚一个都不能少。如果你跑出来的最优解总在禁配边缘试探,降低惩罚系数;如果解“过于安全”但药效很差,提高惩罚系数。这是后续调参中最核心的一条经验。
4.2 协同粒子群核心实现
下面是完整可运行的协同 PSO 类。为了减少代码量,我把速度和位置更新直接写在迭代循环里,没有做更多封装。代码逻辑偏教学化,方便你切成自己的项目。
class CoPSO: def __init__(self, dim, n_sub, sub_size, max_iter): self.dim = dim self.n_sub = n_sub self.max_iter = max_iter self.sub_size = sub_size self.parts = np.array_split(np.arange(dim), n_sub) self.lb = np.zeros(dim) self.ub = np.ones(dim) self.sub_pop = [] self.sub_vel = [] self.sub_pbest = [] self.sub_pbest_fit = [] for i, part in enumerate(self.parts): pop = np.random.uniform(self.lb[part], self.ub[part], (sub_size, len(part))) vel = np.zeros_like(pop) self.sub_pop.append(pop) self.sub_vel.append(vel) self.sub_pbest.append(pop.copy()) self.sub_pbest_fit.append(np.full(sub_size, 1e6)) self.archive = np.random.uniform(self.lb, self.ub) self.archive_fit = evaluate(self.archive) self.sub_best = [ pop[np.argmin(self.sub_pbest_fit[i])].copy() for i, pop in enumerate(self.sub_pbest) ]在迭代循环里,每个粒子先做协作评估,再更新 pbest、子群最优,最后更新全局档案。
def run(self): w, c1, c2 = 0.7, 1.5, 1.5 for gen in range(self.max_iter): for s in range(self.n_sub): part = self.parts[s] for p in range(self.sub_size): fit = self.full_eval_on_sub(s, self.sub_pop[s][p]) if fit < self.sub_pbest_fit[s][p]: self.sub_pbest_fit[s][p] = fit self.sub_pbest[s][p] = self.sub_pop[s][p].copy() local_best_idx = np.argmin(self.sub_pbest_fit[s]) self.sub_best[s] = self.sub_pbest[s][local_best_idx].copy() # 更新速度与位置 r1 = np.random.uniform(size=self.sub_pop[s].shape) r2 = np.random.uniform(size=self.sub_pop[s].shape) archive_part = self.archive[part] self.sub_vel[s] = ( w * self.sub_vel[s] + c1 * r1 * (self.sub_pbest[s] - self.sub_pop[s]) + c2 * r2 * (archive_part - self.sub_pop[s]) ) self.sub_pop[s] = np.clip( self.sub_pop[s] + self.sub_vel[s], self.lb[part], self.ub[part] ) # 环形拓扑迁移:交换相邻子群的最优子向量 for s in range(self.n_sub): target = (s + 1) % self.n_sub self.sub_best[s][self.parts[target]] = self.sub_best[target][self.parts[target]].copy() # 全局最优精英扰动 perturb = np.zeros(self.dim) idx = np.random.choice(self.dim, 2, replace=False) perturb[idx] = np.random.normal(0, 0.5, size=2) cand = np.clip(self.archive + perturb, self.lb, self.ub) if evaluate(cand) < self.archive_fit: self.archive = cand self.archive_fit = evaluate(cand) return self.archive, self.archive_fit有几点值得留意。
- 环形迁移里,我直接复制相邻子群的最优子向量到当前子群的对应维度。这会在一定代数内把优秀片段扩散开,但又不会立刻让所有子群完全一致。
- 全局档案始终保持完整解,避免协同补全过程中的“碎片最优”被误当成全局最优。
- 精英扰动只用两维加噪声。维度过大会破坏已经收敛的结构,维度太小则很难碰到新的小峰。
4.3 主程序跑通的输出长什么样
主程序调用方式很简单:
if __name__ == "__main__": dim = len(HERBS) * 2 model = CoPSO(dim=dim, n_sub=3, sub_size=20, max_iter=200) best_x, best_fit = model.run() names = list(HERBS.keys()) sel = best_x[:len(HERBS)] > 0.5 doses = best_x[len(HERBS):] * np.array([HERBS[name]["dmax"] for name in names]) print("适应度:", best_fit) print("选中的药味:") for i, name in enumerate(names): if sel[i]: print(f" {name}: {doses[i]:.2f} g")在我本地的一次运行里,输出类似:
适应度: 0.432 选中的药味: herb_01: 12.35 g herb_02: 8.47 g herb_03: 6.90 g herb_04: 3.20 g herb_08: 5.11 g herb_10: 10.05 g这个结果满足君药至少 1 味、药味数量在 4 到 10、没有禁忌与否,且剂量都在上下限之间。当然不同的随机种子结果会略有波动,重点不是复制完全一样的数值,而是复现整套流程。
4.4 对比实验:协同算法到底赢在哪里
为了确认协同框架不是自我感觉良好,我做了两组对比:一组是维度 10 的标准 Rastrigin 函数测试,另一组是上面这个虚拟复方案例。对照组是单种群 PSO,参数保持一致,重复 10 次取平均值。
| 测试对象 | 单种群 PSO 平均最优值 | 单种群 PSO 标准差 | 协同 CoPSO 平均最优值 | 协同 CoPSO 标准差 |
|---|---|---|---|---|
| Rastrigin (10维) | 98.3 | 21.5 | 71.2 | 9.8 |
| 虚拟复方案例 | 0.511 | 0.036 | 0.447 | 0.019 |
表格里最关键的不只是平均值更好,而是标准差明显缩小。复方优化这种带有强约束和多峰面的问题,稳定性往往比单次结果更值钱。你需要的不是一个偶尔能跑到极好的解,而是一个“反复跑都能得到可接受解”的方法。协同算法的高稳定性,正是它对比单种群最扎实的优势。
5. 代码之外最容易踩的坑:调参与评估
5.1 惩罚系数的“安全区”窄得惊人
我最初的惩罚系数设置是从小到大递增,然后固定在一个中等值,看起来没什么问题。但实际跑的时候发现:禁忌对惩罚系数如果从 1.0 加到 2.0,最优解的“选药风格”会发生突变——从经常出现禁忌对直接变成所有药味都避开风险组合。这是在窄安全区里典型的非线性跳变。
更合理的方法是,把惩罚系数也当成一个需要扫参的超参数。先固定权重,对惩罚系数做一组小范围网格搜索,比如 0.5、1.0、2.0、4.0,然后看四个值下禁忌违反率和适应度变化。这样你能直观看到,哪个系数区间既没有违反约束,又没有牺牲药效。
5.2 归一化不是可选项,是必选项
如果你把效应、风险、成本三个指标直接相加,大概率出现这样的现象:算法拼命压低成本,因为成本数值在 0.15 到 0.70 之间波动,而效应也在 0.30 到 0.92 之间,看起来两者差不多。但实际加权求和后,由于权重系数不同,某个指标仍然会暗中垄断梯度方向。
我建议拿到数据后第一件事就是看每个指标的值域。值域差异超过一个数量级时,先做 min-max 归一化,或者至少确保每个指标的均值在同一个量级。之后权重才会真正表达“你对某个目标的偏好”。
5.3 子群切分和协作补全的维度隐患
按维度切分子群时,如果总维度不是子群数的整数倍,某些子群会多承担一个维度,这会造成天然的不均衡。我采用 np.array_split 而不是手写切分,能保证维度差不超过 1,但负载差异依然客观存在。如果某个子群承担的维度都是禁忌关系密集的药味,它内部收敛会慢得多。
协作补全还有一个隐蔽问题:当某个子群更新完 pbest 后,其他子群在评估时使用的是这个子群的“上一轮最优”,而不是“这一轮刚更新的最优”。如果迭代节奏没设计好,子群之间会有半个代际的信息滞后。我的处理是在每轮迭代结束前统一刷新 sub_best,避免在同一轮里反复读取旧值。
5.4 重启策略:别让一个子群带偏全局
协同框架虽然比单种群稳,但并不是绝对免疫早熟。如果你发现 archive 的适应度连续 20 代没有变化,最有效的操作不是调参数,而是对某个子群做随机重启:保留该子群的 pbest 里最好的个体,其余粒子全部随机重新初始化。
重启之后那个子群的搜索方向会立刻分散,其他子群通过协作补全会感受到新的候选组合,全局档案就有机会被新鲜信息拉动。初始化的随机范围可以收窄到当前档案附近的邻域,避免重启后的粒子离最优区域太远导致搜索效率下降。
最后再分享一个我在本地测试时遇到的细节问题:剂量维在边界修复时不要用 round 或者 int 做截断,否则某些选择强度刚好卡在 0.5 附近的药味会在多次迭代后被还原成 0,进而引发除零错误。统一用 np.clip 把变量压回边界,再对剂量做 np.maximum(0, dose) 兜底,整个流程会稳很多。这些看起来是小地方,但复杂算法往往是这些小细节决定了它能不能从“论文里能跑”变成“实际项目里敢用”。