从0开始学习卷积神经网络(二)—— 实例学习

在本系列的上一篇文章中分享了卷积神经网络的基础知识,在完全了解1+1=2的基础知识后,本篇文章可以进行2*2=4进阶的学习。将会通过分析一个实例来进行讲解。

简单的图片识别卷积神经网络

数据集

我们来建立一个简单的图片识别卷积神经网络,使用MNIST数据集进行训练。

MNIST(修改后的美国国家标准与技术研究院)数据集是一个大型手写数字数据库,通常用于训练各种图像处理系统和机器学习模型。该数据集中的图片内容大致如下所示:

cnn01

下载下来的数据集如下所示:

1
2
$ ls
train-00000-of-00001.parquet test-00000-of-00001.parquet

加载数据集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import numpy as np
from datasets import load_dataset

train_datasets = load_dataset("parquet", data_files="train-00000-of-00001.parquet")
test_datasets = load_dataset("parquet", data_files="test-00000-of-00001.parquet")

train_datasets = train_datasets["train"]
test_datasets = test_datasets["train"]

print(train_datasets[0])
image = train_datasets[0]["image"]
print(image.size)

image_np = np.array(image)
print(image_np.shape)

# 输出
{'image': <PIL.PngImagePlugin.PngImageFile image mode=L size=28x28 at 0x137BF87C0>, 'label': 5}
(28, 28)
(28, 28)

从上面的输出中可以看出,这个数据集每个元素中包含一个图片,和该图片对应的真实值。图片是一个灰度图,说明只有两维,28 * 28个像素点。

建立卷积神经网络

根据数据集的信息,我们可以开始建立我们的神经网络了,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class numCNN:
alpha = 0.0000001 * 100000
def __init__(self, input_shape: tuple, output_sum: int):
self.input_x, self.input_y = input_shape
self.output_sum = output_sum
# 初始化权重
self.weight = np.random.rand(output_sum, self.input_x * self.input_y)

# 根据输入计算输出
def forward(self, inputData: np.ndarray) -> np.ndarray:
# 检测输入尺寸
assert inputData.shape == (self.input_x, self.input_y)

# 计算卷积
# 首先把28 * 28的矩阵展平成一维
inputData = inputData.flatten()
# 使用numpy的dot方法直接计算卷积
result = self.weight.dot(inputData)
return result

# 计算误差
def loss(self, inputD: np.ndarray, outputD: np.ndarray, label: int):
# 检查输出尺寸
assert len(outputD) == self.output_sum
# 根据lable,生成真实结果
real_answer = np.zeros(self.output_sum)
real_answer[label] = 1

# 计算误差
# 计算误差
deltas = outputD - real_answer
loss = deltas ** 2
# 把28 * 28的矩阵展平成一维
inputD = inputD.flatten()
# 通过外积计算,得到权重变化值
# 不知道为什么用外积,可以思考一下,一个(784, 1)矩阵和一个(10, 1)矩阵如何计算得到一个(784, 10)矩阵,weight矩阵的大小是(784, 10)
weight_deltas = np.outer(deltas, inputD)
return weight_deltas, loss

# 梯度下降
def grad(self, weight_deltas: np.ndarray):
self.weight -= self.alpha * weight_deltas

# 保存训练结果
def save(self):
with open("imageCNN.pkl", "wb") as f:
pickle.dump(self, f)

# 推理
def eval(self, inputD: np.ndarray):
outputD = self.forward(inputD)
print(f"[DEBUG] outputD = {outputD}")
return np.argmax(outputD)

结合上面代码,我们来分析一下我们该如何构建我们的神经网络。

首先,因为图片有28 * 28个像素点,所以相当于有28 * 28个输入。

其次,因为在该数据集中的图片都是手写的0-9这10个数字,所以我们构建的神经网络的作用是根据输入预测这10个数字的概率,那么也就是有10个输出。

接下来我们需要根据输入和输出构建一个权重矩阵,暂时使用随机矩阵来作为初始的权重矩阵。

然后,我们定义forward函数,用来计算输入和权重卷积计算的结果,由于权重是一个(10, 784)大小的矩阵,所以在计算卷积前需要把输入的尺寸从(28, 28)展平成(784, 1)

接着,根据上篇文章的方法编写计算误差的loss函数,计算出权重的变化值。最后,在使用梯度下降算法更新权重值。

另外,我还编写了一个save函数,用来把训练的结果保存到文件中。还有一个eval推理函数,推理出来的答案应该是概率最大值的index。

训练

定义好卷积神经网络后,可以开始编写训练代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def train(new=0):
if not new:
with open("imageCNN.pkl", "rb") as f:
imageCNN = pickle.load(f)
else:
imageCNN = numCNN(image_np.shape, output_sum=10)

for x in range(100):
total_loss = np.array([0.0] * 10)
for dataset in train_datasets:
image, label = dataset["image"], dataset["label"]
label = int(label)
# 把值转换成 0 - 1之间的浮点型
inputD = np.array(image) / 255
# 卷积计算
outputD = imageCNN.forward(inputD)
# 计算损失函数
weight_deltas, loss = imageCNN.loss(inputD, outputD, label)
total_loss += loss
# 修正权重
imageCNN.grad(weight_deltas)
print(f"训练步数:{x}, 损失值:{total_loss}")

imageCNN.save()

在训练的过程中,我们可以通过观察total_loss损失值的情况,调整alpha,或者调整训练的次数,多次循环训练,直到损失值降低到1以下,差不多就算训练完成了。

另外,通过上面的定义,可以知道weight是一个(10, 784)大小的矩阵,也就是说,该矩阵可以转化为十个28 * 28像素的灰度图。我们可以观察一下权重转化为灰度图以后的情况,相关转化代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def weightToImage():
with open("imageCNN.pkl", "rb") as f:
imageCNN = pickle.load(f)

to_pil = transforms.ToPILImage()
weight_matrix = torch.tensor(imageCNN.weight)
weight_matrix = weight_matrix.view(10, 1, 28, 28)

fig, axes = plt.subplots(2, 5, figsize=(10, 6))
axes = axes.flatten()
for axi in range(10):
image = to_pil(weight_matrix[axi])
axes[axi].imshow(image, cmap='gray')
axes[axi].set_title(f"NUM: {axi}")
axes[axi].axis("off")
plt.tight_layout()
plt.show()

生成的图片如下所示:

cnn02

看到这张图有没有茅塞顿开的感觉?我们可以这么理解卷积神经网络识别图片的本质:通过n个训练图片,提取出图片的特征,并且通过不断的循环训练,最终能形成一个特征综合体图片,而推理的过程就是计算输入的图片和哪个特征的重合度更高。

优化

如果跟着我上面的步骤从0开始训练模型,就会发现训练起来速度特别慢。有以下原因:

  1. 数据集的大小有6w张图片,我循环100次也就是有600w次计算循环。
  2. 最开始损失值降的快,但是后期损失值就降的很慢,这就需要更多的训练。

我们该如何加快训练速度呢?

由于我们目前的代码的计算都是通过numpy实现的,全程都是使用cpu进行运算,如果我们使用pytorch库替代numpy,让gpu完成计算过程,计算速度就能加快很多。

我们每次都训练一个数据集,完全可以参考实际的那些开源大模型,一次训练多个数据集(batch相关的参数配置),不过这也需要使用pytorch才能更好的提升训练速度和训练效果。

整合一下上面的代码,其实代码量并不多,我通过gpt-4o把计算方面能优化的都优化好了。其他能修改以下几部分:

alpha

alpha值的设置目前看来只能通过不断的尝试来进行调整

优化输出值

目前forward的输出值如下所示:

1
2
outputD = [-15.25994374  22.8128506  -65.14659919 180.33356777 -46.43430275
55.35769218 37.90586069 71.81096732 -6.70595218 4.06934123]

输出值的我们设定的实际含义是什么呢?输入图片是0-9这十个数字的概率,而上面的输出值明显不是一个概率值,这个时候我们可以增加一个Softmax 激活函数,以下是gpt-4o回复的Softmax 激活函数的具体含义:

1
2
3
4
5
6
7
8
9
10
11
12
Softmax 激活函数
Softmax 是一种常用于 多分类任务 的激活函数,它的作用是:

1. 把输入转换成概率分布(所有输出值相加等于 1)。
2. 放大较大的值,压缩较小的值,让输出更具可解释性。

Softmax 的特点
1. 输出值总和为 1:结果可以直接作为 分类概率。
2. 放大差异:
最大值对应的类别概率更大,使模型更容易收敛。
3. 适用于多分类问题:
常用于 神经网络最后一层,比如 图像分类(MNIST, CIFAR-10)。

更多softmax激活函数的内容请自行研究,该函数的代码如下所示:

1
2
3
def softmax(x):
exp_x = np.exp(x - np.max(x)) # 减去最大值防止溢出
return exp_x / np.sum(exp_x)

修改后的神经网络代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
class numCNN:
......
# 根据输入计算输出
def forward(self, inputData: np.ndarray) -> np.ndarray:
# 检测输入尺寸
assert inputData.shape == (self.input_x, self.input_y)

# 计算卷积
# 首先把28 * 28的矩阵展平成一维
inputData = inputData.flatten()
# 使用numpy的dot方法直接计算卷积
result = self.weight.dot(inputData)
return softmax(result)

最后,我们使用test数据集来判断我们训练后的模型预测效果,评估代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def eval():
with open("imageCNN.pkl", "rb") as f:
imageCNN = pickle.load(f)

success = 0
for i in range(len(test_datasets)):
image, label = test_datasets[i]["image"], test_datasets[i]["label"]
inputD = np.array(image)
# 推理
predict = imageCNN.eval(inputD)
if int(predict) == int(label):
success += 1
# print(f"步数:{i}, 预测结果:{predict}, 实际结果:{label}")
print(f"评估结果,成功率为:{success}/{i+1}")

使用上面描述的代码,训练100个循环后,准确率可以达到80%左右:评估结果,成功率为:8483/10000

使用pytorch架构

首先,根据不同的训练设备,还有测试需求,定义如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if torch.backends.mps.is_available():
if "MPS" in os.environ:
print("use mps device")
device = torch.device("mps")
device_name = "mps"
else:
print("use cpu device")
device = torch.device("cpu")
device_name = "cpu"
else:
if "CUDA" in os.environ:
print("use cuda device")
device = torch.device("cuda")
device_name = "cuda"
else:
print("use cpu device")
device = torch.device("cpu")
device_name = "cpu"
saveFile = f"imageCNN_{device_name}.pth"

在mac m1系列芯片上,使用的是mps设备,在nvidia显卡设备上,使用的cuda。

下一步,定义一个批量加载训练数据集的类,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MNISTDataset(Dataset):
def __init__(self, dataset):
self.dataset = dataset
image = []
label = []
for sample in dataset:
image.append(sample['image'])
label.append(sample['label'])
self.images = torch.tensor(np.array(image, dtype=np.float32) / 255.0).view(-1, 784)
self.labels = torch.tensor(label, dtype=torch.long)

def __len__(self):
return len(self.dataset)

def __getitem__(self, idx):
return self.images[idx], self.labels[idx]
# 每次训练64个训练数据集
batch_size = 64
train_dataset = MNISTDataset(train_datasets)
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, pin_memory=True)

第三步,修改神经网络类,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 修改一下激活函数
def softmax(x):
exp_x = torch.exp(x - torch.max(x, dim=1, keepdim=True)[0])
return exp_x / exp_x.sum(dim=1, keepdim=True)

# 修改CNN模型
class numCNN(nn.Module):
def __init__(self, input_size=28*28, output_size=10, batch_size=64):
super(numCNN, self).__init__()
# 定义一个 (10, 784) 的权重矩阵
self.weight = nn.Parameter(torch.randn(input_size, output_size) * 0.01) # (784, 10)
self.batch_size = batch_size
self.batch_size_range = range(batch_size)
self.real_answer = torch.zeros(self.batch_size, 10, device=device)

def forward(self, x):
return torch.matmul(x, self.weight)

def save(self):
torch.save(self.state_dict(), saveFile)
print(f"训练结果已保存到 '{saveFile}'")

# 梯度计算
def grad(self, inputs, outputs, label):
# 根据lable,生成真实结果
# (64, 10)
real_answer = self.real_answer.clone()
real_answer[self.batch_size_range, label] = 1 # one-hot 编码
# 计算误差
deltas = outputs - real_answer
# 计算权重梯度(批量版外积)
weight_deltas = torch.matmul(deltas.t(), inputs) / self.batch_size # (10, 64) @ (64, 784) -> (10, 784)
return weight_deltas

'''loss: 计算误差
inputD的shape =
'''
def lossFunction(self, outputs, labels):
# 计算损失值
log_probs = -torch.log(outputs[self.batch_size_range, labels] + 1e-9)
return log_probs.mean()

第四步,编写训练函数,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def train(new=0):
# 定义批量训练的size
batch_size = 60000 // 100
# 根据参数判断是从头开始训练,还是加载保存的模型继续训练
model = numCNN(batch_size=batch_size).to(device)
if not new:
model.load_state_dict(torch.load(saveFile))

# 把权重数据加载到指定设备上
model.weight = model.weight.to(device)
# 加载批量数据集
train_dataset = MNISTDataset(train_datasets)
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, pin_memory=True)

# 定义训练轮数
epochs = 10
for x in range(epochs):
start_time = time.time()
total_loss = torch.tensor(0.0, device=device)
for image, label in train_loader:
# 把数据加载到指定设备上
image, label = image.to(device), label.to(device)
# 前向传播
conv_output = model(image)
# 使用激活函数处理输出
probs = softmax(conv_output)
# 计算损失
loss = model.lossFunction(probs, label)
total_loss += loss
# 更新权重梯度
weight_deltas = model.grad(image, probs, label)
# 手动更新权重和偏置
with torch.no_grad():
model.weight -= alpha * weight_deltas.t()
end_time = time.time()
print(f"训练步数:{x}, 损失值:{total_loss.item()},运行时间:{end_time - start_time}")
model.save()

要使用pytorch架构进行训练,可以选择是使用CPU进行计算训练还是GPU进行计算训练,计算代码不需要做任何修改。因为pytorch判断使用什么设备进行计算是看数据在哪个设备上,如果想使用GPU进行计算,那么只要把数据移动到GPU上,如果数据在CPU上,则使用CPU进行计算。

上面的代码还修改了一下计算损失值的函数,因为损失函数只是让开发者来评估训练效果的变量,我们也没必要深入研究,只要知道可以这么计算来评估训练效果就行。想要了解深入细节的可以询问gpt或者自行研究。

最后一步,还要再编写一个评估函数,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def eval():
batch_size = 64 * 1
model = numCNN(batch_size=batch_size).to(device)
model.load_state_dict(torch.load(saveFile))

test_dataset = MNISTDataset(test_datasets)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=True, pin_memory=True)

model.eval() # 设置为评估模式
success = 0
for batch_images, batch_labels in test_loader:
image, label = batch_images.to(device), batch_labels.to(device)
# 推理
output = model(image)
predictions = torch.argmax(output, dim=1)
accuracy = predictions == label
success += accuracy.sum().item()
print(f"评估结果,成功率为:{success}/{len(test_datasets)}")

最后来看看训练速度的情况,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# 使用mac m1 pro设备的CPU进行计算,不使用批量数据集,使用numpy架构,训练10轮
$ /usr/bin/time -p python3 train_image.py train 1
训练步数:0, 损失值:[...],运行时间:5
训练步数:1, 损失值:[...],运行时间:4
训练步数:2, 损失值:[...],运行时间:4
训练步数:3, 损失值:[...],运行时间:4
训练步数:4, 损失值:[...],运行时间:4
训练步数:5, 损失值:[...],运行时间:5
训练步数:6, 损失值:[...],运行时间:4
训练步数:7, 损失值:[...],运行时间:4
训练步数:8, 损失值:[...],运行时间:4
训练步数:9, 损失值:[...],运行时间:4
real 56.31
user 45.23
sys 2.58
# 下面开始都是用pytorch架构
# 使用mac m1 pro设备的CPU进行计算,不使用批量数据集,batch_size = 1,训练10轮
$ python3 train_image_pytorch.py train 1
use cpu device
训练步数:0, 损失值:32250.6171875,运行时间:5.129971027374268
训练步数:1, 损失值:21791.275390625,运行时间:5.347644090652466
训练步数:2, 损失值:20175.958984375,运行时间:5.3410279750823975
训练步数:3, 损失值:19327.568359375,运行时间:5.338259935379028
训练步数:4, 损失值:18796.841796875,运行时间:5.698091983795166
训练步数:5, 损失值:18416.720703125,运行时间:5.464349269866943
训练步数:6, 损失值:18099.1171875,运行时间:5.541553974151611
训练步数:7, 损失值:17852.349609375,运行时间:5.5420732498168945
训练步数:8, 损失值:17676.14453125,运行时间:5.47119402885437
训练步数:9, 损失值:17503.791015625,运行时间:5.431099891662598
训练结束,计算花费时间:54.30526542663574
训练结果已保存到 'imageCNN_cpu.pth'
# 使用mac m1 pro设备的GPU进行计算,不使用批量数据集,batch_size = 1,训练10轮
$ MPS=1 python3 train_image_pytorch.py train 1
use mps device
训练步数:0, 损失值:32261.201171875,运行时间:87.1423671245575
训练步数:1, 损失值:21800.0,运行时间:86.88708591461182
训练步数:2, 损失值:20179.2578125,运行时间:87.0305118560791
训练步数:3, 损失值:19334.32421875,运行时间:86.25123476982117
训练步数:4, 损失值:18790.48046875,运行时间:89.27844595909119
训练步数:5, 损失值:18402.587890625,运行时间:86.51565194129944
训练步数:6, 损失值:18101.673828125,运行时间:83.53264117240906
训练步数:7, 损失值:17864.673828125,运行时间:92.22060513496399
训练步数:8, 损失值:17660.087890625,运行时间:88.08050513267517
训练步数:9, 损失值:17501.029296875,运行时间:84.51630687713623
训练结束,计算花费时间:871.4553558826447
训练结果已保存到 'imageCNN_mps.pth'
# 使用mac m1 pro设备的CPU进行计算,使用批量数据集,batch_size = 60000 // 100,训练10轮
$ /usr/bin/time -p python3 train_image_pytorch.py train 1
use cpu device
训练步数:0, 损失值:222.49859619140625,运行时间:0.30329084396362305
训练步数:1, 损失值:212.8668975830078,运行时间:0.3047807216644287
训练步数:2, 损失值:203.94381713867188,运行时间:0.2595350742340088
训练步数:3, 损失值:195.66217041015625,运行时间:0.24239015579223633
训练步数:4, 损失值:187.9742889404297,运行时间:0.24459481239318848
训练步数:5, 损失值:180.83810424804688,运行时间:0.32437920570373535
训练步数:6, 损失值:174.21531677246094,运行时间:0.21275115013122559
训练步数:7, 损失值:168.0689239501953,运行时间:0.22297191619873047
训练步数:8, 损失值:162.36447143554688,运行时间:0.2187938690185547
训练步数:9, 损失值:157.06793212890625,运行时间:0.26395273208618164
训练结束,计算花费时间:2.597440481185913
训练结果已保存到 'imageCNN_cpu.pth'
real 9.54
user 22.85
sys 4.32
# 使用mac m1 pro设备的GPU进行计算,使用批量数据集,batch_size = 60000 // 10,训练10轮
$ MPS=1 /usr/bin/time -p python3 train_image_pytorch.py train 1
use mps device
训练步数:0, 损失值:225.5301971435547,运行时间:0.5048720836639404
训练步数:1, 损失值:215.48736572265625,运行时间:0.3935577869415283
训练步数:2, 损失值:206.2926025390625,运行时间:0.41111207008361816
训练步数:3, 损失值:197.8079833984375,运行时间:0.4002230167388916
训练步数:4, 损失值:189.952880859375,运行时间:0.43266820907592773
训练步数:5, 损失值:182.66993713378906,运行时间:0.44860124588012695
训练步数:6, 损失值:175.91522216796875,运行时间:0.4099619388580322
训练步数:7, 损失值:169.64776611328125,运行时间:0.4285860061645508
训练步数:8, 损失值:163.83045959472656,运行时间:0.4242072105407715
训练步数:9, 损失值:158.4300994873047,运行时间:0.4490029811859131
训练结束,计算花费时间:4.302792549133301
训练结果已保存到 'imageCNN_mps.pth'
real 10.89
user 19.59
sys 4.87

通过以上的数据对比可以发现,如果不使用批量数据集,pytorch并不会比numpy快多少,并且如果使用GPU进行计算,速度还会降低的非常严重,因为循环开销还有把数据移动到GPU上的开销会特别大。

但是在使用批量数据集后,速度将会有非常显著的提升,不过这里CPU和GPU的对比不具有参考性,之后另外的文章会分析。

总结

在这篇文章中,我们自行构建了一个简单的神经网络,并且对其开始训练,从numpy架构又转化到pytorch架构,不过由于只是变更架构,算法没有发生变化,因此最终的准确率并没有发生大变化,只是pytorch训练的速度更快了,可以在相同的时间内训练更多的轮数,更快的达到收敛极限。下篇文章将会对算法进行优化,提高识别的准确率。

从0开始学习卷积神经网络(二)—— 实例学习

https://nobb.site/2025/02/19/0x90/

Author

Hcamael

Posted on

2025-02-19

Updated on

2025-03-14

Licensed under