microgpt.py 是 Andrej Karpathy 写的一个教学项目,几百行纯 Python 实现完整的 GPT 训练和推理流程,不依赖 PyTorch,甚至不依赖 Numpy!
受到这个项目的启发,我们在原有代码的基础上进一步扩展,从标量自动求导框架开始,依次实现 MLP、RNN、LSTM 和 Transformer 模型,仍然保持纯 Python 的风格,当然代价就是比基于 PyTorch 等的实现慢很多。由于这里只是作为演示项目,训练得到的模型看起来还是在胡言乱语。
自动求导 由于不依赖 PyTorch 和 Numpy,首先需要一个 Value 类来实现自动求导,为后续的训练和推理提供基础。
训练神经网络的核心是知道损失函数 $L$ 对每个参数 $\theta$ 的梯度 $\partial L/\partial\theta$,再沿梯度反方向更新参数。手工推导每一层的梯度既繁琐又容易出错,自动求导则把前向计算记录成一张计算图 ,然后从损失节点开始反向应用链式法则。
例如 $y=f(g(x))$,链式法则为
$$
\frac{\partial y}{\partial x}
=\frac{\partial y}{\partial g}
\frac{\partial g}{\partial x}.
$$
因此,每个运算只需知道自己的局部导数;反向传播会将上游梯度与局部导数相乘,并把结果传给更早的节点。这里采用标量级自动求导:一个 Value 表示一个数,向量和矩阵则由多个 Value 组成。实现效率不高,但计算图和梯度传播过程非常直观。
源码实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 import mathclass Value : __slots__ = ("data" , "grad" , "_children" , "_local_grads" , "_op" ) def __init__ (self, data, children=( ), local_grads=( ), op=None ): self .data = float (data) self .grad = 0.0 self ._children = children self ._local_grads = local_grads self ._op = op def __add__ (self, other ): other = other if isinstance (other, Value) else Value(other) return Value(self .data + other.data, (self , other), (1 , 1 ), op="add" ) def __mul__ (self, other ): other = other if isinstance (other, Value) else Value(other) return Value( self .data * other.data, (self , other), (other.data, self .data), op="mul" ) def __pow__ (self, other ): if isinstance (other, Value): raise TypeError("Only support scalar exponentiation." ) other = Value(other) return Value( self .data**other.data, (self ,), (other.data * self .data ** (other.data - 1 ),), op="pow" , ) def log (self ): return Value(math.log(self .data), (self ,), (1 / self .data,), op="log" ) def exp (self ): return Value(math.exp(self .data), (self ,), (math.exp(self .data),), op="exp" ) def relu (self ): return Value(max (0 , self .data), (self ,), (float (self .data > 0 ),), op="relu" ) def tanh (self ): out = math.tanh(self .data) return Value(out, (self ,), (1.0 - out * out,), "tanh" ) def sigmoid (self ): if self .data >= 0 : out = 1.0 / (1.0 + math.exp(-self .data)) else : exp_x = math.exp(self .data) out = exp_x / (1.0 + exp_x) return Value(out, (self ,), (out * (1.0 - out),), "sigmoid" ) def __neg__ (self ): return self * (-1 ) def __radd__ (self, other ): return self + other def __sub__ (self, other ): return self + (-other) def __rsub__ (self, other ): return other + (-self ) def __rmul__ (self, other ): return self * other def __truediv__ (self, other ): return self * other ** (-1 ) def __rtruediv__ (self, other ): return other * self ** (-1 ) def backward (self ): topo = [] visited = set () def build_topo (v ): if v not in visited: visited.add(v) for child in v._children: build_topo(child) topo.append(v) build_topo(self ) self .grad = 1 for v in reversed (topo): for child, local_grad in zip (v._children, v._local_grads): child.grad += local_grad * v.grad def print_value_tree (v: Value, indent=0 ): op_display = f"[{v._op} ]" if v._op else "[leaf]" prefix = " " * indent print ( f"{prefix} {op_display} Value(data={v.data:.4 f} , grad={v.grad:.4 f} ) (id={id (v)} )" ) for child in v._children: print_value_tree(child, indent + 1 )
对每个运算的重载做两件事:计算前向值,同时记录局部梯度。比如乘法 z = x * y,dz/dx = y,dz/dy = x,所以 local_grads = (other.data, self.data)。
backward() 是核心方法——先用 DFS 后序遍历把计算图线性化为拓扑序(保证子节点在其所有父节点之后),再逆序逐层将 v.grad * local_grad 累加到子节点。用 += 是因为同一节点可能被多条路径共享(比如同一参数在序列不同位置被使用),需要累加所有贡献。
print_value_tree() 函数是额外添加的,用于可视化检查计算图结构,方便观察运算节点之间的拓扑关系。
例子 构造一个简单表达式来验证:$c=ab+a^2$。当 $a=2,b=3$ 时,自动求导应给出
$$
\frac{\partial c}{\partial a}=b+2a=7,
\qquad
\frac{\partial c}{\partial b}=a=2.
$$
代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 a = Value(2.0 ) b = Value(3.0 ) c = a * b + a**2 print ("before backward" )print_value_tree(c) c.backward() print ("after backward" )print_value_tree(c) print ("a.grad =" , a.grad)print ("b.grad =" , b.grad)
输出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 before backward [add] Value(data=10.0000, grad=0.0000) (id=1656535078592) [mul] Value(data=6.0000, grad=0.0000) (id=1656535078512) [leaf] Value(data=2.0000, grad=0.0000) (id=1656535075152) [leaf] Value(data=3.0000, grad=0.0000) (id=1656535078432) [pow] Value(data=4.0000, grad=0.0000) (id=1656535078672) [leaf] Value(data=2.0000, grad=0.0000) (id=1656535075152) after backward [add] Value(data=10.0000, grad=1.0000) (id=1656535078592) [mul] Value(data=6.0000, grad=1.0000) (id=1656535078512) [leaf] Value(data=2.0000, grad=7.0000) (id=1656535075152) [leaf] Value(data=3.0000, grad=2.0000) (id=1656535078432) [pow] Value(data=4.0000, grad=1.0000) (id=1656535078672) [leaf] Value(data=2.0000, grad=7.0000) (id=1656535075152) a.grad = 7.0 b.grad = 2.0
注意这里的 a 作为叶子节点出现了两次。
下面考虑一个更复杂的情况:用 for 循环逐步构造一个更长的表达式。(这大致对应下文中的 RNN 模型)
用如下关系定义序列 ${x_i}$
$$
x_i = a x_{i-1} + b
$$
那么,在固定 $n$ 时,$x_n$ 的表达式显然只和参数 $a,b$ 以及 $x_0$ 有关,可以对这两个参数求导。例如 $n=3$ 时
$$
x_3 = a(a(a x_0 + b) + b) + b = a^3 x_0 + a^2 b + a b + b
$$
当 $x_0 = 1$,$a=2$,$b=3$ 时,自动求导应给出
$$
\frac{\partial x_3}{\partial a}=3 a^2 x_0 + 2 a b + b = 27,
\qquad
\frac{\partial x_3}{\partial b}=a^2 + b = 7.
$$
代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 x0 = Value(1.0 ) a = Value(2.0 ) b = Value(3.0 ) x = x0 for i in range (3 ): x = a * x + b print ("before backward" )print_value_tree(x) x.backward() print ("after backward" )print_value_tree(x) print ("a.grad =" , a.grad)print ("b.grad =" , b.grad)
输出如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 before backward [add] Value(data=29.0000, grad=0.0000) (id=1656535077312) [mul] Value(data=26.0000, grad=0.0000) (id=1656535077152) [leaf] Value(data=2.0000, grad=0.0000) (id=1656534757792) [add] Value(data=13.0000, grad=0.0000) (id=1656535077072) [mul] Value(data=10.0000, grad=0.0000) (id=1656535076992) [leaf] Value(data=2.0000, grad=0.0000) (id=1656534757792) [add] Value(data=5.0000, grad=0.0000) (id=1656535076912) [mul] Value(data=2.0000, grad=0.0000) (id=1656535076832) [leaf] Value(data=2.0000, grad=0.0000) (id=1656534757792) [leaf] Value(data=1.0000, grad=0.0000) (id=1656534758512) [leaf] Value(data=3.0000, grad=0.0000) (id=1656535076512) [leaf] Value(data=3.0000, grad=0.0000) (id=1656535076512) [leaf] Value(data=3.0000, grad=0.0000) (id=1656535076512) after backward [add] Value(data=29.0000, grad=1.0000) (id=1656535077312) [mul] Value(data=26.0000, grad=1.0000) (id=1656535077152) [leaf] Value(data=2.0000, grad=27.0000) (id=1656534757792) [add] Value(data=13.0000, grad=2.0000) (id=1656535077072) [mul] Value(data=10.0000, grad=2.0000) (id=1656535076992) [leaf] Value(data=2.0000, grad=27.0000) (id=1656534757792) [add] Value(data=5.0000, grad=4.0000) (id=1656535076912) [mul] Value(data=2.0000, grad=4.0000) (id=1656535076832) [leaf] Value(data=2.0000, grad=27.0000) (id=1656534757792) [leaf] Value(data=1.0000, grad=8.0000) (id=1656534758512) [leaf] Value(data=3.0000, grad=7.0000) (id=1656535076512) [leaf] Value(data=3.0000, grad=7.0000) (id=1656535076512) [leaf] Value(data=3.0000, grad=7.0000) (id=1656535076512) a.grad = 27.0 b.grad = 7.0
线性回归 先用最简单的监督学习问题验证自动求导是否真的能够训练参数。监督学习的数据由输入 $x$ 和对应的正确答案 $y$ 组成;模型根据 $x$ 给出预测 $\hat y$,损失函数衡量预测与真实值之间的差距。
线性回归假设输入和输出近似满足线性关系。下面生成的数据为
$$
y = w x + b + \varepsilon
$$
其中 $w=2, b=1$ 都是标量,$\varepsilon$ 为高斯噪声。
模型只包含斜率 $w$ 和截距 $b$ 两个可学习参数,预测值与均方误差分别为
$$
\hat y_i=wx_i+b,
\qquad
L=\frac{1}{N}\sum_{i=1}^{N}(\hat y_i-y_i)^2.
$$
调用 total_loss.backward() 后可以得到 $\partial L/\partial w$ 和 $\partial L/\partial b$。随后使用梯度下降更新参数:
$$
\theta\leftarrow\theta-\eta\frac{\partial L}{\partial\theta},
$$
其中 $\eta$ 是学习率。重复“前向预测—计算损失—反向传播—更新参数”,直线就会逐渐拟合带噪声的数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import matplotlib.pyplot as pltimport randomrandom.seed(42 ) true_w, true_b = 2.0 , 1.0 x_vals = [random.uniform(-3 , 3 ) for _ in range (50 )] y_vals = [(true_w * x + true_b) + random.gauss(0 , 0.5 ) for x in x_vals] w = Value(random.gauss(0 , 0.1 )) b = Value(0.0 ) for epoch in range (200 ): losses = [] for x, y_true in zip (x_vals, y_vals): y_pred = w * x + b loss = (y_pred - y_true) ** 2 losses.append(loss) total_loss = sum (losses) / len (losses) total_loss.backward() w.data -= 0.01 * w.grad b.data -= 0.01 * b.grad w.grad = 0 b.grad = 0 print (f"w={w.data:.4 f} (true={true_w} ), b={b.data:.4 f} (true={true_b} )" )plt.scatter(x_vals, y_vals) x_line = sorted (x_vals) y_line = [w.data * x + b.data for x in x_line] plt.plot(x_line, y_line, color="red" ) plt.show()
输出如下
1 w=1.9577 (true=2.0), b=1.0489 (true=1.0)
绘制结果如下:
代码准备 为了简化后续的代码,我们需要准备一些公共的函数和类,包括一些矩阵相关的运算和 Adam 优化器。
真实深度学习框架通常用张量统一表示标量、向量和高维数组。这里没有 Numpy,因此约定:标量使用 Value,向量使用 list[Value],矩阵使用 list[list[Value]]。后续模型虽然结构不同,但最终都可以拆成线性变换、激活函数、归一化和概率计算等基本操作。
矩阵相关运算 这里统一用嵌套 list 表示二维矩阵,不依赖 Numpy。权重矩阵采用 (输出维度, 输入维度) 的形状,因此 linear(x, weight) 实现的是
$$
y=Wx+b.
$$
softmax() 将任意 logits 转换为总和为 $1$ 的概率分布;rmsnorm() 控制向量的数值尺度;parameters_from() 则把模型字典中的矩阵和向量展平成统一的参数列表,方便优化器更新。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 def gen_random_matrix (nout, nin, std=0.08 ): """生成形状为 (nout, nin) 的参数矩阵,并使用高斯分布进行随机初始化。""" return [[Value(random.gauss(0.0 , std)) for _ in range (nin)] for _ in range (nout)] def linear (x, weight, bias=None ): """ 线性变换,等价于矩阵乘向量再加上向量 y = W x + b。 参数约定: - x 和 b 是长度为 nin 的向量 - w 的形状为 (nout, nin) - 返回长度为 nout 的输出向量 - b 可选,默认为 None """ out = [sum (wi * xi for wi, xi in zip (row, x)) for row in weight] if bias is not None : out = [yi + bi for yi, bi in zip (out, bias)] return out def softmax (logits: list [Value] ) -> list [Value]: """把未归一化打分转换为概率分布,并通过减去最大值提升数值稳定性。""" max_val = max (val.data for val in logits) exps = [(val - max_val).exp() for val in logits] denominator = sum (exps) return [exp_value / denominator for exp_value in exps] def rmsnorm (x: list [Value] ) -> list [Value]: """RMSNorm:用均方根对向量进行缩放归一化,不引入可学习仿射参数。""" ms = sum (xi * xi for xi in x) / len (x) scale = (ms + 1e-5 ) ** (-0.5 ) return [xi * scale for xi in x] def parameters_from (state_dict ): """从 state_dict 构建参数列表。""" params = [] for value in state_dict.values(): if value and isinstance (value[0 ], list ): params.extend(param for row in value for param in row) else : params.extend(value) return params def zero_grad (params ): """把所有参数的梯度置零。""" for param in params: param.grad = 0.0
Adam 优化器 普通梯度下降对所有参数使用同一个学习率。Adam 额外维护梯度的一阶矩和二阶矩指数滑动平均:一阶矩近似梯度的移动平均,二阶矩近似梯度平方的移动平均。两者结合后,每个参数都能根据近期梯度的方向和尺度调整自己的更新幅度。
Adam 优化器的计算流程如下:第 $n$ 步,基于参数 $\theta_n$ 进行更新
计算梯度:$g_n = \nabla L(\theta_n)$
更新一阶矩和二阶矩:$m_n = \beta_1 m_{n-1} + (1-\beta_1) g_n$,$v_n = \beta_2 v_{n-1} + (1-\beta_2) g_n^2$
偏差修正:$\widehat{m}_n = m_n / (1 - \beta_1^n)$,$\widehat{v}_n = v_n / (1 - \beta_2^n)$
更新参数:$\theta_{n+1} = \theta_n - \eta, \widehat{m}_n / (\sqrt{\widehat{v}_n} + \varepsilon)$
代码实现如下,这里在训练初期两个滑动平均都从零开始,step() 在更新参数后同时清空梯度,为下一轮反向传播做准备。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 class AdamOptimizer : def __init__ (self, params, learning_rate=0.01 , beta1=0.9 , beta2=0.999 , eps=1e-8 ): self .params = list (params) self .learning_rate = learning_rate self .beta1 = beta1 self .beta2 = beta2 self .eps = eps self .m = [0.0 ] * len (self .params) self .v = [0.0 ] * len (self .params) self .step_count = 0 def step (self, learning_rate=None ): self .step_count += 1 if learning_rate is None : learning_rate = self .learning_rate for i, param in enumerate (self .params): grad = param.grad if grad is None : continue self .m[i] = self .beta1 * self .m[i] + (1.0 - self .beta1) * grad self .v[i] = self .beta2 * self .v[i] + (1.0 - self .beta2) * grad**2 m_hat = self .m[i] / (1.0 - self .beta1**self .step_count) v_hat = self .v[i] / (1.0 - self .beta2**self .step_count) param.data -= learning_rate * m_hat / (v_hat**0.5 + self .eps) param.grad = 0
使用时需要提供超参数,例如
1 2 3 4 5 6 7 8 9 10 11 12 beta1 = 0.85 beta2 = 0.99 eps_adam = 1e-8 learning_rate = 0.01 optimizer = AdamOptimizer( params, learning_rate=learning_rate, beta1=beta1, beta2=beta2, eps=eps_adam, )
MLP 线性回归只能表示输入的线性组合。MLP(Multi-Layer Perceptron,多层感知机)把多个线性层串联起来,并在线性层之间加入非线性激活函数。若没有激活函数,多层线性变换仍然等价于一个线性变换,增加层数也无法拟合更复杂的关系。
这里选择经典的 Iris 三分类问题。XOR 规模太小,而 MNIST 对不依赖 Numpy 的标量自动求导框架又偏大;Iris 可以在较短时间内展示一个完整的多分类训练流程。
准备数据 UCI Iris 包含 150 株鸢尾花,每一个都有四个连续特征,分别属于三个品种(Iris-setosa,Iris-versicolor,Iris-virginica)。
数据文件的前几行如下
1 2 3 4 5 6 7 8 5.1,3.5,1.4,0.2,Iris-setosa 4.9,3.0,1.4,0.2,Iris-setosa 4.7,3.2,1.3,0.2,Iris-setosa 4.6,3.1,1.5,0.2,Iris-setosa 5.0,3.6,1.4,0.2,Iris-setosa 5.4,3.9,1.7,0.4,Iris-setosa 4.6,3.4,1.4,0.3,Iris-setosa 5.0,3.4,1.5,0.2,Iris-setosa
我们希望得到一个分类器,输入一株花的四个特征,返回模型推测的所属类别。
下面自动从 URL 下载数据并存储在本地(默认是当前文件夹下的 iris.data 文件)。然后读取数据,将其按类别划分训练集和测试集,并且用训练集的统计量对训练集和测试集数据做标准化。
四个特征的数值范围并不完全相同,直接训练会使尺度较大的特征更容易主导梯度。标准化将每个特征变换到均值约为 $0$、标准差约为 $1$ 的尺度。均值和标准差只能由训练集计算,再应用到测试集,否则会提前使用测试数据的信息,造成数据泄漏。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 from pathlib import Pathimport randomimport urllib.requestrandom.seed(42 ) IRIS_URL = "https://archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data" IRIS_LABELS = { "Iris-setosa" : 0 , "Iris-versicolor" : 1 , "Iris-virginica" : 2 , } def load_iris (path="iris.data" ): path = Path(path) if not path.exists(): urllib.request.urlretrieve(IRIS_URL, path) samples = [] for line in path.read_text(encoding="utf-8" ).splitlines(): if not line.strip(): continue *features, label = line.split("," ) samples.append((tuple (float (v) for v in features), IRIS_LABELS[label])) return samples def split_and_standardize (samples, test_per_class=10 ): groups = {class_id: [] for class_id in IRIS_LABELS.values()} for sample in samples: groups[sample[1 ]].append(sample) train_samples = [] test_samples = [] for group in groups.values(): random.shuffle(group) test_samples.extend(group[:test_per_class]) train_samples.extend(group[test_per_class:]) feature_count = len (train_samples[0 ][0 ]) means = [ sum (features[j] for features, _ in train_samples) / len (train_samples) for j in range (feature_count) ] stds = [ ( sum ((features[j] - means[j]) ** 2 for features, _ in train_samples) / len (train_samples) ) ** 0.5 for j in range (feature_count) ] def transform (dataset ): return [ ( tuple ((v - mean) / std for v, mean, std in zip (features, means, stds)), label, ) for features, label in dataset ] return transform(train_samples), transform(test_samples)
可以直接加载并划分数据集
1 train_samples, test_samples = split_and_standardize(load_iris())
这里得到的 train_samples 和 test_samples 都是列表,分别有 120 个样本和 30 个样本。
例如 test_samples 的第一个元素 test_samples[0] 形如
1 2 3 4 5 ((-1.0455003649565782, -0.14652714220214308, -1.22947200877847, -1.304769026004168), 0)
搭建模型 这里采用的 MLP 网络结构是 4 -> 16 -> 16 -> 3。最后一层输出三个 logits,损失为真实类别概率的负对数。
网络的参数是三个不同尺寸的矩阵((16,4),(16,16),(3,16))和对应的 bias 向量。
前向传播可以写成
$$
h_1 = \tanh(W_1x+b_1),\quad
h_2 = \tanh(W_2h_1+b_2),\quad
o = W_3h_2+b_3.
$$
输出 $o$ 的三个分量是 logits,它们是尚未归一化的类别分数。经过 softmax 后得到三个类别的预测概率 $p$。对于真实类别 $y$,交叉熵损失为
$$
L=-\log p_y.
$$
最小化该损失会提高真实类别的预测概率。训练集用于更新参数,测试集只用于评估模型对未参与训练样本的泛化能力。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class MLP : def __init__ (self, nin=4 , nhidden=16 , nout=3 ): self .state = { "w1" : gen_random_matrix(nhidden, nin, 0.5 ), "b1" : [Value(0.0 ) for _ in range (nhidden)], "w2" : gen_random_matrix(nhidden, nhidden, 0.3 ), "b2" : [Value(0.0 ) for _ in range (nhidden)], "w3" : gen_random_matrix(nout, nhidden, 0.3 ), "b3" : [Value(0.0 ) for _ in range (nout)], } self .params = parameters_from(self .state) def __call__ (self, x ): hidden1 = [v.tanh() for v in linear(x, self .state["w1" ], self .state["b1" ])] hidden2 = [ v.tanh() for v in linear(hidden1, self .state["w2" ], self .state["b2" ]) ] return linear(hidden2, self .state["w3" ], self .state["b3" ]) def classification_accuracy (model, samples ): correct = 0 for features, target in samples: logits = model(features) prediction = max (range (len (logits)), key=lambda i: logits[i].data) correct += prediction == target return correct / len (samples)
训练 训练时每一步随机抽取 24 个样本组成 mini-batch。相比每次只用一个样本,mini-batch 的梯度更稳定;相比每次遍历整个训练集,它的计算量又更小。参数更新只使用训练集,训练集和测试集准确率则定期计算,用于观察拟合效果与泛化差距。
从获取数据到训练的代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 random.seed(42 ) train_samples, test_samples = split_and_standardize(load_iris()) model = MLP() optimizer = AdamOptimizer(model.params, learning_rate=0.02 ) num_steps = 300 for step in range (num_steps): batch = random.sample(train_samples, 24 ) losses = [] for features, target in batch: probabilities = softmax(model(features)) losses.append(-probabilities[target].log()) loss = sum (losses) / len (losses) loss.backward() optimizer.step(0.01 * (1.0 - 0.8 * step / num_steps)) if step % 50 == 0 or step == num_steps - 1 : train_acc = classification_accuracy(model, train_samples) test_acc = classification_accuracy(model, test_samples) print ( f"step={step:3d} , loss={loss.data:.4 f} , " f"train_acc={train_acc:.1 %} , test_acc={test_acc:.1 %} " )
输出如下
1 2 3 4 5 6 7 step= 0, loss=1.2212, train_acc=51.7%, test_acc=56.7% step= 50, loss=0.2374, train_acc=96.7%, test_acc=93.3% step=100, loss=0.0414, train_acc=97.5%, test_acc=93.3% step=150, loss=0.0160, train_acc=98.3%, test_acc=100.0% step=200, loss=0.0677, train_acc=97.5%, test_acc=93.3% step=250, loss=0.0782, train_acc=98.3%, test_acc=93.3% step=299, loss=0.0151, train_acc=97.5%, test_acc=93.3%
这里使用 MLP 完成 Iris 分类:模型接收一组花朵特征,并输出它所属的类别。下面几节的任务转为文本生成:模型需要根据已经出现的字符预测下一个字符,再把预测结果接回输入,逐步生成完整序列。
RNN RNN 会在字符之间循环传递隐藏状态,因此可以利用已有文本前缀进行后续预测。
准备数据 训练数据使用 Tiny Shakespeare 。它是一个纯文本文件,将莎士比亚戏剧中的人物名、台词、标点和换行按原顺序拼在一起。文件开头形如:
1 2 3 4 5 First Citizen: Before we proceed any further, hear me speak. All: Speak, speak.
我们希望模型从这些剧本文本中学习英文单词的拼写、标点和换行习惯,以及“人物名后通常是冒号和台词”等结构,最后生成一段形式上类似莎士比亚戏剧的新文本。这里的目标不是背诵原文,也不是理解具体剧情,而是学习原始文本中的字符排列规律。
下面的 load_shakespeare() 会在本地没有数据文件时自动下载。smoke=True 时则改用一小段内置文本,适合快速检查代码能否运行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 from pathlib import Pathimport randomimport urllib.requestSHAKESPEARE_URL = ( "https://raw.githubusercontent.com/karpathy/char-rnn/" "master/data/tinyshakespeare/input.txt" ) RNN_SMOKE_TEXT = "First Citizen:\nTo be, or not to be.\n" * 8 def load_shakespeare (smoke=False , path="tiny_shakespeare.txt" ): if smoke: return RNN_SMOKE_TEXT path = Path(path) if not path.exists(): urllib.request.urlretrieve(SHAKESPEARE_URL, path) return path.read_text(encoding="utf-8" )
搭建模型 这里实现一个字符级 vanilla RNN。它不会预先把文本拆分为人物、单词或句子,而是直接把每一个字符作为 token。因此大写字母 F、空格、冒号 : 和换行符 \n 都是模型需要处理的输入。chars = sorted(set(text)) 收集文本中出现过的所有不同字符,字符在 chars 中的下标就是对应的 token id。
字符级语言模型的任务是:给定前面已经出现的字符,预测紧接着的下一个字符。例如,从片段 First 可以构造出以下监督信号:
时间步
当前输入
预测目标
0
F
i
1
i
r
2
r
s
3
s
t
更一般地,对一段长度为 $T+1$ 的文本
$$
c_0,c_1,\ldots,c_T,
$$
输入是 $c_0,\ldots,c_{T-1}$,对应的监督目标是右移一位后的 $c_1,\ldots,c_T$。
vanilla RNN 用隐藏状态压缩已有前缀。当前字符 $c_t$ 先通过 embedding 表得到向量 $x_t$,再与上一个隐藏状态共同计算新的隐藏状态:
$$
h_t=\tanh(W_{xh}x_t+W_{hh}h_{t-1}+b_h).
$$
输出层把 $h_t$ 投影到词表大小的 logits:
$$
o_t=W_{hy}h_t+b_y,
\qquad
p(c_{t+1}\mid c_{\leq t})=\operatorname{softmax}(o_t).
$$
RNN 的示意图如下:
连续调用模型时,h_t 中的 Value 会把不同时间步连成一张计算图。对整段序列的平均交叉熵调用一次 backward(),即可通过时间反向传播得到各时间步共享参数的梯度。
生成时,模型先根据已有字符计算下一个字符的概率分布,从中采样一个字符,再把它作为下一时间步的输入。不断重复这一过程,就能从一个起始字符开始逐字符续写文本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 class RNN : def __init__ (self, chars, n_embd=8 , n_hidden=16 ): self .chars = chars self .char_to_id = { char: i for i, char in enumerate (chars) } self .n_hidden = n_hidden vocab_size = len (chars) self .state = { "wte" : gen_random_matrix(vocab_size, n_embd), "w_xh" : gen_random_matrix(n_hidden, n_embd), "w_hh" : gen_random_matrix(n_hidden, n_hidden), "b_h" : [Value(0.0 ) for _ in range (n_hidden)], "w_hy" : gen_random_matrix(vocab_size, n_hidden), "b_y" : [Value(0.0 ) for _ in range (vocab_size)], } self .params = parameters_from(self .state) def __call__ (self, token_id, h_prev ): x = self .state["wte" ][token_id] from_x = linear(x, self .state["w_xh" ]) from_h = linear(h_prev, self .state["w_hh" ]) h = [ (a + b + bias).tanh() for a, b, bias in zip (from_x, from_h, self .state["b_h" ]) ] return linear(h, self .state["w_hy" ], self .state["b_y" ]), h def loss_for_chunk (self, chunk ): token_ids = [self .char_to_id[char] for char in chunk] h = [Value(0.0 ) for _ in range (self .n_hidden)] losses = [] for token_id, target_id in zip (token_ids, token_ids[1 :]): logits, h = self (token_id, h) losses.append(-softmax(logits)[target_id].log()) return sum (losses) / len (losses) def sample (self, length=200 , temperature=0.8 ): h = [Value(0.0 ) for _ in range (self .n_hidden)] token_id = self .char_to_id.get("\n" , 0 ) generated = [self .chars[token_id]] for _ in range (length): logits, h = self (token_id, h) probabilities = softmax([logit / temperature for logit in logits]) token_id = random.choices( range (len (self .chars)), weights=[p.data for p in probabilities] )[0 ] generated.append(self .chars[token_id]) return "" .join(generated)
loss_for_chunk() 每次都把隐藏状态初始化为零,因此不同训练片段之间不传递状态;片段内部仍会展开完整计算图。这里的 sequence_length 就是截断 BPTT 的长度:长度越大,模型能学习越远的依赖,但纯 Python 自动求导需要保存的计算节点也越多。
训练 每一步从全文随机截取一段文本,计算该片段内所有“下一个字符”预测的平均损失,再更新参数。反向传播后将每个梯度裁剪到 $[-1,1]$,用于缓解 RNN 在较长序列中常见的梯度爆炸。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 def train_recurrent_model ( model, text, sequence_length=16 , num_steps=400 , learning_rate=0.03 ): sequence_length = min (sequence_length, len (text) - 2 ) optimizer = AdamOptimizer(model.params, learning_rate=learning_rate) for step in range (num_steps): start = random.randrange(0 , len (text) - sequence_length - 1 ) chunk = text[start : start + sequence_length + 1 ] loss = model.loss_for_chunk(chunk) loss.backward() for param in model.params: param.grad = max (-1.0 , min (1.0 , param.grad)) current_lr = learning_rate * (1.0 - 0.9 * step / num_steps) optimizer.step(current_lr) if step % 20 == 0 or step == num_steps - 1 : print (f"step={step:3d} , loss={loss.data:.4 f} " ) random.seed(42 ) text = load_shakespeare(smoke=False ) chars = sorted (set (text)) model = RNN(chars) train_recurrent_model(model, text)
输出如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 step= 0, loss=4.1769 step= 20, loss=3.7602 step= 40, loss=3.3233 step= 60, loss=2.8215 step= 80, loss=2.9692 step=100, loss=3.2808 step=120, loss=2.7491 step=140, loss=3.4614 step=160, loss=2.7389 step=180, loss=2.8508 step=200, loss=3.2143 step=220, loss=2.5482 step=240, loss=3.0552 step=260, loss=2.8894 step=280, loss=3.2605 step=300, loss=2.6412 step=320, loss=2.9834 step=340, loss=2.8843 step=360, loss=3.1227 step=380, loss=3.0897 step=399, loss=2.9447
训练过程中,学习率从 0.03 线性衰减到接近 0.003。如果只是验证完整流程,可以把数据加载改为 load_shakespeare(smoke=True),并适当减少训练步数。
推理采样 采样同样从全零隐藏状态开始,并用换行符作为第一个输入。每一步把 logits 除以温度参数后转成概率,再从分布中随机选取下一个字符,并把更新后的隐藏状态传到下一步。
温度参数会影响输出质量:温度越低,概率分布越集中,生成结果通常更稳定但更重复;温度越高,结果更多样,也更容易出现不连贯字符。
例如
1 print (model.sample(length=100 , temperature=0.5 ))
输出如下
1 2 Ther at! won mare Io fe teo me be for thy ir win hetaly tyt shit yes hos sore tor fut him fo Te tos
由于这是很小的 vanilla RNN,而且训练步数有限,输出主要用于验证模型确实能从数据中学习字符分布,不应期待生成连贯的长文本。
LSTM LSTM(Long Short-Term Memory)是对 vanilla RNN 的改进。下面沿用上一节的数据、字符级预测任务和训练方式,只关注循环单元发生了哪些变化。
除了 LSTM,门控循环单元(Gated Recurrent Unit, GRU)也是一种对 RNN 的改进,结构比 LSTM 更为简洁。但是随着 Transformer 的流行并取得巨大成功,RNN 这一套都有点过时了。
准备数据 直接复用 RNN 一节准备好的 Tiny Shakespeare 文本、chars 字符表以及错开一位的输入和预测目标,不再重复下载和预处理代码。
搭建模型 vanilla RNN 反复用同一个非线性变换更新隐藏状态,序列较长时容易出现梯度消失或梯度爆炸。LSTM 在隐藏状态 $h_t$ 之外增加记忆状态 $c_t$,并通过门控机制决定保留、写入和输出哪些信息,使梯度更容易沿时间方向传播。
在时间步 $t$,LSTM 同时接收当前字符的 embedding $x_t$、上一时刻的隐藏状态 $h_{t-1}$ 和记忆状态 $c_{t-1}$。先将 $x_t$ 与 $h_{t-1}$ 拼接为 $z_t=[x_t;h_{t-1}]$,再计算四组向量:
$$
i_t = \sigma(W_i z_t+b_i),
f_t = \sigma(W_f z_t+b_f),
o_t = \sigma(W_o z_t+b_o),
g_t = \tanh(W_g z_t+b_g).
$$
分别叫做输入门、遗忘门、输出门和候选记忆。
三个 sigmoid 门的值都在 $0$ 到 $1$ 之间,可以看作信息通过的比例。随后更新记忆状态和隐藏状态:
$$
c_t=f_t\odot c_{t-1}+i_t\odot g_t,
\qquad
h_t=o_t\odot\tanh(c_t).
$$
遗忘门 $f_t$ 决定旧记忆保留多少,输入门 $i_t$ 决定候选记忆 $g_t$ 写入多少,输出门 $o_t$ 决定当前记忆状态有多少形成隐藏状态。符号 $\odot$ 表示逐元素乘法。
LSTM 的完整示意图如下:
下面把四组线性变换合并成一次输出维度为 4 * n_hidden 的投影,再切分为四个门。这与分别定义四组矩阵等价,但代码更紧凑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 class LSTM : def __init__ (self, chars, n_embd=8 , n_hidden=16 ): self .chars = chars self .char_to_id = {char: i for i, char in enumerate (chars)} self .n_hidden = n_hidden vocab_size = len (chars) self .state = { "wte" : gen_random_matrix(vocab_size, n_embd), "w_gates" : gen_random_matrix(4 * n_hidden, n_embd + n_hidden), "b_gates" : [Value(0.0 ) for _ in range (4 * n_hidden)], "w_hy" : gen_random_matrix(vocab_size, n_hidden), "b_y" : [Value(0.0 ) for _ in range (vocab_size)], } self .params = parameters_from(self .state) def __call__ (self, token_id, h_prev, c_prev ): x = self .state["wte" ][token_id] combined = x + h_prev gates = linear(combined, self .state["w_gates" ], self .state["b_gates" ]) n = self .n_hidden input_gate = [value.sigmoid() for value in gates[:n]] forget_gate = [value.sigmoid() for value in gates[n : 2 * n]] output_gate = [value.sigmoid() for value in gates[2 * n : 3 * n]] candidate = [value.tanh() for value in gates[3 * n :]] c = [ f * c_old + i * g for f, c_old, i, g in zip (forget_gate, c_prev, input_gate, candidate) ] h = [o * cell.tanh() for o, cell in zip (output_gate, c)] logits = linear(h, self .state["w_hy" ], self .state["b_y" ]) return logits, h, c def loss_for_chunk (self, chunk ): token_ids = [self .char_to_id[char] for char in chunk] h = [Value(0.0 ) for _ in range (self .n_hidden)] c = [Value(0.0 ) for _ in range (self .n_hidden)] losses = [] for token_id, target_id in zip (token_ids, token_ids[1 :]): logits, h, c = self (token_id, h, c) losses.append(-softmax(logits)[target_id].log()) return sum (losses) / len (losses) def sample (self, length=200 , temperature=0.8 ): h = [Value(0.0 ) for _ in range (self .n_hidden)] c = [Value(0.0 ) for _ in range (self .n_hidden)] token_id = self .char_to_id.get("\n" , 0 ) generated = [self .chars[token_id]] for _ in range (length): logits, h, c = self (token_id, h, c) probabilities = softmax([logit / temperature for logit in logits]) token_id = random.choices( range (len (self .chars)), weights=[p.data for p in probabilities] )[0 ] generated.append(self .chars[token_id]) return "" .join(generated)
c 是沿序列传递的长期记忆,h 是当前时间步暴露给输出层的状态。两者都由 Value 组成,因此对序列损失调用 backward() 时,自动求导会沿二者构成的计算图执行 BPTT。
训练 训练流程不变:仍然随机截取文本片段,计算所有时间步的平均交叉熵,通过 BPTT 反向传播,裁剪梯度并使用 Adam 更新参数。直接复用 RNN 一节定义的 train_recurrent_model():
1 2 model = LSTM(chars) train_recurrent_model(model, text)
输出如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 step= 0, loss=4.1745 step= 20, loss=3.7332 step= 40, loss=3.2253 step= 60, loss=3.8335 step= 80, loss=2.9637 step=100, loss=3.6416 step=120, loss=2.5353 step=140, loss=3.1891 step=160, loss=3.1946 step=180, loss=2.7790 step=200, loss=3.0158 step=220, loss=2.7105 step=240, loss=2.5928 step=260, loss=2.5821 step=280, loss=2.2012 step=300, loss=2.6023 step=320, loss=2.6714 step=340, loss=2.6286 step=360, loss=2.1561 step=380, loss=2.6537 step=399, loss=2.0438
虽然 LSTM 缓解了梯度消失,但仍可能出现较大的梯度,因此共用训练函数继续保留梯度裁剪。在相同隐藏维度下,四组门控也使 LSTM 的参数量大于 vanilla RNN。
推理采样 推理时除了隐藏状态 h,还要持续传递记忆状态 c。其余过程与 RNN 相同:根据 logits 得到概率分布,采样下一个字符,再将该字符送回模型。
1 print (model.sample(length=100 , temperature=0.5 ))
输出如下
1 2 3 YRIEIIESEGEENO: TIOENO: Mhhe thit aun, I Yin tit thet tam the on tor the the an hon at me, yan tote
在这个很小的示例中,LSTM 不一定会明显优于 RNN,这里主要是展示门控循环单元如何在同一套标量自动求导框架中实现。
RNN 和 LSTM 依靠循环状态按顺序压缩文本前缀;Transformer 则让当前位置通过因果自注意力直接读取此前各位置的信息。本节实现的是仅含解码器的 GPT 式 Transformer,仍然执行字符级的“预测下一个 token”任务。
这部分主要基于 microgpt.py 进行整理。
准备数据 我们使用 makemore 项目的人名数据集 ,其中每一行是一个名字。
数据文件的前几行如下
1 2 3 4 5 6 7 8 9 10 11 12 13 emma olivia ava isabella sophia charlotte mia amelia harper evelyn abigail emily elizabeth
我们的目标是让 GPT 学习名字中的字符排列规律,并生成“看起来像名字”的新字符串。
下面定义数据集地址。load_names() 会先检查本地是否已有 names.txt,不存在时再从网络下载;随后去掉空行并返回名字列表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 from pathlib import Pathimport randomimport urllib.requestNAMES_URL = "https://raw.githubusercontent.com/karpathy/makemore/988aa59/names.txt" def load_names (path="names.txt" ): path = Path(path) if not path.exists(): urllib.request.urlretrieve(NAMES_URL, path) return [ name for line in path.read_text(encoding="utf-8" ).splitlines() if (name := line.strip()) ]
加载数据后打乱顺序。训练循环会依次取出名字,打乱可以避免始终按照原文件中的固定顺序训练。
1 2 3 4 docs = load_names() random.shuffle(docs) chars = sorted (set ("" .join(docs))) print (f"num docs: {len (docs)} " )
字符表的下标就是普通字符的 token id。除此之外,模型还会增加一个特殊的 BOS(Beginning of Sequence)token。在这个简化模型中,它同时承担序列开始和结束两个角色:训练时首尾各加一个,推理时采样到它就停止,因此不需要额外的 EOS token。
搭建模型 GPT 将词表、模型超参数和全部参数矩阵集中保存。它的单步调用接口与前面的循环模型相似:输入当前 token 和已有状态,返回下一个 token 的 logits。区别在于这里传递的状态不是单个隐藏向量,而是每一层积累的 KV 缓存。
结构整体沿用 GPT 风格的因果语言模型,但做了若干简化:归一化层使用 RMSNorm,全部线性层省略偏置,前馈激活函数使用 ReLU。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 class GPT : def __init__ (self, chars, n_layer=1 , n_embd=16 , block_size=16 , n_head=4 ): assert n_embd % n_head == 0 , "n_embd 必须能被 n_head 整除" self .chars = chars self .char_to_id = {char: i for i, char in enumerate (self .chars)} self .bos = len (self .chars) self .vocab_size = len (self .chars) + 1 self .n_layer = n_layer self .n_embd = n_embd self .block_size = block_size self .n_head = n_head self .head_dim = n_embd // n_head self .state = { "wte" : gen_random_matrix(self .vocab_size, n_embd), "wpe" : gen_random_matrix(block_size, n_embd), "lm_head" : gen_random_matrix(self .vocab_size, n_embd), } for i in range (n_layer): self .state[f"layer{i} .attn_wq" ] = gen_random_matrix(n_embd, n_embd) self .state[f"layer{i} .attn_wk" ] = gen_random_matrix(n_embd, n_embd) self .state[f"layer{i} .attn_wv" ] = gen_random_matrix(n_embd, n_embd) self .state[f"layer{i} .attn_wo" ] = gen_random_matrix(n_embd, n_embd) self .state[f"layer{i} .mlp_fc1" ] = gen_random_matrix(4 * n_embd, n_embd) self .state[f"layer{i} .mlp_fc2" ] = gen_random_matrix(n_embd, 4 * n_embd) self .params = parameters_from(self .state) def __call__ (self, token_id, pos_id, keys, values ): tok_emb = self .state["wte" ][token_id] pos_emb = self .state["wpe" ][pos_id] x = [t + p for t, p in zip (tok_emb, pos_emb)] x = rmsnorm(x) for li in range (self .n_layer): x_residual = x x = rmsnorm(x) q = linear(x, self .state[f"layer{li} .attn_wq" ]) k = linear(x, self .state[f"layer{li} .attn_wk" ]) v = linear(x, self .state[f"layer{li} .attn_wv" ]) keys[li].append(k) values[li].append(v) x_attn = [] for head in range (self .n_head): hs = head * self .head_dim q_h = q[hs : hs + self .head_dim] k_h = [ki[hs : hs + self .head_dim] for ki in keys[li]] v_h = [vi[hs : hs + self .head_dim] for vi in values[li]] attn_logits = [ sum (q_h[j] * k_h[t][j] for j in range (self .head_dim)) / self .head_dim**0.5 for t in range (len (k_h)) ] attn_weights = softmax(attn_logits) head_out = [ sum (attn_weights[t] * v_h[t][j] for t in range (len (v_h))) for j in range (self .head_dim) ] x_attn.extend(head_out) x = linear(x_attn, self .state[f"layer{li} .attn_wo" ]) x = [a + b for a, b in zip (x, x_residual)] x_residual = x x = rmsnorm(x) x = linear(x, self .state[f"layer{li} .mlp_fc1" ]) x = [xi.relu() for xi in x] x = linear(x, self .state[f"layer{li} .mlp_fc2" ]) x = [a + b for a, b in zip (x, x_residual)] return linear(x, self .state["lm_head" ]) def loss_for_doc (self, doc ): tokens = [self .bos] + [self .char_to_id[ch] for ch in doc] + [self .bos] n = min (self .block_size, len (tokens) - 1 ) keys = [[] for _ in range (self .n_layer)] values = [[] for _ in range (self .n_layer)] losses = [] for pos_id in range (n): token_id = tokens[pos_id] target_id = tokens[pos_id + 1 ] logits = self (token_id, pos_id, keys, values) probabilities = softmax(logits) loss_t = -probabilities[target_id].log() losses.append(loss_t) return sum (losses) / n def sample (self, temperature=0.5 ): keys = [[] for _ in range (self .n_layer)] values = [[] for _ in range (self .n_layer)] token_id = self .bos generated = [] for pos_id in range (self .block_size): logits = self (token_id, pos_id, keys, values) probabilities = softmax([logit / temperature for logit in logits]) token_id = random.choices( range (self .vocab_size), weights=[p.data for p in probabilities] )[0 ] if token_id == self .bos: break generated.append(self .chars[token_id]) return "" .join(generated)
与 RNN 和 LSTM 一样,模型参数通过 model.params 暴露,序列损失和采样也分别封装为独立方法。注意这里没有 bias,RMSNorm 也没有可学习的仿射参数。
训练 训练循环现在只负责选取样本、反向传播和更新参数,名字的编码、KV 缓存及逐 token 损失都由 loss_for_doc() 处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 random.seed(42 ) model = GPT(chars) optimizer = AdamOptimizer(model.params, learning_rate=0.01 , beta1=0.85 , beta2=0.99 ) num_steps = 500 for step in range (num_steps): doc = docs[step % len (docs)] loss = model.loss_for_doc(doc) loss.backward() optimizer.step(0.01 * (1.0 - step / num_steps)) print (f"step {step+1 :4d} / {num_steps:4d} | loss {loss.data:.4 f} " , end="\r" )
推理采样 推理从 BOS 开始自回归采样,直到再次采样到 BOS 或达到最大上下文长度。
1 2 for sample_idx in range (20 ): print (f"sample {sample_idx+1 :2d} : {model.sample(temperature=0.5 )} " )
输出如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 sample 1: salee sample 2: kanare sample 3: maran sample 4: jalele sample 5: jalen sample 6: elanan sample 7: dana sample 8: bole sample 9: reria sample 10: kara sample 11: janan sample 12: sada sample 13: tania sample 14: janlin sample 15: lalie sample 16: kekan sample 17: ganan sample 18: jaylal sample 19: miolan sample 20: sedesh