神经网络如何工作的?

本文是Kaggle上的notebook:《How does a neural net really work?》的学习笔记

avatar_cover Alex Xiang
2024-09-07 262阅读

原文:How does a neural net really work?

神经网络就是数学意义上的函数,对于标准的神经网络,这样的数学函数做了下面的事情: - 将输入乘以某些值,这些值被称作参数 - 将这些结果加起来 - 负值用零代替,然后得到输出

上面就是神经网络一个“层(layer)”所作的事情,然后上面三件事情会不断的重复,使用上一层的输出作为下一层的输入。通常参数一开始都是随机数,一个初始的神经网络肯定是干不了任何事情的,因为它就是随机的。

为了让这个数学函数能学到东西,我们需要让参数更好一些。为此我们会用到“梯度下降”。来看一下这是怎么做的:

from ipywidgets import interact
from fastai.basics import *

plt.rc('figure', dpi=90)

def plot_function(f, title=None, min=-2.1, max=2.1, color='r', ylim=None):
    x = torch.linspace(min,max, 100)[:,None]
    if ylim: plt.ylim(ylim)
    plt.plot(x, f(x), color)
    if title is not None: plt.title(title)

def f(x): return 3*x**2 + 2*x + 1

plot_function(f, "$3x^2 + 2x + 1$")

这个函数是二次函数,接下来定义了一个通用的二次函数quad,带有a、b、c三个参数,通过python的partial函数又定义了一个mk_quad函数,这个函数能够特例化a、b、c三个参数,使得只需要传一个x参数,具体代码如下:

def quad(a, b, c, x): return a*x**2 + b*x + c

def mk_quad(a,b,c): return partial(quad, a,b,c)

f2 = mk_quad(3,2,1)
plot_function(f2)

接下来生成一组模拟数据,为数据增加噪音,从add_noise函数看x和y之间应该是二次关系,不过通过随机数增加了噪音。后面会想办法从这组数据推断出二次函数的参数。数据生成的代码如下:

def noise(x, scale): return np.random.normal(scale=scale, size=x.shape)

def add_noise(x, mult, add): return x * (1+noise(x,mult)) + noise(x,add)

np.random.seed(42)

x = torch.linspace(-2, 2, steps=20)[:,None]
y = add_noise(f(x), 0.15, 1.5)

x[:5],y[:5]

output:
(tensor([[-2.0000],
         [-1.7895],
         [-1.5789],
         [-1.3684],
         [-1.1579]]),
 tensor([[11.8690],
         [ 6.5433],
         [ 5.9396],
         [ 2.6304],
         [ 1.7947]], dtype=torch.float64))

数据的分布如下图,粗看确实是符合二次曲线的特征,后面会推断出二次曲线的a、b、c参数的值。

一种方式是尝试给几组a、b、c的值,看看结果是否符合要求,对于这个例子比较简单,在notebook中可以交互式的修改a、b、c的值来观察曲线与数据的吻合度。

可以多尝试几次修改a、b、c的值,观察一下每个参数对图像的影响,与二次图像类似,控制抛物线的开口大小与方向,b控制抛物线的倾斜程度,c控制抛物线的上下位置。

光凭感觉并不能确定哪个结果是最优的,所以需要一个评价指标,可以用每个点到曲线的距离来作为评价方式:

def mae(preds, acts): return (torch.abs(preds-acts)).mean()

@interact(a=1.1, b=1.1, c=1.1)
def plot_quad(a, b, c):
    f = mk_quad(a,b,c)
    plt.scatter(x,y)
    loss = mae(f(x), y)
    plot_function(f, ylim=(-3,12), title=f"MAE: {loss:.2f}")

上面的代码更新了之前的交互式显示抛物线的代码,在图像顶部显示当前的MAE(mean absolute error )值。

现代的神经网络经常会有数千万的参数,肯定不可能像上面那样手工调整滑动条来观察结果,我们需要将这个过程自动化。幸运的是,微积分可以帮我们实现这点。

自动梯度下降

基本思想是这样的:如果我们知道mae函数相对于参数a、b和c的梯度,那么这意味着我们知道调整a将如何改变mae的值。如果a具有负梯度,那么我们知道增加a会减少mae。然后我们知道这就是我们需要做的,因为我们试图使mae尽可能低。

所以我们需要找到mae对于所有参数的梯度,然后忘正向小幅调整梯度。

def quad_mae(params):
    f = mk_quad(*params)
    return mae(f(x), y)

quad_mae([1.1, 1.1, 1.1])

# tensor(2.4219, dtype=torch.float64)

这个输出之前直接调用mae函数是一样的。接下来我们

abc = torch.tensor([1.1,1.1,1.1])

abc.requires_grad_()  # 告诉PyTorch需要计算这些参数的梯度
# tensor([1.1000, 1.1000, 1.1000], requires_grad=True)

loss = quad_mae(abc)
loss
# tensor(2.4219, dtype=torch.float64, grad_fn=<MeanBackward0>)

loss.backward()  # PyTorch计算梯度

abc.grad
# tensor([-1.3529, -0.0316, -0.5000])

从梯度的值来看(负数),我们参数都比预期的小,为此可以将参数减去梯度与一个小数值的乘积,这样可以小幅改善结果:

with torch.no_grad():
    abc -= abc.grad*0.01
    loss = quad_mae(abc)

print(f'loss={loss:.2f}')
# loss=2.40

可以看到,loss的值变小了!这里,所乘的小数值被称为学习率,在训练的时候是一个非常重要的超参数。我们的计算放在with torch.no_grad()里面,这会告诉pytorch不去做自动梯度的计算,这是因为abc -= abc.grad0.01这一步并不是二次函数的模型的一部分,不希望计算结果影响到模型。

上面的步骤我们可以用循环重复多次:

for i in range(10):
    loss = quad_mae(abc)
    loss.backward()
    with torch.no_grad(): abc -= abc.grad*0.01
    print(f'step={i}; loss={loss:.2f}')

output:
step=0; loss=2.40
step=1; loss=2.36
step=2; loss=2.30
step=3; loss=2.21
step=4; loss=2.11
step=5; loss=1.98
step=6; loss=1.85
step=7; loss=1.72
step=8; loss=1.58
step=9; loss=1.46

可以看到loss在不断变小。如果再增加循环次数,loss还会继续变小,直到逼近0。但是如果继续循环,loss有可能又会短暂增加,这是因为已经在远离最优解。为了避免这种情况,可以在这种情况下减小学习率,大部分的框架都会自动处理学习率,例如fastai和PyTorch。

神经网络能做到的远超上面这个二次函数的例子,实际上神经网络有几乎无限的表现力,能模拟足够复杂的可计算函数。可计算函数可以覆盖你能想象到的很多场景:语音识别、绘画、医疗影像诊断、创作小说,等等。相比前面简单的例子,更复杂的神经网络其实就是有大量的参数,实际要做的也无非两件事情:

  • 大量参数的乘法计算,并将结果加起来
  • 应用函数𝑚𝑎𝑥(𝑥,0),将负值转换成0

在PyTorch,max(x, 0)可以用torch.clip(x, 0.)来实现,实际上也可以用F.relu(x),在PyTorch中这个函数与max(x, 0)等价。F即torch.nn.functional模块。

在原文的notebook中,我们可以通过交互式的调整m和b的值来看一下函数的曲线:

对应的代码:

def rectified_linear(m,b,x):
    y = m*x+b
    return torch.clip(y, 0.)

import torch.nn.functional as F
def rectified_linear2(m,b,x): return F.relu(m*x+b)
plot_function(partial(rectified_linear2, 1,1))

@interact(m=1.5, b=1.5)
def plot_relu(m, b):
    plot_function(partial(rectified_linear, m,b), ylim=(-1,4))

可以看到m控制斜率,b控制“钩子”的显示。最后再看一个例子,可以通过四个参数的互动来观察两个类似函数的叠加的结果。理论上我们可以通过叠加更多的函数来模拟单输入得到足够精度的输出。

Filter blog posts by tag 机器学习 神经网络 kaggle