背景
作为一名对深度学习充满热情的初学者,我一直在寻找机会深入理解和实践神经网络模型。最近,我发现了一个非常有趣的项目:这是由Andrej Karpathy实现的,基于1989年Yann LeCun等人的论文"Backpropagation Applied to Handwritten Zip Code Recognition( 反向传播算法在手写邮政编码识别中的应用,即训练神经网络来识别手写的邮政编码的图像)"的神经网络模型。这篇论文是卷积神经网络(Convolutional Neural Networks,简称CNN)的早期工作之一,对于理解CNN的基本概念和工作原理非常有帮助。
然而,作为一个新手,我发现理解和实践这个模型的代码实现并不容易。尽管我已经对神经网络有了基本的理解,但是这个模型涉及到的一些概念和技术,如反向传播(Backpropagation)、卷积层(Convolutional Layer)、全连接层(Fully Connected Layer)等,对我来说仍然有些复杂和深奥。
因此,我决定以学习为目的,逐行分析这个模型的代码实现。我希望通过这种方式,我能够更深入地理解这个模型的工作原理,掌握其实现的技术细节,以及学习如何在实践中应用这些知识。我相信,这将对我今后的学习和研究工作有很大的帮助。
在接下来的文章中,我将分享我在学习这个模型代码实现过程中的一些心得和体会。我会尽可能详细地解释每一行代码的含义和作用,以及它们是如何共同工作,实现这个复杂的神经网络模型的。我希望我的分享能够帮助到和我一样的初学者,一起在深度学习的道路上前进。
(GPT4生成的文案)
实践过程
框架图

(来自Karpathy文章)
图片左侧为架构图,总共有4层。H1,H2为卷积层。H3为隐藏全连接层,output层为输出层。
卷积层:神经网络中的一种层,主要用于处理图像数据,通过滤波器在输入数据上滑动并进行卷积运算,提取出图像的局部特征。卷积层就像一双"有洞察力的眼睛",它可以从图片中找出一些特殊的模式,比如线条、颜色或者形状等。
全连接层:每个神经元都与前一层的所有神经元相连,用于整合前面层提取的所有特征,输出一个固定大小的向量,通常位于网络的最后几层。全连接层就像一个"大脑",它把这些模式整合在一起,帮助我们理解这个图片到底是什么。
输出层:输出最终预测的预测结果。本文中即识别出图像是哪个数字的概率。
流程

框架宏观流程为
-
预处理train、test数据
-
用train数据模型训练,评估输出准确性,优化模型参数继续训练,直到loss达到要求。
-
再将test数据输入模型,评估输出准确性。
Prepro
下载代码仓库
默认本地有python环境和pip命令
Python
git clone https://github.com/karpathy/lecun1989-repro.git
创建虚拟环境和安装依赖
Python
python3 -m venv lecun1989
source lecun1989/bin/activate
pip install torch numpy torchvision matplotlib tensorboardX
执行prepro.py
Python
python3 prepro.py
会生成以下文件
这个文件是MNIST数据集的一部分,它包含了手写数字的图像。这些图像被存储为ubyte格式,这是一种无符号字节格式。由于这种格式不是人类可读的,所以你用文本格式打开看到的内容看起来像乱码。
让GPT帮我们生成一个脚本,看看图片长什么样子。
Python
import numpy as np
import matplotlib.pyplot as plt
import gzip
import struct
def read_idx(filename):
with gzip.open(filename, 'rb') as f:
zero, data_type, dims = struct.unpack('>HBB', f.read(4))
shape = tuple(struct.unpack('>I', f.read(4))[0] for d in range(dims))
return np.frombuffer(f.read(), dtype=np.uint8).reshape(shape)
images = read_idx('./data/MNIST/raw/t10k-images-idx3-ubyte.gz')
plt.imshow(images[0], cmap='gray')
plt.show()

可通过修改images下标,查看不同的数字。plt.imshow(images[0], cmap='gray')
逐行分析prepro.py代码
设置torch和np(numpy)的随机种子为1337。在机器学习中,随机性是一个重要因素。训练过程中需要引入一些随机行为,所以固定随机数,可以让整个过程重现。
Python
torch.manual_seed(1337)
np.random.seed(1337)
对训练集和测试集进行循环处理。
Python
for split in {'train', 'test'}:
下载MNIST数据集,并根据当前的循环变量来选择是加载训练集还是测试集。
Python
data = datasets.MNIST('./data', train=split=='train', download=True)
设置训练集的大小为7291,测试集的大小为2007。一般测试数据集都会小于训练集。
Python
n = 7291 if split == 'train' else 2007
生成一个随机排列,然后取前n个元素,这样每次可以随机选择n个样本
Python
rp = np.random.permutation(len(data))[:n]
创建两个全0和全-1的张量,X和Y用于存储处理后的图像和标签。其中Y是二维的。
Python
X = torch.full((n, 1, 16, 16), 0.0, dtype=torch.float32)
Y = torch.full((n, 10), -1.0, dtype=torch.float32)
对随机选择的n个样本进行循环处理
Python
for i, ix in enumerate(rp):
获取当前样本的图像和标签。
Python
I, yint = data[int(ix)]
将图像转换为NumPy数组,然后转换为PyTorch张量,并将像素值缩放到[-1, 1]范围。除以127.5的原因是进行归一化。在图像处理中,像素值通常在0到255之间。为了使这些值在训练神经网络时更稳定,通常会将它们归一化到-1到1的范围。
Python
xi = torch.from_numpy(np.array(I, dtype=np.float32)) / 127.5 - 1.0
为张量添加一个假的批次维度和通道维度。None可以理解为空值。这两个参数是torch需要的参数
Python
xi = xi[None, None, ...]
使用双线性插值将图像的大小调整为16x16
Python
xi = F.interpolate(xi, (16, 16), mode='bilinear')
将处理后的图像存储到X张量中。X是输入数据,也就是模型需要进行预测的数据。
Python
X[i] = xi[0]
将正确类别的标签设置为1.0。Y是标签,也就是每个输入数据对应的真实值。在训练过程中,模型会尝试学习一个从X到Y的映射,即给定一个输入数据X,模型能够预测出对应的标签Y。
Python
Y[i, yint] = 1.0
将处理后的图像和标签保存为.pt文件
Python
torch.save((X, Y), split + '1989.pt')
打印数据结构
我们如果想看下每个参数的数据结构长什么样子,修改一些参数:
-
只遍历train数据
-
将数据的长度n设置为1
-
将输入X张量维度里长度16改为4
-
为方便区分,将不同位置的xi分别设为xi1,xi2。
Python
for split in {'train'}:
data = datasets.MNIST('./data', train=split=='train', download=True)
n = 1
rp = np.random.permutation(len(data))[:n]
X = torch.full((n, 1, 4, 4), 0.0, dtype=torch.float32)
Y = torch.full((n, 10), -1.0, dtype=torch.float32)
for i, ix in enumerate(rp):
I, yint = data[int(ix)]
print("# I: ", I)
print("# yint: ", yint)
xi1 = torch.from_numpy(np.array(I, dtype=np.float32)) / 127.5 - 1.0
print("# xi1: ", xi)
xi2 = xi1[None, None, ...]
xi2 = F.interpolate(xi2, (4, 4), mode='bilinear')
print("# xi2: ", xi2)
X[i] = xi2[0] # store
print("# X: ", X)
Y[i, yint] = 1.0
print("# Y: ", Y)
打印数据结果
可以看到,I是一个PIL.Image.Image类型的数据结构,size为28*28,和xi1数据维度28*28对应。
xi2是一个4维数组,只有最底层长度为4。
而Y是1*10的数组(1为n)。其中第6项被设置为1。即一个图片对应的数字为6。
Python
# I: <PIL.Image.Image image mode=L size=28x28 at 0x109318CA0>
# yint: 6
xi1为28*28的张量。
# xi1: tensor([[-1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000,
-1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000,
-1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000,
-1.0000, -1.0000, -1.0000, -1.0000],
... xi1 省略一些项
[-1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, 0.8902, 0.9843,
0.9843, 0.9843, 0.9843, 0.9843, 0.9843, 0.9843, 0.9843, 0.9922,
0.9843, 0.9843, 0.9843, 0.9843, 0.9843, 0.9843, 0.9843, 0.0431,
-1.0000, -1.0000, -1.0000, -1.0000],
[-1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, 0.8902, 0.9843,
0.9843, 0.9843, 0.9843, 0.9843, 0.9843, 0.9843, 0.9843, 0.9922,
0.9843, 0.9843, 0.9843, 0.9843, 0.9843, 0.9843, 0.6078, -0.7804,
-1.0000, -1.0000, -1.0000, -1.0000],
... xi1 省略一些项
[-1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000,
-1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000,
-1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000,
-1.0000, -1.0000, -1.0000, -1.0000]])
# xi2: tensor([[[[-1.0000, -1.0000, 0.3333, -1.0000],
[-1.0000, 0.7961, -1.0000, -1.0000],
[-1.0000, 0.9843, 0.9843, -1.0000],
[-1.0000, -1.0000, -1.0000, -1.0000]]]])
# X: tensor([[[[-1.0000, -1.0000, 0.3333, -1.0000],
[-1.0000, 0.7961, -1.0000, -1.0000],
[-1.0000, 0.9843, 0.9843, -1.0000],
[-1.0000, -1.0000, -1.0000, -1.0000]]]])
# Y: tensor([[-1., -1., -1., -1., -1., -1., 1., -1., -1., -1.]])
Repro
执行结果
执行repro.py文件,python3 repro.py
Python
{'learning_rate': 0.03, 'output_dir': 'out/base'}
model stats:
# params: 9760
# MACs: 63660
# activations: 1000
1
eval: split train. loss 6.522416e-02. error 10.15%. misses: 739
eval: split test . loss 6.352936e-02. error 9.87%. misses: 198
2
eval: split train. loss 4.566197e-02. error 7.02%. misses: 511
eval: split test . loss 4.721170e-02. error 7.37%. misses: 148
3
eval: split train. loss 3.546040e-02. error 5.27%. misses: 384
eval: split test . loss 4.091616e-02. error 6.43%. misses: 128
...
22
eval: split train. loss 5.695253e-03. error 0.95%. misses: 69
eval: split test . loss 2.754489e-02. error 3.64%. misses: 72
23
eval: split train. loss 5.344314e-03. error 0.86%. misses: 63
eval: split test . loss 2.703927e-02. error 3.54%. misses: 71
逐行分析repro.py代码
定义一个名为Net的类,它继承自nn.Module,用于构建神经网络。
Python
class Net(nn.Module):
定义类的初始化函数,调用父类nn.Module的初始化函数
Python
def __init__(self):
super().__init__()
定义一个lambda函数用于初始化权重。初始化两个变量,用于跟踪MACs(乘法累加)和激活的数量。
mac:用来衡量模型的计算复杂度,也用来预测模型在特定硬件上的运行时间。 acts:激活是指通过激活函数(如ReLU,tanh等)处理的神经元的输出。在深度学习模型中,每个激活都需要存储在内存中,因此激活的数量直接影响了模型的内存和复杂度。
Python
winit = lambda fan_in, *shape: (torch.rand(*shape) - 0.5) * 2 * 2.4 / fan_in**0.5
macs = 0 # keep track of MACs (multiply accumulates)
acts = 0 # keep track of number of activations
初始化第1层的权重和偏置。根据前面winit函数,设置一个随机4维矩阵(12, 1, 5, 5),然后缩放5*5*1的大小。
判断初始化的神经元元素总数是否1068。再更新MACs和激活数量。
Python
self.H1w = nn.Parameter(winit(5*5*1, 12, 1, 5, 5))
self.H1b = nn.Parameter(torch.zeros(12, 8, 8)) # presumably init to zero for biases
assert self.H1w.nelement() + self.H1b.nelement() == 1068
macs += (5*5*1) * (8*8) * 12
acts += (8*8) * 12
在transformer架构中,权重和偏置就是用来训练的参数。不断调整权重和偏置,使模型loss更低。下面就是transformer全连接层的计算公式。输入x,分别用权重W和偏置b对x进行计算。
初始化第2层参数。
Python
self.H2w = nn.Parameter(winit(5*5*8, 12, 8, 5, 5))
self.H2b = nn.Parameter(torch.zeros(12, 4, 4)) # presumably init to zero for biases
assert self.H2w.nelement() + self.H2b.nelement() == 2592
macs += (5*5*8) * (4*4) * 12
acts += (4*4) * 12
初始化第3层参数。全连接层矩阵形状为二维(192, 30),。
Python
self.H3w = nn.Parameter(winit(4*4*12, 4*4*12, 30))
self.H3b = nn.Parameter(torch.zeros(30))
assert self.H3w.nelement() + self.H3b.nelement() == 5790
macs += (4*4*12) * 30
acts += 30
初始化输出层。全连接层矩阵形状为二维(30, 10),
Python
self.outw = nn.Parameter(winit(30, 30, 10))
self.outb = nn.Parameter(-torch.ones(10)) # 9/10 targets are -1, so makes sense to init slightly towards it
assert self.outw.nelement() + self.outb.nelement() == 310
macs += 30 * 10
acts += 10
每一层的作用:
第1层:卷积层,对输入的图像进行卷积操作,提取出图像中的局部特征,如空间结构信息(卷积,就是对输入数据的每个位置进行卷积求和,最后得到输出,输出的结果包含了输入数据的特征,简单理解就是特征提取。)。
第2层:卷积层,提取出更高级的特征,复杂的形状、纹理。
第3层:全连接层,将前面卷积层提取的所有特征进行整合,输出一个固定大小的向量。这个向量可以被看作是输入图像的一个高级表示,包含了图像的全局信息。也称为隐藏层MLP,训练过程中不能直接观察到它们的值。
每一层的向量大小都不同,一般在中间层会大一点,拿来训练特征。最后输出层向量大小小一点,方便输出概率。
定义前向传播函数。当执行a = model(x)时,forward方法就会被调用。a即为对输入数据的预测结果。
Python
def forward(self, x)
对输入x进行填充,对x进行卷积操作(conv2d)和激活函数(tanh)。
Python
# x has shape (1, 1, 16, 16)
x = F.pad(x, (2, 2, 2, 2), 'constant', -1.0) # pad by two using constant -1 for background
x = F.conv2d(x, self.H1w, stride=2) + self.H1b
x = torch.tanh(x)
对输入x进行填充,卷积,合并cat,激活操作。
Python
# x is now shape (1, 12, 8, 8)
x = F.pad(x, (2, 2, 2, 2), 'constant', -1.0) # pad by two using constant -1 for background
slice1 = F.conv2d(x[:, 0:8], self.H2w[0:4], stride=2) # first 4 planes look at first 8 input planes
slice2 = F.conv2d(x[:, 4:12], self.H2w[4:8], stride=2) # next 4 planes look at last 8 input planes
slice3 = F.conv2d(torch.cat((x[:, 0:4], x[:, 8:12]), dim=1), self.H2w[8:12], stride=2) # last 4 planes are cross
x = torch.cat((slice1, slice2, slice3), dim=1) + self.H2b
x = torch.tanh(x)
对输入x铺平,同样的方式对隐藏层进行操作。
Python
# x is now shape (1, 12, 4, 4)
x = x.flatten(start_dim=1) # (1, 12*4*4)
x = x @ self.H3w + self.H3b
x = torch.tanh(x)
对输出层进行同样操作。
Python
# x is now shape (1, 30)
x = x @ self.outw + self.outb
x = torch.tanh(x)
# x is finally shape (1, 10)
return x
检查当前模块是否被直接运行
Python
if __name__ == '__main__'
命令行输出一些参数信息,学习率,输出路径
Python
parser = argparse.ArgumentParser(description="Train a 1989 LeCun ConvNet on digits")
parser.add_argument('--learning-rate', '-l', type=float, default=0.03, help="SGD learning rate")
parser.add_argument('--output-dir' , '-o', type=str, default='out/base', help="output directory for training logs")
args = parser.parse_args()
print(vars(args))
初始化随机数,前面已经说过随机数的作用。
Python
# init rng
torch.manual_seed(1337)
np.random.seed(1337)
torch.use_deterministic_algorithms(True)
设置log写入逻辑
Python
# set up logging
os.makedirs(args.output_dir, exist_ok=True)
with open(os.path.join(args.output_dir, 'args.json'), 'w') as f:
json.dump(vars(args), f, indent=2)
writer = SummaryWriter(args.output_dir)
初始化模型,以及输出模型的参数
Python
# init a model
model = Net()
print("model stats:")
print("# params: ", sum(p.numel() for p in model.parameters())) # in paper total is 9,760
print("# MACs: ", model.macs)
print("# activations: ", model.acts)
初始化数据,数据结构来自于prepro
Python
# init data
Xtr, Ytr = torch.load('train1989.pt')
Xte, Yte = torch.load('test1989.pt')
初始化优化器。
优化器,在神经网络中的作用是调整模型的参数以最小化(或最大化)损失函数。优化器通过不断地更新模型的权重和偏置。当然也可以调节学习率,但是本文代码中学习率是固定的。
Python
# init optimizer
optimizer = optim.SGD(model.parameters(), lr=args.learning_rate)
评估损失函数的功能。
Yhat = model(X),计算出概率,
loss = torch.mean((Y - Yhat)**2)。先计算预测值和真实值之间差的平方,再求平均值。
Python
def eval_split(split):
# eval the full train/test set, batched implementation for efficiency
model.eval()
X, Y = (Xtr, Ytr) if split == 'train' else (Xte, Yte)
Yhat = model(X)
loss = torch.mean((Y - Yhat)**2)
err = torch.mean((Y.argmax(dim=1) != Yhat.argmax(dim=1)).float())
print(f"eval: split {split:5s}. loss {loss.item():e}. error {err.item()*100:.2f}%. misses: {int(err.item()*Y.size(0))}")
writer.add_scalar(f'error/{split}', err.item()*100, pass_num)
writer.add_scalar(f'loss/{split}', loss.item(), pass_num)
迭代23次。每次都重新训练模型。
Python
# train
for pass_num in range(23):
# perform one epoch of training
model.train()
迭代数据,获取一个训练样本。
Python
for step_num in range(Xtr.size(0)):
# fetch a single example into a batch of 1
x, y = Xtr[[step_num]], Ytr[[step_num]]
计算预测值和损失
Python
# forward the model and the loss
yhat = model(x)
loss = torch.mean((y - yhat)**2)
计算梯度,反向传播,并更新参数。
梯度:一般是用来计算函数最小值的。它的思路很简单,想象在山顶放了一个球,一松手它就会顺着山坡最陡峭的地方滚落到谷底。
反向传播:根据的loss结果,反向更新模型里的梯度参数(loss.backward()),比如权重和偏置,让模型继续训练。
Python
# calculate the gradient and update the parameters
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
评估训练和测试的错误和指标。
Python
# after epoch epoch evaluate the train and test error / metrics
print(pass_num + 1)
eval_split('train')
eval_split('test')
将最终的模型保存到文件
Python
# save final model to file
torch.save(model.state_dict(), os.path.join(args.output_dir, 'model.pt'))
总结
这篇文章详细介绍了如何实践Karpathy实现的1989年论文"Backpropagation Applied to Handwritten Zip Code Recognition"的神经网络模型。首先介绍了背景和动机,然后详细解析了模型的代码实现,包括数据预处理、模型构建、参数初始化、前向传播、优化器设置、损失函数计算、参数更新等步骤。还通过实际运行代码,展示了模型训练的过程和结果。最后,分享了自己的学习心得和体会,希望能帮助其他初学者更好地理解和实践神经网络模型。