为什么语言规律可以被学习:从梵语到 CodeChat 的 8B 权重
缘起:一场微信群聊。有人说"法语无论语法还是读音都是标准化的",接着话头转到梵语—— "公元前 500 年就已经被形式化了,和现代的编程语言一样用递归公式定义"、 "梵语的规则里面甚至有编译和优化步骤"、"几乎就是编译器手册"、"有 4000 条规则"、 "你背诵的经文其实是编码之后的,并不是原文,你要理解原文要解码"、"自带校验码"、 最后有人补一句:"所以学 LISP 的最后都入佛门了。"
这些话每一句都不是玄学。它们恰好命中了"语言规律为什么可以被一个神经网络学到" 这个问题的四个层次:语言学的、几何的、统计的、神经的。
这篇文章用本仓库的代码和真实训练日志把这四层串起来。
目录
- 0. 一个可以量化的问题
- 1. 语言学层:梵语是一门被"编译"过的语言
- 2. 为什么"规则"意味着"可学":重复即梯度
- 3. 统计层:11.37 → 0.61,被穿透的到底是多少比特
- 4. 几何层:规则的几何定义是"不变的方向"
- 5. 神经层:Transformer 就是一台软的重写机
- 6. Sandhi 与 BPE:同一个可逆性难题
- 7. 经文的校验码:冗余编码与本仓库的阶梯奖励
- 8. 为什么"可以被穿透":Gold 定理与概率的逃生舱
- 9. 边界:哪些东西不在权重里
- 10. 一张对照表
0. 一个可以量化的问题
"语言规律可以被学习"这句话听起来像哲学命题。它不是。它是一个有单位的工程量。
本仓库 reports/TRAINING_REPORT_8b_a88_x8.md 里有一行:
8B 预训练 30k 步,loss 11.37 → 0.61
这两个数字就是答案的全部。
- 11.37 nats 是随机初始化的模型面对 Python 源码时的交叉熵。作为参照,
codechat/tokenizer.py:14的VOCAB_SIZE = 50257,均匀乱猜的熵是ln(50257) = 10.82 nats。 11.37 略高于它——因为随机初始化的输出分布不是均匀分布,而是偏了的乱猜,比乱猜还差一点。 - 0.61 nats 是训练结束后的交叉熵。困惑度
e^0.61 = 1.84。
模型在每个 token 位置上,平均只在不到两个候选之间犹豫。
换成比特:11.37 / ln2 = 16.40 bits → 0.61 / ln2 = 0.88 bits。
每个 token 被压掉了 15.5 比特。 这 15.5 比特就是"语言规律"的重量。它不是比喻,它是从
codechat/gpt.py:129-133那个F.cross_entropy里读出来的数。
所以问题应该重新问:是什么让这 15.5 比特存在,又是什么让它可以被 80 亿个浮点数吸收?
梵语给了一个异常干净的答案。
1. 语言学层:梵语是一门被"编译"过的语言
1.1 群里那句"公元前 500 年就被形式化了"是准确的
公元前 5–4 世纪,波你尼(Pāṇini)写了《八章书》(Aṣṭādhyāyī),约 3959 条 sūtra(不同传本 3983 条上下,群里说"4000 条"是对的)。它不是一本描述性语法书,它是一台生成器:
输入: 词根(dhātu) + 语法环境(人称/数/时态/语态/格)
过程: 按规则表逐条改写
输出: 唯一确定的合法词形
这台生成器有下面这些性质,每一条都能在现代编译器里找到对应物:
| 《八章书》的机制 | 梵文术语 | 现代对应 |
|---|---|---|
| 规则中的规则、控制规则如何被使用 | paribhāṣā | 元规则 / 宏 / 解释器指令 |
| 冲突时后出现的规则胜出 | vipratiṣedha | 规则优先级 / 最长匹配 / shadowing |
| 一条 sūtra 的上下文自动被后续 sūtra 继承 | anuvṛtti | 词法作用域 / 环境继承 / let 嵌套 |
| 用「首元素 + 终止标记」压缩表示一整类音 | pratyāhāra(湿婆经) | 区间编码 / 位掩码 / 符号表压缩 |
| 规则可以作用在自身输出上 | — | 递归 / 不动点迭代 |
| 一条规则临时"假装"输入是别的东西 | sthānivadbhāva | 视图 / 代换 / 中间表示(IR) |
pratyāhāra 特别值得展开,因为它是纯粹的压缩表达。湿婆经把梵语所有音素排成 14 组特定顺序,每组末尾加一个哑标记(it)。之后任何一个音类都可以用「起点音 + 某个哑标记」两个字符表示:
音素序列: a i u ṇ | ṛ ḷ k | e o ṅ | ai au c | ...
└─────┘ 哑标记
写 "aC" → 从 a 开始到标记 C 为止的全部音 = 所有元音
写 "haL" → 从 h 开始到标记 L 为止的全部音 = 所有辅音
写 "aL" → 全部音素
这是用两个字符寻址一个集合。4000 条规则之所以能装下整门语言,靠的就是这种寻址方式——和你在编译器里用一个 u64 位掩码表示 "所有二元算符" 是同一个动机:规则表要小,就必须让「集合」本身可被廉价命名。
群里说"几乎就是编译器手册",这个判断在结构上成立。计算机科学界甚至有人正式提议把 BNF(Backus–Naur Form)改称 Pāṇini–Backus Form(Ingerman, 1967),理由就是波你尼的规则形式先于 Backus 两千多年。
1.2 法语是同一件事的弱化版
群里从法语起头是有道理的。法语的「拼写 → 读音」方向接近确定性函数:
-eau → /o/ beau, eau, bureau
-ent → ∅(动词第三人称复数词尾不发音)
-ille → /ij/ fille, famille (例外:ville, mille)
c + e/i/y → /s/, 否则 → /k/
学了规则就能读,不需要音标。英语做不到(ough:through / though / thought / rough / cough / bough,六个词六种读法)。
但注意一个重要的不对称:
拼写 ──确定性──▶ 读音 (单射性好,规则可写完)
读音 ──多对一──▶ 拼写 vers / vert / verre / ver / vair 全读 /vɛʁ/
法语的正字法是一个有损压缩的解码器,不是编码器。 这个不对称待会儿在第 6 节会以 tokenizer 的形式原封不动地重现。
1.3 梵语更彻底:词形是一个张量积
梵语的形态学不是"一堆不规则表",它是几个独立维度的笛卡尔积:
动词形 = 词根 ⊗ 时态语气(lakāra, 10) ⊗ 人称(3) ⊗ 数(3) ⊗ 语态(2)
≈ 180 个形式 / 每个词根
名词形 = 词干 ⊗ 格(8) ⊗ 数(3) ≈ 24 个形式 / 每个词干
(性别 3 影响的是走哪张变格表)
一个词根 √gam(去)可以机械地生成 gacchati / gacchataḥ / gacchanti / agacchat / gamiṣyati / jagāma / ...,每一个都由「词根 + 各维度取值」经规则展开而来。
这是一个低秩结构。 请记住这句话——第 4 节整节都在讲它,因为这正是 embedding 空间能把语法编码进去的原因。
2. 为什么"规则"意味着"可学":重复即梯度
你的 TODO 里写着"大量的重复,经文"。这是整篇文章最关键的一句。
2.1 一条规则被应用一万次,就是同一个梯度被累加一万次
考虑最朴素的情形。假设语言里有一条规则 R,在语料里被触发了 N 次。神经网络看到的是 N 个不同的句子,但这 N 个句子在 R 触发的那个位置上,要求的输出变换是同一个。
样本 1: ... x₁ ──R──▶ y₁ 梯度 g₁
样本 2: ... x₂ ──R──▶ y₂ 梯度 g₂
...
样本 N: ... x_N ─R──▶ y_N 梯度 g_N
如果 R 是一条真规则,g₁...g_N 在参数空间里方向高度一致
→ 累加后模长 ≈ N·|g| (相干叠加)
如果只是噪声/巧合,方向随机
→ 累加后模长 ≈ √N·|g| (随机游走)
信噪比随 √N 增长。 规则越是被反复应用,它在梯度里就越突出;巧合则被平均掉。这就是"语言规律可以被学习"的机械原因——不需要任何神秘主义。
这也解释了为什么本仓库的 PretrainLoader 长这样:
# codechat/dataloader.py:49-52
def next_batch(self):
shard = self._load(self.shards[self.rng.integers(len(self.shards))])
ix = self.rng.integers(0, len(shard) - self.block_size - 1, size=(self.batch_size,))
...
它随机地、有放回地、无穷次地从语料里抽 2048-token 的窗口。同一段 Python 代码在 30000 步训练里会被采到很多次,同一条语法规则(def 后面跟标识符、: 后面缩进、( 必须闭合)会被触发千百万次。
训练的本质就是让规则相干叠加,让噪声随机游走。
2.2 吠陀经文:人类在没有硬盘的时候用同一个原理
古印度传承经文靠的不是抄写,是背诵。而且是多种排列方式的背诵(pāṭha):
原文(saṃhitā): a b c d
逐词(pada): a | b | c | d
连读(krama): ab bc cd
辫式(jaṭā): ab ba ab bc cb bc cd dc cd
密式(ghana): ab ba abc cba abc bc cb bcd dcb bcd ...
ghana-pāṭha 里每一个词平均出现 13 次,而且出现在不同的邻接关系中。
群里那句"你背诵的经文其实是编码之后的,并不是原文,你要理解原文要解码"——字面上正确。背 ghana 的人念出来的不是原文,是原文的一个冗余展开;原文要从这个展开里还原。
而"自带校验码"也字面上正确:如果某处记错了一个词,它会在多个不同的邻接对里同时不一致,错误立刻暴露且位置可定位。这就是纠错码的定义性质。
这套机制的效果是可验证的:吠陀经文口传两千余年,不同地区传本之间的差异小到可以逐音节比对。 在没有写本的条件下,这是人类做过的最成功的一次数据完整性工程。
2.3 两者是同一件事
| 吠陀 pāṭha | 神经网络预训练 | |
|---|---|---|
| 单元 | 词 | token |
| 冗余来源 | 同一个词在多种排列中重复 | 同一个模式在多个上下文中重复 |
| 冗余度 | 每词约 13 次 | 每个高频模式 10⁶–10⁹ 次 |
| 目的 | 抵抗记忆噪声 | 抵抗梯度噪声 |
| 结果 | 无损还原 | loss 11.37 → 0.61 |
古印度人和 SGD 都发现了同一个事实:要让脆弱的存储介质记住有结构的东西,就把结构重复地、以不同的切面反复呈现给它。
3. 统计层:11.37 → 0.61,被穿透的到底是多少比特
3.1 交叉熵就是"你还有多少不懂"
codechat/gpt.py:129-133:
loss = F.cross_entropy(
logits.view(-1, logits.size(-1)).float(),
targets.view(-1),
ignore_index=-100,
)
这一行的输出有一个精确的信息论含义:在模型的信念下,编码真实下一个 token 平均需要多少 nat。
| 状态 | loss (nats) | 困惑度 | 含义 |
|---|---|---|---|
| 均匀乱猜(理论下界的上界) | 10.82 | 50257 | 语言无结构 |
| 随机初始化(实测 step 1) | 11.37 | 86,600 | 比乱猜还差 |
| 8B 预训练结束(step 30k) | 0.61 | 1.84 | 几乎不犹豫 |
| v6 联合 SFT 起点 | 2.51 | 12.3 | 换了数据分布,重新爬 |
| v6 联合 SFT 结束 | 0.2–0.9 | 1.2–2.5 | 对话格式也被吸收 |
3.2 全部字符串的空间 vs 合法字符串的集合
设想 block_size = 2048(codechat/gpt.py:23)。所有可能的 token 序列有:
50257^2048 ≈ 10^9628
而实际存在的、语法正确、语义连贯的 Python 程序有多少?用实测熵估:
e^(0.61 × 2048) ≈ 10^543
比值:
10^543 / 10^9628 = 10^(-9085)
合法序列在全部序列中占的比例是 10 的负九千次方量级。
这就是"语言规律"的几何面貌:语言不是空间里的一片云,它是一根极细的线。
这里必须诚实:0.61 是在
codeparrot/github-code-clean的 Python 子集上测的, 且是训练分布内的数字,不是留出集。它反映的是"这个模型在这个分布上还剩多少不确定", 不是 Python 语言熵的无偏估计。但数量级的结论不受影响。
3.3 为什么"细"等于"可学"
统计学习的核心权衡:
泛化误差 ≲ 训练误差 + O( √( 模型复杂度 / 样本数 ) )
如果目标函数可以躺在一个低维(这里"维"指有效自由度,不是坐标数)的假设类里,那么用有限样本逼近它就是可能的。
梵语把这一点做到了极致:一门语言的全部合法词形,由 4000 条规则生成。规则表的描述长度是 O(4000),而它覆盖的词形数量是天文数字。
这就是"可被穿透"的定义:生成器远小于被生成物。
CodeChat 的 8B 权重是同一件事的连续版本——83 亿个参数(gpt.py:39, depth=40, n_embd=4096)编码了一个能生成近乎无限多合法 Python 程序的分布。压缩比是天文数字,因为规律本身就是压缩。
4. 几何层:规则的几何定义是"不变的方向"
你的 TODO 里写着"几何定义"和"统计学几何规律"。这里给一个可操作的定义。
4.1 定义
一条语法规则,在表示空间里的几何形式,是一个作用在低维子空间上的近似不变变换。
拆开说:
- 表示空间:
codechat/gpt.py:100,self.tok_emb = nn.Embedding(50257, 4096)。 每个 token 是 ℝ⁴⁰⁹⁶ 里的一个点。加上 40 层残差流的演化,每个位置在每一层都有一个 ℝ⁴⁰⁹⁶ 的状态。 - 低维子空间:规则只关心少数几个特征。"第三人称单数"这个属性可能只占据 4096 维里的 一两个方向,其余维度与它无关。
- 近似不变变换:规则 R 对应一个映射 T_R,使得对所有满足触发条件的 x,
T_R(x)都落在正确的输出区域。它对无关维度近似恒等。
4.2 梵语给出了教科书级的例子
回到 1.3 节:梵语动词形 = 词根 ⊗ 时态 ⊗ 人称 ⊗ 数 ⊗ 语态。
如果表示空间学到了这个结构,那么在几何上应该有:
vec(gacchati) − vec(gacchanti) ≈ vec(bhavati) − vec(bhavanti)
「他去」 「他们去」 「他是」 「他们是」
└────────── 同一个"单数→复数"方向向量 ──────────┘
这就是 king − man + woman ≈ queen 的严肃版本。区别在于:
- 英语的类比关系是零散的、有例外的(
go/went,child/children); - 梵语的类比关系是规则生成的、几乎无例外的,因为它本来就是被形式化设计出来的。
于是梵语的形态学在表示空间里应该近似是一个多线性结构——几个近似正交的方向轴,词形是它们的组合。这不是猜测,它是 1.3 节那个张量积在几何上的直接后果。
▲ 数 (单/双/复)
│
│ ● gacchanti
│ ╱
│ ╱
│ ● gacchati
│ ╱
───────────┼──────────────▶ 人称 (1/2/3)
╱│
╱ │
╱ │
▼
时态 (10 个 lakāra)
一个动词形 = 词根锚点 + Σ (各维度的偏移向量)
4.3 为什么英语/Python 也能有这种结构(只是更脏)
Python 的规律性介于梵语和英语之间:
def f(x): 缩进规则近乎严格 → 强几何结构
return x
list.append 方法名与类型的关联 → 中等强度
# TODO: fix 注释内容 → 几乎无结构,纯噪声
loss = 0.61 是这三类混合后的加权平均。语法部分的局部 loss 远低于 0.61,自然语言注释部分远高于——模型对 def 后面必然跟标识符几乎零犹豫,对注释里下一个词写什么则相当不确定。
这就是"统计学几何规律"的真实样子:不是全空间的均匀低维,而是分区域的、软硬不一的流形。
5. 神经层:Transformer 就是一台软的重写机
你的 TODO 里写着"神经化这个角度"。这一节把《八章书》和 gpt.py 逐行对上。
5.1 残差流 = 正在被推导的中间形式
codechat/gpt.py:90-93:
def forward(self, x):
x = x + self.attn(self.ln1(x))
x = x + self.mlp(self.ln2(x))
return x
请把它读成一条重写规则的应用:
x_new = x_old + Δ
其中 Δ = 「读取上下文,判断该应用什么规则,产出增量」
这正是一个 term-rewriting 引擎的一步:读状态 → 匹配规则 → 写回状态。区别只有一个——它是软的、可微的、所有规则同时以不同权重被应用,而不是离散地选一条。
40 层(depth=40)意味着:最多 40 步串行的规则应用。这不是比喻,这是架构的硬约束——任何需要超过 40 步串行推导才能得到的结论,模型必须靠别的方式(比如在输出里写出中间步骤,即 CoT)来绕过。
5.2 注意力 = anuvṛtti(上下文继承)的可微版本
波你尼的 anuvṛtti:一条 sūtra 的限定条件自动被后面若干条 sūtra 继承,直到被覆盖。读《八章书》时你必须往回看,才知道当前这条规则在什么环境下生效。
codechat/gpt.py:67:
y = F.scaled_dot_product_attention(q, k, v, is_causal=True)
is_causal=True:只能往左看。和梵语推导的从左到右、和 anuvṛtti 的"继承前面"完全同构。q·k:当前位置在查询"我该继承哪些上下文"。v:被继承的内容本身。
注意力就是可微的、学出来的 anuvṛtti。 波你尼手工指定了继承链;attention 用 QK 点积在运行时算出继承链。
5.3 MLP = 规则表
codechat/gpt.py:75-79:
self.fc = nn.Linear(cfg.n_embd, 4 * cfg.n_embd, bias=False)
self.proj = nn.Linear(4 * cfg.n_embd, cfg.n_embd, bias=False)
一个广为接受的解读(Geva et al., Transformer Feed-Forward Layers Are Key-Value Memories):fc 的每一行是一个模式(key),proj 的每一列是对应的输出(value)。一次前向就是"匹配所有模式,按匹配度加权取出对应输出"。
这就是一张规则表。 数一下 8B 的规模:
每层 hidden 单元数 = 4 × 4096 = 16384
总层数 = 40
规则槽位总数 = 655,360
对照:
《八章书》 ≈ 4,000 条规则 离散、可读、无例外、人写
CodeChat 8B ≈ 655,360 个槽位 连续、不可读、有例外、SGD 写
差了 160 倍的容量,换来的是:不需要人来发现规则。这是整个深度学习相对于形式语法的唯一但决定性的优势。
5.4 权重共享 = 生成器与识别器是同一张表
codechat/gpt.py:105-106:
# weight tying
self.head.weight = self.tok_emb.weight
同一个 [50257, 4096] 矩阵既用于「token → 向量」(读入),也用于「向量 → token 分布」(写出)。
波你尼的规则表也是双向的:同一套规则既能从词根生成词形,也能用来判定一个词形是否合法。语法书天然是生成器兼识别器。
权重绑定把这件事变成了架构约束:读和写共用同一个符号表。
5.5 一张对照表
| 《八章书》 | CodeChat 代码 | 位置 |
|---|---|---|
| 词根 + 语法环境(输入) | idx token 序列 |
gpt.py:115 |
| 逐条改写、状态演进 | x = x + attn(...); x = x + mlp(...) |
gpt.py:91-92 |
| anuvṛtti 上下文继承 | 因果注意力 | gpt.py:67 |
| 规则表(4000 条 sūtra) | MLP key-value(655k 槽位) | gpt.py:75-79 |
| 规则优先级 / 冲突消解 | softmax 的软竞争 | gpt.py:145 |
| 推导步数上限 | depth = 40 |
gpt.py:39 |
| 生成器 = 识别器 | weight tying | gpt.py:106 |
| 从左到右推导 | is_causal=True |
gpt.py:67 |
| 合法词形(输出) | logits → 采样 |
gpt.py:126, 146 |
群里那句"学 LISP 的最后都入佛门了"——LISP 的同像性(代码即数据)、宏(写规则的规则)、eval 的不动点,确实和 paribhāṣā(元规则)、anuvṛtti(作用域继承)在结构上同构。这不是巧合,而是任何一个想用有限规则覆盖无限表达的系统,都会收敛到同一组设计:递归、元层、作用域、压缩寻址。波你尼在公元前 5 世纪撞上了它,McCarthy 在 1958 年撞上了它,Transformer 在 2017 年用连续参数重新实现了它。
6. Sandhi 与 BPE:同一个可逆性难题
6.1 梵语的 sandhi 是强制的音变
梵语要求相邻词在书写时就发生连音(sandhi),而且必须:
tat + api → tadapi
na + asti → nāsti
rāmaḥ + iti → rāma iti
deva + indra → devendra (a + i → e)
正向(合成)是确定性的:给定两个词,输出唯一。
逆向(切分)是歧义的:看到 devendra,它可能来自 deva + indra,也可能来自 devā + indra,或者本身就是一个词。梵语文本的自动切分(sandhi splitting)至今是计算语言学的一个真实难题,因为搜索空间随句长指数增长。
和 1.2 节法语的「拼写→读音单射,读音→拼写多对一」是完全相同的不对称。
6.2 本仓库的 tokenizer 有一模一样的问题
codechat/tokenizer.py:11-19:
# Reserve some "special" tokens for chat formatting. GPT-2 BPE has 50257 tokens
# (including <|endoftext|>). We repurpose <|endoftext|> as both BOS and EOS,
# and encode chat turn boundaries as plain text markers that get BPE'd.
VOCAB_SIZE = _ENC.n_vocab # 50257
EOT = _ENC.eot_token # 50256
USER_TAG = "<|user|>"
ASSISTANT_TAG = "<|assistant|>"
END_TAG = "<|end|>"
注意注释里那句 "get BPE'd"。<|user|> 不是一个原子 token,它被 BPE 拆成若干碎片(<, |, user, |, > 之类)。这就是 sandhi:边界标记被溶解进了字节流,不再有物理边界。
后果和梵语切分完全一致:
训练侧 encode_chat() 生成 "<|user|>\n...\n<|end|>\n" → 碎片序列 A
推理侧 如果多打一个空格 "<|user|> \n...\n<|end|>\n" → 碎片序列 B ≠ A
模型学到的是 A 的统计规律。喂 B 给它,等于喂了一个它没见过的 sandhi 形式。
这就是 docs/sft_rl_inference_mechanics.md §5.2 强调 "训练侧和推理侧必须逐字节一致" 的根本原因。它不是洁癖,它是因为 BPE 是一个 sandhi,而 sandhi 不可靠地可逆。
6.3 停止条件:同一个坑的另一个入口
codechat/gpt.py:136-148 的 generate 根本不检查停止符:
@torch.no_grad()
def generate(self, idx, max_new_tokens, temperature=0.8, top_k=50):
for _ in range(max_new_tokens):
...
idx = torch.cat([idx, next_id], dim=1)
return idx
它跑满 max_new_tokens 才停。因为 <|end|> 不是一个 token,是一串碎片,没法用 next_id == END_TOKEN 判断。
梵语的解决方案是 avagraha(ऽ)——一个显式标记,用来标注"这里原本有一个被 sandhi 吃掉的元音"。 现代 LLM 的解决方案是真正的 special token(把 <|im_start|> 加进词表,让它成为原子)。本仓库两个都没做,所以只能在调用侧靠字符串匹配兜底。
这是一个两千五百年前就被识别、并被给出正确解法的问题。
7. 经文的校验码:冗余编码与本仓库的阶梯奖励
7.1 二值判据没有梯度——这是本仓库最贵的一课
reports/TRAINING_REPORT_8b_a88_x8.md 记录了一次失败:
rl step 240 | reward 0.000 (max 0.00) | loss 0.0335
rl step 300 | reward 0.000 (max 0.00) | loss 0.0277
rl step 415 | reward 0.000 (max 0.00) | loss 0.0323
415 步,reward 恒为 0。 原因写在 codechat/funcall_reward.py:4-7:
CodeChat's MBPP RL failed because the reward was binary (pass all unit
tests -> 1.0, else 0.0). For an 8B model with weak code SFT this meant
every rollout scored 0, GRPO's advantage collapsed to 0, and training
did nothing for 415 steps.
GRPO 的优势是组内减均值。如果一组 rollout 全是 0,均值也是 0,优势恒等于 0,梯度恒等于 0。日志里那个在 0.03 抖动的 loss 只是 KL 惩罚项的自振荡,不是学习。
语法上的"对/错"二值判断,在模型还不会的时候,是一个零信息的信号。
7.2 解法:把判据变成阶梯
codechat/funcall_reward.py:17-25 的阶梯:
model output → tier
─────────────────────────────────────────────────────
no <functioncall> tag at all → 0.00
tag present, body not valid JSON → 0.15
JSON OK but missing 'name' → 0.30
JSON OK, wrong function name → 0.35
right name, no/empty arguments → 0.55
right name, args parse, partial match → 0.55 + 0.45 * match_frac
right name, all arguments exactly match → 1.00
这是一个部分推导的信用分配:模型的输出被解析器一层层剥开,走到哪一层就给哪一层的分。哪怕只学会了"要输出 <functioncall> 这个标记",也立刻拿到 0.15。
效果(reports/TRAINING_REPORT_8b_v6_unified.md):
v6 joint SFT 出来即 pass@1 = 85.4%, pass@8 = 87.5%
funcall RL reward_mean 稳定 0.88–1.0
对比 v1 的恒 0。同一个模型,同一个算法,只换了奖励的形状。
7.3 这和 ghana-pāṭha 是同一个设计
回到 2.2 节。为什么古印度人不是简单地"多背几遍",而是发明了 krama / jaṭā / ghana 这些排列?
因为单纯重复只能检测"我忘了",检测不出"我记错了",更定位不了错在哪。而多重排列让每个词出现在多个不同的邻接对里:
原文 a b c d,你把 c 记成了 c'
简单重复: a b c' d → 自洽,无法发现
ghana-pāṭha: bc' c'b bc'd ... → 与 cd dc cd 段落冲突
cd → c'd 与其它段落不一致
→ 错误在多处同时暴露 → 可检测 + 可定位
这就是把"对/错"的二值判断,展开成"在哪一层、哪一个位置不一致"的阶梯判断。
| ghana-pāṭha | funcall 阶梯奖励 | |
|---|---|---|
| 朴素方案 | 整段背对 / 背错 | 全部测试通过 / 不通过 |
| 朴素方案的问题 | 无法定位错误 | 全 0,无梯度 |
| 改进 | 多重排列暴露局部不一致 | 分层解析暴露局部正确 |
| 得到的信息 | 错在第几个词 | 对到第几层 |
| 效果 | 两千年无损传承 | reward 0.00 → 0.88 |
codechat/funcall_reward.py:53-100 的 _extract_functioncall_json 就是那个"解码器"——它做括号平衡扫描,从模型输出的字节流里把结构还原出来。
群里说"你背诵的经文其实是编码之后的,你要理解原文要解码"。这行 Python 函数在做的,就是解码。
8. 为什么"可以被穿透":Gold 定理与概率的逃生舱
你的 TODO 里写着"探索,为什么可以被穿透"。这里给出理论上的答案,因为它决定了这件事的边界在哪。
8.1 坏消息:Gold 定理
Gold (1967):任何包含所有有限语言和至少一个无限语言的语言类(superfinite class),都不能仅从正例中在极限内被识别出来。
具体地:只给你合法句子(没人告诉你哪些句子不合法),你无法在有限时间内确定地锁定正则语言、上下文无关语言、乃至任何足够丰富的语言类。
这曾经是"语言不可能被学习,必须先天内置"(Chomsky 的刺激贫乏论证)的形式化支柱。
8.2 好消息:Horning 定理
Horning (1969):如果目标不是"识别集合",而是"逼近分布"——随机上下文无关文法(SCFG)是可以从正例中以概率 1 学习的。
关键差别:
Gold 的设定: 学习一个集合 L ⊆ Σ* (某句子 ∈ L 还是 ∉ L)
Horning 的设定:学习一个分布 P over Σ* (某句子的概率是多少)
在分布设定下,"没见过"本身就是信息。一个句法上可能但从未出现的结构,会被赋予低概率——这等价于一个软的负例。Gold 定理的困难恰恰来自"缺少负例",而概率建模从数据的相对频率里凭空造出了负例。
8.3 这正是 CodeChat 在做的事
codechat/gpt.py:129:
loss = F.cross_entropy(logits, targets, ignore_index=-100)
交叉熵是分布匹配,不是集合判定。模型从未被告知"这段代码语法错误",它只被告知"这段代码出现过"。它学到的是 P(下一个 token | 上文),而不是"合法 / 不合法"。
这就是"为什么可以被穿透"的完整答案:
语言规律可以被学习,不是因为它作为一个集合可学(Gold 说不可以), 而是因为它作为一个分布可学(Horning 说可以), 而分布可学的前提是——规律在数据里被高频、相干、多切面地重复(第 2 节), 且规律本身占据一个远小于全空间的低维结构(第 3、4 节)。
梵语是这个前提的极端情形:规律被人工设计成 4000 条无例外的规则, 并被 ghana-pāṭha 以每词 13 次的冗余度反复呈现。
它是"最容易被穿透的语言",因为它是唯一一门为了被完美传输而设计的自然语言。
8.4 反过来说:什么时候穿不透
Gold 定理没有消失,它只是被绕过了。绕过是有代价的,代价体现在本仓库的失败记录里:
reports/TRAINING_REPORT_8b_a88_x8.md 的 MBPP RL 恒 0——因为"程序通过单元测试"是一个集合判定,不是分布匹配。模型在这个判据上退回到了 Gold 的设定,于是学不动。
docs/ 里 eval_mbpp_pass_at_k.py 的 VERDICT 阈值也是这个道理:
pass@1 < 1% → 别跑 GRPO(分布几乎不覆盖目标集合)
pass@1 ∈ [1,5%) → 用 tiered reward + group≥8 + 过滤
pass@1 ≥ 5% → 标准 GRPO 可以工作
当分布已经覆盖到目标一点点的时候,二值判据才重新变得可用。 在此之前,必须把判据软化成阶梯——也就是第 7 节做的事。
9. 边界:哪些东西不在权重里
这篇文章一路在论证"语言规律可以被学习"。为了不夸大,必须说清楚哪些东西学不进权重。
docs/sft_rl_inference_mechanics.md §1 的核心二分:
知识住在 W 里 协议住在代码里
────────────── ──────────────
语法规律 chat template 的字节序列
词形变化 停止条件的判断
代码模式 工具执行循环
函数调用的时机 采样温度 / top-k
对应到梵语:
- 《八章书》的 4000 条规则 = 权重能学的部分
- 怎么念、什么时候念、念错了怎么办 = 传承体系(gurukula、pāṭha 制度)里的部分,不在规则书里
codechat/gpt.py:136-148 的 generate 不会停,scripts/funcall_cli.py 必须在循环里做字符串匹配——这些是"协议",永远不会因为多训一万步就自动出现。
同理,波你尼的书里没写"背诵时要用 ghana 排列"。那是另一套系统。
规律可学,纪律不可学。 这可能是这篇文章最有实用价值的一句。
10. 一张对照表
| 层次 | 梵语的答案 | CodeChat 的答案 | 代码位置 |
|---|---|---|---|
| 语言学 | 4000 条 sūtra,含元规则、优先级、递归、压缩寻址 | 655k 个 MLP 槽位,含软优先级、40 层递归 | gpt.py:39,75-79 |
| 重复 | ghana-pāṭha,每词 13 次冗余展开 | 随机窗口采样,30k 步 × 8 卡 | dataloader.py:49-52 |
| 统计 | 无例外规则 → 熵极低 | loss 11.37 → 0.61,困惑度 1.84 | gpt.py:129 |
| 几何 | 词形 = 词根 ⊗ 时态 ⊗ 人称 ⊗ 数 ⊗ 语态(低秩) | embedding 里的近似线性属性方向 | gpt.py:100 |
| 神经 | 逐条改写,上下文继承(anuvṛtti) | x = x + attn(x),因果注意力 |
gpt.py:67,91-92 |
| 可逆性 | sandhi 正向确定、逆向歧义;用 avagraha 标注 | BPE 正向确定、逆向歧义;无 special token | tokenizer.py:11-19 |
| 纠错 | 多重排列 → 错误多处暴露 → 可定位 | 阶梯奖励 → 部分正确可评分 → 有梯度 | funcall_reward.py:17-25 |
| 失败模式 | 传承中断则规则失传 | 二值 reward → advantage ≡ 0 → 415 步空转 | TRAINING_REPORT_8b_a88_x8.md:192-197 |
| 理论保证 | 人工设计,无需学习 | Horning:分布可学;Gold:集合不可学 | — |
| 不可学部分 | 念诵纪律、传承制度 | chat template、停止条件、工具循环 | sft_rl_inference_mechanics.md:33 |
结语
群里那句"我一直觉得古代印度文明就像是程序员建立的",说反了一半。
不是印度人像程序员。是任何一个认真面对"用有限规则覆盖无限表达"这个问题的人,最后都会走到同一个地方:递归、元规则、作用域、压缩寻址、冗余校验。
波你尼在公元前 5 世纪走到了,用的是离散符号和人的记忆。
McCarthy 在 1958 年走到了,用的是 S-表达式和 eval。
本仓库的 codechat/gpt.py,148 行,走到了同一个地方,用的是 83 亿个浮点数和交叉熵。
语言规律之所以可以被学习,是因为它一开始就是被"写"出来的——不管写它的是波你尼,还是十亿次相干叠加的梯度。
而那 15.5 个比特,就是这件事的收据。
参考
- Pāṇini, Aṣṭādhyāyī(约公元前 5–4 世纪),约 3959 条 sūtra
- P. Z. Ingerman, "Pāṇini-Backus Form Suggested", CACM 10(3), 1967
- E. M. Gold, "Language Identification in the Limit", Information and Control 10(5), 1967
- J. J. Horning, A Study of Grammatical Inference, PhD thesis, Stanford, 1969
- M. Geva et al., "Transformer Feed-Forward Layers Are Key-Value Memories", EMNLP 2021
- 本仓库:
codechat/gpt.py,codechat/tokenizer.py,codechat/dataloader.py,codechat/funcall_reward.py,reports/TRAINING_REPORT_8b_a88_x8.md,reports/TRAINING_REPORT_8b_v6_unified.md,docs/sft_rl_inference_mechanics.md