CS221 · 人工智能:原理与技术
Lecture 16 · 逻辑 II:一阶逻辑
源文件:lecture-16.md
Lecture 16 · 逻辑 II:一阶逻辑
CS221: Artificial Intelligence — Principles and Techniques · Stanford · Autumn 2025
📅 Nov 12 · 💻first_order_logic.py
从命题逻辑到一阶逻辑
上一讲(Lecture 15 · 逻辑 I:命题逻辑) 建立了逻辑的完整框架:逻辑是一门表示知识并对其推理的语言,由三件套定义——语法(什么是合法公式)、语义(公式的含义,通过模型与解释函数给出)、推理规则(如何从旧公式推出新公式)。在命题逻辑中,命题符号(如 Rain、Wet)加上联结词 $\neg\ \wedge\ \vee\ \to\ \leftrightarrow$ 组成公式;模型是对命题符号的真值赋值;核心概念包括蕴含 $\text{KB} \models f$、可满足性、模型检查、modus ponens、归结与可靠性/完备性。
本讲(一阶逻辑,Logic II) 解决命题逻辑的表达力瓶颈:命题逻辑把 “AliceKnowsArithmetic” 当作一个不可分割的原子,既看不到里面的 alice、Knows、arithmetic 结构,也没法说”所有学生都……”。一阶逻辑(first-order logic, FOL) 补上这块表达力:引入对象(objects)、函数(functions)、谓词(predicates)、变量(variables) 与量词(quantifiers) $\forall, \exists$。代价是推理更难——命题逻辑可以枚举有限个模型,而一阶逻辑的论域可能无限,蕴含判定只是半可判定的(见第 8 节)。
提示
一句话本质:命题逻辑把世界看成一堆真假开关;一阶逻辑把世界看成一堆对象 + 它们之间的关系——这才够描述”每个学生都有一个 TA”。表达力与推理难度的这组权衡,是贯穿本讲的主线。
动机例子:如何简洁地表达——
- “每个学生都懂算术”:不能把学生一个个列出来,列表可能很长、甚至事先不知道有哪些学生;
- “每个大于 2 的偶数都是两个素数之和”(哥德巴赫猜想):对象(偶数)有无穷多个,任何枚举都写不完。
这就是本讲要跨越的表达力鸿沟。
1. 热身:一阶逻辑里的一次推理
在深入语法之前,先看源码 motivating_example() 的完整故事,感受一阶逻辑”长什么样”以及推理”能做什么”。所有符号都属于同一个论域类型 Object:常量(alice、phoenix)指代(denote)对象,函数(father: Object → Object)把对象映射到对象,谓词(Student: Object → Bool、Knows: Object × Object → Bool)把对象映射到真值。
from z3 import (Bool, Const, Function, BoolSort, DeclareSort,
And, Or, Not, Implies, ForAll, Exists, Solver, unsat)
Object = DeclareSort("Object") # 只用一个 sort(类型),简化处理
alice, phoenix = Const("alice", Object), Const("phoenix", Object) # 常量 → 对象
x, y = Const("x", Object), Const("y", Object) # 变量(供量词绑定)
father = Function("father", Object, Object) # 函数 → 对象
Student = Function("Student", Object, BoolSort()) # 一元谓词 → 真值
Knows = Function("Knows", Object, Object, BoolSort()) # 二元谓词
Snowing = Bool("Snowing") # 0 元谓词 = 命题符号(退化回命题逻辑)
说明
Snowing = Bool(...) 是一个 0 元谓词——不接受任何对象参数,直接就是真值。可见命题逻辑是一阶逻辑的特例:当所有谓词都是 0 元时,一阶逻辑就退化为命题逻辑。
1.1 知识库与提问:归约到可满足性
源码把一段自然语言逐句翻译进知识库 kb,再用 Z3 求解器回答 yes/no 问题:
def motivating_example():
kb = []
# Alice is a student. → Student(alice)
kb.append(Student(alice))
# Alice is from Phoenix. → From(alice, phoenix)
kb.append(From(alice, phoenix))
# Phoenix is a hot city. → Hot(phoenix) ∧ City(phoenix)
kb.append(And(Hot(phoenix), City(phoenix)))
# Students are people. → ∀x. Student(x) → Person(x)
kb.append(ForAll([x], Implies(Student(x), Person(x))))
# Cities are places. → ∀x. City(x) → Place(x)
kb.append(ForAll([x], Implies(City(x), Place(x))))
# If it is snowing, it is cold. → Snowing → Cold
kb.append(Implies(Snowing, Cold))
# Is it snowing?
result = ask(kb, Snowing) # → "I don't know"
回答问题的机制 ask() 和上一讲完全一样:把蕴含/矛盾归约成可满足性,交给模型检查器(这里是 Z3):
def ask(kb: list[Formula], f: Formula) -> str:
"""向 KB 问一个 yes/no 问题,归约为可满足性问题,交给 Z3 求解器。"""
solver = Solver()
solver.add(kb + [Not(f)])
if solver.check() == unsat: # KB ∧ ¬f 不可满足 ⟹ KB ⊧ f
return "Yes"
solver = Solver()
solver.add(kb + [f])
if solver.check() == unsat: # KB ∧ f 不可满足 ⟹ KB ⊧ ¬f
return "No"
return "I don't know" # 两边都可满足 ⟹ 悬而未决(contingent)
提示
ask 的逻辑与 Lecture 15 · 逻辑 I:命题逻辑 第 6 节的两次 SAT 调用一模一样:$\text{KB} \models f \iff \text{KB} \cup \{\neg f\}$ 不可满足。若 $\text{KB} \cup \{\neg f\}$ 和 $\text{KB} \cup \{f\}$ 都可满足,说明 $f$ 相对 KB 是或然的(contingent),答”不知道”。归约框架不因换了逻辑而变,变的只是底层可满足性检查的难度。
1.2 逐步逼近答案
随着知识增加,Is it snowing? 的答案会变化——这正是推理的价值:
graph LR
A0["初始 KB<br/>→ I don't know"] --> A1["+ 炎热之地的人若遇雪则不快乐<br/>→ I don't know"]
A1 --> A2["+ Happy(alice)<br/>→ No(推出不下雪)"]
style A0 fill:#fff9c4,stroke:#f9a825
style A1 fill:#fff9c4,stroke:#f9a825
style A2 fill:#c8e6c9,stroke:#2e7d32
加入最后两条后:
# If a person is from a hot place and it is snowing, they are unhappy.
# ∀x, y. Person(x) ∧ From(x, y) ∧ Hot(y) ∧ Place(y) ∧ Snowing → ¬Happy(x)
kb.append(ForAll([x, y], Implies(
And(Person(x), From(x, y), Hot(y), Place(y), Snowing),
Not(Happy(x)))))
# Alice is happy. → Happy(alice)
kb.append(Happy(alice))
result = ask(kb, Snowing) # → "No"
例子
多跳链式推理:Alice 是学生 ⟹(”学生是人”)Alice 是 Person;Phoenix 是 hot city ⟹(”城市是地方”)是 hot Place;假设下雪,则”来自炎热之地的人会不开心”给出 $\neg\text{Happy}(\text{alice})$;但 KB 里有 $\text{Happy}(\text{alice})$——矛盾。故 $\text{KB} \cup \{\text{Snowing}\}$ 不可满足,必然不下雪。这本质上是一次归谬(反证)推理,命题逻辑几乎无法优雅表达——每条通用规则都得对每个人各抄一遍。
注意
这里所有 $\forall x\,(\text{Student}(x) \to \text{Person}(x))$ 之类的规则,靠的是变量 + 量词一次性覆盖所有对象。这正是命题逻辑缺的东西——下一节精确说明它”缺”在哪。
2. 命题逻辑的局限
源码 limitations_of_propositional_logic() 尝试用命题逻辑表达几个事实,暴露它的笨拙。
2.1 三个越来越难的句子
def limitations_of_propositional_logic():
# "Alice and Bob both know arithmetic." 勉强能写,但要一个个列
AliceKnowsArithmetic = Bool("AliceKnowsArithmetic")
BobKnowsArithmetic = Bool("BobKnowsArithmetic")
formula = And(AliceKnowsArithmetic, BobKnowsArithmetic)
# "All students know arithmetic." 只能对已知的人展开成合取
AliceIsStudent = Bool("AliceIsStudent")
BobIsStudent = Bool("BobIsStudent")
formula = And(Implies(AliceIsStudent, AliceKnowsArithmetic),
Implies(BobIsStudent, BobKnowsArithmetic))
# "Every even integer greater than 2 is the sum of two primes."
# ??? —— 对象有无穷个,命题逻辑彻底无能为力
第一句还能忍(只是啰嗦);第二句已经变味——“所有学生”被偷换成”目前列出来的这几个人”,来了新学生就得改公式;第三句彻底写不出——无穷多个偶数意味着无穷长的合取。
2.2 到底缺了什么?
源码点破两处缺失:
| 缺失 | 命题逻辑 | 一阶逻辑 |
|---|---|---|
| 对象与谓词 | AliceKnowsArithmetic 是一个整体,看不到内部结构 |
拆成对象 alice、arithmetic 与关系 Knows:$\text{Knows}(\text{alice}, \text{arithmetic})$ |
| 量词与变量 | “所有”只能手动枚举,无法/不可能列尽 | $\forall x$ 一次性覆盖论域中所有对象 |
graph LR
subgraph 命题逻辑
A["AliceKnowsArithmetic<br/>(不透明原子)"]
end
subgraph 一阶逻辑
B["Knows"] --> C["alice"]
B --> D["arithmetic"]
end
A -.拆解出结构.-> B
style A fill:#ffcdd2,stroke:#c62828
style B fill:#ffe0b2,stroke:#e65100
style C fill:#e1f5fe,stroke:#0277bd
style D fill:#e1f5fe,stroke:#0277bd
提示
命题逻辑像只有”按钮名”的仪表盘,每个按钮独立开关,按钮之间的任何联系都要靠额外公式硬编码;一阶逻辑像有了名词、动词、量词的语言,能直接谈论无穷多对象及其关系。”表达力升级”的本质是:把原子命题内部的结构暴露给逻辑系统,让规则可以在结构上泛化。
3. 一阶逻辑语法(Syntax)
对应源码 first_order_logic_syntax()。核心区分:项(term)指代对象,公式(formula)指代真值。
3.1 项(Terms)——指代对象
alice = Const("Alice", Object) # 常量:直接命名一个对象
x, y = Const("x", Object), Const("y", Object) # 变量:待量词绑定
father = Function("father", Object, Object) # 函数:对象 → 对象,可嵌套
add = Function("add", Object, Object, Object)
term = father(alice) # father(alice) 仍是一个项(对象),可无限嵌套 father(father(alice))
term = add(x, y)
三类项:常量符号(直接命名对象)、变量(占位、待量词绑定)、函数应用(函数作用在项上,结果仍是项,可无限嵌套)。
3.2 公式(Formulas)——指代真值
# 原子公式(atomic formula):谓词作用在项上
Snowing = Bool("Snowing") # 0 元谓词(= 命题符号)
Student = Function("Student", Object, BoolSort()) # 一元谓词
Knows = Function("Knows", Object, Object, BoolSort()) # 二元谓词
formula = Knows(x, arithmetic)
# 联结词作用在公式上(与命题逻辑相同)
formula = Implies(Student(x), Knows(x, arithmetic)) # Student(x) → Knows(x, arithmetic)
# 量词作用在公式上
formula = ForAll([x], Implies(Student(x), Knows(x, arithmetic))) # ∀x. Student(x) → Knows(x, arithmetic)
formula = Exists([x], And(Student(x), Knows(x, arithmetic))) # ∃x. Student(x) ∧ Knows(x, arithmetic)
合法公式的归纳定义(与命题逻辑同构):原子公式(谓词作用在项上)是公式;公式经联结词组合仍是公式;公式经量词 $\forall x$、$\exists x$ 绑定变量后仍是公式。
3.3 哪些不是合法公式
源码特意列出非公式,帮助分清项与公式的边界:
# ✗ father(x) —— 是项(对象),不是公式(真值)
# ✗ Knows(Student, arithmetic) —— Student 是谓词,不能当项塞进去
# ✗ Foo(Knows(alice, arithmetic))—— 不能把函数作用在公式上
注意
谓词/联结词/量词作用在合适的输入上产出公式(真值);函数只能作用在项上产出项(对象)。项和公式是两个互不相通的世界,混用就是语法错误。记号约定:小写开头表示项(alice, x, father(alice)),大写开头表示谓词/公式(Knows(x, arithmetic))。
3.4 语法树
以 $\forall x\,(\text{Student}(x) \to \text{Knows}(x, \text{arithmetic}))$ 为例,公式的语法结构是一棵树——叶子是项,中间是谓词,再往上是联结词与量词:
graph TD
Q["∀x<br/>(全称量词)"] --> IMP["→<br/>(蕴含)"]
IMP --> P1["Student<br/>(一元谓词)"]
IMP --> P2["Knows<br/>(二元谓词)"]
P1 --> T1["x<br/>(变量·项)"]
P2 --> T2["x<br/>(变量·项)"]
P2 --> T3["arithmetic<br/>(常量·项)"]
style Q fill:#ffe0b2,stroke:#e65100
style IMP fill:#f3e5f5,stroke:#7b1fa2
style P1 fill:#e1f5fe,stroke:#0277bd
style P2 fill:#e1f5fe,stroke:#0277bd
style T1 fill:#c8e6c9,stroke:#2e7d32
style T2 fill:#c8e6c9,stroke:#2e7d32
style T3 fill:#c8e6c9,stroke:#2e7d32
- 🟠 橙:量词(作用域最外层) 🟣 紫:联结词 🔵 蓝:谓词(→ 真值) 🟢 绿:项(→ 对象)
4. 一阶逻辑语义(Semantics)
对应源码 first_order_logic_semantics()。语义回答:这些公式到底是什么意思? 即定义解释机制,把公式 $f$ 和模型 $w$ 映射到真值 $\mathcal{I}(f, w)$。难点在于:一阶逻辑的”模型”不能再照抄命题逻辑的”真值赋值”。
4.1 失败的尝试:直接给原子公式赋真值
模仿命题逻辑,把模型定义成”对所有原子公式的真值赋值” $w = \{\text{Student}(\text{alice}){:}\ \text{True},\ \dots\}$。两个致命问题:
# 问题 1:带函数的原子公式有无穷多个!
term = father(alice)
term = father(father(alice))
term = father(father(father(alice))) # ... 无限
# 一个模型要给无穷多原子公式赋值——不可行
# 问题 2:语法不同的两个项可能指同一个对象
term = father(alice)
term = bob # 万一 Bob 就是 Alice 的父亲?
# 但 {Knows(father(alice), ...): True, Knows(bob, ...): False} 会让它们独立——不对
注意
症结在于:把语义直接绑在语法(符号串)上,就无法处理”函数造成的无穷多项”和”不同名字指同一对象”这两件事。真实世界里对象是有限的、名字可以有别名——语义必须以对象为本,而不是以名字为本。
4.2 正确做法:论域 + 解释函数
关键思想:引入一层间接(a layer of indirection)——在符号和真值之间插入”对象”这一层。
定义:一阶逻辑的一个模型 $w$ 由两部分组成:
- 论域(domain):一个非空的对象集合,如["o1", "o2", "o3"];
- 解释函数(interpretation function):把每个常量符号映射到一个对象、每个函数符号映射到论域上的函数、每个谓词符号映射到论域上的关系。
domain = ["o1", "o2", "o3"]
constants = { # 常量 → 对象
alice: "o1",
bob: "o2",
arithmetic: "o3",
}
functions = { # 函数 → (对象→对象) 映射表
father: {"o1": "o2"}, # father(o1) = o2 ⟹ Bob 是 Alice 的父亲
}
predicates = { # 谓词 → 使其为真的对象(元组)集合
Knows: {("o1", "o3"): True, ("o2", "o3"): True},
Student: {"o1": True},
}
interpretation = Interpretation(constants=constants,
functions=functions,
predicates=predicates)
# 模型 = 论域 + 解释函数
w = FirstOrderLogicModel(domain=domain, interpretation=interpretation)
源码里两个数据结构就是模型的骨架:
@dataclass(frozen=True)
class Interpretation:
"""定义一阶逻辑模型:把符号映射到论域中的对象。"""
constants: dict[Const, str] # 常量 → 对象
functions: dict[Function, dict[str, str]] # 函数 → 映射表
predicates: dict[Function, dict[tuple[str, ...], bool]] # 谓词 → 关系表
@dataclass(frozen=True)
class FirstOrderLogicModel:
"""一阶逻辑的模型,由论域和解释函数组成。"""
domain: list[str]
interpretation: Interpretation
graph LR
subgraph SYM["符号层(语法)"]
C1["alice"]:::sym
C2["bob"]:::sym
F1["father"]:::sym
P1["Knows"]:::sym
end
subgraph OBJ["论域(对象)"]
O1(("o1")):::obj
O2(("o2")):::obj
O3(("o3")):::obj
end
C1 -.->|"解释"| O1
C2 -.->|"解释"| O2
F1 -.->|"father(o1)=o2"| O2
P1 -.->|"Knows(o1,o3)=真"| O3
O1 == 关系 Knows ==> O3
classDef sym fill:#e1f5fe,stroke:#0277bd
classDef obj fill:#ffe0b2,stroke:#e65100
提示
“一层间接”同时化解了两难:(1)无穷多的项 $\text{father}(\text{father}(\text{alice}))$ 不需要单独赋值,只需反复查函数表逐层求值;(2)$\text{father}(\text{alice})$ 与 $\text{bob}$ 若都解释到 o2,它们就自动指同一对象,谓词对它们的取值必然一致。这与编程里”指针/引用”的思想同源:名字是入口,实体在堆上,多个名字可以指向同一实体。
4.3 递归解释:interpret_formula 与 interpret_term
任意公式的真值递归定义。核心是量词分支(一阶逻辑独有)与谓词查表;联结词 $\neg\ \wedge\ \vee\ \to$ 的分支与命题逻辑完全一样,此处省略:
def interpret_formula(f, w, subst={}):
"""给定公式 f 和模型 w,计算 w 是否满足 f。subst 把变量映射到对象。"""
if is_quantifier(f) and f.is_forall():
# 全称量词 ∀x. body :把 x 绑到论域每个对象,全部为真才为真
variable, body = Var(0, f.var_sort(0)), f.body()
result = True
for o in w.domain:
result = result and interpret_formula(body, w, subst | {variable: o})
elif is_quantifier(f) and f.is_exists():
# 存在量词 ∃x. body :只要论域中有一个对象为真即为真
variable, body = Var(f.var_name(0), f.var_sort(0)), f.body()
result = False
for o in w.domain:
result = result or interpret_formula(body, w, subst | {variable: o})
elif f.num_args() == 1 and not is_not(f): # 一元谓词 Student(t):查关系表
predicate, arg = f.decl(), f.arg(0)
table = w.interpretation.predicates[predicate]
result = table.get(interpret_term(arg, w, subst), False)
elif f.num_args() == 2 and not (is_and(f) or is_or(f) or is_implies(f)):
# 二元谓词 Knows(t1, t2):以对象元组查关系表
predicate, a1, a2 = f.decl(), f.arg(0), f.arg(1)
table = w.interpretation.predicates[predicate]
v1, v2 = interpret_term(a1, w, subst), interpret_term(a2, w, subst)
result = table.get((v1, v2), False)
# ... 其余分支 ¬ / ∧ / ∨ / → 与命题逻辑相同(对子公式递归后组合)
return result
项的解释同样递归——变量查 subst(当前量词绑定),常量查 constants,函数应用先递归求参数、再查函数表:
def interpret_term(t, w, subst):
"""在模型 w 和替换 subst 下解释一个项,返回它指代的对象。"""
if is_var(t): # 变量 x → 查当前绑定
result = subst[t]
elif t.num_args() == 0: # 常量 alice → 查解释函数
result = w.interpretation.constants[t]
else: # 函数应用 father(alice)
func, args = t.decl(), t.args()
func_value = w.interpretation.functions[func]
arg_values = [interpret_term(a, w, subst) for a in args]
result = func_value[arg_values[0]] # 一元情形直接查表;多元时以元组为键
return result
调用示例:
result = interpret_formula(Knows(alice, arithmetic), w) # True(查表 (o1,o3)=T)
result = interpret_formula(ForAll([x], Knows(x, arithmetic)), w) # False(o2 知道但 o3 自己不)
提示
量词的语义就是在论域上循环:$\forall x$ = 对每个对象都成立(and 折叠,初值 True——空论域上真);$\exists x$ = 存在某个对象成立(or 折叠,初值 False——空论域上假)。论域有限时可以直接枚举求值——这正是这份教学代码”量词可计算”的前提;论域无限(如全体整数)时,语义定义依然成立,但逐一枚举不再可行,推理必须走符号路线(第 5、6 节)。
说明
这份教学实现有意做了简化:is_variable 靠硬编码变量名(x, y, z)区分变量与常量,量词也只处理了单变量的情形。真正的一阶逻辑实现(如 Z3 内部)用 de Bruijn 索引管理变量绑定,这些工程细节不影响概念理解。
4.4 全称 vs 存在
graph LR
D["论域<br/>{o1, o2, o3}"] --> FA["∀x. P(x)<br/>每个对象都真才为真<br/>(and 折叠, 初值 True)"]
D --> EX["∃x. P(x)<br/>有一个对象真即为真<br/>(or 折叠, 初值 False)"]
style D fill:#ffe0b2,stroke:#e65100
style FA fill:#c8e6c9,stroke:#2e7d32
style EX fill:#c8e6c9,stroke:#2e7d32
由此还能看出一对对偶关系(德摩根律的量词版):$\neg \forall x\, P(x) \equiv \exists x\, \neg P(x)$,$\neg \exists x\, P(x) \equiv \forall x\, \neg P(x)$——“并非所有人都及格”等价于”有人不及格”。
5. 推理之一:命题化(Propositionalization)
对应源码 propositionalization()。最直接的推理思路:把一阶逻辑归约回命题逻辑,复用上一讲的全部技术(模型检查、SAT 求解等)。
5.1 两个前提假设
一般情况做不到(一阶逻辑严格更强)。但若模型满足两个假设,就能命题化:
# 待归约的知识库
kb = [
Student(alice),
Student(bob),
ForAll([x], Implies(Student(x), Knows(x, arithmetic))),
Exists([x], And(Student(x), Knows(x, arithmetic))),
]
| 假设 | 含义 | 排除的情形 |
|---|---|---|
| 唯一名称(unique names) | 每个对象至多对应一个常量(不同名字不指同一对象) | 排除 $\text{father}(\text{alice}) = \text{bob}$ 这类别名 |
| 论域封闭(domain closure) | 每个对象至少对应一个常量(没有匿名对象) | 排除论域里有”没名字”的对象 |
提示
两个假设合起来 = 常量与对象一一对应。这样一来论域就是有限且已知的(就是常量列表本身),量词才能安全地展开成有限的合取/析取。第 4.2 节引入的那层”间接”在此被抹平了——符号即对象,一阶逻辑随之塌缩回命题逻辑。
5.2 展开量词
有了假设,$\forall$ 展开为对所有常量的合取,$\exists$ 展开为析取,每个接地(grounded)的原子公式变成一个命题符号:
$$\forall x\, P(x) \;\rightsquigarrow\; P(a_1) \wedge \cdots \wedge P(a_n), \qquad \exists x\, P(x) \;\rightsquigarrow\; P(a_1) \vee \cdots \vee P(a_n)$$
# ∀x. Student(x) → Knows(x, arithmetic) 在 {alice, bob} 上展开
# ∃x. Student(x) ∧ Knows(x, arithmetic)
StudentAlice = Bool("StudentAlice")
StudentBob = Bool("StudentBob")
KnowsAliceArithmetic = Bool("KnowsAliceArithmetic")
KnowsBobArithmetic = Bool("KnowsBobArithmetic")
kb = [
StudentAlice,
StudentBob,
# ∀ 展开成合取
And(Implies(StudentAlice, KnowsAliceArithmetic),
Implies(StudentBob, KnowsBobArithmetic)),
# ∃ 展开成析取
Or(And(StudentAlice, KnowsAliceArithmetic),
And(StudentBob, KnowsBobArithmetic)),
]
# 现在这就是纯命题逻辑,可用上一讲的模型检查等技术求解
flowchart LR
A["一阶 KB<br/>∀x. Student(x)→Knows(x,arith)"] -->|唯一名称 + 论域封闭| B["命题 KB<br/>StudentAlice→KnowsAliceArith<br/>∧ StudentBob→KnowsBobArith"]
B --> C["模型检查<br/>(上一讲技术)"]
style A fill:#e1f5fe,stroke:#0277bd
style B fill:#ffe0b2,stroke:#e65100
style C fill:#c8e6c9,stroke:#2e7d32
说明
在这两个假设下,一阶逻辑只是命题逻辑的语法糖(syntactic sugar):表达力相同,但更好读好写。可一旦允许函数符号(产生无穷多项)或匿名对象,命题化就失效——展开会无穷无尽。这时需要直接在一阶语法上做推理,也就是下一节的替换与合一。另注意展开的代价:$k$ 元谓词在 $n$ 个常量上会产生 $n^k$ 个命题符号,命题化即使可行也可能引起组合爆炸。
6. 推理之二:Modus Ponens + 替换 + 合一
对应源码 first_order_logic_inference_rules()。当命题化不可行(或太贵)时,直接在一阶语法上做符号推理。
6.1 确定子句(Definite Clause)
Modus ponens 只作用在一种特殊形状的公式上——确定子句:
$$\forall x_1 \cdots \forall x_n\,\big[(a_1 \wedge \cdots \wedge a_k) \to b\big]$$
其中 $x_i$ 是变量,$a_1, \dots, a_k, b$ 都是原子公式。特例 $k = 0$ 时就是一个原子事实。
# 例(是确定子句):
formula = ForAll([x, y, z], Implies(And(Takes(x, y), Covers(y, z)), Knows(x, z)))
# 变量 x,y,z;前提 a1=Takes(x,y), a2=Covers(y,z);结论 b=Knows(x,z)
# 反例(不是确定子句——含析取 / 存在量词):
formula = Or(Student(alice), Student(bob))
formula = Exists([x], And(Student(x), Knows(x, arithmetic)))
提示
确定子句里不允许析取($\vee$)和存在量词($\exists$)——即”不确定的知识”(”Alice 或 Bob 是学生”没说清是谁;”存在一个学生”没说是哪个)。把知识限制成”若干条件全满足则得出一个明确结论”的形状,推理就变成高效的模式匹配 + 链式触发(Prolog 正是建立在这类 Horn 子句之上);代价是不完备(见 6.6)。
6.2 问题:精确匹配失败
朴素 modus ponens 要求已知事实与规则前提逐字相等,但变量挡了路:
kb = [
Takes(alice, cs221), # a_1'
Covers(cs221, logic), # a_2'
ForAll([x, y, z], Implies(And(Takes(x, y), Covers(y, z)), Knows(x, z))),
]
# 想推出 Knows(alice, logic)
# 但 Takes(alice, cs221) ≠ Takes(x, y) —— 无法精确匹配,规则用不上
解法:替换(substitution)+ 合一(unification)——先让规则的变量”落地”成具体的项,再套用 modus ponens。
6.3 替换(Substitution)
替换 $\theta$ 就是在公式里对变量做”查找—替换”,记作 $\text{substitute}(f, \theta)$:
def substitute(f, subst):
"""把公式 f 中出现在 subst 里的变量替换掉。"""
if f in subst:
result = subst[f] # 命中:直接换成目标项
else:
new_args = [substitute(f.arg(i), subst) for i in range(f.num_args())]
result = f.decl()(*new_args) # 递归替换每个子项后重建
return result
# 例:
substitute(Knows(x, y), {x: alice, y: cs221}) # Knows(alice, cs221)
substitute(And(Student(x), Knows(x, y)), {x: alice, y: z}) # Student(alice) ∧ Knows(alice, z)
6.4 合一(Unification)
合一 = 找一个替换 $\theta$,使两个公式变得完全相同:$\text{substitute}(f_1, \theta) = \text{substitute}(f_2, \theta)$。
def unify(f1, f2, subst):
"""找到扩展 subst 的替换,使 f1 与 f2 相等;失败返回 None。"""
if f1 == f2:
return subst
elif is_variable(f1): # f1 是变量 → 绑定到 f2
subst[f1] = substitute(f2, subst)
return subst
elif is_variable(f2): # f2 是变量 → 绑定到 f1
subst[f2] = substitute(f1, subst)
return subst
else:
if f1.decl() != f2.decl(): # 顶层符号不同 → 失败
return None
for i in range(f1.num_args()): # 逐个子项递归合一,任一失败即整体失败
if unify(f1.arg(i), f2.arg(i), subst) is None:
return None
return subst
def is_variable(f):
# Takes(alice, x) 里其实分不清常量 alice 与变量 x,只能硬编码变量名
return f.decl().name() in ["x", "y", "z"]
# 例:
unify(Knows(x, y), Knows(alice, bob), {}) # {x: alice, y: bob}
unify(Knows(alice, y), Knows(x, z), {}) # {x: alice, y: z}(把 x 绑到 alice…)
unify(Knows(alice, y), Knows(bob, z), {}) # None —— alice≠bob,合一失败
说明
这份教学实现省略了完整合一算法的两处细节:(1)出现检查(occurs check)——把变量 $x$ 绑定到含 $x$ 自身的项(如 $x \mapsto \text{father}(x)$)会造成无穷循环,标准算法在绑定前要检查并拒绝;(2)多条规则同时使用时要先变量改名(standardizing apart),避免两条规则恰好都用变量 $x$ 而互相干扰。另外,合一算法返回的是最一般合一子(most general unifier, MGU)——约束最少的那个替换,保证不过度特化。
6.5 带合一的 Modus Ponens
把两块拼起来:先用合一匹配前提,再把得到的替换施加到结论上:
$$\frac{a_1', \dots, a_k' \qquad \forall x_1 \cdots \forall x_n\,\big[(a_1 \wedge \cdots \wedge a_k) \to b\big]} {\text{substitute}(b, \theta)} \quad \text{其中 } \theta = \text{unify}(a_1' \wedge \cdots \wedge a_k',\ a_1 \wedge \cdots \wedge a_k)$$
theta = unify(
And(Takes(alice, cs221), Covers(cs221, logic)), # 已知前提 a_1' ∧ a_2'
And(Takes(x, y), Covers(y, z)), # 规则前提 a_1 ∧ a_2
{})
# theta = {x: alice, y: cs221, z: logic}
if theta is not None:
result = substitute(Knows(x, z), theta) # → Knows(alice, logic) ✓
例子
已知 $\text{Takes}(\text{alice}, \text{cs221})$ 与 $\text{Covers}(\text{cs221}, \text{logic})$,规则前提是 $\text{Takes}(x, y) \wedge \text{Covers}(y, z)$。合一逐参数进行:$x \mapsto \text{alice}$,$y \mapsto \text{cs221}$(注意 $y$ 在两个原子里都出现,第二个原子要求 $\text{Covers}(\text{cs221}, z)$,恰好一致),$z \mapsto \text{logic}$。得 $\theta = \{x{:}\ \text{alice},\ y{:}\ \text{cs221},\ z{:}\ \text{logic}\}$,施加到结论 $\text{Knows}(x, z)$ 上,导出新事实 $\text{Knows}(\text{alice}, \text{logic})$。变量 $y$ 起到了”把两个前提铆在同一门课上”的作用——这是合一比逐个匹配更强的地方。
6.6 复杂度与性质
每次 modus ponens 产出一个接地的原子公式(如 $\text{Knows}(\text{alice}, \text{logic})$)。无函数符号时,可导出的原子公式最多 $O(c^k)$ 个($c$ 为常量个数、$k$ 为谓词最大元数),前向推理必然终止;一旦有函数符号,项能无限嵌套($\text{Knows}(\text{father}(\text{father}(\text{alice})), \text{logic})$……),可导出集合可能无穷,前向推理可能不停机。
| 性质 | Modus Ponens(确定子句) |
|---|---|
| 可靠(sound) | ✅ 若 $\text{KB} \vdash f$ 则 $\text{KB} \models f$(推出来的都对) |
| 完备(complete) | ❌ 对一般一阶 KB 存在 $\text{KB} \models f$ 但 $\text{KB} \nvdash f$;需一阶归结才反驳完备 |
| 适用形式 | 只限确定子句(无 $\vee$、无 $\exists$) |
注意
呼应 Lecture 15 · 逻辑 I:命题逻辑 第 8 节:命题逻辑里 modus ponens 只对 Horn 子句 KB(配原子查询)完备,一般情形要靠归结。一阶逻辑同理——modus ponens 高效但不完备;一阶归结(含合一)由 Robinson(1965)提出,对完整一阶逻辑是反驳完备的通用推理程序。但完备不等于可判定:见第 8 节的半可判定性。
7. 自然语言 → 一阶逻辑
对应源码 natural_language_to_first_order_logic()。一阶逻辑天然适合编码自然语言陈述——这也是逻辑知识库(如 Cyc、语义网)的建模基本功。
7.1 量词的黄金搭配
# "Alice and Bob both know arithmetic."
And(Knows(alice, arithmetic), Knows(bob, arithmetic))
# "All students know arithmetic." ∀ 配 →
ForAll([x], Implies(Student(x), Knows(x, arithmetic)))
# "Some student knows arithmetic." ∃ 配 ∧
Exists([x], And(Student(x), Knows(x, arithmetic)))
提示
经验法则:全称量词 $\forall$ 通常配蕴含 $\to$——“所有学生懂算术”实际是说”任取一个对象,如果它是学生,那么它懂算术”,对非学生不做任何断言;存在量词 $\exists$ 通常配合取 $\wedge$——“有学生懂算术”是说”存在一个对象,它既是学生又懂算术”。$\to$ 负责”筛选”,$\wedge$ 负责”同时断言”。
7.2 搭配错误会怎样
# ∀ 配 ∧ —— 太强!
ForAll([x], And(Student(x), Knows(x, arithmetic)))
# 意思变成:"每个对象都既是学生又懂算术"(论域里所有东西都是学生)
# ∃ 配 → —— 太弱!
Exists([x], Implies(Student(x), Knows(x, arithmetic)))
# 意思变成:"存在某对象,它要么不是学生,要么懂算术"(几乎恒真,没信息量)
注意
四种搭配:✅ $\forall + \to$(所有学生懂算术)、✅ $\exists + \wedge$(有学生懂算术);❌ $\forall + \wedge$(万物皆学生且懂算术,太强)、❌ $\exists + \to$(几乎恒真——只要论域里有任何一个非学生对象,公式就靠”前件为假、蕴涵空真”自动成立,太弱)。$\exists + \to$ 的病根正是上一讲强调的蕴涵真值表:前件为假时整个蕴涵为真。
7.3 嵌套量词与函数
一阶逻辑的表达力在嵌套量词和函数处彻底展开:
# "There is some course that every student has taken." ∃ 外, ∀ 内
Exists([x], And(Course(x), ForAll([y], Implies(Student(y), Takes(y, x)))))
# "Every even integer greater than 2 is the sum of two primes."(哥德巴赫猜想)
ForAll([x], Implies(And(Even(x), GreaterThan(x, two)),
Exists([y, z], And(Prime(y), Prime(z), add(y, z) == x))))
# "If a student takes a course and the course covers a concept,
# then the student knows the concept."
ForAll([x, y, z], Implies(
And(Student(x), Takes(x, y), Course(y), Covers(y, z), Concept(z)),
Knows(x, z)))
说明
“每个大于 2 的偶数都是两素数之和”——第 2 节命题逻辑束手无策的句子,一阶逻辑一行写完:$\forall x\,\big[\text{Even}(x) \wedge x > 2 \to \exists y\, \exists z\,(\text{Prime}(y) \wedge \text{Prime}(z) \wedge y + z = x)\big]$。注意 add(y, z) == x 用到了函数 + 相等谓词。另外量词顺序至关重要:$\exists x\, \forall y$(有一门课所有人都上过——同一门课)与 $\forall y\, \exists x$(每人都上过某门课——可以各不相同)含义完全不同。
8. 命题逻辑 vs 一阶逻辑
| 维度 | 命题逻辑(Logic I) | 一阶逻辑(Logic II) |
|---|---|---|
| 基本单元 | 命题符号(真假原子) | 对象、函数、谓词、变量 |
| 原子公式 | $\text{Snowing}$(不可分) | $\text{Knows}(\text{alice}, \text{arithmetic})$(有结构) |
| 量词 | 无 | $\forall$(全称)、$\exists$(存在) |
| 项 vs 公式 | 无”项”概念 | 项指代对象,公式指代真值 |
| 模型 | 对符号的真值赋值 | 论域 + 解释函数 |
| “所有学生……” | 只能手动枚举 | $\forall x\,(\text{Student}(x) \to \cdots)$ 一句话 |
| 表达力 | 弱 | 强(严格包含命题逻辑) |
| 推理 | 可枚举全部 $2^n$ 个模型 | 论域可能无限,一般不可枚举 |
| 可判定性 | 可判定(枚举/ SAT 求解必终止) | 半可判定(见下方说明) |
| 完备推理规则 | 归结(反驳完备) | 一阶归结 + 合一(反驳完备) |
注意
一阶逻辑的蕴含是半可判定(semi-decidable)的,方向要记对:若 $\text{KB} \models f$ 确实成立,完备的推理程序(如一阶归结)保证在有限步内给出证明——有效/不可满足的情形是可以枚举确认的;但若 $\text{KB} \not\models f$,程序可能永远不停机——没有通用算法能在有限时间内确认”可满足/不蕴含”(Church 与 Turing,1936)。对照命题逻辑:模型有限,两个方向都能终止,故完全可判定。一句话:一阶逻辑里”是”等得到答案,”否”可能等不到。
提示
一句话总结:一阶逻辑 = 命题逻辑 + 对象/关系/量词。它把”真假开关面板”升级为”谈论无穷对象及其关系的语言”——表达力换来了推理难度,这是逻辑世界里典型的”没有免费午餐”。
9. 总结
mindmap
root((一阶逻辑))
动机
命题逻辑原子不透明
无法表达所有与存在
无穷对象彻底失效
语法
项指代对象
常量 变量 函数
公式指代真值
谓词 联结词 量词
语义
模型是论域加解释函数
符号到对象的一层间接
递归求值
量词即论域上循环
推理
命题化
唯一名称与论域封闭
带合一的 modus ponens
替换与合一
可靠但不完备
一阶归结才反驳完备
应用
自然语言编码
全称配蕴含
存在配合取
嵌套量词与函数
关键要点:
- 命题逻辑的局限:原子命题不透明、无法表达”所有/存在”,遇到无穷对象(哥德巴赫猜想)彻底失效。
- 一阶逻辑的新原料:项(常量/变量/函数应用,指代对象)、谓词(作用在项上,产出真值)、量词($\forall, \exists$ 绑定变量);命题逻辑是”所有谓词都 0 元”的特例。
- 语义 = 论域 + 解释函数:在符号与真值之间加一层”对象”的间接,同时解决函数带来的无穷项与”不同名字同一对象”两难;$\mathcal{I}(f, w)$ 递归求值,量词就是在论域上做
and/or折叠。 - 命题化:在唯一名称 + 论域封闭假设下(常量与对象一一对应),一阶逻辑退化为命题逻辑的语法糖,可复用 Lecture 15 · 逻辑 I:命题逻辑 的模型检查;有函数符号时失效。
- Modus ponens + 替换 + 合一:在确定子句上做一阶推理;合一找到让公式相同的最一般替换。整体可靠但不完备——完备(反驳意义上)需要一阶归结;且一阶蕴含只是半可判定。
- 建模技巧:$\forall$ 配 $\to$,$\exists$ 配 $\wedge$;量词嵌套顺序改变含义;搭配错了语义会悄悄跑偏。
下一讲预告:逻辑用手写规则表示知识——精确但昂贵、脆弱。知识能否从海量文本中自动学出来?下一讲进入语言模型:从 n-gram 到 Transformer 与注意力,再到预训练 + 对齐——今天的大模型把海量常识隐式压进参数里,与本讲的显式知识库形成鲜明对照。→ Lecture 17 · 语言模型
复习自测
题目
两个原因。其一,函数符号让项可以无限嵌套($\text{father}(\text{father}(\cdots))$),原子公式因此有无穷多个,”给每个原子公式赋真值”的表写不完;其二,语法不同的项可能指同一对象($\text{father}(\text{alice})$ 与 $\text{bob}$),直接按符号赋值会让它们的真值互相独立,违背事实。引入论域后:项通过解释函数求值到对象,谓词只对对象(元组)定义真值——无穷项靠反复查表求值,别名自动合流。
题目
(a) $\exists t\,\big[\text{TA}(t) \wedge \forall s\,(\text{Student}(s) \to \text{Knows}(t, s))\big]$——同一位 TA 通吃所有学生。
(b) $\forall s\,\big[\text{Student}(s) \to \exists t\,(\text{TA}(t) \wedge \text{Knows}(t, s))\big]$——每个学生各有其 TA,可以人人不同。
区别全在量词顺序:$\exists$ 在外层时,那个对象要对内层的所有情形统一负责;$\exists$ 在内层时,可随外层变量逐个挑选。(a) 蕴含 (b),反之不成立。
题目
$\forall x\,(\text{Student}(x) \wedge \text{Knows}(x, \cdot))$ 断言论域里每个对象都是学生且懂算术——把”筛选条件”误写成了”全称断言”,太强。$\exists x\,(\text{Student}(x) \to \text{Knows}(x, \cdot))$ 则太弱:只要论域里存在任何一个非学生,前件为假、蕴涵空真,公式就自动成立,几乎不携带信息。病根是蕴涵的真值表——前件为假时整体为真。正确搭配:$\forall + \to$(筛选后断言)、$\exists + \wedge$(同时断言存在)。
题目
顶层符号都是 Knows,逐参数合一。第 1 参数:unify(alice, x)——$x$ 是变量,绑定 $x \mapsto \text{alice}$,替换变为 $\{x{:}\ \text{alice}\}$。第 2 参数:unify(y, father(x))——$y$ 是变量,绑定 $y \mapsto \text{substitute}(\text{father}(x), \{x{:}\ \text{alice}\}) = \text{father}(\text{alice})$。最终 $\theta = \{x{:}\ \text{alice},\ y{:}\ \text{father}(\text{alice})\}$。注意绑定时先对目标施加当前替换——这一步保证后绑定的变量吸收先前的约束。
题目
唯一名称排除”不同常量指同一对象”(如 $\text{father}(\text{alice})$ 恰好就是 bob);论域封闭排除”论域中存在没有名字的对象”。两者合起来使常量与对象一一对应,论域有限且已知,$\forall/\exists$ 才能安全展开成有限合取/析取。有函数符号时,项集合无穷($\text{father}$ 可无限嵌套),要么论域被迫无穷、要么大量项是匿名对象的别名——两个假设至少破一个,展开无法终止。此时只能走替换 + 合一(或一阶归结)的符号推理路线。
参考资料
- 💻 first_order_logic.py — 本讲源码(Z3 + 自定义解释器)
- 💻 propositional_logic.py — 上一讲命题逻辑(
Formula、I、get_models) - 📖 Russell & Norvig, Artificial Intelligence: A Modern Approach — 第 8 章「一阶逻辑」、第 9 章「一阶逻辑推理」(合一、前向/后向链、归结)
- 🔗 Z3 Theorem Prover — 源码中用于可满足性求解的 SMT 求解器
- 🔗 Z3 Python 教程 —
ForAll/Exists/Solver用法 - 📄 Goldbach’s Conjecture — 「大于 2 的偶数皆两素数之和」,一阶逻辑经典例句
- 🌐 CS221 课程主页 — 全部讲义与作业