基于国立台湾大学李宏毅机器学习的课程笔记

前期工作

cuda

Compute Unified Device Architecture (CUDA):简单地说,就是允许软件调用gpu来计算的一个接口
CUDA Runtime API vs. CUDA Driver API

  • 驱动版本需要≥运行时api版本
  • driver user-space modules需要和driver kernel modules版本一致
  • 当我们谈论cuda时,往往是说runtime api

以下是nvida的介绍原文:

It is composed of two APIs:

  • A low-level API called the CUDA driver API,
  • A higher-level API called the CUDA runtime API that is implemented on top of the CUDA driver API.

The CUDA runtime eases device code management by providing implicit initialization, context management, and module management. The C host code generated by nvcc is based on the CUDA runtime (see Section 4.2.5), so applications that link to this code must use the CUDA runtime API.

In contrast, the CUDA driver API requires more code, is harder to program and debug, but offers a better level of control and is language-independent since it only deals with cubin objects (see Section 4.2.5). In particular, it is more difficult to configure and launch kernels using the CUDA driver API, since the execution configuration and kernel parameters must be specified with explicit function calls instead of the execution configuration syntax described in Section 4.2.3. Also, device emulation (see Section 4.5.2.9) does not work with the CUDA driver API.

简单地说,driver更底层,更抽象但性能和自由度更好,runtime则相反

容器


infrastructure(基础设施)
简单地说,虚拟机的隔离级别比容器更高,虚拟机会模拟出一个系统及其系统api,而docker依旧调用宿主机的api,因此docker更为轻量级
docker是处理复杂环境问题的良策,比虚拟机更为轻量
其他常用的容器:Slurm and Kubernetes

Docker Hub repository of PyTorch

理论

官方文档
Official Pytorch Tutorials
pytorch-for-numpy-users

张量tensor:用于表示n维数据的一种概念,例如一维张量是向量,二维是矩阵……

dim in PyTorch == axis in NumPy

1
2
import torch
import numpy as np
1
2
3
4
5
6
7
8
9
10
def test():
print("", torch.cuda.is_available())
if torch.cuda.is_available():
device = torch.device("cuda")
print(f"There are {torch.cuda.device_count()} GPU(s) available.")
print("Device name:", torch.cuda.get_device_name(0))
else:
print("No GPU available, using the CPU instead.")
device = torch.device("cpu")
test()
 True
There are 1 GPU(s) available.
Device name: NVIDIA GeForce RTX 2070

以下是一些朴素的张量操作:

1
2
3
4
x = torch.tensor([[1, -1], [-1, 1]])
print(x)
x = torch.from_numpy(np.array([[1, -1], [-1, 1]]))
print(x)
tensor([[ 1, -1],
        [-1,  1]])
tensor([[ 1, -1],
        [-1,  1]])
1
2
3
4
x = torch.zeros([2, 2])
print(x)
x = torch.ones([1, 2, 5])
print(x)
tensor([[0., 0.],
        [0., 0.]])
tensor([[[1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.]]])
1
2
3
4
5
x = torch.zeros([2, 3])
print(x.shape)

x = x.transpose(0, 1)
print(x.shape)
torch.Size([2, 3])  
torch.Size([3, 2])

对每一层神经网络,输入的x乘以权重向量w(eight),加上一个标量b(ias)后就是输出y
下图中我们训练一个将32维向量转化为64维向量输出的模型,因此权值矩阵规模是64×32,输入向量是32×1,输出是64×1
算出线性的权值和之后,增加一层激活函数Activation Function,激活函数常是非线性的,用于增强网络学习能力,如果没有激活函数,网络就是单纯的有很多层数的线性回归
全连接前馈神经网络(fully-connected feed-forward network):前馈表示从前到后训练,全连接表示相邻的两层中,所有的神经元都是相连的;网络第一层称为输入层(input layer),最后一层称为输出层(output layer),中间的层数被称为隐藏层(hidden layer)
深度学习:这个定义非常简单粗暴,意思是隐藏层很多,一般可能有三位数起步,并非越多层就越好,这需要合适的数据集,合适的提取特征方式,即feature engineering,才能定义一个较好的网络

  • Sigmoid函数也叫Logistic函数,用于隐层神经元输出,取值范围为(0,1),它可以将一个实数映射到(0,1)的区间,可以用来做二分类。在特征相差比较复杂或是相差不是特别大时效果比较好,图像类似一个S形曲线:
    • $ f(x)=\frac{1}{1+e^{-x}} $
  • ReLU函数又称为修正线性单元(Rectified Linear Unit),是一种分段线性函数,弥补了sigmoid函数的梯度消失问题(即该函数两端非常平滑,导数趋近0,遇到数值偏两端的数据,loss很难传播):
    • $f(x)={\left\{\begin{array}{l l}{x}&{,x\gt =0}\\ {0}&{,x\lt 0}\end{array}\right.} $

梯度下降法
有点类似于牛顿法(牛顿法理论是二阶收敛,梯度则为一阶,牛顿法速度更快计算量更大),所谓的梯度就是一个多元函数中,对一个点求各个元的偏导数取值组成的一个表示方向的向量(即偏导数乘对应元的单位向量)
这个梯度一般指向当前点增加最快的方向,把他乘以-1就会得到下降最快的方向(一般用于最小化损失函数),梯度只表示方向,因此还需要选择合适的步长α,乘以方向向量后就得到移动的路径,步长太长了会跨过极小值然后来回震荡,太短了效率会很差
梯度下降算法需要设置一个学习率(learning rate),每次迭代中,未知参数的梯度下降的步长取决于学习率的设置,这种由人为设定的参数被称为超参(hyperparameters)
如果我们的数据集很大,计算梯度会相当复杂,则可以分为n个batch,每个batch有B个资料,即 mini-batch 梯度下降(MBGD,Mini-Batch GradientDescent

损失函数loss function:评估训练成果的一个标准,常见有:

  • criterion = nn.MSELoss():Mean Squared Error
  • criterion = nn.CrossEntropyLoss():Cross Entropy 交叉熵

在不同的领域中,熵被表示为混乱程度,不确定性,惊奇程度,不可预测性,信息量等,但在机器学习中我们只需要对信息论中的熵有基本了解
信息论中熵的概念首次被香农提出,目的是寻找一种高效/无损地编码信息的方法,即无损编码事件信息的最小平均编码长度 即信息熵H(X) = -Σ p(x) log2 p(x) (p为概率)

接下来说明这个公式,假设我们用二进制的哈夫曼编码,一个信息出现概率是1/2,即其他所有情况加起来也是1/2,那么我们会发现其编码长度必然是-log(1/2),也就是1,恰好和我们的香农熵定义一致了,这是为什么呢?
严谨的数学证明超出了cs专业范围,这里只说一下直观理解,熵有两个性质:

  • 概率越小信息量越大(如果一个小概率事件发生了,就排除了非常多其他可能性)
  • 假设两个随机变量x,y相互独立,那么分别观测两个变量得到的信息量应该和同时观测两个变量的信息量是相同的,h(x+y)=h(x)+h(y)

如此一来对数函数的负数完美符合条件,基数则无所谓,直观地理解,基数对应用几进制编码,而要最短化编码,越小概率就应该用更长的位数,把短位数腾出来给大概率事件用,当然实际中编码的位数是离散的,而且相比对数取负只能多不能少,因此香农熵是一个理论最优值,熵编码就指无损情况下的编码方式,最常用的就是哈夫曼编码,所有熵编码方式的编码长度大于等于香农熵

现实中常用二进制编码信息,例如对8种不同的信息,最直观的编码是三位二进制,每三位表示一个独特信息。
我们可以用概率表示每种信息出现的可能,例如8种信息,每个都等可能出现,那么以概率为权的哈夫曼编码就会用所有的3位二进制编码这8种信息,熵就是3,而其他情况熵可以当做哈夫曼树的总编码长度算
那么如何理解熵能反映混乱度呢?如果熵比较大(即平均编码长度较长),意味着这一信息有较多的可能状态,相应的每个状态的可能性比较低;因此每当来了一个新的信息,我们很难对其作出准确预测,即有着比较大的混乱程度/不确定性/不可预测性

交叉熵
交叉熵用于评估估计概率得到的熵与真实熵的差距,交叉的含义很直观,就是使用P计算期望,使用Q计算编码长度
为什么这么选而不是反过来呢?这取决于我们的目的,一般来说,我们希望估计的编码长度和理论最优的熵差距较小,要比对取优的主要是模型的编码长度即logQ,可以这么理解,熵公式中的对数函数视为视为对一个特定概率事件的编码长度,由于现实的概率分布实际上是确定的,那么需要评估的也就是编码方式的效率
由于熵是给定概率分布下的最优值,交叉熵只可能大于等于熵,两者差越小或者说交叉熵越小表示模型估计越准

eg.
nn:neural network
loss = criterion(model_output, expected_value)

优化算法 optimizer

torch.optim
通俗讲解反向传播

最流行的优化就是反向传播,即与前向训练过程相反,用结果的损失值修正训练中的权值,在数学上就是求损失对前一层权重的偏导数
但是损失函数的参数是本层的输出值,因此需要链式法则求偏导,先求出损失函数对输出的偏导,再乘以本层输出对前一层权重的偏导(当然我们知道本层神经元收到上层的输出后还要算个激活函数才能得到最后的输出,因此中间还有一步本层输出对上层输出求偏导,只有上层输出直接和上层权重有关)


如图,权重结合上层out得到net,net经过激活函数得到本层out,本层out用来计算损失值,因此反向传播会反过来算
只要把这些名词换成常用的yx,那么求偏导的过程就会很简单直白
得到偏导函数后,接下来正如在梯度下降中学到的,我们希望通过不断调整上层权重来最小化结果层的损失函数,每次用一个“学习速率 \(\eta\) ”乘以偏导数来更新权重

涉及隐藏层时稍微有所不同,在由于隐藏层的下一层会连到不同的结果,也就会产生多个损失值,在out部分需要对不同的损失值求偏导,即总误差对outh1的偏导是E(o1)和E(o2)对outh1偏导的和

e.g.:

  1. optimizer.zero_grad():重设梯度(即训练完一段后梯度置0,截断反向传播再继续训练)
  2. Call loss.backward() 反向传播以减少损失
  3. Call optimizer.step() 调整模型参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

dataset = MyDataset(file)
tr_set = DataLoader(dataset, 16, shuffle=True)
model = MyModel().to(device)
criterion = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), 0.1)

for epoch in range(n_epochs):
model.train()
for x, y in tr_set:
optimizer.zero_grad()
x, y = x.to(device), y.to(device)
pred = model(x)
loss = criterion(pred, y)
loss.backward()
optimizer.step()

参考项目:

DataLoader(train_date,shuffle=True)中shuffle表示打乱数据集,符合直觉的想法是:这对避免过拟合有帮助

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
26
27
28
29
30

class My_model(nn.Module):
def __init__(self,inputdim):
super(My_model,self).__init__()
self.layers=nn.sequential(#将多个层按顺序组合在一起
nn.Linear(input_dim,64),
nn.ReLU(),
nn.Linear(6432),
nn.ReLU(),
nn.Linear(321)
)

def forward(self, x):
x = self.layers(x)
x = x.sgueeze(1)#(B,l) -> B
return x

#Training loop
for epoch in range(3000):
model.train() # Set your model to train mode.
# tgdm is a package to visualize your training progress
train_pbar = tqdm(train_loader, position=0, leave=True)
for x, y in train_pbar:
x, y = x.to('cuda'), y.to('cuda')
pred = model(x)
loss = criterion(pred, y)
loss.backward()# Compute gradient(backpropagation).
optimizer.step()# Update parameters.
optimizer.zero_grad() # Set gradient to zero.

PyTorch Documentation

常见问题

  1. 模型偏差:模型在训练资料上的损失函数很大时,可能是因为在这个问题中选择的模型太过简单,以至于无论用这个给模型选择什么样的参数θ,损失函数f(θ)都不会变得很小
  2. 优化问题,梯度下降看上去很美好,但常常会卡在一个局部最优(local minima)点,这个局部最优可能和全局最优(global minima)差得很远,因此需要选取更好的优化算法如 Adam,RMSProp,AdaGrad 等
  3. 过拟合,训练集Loss很小,测试集却很大,需要注意的是首先得满足前一个条件,不然也可能是1.2.问题
    1. 最有效的一种解决方案是增加训练资料,但很多时候是无法做到的
    2. 第二种方法就是数据增广(Data Augmentation),常用于图像处理。既然不能增加数据,那就更好地利用现有数据;例如:对图像左右镜像,改变比例等等,需要注意不能过度改变数据特征,例如上下颠倒图片
    3. 增加对模型的限制,常见如早停止,正则化,丢弃部分不合理数据等等

梯度消失

如果我们用最常见的sigmoid函数,且在某层上输入值在函数的两端,此时导数非常小,也就是梯度非常小,这层上的反向传播几乎无法更新,不仅如此,更之前的层级也很难继续传播,这就是所谓的梯度消失问题。
除了更新缓慢问题外,这还会导致浅层和深层的更新程度区别巨大,让模型变得“不均匀”,此外,由于sig函数的梯度区间较小,模型深了几乎必然有这种问题

ReLU用于解决梯度消失问题,起梯度要么是0要么是1,只在负端消失,这一个额外的好处,如果负端的数据其实是噪声或者是一些我们不关注的特征,那么扔掉反而会让模型效果更好
这种激活函数的缺点是,梯度非负数,对于一层所有的w,梯度的符号都是一样的,只能一起增大或者减小,这可能减少模型的准确度
通常,激活函数的输入值有一项偏置项(bias),假设bias变得太小,以至于输入激活函数的值总是负的,那么反向传播过程经过该处的梯度恒为0,对应的权重和偏置参数此次无法得到更新。如果对于所有的样本输入,该激活函数的输入都是负的,那么该神经元再也无法学习,称为神经元”死亡“问题

LeakyReLU的提出就是为了解决神经元”死亡“问题,其输入小于0的部分,值为负,且有微小的梯度,除了避免死亡还有一个可能的好处是,该微小的梯度可能让训练有一些微小的振动,在特定情况能跳出局部最优解

正则化

正则化指为解决适定性问题或过拟合而加入额外信息的过程,在机器学习中,常见的就是为损失函数添加一些调整项
根据前文所述,学习的过程就是根据损失函数与权重的关系不断调整权重以最小化损失,而正则化的目的是不要让权重关系太复杂以致于没有普适性。我们将原始的损失函数画一个图,正则项再画一个图,需要找的就是两个函数同样权重基础上的最小和
用比较简单的双权重,均方误差损失函数来说,w1,w2就是xy轴,最后的loss作为z轴,原始损失函数L可能千奇百怪,但最后要找的是其与正则项的最小和.这个值通常在两个函数的交点取,而(二维层面上)L1的图像是菱形,l2是个圆,符合直观地推导,前者交点很容易在坐标轴上取,后者容易在离坐标轴近的地方取,即l1容易让权重稀疏,L2容易让它们的值的绝对值较小且分布均匀
数学上讲,抛开不确定的损失函数,l1正则项的导数是w×正负信号量,迭代时如果w大于0会减少,大于0会增加,最后很容易变成0;而l2的导数是w的一次函数且一次项系数小于1,迭代让w不断减小,这个减小量与w本身有关,因此一般来说不容易减到0

范数是具有“长度”概念的函数,用于衡量一个矢量的大小(测量矢量的测度)
由于不是数学系的,这里就极为不严谨地记录一下范数的理解:

  • 0范数,向量中非零元素的个数
  • 1范数,为绝对值之和
  • 2范数,就是通常意义上的模

正则化的目的可以理解为限制权重向量的复杂度,实际做法为在损失函数中加入与权重向量复杂度有关的惩罚项,而范数在某种意义上可以反映这点,因此可作为选取正则项的依据
顺便一提a star算法也会用类似的测度估计距离

神经网络模型

线性模型

回归问题常常用若干输入产生一个连续值作为输出,线性回归(Linear Regression)和逻辑回归(Logistics Regression)是常见的线性模型

回归

线性回归,即y = wx + b ,是最简单的回归模型,但纯一次项的拟合能力较为受限,这种情况下就需要多项式回归
我们将w视为权值向量,x视为从一次x'到n次x'组成的向量,那么多项式模型依旧可以用原先的线性公式表示
增加多项式的次数可以更好拟合训练集,但对测试集的效果就未必了,很容易出现过拟合问题,如果出现,依旧需要正则化,增加数据数量或者维度等优化

分类

分类问题最简单的解决方法莫过于对回归产生的结果进行筛选,一个区间对应一个类别,但这么做会很难处理区间的划分情况,因此需要其他的处理方法
分类问题的损失函数与回归不同,可以单纯用分类错误的数量计算,解决分类问题有两者常见模型:感知机(Perceptron)和支持向量机(SVM)

在统计学中,方差是用来度量单个随机变量的离散程度,而协方差则一般用来刻画两个随机变量的相似程度

\[ P(A | B) = \frac{P(B | A) \cdot P(A)}{P(B)} \]