Chapter 1. 前置基础:Python 库

numpymatplotlib 的使用(外部库)

  • 导入约定: plt \rightarrow matplotlib.pyplotnp \rightarrow numpy

  • NumPy 常用方法:

    • np.array(): 创建数组。
    • np.arange(START, END, STEP): 创建等差数列数组。
    • np.shape: 获取数组形状。
    • np.dot(): 计算矩阵乘积(点积)。
    • np.sum(): 计算数组元素的总和。
    • np.exp(): 计算数组元素的指数函数。
    • np.max(): 获取数组中的最大值。
    • np.argmax(): 获取数组中最大值的索引。
  • Matplotlib 常用方法:

    • plt.show(): 显示图像。

    • plt.plot(X, Y, label): 绘制折线图并设置标签。

    • plt.xlabel(), plt.ylabel(): 设置 x 轴和 y 轴的标签。

    • plt.title(): 设置图表标题。

    • plt.legend(): 显示图例。

    • plt.imshow(): 显示图像数据。

    • plt.imread(): 读取图像。


Chapter 2. 感知机 (Perceptron)

2.1 什么是感知机?

感知机接收多个输入信号,输出一个单一信号。

Several input signals \rightarrow single output signal.

模型示意:

  • 输入: x1,x2x_1, x_2

  • 权重 (Weight): w1,w2w_1, w_2

  • 输出: yy

数学表达:

y={0(w1x1+w2x2θ)1(w1x1+w2x2>θ)y = \begin{cases} 0 & (w_1x_1 + w_2x_2 \le \theta) \\ 1 & (w_1x_1 + w_2x_2 > \theta) \end{cases}

  • 0 代表 “不传递” (not pass)

  • 1 代表 “传递” (pass)

2.2 简单逻辑电路

2.2.1 与门 (AND Gate)

有两个输入,一个输出。以下是它的真值表 (Truth table):

x1x_1 x2x_2 yy
0 0 0
1 0 0
0 1 0
1 1 1

如何用感知机模拟与门?

  • 目标: 找到合适的 w1,w2w_1, w_2θ\theta 的值。

  • 解法: 有无数种选择 (infinity choices)。

    • 示例 1: (w1,w2,θ)=(0.5,0.5,0.7)(w_1, w_2, \theta) = (0.5, 0.5, 0.7)
    • 示例 2: (w1,w2,θ)=(0.5,0.5,0.8)(w_1, w_2, \theta) = (0.5, 0.5, 0.8)
    • 示例 3: (w1,w2,θ)=(1,1,1)(w_1, w_2, \theta) = (1, 1, 1)

2.2.2 与非门和或门 (NAND Gate and OR Gate)

  • 与非门 (NAND Gate): 即 Not AND(与门的相反)。只有当 x1,x2x_1, x_2 都等于 1 时,y=0y=0

  • 或门 (OR Gate): 只要 x1x_1x2x_2 之中有一个等于 1,y=1y=1

总结:
可以通过改变权重 (weights) 来构建不同的逻辑电路。

2.3 感知机的实现

2.3.1 简单实现

利用 Python 实现一个基础的与门:

1
2
3
4
5
6
7
def AND(x1, x2):
w1, w2, theta = 0.5, 0.5, 0.7
tmp = x1 * w1 + x2 * w2
if tmp <= theta:
return 0
elif tmp > theta:
return 1

2.3.2 导入偏置和权重

将公式中的 θ\theta 替换为 b-b ,公式将转换为:

y={0(w1x1+w2x2+b0)1(w1x1+w2x2+b>0)y = \begin{cases} 0 & (w_1x_1 + w_2x_2 + b \le 0) \\ 1 & (w_1x_1 + w_2x_2 + b > 0) \end{cases}

  • 在这里,bb 被称为 偏置 (bias)w1,w2w_1, w_2 被称为 权重 (weights)

  • 在 Python 中,可以使用 np.array()np.sum() 来计算它。

意义:

  • Weight (权重): 决定了各个输入信号的重要性 (define the importance of every input)。

  • Bias (偏置): 决定了神经元被激活的难易程度 (define the difficulty of the activation of neurons)。你可以将偏置视为一种初始值。

在广义的表达中,参数 wwbb 都可以统称为权重。

2.4 感知机的局限性

2.4.1 异或门 (XOR Gate)

XOR Gate(异或门),全称为 Exclusive-OR gate,又称逻辑异或电路。
其逻辑定义为:仅当输入的 xx 中有且只有一个为 1 时,输出才为 1。

真值表 (Truth Table):

x1x_1 x2x_2 yy
0 0 0
0 1 1
1 0 1
1 1 0

问题提出: 令人惊讶的是,你无法使用单层感知机来模拟(emulate)XOR 门。

让我们通过图表深入探究其背后的原因(using graph to find out the reason)。

  • 以 OR(或门)为例,感知机可以通过一条直线方程(例如:x2=0.5x1x_2 = 0.5 - x_1)将输出为 0 和输出为 1 的区域完美划分开。
    • 点 (1, 0) 输出 1
    • 点 (0, 1) 输出 1
    • 点 (0, 0) 输出 0
  • 但是对于 XOR 门,点 (1, 1) 的输出应当为 0。你无法用一条直线将这些点(属于 0 和属于 1 的类别)划分开,这在二维平面上是不可能的。

2.4.2 线性与非线性 (Linear and Nonlinear)

我们无法用一条 直线 (straight line) 来划分上述的输出范围。但是用曲线 (curve) 呢?

  • 由直线划分的空间被称为线性空间 (Linear Space)
  • 由曲线划分的空间被称为非线性空间 (Nonlinear Space)

这两个概念在机器学习领域都非常常见。


2.5 多层感知机 (Multilayer Perceptron, MLP)

即使我们还不了解 MLP 的具体概念,我们也可以换种思路来解决 XOR 门的问题。

2.5.1 已有门电路的组合

我们可以通过组合已有的基本逻辑门来实现异或逻辑。

基础符号 (Symbols):

  • AND:与门
  • NAND:与非门
  • OR:或门

思考提示 (Tip):
如何设置逻辑门 [1?], [2?], [3?],使得输入 x1,x2x_1, x_2 最终能输出 XOR 的结果 yy

答案是 (The Ans is):

  • [1?] \Rightarrow NAND (与非门)
  • [2?] \Rightarrow OR (或门)
  • [3?] \Rightarrow AND (与门)

(结合真值表来推导这个逻辑过程会更加容易。)


2.5.2 异或门的实现

1
2
3
4
5
def XOR(x1, x2):
s1 = NAND(x1, x2)
s2 = OR(x1, x2)
y = AND(s1, s2)
return y

将逻辑转换为神经网络结构:

  • Layer 0 (输入层): 接收输入 (x1,x2)(x_1, x_2)
  • Layer 1 (隐藏层): 进行第一次逻辑转换,输出 (s1,s2)(s_1, s_2)
  • Layer 2 (输出层): 结合隐藏层的结果,输出最终判定 yy

这就是一个双层感知机 (2-layered perceptron)。(注:通常计算神经网络层数时,不包含输入层 Layer 0)。

注意 (NOTICE):
感知机是神经网络 (Neural Network) 的基础,这将在下一章中详细介绍。

Chapter 3. 神经网络 (ANN)

3.1 From perceptron to ANN

3.1.1 Example of an ANN 神经网络的例子

最左边为 输入层 (Input Layer),最右边为 输出层 (Output Layer),中间一列为 中间层/隐藏层 (Hidden Layer)。隐藏层的神经元肉眼不可见。
通常把输入层到输出层依次称为第 0 层、第 1 层、第 2 层(从 0 开始是为了方便 Python 实现)。

3.1.2 激活函数 (Activation Function)

在感知机中,我们将输入信号的总和转换为输出信号。引入一个新函数 h(x)h(x),将之前的感知机公式改写为:

a=b+w1x1+w2x2a = b + w_1x_1 + w_2x_2

y=h(a)y = h(a)

  • 首先,计算加权输入信号和偏置的总和,记为 aa
  • 然后,用 h()h() 函数将 aa 转换为输出 yy

h(x)h(x) 即为 激活函数 (Activation Function)。它的作用在于决定如何来激活输入信号的总和。

上一章感知机中使用的激活函数是以阈值为界,一旦超过阈值就切换输出,这种函数称为 阶跃函数 (Step function)。神经网络中使用的是其他激活函数。


3.2 常见的激活函数

3.2.1 阶跃函数 (Step Function)

当输入超过 0 时输出 1,否则输出 0。

Python 实现 (支持 NumPy 数组):

1
2
3
def step_function(x):
y = x > 0
return y.astype(int) # bool to integer

教材中 Line 3 为 return y.astype(np.int),资料显示 NumPy 1.20np.int 被弃用。我的环境为 Python 3.13.12 && NumPy 2.4.3 ,使用 int

3.2.2 sigmoid 函数 (Sigmoid Function)

神经网络中经常使用的一个激活函数是 sigmoid 函数,公式如下:

h(x)=11+exp(x)h(x) = \frac{1}{1 + \exp(-x)}

Python 实现:

1
2
def sigmoid(x):
return 1 / (1 + np.exp(-x))

阶跃函数 vs. sigmoid 函数:

  1. 平滑性 (Smoothness): sigmoid 函数是一条平滑的曲线,输出发生连续性的变化;而阶跃函数以 0 为界发生急剧变化。平滑性对神经网络的学习具有重要意义。
  2. 连续值 vs 二元值: 感知机中神经元之间流动的是 0 或 1 的二元信号,而神经网络中流动的是连续的实数值信号。
  3. 共同点: 两者均为 非线性函数 (Nonlinear function),且输出都在 0 到 1 之间。输入信号越重要,输出值越接近 1。

为什么必须使用非线性函数?
神经网络的激活函数必须使用非线性函数。如果使用线性函数(如 h(x)=cxh(x) = cx),加深神经网络的层数就没有意义了。因为多层线性网络的运算总可以等效为单层网络(例如 y(x)=h(h(h(x)))=c×c×c×x=axy(x) = h(h(h(x))) = c \times c \times c \times x = a x)。为了发挥多层网络带来的优势,必须使用非线性函数。

3.2.3 ReLU 函数 (Rectified Linear Unit)

近年来,ReLU 函数在神经网络中更为常用。
它在输入大于 0 时直接输出该值,在输入小于等于 0 时输出 0。

h(x)={x(x>0)0(x0)h(x) = \begin{cases} x & (x > 0) \\ 0 & (x \le 0) \end{cases}

Python 实现:

1
2
def relu(x):
return np.maximum(0, x)

3.3 多维数组的运算

巧妙地使用 NumPy 多维数组,可以极大地简化神经网络的实现。

3.3.1 矩阵乘法

使用 np.dot() 计算矩阵的乘积(点积)。

  • 核心法则: 左边矩阵的列数(第 1 维)必须和右边矩阵的行数(第 0 维)相等。
  • 形状变化示例: 3×23 \times 2 的矩阵 \cdot 2×42 \times 4 的矩阵 \rightarrow 输出 3×43 \times 4 的矩阵。
1
2
3
A = np.array([[1,2], [3,4]])
B = np.array([[5,6], [7,8]])
np.dot(A, B)

3.3.2 神经网络的内积运算

在神经网络中,可以通过矩阵乘法一次性计算出所有的加权和。
假设输入 XX 形状为 (2,),权重 WW 形状为 (2, 3),则输出 YY 的形状为 (3,)。

Y=XW\mathbf{Y} = \mathbf{X} \cdot \mathbf{W}

使用 np.dot(X, W) 可以避免使用低效的 for 循环。


3.4 3层神经网络的实现

实现一个具有 2 个输入、第 1 个隐藏层 3 个神经元、第 2 个隐藏层 2 个神经元、输出层 2 个神经元的网络。

数学符号说明:

  • w12(1)w^{(1)}_{12} 表示:前一层的第 2 个神经元指向后一层的第 1 个神经元的权重。上标 (1)(1) 表示第 1 层的权重。

前向传播 (Forward Propagation) 代码汇总:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import numpy as np
from act_func import funcs # import activation functions from act_func.py
def init_network():
    network = {} # Create an empty dictionary to store the network parameters
    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
    Z1 = funcs.sigmoid(A1)
    A2 = np.dot(Z1, W2) + B2
    Z2 = funcs.sigmoid(A2)
    A3 = np.dot(Z2, W3) + B3
    Y = A3  # Output layer equals to A3
    return Y
x = np.array([1.0, 0.5])
y = forward(init_network(), x)
print("Output of the network:", y)

3.5 输出层的设计

机器学习的问题大致分为两类,根据问题不同,输出层的激活函数也不同:

  1. 回归问题 (Regression): 预测连续的数值(如预测体重)。通常使用 恒等函数 (Identity Function)
  2. 分类问题 (Classification): 判断数据属于哪一个类别(如图像是猫还是狗)。通常使用 softmax 函数

3.5.1 softmax 函数 (Softmax Function)

softmax 函数公式:

yk=exp(ak)i=1nexp(ai)y_k = \frac{\exp(a_k)}{\sum_{i=1}^{n} \exp(a_i)}

分子是当前输入信号的指数函数,分母是所有输入信号的指数函数的和。

防止溢出的改进:
由于指数运算极易产生超大值(如 exp(100)\exp(100) 会变成无穷大 inf),导致计算溢出并返回 nan。解决方法是:在计算指数时,给所有输入信号减去输入信号中的最大值

yk=Cexp(ak)Ci=1nexp(ai)=exp(ak+logc)i=1nexp(ai+logc)=exp(ak+C)i=1nexp(ai+C)y_k = \frac{C\exp(a_k)}{C\sum_{i=1}^{n} \exp(a_i)} = \frac{\exp(a_k + \log{c})}{\sum_{i=1}^{n} \exp(a_i + \log{c})} = \frac{\exp(a_k + C')}{\sum_{i=1}^{n} \exp(a_i + C')}

改进后的 Python 实现:

1
2
3
4
5
6
def softmax(a):
c = np.max(a)
exp_a = np.exp(a - c) # 溢出对策
sum_exp_a = np.sum(exp_a)
y = exp_a / sum_exp_a
return y

3.5.2 softmax 函数的特征

  • 概率解释: softmax 的输出值在 0.0 到 1.0 之间,且 输出值的总和恒为 1。因此,我们可以把 softmax 的输出解释为“概率”。
  • 单调性: 由于 exp(x)\exp(x) 是单调递增函数,应用 softmax 前后的元素大小关系不会改变。
  • 在实际的分类任务的推理阶段,由于只想知道最大概率属于哪一类,通常会省略输出层的 softmax 函数以减少计算量;但在学习阶段是必须的。

输出层的神经元数量: 通常设定为待分类的类别数量。例如数字 0~9 识别问题,输出层神经元数量应设为 10。


3.6 手写数字识别

求解机器学习问题分为 学习 (Training)推理 (Inference/Forward Propagation) 两个阶段。这里我们假设已经完成了参数的学习,直接使用学习到的权重进行推理。

3.6.1 MNIST 数据集

MNIST 是最著名的机器学习数据集之一,包含 0~9 的手写数字图像。

  • 每张图像大小:28 像素 ×\times 28 像素 = 784 个像素,灰度图(单通道)。
  • 预处理 (Pre-processing):通常会将图像数据正规化 (Normalization) 到 0.0~1.0 之间(即将像素值除以 255)。

原书提供的 MMNIST 数据集下载链接已失效,我将其修改为另一 GitHub 仓库中的链接,已验证可用。

GitHub 的 Raw 文件链接结构为:
https://github.com/用户名/仓库名/raw/refs/heads/分支名/文件路径
可用的 URL_Base:https://github.com/guliang21/Dataset/raw/refs/heads/master/MNIST/

3.6.2 批处理 (Batch Processing)

如果一张一张地处理图像,效率会比较低。我们可以将多张图像打包在一起作为输入进行计算,这种打包的数据称为 批 (Batch)

  • 单张图像的形状:1×7841 \times 784 \rightarrow 网络 \rightarrow 输出 1×101 \times 10
  • 批处理 (比如 batch_size=100):100×784100 \times 784 \rightarrow 网络 \rightarrow 输出 100×10100 \times 10

批处理的优势:
大多数数值计算库(如 NumPy)都对大型数组的矩阵运算进行了高度优化,批处理一次性计算大型数组的速度,远远快于用 for 循环逐个计算小型数组。

批处理的 Python 实现示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 假设 x 为测试图像集 (10000, 784), t 为真实标签
batch_size = 100
accuracy_cnt = 0

for i in range(0, len(x), batch_size):
# 提取批数据
x_batch = x[i:i+batch_size]

# 预测 (返回的形状为 100 x 10)
y_batch = predict(network, x_batch)

# 获取每一行概率最高的元素的索引,axis=1 指定沿着第 1 维方向找最大值
p = np.argmax(y_batch, axis=1)

# 比较预测结果和真实标签,统计正确个数
accuracy_cnt += np.sum(p == t[i:i+batch_size])

print("Accuracy:" + str(float(accuracy_cnt) / len(x)))

3.7 Chapter 3 小结

  • 激活函数:神经网络的激活函数使用平滑变化的 sigmoid 函数或 ReLU 函数,而感知机使用的是突变的阶跃函数。
  • 矩阵运算:通过巧妙地使用 NumPy 多维数组的 np.dot(),可以高效地实现神经网络的前向传播。
  • 任务分类与输出层
    • 回归问题:一般用恒等函数。
    • 分类问题:一般用 softmax 函数。输出层神经元数量设为类别数。
  • 批处理 (Batch):输入数据的集合称为批。以批为单位进行推理可以极大地提升矩阵运算的效率,实现高速运算。

Chapter 4. 神经网络的学习

学习 (Learning):指从训练数据中自动获取最优权重参数的过程。

4.1 从数据中学习

神经网络的特征就是可以从数据中学习,由数据自动决定权重参数的值,不需要太多的人工干预。

4.1.1 数据驱动

人在解决问题时会综合考虑各种因素,从中发现规律,但这对机器来说非常困难。例如实现数字“5”的识别,由于人的写字习惯不同,很难人为总结出普适的规律。

因此需要利用有效数据来处理,从中提取特征量。

  • 传统机器学习 (Machine Learning):使用机器分类器进行学习,但需要人为设计并提取特征量 (Features)。特征量是可以从输入数据中准确提取本质数据的转换器(通常表示为向量形式)。
  • 深度学习 (Deep Learning): 神经网络不需要人为介入提取特征量,直接学习图像等原始数据本身。这就是深度学习和传统机器学习在概念上的重要区分。

端到端机器学习 (End-to-end Machine Learning)
深度学习也被称为端到端机器学习。“端到端”指从输入端(原始数据)直接到输出端(最终结果)。最大优点是对所有问题都可以用同样的流程去解决。

4.1.2 训练数据与测试数据

机器学习中,通常将数据分为两种:

  1. 训练数据 (Training Data): 用于模型的学习,寻找最优的权重参数。
  2. 测试数据 (Test Data): 用于评价训练得到的模型在实际场景中的能力。

为什么要分为两种?
目的是为了正确评价模型的泛化能力 (Generalization Ability)。泛化能力指处理未曾见过的(不包含在训练数据中)新数据的能力。获得泛化能力是机器学习的最终目标。
如果在学习中仅仅使用一个数据集去学习和评价,模型可能会过度迎合该数据集,导致在训练集上表现极好,面对新数据时却表现极差,这种状态被称为 过拟合 (Overfitting)

Note: 过拟合一定程度上和应试教育有点相似。被训练成刷题机器,而不是理解知识的本质,导致面对实际生活中的各种问题时无法很好地应对。


4.2 损失函数 (Loss Function)

神经网络以某个指标为线索来寻找最优权重参数,这个指标被称为损失函数 (Loss Function)
它是一种表示神经网络性能的“恶劣程度”的指标,可以理解为:不符合预期的程度。虽然可以使用任意函数,但一般常用均方误差 (MSE)交叉熵误差 (CEE)

4.2.1 均方误差 (Mean Squared Error, MSE)

E=12k(yktk)2E=\frac{1}{2}\sum_{k}(y_k-t_k)^2

  • yky_k: 神经网络的输出
  • tkt_k: 监督数据 (正确解)
  • kk: 数据的维数

它计算神经网络的输出和正确解监督数据的各个元素之差的平方然后再求总和。

监督数据 (Supervised Data): 将正确解标签表示为 1,其他标签表示为 0 的方法称为 one-hot 表示

4.2.2 交叉熵误差 (Cross Entropy Error, CEE)

E=ktklogykE=-\sum_k t_k \log y_k

  • log\log: 表示以 e\mathrm{e} 为底数的自然对数 (loge\log_\mathrm{e})
  • yky_k: 神经网络的输出
  • tkt_k: 正确解标签 (one-hot 表示)

原理解析: tkt_k 是 one-hot 表示,即正确解标签的索引为 1,其他均为 0。因此,错误解的标签 0 乘进去后对应项就消失了。也就是说,交叉熵误差的值仅由正确解标签所对应的输出结果决定。正确解对应的输出概率越大,此式算出来的值就越趋于 0。

Python 实现:

1
2
3
4
5
6
class loss: 
@staticmethod
def cross_entropy_error(y, t):
"""Cross Entropy Error (CEE)"""
delta = 1e-7 # to prevent log(0)
return -np.sum(t * np.log(y + delta))

注意: 这里在计算 np.log() 时加上了一个极小值 delta。因为存在 y=0y=0 的情况,此时 log(0)\log(0) 的值是 -inf,会导致后续计算无法进行。作为保护性对策,添加一个极小值来防止这种情况发生。

4.2.3 mini-batch 学习

机器学习的过程本质上是针对训练数据,找到使损失函数的值尽可能小的权重参数。这意味着计算损失函数时必须将所有训练数据作为对象。

前面介绍的损失函数都是针对单个数据的。如果要求所有训练数据的交叉熵误差总和,公式如下:

E=1NnktnklogynkE=-\frac{1}{N}\sum_n\sum_kt_{nk}\log y_{nk}

  • 假设数据有 NN 个。
  • [   ]nk[\ \ \ ]_{nk} 表示第 nn 个数据的第 kk 个元素。

这相当于是对所有数据的损失求和,再除以 NN 进行正规化 (Normalization),以求得单个数据的平均损失函数。这样得到的结果就不会因为训练数据量的大小而发生改变。
针对数以百万计的训练数据,我们可以从中随机抽取一小部分(例如 100 笔),这小部分数据称为 mini-batch (微批次)。每次都拿这个 mini-batch 的数据来计算损失函数并更新参数,这种学习方式称为 mini-batch 学习