精读Karpathy文章"深度神经网络:33年前与33年后"

背景

作为一名对深度学习充满热情的初学者,我一直在寻找机会深入理解和实践神经网络模型。最近,我发现了一个非常有趣的项目:这是由Andrej Karpathy实现的,基于1989年Yann LeCun等人的论文"Backpropagation Applied to Handwritten Zip Code Recognition( 反向传播算法在手写邮政编码识别中的应用,即训练神经网络来识别手写的邮政编码的图像)"的神经网络模型。这篇论文是卷积神经网络(Convolutional Neural Networks,简称CNN)的早期工作之一,对于理解CNN的基本概念和工作原理非常有帮助。

然而,作为一个新手,我发现理解和实践这个模型的代码实现并不容易。尽管我已经对神经网络有了基本的理解,但是这个模型涉及到的一些概念和技术,如反向传播(Backpropagation)、卷积层(Convolutional Layer)、全连接层(Fully Connected Layer)等,对我来说仍然有些复杂和深奥。

因此,我决定以学习为目的,逐行分析这个模型的代码实现。我希望通过这种方式,我能够更深入地理解这个模型的工作原理,掌握其实现的技术细节,以及学习如何在实践中应用这些知识。我相信,这将对我今后的学习和研究工作有很大的帮助。

在接下来的文章中,我将分享我在学习这个模型代码实现过程中的一些心得和体会。我会尽可能详细地解释每一行代码的含义和作用,以及它们是如何共同工作,实现这个复杂的神经网络模型的。我希望我的分享能够帮助到和我一样的初学者,一起在深度学习的道路上前进。

(GPT4生成的文案)

Demo代码地址

实践过程

框架图

(来自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"的神经网络模型。首先介绍了背景和动机,然后详细解析了模型的代码实现,包括数据预处理、模型构建、参数初始化、前向传播、优化器设置、损失函数计算、参数更新等步骤。还通过实际运行代码,展示了模型训练的过程和结果。最后,分享了自己的学习心得和体会,希望能帮助其他初学者更好地理解和实践神经网络模型。

参考文献

Deep Neural Nets: 33 years ago and 33 years from now

什么是梯度下降法? - 知乎

相关推荐
计算机小手3 小时前
FastGPT实战:从0搭建AI知识库与MCP AI Agent系统
人工智能·经验分享·aigc·开源软件
LeeZhao@8 小时前
【狂飙AGI】第4课:前沿技术-具身智能
语言模型·自然语言处理·aigc·embedding·agi
FogLetter8 小时前
智能前端中的语音交互:React音频播放与高级前端技术全解析
前端·react.js·aigc
后端小肥肠12 小时前
Coze智能体实战:3分钟构建专属数字人!公众号文章一键转为数字人口播视频(附喂饭级教程)
人工智能·aigc·coze
键盘歌唱家14 小时前
AIGC方案-java实现视频伪动效果
java·aigc·音视频
墨风如雪1 天前
告别低效!Claude Code:你的代码库来了个“全能管家”
aigc
一只爱撸猫的程序猿1 天前
创建一个基于Spring AI的智能旅行简单案例
spring boot·程序员·aigc
拖拖7651 天前
让大模型真正”思考”:Reinforcement Pre-Training(RPT)论文解读与实践
人工智能·aigc
redreamSo1 天前
AI Daily | AI日报:2025中国AI算力大会6月将举办; 程鹏:大模型重塑人才选拔方式; 李飞飞:空间智能是AI核心组件
程序员·aigc·资讯
—Qeyser1 天前
让 Deepseek 写电器电费计算器小程序
ai·chatgpt·小程序·deepseek