近十年著名演讲的情感分析#

注意

本文目前未经测试。请通过使其完全可执行来帮助改进本教程!

本教程演示了如何使用 NumPy 从头开始构建一个简单的长短期记忆网络 (LSTM),以对具有社会相关性和伦理获取的数据集执行情感分析。

您的深度学习模型(LSTM)是一种循环神经网络,它将学习从 IMDB 评论数据集中将一段文本分类为积极或消极。该数据集包含 50,000 条电影评论和相应的标签。基于这些评论及其相应标签的数字表示(监督学习),神经网络将通过前向传播和随时间反向传播来学习情感,因为我们在此处理的是序列数据。输出将是一个向量,其中包含文本样本为积极的概率。

如今,深度学习正在日常生活中得到应用,现在更重要的是要确保使用人工智能做出的决策不会反映对特定人群的歧视行为。在利用人工智能输出时,将公平性纳入考虑至关重要。在本教程中,我们将尝试从伦理角度审视我们管道中的所有步骤。

先决条件#

您应熟悉 Python 编程语言和 NumPy 数组操作。此外,建议对线性代数和微积分有一些了解。您还应熟悉神经网络的工作原理。作为参考,您可以访问 Pythonn维数组上的线性代数微积分教程。

要回顾深度学习基础知识,您应该考虑阅读d2l.ai 这本书,这是一本交互式深度学习书籍,包含多框架代码、数学和讨论。您还可以查阅从零开始的 MNIST 深度学习教程,了解如何从头开始实现一个基本的神经网络。

除了 NumPy,您还将利用以下 Python 标准模块进行数据加载和处理:

本教程可以在隔离环境中本地运行,例如 Virtualenvconda。您可以使用 Jupyter Notebook 或 JupyterLab 来运行每个笔记本单元。

目录#

  1. 数据收集

  2. 预处理数据集

  3. 从头开始构建和训练 LSTM 网络

  4. 对收集到的演讲进行情感分析

  5. 后续步骤

1. 数据收集#

在开始之前,您在选择用于训练模型的数据时,应始终牢记一些要点:

  • 识别数据偏差 - 偏差是人类思维过程中固有的组成部分。因此,来源于人类活动的数据反映了这种偏差。这种偏差在机器学习数据集中常见的表现形式有:

    • 历史数据中的偏差:历史数据通常偏向或不利于特定群体。数据也可能严重不平衡,对受保护群体的信息有限。

    • 数据收集机制中的偏差:缺乏代表性导致数据收集过程中存在固有偏差。

    • 对可观察结果的偏差:在某些情况下,我们只拥有部分人群的真实结果信息。在缺乏所有结果信息的情况下,甚至无法衡量公平性。

  • 保护敏感数据的人类匿名性Trevisan 和 Reilly 列出了一系列需要格外谨慎处理的敏感话题。我们在此列出并补充了一些:

    • 个人日常活动(包括位置数据);

    • 有关损伤和/或医疗记录的个人详细信息;

    • 疼痛和慢性病的感情描述;

    • 有关收入和/或福利金的财务信息;

    • 歧视和虐待事件;

    • 对医疗保健和支持服务提供者的个人批评/赞扬;

    • 自杀念头;

    • 对权力结构的批评/赞扬,特别是如果这会危及他们的安全;

    • 个人识别信息(即使以某种方式匿名化),包括指纹或声音等。

虽然从如此多的人那里获得同意可能很困难,尤其是在在线平台上,但其必要性取决于您数据中包含的主题的敏感性以及其他指标,例如数据来源平台是否允许用户使用化名操作。如果网站有强制使用真实姓名的政策,那么需要征求用户的同意。

在本节中,您将收集两个不同的数据集:IMDb 电影评论数据集,以及为本教程精心策划的 10 篇演讲,其中包括来自世界各地、不同时代和不同主题的活动家。前者将用于训练深度学习模型,而后者将用于进行情感分析。

收集 IMDb 评论数据集#

IMDb 评论数据集是由 Andrew L. Maas 从热门电影评分服务 IMDb 收集和准备的大型电影评论数据集。IMDb 评论数据集用于二元情感分类,即评论是积极的还是消极的。它包含 25,000 条电影评论用于训练,25,000 条用于测试。所有这 50,000 条评论都是带标签的数据,可用于监督深度学习。为了便于重现,我们将从 Zenodo 获取数据。

IMDb 平台允许将其公共数据集用于个人和非商业用途。我们已尽最大努力确保这些评论不包含任何前述与评论者相关的敏感话题。

收集和加载演讲稿#

我们选择了世界各地活动家关于气候变化、女权主义、LGBTQA+ 权利和种族主义等问题的演讲。这些演讲稿来源于报纸、联合国官方网站和知名大学的档案,如下表所示。我们创建了一个 CSV 文件,其中包含转录的演讲、演讲者和演讲来源。我们确保在数据中包含不同的人口统计学信息,并涵盖了一系列不同主题,其中大部分关注社会和/或伦理问题。

演讲

演讲者

来源

巴纳德学院毕业典礼

莉玛·博维

巴纳德学院

联合国青年教育演讲

马拉拉·优素福扎伊

《卫报》

联合国大会关于种族歧视的讲话

琳达·托马斯·格林菲尔德

美国驻联合国代表团

你们好大的胆子

格蕾塔·通贝里

NBC

让世界沉默五分钟的演讲

塞文·铃木

地球宪章

希望演讲

哈维·米尔克

波士顿美术馆

在“茁壮成长”大会上的演讲

艾伦·佩吉

赫芬顿邮报

我有一个梦想

马丁·路德·金

马歇尔大学

2. 预处理数据集#

数据预处理是构建任何深度学习模型之前极其关键的一步,但是为了使本教程专注于模型构建,我们不会深入探讨预处理的代码。下面简要概述了我们为清理数据并将其转换为数字表示所采取的所有步骤。

  1. 文本去噪:在将文本转换为向量之前,清理文本并去除所有无用的部分(即数据中的噪声)非常重要,这包括将所有字符转换为小写、删除 HTML 标签、括号和停用词(对句子意义贡献不大的词)。没有这一步,数据集通常是一堆计算机无法理解的词。

  2. 将词语转换为向量:词嵌入是一种学习到的文本表示形式,其中具有相同含义的词语具有相似的表示。单个词语在预定义的向量空间中表示为实值向量。GloVe 是斯坦福大学开发的一种无监督算法,通过从语料库生成全局词-词共现矩阵来生成词嵌入。您可以从 GloVe 官方网站下载包含嵌入的压缩文件。在这里,您可以选择不同大小或训练数据集的四个选项中的任意一个。我们选择了内存消耗最少的嵌入文件。

GloVe 词嵌入包含经过数十亿甚至高达 8400 亿个词元训练的集合。这些算法表现出刻板印象偏见,例如性别偏见,这可以追溯到原始训练数据。例如,某些职业似乎更偏向于特定性别,从而强化了有问题的刻板印象。解决此问题的最近方法是采用一些去偏算法,例如 这篇研究文章中提出的算法,可以将其用于您选择的嵌入上以减轻(如果存在)的偏见。

您将首先导入构建深度学习网络所需的包。

# Importing the necessary packages
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import pooch
import string
import re
import zipfile
import os

# Creating the random instance
rng = np.random.default_rng()

接下来,您将定义一组文本预处理辅助函数。

class TextPreprocess:
    """Text Preprocessing for a Natural Language Processing model."""

    def txt_to_df(self, file):
        """Function to convert a txt file to pandas dataframe.

        Parameters
        ----------
        file : str
            Path to the txt file.

        Returns
        -------
        Pandas dataframe
            txt file converted to a dataframe.

        """
        with open(imdb_train, 'r') as in_file:
            stripped = (line.strip() for line in in_file)
            reviews = {}
            for line in stripped:
                lines = [splits for splits in line.split("\t") if splits != ""]
                reviews[lines[1]] = float(lines[0])
        df = pd.DataFrame(reviews.items(), columns=['review', 'sentiment'])
        df = df.sample(frac=1).reset_index(drop=True)
        return df

    def unzipper(self, zipped, to_extract):
        """Function to extract a file from a zipped folder.

        Parameters
        ----------
        zipped : str
            Path to the zipped folder.

        to_extract: str
            Path to the file to be extracted from the zipped folder

        Returns
        -------
        str
            Path to the extracted file.

        """
        fh = open(zipped, 'rb')
        z = zipfile.ZipFile(fh)
        outdir = os.path.split(zipped)[0]
        z.extract(to_extract, outdir)
        fh.close()
        output_file = os.path.join(outdir, to_extract)
        return output_file

    def cleantext(self, df, text_column=None,
                  remove_stopwords=True, remove_punc=True):
        """Function to clean text data.

        Parameters
        ----------
        df : pandas dataframe
            The dataframe housing the input data.
        text_column : str
            Column in dataframe whose text is to be cleaned.
        remove_stopwords : bool
            if True, remove stopwords from text
        remove_punc : bool
            if True, remove punctuation symbols from text

        Returns
        -------
        Numpy array
            Cleaned text.

        """
        # converting all characters to lowercase
        df[text_column] = df[text_column].str.lower()

        # List of stopwords taken from https://gist.github.com/sebleier/554280
        stopwords = ["a", "about", "above", "after", "again", "against",
                     "all", "am", "an", "and", "any", "are",
                     "as", "at", "be", "because",
                     "been", "before", "being", "below",
                     "between", "both", "but", "by", "could",
                     "did", "do", "does", "doing", "down", "during",
                     "each", "few", "for", "from", "further",
                     "had", "has", "have", "having", "he",
                     "he'd", "he'll", "he's", "her", "here",
                     "here's", "hers", "herself", "him",
                     "himself", "his", "how", "how's", "i",
                     "i'd", "i'll", "i'm", "i've",
                     "if", "in", "into",
                     "is", "it", "it's", "its",
                     "itself", "let's", "me", "more",
                     "most", "my", "myself", "nor", "of",
                     "on", "once", "only", "or",
                     "other", "ought", "our", "ours",
                     "ourselves", "out", "over", "own", "same",
                     "she", "she'd", "she'll", "she's", "should",
                     "so", "some", "such", "than", "that",
                     "that's", "the", "their", "theirs", "them",
                     "themselves", "then", "there", "there's",
                     "these", "they", "they'd", "they'll",
                     "they're", "they've", "this", "those",
                     "through", "to", "too", "under", "until", "up",
                     "very", "was", "we", "we'd", "we'll",
                     "we're", "we've", "were", "what",
                     "what's", "when", "when's",
                     "where", "where's",
                     "which", "while", "who", "who's",
                     "whom", "why", "why's", "with",
                     "would", "you", "you'd", "you'll",
                     "you're", "you've",
                     "your", "yours", "yourself", "yourselves"]

        def remove_stopwords(data, column):
            data[f'{column} without stopwords'] = data[column].apply(
                lambda x: ' '.join([word for word in x.split() if word not in (stopwords)]))
            return data

        def remove_tags(string):
            result = re.sub('<*>', '', string)
            return result

        # remove html tags and brackets from text
        if remove_stopwords:
            data_without_stopwords = remove_stopwords(df, text_column)
            data_without_stopwords[f'clean_{text_column}'] = data_without_stopwords[f'{text_column} without stopwords'].apply(
                lambda cw: remove_tags(cw))
        if remove_punc:
            data_without_stopwords[f'clean_{text_column}'] = data_without_stopwords[f'clean_{text_column}'].str.replace(
                '[{}]'.format(string.punctuation), ' ', regex=True)

        X = data_without_stopwords[f'clean_{text_column}'].to_numpy()

        return X


    def sent_tokeniser(self, x):
        """Function to split text into sentences.

        Parameters
        ----------
        x : str
            piece of text

        Returns
        -------
        list
            sentences with punctuation removed.

        """
        sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?)\s', x)
        sentences.pop()
        sentences_cleaned = [re.sub(r'[^\w\s]', '', x) for x in sentences]
        return sentences_cleaned

    def word_tokeniser(self, text):
        """Function to split text into tokens.

        Parameters
        ----------
        x : str
            piece of text

        Returns
        -------
        list
            words with punctuation removed.

        """
        tokens = re.split(r"([-\s.,;!?])+", text)
        words = [x for x in tokens if (
            x not in '- \t\n.,;!?\\' and '\\' not in x)]
        return words

    def loadGloveModel(self, emb_path):
        """Function to read from the word embedding file.

        Returns
        -------
        Dict
            mapping from word to corresponding word embedding.

        """
        print("Loading Glove Model")
        File = emb_path
        f = open(File, 'r')
        gloveModel = {}
        for line in f:
            splitLines = line.split()
            word = splitLines[0]
            wordEmbedding = np.array([float(value) for value in splitLines[1:]])
            gloveModel[word] = wordEmbedding
        print(len(gloveModel), " words loaded!")
        return gloveModel

    def text_to_paras(self, text, para_len):
        """Function to split text into paragraphs.

        Parameters
        ----------
        text : str
            piece of text

        para_len : int
            length of each paragraph

        Returns
        -------
        list
            paragraphs of specified length.

        """
        # split the speech into a list of words
        words = text.split()
        # obtain the total number of paragraphs
        no_paras = int(np.ceil(len(words)/para_len))
        # split the speech into a list of sentences
        sentences = self.sent_tokeniser(text)
        # aggregate the sentences into paragraphs
        k, m = divmod(len(sentences), no_paras)
        agg_sentences = [sentences[i*k+min(i, m):(i+1)*k+min(i+1, m)] for i in range(no_paras)]
        paras = np.array([' '.join(sents) for sents in agg_sentences])

        return paras

Pooch 是一个由科学家创建的 Python 包,它管理通过 HTTP 下载数据文件并将其存储在本地目录中。我们使用它来设置一个下载管理器,其中包含从我们的注册表中获取数据文件并将其存储在指定缓存文件夹所需的所有信息。

data = pooch.create(
    # folder where the data will be stored in the
    # default cache folder of your Operating System
    path=pooch.os_cache("numpy-nlp-tutorial"),
    # Base URL of the remote data store
    base_url="",
    # The cache file registry. A dictionary with all files managed by this pooch.
    # The keys are the file names and values are their respective hash codes which
    # ensure we download the same, uncorrupted file each time.
    registry={
        "imdb_train.txt": "6a38ea6ab5e1902cc03f6b9294ceea5e8ab985af991f35bcabd301a08ea5b3f0",
         "imdb_test.txt": "7363ef08ad996bf4233b115008d6d7f9814b7cc0f4d13ab570b938701eadefeb",
        "glove.6B.50d.zip": "617afb2fe6cbd085c235baf7a465b96f4112bd7f7ccb2b2cbd649fed9cbcf2fb",
    },
    # Now specify custom URLs for some of the files in the registry.
    urls={
        "imdb_train.txt": "doi:10.5281/zenodo.4117827/imdb_train.txt",
        "imdb_test.txt": "doi:10.5281/zenodo.4117827/imdb_test.txt",
        "glove.6B.50d.zip": 'https://nlp.stanford.edu/data/glove.6B.zip'
    }
)

下载 IMDb 训练和测试数据文件

imdb_train = data.fetch('imdb_train.txt')
imdb_test = data.fetch('imdb_test.txt')

实例化 TextPreprocess 类以对数据集执行各种操作

textproc = TextPreprocess()

将每个 IMDb 文件转换为 pandas 数据框,以便更方便地预处理数据集

train_df = textproc.txt_to_df(imdb_train)
test_df = textproc.txt_to_df(imdb_test)

现在,您将通过删除停用词和标点符号的出现来清理上面获得的数据框。您还将从每个数据框中检索情感值以获取目标变量。

X_train = textproc.cleantext(train_df,
                       text_column='review',
                       remove_stopwords=True,
                       remove_punc=True)[0:2000]

X_test = textproc.cleantext(test_df,
                       text_column='review',
                       remove_stopwords=True,
                       remove_punc=True)[0:1000]

y_train = train_df['sentiment'].to_numpy()[0:2000]
y_test = test_df['sentiment'].to_numpy()[0:1000]

相同的过程也适用于已收集的演讲。

由于我们将在本教程后面进行每篇演讲的段落情感分析,我们需要标点符号将文本分成段落,因此在此阶段我们不删除它们的标点符号。

speech_data_path = 'tutorial-nlp-from-scratch/speeches.csv'
speech_df = pd.read_csv(speech_data_path)
X_pred = textproc.cleantext(speech_df,
                            text_column='speech',
                            remove_stopwords=True,
                            remove_punc=False)
speakers = speech_df['speaker'].to_numpy()

您现在将下载 GloVe 嵌入,解压缩它们并构建一个将每个单词和单词嵌入映射起来的字典。这将充当缓存,以便在您需要用每个单词的相应单词嵌入替换时使用。

glove = data.fetch('glove.6B.50d.zip')
emb_path = textproc.unzipper(glove, 'glove.6B.300d.txt')
emb_matrix = textproc.loadGloveModel(emb_path)

3. 构建深度学习模型#

是时候开始实现我们的 LSTM 了!您首先必须熟悉深度学习模型基本组成部分的一些高级概念。您可以参考 从零开始的 MNIST 深度学习教程

然后您将学习循环神经网络与普通神经网络有何不同,以及它为何如此适合处理序列数据。之后,您将使用 Python 和 NumPy 构建一个简单深度学习模型的构建块,并对其进行训练,使其能够以一定的准确性学习将一段文本的情感分类为积极或消极。

长短期记忆网络介绍#

多层感知机(MLP)中,信息只在一个方向上传播——从输入层,经过隐藏层,到输出层。信息直接穿过网络,从不考虑后续阶段的先前节点。因为它只考虑当前输入,所以学习到的特征不会在序列的不同位置共享。此外,它无法处理长度变化的序列。

与 MLP 不同,RNN 旨在处理序列预测问题。RNN 引入了状态变量来存储过去信息,并结合当前输入,以确定当前输出。由于 RNN 与序列中所有数据点共享学习到的特征,无论其长度如何,因此它能够处理长度变化的序列。

然而,RNN 的问题在于它无法保留长期记忆,因为给定输入对隐藏层的影响,以及因此对网络输出的影响,在网络循环连接中循环时会呈指数衰减或爆炸。这种缺点被称为梯度消失问题。长短期记忆 (LSTM) 是一种 RNN 架构,专门设计用于解决梯度消失问题

模型架构概述#

Overview of the model architecture, showing a series of animated boxes. There are five identical boxes labeled A and receiving as input one of the words in the phrase "life's a box of chocolates". Each box is highlighted in turn, representing the memory blocks of the LSTM network as information passes through them, ultimately reaching a "Positive" output value.

在上方的 GIF 中,标记为 \(A\) 的矩形称为 单元格(Cells),它们是我们的 LSTM 网络的 内存块(Memory Blocks)。它们负责选择在序列中记住什么,并通过两个状态将该信息传递给下一个单元格,这两个状态分别称为 隐藏状态(hidden state) \(H_{t}\)单元状态(cell state) \(C_{t}\),其中 \(t\) 表示时间步。每个 单元格 都有专门的门,负责存储、写入或读取传递给 LSTM 的信息。现在,我们将仔细查看网络的架构,并通过实现其中发生的每个机制来了解它。

我们首先编写一个函数来随机初始化模型训练时将学习到的参数

def initialise_params(hidden_dim, input_dim):
    # forget gate
    Wf = rng.standard_normal(size=(hidden_dim, hidden_dim + input_dim))
    bf = rng.standard_normal(size=(hidden_dim, 1))
    # input gate
    Wi = rng.standard_normal(size=(hidden_dim, hidden_dim + input_dim))
    bi = rng.standard_normal(size=(hidden_dim, 1))
    # candidate memory gate
    Wcm = rng.standard_normal(size=(hidden_dim, hidden_dim + input_dim))
    bcm = rng.standard_normal(size=(hidden_dim, 1))
    # output gate
    Wo = rng.standard_normal(size=(hidden_dim, hidden_dim + input_dim))
    bo = rng.standard_normal(size=(hidden_dim, 1))

    # fully connected layer for classification
    W2 = rng.standard_normal(size=(1, hidden_dim))
    b2 = np.zeros((1, 1))

    parameters = {
        "Wf": Wf,
        "bf": bf,
        "Wi": Wi,
        "bi": bi,
        "Wcm": Wcm,
        "bcm": bcm,
        "Wo": Wo,
        "bo": bo,
        "W2": W2,
        "b2": b2
    }
    return parameters

前向传播#

既然您已经初始化了参数,您就可以将输入数据沿前向方向通过网络。每一层都接受输入数据,进行处理并将其传递给下一层。这个过程称为 前向传播(Forward Propagation)。您将采用以下机制来实现它:

  • 加载输入数据的词嵌入

  • 将嵌入传递给 LSTM

  • 在 LSTM 的每个记忆块中执行所有门机制,以获得最终的隐藏状态

  • 将最终隐藏状态通过全连接层,以获得序列为正向的概率

  • 将所有计算值存储在缓存中,以便在反向传播期间使用

Sigmoid 属于非线性激活函数家族。它帮助网络更新或遗忘数据。如果一个值的 Sigmoid 结果为 0,则该信息被视为遗忘。类似地,如果结果为 1,则信息保留。

def sigmoid(x):
    n = np.exp(np.fmin(x, 0))
    d = (1 + np.exp(-np.abs(x)))
    return n / d

遗忘门将当前词嵌入和先前隐藏状态连接在一起作为输入。它决定旧记忆单元内容哪些部分需要关注,哪些可以忽略。

def fp_forget_gate(concat, parameters):
    ft = sigmoid(np.dot(parameters['Wf'], concat)
                 + parameters['bf'])
    return ft

输入门接收当前词嵌入和先前的隐藏状态连接在一起作为输入,并控制我们通过候选记忆门考虑多少新数据,该门利用Tanh来调节流经网络的值。

def fp_input_gate(concat, parameters):
    it = sigmoid(np.dot(parameters['Wi'], concat)
                 + parameters['bi'])
    cmt = np.tanh(np.dot(parameters['Wcm'], concat)
                  + parameters['bcm'])
    return it, cmt

最后我们有输出门,它从当前词嵌入、先前隐藏状态和已更新有遗忘门和输入门信息的单元状态中获取信息,以更新隐藏状态的值。

def fp_output_gate(concat, next_cs, parameters):
    ot = sigmoid(np.dot(parameters['Wo'], concat)
                 + parameters['bo'])
    next_hs = ot * np.tanh(next_cs)
    return ot, next_hs

下图总结了 LSTM 网络记忆块中的每个门机制

图片已根据来源修改

Diagram showing three sections of a memory block, labeled "Forget gate", "Input gate" and "Output gate". Each gate contains several subparts, representing the operations performed at that stage of the process.

但如何从 LSTM 的输出中获取情感?#

从序列中最后一个记忆块的输出门获得的隐藏状态被认为是序列中所有信息的表示。为了将这些信息分类到各种类别(在我们的例子中是积极和消极两类),我们使用一个全连接层,它首先将这些信息映射到预定义的输出大小(在我们的例子中是 1)。然后,一个激活函数(如 sigmoid)将此输出转换为介于 0 和 1 之间的值。我们将大于 0.5 的值视为表示积极情感。

def fp_fc_layer(last_hs, parameters):
    z2 = (np.dot(parameters['W2'], last_hs)
          + parameters['b2'])
    a2 = sigmoid(z2)
    return a2

现在您将把所有这些函数组合起来,总结模型架构中的前向传播步骤。

def forward_prop(X_vec, parameters, input_dim):

    hidden_dim = parameters['Wf'].shape[0]
    time_steps = len(X_vec)

    # Initialise hidden and cell state before passing to first time step
    prev_hs = np.zeros((hidden_dim, 1))
    prev_cs = np.zeros(prev_hs.shape)

    # Store all the intermediate and final values here
    caches = {'lstm_values': [], 'fc_values': []}

    # Hidden state from the last cell in the LSTM layer is calculated.
    for t in range(time_steps):
        # Retrieve word corresponding to current time step
        x = X_vec[t]
        # Retrieve the embedding for the word and reshape it to make the LSTM happy
        xt = emb_matrix.get(x, rng.random(size=(input_dim, 1)))
        xt = xt.reshape((input_dim, 1))

        # Input to the gates is concatenated previous hidden state and current word embedding
        concat = np.vstack((prev_hs, xt))

        # Calculate output of the forget gate
        ft = fp_forget_gate(concat, parameters)

        # Calculate output of the input gate
        it, cmt = fp_input_gate(concat, parameters)
        io = it * cmt

        # Update the cell state
        next_cs = (ft * prev_cs) + io

        # Calculate output of the output gate
        ot, next_hs = fp_output_gate(concat, next_cs, parameters)

        # store all the values used and calculated by
        # the LSTM in a cache for backward propagation.
        lstm_cache = {
        "next_hs": next_hs,
        "next_cs": next_cs,
        "prev_hs": prev_hs,
        "prev_cs": prev_cs,
        "ft": ft,
        "it" : it,
        "cmt": cmt,
        "ot": ot,
        "xt": xt,
        }
        caches['lstm_values'].append(lstm_cache)

        # Pass the updated hidden state and cell state to the next time step
        prev_hs = next_hs
        prev_cs = next_cs

    # Pass the LSTM output through a fully connected layer to
    # obtain probability of the sequence being positive
    a2 = fp_fc_layer(next_hs, parameters)

    # store all the values used and calculated by the
    # fully connected layer in a cache for backward propagation.
    fc_cache = {
    "a2" : a2,
    "W2" : parameters['W2']
    }
    caches['fc_values'].append(fc_cache)
    return caches

反向传播#

每次前向传播通过网络后,您将实现 随时间反向传播 算法,以累积每个参数在时间步长上的梯度。通过 LSTM 的反向传播不像其他常见的深度学习架构那样直接,因为它底层层之间的特殊交互方式。尽管如此,方法大致相同;识别依赖关系并应用链式法则。

我们首先定义一个函数,将每个参数的梯度初始化为由零组成的数组,其维度与相应参数相同。

# Initialise the gradients
def initialize_grads(parameters):
    grads = {}
    for param in parameters.keys():
        grads[f'd{param}'] = np.zeros((parameters[param].shape))
    return grads

现在,对于每个门和全连接层,我们定义一个函数来计算损失相对于传入输入和所用参数的梯度。为了理解导数计算背后的数学原理,我们建议您阅读 Christina Kouridi 的这篇有益的博客

定义一个函数来计算遗忘门中的梯度

def bp_forget_gate(hidden_dim, concat, dh_prev, dc_prev, cache, gradients, parameters):
    # dft = dL/da2 * da2/dZ2 * dZ2/dh_prev * dh_prev/dc_prev * dc_prev/dft
    dft = ((dc_prev * cache["prev_cs"] + cache["ot"]
           * (1 - np.square(np.tanh(cache["next_cs"])))
           * cache["prev_cs"] * dh_prev) * cache["ft"] * (1 - cache["ft"]))
    # dWf = dft * dft/dWf
    gradients['dWf'] += np.dot(dft, concat.T)
    # dbf = dft * dft/dbf
    gradients['dbf'] += np.sum(dft, axis=1, keepdims=True)
    # dh_f = dft * dft/dh_prev
    dh_f = np.dot(parameters["Wf"][:, :hidden_dim].T, dft)
    return dh_f, gradients

定义一个函数来计算输入门候选记忆门中的梯度

def bp_input_gate(hidden_dim, concat, dh_prev, dc_prev, cache, gradients, parameters):
    # dit = dL/da2 * da2/dZ2 * dZ2/dh_prev * dh_prev/dc_prev * dc_prev/dit
    dit = ((dc_prev * cache["cmt"] + cache["ot"]
           * (1 - np.square(np.tanh(cache["next_cs"])))
           * cache["cmt"] * dh_prev) * cache["it"] * (1 - cache["it"]))
    # dcmt = dL/da2 * da2/dZ2 * dZ2/dh_prev * dh_prev/dc_prev * dc_prev/dcmt
    dcmt = ((dc_prev * cache["it"] + cache["ot"]
            * (1 - np.square(np.tanh(cache["next_cs"])))
            * cache["it"] * dh_prev) * (1 - np.square(cache["cmt"])))
    # dWi = dit * dit/dWi
    gradients['dWi'] += np.dot(dit, concat.T)
    # dWcm = dcmt * dcmt/dWcm
    gradients['dWcm'] += np.dot(dcmt, concat.T)
    # dbi = dit * dit/dbi
    gradients['dbi'] += np.sum(dit, axis=1, keepdims=True)
    # dWcm = dcmt * dcmt/dbcm
    gradients['dbcm'] += np.sum(dcmt, axis=1, keepdims=True)
    # dhi = dit * dit/dh_prev
    dh_i = np.dot(parameters["Wi"][:, :hidden_dim].T, dit)
    # dhcm = dcmt * dcmt/dh_prev
    dh_cm = np.dot(parameters["Wcm"][:, :hidden_dim].T, dcmt)
    return dh_i, dh_cm, gradients

定义一个函数来计算输出门的梯度

def bp_output_gate(hidden_dim, concat, dh_prev, dc_prev, cache, gradients, parameters):
    # dot = dL/da2 * da2/dZ2 * dZ2/dh_prev * dh_prev/dot
    dot = (dh_prev * np.tanh(cache["next_cs"])
           * cache["ot"] * (1 - cache["ot"]))
    # dWo = dot * dot/dWo
    gradients['dWo'] += np.dot(dot, concat.T)
    # dbo = dot * dot/dbo
    gradients['dbo'] += np.sum(dot, axis=1, keepdims=True)
    # dho = dot * dot/dho
    dh_o = np.dot(parameters["Wo"][:, :hidden_dim].T, dot)
    return dh_o, gradients

定义一个函数来计算全连接层的梯度

def bp_fc_layer (target, caches, gradients):
    # dZ2 = dL/da2 * da2/dZ2
    predicted = np.array(caches['fc_values'][0]['a2'])
    target = np.array(target)
    dZ2 = predicted - target
    # dW2 = dL/da2 * da2/dZ2 * dZ2/dW2
    last_hs = caches['lstm_values'][-1]["next_hs"]
    gradients['dW2'] = np.dot(dZ2, last_hs.T)
    # db2 = dL/da2 * da2/dZ2 * dZ2/db2
    gradients['db2'] = np.sum(dZ2)
    # dh_last = dZ2 * W2
    W2 = caches['fc_values'][0]["W2"]
    dh_last = np.dot(W2.T, dZ2)
    return dh_last, gradients

将所有这些函数组合起来,总结我们模型的反向传播步骤。

def backprop(y, caches, hidden_dim, input_dim, time_steps, parameters):

    # Initialize gradients
    gradients = initialize_grads(parameters)

    # Calculate gradients for the fully connected layer
    dh_last, gradients = bp_fc_layer(target, caches, gradients)

    # Initialize gradients w.r.t previous hidden state and previous cell state
    dh_prev = dh_last
    dc_prev = np.zeros((dh_prev.shape))

    # loop back over the whole sequence
    for t in reversed(range(time_steps)):
        cache = caches['lstm_values'][t]

        # Input to the gates is concatenated previous hidden state and current word embedding
        concat = np.concatenate((cache["prev_hs"], cache["xt"]), axis=0)

        # Compute gates related derivatives
        # Calculate derivative w.r.t the input and parameters of forget gate
        dh_f, gradients = bp_forget_gate(hidden_dim, concat, dh_prev, dc_prev, cache, gradients, parameters)

        # Calculate derivative w.r.t the input and parameters of input gate
        dh_i, dh_cm, gradients = bp_input_gate(hidden_dim, concat, dh_prev, dc_prev, cache, gradients, parameters)

        # Calculate derivative w.r.t the input and parameters of output gate
        dh_o, gradients = bp_output_gate(hidden_dim, concat, dh_prev, dc_prev, cache, gradients, parameters)

        # Compute derivatives w.r.t prev. hidden state and the prev. cell state
        dh_prev = dh_f + dh_i + dh_cm + dh_o
        dc_prev = (dc_prev * cache["ft"] + cache["ot"]
                   * (1 - np.square(np.tanh(cache["next_cs"])))
                   * cache["ft"] * dh_prev)

    return gradients

更新参数#

我们通过一种称为 Adam 的优化算法来更新参数,它是随机梯度下降的一种扩展,最近在计算机视觉和自然语言处理的深度学习应用中得到了更广泛的采用。具体来说,该算法计算梯度和平方梯度的指数移动平均值,参数 beta1beta2 控制这些移动平均值的衰减率。Adam 在其他梯度下降算法方面显示出更高的收敛性和鲁棒性,并且通常被推荐作为默认的训练优化器。

定义一个函数来初始化每个参数的移动平均值

# initialise the moving averages
def initialise_mav(hidden_dim, input_dim, params):
    v = {}
    s = {}
    # Initialize dictionaries v, s
    for key in params:
        v['d' + key] = np.zeros(params[key].shape)
        s['d' + key] = np.zeros(params[key].shape)
    # Return initialised moving averages
    return v, s

定义一个函数来更新参数

# Update the parameters using Adam optimization
def update_parameters(parameters, gradients, v, s,
                      learning_rate=0.01, beta1=0.9, beta2=0.999):
    for key in parameters:
        # Moving average of the gradients
        v['d' + key] = (beta1 * v['d' + key]
                        + (1 - beta1) * gradients['d' + key])

        # Moving average of the squared gradients
        s['d' + key] = (beta2 * s['d' + key]
                        + (1 - beta2) * (gradients['d' + key] ** 2))

        # Update parameters
        parameters[key] = (parameters[key] - learning_rate
                           * v['d' + key] / np.sqrt(s['d' + key] + 1e-8))
    # Return updated parameters and moving averages
    return parameters, v, s

训练网络#

您将首先初始化网络中使用的所有参数和超参数

hidden_dim = 64
input_dim = emb_matrix['memory'].shape[0]
learning_rate = 0.001
epochs = 10
parameters = initialise_params(hidden_dim,
                               input_dim)
v, s = initialise_mav(hidden_dim,
                      input_dim,
                      parameters)

为了优化您的深度学习网络,您需要根据模型在训练数据上的表现来计算损失。损失值表示模型在每次优化迭代后表现得有多糟糕或多好。
定义一个函数,使用负对数似然来计算损失。

def loss_f(A, Y):
    # define value of epsilon to prevent zero division error inside a log
    epsilon = 1e-5
    # Implement formula for negative log likelihood
    loss = (- Y * np.log(A + epsilon)
            - (1 - Y) * np.log(1 - A + epsilon))
    # Return loss
    return np.squeeze(loss)

设置神经网络的学习实验,包括训练循环,并启动训练过程。您还将评估模型在训练数据集上的性能,以了解模型“学习”得如何,以及在测试数据集上的性能,以了解其“泛化”能力。

如果您已将训练好的参数存储在 npy 文件中,则跳过运行此单元格。

# To store training losses
training_losses = []
# To store testing losses
testing_losses = []

# This is a training loop.
# Run the learning experiment for a defined number of epochs (iterations).
for epoch in range(epochs):
    #################
    # Training step #
    #################
    train_j = []
    for sample, target in zip(X_train, y_train):
        # split text sample into words/tokens
        b = textproc.word_tokeniser(sample)

        # Forward propagation/forward pass:
        caches = forward_prop(b,
                              parameters,
                              input_dim)

        # Backward propagation/backward pass:
        gradients = backprop(target,
                             caches,
                             hidden_dim,
                             input_dim,
                             len(b),
                             parameters)

        # Update the weights and biases for the LSTM and fully connected layer
        parameters, v, s = update_parameters(parameters,
                                             gradients,
                                             v,
                                             s,
                                             learning_rate=learning_rate,
                                             beta1=0.999,
                                             beta2=0.9)

        # Measure the training error (loss function) between the actual
        # sentiment (the truth) and the prediction by the model.
        y_pred = caches['fc_values'][0]['a2'][0][0]
        loss = loss_f(y_pred, target)
        # Store training set losses
        train_j.append(loss)

    ###################
    # Evaluation step #
    ###################
    test_j = []
    for sample, target in zip(X_test, y_test):
        # split text sample into words/tokens
        b = textproc.word_tokeniser(sample)

        # Forward propagation/forward pass:
        caches = forward_prop(b,
                              parameters,
                              input_dim)

        # Measure the testing error (loss function) between the actual
        # sentiment (the truth) and the prediction by the model.
        y_pred = caches['fc_values'][0]['a2'][0][0]
        loss = loss_f(y_pred, target)

        # Store testing set losses
        test_j.append(loss)

    # Calculate average of training and testing losses for one epoch
    mean_train_cost = np.mean(train_j)
    mean_test_cost = np.mean(test_j)
    training_losses.append(mean_train_cost)
    testing_losses.append(mean_test_cost)
    print('Epoch {} finished. \t  Training Loss : {} \t  Testing Loss : {}'.
          format(epoch + 1, mean_train_cost, mean_test_cost))

# save the trained parameters to a npy file
np.save('tutorial-nlp-from-scratch/parameters.npy', parameters)

绘制训练和测试损失图是一个好习惯,因为学习曲线通常有助于诊断机器学习模型的行为。

fig = plt.figure()
ax = fig.add_subplot(111)

# plot the training loss
ax.plot(range(0, len(training_losses)), training_losses, label='training loss')
# plot the testing loss
ax.plot(range(0, len(testing_losses)), testing_losses, label='testing loss')

# set the x and y labels
ax.set_xlabel("epochs")
ax.set_ylabel("loss")

plt.legend(title='labels', bbox_to_anchor=(1.0, 1), loc='upper left')
plt.show()

演讲数据情感分析#

模型训练完成后,您可以使用更新后的参数开始进行预测。您可以将每篇演讲分成统一大小的段落,然后将其传递给深度学习模型,并预测每个段落的情感。

# To store predicted sentiments
predictions = {}

# define the length of a paragraph
para_len = 100

# Retrieve trained values of the parameters
if os.path.isfile('tutorial-nlp-from-scratch/parameters.npy'):
    parameters = np.load('tutorial-nlp-from-scratch/parameters.npy', allow_pickle=True).item()

# This is the prediction loop.
for index, text in enumerate(X_pred):
    # split each speech into paragraphs
    paras = textproc.text_to_paras(text, para_len)
    # To store the network outputs
    preds = []

    for para in paras:
        # split text sample into words/tokens
        para_tokens = textproc.word_tokeniser(para)
        # Forward Propagation
        caches = forward_prop(para_tokens,
                              parameters,
                              input_dim)

        # Retrieve the output of the fully connected layer
        sent_prob = caches['fc_values'][0]['a2'][0][0]
        preds.append(sent_prob)

    threshold = 0.5
    preds = np.array(preds)
    # Mark all predictions > threshold as positive and < threshold as negative
    pos_indices = np.where(preds > threshold)  # indices where output > 0.5
    neg_indices = np.where(preds < threshold)  # indices where output < 0.5
    # Store predictions and corresponding piece of text
    predictions[speakers[index]] = {'pos_paras': paras[pos_indices[0]],
                                    'neg_paras': paras[neg_indices[0]]}

可视化情感预测

x_axis = []
data = {'positive sentiment': [], 'negative sentiment': []}
for speaker in predictions:
    # The speakers will be used to label the x-axis in our plot
    x_axis.append(speaker)
    # number of paras with positive sentiment
    no_pos_paras = len(predictions[speaker]['pos_paras'])
    # number of paras with negative sentiment
    no_neg_paras = len(predictions[speaker]['neg_paras'])
    # Obtain percentage of paragraphs with positive predicted sentiment
    pos_perc = no_pos_paras / (no_pos_paras + no_neg_paras)
    # Store positive and negative percentages
    data['positive sentiment'].append(pos_perc*100)
    data['negative sentiment'].append(100*(1-pos_perc))

index = pd.Index(x_axis, name='speaker')
df = pd.DataFrame(data, index=index)
ax = df.plot(kind='bar', stacked=True)
ax.set_ylabel('percentage')
ax.legend(title='labels', bbox_to_anchor=(1, 1), loc='upper left')
plt.show()

在上面的图中,您可以看到每篇演讲预计携带积极和消极情感的百分比。由于本实现优先考虑简单性和清晰度而非性能,我们不能期望这些结果非常准确。此外,在对一个段落进行情感预测时,我们没有使用相邻段落作为上下文,这本可以带来更准确的预测。我们鼓励读者尝试模型,并进行 后续步骤 中建议的一些调整,观察模型性能如何变化。

从伦理角度审视我们的神经网络#

理解准确识别文本情感并非易事至关重要,这主要是因为人类表达情感的方式复杂多样,例如使用反讽、讽刺、幽默,或在社交媒体中使用缩写。此外,将文本简单地分为“积极”和“消极”两类可能存在问题,因为这样做没有任何上下文。词语或缩写可能根据年龄和地点传达截然不同的情感,而我们在构建模型时并未考虑这些因素。

除了数据,人们也越来越担心数据处理算法以不透明的方式影响政策和日常生活,并引入偏见。某些偏见,如归纳偏置,对于帮助机器学习模型更好地泛化至关重要,例如我们之前构建的 LSTM 偏向于在长序列中保留上下文信息,这使得它非常适合处理序列数据。问题在于当社会偏见渗透到算法预测中时。通过超参数调整等方法优化机器学习算法,可以进一步放大这些偏见,因为它会学习数据中的每一位信息。

也有一些情况是偏见仅存在于输出中,而不在输入(数据、算法)中。例如,在情感分析中,女性作者文本的准确率往往高于男性作者文本。情感分析的最终用户应意识到其微小的性别偏见可能会影响从中得出的结论,并在必要时应用校正因子。因此,对算法问责制的要求应包括测试系统输出的能力,包括按性别、种族和其他特征深入分析不同用户群体的能力,以识别并希望纠正系统输出偏见。

后续步骤#

您已经学习了如何仅使用 NumPy 从头开始构建和训练一个简单的长短期记忆网络来执行情感分析。

为了进一步增强和优化您的神经网络模型,您可以考虑以下一种或多种方法:

  • 通过引入多个 LSTM 层来改变架构,使网络更深。

  • 使用更高的 epoch 大小进行更长时间的训练,并添加更多的正则化技术,如早期停止,以防止过拟合。

  • 引入验证集,对模型拟合进行无偏评估。

  • 应用批量归一化以实现更快、更稳定的训练。

  • 调整其他参数,例如学习率和隐藏层大小。

  • 使用Xavier 初始化来初始化权重,以防止梯度消失/爆炸,而不是随机初始化它们。

  • 双向 LSTM 替换 LSTM,以利用左右上下文来预测情感。

如今,LSTM 已被Transformer取代,后者使用注意力机制来解决困扰 LSTM 的所有问题,例如缺乏迁移学习、缺乏并行训练以及长序列的梯度链过长问题。

从头开始使用 NumPy 构建神经网络是了解 NumPy 和深度学习的好方法。然而,对于实际应用,您应该使用专门的框架——如 PyTorch、JAX 或 TensorFlow——它们提供类似 NumPy 的 API,内置自动微分和 GPU 支持,并且专为高性能数值计算和机器学习而设计。

最后,要了解更多关于在开发机器学习模型时伦理如何发挥作用的信息,您可以参考以下资源: