Yanick's Blog

Be Better & Have Fun

生成博客首页图

本文在 AI 指导下完成:数据准备→模型搭建→训练优化→评估推理

数据准备

我们用最为经典的 MNIST 数据集,可以从 Github 获取,不过 torchvision 已经包含了此数据集,直接使用即可

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
import torchvision
import matplotlib.pyplot as plt
import numpy as np

# 加载原始 MNIST 训练集(无 transform,保留原始格式)
train_dataset = torchvision.datasets.MNIST(
root='./data',
train=True,
download=True,
)

# 获取前2个样本的原始数据和标签
data = train_dataset.data # 形状 (60000, 28, 28),uint8 0-255
targets = train_dataset.targets # 形状 (60000,)

# 提取前2个样本
img1_tensor = data[0] # 第1个样本:张量 (28, 28)
label1 = targets[0].item() # 第1个样本的标签(转成Python数字)
img2_tensor = data[1] # 第2个样本:张量 (28, 28)
label2 = targets[1].item() # 第2个样本的标签

# 格式转换:PyTorch 张量 → NumPy 数组(matplotlib 支持 NumPy 格式)
img1_np = img1_tensor.numpy()
img2_np = img2_tensor.numpy()

# 可视化前2个样本(并排显示)
plt.figure(figsize=(8, 4))

# 第1个样本
plt.subplot(1, 2, 1)
plt.imshow(img1_np, cmap='gray') # cmap='gray' 表示灰度图显示
plt.title(f'训练集第1个样本\n真实标签: {label1}')
plt.axis('off') # 隐藏坐标轴,更干净

# 第2个样本
plt.subplot(1, 2, 2)
plt.imshow(img2_np, cmap='gray')
plt.title(f'训练集第2个样本\n真实标签: {label2}')
plt.axis('off')

plt.tight_layout() # 自动调整子图间距
plt.show()

# 打印原始数据的关键信息(帮助理解)
print("="*50)
print("训练集第1个样本信息:")
print(f"张量形状: {img1_tensor.shape} → (高, 宽)(无通道维度,原始灰度图)")
print(f"数据类型: {img1_tensor.dtype} → uint8(0-255 整数像素值)")
print(f"像素值范围: [{img1_tensor.min()}, {img1_tensor.max()}]")
print(f"真实标签: {label1}")

print("\n训练集第2个样本信息:")
print(f"张量形状: {img2_tensor.shape}")
print(f"像素值范围: [{img2_tensor.min()}, {img2_tensor.max()}]")
print(f"真实标签: {label2}")
print("="*50)

image

等待下载完成即可

数据处理

这一步我们加载数据,然后获取我们想要的输入

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
import torch
import torchvision
import torchvision.transforms as transforms

transform = transforms.Compose([
transforms.ToTensor(), # 核心:图片 → Tensor
])

# 2. 加载训练集(download=True 首次运行会自动下载)
train_dataset = torchvision.datasets.MNIST(
root='./data', # 数据保存路径
train=True, # True=训练集,False=测试集
download=True,
transform=transform
)

# 3. 加载测试集
test_dataset = torchvision.datasets.MNIST(
root='./data',
train=False,
download=True,
transform=transform
)

# 4. 批量加载数据(DataLoader 自动打乱、分批,提升训练效率)
batch_size = 64 # 每批处理 64 张图片
train_loader = torch.utils.data.DataLoader(
train_dataset,
batch_size=batch_size,
shuffle=True # 训练集打乱,增加泛化性
)
test_loader = torch.utils.data.DataLoader(
test_dataset,
batch_size=batch_size,
shuffle=False # 测试集无需打乱
)

# 验证数据:查看一批数据的形状
images, labels = next(iter(train_loader))
print("单批图片形状:", images.shape) # 输出 (64, 1, 28, 28) → (批量数, 通道数, 高, 宽)
print("单批标签形状:", labels.shape) # 输

这样就完成了我们所有的准备步骤

搭建模型

这里我们从最简单的 MLP 开始

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
import torch
import torch.nn as nn
import torch.nn.functional as F

class MLP(nn.Module):
def __init__(self):
super(MLP, self).__init__()
# 输入层:28×28 图片 → 展平为 784 维向量
self.fc1 = nn.Linear(28*28, 256) # 第一层全连接:784 → 256(隐藏层神经元数)
self.fc2 = nn.Linear(256, 128) # 第二层全连接:256 → 128
self.fc3 = nn.Linear(128, 10) # 输出层:128 → 10(对应 0-9 共 10 类)

def forward(self, x):
# 前向传播:定义数据流动路径
x = x.view(-1, 28*28) # 展平:(batch, 1, 28, 28) → (batch, 784)
x = F.relu(self.fc1(x)) # 激活函数 ReLU(引入非线性)
x = F.relu(self.fc2(x))
x = self.fc3(x) # 输出层无需激活(后续用交叉熵损失,内置 Softmax)
return x

# 初始化模型
model = MLP()
# 移到 GPU(如果可用)
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
model.to(device)
print(model) # 打印模型结构,确认无误

最终模型为

1
2
3
4
5
MLP(
(fc1): Linear(in_features=784, out_features=256, bias=True)
(fc2): Linear(in_features=256, out_features=128, bias=True)
(fc3): Linear(in_features=128, out_features=10, bias=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
import torch.optim as optim

# 1. 定义损失函数和优化器
criterion = nn.CrossEntropyLoss() # 分类问题专用损失(内置 Softmax)
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9) # SGD 优化器,学习率 0.01

# 2. 训练循环(epoch:遍历整个训练集的次数,这里设 5 次)
num_epochs = 5
for epoch in range(num_epochs):
model.train() # 切换到训练模式(启用 Dropout/BatchNorm 等)
running_loss = 0.0 # 记录当前 epoch 的总损失

# 遍历训练集的每一批数据
for i, (images, labels) in enumerate(train_loader, 0):
# 数据移到 GPU/CPU
images, labels = images.to(device), labels.to(device)

# 关键步骤:
optimizer.zero_grad() # 清空上一批的梯度(避免累积)
outputs = model(images) # 前向传播:模型预测
loss = criterion(outputs, labels) # 计算损失
loss.backward() # 反向传播:计算梯度
optimizer.step() # 更新参数:梯度下降

# 打印训练进度
running_loss += loss.item()
if i % 100 == 99: # 每 100 批打印一次
print(f'Epoch [{epoch+1}/{num_epochs}], Batch [{i+1}/{len(train_loader)}], Loss: {running_loss/100:.4f}')
running_loss = 0.0

print('训练完成!')

运行之后的模型可以进行保存和载入

1
2
3
4
# 保存模型
torch.save(model.state_dict(), 'digit_recognition_model.pth')
# 加载模型
model.load_state_dict(torch.load('digit_recognition_model.pth'))

评估模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
model.eval()  # 切换到评估模式
correct = 0 # 正确预测的样本数
total = 0 # 总样本数

with torch.no_grad(): # 关闭梯度计算
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images) # 前向传播预测
_, predicted = torch.max(outputs.data, 1) # 取预测概率最大的类别(outputs 形状:(batch, 10))
total += labels.size(0) # 累计总样本数
correct += (predicted == labels).sum().item() # 累计正确数

# 计算准确率
accuracy = 100 * correct / total
print(f'测试集准确率: {accuracy:.2f}%')
1
测试集准确率: 97.20%

验证运行

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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
import tkinter as tk
from tkinter import messagebox
from PIL import Image, ImageDraw
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision.transforms as transforms
import matplotlib.pyplot as plt

# -------------------------- 1. 配置参数(需根据你的训练情况修改)--------------------------
# 模型路径(替换为你训练时保存的模型路径)
MODEL_PATH = "digit_recognition_model.pth"
# 模型类型(选择你训练的模型:MLP 或 CNN)
MODEL_TYPE = "MLP" # 可选 "MLP" 或 "CNN"
# 设备配置(M 系列 Mac 用 MPS,其他用 CPU)
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")


# -------------------------- 2. 定义模型结构(必须和训练时完全一致!)--------------------------
# 多层感知机(MLP)
class MLP(nn.Module):
def __init__(self):
super(MLP, self).__init__()
self.fc1 = nn.Linear(28 * 28, 256)
self.fc2 = nn.Linear(256, 128)
self.fc3 = nn.Linear(128, 10)

def forward(self, x):
x = x.view(-1, 28 * 28) # 展平
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
return self.fc3(x)


# -------------------------- 3. 加载训练好的模型--------------------------
def load_model():
# 初始化模型(和训练时一致)
if MODEL_TYPE == "MLP":
model = MLP().to(device)
else:
raise ValueError("MODEL_TYPE 只能是 'MLP'")

# 加载模型参数
try:
model.load_state_dict(torch.load(MODEL_PATH, map_location=device))
model.eval() # 切换到评估模式
print(f"模型加载成功!使用设备:{device}")
return model
except FileNotFoundError:
messagebox.showerror("错误", f"未找到模型文件:{MODEL_PATH}\n请先训练模型并保存,或修改 MODEL_PATH")
exit()


# 加载模型(程序启动时执行)
model = load_model()


# -------------------------- 4. 图像预处理函数(关键:和训练时一致)--------------------------
def preprocess_image(image):
"""
输入:PIL 图像(300×300 白底黑字)
输出:模型可接收的 Tensor((1, 1, 28, 28))
"""
# 定义预处理流程
transform_list = [
transforms.Resize((28, 28)), # 缩放到 28×28
transforms.ToTensor(), # 转 Tensor(0-255 → 0-1)
]

transform = transforms.Compose(transform_list)

# 预处理步骤
img = image.convert("L") # 转灰度图
img = img.point(lambda x: 255 - x) # 反色:白底黑字 → 黑底白字(匹配 MNIST 格式)
img_tensor = transform(img).unsqueeze(0) # 添加 batch 维度 (1, 1, 28, 28)
return img_tensor.to(device), img # 返回 Tensor 和预处理后的 PIL 图(用于可视化)


# -------------------------- 5. 模型推理函数--------------------------
def predict_digit(image):
"""输入手写图像,返回预测结果"""
img_tensor, processed_img = preprocess_image(image)

with torch.no_grad(): # 关闭梯度计算
output = model(img_tensor)
pred_label = torch.max(output, 1)[1].item() # 取概率最大的标签
pred_prob = torch.softmax(output, 1)[0][pred_label].item() # 计算预测概率

return pred_label, pred_prob, processed_img


# -------------------------- 6. Tkinter GUI 手写画布--------------------------
class HandwritingApp:
def __init__(self, root):
self.root = root
self.root.title("手写数字识别(28×28 MNIST 风格)")
self.root.geometry("400x400") # 窗口大小

# 画布配置(300×300,白色背景)
self.canvas = tk.Canvas(root, width=300, height=300, bg="white", bd=2, relief=tk.SUNKEN)
self.canvas.pack(pady=20)

# 按钮配置
self.btn_frame = tk.Frame(root)
self.btn_frame.pack()

self.predict_btn = tk.Button(self.btn_frame, text="识别数字", command=self.on_predict, font=("Arial", 12))
self.predict_btn.grid(row=0, column=0, padx=10)

self.clear_btn = tk.Button(self.btn_frame, text="清空画布", command=self.on_clear, font=("Arial", 12))
self.clear_btn.grid(row=0, column=1, padx=10)

# 绘制状态
self.drawing = False
self.last_x = None
self.last_y = None

# 创建 PIL 图像和绘图对象(用于保存画布内容)
self.pil_image = Image.new("RGB", (300, 300), "white")
self.draw = ImageDraw.Draw(self.pil_image)

# 绑定鼠标事件
self.canvas.bind("<Button-1>", self.start_drawing) # 鼠标按下
self.canvas.bind("<B1-Motion>", self.draw_line) # 鼠标拖动
self.canvas.bind("<ButtonRelease-1>", self.stop_drawing) # 鼠标释放

def start_drawing(self, event):
"""开始绘制(鼠标按下)"""
self.drawing = True
self.last_x = event.x
self.last_y = event.y

def draw_line(self, event):
"""绘制线条(鼠标拖动)"""
if self.drawing:
# 在 Tkinter 画布上画(可视化)
self.canvas.create_line(
self.last_x, self.last_y, event.x, event.y,
fill="black", width=15, capstyle=tk.ROUND, smooth=tk.TRUE
)
# 在 PIL 图像上画(用于后续保存和预处理)
self.draw.line(
[(self.last_x, self.last_y), (event.x, event.y)],
fill="black", width=15, joint="round"
)
# 更新最后坐标
self.last_x = event.x
self.last_y = event.y

def stop_drawing(self, event):
"""停止绘制(鼠标释放)"""
self.drawing = False

def on_clear(self, ):
"""清空画布"""
self.canvas.delete("all") # 清空 Tkinter 画布
self.pil_image = Image.new("RGB", (300, 300), "white") # 重置 PIL 图像
self.draw = ImageDraw.Draw(self.pil_image)

def on_predict(self):
"""识别数字(点击按钮触发)"""
try:
# 推理手写数字
pred_label, pred_prob, processed_img = predict_digit(self.pil_image)

# 显示结果(弹窗 + 可视化预处理后的 28×28 图)
messagebox.showinfo(
"预测结果",
f"预测数字:{pred_label}\n预测概率:{pred_prob:.2%}"
)

# 可视化预处理后的 28×28 图(和 MNIST 格式一致)
plt.figure(figsize=(4, 4))
plt.imshow(processed_img, cmap="gray")
plt.title(f"预处理后(28×28)→ 预测:{pred_label}")
plt.axis("off")
plt.show()

except Exception as e:
messagebox.showerror("错误", f"识别失败:{str(e)}")


# -------------------------- 7. 运行 GUI 程序--------------------------
if __name__ == "__main__":
root = tk.Tk()
app = HandwritingApp(root)
root.mainloop()

优化点

CNN

使用 CNN 处理 图片是比 MLP 更高效的方式 Convolutional Neural Networks Explained (CNN Visualized)

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
import torch
import torch.nn as nn
import torch.nn.functional as F


class CNN(nn.Module):
def __init__(self):
super(CNN, self).__init__()
# 卷积层 1:输入 1 通道 → 32 通道,卷积核 3×3,步长 1, padding 1(保持尺寸不变)
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, stride=1, padding=1)
# 池化层 1:最大池化 2×2(尺寸减半:28→14)
self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
# 卷积层 2:32 通道 → 64 通道
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1)
# 全连接层 1:64 通道×7×7(池化后尺寸)→ 128 维
self.fc1 = nn.Linear(64 * 7 * 7, 128)
self.fc2 = nn.Linear(128, 10)

def forward(self, x):
x = self.pool(F.relu(self.conv1(x))) # conv1 → ReLU → pool
x = self.pool(F.relu(self.conv2(x))) # conv2 → ReLU → pool(尺寸再减半:14→7)
x = x.view(-1, 64 * 7 * 7) # 展平:(batch, 64, 7, 7) → (batch, 64*7*7)
x = F.relu(self.fc1(x))
x = self.fc2(x)
return x

不得不说 CNN 真的很厉害,成功率非常的高。

归一化

原始 MNIST 图片的像素值范围是 [0, 1](因为前面用了 ToTensor(),把 0-255 的灰度值除以 255 转成了 0-1),但不同图片的亮度分布可能差异较大(比如有的图整体偏暗,像素值集中在 0.2 左右;有的偏亮,集中在 0.8 左右)。归一化后,所有图片的像素值都会集中在 [-1, 1] 附近,模型的权重更新时梯度不会 “忽大忽小”,能更快找到最优解。

修改训练的时候

1
2
3
4
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)) # MNIST 标准归一化
])

transforms.Normalize(mean, std) 的核心作用是 将像素值标准化为「均值≈0、标准差≈1」的分布,公式是:
归一化后像素值 = (原始像素值 - 均值) / 标准差

这两个数是怎么来的?—— 统计出来的
不是凭空捏造,而是对 MNIST 训练集的所有像素值做了全局统计:
把 MNIST 6 万张训练图,每张都展平成 784 个像素(28×28),总共得到 60000×784 = 47,040,000 个像素值;

  • 计算这 4700 万个像素值的 平均值 → 得到 0.1307;
  • 计算这 4700 万个像素值的 标准差 → 得到 0.3081

手写数字识别(28×28-MNIST-风格)-2025-11-30-23:21:10

image

最近读了这本书,确实非常的不错,将 GPT 拆解了,虽然只是一个比较早期的 GPT-2 但是五脏俱全。

阅读全文 »

image

模型上下文协议(MCP)基于一种灵活、可扩展的架构构建,该架构能够实现 LLM 应用程序与集成之间的无缝通信。

阅读全文 »

2025.4.18,我的奶奶去世了,享年 87 岁,大概是去年查出来了食道癌,一开始吃一些靶向药还有效果,也没有过多久就产生了耐药性,从发现到离世大概也就是只有不到一年的时间。
最开始奶奶仅有腹部不适,去医院检查,医生认为老人年纪很大了,不宜开刀和化疗。给予了一些临终关怀。

阅读全文 »

image

写在最前面: 网上大部分的教程可能是过时的,headscale 内置了 DERP 服务,只需要开启即可,并且很多恩山的教程写的比较 freestyle。

阅读全文 »
0%