0%

学完了CNN的基本构件,让我们用TensorFlow来搭建一个CNN,并用这个网络完成之前那个简单的猫狗分类任务。

项目网址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/BasicCNN

获取数据集

和之前几次的代码实战任务一样,我们这次还用的是Kaggle上的猫狗数据集。我已经写好了数据预处理的函数。使用如下的接口即可获取数据集:

1
2
3
4
train_X, train_Y, test_X, test_Y = get_cat_set(
'dldemos/LogisticRegression/data/archive/dataset', train_size=1500)
print(train_X.shape) # (m, 224, 224, 3)
print(train_Y.shape) # (m , 1)

这次的数据格式和之前项目中的有一些区别。

在使用全连接网络时,每一个输入样本都是一个一维向量。在预处理数据集时,我就做了一个flatten操作,把图片的所有颜色值塞进了一维向量中。而在CNN中,对于卷积操作,每一个输入样本都是一个三维张量。在用OpenCV读取完图片后,不用对图片Resize,直接拿过来用就可以了。

另外,在用NumPy实现时,我们把数据集大小m当作了最后一个参数。而TensorFlow默认张量是”NHWC(数量-高度-宽度-通道数)”格式。在此项目中,我们是按照TensorFlow的格式预处理数据的。

初始化模型

根据课堂里讲的CNN构建思路,我搭了一个这样的网络。

由于这个二分类任务比较简单,我在设计时尽可能让可训练参数更少。刚开始用一个大步幅、大卷积核的卷积快速缩小图片边长,之后逐步让图片边长减半、深度翻倍。

这样一个网络用TensorFlow实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def init_model(input_shape=(224, 224, 3)):
model = tf.keras.Sequential([
tf.keras.layers.Conv2D(16, 11, (3, 3), input_shape=input_shape),
tf.keras.layers.BatchNormalization(3),
tf.keras.layers.ReLU(),
tf.keras.layers.MaxPool2D(),
tf.keras.layers.Conv2D(32, 5),
tf.keras.layers.BatchNormalization(3),
tf.keras.layers.ReLU(),
tf.keras.layers.MaxPool2D(),
tf.keras.layers.Conv2D(64, 3, padding='same'),
tf.keras.layers.BatchNormalization(3),
tf.keras.layers.ReLU(),
tf.keras.layers.Conv2D(64, 3),
tf.keras.layers.BatchNormalization(3),
tf.keras.layers.ReLU(),
tf.keras.layers.MaxPool2D(),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(2048, 'relu'),
tf.keras.layers.Dense(1, 'sigmoid')
])

model.summary()

return model

tf.keras.Sequential()用于创建一个串行的网络(前一个模块的输出就是后一个模块的输入)。网络各模块用到的初始化参数的介绍如下:

  • Conv2D: 输出通道数、卷积核边长、步幅(要用一个数对表示)、填充方法。
  • BatchNormalization: 做归一化的维度(全填3即可)。
  • Dense(全连接层):输出通道数、激活函数。

根据之前的设计,把参数填入这些模块即可。

另外,TensorFlow维护的是静态图。一种比较简单的建图方法是在第一层里给出input_shape参数,让框架提前算好后续每一层中间结果的形状。

建图成功后,调用model.summary()可以查看网络各层的形状、参数量信息。

训练与推理

有了数据集和模型,用TensorFlow训练是一件很简单的事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
def main():
train_X, train_Y, test_X, test_Y = get_cat_set(
'dldemos/LogisticRegression/data/archive/dataset', train_size=1500)
print(train_X.shape) # (m, 224, 224, 3)
print(train_Y.shape) # (m , 1)

model = init_model()
model.compile(optimizer='adam',
loss='binary_crossentropy',
metrics=['accuracy'])

model.fit(train_X, train_Y, epochs=20, batch_size=16)
model.evaluate(test_X, test_Y)

使用init_model初始化模型后,用compile填入模型的优化器、误差函数、评估指标信息。之后,只要用fit输入训练输入、训练标签、epoch数、batch size即可开始训练。训练结束后,用evaluate输入测试输入、测试标签即可在测试集上评估模型。

TensorFlow的这些函数确实非常方便,这里test_X, test_Y, train_X, train_Y其实都是NumPy里的ndarray,可以不用显式地把它们转换成TensorFlow里的张量。

实验结果

由于数据量较少,20个epoch后模型在训练集上的精度就快满了:

1
2
Epoch 20/20
188/188 [==============================] - 23s 121ms/step - loss: 0.0690 - accuracy: 0.9776

测试集上的精度就没那么高了:

1
13/13 [==============================] - 1s 30ms/step - loss: 1.0136 - accuracy: 0.7375

相比前几周用的全连接网络,CNN的效果出彩很多。相信加入更多训练数据的话,模型在测试集上的表现会更好。

另外,TensorFlow的高度封装的函数确实很好用,寥寥几行代码就完成了训练配置、训练、评估。相比用NumPy从零写代码,编程框架的开发效率会高上很多。

下篇文章里,我会介绍本项目的等价PyTorch实现。大家届时可以比较一下两个框架的区别。

前排提示:这周的课有很多知识点都在图中,一定要仔细地看一看图。

课堂笔记

计算机视觉

CV(Computer Vision, 计算机视觉)是计算机科学的一个研究领域。该领域研究如何让计算机“理解”图像,从而完成一些只有人类才能完成的高级任务。这些高级任务有:图像分类、目标检测、风格转换等。

想具体了解有哪些计算机视觉任务,可以直接去访问OpenMMLab的GitHub主页:https://github.com/open-mmlab 。我随手整理了一下:图像分类、目标检测、语义分割、图像补全、光流、图像超分辨率、自动抠图、姿态识别、视频插帧、视频目标跟踪、文字识别与理解、图像生成、视频理解、3D目标检测与语义分割……

现在,大多数前沿CV算法是用深度学习实现的。

但是,在CV任务上使用我们之前学的经典神经网络,会碰到一个问题:神经网络输入层的通道数与输入图像尺寸正相关。对于一幅$64\times64\times3$的图像,输入的通道数是$12288$;而对于一幅$1000\times1000\times3$的图像,输入的通道数就高达$3\times 10^6$了。而网络第一层的参数量又与输入的通道数正相关。对于一个通道数高达$3\times 10^6$的输入,假设网络第一个隐藏层有$1000$个神经元,那么这一层的$W$将有$1000 \times 3\times 10^6=3\times 10^9$个参数。有这么多参数,除非有海量的数据,不然网络非常容易过拟合。现有的数据量和计算资源还是跑不动参数这么多的网络的。

因此,在CV中,我们一般不使用之前学的经典神经网络架构,而是使用一种新的网络架构——CNN(Convolutional Neural Network, 卷积神经网络)。

教材这一段的引入新知识组织得非常棒,从参数量的角度自然而然地从全连接网络过度到卷积神经网络。

让我们从卷积神经网络最简单的构件——卷积学起,一步一步认识卷积神经网络。

边缘检测

卷积是一种定义在图像上的操作。在深度学习时代之前,它最常用于图像处理。让我们来看看卷积在图像处理中的一个经典应用——边缘检测,通过这个应用来学习卷积。

边缘检测的示意图如上所示。输入一张图片,我们希望计算机能够检测出图像纵向和横向的边缘,把有边缘的地方标成白色,没有边缘的地方标成黑色。

我们可以用卷积实现边缘检测。让我们来看看卷积运算是怎么样对数据进行操作的。

卷积有两个输入:一幅图像和一个卷积核(英文是kernel,也叫做filter滤波器),其中卷积核是一个二维矩阵。我们这里假设图像是一幅单通道$6 \times 6$的矩阵,卷积核是一个$3 \times 3$的矩阵。经过卷积后,我们会得到一个$4 \times 4$的单通道图像(稍后会介绍$4 \times 4$是怎么算出来的)。

卷积操作会依次算出输出图像中每一个格子的值。对于输出左上角第一个格子,它的计算方法如下:

首先,我们把$3 \times 3$的卷积核“套”在输入图像的左上角。之后,我们把同一个位置的两个数字乘起来。比如图像左上角第一行是$3 0 1$,卷积核第一行是$1 0 1$,做完乘法运算后应该得到$3 0 -1$。最后,把所有乘法结果加起来,这个和就是输出中第一个格子的值。通过计算,这个值是$-5$,我们把它填入到输出图像中。

按同样的道理,我们可以填完第一行剩下的格子:

从第二行开始,卷积核要往下移一格。

以此类推,我们可以填完所有格子。大家明白了为什么输出是$4 \times 4$的图像吗?没错,把$3 \times 3$的卷积核放到$6 \times 6$的图像上,只有$4 \times 4$个位置能放得下。

学会了卷积,该怎么用卷积完成边缘检测呢?我们可以看下面这个例子:

来看左边那幅图像,它左侧是白的,右侧是灰的。很明显,中间有一条纵向的边缘。当我们用图中那个卷积核对图像做卷积操作后,输出的图像中间是白色的(非0值),两侧是黑色的。输出图像用白色标出了原图像的纵向边缘,达到了边缘检测的目的。

刚刚那个卷积核只能检测纵向的边缘。大家应该能猜出,如果我们把卷积核转一下,就能检测横向的边缘了。

实际上,不仅是横向和纵向,我们还可以通过改变卷积核,检测出图像45°,30°的边缘。同时,卷积核里面的数值也不一定是1和-1,还有各种各样的取值方法。如果大家感兴趣,可以参考数字图像处理中有关边缘检测的介绍。

卷积与交叉相关

其实,现在我们在课堂上学的和编程框架里用的卷积,在数学上叫做“交叉相关(cross-correlation)”。数学中真正的那个卷积,要先对卷积核做一个旋转180°的操作,再做我们现在的那个卷积的操作。相比交叉相关,数学中的那个卷积能够满足交换律、结合律等一些实用的性质。

但是,在图像处理中,我们是从工程的角度而不是理科的角度使用卷积。要实现多次卷积的操作,只要拿图像多卷几次就好了,不用考虑结合律等复杂的性质。对于计算机来说,旋转卷积核180°是一个费时而多余的操作。因此,我们现在说到的卷积,实际上是一个简化版的卷积,即交叉相关。

如果大家对这方面的知识感兴趣,欢迎阅读网上的这篇文章:https://zhuanlan.zhihu.com/p/33194385

填充

卷积后,图像的边长会变小。比如刚刚那个$6 \times 6$的图像经$3 \times 3$卷积后,会得到一个$4 \times 4$的图像。这是因为原图像中只有$4 \times 4$个位置放得下卷积核。

更一般地,如果原图像大小为$n \times n$,卷积核大小$f \times f$,则卷积后的图像为$(n-f+1) \times (n-f+1)$。

卷积操作导致的这种“缩水”现象有两个缺点:1)图像的分辨率会越来越小。最坏的情况下,图像变成了$1 \times 1$的大小,再也无法进行卷积操作了。2)图像中间的数据会被算到多次,而边缘处数据被算的次数较少。

填充(padding) 操作可以解决这些的问题:在做卷积操作之前,我们可以往图像四周填充一些像素,使得卷积操作后的图像大小不变。比如$6 \times 6$的图像做$3 \times 3$卷积时,可以先把图像填充成$8 \times 8$。这样,卷积后的图像还能保持$6 \times 6$的大小。

填充操作有两个参数:填充的数据和向四周填充的宽度。对于填充的数据,一般情况下,全部填0即可。而对于填充宽度,其取决于卷积核的大小。为了让图像大小不变,我们应该让填充宽度$p$满足$n+2p-f+1=n$,解得$p=\frac{f-1}{2}$。为了让$p$是整数,卷积核边长最好是奇数。

解释一下$n+2p-f+1=n$这个方程的左侧是怎么得来的。由于填充是上下、左右都填,填充后的图像边长是$n+2p$。根据开始的卷积后图像边长公式$n-f+1$,我们可以得到填充+卷积后边长公式$n+2p-f+1。$

加入了填充操作后,我们可以把卷积分成两类:有效卷积等长卷积。前者不做填充操作,只对图像的有效区域做卷积。而后者会在卷积前做一次填充,保证整个操作的前后图像大小不变。

跨步卷积

跨步卷积的英文是strided convolution。strided来源于动词stride,表示“大步走”。我没有在网上找到一个合适的对这里的strided的翻译。我觉得直接翻译成“跨步卷积”就挺好。

还有一个翻译的小细节:做名词时,stride应翻译成“步幅”,而“步长”的英文应该是step。二者在描述人类行走时略有区别。

之前,每做完一次卷积后,我们都会让卷积核往右移1格;每做完一行卷积后,我们都会让卷积核往下移1格。但实际上,我们可以让卷积核移动得更快一点。卷积核每次移动的长度$s$称为步幅(stride).

跨步卷积的部分计算示意图(第1, 2, 4次计算)如下:

可以看到,步幅改变后,输出图像的边长也改变了。一般地,卷积后图像边长满足下面这个公式,大家可以自行推导验证一下:

其中$\lfloor x \rfloor$表示去掉$x$的小数部分,只保留其整数部分,即向下取整。

在3D数据体上卷积

之前我们学的卷积都是定义在一个二维单通道图像上的。在一个三通道的图像上,应该怎么进行卷积呢?

其实,对3D数据体的卷积是类似的。对于一个有3个通道的图像,卷积核也应该有3个通道。这样,图像和卷积核就从面变成了体。和2D时一样,我们把两个数据体对应位置的元素相乘,最后再把乘法的结果加起来,放到输出图像对应的格子中。

我认为,把三通道的图像表示成$3 \times 6 \times 6$更好理解一些。这样,输入图像的其实是一个二维图像的数组,$3 \times 3 \times 3$的卷积核其实也是一个$3 \times 3$卷积核的数组。我们把数组中下标一样的图像和卷积核做卷积,最后把所有数组的结果加到一起。

图像是用CHW(通道-高-宽)还是HWC表示,这件事并没有一个定论。似乎TensorFlow是用HWC,PyTorch是用CHW。这门课默认使用的是HWC。

既然输入都可以是多通道图像了,输出图像是不是也可以有多个通道呢?是的,我们只要用多个卷积核来卷图像,就可以得到一个多通道的图像了。

总结一下,假如输入图像的形状是$n \times n \times n_c$,卷积核的形状则是$f \times f \times n_c$。注意这个$n_c$必须是同一个数。假如有$n_c’$个卷积核,则输出图像的形状是$(n - f + 1) \times (n - f + 1) \times (n_c’)$。

在某些框架中,卷积核数量会也会当成卷积核的一个维度,比如可以用$n_c’ \times f \times f \times n_c$来表示一个卷积核组。

卷积神经网络中的卷积层

现在,我们已经掌握了卷积的基本知识,让我们来看看卷积神经网络中的卷积层长什么样。

卷积在卷积层中的地位,就和乘法操作在传统神经网络隐藏层中的地位一样。因此,在卷积层中,除了基础的卷积操作外,还有添加偏移量、使用激活函数这两步。注意,每有一个输出通道,就有一个$b$。

现在,我们可以总结一下一个卷积层中涉及的所有中间变量以及它们的形状了。

池化层与全连接层

池化层执行的池化操作和卷积类似,都是拿一个小矩阵盖在图像上,根据被小矩阵盖住的元素来算一个结果。因此,池化也有池化边长$f$和池化步幅$s$这两个参数。而与卷积不同的是,池化是一个没有可学习参数的操作,它的结果完全取决于输入。比如对于最大池化,每一步计算都会算出被覆盖区域的最大值。

比如上图中,我们令池化边长为2,步幅为2。这样,就等于把一个$4 \times 4$的图像分成了$2 \times 2$个等大的区域。对于每一个区域,我们算一个最大值。

一般情况下,最常用的池化就是这种边长为2,步幅为2的池化。做完该操作后,图像的边长会缩小至原来的$\frac{1}{2}$。

除了最大池化,还有计算区域内所有数平均值的平均池化。但现在几乎只用最大池化,不用平均池化了。

没有人知道池化层究竟为什么这么有用。一种可能的解释是:池化层忽略了细节,保留了关键信息,使后续网络能够只关注之前输出的最值/平均值。

全连接层其实就是我们之前学的经典神经网络中的层。前一层的每一个神经元和后一层的每一个神经元直接都有连接。当然,在把图像喂入全连接层之前,一定别忘了做flatten操作,把图像中所有数据平铺成一个一维向量。

CNN示例

学完了CNN所有的基础构件,我们或许会感到疑惑:每个卷积层、池化层、全连接层都有那么多超参数,而且层与层之间可以随意地排列组合。该怎么搭建一个CNN呢?不急,让我们来看一个CNN的实例:

这个网络是经典网络LeNet-5的改进版,它被用于一个10-分类任务。我们会在下周正式学习这个网络。现在,让我们通过概览这个网络来找出一些搭建CNN的规律。

网络按照“卷积-池化-卷积-池化-全连接-全连接-softmax”的顺序执行。通常情况下,CNN都是执行若干次卷积,后面跟一次池化。等所有卷积核池化做完,才会做全连接操作。全连接之后就是由softmax激活的输出层。

另外,图像的形状也有一些规律。在卷积核池化的过程中,图像的边长不断变小,而通道数会不断变大。

下周,我们会继续认识一些经典的CNN架构,这些经典架构能够启发我们,帮助我们更好地搭建自己的CNN。

为什么用卷积?

这周,我们一直都在讲卷积。而卷积具体有哪些优点呢?

首先,卷积最大的优势就是需要的参数量少。回想这周开头讲的参数量问题。对于图像数据,如果用全连接网络的话,网络的参数会非常多。而卷积的两个性质,使得需要的参数量大大降低。这两个性质是权重共享与稀疏连接。

权重共享:对于输入图像的所有位置来说,卷积核的参数是共享的。这种设计是十分合理的。比如在边缘检测中,只要我们用同样一个[[1, 0, -1], [1, 0, -1], [1, 0, -1]]的卷积核卷网络,就能检测出垂直方向的边缘。这样,卷积操作的参数量就只由卷积核参数决定,而与图像大小无关。

稀疏连接:卷积核的大小通常很小,也就是卷积操作的一个输出只会由少部分的输入决定。这样,相比一个输出要由所有输入决定的全连接网络,参数量得到进一步的减少。

除了减少参数量外,这两个特性还让网络更加不容易过拟合。回想之前学过的dropout,卷积的这些特性就和扔掉了部分激活输出一样。

另外,卷积操作还适合捕捉平移不变性(translation invariance)。这个词的意思是说,如果一张图里画了一个小猫,如果你把图片往右移动几格,那么图片里还是一个小猫。由于同样的卷积操作会用在所有像素上,这种平移后不变的特性非常容易被CNN捕捉。

总结

在这堂课中,我们认识了CNN的三大基础构件:卷积、池化、全连接。其中,卷积和池化是新学的知识。这堂课的内容非常多,也非常重要,让我们来回顾一下。

  • CNN 的优点
    • CNN 与全连接网络的参数比较
    • 权重共享、稀疏连接
  • 卷积操作
    • 基本运算流程
    • 填充
    • 步幅
    • 示例:边缘检测
  • 卷积层
    • 对多通道图像卷积
    • 输出多通道图像
    • 加上bias,送入激活函数
  • 池化层
    • 运算流程
    • 最大池化与平均池化
  • CNN 示例
    • 如何组合不同类别的层:卷积接池化,最后全连接。
    • 图像边长变小,通道数变大。

由于深度学习编程框架通常会帮我们实现好卷积,卷积的实现细节倒没有那么重要。在这周的课里,最重要的是一些宏观的知识。我们要知道卷积有哪些参数、哪些超参数,了解卷积的优点。同时,还要知道卷积和其他构件是如何组成一个CNN的。

在这周的编程实战里,我们会用框架(TensorFlow和PyTorch)实现一个简单的CNN,完成图像分类任务。有时间多的话,我还会介绍一下如何用NumPy实现卷积的正向和反向传播。

第三阶段回顾

在过去两周里,我们学习了改进深度学习模型的一些策略。让我们来回顾一下。

首先,我们应该设置好任务的目标。选取开发/测试集时,应参考实际应用中使用的数据分布。设置优化指标时,应使用单一目标。可以设置一个最优化目标和多个满足目标。

在搭建模型时,我们可以根据现有的数据量、问题的难易度,选择端到端学习或者是多阶段学习。

训练模型前,如果有和该任务相似的预训练模型,我们可以采取迁移学习,把其他任务的模型权重搬过来;如果我们的模型要完成多个相似的任务,可以同时训练多个任务的模型。

有了目标,搭好了模型之后,就可以开始训练模型了。有了训练好的模型后,我们可以根据模型的训练误差、训练开发误差、开发误差来诊断模型当前存在的问题。当然,在诊断之前,我们可以先估计一下人类在该问题上的最低误差,以此为贝叶斯误差的一个估计。通过比较贝叶斯误差和训练误差,我们能知道模型是否存在偏差问题;通过比较训练误差和训练开发误差,我们能知道模型是否存在方差问题;通过比较训练开发误差和开发误差,我们能知道模型是否存在数据不匹配问题。

另一方面,如果在改进模型时碰到了问题,不妨采取错误分析技术,看看模型究竟错在哪。我们可以拿出开发集的一个子集,统计一下模型的具体错误样例,看看究竟是模型在某些条件下表现得不好,还是标错的数据太多了。

这些内容可能比较偏向于工程经验,没有过多的数学理论。但是,相信大家在搭建自己的深度学习项目时,这些知识一定能派上用场。

第四阶段预览

在这之后,我们要分别学习两大类神经网络:处理图像的网络和处理序列数据的网络。在第四门课《卷积神经网络》中,我们就会学习能够处理图像问题的卷积神经网络。一起来看看接下来要学的内容吧。

《卷积神经网络》的课需花四周学完。第一周,我们会学习卷积神经网络的基本构件,建立对卷积神经网络的基本认识,为后续的学习做准备。具体的内容有:

  • 卷积操作
    • 从卷积核到卷积
    • 卷积的属性——填充、步幅
    • 卷积层
  • 池化操作
  • 卷积神经网络示例

最简单的计算机视觉任务是图像分类。第二周,我们将学习一系列图像分类网络。这些网络不仅能在图像分类上取得优秀的成绩,还是很多其他计算机视觉任务的基石。通过学习它们,我们不仅能见识一些经典网络的架构,更能从中学习到搭建卷积神经网络的一般规律。其内容有:

  • 早期神经网络
    • LeNet-5
    • AlexNet
    • VGG
  • 残差神经网络
  • Inception 网络
  • MobileNet
  • 搭建卷积网络项目
    • 使用开源代码
    • 迁移学习
    • 数据增强

第三周,我们将学习计算机视觉中一个比较热门的任务——目标检测。目标检测要求算法不仅能辨别出图片中的物体,还要能把物体精确地框出来。我们会一步一步学习如何搭建完成目标检测的卷积神经网络:

  • 目标定位与关键点检测
  • 使用卷积神经网络的目标检测
    • 滑动窗口算法
    • 基于卷积的滑动窗口
  • YOLO 算法
    • 结合目标定位与滑动窗口
    • 交并比(IoU)
    • NMS(非极大值抑制)
    • 锚框(Anchor boxes)
  • R-CNN 系列算法简介

此外,这周还会稍微提及另一个计算机视觉任务——语义分割的基本知识:

  • 基于U-Net的语义分割
    • 反卷积
    • U-Net架构

最后一周,第四周,我们又会认识两个新任务:人脸检测与神经网络风格迁移。具体的内容有:

  • 人脸检测
    • 人脸检测问题与一次性学习
    • 孪生神经网络
    • 三元组误差
    • 转化成二分类问题
  • 神经网络风格迁移
    • 风格迁移简介
    • 利用神经网络学到的东西
    • 风格迁移中的误差
    • 推广到1维和3维

相比之前的课,学习第四门课时需要花更多的精力,主要因为以下几点:

  1. 课程难度变高。
  2. 课程的编程练习很多。
  3. 课堂上介绍了很多论文作为拓展学习的方向。

如果你未来要以计算机视觉为研究方向的话,这四周的内容一定要认真掌握。同时,编程练习和论文阅读也不能落下。据我估计,如果要打好计算机视觉方向上的坚实的基础,至少还要多花费两周时间去认真阅读经典论文,做好相关的技术调研。

在未来的几周里,我仅会上传课堂笔记,并尽最大可能复现一下课后的习题。在所有的五门课上完后(大约2个月后),我会回过头来补充计算机视觉相关的论文阅读笔记、项目实现笔记,对视频课中没来得及讲完的内容查缺补漏,以呈现一套翔实的深度学习学习笔记,辅助大家更好地入门深度学习。

拍照时,我们可能辛辛苦苦地找了个角度,却忘记了调整光线,拍出了黑乎乎的照片:

这种情况下,最常见的补救方法是P图。打开PhotoShop,按下”ctrl+m”,就能够打开调整图像亮度的界面:

这个界面中间灰色的区域表示图像的亮度分布。坐标轴横轴表示亮度,纵轴表示对应亮度的像素的数量。可以看出,整幅图片非常暗,亮度低的像素占了大多数。

为了提亮图片,我们可以调整中间那条曲线。这条曲线表示如何把某一种亮度映射到另一种亮度上。初始情况下,曲线是$y=x$,也就是不改变原图片的亮度。由于低亮度的像素占比较多,我打算构造一个对低亮度像素进行较大增强,而尽可能保持高亮度像素的曲线。其运行结果如下:

嗯,不错。看起来图像确实变亮了不少。但感觉图片看上去还不够自然。有没有一种自动帮我们提亮图像的工具呢?

Zero-DCE就是一个利用深度学习自动调亮图片的算法。让我们看看它的运行结果:

哇!这也太强了。除了效果好之外,Zero-DCE还有许多亮点:

  • 不需要带标注的数据,甚至不需要参考数据(这里的参考数据指一张暗图对应的亮图)!
  • 训练数据少,训练时间短,只需约30分钟。
  • 推理速度极快。在手机上也能实时运行。

让我们来读一下Zero-DCE的论文,看看这个算法是怎么实现的。看完论文后,我还会解读一下官方的PyTorch代码实现。

Zero-DCE 论文解读

核心思想

自从CNN(卷积神经网络)火了以后,很多图像问题都可以用CNN来解决:把图像输入进CNN,乱卷一通,最后根据任务的需要,输出分类的概率(图像分类)、检测框和类别(目标检测)或另一幅图像(超分辨率)。

同时,对于输出也是一幅图像的问题,人们会利用GAN(生成对抗网络)能生成图像的特性,尝试用GAN来解决问题。比如在超分辨率任务中,GAN就得到了广泛的使用。

而图像提亮问题恰好就是一个输入、输出都是图像的问题。在此之前,既有基于CNN的方法,也有基于GAN的方法。人们尝试构造更精巧的网络,希望网络能输出亮度更合适的图像。

可是,Zero-DCE别出心裁,返璞归真地用了一种更简单的方式来生成亮度更合适的图像。还记得本文开头提到的,PhotoShop里的那个亮度映射曲线吗?实际上,我们只需要一条简简单单的曲线,把不同亮度的像素映射到一个新的亮度上,就足以产生亮度恰好合适的图像了。Zero-DCE就是用神经网络来拟合一条亮度映射曲线,再根据曲线和原图像生成提亮图像。整个计算过程是可导的,可以轻松地用梯度下降法优化神经网络。

另外,与其他一些任务不同,「亮度」是一个很贴近数学的属性。对于物品的种类、文字的意思这种抽象信息,我们很难用数字来表达。而亮度用一个数字来表示就行了。因此,在图像提亮问题中,我们不一定需要带标签的训练数据,而是可以根据图像本身的某些性质,自动判断出一幅图像是不是“亮度合理”的。

为了让计算机自动判断生成图像的亮度、与原图像的相似度等和图像质量相关的属性,Zero-DCE在训练中使用了一些新颖的误差函数。通过用这些误差函数约束优化过程,算法既能保证生成出来的图片亮度合理,又能保证图片较为真实、贴近原图像。

拟合亮度映射的曲线、不需要标签的误差函数,这两项精巧的设计共同决定了Zero-DCE算法的优势。原论文总结了该工作的三条贡献:

  1. 这是第一个不需要参考结果的低光照增强网络,直接避免了统计学习中的过拟合问题。算法能够适应不同光照条件下的图片。
  2. 该工作设计了一种随输入图像而变的映射曲线。该曲线是高阶的。每个像素有一条单独的曲线。曲线能高效地完成映射过程。
  3. 本作的方法表明,在缺乏参考图像时,可以设计一个与任务相关而与参考图像无关的误差,以完成深度图像增强模型的训练。

除了学术上的贡献外,算法也十分易用。算法的提亮效果优于其他方法,训练速度和推理速度更是冠绝一方。

接下来,让我们详细探究一下亮度映射曲线、误差函数这两大亮点究竟是怎么设计的。

提亮曲线

本文使用的亮度映射曲线被称作提亮曲线(Light-Enhancement Curve, LE-curve)。设计该曲线时,应满足几个原则:

  1. 由于亮度值落在区间$[0, 1]$,为保证亮度值的值域不变,曲线在0处值要为0,在1处值要为1。
  2. 曲线必须是单调递增的。不然可能会出现图像中原本较亮的地方反而变暗。
  3. 曲线公式必须简单,以保证可导。

因此,本作使用了如下的公式描述曲线:

其中,$\mathbf{x}$是像素坐标,$\alpha \in [-1, 1]$是可学习参数,$LE(I(\mathbf{x}); \alpha)$是输入$I(\mathbf{x})$的增强图像(三个颜色通道分别处理)。这个函数非常巧妙,大家可以验证一下它是不是满足刚刚那三条原则。

$\alpha$是公式里唯一一个可变参数。我们来看看不同的$\alpha$能产生怎样的曲线;

可以看出,$\alpha$虽然能够上下调节曲线,但由于曲线本质上是一个二次函数,曲线的变化还不够丰富。为了拟合更复杂的曲线,本作迭代嵌套了这个函数。也就是说:

一般地,

迭代嵌套开始那个二次函数,就能够表示一个更高次的函数了。每一轮迭代,都有一个新的参数$\alpha_n$。本作令最大的$n$为8,即调用二次函数8次,拟合某个$2^8$次函数。

但是,我们不希望每个像素都用同样的提亮函数。比如如果图像中某个地方亮着灯,那么这个地方的像素值就不用改变。因此,每个像素应该有独立的$\alpha$。最终的提亮函数为:

这一改动还是很有必要的。下图显示了某输入图片在不同像素处的$\alpha$的绝对值:

可以看出,在较亮的地方,图像没有变化,$\alpha$几乎为0;而在较暗的地方,$\alpha$的数值也较大。

知道了要拟合的目标曲线的公式,下面我们来看看拟合该曲线的神经网络长什么样。

由于要拟合的数据不是很复杂,本作使用到的网络DCE-Net非常简单。它一共有7层(6个隐藏层,1个输出层)。所有层都是普通的3x3等长(stride=1)卷积层。为保持相邻像素间的联系,卷积层后不使用Batch Normalization。隐藏层激活函数为ReLU,由于输出落在$[-1, 1]$,输出层的激活函数是tanh。如图所示,6个隐藏层使用了和U-Net类似的对称跳连。3、4层的输出会拼接到一起再送入第5层,2、5层输出拼接送入第6层,1、6层输出拼接送入第7层。经过输出层后,每个像素有24个通道——有RGB 3个颜色通道,每个通道有8个参数。

似乎开源代码里没有去掉Batch Normalization。

看完了网络结构与其输出的意义,我们继续看一下误差函数是怎么设置的。

无需参考的误差函数

为了能不使用参考数据,本作精心设计了四个误差函数,以从不同的角度约束增强后的图像。

空间一致误差(Spatial Consistency Loss)

图像增强后,我们肯定不希望图像的内容发生改变。更准确一点描述,我们不希望某像素的值和其相邻像素的值的差发生过大的改变。因此,我们可以设置下述误差:

,其中$K$是像素数,$i$是对像素的遍历。$\Omega(i)$是第$i$个像素的4邻域。$Y, I$分别是增强图像和输入图像。

但实际上,我们的要求不必那么苛刻,不用要求每个像素和周围像素的相对值都不改变。在实现中,$i$其实是一个$4 \times 4$的一个“大像素”区域,每个大像素的值是其中所有像素值的平均值。在实现时,大像素可以通过平均池化来求得。因此,上式中的$K$其实指的是大像素的数量,$Y, I$分别是增强图像和输入图像经池化后得到的图像。

曝光控制误差(Exposure Control Loss)

为了不让某些地方过暗,某些地方过亮,我们可以让极端亮度更少,即让每个像素的亮度更靠近某个中间值。这个约束可以用如下的误差函数表达:

,其中常数$E$描述了亮度的中间值,根据经验可以取0.6。和之前的$Y$类似,这里的$Y$也是一个大像素区域中亮度的平均值。大像素宽度可调,文中使用的宽度是16。$M$是大像素的总个数。

颜色恒定误差(Color Constancy Loss)

根据前人研究中的某些结论,图像某一颜色通道的数值不应显著超出其他通道。因此,有如下误差:

,这里,$(p, q)$遍历了三个颜色通道中所有两两组合,$J_p$表示颜色通道$p$的亮度平均值。

光照平滑误差(Illumination Smoothness Loss)

为了保持相邻像素的单调关系,即让相邻像素之间的亮度改变不是那么显著,我们需要让相邻像素间的参数$\alpha \in A$更相近一点。这种要求可以这样表示:

,其中,$N$是迭代次数,$\nabla_x, \nabla_y$分别是水平和垂直的梯度算子。对于图像,水平梯度和垂直梯度就是和左方、上方相邻像素之间的数值的差。

网上公开出来的论文中,这个公式少了一个左绝对值号。

总误差

总误差即上述四个误差的加权和:

理论上,描述4个量的相对加权关系至少要3个权重(默认剩下一个权重为1)。但是,原论文只写了两个权重。而代码里却有3个权重。我认为是论文没写清楚。

在开源代码中,上述四个权重分别为$W_1=1, W_2=10, W_3=5, W_4=200$。

这四个误差中,有几个误差的作用十分重要。大家可以看看去掉某项误差后,网络的复原效果:

去掉$L_{spa}$后,生成出来的图像勉强还行。剩下的误差,哪怕去掉任何一个,生成图像的效果都会很差劲。

Zero-DCE++

Zero-DCE是发表在CVPR会议上的。之后,Zero-DCE的拓展版Zero-DCE++发到了TPAMI期刊上。期刊版版面足够,原论文中一些来不及讲清的地方(比如空间一致误差)在期刊版中都有更详尽的说明。大家如果想读论文,建议直接读期刊版本的。论文层层递进,逻辑非常清楚,非常适合从头到尾读一遍。

Zero-DCE++在方法上主要是对性能上进行了一些增强,而没有改进原作的核心思想。拓展点有:

  1. 和MobileNet类似,把普通卷积替换成更快的逐通道可分卷积(depthwise separable convolution)。
  2. 经研究,8次迭代中,每次的参数$\alpha$都差不多。因此,可以让网络只输出3个值,而不是24个值。
  3. 由于该任务对图像尺寸不敏感,为了减小卷积开销,可以一开始对图像下采样,最后再上采样回来。

经优化后,参数量减少8倍,运算量在一般大小的图像上减少上百倍,训练只需20分钟。

总结

Zero-DCE是一个简单优美的低光照增强算法。该算法巧妙地建模了光照增强问题,并创造性地使用了和参考数据无关的误差,竟然让基于深度学习的低光照增强算法做到了训练块、性能高、对数据要求低。希望这篇文章用到的思想也能启发其他图像任务。

然而,本文的第一作者在指导我们时说道:“低光图片增强问题要解决两件事:图像去模糊和亮度增强。而Zero-DCE只能完成后者。同时,低光图片的特例也非常多。现在想做一个低光照增强的商业应用是很困难的。”是啊,想让低光照增强落地,用手机瞬间点亮拍暗了的照片,任重而道远啊。

Zero-DCE 开源代码的使用

代码可以在 https://github.com/Li-Chongyi/Zero-DCE 里找到。

由于算法没那么复杂,实现所需的代码并不多。同时,这份代码也写得比较工整清楚。整份代码读起来还是非常轻松的。

安装与使用

直接clone仓库:

1
git clone git@github.com:Li-Chongyi/Zero-DCE.git

之后,切到内侧的文件夹:

1
cd Zero-DCE/Zero-DCE_code

直接运行脚本就行了:

1
python lowlight_test.py 

注意!!这份代码对Windows不太友好,有一处路径操作写得不好。在lowlight_test.py这份文件中,有一坨完成os.makedirs()的代码,建议改成:

1
2
3
dir, fn = os.path.split(result_path)
if not os.path.exists(dir):
os.makedirs(dir)

同时,代码用VSCode打开后编辑,会出现莫名其妙的缩进不对齐问题。建议拿个格式化工具修一下。为了编辑这份代码,我不得不把所有缩进重新调了一遍。

这是我跑的一个结果,效果很不错:

代码选读

代码实现中有一些可以讲一讲的地方。

看一下神经网络的实现:

整个神经网络部分还是很简明的。

那个求第一个误差空间一致误差L_spa的代码是很炫酷的。让我们忽略掉那个合成大像素的操作,直接看一下这里和相邻像素的差是怎么实现的。

首先,这里定义了一堆“参数”。

之后,这些参数被扔进了卷积里,用来卷原图像和增强图像。这是在干什么呢?

原来啊,在深度学习时代之前,卷积本来就是图像处理里的一个普普通通的操作。开始那张图定义的不是参数,而是3x3常量卷积核。用那几个卷积核卷积图像,可以得到图像和上下左右之间的差。

这种写法很帅,但是增加了很多计算量。文件里有很多没删干净的代码,不知道是不是本来还有其他设计。

在第四个误差L_TV里,也有一个要算和相邻像素之间的差的梯度计算。这份实现就写得老实多了。

这份代码中就是这里有一点难看懂,其他地方都是非常基础的PyTorch调用,非常适合初学者用来学习PyTorch。

彩蛋

其实我的头像一开始也拍得很暗。我是拿PS把这张照片提亮的。

非常凑巧,我在提亮这张照片时,也是用PS里的那个曲线迭代了几次。每次的曲线也恰巧都是一个二次函数。其实现过程和这篇工作如出一辙。

那么,让Zero-DCE来增强这幅图像,能达到怎样的效果呢?

看来,这个算法还是不太行啊。脸部的光照过于均匀,以至于失去了真实性。头发也白了。比我自己P的差多了。而且,我根本不会用PS,只是随手调了一下,P得也不是很好。AI想战胜人类,还是早了一万年啊。

这是我这几个月来第一次只想把文章发到个人博客上,而不想发到其他平台上。

顺带一提,我是这样看待个人博客与其他平台的:个人博客写的东西,基本都是围绕我自己。只有非常关注我的人,才会看到这些内容。大众看不看得到,无所谓。我就是为了自己而写。我可以不管语法,不管内容,想怎么写怎么写。而在其他平台写的东西,多少有一点宣传的意思在内,会受到种种制约。

前几天,我去发表了一篇个人感想的文章。风格其实和我之前的博文差不多,但是主题更吸引人(尤其是标题),内容稍长。如我所意料地,相对我现在拥有的流量而言,这篇文章收获了空前的关注。

我开心也不开心。开心是因为,总算写的东西有很多人看了。而且,这篇文章很受欢迎,完全在我的意料之中。也就是说,我有估计文章传播力的判断力。真的只是为了提升文章的阅读数据,我可以轻松写出一堆文章来。

不开心的原因就比较复杂了。

上周六晚上,发文章之前,我突然心跳加速,紧张了起来。文章是几周前写的,也早早就决定了要发。我也不知道为什么突然会这样。发完文章,我就立刻去吃饭了,也不敢盯着发送的数据看。吃饭回来一看,文章果然大受关注。第二天,知乎也开始推送我的文章了,也确实引了一大批人。

看到大家的评论,我这才明白我在担心什么。

我那篇文章写得是什么?纯粹写的是我的心路历程,我做了什么,想了什么。我的经历多么可笑,我的想法多么龌龊。我根本不管这么多,只是原原本本地写了下来,然后发了出去。就好像没穿衣服站在街上一样。

当然,我之所以敢这么做,就是因为我已经完全看淡了过去的那些事情。我敢写出来,说明我把过去的那个“我”已经不当成我自己了。我把那段经历,完完全全当成一个商品在售卖。经历是个人组成的一部分。也就是说,我把自己灵魂的一部分割了下来,拿出去卖。

有些人分享经历,是为了获取认同。获得他人的赞,就像获得了他人的鼓励一样。

但是,我把自己的目的写得很明确:主要是为了流量,次要是为了分享。他人的赞,甚至是加油的祝福,对早已熬过去的我来说一点帮助也没有。

甚至我还有层次更高的考虑。我写这篇文章,就是要向大众展示真实的自己。我不怕别人看到自己的缺点。我不会去打造一个完美无缺的人。不论以后我做成了多大的事情,我都只是一个心思很普通的人。我要拿这篇文章去约束未来的自己。

当然,我准备发表这篇文章时,还有更多的考量。这些层次非常高的考量是我潜意识里计划的,我现在一下都总结不出来。我说的很多东西,现在听起来都是吹牛,但我相信过个几年回头来看,我现在说的东西都是必然的预言。

可是,这样的写作可持续吗?

不可能的。

我再怎么如自己所期盼得铁石心肠,也不愿意天天把自己的事情拿出去展示。不管是好的事情,还是坏的事情。讲好的事情,就是拿自己过去的成就吹嘘;讲坏的事情,就是卖惨,博同情。这两件事我都不愿意做。我更希望我的作品受到关注。那些东西是实实在在的应该拿出来展示的东西。

我的思绪有点乱,一下也表达不清我想说什么。好不容易写一次个人博客,我也不想改文章了。我就想想到哪写到哪。总之,以后,我会在分享个人经验的时候更加慎重。我既不要过分卖弄成就,也不要刻意地去塑造一个很惨的形象。发个人经历的文章之前,要更加慎重。

还有一点,我很不开心。对比之下,看技术文章的人寥寥无几,而输出情感的文章倒是喜闻乐见。这里面的道理我肯定懂。受众、花费的精力……很多东西决定了文章有没有人看。我从类型上非常能够理解。但心里就是不好受。

所以想来想去,思绪还是回到了之前那个问题上:我为什么要去做自媒体?

我一直没有单独谈这个问题。只在我个人博客的这个地方这个地方稍微谈了一下。现在可以认真想一下了。

我还确实是一个能不说谎就不说谎的人。哪怕是讲真话没什么意义,我也会毫不在意地表示自己的真实想法。我就是要让自己一直做一个坦荡的人。我在第一篇DL教程开头就讲了,“诲人不如诲己”。写文章,本来就是为了提升我自己的学习效率。也确实,自从开始在自媒体上发文章后,我每周都很固定地会写好教程,学习的效率高了很多。同时,还有几个人说愿意跟着教程学。我也不管是真的假的,只要能帮助到人,我就非常开心。因此,发教程,不管有没有人看,我都照发不误。

这正常地发下去,倒也没什么关系。可是,发完个人经历感想,受到了一定程度的关注后,我反而开始动摇了:“是不是以后都得写这种文章才有人看?”,“我写技术文章还有意义吗?”,之类的想法不断涌现。

我必须得重新审视一下我搞自媒体的意义了。

我向来就讨厌迎合他人,迎合社会,迎合任何人订下的评价标准。开始发自媒体,我还以为是自己终于肯变通一下,肯去迎合一下这个世界的规则了。甚至当时我还嘲笑了自己的不坚定。

但现在我发现,我根本没变。我就是一个无比自傲的人。我就是能够创作出好的东西,我就是能给他人带来价值。凭什么我要去迎合别人的想法?凭什么我要去宣传?我不服气啊。

我之前之所以没想这么多,就是如前面所写的,我一直抱有的是提升自我,顺便分享给他人的心态。

但我一想到要最优化我在自媒体上的名气,我就很不服气。我就是不像刻意去为了提升影响力而提升影响力。我要靠我展示出来的真正的东西来提升影响力。无论是艺术作品,还是教程,还是科研产出,还是其他什么的。我不想花一丝一毫在宣传上。我就只想把自己的东西做到极致,让他人主动来我这里看东西。

没办法,我内心的理性都很清楚,这个世界的规则是怎么样的,自媒体是怎么运行的。理性思考指出,我想要的事情是不太可能的。

这就是一个站着还是跪着的抉择。我没有膝盖,跪不下来,只会站着;我也没有脖子,不会向下看,只会盯着前方看。做不到的事情,就是不想做。

其实,我想搞自媒体,最初的想法就是不希望未来出现这些事。我以后要做游戏,不希望做了好游戏没人玩。所以,我想着是做视频。我的口才好,在某些领域一定能大受欢迎。但我还是害怕,害怕没人看。有人(包括我内心里质疑的声音)这就会说了:“你害怕,不就是做不好吗?还找什么借口?”那我表述得再详细一点。假如我做了自以为100分的东西,客观上能打80分,但是只收获了0.01分的关注度,换你来你受得了吗?而且创作是一个不断进步的过程,可能开始还不太习惯,做着做着就能发挥全部的才能了。这一开始就没了创作热情,还怎么做下去呢?再有,做视频,最重要的其实是时间上的成本。我暂时想不出一个时间成本低、视频节目效果好、有干货的视频项目。所以不做。

那问题又传递下去了。不敢做游戏,所以计划做视频;不敢做视频,所以计划写文章。这才是我最后决定搞文字自媒体的原因。

现在,问题又绕回来了。就是如我所料地,在没有流量的前提下,写得好但是不吸引人的东西,就是没人看。我又不太肯去最优化流量,去想法设法地宣传。这下好了,又陷入死结了。

我发完上一篇个人感想,受到一定程度的欢迎后,我的大脑一下子就想到了这么多,所以一下低沉了起来。我花了大约一小时,才把这些想法组织起来,理解了自己的心理的成因。我的思维也太跳跃了吧。

再仔细一想,问题也很简单:还做不做下去?到了该抉择的时候了。

不做也没什么关系。对我来说,靠实力把名气打响是很简单的。我现在在搞科研,搞得好,自然能收获名誉。这种名气与实力挂钩的领域是我最推崇的。另外,我也可以直接去做游戏。游戏做完了,我觉得做得足够好了,再去想该怎么宣传。不要担心卖不出去,搞好质量就行了。

对,我就是有这样的自信。不靠现在的自媒体,不去刻意宣传,还是刻意把名气打响。

只是到时候不叫座就怪不得别人了。一切都是自己菜。

那要不就还是做下去。不忘初心。

初心是什么?就是分享。发文章是零成本的事情,有人看就是赚到。

也不去做什么宣传,该怎么样就怎么样了。

我的性格就是不适合做自媒体。我讨厌过度拟合一个目标,讨厌为了目的不择手段。在受到他人赞誉的时候也没有那么强的正反馈。

但我还有一个足以忽视其他一切条件的个性:我不相信有我做不好的事情。

一般的人质疑我,我笑一笑也就过去了。问题是我开始自己质疑自己做不好了,这就令我很焦躁了。

说我做不好自媒体,说我不肯去迎合他人,说我太傲慢了。

好,我忍。

我就必须证明一下自己了。

我会再做一段时间自媒体,稍微花点心思去总结一下他人的经验,制定更详细的计划,更有智谋地完成自媒体运营这件事。

同样,我会坚持几个底限:不去刷数据;不去写没有价值的东西;不过度宣传;不影响我的主业。

如果我发现,搞自媒体要花太多精力,影响到我的其他计划的话,我就放弃。否则,我会用这件事来展示我的能力,搞一个有影响的自媒体出来。

其实写这篇文章之前,我心里基本就有了答案。写一遍只是让自己更清醒一点。我可以再次宣告道:请世人见证我的成就。

今天跟喝醉了一样,但我从来不喝酒。所以,也可以这样说,我怎么都喝不醉。

冲了个澡,醒酒了。运营自媒体和个人博客完全是两个东西。自媒体必须从用户的角度来考虑文章题材。我还是按照老计划,先零成本地把技术博客给写好来。之后,用一种处于博客和自媒体的中间形式来运营。偏自媒体的文章中,主要关注两种形式:一篇长篇大作和定期更新长期连载的小体量作品。此外,夹杂一些和大学教育、编程有关的非技术的感想文章。没必要为了文章关注量这种小事担心,做好手中的事就行了。

学习提示

这周要学习四项内容:错误分析、使用不匹配的数据、完成多个任务的学习、端到端学习。

其中,前两项内容是对上一周内容的扩展。学完这些知识后,我们能更好地决定下一步的改进计划。通过处理分布不匹配的数据,我们能够学会如何诊断一种新的问题:数据不匹配问题。之后,我们使用错误分析技术,找到模型具体的错误样例,进一步改进模型。

后两项内容分别是两项深度学习的应用技巧。我们会学习迁移学习、多任务学习这两种处理多个学习任务的方法。我们还会学习如何用深度学习把问题一步到位地解决,而不是分好几个步骤。

课堂笔记

错误分析

分析具体错误

当我们想提升模型的准确率时,一种做法是统计模型输出错误的样例,看看哪类数据更容易让模型出错。

比如,在提升一个小猫分类器的准确率时,我们可以去看看分类器最容易把其他哪种动物错分类成小猫。经过调查后,我们可能会发现一些小狗长得很像小猫,分类器在这些小狗图片上的表现不佳:

这时,我们可以考虑去提升模型在小狗图片上的表现。

但是,在决定朝着某个方向改进模型之前,我们应该先做一个数据分析,看看这样的改进究竟有没有意义。我们可以去统计100张分类错误的开发集图片,看看这些图片里有多少张是小狗。如果小狗图片的数量很小,比如说只有5张,那么无论我们再怎么提升模型辨别小狗的能力,我们顶多把10%的错误率降到9.5%,提升微乎其微;但如果错分为小狗图片的数量很多,比如有50张,那么我们最优情况下可以把错误率从10%降到5%,这个提升就很显著了。

更系统地,我们可以建立一套同时分析多个改进方向的数据分析方法。比如说,在小猫的错误样例中,一些输入样本是很像小猫的小狗,一些输入样本是其他大型猫科动物,一些输入样本过于模糊。我们可以挑一些错误的样例,分别去记录这些错误样例的出现情况:

在这个表格中,我们可以记录每张分类错误的图片是由哪一种错误引起的,并留下一些备注。

调研已有问题的同时,我们还可以顺便去发现一些新的问题。比如我们可能会发现某些错分类的图片加了滤镜。发现这个新问题后,我们可以去表格中新建“滤镜”这一列。

手动分析完所有样例后,我们统计每种错误的百分比,看看改进哪种问题的价值更大。

清理标错的数据

在有监督学习中,标注数据往往是人工完成的,数据的标签有误也是情理之中的事。那么,如果数据中有标错的数据,它们会对模型的表现有什么影响呢?

首先,来看训练集有误的影响。事实上,深度学习算法对随机错误的容忍度很高。如果有少量样本是不小心标错的,那么它们对训练结果几乎没有影响。但是,如果数据中有系统性错误,比如白色的小狗全部被标成了小猫,那问题就大了,因为模型也会学到数据集中这种错误的规律。

接着,我们来看开发集有误的影响。为了确认标错数据的影响,我们应该用刚刚的表格统计法,顺便调查一下标错数据的比例:

在开发集误差不同时,标错数据产生的影响也不同。假设我们分别有一个开发集误差为10%的分类器和一个误差为2%的分类器:

对于第一个分类器,总体占比0.6%的错标数据相对于10%的开发集错误率几乎可以忽略。但是,对于第二个误差为2%的分类器,0.6%的错标数据就显得占比很大了。在这种情况下,假如有同一个模型有两个权重记录点,一个误差为2.1%,一个误差为1.9%。由于误差的存在,我们不好说第二个记录点就优于第一个记录点。回想一下,开发集本来的目的就是帮助我们选择一个在开发集上表现更好的模型。分辨不出更好的模型,开发集就失效了。因此,我们必须要去纠正一下这些开发集中的错标数据。

在纠正错标数据时,我们要注意以下几点:

  • 由于开发集和测试集应来自同一个分布,纠正数据的过程应该在开发集和测试集上同步进行。
  • 不仅要检查算法输出错误的样本中的错标样本,还要考虑那些标注错误却输出正确的样本。
  • 不一定要去训练集上纠正错标样本,因为训练集和开发集/测试集可以来自不同的分布。

吴恩达老师建议道,尽管很多人会因为检查数据这件事很琐碎而不愿意去一个一个检查算法输出错误的样本,但他还是鼓励大家这样做。他在自己领导的深度学习项目中,经常亲自去检查错误样本。检查错误样本往往能够确认算法之后的改进方向,在这件事上花时间绝对是值得的。

快速构建第一个系统,再迭代更新

在面对一个全新的深度学习问题时,我们不应该一上来就花很多时间去开发一个复杂的系统,而是应该按照下面的步骤尽快开始迭代:

  • 快速建立开发集、测试集和评估指标以树立一个目标。
  • 快速构建一个初始的系统。
  • 使用偏差和方差分析、错误分析来获取后续任务的优先级。

简而言之,就是:快速构建第一个系统,再迭代更新。

当然,如果你在这个问题上已经很有经验了,或者这个问题已经有很多的科研文献,那么一上来就使用一套较为复杂却十分成熟的系统也是可以的。

这种快速迭代的思想同样适用于人生中的其他任务。比如,软件开发中,敏捷开发指的就是快速开发出原型,再逐步迭代。同样,我们在计划做一件事时,不必事先就想得面面俱到,可以尽快下手,再逐渐去改良做法。

不匹配的训练集与开发/测试集

在不同分布上训练与测试

到目前为止,我们已经多次学习过,开发集和测试集的分布必须一致,但是它们与训练集的分布不一定要一致。让我们来看一个实际的例子:

假设我们要开发一个小猫分类的手机程序。我们有两批数据,第一批是从网站上爬取的高清图片,共200,000张;第二批是使用手机摄像头拍摄上传的图片,有10,000张。最终,用户在使用我们的手机程序时,也是要通过拍照上传。

现在,有一个问题:该如何划分训练集、测试集、开发集呢?

一种方法是把所有数据混在一起,得到210,000张图片。之后,按照某种比例划分三个集合,比如按照205,000/2,500/2,500的比例划分训练/测试/开发集。

这种方法有一个问题:我们的开发集和测试集中有很多高清图片。但是,用户最终上传的图片可能都不是高清图片,而是模糊的收集摄像图片。在开发集和测试集中混入更简单的高清图片会让评估结果偏好,不能反映模型在实际应用中的真正表现。

另一种方法是只用手机拍摄的图片作为开发集和测试集。我们可以从手机拍摄的图片里选5,000张放进训练集里,剩下各放2,500张到开发/训练集里。这样的话,开发集和测试集就能更好地反映模型在我们所期望的指标上的表现了。

总结来说,如果我们有来自不同分布的数据,我们应该谨慎地划分训练集与开发/测试集,尽可能让开发/测试集只包含我们期待的分布中的数据,哪怕这样做会让训练集和开发/测试集的分布不一致。

不同数据分布下的偏差与方差问题

在之前的学习中,我们一直把机器学习模型的改进问题分为偏差问题和方差问题两种。而在使用不匹配的数据分布后,我们会引入一个新的分布不匹配问题。

还是在刚刚提到的小猫分类模型中,我们用第二种方法设置了分布不一致的训练集和开发/训练集。假设我们得到了1%的训练误差和10%的开发误差。但是,我们使用了不同分布的数据,开发/测试集的数据可能比训练数据要难得多。我们难以分辨更高的开发误差是过拟合导致的,还是开发集比训练集难度更高导致的。

为了区分这两种问题,我们需要划分出一个只评估一种问题的新数据集——训练开发集(Training-dev set)。训练开发集的用法和我们之前用的开发集类似,但是其数据分布和训练集一致,而不参与训练。通过比较模型在训练集和训练开发集上的准确度,我们就能单独评估模型的方差,进而拆分过拟合问题和数据不匹配问题了。

加入了这个数据集后,让我们对几个示例进行改进问题分析。

假设人类在小猫分类上的失误率是0%。现在,有以下几个不同准确率的模型:

误差/样本 1 2 3 4
训练误差 1% 1 10% 10%
训练开发误差 9% 1.5% 11% 11%
开发误差 10% 10 12% 20%
问题诊断 高方差 数据不匹配 高偏差 高偏差、数据不匹配

也就是说,在多出了数据不匹配问题后,我们可以通过加入一个训练开发集来区分不同的问题。

当然,数据不匹配不一定会加大误差。如果开发/测试集上的数据更加简单,模型有可能取得比训练集还低的误差。

结合上周的知识,总结一下,考虑数据不匹配问题后,我们应该建立如下的表格:

首先,我们要知道训练集上人类的表现,以此为贝叶斯误差的一个估计。之后,我们要测训练误差和训练开发误差。训练误差和人类表现之间的差距为可规避偏差,训练开发误差和训练误差之间的差距为方差。最后,我们计算开发/测试集误差,这个误差和训练开发误差之间的差距为数据不匹配造成的差距。

一般来说,只把上述内容填入表格即可明确当前模型存在的问题。不过,如果我们能够获取开发/测试数据分布上的人类误差和训练误差,把上表填满,我们就能获取更多的启发。比如上表中,如果我们发现在开发/测试数据上人类的表现也是6%,这就说明开发/测试数据对于人类来说比较难,但是对模型来说比较简单。

完成多个任务

迁移学习

深度学习的一大强大之处,就是一个深度学习模型在某任务中学习到的知识,能够在另一项任务中使用。比如在计算机视觉中,目标检测等更难的任务会把图像分类任务的模型作为其模型组成的一部分。这种技术叫做迁移学习

假如我们有一个通用图像识别的数据集和一个医学图像识别数据集,我们可以先训好一个通用的图像识别模型,再对模型做一些调整,换医学图像数据上去再训练出一个医学图像识别模型。

具体来说,以上图中展示的情况为例,我们可以在训练完通用图像识别模型后,删掉最后一个输出层,初始化一个符合医学图像识别任务要求的输出层。之后,我们使用医学图像来训练。在这个过程中,如果新数据较少,我们既可以只训练最后的输出层,而保持其他层参数不变;如果新数据够多,我们可以让所有参数都参与训练。

这里还要介绍两个重要的深度学习名词。如果换新数据后要训练所有参数,则换数据前的训练过程称为预训练(pre-training) ,换数据后的训练过程称为微调(fine-tuning)

在上面的例子中,我们只是删掉了一个输出层,加了一个输出层。实际上,删哪些层换哪些层都没有一定的标准。如果任务变得更难了,我们可以删一个输出层,再加几个隐藏层和一个输出层。

迁移学习最常见的场合,是我们想完成训练数据较少的B任务,却在相似的A任务中有大量的训练数据。这时,我们就可以先学A任务,再迁移到B任务上。如果A、B任务的数据量差不多,那迁移学习就没什么意义了,因为同样是一份数据,对于B任务来说,一份B任务的数据肯定比一份A任务的数据要有用得多。

另外,迁移学习之所以能有效,是因为神经网络的浅层总能学到一些和任务无关,而之和数据相关的知识。因此,A任务和B任务要有一样的输入,且A任务的浅层特征能够帮助到任务B。

多任务学习

在刚刚学的迁移学习中,模型会先学任务A,再学任务B。而在另一个面向多个任务的学习方法中,模型可以并行地学习多个任务。这种学习方法叫做多任务学习

还是来先看一个例子。在开发无人驾驶车时,算法要分别识别出一张图片中是否有人行道、汽车、停止路牌、红绿灯……。识别每一种物体是否存在,都是一个二分类问题。使用多任务学习,我们可以让一个模型同时处理多个任务,即把模型的输出堆叠起来:

这里,一定要区分多个二分类问题和多分类问题。多分类中,一个物体只可能属于多个类别中的一种;而多个二分类问题中,图片可以被同时归为多个类别。

使用多任务学习时,除了输出数据格式需要改变,网络结构和损失函数也需要改变。多个二分类任务的网络结构和多分类的类似,都要在最后一层输出多个结果;而误差和多分类的不一样,不使用softmax,而是使用多个sigmoid求和(每个sigmoid对应一个二分类任务)。

此外,多个二分类任务和多分类任务还有一个不同。在执行多分类学习时,由于所有任务都用统一的数据,数据的标注可能有缺失。比如某几张图片可能没有标出红绿灯,另外几张图片又没有标出人行道。在多任务学习中,我们是允许数据中出现“模糊不清”的现象的,可以把没有标注的数据标成”?”。这样,碰到标注是”?”的数据时,我们就不对这一项进行损失函数的计算。

和迁移学习一样,多任务学习在使用上有一些要求。

首先,所有任务都必须受益于相同的浅层特征。这是显而易见的。

其次,每类任务的数据集都要差不多大。在迁移学习中,我们有比较大的数据集A和比较小的数据集B。而在迁移学习中,假如我们有100项任务,每种数据有1000条数据。对于每一项任务来说,其他99项任务的99000条数据就像数据集A一样,自己的1000条数据就像数据集B一样。

最后,经研究,只有当神经网络模型足够大时,使用多任务学习才至少不比分别学习每个任务差。

在实践中,迁移学习比多任务学习常见得多。

端到端深度学习

深度学习的另一大强大之处,就是端到端(end-to-end)学习。这项技术可以让搭建学习算法简单很多。让我们先看看端到端学习具体是指什么。

不使用深度学习的话,一项任务可能会被拆成多个子步骤。比如在NLP(自然语言处理)中,为了让电脑看懂人类的语言,传统方法会先提取语言中的词语,再根据语法组织起词语,最后再做进一步的处理。而在端到端学习中,深度学习可能一步就把任务完成了。比如说机器翻译这项NLP任务,用深度学习的话,输入是某语言的句子,输出就是另一个语言的句子,中间不需要有其他任何步骤。

相较于多步骤的方法,端到端学习的方法需要更多的数据。仅在数据足够的情况下,端到端学习才是有效的。下面,我们来看一个反例。

在人脸识别任务中,输入是一张图片,输出是图片中人脸的身份。这里有一个问题:识别人脸之前,算法需要先定位人脸的位置。如果使用端到端学习的话,学习算法要花很长时间才能学会找到人脸并识别人脸的身份。

相比之下,我们可以把这个人物拆成两个阶段:第一阶段,算法的输入是图片,输出是一个框,框出了人脸所在位置;第二阶段,输入是框里的人脸,输出是人脸的身份。学习算法可以轻松地完成这两个子问题,这种非端到端的方法反而更加通用。

总结一下,非端到端学习想要优于端到端学习,必须满足两个条件:每个子任务都比较简单;每个子任务的数据很多,而整个任务的数据很少。

那么,具体哪些情况下该用端到端学习,哪些情况下不用呢?我们来看看端到端学习的优缺点:

优点:

  • 让数据说话。相较于手工设计的某些步骤,端到端学习能够从海量数据中发现于更适合计算机理解的统计规律。
  • 减少手工设计的工作量,让设计者少花点精力。

缺点:

  • 可能需要大量的数据。
  • 排除了可能有用的手工设计的东西。比如人脸识别中,显然,找出人脸是一个绕不过去的子步骤。

归根结底,还是数据量决定了是否使用端到端学习。在复杂的任务中,要达成端到端需要非常非常多的数据,在不能够获取足够数据之前,还是使用多阶段的方法好;而对于简单的任务,可能要求的数据不多,直接用端到端学习就能很好地完成任务了。

总结

这周的知识点如下:

  • 错误分析
    • 用表格做错误分析
    • 统计错标数据
  • 数据不匹配
    • 何时使用数据分布不同的训练集和开发/测试集
    • 训练开发集
    • 如何诊断数据不匹配问题
  • 完成多个任务
    • 迁移学习的定义与常见做法
    • 预训练、微调
    • 多任务学习的定义
    • 多个二分类任务
    • 迁移学习与多任务学习的优劣、使用场景
  • 端到端深度学习
    • 认识端到端学习的例子
    • 何时使用端到端学习

和上周一样,这周的知识都是一些只需要了解的概念,没有什么很复杂的公式。大家可以较为轻松地看完这周的内容。

另外,这周也没有官方的编程作业。

经过了之前的学习,我们学会了许多改进深度学习模型的方法,比如:

  • 收集更多数据
  • 收集更多样化的数据
  • 延长训练时间
  • 用高级梯度下降算法
  • 缩小/扩大网络
  • 使用正则化
  • ……

这么多方法,如果只是一个一个试过去,开发效率就太低了。在未来的两周,我们会学习一些改进机器学习的策略。这些策略会给我们一些启发性的指导,让我们在改进模型时更明确下一步该做什么。

学习提示

这周课没有太多的新内容,主要是拓展了第二门课第一周有关偏差与方差分析的内容。学完了这周的课后,大家会进一步了解如何在一个全新的机器学习任务上设置目标,并通过误差分析等技术逐步靠近目标。

课堂笔记

正交化

如何从众多的改进方案中选择出优先级较高的呢?让我们先看看生活中一些其他事情的例子:

首先,是调整老式电视机的例子。老式电视机的画面不一定恰好能端端正正地填满屏幕,需要人为地调整画面的位置。一般这些电视机都有很多按钮,每个按钮各负责一项调整功能,比如调整上下位置、左右位置、缩放、旋转等。每个按钮之间的功能互不干扰。

另外,还有一个开汽车的例子。汽车最少有三种操作:转方向盘、加速、减速。只需要组合这三种操作,我们就能让汽车沿着某一路线跑起来。而如果汽车只有两个可以左右调整的按钮,一个按钮控制0.3倍的角度和-0.8倍的速度,另一个按钮控制2倍的角度和0.9倍的速度,那司机控制汽车时肯定会倍感吃力。

以上两个例子显示了正交化的好处。正交可以指数学里两条直线垂直,这里指的是两个调整方向互不干扰。通过调整正交的参数,我们可以把事物的“坐标分量”逐个调整到我们期待的“位置”。

类似地,在改进机器学习项目时,也可以使用正交化。

在机器学习项目中,大概有4个“坐标分量”需要调整:拟合训练集、拟合开发集、拟合测试集、提升实际应用中的表现。对于这每一项目标,我们都应该使用相互正交的策略去调整,比如:

  • 拟合训练集 - 用更大的网络
  • 拟合开发集 - 正则化
  • 拟合测试集 - 用更大的开发集
  • 提升实际应用中的表现 - 更换损失函数

值得一提的是,提前停止是一个即会影响训练误差,又会影响开发误差的方法。这个方法不满足正交化的要求,使用此方法时需要多多注意。

设置目标

单一指标

在分类任务中,一般有下面这两种评价指标:

  • 精确率(precision, 又称查准率):所有识别为猫的图片中,究竟有多少确实是猫?
  • 召回率(recall, 又称查全率):所有猫的图片中,有多少猫被正确识别了?

注意,我们之前代码实战中用的准确率(accuracy)和精确率(precision)不是一个指标。

现在,假设有两个模型,它们在开发集上的评估结果如下:

  • 模型1:精确率95%,召回率90%。
  • 模型2:精确率98%,召回率85%。

二者在精确率和召回率上各有优劣,该怎么从中选一个更好的模型出来呢?

设置目标的一个原则是:只使用单一实数作为评价标准。因此,我们要想办法用一个指标把这两个指标都考虑进来。比如使用F1-score,它的公式如下:

再看一个例子。假如我们开发好了几个算法,我们要用来自不同国家的数据去测试它们。不同算法在不同国家的数据上表现较好。为了快速选取一个最好的算法,我们可以去计算每个算法的表现平均值。

有了单一评价标准,我们就可以快速比较各个模型在开发集上的表现,并选择一个更好的模型。这样,我们开发的迭代速度也变快了。

满足指标与优化指标

在有多个评价指标时,不是总能挑选出一个最恰当的综合指标的。比如评价某算法时既要考虑到准确率,又要考虑到运行时间。用一个综合指标来组合它们显然不太现实。这时,我们可以把指标分成满足指标优化指标

比如说,我们有这样几个算法:

分类器 准确率 运行时间
A 90% 80ms
B 92% 95ms
C 95% 1500ms

算法C是挺好的,但是它相较A,B实在跑的太慢了。因此,我们可以设置以下的评价标准:

满足运行时间≤100ms的前提下,最大化准确率。

这个标准既保证了运行时间不会太长,又能选出准确率较高的算法。按照这个标准,B应该是最优的分类器。

在这个例子中,准确率就是优化指标,运行时间就是满足指标。

这种新的选取指标的方法应该和之前提到的单一指标原则结合起来。准确来说,应该只有一个优化指标,外加若干个满足指标。

训练/开发/测试的分布

开发集和评价指标,共同决定了我们的优化目标。因此,我们应该谨慎地选择各数据集的数据分布,防止优化目标跑偏。

举个例子,假如我们收集了来自不同地区的数据,有亚洲、欧洲……。假如我们令亚洲的数据为开发集,欧洲的数据为测试集,我们就可能会训练出一个在开发集上表现优秀,却在测试集上表现糟糕的模型。正确的做法是,我们把来自不同地区的数据打乱,把数据随机分成开发集和测试集。

还有一个改编自真实故事的例子。一个团队想开发根据某人的邮政编码预测他同意贷款的概率的算法。他们以中等收入地区的邮政编码为开发集,却以低收入地区的邮政编码为测试集。显然,在这两个地区的人同意贷款的概率会差很多。最后,这个团队花了3个月优化了算法在开发集上的表现,却发现模型在测试集上表现奇差,不得以推倒重来。

也就是说,我们应该让训练集和测试集能够反映我们将来实际应用时的数据,并且训练集和测试集都得来自同一个分布。设置开发集和评估指标,就像立了一个靶子一样。训练,就是让射出的箭更靠靶心。而测试集,应该反映我们期望箭射到的位置。我们既要知道箭应该射在哪里,还要把靶子摆对。

开发集和测试集的大小

这些知识在第二周已经学过了,这里再强调一次。

数据量小的时候(比如说数量级在万以下),我们可以按6:2:2的比例划分训练/开发/测试集。但数据量大的时候,就不用考虑比例了,按固定大小选择差不多大小的开发集和测试集即可。

那么,测试集要多大才够呢?从统计学的眼光来看,把测试集当成实际应用数据中的一个采样结果的话,我们应该保证测试集有很高的置信度能反映模型在实际应用中的综合表现。当然,对于简单的数据分布,我们可以用统计学知识严谨地算出置信度。而对于人工智能任务中用到的海量数据,数学工具就难以派上用场了。我们只能根据经验选择一个足够大的测试集。比如有百万级数据的话,一万个测试样本就够了。

何时更换开发/测试集与评价指标

在算法投入应用后,我们可能会发现新的评价角度。比如对于小猫分类模型,我们本来只期望它能正确识别小猫。可是,随着使用应用的人变多,我们发现有的用户会上传色情图片。这时,我们不仅希望模型能只找出小猫,还要能过滤掉色情图片。

这样,我们就引入了一个新的评价指标。这样,之前辨认小猫能力强的模型,可能会在辨认色情图片上较差。

为了考虑这个新的评价指标,我们可以修改误差函数,用更高的权重加大色情图片分类错误的惩罚。

总结来说,当我们发现使用当前指标得出来的最优模型,与考虑到某些新因素后得到的最优模型不同时,我们就应该更换开发/测试集与评价指标了。

与人类级表现比较

为什么是人类级表现

我们经常能看到AI与人类比较的新闻:什么AlphaGo在围棋上战胜人类了,什么在ImageNet上AI的分类准确率超过了人类啊,等等。除了博眼球的新闻外,业内同样也会时常将机器学习模型和人类比较。这是为什么呢?

在许多任务中,人类的表现都非常出色。当AI超过了人类后,往往也达到了这类问题的最优精度。在机器学习模型超过人类前,与人类比较有以下好处:

  1. 获取人类标注的数据。
  2. 从手动误差分析中获得启发:为什么人就能做对?
  3. 更好地分析偏差与方差。

其中,第1条是显然的,第2条会在下周介绍。接下来,我们看看第3条是怎么回事。

可规避偏差

这个知识之前也学过了一点。

如果一个模型的训练误差是8%,开发误差是10%,我们不一定说模型就存在这个偏差问题。有可能模型在训练集上已经几乎达到了最优的表现;

在判断一件事时,有可能因为信息的缺乏,最优的准确率也达不到100%,总会存在一些误差。这样的最小的误差叫做贝叶斯误差。人类的表现,通常可以用作贝叶斯误差的一个估计。

在刚才那个例子中,如果人类误差是1%,那么模型的训练误差还有7%的提升空间;而如果人类误差是7.5%,那说不定模型的训练误差只有0.5%的提升空间了。对于前者,我们应该关注偏差;关于后者,我们应该关注方差。这里讲到的7%, 0.5%的提升空间,可以称作可规避偏差

理解人类级表现

假如让人类来完成医学图片分类任务,人们得到了以下的分类误差:

从一个普通人,到一群有经验的医生,误差逐渐降低。那么,哪个误差算是人类级表现呢?

回顾上一节的内容,人类误差是贝叶斯误差的一个估计。因此,人类最优的表现,才应该被视作是人类误差。

当然,获取人类级表现的目的还是为了做偏差和方差分析。如果当前的训练误差是5%,那不管人类误差是1%,0.7%,还是5%,都差不多。而如果训练误差到了1%,甚至更低,那就要仔细地获取人类误差了。

提升模型表现

最后,再一次回顾一下如何减少偏差和方差。

机器学习有两大假设:模型能够很好地拟合训练集、模型能够泛化到开发/测试集上。它们分别对应偏差问题和方差问题。

训练误差和人类级表现之间的差是可规避偏差,开发集和训练集之间的差是方差。

训练更大的模型、训练更久/用更好的优化算法能够解决偏差问题。

使用更多数据、正则化能解决方差问题。

用更好的架构、超参数能同时解决这两个问题。

总结

这节课涉及的新知识很少,大家就权当是复习了一下之前的知识。这节课大概学了这些东西:

  • 正交化
  • 目标
    • 单一指标
    • 满足指标与优化指标
  • 开发集与测试集
    • 分布
    • 大小
  • 人类级表现
    • 贝叶斯误差
    • 可规避偏差
    • 提升模型表现的思路


只有保研生参加的说明会结束后,大家都围着辅导员,焦急而欣喜地确认着自己的前程。待人群散开后,我走到辅导员身边,询问放弃保研的流程。

“只要写一张确认放弃保研的保证书即可。”

拿出先前准备好的纸笔,我流畅地写了几行字,提交了本专业唯一一份保研放弃书。


2022年6月,没有季节之分的新加坡,却到了毒蚊肆虐的高峰期。我不幸感染病毒,前往南洋理工大学的校医院就诊。

诊断结束后,医生关切地说道:“你这几天就不要去听课了。我给你开假条。”

我笑道:“我是员工,不是学生。”

是啊,不论穿着、相貌、言行多像一名学生,现在的我,确确实实是一名员工。

离开诊所,望了望晴朗的天空,我忽然意识到,夏天来了。

如果是在国内的话,已经是夏天了吧?

应该是这样没错。去年的这个时候,学校里可热了。

全体学生聚在操场上的那一天,阳光正盛……


去年,我还是学生。

糊里糊涂地完成了毕业设计,通过了答辩,时间已经悄悄来到了六月。

这一天,太阳不遗余力地展示起了夏天的风采。火辣辣的阳光直射在北京理工大学中关村校区的操场上,我穿着严实的学士服,感到异常闷热。我一会儿调整着学士帽的角度以遮挡阳光,一会儿又摘下帽子当扇子扇风。

不一会儿,主持人宣布了毕业典礼的开始。在酷热的今天,哪怕是一向讨厌集会的我,也静了下来,默默地听着演讲。

主持人开始念起各专业毕业学生的名单了。理论上,名单是包含了每一位同学的名字的。可为了节约时间,主持人念完前几位学生的名字后,就会以“等人”来略过后面的名字。

一个个陌生的名字,就像一声声倒计时。我深深地感受到了毕业离校的临近。

有的人保研成功,已经去实验室待了几个月。

有的人面试成功,正在做正式入职的准备。

有的人考研成功,和未来的导师刚打完招呼。

可我呢?

我该去哪呢?

造成现在的局面,真的都是我自己的错吗?


不,我很早就想好自己的出路了。

早在大一,我就做好了出国留学的打算。

托福与GRE,硕士与博士。这些信息都我来说就如常识一般。

“先尝试科研,适合就读博士,不适合就读硕士。”

这是在综合了无数份信息后,得到的平均答案。

“大三开始在做科研,暑假去参加暑研。托福考试只有两年的有效期,也只能大三之后考。”

这也是平均而言的结论。

既然大家这么说,我也就这么做吧。

在快乐的算法竞赛中,我度过了大学的前两年。


大三到了,该做留学的准备了。

刚从竞赛暑期集训中缓过来的我,错过了,或者说压根就没注意过某个学校官方的大三暑研项目。“错过就错过吧,反正大家的暑研都是自己找的。到寒假了再找吧。”我勉强安慰着迷茫的自己。

按照计划,我去找了本校的老师做科研,提前看一看自己是否适合做研究。老师本来说让一个博士生带我做点项目,后来渐渐就没了音讯。大三课业繁重,又有最后半年竞赛要打,我也无暇顾及科研的事情了。

熬过上半学期,在留学上毫无进展的我,开始回家过寒假。我计划一边套磁(方言,意为“套近乎”,特指在申请出国留学时,提前给导师发邮件推荐自己)暑研,一边开始语言考试的准备,希望能在下半学期考完语言考试。

突如其来的一场疫情,打乱了我的计划。

当然,之前那个官方暑研项目也泡汤了。我只能以此来安慰自己。

为了让自己看上去在做一些什么,每天一个人在楼下上完网课后,我总会去套磁一个教授。

之所以每天只向一个教授发邮件,是因为我害怕发邮件这件事。

点开学校主页,找到教授的研究方向,在一堆陌生的名词中拎出一两个,组织成一封“我对你的研究感兴趣,请让我参与暑研”的邮件。这一过程对我来说,是一件很惶恐而绝望的事情。

每看完一个教授的简历后,我就隐隐感到自己的背景是绝不可能申请上他的暑研的。可是,正如坠落山崖的人总想抓住什么一样,我还是不得不把邮件发出去。每发出一封邮件,我就像了了一桩大事一般,如释重负。可是,每发出一封邮件,我又能意识到,又少了一个可以套磁的教授,无教授可找的绝望又离我近了一分。

就这样,为了消化套磁每天带来的压力,我只敢一天发一封邮件。

每天向不同的人告白,告白前就已经意识到了失败。可是,还是要为明天的告白做准备。大概就是这样痛苦的感觉吧。

虽然我还没有可以自由选择方向的资格,但我只想做图形学的研究。可是,我之前几乎没有任何相关科研经历,也没有任何人脉。纯粹做图形学的教授也越来越少。想找到暑研的难度是极大的。

我的心情很矛盾。一方面,我为做图形学的教授很少,没有套磁目标而担忧;另一方面,我又为做图形学的教授很少,可以过滤掉一批做其他方向的教授以逃避发邮件而感到释怀。或许,我所谓“热爱图形学”只是一块遮羞布而已。我害怕前途未卜的未来,我害怕在黑暗中迷失,但我又害怕迈出脚步。我选择图形学,或许只是图形学的教授很少,套磁失败起来很快而已。套磁完所有只做图形学的教授,失败了,我就可以以“我已经努力过了”来安慰自己。、

事实也确实如我所预料得一样,只有一个教授回了我邮件——一封找了一个温柔而拙劣的理由把我拒绝的邮件。我再也不用去套磁了,再也不用忍受发邮件的煎熬了。可是,我所担心的没有去处的未来,正在向我一步一步逼近。


大三下学期,我以“优等生”的姿态活着。

即使是网课,我也认真听着老师的讲解,认真完成着大作业。课余时间,我还继续在本校老师那做一点“科研”。

但是,我在留学上没有任何进展。

以“大三的作业太多”、“回学校后一切就能好起来”为借口,我暂时忘掉了留学这件事,舒服地过了几个月。

2020年年中,特朗普的一纸10043总统令,禁止某些中国高校的学生去美国留学,粉碎了无数学子的留学梦。历史的尘埃,恰恰就砸在了我们学校上。

听到这个消息,我的第一反应不是愤慨,不是焦虑,而是释怀:这下好了,大家都去不了美国了。

虽然我之前一直只打算申请美国的学校,但这个令我规划彻底失效的消息却使我获得了某种程度上的解脱。

是真的解脱了吗?还只是受到巨大打击之后的应激反应呢?

我不知道。

我只知道我必须要做一点什么,一定要迈出脚步。

我不想被无路可走的黑暗吞没。

有人说,这个留学禁令只会持续一年,明年的留学生肯定不受影响。我根本来不及仔细思考,立刻把这个判断奉为真理,继续之前的留学准备。


我一直都在做一点什么。

没有暑研,我就在学期结束后立刻返校,捣鼓我那怎么都没有成果的“科研”。

正式开学后,我立刻开启了托福的准备。

我一直都在做一点什么。

但我真的什么都不想做。

但凡做起和留学相关的事,我就感到无比煎熬。开始做了一会儿后,巨大的负担就压在了我的心上。没办法,我必须要逃避。我的炉石传说酒馆战棋打到了一万多分。

但我还是觉得该做一些什么。

我勉强考出了过线的GRE,快要过线的托福。

我参与了本校教授和国外教授的合作科研项目。两位美国老教授高风亮节,视如己出,言传身教,令我彻底下定决心去读博士。他们虽然颇有名誉,但和我不是一个专业,在留学上给不了我功利性的帮助。即使如此,我依然非常感激他们。

我急匆匆地做好了材料准备,提交了数个学校的博士申请。

12月中旬,我提交完了所有美国学校的申请。

一切都结束了。

我不用再做一些什么了吧?


只申请美国,只申请图形学,只申请博士。

我恐怕根本不是奔着成功留学去的,只是想做什么就做了什么而已。所谓“眼高手低”,大抵如此吧。

我是真的眼高手低吗?

聪明绝顶的我怎么可能没有对自己的一个客观认识。我知道,申请成功的概率微乎其微;我知道,六月之后即迎来“失学”的未来;我知道,我害怕失败,害怕无路可走的绝望。

但是,我更清楚我想要什么,不想要什么。

成功,不是美国顶尖学校的博士录取通知书,不是4.0的绩点,不是110分以上的托福分数,不是330分以上的GRE分数,不是光鲜亮丽的获奖记录,不是琳琅满目的论文发表记录。

成功,不是奖学金获得记录,不是年级第一的成绩,不是饱满的社会工作经历,不是“努力”、“感人”的苦学经历。当然,也不是我唯一能展示出来的,ACM金牌的获得记录。

成功,不是金钱,不是地位,不是权力,不是名誉,不是异性缘,不是房子,不是车子,不是你在哪国生活,不是你的照片多好看,不是你展示出来自己的生活过得有多好。

成功,是:我觉得成功,就是成功。

我觉得,只有做自己喜欢的事情,在自己喜欢的领域做出了令自己满意的成就,才叫成功。

打了三年左右的竞赛,大奖我拿的确实不多。但是,在这段时间里,我过得很开心。我触摸到了灵魂的兴奋点,初次体会到了人生的意义。

我害怕。

我害怕未来。

我害怕上不了学的未来。

我害怕申请失败上不了学的未来。

我害怕因为套磁不够积极导致申请失败上不了学的未来。

我害怕因为方向选得不够多套磁不够积极导致申请失败上不了学的未来。

因此,

我放弃。

我放弃思考。

我放弃人生规划的思考。

我放弃留学相关人生规划的思考。

我放弃寻找更合适的国内研究项目人生规划的思考。

我放弃套磁更多方向的教授寻找更合适的国内研究项目人生规划的思考。

但是,

我坚持。

我坚持底线。

我坚持人生价值的底线。

我坚持不随留学结果变动的人生价值的底线。

我坚持不肯妥协不随留学结果变动的人生价值的底线。

我坚持不肯妥协不随留学结果变动只为自己开心的人生价值的底线。

最终,我任性而顽固地在焦虑中失败了。不过,我也很庆幸,不管我的感受有多么糟糕,我在潜意识里依旧坚持了自己的底线。我没有为了留学而留学,也丝毫没有怀疑过自己对人生目标的判断。


后来,我依然焦急地寻找着出路。

我知道自己为逃避选择而产生的任性是很不合理的。赶在截止日期之前,我去尝试申请了其他国家的学校,尝试申请了可以转成博士的研究型硕士。结果,时间已晚,剩余的机会并不多,我也没有申请成功。

我已经在积累压力和释放压力中循环多次了。写套磁信时积累压力,发邮件时释放压力;申请学校时积累压力,申请季结束后释放压力;等结果时积累压力,收到拒信时释放压力。这就像一个溺水的人,反复挣扎出水面,难得呼吸到一两口新鲜空气一样。那么,收到最后一封拒信时,就是我最后一次能够离开水面了。

但是,我依然没有放弃“生的希望”。或许在很早之前,我就已经在心里默认自己会踏上这条退路了。

这条退路就是gap,去实验室先当科研助理,积累背景,再去申请博士。

gap是一个从国外传来的词,表示毕业后不去上学,而是去玩个一两年。用中文来说的话,gap year可以翻译成“间隔年”。到了国内留学圈,gap的意思就变了。毕业之后,不管你是不是在享受没有学业的人生,只要你没有上学,就可以称为gap。当科研助理,是一种最常见的gap方式。

从大一就开始看留学经验分享的我,很早就知道了gap的存在。通过分析他人的gap经验,我也欣然接受了gap,做好了心理准备。或许我在留学季的种种挣扎,不过是自我欺骗而已。我内心早就放弃了本科直接申请博士。由于有这个底牌的存在,我可以索性破罐子破摔,只去追求小概率的自己想要的结果。

虽说是早就做好了心理准备,但被压力挤得喘不过气的我,还是慢慢吞吞而消极地进行着gap的计划。我本来做好了去一家公司的准备,就没有去找第二个选择了。可是,毕业前我想了解入职事项时,却发现我莫名其妙地被鸽了。

毕业典礼即将到来,我选择享受最后一刻的本科时光,搁置了gap的事。

本科毕业后,作为无业游民的我回到了家里,立刻开始了科研助理的套磁。

和之前的暑研套磁一样,申请科研助理也要用同样的方式发邮件申请岗位。一想起暑研,整个留学过程给我带来的压力的总和就扑面而来。同样,我的心理承受能力只允许我一天只发一两封邮件。

待在家里天天吃干饭,我肯定会被无尽的压力给冲垮。恰巧同学邀请我去毕业旅行,我欣然答应。不知怎地,我就是有一种能在旅游中申请成功的自信。

由于美国的学校都去不了了,现在我只能从其他国家入手。这次,我不再头铁了,从对ACM竞赛认可度最高的华人圈开始申请。同时,由于做图形学的人太少了,我决定扩大范围,也申请计算机视觉方向的研究。计算机视觉我也不讨厌,我会让自己尽快喜欢上这个领域,并且尽可能选择和图形学相关的细分方向。

我认真套了几个香港的教授,杳无音讯。我又看到南洋理工大学在招聘平台上正式招募科研助理,就顺手投了一份简历。正当我为没收到任何回信,准备进一步扩大方向的选择范围时,我申请得最不认真的南洋理工大学竟然向我发出了面试邀请。

说是面试,但这毕竟不是庄重的博士申请。能给科研助理的申请发面试机会,基本上就是决定要你了。我本来还准备了英文ppt和英文演讲腹稿,谁知面试开始后,老师亲切地对我说可以说中文。谈起选择我的理由时,老师说,像我这样有扎实的底层编程基础的人不多,而且我的博客写得很好。在轻松的氛围中,我们聊了聊我过去的经历,敲定了科研助理一事。由于疫情,新加坡签证管得严,我要等半年才能拿到签证。老师帮我先安排了一个和他的实验室有合作的国内工作岗位,就当是为之后的学习打基础。

没想到,这一次,如我所愿地,我在旅行中完成了套磁、科研助理面试、国内工作岗位面试。旅行的时间不短,在享受完旅行后没在家躺几天,我就得动身前往上海办入职了。


七月底,我去上海人工智能实验室的OpenMMLab以全职员工的身份“实习”。也就是说,工资按正式员工的发,但是和实习生一样不待很长时间。大概六个月后,签证就会办好。

总算,我也是从学校迈向社会了。很幸运,OpenMMLab是混沌社会中的一块净土。OpenMMLab主要做的是开源项目,不以业务为导向,没有什么KPI的压力。同时,由于大组刚刚成立不久,同事的素质都很高。全职员工大多是名校硕士,实习生中有名校本科生,也有在读博士的科研大佬。

站在徐汇西岸智塔的高层,俯视着蓝天下的黄浦江,我有一种说不出来的畅快。这样开阔的风景,是矮小的校园里所见不到的。从这里望出去,哪怕是上海交通大学,也不过我眼睛里的一点而已。

公司里见到的,都是年轻的面孔。于其说是同事,倒是更像大学里的同学。可是,多数同事都已经工作多年,早已褪去了学生的稚嫩。从他们口中,听到的更多是人情冷暖。房子、车子、伴侣……尽是些我插不上嘴的话题。

作为工资可观,又随时准备走人的单身程序员,我的日子倒是逍遥得很。可是,同事们比我有更多的可待之物。即使公司的工作环境比其他许多地方都要舒适,他们依然觉得上班养家是一件很不容易的事情。从他们身上,我看到了自己可能的一个未来:我就这样一辈子生活在上海,结婚生子,悠闲度日……

然而,现在安逸的生活让我忘记了本科申请时的所有烦恼。我以前所未有的高效率生活着,对未来的人生也有了更多的期冀。既然看到了校园内看不到的风景,那就要树立本科时想不到的理想。


说是全国最大的开源算法体系,也不过如此嘛。

不然,为什么重构代码库的事情,会让工作时间不过四个月的我来承担呢?

刚到公司时,我确实是懵懵懂懂的。我配开发环境配了一两天,给我开通企业微信又花了一周多,好不容易才安顿下来。

第一次小组会,我是以一个听众的身份参加的。ONNX Runtime、TensorRT、ncnn……这些犹如外星语的名词一个一个蹦出,令唯一一个听众感到战战兢兢:“这么多复杂的技术,我能学得过来吗?”

我们小组负责模型部署代码库的开发。学了一段时间的相关知识后,我发现,模型部署,可是光鲜的“算法”项目中最脏最累的活。对内,我们要对接数个计算机视觉的开源库;对外,我们又要使用数个运行深度学习模型的推理引擎。其他各个代码库之间不一致的地方,就要靠我们来硬生生地焊接起来。

这么琐碎的工作,自然也容易出现纰漏。正在学习我们的代码库时,我发现了一个bug。正好,我决定修复这个bug,作为我对我们组的第一份贡献。

提交代码,必须要使用到代码管理工具。本科时,我只会用傻瓜式的图形界面来使用Git这项代码管理工具。我们是做开源项目的,自然要把代码放到基于Git技术的GitHub开源代码平台上。由于经验不足,我只能在实践中慢慢学习Git的用法。

和组里的同时讨教过后,我修完了bug,并在自己电脑上完成了代码管理。之后,只剩下把代码提交到小组的代码平台,并把我写的那部分代码合入到整个代码库里了。我接下来的操作会改动代码库,一旦出了纰漏,肯定会引起很严重的后果。因此,我小心翼翼地进行着提交代码的操作。

提交完成后,代码库网站上突然出现了一个大大的红叉。这可把我吓坏了。我连忙向同事求救,一面拜托他们快点撤销掉我的操作,一面询问着正确的操作方法。还好,我错误的操作没有什么破坏性。原来,在使用Git和GitHub时,我不能直接向主代码库提交代码,而是应该先向自己克隆出的代码库提交代码。只要按照正确的步骤,重新操作一次就好了。

有惊无险地,我的第一份代码总算合入了整个项目中。虽然代码上的改动只有四五行,但我还是很骄傲地在下次组会上汇报了我的成就。小组领导也在会议记录上欣然记录下了我的这项产出,与其他人涉及上百行代码修改的成果一起。

提交完第一次代码之后,原先像城堡一样复杂的开源代码,在我眼里就成了一排排的破房子。我们的工作,不过是立几根杆子撑住快要倒塌的房子,又去旁边的土地上新建几座房子而已。

从提交几行代码修复小bug,到对接一个视觉算法库,我的贡献度逐渐向其他同事靠拢。几个月后,把略有难度的重构任务交给我,也算是自然而然的事。

为了完成重构,不阻碍他人的工作进度,我高压工作了几天。不过,我倒是不怎么感到疲惫——

我们的代码库要开源了。


2021年的平安夜,上海下着小雨。街头的树上挂着灯饰,点亮了黑夜,也点亮了路旁的积水。街道仿佛笼罩在一片白雪之中,就和人们印象中的圣诞节一样。

到处都是圣诞节的氛围。我从公司楼下的商场走出,一路上看到了不少情侣。恰逢本周最后一个工作日,大家都早早地下班过节。不知怎地,在这种氛围的感染下,我望着天空,感到一丝惆怅。

大概是因为,下周一,我们的项目就要开源了吧。

虽说我们的项目叫做“开源项目”,但是在代码功能尚未齐全的早期,项目是在私有账号下闭源开发的。在基本功能差不多完备了后,大组领导会择一良辰吉日,隆重向世人宣布开源。最后一次开源评审的通过、宣传视频终稿的提交、暂停开发工作后无聊而紧张的查缺补漏……一切都预示着项目开源的到来。

周一的晚上,一切准备就绪。小组的各位都聚在同一台电脑前。

这些代码是属于谁的呢?

作为员工,这些代码应该是属于公司的吧。

作为开源项目,这些代码又应该属于整个开源社区的吧。

但是,此时此刻,这些代码就是只属于我们的作品。

按下确认开源代码库的按钮后,大家纷纷鼓起了掌。

随后,大家不约而同地转发了我们代码库开源的宣传文章。

我想,现在,其他几位同事的感受,应该和我是一样的。

过了几天,仗着OpenMMLab的名气,我们的代码库登上了GitHub Trending榜第一。

之后,我们的身份从纯粹的开发者,变成了时而回答社区问题的客服人员。

再之后,就过年了。

过年回来,没待两周,我就收到了新加坡签证通过的消息。我很快办好了离职。

虽说是离职了,但我也没能立刻就离开上海。我心安理得地放了一周的假,像相恋多年和平分手却又一时不习惯分离的情侣一样,天天在公司里吃了一顿又一顿的散伙饭。

在香港转机时,我们需要在机场就地过夜。

在明亮的大厅里,我睡不着,又想起了同样明亮的那个夜晚。

原来,令我惆怅的,是一月份的到来。从一月往前数六个月,就是由热转凉的七月啊。


到了新加坡后,我很快就熟悉了学校里的生活。

去食堂点菜,刚掏出员工卡时,总有店家会向我确认道:“付款方式是学生卡吧?”我也总是点头默认。

被别人当成学生时,我总会很开心。 或许,我一直向往“学生”般天真烂漫、无拘无束的生活吧。

很幸运,现在,我正享受着这种生活。

我当了十六年学生,一直对众人口中所谓的“学习”嗤之以鼻。没想到,我却在不是学生的今天,体会到了真正的学习:没有家长,没有作业,没有考试,我可以出于热爱,为了自己而学习。在导师的计划下,除了完成实验室的项目外,我的主要任务就是从头认真学一遍深度学习,为以后的科研打下基础。

做着喜欢的事情,朝着理想一步一步迈进,这是我梦寐以求的生活。

没有学业的约束,没有最晚起床时间,能整天都抱着电脑。

其实,我现在有的条件,去年大四时也有。

这一年来,究竟是哪改变了呢?

我想,应该是心境吧。


去年,我一直带着“前途未卜”这项异常状态。

虽说是一直有这么个东西压在心上,可从客观上来看,我大四一年的生活都没受到任何影响。该考试考试,该写论文写论文,该毕业毕业。一切都正常地进行着。

可是,毕业,对没有去处的我来说就像是世界末日一样。仿佛一毕业,一盆水就浇到了我人生的水彩画上,我拥有的一切都将褪色,消逝。我根本不敢考虑之后的事情。

我的感受,完全是自己创造出来的。我惧怕未来,所以给自己创造了一个险恶的心理环境。虽然我想挣扎着逃出,可每一项努力的失败,又在我心中下起了一阵阵暴雨。我在自己给自己设下的绝境中,无法自拔。

我口口声声说着自己不忌惮世俗的眼光,可到头来还是难以免俗。分数、论文数量、录取学校,这些东西都成我心中挥之不去的阴影。

我所谓做好了gap的准备,不过是自欺欺人。连现在的东西都不肯割舍,连未来的方向都不敢主动去寻找。我只是一直在被外界推着前进,而难以自己迈出脚步。

阻碍我的,是我自己的心境。

可是,当时的我真的就有能力去改变自己的心境吗?

做不到的。

当时的我,只能看到那些东西。

从学校到公司不过一个多月,我的心情就大有转变。显然,并不是我聪明了多少,或是坚强了多少。一切,都只是环境变了。

找到出路,不过是让我能够从泥潭中走出。而在半年的实习经验,则洗净了我身上的泥。

人的思考方式不可能在短期改变,能够快速改变的,只有身处的环境。环境的改变,有时更能让人产生思考、心境上的转变。

正是因为见到了从未见过的东西,我才能认识到之前的浅薄。如果当年在学校时,我能够多找一些有相同境遇的人交流,或是提前去社会里看看,又或是暂别学校好好清醒几天,说不定早就能够走出心理上的牢笼。

心境决定了感觉上的好恶,环境又很大程度上影响了心境。

面对心里的险境,一方面要看开一点,在更广的时间和空间上看待目前的处境;另一方面,不必去苛责自己,说不定换一个环境,一切都会好起来。

这世上所有与内心的苦难所斗争的人啊:

你们千万不要气馁。

人的一生,必然是伴随苦难的。小时候,有做不完的假期作业,父母老师的责骂,吓人的期末考试;长大了,有千军万马过独木桥的高考、考研,有毕业后逃不开的就业;再往后,还可能有破产、众叛亲离、疾病缠身。

苦难压得人喘不过气,让人想要逃避。

可是,逃避又有什么错呢?

遭遇苦难,必然是在追求自我超越的路途之中的。敢于去挑战困难,本来就不是一件容易的事。

那么,短暂的逃避,也不过是出于自我保护,为了让干涸的心灵多浸润几滴甘露而已。

真正的勇士,从来都不是一帆风顺的人。有拼搏,有苦难,有逃避,有自责,有前进。这样的人,才称得上是勇敢的。

我想,笼中之鸟,也梦想过展翅翱翔;井底之蛙,也畅想过圆形以外的世界。不论现状多么糟糕,不论视野多么受限,大家都不会放弃对美好的期盼。这时,不妨转换一下环境,调整自己的心境。说不定现在看来天都快塌下来的事情,在未来只是一桩笑谈。

未来,随着我能做到的越来越多,肯定会经历更大的挫折,面对更难的挑战。我也不能保证自己就不会再次陷入心情的低谷中。但是,无论何时,我都会坚持自己的追求。不论是从主观上改变对困难本身的看法,还是改变客观的环境让自己冷静下来,我会用种种手段来摆脱困境。因此,在未来,只会留下更多我战胜困难的事迹。


保研说明会是在大四开学不久后召开的。当时,我连语言考试都没有准备好。说明会一结束,我就回去练听力了。如果能让现在的我给当时的自己带一句话,我会说:

池中寄卧又何妨,风雨之巅尽是晴。

我的评论

我本来是打算取得了某些成就后,再认真总结留学的心得的。恰逢上个月CSDN办了一场征文活动(活动的质量烂得一塌糊涂),我就随手写了一篇人生感想。等我以后确实有成就了,再写一篇有关CS PhD留学的思路指南。

这篇文章的质量很一般。用词还需要多加考究,事情完全贴合实际而少了一些阅读上的趣味,并且很多文字我是以演讲者的视角写的,念起来通顺但不严格符合语法。文章分了几次写完,行文中有不连贯之处。说理时略显僵硬,明明有很多方面的感想,却只能勉强揉成一团表达出来。倒是叙事结构上稍有构想,略微超出了我的平均写作水平。

但是,这篇文章最重要的,是文章内容中传递出来的“我”的心理活动,以及文字写作中传递出来的我的心理活动。这些感受都是很真切的。我觉得这是本文最宝贵的地方。

顺带一提,我是不怎么读书的,文学积累严重不足。为了写本文最后的诗句,我还特意去查一下格律,确保平仄没有写错。这两句话质量如何,我现在评价不了。但还是一样,它们蕴含了我的志气。

写这篇文章,我的主要目的是吹牛,试图收割流量。另外,我也很想把我的经历分享给更多的人。一方面,我知道大部分人都会经历和我类似的境遇,都会体会到孤立无援的感觉,相信这篇文章能给人启发;另一方面,我认为世界上广泛流传的价值观全是错的,我必须去宣传一些能让大家变得更好的思考方式。

希望大家读后能有所收获。

GAN(生成对抗网络)是一种能够自动生成内容的神经网络模型。近年来,许多图像生成的研究都基于GAN。

以人脸生成任务为例,一类常见图像GAN的原理如下:模型先要学会辨别一张图像是不是人脸。之后,模型会把一个个高维实数向量表示的“身份证号”映射成一幅幅图像,并根据辨别人脸的知识学习如何让图像长得更像人脸。最终,学习结束后,每一个“身份证号”都会映射到一张逼真的人脸上。只需要给模型一个随机的高维向量,模型就会生成一张人脸。

但是,使用这类图像GAN时,我们不能对图像生成的过程加以干预。也就是说,我们不知道神经网络是怎么把一个个向量映射成一张张栩栩如生的人脸的,而只能将其视作一个黑盒。因此,这些GAN只能用来随机地画出几张漂亮的画,搞搞大新闻,难以产生更加实际的应用。

对此,英伟达提出了可控的图像生成网络StyleGAN,引发了无数研究者的关注。在这个模型生成完一幅图像后,我们可以对图片进行由粗至精共18种微调:同样以人脸生成为例,我们既可以调整性别、年龄这种更宏观的属性,也能调整肤色、发色这种更具体的属性。下图是论文中展示的结果:

最左侧一列是生成的图集A,最上方一行是生成的图集B。前三行、中间两行、最后一行分别是把图集B图像的高级、中级、低级属性混合进图集A图像得到的结果。

可以看出,混入高级属性,人脸的肤色得以保留,而五官、性别等特征被修改了;混入中级属性,人脸的性别、年龄得以保留,而脸部的轮廓被修改了;混入低级属性,人脸的样子几乎不变,而发色、肤色被修改了。StyleGAN确实能神奇地修改生成图像的各类属性。

那么,StyleGAN是如何用这18支“画笔”作画的呢?StyleGAN还有哪些出色的特性呢?我们能用StyleGAN开发出怎样的应用呢?在这篇文章里,让我们来认识一下StyleGAN的主要特点,并快速地用开源项目运行一下StyleGAN。

本文不会对StyleGAN的原理进行详细的解读。在后续文章中,我会系统地讲解StyleGAN的论文及开源实现的使用方法。

原理讲解

控制图像的风格

StyleGAN的名字里有一个”style”,该单词是“风格”的意思。这个单词在图像领域有一个特别的含义:一项神经网络风格迁移(Neural Style Transfer)的研究曾指出,图像可以看成是内容与风格的结合。即使是同一幅风景,不同艺术家也会画出不同风格的作品:

如果对神经网络风格迁移的经典论文感兴趣,欢迎阅读我之前写的解读文章

在那项研究中,人们发现,在由很多卷积层堆成的卷积神经网络中,越浅的层能表示越实际的风格,越深的层能表示越抽象的风格。受此启发,StyleGAN也使用了卷积神经网络来生成图像,并把“控制信号”放在了每个卷积层后。这样,只要调整“控制信号”,就能改变图像的风格了。StyleGAN的生成网络的部分结构如下:

在这个网络中,”Const 4x4x512”表示一个恒定的数据块,该数据块的分辨率是4x4。网络会通过一系列操作,把这个数据块逐渐放大成一幅1024x1024的图像。”AdaIN”是一种运算,该运算受到表示风格的“控制信号”A的影响。”Conv 3x3”是普通的3x3卷积层。”Upsample”表示图像上采样2倍。

把图像从4x4翻倍放大至1024x1024,要翻倍8次,一共涉及9个模块的运算(一个模块就是图中的一个灰框)。而每个模块里又有2个AdaIN,所以,一共有18个调整图像风格的“控制信号”。这就是为什么我们可以说AI在用18支画笔作画。

那么,如何像开头所展示得一样,混合不同人脸的风格呢?这就要详细介绍一下“控制信号”的由来了。前面也提过,通常图像GAN需要输入一个高维实数向量,模型会根据这个向量来生成图像。在StyleGAN中,“控制信号”就来自于这个高维向量。默认情况下,所有“控制信号”都来自同一个高维向量。而如果令某些层的“控制信号”来自于另一个高维向量,就能产生风格混合的效果。前面提到把图像B的低级、高级特征混入图像A,其实就是用图像B代替图像A在网络的浅层、深层的“控制信号”。

为什么调整不同层的风格能够对图像产生不同程度的改变呢?可以这样想象:浅层的数据分辨率较低,只能记录图像的年龄、姿态这种更宏观的信息;而随着数据的分辨率不断放大,深层的数据已经逐渐记录下了人脸的形状,只剩下肤色、发色这种更具体的信息可供更改了。

实际上,从开头展示出来的图片中能够看出,风格混合并不是真的混合了图像的绘画风格,而是混合了图像的各种属性。出于对之前「神经风格迁移」论文中“风格”一词的统一描述,StyleGAN的论文沿用了“风格”一词。

除此之外,StyleGAN还有哪些特性呢?让我们看下去。

随机调整图像细节

在让计算机生成图像时,除了要求图像足够像某种事物外,最好还要能够随机改变图像的细节。比如对于一幅人脸照片来说,如果几束头发的位置发生了偏移,我们还是会认为这是原来那张照片。因此,我们希望生成出来的人脸在头发上的细节可以随机一点。

在传统的图像生成方法中,研究者总是得构造出一些巧妙的参数,通过随机改变这些参数让图像在内容大致不变的前提下调整细节。构造这些参数的过程是非常困难的。而对于StyleGAN来说,它的结构特别适合插入能够修改图像细节的噪声,让我们看一看StyleGAN生成网络完整的结构图:

其实,在输入层或者卷积层后,还有一个与噪声B的加法操作。其中,B通常是从标准正态分布中随机生成的。对于同一个高维向量生成出的图像,改变噪声B会修改图像的细节:

如图所示,改变噪声会改变头发的细节。实际上,噪声几乎不会影响整幅图片的观感,而只会改变头发、胡须、衣领等小细节。

通过改变噪声,我们能够从一幅照片的多个版本中找出一个细节最好的。此外,通过连续改变噪声,我们能够让图像发生连续的变化。这一特性很适合制作简单的2D动画。

靠近平均图像

StyleGAN还有一个很好玩的应用:让生成的某张脸更加靠近大众脸。这一功能是怎么实现的呢?

对数字求平均值,得到的是所有数字的平均水平;对坐标求平均值,得到是一个平均位置。可是,该怎么对照片求平均值呢?如果只是把所有照片的像素值加到一起,再求平均值,肯定只会得到一幅乱糟糟的图像。而StyleGAN则提供了一种求平均图像的方法。

前面提过,GAN是靠一个高维向量表示的“身份证号”来生成图像的。StyleGAN通过一些映射操作,让高维向量的距离与生成出来的图片的相似度相关。也就是说,越近的向量,生成出来的图片就越像。因此,我们可以求出一堆向量的平均值,从而得到一幅平均图像。

有了平均图像,接下来就是如何让另一幅图像更靠近平均图像了。和前面一样的道理,相似的向量能生成相似的图像,那么两个向量的平均值,就能几乎均等地表示两幅图的特性。理想情况下,如果一个向量表示“白发萝莉”,另一个向量表示“黑发熟女”,那么它们的平均向量应该表示“银发少女”。当然,如果不使用平均值,而是使用其他和为1的加权方式,就能让中间的图像更靠近另外某幅图像了。这一操作叫做图像插值。

有了平均图像,有了图像插值方法,就可以一幅图像更靠近平均图像了。下面是几个插值示例图,其中$\psi$表示原图像的加权权重:

$\psi=1$表示随机生成的原图像,$\psi=0$就是平均图像,$\psi<0$表示图像往平均图像“相反”的方向移动得到的图像。

可以看出,随着人像不断靠近平均,甚至往反的方向移动,人像的整体内容都在平滑地改变。比较有趣的是,当人像反向后,人物的性别都反过来了。

从这个例子能看出,StyleGAN使用的输入向量隐含了语义上的信息。通过对输入向量进行简单的数学操作,就能让生成出来的图像朝有意义的方向改变。

总结与展望

通过认识StyleGAN的网络结构,我们了解了StyleGAN的两大输入:表示风格的高维向量与随机扰动图像的噪声。通过修改这些输入,图像会发生不同程度的改变。

基于这些基本操作,我们可以开发出许多图像编辑应用,比如风格混合、简单动画、图像插值、语义反向等。正是因为这些五花八门的图像编辑效果,许多研究者都尝试对StyleGAN的功能进行改进与拓展,发表了很多有趣的科研工作。

令一方面,由于GAN的生成内容取决于训练数据。如果我们用人脸以外的图片作为训练集,就可以让AI画出其他物体来,比如动漫头像、小猫、汽车、酒店房间。这样,就可以开发出人脸编辑之外的应用了。

如果你想直观地体会StyleGAN的效果,可以查看StyleGAN作者发布的视频(在外网)。

如果你想立刻跑一跑StyleGAN,别走开。在附录中,我会介绍如何利用开源项目快速运行StyleGAN。

这篇文章只是对StyleGAN非常粗浅的一个介绍。如果你想认真研究StyleGAN,欢迎阅读我之后发布的StyleGAN论文精读。

快速运行StyleGAN

OpenMMLabMMGeneration用PyTorch实现了StyleGAN并提供了模型权重文件。让我们看看该怎样快速运行StyleGAN。

我使用的MMGen版本是0.7.1

安装

访问官方文档,按照教程装好mmgen。

安装大致分以下几步:

  1. 装PyTorch。
  2. 装mmcv。
  3. 装mmgen。

下模型权重文件

这个网站里找到模型的下载链接。

模型名称中最后的数字表示生成图像的分辨率。按照需要,点击某个模型后面的”model”,下载权重文件。

权重文件下载好了后,推荐放到代码仓库的checkpoints目录下。

运行模型

在某目录下(比如代码仓库的work_dirs目录下)新建并编写Python文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from mmgen.apis import init_model, sample_unconditional_model
import mmcv
from torchvision import utils

config_file = "configs/styleganv1/styleganv1_ffhq_1024_g8_25Mimg.py"
checkpoint_file = "checkpoints/styleganv1_ffhq_1024_g8_25Mimg_20210407_161627-850a7234.pth"
device = "cuda:0"

model = init_model(config_file, checkpoint_file, device)
results = sample_unconditional_model(model, 16)
results = (results[:, [2, 1, 0]] + 1.) / 2.

# save images
mmcv.mkdir_or_exist('work_dirs/styleganv1')
utils.save_image(results, 'work_dirs/styleganv1/output.png', nrow=4)

稍微解释一下这段代码。init_model用于新建一个模型,该函数有三个参数:配置文件config_file、权重文件checkpoint_file、运行设备device。配置文件放在仓库的configs/styleganv1目录下,根据刚刚下载的权重文件,选择配套的配置文件,修改config_file即可。checkpoint_file要填写刚刚下载好的权重文件的路径。device是运行的设备,有GPU的话写cuda:0即可。

sample_unconditional_model用于随机生成一些向量,并用这些向量来生成图片。该函数第一个参数是模型,第二个参数是生成图像的数量。

图像生成完毕后,数据范围是(-1, 1),我们要把它转换成数据范围是(0, 1)的图像。同时,为了兼容输出图像的API,我们还要把颜色通道反向。

最后,调用创建文件夹和保存图片的API,把所有输出图片以网格形式保存到某个文件中。

执行这个Python脚本后,我们就能得到分布在4x4网格中的16幅人脸图像了。我得到的一个结果是:

注意!虽然大部分情况下生成器的表现都挺不错,但它偶尔会生成一些很吓人的图像(比如右上角那张史莱姆人)。大家看输出结果前一定要做好心理准备!


这段代码仅仅展示了StyleGAN生成图像的基本功能。在后续的论文解读文章中,我还会继续介绍如何利用mmgen实现StyleGAN的各种花式应用。

姊妹篇:https://zhouyifan.net/2022/06/27/DLS-note-7-2/。

安装PyTorch

前言

配编程环境考察的是利用搜索引擎的能力。在配环境时,应该多参考几篇文章。有英文阅读能力的应该去参考官方给的配置教程。出了问题把问题的出错信息放到搜索引擎上去查。一般多踩几次坑,多花点时间,环境总能配好。

本文只能给出一个大概率可行的指导,不能覆盖所有情况。如果在执行本文的安装步骤时出了问题,请灵活使用搜索引擎。

配置深度学习编程框架时,强烈推荐配置GPU版本。本文会介绍PyTorch GPU版本的配置。如果只想用CPU版本的话,跳过“CUDA与cuDNN”一节即可。

本文会同时介绍Windows和Linux下的安装方法。二者操作有区别的地方本文会特别强调,若没有强调则默认二者处理方法一致。

CUDA与cuDNN

CUDA是NVIDIA显卡的GPU编程语言。cuDNN是基于CUDA编写的GPU深度学习编程库。在使用深度学习编程框架时,我们一般都要装好CUDA和cuDNN。

这个安装步骤主要分三步:

  1. 装显卡驱动
  2. 装CUDA
  3. 装cuDNN

其中,显卡驱动一般不需要手动安装,尤其是在自带了NVIDIA显卡的Windows电脑上。

显卡驱动

nvidia-smi查看电脑的CUDA驱动最高支持版本。下图标出了命令运行成功后该信息所在位置:

如果命令能成功运行,记住这个信息。

如果这个命令失败了,就说明电脑需要重新安装显卡驱动。现在(2022年)CUDA的主流版本都是11.x,如果你发现驱动支持的最高版本偏低,也可以按照下面的步骤重新安装显卡驱动。

访问NVIDIA驱动官网:https://www.nvidia.cn/geforce/drivers/ 。在网站上,输入显卡型号和操作系统等信息,即可找到对应的驱动安装程序。

对于Windows,下载的是一个有GUI的安装器;对于Linux,下载的是一个shell脚本。如果你用的是Linux服务器,没有图形接口,可以先复制好下载链接,之后用wget下载脚本。

之后,运行安装器,按照指引即可完成驱动的安装。

注意,如果是带图形界面的Linux系统,可能要关闭图像界面再安装驱动。比如对于Ubuntu,一般要关闭nouveau再重启。请参考 https://zhuanlan.zhihu.com/p/59618999 等专门介绍Ubuntu显卡驱动安装的文章。

能够执行nvidia-smi后,执行该命令,找到驱动支持的最高CUDA版本。

CUDA

首先,我们要定一个CUDA安装版本。

CUDA安装版本的第一个限制是,该版本不能大于刚刚在nvidia-smi中获取的最高CUDA版本。

第二个限制是,PyTorch版本必须支持当前CUDA版本。在 https://pytorch.org/get-started/previous-versions/ 中,有许多安装命令。每条Linux和Windows的安装命令中,有一条cudatoolkit=x.x的参数。这个参数表示的是当前PyTorch版本一定支持的CUDA版本。当然,并不是其他版本就不支持,一般新CUDA版本会向旧版的兼容。为了保险,可以尽可能和安装命令中的CUDA版本对齐。

由于开发环境中可能会安装多个编程框架(TensorFlow,PyTorch),建议先安装一个比较常用、版本较高的CUDA,比如CUDA 11.1,11.2之类的。之后,让编程框架向CUDA版本妥协。

如果之后安装PyTorch后发现CUDA版本不对应,可以尝试升级PyTorch版本。如果PyTorch实在是支持不了当前的CUDA版本,最后再考虑降级当前的CUDA版本。

选好了CUDA版本后,去 https://developer.nvidia.com/cuda-toolkit-archive 上下载CUDA安装器。同样,Windows和Linux分别会得到GUI安装器和shell脚本。

装完CUDA后,再控制台上输入nvcc -Vnvcc是CUDA专用的编译器,-V用于查询版本。如果这个命令能够运行,就说明CUDA已经装好了。以下是nvcc -V的输出:

cuDNN

打开下载网站 https://developer.nvidia.com/rdp/cudnn-download (最新版本) 或 https://developer.nvidia.com/rdp/cudnn-archive (历史版本)。注册账号并登录。

根据CUDA版本,找到合适版本的cuDNN。https://docs.nvidia.com/deeplearning/cudnn/archives/index.html 这个网站列出了每个cuDNN版本支持的CUDA版本(Support Matrix)。一般来说,可以去找最新的cuDNN,看它是否兼容当前的CUDA版本。如果不行,再考虑降级cuDNN。一般来说,CUDA 11.x 的兼容性都很好。

选好了cuDNN版本后,去上面的下载网站上下载最新或某个历史版本的cuDNN。注意,应该下载一个压缩文件,而不应该下载一个可执行文件。比如对于所有的Linux系统,都应该下载”xxx for Linux x86_64 (Tar)”

装CUDA和cuDNN,主要的目的是把它们的动态库放进环境变量里,把头文件放到系统头文件目录变量里。因此,下一步,我们要把cuDNN的文件放到系统能够找到的地方。由于CUDA的库目录、包含目录都会在安装时自动设置好,一种简单的配置方法是把cuDNN的文件放到CUDA的对应目录里。

对于Windows,我们要找到CUDA的安装目录,比如C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v10.2。再找到刚刚cuDNN解压后的目录,比如D:\Download\cudnn-11.1-windows-x64-v8.0.4.30\cuda。把cuDNN目录下bin、include、lib里的文件分别复制到CUDA目录的对应文件夹中。

对于Linux,CUDA的安装目录一般是/usr/local/cuda。再找到cuDNN的解压目录,比如~/Downloads/cudnn-linux-x86_64-8.4.0.27_cuda11.6-archive。切换到cuDNN的根目录下,输入类似下面的命令:

1
2
3
4
sudo cp include/* /usr/local/cuda/include
sudo cp lib/lib* /usr/local/cuda/lib64
sudo chmod a+r /usr/local/cuda/include/*
sudo chmod a+r /usr/local/cuda/lib64/lib*

该命令用于把所有cuDNN的相关文件暴力复制到cuda的对应目录下,并修改它们的访问权限。一定要注意一下该命令中的路径,如果路径不对应的话要修改上述命令,比如有些cuDNN的库目录不叫lib而叫lib64

如果大家对操作系统熟悉的话,可以灵活地把复制改为剪切或者软链接。

Anaconda

Anaconda可以让用户更好地管理Python包。反正大家都在用,我也一直在用。

无论是什么操作系统,都可以在这里下Anaconda:
https://www.anaconda.com/products/individual#Downloads

同样,Windows和Linux分别会得到GUI安装器和shell脚本。

下好了安装器后,按照默认配置安装即可。

安装完成后,下一步是打开有Anaconda环境的控制台。

在Windows下,点击任务栏中的搜索框,搜索Anaconda,打开Anaconda Powershell Prompt (Anaconda)或者Anaconda Prompt (Anaconda)

在Linux下,新建一个命令行即可。

如果在命令行里看到了(base),就说明安装成功了。

之后,要创建某个Python版本的虚拟环境,专门放我们用来做深度学习的Python库。该命令如下:

1
conda create --name {env_name} python={version}

比如我要创建一个名字叫pt,Python版本3.7的虚拟环境:

1
conda create --name pt python=3.7

创建完成后,使用下面的命令进入虚拟环境:

1
conda activate {env_name}

我的命令是:

1
conda activate pt

如果在命令行前面看到了({env_name}),就算是成功了:

完成上述步骤后,在VSCode里用ctrl+shift+p打开命令面板,输入select interpreter,找到Python: Select Interpreter这个选项,选择刚刚新建好的虚拟环境中的Python解释器。这样,新建VSCode的控制台时,控制台就能自动进入到conda虚拟环境里了。

PyTorch

推荐直接去官网首页下载。在首页,可以找到稳定版、最新版、长期支持版在不同操作系统下用不同包管理器,不同设备(不同CUDA版本或CPU)的pytorch安装命令:

这里选操作系统和编程语言没什么好讲的,包管理器也是最好选conda。要注意的就是PyTorch版本和CUDA版本。PyTorch版本最好选择稳定版和长期支持版(第一个和第三个)。同时,如前文所述,PyTorch和CUDA有一个大致的对应关系,最好能找到一个版本完美对应的安装命令。如果这里找不到合适的命令,可以去 https://pytorch.org/get-started/previous-versions/ 找旧版PyTorch的安装命令。

比如我要装cuda11.1的LTS版PyTorch,查出来的命令是:

1
conda install pytorch torchvision torchaudio cudatoolkit=11.1 -c pytorch-lts -c nvidia

又比如我要装当前稳定版cuda11.3的PyTorch,查出来的命令是:
1
conda install pytorch torchvision torchaudio cudatoolkit=11.3 -c pytorch

去Anaconda的命令行里执行这样一句安装指令即可。

如果下载速度较慢,请更换conda和pip的下载源。可参考的教程很多,比如 https://blog.csdn.net/u011935830/article/details/10307 95。

如果显卡驱动和conda都装好了,执行完上面的命令后,GPU版PyTorch也就装好了。打开Python,执行下面的命令(或者写一个.py文件再运行),即可验证GPU版安装是否成功。

1
2
import torch
print(torch.cuda.is_available())

如果输出了True,就说明GPU版的PyTorch安装成功了。

用PyTorch实现多分类任务

每当学习一门新的编程技术时,程序员们都会完成一个”Hello World”项目。让我们完成一个简单的点集多分类任务,作为PyTorch的入门项目。这个项目只会用到比较底层的函数,而不会使用框架的高级特性,可以轻松地翻译成纯NumPy或者其他框架的实现。

在这个项目中,我们会学到以下和PyTorch有关的知识:

  • PyTorch与NumPy的相互转换
  • PyTorch的常见运算(矩阵乘法、激活函数、误差)
  • PyTorch的初始化器
  • PyTorch的优化器
  • PyTorch维护梯度的方法

我们将按照程序运行的逻辑顺序,看看这个多分类器是怎么实现的。

如果你看过我其他的代码实战文章,欢迎比较一下这些代码,看看相比NumPy,PyTorch节约了多少代码。同时可以看一看PyTorch和TensorFlow的区别。

欢迎在GitHub上面访问本项目

数据集

本项目中,我们要用到一个平面点数据集。在平面上,有三种颜色不同的点。我们希望用PyTorch编写的神经网络能够区分这三种点。

在项目中,我已经写好了生成数据集的函数。generate_points能根据数据集大小生成一个平面点数据集。generate_plot_set能生成最终测试平面上每一个“像素”的测试集。使用这两个函数,得到的X的形状为[2, m](因为是平面点,所以只有两个通道),Y的形状为[1, m]Y的元素是0-2的标签,分别表示红、绿、蓝三种颜色的点。

1
2
3
4
5
train_X, train_Y = generate_points(400)
plot_X = generate_plot_set()

# X: [2, m]
# Y: [1, m]

数据预处理与PyTorch转换

我们刚刚得到的X, Y都是NumPy数组,我们要把它们转换成PyTorch认识的数据结构。

在PyTorch中,所有参与运算的张量都用同一个类表示,其类型名叫做Tensor。而在构建张量时,我们一般要用torch.tensor这个函数。不要把torch.Tensortorch.tensor搞混了哦。

使用torch.tensor和使用np.ndarray非常类似,一般只要把数据传入第一个参数就行。有需要的话可以设置数据类型。对于train_X,可以用如下代码转换成torch的数据:

1
train_X_pt = torch.tensor(train_X, dtype=torch.float32)

而在使用train_Y时,要做一些额外的预处理操作。在计算损失函数时,PyTorch默认标签Y是一个一维整形数组。而我们之前都会把Y预处理成[1, m]的张量。因此,这里要先做一个维度转换,再转张量:

1
train_Y_pt = torch.tensor(train_Y.squeeze(0), dtype=torch.long)

经过上述操作,X, Y再被送入PyTorch模型之前的形状是:

1
2
3
4
5
print(train_X_pt.shape)
print(train_Y_pt.shape)

# X: [2, m]
# Y: [m]

PyTorch多分类模型

处理完了数据,接下来,我们就要定义神经网络了。在神经网络中,我们要实现初始化、正向传播、误差、评估这四个方法。

初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MulticlassClassificationNet():
def __init__(self, neuron_cnt: List[int]):
self.num_layer = len(neuron_cnt) - 1
self.neuron_cnt = neuron_cnt
self.W = []
self.b = []
for i in range(self.num_layer):
new_W = torch.empty(neuron_cnt[i + 1], neuron_cnt[i])
new_b = torch.empty(neuron_cnt[i + 1], 1)
torch.nn.init.kaiming_normal_(new_W, nonlinearity='relu')
torch.nn.init.kaiming_normal_(new_b, nonlinearity='relu')
self.W.append(torch.nn.Parameter(new_W))
self.b.append(torch.nn.Parameter(new_b))
self.trainable_vars = self.W + self.b
self.loss_fn = torch.nn.CrossEntropyLoss()

和之前一样,我们通过neuron_cnt指定神经网络包含输出层在内每一层的神经元数。之后,根据每一层的神经元数,我们就可以初始化参数Wb了。

使用PyTorch,我们可以方便地完成一些高级初始化操作。首先,我们用torch.empty生成一个形状正确的空张量。之后,我们调用torch.nn.init.kaiming_normal_的初始化函数。kaiming_normal就是He Initialization。这个初始化方法需要指定激活函数是ReLU还是LeakyReLU。我们之后要用ReLU,所以nonlinearity是那样填的。

初始化完成后,为了让torch知道这几个张量是用可训练的参数,我们把它们
构造成torch.nn.Parameter。这样,torch就会自动更新这些参数了。

最后,我们用self.trainable_vars = self.W + self.b记录一下所有待优化变量,并提前初始化一个交叉熵误差函数,为之后的优化算法做准备

正向传播

正向传播的写法很简单,只要在每层算一个矩阵乘法和一次加法,再经过激活函数即可(在这个神经网络中,隐藏层的激活函数默认使用ReLU):

1
2
3
4
5
6
7
8
9
10
def forward(self, X):
A = X
for i in range(self.num_layer):
Z = torch.matmul(self.W[i], A) + self.b[i]
if i == self.num_layer - 1:
A = F.softmax(Z, 0)
else:
A = F.relu(Z)

return A

在这份代码中,torch.matmul用于执行矩阵乘法,等价于np.dot。和NumPy里的张量一样,PyTorch里的张量也可以直接用运算符+来完成加法。

做完了线性层的运算后,我们可以方便地调用torch.nn.functional里的激活函数完成激活操作。在大多数人的项目中,torch.nn.functional会被导入简称成F。PyTorch里的底层运算函数都在F中,而构造一个函数类(比如刚刚构造的torch.nn.CrossEntropyLoss()再调用该函数类,其实等价于直接去运行F里的函数。

值得一提的是,PyTorch会自动帮我们计算导数。因此,我们不用在正向传播里保存中间运算结果,也不用再编写反向传播函数了。

损失函数

由于之前已经初始化好了误差函数,这里直接就调用就行了:

1
2
def loss(self, Y, Y_hat):
return self.loss_fn(Y_hat.T, Y)

self.loss_fn = torch.nn.CrossEntropyLoss()就是PyTorch的交叉熵误差函数,它也适用于多分类。由于这个函数要求第一个参数的形状为[num_samples, num_classes],和我们的定义相反,我们要把网络输出Y_hat转置一下。第二个输入Y必须是一维整形数组,我们之前已经初始化好了,不用做额外操作,PyTorch会自动把它变成one-hot向量。做完运算后,该函数会自动计算出平均值,不要再手动求一次平均。

评估

为了监控网络的运行结果,我们可以手写一个评估网络正确率和误差的函数:

1
2
3
4
5
6
7
8
9
10
11
def evaluate(self, X, Y, return_loss=False):
Y_hat = self.forward(X)
Y_predict = Y
Y_hat_predict = torch.argmax(Y_hat, 0)
res = (Y_predict == Y_hat_predict).float()
accuracy = torch.mean(res)
if return_loss:
loss = self.loss(Y, Y_hat)
return accuracy, loss
else:
return accuracy

首先,我们使用Y_hat = self.forward(X),根据X算出估计值Y_hat。之后我们就要对YY_hat进行比较了。

Y_hat只记录了分类成各个类别的概率,用向量代表了标签。为了方便比较,我们要把它转换回用整数表示的标签。这个转换函数是torch.argmax

和数学里的定义一样,torch.argmax返回令函数最大的参数值。而对于数组来说,就是返回数组里值最大的下标值。torch.argmax的第一个参数是参与运算的张量,第二个参数是参与运算的维度。Y_hat的形状是[3, m],我们要把长度为3的向量转换回标签向量,因此应该对第一维进行运算(即维度0)。

得到了Y_predict, Y_hat_predict后,我们要比对它们以计算准确率。这时,我们可以用Y_predict == Y_hat_predict得到一个bool值的比对结果。PyTorch的类型比较严格,bool值是无法参与普通运算的,我们要用.float强制类型转换成浮点型。

最后,用accuracy = torch.mean(res)就可以得到准确率了。

由于我们前面写好了loss方法,计算loss时直接调用方法就行了。

模型训练

写完了模型,该训练模型了。下面是模型训练的主要代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
def train(model: MulticlassClassificationNet,
X,
Y,
step,
learning_rate,
print_interval=100):
optimizer = torch.optim.Adam(model.trainable_vars, learning_rate)
for s in range(step):
Y_hat = model.forward(X)
cost = model.loss(Y, Y_hat)
optimizer.zero_grad()
cost.backward()
optimizer.step()

PyTorch使用一系列的优化器来维护梯度下降的过程。我们只需要用torch.optim.Adam(model.trainable_vars, learning_rate)即可获取一个Adam优化器。构造优化器时要输入待优化对象,我们已经提前存好了。

接下来,我们看for s in range(step):里每一步更新参数的过程。

在PyTorch里,和可学习参数相关的计算所构成的计算图会被动态地构造出来。我们只要普通地写正向传播代码,求误差即可。

执行完cost = model.loss(Y, Y_hat),整个计算图就已经构造完成了。我们调用optimizer.zero_grad()清空优化器,用cost.backward()自动完成反向传播并记录梯度,之后用optimizer.step()完成一步梯度下降。

可以看出,相比完全用NumPy实现,PyTorch用起来十分方便。只要我们用心定义好了前向传播函数和损失函数,维护梯度和优化参数都可以交给编程框架来完成。

实验

做完了所有准备后,我们用下面的代码初始化模型并调用训练函数

1
2
3
4
n_x = 2
neuron_list = [n_x, 10, 10, 3]
model = MulticlassClassificationNet(neuron_list)
train(model, train_X_pt, train_Y_pt, 5000, 0.001, 1000)

这里要注意一下,由于数据有三种类别,神经网络最后一层必须是3个神经元。

网络训练完成后,我们用下面的代码把网络推理结果转换成可视化要用的NumPy结果:

1
2
3
plot_result = model.forward(torch.Tensor(plot_X))
plot_result = torch.argmax(plot_result, 0).numpy()
plot_result = np.expand_dims(plot_result, 0)

运行完plot_result = model.forward(torch.Tensor(plot_X))后,我们得到的是一个[3, m]的概率矩阵。我们要用torch.argmax(plot_result, 0)把它转换回整型标签。

之后,我们对PyTorch的张量调用.numpy(),即可使用我们熟悉的NumPy张量了。为了对齐可视化API的格式,我用expand_dims把最终的标签转换成了[1, m]的形状。

完成了转换,只需调用我写的可视化函数即可看出模型是怎样对二维平面分类的:

1
visualize(train_X, train_Y, plot_result)

我的一个运行结果如下:

只能说,神经网络实在太强啦。

总结

在这篇笔记中,我介绍了PyTorch在Windows/Linux下的从零安装方法,并且介绍了一个简单的PyTorch多分类项目。希望大家能通过这篇笔记,成功上手PyTorch。

项目链接:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/MulticlassClassification