基于神经网络模型的波士顿房价预测
波士顿房价预测是一个经典的机器学习任务,类似于程序员世界的“Hello World”。以“波士顿房价预测”任务为例,我们学习如何使用Python语言和Numpy库来构建神经网络模型。
波士顿地区的房价受诸多因素影响。该数据集统计了13种可能影响房价的因素和该类型房屋的均价,期望构建一个基于13个因素进行房价预测的模型,如下所示。
对于预测问题,可以根据预测输出的类型是连续的实数值,还是离散的标签,区分为回归任务和分类任务。因为房价是一个连续值,所以房价预测是一个回归任务。下面我们尝试用最简单的线性回归模型解决这个问题,并用神经网络来实现这个模型。
构建深度学习/神经网络基本步骤
数据处理
数据处理包含五个部分:数据导入、数据形状变换、数据集划分、数据归一化处理和封装load data函数。数据预处理后,才能被模型调用。
- 数据导入
- 数据形状变换
由于读入的原始数据是1维的,所有数据都连在一起。因此需要我们将数据的形状进行变换,形成一个2维的矩阵,每行为一个数据样本(14个值),每个数据样本包含13个X(影响房价的特征)和一个Y(该类型房屋的均价)。 - 数据集划分
将数据集划分成训练集和测试集,其中训练集用于确定模型的参数,测试集用于评判模型的效果。我们期望模型学习的是任务的本质规律,而不是训练数据本身,模型训练未使用的数据,才能更真实的评估模型的效果。
在本案例中,我们将80%的数据用作训练集,20%用作测试集,实现代码如下。通过打印训练集的形状,可以发现共有404个样本,每个样本含有13个特征和1个预测值。 - 数据归一化
对每个特征进行归一化处理,使得每个特征的取值缩放到0~1之间。这样做有两个好处:一是模型训练更高效;二是特征前的权重大小可以代表该变量对预测结果的贡献度(因为每个特征值本身的范围相同)。 - 封装成load data函数
def load_data():
# 1.导入数据
datafile = './'
data = np.fromfile(datafile,sep=' ')
feature_names = [ 'CRIM', 'ZN', 'INDUS', 'CHAS', 'NOX', 'RM', 'AGE', \
'DIS', 'RAD', 'TAX', 'PTRATIO', 'B', 'LSTAT', 'MEDV' ]
fature_num = len(feature_names)
#2.数据变形
data = data.reshape(data.shape[0]//feature_num, feature_num)
#3.训练集划分
ratio = 0.8
offset = int(data.shape[0]*ratio)
train_data = data[:offset]#数组默认行,取前400多行
#4.归一化,先求出每列最大最小平均值各14个(13特征+1房价),对每列(14列)各个列归一化
#注意:最大最小平均值只针对训练集(真实世界中测试集归一化参数未知),并应用到所有数据上
maxinum,mininum,avgs = \
train_data.max(axis=0),\
train_data.min(axis=0),\
train_data.sum(axis=0)/train_data.shape[0]
for i in range(feature_num):
data[:,i]=(data[:,i]-avgs[i])/(maxinum[i]-mininum[i])
#5.归一化完以后的数据划分(前offset行为训练集)
train_data = [:offset]
test_data = [offset:]
return train_data,test_data
#加载数据
train_data,test_data = load_data()
x = train_data[:,:-1] #所有行,列[:-1]即1-13列
y = train_data[:,-1:] #最后一列,即第14列
模型设计
模型设计是深度学习模型关键要素之一,也称为网络结构设计,相当于模型的假设空间,即实现模型“前向计算”(从输入到输出)的过程。
深度学习的一个神经元,就可以表示线性回归,去掉激活函数(非线性)即可。
前向计算:
将上述计算预测输出的过程以“类和对象”的方式来描述,类成员变量有参数w和b。通过写一个forward函数(代表“前向计算”)完成上述从特征和参数到输出预测值的计算过程,代码如下所示。
#以类和对象的方式定义网络模型
class Network(object):
def __init__(self,num_of_weights):
np.random.seed(0) #设置种子,保证每次生成随机数一样
self.w = np.random.randn(num_of_weights,1) #随机生成初始权重
self.b = 0. #初始偏置为0
def forward(self,x):
z=np.dot(x,self.w)+self.b #线性方程,注意x(一行)和self.w(一列)顺序不能反
return z
此时,可以随便取一个值计算一下
net = Network(13)
x1 = x[0]
y1 = y[0]
z = net.forward(x1)
print(z)
print(y1)
输出:
[2.39362982]
[0.42222222]
可以看到输出Z和目标值Y还相差甚远,说明该模型在没有学习的情况下,还没有预测能力,所以需要接下来参数寻优,但起码模型框架搭建起来了。
训练配置
模型设计完成后,需要通过训练配置寻找模型的最优值,即通过损失函数来衡量模型的好坏。训练配置也是深度学习模型关键要素之一。
- 在本例中,我们需要有某种指标来衡量预测值Z跟真实值Y之间的差距。对于回归问题,最常采用的衡量方法是使用均方误差作为评价模型好坏的指标,即:Loss=(Y-Z)**2。
- Loss就是损失函数,它是衡量模型好坏的指标。在回归问题中,均方误差是一种比较常见的形式,分类问题中通常会采用交叉熵作为损失函数。
在Network类下面添加损失函数的计算过程如下:
#以类和对象的方式定义网络模型
class Network(object):
def __init__(self,num_of_weights):
np.random.seed(0) #设置种子,保证每次生成随机数一样
self.w = np.random.randn(num_of_weights,1) #随机生成初始权重
self.b = 0. #初始偏置为0
def forward(self,x):
z=np.dot(x,self.w)+self.b #线性方程,x是506*13,w是13*1,b是13*1,所以z是506*1
return z
def Loss(self,z,y):
cost = np.mean((z-y)**2) #最小均方误差,两列相减
return cost
训练过程
接下来介绍如何求解参数w和b的数值,这个过程也称为模型训练过程。
目标是让定义的损失函数Loss尽可能的小,也就是说找到一个参数解w和b,使得损失函数取得极小值。
但是导数为0这种方法只对线性回归这样简单的任务有效。如果模型中含有非线性变换,或者损失函数不是均方差这种简单的形式,则很难通过上式求解。为了解决这个问题,下面我们将引入更加普适的数值求解方法:梯度下降法。
梯度下降法
在现实中存在大量的函数正向求解容易,但反向求解较难,被称为单向函数,这种函数在密码学中有大量的应用。密码锁的特点是可以迅速判断一个密钥是否是正确的(已知x,求y很容易),但是即使获取到密码锁系统,无法破解出正确的密钥是什么(已知y,求x很难)。
这种情况类似于一位想从山峰走到坡谷的盲人,他看不见坡谷在哪(无法逆向求解出Loss导数为0时的参数值),但可以伸脚探索身边的坡度(当前点的导数值,也称为梯度)。那么,求解Loss函数最小值可以这样实现:从当前的参数取值,一步步的按照下坡的方向下降,直到走到最低点。这种方法笔者称它为“盲人下坡法”。哦不,有个更正式的说法“梯度下降法”。训练的关键是找到一组(w,b),使得损失函数L取极小值。
注:选w5和w9两个参数是因为这两个参数的图形最直观,且参数多了在三维以上空间不好看出。
注:为什么选择均方误差,而不是绝对值误差?
因为均方误差曲线呈现出“圆滑”的坡度。下图呈现了只有一个参数维度时,均方误差和绝对值误差(只将每个样本的误差累加,不做平方处理)的损失函数曲线图。
由此可见,均方误差表现的“圆滑”的坡度有两个好处:
- 曲线的最低点是可导的。
- 越接近最低点,曲线的坡度逐渐放缓,有助于通过当前的梯度来判断接近最低点的程度(是否需要逐渐减少步长,以免错过最低点)。
而绝对值误差是不具备这两个特性的,这也是损失函数的设计不仅仅要考虑“合理性”,还要追求“易解性”的原因。
微积分的基础知识告诉我们,沿着梯度的反方向,是函数值下降最快的方向。简单理解,函数在某一个点的梯度方向是曲线斜率最大的方向,但梯度方向是向上的,所以下降最快的是梯度的反方向。
计算梯度
公式推导:
注:为什么前面乘以1/2?
因为二次函数求导的时候会产生因子2,L求导时平方2会到前面,所以引入1/2就可以抵消。
只取第一个样本,查看其数据内容和维度:
x1 = x[0]
y1 = y[0]
z1 = net.forward(x1)
print('x1 {}, shape {}'.format(x1, x1.shape))
print('y1 {}, shape {}'.format(y1, y1.shape))
print('z1 {}, shape {}'.format(z1, z1.shape))
输出:
x1 [0. 0.18 0.07344184 0. 0.31481481 0.57750527
0.64160659 0.26920314 0. 0.22755741 0.28723404 1.
0.08967991], shape (13,)
y1 [0.42222222], shape (1,)
z1 [130.86954441], shape (1,)
按上面的公式,当只有一个样本时,可以计算某个w的梯度。
使用numpy进行梯度计算
利用numpy的广播机制,计算:
那么对于有N个样本的情形,我们可以直接使用如下方式计算出所有样本对梯度的贡献,这就是使用Numpy库广播功能带来的便捷。 小结一下这里使用Numpy库的广播功能:
- 一方面可以扩展参数的维度,代替for循环来计算1个样本对从w0到w12的所有参数的梯度。
- 另一方面可以扩展样本的维度,代替for循环来计算样本0到样本403对参数的梯度。
z = net.forward(x)
gradient_w = (z - y) * x
print('gradient_w shape {}'.format(gradient_w.shape))
print(gradient_w)
输出:
gradient_w shape (404, 13)
[[0.00000000e+00 2.34805180e+01 9.58029163e+00 ... 3.74689117e+01
1.30447322e+02 1.16985043e+01]
[2.54738434e-02 0.00000000e+00 2.83333765e+01 ... 5.97311025e+01
1.07975454e+02 2.20777626e+01]
[3.07963708e-02 0.00000000e+00 3.42860463e+01 ... 7.22802431e+01
1.29029688e+02 8.29246719e+00]
...
[3.97706874e+01 0.00000000e+00 1.74130673e+02 ... 2.01043762e+02
2.48659390e+02 1.27554582e+02]
[2.69696515e+01 0.00000000e+00 1.75225687e+02 ... 2.02308019e+02
2.34270491e+02 1.28287658e+02]
[6.08972123e+01 0.00000000e+00 1.53017134e+02 ... 1.76666981e+02
2.18509161e+02 1.08772220e+02]]
此时,各维度为:
gradient_w shape (404, 13)
x shape (404, 13)
y shape (404, 1)
z shape (404, 1)
gradient_w的形状是(13,),而w的维度是(13, 1)。导致该问题的原因是使用np.mean函数时消除了第0维。为了加减乘除等计算方便,gradient_w和w必须保持一致的形状。因此我们将gradient_w的维度也设置为(13,1),代码如下:
gradient_w = gradient_w[:, np.newaxis]#加一维,这维是虚的,从(13,)变成(13,1)
print('gradient_w shape', gradient_w.shape)
输出:
gradient_w shape (13, 1)
- 为什么此处维度要一致?
因为这个gradient和参数后续是要相加的,必须维度一致才能相加。gradient代表了要朝哪个方向走一小步,参数是当前站的位置点,两者相加代表下一步的落脚点。
综合上面的剖析,计算梯度的代码如下:
z = net.forward(x)
gradient_w = (z - y) * x
gradient_w = np.mean(gradient_w, axis=0) #求每列的均值,即把每行相加除以总行数
gradient_w = gradient_w[:, np.newaxis] #从(13,)变成(13,1)
上述代码非常简洁地完成了w的梯度计算。同样,计算偏置b的梯度的代码也是类似的原理,但b是一维
gradient_b = (z - y)
gradient_b = np.mean(gradient_b)
# 此处b是一个数值,所以可以直接用np.mean得到一个标量
现在将计算w和b的梯度的过程,写成Network类的gradient函数,实现方法如下所示:
#以类和对象的方式定义网络模型
class Network(object):
def __init__(self,num_of_weights):
np.random.seed(0) #设置种子,保证每次生成随机数一样
self.w = np.random.randn(num_of_weights,1) #随机生成初始权重
self.b = 0. #初始偏置为0
def forward(self,x):
z=np.dot(x,self.w)+self.b #线性方程,x是506*13,w是13*1,b是13*1,所以z是506*1
return z
def Loss(self,z,y):
loss = np.mean((z-y)**2) #最小均方误差,两列相减
cost = np.sum(loss)/loss.shape[0]
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] #np.mean函数消除了第0维,再加上
gradient_b = z-y #同理求b的梯度
gradient_b = np.mean(gradient_b) #b是一个数,不用加一维
return gradient_w, gradient_b
梯度下降总结:(前向计算+后向传播)
可分为以下四个步骤,且顺序不能乱!
1.前向计算:得到模型的输出,即预测值z
2.损失函数计算:预测值z和真实值y比较计算,得到Loss
3.梯度计算:z,y,x都需要,才能计算梯度
4.梯度跟新:根据梯度,更新参数
# 调用上面定义的gradient函数,计算梯度
# 初始化网络
net = Network(13)
# 设置[w5, w9] = [-100., -100.]
net.w[5] = -100.0
net.w[9] = -100.0
z = net.forward(x) #步骤1
loss = net.loss(z, y) #步骤2
gradient_w, gradient_b = net.gradient(x, y) #步骤3 net.gradient函数里需要z
gradient_w5 = gradient_w[5][0] #以参数5,9为例
gradient_w9 = gradient_w[9][0]
print('point {}, loss {}'.format([net.w[5][0], net.w[9][0]], loss))
print('gradient {}'.format([gradient_w5, gradient_w9]))
输出:
point [-100.0, -100.0], loss 7873.345739941161
gradient [-45.87968288123223, -35.50236884482904]
若其中:
gradient_w5 = gradient_w[5]
gradient_w9 = gradient_w[9]
输出:(说明gradient_w[5]是一个数组,gradient_w[5][0]就是取这个数)
point [-100.0, -100.0], loss 7873.345739941161
gradient [array([-45.87968288]), array([-35.50236884])]
以上是前三步骤,下面是第四步骤即更新参数:
梯度更新(确定损失函数更小的点)
下面我们开始研究更新梯度的方法。首先沿着梯度的反方向移动一小步,找到下一个点P1,观察损失函数的变化。
可以发现沿着梯度反方向走一小步,下一个点的损失函数的确减少了。感兴趣的话,大家可以尝试不停的点击下面的代码块,观察损失函数是否一直在变小。
# 在[w5, w9]平面上,沿着梯度的反方向移动到下一个点P1
# 定义移动步长 eta ,控制每次参数值沿着梯度反方向变动的大小,即每次移动的步长,又称为学习率。
eta = 0.1
# 更新参数w5和w9,相减:参数需要向梯度的反方向移动。
net.w[5] = net.w[5] - eta * gradient_w5
net.w[9] = net.w[9] - eta * gradient_w9
# 重新计算z和loss
z = net.forward(x)
loss = net.loss(z, y)
gradient_w, gradient_b = net.gradient(x, y)
gradient_w5 = gradient_w[5][0]
gradient_w9 = gradient_w[9][0]
print('point {}, loss {}'.format([net.w[5][0], net.w[9][0]], loss))
print('gradient {}'.format([gradient_w5, gradient_w9]))
输出:(点击运行了几十下以后)
point [1.7845446324229493, -11.453816122683802], loss 7.066107442998109
gradient [0.08098659354206239, -0.6898197483821624]
注:为什么之前要做输入特征的归一化,保持尺度一致?
这是为了让统一的步长更加合适。如下图所示,特征输入归一化后,不同参数输出的Loss是一个比较规整的曲线,朝哪个方向都一样,学习率可以设置成统一的值 ;特征输入未归一化时,不同特征对应的参数所需的步长不一致,尺度较大的参数w1需要大步长,尺寸较小的参数w2需要小步长,导致无法设置统一的学习率。
代码封装Train函数
实现逻辑:1.前向计算输出、2.根据输出和真实值计算Loss、3.基于Loss和输入计算梯度、4.根据梯度更新参数值”四个部分反复执行,直到到损失函数最小。
#以类和对象的方式定义网络模型
class Network(object):
def __init__(self,num_of_weights):
np.random.seed(0) #设置种子,保证每次生成随机数一样
self.w = np.random.randn(num_of_weights,1) #随机生成初始权重
self.b = 0. #初始偏置为0
def forward(self,x):
z=np.dot(x,self.w)+self.b #线性方程,x是506*13,w是13*1,b是13*1,所以z是506*1
return z
def loss(self,z,y):
loss = np.mean((z-y)**2) #最小均方误差,两列相减
cost = np.sum(loss)/loss.shape[0]
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] #np.mean函数消除了第0维,再加上
gradient_b = z-y #同理求b的梯度
gradient_b = np.mean(gradient_b) #b是一个数,不用加一维
return gradient_w, gradient_b
def update(self,gradient_w,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):
points = []
losses = []
for i in range(iterations):#循环四步
points.append(self.w)
z = self.forward(x) #1.前向计算
L = self.loss(z,y) #2.损失函数
gradient_w, gradient_b = self.gradient(x,y) #3.计算梯度
self.update(gradient_w, gradient_b,eta) #4.梯度更新
losses.append(L)
if i%50 == 0:
print('iter {}, point {}, loss {}'.format(i, [net.w], L))
return points,losses
#获取数据
train_data, test_data = load_data()
x = train_data[:, :-1]
y = train_data[:, -1:]
#创建网络
net = Network(13)
num_iterations = 1000
#启动训练
points,losses = net.train(x, y, iteration=num_iterations, eta=0.01)
#画出损失函数变化
plot_x = np.arange(num_itrations)
plot_y = np.array(losses)
plt.plot(plot_x, plot_y)
plt.show()
loss变化:
随机梯度下降法(Stochastic Gradient Descent,SGD)
在上述程序中,每次损失函数和梯度计算都是基于数据集中的全量数据。对于波士顿房价预测任务数据集而言,样本数比较少,只有404个。但在实际问题中,数据集往往非常大,如果每次都使用全量数据进行计算,效率非常低,通俗地说就是“杀鸡焉用牛刀”。由于参数每次只沿着梯度反方向更新一点点,因此方向并不需要那么精确。一个合理的解决方案是每次从总的数据集中随机抽取出小部分数据来代表整体,基于这部分数据计算梯度和损失来更新参数,这种方法被称作随机梯度下降法,核心概念如下:
- mini-batch:每次迭代时抽取出来的一小批数据被称为一个mini-batch。
- batch_size:一个mini-batch所包含的样本数目称为batch_size。
- epoch:当程序迭代的时候,按mini-batch逐渐抽取出样本,当把整个数据集都遍历到了的时候,则完成了一轮训练,也叫一个epoch。启动训练时,可以将训练的轮数num_epochs和batch_size作为参数传入。
数据处理需要实现拆分数据批次和样本乱序(为了实现随机抽样的效果)两个功能。
将train_data分成大小为batch_size的多个mini_batch,如下代码所示:将train_data分成 404/10+1=41 个 mini_batch,其中前40个mini_batch,每个均含有10个样本,最后一个mini_batch只含有4个样本。
batch_size = 10
n = len(train_data)
mini_batches = [train_data[k:k+batch_size] for k in range(0, n, batch_size)]
print('total number of mini_batches is ', len(mini_batches))
print('first mini_batch shape ', mini_batches[0].shape)
print('last mini_batch shape ', mini_batches[-1].shape)
输出:
total number of mini_batches is 41
first mini_batch shape (10, 14)
last mini_batch shape (4, 14)
上面是按顺序读取mini_batch,而SGD里面是随机抽取一部分样本代表总体。为了实现随机抽样的效果,我们先将train_data里面的样本顺序随机打乱,然后再抽取mini_batch。随机打乱样本顺序,需要用到np.random.shuffle函数。
注:为什么要乱序?
因为通过大量实验发现,模型对最后出现的数据印象更加深刻。训练数据导入后,越接近模型训练结束,最后几个批次数据对模型参数的影响越大。为了避免模型记忆影响训练效果,需要进行样本乱序操作。
实现以上两个功能的完整代码:
# 获取数据
train_data, test_data = load_data()
# 打乱样本顺序
np.random.shuffle(train_data)
# 将train_data分成多个mini_batch
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)
训练过程代码修改
将每个随机抽取的mini-batch数据输入到模型中用于参数训练。训练过程的核心是两层循环:
1.第一层循环,代表整个样本集合被训练遍历的次数,称为“epoch”。
2.第二层循环,代表每次遍历时,样本集合被拆分成的多个批次,需要全部执行训练,称为“iter (iteration)”。
核心:整个深度学习参数优化过程就是“一招鲜“,无论多复杂的深度学习模型,训练部分就是两层循环加经典四步。
随机梯度下降,速度更快了,使得模型性能大大提升。
将两部分改写的代码集成到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()