# 勤奋的凯尔森同学10000h刻意练习Day17:手撕三层网络与学习机制 —— 前向传播、损失函数、数值梯度与梯度下降

所属专栏: 凯尔森的10000h刻意练习
Slogan: 一个36岁程序员, 研究生(学硕),前世界500强top20软件开发Java工程师,用10000小时深耕AI, 目标Top10 AI资深工程师, 全程日更记录。

网络搭好了,可它凭什么知道自己猜得对不对、又该往哪个方向调参数?

Day16把感知机和激活函数备齐了,今天先把它们拼成一个完整的三层网络,跑通“输入→权重→激活→输出”的前向传播,在手写数字上做一次推理。可推理只是“猜”,要让网络“学”,还需要三件东西:一把衡量“猜得有多错”的尺子(损失函数)、一个算“参数往哪调”的工具(数值梯度),和一套“沿着梯度迭代”的引擎(梯度下降)。

# 1.1 三层神经网络:从输入到输出的处理流程

深度神经网络由多个层(layer)组成,通常将其称之为模型(Model)。整个模型接受原始输入(特征),生成输出(预测),并包含一些参数。而在模型内部,每个单独的层都会接受一些输入(由前一层提供),生成输出(到下一层的输入),并包含一组参数;层层向下传递,就可以得到最终的输出值。

神经网络中的参数,就是每一层的权重和偏置。

我们这里以一个三层神经网络为例,实现从输入到输出的处理计算,这个过程就是前向传播(forward)。

简单起见,我们的输入层(第0层)有2个神经元;第1个隐藏层(第1层)有3个神经元;第2个隐藏层(第2层)有2个神经元;输出层(第3层)有2个神经元。

# 1.2 各层之间的信号传递

上面只是三层网络的示意图,实际上每层还应该有偏置,各输入信号加权总和还要经过激活函数的处理。接下来逐层进行分析,考察信号在各层之间传递的过程。

# 1.2.1 输入层(第0层)→ 第1层

权重和神经元的上标(1)表示网络层号。而下标对于神经元来说,就是这一层内的"索引号";对于权重来说则包含两个数字,分别代表前一层和后一层神经元的索引号。所以,就表示这是第1层的权重(输入层到第1层),并且是从第2个输入节点到第1层第1个节点。偏置的下标只有1个,因为前一层的偏置节点只有一个。

所以,可以利用式(2.4)(2.5)得到:

(2.6)

(2.7)

同样,对于第1层的第2个、第3个神经元,有:

我们可以直接写成矩阵乘法的形式:

(2.8)

其中,

可以看到,由于有2个输入节点、3个第1层节点,所以全连接层的权重W就应该是一个2×3的矩阵。这样,我们就可以很容易地利用Numpy中的矩阵乘法计算出输出信号值了。

矩阵维度的记忆口诀:权重矩阵的形状永远是"前一层神经元数 × 后一层神经元数"。输入是1×2,权重是2×3,乘完加偏置就是1×3——维度对不上,矩阵乘法直接报错,所以维度检查也是写神经网络代码时最快的排错手段。

# 1.2.2 第1层 → 第2层

第1层到第2层的处理类似,权重参数应该是一个3×2的矩阵。

# 1.2.3 第2层 → 输出层(第3层)

第2层到输出层的处理也类似,权重参数为2×2的矩阵;不过输出层的激活函数一般与隐藏层是不同的,这里用表示。

# 1.3 代码实现

我们可以将神经网络的所有参数(每一层的权重w和偏置b),保存在一个字典network中;并定义函数:

  • init_network():对参数进行初始化,每一个权重参数都是一个矩阵(二维),每一个偏置参数则是一个数组(一维);
  • forward():前向传播,将输入信号转换为输出信号的处理操作。

这里的激活函数,隐藏层用sigmoid,输出层用identity(恒等函数)。

具体代码如下(书中是从common.functions导入sigmoid和identity_function,这里直接给出等价实现,保证代码可独立运行):

import numpy as np

# 书中实现:from common.functions import sigmoid, identity_function
def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def identity_function(x):
    return x

def init_network():
    network = {}
    network['W1'] = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]])
    network['b1'] = np.array([0.1, 0.2, 0.3])
    network['W2'] = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]])
    network['b2'] = np.array([0.1, 0.2])
    network['W3'] = np.array([[0.1, 0.3], [0.2, 0.4]])
    network['b3'] = np.array([0.1, 0.2])

    return network

def forward(network, x):
    w1, w2, w3 = network['W1'], network['W2'], network['W3']
    b1, b2, b3 = network['b1'], network['b2'], network['b3']

    a1 = np.dot(x, w1) + b1   # 1×2 @ 2×3 -> 1×3
    z1 = sigmoid(a1)
    a2 = np.dot(z1, w2) + b2  # 1×3 @ 3×2 -> 1×2
    z2 = sigmoid(a2)
    a3 = np.dot(z2, w3) + b3  # 1×2 @ 2×2 -> 1×2
    y = identity_function(a3)

    return y

network = init_network()
x = np.array([1.0, 0.5])
y = forward(network, x)

print(y)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[0.31682708 0.69627909]
1

每一行np.dot对应一节信号传递:加权求和→激活→交给下一层。三次矩阵乘法,前向传播就走完了。

# 1.4 应用案例:手写数字识别

我们依然使用Digit Recognizer数据集来进行手写数字识别:https://www.kaggle.com/competitions/digit-recognizer (opens new window)。

文件train.csv中包含手绘数字(从0到9)的灰度图像,每张图像为28×28像素,共784像素。每个像素有一个0到255的值表示该像素的亮度。

文件第1列为标签,之后784列分别为784个像素的亮度值。

我们的任务,就是要搭建一个神经网络,实现它的前向传播;也就是要根据输入的数据(28×28 = 784数据点表示的图像),推断出它到底是哪个数字,这个过程也被称为"推理"。

这里,我们构建的也是一个三层神经网络,输入层应该有784个神经元,输出层有10个神经元(表示0~9的分类结果);中间设置2个隐藏层,第一个隐藏层有50个神经元,第二个隐藏层有100个神经元。这里的参数是需要学习得到的;我们假设已经学习完毕,直接从保存好的权重文件中读取即可。

权重文件从哪来? 这个文件不是sklearn自带的,而是教材官方提供的预训练权重sample_weight.pkl(本质是一个pickle字典,内含W1/b1/W2/b2/W3/b3六个数组)。到教材配套仓库下载并放到data/目录即可: https://raw.githubusercontent.com/oreilly-japan/deep-learning-from-scratch/master/ch03/sample_weight.pkl (opens new window)

也可以用浏览器打开仓库页面https://github.com/oreilly-japan/deep-learning-from-scratch/blob/master/ch03/sample_weight.pkl (opens new window)点"Download raw file"。注意该权重的输入维度是784,只适用于Kaggle的28×28数据;只用内置8×8数字集(64维)时推理会自动跳过——64维的完整训练流程在Day18实现。

对照1.3节的小网络看矩阵维度,套路完全一致:

信号传递 计算 维度变化
输入层 → 隐藏层1 x @ W1 + b1 784 → 50,W1是784×50
隐藏层1 → 隐藏层2 z1 @ W2 + b2 50 → 100,W2是50×100
隐藏层2 → 输出层 z2 @ W3 + b3 100 → 10,W3是100×10

和1.3节唯一的区别是输出层激活函数换成了softmax(多分类要输出概率分布),以及输入从单条向量变成了100条一个批次的矩阵(100×784 @ 784×50 = 100×50,批量推理一次算完)。

下面把数据加载、激活函数统一定义好(Day18训练时直接复用,不再重复定义)。load_data()负责加载、归一化、切分并返回训练/测试四份数据;有Kaggle的train.csv就读784维数据,没有则自动回退到sklearn内置的8×8手写数字集(64维),代码无需改动。推理阶段只用到测试集,训练集先留着:

import os
import pickle
import numpy as np
import pandas as pd
import joblib
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import MinMaxScaler

# 书中实现: from common.functions import sigmoid, softmax
def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def softmax(a):
    c = np.max(a, axis=-1, keepdims=True)  # 减最大值防止exp溢出, 支持批量输入
    exp_a = np.exp(a - c)
    return exp_a / np.sum(exp_a, axis=-1, keepdims=True)

def load_data():
    """加载 -> 7:3分层切分 -> 只用训练集fit归一化, 返回 x_train, x_test, y_train, y_test"""
    if os.path.exists("../data/train.csv"):       # Kaggle: 28×28=784维
        data = pd.read_csv("../data/train.csv")
        X_raw, y_raw = data.drop("label", axis=1).values, data["label"].values
    else:                                          # 离线兜底: 8×8=64维
        from sklearn.datasets import load_digits
        digits = load_digits()
        X_raw, y_raw = digits.data, digits.target
    # 先切分, 再归一化: 防止测试集的统计量(最大/最小值)泄露进训练过程
    x_train, x_test, y_train, y_test = train_test_split(
        X_raw, y_raw, test_size=0.3, random_state=42, stratify=y_raw)
    scaler = MinMaxScaler()                        # 归一化参数只从训练集学
    x_train = scaler.fit_transform(x_train)
    x_test = scaler.transform(x_test)             # 测试集只transform, 不fit
    return x_train, x_test, y_train, y_test

def init_network():
    # 兼容两种预训练权重: 自己的joblib文件, 或教材官方的sample_weight.pkl
    if os.path.exists("../data/nn_sample"):
        return joblib.load("../data/nn_sample")
    with open("../data/sample_weight.pkl", "rb") as f:
        return pickle.load(f)

def predict(network, x):
    w1, w2, w3 = network['W1'], network['W2'], network['W3']
    b1, b2, b3 = network['b1'], network['b2'], network['b3']

    a1 = np.dot(x, w1) + b1
    z1 = sigmoid(a1)
    a2 = np.dot(z1, w2) + b2
    z2 = sigmoid(a2)
    a3 = np.dot(z2, w3) + b3
    return softmax(a3)

# 推理只需要测试集; x_train/y_train 先留着, Day18训练时会用到
x_train, x_test, y_train, y_test = load_data()

weight_path = "../data/nn_sample" if os.path.exists("../data/nn_sample") else \
              "../data/sample_weight.pkl" if os.path.exists("../data/sample_weight.pkl") else None

if weight_path is None:                          # 两个权重文件都没有
    print("未找到预训练权重(nn_sample 或 sample_weight.pkl), 跳过推理。")
    print("下载地址见本节说明; 或直接学Day18, 用反向传播自己训练一份。")
elif x_test.shape[1] != 784:                     # 权重只认784维输入
    print(f"预训练权重要求784维输入, 当前数据是{x_test.shape[1]}维(内置digits), 跳过推理。")
else:
    network = init_network()
    batch_size, accuracy_cnt = 100, 0
    for i in range(0, len(x_test), batch_size):
        x_batch = x_test[i:i+batch_size]              # 100×784
        y_batch = predict(network, x_batch)          # 100×10
        p = np.argmax(y_batch, axis=1)               # 概率最大的下标即预测类别
        accuracy_cnt += np.sum(p == y_test[i:i+batch_size])
    print("Accuracy:" + str(float(accuracy_cnt) / len(x_test)))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72

几个细节值得留意:

  • 归一化只fit训练集:fit_transform只用在训练集上,测试集仅transform,这和Day13讲的"防止数据泄露"是同一个规矩。
  • 批量推理:for循环每次取100条组成一个批次,矩阵乘法一次性算完100×784 @ 784×50,比逐条循环快得多——这就是GPU时代神经网络的标准玩法(Day18训练时同样按小批量喂数据)。
  • 输出层用softmax:隐藏层仍然用sigmoid,输出层换成softmax把10个输出压成概率分布,np.argmax取概率最大的下标就是预测的数字。

# 1.5 损失函数:模型学习的指南针

神经网络需要以某个指标为线索来寻找最优权重参数,这个指标就是损失函数。损失函数的值越小,代表当前参数越适合训练数据。常见损失函数按任务类型分为三大类:回归损失、分类损失和通用损失。

# 1.5.1 均方误差(MSE/L2 Loss)

均方误差(Mean Squared Error, MSE),也称L2 Loss,是回归任务和最基础的神经网络训练中最常用的损失函数之一:

其中表示神经网络的输出,表示监督数据的标签(正确的解标签),是数据的维度。对于固定维度的网络,前面的系数有时不重要,公式也可以写成:

这个系数是为了求导时和平方项的2约掉,让梯度表达式更简洁。L2 Loss有一个明显特点:对异常值敏感,遇到离群点时误差会被平方放大,容易导致梯度爆炸。

代码实现如下:

import numpy as np

def mean_squared_error(y, t):
    return 0.5 * np.sum((y - t)**2)

# 验证:预测与标签越接近,损失越小
y = np.array([0.1, 0.2, 0.7])
t = np.array([0, 0, 1])
print("接近标签时的MSE:", mean_squared_error(y, t))

y_far = np.array([0.5, 0.3, 0.2])
print("远离标签时的MSE:", mean_squared_error(y_far, t))
1
2
3
4
5
6
7
8
9
10
11
12
接近标签时的MSE: 0.04500000000000001
远离标签时的MSE: 0.385
1
2

# 1.5.2 交叉熵误差

除了均方误差,交叉熵误差(Cross Entropy Error)也经常被用作损失函数,尤其在分类任务中比MSE更常用:

其中表示自然对数,表示神经网络的输出(通常经过softmax后表示概率),表示正确解标签。在one-hot表示中,只有正确解标签对应的值为1,其它均为0。这意味着交叉熵实际上只关心模型对正确类别的预测概率:概率越接近1,损失越接近0;概率越接近0,损失趋向无穷大。

代码实现如下:

def cross_entropy_error(y, t):
    if y.ndim == 1:
        t = t.reshape(1, t.size)
        y = y.reshape(1, y.size)

    # 监督数据是one-hot向量的情况下,转换为正确解标签的索引
    if t.size == y.size:
        t = t.argmax(axis=1)

    batch_size = y.shape[0]
    return -np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size

# 验证:batch_size=1,正确类别索引为2
y = np.array([[0.1, 0.2, 0.7]])
t = np.array([[0, 0, 1]])
print("交叉熵损失:", cross_entropy_error(y, t))

# 如果预测概率很低
y_low = np.array([[0.5, 0.4, 0.1]])
print("预测错误时的交叉熵:", cross_entropy_error(y_low, t))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
交叉熵损失: 0.35667494393873245
预测错误时的交叉熵: 2.302585092994046
1
2

# 1.6 分类任务损失函数

分类任务是深度学习最常见的场景之一。按类别数量可分为二分类和多分类,两者的损失函数形式略有不同。

# 1.6.1 二分类任务:二元交叉熵(Binary Cross-Entropy)

二分类任务常用二元交叉熵损失函数(Binary Cross-Entropy Loss)。模型输出一个概率值,表示样本属于正类(标签为1)的概率:

其中:

  • 为真实值,通常为0或1
  • 为预测值,表示样本为1的概率

这个公式的直觉很清晰:当时,损失变成,模型越确信是正类(概率接近1),损失越小;当时,损失变成,模型越确信是负类,损失越小。

# 1.6.2 多分类任务:多类交叉熵(Categorical Cross-Entropy)

多分类任务常用多类交叉熵损失函数(Categorical Cross-Entropy Loss)。它是对每个类别的预测概率与真实标签之间差异的加权平均:

其中:

  • 是类别数
  • 为真实值,表示是否为类别,通常为0或1
  • 为预测值,表示样本为类别的概率

由于one-hot标签中只有正确类别为1,其余为0,上式实际上只累加了正确类别对应的项。这和1.1.2节的交叉熵误差是同一回事,只是这里更明确地写出了多类别求和的形式。

# 1.7 回归任务损失函数

回归任务预测连续值,不同的损失函数对异常值的容忍度和梯度特性不同,适用于不同场景。

# 1.7.1 MAE/L1 Loss

平均绝对误差(Mean Absolute Error, MAE),也称L1 Loss:

L1 Loss对异常值鲁棒,因为误差是线性增长而非平方增长。但它在0点处不可导,优化时需要用次梯度或平滑近似。

# 1.7.2 MSE/L2 Loss

均方误差(Mean Squared Error, MSE),也称L2 Loss:

L2 Loss对异常值敏感,遇到异常值时易发生梯度爆炸。优点是处处可导,优化稳定。这与1.1.1节的MSE是同一个公式,只是在回归语境下重新强调它的特性。

# 1.7.3 Smooth L1 Loss

Smooth L1 Loss(也叫Huber Loss的一种形式)结合了L1和L2的优点:

当误差较小()时使用L2 Loss,使得损失函数平滑可导;当误差较大()时使用L1 Loss降低异常值的影响。这个设计让损失在0附近像抛物线(梯度连续),在远处像直线(梯度不爆炸),是目标检测等任务中的标配损失函数。

何时用哪个损失函数? 从任务和数据两个维度选:

  • 分类任务:首选交叉熵(二分类用BCE,多分类用CCE)。交叉熵的梯度在预测错误时更大,训练初期收敛更快;而MSE在分类任务中梯度信号弱,容易导致学习缓慢。
  • 回归任务,数据干净:用MSE/L2,优化稳定、收敛快。
  • 回归任务,有异常值:用MAE/L1,对离群点不敏感。
  • 回归任务,既要稳定又要抗异常值:用Smooth L1,兼顾两者优点。目标检测的边界框回归几乎都用它。

# 1.8 数值微分:梯度的度量衡

损失函数的值越小,代表选取的参数越适合。想要求得损失函数的最小值,最基本的想法就是对函数求导,解出导数值为0的点,并判断它是否为极小值或最小值。

然而,实际的神经网络损失函数极其复杂,直接求导不容易得到解析解。这时可以用数值微分的方式来求某点处的导数,这在工程上应用非常广泛。

# 1.8.1 导数的定义与数值微分

在数学上,导数被定义为:

这个定义表达出了导数的本质:当发生一个微小的变化时,函数值也会发生变化;当趋近于0时,的"变化率"就是这一点的导数值。

利用这个定义,可以直接以数值计算的方式,用微小的差分来求函数某点处的导数值,这种方法称为数值微分。

# 1.8.2 中心差分:更稳定的数值微分

数值微分可以用代码非常方便地实现。但直接用这种前向差分会有截断误差。更好的做法是以为中心,计算两边各发生微小变化后的差分:

这种方法称为中心差分。它通过对称采样抵消了一部分高阶误差,精度比单侧差分高一个数量级。另外,取微小值时不能太小,比如不能取,否则会导致计算机浮点数表示的精度不够,出现舍入误差。工程上通常取是一个安全且有效的选择。

代码实现如下:

def numerical_diff(f, x):
    h = 1e-4  # 微小值 0.0001
    return (f(x + h) - f(x - h)) / (2 * h)

# 验证:f(x) = x^2 的导数在 x=3 处应为 6
def f1(x):
    return x ** 2

print("f(x)=x^2 在 x=3 处的导数:", numerical_diff(f1, 3.0))

# 验证:f(x) = x^3 的导数在 x=2 处应为 12
def f2(x):
    return x ** 3

print("f(x)=x^3 在 x=2 处的导数:", numerical_diff(f2, 2.0))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
f(x)=x^2 在 x=3 处的导数: 6.000000000039306
f(x)=x^3 在 x=2 处的导数: 12.000000000663693
1
2

误差在量级,对于工程计算完全够用。

# 1.8.3 偏导数

如果函数的自变量并非单个元素,而是多个元素,例如:

可将其中一个元素看作参数,此时可看作关于另一元素的函数。在固定的情况下,可计算关于的导数:

这种导数称为偏导数。更一般地,一个多元函数在点处对的偏导数定义为:

偏导数同样可以用数值微分来求:只改变一个自变量,其它保持不变,做差分考察函数值的变化率。

# 1.8.4 梯度

多元函数关于每个变量都有偏导数,在点处,这些偏导数定义了一个向量:

这个向量称为在点的梯度。例如在处的梯度为。

在函数的极小值、极大值和鞍点处,梯度为0。需要注意的是,梯度代表的其实是函数值增大最快的方向;在实际应用中,我们需要寻找损失函数的最小值,所以一般选择负梯度向量。负梯度代表的是函数值减小最快的方向,但它并不一定直接指向函数图像的最低点,只是局部最速下降方向。

利用数值微分,可以在代码中实现梯度的计算:

def numerical_gradient(f, x):
    h = 1e-4  # 0.0001
    grad = np.zeros_like(x)

    for idx in range(x.size):
        tmp_val = x[idx]
        x[idx] = float(tmp_val) + h
        fxh1 = f(x)  # f(x+h)

        x[idx] = tmp_val - h
        fxh2 = f(x)  # f(x-h)
        grad[idx] = (fxh1 - fxh2) / (2 * h)

        x[idx] = tmp_val  # 还原值

    return grad

# 验证:f(x, y) = x^2 + x*y + y^2 在 (1, 1) 处的梯度应为 [3, 3]
def f3(x):
    return x[0]**2 + x[0]*x[1] + x[1]**2

x = np.array([1.0, 1.0])
grad = numerical_gradient(f3, x)
print("f(x,y)=x^2+xy+y^2 在 (1,1) 处的梯度:", grad)

# 验证:f(x, y) = x^2 + y^2 在 (3, 4) 处的梯度应为 [6, 8]
def f4(x):
    return x[0]**2 + x[1]**2

x2 = np.array([3.0, 4.0])
grad2 = numerical_gradient(f4, x2)
print("f(x,y)=x^2+y^2 在 (3,4) 处的梯度:", grad2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
f(x,y)=x^2+xy+y^2 在 (1,1) 处的梯度: [3. 3.]
f(x,y)=x^2+y^2 在 (3,4) 处的梯度: [6. 8.]
1
2

数值微分是理解梯度最直观的方式,但在实际训练深度神经网络时,参数数量动辄百万甚至上亿,对每个参数都做一次前向传播算差分,计算量是不可接受的。Day18会先用数值梯度驱动SGD把网络训起来,再用反向传播以链式法则把梯度计算从输出层倒推回输入层,将复杂度从"参数数量×前向传播"降到"与网络前向传播同量级",这是深度学习能够规模化训练的核心技术。

# 1.9 神经网络的梯度:损失函数关于权重的变化率

# 1.9.1 梯度的定义

在神经网络的学习中,梯度的计算非常重要。神经网络中的梯度,指的就是损失函数关于权重参数的梯度——它告诉我们:如果某个权重参数微调一点,损失函数会跟着变化多少。

为什么关心这个?因为训练的目标是让尽量小。梯度指向增长最快的方向,那么负梯度就指向减小最快的方向——这就是参数应该更新的方向。

# 1.9.2 一个2×3矩阵的具体例子

我们以一个单层的简单网络为例,形状为2×3,权重参数为,损失函数记为。那么它的权重参数和梯度为:

这里,梯度 也是一个2×3的矩阵,其中各个元素由关于中各元素的偏导数构成。换句话说:梯度的形状和权重的形状完全一致,每个位置都对应一个"这个参数该往哪调、调多少"的信号。

# 1.9.3 代码实现:数值梯度计算

计算这个简单网络的梯度,可以用代码实现如下。这里用的是数值梯度(numerical gradient)——不解析求导,而是用微小的扰动来近似:

import numpy as np

def softmax(x):
    """softmax函数,把 logits 转成概率分布"""
    if x.ndim == 2:
        x = x - x.max(axis=1, keepdims=True)
        x = np.exp(x)
        x /= x.sum(axis=1, keepdims=True)
        return x
    x = x - np.max(x)
    return np.exp(x) / np.sum(np.exp(x))

def cross_entropy_error(y, t):
    """交叉熵损失,y是预测概率,t是真实标签(one-hot)"""
    if y.ndim == 1:
        t = t.reshape(1, t.size)
        y = y.reshape(1, y.size)
    if t.size == y.size:
        t = t.argmax(axis=1)
    batch_size = y.shape[0]
    return -np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size

def numerical_gradient(f, x):
    """数值梯度:用中心差分近似偏导数"""
    h = 1e-4  # 微小扰动
    grad = np.zeros_like(x)
    it = np.nditer(x, flags=['multi_index'], op_flags=['readwrite'])
    while not it.finished:
        idx = it.multi_index
        tmp_val = x[idx]
        x[idx] = float(tmp_val) + h
        fxh1 = f(x)  # f(x+h)
        x[idx] = float(tmp_val) - h
        fxh2 = f(x)  # f(x-h)
        grad[idx] = (fxh1 - fxh2) / (2 * h)
        x[idx] = tmp_val  # 恢复原值
        it.iternext()
    return grad

class simpleNet:
    """单层的简单网络:2输入 -> 3输出,无激活函数"""
    def __init__(self):
        np.random.seed(42)               # 固定种子, 保证文中输出可复现
        self.W = np.random.randn(2, 3)  # 随机初始化2×3权重

    def predict(self, x):
        return np.dot(x, self.W)

    def loss(self, x, t):
        z = self.predict(x)
        y = softmax(z)
        loss = cross_entropy_error(y, t)
        return loss

# 构造数据
x = np.array([0.6, 0.9])   # 输入:2维
t = np.array([0, 0, 1])    # 标签:one-hot,第3类

net = simpleNet()

# 定义损失函数关于W的包装(数值梯度需要一元函数)
f = lambda w: net.loss(x, t)
dW = numerical_gradient(f, net.W)

print("梯度 dL/dW:")
print(dW)
print("\n梯度形状:", dW.shape)  # 和W一样,也是(2, 3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
梯度 dL/dW:
[[ 0.43933772  0.06173286 -0.50107059]
 [ 0.65900658  0.0925993  -0.75160588]]

梯度形状: (2, 3)
1
2
3
4
5

输出是一个2×3矩阵,每个元素表示:如果增加,大约增加。正值表示增大会让变大(应该减小),负值表示增大会让变小(应该增大)。梯度就是参数更新的导航图。

# 1.10 梯度下降法:沿着负梯度方向迭代优化

# 1.10.1 核心思想

梯度下降法(Gradient Descent)是一种用于最小化目标函数的迭代优化算法。核心是沿着目标函数(如损失函数)的负梯度方向逐步调整参数,从而逼近函数的最小值。梯度方向指示了函数增长最快的方向,因此负梯度方向是函数下降最快的方向。

如图所示,初始点处的负梯度方向(箭头)指向函数值减小的方向。沿着这个方向走一小步,函数值就会下降;重复这个过程,就能一步步逼近谷底(极小值点)。

具体来说,我们初始找到函数的一个点,按下式进行更新:

这样就可以沿着负梯度方向,找到一个新的点,让函数值更小。

这里的表示每次的更新量,在神经网络的学习过程中,就代表了一次学习的步长(一次学习多少、多大程度去更新参数),称为 学习率(learning rate)。学习率需要预先设定好,过大或过小都会导致学习效果不佳:

  • 过大:步子太大,可能跨过最小值,甚至发散
  • 过小:步子太小,收敛太慢,训练时间爆炸

# 1.10.2 代码实现

梯度下降法可以代码实现如下:

import numpy as np

def numerical_gradient(f, x):
    """数值梯度(同上)"""
    h = 1e-4
    grad = np.zeros_like(x)
    it = np.nditer(x, flags=['multi_index'], op_flags=['readwrite'])
    while not it.finished:
        idx = it.multi_index
        tmp_val = x[idx]
        x[idx] = float(tmp_val) + h
        fxh1 = f(x)
        x[idx] = float(tmp_val) - h
        fxh2 = f(x)
        grad[idx] = (fxh1 - fxh2) / (2 * h)
        x[idx] = tmp_val
        it.iternext()
    return grad

def gradient_descent(f, init_x, lr=0.01, step_num=100):
    """
    梯度下降法
    f: 目标函数
    init_x: 初始点
    lr: 学习率
    step_num: 迭代次数
    """
    x = init_x
    x_history = []

    for i in range(step_num):
        x_history.append(x.copy())
        grad = numerical_gradient(f, x)
        x -= lr * grad  # 沿负梯度方向更新

    return x, np.array(x_history)

# 测试:求 f(x1, x2) = x1^2 + x2^2 的最小值
def function_2(x):
    return x[0]**2 + x[1]**2

init_x = np.array([-3.0, 4.0])  # 初始点(-3, 4)
x_final, x_history = gradient_descent(function_2, init_x, lr=0.1, step_num=100)

print("最终位置:", x_final)           # 应该接近(0, 0)
print("最终函数值:", function_2(x_final))  # 应该接近0
print("迭代次数:", len(x_history))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
最终位置: [-6.11110793e-10  8.14814390e-10]
最终函数值: 1.03735763e-18
迭代次数: 100
1
2
3

从出发,经过100次迭代,最终停在,几乎就是原点——这正是的最小值点。梯度下降法用100次"算梯度→走一小步"的循环,完成了我们肉眼一看就知道答案的优化问题。

# 1.10.3 梯度与参数更新的关系

现在把1.1和1.2连起来:梯度计算是"看方向",梯度下降是"迈步子"。

  • 1.5节的numerical_gradient回答:在当前参数下,损失函数往哪个方向增长最快?
  • 1.6节的gradient_descent回答:既然知道了方向,那参数应该怎么更新?

合起来就是神经网络训练的完整逻辑:

这个公式就是神经网络"学习"的数学表达——不是凭空变聪明,而是沿着负梯度方向,一小步一小步地调整参数,让损失函数值一点点降下来。

# 1.11 模型训练相关概念:Epoch、Batch Size、Iteration

# 1.11.1 Epoch

1个Epoch表示模型完整遍历一次整个训练数据集的过程。例如,训练10个Epoch表示模型将整个数据集反复学习10次。

模型需要多次遍历数据集(多个Epoch)才能逐步学习数据中的模式,单次遍历数据集(1个Epoch)通常不足以让模型收敛,多次遍历可以逐步优化模型参数。

# 1.11.2 Batch Size

Batch Size是每次训练时输入的样本数量。例如,Batch Size=32 表示每次用32个样本计算一次梯度并更新模型参数。

小批量数据计算梯度比单样本(Batch Size=1)更稳定,比全批量(Batch Size=全体数据)更高效。并且较小的Batch Size可能带来更多噪声,有助于模型泛化。

# 1.11.3 Iteration

一次Iteration表示**完成一个Batch数据的正向传播(预测)和反向传播(更新参数)**的过程。

例如,数据集现有2000个样本,对其训练10个Epoch,选择Batch Size=64:

  • Batch个数为 个(最后一个Batch仅有16个样本)
  • 每个Epoch中迭代次数 Iteration = 32次
  • 总迭代次数为 次
  • 总训练样本数为

这三个概念的关系可以总结为:Epoch是"轮",Iteration是"步",Batch Size是"每步吃多少数据"。

# 1.12 随机梯度下降法(SGD):从理论到实践

# 1.12.1 SGD的定义

在神经网络的学习过程中,可以使用梯度下降法来更新参数,目标就是减小损失函数的值。

但实际操作时有一个问题:如果数据集有100万条,每次算梯度都要遍历全部数据,计算量太大。于是人们想了个办法:从训练数据中随机选择一个小批量数据(mini-batch),然后用梯度下降法迭代多个轮次(iteration)。这种"对随机选择的数据进行的梯度下降法",被称作 随机梯度下降法(stochastic gradient descent, SGD)。

"随机"体现在每次迭代都随机抽一批数据;"梯度下降"体现在用这批数据算出的梯度来更新参数。SGD用少量数据的梯度近似全体数据的梯度,虽然每次的方向可能有噪声,但长期看仍然指向损失减小的方向——而且计算效率大大提高。

# 1.12.2 SGD的四个步骤

SGD的具体过程如下:

1) 随机选择批数据(mini-batch)

从训练数据中随机选出一部分数据,学习的目标就是要减少这个mini-batch数据的损失函数值。

2) 计算梯度

对当前的各权重参数,计算出梯度的值,负梯度就表示了损失函数减小最多的方向。

3) 更新参数

按照1.6节中梯度下降法的公式,对权重参数沿负梯度方向进行微小更新:

4) 重复迭代

重复上面的步骤1)2)3),直到完成预定的总迭代次数。

# 1.12.3 梯度计算与SGD的连接

现在可以回答开篇的问题了:计算机不会求导,但它会用数值方法近似梯度;计算机不会解方程,但它会用迭代方法逼近最优解。

  • 梯度计算(1.5节)是SGD的第二步——告诉参数该往哪调
  • 梯度下降(1.6节)是SGD的第三步——告诉参数调多少
  • SGD(1.8节)把这两步包装成一个可执行的循环:抽数据→算梯度→更新参数→重复

没有梯度计算,SGD就不知道方向;没有梯度下降,梯度计算就只是纸上谈兵。两者结合,才让神经网络具备了"从数据中学习"的能力。

# 小结

今天把前向传播、损失函数、数值梯度和梯度下降串成了一条完整的概念链。但数值梯度太慢——网络一深就扛不住。Day18用反向传播算法把链式法则交给框架自动算,再解决深度网络的梯度消失、参数初始化和正则化问题。

最近更新: 2026/9/30
 |  Copyright © -Present Kelsen All Rights Reserved. | Author KelsenJing