本章内容
- 为神经网络构建基础层
- 使用反向传播训练神经网络
- 在Python中实现基本的神经网络
- 在PyTorch中实现可扩展的神经网络
- 堆叠网络层以获得更好的数据表示
- 调整神经网络以提高性能
当你读到本章标题中的“词汇大脑”时,你的大脑中的神经元可能开始激活,试图提醒你以前在哪里听到过类似的词汇。而现在,当你读到“heard”这个词时,你的大脑神经元可能会将标题中的词汇与处理单词声音的大脑部分连接起来。也许,你的听觉皮层的神经元开始将“词汇大脑”这个短语与常见的押韵短语连接起来,比如“鸟脑”(bird brain)。
即使我们的脑袋没有预测到你脑袋的反应,你即将自己构建一个小型的大脑,能够处理一个单词并预测它的含义。你即将构建的这个“词汇大脑”在某些特别复杂的NLP任务中,比我们人类的大脑要强得多。神经网络甚至能够在处理一个看似对人类没有任何意义的人的名字时,做出关于其含义的预测。
如果你对这些关于大脑、预测和单词的讨论感到困惑,不用担心。你将从简单的开始,只用一个人工神经元,用Python构建,并使用PyTorch来处理将你的神经元与其他神经元连接所需的所有复杂数学。一旦你理解了神经网络,你将开始理解深度学习,并能够在现实世界中使用它,不仅是为了娱乐,带来积极的社会影响,如果你坚持的话,也可以带来利润。
5.1 为什么选择神经网络?
当你使用深度神经网络进行机器学习时,这被称为深度学习。在过去的几年里,深度学习突破了许多艰难NLP问题的准确性和智能瓶颈,以下是一些例子:
- 问答系统
- 阅读理解
- 摘要生成
- 自然语言推理
最近,深度学习(深度神经网络)使得以前难以想象的应用成为可能:
- 长时间且有趣的对话
- 伴侣关系
- 编写软件
最后一个,编写软件,特别有趣,因为NLP神经网络正在被用来编写软件……等等……为NLP编写软件。这意味着,AI和NLP算法越来越接近于自我复制和自我改进的那一天。这为神经网络作为通向人工通用智能(AGI)——或至少是更具智能的机器——的路径重新带来了希望和兴趣。而NLP已经被直接用于生成推进这些NLP算法智能的软件。这个良性循环正在创造出如此复杂和强大的模型,以至于人类很难理解它们,并解释它们是如何工作的。OpenAI的一篇文章表明,模型复杂度的一个明显转折点出现在2012年,当时Geoffrey Hinton对神经网络架构的改进开始流行。从2012年起,最大的AI训练运行所使用的计算量以3.4个月的翻倍时间指数增长。神经网络使这一切成为可能,因为它们:
- 能够从少量示例中更好地泛化
- 能够从原始数据中自动工程化特征
- 能够轻松地在任何无标签文本上进行训练
神经网络为你做特征工程,并且做得非常优化。它们根据你在管道中设定的任务提取数据中普遍有用的特征和表示,现代神经网络特别适用于信息丰富的数据,比如自然语言文本。
5.1.1 神经网络与单词
使用神经网络时,你不必猜测是否需要使用专有名词、平均单词长度或手工制作的单词情感分数。你可以避免使用可读性评分或情感分析工具来降低数据的维度。你甚至不需要使用盲目的(无监督的)降维方法,比如停用词过滤、词干提取、词形还原、LDA、PCA、t-SNE或聚类。一个神经网络小脑可以为你完成这些任务,并且会根据单词与目标之间的统计关系最优地完成。
警告 在深度学习管道中,除非你完全确定它能帮助你的模型在应用中表现得更好,否则避免使用词干提取器、词形还原器或其他基于关键词的预处理。你会发现,词干提取和词形还原会“剥夺”模型中重要的信息,减少它能找到的模式和特征,从而影响它执行任务的效果。
如果你正在进行词干提取、词形还原或基于关键词的分析,你可能想尝试在没有这些过滤器的情况下运行你的管道。不管你使用的是NLTK、Stanford Core NLP,还是spaCy——手工制作的语言学算法如词形还原器很可能并不有帮助。这些算法的限制在于定义它们的手工标注的词汇和手工编写的正则表达式。
以下是一些可能会使你的神经网络出错的预处理算法:
- Porter词干提取器
- Penn Treebank词形还原器
- Flesch-Kincaid可读性分析器
- VADER情感分析器
在现代机器学习和深度学习的超连接世界中,自然语言演变得太快,以至于这些算法跟不上。词干提取器和词形还原器已经过拟合到过去的时代。50年前,“超连接”和“过拟合”这两个词是不存在的。词形还原器、词干提取器和情感分析器经常在遇到这些意料之外的单词时做出错误处理。
深度学习是NLP的革命性变革。在过去,像Julie Beth Lovins这样的语言学天才需要手工制作算法,从文本中提取词干、词形和关键词。(她的一遍词干提取器和词形还原算法后来被Martin Porter等人推广。)深度神经网络现在使所有这些繁重的工作变得不再必要。它们根据单词的统计信息直接访问单词的意义,无需使用像词干提取器和词形还原器这样的脆弱算法。
即使是强大的特征工程方法,比如第4章讨论的潜在语义分析(LSA),也无法匹敌神经网络的自然语言理解能力。决策树、随机森林和提升树的决策阈值自动学习无法提供神经网络的语言理解深度。传统的机器学习算法使全文搜索和普遍可访问的知识成为现实,但深度学习与神经网络使得人工智能和智能助手成为可能。深度学习已经被集成到你使用的许多数字产品中,并且现在以你几年前无法想象的方式推动着你的思维。
你在前几章学到的NLP的力量即将变得更强大。你将需要理解人工神经元层叠网络如何工作,以确保你的算法为社会带来好处,而不是破坏它。为了将这股力量用在正道上,你需要对神经网络的工作原理有一个直观的了解——从单个神经元到整个网络。你还将想要理解它们为何在许多NLP问题上表现如此优秀……以及它们为何在其他问题上表现得如此糟糕。
我们希望拯救你免于经历曾经让研究人员气馁的“AI冬天”。如果你错误地使用神经网络,你可能会被一个过拟合的NLP管道“冻伤”,它在测试数据上表现得很好,但在现实世界中却适得其反。随着你开始理解神经网络的工作原理,你将开始看到如何构建更强大的NLP神经网络。用于NLP问题的神经网络以其脆弱性和易受攻击(如数据中毒)而著称。但首先,你必须了解单个神经元是如何工作的。
建议
以下是两篇关于用神经网络处理自然语言文本的极佳资料,你甚至可以用它们来训练一个深度学习管道,理解NLP的术语:
- 《A Primer on Neural Network Models for Natural Language Processing》 by Yoav Goldberg (archive.is/BNEgK)
- 《CS224d: Deep Learning for Natural Language Processing》 by Richard Socher (web.stanford.edu/class/cs224…)
你可能还想看看Manning出版的《Deep Learning for Natural Language Processing》 by Stephan Raaijmakers (www.manning.com/books/deep-…)。
5.1.2 神经元作为特征工程师
线性回归、逻辑回归和朴素贝叶斯模型的主要限制之一是,它们都要求你逐个地进行特征工程。你必须在所有可能的将文本表示为数字的方式中,找到文本的最佳数值表示。然后,你需要为这些工程化的特征表示参数化一个函数,该函数接收这些特征表示并输出预测结果。只有在此之后,优化器才能开始寻找最佳的参数值,以预测输出变量。
注 在某些情况下,你可能需要手动为你的NLP管道设计阈值特征。如果你需要一个可解释的模型,以便与你的团队讨论并与现实世界现象相关联,这将特别有用。为了创建一个特征工程较少的简化模型,且不使用神经网络,你需要检查每个特征的残差图。当你在某个特征的特定值处看到残差的间断或非线性时,那是一个很好的阈值,可以添加到你的管道中。有时,你甚至可以找到你设计的阈值与现实世界现象之间的关联。
例如,你在第3章使用的TF-IDF向量表示对于信息检索和全文搜索非常有效。然而,TF-IDF向量通常在语义搜索或实际中的自然语言理解(NLU)中泛化效果不佳,因为有时单词会拼写错误或被以模糊的方式使用,而第4章中提到的PCA或LSA转换可能无法为你的特定问题找到正确的主题向量表示。它们对于可视化很有用,但并不适合NLU应用。多层神经网络承诺为你进行特征工程,并且在某种意义上做得是最优的。神经网络能够搜索一个更广泛的可能的特征工程函数空间。
处理多项式特征爆炸
神经网络能够优化的另一个特征工程示例是多项式特征提取(想想你上次使用sklearn.preprocessing.PolynomialFeatures
时)。在特征工程过程中,你可能猜测输入和输出之间的关系是二次的。在这种情况下,你会将输入特征平方,并使用这些新特征重新训练模型,以查看它是否提高了模型在测试集上的准确性。基本上,如果某个特征的残差(预测值减去测试集标签)看起来不是以零为中心的白噪声,那就是你可以通过使用某种非线性函数(如平方、立方、平方根、对数或指数)来消除模型预测中的更多误差的机会。你可以用任何你能想到的函数,这将帮助你逐渐形成直觉,找出最能提高准确性的函数。如果你不知道哪些交互可能是解决问题的关键,那么你就必须将所有特征相乘。
你知道这个兔子洞的深度和广度。可能的四次多项式特征的数量几乎是无限的。你可能会尝试使用PCA或LSA将TF-IDF向量的维度从数万个降到数百个维度。但加入四次多项式特征将会指数级地扩展你的维度,甚至超越TF-IDF向量的维度。
即使有数百万个可能的多项式特征,仍然会有数百万个阈值特征。决策树的随机森林和提升决策树已经发展到能够自动进行特征工程的地步,因此寻找正确的阈值特征本质上已经是一个解决的问题。但这些特征表示很难解释,有时也不能很好地泛化到现实世界中。正是在这一点上,神经网络可以提供帮助。
特征工程的圣杯是找到能够说明现实世界物理规律的表示。如果你的特征能够根据现实世界现象进行解释,你就可以开始建立信心,认为它们不仅仅是预测性的。你可能会有一个真正的因果模型,它在一般情况下描述了世界的某些真理——不仅仅是针对你的数据集。
Peter Woit解释了现代物理学中可能模型的爆炸性增长大部分是“不甚至是错的”。这些“不甚至是错的”模型就是你使用sklearn.preprocessing.PolynomialFeatures
时创建的模型——这是真正的问题。提取的数百万个多项式特征中,很少有是物理上可能的。换句话说,绝大多数多项式特征只是噪声。所以,如果你在预处理时使用多项式特征,最好将degree
参数限制为2或更小。
注 对于任何机器学习管道,确保你的多项式特征中永远不要包含超过两个物理量的乘积。如果你决定尝试多项式特征,且其次数大于2,你可以通过过滤掉不可实现的(三个特征的)交互特征来避免麻烦。例如,x1 * x2 ** 2
是一个合法的三次多项式特征,但x1 * x2 * x3
不是。涉及多个特征交互(乘积)的多项式特征在物理上是不可实现的。移除这些“幻想特征”将提高你NLP管道的鲁棒性,并帮助你减少生成模型中的幻觉。
我们希望,到现在为止,你已经被神经网络所提供的可能性所启发。让我们开始进入神经网络的世界,构建看起来像逻辑回归的单一神经元。最终,你将能够将这些神经元组合和堆叠成层,优化特征工程。
5.1.3 生物神经元
Frank Rosenblatt 基于他对生物神经元如何在大脑中工作的理解,提出了第一个人工神经网络。他称其为感知机(perceptron),因为他使用它来帮助机器感知其环境,使用传感器数据作为输入。他希望它们能通过消除手工设计过滤器来从数据中提取特征,从而彻底改变机器学习。他还希望能够自动化寻找适合任何问题的函数组合的过程。
Rosenblatt 想要使工程师能够构建 AI 系统,而不必为每个问题设计专门的模型。当时,工程师们使用线性回归、多项式回归、逻辑回归和决策树来帮助机器人做决策。Rosenblatt 的感知机是一种新的机器学习算法,它可以逼近任何函数,而不仅仅是线性函数、逻辑函数或多项式函数。他的算法基于生物神经元的工作原理。Rosenblatt 在成功的逻辑回归模型的基础上进行创新,稍微修改了优化算法,更好地模拟神经科学家们对生物神经元如何随时间调整其对环境反应的理解。
电信号通过树突(见图5.1)进入大脑中的生物神经元,并进入细胞核。细胞核积累电荷,并随着时间的推移逐渐积累。当细胞核中的积累电荷达到该神经元的激活水平时,它会通过轴突发出电信号。一个神经元的轴突与另一个神经元的树突相接触的地方称为突触;然而,神经元并不是完全相同的。大脑中神经元的树突对某些神经元输入比其他输入更“敏感”。而细胞核本身的激活阈值可能更高或更低,这取决于它在大脑中的功能。因此,对于一些更加敏感的神经元,输入信号较少就足以触发输出信号通过轴突传递出去。
你可以想象神经科学家如何通过对真实神经元进行实验,测量个别树突和神经元的敏感性。这种敏感性可以被赋予一个数值。Rosenblatt 的感知机将这一生物神经元进行了抽象,创建了一个人工神经元,并为每个输入(树突)分配了与之相关的权重。对于人工神经元,如 Rosenblatt 的感知机,你将个别树突的敏感性表示为通过神经网络某一特定路径的数值权重或增益。生物细胞在决定如何强烈和频繁地激活其输出轴突时,会增强或抑制传入信号。较高的权重代表对输入中微小变化的较高敏感度,并且对于给定输入产生更强的输出信号。
生物神经元在其生命周期中会动态地改变这些权重,在决策过程中学习哪些输出在特定情况下会得到奖励。你将模仿这一生物学习过程,使用称为反向传播的机器学习过程。但是在你学习如何通过网络将权重的变化反向传播之前,先看看你是否能理解图5.2中单个神经元的前向传播是如何工作的。前向传播的数学运算是否让你联想到线性回归?
AI研究人员希望用神经网络——这些“小脑”——的模糊和更广泛的逻辑,替代逻辑回归、线性回归和多项式特征提取中的严格数学公式。Rosenblatt的人工神经元甚至可以处理三角函数和其他高度非线性的函数。每个神经元解决问题的一部分,并且可以与其他神经元结合,学习越来越复杂的函数(尽管并非所有的函数——甚至是像异或门(XOR)这样的简单函数,也无法用单层感知机解决)。他称这些人工神经元的集合为感知机。
Rosenblatt当时并没有意识到,他的人工神经元可以像生物神经元一样层叠连接。现代深度学习中,我们将一个神经元群体的预测结果与另一个神经元群体相连接,以精细化预测。这使得我们能够创建分层网络来建模任何函数。它们现在能够解决任何机器学习问题……只要你有足够的时间和数据。你可以在图5.3中看到神经网络如何堆叠层,以产生更复杂的输出。
5.1.4 感知机
神经元做的最复杂的事情之一就是处理语言。想一想,感知机是如何用来处理自然语言文本的。图5.2中展示的数学公式是否让你想起了你以前使用过的任何机器学习模型?你知道有哪些机器学习模型是将输入特征与权重或系数的向量相乘的吗?那就是线性回归。那么,如果你在线性回归的输出上使用了一个sigmoid激活函数或逻辑函数呢?这开始看起来很像逻辑回归了。
在感知机中使用的sigmoid激活函数实际上与逻辑回归中使用的逻辑函数相同——sigmoid只是指“S”形的曲线。逻辑函数恰好具备我们所需的形状,用于创建软阈值或逻辑二元输出,所以实际上,你的神经元在这里做的相当于对输入进行逻辑回归。
这是一个在Python中实现的逻辑函数公式:
>>> def logistic(x, w=1., phase=0, gain=1):
... return gain / (1. + np.exp(-w * (x - phase)))
下面是逻辑函数的形态,以及系数(权重)和相位(截距)如何影响其形状:
>>> import pandas as pd
>>> import numpy as np
>>> import seaborn as sns
>>> sns.set_style()
>>> xy = pd.DataFrame(np.arange(-50, 50) / 10., columns=['x'])
>>> for w, phase in zip([1, 3, 1, 1, .5], [0, 0, 2, -1, 0]):
... kwargs = dict(w=w, phase=phase)
... xy[f'{kwargs}'] = logistic(xy['x'], **kwargs)
>>> xy.plot(grid="on", ylabel="y")
在你之前的章节中,做自然语言句子的逻辑回归时,你的输入是什么?你首先用关键词检测器(如 CountVectorizer 或 TfidfVectorizer)处理文本。这些模型使用了一个分词器,类似于你在第二章中学到的分词器,来将文本分割成单个单词,然后对其进行计数。所以对于NLP来说,通常使用词袋模型(BOW)计数或TF-IDF向量作为NLP模型的输入,神经网络也是如此。
Rosenblatt的每个输入权重(生物学中的树突)都有一个可调的数值,用来表示该信号的权重或敏感度。Rosenblatt通过一个电位计来实现这个权重,就像老式立体声音响接收器上的音量旋钮一样。这使得研究人员能够手动调整神经元对每个输入的敏感度。通过调整这个敏感度旋钮,感知机可以对BOW或TF-IDF向量中每个单词的计数变得更加或不那么敏感。
一旦某个特定单词的信号根据敏感度或权重增加或减少,它就会传递到生物神经元细胞的主体部分。在感知机的主体部分以及真实的生物神经元中,输入信号会被加在一起。然后,这个信号会通过一个软阈值函数(如sigmoid)处理,之后再通过轴突发送信号。生物神经元只有在信号超过某个阈值时才会发火。感知机中的sigmoid函数使得在最小-最大范围的50%处实现该阈值变得容易。如果神经元在给定的单词组合或输入信号下没有发火,意味着它是一个负分类匹配。
5.1.5 Python中的感知机
机器可以通过将数值特征与“权重”相乘并将它们组合起来,来模拟一个非常简单的神经元,从而生成预测或做出决策。这些数值特征代表了你的对象作为一个机器可以理解的数值向量。考虑一下一个服务,希望通过将房屋的自然语言描述表示为数值向量来预测房价。如何通过仅使用NLP模型来实现这一点?
你可以尝试将房屋的口头描述作为特征,使用每个单词的计数,就像你在第2章和第3章中做的那样。或者,你可以使用像主成分分析(PCA)这样的转换,将这些数千维度压缩成主题向量,就像你在第4章中做的那样。但这些方法仅仅是根据每个特征的变异性或方差来猜测哪些特征是重要的。也许,描述中的关键字是房屋的平方英尺数和卧室数量。这些单词向量和主题向量完全忽略了这些数值。
在“正常”的机器学习问题中,如预测房价,你可能会有结构化的数值数据。你通常会有一张列出所有重要特征的表格,例如平方英尺、最后售出价格、卧室数量,甚至是纬度、经度或邮政编码。然而,对于自然语言问题,我们希望模型能够处理非结构化数据:文本。你的模型必须弄清楚哪些单词——以及它们的组合或顺序——是预测目标变量的关键。你的模型必须读取房屋描述,并像人类大脑一样,猜测房价。神经网络是你拥有的最接近能够模仿部分人类直觉的机器。
深度学习的美妙之处在于,你可以将你能想到的每个特征作为输入。这意味着你可以输入完整的文本描述,并让你的转换器生成一个高维的TF-IDF向量,神经网络可以很好地处理它。你甚至可以使用更高维度的向量。你可以将原始的、未经过滤的文本作为单词的独热编码序列输入。你还记得我们在第2章讨论的钢琴卷轴吗?神经网络就是为这种原始的自然语言数据表示而生的。
浅层学习
对于你的第一个深度学习NLP问题,你将保持其浅层结构。为了理解深度学习的魔力,首先要了解一个神经元是如何工作的。一个神经元会为你输入到模型中的每个特征找到一个权重。你可以把这些权重看作是允许进入神经元的信号的百分比。如果你熟悉线性回归,你可能会认出这些图示,并看到这些权重其实就是线性回归的斜率。如果你再加入一个逻辑函数,这些权重就是逻辑回归在给定数据集示例时学习到的系数。换句话说,单个神经元输入的权重在数学上等价于多元线性回归或逻辑回归中的斜率。
提示 与scikit-learn机器学习模型一样,单个特征通常表示为xi,或者在Python中表示为x[i]。其中i是一个索引整数,表示输入向量中的位置,给定示例的所有特征集合位于向量x中。
类似地,你会看到每个特征的关联权重wi,其中i对应于x中的整数。权重通常表示为向量W:
有了这些特征,你只需将每个特征(xi)与相应的权重(wi)相乘,然后将它们相加。
以下是一个有趣的简单示例,确保你理解这些数学运算。假设你有一个词袋(BOW)向量,表示一个像“green egg egg ham ham ham spam spam spam spam”这样的短语:
>>> from collections import Counter
>>> np.random.seed(451)
>>> tokens = "green egg egg ham ham ham spam spam spam spam".split()
>>> bow = Counter(tokens)
>>> x = pd.Series(bow)
>>> x
green 1
egg 2
ham 3
spam 4
>>> x1, x2, x3, x4 = x
>>> x1, x2, x3, x4
(1, 2, 3, 4)
>>> w0 = np.round(.1 * np.random.randn(), 2)
>>> w0
0.07
>>> w1, w2, w3, w4 = (.1 * np.random.randn(len(x))).round(2)
>>> w1, w2, w3, w4
(0.12, -0.16, 0.03, -0.18)
>>> x = np.array([1, x1, x2, x3, x4]) #1
>>> w = np.array([w0, w1, w2, w3, w4]) #2
>>> y = np.sum(w * x) #3
>>> y
-0.76
#1 为什么我们需要额外的输入1?
#2 注意额外的权重w0?
#3 通常,这里会使用一个中间变量z,而不是y。
这个四输入、一输出的单神经元网络,在这些随机权重下输出了-0.76的值,这个神经元尚未经过训练。
你在这里缺少的最后一部分是:你需要对输出(y)运行一个非线性函数,改变输出的形状,使其不仅仅是线性回归。通常,使用阈值或剪切函数来决定神经元是否应该发火。对于一个阈值函数,如果加权和超过某个阈值,感知机输出1;否则,输出0。你可以用一个简单的阶跃函数(在图5.2中标记为激活函数)来表示这个阈值。
以下是将阶跃函数或阈值函数应用于神经元输出的代码:
>>> threshold = 0.0
>>> y = int(y > threshold)
如果你希望模型输出一个连续的概率或可能性,而不是二元的0或1,你可能想使用我们在本章前面介绍的逻辑激活函数:
>>> y = logistic(x)
神经网络就像其他任何机器学习模型一样——你为模型提供输入(特征向量)和输出(预测)的数值示例。就像传统的逻辑回归一样,神经网络会通过试错的方式找到最能预测输出的输入权重。你的损失函数将衡量模型的误差。
确保这个Python实现的神经元数学运算对你有意义。记住,我们写的代码仅用于神经元的前馈路径。这个数学计算与在scikit-learn中进行四输入一输出的逻辑回归时,LogisticRegression.predict()
函数的计算非常相似。
注 损失函数是输出一个分数,用来衡量模型的坏程度——它的预测误差总量。目标函数则衡量模型的好坏,基于误差的大小。损失函数就像学生在考试中答错的题目百分比,而目标函数则像该考试的成绩或百分比得分。你可以利用这些来帮助你学习正确的答案,并在测试中不断进步。
为什么需要额外的权重?
你注意到你有一个额外的权重w0吗?没有标记为x0的输入,那为什么还会有w0?你能猜到为什么我们总是给神经元一个常数值为1.0的输入信号x0吗?回想一下你以前构建的线性回归和逻辑回归模型。你还记得单变量线性回归公式中的额外系数吗?
y变量是模型的输出或预测,x变量是该模型中的单个独立特征变量。你可能记得m代表斜率,但你记得b代表什么吗?
现在,你能猜到额外的权重w0是做什么用的吗?为什么我们总是确保它不受输入的影响(将其与输入1.0相乘)?
它就是你线性回归中的截距,只是将其“重新命名”为了神经网络这一层的偏置权重(w0)。
图5.2和这个例子提到了偏置。这是什么意思?偏置是神经元的一个“始终开启”的输入。神经元为它分配了一个权重,就像每个输入元素一样,这个权重与其他权重一起被训练。神经网络文献中通常以两种方式表示这一点。你可能看到输入表示为基础输入向量,假设是n个元素,并在向量的开头或末尾附加一个1,这样就得到一个(n+1)维的向量。1的位置对网络来说是无关紧要的,只要它在所有样本中保持一致。其他时候,人们假设偏置项的存在,并在图表中省略它,但与之关联的权重是单独存在的,始终与1相乘并加到样本输入的值与其相关权重的点积上。两者实际上是一样的。
我们需要偏置权重的原因是,我们需要神经元对所有输入为零的情况具有弹性。可能网络需要学习在输入为零时输出0,但也可能不需要。如果没有偏置项,神经元对于任何你开始使用的或试图学习的权重都会输出0 * weight = 0。通过添加偏置项,你就不会遇到这个问题。而且,如果神经元需要学习输出0,神经元可以学习将与偏置项关联的权重减少到足够低,从而保持点积低于阈值。
图5.4展示了可视化你大脑中发生的事情与神经网络代码中发生的事情之间的一种对应方式。当你查看这个图表时,想一想你是如何使用大脑深处的生物神经元来阅读这本书,并通过深度学习神经网络的模拟来学习自然语言处理的。
最简单的单神经元的Python代码如下:
>>> def neuron(x, w):
... z = sum(wi * xi for xi, wi in zip(x, w)) #1
... return z > 0 #2
#1 x 和 w 必须是向量——列表、元组或数值数组。
#2 这个复杂的表达式是 w.dot(x) 的点积。
也许你对NumPy和向量化的数学运算更为熟悉,就像你在线性代数课上学到的那样:
>>> def neuron(x, w):
... z = np.array(wi).dot(w)
... return z > 0
注 任何Python条件表达式都会评估为True或False的布尔值。如果你在数学运算中使用布尔类型,例如加法或乘法,Python会将True值强制转换为数值int或float,值为1或1.0。当你将布尔值与另一个数字相乘或相加时,False值会被强制转换为1或0。
w变量包含模型的权重参数向量。这些值将在训练过程中,神经元的输出与期望输出进行比较时被学习。x变量包含传入神经元的信号值向量。这是特征向量,比如自然语言模型的TF-IDF向量。对于生物神经元,输入是通过树突传递的电脉冲的频率。一个神经元的输入通常是另一个神经元的输出。
提示 输入(x)和权重(w)之间逐对相乘的和,实际上与两个向量x和y的点积是完全相同的。如果你使用NumPy,神经元可以通过一个简短的Python表达式来实现:w.dot(x) > 0
。这就是为什么线性代数对于神经网络如此有用的原因。神经网络主要就是通过输入与参数的点积来进行计算,而GPU是专为并行处理这些点积的乘法和加法而设计的计算处理芯片,每个GPU核心执行一个操作。这意味着,一个单核心的GPU通常比一个四核的CPU快250倍进行点积运算。
如果你熟悉数学的自然语言,你可能更喜欢方程式5.1中的求和符号表示:
激活阈值方程与Python表达式int(sum(x_i * w_i) > threshold)
相同。这允许分类器根据输入特征做出二元决策。这个激活函数应该仅在神经网络的最后输出层使用,因为它会丢弃关于特征加权和的幅度的所有信息。特征的加权和可以告诉你分类器在对特定示例做出决策时的信心程度。
你的感知机还没有学到任何东西,但你已经实现了一个相当重要的目标:你将数据输入到模型中并得到了输出。考虑到你没有说明权重值来自何处,这个输出可能是错误的,但接下来事情将变得有趣。
提示 任何神经网络的基本单元是神经元。基本的感知机是更通用神经元的一种特殊情况。我们现在将感知机称为神经元,当这种术语不再适用时,我们会重新回到更通用的术语。
5.2 一个示例逻辑神经元
事实证明,你已经熟悉一种非常常见的感知机或神经元。当你在神经元上使用逻辑函数作为激活函数时,本质上你已经创建了一个逻辑回归模型。带有逻辑激活函数的单个神经元在数学上等价于scikit-learn中的LogisticRegression
模型——唯一的区别是它们的训练方式。你将首先训练一个逻辑回归模型,并将其与在相同数据上训练的单神经元神经网络进行比较。
5.2.1 点击诱饵的逻辑
软件(以及人类)通常需要根据逻辑标准做出决策。例如,你可能每天会多次需要决定是否点击某个特定的链接或标题。有时,这些链接会将你带到一篇假新闻文章,所以你的大脑在点击某个链接之前会学习一些逻辑规则:
- 这是你感兴趣的主题吗?
- 链接看起来像广告或垃圾邮件吗?
- 它来自一个信誉良好的来源,还是一个你喜欢的来源?
- 它看起来真实或事实吗?
每个这些决策都可以在机器中的人工神经元中建模,你可以使用这个模型在电路板中创建一个逻辑门,或在软件中创建一个条件表达式(if
语句)。如果你使用人工神经元,处理这四个决策的最小人工“脑”将使用四个逻辑回归门。
为了模仿你大脑中的“点击诱饵过滤器”,你可能决定基于标题的长度训练一个逻辑回归模型。也许,你有一种直觉,认为较长的标题更有可能是耸人听闻和夸张的。下面是一个虚假新闻和真实新闻标题及其标题长度(以字符为单位)的散点图。神经元的输入权重等价于图5.5中虚假新闻分类器的逻辑回归图中最大斜率的位置,只有一个特征:标题长度。
5.2.2 性别教育
这个章节标题对于点击诱饵来说怎么样?由于假新闻(点击诱饵)数据集已经在Kaggle上充分利用,你将切换到一个更有趣且实用的数据集。你将使用感知机(人工神经元)来预测名字的性别。
你将用这个简单的架构解决一个日常的自然语言理解(NLU)问题,这个问题是你大脑中的数百万神经元每天都会尝试解决的。你的大脑强烈地被激励去识别你在社交媒体上互动的人的出生性别。一个单一的人工神经元可以通过使用一个人的名字中的字符来大约以80%的准确率解决这个问题。你将使用来自美国各州和领地的3.17亿份出生证样本,这些数据跨越了超过100年的时间。
在本节中,我们将使用“性别”这个词来指代医生在婴儿出生时分配给他们的标签。在美国,名字、性别和出生日期会根据州的法律记录在出生证上。性别类别的定义可以由填写并签署出生证的人进行解释和判断。在来自美国出生证的数据集中,出生时的性别可能基于新生儿的外部解剖学、染色体或激素来确定。大多数新生儿被分配为男性或女性,但这是过于简化的,生物学和生活方式有时会模糊男性/女性二分法的界限。
男性和女性并不是出生性别分类的最终标准。美国疾病控制与预防中心(CDC)建议,美国互操作性核心数据(USCDI)标准应包括多个非二元性别类别,以供临床或医疗使用。除了女性和男性,大多数西方医疗系统还建议包括“未知”和“未列出(指定)”等类别。
你需要确保测试集中的名字不出现在训练集的任何地方。你还需要确保测试集中的每个名字只有一个“正确”的标签。但对于任何特定的名字,并不存在一个正确的二元性别标签;实际上,根据名字在出生证上对应的性别比例,存在一个关于“男性”或“女性”的概率得分(连续值)。但是,这个“正确”的得分会随着你向数据集添加新例子而变化。自然语言处理是混乱且流动的,因为自然世界及其描述语言是动态的,无法“固定在墙上”。
理论上,这将使得你的模型有可能达到100%的准确率。显然,对于像这样的任务,这在实践中是不可能的,因为即使是人类也无法做到100%的准确率。你在测试集上的准确度将告诉你离这一理想有多近,但前提是你删除了测试集中的重复名字。
5.2.3 代词、性别和性别
我们在讨论性别分类时,不能不谈到性别。性别是一个比出生时指定性别更大、更复杂的话题。它涵盖了社会构建的角色、行为、表现和身份,可能与一个人指定的性别不同。一个人的性别认同不限于男性/女性二元分类,而且可能随着时间变化。代词是人们用来表达和确认自己性别认同的重要方式之一。
处理性别问题是一个微妙的事情,涉及重要的法律和社会影响。在不同的文化中,特别是那些有严格规范的文化中,性别认同的讨论和表达可能会带来严重后果,有时甚至涉及个人安全。正因为如此,我们曾讨论是否要将这一部分包含在书中;然而,我们认为这个话题很重要,恰恰是因为性别、性别认同和人类常常做出的性别假设之间存在隐含联系。NLP技术可以产生性别偏见的系统,这些系统会在社会中延续和放大性别偏见和跨性别恐惧症,但它们也可以用来识别和减轻数据集、模型和系统中的性别偏见,这些系统影响着我们生活的各个方面。
在NLP中有一个重要的挑战,叫做共指消解(coreference resolution),这是指NLP算法识别自然语言文本中与代词相关的对象或词语。例如,考虑这些句子中的代词:“Maria was born in Ukraine. Her father was a physicist. 15 years later she left there for Israel.”你可能没有意识到,但你一瞬间就解决了三个共指问题。你的大脑统计了“Maria”使用“Her”代词的可能性,并且将“Ukraine”与我们在“there”中所指的地方对应起来。
通过意识到我们的大脑对名字附加的偏见,这种偏见通常会影响人们在现实生活中的行为,你可以设计算法来抵消并弥补这种偏见。因此,知道你文本中人名所关联的“男性”或“女性”性别可能对构建你的NLU管道至关重要。即使一个特定名字的出生性别识别对于所提到的人的性别表现较差,这仍然很有帮助。文本的作者通常会期望你根据名字对性别做出假设。在性别颠覆的科幻小说中,像威廉·吉布森(William Gibson)这样的先知性作家利用这一点来让你保持警觉,并扩展你的思维。在本章中,我们利用了一个简化的二元性别数据集,为你从零开始构建自然语言处理技能提供了框架。
提示 确保你的NLP管道和聊天机器人对所有人类都友好、包容且易于访问。为了确保你的算法没有偏见,你可以在处理的文本数据中规范化任何性别信息。在下一章中,你将看到性别如何影响你的算法所做出的决策,你将看到性别如何影响你每天与之打交道的企业或雇主的决策。
5.2.4 性别逻辑
首先,导入 pandas 并设置 max_rows
,以便只显示 DataFrame 中的几行:
>>> import pandas as pd
>>> import numpy as np
>>> pd.options.display.max_rows = 7
接下来,从 nlpia2 仓库下载原始数据,并仅采样 10,000 行,以保持计算机处理速度:
>>> np.random.seed(451)
>>> URL = 'https://gitlab.com/tangibleai/nlpia2/'\
... '-/raw/main/src/nlpia2/data/baby-names-us-10k.csv.gz'
>>> df = pd.read_csv(URL) #1
>>> df = df.sample(10_000) #2
>>> df.shape
(10000, 6)
#1 如果你从 GitLab 下载了 nlpia2 源代码,可以从那里加载更小的 baby-names-us-10k.csv.gz
文件。
#2 在接下来的这些示例中,你只需要一个小样本的出生证数据集。
数据跨越了 100 多年的美国出生证,但只包括婴儿的名字。
region | sex | year | name | count | freq |
---|---|---|---|---|---|
6139665 | WV | F | Brittani | 10 | 0.000003 |
2565339 | MD | F | Ida | 18 | 0.000005 |
22297 | AK | M | Maxwell | 5 | 0.000001 |
… | … | … | … | … | … |
4475894 | OK | F | Leah | 9 | 0.000003 |
5744351 | VA | F | Carley | 11 | 0.000003 |
5583882 | TX | M | Kartier | 10 | 0.000003 |
目前可以忽略区域和出生年份的信息。你只需要名字这一自然语言特征来合理地预测性别。目标变量将是性别(M 或 F)。这个数据集中除了男性和女性外没有其他性别类别。
你可能会享受探索数据集,看看你关于父母给孩子起名字的直觉有多少次是正确的。机器学习和 NLP 是一个很好的工具,可以打破刻板印象和误解:
>>> df.groupby(['name', 'sex'])['count'].sum()[('Timothy',)]
sex
F 5
M 3538
这就是 NLP 和数据科学的乐趣所在。它给了我们一个更广阔的世界视野,打破了我们生物大脑的有限视角。你可能从未见过一个名叫 Timothy 的女性,但至少 0.1% 的名为 Timothy 的婴儿在美国的出生证上标记为女性。
为了加速模型训练,如果地区和年份不是你希望模型预测的名字特征,你可以将数据按地区和年份聚合(合并):
>>> df = df.set_index(['name', 'sex'])
>>> groups = df.groupby(['name', 'sex'])
>>> counts = groups['count'].sum()
>>> counts
name sex
Aaden M 51
Aahana F 26
Aahil M 5
..
Zvi M 5
Zya F 8
Zylah F 5
因为我们已经聚合了 count
列的数值数据,所以 counts
对象现在是一个 pandas Series 对象,而不是 DataFrame。它看起来有点奇怪,因为我们在名字和性别上创建了一个多层索引。你能猜到为什么吗?
现在,数据集看起来像是训练逻辑回归的有效示例。实际上,如果我们只想预测这个数据库中名字的性别,我们可以仅仅使用每个名字的最大计数(最常见的用法)。但这是一本关于 NLP 和自然语言理解(NLU)的书,所以你希望你的模型以某种方式理解名字的文本内容。你还希望它能处理那些不在这个数据库中的奇怪名字——比如 Carlana,一个由 Carl 和 Ana 组成的合成词,或者像 Cason 这样的独一无二的名字。那些不属于训练集或测试集的例子称为分布外(out of distribution)。在现实世界中,你的模型几乎总会遇到从未见过的单词和短语。当一个模型能够对这些分布外的例子进行推断时,这被称为泛化。
但如何将一个单词(比如名字)进行标记化,以便模型能够泛化到它从未见过的全新名字呢?你可以使用每个单词(或名字)中的字符 n-grams 作为标记,并且可以设置 TfidfVectorizer 来计数字符和字符 n-grams,而不是单词。你可以尝试更宽或更窄的 ngram_range,不过对于大多数基于 TF-IDF 的信息检索和 NLU 算法,3-grams 是一个不错的选择。例如,最先进的数据库 PostgreSQL 默认使用字符 3-grams 作为全文搜索索引。
在后续章节中,你甚至会使用 word piece 和 sentence piece 分词技术,这些方法可以最优地选择多种字符序列作为你的标记:
>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> vectorizer = TfidfVectorizer(
... use_idf=False, #1
... analyzer='char',
... ngram_range=(1, 3) #2
... )
>>> vectorizer
TfidfVectorizer(analyzer='char', ngram_range=(1, 3), use_idf=False)
注 1. 防止向量化器将每行向量除以逆文档频率。
2. PostgreSQL 和其他全文搜索功能使用 1-gram、2-gram 和 3-gram 的“trigram index”。
你不应该用文档频率之类的东西来规范化标记计数吗?你将使用出生人数来做到这一点。对于名字的 TF-IDF 向量,你希望使用出生人数或人群作为文档频率。这将帮助你的向量表示名字在你独特名字语料库之外的频率。
现在,你已经根据名字和性别对名字系列进行了索引,并对州和年份进行聚合,减少了唯一行数。你可以在计算 TF-IDF 字符 n-gram 词频之前“去重”名字。别忘了跟踪出生证的数量,以便将其用作文档频率:
>>> df = pd.DataFrame([list(tup) for tup in counts.index.values],
... columns=['name', 'sex'])
>>> df['count'] = counts.values
>>> df
name sex counts
0 Aaden M 51
1 Aahana F 26
2 Aahil M 5
... ... .. ...
4235 Zvi M 5
4236 Zya F 8
4237 Zylah F 5
你已经将 10,000 个名字-性别对聚合为 4,238 个唯一的名字-性别配对。现在,你可以准备将数据划分为训练集和测试集:
>>> df['istrain'] = np.random.rand(len(df)) < .9
>>> df
name sex counts istrain
0 Aaden M 51 True
1 Aahana F 26 True
2 Aahil M 5 True
... ... .. ... ...
4235 Zvi M 5 True
4236 Zya F 8 True
4237 Zylah F 5 True
为了确保你不会意外地交换名字的性别,可以重新创建名字和性别的多重索引:
>>> df.index = pd.MultiIndex.from_tuples(
... zip(df['name'], df['sex']), names=['name_', 'sex_'])
>>> df
name sex count istrain
name_ sex_
Aaden M Aaden M 51 True
Aahana F Aahana F 26 True
Aahil M Aahil M 5 True
... ... .. ... ...
Zvi M Zvi M 5 True
Zya F Zya F 8 True
Zylah F Zylah F 5 True
如你所见,这个数据集包含许多名字的冲突标签。在现实生活中,许多名字既用于男性也用于女性婴儿(以及其他性别类别)。就像所有的机器学习分类问题一样,数学将其视为回归问题。模型实际上是在预测一个连续值,而不是一个离散的二元类别。线性代数和现实生活只处理实数值。在机器学习中,所有的二分法都是错误的。机器并不认为词语和概念是硬性的分类,所以你也不应该如此:
>>> df_most_common = {} #1
>>> for name, group in df.groupby('name'):
... row_dict = group.iloc[group['count'].argmax()].to_dict() #2
... df_most_common[(name, row_dict['sex'])] = row_dict
>>> df_most_common = pd.DataFrame(df_most_common).T #3
#1 构建 Series 的最快方法是使用字典。
#2 如果有两行相同的名字(但性别不同),使用计数较大的行。
#3 从字典构建的 DataFrame 将是单行的。转置以创建列。
由于重复数据,可以从 istrain
中创建测试集标志:
>>> df_most_common['istest'] = ~df_most_common['istrain'].astype(bool)
>>> df_most_common
name sex count istrain istest
Aaden M Aaden M 51 True False
Aahana F Aahana F 26 True False
Aahil M Aahil M 5 True False
... ... .. ... ... ...
Zvi M Zvi M 5 True False
Zya F Zya F 8 True False
Zylah F Zylah F 5 True False
现在,你可以将 istest
和 istrain
标志转移回原始 DataFrame,并确保填充 NaN
为 False:
>>> df['istest'] = df_most_common['istest']
>>> df['istest'] = df['istest'].fillna(False)
>>> df['istrain'] = ~df['istest']
>>> istrain = df['istrain']
>>> df['istrain'].sum() / len(df)
0.9091... #1
>>> df['istest'].sum() / len(df)
0.0908... #2
>>> (df['istrain'].sum() + df['istest'].sum()) / len(df)
1.0
#1 大约 91% 的样本可以用于训练。
#2 大约 9% 的样本可以用于测试。
现在,你可以使用训练集拟合 TfidfVectorizer,而不会通过重复的名字来扭曲 n-gram 计数:
>>> unique_names = df['name'][istrain].unique()
>>> unique_names = df['name'][istrain].unique()
>>> vectorizer.fit(unique_names)
>>> vecs = vectorizer.transform(df['name'])
>>> vecs
<4238x2855 sparse matrix of type '<class 'numpy.float64'>'
with 59959 stored elements in Compressed Sparse Row format>
在处理稀疏数据结构时要小心。如果你将它们转换为正常的密集数组(使用 .todense()
),可能会用尽计算机的所有内存,导致崩溃。但这个稀疏矩阵仅包含大约 1700 万个元素,所以大多数笔记本电脑应该能够正常处理。你可以使用 toarray()
方法将稀疏矩阵转换为 DataFrame,并为行和列提供有意义的标签:
>>> vecs = pd.DataFrame(vecs.toarray())
>>> vecs.columns = vectorizer.get_feature_names_out()
>>> vecs.index = df.index
>>> vecs.iloc[:,:7]
a aa aac aad aah aak aal
Aaden 0.175188 0.392152 0.0 0.537563 0.000000 0.0 0.0
Aahana 0.316862 0.354641 0.0 0.000000 0.462986 0.0 0.0
Aahil 0.162303 0.363309 0.0 0.000000 0.474303 0.0 0.0
... ... ... ... ... ... ... ...
Zvi 0.000000 0.000000 0.0 0.000000 0.000000 0.0 0.0
Zya 0.101476 0.000000 0.0 0.000000 0.000000 0.0 0.0
Zylah 0.078353 0.000000 0.0 0.000000 0.000000 0.0 0.0
注意,列标签(字符 n-grams)都以小写字母开头。看起来像是 TfidfVectorizer
将所有内容都转换为小写。大写字母可能会帮助模型,所以我们不进行小写转换,再次向量化这些名字:
>>> vectorizer = TfidfVectorizer(analyzer='char',
... ngram_range=(1, 3), use_idf=False, lowercase=False)
>>> vectorizer = vectorizer.fit(unique_names)
>>> vecs = vectorizer.transform(df['name'])
>>> vecs = pd.DataFrame(vecs.toarray())
>>> vecs.columns = vectorizer.get_feature_names_out()
>>> vecs.index = df.index
>>> vecs.iloc[:,:5]
A Aa Aad Aah Aal
name_ sex_
Aaden M 0.193989 0.393903 0.505031 0.000000 0.0
Aahana F 0.183496 0.372597 0.000000 0.454943 0.0
Aahil M 0.186079 0.377841 0.000000 0.461346 0.0
... ... ... ... ... ...
Zvi M 0.000000 0.000000 0.000000 0.000000 0.0
Zya F 0.000000 0.000000 0.000000 0.000000 0.0
Zylah F 0.000000 0.000000 0.000000 0.000000 0.0
这样更好了。这些字符 1-gram、2-gram 和 3-gram 应该包含足够的信息来帮助神经网络猜测出生证数据库中名字的性别。
选择神经网络框架
逻辑回归是处理高维特征向量(如 TF-IDF 向量)的完美机器学习模型。要将逻辑回归转变为神经元,你只需要一种方法将它与其他神经元连接起来。你需要一个神经元,能够学习预测其他神经元的输出,并且你需要分散学习,这样一个神经元就不会尝试做所有的工作。每次你的神经网络从数据集中获得一个示例,并看到正确的答案时,它将能够计算出自己的错误——损失或误差。但如果有多个神经元共同工作来贡献这个预测,它们每个都需要知道该如何调整它们的权重,从而将输出调整得更接近正确答案。为了知道这一点,你需要了解每个权重如何影响输出——即相对于误差的梯度(斜率)。计算梯度(斜率)并告诉所有神经元该如何调整它们的权重以减少损失的过程叫做反向传播(backpropagation)。
一个深度学习框架可以自动为你处理所有这些。在本书的第一版写作时,TensorFlow 和 Keras(一个使 TensorFlow 更易于访问和可读的工具箱)是构建机器学习库和应用的最流行工具。然而,自那时以来,我们看到 TensorFlow 在创建新模型方面的使用持续下降,而另一个框架 PyTorch 却快速崛起。虽然 TensorFlow 由于其适用于大规模部署以及支持工具和硬件,仍然被许多公司使用,但实践者们越来越多地转向 PyTorch,因为它的灵活性、直观性和“Pythonic”设计。TensorFlow 生态系统的衰退和 PyTorch 的快速增长是我们决定发布本书第二版的主要原因。那么,PyTorch 到底有什么特别之处?
维基百科对所有深度学习框架进行了公正且详细的比较,pandas 让你可以直接从网页加载它到 DataFrame 中:
>>> import pandas as pd
>>> import re
>>> dfs = pd.read_html('https://en.wikipedia.org/wiki/'
... + 'Comparison_of_deep_learning_software')
>>> tabl = dfs[0]
你可以使用一些基本的 NLP 技巧来为维基百科文章中的前十个深度学习框架打分,文章列出了它们的优缺点。每当你想将半结构化的自然语言转化为数据以供 NLP 管道使用时,你都会发现这类代码很有用:
>>> bincols = list(tabl.loc[:, 'OpenMP support':].columns)
>>> bincols += ['Open source', 'Platform', 'Interface']
>>> dfd = {}
>>> for i, row in tabl.iterrows():
... rowd = row.fillna('No').to_dict()
... for c in bincols:
... text = str(rowd[c]).strip().lower()
... tokens = re.split(r'\W+', text)
... tokens += '*'
... rowd[c] = 0
... for kw, score in zip(
... 'yes via roadmap no linux android python *'.split(),
... [1, .9, .2, 0, 2, 2, 2, .1]):
... if kw in tokens:
... rowd[c] = score
... break
... dfd[i] = rowd
现在,维基百科表格已经清理完毕,你可以为每个深度学习框架计算某种“总分”:
>>> tabl = pd.DataFrame(dfd).T
>>> scores = tabl[bincols].T.sum() #1
>>> tabl['Portability'] = scores
>>> tabl = tabl.sort_values('Portability', ascending=False)
>>> tabl = tabl.reset_index()
>>> tabl[['Software', 'Portability']][:10]
Software Portability
0 PyTorch 14.9
1 Apache MXNet 14.2
2 TensorFlow 13.2
3 Deeplearning4j 13.1
4 Keras 12.2
5 Caffe 11.2
6 PlaidML 11.2
7 Apache SINGA 11.2
8 Wolfram Mathematica 11.1
9 Chainer 11
#1 可移植性得分包括“积极开发”、“开源”、“支持 Linux”和“Python API”。
PyTorch 因其对 Linux、Android 和所有流行深度学习应用的支持而得到了接近完美的得分。另一个使用 PyTorch 的好理由是,它可以让我们一步一步地跟随深度学习过程,这比 Keras 的抽象更加透明和直观。
你可能还想检查另一个有前景的框架:ONNX。它实际上是一个元框架和开放标准,允许你在不同框架之间来回转换。ONNX 还具有一些优化和剪枝功能,能够让你的模型在受限硬件(如便携设备)上更快地运行推理。为了比较,我们来看看 scikit-learn 和 PyTorch 在构建神经网络模型时的差异,见表 5.1。
表 5.1 Scikit-learn vs. PyTorch
Scikit-learn | PyTorch | |
---|---|---|
用于机器学习 | 用于深度学习 | 不支持 GPU |
model.predict() | model.forward() | |
model.fit() | 使用自定义 for 循环训练 | 简单、熟悉的 API |
框架的讨论到此为止——你来这里是为了学习神经元。PyTorch 正是你所需要的工具。接下来还有很多内容需要探索,帮助你熟悉新的 PyTorch 工具箱。
5.2.5 一个简洁的 PyTorch 神经元
最后,是时候使用 PyTorch 框架构建一个神经元了。让我们通过预测你在本章早些时候清理的名字的性别来实践这一点。你可以通过使用 PyTorch 实现一个具有逻辑激活函数的单一神经元开始——就像你在本章开始时用于学习玩具示例的那样:
>>> import torch
>>> class LogisticRegressionNN(torch.nn.Module):
... def __init__(self, num_features, num_outputs=1):
... super().__init__()
... self.linear = torch.nn.Linear(num_features, num_outputs)
... def forward(self, X):
... return torch.sigmoid(self.linear(X))
>>> model = LogisticRegressionNN(num_features=vecs.shape[1], num_outputs=1)
>>> model
LogisticRegressionNN(
(linear): Linear(in_features=3663, out_features=1, bias=True)
)
让我们看看发生了什么。我们的模型是一个类,继承了用于定义神经网络的 PyTorch 类 torch.nn.Module
。与每个 Python 类一样,它有一个名为 __init__
的构造函数。构造函数是你可以定义神经网络所有属性的地方——最重要的是模型的层。在我们的例子中,我们有一个非常简单的架构,只有一层神经元,这意味着将只有一个输出。而输入或特征的数量将等于 TF-IDF 向量的长度,也就是特征的维度。在我们的名字数据集中,有 3,663 个独特的 1-gram、2-gram 和 3-gram,因此这就是你为这个单一神经元网络提供的输入数量。
第二个你需要为神经网络实现的关键方法是 forward()
方法。这个方法定义了模型的输入如何通过它的层传播——即前向传播。如果你在问反向传播在哪里,稍后你会看到,但它不在构造函数中。我们决定为神经元使用逻辑或 sigmoid 激活函数——所以我们的 forward()
方法将使用 PyTorch 的内置函数 sigmoid
。
这些就是你训练模型所需要的一切吗?还不完全是。神经元还需要两个重要的部分来进行学习。一个是损失函数,或者说成本函数,你在本章前面看到过。均方误差(MSE),在附录 D 中有详细讨论,如果这是一个回归问题,MSE 将是一个不错的错误度量标准。对于这个问题,你正在做二元分类,因此二元交叉熵是一个更常用的错误(损失)度量标准。以下是对于单个分类概率 p 的二元交叉熵的公式:
该函数的对数性质使其能够惩罚“自信错误”的例子,比如当你的模型以较高的概率预测某个名字的性别为男性,而实际上这个名字通常被标注为女性。我们可以通过使用我们数据集中关于某个性别的名字频率这一额外信息,来让惩罚更加符合实际情况:
>>> loss_func_train = torch.nn.BCELoss(
... weight=torch.Tensor(df[['count']][istrain].values))
>>> loss_func_test = torch.nn.BCELoss( #1
... weight=torch.Tensor(df[['count']][~istrain].values))
>>> loss_func_train
BCELoss()
#1 损失函数是有状态的,因此你需要为测试集和训练集分别创建实例。
接下来,我们需要选择的是如何根据损失调整权重——即优化算法。记住我们的目标是最小化损失函数。我们可以将这个最小化问题类比为站在损失“碗”的斜坡上,试图滑向碗的最低点。实现向下滑行的最常见方法是随机梯度下降(SGD)。它不像你的Python感知机那样考虑整个数据集,而是每次只计算一个样本的梯度,或者可能是一个小批量的样本。我们将在本章末深入探讨滑雪类比和SGD的机制。 优化器需要两个参数来知道如何以及多快地沿着损失的斜坡滑行:学习率和动量。学习率决定了当发生错误时你的权重变化的幅度——可以将其视为你的“滑雪速度”。增加学习率有助于模型更快地收敛到局部最小值,但如果过大,每次接近最小值时你可能会超调。你在PyTorch中使用的任何优化器都会有一个学习率。 动量是我们梯度下降算法的一个特性,它允许在朝正确方向移动时“加速”,在远离目标时“减速”。我们如何决定给这两个属性赋值呢?像本书中提到的其他超参数一样,你需要优化它们,看看哪个值对你的问题最有效。现在,你可以选择一些任意的值给动量和学习率(lr):
>>> from torch.optim import SGD
>>> hyperparams = {'momentum': 0.001, 'lr': 0.02} #1
>>> optimizer = SGD(
... model.parameters(), **hyperparams) #2
>>> optimizer
SGD (
Parameter Group 0
dampening: 0
differentiable: False
foreach: None
lr: 0.02
maximize: False
momentum: 0.001
nesterov: False
weight_decay: 0
)
#1 将超参数存储在字典中可以使记录模型调优结果更方便。
#2 将模型的参数传递给优化器,让它知道每个训练步骤应该更新哪些参数。
在开始模型训练之前,最后一步是将测试集和训练集转换为PyTorch模型能够处理的格式:
>>> X = vecs.values
>>> y = (df[['sex']] == 'F').values
>>> X_train = torch.Tensor(X[istrain])
>>> X_test = torch.Tensor(X[~istrain])
>>> y_train = torch.Tensor(y[istrain])
>>> y_test = torch.Tensor(y[~istrain])
最后,你准备好进行本章最重要的部分——性别学习了!让我们来看一下每个步骤会发生什么:
>>> from tqdm import tqdm
>>> num_epochs = 200
>>> pbar_epochs = tqdm(range(num_epochs), desc='Epoch:', total=num_epochs, ascii=' =')
>>> for epoch in pbar_epochs:
... optimizer.zero_grad() #1
... outputs = model(X_train)
... loss_train = loss_func_train(outputs, y_train) #2
... loss_train.backward() #3
... optimizer.step() #4
Epoch:: 100%|================================| 200/200 [00:04<00:00, 42.84it/s]
↪ 96.26it/s]
#1 步骤1:将存储的梯度清零
#2 步骤2:计算训练损失
#3 步骤3:计算训练集上的梯度
#4 步骤4:使用优化器更新权重和偏差(反向传播)
这真快!训练这个单神经元约200个epoch,每个epoch成千上万个样本,只需要几秒钟。 看起来很简单,对吧?我们尽可能简单化了操作,这样你就能清楚地看到每一步。但我们还不知道我们的模型表现如何!让我们添加一些工具函数,帮助我们查看神经元是否随着时间的推移得到了改进。这称为仪器化。我们当然可以查看损失,但用更直观的分数(如准确度)来衡量模型的表现也是很好的。 首先,你需要一个函数,将我们从模块中得到的PyTorch张量转换回numpy数组:
>>> def make_array(x):
... if hasattr(x, 'detach'):
... return torch.squeeze(x).detach().numpy()
... return x
你可以使用这个工具函数来衡量每次迭代时输出(预测)的准确性:
>>> def measure_binary_accuracy(y_pred, y):
... y_pred = make_array(y_pred).round()
... y = make_array(y).round()
... num_correct = (y_pred == y).sum()
... return num_correct / len(y)
现在,你可以重新运行训练,使用这个工具函数查看每个epoch时模型的损失和准确度:
for epoch in range(num_epochs):
optimizer.zero_grad() #1
outputs = model(X_train)
loss_train = loss_func_train(outputs, y_train)
loss_train.backward()
epoch_loss_train = loss_train.item()
optimizer.step()
outputs_test = model(X_test)
loss_test = loss_func_test(outputs_test, y_test).item()
accuracy_test = measure_binary_accuracy(outputs_test, y_test)
if epoch % 20 == 19: #2
print(f'Epoch {epoch}:'
f' loss_train/test: {loss_train.item():.4f}/{loss_test:.4f},'
f' accuracy_test: {accuracy_test:.4f}')
输出:
Epoch 19: loss_train/test: 80.1816/75.3989, accuracy_test: 0.4275
Epoch 39: loss_train/test: 75.0748/74.4430, accuracy_test: 0.5933
Epoch 59: loss_train/test: 71.0529/73.7784, accuracy_test: 0.6503
Epoch 79: loss_train/test: 67.7637/73.2873, accuracy_test: 0.6839
Epoch 99: loss_train/test: 64.9957/72.9028, accuracy_test: 0.6891
Epoch 119: loss_train/test: 62.6145/72.5862, accuracy_test: 0.6995
Epoch 139: loss_train/test: 60.5302/72.3139, accuracy_test: 0.7073
Epoch 159: loss_train/test: 58.6803/72.0716, accuracy_test: 0.7073
Epoch 179: loss_train/test: 57.0198/71.8502, accuracy_test: 0.7202
Epoch 199: loss_train/test: 55.5152/71.6437, accuracy_test: 0.7280
#1 清零梯度,确保不会累积来自前一个epoch的梯度 #2 每20个epoch打印一次进度报告(记得Python的索引是从零开始的) 通过仅一组权重和一个神经元,你的简单模型能够在我们这个杂乱、模糊的现实世界数据集上获得超过70%的准确度。现在,你可以添加一些来自现实世界的更多例子,看看模型的表现:
>>> X = vectorizer.transform(
... ['John', 'Greg', 'Vishvesh', #1
... 'Ruby', 'Carlana', 'Sarah']) #2
>>> model(torch.Tensor(X.todense()))
tensor([[0.0196],
[0.1808],
[0.3729],
[0.4964],
[0.8062],
[0.8199]], grad_fn=<SigmoidBackward0>)
#1 我们生活中那些友好且慷慨的男性的名字
#2 我们写这本书时脑海中最突出的女性名字
此前,我们选择使用值1表示女性,0表示男性。前面三个名字,John、Greg和Vishvesh,都是为我们贡献了很多开源项目的男性名字,这些项目对我们来说很重要,包括本书中的代码。看起来Vishvesh的名字在美国的男婴出生证上并不像John或Greg那样常见。因此,模型对John的男性特征比对Vishvesh的男性特征更为自信。 接下来的三个名字,Sarah、Carlana和Ruby,是我们在写这本书时脑海中最突出的女性名字。Ruby这个名字可能包含一些男性特征,因为类似的名字Rudy(通常用于男婴)和Ruby只有一个字母的差距。奇怪的是,包含了常见男性名字Carl的Carlana,反而被模型自信地预测为女性名字。
5.3 沿着误差斜坡滑行
训练神经网络的目标是通过找到模型的最佳参数(权重)来最小化损失函数。在优化循环的每一步,你的算法都会找到下坡最陡的路径。请记住,这个误差斜坡并不是指数据集中某一个样本的误差,而是最小化一批数据中所有点的误差均值(成本)。创建一个关于这个问题的可视化图形可以帮助你建立一个心理模型,理解当你在调整网络权重时实际上在做什么。
你可能熟悉均方根误差(RMSE),它是回归问题中最常见的成本函数。如果你想象将误差作为权重的函数进行绘图,给定一个特定的输入和期望的输出,必定存在一个点,这个点是该函数最接近零的位置;这就是你的最小值——即模型误差最小的位置。
这个最小值将是给定训练样本的最优输出对应的权重集。你经常会看到这个表示为一个三维的碗,其中两个轴是二维的权重向量,第三个轴是误差(见图5.6)。这个描述是一个极简化的版本,但在更高维空间(涉及多个权重的情况)中,概念是相同的。
同样地,你可以将误差面绘制为所有可能权重的函数,遍历训练集中的所有输入,但你需要稍微调整误差函数。你需要一个表示给定一组权重下,所有输入的总误差的函数。在这个例子中,你可以使用均方误差作为z轴。这里,你会在误差面上找到一个位置,该位置的坐标就是一组权重向量,这些权重最小化了你预测与训练集中的分类标签之间的平均误差。这组权重将使你的模型尽可能地拟合整个训练集。
5.3.1 从滑雪升降机到斜坡:梯度下降与局部最小值
这个可视化图形表示什么呢?在每个epoch中,算法都在进行梯度下降,试图最小化误差。每次你调整权重的方向时,希望下一次能够减少误差。一个凸形的误差面会很好。就像站在滑雪斜坡上一样,环顾四周,找出哪个方向是向下的,然后朝那个方向前进!
但是,你并不总是幸运到拥有这样一个光滑的碗形误差面;它可能会有一些坑洼和凹陷。这种情况就是所谓的非凸误差曲线。就像滑雪一样,如果这些坑洼足够大,它们可能会将你“吸引”进去,你可能永远无法到达斜坡的最低点。
同样,图示表示的是二维输入的权重,但如果你有10维、50维或1000维输入,概念是一样的。在这些更高维的空间中,进行可视化已经没有意义了,因此你只能依赖数学。 一旦你开始使用神经网络,误差面的可视化就变得不那么重要了。你可以通过观察(或绘制)误差或相关指标在训练过程中的变化趋势,看看它是否趋向零,从而获得相同的信息。这会告诉你网络是否走在正确的轨道上。这些三维表示图作为一个有用的工具,帮助你构建该过程的心理模型。
但是,非凸误差空间呢?那些坑洼和凹陷难道不是问题吗?是的,的确是。根据你随机开始的权重位置,你可能会最终落到非常不同的权重值上,训练可能会因此停下来,因为从这个局部最小值没有其他下降的路径(见图5.7)。这就意味着,如果模型的初始权重设置不当,可能会困在局部最小值里,无法走出这个“坑”,这也是训练神经网络时常见的问题之一。为了避免这种情况,通常会采用一些策略,如使用不同的初始权重,或使用优化算法中的一些技巧(如动量法、学习率调节等),以帮助算法跳出局部最小值,找到全局最优解。
随着你进入更高维的空间,局部最小值也会跟随你进入。增加更多的维度使得区分局部最小值与全局最小值变得越来越困难,因为你的算法正在寻找的是全局最小值。
5.3.2 打破常规:随机梯度下降
到目前为止,你一直在聚合所有训练样本的误差,并尽可能快速地沿着最陡的路径滑行。但一次性对整个训练集进行训练,逐个样本地处理,略显短视。就像选择雪地公园中的下坡段,却忽略了所有的跳跃。有时候,一个好的滑雪跳跃可以帮助你越过一些崎岖的地形。
如果你尝试一次性训练整个数据集,可能会用尽内存,使训练进程被交换区拖慢——数据在RAM和较慢的持久存储之间来回交换。这个静态的误差面可能有一些陷阱。由于你是从随机的起始点(初始模型权重)开始,你可能会盲目地沿着某个方向滑向局部最小值(如凹坑、洞穴或其他局部低谷)。你可能并不知道还有更好的权重选项存在,而你的误差面是静态的。一旦你到达了误差面的局部最小值,就没有下坡路帮助模型继续向下滑行。
为了打破常规,你希望在过程中加入一些随机性,并周期性地打乱训练样本的顺序。通常,你会在每次通过训练数据集后,重新洗牌训练样本的顺序。打乱数据会改变模型在每个样本上的预测误差顺序,因此它会改变模型寻找全局最小值(即该数据集的最小模型误差)的路径。这种数据打乱就是随机梯度下降(SGD)中的“随机”部分。
梯度下降的“梯度”估算部分仍然有提升空间。你可以给优化器增加一点“谦逊”,使其不至于过于自信,盲目跟随每一次新的猜测直到它认为找到全局最小值的位置。实际上,滑雪斜坡的形状很少是笔直的,它直接指向山脚下的滑雪小屋。所以你的模型在朝着下坡斜坡(梯度)的方向滑行时,并不会一直滑下去,而是只滑一小段。这样,每个样本的梯度就不会把你的模型引得太远,避免它在错误的方向上迷失。你可以通过调整SGD优化器的学习率超参数,来控制模型对每个单独样本梯度的自信度。
另一种训练方法是批量学习。批量是训练数据的一个子集——例如,数据集的0.1%、1%、10%或20%。每个批量都会创建一个新的误差面,你可以在这个误差面上滑行,探索寻找未知的“全局”误差面最小值。你的训练数据只是实际世界中样本的一个子集,因此你的模型不应该假设“全局”真实世界的误差面与训练数据的任何一部分误差面相同。
这也引出了大多数自然语言处理问题的最佳策略:小批量学习。Geoffrey Hinton发现,对于大多数神经网络训练问题,批量大小约为16到64个样本是最优的。这是一个平衡SGD的波动性和你希望朝着全局最小值正确方向取得显著进展的理想大小。当你朝着波动的误差面上不断变化的局部最小值滑行时,只要有正确的数据和超参数,你就能更容易地朝着全局最小值前进。小批量学习是一种平衡全批量学习和单个样本训练的好方法,它兼具随机学习(随机游走)和梯度下降学习(快速沿着假设的斜坡滑行)两者的优点。
虽然反向传播的细节非常有趣,但它并不简单,我们在这里不展开讲解。一个有助于训练模型的心理图像是:将你的问题的误差面想象成某个外星星球上未探索的地形。你的优化器只能观察你脚下地面的坡度,并根据这一信息向下滑行几步,然后再检查坡度(梯度)。这样探索这个星球可能会很慢,但一个好的优化算法可以帮助你的神经网络记住地图上的所有好位置,并利用它们来猜测一个新的位置进行探索,寻找全局最小值。在地球上,这个最低点就是南极Denman冰川下的峡谷底部——海平面下3500米。一个好的小批量学习策略将帮助你找到滑雪斜坡或冰川上最陡的下坡路(如果你害怕高度,那这个想法可能不太愉快),最终滑向全局最小值。希望你很快能在山脚下的滑雪小屋旁的火炉旁,或Denman冰川下的冰洞里的篝火旁找到自己。
看看你能否在本章中创建的感知机上增加更多的层,并观察在增加网络复杂度时,结果是否有所改善。更大并不总是更好,尤其是对于小问题。
5.4 测试自己
-
Rosenblatt的人工神经元无法解决的简单AI逻辑“问题”是什么?
- Rosenblatt的人工神经元(感知机)无法解决的是异或(XOR)问题。异或问题是一种线性不可分问题,无法通过简单的感知机来解决。
-
对Rosenblatt架构的哪个小改动“修复”了感知机并结束了第一次“AI寒冬”?
- 通过引入多层感知机(MLP)并采用反向传播算法(Backpropagation) ,这个小改动解决了感知机无法处理的复杂问题,结束了第一次“AI寒冬”。
-
在scikit-learn模型中,相当于PyTorch模型的
forward()
函数的是什么?- 在scikit-learn模型中,等效于PyTorch模型的
forward()
函数的是**predict()
**函数。predict()
函数用于在训练完成后,对新的数据进行预测。
- 在scikit-learn模型中,等效于PyTorch模型的
-
如果你对名字按照年份和地区进行聚合,在性别预测的Logistic Regression模型上,你能实现什么测试集准确度?别忘了对测试集进行分层抽样以避免作弊。
- 在聚合名字并进行分层抽样的前提下,准确度通常能达到90%以上。通过这种方式,你可以提高模型的预测准确度,因为数据的多样性得到增加,模型对不同地区和年份的适应能力也会提高。
总结
- 最小化成本函数是机器逐步学习单词的方式。
- 反向传播算法是神经网络学习的手段。
- 权重对模型误差的贡献量与它需要更新的量是直接相关的。
- 神经网络本质上是优化引擎。
- 通过监控误差的逐渐减少,注意训练过程中的陷阱(局部最小值)。