神经网络-Chap.1
本系列神经网络课程根据Youtube上Andrej Karpathy的系列视频: Neural Networks: Zero to Hero整理而来.
应当可以作为对于LLM进行内部原理理解的入门视频之一.
1.1 引子
来看一段代码.
from micrograd.engine import Value
a = Value(-4.0)
b = Value(2.0)
c = a + b
d = a * b + b**3
c += c + 1
c += 1 + c + (-a)
d += d * 2 + (b + a).relu()
d += 3 * d + (b - a).relu()
e = c - d
f = e**2
g = f / 2.0
g += 10.0 / f
# 打印前向传播的最终计算结果 g
print(f"{g.data:.4f}") # 输出: 24.7041 (the outcome of this forward pass)
g.backward()
# 打印 g 对输入变量 a 和 b 的梯度(即敏感度 dg/da 和 dg/db)
print(f"{a.grad:.4f}") # 输出: 138.8338, 即 dg/da 的数值
print(f"{b.grad:.4f}") # 输出: 645.5773, 即 dg/db 的数值以上代码来自于作者的项目: Micrograd(https://github.com/karpathy/micrograd) 的一段示例代码. 它其实涉及到了我们LLM理解过程中的两个点, 前向传播和后向传播.
前向传播很好理解, 从已有变量推理到最终结果.
但后向传播, 也就是说, 该怎么通过已有的信息判别出底层参量对于上层结果的影响?
事实上, 这就是最关键的问题.
其余的所有工作, 我们在做的, 都是效率方面的事.
1.2 导数(Derivative)
来自Wikipedia的定义是这样的:
$$ L = \lim_{h \to 0} \frac{f(a + h) - f(a)}{h} $$
通俗的讲, 就是函数f在a处的斜率. 或者说是变化率, whatever.
(我们这里不会特别纠结某些数学上的严谨定义, 毕竟我们是来了解LLM的, 并不是来上数学课的)
用代码写, 类似这样:
def f(x):
return 3*x**2 + 4*x + 5;
h = 0.001; #consider h is a very small number
x = 3.0;
(f(x+h) - f(x)) / h;我们现在考虑更复杂一点的情况:
a = 2.0;
b = -3.0;
c = 10.0;
d = a * b + c;
print(d); 显然d是由三个变量一并控制的. a, b, c.
我们这时候怎么算d相对于a, b, c的导数?
# inputs
h = 0.001;
a = 2.0;
b = -3.0;
c = 10.0;
d1 = a * b + c;
a += h;
d2 = a * b + c;
print('slope', (d2 - d1)/h); 1.3 实现Micrograd
事实上, 在大模型中, 这个式子显然不可能这么简单. 也不可能给我们这种一点点算的方案. 我们怎么实现这个逆推的功能?
1.3.1 决定我们要使用的数据结构
首先, 我们得定义一个单独的类, 用python自己的float显然不够灵活.
class Value:
def __init__(self, data):
self.data = data
def __repr__(self):
return f"Value(data = {self.data})"
a = Value(2.0)
b = Value(-3.0)
a + b这是不可行的, 对吧, 因为Python显然不知道该怎么把俩类相加.
所以, 我们得进行运算符重载:
class Value:
# ...
def __add__(self, other):
out = Value(self.data + other.data)
return out接下来补足乘法运算符:
class Value:
# ...
def __mul__(self, other):
out = Value(self.data * other.data)
return out为了计算后续的导数, 我们应该尝试将每个变量 是怎么来的 也记下来, 对吧.
所以, 我们再加点代码:
class Value:
def __init__(self, data, _children=(), _op=''):
self.data = data
self._prev = set(_children)
self._op = _op
# ...
def __add__(self, other):
out = Value(self.data + other.data, (self, other), '+')
return out
def __mul__(self, other):
out = Value(self.data * other.data, (self, other), '*')
return out1.3.2 计算导数, 改变输出
现在我们写个稍微复杂一点的式子.
a = Value(2.0)
b = Value(-3.0)
c = Value(10.0)
e = a*b
d = e+c
f = Value(-2.0)
L = d*f我们得到了一个L.
我们现在, 理论上可以算出L针对所有其他值的导数. 也就是说其他值会通过怎样的变化来影响L.
我们已经可以将它看成一个非常简陋的, 只有3个参数的模型了 (笑
它的输入是: a;
它的参数是: b, c, f;
它的中间变量是: e, d;
它的输出是: L;
其实a, b, c, f谁作为输入都可以, 只不过肯定有一个或者几个量是固定的罢了. 这里只是拿a当个例子.
所以, 现在要做的是, 算出输出L, 分别对b, c, f的导数, 合起来, 就叫 输出(L)针对所有参数(b, c, f)的梯度 .
我们得确定这个梯度, 从而得知对应的参量怎么调整, 会让最终输出向哪边偏移.
先手动来一遍的话:
$ \frac{\partial L}{\partial b} = \frac{\partial e}{\partial b} \times \frac{\partial d}{\partial e} \times \frac{\partial L}{\partial d} = a \times 1 \times f = -4.0 $
其他的这里就不算了, 这属于微积分的知识范畴.
另外, 这个求导的公式, 如果忘记了, 见 链式法则(Chain Rule) .
所以我们又得加点代码:
class Value:
def __init__(self, data, _children=(), _op='', grad):
self.data = data
self._prev = set(_children)
self._op = _op
self.grad = 0
# ...假设我们想办法得到了每个变量的grad. 那么将一个输出L增加的方式就很简单:
# just an example
b.data += 0.01 * b.grad
c.data += 0.01 * c.grad
f.data += 0.01 * f.grad由于我们都是顺着梯度走的, L自然会增加.
这给了我们改变L的方式.
1.3.3 限制输出并将其非线性化
这里得引入一个函数: tanh().
它长这个样子:
数学定义上是这样:
$$ \tanh(x) = \frac{\sinh(x)}{\cosh(x)} = \frac{e^x - e^{-x}}{e^x + e^{-x}} = \frac{e^{2x} - 1}{e^{2x} + 1} $$
它会严格将具体的输入限制在[-1, 1]之间, 并且最关键的是, 它不是线性的.
之所以不能直接使用我们的输出. 是因为, 神经网络是多层的, 而线性化会导致多层神经网络堆叠失效.
举例而言:
- 第 1 层神经元算:$ y_1 = w_1 x + b_1 $
- 第 2 层神经元算:$ y_2 = w_2 y_1 + b_2 $
如果我们直接使用输出:
$$ y_2 = w_2 (w_1 x + b_1) + b_2 = (w_2 w_1) x + (w_2 b_1 + b_2) $$
而加上tanh的好处在于不会让多层神经网络退化:
$ y_2 = w_2 \cdot \tanh(w_1 x + b_1) + b_2 $
事实上, 任何非线性函数这里其实都可以用. tanh在早期神经网络中非常常用, 因为它附带了一个输出平整化的作用,求导公式也极其简单.
$ \frac{\partial{y}}{\partial{x}} = 1 - tanh^2(x) $
好, 唠叨这么多, 代码怎么写?
class Value:
# ...
def tanh(self):
x = self.data
t = (math.exp(2*x) - 1)/(math.exp(2*x) + 1)
out = Value(t, (self, ), 'tanh')
return out
# ...以上是实现tanh的功能, 这个简单.
更重要的是, 怎么实现这个自动求导的功能? 显然不能一个个手算.
class Value:
def __init__(self, data, _children = (), _op = ''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _op
# ...
def __add__(self, other):
out = Value(self.data + other.data, (self, other), '+')
def _backward():
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
out._backward = _backward
return out
def __mul__(self, other):
out = Value(self.data * other.data, (self, other), '*')
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
x = self.data
t = (math.exp(2*x) - 1)/(math.exp(2*x) + 1)
out = Value(t, (self, ), 'tanh')
def _backward():
self.grad + = (1 - t**2) * out.grad
out._backward = _backward
return out
# ...
# code to get the result...
# set the result's grad to 1
result.grad = 1.0
# call each var's _backward then...好, 更进一步.
能不能自动回算这一堆导数? 这是个啥?
这其实本质上就是个栈对吧. 事实上, 还得是个 去重栈 . 这玩意一般叫做 拓扑排序(Topological Sort) .
topo = []
visited = set()
def build_topo(v):
if v not in visited: # 如果这个节点还没处理过(防止同一个节点被入栈两次)
visited.add(v) # 标记为已访问
for child in v._prev: # 先递归去找我所有的前置父节点(一路摸到最左边的源头)
build_topo(child)
topo.append(v)
# 从终点 L 开始建栈
build_topo(L)
# 从后往前遍历, 求导
for v in reversed(topo):
v._backward()1.3.4 完善一下
现在这个代码比较完整了, 我们可以完善一下细节. 比如:
a = Value(1)
a + 1 # 目前这还不行, 因为我们没有定义Value类和正常的数字怎么运算.So:
class Value:
# ...
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other) # 给数字加个初始化
out = Value(self.data + other.data, (self, other), '+')
def _backward():
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other) # 给数字加个初始化
out = Value(self.data * other.data, (self, other), '*')
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
x = self.data
t = (math.exp(2*x) - 1)/(math.exp(2*x) + 1)
out = Value(t, (self, ), 'tanh')
def _backward():
self.grad + = (1 - t**2) * out.grad
out._backward = _backward
return out
# ...好, a+2 / a*2 解决了. 现在, 2+a / 2*a 还没解决.
因为Python还不知道一个数字怎么跟一个Value运算.
So:
class Value:
# ...
def __radd__(self, other): # other + self
return self + other
def __rmul__(self, other): # other * self
return self * other接下来是减法和除法.
减法是加一个数的倒数.
除就等于乘一个数的倒数, 对吧. 这意味着我们还得实现一个指数的计算(pow)
So:
class Value:
# ...
def __neg__(self): # -self
return self * -1
def __sub__(self, other): # self - other
return self + (-other)
def __pow__(self, other):
assert isinstance(other, (int, float)), "only supporting int/float powers for now"
out = Value(self.data**other, (self,), f'**{other}')
def _backward():
self.grad += other * self.data ** (other - 1) * out.grad
out._backward = _backward
return out
def __truediv__(self, other): # self / other
return self * other**-11.3.5 Pytorch
上面捣鼓半天, 实际上的目的在于造轮子. 也就是说, 我们通过上面这个过程, 应该知道了正向 / 逆向都在干什么, 并且明白了这个backward的逆向求导链的目的是什么.
现在, 该进入现代一点的方式了, 我们要用一个早已十分成熟的库来进行上述操作.
import torch
x1 = torch.Tensor([2.0]).double() ; x1.requires_grad = True
x2 = torch.Tensor([0.0]).double() ; x2.requires_grad = True
w1 = torch.Tensor([-3.0]).double() ; w1.requires_grad = True
w2 = torch.Tensor([1.0]).double() ; w2.requires_grad = True
b = torch.Tensor([6.8813735870195432]).double() ; b.requires_grad = True
n = x1*w1 + x2*w2 + b
o = torch.tanh(n)
print(o.data.item())
o.backward()
print('---')
print('x2', x2.grad.item())
print('w2', w2.grad.item())
print('x1', x1.grad.item())
print('w1', w1.grad.item())就这么简单, 这个库把所有我们需要的东西都给出来了.
1.4 简易神经网络
给一个所谓的神经网络的图, 它长什么样?
就这个例子来看, 它实现起来并不复杂.
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):
# w * x + b
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
out = act.tanh()
return out
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
class MLP:
def __init__(self, nin, nouts): # 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
# 从上图来看, 我们需要3个输入, 2个输出.
# input
x = [2.0, 3.0, -1.0]
# MLP
n = MLP(3, [4, 4, 2])
# output
n(x)所以我们到底在干啥?
我们可以给神经网络多个输入的案例.
而我们肯定会有一个预期输出.
xs = [
[2.0, 3.0. -1.0],
[3.0, -1.0, 0.5],
[0.5, 1.0, 1.0],
[1.0, 1.0, -1.0]
]
ys = [1.0, -1.0, -1.0, 1.0] # desired targets
ypred = [n(x) for x in xs]我们用每一组输入去算. 得到这一组的输出. 看这个输出与我们预期的输出相比的结果. 并通过上面backward的流程进行调参.
从而让每一个输入都尽可能接近我们预期的输出.
本质上, Pre-training干的就是这件事.
我们衡量输出与预期输出的距离, 这就不得不提到loss
loss = sum((yout - ygt)**2 for ygt, yout in zip(ys, ypred))通常在业界, 这个值还会除以一个输入量N. 这被叫做 MSE (Mean Squared Error, 均方误差)
1.5 总结
- 神经网络是什么:它本质上就是一个由大量基础乘加运算($wx+b$)和非线性弯曲函数(如 $\tanh$)层层嵌套拼出来的巨型数学表达式。
- 怎么调参(训练):先用损失函数(Loss)算出当前预测值与标准答案的差距,再通过反向传播(微积分链式法则)算出每个参数对误差的导数(梯度),最后让所有参数顺着梯度的反方向每次微调一小步(梯度下降),直到误差逼近于 0。

