本文属于《Python神经网络入门零基础保姆级路线图》专栏上一篇python神经网络编程入门五----误差算出来了权重到底怎么改下一篇python神经网络编程入门(七)——跑通MNIST手写数字识别验证训练成果与测试准确率。完整目录 更新记录《Python神经网络入门零基础保姆级路线图附全系列免费源码》我们来把上一期的理论变成实实在在能跑的代码。上一期我们花了大把时间搞懂了梯度下降这个“摸黑下山”的道理也看明白了那个让很多人头大的权重更新公式Δw -α × (误差) × (激活函数斜率) × (上一层输出)公式摆在那但光看它还是觉得有点虚。这一期我们就亲手把这个公式变成Python代码让你亲眼看着一个神经网络从“啥也不会”到“慢慢学会”的过程。我们从最简单的开始一步一步来。你不需要任何机器学习框架只用最基本的numpy就能搭建出一个完整的、能学习的神经网络。先别急聊聊我们到底要做什么在敲任何代码之前我们先想清楚一个问题一个神经网络最核心、最不能少的东西是什么停一下想一想再往下看。你的答案可能有很多节点、层、激活函数……都没错。但如果我们把“漂亮的外表”都剥掉只剩骨架神经网络其实就两样东西一堆权重数字能做乘法、加法、乘法的能力计算对就这么简单。输入来了乘以权重加上偏置这里我们先忽略偏置再经过激活函数得到输出。整个过程就是数字的流动。所以我们的代码核心任务就是管理好这一堆数字让它们能正确地流动起来。第一步先搭架子——写一个空的类好既然核心是“管理数据”用Python的类class来管理是最合适的。这就好比我们用一个文件夹把所有相关的文件整理在一起。先写一个最空的架子import numpy as np # 我们依赖numpy做矩阵运算 import scipy.special # 我们用它提供的S函数 class NeuralNetwork: 一个三层神经网络 def __init__(self): # 这里先什么都不做 pass def query(self): # 这里也先什么都不做 pass def train(self): # 这里也先什么都不做 pass好停我们来拆解一下class NeuralNetwork:是我们定义了一个“蓝图纸”。__init__是“建造师”每次创建一个新的网络都会先调用它来搭骨架。query是“问路员”你给输入它告诉你输出。train是“教练员”你给输入和正确答案它负责调整网络内部。这一部分不要求能跑通它只是我们的“计划书”。你先敲完这三段pass感受一下这个空架子。第二步让“建造师”干活——完善__init__现在我们来告诉__init__它到底要搭些什么。一个网络得知道自己有几层、每层几个节点。所以我们先给它三个数字输入节点数、隐藏节点数、输出节点数。另外还得告诉它学习率也就是我们迈步子的大小。class NeuralNetwork: def __init__(self, input_nodes, hidden_nodes, output_nodes, learning_rate): # 记住这些数字以后要用 self.inodes input_nodes self.hnodes hidden_nodes self.onodes output_nodes self.lr learning_rate好到这里停一下。你现在创建了一个网络对象它能记住自己的“身材”了。但我们还需要它“长肉”——也就是权重。先问自己一个问题权重应该是什么形状输入层到隐藏层隐藏层有多少个节点就要有多少行输入层有多少个节点就要有多少列。所以是(hidden_nodes, input_nodes)。隐藏层到输出层同理是(output_nodes, hidden_nodes)。为了避免网络“太胖”或者“太瘦”我们使用小的随机数来初始化权重# 初始化权重矩阵用正态分布采样均值0标准差为1/sqrt(传入链接数) self.wih np.random.normal(0.0, pow(self.hnodes, -0.5), (self.hnodes, self.inodes)) self.who np.random.normal(0.0, pow(self.onodes, -0.5), (self.onodes, self.hnodes))注意这里pow(self.hnodes, -0.5)就是1 / sqrt(隐藏层节点数)。这是经验规则防止权重太大导致网络“吃饱了撑的”饱和。sqrt是算数平方根的意思哈这里前面一期提到过哈公式权重范围 ≈ ±1/√(传入链接数)为什么用1/√n假设一个节点有n条输入链接每条链接的权重是w输入信号是x假设x在0~1之间。这个节点收到的总信号是总信号 w₁x₁ w₂x₂ ... wₙxₙ如果每个w都很大比如 ±1那么n个加起来总信号会大到离谱比如100个±1加起来可能到几十S函数直接就饱和了输出接近0或1梯度几乎为0学不动。那1/√n是怎么来的假设权重在±a之间均匀分布输入信号在0~1之间。那么总信号的方差大约是方差(总信号) ≈ n × 方差(w) × 方差(x)如果w在±a之间均匀分布它的方差是a²/3。为了让总信号的方差保持在1左右不爆炸也不消失需要n × a²/3 ≈ 1→a ≈ √(3/n)约等于1/√n常数3被经验调整掉了。所以1/√n是一个经验法则保证信号不会太大 → S函数不饱和信号不会太小 → 梯度不消失这个√是对初始化的保障。最后我们还需要激活函数。S函数是必须的我们把它绑定到对象上方便以后调用# 激活函数S型函数 self.activation_function lambda x: scipy.special.expit(x)好了现在__init__已经完成了。你把这几块拼在一起就得到了一个“有血有肉”的网络骨架。第三步让“问路员”带路——实现query有了权重我们就能让信号流动了。query的任务就是你给一个输入它算出输出。这个过程我们非常熟悉了输入 → 乘以权重 → 过S函数 → 输出。我们来写def query(self, inputs_list): # 1. 把输入列表转成列向量2D数组 inputs np.array(inputs_list, ndmin2).T # 2. 计算隐藏层的输入信号 hidden_inputs np.dot(self.wih, inputs) # 3. 计算隐藏层的输出信号 hidden_outputs self.activation_function(hidden_inputs) # 4. 计算最终输出层的输入信号 final_inputs np.dot(self.who, hidden_outputs) # 5. 计算最终输出层的输出信号 final_outputs self.activation_function(final_inputs) return final_outputs我们分解一下np.array(inputs_list, ndmin2).T这行代码把[0.5, 0.3]这样的列表变成了一个列向量。为什么要转置因为矩阵乘法要求形状匹配(隐藏节点数, 输入节点数) × (输入节点数, 1)(隐藏节点数, 1)。如果不转置维度就对不上了。np.dot是矩阵乘法。它把权重矩阵和信号矩阵相乘得到下一层的输入信号。你现在可以停下来手动创建一个网络随便给个输入看看它会不会报错。虽然输出的数字毫无意义因为没训练过但只要代码不报错就说明你的网络“活着”第四步让“教练员”发力——实现train现在到了最激动人心的部分。train要做的事情就是用误差来调整权重。它的前半段跟query一模一样——先做一次前向传播。然后计算误差反向传播更新权重。4.1 前向传播跟query一样def train(self, inputs_list, targets_list): # 转成列向量 inputs np.array(inputs_list, ndmin2).T targets np.array(targets_list, ndmin2).T # 前向传播跟query完全一样 hidden_inputs np.dot(self.wih, inputs) hidden_outputs self.activation_function(hidden_inputs) final_inputs np.dot(self.who, hidden_outputs) final_outputs self.activation_function(final_inputs)4.2 计算输出层误差# 输出层误差 目标值 - 实际值 output_errors targets - final_outputs4.3 计算隐藏层误差反向传播# 隐藏层误差 输出层误差 × 权重矩阵的转置 hidden_errors np.dot(self.who.T, output_errors)4.4 更新权重核心中的核心这就是我们花了两期才搞懂的梯度下降公式。它的代码实现非常简洁# 更新隐藏层 → 输出层的权重 self.who self.lr * np.dot( (output_errors * final_outputs * (1.0 - final_outputs)), np.transpose(hidden_outputs) ) # 更新输入层 → 隐藏层的权重 self.wih self.lr * np.dot( (hidden_errors * hidden_outputs * (1.0 - hidden_outputs)), np.transpose(inputs) )这里有两个细节需要你特别注意output_errors * final_outputs * (1.0 - final_outputs)这三项相乘就是公式里的误差 × 激活函数的斜率。np.transpose(hidden_outputs)把隐藏层输出转置了这样才能跟前面的矩阵相乘得到和权重矩阵相同形状的更新量。第五步把整个拼图组装起来现在你手上有三块拼图__init__、query、train。把它们放在一起就是我们完整的神经网络类。import numpy as np import scipy.special class NeuralNetwork: 三层神经网络类输入层 → 隐藏层 → 输出层 包含初始化、前向查询、反向训练三大核心功能 def __init__(self, input_nodes, hidden_nodes, output_nodes, learning_rate): 【建造师】初始化网络的结构和参数 参数: input_nodes: 输入层节点数 hidden_nodes: 隐藏层节点数 output_nodes: 输出层节点数 learning_rate: 学习率控制每次调整的步子大小 # ----- 1. 记录网络结构 ----- # 这些数字决定了网络的身材以后在 query 和 train 中都会用到 self.inodes input_nodes # 输入层节点个数 self.hnodes hidden_nodes # 隐藏层节点个数 self.onodes output_nodes # 输出层节点个数 self.lr learning_rate # 学习率控制调整幅度 # ----- 2. 创建权重矩阵网络的核心记忆----- # # 权重的形状说明 # wih: (隐藏层节点数, 输入层节点数) # who: (输出层节点数, 隐藏层节点数) # # 为什么要这样设计 # 矩阵乘法时行数 目标层节点数列数 源层节点数 # 这样 wih × 输入 就能得到 隐藏层输入 # 这样 who × 隐藏层输出 就能得到 输出层输入 # 输入层 → 隐藏层的权重矩阵 # 用正态分布随机初始化均值0标准差 1/sqrt(隐藏层节点数) # 这个经验规则可以防止权重太大导致网络饱和 self.wih np.random.normal( 0.0, # 均值 pow(self.hnodes, -0.5), # 标准差 1/√隐藏节点数 (self.hnodes, self.inodes) # 矩阵形状 ) # 隐藏层 → 输出层的权重矩阵 self.who np.random.normal( 0.0, pow(self.onodes, -0.5), # 标准差 1/√输出节点数 (self.onodes, self.hnodes) ) # ----- 3. 定义激活函数 ----- # S型函数Sigmoid将任意实数压缩到0~1之间 # 使用 scipy.special.expit 实现它数值稳定性更好 # lambda 是一种快捷定义函数的方式相当于 # def activation_function(x): # return scipy.special.expit(x) self.activation_function lambda x: scipy.special.expit(x) # 初始化完成 # 现在这个网络有了骨架结构、肌肉权重和神经激活函数 # 但它还没学过任何东西就像一张白纸 def query(self, inputs_list): 【问路员】给定输入让网络计算输出 参数: inputs_list: 输入数据列表例如 [0.5, 0.3] 返回: final_outputs: 网络的输出列向量例如 [[0.62], [0.35]] # ----- 1. 把输入转换成列向量 ----- # np.array(inputs_list, ndmin2) 把 [0.5, 0.3] 变成 [[0.5, 0.3]] # .T 转置后变成 [[0.5], [0.3]]即列向量 # 为什么要转置因为矩阵乘法要求维度匹配 # wih 的形状是 (隐藏节点数, 输入节点数) # 输入列向量的形状是 (输入节点数, 1) # 两者相乘得到 (隐藏节点数, 1)正好是隐藏层的输入 inputs np.array(inputs_list, ndmin2).T # ----- 2. 计算隐藏层的输入信号 ----- # 公式: hidden_inputs wih × inputs # 这一步把输入和权重结合起来算出每个隐藏节点收到的信号 hidden_inputs np.dot(self.wih, inputs) # ----- 3. 计算隐藏层的输出信号 ----- # 把加权和信号通过S函数挤压成0~1之间的值 # 这就是隐藏层神经元的激活过程 hidden_outputs self.activation_function(hidden_inputs) # ----- 4. 计算输出层的输入信号 ----- # 公式: final_inputs who × hidden_outputs # 把隐藏层的输出再跟输出层的权重结合 final_inputs np.dot(self.who, hidden_outputs) # ----- 5. 计算输出层的输出信号 ----- # 再次经过S函数得到最终输出 final_outputs self.activation_function(final_inputs) # 返回结果 return final_outputs # 查询完成 # 整个过程就是输入 → 加权 → 激活 → 加权 → 激活 → 输出 # 信号从输入层一直流到了输出层 def train(self, inputs_list, targets_list): 【教练员】用一组训练样本训练网络调整权重 参数: inputs_list: 输入数据列表例如 [0.5, 0.3] targets_list: 目标输出列表正确答案例如 [0.9, 0.1] 训练过程分为两个阶段 1. 前向传播和 query 一样算出当前网络的输出 2. 反向传播用误差来调整权重梯度下降 # 第一阶段前向传播和 query 完全一样 # 把输入转成列向量 inputs np.array(inputs_list, ndmin2).T # 把目标输出也转成列向量 targets np.array(targets_list, ndmin2).T # 计算隐藏层的输入和输出 hidden_inputs np.dot(self.wih, inputs) hidden_outputs self.activation_function(hidden_inputs) # 计算输出层的输入和输出 final_inputs np.dot(self.who, hidden_outputs) final_outputs self.activation_function(final_inputs) # 前向传播结束现在我们知道了网络在当前权重下的答案 # 第二阶段反向传播 # ----- 1. 计算输出层误差 ----- # 误差 正确答案 - 网络的答案 output_errors targets - final_outputs # ----- 2. 计算隐藏层误差反向传播 ----- # 把输出层的误差甩回隐藏层 # 公式: hidden_errors who^T × output_errors # who^T 是权重矩阵的转置行和列互换 # 这个操作实现了按权重比例分配误差 hidden_errors np.dot(self.who.T, output_errors) # ----- 3. 更新隐藏层→输出层的权重 ----- # 这是整个网络最核心的一步 # 公式: Δwho -α × output_errors × S(final_inputs) × hidden_outputs^T # # 分解一下 # output_errors : 输出层的误差 # final_outputs * (1 - final_outputs) : S函数的斜率导数 # np.transpose(hidden_outputs) : 隐藏层输出的转置 # self.lr : 学习率控制步子大小 # # 负号去哪了因为我们直接用误差的反方向调整 # 这里 output_errors targets - outputs已经包含了方向信息 self.who self.lr * np.dot( (output_errors * final_outputs * (1.0 - final_outputs)), np.transpose(hidden_outputs) ) # ----- 4. 更新输入层→隐藏层的权重 ----- # 跟上面的公式完全对称只是换成了隐藏层的误差和输入 # 公式: Δwih -α × hidden_errors × S(hidden_inputs) × inputs^T self.wih self.lr * np.dot( (hidden_errors * hidden_outputs * (1.0 - hidden_outputs)), np.transpose(inputs) ) # 训练完成 # 两行代码更新了网络里所有的权重 # 这就是梯度下降的魔力——用矩阵运算一次性调整所有参数第六步让网络真正跑起来有了蓝图我们来“盖房子”。# 创建一个2-2-2的网络2个输入2个隐藏2个输出 net NeuralNetwork(2, 2, 2, 0.1) # 给一组输入和对应的目标 inputs [0.5, 0.3] targets [0.9, 0.1] # 训练一次 net.train(inputs, targets) # 查询结果 output net.query(inputs) print(f训练后的输出: {output})你会看到输出是两个数字它们可能离[0.9, 0.1]还很远。这很正常因为只训练了一次。来我们让它多学一会儿# 训练1000次 for epoch in range(1000): net.train(inputs, targets) if epoch % 100 0: output net.query(inputs) error np.sum((np.array(targets) - output.flatten()) ** 2) print(f第{epoch}轮, 误差: {error:.6f})看着误差数字从零点几慢慢降到接近0你会真切地感受到它真的在学第七步总结回顾一下我们这期一起走过的路步骤你做的事情代码行数1搭空架子3个pass2告诉网络它的“身材”记住节点数3给网络“长肉”创建权重矩阵4让它会“问路”实现query5让它会“学习”实现train6组装并测试创建对象、训练、查询你从零开始亲手写出了一段能“学习”的代码。不要小看这件事。很多人会用PyTorch或TensorFlow但只有很少的人知道它们底层是怎么工作的。你现在就是那“很少的人”之一。下一期预告既然网络已经会学习了我们该让它干点正事了。下一期我们会用这个网络去做一件很酷的事情——识别手写数字。没办法这个比较经典争取数据集也自己做嘿嘿它会学会看0, 1, 2, 3, 4, 5, 6, 7, 8, 9就像你小时候学认数字一样。代码我们已经写好了到时候只需要把数据喂进去调整一下参数就能看到它从“文盲”变成“识字”的完整过程。我们下期见完整代码片段1import numpy as np import scipy.special class NeuralNetwork: 三层神经网络类输入层 → 隐藏层 → 输出层 包含初始化、前向查询、反向训练三大核心功能 def __init__(self, input_nodes, hidden_nodes, output_nodes, learning_rate): 【建造师】初始化网络的结构和参数 参数: input_nodes: 输入层节点数 hidden_nodes: 隐藏层节点数 output_nodes: 输出层节点数 learning_rate: 学习率控制每次调整的步子大小 # ----- 1. 记录网络结构 ----- # 这些数字决定了网络的身材以后在 query 和 train 中都会用到 self.inodes input_nodes # 输入层节点个数 self.hnodes hidden_nodes # 隐藏层节点个数 self.onodes output_nodes # 输出层节点个数 self.lr learning_rate # 学习率控制调整幅度 # ----- 2. 创建权重矩阵网络的核心记忆----- # # 权重的形状说明 # wih: (隐藏层节点数, 输入层节点数) # who: (输出层节点数, 隐藏层节点数) # # 为什么要这样设计 # 矩阵乘法时行数 目标层节点数列数 源层节点数 # 这样 wih × 输入 就能得到 隐藏层输入 # 这样 who × 隐藏层输出 就能得到 输出层输入 # 输入层 → 隐藏层的权重矩阵 # 用正态分布随机初始化均值0标准差 1/sqrt(隐藏层节点数) # 这个经验规则可以防止权重太大导致网络饱和 self.wih np.random.normal( 0.0, # 均值 pow(self.hnodes, -0.5), # 标准差 1/√隐藏节点数 (self.hnodes, self.inodes) # 矩阵形状 ) # 隐藏层 → 输出层的权重矩阵 self.who np.random.normal( 0.0, pow(self.onodes, -0.5), # 标准差 1/√输出节点数 (self.onodes, self.hnodes) ) # ----- 3. 定义激活函数 ----- # S型函数Sigmoid将任意实数压缩到0~1之间 # 使用 scipy.special.expit 实现它数值稳定性更好 # lambda 是一种快捷定义函数的方式相当于 # def activation_function(x): # return scipy.special.expit(x) self.activation_function lambda x: scipy.special.expit(x) # 初始化完成 # 现在这个网络有了骨架结构、肌肉权重和神经激活函数 # 但它还没学过任何东西就像一张白纸 def query(self, inputs_list): 【问路员】给定输入让网络计算输出 参数: inputs_list: 输入数据列表例如 [0.5, 0.3] 返回: final_outputs: 网络的输出列向量例如 [[0.62], [0.35]] # ----- 1. 把输入转换成列向量 ----- # np.array(inputs_list, ndmin2) 把 [0.5, 0.3] 变成 [[0.5, 0.3]] # .T 转置后变成 [[0.5], [0.3]]即列向量 # 为什么要转置因为矩阵乘法要求维度匹配 # wih 的形状是 (隐藏节点数, 输入节点数) # 输入列向量的形状是 (输入节点数, 1) # 两者相乘得到 (隐藏节点数, 1)正好是隐藏层的输入 inputs np.array(inputs_list, ndmin2).T # ----- 2. 计算隐藏层的输入信号 ----- # 公式: hidden_inputs wih × inputs # 这一步把输入和权重结合起来算出每个隐藏节点收到的信号 hidden_inputs np.dot(self.wih, inputs) # ----- 3. 计算隐藏层的输出信号 ----- # 把加权和信号通过S函数挤压成0~1之间的值 # 这就是隐藏层神经元的激活过程 hidden_outputs self.activation_function(hidden_inputs) # ----- 4. 计算输出层的输入信号 ----- # 公式: final_inputs who × hidden_outputs # 把隐藏层的输出再跟输出层的权重结合 final_inputs np.dot(self.who, hidden_outputs) # ----- 5. 计算输出层的输出信号 ----- # 再次经过S函数得到最终输出 final_outputs self.activation_function(final_inputs) # 返回结果 return final_outputs # 查询完成 # 整个过程就是输入 → 加权 → 激活 → 加权 → 激活 → 输出 # 信号从输入层一直流到了输出层 def train(self, inputs_list, targets_list): 【教练员】用一组训练样本训练网络调整权重 参数: inputs_list: 输入数据列表例如 [0.5, 0.3] targets_list: 目标输出列表正确答案例如 [0.9, 0.1] 训练过程分为两个阶段 1. 前向传播和 query 一样算出当前网络的输出 2. 反向传播用误差来调整权重梯度下降 # 第一阶段前向传播和 query 完全一样 # 把输入转成列向量 inputs np.array(inputs_list, ndmin2).T # 把目标输出也转成列向量 targets np.array(targets_list, ndmin2).T # 计算隐藏层的输入和输出 hidden_inputs np.dot(self.wih, inputs) hidden_outputs self.activation_function(hidden_inputs) # 计算输出层的输入和输出 final_inputs np.dot(self.who, hidden_outputs) final_outputs self.activation_function(final_inputs) # 前向传播结束现在我们知道了网络在当前权重下的答案 # 第二阶段反向传播 # ----- 1. 计算输出层误差 ----- # 误差 正确答案 - 网络的答案 output_errors targets - final_outputs # ----- 2. 计算隐藏层误差反向传播 ----- # 把输出层的误差甩回隐藏层 # 公式: hidden_errors who^T × output_errors # who^T 是权重矩阵的转置行和列互换 # 这个操作实现了按权重比例分配误差 hidden_errors np.dot(self.who.T, output_errors) # ----- 3. 更新隐藏层→输出层的权重 ----- # 这是整个网络最核心的一步 # 公式: Δwho -α × output_errors × S(final_inputs) × hidden_outputs^T # # 分解一下 # output_errors : 输出层的误差 # final_outputs * (1 - final_outputs) : S函数的斜率导数 # np.transpose(hidden_outputs) : 隐藏层输出的转置 # self.lr : 学习率控制步子大小 # # 负号去哪了因为我们直接用误差的反方向调整 # 这里 output_errors targets - outputs已经包含了方向信息 self.who self.lr * np.dot( (output_errors * final_outputs * (1.0 - final_outputs)), np.transpose(hidden_outputs) ) # ----- 4. 更新输入层→隐藏层的权重 ----- # 跟上面的公式完全对称只是换成了隐藏层的误差和输入 # 公式: Δwih -α × hidden_errors × S(hidden_inputs) × inputs^T self.wih self.lr * np.dot( (hidden_errors * hidden_outputs * (1.0 - hidden_outputs)), np.transpose(inputs) ) # 训练完成 # 两行代码更新了网络里所有的权重 # 这就是梯度下降的魔力——用矩阵运算一次性调整所有参数 if __name__ __main__: # 固定随机种子保证每次运行结果一致与图4数据对应 np.random.seed(42) # 创建2-2-2网络 net NeuralNetwork(2, 2, 2, 0.1) # 固定训练数据延续文章中的数值 inputs [0.5, 0.3] targets [0.9, 0.1] # 训练前查看初始输出 print(训练前的输出:, net.query(inputs).flatten()) print(目标输出:, targets) # 训练1000轮并记录每一轮的误差 loss_history [] for epoch in range(1000): net.train(inputs, targets) output net.query(inputs) error np.sum((np.array(targets) - output.flatten()) ** 2) loss_history.append(error) # 每100轮打印一次进度 if epoch % 100 0: print(f第{epoch:3d}轮, 误差: {error:.6f}) # 训练后查看最终输出 print(\n训练后的输出:, net.query(inputs).flatten()) print(目标输出:, targets) # 打印关键数据方便与图4对照 print(\n【关键误差数据) print(f初始误差: {loss_history[0]:.6f}) print(f第100轮误差: {loss_history[100]:.6f}) print(f第500轮误差: {loss_history[500]:.6f}) print(f第1000轮误差: {loss_history[999]:.6f})