初识推理——贝叶斯网络

初识推理——贝叶斯网络

在《AI Game Programming Wisdom》中提到了用来实现游戏中智能体推理能力的方式,即 「贝叶斯网络」。按理说身为游戏开发者,让游戏中的NPC知道任意完全的信息并非难事,但像让它像常人一样,在不知晓信息全貌的情况下,作出不准确但又合理的推理却非易事。

image

比如即时战略游戏中,在战争迷雾的干扰下玩家只能通过一些方法窥得敌方部分动向,然后根据自身判断采取战略。对于人机来说战争迷雾形同虚设,只要我们愿意,完全可以将全局信息告知AI,但显然这对玩家不公平。我们希望它也能像正常玩家一样去根据残缺信息进行推理,再规划行动。

下面我们就来看看贝叶斯网络是如何做到合理推理的,不过我们会从理论开始讲起,期间我们会经常结合例子,避免枯燥,且保证公式简单,而后再尝试用 C# 实现,完整代码可以戳这里

PS:我还是提前说一下吧,其实贝叶斯网络在游戏开发中的应用很有限 (σ`д′)σ ,我甚至没有找到代表性的案例。如果你依旧对它感兴趣可以接着看。

一、贝叶斯公式

虽说贝叶斯网络并不是贝叶斯本人发明的,但这个网络却实实在在用到了贝叶斯提出的贝叶斯公式,以防你忘记了概率论相关的基础知识,这里我们一起回忆一下:

  • \(P(B)\) 意为:「B事件」发生的概率
  • \(P(A|B)\) 意为:在「B事件」发生的情况下,「A事件」发生的概率
  • \(P(AB)\) 意为:「A事件」与「B事件」同时发生的概率,不难理解 \(P(AB) = P(BA)\)
  • \(P(AB) = P(A|B) * P(B)\)

而贝叶斯公式就是:

\[P(A|B) = \frac{P(B|A)*P(A)}{P(B)} \]

看似平平无奇,或许你也能根据刚刚的基础知识中推出这一公式。这一公式的亮点在于让 \(P(A|B)\)\(P(B|A)\) 之间建立了联系,帮助我们进行逆推理

假设「A事件」是在800里外开枪打中靶子的概率,「B事件」是正中靶心的概率,在有充分信息(也就是知道了 \(P(A)\)\(P(B)\) )的情况下,我们就能由“在800里外开枪正中靶心”的概率,来逆推出“正中靶心的一枪来自800里外”的概率。

二、贝叶斯网络

现在该讲讲贝叶斯网络了,贝叶斯网络虽也是人工智能工具,但它并非与如今热门的神经网络是同类。贝叶斯网络是数据结构中的“图”结构,具体来说是一个 有向无环图,就像这样(图出自《人工智能:现代方法(第四版)》):

image

图中的每个节点,就表示一类事件。图中的有向边则表示 概率依赖,例如上图中「Burglary(盗窃)」指向「Alarm(警报)」的有向边就意味着警报响起与否的概率依赖于盗窃事件。

每个节点还有自己的条件概率表,以图中的「Alarm」为例,它为「true」的概率与「Burglary」和「Earthquake」有关,

B E P(A = true| B, E)
True True 0.95
True False 0.94
False True 0.29
False False 0.001

当盗窃与地震都发生时,警报响起的概率为95%;只有盗窃发生而地震未发生时,警报响起的概率为94%;只有地震发生而盗窃未发生时,警报响起的概率为29%……


贝叶斯网络很清晰地展示了各个事件间的依赖关系,这方便了我们计算某个事件的具体概率。例如,在只知道盗窃和地震发生的概率,但现在具体有没有发生并不清楚,问警报响起的概率是多少?

要计算警报响起的概率 (P(A=\text{true})),我们需要用结合它的条件概率表,把 (B)(盗窃)和 (E)(地震)的所有组合情况都考虑进去:

\[\begin{align*} P(A=\text{true}) &= P(A|B=T,E=T)P(B=T)P(E=T) \\ &\quad + P(A|B=T,E=F)P(B=T)P(E=F) \\ &\quad + P(A|B=F,E=T)P(B=F)P(E=T) \\ &\quad + P(A|B=F,E=F)P(B=F)P(E=F) \\ \\ &= 0.95 \times 0.001 \times 0.002 \\ &\quad + 0.94 \times 0.001 \times 0.998 \\ &\quad + 0.29 \times 0.999 \times 0.002 \\ &\quad + 0.001 \times 0.999 \times 0.998 \\ &\approx 0.0000019 + 0.00093812 + 0.00057942 + 0.000998002 \\ &\approx 0.002517442 \end{align*} \]

我们可以说,警报响起的概率是0.25%。顺带一提,这种计算方式就是 「全概率计算公式」,基本思想就是:想知道「A事件」发生的可能性,就把能影响「A事件」发生的所有事件的可能性相加。


贝叶斯网络还能很方便地根据已知的“证据”来进一步调整计算可能性。例如,现在我们明确知道地震发生了,那么警报响起的概率是多少?

我们直接让 \(P(E=T) = 1、P(E=F) = 0\),然后再像之前一样计算:

\[\begin{align*} P(A=\text{true}) &= P(A|B=T,E=T)P(B=T)P(E=T) \\ &\quad + P(A|B=T,E=F)P(B=T)P(E=F) \\ &\quad + P(A|B=F,E=T)P(B=F)P(E=T) \\ &\quad + P(A|B=F,E=F)P(B=F)P(E=F) \\ \\ &= P(A|B=T,E=T)P(B=T) * 1 \\ &\quad + P(A|B=T,E=F)P(B=T) * 0 \\ &\quad + P(A|B=F,E=T)P(B=F) * 1 \\ &\quad + P(A|B=F,E=F)P(B=F) * 0 \\ \\ &= P(A|B=T,E=T)P(B=T) \\ &\quad + P(A|B=F,E=T)P(B=F) \\ &= 0.95 \times 0.001 + 0.29 \times 0.999 \\ &= 0.00095 + 0.28971 \\ &= 0.29066 \end{align*} \]

结果是29%,比一无所知时高了一些,看看整个计算过程,最后其实就是只计算地震发生的情况下(即 \(P(E = T)\) ),警报响起的概率。


那再进一步,如果现在已知地震发生且警报响起,那警报的响起是因为地震导致的概率是多少?

回顾上一步的计算,我们已经知道了,地震发生且警报响起的概率约为29%,且它由两部分组成:一部分是此时发生盗窃导致的概率——0.095%,另一部分是此时没有发生盗窃(说白了就是纯地震引发)导致的概率——28.97%。

我们进行下归一化操作:

\[地震导致的概率占比 = \frac{28.97\%}{29.06\%} \approx 99.67\% \]

\[盗窃导致的概率占比 = \frac{0.095\%}{29.06\%} \approx 0.033\% \]

由此,我们得知,地震发生且警报响起的情况下,是地震导致响起的概率是99.67%,是盗窃导致的概率是0.33%。

三、变量消元法

希望前文提及的相关计算不会让你觉得难以理解,现在来看看一种能简化计算的方法——变量消元法。

让我们看一个新的例子,一个关于天气、草地、路面的例子:

image

图中总共有5个变量和对应的5个条件概率表,我们将这5个条件概率表分别标记为F1~F5,它们将作为最初的 因子(Factor)

在变量消元的过程中,变量会逐渐减少、因子里的成员也会不断变化,最终只剩一个,那就是我们想要的答案。

例如,在什么额外信息都没有的情况下,我们想求路面滑的概率,即 P(L),我们就需要消去除 L 外的其他变量;如果是求 P(L|C = True) 则是消去除 L、C 之外的其他变量。

那还是以求 P(L) 为例,我们要去除 C、S、R、W。那要先去除哪个?又要怎么去除呢?

对于第一个问题,我们将最小「消元成本」的变量作为首选,那如何计算「消元成本」呢?很简单,看看有哪些因子包含了这个变量,这些因子所涉及的变量基数(可能状态)乘积就是它的成本,在这次的例子中所有变量都只有True或False两种状态,也就是说基数都是2,那下面我们实操看看。

① 计算变量 W 的成本

  • 包含 W 的因子: F4(P(W|S,R))
  • 涉及变量:S, R, W
  • 成本 = 2×2×2 = 8

② 计算变量 S 的成本

  • 包含 S 的因子:F2(P(S|C))+ F4(P(W|S,R))
  • 涉及变量:C, S, R, W
  • 成本 = 2×2×2×2 = 16

③ 计算变量 C 的成本

  • 包含 C 的因子:F1(P(C)) + F2(P(S|C))+ F3(P(C|R))
  • 涉及变量:C, S, R
  • 成本 = 2×2×2 = 8

④ 计算变量 R 的成本

  • 包含 R 的因子:F3 + F4 + F5
  • 涉及变量:C, S, R, W, L
  • 成本 = 2×2×2×2×2 = 32

显然,W 和 C 二者的成本最低,随便选一个的话,就先选 C 吧。那来看看如何对 C 执行消元:

  1. 把所有包含C的因子相乘,得到新因子(如果只有一个的话,就不用乘了)
    新因子假设名为F6,则

    \[F6 = F1 \times F2 \times F3 = P(C) \times P(S|C) \times P(R|C) \]

    C S R P (C) × P (S|C) × P (R|C)
    T T T 0.5 × 0.1 × 0.8 = 0.04
    T T F 0.5 × 0.1 × 0.2 = 0.01
    T F T 0.5 × 0.9 × 0.8 = 0.36
    T F F 0.5 × 0.9 × 0.2 = 0.09
    F T T 0.5 × 0.5 × 0.2 = 0.05
    F T F 0.5 × 0.5 × 0.8 = 0.20
    F F T 0.5 × 0.5 × 0.2 = 0.05
    F F F 0.5 × 0.5 × 0.8 = 0.20

    这个F6的本质是 C、S、R的联合概率 P(C,S,R),它包含变量:C、S、R


  1. 对 C 执行「求和消元」,把 C 从因子中删掉
    既然要消去变量C,我们就对合并后的 F6 里的 C 做全状态求和

    • C 只有2种状态:True / False
    • 对每一组固定的(S,R),把「C=True」和「C=False」的概率加在一起
    • 相加完成后,变量C就彻底消失了

    \[F_{新} = \sum_{C} F6 = \sum_{C} P(C,S,R) = P(S,R) \]

    image

    我们不再关心C是什么,把C所有可能情况的概率全部合并,C就被“消掉”了,最终得到一个只含S、R的新因子F6


  1. 更新全局因子列表:删掉被合并过的旧因子,只用新因子替代。
    也就是移除原来包含C的3个旧因子:F1、F2、F3,加入刚刚消元完成的新因子:F6。C从此彻底消失,下一步我们只需要在F4、F5、F6中,继续消S、W、R即可。(要记住,新的F6因子算作包含S和R的因子)

重复这个过程,直到所有需要消除的变量都没了,这时一定会只剩一个因子,这就是我们需要的答案,即P(L)。

也许你会感到奇怪,我们说变量消元法能简化,但如果要求P(L)的话,只考虑 C、R、L 的概率相关计算不是更简单吗?

image

这是因为我们举例的网络比较简单且求的问题也简单,换一个问题来看看:已知草地湿的情况下,路面滑的概率是多少,即P(L|W = True),草地变湿与否会影响路面滑的概率吗?在这种情况下哪些变量会参与计算呢?

在更大的网络结构中,我们就很难直接看出变量之间的关联了。这时我们就不得不暴力考虑所有变量来进行全概率公式计算了,而在这种情况下,变量消元法能根据贪心思想优先排除无关变量,将计算分解为一步步局部计算从而达到简化的效果。

四、D分离

通过观察贝叶斯网络,其实我们有办法看出两个变量在给定条件的情况下是否独立,这需要用到一种叫 「D分离」 的办法。

来看看3个变量+2个箭头的小网络(也可以称它为“接合”),这是贝叶斯网络的构建模块,共有三种形式 (这三种连接方式的名称千奇百怪 并各自附带一个例子:

  1. “链”接合: A→B→C

    image

    在这种接合下,给定条件是B的情况下,A和C就是没啥关联的,而当不知道B时,二者就是有关联的。例如,晚上游戏玩的多会占用睡眠时间,造成睡眠不足,从而导致上课犯困。
     
    如果来做一个观察统计的话,你会发现,玩游戏多的人,上课更容易犯困。但如果你再考虑睡眠不足与否的话,就会发现只要睡眠不足,不管玩游戏多不多,上课都会犯困。
     
    你看,原本不知道“睡眠不足”这一条件的话,玩游戏多和上课犯困就是有关联的(非条件独立)。但一旦知道了“睡眠不足”,二者就没有关联了。
    因此,在「知道」B的情况下,A和C之间是没有关联的(被阻断的)。
     

  2. “叉”接合:A←B→C

    image

    这种接合和“链”接合的情况一样,在给定条件B的情况下,A和C无关,反之有关。例如,天气热会导致吃冰淇淋的次数和开空调的次数都上升。
     
    在不看天气热与否的情况下,很容易发现,吃冰淇淋的次数提高的话,开空调的次数也会提高,二者有关联。一旦知道了天气热与否,“误会”就可以解开了,这也不难理解:我们找到了共同原因。
    因此,在「知道」B的情况下,A和C之间是没有关联的(被阻断的)。
     

  3. “对撞”接合:A→B←C

    image

    最后一种接合比较有意思,在不知道B的情况下,A和C是没有关联的,而一旦知道B,A和C就会变得有关联(并且往往是负相关)。例如,我们都知道试卷的难度与你个人在这个科目的能力是没有关系的,试卷不会因你能力好坏而变简单或者变难。

    然而,如果知道一场考试你的成绩很好,二者就有关联了:你能考得好,要么是这次试卷比较简单,要么是你本身这科就很厉害。在我知道你考得很好的情况下,如果你说:“其实在这个科目上,我的学习能力也不怎么样”,那我就会认为这次试卷难度一定比较低;反之,如果你说:“这次试卷其实挺难的”,那我就会夸赞你是这科的“学霸”。
     
    需要注意的是,如果知道了 B 的子节点,也会导致A和C有关联,因为等于间接 “部分观察” 了 B,从而激活了对撞结构,让原本独立的 A 和 C 产生关联。例如,B(考试成绩) → D(老师对你的评价),老师的评价 D 完全由考试成绩 B 决定,所以知道了 D 就等于间接知道了 B 的信息。
    可以说,在「不知道」B 以及 B的子节点的情况下,A和C之间是没有关联的(被阻断的)。

现在,放眼更大的网络结构,根据我们学到的这三种接合,怎么判断任意两个变量在给定条件的情况下是否有关联呢?这不难,直接看整个网络种,这俩变量,在给定条件的作用下,它们之间的 所有「路径(不考虑箭头方向)」 是否 都是 阻断的。

只要有一条路径没有被阻断,它们就是有关联的。就以下面这张图为例:

image

在已知 G 的情况下,C 和 E 是否有关联呢?

首先,我们找到 C 和 E 之间的路径,共有两条:

  1. \(C→D←F←E\)
    先看 \(C→D←F\),显然是对撞接合,由于D未知,所以C和F被阻断,光速结案,这条路径是阻断的。
     
  2. \(C→H→F←E\)
    先看 \(C→H→F\),显然是链接合,由于H未知,所以C和F没被阻断;再看看 \(H→F←E\) 由于F是未知的,且已知的G也不是F的子节点,所以H和E阻断了,这条路径也是阻断的。

两条路径都是阻断的,那么我们就可以断言,已知 G 的情况下,C 和 E 是独立的,没有关联。

那如果把 已知 G 改成 B,C 和 E 是否还有关联呢? 要记得,只要有一条路径没阻断,就算有关联哦。

答案是有关联。因为 B 是 D 的子节点,已知 B 的话,对撞接合的 \(C→D←F\) 就不被阻断了;而后面的 \(D←F←E\) 显然是链接合,F未知,故也没阻断。因此,存在没阻断的路径, C 和 E 有关联。

五、do操作

我们提到过 P(W| S = True) 可以表示已知洒水器打开的情况下,草地变湿的概率,其中的 S = True 可以看作是“「观察到」S = True 这一现象”。为什么突然强调这一点呢?因为我们接下来要了解一个似是而非的操作 「do操作」,就像 P(W | do(S = True))

do操作 的本质是模拟主动干预:强制将变量设为指定状态。体现在图中就是 切断该变量与所有父节点的边(移除所有指向它的箭头),让它不再受其他变量影响,仅保留它对后代变量的影响。

image

这也能说明 普通的「观察到」与 do操作 之间最大的不同,虽说二者都是 S = True,但前者维持着原本的网络结构,观察到 S=True 时,我们会通过原网络结构反向推断多云、下雨的概率(比如 “洒水器打开了,可能是因为天气不阴,下雨概率更低”)。而 do操作 因为是强制执行切断了那些会影响其状态的变量间的关系,此时洒水器打开这件事不再能反过来逆推多云、下雨的概率了。

那这有什么现实意义呢?其实 do操作 并不适用于所有贝叶斯网络,它要求贝叶斯网络中的箭头不再表示概率依赖,而是表示因果顺序(因 → 果)。此时贝叶斯网络就可以变为 「因果贝叶斯网络」,专门用于回答干预性问题,比如:

image
  • “如果我强制打开洒水器,草地变湿的概率会变成多少?”
  • “如果我禁止吸烟,肺癌发病率会下降多少?”

这类问题无法用普通观察(相关性)回答,必须用 do操作 模拟干预。

六、简单实现贝叶斯网络

现在可以来实现下贝叶斯网络了,这次实现的贝叶斯网络是离散变量的,如果你的变量是连续的,就需要自己设计函数将它离散化。

变量类

变量类较简单,它只需要包含变量名,和离散状态的总数就可以了。假设天气变量,有3种状态,分别为:晴、阴、雨,那它的状态数就是3。我们还要修改相等性比较的逻辑,以便字典查询、相同比较等能正常运行。

using System;

public class Variable
{        
    /// <summary>
    /// 变量名(如"Weather")
    /// </summary>
    public string Name { get; }

    /// <summary>
    /// 状态数(基数,如晴/阴/雨→3)
    /// </summary>
    public int Cardinality { get; }

    public Variable(string name, int cardinality)
    {
        Name = name;
        Cardinality = cardinality < 2 ? 2 : cardinality; // 状态数必须≥2(至少两种状态)
    }

    public Variable(Variable var)
    {
        Name = var.Name;
        Cardinality = var.Cardinality;
    }

    public override bool Equals(object obj)
    {
        if (obj is not Variable other) 
            return false;
        return Name == other.Name && Cardinality == other.Cardinality;
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Name, Cardinality);
    }

    public static bool operator ==(Variable left, Variable right)
    {
        return Equals(left, right);
    }

    public static bool operator !=(Variable left, Variable right)
    {
        return !Equals(left, right);
    }
}

因子类

因子类要进行的操作比较多,在前面学习时我们也看到了,在变量消元的过程中会频繁地创建大小不一的新因子。因子类的构成可以分为3部分:变量类型、变量具体状态、概率值,是多个「变量-状态」与概率值对应形成的多维结构。在计算时会比较麻烦,有办法简化吗?

image

如果我们将变量的顺序固定为 S、R、W,显然总共有3个变量,因为每个变量又有2种状态,所以总共会有 222 = 8 种组合。如果我们让状态False为0,True为1,我们可以列出这样一个表格:

image

有办法将这8种组合映射到一个整数上吗?有,我们用一个叫“步长”的整数数组,它的长度与变量总数相同,其顺序也与之前变量的固定顺序一致,它表示该变量需要乘多少,以便我们计算状态值。例如,上面这个计算的步长数组就是 4、2、1,对应 S的状态值需要乘4、R需要乘2……

image

那它是怎么算出来的?步长数组的计算规则是:末位变量步长 = 1;除末位外,前一位变量步长 = 后一位变量步长 × 后一位变量的状态总数

  • 例:变量顺序 [S, R, W],状态数均为 2:
    • W 步长 = 1
    • R 步长 = 1 × 2 = 2
    • S 步长 = 2 × 2 = 4
  • 状态组合 [S=1, R=1, W=0] → 索引 = 1×4 + 1×2 + 0×1 = 6
using System;
using System.Collections.Generic;

public class Factor
{
    /// <summary>
    /// 因子包含的变量列表(如 Weather+Sprinkle)
    /// </summary>
    public List<Variable> Variables { get; }

    /// <summary>
    /// 概率值数组(长度 = 所有变量状态数的乘积,如 3×2=6)
    /// </summary>
    public float[] Values { get; }
    
    /// <summary>
    /// 步长数组(用于快速计算「状态组合」对应的数组索引)
    /// </summary>
    private int[] _strides;

    public Factor(List<Variable> variables, float[] values)
    {
        Variables = variables;
        Values = values;
        ComputeStrides();
    }

    /// <summary>
    /// 状态组合→概率值
    /// </summary>
    public float GetValue(int[] assignment)
    {
        int index = 0;
        for (int i = 0; i < assignment.Length; i++)
        {
            index += assignment[i] * _strides[i];
        }
        return Values[index];
    }

    /// <summary>
    /// 概率值归一化
    /// </summary>
    public void Normalize()
    {
        float sum = 0;
        for(int i = 0; i < Values.Length; ++i)
        {
            sum += Values[i];
        }
        if (Math.Abs(sum) < 1e-9f)
        {
            return; // 避免除零
        }
        for (int i = 0; i < Values.Length; i++)
        {
            Values[i] /= sum;
        }
    }

    /// <summary>
    /// 将一维数组索引还原为状态组合(比如索引 1→[0,1])
    /// </summary>
    private static void DecodeAssignment(int index, List<Variable> vars, int[] assignment)
    {
        for (int i = vars.Count - 1; i >= 0; --i)
        {
            assignment[i] = index % vars[i].Cardinality;
            index /= vars[i].Cardinality;
        }
    }

    /// <summary>
    /// 计算步长数组 作用:将「变量状态组合」转化为一维数组的索引(比如 Weather = 晴 (0)、Sprinkle = 开 (1) → 索引 = 0×2 +1=1)。
    /// </summary>
    private void ComputeStrides()
    {
        _strides = new int[Variables.Count];
        int stride = 1;
        for (int i = Variables.Count - 1; i >= 0; i--)
        {
            _strides[i] = stride;
            stride *= Variables[i].Cardinality;
        }
    }

    /// <summary>
    /// 获取变量组合的总数
    /// </summary>
    private static int GetVarsTotalCardinality(List<Variable> vars)
    {
        int totalSize = 1;
        for(int i = 0; i < vars.Count; ++i)
        {
            totalSize *= vars[i].Cardinality;
        }
        return totalSize;
    }
}

除此之外,还要补充几个来辅助变量消元计算的函数:

  1. 「因子乘法」核心是把两个因子的变量合并,每个状态组合的概率是两个因子对应概率的乘积,其实就是构建联合概率分布。可以理解为它先计算出了合并后的新因子的尺寸,并构建了等规模的空表,步骤3进行循环时再填充空表。填充过程中会用到 GetProjectedValue 寻找对应 f1f2 因子中的值相乘。

    image
    /// <summary>
    /// 因子乘法,合并两个因子(对应概率的乘积),比如 P (A|B) × P (B) = P (A,B)。
    /// </summary>
    public static Factor Multiply(Factor f1, Factor f2)
    {
        // 步骤1:合并变量(去重),比如 [R,W] + [R,S] → [R,S,W]
        var newVars = new List<Variable>(f1.Variables);
        foreach (var var in f2.Variables)
        {
            if (!newVars.Contains(var)) newVars.Add(var);
        }
    
        // 步骤2:计算新因子的长度(所有变量状态数的乘积)
        int totalSize = GetVarsTotalCardinality(newVars);
        var newValues = new float[totalSize];
        var assignment = new int[newVars.Count];
    
        // 步骤3:遍历所有状态组合,计算乘积
        for (int i = 0; i < totalSize; i++)
        {
            // 把一维索引还原成状态组合(比如索引3→[1,1])
            DecodeAssignment(i, newVars, assignment);
            // 把新状态组合分别映射到f1、f2上,取对应概率
            float v1 = GetProjectedValue(f1, newVars, assignment);
            float v2 = GetProjectedValue(f2, newVars, assignment);
            // 乘积作为新因子的概率
            newValues[i] = v1 * v2;
        }
    
        return new Factor(newVars, newValues);
    }
    
    /// <summary>
    /// 辅助:将「全变量状态组合」投影到单个因子的变量上(比如 [R,W]→[R])
    /// </summary>
    private static float GetProjectedValue(Factor f, List<Variable> newVars, int[] fullAssignment)
    {
        var subAssign = new int[f.Variables.Count];
        for (int i = 0; i < f.Variables.Count; ++i)
        {
            // 找到当前变量在新变量列表中的位置,提取对应状态值
            int idx = newVars.IndexOf(f.Variables[i]);
            subAssign[i] = fullAssignment[idx];
        }
        // 用子状态组合取因子的概率值
        return f.GetValue(subAssign);
    }
    
  2. 「求和消元」是变量消元法的核心:把某个变量的所有状态概率加起来,移除这个变量,得到剩余变量的边缘概率分布,也就是之前提到的这一步:

    image

    逻辑结构与因子乘法类似,前两个步骤构建出一个移除了变量后的更小的新因子规模的空表,再遍历填充表格。步骤3是具体的求和过程:将被移除变量的各个状态值轮流加到新因子中,使之变成原本的因子,再查找原本因子下的概率,进行累加(相当于上面那种图从右边找左边的过程)。

    /// <summary>
    /// 求和消元(边缘化):移除某个变量(对该变量的所有状态求和)
    /// </summary>
    public static Factor SumOut(Factor f, Variable var)
    {
        // 步骤1:移除要消去的变量,得到新变量列表
        var newVars = new List<Variable>();
        foreach (var v in f.Variables)
        {
            if (v != var) newVars.Add(v);
        }
    
        // 步骤2:计算新因子长度
        int newSize = GetVarsTotalCardinality(newVars);
        var newValues = new float[newSize];
        var newAssign = new int[newVars.Count];
    
        // 步骤3:遍历新变量的所有状态,累加消去变量的概率
        for (int i = 0; i < newSize; i++)
        {
            DecodeAssignment(i, newVars, newAssign);
            float sum = 0;
            // 遍历要消去变量的所有状态(比如R有0/1两种状态)
            for (int s = 0; s < var.Cardinality; s++)
            {
                // 把新状态组合扩展为包含消去变量的完整组合(比如[W=0]→[R=s,W=0])
                int[] fullAssign = ExpandAssignment(f.Variables, newVars, newAssign, var, s);
                // 累加概率
                sum += f.GetValue(fullAssign);
            }
            newValues[i] = sum;
        }
    
        return new Factor(newVars, newValues);
    }
    
    /// <summary>
    /// 辅助:将「子变量状态组合」扩展为「全变量状态组合」(比如 [W]→[R,W],R指定为s)
    /// </summary>
    private static int[] ExpandAssignment(List<Variable> fullVars, List<Variable> subVars, int[] subAssign, Variable var, int state)
    {
        var fullAssign = new int[fullVars.Count];
        for (int i = 0; i < fullVars.Count; ++i)
        {
            if (fullVars[i] == var)
            {
                // 固定消去变量的状态
                fullAssign[i] = state;
            }
            else
            {
                // 从子状态中提取对应变量的状态
                int idx = subVars.IndexOf(fullVars[i]);
                fullAssign[i] = subAssign[idx];
            }
        }
        return fullAssign;
    }
    
  3. 「证据压缩」是引入观测证据的核心:已知某个变量的具体状态(比如 “草地湿 = 真”),过滤掉所有不符合该状态的概率,只保留有效概率。简单来说就是:“只看变量等于某个值的情况,其他情况都删掉”。

    /// <summary>
    /// 证据压缩:将Factor中var不为state的概率移除,返回新的Factor
    /// </summary>
    public Factor Restrict(Variable var, int state)
    {
        // 如果因子不包含该变量,直接返回自身
        if (!Variables.Contains(var))
            return this;
    
        // 步骤1:移除被固定的变量(不加入newVars),得到新变量列表
        var newVars = new List<Variable>();
        foreach (var v in Variables)
        {
            if (v != var) newVars.Add(v);
        }
    
        // 步骤2:计算新因子长度
        int newSize = GetVarsTotalCardinality(newVars);
        var newValues = new float[newSize];
        var newAssign = new int[newVars.Count];
    
        // 步骤3:遍历新变量的所有状态,提取固定状态的概率
        for (int i = 0; i < newSize; i++)
        {
            DecodeAssignment(i, newVars, newAssign);
            // 扩展为包含固定变量的完整状态组合(比如[R=0]→[R=0,W=1])
            int[] fullAssign = ExpandAssignment(Variables, newVars, newAssign, var, state);
            // 提取对应概率
            newValues[i] = GetValue(fullAssign);
        }
    
        return new Factor(newVars, newValues);
    }
    

Factor 类加入了上面这些函数后就可以了,至于具体的运算过程我们交给其他类。

贝叶斯网络类

在这个设计中,贝叶斯网络类只负责存储变量与因子,查询工作只是包装推理类的公开方法。因为贝叶斯网络其实不止一种计算方式,还有一类基于采样的方法,能在庞大的网络中较快速地得到近似答案。

using System.Collections.Generic;

/// <summary>
/// 贝叶斯网络
/// </summary>
public class BayesNetwork
{
    public List<Variable> Variables = new();
    public List<Factor> Factors = new();

    public void AddVariable(params Variable[] vars)
    {
        foreach(var v in vars)
        {
            Variables.Add(v);
        }
    }
    public void AddFactor(params Factor[] factors)
    {
        foreach(var f in factors)
        {
            Factors.Add(f);
        }
    }

    /// <summary>
    /// 执行贝叶斯网络推理(变量消元法),计算指定查询变量的后验概率
    /// </summary>
    /// <param name="evidence">证据集合(键=变量,值=该变量的观测状态,如{Alarm, 1}表示警报响);可为null(无证据)</param>
    /// <param name="intervention">do操作集合(键=变量,值=强制设置该变量的状态,如{Alarm, 1}表示do警报响);可为null(无do操作)</param>
    /// <param name="queryVars">要查询的变量列表(支持单/多变量,如传入[Burglary, Earthquake]表示查询二者联合概率)</param>
    /// <returns>包含查询变量后验概率的Factor对象,Values数组为各状态的概率(已归一化,和为1)</returns>
    public Factor Query(Dictionary<Variable, int> evidence, Dictionary<Variable, int> intervention, params Variable[] queryVars)
    {
        return Inference.Query(this, evidence, intervention, queryVars);
    }
}

推理静态类

最后就是用于推理的类了,它采用变量消元法在给定证据的情况下查询指定变量为各状态值的概率,还提供了do操作。

do操作的实现比较简单,思路就是:当变量A被干预时,就将除了A以外的变量的因子(条件概率表)先记录下,再加入被干预成指定状态的变量A因子。由于我们不希望do操作真的改变了网络结构,所以会将因子拷贝一份。

using System;
using System.Collections.Generic;

// 推理引擎
public static class Inference
{
    /// <summary>
    /// 执行贝叶斯网络推理(变量消元法),计算指定查询变量的后验概率
    /// </summary>
    /// <param name="bn">待推理的贝叶斯网络(包含变量和因子)</param>
    /// <param name="evidence">证据集合(键=变量,值=该变量的观测状态,如{Alarm, 1}表示警报响);可为null(无证据)</param>
    /// <param name="intervention">do运算集合(键=变量,值=强制设置该变量的状态,如{Alarm, 1}表示do警报响);可为null(无do运算)</param>
    /// <param name="queryVars">要查询的变量列表(支持单/多变量,如传入[Burglary, Earthquake]表示查询二者联合概率)</param>
    /// <returns>包含查询变量后验概率的Factor对象,Values数组为各状态的概率(已归一化,和为1)</returns>
    public static Factor Query(BayesNetwork bn, Dictionary<Variable, int> evidence, Dictionary<Variable, int> intervention, params Variable[] queryVars)
    {
        // 拷贝网络因子(避免修改原网络的因子数据)
        var factors = new List<Factor>(bn.Factors);

        if (intervention != null && intervention.Count > 0)
        {
            Do(ref factors, intervention);
        }

        var evidenceVars = new List<Variable>();
        if (evidence != null && evidence.Count > 0)
        {
            // 应用证据:对所有因子执行证据压缩(仅保留证据变量指定状态的概率)
            foreach (var e in evidence)
            {
                var newFactors = new List<Factor>();
                for (int i = 0; i < factors.Count; ++i)
                {
                    newFactors.Add(factors[i].Restrict(e.Key, e.Value));
                }
                factors = newFactors;
                // 记录证据变量
                evidenceVars.Add(e.Key);
            }
        }

        // 手动循环筛选隐藏变量:需要消去的变量 = 网络所有变量 - 查询变量 - 证据变量
        var hidden = new List<Variable>();
        for (int i = 0; i < bn.Variables.Count; ++i)
        {
            // 判断:不在查询变量中 且 不在证据变量中
            var v = bn.Variables[i];
            if(Array.IndexOf(queryVars, v) == -1 && evidenceVars.IndexOf(v) == -1)
            {
                hidden.Add(v);
            }
        }

        VariableElimination(factors, hidden);

        // 合并剩余因子并归一化:得到查询变量的后验概率(和为1)
        var result = factors[0]; // 初始化为第一个因子
        for (int i = 1; i < factors.Count; i++)
        {
            result = Factor.Multiply(result, factors[i]);
        }
        result.Normalize(); // 归一化确保概率和为1
        return result;
    }

    /// <summary>
    /// do 运算:强制变量取某状态(因果干预)
    /// </summary>
    private static void Do(ref List<Factor>factors, Dictionary<Variable, int> intervention)
    {
        // 1 删除所有包含该变量的因子
        var newFactors = new List<Factor>();
        for (int i = 0; i < factors.Count; i++)
        {
            var f = factors[i];
            foreach (var iv in intervention)
            {
                // CPT 的最后一个变量是该节点
                if (f.Variables[^1] != iv.Key)
                {
                    newFactors.Add(f);
                }
            }
        }
        factors = newFactors;
        // 2 创建 delta 因子 δ(X=x)
        foreach (var iv in intervention)
        {
            var values = new float[iv.Key.Cardinality];
            for (int i = 0; i < values.Length; i++)
            {
                values[i] = (i == iv.Value) ? 1f : 0f;
            }
            factors.Add(new Factor( new List<Variable> { iv.Key }, values));
        }
    }

    /// <summary>
    /// 变量消元核心方法(Min-Degree):选计算成本最小的变量先消,避免中间因子爆炸
    /// </summary>
    /// <param name="factors">贝叶斯网络的所有因子(条件概率表),会在消元过程中动态更新</param>
    /// <param name="hidden">需要消去的隐藏变量列表(消完则为空)</param>
    private static void VariableElimination(List<Factor> factors, List<Variable> hidden)
    {   
        // 循环消元:直到所有隐藏变量都被消完
        while (hidden.Count > 0)
        {
            // 初始化:最优消元变量(成本最小)、最小消元成本(初始为极大值)
            Variable bestVar = null;
            int minFactorSize = int.MaxValue;
            var allVars = new HashSet<Variable>();

            // 步骤1:遍历所有隐藏变量,选「消元成本最小」的变量
            for (int i = 0; i < hidden.Count; ++i)
            {
                // 1.1 找到当前变量的所有相关因子(包含该变量的因子)
                int size = 1; // 消元成本初始值(基数乘积的初始为1)
                allVars.Clear(); // 哈希表:记录已统计的变量(去重,避免重复乘基数)
                for(int j = 0; j < factors.Count; ++j)
                {
                    // 判断因子是否包含当前隐藏变量(是则加入相关因子列表)
                    if (factors[j].Variables.Contains(hidden[i]))
                    {
                        // 1.2 计算「消元成本」:合并相关因子后的大小(唯一变量基数乘积)
                        var relatedVars = factors[j].Variables;
                        for (int k = 0; k < relatedVars.Count; ++k)
                        {
                            // 变量是新的,未统计过 → 乘基数(计算成本)
                            if (allVars.Add(relatedVars[k]))
                            {
                                size *= relatedVars[k].Cardinality;
                            }
                        }
                    }
                }
                // 边界:无相关因子(该变量不影响结果),直接移除,跳过后续计算
                if (allVars.Count == 0) 
                { 
                    hidden.RemoveAt(i--); 
                    continue;
                }
                // 1.3 更新最优变量:如果当前变量成本更小,选为最优
                if (size < minFactorSize) 
                {
                    minFactorSize = size; 
                    bestVar = hidden[i];
                }
            }
            // 边界:无最优变量(所有隐藏变量都无相关因子),退出循环
            if (bestVar == null)
            {
                break;
            }

            // 步骤2:对最优变量执行「合并因子 + 求和消元」
            // 2.1 重新找到最优变量的所有相关因子(和步骤1.1逻辑一致)
            var relatedFactors = new List<Factor>();
            for (int i = 0; i < factors.Count; ++i)
            {
                if (factors[i].Variables.Contains(bestVar))
                {
                    relatedFactors.Add(factors[i]);
                }
            }
            // 2.2 合并所有相关因子(因子乘法 → 生成包含最优变量的联合因子)
            Factor product = relatedFactors[0];
            for (int i = 1; i < relatedFactors.Count; ++i) 
            {
                product = Factor.Multiply(product, relatedFactors[i]);
            }

            // 步骤3:更新因子列表(核心:消去最优变量,用新因子替换旧因子)
            // 3.1 移除所有被合并的旧因子
            factors.RemoveAll(f => relatedFactors.Contains(f));
            // 3.2 求和消元:对最优变量求和,生成不含该变量的新因子,加入列表
            factors.Add(Factor.SumOut(product, bestVar));
            // 3.3 从隐藏变量列表移除已消元的变量
            hidden.Remove(bestVar);
        }
    }
}

要注意 do操作 中判别当前因子是否属于do操作的变量时,我们用的是 if (f.Variables[^1] != iv.Key)

为什么这样就能判断呢?这和之前提到的限制有关,因为我们采用状态值编码的方式计算整数来代表状态组合,最初的因子都是各个变量的条件概率表。我们约定初始化时,变量数组的末位就是当前变量,由此可以快速通过比较变量数组末位是否匹配来判断。

限制就是初始化因子时要记住含义才能保证表述正确,例如:

// 【成绩(共A、B、C,3个级别)】的条件概率 P(成绩|智商, 课程难度)
// 变量顺序:[智商,课程难度,成绩],共 2×2×3=12 种状态组合
var gradeProbs = new float[]
{
    0.3f, 0.4f, 0.3f,  // 智商低+课程简单 → 成绩 A=0.3、B=0.4、C=0.3
    0.05f, 0.25f, 0.7f,// 智商低+课程困难 → 成绩 A=0.05、B=0.25、C=0.7
    0.9f, 0.08f, 0.02f,// 智商高+课程简单 → 成绩 A=0.9、B=0.08、C=0.02
    0.5f, 0.3f, 0.2f,  // 智商高+课程困难 → 成绩 A=0.5、B=0.3、C=0.2
};

在文首的完整项目链接中,提供了3个经典案例以展示使用过程:

image

七、尾声

提了这么多,贝叶斯网络在游戏玩法中的应用真的广泛吗?并非,甚至可以说是算冷门的了。就算是《AI Game Programming Wisdom》中提到的用于射击游戏的“可见性与命中率计算”、潜行游戏的“发现”模型等,都不曾有听闻具体项目应用。

我也是在寻找贝叶斯网络游戏应用实例的过程中,接触到了《为什么》这一书,了解到了「因果贝叶斯网络」,所以心血来潮用这篇数学语言稀碎的博客记录了下。

如果你问我会用贝叶斯网络做什么,我会倾向于用它来做游戏开发相关的分析工具。例如,通过游戏资产的相关数据来推断其性能,又或者反过来在性能较差的情况下逆推可能的瓶颈……以及如果将这个模型的面数强行增加 5k 会对性能造成什么影响等等。

这类工具其实和贝叶斯网络疾病诊断工具的思路很像,但显然网络结构的确定离不开专家的意见。想“考古”贝叶斯网络应用的同学,可以看看这本书 《Bayesian Networks A Practical Guide to Applications》

image

虽说现在也有贝叶斯网络结构和参数的自动化训练方法,但需要较大量的训练数据。采用专家的经验还是更方便的,且得益于贝叶斯网络的可解释性,要是出问题了也好直接改。或者二者混用,让人工构建网络的结构,再由自动化训练网络的数据。

posted @ 2026-03-19 10:23  狐王驾虎  阅读(42)  评论(0)    收藏  举报