神经网络基础组件

目录

一、感知器

介绍

代码示例

二、激活函数

常见的激活函数,及其图像

sigmoid

Tanh

ReLu

softmax

三、损失函数

均方误差

分类交叉熵损失

二元交叉熵损失

四、感知机实现简单二分类任务

准备

实现

总结及技术拓展

简单总结构造思路

数据集划分

早停技术

正则化

五、NLP小实验--餐厅评论感情分类

数据集简介

数据集处理

Pytorch的数据集处理

词汇表、向量化器和DataLoader

词汇表

向量化器

DATALOADER

感知机分类器

训练

数据

模型结构定义

训练

评估

评估的另一种方法

观察模型权重


一、感知器

介绍

最简单的神经网络单元是感知器。感知器在历史上是非常松散地模仿生物神经元的。就像生物神经元一样,有输入和输出,“信号”从输入流向输出。如图所示结构

每个感知器单元有一个输入(x),一个输出(y),和三个“旋钮”(knobs):一组权重(w),偏量(b),和一个激活函数(f)。权重和偏量都从数据学习,激活函数是精心挑选的取决于网络的网络设计师的直觉和目标输出。数学上,我们可以这样表示:

y=f(wx+b)

通常情况下感知器有不止一个输入。我们可以用向量表示这个一般情况;即,x和w是向量,w和x的乘积替换为点积:

y=f(\vec{w}^T\vec{x}+b)

激活函数,这里用f表示,通常是一个非线性函数。下面给出一个简单的感知机实现。

代码示例

import torch
import torch.nn as nn

class Perceptron(nn.Module):
    """ A Perceptron is one Linear layer """
    def __init__(self, input_dim):
        """
        Args:
            input_dim (int): size of the input features
        """
        super(Perceptron, self).__init__()
        self.fc1 = nn.Linear(input_dim, 1)

    def forward(self, x_in):
        """The forward pass of the Perceptron

        Args:
            x_in (torch.Tensor): an input data tensor.
                x_in.shape should be (batch, num_features)
        Returns:
            the resulting tensor. tensor.shape should be (batch,)
        """
        return torch.sigmoid(self.fc1(x_in)).squeeze()# 一个全连接层,一个激活函数层,最后去除张量中大小为1的维度

nn.Module 是 PyTorch 中所有神经网络模块的基类。在__init__ 方法中,通过调用 super()函数来调用父类的构造函数,以确保正确初始化。super()函数主要去继承父类的属性与方法,在此举个栗子。

class ParentClass:
    def __init__(self, x):
        self.x = x

    def parent_method(self):
        print("This is a parent method")

class ChildClass(ParentClass):
    def __init__(self, x, y):
        super().__init__(x)
        self.y = y

    def child_method(self):
        print("This is a child method")

如代码所示,子类中通过这种方法可以去调用父类的初始化函数。

线性运算\vec{w}^T\vec{x}+b称为仿射变换。PyTorch 方便地在 torch 中提供了一个Linear() 类。nn模块,它做权值和偏差所需的簿记,并做所需的仿射变换。在“深入到有监督的训练”中,将看到如何从数据中“学习”权重w和b的值。前面示例中使用的激活函数是 sigmoid 函数。在下一节中,我们将回顾一些常见的激活函数,包括 sigmoid 函数。

二、激活函数

常见的激活函数,及其图像

激活函数是神经网络中引入的非线性函数,用于捕获数据中的复杂关系。在“深入到有监督的训练”和“多层感知器”中,我们深入研究了为什么学习中需要非线性,但首先,让我们看看一些常用的激活函数。

sigmoid

sigmoid 是神经网络历史上最早使用的激活函数之一。它取任何实值并将其压缩在0和1之间。数学上,sigmoid 的表达式如下:


f(x) = \frac{1}{1+e^{-x}}


 

import torch
import matplotlib.pyplot as plt

x = torch.range(-5., 5., 0.1)
y = torch.sigmoid(x)#画出sigmoid
plt.plot(x.numpy(), y.numpy())
plt.show()

结果:

从图中可以看出,sigmoid 函数饱和(即产生极值输出)非常快,对于大多数输入。这可能成为一个问题,因为它可能导致梯度变为零或发散到溢出的浮点值。这些现象分别被称为消失梯度问题和爆炸梯度问题。因此,在神经网络中,除了在输出端使用 sigmoid 单元外,很少看到其他使用 sigmoid 单元的情况,在输出端,压缩属性允许将输出解释为概率。

Tanh

tanh 激活函数是 sigmoid 在外观上的不同变体。当你写下 tanh 的表达式时,这就变得很清楚了:


f(x) = tanhx = \frac{e^x - e^{-x}}{e^x + e^{-x}}


通过一些争论(我们留作练习),可以确信 tanh 只是 sigmoid 的一个线性变换。当为 tanh() 写下 PyTorch 代码并绘制曲线时,这一点也很明显。注意双曲正切,像 sigmoid ,也是一个“压缩”函数,除了它映射一个实值集合从 (-∞,+∞)到 (-1,+1)范围。

import torch
import matplotlib.pyplot as plt

x = torch.range(-5., 5., 0.1)
y = torch.tanh(x)#画出tanh
plt.plot(x.numpy(), y.numpy())
plt.show()

结果:

ReLu

ReLU代表线性整流单元。这可以说是最重要的激活函数。事实上如果没有使用 ReLU,许多最近在深度学习方面的创新都是不可能实现的。对于一些如此基础的东西来说,神经网络激活函数的出现也是令人惊讶的。它的形式也出奇的简单:


f(x) = max(0,x)

图形代码:

import torch
import matplotlib.pyplot as plt

relu = torch.nn.ReLU()#画Relu
x = torch.range(-5., 5., 0.1)
y = relu(x)

plt.plot(x.numpy(), y.numpy())
plt.show()

结果:

ReLU 的裁剪效果有助于消除梯度问题,随着时间的推移,网络中的某些输出可能会变成零,再也不会恢复。这就是所谓的“dying ReLU”问题。为了减轻这种影响,提出了 Leaky ReLU 或 Parametric ReLU (PReLU) 等变体,其中泄漏系数 a 是一个可学习参数:

𝑓(𝑥)=𝑚𝑎𝑥(𝑥,𝑎𝑥)

import torch
import matplotlib.pyplot as plt

prelu = torch.nn.PReLU(num_parameters=1)#PRelu
x = torch.range(-5., 5., 0.1)
y = prelu(x)

plt.plot(x.detach().numpy(), y.detach().numpy())
plt.show()

softmax

激活函数的另一个选择是 softmax 。与 sigmoid 函数类似,softmax 函数将每个单元的输出压缩为 0 到 1 之间。然而,softmax 操作还将每个输出除以所有输出的和,从而得到一个离散概率分布,除以 k 个可能的类。结果分布中的概率总和为 1。

softmax(x_i) = \frac{e^{x_i}}{\sum^{k}_{j=1}{e^{x_j}}}

import torch.nn as nn
import torch

softmax = nn.Softmax(dim=1)
x_input = torch.randn(1, 3)
y_output = softmax(x_input)
print(x_input)
print(y_output)
print(torch.sum(y_output, dim=1))

运行结果:

tensor([[0.1517, 0.5601, 1.6671]])
tensor([[0.1417, 0.2132, 0.6450]])
tensor([1.])

三、损失函数

在实验1中,我们看到了通用的监督机器学习架构,以及损失函数或目标函数如何通过查看数据来帮助指导训练算法选择正确的参数。回想一下,一个损失函数truth(y)和预测(ŷ)作为输入,产生一个实值的分数。这个分数越高,模型的预测就越差。PyTorch 在它的 nn 包中实现了许多损失函数,这些函数太过全面,这里就不介绍了,但是我们将介绍一些常用的损失函数。

均方误差


回归问题的网络的输出$(ŷ)$和目标$(y)$是连续值,一个常用的损失函数的均方误差(MSE)。


L_{MSE}(y,y') = \frac{1}{n}\sum^{n}_{i=1}{(y-y')^2}


MSE 就是预测值与目标值之差的平方的平均值。还有一些其他的损失函数可以用于回归问题,例如平均绝对误差(MAE)和均方根误差(RMSE),但是它们都涉及到计算输出和目标之间的实值距离。

import torch
import torch.nn as nn

mse_loss = nn.MSELoss()
outputs = torch.randn(3, 5, requires_grad=True)
targets = torch.randn(3, 5)
loss = mse_loss(outputs, targets)#计算均方误差
print(loss)

结果:

tensor(1.3934, grad_fn=<MseLossBackward>)

分类交叉熵损失

分类交叉熵损失(categorical cross-entropy loss)通常用于多类分类设置,其中输出被解释为类隶属度概率的预测。目标(y)是 n 个元素的向量,表示所有类的真正多项分布。如果只有一个类是正确的,那么这个向量就是 one hot 向量。网络的输出(ŷ)(ŷ)也是一个向量 n 个元素,但代表了网络的多项分布的预测。分类交叉熵将比较这两个向量(𝑦,ŷ)(𝑦,ŷ)来衡量损失:

L_{cross-entropy}(y,y') = -\sum_i{y_ilog(y'_i)}

交叉熵和它的表达式起源于信息论,但是为了本节的目的,把它看作一种计算两个分布有多不同的方法是有帮助的。我们希望正确的类的概率接近 1,而其他类的概率接近 0。

import torch
import torch.nn as nn

ce_loss = nn.CrossEntropyLoss()
outputs = torch.randn(3, 5, requires_grad=True)
targets = torch.tensor([1, 0, 3], dtype=torch.int64)
loss = ce_loss(outputs, targets)#交叉熵
print(loss)

结果:

tensor(1.7532, grad_fn=<NllLossBackward>)

二元交叉熵损失

我们在上一节看到的分类交叉熵损失函数在有多个类的分类问题中非常有用。有时,我们的任务包括区分两个类——也称为二元分类。在这种情况下,利用二元交叉熵损失是有效的。

bce_loss = nn.BCELoss()
sigmoid = nn.Sigmoid()#概率模型
probabilities = sigmoid(torch.randn(4, 1, requires_grad=True))
targets = torch.tensor([1, 0, 1, 0],  dtype=torch.float32).view(4, 1)
loss = bce_loss(probabilities, targets)#二元交叉熵
print(probabilities)
print(loss)
tensor([[0.6349],
        [0.7365],
        [0.6947],
        [0.8348]], grad_fn=<SigmoidBackward>)
tensor(0.9882, grad_fn=<BinaryCrossEntropyBackward>)

四、感知机实现简单二分类任务

准备

有监督学习一般需要以下内容:模型(激活函数),损失函数,训练数据和优化算法。监督学习的训练数据是观察和目标对,模型从观察中计算预测,损失衡量预测相对于目标的误差。训练的目的是利用基于梯度的优化算法来调整模型的参数,使损失尽可能小。

本小节将给出代码去实现感知机学习一条直线(或超平面)去分类

这里我们选择激活函数为sigmoid,用于算数据相对于某一类的概率,边界设为0.5,这个问题是个二分类问题,最适合的损失函数是二元交叉熵损失。对于优化器,PyTorch 库为优化器提供了几种选择。随机梯度下降法(SGD)是一种经典的选择算法,但对于复杂的优化问题,SGD 存在收敛性问题,往往导致模型较差。当前首选的替代方案是自适应优化器,例如 Adagrad 或 Adam。我们使用 Adam。对于 Adam,默认的学习率是 0.001。对于学习率之类的超参数,总是建议首先使用默认值,除非从论文中获得了需要特定值的秘诀。

import torch.nn as nn
import torch.optim as optim

input_dim = 2
lr = 0.001

perceptron = Perceptron(input_dim=input_dim)
bce_loss = nn.BCELoss()
optimizer = optim.Adam(params=perceptron.parameters(), lr=lr)

实现

学习从计算损失开始,也就是说,模型预测离目标有多远。损失函数的梯度,反过来,是参数应该改变多少的信号。每个参数的梯度表示给定参数的损失值的瞬时变化率。实际上,这意味着可以知道每个参数对损失函数的贡献有多大。直观上,这是一个斜率,可以想象每个参数都站在它自己的山上,想要向上或向下移动一步。基于梯度的模型训练所涉及的最简单的形式就是迭代地更新每个参数,并使用与该参数相关的损失函数的梯度。

让我们看看这个梯度步进(gradient-steeping)算法是什么样子的。首先,使用名为zero_grad()的函数清除当前存储在模型(感知器)对象中的所有记帐信息,例如梯度。然后,模型计算给定输入数据(x_data)的输出(y_pred)。接下来,通过比较模型输出(y_pred)和预期目标(y_target)来计算损失。这正是有监督训练信号的有监督部分。PyTorch 损失对象(criteria)具有一个名为bcakward()的函数,该函数迭代地通过计算图向后传播损失,并将其梯度通知每个参数。最后,优化器(opt)用一个名为step()的函数指示参数如何在知道梯度的情况下更新它们的值。

整个训练数据集被划分成多个批(batch)。在文献和本课程中,术语 minibatch 也可以互换使用,而不是“batch”来强调每个batch都明显小于训练数据的大小;例如,训练数据可能有数百万个,而小批数据可能只有几百个。梯度步骤的每一次迭代都在一批数据上执行。名为 batch_size 的超参数指定批次的大小。由于训练数据集是固定的,增加批大小会减少批的数量。在多个批处理(通常是有限大小数据集中的批处理数量)之后,训练循环完成了一个 epoch。epoch 是一个完整的训练迭代。如果每个 epoch 的批数量与数据集中的批数量相同,那么 epoch 就是对数据集的完整迭代。模型是为一定数量的 epoch 而训练的。要训练的epoch的数量对于选择来说不是复杂的,但是有一些方法可以决定什么时候停止,我们稍后将讨论这些方法。如Example 3-10所示,受监督的训练循环因此是一个嵌套循环:数据集或批处理集合上的内部循环,以及外部循环,后者在固定数量的 epoches 或其他终止条件上重复内部循环。

import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

%matplotlib inline
# 定义左右中心点
LEFT_CENTER = (3, 3)
RIGHT_CENTER = (3, -2)

class Perceptron(nn.Module):
    """ 感知器模型,包含一个线性层 """

    def __init__(self, input_dim):
        """
        Args:
            input_dim (int): 输入特征的大小
        """
        super(Perceptron, self).__init__()
        self.fc1 = nn.Linear(input_dim, 1)

    def forward(self, x_in):
        """感知器模型的前向传播

        Args:
            x_in (torch.Tensor): 输入数据张量。x_in.shape 应该是 (batch, input_dim)
        Returns:
            结果张量。tensor.shape 应该是 (batch, 1)
        """
        return torch.sigmoid(self.fc1(x_in))

# 生成数据的函数
def get_toy_data(batch_size, left_center=LEFT_CENTER, right_center=RIGHT_CENTER):
    """
    生成模拟数据,返回数据和标签

    参数:
    - batch_size: 每个批次的数据量
    - left_center: 左侧正态分布的均值
    - right_center: 右侧正态分布的均值

    返回:
    - x_data: 生成的数据张量
    - y_targets: 对应的标签张量
    """
    x_data = []  # 存储生成的数据
    y_targets = np.zeros(batch_size)  # 初始化标签数组,默认标签为 0
    for batch_i in range(batch_size):
        if np.random.random() > 0.5:  # 随机选择一个分布生成数据
            x_data.append(np.random.normal(loc=left_center))  # 从左侧分布生成数据
        else:
            x_data.append(np.random.normal(loc=right_center))  # 从右侧分布生成数据
            y_targets[batch_i] = 1  # 对应标签设置为 1
    return torch.tensor(x_data, dtype=torch.float32), torch.tensor(y_targets, dtype=torch.float32)

def visualize_results(perceptron, x_data, y_truth, n_samples=1000, ax=None, epoch=None, 
                      title='', levels=[0.3, 0.4, 0.5], linestyles=['--', '-', '--']):
    y_pred = perceptron(x_data)
    y_pred = (y_pred > 0.5).long().data.numpy().astype(np.int32)

    x_data = x_data.data.numpy()
    y_truth = y_truth.data.numpy().astype(np.int32)

    n_classes = 2

    all_x = [[] for _ in range(n_classes)]
    all_colors = [[] for _ in range(n_classes)]
    
    colors = ['black', 'white']
    markers = ['o', '*']
    
    for x_i, y_pred_i, y_true_i in zip(x_data, y_pred, y_truth):
        all_x[y_true_i].append(x_i)
        if y_pred_i == y_true_i:
            all_colors[y_true_i].append("white")
        else:
            all_colors[y_true_i].append("black")

    all_x = [np.stack(x_list) for x_list in all_x]

    if ax is None:
        _, ax = plt.subplots(1, 1, figsize=(10,10))
        
    for x_list, color_list, marker in zip(all_x, all_colors, markers):
        ax.scatter(x_list[:, 0], x_list[:, 1], edgecolor="black", marker=marker, facecolor=color_list, s=300)
    
        
    xlim = (min([x_list[:,0].min() for x_list in all_x]), 
            max([x_list[:,0].max() for x_list in all_x]))
            
    ylim = (min([x_list[:,1].min() for x_list in all_x]), 
            max([x_list[:,1].max() for x_list in all_x]))
            
    # 超平面
    
    xx = np.linspace(xlim[0], xlim[1], 30)
    yy = np.linspace(ylim[0], ylim[1], 30)
    YY, XX = np.meshgrid(yy, xx)
    xy = np.vstack([XX.ravel(), YY.ravel()]).T
    
    Z = perceptron(torch.tensor(xy, dtype=torch.float32)).detach().numpy().reshape(XX.shape)
    ax.contour(XX, YY, Z, colors='k', levels=levels, linestyles=linestyles)    
    
    plt.suptitle(title)
    
    if epoch is not None:
        plt.text(xlim[0], ylim[1], "Epoch = {}".format(str(epoch)))

# 设置随机种子
seed = 1337

torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
np.random.seed(seed)

# 生成数据
x_data, y_truth = get_toy_data(batch_size=1000)

x_data = x_data.data.numpy()
y_truth = y_truth.data.numpy()

left_x = []
right_x = []
left_colors = []
right_colors =  []

for x_i, y_true_i in zip(x_data, y_truth):
    color = 'black'

    if y_true_i == 0:
        left_x.append(x_i)
        left_colors.append(color)

    else:
        right_x.append(x_i)
        right_colors.append(color)

left_x = np.stack(left_x)
right_x = np.stack(right_x)

_, ax = plt.subplots(1, 1, figsize=(10,4))

ax.scatter(left_x[:, 0], left_x[:, 1], color=left_colors, marker='*', s=100)
ax.scatter(right_x[:, 0], right_x[:, 1], facecolor='white', edgecolor=right_colors, marker='o', s=100)

plt.axis('off');
        
# 设置超参数
lr = 0.01
input_dim = 2

batch_size = 1000
n_epochs = 12
n_batches = 5

# 设置随机种子,保证实验的可重复性
seed = 1337

torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
np.random.seed(seed)

# 初始化感知器模型、优化器和损失函数
perceptron = Perceptron(input_dim=input_dim)
optimizer = optim.Adam(params=perceptron.parameters(), lr=lr)
bce_loss = nn.BCELoss()

# 初始化损失列表
losses = []

# 生成静态数据用于可视化
x_data_static, y_truth_static = get_toy_data(batch_size)
fig, ax = plt.subplots(1, 1, figsize=(10,5))
# 可视化初始模型状态
visualize_results(perceptron, x_data_static, y_truth_static, ax=ax, title='初始模型状态')
plt.axis('off')
#plt.savefig('initial.png')

# 定义训练过程中的终止条件
change = 1.0
last = 10.0
epsilon = 1e-3
epoch = 0

# 开始训练循环,直到满足终止条件
while change > epsilon or epoch < n_epochs or last > 0.3:
    # 迭代每个 epoch
    for _ in range(n_batches):
        # 清空梯度
        optimizer.zero_grad()
        # 获取一个批次的数据
        x_data, y_target = get_toy_data(batch_size)
        # 进行前向传播
        y_pred = perceptron(x_data).squeeze()
        # 计算损失
        loss = bce_loss(y_pred, y_target)
        # 反向传播
        loss.backward()
        # 更新模型参数
        optimizer.step()
        
        # 记录损失值
        loss_value = loss.item()
        losses.append(loss_value)

        # 计算损失变化
        change = abs(last - loss_value)
        last = loss_value
               
    # 可视化当前训练状态
    fig, ax = plt.subplots(1, 1, figsize=(10,5))
    visualize_results(perceptron, x_data_static, y_truth_static, ax=ax, epoch=epoch, 
                      title=f"{loss_value}; {change}")
    plt.axis('off')
    epoch += 1
    #plt.savefig('epoch{}_toylearning.png'.format(epoch))

结果:

原始图像:

开始训练图像:

最终图像:

分类错误的点用黑色重点标记了。

总结及技术拓展

简单总结构造思路

基于梯度监督学习的核心概念很简单:定义模型,计算输出,使用损失函数计算梯度,应用优化算法用梯度更新模型参数。

数据集划分

一定要记住,最终的目标是很好地概括数据的真实分布。这是什么意思?假设我们能够看到无限数量的数据(“真实/不可见的分布”),那么存在一个全局的数据分布。显然,我们不能那样做。相反,我们用有限的样本作为训练数据。我们观察有限样本中的数据分布这是真实分布的近似或不完全图像。如果一个模型不仅减少了训练数据中样本的误差,而且减少了来自不可见分布的样本的误差,那么这个模型就比另一个模型具有更好的通用性。当模型致力于降低它在训练数据上的损失时,它可以过度适应并适应那些实际上不是真实数据分布一部分的特性。

要实现这一点,标准实践是将数据集分割为三个随机采样的分区,称为训练、验证和测试数据集,或者进行 k-fold 交叉验证。分成三个分区是两种方法中比较简单的一种,因为它只需要一次计算。应该采取预防措施,确保在三个分支之间的类分布保持相同。换句话说,通过类标签聚合数据集,然后将每个由类标签分隔的集合随机拆分为训练、验证和测试数据集,这是一种很好的实践。一个常见的分割百分比是预留 70% 用于培训,15% 用于验证,15% 用于测试。不过,这不是一个硬编码的约定。

在某些情况下,可能存在预定义的训练、验证和测试分离;这在用于基准测试任务的数据集中很常见。在这种情况下,重要的是只使用训练数据更新模型参数,在每个 epoch 结束时使用验证数据测量模型性能,在所有的建模选择被探索并需要报告最终结果之后,只使用测试数据一次。这最后一部分是极其重要的,因为更多的机器学习工程师在玩模型的性能测试数据集,他们是偏向选择测试集上表现得更好。当这种情况发生时,它是不可能知道该模型性能上看不见的数据没有收集更多的数据。

使用 k-fold 交叉验证的模型评估与使用预定义分割的评估非常相似,但是在此之前还有一个额外的步骤,将整个数据集分割为k个大小相同的 fold。其中一个 fold 保留用于评估,剩下的k-1fold用于训练。通过交换出计算中的哪些 fold,可以重复执行此操作。因为有 k 个 fold,每一个 fold 都有机会成为一个评价 fold,并产生一个特定于 fold 的精度,从而产生 k 个精度值。最终报告的准确性只是具有标准差的平均值。k-fold 评估在计算上是昂贵的,但是对于较小的数据集来说是非常必要的,对于较小的数据集来说,错误的分割可能导致过于乐观(因为测试数据太容易了)或过于悲观(因为测试数据太困难了)。

早停技术

之前的例子训练了固定次数的模型。虽然这是最简单的方法,但它是任意的和不必要的。正确度量模型性能的一个关键功能是使用该度量来知道何时应该停止训练。最常用的方法是使用启发式方法,称为早期停止(early stopping)。早期停止通过跟踪验证数据集上从一个 epoch 到另一个 epoch 的性能并注意性能何时不再改进来的工作。然后,如果业绩继续没有改善,训练将终止。在结束训练之前需要等待的时间称为耐心。一般来说,模型停止改进某些数据集的时间点称为模型收敛的时间点。在实际应用中,我们很少等待模型完全收敛,因为收敛是耗时的,而且会导致过拟合。

正则化

正则化的概念来源于数值优化理论。回想一下,大多数机器学习算法都在优化损失函数,以找到最可能解释观测结果(即,产生的损失最少)。对于大多数数据集和任务,这个优化问题可能有多个解决方案(可能的模型)。那么我们(或优化器)应该选择哪一个呢?

过求助于奥卡姆剃刀,我们凭直觉知道一个简单的解释比复杂的解释更好。这种机器学习中的平滑约束称为 L2 正则化。在 PyTorch 中,可以通过在优化器中设置 weight_decay 参数来控制这一点。weight_decay 值越大,优化器选择的解释就越流畅;也就是说,L2 正则化越强。

除了 L2,另一种流行的正则化是L1正则化。L1 通常用来鼓励稀疏解;换句话说,大多数模型参数值都接近于零。还有一种结构正则化技术,称为“dropout”,随机失活。模型正则化是一个活跃的研究领域,PyTorch 是实现自定义正则化的灵活框架。

五、NLP小实验--餐厅评论感情分类

在上一节中,我们通过一个玩具示例深入研究了有监督的训练,并阐述了许多基本概念。在本节中,我们将重复上述练习,但这次使用的是一个真实的任务和数据集:使用感知器和监督培训对 Yelp 上的餐馆评论进行分类,判断它们是正面的还是负面的。因为这是本课程实验中第一个完整的 NLP 示例,所以我们将极其详细地描述辅助数据结构和训练例程。后面几章中的示例将遵循非常相似的模式,因此请仔细遵循本节,并在需要复习时参考它。

在本课程实验的每个示例的开头,我们将描述正在使用的数据集和任务。在这个例子中,我们使用 Yelp 数据集,它将评论与它们的情感标签(正面或负面)配对。此外,我们还描述了一些数据集操作步骤,这些步骤用于清理数据集并将其划分为训练、验证和测试集。

在理解数据集之后,将看到定义三个辅助类的模式,这三个类在本书中反复出现,用于将文本数据转换为向量化的形式:词汇表(the Vocabulary)、向量化器(Vectorizer)和 PyTorch的DataLoader。词汇表协调我们在“观察和目标编码”中讨论的整数到令牌(token)映射。我们使用一个词汇表将文本标记(text tokens)映射到整数,并将类标签映射到整数。接下来,矢量化器(vectorizer)封装词汇表,并负责接收字符串数据,如审阅文本,并将其转换为将在训练例程中使用的数字向量。我们使用最后一个辅助类,PyTorch 的 DataLoader,将单个向量化数据点分组并整理成 minibatches。

在描述了构成文本向量化小批处理管道(text-to-vectorized-minibatch pipeline)的数据集和辅助类之后,概述了感知器分类器及其训练例程。需要注意的重要一点是,本课程实验中的每个示例的训练例程基本保持不变。我们会在这个例子中更详细地讨论它,因此,我们使用这个例子作为未来训练例程的参考。通过讨论结果来总结这个例子,深入了解模型学习到了什么。

数据集简介

2015年,Yelp 举办了一场竞赛,要求参与者根据点评预测一家餐厅的评级。同年,Zhang, Zhao,和Lecun(2015)将 1 星和 2 星评级转换为“消极”情绪类,将3星和4星评级转换为“积极”情绪类,从而简化了数据集。该数据集分为 56 万个训练样本和3.8万个测试样本。在这个数据集部分的其余部分中,我们将描述最小化清理数据并导出最终数据集的过程。然后,我们概述了利用 PyTorch 的数据集类的实现。(本小节实例具体代码见3_5_yelp_dataset_preprocessing_LITE.ipynb)

在这个例子中,我们使用了简化的 Yelp 数据集,但是有两个细微的区别。第一个区别是我们使用数据集的“轻量级”版本,它是通过选择 10% 的训练样本作为完整数据集而派生出来的。这有两个结果:首先,使用一个小数据集可以使训练测试循环快速,因此我们可以快速地进行实验。其次,它生成的模型精度低于使用所有数据。这种低精度通常不是主要问题,因为您可以使用从较小数据集子集中获得的知识对整个数据集进行重新训练。在训练深度学习模型时,这是一个非常有用的技巧,因为在许多情况下,训练数据的数量是巨大的。

从这个较小的子集中,我们将数据集分成三个分区:一个用于训练,一个用于验证,一个用于测试。虽然原始数据集只有两个部分,但是有一个验证集是很重要的。在机器学习中,经常在数据集的训练部分上训练模型,并且需要一个 held-out 部分来评估模型的性能。如果模型决策基于 held-out 部分,那么模型现在不可避免地偏向于更好地执行 held-out 部分。因为度量增量进度是至关重要的,所以这个问题的解决方案是使用第三个部分,它尽可能少地用于评估。

综上所述,应该使用数据集的训练部分来派生模型参数,使用数据集的验证部分在超参数之间进行选择(进行建模决策),使用数据集的测试分区进行最终评估和报告。在Example 3-11中,我们展示了如何分割数据集。注意,随机种子被设置为一个静态数字,我们首先通过类标签聚合以确保类分布保持不变。

数据集处理

import collections
import numpy as np
import pandas as pd
import re

from argparse import Namespace

# 使用 Namespace 类创建一个命名空间对象,存储各种参数
args = Namespace(
    raw_train_dataset_csv="raw_train.csv",  # 训练数据集的原始 CSV 文件路径
    raw_test_dataset_csv="raw_test.csv",    # 测试数据集的原始 CSV 文件路径
    proportion_subset_of_train=0.1,  # 训练集子集的比例,表示从原始训练集中抽取10%的数据
    train_proportion=0.7,    # 训练数据在总数据中的比例
    val_proportion=0.15,     # 验证数据在总数据中的比例
    test_proportion=0.15,    # 测试数据在总数据中的比例
    output_munged_csv="reviews_with_splits_lite.csv",  # 处理后的数据保存的 CSV 文件路径
    seed=1337    # 随机种子,用于确保数据划分的可重复性
)

# 打印命名空间对象,显示所有参数及其对应的值
print(args)
Namespace(output_munged_csv='reviews_with_splits_lite.csv', proportion_subset_of_train=0.1, raw_test_dataset_csv='raw_test.csv', raw_train_dataset_csv='raw_train.csv', seed=1337, test_proportion=0.15, train_proportion=0.7, val_proportion=0.15)
# 读取原始训练数据
train_reviews = pd.read_csv(args.raw_train_dataset_csv, header=None, names=['rating', 'review'])  
# 参数说明:
# - args.raw_train_dataset_csv: 原始训练数据的 CSV 文件路径
# - header=None: 指定文件中没有标题行
# - names=['rating', 'review']: 指定列名为 'rating' 和 'review'

# 打印读取的训练数据
print(train_reviews)
      rating                                             review
0            1  Unfortunately, the frustration of being Dr. Go...
1            2  Been going to Dr. Goldberg for over 10 years. ...
2            1  I don't know what Dr. Goldberg was like before...
3            1  I'm writing this review to give you a heads up...
4            2  All the food is great here. But the best thing...
5            1  Wing sauce is like water. Pretty much a lot of...
6            1  Owning a driving range inside the city limits ...
7            1  This place is absolute garbage...  Half of the...
8            2  Before I finally made it over to this range I ...
9            2  I drove by yesterday to get a sneak peak.  It ...
10           1  After waiting for almost 30 minutes to trade i...
11           2  Wonderful reuben.  Map shown on Yelp page is i...
12           2  After a morning of Thrift Store hunting, a fri...
13           2  This is a hidden gem, no really. It took us fo...
14           2  Awesome drink specials during happy hour. Fant...
15           1  Very disappointed in the customer service. We ...
16           1  Used to go there for tires, brakes, etc.  Thei...
17           1  I got 'new' tires from them and within two wee...
18           1  Terrible. Preordered my tires and when I arriv...
19           2  I've been informed by a fellow Yelper that the...
20           1  Don't waste your time.  We had two different p...
21           1  I will start by saying we have a nice new deck...
22           1                                            Hoofah.
23           1  Average run of the mill store.  Associates are...
24           1  Two meals, on the recommendation of a friend w...
25           2  If you are searching for a go to fish place fo...
26           2  Italian dinners, American, endless soup and sa...
27           2  Good Luck getting a seat, that's all I have to...
28           2  The biggest breakfast in Pittsburgh, as far as...
29           2  Cheap, unpretentious, and, for this, one of my...
...        ...                                                ...
559970       2  Hard choosing a place to eat when you're on th...
559971       2  Woooo!!!! Love this place!!! Missed ir soooo m...
559972       1  Don't go here.  I know you might want to try i...
559973       1  I had high hopes for this place. When I learne...
559974       1  Went to checkers on a friends recommendation t...
559975       2  The food at this place is amazing. My favorite...
559976       2  Love this place! The portions are generous but...
559977       2  My wife and I live in the area and are pleasan...
559978       1  Went for dinner with friends tonight. The plac...
559979       2  Great ambiance, absolutely love the decor, ver...
559980       2  Red Rice serves the best chicken kelaguen!  \n...
559981       2  As a person who have lots of Chamorro friends,...
559982       1  Buffet was recently open after renovation so m...
559983       1  Having moved 15 times and having been referred...
559984       2  We had the best experience. The movers were su...
559985       1  A man came to the house to estimate. He gave m...
559986       2  Scottsdale Beer Company has only been open 2 d...
559987       2  Just found my new favorite hang out. This plac...
559988       1  Really disappointing. Either the place was hav...
559989       2  When Corey sent the email about tickets being ...
559990       1  Wow, really?  Bought one item from them, a ham...
559991       2  We tried the Cheese Danish w lemon. Creamy, fl...
559992       1  Long line, inefficient staff. Maybe my expecta...
559993       1  First: Please note that many people are review...
559994       1  I'm shocked everyone time I go to a Bouchon Ba...
559995       2  Ryan was as good as everyone on yelp has claim...
559996       2  Professional \nFriendly\nOn time AND affordabl...
559997       1  Phone calls always go to voicemail and message...
559998       1  Looks like all of the good reviews have gone t...
559999       2  Ryan Rocks! I called him this morning for some...

[560000 rows x 2 columns]
# making the subset equal across the review classes
by_rating = collections.defaultdict(list)
for _, row in train_reviews.iterrows():
    by_rating[row.rating].append(row.to_dict())
    
review_subset = []

for _, item_list in sorted(by_rating.items()):

    n_total = len(item_list)
    n_subset = int(args.proportion_subset_of_train * n_total)
    review_subset.extend(item_list[:n_subset])

review_subset = pd.DataFrame(review_subset)
review_subset.head()
ratingreview
01Unfortunately, the frustration of being Dr. Go...
11I don't know what Dr. Goldberg was like before...
21I'm writing this review to give you a heads up...
31Wing sauce is like water. Pretty much a lot of...
41Owning a driving range inside the city limits ...

train_reviews.rating.value_counts()
2    280000
1    280000
Name: rating, dtype: int64
review_subset.rating.value_counts()
2    28000
1    28000
Name: rating, dtype: int64
# Unique classes
set(review_subset.rating)
{1, 2}

# Splitting the subset by rating to create our new train, val, and test splits
by_rating = collections.defaultdict(list)
for _, row in review_subset.iterrows():
    by_rating[row.rating].append(row.to_dict())
    
final_list = []
np.random.seed(args.seed)

for _, item_list in sorted(by_rating.items()):

    np.random.shuffle(item_list)
    
    n_total = len(item_list)
    n_train = int(args.train_proportion * n_total)
    n_val = int(args.val_proportion * n_total)
    n_test = int(args.test_proportion * n_total)
    
    # Give data point a split attribute
    for item in item_list[:n_train]:
        item['split'] = 'train'
    
    for item in item_list[n_train:n_train+n_val]:
        item['split'] = 'val'
        
    for item in item_list[n_train+n_val:n_train+n_val+n_test]:
        item['split'] = 'test'

    # Add to final list
    final_list.extend(item_list)

# Write split data to file
final_reviews = pd.DataFrame(final_list)

final_reviews.split.value_counts()
train    39200
val       8400
test      8400
Name: split, dtype: int64
# Preprocess the reviews
def preprocess_text(text):
    text = text.lower()
    text = re.sub(r"([.,!?])", r" \1 ", text)
    text = re.sub(r"[^a-zA-Z.,!?]+", r" ", text)
    return text
    
final_reviews.review = final_reviews.review.apply(preprocess_text)

final_reviews['rating'] = final_reviews.rating.apply({1: 'negative', 2: 'positive'}.get)

final_reviews.head()
ratingreviewsplit
0negativeterrible place to work for i just heard a stor...train
1negativehours , minutes total time for an extremely s...train
2negativemy less than stellar review is for service . w...train
3negativei m granting one star because there s no way t...train
4negativethe food here is mediocre at best . i went aft...train
final_reviews.to_csv(args.output_munged_csv, index=False)

Pytorch的数据集处理

PyTorch 通过提供数据集类为数据集提供了一个抽象。数据集类是一个抽象迭代器。在对新数据集使用 PyTorch 时,必须首先从数据集类继承子类(或继承),并实现 getitem 和 len 方法。对于这个例子,我们创建了一个 ReviewDataset 类,它继承自 PyTorch 的 Dataset 类,并实现了两个方法:__getitemlen__。通过实现这两种方法,有一个概念上的约定,允许各种PyTorch实用程序使用我们的数据集。在下一节中,我们将介绍其中一个实用程序,特别是DataLoader。下面的实现严重依赖于一个名为 ReviewVectorizer 的类。在下一节中,我们将描述 ReviewVectorizer,但是您可以直观地将其描述为处理从评审文本到表示评审的数字向量的转换的类。神经网络只有通过一定的向量化步骤才能与文本数据进行交互。总体设计模式是实现一个数据集类,它处理一个数据点的向量化逻辑。然后,PyTorch 的 DataLoader(下一节也将介绍)将通过对数据集进行采样和整理来创建小批数据(minibatch)。

from torch.utils.data import Dataset

class ReviewDataset(Dataset):
    def __init__(self, review_df, vectorizer):
        """
        Args:
            review_df (pandas.DataFrame): the dataset
            vectorizer (ReviewVectorizer): vectorizer instantiated from dataset
        """
        self.review_df = review_df
        self._vectorizer = vectorizer

        self.train_df = self.review_df[self.review_df.split=='train']
        self.train_size = len(self.train_df)

        self.val_df = self.review_df[self.review_df.split=='val']
        self.validation_size = len(self.val_df)

        self.test_df = self.review_df[self.review_df.split=='test']
        self.test_size = len(self.test_df)

        self._lookup_dict = {'train': (self.train_df, self.train_size),
                             'val': (self.val_df, self.validation_size),
                             'test': (self.test_df, self.test_size)}

        self.set_split('train')

    @classmethod
    def load_dataset_and_make_vectorizer(cls, review_csv):
        """Load dataset and make a new vectorizer from scratch

        Args:
            review_csv (str): location of the dataset
        Returns:
            an instance of ReviewDataset
        """
        review_df = pd.read_csv(review_csv)
        return cls(review_df, ReviewVectorizer.from_dataframe(review_df))

    def get_vectorizer(self):
        """ returns the vectorizer """
        return self._vectorizer

    def set_split(self, split="train"):
        """ selects the splits in the dataset using a column in the dataframe

        Args:
            split (str): one of "train", "val", or "test"
        """
        self._target_split = split
        self._target_df, self._target_size = self._lookup_dict[split]

    def __len__(self):
        return self._target_size

    def __getitem__(self, index):
        """the primary entry point method for PyTorch datasets

        Args:
            index (int): the index to the data point
        Returns:
            a dict of the data point's features (x_data) and label (y_target)
        """
        row = self._target_df.iloc[index]

        review_vector = \
            self._vectorizer.vectorize(row.review)

        rating_index = \
            self._vectorizer.rating_vocab.lookup_token(row.rating)

        return {'x_data': review_vector,
                'y_target': rating_index}

    def get_num_batches(self, batch_size):
        """Given a batch size, return the number of batches in the dataset

        Args:
            batch_size (int)
        Returns:
            number of batches in the dataset
        """
        return len(self) // batch_size

词汇表、向量化器和DataLoader

词汇表、向量化器和DataLoader是三个类,我们几乎在NLP实验的每个示例中都使用它们来执行一个关键的管道:将文本输入转换为向量化的小批(minibatch)。管道从预处理文本开始;每个数据点都是令牌的集合。在本例中,令牌碰巧是单词,但是正如将在实验4和实验6中看到的,令牌也可以是字符。以下小节中提供的三个类负责将每个令牌映射到一个整数,将此映射应用到每个数据点,以创建一个向量化表单,然后将向量化数据点分组到模型的一个小批处理中。

词汇表

从文本到向量化的minibatch处理的第一步是将每个令牌(tokens)映射到其自身的数字版本。标准的方法是在令牌(tokens)和整数之间有一个双向映射(可以反向映射)。在Python中,这只是两个字典。我们将这个词封装到词汇表类中,如Example 3-14所示。词汇表类不仅管理这个bijection—允许用户添加新的令牌并使索引自动递增—而且还处理一个名为UNK.UNK的特殊令牌,它代表“未知”令牌。通过使用UNK令牌,我们可以在测试期间处理训练中从未见过的令牌;例如,可能会遇到一个以前从未见过的单词。正如我们将在接下来的矢量化器中看到的,我们甚至将显式地限制词汇表中不经常出现的令牌,以便在我们的训练例程中有UNK令牌。这对于限制词汇表类使用的内存非常重要。预期的行为是调用add_token向词汇表中添加新的令牌,检索令牌索引时调用lookup_token,检索特定索引对应的令牌时调用lookup_index。

from argparse import Namespace
from collections import Counter
import json
import os
import re
import string

import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from tqdm import tqdm_notebook
class Vocabulary(object):
    """Class to process text and extract vocabulary for mapping"""

    def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
        """
        Args:
            token_to_idx (dict): a pre-existing map of tokens to indices
            add_unk (bool): a flag that indicates whether to add the UNK token
            unk_token (str): the UNK token to add into the Vocabulary
        """

        if token_to_idx is None:
            token_to_idx = {}
        self._token_to_idx = token_to_idx

        self._idx_to_token = {idx: token 
                              for token, idx in self._token_to_idx.items()}
        
        self._add_unk = add_unk
        self._unk_token = unk_token
        
        self.unk_index = -1
        if add_unk:
            self.unk_index = self.add_token(unk_token) 
        
        
    def to_serializable(self):
        """ returns a dictionary that can be serialized """
        return {'token_to_idx': self._token_to_idx, 
                'add_unk': self._add_unk, 
                'unk_token': self._unk_token}

    @classmethod
    def from_serializable(cls, contents):
        """ instantiates the Vocabulary from a serialized dictionary """
        return cls(**contents)

    def add_token(self, token):
        """Update mapping dicts based on the token.

        Args:
            token (str): the item to add into the Vocabulary
        Returns:
            index (int): the integer corresponding to the token
        """
        if token in self._token_to_idx:
            index = self._token_to_idx[token]
        else:
            index = len(self._token_to_idx)
            self._token_to_idx[token] = index
            self._idx_to_token[index] = token
        return index
    
    def add_many(self, tokens):
        """Add a list of tokens into the Vocabulary
        
        Args:
            tokens (list): a list of string tokens
        Returns:
            indices (list): a list of indices corresponding to the tokens
        """
        return [self.add_token(token) for token in tokens]

    def lookup_token(self, token):
        """Retrieve the index associated with the token 
          or the UNK index if token isn't present.
        
        Args:
            token (str): the token to look up 
        Returns:
            index (int): the index corresponding to the token
        Notes:
            `unk_index` needs to be >=0 (having been added into the Vocabulary) 
              for the UNK functionality 
        """
        if self.unk_index >= 0:
            return self._token_to_idx.get(token, self.unk_index)
        else:
            return self._token_to_idx[token]

    def lookup_index(self, index):
        """Return the token associated with the index
        
        Args: 
            index (int): the index to look up
        Returns:
            token (str): the token corresponding to the index
        Raises:
            KeyError: if the index is not in the Vocabulary
        """
        if index not in self._idx_to_token:
            raise KeyError("the index (%d) is not in the Vocabulary" % index)
        return self._idx_to_token[index]

    def __str__(self):
        return "<Vocabulary(size=%d)>" % len(self)

    def __len__(self):
        return len(self._token_to_idx)

向量化器

从文本数据集到向量化的小批处理的第二个阶段是迭代输入数据点的令牌,并将每个令牌转换为其整数形式。这个迭代的结果应该是一个向量。由于这个向量将与来自其他数据点的向量组合,因此有一个约束条件,即由矢量化器生成的向量应该始终具有相同的长度。

为了实现这些目标,Vectorizer类封装了评审词汇表,它将评审中的单词映射到整数。在Example 3-15中,矢量化器为from_dataframe方法使用Python的classmethod装饰器来指示实例化矢量化器的入口点。from_dataframe方法在panda dataframe的行上迭代,有两个目标。第一个目标是计算数据集中出现的所有令牌的频率。第二个目标是创建一个词汇表,该词汇表只使用与方法截止提供的关键字参数一样频繁的令牌。有效地,这种方法是找到所有至少出现截止时间的单词,并将它们添加到词汇表中。由于还将UNK令牌添加到词汇表中,因此在调用词汇表的lookup_令牌方法时,未添加的任何单词都将具有unk_index。

方法向量化封装了向量化器的核心功能。它以表示评审的字符串作为参数,并返回评审的向量化表示。在这个例子中,我们使用在第1章中介绍的折叠的onehot表示。这种表示方式创建了一个二进制向量——一个包含1和0的向量——它的长度等于词汇表的大小。二进制向量在与复习中的单词对应的位置有1。注意,这种表示有一些限制。首先,它是稀疏的——复习中惟一单词的数量总是远远少于词汇表中惟一单词的数量。第二,它抛弃了单词在评论中出现的顺序(“bag of words”)。在后面的实验中,将看到其他没有这些限制的方法。

class ReviewDataset(Dataset):
    def __init__(self, review_df, vectorizer):
        """
        Args:
            review_df (pandas.DataFrame): the dataset
            vectorizer (ReviewVectorizer): vectorizer instantiated from dataset
        """
        self.review_df = review_df
        self._vectorizer = vectorizer

        self.train_df = self.review_df[self.review_df.split=='train']
        self.train_size = len(self.train_df)

        self.val_df = self.review_df[self.review_df.split=='val']
        self.validation_size = len(self.val_df)

        self.test_df = self.review_df[self.review_df.split=='test']
        self.test_size = len(self.test_df)

        self._lookup_dict = {'train': (self.train_df, self.train_size),
                             'val': (self.val_df, self.validation_size),
                             'test': (self.test_df, self.test_size)}

        self.set_split('train')

    @classmethod
    def load_dataset_and_make_vectorizer(cls, review_csv):
        """Load dataset and make a new vectorizer from scratch
        
        Args:
            review_csv (str): location of the dataset
        Returns:
            an instance of ReviewDataset
        """
        review_df = pd.read_csv(review_csv)
        train_review_df = review_df[review_df.split=='train']
        return cls(review_df, ReviewVectorizer.from_dataframe(train_review_df))
    
    @classmethod
    def load_dataset_and_load_vectorizer(cls, review_csv, vectorizer_filepath):
        """Load dataset and the corresponding vectorizer. 
        Used in the case in the vectorizer has been cached for re-use
        
        Args:
            review_csv (str): location of the dataset
            vectorizer_filepath (str): location of the saved vectorizer
        Returns:
            an instance of ReviewDataset
        """
        review_df = pd.read_csv(review_csv)
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
        return cls(review_df, vectorizer)

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """a static method for loading the vectorizer from file
        
        Args:
            vectorizer_filepath (str): the location of the serialized vectorizer
        Returns:
            an instance of ReviewVectorizer
        """
        with open(vectorizer_filepath) as fp:
            return ReviewVectorizer.from_serializable(json.load(fp))

    def save_vectorizer(self, vectorizer_filepath):
        """saves the vectorizer to disk using json
        
        Args:
            vectorizer_filepath (str): the location to save the vectorizer
        """
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)

    def get_vectorizer(self):
        """ returns the vectorizer """
        return self._vectorizer

    def set_split(self, split="train"):
        """ selects the splits in the dataset using a column in the dataframe 
        
        Args:
            split (str): one of "train", "val", or "test"
        """
        self._target_split = split
        self._target_df, self._target_size = self._lookup_dict[split]

    def __len__(self):
        return self._target_size

    def __getitem__(self, index):
        """the primary entry point method for PyTorch datasets
        
        Args:
            index (int): the index to the data point 
        Returns:
            a dictionary holding the data point's features (x_data) and label (y_target)
        """
        row = self._target_df.iloc[index]

        review_vector = \
            self._vectorizer.vectorize(row.review)

        rating_index = \
            self._vectorizer.rating_vocab.lookup_token(row.rating)

        return {'x_data': review_vector,
                'y_target': rating_index}

    def get_num_batches(self, batch_size):
        """Given a batch size, return the number of batches in the dataset
        
        Args:
            batch_size (int)
        Returns:
            number of batches in the dataset
        """
        return len(self) // batch_size  
    
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"):
    """
    A generator function which wraps the PyTorch DataLoader. It will 
      ensure each tensor is on the write device location.
    """
    dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
                            shuffle=shuffle, drop_last=drop_last)

    for data_dict in dataloader:
        out_data_dict = {}
        for name, tensor in data_dict.items():
            out_data_dict[name] = data_dict[name].to(device)
        yield out_data_dict
class ReviewVectorizer(object):
    """ The Vectorizer which coordinates the Vocabularies and puts them to use"""
    def __init__(self, review_vocab, rating_vocab):
        """
        Args:
            review_vocab (Vocabulary): maps words to integers
            rating_vocab (Vocabulary): maps class labels to integers
        """
        self.review_vocab = review_vocab
        self.rating_vocab = rating_vocab

    def vectorize(self, review):
        """Create a collapsed one-hit vector for the review
        
        Args:
            review (str): the review 
        Returns:
            one_hot (np.ndarray): the collapsed one-hot encoding 
        """
        one_hot = np.zeros(len(self.review_vocab), dtype=np.float32)
        
        for token in review.split(" "):
            if token not in string.punctuation:
                one_hot[self.review_vocab.lookup_token(token)] = 1

        return one_hot

    @classmethod
    def from_dataframe(cls, review_df, cutoff=25):
        """Instantiate the vectorizer from the dataset dataframe
        
        Args:
            review_df (pandas.DataFrame): the review dataset
            cutoff (int): the parameter for frequency-based filtering
        Returns:
            an instance of the ReviewVectorizer
        """
        review_vocab = Vocabulary(add_unk=True)
        rating_vocab = Vocabulary(add_unk=False)
        
        # Add ratings
        for rating in sorted(set(review_df.rating)):
            rating_vocab.add_token(rating)

        # Add top words if count > provided count
        word_counts = Counter()
        for review in review_df.review:
            for word in review.split(" "):
                if word not in string.punctuation:
                    word_counts[word] += 1
               
        for word, count in word_counts.items():
            if count > cutoff:
                review_vocab.add_token(word)

        return cls(review_vocab, rating_vocab)

    @classmethod
    def from_serializable(cls, contents):
        """Instantiate a ReviewVectorizer from a serializable dictionary
        
        Args:
            contents (dict): the serializable dictionary
        Returns:
            an instance of the ReviewVectorizer class
        """
        review_vocab = Vocabulary.from_serializable(contents['review_vocab'])
        rating_vocab =  Vocabulary.from_serializable(contents['rating_vocab'])

        return cls(review_vocab=review_vocab, rating_vocab=rating_vocab)

    def to_serializable(self):
        """Create the serializable dictionary for caching
        
        Returns:
            contents (dict): the serializable dictionary
        """
        return {'review_vocab': self.review_vocab.to_serializable(),
                'rating_vocab': self.rating_vocab.to_serializable()}

DATALOADER

文本向矢量化的小批处理的最后一个阶段是对向矢量化的数据点进行分组。因为分组成小批是训练神经网络的重要部分,所以PyTorch提供了一个名为DataLoader的内置类来协调这个过程。DataLoader类通过提供一个PyTorch数据集(例如为本例定义的ReviewDataset)、一个batch_size和一些其他关键字参数来实例化。得到的对象是一个Python迭代器,它对数据集.19中提供的数据点进行分组和整理。在Example 3-16中,我们将DataLoader包装在generate_batch()函数中,该函数是一个生成器,用于方便地在CPU和GPU之间切换数据。

def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"):
    """
    A generator function which wraps the PyTorch DataLoader. It will
      ensure each tensor is on the write device location.
    """
    dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
                            shuffle=shuffle, drop_last=drop_last)

    for data_dict in dataloader:
        out_data_dict = {}
        for name, tensor in data_dict.items():
            out_data_dict[name] = data_dict[name].to(device)
        yield out_data_dict

感知机分类器

我们在这里使用的模型是我们在本实验开头展示的感知器的重新实现。ReviewClassifier继承自PyTorch的模块,并创建具有单个输出的单个线性层。因为这是一个二元分类设置(消极或积极的审查),所以这是一个适当的设置。最终的非线性函数为sigmoid函数。

我们对forward方法进行参数化,以允许可选地应用sigmoid函数。要理解其中的原因,首先需要指出的是,在二元分类任务中,二元交叉熵损失(torch.nn.BCELoss)是最合适的损失函数。它是用数学公式表示二进制概率的。然而,应用一个乙状结肠然后使用这个损失函数存在数值稳定性问题。为了给用户提供更稳定的快捷方式,PyTorch提供了BCEWithLogitsLoss。要使用这个损失函数,输出不应该应用sigmoid函数。因此,在默认情况下,我们不应用sigmoid。但是,如果分类器的用户希望得到一个概率值,则需要使用sigmoid,并将其作为选项保留。在Example 3-17的结果部分中,我们看到了以这种方式使用它的示例。

class ReviewClassifier(nn.Module):
    """ a simple perceptron based classifier """
    def __init__(self, num_features):
        """
        Args:
            num_features (int): the size of the input feature vector
        """
        super(ReviewClassifier, self).__init__()
        self.fc1 = nn.Linear(in_features=num_features, 
                             out_features=1)

    def forward(self, x_in, apply_sigmoid=False):
        """The forward pass of the classifier
        
        Args:
            x_in (torch.Tensor): an input data tensor. 
                x_in.shape should be (batch, num_features)
            apply_sigmoid (bool): a flag for the sigmoid activation
                should be false if used with the Cross Entropy losses
        Returns:
            the resulting tensor. tensor.shape should be (batch,)
        """
        y_out = self.fc1(x_in).squeeze()
        if apply_sigmoid:
            y_out = torch.sigmoid(y_out)
        return y_out

训练

在本节中,我们将概述训练例程的组件,以及它们如何与数据集和模型结合来调整模型参数并提高其性能。在其核心,训练例程负责实例化模型,在数据集上迭代,在给定数据作为输入时计算模型的输出,计算损失(模型的错误程度),并根据损失比例更新模型。虽然这可能看起来有很多细节需要管理,但是改变培训常规的地方并不多,因此,在您的深度学习开发过程中,这将成为一种习惯。为了帮助管理高层决策,我们使用args对象集中协调所有决策点

数据

from argparse import Namespace

args = Namespace(
    # Data and Path information
    frequency_cutoff=25,
    model_state_file='model.pth',
    review_csv='reviews_with_splits_lite.csv',
    save_dir='model_storage/ch3/yelp/',
    vectorizer_file='vectorizer.json',
    # No Model hyper parameters
    # Training hyper parameters
    batch_size=128,
    early_stopping_criteria=5,
    learning_rate=0.001,
    num_epochs=100,
    seed=1337,
    # ...  runtime options omitted for space
)

在本节的其余部分中,我们首先描述训练状态,这是一个用于跟踪关于训练过程的信息的小字典。当跟踪关于训练例程的更多细节时,这个字典将会增长,如果选择这样做,可以系统化它,但是在下一个示例中给出的字典是您将在模型训练期间跟踪的基本信息集。在描述了训练状态之后,我们将概述为要执行的模型训练实例化的对象集。这包括模型本身、数据集、优化器和损失函数。在其他示例和补充材料中,我们包含了其他组件,但为了简单起见,我们不在文本中列出它们。最后,我们用训练循环本身结束本节,并演示标准PyTorch优化模式。

模型结构定义

import torch.optim as optim
import pandas as pd
def make_train_state(args):
    return {'epoch_index': 0,
            'train_loss': [],
            'train_acc': [],
            'val_loss': [],
            'val_acc': [],
            'test_loss': -1,
            'test_acc': -1}
train_state = make_train_state(args)
# print(args)
if not torch.cuda.is_available():
    args.cuda = False
args.device = torch.device("cuda" if args.cuda else "cpu")

# dataset and vectorizer
dataset = ReviewDataset.load_dataset_and_make_vectorizer(args.review_csv)
vectorizer = dataset.get_vectorizer()

# model
classifier = ReviewClassifier(num_features=len(vectorizer.review_vocab))
classifier = classifier.to(args.device)

# loss and optimizer
loss_func = nn.BCEWithLogitsLoss()
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)

训练

def make_train_state(args):
    return {'stop_early': False,
            'early_stopping_step': 0,
            'early_stopping_best_val': 1e8,
            'learning_rate': args.learning_rate,
            'epoch_index': 0,
            'train_loss': [],
            'train_acc': [],
            'val_loss': [],
            'val_acc': [],
            'test_loss': -1,
            'test_acc': -1,
            'model_filename': args.model_state_file}

def update_train_state(args, model, train_state):
    """Handle the training state updates.

    Components:
     - Early Stopping: Prevent overfitting.
     - Model Checkpoint: Model is saved if the model is better

    :param args: main arguments
    :param model: model to train
    :param train_state: a dictionary representing the training state values
    :returns:
        a new train_state
    """

    # Save one model at least
    if train_state['epoch_index'] == 0:
        torch.save(model.state_dict(), train_state['model_filename'])
        train_state['stop_early'] = False

    # Save model if performance improved
    elif train_state['epoch_index'] >= 1:
        loss_tm1, loss_t = train_state['val_loss'][-2:]

        # If loss worsened
        if loss_t >= train_state['early_stopping_best_val']:
            # Update step
            train_state['early_stopping_step'] += 1
        # Loss decreased
        else:
            # Save the best model
            if loss_t < train_state['early_stopping_best_val']:
                torch.save(model.state_dict(), train_state['model_filename'])

            # Reset early stopping step
            train_state['early_stopping_step'] = 0

        # Stop early ?
        train_state['stop_early'] = \
            train_state['early_stopping_step'] >= args.early_stopping_criteria

    return train_state

def compute_accuracy(y_pred, y_target):
    y_target = y_target.cpu()
    y_pred_indices = (torch.sigmoid(y_pred)>0.5).cpu().long()#.max(dim=1)[1]
    n_correct = torch.eq(y_pred_indices, y_target).sum().item()
    return n_correct / len(y_pred_indices) * 100

def make_train_state(args):
    return {'stop_early': False,
            'early_stopping_step': 0,
            'early_stopping_best_val': 1e8,
            'learning_rate': args.learning_rate,
            'epoch_index': 0,
            'train_loss': [],
            'train_acc': [],
            'val_loss': [],
            'val_acc': [],
            'test_loss': -1,
            'test_acc': -1,
            'model_filename': args.model_state_file}

def update_train_state(args, model, train_state):
    """Handle the training state updates.

    Components:
     - Early Stopping: Prevent overfitting.
     - Model Checkpoint: Model is saved if the model is better

    :param args: main arguments
    :param model: model to train
    :param train_state: a dictionary representing the training state values
    :returns:
        a new train_state
    """

    # Save one model at least
    if train_state['epoch_index'] == 0:
        torch.save(model.state_dict(), train_state['model_filename'])
        train_state['stop_early'] = False

    # Save model if performance improved
    elif train_state['epoch_index'] >= 1:
        loss_tm1, loss_t = train_state['val_loss'][-2:]

        # If loss worsened
        if loss_t >= train_state['early_stopping_best_val']:
            # Update step
            train_state['early_stopping_step'] += 1
        # Loss decreased
        else:
            # Save the best model
            if loss_t < train_state['early_stopping_best_val']:
                torch.save(model.state_dict(), train_state['model_filename'])

            # Reset early stopping step
            train_state['early_stopping_step'] = 0

        # Stop early ?
        train_state['stop_early'] = \
            train_state['early_stopping_step'] >= args.early_stopping_criteria

    return train_state
def set_seed_everywhere(seed, cuda):
    np.random.seed(seed)
    torch.manual_seed(seed)
    if cuda:
        torch.cuda.manual_seed_all(seed)

def handle_dirs(dirpath):
    if not os.path.exists(dirpath):
        os.makedirs(dirpath)
def compute_accuracy(y_pred, y_target):
    y_target = y_target.cpu()
    y_pred_indices = (torch.sigmoid(y_pred)>0.5).cpu().long()#.max(dim=1)[1]
    n_correct = torch.eq(y_pred_indices, y_target).sum().item()
    return n_correct / len(y_pred_indices) * 100

args = Namespace(
    # Data and Path information
    frequency_cutoff=25,
    model_state_file='model.pth',
    review_csv='reviews_with_splits_lite.csv',
    # review_csv='data/yelp/reviews_with_splits_full.csv',
    save_dir='model_storage/ch3/yelp/',
    vectorizer_file='vectorizer.json',
    # No Model hyper parameters
    # Training hyper parameters
    batch_size=128,
    early_stopping_criteria=5,
    learning_rate=0.001,
    num_epochs=10,
    seed=1337,
    # Runtime options
    catch_keyboard_interrupt=True,
    cuda=True,
    expand_filepaths_to_save_dir=True,
    reload_from_files=False,
)

if args.expand_filepaths_to_save_dir:
    args.vectorizer_file = os.path.join(args.save_dir,
                                        args.vectorizer_file)

    args.model_state_file = os.path.join(args.save_dir,
                                         args.model_state_file)
    
    print("Expanded filepaths: ")
    print("\t{}".format(args.vectorizer_file))
    print("\t{}".format(args.model_state_file))
    
# Check CUDA
if not torch.cuda.is_available():
    args.cuda = False

print("Using CUDA: {}".format(args.cuda))

args.device = torch.device("cuda" if args.cuda else "cpu")

# Set seed for reproducibility
set_seed_everywhere(args.seed, args.cuda)

# handle dirs
handle_dirs(args.save_dir)

if args.reload_from_files:
    # training from a checkpoint
    print("Loading dataset and vectorizer")
    dataset = ReviewDataset.load_dataset_and_load_vectorizer(args.review_csv,
                                                            args.vectorizer_file)
else:
    print("Loading dataset and creating vectorizer")
    # create dataset and vectorizer
    dataset = ReviewDataset.load_dataset_and_make_vectorizer(args.review_csv)
    dataset.save_vectorizer(args.vectorizer_file)    
vectorizer = dataset.get_vectorizer()

classifier = ReviewClassifier(num_features=len(vectorizer.review_vocab))

classifier = classifier.to(args.device)

loss_func = nn.BCEWithLogitsLoss()
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
                                                 mode='min', factor=0.5,
                                                 patience=1)

train_state = make_train_state(args)

epoch_bar = tqdm_notebook(desc='training routine', 
                          total=args.num_epochs,
                          position=0)

dataset.set_split('train')
train_bar = tqdm_notebook(desc='split=train',
                          total=dataset.get_num_batches(args.batch_size), 
                          position=1, 
                          leave=True)
dataset.set_split('val')
val_bar = tqdm_notebook(desc='split=val',
                        total=dataset.get_num_batches(args.batch_size), 
                        position=1, 
                        leave=True)

try:
    for epoch_index in range(args.num_epochs):
        train_state['epoch_index'] = epoch_index

        # Iterate over training dataset

        # setup: batch generator, set loss and acc to 0, set train mode on
        dataset.set_split('train')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        running_loss = 0.0
        running_acc = 0.0
        classifier.train()

        for batch_index, batch_dict in enumerate(batch_generator):
            # the training routine is these 5 steps:

            # --------------------------------------
            # step 1. zero the gradients
            optimizer.zero_grad()

            # step 2. compute the output
            y_pred = classifier(x_in=batch_dict['x_data'].float())

            # step 3. compute the loss
            loss = loss_func(y_pred, batch_dict['y_target'].float())
            loss_t = loss.item()
            running_loss += (loss_t - running_loss) / (batch_index + 1)

            # step 4. use loss to produce gradients
            loss.backward()

            # step 5. use optimizer to take gradient step
            optimizer.step()
            # -----------------------------------------
            # compute the accuracy
            acc_t = compute_accuracy(y_pred, batch_dict['y_target'])
            running_acc += (acc_t - running_acc) / (batch_index + 1)

            # update bar
            train_bar.set_postfix(loss=running_loss, 
                                  acc=running_acc, 
                                  epoch=epoch_index)
            train_bar.update()

        train_state['train_loss'].append(running_loss)
        train_state['train_acc'].append(running_acc)

        # Iterate over val dataset

        # setup: batch generator, set loss and acc to 0; set eval mode on
        dataset.set_split('val')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        running_loss = 0.
        running_acc = 0.
        classifier.eval()

        for batch_index, batch_dict in enumerate(batch_generator):

            # compute the output
            y_pred = classifier(x_in=batch_dict['x_data'].float())

            # step 3. compute the loss
            loss = loss_func(y_pred, batch_dict['y_target'].float())
            loss_t = loss.item()
            running_loss += (loss_t - running_loss) / (batch_index + 1)

            # compute the accuracy
            acc_t = compute_accuracy(y_pred, batch_dict['y_target'])
            running_acc += (acc_t - running_acc) / (batch_index + 1)
            
            val_bar.set_postfix(loss=running_loss, 
                                acc=running_acc, 
                                epoch=epoch_index)
            val_bar.update()

        train_state['val_loss'].append(running_loss)
        train_state['val_acc'].append(running_acc)

        train_state = update_train_state(args=args, model=classifier,
                                         train_state=train_state)

        scheduler.step(train_state['val_loss'][-1])

        train_bar.n = 0
        val_bar.n = 0
        epoch_bar.update()

        if train_state['stop_early']:
            break

        train_bar.n = 0
        val_bar.n = 0
        epoch_bar.update()
except KeyboardInterrupt:
    print("Exiting loop")
    
    # compute the loss & accuracy on the test set using the best available model

评估

classifier.load_state_dict(torch.load(train_state['model_filename']))
classifier = classifier.to(args.device)

dataset.set_split('test')
batch_generator = generate_batches(dataset, 
                                   batch_size=args.batch_size, 
                                   device=args.device)
running_loss = 0.
running_acc = 0.
classifier.eval()

for batch_index, batch_dict in enumerate(batch_generator):
    # compute the output
    y_pred = classifier(x_in=batch_dict['x_data'].float())

    # compute the loss
    loss = loss_func(y_pred, batch_dict['y_target'].float())
    loss_t = loss.item()
    running_loss += (loss_t - running_loss) / (batch_index + 1)

    # compute the accuracy
    acc_t = compute_accuracy(y_pred, batch_dict['y_target'])
    running_acc += (acc_t - running_acc) / (batch_index + 1)

train_state['test_loss'] = running_loss
train_state['test_acc'] = running_acc

print("Test loss: {:.3f}".format(train_state['test_loss']))
print("Test Accuracy: {:.2f}".format(train_state['test_acc']))
Test loss: 0.217
Test Accuracy: 91.85

评估的另一种方法

评价模型的另一种方法是对新数据进行推断,并对模型是否有效进行定性判断。

def predict_rating(review, classifier, vectorizer,
                   decision_threshold=0.5):
    """Predict the rating of a review

    Args:
        review (str): the text of the review
        classifier (ReviewClassifier): the trained model
        vectorizer (ReviewVectorizer): the corresponding vectorizer
        decision_threshold (float): The numerical boundary which
            separates the rating classes
    """

    review = preprocess_text(review)
    vectorized_review = torch.tensor(vectorizer.vectorize(review))
    result = classifier(vectorized_review.view(1, -1))

    probability_value = F.sigmoid(result).item()

    index =  1
    if probability_value < decision_threshold:
        index = 0

    return vectorizer.rating_vocab.lookup_index(index)

test_review = "this is a pretty awesome book"
prediction = predict_rating(test_review, classifier, vectorizer)
print("{} -> {}".format(test_review, prediction))
this is a pretty awesome book -> positive

观察模型权重

最后,了解模型在完成训练后是否表现良好的最后一种方法是检查权重,并对权重是否正确做出定性判断。如下所示,使用感知器和压缩的onehot编码,这是一种相当简单的方法,因为每个模型的权重与词汇表中的单词完全对应。

# Sort weights
fc1_weights = classifier.fc1.weight.detach()[0]
_, indices = torch.sort(fc1_weights, dim=0, descending=True)
indices = indices.numpy().tolist()

# Top 20 words
print("Influential words in Positive Reviews:")
print("--------------------------------------")
for i in range(20):
    print(vectorizer.review_vocab.lookup_index(indices[i]))
Influential words in Positive Reviews:
--------------------------------------
delicious
amazing
great
fantastic
vegas
awesome
excellent
perfect
love
pleasantly
yummy
yum
wonderful
best
ngreat
favorite
reasonable
solid
loved
glad
# Top 20 negative words
print("Influential words in Negative Reviews:")
print("--------------------------------------")
indices.reverse()
for i in range(20):
    print(vectorizer.review_vocab.lookup_index(indices[i]))
Influential words in Negative Reviews:
--------------------------------------
worst
mediocre
bland
horrible
rude
terrible
meh
awful
overpriced
tasteless
disgusting
disappointing
poorly
poor
ok
not
dirty
disappointment
unfriendly
elsewhere
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值