老周是一位经验丰富的寻宝猎人。这一天他站在一片连绵的群山前,手里只有一个能测“宝藏信号强度”的罗盘——走得越近,指针摆得越猛。任务很明确:找出整片山里信号最强的那座金矿。真正的麻烦在于三点:山多得根本数不清;雾气浓得只能看清脚边两三步;而你不可能在退休前把每寸山坡都踩一遍。老周试过的办法不止一种:有时他闭着眼只管往高处爬;有时他故意带着“温度计”,让自己忽冷忽热地乱跳;有时他干脆叫来一队帮手,大家分头探路、定期碰头交换情报。这些看似普通的登山经验,恰恰就是因果发现中“基于分数方法”最关键的工程环节——搜索策略。本文用老周的故事,讲清楚为什么要在海量因果关系中搜索最优结构、三种主流搜索算法如何工作,以及它们如何权衡探索与利用。
搜索策略直接决定因果结构学习的效率与质量。掌握爬山、模拟退火与遗传算法,是从事因果发现与可信 AI 工程的基本功,也是构建可审计、可干预 AI 系统的安全底座。
可视化演示
展示爬山法邻域操作寻优、模拟退火温度调度与劣解接受概率、遗传算法种群进化三代操作。
本节配有交互动效(进入视口后自动加载,约 3KB)。
爬山法:贪心局部寻优 加边/删边/反转边三种邻域操作 每次选评分提升最大的操作 score(new)-score(old)>0 贪心策略
搜索为何难
老周出发前先在驿站买了一卷“全山脉地形图”。展开一看他傻了眼:地图上标注的山峰密密麻麻,而且这张图还在随心所欲地扩展——每多一个变量,就像地图多了一整条新山脉。真正的宝藏猎人知道,这辈子不可能把每座山都爬一遍。
在因果发现里,这张“山脉图”就是所有可能的因果结构集合。给定 p 个变量,每对变量之间可以连一条有向边,但要保证没有环——这样的有向无环图(DAG)数量用 Robinson 递推可以精确算出。它根本不是线性增长,而是超指数爆炸。
正因为空间太大,寻找最优结构被证明是 NP 难问题,穷举只适用于 4~5 个变量的玩具场景。实用的出路是:让评分函数担任向导,用搜索策略在巨大空间里“走一步看一步”,以有限的计算量逼近全局最优评分。搜索策略的优劣,直接决定结构学习的效率与质量。
所以老周并没有被地图吓退。他收起地图,掏出罗盘,决定用脑子而不是用脚来征服群山——先试最笨的办法。
- 组合项从 p 个节点中选出 k 个节点的组合数,相当于先从地图上圈出 k 座“候选山”,其余节点随后再处理。
- 二分连接数被选出的 k 个节点与剩余 p−k 个节点之间,每条有向边可连可不连,共 k(p−k) 个边位置,带来 2 的幂。
- 容斥递推交替加减不同 k 的项并乘上规模缩小的 d(p−k),从而去掉所有成环情形,最终得到精确的 DAG 计数。
爬山算法
老周的第一招最简单:闭眼向上爬。站定一处,向左迈一步试试信号,向右迈一步试试信号,哪边强就往哪边走,反复循环。这个方法上路快、不费脑子,但老周很快发现——他常常停在半山腰的小土包上,看得见远处的大金山,却下不去。
在 DAG 空间里,“迈一步”被定义为一次邻域操作:加一条边、删一条边或反转一条边的方向,同时保证结果仍是无环图。从某个初始图出发,爬山算法每次遍历全部邻域,选择评分最优的那个图作为下一步。
爬山算法是一种纯粹的贪心(利用)策略:它只看一步之内的信息,从不“战略性撤退”。因此它对初始解非常敏感——起点不同,终点天差地别。工程上常用“随机重启”缓解:随机生成多个初始图分别爬山,取其中评分最优的结果。
老周管这个叫“近视登山法”:快是真快,但经常白忙一场。于是他在口袋里揣了一把骰子——爬完一趟不满意,就掷骰子换一座山重新爬,这便是随机重启的朴素智慧。
- 邻域集合与当前图仅相差一条边的所有合法 DAG 的集合,相当于老周一步能踩到的左右两个点位。
- 贪心更新在当前一步能到的地方里挑评分最低的图作为新位置,只比一步,绝不前瞻。
- 停止条件当所有邻域评分都不低于当前评分,说明已经站在“一步视野”内的最低点,算法终止。
模拟退火
第二次进山,老周学乖了。他给自己装了一支“神奇温度计”:出门时体温很高,脚步发飘,经常放着眼前的上坡不走,反而往谷底乱窜;等太阳落山,体温降下来,他的步伐越来越稳,最终稳稳当当地停在了最大金山上。
这个灵感来自冶金工艺:金属在高温下原子剧烈运动、结构无序;缓慢冷却后,原子才排列成能量最低的晶体。模拟退火把“温度”做成一个参数 T,用接受概率控制搜索的随机性。
每一轮,算法随机生成一个邻域图 G′。若 G′ 评分更好则直接接受;若更差,则以概率 exp(−ΔS/T) 接受,其中 ΔS 是两者评分差。温度按调度(通常指数衰减)逐渐降低,随机跳跃越来越少,最终收敛到爬山式精细搜索。
老周的经验是:把温度当成“好奇心”来管理——年轻时满世界乱跑,成熟后专心挖宝。这正是探索与利用平衡的最美体现。
- 代价差值新图与当前图的评分差,代表这次“下坡”要损失多少信号强度。
- 温度参数T 越大,指数负值的绝对值越小,接受概率越大;T 越接近 0,概率越接近 0。
- 指数映射指数函数把任意正代价压缩到 (0,1) 区间,成为可比较的概率值。
遗传算法
一个人的力量终究有限。第三次寻宝,老周摇来一支二十人的寻宝队。大家从不同山口进山,每隔几天集合一次,把两两的路线图各取一半拼成新路线分给新人;偶尔有人心血来潮拐进一条从没走过的沟谷。几轮下来,整支队伍奇迹般聚到了最大的金矿上。
遗传算法把每个候选 DAG 看成一个“个体”,一组个体构成“种群”。个体优劣由适应度函数衡量:在我们的问题里,评分越低(BIC 越小)适应度越高,就像藏宝图越接近真实金矿越有价值。
每一代,算法按适应度比例选择若干“父母”,通过交叉把两个父代图的边结构混合生成子代,再以一定概率变异(随机执行一次邻域操作)。子代构成新一代种群,替代旧种群,如此迭代直到评分收敛——相当于让一队老周同时进化。
老周发现,遗传算法最妙的地方是“共享”:一个人的迷路信息,通过交叉能变成全队的财富;变异又保证了队伍不会过早统一到同一条错误路线上。
- 适应度转换用负 BIC 作为适应度,评分越低适应度越高,天然满足“择优”方向。
- 归一化分母把种群所有个体的适应度求和,让每个概率落在 0~1 之间。
- 比例选择个体 i 的适应度占全种群的比例即其被选中概率,数值越大越容易当“父母”。
探索与利用
寻宝队内部分成了两派:激进派主张多去没标记过的新山脉碰运气,保守派主张守住当前信号最强的山头死磕到底。老周当了和事佬:前期让大家放开了探索,后期统一收网深挖——顺序不能反。
这是搜索算法的“第一性矛盾”:探索(exploration)去未去之处获取新信息,利用(exploitation)在已知最优处深耕获利。纯探索像无头苍蝇,纯利用像井底之蛙。三种算法的性格差异,本质就是它们对这对矛盾的不同取舍。
爬山纯利用不探索;遗传算法用变异维持少量探索;模拟退火则是把“探索权重”做成随时间衰减的调度表——温度就是那个旋钮。
老周把这套时间表贴在营地里:白天探路,傍晚挖宝。道理朴素——先有广度,才有深度。
- 初始温度T₀ 决定开局探索的猛烈程度,类似寻宝队出发时愿意跑多远。
- 衰减因子α 略小于 1,每轮都乘一次,温度呈指数衰减。
- 迭代步数k 越大温度越低,调度曲线从“燥热”平滑滑向“冷静”。
工程落地
理论讲得再好,寻宝队长还要管三件杂事:从哪出发、何时收工、带多少干粮。老周的装备清单,就是搜索算法的工程配置单。
初始解最常见的选择是空图(没有任何边),实现简单且无偏;如果领域专家能给出若干先验边,塞进初始图会让搜索少走很多弯路。停止条件通常是“评分改善小于阈值 ε”或者“达到最大迭代次数 t_max”。
复杂度上,爬山每轮要扫描 O(p²) 个邻域并逐一评分,p 稍大就非常昂贵。工程上常用随机采样邻域子集代替全量扫描,或限制每次只评估少量候选;退火的迭代次数、初始温度和降温系数,遗传的种群大小、交叉变异概率,都需要按数据规模做折中。
老周的忠告:装备不是越贵越好。参数调得太激进,再好的算法也会在超指数空间里迷路;调得太保守,又和爬山没有区别。
- 评分变化量相邻两轮评分差的绝对值,波动很小说明搜索已趋于稳定。
- 收敛阈值ε 越小搜索越久、结果越精细,等于“信号不再增强就认定到顶”。
- 迭代上限t_max 是兜底护栏,防止算法在超指数空间里空转到天荒地老。
效果评估
满载而归的老周没有急着庆祝。他翻出一张官方发布的“标准藏宝图”——那是用人工合成数据造的已知因果结构——将自己的路线图叠上去逐格比对:哪些边找错了,错了几条,来回跑十次结果稳不稳定。
在合成数据上,真实 DAG G* 是已知的,因此可以量化学习结果与真值的差距。最常用的指标是 SHD(结构汉明距离):统计两张图的邻接矩阵中不一致的元素个数,0 表示完全恢复。
工程上还会做多次随机初始化,观察 SHD 的方差——稳定地小才是可靠的。在 AI 安全视角下,这一步尤其关键:因果图一错,下游的归因、干预与解释全盘皆错;搜索质量划定了系统“可被信任”的安全边界。
老周把评估结果写进行囊笔记,也写进了团队的规矩:没有经过 SHD 和稳定性检验的因果图,一律不准用于下游决策。这是寻宝人的底线,也是 AI 的安全线。
- 邻接矩阵元素A(i,j) 取 1 表示存在边 i→j,取 0 表示不存在,是因果图的数字化形态。
- 指示函数方括号内条件成立时计 1,否则计 0,用于标记“这一格两幅图是否不同”。
- 双重求和遍历所有有序节点对,把不一致的格子全部累加,得到结构总误差。
AI 应用场景
场景一:金融风控中的逾期归因
阶段:因果发现(模型结构学习)
解决的问题:传统风控模型只输出“谁可能逾期”的预测分,却说不清“为什么”。分析师手工画因果图既慢又主观。用基于分数的搜索自动学习变量间因果 DAG,能让逾期率的关键驱动因素一目了然。就像老周用罗盘定位金矿,搜索算法用评分函数定位真正的风险源头。
具体案例:某消费金融公司收集用户收入、负债比、征信查询次数、历史逾期等 12 个特征,用模拟退火搜索 BIC 评分最优的因果图,锁定了“负债比→逾期率”的直接因果边,并据此设计还款提醒策略,逾期率下降 18%。
场景二:大模型可解释与安全审计
阶段:模型审计与归因分析
解决的问题:大模型回答“正确但不知道哪条推理链出了问题”,出现幻觉时难以追责。用因果结构搜索刻画模型内部概念依赖,定位幻觉的传播路径。
具体案例:安全团队对客服大模型的 2000 条错误回复做因果归因:以“输入摘要话题、检索上下文、注意力权重、输出置信度、是否幻觉”为节点,用遗传算法搜索因果图,发现“注意力漂移→输出置信度虚高→幻觉”的传导链,为模型修复提供了证据。
场景三:生物基因调控网络重建
阶段:结构化因果学习
解决的问题:基因表达数据维度高、真实调控结构未知,手工建模不现实;搜索算法需要在海量候选 DAG 中逼近最优网络。
具体案例:团队对 3000 个细胞样本、20 个关键基因的表达数据,用带随机重启的爬山算法学习调控网络,三天内得到与文献高度吻合的 15 条关键边,其中 12 条已被实验验证。
Python 演示
import randomimport numpy as np def has_cycle(adj): # DFS 三色标记判断有向图是否含环 p = adj.shape[0] color = [0] * p def dfs(u): color[u] = 1 for v in range(p): if adj[u, v] and (color[v] == 1 or (color[v] == 0 and dfs(v))): return True color[u] = 2 return False return any(dfs(i) for i in range(p) if color[i] == 0) def bic_score(X, adj): # BIC 评分,越低越好:对每个变量回归其父变量 n, p = X.shape total = 0.0 for j in range(p): pa = [i for i in range(p) if adj[i, j] == 1] if pa: mat = np.column_stack([X[:, i] for i in pa] + [np.ones(n)]) beta, _, _, _ = np.linalg.lstsq(mat, X[:, j], rcond=None) pred = mat @ beta k = len(pa) + 1 else: pred = np.full(n, X[:, j].mean()) k = 1 rss = float(np.sum((X[:, j] - pred) ** 2)) total += n * np.log(max(rss / n, 1e-9)) + k * np.log(n) return total def neighbors(adj): # 枚举所有单边邻域操作:加边、删边、反边 p = adj.shape[0] for i in range(p): for j in range(p): if i == j: continue if adj[i, j] == 1: na = adj.copy() na[i, j] = 0 yield 'remove', i, j, na else: na = adj.copy() na[i, j] = 1 if not has_cycle(na): yield 'add', i, j, na if adj[j, i] == 1: nb = adj.copy() nb[j, i] = 0 nb[i, j] = 1 if not has_cycle(nb): yield 'reverse', i, j, nb def hill_climbing(X, max_iter=60): adj = np.zeros((X.shape[1], X.shape[1]), dtype=int) score = bic_score(X, adj) for _ in range(max_iter): cand = min(neighbors(adj), key=lambda c: bic_score(X, c[3])) new_score = bic_score(X, cand[3]) if new_score >= score - 1e-9: break adj, score = cand[3], new_score return adj def simulated_annealing(X, max_iter=120, t0=3.0, cooling=0.97): adj = np.zeros((X.shape[1], X.shape[1]), dtype=int) score = bic_score(X, adj) best_adj = adj.copy() best_score = score t = t0 for _ in range(max_iter): cand = random.choice(list(neighbors(adj)))[3] new_score = bic_score(X, cand) dc = new_score - score if dc < 0 or random.random() < np.exp(-dc / t): adj = cand score = new_score if score < best_score: best_adj = adj.copy() best_score = score t *= cooling return best_adj def random_dag(p): # 按随机拓扑序生成一个随机 DAG adj = np.zeros((p, p), dtype=int) order = list(range(p)) random.shuffle(order) for x in range(p): for y in range(x + 1, p): if random.random() < 0.5: adj[order[x], order[y]] = 1 return adj def genetic_algorithm(X, pop=10, gen=20, rate=0.3): p = X.shape[1] pool = [random_dag(p) for _ in range(pop)] for _ in range(gen): pool = sorted(pool, key=lambda a: bic_score(X, a))[:pop // 2 + 1] while len(pool) < pop: a, b = random.sample(pool, 2) child = np.zeros((p, p), dtype=int) for i in range(p): for j in range(p): if i != j: child[i, j] = a[i, j] if random.random() < 0.5 else b[i, j] if random.random() < rate: child = random.choice(list(neighbors(child)))[3] if has_cycle(child): child = np.zeros((p, p), dtype=int) pool.append(child) return min(pool, key=lambda a: bic_score(X, a)) if __name__ == '__main__': random.seed(7) np.random.seed(7) n = 300 x1 = np.random.randn(n) x3 = np.random.randn(n) x2 = 0.8 * x1 + 0.7 * x3 + np.random.randn(n) * 0.5 X = np.column_stack([x1, x2, x3]) true_adj = np.array([[0, 1, 0], [0, 0, 0], [0, 1, 0]]) for name, fn in [('爬山', hill_climbing), ('退火', simulated_annealing), ('遗传', genetic_algorithm)]: adj_out = fn(X) print(name, 'SHD =', int(np.sum(adj_out != true_adj)), ' BIC =', round(bic_score(X, adj_out), 2))
total += n * np.log(max(rss / n, 1e-9)) + k * np.log(n)BIC 的拟合与惩罚两项:数据拟合越好残差越小,但每多一条父边就多付 k·ln n 的“复杂度税”,防止搜索傻乎乎地乱加边。if adj[u, v] and (color[v] == 1 or (color[v] == 0 and dfs(v))):三色 DFS 发现环就返回 True,保证邻域生成时只放行合法 DAG,相当于“山路必须不绕圈”。yield 'add', i, j, na生成器逐个吐出加边、删边、反边操作,这是三种算法共同的“一步”。cand = min(neighbors(adj), key=lambda c: bic_score(X, c[3]))爬山在前后左右一步内挑评分最低的图,纯利用不探索。if dc < 0 or random.random() < np.exp(-dc / t):退火接受准则:更好直接走,更差按概率走,温度 t 越大概率越小。pool = sorted(pool, key=lambda a: bic_score(X, a))[:pop // 2 + 1]遗传算法每代先按评分排序,只留最优秀的一半当父母,优胜劣汰。child[i, j] = a[i, j] if random.random() < 0.5 else b[i, j]交叉:逐格随机继承两个父图的边,拼出“混血”藏宝图。if has_cycle(child): child = np.zeros((p, p), dtype=int)变异后的孩子若成环就退回空图,保证种群始终合法。
常见误区
误区初学者认为爬山算法从任意起点出发都能找到最好的因果结构,跑一次看到评分不再下降,就当作最终答案,完全不管起点是什么。
正解爬山只看一步内的评分,非常容易停在评分函数的局部峰。工程上必须配合随机重启、多起点运行,或用模拟退火、遗传算法这类带随机性的方法,才能以更高概率逼近全局最优。老周的经验是:一座山爬不放心,多爬几座才踏实。
为何常见:人脑直觉认为“不断变好=最终最好”,忽略了每一步选择都受限于当前一步的视野。
误区以为退火接受较差解等于完全不讲道理地随机跳,和无脑随机搜索没什么区别,评分再差也会被接受。
正解接受差解的概率由 exp(−ΔS/T) 精确控制:ΔS 越大越难接受、T 越低也越难接受,且温度按调度平滑下降,探索期之后自然收敛到精细利用。这种“可控的随机”是它与纯随机搜索的关键区别。
为何常见:只看到“接受差解”四个字,忽略了背后的指数概率公式和降温调度共同形成的约束。
误区觉得遗传算法只要适应度函数写得对,种群大小、交叉概率、变异概率随便设置都能得到好结果。
正解种群太小,交叉很快失去多样性,算法退化成反复复制同一对父母;变异率过低则难以引入新结构。必须搭配足够大的种群和恰当的变异率,才能维持“探索”所需的基因多样性。
为何常见:把“进化”想得太神奇,忽略多样性维持机制才是遗传算法的发动机。
误区认为一旦评分函数收敛、分数不再下降,学习得到的结构就必然等于真实因果关系,可以直接用于下游分析。
正解基于分数的搜索只能找到“在评分意义下最优”的图,且同一评分可能对应多个 Markov 等价图;还要通过效果评估(如 SHD、多次运行的稳定性)和领域知识交叉验证,才能谈因果解释。
为何常见:把“数学上的最优”与“事实上的正确”划等号,忽略了观测数据本身的信息边界。
误区认为模拟退火温度越高、探索越多,效果就一定越好,于是一上来就设一个极大的初始温度。
正解温度过高意味着连评分极差的解都频繁接受,搜索在空间里乱撞,浪费大量迭代也难以落稳到峰顶。需要和降温速率配合:先高温粗探、再低温精爬,探索与利用要按时间表分配。
为何常见:只看到探索的收益,忘了探索的代价是牺牲利用,二者必须权衡。
面试题
Q1. 关于候选 DAG 数量随变量数的增长规律,下列说法正确的是?
- A.随变量数线性增长
- B.随变量数多项式增长
- C.随变量数超指数增长← 正确答案
- D.与变量数量完全无关
答案:C。候选 DAG 数量满足 Robinson 递推:p=5 时约 29281 种,p=10 时约 4.2×10¹⁸ 种,呈超指数爆炸。就像老周面前的山多到永远不可能逐峰踩遍,穷举注定不现实。
Q2. 爬山算法在某处停止,所有邻域操作都无法再降低评分,这说明?
- A.一定发现了全局最优结构
- B.可能陷入了局部最优← 正确答案
- C.评分函数出现了严重错误
- D.数据中一定存在较多噪声
答案:B。爬山只比较一步可达的邻域,评分不再改善只说明“眼前一寸内没有更好的图”;远处可能还有评分更低的全局峰。所以老周会换起点重爬几次,而不是就此收工。
Q3. 以下哪项不属于基于分数搜索的典型邻域操作?
- A.增加一条有向边
- B.删除一条有向边
- C.反转一条有向边
- D.把 DAG 变成无向图← 正确答案
答案:D。加边、删边、反边是三种标准邻域操作,且必须保持无环;把 DAG 变成无向图改变了问题定义,不再是“在 DAG 空间中走一步”,因此不属于典型的单步邻域操作。
Q4. 模拟退火中,某较差候选解的接受概率公式是?
- A.exp(+ΔS/T)
- B.exp(−ΔS/T)← 正确答案
- C.1 − ΔS/T
- D.ΔS/T
答案:B。接受概率等于 exp(−ΔS/T),其中 ΔS 是新解与当前解的评分差(正数)。代价越大概率越小、温度越低概率越小,恰是“高温敢下坡、低温稳爬坡”这一直觉的数学表达。
Q5. 当温度 T 趋近于 0 时,模拟退火的行为最接近下列哪种?
- A.完全随机搜索
- B.爬山式贪心上升← 正确答案
- C.遗传算法的交叉
- D.穷举全部候选图
答案:B。T→0 时 exp(−ΔS/T)→0,几乎不再接受较差解,只接受评分更优的移动——这与爬山算法完全一致。这也解释了为什么退火是“先探索后利用”的平滑版本。
Q6. 遗传算法中,有助于维持种群多样性的机制有哪些?
- A.交叉组合父母边结构← 正确答案
- B.变异随机增删反边← 正确答案
- C.适应度高的个体被选中
- D.迭代一定次数后停止
答案:AB。交叉产生边结构的新组合、变异注入随机扰动,两者都在制造多样性;适应度选择反而是“优胜劣汰”,会压缩多样性;停止条件只控制运行时长,与多样性无关。
Q7. 在探索与利用的权衡中,模拟退火实现平滑过渡靠的是?
- A.逐渐增大变异概率
- B.温度随迭代指数下降← 正确答案
- C.不断扩充邻域范围
- D.每次从空图重新开始
答案:B。T_k = α^k × T₀ 使温度指数下降,前期接受差解概率高(多探索),后期只接受改善(多利用),实现从粗到细的自然过渡,这正是老周“白天探路、傍晚挖宝”的时间表。
Q8. 关于 SHD 指标,下列说法正确的是?
- A.值越大说明结构越接近真实图
- B.为 0 时学习图与真值邻接矩阵完全一致← 正确答案
- C.SHD 一定随迭代次数单调下降
- D.SHD 只能用于无向图比较
答案:B。SHD 统计两个邻接矩阵中不一致的元素个数,越小越好;为 0 表示结构完全一致。但它在 Markov 等价图上可能区分不出方向差异,所以还要结合稳定性测试与领域知识。
Q9. 关于爬山算法与模拟退火的对比,下列说法正确的有?
- A.爬山较易陷入局部最优← 正确答案
- B.退火靠接受差解跳出局部峰← 正确答案
- C.爬山得到的评分必然更低
- D.两者都靠邻域操作生成候选← 正确答案
答案:ABD。C 错误——退火往往得到不差于爬山的解;A、B、D 正确:爬山只用利用、退火加了探索,但两者都在“加边、删边、反边且保持无环”的邻域上移动。
Q10. 关于随机重启爬山的说法,正确的有?
- A.从多个随机初始图开始搜索← 正确答案
- B.多次运行会使总耗时成倍增加← 正确答案
- C.必然找到全局最优的因果图
- D.提高跳出坏局部峰的概率← 正确答案
答案:ABD。随机重启本质是用多次独立爬山的计算换取更可靠的全局逼近,总时间约为重启动次数倍的叠加;它只是提高概率,不能保证找到全局最优,故 C 错。这正是老周“多爬几座山”策略的科学版本。