上一课我们学会了线性回归:y = wx + b,一条直线。但现实世界大多不是直线:

  • 房价和面积的关系,可能先平缓后陡峭(不是直线)
  • 一张图是不是"猫",根本没法用一个线性公式判断
  • 一段文字的情感,和词频也不是线性关系

这些都需要非线性神经网络(Neural Network) 的核心,就是"把很多线性组合,叠加非线性激活,层层堆叠",从而拟合任意复杂的函数。这一课,我们亲手搭一个神经网络,看它怎么突破直线的局限。


一、问题:一条直线不够用

先看一个简单但"直线搞不定"的问题——异或(XOR)

x1x2输出
000
011
101
110

XOR 是"两个输入不同则输出 1"。你不可能用一条直线把平面上的 (0,0)(1,1) 和 (0,1)(1,0) 分开。而神经网络可以——这就是它存在的意义。


二、神经网络是什么:一层层"神经单元"

一个神经元做的事,就是我们上节课的线性回归,再加一个非线性激活函数

神经元: z = w·x + b          (线性组合)
         a = 激活(z)          (非线性激活)
  • z = w·x + b:对输入加权求和(熟悉的线性部分)
  • 激活函数:引入非线性,这是关键

常见激活函数

函数公式作用
Sigmoid1/(1+e⁻ˣ)压到 (0,1),像概率
ReLUmax(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、b
  • d_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) / 全部整体对多少?(会骗人)
精确率 PrecisionTP / (TP+FP)报"正"里,有多少是真的?
召回率 RecallTP / (TP+FN)真的"正",捞回来多少?
F1 分数2·P·R / (P+R)精确率与召回率的调和平均

记忆口诀:精确率管"别冤枉好人"(报得准不准),召回率管"别放过坏人"(捞得全不全)。

场景选择

  • 垃圾邮件过滤:宁可漏几个垃圾(低召回),也别误杀正常邮件(要高精确率
  • 癌症筛查:宁可多拉几个人复查(低精确),也别漏掉患者(要高召回率
  • 想两者兼顾:看 F1ROC-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%+ 的准确率。它没有"看懂"任何笔画规则,纯粹是从像素里学出来的——这就是深度学习所有奇迹的起点。


十、小结

  1. 神经网络 = 线性组合 + 非线性激活,层层堆叠;激活函数是灵魂,去掉它多层也变回直线(学不了 XOR)。
  2. 反向传播 = 用链式法则把误差往回传,逐层算出每个参数的梯度,再梯度下降更新——现代深度学习框架自动做这个,原理就是今天这段代码。
  3. 万能近似定理:足够宽/深的网络能拟合任意连续函数;"深度学习"的"深",就是层数多。

先别急着往后翻,把 XOR 网络跑通、再把"去掉激活函数就学不会"对比一遍——这两个实验,让你彻底理解"非线性"和"反向传播"这两个神经网络最核心的概念。

标签: none

添加新评论