从引擎到网络:三个类,41 个参数
上一篇写完了 Value,一个能对任意表达式求导的引擎。但到目前为止,代码里只有表达式,没有网络。
这一篇把它包成 Neuron → Layer → MLP。没有任何新数学,纯粹是封装。
三个类的签名
这是最容易搞混的地方,先把入参出参列清楚:
| 方法 | 入参 | 返回 |
|---|---|---|
Neuron(nin) | nin = 接几个输入 | — |
Neuron.__call__(x) | list,长 nin | 单个 Value |
Neuron.parameters() | 无 | list[Value],长 nin+1 |
Layer(nin, nout) | nin = 每个神经元接几个输入;nout = 本层有几个神经元 | — |
Layer.__call__(x) | list,长 nin | list[Value] 长 nout(nout=1 时脱成单个 Value) |
Layer.parameters() | 无 | list[Value],长 nout×(nin+1) |
MLP(nin, nouts) | nin = 网络输入维度;nouts = list,每层神经元数 | — |
MLP.__call__(x) | list,长 nin | Value 或 list[Value] |
MLP.parameters() | 无 | 全部参数,拍平的一维列表 |
三十行代码,全文说的都是它:
import random
class Neuron: def __init__(self, nin): self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)] self.b = Value(random.uniform(-1, 1))
def __call__(self, x): act = sum((wi*xi for wi, xi in zip(self.w, x)), self.b) return act.tanh()
def parameters(self): return self.w + [self.b]
class Layer: def __init__(self, nin, nout): self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x): outs = [n(x) for n in self.neurons] return outs[0] if len(outs) == 1 else outs
def parameters(self): return [p for neuron in self.neurons for p in neuron.parameters()]
class MLP: def __init__(self, nin, nouts): sz = [nin] + nouts self.layers = [Layer(sz[i], sz[i+1]) for i in range(len(nouts))]
def __call__(self, x): for layer in self.layers: x = layer(x) return x
def parameters(self): return [p for layer in self.layers for p in layer.parameters()]Value 就是上一篇那个类,一行没改。这三个类里一个 _backward 都没有 —— 它们只是在调用
Value 的运算符,梯度机制早就埋在里面了。
Layer(3, 4)的第二个参数是 int,MLP(3, [4])的是 list。MLP(3, 4)会报错。
参数数量是怎么来的
权重个数 = 这个神经元接收多少个输入。 因为每个输入必须配一个权重才能加权:
z = w₁x₁ + w₂x₂ + w₃x₃ + b偏置永远只有 1 个,它不跟任何输入配对。所以每个神经元 n+1 个参数。
以 MLP(3, [4, 4, 1]) 为例:
| 层 | 接几个输入 | 每神经元 w 数 | 神经元数 | 本层合计 |
|---|---|---|---|---|
| 第 1 层 | 3(原始输入) | 3 | 4 | 4×(3+1) = 16 |
| 第 2 层 | 4(第 1 层的输出) | 4 | 4 | 4×(4+1) = 20 |
| 第 3 层 | 4 | 4 | 1 | 1×(4+1) = 5 |
总计 41 个。
看图时数一下射进第 1 层某个结点的线 —— 三条,那就是三个权重,再加自己的偏置,一个神经元四个参数。射进第 2 层的是四条。线的条数就是权重的个数。
真正由人决定的只有一样:
| 谁决定 | |
|---|---|
| 每层神经元个数 | 你写的 nouts,超参数,靠试 |
| 每层输入维度 | 自动 = 前一层神经元数 |
| 每个神经元权重数 | 自动 = 本层输入维度 |
sz 的滑动窗口
sz = [nin] + nouts # [3] + [4,4,1] = [3, 4, 4, 1]self.layers = [Layer(sz[i], sz[i+1]) for i in range(len(nouts))]sz 是每一层的宽度序列,相邻两个配成一层:
sz = [3, 4, 4, 1] └┬┘ └┬┘ └┬┘ Layer(3,4)(4,4)(4,1)这个滑动窗口式的取法,把「前一层的输出就是后一层的输入」这条约束自动编码进了索引,不会出现维度对不上的情况。
Layer(4, 4)里两个 4 含义不同:左边是第 1 层的神经元数(= 它吐出几个数),右边是第 2 层自己的神经元数。碰巧相等是因为nouts写了两个 4,换成[5, 2, 1]就不同了。
三行就是前向传播
def __call__(self, x): for layer in self.layers: x = layer(x) return x关键在 x = layer(x) 这个自我覆盖 —— Python 先算右边,再赋给左边:
| 轮次 | 进去的 x | 出来的 x |
|---|---|---|
| 1 | [2.0, 3.0, -1.0] 原始输入 | 4 个 Value |
| 2 | 上一轮那 4 个 | 4 个 Value |
| 3 | 上一轮那 4 个 | 1 个 Value |
原始输入只在第一轮出现,之后 x 装的都是中间层的激活值。
前几篇里那个反复出现的问题 —— 「上一层的 a 怎么变成下一层的 x」—— 答案就是这一行赋值。同一批 Value 对象,在上一层叫输出,在下一层就是输入。没有转换、没有复制,只是换了个角色。
这不是递归:循环次数在开始前就定了(len(self.layers))。真正的递归在 backward() 里的 build,那里图的深度不确定、结构是树状的。
parameters() 的三级汇总
Neuron ──拼接──→ Layer ──拍平──→ MLP ──再拍平──→ 41 个嵌套推导式的读法
[ p for layer in self.layers for p in layer.parameters() ] ↑ └─── 外层 ───┘ └──── 内层 ────┘ 产出两个 for 的顺序和普通循环一致(外层写在前),只有产出的 p 被提到了最前面。等价于:
result = []for layer in self.layers: for p in layer.parameters(): result.append(p)必须拍平。 写成单层 [layer.parameters() for layer in ...] 会得到列表套列表,后面 p.grad = 0 直接报错。
返回的是引用,不是拷贝
这点是训练循环能工作的前提:
for p in n.parameters(): p.data += -0.05 * p.grad # 真的改了网络的权重验证:n.parameters()[0] is n.layers[0].neurons[0].w[0] → True
拍平后的顺序是权重在前、偏置在后,因为 return self.w + [self.b]:
| 索引 | 是谁 |
|---|---|
| 0–2 | 第 1 层 · 神经元 0 · w0、w1、w2 |
| 3 | 第 1 层 · 神经元 0 · b |
| 4–7 | 第 1 层 · 神经元 1 |
| 16– | 第 2 层开始,每 5 个一组 |
几个 Python 语法点
__call__:括号就是触发符。 写 n 只是引用对象,什么都不发生;写 n([2.0, 3.0]) 才触发 __call__。和 a + b 触发 __add__ 是同一套机制。PyTorch 也重载了它,所以能写 model(x) 而不是 model.forward(x)。
zip:按位置配对。
sum((wi*xi for wi, xi in zip(self.w, x)), self.b)权重和输入是两个平行列表,必须按位置对应。zip 像拉链把它们咬合,长度不等时按短的截断。
这个截断行为是个隐患:输入长度不对时不报错,只是静默丢弃多余部分。 PyTorch 会做形状检查。
sum(..., self.b) 的第二个参数是起始值。 累加过程是 b → b + w₀x₀ → b + w₀x₀ + w₁x₁。拿偏置当起点,顺手就把它加进去了,还少建一个节点。不给起始值的话会从 0 开始,那就需要 __radd__(上一篇补的那个)。
self.w + [self.b] 里的 + 是列表拼接,不是向量加法。 [w₀, w₁] + [b] → [w₀, w₁, b],所以 self.b 要套方括号,写 self.w + self.b 会报 TypeError。同一个 +:list 是拼接、ndarray 是逐元素加、Value 是建计算图节点 —— 取决于左操作数是什么类型。
random.uniform(-1, 1) 为什么要随机。 如果初始化成全 0 或全相同,同一层的神经元会算出完全一样的输出 → 收到完全一样的梯度 → 更新后还是一样。几个神经元退化成一个。 随机初始化是为了打破对称性。
范围选 −1~1,是因为 tanh 在 |z| > 3 之后就饱和了(斜率不到 0.01)。权重太大会让 z 一开始就落在饱和区,训练卡死。
只有参与计算的那部分图会有梯度
对单个神经元的输出调 backward(),只有它那条链上的参数有梯度,同层其他神经元全是 0:
outs = layer(x)outs[0].backward()# layer.neurons[0] 的参数有梯度# layer.neurons[1] 的参数全是 0因为梯度只流经参与了计算的那部分图。同一层的神经元是并列、互不相干的几条链,只有汇总到同一个 loss 之后才会全部被激活。
这正是下一步要做的事:写一个损失函数,把所有输出收拢成一个数,那时 41 个参数才都有梯度。
小结
三个类没有任何新数学,它们只做封装:
Neuron是一组权重加一次激活Layer是一排并列的 NeuronMLP是 Layer 的串联
难的部分(闭包、拓扑排序、+=)上一篇已经写完了,这里只是把它包装成好用的形状。这种「底层难、上层薄」的结构在框架设计里很常见,PyTorch 的 nn.Module 也是。
收获有三点:一行声明任意结构 —— 从手写表达式到 MLP(3, [4,4,1]),换结构只改一个参数;对「层」的祛魅 —— 后面读 nn.Linear、nn.Sequential,结构完全一样,只是把标量换成了矩阵;训练循环的基础设施到位了 —— parameters() 能拿到全部参数的引用,下一步才写得出那个真正让 loss 下降的循环。
网络能算了,但它还不会学 —— 41 个参数现在还是随机的。
造一个小数据集、写损失函数、跑训练循环看着 loss 真的降下去,是下一篇的内容。