bobofamilyNIU CHUN
05/36

从引擎到网络:三个类,41 个参数

2026-09-018 分钟—阅读

上一篇写完了 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,长 ninlist[Value] 长 nout(nout=1 时脱成单个 Value)
Layer.parameters()无list[Value],长 nout×(nin+1)
MLP(nin, nouts)nin = 网络输入维度;nouts = list,每层神经元数—
MLP.__call__(x)list,长 ninValue 或 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) 会报错。

上半:层结构与每层的参数算式。下半:数据流,以及 x = layer(x) 那一步。

参数数量是怎么来的

权重个数 = 这个神经元接收多少个输入。 因为每个输入必须配一个权重才能加权:

z = w₁x₁ + w₂x₂ + w₃x₃ + b

偏置永远只有 1 个,它不跟任何输入配对。所以每个神经元 n+1 个参数。

以 MLP(3, [4, 4, 1]) 为例:

层接几个输入每神经元 w 数神经元数本层合计
第 1 层3(原始输入)344×(3+1) = 16
第 2 层4(第 1 层的输出)444×(4+1) = 20
第 3 层4411×(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] 就不同了。

交互动画 · 一次 n(x) 调用 · 12 步 · 可单步新窗口打开 ↗

三行就是前向传播

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 是一排并列的 Neuron
  • MLP 是 Layer 的串联

难的部分(闭包、拓扑排序、+=)上一篇已经写完了,这里只是把它包装成好用的形状。这种「底层难、上层薄」的结构在框架设计里很常见,PyTorch 的 nn.Module 也是。

收获有三点:一行声明任意结构 —— 从手写表达式到 MLP(3, [4,4,1]),换结构只改一个参数;对「层」的祛魅 —— 后面读 nn.Linear、nn.Sequential,结构完全一样,只是把标量换成了矩阵;训练循环的基础设施到位了 —— parameters() 能拿到全部参数的引用,下一步才写得出那个真正让 loss 下降的循环。


网络能算了,但它还不会学 —— 41 个参数现在还是随机的。

造一个小数据集、写损失函数、跑训练循环看着 loss 真的降下去,是下一篇的内容。