使用Python和Numpy构建神经网络模型

构建神经网络/深度学习模型的基本步骤:

  • 数据处理。读取数据并完成预处理操作
  • 模型设计。网络结构设计,构建模型的假设空间。模型能够表达的关系集合
  • 训练配置。设定模型采用的寻解算法,并指定计算资源。
  • 训练过程。循环调用训练过程,每轮都包括前向计算、损失函数和反向传播三个步骤。
  • 模型保存。将训练好的模型保存,模型预测时调用。

处理数据

数据处理包含五个部分:数据预处理后,才能被模型调用。

  • 数据导入。
  • 数据形状变换。
  • 数据集划分。将数据集划分成训练集和测试集,其中训练集用于确定模型的参数,测试集用于评判模型的效果
  • 数据归一化处理。对每个特征进行归一化处理,使得每个特征的取值缩放到0~1之间。这样做有两个好处:
    • 一是模型训练更高效;
    • 二是特征前的权重大小可以代表该变量对预测结果的贡献度(因为每个特征值本身的范围相同)。
  • 封装load data函数。
def load_data():
    # 从文件导入数据
    datafile = './work/housing.data'
    data = np.fromfile(datafile, sep=' ')

    # 每条数据包括14项,其中前面13项是影响因素,第14项是相应的房屋价格中位数
    feature_names = [ 'CRIM', 'ZN', 'INDUS', 'CHAS', 'NOX', 'RM', 'AGE', \
                      'DIS', 'RAD', 'TAX', 'PTRATIO', 'B', 'LSTAT', 'MEDV' ]
    feature_num = len(feature_names)

    # 将原始数据进行Reshape,变成[N, 14]这样的形状
    data = data.reshape([data.shape[0] // feature_num, feature_num])

    # 将原数据集拆分成训练集和测试集
    # 这里使用80%的数据做训练,20%的数据做测试
    # 测试集和训练集必须是没有交集的
    ratio = 0.8
    offset = int(data.shape[0] * ratio)
    training_data = data[:offset]

    # 计算训练集的最大值,最小值
    maximums, minimums = training_data.max(axis=0), \
                            training_data.min(axis=0)

    # 对数据进行归一化处理
    for i in range(feature_num):
        data[:, i] = (data[:, i] - minimums[i]) / (maximums[i] - minimums[i])

    # 训练集和测试集的划分比例
    training_data = data[:offset]
    test_data = data[offset:]
    return training_data, test_data

模型设计

模型设计是深度学习模型关键要素之一,也称为网络结构设计,相当于模型的假设空间,即实现模型"前向计算"(从输入到输出)的过程。

神经网络的三个关键:加权和(留下),非线性变换(去掉),多层连接(去掉)

为何很多传统的机器学习算法均可以用深度学习模型替换?

深度学习网络可以表达

神经网络就是一系列的向量运算。

class Network(object):
    def __init__(self, num_of_weights):
        # 随机产生w的初始值
        # 为了保持程序每次运行结果的一致性,
        # 此处设置固定的随机数种子
        np.random.seed(0)
        self.w = np.random.randn(num_of_weights, 1)
        self.b = 0.
        
    def forward(self, x):
        z = np.dot(x, self.w) + self.b
        return z

训练配置

模型设计完成后,需要通过训练配置寻找模型的最优值,即通过损失函数来衡量模型的好坏。

在回归问题中常用均方误差作为损失函数,而在分类问题中常用采用交叉熵(Cross-Entropy)作为损失函数.

class Network(object):
    def __init__(self, num_of_weights):
        # 随机产生w的初始值
        # 为了保持程序每次运行结果的一致性,此处设置固定的随机数种子
        np.random.seed(0)
        self.w = np.random.randn(num_of_weights, 1)
        self.b = 0.
        
    def forward(self, x):
        z = np.dot(x, self.w) + self.b
        return z
    
    def loss(self, z, y):
        error = z - y
        cost = error * error
        cost = np.mean(cost)
        return cost

训练过程

求解参数 𝑤 和 𝑏 的数值,这个过程也称为模型训练过程。训练过程是深度学习模型的关键要素之一,其目标是让定义的损失函数尽可能的小,也就是说找到一个参数解 𝑤 和 𝑏 ,使得损失函数取得极小值。

数值求解方法:

梯度下降法。从当前的参数取值,一步步的按照下坡的方向下降,直到走到最低点。

net = Network(13)
losses = []
#只画出参数w5和w9在区间[-160, 160]的曲线部分,以及包含损失函数的极值
w5 = np.arange(-160.0, 160.0, 1.0)
w9 = np.arange(-160.0, 160.0, 1.0)
losses = np.zeros([len(w5), len(w9)])

#计算设定区域内每个参数取值所对应的Loss
for i in range(len(w5)):
    for j in range(len(w9)):
        net.w[5] = w5[i]
        net.w[9] = w9[j]
        z = net.forward(x)
        loss = net.loss(z, y)
        losses[i, j] = loss

#使用matplotlib将两个变量和对应的Loss作3D图
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
fig = plt.figure()
ax = Axes3D(fig)

w5, w9 = np.meshgrid(w5, w9)

ax.plot_surface(w5, w9, losses, rstride=1, cstride=1, cmap='rainbow')
plt.show()

均方误差和绝对值误差(只将每个样本的误差累加,不做平方处理)的损失函数曲线图:

20230304164326

均方误差表现的"圆滑"的坡度有两个好处:

  • 曲线的最低点是可导的。
  • 越接近最低点,曲线的坡度逐渐放缓,有助于通过当前的梯度来判断接近最低点的程度(是否逐渐减少步长,以免错过最低点)。

而绝对值误差是不具备这两个特性的,这也是损失函数的设计不仅仅要考虑"合理性",还要追求"易解性"的原因。

Numpy库的广播功能:

一方面可以扩展参数的维度,代替for循环来计算1个样本对从 𝑤0 到 𝑤12 的所有参数的梯度。 另一方面可以扩展样本的维度,代替for循环来计算样本0到样本403对参数的梯度。

class Network(object):
    def __init__(self, num_of_weights):
        # 随机产生w的初始值
        # 为了保持程序每次运行结果的一致性,此处设置固定的随机数种子
        np.random.seed(0)
        self.w = np.random.randn(num_of_weights, 1)
        self.b = 0.
        
    def forward(self, x):
        z = np.dot(x, self.w) + self.b
        return z
    
    def loss(self, z, y):
        error = z - y
        num_samples = error.shape[0]
        cost = error * error
        cost = np.sum(cost) / num_samples
        return cost
    
    def gradient(self, x, y):
        z = self.forward(x)
        gradient_w = (z-y)*x
        gradient_w = np.mean(gradient_w, axis=0)
        gradient_w = gradient_w[:, np.newaxis]
        gradient_b = (z - y)
        gradient_b = np.mean(gradient_b)
        
        return gradient_w, gradient_b

确定损失函数更小的点

为什么之前我们要做输入特征的归一化,保持尺度一致?这是为了让统一的步长更加合适。特征输入归一化后,不同参数输出的Loss是一个比较规整的曲线,学习率可以设置成统一的值 ;特征输入未归一化时,不同特征对应的参数所需的步长不一致,尺度较大的参数需要大步长,尺寸较小的参数需要小步长,导致无法设置统一的学习率。

训练扩展到全部参数

class Network(object):
    def __init__(self, num_of_weights):
        # 随机产生w的初始值
        # 为了保持程序每次运行结果的一致性,此处设置固定的随机数种子
        np.random.seed(0)
        self.w = np.random.randn(num_of_weights, 1)
        self.b = 0.
        
    def forward(self, x):
        z = np.dot(x, self.w) + self.b
        return z
    
    def loss(self, z, y):
        error = z - y
        num_samples = error.shape[0]
        cost = error * error
        cost = np.sum(cost) / num_samples
        return cost
    
    def gradient(self, x, y):
        z = self.forward(x)
        gradient_w = (z-y)*x
        gradient_w = np.mean(gradient_w, axis=0)
        gradient_w = gradient_w[:, np.newaxis]
        gradient_b = (z - y)
        gradient_b = np.mean(gradient_b)        
        return gradient_w, gradient_b
    
    def update(self, gradient_w, gradient_b, eta = 0.01):
        self.w = self.w - eta * gradient_w
        self.b = self.b - eta * gradient_b
        
    def train(self, x, y, iterations=100, eta=0.01):
        losses = []
        for i in range(iterations):
            z = self.forward(x)
            L = self.loss(z, y)
            gradient_w, gradient_b = self.gradient(x, y)
            self.update(gradient_w, gradient_b, eta)
            losses.append(L)
            if (i+1) % 10 == 0:
                print('iter {}, loss {}'.format(i, L))
        return losses

# 获取数据
train_data, test_data = load_data()
x = train_data[:, :-1]
y = train_data[:, -1:]
# 创建网络
net = Network(13)
num_iterations=1000
# 启动训练
losses = net.train(x,y, iterations=num_iterations, eta=0.01)

# 画出损失函数的变化趋势
plot_x = np.arange(num_iterations)
plot_y = np.array(losses)
plt.plot(plot_x, plot_y)
plt.show()

随机梯度下降

但在实际问题中,数据集往往非常大,如果每次都使用全量数据进行计算,效率非常低,通俗地说就是"杀鸡焉用牛刀"。由于参数每次只沿着梯度反方向更新一点点,因此方向并不需要那么精确。一个合理的解决方案是每次从总的数据集中随机抽取出小部分数据来代表整体,基于这部分数据计算梯度和损失来更新参数,这种方法被称作随机梯度下降法(Stochastic Gradient Descent,SGD),核心概念如下:

  • minibatch:每次迭代时抽取出来的一批数据被称为一个minibatch。
  • batch size:每个minibatch所包含的样本数目称为batch size。
  • Epoch:当程序迭代的时候,按minibatch逐渐抽取出样本,当把整个数据集都遍历到了的时候,则完成了一轮训练,也叫一个Epoch(轮次)。启动训练时,可以将训练的轮数num_epochs和batch_size作为参数传入。

通过大量实验发现,模型对最后出现的数据印象更加深刻。训练数据导入后,越接近模型训练结束,最后几个批次数据对模型参数的影响越大。为了避免模型记忆影响训练效果,需要进行样本乱序操作。

train函数的代码如下:

# 获取数据
train_data, test_data = load_data()

# 打乱样本顺序
np.random.shuffle(train_data)

# 将train_data分成多个minibatch
batch_size = 10
n = len(train_data)
mini_batches = [train_data[k:k+batch_size] for k in range(0, n, batch_size)]

# 创建网络
net = Network(13)

# 依次使用每个mini_batch的数据
for mini_batch in mini_batches:
    x = mini_batch[:, :-1]
    y = mini_batch[:, -1:]
    loss = net.train(x, y, iterations=1)

将每个随机抽取的minibatch数据输入到模型中用于参数训练。训练过程的核心是两层循环:

第一层循环,代表样本集合要被训练遍历几次,称为"epoch",代码如下: for epoch_id in range(num_epochs):

第二层循环,代表每次遍历时,样本集合被拆分成的多个批次,需要全部执行训练,称为"iter (iteration)",代码如下:

for iter_id,mini_batch in emumerate(mini_batches):

在两层循环的内部是经典的四步训练流程:前向计算->计算损失->计算梯度->更新参数,这与大家之前所学是一致的,代码如下:

x = mini_batch[:, :-1]
y = mini_batch[:, -1:]
a = self.forward(x)  #前向计算
loss = self.loss(a, y)  #计算损失
gradient_w, gradient_b = self.gradient(x, y)  #计算梯度
self.update(gradient_w, gradient_b, eta)  #更新参数

将两部分改写的代码集成到Network类中的train函数中,最终的实现如下。


import numpy as np

class Network(object):
    def __init__(self, num_of_weights):
        # 随机产生w的初始值
        # 为了保持程序每次运行结果的一致性,此处设置固定的随机数种子
        #np.random.seed(0)
        self.w = np.random.randn(num_of_weights, 1)
        self.b = 0.
        
    def forward(self, x):
        z = np.dot(x, self.w) + self.b
        return z
    
    def loss(self, z, y):
        error = z - y
        num_samples = error.shape[0]
        cost = error * error
        cost = np.sum(cost) / num_samples
        return cost
    
    def gradient(self, x, y):
        z = self.forward(x)
        N = x.shape[0]
        gradient_w = 1. / N * np.sum((z-y) * x, axis=0)
        gradient_w = gradient_w[:, np.newaxis]
        gradient_b = 1. / N * np.sum(z-y)
        return gradient_w, gradient_b
    
    def update(self, gradient_w, gradient_b, eta = 0.01):
        self.w = self.w - eta * gradient_w
        self.b = self.b - eta * gradient_b
            
                
    def train(self, training_data, num_epochs, batch_size=10, eta=0.01):
        n = len(training_data)
        losses = []
        for epoch_id in range(num_epochs):
            # 在每轮迭代开始之前,将训练数据的顺序随机打乱
            # 然后再按每次取batch_size条数据的方式取出
            np.random.shuffle(training_data)
            # 将训练数据进行拆分,每个mini_batch包含batch_size条的数据
            mini_batches = [training_data[k:k+batch_size] for k in range(0, n, batch_size)]
            for iter_id, mini_batch in enumerate(mini_batches):
                #print(self.w.shape)
                #print(self.b)
                x = mini_batch[:, :-1]
                y = mini_batch[:, -1:]
                a = self.forward(x)
                loss = self.loss(a, y)
                gradient_w, gradient_b = self.gradient(x, y)
                self.update(gradient_w, gradient_b, eta)
                losses.append(loss)
                print('Epoch {:3d} / iter {:3d}, loss = {:.4f}'.
                                 format(epoch_id, iter_id, loss))
        
        return losses

# 获取数据
train_data, test_data = load_data()

# 创建网络
net = Network(13)
# 启动训练
losses = net.train(train_data, num_epochs=50, batch_size=100, eta=0.1)

# 画出损失函数的变化趋势
plot_x = np.arange(len(losses))
plot_y = np.array(losses)
plt.plot(plot_x, plot_y)
plt.show()

随机梯度下降加快了训练过程,但由于每次仅基于少量样本更新参数和计算损失ii,所以损失下降曲线会出现震荡。

总结

本节我们详细介绍了如何使用Numpy实现梯度下降算法,构建并训练了一个简单的线性模型实现波士顿房价预测,可以总结出,使用神经网络建模房价预测有三个要点:

构建网络,初始化参数www和bbb,定义预测和损失函数的计算方法。 随机选择初始点,建立梯度的计算方法和参数更新方式。 将数据集的数据按batch size的大小分成多个minibatch,分别灌入模型计算梯度并更新参数,不断迭代直到损失函数几乎不再下降。