规则系统是「人工智能」这个词出现之前就有的东西:把人的判断写成「若若干条件成立, 则某结论成立」,再让机器去匹配。它不学任何东西,所有的聪明都是人预先写进去的。 这一节量两件事: 同一套规则,前向链和反向链各要走多少步;以及规则一多,维护成本为什么按平方长。
上一节说 1970 年代的第一批程序靠「搜索」赢了一小段,被 1969 年的异或结论打回原形。 这一节讲紧接着起来的那条路:不搜了,改成人把知识写死。 本节的前向链是 2.3 专家系统推理机的骨架;规则数量的成本分析会在2.3 里再算一次,从另一个角度。
一、IF-THEN 是最早的「编程智能」
一条产生式规则长这样:若「有毛发」,则「哺乳动物」。左边叫前件,是一组条件; 右边叫后件,是一个结论。规则本身不知道自己在整个系统里干什么,它只说这一件事。 一个规则系统有三个零件:规则库(所有 IF-THEN)、工作内存(当前已知的事实)、 推理机(负责匹配和推进)。这三样加起来,就是一个会「想」的程序。 它和普通程序最大的区别在「没有调用关系」。普通程序里函数 A 调 B、B 调 C,执行顺序是写死的; 规则系统里十五条规则平铺在一起,谁先触发取决于当前有哪些事实。 这个特点叫「数据驱动」。 你改一条事实,可能触发一串完全不同的规则,而代码一行没动。这是它的威力, 也是它的麻烦——排查问题时你没法顺着调用栈走。 这套东西的历史比「人工智能」这个名字还早。1943 年波斯特提出产生式系统,本来是用来研究形式语言的; 1970 年代纽厄尔和西蒙把它搬到 AI 上,才有了「用规则表示知识」这条路。
二、前向链:从事实往结论走
前向链的思路最直白: 手上有什么事实,就看哪条规则的条件全满足了,满足了就推出新结论,把新结论加进事实集, 再看一轮,直到没有新东西。 这就像倒推多米诺:你推倒第一张,后面自己会倒。 Demo 里给了七个观察——有毛发、吃肉、黄褐色、有斑点、眼盯前方、有爪、 有犬齿——两轮就推出了「金钱豹」。 前向链有一个很好的性质:它是单调的。事实只增不减,所以不管规则按什么顺序排, 最后的事实集完全一样。Demo 里打乱顺序跑 50 次,得到的结果只有 1 种——这不是巧合,是逻辑上的必然。 工程上前向链用在「事实一直涌进来」的场合:银行的反欺诈规则、监控的告警联动、保险的自动核保。 这些场景的共同点是:没人预先知道要问什么,只能把所有能推的都推一遍。
三、反向链:从结论往事实走
反向链反过来: 先假定目标成立,问「要让它成立,得先有什么」,一层层往下问,问到全是已知事实为止。 找着一条路就停。 Demo 里问「它是金钱豹吗」,反向链展开成七步:金钱豹要食肉动物、黄褐色、有斑点; 食肉动物要哺乳动物、吃肉;哺乳动物要有毛发。三样都在事实里,于是整条链通了。 这里有个反直觉的结果:在这个只有 15 条规则的小例子里,反向链反而更费。它碰了 7 个节点, 前向链只推了 3 条。原因是它把「已在事实里」的那几个也走了一遍,而前向链一轮就完了。 反向链的优势要到规则多的时候才出来。假设有一千八百条规则,前向链每推一轮都要扫一千八百次; 反向链每层只扫「结论等于当前目标」的那几条,平均下来是千分之几。 所以选哪种不看谁聪明,看「规则多还是事实多」。 规则多、事实少——反向链省;事实一直涌进来——前向链省。专家系统里两种都留着,由人按场景挑。
四、规则打架的时候听谁的
前向链有个细节 Demo 里没展开:如果同一轮里有好几条规则同时满足,先执行哪条?这叫冲突消解, 工程上必须有个说法。
| 策略 | 怎么定序 | 什么时候用 |
|---|---|---|
| 优先级 | 每条规则带一个数字,大的先跑 | 有明确的重要级别,比如安全规则最高 |
| 特异性 | 条件多的先跑 | 默认策略,专治「一般规则吃掉特例」 |
| 新近性 | 用最近加入的事实触发的先跑 | 事实是流式进来的时候 |
| 书写顺序 | 谁写在前面谁先跑 | 最简单,也最容易埋坑 |
| 特异性这一条最值得留意,它治的是「一般规则吃掉特例」。 | ||
| 比如「有毛发 → 哺乳动物」和「有毛发 且 会下蛋 → 单孔类」,后者条件更多、更具体,就该先跑。 | ||
| 如果两条规则条件数一样、结论还冲突, | ||
| 那就没辙了——这是规则库写坏了,不是推理机能救的。上千条规则的系统里,这种冲突是主要 bug 来源。 |
五、三个好处,和一件躲不开的事
规则系统能活这么久,是因为三个好处都很硬。 一是可解释。它的推理过程本身就是一串 IF-THEN,你可以原样翻译成人话给用户看:「因为你有毛发、 吃肉、黄褐色、有斑点,所以我判断是金钱豹。」今天的深度网络做不到这个。 二是可增量。加一条规则不影响已有的规则(除非冲突),不用重训、不用动其他代码。 这是它当年能把知识一条条攒起来的原因。 三是不要数据。专家脑子里有就行。这一条在 1980 年代是巨大优势——那时候没有数据集,只有人。 躲不开的那件事是:每一条规则都得有人坐下来写。Demo 最后算了一个数:15 条规则认 8 个物种, 平均 1.88 条一个;外推到一千个物种要 1875 条,存储只要 73 KB。 麻烦不在存储,在「两两组合」——1875 条规则有 175 万对。每加一条, 都要检查它跟前面所有规则打不打架。维护成本随规则数平方增长,这是第二次寒冬的成本结构。
六、动手 Demo:十五条规则认八个物种,前向链与反向链各走一遍
规则集是 AI 教材里那个经典的动物识别例子,我按自己的理解重写了一遍,补上了鸵鸟、企鹅、
信天翁三只鸟。八个物种、四个中间概念、十五条规则。
代码里 forward 和 backward 各二十行,都没有任何技巧:一个循环推到底,一个递归往回问。
RULES = (((u'有毛发',), u'哺乳动物'),
((u'产奶',), u'哺乳动物'),
((u'有羽毛',), u'鸟'),
((u'会飞', u'会下蛋'), u'鸟'),
((u'哺乳动物', u'吃肉'), u'食肉动物'),
# …… 一共 15 条
((u'鸟', u'不会飞', u'长腿', u'长脖'), u'鸵鸟'))
def forward(facts, rules):
"""前向链:事实驱动,推到不动为止。"""
facts = set(facts)
trace = []
for _ in range(20):
hit = False
for cond, concl in rules:
if concl in facts: # 已经知道,跳过
continue
if all(c in facts for c in cond):
facts.add(concl)
trace.append((cond, concl))
hit = True
if not hit: # 一轮下来没新东西,停
break
return facts, trace
def backward(goal, facts, rules, seen=None):
"""反向链:目标驱动,找到一条路就返回。"""
if goal in facts:
return [(goal, u'已知')]
if goal in seen: # 防环
return None
seen.add(goal)
for cond, concl in rules:
if concl != goal:
continue
parts = [backward(c, facts, rules, seen) for c in cond]
if all(parts):
return ([x for p in parts for x in p]
+ [(goal, u'由 ' + u' + '.join(cond))])
return None
实跑输出:
--------------------------------------------------------------------
十五条规则、八个物种
--------------------------------------------------------------------
==============================================================
[1] 先把规则集摆出来
15 条产生式规则,每条是「若若干条件成立,则某结论成立」。
中间概念只有四个:哺乳动物、鸟、食肉动物、有蹄类。
八个具体物种靠这四个中间概念接上去——这就是「分层」。
R01 若 有毛发,则 哺乳动物
R02 若 产奶,则 哺乳动物
R03 若 有羽毛,则 鸟
R04 若 会飞 且 会下蛋,则 鸟
R05 若 哺乳动物 且 吃肉,则 食肉动物
R06 若 哺乳动物 且 有爪 且 有犬齿 且 眼盯前方,则 食肉动物
R07 若 食肉动物 且 黄褐色 且 有斑点,则 金钱豹
R08 若 食肉动物 且 黄褐色 且 有黑色条纹,则 老虎
R09 若 食肉动物 且 黄褐色 且 有鬃毛,则 狮子
R10 若 有蹄类 且 长脖子 且 长腿 且 有斑点,则 长颈鹿
R11 若 有蹄类 且 有黑白条纹,则 斑马
R12 若 哺乳动物 且 有蹄,则 有蹄类
R13 若 鸟 且 不会飞 且 长腿 且 长脖,则 鸵鸟
R14 若 鸟 且 不会飞 且 会游泳 且 黑白二色,则 企鹅
R15 若 鸟 且 会飞 且 会游泳,则 信天翁
[2] 前向链:从一组观察出发,一直推到不动
已知:有毛发、吃肉、黄褐色、有斑点、眼盯前方、有爪、有犬齿
序号 触发条件 推出
1 有毛发 哺乳动物
2 哺乳动物 且 吃肉 食肉动物
3 食肉动物 且 黄褐色 且 有斑点 金钱豹
推出 3 条新事实,2 轮就停了。
注意前向链是单调的:事实只增不减,所以规则换个顺序,
最终事实集一模一样,只是推出的先后次序不同。
打乱规则顺序跑 50 次,得到的最终事实集有 1 种。
[3] 同一件事,反向链只碰几个目标
问「它是金钱豹吗」,反向链展开的步骤(缩进表示子目标):
有毛发 已在事实里
哺乳动物 由「有毛发」推得
吃肉 已在事实里
食肉动物 由「哺乳动物 且 吃肉」推得
黄褐色 已在事实里
有斑点 已在事实里
金钱豹 由「食肉动物 且 黄褐色 且 有斑点」推得
从最里面往回读:先证「有毛发」,才有哺乳动物;
再有「吃肉」,才有食肉动物;再加上两个颜色特征,才是金钱豹。
前向链推了 3 条,反向链碰了 7 个节点——在这个只有 15 条规则的小例子里,
反向链反而更费:它把「已在事实里」的那几个也走了一遍。
反向链的优势要到规则多的时候才出来:
假设有 1875 条规则,前向链每推一轮就要扫 1875 次;
反向链每层只扫「结论等于当前目标」的那几条,平均 1875 除以结论种类数。
所以选哪种不看谁聪明,看「规则多还是事实多」。
规则多、事实少——反向链省;事实一直涌进来(传感器、日志)——前向链省。
[4] 换个写法:不分层,每个物种一条大规则
写法 规则条数 条件总数 改「哺乳动物」要动几处
分层 15 39 2 条(R01、R02)
扁平 8 31 5 条(五个哺乳动物全要改)
有意思的地方在这里:扁平写法规则更少,条件也更少。
15 条对 8 条,39 个条件对 31 个条件——分层在「量」上什么都没省。
分层真正省的是「改一处」的成本:
把「哺乳动物」的判据改一下,分层动 2 条,扁平动 5 条,
而且那 5 条里的条件散在几百个字符中间,改漏一条就埋一个 bug。
所以规则系统的价值不在条数,在「同一条知识只写一遍」。
[5] 两条规则依赖「不会飞」,而系统推不出否定
R13 说鸵鸟是「鸟 且 不会飞 且 长腿 且 长脖」。
「不会飞」这五个字,系统自己是推不出来的——
它只知道「有什么」,不知道「没有什么」。
输入 问「是鸵鸟吗」 推出的事实数
事实里有「不会飞」 是 6
事实里没有「不会飞」 不能确定 4
第二行不是「否」,是「推不出来」。程序不会报错,它只是沉默。
工程上这靠「封闭世界假设」补:没写进事实的,就当它不成立。
代价是:漏录一条事实,结论会静默地错,而且没有任何提示。
[6] 规则条数不是问题,规则之间的「对」才是
现在:15 条规则认 8 个物种,平均 1.88 条一个。
100 个物种 约 187 条规则 存 7 KB 规则两两组合 17391 对
1000 个物种 约 1875 条规则 存 73 KB 规则两两组合 1756875 对
10000 个物种 约 18750 条规则 存 732 KB 规则两两组合 175771875 对
存储从来不是问题:一万个物种也就几百 KB。
问题是那个「两两组合」——每多一条规则,要检查它跟前面所有规则
打不打架;规则上千条之后,这个数字就没人过得完了。
维护成本是随规则数的平方长的,不是随它线性长的。
这就是第二次寒冬的成本结构:不是写不出规则,是写完管不住。
第一段先把规则全列出来。注意中间概念只有四个——哺乳动物、鸟、食肉动物、有蹄类。 八个物种不是直接挂在特征上,是挂在这四个概念上,这就是「分层」。 第二段跑前向链。七条事实进去,两轮之后推出三条新结论:哺乳动物、食肉动物、金钱豹。 第一轮推完三条,第二轮一条都推不出来,于是停。 「打乱规则顺序跑 50 次,最终事实集有 1 种」这一行是这段的重点。 它不是测出来的规律,是逻辑上的必然:前向链单调,所以结果与顺序无关。这一点在工程上很重要—— 你不用操心规则的排列。 第三段跑反向链。输出是从最里面往外读的:先证「有毛发」,才有哺乳动物; 再有「吃肉」,才有食肉动物;再加两个颜色特征,才是金钱豹。 七步里有四步是「已在事实里」,一步没白走。 然后是一个反直觉的数:反向链碰了 7 个节点,前向链只推了 3 条。在这个小例子里反向链更费。 这不是反向链不好,是规则太少——它的优势要到「规则上千条、只问其中一条」的时候才显出来。 第四段换写法。把分层拍平,每个物种写一条大规则:8 条对 15 条,31 个条件对 39 个条件。 分层在「量」上什么都没省,反而更多。 分层省的是「改一处」的成本: 「哺乳动物」的判据改一下,分层动 2 条,扁平动 5 条,而且那 5 条里的条件散在几百个字符中间。 这是知识表示的价值所在——不是省字数,是让同一条知识只写一遍。 第五段是这一节最该记住的实验。R13 说鸵鸟是「鸟 且 不会飞 且 长腿 且 长脖」。 「不会飞」这五个字系统自己推不出来——它只知道「有什么」,不知道「没有什么」。 把「不会飞」从事实里拿掉,问「是鸵鸟吗」,答案不是「否」,是「不能确定」。 程序不报错,它只是沉默。工程上靠「封闭世界假设」补:没写进事实的,就当它不成立。 代价是漏录一条事实,结论会静默地错。 第六段算维护成本。一百个物种 187 条规则、7 KB;一千个 1875 条、73 KB; 一万个 18750 条、732 KB。存储从来不是问题。 问题是那个「规则两两组合」:一万个物种对应 1.7 亿对。 每多一条规则,要检查它跟前面所有规则打不打架。这就是第二次寒冬的成本结构——不是写不出规则, 是写完管不住。
30 秒实验
- 把
forward里那句if concl in facts: continue删掉,重跑第二段。 你会看到同一条结论被反复推、trace 变得极长,但最终事实集不变。这一行不是优化, 是防重复记账。 - 把 FACTS 里的「有毛发」换成「产奶」,重跑第二段。 结论完全一样——因为 R01 和 R02 是两条并行的入口,这正是规则系统「多条路通一个结论」的样子。
- 把
backward里的seen去掉,然后问一个规则集里不存在的目标,比如「恐龙」。 会 RecursionError——规则之间有环的时候(虽然这个集合里没有),没有 seen 就会转不出来。
常见误区
- 以为规则系统「过时了」。它今天还在跑:银行的信贷审批、保险的核保、运营商的套餐推荐、 医院的临床路径。这些场景的共同点是「必须能解释」,而深度网络给不出解释。
- 以为前向链和反向链是两种算法,选一个更好的。它们是两种「问法」,同一套规则两种都能跑, 区别只在于「事实多还是规则多」。在 Demo 那个 15 条规则的小例子里,前向链反而更省。
- 以为规则顺序会影响结果。前向链单调,事实只增不减, 所以最终事实集与顺序无关(Demo 里 50 次打乱,1 种结果)。 顺序只影响「推出的先后」,不影响「推出什么」。但冲突消解会受影响——那是另一回事。
- 以为规则系统的瓶颈是存储。一万个物种 732 KB,一张软盘都嫌小。 瓶颈是「规则两两之间会不会打架」,是平方级的维护成本。把成本算到这一层, 才看得懂第二次寒冬。
自测三问(点开对答案)
1. 前向链为什么与规则顺序无关,反向链为什么有关? —— 前向链是单调的:每一轮把「条件全部满足」的规则都推一遍,事实只增不减, 所以最后一轮停下来时的集合,与中间先推谁无关。 **反向链找到一条通路就返回,不同的规则顺序会让它先撞上不同的通路,于是走的路不一样。** 结果(是或否)一样,路径不一定一样。 2. 「不会飞」这个事实,为什么系统自己推不出来? —— 因为规则系统的推理是「正向的单调推理」:它只能从已有事实推出新事实, **不能因为「找不到会飞的证据」就推断「不会飞」。**要在系统里表达否定, 只能把「不会飞」当一条独立的事实直接输入, 或者引入「封闭世界假设」(没写进事实的就当它不成立)。 **前者要人保证不遗漏,后者要人承担静默出错的代价。** 3. 为什么说规则系统的维护成本是平方级的? —— 因为它没有「模块边界」。 加一条规则时,你没法只检查跟它「调用关系」上的邻居——规则之间是平铺的, 任何两条都可能在同一个事实集上同时触发,从而冲突。 **n 条规则有 n(n-1)/2 对,一千八百条就是 175 万对。**这个数字决定了一件事: 规则库大到一定程度,就没人能保证它内部一致。规则系统把「怎么想」写死了,可它得先有「知道什么」。 下一节讲这些知识用什么形式存——三种存法各自的取舍。
- ☐ (本节完,下一节 2.2 知识库)
相关笔记
- 人工智能的黎明时期
- 知识库
- 41 课 · 图解深度学习(第 7 章讲规则驱动的配置,是同一种思路的另一面)
- 📘 图解人工智能 · 索引