简介:面向深度学习刚入门者的鱼书(《深度学习入门:基于Python的理论与实现》)配套源码增强包,作者在原始代码基础上添加了详细个人注释,并结合MNIST手写数字识别案例梳理神经网络搭建流程,适合希望通过动手实践快速跨越理论与代码鸿沟的初学者。压缩包共117个文件,约36.68MB,含63个Python脚本、9个Jupyter Notebook、1本教程PDF及环境配置与数据文件;其中py和ipynb覆盖从感知机到卷积网络的章节示例,pkl与gz为预训练权重和MNIST数据集,无需额外联网下载即可运行。该资源已有2061人学习下载,作者逐段拆解代码逻辑,对关键算法选择、损失计算、梯度更新等实现技巧补充说明,同时附environment.yml环境文件,帮助新手避免配置障碍。对照注解源码、Notebook与PDF教程,读者可以系统掌握数据加载、模型构建、训练评估的完整流程,适合自学与课堂辅助使用。
1. 带注解的入门源码:新手深度学习最缺的其实是这一步
刚接触深度学习的人,很容易从最经典的入门教材看起,概念记得一堆,一开编辑器却不知道从哪下手。这份源码包就是配着那本在校园里流传很广的入门书来的,代码齐整、按章节组织,更难得的是有人把关键位置都补上了中文注解。它不是多复杂的工程,而是把神经网络最小闭环拆开给你看:数据加载、激活函数、损失函数、反向传播、训练验证,全都有可以直接跑的版本。对刚入门的开发者和学生来说,跟着这个包过一遍,比对着书抄代码省下大量时间,还能提前看到哪些地方容易踩坑。这篇文章会按我实际拆包验证的顺序,把它的结构、使用方法、调参边界和常见问题一次讲完。
2. 先把代码骨架拆开:数据加载、激活函数、损失函数是三个闭环
2.1 数据加载:预处理之后的MNIST才是标准格式
这个源码包在第一课就把数据加载做好了封装,你不需要关心数据集怎么下载、怎么解压,直接调用加载函数就能获得训练集和测试集。这种设计对有经验的人来说是省事,对刚入门的人反而容易变成黑匣子,所以我习惯先把这一层剥开。
def load_data(normalize=True, flatten=True, one_hot_label=False): # 常见做法是先检查本地缓存目录,没有再触发下载 dataset_dir = os.path.join(os.path.dirname(__file__), 'dataset') if not os.path.exists(dataset_dir): os.makedirs(dataset_dir) # 这里会判断pickle缓存是否存在,存在就直接读取,避免二次解压 save_file = os.path.join(dataset_dir, "mnist.pkl") if os.path.exists(save_file): with open(save_file, 'rb') as f: dataset = pickle.load(f) else: # 首次运行才执行原始文件下载与预处理 dataset = _download_and_preprocess(dataset_dir) with open(save_file, 'wb') as f: pickle.dump(dataset, f, -1) if normalize: for key in ('train_img', 'test_img'): dataset[key] = dataset[key].astype(np.float32) dataset[key] /= 255.0 if flatten: # 把 28*28 的二维图像拉平成 784 维向量,方便后面做矩阵运算 for key in ('train_img', 'test_img'): dataset[key] = dataset[key].reshape(-1, 784) if not one_hot_label: # 标签不做one-hot时返回普通数组,做交叉熵时需要开成one-hot for key in ('train_label', 'test_label'): dataset[key] = np.argmax(dataset[key], axis=1) return (dataset['train_img'], dataset['train_label']), (dataset['test_img'], dataset['test_label'])这段代码有两个参数值得留意。第一个是normalize,它的作用是把像素值从 0 到 255 压缩到 0 到 1 区间。如果不做归一化,输入数值太大,梯度在反向传播时容易溢出,损失函数偶尔会直接变成 NaN,而且收敛速度明显变慢。第二个是one_hot_label,它为分类任务准备标签格式。做均方误差时可以不开,但做交叉熵损失时标签必须是 one-hot 形式,不然维度对不上,计算会直接报错。
我在第一次跑这个包时犯过一个小错,就是把flatten=False传进去,结果网络的前向传播里权重形状全乱了,报错信息指向矩阵乘法维度不匹配。后来我习惯在加载完数据后打印一次train_img.shape,看到(60000, 784)心里才踏实。这里的 60000 是训练样本数,784 是展平后的像素数,后面所有全连接层的输入维度都以它为基准。
2.2 激活函数实现:为什么既要ReLU也要sigmoid
源码包里把激活函数单独写成函数模块,这是入门阶段最容易看懂也最容易忽略的部分。很多初学者会想,不就是max(0, x)和1 / (1 + exp(-x))吗,有什么好研究的。实际上这两个函数的选择直接决定了网络能不能训练起来,以及训练速度差多少。
def sigmoid(x): # 数值稳定版本:先判断正负,避免指数运算时溢出 return np.where(x >= 0, 1.0 / (1.0 + np.exp(-x)), np.exp(x) / (1.0 + np.exp(x))) def relu(x): return np.maximum(0, x) def softmax(a): # 减去最大值再做指数,是防止溢出的标准手法 c = np.max(a) exp_a = np.exp(a - c) return exp_a / np.sum(exp_a)这里的sigmoid我特意用了分段写法,而不是教科书里最朴素的式子。原因很简单,当x是很大的负数时,np.exp(-x)会变成极大的正数,直接计算会溢出。深层的实践中,这个细节平时触发概率低,但一旦网络初始化不好,输入落到极端区域,就会看到损失变成 NaN,然后排查半天才发现是数值溢出的问题。
sigmoid有一个绕不开的缺点,就是饱和区的梯度几乎为 0,多层叠加之后梯度消失严重。所以后面几章的代码里,隐藏层默认都换成了relu,只有输出层用softmax做概率分布。relu的优点是正区间梯度恒为 1,反向传播时信号不容易衰减,但代价是负区间直接截断,某些神经元一旦被置 0 就很难恢复。源码注解里对这一点写得很清楚,我建议新手在改网络结构时记住一个原则:隐藏层优先用 ReLU,输出层做分类用 softmax,回归用恒等,别有心理负担。
2.3 损失函数连上梯度:反向传播的黑匣子从这一节打开
两层的神经网络,前向传播很好理解,反向传播才是入门路上的第一道坎。源码包把损失函数定义、梯度计算和参数更新拆成了三个独立方法,注释里还标了每一步的变量形状。我拆包之后第一件事就是把这些形状写下来,对照着看。
def loss(self, x, t): # 前向算一次,得到预测值 y = self.predict(x) # 交叉熵损失,t 是 one-hot 形式的监督标签 return cross_entropy_error(y, t) def gradient(self, x, t): # 数值微分法求梯度,简单但极慢;源码后面会替换成反向传播法 loss_W = lambda W: self.loss(x, t) grads = {} for key in ('W1', 'b1', 'W2', 'b2'): grads[key] = numerical_gradient(loss_W, self.params[key]) return grads上面这段是早期章节的数值微分版本,它的正确性毋庸置疑,但效率低到让人怀疑人生。每一轮参数更新都要对每个权重算两次前向,两层的网络跑几十轮就要等很久。源码在后面章节引入了误差反向传播,也就是backward方法,它和前向的过程刚好反过来,把损失对输出的梯度逐层往前传。
我排错时最常做的一件事,就是在反向传播之后顺手做一个梯度检查,用数值梯度和反向传播算出来的梯度对比,相对误差控制在 1e-7 以内。这个习惯帮我抓出过不少 bug,比如某个矩阵转置忘了写,某一步多乘了一个不该有的系数,都是肉眼根本发现不了的。新手直接跑这个包,建议也不要只盯着损失下降,把梯度检查当成标配步骤。
3. 从实机训练再到调参:两个网络跑通才算入门
3.1 两层神经网络的实现边界
源码包的核心是一个两层全连接网络,结构是输入层 784 维、隐藏层 50 个神经元、输出层 10 个神经元。这个规模对于 MNIST 这种标准数据集来说刚刚好,足够学到特征,又不会因为太深导致新手看不懂中间过程。
class TwoLayerNet: def __init__(self, input_size=784, hidden_size=50, output_size=10): # 权重按高斯分布初始化,标准差取 0.01,避免初始值过大导致梯度爆炸 self.params = {} self.params['W1'] = np.random.randn(input_size, hidden_size) * 0.01 self.params['b1'] = np.zeros(hidden_size) self.params['W2'] = np.random.randn(hidden_size, output_size) * 0.01 self.params['b2'] = np.zeros(output_size) def predict(self, x): a1 = np.dot(x, self.params['W1']) + self.params['b1'] z1 = relu(a1) a2 = np.dot(z1, self.params['W2']) + self.params['b2'] return softmax(a2) def accuracy(self, x, t): y = self.predict(x) y = np.argmax(y, axis=1) if t.ndim != 1: t = np.argmax(t, axis=1) return np.sum(y == t) / float(x.shape[0])predict里的每一步都对应一个清晰的形状变化:输入从(batch_size, 784)变成(batch_size, 50),再变成(batch_size, 10),最后 softmax 成概率。很多新手会问为什么偏置b2初始化为零而不是随机数,因为偏置承担的是平移功能,如果初始也是随机数,反而会给网络引入不必要的初始偏差。权重用很小的标准差初始化,也是为了防止一开始就进入激活函数的饱和区。
我实际跑的时候发现,50 个隐藏神经元针对 MNIST 这种简单任务会有不错的效果,测试准确率大概在 96% 上下。如果你手头的任务比 MNIST 更难,比如带噪声的车牌识别或者复杂纹理分类,这个容量肯定不够,需要把 hidden_size 提到 256 或者 512。源码这个结构本身就是让你做实验用的,不是让你直接拿去生产。
3.2 学习率、批量大小、权重初始化
训练过程的主循环在源码包里是单独一段脚本,参数集中在开头。我每次复现这个项目都会刻意把学习率、批大小、训练轮数这三个值抄到纸上,因为它们三个互相牵制,只看单个值没有意义。
# 训练超参数集中区 iters_num = 10000 train_size = x_train.shape[0] batch_size = 100 learning_rate = 0.1 for i in range(iters_num): batch_mask = np.random.choice(train_size, batch_size) x_batch = x_train[batch_mask] t_batch = t_train[batch_mask] grad = network.gradient(x_batch, t_batch) # 参数更新:SGD,最朴素的写法 for key in ('W1', 'b1', 'W2', 'b2'): network.params[key] -= learning_rate * grad[key]这里的学习率 0.1 是源码预设值,在这个两层结构上是能正常收敛的。但同样的 0.1,换到更深一点的网络或者换到另一个数据集,就可能炸掉。我之前在某个业务数据集上直接沿用 0.1,损失曲线一开始还正常,迭代到几百轮直接变成 NaN,那就是学习率太大导致参数跳出了合理区间,数值发散。后来我改成先把学习率设成 0.001,跑一轮看损失变化,再逐步往上加,这个习惯比任何理论公式都好用。
批量大小 100 也是常规选择。批量太大,比如 1000,梯度方向偏稳定但单轮计算慢;批量太小,比如 1,梯度噪声大,收敛曲线抖得厉害,新手很容易因为看到损失曲线不光滑而误判为不收敛。权重初始化除了源码里用的0.01 * randn,实际还有其他策略,比如 Xavier 和 He 初始化,后面章节的代码里会覆盖到。新手不需要记太多,初期固定用源码的初始化,等结构复杂了再切换。
3.3 注解笔记怎么补才有价值
这份资源最大的特色是“自己添加的注解”,但每个人看注解的需求不一样,直接照单全收效果有限。我拆包时发现,注解主要集中在三个位置:前向传播的维度变化、反向传播每一步的公式来源、训练循环里每个变量的含义。这三类正是新手最容易卡住的地方。
我一般会在拿到这份源码之后,做两件额外的事。第一,把每个方法调用处的变量形状写进注释里,比如x_batch是(100, 784),grad['W1']是(784, 50),这样以后回来看代码不用重新推形状。第二,遇到看不懂的反向传播公式,我会在下面补一行“这里的dout表示什么”,这种问题式注解比直接抄结论更能帮助记忆。
注意一点,源码的注解是原有下载者的理解,不代表所有地方都正确。我见过有人在注释里把一个矩阵转置的方向写反了,如果你完全照背,反而会把自己绕进去。遇到解释和代码行为不符的情况,以实际运行为准,最好的验证方法就是打印中间变量的形状。
4. 避坑实录:带注解源码包里最容易翻车的五个点
4.1 训练损失不下降,迭代了几百轮纹丝不动
现象是每轮打印的 loss 始终在 2.3 附近徘徊,准确率约等于随机猜测。原因通常是权重初始化把数值压得太小,或者使用了 sigmoid 作为隐藏层激活函数导致梯度消失。解决方法是先检查激活函数,把隐藏层换成 ReLU;再把权重标准差从 0.01 调到 0.05 或 0.1,重新初始化。还有一次我遇到同样的现象,原因竟是忘了把normalize=True传入数据加载函数,像素值范围 0 到 255 直接喂进网络,调整数据预处理后一切恢复正常。
4.2 矩阵乘法维度不匹配,报错信息指向最隐蔽的那一行
现象是运行前向传播时报ValueError: shapes (100,50) and (784,50) not aligned,看代码明明写得很对。原因多半是数据没有展平,输入还是(100, 1, 28, 28)的四维数组,和第一层权重(784, 50)做点积时维度对不上。解决方法是打印x.shape,确认在进入网络前是否调用了reshape(-1, 784)。这类错误还有个变体,就是标签没有转成 one-hot,形状和输出层对不上,在损失函数里报错。我的排查顺序一般是先打印输入形状,再打印标签形状,十次有九次能定位。
4.3 损失函数变成 NaN,训练曲线突然消失
现象是训练到某一步后 loss 直接变 NaN,后面所有输出都是 NaN。原因通常是数值溢出,常见于 sigmoid 或 softmax 的实现没有做数值稳定处理。尤其是 softmax,如果直接np.exp(a),当 a 里出现大正数时,指数结果直接超出浮点范围。解决方法是检查 softmax 实现是否减去了最大值,检查交叉熵里是否有log(0)的情况,出现log(0)时给对数值加一个极小量1e-7。这套代码里 softmax 已经是稳定版本,但如果自己改动过,踩坑概率极高。
4.4 反向传播梯度检查误差大于 1e-3,不知道信谁
现象是数值微分和反向传播算出的梯度不一样,误差到了 1e-2 级别。原因大多是反向传播实现里的某个矩阵乘少了转置,或者某一路梯度忘记乘激活函数的导数。解决方法是把反向传播拆开,逐层对照前向传播的形状和公式,先用小批量数据跑一次梯度检查,误差控制在 1e-7 以内再开始训练。注意梯度检查时数据量不要太大,样本量越小计算越快,我一般用 3 个样本就够排查问题。
4.5 换到别的机器后数据集加载失败,卡在下载步骤
现象是环境一换,运行代码提示连接超时或者文件不存在,明明源码包里带了数据目录。原因可能是源数据包在下载时被网络环境阻断,或者目录权限不对,pickle 缓存写不进去。解决方法是手动把数据集文件放到dataset目录下,再运行一次加载脚本,让它直接走缓存分支。还有一个更隐蔽的坑,就是源码包解压时保持了只读属性,导致 pickle 写入失败,用chmod加写权限后恢复正常。这个现象在 Windows 和 Linux 之间切换时特别常见,先查目录权限再查网络问题。
5. 更进一步的自检技巧:梯度检查配合激活值分布的观测手段
源码能跑通只代表前向链路没有大问题,代表不了反向传播就一定对。我在跑完这个包之后,强制自己每次改动网络结构都要过一次两种验证,比单纯盯着准确率靠谱得多。第一个验证是梯度检查,在改动后的网络上重新跑一遍数值梯度对比,数值差分和反向传播的误差保持在 1e-7 左右才敢继续调参。
第二个验证是激活值分布观测。训练过程中每隔几百轮,把隐藏层的输出值统计一下,看看有没有大量神经元输出集中在 0 或者集中在饱和区。如果 ReLU 之后出现大量 0,说明这个节点已经死掉,权重更新再也救不回来;如果 sigmoid 的输出大量贴着 0 或 1,说明梯度快要消失了。提前发现这两类现象,比损失曲线变难看之后再补救高效得多。
def inspect_hidden_output(network, x_batch): # 取一批数据过前向,停在第一个隐藏层输出处 a1 = np.dot(x_batch, network.params['W1']) + network.params['b1'] z1 = relu(a1) # 统计 ReLU 之后零值占比,超过 20% 就说明有问题 zero_ratio = np.mean(z1 == 0) print(f"hidden zero ratio: {zero_ratio:.3f}") return zero_ratio我个人的习惯是新手期就养成两个硬性动作:第一,每个自定义网络结构搭完先跑 3 个小样本的梯度检查;第二,训练每 500 轮打印一次隐藏层零值比例和激活值分布。这两个动作对着这份源码包就能练起来,不需要额外数据集,把 MNIST 批大小改成 16 就可以快速验证。那之后我每次拿到新的网络实现都会先过这两道检查,慢慢就形成了肌肉记忆。希望这个习惯也能帮你少走弯道,真踩到坑的时候有个明确的复盘方向。
本文还有配套的精品资源,点击获取