范畴论深度实战:从范畴、函子与自然变换到单子、Yoneda 引理与 Kleisli 范畴的完整工程链路
范畴论常被误读为"抽象代数之上的另一层抽象",但它的真正价值在于提供一套描述"结构如何被保持、计算如何被组合"的通用语言。本文用纯 Python 从零实现范畴、函子、自然变换、单子与 Yoneda 引理,并逐条用代码验证代数定律,最后落到它与深度学习、Agent 工作流的工程映射。
一、范畴:对象、态射与复合律
一个范畴由三类东西组成:对象(Object)、态射(Morphism,也叫箭头)、以及态射的复合运算。复合必须满足两条公理——单位元律(任何对象都有一条不改变结构的恒等态射)与结合律((h∘g)∘f = h∘(g∘f))。我们用一个四对象范畴来落地:对象 A→B→C→D,由生成态射 a,b,c 及其复合构成。
# 范畴 = 对象 + 态射 + 复合 + 单位元
class Mor:
def __init__(self, name, src, dst, path):
self.name = name; self.src = src; self.dst = dst; self.path = path
def __repr__(self): return self.name
class Category:
def __init__(self, objs):
self.objs = set(objs); self.morphisms = {}
def add(self, m): self.morphisms[(m.src, m.dst, m.name)] = m
def compose(self, g, f):
# g after f:要求 f 的终点等于 g 的起点
assert f.dst == g.src, f"cannot compose {g} after {f}"
return Mor(g.name + "∘" + f.name, f.src, g.dst, g.path + f.path)
def id(self, X): return Mor("id_" + X, X, X, ())
C = Category(["A", "B", "C", "D"])
a = Mor("a", "A", "B", ("a",))
b = Mor("b", "B", "C", ("b",))
c = Mor("c", "C", "D", ("c",))
for m in (a, b, c): C.add(m)
for X in ["A", "B", "C", "D"]: C.add(C.id(X))
ba = C.compose(b, a) # B 在 A 之后、C 在 B 之后
cba = C.compose(c, ba)
C.add(ba); C.add(cba)
# 验证单位元律与结合律
assert C.compose(b, C.id("B")).path == b.path
assert C.compose(C.id("B"), a).path == a.path
assert C.compose(c, C.compose(b, a)).path == C.compose(C.compose(c, b), a).path
print("category laws OK; cba.path =", cba.path)
运行结果打印 category laws OK; cba.path = ('c', 'b', 'a'),说明复合的"先做 a、再 b、再 c"与分组方式无关——这正是结合律。
二、函子:保持结构的范畴间映射
函子(Functor)是把一个范畴映射到另一个范畴的"结构保持"变换:它把对象映成对象、态射映成态射,并且必须保持单位元与复合。最常见的目标是 Set(集合范畴)——这也是 Yoneda 引理的舞台。下面把我们的范畴映到集合:对象 A→{0}、B→{0,1}、C→{0,1,2}、D→{0,1,2,3},生成态射映成"保序单射"。
# 函子 F: C -> Set:对象映成集合、态射映成函数,保持结构与单位元
def F_obj(X):
return {"A": {0}, "B": {0, 1}, "C": {0, 1, 2}, "D": {0, 1, 2, 3}}[X]
BASE = {
"a": {0: 0},
"b": {0: 0, 1: 1},
"c": {0: 0, 1: 1, 2: 2},
}
def comp_func(g, f):
return {x: g[f[x]] for x in f}
def F_mor(m):
if not m.path: # 单位态射
return {x: x for x in F_obj(m.src)}
f = BASE[m.path[-1]] # 最内层:先作用
for nxt in reversed(m.path[:-1]):
f = comp_func(BASE[nxt], f) # 逐层向外复合(F(c∘b∘a)=F(c)∘F(b)∘F(a))
return f
# 验证函子律:(1) F(id_X) == id_{F(X)} (2) F(g∘f) == F(g)∘F(f)
for X in ["A", "B", "C", "D"]:
fid = F_mor(C.id(X))
assert set(fid.keys()) == F_obj(X) and all(fid[x] == x for x in fid)
assert comp_func(F_mor(b), F_mor(a)) == F_mor(ba)
assert comp_func(F_mor(c), F_mor(ba)) == F_mor(cba)
print("functor laws OK")
两条断言通过,说明 F 确实是一个函子:它把复合态射精确对应到复合函数。注意"对象和态射都按结构对应"正是函子与任意映射的本质区别。
三、自然变换:函子之间的态射
如果函子是"范畴之间的箭头",那自然变换就是"函子之间的箭头"——它给每个对象配一个-component 映射,并要求对所有态射 m: X→Y 满足自然性方块:G(m)∘α_X = α_Y∘F(m)。下面的例子构造 F(上节的集合函子)与它的 "Maybe" 版本 G(每个集合多一个 None 元素),并证明"包含映射"α_X(x)=x 是一个自然变换。
# 两个 Set 值函子 F 与 G,及其间自然变换 α: F -> G
def G_obj(X): return F_obj(X) | {None}
def G_mor(m):
f = F_mor(m)
return {**f, None: None} # 把 None 映射到 None
def alpha(X): return {x: x for x in F_obj(X)} # 包含:F(X) -> G(X)
all_m = [a, b, c, ba, cba] + [C.id(X) for X in ["A", "B", "C", "D"]]
ok = True
for m in all_m:
lhs = comp_func(G_mor(m), alpha(m.src)) # G(m) ∘ α_src
rhs = comp_func(alpha(m.dst), F_mor(m)) # α_dst ∘ F(m)
if lhs != rhs:
ok = False; print("naturality FAIL at", m.name)
print("natural transformation OK:", ok)
自然性方块对所有态射都成立,意味着 α 不是"逐对象随意选"的,而是被范畴结构统一决定的——这正是"自然"二字的含义。
四、单子:用单位元与 bind 编排计算
单子(Monad)是自函子 T 配上两个运算:unit(把值注入单子上下文)与 bind(把单子值喂给一个产生单子值的函数)。它必须满足三条定律——左单位元、右单位元、结合律。单子在函数式编程里用来顺序编排带"副作用"的计算(Maybe 表示可能失败、List 表示非确定性)。我们用 Maybe 与 List 两个经典单子来验证。
# 单子 = (自函子 T, unit, bind) + 三条定律
def maybe_unit(x): return ("Just", x)
def maybe_bind(m, f):
return f(m[1]) if m[0] == "Just" else ("Nothing",)
def list_unit(x): return [x]
def list_bind(xs, f): return [y for x in xs for y in f(x)]
def check_monad_laws(unit, bind, x):
f = lambda v: unit(v * 2)
g = lambda v: unit(v + 10)
# 左单位元:unit(x) >>= f ≡ f(x)
assert bind(unit(x), f) == f(x)
# 右单位元:m >>= unit ≡ m
m = unit(x)
assert bind(m, unit) == m
# 结合律:(m >>= f) >>= g ≡ m >>= (λv. f(v) >>= g)
assert bind(bind(m, f), g) == bind(m, lambda v: bind(f(v), g))
return True
print("Maybe monad laws:", check_monad_laws(maybe_unit, maybe_bind, 21))
print("List monad laws:", check_monad_laws(list_unit, list_bind, 21))
两段都打印 True,说明 Maybe 与 List 都满足单子定律。注意 List 单子的 bind 正是"把每个结果展开后拼接"——这就是非确定性搜索的语义。
五、Kleisli 组合:把单子计算串成管道
单子计算常写成 A→M[B] 形式的"带上下文的函数"。把它们用 Kleisli 组合 g <=< f = λx. f(x) >>= g 串起来,会得到一条干净的计算管道,且满足结合律。这让我们可以用普通函数组合的直觉来拼接一连串可能失败或产生多值的步骤。
# Kleisli 组合:f: A->M[B], g: B->M[C] 组合为 g <=< f : A->M[C]
bind = maybe_bind # 绑定到 Maybe 单子(块四已定义)
def kleisli(f, g):
return lambda x: bind(f(x), g)
f = lambda x: ("Just", x + 1)
g = lambda x: ("Just", x * 3) if x < 100 else ("Nothing",)
h = lambda x: ("Just", x - 5)
# 结合律:(f >=> g) >=> h == f >=> (g >=> h)
left = kleisli(kleisli(f, g), h)
right = kleisli(f, kleisli(g, h))
for v in [0, 7, 50, 200]:
assert left(v) == right(v), f"kleisli assoc failed at {v}"
print("Kleisli associativity OK")
对包括触发 Nothing 的输入(v=200)都成立,说明管道在"失败短路"语义下依然保持代数结构——这正是错误处理被干净编入控制流的原因。
六、Yoneda 引理:对象由它与万物的关系决定
Yoneda 引理是范畴论的中枢定理:对任意函子 F,自然变换集合 Nat(Hom(O,−), F) 与 F(O) 一一对应。取 F = Hom(X,−),就得到 Nat(Hom(O,−), Hom(X,−)) ≅ Hom(X, O)——一个对象 X 由"所有指向它的箭头"完整刻画。下面在一个三对象小范畴里穷举自然变换并核对数量,从而对引理做可计算验证。
import itertools
# 三对象小范畴:对象 O,A,B;态射 id_*, f:O->A, g:O->B, h:A->B, hf=h∘f:O->B
MORPHS = [
("id_O", "O", "O"), ("id_A", "A", "A"), ("id_B", "B", "B"),
("f", "O", "A"), ("g", "O", "B"), ("h", "A", "B"), ("hf", "O", "B"),
]
SRC = {n: s for n, s, _ in MORPHS}
DST = {n: d for n, _, d in MORPHS}
def comp(g, f): # g after f
if DST[f] != SRC[g]: return None
if f.startswith("id_"): return g
if g.startswith("id_"): return f
if f == "f" and g == "h": return "hf"
return None
def hom(O, X): return [n for n, s, d in MORPHS if s == O and d == X]
def F_obj(X): return hom("O", X) # 函子 Hom(O,−)
def F_mor(m, s): return {u: comp(m, u) for u in hom("O", s) if comp(m, u)}
def G_obj(X, Z): return hom(X, Z) # 函子 Hom(X,−)
def G_mor(X, m, s): return {u: comp(m, u) for u in hom(X, s) if comp(m, u)}
def all_functions(dom, cod):
if not dom: return [{}]
if not cod: return []
return [dict(zip(dom, v)) for v in itertools.product(cod, repeat=len(dom))]
def count_nat(X):
comps = {o: all_functions(F_obj(o), G_obj(X, o)) for o in ["O", "A", "B"]}
count = 0
for aO in comps["O"]:
for aA in comps["A"]:
for aB in comps["B"]:
alpha = {"O": aO, "A": aA, "B": aB}
ok = True
for m, s, d in MORPHS:
if s == d and m.startswith("id_"): continue # 单位态射自然性自动成立
Fm = F_mor(m, s); Gm = G_mor(X, m, s)
for u in Fm:
if Gm.get(alpha[s][u]) != alpha[d].get(Fm[u]):
ok = False; break
if not ok: break
if ok: count += 1
return count
for X in ["O", "A", "B"]:
c = count_nat(X)
pred = len(hom(X, "O")) # Yoneda 预言:应等于 |Hom(X, O)|
print(f"X={X}: Nat(Hom(O,-),Hom(X,-)) = {c}, |Hom(X,O)| = {pred}, match={c == pred}")
输出为 X=O: ... = 1, |Hom(O,O)| = 1, match=True 与 X=A/B: ... = 0, |Hom(X,O)| = 0, match=True。这意味着:从小范畴 O 出发只有一条自然变换(恒等),而到 O 没有箭头的对象则对应零个自然变换——代码穷举与 Yoneda 引理的代数预言完全吻合。
七、与 AI / 深度学习的映射
范畴论的抽象不是装饰,它在现代系统里有具体落点:
| 范畴论概念 | 在 AI / 系统中的对应 |
|---|---|
函子 F: C→D |
类型构造器 + fmap;数据增强、特征映射、跨模态编码 |
| 自然变换 | 架构之间的结构保持映射;模型蒸馏、权重插值的可交换性 |
单子 unit/bind |
顺序编排副作用:Agent 的工具调用链、错误恢复、流式生成 |
| Kleisli 组合 | 把"可能失败/多值"的步骤拼成管道(LLM 多步推理、检索-生成链) |
| 积 / 余积 | 乘积类型(结构化状态)与求和类型(多分支策略) |
| 极限 / 余极限 | 通用构造:注意力聚合(极限观)、专家路由的 coproduct |
| Yoneda / 表示引理 | "对象由与其他对象的态射决定" ↔ 词/节点由上下文表征(嵌入的本质) |
| 伴随(Adjunction) | 编码器-解码器对、抽象-具体化;Fong–Spivak "反向传播即函子" |
一个直接的工程收获:把 Agent 的多步工具调用建模为 Kleisli 管道,错误处理与并发展开就被吸收进单子代数,而不是散落在 if/else 里。
八、工程边界与何时该用范畴论
范畴论是强力的结构化思维工具,但也有明确边界:
- 抽象有成本:对一次性脚本引入单子/函子往往是过度设计;它在大而组合的系统中才回本。
- 可观测性优先:范畴结构描述的是"组合方式",不替代正确性测试与性能剖析。
- 不要为范畴而范畴:很多"单子化"能改写为普通生成器或
async/await,可读性更好。 - pedagogy vs 交付:用范畴视角设计模块边界有益,但把范畴术语写进接口名通常会吓退协作者。
一句话:用范畴论来组织"如何组合",而不是来替换"计算本身"。
结语
从范畴的三条公理,到函子保持结构、自然变换统一函子、单子编排计算、Yoneda 把对象还原为关系网络——我们全程用可运行的 Python 验证了每条定律。它给我们的真正礼物不是符号,而是"在组合时保持语义"的纪律:当你把深度学习层、Agent 步骤或数据管道当成可复合的态射来设计时,正确的结构会被代数本身逼出来。

发表评论 取消回复