Sutskever 30 #03:记忆为什么会漏,LSTM 怎么堵的?

上一篇留下的问题 上一篇里,我们用一个 vanilla RNN 在 2490 个字符上跑出了 hello world。但那个模型的记忆只有 25 步——再远的东西它就看不到了。 一个自然的想法是:那把记忆加大呢?64 个隐藏单元不够,换 1000 个,换 10000 个。 没用。白板再大也没用。 乘法效应 vanilla RNN 每一步做的是:把旧状态和新输入混在一起,过一个 tanh。这个操作是乘法性质的——旧状态被一个矩阵乘一次、压一次。连续做 30 步,就是连乘 30 次。 $0.9^{30} \approx 0.04$ 哪怕每次只损失 10%,30 步之后只剩 4%。每一步都在把之前写的东西乘以一个小于 1 的数,越乘越淡。白板开到多大都一样。 训练的时候也一样。梯度要从最后一步往回传,告诉前面的参数"你该记住什么"。每往回传一步,信号就乘一次,30 步之后信号几乎为 0。学习信号传不到那么远,模型就学不到"30 步之前发生了什么"。 这就是梯度消失。连乘带来的衰减,是 vanilla RNN 记不远的根本原因。 LSTM:开一条加法的路 LSTM 的做法是,在乘法链旁边开了一条加法通道——cell state。这条通道专门用来存记忆,更新方式是加法:旧记忆保留一部分,加上新信息的一部分。 光有加法通道还不够。如果每一步都把所有新信息加进去,cell state 很快就会被噪音淹没。所以 LSTM 还配了三个开关,控制这条通道上的读写: 三个开关在每一步都问三个问题: 忘记开关(forget gate):旧记忆里哪些要继续留着,哪些可以扔掉? 写入开关(input gate):新信息要不要写进记忆?写多少? 输出开关(output gate):记忆里的东西,这一步要拿多少出来用? 每个开关输出一个 0 到 1 之间的数。1 就是全开,0 就是全关。 代码里长这样: # 忘记开关 f = sigmoid(np.dot(self.Wf, concat) + self.bf) # 写入开关 i = sigmoid(np.dot(self.Wi, concat) + self.bi) # 新信息候选 c_tilde = np.tanh(np.dot(self.Wc, concat) + self.bc) # 更新记忆:旧的留一部分 + 新的写一部分 c_next = f * c_prev + i * c_tilde # 输出开关 o = sigmoid(np.dot(self.Wo, concat) + self.bo) h_next = o * np.tanh(c_next) 关键是这一行: ...

April 16, 2026

Sutskever 30 #02:猜下一个字符,然后呢?

一个任务 给你一串字符,猜下一个是什么。就这一个任务。 2015 年,Karpathy 拿这个任务训练了一个 RNN。训练完让它自己写,它写出了能编译的 C 代码。没人教它什么是函数、什么是括号——它只是被"猜下一个字符"反复训练,自己学会了这些。他把过程写成了一篇博客,影响力比很多正式 paper 都大。 我们拿最小的版本跑了一遍。 hello world 先看结果。这是我们的模型在第 1800 步生成的文本: rmption is pattornnin eell n levery ywormnearn data hello world helens pis prnters hello world 完整地冒出来了,旁边还是一堆乱七八糟的东西。 这个模型的全部训练数据是 2490 个字符——几句英文短句复制 10 遍。hidden size 64,vanilla RNN,纯 NumPy 手写。它从来不知道 “hello world” 是一个词组。 那它怎么学会的? 模型每一步对 24 个字符各猜一个概率,加起来是 1。训练数据里有正确答案——比如 h 后面确实跟着 e。一开始模型给 e 的概率很低,可能 4%。 猜错了怎么办?算一个数: $$\text{loss} = -\log(p)$$$p$ 是模型给正确答案的概率。$-\log(0.04) \approx 3.2$,挺疼的。$-\log(0.9) \approx 0.1$,几乎没感觉。概率越低,这个数越大,惩罚越重。 为什么用 $\log$ 而不是更直觉的 $1 - p$?因为 $1 - p$ 是线性的:概率从 50% 降到 40%,惩罚增加 0.1;从 5% 降到 4%,惩罚也只增加 0.01。但模型已经很离谱了(5%)还能更离谱(4%),这种恶化应该更疼才对。$-\log(p)$ 做到了这一点——概率越低,曲线越陡,越错越疼。 ...

April 15, 2026