分享:【从零开始大模型开发与微调:基于PyTorch与ChatGLM】(从MNIST到自定义神经网络框架:把PyTorch2.0的训练流程真正拆开)(整理分享)

今天翻到一篇不错的技术分享,看完之后自己也琢磨了一下,把思路梳理记录下来。

🔥承渊政道:个人主页

❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识东西》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》 《MySQL数据库学习》

✨逆境不吐心中苦,顺境不忘来时路!✨

🎬 博主简介:

前面搞定了第一个PyTorch深度学习示例程序——一个挺轻松的MNIST手写体生成器,其作用是向大家演示一个PyTorch深度学习程序的基本构建与完整的训练过程.PyTorch作为一个成熟的深度学习框架,对于使用者来说,即使是初学者,也能很容易地用其进行深度学习项目的训练,只要编写出轻松的代码就也能构建相应的模型进行实验,但其缺点在于框架的背后东西都被隐藏起来了.这篇文章将使用Python达成一个轻量级的、易于扩展的深度学习框架,目的是希望大家从这一过程中了解深度学习的基本组件以及框架的设计和达成,从而为后续的学习打下基础.这篇文章首先使用PyTorch搞定MNIST分类的练习,主要是为了熟悉PyTorch的基本使用流程;之后将达成一个自定义的深度学习框架,从基本的流程开始分析,对神经网络中的关键组件进行抽象,确定基本框架,之后对框架中的各个组件进行代码实现;最终基于自定义框架实现MNIST分类,并与PyTorch实现的MNIST分类进行轻松的对比验证.废话不多说,下面跟着小编的节奏🎵一起去疯狂的学习吧!

目录

2.自定义神经网络框架的基本设计 3.PyTorch 和自定义框架的对照搞懂4.总结

1.实战MNIST手写体识别

前面对MNIST数据集做了介绍,描述了其构成方式及其数据特征和标签含义等.了解这些信息有助于编写合适的程序来对MNIST数据集进行分析和分类识别.下面将实现MNIST数据集分类的任务.


1.1数据图像的获取与标签的说明

前面已经详细介绍了MNIST数据集,咱们也能使用下面代码获取数据:

import numpy as np
     x_train = np.load("./dataset/mnist/x_train.npy")
     y_train_label = np.load("./dataset/mnist/y_train_label.npy")

基本数据的获取在前面也做了介绍,这里不再过多阐述.需要留意的是,咱们在前面介绍MNIST数据集时,只使用了图像数据,没有对标签进行说明,在这里重点对数据标签,也就是y_train_labe进行介绍.
下面使用print(y_train_label[:10])打印出数据集的前10个标签,结果如下:
[5 0 4 1 9 2 1 3 1 4]
也能很清楚地看到,这里打印出了10个字符,每个字符对应相应数字的数据图像所对应的数字标签.

可以说,训练集中每个实例的标签对应0~9的任意一个数字,用以对图片进行标注.另外,需要留意的是,对于提取出来的MNIST的特征值,默认使用一个0~9的数值进行标注,但是这种标注方法并不能使得损失函数获得一个好的结果,因此常用one_hot计算方法,将其值具体落在某个标注区间中.
One-hot 编码(独热编码)本质是:
👉把类别标签变成“只有一个位置为1,其余全是0”的向量.
🧠 1.基本定义
假设有 K 个类别:

类别one-hot0[1,0,0]1[0,1,0]2[0,0,1]

🧮 2.计算方法(核心公式)
如果标签是 y,类别总数是 K

o

n

e

_

h

o

t

(

y

)

i

=

{

1

,

i

=

y

0

,

i

y

one\_hot(y)_i = \begin{cases} 1, & i = y \\ 0, & i \ne y \end{cases}

one_hot(y)i​={1,0,​i=yi=y​

🐍 3.Python手写实现
方法1:纯 Python

def one_hot(y, num_classes):
    result = [0] * num_classes
    result[y] = 1
    return result

print(one_hot(2, 5))
# [0, 0, 1, 0, 0]

方法2:NumPy(推荐)

import numpy as np

def one_hot(y, num_classes):
    return np.eye(num_classes)[y]

示例:

y = np.array([0, 2, 1])
print(np.eye(3)[y])

输出:

[[1. 0. 0.]
 [0. 0. 1.]
 [0. 1. 0.]]

⚡4.PyTorch方法(最常用)

import torch
import torch.nn.functional as F

y = torch.tensor([0, 2, 1])

one_hot = F.one_hot(y, num_classes=3)
print(one_hot)

输出:

tensor([[1, 0, 0],
        [0, 0, 1],
        [0, 1, 0]])

🧠5.实际用途
one-hot 常用于:

  • 分类任务标签
  • softmax 前处理
  • 交叉熵搞懂(虽然 PyTorch 内部通常不需要你手动 one-hot)
🚀6.如果你在做 MNIST 比如标签:

y = 7

one-hot:

[0,0,0,0,0,0,0,1,0,0]

这里主要介绍将单一序列转换成one_hot的方法.一般情况下,可以用NumPy实现one_hot的表示方法,但是这样转换生成的是numpy.array格式的数据,并不适合直接输入到PyTorch中.
不过PyTorch提供了已经编写好的转换函数:torch.nn.functional.one_hot

完整的one_hot使用方法如下:

import torch
import torch.nn.functional as F
from torchvision import datasets, transforms

#下载 MNIST 数据(自动下载,不需要 npy 文件)
train_data = datasets.MNIST(
    root="./data",
    train=True,
    download=True,
    transform=transforms.ToTensor()
)

#取一部分 label
labels = train_data.targets[:5]   # 前5个标签

print("原始标签:")
print(labels)

#转 one-hot
one_hot = F.one_hot(labels, num_classes=10)

print("\nOne-hot 结果:")
print(one_hot)

运行结果如下图所示.可以看到,one_hot的作用是将一个序列转换成以one_hot形式表示的数据集.

所有的行或者列都被设置成0,而每个特定的位置都用一个1来表示,如下图所示:

简单来说,MNIST数据集的标签实际上就是一个表示60000幅图片的60 000×10大小的矩阵张量[60000,10].前面的行数指的是数据集中的图片为60 000幅,后面的10是指10个列向量.


1.2实战基于PyTorch2.0的手写体识别模型

下面使用PyTorch 2.0框架搞定MNIST手写体数字的识别.

1.模型的准备(多层感知机)
前面讲过了,PyTorch最重要的一项东西是模型的准备与设计,而模型的设计最关键的一点就是了解输出和输入的数据结构类型.通过图像降噪的演示,已经了解到咱们输入的数据是一个[28,28]大小的二维图像.而通过对数据结构的分析可以得知,对于每个图形都有一个确定的分类结果,也就是一个0~9之间的确定数字.因此为了实现对输入图像进行数字分类这个想法,必须设计一个合适的判别模型.而从上面对图像的分析来看,最直观的想法就将图形作为一个整体结构直接输入到模型中进行判断.基于这种思路,简单的模型设计就是同时对图像所有参数进行计算,即使用一个多层感知机(Multilayer Perceptron,MLP)来对图像进行分类,整体的模型设计结构如下图所示.

从图可以看到,一个多层感知机模型就是将数据输入后,分散到每个模型的节点(隐藏层),进
行数据计算后,即可将计算结果输出到对应的输出层中.多层感知机的模型结构如下:

import torch.nn as nn

class NeuralNetwork(nn.Module):
    def __init__(self):
        super(NeuralNetwork, self).__init__()

        self.flatten = nn.Flatten()

        self.linear_relu_stack = nn.Sequential(
            nn.Linear(28 * 28, 312),
            nn.ReLU(),
            nn.Linear(312, 256),
            nn.ReLU(),
            nn.Linear(256, 10)
        )

    def forward(self, input):
        x = self.flatten(input)
        logits = self.linear_relu_stack(x)
        return logits

2.损失函数的表示与计算
我们使用MSE作为目标图形与预测图形的损失值,而在本例中,我们需要预测的目标是图形的"分类:,而不是图形表示本身,因此我们需要寻找并使用一种新的能够对类别归属进行"计算"的函数.本例所使用的损失函数为torch.nn.CrossEntropyLoss.PyTorch官网对其介绍如下:

CLASS torch.nn.CrossEntropyLoss(weight=None, size_average=None, ignore_index=-100,reduce=None, reduction='mean', label_smoothing=0.0)

该损失函数计算输入值(Input)和目标值(Target)之间的交叉熵损失.交叉熵损失函数可用于训
练一个单标签或者多标签类别的分类问题.给定参数weight时,其为分配给每个类别的权重的一维张量(Tensor).当数据集分布不均衡时,这个参数很有用.同样需要留意的是,因为torch.nn.CrossEntropyLoss内置了Softmax运算,而Softmax的作用是计算分类结果中最大的那个类.从下图所示的代码实现中可以看到,此时CrossEntropyLoss已经在实现计算时完成了Softmax计算,因此在使用torch.nn.CrossEntropyLoss作为损失函数时,不需要在网络的最终添加Softmax层.此外,label应为一个整数,而不是one-hot编码形式.

代码如下:

import torch
y = torch.LongTensor([0])
z = torch.Tensor([[0.2,0.1,-0.1]])
criterion = torch.nn.CrossEntropyLoss()
loss = criterion(z,y)
print(loss)

目前大家需要掌握的就是这些内容,CrossEntropyLoss的数学公式较为复杂,建议学有余力的人查阅相关资料进行学习.小编在这里可以简要介绍一下
CrossEntropyLoss 数学公式
一、多分类交叉熵(Cross Entropy)

L

=

i

=

1

C

y

i

log

(

y

^

i

)

\mathcal{L} = - \sum_{i=1}^{C} y_i \log(\hat{y}_i)

L=−i=1∑C​yi​log(y^​i​)
二、符号说明

符号含义(C)类别数量(y_i)真实标签(one-hot)(\hat{y}_i)预测概率(softmax输出)

三、简化形式(最常用)
当真实类别为 (k) 时:

L

=

log

(

y

^

k

)

\mathcal{L} = - \log(\hat{y}_k)

L=−log(y^​k​)

👉 只惩罚正确类别的概率
四、Softmax 定义

y

^

i

=

e

z

i

j

=

1

C

e

z

j

\hat{y}_i = \frac{e^{z_i}}{\sum_{j=1}^{C} e^{z_j}}

y^​i​=∑j=1C​ezj​ezi​​
五、CrossEntropy 完整展开形式

L

=

log

(

e

z

k

j

=

1

C

e

z

j

)

\mathcal{L} = -\log \left( \frac{e^{z_k}}{\sum_{j=1}^{C} e^{z_j}} \right)

L=−log(∑j=1C​ezj​ezk​​)
六、PyTorch 等价实现

loss = nn.CrossEntropyLoss()
loss(logits, labels)

等价于:
CrossEntropyLoss=LogSoftmax+NLLLoss
一句话总结:CrossEntropyLoss=−log(正确类别概率)

3.基于PyTorch的手写体数字识别
下面开始实现基于PyTorch的手写体数字识别.通过前面的介绍,我们还需要定义深度学习的优化器部分,在这里采用Adam优化器,代码如下:

model = NeuralNetwork()
optimizer = torch.optim.Adam(model.parameters(), lr=2e-5)  #设定优化函数

在这里首先需要定义模型,之后将模型参数传入优化器中,lr是对学习率的设定,根据设定的学习率进行模型计算.完整的手写体数字识别模型如下:

import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader

# =====================
# 超参数
# =====================
batch_size = 320
epochs = 11

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# =====================
# 数据
# =====================
train_dataset = datasets.MNIST(
    root="./data",
    train=True,
    transform=transforms.ToTensor(),
    download=True
)

train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)

# =====================
# 模型
# =====================
class NeuralNetwork(nn.Module):
    def __init__(self):
        super().__init__()
        self.flatten = nn.Flatten()
        self.linear_relu_stack = nn.Sequential(
            nn.Linear(28 * 28, 312),
            nn.ReLU(),
            nn.Linear(312, 256),
            nn.ReLU(),
            nn.Linear(256, 10)
        )

    def forward(self, x):
        x = self.flatten(x)
        return self.linear_relu_stack(x)

model = NeuralNetwork().to(device)

# loss + optimizer
loss_fn = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=2e-5)

# =====================
# 训练
# =====================
for epoch in range(epochs):
    model.train()

    running_loss = 0.0
    correct = 0
    total = 0

    for x, y in train_loader:
        x, y = x.to(device), y.to(device)

        # forward
        pred = model(x)
        loss = loss_fn(pred, y)

        # backward
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        # stats
        running_loss += loss.item()
        correct += (pred.argmax(1) == y).sum().item()
        total += y.size(0)

    train_loss = running_loss / len(train_loader)
    accuracy = correct / total

    print(f"epoch: {epoch} train_loss: {train_loss:.2f} accuracy: {accuracy:.2f}")

可以看到,随着模型循环次数的增加,模型的损失值在降低,而准确率在逐渐增高.

1.3基于Netron库的PyTorch2.0模型可视化

前面带领大家完成了基于PyTorch2.0的MNIST模型的设计,并基于此完成了MNIST手写体数字
的识别.此时可能有人对我们自己设计的模型结构感到好奇,如果能够可视化地显示模型结构就更好了.大家可以自行在百度搜索Netron.Netron是一个深度学习模型可视化库,支持可视化地表示PyTorch2.0的模型存档文件.因此,我们可以把1.2节中PyTorch的模型结构保存为文件,并通过Netron进行可视化展示.保存模型的代码如下:

import torch
# device
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# 模型定义
class NeuralNetwork(torch.nn.Module):
    def __init__(self):
        super(NeuralNetwork, self).__init__()

        self.flatten = torch.nn.Flatten()

        self.linear_relu_stack = torch.nn.Sequential(
            torch.nn.Linear(28 * 28, 312),
            torch.nn.ReLU(),
            torch.nn.Linear(312, 256),
            torch.nn.ReLU(),
            torch.nn.Linear(256, 10)
        )

    def forward(self, input):
        x = self.flatten(input)
        logits = self.linear_relu_stack(x)
        return logits

# 创建模型
model = NeuralNetwork().to(device)

# 保存模型
torch.save(model.state_dict(), "./model.pth")

建议大家从GitHub上下载Netron,其主页提供了基于不同版本的安装方式
Netron GitHub(官方仓库)

大家可以依照操作系统的不同下载对应的文件,在这里安装的是基于MacOS文件,安装后是一个图形界面,直接在界面上单击file操作符号打开我们刚才保存的.pth文件.

可以看到,此时我们定义的模型结构被可视化地展示出来了,每个模块的输入输出维度在图上都展示出来了,单击深色部分可以看到每个模块更详细的说明.


2.自定义神经网络框架的基本设计

下面学习自定义神经网络框架,稍微有点棘手,建议有一定编程基础的朋友掌握一下,其他读者了
解一下即可.对于一个普通的神经网络运算流程来说,最基本的过程包含两个阶段,即训练(training)和预测(predict).而训练的基本流程包括输入数据、网络层前向传播、计算损失、网络层反向传播梯度、更新参数这一系列过程.对于预测来说,又分为输入数据、网络层前向传播和输出结果.


2.1神经网络框架的抽象实现

神经网络的预测就是训练过程的一部分,因此,基于训练的过程,我们可以对神经网络中的基本组
件进行抽象.在这里,神经网络的组件被抽象成4部分,分别是数据输入、计算层(包括激活层)、损失计算以及优化器,如下图所示.

各个部分的作用如下:

  • 输入数据:这个是神经网络中数据输入的基本内容,一般我们将其称为tensor.
  • 计算层:负责接收上一层的输入,进行该层的运算,并将结果输出给下一层,由于tensor的流动有前向和反向两个方向,因此对于每种类型的网络层,我们都需要同时实现forward和backward两种运算.
  • 激活层:通常与计算层结合在一起对每个计算层进行非线性分割.
  • 损失计算:在给定模型预测值与真实值之后,使用该组件计算损失值以及关于最终一层的梯度.
  • 优化器:负责使用梯度更新模型的参数.
基于上面的分析,我们可以按照抽象的认识完成深度学习代码的流程设计,如下所示:

# define model
net = Net(Activity([layer1, layer2, ...]))   #数据的激活与计算
model = Model(net, loss_fn, optimizer)
# training                                   #训练过程
pred = model.forward(train_X)                #前向计算
loss, grads = model.backward(pred, train_Y)  #反向计算
model.apply_grad(grads)                      #参数优化
# inference                                  #预测过程
test_pred = model.forward(test_X)

上面代码中,我们定义了一个net计算层,之后将net、loss-fn、optimizer一起传给model.model实现了forward、backward和apply_grad三个接口,分别对应前向传播、反向传播和参数更新三个功能.下面我们分别对这些内容进行实现.


2.2自定义神经网络框架的具体实现

下面演示自定义神经网络框架的具体实现,这个实现较为棘手,请大家按下面的说明步骤进行学习.
1.tensor数据包装
根据前面的分析,首先需要实现数据的输入输出定义,即张量的定义类型.张量是神经网络中的基本数据单位,为了简化起见,这里直接使用numpy.ndarray类作为tensor类的实现.

import numpy as np
tensor = np.random.random(size=(10,28,28,1))

上面代码中,我们直接使用NumPy包中的random函数生成数据.

2.layer计算层的基类与实现
计算层的作用是对输入的数据进行计算,在这一层中输入数据的前向计算在forward过程中完成,相对于普通的计算层来说,除了需要计算forward过程外,还需要实现一个参数更新的backward过程.因此,一个基本的计算层的基类如下:

class Layer:
    """Base class for layers."""

    def __init__(self):
        self.params = {p: None for p in self.param_names}
        self.nt_params = {p: None for p in self.nt_param_names}
        self.initializers = {}
        self.grads = {}
        self.shapes = {}
        self._is_training = True  # used in BatchNorm/Dropout layers
        self._is_init = False
        self.ctx = {}

    def __repr__(self):
        shape = None if not self.shapes else self.shapes
        return f"layer: {self.name}\tshape: {shape}"

    def forward(self, inputs):
        raise NotImplementedError

    def backward(self, grad):
        raise NotImplementedError

    @property
    def is_init(self):
        return self._is_init

    @is_init.setter
    def is_init(self, is_init):
        self._is_init = is_init
        for name in self.param_names:
            self.shapes[name] = self.params[name].shape

    @property
    def is_training(self):
        return self._is_training

    @is_training.setter
    def is_training(self, is_train):
        self._is_training = is_train

    @property
    def name(self):
        return self.__class__.__name__
@property
def param_names(self):
    return ()

@property
def nt_param_names(self):
    return ()

def _init_params(self):
    for name in self.param_names:
        self.params[name] = self.initializers[name](self.shapes[name])
    self.is_init = True

下面实现一个基本的神经网络计算层——全连接层.关于全连接层的详细介绍,我在后面文章中
会讲解,在这里主要将其作为一个简单的计算层来实现.在全连接层的计算过程中,forward接受上层的输入inputs实现ωx+b的计算;backward正好相反,接受来自反向的梯度.具体实现如下:在这里我们实现一个可以计算的forward函数,其目的是对输入的数据进行前向计算,具体计算结果如下:

import numpy as np

class Dense(Layer):
    """A dense layer operates: outputs = dot(inputs, weight) + bias

    :param num_out: A positive integer, number of output neurons
    :param w_init: Weight initializer
    :param b_init: Bias initializer
    """

    def __init__(self,
                 num_out,
                 w_init=XavierUniform(),
                 b_init=Zeros()):

        super().__init__()

        self.initializers = {
            "w": w_init,
            "b": b_init
        }

        self.shapes = {
            "w": [None, num_out],
            "b": [num_out]
        }

    def forward(self, inputs):
        if not self.is_init:
            self.shapes["w"][0] = inputs.shape[1]
            self._init_params()

        self.ctx = {"X": inputs}

        return inputs @ self.params["w"] + self.params["b"]

    def backward(self, grad):
        self.grads["w"] = self.ctx["X"].T @ grad
        self.grads["b"] = np.sum(grad, axis=0)

        return grad @ self.params["w"].T

    @property
    def param_names(self):
        return "w", "b"

在这里我们实现一个可以计算的forward函数,其目的是对输入的数据进行前向计算,具体计算结果如下:

import numpy as np

# 1. 基础 Layer
class Layer:
    def __init__(self):
        self.params = {}
        self.shapes = {}
        self.initializers = {}
        self.grads = {}
        self.ctx = {}

        self._is_init = False

    @property
    def is_init(self):
        return self._is_init

    @is_init.setter
    def is_init(self, v):
        self._is_init = v

# 2. 初始化器
class XavierUniform:
    def __call__(self, shape):
        in_dim, out_dim = shape
        limit = np.sqrt(6 / (in_dim + out_dim))
        return np.random.uniform(-limit, limit, size=shape)

class Zeros:
    def __call__(self, shape):
        return np.zeros(shape)

# 3. Dense 层
class Dense(Layer):
    def __init__(self, num_out,
                 w_init=XavierUniform(),
                 b_init=Zeros()):

        super().__init__()

        self.initializers = {
            "w": w_init,
            "b": b_init
        }

        self.shapes = {
            "w": [None, num_out],
            "b": [num_out]
        }

    def forward(self, inputs):
        if not self.is_init:
            self.shapes["w"][0] = inputs.shape[1]

            self.params["w"] = self.initializers["w"](self.shapes["w"])
            self.params["b"] = self.initializers["b"](self.shapes["b"])

            self.is_init = True

        self.ctx["X"] = inputs

        return inputs @ self.params["w"] + self.params["b"]

# 4. 输入数据
tensor = np.random.random(size=(10, 28, 28, 1))

# flatten
tensor = tensor.reshape(10, 28 * 28)

# 5. forward + 输出
layer = Dense(512)
res = layer.forward(tensor)

print("output shape:", res.shape)
print("sample output (first row):")
print(res[0][:10])

上面代码生成了一个随机数据集,再通过reshape函数对其进行折叠,之后使用我们自定义的全连接层对其进行计算.

3.激活层的基类与实现
神经网络框架中的另一个重要的部分是激活函数.激活函数可以看作是一种网络层,同样需要实现forward和backward方法.我们通过继承Layer基类实现激活函数类,这里实现了常用的ReLU激活函数.forwar和backward方法分别实现对应激活函数的正向计算和梯度计算,代码如下:

# activity_layer
import numpy as np

class Layer(object):
    def __init__(self, name):
        self.name = name
        self.params, self.grads = None, None

    def forward(self, inputs):
        raise NotImplementedError

    def backward(self, grad):
        raise NotImplementedError

class Activation(Layer):
    """Base activation layer"""

    def __init__(self, name):
        super().__init__(name)
        self.inputs = None

    def forward(self, inputs):  # 下面调用具体 forward 实现
        self.inputs = inputs
        return self.forward_func(inputs)

    def backward(self, grad):  # 下面调用具体 backward 实现
        return self.backward_func(self.inputs) * grad

    def forward_func(self, x):  # 具体 forward 实现
        raise NotImplementedError

    def backward_func(self, x):  # 具体 backward 实现
        raise NotImplementedError

class ReLU(Activation):
    """ReLU activation function"""

    def __init__(self):
        super().__init__("ReLU")

    def forward_func(self, x):
        return np.maximum(x, 0.0)

    def backward_func(self, x):
        return (x > 0.0)

这里需要注意,对于具体的forward和backward实现函数,需要实现一个特定的需求对应的函数,从而完成对函数的计算.

4.辅助网络更新的基类——Net
对于神经网络来说,误差需要在整个模型中传播,即正向(Forward)传播和反向(Backward)传播
.正向传播的实现方法很简单,按顺序遍历所有层,每层计算的输出作为下一层的输入;反向传播则逆序遍历所有层,将每层的梯度作为下一层的输入.这一部分的具体实现需要建立一个辅助网络参数更新的网络基类,其作用是对每一层进行forward和backward计算,并更新各个层中的参数.为了达成这个目标,我们建立一个model基类,其作用是将每个网络层参数及其梯度保存下来.具体实现的model类如下:

class Net(object):
    def __init__(self, layers):
        self.layers = layers

    def forward(self, inputs):
        for layer in self.layers:
            inputs = layer.forward(inputs)
        return inputs

    def backward(self, grad):
        all_grads = []
        for layer in reversed(self.layers):
            grad = layer.backward(grad)
            all_grads.append(layer.grads)
        return all_grads[::-1]

    def get_params_and_grads(self):
        for layer in self.layers:
            yield layer.params, layer.grads

    def get_parameters(self):
        return [layer.params for layer in self.layers]

    def set_parameters(self, params):
        for i, layer in enumerate(self.layers):
            for key in layer.params.keys():
                layer.params[key] = params[i][key]

5.损失函数计算组件与优化器
对于神经网络的训练来说,损失的计算与参数优化是必不可少的操作.对于损失函数组件来说,给定了预测值和真实值,需要计算损失值和关于预测值的梯度.我们分别使用loss和grad两个方法来实现.具体而言,我们需要实现基类的损失(loss)函数与优化器(optimizer)函数.损失函数如下:

# loss
class BaseLoss(object):
   def loss(self, predicted, actual):
       raise NotImplementedError
   def grad(self, predicted, actual):
       raise NotImplementedError

而优化器的基类需要实现根据当前的梯度,计算返回实际优化时每个参数改变的步长,代码如下:

import numpy as np

class BaseOptimizer(object):
    def __init__(self, lr, weight_decay):
        self.lr = lr
        self.weight_decay = weight_decay

    def compute_step(self, grads, params):
        step = list()

        # flatten all gradients
        flatten_grads = np.concatenate([
            np.ravel(v)
            for grad in grads
            for v in grad.values()
        ])

        # compute step
        flatten_step = self._compute_step(flatten_grads)

        # reshape gradients
        p = 0

        for param in params:
            layer = dict()

            for k, v in param.items():
                block = np.prod(v.shape)

                _step = flatten_step[p:p + block].reshape(v.shape)

                # weight decay
                _step = self.weight_decay * v

                layer[k] = _step

                p += block

            step.append(layer)

        return step

    def _compute_step(self, grad):
        raise NotImplementedError

下面是对这两个类的具体实现.对于损失函数来说,我们最常用的也就是所使用的多分类损失
函数——多分类Softmax交叉熵.具体的数学形式如下:

具体实现形式如下:

class CrossEntropyLoss(BaseLoss):

    def loss(self, predicted, actual):
        m = predicted.shape[0]

        exps = np.exp(predicted - np.max(predicted, axis=1, keepdims=True))
        p = exps / np.sum(exps, axis=1, keepdims=True)

        nll = -np.log(np.sum(p * actual, axis=1))

        return np.sum(nll) / m

    def grad(self, predicted, actual):
        m = predicted.shape[0]

        grad = np.copy(predicted)
        grad -= actual

        return grad / m

这里需要注意的是,我们在设计优化器时并没有进行归一化处理,因此在使用之前需要对分类数据进行one-hot表示,对其进行表示的函数如下:

def get_one_hot(targets, nb_classes=10):
        return np.eye(nb_classes)[np.array(targets).reshape(-1)]

对于优化器来说,其公式推导较为复杂,我在这里只实现常用的Adam优化器,具体数学推导部分有兴趣的朋友可自行研究学习.

class Adam(BaseOptimizer):

    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999,
                 eps=1e-8, weight_decay=0.0):

        super().__init__(lr, weight_decay)

        self._b1, self._b2 = beta1, beta2
        self._eps = eps
        self._t = 0

        self._m, self._v = 0, 0

    def _compute_step(self, grad):
        self._t += 1

        # first moment
        self._m = self._b1 * self._m + (1 - self._b1) * grad

        # second moment
        self._v = self._b2 * self._v + (1 - self._b2) * (grad ** 2)

        # bias correction
        m_hat = self._m / (1 - self._b1 ** self._t)
        v_hat = self._v / (1 - self._b2 ** self._t)

        return -self.lr * m_hat / (np.sqrt(v_hat) + self._eps)

6.整体model类的实现
Model类实现了我们一开始设计的3个接口:forward、backward和apply_grad.在forward方法中,直接调用net的forward方法,在backward方法中,把net、loss、optimizer串联起来,首先计算损失(loss),然后进行反向传播获得梯度,接着由optimizer计算步长,最后通过apply_grad对参数进行更新,代码如下:

class Model(object):

    def __init__(self, net, loss, optimizer):
        self.net = net
        self.loss = loss
        self.optimizer = optimizer

    def forward(self, inputs):
        return self.net.forward(inputs)

    def backward(self, preds, targets):
        loss = self.loss.loss(preds, targets)
        grad = self.loss.grad(preds, targets)

        grads = self.net.backward(grad)
        params = self.net.get_parameters()

        step = self.optimizer.compute_step(grads, params)

        return loss, step

    def apply_grad(self, grads):
        for grad, (param, _) in zip(grads, self.net.get_params_and_grads()):
            for k, v in param.items():
                param[k] += grad[k]

在Model类中,我们串联了损失函数、优化器以及对应的参数更新方法,从而将整个深度学习模型作为一个完整的框架进行计算.

7.基于自定义框架的神经网络框架的训练
完整的自定义神经网络框架训练如下:

import numpy as np

# Layer system
class Layer:
    def __init__(self):
        self.params = {}
        self.grads = {}
        self.ctx = {}

    def forward(self, x):
        raise NotImplementedError

    def backward(self, grad):
        raise NotImplementedError

class Dense(Layer):
    def __init__(self, out_dim):
        super().__init__()
        self.out_dim = out_dim
        self.in_dim = None

    def forward(self, x):
        if self.in_dim is None:
            self.in_dim = x.shape[1]
            self.W = np.random.randn(self.in_dim, self.out_dim) * 0.01
            self.b = np.zeros(self.out_dim)

        self.ctx["x"] = x
        return x @ self.W + self.b

    def backward(self, grad):
        x = self.ctx["x"]

        self.grads["W"] = x.T @ grad
        self.grads["b"] = np.sum(grad, axis=0)

        return grad @ self.W.T

class ReLU(Layer):
    def forward(self, x):
        self.ctx["x"] = x
        return np.maximum(0, x)

    def backward(self, grad):
        x = self.ctx["x"]
        return grad * (x > 0)

# Net
class Net:
    def __init__(self, layers):
        self.layers = layers

    def forward(self, x):
        for layer in self.layers:
            x = layer.forward(x)
        return x

    def backward(self, grad):
        for layer in reversed(self.layers):
            grad = layer.backward(grad)

# Loss
class SoftmaxCrossEntropy:
    def loss(self, pred, y):
        pred = pred - np.max(pred, axis=1, keepdims=True)
        exp = np.exp(pred)
        prob = exp / np.sum(exp, axis=1, keepdims=True)

        self.prob = prob
        self.y = y

        return -np.mean(np.sum(y * np.log(prob + 1e-9), axis=1))

    def grad(self, pred, y):
        return (self.prob - y) / y.shape[0]

# Optimizer
class SGD:
    def __init__(self, lr=0.01):
        self.lr = lr

    def step(self, net):
        for layer in net.layers:
            if hasattr(layer, "W"):
                layer.W -= self.lr * layer.grads["W"]
                layer.b -= self.lr * layer.grads["b"]

# Model
class Model:
    def __init__(self, net, loss, optimizer):
        self.net = net
        self.loss = loss
        self.optimizer = optimizer

    def forward(self, x):
        return self.net.forward(x)

    def backward(self, pred, y):
        loss = self.loss.loss(pred, y)
        grad = self.loss.grad(pred, y)
        self.net.backward(grad)
        return loss

    def step(self):
        self.optimizer.step(self.net)

# utils
def one_hot(y, num_class=10):
    return np.eye(num_class)[y]

# demo training (MNIST-like)
if __name__ == "__main__":

    # fake data (替换成你的MNIST也可以)
    x = np.random.randn(1000, 784)
    y = np.random.randint(0, 10, 1000)
    y = one_hot(y)

    net = Net([
        Dense(128),
        ReLU(),
        Dense(64),
        ReLU(),
        Dense(10)
    ])

    model = Model(
        net=net,
        loss=SoftmaxCrossEntropy(),
        optimizer=SGD(lr=0.01)
    )

    for epoch in range(5):
        pred = model.forward(x)
        loss = model.backward(pred, y)
        model.step()

        acc = np.mean(np.argmax(pred, axis=1) == np.argmax(y, axis=1))
        print(f"epoch {epoch} loss {loss:.4f} acc {acc:.4f}")

最终训练结果如下:

可以看到,随着训练的深入进行,此时损失值在降低,而准确率随着训练次数的增加在不停地增高.


3.PyTorch 和自定义框架的对照搞懂

学完这篇文章后,建议把 PyTorch 写法和自定义框架写法逐项对应起来:

训练动作PyTorch 写法自定义框架写法前向传播logits = model(x)logits = net.forward(x)计算损失loss = criterion(logits, y)loss = loss_fn.forward(logits, y)清空梯度optimizer.zero_grad()手写框架通常直接覆盖梯度反向传播loss.backward()net.backward(loss_fn.backward())参数更新optimizer.step()optimizer.step()预测类别logits.argmax(dim=1)logits.argmax(axis=1)

这个对照挺重要.你会发现 PyTorch 并没有魔法,它只是把每个组件做得更通用、更高效、更稳定.


4.总结

本文演示了使用PyTorch框架进行手写体数字识别的实战案例,我完整地对MNIST手写体图片做了分类,同时讲解了模型的标签问题,以及后期常用的损失函数计算方面的内容.可以说CrossEntropy损失函数将会是深度学习最重要的损失函数,需要大家认真学习.同时,通过自定义一个深度学习框架,完整地演示了深度学习框架的设计过程,并且讲解了各部分的开发原理以及最终组合在一起运行的流程,引导大家进一步熟悉深度学习框架.

本文表面上是在讲 MNIST,实际上是在建立深度学习训练的基本心智模型.

你至少应该带走四个结论:

  • 分类任务先看形状. 输入 [B, 1, 28, 28],输出 [B, 10],标签 [B].
  • CrossEntropyLoss 接收 logits 和类别索引. 普通多分类不要手动加 Softmax.
  • 训练循环是固定套路. forward、loss、zero_grad、backward、step,一个都不能乱.
  • 框架不是魔法. 自定义一个迷你框架后,你会发现 PyTorch 的核心抽象就是 Layer、Loss、Optimizer 和 Model 的系统化组合.
从这个角度看,本文不是简单的入门案例,而是后续学习 CNN、RNN、Transformer、大模型微调之前的一块地基.地基打得越扎实,后面遇到更复杂模型时越不容易被代码表象带偏.

🚀真正的勇者不是流泪的人,而是含泪奔跑的人!


敬请期待下一篇文章内容

每日心灵鸡汤: 愿一切美好都不期而遇!

《人民日报》中这段话给了我很深的感触:"走着走着,已经夏天了.人生总有太多来不及,时间快得太快,一眨眼就是一天,一回头就是一年,转身就是一辈子."那些曾经的日子,变成也好、亦也罢,都已经成为过去.时光流逝,不可复得,往前走别回头.花会沿途盛开,以后可以路过.一步有一步的味道,一站有一站的风景.用心保持热爱,过好眼前的生活.愿接下来日子,平安、健康、顺利、好运,四季轮回.愿一切美好都不期而遇.

本次分享就到这里。技术这东西越研究越有意思,后续有新的收获我也会继续更新。

评论 (0)

暂无评论