1. 这个实验到底要做什么
去年我在社区里分享了一段只有十几行的神经网络代码,评论区问得最多的一句话是:“就这点代码,能叫神经网络?”说实话,我特别理解这种疑问。很多人对神经网络的印象停留在“高深、复杂、需要大量数学基础”,觉得没有几百行代码、不手写反向传播,就不算真正接触了深度学习。
但今天的实际情况是,像 TensorFlow、PyTorch 这样的框架已经把所有底层实现打包成了几层 API——你要做的,就是用这些积木块把网络结构搭出来。10 行代码搭一个能跑的神经网络,完全真实,而且是个特别好的切入点:它让你先看见全貌,再回头看细节。
这篇内容我准备了很久,目标读者就是两类人:一是刚接触机器学习、想用最短路径看看神经网络到底怎么工作的新手;二是已经会调库但一直没搞懂每个参数在干什么的“半吊子”学习者。实验会用 Python 和 Keras 完成,数据集选用最经典的鸢尾花(Iris)数据,任务是一个三分类问题。跑完整个流程,你会清楚感知到“神经网络从输入到输出到底经历什么”,而不是停留在“它很神奇”的模糊印象里。
为了让整个过程尽量贴近实际操作,我不会只贴一段代码让你自己琢磨。我会把每一行代码拆开讲清楚,把模型训练过程中的关键决策点以及常见的坑都聊一遍,确保你照着敲一遍也能跑通。
2. 为什么说用 10 行代码就能搭建神经网络
2.1 框架把最难的数学藏在了底层
先说个扎心的事实:如果不用任何深度学习框架,纯粹靠 NumPy 手写一个多层感知机,从参数初始化、前向传播、反向传播到梯度更新,至少要 150 到 200 行代码,其中 80% 都在处理矩阵运算的维度和公式推导。而且这还只是最简单的网络,随便加个 BatchNorm 或者正则化,代码量立刻翻倍。
十年前,深度学习研究者确实都是这么过来的。但后来 Keras 这类高层 API 出现,把网络定义、损失函数、优化器、训练循环全部封装成了声明式接口。你今天写神经网络,本质上更像是在搭积木——每一层是一个组件,训练是一个命令。底层那些张量运算、自动微分、梯度下降的实现细节,框架全都替你处理完了。
所以 10 行代码搭神经网络,不是噱头,而是现代深度学习工程化的真实状态。
2.2 技术选型:为什么用 Keras 而不是 PyTorch
动手之前,先聊聊框架选择。目前主流的中文社区里,PyTorch 的声量确实越来越大,学术论文的代码几乎都默认 PyTorch,但它解决问题的思路是“define-by-run”,代码结构更灵活,也会更“啰嗦”一点。哪怕是最简单的线性层网络,PyTorch 也要写一堆optimizer.zero_grad()、loss.backward()、optimizer.step(),这些步骤虽然清晰,但对于第一次接触神经网络的新手来说,反而增加了认知负担。
Keras 走的是“define-and-run”路线,你用几行代码声明网络结构,调用fit()就进入训练闭环。初学者可以把全部注意力放在“数据长什么样、网络有几层、训练效果如何”这几个关键问题上,不会被样板代码干扰。说实话,我自己现在做原型验证也经常用 Keras,因为快。
另外,Keras 在 TensorFlow 里可以直接from tensorflow import keras调用,不需要额外安装,这一点对新手特别友好。
我画过一张对比表,供参考:
| 对比维度 | Keras(本实验选用) | PyTorch |
|---|---|---|
| 学习曲线 | 平缓,几条命令即可上手 | 较陡,需理解动态图机制 |
| 代码结构 | 声明式,简洁 | 命令式,灵活但啰嗦 |
| 调试体验 | 相对黑盒 | 更透明,逐行可断点 |
| 生产部署 | 有 TF Serving 等完整链路 | 也有 TorchServe,但整体偏学术 |
| 适合人群 | 新手入门、快速原型 | 科研、需要深度定制的人 |
2.3 一个反直觉的事实:模型精度不是这个实验的重点
很多人第一次跑通神经网络,第一个动作就是盯着准确率看,然后开始焦虑:“准确率才 0.9,是不是代码写错了?”
先把这个心态端正过来:用 10 行代码搭的模型,核心目的是验证“从数据输入到模型输出”这条链路是否走通,而不是在整个公开数据集上刷精度。像鸢尾花数据集,用逻辑回归都能做到 95% 以上,神经网络在这里更多是“杀鸡用牛刀”地演示原理。
所以这个实验,你要观察的重点是三件事:
- 输入数据经过网络之后,输出的 shape 是否符合预期
- 训练过程中 loss 是否在逐步下降
- 测试集上的评估结果是否明显优于随机猜测(三分类的随机准确率是 33%)
只要这三点都满足,说明你的神经网络搭建成功,且已经学会了数据中的某种模式。
3. 核心概念速览:前馈、反向传播与激活函数
3.1 神经网络的两次“传播”
进入代码之前,必须把两个概念讲清楚,因为后续所有调试都会围绕它们发生:前向传播(前馈)和反向传播。
前向传播的逻辑可以用一个特别生活化的类比解释:想象你是一个新来的销售,需要给一批商品定价格。你手上只有四个特征(尺寸、重量、材质、品牌),你根据这四条线索,进入一个“决策房间”,房间里每个同事分别负责不同的判断,依次传递意见,最后汇总出一个估价。这个过程,数据从第一层流向最后一层,中间每一层都会对信息做一次变换,这就是前向传播。
反向传播的逻辑则像考试对答案:你发现刚才估价 100 块,而真实价格是 80 块,于是你往回倒推,看看是哪个环节、哪个“同事”的判断造成了这 20 块的偏差,然后让这个人把权重调一调,下次更准。神经网络训练的本质,就是反复执行“前向算出误差,反向逐层修正参数”这个循环。
3.2 激活函数为什么必不可少
如果你没有激活函数,神经网络无论堆多少层,本质上都等价于一次线性变换,这是数学上可以直接证明的结论。换句话说,它永远学不会非线性关系,比如“颜色深且形状圆的商品反而便宜”这种复杂规律。
我在代码里会用到两种激活函数,分别是隐藏层的 ReLU 和输出层的 Softmax。
ReLU 的公式是f(x) = max(0, x),它的优点是计算简单、能缓解梯度消失。你把它理解成一个“过滤器”:好消息(正数)原样通过,坏消息(负数)直接截断为零。这个特性让网络训练快且稳定,是目前隐藏层事实标准。
Softmax 则是专门用于多分类输出的激活函数,它会把网络最后一层的若干个实数输出,转换成一堆和为 1 的概率值。比如输出的三个数字是 [2.0, 1.0, 0.5],经过 Softmax 后变成 [0.65, 0.24, 0.11],我们就说这个样本有 65% 概率属于第一类。
提示:新手最常见的错误之一,就是在多分类任务里最后一个隐藏层忘加 Softmax,导致输出不是概率分布。遇到这种情况,模型的损失会很高,而且很难收敛。
3.3 损失函数与优化器到底在干什么
损失函数给出“模型错得有多离谱”的量化指标,反向传播就是沿着这个指标的梯度方向调整权重。代码里用的损失函数是sparse_categorical_crossentropy,名字看着长,拆开理解就不难:
- categorical_crossentropy:交叉熵损失,用于分类任务。它在数学上衡量的是两个概率分布之间的差距。
- sparse_ 前缀:表示我们喂给模型的标签是整数(0、1、2),而不是 one-hot 编码后的向量。如果你用了 one-hot,需要去掉这个前缀,直接用
categorical_crossentropy。
优化器是负责权重更新的策略,代码里选的是 Adam。Adam 可以理解为一个自带“惯性”的梯度下降:它不仅看当前坡度,还参考前几步的下降方向来修正步伐,结果就是它收敛快、对学习率不敏感。对新手来说,Adam 是最不会出错的默认选择。
4. 实操部分:10 行核心代码搭建神经网络
4.1 环境准备:一杯咖啡的时间
开始写代码之前,先把环境整明白,不然代码跑不起来,你会在环境问题上浪费两小时。
我自己常用的一套配置如下:
Python 3.9 或以上 tensorflow 2.10 或以上 scikit-learn 1.2 或以上 numpy 1.23 或以上安装命令很简单:
pip install tensorflow scikit-learn numpy如果你用的是 M 系列芯片的 Mac,或者有 NVIDIA GPU,可能需要额外处理。但这类内容网上已经有很多中文教程,这里不展开,默认我用的是 CPU 版 TensorFlow。鸢尾花数据集就三万多个数值,跑几十轮迭代,CPU 也就是几秒的事。
注意:TensorFlow 在 M 系列 Mac 上的安装,推荐直接用
pip install tensorflow-macos或者用tensorflow的官方轮子,这几年的兼容性已经很好了。Windows 上则要注意 Python 版本,3.11 以上某些轮子可能不完整,保险起见建议用 3.9 或 3.10。
4.2 完整代码:一个可以照抄的版本
先把全部代码贴出来。这段代码我精简到 12 行,其中两行是导入和打印,严格意义上的网络定义加训练流程就是 10 行左右:
import numpy as np from tensorflow import keras from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split X, y = load_iris(return_X_y=True) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) model = keras.Sequential([ keras.layers.Dense(8, activation='relu', input_shape=(4,)), keras.layers.Dense(3, activation='softmax') ]) model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy']) model.fit(X_train, y_train, epochs=50, verbose=0) print(model.evaluate(X_test, y_test))补充 end-to-end runnable 版本,包含预测部分:
import numpy as np from tensorflow import keras from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split X, y = load_iris(return_X_y=True) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) model = keras.Sequential([ keras.layers.Dense(8, activation='relu', input_shape=(4,)), keras.layers.Dense(3, activation='softmax') ]) model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy']) model.fit(X_train, y_train, epochs=50, verbose=0) y_pred = np.argmax(model.predict(X_test), axis=1) print("预测结果:", y_pred) print("真实标签:", y_test) loss, acc = model.evaluate(X_test, y_test, verbose=0) print(f"测试集损失:{loss:.4f},准确率:{acc:.4f}")这段代码在标准环境下,跑完大概需要 1 到 2 秒。
4.3 逐行拆解:每一行代码在干什么
现在逐行讲。跳过导入和数据处理两行,从核心代码开始。
第一段是模型结构定义:
model = keras.Sequential([ keras.layers.Dense(8, activation='relu', input_shape=(4,)), keras.layers.Dense(3, activation='softmax') ])Sequential的中文直译是“顺序模型”,意思是网络层是按顺序前后拼接的。第一层Dense(8, activation='relu', input_shape=(4,))是全连接层,有 8 个神经元,接收 4 个输入特征,激活函数是 ReLU。这里有个常见疑问:隐藏层神经元数量为什么选 8?
其实这个数字不是严格算出来的。对于这种只有 4 个输入、3 个类别的简单任务,隐藏层神经元 4 到 16 个都行,8 是个人偏好——太少学不到特征,太多容易过拟合还会拖慢训练。这个参数到了真实项目里,一般靠试或者靠经验法则:输入维度加输出维度的两倍附近。
第二层Dense(3, activation='softmax')是输出层,3 个神经元对应 3 个类别,Softmax 把输出变成概率分布。注意它不需要再指定input_shape,Keras 会自动沿用上一层的输出维度。
第二段是编译:
model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])compile的意义是把你之前定义的网络和训练策略绑定。三个参数分别指定了如何更新权重、如何计算误差、评估模型时关注什么指标。这一步不涉及实际计算,只是配置。
第三段是训练:
model.fit(X_train, y_train, epochs=50, verbose=0)fit才是真正开始干活的地方。epochs=50表示将整个训练集完整送入网络 50 遍。每一遍,模型都会把 120 个训练样本(鸢尾花总共 150 个,切走 30 个测试集)分批送进去,计算梯度、更新参数。verbose=0表示不打印每个 epoch 的进度条。如果想看训练过程中的 loss 变化,把verbose改成 1 或 2。
4.4 数据准备的细节:为什么不直接塞原始数据
这次用的鸢尾花数据集,是机器学习领域最经典的“练习题库”:每个样本有 4 个数值特征(花萼长宽、花瓣长宽),标签是三种鸢尾花之一(山鸢尾、变色鸢尾、维吉尼亚鸢尾)。
你可能要问:为什么加载数据之后要先做train_test_split?
因为机器学习的铁律是:模型必须用“没见过”的数据来检验。你要是在全部数据上训练,又在同一份数据上评估,模型会“背答案”而不自知——准确率虚高,换个新数据就现出原形。test_size=0.2就是切 20% 出来留作考试,剩下 80% 用来学习。
random_state=42是固定随机种子。很多第一次跑代码的人不理解为什么非要固定它:如果你不用固定种子,每次切分的数据都不一样,每次跑出来的准确率也会轻微浮动,这不利于复现实验结果。固定 42,你能保证自己和别人跑出来的训练集、测试集分布完全一致,方便对比。
实操心得:我强烈建议新手在做这类实验时,第一步永远先打印一下
X_train.shape和y_train.shape。我见过太多学员因为数据 shape 和网络输入对不上,卡在莫名其妙的地方。先确认数据维度,再定义模型,能省掉 80% 的调试时间。
4.5 模型评估:如何判断好坏
模型的最后一行:
print(model.evaluate(X_test, y_test))evaluate会同时返回测试集上的损失值和准确率,通常跑出来的结果在 0.9 以上的准确率。这个结果远超三分类随机猜测的 33%,说明模型确实学到了分类规律。
但只看一个准确率数字,还不能体现深入观察的价值。我一般会再加一段预测展示,看看模型具体错在哪些样本上:
y_pred = np.argmax(model.predict(X_test), axis=1) for i in range(len(y_test)): status = "✔" if y_pred[i] == y_test[i] else "✘" print(f"样本{i}: 预测{y_pred[i]}, 真实{y_test[i]} {status}")其中np.argmax(..., axis=1)的含义是把概率最高的类别的下标提取出来。模型输出的概率分布是三维向量,比如[0.05, 0.90, 0.05],Argmax 就会返回 1。看到哪些样本被分错,能帮你理解模型的能力边界,也方便后续脏数据分析。
5. 从零手写一个更精简的网络(理解辅助)
5.1 不用框架,能不能更简单地理解神经网络
如果你觉得上面 Keras 的代码还是有点“黑盒”,这里再提供一个更加底层的版本。我不用 TensorFlow,而是只用 NumPy 搭建一个等价网络的核心逻辑,其中隐藏层 8 个神经元、输出层 3 个神经元,激活函数分别为 ReLU 和 Softmax,损失函数是交叉熵。用梯度下降法手动更新参数。这个版本更直白,适合理解神经网络的内部运转。
import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder X, y = load_iris(return_X_y=True) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) X_train = (X_train - X_train.mean(axis=0)) / X_train.std(axis=0) X_test = (X_test - X_test.mean(axis=0)) / X_test.std(axis=0) enc = OneHotEncoder(sparse=False) Y_train = enc.fit_transform(y_train.reshape(-1, 1)) np.random.seed(42) W1 = np.random.randn(4, 8) * 0.1 b1 = np.zeros((1, 8)) W2 = np.random.randn(8, 3) * 0.1 b2 = np.zeros((1, 3)) lr = 0.1 for epoch in range(200): # 前向传播 z1 = X_train @ W1 + b1 a1 = np.maximum(0, z1) # ReLU z2 = a1 @ W2 + b2 exp_z = np.exp(z2 - z2.max(axis=1, keepdims=True)) probs = exp_z / exp_z.sum(axis=1, keepdims=True) # Softmax # 交叉熵损失 loss = -np.mean(np.log(probs[np.arange(len(Y_train)), y_train])) # 反向传播 dz2 = probs - Y_train dW2 = a1.T @ dz2 / len(X_train) db2 = dz2.mean(axis=0, keepdims=True) dz1 = (dz2 @ W2.T) dz1[a1 <= 0] = 0 # ReLU 导数 dW1 = X_train.T @ dz1 / len(X_train) db1 = dz1.mean(axis=0, keepdims=True) # 参数更新 W1 -= lr * dW1 b1 -= lr * db1 W2 -= lr * dW2 b2 -= lr * db2 if epoch % 20 == 0: print(f"epoch {epoch}, loss {loss:.4f}") # 测试 z1 = X_test @ W1 + b1 a1 = np.maximum(0, z1) z2 = a1 @ W2 + b2 pred = np.argmax(z2, axis=1) acc = (pred == y_test).mean() print("测试准确率:", acc)这段纯 NumPy 代码有 50 多行,但它的逻辑和前面 Keras 版本是完全等价的。当你亲手完成从“前向传播算输出”到“反向传播更新权重”的闭环,再去回看 Keras 的 API,就会有一个特别直观的感受:model.fit()背后打包的,就是这些你刚刚手写过的操作。
5.2 从手写版本中学到的三条实战教训
第一,权重初始化不能乱来。上面代码里我用np.random.randn(...) * 0.1,也就是把标准正态采样的值压缩到 0.1 倍。这是为了确保初始梯度不会太大或太小。如果你用np.random.randn(4, 8)而不乘任何系数,参数初始值动辄 1、2,网络前向传播后输出的 Softmax 概率会接近 one-hot,梯度接近零,训练基本废掉。
第二,Softmax 前要减去最大值。我在np.exp(z2 - z2.max(axis=1, keepdims=True))这一行做了这个操作。虽然名字还是 Softmax,但结果等价,而且避免了np.exp在输入较大时产生极大数导致的计算溢出。这是数值稳定性上的一个经典细节,框架内部早就实现了,但你手写时很容易忽略。
第三,损失函数的“真实标签路径”写法。交叉熵在实现时要索引到正确类别的概率:np.log(probs[np.arange(len(Y_train)), y_train])。这个写法一次算出所有样本的交叉熵贡献,比写 for 循环快很多,也是 NumPy 常用的向量化技巧。
手写版本的学习率我用的是 0.1,这算是个经验起始值。太大容易震荡不收敛,太小则要等很久才能下降。你把它改成 1.0 试试,会看到 loss 曲线剧烈抖动,甚至越调越高,这就是学习率过大的直观感受。
5.3 手写版本和框架版本的性能对比
很多读者关心:手写版本有没有意义?我的观点是:纯手写神经网络对你理解“神经网络是什么”极有帮助,但仅此而已。它并不是一个可用于生产的方案。
| 对比维度 | Keras 版本 | 手写 NumPy 版本 |
|---|---|---|
| 代码量 | 约 10 行核心代码 | 50 行以上 |
| 自动微分 | 内置 | 手动推导 |
| 支持 GPU | 自动 | 不支持 |
| 数值稳定性 | 内置保证 | 需自己处理 |
| 调试难度 | 略黑盒 | 完全透明 |
| 学习价值 | 工程落地思维 | 算法原理思维 |
建议路径是:先用 Keras 帮你建立全局观,跑通了再回头手写一遍,两相对照,你的理解会非常扎实。
6. 常见问题速查表与避坑心得
6.1 新手高频问题:症状、原因与对策
我把这些年带新人跑神经网络时遇到的常见问题整理成一个速查表,按“症状-原因-对策”三列排开:
| 症状 | 原因 | 对策 |
|---|---|---|
| 训练时 loss 是 NaN | 学习率过大或数据需要归一化 | 降低学习率,检查数据是否标准化 |
| 准确率一直停留在 0.33 左右 | 模型没学到东西,可能网络太浅 | 检查激活函数,确认标签格式 |
| 训练 loss 一直在下降但测试准确率低 | 过拟合 | 增加数据、增加 Dropout 层 |
| 报错 shape 不匹配 | 输入维度声明错了 | 打印数据 shape,对照网络第一层输入维度 |
| 第一次能跑通,第二次结果不一样 | 没有固定随机种子 | 代码开头np.random.seed(42) |
| 模型输出全是同一个类 | 最后一层没加合适的激活 | 分类任务加 Softmax |
6.2 关于 Keras 版本兼容性经验
TensorFlow 2.x 的 API 迭代很快,网上搜到的旧教程经常遇到 API 消失或者参数更名。比如早年间keras是一个独立安装的库,现在官方推荐的做法是直接用from tensorflow import keras。如果你在环境里单独装了keras,同时又装了tensorflow,经常会出现TypeError: 'module' object is not callable或者奇怪的导入错误。
推荐的诊断路径是:代码开头打印print(tf.__version__)和print(keras.__version__),确认它们来自同一个 TensorFlow 包。如果模块版本混乱,建议注销冲突的包,只留 TensorFlow 自带的那份。
6.3 数据归一化:多数新手会忽略的隐藏步骤
鸢尾花数据集的四个特征,取值范围都在 0 到 8 之间,量级差异不大,所以很多教程不归一化也跑得很好。但一旦你切换到真实数据,比如“收入”是几万、“年龄”是几十、OneHot列是 0 或 1,模型很可能训不动,或者训练特别慢。原因是数值大的特征天然占据了更大的梯度权值,相当于它“喊的声音”比其他特征大得多。归一化之后,所有特征处于同一数量级,模型就听得到所有特征的声音。
常见的归一化手段是标准化:让所有特征满足均值为 0、方差为 1。这种方式在极端值很多的数据上比简单的 MinMax 缩放更稳定。
我专门加了这个内容是因为:很多人调了半天参数,准确率上不去,最后发现只是少了这几行数据预处理代码。在真实项目里,数据预处理的重要性远高于神经网络结构本身。
6.4 我的个人调试习惯
最后分享几个我自己的调试习惯,不一定标准,但实测下来很管用:
第一,第一次训练永远把verbose开成 1 或 2,盯着每个 epoch 的 loss 变化。loss 从大到小,说明在学;loss 忽大忽小,说明学习率问题或数据问题;loss 一直不动,基本就是数据或梯度出问题。改参数不可怕,可怕的是你闭着眼睛调。
第二,固定random_state。不只是切分数据,模型的随机种子也值得固定。虽然 Keras 并不总是能完美复现(GPU 上的确定性本身就是个难题),但固定的种子至少能让你在不同代码版本间做有效对比。
第三,把可视化加进来。每轮训练后用 Matplotlib 绘制 loss 曲线,用混淆矩阵观察错误类型。绝大多数网络问题都能从“训练曲线”和“错误分布”中一眼看出来。
7. 后续扩展:从 10 行到真实项目
很多读者跑通这个实验后会问:下一步学什么?我按学习路径排一个优先级:
首先是换掉数据集。鸢尾花数据集只能做演示,真实项目里你不会遇到这么“乖巧”的数据。下一步建议先换成人手写数字数据集(MNIST),它也是 10 分类,但图像数据意味着你要从向量输入升级到矩阵输入。你会碰到卷积神经网络(CNN)这个方向,代码里只需把Dense层换成Conv2D和MaxPooling2D,但理解起来可能需要几天。
然后是理解训练技巧。比如你可以在现在的模型上加入Dropout层看看过拟合怎么缓解,或者把优化器换掉对比收敛速度。我写过一个实验,在相同数据上分别用 SGD、Adam、RMSprop 跑,误差曲线差异很大,建议新手工余时间都试试。
再往后就是序列数据处理,自然语言或者时间序列都会涉及 RNN 和 LSTM。这些方向的模型结构跟咱们现在这个简单前馈网络已经有很大差别,但是底层的“前向传播-反向传播-优化器”这一套三件套逻辑完全一致。
我个人的体会是:搞懂原理之后再去看那些看起来特别复杂的深度学习项目,心里会踏实很多。因为任何复杂模型,拆到底都是若干层、若干激活函数、一个损失函数、一个优化器在协同工作,而这些你都亲手搭建过——虽然只有 10 行,但那是你从 0 到 1 跨出的最重要一步。