一、模型选择
在机器学习中,我们通常在评估几个候选模型后选择最终的模型。 这个过程叫做模型选择。
有时候需要比较的是不同模型,有时候比较的是同一个模型下的不同超参数。
1、数据集划分
训练模型是在训练集上,那确定超参数是在哪一个数据集?
肯定不能在训练集上啊,这样无法估计泛化误差;那在测试集上也不行啊,因为测试集只能用一次,没有那么多测试集够我们使用,所以就有了以下数据集的划分!
在机器学习任务中,通常将数据集划分为三个部分:训练集(training set),验证集(validation set),和测试集(test set)。
训练集用于训练模型的参数,通过优化算法对模型进行参数更新和调整。模型在训练集上进行多次迭代训练,以逐步学习数据的规律和模式。
验证集用于选择模型的超参数,例如模型的复杂度、学习率等。通过在验证集上评估不同超参数设置下的模型性能,可以选择最佳的超参数组合,从而提高模型的泛化能力。
测试集用于最终评估模型的性能和泛化能力。测试集是模型从未见过的数据,用于模拟模型在实际应用场景中的表现。在测试集上评估模型的性能可以给出对模型在新数据上的预测能力的准确评估。
特别强调,验证集、测试集要划分开,必须保证测试集是从未见过的数据,要不然会导致模型虚高。
但是我们在写代码的时候并没有真正的测试集,虽然写着 test_iter,但是其实是验证集。因为真正的测试集是新数据,甚至没有标号。
2、K-则交叉验证
但是我们经常会遇见一个问题,没有那么多数据供我们使用!!!
这时候就需要用到 k-则交叉验证。
K折交叉验证,初始采样分割成K个子样本,一个单独的子样本被保留作为验证模型的数据,其他K-1个样本用来训练。交叉验证重复K次,每个子样本验证一次,平均K次的结果,最终得到一个单一估测。一般 k 为 5或者10.
损失为 这 k 个的损失之和求平均值。
二、模型评估
1、泛化误差
大家有没有讲过这样的现象,明明训练的时候误差比较小,但是到了最后预测的时候,误差却比较大,这个是为什么呢?
首先我们一个明确一个事情,我们的算法必须能够在先前未观测到的新输入上表现良好,而不只是在训练集上表现良好。在先前未观测到的输入上表现良好的能力被称为泛化 (generaliza-tion)。
接下来引入了两个新概念,训练误差和泛化误差(也叫测试误差)。
-
训练误差:模型在训练数据集上计算得到的误差。
-
泛化误差:模型在一个新数据(从未见过的数据)上的损失。
通常,我们只关注泛化误差,而不是训练误差;因为泛化误差可以衡量模型在新数据上的泛化能力。
最直观的现象就 欠拟合、过拟合。接下来让我们来看看。
2、容量、欠拟合、过拟合
欠拟合是指模型不能在训练集上获得足够低的误差,更不能很好地泛化新数据的现象。
过拟合是训练误差和测试误差之间的差距太大。
那欠拟合、过拟合跟什么有关系呢?一般跟模型容量、数据有关。
模型容量(model capacity)是指一个机器学习模型能够拟合复杂函数的能力。它取决于模型所包含的参数数量以及模型的结构。具有更多参数和更复杂结构的模型通常具有更高的容量,可以更好地适应训练数据。
模型容量过低:当模型容量不足以表示数据中的复杂模式时,模型会出现欠拟合现象,无法很好地拟合训练数据。
模型容量过高:当模型容量过高时,模型可能过度拟合训练数据,记住了数据中的噪声和细节,而无法泛化到新数据。
大致可以表现为下图
接下来更加直观的看一下模型容量的影响
当数据固定的时候,我们从简单模型慢慢到复杂模型,这是平时的调参策略。
当模型容量比较低的时候,训练损失、泛化误差比较高;随着模型容量的增加,训练损失在不断减少。但是并不是模型容量越高越好,越高意味着复杂度越高,意味着记住更多的信息,意味着记录大量的噪音,这会导致模型去拟合噪音,导致泛化误差变大,需要把握这个度。
模型容量是可以估计的,但仅限于同种类型模型。比如树模型不能与神经网络进行比较;
估计模型容量,主要从两个因素考虑:参数个数、参数值的选择范围
参数越多、参数可选择的范围越大,表示模型复杂度越高,也就是模型容量越高。
3、正则化
欠拟合可以通过增大模型容量来解决,那么过拟合怎么处理呢?
可能大家都会想到,不是数据不够吗,那我们去收集更多的训练数据来缓解不就行了吗。其实这个方法是不可行的,成本高、耗时多,那么怎么解决呢?
最常见的方法就是使用正则化。
一般来说,解决过拟合问题就是限制特征数量,但是简单的限制特征数量会导致模型变得过于简单,无法很好的捕捉数据。那么怎么办呢?不限制特征数量的话,会导致过拟合问题。其实还有一个解决办法,就是通过控制特征的可选择范围来控制模型容量。其实正则化就是通过控制特征的可选择范围,来使模型容量处于一个平衡位置。接下来看一下严格定义。
正则化是一种用于控制机器学习模型复杂性的技术,旨在防止模型过度拟合训练数据,从而提高其在未知数据上的泛化能力。正则化通过在模型的损失函数中引入额外的惩罚项,促使模型的权重保持较小的值。
常见的有 L1正则化、L2正则化、Dropout。今天咱们着重看一下L2正则化(权重衰退)。
(1)L2正则化(权重衰退)
前面也说了 正则化是通过限制参数值的选择范围来控制模型容量的,L1、L2正则化的区别就在于权重可选择的范围这里不同。L2正则化,惩罚项为 L2范数的平方。
minℓ(w,b) subject to ∥w∥2≤θmin ell(mathbf{w}, b) quad text { subject to }|mathbf{w}|^{2} leq theta
就在是随机梯度下降的时候,既要使损失最小,还需要把每一个权重都小于等于 θsqrt{theta} 。(他们所有数的总和都需要小于等于 θsqrt{theta} ,何况是单个权重呢)
通常不会限制偏移(bias),因为 bb 表示的是在原点时的偏移,和过拟合没有什么关系。起决定作用的是 wmathbf{w} ,当 wmathbf{w} 被限制在一定范围内时,bb 自然而然也会有相应调整。
但是上面的限制优化起来比较麻烦,所有就又换了下面这个形式,通常都是使用如下形式。
minℓ(w,b)+λ2∥w∥2min ell(mathbf{w}, b)+frac{lambda}{2}|mathbf{w}|^{2}
至于怎么得到的呢?下面就是数学问题了。
首先把上面的问题转换为数学问题:有一个方程f(x)想要使得取最小值,这很简单(求极值点,判断最大值、最小值即可),但是又加了一个限制条件,使得方程里面的参数都需要小于等于某个数。
这时候就需要用到拉格朗日乘子法了。
拉格朗日乘数法(英语:Lagrange multiplier,以数学家约瑟夫·拉格朗日命名),在数学中的最优化问题中,是一种寻找多元函数在其变量受到一个或多个条件的约束时的局部极值的方法。这种方法可以将一个有n个变量与k个约束条件的最优化问题转换为一个解有n + k个变量的方程组的解的问题。这种方法中引入了一个或一组新的未知数,即拉格朗日乘数,又称拉格朗日乘子,或拉氏乘子,它们是在转换后的方程,即约束方程中作为梯度(gradient)的线性组合中各个向量的系数。
定义比较难懂,大致用法如下
比如我们需要求 f(x,y)operatorname{f}(x,y) 在 g(x,y)=0operatorname{g}(x,y) = 0 的局部极值时,我们可以引入新变量 拉格朗日乘数 ,这时候我们只需要求下列 拉格朗日函数的局部极值
h(x,y,λ)=f(x,y)+λ⋅g(x,y)operatorname{h}(x,y,lambda ) = operatorname{f}(x,y) + lambda cdot operatorname{g}(x,y)
因此,咱们上面那个问题就可以进行求解了,至于原理,等后面再说!
minℓ(w,b) subject to ∥w∥2≤θ⇒minℓ(w,b)+λ⋅(∥w∥2−θ)begin{align}
min quad ell(mathbf{w}, b) quad text { subject to }|mathbf{w}|^{2} leq theta \
Rightarrow min quad ell (mathbf{w}, b) + lambda cdot (|mathbf{w}|^2 – theta )\
end{align}
其实就是在损失函数后面加上一个惩罚项。
接下来就是求极值的问题了,由于 θtheta 是一个常数,对求 损失函数的最小值 对应的 wmathbf{w} 不影响,所有可以写成下式
minℓ(w,b)+λ⋅∥w∥2min quad ell (mathbf{w}, b) + lambda cdot |mathbf{w}|^2
但是这样求最小值的时候会和不省略 θtheta 的不一样,由于影响比较小,所有咱们可以这样做。
为了方便后面求偏导,有一个 范数的平方,所有可以把 λlambda 变成 λ2frac{lambda }{2}
最终结果为
minℓ(w,b)+λ2⋅∥w∥2min quad ell (mathbf{w}, b) + frac{lambda}{2} cdot |mathbf{w}|^2
这样就和上面优化后的公式一样了。
拉格朗日乘子法的证明,大家可以看这 拉格朗日乘子法的证明
加了惩罚项对最优解的影响,可以看下图
绿色的是原来的损失函数的等高线,本来是应该取到中心的。但是由于限制条件,只能取 绿色、橙色的交点,这样就同时满足条件了。应该可以从图直观看出,惩罚项把权重拉低了!
最后来看一下参数更新。
首先重新计算一个 损失函数的梯度。
∂∂w(ℓ(w,b)+λ2⋅∥w∥2)=∂ℓ(w,b)∂w+λ⋅wfrac{partial }{partial mathbf{w}} (ell (mathbf{w}, b) + frac{lambda}{2} cdot |mathbf{w}|^2) = frac{partial ell (mathbf{w}, b)}{partial mathbf{w}} + lambda cdot mathbf{w}
接下来就是更新参数了。
wt+1=wt−η∂ℓ(wt,bt)∂wt=wt−η(∂ℓ(wt,bt)∂wt+λ⋅wt)=(1−ηλ)wt−η∂ℓ(wt,bt)∂wtbegin{align}
mathbf{w}_{t + 1} &= mathbf{w}_t – eta frac{partial ell(mathbf{w}_t, b_t) }{partial mathbf{w}_t}\
&=mathbf{w}_t – eta (frac{partial ell (mathbf{w}_t, b_t)}{partial mathbf{w}_t} + lambda cdot mathbf{w}_t )\
&= (1-eta lambda )mathbf{w}_t – eta frac{partial ell (mathbf{w}_t, b_t)}{partial mathbf{w}_t}
end{align}
对比于之前,wtmathbf{w}_t 前面多了系数,并且一般来说,这个系数 1−ηλ1-eta lambda 是小于 1 的。
这样在每次更新权重的时候,先把之前的上一次的权重变小,再沿着梯度的反方向走。所以叫做权重衰退。
(2)L2正则化代码实现
上面都是理论,下面咱们来手动模拟一个会产生过拟合的模型。
首先就是根据咱们设置正确的权重生成训练集、测试集。下面内容忘记的可以看一下线性模型生成数据。
!pip install d2l==0.17
%matplotlib inline
import torch
from torch import nn
from d2l import torch as d2l
# 生成随机数据
n_train,n_test,num_inputs,batch_size = 20,100,200,5
true_w,true_b = torch.ones(num_inputs,1) * 0.01 , 0.05
train_data = d2l.synthetic_data(true_w,true_b,n_train)
train_iter = d2l.load_array(train_data, batch_size)
test_data = d2l.synthetic_data(true_w,true_b,n_test)
test_iter = d2l.load_array(test_data, batch_size,is_train=False)
其中调用了d2l的两个库函数,如下
#随机生成数据
def synthetic_data(w, b, num_examples):
"""Generate y = Xw + b + noise.
Defined in :numref:`sec_utils`"""
X = d2l.normal(0, 1, (num_examples, len(w)))
y = d2l.matmul(X, w) + b
y += d2l.normal(0, 0.01, y.shape)
return X, d2l.reshape(y, (-1, 1))
# 返回训练数据/测试数据 迭代器
def load_array(data_arrays, batch_size, is_train=True):
"""Construct a PyTorch data iterator.
Defined in :numref:`sec_utils`"""
dataset = torch.utils.data.TensorDataset(*data_arrays)
return torch.utils.data.DataLoader(dataset, batch_size, shuffle=is_train)
接下来就是初始化参数了。
def init_params():
w = torch.normal(0,1,size=(num_inputs,1),requires_grad=True)
b = torch.zeros(1,requires_grad=True)
return [w,b]
定义惩罚项
# L2正则化
def l2_penalty(w):
return torch.sum(w.pow(2)) / 2 #L2范数 为向量的平方和再开平方
训练过程
def train(lambd):
w,b = init_params()
net,loss = lambda X : d2l.linreg(X,w,b),d2l.squared_loss #匿名函数,X为参数,后面为匿名函数
num_epochs,lr = 100,0.003
animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
xlim=[5, num_epochs], legend=['train', 'test'])
for epoch in range(num_epochs):
for X,y in train_iter:
l = loss(net(X),y) + lambd * l2_penalty(w) #新的损失函数为 损失函数 + 惩罚项
l.sum().backward()
d2l.sgd([w,b],lr,batch_size)
if (epoch + 1) % 5 == 0:
animator.add(epoch + 1, (d2l.evaluate_loss(net, train_iter, loss),
d2l.evaluate_loss(net, test_iter, loss)))
print('w的L2范数是:', torch.norm(w).item())
(3)Dropout
前面讲了L2正则化,接下来看看另外一个常用的方法 Dropout吧!
上面是从权重的范数来评估模型的质量的,还可以从另外一个角度平滑性来考虑。
平滑性就是函数不应该对其输入的微小变化敏感。对于好模型来说,添加一些随机噪音 对结果毫无影响。
在前向传播过程中,计算每一内部层的同时注入噪音,这个就叫做 Dropout。因为我们从表面看起来是在训练过程中丢弃一些神经元。
下面看一下Dropout的定义。
随机失活(dropout)是对具有深度结构的人工神经网络进行优化的方法,在学习过程中通过将隐含层的部分权重或输出随机归零,降低节点间的相互依赖性(co-dependence )从而实现神经网络的正则化(regularization),降低其结构风险(structural risk)
那么怎么注入噪声呢?
其实也很简单,就是随机把某些权重随机设置为0,概率为p,没有设置为0的权重都会除以 1 – p。
h′={0 概率为 ph1−p 其他情况 h^{prime}=left{begin{array}{ll}
0 & text { 概率为 } p \
frac{h}{1-p} & text { 其他情况 }
end{array}right.
h 为当前层即将输入到下一层的值。
为什么剩余的权重要除以 1 – p呢?
是为了保持在使用Dropout训练过程中的期望 和没有Dropout的模型的期望一致,测试阶段没有Dropout。保持一致的话,以便能够取得良好的泛化性能。
但是学完这些之后,我还是不太了解为什么Dropout可以对抗过拟合呢?然后我问了一下ChatGPT。
-
减少神经元之间的共适应性:在神经网络中,神经元之间存在相互依赖的情况,某些神经元可能会过分依赖其他神经元的输出。这种相互依赖容易导致过拟合,即网络过度学习了训练数据的特定模式。通过使用dropout,每个神经元都有一定概率被随机丢弃,这意味着在训练过程中,网络无法依赖特定的神经元来学习和传递信息,从而减少了神经元之间的共适应性,使得网络更加鲁棒。
-
增强模型的泛化能力:过拟合的一个常见原因是模型过于复杂,能够记住大量噪声或异常数据。通过使用dropout,我们实际上对网络进行了模型平均化。因为每次训练时只采样出网络的一个子集,不同的子网络会更多地关注不同的特征和模式。这种模型平均化的效果相当于将多个不同的模型集成在一起,可以有效减少过拟合,并提高网络的泛化能力。
感觉似乎明白了。可以用下面的例子来理解。
借用沐神的例子。我们要利用模型来帮银行找出没有能力还贷款的人。在以往的数据中(训练数据),五个没还贷款的中有3个是穿蓝色衣服的(可能是蓝领),然后模型就会记住这个现象,并且传递给下一层。但是这只是个现象,人家有可能就是单纯穿个蓝色衣服而已,明天就换一个其他颜色的衣服了。Dropout后,可以减轻后面层对衣服这个依赖项,随机丢弃一些特征,无法传递到下一层。可以使模型泛化能力更好点。
这个比较通俗易懂,可以看一下这篇文章 CNN 入门讲解:什么是dropout?
(4)Dropout代码实现
首先先定义一个Dropout层来处理输出数据,随机失活。
def dropout_layer(X,dropout):
assert 0 <= dropout <= 1 #不在访问就出现AssertionError 报错
if dropout == 0: #概率为0,表示不处理
return X
if dropout == 1: #为1,表示全失活
return torch.zeros_like(X)
mask = (torch.rand(X.shape) > dropout).float() #mask非1即0,小于dropout的失活
return mask * X / (1 - dropout)
咱们可以验证一下,是否正确实现了。
X= torch.arange(16, dtype = torch.float32).reshape((2, 8))
print(X)
print(dropout_layer(X, 0.))
print(dropout_layer(X, 0.5))
print(dropout_layer(X, 1.))
接下来就是定义模型及其参数了。
num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256 #2个隐藏层
dropout1,dropout2 = 0.2,0.5
class Net(nn.Module):
def __init__(self,num_inputs, num_outputs, num_hiddens1, num_hiddens2,is_training = True): #初始化函数
super(Net, self).__init__()
self.num_inputs = num_inputs
self.training = is_training
self.lin1 = nn.Linear(num_inputs, num_hiddens1)
self.lin2 = nn.Linear(num_hiddens1, num_hiddens2)
self.lin3 = nn.Linear(num_hiddens2, num_outputs)
self.relu = nn.ReLU()
#重写前向传播,加入Dropout
def forward(self,X):
H1 = self.relu(self.lin1(X.reshape((-1,self.num_inputs))))
if self.training == True: #如果是训练过程,就使用dropout,test时不能使用
H1 = dropout_layer(H1,dropout1)
H2 = self.relu(self.lin2(H1))
if self.training == True:
H2 = dropout_layer(H2,dropout2)
out = self.lin3(H2)
return out
net = Net(num_inputs,num_outputs,num_hiddens1,num_hiddens2)
最后就是训练
num_epochs, lr, batch_size = 10, 0.5, 256
loss = nn.CrossEntropyLoss()
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
trainer = torch.optim.SGD(net.parameters(), lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
可以对比不使用Dropout有什么区别。
可以明显发现,不使用Dropout,虽然train acc很高,但是test acc不稳定,明显就是过拟合了。
其实我有一个疑问,forward在哪里被调用的,作用是什么,为什么之前没有用过?先已解决,给出解答。
首先,这个自定义Net是继承 nn.Module,然后重写了forward。当我们在d2l封装的train_ch3函数里面执行 net(X)的时候,会调用 call,然后在__call__里面调用forward
forward的作用是 模型从输入数据开始,经过一系列的线性和非线性运算,最终得到输出结果的过程。就是可以通过当前的权重,然后从第一次到最后一层进行计算,输出结果。在根据这个结果 计算损失。然后使用后向传播计算损失函数梯度,更新参数。
简洁实现更简单了,就是在定义模型的时候添加Dropout层即可
#定义模型
dropout1, dropout2 = 0.2, 0.5
net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
# 在第一个全连接层之后添加一个dropout层
nn.Dropout(dropout1),
nn.Linear(256, 256),
nn.ReLU(),
# 在第二个全连接层之后添加一个dropout层
nn.Dropout(dropout2),
nn.Linear(256, 10))
def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights)
#训练
num_epochs, lr, batch_size = 10, 0.5, 256
loss = nn.CrossEntropyLoss()
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
trainer = torch.optim.SGD(net.parameters(), lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
PS: 本文整理自沐神的 动手学深度学习,外加自己的补充。
参考链接: