<jupyter_start><jupyter_text>自然语言情感分析和文本匹配是日常生活中最常用的两类自然语言处理任务，本节主要介绍情感分析和文本匹配原理实现和典型模型，以及如何使用飞桨完成情感分析任务。  自然语言情感分析在学习本节课内容前，让我们先看一段来自肥伦秀的[视频片段](https://www.bilibili.com/video/av40396494?from=search&seid=9852893210841347755)，感受下人类语言情感的复杂性。众所周知，人类自然语言中包含了丰富的情感色彩：表达人的情绪（如悲伤、快乐）、表达人的心情（如倦怠、忧郁）、表达人的喜好（如喜欢、讨厌）、表达人的个性特征和表达人的立场等等。利用机器自动分析这些情感倾向，不但有助于帮助企业了解消费者对其产品的感受，为产品改进提供依据；同时还有助于企业分析商业伙伴们的态度，以便更好地进行商业决策。简单的说，我们可以将情感分析（sentiment classification）任务定义为一个分类问题，即指定一个文本输入，机器通过对文本进行分析、处理、归纳和推理后自动输出结论，如**图1**所示。图1：情感分析任务通常情况下，人们把情感分析任务看成一个三分类问题，如 **图2** 所示：图2：情感分析任务- **正向：** 表示正面积极的情感，如高兴，幸福，惊喜，期待等。- **负向：** 表示负面消极的情感，如难过，伤心，愤怒，惊恐等。- **其他：** 其他类型的情感。在情感分析任务中，研究人员除了分析句子的情感类型外，还细化到以句子中具体的“方面”为分析主体进行情感分析（aspect-level），如下：> 这个薯片口味有点咸，太辣了，不过口感很脆。关于薯片的口味方面是一个负向评价（咸，太辣），然而对于口感方面却是一个正向评价（很脆）。> 我很喜欢夏威夷，就是这边的海鲜太贵了。关于夏威夷是一个正向评价（喜欢），然而对于夏威夷的海鲜却是一个负向评价（价格太贵）。  使用深度神经网络完成情感分析任务上一节课我们学习了通过把每个单词转换成向量的方式，可以完成单词语义计算任务。那么我们自然会联想到，是否可以把每个自然语言句子也转换成一个向量表示，并使用这个向量表示完成情感分析任务呢？在日常工作中有一个非常简单粗暴的解决方式：就是先把一个句子中所有词的embedding进行加和平均，再用得到的平均embedding作为整个句子的向量表示。然而由于自然语言变幻莫测，我们在使用神经网络处理句子的时候，往往会遇到如下两类问题：- **变长的句子：** 自然语言句子往往是变长的，不同的句子长度可能差别很大。然而大部分神经网络接受的输入都是张量，长度是固定的，那么如何让神经网络处理变长数据成为了一大挑战。- **组合的语义：** 自然语言句子往往对结构非常敏感，有时稍微颠倒单词的顺序都可能改变这句话的意思，比如：  >你等一下我做完作业就走。  >  >我等一下你做完工作就走。  >我不爱吃你做的饭。  >  >你不爱吃我做的饭。    >我瞅你咋地。  >  >你瞅我咋滴。  因此，我们需要找到一个可以考虑词和词之间顺序（关系）的神经网络，用于更好地实现自然语言句子建模。  处理变长数据在使用神经网络处理变长数据时，需要先设置一个全局变量max_seq_len，再对预料中的句子进行处理，将不同的句子组成mini-batch，用于神经网络学习和处理。**1. 设置全局变量**设定一个全局变量max_seq_len，用来控制神经网络最大可以处理文本的长度。我们可以先观察语料中句子的分布，再设置合理的max_seq_len值，以最高的性价比完成句子分类任务（如情感分类）。**2. 对语料中的句子进行处理**我们通常采用**截断+填充** 的方式，对语料中的句子进行处理，将不同的句子组成mini-batch，以便让句子转换成一个张量给神经网络进行处理计算，如 **图 3** 所示。图3：变长数据处理* 对于长度超过max_seq_len的句子，我们通常会把这个句子进行截断，以便可以输入到一个张量中。句子截断的过程是有技巧的，有时截取句子的前一部分会比后一部分好，有时则恰好相反。当然也存在其他的截断方式，有兴趣的读者可以翻阅一下相关资料，这里不做赘述。* 对于句子长度不足max_seq_len的句子，我们一般会使用一个特殊的词语对这个句子进行填充，这个过程称为Padding。假设给定一个句子“我，爱，人工，智能”，max_seq_len=6，那么可能得到两种填充方式：  - **前向填充：** “[pad]，[pad]，我，爱，人工，智能”  - **后向填充：**“我，爱，人工，智能，[pad]，[pad]”同样，不同的填充方式也对网络训练效果有一定影响。一般来说，我们比较倾向选择后向填充的方式。  学习句子的语义上一节课我们已经学习了如何学习每个单词的语义信息，从刚才的举例中我们也会观察到，一个句子中词的顺序往往对这个句子的整体语义有比较重要的影响。因此，在刻画整个句子的语义信息过程中，不能撇开顺序信息。如果简单粗暴地把这个句子中所有词的向量做加和，会使得我们的模型无法区分句子的真实含义，例如：>我不爱吃你做的饭。>>你不爱吃我做的饭。一个有趣的想法，把一个自然语言句子看成一个序列，把整个自然语言的生成过程看成是一个序列生成的过程。例如对于句子“我，爱，人工，智能”，这句话的生成概率$\text{P}(\text{我，爱，人工，智能})$可以被表示为：$\text{P}(\text{我，爱，人工，智能})=\text{P}(我|\text{})*\text{P}(爱|\text{，我})* \text{P}(人工|\text{，我，爱})* \text{P}(智能|\text{，我，爱，人工})* \text{P}(\text{}|\text{，我，爱，人工，智能})$其中$\text{}$和$\text{}$是两个特殊的不可见符合，表示一个句子在逻辑上的开始和结束。上面的公式把一个句子的生成过程建模成一个序列的决策过程，这就是香农在1950年左右提出的使用马尔可夫过程建模自然语言的思想。使用序列的视角看待和建模自然语言有一个明显的好处，那就是在对每个词建模的过程中，都有一个机会去学习这个词和之前生成的词之间的关系，并利用这种关系更好地处理自然语言。如 **图4** 所示，生成句子“我，爱，人工”后，“智能”在下一步生成的概率就变得很高了，因为“人工智能”经常同时出现。图4：自然语言生成过程示意图通过考虑句子内部的序列关系，我们就可以清晰地区分“我不爱吃你做的菜”和“你不爱吃我做的菜”这两句话之间的联系与不同了。事实上，目前大多数成功的自然语言模型都建立在对句子的序列化建模上。下面让我们学习一个经典的序列化建模模型：长短时记忆网络。  循环神经网络RNN和长短时记忆网络LSTM循环神经网络（Recurrent Neural Network，RNN）是一个非常经典的面向序列的模型，可以对自然语言句子或是其他时序信号进行建模，RNN网络结构如 **图5** 所示。图5：循环神经网络结构不同于其他常见的神经网络结构，循环神经网络的输入是一个序列信息。假设给定任意一句话$[x_0, x_1, ..., x_n]$，其中每个$x_i$都代表了一个词，如“我，爱，人工，智能”。循环神经网络从左到右逐词阅读这个句子，并不断调用一个相同的RNN Cell来处理时序信息。每阅读一个单词，循环神经网络会先将本次输入的单词通过embedding lookup转换为一个向量表示。再把这个单词的向量表示和这个模型内部记忆的向量$h$融合起来，形成一个更新的记忆。最后将这个融合后的表示输出出来，作为它当前阅读到的所有内容的语义表示。当循环神经网络阅读过整个句子之后，我们就可以认为它的最后一个输出状态表示了整个句子的语义表示。听上去很复杂，下面我们以一个简单地例子来说明，假设输入的句子为：> “我，爱，人工，智能”循环神经网络开始从左到右阅读这个句子，在未经过任何阅读之前，循环神经网络中的记忆向量是空白的。其处理逻辑如下：1. 网络阅读单词“我”，并把单词“我”的向量表示和空白记忆相融合，输出一个向量$h_1$，用于表示“空白+我”的语义。1. 网络开始阅读单词“爱”，这时循环神经网络内部存在“空白+我”的记忆。循环神经网络会将“空白+我”和“爱”的向量表示相融合，并输出“空白+我+爱”的向量表示$h_2$，用于表示“我爱”这个短语的语义信息。1. 网络开始阅读单词“人工”，同样经过融合之后，输出“空白+我+爱+人工”的向量表示$h_3$，用于表示“空白+我+爱+人工”语义信息。1. 最终在网络阅读了“智能”单词后，便可以输出“我爱人工智能”这一句子的整体语义信息。------**说明：**在实现当前输入$x_t$和已有记忆$h_{t-1}$融合的时候，循环神经网络采用相加并通过一个激活函数tanh的方式实现：$h_t = tanh(WX_{t}+VH_{t-1}+b)$tanh函数是一个值域为（-1,1）的函数，其作用是长期维持内部记忆在一个固定的数值范围内，防止因多次迭代更新导致数值爆炸。同时tanh的导数是一个平滑的函数，会让神经网络的训练变得更加简单。-----上述方法听上去很有效（事实上在有些任务上效果还不错），但是存在一个明显的缺陷，就是当阅读很长的序列时，网络内部的信息会变得越来越复杂，甚至会超过网络的记忆能力，使得最终的输出信息变得混乱无用。长短时记忆网络（Long Short-Term Memory，LSTM）内部的复杂结构正是为处理这类问题而设计的，其网络结构如 **图6** 所示。图6：LSTM网络结构长短时记忆网络的结构和循环神经网络非常类似，都是通过不断调用同一个cell来逐次处理时序信息。每阅读一个新单词$x_t$，就会输出一个新的输出信号$h_t$，用来表示当前阅读到所有内容的整体向量表示。不过二者又有一个明显区别，长短时记忆网络在不同cell之间传递的是二个记忆信息，而不像循环神经网络一样只有一个记忆信息，此外长短时记忆网络的内部结构也更加复杂，如 **图7** 所示。图7：LSTM网络内部结构示意图区别于循环神经网络RNN，长短时记忆网络最大的特点是在更新内部记忆时，引入了遗忘机制。即容许网络忘记过去阅读过程中看到的一些无关紧要的信息，只保留有用的历史信息。通过这种方式延长了记忆长度。举个例子：> 我觉得这家餐馆的菜品很不错，烤鸭非常正宗，包子也不错，酱牛肉很有嚼劲。但是服务员态度太恶劣了，我们在门口等了50分钟都没有能成功进去，好不容易进去了，桌子也半天没人打扫。整个环境非常吵闹，我的孩子都被吓哭了，我下次不会带朋友来。当我们阅读上面这段话的时候，可能会记住一些关键词，如烤鸭好吃、牛肉有嚼劲、环境吵等，但也会忽略一些不重要的内容，如“我觉得”、“好不容易”等，长短时记忆网络正是受这个启发而设计的。长短时记忆网络的Cell有三个输入：- 这个网络新看到的输入信号，如下一个单词，记为$x_{t}$， 其中$x_{t}$是一个向量，$t$代表了当前时刻。- 这个网络在上一步的输出信号，记为$h_{t-1}$，这是一个向量，维度同$x_{t}$相同。- 这个网络在上一步的记忆信号，记为$c_{t-1}$，这是一个向量，维度同$x_{t}$相同。得到这两个信号之后，长短时记忆网络没有立即去融合这两个向量，而是计算了如下门的权重：- 输入门：$i_{t}=sigmoid(W_{i}X_{t}+V_{i}H_{t-1}+b_i)$，控制有多少输入信号会被融合。- 遗忘门：$f_{t}=sigmoid(W_{f}X_{t}+V_{f}H_{t-1}+b_f)$，控制有多少过去的记忆会被融合。- 输出门：$o_{t}=sigmoid(W_{o}X_{t}+V_{o}H_{t-1}+b_o)$，控制最终输出多少记忆。- 单元状态：$g_{t}=tanh(W_{g}X_{t}+V_{g}H_{t-1}+b_g)$通过学习这些门的权重设置，长短时记忆网络可以根据当前的输入信号和记忆信息，有选择性地忽略或者强化当前的记忆或是输入信号，帮助网络更好地学习长句子的语义信息：$c_{t} = f_{t} \cdot c_{t-1} + i_{t} \cdot g_{t}$$h_{t} = o_{t} \cdot tanh(c_{t})$------**说明：**事实上，长短时记忆网络之所以能更好地对长文本进行建模，还存在另外一套更加严谨的计算和证明，有兴趣的读者可以翻阅一下引文中的参考资料进行详细研究。------  使用LSTM完成情感分析任务借助长短时记忆网络，我们可以非常轻松地完成情感分析任务。如 **图8** 所示。对于每个句子，我们首先通过截断和填充的方式，把这些句子变成固定长度的向量。然后，利用长短时记忆网络，从左到右开始阅读每个句子。在完成阅读之后，我们使用长短时记忆网络的最后一个输出记忆，作为整个句子的语义信息，并直接把这个向量作为输入，送入一个分类层进行分类，从而完成对情感分析问题的神经网络建模。图8：LSTM完成情感分析任务流程  使用飞桨实现基于LSTM的情感分析模型 接下来让我们看看如何使用飞桨实现一个基于长短时记忆网络的情感分析模型。在飞桨中，不同深度学习模型的训练过程基本一致，流程如下：1. **数据处理**：选择需要使用的数据，并做好必要的预处理工作。2. **网络定义**：使用飞桨定义好网络结构，包括输入层，中间层，输出层，损失函数和优化算法。3. **网络训练**：将准备好的数据送入神经网络进行学习，并观察学习的过程是否正常，如损失函数值是否在降低，也可以打印一些中间步骤的结果出来等。4. **网络评估**：使用测试集合测试训练好的神经网络，看看训练效果如何。在数据处理前，需要先加载飞桨平台（如果用户在本地使用，请确保已经安装飞桨）。<jupyter_code>import io
import os
import re
import sys
import six
import requests
import string
import tarfile
import hashlib
from collections import OrderedDict 
import math
import random
import numpy as np
import paddle
import paddle.fluid as fluid

from paddle.fluid.dygraph.nn import Embedding<jupyter_output><empty_output><jupyter_text>数据处理首先，需要下载语料用于模型训练和评估效果。我们使用的是IMDB的电影评论数据，这个数据集是一个开源的英文数据集，由训练数据和测试数据组成。每个数据都分别由若干小文件组成，每个小文件内部都是一段用户关于某个电影的真实评价，以及他/她对这个电影的情感倾向（是正向还是负向），数据集下载的代码如下：<jupyter_code>def download():
    #通过python的requests类，下载存储在
    #https://dataset.bj.bcebos.com/imdb%2FaclImdb_v1.tar.gz的文件
    corpus_url = "https://dataset.bj.bcebos.com/imdb%2FaclImdb_v1.tar.gz"
    web_request = requests.get(corpus_url)
    corpus = web_request.content

    #将下载的文件写在当前目录的aclImdb_v1.tar.gz文件内
    with open("./aclImdb_v1.tar.gz", "wb") as f:
        f.write(corpus)
    f.close()

download()<jupyter_output><empty_output><jupyter_text>接下来，将数据集加载到程序中，并打印一小部分数据观察一下数据集的特点，代码如下：<jupyter_code>def load_imdb(is_training):
    data_set = []

    #aclImdb_v1.tar.gz解压后是一个目录
    #我们可以使用python的rarfile库进行解压
    #训练数据和测试数据已经经过切分，其中训练数据的地址为：
    #./aclImdb/train/pos/ 和 ./aclImdb/train/neg/，分别存储着正向情感的数据和负向情感的数据
    #我们把数据依次读取出来，并放到data_set里
    #data_set中每个元素都是一个二元组，（句子，label），其中label=0表示负向情感，label=1表示正向情感
    
    for label in ["pos", "neg"]:
        with tarfile.open("./aclImdb_v1.tar.gz") as tarf:
            path_pattern = "aclImdb/train/" + label + "/.*\.txt$" if is_training \
                else "aclImdb/test/" + label + "/.*\.txt$"
            path_pattern = re.compile(path_pattern)
            tf = tarf.next()
            while tf != None:
                if bool(path_pattern.match(tf.name)):
                    sentence = tarf.extractfile(tf).read().decode()
                    sentence_label = 0 if label == 'neg' else 1
                    data_set.append((sentence, sentence_label)) 
                tf = tarf.next()

    return data_set

train_corpus = load_imdb(True)
test_corpus = load_imdb(False)

for i in range(5):
    print("sentence %d, %s" % (i, train_corpus[i][0]))    
    print("sentence %d, label %d" % (i, train_corpus[i][1]))<jupyter_output>sentence 0, Zentropa has much in common with The Third Man, another noir-like film set among the rubble of postwar Europe. Like TTM, there is much inventive camera work. There is an innocent American who gets emotionally involved with a woman he doesn't really understand, and whose naivety is all the more striking in contrast with the natives.<br /><br />But I'd have to say that The Third Man has a more well-crafted storyline. Zentropa is a bit disjointed in this respect. Perhaps this is intentional: it is presented as a dream/nightmare, and making it too coherent would spoil the effect. <br /><br />This movie is unrelentingly grim--"noir" in more than one sense; one never sees the sun shine. Grim, but intriguing, and frightening.
sentence 0, label 1
sentence 1, Zentropa is the most original movie I've seen in years. If you like unique thrillers that are influenced by film noir, then this is just the right cure for all of those Hollywood summer blockbusters clogging the theaters these [...]<jupyter_text>一般来说，在自然语言处理中，需要先对语料进行切词，这里我们可以使用空格把每个句子切成若干词的序列，代码如下：<jupyter_code>def data_preprocess(corpus):
    data_set = []
    for sentence, sentence_label in corpus:
        #这里有一个小trick是把所有的句子转换为小写，从而减小词表的大小
        #一般来说这样的做法有助于效果提升
        sentence = sentence.strip().lower()
        sentence = sentence.split(" ")
        
        data_set.append((sentence, sentence_label))

    return data_set

train_corpus = data_preprocess(train_corpus)
test_corpus = data_preprocess(test_corpus)
print(train_corpus[:5])<jupyter_output>[(['zentropa', 'has', 'much', 'in', 'common', 'with', 'the', 'third', 'man,', 'another', 'noir-like', 'film', 'set', 'among', 'the', 'rubble', 'of', 'postwar', 'europe.', 'like', 'ttm,', 'there', 'is', 'much', 'inventive', 'camera', 'work.', 'there', 'is', 'an', 'innocent', 'american', 'who', 'gets', 'emotionally', 'involved', 'with', 'a', 'woman', 'he', "doesn't", 'really', 'understand,', 'and', 'whose', 'naivety', 'is', 'all', 'the', 'more', 'striking', 'in', 'contrast', 'with', 'the', 'natives.<br', '/><br', '/>but', "i'd", 'have', 'to', 'say', 'that', 'the', 'third', 'man', 'has', 'a', 'more', 'well-crafted', 'storyline.', 'zentropa', 'is', 'a', 'bit', 'disjointed', 'in', 'this', 'respect.', 'perhaps', 'this', 'is', 'intentional:', 'it', 'is', 'presented', 'as', 'a', 'dream/nightmare,', 'and', 'making', 'it', 'too', 'coherent', 'would', 'spoil', 'the', 'effect.', '<br', '/><br', '/>this', 'movie', 'is', 'unrelentingly', 'grim--"noir"', 'in', 'more', 'than', 'one', 'sense;', 'one', [...]<jupyter_text>在经过切词后，需要构造一个词典，把每个词都转化成一个ID，以便于神经网络训练。代码如下：------**注意：**在代码中我们使用了一个特殊的单词"[oov]"（out-of-vocabulary），用于表示词表中没有覆盖到的词。之所以使用"[oov]"这个符号，是为了处理某一些词，再测试数据中有，但训练数据没有的现象。------<jupyter_code>#构造词典，统计每个词的频率，并根据频率将每个词转换为一个整数id
def build_dict(corpus):
    word_freq_dict = dict()
    for sentence, _ in corpus:
        for word in sentence:
            if word not in word_freq_dict:
                word_freq_dict[word] = 0
            word_freq_dict[word] += 1

    word_freq_dict = sorted(word_freq_dict.items(), key = lambda x:x[1], reverse = True)
    
    word2id_dict = dict()
    word2id_freq = dict()

    #一般来说，我们把oov和pad放在词典前面，给他们一个比较小的id，这样比较方便记忆，并且易于后续扩展词表
    word2id_dict['[oov]'] = 0
    word2id_freq[0] = 1e10

    word2id_dict['[pad]'] = 1
    word2id_freq[1] = 1e10

    for word, freq in word_freq_dict:
        word2id_dict[word] = len(word2id_dict)
        word2id_freq[word2id_dict[word]] = freq

    return word2id_freq, word2id_dict

word2id_freq, word2id_dict = build_dict(train_corpus)
vocab_size = len(word2id_freq)
print("there are totoally %d different words in the corpus" % vocab_size)
for _, (word, word_id) in zip(range(50), word2id_dict.items()):
    print("word %s, its id %d, its word freq %d" % (word, word_id, word2id_freq[word_id]))<jupyter_output>there are totoally 252173 different words in the corpus
word [oov], its id 0, its word freq 10000000000
word [pad], its id 1, its word freq 10000000000
word the, its id 2, its word freq 322174
word a, its id 3, its word freq 159949
word and, its id 4, its word freq 158556
word of, its id 5, its word freq 144459
word to, its id 6, its word freq 133965
word is, its id 7, its word freq 104170
word in, its id 8, its word freq 90521
word i, its id 9, its word freq 70477
word this, its id 10, its word freq 69711
word that, its id 11, its word freq 66288
word it, its id 12, its word freq 65490
word /><br, its id 13, its word freq 50935
word was, its id 14, its word freq 47023
word as, its id 15, its word freq 45098
word for, its id 16, its word freq 42840
word with, its id 17, its word freq 42725
word but, its id 18, its word freq 39757
word on, its id 19, its word freq 31618
word movie, its id 20, its word freq 30885
word his, its id 21, its word freq 29058
word are, its id 22, its word freq[...]<jupyter_text>在完成word2id词典假设之后，我们还需要进一步处理原始语料，把语料中的所有句子都处理成ID序列，代码如下：<jupyter_code>#把语料转换为id序列
def convert_corpus_to_id(corpus, word2id_dict):
    data_set = []
    for sentence, sentence_label in corpus:
        #将句子中的词逐个替换成id，如果句子中的词不在词表内，则替换成oov
        #这里需要注意，一般来说我们可能需要查看一下test-set中，句子oov的比例，
        #如果存在过多oov的情况，那就说明我们的训练数据不足或者切分存在巨大偏差，需要调整
        sentence = [word2id_dict[word] if word in word2id_dict \
                    else word2id_dict['[oov]'] for word in sentence]    
        data_set.append((sentence, sentence_label))
    return data_set

train_corpus = convert_corpus_to_id(train_corpus, word2id_dict)
test_corpus = convert_corpus_to_id(test_corpus, word2id_dict)
print("%d tokens in the corpus" % len(train_corpus))
print(train_corpus[:5])
print(test_corpus[:5])<jupyter_output>25000 tokens in the corpus
[([2, 956, 9, 308, 16, 10, 20, 66, 173, 518, 38, 9, 761, 6, 896, 12, 653, 97, 31833, 10, 7, 167, 20, 43, 3, 1343, 47, 16, 1397, 375, 3, 587, 2635, 2, 75, 7, 2353, 1594, 103, 574, 35042, 17, 30, 182, 4827, 1945, 3, 67291, 5, 9, 118, 48, 25, 113, 224, 9922, 4, 759, 3, 2503, 11223, 5, 615, 480, 618, 70, 203, 3996, 313, 302, 26419, 52, 7, 164, 154, 6, 10, 728, 4, 20123, 2, 2570, 190, 2314, 38, 4239, 5, 262, 55, 543, 10, 293, 10, 20, 2994, 6, 4, 34, 2, 584, 104, 1833, 4, 295, 9, 95, 318, 5, 12, 35, 9, 1735, 12, 126, 147, 91, 76, 1736, 121, 135, 34, 9573, 6, 3369, 522, 8, 2, 13595, 5, 32, 6033, 1681, 5, 646, 33, 22, 35, 111, 3114, 4929, 70, 2, 6280, 5462, 11204, 2, 39597, 2838, 2, 26376, 2648, 3770, 2, 433, 68444, 2, 3494, 1842, 2, 4430, 2198, 27758, 2, 216246, 314, 1842, 3, 19928, 328, 216247, 4, 9, 147, 456, 43, 92, 5, 429, 382, 3, 138, 906, 90, 26, 95, 2, 20, 1090, 52, 66, 45, 518, 159, 8, 204, 18, 726, 10, 20, 14, 3, 2795, 9, 128, 81, 710, 3, 720, 34877, 216248[...]<jupyter_text>接下来，我们就可以开始把原始预料中的每个句子通过截断和填充，转换成一个固定长度的句子，并将所有数据整理成mini-batch，用于训练模型，代码如下：<jupyter_code>#编写一个迭代器，每次调用这个迭代器都会返回一个新的batch，用于训练或者预测
def build_batch(word2id_dict, corpus, batch_size, epoch_num, max_seq_len, shuffle = True):

    #模型将会接受的两个输入：
    # 1. 一个形状为[batch_size, max_seq_len]的张量，sentence_batch，代表了一个mini-batch的句子。
    # 2. 一个形状为[batch_size, 1]的张量，sentence_label_batch，
    #    每个元素都是非0即1，代表了每个句子的情感类别（正向或者负向）
    sentence_batch = []
    sentence_label_batch = []

    for _ in range(epoch_num): 

        #每个epcoh前都shuffle一下数据，有助于提高模型训练的效果
        #但是对于预测任务，不要做数据shuffle
        if shuffle:
            random.shuffle(corpus)

        for sentence, sentence_label in corpus:
            sentence_sample = sentence[:min(max_seq_len, len(sentence))]
            if len(sentence_sample) < max_seq_len:
                for _ in range(max_seq_len - len(sentence_sample)):
                    sentence_sample.append(word2id_dict['[pad]'])
            
            #飞桨在1.6.1要求输入数据必须是形状为[batch_size, max_seq_len，1]的张量
            #在飞桨的后续版本中不再存在类似的要求
            sentence_sample = [[word_id] for word_id in sentence_sample]

            sentence_batch.append(sentence_sample)
            sentence_label_batch.append([sentence_label])

            if len(sentence_batch) == batch_size:
                yield np.array(sentence_batch).astype("int64"), np.array(sentence_label_batch).astype("int64")
                sentence_batch = []
                sentence_label_batch = []

    if len(sentence_batch) == batch_size:
        yield np.array(sentence_batch).astype("int64"), np.array(sentence_label_batch).astype("int64")

for _, batch in zip(range(10), build_batch(word2id_dict, 
                    train_corpus, batch_size=3, epoch_num=3, max_seq_len=30)):
    print(batch)<jupyter_output>(array([[[ 35278],
        [     7],
        [     3],
        [    49],
        [  3171],
        [   391],
        [     5],
        [    24],
        [157929],
        [     6],
        [    28],
        [157930],
        [    12],
        [  1457],
        [    87],
        [     5],
        [     2],
        [  1433],
        [157931],
        [     8],
        [     3],
        [   108],
        [  6373],
        [ 17573],
        [   781],
        [  3007],
        [     4],
        [  2548],
        [  3110],
        [    19]],

       [[  8070],
        [    24],
        [    43],
        [     3],
        [    56],
        [   275],
        [    18],
        [  8070],
        [   351],
        [    36],
        [  1119],
        [     6],
        [   422],
        [    59],
        [  1179],
        [     4],
        [   180],
        [    89],
        [    19],
        [  9311],
        [ 68731],
        [     2],
        [  2214],
        [   202],
        [  4006],
       [...]<jupyter_text>网络定义 1. 定义长短时记忆模型使用飞桨定义一个长短时记忆模型，以便情感分析模型调用，代码如下：<jupyter_code>#使用飞桨实现一个长短时记忆模型
class SimpleLSTMRNN(fluid.Layer):
    
    def __init__(self,
                 name_scope,
                 hidden_size,
                 num_steps,
                 num_layers=1,
                 init_scale=0.1,
                 dropout=None):
        
        #这个模型有几个参数：
        #1. name_scope，表示这个模型的名字，用于区别不同的实例
        #2. hidden_size，表示embedding-size，或者是记忆向量的维度
        #3. num_steps，表示这个长短时记忆网络，最多可以考虑多长的时间序列
        #4. num_layers，表示这个长短时记忆网络内部有多少层，我们知道，
        #   给定一个形状为[batch_size, seq_len, embedding_size]的输入，
        # 长短时记忆网络会输出一个同样为[batch_size, seq_len, embedding_size]的输出，
        #   我们可以把这个输出再链到一个新的长短时记忆网络上
        # 如此叠加多层长短时记忆网络，有助于学习更复杂的句子甚至是篇章。
        #5. init_scale，表示网络内部的参数的初始化范围，
        # 长短时记忆网络内部用了很多tanh，sigmoid等激活函数，这些函数对数值精度非常敏感，
        # 因此我们一般只使用比较小的初始化范围，以保证效果，
        
        super(SimpleLSTMRNN, self).__init__(name_scope)
        self._hidden_size = hidden_size
        self._num_layers = num_layers
        self._init_scale = init_scale
        self._dropout = dropout
        self._input = None
        self._num_steps = num_steps
        self.cell_array = []
        self.hidden_array = []

        # weight_1_arr用于存储不同层的长短时记忆网络中，不同门的W参数
        self.weight_1_arr = []
        self.weight_2_arr = []
        # bias_arr用于存储不同层的长短时记忆网络中，不同门的b参数
        self.bias_arr = []
        self.mask_array = []

        # 通过使用create_parameter函数，创建不同长短时记忆网络层中的参数
        # 通过上面的公式，我们知道，我们总共需要8个形状为[_hidden_size, _hidden_size]的W向量
        # 和4个形状为[_hidden_size]的b向量，因此，我们在声明参数的时候，
        # 一次性声明一个大小为[self._hidden_size * 2, self._hidden_size * 4]的参数
        # 和一个 大小为[self._hidden_size * 4]的参数，这样做的好处是，
        # 可以使用一次矩阵计算，同时计算8个不同的矩阵乘法
        # 以便加快计算速度
        for i in range(self._num_layers):
            weight_1 = self.create_parameter(
                attr=fluid.ParamAttr(
                    initializer=fluid.initializer.UniformInitializer(
                        low=-self._init_scale, high=self._init_scale)),
                shape=[self._hidden_size * 2, self._hidden_size * 4],
                dtype="float32",
                default_initializer=fluid.initializer.UniformInitializer(
                    low=-self._init_scale, high=self._init_scale))
            self.weight_1_arr.append(self.add_parameter('w_%d' % i, weight_1))
            bias_1 = self.create_parameter(
                attr=fluid.ParamAttr(
                    initializer=fluid.initializer.UniformInitializer(
                        low=-self._init_scale, high=self._init_scale)),
                shape=[self._hidden_size * 4],
                dtype="float32",
                default_initializer=fluid.initializer.Constant(0.0))
            self.bias_arr.append(self.add_parameter('b_%d' % i, bias_1))

    # 定义LSTM网络的前向计算逻辑，飞桨会自动根据前向计算结果，给出反向结果
    def forward(self, input_embedding, init_hidden=None, init_cell=None):
        self.cell_array = []
        self.hidden_array = []
        
        #输入有三个信号：
        # 1. input_embedding，这个就是输入句子的embedding表示，
        # 是一个形状为[batch_size, seq_len, embedding_size]的张量
        # 2. init_hidden，这个表示LSTM中每一层的初始h的值，有时候，
        # 我们需要显示地指定这个值，在不需要的时候，就可以把这个值设置为空
        # 3. init_cell，这个表示LSTM中每一层的初始c的值，有时候，
        # 我们需要显示地指定这个值，在不需要的时候，就可以把这个值设置为空

        # 我们需要通过slice操作，把每一层的初始hidden和cell值拿出来，
        # 并存储在cell_array和hidden_array中
        for i in range(self._num_layers):
            pre_hidden = fluid.layers.slice(
                init_hidden, axes=[0], starts=[i], ends=[i + 1])
            pre_cell = fluid.layers.slice(
                init_cell, axes=[0], starts=[i], ends=[i + 1])
            pre_hidden = fluid.layers.reshape(
                pre_hidden, shape=[-1, self._hidden_size])
            pre_cell = fluid.layers.reshape(
                pre_cell, shape=[-1, self._hidden_size])
            self.hidden_array.append(pre_hidden)
            self.cell_array.append(pre_cell)

        # res记录了LSTM中每一层的输出结果（hidden）
        res = []
        for index in range(self._num_steps):
            # 首先需要通过slice函数，拿到输入tensor input_embedding中当前位置的词的向量表示
            # 并把这个词的向量表示转换为一个大小为 [batch_size, embedding_size]的张量
            self._input = fluid.layers.slice(
                input_embedding, axes=[1], starts=[index], ends=[index + 1])
            self._input = fluid.layers.reshape(
                self._input, shape=[-1, self._hidden_size])
            
            # 计算每一层的结果，从下而上
            for k in range(self._num_layers):
                # 首先获取每一层LSTM对应上一个时间步的hidden，cell，以及当前层的W和b参数
                pre_hidden = self.hidden_array[k]
                pre_cell = self.cell_array[k]
                weight_1 = self.weight_1_arr[k]
                bias = self.bias_arr[k]

                # 我们把hidden和拿到的当前步的input拼接在一起，便于后续计算
                nn = fluid.layers.concat([self._input, pre_hidden], 1)
                
                # 将输入门，遗忘门，输出门等对应的W参数，和输入input和pre-hidden相乘
                # 我们通过一步计算，就同时完成了8个不同的矩阵运算，提高了运算效率
                gate_input = fluid.layers.matmul(x=nn, y=weight_1)

                # 将b参数也加入到前面的运算结果中
                gate_input = fluid.layers.elementwise_add(gate_input, bias)
                
                # 通过split函数，将每个门得到的结果拿出来
                i, j, f, o = fluid.layers.split(
                    gate_input, num_or_sections=4, dim=-1)
                
                # 把输入门，遗忘门，输出门等对应的权重作用在当前输入input和pre-hidden上
                c = pre_cell * fluid.layers.sigmoid(f) + fluid.layers.sigmoid(
                    i) * fluid.layers.tanh(j)
                m = fluid.layers.tanh(c) * fluid.layers.sigmoid(o)
                
                # 记录当前步骤的计算结果，
                # m是当前步骤需要输出的hidden
                # c是当前步骤需要输出的cell
                self.hidden_array[k] = m
                self.cell_array[k] = c
                self._input = m
                
                # 一般来说，我们有时候会在LSTM的结果结果内加入dropout操作
                # 这样会提高模型的训练鲁棒性
                if self._dropout is not None and self._dropout > 0.0:
                    self._input = fluid.layers.dropout(
                        self._input,
                        dropout_prob=self._dropout,
                        dropout_implementation='upscale_in_train')
                    
            res.append(
                fluid.layers.reshape(
                    self._input, shape=[1, -1, self._hidden_size]))
        
        # 计算长短时记忆网络的结果返回回来，包括：
        # 1. real_res：每个时间步上不同层的hidden结果
        # 2. last_hidden：最后一个时间步中，每一层的hidden的结果，
        # 形状为：[batch_size, num_layers, hidden_size]
        # 3. last_cell：最后一个时间步中，每一层的cell的结果，
        # 形状为：[batch_size, num_layers, hidden_size]
        real_res = fluid.layers.concat(res, 0)
        real_res = fluid.layers.transpose(x=real_res, perm=[1, 0, 2])
        last_hidden = fluid.layers.concat(self.hidden_array, 1)
        last_hidden = fluid.layers.reshape(
            last_hidden, shape=[-1, self._num_layers, self._hidden_size])
        last_hidden = fluid.layers.transpose(x=last_hidden, perm=[1, 0, 2])
        last_cell = fluid.layers.concat(self.cell_array, 1)
        last_cell = fluid.layers.reshape(
            last_cell, shape=[-1, self._num_layers, self._hidden_size])
        last_cell = fluid.layers.transpose(x=last_cell, perm=[1, 0, 2])
        
        return real_res, last_hidden, last_cell<jupyter_output><empty_output><jupyter_text>2. 定义情感分析模型长短时记忆模型定义完成后，我们便可以开始定义一个基于长短时记忆模型的情感分析模型，代码如下：<jupyter_code># 定义一个可以用于情感分类的网络
class SentimentClassifier(fluid.Layer):
    def __init__(self,
                 name_scope,
                 hidden_size,
                 vocab_size,
                 class_num=2,
                 num_layers=1,
                 num_steps=128,
                 init_scale=0.1,
                 dropout=None):
        
        #这个模型的参数分别为：
        #1. name_scope，表示这个模型的名字，用于区别不同的实例
        #2. hidden_size，表示embedding-size，hidden已经cell向量的维度
        #3. vocab_size，模型可以考虑的词表大小
        #4. class_num，情感类型个数，可以是2分类，也可以是多分类
        #5. num_steps，表示这个情感分析模型最大可以考虑的句子长度
        #6. init_scale，表示网络内部的参数的初始化范围，
        # 长短时记忆网络内部用了很多tanh，sigmoid等激活函数，这些函数对数值精度非常敏感，
        # 因此我们一般只使用比较小的初始化范围，以保证效果
        
        super(SentimentClassifier, self).__init__(name_scope)
        self.hidden_size = hidden_size
        self.vocab_size = vocab_size
        self.class_num = class_num
        self.init_scale = init_scale
        self.num_layers = num_layers
        self.num_steps = num_steps
        self.dropout = dropout

        # 声明一个LSTM模型，用来把一个句子抽象城一个向量
        self.simple_lstm_rnn = SimpleLSTMRNN(
            self.full_name(),
            hidden_size,
            num_steps,
            num_layers=num_layers,
            init_scale=init_scale,
            dropout=dropout)
        
        # 声明一个embedding层，用来把句子中的每个词转换为向量
        self.embedding = Embedding(
            self.full_name(),
            size=[vocab_size, hidden_size],
            dtype='float32',
            is_sparse=False,
            param_attr=fluid.ParamAttr(
                name='embedding_para',
                initializer=fluid.initializer.UniformInitializer(
                    low=-init_scale, high=init_scale)))
        
        # 在得到一个句子的向量表示后，我们需要根据这个向量表示对这个句子进行分类
        # 一般来说，我们可以把这个句子的向量表示，
        # 乘以一个大小为[self.hidden_size, self.class_num]的W参数
        # 并加上一个大小为[self.class_num]的b参数
        # 通过这种手段达到把句子向量映射到分类结果的目标
        
        # 我们需要声明最终在使用句子向量映射到具体情感类别过程中所需要使用的参数
        # 这个参数的大小一般是[self.hidden_size, self.class_num]
        self.softmax_weight = self.create_parameter(
            attr=fluid.ParamAttr(),
            shape=[self.hidden_size, self.class_num],
            dtype="float32",
            default_initializer=fluid.initializer.UniformInitializer(
                low=-self.init_scale, high=self.init_scale))
        # 同样的，我们需要声明最终分类过程中的b参数
        #  这个参数的大小一般是[self.class_num]
        self.softmax_bias = self.create_parameter(
            attr=fluid.ParamAttr(),
            shape=[self.class_num],
            dtype="float32",
            default_initializer=fluid.initializer.UniformInitializer(
                low=-self.init_scale, high=self.init_scale))

    def forward(self, input, label):

        # 首先我们需要定义LSTM的初始hidden和cell，这里我们使用0来初始化这个序列的记忆
        init_hidden_data = np.zeros(
            (1, batch_size, embedding_size), dtype='float32')
        init_cell_data = np.zeros(
            (1, batch_size, embedding_size), dtype='float32')

        # 将这些初始记忆转换为飞桨可计算的向量
        # 并设置stop-gradient=True，避免这些向量被更新，从而影响训练效果
        init_hidden = fluid.dygraph.to_variable(init_hidden_data)
        init_hidden.stop_gradient = True
        init_cell = fluid.dygraph.to_variable(init_cell_data)
        init_cell.stop_gradient = True

        init_h = fluid.layers.reshape(
            init_hidden, shape=[self.num_layers, -1, self.hidden_size])

        init_c = fluid.layers.reshape(
            init_cell, shape=[self.num_layers, -1, self.hidden_size])

        # 将输入的句子的mini-batch input，转换为词向量表示
        x_emb = self.embedding(input)

        x_emb = fluid.layers.reshape(
            x_emb, shape=[-1, self.num_steps, self.hidden_size])
        if self.dropout is not None and self.dropout > 0.0:
            x_emb = fluid.layers.dropout(
                x_emb,
                dropout_prob=self.dropout,
                dropout_implementation='upscale_in_train')
        
        # 使用LSTM网络，把每个句子转换为向量表示
        rnn_out, last_hidden, last_cell = self.simple_lstm_rnn(x_emb, init_h,
                                                               init_c)
        last_hidden = fluid.layers.reshape(
            last_hidden, shape=[-1, self.hidden_size])
        
        # 将每个句子的向量表示，通过矩阵计算，映射到具体的情感类别上
        projection = fluid.layers.matmul(last_hidden, self.softmax_weight)
        projection = fluid.layers.elementwise_add(projection, self.softmax_bias)
        projection = fluid.layers.reshape(
            projection, shape=[-1, self.class_num])
        pred = fluid.layers.softmax(projection, axis=-1)
        
        # 根据给定的标签信息，计算整个网络的损失函数，这里我们可以直接使用分类任务中常使用的交叉熵来训练网络
        loss = fluid.layers.softmax_with_cross_entropy(
            logits=projection, label=label, soft_label=False)
        loss = fluid.layers.reduce_mean(loss)

        # 最终返回预测结果pred，和网络的loss
        return pred, loss<jupyter_output><empty_output><jupyter_text>模型训练在完成模型定义之后，我们就可以开始训练模型了。当训练结束以后，我们可以使用测试集合评估一下当前模型的效果，代码如下：<jupyter_code>import paddle.fluid as fluid
#开始训练
batch_size = 128
epoch_num = 5
embedding_size = 256
step = 0
learning_rate = 0.01
max_seq_len = 128

with fluid.dygraph.guard(fluid.CUDAPlace(0)):
    # 创建一个用于情感分类的网络实例，sentiment_classifier
    sentiment_classifier = SentimentClassifier(
        "sentiment_classifier", embedding_size, vocab_size, num_steps=max_seq_len)
    # 创建优化器AdamOptimizer，用于更新这个网络的参数
    adam = fluid.optimizer.AdamOptimizer(learning_rate=learning_rate)

    for sentences, labels in build_batch(
        word2id_dict, train_corpus, batch_size, epoch_num, max_seq_len):
        
        sentences_var = fluid.dygraph.to_variable(sentences)
        labels_var = fluid.dygraph.to_variable(labels)
        pred, loss = sentiment_classifier(sentences_var, labels_var)

        loss.backward()
        adam.minimize(loss)
        sentiment_classifier.clear_gradients()
        
        step += 1
        if step % 10 == 0:
            print("step %d, loss %.3f" % (step, loss.numpy()[0]))
            
    # 我们希望在网络训练结束以后评估一下训练好的网络的效果
    # 通过eval()函数，将网络设置为eval模式，在eval模式中，网络不会进行梯度更新
    sentiment_classifier.eval()
    # 这里我们需要记录模型预测结果的准确率
    # 对于二分类任务来说，准确率的计算公式为：
    # (true_positive + true_negative) / 
    # (true_positive + true_negative + false_positive + false_negative)
    tp = 0.
    tn = 0.
    fp = 0.
    fn = 0.
    for sentences, labels in build_batch(
        word2id_dict, test_corpus, batch_size, 1, max_seq_len):
        
        sentences_var = fluid.dygraph.to_variable(sentences)
        labels_var = fluid.dygraph.to_variable(labels)
        
        # 获取模型对当前batch的输出结果
        pred, loss = sentiment_classifier(sentences_var, labels_var)

        # 把输出结果转换为numpy array的数据结构
        # 遍历这个数据结构，比较预测结果和对应label之间的关系，并更新tp，tn，fp和fn
        pred = pred.numpy()
        for i in range(len(pred)):
            if labels[i][0] == 1:
                if pred[i][1] > pred[i][0]:
                    tp += 1
                else:
                    fn += 1
            else:
                if pred[i][1] > pred[i][0]:
                    fp += 1
                else:
                    tn += 1

    # 输出最终评估的模型效果
    print("the acc in the test set is %.3f" % ((tp + tn) / (tp + tn + fp + fn)))<jupyter_output>step 10, loss 0.701
step 20, loss 0.718
step 30, loss 0.761
step 40, loss 0.687
step 50, loss 0.681
step 60, loss 0.696
step 70, loss 0.689
step 80, loss 0.706
step 90, loss 0.682
step 100, loss 0.704
step 110, loss 0.680
step 120, loss 0.645
step 130, loss 0.533
step 140, loss 0.628
step 150, loss 0.624
step 160, loss 0.591
step 170, loss 0.515
step 180, loss 0.422
step 190, loss 0.417
step 200, loss 0.332
step 210, loss 0.432
step 220, loss 0.403
step 230, loss 0.307
step 240, loss 0.313
step 250, loss 0.399
step 260, loss 0.371
step 270, loss 0.275
step 280, loss 0.328
step 290, loss 0.339
step 300, loss 0.317
step 310, loss 0.329
step 320, loss 0.272
step 330, loss 0.259
step 340, loss 0.257
step 350, loss 0.297
step 360, loss 0.242
step 370, loss 0.393
step 380, loss 0.360
step 390, loss 0.361
step 400, loss 0.053
step 410, loss 0.024
step 420, loss 0.077
step 430, loss 0.095
step 440, loss 0.029
step 450, loss 0.049
step 460, loss 0.053
step 470, loss 0.121
step 480, loss 0.048
s[...]
{'fraction_non_alphanumeric': 0.1208681739970417, 'fraction_numerical': 0.04582364838349383, 'mean_word_length': 3.0939199209095403, 'pattern_counts': {'":': 0, '<': 45, '<?xml version=': 0, '>': 64, 'https://': 3, 'lorem ipsum': 0, 'www.': 1, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 2, 'word_list_counts': {'cursed_substrings.json': 5, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '10', 'dir': 'jupyter-structured-clean-dedup', 'id': '433721', 'license': 'Apache-2.0', 'n_tokens_mistral': 17768, 'n_tokens_neox': 18097, 'n_words': 2048, 'path': 'chapter-4-Natural_Language_Processing/4-3-Sentence_Classification.ipynb', 'repo_name': 'lcsss1995/tutorials', 'size': '33127'}