字符串(2)“90”
对过go十年著名演讲的情感分析 — NumPy 教程

过go十年著名演讲的情绪分析#

警告

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

本教程演示如何在 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 文件,其中包含转录的演讲、演讲者以及演讲的来源。我们确保在数据中包含不同的人口统计数据,并包含一系列不同的主题,其中大多数关注社会和/或道德问题。

演讲

扬声器

来源

巴纳德学院毕业典礼

莱玛·古博伊

巴纳德学院

联合国关于青年教育的演讲

马拉拉·优素福扎伊

守护者

在联大关于种族歧视的讲话

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

美国常驻联合国代表团

你怎么敢

格蕾塔·桑伯格

美国全国广播公司

令世界沉默5分钟的演讲

铃木塞文

地球宪章

希望演讲

哈维·米尔克

波士顿美术博物馆

在Thrive大会上的讲话

艾伦·佩吉

赫芬顿邮报

我有一个梦想

马丁·路德·金

马歇尔大学

2. 预处理数据集#

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

  1. 文本go噪:在将文本转换为向量之前,重要的是要清理它并通过将所有字符转换为小写、删除 html 标签、括号和停用词(不会添加太多内容的单词)来删除所有无用的部分(即数据中的噪音)句子的意思)。如果没有这一步,数据集通常是计算机无法理解的一组单词。

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

GloVe 词嵌入包括使用数十亿个令牌进行训练的集合,其中一些令牌高达 8400 亿个。这些算法表现出刻板的偏见,例如可以追溯到原始训练数据的性别偏见。例如,某些职业似乎更偏向于特定性别,从而强化了有问题的陈规定型观念。该问题最接近的解决方案是一些go偏差算法,如 https://web.stanford.edu/class/archive/cs/cs224n/cs224n.1184/reports/6835575.pdf 中介绍的算法,可以在嵌入上使用他们选择减少偏见(如果存在)。

您将首先导入必要的包来构建我们的深度学习网络。

# 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 引入状态变量来存储过go的信息以及当前的输入,以确定当前的输出。由于 RNN 与序列中的所有数据点共享学习到的特征(无论其长度如何),因此它能够处理不同长度的序列。

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

模型架构概述#

模型架构概述,显示一系列动画框。有五个相同的盒子,标记为 A,并接收短语“life's a box of Chocolates”中的单词之一作为输入。每个框依次突出显示,代表信息通过 LSTM 网络的记忆块,最终达到“正”输出值。

在上面的 gif 中,标记的矩形\(A\)被称为Cells,它们是我们 LSTM 网络的内存块。它们负责选择在序列中记住什么,并通过称为“记忆”的两种状态将该信息传递到下一个单元。hidden state \(H_{t}\)cell state \(C_{t}\)在哪里\(t\)表示时间步长。每个Cell都有专用的门,负责存储、写入或读取传递给 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 网络内存块中的每个门机制:

图片已从此来源修改

该图显示了内存块的三个部分,标记为“遗忘门”、“输入门”和“输出门”。每个门包含几个子部分,代表在该过程的该阶段执行的操作。

但是如何从 LSTM 的输出中获取情绪呢?#

从序列中最后一个内存块的输出门获得的隐藏状态被认为是序列中包含的所有信息的表示。为了将这些信息分类为各种类别(在我们的例子中为 2 类,正类和负类),我们使用全连接层,该层首先将此信息映射到预定义的输出大小(在我们的例子中为 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 进行反向传播并不像通过其他常见深度学习架构那样简单。尽管如此,方法基本上是相同的;识别依赖关系并应用链式法则。backpropagation through time

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

# 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()

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

从伦理角度看待我们的神经网络#

重要的是要明白,准确识别文本的情感并不容易,这主要是因为人类表达情感的方式很复杂,包括使用反讽、挖苦、幽默,或者在社交媒体中使用缩写。此外,将文本整齐地分为两类:“正面”和“负面”可能会出现问题,因为它是在没有任何上下文的情况下完成的。根据年龄和地点,单词或缩写可以传达非常不同的情感,而我们在构建模型时没有考虑到这些。

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

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

下一步

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

为了进一步增强和优化您的神经网络模型,您可以考虑以下组合之一:

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

  • 使用更大的纪元大小来训练更长时间,并添加更多正则化技术(例如提前停止)以防止过度拟合。

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

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

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

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

  • 将 LSTM 替换为双向 LSTM,以使用左右上下文来预测情绪。

如今,LSTM 已被Transformer取代(Transformer 使用Attention来解决困扰 LSTM 的所有问题,例如缺乏迁移学习、缺乏并行训练以及长序列的长梯度链)

使用 NumPy 从头开始​​构建神经网络是了解 NumPy 和深度学习更多信息的好方法。然而,对于现实世界的应用程序,您应该使用专门的框架 - 例如 PyTorch、JAX、TensorFlow 或 MXNet - 提供类似 NumPy 的 API,具有内置的自动微分和 GPU 支持,并且专为高性能数值计算和机器学习。

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

  • 图灵研究所的数据伦理资源。 https://www.turing.ac.uk/research/data-ethics

  • 考虑人工智能如何转移权力,Pratyusha Kalluri 的文章演讲

  • 更多道德资源请参阅Rachel Thomas 和Radical AI 播客的这篇博文