Bootstrap

昇思25天学习打卡营第10天 | 自然语言处理:RNN实现情感分类

1. RNN实现情感分类

1.2 概述

情感分类是自然语言处理中的经典任务,是典型的分类问题。本节使用MindSpore实现一个基于RNN网络的情感分类模型,实现如下的效果:

输入: This film is terrible
正确标签: Negative(负面)
预测标签: Negative

输入: This film is great
正确标签: Positive(正面)
预测标签: Positive

1.3 数据准备

本节使用情感分类的经典数据集IMDB影评数据集,数据集包含Positive和Negative两类,下面为其样例:

ReviewLabel
“Quitting” may be as much about exiting a pre-ordained identity as about drug withdrawal. As a rural guy coming to Beijing, class and success must have struck this young artist face on as an appeal to separate from his roots and far surpass his peasant parents’ acting success. Troubles arise, however, when the new man is too new, when it demands too big a departure from family, history, nature, and personal identity. The ensuing splits, and confusion between the imaginary and the real and the dissonance between the ordinary and the heroic are the stuff of a gut check on the one hand or a complete escape from self on the other.Negative
This movie is amazing because the fact that the real people portray themselves and their real life experience and do such a good job it’s like they’re almost living the past over again. Jia Hongsheng plays himself an actor who quit everything except music and drugs struggling with depression and searching for the meaning of life while being angry at everyone especially the people who care for him most.Positive

此外,需要使用预训练词向量对自然语言单词进行编码,以获取文本的语义特征,本节选取Glove词向量作为Embedding。

1.3.1 数据下载模块

为了方便数据集和预训练词向量的下载,首先设计数据下载模块,实现可视化下载流程,并保存至指定路径。数据下载模块使用requests库进行http请求,并通过tqdm库对下载百分比进行可视化。此外针对下载安全性,使用IO的方式下载临时文件,而后保存至指定的路径并返回。

tqdmrequests库需手动安装,命令如下:pip install tqdm requests

# 导入os模块,提供了一种方便的方式来使用操作系统相关的功能。
import os
# 导入shutil模块,它提供了许多文件操作的高级函数。
import shutil
# 导入requests模块,用于发起HTTP请求。
import requests
# 导入tempfile模块,用于创建临时文件和目录。
import tempfile
# 从tqdm模块导入tqdm函数,用于在循环中显示进度条。
from tqdm import tqdm
# 从typing模块导入IO,用于类型注解,表示输入输出流。
from typing import IO
# 从pathlib模块导入Path,用于进行路径操作。
from pathlib import Path

# 设置缓存目录为用户主目录下的`.mindspore_examples`文件夹。
cache_dir = Path.home() / '.mindspore_examples'

def http_get(url: str, temp_file: IO):
    """
    使用requests库从指定的URL下载数据,
    并将数据写入到临时文件中,
    同时使用tqdm库显示下载进度。
    """
    # 发起一个HTTP GET请求,并设置stream=True以支持分块下载。
    req = requests.get(url, stream=True)
    # 从响应头中获取内容长度。
    content_length = req.headers.get('Content-Length')
    # 如果内容长度存在,则将其转换为整数,用于进度条的总数。
    total = int(content_length) if content_length is not None else None
    # 创建一个进度条对象,设置其单元为字节,并设置总长度。
    progress = tqdm(unit='B', total=total)
    # 分块读取响应内容。
    for chunk in req.iter_content(chunk_size=1024):
        # 如果当前块不为空,则更新进度条,并将块写入临时文件。
        if chunk:
            progress.update(len(chunk))
            temp_file.write(chunk)
    # 关闭进度条。
    progress.close()

def download(file_name: str, url: str):
    """
    从指定的URL下载文件,并将其保存到缓存目录下,
    如果文件已存在于缓存目录中,则直接返回缓存路径。
    """
    # 检查缓存目录是否存在,如果不存在则创建。
    if not os.path.exists(cache_dir):
        os.makedirs(cache_dir)
    # 构建缓存文件的完整路径。
    cache_path = os.path.join(cache_dir, file_name)
    # 检查缓存文件是否存在。
    cache_exist = os.path.exists(cache_path)
    # 如果缓存文件不存在,则下载文件。
    if not cache_exist:
        # 创建一个命名临时文件。
        with tempfile.NamedTemporaryFile() as temp_file:
            # 调用http_get函数下载文件到临时文件。
            http_get(url, temp_file)
            # 刷新文件缓冲区,确保所有数据都写入磁盘。
            temp_file.flush()
            # 将文件指针移回文件开头。
            temp_file.seek(0)
            # 打开缓存文件,以二进制写模式。
            with open(cache_path, 'wb') as cache_file:
                # 将临时文件的内容复制到缓存文件。
                shutil.copyfileobj(temp_file, cache_file)
    # 返回缓存文件的路径。
    return cache_path
    
# 调用download函数,下载名为'aclImdb_v1.tar.gz'的文件,
# 文件的来源URL为'https://mindspore-website.obs.myhuaweicloud.com/notebook/datasets/aclImdb_v1.tar.gz'。
# 如果文件已经存在于缓存目录中,则不会重复下载,直接返回缓存文件的路径。
imdb_path = download('aclImdb_v1.tar.gz', 'https://mindspore-website.obs.myhuaweicloud.com/notebook/datasets/aclImdb_v1.tar.gz')

# 打印出下载后或已存在的文件的路径。
imdb_path

输出:

100%|██████████| 84125825/84125825 [00:02<00:00, 39347169.56B/s]
'/home/nginx/.mindspore_examples/aclImdb_v1.tar.gz'

1.3.2 加载IMDB数据集

下载好的IMDB数据集为tar.gz文件,我们使用Python的tarfile库对其进行读取,并将所有数据和标签分别进行存放。原始的IMDB数据集解压目录如下:

    ├── aclImdb
    │   ├── imdbEr.txt
    │   ├── imdb.vocab
    │   ├── README
    │   ├── test
    │   └── train
    │         ├── neg
    │         ├── pos
    ...

数据集已分割为train和test两部分,且每部分包含neg和pos两个分类的文件夹,因此需分别train和test进行读取并处理数据和标签。

# 导入re模块,用于正则表达式操作。
import re
# 导入six模块,用于提供Python 2和Python 3的兼容性。
import six
# 导入string模块,提供字符串常量和字符串操作。
import string
# 导入tarfile模块,用于读写tar归档文件。
import tarfile

# 定义一个名为IMDBData的类,用于加载和处理IMDB数据集。
class IMDBData():
    """
    IMDB数据集加载器

    加载IMDB数据集并处理为一个Python迭代对象。

    """
    # 定义一个类属性label_map,用于将文本标签映射为数字标签。
    label_map = {
        "pos": 1,  # 正面评价映射为1
        "neg": 0   # 负面评价映射为0
    }
    
    # 类的初始化方法,接收数据集路径和模式(训练或测试)。
    def __init__(self, path, mode="train"):
        self.mode = mode  # 设置模式
        self.path = path  # 设置数据集路径
        self.docs, self.labels = [], []  # 初始化文档和标签列表

        # 加载正面和负面评价的数据。
        self._load("pos")
        self._load("neg")

    # 一个私有方法,用于加载指定标签的数据。
    def _load(self, label):
        # 构建一个正则表达式模式,用于匹配特定模式的数据文件。
        pattern = re.compile(r"aclImdb/{}/{}/.*\.txt$".format(self.mode, label))
        # 打开tar归档文件。
        with tarfile.open(self.path) as tarf:
            tf = tarf.next()  # 获取tar归档中的下一个成员
            while tf is not None:  # 当成员存在时进行循环
                if bool(pattern.match(tf.name)):  # 如果成员名称匹配模式
                    # 读取文件内容,并进行预处理:去除换行符、标点和特殊字符,转换为小写,并分词。
                    self.docs.append(str(tarf.extractfile(tf).read().rstrip(six.b("\n\r"))
                                         .translate(None, six.b(string.punctuation)).lower()).split())
                    self.labels.append([self.label_map[label]])  # 添加对应的数字标签
                tf = tarf.next()  # 获取tar归档中的下一个成员

    # 定义__getitem__方法,允许类实例使用索引访问。
    def __getitem__(self, idx):
        return self.docs[idx], self.labels[idx]  # 返回指定索引的文档和标签

    # 定义__len__方法,返回文档的数量。
    def __len__(self):
        return len(self.docs)  # 返回文档列表的长度

# 创建IMDBData类的实例imdb_train,传入之前下载的IMDB数据集路径imdb_path,
# 并指定模式为'train',这意味着加载的是训练集的数据。
imdb_train = IMDBData(imdb_path, 'train')

# 调用imdb_train实例的__len__方法,获取训练集中文档的数量。
len(imdb_train)

输出:

25000

将IMDB数据集加载至内存并构造为迭代对象后,可以使用mindspore.dataset提供的Generatordataset接口加载数据集迭代对象,并进行下一步的数据处理,下面封装一个函数将train和test分别使用Generatordataset进行加载,并指定数据集中文本和标签的column_name分别为textlabel:

# 导入mindspore.dataset模块,提供数据集加载和操作的功能。
import mindspore.dataset as ds

# 定义一个名为load_imdb的函数,用于加载IMDB数据集。
def load_imdb(imdb_path):
    """
    加载IMDB数据集,分为训练集和测试集。

    参数:
    imdb_path (str): IMDB数据集的路径。

    返回:
    tuple: 包含训练集和测试集的元组。
    """
    # 创建训练集的数据集对象,使用IMDBData类作为数据生成器,
    # 指定列名["text", "label"],设置随机打散数据,并且只取10000个样本。
    imdb_train = ds.GeneratorDataset(IMDBData(imdb_path, "train"), column_names=["text", "label"], shuffle=True, num_samples=10000)
    # 创建测试集的数据集对象,使用IMDBData类作为数据生成器,
    # 指定列名["text", "label"],不进行数据打散。
    imdb_test = ds.GeneratorDataset(IMDBData(imdb_path, "test"), column_names=["text", "label"], shuffle=False)
    # 返回训练集和测试集的数据集对象。
    return imdb_train, imdb_test

# 调用load_imdb函数,传入之前下载的IMDB数据集路径imdb_path,
# 该函数将返回训练集和测试集的数据集对象。
imdb_train, imdb_test = load_imdb(imdb_path)

# 打印出训练集数据集对象。
imdb_train

输出:

<mindspore.dataset.engine.datasets_user_defined.GeneratorDataset at 0xfffed3852190>

可以看到, 加载的IMDB数据集imdb_train是一个GeneratorDataset对象。

1.3.3 加载预训练词向量

预训练词向量是对输入单词的数值化表示,通过nn.Embedding层,采用查表的方式,输入单词对应词表中的index,获得对应的表达向量。
因此进行模型构造前,需要将Embedding层所需的词向量和词表进行构造。这里我们使用Glove(Global Vectors for Word Representation)这种经典的预训练词向量,
其数据格式如下:

WordVector
the0.418 0.24968 -0.41242 0.1217 0.34527 -0.044457 -0.49688 -0.17862 -0.00066023 …
,0.013441 0.23682 -0.16899 0.40951 0.63812 0.47709 -0.42852 -0.55641 -0.364 …

我们直接使用第一列的单词作为词表,使用dataset.text.Vocab将其按顺序加载;同时读取每一行的Vector并转为numpy.array,用于nn.Embedding加载权重使用。具体实现如下:

import zipfile
import numpy as np

def load_glove(glove_path):
    glove_100d_path = os.path.join(cache_dir, 'glove.6B.100d.txt')
    if not os.path.exists(glove_100d_path):
        glove_zip = zipfile.ZipFile(glove_path)
        glove_zip.extractall(cache_dir)

    embeddings = []
    tokens = []
    with open(glove_100d_path, encoding='utf-8') as gf:
        for glove in gf:
            word, embedding = glove.split(maxsplit=1)
            tokens.append(word)
            embeddings.append(np.fromstring(embedding, dtype=np.float32, sep=' '))
    # 添加 <unk>, <pad> 两个特殊占位符对应的embedding
    embeddings.append(np.random.rand(100))
    embeddings.append(np.zeros((100,), np.float32))

    vocab = ds.text.Vocab.from_list(tokens, special_tokens=["<unk>", "<pad>"], special_first=False)
    embeddings = np.array(embeddings).astype(np.float32)
    return vocab, embeddings

由于数据集中可能存在词表没有覆盖的单词,因此需要加入<unk>标记符;同时由于输入长度的不一致,在打包为一个batch时需要将短的文本进行填充,因此需要加入<pad>标记符。完成后的词表长度为原词表长度+2。

下面下载Glove词向量,并加载生成词表和词向量权重矩阵。

glove_path = download('glove.6B.zip', 'https://mindspore-website.obs.myhuaweicloud.com/notebook/datasets/glove.6B.zip')
vocab, embeddings = load_glove(glove_path)
len(vocab.vocab())

输出:

100%|██████████| 862182613/862182613 [00:23<00:00, 37006280.82B/s]
400002

使用词表将the转换为index id,并查询词向量矩阵对应的词向量:

idx = vocab.tokens_to_ids('the')
embedding = embeddings[idx]
idx, embedding

输出:

(0,
 array([-0.038194, -0.24487 ,  0.72812 , -0.39961 ,  0.083172,  0.043953,
        -0.39141 ,  0.3344  , -0.57545 ,  0.087459,  0.28787 , -0.06731 ,
         0.30906 , -0.26384 , -0.13231 , -0.20757 ,  0.33395 , -0.33848 ,
        -0.31743 , -0.48336 ,  0.1464  , -0.37304 ,  0.34577 ,  0.052041,
         0.44946 , -0.46971 ,  0.02628 , -0.54155 , -0.15518 , -0.14107 ,
        -0.039722,  0.28277 ,  0.14393 ,  0.23464 , -0.31021 ,  0.086173,
         0.20397 ,  0.52624 ,  0.17164 , -0.082378, -0.71787 , -0.41531 ,
         0.20335 , -0.12763 ,  0.41367 ,  0.55187 ,  0.57908 , -0.33477 ,
        -0.36559 , -0.54857 , -0.062892,  0.26584 ,  0.30205 ,  0.99775 ,
        -0.80481 , -3.0243  ,  0.01254 , -0.36942 ,  2.2167  ,  0.72201 ,
        -0.24978 ,  0.92136 ,  0.034514,  0.46745 ,  1.1079  , -0.19358 ,
        -0.074575,  0.23353 , -0.052062, -0.22044 ,  0.057162, -0.15806 ,
        -0.30798 , -0.41625 ,  0.37972 ,  0.15006 , -0.53212 , -0.2055  ,
        -1.2526  ,  0.071624,  0.70565 ,  0.49744 , -0.42063 ,  0.26148 ,
        -1.538   , -0.30223 , -0.073438, -0.28312 ,  0.37104 , -0.25217 ,
         0.016215, -0.017099, -0.38984 ,  0.87424 , -0.72569 , -0.51058 ,
        -0.52028 , -0.1459  ,  0.8278  ,  0.27062 ], dtype=float32))

1.4 数据集预处理

通过加载器加载的IMDB数据集进行了分词处理,但不满足构造训练数据的需要,因此要对其进行额外的预处理。其中包含的预处理如下:

  • 通过Vocab将所有的Token处理为index id。
  • 将文本序列统一长度,不足的使用<pad>补齐,超出的进行截断。

这里我们使用mindspore.dataset中提供的接口进行预处理操作。这里使用到的接口均为MindSpore的高性能数据引擎设计,每个接口对应操作视作数据流水线的一部分,详情请参考MindSpore数据引擎
首先针对token到index id的查表操作,使用text.Lookup接口,将前文构造的词表加载,并指定unknown_token。其次为文本序列统一长度操作,使用PadEnd接口,此接口定义最大长度和补齐值(pad_value),这里我们取最大长度为500,填充值对应词表中<pad>的index id。

除了对数据集中text进行预处理外,由于后续模型训练的需要,要将label数据转为float32格式。

import mindspore as ms

lookup_op = ds.text.Lookup(vocab, unknown_token='<unk>')
pad_op = ds.transforms.PadEnd([500], pad_value=vocab.tokens_to_ids('<pad>'))
type_cast_op = ds.transforms.TypeCast(ms.float32)

完成预处理操作后,需将其加入到数据集处理流水线中,使用map接口对指定的column添加操作。

imdb_train = imdb_train.map(operations=[lookup_op, pad_op], input_columns=['text'])
imdb_train = imdb_train.map(operations=[type_cast_op], input_columns=['label'])

imdb_test = imdb_test.map(operations=[lookup_op, pad_op], input_columns=['text'])
imdb_test = imdb_test.map(operations=[type_cast_op], input_columns=['label'])

由于IMDB数据集本身不包含验证集,我们手动将其分割为训练和验证两部分,比例取0.7, 0.3。

imdb_train, imdb_valid = imdb_train.split([0.7, 0.3])

最后指定数据集的batch大小,通过batch接口指定,并设置是否丢弃无法被batch size整除的剩余数据。

调用数据集的mapsplitbatch为数据集处理流水线增加对应操作,返回值为新的Dataset类型。现在仅定义流水线操作,在执行时开始执行数据处理流水线,获取最终处理好的数据并送入模型进行训练。

imdb_train = imdb_train.batch(64, drop_remainder=True)
imdb_valid = imdb_valid.batch(64, drop_remainder=True)

1.4 模型构建

完成数据集的处理后,我们设计用于情感分类的模型结构。首先需要将输入文本(即序列化后的index id列表)通过查表转为向量化表示,此时需要使用nn.Embedding层加载Glove词向量;然后使用RNN循环神经网络做特征提取;最后将RNN连接至一个全连接层,即nn.Dense,将特征转化为与分类数量相同的size,用于后续进行模型优化训练。整体模型结构如下:

nn.Embedding -> nn.RNN -> nn.Dense

这里我们使用能够一定程度规避RNN梯度消失问题的变种LSTM(Long short-term memory)做特征提取层。下面对模型进行详解:

1.4.1 Embedding(词嵌入矩阵)

Embedding层又可称为EmbeddingLookup层,其作用是使用index id对权重矩阵对应id的向量进行查找,当输入为一个由index id组成的序列时,则查找并返回一个相同长度的矩阵,例如:

embedding = nn.Embedding(1000, 100) # 词表大小(index的取值范围)为1000,表示向量的size为100
input shape: (1, 16)                # 序列长度为16
output shape: (1, 16, 100)

这里我们使用前文处理好的Glove词向量矩阵,设置nn.Embeddingembedding_table为预训练词向量矩阵。对应的vocab_size为词表大小400002,embedding_size为选用的glove.6B.100d向量大小,即100。

1.4.2 RNN(循环神经网络)

循环神经网络(Recurrent Neural Network, RNN)是一类以序列(sequence)数据为输入,在序列的演进方向进行递归(recursion)且所有节点(循环单元)按链式连接的神经网络。下图为RNN的一般结构:

RNN-0

图示左侧为一个RNN Cell循环,右侧为RNN的链式连接平铺。实际上不管是单个RNN Cell还是一个RNN网络,都只有一个Cell的参数,在不断进行循环计算中更新。

由于RNN的循环特性,和自然语言文本的序列特性(句子是由单词组成的序列)十分匹配,因此被大量应用于自然语言处理研究中。下图为RNN的结构拆解:

RNN

RNN单个Cell的结构简单,因此也造成了梯度消失(Gradient Vanishing)问题,具体表现为RNN网络在序列较长时,在序列尾部已经基本丢失了序列首部的信息。为了克服这一问题,LSTM(Long short-term memory)被提出,通过门控机制(Gating Mechanism)来控制信息流在每个循环步中的留存和丢弃。下图为LSTM的结构拆解:

LSTM

本节我们选择LSTM变种而不是经典的RNN做特征提取,来规避梯度消失问题,并获得更好的模型效果。下面来看MindSpore中nn.LSTM对应的公式:

h 0 : t , ( h t , c t ) = LSTM ( x 0 : t , ( h 0 , c 0 ) ) h_{0:t}, (h_t, c_t) = \text{LSTM}(x_{0:t}, (h_0, c_0)) h0:t,(ht,ct)=LSTM(x0:t,(h0,c0))

这里nn.LSTM隐藏了整个循环神经网络在序列时间步(Time step)上的循环,送入输入序列、初始状态,即可获得每个时间步的隐状态(hidden state)拼接而成的矩阵,以及最后一个时间步对应的隐状态。我们使用最后的一个时间步的隐状态作为输入句子的编码特征,送入下一层。

Time step:在循环神经网络计算的每一次循环,成为一个Time step。在送入文本序列时,一个Time step对应一个单词。因此在本例中,LSTM的输出 h 0 : t h_{0:t} h0:t对应每个单词的隐状态集合, h t h_t ht c t c_t ct对应最后一个单词对应的隐状态。

1.4.3 Dense(全连接层)

在经过LSTM编码获取句子特征后,将其送入一个全连接层,即nn.Dense,将特征维度变换为二分类所需的维度1,经过Dense层后的输出即为模型预测结果。

# 导入math模块,提供数学函数。
import math
# 导入mindspore模块,提供MindSpore框架的核心功能。
import mindspore as ms
# 导入mindspore.nn模块,提供神经网络相关的API。
import mindspore.nn as nn
# 导入mindspore.ops模块,提供操作符相关的API。
import mindspore.ops as ops
# 从mindspore.common.initializer导入初始化器。
from mindspore.common.initializer import Uniform, HeUniform

# 定义一个名为RNN的类,继承自nn.Cell,用于创建一个RNN模型。
class RNN(nn.Cell):
    def __init__(self, embeddings, hidden_dim, output_dim, n_layers,
                 bidirectional, pad_idx):
        """
        初始化RNN模型。

        参数:
        embeddings (Tensor): 预训练的词嵌入矩阵。
        hidden_dim (int): 隐藏层的维度。
        output_dim (int): 输出层的维度。
        n_layers (int): RNN层的数量。
        bidirectional (bool): 是否使用双向RNN。
        pad_idx (int): 填充索引,用于处理序列的填充。
        """
        super().__init__()  # 调用父类的初始化方法
        # 获取词嵌入矩阵的大小
        vocab_size, embedding_dim = embeddings.shape
        # 创建词嵌入层
        self.embedding = nn.Embedding(vocab_size, embedding_dim, embedding_table=ms.Tensor(embeddings), padding_idx=pad_idx)
        # 创建LSTM层
        self.rnn = nn.LSTM(embedding_dim,
                           hidden_dim,
                           num_layers=n_layers,
                           bidirectional=bidirectional,
                           batch_first=True)
        # 定义全连接层的权重和偏置初始化器
        weight_init = HeUniform(math.sqrt(5))
        bias_init = Uniform(1 / math.sqrt(hidden_dim * 2))
        # 创建全连接层
        self.fc = nn.Dense(hidden_dim * 2, output_dim, weight_init=weight_init, bias_init=bias_init)

    def construct(self, inputs):
        """
        前向传播函数。

        参数:
        inputs (Tensor): 输入数据。

        返回:
        Tensor: 模型的输出。
        """
        # 通过词嵌入层处理输入
        embedded = self.embedding(inputs)
        # 通过LSTM层处理嵌入后的输入
        _, (hidden, _) = self.rnn(embedded)
        # 合并双向LSTM的最后一个时间步的隐藏状态
        hidden = ops.concat((hidden[-2, :, :], hidden[-1, :, :]), axis=1)
        # 通过全连接层得到最终输出
        output = self.fc(hidden)
        return output

1.4.4 损失函数与优化器

完成模型主体构建后,首先根据指定的参数实例化网络;然后选择损失函数和优化器。针对本节情感分类问题的特性,即预测Positive或Negative的二分类问题,我们选择nn.BCEWithLogitsLoss(二分类交叉熵损失函数)。

# 定义隐藏层的尺寸,即LSTM单元的数量。
hidden_size = 256
# 定义输出层的尺寸,由于是二分类问题,输出尺寸为1。
output_size = 1
# 定义LSTM层的数量。
num_layers = 2
# 设置为True以使用双向LSTM。
bidirectional = True
# 定义学习率。
lr = 0.001
# 获取词汇表中<pad> token的索引,用于在嵌入层中屏蔽填充。
pad_idx = vocab.tokens_to_ids('<pad>')

# 创建RNN模型实例,传入预训练的词嵌入矩阵embeddings和其他配置参数。
model = RNN(embeddings, hidden_size, output_size, num_layers, bidirectional, pad_idx)
# 创建损失函数实例,使用二进制交叉熵损失函数,并设置减少方式为求平均值。
loss_fn = nn.BCEWithLogitsLoss(reduction='mean')
# 创建优化器实例,使用Adam优化算法,传入模型的训练参数和学习率。
optimizer = nn.Adam(model.trainable_params(), learning_rate=lr)

1.4.5 训练逻辑

在完成模型构建,进行训练逻辑的设计。一般训练逻辑分为一下步骤:

  1. 读取一个Batch的数据;
  2. 送入网络,进行正向计算和反向传播,更新权重;
  3. 返回loss。

下面按照此逻辑,使用tqdm库,设计训练一个epoch的函数,用于训练过程和loss的可视化。

# 定义一个前向传播函数,它接受数据和标签,返回模型的损失。
def forward_fn(data, label):
    """
    前向传播函数,用于计算模型的损失。

    参数:
    data (Tensor): 输入数据。
    label (Tensor): 真实标签。

    返回:
    Tensor: 模型的损失。
    """
    logits = model(data)  # 通过模型得到预测的logits
    loss = loss_fn(logits, label)  # 计算损失
    return loss

# 使用MindSpore的value_and_grad函数来创建一个梯度计算函数,
# 它将计算前向传播函数的值和梯度。
grad_fn = ms.value_and_grad(forward_fn, None, optimizer.parameters)

# 定义一个训练步骤函数,它接受数据和标签,执行一次梯度下降。
def train_step(data, label):
    """
    执行一次训练步骤,包括前向传播、计算梯度、应用梯度。

    参数:
    data (Tensor): 输入数据。
    label (Tensor): 真实标签。

    返回:
    Tensor: 执行步骤后的损失。
    """
    loss, grads = grad_fn(data, label)  # 计算损失和梯度
    optimizer(grads)  # 更新模型的参数
    return loss

# 定义一个训练一个周期的函数,它遍历整个训练数据集,并执行训练步骤。
def train_one_epoch(model, train_dataset, epoch=0):
    """
    在一个训练周期内训练模型。

    参数:
    model (Cell): 要训练的模型。
    train_dataset (Dataset): 训练数据集。
    epoch (int): 当前周期的编号。

    返回:
    None
    """
    model.set_train()  # 将模型设置为训练模式
    total = train_dataset.get_dataset_size()  # 获取训练数据集的大小
    loss_total = 0  # 初始化总损失
    step_total = 0  # 初始化总步骤数
    # 使用tqdm创建一个进度条,显示训练进度
    with tqdm(total=total) as t:
        t.set_description('Epoch %i' % epoch)  # 设置进度条的描述
        for i in train_dataset.create_tuple_iterator():  # 遍历训练数据集
            loss = train_step(*i)  # 执行训练步骤
            loss_total += loss.asnumpy()  # 累加损失
            step_total += 1  # 累加步骤数
            t.set_postfix(loss=loss_total/step_total)  # 更新进度条的后缀信息
            t.update(1)  # 更新进度条

1.4.6 评估指标和逻辑

训练逻辑完成后,需要对模型进行评估。即使用模型的预测结果和测试集的正确标签进行对比,求出预测的准确率。由于IMDB的情感分类为二分类问题,对预测值直接进行四舍五入即可获得分类标签(0或1),然后判断是否与正确标签相等即可。下面为二分类准确率计算函数实现:

import numpy as np

# 定义一个名为binary_accuracy的函数,用于计算二分类问题的准确率。
def binary_accuracy(preds, y):
    """
    计算每个batch的准确率。

    参数:
    preds (Tensor): 模型的预测值。
    y (Tensor): 真实标签。

    返回:
    float: 当前batch的准确率。
    """

    # 对预测值进行sigmoid激活,然后四舍五入到最接近的整数,
    # 以便将预测值转换为0或1,对应于二分类的负类或正类。
    rounded_preds = np.around(ops.sigmoid(preds).asnumpy())
    # 计算预测正确的数量,将比较结果转换为浮点数,以便进行后续的平均计算。
    correct = (rounded_preds == y).astype(np.float32)
    # 计算准确率,即正确预测的数量除以总预测数量。
    acc = correct.sum() / len(correct)
    return acc

有了准确率计算函数后,类似于训练逻辑,对评估逻辑进行设计, 分别为以下步骤:

  1. 读取一个Batch的数据;
  2. 送入网络,进行正向计算,获得预测结果;
  3. 计算准确率。

同训练逻辑一样,使用tqdm进行loss和过程的可视化。此外返回评估loss至供保存模型时作为模型优劣的判断依据。

在进行evaluate时,使用的模型是不包含损失函数和优化器的网络主体;
在进行evaluate前,需要通过model.set_train(False)将模型置为评估状态,此时Dropout不生效。

# 定义一个名为evaluate的函数,用于评估模型在测试数据集上的性能。
def evaluate(model, test_dataset, criterion, epoch=0):
    """
    评估模型在测试数据集上的性能。

    参数:
    model (Cell): 要评估的模型。
    test_dataset (Dataset): 测试数据集。
    criterion (Function): 损失函数。
    epoch (int): 当前周期的编号。

    返回:
    float: 测试数据集上的平均损失。
    """
    # 获取测试数据集的大小。
    total = test_dataset.get_dataset_size()
    # 初始化损失和准确率的累积值。
    epoch_loss = 0
    epoch_acc = 0
    step_total = 0
    # 将模型设置为评估模式。
    model.set_train(False)

    # 使用tqdm创建一个进度条,显示评估进度。
    with tqdm(total=total) as t:
        t.set_description('Epoch %i' % epoch)
        for i in test_dataset.create_tuple_iterator():
            # 通过模型得到预测值。
            predictions = model(i[0])
            # 计算损失。
            loss = criterion(predictions, i[1])
            # 累加损失。
            epoch_loss += loss.asnumpy()

            # 计算准确率。
            acc = binary_accuracy(predictions, i[1])
            # 累加准确率。
            epoch_acc += acc

            # 累加步骤数。
            step_total += 1
            # 更新进度条的后缀信息。
            t.set_postfix(loss=epoch_loss/step_total, acc=epoch_acc/step_total)
            # 更新进度条。
            t.update(1)

    # 返回测试数据集上的平均损失。
    return epoch_loss / total

1.5 模型训练与保存

前序完成了模型构建和训练、评估逻辑的设计,下面进行模型训练。这里我们设置训练轮数为5轮。同时维护一个用于保存最优模型的变量best_valid_loss,根据每一轮评估的loss值,取loss值最小的轮次,将模型进行保存。为节省用例运行时长,此处num_epochs设置为2,可根据需要自行修改。

# 定义训练周期数。
num_epochs = 2
# 初始化最佳验证损失为一个无穷大的浮点数。
best_valid_loss = float('inf')
# 定义检查点文件的名称,包括路径。
ckpt_file_name = os.path.join(cache_dir, 'sentiment-analysis.ckpt')

# 遍历指定的训练周期数。
for epoch in range(num_epochs):
    # 调用训练一个周期的函数,对模型进行训练。
    train_one_epoch(model, imdb_train, epoch)
    # 调用评估函数,评估模型在验证数据集上的性能。
    valid_loss = evaluate(model, imdb_valid, loss_fn, epoch)

    # 检查验证损失是否小于当前的最佳验证损失。
    if valid_loss < best_valid_loss:
        # 如果验证损失更小,更新最佳验证损失,并保存检查点。
        best_valid_loss = valid_loss
        ms.save_checkpoint(model, ckpt_file_name)

输出:

Epoch 0:   0%|          | 0/109 [00:00<?, ?it/s]
|
Epoch 0: 100%|██████████| 109/109 [13:24<00:00,  7.38s/it, loss=0.677]  
Epoch 0: 100%|██████████| 46/46 [00:39<00:00,  1.17it/s, acc=0.582, loss=0.685]
Epoch 1: 100%|██████████| 109/109 [01:23<00:00,  1.31it/s, loss=0.652]
Epoch 1: 100%|██████████| 46/46 [00:13<00:00,  3.40it/s, acc=0.614, loss=0.653]

可以看到每轮Loss逐步下降,在验证集上的准确率逐步提升。内存占用峰值20GB+, 耗时超过15分钟.

1.6 模型加载与测试

模型训练完成后,一般需要对模型进行测试或部署上线,此时需要加载已保存的最优模型(即checkpoint),供后续测试使用。这里我们直接使用MindSpore提供的Checkpoint加载和网络权重加载接口:1.将保存的模型Checkpoint加载到内存中,2.将Checkpoint加载至模型。

load_param_into_net接口会返回模型中没有和Checkpoint匹配的权重名,正确匹配时返回空列表。

# 定义一个名为load_checkpoint的函数,用于加载之前保存的检查点。
def load_checkpoint(ckpt_file_name):
    """
    加载检查点,获取参数的字典。

    参数:
    ckpt_file_name (str): 检查点文件的名称,包括路径。

    返回:
    dict: 包含参数的字典。
    """
    # 使用mindspore的load_checkpoint函数加载检查点,并获取参数的字典。
    return ms.load_checkpoint(ckpt_file_name)

# 定义一个名为load_param_into_net的函数,用于将参数加载到网络中。
def load_param_into_net(model, param_dict):
    """
    将参数加载到网络中。

    参数:
    model (Cell): 要加载参数的网络。
    param_dict (dict): 包含参数的字典。

    返回:
    None
    """
    # 使用mindspore的load_param_into_net函数将参数字典中的参数加载到网络中。
    ms.load_param_into_net(model, param_dict)

输出:

([], [])

对测试集打batch,然后使用evaluate方法进行评估,得到模型在测试集上的效果。

# 将测试数据集调整为批量大小为64的批次。
imdb_test = imdb_test.batch(64)
# 评估模型在测试数据集上的性能。
evaluate(model, imdb_test, loss_fn)

输出:

Epoch 0: 100%|█████████▉| 390/391 [01:28<00:00,  4.54it/s, acc=0.595, loss=0.661]
-
Epoch 0: 100%|██████████| 391/391 [03:16<00:00,  1.99it/s, acc=0.596, loss=0.661]
0.6608867953195596

1.7 自定义输入测试

最后我们设计一个预测函数,实现开头描述的效果,输入一句评价,获得评价的情感分类。具体包含以下步骤:

  1. 将输入句子进行分词;
  2. 使用词表获取对应的index id序列;
  3. index id序列转为Tensor;
  4. 送入模型获得预测结果;
  5. 打印输出预测结果。

具体实现如下:

# 定义一个名为score_map的字典,用于将整数标签映射到情感标签。
score_map = {
    1: "Positive",
    0: "Negative"
}

# 定义一个名为predict_sentiment的函数,用于预测句子情感。
def predict_sentiment(model, vocab, sentence):
    """
    预测句子的情感。

    参数:
    model (Cell): 要预测情感的模型。
    vocab (Vocab): 词汇表。
    sentence (str): 要预测情感的句子。

    返回:
    str: 句子的情感标签。
    """
    # 将模型设置为评估模式。
    model.set_train(False)
    # 将句子转换为小写,并分词。
    tokenized = sentence.lower().split()
    # 将分词后的句子转换为索引序列。
    indexed = vocab.tokens_to_ids(tokenized)
    # 创建一个包含索引序列的张量。
    tensor = ms.Tensor(indexed, ms.int32)
    # 将张量展平为一维。
    tensor = tensor.expand_dims(0)
    # 通过模型得到预测结果。
    prediction = model(tensor)
    # 使用sigmoid函数将预测结果转换为概率,然后四舍五入到最接近的整数。
    rounded_prediction = np.round(ops.sigmoid(prediction).asnumpy())
    # 根据整数标签映射到情感标签。
    return score_map[int(rounded_prediction)]

最后我们预测开头的样例,可以看到模型可以很好地将评价语句的情感进行分类。

predict_sentiment(model, vocab, "This film is terrible")

输出:

'Negative'
predict_sentiment(model, vocab, "This film is great")

输出:

'Positive'

RNN情感分类

2. 小结

本文主要介绍了RNN实现情感分类的整个过程。主要包括:数据下载,数据集预处理,模型构建,模型训练与保存,模型加载与测试,自定义输入测试等过程。其中模型构建的具体步骤包括构建词嵌入矩阵、RNN循环神经网络、Dense全连接层、损失函数和优化器、训练函数和评估函数。

;