笔者最近跟着Andrej Karpathy 零基础学习神经网络的视频课学习神经网络中,顺便把学习笔记发在这里。Andrej Karpathy是神经网络领域的大佬,他对Python的使用了如指掌,但是笔者其实对Python的掌握不是特别的熟练。虽然说之前也写过一些爬虫,但是笔者最近两年都是在做Java开发。因此笔者写的代码可能更通俗易懂一些。也不会像Andrej Karpathy一样使用很多的代码上的简写,可能也更通俗一些吧。另外Andrej Karpathy发在GitHub上的代码没有生成图示的相关代码,笔者在这里补上了。
准备工作
安装Python
安装Graphviz(生成图表)
观看视频课程
正式开始编写代码
编写绘制图表的draw_dot.py
这个脚本提供了draw_dot(root)方法,将接下来我们需要编写的自定义类Value输入之后,它可以生成结构图文件
from graphviz import Digraph
import os
os.environ['PATH'] += os.pathsep + r'C:\Program Files\Graphviz\bin'
def trace(root):
nodes, edges = set(), set()
def build(v):
if v not in nodes:
nodes.add(v)
for child in v._prev:
edges.add((child, v))
build(child)
build(root)
return nodes, edges
def draw_dot(root):
dot = Digraph(format="png", graph_attr={"rankdir":"LR","dpi": "300"})
nodes, edges = trace(root)
for node in nodes:
uid = str(id(node))
dot.node(name = uid, label = "{ %s | data %.4f | grad %.4f }" % (node.label, node.data, node.grad), shape='record')
if node._op:
dot.node(name = uid + node._op, label = node._op)
dot.edge(uid + node._op, uid)
for node1, node2 in edges:
dot.edge(str(id(node1)), str(id(node2)) + node2._op)
dot.render('complex_graph', view=True)
return dot
自定义Value类
我们需要编写一个自定义Value类并且为它提供反向传播的方法。对于不同的运算,需要根据其求导公式实现不同的反向传播方法。这个Value类用于组建神经元,接下来我们还会编写神经元。
import random
class Value:
def __init__(self, data, _children=(), _op="", label=""):
self.data = data
self.grad = 0 # 梯度
self._backward = lambda : None # 反向传播函数,默认为空,运算会进行替换
self._prev = set(_children) # 其孩子节点
self._op = _op # 运算符
self.label = label # 节点名称
# 加法
def __add__(self, other):
if not isinstance(other, Value):
other = Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
self.grad += out.grad
other.grad += 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 __pow__(self, other):
# 只支持int float 幂指数
assert isinstance(other, (int, float))
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 relu(self):
out = Value(0 if self.data < 0 else self.data, (self,),"ReLU")
def _backward():
if out.data > 0:
self.grad = self.grad + out.grad
else:
self.grad = self.grad
out._backward = _backward
return out
# 反向传播入口函数
def backward(self):
"""
执行流程示例:
假设计算图:a → b → c → out
拓扑排序后:topo = [a, b, c, out]
反向传播顺序:reversed(topo) = [out, c, b, a]
"""
# 先拓扑排序
topo = []
visited = set()
def build_top(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_top(child)
topo.append(v)
build_top(self)
# 根据拓扑排序应用链式法则
self.grad = 1 # 根节点设置为1
for v in reversed(topo):
v._backward()
# 取负数运算
def __neg__(self):
return self * -1
# 反向加法 other + self
def __radd__(self, other):
return self + other
# 减法
def __sub__(self, other):
return self + (-other)
# 反向减法 other - self
def __rsub__(self, other):
return other + (-self)
# 反向乘法 other * self
def __rmul__(self, other):
return self * other
# 除法 self / other = self * (other ^ -1 )
def __truediv__(self, other):
return self * other ** -1
# 反向除法 other / self = other * (self ^ -1)
def __rtruediv__(self, other):
return other * self ** -1
# 调试输出
def __repr__(self):
return f"Value{self.data}, grad={self.grad}"
现在我们写一个main.py来验证一下:
from value import Value
from draw_dot import draw_dot
a = Value(2, label="a")
b = Value(3, label="b")
c = a * b + a ** 2
c.label = "c"
# 用反向传播方法计算梯度
c.backward()
draw_dot(c)
运行后结构图如下,符合预期:
现在我们修改一下main.py,我想看看ReLU的作用:
from value import Value
from draw_dot import draw_dot
x = Value(-2.0, label="x")
y = x.relu()
y.backward()
draw_dot(y)
运行一下,可以看到,当X的值是负数的时候,它往后传的data会归零。因此,当它的data为负数时,它的梯度是0,因为它在正向传播中无法对后续的节点产生作用:
我们修改一下main.py,看看x为正数的情况:
from value import Value
from draw_dot import draw_dot
x = Value(2.0, label="x")
y = x.relu()
y.backward()
draw_dot(y)
运行一下:
很明显,当X的值为正数的时候,ReLU不会对其产生任何影响。
自定义基类与神经元
我们先要制定一个基类,然后自定义神经元,让我们开始写nn.py:
"""
Module (基类)
│
├── Neuron(nin) # 单神经元: y = ReLU(w·x + b)
│ └── 参数: nin 个权重 + 1 个偏置
│
├── Layer(nin, nout) # 全连接层: nout 个神经元并行
│ └── 参数: nin×nout 个权重 + nout 个偏置
│
└── MLP(nin, nouts) # 多层感知机: 多个 Layer 堆叠
└── 参数: 所有 Layer 参数之和
"""
import random
from value import Value
# 基类
class Module:
# 返回所有可学习参数
def parameters(self):
return []
# 清除所有参数的梯度,每次训练之前都需要将梯度清零,然后再反向传播,获得梯度,最后再更新参数
def zero_grad(self):
for p in self.parameters():
p.grad = 0
# 神经元
class Neuron(Module):
def __init__(self, nin, nonlin=True):
"""
:param nin: 输入特征的维度。这个地方是理解神经网络的一个非常重要的重点,
其前一层有几个神经元,每一个神经元都要跟后一层的每一个神经元进行运算。
假设第一层有5个神经元,那么它们就会有5个输出,这5个输出要跟第二层的每一个神经元中的参数进行相乘的运算,那么第二层的每一个神经元都需要有5个参数才能实现运算。
第二层的神经元的5个参数与前一层的5个神经元的输出分别相乘,之后,再加上一个偏置值b,就是第二层的这一个神经元的输出
:param nonlin: 是否开启ReLU
"""
# 根据前一层的输出量为当前神经元初始化相同数量的参数
self.w = []
for i in range(nin):
self.w.append(Value(random.uniform(-1, 1),label = f"x{i}"))
# 初始化偏置,默认为0
self.b = Value(0,label="b")
# 是否激活ReLU
self.nonlin = nonlin
# 前向传播计算,也就是计算当前神经元的输出,这个输出当然是一个值
def __call__(self, x):
"""
:param x: 输入向量,也就是其输入的对应的值,其长度应该是nin
:return: 神经元输出,应该是一个值
计算方式:假设其上一层有5个神经元,分别输出了[1,2,3,4,5],那么,在当前神经元应先初始化五个参数[Value(6),Value(7),Value(8),Value(9),Value(10)]和偏置b
结果 = 1 * Value(6) + 2 * Value(7) + 3 * Value(8) + 4 * Value(9) + 5 * Value(10) + b
"""
act = Value(0)
for i in range(len(x)):
act += x[i] * self.w[i]
# 加上偏置值
act += self.b
# 如果启用ReLU,则调用ReLU的方法
if self.nonlin:
act = act.relu()
return act
# 返回该神经元所有可学习参数
def parameters(self):
"""
无非就是返回这个神经元初始化创建的所有的参数,其数量应该跟其输入值是一样的,另外还要加上它的偏置值
"""
return self.w + [self.b]
# 字符串表示,用于调试和打印
def __repr__(self):
return f"{'ReLU' if self.nonlin else 'Linear'}Neuron({len(self.w)})"
现在我们改一下main.py,看看我们写的这个神经元工作正不正常:
from value import Value
from draw_dot import draw_dot
from nn import Neuron
n = Neuron(5)
x = [1,2,3,4,5]
y = n.__call__(x)
y.backward()
draw_dot(y)
由于这个神经元,最终返回的也是一个Value类,因此,我们只需要对这个最终返回的Value类进行一次反向传播,就可以看到所有参数对应的梯度,让我们运行一下,看看效果:
可以看到,这一次由于这个神经元最终输出的是个负数,而又打开了ReLU,这导致这个神经元在当前的输出是0,并且其所有的参数梯度都是0。让我们再运行一次看看其他情况:
可以看到,这一次由于其最终输出是一个正数,所以各个参数的梯度都是正常显示的。
观察仔细的朋友可能看到,在第二列,我们有一个节点,data=0,grad=1。这个实际上是因为我们在写神经元的代码时,在写__call__()方法时,通过act = Value(0)定义了初始的一个节点,但由于之后我们写这个神经元返回该神经元所有可学习参数的代码中,并没有返回这个节点,因此它不会影响到我们之后写神经网络Layer和MLP之后的代码,因为它不在可学习参数之列,所以在未来我们进行反向传播的时候,也不会对这个参数做任何微调。
自定义全连接层
接下来我们来实现全连接层。一个全连接层应该由多个神经元组成,每一个神经元独立地接收所有的输入,并且产生一个输出。这一层有多少个神经元,它就应该产生多少个输出,上一层有多少个神经元,就决定了下一层每一个神经元的输入个数。
# 全连接层,由多个神经元组成,每个神经元独立处理相同的输入,产生一个输出
class Layer(Module):
def __init__(self, nin, nout, **kwargs):
"""
:param nin: 输入的特征维数,也就是有多少个输入值,也就是上一层有多少个神经元
:param nout: 输出的神经元个数,也就是当前这一层有多少个神经元
:param kwargs: 传递给每一个神经元的额外参数,如 nonlin=False
"""
# 创建 nout个神经元
self.neurons = []
for _ in range(nout):
self.neurons.append(Neuron(nin, **kwargs))
# 前向传播方法,由于每一个神经元都是独立处理相同的输入,产生一个输出,因此可以并行计算每个神经元的输出。当然,我说的是实际工业上的使用情况,在这里使用一个for循环是不可能进行并行运算的
def __call__(self, x):
"""
:param x: 输入向量,也就是其输入的对应的值,其长度应该是nin
:return: 如果只有一个神经元,返回单个Value,如果这一层有多个神经元,则返回列表
"""
out = []
for n in self.neurons:
out.append(n(x))
# 如果只有一个神经元,返回单个Value,如果这一层有多个神经元,则返回列表
if len(out) == 1:
return out[0]
else:
return out
# 返回该层所有神经元的所有可学习参数
def parameters(self):
ret = []
for n in self.neurons:
# 这里不能用append,而是应该用extend,这样可以把每一个元素的展开放到列表中
ret.extend(n.parameters())
return ret
# 字符串表示,用于调试和打印
def __repr__(self):
return f"Layer of [{', '.join(str(n) for n in self.neurons)}]"
我们现在为draw_dot.py新增一个方法,允许其输入Value列表,并且将结构打印在同一张图上。因为全连接层,除非是最后一层,否则应该有多个输出,我希望把它放在同一张图上展示:
def draw_dot_list(roots, filename='multi_graph', view=True):
"""传入 Value 列表,将多个计算根节点的图合并绘制为一个图。
:param roots: Value 对象的列表,如 [y1, y2, y3]
:param filename: 输出文件名(不含扩展名)
:param view: 是否自动打开图片
:return: Digraph 对象
"""
# 1. 合并所有根节点的节点和边(取并集)
all_nodes = set()
all_edges = set()
def trace(root):
nodes, edges = set(), set()
def build(v):
if v not in nodes:
nodes.add(v)
for child in v._prev:
edges.add((child, v))
build(child)
build(root)
return nodes, edges
for root in roots:
n, e = trace(root)
all_nodes |= n
all_edges |= e
dot = Digraph(format="png", graph_attr={"rankdir": "LR", "dpi": "300"})
for node in all_nodes:
uid = str(id(node))
dot.node(name=uid, label="{ %s | data %.4f | grad %.4f }" % (node.label, node.data, node.grad), shape='record')
if node._op:
dot.node(name=uid + node._op, label=node._op)
dot.edge(uid + node._op, uid)
for node1, node2 in all_edges:
dot.edge(str(id(node1)), str(id(node2)) + node2._op)
dot.render(filename, view=view)
return dot
现在我们重新写一下main.py,来验证一下我们的全连接层是否正确:
from draw_dot import draw_dot_list
from nn import Layer
n = Layer(5, 2, nonlin=False)
x = [1,2,3,4,5]
y = n.__call__(x) # 返回两个神经元的输出列表
for out in y:
out.backward()
draw_dot_list(y)
由于这一层,我们定义了两个神经元,因此我们拿到的输出后要对这两个神经元执行反向传播的方法,以更新所有的梯度值,现在我们来运行一下:
可以看到,随着我们结构的愈加完善,其图表也变得愈加复杂,但是整体上还是符合我们的预期的,非常不错!
自定义多层感知机MLP
现在我们把各个层串到一起,形成一个多层感知机MLP
# 多层感知机,由多个全连接层堆砌而成
class MLP(Module):
"""
除最后一层外每层都使用 ReLU 激活。
网络结构示例:
MLP(2, [8, 4, 1])
输入层 (2)
↓
Layer 1 (2→8) + ReLU
↓
Layer 2 (8→4) + ReLU
↓
Layer 3 (4→1) + Linear (输出层)
↓
输出 (1)
参数数量计算:
Layer 1: 2×8 + 8 = 24
Layer 2: 8×4 + 4 = 36
Layer 3: 4×1 + 1 = 5
总计: 65 个参数
中间层有多少个神经元是可以调节的
"""
def __init__(self, nin, nout):
"""
:param nin:输入层维度,也就是初始到底有多少个输入值
:param nout:各层的输出维度列表,如[8 , 4 , 1]
"""
self.layers = []
# 构建完整的维度链:[输入, 隐藏1, 隐藏2, ..., 输出]
dims = [nin] + nout
# 创建中间层,除最后一层外每层都使用 ReLU 激活。
for i in range(len(nout)):
if i == len(nout) - 1:
# 除最后一层外每层都使用 ReLU 激活。
layer = Layer(dims[i], dims[i + 1], nonlin=False)
else:
layer = Layer(dims[i], dims[i + 1], nonlin=True)
self.layers.append(layer)
# 前向传播
def __call__(self, x):
# 由于每一层的输出都是下一层的输入,所以我们应该从第一层开始,给第一层传入整个模型的输入值,然后获取第一层的输出,再传递给第二层,以此类推
for layer in self.layers:
x = layer(x)
return x
# 返回所有可学习参数
def parameters(self):
# 也就是按层展开。所有层的参数
ret = []
for layer in self.layers:
# 这里不能用append,而是应该用extend,这样可以把每一个元素的展开放到列表中
ret.extend(layer.parameters())
return ret
# 字符串表示,用于调试和打印
def __repr__(self):
return f"MLP of [{', '.join(str(layer) for layer in self.layers)}]"
现在我们修改一下main.py,来验证一下我们的多层感知机是否正确,对于一个多层感知机,其最后返回的肯定是一个Value类,因此,我们用draw_dot()方法来绘制就行了:
from draw_dot import draw_dot
from nn import MLP
n = MLP(5, [8 , 4 , 1])
x = [1,2,3,4,5]
y = n.__call__(x)
y.backward()
draw_dot(y)
我们这里定义的是一个中间层为[8 , 4 , 1]的多层感知机,运行一下看看效果:
可以看到,由65个参数组成的神经网络已经大得吓人了,并且除了最后一层没有ReLU,其他层都是开启了ReLU的。这一个图不太具有可读性了,但是我们已经初步完成了一个多层感知机,为了便于调试以及观察,我们现在把随机种子设置为42,这样每一次的随机值都是一定的。我现在只想知道当前模型对于这一个输入,其最终的输出是什么:
from nn import MLP
import random
random.seed(42)
n = MLP(5, [8 , 4 , 1])
x = [1,2,3,4,5]
y = n.__call__(x)
y.backward()
print(y)
运行一下,看看结果:
D:\Data\Anacondea\envs\lora\python.exe D:\Data\Python\Class_1\main.py
Value0.5775760457072401, grad=1
无论运行多少次,目前最后的结果都是一定的,对于这个模型,我们进行相同的输入,其结果为0.57左右,接下来,我们需要计算损失值。
这个模型目前的输入与我们预计的输入的区别,就是损失值。换句话说,我们用损失值来衡量当前模型的输出与我们所期望的输出的差额。现在假设我们对于[1,2,3,4,5]这个输入,我们期望其输出1,我们用一个很简单的方法计算损失,即用当前模型得到的结果与我们预期的结果相减之后乘以一个平方,这样能确保损失永远是正数:
我们现在重新写一下main.py,并且根据我们的逻辑来实现一步的微调:
from nn import *
import random
random.seed(42)
n = MLP(5, [8 , 4 , 1])
x = [1,2,3,4,5]
# 正向传播,得到模型当前对于输入向量的输出
y = n(x) # 与 y = n.__call__(x)相同
# 这里定义我们的预期值
y_true = Value(1)
# 计算损失,乘以一个平方,确保损失永远为正数
loss = (y_true - y) ** 2
print(loss)
# 将所有参数的梯度清空
n.zero_grad()
# 计算损失的反向传播
loss.backward()
# 将各个参数的值根据其梯度进行微调
for p in n.parameters():
p.data += -0.01 * p.grad
# 再次正向传播,看看当前更新后模型对于同样的输入,得到了什么输出
y = n(x)
loss = (y_true - y) ** 2
print(loss)
我们运行一下看看:
D:\Data\Anacondea\envs\lora\python.exe D:\Data\Python\Class_1\main.py
Value0.1784419971603317, grad=0
Value0.017774538128664325, grad=0
可以很明显看到,我们的损失是从0.17变成了0.017,现在我们修改代码,让模型循环微调10次:
from nn import *
import random
random.seed(42)
n = MLP(5, [8 , 4 , 1])
x = [1,2,3,4,5]
# 正向传播,得到模型当前对于输入向量的输出
y = n(x) # 与 y = n.__call__(x)相同
# 这里定义我们的预期值
y_true = Value(1)
# 计算损失,乘以一个平方,确保损失永远为正数
loss = (y_true - y) ** 2
print(loss)
for _ in range(10):
# 将所有参数的梯度清空
n.zero_grad()
# 计算损失的反向传播
loss.backward()
# 将各个参数的值根据其梯度进行微调
for p in n.parameters():
p.data += -0.01 * p.grad
# 再次正向传播,看看当前更新后模型对于同样的输入,得到了什么输出
y = n(x)
loss = (y_true - y) ** 2
print(loss)
运行一下:
D:\Data\Anacondea\envs\lora\python.exe D:\Data\Python\Class_1\main.py
Value0.1784419971603317, grad=0
Value0.017774538128664325, grad=0
Value0.0025305873910934284, grad=0
Value0.00031141530032524655, grad=0
Value4.034746486335811e-05, grad=0
Value5.131459834284199e-06, grad=0
Value6.569582290880621e-07, grad=0
Value8.390869140101244e-08, grad=0
Value1.0726138625601893e-08, grad=0
Value1.3707193806979307e-09, grad=0
Value1.751865023724053e-10, grad=0
可以看到,我们的损失值是越来越小的,直到最后可以忽略不计。现在我们再改造一下main.py,提供多对输入和预期输出,所以我们需要修改一下计算损失时的方法,因为我们这里的样本是使用了一个真实的数学函数,所以我们可以为它提供一个验证样本,不断的查看当前模型的输出跟我们的验证样本结果的差值:
from nn import *
import random
random.seed(42)
n = MLP(5, [8 , 4 , 1])
# y = 2*x1 - 1.5*x2 + 0.5*x3 - 3*x4 + 1*x5 + 0.5
def true_func(x):
return 2.0*x[0] - 1.5*x[1] + 0.5*x[2] - 3.0*x[3] + 1.0*x[4] + 0.5
# 训练样本
xs = [
[2.0, 3.0, -1.0, 0.5, 1.0],
[1.0, -2.0, 4.0, 2.0, 0.0],
[-1.0, 2.0, 3.0, 1.0, 1.0],
[0.0, 1.0, -2.0, -1.0, 3.0],
[4.0, -3.0, 2.0, 0.5, 2.0],
[-2.0, -1.0, 5.0, 3.0, 0.5],
[1.5, 0.0, 0.5, 2.0, 1.0],
[0.5, 4.0, -3.0, -0.5, 2.0],
[-0.5, 5.0, 1.0, 1.5, 0.0],
[3.0, -1.0, -2.0, 1.0, 4.0],
[-3.0, 2.0, 5.0, 0.5, -1.0],
[2.5, 1.0, -4.0, -2.0, 3.0],
[-1.5, -3.0, 0.0, 1.5, -2.0],
[0.5, 0.5, 0.5, 0.5, 0.5],
[3.0, 3.0, 3.0, -1.0, -1.0],
[-2.0, 4.0, -1.0, 2.0, 0.0],
[1.0, 1.0, 1.0, 1.0, 1.0],
[5.0, -5.0, 2.0, -2.0, 2.0],
[-4.0, 0.0, -3.0, 0.0, 5.0],
[0.0, -2.0, 1.0, 3.0, -3.0],
]
# 用线性公式计算对应的 y 值
ys = [true_func(x) for x in xs]
# 验证样本
xs_val = [1.0, -2.5, 0.5, 2.0, 1.5]
ys_val = true_func(xs_val)
for _ in range(10000):
# 将所有参数的梯度清空
n.zero_grad()
# 逐个正向传播,拿到当前模型对于各个输入的输出值
y_preds = [n(x) for x in xs]
# 计算损失值
loss = 0
for y, y_pred in zip(ys, y_preds):
loss += (y_pred - y) ** 2
# 除以样本数量,防止损失值随着样本数量的增加而无限增长
loss = loss / len(ys)
# 计算验证样本
y_pred = n(xs_val)
if _ % 200 == 0:
print(f"epoch {_} 当前损失值:{loss.data:.4f},验证样本输出:{y_pred.data:.4f},目标:{ys_val}")
# 计算损失的反向传播
loss.backward()
# 将各个参数的值根据其梯度进行微调
for p in n.parameters():
p.data += -0.0005 * p.grad
我们来运行一下,由于这里笔者设置了训练1000步,所以笔者不会把所有的付出都放在下方,而是会截取开头跟结尾的部分输出:
D:\Data\Anacondea\envs\lora\python.exe D:\Data\Python\Class_1\main.py
epoch 0 当前损失值:92.7359,验证样本输出:-1.0240,目标:2.0
epoch 200 当前损失值:3.9360,验证样本输出:0.9463,目标:2.0
epoch 400 当前损失值:2.1334,验证样本输出:-0.2241,目标:2.0
epoch 600 当前损失值:1.5454,验证样本输出:-0.3436,目标:2.0
...
...
...
epoch 9000 当前损失值:0.0186,验证样本输出:1.7727,目标:2.0
epoch 9200 当前损失值:0.0177,验证样本输出:1.7738,目标:2.0
epoch 9400 当前损失值:0.0169,验证样本输出:1.7748,目标:2.0
epoch 9600 当前损失值:0.0162,验证样本输出:1.7760,目标:2.0
epoch 9800 当前损失值:0.0156,验证样本输出:1.7771,目标:2.0
可以惊喜地看到,损失值正在稳步的下降,并且验证样本的输出也越来越逼近目标值了。那么我们的手搓MLP就完成了。
结语
这也是笔者第一次跟着教程完全自己手搓神经网络,神经网络的学习是一个长期的过程,笔者的学习路径是先看了视频课程,然后手搓代码的过程中写了这一篇文章。如果有读者看到这篇文章有不解的地方,可以通过邮箱联系笔者,笔者很愿意与大家进行交流,共同进步。
我们自己手搓的神经网络,其训练并不能多核并行,效率上是比较慢的,训练1万次就已经要消耗近半分钟了,参数量也比较小。之后应该还是要通过PyTorch来实现模型,但是就笔者而言,手搓的这一套MLP对于笔者理解神经网络的这类结构有着巨大的帮助,受益匪浅。