Neural Networks-part1


神经网络-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 out

1.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 function

数学定义上是这样:

$$ \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**-1

1.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 简易神经网络

给一个所谓的神经网络的图, 它长什么样?

Multi-Layer Perceptron, MLP

就这个例子来看, 它实现起来并不复杂.

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。

文章作者: MUG-chen
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 MUG-chen !
  目录
加载中...