过去十年重要演讲的情感分析#
注意
本文目前尚未测试。通过使其完全可执行来帮助改进本教程!
本教程演示了如何使用 NumPy 从头开始构建一个简单的长短期记忆网络 (LSTM),以对社会相关且伦理获取的数据集执行情感分析。
您的深度学习模型(LSTM)是一种循环神经网络的形式,它将学习从 IMDB 影评数据集对一段文本进行正面或负面的分类。数据集包含 50,000 条电影评论和相应的标签。基于这些评论及其相应标签的数字表示(监督学习),神经网络将通过时间进行前向传播和反向传播进行训练,以学习情感,因为我们在这里处理的是顺序数据。输出将是一个向量,包含文本样本为正面的概率。
如今,深度学习正逐渐应用于日常生活,因此,确保使用 AI 做出的决策不会对特定人群表现出歧视行为变得越来越重要。在使用 AI 输出时,务必考虑公平性。在整个教程中,我们将尝试从伦理的角度对我们流水线中的所有步骤提出质疑。
先决条件#
您应该熟悉 Python 编程语言以及使用 NumPy 进行数组操作。此外,建议了解一些线性代数和微积分知识。您还应该熟悉神经网络的工作原理。作为参考,您可以访问Python、N 维数组上的线性代数和微积分教程。
要复习深度学习基础知识,您可以考虑阅读d2l.ai 书籍,这是一本包含多框架代码、数学和讨论的交互式深度学习书籍。您还可以参考从头开始的 MNIST 深度学习教程,了解如何从头开始实现一个基本的神经网络。
除了 NumPy 之外,您还将利用以下 Python 标准模块进行数据加载和处理
pandas
用于处理数据帧Matplotlib
用于数据可视化pooch
用于下载和缓存数据集
本教程可以在隔离的环境中本地运行,例如Virtualenv或conda。您可以使用Jupyter Notebook 或 JupyterLab运行每个 Notebook 单元格。
目录#
数据收集
预处理数据集
构建和训练从头开始的 LSTM 网络
对收集到的演讲进行情感分析
后续步骤
1. 数据收集#
在开始之前,您应该始终牢记一些要点,然后再选择您希望用于训练模型的数据
**识别数据偏差** - 偏差是人类思维过程的固有组成部分。因此,来自人类活动的数据反映了这种偏差。机器学习数据集中这种偏差发生的一些方式包括
历史数据中的偏差:历史数据通常偏向于或反对特定群体。数据也可能严重不平衡,受保护群体的相关信息有限。
数据收集机制中的偏差:缺乏代表性会在数据收集过程中引入固有偏差。
偏向可观察结果:在某些情况下,我们只有特定人群的真实结果信息。在缺乏所有结果信息的情况下,甚至无法衡量公平性
**保护敏感数据的个人匿名性**:Trevisan 和 Reilly 确定了一系列需要格外谨慎处理的敏感话题。我们将在下面给出相同的列表,并增加一些内容
个人日常活动(包括位置数据);
关于损伤和/或医疗记录的个人详细信息;
关于疼痛和慢性疾病的情感描述;
关于收入和/或福利金的财务信息;
歧视和虐待事件;
对医疗保健和支持服务个别提供者的批评/赞扬;
自杀念头;
对权力结构的批评/赞扬,尤其是在损害其安全的情况下;
个人识别信息(即使以某种方式匿名化),包括指纹或语音等。
虽然从这么多人那里尤其是在线平台上获得同意可能很困难,但其必要性取决于您的数据包含的主题的敏感性以及其他指标,例如数据获取的平台是否允许用户以化名操作。如果网站的政策强制使用真实姓名,则需要征求用户的同意。
在本节中,您将收集两个不同的数据集:IMDb 电影评论数据集和为本教程精心挑选的 10 篇演讲,包括来自世界各地不同国家、不同时间和不同主题的活动家。前者将用于训练深度学习模型,而后者将用于执行情感分析。
收集 IMDb 影评数据集#
IMDb 影评数据集是由 Andrew L. Maas 从流行的电影评分服务 IMDb 收集和准备的大型电影评论数据集。IMDb 影评数据集用于二元情感分类,即评论是正面还是负面。它包含 25,000 条电影评论用于训练,25,000 条用于测试。这 50,000 条评论都是带标签的数据,可用于监督深度学习。为了方便复制,我们将从Zenodo获取数据。
IMDb 平台允许将他们的公共数据集用于个人和非商业用途。我们尽最大努力确保这些评论不包含任何上述与评论者相关的敏感话题。
收集和加载演讲稿#
我们选择了来自全球各地活动家的演讲,他们讨论了气候变化、女权主义、LGBTQ+ 权利和种族主义等问题。这些演讲来自报纸、联合国官方网站和知名大学的档案,如以下表格所示。我们创建了一个 CSV 文件,其中包含演讲稿、演讲者以及演讲稿来源。我们确保在数据中包含不同的群体,并包含一系列不同的主题,其中大部分主题侧重于社会和/或伦理问题。
2. 预处理数据集#
在构建任何深度学习模型之前,预处理数据是一个极其重要的步骤,但是为了使教程专注于构建模型,我们不会深入研究预处理代码。下面简要概述了我们为清理数据并将其转换为数字表示而采取的所有步骤。
**文本去噪**:在将文本转换为向量之前,务必对其进行清理并删除所有无用的部分,即通过将所有字符转换为小写、删除 html 标签、括号和停用词(对句子意义贡献不大的词)来消除数据中的噪声。如果没有此步骤,数据集通常是一堆计算机不理解的词。
**将单词转换为向量**:词嵌入是文本的学习表示,其中具有相同含义的词具有相似的表示。单个词在预定义的向量空间中表示为实值向量。GloVe 是斯坦福大学开发的一种无监督算法,通过从语料库生成全局词-词共现矩阵来生成词嵌入。您可以从 https://nlp.stanford.edu/projects/glove/ 下载包含嵌入的压缩文件。在这里,您可以选择四种选项中的任何一种,以获得不同大小或训练数据集。我们选择了最节省内存的嵌入文件。
GloVe 词嵌入包括在数十亿个标记上训练的集合,有些高达 8400 亿个标记。这些算法表现出刻板印象的偏差,例如可以追溯到原始训练数据的性别偏差。例如,某些职业似乎更偏向于特定性别,从而强化了存在问题刻板印象。解决此问题的最接近的方案是一些去偏算法,如 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 引入状态变量来存储过去的信息,以及当前输入,以确定当前输出。由于 RNN 无论序列长度如何,都会与序列中所有数据点共享学习到的特征,因此它能够处理长度不同的序列。
然而,RNN 的问题在于它无法保留长期记忆,因为给定输入对隐藏层的影响,进而对网络输出的影响,要么随着它在网络的循环连接中循环而呈指数衰减,要么呈指数增长。这种缺点被称为**梯度消失问题**。长短期记忆网络(LSTM)是一种专门设计用来解决梯度消失问题的 RNN 架构。
模型架构概述#
在上图 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
反向传播#
在每次通过网络的前向传递之后,您将实现backpropagation through time
算法,以累积每个参数在时间步长上的梯度。由于其底层层的特殊交互方式,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 的优化算法更新参数,Adam 是随机梯度下降的扩展,最近在计算机视觉和自然语言处理的深度学习应用中得到更广泛的采用。具体来说,该算法计算梯度和梯度平方的指数移动平均值,参数beta1
和beta2
控制这些移动平均值的衰减率。与其他梯度下降算法相比,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 层来更改架构,使网络更深。
使用更大的 epoch 大小进行更长时间的训练,并添加更多正则化技术(如提前停止),以防止过拟合。
引入验证集,以便对模型拟合进行无偏评估。
应用批归一化,以实现更快、更稳定的训练。
调整其他参数,例如学习率和隐藏层大小。
使用Xavier 初始化初始化权重,以防止梯度消失/爆炸,而不是随机初始化它们。
将 LSTM 替换为双向 LSTM,以使用左右上下文来预测情感。
如今,LSTM 已被Transformer(它使用注意力机制来解决困扰 LSTM 的所有问题,例如缺乏迁移学习、缺乏并行训练以及长序列的长梯度链)取代。
使用 NumPy 从头开始构建神经网络是了解 NumPy 和深度学习的好方法。但是,对于实际应用,您应该使用专门的框架(例如 PyTorch、JAX 或 TensorFlow),这些框架提供了类似 NumPy 的 API、内置的自动微分和 GPU 支持,并且专为高性能数值计算和机器学习而设计。
最后,要了解更多关于在开发机器学习模型时如何发挥伦理作用的信息,您可以参考以下资源:
图灵研究所的数据伦理资源。https://www.turing.ac.uk/research/data-ethics
蕾切尔·托马斯 (Rachel Thomas) 的这篇博文和Radical AI 播客上的更多伦理资源。