基于可视化重学二分类模型

1. 引出分类问题

最简单的分类问题大概是这样:假设有两类点,红色和蓝色,目标是找到一个曲线,来将红色和蓝色两类点分开。

为了求解,需要把它转换为数学问题,我们可以这样定义:红色赋0,蓝色赋1,与0对应的是负类,与1对应的是正类。如下表所示:

颜色数值分类
红色0
蓝色1

注:在分类模型中,始终预测的是正类(也就是蓝色)的概率,并且对于任何一个点,是正类和负类的概率之和始终为1。

现在问题变成:如何找到一个模型,能够对输入点进行运算后输出的结果是0或者1。

要求解这个问题,和之前的线性模型一样,首先要准备数据集和推导模型定义。

2. 数据准备

2.1 数据生成

这次生成的数据会增加一点复杂性,采用两个特征(x1和x2)。为了贴合实际场景,我们不再基于模型来生成数据,而是先生成数据,再来推导模型。

# make_moons用于生成二维非线性数据集
from sklearn.datasets import make_moons

x, y = make_moons(n_samples=100, noise=0.3, random_state=0)
x.shape, y.shape, x[0], y[0]
((100, 2), (100,), array([0.03159499, 0.98698776]), 0)

上图打印结果可以看出:输入x的形状是100行2列,输出y的形状是100行1列且只有0和1两个值。

那么这个数据集长什么样子呢?

import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap

plt.scatter(x[:, 0], x[:, 1], c=y, cmap=ListedColormap(['r', 'b']))

在这里插入图片描述

这个数据集的分布形状有点像是上、下两个弯月,这大概就是make_moons名字的由来。

注:实际上这个数据集的分布可以更像月亮,根源在于上面设置了高斯噪声noise=0.3,有兴趣可以自己尝试下去掉噪声或减小噪声看看会发生什么?

2.2 拆分数据集

下面要对数据集进行拆分,这次尝试下sklearn中现成的train_test_split方法。

牢记:拆分数据集永远是生成数据后要做的第一件事,它应该放在任何的数据预处理和转换之前。

from sklearn.model_selection import train_test_split

x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2, random_state=42)

def show_datamap(x_train, x_test, y_train, y_test):
    fig, ax = plt.subplots(1, 2, figsize=(10, 5))
    colormap = ListedColormap(['r', 'b'])
    ax[0].set_title("Generated data - Train")
    ax[0].set_xlabel("$x_1$")
    ax[0].set_ylabel("$x_2$")
    # c=y_train表示使用数组y_train中的值来决定每个点的颜色
    # cmap指定了一个颜色映射,c中的每种类别所对应的颜色列表
    ax[0].scatter(x_train[:, 0], x_train[:, 1], c=y_train, cmap=colormap)

    ax[1].set_title("Generated data - Test")
    ax[1].set_xlabel("$x_1$")
    ax[1].set_ylabel("$x_2$")
    ax[1].scatter(x_test[:, 0], x_test[:, 1], c = y_test, cmap=colormap)

    plt.show()

show_datamap(x_train, x_test, y_train, y_test)

在这里插入图片描述

2.3 标准化数据集

使用StandardScaler对数据进行标准化操作(也称为归一化),将每个特征变成均值为0标准差为1。

  • fit(): 用来计算mean(均值)和std(标准差),以便后面进行数据的标准化。
  • transform(): 根据fit()函数计算的mean和std对数据进行标准化。

注:数据标准化操作对于梯度下降法求最优解非常重要,如果少了它模型难以收敛甚至不收敛,想了解具体原因可以参考:基于numpy演练可视化梯度下降

from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaler.fit(x_train)

x_train_scaler = scaler.transform(x_train)
x_test_scaler = scaler.transform(x_test)

show_datamap(x_train_scaler, x_test_scaler, y_train, y_test)

在这里插入图片描述

注:标准化操作应该只针对训练集进行fit,否则测试集会提前暴露给模型。

2.4 封装数据迭代器

将数据封装为可以小批量迭代的数据加载器。

注:在深度学习中,32位浮点数是推荐的精度选择,与更小的浮点数相比它足够精确,与更大的64位相比来说,它在计算效率和内存占用方面都更加占优。

import torch
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(13)

x_train_tensor = torch.as_tensor(x_train_scaler).float()
x_test_tensor = torch.as_tensor(x_test_scaler).float()
y_train_tensor = torch.as_tensor(y_train.reshape(-1, 1)).float()
y_test_tensor = torch.as_tensor(y_test.reshape(-1, 1)).float()

train_dataset = TensorDataset(x_train_tensor, y_train_tensor)
test_dataset = TensorDataset(x_test_tensor, y_test_tensor)

train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=16, shuffle=True)

next(iter(train_loader))

([tensor([[-0.7130,  1.4213],
          [-0.4953, -0.1849],
          [ 0.5236, -1.1315],
          [-0.2952, -0.5533],
          [-1.5423, -1.1971],
          [-1.5658,  1.2444],
          [-0.2826, -1.5494],
          [-1.4847, -0.2943],
          [ 1.2190, -1.5087],
          [-0.3082, -0.0891],
          [-1.2452, -0.6103],
          [-0.1700,  0.1368],
          [-1.4250,  0.7557],
          [ 0.1762,  0.9766],
          [-0.7661,  0.0738],
          [-0.5832, -0.4449]]),
  tensor([[0.],
          [1.],
          [1.],
          [1.],
          [0.],
          [0.],
          [1.],
          [0.],
          [1.],
          [1.],
          [0.],
          [0.],
          [0.],
          [0.],
          [0.],
          [1.]])],

现在已经有了数据,接下来就是找一个模型,目的是将红色和蓝色两类数据分开。

3. 模型推导

3.1 从线性模型出发

线性回归是一切模型的起点,将它称为模型之母都不为过,上面两个特征的数据分类问题,用线性模型可以表示为:

y = b + w 1 x 1 + w 2 x 2 + ϵ \Large y = b + w_1x_1 + w_2x_2 + \epsilon y=b+w1x1+w2x2+ϵ

但线性回归却不能用来预测分类问题,因为它的输出值是连续的,而分类需要的输出结果是离散值,不是0就是1。那怎么办?

简单的做法就是在中间做一个转换,将连续值转换为离散值。例如将正的输出分配为1,负的输出分配为0。如下所示:

y = { 1 ,  if  b + w 1 x 1 + w 2 x 2 ≥ 0 0 ,  if  b + w 1 x 1 + w 2 x 2 < 0 \Large y = \begin{cases} 1,\ \text{if }b + w_1x_1 + w_2x_2 \ge 0 \\ 0,\ \text{if }b + w_1x_1 + w_2x_2 < 0 \end{cases} y= 1, if b+w1x1+w2x200, if b+w1x1+w2x2<0

3.2 从logit到概率

但这里有一个问题,如果一个数据点的logit等于0,它既不是正数也不是负数,恰好位于决策边界上,虽然我们可以如上面公式一样将它纳入正类,但实际上这个分配有很大的不确定性,它的实际概率应该是0.5(50%)。

y = { 1 ,  if  b + w 1 x 1 + w 2 x 2 > 0 0.5 ,  if  b + w 1 x 1 + w 2 x 2 = 0 0 ,  if  b + w 1 x 1 + w 2 x 2 < 0 \Large y = \begin{cases} 1,\ \text{if }b + w_1x_1 + w_2x_2 > 0 \\ 0.5,\ \text{if }b + w_1x_1 + w_2x_2 = 0 \\ 0,\ \text{if }b + w_1x_1 + w_2x_2 < 0 \end{cases} y= 1, if b+w1x1+w2x2>00.5, if b+w1x1+w2x2=00, if b+w1x1+w2x2<0

根据这个推理:

  • 较大的正logit值输出不一定就是1,它其实是分配一个较高的概率。
  • 较大的负logit值输出也不一定就是0,它其实是分配一个较低的概率。

由此,我们发现,这里需要的是一个能将logit值转换为概率的函数。而sigmoid函数正是能将logit值转换为概率的函数。它的计算公式如下(z可以看作线性输出的logit值):

p = σ ( z ) = 1 1 + e − z \Large p = \sigma(z) = \frac{1}{1+e^{-z}} p=σ(z)=1+ez1

注:logtis可以看作神经网络输出的未经过归一化(softmax/sigmoid)的概率,logits可以取任意实数值,正值表示趋向于分类到该类,负值表示趋向于不分类到该类。与之对应,probability则是经过softmax/sigmoid归一化到0-1之间,表示属于该类的概率。

它的模型曲线可以用matplotlib绘制出来。

import numpy as np
import matplotlib.pyplot as plt

def sigmoid(logtis):
    return 1 / (1 + np.exp(-logtis))

def show(logits, logit_sample):
    probits = sigmoid(logits)
    probit_sample = sigmoid(logit_sample)

    fig, ax = plt.subplots(1, 1, figsize=(5, 5))
    ax.plot(logits, probits)
    ax.set_xlabel('logits')
    ax.set_ylabel('probits')
    ax.set_title('Sigmoid')
    ax.scatter(logit_sample, probit_sample, c = 'r')

    for i in  range(len(logit_sample)):
        ax.annotate(f"({logit_sample[i]:.2f}, {probit_sample[i]:.2f})", xy=(logit_sample[i] + 0.5, probit_sample[i]))
    
    fig.tight_layout()
    plt.show()

logtis = np.linspace(-10, 10, 100)
logit_sample = np.array([-2., 0, 2.])
show(logtis, logit_sample)
    

在这里插入图片描述

由图可知,这个sigmoid函数能将负无穷到正无穷之间的任何输入值映射到0到1之间,即输出一个概率值,因此这个函数可以用来做分类预测。最终模型的数学表示为:

P ( y = 1 ) = σ ( z ) = σ ( b + w 1 x 1 + w 2 x 2 ) \Large \text{P}(y=1) = \sigma(z) = \sigma(b+w_1x_1+w_2x_2) P(y=1)=σ(z)=σ(b+w1x1+w2x2)

其中, y = 1 y=1 y=1表示预测为正类, z z z表示模型的输入值(即线性回归的输出值), σ ( z ) \sigma(z) σ(z)表示sigmoid函数。

3.3 模型定义

如果用pytorch来构造这个模型,代码如下:

import torch
import torch.nn as nn

class ClassificationModel(nn.Module):
    def __init__(self, input_dims, output_dims):
        super().__init__()
        self.layer1 = nn.Linear(input_dims, output_dims)
        self.layer2 = nn.Sigmoid()
    
    def forward(self, x):
        return self.layer2(self.layer1(x))

torch.manual_seed(42)
model = ClassificationModel(2, 1)
model.state_dict()

OrderedDict([('layer1.weight', tensor([[0.5406, 0.5869]])),
             ('layer1.bias', tensor([-0.1657]))])

注:尽管模型有两个层,但state_dict输出结果中仅包含来自线性层的参数,这是因为sigmoid层不包含任何可学习的参数。

如果用它进行概率预测:

y_0 = model(x_train_tensor[0])
y_0
tensor([0.4050], grad_fn=<SigmoidBackward0>)
3.4 损失函数

分类模型输出的是概率,需要使用交叉熵来评估损失,它可以用来衡量两个概率分布之间的距离,交叉熵越小,表示两个概率分布越相似。

pytorch中提供的交叉熵损失函数有:

  1. BCELoss: 二元交叉熵损失,适用于二分类问题
  2. CrossEntropyLoss: 适用于多分类问题,输入值为未经过激活函数处理的原始logits,函数内部会对输入值应用softmax激活函数,转换为概率分布后再计算交叉熵损失。

其中BCELoss按照输入值的不同又分为:

  • nn.BCELoss: 输入值为经过sigmoid激活函数处理后的概率值,范围在[0,1]之间。
  • nn.BCEWithLogitsLoss: 输入值为logits,内部会应用sigmoid函数。

二元分类问题一般采用二元交叉熵损失(BCE)。

注:关于交叉熵的原理可以参考:动手学深度学习——softmax回归

loss_fn = nn.BCELoss(reduction='mean')
loss_fn

输出:

BCELoss()

注:nn.BCELoss本身并不是损失函数,它是一个高阶函数,它的返回值loss_fn才是真正的损失函数。

测试损失函数:

example_labels = torch.tensor([1.0, 0.0])
example_predictions = torch.tensor([0.9, 0.2])
loss = loss_fn(example_predictions, example_labels)
loss
tensor(0.1643)

与均方差nn.MSELoss不同,交叉熵损失函数的传参是有严格顺序的,必须先传预测值,再传标签值,否则将会得到一个完全不同的错误结果(如下所示)。

wrong_loss = loss_fn(example_labels, example_predictions)
wrong_loss
tensor(15.0000)

与BCELoss函数对应的还有一个二分类损失函数nn.BCEWithLogitsLoss, 与前者不同的是,此函数将logit作为参数。这意味着在模型层次上不应该将sigmoid作为模型的最后一层。这样一来,就有了模型和损失函数的两种组合使用方式:

  1. nn.sigmoid作为模型最后一层,这意味着模型会输出概率,结合nn.BCELoss来计算损失。
  2. 最后一层没有sigmoid,这意味着模型会输出logit,并结合nn.BCEWithLogitsLoss来计算损失。

注:上面的组合千万不能混用,混用sigmoid和nn.BCEWithLogitsLoss将是错误的。

4. 训练

4.1 模型配置

为训练器配置模型、损失函数和优化器。

注:训练器复用了前面文章已经创建好的Trainer, 详情参考:基于面向对象重构模型训练器

import torch.optim as optim
from trainer_v1 import LinearTrainer as Trainer

lr = 0.2

torch.manual_seed(42)
model = ClassificationModel(2, 1)
loss_fn = nn.BCELoss(reduction='mean')
optimizer = optim.SGD(model.parameters(), lr = lr)

trainer = Trainer(model, loss_fn, optimizer, True)
trainer.set_loader(train_loader, test_loader)
trainer.set_seed(42)
trainer.set_tensorboard('classification', '../log', True)

trainer.model.state_dict()

set train_loader: <torch.utils.data.dataloader.DataLoader object at 0x15b6142e0>
test_loader: <torch.utils.data.dataloader.DataLoader object at 0x15b6163b0>
clear tensorboard path: ../log/classification
Tensorboard log dir: ../log/classification





OrderedDict([('layer1.weight', tensor([[0.5406, 0.5869]])),
             ('layer1.bias', tensor([-0.1657]))])
x_train_tensor.shape, y_train_tensor.shape

(torch.Size([80, 2]), torch.Size([80, 1]))
4.2 开始训练
trainer.train(100)
trainer.model.state_dict()
OrderedDict([('layer1.weight', tensor([[ 1.1949, -1.5316]])),
             ('layer1.bias', tensor([0.0038]))])

可视化损失下降

%load_ext tensorboard
%tensorboard --logdir ../log/classification

在这里插入图片描述

注:上面的损失图中有一个现象:测试损失比训练损失还要小。换句话说,相当于在未知数据上的表现比已知训练数据上的表现还好,有点匪夷所思。但有一种情况会出现,就是测试数据比训练数据更容易分类,实际上,我们的数据中确实如此。

4.3 概率到类别

用训练好的模型参数来预测,这里将前4个数据点作为样本进行预测,输出的是4个点成为正类的概率。

predictions = trainer.predict(x_train_tensor[:4])
predictions
array([[0.4432624 ],
       [0.9382942 ],
       [0.47342145],
       [0.21088998]], dtype=float32)

注:上面这些浮点数字,是四个点成为正类(即蓝点)的概率。

我们最终要的是类别:蓝色或红色,从概率到类别只需要一个逻辑判断:
y = { 1 ,  if P ( y = 1 ) ≥ 0.5 0 ,  if P ( y = 1 ) < 0.5 \Large y = \begin{cases} 1,\ \text{if P}(y=1) \ge 0.5 \\ 0,\ \text{if P}(y=1) < 0.5 \end{cases} y= 1, if P(y=1)0.50, if P(y=1)<0.5

注:这里的0.5相当于正类和负类之间的边界阀值,也可以采用其它阀值,不同的阀值将带来不同的边界。

def probits_to_classes(probits, threshold=0.5):
    return (probits >= threshold).astype(int)

probits_to_classes(predictions)
array([[0],
       [1],
       [0],
       [0]])
4.4 决策边界

我们用等高线来展示模型在训练时真正看到的内容,图中灰色的线就是判断的边界阀值(概率=0.5),正是它将红色和蓝色分开。

def build_contour_data(model, device):
    h = .02  # step size in the mesh
    x_min, x_max = -2.25, 2.25
    y_min, y_max = -2.25, 2.25
    xx1, xx2 = np.meshgrid(np.arange(x_min, x_max, h),
                         np.arange(y_min, y_max, h))
    # ravel函数用于将二维数组降为一维数组
    # np.c_用于按列堆叠数组,将两个一维数组按列堆叠,形成新的二维数组。
    probits = model(torch.as_tensor(np.c_[xx1.ravel(), xx2.ravel()]).float().to(device))
    yhat = probits.detach().cpu().numpy().reshape(xx1.shape)
    return xx1, xx2, yhat

def show_boundary(x_train, y_train, x_test, y_test, model, device, cm=None, cm_bright=None):
    # 从红色过滤到蓝色,中间经过白色,通常用于表示数据从负值到正值的连续变化
    cm = plt.cm.RdBu if cm is None else cm
    cm_bright = ListedColormap(['#FF0000', '#0000FF']) if cm_bright is None else cm_bright
    xx1, xx2, yhat = build_contour_data(model, device)

    fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))

    def show_axis(ax, X, y, title):
        #  levels指定了等高线级别,只绘制yhat中值为0.5的等高线, vmin和vmax设置了cmap颜色映射的最小值和最大值
        ax.contour(xx1, xx2, yhat, levels=[.5], cmap="Greys", vmin=0, vmax=1)
        # contourf用于在等高线之间填充颜色,以表示不同的值范围
        # 25 指定了等高线填充的级别数,函数会自动计算25个值范围,并在这些范围之间填充不同颜色
        # alpha设置了透明度
        contour = ax.contourf(xx1, xx2, yhat, 25, cmap=cm, alpha=.8, vmin=0, vmax=1)
        # Plot the training points
        ax.scatter(X[:, 0], X[:, 1], c=y, cmap=cm_bright)

        ax.set_xlim(xx1.min(), xx1.max())
        ax.set_ylim(xx2.min(), xx2.max())
        ax.set_xlabel(r'$X_1$')
        ax.set_ylabel(r'$X_2$')
        ax.set_title(title)
        ax.grid(False)

        ax_c = plt.colorbar(contour)
        ax_c.set_ticks([0, .25, .5, .75, 1])
    
    show_axis(ax[0], x_train, y_train, "Train boundary")
    show_axis(ax[1], x_test, y_test, "Test boundary")

    plt.tight_layout()
    plt.show()

show_boundary(x_train_scaler, y_train, x_test_scaler, y_test, trainer.model, trainer.device)

在这里插入图片描述

注:图中间灰线可以基本上区分红点和蓝点,因为红点多集中在左上角,蓝点多集中在右下角,颜色越深意味着概率越高。
注:有两类点看起来很醒目:红色区域中的蓝点和蓝色区域中的红点,因为这两类点被错误的分类。
注:测试集比训练集更可分离,因为边界之外的点更少,这也说明了为什么测试损失与训练损失低,因为类别越可分离,则损失越低。

附:一个有趣的数据可分问题

下面是一个包含10个数据点的小型数据集,其中7个红色,3个蓝色,每个数据点只有一个特征,由于只有一个维度,所以可以把它画在一条线上。

如何解决数据是否可分类的问题,低维不可分,到高维可分

x = np.array([-2.8, -2.2, -1.8, -1.3, -.4, 0.3, 0.6, 1.3, 1.9, 2.5])
y_train = np.array([0., 0., 0., 0., 1., 1., 1., 0., 0., 0.])
def show_non_separable(x, y, colors=None):
    if colors is None:
        colors = ['r', 'b']
    fig, ax = plt.subplots(1, 1, figsize=(10, 2))

    ax.grid(False)
    ax.set_ylim([-.1, .1])
    ax.axes.get_yaxis().set_visible(False)
    ax.plot([-3, 3], [0, 0], linewidth=2, c='k', zorder=1)
    ax.plot([0, 0], [-.03, .03], c='k', zorder=1)
    # 从y数组中选取值为1或1的元素的下标,选取这些下标对应的x值
    ax.scatter(x[y==1], np.zeros_like(x[y==1]), c=colors[1], s=150, zorder=2, linewidth=3)
    ax.scatter(x[y==0], np.zeros_like(x[y==0]), c=colors[0], s=150, zorder=2, linewidth=3)
    ax.set_xlabel(r'$X_1$')
    ax.set_title('One Dimension')
    
    fig.tight_layout()
    plt.show()
    

show_non_separable(x, y_train)

在这里插入图片描述

那么能用一条直线把红色和蓝色分开吗? 显然是不能的,这些点是不可分的。

但这只是在一个维度时不可分,同样的数据如果放到二维空间中,这里简单使用原始数据的平方。

X 2 = f ( X 1 ) = X 1 2 \Large X_2 = f(X_1)= X_1^2 X2=f(X1)=X12

def two_dimensions(x, y, colors=None):
    if colors is None:
        colors = ['r', 'b']
    
    x2 = np.concatenate([x.reshape(-1, 1), (x ** 2).reshape(-1, 1)], axis=1)

    fig, ax = plt.subplots(1, 1, figsize=(5, 4.5))
    ax.scatter(*x2[y==1, :].T, c='b', s=15, zorder=2, linewidth=3)
    ax.scatter(*x2[y==0, :].T, c='r', s=15, zorder=2, linewidth=3)
    ax.plot([-2, 2], [1, 1], 'k--', linewidth=1)
    ax.set_xlabel(r'$X_1$')
    ax.set_ylabel(r'$X_2=X_1^2$')
    ax.set_title('Two Dimensions')

    fig.tight_layout()
    plt.show()
two_dimensions(x, y_train)

在这里插入图片描述

在一维空间中,这些点不可分, 但在二维空间中,这些点就很容易分开。 这里引出一个结论:维数越多,点的可分性就越强。

相关阅读

  • 19
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

沉下心来学鲁班

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值