“深度学习01”——很多人看到这个编号,第一反应是教材目录或者网课列表里的某一讲。但我更愿意把它看作一个动手项目:用最底层的方式,亲手做第一个能自主学习的神经网络。这几年大模型很火,聊天、画图、写代码,仿佛只要调接口就能拥有一切。可回到根上,深度学习解决的核心问题其实特别朴素:扔给计算机一堆数据,让它自己找规律,然后用找到的规律去预测从没见过的新样本。这篇博文,就是带你把“找规律”这件事从原理到代码完整走一遍。
这个系列第一篇,我不打算让你去装任何重型框架,不调高级封装的接口,只用一个脚本手写一个迷你神经网络。它能做什么?它能学会区分两类数据,并且在训练完成后,对没见过的数据做出预测。适合谁?零基础想入门AI的新手——尤其是那些已经调过接口、但完全不清楚内部发生了什么的人。顺便说一句,哪怕你以后要投身大模型方向,底层那套“前向传播、计算损失、反向传播、更新参数”的流程,也半点没变。先把这条主线打通,后面什么都顺。
1. 神经网络到底在学什么:先拆掉“深度学习”的神秘感
1.1 一个神经元就是一台小计算器
很多人被“神经网络”这个词吓住,以为里面有什么高深莫测的生物机制。实际上你可以把每个神经元理解成一台微型计算器:它接收一组输入,给每个输入配一个权重(重要程度),把输入乘权重再加一个偏置,最后丢进一个激活函数做非线性变换。
打个比方。你在调音响,音量旋钮就是权重,音调旋钮是另一个权重,偏置相当于“就算所有旋钮归零也仍然存在的底噪”。模型要学的东西,就是找到一组最合适的旋钮位置,让最终输出的声音最好听。
深度学习里的“深度”,就是很多这样的神经元一层接一层串联。每一层把上一层的结果重新加工,逐层提取更抽象的信息。第一层可能只能看到像素边缘,中层看到纹理,高层看到物体轮廓,这个类似“层层抽象”的过程,就是深度学习在图像、语音、文本上多次奏效的根本原因。
1.2 损失函数:模型的“错题分数”
模型学得好不好,得有个量化标准。这个标准就是损失函数。训练时,网络拿一批样本做预测,把预测结果和真实标签(答案)比一下,算出一个分数——这个分数就是损失,英文叫loss。分数越低,说明预测越接近真实答案。
举个例子,你让孩子做口算题,每道题错得越离谱,扣分越多。损失函数就是那套扣分规则。深度学习的训练过程,说白了就是“刷题”:一遍遍看样本,算分数,然后调整参数让分数下降。
常见的损失函数有两种。回归问题常用均方误差(MSE),就是预测值和真实值差的平方;分类问题常用交叉熵,它衡量的是预测概率分布和真实分布的差异。我在第一个实验里用均方误差,因为它求导直观,方便新手跟着推导走一遍,不会在数学上卡壳。
1.3 梯度下降:下山才是正经事
模型有上万个参数时,怎么降低损失?答案是梯度下降。梯度是个向量,指向损失函数上升最快的方向。我们沿着相反方向走,就能让损失下降。
你可以想象自己站在山坡上,四周起雾,看不见路,但能感觉到脚下哪边坡度更陡。梯度下降就是:朝坡下最陡的方向迈一步,到新位置再感觉一次,再迈一步,直到走到谷底。
关键是“迈多大步”——这一步的尺寸就是学习率。学习率太大,可能一步跨过谷底,在两边来回震荡;学习率太小,训练半天还在山腰磨蹭。后面我会专门讲这里踩过的坑。
2. 准备一个零依赖的深度学习实验台
2.1 为什么我劝你别一上来就装框架
现在网上很多教程开局就让装某个流行框架,拖下来几百兆依赖,再跑个“经典数据集”,十有八九会在环境配置上栽跟头。而且就算跑通了,本质还是“拿着计算器按了一个按钮”,大脑里对神经网络的概念依然模糊。
我的建议:第一个实验,零依赖,纯标准库。只需要Python,不需要任何多余组件。这样有两个好处。第一,不安装那些庞然大物,你可以在十分钟内搭起实验台。第二,所有代码都摊在你面前,每一行乘法你都看得懂,反向传播的每一环都能用肉眼追踪,这对建立直觉太重要了。
等你理解了核心原理,再去碰现成框架,那是升级工具而不是寻找救命稻草。这个顺序千万别反。
2.2 环境准备只需一步
确保电脑上有Python 3就可以。在终端里敲一下python --version,如果能看到版本号,这台机器就能跑完本篇全部代码。
我建议你创建一个文件夹,专门放这个实验。比如叫deep_learning_01,把后面所有代码存成一个.py文件。如果你之前没用过Python,也不需要去找集成开发环境,记事本加命令行足够;如果更习惯用编辑器,挑个轻量的就行。重点是别在工具上浪费时间,代码本身才是主角。
2.3 构造数据:两个有重叠的簇
深度学习离不开数据。为了控制变量,我不去下载真实数据集,而是用程序造一份玩具数据。这份数据一共200个样本,每个样本有两个特征x1、x2,标签y是0或1,代表两个类别。
我把0类放在坐标系原点附近,1类放在右上角(2,2)附近,同时给两个簇加上随机扰动,让它们有一部分区域是重叠的。这样一来,简单的“画一条直线”很难完美分开两类,神经网络才有表现非线性的空间。
为什么要让它们重叠?如果两类数据完全分开,那一个线性分类器就够了,神经网络的优势根本看不出来。重叠区域恰恰是考验模型泛化能力的场景——它必须学会妥协,找到最合理的决策边界。
顺便说一句,这200个样本里会有150个作训练集,50个作测试集。训练集像是平时的练习题,测试集像是期末考试的卷子。考卷绝对不能提前给学生看,否则成绩再漂亮也是假的。
下面这段代码生成原始数据:
import math import random random.seed(42) def gauss(): u1 = random.random() while u1 < 1e-9: u1 = random.random() u2 = random.random() return math.sqrt(-2 * math.log(u1)) * math.cos(2 * math.pi * u2) def generate_data(n_a=100, n_b=100): samples = [] for _ in range(n_a): x1 = gauss() * 1.2 x2 = gauss() * 1.2 samples.append((x1, x2, 0)) for _ in range(n_b): x1 = gauss() * 1.6 + 2.0 x2 = gauss() * 1.6 + 2.0 samples.append((x1, x2, 1)) random.shuffle(samples) return samples all_data = generate_data() train_data = all_data[:150] test_data = all_data[150:]看到没,完全没用到第三方库,只用random生成随机分布,用math做开方和三角函数。这里有个小细节:random.seed(42)固定了随机种子,这样每次运行生成的样本都一样,实验结果可以复现和对比。实际项目中随机种子也是个很有用的习惯,不是只在学校实验里才用。
3. 手写一个能学习的迷你神经网络
3.1 网络结构:2-4-1
这个网络的形状我定成输入层2个节点、隐藏层4个节点、输出层1个节点。输入是(x1, x2)两个特征,隐藏层负责把特征做非线性组合,输出层最终输出“属于1类”的概率。
算一下参数数量:输入层到隐藏层有2×4=8个权重,隐藏层每个节点有1个偏置,共4个;隐藏层到输出层有4个权重,输出层有1个偏置。总参数13个。就这么点参数,已经能构成一个完整的学习系统。
隐藏层的激活函数我用tanh,输出层用sigmoid。tanh把输出压缩到-1到1之间,中心为0,梯度更平稳;sigmoid把最终输出压缩到0到1之间,天然适合表示概率。这里不展开讲每个函数的导数,但你会在代码和梯度推导中看到它们的重要性。
初始化时,所有权重取(-0.5, 0.5)之间的随机数。为什么不能全设成0?如果同一个隐藏层的所有神经元初始一样,训练中它们会一直保持一样,等于白白浪费了容量。随机初始化才能让每个神经元学到的模式各不相同。
3.2 前向传播:让数据流过网络
前向传播就是逐个神经元计算的过程。对每个隐藏神经元j,先算加权和:
z_j = sum(w1[j][i] * x[i]) + b1[j]
然后过激活函数h_j = tanh(z_j)。
输出层再把这些隐藏特征加权,过sigmoid,得到最终概率p。
input_size = 2 hidden_size = 4 output_size = 1 w1 = [[random.uniform(-0.5, 0.5) for _ in range(input_size)] for _ in range(hidden_size)] b1 = [0.0] * hidden_size w2 = [random.uniform(-0.5, 0.5) for _ in range(hidden_size)] b2 = 0.0 def forward(x): hidden = [] for j in range(hidden_size): z = b1[j] for i in range(input_size): z += w1[j][i] * x[i] hidden.append(math.tanh(z)) z_out = b2 for j in range(hidden_size): z_out += w2[j] * hidden[j] prob = 1.0 / (1.0 + math.exp(-z_out)) return hidden, prob我建议你亲手对着纸画一遍这个流程:输入x进去,走一层,过函数,再走一层,出来一个概率。这个动作就是“前向传播”。后面所有训练逻辑,都是为了调整w1、b1、w2、b2,让这个输出概率逼近真实标签。
3.3 反向传播:把损失“分摊”回每个参数
到了整个实验最核心的环节。我当年第一次看反向传播,满脑子都是“为什么一个式子要连着求三次导”。后来画图才算真正理解:它做的事就是把最终的损失,一步一步“分摊”回去,让每个参数知道自己该往哪个方向调。
用链式法则从后往前推。对于输出层:
导数值 = (概率 - 标签) × 概率 × (1 - 概率)
这个值叫“输出层梯度”。拿到它之后,就能算出每个w2[j]的梯度:输出层梯度 × 第j个隐藏节点的输出。偏置b2的梯度就是输出层梯度本身。然后继续往前传:先把输出层梯度乘上w2[j],作为第j个隐藏节点的“责任”,再乘上tanh的导数(1 - h[j]²),就得到隐藏层每个神经元的梯度。最终给到w1[j][i]的梯度,就是隐藏层梯度 × 输入x[i]。
这些过程听起来拗口,代码落地其实不长:
learning_rate = 0.15 epochs = 2000 for epoch in range(epochs): total_loss = 0.0 for x, y_true in train_data: hidden, prob = forward(x) err = prob - y_true total_loss += 0.5 * err * err d_out = err * prob * (1.0 - prob) grad_w2 = [d_out * h for h in hidden] grad_b2 = d_out grad_hidden = [d_out * w2[j] for j in range(hidden_size)] grad_z1 = [grad_hidden[j] * (1.0 - hidden[j] * hidden[j]) for j in range(hidden_size)] grad_w1 = [[grad_z1[j] * x[i] for i in range(input_size)] for j in range(hidden_size)] grad_b1 = list(grad_z1) b2 -= learning_rate * grad_b2 for j in range(hidden_size): b1[j] -= learning_rate * grad_b1[j] w2[j] -= learning_rate * grad_w2[j] for i in range(input_size): w1[j][i] -= learning_rate * grad_w1[j][i] if epoch % 200 == 0: print(f"epoch {epoch}, loss {total_loss / len(train_data):.4f}")有个细节必须注意:梯度要在更新参数之前全部算完。代码里我先算好所有grad开头的变量,然后再统一更新。如果你在循环里边算边更新,那后面节点拿到的梯度已经是“用过旧权重”的值,整个推导链就会乱掉。
3.4 训练完毕:看它到底学会没有
训练结束,用测试集来验收。测试的逻辑和训练时完全一样:走一遍前向传播,输出概率大于0.5判为1类,否则判为0类。把预测标签和真实标签对齐,统计准确率。
def predict(x): _, prob = forward(x) return 1 if prob >= 0.5 else 0 correct = 0 for x, y_true in test_data: pred = predict(x) if pred == y_true: correct += 1 print(f"test accuracy: {correct / len(test_data):.4f}")我本机跑这段代码时,2000轮后训练损失稳定在0.30附近,测试集准确率有九成左右。你别背这个数字,因为每次运行受随机种子影响其实不大(种子固定了),和你手写代码后得到的几乎一样。如果你把学习率从0.15改成0.5,很可能会看到损失在某个区间震荡,准确率反而下降——这就是学习率过大的经典症状。
3.5 为什么这么小的网络也能有效?
有人会问:13个参数是不是太少了?它凭什么能学出一个非线性分界线?关键是隐藏层。4个隐藏神经元,相当于把这一个平面上的坐标变换成了4个特征,再用这4个特征做决策。神经网络的本质,往往就是把原始输入映射到一个更容易分类的空间。这个想法很简单,却是深度学习所有奇迹的起点。
4. 训练过程常见的坑与排查技巧
4.1 损失不降?别急着调代码,先看学习率
新手最常遇到的情况就是loss卡住不动,或者来回震荡。我见过不少人反复检查网络、检查梯度,最后发现只是学习率设得离谱。
经验值:在普通小玩具问题上,学习率从0.01到0.3之间比较安全。如果损失曲线像心电图,多半是学习率太大,降一档;如果学得慢吞吞,可以适当加大一点。
我个人的习惯是先用较大的学习率快速确认代码能跑通,再逐步缩小,观察损失曲线收敛情况。这比一上来就追求“完美学习率”要高效得多。
4.2 梯度消失:sigmoid的隐形陷阱
代码里隐藏层我特意选了tanh而非sigmoid,不是随手的决定。sigmoid的输出不是0中心,而且在两端导数几乎为0。当层数变深时,梯度一层层乘下来,很容易趋近于0,导致浅层参数无法更新——这就是“梯度消失”。
在这个玩具模型里,只有4个隐藏神经元,sigmoid影响还不明显。可一旦你想堆神经网络层数,问题就会爆发。后面的实验你可以试着把tanh换成sigmoid,把隐藏层加到20层,再训练,大概率会发现前几层权重几乎没变化。
4.3 别让训练集和测试集共享任何信息
我在划分数据时用了shuffle,再把前150个作为训练集、后50个作为测试集。有朋友犯过一个毛病:先随机抽了100个训练,再在剩下的里面又挑训练集,结果测试集里混进了训练样本,最终准确率高得离谱。
评估模型要像参加考试:测试集必须“没见过”。更严格的流程还会把数据分成训练、验证、测试三份,验证集用来调超参数,测试集只用于最终评估。玩具项目也要养成这个习惯,将来面对真实项目会少走很多弯路。
4.4 数据归一化:量纲不同会要命
我这份数据里x1和x2的量纲差不多,所以不归一化也能收敛。但真实数据经常一个特征是百分制,一个特征是小数,甚至差了几个数量级。权重更新时,大数值特征会主导梯度,其他特征几乎学不到东西。
归一化通常就是把每个特征减均值、除以标准差,让所有特征都落到类似的尺度。养成看到数据就先想想“量纲统一了没有”的习惯,能帮你避开大量训练不收敛的问题。
5. 从玩具模型到真实项目:下一步怎么走
5.1 这个实验你真正学到的东西
不要小看这个几十行的迷你网络。它包含了深度学习中所有的核心骨架:参数化模型、前向传播、损失函数、梯度下降、反向传播、数据集划分、超参数设置。你现在可以自豪地说:我不是只会“调包”,而是知道包装纸里面长什么样。
以后不管用哪个主流框架,你会发现训练流程和我这个实验如出一辙:定义网络结构、选损失函数、写训练循环、前向算loss、反向算梯度、更新参数。框架只是把这些步骤的底层运算做成高效且自动求导的引擎。
5.2 换掉玩具数据:马上会遇到新问题
接着做下一个升级实验:把数据换成一幅真实图片的分类任务。你会发现直接沿用这个网络根本没法收敛。为什么?输入特征从2个变成几万个,隐藏层神经元数量继续停留在4个?那参数量爆炸,计算量让人等不起,过拟合也随之而来。
到这一步,你再去学卷积、池化、dropout、批量归一化这些进阶武器,就会明白它们全是冲着“真实数据的规模”和“网络过深后的各种病”去的。带着问题去学新概念,效率远高于单纯背公式。
5.3 一份可以立刻动手的升级清单
我建议你照着这个顺序玩下几个变体,每个改动都能加深理解:
- 把tanh换成sigmoid,观察损失下降速度变化。
- 把隐藏层从4个节点改成32个,观察准确率和训练时间变化。
- 把学习率从0.15改成1.5,记录loss曲线如何震荡。
- 把数据集改成三类,输出层变成3个节点,用softmax输出概率。
- 在训练循环里加入mini-batch梯度下降:不每样本更新,而是每32个样本累计一次梯度再更新。
这些变体看起来都只是小改动,但每做完一个,你对模型的掌控感都会实打实提升一截。
最后说点我自己的体会。我第一次写这类手写网络时,吃亏最大的地方不是公式,而是维度。w1、w2、b1、b2在脑子里全是乱的,梯度更新时经常把一个二维列表和一个一维列表对错了位。后来我花了很长时间画了一张计算图,把每个张量旁边标注了形状(shape),才彻底理顺。
所以我真心建议你把这几十行代码亲手敲一遍,不要复制粘贴。以后跑再大的模型,遇到维度不匹配报错,你都能靠这张“张量形状地图”快速定位问题。这个迷你网络虽然小,但它已经是一个完整的生命体:会学习、会犯错、也能被理解。把这份感觉记住,后面无论学什么深度学习内容,你都不会虚。