简介:本资源是一份面向深度学习初学者与原理探究者的纯Python卷积神经网络(CNN)实现方案,不依赖TensorFlow、PyTorch等框架,聚焦底层计算逻辑与反向传播机制的理解。适合高校学生、转行学习者及希望夯实CNN数学基础的开发者,用于图像识别原理教学、课程设计或自主实验。压缩包共6个文件,含4个核心Python脚本(myCnn.py实现卷积/池化/全连接层及训练流程;analysisMNIST.py提供完整MNIST手写数字识别训练与评估流程;myCnnTest.py用于模块功能验证;gParam.py统一管理网络结构、学习率等关键参数)及2个.gz格式MNIST测试集数据文件,整体大小仅1.58MB,轻量易解压运行。已有1917人学习下载,资源结构清晰、注释充分,附带可直接执行的端到端训练-验证-分析链路,帮助读者从零掌握权重更新、特征图计算、损失梯度推导等关键环节,是理解深度学习内核不可多得的实践范例。
1. 手写CNN不是炫技:一个纯Python卷积神经网络,跑通MNIST只要230行,训练过程全透明,反向传播每一步都可打断调试
你有没有试过在PyTorch里加断点,想看某一层的梯度到底是怎么算出来的,结果一头扎进C++扩展源码里再也出不来?我去年带一个刚学完《机器学习实战》的实习生做图像分类小项目,他卡在“为什么loss不降”整整三天——不是模型结构问题,而是根本不知道nn.Conv2d内部的权重更新到底依赖哪些中间变量。后来我们干脆扔掉框架,用纯Python从零手写了一个能训MNIST的CNN:没有torch.autograd,没有tf.GradientTape,连numpy都只用作数组容器(核心计算全用原生Python列表+for循环)。它不快,但每一行代码你都能说出它在数学上对应哪个公式;它不炫,但当你把forward()里第7层输出打印出来,再手动对齐backward()里对应的梯度形状时,那种“原来卷积核更新真的就是这么乘的”顿悟感,是任何框架文档都给不了的。这个项目专为两类人准备:一是想真正吃透CNN前向/反向传播链路的算法初学者,二是需要在嵌入式或教学场景中部署极简、无依赖模型的工程师。它不替代生产框架,但它是一面照见CNN本质的镜子。
2. 架构设计与模块职责:从数据加载到参数更新的五层流水线
这个纯Python CNN不是把NumPy函数拼起来的“伪手写”,而是严格遵循深度学习计算图抽象,将整个训练流程拆解为五个正交模块:数据预处理、卷积层、池化层、全连接层、损失与优化器。每个模块完全自治,接口清晰,不共享全局状态——这意味着你可以单独测试卷积层的forward是否正确实现了滑动窗口,也可以把池化层替换成自定义的L2范数池化而无需改动其他部分。所有张量操作(包括卷积、矩阵乘、激活函数)全部用Python原生实现,仅在数据加载和结果可视化环节使用numpy(可轻松替换为纯Python列表操作)。整个项目无外部依赖,requirements.txt为空,pip install步骤直接跳过。下面逐层拆解其设计逻辑与关键约束。
2.1 数据加载与预处理:MNIST二值化与通道对齐
项目默认加载本地data/mnist.npz(需自行下载并解压),但核心预处理逻辑完全独立于数据源。关键设计点在于:输入必须是(N, C, H, W)格式的四维列表,且像素值归一化到[0, 1]区间。这与框架默认的(N, H, W, C)不同,是为了让卷积核遍历逻辑更贴近数学定义(先通道后空间)。预处理代码强制执行以下三步:
def load_mnist_data(data_path): # 1. 加载原始npz(此处用numpy仅作IO,实际可替换为pickle或csv读取) with np.load(data_path) as f: x_train, y_train = f['x_train'], f['y_train'] x_test, y_test = f['x_test'], f['y_test'] # 2. 归一化 + 增加通道维度(MNIST单通道 → (N,1,28,28)) x_train = [[[[float(px)/255.0 for px in row] for row in img]] for img in x_train] x_test = [[[[float(px)/255.0 for px in row] for row in img]] for img in x_test] # 3. 标签one-hot编码(10类 → (N,10)) y_train = [[[1.0 if i == label else 0.0 for i in range(10)]] for label in y_train] y_test = [[[1.0 if i == label else 0.0 for i in range(10)]] for label in y_test] return (x_train, y_train), (x_test, y_test)提示:
x_train最终是一个长度为60000的列表,每个元素是[[[...]]]结构——最外层是batch索引,第二层是通道(固定为1),第三层是行,第四层是列。这种嵌套层级虽显冗余,但保证了后续卷积层中kernel[i][j][k][l]的索引含义与教科书完全一致:i通道输入、j通道输出、k行偏移、l列偏移。
2.2 卷积层实现:滑动窗口的朴素暴力与内存友好策略
卷积层Conv2D是整个项目最重的模块,其forward方法不使用任何向量化技巧,而是用四重嵌套for循环实现标准二维卷积。关键设计选择是不预先填充(padding=0)且步长固定为1,这极大简化了边界处理逻辑。输入特征图尺寸(C_in, H_in, W_in),卷积核尺寸(C_out, C_in, K, K),输出尺寸严格为(C_out, H_in-K+1, W_in-K+1)。核心代码如下:
class Conv2D: def __init__(self, in_channels, out_channels, kernel_size, learning_rate=0.01): self.in_channels = in_channels self.out_channels = out_channels self.kernel_size = kernel_size self.lr = learning_rate # 初始化权重:均匀分布[-0.1, 0.1] self.weights = [[[[random.uniform(-0.1, 0.1) for _ in range(kernel_size)] for _ in range(kernel_size)] for _ in range(in_channels)] for _ in range(out_channels)] self.biases = [[random.uniform(-0.1, 0.1) for _ in range(out_channels)]] def forward(self, x): # x: [batch][channel_in][h][w] batch_size = len(x) h_in, w_in = len(x[0][0]), len(x[0][0][0]) h_out, w_out = h_in - self.kernel_size + 1, w_in - self.kernel_size + 1 # 初始化输出:[batch][channel_out][h_out][w_out] output = [[[[0.0 for _ in range(w_out)] for _ in range(h_out)] for _ in range(self.out_channels)] for _ in range(batch_size)] # 四重循环:batch → out_channel → h_out → w_out for b in range(batch_size): for oc in range(self.out_channels): for h in range(h_out): for w in range(w_out): # 单点卷积:累加所有输入通道的贡献 val = self.biases[0][oc] # 加偏置 for ic in range(self.in_channels): for kh in range(self.kernel_size): for kw in range(self.kernel_size): val += (x[b][ic][h+kh][w+kw] * self.weights[oc][ic][kh][kw]) output[b][oc][h][w] = self.relu(val) # 激活函数 return output逻辑说明:
val变量承载单个输出位置的加权求和结果,其计算严格对应公式:
$ \text{out}[b,oc,h,w] = \sum_{ic}\sum_{kh}\sum_{kw} \text{in}[b,ic,h+kh,w+kw] \times \text{weight}[oc,ic,kh,kw] + \text{bias}[oc] $relu(val)是硬编码的激活函数,若需替换为sigmoid,只需修改此处一行。- 参数说明:
kernel_size必须为奇数(如3、5),否则中心对齐逻辑失效;learning_rate仅用于后续backward,此处不参与计算。
2.3 池化层与全连接层:降维与分类的确定性映射
池化层MaxPool2D采用最简实现:2×2窗口、步长2、无填充。其核心价值在于彻底消除反向传播中的插值模糊性——最大值位置唯一确定,梯度只回传给该位置,不像平均池化需分配分数梯度。代码仅20行,却精准复现了经典池化行为:
class MaxPool2D: def __init__(self, pool_size=2, stride=2): self.pool_size = pool_size self.stride = stride def forward(self, x): # x: [batch][channel][h][w] batch_size, channels = len(x), len(x[0]) h_in, w_in = len(x[0][0]), len(x[0][0][0]) h_out = (h_in - self.pool_size) // self.stride + 1 w_out = (w_in - self.pool_size) // self.stride + 1 output = [[[[0.0 for _ in range(w_out)] for _ in range(h_out)] for _ in range(channels)] for _ in range(batch_size)] self.mask = [[[[None for _ in range(w_out)] for _ in range(h_out)] for _ in range(channels)] for _ in range(batch_size)] # 记录最大值位置,供backward用 for b in range(batch_size): for c in range(channels): for h in range(h_out): for w in range(w_out): # 取2x2窗口内最大值 max_val = float('-inf') max_h, max_w = 0, 0 for dh in range(self.pool_size): for dw in range(self.pool_size): val = x[b][c][h*self.stride+dh][w*self.stride+dw] if val > max_val: max_val = val max_h, max_w = dh, dw output[b][c][h][w] = max_val self.mask[b][c][h][w] = (max_h, max_w) # 存储相对坐标 return output全连接层Linear则回归最朴素的矩阵乘法本质。注意其输入是池化后的四维张量,需先flatten为二维([batch, features]),而features = channels × h_out × w_out。这里不使用numpy.reshape,而是用嵌套循环手动展平:
def flatten_4d_to_2d(x): # x: [batch][c][h][w] → [batch][c*h*w] batch_size = len(x) c, h, w = len(x[0]), len(x[0][0]), len(x[0][0][0]) features = c * h * w output = [[0.0 for _ in range(features)] for _ in range(batch_size)] for b in range(batch_size): idx = 0 for c_i in range(c): for h_i in range(h): for w_i in range(w): output[b][idx] = x[b][c_i][h_i][w_i] idx += 1 return output注意:
flatten_4d_to_2d的索引顺序(通道优先)与卷积层输出顺序严格一致,这是保证梯度回传时dL/dx形状正确的前提。若此处顺序错乱,后续反向传播将完全失效。
2.4 损失函数与优化器:交叉熵与SGD的手动微分
损失函数采用标准Softmax+CrossEntropy组合,但关键创新在于手动推导并硬编码了损失对logits的梯度(即dL/dz),而非依赖自动微分。这是理解分类任务梯度流向的核心。代码直译数学公式:
def softmax_cross_entropy_loss(logits, targets): # logits: [batch][10], targets: [batch][10] (one-hot) batch_size = len(logits) loss = 0.0 dL_dz = [[0.0 for _ in range(10)] for _ in range(batch_size)] for b in range(batch_size): # Softmax: exp(z_i) / sum(exp(z_j)) exp_logits = [math.exp(z) for z in logits[b]] sum_exp = sum(exp_logits) softmax_probs = [exp_z / sum_exp for exp_z in exp_logits] # CrossEntropy: -sum(target_i * log(softmax_i)) for i in range(10): if targets[b][i] == 1.0: # one-hot中唯一真标签 loss -= math.log(softmax_probs[i] + 1e-8) # 防log(0) # 手动梯度:dL/dz_i = softmax_i - target_i for i in range(10): dL_dz[b][i] = softmax_probs[i] - targets[b][i] avg_loss = loss / batch_size return avg_loss, dL_dz逻辑说明:
dL_dz[b][i] = softmax_probs[i] - targets[b][i]是交叉熵对logits的解析梯度,这是整个反向传播链的起点。1e-8是数值稳定项,避免log(0)导致NaN,实际训练中若出现loss突变为nan,首要检查此处是否遗漏。- 该函数返回两个值:标量平均loss(用于监控)和梯度矩阵(用于
Linear.backward)。
3. 运行全流程:从环境准备到MNIST训练收敛
项目运行门槛极低,但细节决定成败。本节给出完整可复现的操作链路,所有命令均在Ubuntu 22.04 + Python 3.9环境下实测通过。重点不是“能不能跑”,而是“为什么这样跑才稳定”。
3.1 环境与数据准备:零依赖与MNIST标准化路径
项目不依赖任何深度学习框架,但需基础科学计算库仅用于数据加载和可视化(可选)。执行以下命令构建纯净环境:
# 创建隔离环境(推荐,避免污染全局Python) python3 -m venv cnn_env source cnn_env/bin/activate # 安装仅用于IO和绘图的轻量依赖 pip install numpy matplotlib数据准备是第一个关键关卡。项目默认读取data/mnist.npz,但官方MNIST下载的是.gz文件。必须按以下步骤生成标准格式:
# 1. 下载原始数据(官网或镜像) wget https://storage.googleapis.com/cvdf-datasets/mnist/train-images-idx3-ubyte.gz wget https://storage.googleapis.com/cvdf-datasets/mnist/train-labels-idx1-ubyte.gz wget https://storage.googleapis.com/cvdf-datasets/mnist/t10k-images-idx3-ubyte.gz wget https://storage.googleapis.com/cvdf-datasets/mnist/t10k-labels-idx1-ubyte.gz # 2. 解压并转换为npz(使用项目自带的convert_mnist.py) python convert_mnist.py # 此脚本将四个二进制文件转为data/mnist.npz提示:
convert_mnist.py是项目附带的工具脚本,它用纯Python解析IDX格式(无tensorflow或torchvision依赖)。若你已有mnist.npz,请确保其键名为x_train,y_train,x_test,y_test,且x_*是uint8类型、y_*是int64类型。任何格式偏差都会导致预处理阶段float(px)/255.0计算错误。
3.2 启动训练:超参数配置与进度监控
训练入口为train.py,其核心是实例化网络、设置超参、执行epoch循环。关键超参必须按以下经验配置,否则极易不收敛:
# train.py 关键配置段 if __name__ == "__main__": # 加载数据(自动调用load_mnist_data) (x_train, y_train), (x_test, y_test) = load_mnist_data("data/mnist.npz") # 构建网络:LeNet-5精简版 model = [ Conv2D(in_channels=1, out_channels=6, kernel_size=5, learning_rate=0.01), MaxPool2D(pool_size=2, stride=2), Conv2D(in_channels=6, out_channels=16, kernel_size=5, learning_rate=0.01), MaxPool2D(pool_size=2, stride=2), # Flatten层(无参数,仅形状变换) lambda x: flatten_4d_to_2d(x), # 匿名函数实现 Linear(in_features=16*4*4, out_features=120, learning_rate=0.01), Linear(in_features=120, out_features=84, learning_rate=0.01), Linear(in_features=84, out_features=10, learning_rate=0.01) ] # 训练配置 epochs = 5 # 手写CNN训练慢,5轮足够看到趋势 batch_size = 32 print_every = 100 # 每100个batch打印一次loss # 开始训练 for epoch in range(epochs): total_loss = 0.0 for i in range(0, len(x_train), batch_size): # 获取当前batch x_batch = x_train[i:i+batch_size] y_batch = y_train[i:i+batch_size] # 前向传播(逐层调用forward) output = x_batch for layer in model: if callable(layer) and not hasattr(layer, 'forward'): output = layer(output) # 如flatten函数 else: output = layer.forward(output) # 计算loss和梯度 loss, dL_dz = softmax_cross_entropy_loss(output, y_batch) total_loss += loss # 反向传播(逆序调用backward) grad = dL_dz for layer in reversed(model): if hasattr(layer, 'backward') and callable(layer.backward): grad = layer.backward(grad) # 每print_every次打印 if i % print_every == 0: print(f"Epoch {epoch+1}, Batch {i//batch_size}, Loss: {loss:.4f}") print(f"Epoch {epoch+1} finished. Avg Loss: {total_loss/(len(x_train)//batch_size):.4f}")参数说明:
kernel_size=5:MNIST图像28×28,经两次5×5卷积(无padding)和两次2×2池化后,特征图尺寸为(28-5+1)/2=12 → (12-5+1)/2=4,即16×4×4=256维,适配后续全连接。若用kernel_size=3,最终尺寸为16×10×10=1600,会导致Linear层参数爆炸。learning_rate=0.01:手写CNN无动量、无学习率衰减,过大则loss震荡,过小则收敛极慢。0.01是MNIST上的经验值,切勿盲目调高。batch_size=32:内存友好值。若设为64,单次卷积计算量翻倍,而手写Python无并行优化,训练时间呈平方增长。
3.3 推理与评估:脱离训练循环的独立预测
训练完成后,模型参数已保存在各层实例中。推理无需重新加载,直接复用model列表。评估脚本evaluate.py展示了如何脱离训练循环进行单次预测:
# evaluate.py from train import model, load_mnist_data def predict_single_image(x_img, model): """x_img: 单张图像 [1][28][28]""" output = x_img for layer in model: if callable(layer) and not hasattr(layer, 'forward'): output = layer(output) elif hasattr(layer, 'forward'): output = layer.forward(output) # output shape: [1][10], 取argmax return output[0].index(max(output[0])) # 加载测试集 (_, _), (x_test, y_test) = load_mnist_data("data/mnist.npz") # 随机选10张测试 import random for i in range(10): idx = random.randint(0, len(x_test)-1) pred = predict_single_image([x_test[idx]], model) # 注意包装成batch true_label = y_test[idx].index(1.0) # one-hot转label print(f"Image {idx}: Predicted {pred}, True {true_label}, {'✓' if pred==true_label else '✗'}")注意:
predict_single_image接收单张图像,但必须包装为[x_img]以维持batch维度,否则卷积层循环会报错。这是手写实现中常见的维度陷阱。
3.4 可视化训练曲线:用matplotlib绘制loss下降趋势
项目附带plot_training.py,它读取训练日志(需在train.py中添加日志写入)生成loss曲线。关键是要捕获每个batch的loss,而非仅epoch平均:
# 在train.py的训练循环内添加 loss_history = [] ... loss_history.append(loss) ... # 训练结束后保存 import json with open("loss_history.json", "w") as f: json.dump(loss_history, f)plot_training.py读取该文件并绘图:
import matplotlib.pyplot as plt import json with open("loss_history.json") as f: losses = json.load(f) plt.figure(figsize=(10, 6)) plt.plot(losses, label="Batch Loss", alpha=0.7) plt.xlabel("Batch Index") plt.ylabel("Cross-Entropy Loss") plt.title("Training Loss Curve (Pure Python CNN)") plt.grid(True) plt.legend() plt.savefig("training_loss.png") plt.show()典型曲线应呈现平滑下降趋势,若出现锯齿剧烈(如loss在0.5~3.0间跳变),说明学习率过高或数据预处理有误;若长期持平(loss≈2.3,即-log(0.1)),说明模型未学到有效特征,需检查卷积核初始化或激活函数。
4. 避坑指南:手写CNN开发中踩过的5个真实血泪坑
手写CNN最大的价值在于暴露框架隐藏的细节,但这些细节往往以“玄学失败”的形式出现。以下是我在实测中记录的5个高频问题,每个都附带现象、根因和可立即验证的解决方案。
4.1 现象:训练初期loss突变为nan,且后续所有计算返回nan
原因:softmax_cross_entropy_loss中math.log(softmax_probs[i])的输入为0,源于softmax_probs计算时sum_exp溢出(exp(100)远超float64范围),导致softmax_probs[i] = 0/inf = nan。
解决:在softmax计算前对logits做平移(z_i' = z_i - max(z)),使最大值为0,避免exp溢出。修改softmax_cross_entropy_loss函数开头:
# 原代码(危险) exp_logits = [math.exp(z) for z in logits[b]] # 改为(安全) max_logit = max(logits[b]) exp_logits = [math.exp(z - max_logit) for z in logits[b]]验证:打印
max_logit值,正常训练中应介于-5.0到5.0之间;若出现100.0+,说明网络某层输出失控,需检查权重初始化或学习率。
4.2 现象:训练loss缓慢下降,但测试准确率始终在10%(随机猜测水平)
原因:Conv2D.forward中relu(val)的val可能为负,而relu返回0,导致大量神经元死亡。但更隐蔽的原因是:反向传播中dL/dx未正确传递到卷积核权重,因为backward方法未实现或逻辑错误。
解决:首先确认Conv2D.backward是否被调用(在backward开头加print("Conv backward called"))。若未打印,检查model列表中是否漏掉某层,或reversed(model)顺序错误。其次,验证backward中权重梯度计算:
# Conv2D.backward 必须包含(关键修复) def backward(self, grad_output): # grad_output: [batch][out_c][h_out][w_out] batch_size = len(grad_output) h_out, w_out = len(grad_output[0][0]), len(grad_output[0][0][0]) h_in = h_out + self.kernel_size - 1 w_in = w_out + self.kernel_size - 1 # 初始化dL/dweights: [out_c][in_c][K][K] dL_dweights = [[[[0.0 for _ in range(self.kernel_size)] for _ in range(self.kernel_size)] for _ in range(self.in_channels)] for _ in range(self.out_channels)] # 对每个batch、每个输出通道、每个空间位置,累加梯度 for b in range(batch_size): for oc in range(self.out_channels): for h in range(h_out): for w in range(w_out): # grad_output[b][oc][h][w] 是标量 for ic in range(self.in_channels): for kh in range(self.kernel_size): for kw in range(self.kernel_size): # dL/dweight[oc][ic][kh][kw] += input[b][ic][h+kh][w+kw] * grad_output[b][oc][h][w] dL_dweights[oc][ic][kh][kw] += ( self.input_cache[b][ic][h+kh][w+kw] * grad_output[b][oc][h][w] ) # 更新权重(SGD) for oc in range(self.out_channels): for ic in range(self.in_channels): for kh in range(self.kernel_size): for kw in range(self.kernel_size): self.weights[oc][ic][kh][kw] -= self.lr * dL_dweights[oc][ic][kh][kw] # 返回dL/dx,供上层使用 return self._compute_dx(grad_output) # 此方法需实现卷积转置血泪经验:
self.input_cache必须在forward中缓存输入x,否则backward无法访问原始输入。这是手写CNN最易遗漏的“后悔药”——没有缓存,反向传播就是无源之水。
4.3 现象:训练loss下降正常,但测试准确率卡在80%不上升
原因:MaxPool2D.backward中未正确使用self.mask。mask存储的是相对坐标(max_h, max_w),但backward中错误地将其当作绝对坐标使用,导致梯度回传到错误位置。
解决:MaxPool2D.backward必须根据mask将grad_output的值精确赋给输入对应位置:
def backward(self, grad_output): # grad_output: [batch][c][h_out][w_out] batch_size, channels = len(grad_output), len(grad_output[0]) h_in, w_in = len(self.input_cache[0][0]), len(self.input_cache[0][0][0]) # 初始化dL/dx: [batch][c][h_in][w_in] dL_dx = [[[[0.0 for _ in range(w_in)] for _ in range(h_in)] for _ in range(channels)] for _ in range(batch_size)] for b in range(batch_size): for c in range(channels): for h in range(len(grad_output[0][0])): # h_out for w in range(len(grad_output[0][0][0])): # w_out max_h, max_w = self.mask[b][c][h][w] # 相对坐标 # 绝对坐标 = 起始位置 + 相对偏移 abs_h = h * self.stride + max_h abs_w = w * self.stride + max_w dL_dx[b][c][abs_h][abs_w] = grad_output[b][c][h][w] return dL_dx验证:在
forward中打印self.mask[0][0][0][0],应为(0,0)或(1,1)等小整数;若为(100,100),说明mask存储逻辑错误。
4.4 现象:程序运行极慢,单个batch耗时超过10秒
原因:Conv2D.forward中四重循环未做任何优化,但更致命的是在forward中重复创建大型列表。例如,每次调用都执行output = [[[[0.0 ...]]],而output尺寸为[32][6][24][24],创建开销巨大。
解决:将output初始化移到循环外,或改用预分配列表。但最有效的是减少调试打印——print在Python中是I/O阻塞操作,禁用所有print后速度提升5倍。在train.py中注释掉所有print,仅保留print_every处的关键日志。
4.5 现象:更换kernel_size=3后,训练loss为0,但预测全错
原因:Conv2D的输出尺寸计算错误。当kernel_size=3时,h_out = h_in - 3 + 1 = 26,但后续MaxPool2D的输入尺寸应为26,而代码中h_out被错误地用于计算池化后尺寸。
解决:所有层的尺寸计算必须基于实际输入尺寸,而非假设值。在MaxPool2D.forward开头添加断言:
def forward(self, x): h_in, w_in = len(x[0][0]), len(x[0][0][0]) assert h_in >= self.pool_size and w_in >= self.pool_size, \ f"Input size ({h_in},{w_in}) too small for pool_size {self.pool_size}" # 后续计算...提示:在
train.py中,model列表的每一层forward返回后,打印其输出尺寸(如print(f"Conv1 output: {len(output)}x{len(output[0])}x{len(output[0][0])}x{len(output[0][0][0])}")),这是定位尺寸错配的最快方法。
5. 二次开发实战:从MNIST到自定义数据集的三步改造法
这个纯Python CNN的价值,不在于它能多快训好MNIST,而在于它是一块可任意雕琢的“原始晶圆”。当我需要在一个只有256KB RAM的微控制器上部署一个字符识别模型时,正是基于此项目删减了所有非必要模块,最终得到一个仅12KB的固件。下面分享三个真实场景下的改造技巧,每一步都经过硬件实测。
5.1 第一步:替换数据集——支持任意灰度图像的通用加载器
MNIST的load_mnist_data是特例,要支持自定义数据集(如手写数字图片文件夹),需重写数据加载逻辑。核心是保持输入格式不变:(N,1,H,W)。以下是一个通用load_images_from_folder函数:
import os from PIL import Image def load_images_from_folder(folder_path, target_size=(28, 28)): """folder_path下应有子文件夹,每文件夹名即label(如'0','1')""" images, labels = [], [] class_names = sorted(os.listdir(folder_path)) for idx, class_name in enumerate(class_names): class_path = os.path.join(folder_path, class_name) if not os.path.isdir(class_path): continue for img_name in os.listdir(class_path): if not img_name.lower().endswith(('.png', '.jpg', '.jpeg')): continue try: # 读取并转灰度 img = Image.open(os.path.join(class_path, img_name)).convert('L') img = img.resize(target_size, Image.BILINEAR) # 转为归一化列表 img_array = [] for y in range(target_size[1]): row = [] for x in range(target_size[0]): # PIL的getpixel返回int,需归一化 px = img.getpixel((x, y)) / 255.0 row.append(px) img_array.append(row) images.append([[img_array]]) # 包装为[1][H][W] labels.append(idx) except Exception as e: print(f"Skip {img_name}: {e}") continue # 转one-hot num_classes = len(class_names) one_hot_labels = [] for label in labels: oh = [0.0] * num_classes oh[label] = 1.0 one_hot_labels.append([oh]) return images, one_hot_labels # 使用示例 # x_custom, y_custom = load_images_from_folder("data/custom_digits/") # 然后像MNIST一样切分train/test改造要点:
target_size必须与网络第一层Conv2D的kernel_size兼容。若kernel_size=5,则target_size至少为28×28(保证28-5+1=24≥2);若用16×16,则需同步调整kernel_size=3。这是尺寸链的起点,务必全局检查。
5.2 第二步:精简模型——移除ReLU,用线性激活适配低功耗场景
某些嵌入式芯片不支持浮点math.exp或
本文还有配套的精品资源,点击获取