「PyTorch自然语言处理系列」3. 神经网络的基本组件(下)

来源 |  Natural Language Processing with PyTorch

作者 | Rao,McMahan

译者 | Liangchu

校对 | gongyouliu

编辑 | auroral-L

全文共11343字,预计阅读时间90分钟。

上下拉动翻看整个目录

1. 感知机:最简单的神经网络

2. 激活函数

    2.1 Sigmoid

    2.2 Tanh

    2.3 ReLU

    2.4 Softmax

3. 损失函数

    3.1 均方误差损失

    3.2 分类交叉熵损失

    3.3 二元交叉熵损失

4. 深入有监督训练

    4.1 构造玩具数据

      4.1.1 选择模型

      4.1.2 转换概率到具体类

      4.1.3 选择损失函数

      4.1.4 选择优化器

    4.2 放到一起:基于梯度的监督学习

5. 补充训练概念

    5.1 正确度量模型表现:评估度量

    5.2 正确度量模型表现:分割数据集

    5.3 知道什么时候停止训练

    5.4 找到正确的超参数

    5.5 正则化

6. 示例:分类餐馆评论的情感

    6.1 Yelp 评论数据集

    6.2 理解 PyTorch 的数据集表示

    6.3 Vocabulary,Vectorizer和DataLoader

      6.3.1 Vocabulary

      6.3.2 Vectorizer

      6.3.3 DataLoader

    6.4 感知机分类器

    6.5 训练例程

      6.5.1 设置阶段来启动训练

      6.5.2 训练循环

  6.6 评估(evaluation),推理(inference)和检查(inspection)

      6.6.1 在测试数据上评估

      6.6.2 推理和分类新的数据点

      6.6.3 检查模型权重

7. 总结

    参考资料

6. 示例:分类餐馆评论的情感

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

在本书每个示例的开头中,我们将首先描述需要使用的数据集和任务。在本例中,我们使用 Yelp 数据集,它将评论与它们的情感标签(positive或negative)配对。此外,我们还描述了一些数据集操作,我们使用这些步骤来清洗数据集并将其划分为训练集、验证集和测试集。

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

下面的部分描述感知器分类器及其训练例程。本书中每个示例的训练例程基本保持不变,但是我们会在这个例子中更详细地讨论它。所以我得重述一遍:希望你能使用这个例子作为学习未来训练例程的参考。我们通过讨论结果来总结这个例子,并看看模型学习到了什么。

6.1 Yelp 评论数据集

2015 年,Yelp 举办了一场竞赛,要求参与者根据评论预测一家餐厅的评级。同年,Zhang, Zhao,和 Lecun(2015)将 1 星和 2 星评级转换为“negative”情绪类,将 3 星和 4 星评级转换为“positive”情绪类,从而简化了数据集。他们还将该数据集分为 56 万个训练样本和 3.8 万个测试样本。在本例中,我们使用简化的Yelp数据集,注意有两个细微差别。在这个数据集的其余部分中,我们将描述清洗数据并导出最终数据集的过程。然后,我们将概述利用 PyTorch 的Dataset类的实现。

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

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

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

示例 3-12:分割原始数据集以创建训练、验证和测试集

# Splitting the subset by rating to create 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()) 
 
# Create split data 
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) 
 
final_reviews = pd.DataFrame(final_list)

除了创建一个有三个分区用于训练、验证和测试的子集之外,我们还最低程度地清洗数据,这是通过在标点符号周围添加空格,以及移除对所有分割集合(即训练、测试和验证集合)都不是标点符号的无关符号来实现的,如下例(3-13)所示:

示例 3-13:最低程度清洗数据

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)
6.2 理解 PyTorch 的数据集表示

下例(3-14)中给出的ReviewDataset类假设数据集已被最低程度地清洗并分割为了三个部分。尤其要注意,数据集假定它可以基于空格分隔评论,以便获得评论中的token列表。再者,它假定数据有一个注释用于将数据拆分为它所属的部分。需要注意的是,我们使用 Python 的classmethod装饰器为这个数据集类指定了入口点方法。我们在整本书中都遵循这个模式。

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

示例 3-14:一个用于Yelp评论数据集的Pytorch数据集类:ReviewDataset

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
6.3 Vocabulary,Vectorizer和DataLoader

Vocabulary,Vectorizer和DataLoader是三个类,我们几乎在本书的每个示例中都使用它们来执行一个关键的管道(pipeline):将文本输入转换为向量化的minibatch。pipeline从预处理文本开始,每个数据点都是token的集合。在本例中,token正好表示单词(word),但是正如你将在第四章和第六章中所见,token也可能是字符(character)。在下面的小节中描述的三个类分别负责:将每个token映射到一个整数;将此映射应用到每个数据点以创建一个向量化列表;将向量化的数据点分组到模型的一个minibatch中。

6.3.1 Vocabulary

从文本到向量化的小批量处理的第一步是将每个token映射到其自身对应的数字。标准的方法是在标记(tokens)和整数之间创建一个双向单射(bijection)——这个映射是可逆的。在 Python 中,这只是两个字典。我们将这个双向单射封装到Vocabulary类中,正如下例(3-15)所示。Vocabulary类不仅管理这个bijection(从而允许用户添加自增索引的新token),而且还处理一个名为UNK的特殊token,它代表未知token,即unknown token。通过使用UNK标记,我们可以在测试期间处理训练中从未出现过的标记(例如,你可能会遇到一个在训练集中没见过的单词)。正如我们将在下面的Vectorizer中看到的,我们甚至会显式地限制词汇表中不经常出现的token,以便在我们的训练例程中存在UNK标记,这对于限制Vocabulary类所占内存非常重要。预期的行为是:调用add_token()向Vocabulary中添加新的token,检索标记索引时调用lookup_token(),而检索特定索引对应的标记时调用lookup_index()。

示例 3-15:Vocabulary类维护机器学习pipeline其余部分所需的标记到整数的映射

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 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.add_unk: 
            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)
6.3.2 Vectorizer

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

为了实现上述目标,Vectorizer类封装了评论Vocabulary,它将评论中的单词映射到整数。在下例(3-16)中,Vectorizer为from_dataframe()方法使用 Python 的@classmethod装饰器来指示实例化Vectorizer的入口点。from_dataframe()方法带着两个目标在 Pandas Dataframe的行上迭代:第一个目标是计算数据集中所有token出现的频率;第二个目标是创建一个Vocabulary,它只使用与方法提供的关键参数cutoff一样频繁的token。这种方法能有效找到所有至少出现cutoff次数的单词,并将它们添加到Vocabulary中。由于还将UNK标记添加到了Vocabulary中,因此在调用Vocabulary的lookup_token()方法时,任何未添加的单词都将具有unk_index。

方法vectorizer()封装了Vectorizer的核心功能。它以表示评论的字符串作为参数,并返回评论的的向量化表示。在本例中,我们使用在第一章中介绍的折叠的独热表示。这种表示方式创建了一个二进制向量——一个包含 1 和 0 的向量——它的长度等于Vocabulary的大小。二进制向量在与评论中出现的单词对应的位置有 1值。注意,这种表示法是比较有限的。首先,它是稀疏的——评论中唯一单词的数量总是远远少于Vocabulary中唯一单词的数量。第二,它忽略了单词在评论中出现的顺序(词袋法bag of words)。在后面的章节中,你将看到其他不受这些限制的方法。

示例 3-16:Vectorizer类将文本转为数值向量

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): 
        """Intantiate 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()}
6.3.3 DataLoader

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

示例 3-17:从数据集产生minibatch

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
6.4 感知机分类器

我们在本例中使用的模型是我们在本章开头展示的Perceptron分类器的再实现。ReviewClassifier继承自 PyTorch 的Module,并创建具有单个输出的单个Linear层。由于这是一个二分类情况(negative或positive的评论),所以这是一个恰当的设置。最终的非线性函数为 sigmoid 函数。

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

示例 3-18:用于分类Yelp评论的感知机分类器

import torch.nn as nn 
import torch.nn.functional as F 
 
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 = F.sigmoid(y_out) 
        return y_out
6.5 训练例程

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

示例 3-19:用于基于感知器的Yelp评论分类器的超参数和程序选项

from argparse import Namespace 
 
args = Namespace( 
    # Data and path information 
    frequency_cutoff=25, 
    model_state_file='model.pth', 
    review_csv='data/yelp/reviews_with_splits_lite.csv', 
    save_dir='model_storage/ch3/yelp/', 
    vectorizer_file='vectorizer.json', 
    # No model hyperparameters 
    # Training hyperparameters 
    batch_size=128, 
    early_stopping_criteria=5, 
    learning_rate=0.001, 
    num_epochs=100, 
    seed=1337, 
    # Runtime options omitted for space 
)

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

6.5.1 设置阶段来启动训练

下例(3-20)展示了我们为这个示例实例化的训练组成部分。第一项是初始训练状态,该函数接受args object作为参数,以便训练状态能够处理复杂信息,但是在本书中,我们并不展示这些复杂性。我们建议你参阅补充材料,看看在训练状态下还可以使用哪些额外的东西。这里显示的最小集包括训练损失(training loss)、训练精度(training accuracy)、验证损失(validation loss)和验证精度(validation accuracy)的周期索引和列表。它还包括测试损失(test loss)和测试精度(test accuracy)两个字段。

接下来要实例化的两项是数据集和模型。在本例中以及本书的其他示例中,我们设计数据集以负责实例化向量化器。在补充材料中,数据集实例化嵌套在一个if语句中,该if语句允许加载以前实例化的向量化器,或者一个新的将保存到磁盘的实例化。重要的是,通过协调用户的意愿(通过args.cuda)和检查 GPU 设备是否确实可用来将模型移动到正确的设备。目标设备用于核心训练循环中的generate_batch()函数调用,以使得数据和模型位于相同的设备位置。

初始实例化中的最后两项是损失函数和优化器。本例中使用的损失函数是BCEWithLogitsLoss()。(正如在“感知器分类器”一节中所提到的,二分类最合适的损失函数是二元交叉熵损失,并且将BCEWithLogitsLoss()函数与不将sigmoid函数应用于输出的模型配对会比将BCELoss()函数与将sigmoid函数应用于输出的模型配对更稳定)。我们使用的优化器是 Adam 优化器。一般来说,Adam 与其他优化器相比竞争力很强,在撰写本文时,还没有令人信服的证据表明可以使用任何其他优化器来替代 Adam。我们鼓励你通过尝试其他优化器并比较效果来验证这一点:

示例 3-20:实例化数据集,模型,损失,优化器和训练状态

import torch.optim as optim 
 
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) 
 
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)
6.5.2 训练循环

训练循环使用来自初始实例化的对象来更新模型参数,以便随着时间的推移进行改进。更具体地说,训练循环由两个循环组成:一个内循环覆盖数据集中的小批量,另一个外循环重复内循环若干次。在内循环中,计算每个小批量的损失,并使用优化器更新模型参数。下例(3-21)展示了这部分代码,然后是对代码的详细解释:

示例 3-21:粗糙的训练循环

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 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_batch = loss.item() 
        running_loss += (loss_batch - 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_batch = compute_accuracy(y_pred, batch_dict['y_target']) 
        running_acc += (acc_batch - running_acc) / (batch_index + 1) 
 
    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): 
 
        # step 1\. compute the output 
        y_pred = classifier(x_in=batch_dict['x_data'].float()) 
 
        # step 2\. compute the loss 
        loss = loss_func(y_pred, batch_dict['y_target'].float()) 
        loss_batch = loss.item() 
        running_loss += (loss_batch - running_loss) / (batch_index + 1) 
 
        # step 3\. compute the accuracy 
        acc_batch = compute_accuracy(y_pred, batch_dict['y_target']) 
        running_acc += (acc_batch - running_acc) / (batch_index + 1) 
 
    train_state['val_loss'].append(running_loss) 
    train_state['val_acc'].append(running_acc)

在第一行中,我们使用for循环,它的范围跨越各个epochs。epochs的数量是一个可以设置的超参数,它控制训练例程应该对数据集进行多少次处理。在实践中,你应该使用类似于早停法early stopping标准的东西来在循环结束之前终止它。在补充材料中,我们展示了如何做到这一点。

在for循环的顶部,有几个例程定义和实例化。首先设置训练状态的周期索引,然后设置数据集的分割(首先是"train",然后是"val"——当我们想在周期结束时测量模型效果;最后是"test"——当我们想评估模型的最终效果时)。鉴于我们是如何构造数据集的,应该总是在调用generate_batch()之前设置这个分割。创建batch_generator之后,将实例化两个浮点数,以跟踪批batch之间的损失和准确性。有关这里使用的“运行平均公式”的更多细节,请参阅 Wikipedia 的moving average页面。最后,调用分类器的.train()方法,表示模型处于“训练模式”,并且模型参数是可变的,也支持像dropout这样的正则化机制(具体见“Regularizing MLPs: Weight Regularization and Structural Regularization (or Dropout)”)。

训练循环的下一部分是迭代batch_generator中的训练batch,并执行更新模型参数的基本操作。在每个batch迭代中,首先使用optimizer.zero_grad()方法重置优化器的梯度,然后从模型中计算输出。接下来,损失函数用于计算模型输出与监督目标(真实类标签)之间的损失。在此之后,对损失对象(而不是损失函数对象)调用loss.backward()方法,使得梯度传播到每个参数。最后,优化器使用这些传播的梯度来使用optimizer.step()方法执行参数更新。这五个步骤是梯度下降的基本步骤。除此之外,还有一些用于簿记和跟踪的额外操作。具体来说,损失和精度(作为常规 Python 变量存储)可以计算得到,然后用于更新运行损失和运行精度变量。

在训练分割batch的内部循环之后,有更多簿记和实例化操作。首先使用最终损失和精度值更新训练状态,然后创建一个新的批量生成器、运行损失和运行精度。验证数据的循环几乎与训练数据相同,因此重用相同的变量。然而有一个主要区别:调用分类器的.eval()方法,它执行与分类器的.train()方法相反的操作。eval()方法使模型参数不可变,且禁用dropout。eval模式还禁止计算梯度的损失和由梯度到参数的传播。这点是非常重要的,因为我们不希望模型根据验证数据调整参数,相反,我们希望这些数据作为模型执行情况的度量。如果其衡量效果在训练数据和验证数据之间存在着很大的差异,那么这个模型很可能过拟合(overfit)了,我们应该对模型或训练例程做些调整(比如使用早停法,我们会在这个例子的补充notebook中演示这一点)。

在对验证数据进行迭代并保存由此产生的验证损失和精度值之后,外部for循环就完成了。我们在本书中实现的每个训练例程都将遵循非常相似的设计模式。事实上,所有梯度下降算法都遵循相似的设计模式。在你习惯了从头开始编写这个循环之后,你将会学会如何使用它执行梯度下降。

6.6 评估(evaluation),推理(inference)和检查(inspection)

在有了一个训练过的模型之后,接下来就是:要么评估它是如何处理一些保留下来的数据的,要么使用它对新数据进行推理,要么检查模型的权重来看看它学到了什么。在本节中,我们将展示所有三个步骤。

6.6.1 在测试数据上评估

为了评估保留测试集上的数据,代码与我们在上一个示例中看到的训练例程中的验证循环完全相同,但有一个细微的区别:分割设置为'test'而不是'val'。数据集的两个分区之间的区别在于,测试集应该尽可能少地运行。每当在测试集上运行一个训练过的模型,做出一个新的模型决策(例如改变layer的大小),并在测试集上重新衡量新的再训练模型时,你都将建模决策偏向于测试数据。换句话说,若你足够频繁地重复这个过程,那么测试集作为真正交付数据的精确度量将变得毫无意义。下例(3-22)对此进行了更深入的研究:

示例 3-22:测试集评估

Input[0] 
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_batch = loss.item() 
    running_loss += (loss_batch - running_loss) / (batch_index + 1) 
 
    # compute the accuracy 
    acc_batch = compute_accuracy(y_pred, batch_dict['y_target']) 
    running_acc += (acc_batch - running_acc) / (batch_index + 1) 
 
train_state['test_loss'] = running_loss 
train_state['test_acc'] = running_acc 
 
Input[1] 
print("Test loss: {:.3f}".format(train_state['test_loss'])) 
print("Test Accuracy: {:.2f}".format(train_state['test_acc'])) 
 
Output[1] 
Test loss: 0.297 
Test Accuracy: 90.55
6.6.2 推理和分类新的数据点

评价模型的另一种方法是对新数据进行推理,并对模型是否有效进行定性判断。我们可以在下例(3-23)中看到这一点:

示例 3-23:打印样本评论的预测

Input[0] 
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) 
 
Output[0] 
this is a pretty awesome book -> positive
6.6.3 检查模型权重

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

示例 3-24:检查分类器学到了什么

Input[0] 
# 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])) 
 
Output[0] 
Influential words in Positive Reviews: 
-------------------------------------- 
great 
awesome 
amazing 
love 
friendly 
delicious 
best 
excellent 
definitely 
perfect 
fantastic 
wonderful 
vegas 
favorite 
loved 
yummy 
fresh 
reasonable 
always 
recommend 
 
Input[1] 
# 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])) 
 
Output[1] 
Influential words in Negative Reviews: 
-------------------------------------- 
worst 
horrible 
mediocre 
terrible 
not 
rude 
bland 
disgusting 
dirty 
awful 
poor 
disappointing 
ok 
no 
overpriced 
sorry 
nothing 
meh 
manager 
gross

7. 总结

在这一章中,你学习了有监督神经网络训练的一些基本概念:

• 最简单的神经网络模型——感知器

• 基本概念如激活函数、损失函数及其不同种类

• 在一个玩具示例的上下文中,训练循环、批大小和周期

• generalization泛化(普遍性)的意义,以及使用训练/测试/验证数据来衡量泛化效果的良好实践

• 早停法等准则用来确定训练算法的端终点或收敛性

• 什么是超参数,以及超参数的一些例子,如批大小,学习率等等

• 如何使用 PyTorch 实现的感知器模型对英文 Yelp 餐厅评论进行分类,如何通过检验权重来解释该模型

在第四章中,我们将介绍前馈网络(feed-forward networks),首先在不起眼的感知器模型的基础上,通过纵向和横向叠加,从而得到多层感知器(multilayer perceptron)模型。我们还会研究新的一种基于卷积运算来捕获语言子结构的前馈网络。

参考资料

Zhang, Xiang, et al. (2015). “Character-Level Convolutional Networks for Text Classification.” Proceedings of NIPS

0329bd48aaeefc46f088996762f644c3.png

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

数据与智能

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

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

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

打赏作者

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

抵扣说明:

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

余额充值