首页 / 资讯中心 / 文章详情

从感知机到梯度下降:零基础搭建属于自己的神经网络

从感知机到梯度下降:零基础搭建属于自己的神经网络 ★ FEATURED ARTICLE
这本书的前四章放在一起看其实是一套完整的“从零造一个会学习的神经网络”闭环感知机告诉你神经元为什么需要“层”神经网络告诉你前向传播怎么算损失函数告诉你什么叫“学错了”梯度下降则告诉你朝哪个方向修正参数。很多初学者把前四章当成四个独立话题去啃结果看完感知机忘了激活函数看完损失函数又不懂梯度非常亏。我自己的经验是一条线串下来四章其实是同一件事层层递进把一个最简单的两层网络从无到有地训练起来你就能对后面所有深度学习框架的原理有一个很难被撼动的理解基础。这篇内容适合两类人一是刚接触深度学习、想搞懂底层原理而不是只调API的开发者二是已经用过框架、但总觉得那些loss.backward()和optimizer.step()像魔法的朋友。前者可以按这个路线从头跑一遍后者也能补上很多“知其所以然”的盲点。下面是我结合实操经验整理的前四章核心拆解每一部分都附了可以直接跑的代码和踩坑记录。1. 感知机先把最朴素的“神经元”搞明白1.1 感知机的数学形态与生活直觉感知机的形式极其简单把输入x1, x2各自乘上一个权重w1, w2加上一个偏置b如果加和大于0就输出1否则输出0。用公式写就是def perceptron(x1, x2, w1, w2, b): if w1 * x1 w2 * x2 b 0: return 1 return 0这个公式用生活场景来理解就很直接假设一门考试有两科每科满分100录取线是总分相加后还要超过某一条线。权重就是每科在录取中的重要性偏置就是那条“最低门槛”。w1大意味着科目一重要b为负意味着整体标准偏高、必须得考得足够好才能被选中。你完全可以把阈值从公式右侧移到左侧变成偏置项代码里处理起来更统一——几乎所有深度学习实现都是这么写的。我强烈建议新手把“与门”“或门”“与非门”这三种逻辑门都亲手用感知机实现一遍。这里有个很关键的认知同样一套感知机结构只要改权重和偏置就能表示完全不同的逻辑运算。比如与门的权重可以取(0.5, 0.5)偏置取-0.7或门的权重取(0.5, 0.5)偏置取-0.2。数值本身不是死答案关键是你对“加权求和后过阈值”这个机制有直觉。1.2 单层感知机的天花板线性可分单层感知机能解决的问题有一个严格边界只能用一条直线把两类数据分开。能分开的问题叫线性可分不能分开的叫线性不可分。异或门XOR就是典型的线性不可分问题输入A输入BXOR输出000011101110你把四个点画在二维平面上会发现(0,1)和(1,0)是一类(0,0)和(1,1)是另一类无论你怎么画一条直线都无法把这两类完美分开。这是感知机在理论层面的硬伤也是引出神经网络的绝佳伏笔。注意这个“线性可分”的概念在后面的学习曲线、支持向量机、甚至注意力机制的很多讨论里都会反复出现。前四章这里把它吃透后面会省很多力气。1.3 层数带来非线性感知机如何升级成网络解决XOR的办法不是把单个感知机变复杂而是把多个感知机叠成两层先算与非门和或门再把它们的输出作为下一层感知机的输入。这本质上就是“用层来叠加非线性变换”。神经网络和感知机的差别其实就两点一是激活函数从不可导的阶跃函数换成了可导的平滑函数二是层数可以任意加深。为什么激活函数必须可导后面梯度下降要靠导数来确定参数修正方向阶跃函数导数几乎处处为0梯度根本传不下去。所以从感知机跨到神经网络表面上是“激活函数换了个形状”实际上是“从不可学习的规则拼接走向了可学习的参数优化”。我见过很多人在这个点上没转过来建议动手画一下sigmoid函数的图像再对比阶跃函数你就能直观感受到“光滑可导”意味着什么。2. 前向传播的三块积木多维数组、激活函数、输出设计2.1 NumPy多维数组为什么矩阵运算如此重要神经网络的前向传播本质就是一连串的矩阵乘法。三维批量数据、四维特征图在内部都是多维数组NumPy的dot函数就是计算的主力。dot对初学者最大的坑是维度匹配左矩阵的列数必须等于右矩阵的行数。import numpy as np x np.random.randn(10, 784) # 10张28*28的图片摊平后每张784维 W1 np.random.randn(784, 100) # 第一层权重100个神经元 b1 np.random.randn(100) # 每个神经元一个偏置 h np.dot(x, W1) b1 # 结果是(10, 100)这里(10, 784)乘(784, 100)得到(10, 100)意思是10个样本一次性通过了第一层每个样本得到100维的特征向量。如果你会写for循环逐个样本处理就能看出矩阵计算的威力一次矩阵乘法等价于替你把循环内部的运算全部并行化地算完GPU能加速的也正是这类运算。用手写循环的代价不仅是慢更关键的是你很容易写出无法向量化的代码到真正用框架时会非常不适应。2.2 激活函数选型sigmoid、ReLU与恒等函数激活函数是神经网络的“非线性来源”。如果只用线性变换无论叠多少层整体仍然等价于一层线性变换深层结构就毫无意义。书里重点介绍的sigmoid函数长这样def sigmoid(x): return 1 / (1 np.exp(-x))它的输出范围在0到1之间平滑且可导很适合用来理解“神经元被激活的程度”。但实际工程里sigmoid有两个很明显的短板一是输出不以0为中心会导致下一层的输入总是正数影响梯度更新效率二是在两端导数趋近于0深层网络中梯度连乘后容易消失网络根本学不动。这也是ReLU在后来成为主流默认选择的原因max(0, x)计算极简单负数部分输出0带来稀疏性正数部分导数为1至少在正向区间不会让梯度凭空消失。激活函数输出范围主要优点主要缺点sigmoid(0, 1)平滑可导适合入门理解两端梯度饱和不以0为中心ReLU[0, ∞)计算快正区间梯度恒1负区间梯度为0可能出现“死神经元”tanh(-1, 1)以0为中心两端仍有饱和问题恒等函数(-∞, ∞)适合回归输出不改变输入一般不用于隐藏层我自己的习惯是理解原理用sigmoid做实际模型默认ReLU系。前四章的学习重点不在“哪个最好”而在理解激活函数在向前传播中扮演的角色——它就是一个逐元素的非线性映射把上层输出的线性加权结果“掰弯”让整个网络拥有逼近各种函数的能力。2.3 输出层设计恒等函数与softmax各司其职输出层的激活函数要根据任务类型来定。回归问题输出一个连续数值直接使用恒等函数即可分类问题要输出属于各个类别的概率就需要softmaxdef softmax(a): c np.max(a) exp_a np.exp(a - c) # 减去最大值防止溢出 return exp_a / np.sum(exp_a)softmax有三个很重要的性质。第一所有输出都在0到1之间而且和为1可以当概率解读。第二它是单调递增的也就是说原始得分最大的类别经过softmax后概率仍然是最大的不改变排名。第三它在实现时必须减去输入的最大值否则指数运算会溢出。实际推断时其实可以跳过softmax直接取最大原始得分对应的类别因为单调性保证了结果一致但在训练阶段必须保留因为损失函数要用到softmax输出的概率分布。2.4 第一次跑通MNIST前向推断的完整流程MNIST手写数字识别是前四章的里程碑实验28×28的灰度图10个类别6万张训练图、1万张测试图。整个前向流程拆开看极其清晰# 假设x是已经摊平并归一化的输入shape为(N, 784) # 网络的权重W1(784, 50)b1(50)W2(50, 10)b2(10) z1 np.dot(x, W1) b1 h1 sigmoid(z1) z2 np.dot(h1, W2) b2 y softmax(z2) # 每个样本输出10个类别的概率分布这里有个非常实用的细节输入数据一定要先归一化把像素值从0-255缩放到0.0-1.0。否则大数值输入会让加权和变得非常大进sigmoid后直接进入饱和区导致梯度极小后面训练效率极低。还有一点是数据预处理时把图像摊平成向量再送入网络框架里通常叫flatten这一步丢失了像素的二维空间结构但前四章为了聚焦于矩阵运算的基本逻辑这样做是合理的。3. 损失函数给网络一个“学错了多少”的量化信号3.1 为什么不能拿正确率当学习信号一个很自然的疑问是既然任务是识别手写数字为什么不直接用识别正确率作为优化目标问题在于正确率是离散的。比如10张图里从识别对7张变成识别对8张正确率从70%跳到80%但这中间参数是怎么变的、下一次该往哪个方向调完全没有梯度信息。损失函数的价值在于它把“错得多严重”变成了一个连续的、可微的实数比如预测分布和目标分布差得越远损失越大参数微调时损失会平滑地变化梯度才能给出调整方向。提示损失函数最大的意义不是“评价模型好坏”而是给梯度下降提供一个连续可导的“地形图”。没有这个地形图神经网络就没有办法学习。3.2 均方误差与交叉熵误差的取舍书里重点对比了两种损失函数。均方误差公式是0.5 * sum((y - t)^2)实现简单适合回归问题。交叉熵误差公式是-sum(t * log(y))更适合分类问题。原因可以从两个角度理解。从概率角度看交叉熵衡量的是两个概率分布之间的距离预测分布越接近真实分布交叉熵越小。从梯度角度看用交叉熵配合softmax时输出层的梯度形式极其简洁y - t也就是预测概率减去真实标签。这个梯度有多大直接由“预测错得多严重”决定预测概率越离谱修正幅度越大而如果用均方误差配合softmax梯度里会多出一个sigmoid(z)项在输出接近0或1时梯度几乎消失学习速度会显著变慢。def cross_entropy_error(y, t): delta 1e-7 return -np.sum(t * np.log(y delta))实现上有个必踩的坑log(0)结果是负无穷会导致梯度变成NaN。所以几乎所有的交叉熵实现里都会在取对数前加一个极小值delta。这不是数学上的严谨要求纯粹是数值稳定性上的工程保护。3.3 mini-batch学习为什么批次比单样本更好用基于全部训练数据计算损失再更新一次参数叫批梯度下降每次都只用一个样本更新叫随机梯度下降。书里的mini-batch介于两者之间每次随机抽取一小部分样本用这批样本的平均损失作为整体损失的近似。mini-batch的核心价值在于“用少量随机样本估计整体梯度方向”。整体梯度方向计算太贵单样本梯度又太震荡折中方案是每次用几十到几百个样本既保证了更新方向的稳定性又大幅降低了算力需求。具体实现就是把前面写的损失函数套在多个样本上计算平均def cross_entropy_error_batch(y, t): if y.ndim 1: y y.reshape(1, y.size) t t.reshape(1, t.size) batch_size y.shape[0] delta 1e-7 return -np.sum(t * np.log(y delta)) / batch_size如果你的监督标签是整数形式而不是one-hot向量实现更简单-np.sum(np.log(y[np.arange(batch_size), t] delta)) / batch_size只取每个样本正确类别对应的预测概率。两种写法等价但理解标签的shape差异是避免维度错误的关键环节。4. 梯度下降让参数学会朝正确的方向移动4.1 导数的直觉与梯度的方向梯度是“由各个变量的偏导数组成的向量”。单变量导数告诉你“函数值沿着自变量增加方向的变化率”偏导数告诉你“固定其它变量时沿这个方向单独变化会发生什么”。把所有偏导数组合成梯度就得到函数值增长最快的方向。优化神经网络的目标是最小化损失所以参数更新要走梯度的反方向也就是“负梯度方向”。这里可以用下山来类比你在山坡上想知道怎么走才能最快到谷底就先观察哪个方向最陡峭、坡度最大然后朝相反方向迈步。梯度告诉你的就是“哪个方向最陡峭”负梯度就是“下山最快”的方向。4.2 数值微分一套能让你看见梯度的慢工具数值微分是前四章用来计算梯度的方法核心原理就是导数的定义取一个极小的h计算(f(xh) - f(x-h)) / (2h)这叫中心差分比单侧差分精度高不少def numerical_gradient(f, x): h 1e-4 grad np.zeros_like(x) for idx in range(x.size): tmp_val x[idx] x[idx] tmp_val h fxh1 f(x) x[idx] tmp_val - h fxh2 f(x) grad[idx] (fxh1 - fxh2) / (2 * h) x[idx] tmp_val return grad这个方法的优点是实现简单、完全不需要推导导数公式适合验证自己对梯度的理解。缺点是慢且不精确每算一个参数的梯度都要额外跑两次完整的前向传播。一个小型两层网络几千个参数用数值微分更新一步都慢得难以忍受更别提现代网络动辄上亿参数了。所以实际训练神经网络用的是反向传播它是利用链式法则高效计算解析梯度的算法。前四章梯度下降部分的用意是把“梯度”这个抽象概念通过数值微分具象化让你先理解优化在做什么再在后续章节学习如何加速计算。4.3 学习率最需要亲手调一次才能记住的参数梯度下降的参数更新公式很简单W W - learning_rate * grad。但学习率这个超参数对训练结果的影响极大可以说是前四章里最值得反复实验的一个变量。学习率过大会怎样参数一步跨太大可能越过最低点损失反复震荡甚至直接爆成NaN。学习率过小呢参数每步只挪一点点训练过程慢到让人怀疑程序写错了。# 最简单的SGD更新 for key in params.keys(): params[key] - lr * grads[key]我的经验是一开始把学习率设在0.01量级观察损失的前几十次迭代如果损失暴增或出现NaN就除以10如果损失下降太慢就乘以10。不要指望一次调到位训练本来就是不断试错的过程。另外要注意前四章只用SGD后续的Momentum、AdaGrad、Adam都是在SGD的基础上对更新方向或步长做改造先把SGD的脾气摸透后面看优化器原理会轻松很多。4.4 两层网络训练闭环前四章的高潮前四章最后把前向传播、损失计算、梯度计算、参数更新串成一个完整的训练循环。这个循环值得亲手敲三遍以上结构如下import numpy as np def sigmoid(x): return 1 / (1 np.exp(-x)) # 两层网络参数初始化权重不能全零 W1 np.random.randn(784, 50) * 0.01 b1 np.zeros(50) W2 np.random.randn(50, 10) * 0.01 b2 np.zeros(10) params {W1: W1, b1: b1, W2: W2, b2: b2} def predict(x): h sigmoid(np.dot(x, params[W1]) params[b1]) return np.dot(h, params[W2]) params[b2] # 这里先不softmax损失函数里处理 def loss(x, t): y predict(x) # 交叉熵softmax的简化形式直接取正确类别的得分再归一化 exp_y np.exp(y - np.max(y, axis1, keepdimsTrue)) prob exp_y / np.sum(exp_y, axis1, keepdimsTrue) delta 1e-7 return -np.mean(np.log(prob[np.arange(len(t)), t] delta)) # 训练循环 lr 0.1 for epoch in range(1000): # 这里数值梯度计算量极大实际只是演示原理 grads numerical_gradient(lambda W: loss(x_batch, t_batch), params[W1]) params[W1] - lr * grads这个循环里藏着两个极容易被忽略的暗坑。第一个是权重初始化不能全零否则同一层的所有神经元输入相同、梯度相同整个网络退化成只有一个神经元在训练这就是对称性问题。第二个是numerical_gradient在真实规模的数据上慢得离谱书里这么做是为了教学实际工程中必须换成反向传播。能把这个循环从“背下来”变成“想明白为什么每个环节都不能少”前四章就算真正吃透了。5. 前四章实操中常见的五个问题与排查经验5.1 损失变成NaN怎么办最常见的原因是学习率过大参数更新一步跨出太远导致加权和数值爆炸sigmoid输出进入饱和区或softmax指数溢出。处理办法很直接把学习率调小比如从0.1改成0.01再试。另一个隐蔽原因是交叉熵里log(0)导致负无穷这时候要在对数运算里加微小值保护。代码里看到NaN不用慌按“学习率先缩小十倍再看数据有没有归一化”的顺序排查。5.2 两层网络完全学不动准确率一直停在随机水平先检查数据归一化直接用0到255的像素值喂给网络加权和会很大sigmoid全部进入饱和区梯度几乎为零网络根本更新不了。把输入缩放到0到1再跑一次往往立刻就有改观。其次检查网络深度和宽度是否合理太窄的隐藏层表达能力有限但前四章的MNIST实验用50个隐藏神经元就足够了问题一般不出在容量上。5.3 初始化全零导致“对称网络”学不到任何东西前面提到全零初始化的坑现象是损失下降极其缓慢甚至停滞所有神经元的权重更新后仍然一模一样。用随机初始化打破对称性就好。但随机初始化的尺度也要注意太大会让激活输出饱和太小会让梯度更新微乎其微实际常用的是np.random.randn(...) * 0.01这类小随机值起步。5.4 矩阵维度报错怎么快速定位维度不匹配是入门阶段报错率最高的地方。先别急着改代码把每个矩阵的shape打印出来检查“前一个矩阵的列数是否等于后一个矩阵的行数”。输入(N, d)过第一层(d, h)得到(N, h)再过第二层(h, o)得到(N, o)。这一条链条整清楚了80%的维度报错都能自己解决。偏置的shape也要注意一般设计成(h,)和(o,)广播机制会把它加到每一行上。5.5 数值微分算出的梯度怎么知道它是对的前四章里数值微分是主要工具后续章节引入反向传播后你可能会担心反向传播算错。此时数值微分就变成了验证工具对同一组参数分别用数值微分和反向传播计算梯度两者结果应当非常接近。这个验证方法在后续实践里非常实用我至今在实现自定义层时都会先用数值微分对比一次梯度确认无误再放心使用。常见问题可能原因解决方向损失输出NaN学习率过大 / log(0)调小学习率对数内加微小值准确率停滞数据未归一化 / 初始化不当归一化输入改用小随机数初始化网络学不动全零初始化 / 梯度消失随机初始化换ReLU类激活函数维度报错矩阵shape不匹配打印各层shape逐层核对训练极慢使用了数值微分换反向传播计算梯度我在实际看书和带新人过前四章时最大的体会是“看懂公式”和“跑通代码”之间还差着一整条鸿沟。公式停留在纸面上是死的动手去改学习率、调初始化尺度、盯着损失一条条降下来那些概念才会在你脑子里长成真正的直觉。建议你至少把第四章的两层网络实现完整敲三遍第一遍照着理解第二遍合上书自己写第三遍从零独立写并加一个小改动比如换ReLU、改层宽度观察行为变化。做完这三遍前四章能教你的东西基本就全部消化完了。后面再去接触卷积网络、循环网络、深度学习的工程技巧时你会发现自己根本不缺地基缺的只是往上层搬砖的体力。
阅读完成 · 觉得有帮助?
咨询建站