原文:
annas-archive.org/md5/143aadf706a620d20916160319321b2e
译者:飞龙
第十章:机器学习最佳实践
在处理了多个涵盖重要机器学习概念、技术和广泛使用的算法的项目之后,你已经对机器学习生态系统有了全面的了解,并且在使用机器学习算法和 Python 解决实际问题方面积累了扎实的经验。然而,当我们开始从零开始在现实世界中开展项目时,仍会面临一些问题。本章旨在通过 21 条最佳实践,帮助我们为此做好准备,贯穿整个机器学习解决方案的工作流程。
本章将涵盖以下主题:
-
机器学习解决方案工作流程
-
数据准备阶段的最佳实践
-
训练集生成阶段的最佳实践
-
模型训练、评估和选择阶段的最佳实践
-
部署与监控阶段的最佳实践
机器学习解决方案工作流程
通常,解决机器学习问题的主要任务可以总结为四个方面,如下所示:
-
数据准备
-
训练集生成
-
模型训练、评估与选择
-
部署与监控
从数据源开始,到最终的机器学习系统,机器学习解决方案基本上遵循如下模式:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_10_01.png
图 10.1:机器学习解决方案的生命周期
在接下来的部分中,我们将学习这些四个阶段的典型任务、常见挑战以及最佳实践。
数据准备阶段的最佳实践
没有数据就无法构建机器学习系统。因此,数据收集应当是我们首先关注的重点。
最佳实践 1 – 完全理解项目目标
在开始收集数据之前,我们应该确保完全理解项目的目标和业务问题,因为这将指导我们选择哪些数据来源,以及在哪些领域需要充足的领域知识和专业技能。例如,在前面的章节中,第五章,使用回归算法预测股价,我们的目标是预测股票指数的未来价格,因此我们首先收集了其历史表现的数据,而不是收集与之无关的欧洲股票的历史表现数据。在第三章,使用基于树的算法预测在线广告点击率中,业务问题是优化广告投放,目标是提高点击率,因此我们收集了点击流数据,记录谁在什么页面点击或未点击了哪个广告,而不是仅仅使用广告在网页域名中展示的数量。
最佳实践 2 – 收集所有相关字段
确定了目标后,我们可以缩小潜在的数据源进行调查。现在的问题是:是否有必要收集数据源中所有领域的数据,还是仅仅一个属性的子集就足够了?如果我们能事先知道哪些属性是关键指标或预测因素,那将是最理想的。然而,实际上很难确保由领域专家挑选的属性能够产生最佳的预测结果。因此,对于每个数据源,建议收集与项目相关的所有字段,尤其是在重新收集数据既费时又几乎不可能的情况下。
例如,在股价预测的案例中,我们收集了所有字段的数据,包括Open、High、Low和Volume,尽管最初我们不确定high和low预测的用处。然而,获取股市数据非常迅速且容易。在另一个例子中,如果我们想要通过抓取在线文章进行主题分类并自己收集数据,我们应该尽可能多地存储信息。否则,如果某些信息没有被收集,但后来发现它很有价值,例如文章中的超链接,文章可能已经从网页上删除;如果它仍然存在,重新抓取这些页面的成本可能会很高。
在收集了我们认为有用的数据集之后,我们需要通过检查其一致性和完整性来确保数据质量。一致性是指数据分布随时间的变化情况。完整性是指在字段和样本中的数据量。这将在以下两个实践中详细解释。
最佳实践 3 – 维护字段值的一致性和标准化
在一个已经存在的数据集或我们从零开始收集的数据集中,我们经常看到不同的值表示相同的含义。例如,在Country
字段中,我们看到American、US和U.S.A,在Gender
字段中,我们看到male和M。有必要统一或标准化字段中的值,否则在后期阶段,算法会混淆处理不同的特征值,即使它们具有相同的含义。例如,我们只保留Gender
字段中的三种选项:M、F和gender-diverse,并替换其他替代值。记录哪些值被映射到字段的默认值也是一个很好的做法。
此外,同一字段中值的格式也应保持一致。例如,在age字段中,可能会有真实年龄值,如21和35,也可能会有不正确的年龄值,如1990和1978;在rating字段中,可能会看到基数数字和英文数字,如1、2、3,以及one、two、three。应该进行转换和重新格式化,以确保数据的一致性。
最佳实践 4 – 处理缺失数据
由于各种原因,现实世界中的数据集很少完全干净,通常包含缺失或损坏的值。它们通常被呈现为空白、Null、-1, 999999、unknown 或其他任何占位符。带有缺失数据的样本不仅提供不完整的预测信息,还会使机器学习模型困惑,因为它无法确定 -1 或 unknown 是否具有特定含义。准确定位并处理缺失数据是非常重要的,以避免在后期模型的性能受到影响。
这里有三种基本策略,我们可以用来处理缺失数据问题:
-
丢弃包含任何缺失值的样本。
-
丢弃任何样本中包含缺失值的字段。
-
根据属性的已知部分推断缺失值。这个过程称为缺失数据填补。典型的填补方法包括用所有样本中字段的平均值或中位数替换缺失值,或者对于分类数据,用最频繁出现的值替换。
前两种策略实现简单;然而,它们牺牲了数据的丢失,特别是当原始数据集不够大时。第三种策略并不放弃任何数据,而是试图填补空缺。
让我们看看每种策略在一个包含六个样本(年龄、收入)的数据集中如何应用 - (30, 100), (20, 50), (35, unknown), (25, 80), (30, 70), 和 (40, 60)。
-
如果我们使用第一种策略处理这个数据集,它将变成 (30, 100), (20, 50), (25, 80), (30, 70), 和 (40, 60)。
-
如果我们采用第二种策略,数据集变成了 (30), (20), (35), (25), (30), 和 (40),只有第一个字段保留了下来。
-
如果我们决定补全未知值而不是跳过它,例如样本 (35, unknown) 可以被转换为 (35, 72),其中第二个字段的值是其余值的平均值,或者转换为 (35, 70),其中第二个字段的值是中位数。
在 scikit-learn 中,SimpleImputer
类 (scikit-learn.org/stable/modules/generated/sklearn.impute.SimpleImputer.html
) 提供了一个写得很好的填补转换器。我们可以用它来做下面这个小例子:
>>> import numpy as np
>>> from sklearn.impute import SimpleImputer
在 numpy
中,用 np.nan
表示未知值,如下所示:
>>> data_origin = [[30, 100],
... [20, 50],
... [35, np.nan],
... [25, 80],
... [30, 70],
... [40, 60]]
使用平均值初始化填补转换器,并从原始数据中获取平均值:
>>> imp_mean = SimpleImputer(missing_values=np.nan, strategy='mean')
>>> imp_mean.fit(data_origin)
补全缺失值如下:
>>> data_mean_imp = imp_mean.transform(data_origin)
>>> print(data_mean_imp)
[[ 30\. 100.]
[ 20\. 50.]
[ 35\. 72.]
[ 25\. 80.]
[ 30\. 70.]
[ 40\. 60.]]
同样地,使用中位数初始化填补转换器,如下所示:
>>> imp_median = SimpleImputer(missing_values=np.nan, strategy='median')
>>> imp_median.fit(data_origin)
>>> data_median_imp = imp_median.transform(data_origin)
>>> print(data_median_imp)
[[ 30\. 100.]
[ 20\. 50.]
[ 35\. 70.]
[ 25\. 80.]
[ 30\. 70.]
[ 40\. 60.]]
当新样本出现时,可以使用训练好的转换器填补缺失值(在任何属性中),例如用平均值,如下所示:
>>> new = [[20, np.nan],
... [30, np.nan],
... [np.nan, 70],
... [np.nan, np.nan]]
>>> new_mean_imp = imp_mean.transform(new)
>>> print(new_mean_imp)
[[ 20\. 72.]
[ 30\. 72.]
[ 30\. 70.]
[ 30\. 72.]]
请注意,年龄字段中的 30
是原始数据集中这六个年龄值的平均值。
现在我们已经看到了填补工作的方式及其实施,让我们通过以下示例来探讨填充缺失值和丢弃缺失数据策略如何影响预测结果:
-
首先,我们加载糖尿病数据集,如下所示:
>>> from sklearn import datasets >>> dataset = datasets.load_diabetes() >>> X_full, y = dataset.data, dataset.target
-
通过添加 25% 的缺失值来模拟一个损坏的数据集:
>>> m, n = X_full.shape >>> m_missing = int(m * 0.25) >>> print(m, m_missing) 442 110
-
随机选择
m_missing
个样本,如下所示:>>> np.random.seed(42) >>> missing_samples = np.array([True] * m_missing + [False] * (m - m_missing)) >>> np.random.shuffle(missing_samples)
-
对于每个缺失的样本,随机选择
n
个特征中的一个:>>> missing_features = np.random.randint(low=0, high=n, size=m_missing)
-
用
nan
表示缺失值,如下所示:>>> X_missing = X_full.copy() >>> X_missing[np.where(missing_samples)[0], missing_features] = np.nan
-
然后,我们通过丢弃包含缺失值的样本来处理这个损坏的数据集:
>>> X_rm_missing = X_missing[~missing_samples, :] >>> y_rm_missing = y[~missing_samples]
-
通过交叉验证方式,在移除了缺失样本的数据集上,估计使用这种策略的平均回归分数 R²,如下所示:
>>> from sklearn.ensemble import RandomForestRegressor >>> from sklearn.model_selection import cross_val_score >>> regressor = RandomForestRegressor(random_state=42, max_depth=10, n_estimators=100) >>> score_rm_missing = cross_val_score(regressor,X_rm_missing, y_rm_missing).mean() >>> print(f'Score with the data set with missing samples removed: {score_rm_missing:.2f}') Score with the data set with missing samples removed: 0.38
-
现在,我们通过使用均值来填补缺失值来处理这个损坏的数据集,如下所示:
>>> imp_mean = SimpleImputer(missing_values=np.nan, strategy='mean') >>> X_mean_imp = imp_mean.fit_transform(X_missing)
-
类似地,通过估计平均 R² 来衡量使用这种策略的效果,如下所示:
>>> regressor = RandomForestRegressor(random_state=42, max_depth=10, n_estimators=100) >>> score_mean_imp = cross_val_score(regressor, X_mean_imp, y).mean() >>> print(f'Score with the data set with missing values replaced by mean: {score_mean_imp:.2f}') Score with the data set with missing values replaced by mean: 0.41
-
在这种情况下,填补策略比丢弃策略更有效。那么,填补后的数据集与原始完整数据集相比有多大差距?我们可以通过在原始数据集上估计平均回归分数来再次检查,如下所示:
>>> regressor = RandomForestRegressor(random_state=42, max_depth=10, n_estimators=500) >>> score_full = cross_val_score(regressor, X_full, y).mean() >>> print(f'Score with the full data set: {score_full:.2f}') Score with the full data set: 0.42
结果表明,在补充后的数据集中丢失了少量信息。
然而,并不能保证填补策略总是更好,有时,丢弃带有缺失值的样本可能更有效。因此,通过交叉验证比较不同策略的性能是一个很好的实践,正如我们之前所做的。
最佳实践 5 – 存储大规模数据
随着数据规模的不断增长,我们往往不能简单地将数据安装在我们的单个本地机器上,需要将其存储在云端或分布式文件系统中。由于这主要是一本关于 Python 机器学习的书籍,我们只会涉及一些你可以深入了解的基本领域。存储大数据的两种主要策略是 扩展 和 扩展:
-
扩展 方法通过增加更多磁盘等方式来增加存储容量,如果数据超出了当前系统容量。这在快速访问平台上非常有用。
-
在扩展性方法中,存储容量随着存储集群中新增节点的加入而逐步增长。Hadoop 分布式文件系统(HDFS)(
hadoop.apache.org/
)和 Spark(spark.apache.org/
)用于在扩展集群中存储和处理大数据,其中数据分布在成百上千个节点上。此外,还有基于云的分布式文件服务,如亚马逊 Web Services 中的 S3(aws.amazon.com/s3/
)、Google Cloud 中的 Google Cloud Storage(cloud.google.com/storage/
)和 Microsoft Azure 中的 Storage(azure.microsoft.com/en-us/services/storage/
)。它们具有大规模可扩展性,设计用于安全且持久的存储。
除了选择合适的存储系统以增加容量外,你还需要关注以下做法:
-
数据分区:将数据划分为更小的分区或碎片。这可以将负载分配到多个服务器或节点,从而实现更好的并行处理和检索。
-
数据压缩和编码:实施数据压缩技术以减少存储空间,并优化数据检索时间。
-
复制和冗余:将数据复制到多个存储节点或地理位置,以确保数据的可用性和容错性。
-
安全性和访问控制:实施强大的访问控制机制,确保只有授权人员能够访问敏感数据。
在数据准备充分的情况下,可以安全地进入训练集生成阶段。让我们来看下一部分。
训练集生成阶段的最佳实践
这个阶段的典型任务可以总结为两个主要类别:数据预处理和特征工程。
首先,数据预处理通常包括类别型特征编码、特征缩放、特征选择和降维。
最佳实践 6 – 识别具有数值值的类别型特征
一般来说,类别型特性容易辨认,因为它们传递的是定性信息,如风险等级、职业和兴趣。然而,如果特性具有离散且可计数(有限)数量的数值,例如表示月份的 1 到 12,或者表示真假值的 1 和 0 时,判断就会变得复杂。
判断某个特性是类别型还是数值型的关键在于它是否提供数学或排名的含义;如果有,则是数值型特性,例如从 1 到 5 的产品评分;否则,是类别型特性,例如月份或星期几。
最佳实践 7 – 决定是否对类别型特征进行编码
如果某个特征被视为类别型特征,我们需要决定是否对其进行编码。这取决于我们在后续阶段将使用哪些预测算法。朴素贝叶斯和基于树的算法可以直接处理类别型特征,而其他算法通常不能,在这种情况下,编码是必需的。
由于特征生成阶段的输出是模型训练阶段的输入,特征生成阶段的步骤应与预测算法兼容。因此,我们应将特征生成和预测模型训练这两个阶段作为一个整体来看待,而不是将其视为两个孤立的部分。接下来的两个实用建议也强调了这一点。
最佳实践 8 – 决定是否选择特征,如果选择,应该如何操作
在第四章《使用逻辑回归预测在线广告点击率》中,你已经看到了如何使用基于 L1 正则化的逻辑回归和随机森林进行特征选择。特征选择的好处包括以下几点:
-
降低预测模型的训练时间,因为冗余或无关特征已被剔除
-
由于前述原因,减少过拟合
-
可能提升性能,因为预测模型将从具有更重要特征的数据中学习
请注意,我们使用了可能这个词,因为无法绝对确定特征选择一定会提高预测精度。因此,最好通过交叉验证比较进行特征选择与不进行特征选择的表现。例如,通过执行以下步骤,我们可以通过SVC
模型以交叉验证方式估算特征选择的影响,进而衡量其对分类准确率的平均影响:
-
首先,我们从
scikit-learn
加载手写数字数据集,如下所示:>>> from sklearn.datasets import load_digits >>> dataset = load_digits() >>> X, y = dataset.data, dataset.target >>> print(X.shape) (1797, 64)
-
接下来,估算原始数据集(64 维)的准确率,具体如下所示:
>>> from sklearn.svm import SVC >>> from sklearn.model_selection import cross_val_score >>> classifier = SVC(gamma=0.005, random_state=42) >>> score = cross_val_score(classifier, X, y).mean() >>> print(f'Score with the original data set: {score:.2f}') Score with the original data set: 0.90
-
然后,基于随机森林进行特征选择,并根据特征的重要性分数对其进行排序:
>>> from sklearn.ensemble import RandomForestClassifier >>> random_forest = RandomForestClassifier(n_estimators=100, criterion='gini', n_jobs=-1, random_state=42) >>> random_forest.fit(X, y) >>> feature_sorted = np.argsort(random_forest.feature_importances_)
-
现在选择不同数量的前几个特征来构建新数据集,并在每个数据集上估算准确率,具体如下:
>>> K = [10, 15, 25, 35, 45] >>> for k in K: ... top_K_features = feature_sorted[-k:] ... X_k_selected = X[:, top_K_features] ... # Estimate accuracy on the data set with k selected features ... classifier = SVC(gamma=0.005) ... score_k_features = cross_val_score(classifier, X_k_selected, y).mean() ... print(f'Score with the dataset of top {k} features: {score_k_features:.2f}') ... Score with the dataset of top 10 features: 0.86 Score with the dataset of top 15 features: 0.92 Score with the dataset of top 25 features: 0.95 Score with the dataset of top 35 features: 0.93 Score with the dataset of top 45 features: 0.90
如果我们使用随机森林选择的前 25 个特征,SVM 分类性能可以从0.9
提升到0.95
。
最佳实践 9 – 决定是否降维,如果降维,应该如何操作
特征选择和降维的区别在于,前者是从原始数据空间中选择特征,而后者则是从原始空间的投影空间中选择特征。降维具有与特征选择相似的以下优点:
-
降低预测模型的训练时间,因为冗余或相关特征已被合并为新的特征
-
由于同样的原因,减少过拟合
-
可能提高性能,因为预测模型将从具有较少冗余或相关特征的数据中学习
再次强调,降维并不能保证产生更好的预测结果。为了检验其效果,建议在模型训练阶段集成降维方法。以之前的手写数字示例为例,我们可以衡量基于主成分分析(PCA)的降维效果,其中我们保留不同数量的主要成分来构建新数据集,并估算每个数据集的准确度:
>>> from sklearn.decomposition import PCA
>>> # Keep different number of top components
>>> N = [10, 15, 25, 35, 45]
>>> for n in N:
... pca = PCA(n_components=n)
... X_n_kept = pca.fit_transform(X)
... # Estimate accuracy on the data set with top n components
... classifier = SVC(gamma=0.005)
... score_n_components =
cross_val_score(classifier, X_n_kept, y).mean()
... print(f'Score with the dataset of top {n} components:
{score_n_components:.2f}')
Score with the dataset of top 10 components: 0.94
Score with the dataset of top 15 components: 0.95
Score with the dataset of top 25 components: 0.93
Score with the dataset of top 35 components: 0.91
Score with the dataset of top 45 components: 0.90
如果我们使用 PCA 生成的前 15 个特征,SVM 分类性能可以从0.9
提高到0.95
。
最佳实践 10 – 决定是否重新缩放特征
如在第五章《使用回归算法预测股票价格》和第六章《使用人工神经网络预测股票价格》中所见,基于 SGD 的线性回归、SVR 和神经网络模型要求特征通过去均值并缩放到单位方差进行标准化。那么,什么时候需要特征缩放,什么时候不需要呢?
一般而言,朴素贝叶斯和基于树的算法对不同尺度的特征不敏感,因为它们独立地看待每个特征。
在大多数情况下,涉及任何形式的样本距离(或空间分离)的学习算法都需要缩放/标准化的输入,如 SVC、SVR、k-means 聚类和k 近邻(KNN)算法。对于任何使用 SGD 进行优化的算法(例如带有梯度下降的线性回归或逻辑回归,以及神经网络),特征缩放也是必须的。
到目前为止,我们已经涵盖了数据预处理的技巧,接下来将讨论特征工程的最佳实践,作为训练集生成的另一个主要方面。我们将从两个角度进行探讨。
最佳实践 11 – 利用领域专业知识进行特征工程
如果我们幸运地拥有足够的领域知识,我们可以应用它来创建领域特定的特征;我们利用我们的商业经验和洞察力,识别数据中的信息,并制定与预测目标相关的新数据。例如,在第五章《使用回归算法预测股票价格》中,我们根据投资者在做出投资决策时通常关注的因素,设计并构建了用于股票价格预测的特征集。
虽然特定的领域知识是必需的,但有时我们仍然可以应用一些通用的技巧。例如,在与客户分析相关的领域,如营销和广告,一天中的时间、一周中的日子和月份通常是重要的信号。在Date
列中给定数据点的值为2020/09/01,Time
列中的值为14:34:21,我们可以创建新特征,包括下午、星期二和九月。在零售业中,通常会聚合一段时间内的信息以提供更好的洞察力。例如,过去三个月内客户访问店铺的次数,或者上一年每周平均购买产品的次数,可以成为客户行为预测的良好预测指标。
最佳实践 12 – 在没有领域专业知识的情况下进行特征工程
如果不幸我们缺乏领域知识,我们如何生成特征?不要惊慌。有几种通用方法可以遵循,例如二值化、离散化、交互和多项式转换。
二值化和离散化
二值化是将数值特征转换为二进制特征的过程,具有预设阈值。例如,在垃圾邮件检测中,对于特征(或术语)prize,我们可以生成新特征whether_term_prize_occurs
:任何频率大于 1 的术语值变为 1;否则为 0。特征每周访问次数可用于生成新特征is_frequent_visitor
,判断其值是否大于或等于 3。我们使用 scikit-learn 来实现这种二值化,如下所示:
>>> from sklearn.preprocessing import Binarizer
>>> X = [[4], [1], [3], [0]]
>>> binarizer = Binarizer(threshold=2.9)
>>> X_new = binarizer.fit_transform(X)
>>> print(X_new)
[[1]
[0]
[1]
[0]]
离散化是将数值特征转换为具有有限可能值的分类特征的过程。二值化可以看作是离散化的一种特殊情况。例如,我们可以生成年龄组特征:“18-24”适用于 18 到 24 岁的年龄,“25-34”适用于 25 到 34 岁的年龄,“34-54”,和“55+”。
交互
这包括两个数值特征的求和、乘积或任何操作,以及两个分类特征的联合条件检查。例如,每周访问次数和每周购买产品数量可用于生成每次访问购买产品数量特征;兴趣和职业,如体育和工程师,可以形成职业和兴趣,例如对体育感兴趣的工程师。
多项式转换
这是生成多项式和交互特征的过程。对于两个特征a和b,生成的二次多项式特征包括a²、ab和b²。在 scikit-learn 中,我们可以使用PolynomialFeatures
类(scikit-learn.org/stable/modules/generated/sklearn.preprocessing.PolynomialFeatures.html
)来执行多项式转换,如下所示:
>>> from sklearn.preprocessing import PolynomialFeatures
>>> X = [[2, 4],
... [1, 3],
... [3, 2],
... [0, 3]]
>>> poly = PolynomialFeatures(degree=2)
>>> X_new = poly.fit_transform(X)
>>> print(X_new)
[[ 1\. 2\. 4\. 4\. 8\. 16.]
[ 1\. 1\. 3\. 1\. 3\. 9.]
[ 1\. 3\. 2\. 9\. 6\. 4.]
[ 1\. 0\. 3\. 0\. 0\. 9.]]
请注意,生成的新特征包括1(偏置,截距)、a、b、a²、ab和b²。
最佳实践 13 – 记录每个特征的生成过程
我们已经讨论了如何结合领域知识进行特征工程的规则,总的来说,还有一件事值得注意:记录每个特征的生成过程。听起来这似乎微不足道,但实际上我们常常忘记特征是如何获得或创建的。在模型训练阶段经过一些失败的尝试后,我们通常需要回到这一阶段,尝试创建更多的特征,以期提高性能。我们必须清楚特征是如何生成的,以及哪些特征没有发挥作用,哪些特征有更多潜力。
最佳实践 14 – 从文本数据中提取特征
我们将从传统的特征提取方法——tf 和 tf-idf 开始。然后,我们将继续介绍一种现代方法:词嵌入。具体来说,我们将讨论使用Word2Vec
模型的词嵌入,以及神经网络模型中的嵌入层。
tf 和 tf-idf
在第七章,《使用文本分析技术挖掘 20 个新闻组数据集》和第八章,*《通过聚类和主题建模发现新闻组数据集中的潜在主题》*中,我们深入处理了文本数据,并根据词频(tf)和词频-逆文档频率(tf-idf)提取了文本特征。两种方法都将每个文档的单词(术语)视为一个单词集合或词袋(BoW),忽略单词顺序但保留单词的多重性。tf 方法仅使用词汇的计数,而 tf-idf 则通过为每个 tf 分配一个与文档频率成反比的加权因子来扩展 tf。通过引入 idf 因子,tf-idf 减小了那些频繁出现的常见术语(如“get”和“make”)的权重,强调那些罕见但传达重要含义的术语。因此,通常从 tf-idf 提取的特征比从 tf 提取的特征更具代表性。
正如你可能记得的,一个文档通常由一个非常稀疏的向量表示,只有当前出现的术语才有非零值。该向量的维度通常很高,这由词汇表的大小和独特术语的数量决定。此外,这种独热编码方法将每个术语视为独立项,并且不考虑单词之间的关系(在语言学中称为“上下文”)。
词嵌入
相反,另一种方法称为词嵌入,它能够捕捉词汇的意义及其上下文。在这种方法中,一个词通过一个浮动数值的向量来表示。它的维度远小于词汇表的大小,通常仅为几百。
嵌入向量是实值的,每个维度表示词汇表中单词的某个意义方面。这有助于保留单词的语义信息,而不是像在使用 tf 或 tf-idf 的 one-hot 编码方法中那样丢弃它。一个有趣的现象是,语义相似的单词的向量在几何空间中是彼此接近的。例如,clustering 和 grouping 两个词都指代机器学习中的无监督聚类,因此它们的嵌入向量是相近的。
以下是获得词嵌入的一些常见方法:
-
Word2Vec:使用 Skip-gram 或连续词袋(CBOW)模型,在你的特定语料库上训练自己的 Word2Vec 嵌入。我们在第七章中讨论过这个内容,使用文本分析技术挖掘 20 个新闻组数据集。像 Python 中的 Gensim 这样的库提供了易于使用的接口来训练 Word2Vec 嵌入。我们稍后会展示一个简单的示例。
-
预训练嵌入:使用在大语料库上训练的预训练词嵌入。我们在第七章中也讨论过这个内容。常见的例子包括:
-
FastText
-
GloVe(全局词向量表示)
-
BERT(双向编码器表示变换器)
-
GPT(生成式预训练变换器)
-
USE(通用句子编码器)嵌入
-
-
训练带嵌入层的自定义模型:如果你有特定的领域或数据集,可以使用自定义神经网络模型训练自己的词嵌入。
Word2Vec 嵌入
在深入研究训练自定义词嵌入模型之前,我们先看一个基本的Word2Vec
模型训练示例,使用gensim
:
我们首先导入gensim
模块:
>>> from gensim.models import Word2Vec
我们定义了一些用于训练的示例句子:
>>> sentences = [
["i", "love", "machine", "learning", "by", "example"],
["machine", "learning", "and", "deep", "learning", "are", "fascinating"],
["word", "embedding", "is", "essential", "for", "many", "nlp", "tasks"],
["word2vec", "produces", "word", "embeddings"]
]
在实际操作中,你需要将句子格式化为类似sentences
对象的单词列表。
然后,我们创建一个 Word2Vec 模型,设置多个参数,如vector_size
(嵌入维度)、window
(上下文窗口大小)、min_count
(词频的最小值)和sg
(训练算法—0
表示 CBOW,1
表示 Skip-gram):
>>> model = Word2Vec(sentences=sentences, vector_size=100, window=5,
min_count=1, sg=0)
训练完成后,我们可以使用模型的wv
属性来访问词向量。这里,我们展示单词machine的嵌入向量:
>>> vector = model.wv["machine"]
>>> print("Vector for 'machine':", vector)
Vector for 'machine': [ 9.2815855e-05 3.0779743e-03 -6.8117767e-03 -1.3753572e-03 7.6693585e-03 7.3465472e-03 -3.6724545e-03 2.6435424e-03 -8.3174659e-03 6.2051434e-03 -4.6373457e-03 -3.1652437e-03 9.3113342e-03 8.7273103e-04 7.4911476e-03 -6.0739564e-03 5.1591368e-03 9.9220201e-03 -8.4587047e-03 -5.1362212e-03 -7.0644980e-03 -4.8613679e-03 -3.7768795e-03 -8.5355258e-03 7.9550967e-03 -4.8430962e-03 8.4243221e-03 5.2609886e-03 -6.5501807e-03 3.9575580e-03 5.4708594e-03 -7.4282014e-03 -7.4055856e-03 -2.4756377e-03 -8.6252270e-03 -1.5801827e-03 -4.0236043e-04 3.3001360e-03 1.4415972e-03 -8.8241365e-04 -5.5940133e-03 1.7302597e-03 -8.9826871e-04 6.7939684e-03 3.9741215e-03 4.5290575e-03 1.4341431e-03 -2.6994087e-03 -4.3666936e-03 -1.0321270e-03 1.4369689e-03 -2.6467817e-03 -7.0735654e-03 -7.8056543e-03 -9.1217076e-03 -5.9348154e-03 -1.8470082e-03 -4.3242811e-03 -6.4605214e-03 -3.7180765e-03 4.2892280e-03 -3.7388816e-03 8.3797537e-03 1.5337169e-03 -7.2427099e-03 9.4338059e-03 7.6304432e-03 5.4950463e-03 -6.8496312e-03 5.8225882e-03 4.0093577e-03 5.1861661e-03 4.2569390e-03 1.9407619e-03 -3.1710821e-03 8.3530620e-03 9.6114948e-03 3.7916750e-03 -2.8375010e-03 6.6632601e-06 1.2186278e-03 -8.4594022e-03 -8.2233679e-03 -2.3177716e-04 1.2370384e-03 -5.7435711e-03 -4.7256653e-03 -7.3463405e-03 8.3279097e-03 1.2112247e-04 -4.5090448e-03 5.7024667e-03 9.1806483e-03 -4.0998533e-03 7.9661217e-03 5.3769764e-03 5.8786790e-03 5.1239668e-04 8.2131373e-03 -7.0198057e-03]
请记住,这只是一个基本示例。在实际应用中,你可能需要更彻底地预处理数据,调整超参数,并在更大的语料库上训练,以获得更好的嵌入。
自定义神经网络中的嵌入层
在用于 NLP 任务的完整深度神经网络中,我们通常会将嵌入层与其他层(如全连接(dense)层或循环层)结合使用(我们将在第十二章中讨论循环层,使用循环神经网络进行序列预测),以构建一个更复杂的模型。嵌入层使网络能够学习输入数据中单词的有意义表示。
让我们来看一个简化的例子,展示如何使用嵌入层进行词向量表示。在 PyTorch 中,我们使用 nn.Embedding
模块(pytorch.org/docs/stable/generated/torch.nn.Embedding.html
)来实现嵌入层:
>>> import torch
>>> import torch.nn as nn
>>> input_data = torch.LongTensor([[1, 2, 3, 4], [5, 1, 6, 3]])
>>> # Define the embedding layer
>>> vocab_size = 10 # Total number of unique words
>>> embedding_dim = 3 # Dimensionality of the embeddings
>>> embedding_layer = nn.Embedding(vocab_size, embedding_dim)
...
>>> embedded_data = embedding_layer(input_data)
>>> print("Embedded Data:\n", embedded_data)
Embedded Data:
tensor([[[-1.2462, 0.4035, 0.4463],
[-0.5218, 0.8302, -0.6920],
[-0.4720, -1.2894, 1.0763],
[-2.2879, -0.4834, 0.3416]],
[[ 1.5886, -0.3489, -0.4579],
[-1.2462, 0.4035, 0.4463],
[-1.2322, -0.5981, -0.1349],
[-0.4720, -1.2894, 1.0763]]], grad_fn=<EmbeddingBackward0>)
在这个例子中,我们首先从 PyTorch 中导入必要的模块。我们定义了一些包含单词索引的示例输入数据(例如,1 代表 I,2 代表 love,3 代表 machine,4 代表 learning)。然后,我们使用 nn.Embedding
定义嵌入层,其中 vocab_size
为词汇表中独特单词的总数,embedding_dim
为嵌入的目标维度。嵌入层通常是神经网络模型中的第一层,位于输入层之后。在完成网络训练后,当我们将输入数据传递通过嵌入层时,它会返回每个输入单词索引的嵌入向量。输出 embedded_data
的形状为 (sample size, sequence length, embedding_dim)
,在我们的例子中为 (2, 4, 3)
。
再次说明,这是一个简化的例子。实际上,嵌入层通常涉及到更复杂的架构,并且有更多的层次,以便处理和解读嵌入向量,执行特定任务,如分类、情感分析或序列生成。请留意即将到来的 第十二章。
想了解 tf-idf 与词嵌入的选择吗?在传统的自然语言处理(NLP)应用中,例如简单的文本分类和主题建模,tf 或 tf-idf 仍然是一个非常出色的特征提取方法。在更复杂的领域,如文本摘要、机器翻译、命名实体识别、问答和信息检索中,词嵌入被广泛使用,并且相比传统方法,提供了显著增强的特征。
现在你已经回顾了数据和特征生成的最佳实践,让我们接下来看看模型训练。
模型训练、评估和选择阶段的最佳实践
在给定的监督学习问题中,许多人首先问的问题通常是 解决这个问题的最佳分类或回归算法是什么? 然而,并没有一刀切的解决方案,也没有免费的午餐。没有人能在尝试多个算法并对最优算法进行微调之前知道哪个算法效果最好。在本节中,我们将深入探讨与此相关的最佳实践。
最佳实践 15 – 选择合适的算法开始
由于算法需要调节多个参数,耗尽所有算法并对每个算法进行微调可能是极其耗时且计算开销巨大的。我们应该根据以下通用指导原则,挑选一到三个算法开始(注意,我们这里关注的是分类问题,但理论也适用于回归问题,并且通常回归中也有对应的算法)。
在筛选潜在算法之前,我们需要明确几个方面,具体如下:
-
训练数据集的大小
-
数据集的维度
-
数据是否线性可分
-
特征是否独立
-
偏差与方差的容忍度与权衡
-
是否需要在线学习
现在,让我们从上述角度出发,看看如何选择合适的算法来开始。
朴素贝叶斯
这是一个非常简单的算法。对于相对较小的训练数据集,如果特征独立,朴素贝叶斯通常能表现良好。对于大型数据集,尽管实际情况未必如此,但在假设特征独立的前提下,朴素贝叶斯依然能表现出色。由于其计算简洁,朴素贝叶斯的训练通常比其他任何算法都要快。然而,这也可能导致较高的偏差(但低方差)。
逻辑回归
这可能是最广泛使用的分类算法,也是机器学习从业者在面对分类问题时通常会尝试的第一个算法。当数据是线性可分或近似线性可分时,它表现良好。即使数据不可线性分离,也有可能将线性不可分的特征转化为可分特征,然后应用逻辑回归。
在以下实例中,原始空间中的数据是线性不可分的,但通过两个特征的交互作用,转化后的空间中的数据变得可分:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_10_02.png
图 10.2:将特征从线性不可分转化为可分
此外,逻辑回归通过使用 SGD 优化,能够极大地扩展到大数据集,这使其在解决大数据问题时非常高效。同时,它也使得在线学习成为可能。尽管逻辑回归是一个低偏差、高方差的算法,但我们通过加入 L1、L2 或两者的混合正则化来克服可能的过拟合问题。
支持向量机(SVM)
这个方法足够通用,可以适应数据的线性可分性。对于可分的数据集,使用线性核的 SVM 与逻辑回归的表现相当。除此之外,如果使用非线性核(如 RBF),SVM 也能很好地处理非可分数据集。而逻辑回归在高维数据集上可能会遇到挑战,而 SVM 仍能表现良好。一个很好的例子是新闻分类,其中特征的维度通常达到数万维。总的来说,使用合适的核和参数,SVM 能实现非常高的准确性,但这可能会以大量计算和高内存消耗为代价。
随机森林(或决策树)
数据的线性可分性对该算法并不重要,它可以直接处理类别特征而无需编码,这提供了极大的使用便利。此外,训练后的模型非常容易解释,并且可以向非机器学习从业者说明,这一点是大多数其他算法无法做到的。另外,随机森林增强了决策树算法,通过集成多个独立的树来减少过拟合。其性能可与 SVM 相媲美,同时相比于 SVM 和神经网络,微调随机森林模型要容易得多。
神经网络
神经网络非常强大,特别是随着深度学习的发展。然而,找到合适的拓扑结构(层、节点、激活函数等)并不容易,更不用说耗时的训练和调优过程了。因此,它们并不适合作为一般机器学习问题的起始算法。然而,对于计算机视觉和许多 NLP 任务,神经网络仍然是首选模型。总之,以下是使用神经网络特别有益的一些场景:
-
复杂模式:当任务涉及学习数据中的复杂模式或关系,而这些模式对于传统算法来说可能很难捕捉时。
-
大量数据:当你有足够的数据可用于训练时,神经网络通常表现得很好,因为它们能够从大量数据集中学习。
-
非结构化数据:神经网络擅长处理非结构化数据类型,如图像、音频和文本,而传统方法可能在提取有意义特征时遇到困难。在情感分析、机器翻译、命名实体识别和文本生成等 NLP 任务中,神经网络,特别是循环神经网络和变换器模型,已经展现了卓越的性能。在图像分类、目标检测、图像分割和图像生成任务中,深度神经网络彻底改变了计算机视觉领域。
最佳实践 16 – 减少过拟合
在讨论算法的优缺点时,我们提到了避免过拟合的方法。这里我们正式总结如下:
-
更多数据,若可能:增加训练数据集的规模。更多数据可以帮助模型学习到相关的模式,并减少其记忆噪声的倾向。
-
简化,若可能:模型越复杂,过拟合的可能性越高。复杂模型包括具有过深深度的树或森林、具有高次多项式转换的线性回归、以及具有复杂核函数的 SVM。
-
交叉验证:这是我们在本书所有章节中培养出的一个良好习惯。
-
正则化:这通过添加惩罚项来减少因在给定训练集上完美拟合模型而导致的误差。
-
早停法:在训练过程中监控模型在验证集上的表现。当验证集上的表现开始下降时,停止训练,表明模型开始出现过拟合。
-
Dropout(丢弃法):在神经网络中,在训练过程中应用丢弃层。丢弃法在每次前向传播时随机丢弃一部分神经元,从而防止对特定神经元的依赖。
-
特征选择:选择一个相关特征的子集。去除无关或冗余的特征可以防止模型对噪声进行拟合。
-
集成学习:这涉及将一组弱模型结合成一个更强的模型。
那么,我们如何判断一个模型是出现了过拟合,还是另一种极端,欠拟合呢?我们来看看下一节。
最佳实践 17 – 诊断过拟合与欠拟合
学习曲线通常用来评估模型的偏差和方差。学习曲线是一种图表,比较了在一定数量的训练样本上,交叉验证的训练和测试得分。
对于一个在训练样本上拟合良好的模型,训练样本的表现应该超过期望值。理想情况下,随着训练样本数量的增加,模型在测试样本上的表现会有所提升;最终,测试样本的表现会接近训练样本的表现。
当测试样本上的表现收敛到一个远低于训练表现的值时,可以得出过拟合的结论。在这种情况下,模型无法推广到未见过的实例。
对于一个在训练样本上表现都不佳的模型,容易识别出欠拟合:训练和测试样本上的表现都低于学习曲线中期望的表现。
下面是一个理想情况下学习曲线的例子:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_10_03.png
图 10.3:理想学习曲线
过拟合模型的学习曲线示例如下图所示:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_10_04.png
图 10.4:过拟合学习曲线
欠拟合模型的学习曲线可能如下图所示:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_10_05.png
图 10.5:欠拟合学习曲线
要生成学习曲线,你可以使用 scikit-learn 中的 learning_curve
模块 (scikit-learn.org/stable/modules/generated/sklearn.model_selection.learning_curve.html#sklearn.model_selection.learning_curve
),以及在 scikit-learn.org/stable/auto_examples/model_selection/plot_learning_curve.html
定义的 plot_learning_curve
函数。
最佳实践 18 - 在大规模数据集上建模
我们在第四章《使用逻辑回归预测在线广告点击率》中积累了处理大规模数据集的经验。这里有一些技巧可以帮助你更高效地在大规模数据上建模。
首先,从一个较小的子集开始,例如一个可以在本地机器上运行的子集。这有助于加速早期实验。显然,你不想在整个数据集上进行训练,只是为了判断 SVM 或随机森林哪个效果更好。相反,你可以随机采样数据点,并在选定的子集上快速运行几个模型。
第二个建议是选择可扩展的算法,如逻辑回归、线性 SVM 和基于 SGD 的优化算法。这一点非常直观。
这里还有其他在大规模数据集上建模的最佳实践:
-
采样和子集选择:在开始模型开发时,使用较小的子集来快速迭代和实验。一旦模型架构和参数调整完毕,再扩展到完整的数据集。
-
分布式计算:利用像 Apache Spark 这样的分布式计算框架,在多个节点或集群上处理大规模数据处理和模型训练。
-
特征工程:专注于相关特征,避免不必要的维度。如果需要,可以使用像 PCA 或 t-SNE 这样的降维技术来减少特征空间。
-
并行化:探索并行化训练的技术,如数据并行或模型并行,以利用多个 GPU 或分布式系统。
-
内存管理:通过使用数据生成器、从存储流式传输数据以及在不再需要时释放内存来优化内存使用。
-
优化的库:选择针对大规模数据优化的库和框架,如 TensorFlow、PyTorch、scikit-learn 和 XGBoost。
-
增量学习:对于流式数据或动态数据集,考虑使用增量学习技术,在新数据到达时更新模型。
最后但同样重要的是,别忘了保存训练好的模型。训练大规模数据集需要很长时间,如果可能的话,你希望避免重复这一过程。我们将在最佳实践 19 - 保存、加载和重用模型中详细探讨保存和加载模型的内容,这是部署和监控阶段的一部分。
部署和监控阶段的最佳实践
完成前面三个阶段的所有过程后,我们现在拥有了一个完善的数据预处理流水线和一个正确训练的预测模型。机器学习系统的最后阶段包括保存前面阶段产生的模型,并将它们部署到新数据上,同时监控它们的性能并定期更新预测模型。我们还需要实现监控和日志记录,以跟踪模型的性能、训练进度以及训练过程中可能出现的问题。
最佳实践 19 – 保存、加载和重用模型
当机器学习被部署时,新的数据应经过与前面阶段相同的数据预处理程序(缩放、特征工程、特征选择、降维等)。预处理后的数据将输入到训练好的模型中。我们不能在每次新数据到来时重新运行整个过程并重新训练模型。相反,我们应该在相应阶段完成后保存已经建立的预处理模型和训练好的预测模型。在部署模式下,这些模型会提前加载,并用来从新数据中生成预测结果。接下来,让我们探讨如何使用 pickle、TensorFlow 和 PyTorch 保存和加载模型的方法。
使用 pickle 保存和恢复模型
我们从使用 pickle
开始。通过糖尿病示例来说明这一点,其中我们对数据进行标准化,并使用 SVR
模型,如下所示:
>>> dataset = datasets.load_diabetes()
>>> X, y = dataset.data, dataset.target
>>> num_new = 30 # the last 30 samples as new data set
>>> X_train = X[:-num_new, :]
>>> y_train = y[:-num_new]
>>> X_new = X[-num_new:, :]
>>> y_new = y[-num_new:]
对训练数据进行缩放预处理,如下命令所示:
>>> from sklearn.preprocessing import StandardScaler
>>> scaler = StandardScaler()
>>> scaler.fit(X_train)
现在使用 pickle
保存已建立的标准化器和 scaler
对象,如下所示:
>>> import pickle
>>> pickle.dump(scaler, open("scaler.p", "wb" ))
这会生成一个 scaler.p
文件。
然后,继续在缩放数据上训练一个 SVR
模型,如下所示:
>>> X_scaled_train = scaler.transform(X_train)
>>> from sklearn.svm import SVR
>>> regressor = SVR(C=20)
>>> regressor.fit(X_scaled_train, y_train)
使用 pickle
保存训练好的 regressor
对象,如下所示:
>>> pickle.dump(regressor, open("regressor.p", "wb"))
这会生成一个 regressor.p
文件。
在部署阶段,我们首先从前面两个文件加载保存的标准化器和 regressor
对象,如下所示:
>>> my_scaler = pickle.load(open("scaler.p", "rb" ))
>>> my_regressor = pickle.load(open("regressor.p", "rb"))
然后,我们使用标准化器对新数据进行预处理,并使用刚加载的 regressor
对象进行预测,如下所示:
>>> X_scaled_new = my_scaler.transform(X_new)
>>> predictions = my_regressor.predict(X_scaled_new)
在 TensorFlow 中保存和恢复模型
我还将演示如何在 TensorFlow 中保存和恢复模型。作为示例,我们将在癌症数据集上训练一个简单的逻辑回归模型,保存训练好的模型,并在接下来的步骤中重新加载:
-
导入必要的 TensorFlow 模块,并从
scikit-learn
加载癌症数据集并对数据进行重缩放:>>> import tensorflow as tf >>> from tensorflow import keras >>> from sklearn import datasets >>> cancer_data = datasets.load_breast_cancer() >>> X = cancer_data.data >>> X = scaler.fit_transform(X) >>> y = cancer_data.target
-
使用 Keras Sequential API 构建一个简单的逻辑回归模型,并指定几个参数:
>>> learning_rate = 0.005 >>> n_iter = 10 >>> tf.random.set_seed(42) >>> model = keras.Sequential([ ... keras.layers.Dense(units=1, activation='sigmoid') ... ]) >>> model.compile(loss='binary_crossentropy', ... optimizer=tf.keras.optimizers.Adam(learning_rate))
-
使用数据训练 TensorFlow 模型:
>>> model.fit(X, y, epochs=n_iter) Epoch 1/10 18/18 [==============================] - 0s 943us/step - loss: 0.2288 Epoch 2/10 18/18 [==============================] - 0s 914us/step - loss: 0.1591 Epoch 3/10 18/18 [==============================] - 0s 825us/step - loss: 0.1303 Epoch 4/10 18/18 [==============================] - 0s 865us/step - loss: 0.1147 Epoch 5/10 18/18 [==============================] - 0s 795us/step - loss: 0.1042 Epoch 6/10 18/18 [==============================] - 0s 796us/step - loss: 0.0971 Epoch 7/10 18/18 [==============================] - 0s 862us/step - loss: 0.0917 Epoch 8/10 18/18 [==============================] - 0s 913us/step - loss: 0.0871 Epoch 9/10 18/18 [==============================] - 0s 795us/step - loss: 0.0835 Epoch 10/10 18/18 [==============================] - 0s 767us/step - loss: 0.0806
-
显示模型的架构:
>>> model.summary() Model: "sequential" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= dense (Dense) multiple 31 ================================================================= Total params: 31 Trainable params: 31 Non-trainable params: 0 _________________________________________________________
我们将检查是否能够稍后恢复相同的模型。
-
希望前面的步骤你已经熟悉。如果不熟悉,可以随时查看我们的 TensorFlow 实现。现在我们将模型保存到一个路径中:
>>> path = './model_tf' >>> model.save(path)
之后,你会看到一个名为model_tf
的文件夹被创建。该文件夹包含训练模型的架构、权重和训练配置。
-
最后,我们从之前的路径加载模型,并显示加载后的模型路径:
>>> new_model = tf.keras.models.load_model(path) >>> new_model.summary() Model: "sequential" _________________________________________________________ Layer (type) Output Shape Param # ========================================================= dense (Dense) multiple 31 ========================================================= Total params: 31 Trainable params: 31 Non-trainable params: 0 _________________________________________________________
我们刚刚重新加载了完全相同的模型。
在 PyTorch 中保存和恢复模型
最后,让我们来看一下如何在 PyTorch 中保存和恢复模型。同样,我们将在相同的癌症数据集上训练一个简单的逻辑回归模型,保存训练好的模型,并在接下来的步骤中重新加载它:
-
转换用于建模的
torch
张量:>>> X_torch = torch.FloatTensor(X) >>> y_torch = torch.FloatTensor(y.reshape(y.shape[0], 1))
-
使用
nn.sequential
模块以及损失函数和优化器构建一个简单的逻辑回归模型:>>> torch.manual_seed(42) >>> model = nn.Sequential(nn.Linear(X.shape[1], 1), nn.Sigmoid()) >>> loss_function = nn.BCELoss() >>> optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
-
重用我们在第六章 - 使用人工神经网络预测股票价格中开发的
train_step
函数,并将PyTorch
模型在数据上训练 10 轮:>>> def train_step(model, X_train, y_train, loss_function, optimizer): pred_train = model(X_train) loss = loss_function(pred_train, y_train) model.zero_grad() loss.backward() optimizer.step() return loss.item() >>> for epoch in range(n_iter): loss = train_step(model, X_torch, y_torch, loss_function, optimizer) print(f"Epoch {epoch} - loss: {loss}") Epoch 0 - loss: 0.8387020826339722 Epoch 1 - loss: 0.7999904751777649 Epoch 2 - loss: 0.76298588514328 Epoch 3 - loss: 0.7277476787567139 Epoch 4 - loss: 0.6943162679672241 Epoch 5 - loss: 0.6627081036567688 Epoch 6 - loss: 0.6329135298728943 Epoch 7 - loss: 0.6048969030380249 Epoch 8 - loss: 0.5786024332046509 Epoch 9 - loss: 0.5539639592170715
-
显示模型的架构:
>>> print(model) Sequential( (0): Linear(in_features=30, out_features=1, bias=True) (1): Sigmoid() )
我们将看看是否可以稍后检索到相同的模型。
-
希望之前的步骤对你来说是熟悉的。如果不是,可以随时查看我们的 PyTorch 实现。现在,我们将模型保存到一个路径:
>>> path = './model.pth ' >>> torch.save(model, path)
之后,你会看到一个名为model.pth
的文件夹被创建。该文件夹包含整个训练模型的架构、权重和训练配置。
-
最后,我们从之前的路径加载模型,并显示加载后的模型路径:
>>> new_model = torch.load(path) >>> print(new_model) Sequential( (0): Linear(in_features=30, out_features=1, bias=True) (1): Sigmoid() )
我们刚刚重新加载了完全相同的模型。
最佳实践 20 - 监控模型性能
机器学习系统现在已启动并运行。为了确保一切正常,我们需要定期进行性能检查。为此,除了实时预测之外,我们还应该同时记录真实值。以下是一些监控模型性能的最佳实践:
-
定义评估指标:选择与问题目标相符的评估指标。准确率、精确度、召回率、F1 分数、AUC-ROC、R²和均方误差是一些常见的指标。
-
基线性能:建立一个基线模型或简单的基于规则的方法,以比较模型的性能。这为理解模型是否有价值提供了背景。
-
学习曲线:绘制学习曲线,展示训练和验证损失或评估指标随周期的变化。这有助于识别过拟合或欠拟合问题,如在最佳实践 17 - 诊断过拟合和欠拟合中所提到的。
继续使用本章前面提到的糖尿病示例,我们进行如下性能检查:
>>> from sklearn.metrics import r2_score
>>> print(f'Health check on the model, R²: {r2_score(y_new,
predictions):.3f}')
Health check on the model, R²: 0.613
我们应该记录性能并设置警报以监控性能衰退。
最佳实践 21 - 定期更新模型
如果表现变差,可能是数据的模式发生了变化。我们可以通过更新模型来解决这个问题。根据模型是否支持在线学习,模型可以通过新的数据集进行在线更新,或使用最新的数据完全重新训练。以下是本章最后部分的一些最佳实践:
-
监控模型表现:持续监控模型表现指标。如果出现显著下降,这通常意味着模型需要更新。
-
定期更新:根据数据变化频率和业务需求实施模型更新计划。这样可以确保模型保持相关性,避免不必要的更新。
-
在线更新:对于支持在线学习的模型,使用新数据逐步更新模型。这适用于基于梯度下降算法或朴素贝叶斯的模型。在线更新可以减少重新训练整个模型的需要,并使模型随着时间推移适应变化的模式。
-
版本控制:保持模型和数据集的版本控制,以便跟踪变更,并在必要时进行回滚。这有助于在一段时间内对比模型表现,并在更新导致表现下降时恢复到先前版本。
-
定期审计:定期审查模型表现,重新评估业务目标,并在必要时更新评估指标。
记住,监控应该是一个持续的过程,从模型开发到部署和维护,确保你的机器学习模型始终有效、值得信赖,并与业务目标保持一致。
总结
本章的目的是为你准备好应对真实世界中的机器学习问题。我们从机器学习解决方案的通用工作流程开始:数据准备、训练集生成、算法训练、评估与选择,最后是系统部署和监控。然后,我们深入探讨了每个阶段的典型任务、常见挑战和最佳实践。
实践出真知。最重要的最佳实践就是实践本身。通过一个真实世界的项目开始,深化你的理解,并应用你所学到的知识。
在下一章,我们将开始我们的深度学习之旅,使用卷积神经网络对服装图片进行分类。
练习
-
你能使用词嵌入提取文本特征,并开发一个多类分类器来分类新闻组数据吗?(请注意,使用词嵌入可能无法比 tf-idf 获得更好的结果,但它是一个很好的实践。)
-
你能在 Kaggle (www.kaggle.com) 上找到一些挑战并实践你在整本书中学到的内容吗?
加入我们书籍的 Discord 讨论空间
加入我们社区的 Discord 讨论空间,与作者和其他读者交流:
第十一章:使用卷积神经网络对服装图像进行分类
上一章总结了我们对一般机器学习最佳实践的讲解。从本章开始,我们将深入探讨更高级的深度学习和强化学习主题。
当我们进行图像分类时,通常会将图像展开成像素向量,然后输入到神经网络(或其他模型)中。尽管这样做可能能完成任务,但我们会丢失重要的空间信息。在本章中,我们将使用卷积神经网络(CNNs)从图像中提取丰富且可区分的表示。你将看到,CNN 的表示能够将“9”识别为“9”,将“4”识别为“4”,将猫识别为猫,或者将狗识别为狗。
我们将从探索 CNN 架构中的各个构建模块开始。接着,我们将开发一个 CNN 分类器,在 PyTorch 中对服装图像进行分类,并揭示卷积机制。最后,我们将介绍数据增强技术,以提升 CNN 模型的性能。
本章将涵盖以下主题:
-
开始了解 CNN 构建模块
-
为分类设计 CNN 架构
-
探索服装图像数据集
-
使用 CNN 对服装图像进行分类
-
使用数据增强提升 CNN 分类器的性能
-
利用迁移学习提升 CNN 分类器性能
开始了解 CNN 构建模块
尽管常规的隐藏层(我们至今看到的全连接层)在某些层次上提取数据特征时表现良好,但这些表示可能不足以区分不同类别的图像。CNN 可以用来提取更丰富、更具区分性的表示,例如,使汽车成为汽车,使飞机成为飞机,或者使手写字母“y”和“z”可识别为“y”和“z”,等等。CNN 是一种受人类视觉皮层生物学启发的神经网络。为了揭开 CNN 的神秘面纱,我将从介绍典型 CNN 的组成部分开始,包括卷积层、非线性层和池化层。
卷积层
卷积层是 CNN 中的第一层,或者是如果 CNN 有多个卷积层的话,前几层。
CNN,特别是它们的卷积层,模仿了我们视觉细胞的工作方式,具体如下:
-
我们的视觉皮层中有一组复杂的神经细胞,这些细胞对视觉场的特定子区域非常敏感,称为感受野。例如,一些细胞只有在垂直边缘出现时才会响应;一些细胞仅在暴露于水平边缘时才会激活;一些细胞对特定方向的边缘反应更强。这些细胞组合在一起,产生完整的视觉感知,每个细胞专门处理特定的组成部分。CNN 中的卷积层由一组过滤器组成,作用类似于人类视觉皮层中的这些细胞。
-
简单的细胞只在其感受域内出现边缘类模式时作出反应。更复杂的细胞对更大的子区域敏感,因此可以对整个视野内的边缘类模式作出反应。一堆卷积层则是一些复杂的细胞,可以在更大的范围内检测模式。
卷积层处理输入图像或矩阵,并通过在输入上执行卷积操作,模拟神经细胞如何对它们调适的特定区域作出反应。从数学上讲,它计算卷积层节点与输入层中单个小区域的点积。这个小区域就是感受野,而卷积层的节点可以看作滤波器上的值。当滤波器沿着输入层滑动时,会计算滤波器与当前感受野(子区域)之间的点积。经过滤波器卷积过所有子区域后,会得到一个新的层,称为特征图。让我们看一个简单的例子,如下所示:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_01.png
图 11.1:特征图是如何生成的
在这个例子中,层* l 包含 5 个节点,滤波器由 3 个节点组成[w[1], w[2], w[3]]。我们首先计算滤波器与层 l 中前 3 个节点的点积,得到输出特征图中的第一个节点;接着,计算滤波器与中间 3 个节点的点积,生成输出特征图中的第二个节点;最后,第三个节点是通过对层 l *中最后 3 个节点进行卷积得到的。
现在,我们将通过以下示例更详细地了解卷积是如何工作的:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_02.png
图 11.2:卷积是如何工作的
在这个例子中,一个 33 的滤波器在一个 55 的输入矩阵上滑动,从左上角的子区域到右下角的子区域。对于每个子区域,使用滤波器计算点积。以左上角的子区域(橙色矩形)为例:我们有 1 * 1 + 1 * 0 + 1 * 1 = 2,因此特征图中的左上角节点(橙色矩形)值为 2。对于下一个最左边的子区域(蓝色虚线矩形),我们计算卷积为 1 * 1 + 1 * 1 + 1 * 1 = 3,所以结果特征图中下一个节点(蓝色虚线矩形)值为 3。最后,生成一个 3*3 的特征图。
那么,我们使用卷积层来做什么呢?它们实际上是用来提取特征的,比如边缘和曲线。如果输出特征图中的像素值较高,则说明对应的感受野包含了滤波器识别的边缘或曲线。例如,在前面的例子中,滤波器描绘了一个反斜线形状“\”的对角边缘;蓝色虚线矩形中的感受野包含了类似的曲线,因此生成了最高强度值 3。然而,右上角的感受野没有包含这样的反斜线形状,因此它在输出特征图中产生了值为 0 的像素。卷积层的作用就像是一个曲线检测器或形状检测器。
此外,卷积层通常会有多个滤波器,检测不同的曲线和形状。在前面的简单示例中,我们只使用了一个滤波器并生成了一个特征图,它表示输入图像中的形状与滤波器所表示的曲线的相似度。为了从输入数据中检测更多的模式,我们可以使用更多的滤波器,比如水平、垂直曲线、30 度角和直角形状。
此外,我们可以堆叠多个卷积层来生成更高级的表示,例如整体形状和轮廓。链式连接更多的层将产生更大的感受野,能够捕捉到更多的全局模式。
每个卷积层之后,我们通常会应用一个非线性层。
非线性层
非线性层基本上就是我们在第六章《使用人工神经网络预测股票价格》中看到的激活层。它的作用显然是引入非线性。回想一下,在卷积层中,我们只执行线性操作(乘法和加法)。无论神经网络有多少个线性隐藏层,它都只能表现得像一个单层感知器。因此,我们需要在卷积层后加一个非线性激活层。再次强调,ReLU 是深度神经网络中最常用的非线性层候选。
池化层
通常,在一个或多个卷积层(及非线性激活层)之后,我们可以直接利用提取的特征进行分类。例如,在多类分类问题中,我们可以应用 Softmax 层。但我们先做一些数学运算。
假设我们对 28 * 28 的输入图像应用 20 个 5 * 5 的滤波器在第一层卷积中,那么我们将得到 20 个输出特征图,每个特征图的大小为 (28 – 5 + 1) * (28 – 5 + 1) = 24 * 24 = 576。这意味着下一层的输入特征数将从 784 (28 * 28) 增加到 11,520 (20 * 576)。然后我们在第二层卷积中应用 50 个 5 * 5 的滤波器。输出的大小变为 50 * 20 * (24 – 5 + 1) * (24 – 5 + 1) = 400,000。这比我们最初的 784 要高得多。我们可以看到,在最后的 softmax 层之前,每经过一层卷积层,维度都会急剧增加。这可能会引发过拟合问题,更不用说训练如此大量权重的成本了。
为了解决维度急剧增加的问题,我们通常会在卷积层和非线性层之后使用池化层。池化层也叫做下采样层。正如你所想,它通过对子区域中的特征进行聚合来减少特征图的维度。典型的池化方法包括:
-
最大池化,取所有不重叠子区域的最大值
-
均值池化,取所有不重叠子区域的均值
在以下示例中,我们对 4 * 4 的特征图应用 2 * 2 的最大池化滤波器,并输出一个 2 * 2 的结果:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_03.png
图 11.3:最大池化的工作原理
除了维度减小,池化层还有另一个优点:平移不变性。这意味着即使输入矩阵经历了小幅度的平移,它的输出也不会改变。例如,如果我们将输入图像向左或向右移动几个像素,只要子区域中的最高像素保持不变,最大池化层的输出仍然会保持相同。换句话说,池化层使得预测对位置的敏感度降低。以下示例说明了最大池化如何实现平移不变性。
这是 4 * 4 的原始图像,以及使用 2 * 2 滤波器进行最大池化后的输出:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_04.png
图 11.4:原始图像和最大池化输出
如果我们将图像向右移动 1 个像素,得到以下移动后的图像和相应的输出:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_05.png
图 11.5:移动后的图像和输出
即使我们水平移动输入图像,输出也是相同的。池化层增加了图像平移的鲁棒性。
你现在已经了解了 CNN 的所有组件了。比你想象的要简单,对吧?接下来我们来看看它们是如何构成一个 CNN 的。
构建用于分类的卷积神经网络(CNN)
将三种类型的卷积相关层与全连接层(们)结合在一起,我们可以按如下方式构建 CNN 模型进行分类:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_06.png图 11.6:CNN 架构
在此示例中,输入图像首先被传入一个卷积层(带有 ReLU 激活函数),该层由一组滤波器组成。卷积滤波器的系数是可训练的。一个训练良好的初始卷积层能够提取输入图像的良好低级特征表示,这对于后续的卷积层(如果有的话)以及后续的分类任务是至关重要的。然后,每个生成的特征图会通过池化层进行下采样。
接下来,将聚合的特征图输入到第二个卷积层。类似地,第二个池化层减少了输出特征图的尺寸。你可以根据需要链式连接任意数量的卷积层和池化层。第二个(或更多,如果有的话)卷积层试图通过一系列从前面层派生的低级表示,构建高级表示,比如整体形状和轮廓。
到目前为止,特征图都是矩阵。我们需要将它们展平为一个向量,然后才能进行后续的分类。展平后的特征就像是输入到一个或多个全连接的隐藏层。我们可以将 CNN 看作是一个常规神经网络之上的分层特征提取器。CNN 特别适合于利用强大而独特的特征来区分图像。
如果我们处理的是二分类问题,网络的输出将是一个逻辑函数;如果是多类问题,则是一个 softmax 函数;如果是多标签问题,则是多个逻辑函数的集合。
到目前为止,你应该对 CNN 有了较好的理解,并且应该准备好解决服装图像分类问题。让我们从探索数据集开始。
探索服装图像数据集
服装数据集 Fashion-MNIST (github.com/zalandoresearch/fashion-mnist
)是来自 Zalando(欧洲最大的在线时尚零售商)的图像数据集。它包含 60,000 个训练样本和 10,000 个测试样本。每个样本都是一个 28 * 28 的灰度图像,并带有以下 10 个类别的标签,每个类别代表一种服装:
-
0: T 恤/上衣
-
1: 长裤
-
2: 套头衫
-
3: 连衣裙
-
4: 外套
-
5: 凉鞋
-
6: 衬衫
-
7: 运动鞋
-
8: 包
-
9: 踝靴
Zalando 旨在使该数据集像手写数字 MNIST 数据集一样流行,用于基准测试算法,因此称其为 Fashion-MNIST。
你可以通过 获取数据 部分中的 GitHub 链接直接下载数据集,或者直接从 PyTorch 导入,它已经包含了数据集及其数据加载器 API。我们将采用后者的方法,如下所示:
>>> import torch, torchvision
>>> from torchvision import transforms
>>> image_path = './'
>>> transform = transforms.Compose([transforms.ToTensor()])
>>> train_dataset = torchvision.datasets.FashionMNIST(root=image_path,
train=True,
transform=transform,
download=True)
>>> test_dataset = torchvision.datasets.FashionMNIST(root=image_path,
train=False,
transform=transform,
download=False)
我们刚刚导入了 torchvision
,这是 PyTorch 中一个包,提供了访问数据集、模型架构以及用于计算机视觉任务的各种图像转换工具。
torchvision
库包含以下关键组件:
-
数据集和数据加载器:
torchvision.datasets
提供了用于图像分类、目标检测、语义分割等任务的标准数据集。示例包括 MNIST、CIFAR-10、ImageNet、FashionMNIST
等。torch.utils.data.DataLoader
帮助创建数据加载器,从数据集中高效加载和预处理数据批次。 -
转换:
torchvision.transforms
提供了多种图像转换工具,用于数据增强、标准化和预处理。常见的转换包括调整大小、裁剪、标准化等。 -
模型架构:
torchvision.models
提供了多种计算机视觉任务的预训练模型架构。 -
工具:
torchvision.utils
包含用于图像可视化、将图像转换为不同格式等的实用函数。
我们刚刚加载的 Fashion-MNIST 数据集带有预设的训练集和测试集分区方案。训练集存储在 image_path
中。然后,我们将它们转换为 Tensor 格式。输出这两个数据集对象以获取更多细节:
>>> print(train_dataset)
Dataset FashionMNIST
Number of datapoints: 60000
Root location: ./
Split: Train
StandardTransform
Transform: Compose(
ToTensor()
)
>>> print(test_dataset)
Dataset FashionMNIST
Number of datapoints: 10000
Root location: ./
Split: Test
StandardTransform
Transform: Compose(
ToTensor()
)
如你所见,共有 60,000 个训练样本和 10,000 个测试样本。
接下来,我们将训练集加载为每批 64 个样本,如下所示:
>>> from torch.utils.data import DataLoader
>>> batch_size = 64
>>> torch.manual_seed(42)
>>> train_dl = DataLoader(train_dataset, batch_size, shuffle=True)
在 PyTorch 中,DataLoader
是一个工具,用于在训练或评估机器学习模型时高效加载和预处理数据集中的数据。它本质上是对数据集的包装,提供了遍历数据批次的方法。这对于处理大型数据集(无法完全加载到内存中)特别有用。
DataLoader 的关键特性:
-
批处理:它会自动将数据集分成指定大小的批次,从而在训练过程中使用小批量数据。
-
洗牌:你可以将
shuffle
参数设置为True
,以在每个训练周期之前对数据进行洗牌,这有助于减少偏差并提高收敛性。
随时检查第一批数据中的图像样本及其标签,例如:
>>> data_iter = iter(train_dl)
>>> images, labels = next(data_iter)
>>> print(labels)
tensor([5, 7, 4, 7, 3, 8, 9, 5, 3, 1, 2, 3, 2, 3, 3, 7, 9, 9, 3, 2, 4, 6, 3, 5, 5, 3, 2, 0, 0, 8, 4, 2, 8, 5, 9, 2, 4, 9, 4, 4, 3, 4, 9, 7, 2, 0, 4, 5, 4, 8, 2, 6, 7, 0, 2, 0, 6, 3, 3, 5, 6, 0, 0, 8])
标签数组不包含类别名称。因此,我们将它们定义如下,并稍后用于绘图:
>>> class_names = ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat', 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot']
查看图像数据的格式,如下所示:
>>> print(images[0].shape)
torch.Size([1, 28, 28])
>>> print(torch.max(images), torch.min(images))
tensor(1.) tensor(0.)
每张图像表示为 28 * 28 像素,其值的范围是 [0, 1]
。
现在我们展示一张图像,如下所示:
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> npimg = images[1].numpy()
>>> plt.imshow(np.transpose(npimg, (1, 2, 0)))
>>> plt.colorbar()
>>> plt.title(class_names[labels[1]])
>>> plt.show()
在 PyTorch 中,np.transpose(npimg, (1, 2, 0))
用于通过matplotlib
可视化图像。(1, 2, 0)
是表示维度新顺序的元组。在 PyTorch 中,图像采用(channels, height, width)
格式表示,而 matplotlib 期望图像采用(height, width, channels)
格式。因此,使用np.transpose(npimg, (1, 2, 0))
重新排列图像数组的维度,以匹配 matplotlib 期望的格式。
请参考下面的运动鞋图像——最终结果:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_07.png
图 11.7:Fashion-MNIST 中的一个训练样本
同样地,我们展示前 16 个训练样本,如下所示:
>>> for i in range(16):
... plt.subplot(4, 4, i + 1)
... plt.subplots_adjust(hspace=.3)
... plt.xticks([])
... plt.yticks([])
... npimg = images[i].numpy()
... plt.imshow(np.transpose(npimg, (1, 2, 0)), cmap="Greys")
... plt.title(class_names[labels[i]])
... plt.show()
请参考下面的图像以查看结果:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_08.png
图 11.8:Fashion-MNIST 中的 16 个训练样本
在下一部分,我们将构建我们的 CNN 模型来分类这些服装图像。
使用 CNN 对服装图像进行分类
如前所述,CNN 模型有两个主要组件:由一组卷积层和池化层组成的特征提取器,以及类似常规神经网络的分类器后端。
让我们从构建 CNN 模型开始这个项目。
构建 CNN 模型
我们导入必要的模块并初始化一个基于 Sequential 的模型:
>>> import torch.nn as nn
>>> model = nn.Sequential()
对于卷积提取器,我们将使用三个卷积层。我们从第一个卷积层开始,使用 32 个小尺寸的 3 * 3 滤波器。这是通过以下代码实现的:
>>> model.add_module('conv1',
nn.Conv2d(in_channels=1,
out_channels=32,
kernel_size=3)
)
>>> model.add_module('relu1', nn.ReLU())
请注意,我们使用 ReLU 作为激活函数。
卷积层后跟一个 2 * 2 滤波器的最大池化层:
>>> model.add_module('pool1', nn.MaxPool2d(kernel_size=2))
下面是第二个卷积层。它有 64 个 3 * 3 滤波器,并且也带有 ReLU 激活函数:
>>> model.add_module('conv2',
nn.Conv2d(in_channels=32,
out_channels=64,
kernel_size=3)
)
>>> model.add_module('relu2', nn.ReLU())
第二个卷积层后跟另一个带有 2 * 2 滤波器的最大池化层:
>>> model.add_module('pool2', nn.MaxPool2d(kernel_size=2))
我们继续添加第三个卷积层。此时它有 128 个 3 * 3 滤波器:
>>> model.add_module('conv3',
nn.Conv2d(in_channels=64,
out_channels=128,
kernel_size=3)
)
>>> model.add_module('relu3', nn.ReLU())
让我们暂停一下,看看生成的滤波器映射是什么。我们将一批随机样本(64 个样本)输入到到目前为止构建的模型中:
>>> x = torch.rand((64, 1, 28, 28))
>>> print(model(x).shape)
torch.Size([64, 128, 3, 3])
通过提供输入形状(64, 1, 28, 28)
,表示批量中的 64 张图像,图像大小为 28 * 28,输出的形状为(64, 128, 3, 3)
,表示具有 128 个通道和 3 * 3 空间大小的特征图。
接下来,我们需要将这些小的 128 * 3 * 3 空间表示展平,以提供特征给下游的分类器后端:
>>> model.add_module('flatten', nn.Flatten())
结果,我们得到了一个形状为(64, 1152)
的展平输出,通过以下代码计算得出:
>>> print(model(x).shape)
torch.Size([64, 1152])
对于分类器后端,我们只使用一个包含 64 个节点的隐藏层:
>>> model.add_module('fc1', nn.Linear(1152, 64))
>>> model.add_module('relu4', nn.ReLU())
这里的隐藏层是常规的全连接层,使用 ReLU 作为激活函数。
最后,输出层有 10 个节点,代表我们案例中的 10 个不同类别,并带有 softmax 激活函数:
>>> model.add_module('fc2', nn.Linear(64, 10))
>>> model.add_module('output', nn.Softmax(dim = 1))
让我们来看一下模型架构,如下所示:
>>> print(model)
Sequential(
(conv1): Conv2d(1, 32, kernel_size=(3, 3), stride=(1, 1))
(relu1): ReLU()
(pool1): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(conv2): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1))
(relu2): ReLU()
(pool2): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(conv3): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1))
(relu3): ReLU()
(flatten): Flatten(start_dim=1, end_dim=-1)
(fc1): Linear(in_features=1152, out_features=64, bias=True)
(relu4): ReLU()
(fc2): Linear(in_features=64, out_features=10, bias=True)
(output): Softmax(dim=1)
)_________________________________________________________________
如果你想详细显示每一层,包括其输出的形状和可训练参数的数量,可以使用torchsummary
库。你可以通过pip
安装它,并按如下方式使用:
>>> pip install torchsummary
>>> from torchsummary import summary
>>> summary(model, input_size=(1, 28, 28), batch_size=-1, device="cpu")
----------------------------------------------------------------
Layer (type) Output Shape Param #
================================================================
Conv2d-1 [-1, 32, 26, 26] 320
ReLU-2 [-1, 32, 26, 26] 0
MaxPool2d-3 [-1, 32, 13, 13] 0
Conv2d-4 [-1, 64, 11, 11] 18,496
ReLU-5 [-1, 64, 11, 11] 0
MaxPool2d-6 [-1, 64, 5, 5] 0
Conv2d-7 [-1, 128, 3, 3] 73,856
ReLU-8 [-1, 128, 3, 3] 0
Flatten-9 [-1, 1152] 0
Linear-10 [-1, 64] 73,792
ReLU-11 [-1, 64] 0
Linear-12 [-1, 10] 650
Softmax-13 [-1, 10] 0
================================================================
Total params: 167,114
Trainable params: 167,114
Non-trainable params: 0
----------------------------------------------------------------
Input size (MB): 0.00
Forward/backward pass size (MB): 0.53
Params size (MB): 0.64
Estimated Total Size (MB): 1.17
----------------------------------------------------------------
如你所见,卷积层的输出是三维的,其中前两个是特征图的维度,第三个是卷积层中使用的滤波器数量。在示例中,最大池化输出的大小(前两个维度)是其输入特征图的一半。特征图被池化层下采样。你可能想知道,如果去掉所有池化层,训练的参数会有多少。实际上,是 4,058,314 个!所以,应用池化的好处显而易见:避免过拟合并减少训练成本。
你可能会想知道为什么卷积滤波器的数量在每一层中不断增加。回想一下,每个卷积层都试图捕捉特定层次结构的模式。第一层卷积捕捉低级模式,例如边缘、点和曲线。然后,后续的层将这些在前几层中提取的模式组合起来,形成更高级的模式,例如形状和轮廓。随着我们在卷积层中向前推进,在大多数情况下,捕捉的模式组合会越来越多。因此,我们需要不断增加(或至少不减少)卷积层中滤波器的数量。
拟合 CNN 模型
现在是时候训练我们刚刚构建的模型了。
首先,我们使用 Adam 作为优化器,交叉熵作为损失函数,分类准确率作为评估指标来编译模型:
>>> device = torch.device("cuda:0")
# device = torch.device("cpu")
>>> model = model.to(device)
>>> loss_fn = nn.CrossEntropyLoss()
>>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
在这里,我们使用 GPU 进行训练,因此我们运行torch.device("cuda:0")
来指定 GPU 设备(第一个设备,索引为 0)并在其上分配张量。选择 CPU 也是一种可行的选项,但相对较慢。
接下来,我们通过定义以下函数来训练模型:
>>> def train(model, optimizer, num_epochs, train_dl):
for epoch in range(num_epochs):
loss_train = 0
accuracy_train = 0
for x_batch, y_batch in train_dl:
x_batch = x_batch.to(device)
y_batch = y_batch.to(device)
pred = model(x_batch)
loss = loss_fn(pred, y_batch)
loss.backward()
optimizer.step()
optimizer.zero_grad()
loss_train += loss.item() * y_batch.size(0)
is_correct = (torch.argmax(pred, dim=1) ==
y_batch).float()
accuracy_train += is_correct.sum().cpu()
loss_train /= len(train_dl.dataset)
accuracy_train /= len(train_dl.dataset)
print(f'Epoch {epoch+1} - loss: {loss_train:.4f} - accuracy:
{accuracy_train:.4f}')
我们将训练 CNN 模型进行 30 次迭代并监控学习进度:
>>> num_epochs = 30
>>> train(model, optimizer, num_epochs, train_dl)
Epoch 1 - loss: 1.7253 - accuracy: 0.7385
Epoch 2 - loss: 1.6333 - accuracy: 0.8287
…
Epoch 10 - loss: 1.5572 - accuracy: 0.9041
…
Epoch 20 - loss: 1.5344 - accuracy: 0.9270
...
Epoch 29 - loss: 1.5249 - accuracy: 0.9362
Epoch 30 - loss: 1.5249 - accuracy: 0.9363
我们能够在训练集上达到约 94%的准确率。如果你想查看在测试集上的表现,可以执行如下操作:
>>> test_dl = DataLoader(test_dataset, batch_size, shuffle=False)
>>> def evaluate_model(model, test_dl):
accuracy_test = 0
with torch.no_grad():
for x_batch, y_batch in test_dl:
pred = model.cpu()(x_batch)
is_correct = torch.argmax(pred, dim=1) == y_batch
accuracy_test += is_correct.float().sum().item()
print(f'Accuracy on test set: {100 * accuracy_test / 10000} %')
>>> evaluate_model(model, test_dl)
Accuracy on test set: 90.25 %
该模型在测试数据集上达到了 90%的准确率。请注意,由于隐藏层初始化的差异,或 GPU 中的非确定性操作等因素,这一结果可能会有所不同。
最佳实践
与 CPU 相比,更适合在 GPU 上执行的操作通常涉及可以并行化的任务,这些任务能够受益于 GPU 架构提供的大规模并行性和计算能力。以下是一些例子:
-
矩阵和卷积操作
-
同时处理大量数据批次。涉及批处理的任务,如机器学习模型中小批次的训练和推理,受益于 GPU 的并行处理能力。
-
神经网络中的前向传播和反向传播,通常由于硬件加速,在 GPU 上更快。
最佳实践
相比于 GPU,适合在 CPU 上执行的操作通常涉及较少的可并行化任务,需要更多的顺序处理或小数据集。以下是一些示例:
-
预处理工作,如数据加载、特征提取和数据增强。
-
小模型推理。对于小模型或计算要求较低的推理任务,在 CPU 上执行操作可能更具成本效益。
-
控制流操作。涉及条件语句或循环的操作通常在 CPU 上更高效,因为 CPU 具有顺序处理的特点。
你已经看到了训练模型的表现,你可能会想知道卷积滤波器的样子。你将在下一节中了解。
可视化卷积滤波器
我们从训练好的模型中提取卷积滤波器,并通过以下步骤进行可视化。
从模型摘要中,我们知道模型中的conv1
、conv2
和conv3
层是卷积层。以第三个卷积层为例,我们获取它的滤波器如下:
>>> conv3_weight = model.conv3.weight.data
>>> print(conv3_weight.shape)
torch.Size([128, 64, 3, 3])
很明显,有 128 个滤波器,每个滤波器的尺寸为 3x3,并且包含 64 个通道。
接下来,为了简化,我们仅可视化前 16 个滤波器中的第一个通道,排列为四行四列:
>>> n_filters = 16
>>> for i in range(n_filters):
... weight = conv3_weight[i].cpu().numpy()
... plt.subplot(4, 4, i+1)
... plt.xticks([])
... plt.yticks([])
... plt.imshow(weight[0], cmap='gray')
... plt.show()
请参见以下截图以获取最终结果:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_09.png
图 11.9:训练后的卷积滤波器
在卷积滤波器中,深色方块代表较小的权重,白色方块表示较大的权重。根据这一直觉,我们可以看到第二行第二个滤波器检测到了接收域中的垂直线,而第一行第三个滤波器则检测到了从右下角的亮色到左上角的暗色的梯度。
在前面的例子中,我们用 60,000 个标注样本训练了服装图像分类器。然而,在实际中收集这么大的标注数据集并不容易。具体来说,图像标注既昂贵又耗时。如何在样本数量有限的情况下有效地训练图像分类器?一种解决方案是数据增强。
使用数据增强提升 CNN 分类器性能
数据增强意味着扩展现有训练数据集的大小,以提高泛化性能。它克服了收集和标注更多数据的成本。在 PyTorch 中,我们使用torchvision.transforms
模块来实时实现图像增强。
用于数据增强的翻转操作
增强图像数据的方法有很多,最简单的可能是水平或垂直翻转图像。例如,如果我们水平翻转一张现有图像,就会得到一张新图像。为了创建水平翻转的图像,我们使用transforms.functional.hflip
,如下所示:
>>> image = images[1]
>>> img_flipped = transforms.functional.hflip(image)
让我们看看翻转后的图像:
>>> def display_image_greys(image):
npimg = image.numpy()
plt.imshow(np.transpose(npimg, (1, 2, 0)), cmap="Greys")
plt.xticks([])
plt.yticks([])
>>> plt.figure(figsize=(8, 8))
>>> plt.subplot(1, 2, 1)
>>> display_image_greys(image)
>>> plt.subplot(1, 2, 2)
>>> display_image_greys(img_flipped)
>>> plt.show()
请参阅以下截图以查看最终结果:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_10.png
图 11.10:用于数据增强的水平翻转图像
在使用数据增强进行训练时,我们将使用随机生成器创建处理过的图像。对于水平翻转,我们将使用transforms.RandomHorizontalFlip
,它以 50%的概率随机水平翻转图像,从而有效地增强数据集。让我们看三个输出样本:
>>> torch.manual_seed(42)
>>> flip_transform =
transforms.Compose([transforms.RandomHorizontalFlip()])
>>> plt.figure(figsize=(10, 10))
>>> plt.subplot(1, 4, 1)
>>> display_image_greys(image)
>>> for i in range(3):
plt.subplot(1, 4, i+2)
img_flip = flip_transform(image)
display_image_greys(img_flip)
请参阅以下截图以查看最终结果:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_11.png
图 11.11:用于数据增强的随机水平翻转图像
如你所见,生成的图像要么是水平翻转的,要么没有翻转。
通常,水平翻转的图像传达的信息与原始图像相同。垂直翻转的图像不常见,尽管你可以使用transforms.RandomVerticalFlip
来生成它们。值得注意的是,翻转仅适用于与方向无关的情况,比如分类猫和狗或识别汽车部件。相反,在方向很重要的情况下进行翻转是危险的,比如区分左转标志和右转标志。
用于数据增强的旋转
与水平或垂直翻转每次旋转 90 度不同,可以在图像数据增强中应用小到中度的旋转。让我们看看使用transforms
进行的随机旋转。在以下示例中,我们使用RandomRotation
:
>>> torch.manual_seed(42)
>>> rotate_transform =
transforms.Compose([transforms. RandomRotation(20)])
>>> plt.figure(figsize=(10, 10))
>>> plt.subplot(1, 4, 1)
>>> display_image_greys(image)
>>> for i in range(3):
plt.subplot(1, 4, i+2)
img_rotate = rotate_transform(image)
display_image_greys(img_rotate)
请参阅以下截图以查看最终结果:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_12.png
图 11.12:用于数据增强的旋转图像
在前面的示例中,图像被旋转了从-20(逆时针方向)到 20(顺时针方向)的任意角度。
用于数据增强的裁剪
裁剪是另一种常用的增强方法。它通过选择原始图像的一部分生成新图像。通常,这一过程伴随裁剪区域被调整为预定的输出尺寸,以确保尺寸一致。
现在,让我们探索如何利用transforms.RandomResizedCrop
随机选择裁剪区域的宽高比,并随后调整结果的大小以匹配原始尺寸:
>>> torch.manual_seed(42)
>>> crop_transform = transforms.Compose([
transforms.RandomResizedCrop(size=(28, 28), scale=(0.7, 1))])
>>> plt.figure(figsize=(10, 10))
>>> plt.subplot(1, 4, 1)
>>> display_image_greys(image)
>>> for i in range(3):
plt.subplot(1, 4, i+2)
img_crop = crop_transform(image)
display_image_greys(img_crop)
这里,size
指定裁剪和调整大小后输出图像的尺寸;scale
定义了裁剪的缩放范围。如果设置为(min_scale
,max_scale
),则裁剪区域的大小会随机选择,范围在min_scale
和max_scale
倍的原始图像大小之间。
请参考以下截图查看最终结果:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_11_13.png
图 11.13:用于数据增强的裁剪图像
如您所见,scale=(0.7, 1.0)
表示裁剪区域的大小可以在原始图像大小的 70%到 100%之间变化。
通过数据增强改进服装图像分类器
配备了几种常见的数据增强方法后,我们将在以下步骤中应用它们来训练我们的图像分类器,使用一个小型数据集:
-
我们通过结合刚才讨论的所有数据增强技术来构建转换函数:
>>> torch.manual_seed(42) >>> transform_train = transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.RandomRotation(10), transforms.RandomResizedCrop(size=(28, 28), scale=(0.9, 1)), transforms.ToTensor(), ])
在这里,我们使用水平翻转、最多 10 度的旋转和裁剪,裁剪的尺寸范围在原始图像的 90%到 100%之间。
-
我们重新加载训练数据集,使用这个转换函数,并仅使用 500 个样本进行训练:
>>> train_dataset_aug = torchvision.datasets.FashionMNIST( root=image_path, train=True, transform=transform_train, download=False) >>> from torch.utils.data import Subset >>> train_dataset_aug_small = Subset(train_dataset_aug, torch.arange(500))
我们将看到数据增强如何在可用的非常小的训练集上提高泛化能力和性能。
-
将这个小型但增强过的训练集按 64 个样本一批加载,像我们之前做的那样:
>>> train_dl_aug_small = DataLoader(train_dataset_aug_small, batch_size, shuffle=True)
请注意,即使是同一原始图像,通过这个数据加载器进行迭代时,会生成不同的增强图像,可能会被翻转、旋转或在指定范围内裁剪。
-
接下来,我们使用之前相同的架构初始化 CNN 模型,并相应地初始化优化器:
>>> model = nn.Sequential() >>> ...(here we skip repeating the same code) >>> model = model.to(device) >>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
-
现在我们在增强后的小型数据集上训练模型:
>>> train(model, optimizer, 1000, train_dl_aug_small) Epoch 1 - loss: 2.3013 - accuracy: 0.1400 ... Epoch 301 - loss: 1.6817 - accuracy: 0.7760 ... Epoch 601 - loss: 1.5006 - accuracy: 0.9620 ... Epoch 1000 - loss: 1.4904 - accuracy: 0.9720
我们对模型进行了 1,000 次迭代的训练。
-
让我们看看它在测试集上的表现如何:
>>> evaluate_model(model, test_dl) Accuracy on test set: 79.24%
使用数据增强的模型在测试集上的分类准确率为 79.24%。请注意,这个结果可能会有所不同。
我们还尝试了不使用数据增强进行训练,结果测试集的准确率大约为 76%。当使用数据增强时,准确率提高到了 79%。像往常一样,您可以像我们在第六章《使用人工神经网络预测股票价格》中所做的那样,随意调整超参数,看看能否进一步提高分类性能。
迁移学习是提升 CNN 分类器性能的另一种方法。让我们继续下一部分。
通过迁移学习推进 CNN 分类器
迁移学习是一种机器学习技术,其中在一个任务上训练好的模型会被适配或微调用于第二个相关任务。在迁移学习中,第一次任务(源任务)训练期间获得的知识被用来改进第二次任务(目标任务)的学习。当目标任务的数据有限时,这尤其有用,因为它可以将来自更大或更具多样性的数据集的知识转移过来。
迁移学习的典型工作流程包括:
-
预训练模型:从一个已经在一个大规模且相关的数据集上针对不同但相关任务训练好的预训练模型开始。这个模型通常是一个深度神经网络,例如用于图像任务的 CNN 模型。
-
特征提取:使用预训练模型作为特征提取器。移除最后的分类层(如果存在),并使用其中一个中间层的输出作为数据的特征表示。这些特征可以捕捉到源任务中的高级模式和信息。
-
微调:在特征提取器上添加新的层。这些新层是针对目标任务的,通常会随机初始化。然后,您将整个模型,包括特征提取器和新层,在目标数据集上进行训练。微调使得模型能够适应目标任务的具体要求。
在实现迁移学习进行我们的服装图像分类任务之前,让我们先来探索 CNN 架构和预训练模型的发展历程。即使是早期的 CNN 架构,今天仍然在积极使用!这里的关键是,所有这些架构都是现代深度学习工具箱中的宝贵工具,特别是在进行迁移学习任务时。
CNN 架构和预训练模型的发展
用于图像处理的 CNN 概念可以追溯到 1990 年代。早期的架构如LeNet-5(1998 年)展示了深度神经网络在图像分类中的潜力。LeNet-5 由两组卷积层组成,后跟两层全连接层和一层输出层。每个卷积层使用 5x5 的卷积核。LeNet-5 在图像分类任务中起到了重要作用,展示了深度学习的有效性。它在 MNIST 数据集上取得了高精度,MNIST 是一个广泛使用的手写数字识别基准数据集。
LeNet-5 的成就为更复杂架构的创建铺平了道路,例如AlexNet(2012 年)。它由八层组成——五组卷积层后接三层全连接层。它首次在深度 CNN 中使用了 ReLU 激活函数,并在全连接层中采用了 dropout 技术以防止过拟合。使用了数据增强技术,如随机裁剪和水平翻转,来提高模型的泛化能力。AlexNet 的成功引发了对神经网络的重新关注,并促成了更深层次和更复杂架构的发展,包括 VGGNet、GoogLeNet 和 ResNet,这些架构在计算机视觉中成为了基础。
VGGNet由牛津大学的视觉几何小组于 2014 年提出。VGGNet 遵循一个简单且统一的架构,由一系列卷积层组成,后面跟着最大池化层,最后是堆叠的全连接层。它主要使用 3x3 卷积滤波器,能够捕捉细粒度的空间信息。最常用的版本是 VGG16 和 VGG19,它们分别有 16 层和 19 层,常作为各种计算机视觉任务中的迁移学习起点。
同年,GoogLeNet,更广为人知的Inception,由谷歌开发。GoogLeNet 的标志性特点是 Inception 模块。与使用固定滤波器大小的单个卷积层不同,Inception 模块并行使用多种滤波器大小(1x1、3x3、5x5)。这些并行操作可以在不同的尺度上捕捉特征,从而提供更丰富的表示。与 VGGNet 类似,预训练的 GoogLeNet 也有多个版本,例如 InceptionV1、InceptionV2、InceptionV3 和 InceptionV4,每个版本在架构上有所变化和改进。
ResNet,即残差网络,由 Kaiming He 等人在 2015 年提出,旨在解决消失梯度问题——在 CNN 中,损失函数的梯度变得极其微小。其核心创新是使用残差连接。这些模块允许网络在训练过程中跳过某些层。残差模块不是直接学习从输入到输出的目标映射,而是学习一个残差映射,该映射与原始输入相加。通过这种方式,深层网络成为可能。它的预训练模型有多个版本,例如 ResNet-18、ResNet-34、ResNet-50、ResNet-101 以及极深的 ResNet-152。再次强调,数字表示网络的深度。
CNN 架构和预训练模型的开发仍在继续,随着像 EfficientNet、MobileNet 等创新的出现,以及针对特定任务的定制架构。例如,MobileNet模型设计时注重高效的计算资源和内存使用。它们专门为在硬件能力有限的设备上部署而优化,如智能手机、物联网设备和边缘设备。
你可以在此页面查看所有可用的预训练模型:PyTorch 预训练模型。
CNN 架构的演变以及预训练模型的出现彻底改变了计算机视觉任务。它们显著提高了图像分类、目标检测、分割以及许多其他应用的技术水平。
现在,让我们探索使用预训练模型来增强我们的服装图像分类器。
通过微调 ResNet 来改进服装图像分类器
我们将在接下来的步骤中使用预训练的 ResNet,具体是 ResNet-18 进行迁移学习:
-
我们首先从
torchvision
导入预训练的 ResNet-18 模型:>>> from torchvision.models import resnet18 >>> my_resnet = resnet18(weights='IMAGENET1K_V1')
这里,IMAGENET1K
指的是在 ImageNet-1K 数据集上训练的预训练模型(www.image-net.org/download.php
),而V1
指的是预训练模型的版本 1。
这是预训练模型步骤。
-
由于 ImageNet-1K 数据集包含 RGB 图像,原始
ResNet
中的第一卷积层设计用于三维输入。然而,我们的FashionMNIST
数据集包含灰度图像,因此我们需要修改它以接受一维输入:>>> my_resnet.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)
我们只是将原始定义中第一个卷积层的第一个参数——输入维度——从 3 改为 1:
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
-
将输出层改为输出 10 个类别而不是 1,000 个类别:
>>> num_ftrs = my_resnet.fc.in_features >>> my_resnet.fc = nn.Linear(num_ftrs, 10)
在这里,我们只更新输出层的输出大小。
第 2 步和第 3 步为微调过程做准备。
-
最后,我们通过在完整训练集上训练,微调了已适配的预训练模型:
>>> my_resnet = my_resnet.to(device) >>> optimizer = torch.optim.Adam(my_resnet.parameters(), lr=0.001) >>> train(my_resnet, optimizer, 10, train_dl) Epoch 1 - loss: 0.4797 - accuracy: 0.8256 Epoch 2 - loss: 0.3377 - accuracy: 0.8791 Epoch 3 - loss: 0.2921 - accuracy: 0.8944 Epoch 4 - loss: 0.2629 - accuracy: 0.9047 Epoch 5 - loss: 0.2336 - accuracy: 0.9157 Epoch 6 - loss: 0.2138 - accuracy: 0.9221 Epoch 7 - loss: 0.1911 - accuracy: 0.9301 Epoch 8 - loss: 0.1854 - accuracy: 0.9312 Epoch 9 - loss: 0.1662 - accuracy: 0.9385 Epoch 10 - loss: 0.1575 - accuracy: 0.9427
仅经过 10 次迭代,经过微调的 ResNet 模型就达到了 94%的准确率。
-
它在测试集上的表现如何?让我们看一下以下结果:
>>> evaluate_model(my_resnet, test_dl) Accuracy on test set: 91.01 %
通过仅进行 10 次训练迭代,我们能够将测试集的准确率从 90%提升到 91%。
使用 CNN 进行迁移学习是一种强大的技术,可以让你利用预训练模型,并将其调整为特定的图像分类任务。
概述
本章中,我们使用 CNN 对服装图像进行分类。我们从详细解释 CNN 模型的各个组成部分开始,学习了 CNN 是如何受到我们视觉细胞工作方式的启发。然后,我们开发了一个 CNN 模型,用于分类 Zalando 的 Fashion-MNIST 服装图像。我们还讨论了数据增强和几种流行的图像增强方法。在讨论了 CNN 架构和预训练模型的演变之后,我们通过 ResNets 进行了迁移学习的实践。
在下一章,我们将重点介绍另一种类型的深度学习网络:循环神经网络(RNNs)。CNN 和 RNN 是目前最强大的两种深度神经网络,使得深度学习在如今如此受欢迎。
练习
-
如前所述,你能尝试微调 CNN 图像分类器并看看能否超越我们所取得的成绩吗?
-
你也能使用 dropout 技术来改进 CNN 模型吗?
-
你能尝试使用预训练的视觉 Transformer 模型吗:
huggingface.co/google/vit-base-patch16-224?
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者一起讨论:
第十二章:使用循环神经网络(RNN)进行序列预测
在上一章中,我们关注了卷积神经网络(CNNs),并利用它们处理图像相关任务。在本章中,我们将探索循环神经网络(RNNs),它们适用于顺序数据和时间依赖数据,如日常温度、DNA 序列以及顾客随时间变化的购物交易。你将学习循环架构的工作原理,并看到该模型的变体。接着我们将研究它们的应用,包括情感分析、时间序列预测和文本生成。
本章将涵盖以下主题:
-
跟踪顺序学习
-
通过示例学习 RNN 架构
-
训练一个 RNN 模型
-
克服长期依赖问题,使用长短期记忆(LSTM)
-
使用 RNN 分析电影评论情感
-
重新审视使用 LSTM 进行股票价格预测
-
用 LSTM 写你自己的《战争与和平》
引入序列学习
本书中我们迄今解决的机器学习问题都是时间独立的。例如,广告点击率不依赖于用户历史的广告点击,我们之前的方法中也没有考虑这一点;在面部分类中,模型只输入当前的面部图像,而不是之前的图像。然而,生活中有很多情形是依赖时间的。例如,在金融欺诈检测中,我们不能仅仅看当前交易;我们还需要考虑之前的交易,以便基于它们的差异进行建模。另一个例子是词性(PoS)标注,我们为一个词分配词性(动词、名词、副词等)。我们不仅要关注给定的词,还必须查看一些前面的词,有时还需要查看后面的词。
在像上述提到的时间依赖情况中,当前的输出不仅依赖于当前输入,还依赖于之前的输入;请注意,之前输入的长度是没有固定的。使用机器学习解决这类问题叫做序列学习或序列建模。显然,时间依赖事件叫做序列。除了发生在不重叠时间间隔的事件(例如金融交易和电话通话)之外,文本、语音和视频也是顺序数据。
你可能会想,为什么我们不能通过直接输入整个序列来常规建模顺序数据。这可能会带来很大的限制,因为我们必须固定输入大小。一个问题是,如果一个重要事件位于固定窗口之外,我们将丧失信息。但是我们能否使用一个非常大的时间窗口呢?请注意,特征空间会随着窗口大小的增加而增加。如果我们希望覆盖某一时间窗口中的足够事件,特征空间将变得过于庞大。因此,过拟合可能是另一个问题。
我希望你现在明白了为什么我们需要用不同的方式建模顺序数据。在接下来的章节中,我们将讨论现代序列学习中使用的建模技术之一:RNN。
通过示例学习 RNN 架构
正如你所想,RNN 的突出特点在于其递归机制。我们将在下一节开始详细解释这一点,之后会讨论不同类型的 RNN 以及一些典型的应用。
递归机制
回想一下,在前馈网络(如普通神经网络和 CNN)中,数据是单向流动的,从输入层到输出层。而在 RNN 中,递归架构允许数据回流至输入层。这意味着数据不局限于单向传播。具体来说,在 RNN 的一个隐藏层中,前一个时间点的输出将成为当前时间点输入的一部分。下图展示了 RNN 中数据的一般流动方式:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_01.png
图 12.1:RNN 的一般形式
这种递归结构使得 RNN 能够很好地处理顺序数据,包括时间序列(如每日温度、每日产品销量和临床脑电图记录)以及具有顺序的通用连续数据(如句子中的单词和 DNA 序列)。以金融欺诈检测器为例,前一交易的输出特征会作为当前交易的训练输入。最终,对一笔交易的预测依赖于所有先前的交易。接下来,我将通过数学和可视化的方式解释递归机制。
假设我们有一些输入 x[t]。这里,t 代表时间步或顺序位置。在前馈神经网络中,我们通常假设不同 t 的输入是相互独立的。我们将时间步 t 时隐藏层的输出表示为 h[t] = f(x[t]),其中 f 是隐藏层的抽象函数。
这在下图中有所体现:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_02.png
图 12.2:前馈神经网络的一般形式
相反,RNN 中的反馈循环将前一状态的信息传递给当前状态。在某一时间步 t 的 RNN 隐藏层输出可以表示为 h[t] = f(h[t][−1], x[t])。这在下图中有所体现:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_03.png
图 12.3:随时间步展开的递归层
相同的任务,f,在序列的每个元素上执行,输出 h[t] 依赖于先前计算中生成的输出 h[t][−1]。这种链式结构捕获了迄今为止计算的“记忆”。这也是 RNN 在处理序列数据时如此成功的原因。
此外,由于递归结构的存在,RNN 在处理不同组合的输入序列和/或输出序列时也具有很大的灵活性。在接下来的章节中,我们将讨论基于输入和输出的不同 RNN 分类,包括以下内容:
-
多对一
-
一对多
-
多对多(同步)
-
多对多(未同步)
我们将从多对一 RNN 开始。
多对一 RNN
最直观的 RNN 类型可能是多对一。多对一 RNN 可以拥有任意数量的时间步输入序列,但在处理完整个序列后,它只会生成一个输出。以下图表展示了多对一 RNN 的一般结构:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_04.png
图 12.4:多对一 RNN 的一般形式
在这里,f 表示一个或多个递归隐藏层,每个隐藏层接收来自前一个时间步的输出。以下是三个隐藏层堆叠的示例:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_05.png
图 12.5:三个递归层堆叠的示例
多对一 RNN 广泛应用于序列数据的分类。情感分析就是一个很好的例子,其中 RNN 会读取整个客户评论,并分配一个情感评分(正面、 中立或负面情感)。类似地,我们也可以在新闻文章的主题分类中使用这种类型的 RNN。识别歌曲的类型是另一个应用,因为模型可以读取整个音频流。我们还可以使用多对一 RNN 来判断患者是否正在发生癫痫发作,这可以通过脑电图(EEG)迹线来实现。
一对多 RNN
一对多 RNN 完全是多对一 RNN 的相反。它们仅接收一个输入(而非序列),并生成一系列输出。典型的一对多 RNN 如下图所示:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_06.png
图 12.6:一对多 RNN 的一般形式
再次说明,f 代表一个或多个递归隐藏层。
请注意,这里的“一”指的是单个时间步或非序列输入,而不是输入特征的数量。
一对多 RNN 通常用作序列生成器。例如,我们可以根据起始音符和/或风格生成一段音乐。类似地,我们可以使用一对多 RNN 和指定的起始词像专业编剧一样编写电影剧本。图像标注是另一个有趣的应用:RNN 接受图像并输出图像的描述(一个句子的词语)。
多对多(同步)RNN
第三种类型的 RNN——多对多(同步)RNN,允许输入序列中的每个元素都有一个输出。让我们看看以下多对多(同步)RNN 中数据的流动方式:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_07.png
图 12.7:多对多(同步)RNN 的一般形式
如你所见,每个输出都是基于其对应的输入和所有之前的输出计算得出的。
这种类型的 RNN 的一个常见应用场景是时间序列预测,在这里我们希望根据当前和之前观察到的数据,在每个时间步进行滚动预测。以下是一些我们可以利用多对多(同步)RNN 进行时间序列预测的例子:
-
一个商店每天的产品销售量
-
股票的每日收盘价
-
一个工厂每小时的电力消耗
它们还广泛用于解决自然语言处理(NLP)问题,包括词性标注、命名实体识别和实时语音识别。
多对多(不同步)RNN
有时,我们只希望在处理完整个输入序列之后再生成输出序列。这是多对多 RNN 的不同步版本。
请参考下图,了解一个多对多(不同步)RNN 的一般结构:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_08.png
图 12.8:多对多(不同步)RNN 的一般形式
请注意,输出序列的长度(前面图中的 Ty)可以与输入序列的长度(前面图中的 Tx)不同。这为我们提供了一些灵活性。
这种类型的 RNN 是机器翻译的首选模型。例如,在法英翻译中,模型首先读取法语的完整句子,然后生成翻译后的英语句子。多步预测是另一个常见的例子:有时,我们被要求在给定过去一个月的数据时预测未来几天的销售额。
你现在已经了解了基于模型输入和输出的四种类型的 RNN。
等等,那一对一 RNN 呢?并没有这种东西。一对一只是一个普通的前馈模型。
在本章稍后的部分,我们将应用这些类型的 RNN 来解决项目问题,包括情感分析和词语生成。现在,让我们弄清楚如何训练一个 RNN 模型。
训练 RNN 模型
为了说明我们如何优化 RNN 的权重(参数),我们首先在网络上标注权重和数据,如下所示:
-
U表示连接输入层和隐藏层的权重。
-
V表示隐藏层与输出层之间的权重。请注意,这里我们仅使用一个递归层以简化问题。
-
W表示递归层的权重;即反馈层的权重。
-
x[t]表示时间步t的输入。
-
s[t]表示时间步t的隐藏状态。
-
h[t]表示时间步t的输出。
接下来,我们展开简单的 RNN 模型,涵盖三个时间步:t - 1,t,和t + 1,如下所示:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_09.png
图 12.9:展开递归层
我们将层之间的数学关系描述如下:
-
我们让a表示隐藏层的激活函数。在 RNN 中,我们通常选择 tanh 或 ReLU 作为隐藏层的激活函数。
-
给定当前输入x[t]和之前的隐藏状态s[t][−1],我们通过s[t] = a(Ux[t] + Ws[t][−1])计算当前的隐藏状态。
随时可以再读一遍第六章,使用人工神经网络预测股票价格,以复习你对神经网络的知识。
- 类似地,我们根据以下公式计算s[t][−1]:
s[t][-2]:s[t][-1]=a(Ux[t][-1]+Ws[t][-2])
- 我们重复这个过程,直到s[1],它依赖于:
s[0]:s[1]=a(Ux[1]+Ws[0])
我们通常将s[0]设置为全零。
-
我们让g表示输出层的激活函数。如果我们想执行二分类任务,它可以是一个 sigmoid 函数;对于多分类任务,它可以是 softmax 函数;对于回归问题,它可以是一个简单的线性函数(即没有激活函数)。
-
最后,我们计算时间步t的输出:
h[t]:h[t]=g(Vs[t])
由于隐藏状态在各个时间步之间的依赖关系(即,s[t]依赖于s[t][−1],s[t][−1]依赖于s[t][−2],以此类推),递归层为网络引入了记忆,它能够捕捉并保留所有之前时间步的信息。
就像我们在传统神经网络中做的那样,我们应用反向传播算法来优化 RNN 中的所有权重:U、V和W。然而,正如你可能已经注意到的,某个时间步的输出间接地依赖于所有之前的时间步(h^t 依赖于s[t],而s[t]依赖于所有之前的时间步)。因此,我们需要计算当前时间步之外的所有先前时间步的损失。因此,权重的梯度是这样计算的。例如,如果我们想要计算时间步t = 4 的梯度,我们需要反向传播前四个时间步(t = 3,t = 2,t = 1,t = 0),并将这五个时间步的梯度加总起来。这种版本的反向传播算法被称为时间反向传播(BPTT)。
循环神经网络(RNN)的结构使其能够从输入序列的最开始捕捉信息。这提升了序列学习的预测能力。你可能会想,传统的 RNN 能否处理长序列。理论上它们是可以的,但实际上由于梯度消失问题,它们无法有效处理长序列。梯度消失意味着梯度在长时间步长下会变得极小,从而阻碍了权重的更新。我将在下一节详细解释这个问题,并介绍一种变体架构——LSTM,它有助于解决这一问题。
通过 LSTM 克服长期依赖问题
让我们从传统 RNN 的梯度消失问题开始。这个问题是怎么产生的呢?回想一下,在反向传播过程中,梯度会随着每个时间步的传递而衰减(即,s[t]=a(Ux[t]+Ws[t-1]);长输入序列中的早期元素对当前梯度的计算几乎没有贡献。这意味着传统 RNN 只能捕捉短时间窗口内的时序依赖。然而,时间步之间的远距离依赖有时是预测中的关键信号。包括 LSTM 和门控循环单元(GRU)在内的 RNN 变体正是为了解决需要学习长期依赖关系的问题。
本书将重点介绍 LSTM,因为它比 GRU 更为流行。LSTM 是十年前提出的,比 GRU 更成熟。如果你有兴趣深入了解 GRU 及其应用,可以查阅 Yuxi Hayden Liu(Packt Publishing)所著的《Hands-On Deep Learning Architectures with Python》一书。
在 LSTM 中,我们使用了一个门控机制来处理长期依赖。它的“魔力”来源于一个记忆单元和三个基于循环单元的门控结构。“门”(gate)这个词源自于电路中的逻辑门(en.wikipedia.org/wiki/Logic_gate
)。它本质上是一个 Sigmoid 函数,其输出值范围从 0
到 1
。0
表示“关闭”逻辑,而 1
表示“开启”逻辑。
LSTM 版本的循环单元如下图所示,在传统版本之后以便对比:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_10.png
图 12.10:传统 RNN 和 LSTM RNN 的循环单元对比
让我们从左到右详细看一下 LSTM 循环单元:
-
c[t] 是记忆单元。它从输入序列的最开始就开始记忆信息。
-
f 代表遗忘门。它决定了从之前的记忆状态 c[t][−1] 中需要遗忘多少信息,或者换句话说,需要传递多少信息。设 W^f 为遗忘门和先前隐藏状态 s[t][−1] 之间的权重,U^f 为遗忘门和当前输入 x[t] 之间的权重。
-
i 代表 输入门。它控制从当前输入中传递多少信息。W^i 和 U^i 是连接输入门与前一个隐藏状态 s[t][−1] 和当前输入 x[t] 的权重。
-
tanh 是隐藏状态的激活函数。它充当了普通 RNN 中的 a。它的输出是基于当前输入 x[t],以及相关的权重 U^c,前一个隐藏状态 s[t][−1] 和相应的权重 W^c 计算出来的。
-
o
作为 输出门。它定义了从内部记忆中提取多少信息作为整个循环单元的输出。像往常一样,W^o 和 U^o 是分别与前一个隐藏状态和当前输入相关的权重。
我们描述这些组件之间的关系如下:
- 忘记门的输出,f,在时间步 t 的计算为:
f = sigmoid(W^f * s*[t-1] + U^f * x*[t])
- 输入门的输出,i,在时间步* t *的计算为:
i = sigmoid(W^i * s*[t-1] + U^i * x*[t])
- tanh 激活函数的输出,c’,在时间步 t 的计算为:
c’ = tanh(W^c * s*[t-1] + U^c * x*[t])
- 输出门的输出,o,在时间步 t 的计算为:
o = sigmoid(W^o * s*[t-1] + U^o * x*[t])
-
记忆单元 c[t] 在时间步 t 被更新为 c[t] = f.*c[t-1] + i.**c’(其中,操作符 . 表示逐元素乘法)。同样,sigmoid 函数的输出值在 0 到 1 之间。因此,忘记门 f 和输入门 i 分别控制从先前的记忆 c[t][−1] 和当前记忆输入 c’ 中携带多少信息。
-
最后,我们通过 s[t] = o.*c[t] 更新隐藏状态 s[t],其中 o 是输出门,控制更新后的记忆单元 c[t] 被用于整个单元的输出的程度。
最佳实践
LSTM 通常被认为是实际应用中 RNN 模型的默认选择,因为它能够有效地捕捉序列数据中的长期依赖关系,同时缓解梯度消失问题。然而,GRU 也常用于特定任务和数据集特征的情况下。LSTM 和 GRU 的选择取决于以下因素:
-
模型复杂度:LSTM 通常比 GRU 有更多的参数,因为它们有额外的门控机制。如果你有有限的计算资源或处理较小的数据集,GRU 由于其更简单的架构可能更适合。
-
训练速度:GRU 通常比 LSTM 训练速度更快。如果训练时间是一个考虑因素,GRU 可能是更好的选择。
-
性能:LSTM 通常在需要建模序列数据中长期依赖关系的任务中表现更好。如果你的任务涉及捕捉复杂的时间模式并且担心过拟合,那么 LSTM 可能更合适。
一如既往,我们应用BPTT算法训练 LSTM RNN 中的所有权重,包括与三个门和 tanh 激活函数相关的四组权重,分别是U和W。通过学习这些权重,LSTM 网络高效地显式建模了长期依赖关系。因此,LSTM 是实际应用中首选的 RNN 模型。
接下来,您将学习如何使用 LSTM RNN 解决实际问题。我们将从对电影评论情感进行分类开始。
使用 RNN 分析电影评论情感
现在,进入我们的第一个 RNN 项目:电影评论情感分析。我们将以 IMDb(www.imdb.com/
)电影评论数据集(ai.stanford.edu/~amaas/data/sentiment/
)为例。该数据集包含 25,000 条用于训练的高人气电影评论和另外 25,000 条用于测试的评论。每条评论都标记为 1(正面)或 0(负面)。我们将通过以下三个部分构建基于 RNN 的电影情感分类器:分析与预处理电影评论数据,开发简单的 LSTM 网络,以及通过多个 LSTM 层提升性能。
数据分析与预处理
我们将从数据分析和预处理开始,具体步骤如下:
-
PyTorch 的
torchtext
内置了 IMDb 数据集,因此我们首先加载该数据集:>>> from torchtext.datasets import IMDB >>> train_dataset = list(IMDB(split='train')) >>> test_dataset = list(IMDB(split='test')) >>> print(len(train_dataset), len(test_dataset)) 25000 25000
我们将加载 25,000 个训练样本和 25,000 个测试样本。
如果在运行代码时遇到任何错误,请考虑安装torchtext
和portalocker
包。你可以使用以下命令通过conda
安装:
conda install -c torchtext
conda install -c conda-forge portalocker
或者,通过pip
安装:
pip install portalocker
-
现在,让我们探索训练集中的词汇:
>>> import re >>> from collections import Counter, OrderedDict >>> def tokenizer(text): text = re.sub('<[^>]*>', '', text) emoticons = re.findall('(?::|;|=)(?:-)?(?:\)|\(|D|P)', text.lower()) text = re.sub('[\W]+', ' ', text.lower()) +\ ' '.join(emoticons).replace('-', '') tokenized = text.split() return tokenized >>> >>> token_counts = Counter() >>> train_labels = [] >>> for label, line in train_dataset: train_labels.append(label) tokens = tokenizer(line) token_counts.update(tokens) >>> print('Vocab-size:', len(token_counts)) Vocab-size: 75977 >>> print(Counter(train_labels)) Counter({1: 12500, 2: 12500})
在这里,我们定义一个函数,从给定的文档(在本例中为电影评论)中提取令牌(单词)。该函数首先移除类似 HTML 的标签,然后提取并标准化表情符号,去除非字母数字字符,并将文本分割成单词列表以供进一步处理。我们将令牌及其出现次数存储在Counter
对象token_counts
中。
如上所示,训练集包含大约 76,000 个独特的词汇,并且正负样本数量完全平衡,正样本标记为“2
”,负样本标记为“1
”。
-
我们将词汇令牌输入到嵌入层
nn.Embedding
中。嵌入层需要整数输入,因为它专门设计用于处理离散的类别数据(如单词索引),并将其转化为神经网络可以处理并学习的连续表示。因此,我们需要首先将每个令牌编码为唯一的整数,如下所示:>>> from torchtext.vocab import vocab >>> sorted_by_freq_tuples = sorted(token_counts.items(), key=lambda x: x[1], reverse=True) >>> ordered_dict = OrderedDict(sorted_by_freq_tuples) >>> vocab_mapping = vocab(ordered_dict)
我们使用 PyTorch 中的vocab
模块,根据语料库中单词的频率创建词汇表(令牌映射)。但这个词汇表还不完整,接下来两个步骤我们将说明原因。
-
在检查训练集中文档的长度时,你会注意到它们的长度从 10 个词到 2498 个词不等。通常的做法是对序列进行填充,以确保批处理时长度一致。因此,我们将特殊符号
"<pad>"
(表示填充)插入词汇映射,位置为索引0
,作为占位符:>>> vocab_mapping.insert_token("<pad>", 0)
-
我们还需要在推理过程中处理未见过的单词。与之前的步骤类似,我们将特殊符号
"<unk>"
(代表“未知”)插入到词汇映射中,位置为索引1
。该符号表示词汇表外的单词或在训练数据中找不到的单词:>>> vocab_mapping.insert_token("<unk>", 1) >>> vocab_mapping.set_default_index(1)
我们还将默认的词汇映射设置为1
。这意味着"<unk>"
(索引 1)被用作未见过的或词汇表外单词的默认索引。
让我们来看一下以下示例,展示给定单词的映射,包括一个未见过的单词:
>>> print([vocab_mapping[token] for token in ['this', 'is', 'an', 'example']])
[11, 7, 35, 462]
>>> print([vocab_mapping[token] for token in ['this', 'is', 'example2']])
[11, 7, 1]
到目前为止,我们已经完成了完整的词汇映射。
最佳实践
在 RNN 中使用特殊符号如<pad>
和<unk>
是处理变长序列和词汇表外单词的常见做法。以下是它们使用的一些最佳实践:
-
使用
<pad>
符号将序列填充为固定长度。这样可以确保所有输入序列具有相同的长度,这是神经网络中高效批处理所必需的。将填充添加到序列的末尾,而不是开头,以保持输入数据的顺序。在对文本数据进行分词时,为<pad>
符号分配一个唯一的整数索引,并确保它在嵌入矩阵中对应一个零向量。 -
使用
<unk>
符号表示不在模型词汇表中的词汇表外单词。在推理时,将任何不在词汇表中的单词替换为<unk>
符号,以确保模型能够处理输入。 -
在训练过程中,排除
<pad>
符号对损失的贡献,以避免扭曲学习过程。 -
监控数据集中
<unk>
符号的分布,以评估词汇表外单词的普遍性,并相应地调整词汇表的大小。
-
接下来,我们定义一个函数,定义如何将样本批次进行整理:
>>> import torch >>> import torch.nn as nn >>> device = torch.device("cuda" if torch.cuda.is_available() else "cpu") >>> text_transform = lambda x: [vocab[token] for token in tokenizer(x)] >>> def collate_batch(batch): label_list, text_list, lengths = [], [], [] for _label, _text in batch: label_list.append(1\. if _label == 2 else 0.) processed_text = [vocab_mapping[token] for token in tokenizer(_text)] text_list.append(torch.tensor(processed_text, dtype=torch.int64)) lengths.append(len(processed_text)) label_list = torch.tensor(label_list) lengths = torch.tensor(lengths) padded_text_list = nn.utils.rnn.pad_sequence( text_list, batch_first=True) return padded_text_list.to(device), label_list.to(device), lengths.to(device)
除了像以前那样生成输入和标签输出外,我们还会生成给定批次中每个样本的长度信息。注意,在这里我们将原始标签中的正标签从 2 转换为 1,以进行标签标准化和适配二元分类的损失函数。长度信息用于高效处理变长序列。取一个包含四个样本的小批次,并检查处理后的批次:
>>> from torch.utils.data import DataLoader
>>> torch.manual_seed(0)
>>> dataloader = DataLoader(train_dataset, batch_size=4, shuffle=True,
collate_fn=collate_batch)
>>> text_batch, label_batch, length_batch = next(iter(dataloader))
>>> print(text_batch)
tensor([[ 46, 8, 287, 21, 16, 2, 76, 3987, 3, 226, 10, 381, 2, 461, 14, 65, 9, 1208, 17, 8, 13, 856, 2, 156, 70, 398, 50, 32, 2338, 67, 103, 6, 110, 19, 9, 2, 130, 2, 153, 12, 14, 65, 1002, 14, 4, 1143, 226, 6, 1061, 31, 2, 1317, 293, 10, 61, 542, 1459, 24, 6, 105,
...
...
...
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], device='cuda:0')
>>> print(label_batch)
>>> tensor([0., 1., 1., 0.], device='cuda:0')
>>> print(length_batch)
tensor([106, 76, 247, 158], device='cuda:0')
>>> print(text_batch.shape)
torch.Size([4, 247])
你可以看到,处理过的文本序列已标准化为 247 个标记的长度,第一、第二和第四个样本使用 0 进行填充。
-
最后,我们对训练集和测试集进行批处理:
>>> batch_size = 32 >>> train_dl = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, collate_fn=collate_batch) >>> test_dl = DataLoader(test_dataset, batch_size=batch_size, shuffle=False, collate_fn=collate_batch)
生成的数据加载器已准备好用于情感预测。
让我们继续构建 LSTM 网络。
构建一个简单的 LSTM 网络
现在,训练和测试数据加载器已经准备好,我们可以构建第一个 RNN 模型,该模型包含一个将输入单词标记编码的嵌入层,一个 LSTM 层,随后是一个全连接层:
-
首先,我们定义网络超参数,包括输入维度和嵌入层的嵌入维度:
>>> vocab_size = len(vocab_mapping) >>> embed_dim = 32
我们还定义了 LSTM 层和全连接层中的隐藏节点数量:
>>> rnn_hidden_dim = 50
>>> fc_hidden_dim = 32
-
接下来,我们构建我们的 RNN 模型类:
>>> class RNN(nn.Module): def __init__(self, vocab_size, embed_dim, rnn_hidden_dim, fc_hidden_dim): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.rnn = nn.LSTM(embed_dim, rnn_hidden_dim, batch_first=True) self.fc1 = nn.Linear(rnn_hidden_dim, fc_hidden_dim) self.relu = nn.ReLU() self.fc2 = nn.Linear(fc_hidden_dim, 1) self.sigmoid = nn.Sigmoid() def forward(self, text, lengths): out = self.embedding(text) out = nn.utils.rnn.pack_padded_sequence( out, lengths.cpu().numpy(), enforce_sorted=False, batch_first=True) out, (hidden, cell) = self.rnn(out) out = hidden[-1, :, :] out = self.fc1(out) out = self.relu(out) out = self.fc2(out) out = self.sigmoid(out) return out
nn.Embedding
层用于将输入的单词索引转换为密集的词嵌入。padding_idx
参数设置为 0,表示在嵌入过程中应忽略填充标记。
递归层nn.LSTM
接受嵌入的输入序列并按顺序处理。batch_first=True
表示输入的第一维是批次大小。
全连接隐藏层fc1
位于 LSTM 层之后,对全连接层的输出应用 ReLU 激活函数。
最后一层只有一个输出,因为该模型用于二分类(情感分析)。
在前向传递方法中,pack_padded_sequence
用于打包和填充序列,以便在 LSTM 层中高效处理。打包后的序列通过 LSTM 层处理,并提取最终的隐藏状态(hidden[-1, :, :]
)。
-
接着,我们创建一个 LSTM 模型的实例,使用我们之前定义的特定超参数。我们还确保将模型放置在指定的计算设备上(如果有 GPU,则使用 GPU)进行训练和推理:
>>> model = RNN(vocab_size, embed_dim, rnn_hidden_dim, fc_hidden_dim) >>> model = model.to(device)
-
至于损失函数,我们使用
nn.BCELoss()
,因为这是一个二分类问题。我们还设置了相应的优化器,并尝试使用学习率为0.003
,如下所示:>>> loss_fn = nn.BCELoss() >>> optimizer = torch.optim.Adam(model.parameters(), lr=0.003)
-
现在,我们定义一个训练函数,负责训练模型进行一次迭代:
>>> def train(model, dataloader, optimizer): model.train() total_acc, total_loss = 0, 0 for text_batch, label_batch, length_batch in dataloader: optimizer.zero_grad() pred = model(text_batch, length_batch)[:, 0] loss = loss_fn(pred, label_batch) loss.backward() optimizer.step() total_acc += ((pred>=0.5).float() == label_batch) .float().sum().item() total_loss += loss.item()*label_batch.size(0) total_loss /= len(dataloader.dataset) total_acc /= len(train_dl.dataset) print(f'Epoch {epoch+1} - loss: {total_loss:.4f} - accuracy: {total_acc:.4f}')
它还显示了每次迭代结束时的训练损失和准确率。
-
然后,我们训练模型 10 次迭代:
>>> torch.manual_seed(0) >>> num_epochs = 10 >>> for epoch in range(num_epochs): train(model, train_dl, optimizer) Epoch 1 - loss: 0.5899 - accuracy: 0.6707 Epoch 2 - loss: 0.4354 - accuracy: 0.8019 Epoch 3 - loss: 0.2762 - accuracy: 0.8888 Epoch 4 - loss: 0.1766 - accuracy: 0.9341 Epoch 5 - loss: 0.1215 - accuracy: 0.9563 Epoch 6 - loss: 0.0716 - accuracy: 0.9761 Epoch 7 - loss: 0.0417 - accuracy: 0.9868 Epoch 8 - loss: 0.0269 - accuracy: 0.9912 Epoch 9 - loss: 0.0183 - accuracy: 0.9943 Epoch 10 - loss: 0.0240 - accuracy: 0.9918
经过 10 次迭代后,训练准确率接近 100%。
-
最后,我们在测试集上评估性能:
>>> def evaluate(model, dataloader): model.eval() total_acc = 0 with torch.no_grad(): for text_batch, label_batch, lengths in dataloader: pred = model(text_batch, lengths)[:, 0] total_acc += ((pred>=0.5).float() == label_batch) .float().sum().item() print(f'Accuracy on test set: {100 * total_acc/len(dataloader.dataset)} %') >>> evaluate(model, test_dl) Accuracy on test set: 86.1 %
我们获得了 86%的测试准确率。
堆叠多个 LSTM 层
我们还可以堆叠两个(或更多)递归层。以下图示展示了如何堆叠两个递归层:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_11.png
图 12.11:展开两个堆叠的递归层
在 PyTorch 中,堆叠多个 RNN 层非常简单。以 LSTM 为例,只需在num_layers
参数中指定 LSTM 层的数量:
>>> nn.LSTM(embed_dim, rnn_hidden_dim, num_layers=2, batch_first=True)
在这个例子中,我们堆叠了两个 LSTM 层。可以尝试使用多层 RNN 模型,看看是否能超过之前的单层模型。
至此,我们刚刚完成了使用 RNN 进行情感分类项目的回顾。在下一个项目中,我们将重新探讨股票价格预测,并使用 RNN 解决它。
使用 LSTM 重新预测股票价格
回想一下在第六章《使用人工神经网络预测股票价格》中,我们从过去的价格和特定时间步内的表现中提取特征,然后训练标准神经网络。在本例中,我们将利用 RNN 作为顺序模型,利用五个连续时间步的特征,而不仅仅是一个。让我们通过以下步骤来查看过程:
-
最初,我们加载股票数据,创建特征和标签,然后将其拆分为训练集和测试集,模仿我们在第六章中的做法:
>>> data_raw = pd.read_csv('19900101_20230630.csv', index_col='Date') >>> data = generate_features(data_raw) >>> start_train = '1990-01-01' >>> end_train = '2022-12-31' >>> start_test = '2023-01-01' >>> end_test = '2023-06-30' >>> data_train = data.loc[start_train:end_train] >>> X_train = data_train.drop('close', axis=1).values >>> y_train = data_train['close'].values >>> data_test = data.loc[start_test:end_test] >>> X_test = data_test.drop('close', axis=1).values >>> y_test = data_test['close'].values
在这里,我们重用了在第六章中定义的特征和标签生成函数generate_features
。类似地,我们使用StandardScaler
对特征空间进行缩放,并将数据转换为FloatTensor
:
>>> from sklearn.preprocessing import StandardScaler
>>> scaler = StandardScaler()
>>> X_scaled_train = torch.FloatTensor(scaler.fit_transform(X_train))
>>> X_scaled_test = torch.FloatTensor(scaler.transform(X_test))
>>> y_train_torch = torch.FloatTensor(y_train)
>>> y_test_torch = torch.FloatTensor(y_test)
-
接下来,我们定义一个函数来创建序列:
>>> def create_sequences(data, labels, seq_length): sequences = [] for i in range(len(data) - seq_length): seq = data[i:i+seq_length] label = labels[i+seq_length-1] sequences.append((seq, label)) return sequences >>> seq_length = 5 >>> sequence_train = create_sequences(X_scaled_train, y_train_torch, seq_length) >>> sequence_test = create_sequences(X_scaled_test, y_test_torch, seq_length)
在这里,每个生成的序列由两部分组成:输入序列,包含五天连续的特征;标签,表示这五天期间最后一天的价格。我们分别为训练集和测试集生成序列。
-
随后,我们为训练序列建立一个数据加载器,为模型构建和训练做准备:
>>> batch_size = 128 >>> train_dl = DataLoader(sequence_train, batch_size=batch_size, shuffle=True)
在这个项目中,我们将批量大小设置为 128。
-
现在,我们定义一个包含两层 LSTM 的 RNN 模型,后接一个全连接层和一个回归输出层:
>>> class RNN(nn.Module): def __init__(self, input_dim, rnn_hidden_dim, fc_hidden_dim): super().__init__() self.rnn = nn.LSTM(input_dim, rnn_hidden_dim, 2, batch_first=True) self.fc1 = nn.Linear(rnn_hidden_dim, fc_hidden_dim) self.relu = nn.ReLU() self.fc2 = nn.Linear(fc_hidden_dim, 1) def forward(self, x): out, (hidden, cell) = self.rnn(x) out = hidden[-1, :, :] out = self.fc1(out) out = self.relu(out) out = self.fc2(out) return out
LSTM 层捕捉输入数据中的顺序依赖性,全连接层执行最终的回归任务。
-
接下来,我们在指定输入维度和隐层维度后初始化模型,并使用 MSE 作为损失函数:
>>> rnn_hidden_dim = 16 >>> fc_hidden_dim = 16 >>> model = RNN(X_train.shape[1], rnn_hidden_dim, fc_hidden_dim) >>> device = torch.device("cuda" if torch.cuda.is_available() else "cpu") >>> model = model.to(device) >>> loss_fn = nn.MSELoss() >>> optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
小到中等的数值(如 16)通常作为 RNN 隐层维度的起始值,以提高计算效率。所选的优化器(Adam)和学习率(0.01
)是可以调优的超参数,以获得更好的性能。
-
接下来,我们将模型训练 1000 次,步骤如下:
>>> def train(model, dataloader, optimizer): model.train() total_loss = 0 for seq, label in dataloader: optimizer.zero_grad() pred = model(seq.to(device))[:, 0] loss = loss_fn(pred, label.to(device)) loss.backward() optimizer.step() total_loss += loss.item()*label.size(0) return total_loss/len(dataloader.dataset) >>> num_epochs = 1000 >>> for epoch in range(num_epochs): >>> loss = train(model, train_dl, optimizer) >>> if epoch % 100 == 0: >>> print(f'Epoch {epoch+1} - loss: {loss:.4f}') Epoch 1 - loss: 24611083.8868 Epoch 101 - loss: 5483.5394 Epoch 201 - loss: 11613.8535 Epoch 301 - loss: 4459.1431 Epoch 401 - loss: 4646.8745 Epoch 501 - loss: 4046.1726 Epoch 601 - loss: 3583.5710 Epoch 701 - loss: 2846.1768 Epoch 801 - loss: 2417.1702 Epoch 901 - loss: 2814.3970
训练期间每 100 次迭代显示一次 MSE。
-
最后,我们将训练好的模型应用于测试集并评估性能:
>>> predictions, y = [], [] >>> for seq, label in sequence_test: with torch.no_grad(): pred = model.cpu()(seq.view(1, seq_length, X_test.shape[1]))[:, 0] predictions.append(pred) y.append(label) >>> from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score >>> print(f'R²: {r2_score(y, predictions):.3f}') R²: 0.897
我们在测试集上获得了R
²为0.9
的结果。你可能会注意到,这并未超越我们之前的标准神经网络。原因是我们的训练数据集相对较小,仅有八千个样本。RNN 通常需要更大的数据集才能表现优异。
到目前为止,我们探讨的两个 RNN 模型都遵循了多对一结构。在接下来的项目中,我们将使用多对多结构创建一个 RNN,目标是生成一部“小说”。
使用 RNN 编写自己的《战争与和平》
在这个项目中,我们将解决一个有趣的语言建模问题——文本生成。
基于 RNN 的文本生成器可以根据我们输入的文本生成任何内容。训练文本可以来自小说,如权力的游戏,莎士比亚的诗歌,或电影剧本如黑客帝国。如果模型训练良好,生成的人工文本应与原文相似(但不完全相同)。在这一部分,我们将使用 RNN 编写我们自己的战争与和平,这是俄罗斯作家列夫·托尔斯泰所著的一部小说。你也可以根据你喜欢的任何书籍训练自己的 RNN。
我们将在构建训练集之前进行数据获取和分析。之后,我们将构建并训练一个用于文本生成的 RNN 模型。
获取和分析训练数据
我建议从目前不受版权保护的书籍中下载文本数据进行训练。《古腾堡计划》(www.gutenberg.org)是一个很好的选择,它提供超过 60,000 本版权已过期的免费电子书。
原作战争与和平可以从www.gutenberg.org/ebooks/2600
下载,但请注意,需要进行一些清理工作,如去除额外的开头部分“The Project Gutenberg EBook”、目录和纯文本 UTF-8 文件的额外附录“End of the Project Gutenberg EBook of War and Peace”(www.gutenberg.org/files/2600/2600-0.txt
)。因此,我们将直接从cs.stanford.edu/people/karpathy/char-rnn/warpeace_input.txt
下载已清理的文本文件。让我们开始吧:
-
首先,我们读取文件并将文本转换为小写字母:
>>> with open('warpeace_input.txt', 'r', encoding="utf8") as fp: raw_text = fp.read() >>> raw_text = raw_text.lower()
-
接着,我们通过打印出前 200 个字符来快速查看训练文本数据:
>>> print(raw_text[:200]) "well, prince, so genoa and lucca are now just family estates of the buonapartes. but i warn you, if you don't tell me that this means war, if you still try to defend the infamies and horrors perpetr
-
接下来,我们统计唯一单词的数量:
>>> all_words = raw_text.split() >>> unique_words = list(set(all_words)) >>> print(f'Number of unique words: {len(unique_words)}') Number of unique words: 39830
然后,我们统计总字符数:
>>> n_chars = len(raw_text)
>>> print(f'Total characters: {n_chars}')
Total characters: 3196213
-
从这 300 万个字符中,我们获得唯一的字符,如下所示:
>>> chars = sorted(list(set(raw_text))) >>> vocab_size = len(chars) >>> print(f'Total vocabulary (unique characters): {vocab_size}') Total vocabulary (unique characters): 57 >>> print(chars) ['\n', ' ', '!', '"', "'", '(', ')', '*', ',', '-', '.', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '=', '?', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'à', 'ä', 'é', 'ê', '\ufeff']
原始训练文本由 57 个唯一字符和接近 40,000 个唯一单词组成。生成单词的难度远大于生成字符,因为生成单词需要一步计算 40,000 个概率,而生成字符只需要一步计算 57 个概率。因此,我们将字符视为一个 token,词汇表由 57 个字符组成。
那么,我们如何将字符输入到 RNN 模型中并生成输出字符呢?我们将在下一部分中见到。
为 RNN 文本生成器构建训练集
回顾一下,在一个同步的“多对多”RNN 中,网络接受一个序列并同时生成一个序列;模型捕捉序列中各元素之间的关系,并基于学习到的模式生成一个新的序列。对于我们的文本生成器,我们可以输入固定长度的字符序列,让它生成相同长度的序列,其中每个输出序列比输入序列向右偏移一个字符。以下示例将帮助你更好地理解这一点。
假设我们有一个原始文本样本“learning
”,并且我们希望序列长度为 5。在这种情况下,我们可以有一个输入序列“learn
”和一个输出序列“earni
”。我们可以将它们输入到网络中,如下所示:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_12.png
图 12.12:将训练集(“learn”,“earni”)输入 RNN
我们刚刚构造了一个训练样本("learn","
“earni
")。类似地,要从整个原始文本构造训练样本,首先,我们需要将原始文本拆分成固定长度的序列,X;然后,我们需要忽略原始文本的第一个字符,并将其拆分成与原序列长度相同的序列,Y。X中的一个序列是训练样本的输入,而Y中的对应序列是样本的输出。假设我们有一个原始文本样本“machine learning by example”,并且我们将序列长度设置为 5,我们将构造以下训练样本:
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/B21047_12_13.png
图 12.13:从“machine learning by example”构建的训练样本
在这里,https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/py-ml-ex-4e/img/Icon.png表示空格。请注意,剩余的子序列“le”长度不足,因此我们将其丢弃。
我们还需要对输入和输出字符进行独热编码,因为神经网络模型只接受数字数据。我们简单地将 57 个独特字符映射到从 0 到 56 的索引,如下所示:
>>> index_to_char = dict((i, c) for i, c in enumerate(chars))
>>> char_to_index = dict((c, i) for i, c in enumerate(chars))
>>> print(char_to_index)
{'\n': 0, ' ': 1, '!': 2, '"': 3, "'": 4, '(': 5, ')': 6, '*': 7, ',': 8, '-': 9, '.': 10, '/': 11, '0': 12, '1': 13, '2': 14, '3': 15, '4': 16, '5': 17, '6': 18, '7': 19, '8': 20, '9': 21, ':': 22, ';': 23, '=': 24, '?': 25, 'a': 26, 'b': 27, 'c': 28, 'd': 29, 'e': 30, 'f': 31, 'g': 32, 'h': 33, 'i': 34, 'j': 35, 'k': 36, 'l': 37, 'm': 38, 'n': 39, 'o': 40, 'p': 41, 'q': 42, 'r': 43, 's': 44, 't': 45, 'u': 46, 'v': 47, 'w': 48, 'x': 49, 'y': 50, 'z': 51, 'à': 52, 'ä': 53, 'é': 54, 'ê': 55, '\ufeff': 56}
例如,字符c
变成了一个长度为 57 的向量,其中索引 28 的位置为1
,其他索引位置都是0
;字符h
变成了一个长度为 57 的向量,其中索引 33 的位置为1
,其他索引位置都是0
。
现在字符查找字典已经准备好了,我们可以构建整个训练集,如下所示:
>>> import numpy as np
>>> text_encoded = np.array(char_to_index[ch] for ch in raw_text],
dtype=np.int32)
>>> seq_length = 40
>>> chunk_size = seq_length + 1
>>> text_chunks = np.array([text_encoded[i:i+chunk_size]
for i in range(len(text_encoded)-chunk_size+1)])
在这里,我们将序列长度设置为40
,并获得长度为41
的训练样本,其中前 40 个元素表示输入,最后 40 个元素表示目标。
接下来,我们初始化训练数据集对象和数据加载器,这将用于模型训练:
>>> import torch
>>> from torch.utils.data import Dataset
>>> class SeqDataset(Dataset):
def __init__(self, text_chunks):
self.text_chunks = text_chunks
def __len__(self):
return len(self.text_chunks)
def __getitem__(self, idx):
text_chunk = self.text_chunks[idx]
return text_chunk[:-1].long(), text_chunk[1:].long()
>>> seq_dataset = SeqDataset(torch. from_numpy (text_chunks))
>>> batch_size = 64
>>> seq_dl = DataLoader(seq_dataset, batch_size=batch_size, shuffle=True,
drop_last=True)
我们只需创建一个每批 64 个序列的数据加载器,在每个训练周期开始时打乱数据,并丢弃任何无法完整组成批次的剩余数据点。
我们终于准备好了训练集,现在是时候构建和拟合 RNN 模型了。让我们在下一节中进行操作。
构建和训练 RNN 文本生成器
我们首先构建 RNN 模型,如下所示:
>>> class RNN(nn.Module):
def __init__(self, vocab_size, embed_dim, rnn_hidden_dim):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
self.rnn_hidden_dim = rnn_hidden_dim
self.rnn = nn.LSTM(embed_dim, rnn_hidden_dim,
batch_first=True)
self.fc = nn.Linear(rnn_hidden_dim, vocab_size)
def forward(self, x, hidden, cell):
out = self.embedding(x).unsqueeze(1)
out, (hidden, cell) = self.rnn(out, (hidden, cell))
out = self.fc(out).reshape(out.size(0), -1)
return out, hidden, cell
def init_hidden(self, batch_size):
hidden = torch.zeros(1, batch_size, self.rnn_hidden_dim)
cell = torch.zeros(1, batch_size, self.rnn_hidden_dim)
return hidden, cell
这个类定义了一个序列到序列的模型,它接受标记化的输入,将标记索引转换为密集的向量表示,经过嵌入层处理后,通过 LSTM 层处理这些密集向量,并生成序列中下一个标记的 logits。
在这个类中,init_hidden
方法初始化 LSTM 的隐藏状态和细胞状态。它接受batch_size
作为参数,用于确定初始状态的批次大小。创建了两个张量:hidden
和cell
,它们都初始化为零。forward
方法接收两个额外的输入:hidden
和cell
,它们对应于我们 RNN 模型的多对多架构。
还有一点需要注意的是,这里我们使用 logits 作为模型的输出,而不是概率,因为我们将从预测的 logits 中进行采样,以生成新的字符序列。
现在,让我们按照如下方式训练刚刚定义的 RNN 模型:
-
首先,我们指定嵌入维度和 LSTM 隐藏层的大小,并初始化 RNN 模型对象:
>>> embed_dim = 256 >>> rnn_hidden_dim = 512 >>> model = RNN(vocab_size, embed_dim, rnn_hidden_dim) >>> model = model.to(device) >>> model RNN( (embedding): Embedding(57, 256) (rnn): LSTM(256, 512, batch_first=True) (fc): Linear(in_features=512, out_features=57, bias=True) )
相对较高的嵌入维度(如 256)有助于捕捉单词的更丰富的语义信息。这对于像文本生成这样的任务是有益的。然而,过高的维度会增加计算成本,并可能导致过拟合。256 维度在这些因素之间提供了一个良好的平衡。
文本生成通常要求模型学习序列中单词之间的长期依赖关系。512 的隐藏层大小能够较好地捕捉这些复杂的关系。
-
下一步是定义损失函数和优化器。在多类分类的情况下,每个目标字符都有一个单独的 logit 输出,我们使用
CrossEntropyLoss
作为适当的损失函数:>>> loss_fn = nn.CrossEntropyLoss() >>> optimizer = torch.optim.Adam(model.parameters(), lr=0.003)
-
现在,我们训练模型 10,000 个 epoch。在每个 epoch 中,我们在从数据加载器中选取的一个训练批次上训练我们的多对多 RNN,并且每 500 个 epoch 显示一次训练损失:
>>> num_epochs = 10000 >>> for epoch in range(num_epochs): hidden, cell = model.init_hidden(batch_size) seq_batch, target_batch = next(iter(seq_dl)) seq_batch = seq_batch.to(device) target_batch = target_batch.to(device) optimizer.zero_grad() loss = 0 for c in range(seq_length): pred, hidden, cell = model(seq_batch[:, c], hidden.to(device), cell.to(device)) loss += loss_fn(pred, target_batch[:, c]) loss.backward() optimizer.step() loss = loss.item()/seq_length if epoch % 500 == 0: print(f'Epoch {epoch} - loss: {loss:.4f}') Epoch 0 - loss: 4.0255 Epoch 500 - loss: 1.4560 Epoch 1000 - loss: 1.2794 ... 8500 loss: - 1.2557 Epoch 9000 - loss: 1.2014 Epoch 9500 - loss: 1.2442
对于给定序列中的每个元素,我们将前一个隐藏状态与当前输入一起馈送到递归层。
-
模型训练完成,现在是时候评估其性能了。我们可以通过提供几个起始单词来生成文本,例如:
>>> from torch.distributions.categorical import Categorical >>> def generate_text(model, starting_str, len_generated_text=500): encoded_input = torch.tensor([char_to_index[s] for s in starting_str]) encoded_input = torch.reshape(encoded_input, (1, -1)) generated_str = starting_str model.eval() hidden, cell = model.init_hidden(1) for c in range(len(starting_str)-1): _, hidden, cell = model(encoded_input[:, c].view(1), hidden, cell) last_char = encoded_input[:, -1] for _ in range(len_generated_text): logits, hidden, cell = model(last_char.view(1), hidden, cell) logits = torch.squeeze(logits, 0) last_char = Categorical(logits=logits).sample() generated_str += str(index_to_char[last_char.item()]) return generated_str >>> model.to('cpu') >>> print(generate_text(model, 'the emperor', 500)) the emperor!" said he. "finished! it's all with moscow, it's not get bald hills!" he added the civer with whom and desire to change. they really asked the imperor's field!" she said. alpaty. there happed the cause of the longle matestood itself. "the mercy tiresist between paying so impressions, and till the staff offsicilling petya, the chief dear body, returning quite dispatchma--he turned and ecstatically. "ars doing her dome." said rostov, and the general feelings of the bottom would be the pickled ha
我们生成一个 500 字符的文本,起始输入为“the emperor”。具体来说,我们首先初始化 RNN 模型的隐藏状态和细胞状态。这个步骤是生成文本所必需的。然后,在for
循环中,我们遍历起始文本中的字符,除了最后一个字符。对于输入中的每个字符,我们将其通过模型,更新隐藏状态和细胞状态。为了生成下一个字符索引,我们预测所有可能字符的 logits,并根据 logits 使用Categorical
分布进行采样。这样,我们就成功地使用了多对多类型的 RNN 来生成文本。
随意调整模型,使基于 RNN 的文本生成器能够写出更现实且有趣的*《战争与和平》*版本。
具有多对多结构的 RNN 是一种序列到序列(seq2seq)模型,它接收一个序列并输出另一个序列。一个典型的例子是机器翻译,其中一种语言的单词序列被转化为另一种语言的序列。最先进的 seq2seq 模型是Transformer模型,它由 Google Brain 开发。我们将在下一章中讨论它。
摘要
在本章中,我们完成了三个自然语言处理项目:情感分析、股票价格预测和使用 RNN 进行文本生成。我们从对递归机制和不同 RNN 结构的详细解释开始,探讨了不同输入和输出序列的形式。你还了解了 LSTM 如何改进传统的 RNN。
在下一章中,我们将重点讨论 Transformer,这一近期最先进的序列学习模型,以及生成模型。
练习
-
使用双向递归层(你可以轻松地自己学习)并将其应用到情感分析项目中。你能超越我们所取得的成果吗?提示:在 LSTM 层中将
bidirectional
参数设置为True
。 -
随意调整文本生成器中的超参数,看看你能否生成一个更现实且有趣的*《战争与和平》*版本。
-
你能否在你喜欢的任何一本书上训练一个 RNN 模型,以写出你自己的版本?
加入我们书籍的 Discord 空间
加入我们社区的 Discord 空间,与作者和其他读者进行讨论: