从算法到人工智能 · 第 23 课:神经网络——从线性到非线性
上一课我们学会了线性回归:y = wx + b,一条直线。但现实世界大多不是直线:
- 房价和面积的关系,可能先平缓后陡峭(不是直线)
- 一张图是不是"猫",根本没法用一个线性公式判断
- 一段文字的情感,和词频也不是线性关系
这些都需要非线性。神经网络(Neural Network) 的核心,就是"把很多线性组合,叠加非线性激活,层层堆叠",从而拟合任意复杂的函数。这一课,我们亲手搭一个神经网络,看它怎么突破直线的局限。
一、问题:一条直线不够用
先看一个简单但"直线搞不定"的问题——异或(XOR):
| x1 | x2 | 输出 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
XOR 是"两个输入不同则输出 1"。你不可能用一条直线把平面上的 (0,0)(1,1) 和 (0,1)(1,0) 分开。而神经网络可以——这就是它存在的意义。
二、神经网络是什么:一层层"神经单元"
一个神经元做的事,就是我们上节课的线性回归,再加一个非线性激活函数:
神经元: z = w·x + b (线性组合)
a = 激活(z) (非线性激活)z = w·x + b:对输入加权求和(熟悉的线性部分)激活函数:引入非线性,这是关键
常见激活函数
| 函数 | 公式 | 作用 |
|---|---|---|
| Sigmoid | 1/(1+e⁻ˣ) | 压到 (0,1),像概率 |
| ReLU | max(0, x) | 简单高效,最常用 |
| Tanh | (eˣ-e⁻ˣ)/(eˣ+e⁻ˣ) | 压到 (-1,1) |
ReLU 是现在的主流:ReLU(x) = max(0, x)——负的变 0,正的保留。简单、计算快、效果好。
为什么 ReLU 赢了?——梯度消失与梯度爆炸
这背后是深度学习训练的头号难题:梯度消失 / 梯度爆炸(Vanishing / Exploding Gradient)。
回忆上一课的反向传播:梯度要从输出层一层层乘回去。每一层都要乘一个导数。
- Sigmoid / Tanh 的病:它们在两端是"饱和"的——导数趋近 0(sigmoid 导数最大只有 0.25)。层一深,0.25 连乘几十次,梯度就小到几乎为 0 → 梯度消失,前面的层根本学不动。
- 反过来,如果权重初始化得太大,连乘会指数爆炸 → 梯度爆炸,参数乱跳,训练崩掉。
ReLU 的答案:ReLU(x)=max(0,x),在 x>0 的区间导数恒等于 1。1 连乘多少层还是 1,梯度不会凭空消失。这就是 ReLU 能支撑几十层、上百层深网络的底层原因(负半区导数为 0 造成的"死神经元"问题,则由后面要学的 Leaky ReLU、BatchNorm 等缓解)。
记住:激活函数选 ReLU,核心不是"简单",而是它在正区间的导数恒为 1,从根上缓解了梯度消失。这也是为什么你几乎看不到现代深度网络还在用 sigmoid 当隐藏层激活。
三种激活函数导数的直观对比
| 函数 | 导数特点 | 深层网络的命运 |
|---|---|---|
| Sigmoid | 两端趋 0,最大 0.25 | 连乘后梯度消失 |
| Tanh | 两端趋 0,最大 1 | 仍会饱和、梯度消失 |
| ReLU | 正区间恒为 1 | 梯度不衰减,能堆很深 |
三、从零手写一个神经网络
我们用纯 Python + NumPy,搭一个能学 XOR 的最小网络:2 输入 → 4 隐藏神经元 → 1 输出。隐藏层用 ReLU,输出层用 Sigmoid。
import numpy as np
# XOR 数据
X = np.array([[0,0],[0,1],[1,0],[1,1]], dtype=float)
y = np.array([[0],[1],[1],[0]], dtype=float)
np.random.seed(1)
# 参数:W1(2x4), b1(1x4), W2(4x1), b2(1x1)
W1 = np.random.randn(2, 4) * 0.5
b1 = np.zeros((1, 4))
W2 = np.random.randn(4, 1) * 0.5
b2 = np.zeros((1, 1))
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def relu(z):
return np.maximum(0, z)
lr = 0.5
for epoch in range(5000):
# 前向传播
z1 = X @ W1 + b1 # 隐藏层线性
a1 = relu(z1) # 隐藏层激活(非线性!)
z2 = a1 @ W2 + b2 # 输出层线性
a2 = sigmoid(z2) # 输出层激活(压到 0~1)
# 损失(二分类交叉熵的简化:MSE)
loss = np.mean((a2 - y) ** 2)
# 反向传播(链式法则求梯度)
d_a2 = 2 * (a2 - y) / len(y)
d_z2 = d_a2 * a2 * (1 - a2) # sigmoid 导数
d_W2 = a1.T @ d_z2
d_b2 = np.sum(d_z2, axis=0, keepdims=True)
d_a1 = d_z2 @ W2.T
d_z1 = d_a1 * (z1 > 0) # ReLU 导数
d_W1 = X.T @ d_z1
d_b1 = np.sum(d_z1, axis=0, keepdims=True)
# 梯度下降更新
W1 -= lr * d_W1; b1 -= lr * d_b1
W2 -= lr * d_W2; b2 -= lr * d_b2
if epoch % 1000 == 0:
print(f"epoch {epoch:4d} | loss {loss:.5f}")
print("\n预测结果(应接近 [0,1,1,0]):")
print(a2.round(3).flatten())跑起来你会看到:loss 从 0.25 降到接近 0,输出从 [0.5,0.5,0.5,0.5] 变成 [0,1,1,0]。神经网络学会了 XOR——这是单条直线永远做不到的事。
四、核心机制:反向传播(Backpropagation)
上面的代码里,最"魔法"的部分是反向传播——怎么算出每个参数的梯度。
直觉理解:误差从输出层往回传,用链式法则逐层分配责任。
前向:X → z1 → a1 → z2 → a2(算出预测和损失)
反向:损失 → 逐层求导 → 更新每个 W、bd_z2 = d_a2 * sigmoid'(z2):误差乘激活函数的导数,往回传一层d_W2 = a1.T @ d_z2:这一层的权重梯度 = 上一层激活 × 本层误差- 继续往上一层传
d_a1 = d_z2 @ W2.T,再乘relu'(z1)……
这就是"链式法则":误差像水流一样,从输出倒灌回输入,每一层都算出"自己该负多少责",然后各自往梯度反方向调一点。
现代深度学习框架(PyTorch、TensorFlow)就是自动帮你做这个——但原理就是今天这段代码。
五、为什么"多一层"能拟合任意函数
万能近似定理:一个含足够多隐藏神经元的单隐藏层网络,可以任意逼近任何连续函数。层数越多、每层神经元越多,表达能力越强。
这就是"深度学习"名字的由来——深 = 层多。第 16~18 课的 Transformer,本质就是一个几十上百层的神经网络。
六、交叉熵:分类为什么不该用 MSE
第三节的代码,输出层用 sigmoid,损失我却偷懒写了 MSE((a2 - y)²)。注释里也标了——那是"简化版"。真正的分类损失应该用交叉熵(Cross-Entropy),这背后有一个不补就说不通的道理。
MSE 在分类里的病根:梯度消失
当输出层用 sigmoid 时,MSE 对某个参数的梯度里,会带着一项 sigmoid'(z) = σ(1-σ):
- 预测越离谱(比如真实是 1,却预测成 0.0001),σ 越接近 0 或 1
- 此时
σ(1-σ)越接近 0 —— 梯度也趋近 0 - 结果:错得越狠,反而学得越慢,网络"摆烂"卡住不动
这就是梯度消失(vanishing gradient):损失函数选错了,优化器在最该努力的地方使不上劲。
交叉熵:恰好把这一项"约掉"
交叉熵损失的梯度(对 sigmoid 输出)化简后是惊人的简单:
损失 L = -[y·log(ŷ) + (1-y)·log(1-ŷ)] (ŷ 是预测概率)
梯度 dL/dz = ŷ - y (直接把 sigmoid' 约掉了!)看最后一行:梯度 = 预测值 - 真实值,和线性回归 MSE 的梯度长得一模一样。那个导致梯度消失的 σ(1-σ) 项,被交叉熵的 log 给"消"掉了。
所以:回归用 MSE,分类用交叉熵。这不是习惯,而是数学上"谁能让梯度顺畅流动"的选择。
信息论直觉(可选深挖)
交叉熵的公式来自信息论:它衡量"真实分布 y 和预测分布 ŷ 差多远"。
- 当 ŷ 和 y 完全一致,交叉熵 = 0(完美)
- 差得越远,交叉熵越大
更本质的说法:交叉熵 = 用预测分布 ŷ 去编码真实分布 y 时,平均要多花多少比特。预测越准,编码越省。所以最小化交叉熵 = 让预测分布尽可能接近真实分布——这正是我们想要的。
(回到第三节代码:把它改成交叉熵后,XOR 会收敛得更快更稳,而且不会像 MSE+sigmoid 那样在某些初始化下卡住。)
七、怎么判断模型好不好:评估指标
前面实验都在报"准确率(accuracy)"。但准确率有一个著名的陷阱:
一个"预测所有人都不患癌"的模型,在 99.9% 的健康人群里,准确率高达 99.9%——但它漏掉了所有癌症患者,毫无用处。
类别不平衡时,准确率会骗人。 所以有了更细的指标。先看一张表——混淆矩阵(Confusion Matrix):
| 预测为"正" | 预测为"负" | |
|---|---|---|
| 真实为正 | TP(真阳) | FN(假阴,漏报) |
| 真实为负 | FP(假阳,误报) | TN(真阴) |
由它衍生出四个核心指标:
| 指标 | 公式 | 回答的问题 |
|---|---|---|
| 准确率 Accuracy | (TP+TN) / 全部 | 整体对多少?(会骗人) |
| 精确率 Precision | TP / (TP+FP) | 报"正"里,有多少是真的? |
| 召回率 Recall | TP / (TP+FN) | 真的"正",捞回来多少? |
| F1 分数 | 2·P·R / (P+R) | 精确率与召回率的调和平均 |
记忆口诀:精确率管"别冤枉好人"(报得准不准),召回率管"别放过坏人"(捞得全不全)。
场景选择:
- 垃圾邮件过滤:宁可漏几个垃圾(低召回),也别误杀正常邮件(要高精确率)
- 癌症筛查:宁可多拉几个人复查(低精确),也别漏掉患者(要高召回率)
- 想两者兼顾:看 F1 或 ROC-AUC(横轴假阳率、纵轴真阳率围成的面积,0.5 是瞎猜、1.0 是完美)
代码一行算清:
from sklearn.metrics import classification_report, confusion_matrix
y_true = [0, 1, 1, 0, 1, 0, 1, 1] # 真实
y_pred = [0, 1, 0, 0, 1, 1, 1, 1] # 模型预测
print(confusion_matrix(y_true, y_pred))
print(classification_report(y_true, y_pred)) # 精确率/召回率/F1 一键出体会:报结果只说"准确率 95%"是不够的。真正负责的评估,要问清楚"这 95% 是在什么类别分布下得到的、漏了什么、冤了什么"。这第 7 节和第 6 节,是面试里"机器学习基础"几乎必考的两块。
八、复杂度小结
| 项 | 说明 |
|---|---|
| 前向传播 | 逐层矩阵乘法,O(参数量) |
| 反向传播 | 与前向同阶,O(参数量) |
| 参数量 | 相邻层神经元数的乘积之和 |
| 训练一轮 | O(样本数 × 参数量) |
九、动手时间 🎯
实验 1:跑上面的 XOR 网络,看 loss 下降
把第三节课代码完整跑一遍,观察输出如何从 [0.5,0.5,0.5,0.5] 逼近 [0,1,1,0]。这是"非线性能力"最直观的证明。
实验 2:感受激活函数的重要性(去掉非线性会怎样)
把 relu 换成恒等函数(即不加激活),看还能不能学 XOR:
import numpy as np
X = np.array([[0,0],[0,1],[1,0],[1,1]], dtype=float)
y = np.array([[0],[1],[1],[0]], dtype=float)
np.random.seed(1)
W1 = np.random.randn(2,4)*0.5; b1=np.zeros((1,4))
W2 = np.random.randn(4,1)*0.5; b2=np.zeros((1,1))
def sigmoid(z): return 1/(1+np.exp(-z))
lr=0.5
for epoch in range(5000):
z1 = X@W1+b1; a1 = z1 # 没有激活!纯线性
z2 = a1@W2+b2; a2 = sigmoid(z2)
loss = np.mean((a2-y)**2)
d_z2 = 2*(a2-y)/len(y)*a2*(1-a2)
d_W2 = a1.T@d_z2; d_b2=np.sum(d_z2,axis=0,keepdims=True)
d_a1 = d_z2@W2.T; d_z1 = d_a1 # 线性层导数=1
d_W1 = X.T@d_z1; d_b1=np.sum(d_z1,axis=0,keepdims=True)
W1-=lr*d_W1; b1-=lr*d_b1; W2-=lr*d_W2; b2-=lr*d_b2
if epoch%1000==0: print(f"epoch {epoch} | loss {loss:.5f}")
print("\n无激活函数输出:", a2.round(3).flatten(), "(学不会 XOR,loss 卡住)")体会:去掉激活函数后,多层线性堆叠还是线性,等价于一层——永远学不会 XOR。激活函数是神经网络的灵魂。
实验 3:用网络拟合一个"非直线"函数
用神经网络拟合一个波浪形的函数 y = sin(x)(纯线性回归做不到):
import numpy as np
np.random.seed(0)
X = np.linspace(-3, 3, 100).reshape(-1, 1)
y = np.sin(X)
np.random.seed(1)
W1 = np.random.randn(1, 16) * 0.5; b1 = np.zeros((1, 16))
W2 = np.random.randn(16, 1) * 0.5; b2 = np.zeros((1, 1))
def relu(z): return np.maximum(0, z)
lr = 0.01
for epoch in range(20000):
z1 = X @ W1 + b1; a1 = relu(z1)
z2 = a1 @ W2 + b2
loss = np.mean((z2 - y) ** 2)
d_z2 = 2 * (z2 - y) / len(y)
d_W2 = a1.T @ d_z2; d_b2 = np.sum(d_z2, axis=0, keepdims=True)
d_a1 = d_z2 @ W2.T; d_z1 = d_a1 * (z1 > 0)
d_W1 = X.T @ d_z1; d_b1 = np.sum(d_z1, axis=0, keepdims=True)
W1 -= lr * d_W1; b1 -= lr * d_b1; W2 -= lr * d_W2; b2 -= lr * d_b2
if epoch % 5000 == 0: print(f"epoch {epoch:5d} | loss {loss:.5f}")
print("\n最终 loss:", round(loss, 5), "(应降到 0.001 以下,拟合出了 sin 曲线)")体会:一个 16 个隐藏神经元的网络,就能拟合 sin 这种波浪曲线——这正是"万能近似"的力量。
实验 4(挑战):手写数字识别(MNIST 雏形)
这是神经网络最经典的应用。我们用 sklearn(或纯 NumPy)训练一个网络识别手写数字,看它从 28×28=784 个像素里"学"出数字:
# 若未安装 scikit-learn,先运行:pip install scikit-learn
from sklearn.datasets import load_digits
from sklearn.neural_network import MLPClassifier
from sklearn.model_selection import train_test_split
digits = load_digits() # 1797 张 8x8 手写数字图
X, y = digits.data / 16.0, digits.target
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
clf = MLPClassifier(hidden_layer_sizes=(64, 32), max_iter=300, random_state=42)
clf.fit(X_train, y_train)
acc = clf.score(X_test, y_test)
print(f"手写数字识别准确率: {acc:.1%}")体会:一个两层神经网络,就能在 8×8 手写数字上达到 95%+ 的准确率。它没有"看懂"任何笔画规则,纯粹是从像素里学出来的——这就是深度学习所有奇迹的起点。
十、小结
- 神经网络 = 线性组合 + 非线性激活,层层堆叠;激活函数是灵魂,去掉它多层也变回直线(学不了 XOR)。
- 反向传播 = 用链式法则把误差往回传,逐层算出每个参数的梯度,再梯度下降更新——现代深度学习框架自动做这个,原理就是今天这段代码。
- 万能近似定理:足够宽/深的网络能拟合任意连续函数;"深度学习"的"深",就是层数多。
先别急着往后翻,把 XOR 网络跑通、再把"去掉激活函数就学不会"对比一遍——这两个实验,让你彻底理解"非线性"和"反向传播"这两个神经网络最核心的概念。