在这次作业中会学到:(参考 https://zhuanlan.zhihu.com/p/536483424)
本项目中,我们要用到一个平面点数据集。在平面上,有三种颜色不同的点。我们希望用PyTorch编写的神经网络能够区分这三种点。
import matplotlib.pyplot as plt
import numpy as np
from typing import List
def generate_points(cnt):
x = np.random.rand(cnt)
print(x.shape)
y = np.random.rand(cnt)
X = np.stack([x,y],1)
print("X shape", X.shape)
Y = np.where(y > x * x, np.where(y > x**0.5, 0, 1), 2)
#np.where(condition, x, y),满足条件(condition),输出x,不满足输出y。
# x ** 0.5是x开根号的意思
return X.T, Y[..., np.newaxis].T
##x[:, np.newaxis] :放在后面,会给列上增加维度;
##x[np.newaxis, :] :放在前面,会给行上增加维度;
#np.stack 是 NumPy 中用于 沿新轴堆叠多个数组 的函数,适用于将多个数组组合成一个更高维度的数组。以下是其用法详解:
#axis 的作用
#axis 值 堆叠方向 输入形状 (N个数组) 输出形状
#0 新维度作为第一个轴 (a, b) × N (N, a, b)
#1 新维度作为第二个轴 (a, b) × N (a, N, b)
#2 新维度作为第三个轴 (a, b) × N (a, b, N)
#-1 新维度作为最后一个轴(默认) (a, b) × N (a, b, N)
def plot_points(X,Y):
new_x = X[0, :]
new_y = X[1, :]
Y = np.squeeze(Y, 0)
color_map = np.array(['r', 'g', 'b'])
c = color_map[Y]
plt.scatter(new_x, new_y, color = c)
plt.show()
train_X, train_Y = generate_points(400)
print(train_X.shape)
print(train_Y.shape)
plot_points(train_X, train_Y)
数据预处理与PyTorch转换
在PyTorch中,所有参与运算的张量都用同一个类表示,其类型名叫做Tensor。而在构建张量时,我们一般用torch.tensor这个函数。
使用torch.tensor和使用np.ndarray非常类似,一般只要把数据传入第一个参数就行,有需要的话可以设置数据类型,对于train_X:
train_X_pt = torch.tensor(train_X, dtype=torch.float32)
而在使用train_Y
时,要做一些额外的预处理操作。在计算损失函数时,PyTorch默认标签Y
是一个一维整形数组。而我们之前都会把Y
预处理成[1, m]
的张量。因此,这里要先做一个维度转换,再转张量:
train_Y_pt = torch.tensor(train_Y.squeeze(0), dtype=torch.long)
经过上述操作,X, Y
再被送入PyTorch模型之前的形状是:
print(train_X_pt.shape)
print(train_Y_pt.shape)
# X: [2, m]
# Y: [m]
处理完了数据,接下来,我们就要定义神经网络了。在神经网络中,我们要实现初始化、正向传播、误差、评估这四个方法。
class MulticlassClassficationNet():
def __init__(self, neuron_cnt: List[int]):
self.num_layer = len(neuron_cnt)-1
self.neuron_cnt = neuron_cnt
self.W = []
self.b = []
for i in range(self.num_layer):
new_W = torch.empty(neuron_cnt[i+1], neuron_cnt[i]) #empty生成形状的空张量
new_B = torch.empty(neuron_cnt[i+1], 1)
torch.nn.init.kaiming_normal_(new_W, nonlinearity='relu')#kaiming_normal就是He Initialization
torch.nn.init.kaiming_normal_(new_B, nonlinearity='relu')
self.W.append(torch.nn.Parameter(new_W))#我们把它们 构造成torch.nn.Parameter。这样,torch就会自动更新这些参数了。
self.b.append(torch.nn.Parameter(new_B))
self.trainable_vars = self.W + self.b
self.loss_fn = torch.nn.CrossEntropyLoss()
def forward(self, X):
A = X
for i in range(self.num_layer):
Z = torch.matmul(self.W[i], A) + self.b[i]
if i == self.num_layer -1:
A = F.softmax(Z,0)
else:
A = F.relu(Z)
return A
def loss(self, Y, Y_hat):
return self.loss_fn(Y_hat.T, Y)
def evaluate (self, X, Y, return_loss = False):
Y_hat = self.forward(X)
Y_predict = Y
Y_hat_predict = torch.argmax(Y_hat, 0)
res = (Y_predict == Y_hat_predict).float()
accuracy = torch.mean(res)
if return_loss:
loss = self.loss(Y, Y_hat)
return accuracy, loss
else:
return accuracy
def train(model: MulticlassClassficationNet,
X,
Y,
steps,
learning_rate,
print_interval=100):
optimizer = torch.optim.Adam(model.trainable_vars, learning_rate)
for i in range(steps):
Y_hat = model.forward(X)
cost = model.loss(Y, Y_hat)
optimizer.zero_grad()
cost.backward()
optimizer.step()
if i % print_interval == 0:
accuracy, loss = model.evaluate(X, Y, return_loss=True)
print(f'Step: {i}')
print(f'Accuracy: {accuracy}')
print(f'Train loss: {loss}')
和之前一样,我们通过neuron_cnt
指定神经网络包含输出层在内每一层的神经元数。之后,根据每一层的神经元数,我们就可以初始化参数W
和b
了。
使用PyTorch,我们可以方便地完成一些高级初始化操作。首先,我们用torch.empty
生成一个形状正确的空张量。之后,我们调用torch.nn.init.kaiming_normal_
的初始化函数。kaiming_normal就是He Initialization。这个初始化方法需要指定激活函数是ReLU还是LeakyReLU。我们之后要用ReLU,所以nonlinearity
是那样填的。
初始化完成后,为了让torch知道这几个张量是用可训练的参数,我们把它们 构造成torch.nn.Parameter
。这样,torch就会自动更新这些参数了。
最后,我们用self.trainable_vars = self.W + self.b
记录一下所有待优化变量,并提前初始化一个交叉熵误差函数,为之后的优化算法做准备
正向传播中
在这份代码中,torch.matmul
用于执行矩阵乘法,等价于np.dot
。和NumPy里的张量一样,PyTorch里的张量也可以直接用运算符+
来完成加法。
做完了线性层的运算后,我们可以方便地调用torch.nn.functional
里的激活函数完成激活操作。在大多数人的项目中,torch.nn.functional
会被导入简称成F
。PyTorch里的底层运算函数都在F
中,而构造一个函数类(比如刚刚构造的torch.nn.CrossEntropyLoss()
再调用该函数类,其实等价于直接去运行F
里的函数。
值得一提的是,PyTorch会自动帮我们计算导数。因此,我们不用在正向传播里保存中间运算结果,也不用再编写反向传播函数了
损失函数
由于之前已经初始化好了误差函数,这里直接就调用就行了:
def loss(self, Y, Y_hat):
return self.loss_fn(Y_hat.T, Y)
self.loss_fn = torch.nn.CrossEntropyLoss()
就是PyTorch的交叉熵误差函数,它也适用于多分类。由于这个函数要求第一个参数的形状为[num_samples, num_classes]
,和我们的定义相反,我们要把网络输出Y_hat
转置一下。第二个输入Y
必须是一维整形数组,我们之前已经初始化好了,不用做额外操作,PyTorch会自动把它变成one-hot向量。做完运算后,该函数会自动计算出平均值,不要再手动求一次平均。
评估
首先,我们使用Y_hat = self.forward(X)
,根据X
算出估计值Y_hat
。之后我们就要对Y
和Y_hat
进行比较了。
Y_hat
只记录了分类成各个类别的概率,用向量代表了标签。为了方便比较,我们要把它转换回用整数表示的标签。这个转换函数是torch.argmax
。
和数学里的定义一样,torch.argmax
返回令函数最大的参数值。而对于数组来说,就是返回数组里值最大的下标值。torch.argmax
的第一个参数是参与运算的张量,第二个参数是参与运算的维度。Y_hat
的形状是[3, m]
,我们要把长度为3的向量转换回标签向量,因此应该对第一维进行运算(即维度0)。
得到了Y_predict, Y_hat_predict
后,我们要比对它们以计算准确率。这时,我们可以用Y_predict == Y_hat_predict
得到一个bool值的比对结果。PyTorch的类型比较严格,bool值是无法参与普通运算的,我们要用.float
强制类型转换成浮点型。
最后,用accuracy = torch.mean(res)
就可以得到准确率了。
由于我们前面写好了loss
方法,计算loss时直接调用方法就行了。
训练
PyTorch使用一系列的优化器来维护梯度下降的过程。我们只需要用torch.optim.Adam(model.trainable_vars, learning_rate)
即可获取一个Adam优化器。构造优化器时要输入待优化对象,我们已经提前存好了。
接下来,我们看for s in range(step):
里每一步更新参数的过程。
在PyTorch里,和可学习参数相关的计算所构成的计算图会被动态地构造出来。我们只要普通地写正向传播代码,求误差即可。
执行完cost = model.loss(Y, Y_hat)
,整个计算图就已经构造完成了。我们调用optimizer.zero_grad()
清空优化器,用cost.backward()
自动完成反向传播并记录梯度,之后用optimizer.step()
完成一步梯度下降。
可以看出,相比完全用NumPy实现,PyTorch用起来十分方便。只要我们用心定义好了前向传播函数和损失函数,维护梯度和优化参数都可以交给编程框架来完成。
实验:
train_X, train_Y = generate_points(400)
plot_points(train_X, train_Y)
plot_X = generate_plot_set()
n_x = train_X.shape[0]
neuron_cnt = [n_x, 10, 10, 3]
model = MulticlassClassficationNet(neuron_cnt)
train_X_pt = torch.tensor(train_X, dtype=torch.float32)
train_Y_pt = torch.tensor(train_Y.squeeze(0), dtype=torch.long)
print(train_X_pt.shape)
print(train_Y_pt.shape)
train(model, train_X_pt, train_Y_pt, 20000, 0.01, 1000)
plot_result = model.forward(torch.Tensor(plot_X))
plot_result = torch.argmax(plot_result, 0).numpy()
plot_result = np.expand_dims(plot_result, 0)
visualize(train_X, train_Y, plot_result)
网络训练完成后,我们用下面的代码把网络推理结果转换成可视化要用的NumPy结果:
plot_result = model.forward(torch.Tensor(plot_X))
plot_result = torch.argmax(plot_result, 0).numpy()
plot_result = np.expand_dims(plot_result, 0)
运行完plot_result = model.forward(torch.Tensor(plot_X))
后,我们得到的是一个[3, m]
的概率矩阵。我们要用torch.argmax(plot_result, 0)
把它转换回整型标签。
def visualize(X, Y, plot_set_result: np.ndarray):
x = np.linspace(0, 1, 100)
y = np.linspace(0, 1, 100)
xx, yy = np.meshgrid(x, y)
color = plot_set_result.squeeze()
color_map_1 = np.array(['r', 'g', 'b'])
color_map_2 = ['#AA0000', '#00AA00', '#0000AA']
c = color_map_1[color]
plt.scatter(xx, yy, c=c, marker='s')
plt.xlim(0, 1)
plt.ylim(0, 1)
origin_x = X.T[:, 0]
origin_y = X.T[:, 1]
origin_color = Y.squeeze(0)
origin_color = [color_map_2[oc] for oc in origin_color]
plt.scatter(origin_x, origin_y, c=origin_color)
plt.show()