前言：手写数字识别如果说波士顿房价预测是机器学习中的hello world,，那么手写数字识别就是深度学习中的hello world。在处理如下图所示的手写邮政编码的简单图像分类任务时，可以使用基于MNIST数据集的手写数字识别模型。MNIST是深度学习领域标准、易用的成熟数据集，包含60000条训练样本和10000条测试样本。 任务输入：一系列手写数字图片，其中每张图片都是28x28的像素矩阵。任务输出：经过了大小归一化和居中处理，输出对应的0~9的数字标签。本次任务使用的网络模型为卷积神经网络（CNN），它被广泛应用于图像识别，自然语言等领域。该任务同样遵循五步走战略，即数据处理—模型设计—训练配置—训练过程—模型保存 极简版本的实现代码import paddle import paddle.fluid as fluid from paddle.fluid.dygraph.nn import Linear import numpy as np import os from PIL import Image class MNIST(fluid.dygraph.Layer): def __init__(self): super(MNIST,self).__init__() # 定义一层全连接层，输出维度1，激活函数None self.fc = Linear(input_dim = 784,output_dim=1) # 前向计算 def forward(self,inputs): outputs = self.fc(inputs) return outputs # 创建动态图工作环境 with fluid.dygraph.guard(): # 声明网络结构 model = MNIST() # 启动训练模式 model.train() # 定义读取函数 train_loader = paddle.batch(paddle.dataset.mnist.train(),batch_size=16) # 定义优化器，使用SGD，学习率设置为0.001 optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.001,parameter_list=model.parameters()) epoch_num = 10 # 10次大循环 for epoch in range(epoch_num): for batch_id,data in enumerate(train_loader()): # 数据格式转换 img_data = np.array([x[0] for x in data]).astype("float32") label_data = np.array([x[1] for x in data]).astype("float32").reshape(-1,1) # 为了loss计算，最后一维要是1。-1是占位符，这个位置是为了batch_size预留的 # 将数据转为飞桨动态图格式 img = fluid.dygraph.to_variable(img_data) label = fluid.dygraph.to_variable(label_data) # 前向计算 predict = model.forward(img) # 计算损失 loss = fluid.layers.square_error_cost(predict,label) avg_loss = fluid.layers.mean(loss) # 每训练1000次数据就打印一次 if batch_id != 0 and batch_id % 1000 == 0: print("epoch is {},batch is {}, loss is {}".format(epoch,batch_id,avg_loss.numpy())) # 后向传播 avg_loss.backward() # 更新参数 optimizer.minimize(avg_loss) # 最小化损失 model.clear_gradients() # 清除梯度 # 保存模型 fluid.save_dygraph(model.state_dict(), 'mnist')说明：飞桨提供了多个封装好的数据集API，涵盖计算机视觉、自然语言处理、推荐系统等多个领域，帮助读者快速完成深度学习任务。如在手写数字识别任务中，通过paddle.dataset.mnist可以直接获取处理好的MNIST训练集、测试集。飞桨将维度是28*28的手写数字图像转成向量形式存储，因此使用飞桨数据加载器读取到的手写数字图像是长度为784（28*28）的向量。paddle.batch函数将MNIST数据集拆分成多个批次，并生成一个可迭代对象，通过enumerate()序列解包后才能调用。需要注意的是batch()是正序读取的，并没有乱序，这里的模型也没有乱序。输入像素的位置排布信息对理解图像内容非常重要（如将原始尺寸为28*28图像的像素按照7*112的尺寸排布，那么其中的数字将不可识别），因此网络的输入设计为28*28的尺寸，而不是1*784，以便于模型能够正确处理像素之间的空间信息。事实上，采用只有一层的简单网络（对输入求加权和）时并没有处理位置关系信息，,还是输入1*784，因此可以猜测出此模型的预测效果有限。在后续优化环节中，介绍的卷积神经网络则更好的考虑了这种位置关系信息，模型的预测效果也会显著提升。从损失看，效果并不好。模型测试模型测试的主要目的是验证训练好的模型是否能正确识别出数字，包括如下四步：声明实例加载模型：加载训练过程中保存的模型参数。灌入数据：将测试样本传入模型，模型的状态设置为校验状态（eval），显式告诉框架我们接下来只会使用前向计算的流程，不会计算梯度和梯度反向传播。获取预测结果，取整后作为预测标签输出。在模型测试之前，需要先从./work/example_0.jpg文件中读取样例图片，并进行归一化处理。def load_img(pic_path): # 加载图片 im = Image.open(pic_path).convert('L') # 转化为灰度图像 im = im.resize((28,28),Image.ANTIALIAS) im = np.array(im).reshape(1,-1).astype(np.float32) # 图像归一化，保持和数据集的数据范围一致 im = 1 - im / 127.5 return im # 预测 with fluid.dygraph.guard(): model = MNIST() # 读取模型参数 model_dict,_ = fluid.load_dygraph("./mnist/mnist") # 载入模型参数 model.load_dict(model_dict) # 模型调为预测模式 model.eval() # 读取图片数据 img = load_img('./mnist/example_0.png') result = model.forward((fluid.dygraph.to_variable(img))) # 打印结果 print("本次预测的数字是", result.numpy().astype('int32'))结果是4，实际是0模型优化：数据处理上一节模型测试效果并未达成预期。我们换个思路，从横向展开，如下图所示，逐个环节优化，以达到最优训练效果。 上一节，我们通过调用飞桨提供的API（paddle.dataset.mnist）加载MNIST数据集。但在工业实践中，我们面临的任务和数据环境千差万别，通常需要自己编写适合当前任务的数据处理程序，一般涉及如下五个环节：读入数据划分数据集生成批次数据训练样本集乱序校验数据有效性其中，数据格式如下：MNIST数据集以json格式存储在本地，其数据存储结构如下图所示。 data包含三个元素的列表：train_set、val_set、 test_set，包括50000条训练样本，10000条验证样本，10000条测试样本，共70000条数据。每个样本包含手写数字图片和对应的标签。train_set（训练集）：用于确定模型参数。val_set（验证集）：用于调节模型超参数（如多个网络结构、正则化权重的最优选择），另外还有多模型的情况下，哪个更有效也是通过这个集合判断的。。test_set（测试集）：用于估计应用效果（没有在模型中应用过的数据，更贴近模型在真实场景应用的效果）。超参数是在开始学习过程之前设置值的参数，而不是通过训练得到的参数数据。通常情况下，需要对超参数进行优化，给学习机选择一组最优超参数，以提高学习的性能和效果。为什么要分验证集和测试集呢？因为很多paper里所谓的测试集其实就是验证集，也就是说他们的模型只在“测试集”上表现好，加入新的数据就拉垮了，这里的“测试集”只是帮我们选择哪一些paper的模型比较好，却无法真正应用到工业实践中。因此我们要把验证集合测试集完全分开，以真正确保实践中的效果。train_set包含两个元素的列表：train_images、train_labels。train_images：[50000, 784]的二维列表，包含50000张图片。每张图片用一个长度为784的向量表示，内容是28*28尺寸的像素灰度值（黑白图片）。train_labels：[50000, ]的列表，表示这些图片对应的分类标签，即0-9之间的一个数字。完整代码如下，仍然分为三部分：读取数据（定义了一个函数），声明网络结构，启动训练。import paddle import paddle.fluid as fluid from paddle.fluid.dygraph.nn import Linear import numpy as np import os import gzip import json import random def load_data(mode="train"): # 从本地加载数据 data = json.load(gzip.open('./mnist/mnist.json.gz')) train_set,val_set,test_set = data if mode == "train": imgs,labels = train_set[0],train_set[1] elif mode == "val": imgs,labels = val_set[0],val_set[1] elif mode == "test": imgs,labels = test_set[0],test_set[1] else: raise Exception("mode参数只能为['train', 'valid', 'eval']之一") print("{0}数据集数量: {1}".format(mode,len(imgs))) # 数据集校验 assert len(imgs) == len(labels),"图像数量（{0}）必须等于标签数量（{1}）".format(en(imgs),len(labels)) # 数据集相关参数 img_rows,img_cols = 28,28 batch_size = 100 # 生成数据索引 index_list = list(range(len(imgs))) # 数据生成器 def data_generator(): # 相当于paddle中的batch # 只有训练模式下才需乱序 if mode == "train": # 索引乱序 random.shuffle(index_list) imgs_list = [] labels_list = [] for index in index_list: # 将数据处理成期望的格式，比如类型为float32，shape为[1, 28, 28] # 对于线性回归模型来说，1*28*28中的“1”并无必要，因为该模型对于尺寸并不敏感。 # 我们这里将其处理为带尺寸的格式，是因为后面有些模型，比如CNN能够很好地处理这种位置信息。 img = np.reshape(imgs[index], [1, img_rows, img_cols]).astype('float32') label = np.reshape(labels[index], [1]).astype('float32') imgs_list.append(img) labels_list.append(label) if len(imgs_list) == batch_size: yield np.array(imgs_list),np.array(labels_list) imgs_list = [] labels_list = [] # 如果剩余的数量少于batchsize，那么就塞到一起 if len(imgs_list) > 0: yield np.array(imgs_list),np.array(labels_list) return data_generator class MNIST(fluid.dygraph.Layer): def __init__(self): super(MNIST,self).__init__() # 定义一层全连接层，输出维度1，激活函数None self.fc = Linear(input_dim = 784,output_dim=1) # 前向计算 def forward(self,inputs): inputs = fluid.layers.reshape(inputs, (-1, 784)) # 这里的-1可以换成inputs.shape[0] outputs = self.fc(inputs) return outputs # 创建动态图工作环境 place = fluid.CPUPlace() # 指定训练硬件 with fluid.dygraph.guard(place): # 声明网络结构 model = MNIST() # 启动训练模式 model.train() # 定义读取函数 train_loader = load_data('train') ''' # 异步读取数据 # 声明一个DataLoader对象,从生成器中读取数据。capaacity是仓库大小， data_reader = fluid.io.DataLoader.from_generator(capacity=5,return_list=True) data_reader.set_batch_generator(train_loader,places=place) ''' # 定义优化器，使用SGD，学习率设置为0.001 optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.001,parameter_list=model.parameters()) epoch_num = 10 # 10次大循环 for epoch in range(epoch_num): for batch_id,data in enumerate(train_loader()): # 若采用异步读取，则将train_loader()替换为data_reader img_data,label_data = data # 将数据转为飞桨动态图格式 img = fluid.dygraph.to_variable(img_data) label = fluid.dygraph.to_variable(label_data) # 前向计算 predict = model(img) # 计算损失 loss = fluid.layers.square_error_cost(predict,label) avg_loss = fluid.layers.mean(loss) # 每训练200次数据就打印一次 if batch_id != 0 and batch_id % 200 == 0: print("epoch is {},batch is {}, loss is {}".format(epoch,batch_id,avg_loss.numpy())) # 后向传播 avg_loss.backward() # 更新参数 optimizer.minimize(avg_loss) # 最小化损失 model.clear_gradients() # 清除梯度 # 保存模型 fluid.save_dygraph(model.state_dict(), 'mnist')和极简版本相比，主要是我们自己实现了读取数据，并且实现了数据的乱序。然后在数据处理中我们加入了校验，判断image和label的数量是否一致。另外代码中加入了异步读取数据的注释，异步读取数据时，数据读取和模型训练并行执行，从而加快了数据读取速度，牺牲一小部分内存换取数据读取效率的提升。同步数据读取：数据读取与模型训练串行。当模型需要数据时，才运行数据读取函数获得当前批次的数据。在读取数据期间，模型一直等待数据读取结束才进行训练，数据读取速度相对较慢。异步数据读取：数据读取和模型训练并行。读取到的数据不断的放入缓存区，无需等待模型训练就可以启动下一轮数据读取。当模型训练完一个批次后，不用等待数据读取过程，直接从缓存区获得下一批次数据进行训练，从而加快了数据读取速度。异步队列：数据读取和模型训练交互的仓库，二者均可以从仓库中读取数据，它的存在使得两者的工作节奏可以解耦。实现函数为fluid.io.DataLoader.from_generator，其参数名称和含义如下：feed_list：仅在PaddlePaddle静态图中使用，动态图中设置为“None”，本教程默认使用动态图的建模方式；capacity：表示在DataLoader中维护的队列容量，如果读取数据的速度很快，建议设置为更大的值；use\_double\_buffer：是一个布尔型的参数，设置为“True”时，Dataloader会预先异步读取下一个batch的数据并放到缓存区；iterable：表示创建的Dataloader对象是否是可迭代的，一般设置为“True”；return_list：在动态图模式下需要设置为“True”。模型优化：网络结构本节我们介绍介绍两种常见的网络结构：经典的多层全连接神经网络和卷积神经网络。经典的全连接神经网络经典的全连接神经网络来包含四层网络：输入层、两个隐含层和输出层，将手写数字识别任务通过全连接神经网络表示，如下图所示。 输入层：将数据输入给神经网络。在该任务中，输入层的尺度为28×28的像素值。隐含层：增加网络深度和复杂度，隐含层的节点数是可以调整的，节点数越多，神经网络表示能力越强，参数量也会增加。在该任务中，中间的两个隐含层为10×10的结构，通常隐含层会比输入层的尺寸小，以便对关键信息做抽象，激活函数使用常见的sigmoid函数。输出层：输出网络计算结果，输出层的节点数是固定的。如果是回归问题，节点数量为需要回归的数字数量；如果是分类问题，则是分类标签的数量。在该任务中，模型的输出是回归一个数字，输出层的尺寸为1。说明：隐含层引入非线性激活函数sigmoid是为了增加神经网络的非线性能力。举例来说，如果一个神经网络采用线性变换，有四个输入$x_1$~$x_4$，一个输出$y$。假设第一层的变换是$z_1=x_1-x_2$和$z_2=x_3+x_4$，第二层的变换是$y=z_1+z_2$，则将两层的变换展开后得到$y=x_1-x_2+x_3+x_4$。也就是说，无论中间累积了多少层线性变换，原始输入和最终输出之间依然是线性关系。Sigmoid是早期神经网络模型中常见的非线性变换函数，通过如下代码，绘制出Sigmoid的函数曲线。def sigmoid(x): # 直接返回sigmoid函数 return 1. / (1. + np.exp(-x)) # param:起点，终点，间距 x = np.arange(-8, 8, 0.2) y = sigmoid(x) plt.plot(x, y) plt.show() 针对手写数字识别的任务，网络层的设计如下：输入层的尺度为28×28，但批次计算的时候会统一加1个维度（大小为batchsize）。中间的两个隐含层为10×10的结构，激活函数使用常见的sigmoid函数。与房价预测模型一样，模型的输出是回归一个数字，输出层的尺寸设置成1。下述代码为经典全连接神经网络的实现。完成网络结构定义后，即可训练神经网络。class MNIST(fluid.dygraph.Layer): def __init__(self): super(MNIST,self).__init__() # 定义两层全连接隐含层，输出维度是10，激活函数为sigmoid self.fc1 = Linear(input_dim=784,output_dim=10,act="sigmoid") self.fc2 = Linear(input_dim= 10,output_dim=10,act="sigmoid") # 定义一层全连接输出层，输出维度是1，不使用激活函数 self.fc3 = Linear(input_dim= 10,output_dim=1) # 前向计算 def forward(self,inputs,label=None): inputs = fluid.layers.reshape(inputs,[inputs.shape[0],784]) outputs1 = self.fc1(inputs) outputs2 = self.fc2(outputs1) output_fin = self.fc3(outputs2) return output_fin数据处理与训练过程模型优化：数据处理一节一样。卷积神经网络虽然使用经典的全连接神经网络可以提升一定的准确率，但对于计算机视觉问题，效果最好的模型仍然是卷积神经网络。卷积神经网络针对视觉问题的特点进行了网络结构优化，更适合处理视觉问题。卷积神经网络由多个卷积层和池化层组成，如下图所示。卷积层负责对输入进行扫描以生成更抽象的特征表示，池化层对这些特征表示进行过滤，保留最关键的特征信息。 说明：本节只简单介绍用卷积神经网络实现手写数字识别任务，以及它带来的效果提升。读者可以先将卷积神经网络简单的理解成是一种比经典的全连接神经网络更强大的模型即可，更详细的原理和实现在后面的计算机视觉章节中会详细讲述。两层卷积和池化的卷积神经网络实现如下所示。from paddle.fluid.dygraph.nn import Conv2D, Pool2D, Linear # 多层卷积神经网络实现 class MNIST(fluid.dygraph.Layer): def __init__(self): super(MNIST, self).__init__() # 定义卷积层，输出特征通道num_filters设置为20，卷积核的大小filter_size为5，卷积步长stride=1，padding=2 # 激活函数使用relu self.conv1 = Conv2D(num_channels=1, num_filters=20, filter_size=5, stride=1, padding=2, act='relu') # 定义池化层，池化核pool_size=2，池化步长为2，选择最大池化方式 self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max') # 定义卷积层，输出特征通道num_filters设置为20，卷积核的大小filter_size为5，卷积步长stride=1，padding=2 self.conv2 = Conv2D(num_channels=20, num_filters=20, filter_size=5, stride=1, padding=2, act='relu') # 定义池化层，池化核pool_size=2，池化步长为2，选择最大池化方式 self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max') # 定义一层全连接层，输出维度是1，不使用激活函数 self.fc = Linear(input_dim=980, output_dim=1, act=None) # 定义网络前向计算过程，卷积后紧接着使用池化层，最后使用全连接层计算最终输出 def forward(self, inputs): x = self.conv1(inputs) x = self.pool1(x) x = self.conv2(x) x = self.pool2(x) x = fluid.layers.reshape(x, [x.shape[0], -1]) x = self.fc(x) return x其他部分代码仍然保持不变模型优化：损失函数损失函数是模型优化的目标，用于在众多的参数取值中，识别最理想的取值。损失函数的计算在训练过程的代码中，每一轮模型训练的过程都相同，分如下三步：先根据输入数据正向计算预测输出。再根据预测值和真实值计算损失。最后根据损失反向传播梯度并更新参数。分类任务的损失函数在之前的方案中，我们复用了房价预测模型的损失函数-均方误差。从预测效果来看，虽然损失不断下降，模型的预测值逐渐逼近真实值，但模型的最终效果不够理想。究其根本，不同的深度学习任务需要有各自适宜的损失函数。我们以房价预测和手写数字识别两个任务为例，详细剖析其中的缘由如下：房价预测是回归任务，而手写数字识别是分类任务，使用均方误差作为分类任务的损失函数存在逻辑和效果上的缺欠。房价可以是大于0的任何浮点数，而手写数字识别的输出只可能是0-9之间的10个整数，相当于一种标签。在房价预测的案例中，由于房价本身是一个连续的实数值，因此以模型输出的数值和真实房价差距作为损失函数（loss）是符合道理的。但对于分类问题，真实结果是分类标签，而模型输出是实数值，导致以两者相减作为损失不具备物理含义。那么，什么是分类任务的合理输出呢？分类任务本质上是“某种特征组合下的分类概率”。简单举个例子，肿瘤越大（特征），是恶性（标签）的概率就越大。Softmax函数如果模型能输出10个标签的概率，对应真实标签的概率输出尽可能接近100%，而其他标签的概率输出尽可能接近0%，且所有输出概率之和为1。这是一种更合理的假设！与此对应，真实的标签值可以转变成一个10维度的one-hot向量，在对应数字的位置上为1，其余位置为0，比如标签“6”可以转变成[0,0,0,0,0,0,1,0,0,0]。为了实现上述思路，需要引入Softmax函数，它可以将原始输出转变成对应标签的概率，公式如下，其中$C$是标签类别个数。$$ softmax(x_i) = \frac {e^{x_i}}{\sum_{j=0}^N{e^x_j}}, i=0, ..., C-1$$ 从公式的形式可见，每个输出的范围均在0~1之间，且所有输出之和等于1，这是变换后可被解释成概率的基本前提。对应到代码上，我们需要在网络定义部分修改输出层：`self.fc = Linear(input_dim=10, output_dim=1, act='softmax')`，即是对全连接层的输出加一个softmax运算。 **图3** 是一个三个标签的分类模型（三分类）使用的softmax输出层，从中可见原始输出的三个数字3、1、-3，经过softmax层后转变成加和为1的三个概率值0.88、0.12、0。 ![](https://i.yanshu.work/image/5f38d9a5ac336.jpg) 上文解释了为何让分类模型的输出拟合概率的原因，但为何偏偏用softmax函数完成这个职能？ 主要是看中了softmax函数的性质。仍然以肿瘤为例，肿瘤大于某一值，或者小于某一值的时候，肿瘤大小就不会太影响判断其恶性的概率了。也就是说，我们更关注中间大小的值，在中间这个区间上，稍微变化一点，肿瘤是恶性（良性）的概率就会变化很大。如下图。 ![](https://i.yanshu.work/image/5f38d9aad6e1c.jpg) 如果我们用直线拟合，那相当于一个均匀分布，并不符合实际情况。 #### 交叉熵 在模型输出为分类标签的概率时，直接以标签和概率做比较也不够合理，人们更习惯使用交叉熵误差作为分类问题的损失衡量。 交叉熵损失函数的设计是基于最大似然思想：**最大概率得到观察结果的假设是真的**。如何理解呢？举个例子来说，如 **图7** 所示。有两个外形相同的盒子，甲盒中有99个白球，1个蓝球；乙盒中有99个蓝球，1个白球。一次试验取出了一个蓝球，请问这个球应该是从哪个盒子中取出的？ ![](https://i.yanshu.work/image/5f38d9aec62b5.jpg) 相信大家简单思考后均会得出更可能是从乙盒中取出的，因为从乙盒中取出一个蓝球的概率更高$(P(D|h))$，所以观察到一个蓝球更可能是从乙盒中取出的$(P(h|D))$。$D$是观测的数据，即蓝球白球；$h$是模型，即甲盒乙盒。这就是贝叶斯公式所表达的思想： $$P(h|D) ∝ P(h) \cdot P(D|h) $$依据贝叶斯公式，某二分类模型“生成”$n$个训练样本的概率：$$ P(x_1)\cdot S(w^{T}x_1)\cdot P(x_2)\cdot(1-S(w^{T}x_2))\cdot … \cdot P(x_n)\cdot S(w^{T}x_n) $$说明：对于二分类问题，模型为$S(w^{T}x_i)$，$S$为Sigmoid函数。当$y_i$=1，概率为$S(w^{T}x_i)$；当$y_i$=0，概率为$1-S(w^{T}x_i)$。经过公式推导，使得上述概率最大等价于最小化交叉熵，得到交叉熵的损失函数。交叉熵的公式如下：$$ L = -[\sum_{k=1}^{n} t_k\log y_k +(1- t_k)\log(1-y_k)] $$其中，$\log$表示以$e$为底数的自然对数。$y_k$代表模型输出，$t_k$代表各个标签。$t_k$中只有正确解的标签为1，其余均为0（one-hot表示）。因此，交叉熵只计算对应着“正确解”标签的输出的自然对数。比如，假设正确标签的索引是“2”，与之对应的神经网络的输出是0.6，则交叉熵误差是$−\log 0.6 = 0.51$；若“2”对应的输出是0.1，则交叉熵误差为$−\log 0.1 = 2.30$。由此可见，交叉熵误差的值是由正确标签所对应的输出结果决定的。自然对数的函数曲线可由如下代码实现。import matplotlib.pyplot as plt import numpy as np x = np.arange(0.01,1,0.01) y = np.log(x) plt.title("y=log(x)") plt.xlabel("x") plt.ylabel("y") plt.plot(x,y) plt.show() plt.figure() 如自然对数的图形所示，当$x$等于1时，$y$为0；随着$x$向0靠近，$y$逐渐变小。因此，“正确解”标签对应的输出越大，交叉熵的值越接近0；当输出为1时，交叉熵误差为0。反之，如果“正确解”标签对应的输出越小，则交叉熵的值越大。交叉熵的代码实现在手写数字识别任务中，仅改动三行代码，就可以将在现有模型的损失函数替换成交叉熵（cross_entropy）。在读取数据部分，将标签的类型设置成int，体现它是一个标签而不是实数值（飞桨默认将标签处理成“int64”）。在网络定义部分，将输出层改成“输出十个标签的概率”的模式。在训练过程部分，将损失函数从均方误差换成交叉熵。在数据处理部分，需要修改标签变量Label的格式，代码如下所示。从：label = np.reshape(labels[i], [1]).astype('float32')到：label = np.reshape(labels[i], [1]).astype('int64')在网络定义部分，需要修改输出层结构，代码如下所示。从：self.fc = Linear(input_dim=980, output_dim=1, act=None)到：self.fc = Linear(input_dim=980, output_dim=10, act='softmax')修改计算损失的函数，从均方误差（常用于回归问题）到交叉熵误差（常用于分类问题），代码如下所示。从：loss = fluid.layers.square_error_cost(predict, label)到：loss = fluid.layers.cross_entropy(predict, label)虽然上述训练过程的损失明显比使用均方误差算法要小，但因为损失函数量纲的变化，我们无法从比较两个不同的Loss得出谁更加优秀。怎么解决这个问题呢？我们可以回归到问题的本质，谁的分类准确率更高来判断。在后面介绍完计算准确率和作图的内容后，读者可以自行测试采用不同损失函数下，模型准确率的高低。至此，大家阅读论文中常见的一些分类任务模型图就清晰明了，如全连接神经网络、卷积神经网络，在模型的最后阶段，都是使用Softmax进行处理。 由于我们修改了模型的输出格式，因此使用模型做预测时的代码也需要做相应的调整。从模型输出10个标签的概率中选择最大的，将其标签编号输出。# 读取一张本地的样例图片，转变成模型输入的格式 def load_image(img_path): # 从img_path中读取图像，并转为灰度图 im = Image.open(img_path).convert('L') im.show() im = im.resize((28, 28), Image.ANTIALIAS) im = np.array(im).reshape(1, 1, 28, 28).astype(np.float32) # 图像归一化 im = 1.0 - im / 255. return im # 定义预测过程 with fluid.dygraph.guard(): model = MNIST() params_file_path = 'mnist' img_path = './work/example_0.jpg' # 加载模型参数 model_dict, _ = fluid.load_dygraph("mnist") model.load_dict(model_dict) model.eval() tensor_img = load_image(img_path) #模型反馈10个分类标签的对应概率 results = model(fluid.dygraph.to_variable(tensor_img)) #取概率最大的标签作为预测输出 lab = np.argsort(results.numpy()) print("本次预测的数字是: ", lab[0][-1])模型优化：算法优化设置学习率在深度学习神经网络模型中，通常使用标准的随机梯度下降算法更新参数，学习率代表参数更新幅度的大小，即步长。当学习率最优时，模型的有效容量最大，最终能达到的效果最好。学习率和深度学习任务类型有关，合适的学习率往往需要大量的实验和调参经验。探索学习率最优值时需要注意如下两点：学习率不是越小越好。学习率越小，损失函数的变化速度越慢，意味着我们需要花费更长的时间进行收敛，如 图2 左图所示。学习率不是越大越好。只根据总样本集中的一个批次计算梯度，抽样误差会导致计算出的梯度不是全局最优的方向，且存在波动。在接近最优解时，过大的学习率会导致参数在最优解附近震荡，损失难以收敛，如 图2 右图所示。 在训练前，我们往往不清楚一个特定问题设置成怎样的学习率是合理的，因此在训练时可以尝试调小或调大，通过观察Loss下降的情况判断合理的学习率，改下fluid.optimizer.SGDOptimizer的learning_rate=参数即可。parameter_list是需要优化的参数，没有在此列表的参数是不会适用于该学习率的。学习率的主流优化算法学习率是优化器的一个参数，调整学习率看似是一件非常麻烦的事情，需要不断的调整步长，观察训练时间和Loss的变化。经过研究员的不断的实验，当前已经形成了四种比较成熟的优化算法：SGD、Momentum、AdaGrad和Adam，效果如 图3 所示。 SGD： 随机梯度下降算法，每次训练少量数据，抽样偏差导致参数收敛过程中震荡。Momentum： 引入物理“动量”的概念，累积速度，减少震荡，使参数更新的方向更稳定。每个批次的数据含有抽样误差，导致梯度更新的方向波动较大。如果我们引入物理动量的概念，给梯度下降的过程加入一定的“惯性”累积，就可以减少更新路径上的震荡，即每次更新的梯度由“历史多次梯度的累积方向”和“当次梯度”加权相加得到。历史多次梯度的累积方向往往是从全局视角更正确的方向，这与“惯性”的物理概念很像，也是为何其起名为“Momentum”的原因。类似不同品牌和材质的篮球有一定的重量差别，街头篮球队中的投手（擅长中远距离投篮）喜欢稍重篮球的比例较高。一个很重要的原因是，重的篮球惯性大，更不容易受到手势的小幅变形或风吹的影响。AdaGrad： 根据不同参数距离最优解的远近，动态调整学习率。学习率逐渐下降，依据各参数变化大小调整学习率。通过调整学习率的实验可以发现：当某个参数的现值距离最优解较远时（表现为梯度的绝对值较大），我们期望参数更新的步长大一些，以便更快收敛到最优解。当某个参数的现值距离最优解较近时（表现为梯度的绝对值较小），我们期望参数的更新步长小一些，以便更精细的逼近最优解。类似于打高尔夫球，专业运动员第一杆开球时，通常会大力打一个远球，让球尽量落在洞口附近。当第二杆面对离洞口较近的球时，他会更轻柔而细致的推杆，避免将球打飞。与此类似，参数更新的步长应该随着优化过程逐渐减少，减少的程度与当前梯度的大小有关。根据这个思想编写的优化算法称为“AdaGrad”，Ada是Adaptive的缩写，表示“适应环境而变化”的意思。RMSProp是在AdaGrad基础上的改进，AdaGrad会累加之前所有的梯度平方，而RMSprop仅仅是计算对应的梯度平均值，因而可以解决AdaGrad学习率急剧下降的问题。Adam： 由于动量和自适应学习率两个优化思路是正交的，因此可以将两个思路结合起来，这就是当前广泛应用的算法。说明：每种优化算法均有更多的参数设置，详情可查阅飞桨的官方API文档。理论最合理的未必在具体案例中最有效，所以模型调参是很有必要的，最优的模型配置往往是在一定“理论”和“经验”的指导下实验出来的。我们可以尝试选择不同的优化算法训练模型，观察训练时间和损失变化的情况，代码实现如下。#四种优化算法的设置方案，可以逐一尝试效果 optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.001,parameter_list=model.parameters()) #optimizer = fluid.optimizer.AdagradOptimizer(learning_rate=0.01, parameter_list=model.parameters()) #optimizer = fluid.optimizer.AdamOptimizer(learning_rate=0.01, parameter_list=model.parameters())模型优化：资源配置单GPU训练飞桨动态图通过fluid.dygraph.guard(place=None)里的place参数，设置在GPU上训练还是CPU上训练。with fluid.dygraph.guard(place=fluid.CPUPlace()) #设置使用CPU资源训神经网络。 with fluid.dygraph.guard(place=fluid.CUDAPlace(0)) #设置使用GPU资源训神经网络，默认使用服务器的第一个GPU卡。"0"是GPU卡的编号，比如一台服务器有的四个GPU卡，编号分别为０、１、２、３。#仅前3行代码有所变化，在使用GPU时，可以将use_gpu变量设置成True use_gpu = False place = fluid.CUDAPlace(0) if use_gpu else fluid.CPUPlace() with fluid.dygraph.guard(place): ……分布式训练在工业实践中，很多较复杂的任务需要使用更强大的模型。强大模型加上海量的训练数据，经常导致模型训练耗时严重。比如在计算机视觉分类任务中，训练一个在ImageNet数据集上精度表现良好的模型，大概需要一周的时间，因为过程中我们需要不断尝试各种优化的思路和方案。如果每次训练均要耗时1周，这会大大降低模型迭代的速度。在机器资源充沛的情况下，建议采用分布式训练，大部分模型的训练时间可压缩到小时级别。分布式训练有两种实现模式：模型并行和数据并行。模型并行模型并行是将一个网络模型拆分为多份，拆分后的模型分到多个设备上（GPU）训练，每个设备的训练数据是相同的。模型并行的实现模式可以节省内存，但是应用较为受限。模型并行的方式一般适用于如下两个场景：模型架构过大： 完整的模型无法放入单个GPU。如2012年ImageNet大赛的冠军模型AlexNet是模型并行的典型案例，由于当时GPU内存较小，单个GPU不足以承担AlexNet，因此研究者将AlexNet拆分为两部分放到两个GPU上并行训练。网络模型的结构设计相对独立： 当网络模型的设计结构可以并行化时，采用模型并行的方式。如在计算机视觉目标检测任务中，一些模型（如YOLO9000）的边界框回归和类别预测是独立的，可以将独立的部分放到不同的设备节点上完成分布式训练。数据并行数据并行与模型并行不同，数据并行每次读取多份数据，读取到的数据输入给多个设备（GPU）上的模型，每个设备上的模型是完全相同的，飞桨采用的就是这种方式。说明：当前GPU硬件技术快速发展，深度学习使用的主流GPU的内存已经足以满足大多数的网络模型需求，所以大多数情况下使用数据并行的方式。每个设备的模型是完全相同的，但是输入数据不同，因此每个设备的模型计算出的梯度是不同的。如果每个设备的梯度只更新当前设备的模型，就会导致下次训练时，每个模型的参数都不相同。因此我们还需要一个梯度同步机制，保证每个设备的梯度是完全相同的。梯度同步有两种方式：PRC通信方式和NCCL2通信方式（Nvidia Collective multi-GPU Communication Library）。PRC通信方式PRC通信方式通常用于CPU分布式训练，它有两个节点：参数服务器Parameter server和训练节点Trainer，结构如下图所示。 parameter server收集来自每个设备的梯度更新信息，并计算出一个全局的梯度更新。Trainer用于训练，每个Trainer上的程序相同，但数据不同。当Parameter server收到来自Trainer的梯度更新请求时，统一更新模型的梯度。NCCL2通信方式（Collective）当前飞桨的GPU分布式训练使用的是基于NCCL2的通信方式，结构如下图所示。 相比PRC通信方式，使用NCCL2（Collective通信方式）进行分布式训练，不需要启动Parameter server进程，每个Trainer进程保存一份完整的模型参数，在完成梯度计算之后通过Trainer之间的相互通信，Reduce梯度数据到所有节点的所有设备，然后每个节点再各自完成参数更新。飞桨提供了便利的数据并行训练方式，用户只需要对程序进行简单修改，即可实现在多GPU上并行训练。接下来讲述如何将一个单机程序通过简单的改造，变成多机多卡程序。说明：AI Studio当前仅支持单卡GPU，因此本案例需要在本地GPU上执行，无法在AI Studio上演示。我笔电也没有GPU，所以代码也没有验证。在启动训练前，需要配置如下参数：从环境变量获取设备的ID，并指定给CUDAPlace。device_id = fluid.dygraph.parallel.Env().dev_id place = fluid.CUDAPlace(device_id)对定义的网络做预处理，设置为并行模式。strategy = fluid.dygraph.parallel.prepare_context() ## 新增 model = MNIST() model = fluid.dygraph.parallel.DataParallel(model, strategy) ## 新增定义多GPU训练的reader，不同ID的GPU加载不同的数据集。valid_loader = paddle.batch(paddle.dataset.mnist.test(), batch_size=16, drop_last=true) valid_loader = fluid.contrib.reader.distributed_batch_reader(valid_loader)收集每批次训练数据的loss，并聚合参数的梯度。avg_loss = model.scale_loss(avg_loss) ## 新增 avg_loss.backward() mnist.apply_collective_grads() ## 新增完整程序如下所示。def train_multi_gpu(): ##修改1-从环境变量获取使用GPU的序号 place = fluid.CUDAPlace(fluid.dygraph.parallel.Env().dev_id) with fluid.dygraph.guard(place): ##修改2-对原模型做并行化预处理 strategy = fluid.dygraph.parallel.prepare_context() model = MNIST() model = fluid.dygraph.parallel.DataParallel(model, strategy) model.train() #调用加载数据的函数 train_loader = load_data('train') ##修改3-多GPU数据读取，必须确保每个进程读取的数据是不同的 train_loader = fluid.contrib.reader.distributed_batch_reader(train_loader) optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters()) EPOCH_NUM = 5 for epoch_id in range(EPOCH_NUM): for batch_id, data in enumerate(train_loader()): #准备数据 image_data, label_data = data image = fluid.dygraph.to_variable(image_data) label = fluid.dygraph.to_variable(label_data) predict = model(image) loss = fluid.layers.cross_entropy(predict, label) avg_loss = fluid.layers.mean(loss) # 修改4-多GPU训练需要对Loss做出调整，并聚合不同设备上的参数梯度 avg_loss = model.scale_loss(avg_loss) avg_loss.backward() model.apply_collective_grads() # 最小化损失函数，清除本次训练的梯度 optimizer.minimize(avg_loss) model.clear_gradients() if batch_id % 200 == 0: print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy())) #保存模型参数 fluid.save_dygraph(model.state_dict(), 'mnist')启动多GPU的训练，还需要在命令行中设置一些参数变量。打开终端，运行如下命令：python -m paddle.distributed.launch --selected_gpus=0,1,2,3 --log_dir ./mylog train_multi_gpu.pypaddle.distributed.launch：启动分布式运行。selected_gpus：设置使用的GPU的序号（需要是多GPU卡的机器，通过命令watch nvidia-smi查看GPU的序号）。log_dir：存放训练的log，若不设置，每个GPU上的训练信息都会打印到屏幕。train\_multi\_gpu.py：多GPU训练的程序，包含修改过的train\_multi\_gpu()函数。模型优化：训练过程优化训练过程优化思路主要有如下五个关键环节：1. 计算分类准确率，观测模型训练效果。交叉熵损失函数只能作为优化目标，无法直接准确衡量模型的训练效果。准确率可以直接衡量训练效果，但由于其离散性质，不适合做为损失函数优化神经网络。2. 检查模型训练过程，识别潜在问题。如果模型的损失或者评估指标表现异常，通常需要打印模型每一层的输入和输出来定位问题，分析每一层的内容来获取错误的原因。3. 加入校验或测试，更好评价模型效果。理想的模型训练结果是在训练集和验证集上均有较高的准确率，如果训练集上的准确率高于验证集，说明网络训练程度不够；如果验证集的准确率高于训练集，可能是发生了过拟合现象。通过在优化目标中加入正则化项的办法，解决过拟合的问题。4. 加入正则化项，避免模型过拟合。飞桨框架支持为整体参数加入正则化项，这是通常的做法。此外，飞桨框架也支持为某一层或某一部分的网络单独加入正则化项，以达到精细调整参数训练的效果。5. 可视化分析。用户不仅可以通过打印或使用matplotlib库作图，飞桨还提供了更专业的可视化分析工具VisualDL，提供便捷的可视化分析方法。计算模型的分类准确率准确率是一个直观衡量分类模型效果的指标，由于这个指标是离散的，因此不适合作为损失函数来优化。通常情况下，交叉熵损失越小的模型，分类的准确率也越高。基于分类准确率，我们可以公平的比较两种损失函数的优劣，例如【手写数字识别】之损失函数 章节中均方误差和交叉熵的比较。飞桨提供了计算分类准确率的API，使用fluid.layers.accuracy可以直接计算准确率，该API的输入参数input为预测的分类结果predict，输入参数label为数据真实的label。在下述代码中，我们在模型前向计算过程forward函数中计算分类准确率，并在训练时打印每个批次样本的分类准确率。（load_data()函数不变）# 多层卷积神经网络实现 class MNIST(fluid.dygraph.Layer): def __init__(self): …… # 定义网络前向计算过程，卷积后紧接着使用池化层，最后使用全连接层计算最终输出 def forward(self, inputs,label=None): x = self.conv1(inputs) x = self.pool1(x) x = self.conv2(x) x = self.pool2(x) x = fluid.layers.reshape(x, [x.shape[0], -1]) x = self.fc(x) if label is not None: acc = fluid.layers.accuracy(input=x, label=label) # x是预测值，label是真实值 return x, acc else: return x with fluid.dygraph.guard(place): …… EPOCH_NUM = 5 for epoch_id in range(EPOCH_NUM): for batch_id, data in enumerate(train_loader()): #准备数据 image_data, label_data = data image = fluid.dygraph.to_variable(image_data) label = fluid.dygraph.to_variable(label_data) #前向计算的过程，同时拿到模型输出值和分类准确率 predict, acc = model(image, label) ……检查模型训练过程，识别潜在训练问题使用飞桨动态图可以方便的查看和调试训练的执行过程。在网络定义的Forward函数中，可以打印每一层输入输出的尺寸，以及每层网络的参数。通过查看这些信息，不仅可以更好地理解训练的执行过程，还可以发现潜在问题，或者启发继续优化的思路。在下述程序中，使用check_shape变量控制是否打印“尺寸”，验证网络结构是否正确。使用check_content变量控制是否打印“内容值”，验证数据分布是否合理。假如在训练中发现中间层的部分输出持续为0，说明该部分的网络结构设计存在问题，没有充分利用。# 定义模型结构 class MNIST(fluid.dygraph.Layer): def __init__(self): super(MNIST, self).__init__() # 定义一个卷积层，使用relu激活函数 self.conv1 = Conv2D(num_channels=1, num_filters=20, filter_size=5, stride=1, padding=2, act='relu') # 定义一个池化层，池化核为2，步长为2，使用最大池化方式 self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max') # 定义一个卷积层，使用relu激活函数 self.conv2 = Conv2D(num_channels=20, num_filters=20, filter_size=5, stride=1, padding=2, act='relu') # 定义一个池化层，池化核为2，步长为2，使用最大池化方式 self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max') # 定义一个全连接层，输出节点数为10 self.fc = Linear(input_dim=980, output_dim=10, act='softmax') # 加入对每一层输入和输出的尺寸和数据内容的打印，根据check参数决策是否打印每层的参数和输出尺寸 def forward(self, inputs, label=None, check_shape=False, check_content=False): # 给不同层的输出不同命名，方便调试 outputs1 = self.conv1(inputs) outputs2 = self.pool1(outputs1) outputs3 = self.conv2(outputs2) outputs4 = self.pool2(outputs3) _outputs4 = fluid.layers.reshape(outputs4, [outputs4.shape[0], -1]) outputs5 = self.fc(_outputs4) # 选择是否打印神经网络每层的参数尺寸和输出尺寸，验证网络结构是否设置正确 if check_shape: # 打印每层网络设置的超参数-卷积核尺寸，卷积步长，卷积padding，池化核尺寸 print("\n########## print network layer's superparams ##############") print("conv1-- kernel_size:{}, padding:{}, stride:{}".format(self.conv1.weight.shape, self.conv1._padding, self.conv1._stride)) print("conv2-- kernel_size:{}, padding:{}, stride:{}".format(self.conv2.weight.shape, self.conv2._padding, self.conv2._stride)) print("pool1-- pool_type:{}, pool_size:{}, pool_stride:{}".format(self.pool1._pool_type, self.pool1._pool_size, self.pool1._pool_stride)) print("pool2-- pool_type:{}, poo2_size:{}, pool_stride:{}".format(self.pool2._pool_type, self.pool2._pool_size, self.pool2._pool_stride)) print("fc-- weight_size:{}, bias_size_{}, activation:{}".format(self.fc.weight.shape, self.fc.bias.shape, self.fc._act)) # 打印每层的输出尺寸 print("\n########## print shape of features of every layer ###############") print("inputs_shape: {}".format(inputs.shape)) print("outputs1_shape: {}".format(outputs1.shape)) print("outputs2_shape: {}".format(outputs2.shape)) print("outputs3_shape: {}".format(outputs3.shape)) print("outputs4_shape: {}".format(outputs4.shape)) print("outputs5_shape: {}".format(outputs5.shape)) # 选择是否打印训练过程中的参数和输出内容，可用于训练过程中的调试 if check_content: # 打印卷积层的参数-卷积核权重，权重参数较多，此处只打印部分参数 print("\n########## print convolution layer's kernel ###############") print("conv1 params -- kernel weights:", self.conv1.weight[0][0]) print("conv2 params -- kernel weights:", self.conv2.weight[0][0]) # 创建随机数，随机打印某一个通道的输出值 idx1 = np.random.randint(0, outputs1.shape[1]) idx2 = np.random.randint(0, outputs3.shape[1]) # 打印卷积-池化后的结果，仅打印batch中第一个图像对应的特征 print("\nThe {}th channel of conv1 layer: ".format(idx1), outputs1[0][idx1]) print("The {}th channel of conv2 layer: ".format(idx2), outputs3[0][idx2]) print("The output of last layer:", outputs5[0], '\n') # 如果label不是None，则计算分类精度并返回 if label is not None: acc = fluid.layers.accuracy(input=outputs5, label=label) return outputs5, acc else: return outputs5 #在使用GPU机器时，可以将use_gpu变量设置成True use_gpu = False place = fluid.CUDAPlace(0) if use_gpu else fluid.CPUPlace() with fluid.dygraph.guard(place): model = MNIST() model.train() #四种优化算法的设置方案，可以逐一尝试效果 optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters()) #optimizer = fluid.optimizer.MomentumOptimizer(learning_rate=0.01, momentum=0.9, parameter_list=model.parameters()) #optimizer = fluid.optimizer.AdagradOptimizer(learning_rate=0.01, parameter_list=model.parameters()) #optimizer = fluid.optimizer.AdamOptimizer(learning_rate=0.01, parameter_list=model.parameters()) EPOCH_NUM = 1 for epoch_id in range(EPOCH_NUM): for batch_id, data in enumerate(train_loader()): #准备数据，变得更加简洁 image_data, label_data = data image = fluid.dygraph.to_variable(image_data) label = fluid.dygraph.to_variable(label_data) #前向计算的过程，同时拿到模型输出值和分类准确率 if batch_id == 0 and epoch_id==0: # 打印模型参数和每层输出的尺寸 predict, acc = model(image, label, check_shape=True, check_content=False) elif batch_id==401: # 打印模型参数和每层输出的值 predict, acc = model(image, label, check_shape=False, check_content=True) else: predict, acc = model(image, label) #计算损失，取一个批次样本损失的平均值 loss = fluid.layers.cross_entropy(predict, label) avg_loss = fluid.layers.mean(loss) #每训练了100批次的数据，打印下当前Loss的情况 if batch_id % 200 == 0: print("epoch: {}, batch: {}, loss is: {}, acc is {}".format(epoch_id, batch_id, avg_loss.numpy(), acc.numpy())) #后向传播，更新参数的过程 avg_loss.backward() optimizer.minimize(avg_loss) model.clear_gradients() #保存模型参数 fluid.save_dygraph(model.state_dict(), 'mnist') print("Model has been saved.")加入校验或测试，更好评价模型效果在训练过程中，我们会发现模型在训练样本集上的损失在不断减小。但这是否代表模型在未来的应用场景上依然有效？为了验证模型的有效性，通常将样本集合分成三份，训练集、校验集和测试集。训练集 ：用于训练模型的参数，即训练过程中主要完成的工作。校验集 ：用于对模型超参数的选择，比如网络结构的调整、正则化项权重的选择等。测试集 ：用于模拟模型在应用后的真实效果。因为测试集没有参与任何模型优化或参数训练的工作，所以它对模型来说是完全未知的样本。在不以校验数据优化网络结构或模型超参数时，校验数据和测试数据的效果是类似的，均更真实的反映模型效果。如下程序读取上一步训练保存的模型参数，读取测试数据集，并测试模型在测试数据集上的效果。with fluid.dygraph.guard(): print('start evaluation .......') #加载模型参数 model = MNIST() model_state_dict, _ = fluid.load_dygraph('mnist') model.load_dict(model_state_dict) model.eval() eval_loader = load_data('eval') acc_set = [] avg_loss_set = [] for batch_id, data in enumerate(eval_loader()): x_data, y_data = data img = fluid.dygraph.to_variable(x_data) label = fluid.dygraph.to_variable(y_data) prediction, acc = model(img, label) loss = fluid.layers.cross_entropy(input=prediction, label=label) avg_loss = fluid.layers.mean(loss) # batch内loss平均 acc_set.append(float(acc.numpy())) avg_loss_set.append(float(avg_loss.numpy())) #计算多个batch的平均损失和准确率 acc_val_mean = np.array(acc_set).mean() avg_loss_val_mean = np.array(avg_loss_set).mean() # batch间loss平均 print('loss={}, acc={}'.format(avg_loss_val_mean, acc_val_mean))从测试的效果来看，模型在测试集上依然有93%的准确率，证明它是有预测效果的。加入正则化项，避免模型过拟合过拟合现象对于样本量有限、但需要使用强大模型的复杂任务，模型很容易出现过拟合的表现，即在训练集上的损失小，在验证集或测试集上的损失较大，如下图所示。 反之，如果模型在训练集和测试集上均损失较大，则称为欠拟合。过拟合表示模型过于敏感，学习到了训练数据中的一些误差，而这些误差并不是真实的泛化规律（可推广到测试集上的规律）。欠拟合表示模型还不够强大，还没有很好的拟合已知的训练样本，更别提测试样本了。因为欠拟合情况容易观察和解决，只要训练loss不够好，就不断使用更强大的模型即可，因此实际中我们更需要处理好过拟合的问题。过拟合原因造成过拟合的原因是模型过于敏感，而训练数据量太少或其中的噪音太多。如下图所示，理想的回归模型是一条坡度较缓的抛物线，欠拟合的模型只拟合出一条直线，显然没有捕捉到真实的规律，但过拟合的模型拟合出存在很多拐点的抛物线，显然是过于敏感，也没有正确表达真实规律。 如下图所示，理想的分类模型是一条半圆形的曲线，欠拟合用直线作为分类边界，显然没有捕捉到真实的边界，但过拟合的模型拟合出很扭曲的分类边界，虽然对所有的训练数据正确分类，但对一些较为个例的样本所做出的妥协，高概率不是真实的规律。 假设模型也会犯错，通过分析发现可能的原因：情况1：训练数据存在噪音，导致模型学到了噪音，而不是真实规律。情况2：使用强大模型（表示空间大）的同时训练数据太少，导致在训练数据上表现良好的候选假设太多，锁定了一个“虚假正确”的假设。对于情况1，我们使用数据清洗和修正来解决。 对于情况2，我们或者限制模型表示能力，或者收集更多的训练数据。而清洗训练数据中的错误，或收集更多的训练数据往往是一句“正确的废话”，在任何时候我们都想获得更多更高质量的数据。在实际项目中，更快、更低成本可控制过拟合的方法，只有限制模型的表示能力。正则化项为了防止模型过拟合，在没有扩充样本量的可能下，只能降低模型的复杂度，可以通过限制参数的数量或可能取值（参数值尽量小）实现。具体来说，在模型的优化目标（损失）中人为加入对参数规模的惩罚项。当参数越多或取值越大时，该惩罚项就越大。通过调整惩罚项的权重系数，可以使模型在“尽量减少训练损失”和“保持模型的泛化能力”之间取得平衡。泛化能力表示模型在没有见过的样本上依然有效。正则化项的存在，增加了模型在训练集上的损失。飞桨支持为所有参数加上统一的正则化项，也支持为特定的参数添加正则化项。前者的实现如下代码所示，仅在优化器中设置regularization参数即可实现。使用参数regularization_coeff调节正则化项的权重，权重越大时，对模型复杂度的惩罚越高。#各种优化算法均可以加入正则化项，避免过拟合，参数regularization_coeff调节正则化项的权重 #optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, regularization=fluid.regularizer.L2Decay(regularization_coeff=0.1),parameter_list=model.parameters())) optimizer = fluid.optimizer.AdamOptimizer(learning_rate=0.01, regularization=fluid.regularizer.L2Decay(regularization_coeff=0.1),parameter_list=model.parameters())可视化分析两种方案，一是Matplotlib库，另外一个是VisualDL，我们直接上VisualDL。pip install --upgrade --pre visualdl说明：本案例不支持AI Studio演示，请读者在本地安装的飞桨上实践。步骤1：引入VisualDL库，定义作图数据存储位置（供第3步使用），本案例的路径是“log”。from visualdl import LogWriter log_writer = LogWriter("./log")步骤2：在训练过程中插入作图语句。当每100个batch训练完成后，将当前损失作为一个新增的数据点(iter和acc的映射对)存储到第一步设置的文件中。使用变量iter记录下已经训练的批次数，作为作图的X轴坐标。log_writer.add_scalar(tag = 'acc', step = iter, value = avg_acc.numpy()) log_writer.add_scalar(tag = 'loss', step = iter, value = avg_loss.numpy()) iter = iter + 100#引入VisualDL库，并设定保存作图数据的文件位置 from visualdl import LogWriter log_writer = LogWriter(logdir="./log") with fluid.dygraph.guard(): model = MNIST() model.train() optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters()) EPOCH_NUM = 10 iter = 0 for epoch_id in range(EPOCH_NUM): for batch_id, data in enumerate(train_loader()): #准备数据，变得更加简洁 image_data, label_data = data image = fluid.dygraph.to_variable(image_data) label = fluid.dygraph.to_variable(label_data) #前向计算的过程，同时拿到模型输出值和分类准确率 predict, avg_acc = model(image, label) #计算损失，取一个批次样本损失的平均值 loss = fluid.layers.cross_entropy(predict, label) avg_loss = fluid.layers.mean(loss) #每训练了100批次的数据，打印下当前Loss的情况 if batch_id % 100 == 0: print("epoch: {}, batch: {}, loss is: {}, acc is {}".format(epoch_id, batch_id, avg_loss.numpy(), avg_acc.numpy())) log_writer.add_scalar(tag = 'acc', step = iter, value = avg_acc.numpy()) log_writer.add_scalar(tag = 'loss', step = iter, value = avg_loss.numpy()) iter = iter + 100 #后向传播，更新参数的过程 avg_loss.backward() optimizer.minimize(avg_loss) model.clear_gradients() #保存模型参数 fluid.save_dygraph(model.state_dict(), 'mnist')步骤3：命令行启动VisualDL。使用“visualdl --logdir [数据文件所在文件夹路径] 的命令启动VisualDL。在VisualDL启动后，命令行会打印出可用浏览器查阅图形结果的网址。$ visualdl --logdir ./log --port 8080步骤4：打开浏览器，查看作图结果，如 图6 所示。查阅的网址在第三步的启动命令后会打印出来（如http://127.0.0.1:8080/），将该网址输入浏览器地址栏刷新页面的效果如下图所示。除了右侧对数据点的作图外，左侧还有一个控制板，可以调整诸多作图的细节。 我安装visualDL失败了，连带着我paddle和numpy包都出错了，打算重装系统了……扩展：恢复训练模型训练一次要很久，如果因为不可抗力而中断，那就难以接受了，所以我们需要一个实时保存结果，以便下次恢复训练的方案。数据读取和网络结构不变模型保存在开始介绍使用飞桨恢复训练前，先正常训练一个模型，优化器使用Adam，使用动态变化的学习率，学习率从0.01衰减到0.001。每训练一轮后保存一次模型，之后将采用其中某一轮的模型参数进行恢复训练，验证一次性训练和中断再恢复训练的模型表现是否一致（训练loss的变化）。注意进行恢复训练的程序不仅要保存模型参数，还要保存优化器参数。这是因为某些优化器含有一些随着训练过程变换的参数，例如Adam, AdaGrad等优化器采用可变学习率的策略，随着训练进行会逐渐减少学习率。这些优化器的参数对于恢复训练至关重要。为了演示这个特性,下面训练程序使用Adam优化器，学习率以多项式曲线从0.01衰减到0.001（polynomial decay）。lr = fluid.dygraph.PolynomialDecay(0.01, total_steps, 0.001)learning_rate：初始学习率decay_steps：衰减步数end\_learning\_rate：最终学习率power：多项式的幂，默认值为1.0cycle：下降后是否重新上升，polynomial decay的变化曲线下图所示。 #在使用GPU机器时，可以将use_gpu变量设置成True use_gpu = False place = fluid.CUDAPlace(0) if use_gpu else fluid.CPUPlace() with fluid.dygraph.guard(place): model = MNIST() model.train() EPOCH_NUM = 5 BATCH_SIZE = 100 # 定义学习率，并加载优化器参数到模型中 total_steps = (int(60000//BATCH_SIZE) + 1) * EPOCH_NUM lr = fluid.dygraph.PolynomialDecay(0.01, total_steps, 0.001) # 使用Adam优化器 optimizer = fluid.optimizer.AdamOptimizer(learning_rate=lr, parameter_list=model.parameters()) for epoch_id in range(EPOCH_NUM): for batch_id, data in enumerate(train_loader()): #准备数据，变得更加简洁 image_data, label_data = data image = fluid.dygraph.to_variable(image_data) label = fluid.dygraph.to_variable(label_data) #前向计算的过程，同时拿到模型输出值和分类准确率 predict, acc = model(image, label) avg_acc = fluid.layers.mean(acc) #计算损失，取一个批次样本损失的平均值 loss = fluid.layers.cross_entropy(predict, label) avg_loss = fluid.layers.mean(loss) #每训练了200批次的数据，打印下当前Loss的情况 if batch_id % 200 == 0: print("epoch: {}, batch: {}, loss is: {}, acc is {}".format(epoch_id, batch_id, avg_loss.numpy(),avg_acc.numpy())) #后向传播，更新参数的过程 avg_loss.backward() optimizer.minimize(avg_loss) model.clear_gradients() # 保存模型参数和优化器的参数 fluid.save_dygraph(model.state_dict(), './checkpoint/mnist_epoch{}'.format(epoch_id)) fluid.save_dygraph(optimizer.state_dict(), './checkpoint/mnist_epoch{}'.format(epoch_id))恢复训练在上述训练代码中，我们训练了五轮（epoch）。在每轮结束时，均保存了模型参数和优化器相关的参数。使用model.state_dict()获取模型参数。使用optimizer.state_dict()获取优化器和学习率相关的参数。调用fluid.save_dygraph()将参数保存到本地。比如第一轮训练保存的文件是mnist\_epoch0.pdparams，mnist\_epoch0.pdopt，分别存储了模型参数和优化器参数。当加载模型时，如果模型参数文件和优化器参数文件是相同的，我们可以使用load_dygraph同时加载这两个文件，如下代码所示。params_dict, opt_dict = fluid.load_dygraph(params_path)如果模型参数文件和优化器参数文件的名字不同，需要调用两次load_dygraph分别获得模型参数和优化器参数。下面的代码将展示恢复训练的过程，并验证恢复训练是否成功。其中，我们重新定义一个train_again()训练函数，加载模型参数并从第一个epoch开始训练，以便读者可以校验恢复训练后的损失变化。params_path = "./checkpoint/mnist_epoch0" #在使用GPU机器时，可以将use_gpu变量设置成True use_gpu = False place = fluid.CUDAPlace(0) if use_gpu else fluid.CPUPlace() with fluid.dygraph.guard(place): # 加载模型参数到模型中 params_dict, opt_dict = fluid.load_dygraph(params_path) model = MNIST() model.load_dict(params_dict) EPOCH_NUM = 5 BATCH_SIZE = 100 # 定义学习率，并加载优化器参数到模型中 total_steps = (int(60000//BATCH_SIZE) + 1) * EPOCH_NUM lr = fluid.dygraph.PolynomialDecay(0.01, total_steps, 0.001) # 使用Adam优化器 optimizer = fluid.optimizer.AdamOptimizer(learning_rate=lr, parameter_list=model.parameters()) optimizer.set_dict(opt_dict) for epoch_id in range(1, EPOCH_NUM): for batch_id, data in enumerate(train_loader()): #准备数据，变得更加简洁 image_data, label_data = data image = fluid.dygraph.to_variable(image_data) label = fluid.dygraph.to_variable(label_data) #前向计算的过程，同时拿到模型输出值和分类准确率 predict, acc = model(image, label) avg_acc = fluid.layers.mean(acc) #计算损失，取一个批次样本损失的平均值 loss = fluid.layers.cross_entropy(predict, label) avg_loss = fluid.layers.mean(loss) #每训练了200批次的数据，打印下当前Loss的情况 if batch_id % 200 == 0: print("epoch: {}, batch: {}, loss is: {}, acc is {}".format(epoch_id, batch_id, avg_loss.numpy(),avg_acc.numpy())) #后向传播，更新参数的过程 avg_loss.backward() optimizer.minimize(avg_loss) model.clear_gradients()附录：飞桨API的查阅方法方法一：上官网，地址在这：https://www.paddlepaddle.org.cn/documentation/docs/zh/api_cn/index_cn.html方法二：Vscode是支持鼠标移到函数上直接显示帮助的，如下图 当然，如果你想找某一类的问题，比如支持哪些优化算法，还是得上官网上查。
课程链接：https://aistudio.baidu.com/aistudio/education/group/info/1297深度学习框架介绍在构建模型的过程中，每一步所需要完成的任务均可以拆分成个性化和通用化两个部分。个性化部分：往往是指定模型由哪些逻辑元素组合，由建模者完成。通用部分：聚焦这些元素的算法实现，由深度学习框架完成。 用飞桨重写房价预测模型首先说明一下，代码是我看官方教程一点点手打的，可能有部分地方是按我的想法写的，与官方教程不一致，不过测试是成功运行的。导入库首先加载相关库#加载飞桨、Numpy和相关类库 import paddle import paddle.fluid as fluid import paddle.fluid.dygraph as dygraph from paddle.fluid.dygraph import Linear import numpy as np import os import random代码中参数含义如下：paddle/fluid：飞桨的主库，目前大部分的实用函数均在paddle.fluid包内。dygraph：动态图的类库。Linear：神经网络的全连接层函数，即包含所有输入权重相加和激活函数的基本神经元结构。在房价预测任务中，使用只有一层的神经网络（全连接层）来实现线性回归模型。说明：飞桨支持两种深度学习建模编写方式，更方便调试的动态图模式和性能更好并便于部署的静态图模式。静态图模式（声明式编程范式，类比C++）：先编译后执行的方式。用户需预先定义完整的网络结构，再对网络结构进行编译优化后，才能执行获得计算结果。（写完所有代码，才能编译并执行）动态图模式（命令式编程范式，类比Python）：解析式的执行方式。用户无需预先定义完整的网络结构，每写一行网络代码，即可同时获得计算结果。（写一行代码就可执行）为了学习模型和调试的方便，本教程均使用动态图模式编写模型。在后续的资深教程中，会详细介绍静态图以及将动态图模型转成静态图的方法。仅在部分场景下需要模型转换，并且是相对容易的。数据处理和上一篇文章是一样的，拆分测试集，随机抽样，归一化，这里不细说了。def load_data(): # 数据处理 data = np.fromfile('housing.data',sep = ' ') feature_names = [ 'CRIM', 'ZN', 'INDUS', 'CHAS', 'NOX', 'RM', 'AGE', 'DIS', 'RAD', 'TAX', 'PTRATIO', 'B', 'LSTAT', 'MEDV' ] feature_num = len(feature_names) data = data.reshape([data.shape[0]//feature_num,feature_num]) # 拆分训练集 ratio = 0.8 training_data = data[:int(data.shape[0]*ratio),:] # 归一化 maximums, minimums, avgs = np.max(training_data,axis = 0),np.min(training_data,axis = 0),\ np.average(training_data,axis = 0) global range_value # 定义全局变量，以便预测时仍然可以用归一化参数 global avgs_value range_value = maximums - minimums avgs_value = avgs data = (data - avgs) / range_value # 把归一化的数据重新划分为训练集和测试集 training_data = data[:int(data.shape[0]*ratio),:] test_data = data[int(data.shape[0]*ratio):,:] return training_data, test_data模型设计模型定义的实质是定义线性回归的网络结构，飞桨建议通过创建Python类的方式完成模型网络的定义，即定义init函数和forward函数。forward函数是框架指定实现前向计算逻辑的函数，程序在调用模型实例时会自动执行forward方法。在forward函数中使用的网络层需要在init函数中声明。实现过程分如下两步：定义init函数：在类的初始化函数中声明每一层网络的实现函数。在房价预测模型中，只需要定义一层全连接层。定义forward函数：构建神经网络结构，实现前向计算过程，并返回预测结果，在本任务中返回的是房价预测结果。class Regressor(fluid.dygraph.Layer): def __init__(self): super(Regressor, self).__init__() # 定义一层全连接层，输入维度13，输出维度是1，激活函数为None，即不使用激活函数 self.fc = Linear(input_dim=13, output_dim=1, act=None) # 网络的前向计算函数 def forward(self, inputs): x = self.fc(inputs) # self即指向实例本身，所以可以向Linear()类传入参数（大概？） return xsuper() 函数是用于调用父类(超类)的一个方法来自菜鸟教程的一个例子：class A: def __init__(self): self.testa = 2 def add(self, x): y = x+1+self.testa print(y) class B(A): def __init__(self): self.testa = 3 def add(self, x): super(B,self).add(x) b = B() b.add(1)首先Class B(A)表示B继承了A，即A是B的父类。Super将当前的self参数传递给父类A，然后调用父类的方法add()。训练配置训练配置包含四步： 以guard函数指定运行训练的机器资源，表明在with作用域下的程序均执行在本机的CPU资源上。dygraph.guard表示在with作用域下的程序会以飞桨动态图的模式执行（实时执行）。声明定义好的回归模型Regressor实例，并将模型的状态设置为训练。使用load_data函数加载训练数据和测试数据。设置优化算法和学习率，优化算法采用随机梯度下降SGD，学习率设置为0.01。训练配置代码如下所示：# 定义飞桨动态图的工作环境 with fluid.dygraph.guard(): # 声明定义好的线性回归模型 model = Regressor() # 开启模型训练模式 model.train() # 加载数据 training_data, test_data = load_data() # 定义优化算法，这里使用随机梯度下降-SGD # 学习率设置为0.01 opt = fluid.optimizer.SGD(learning_rate=0.01, parameter_list=model.parameters())with可以理解为try …… except …… finally ……的简化版本，回忆一下with打开文件，无论最终结果如何，该文件都会被关闭。博主在vscode上运行报Context manager 'generator' doesn't implement __enter__ and __exit__.错误，但是不影响执行，google也没查到结果。说明：1.默认本案例运行在读者的笔记本上，因此模型训练的机器资源为CPU。2.模型实例有两种状态：训练状态.train()和预测状态.eval()。训练时要执行正向计算和反向传播梯度两个过程，而预测时只需要执行正向计算。为模型指定运行状态，有两点原因：（1）部分高级的算子（例如Drop out和Batch Normalization）在两个状态执行的逻辑不同。（2）从性能和存储空间的考虑，预测状态时更节省内存，性能更好。3.在上述代码中可以发现声明模型、定义优化器等操作都在with创建的 fluid.dygraph.guard()上下文环境中进行，可以理解为with fluid.dygraph.guard()创建了飞桨动态图的工作环境，在该环境下完成模型声明、数据转换及模型训练等操作。在基于Python实现神经网络模型的案例中，我们为实现梯度下降编写了大量代码，而使用飞桨框架只需要定义SGD就可以实现优化器设置，大大简化了这个过程。训练过程训练过程采用二层循环嵌套方式：内层循环： 负责整个数据集的一次遍历，采用分批次方式（batch）。假设数据集样本数量为1000，一个批次有10个样本，则遍历一次数据集的批次数量是1000/10=100，即内层循环需要执行100次。for iter_id, mini_batch in enumerate(mini_batches):外层循环： 定义遍历数据集的次数，通过参数EPOCH_NUM设置。for epoch_id in range(EPOCH_NUM):说明:batch的取值会影响模型训练效果。batch过大，会增大内存消耗和计算时间，且效果并不会明显提升；batch过小，每个batch的样本数据将没有统计意义。由于房价预测模型的训练数据集较小，我们将batch为设置10。整个过程代码实现：with dygraph.guard(fluid.CPUPlace()): # CPU上训练 EPOCH_NUM = 10 # 设置外层循环次数 BATCH_SIZE = 10 # 设置batch大小 # 定义外层循环 for epoch_id in range(EPOCH_NUM): # 在每轮迭代开始之前，将训练数据的顺序随机的打乱 np.random.shuffle(training_data) # 将训练数据进行拆分，每个batch包含10条数据 mini_batches = [training_data[k:k+BATCH_SIZE] for k in range(0, len(training_data), BATCH_SIZE)] # 定义内层循环 for iter_id, mini_batch in enumerate(mini_batches): x = np.array(mini_batch[:, :-1]).astype('float32') # 获得当前批次训练数据 y = np.array(mini_batch[:, -1:]).astype('float32') # 获得当前批次训练标签（真实房价） # 将numpy数据转为飞桨动态图variable形式 house_features = dygraph.to_variable(x) prices = dygraph.to_variable(y) # 前向计算 predicts = model(house_features) # 计算损失 loss = fluid.layers.square_error_cost(predicts, label=prices) avg_loss = fluid.layers.mean(loss) if iter_id%20==0: print("epoch: {}, iter: {}, loss is: {}".format(epoch_id, iter_id, avg_loss.numpy())) # 反向传播 avg_loss.backward() # 最小化loss,更新参数 opt.minimize(avg_loss) # 清除梯度 model.clear_gradients() # 保存模型 fluid.save_dygraph(model.state_dict(), 'LR_model')飞桨训练的数据需要是内置的variable格式数据，我们拿到数据后，可以先转为np.array格式，再通过dygraph.to_variable()进行转换反向传播其实就是利用随即梯度下降更新参数的过程这部分代码直接抄的教程，说实话，有点违背我之前写python的经验了，得好好理解一下。保存并测试模型将模型当前的参数数据model.state_dict()保存到文件中（通过参数指定保存的文件名 LR_model），以备预测或校验的程序调用，代码如下所示。# 定义飞桨动态图工作环境 with fluid.dygraph.guard(): # 保存模型参数，文件名为LR_model fluid.save_dygraph(model.state_dict(), 'LR_model') print("模型保存成功，模型参数保存在LR_model中")测试模型下面我们选择一条数据样本，测试下模型的预测效果。测试过程和在应用场景中使用模型的过程一致，主要可分成如下三个步骤：配置模型预测的机器资源。本案例默认使用本机，因此无需写代码指定。将训练好的模型参数加载到模型实例中。由两个语句完成，第一句是从文件中读取模型参数；第二句是将参数内容加载到模型。加载完毕后，需要将模型的状态调整为eval()（校验）。上文中提到，训练状态的模型需要同时支持前向计算和反向传导梯度，模型的实现较为臃肿，而校验和预测状态的模型只需要支持前向计算，模型的实现更加简单，性能更好。将待预测的样本特征输入到模型中，打印输出的预测结果。通过load_one_example函数实现从数据集中抽一条样本作为测试样本，具体实现代码如下所示。def load_one_example(data_dir): f = open(data_dir, 'r') datas = f.readlines() # 选择倒数第10条数据用于测试 tmp = datas[-10] tmp = tmp.strip().split() one_data = [float(v) for v in tmp] # 对数据进行归一化处理 for i in range(len(one_data)-1): one_data[i] = (one_data[i] - avg_values[i]) / (max_values[i] - min_values[i]) data = np.reshape(np.array(one_data[:-1]), [1, -1]).astype(np.float32) label = one_data[-1] return data, labelwith dygraph.guard(): # 参数为保存模型参数的文件地址 model_dict, _ = fluid.load_dygraph('LR_model') model.load_dict(model_dict) model.eval() # 参数为数据集的文件地址 test_data, label = load_one_example('./work/housing.data') # 将数据转为动态图的variable格式 test_data = dygraph.to_variable(test_data) results = model(test_data) # 对结果做反归一化处理 results = results * (max_values[-1] - min_values[-1]) + avg_values[-1] print("Inference result is {}, the corresponding label is {}".format(results.numpy(), label))通过比较“模型预测值”和“真实房价”可见，模型的预测效果与真实房价接近。
PaddlePaddle飞桨课程学习Day1：深度学习简介与入门模型
前言笔记来自百度架构师手把手带你零基础实践深度学习课程，第一天听下来，我感觉老师讲得还是非常深入浅出，通俗易懂的。推荐大家学一下。课程链接：https://aistudio.baidu.com/aistudio/education/group/info/1297虽然课程标题里说是零基础，但就和之前介绍过的零基础实现神经网络系列文章一样，这里的零基础指的是深度学习零基础。至少你要熟悉python编程，了解基本的数学原理（主要是线性代数和微积分），和部分库（比如Numpy）。博主在学习python和numpy的时候做了一点笔记，可以参考一下：[post url="https://econ.wiki//wiki//编程语言//python//01 基础 Python笔记//" title="python基础语法" intro="雁陎做的关于python语法的笔记"][post url="https://econ.wiki//wiki//编程语言//python//02 numpy库//" title="numpy基础语法" intro="雁陎做的关于numpy语法的笔记"]综述人工智能、机器学习、深度学习的关系概括来说，人工智能、机器学习和深度学习覆盖的技术范畴是逐层递减的。人工智能是最宽泛的概念。机器学习是当前比较有效的一种实现人工智能的方式。深度学习是机器学习算法中最热门的一个分支。如下图 机器学习机器学习的实现机器学习的实现可以分成两步：训练和预测，类似于我们熟悉的归纳和演绎。训练即从样本中抽样出输入和输出的关系，预测即给定新的输入，计算输出。机器学习的方法论图3 是以$H$为模型的假设，它是一个关于参数$W$和输入$X$的函数，用$H(W, X)$ 表示。模型的优化目标是$H(W, X)$的输出与真实输出$Y$尽量一致，两者的相差程度即是模型效果的评价函数（相差越小越好）。那么，确定参数的过程就是在已知的样本上，不断减小该评价函数（$H(W, X)$ 和$Y$相差）的过程，直到学习到一个参数$W$，使得评价函数的取值最小。这个衡量模型预测值和真实值差距的评价函数也被称为损失函数（损失Loss）。 最小化损失是模型的优化目标，实现损失最小化的方法称为优化算法，也称为寻解算法（找到使得损失函数最小的参数解）。参数$W$和输入$X$组成公式的基本结构称为假设。由此可见，模型假设、评价函数（损失/优化目标）和优化算法是构成模型的三个部分。那么这三个部分是如何支撑机器学习流程的呢？如下图 所示： 模型假设：预先圈定好Y~X可能的关系（函数）集（篮圈），以提高训练效率。评价函数：如何确定该函数就是我们要找的？定义一个评价函数，同样将拟合的误差最小作为优化目标。优化算法：在关系可能性集中，寻找使得评价指标最优（损失函数最小/最拟合已有观测样本）的参数，这个寻找的方法即为优化算法。最笨的优化算法即按照参数的可能，穷举每一个可能取值来计算损失函数，保留使得损失函数最小的参数作为最终结果。机器执行学习的框架体现了其学习的本质是“参数估计”（Learning is parameter estimation）。在此基础上，许多看起来完全不一样的问题都可以使用同样的框架进行学习，如科学定律、图像识别、机器翻译和自动问答等，它们的学习目标都是拟合一个“大公式”，如 图5 所示。 深度学习深度学习与机器学习在理论结构上是一致的，即：模型假设、评价函数和优化算法，其根本差别在于假设的复杂度，如下图所示。 这种复杂程度已经无法单纯用数学公式表达，因此研究者设计了神经网络的模型。神经网络人工神经网络包括多个神经网络层，如卷积层、全连接层、LSTM等，每一层又包括很多神经元，超过三层的非线性神经网络都可以被称为深度神经网络。通俗的讲，深度学习的模型可以视为是输入到输出的映射函数，如图像到高级语义（美女）的映射，足够深的神经网络理论上可以拟合任何复杂的函数。因此神经网络非常适合学习样本数据的内在规律和表示层次，对文字、图像和语音任务有很好的适用性。因为这几个领域的任务是人工智能的基础模块，所以深度学习被称为实现人工智能的基础也就不足为奇了。神经网络结构如 图7 所示。 一些基本概念：神经元： 神经网络中每个节点称为神经元，由两部分组成：加权和：将所有输入加权求和。非线性变换（激活函数）：加权和的结果经过一个非线性函数变换，让神经元计算具备非线性的能力。多层连接： 大量这样的节点按照不同的层次排布，形成多层的结构连接起来，即称为神经网络。前向计算： 从输入计算输出的过程，顺序从网络前至后。计算图： 以图形化的方式展现神经网络的计算逻辑又称为计算图。我们也可以将神经网络的计算图以公式的方式表达如下：$$Y =f_3 ( f_2 ( f_1 ( w_1\cdot x_1+w_2\cdot x_2+w_3\cdot x_3+b ) + … ) … ) … )$$发展历程 1940年代：首次提出神经元的结构，但权重是不可学的。1950-60年代：提出权重学习理论，神经元结构趋于完善，开启了神经网络的第一个黄金时代。1969年：提出异或问题（人们惊奇的发现神经网络模型连简单的异或问题也无法解决，对其的期望从云端跌落到谷底），神经网络模型进入了被束之高阁的黑暗时代。1986年：新提出的多层神经网络解决了异或问题，但随着90年代后理论更完备并且实践效果更好的SVM等机器学习模型的兴起，神经网络并未得到重视。2010年左右：深度学习进入真正兴起时期。随着神经网络模型改进的技术在语音和计算机视觉任务上大放异彩，也逐渐被证明在更多的任务，如自然语言处理以及海量数据的任务上更加有效。至此，神经网络模型重新焕发生机，并有了一个更加响亮的名字：深度学习。为何神经网络到2010年后才焕发生机呢？这与深度学习成功所依赖的先决条件：大数据涌现、硬件发展和算法优化有关。影响实现了端到端的学习深度学习改变了很多领域算法的实现模式。在深度学习兴起之前，很多领域建模的思路是投入大量精力做特征工程，将专家对某个领域的“人工”理解沉淀成特征表达，然后使用简单模型完成任务（如分类或回归）。而在数据充足的情况下，深度学习模型可以实现端到端的学习，即不需要专门做特征工程，将原始的特征输入模型中，模型可同时完成特征提取和分类任务，如下图所示。 实现了深度学习框架标准化从各算法均需独立实现到由统一的框架实现从要求专业的理论储备与行业知识到只需少量的理论知识 基本流程不同场景的深度学习模型具备一定的通用性，五个步骤即可完成模型的构建和训练，如下图所示 正是由于深度学习的建模和训练的过程存在通用性，在构建不同的模型时，只有模型三要素不同，其它步骤基本一致，深度学习框架才有用武之地。案例：波士顿房价预测波士顿房价预测是一个经典的机器学习任务，类似于程序员世界的“Hello World”。该数据集统计了13种可能影响房价的因素和该类型房屋的均价，期望构建一个基于13个因素进行房价预测的模型，如 图1 所示。 对于预测问题，可以根据预测输出的类型是连续的实数值，还是离散的标签，区分为回归任务和分类任务。因为房价是一个连续值，所以房价预测显然是一个回归任务。下面我们尝试用最简单的线性回归模型解决这个问题，并用神经网络来实现这个模型。多元线性回归模型假设房价和各影响因素之间能够用线性关系来描述：$$y = {\sum_{j=1}^Mx_j w_j} + b$$模型的求解即是通过数据拟合出每个$w_j$和$b$。其中，$w_j$和$b$分别表示该线性模型的权重和偏置。一维情况下，$w_j$ 和 $b$ 是直线的斜率和截距。线性回归模型使用均方误差作为损失函数（Loss），用以衡量预测房价和真实房价的差异，公式如下：$$MSE = \frac{1}{n} \sum_{i=1}^n(\hat{Y_i} - {Y_i})^{2}$$第一步 数据处理[scode type="yellow"]这部分代码原本是运行在jupyter上的，我搬过来的时候没有带运行结果，也省略了部分打印出来的语句[/scode]读入数据首先打开数据集看一下数据结构： 接着用numpy的fromfile函数读入数据# 导入需要用到的package import numpy as np import json # 读入训练数据 datafile = './work/housing.data' data = np.fromfile(datafile, sep=' ')数据形状变换由于读入的原始数据是1维的，所有数据都连在一起。因此需要我们将数据的形状进行变换，形成一个2维的矩阵，每行为一个数据样本（14个值），每个数据样本包含13个X（影响房价的特征）和一个Y（该类型房屋的均价）。# 读入之后的数据被转化成1维array，其中array的第0-13项是第一条数据，第14-27项是第二条数据，以此类推.... # 这里对原始数据做reshape，变成N x 14的形式 feature_names = [ 'CRIM', 'ZN', 'INDUS', 'CHAS', 'NOX', 'RM', 'AGE','DIS', 'RAD', 'TAX', 'PTRATIO', 'B', 'LSTAT', 'MEDV' ] feature_num = len(feature_names) data = data.reshape([data.shape[0] // feature_num, feature_num])数据集划分将数据集划分成训练集和测试集，其中训练集用于确定模型的参数，测试集用于评判模型的效果。为什么要对数据集进行拆分，而不能直接应用于模型训练呢？因为我们期望模型学习的是任务的本质规律，而不是训练数据本身，模型训练未使用的数据，才能更真实的评估模型的效果。在本案例中，我们将80%的数据用作训练集，20%用作测试集，实现代码如下。通过打印训练集的形状，可以发现共有404个样本，每个样本含有13个特征和1个预测值。ratio = 0.8 offset = int(data.shape[0] * ratio) training_data = data[:offset] training_data.shape数据归一化处理对每个特征进行归一化处理（减平均值然后除以极差），使得每个特征的取值缩放到0~1之间。这样做有两个好处：一是模型训练更高效；二是特征前的权重大小可以代表该变量对预测结果的贡献度（因为每个特征值本身的范围相同）。# 计算train数据集的最大值，最小值，平均值 maximums, minimums, avgs = \ training_data.max(axis=0), \ training_data.min(axis=0), \ training_data.sum(axis=0) / training_data.shape[0] # 对数据进行归一化处理 for i in range(feature_num): #print(maximums[i], minimums[i], avgs[i]) data[:, i] = (data[:, i] - avgs[i]) / (maximums[i] - minimums[i])测试样本同样需要归一化，但需要注意的是，测试样本除以的也是训练样本的极差，为什么？因为我们测试的时候要尽可能拟合真实世界的情况，而真实世界里，我们一般拿不到样本的范围。最后我们将其封装为一个函数load_data()，返回测试集和训练集，这里略。第二步 模型设计-前向计算神经网络的标准结构中每个神经元由加权和与非线性变换构成，然后将多个神经元分层的摆放并连接形成神经网络。线性回归模型可以认为是神经网络模型的一种极简特例，是一个只有加权和、没有非线性变换的神经元（无需形成网络），如下图所示： 神经网络的三个关键：加权和（留下），非线性变换（去掉），多层连接（去掉）上图中的X = [ 'CRIM', 'ZN', 'INDUS', 'CHAS', 'NOX', 'RM', 'AGE','DIS', 'RAD', 'TAX', 'PTRATIO', 'B', 'LSTAT', 'MEDV' ]，Y = ['MEDV']一层神经网络（一个神经元）：$z=W \cdot x+b$激活函数：$y=\text {activation}_{-} f n(z)$对于线性回归activation_fn的形式：$y=z$这个神经元就是将许多输入乘以一个线性的权重，进行加权和，之后再经过一个非线性的激活函数作为一个输出。该模型我们只用线性加权和的部分就足够了。如果将输入特征和输出预测值均以向量表示，输入特征$x$有13个分量，$y$有1个分量，那么参数权重的形状（shape）是$13\times1$。假设我们以如下任意数字赋值参数做初始化：$$w=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, -0.1, -0.2, -0.3, -0.4, 0.0]$$w = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, -0.1, -0.2, -0.3, -0.4, 0.0] w = np.array(w).reshape([13, 1])取出第1条样本数据，观察样本的特征向量与参数向量相乘的结果。x1=x[0] t = np.dot(x1, w)完整的线性回归公式，还需要初始化偏移量b，同样随意赋初值-0.2。那么，线性回归模型的完整输出是$z=t+b$，这个从特征和参数计算输出值的过程称为“前向计算”。b = -0.2 z = t + b 将上述代码整合为一个类，forward函数（代表“前向计算”）完成上述从特征和参数到输出预测值的计算过程，代码如下所示。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调用它也很简单：net = Network(13) x1 = x[0] y1 = y[0] z = net.forward(x1) print(z)如此，模型的基本框架就搭建好了。第三步 损失函数首先我们需要定义一个损失函数，来评估模型的有效性。回归模型中，通常采用均方误差来作为损失函数，公式如下：$$Loss= \frac{1}{N}\sum_{i=1}^N{(y_i - z_i)^2}$$其中，y是真实值，z是预测值，N为样本总数。在Network类下面添加损失函数的计算过程如下：class Network(object): # ... def loss(self, z, y): error = z - y cost = error * error cost = np.mean(cost) return cost计算Loss：net = Network(13) # 此处可以一次性计算多个样本的预测值和损失函数 x1 = x[0:3] y1 = y[0:3] z = net.forward(x1) print('predict: ', z) loss = net.loss(z, y1) print('loss:', loss)第四步 优化算法有损失函数是不够的，我们还需要找到一个方法，优化参数并使损失函数最小。也就是目标：找到一组参数（w,b）的值，使得Loss取极小值。可以发现，Loss是以参数（w,b）为自变量的函数，回忆微分知识，易得，处于曲线极值点（这里是极小值）时的斜率为0，即函数在极值点处的导数为0 那么就好办了，我们只要求以下方程，将（x,y）代入即可$$\frac{\partial{L}}{\partial{w}}=0$$$$\frac{\partial{L}}{\partial{b}}=0$$但是这种方法只对线性回归这样简单的任务有效。如果模型中含有非线性变换，或者损失函数不是均方差这种简单的形式，则很难通过上式求解。为了解决这个问题，下面我们将引入更加普适的数值求解方法：梯度下降法。梯度下降法想象一个山峰走到坡谷的盲人，他看不见坡谷在哪（无法逆向求解出$Loss$导数为0时的参数值），但可以伸脚探索身边的坡度（当前点的导数值，也称为梯度）。那么，求解Loss函数最小值可以这样实现：从当前的参数取值，一步步的按照下坡的方向下降，直到走到最低点。这种方法即“梯度下降法”。训练的关键是找到一组$(w, b)$，使得损失函数$L$取极小值。我们先看一下损失函数$L$只随两个参数$w_5$、$w_9$变化时的简单情形，启发下寻解的思路。$$L=L(w_5, w_9)$$这里我们将$w_0, w_1, ..., w_{12}$中除$w_5, w_9$之外的参数和$b$都固定下来，可以用图画出$L(w_5, w_9)$的形式。之所以挑这两个参数，主要是为了画图方便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()对于这种简单情形，我们利用上面的程序，可以在三维空间中画出损失函数随参数变化的曲面图。从图中可以看出有些区域的函数值明显比周围的点小。观察上述曲线呈现出“圆滑”的坡度，这正是我们选择以均方误差作为损失函数的原因之一。图6 呈现了只有一个参数维度时，均方误差和绝对值误差（只将每个样本的误差累加，不做平方处理）的损失函数曲线图。 由此可见，均方误差表现的“圆滑”的坡度有两个好处：曲线的最低点是可导的。越接近最低点，曲线的坡度逐渐放缓，有助于通过当前的梯度来判断接近最低点的程度（是否逐渐减少步长，以免错过最低点）。而这两个特性绝对值误差是不具备的，这也是损失函数的设计不仅仅要考虑“合理性”，还要追求“易解性”的原因。现在我们要找出一组$[w_5, w_9]$的值，使得损失函数最小，实现梯度下降法的方案如下：步骤1：随机的选一组初始值，例如：$[w_5, w_9] = [-100.0, -100.0]$步骤2：选取下一个点$[w_5^{'} , w_9^{'}]$，使得$L(w_5^{'} , w_9^{'}) < L(w_5, w_9)$步骤3：重复步骤2，直到损失函数几乎不再下降。如何选择$[w_5^{'} , w_9^{'}]$是至关重要的，第一要保证$L$是下降的，第二要使得下降的趋势尽可能的快。微积分的基础知识告诉我们，沿着梯度的反方向，是函数值下降最快的方向，如 图7 所示。简单理解，函数在某一个点的梯度方向是曲线斜率最大的方向，但梯度方向是向上的，所以下降最快的是梯度的反方向。 计算梯度上面我们讲过了损失函数的计算方法，这里稍微加以改写。为了梯度计算更加简洁，引入因子$\frac{1}{2}$，定义损失函数如下：$$L= \frac{1}{2N}\sum_{i=1}^N{(y_i - z_i)^2}$$其中$z_i$是网络对第$i$个样本的预测值：$$z_i = \sum_{j=0}^{12}{x_i^{j}\cdot w_j} + b$$梯度的定义：$$gardient = (\frac{\partial{L}}{\partial{w_0}},\frac{\partial{L}}{\partial{w_1}}, ... ,\frac{\partial{L}}{\partial{w_{12}}} ,\frac{\partial{L}}{\partial{b}})$$可以计算出$L$对$w$和$b$的偏导数：$$\frac{\partial{L}}{\partial{w_j}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)\frac{\partial{z_i}}{\partial{w_j}}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)x_i^{j}}$$$$\frac{\partial{L}}{\partial{b}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)\frac{\partial{z_i}}{\partial{b}}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)}$$从导数的计算过程可以看出，因子$\frac{1}{2}$被消掉了，这是因为二次函数求导的时候会产生因子$2$，这也是我们将损失函数改写的原因。下面我们考虑只有一个样本的情况下，计算梯度：$$L= \frac{1}{2}{(y_i - z_i)^2}$$$$z_1 = {x_1^{0}\cdot w_0} + {x_1^{1}\cdot w_1} + ... + {x_1^{12}\cdot w_{12}} + b$$可以计算出：$$L= \frac{1}{2}{({x_1^{0}\cdot w_0} + {x_1^{1}\cdot w_1} + ... + {x_1^{12}\cdot w_{12}} + b - y_1)^2}$$可以计算出$L$对$w$和$b$的偏导数：$$\frac{\partial{L}}{\partial{w_0}} = ({x_1^{0}\cdot w_0} + {x_1^{1}\cdot w_1} + ... + {x_1^{12}\cdot w_12} + b - y_1)\cdot x_1^{0}=({z_1} - {y_1})\cdot x_1^{0}$$$$\frac{\partial{L}}{\partial{b}} = ({x_1^{0}\cdot w_0} + {x_1^{1}\cdot w_1} + ... + {x_1^{12}\cdot w_{12}} + b - y_1)\cdot 1 = ({z_1} - {y_1})$$程序实现首先，我们取出第一个样本：x1 = x[0] y1 = y[0] z1 = net.forward(x1)然后求w0的梯度：gradient_w0 = (z1 - y1) * x1[0]w1，w2……以此类推，写个for 循环即可求出所有w的梯度，再套个循环就能求出所有样本的所有w梯度。Numpy实现梯度下降法实际训练过程中，我们一般不会使用两层for这样低效的方法，而是借助Numpy的广播机制。计算梯度的代码中直接用$(z_1 - y_1) * x_1$，得到的是一个13维的向量，每个分量分别代表该维度的梯度。x1 = x[0] y1 = y[0] z1 = net.forward(x1) gradient_w = (z1 - y1) * x1 # 得出第一个样本的各维度梯度类似地，我们可以求样本2、样本3……的各个梯度，然后求平均值。但是这仍然是种低效的做法，我们可以直接取出所有训练集样本作梯度运算，即：z = net.forward(x) gradient_w = (z - y) * x然后就会输出一个$13\times 404$维梯度矩阵（404是训练样本数量）。上面gradient_w的每一行代表了一个样本对梯度的贡献。根据梯度的计算公式，总梯度是对每个样本对梯度贡献的平均值。$$\frac{\partial{L}}{\partial{w_j}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)\frac{\partial{z_i}}{\partial{w_j}}} = \frac{1}{N}\sum_{i=1}^N{(z_i - y_i)x_i^{j}}$$我们也可以使用Numpy的均值函数来完成此过程：# axis = 0 表示把每一行做相加然后再除以总的行数 gradient_w = np.mean(gradient_w, axis=0)这里有个问题：gradient_w的形状是(13,)（我的理解是(1,13)），而w的维度是(13, 1)。导致该问题的原因是使用np.mean函数时消除了第0维。为了加减乘除等计算方便，gradient_w和w必须保持一致的形状。因此我们将gradient_w的维度也设置为(13, 1)，代码如下：gradient_w = gradient_w[:, np.newaxis] # np.newaxis类似地，求b的梯度如下：gradient_b = (z - y) gradient_b = np.mean(gradient_b) # 此处b是一个数值，所以可以直接用np.mean得到一个标量将上面计算w和b的梯度的过程，写成Network类的gradient函数，实现方法如下所示。class Network(object): # ... 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第五步 更新参数下面我们开始研究更新梯度的方法。首先沿着梯度的反方向移动一小步，找到下一个点P1，观察损失函数的变化。# 沿着梯度的反方向移动到下一个点P1 # 定义移动步长 eta eta = 0.1 # 更新参数w net.w = net.w - eta * gradient_w # 重新计算z和loss z = net.forward(x) loss = net.loss(z, y) gradient_w, gradient_b = net.gradient(x, y) print('point {}, loss {}'.format(net.w, loss)) print('gradient {}'.format(gradient_w))在上述代码中，每次更新参数使用的语句：net.w = net.w - eta * gradient_w相减：参数需要向梯度的反方向移动。eta：控制每次参数值沿着梯度反方向变动的大小，即每次移动的步长，又称为学习率。这里我们可以回答之前留下的一个问题：为什么要进行归一化？如 图8 所示，特征输入归一化后，不同参数输出的Loss是一个比较规整的曲线，学习率可以设置成统一的值 ；特征输入未归一化时，不同特征对应的参数所需的步长不一致，尺度较大的参数需要大步长，尺寸较小的参数需要小步长，导致无法设置统一的学习率。 代码整合与训练我们将上面的循环计算封装为train()和update()函数，整个流程代码如下：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()总结一下，梯度下降全流程如下：通过前向计算拿到预测输出根据预测值和实际值，计算损失计算梯度根据梯度更新参数1-4循环往复，直到损失达到最小。这样，一个简单的深度学习模型就搭建好了修改：随机梯度下降法在上述程序中，每次损失函数和梯度计算都是基于数据集中的全量数据。在实际问题中，数据集往往非常大，如果每次都使用全量数据进行计算，效率非常低。由于参数每次只沿着梯度反方向更新一点点，因此方向并不需要那么精确。一个合理的解决方案是每次从总的数据集中随机抽取出小部分数据来代表整体，基于这部分数据计算梯度和损失来更新参数，这种方法被称作随机梯度下降法（Stochastic Gradient Descent，SGD），核心概念如下：min-batch：每次迭代时抽取出来的一批数据被称为一个min-batch。（每次训练所需样本数据本身）batch_size：一个mini-batch所包含的样本数目称为batch_size。（每次训练所需样本数）epoch：当程序迭代的时候，按mini-batch逐渐抽取出样本，当把整个数据集都遍历到了的时候，则完成了一轮训练，也叫一个epoch。启动训练时，可以将训练的轮数num_epochs和batch_size作为参数传入。下面结合程序介绍具体的实现过程，涉及到数据处理和训练过程两部分代码的修改。数据处理修改数据处理需要实现拆分数据批次和样本乱序（为了实现随机抽样的效果）两个功能。通过大量实验发现，模型对最后出现的数据印象更加深刻。训练数据导入后，越接近模型训练结束，最后几个批次数据对模型参数的影响越大。为了避免模型记忆影响训练效果，需要进行样本乱序操作。# 获取数据 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”，代码如下：for epoch_id in range(num_epoches):2.第二层循环，代表每次遍历时，样本集合被拆分成的多个批次，需要全部执行训练，称为“iter (iteration)”，代码如下：for iter_id,mini_batch in emumerate(mini_batches):在两层循环的内部是经典的四步训练流程：前向计算->计算损失->计算梯度->更新参数，和前面是一样的。修改后的代码如下：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_epoches, batch_size=10, eta=0.01): n = len(training_data) losses = [] for epoch_id in range(num_epoches): # 在每轮迭代开始之前，将训练数据的顺序随机打乱 # 然后再按每次取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_epoches=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()
这份官方教程在7月初发布的，等到现在好像也没有汉化版，所以直接上手学了，顺便把我翻译的章节放上来。原书地址：https://PyTorch.org/assets/deep-learning/Deep-Learning-with-PyTorch.pdf配套代码：https://github.com/deep-learning-with-PyTorch/dlwpt-code本书论坛：https://livebook.manning.com/#!/book/deep-learning-with-PyTorch/discussion[scode type="yellow"]本人深度学习小白，英文水平极其有限，大部分地方都借助了翻译软件，仅作为学习过程的记录，大家学的话还是直接看原文或者等其他人翻译。另：有删减。[/scode]深度学习革命为了领会这种深度学习方法所带来的范式转变，让我们回过头来看一看。直到过去十年，机器学习严重依赖于特征工程（feature engineering）。特征是对输入数据的转换，便于下游算法（如分类器）在新数据上产生正确的结果。特征工程包括提出正确的变换，以便下游算法能够解决一个任务。例如，为了区分手写数字图像中的1和0，我们会想出一组滤波器来估计图像上的边缘方向，然后训练一个分类器来预测给定边缘方向分布的正确数字。另一个有用的特征可能是封闭的孔的数量，如0、8。深度学习则从原始数据中自动找到这样的表征，以成功执行任务。在1与0的例子中，过滤器会在训练过程中通过反复观察成对的例子和目标标签来完善。这并不是说深度学习中没有特征工程，我们经常需要在学习系统中注入某种形式的先验知识。然而，神经网络能够摄取数据，并在例子的基础上提取有用的表征，这就是深度学习的强大之处。深度学习从业者的关注点并不在于手动制作这些表征，而是在数学实体上进行操作，使其从训练数据中自主发现表征。通常情况下，这些自动创建的特征比那些手动制作的特征更好！与许多颠覆性技术一样，这一事实导致了观念的改变。在图1.1的左侧，我们看到一个从业者正在忙着定义工程特征，并将其输入到学习算法中；任务的结果将和从业者的工程特征一样好。右边是深度学习，原始数据被输入到算法中，算法自动提取分层特征，在算法的引导下，优化自己的任务表现；结果将和从业者的表现一样好，以驱动算法向目标前进。 图1.1：由于数据的增长和计算需求，深度学习从手动提取特征中解脱出来从图1.1的右侧，我们已经可以窥见成功执行深度学习所需要的条件：我们需要有办法提取我们手头的任何数据。我们需要以某种方式定义深度学习机器。我们必须有一种自动化的方式，训练，以获得有用的表征，并使机器产生期望的输出。这就需要我们仔细研究一下我们一直在说的训练这件事。在训练过程中，我们使用一个标准，一个闹喊模型输出和参考数据的实值函数，为我们模型的期望输出和实际输出之间的差异打分（按照惯例，分数越低越好）。 训练包括通过逐步修改我们的深度学习机器来推动标准向更低的分数发展，直到它达到低分数。PyTorch为什么选PyTorch？它简单易用，足够Pythonic，任何python编程老手都会感到熟悉。具体来说，PyTorch提供了一种数据类型：张量（Tensor），用于存放数字、向量、矩阵、数组等，并提供了相应的函数。如果你熟悉numpy库的话，应该会更容易上手。PyTorch提供了GPU加速计算，这比CPU快了50倍。另外，PyTorch在数据运算方面进行了优化。这两个特性对于任何科学计算都是有用的，因此不打算深度学习的老哥也可以尝试学习。PyTorch的一个设计驱动因素是表达能力，它允许开发人员实现复杂的模型，而不会因为库（它不是一个框架）过于复杂而受到影响。基于这个原因，PyTorch在研究中得到了广泛的采用，国际会议上的高引用率就证明了这一点PyTorch配置了C++，这使得它可以在脱离python的环境中运行，这个特性增强了其灵活性。为了更好学习本书，你需要以下两个条件：有一定的Python编程经验。我们不打算在教授python上花费时间；你需要了解 Python 数据类型、类、浮点数字等。愿意深入研究并动手操作。我们将从基础开始，积累我们的工作知识，如果你跟着我们一起学习，你会学得更轻松。本书专注于实用的PyTorch，目的是覆盖足够的领域，让你能够用深度学习解决现实世界的机器学习问题，比如在视觉领域，或者探索研究文献中出现的新模型。与深度学习研究相关的大部分文献都可以在https://arxiv.org中找到。PyTorch如何支持深度学习项目让我们通过研究深度学习项目需要从pytoch中获得什么，来更好地了解PyTorch的构成。首先，PyTorch的 "Py "如同Python中的 "Py"，但其中有很多非Python代码。其实出于性能考虑，PyTorch的大部分代码都是用C++和CUDA（www.geforce. com/hardware/technology/cud）编写的，CUDA是NVIDIA公司的一种类似C++的语言，编译后可以在GPU上大规模并行运行。有一些方法可以直接从C++语言中运行PyTorch，我们将在第15章中讨论这些方法。这个功能的动机之一是为在生产中部署模型提供一个可靠的策略。然而，大多数情况下，我们会从Python中与PyTorch交互，构建模型，训练模型，并使用训练好的模型来解决实际问题。事实上，PyTorch 在可用性和与更广泛的 Python 生态系统集成方面的优势在于 Python API。正如我们已经提到的，PyTorch 的核心是一个提供多维数组的库，也就是 PyTorch 中的 tensors（我们将在第 3 章中详细介绍），以及 PyTorch 模块提供的大量操作库。在PyTorch中，将计算从CPU转移到GPU上，只需要一两个额外的函数调用即可。PyTorch 提供的第二项核心功能是，张量跟踪对其执行的操作，并分析计算输出相对于任何输入的导数，这是用于数值优化的。通过拥有 tensors 和支持 autograd 的张量标准库，PyTorch 可用于物理、渲染、优化、模拟、建模等，我们很有可能看到 PyTorch 以创造性的方式应用于各种科学应用中。但 PyTorch 首先是一个深度学习库，因此它提供了构建神经网络并对其进行训练所需的所有构件。图1.2显示了一个标准设置，它加载数据、训练模型，然后将该模型部署到生产中。 图1.2：PyTorch项目结构示意图，包括加载数据、训练与应用用于构建神经网络的PyTorch核心模块位于torch.nn中，它提供了常见的神经网络层和其他架构组件。全连接层、卷积层、激活函数和损失函数都可以在这里找到（我们将在本书的其他部分详细介绍这些内容）。 这些组件可以用来构建和初始化我们在图 1.2 中看到的未训练模型。为了训练我们的模型，我们需要一些额外的东西：一个训练数据的来源，一个使模型适应训练数据的优化器，以及一个将模型和数据送到硬件的方法，这些硬件将实际执行训练模型所需的计算。在图 1.2 左侧，我们可以看到，在训练数据到达我们的模型之前，需要进行相当多的数据处理。首先，我们需要获取数据。然后，我们需要将数据中的每个样本转换为PyTorch能够实际处理的东西：tensors。在自定义数据（无论其格式如何）和标准化的 PyTorch tensors之间的桥梁就是 torch.utils.data 中 提供的 Dataset 类。由于这个过程在不同的问题上有很大的不同，所以我们必须自己实现这个数据源。我们将在第 4 章中详细研究如何将我们可能要处理的各种类型的数据表示为 tensors。由于数据存储往往很慢，特别是由于访问延迟，我们希望能并行化数据加载。但是由于Python的许多优点并不包括简单、高效、并行处理，因此我们需要多个进程来加载数据，以便将它们组合成批处理：包含多个样本的 tensors。这一点比较复杂，但由于它也比较通用，PyTorch在Dataloader类中提供了所有这些神奇的功能。它的实例可以在后台生成子进程来加载数据集的数据，这样，只要训练循环可以使用它，它就可以准备好并等待训练循环。我们将在第7章认识和使用Dataset和DataLoader。有了获取批量样本的机制，我们可以转向图1.2中心的训练循环本身。通常情况下，训练循环是作为一个标准的Python for循环来实现的。在最简单的情况下，模型在本地CPU或单个GPU上运行所需的计算，一旦训练循环有了数据，就可以立即开始计算。这有可能也是你的基本设置，我们在本书中也会假设这样的设置。在训练循环的每一步，我们都会对我们从数据加载器中得到的样本评估我们的模型。然后，我们使用一些准则或损失函数将模型的输出与期望的输出（目标）进行比较。正如它提供了用于构建模型的组件一样，PyTorch 也提供了各种损失函数供我们使用。它们也在 torch.nn 中提供。当我们用损失函数将实际输出与理想值进行比较后，我们需要稍微推动模型，使其输出更接近目标值。如前所述，这就是 PyTorch autograd 引擎的作用；但我们还需要一个优化器来进行更新，这就是 torch.optim 中提供的优化器。我们将在第 5 章中开始研究使用损失函数和优化器的训练循环，然后在第 6 章到第 8 章中磨练我们的技能，然后开始我们的大项目，即第二部分。使用更复杂的硬件，如多个GPU或多台机器，为训练一个大型模型贡献自己的资源，越来越常见，如图1.2底部中心所示。在这些情况下，torch.nn. parallel。DistributedDataParallel和torch.distributed子模块可以被用来使用额外的硬件。训练循环可能是深度学习项目中最不精彩却最耗时的部分。在它结束时，我们得到了一个模型，它的参数已经在我们的任务上进行了优化：图中训练环路右侧描绘的训练模型。有一个模型来解决任务是很好的，但为了让它有用，我们必须把它放在需要工作的地方。如图1.2所示，这个部署部分可能涉及到将模型放在服务器上，或者将其导出加载到云引擎上，如图所示。或者我们可能会将它与一个更大的应用集成，或者在手机上运行。部署工作的一个特殊步骤可以是导出模型。如前所述，PyTorch默认为即时执行模式（eager mode）。每当Python解释器执行一条涉及PyTorch的指令时，相应的操作就会立即由底层的C++或CUDA实现执行。随着对 tensors 进行操作的指令增多，后端实现会执行更多的操作。PyTorch还提供了一种通过TorchScript提前编译模型的方法。使用TorchScript，PyTorch可以将模型序列化为一组指令，这些指令可以独立于Python:比如说，从C++程序或移动设备上调用。我们可以将其视为一个虚拟机，它有一个有限的指令集，特定于张量运算。这使得我们可以导出我们的模型，既可以作为TorchScript与PyTorch运行时一起使用，也可以以一种叫做ONNX的标准格式导出。这些功能是 PyTorch 生产部署能力的基础。我们将在第 15 章中介绍。硬件与软件要求本书需要对涉及大量数值计算的任务进行编码和运行，如大量矩阵的乘法。事实证明，在新的数据上运行一个预先训练好的网络，是任何一台最近的笔记本电脑或个人计算机都能做到的。即使是把一个预训练的网络，重新训练一小部分，使其在新的数据集上专业化，也不一定需要专门的硬件。你可以使用标准的个人计算机或笔记本电脑来跟进我们在本书部分内容中所做的一切。然而，我们预计，完成第2部分中更高级的例子的完整训练运行将需要支持CUDA的GPU。第二部分中使用的默认参数假定GPU的内存为8GB（我们建议使用NVIDIA GTX 1070或更高），但如果你的硬件的可用内存较少，这些参数可以调整。如果你愿意等待的话，这样的硬件不是必须的，但是在GPU上运行可以将训练时间缩短至少一个数量级（通常会快40-50倍）。 单独来看，计算参数更新所需的操作在现代硬件上是很快的（从几分之一秒到几秒），比如典型的笔记本电脑CPU。问题是，训练需要反复运行这些操作，很多很多次，逐步更新网络参数，以减少训练误差。最后一个考虑因素是操作系统（OS）。 PyTorch从第一个版本开始就支持Linux和macOS，并在2018年支持Windows。由于目前的苹果笔记本电脑不包含支持CUDA的GPU，因此PyTorch的macOS预编译包只支持CPU。在本书中，我们将尽量避免假设您正在运行特定的操作系统，尽管第2部分中的一些脚本显示为在Linux下从Bash提示符运行。这些脚本的命令行应该很容易转换为与Windows兼容的形式。为了方便起见，在可能的情况下，代码会以在Jupyter Notebook上运行的方式列出。
在量子位上看到了这个AI，觉得蛮有意思的，体验下来准确率也不错，分享给大家。开源代码地址：https://github.com/KichangKim/DeepDanbooru在线测试地址：http://kanotype.iptime.org:8003/deepdanbooru/数据集下载工具：https://github.com/KichangKim/DanbooruDownloader该程序使用python 3.6与tensorflow编写数据集与结构作者使用的数据集是danbooru网站上爬下来的数据，该网站对每个二次元画像都贴上了标签，因此将标签与图片一一对应，存到数据库（sqlite）中，就可以进行训练了。你可以使用作者提供的一个下载器（有releases版本，无需编译），自动生成所需要的数据集，也可以使用自己的数据集，但是文件夹结构必须如下：MyDataset/ ├── images/ │ ├── 00/ │ │ ├── 00000000000000000000000000000000.jpg │ │ ├── ... │ ├── 01/ │ │ ├── ... │ └── ff/ │ ├── ... └── my-dataset.sqlite其中，sqlite数据库结构如下：posts ├── id (INTEGER) ├── md5 (TEXT) ├── file_ext (TEXT) ├── tag_string (TEXT) └── tag_count_general (INTEGER)这里需要注意一点，MyDataset中的图片名一定要是[md5].[file_ext]格式，使用自己的数据集时不必使用真实的md5值，但也要保证一一对应。tag_count_general用于项目设置，会使用超过tag_count_general值的数据进行训练。项目(project)结构如下，可调整project.json里的各项设置进行训练。MyProject/ ├── project.json └── tags.txt当然，如果你只是想体验一下不想训练，作者提供了一个在线测试网站，你可以直接上传图片获取标签。或者，作者也提供了一个预训练的模型，可以直接使用。详细使用过程见下文。环境配置首先我们要配置好环境，我们直接下载release版本即可，其中deepdanbooru-v1-20191108-sgd-e30.zip是预训练模型，Source code是源代码，前一个是可选的。但是因为我太懒了电脑性能不够好，所以直接使用他的模型了。据量子位说作者用的是Ryzen 7 1800X + Geforce 1070，花了28天才训练成功，我觉得我电脑撑不住……把源代码下载下来，解压缩并重命名为DeepDanbooru，然后cd到该文件夹。因为第一个发行版本作者并没有把requirements.txt打包进去，所以我们需要手动新建一个txt文档，把以下内容复制进去Click>=7.0 numpy>=1.16.2 scikit-image>=0.15.0 tensorflow>=2.1.0rc1 requests>=2.22.0 six>=1.13.0然后运行pip install -r requirements.txt就可以安装所有需要的包了，当然，你也可以手动一个个pip安装。 安装tensorflow时可能会遇到这两个错误，使用相应的解决方案即可。ERROR: google-auth 1.10.0 has requirement setuptools>=40.3.0, but you'll have setuptools 39.1.0 which is incompatible. # 错误代码，原因是setuptools版本过低，升级一下即可 pip install --upgrade setuptools # 解决方案 ERROR: Cannot uninstall 'wrapt'. It is a distutils installed project and thus we cannot accurately determine which files belong to it which would lead to only a partial uninstall. # 错误代码 pip install -U --ignore-installed wrapt enum34 simplejson netaddr # 解决方案然后单独安装tensorflowpip install tensorflowok，环境已经基本配置完毕。接下来就是添加数据集文件夹和项目文件夹。为了图省事，我们就直接把预训练模型deepdanbooru-v1-20191108-sgd-e30.zip解压到根文件夹，并重命名为project，不需要数据集了（毕竟也不需要训练）然后新建一个test文件夹，作为待检测妹子图存放地，随便放两张进去，运行下面的代码：# python program.py evaluate-project [your_project_folder] [image_file_path] python program.py evaluate-project project test即可享用。如果你机器够厉害也够有耐心，也可以自己训练，具体来说就是按照第一节所说的在根目录下添加一个数据集文件夹，然后修改project/project.json中的database_path这一项，运行下面代码：# python program.py train-project [your_project_folder] python program.py train-project project就可以进行训练。使用选了两张图，运行代码测试一下。等待了7、8秒的样子（看来CPU在这方面真的不行啊），结果如下（左边是测试图片，右边是结果）：[album type="photos"][ ][/album][album type="photos"][ ][/album]这标签打得还是挺详细的，准确率也蛮高。比如场景元素：katana（武士刀），black_legwear（黑板）；服饰：black_legwear（黑色腿袜），school_uniform（学校制服）；发型：ponytail（马尾辫）；身体：upper_body（上半身）、，crossed_legs（交叠的双腿）；动作：unsheathing（出鞘），sitting；表情：smile；甚至还能识别出signature（签名）。但是第一张图识别成男性是不是有点离谱？在作者的在线测试网站上测试了一下 好吧，置信度更高了，而且标签也变少了……大概网站用的是最早一版训练模型？看评论区，有人说这个模型大概给服饰分配的权重特别高，而面容的权重不高，所以大概女装大佬会难到AI吧。不过想想现在动漫里画女硬说男的现象也不少见，根据面容来判断性别似乎也有一定问题。（不是很懂你们二次元.jpg）后续：突然发现这个模型对于风景图识别也挺好的，感觉可以用来给我收集的其他图片全贴上标签啊。
这一系列文章均来自Victor Zhou，他对神经网络的讲解，让我这个数学基础贼差的人也能够很容易地理解，所以决定进行翻译，作为我机器学习的入门教程吧。作者后续发表的相关文章均会汇总到这里。如果我在机器学习领域发现了其他能够补充的材料，也会放到这里。[scode type="red"]提醒：译者英语水平和机器学习水平都很有限，有能力的同学最好去看原文，如果您发现哪里有翻译错误，欢迎留言指出。[/scode] 这个四篇系列文章特别针对初学者，为理解神经网络提供了一种面向基本原理的方法。在深入研究两种流行的变体：递归神经网络（RNN）和卷积神经网络（CNN）之前，我们将为初学者提供经典神经网络的介绍。对于每种类型的网络，我们将：查看网络的结构。了解使用该类型网络的动机。介绍一个可以使用该网络解决的现实问题。手动推导训练我们特定问题的网络所需的梯度。在Python中完全从头开始（仅使用numpy）实现功能全面的网络。知识背景无需机器学习或神经网络知识基础，但是，以下知识背景可以帮助你更快入门：多元微积分，用于得出训练我们的网络所需的梯度时使用。如果您没有该知识背景，可以跳过这些梯度推导。线性代数，特别是矩阵，通常是表示神经网络权重的最佳方法。Python 3，仅需基础水平即可。译者注：虽然作者说仅要求基础，但是还是建议大家（1）掌握python序列、函数、类、循环、条件等相关概念，尤其是类，其实我觉得它已经超出了基础的范畴……（2）掌握numpy库，这个熟悉一遍了解各个方法的作用就行，我当时是专门学了一遍的，不过被秩轴位的概念搞疯了……（3）如上所述，需要你掌握多元微积分，不过这个好像高中就讲过了，至少我一个文科生都懂；线性代数，回去翻翻教材吧……译者在学python和numpy的时候做了笔记，你可以参考：[post url="https://wiki.sitstars.com//wiki//编程语言//python//01%20基础%20Python笔记//" title="python基础语法" intro="雁陎做的关于python语法的笔记"][post url="https://wiki.sitstars.com//wiki//编程语言//python//02%20numpy库//" title="numpy基础语法" intro="雁陎做的关于numpy语法的笔记"]系列文章基础-softmax[post cid="45" /]在正式入门神经网络之前，推荐大家先看一下上面这个关于softmax的简要介绍，因为从第二篇文章起就要开始用到，而且是CNN和RNN重要组成部分。阅读顺序如下，主要是根据一些概念出现的顺序来决定的，如果跳读的话很有可能遇到一些不明白的概念，又要回过头看前面的文章。[post cid="44" /][post cid="46" /][post cid="47" /][post cid="48" /]持续更新中……另外我专门为这篇文章写了一篇名词手册，包含了中英文对照，您可随时查阅：[post cid="49" /]更深入地学习想要更深入地学习？可以参考以下内容：使用Keras构建您的第一个神经网络。学习合适的机器学习库，例如更大/更好的神经网络Tensorflow，Keras和PyTorch。尝试使用神经网络来参与Kaggle数据科学竞赛。斯坦福大学CS231n（关于CNN）课程的笔记。Coursera上有许多优秀的神经网络。
本文记录了机器学习中所有可能遇到的概念，按先后顺序排列，同时列出英文以便查询。在部分概念下列有解释，也有超链接跳转。随时更新中……[scode type="green"]20200714更新：发现一个机器学习术语表，值得收藏：https://aminer.cn/ml_taxonomy[/scode]基础篇神经元：Neurons神经网络中，连接输入和输出的节点就是神经元，它接受给定的输入，进行运算后输出给下一层。权重：weight偏置：biasBias是 “用所有可能的训练数据集训练出的所有模型的输出的平均值” 与 “真实模型”的输出值之间的差异；方差：VarianceVariance是“不同的训练数据集训练出的模型”的输出值之间的差异。偏置与方差的解释来自知乎@J JR梯度：gradient梯度的本意是一个向量（矢量），表示某一函数在该点处的方向导数沿着该方向取得最大值，即函数在该点处沿着该方向（此梯度的方向）变化最快，变化率最大梯度与方向导数密切相关，想了解更多请点击名词上的超链接。激活函数：activation function简单来说，就是上层输入节点与下层输出节点之间的函数关系，它定义了该节点在给定的输入或输入的集合下的输出S型激活函数：sigmoid activation function将无界的输入转化为(0,1)输出的函数前馈神经网络：feedforward neural network前馈神经网络是一种最简单的神经网络，各神经元分层排列。每个神经元只与前一层的神经元相连。接收前一层的输出，并输出给下一层．各层间没有反馈。均方误差：mean squared error，MSE损失函数：loss functions反向传播算法：Backpropagation，BP反向传播是“误差反向传播”的简称，该方法对网络中所有权重计算损失函数的梯度。这个梯度会反馈给最优化方法，用来更新权值以最小化损失函数。随机梯度下降：Stochastic Gradient Descent，SGD梯度下降：我们知道曲面上方向导数的最大值的方向就代表了梯度的方向，因此我们在做梯度下降的时候，应该是沿着梯度的反方向进行权重的更新，可以有效的找到全局的最优解。随机梯度下降（SGD）也称为增量梯度下降，是一种迭代方法，用于优化可微分目标函数。该方法通过在小批量数据上计算损失函数的梯度而迭代地更新权重与偏置项。学习率：learning rate即参数的更新速率卷积：convolution两个函数的加权叠加，用数学公式表示为$(f·g)(n)$连续的定义为：$$( f * g ) ( n ) = \int _ { - \infty } ^ { \infty } f ( \tau ) g ( n - \tau ) d \tau$$离散的定义为：$$( f * g ) ( n ) = \sum _ { \tau = - \infty } ^ { \infty } f ( \tau ) g ( n - \tau )$$卷机层：Convolutional layer卷积核：filter卷积核就是图像处理时，给定输入图像，输入图像中一个小区域中像素加权平均后成为输出图像中的每个对应像素，其中权值由一个函数定义，这个函数称为卷积核。卷积神经网络：Convolution Neural Networks，CNN逐元素乘法：element-wise multiplication索伯算子：Sobel operator也称索贝滤波器，在在影像处理及电脑视觉领域中常被用来做边缘检测。填充：Padding池化：Pooling我们之所以使用卷积后的特征，是因为图像具有“静态型”的属性，也就意味着在一个图像区域的特征极有可能在另一个区域同样适用。所以，当我们描述一个大的图像的时候就可以对不同位置的特征进行聚合统计（例如：可以计算图像一个区域上的某个特定特征的平均值 or 最大值）这种统计方式不仅可以降低纬度，还不容易过拟合。这种聚合统计的操作就称之为池化，或平均池化、最大池化。交叉熵损失：cross-entropy loss交叉熵主要用于度量两个概率分布间的差异性信息。前向（传播）阶段、后向传播阶段：forward phase，backward phase卷积神经网络的训练过程分为两个阶段。第一个阶段是数据由低层次向高层次传播的阶段，即前向传播阶段。另外一个阶段是，当前向传播得出的结果与预期不相符时，将误差从高层次向底层次进行传播训练的阶段，即反向传播阶段。反向传播：backpropagated or backprop递归神经网络：Recurrent Neural Networks，RNN自然语言处理：Natural Language Processing (NLP)独热编码：One hotOne-Hot编码，又称为一位有效编码，主要是采用N位状态寄存器来对N个状态进行编码，每个状态都有他独立的寄存器位，并且在任意时候只有一位有效。基于时间的反向传播算法：Backpropagation Through Time，BPTT隐状态：hidden state爆炸性梯度：exploding gradient即包含大量相乘项时梯度会变得非常大梯度消失问题：Vanishing gradient problem神经网络权重的更新值与误差函数梯度成比例，然而在某些情况下，梯度值会几乎消失，使得权重无法得到有效更新，甚至神经网络可能完全无法继续训练。举例来说，传统的激活函数，如双曲正切函数的梯度值在(0, 1)范围内，而反向传播通过链式法则来计算梯度。 这种做法计算前一层的梯度时，相当于将n个这样小的数字相乘，这就使梯度（误差信号）随n呈指数下降，导致前面的层训练非常缓慢。进阶篇文章中仅仅是提了一句，不懂也不影响的概念：互相关：Cross-correlation长短期记忆：Long Short-Term Memory，LSTM是一种时间递归神经网络（RNN）长短期记忆网络： Long short-term memory一种更强大的RNN框架门控递归单元：Gated Recurrent Units，GRUs)LSTM的一个著名变体双向RNN：Bidirectional RNNs可以处理前向和后向序列词嵌入：Word Embeddings词嵌入是自然语言处理（NLP）中语言模型与表征学习技术的统称。概念上而言，它是指把一个维数为所有词的数量的高维空间嵌入到一个维数低得多的连续向量空间中，每个单词或词组被映射为实数域上的向量。自然语言工具包：Natural Language Toolkit
原文地址为什么使用RNN？递归神经网络（Recurrent Neural Networks，RNN）是一种专门处理序列的神经网络。由于它们在处理文本方面的有效性，因此经常用于自然语言处理（NLP）任务。在本文中，我们将探讨RNN是什么，了解它们的工作原理，并在Python中从头开始构建一个真正的RNN。（仅使用numpy）。这篇文章假定您具有神经网络的基础知识。建议先阅读神经网络基础最原始（vanilla，原意为香草，在机器学习中等同于raw）的神经网络和CNNs存在一个问题：只能接受固定的输入并产生固定的输出。RNN则不同，它可以接受并输出可变长度序列，以下是RNN的一些例子： 输入为红色，RNN本身为绿色，输出为蓝色。资料来源：Andrej Karpathy这种处理序列的能力使RNN非常有用。例如：机器翻译（例如Google Translate）使用“多对多” 型RNN进行。原始文本序列输送到RNN，然后RNN生成翻译后的文本并输出。情绪分析（例如，这是正面还是负面的评价？）通常使用“多对一”的RNN进行。将要分析的文本输入到RNN中，然后生成一个单一的输出分类（例如，这是正面的评价）。在本文的后半部分，我们将构建一个“多对一”式的RNN，实现一个最简单的情感分析功能如何做？让我们考虑一个“多对多”式的RNN，它接受$x _ { 0 } , x _ { 1 } , \dots x _ { n }$的输入，并产生$y _ { 0 } , y _ { 1 } , \dots y _ { n }$的输出，xi和yi可以是任意维度的向量。RNN借助一个迭代更新的 隐状态（hidden state）h来工作，h也是一个任意维度的向量。在任意给定的步长t上，有：下一个 隐状态$h_t$通过先前的 隐状态$h_{t-1}$和下一个输入 $x_t$计算而来。下一个输出$y_t$由$h_t$计算而来。 一个多对多RNN这就是使RNN 反复出现的原因：它对每个步骤使用相同的权重。更具体地说，典型的原始RNN仅使用3组权重来执行其计算：$W_{xh}$用于所有的$x_t\to h_t$链接$W_{hh}$用于所有的$h_{t-1}\to h_t$链接$W_{hy}$用于所有的$h_t\to y_t$链接同时我们为RNN应用两个偏置（biases）：$b_h$，计算$h_t$的时候需要加上它。$b_y$，计算$y_t$的时候需要加上它。我们用矩阵表示权重，用向量表示偏置。这3个权重和2个偏置构成了整个RNN！以下是将所有内容组合在一起的方程式：$$\begin{array} { c } { h _ { t } = \tanh \left( W _ { x h } x _ { t } + W _ { h h } h _ { t - 1 } + b _ { h } \right) } \\ { y _ { t } = W _ { h y } h _ { t } + b _ { y } } \end{array}$$不要跳过这些方程式。停下来思考一下。另外，请记住，权重是矩阵，其他变量是向量。所有的权重都使用矩阵乘法，并且将偏置加到乘积结果中。然后，我们将tanh用作第一个方程式的激活函数（但是也可以使用S型等其他激活函数）。问题我们将从头开始实现RNN，以执行简单的情感分析任务：确定给定的文本字符串是正面的还是负面的。这是我为本文汇总的小型数据集的一部分：TextPositive?i am good✓i am bad❌this is very good✓this is not bad✓i am bad not good❌i am not at all happy❌this was good earlier✓i am not at all bad or sad right now✓计划由于这是一个分类问题，因此我们将使用“多对一” RNN。这类似于我们之前讨论的“多对多” RNN，但是它仅使用最终的 隐状态来输出y 多对一RNN每 个$x_i$将是一个向量，代表文本中的一个单词。输出$y$是一个包含两个数字的向量，一个代表正面，另一个代表负面。我们将用Softmax将这些值转换为概率，并最终确定正负。让我们开始构建我们的RNN！预处理我前面提到的数据集由两个Python字典组成：train_data = { 'good': True, 'bad': False, # ... more data } test_data = { 'this is happy': True, 'i am good': True, # ... more data }True = Positive, False = Negative我们必须进行一些预处理才能将数据转换为可用格式。首先，我们将构建一个包含了数据中所有单词的词汇表：from data import train_data, test_data # Create the vocabulary. vocab = list(set([w for text in train_data.keys() for w in text.split(' ')])) vocab_size = len(vocab) print('%d unique words found' % vocab_size) # 18 unique words foundvocab现在包含训练文本中的所有单词的列表。接下来，我们将分配一个整数索引来表示vocab中的每个单词。# Assign indices to each word. word_to_idx = { w: i for i, w in enumerate(vocab) } idx_to_word = { i: w for i, w in enumerate(vocab) } print(word_to_idx['good']) # 16 (this may change) print(idx_to_word[0]) # sad (this may change)译者注：出现Reloaded modules: data错误是正常的，不影响使用，实在不想看到红字，可以注释掉from data这一句现在，我们可以用其相应的整数索引表示任何给定的单词！这是必要的，因为RNN无法理解单词-我们必须给它们编号。最后，回想一下，每个输入RNN的$x_i$都是向量。我们将使用one-hot向量（即独热编码），每个向量中只含有一个1，其他的全是0。向量中的1位于单词对应的整数索引处。对独热编码不了解的同学，可以参考这篇文章，讲得挺清楚的。由于我们的词汇表中有18个独特的单词，因此每个单词$x_i$将是一个18维的独热矢量。import numpy as np def createInputs(text): ''' Returns an array of one-hot vectors representing the words in the input text string. - text is a string - Each one-hot vector has shape (vocab_size, 1) ''' inputs = [] for w in text.split(' '): v = np.zeros((vocab_size, 1)) v[word_to_idx[w]] = 1 inputs.append(v) return inputs前向阶段是时候应用我们的RNN了！我们将从初始化RNN所需的3个权重和2个偏置开始：import numpy as np from numpy.random import randn class RNN: # A Vanilla Recurrent Neural Network. def __init__(self, input_size, output_size, hidden_size=64): # Weights self.Whh = randn(hidden_size, hidden_size) / 1000 self.Wxh = randn(hidden_size, input_size) / 1000 self.Why = randn(output_size, hidden_size) / 1000 # Biases self.bh = np.zeros((hidden_size, 1)) self.by = np.zeros((output_size, 1))注意：我们将除以1000以减小权重的初始方差。这不是初始化权重的最佳方法，但它很简单并且适用于本文。我们使用np.random.randn()从标准正态分布中初始化权重。接下来，让我们实现RNN的前向传递。还记得我们前面看到的这两个方程吗？$$\begin{array} { c } { h _ { t } = \tanh \left( W _ { x h } x _ { t } + W _ { h h } h _ { t - 1 } + b _ { h } \right) } \\ { y _ { t } = W _ { h y } h _ { t } + b _ { y } } \end{array}$$下面是代码实现：class RNN: # ... def forward(self, inputs): ''' Perform a forward pass of the RNN using the given inputs. Returns the final output and hidden state. - inputs is an array of one hot vectors with shape (input_size, 1). ''' h = np.zeros((self.Whh.shape[0], 1)) # Perform each step of the RNN for i, x in enumerate(inputs): h = np.tanh(self.Wxh @ x + self.Whh @ h + self.bh) # Compute the output y = self.Why @ h + self.by return y, h很简单，对吧？注意我们第一步将h初始化为零向量，因为之前没有h可供我们使用。让我们尝试一下：def softmax(xs): # Applies the Softmax Function to the input array. return np.exp(xs) / sum(np.exp(xs)) # Initialize our RNN! rnn = RNN(vocab_size, 2) inputs = createInputs('i am very good') out, h = rnn.forward(inputs) probs = softmax(out) print(probs) # [[0.50000095], [0.49999905]]如果您需要有关Softmax的复习，请阅读我对Softmax的简单解释我们的RNN可以用，但还不是很有用。让我们改变一下……后向阶段为了训练RNN，我们需要一个损失函数。我们使用交叉熵损失函数（cross-entropy loss），它通常与Softmax相结合。下面是计算它的公式：$$L = - \ln \left( p _ { c } \right)$$其中$p_c$是我们的RNN对正确类别（正面或负面）的预测概率。例如，如果我们的RNN预测正面文本有90％的可能为正面，则损失为：$$L = - \ln \left(0.9 \right)=0.105$$想要更多解释？参考 CNNs中关于交叉熵损失 部分的介绍。现在我们的目标就是使用梯度下降法（gradient descent）训练CNN，以最小化损失。这意味着是时候对梯度求导了！以下部分假定了您有多元微积分的基本知识。您可以根据需要跳过它，但是即使您不太了解，我还是建议您略读一下。Ready? Here we go.定义首先，做一些定义：令y代表我们RNN的原始输出。令p为最终概率：$p = \text{softmax}(y)$。令c为某个文本集的真实标签（也称为“正确”类）。令L为CNN的交叉熵损失：$L = -\ln(p_c)$令$W_{xh}$, $W_{hh}$, 和 $W_{hy}$为RNN中的3个权重矩阵。令$b_h$ 和 $b_y$为RNN的2个偏置向量。设定接下来，我们需要编辑前向阶段以缓存一些数据，供后向阶段使用。在此过程中，我们还将为我们的后向阶段设置框架（skeleton）。代码如下：# Header: rnn.py class RNN: # ... def forward(self, inputs): ''' Perform a forward pass of the RNN using the given inputs. Returns the final output and hidden state. - inputs is an array of one hot vectors with shape (input_size, 1). ''' h = np.zeros((self.Whh.shape[0], 1)) # highlight-start self.last_inputs = inputs self.last_hs = { 0: h } # highlight-end # Perform each step of the RNN for i, x in enumerate(inputs): h = np.tanh(self.Wxh @ x + self.Whh @ h + self.bh) self.last_hs[i + 1] = h # highlight-line # Compute the output y = self.Why @ h + self.by return y, h # highlight-start def backprop(self, d_y, learn_rate=2e-2): ''' Perform a backward pass of the RNN. - d_y (dL/dy) has shape (output_size, 1). - learn_rate is a float. ''' pass # highlight-end对为什么要缓存感到疑惑？我们在CNN的训练概述 中介绍过，这里的原因类似。梯度数学时间到！我们从计算 $\frac{\partial L}{\partial y}$开始，我们知道：$$ L = -\ln(p_c) = -\ln(\text{softmax}(y_c)) $$我将保留实际的使用链式法则的求导 $\frac{\partial L}{\partial y}$作为你的练习，但结果非常好：$$ \frac{\partial L}{\partial y_i} = \begin{cases} p_i & \text{if $i \neq c$} \\ p_i - 1 & \text{if $i = c$} \\ \end{cases} $$例如，如果我们有 $p = [0.2, 0.2, 0.6]$ ，正确的组是 $c = 0$, 那么有 $\frac{\partial L}{\partial y} = [-0.8, 0.2, 0.6]$. 代码实现也非常简单：# Header: main.py # Loop over each training example for x, y in train_data.items(): inputs = createInputs(x) target = int(y) # Forward out, _ = rnn.forward(inputs) probs = softmax(out) # Build dL/dy # highlight-start d_L_d_y = probs d_L_d_y[target] -= 1 # highlight-end # Backward rnn.backprop(d_L_d_y) # highlight-line非常好，接下来我们探讨$W_{hy}$和 $b_y$的梯度，它们仅用于将最终的隐状态（hidden state）转换为RNN的输出。我们有：$$ \frac{\partial L}{\partial W_{hy}} = \frac{\partial L}{\partial y} * \frac{\partial y}{\partial W_{hy}} $$$$ y = W_{hy} h_n + b_y $$其中 $h_n$ 是最终的隐状态。$$ \frac{\partial y}{\partial W_{hy}} = h_n $$$$ \frac{\partial L}{\partial W_{hy}} = \boxed{\frac{\partial L}{\partial y} h_n} $$类似地，$$ \frac{\partial y}{\partial b_y} = 1 $$$$ \frac{\partial L}{\partial b_y} = \boxed{\frac{\partial L}{\partial y}} $$我们现在可以实现backprop()了!# Header: rnn.py class RNN: # ... def backprop(self, d_y, learn_rate=2e-2): ''' Perform a backward pass of the RNN. - d_y (dL/dy) has shape (output_size, 1). - learn_rate is a float. ''' n = len(self.last_inputs) # Calculate dL/dWhy and dL/dby. # highlight-start d_Why = d_y @ self.last_hs[n].T d_by = d_y # highlight-end提醒:在之前的 forward() 我们已经创建了self.last_hs .最后，我们需要 $W_{hh}$, $W_{xh}$,和 $b_h$的梯度, 它们被用于RNN的每一步：$$ \frac{\partial L}{\partial W_{xh}} = \frac{\partial L}{\partial y} \sum_t \frac{\partial y}{\partial h_t} * \frac{\partial h_t}{\partial W_{xh}} $$因为改变$W_{xh}$ 会影响到每一个 $h_t$, 它们都会影响y和最终的L。为了完全计算$W_{xh}$的梯度, 我们需要通过所有的时间步（timesteps）进行反向传播, 这种方法被称为”基于时间的反向传播算法“（Backpropagation Through Time，BPTT）: 基于时间的反向传播算法$W_{xh}$用于所有 $x_t→h_t$的前向链接，因此我们必须反向传播回每个链接。一旦我们到达给定的步骤t，我们需要计算$\frac{\partial h_t}{\partial W_{xh}}$：$$ h_t = \tanh (W_{xh} x_t + W_{hh} h_{t-1} + b_h) $$$\tanh$ 的导数众所周知:$$ \frac{d \tanh(x)}{dx} = 1 - \tanh^2(x) $$同样进行链式求导：$$ \frac{\partial h_t}{\partial W_{xh}} = \boxed{(1 - h_t^2) x_t} $$类似地，$$ \frac{\partial h_t}{\partial W_{hh}} = \boxed{(1 - h_t^2) h_{t-1}} $$$$ \frac{\partial h_t}{\partial b_h} = \boxed{(1 - h_t^2)} $$我们需要的最后一个导数是 $\frac{\partial y}{\partial h_t}$. 我们可以递归地计算它:$$ \begin{aligned} \frac{\partial y}{\partial h_t} &= \frac{\partial y}{\partial h_{t+1}} * \frac{\partial h_{t+1}}{\partial h_t} \\ &= \frac{\partial y}{\partial h_{t+1}} (1 - h_t^2) W_{hh} \\ \end{aligned} $$我们从最后一个隐状态开始实施BPTT并后向工作, 所以在我们想计算$\frac{\partial y}{\partial h_t}$时我们已经有了 $\frac{\partial y}{\partial h_{t+1}}$ ! 最后一个隐状态, $h_n$是例外:$$ \frac{\partial y}{\partial h_n} = W_{hy} $$我们现在有实现BTPP的一切条件，代码如下：# Header: rnn.py class RNN: # ... def backprop(self, d_y, learn_rate=2e-2): ''' Perform a backward pass of the RNN. - d_y (dL/dy) has shape (output_size, 1). - learn_rate is a float. ''' n = len(self.last_inputs) # Calculate dL/dWhy and dL/dby. d_Why = d_y @ self.last_hs[n].T d_by = d_y # Initialize dL/dWhh, dL/dWxh, and dL/dbh to zero. d_Whh = np.zeros(self.Whh.shape) d_Wxh = np.zeros(self.Wxh.shape) d_bh = np.zeros(self.bh.shape) # Calculate dL/dh for the last h. d_h = self.Why.T @ d_y # Backpropagate through time. for t in reversed(range(n)): # An intermediate value: dL/dh * (1 - h^2) temp = ((1 - self.last_hs[t + 1] ** 2) * d_h) # dL/db = dL/dh * (1 - h^2) d_bh += temp # highlight-line # dL/dWhh = dL/dh * (1 - h^2) * h_{t-1} d_Whh += temp @ self.last_hs[t].T # highlight-line # dL/dWxh = dL/dh * (1 - h^2) * x d_Wxh += temp @ self.last_inputs[t].T # highlight-line # Next dL/dh = dL/dh * (1 - h^2) * Whh d_h = self.Whh @ temp # Clip to prevent exploding gradients. for d in [d_Wxh, d_Whh, d_Why, d_bh, d_by]: np.clip(d, -1, 1, out=d) # Update weights and biases using gradient descent. self.Whh -= learn_rate * d_Whh self.Wxh -= learn_rate * d_Wxh self.Why -= learn_rate * d_Why self.bh -= learn_rate * d_bh self.by -= learn_rate * d_by注意事项：为了方便，我们合并$\frac{\partial L}{\partial y} * \frac{\partial y}{\partial h}$ 为 $\frac{\partial L}{\partial h}$ .我们不断更新变量 d_h ，它包含最新的 $\frac{\partial L}{\partial h_{t+1}}$, 我们需要计算 $\frac{\partial L}{\partial h_t}$.完成BPTT后, 我们使用np.clip() 方法对小于-1或大于1的梯度值进行裁剪，这有助于缓解爆炸性梯度（exploding gradient）问题，即包含大量相乘项时梯度会变得非常大。梯度消失问题对于原始RNN来说是一个很大的问题-像LSTM这样的更复杂的RNN通常能更容易地解决这些问题。计算完所有梯度后，我们将使用梯度下降更新权重和偏差。我们的RNN完成了！测试首先，我们将编写一个辅助函数来使用RNN处理数据：# Header: main.py import random def processData(data, backprop=True): ''' Returns the RNN's loss and accuracy for the given data. - data is a dictionary mapping text to True or False. - backprop determines if the backward phase should be run. ''' items = list(data.items()) random.shuffle(items) loss = 0 num_correct = 0 for x, y in items: inputs = createInputs(x) target = int(y) # Forward out, _ = rnn.forward(inputs) probs = softmax(out) # Calculate loss / accuracy loss -= np.log(probs[target]) num_correct += int(np.argmax(probs) == target) if backprop: # Build dL/dy d_L_d_y = probs d_L_d_y[target] -= 1 # Backward rnn.backprop(d_L_d_y) return loss / len(data), num_correct / len(data)现在，我们可以编写训练循环：# Header: main.py # Training loop for epoch in range(1000): train_loss, train_acc = processData(train_data) if epoch % 100 == 99: print('--- Epoch %d' % (epoch + 1)) print('Train:\tLoss %.3f | Accuracy: %.3f' % (train_loss, train_acc)) test_loss, test_acc = processData(test_data, backprop=False) print('Test:\tLoss %.3f | Accuracy: %.3f' % (test_loss, test_acc))运行main.py应该输出如下内容：--- Epoch 100 Train: Loss 0.688 | Accuracy: 0.517 Test: Loss 0.700 | Accuracy: 0.500 --- Epoch 200 Train: Loss 0.680 | Accuracy: 0.552 Test: Loss 0.717 | Accuracy: 0.450 --- Epoch 300 Train: Loss 0.593 | Accuracy: 0.655 Test: Loss 0.657 | Accuracy: 0.650 --- Epoch 400 Train: Loss 0.401 | Accuracy: 0.810 Test: Loss 0.689 | Accuracy: 0.650 --- Epoch 500 Train: Loss 0.312 | Accuracy: 0.862 Test: Loss 0.693 | Accuracy: 0.550 --- Epoch 600 Train: Loss 0.148 | Accuracy: 0.914 Test: Loss 0.404 | Accuracy: 0.800 --- Epoch 700 Train: Loss 0.008 | Accuracy: 1.000 Test: Loss 0.016 | Accuracy: 1.000 --- Epoch 800 Train: Loss 0.004 | Accuracy: 1.000 Test: Loss 0.007 | Accuracy: 1.000 --- Epoch 900 Train: Loss 0.002 | Accuracy: 1.000 Test: Loss 0.004 | Accuracy: 1.000 --- Epoch 1000 Train: Loss 0.002 | Accuracy: 1.000 Test: Loss 0.003 | Accuracy: 1.000看起来不错！是否想亲自尝试或修改此代码？在浏览器中运行此RNN。在Github上也可获得这段代码。尾声在这篇文章中，我们介绍了基础的递归神经网络，包括它们是什么，它们如何工作，它们为什么有用，如何训练它们以及如何代码实现。您可以更深入地学习：了解长短期记忆网络（ Long short-term memory）, 一个更强大更流行的RNN框架，或者了解门控递归单元 ( Gated Recurrent Units，GRUs), （LSTM的一个著名变体）的信息。尝试使用ML库，例如 Tensorflow，Keras，或PyTorch。阅读有关双向RNN（Bidirectional RNNs）的信息，该RNN可以处理前向和后向序列，以输出层可以获得更多信息尝试字嵌入（Word Embeddings）如GloVe或Word2Vec，其可以用于将字符转换为转换为更有用的矢量形式。查看自然语言工具包（Natural Language Toolkit，NLTK），这是一个流行的Python库，用于处理人类语言数据。
原文地址在本文中，我们将深入探讨卷积神经网络（CNN）：如何训练CNN（包括梯度推导），从头实现反向传播（仅使用numpy）以及最终构建完整的训练！本文假定你对CNNs有基础了解，如果否，请参考CNN01本部分同时也要求你有多元微分的知识. 您可以根据需要跳过这些部分，但还是建议阅读它们，对写代码有用。开始我们从上一篇文章停下来的地方开始，我们正在使用CNN来解决MNIST手写数字识别问题： 来自MNIST数据集的样本图像我们（简单的）CNN由一个卷机层（Conv layer），一个最大池化层（Max Pooling layer）和一个Softmax层组成。下面是一个简单的示意图： Our (simple) CNN consisted of a Conv layer, a Max Pooling layer, and a Softmax layer. Here's that diagram of our CNN again:我们写了三个类, 分别是这三个层： Conv3x3, MaxPool, 和 Softmax. 每个类都有一个 forward() ，一种用于构建CNN前向传递的方法# Header: cnn.py conv = Conv3x3(8) # 28x28x1 -> 26x26x8 pool = MaxPool2() # 26x26x8 -> 13x13x8 softmax = Softmax(13 * 13 * 8, 10) # 13x13x8 -> 10 def forward(image, label): ''' Completes a forward pass of the CNN and calculates the accuracy and cross-entropy loss. - image is a 2d numpy array - label is a digit ''' # We transform the image from [0, 255] to [-0.5, 0.5] to make it easier # to work with. This is standard practice. out = conv.forward((image / 255) - 0.5) out = pool.forward(out) out = softmax.forward(out) # Calculate cross-entropy loss and accuracy. np.log() is the natural log. loss = -np.log(out[label]) acc = 1 if np.argmax(out) == label else 0 return out, loss, acc你可以阅读代码或 在浏览器中运行. 也可以在Github上找到它.下面是CNN的输出：MNIST CNN initialized! [Step 100] Past 100 steps: Average Loss 2.302 | Accuracy: 11% [Step 200] Past 100 steps: Average Loss 2.302 | Accuracy: 8% [Step 300] Past 100 steps: Average Loss 2.302 | Accuracy: 3% [Step 400] Past 100 steps: Average Loss 2.302 | Accuracy: 12%显然，我们希望能有10%以上的准确度……让我们训练CNN。训练概述训练神经网络通常包括两个阶段：一个前向阶段，其中输入完全通过神经网络。一个后向阶段，其中梯度被反向传播（backpropagated or backprop)，而且权重不断更新。我们将遵循这种模式来训练我们的CNN。我们还将使用两个主要特定于实现的想法：在前向阶段，每一层将缓存（cashe）任何后向阶段所需的任何数据（例如输入，中间值等）。这意味着任何后向阶段都必须在相应的前向阶段之前。在后向阶段，每一层将接收一个梯度（gradient）并且还返回一个梯度。它将获得相对于其输出的损失梯度 ($\frac{\partial L}{\partial \text{out}}$) ，并返回相对于其输入的损失梯度（$\frac{\partial L}{\partial \text{in}}$​).这两个想法将有助于保持我们训练的整洁有序。了解原因的最好方法可能是查看代码：# Feed forward out = conv.forward((image / 255) - 0.5) out = pool.forward(out) out = softmax.forward(out) # Calculate initial gradient gradient = np.zeros(10) # ... # Backprop gradient = softmax.backprop(gradient) gradient = pool.backprop(gradient) gradient = conv.backprop(gradient)看，我们的代码是不是干净多了？现在想象一下，建立一个由50层而不是3层组成的网络-他甚至比良好的系统更有价值反向传播（Backprop）: Softmax首先，回顾一下交叉熵损失（cross-entropy loss）：$$ L = -\ln(p_c) $$其中 $p_c$是正确组c（换句话说，我们当前图像的实际数字是多少）的预测概率。需要更多解释？回顾前一篇文章的Cross-Entropy Loss 。我们需要计算的第一件事是反向阶段Softmax层的输入， $\frac{\partial L}{\partial out_s}$，其中$out_s$是Softmax层的输出：10个概率组成的向量。这很容易，因为只有$p_i$出现在损耗方程中：$$ \frac{\partial L}{\partial out_s(i)} = \begin{cases} 0 & \text{if $i \neq c$} \\ -\frac{1}{p_i} & \text{if $i = c$} \\ \end{cases} $$提醒：c是正确的组。这就是您看到的初始梯度。# Calculate initial gradient gradient = np.zeros(10) gradient[label] = -1 / out[label]我们几乎已经准备好实现我们的第一个反向阶段-只需要首先执行前面讨论的前向阶段缓存即可：# Header: softmax.py class Softmax: # ... def forward(self, input): ''' Performs a forward pass of the softmax layer using the given input. Returns a 1d numpy array containing the respective probability values. - input can be any array with any dimensions. ''' self.last_input_shape = input.shape # highlight-line input = input.flatten() self.last_input = input # highlight-line input_len, nodes = self.weights.shape totals = np.dot(input, self.weights) + self.biases self.last_totals = totals # highlight-line exp = np.exp(totals) return exp / np.sum(exp, axis=0)我们在此处缓存3件事，这对于实现后向阶段非常有用：降维（ flatten）之前，input的形状（shape）。降维之后的input。总数，这是给SOFTMAX激活函数传的值。有了这些，我们就可以开始推导反向传播阶段的梯度。我们已经得到了Softmax反向阶段的梯度：$\frac{\partial L}{\partial out_s}$​。有这样一个事实： $\frac{\partial L}{\partial out_s}$只有一个非零项c，即正确组。这意味着我们可以忽略一切，除了 $out_s(c)$！首先，计算$out_s(c)$（传递给softmax激活函数的值）相对于总数的的梯度。令第i组的总数为$t_i$ ，然后 $out_s(c)$ 可以表示为:$$ out_s(c) = \frac{e^{t_c}}{\sum_i e^{t_i}} = \frac{e^{t_c}}{S} $$其中 $S = \sum_i e^{t_i}$.需要复习Softmax？阅读我的 Softmax简易介绍.然后考虑第k组（$k \neq c$）. $out_s(c)$ 可以表示为:$$ out_s(c) = e^{t_c} S^{-1} $$使用链式法则求导$$ \begin{aligned} \frac{\partial out_s(c)}{\partial t_k} &= \frac{\partial out_s(c)}{\partial S} (\frac{\partial S}{\partial t_k}) \\ &= -e^{t_c} S^{-2} (\frac{\partial S}{\partial t_k}) \\ &= -e^{t_c} S^{-2} (e^{t_k}) \\ &= \boxed{\frac{-e^{t_c} e^{t_k}}{S^2}} \\ \end{aligned} $$记住，这里假设 $k \neq c$. 现在我们同样可以对c求导, 这次要使用 除法法则 (因为$e^{t_c}$在 $out_s(c)$的分子上):$$ \begin{aligned} \frac{\partial out_s(c)}{\partial t_c} &= \frac{S e^{t_c} - e^{t_c} \frac{\partial S}{\partial t_c}}{S^2} \\ &= \frac{Se^{t_c} - e^{t_c}e^{t_c}}{S^2} \\ &= \boxed{\frac{e^{t_c} (S - e^{t_c})}{S^2}} \\ \end{aligned} $$上面是本文最难的部分，接下来会变得简单。相应代码：# Header: softmax.py class Softmax: # ... def backprop(self, d_L_d_out): ''' Performs a backward pass of the softmax layer. Returns the loss gradient for this layer's inputs. - d_L_d_out is the loss gradient for this layer's outputs. ''' # We know only 1 element of d_L_d_out will be nonzero for i, gradient in enumerate(d_L_d_out): if gradient == 0: continue # e^totals t_exp = np.exp(self.last_totals) # Sum of all e^totals S = np.sum(t_exp) # Gradients of out[i] against totals d_out_d_t = -t_exp[i] * t_exp / (S ** 2) d_out_d_t[i] = t_exp[i] * (S - t_exp[i]) / (S ** 2) # ... to be continued记住 $\frac{\partial L}{\partial out_s}$ 只有正确组才是唯一的非零项。 We start by looking for $c$ by looking for a nonzero gradient in 我们开始通过在d_L_d_out寻找非零梯度来找到这个c. 一旦我们找到了，就可以使用上面的结果来计算梯度$\frac{\partial out_s(i)}{\partial t}$ (d_out_d_totals)：$$ \frac{\partial out_s(k)}{\partial t} = \begin{cases} \frac{-e^{t_c} e^{t_k}}{S^2} & \text{if $k \neq c$} \\ \frac{e^{t_c} (S - e^{t_c})}{S^2} & \text{if $k = c$} \\ \end{cases} $$最终我们希望得到一个关于权重、偏置和输入的损失梯度：我们用权重梯度$\frac{\partial L}{\partial w}$, 更新权重用偏置梯度, $\frac{\partial L}{\partial b}$, 更新偏置.从方法backprop() 返回输入梯度, $\frac{\partial L}{\partial input}$,以便下一层可以使用它。这就是我们在概述一节中所讲的返回梯度！为了计算这三个损失梯度，我们首先需要得到另外三个结果：相对于权重、偏置和输入的总和（total）梯度。相关方程式如下：$$ t = w * input + b $$$$ \frac{\partial t}{\partial w} = input $$$$ \frac{\partial t}{\partial b} = 1 $$$$ \frac{\partial t}{\partial input} = w $$将所有内容放到一起：$$ \frac{\partial L}{\partial w} = \frac{\partial L}{\partial out} * \frac{\partial out}{\partial t} * \frac{\partial t}{\partial w} $$$$ \frac{\partial L}{\partial b} = \frac{\partial L}{\partial out} * \frac{\partial out}{\partial t} * \frac{\partial t}{\partial b} $$$$ \frac{\partial L}{\partial input} = \frac{\partial L}{\partial out} * \frac{\partial out}{\partial t} * \frac{\partial t}{\partial input} $$代码实现：# Header: softmax.py class Softmax: # ... def backprop(self, d_L_d_out): ''' Performs a backward pass of the softmax layer. Returns the loss gradient for this layer's inputs. - d_L_d_out is the loss gradient for this layer's outputs. ''' # We know only 1 element of d_L_d_out will be nonzero for i, gradient in enumerate(d_L_d_out): if gradient == 0: continue # e^totals t_exp = np.exp(self.last_totals) # Sum of all e^totals S = np.sum(t_exp) # Gradients of out[i] against totals d_out_d_t = -t_exp[i] * t_exp / (S ** 2) d_out_d_t[i] = t_exp[i] * (S - t_exp[i]) / (S ** 2) # highlight-start # Gradients of totals against weights/biases/input d_t_d_w = self.last_input d_t_d_b = 1 d_t_d_inputs = self.weights # Gradients of loss against totals d_L_d_t = gradient * d_out_d_t # Gradients of loss against weights/biases/input d_L_d_w = d_t_d_w[np.newaxis].T @ d_L_d_t[np.newaxis] d_L_d_b = d_L_d_t * d_t_d_b d_L_d_inputs = d_t_d_inputs @ d_L_d_t # highlight-end # ... to be continued首先，我们预先计算d_L_d_t 因为它将被多次使用。然后，我们计算每个梯度：d_L_d_w：我们需要二维数组做矩阵乘法（@），但d_t_d_w并d_L_d_t是一维数组。np.newaxis使我们可以轻松地创建长度为1的新轴，因此最终将 (input_len, 1) 位矩阵与 (1, nodes)维矩阵相乘。因此，的最终结果d_L_d_w的形状是(input_len, nodes)，与self.weights形状相同！d_L_d_b：因为d_t_d_b是1 ，所以这很简单。d_L_d_inputs：我们将(input_len, nodes) 维矩阵与 (nodes, 1) 维矩阵相乘，得到长度input_len的结果。上述计算例子是理解为什么代码长这样的最好方式。计算完所有梯度后，剩下的就是实际训练Softmax层！就像我们在介绍神经网络时所做的那样，我们将使用随机梯度下降（SGD）更新权重和偏置，然后返回d_L_d_inputs：# Header: softmax.py class Softmax # ... def backprop(self, d_L_d_out, learn_rate): # highlight-line ''' Performs a backward pass of the softmax layer. Returns the loss gradient for this layer's inputs. - d_L_d_out is the loss gradient for this layer's outputs. - learn_rate is a float # highlight-line ''' # We know only 1 element of d_L_d_out will be nonzero for i, gradient in enumerate(d_L_d_out): if gradient == 0: continue # e^totals t_exp = np.exp(self.last_totals) # Sum of all e^totals S = np.sum(t_exp) # Gradients of out[i] against totals d_out_d_t = -t_exp[i] * t_exp / (S ** 2) d_out_d_t[i] = t_exp[i] * (S - t_exp[i]) / (S ** 2) # Gradients of totals against weights/biases/input d_t_d_w = self.last_input d_t_d_b = 1 d_t_d_inputs = self.weights # Gradients of loss against totals d_L_d_t = gradient * d_out_d_t # Gradients of loss against weights/biases/input d_L_d_w = d_t_d_w[np.newaxis].T @ d_L_d_t[np.newaxis] d_L_d_b = d_L_d_t * d_t_d_b d_L_d_inputs = d_t_d_inputs @ d_L_d_t # highlight-start # Update weights / biases self.weights -= learn_rate * d_L_d_w self.biases -= learn_rate * d_L_d_b return d_L_d_inputs.reshape(self.last_input_shape) # highlight-end注意，我们添加了一个learn_rate参数来控制我们更新权重的速度。另外，我们必须在返回d_L_d_inputs 之间进行reshape()操作，因为在前向传递过程中我们将输入降维了：# Header: softmax.py class Softmax: # ... def forward(self, input): ''' Performs a forward pass of the softmax layer using the given input. Returns a 1d numpy array containing the respective probability values. - input can be any array with any dimensions. ''' self.last_input_shape = input.shape input = input.flatten() # highlight-line self.last_input = input # ...改变 last_input_shape 的形状以使该层为输入返回的梯度具有与原始给定的输入一样的格式。测试：Softmax Backprop我们已经完成了第一个反向传播方案！让我们快速地进行测试。我们将应用train()方法到前一篇文章的代码程序# Header: cnn.py # Imports and setup here # ... def forward(image, label): # Implementation excluded # ... def train(im, label, lr=.005): ''' Completes a full training step on the given image and label. Returns the cross-entropy loss and accuracy. - image is a 2d numpy array - label is a digit - lr is the learning rate ''' # Forward out, loss, acc = forward(im, label) # Calculate initial gradient gradient = np.zeros(10) gradient[label] = -1 / out[label] # Backprop gradient = softmax.backprop(gradient, lr) # TODO: backprop MaxPool2 layer # TODO: backprop Conv3x3 layer return loss, acc print('MNIST CNN initialized!') # Train! loss = 0 num_correct = 0 for i, (im, label) in enumerate(zip(train_images, train_labels)): if i % 100 == 99: print( '[Step %d] Past 100 steps: Average Loss %.3f | Accuracy: %d%%' % (i + 1, loss / 100, num_correct) ) loss = 0 num_correct = 0 l, acc = train(im, label) loss += l num_correct += acc结果将类似于MNIST CNN initialized! [Step 100] Past 100 steps: Average Loss 2.239 | Accuracy: 18% [Step 200] Past 100 steps: Average Loss 2.140 | Accuracy: 32% [Step 300] Past 100 steps: Average Loss 1.998 | Accuracy: 48% [Step 400] Past 100 steps: Average Loss 1.861 | Accuracy: 59% [Step 500] Past 100 steps: Average Loss 1.789 | Accuracy: 56% [Step 600] Past 100 steps: Average Loss 1.809 | Accuracy: 48% [Step 700] Past 100 steps: Average Loss 1.718 | Accuracy: 63% [Step 800] Past 100 steps: Average Loss 1.588 | Accuracy: 69% [Step 900] Past 100 steps: Average Loss 1.509 | Accuracy: 71% [Step 1000] Past 100 steps: Average Loss 1.481 | Accuracy: 70%损失在减少，准确性在上升，CNN已经开始学习了！Backprop: 最大池化不能训练最大池化层，因为它实际上没有任何权重，但是我们仍然需要实现一个backprop()方法来计算梯度。我们将再次从添加前向阶段缓存开始。这次需要缓存的只是输入：# Header: maxpool.py class MaxPool2: # ... def forward(self, input): ''' Performs a forward pass of the maxpool layer using the given input. Returns a 3d numpy array with dimensions (h / 2, w / 2, num_filters). - input is a 3d numpy array with dimensions (h, w, num_filters) ''' self.last_input = input #highlight-line # More implementation # ...在前向传递过程中，“最大池化”（Max Pooling）层将获取一个立体（三维）输入，并通过选择2x2块上的最大值将其宽度和高度尺寸减半。后向传递则相反：通过将每个梯度值分配给其相应2x2块中原始最大值所在的位置，我们将损失梯度的宽度和高度加倍。这是一个例子。最大池化层的前向阶段： 将4x4输入转换为2x2输出的前向阶段示例后向阶段如下： 向后阶段将2x2梯度转换为4x4梯度的示例每个梯度值都分配给原始最大值所在的位置，其他每个值均为零。为什么Max Pooling层的后向阶段这样起作用？从直觉上考虑一下$\frac{\partial L}{\partial inputs}$。如果输入像素不是其2x2块中的最大值，则对损失的边际影响为零，因为稍微更改该值根本不会改变输出！换一种说法，非最大像素的$\frac{\partial L}{\partial input} = 0$。另一方面，该输入像素是最大值会使它的值传递给输出，所以 $\frac{\partial output}{\partial input} = 1$，意味着$\frac{\partial L}{\partial input} = \frac{\partial L}{\partial output}$。我们可以使用在第1部分中编写的iterate_regions()方法非常快地实现这一点：# Header: maxpool.py class MaxPool2: # ... def iterate_regions(self, image): ''' Generates non-overlapping 2x2 image regions to pool over. - image is a 2d numpy array ''' h, w, _ = image.shape new_h = h // 2 new_w = w // 2 for i in range(new_h): for j in range(new_w): im_region = image[(i * 2):(i * 2 + 2), (j * 2):(j * 2 + 2)] yield im_region, i, j def backprop(self, d_L_d_out): ''' Performs a backward pass of the maxpool layer. Returns the loss gradient for this layer's inputs. - d_L_d_out is the loss gradient for this layer's outputs. ''' d_L_d_input = np.zeros(self.last_input.shape) for im_region, i, j in self.iterate_regions(self.last_input): h, w, f = im_region.shape amax = np.amax(im_region, axis=(0, 1)) for i2 in range(h): for j2 in range(w): for f2 in range(f): # If this pixel was the max value, copy the gradient to it. if im_region[i2, j2, f2] == amax[f2]: d_L_d_input[i * 2 + i2, j * 2 + j2, f2] = d_L_d_out[i, j, f2] return d_L_d_input对于每个卷积核中每个2x2图像区域中的每个像素，如果是前向传递的最大值，就复制d_L_d_out到d_L_d_input 接下来进入我们最后一层反向传播: Conv通过Conv层进行反向传播是训练CNN的核心。# Header: conv.py class Conv3x3 # ... def forward(self, input): ''' Performs a forward pass of the conv layer using the given input. Returns a 3d numpy array with dimensions (h, w, num_filters). - input is a 2d numpy array ''' self.last_input = input # highlight-line # More implementation # ...提醒一下：为简单起见，我们假设转换层的输入是二维数组。这仅对本例有效，因为我们将其用作网络的第一层。如果我们要建立一个需要多次使用Conv3x3的更大的网络，则必须将输入设为三维数组。我们主要对conv层中卷积核的损耗梯度感兴趣，因为我们需要用它来更新卷积核的权重。对于conv层，我们已经有$\frac{\partial L}{\partial out}$，所以我们只需要 $\frac{\partial out}{\partial filters}$​。要计算这一点，我们要问自己：改变卷积核的权重将如何影响conv层的输出？现实情况是，更改任何卷积核权重都会影响该滤波器的整个输出图像，因为在卷积过程中每个输出像素都会使用每个像素权重。为了更容易明白，让我们一次只考虑一个输出像素：修改滤镜将如何改变一个特定输出像素的结果？这是一个超级简单的示例，可以帮助您思考以下问题： 3x3图像（左）与3x3卷积核（中间）卷积后产生1x1输出（右）如果我们将中间卷积核的权重增加为1，会怎么样？输出值将会是80 类似地，将任何位置的卷积核权重增加1将使输出增加相应图像像素的值！这表明特定输出像素相对于特定卷积核权重的导数就是相应的图像像素值。进行数学运算可以验证这一点：$$ \begin{aligned} \text{out(i, j)} &= \text{convolve(image, filter)} \\ &= \sum_{x=0}^3 \sum_{y=0}^3 \text{image}(i + x, j + y) * \text{filter}(x, y) \\ \end{aligned} $$$$ \frac{\partial \text{out}(i, j)}{\partial \text{filter}(x, y)} = \text{image}(i + x, j + y) $$我们可以将它们放在一起以找到特定卷积核权重的损耗梯度：$$ \begin{aligned} \frac{\partial L}{\partial \text{filter}(x, y)} &= \sum_i \sum_j \frac{\partial L}{\partial \text{out}(i, j)} * \frac{\partial \text{out}(i, j)}{\partial \text{filter}(x, y)} \end{aligned} $$准备为我们的conv层实现backprop！# Header: conv.py class Conv3x3 # ... def backprop(self, d_L_d_out, learn_rate): ''' Performs a backward pass of the conv layer. - d_L_d_out is the loss gradient for this layer's outputs. - learn_rate is a float. ''' d_L_d_filters = np.zeros(self.filters.shape) for im_region, i, j in self.iterate_regions(self.last_input): for f in range(self.num_filters): d_L_d_filters[f] += d_L_d_out[i, j, f] * im_region # Update filters self.filters -= learn_rate * d_L_d_filters # We aren't returning anything here since we use Conv3x3 as # the first layer in our CNN. Otherwise, we'd need to return # the loss gradient for this layer's inputs, just like every # other layer in our CNN. return None我们通过迭代每个图像区域/卷积核并逐步建立损耗梯度来应用导出的方程式。涵盖所有内容后，我们将像以前一样使用SGD 更新self.filters。请注意解释为什么我们返回一个None-输入的损耗梯度的推导与我们刚才做的很相似，留给读者作为练习：)。这样就完成了！该测试一下了……训练CNN我们将对CNN进行一些训练，在训练期间跟踪其进度，然后在单独的测试集上对其进行测试。这是完整的代码：# Header: cnn.py import mnist import numpy as np from conv import Conv3x3 from maxpool import MaxPool2 from softmax import Softmax # We only use the first 1k examples of each set in the interest of time. # Feel free to change this if you want. train_images = mnist.train_images()[:1000] train_labels = mnist.train_labels()[:1000] test_images = mnist.test_images()[:1000] test_labels = mnist.test_labels()[:1000] conv = Conv3x3(8) # 28x28x1 -> 26x26x8 pool = MaxPool2() # 26x26x8 -> 13x13x8 softmax = Softmax(13 * 13 * 8, 10) # 13x13x8 -> 10 def forward(image, label): ''' Completes a forward pass of the CNN and calculates the accuracy and cross-entropy loss. - image is a 2d numpy array - label is a digit ''' # We transform the image from [0, 255] to [-0.5, 0.5] to make it easier # to work with. This is standard practice. out = conv.forward((image / 255) - 0.5) out = pool.forward(out) out = softmax.forward(out) # Calculate cross-entropy loss and accuracy. np.log() is the natural log. loss = -np.log(out[label]) acc = 1 if np.argmax(out) == label else 0 return out, loss, acc def train(im, label, lr=.005): ''' Completes a full training step on the given image and label. Returns the cross-entropy loss and accuracy. - image is a 2d numpy array - label is a digit - lr is the learning rate ''' # Forward out, loss, acc = forward(im, label) # Calculate initial gradient gradient = np.zeros(10) gradient[label] = -1 / out[label] # Backprop gradient = softmax.backprop(gradient, lr) gradient = pool.backprop(gradient) gradient = conv.backprop(gradient, lr) return loss, acc print('MNIST CNN initialized!') # Train the CNN for 3 epochs for epoch in range(3): print('--- Epoch %d ---' % (epoch + 1)) # Shuffle the training data permutation = np.random.permutation(len(train_images)) train_images = train_images[permutation] train_labels = train_labels[permutation] # Train! loss = 0 num_correct = 0 for i, (im, label) in enumerate(zip(train_images, train_labels)): if i > 0 and i % 100 == 99: print( '[Step %d] Past 100 steps: Average Loss %.3f | Accuracy: %d%%' % (i + 1, loss / 100, num_correct) ) loss = 0 num_correct = 0 l, acc = train(im, label) loss += l num_correct += acc # Test the CNN print('\n--- Testing the CNN ---') loss = 0 num_correct = 0 for im, label in zip(test_images, test_labels): _, l, acc = forward(im, label) loss += l num_correct += acc num_tests = len(test_images) print('Test Loss:', loss / num_tests) print('Test Accuracy:', num_correct / num_tests)Example output from running the code:MNIST CNN initialized! --- Epoch 1 --- [Step 100] Past 100 steps: Average Loss 2.254 | Accuracy: 18% [Step 200] Past 100 steps: Average Loss 2.167 | Accuracy: 30% [Step 300] Past 100 steps: Average Loss 1.676 | Accuracy: 52% [Step 400] Past 100 steps: Average Loss 1.212 | Accuracy: 63% [Step 500] Past 100 steps: Average Loss 0.949 | Accuracy: 72% [Step 600] Past 100 steps: Average Loss 0.848 | Accuracy: 74% [Step 700] Past 100 steps: Average Loss 0.954 | Accuracy: 68% [Step 800] Past 100 steps: Average Loss 0.671 | Accuracy: 81% [Step 900] Past 100 steps: Average Loss 0.923 | Accuracy: 67% [Step 1000] Past 100 steps: Average Loss 0.571 | Accuracy: 83% --- Epoch 2 --- [Step 100] Past 100 steps: Average Loss 0.447 | Accuracy: 89% [Step 200] Past 100 steps: Average Loss 0.401 | Accuracy: 86% [Step 300] Past 100 steps: Average Loss 0.608 | Accuracy: 81% [Step 400] Past 100 steps: Average Loss 0.511 | Accuracy: 83% [Step 500] Past 100 steps: Average Loss 0.584 | Accuracy: 89% [Step 600] Past 100 steps: Average Loss 0.782 | Accuracy: 72% [Step 700] Past 100 steps: Average Loss 0.397 | Accuracy: 84% [Step 800] Past 100 steps: Average Loss 0.560 | Accuracy: 80% [Step 900] Past 100 steps: Average Loss 0.356 | Accuracy: 92% [Step 1000] Past 100 steps: Average Loss 0.576 | Accuracy: 85% --- Epoch 3 --- [Step 100] Past 100 steps: Average Loss 0.367 | Accuracy: 89% [Step 200] Past 100 steps: Average Loss 0.370 | Accuracy: 89% [Step 300] Past 100 steps: Average Loss 0.464 | Accuracy: 84% [Step 400] Past 100 steps: Average Loss 0.254 | Accuracy: 95% [Step 500] Past 100 steps: Average Loss 0.366 | Accuracy: 89% [Step 600] Past 100 steps: Average Loss 0.493 | Accuracy: 89% [Step 700] Past 100 steps: Average Loss 0.390 | Accuracy: 91% [Step 800] Past 100 steps: Average Loss 0.459 | Accuracy: 87% [Step 900] Past 100 steps: Average Loss 0.316 | Accuracy: 92% [Step 1000] Past 100 steps: Average Loss 0.460 | Accuracy: 87% --- Testing the CNN --- Test Loss: 0.5979384893783474 Test Accuracy: 0.78我们的代码有效！在仅有3000个训练步骤中，我们从损失为2.3且准确度为10％的模型变为损失为0.6且准确度为78％的模型。是否想亲自尝试或修改此代码？在浏览器中运行此CNN。在Github上也可以找到该代码。为了节省时间，在此示例中，我们仅使用了整个MNIST数据集的一个子集-CNN的实现并不是特别快。如果我们想训练MNIST CNN使其更加有效，我们会使用ML库如Keras。为了说明我们的CNN的功能，我使用Keras训练了和本文完全相同的 的CNN：# Header: cnn_keras.py import numpy as np import mnist from keras.models import Sequential from keras.layers import Conv2D, MaxPooling2D, Dense, Flatten from keras.utils import to_categorical from keras.optimizers import SGD train_images = mnist.train_images() train_labels = mnist.train_labels() test_images = mnist.test_images() test_labels = mnist.test_labels() train_images = (train_images / 255) - 0.5 test_images = (test_images / 255) - 0.5 train_images = np.expand_dims(train_images, axis=3) test_images = np.expand_dims(test_images, axis=3) model = Sequential([ Conv2D(8, 3, input_shape=(28, 28, 1), use_bias=False), MaxPooling2D(pool_size=2), Flatten(), Dense(10, activation='softmax'), ]) model.compile(SGD(lr=.005), loss='categorical_crossentropy', metrics=['accuracy']) model.fit( train_images, to_categorical(train_labels), batch_size=1, epochs=3, validation_data=(test_images, to_categorical(test_labels)), )在完整的 MNIST数据集（60k训练图像）上运行该代码将得到如下结果：Epoch 1 loss: 0.2433 - acc: 0.9276 - val_loss: 0.1176 - val_acc: 0.9634 Epoch 2 loss: 0.1184 - acc: 0.9648 - val_loss: 0.0936 - val_acc: 0.9721 Epoch 3 loss: 0.0930 - acc: 0.9721 - val_loss: 0.0778 - val_acc: 0.9744通过这个简单的CNN，我们可以达到97.4％的测试准确性！有了更好的CNN架构，我们可以进一步改善-在这个官方的Keras MNIST CNN示例中，它们在12个周期后达到了99.25％的准确度。不熟悉Keras？阅读有关使用Keras构建第一个神经网络或使用Keras训练CNN实现CNN的教程。本文所有代码均可在 Github上找到.
原文链接在过去的几年里，关于卷积神经网络（Convolution Neural Networks，CNN）的讨论越来越多，特别是因为它彻底改变了计算机视觉领域。在本文中，我们将基于神经网络的基本背景知识，探索CNN是什么，了解它们的工作原理，并在Python中从头开始构建一个真正的CNN（仅使用numpy）。这篇文章仅假设你拥有神经网络的基本知识。我对神经网络的介绍涵盖了您需要了解的所有内容，因此您可能需要首先阅读它。为什么使用CNNCNN的经典案例是图像分类，例如查看宠物的图像并确定它是猫还是狗。那么问题来了，为什么不使用普通的神经网络？好问题原因1：图片很大如今，有关计算机视觉问题的图像通常为224x224或更大。想象一下，建立一个神经网络来处理224x224彩色图像：包括图像中的3个彩色通道（RGB），也就是说需要224 x 224 x 3 = 150528个输入特征！在这种网络中，典型的隐藏层可能有1024个节点，因此仅第一层就必须训练150,528 x 1024 = 150+百万个权重。我们的网络非常庞大，几乎无法训练。而且我们也不需要那么多的权重。我们知道像素在与其相邻的环境中最有用。图像中的对象由小的局部特征组成，例如眼睛的圆形虹膜或一张纸的方形角。第一个隐藏层中的每个节点查看每个像素是否很浪费？原因2：位置会发生变化如果你训练了一个网络来检测狗，你会希望它能够检测狗，而不管它出现在图像的什么地方。想象一下，训练一个网络，它能很好地处理一个特定的狗的图像，但是给它一个稍微改变了的图像。狗不会激活相同的神经元，所以网络的反应会完全不同！我们很快就会看到CNN如何帮助我们解决这些问题。数据集在本文中，我们将解决计算机视觉的“Hello, World!”：MNIST手写数字分类问题。很简单：给定图像，将其分类为数字。 MNIST数据集MNIST数据集中的每个图像均为28x28像素，并包含居中的灰度数字。说实话，一般的神经网络实际上可以很好地解决这个问题。您可以将每张图像视为28 x 28 = 784维向量，将其输入到784维输入层，堆叠一些隐藏层，最后得到10个节点的输出层，每个数字1个节点。但是它只会在MNIST数据集包含居中的小图像时起作用，本例中我们不会遇到上述尺寸或偏移问题。请记住，大多数现实世界中的图像分类问题并非易事。卷积什么是卷积神经网络？它是使用卷积层（又称为Conv层）的神经网络，其基于卷积的数学运算。Conv层由一组filter（卷积核）组成，您可以将其视为二维的数字矩阵。这是一个3x3卷积核示例： 我们可以输入图像和卷积核，将卷积核与输入图像卷积来生成输出图像。这包括在某些位置将卷积核覆盖在图像顶部。在卷积核中的值和图像中相应的值之间执行逐元素乘法（element-wise multiplication）。加总所有逐元素乘法的结果。和是输出图像中目标像素的输出值。在所有位置重复该过程。旁注：从技术上讲，我们（以及许多CNN）实际上在这里使用互相关而不是卷积，但是它们几乎是同一件事。我不会在这篇文章中介绍差异，因为它并不重要，但是如果您感到好奇，可以随时查找相关资料。这个四步描述有点抽象，所以让我们做一个例子。考虑一下这个微小的4x4灰度图像和3x3卷积核： 4x4图像（左）和3x3卷积核（右）图像中的数字表示像素强度，其中0为黑色，255为白色。我们将对输入图像和卷积核进行卷积以生成2x2的输出图像： 首先，让我们将卷积核覆盖在图像的左上角： 步骤1：将卷积核（右）覆盖在图像（左）的上方接下来，我们在重叠的图像值和卷积核值之间执行逐元素乘法。结果如下，从左到右，从上到下：图像值卷积核值结果0-1050000100-2080003126233-1-339000010步骤2：执行逐元素乘法。接下来，加总所有的结果，这很容易$$ 62 - 33 = \boxed{29} $$最后，我们将结果放置在输出图像的目标像素中。由于我们的卷积核覆盖在输入图像的左上角，因此我们的目标像素是输出图像的左上像素： 重复以上步骤，得到其他位置像素 它有什么用？让我们缩小一下，在更高的层次上看。将一个图像与一个卷积核进行卷积有什么用?我们可以从我们一直使用的3x3卷积核开始，它通常被称为垂直索伯滤波器 垂直索伯滤波器这是一个垂直索伯滤波器的示例： 图像与垂直索伯滤波器卷积类似地，还有一个水平索伯滤波器卷积： 水平索伯滤波器卷积 图像与水平索伯滤波器卷积看看发生了什么事？索伯滤波器是边缘检测算子（edge-detectors）。垂直索伯滤波器检测垂直边缘，而水平索伯滤波器检测水平边缘。现在可以轻松地解释输出图像：输出图像中的亮像素（值较高的像素）表明原始图像周围有很强的边缘。您能看到为什么边缘检测图像比原始图像更有用的原因吗？回想一下我们的MNIST手写数字分类问题。经过MNIST训练的CNN可能会通过使用边缘检测算子，并检查图像中心附近的两个突出的垂直边缘来寻找数字1。通常，卷积可以帮助我们寻找特定的局部图像特征（例如边缘），以便以后在网络中使用。填充（Padding）还记得将4x4输入图像与3x3卷积核进行卷积以生成2x2的输出图像吗？通常，我们希望输出图像的大小与输入图像的大小相同。为此，我们在图像周围添加零，以便可以在更多位置覆盖卷积核。3x3卷积核需要填充1个像素： 4x4输入与3x3卷积核卷积产生4x4输出这被称为“相同”填充，因为输入和输出具有相同的尺寸。不使用任何填充（这是我们一直在做的），有时也称为“有效”填充。卷积层既然我们知道了图像卷积的工作原理以及为什么它有用，那么让我们看看它在CNN中的实际用法。如前所述，CNN包括使用一组卷积核将输入图像转换为输出图像的卷积层。卷积层的主要参数是一系列卷积核。对于我们的MNIST CNN，我们将使用带有8个卷积核的小型卷机层作为网络的初始层。这意味着它将把28x28的平面输入图像变成26x26x8的立体输出图像： 提醒：输出是26x26x8而不是28x28x8，因为我们使用的是有效的padding，它将输入的宽度和高度减少2。卷积层中的8个卷积核中的每一个都产生26x26的输出，因此堆叠在一起就构成了26x26x8的立体。3*3（卷积核的大小）*8（卷积核数量）=72，也就是说我们只需要72个权重！实现卷积我们将实现一个conv层的前馈部分，该部分负责将卷积核与输入图像进行卷积以产生输出。为简单起见，我们假定卷积核始终为3x3（事实并非如此-5x5和7x7卷积核也很常见）。# Header: conv.py import numpy as np class Conv3x3: # A Convolution layer using 3x3 filters. def __init__(self, num_filters): self.num_filters = num_filters # filters is a 3d array with dimensions (num_filters, 3, 3) # We divide by 9 to reduce the variance of our initial values self.filters = np.random.randn(num_filters, 3, 3) / 9Conv3x3类只接受一个参数：卷积核的数量。在init函数中，我们存储卷积核的数量，并使用NumPy的randn()方法初始化一个随机卷积核数组。注意：在初始化过程中，除以9会比您想象的要重要。如果初始值太大或太小，训练网络将无效。要了解更多信息，请阅读Xavier Initialization。接下来，实际的卷积：# Header: conv.py class Conv3x3: # ... def iterate_regions(self, image): ''' Generates all possible 3x3 image regions using valid padding. - image is a 2d numpy array ''' h, w = image.shape for i in range(h - 2): for j in range(w - 2): im_region = image[i:(i + 3), j:(j + 3)] yield im_region, i, j def forward(self, input): ''' Performs a forward pass of the conv layer using the given input. Returns a 3d numpy array with dimensions (h, w, num_filters). - input is a 2d numpy array ''' h, w = input.shape output = np.zeros((h - 2, w - 2, self.num_filters)) for im_region, i, j in self.iterate_regions(input): output[i, j] = np.sum(im_region * self.filters, axis=(1, 2)) # highlight-line return outputpython›iterate_regions()是一种辅助生成器方法，可以为我们生成所有有效的3x3图像区域。 对于该类的后半部分非常有用。上面突出显示了实际执行卷积的代码行。让我们分解一下：im_region, 一个包含相关图像区域的3x3数组.self.filters, 一个三维数组.代码 im_region * self.filters, 利用了numpy的 broadcasting （广播）功能将两个数组逐元素相乘 。其结果是一个三维数组，与 self.filters维度相同.使用np.sum() axis=(1, 2)对上一步得到的结果相加，生成了一个一维数组 num_filters ，其中每个元素包含对应卷积核的卷积结果将结果分配给 output[i, j], 其中包含输出图像中像素的卷积结果对输出中的每个像素执行以上过程，直到获得最终的输出！代码如下：# Header: cnn.py import mnist from conv import Conv3x3 # The mnist package handles the MNIST dataset for us! # Learn more at https://github.com/datapythonista/mnist train_images = mnist.train_images() train_labels = mnist.train_labels() conv = Conv3x3(8) output = conv.forward(train_images[0]) print(output.shape) # (26, 26, 8)到目前为止看起来不错。注意：在我们的 Conv3x3中，为简单起见，我们假设输入为2维numpy数组，因为这就是我们的MNIST图像的存储方式。这对我们有用，因为我们将其用作网络的第一层，但是大多数CNN都具有更多的Conv层。如果我们要建立一个更大的网络，需要多次使用 Conv3x3，则必须使输入为三维numpy数组。池化层（Pooling）图像中的相邻像素往往具有相似的值，因此经过卷积层输出中的相邻像素也有相似的值。也就是卷机层输出中包含了大量多余的信息。例如，如果我们使用边缘检测滤波器，并在某个位置找到了较强的边缘，则很有可能在从原始像素偏移1个像素的位置上也找到了相对较强的边缘。但是，这些都是相同的边缘！我们没有发现任何新东西。池化层解决了这个问题。他们所做的只是减小输入的大小，通过与池化层的值一起输入。该池通常是通过简单的操作，比如max，min或average来实现。这是最大池化层的示例，池化大小为2： 在4x4图像上的最大池化（池大小2）产生2x2输出为了执行最大池化（_max_ pooling），我们以2x2块（因为池大小= 2）遍历输入图像，并将最大值放入对应像素的输出图像中而已！池化将输入的宽度和高度变为除以池大小的宽度和高度。对于我们的MNIST CNN，我们将在初始转换层之后立即放置一个池大小为2的最大池化层。池化层会将26x26x8输入转换为13x13x8输出：Pooling divides the input's width and height by the pool size. For our MNIST CNN, we'll place a Max Pooling layer with a pool size of 2 right after our initial conv layer. The pooling layer will transform a 26x26x8 input into a 13x13x8 output: 池化的代码实现我们将用与上一节中的conv类相同的方法来实现一个 MaxPool2 类：:# Header: maxpool.py import numpy as np class MaxPool2: # A Max Pooling layer using a pool size of 2. def iterate_regions(self, image): ''' Generates non-overlapping 2x2 image regions to pool over. - image is a 2d numpy array ''' h, w, _ = image.shape new_h = h // 2 new_w = w // 2 for i in range(new_h): for j in range(new_w): im_region = image[(i * 2):(i * 2 + 2), (j * 2):(j * 2 + 2)] yield im_region, i, j def forward(self, input): ''' Performs a forward pass of the maxpool layer using the given input. Returns a 3d numpy array with dimensions (h / 2, w / 2, num_filters). - input is a 3d numpy array with dimensions (h, w, num_filters) ''' h, w, num_filters = input.shape output = np.zeros((h // 2, w // 2, num_filters)) for im_region, i, j in self.iterate_regions(input): output[i, j] = np.amax(im_region, axis=(0, 1)) # highlight-line return output该类的工作方式与Conv3x3类似。要从给定图像区域中找到最大值，我们使用numpy的np.amax()方法。我们设定axis=(0, 1)因为我们只想最大化前两个维度，即宽高，并不包括第三个维度：num_filters（卷积核数量）让我们测试一下！# Header: cnn.py import mnist from conv import Conv3x3 from maxpool import MaxPool2 # The mnist package handles the MNIST dataset for us! # Learn more at https://github.com/datapythonista/mnist train_images = mnist.train_images() train_labels = mnist.train_labels() conv = Conv3x3(8) pool = MaxPool2() output = conv.forward(train_images[0]) output = pool.forward(output) print(output.shape) # (13, 13, 8)Softmax要完成我们的CNN，我们需要赋予它实际进行预测的能力。我们将使用标准的最终层解决多类分类问题：Softmax层，这是一个全连接（密集）层，使用Softmax函数作为激活函数。提醒：全连接层将每个节点连接到上一层的每个输出。我们在神经网络入门中就使用了全连接层。如果您以前从未听说过Softmax，请先阅读我对Softmax的快速介绍，然后再继续。使用我们将使用带有10个节点的softmax层作为CNN的最后一层，每个节点代表一个数字。层中的每个节点将连接到每个输入。应用softmax转换后，由节点表示的概率最高的数字将是CNN的输出！ 交叉熵损失（cross-entropy loss）您可能想问，为什么要将输出转换为概率？最高的输出值会不会总是有最高的概率？如果您这样做，那绝对是正确的。我们实际上不需要使用softmax来预测数字 -我们可以选择网络中输出最高的数字！softmax的真正作用是帮助我们量化对预测的置信度，这在训练和评估CNN时很有用。更具体地说，使用softmax让我们能用交叉熵损失，它考虑了我们对每个预测的确定程度。这是我们计算交叉熵损失的方法：$$ L = -\ln(p_c) $$其中c是正确的组 (在本例中，就是正确的数字), $p_c$ 是对c的预测概率, and $\ln$ is the 自然对数. 一般来说, 更低的损失会更好. 例如在完美的情况下，我们有：$$ p_c = 1, L = -\ln(1) = 0 $$在更真实的情况下，我们有$$ p_c = 0.8, L = -\ln(0.8) = 0.223 $$在后续的文章中我们仍会看到交叉熵损失。Softmax代码实现# Header: softmax.py import numpy as np class Softmax: # A standard fully-connected layer with softmax activation. def __init__(self, input_len, nodes): # We divide by input_len to reduce the variance of our initial values self.weights = np.random.randn(input_len, nodes) / input_len self.biases = np.zeros(nodes) def forward(self, input): ''' Performs a forward pass of the softmax layer using the given input. Returns a 1d numpy array containing the respective probability values. - input can be any array with any dimensions. ''' input = input.flatten() input_len, nodes = self.weights.shape totals = np.dot(input, self.weights) + self.biases exp = np.exp(totals) return exp / np.sum(exp, axis=0)这里没有什么太复杂的。一些要点：我们使用flatten() 使其更易使用, 因为我们不再需要它的形状.np.dot() 将 input和 self.weights 逐元素相乘，并将结果加总。.np.exp() 计算用于Softmax的指数。接下来我们把代码放到一起# Header: cnn.py import mnist import numpy as np from conv import Conv3x3 from maxpool import MaxPool2 from softmax import Softmax # We only use the first 1k testing examples (out of 10k total) # in the interest of time. Feel free to change this if you want. test_images = mnist.test_images()[:1000] test_labels = mnist.test_labels()[:1000] conv = Conv3x3(8) # 28x28x1 -> 26x26x8 pool = MaxPool2() # 26x26x8 -> 13x13x8 softmax = Softmax(13 * 13 * 8, 10) # 13x13x8 -> 10 def forward(image, label): ''' Completes a forward pass of the CNN and calculates the accuracy and cross-entropy loss. - image is a 2d numpy array - label is a digit ''' # We transform the image from [0, 255] to [-0.5, 0.5] to make it easier # to work with. This is standard practice. out = conv.forward((image / 255) - 0.5) out = pool.forward(out) out = softmax.forward(out) # Calculate cross-entropy loss and accuracy. np.log() is the natural log. loss = -np.log(out[label]) acc = 1 if np.argmax(out) == label else 0 return out, loss, acc print('MNIST CNN initialized!') loss = 0 num_correct = 0 for i, (im, label) in enumerate(zip(test_images, test_labels)): # Do a forward pass. _, l, acc = forward(im, label) loss += l num_correct += acc # Print stats every 100 steps. if i % 100 == 99: print( '[Step %d] Past 100 steps: Average Loss %.3f | Accuracy: %d%%' % (i + 1, loss / 100, num_correct) ) loss = 0 num_correct = 0运行之后得到以下（类似）的输出MNIST CNN initialized! [Step 100] Past 100 steps: Average Loss 2.302 | Accuracy: 11% [Step 200] Past 100 steps: Average Loss 2.302 | Accuracy: 8% [Step 300] Past 100 steps: Average Loss 2.302 | Accuracy: 3% [Step 400] Past 100 steps: Average Loss 2.302 | Accuracy: 12%这是有道理的：通过初始化随机权重，您可以期望CNN与随机猜测一样好。随机猜测将产生10％的准确性（因为有10个类别），并且交叉熵损失为${-\ln(0.1)} = 2.302$，这就是我们得到的！想亲自运行这段代码? 在浏览器中运行. 同样也可以在 Github上找到这段代码.总结到此为止，CNN的介绍到此结束！在这篇文章中，我们阐述了CNN对于某些问题（例如图像分类）可能更有用的原因。引入了MNIST手写数字数据集。了解了Conv图层，该图层将卷积核与图像进行卷积以产生更多有用的输出。讨论了Pooling层，它可以帮助去除最有用特征之外的所有内容。实现了Softmax层，因此我们可以使用交叉熵损失。我们还没有介绍更多内容，例如如何实际训练CNN。CNN系列的第2部分深入研究了CNN的训练，包括推导梯度和实现反向传播。另外，您还可以学习使用Keras（一个Python深度学习库）实现自己的CNN。如果您急切希望看到训练有素的CNN：示例在MNIST上训练的Keras CNN可以达到99.25％的准确性。