跳到主要内容

手搓系列 · 搓一个能查账的推理循环

造轮匠
造轮匠

· 阅读约 9 分钟

2016 年 3 月,首尔,AlphaGo 对李世石第二局。第 37 手落在五路肩冲那个位置,解说席先是安静了几秒,然后有人怀疑是不是程序出了 bug。那手棋在人类棋谱里几乎没人下过,AlphaGo 自己的策略网络给它的评分大概是万分之一。

搜索还是把它走了。

这件事我这些年反复想起。不是因为它漂亮,是因为它说明了一个结构上的事实:那一手不是网络想出来的,是搜索从网络手里抢过来的。网络说这步不像人下的,搜索说,不像人下也得算一算后面。最后拍板的是搜索。

上个月读到 Thore Graepel 在 MIT Technology Review 上的一篇文章,他当年是 AlphaGo 团队的核心成员,现在在 UCL 做机器学习讲席。文章的意思大概是:今天的语言模型看着像在思考,实际上还是在很快地做联想式预测,跟 AlphaGo 那套"直觉 + 搜索"里的直觉那一半是一回事。思维链能让数学和代码的分数涨,涨是真的,但底下跑的仍然是同一个 next-token 过程,没有多出第二个组件。

我读的时候一直在想一个更具体的问题:如果让我把"第二个组件"拆出来,最小能小到什么程度?于是上周我打开编辑器搓了一个。不是搓 AlphaGo,是搓那个能对直觉说"不对,再查"的东西。

先给个最糙的直觉:一个能查账的推理器,本质上就是一张随时能翻的状态表,加上一个专门负责挑"下一步该查什么"的打分器,再加上一个只在证据支持时才改表的算子。三样东西,没有第四样。

状态表长什么样

AlphaGo 的搜索里有个东西叫游戏树,每个节点是一个局面,每条边是一步棋。语言模型没有这个。它有的是几千亿个权重,你问它一句它答一句,中间不存在一个"我现在确定了什么、还在怀疑什么"的地方可以让你打开看。

我们先把那个地方造出来。四个标签就够:settled、doubtful、ruled_out、open。每个命题挂一个。

# 一条记录长这样,先别管格式丑不丑
state = {
    "电源正常":   ("ruled_out", 0.02, "进线电压为 0"),
    "保险丝完好": ("open",      0.30, None),
    "开关导通":   ("open",      0.20, None),
    "电机没卡死": ("open",      0.10, None),
}

三个字段:标签、当前信念、支撑这个信念的那条证据。第三条是关键——没有它,这张表和模型内部的激活值没区别,都是一堆你没法追责的数字。

现在缺的是谁来改这张表。这就得写打分器了。

打分器:拿熵当尺子

我们让它挑"下一步该测什么"。挑法用信息增益:假设我现在这套信念分布是 ps,某个测试会把假设劈成两半,我算劈完之后两边的平均熵,跟当前熵差多少,差得越多越值得测。

import math

def H(ps):
    return -sum(p * math.log2(p) for p in ps if p > 0)

def expected_H(ps, mask):
    total = 0.0
    for branch in ([p for p, m in zip(ps, mask) if m],
                   [p for p, m in zip(ps, mask) if not m]):
        w = sum(branch)
        if w > 0:
            total += w * H([p / w for p in branch])
    return total

mask 就是某个测试的真值表:对每个假设,这个测试会返回真还是假。这一行干这件事——把一个测试劈成"返回真"和"返回假"两个分支,各算各的熵,再按概率加权。

然后主循环:

hyps  = ["电源", "保险丝", "开关", "电机"]
prior = [0.4, 0.3, 0.2, 0.1]

tests = {
    "测进线电压": [True,  False, False, False],
    "看保险丝":   [False, True,  False, False],
    "短接开关":   [False, False, True,  False],
    "盘电机":     [False, False, False, True],
}

truth = 1          # 真坏的是保险丝,这个只有台机器知道
ps = prior[:]

for step in range(4):
    h = H(ps)
    gains = {name: h - expected_H(ps, m) for name, m in tests.items()}
    pick = max(gains, key=gains.get)
    result = tests[pick][truth]

    mask = tests[pick] if result else [not m for m in tests[pick]]
    ps = [p if m else 0.0 for p, m in zip(ps, mask)]
    s = sum(ps)
    ps = [p / s for p in ps]

    print(f"step {step}  H={h:.3f}  选 {pick}  结果={result}  剩余={[round(p,3) for p in ps]}")

跑一下,看看出来啥:

step 0  H=1.846  选 盘电机      结果=False  剩余=[0.444, 0.333, 0.222, 0.0]
step 1  H=1.530  选 短接开关    结果=False  剩余=[0.571, 0.429, 0.0, 0.0]
step 2  H=0.985  选 测进线电压  结果=False  剩余=[0.0, 1.0, 0.0, 0.0]

三步收敛,中间每一步都留了记录:当前熵多少、选了哪个测试、返回什么、剩下的信念怎么变。你要是事后想追责,直接翻这三行就行。

但这三步里有个地方值得盯一下。它第一手测的不是最可能坏的保险丝,是电机——因为电机先验最小,排除它最省熵。信息增益这个尺子偏爱先排除小概率假设,它跟"最可疑的先查"不是一回事。这个偏差在诊断场景里是要命的,但先放着,后面再说。

换个先验,看它怎么死

现在把先验改成极端自信:prior = [0.97, 0.01, 0.01, 0.01],也就是这台机器的历史记录说,九成七是电源问题。真坏的是电源。

跑:

step 0  H=0.242  选 看保险丝    结果=False  剩余=[0.980, 0.0, 0.010, 0.010]
step 1  H=0.163  选 短接开关    结果=False  剩余=[0.990, 0.0, 0.0, 0.010]
step 2  H=0.082  选 测进线电压  结果=True   剩余=[1.0, 0.0, 0.0, 0.0]

看着挺对,最后还是收敛到电源了。但它对得毫无道理——第三步之前,四个测试的预期增益我打出来全是零,打分器根本没得选,max 只是按字典顺序捡了第一个。它是被平手规则推着蒙对的,不是被推理推着查对的。

更要命的是前两步。信念压在电源上 0.97,这套打分器就再也不去测电源了——因为测一个你几乎已经确定的命题,预期能砍掉的熵最小。它整个搜索过程都在绕开自己最信的那个东西。如果真实情况恰好在那个 0.97 里藏着一点错,这个循环查一辈子也查不出来。

这就是我搓完这版最想说的话:一个只会降低不确定性的搜索,会被自己的先验锁死。 它优化的是"从我现在的信念看,哪个动作最划算",不是"哪个动作最可能证明我错了"。这两件事在封闭棋盘上差得不多,在开放世界里差得很远。

AlphaGo 那手 37 之所以能出现,恰恰因为它的搜索不是按"降熵"选点的。它是按胜率估值往下推的,策略网络给的一万分之一只是一个先验,不是一个否决。搜索有权力把一个"不像人下的"点算到底,然后拿算出来的结果去覆盖先验。

这版糙在哪

得说清楚,上面这三十行离"推理"还差得远。

假设空间是我人工枚举好的,四个,一个不多一个不少。真实诊断里你连"该考虑哪些原因"这件事本身都得搜。测试的真值表也是我写死的,真实世界里一个测试返回什么,取决于一堆你没建模的中间变量。还有那个 truth 变量——这版里它是上帝视角,真跑起来你只有观测,没有 truth。

另外,这张状态表是扁的。四个命题并列摆着,谁也不依赖谁。真东西是有结构的:电源坏了会连带保险丝的状态没意义,开关短接会改变电压读数的解释。要往上补就得给表加边,变成一张图,然后修订一条就得顺着边传播。这一步加上去,代码量大概要翻三倍,我这次先没做。

但有个东西是这版里做对了的,我觉得不该在往上补的时候丢掉:每一次信念修改,都必须挂一条证据。 上面那个 state 字典里第三列,None 就是不合法状态。这不是工程洁癖,这是"可审计"这三个字的全部重量所在。Graepel 那篇文章里有句话我印象很深,大意是医学、工程、科学这些地方要的不是一个碰巧正确的答案,是一条能从头查到尾的链子。你给医生一个"我觉得是肺炎",他没法用;你给他"这三个指标支持、那两个排除了、剩下这个还没查",他才能接手。

现在的模型给的是前一种。思维链看着像后一种,但你仔细看那些步骤——很多是答案已经出来了之后往回补的。补出来的链子当然自洽,因为它就是照着结论编的。这个我没法从外部严格证明,但凡自己搓过一遍生成过程的人都该有这个警觉:next-token 这个目标函数,不奖励"先有证据后有结论"这个顺序。

再往上那层

我这几天在想的下一步,是把打分器从"降熵"改成"降熵 + 主动证伪"。具体说,给每个还没被排除的假设算一个"它错了的话,哪个测试最先能看出来",把这个作为第二项加进增益里。这样先验 0.97 的那个假设也会被拽出来查一遍,代价是测试次数变多。

这件事在封闭假设空间里好写,在开放世界里很难——因为"能证伪某个假设的测试"本身得先被想出来,而想出来这件事又回到了直觉网络。绕了一圈,直觉和搜索还是得互相喂。AlphaGo 也是这么转的:策略网络缩小搜索宽度,搜索的结果再回去训策略网络。两个组件谁也离不开谁,但它们是两个组件,不是一个。

这大概就是我认为 scaling 解决不了这件事的原因。把模型放大,放大的是那一半。直觉变强,但它不会因此变得愿意怀疑自己——因为它内部没有一个地方可以放"我还在怀疑"这个状态,也没有一个动作叫"去查一下"。这两样东西得从架构上加,不是从数据里长。

所以你看,能查账的推理循环拆开就这么点东西:一张挂证据的表、一个挑下一步查什么的打分器、一个只在证据支持时才改表的算子。上面那三十行丑是丑了点,但它真的能跑,而且每一步你都说得出为什么。

边界得划清楚:这版只是用来懂的,别拿去跑真活。生产里你要做诊断或者做任何高风险判断,老老实实用有完整不确定性建模和审计日志的系统,别拿我这三十行去对着病人。造轮子是为了以后用别人轮子的时候心里有底,不是劝你在产线上手搓。

下次想搓的是给这张表加边——让一条证据的修订能顺着依赖关系传播下去,而不是每次只改一格。那一步补上,"状态"这个词才勉强配得上。

造轮匠
造轮匠

不调库、从零搓一个最小能跑的版本讲原理,直觉先行再补严谨。

查看主页 →