我们成功训练了第一个神经网络模型,它可以根据天气预报来预测冰激凌的销量。
但冰激凌的销量真的是直接由天气情况决定吗?
事实上,天气并不是直接影响冰激凌销量的因素。天气首先影响的是人们的行为和偏好,例如是否愿意出门,以及是否想吃冰激凌。
天气凉爽时:人们更愿意出门,但未必想吃冰激凌;
天气酷热时:人们更想吃冰激凌,但可能不愿意出门;
天气寒冷时:人们既不愿意出门,也不想吃冰激凌。
从这个角度看,冰激凌的销量并不是由天气直接决定的,而是由人们出门的愿望和吃冰激凌的愿望共同决定的。
那么,我们是否可以这样做:
先用一个模型根据天气预测这些中间因素;
再用另一个模型根据这些中间因素预测冰激凌销量?
更进一步,我们是否可以把前一个模型的输出,直接作为后一个模型的输入,并将这两个模型连接成一个整体?
如果可以,那么在训练时,我们是不是可以把冰激凌销量的预测误差,通过反向传播传递回前面的模型,使两个模型能够同时被训练?
这种在网络模型内部自动学习中间因素的能力,正是深度神经网络的独特之处。
层¶
现代深度神经网络模型由很多的层(Layer)组成,每层都可以看作是一个独立的小网络模型。具体到我们的例子,我们将尝试建立一个两层的网络模型。
多层的网络模型将同样具有三个核心流程:前向传播、反向传播和参数更新,并贯穿每一层网络:
前向传播:

反向传播:

参数更新:

from abc import ABC, abstractmethod
import numpy as npnp.random.seed(42)💡 提示:函数
np.random.seed()用于初始化随机数,确保每次运行都产生一样的随机数,结果可复现。
张量¶
class Tensor:
def __init__(self, data):
self.data = np.array(data)
self.grad = np.zeros_like(self.data)
self.gradient_fn = None
self.parents = set()
def backward(self):
if self.gradient_fn is not None:
self.gradient_fn()
for p in self.parents:
p.backward()
def __str__(self):
return f'Tensor({self.data})'数据集¶
class Dataset:
def __init__(self, batch_size=1):
self.batch_size = batch_size
self.load()
self.train()
def load(self):
self.train_data = ([[22.5, 72.0],
[31.4, 45.0],
[19.8, 85.0],
[27.6, 63.0]],
[[95],
[210],
[70],
[155]])
self.test_data = ([[28.1, 58.0]],
[[165]])
def train(self):
self.data = self.train_data
def eval(self):
self.data = self.test_data
def all(self):
x, y = self.data
return Tensor(x), Tensor(y)
def __len__(self):
x, *_ = self.data
return len(x) // self.batch_size
def __getitem__(self, index):
s = slice(index * self.batch_size, (index + 1) * self.batch_size)
x, y = self.data
return Tensor(x[s]), Tensor(y[s])模型¶
从现在开始,我们将构建第一个由多个子网络模型组成的复杂神经网络模型,称为多层(Multi-Layer)网络模型。
这些层按照统一规定的接口(Interface)相互连通,就构成了一个复杂的大型网络模型。
抽象层¶
因此我们需要先定义一个所有层都遵守的规范,称为抽象层(Abstract Layer)。它规定了每个层必须实现的两件事:
forward(前向函数):接受输入向量,计算出输出向量。这是每一层完成前向传播的地方;parameters(参数列表):返回本层需要训练、更新的参数。默认为空列表。
💡 提示:
ABC是 Python 的抽象基类(Abstract Base Class)标记,它的作用是:标记这个类不能被实例化、只能被继承。@abstractmethod则定义了其中的抽象函数,即子类必须实现的函数。
class Layer(ABC):
def __call__(self, x: Tensor):
return self.forward(x)
@abstractmethod
def forward(self, x: Tensor):
pass
@property
def parameters(self):
return []线性层¶
有了抽象层以后,我们把现有的线性回归模型改写成线性层(Linear Layer)。同时扩展两个重要的功能:
随机初始化权重
之前我们一直把权重初始化为平均值:np.ones((out_size, in_size)) / in_size。简单模型这样没问题,但是到了多层模型,完全相同的初始化权重,会计算出相同的输出值,进而相同的梯度,所有输出永远保持一模一样。等于所有多出来的神经元都白费了,这种现象被称为模型训练的对称性(Symmetry)。
解决办法很简单:给每个神经元不同的初始权重,使每个神经元的输出和梯度朝着不同的方向发展。我们用 np.random.randn 来生成(不同的)随机数,再使用一个缩放系数 np.sqrt(2 / in_size) 来调整范围。这个系数可以把权重的初始值控制在合理的范围内,避免梯度在多层间传播时越来越大、或者越来越小。这种初始化方法被称为 He 初始化(He Initialization)。
把梯度传递给父节点
之前的简单模型只需要计算自己权重的梯度。
有了多层结构以后,还要考虑需要把梯度传递给前面的层(父节点)。也就是说输入值可能不是特征,而是前一层传来的中间值,所以:
在梯度函数中需要计算输入值的梯度;
把输入值加入父节点列表中,因为输入值不再是叶节点了,而是中间节点。
💡 提示:这两行代码非常关键,因为这样就把多层网络模型连接了起来,使得反向传播可以穿透层与层的隔断,实现遍历整个复杂网络模型的计算图。
class Linear(Layer):
def __init__(self, in_size, out_size):
self.weight = Tensor(np.random.randn(out_size, in_size) * np.sqrt(2 / in_size))
self.bias = Tensor(np.zeros(out_size))
def forward(self, x: Tensor):
p = Tensor(x.data @ self.weight.data.T + self.bias.data)
def gradient_fn():
self.weight.grad += p.grad.T @ x.data
self.bias.grad += np.sum(p.grad, axis=0)
# 计算输入值的梯度
x.grad += p.grad @ self.weight.data
p.gradient_fn = gradient_fn
# 把输入值加入父节点列表
p.parents = [x]
return p
@property
def parameters(self):
return [self.weight, self.bias]顺序层¶
顺序层(Sequential Layer)是我们的第一个复合层(Composite Layer)。
我们可以把复合层理解成多个子层的容器。顺序层是最简单的一种:顺序执行每一个子层。
forward(前向函数):把输入值依次输送给每一层,前一层的输出作为后一层的输入,最后一层的输出就是预测值;parameters(参数列表):把所有层的参数列表整合成一个大的列表,一次性提供给优化器,从而更新所有参数。
💡 提示:顺序层本身也是从抽象层继承而来,因此它也可以成为另一个顺序层(或者其他复合层)的子层,从而“搭积木”构成更大型的网络模型。
class Sequential(Layer):
def __init__(self, layers):
self.layers = layers
def forward(self, x: Tensor):
# 顺序执行每个子层
for l in self.layers:
x = l(x)
return x
@property
def parameters(self):
# 整合所有子层的参数
return [p for l in self.layers for p in l.parameters]损失函数(均方误差)¶
class MSELoss:
def __call__(self, p: Tensor, y: Tensor):
return self.loss(p, y)
def loss(self, p: Tensor, y: Tensor):
mse = Tensor(np.mean(np.square(y.data - p.data)))
def gradient_fn():
p.grad += -2 * (y.data - p.data) / y.data.size
mse.gradient_fn = gradient_fn
mse.parents = [p]
return mse优化器(随机梯度下降)¶
class SGDOptimizer:
def __init__(self, parameters, lr):
self.parameters = parameters
self.lr = lr
def zero_grad(self):
for p in self.parameters:
p.grad = np.zeros_like(p.data)
def step(self):
for p in self.parameters:
p.data -= p.grad * self.lr训练器¶
class Trainer:
def __init__(self, model, loss_fn, optimizer):
self.model = model
self.loss_fn = loss_fn
self.optimizer = optimizer
def train(self, dataset, epochs):
dataset.train()
for epoch in range(epochs):
for i in range(len(dataset)):
feature, label = dataset[i]
self.optimizer.zero_grad()
prediction = self.model(feature)
loss = self.loss_fn(prediction, label)
loss.backward()
self.optimizer.step()
def test(self, dataset):
dataset.eval()
feature, label = dataset.all()
prediction = self.model(feature)
loss = self.loss_fn(prediction, label)
return prediction, lossLEARNING_RATE = 0.00001批大小¶
BATCH_SIZE = 2轮数¶
EPOCHS = 1000建模¶
现在,我们用顺序层把两个线性层串联成一个两层网络模型:
第一层
Linear(2, 4):输入是 2 个特征[温度, 湿度],输出是 4 个中间值。这 4 个中间值,就对应我们开头设想的出门的愿望和吃冰激凌的愿望,只不过我们给了模型更大的自由度,让模型自己在训练中摸索出真实的数据关联性;第二层
Linear(4, 1):输入是第一层输出的 4 个中间值,输出是 1 个预测值(冰激凌销量)。
第一层的 4 个神经元不直接产生输出值(预测值),也被称为隐藏层(Hidden Layer)。这里也是网络模型体现智能的地方,可以自主捕捉数据的深层联系。
第二层,也即最后一层,被称为输出层(Output Layer)。
dataset = Dataset(BATCH_SIZE)
model = Sequential([
# 隐藏层
Linear(2, 4),
# 输出层
Linear(4, 1),
])
loss_fn = MSELoss()
optimizer = SGDOptimizer(model.parameters, lr=LEARNING_RATE)
trainer = Trainer(model, loss_fn, optimizer)训练¶
trainer.train(dataset, EPOCHS)评估¶
prediction, loss = trainer.test(dataset)
print(f'prediction:\t{prediction}\nloss:\t{loss}')prediction: Tensor([[166.52357679]])
loss: Tensor(2.321286244423445)
实践证明,两层网络模型是可行的。但是和单层网络模型相比,也没有明显的区别,损失值甚至还更差一些。
这并不奇怪。因为到目前为止,我们还只是堆叠了两层线性回归。在数学上,两层线性回归依旧是线性的。要让多层网络模型真正发挥作用,我们还缺最后一块拼图:引入非线性。
课后练习¶
修改隐藏层的神经元个数(比如:2 个、8 个),观察模型训练效果有什么变化?
可以增加隐藏层的数量(比如:2 层、3 层)吗?试一试能不能实现?