OwnFramework

多层感知器#

构建我们自己的神经框架#

本笔记本是 AI for Beginners Curricula 的一部分。请访问该仓库获取完整的学习资料。

在本笔记本中,我们将逐步构建一个能够解决多类别分类任务以及回归任务的多层感知器神经框架。

首先,让我们导入一些必要的库。

In [14]:
%matplotlib nbagg
import matplotlib.pyplot as plt 
from matplotlib import gridspec
from sklearn.datasets import make_classification
import numpy as np
# pick the seed for reproducibility - change it to explore the effects of random variations
np.random.seed(0)
import random

示例数据集#

和之前一样,我们将从一个包含两个参数的简单示例数据集开始。

In [15]:
n = 100
X, Y = make_classification(n_samples = n, n_features=2,
                           n_redundant=0, n_informative=2, flip_y=0.2)
X = X.astype(np.float32)
Y = Y.astype(np.int32)

# Split into train and test dataset
train_x, test_x = np.split(X, [n*8//10])
train_labels, test_labels = np.split(Y, [n*8//10])
In [16]:
def plot_dataset(suptitle, features, labels):
    # prepare the plot
    fig, ax = plt.subplots(1, 1)
    #pylab.subplots_adjust(bottom=0.2, wspace=0.4)
    fig.suptitle(suptitle, fontsize = 16)
    ax.set_xlabel('$x_i[0]$ -- (feature 1)')
    ax.set_ylabel('$x_i[1]$ -- (feature 2)')

    colors = ['r' if l else 'b' for l in labels]
    ax.scatter(features[:, 0], features[:, 1], marker='o', c=colors, s=100, alpha = 0.5)
    fig.show()
In [17]:
plot_dataset('Scatterplot of the training data', train_x, train_labels)
plt.show()
<IPython.core.display.Javascript object>
In [18]:
print(train_x[:5])
print(train_labels[:5])
[[ 1.3382818  -0.98613256]
 [ 0.5128146   0.43299454]
 [-0.4473693  -0.2680512 ]
 [-0.9865851  -0.28692   ]
 [-1.0693829   0.41718036]]
[1 1 0 0 0]

机器学习问题#

假设我们有输入数据集 $\langle X,Y\rangle$,其中 $X$ 是一组特征,$Y$ 是对应的标签。对于回归问题,$y_i\in\mathbb{R}$;对于分类问题,$y_i$ 表示类别编号,$y_i\in{0,\dots,n}$。

任何机器学习模型都可以表示为函数 $f_\theta(x)$,其中 $\theta$ 是一组参数。我们的目标是找到这样的参数 $\theta$,使得模型能够最好地拟合数据集。评估标准由损失函数 $\mathcal{L}$ 定义,我们需要找到最优值

$$ \theta = \mathrm{argmin}\theta \mathcal{L}(f\theta(X),Y) $$

损失函数取决于所解决的问题。

回归的损失函数#

对于回归问题,我们通常使用绝对误差 $\mathcal{L}{abs}(\theta) = \sum{i=1}^n |y_i - f_{\theta}(x_i)|$,或者均方误差:$\mathcal{L}{sq}(\theta) = \sum{i=1}^n (y_i - f_{\theta}(x_i))^2$

In [19]:
# helper function for plotting various loss functions
def plot_loss_functions(suptitle, functions, ylabels, xlabel):
    fig, ax = plt.subplots(1,len(functions), figsize=(9, 3))
    plt.subplots_adjust(bottom=0.2, wspace=0.4)
    fig.suptitle(suptitle)
    for i, fun in enumerate(functions):
        ax[i].set_xlabel(xlabel)
        if len(ylabels) > i:
            ax[i].set_ylabel(ylabels[i])
        ax[i].plot(x, fun)
    plt.show()
In [20]:
x = np.linspace(-2, 2, 101)
plot_loss_functions(
    suptitle = 'Common loss functions for regression',
    functions = [np.abs(x), np.power(x, 2)],
    ylabels   = ['$\mathcal{L}_{abs}}$ (absolute loss)',
                 '$\mathcal{L}_{sq}$ (squared loss)'],
    xlabel    = '$y - f(x_i)$')
<IPython.core.display.Javascript object>

分类的损失函数#

我们先来考虑一下二分类问题。在这种情况下,我们有两个类别,编号为0和1。网络的输出 $f_\theta(x_i)\in [0,1]$ 本质上定义了选择类别1的概率。

0-1损失

0-1损失与计算模型的准确率是一样的——我们统计正确分类的数量:

$$\mathcal{L}{0-1} = \sum{i=1}^n l_i \quad l_i = \begin{cases} 0 & (f(x_i)<0.5 \land y_i=0) \lor (f(x_i)<0.5 \land y_i=1) \ 1 & \mathrm{ otherwise} \end{cases} \ $$

然而,准确率本身并不能反映我们距离正确分类有多远。可能我们只是稍微偏离了正确类别一点点,这在某种意义上来说是“更好”的(因为我们需要调整权重的幅度会小得多),相比于完全偏离正确类别。因此,更常用的是逻辑损失,它能够考虑到这一点。

逻辑损失

$$\mathcal{L}{log} = \sum{i=1}^n -y\log(f_{\theta}(x_i)) - (1-y)\log(1-f_\theta(x_i))$$

In [21]:
x = np.linspace(0,1,100)
def zero_one(d):
    if d < 0.5:
        return 0
    return 1
zero_one_v = np.vectorize(zero_one)

def logistic_loss(fx):
    # assumes y == 1
    return -np.log(fx)
In [22]:
plot_loss_functions(suptitle = 'Common loss functions for classification (class=1)',
                   functions = [zero_one_v(x), logistic_loss(x)],
                   ylabels    = ['$\mathcal{L}_{0-1}}$ (0-1 loss)',
                                 '$\mathcal{L}_{log}$ (logistic loss)'],
                   xlabel     = '$p$')
C:\Users\dmitryso\AppData\Local\Temp/ipykernel_55820/331859503.py:10: RuntimeWarning: divide by zero encountered in log
  return -np.log(fx)
<IPython.core.display.Javascript object>

要理解逻辑损失,可以考虑两种预期输出的情况:

  • 如果我们期望输出为1($y=1$),那么损失是$-log f_\theta(x_i)$。当网络以概率1预测1时,损失为0;而当预测1的概率变小时,损失会变大。
  • 如果我们期望输出为0($y=0$),损失是$-log(1-f_\theta(x_i))$。这里,$1-f_\theta(x_i)$是网络预测为0的概率,逻辑损失的含义与前一种情况中描述的相同。

神经网络架构#

我们已经生成了一个用于二分类问题的数据集。然而,从一开始我们就将其视为多分类问题,这样我们可以轻松地将代码切换到多分类。在这种情况下,我们的单层感知机将具有以下架构:

网络的两个输出对应于两个类别,两个输出中值最大的类别即为正确的解决方案。

模型定义为 $$ f_\theta(x) = W\times x + b $$ 其中 $$\theta = \langle W,b\rangle$$ 是参数。

我们将把这个线性层定义为一个 Python 类,其中包含一个 forward 函数来执行计算。它接收输入值 $x$,并生成该层的输出。参数 Wb 存储在层类中,并在创建时分别用随机值和零值初始化。

In [23]:
class Linear:
    def __init__(self,nin,nout):
        self.W = np.random.normal(0, 1.0/np.sqrt(nin), (nout, nin))
        self.b = np.zeros((1,nout))
        
    def forward(self, x):
        return np.dot(x, self.W.T) + self.b
    
net = Linear(2,2)
net.forward(train_x[0:5])
array([[ 1.77202116, -0.25384488],
       [ 0.28370828, -0.39610552],
       [-0.30097433,  0.30513182],
       [-0.8120485 ,  0.56079421],
       [-1.23519653,  0.3394973 ]])

在许多情况下,与其对单个输入值进行操作,不如对一组输入值(向量)进行操作更高效。由于我们使用的是 Numpy 操作,因此可以将一组输入值传递给我们的网络,它会返回一组输出值。

Softmax:将输出转换为概率#

如你所见,我们的输出并不是概率——它们可以是任意值。为了将它们转换为概率,我们需要对所有类别的值进行归一化。这可以通过 softmax 函数实现:
$$\sigma(\mathbf{z}c) = \frac{e^{z_c}}{\sum{j} e^{z_j}}, \quad\mathrm{for}\quad c\in 1 .. |C|$$

网络的输出 $\sigma(\mathbf{z})$ 可以被解释为类别集合 $C$ 上的概率分布:$q = \sigma(\mathbf{z}_c) = \hat{p}(c | x)$

我们将以相同的方式定义 Softmax 层,作为一个包含 forward 函数的类:

In [24]:
class Softmax:
    def forward(self,z):
        zmax = z.max(axis=1,keepdims=True)
        expz = np.exp(z-zmax)
        Z = expz.sum(axis=1,keepdims=True)
        return expz / Z

softmax = Softmax()
softmax.forward(net.forward(train_x[0:10]))
array([[0.88348621, 0.11651379],
       [0.66369714, 0.33630286],
       [0.35294795, 0.64705205],
       [0.20216095, 0.79783905],
       [0.17154828, 0.82845172],
       [0.24279153, 0.75720847],
       [0.18915732, 0.81084268],
       [0.17282951, 0.82717049],
       [0.13897531, 0.86102469],
       [0.72746882, 0.27253118]])

现在我们得到的输出是概率,也就是说,每个输出向量的总和正好是1。

如果我们有超过两个类别,softmax会对所有类别的概率进行归一化。以下是一个用于MNIST数字分类的网络架构图:

交叉熵损失#

分类问题中的损失函数通常是一个逻辑函数,可以被概括为交叉熵损失。交叉熵损失是一种可以计算两个任意概率分布之间相似性的函数。关于它的更详细讨论可以参考维基百科

在我们的场景中,第一个分布是网络的概率输出,第二个分布是所谓的独热分布,它指定某个类别 $c$ 的概率为 1(其他类别的概率均为 0)。在这种情况下,交叉熵损失可以表示为 $-\log p_c$,其中 $c$ 是期望的类别,$p_c$ 是神经网络给出的该类别的概率。

如果网络为期望类别返回概率 1,则交叉熵损失为 0。实际类别的概率越接近 0,交叉熵损失就越高(并且可以无限增大!)。

In [25]:
def plot_cross_ent():
    p = np.linspace(0.01, 0.99, 101) # estimated probability p(y|x)
    cross_ent_v = np.vectorize(cross_ent)
    f3, ax = plt.subplots(1,1, figsize=(8, 3))
    l1, = plt.plot(p, cross_ent_v(p, 1), 'r--')
    l2, = plt.plot(p, cross_ent_v(p, 0), 'r-')
    plt.legend([l1, l2], ['$y = 1$', '$y = 0$'], loc = 'upper center', ncol = 2)
    plt.xlabel('$\hat{p}(y|x)$', size=18)
    plt.ylabel('$\mathcal{L}_{CE}$', size=18)
    plt.show()
In [26]:
def cross_ent(prediction, ground_truth):
    t = 1 if ground_truth > 0.5 else 0
    return -t * np.log(prediction) - (1 - t) * np.log(1 - prediction)
plot_cross_ent()
<IPython.core.display.Javascript object>

交叉熵损失将再次定义为一个单独的层,但forward函数将有两个输入值:网络前几层的输出p,以及期望的类别y

In [27]:
class CrossEntropyLoss:
    def forward(self,p,y):
        self.p = p
        self.y = y
        p_of_y = p[np.arange(len(y)), y]
        log_prob = np.log(p_of_y)
        return -log_prob.mean() # average over all input samples

cross_ent_loss = CrossEntropyLoss()
p = softmax.forward(net.forward(train_x[0:10]))
cross_ent_loss.forward(p,train_labels[0:10])
1.429664938969559

到目前为止,我们已经为网络的不同层定义了不同的类。这些层的组合可以表示为计算图。现在,我们可以通过以下方式计算给定训练数据集(或其一部分)的损失:

In [28]:
z = net.forward(train_x[0:10])
p = softmax.forward(z)
loss = cross_ent_loss.forward(p,train_labels[0:10])
print(loss)
1.429664938969559

损失最小化问题与网络训练#

一旦我们定义了网络 $f_\theta$,并给定了损失函数 $\mathcal{L}(Y,f_\theta(X))$,我们可以将 $\mathcal{L}$ 视为在固定训练数据集下关于 $\theta$ 的函数:$\mathcal{L}(\theta) = \mathcal{L}(Y,f_\theta(X))$

在这种情况下,网络训练就变成了一个关于参数 $\theta$ 的损失函数 $\mathcal{L}$ 的最小化问题: $$ \theta = \mathrm{argmin}{\theta} \mathcal{L}(Y,f\theta(X)) $$

有一种著名的函数优化方法叫做梯度下降。其核心思想是,我们可以计算损失函数关于参数的导数(在多维情况下称为梯度),并通过调整参数使得误差逐步减小。

梯度下降的工作原理如下:

  • 用一些随机值初始化参数 $w^{(0)}$, $b^{(0)}$
  • 重复以下步骤多次:

$$\begin{align} W^{(i+1)}&=W^{(i)}-\eta\frac{\partial\mathcal{L}}{\partial W}\ b^{(i+1)}&=b^{(i)}-\eta\frac{\partial\mathcal{L}}{\partial b} \end{align} $$

在训练过程中,优化步骤通常是基于整个数据集计算的(记住,损失是通过所有训练样本的总和或平均值计算的)。然而,在实际操作中,我们会取数据集的一小部分,称为小批量(minibatch),并基于这个数据子集计算梯度。由于每次取的子集是随机的,这种方法被称为随机梯度下降(stochastic gradient descent, SGD)

反向传播#

$$\def\L{\mathcal{L}}\def\zz#1#2{\frac{\partial#1}{\partial#2}} \begin{align} \zz{\L}{W} =& \zz{\L}{p}\zz{p}{z}\zz{z}{W}\cr \zz{\L}{b} =& \zz{\L}{p}\zz{p}{z}\zz{z}{b} \end{align} $$

这个过程从网络的输出开始,将损失误差逐步传递回网络的参数。因此,这个过程被称为反向传播

网络训练的一个完整过程包括两个部分:

  • 前向传播,在给定输入小批量数据时计算损失函数的值
  • 反向传播,通过计算图将误差分配回模型参数,尝试最小化该误差。

反向传播的实现#

  • 我们需要为每个节点添加一个 backward 函数,用于在反向传播过程中计算导数并传播误差。
  • 我们还需要根据上述步骤实现参数更新。

我们需要手动为每一层计算导数,例如对于线性层 $z = x\times W+b$: $$\begin{align} \frac{\partial z}{\partial W} &= x \ \frac{\partial z}{\partial b} &= 1 \ \end{align}$$

如果需要补偿层输出的误差 $\Delta z$,我们需要相应地更新权重: $$\begin{align} \Delta x &= \Delta z \times W \ \Delta W &= \frac{\partial z}{\partial W} \Delta z = \Delta z \times x \ \Delta b &= \frac{\partial z}{\partial b} \Delta z = \Delta z \ \end{align}$$

重要提示: 计算并不是针对每个训练样本独立进行的,而是针对整个**小批量(minibatch)**进行的。所需的参数更新 $\Delta W$ 和 $\Delta b$ 是在整个小批量上计算的,相应的向量维度为:$x\in\mathbb{R}^{\mathrm{minibatch}, \times, \mathrm{nclass}}$

In [29]:
class Linear:
    def __init__(self,nin,nout):
        self.W = np.random.normal(0, 1.0/np.sqrt(nin), (nout, nin))
        self.b = np.zeros((1,nout))
        self.dW = np.zeros_like(self.W)
        self.db = np.zeros_like(self.b)
        
    def forward(self, x):
        self.x=x
        return np.dot(x, self.W.T) + self.b
    
    def backward(self, dz):
        dx = np.dot(dz, self.W)
        dW = np.dot(dz.T, self.x)
        db = dz.sum(axis=0)
        self.dW = dW
        self.db = db
        return dx
    
    def update(self,lr):
        self.W -= lr*self.dW
        self.b -= lr*self.db

以同样的方式,我们可以为其余的层定义 backward 函数:

In [30]:
class Softmax:
    def forward(self,z):
        self.z = z
        zmax = z.max(axis=1,keepdims=True)
        expz = np.exp(z-zmax)
        Z = expz.sum(axis=1,keepdims=True)
        return expz / Z
    def backward(self,dp):
        p = self.forward(self.z)
        pdp = p * dp
        return pdp - p * pdp.sum(axis=1, keepdims=True)
    
class CrossEntropyLoss:
    def forward(self,p,y):
        self.p = p
        self.y = y
        p_of_y = p[np.arange(len(y)), y]
        log_prob = np.log(p_of_y)
        return -log_prob.mean()
    def backward(self,loss):
        dlog_softmax = np.zeros_like(self.p)
        dlog_softmax[np.arange(len(self.y)), self.y] -= 1.0/len(self.y)
        return dlog_softmax / self.p

训练模型#

现在我们准备编写训练循环,它将遍历我们的数据集,并逐个小批量地执行优化。完整地遍历整个数据集通常被称为一个周期

In [31]:
lin = Linear(2,2)
softmax = Softmax()
cross_ent_loss = CrossEntropyLoss()

learning_rate = 0.1

pred = np.argmax(lin.forward(train_x),axis=1)
acc = (pred==train_labels).mean()
print("Initial accuracy: ",acc)

batch_size=4
for i in range(0,len(train_x),batch_size):
    xb = train_x[i:i+batch_size]
    yb = train_labels[i:i+batch_size]
    
    # forward pass
    z = lin.forward(xb)
    p = softmax.forward(z)
    loss = cross_ent_loss.forward(p,yb)
    
    # backward pass
    dp = cross_ent_loss.backward(loss)
    dz = softmax.backward(dp)
    dx = lin.backward(dz)
    lin.update(learning_rate)
    
pred = np.argmax(lin.forward(train_x),axis=1)
acc = (pred==train_labels).mean()
print("Final accuracy: ",acc)
    
Initial accuracy:  0.725
Final accuracy:  0.825

很高兴看到我们可以在一个训练周期内将模型的准确率从大约50%提高到接近80%。

网络类#

由于在许多情况下,神经网络只是由多个层组成的,我们可以构建一个类,允许我们将这些层堆叠在一起,并通过它们进行前向和后向传播,而无需显式编写这些逻辑。我们将在Net类中存储层的列表,并使用add()函数添加新层:

In [32]:
class Net:
    def __init__(self):
        self.layers = []
    
    def add(self,l):
        self.layers.append(l)
        
    def forward(self,x):
        for l in self.layers:
            x = l.forward(x)
        return x
    
    def backward(self,z):
        for l in self.layers[::-1]:
            z = l.backward(z)
        return z
    
    def update(self,lr):
        for l in self.layers:
            if 'update' in l.__dir__():
                l.update(lr)

通过这个 Net 类,我们的模型定义和训练变得更加简洁:

In [33]:
net = Net()
net.add(Linear(2,2))
net.add(Softmax())
loss = CrossEntropyLoss()

def get_loss_acc(x,y,loss=CrossEntropyLoss()):
    p = net.forward(x)
    l = loss.forward(p,y)
    pred = np.argmax(p,axis=1)
    acc = (pred==y).mean()
    return l,acc

print("Initial loss={}, accuracy={}: ".format(*get_loss_acc(train_x,train_labels)))

def train_epoch(net, train_x, train_labels, loss=CrossEntropyLoss(), batch_size=4, lr=0.1):
    for i in range(0,len(train_x),batch_size):
        xb = train_x[i:i+batch_size]
        yb = train_labels[i:i+batch_size]

        p = net.forward(xb)
        l = loss.forward(p,yb)
        dp = loss.backward(l)
        dx = net.backward(dp)
        net.update(lr)
 
train_epoch(net,train_x,train_labels)
        
print("Final loss={}, accuracy={}: ".format(*get_loss_acc(train_x,train_labels)))
print("Test loss={}, accuracy={}: ".format(*get_loss_acc(test_x,test_labels)))
Initial loss=0.6212072429381601, accuracy=0.6875: 
Final loss=0.44369925927417986, accuracy=0.8: 
Test loss=0.4767711377257787, accuracy=0.85: 

绘制训练过程#

能够直观地看到网络的训练过程会非常棒!我们将定义一个 train_and_plot 函数来实现这一点。为了可视化网络的状态,我们将使用等级图,也就是说,我们会用不同的颜色来表示网络输出的不同值。

如果你不完全理解下面的一些绘图代码,不用担心——更重要的是理解底层的神经网络概念。

In [34]:
def train_and_plot(n_epoch, net, loss=CrossEntropyLoss(), batch_size=4, lr=0.1):
    fig, ax = plt.subplots(2, 1)
    ax[0].set_xlim(0, n_epoch + 1)
    ax[0].set_ylim(0,1)

    train_acc = np.empty((n_epoch, 3))
    train_acc[:] = np.NAN
    valid_acc = np.empty((n_epoch, 3))
    valid_acc[:] = np.NAN

    for epoch in range(1, n_epoch + 1):

        train_epoch(net,train_x,train_labels,loss,batch_size,lr)
        tloss, taccuracy = get_loss_acc(train_x,train_labels,loss)
        train_acc[epoch-1, :] = [epoch, tloss, taccuracy]
        vloss, vaccuracy = get_loss_acc(test_x,test_labels,loss)
        valid_acc[epoch-1, :] = [epoch, vloss, vaccuracy]
        
        ax[0].set_ylim(0, max(max(train_acc[:, 2]), max(valid_acc[:, 2])) * 1.1)

        plot_training_progress(train_acc[:, 0], (train_acc[:, 2],
                                                 valid_acc[:, 2]), fig, ax[0])
        plot_decision_boundary(net, fig, ax[1])
        fig.canvas.draw()
        fig.canvas.flush_events()

    return train_acc, valid_acc
In [35]:
import matplotlib.cm as cm

def plot_decision_boundary(net, fig, ax):
    draw_colorbar = True
    # remove previous plot
    while ax.collections:
        ax.collections.pop()
        draw_colorbar = False

    # generate countour grid
    x_min, x_max = train_x[:, 0].min() - 1, train_x[:, 0].max() + 1
    y_min, y_max = train_x[:, 1].min() - 1, train_x[:, 1].max() + 1
    xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.1),
                         np.arange(y_min, y_max, 0.1))
    grid_points = np.c_[xx.ravel().astype('float32'), yy.ravel().astype('float32')]
    n_classes = max(train_labels)+1
    while train_x.shape[1] > grid_points.shape[1]:
        # pad dimensions (plot only the first two)
        grid_points = np.c_[grid_points,
                            np.empty(len(xx.ravel())).astype('float32')]
        grid_points[:, -1].fill(train_x[:, grid_points.shape[1]-1].mean())

    # evaluate predictions
    prediction = np.array(net.forward(grid_points))
    # for two classes: prediction difference
    if (n_classes == 2):
        Z = np.array([0.5+(p[0]-p[1])/2.0 for p in prediction]).reshape(xx.shape)
    else:
        Z = np.array([p.argsort()[-1]/float(n_classes-1) for p in prediction]).reshape(xx.shape)
    
    # draw contour
    levels = np.linspace(0, 1, 40)
    cs = ax.contourf(xx, yy, Z, alpha=0.4, levels = levels)
    if draw_colorbar:
        fig.colorbar(cs, ax=ax, ticks = [0, 0.5, 1])
    c_map = [cm.jet(x) for x in np.linspace(0.0, 1.0, n_classes) ]
    colors = [c_map[l] for l in train_labels]
    ax.scatter(train_x[:, 0], train_x[:, 1], marker='o', c=colors, s=60, alpha = 0.5)
In [36]:
def plot_training_progress(x, y_data, fig, ax):
    styles = ['k--', 'g-']
    # remove previous plot
    while ax.lines:
        ax.lines.pop()
    # draw updated lines
    for i in range(len(y_data)):
        ax.plot(x, y_data[i], styles[i])
    ax.legend(ax.lines, ['training accuracy', 'validation accuracy'],
              loc='upper center', ncol = 2)
In [37]:
%matplotlib nbagg 
net = Net()
net.add(Linear(2,2))
net.add(Softmax())

res = train_and_plot(30,net,lr=0.005)
<IPython.core.display.Javascript object>

运行上面的单元格后,你应该能够直观地看到训练过程中类别边界是如何变化的。请注意,我们选择了非常小的学习率,这样可以清楚地观察到整个过程是如何发生的。

多层模型#

上面的网络由多个层构成,但我们实际上只使用了一个 Linear 层来完成分类任务。如果我们决定添加多个这样的层,会发生什么呢?

令人惊讶的是,我们的代码仍然可以正常运行!不过,有一点非常重要需要注意:在线性层之间,我们需要加入一个非线性的激活函数,比如 tanh。如果没有这种非线性,多个线性层的表达能力实际上和单个线性层是一样的——因为线性函数的组合仍然是线性函数!

In [38]:
class Tanh:
    def forward(self,x):
        y = np.tanh(x)
        self.y = y
        return y
    def backward(self,dy):
        return (1.0-self.y**2)*dy

添加多个层是有意义的,因为与单层网络不同,多层模型能够准确分类那些非线性可分的集合。换句话说,拥有多个层的模型会更加强大

可以证明,具有足够数量神经元的两层模型能够分类任何凸数据点集合,而三层网络几乎可以分类任何集合。

从数学上看,多层感知机可以表示为一个更复杂的函数 $f_\theta$,它可以通过以下几个步骤计算:

  • $z_1 = W_1\times x+b_1$
  • $z_2 = W_2\times\alpha(z_1)+b_2$
  • $f = \sigma(z_2)$

这里,$\alpha$ 是一个非线性激活函数,$\sigma$ 是一个 softmax 函数,而 $\theta=\langle W_1,b_1,W_2,b_2\rangle$ 是参数。

梯度下降算法仍然保持不变,但计算梯度会更加复杂。根据链式求导规则,我们可以计算导数如下:

$$\begin{align} \frac{\partial\mathcal{L}}{\partial W_2} &= \color{red}{\frac{\partial\mathcal{L}}{\partial\sigma}\frac{\partial\sigma}{\partial z_2}}\color{black}{\frac{\partial z_2}{\partial W_2}} \ \frac{\partial\mathcal{L}}{\partial W_1} &= \color{red}{\frac{\partial\mathcal{L}}{\partial\sigma}\frac{\partial\sigma}{\partial z_2}}\color{black}{\frac{\partial z_2}{\partial\alpha}\frac{\partial\alpha}{\partial z_1}\frac{\partial z_1}{\partial W_1}} \end{align} $$

注意,这些表达式的开头部分仍然是相同的,因此我们可以继续在计算图中向后传播,超越单线性层以调整更深层的权重。

现在让我们尝试使用两层网络:

In [39]:
net = Net()
net.add(Linear(2,10))
net.add(Tanh())
net.add(Linear(10,2))
net.add(Softmax())
loss = CrossEntropyLoss()
In [40]:
res = train_and_plot(30,net,lr=0.01)
<IPython.core.display.Javascript object>

为什么不总是使用多层模型?#

我们已经看到,多层模型比单层模型更强大、更具有表达力。你可能会想,为什么我们不总是使用多层模型呢?答案是过拟合

我们将在后面的章节中更详细地讨论这个术语,但其核心思想是:模型越强大,它越能很好地拟合训练数据,同时也需要更多的数据来正确地对未见过的新数据进行泛化

线性模型:

  • 训练损失可能会很高——这被称为欠拟合,即模型的能力不足以正确区分所有数据。
  • 验证损失和训练损失大致相同。模型通常能够很好地泛化到测试数据。

复杂的多层模型:

  • 训练损失很低——模型能够很好地拟合训练数据,因为它具有足够的表达能力。
  • 验证损失可能远高于训练损失,并且在训练过程中可能开始增加——这是因为模型“记住”了训练数据点,而忽略了数据的“整体规律”。

过拟合

在这张图中,x 代表训练数据,o 代表验证数据。左图是线性模型(单层),它很好地近似了数据的本质。右图是过拟合模型,它完美地拟合了训练数据,但对其他数据(验证数据)的表现却很差(验证误差非常高)。

要点总结#

  • 简单的模型(较少的层数和神经元,参数较少,“低容量”)不太可能过拟合。
  • 更复杂的模型(更多的层数,每层有更多的神经元,高容量)更容易过拟合。我们需要监控验证误差,确保在进一步训练时它不会开始上升。
  • 更复杂的模型需要更多的数据进行训练。
  • 你可以通过以下方法解决过拟合问题:
    • 简化你的模型
    • 增加训练数据量
  • 偏差-方差权衡 是一个术语,表示你需要在以下方面找到平衡:
    • 模型的能力与数据量之间,
    • 过拟合与欠拟合之间。
  • 没有一个固定的公式来决定你需要多少层或参数——最好的方法是进行实验。

致谢#

本笔记本是 AI for Beginners Curricula 的一部分,由 Dmitry Soshnikov 编写。灵感来源于微软剑桥研究院的神经网络工作坊。一些代码和示例材料取自 Katja HoffmannMatthew JohnsonRyoto Tomioka 的演示文稿,以及 NeuroWorkshop 仓库。


免责声明
本文档使用AI翻译服务Co-op Translator进行翻译。尽管我们努力确保翻译的准确性,但请注意,自动翻译可能包含错误或不准确之处。应以原始语言的文档作为权威来源。对于关键信息,建议使用专业人工翻译。我们对因使用此翻译而引起的任何误解或误读不承担责任。