精通 Java 数据科学(三)
七、极限梯度提升
到目前为止,我们应该已经非常熟悉 Java 中的机器学习和数据科学:我们已经讨论了监督学习和非监督学习,还考虑了机器学习在文本数据中的应用。
在这一章中,我们继续讨论监督机器学习,并将讨论一个在许多监督任务中提供最先进性能的库:XGBoost 和极限梯度提升。我们将会看到一些熟悉的问题,比如预测一个 URL 是否在第一页排名,性能预测,以及搜索引擎的排名,但是这次我们将使用 XGBoost 来解决这个问题。
本章的大纲如下:
- 梯度增压机和 XGBoost
- 安装 XGBoost
- 分类的 XGBoost
- XGBoost 用于回归
- XGBoost 用于学习排名
在本章结束时,您将学习如何从源代码构建 XGBoost,并使用它来解决数据科学问题。
梯度增压机和 XGBoost
梯度提升机 ( GBM )是一种集合算法。GBM 背后的主要思想是采用一些基本模型,然后将这个模型一遍又一遍地与数据相适应,从而逐步提高性能。它与随机森林模型不同,因为 GBM 试图在每一步改进结果,而随机森林建立多个独立的模型并取其平均值。
GBM 背后的主要思想可以用一个线性回归的例子来很好地说明。要对数据进行几个线性回归,我们可以执行以下操作:
- 将基础模型与原始数据进行拟合。
- 取目标值和第一个模型的预测值之间的差(我们称之为步骤 1 的残差),并用它来训练第二个模型。
- 取步骤 1 的残差和步骤 2 的预测之间的差(这是步骤 2 的残差)并拟合第三个模型。
- 继续,直到你训练出 N 个模型。
- 对于预测,将所有单个模型的预测相加。
因此,正如你所看到的,在算法的每一步,模型都试图改善前一步的结果,到最后,它会将所有模型合并到最终的模型中。
本质上,任何模型都可以作为基础模型,不仅仅是线性回归。例如,它可以是逻辑回归或决策树。通常,基于树的模型是非常好的,并且在各种问题上表现出优异的性能。当我们在 GBM 中使用树时,整个模型通常被称为梯度增强树,根据树的类型,它可以是梯度增强回归树或梯度增强分类树。
极限梯度提升,简称 XGBoost ,或 XGB ,是梯度提升机器的一种实现,它提供了一些基础模型,包括决策树。基于树的 XGBoost 模型非常强大:它们不对数据集及其特性中的值的分布做任何假设,它们自然地处理丢失的值, ,并且它们非常快,可以有效地利用所有可用的 CPU。
XGBoost 可以达到优秀的性能,可以从数据中尽可能的压榨。如果你知道举办数据科学竞赛的网站www.kaggle.com/,那么你可能已经听说过 XGBoost。这是获胜者在他们的解决方案中经常使用的库。当然,它在 Kaggle 之外的表现也一样好,并帮助了许多数据科学家的日常工作。
这个库最初是用 C++编写的,但是也有其他语言的绑定,比如 R 和 Python。最近,Java 的一个包装器也被创建了,在这一章中,我们将看到如何在我们的 Java 应用程序中使用它。这个包装器库叫做 XGBoost4j,,它是通过 Java 本地接口 ( JNI )绑定实现的,所以它在底层使用 C++。但是在我们使用它之前,我们需要能够构建和安装它。现在我们来看看如何做到这一点。
安装 XGBoost
正如我们已经提到的,XGBoost 是用 C++编写的,有一个 Java 库允许通过 JNI 在 Java 中使用 XGBoost。不幸的是,在撰写本文时,XGBoost4J 在 Maven Central 上不可用,这意味着它需要在本地构建,然后发布到本地 Maven 存储库。有计划将这个库发布到中央存储库,你可以在 github.com/dmlc/xgboos… 看到进展。
即使当它发布到 Maven Central 时,知道如何构建它以获得具有最新更改和错误修复的最新版本仍然是有用的。因此,让我们看看如何构建 XGBoost 库本身,然后如何为它构建 Java 包装器。关于这一点,你可以遵循 xgboost.readthedocs.io/en/latest/b… 的官方指示,这里我们将给出一个非官方的总结。
XGBoost 主要针对 Linux 系统,所以在 Linux 上构建它是很简单的:
git clone --recursive https://github.com/dmlc/xgboost
cd xgboost
make -j4
通过执行前面的命令,我们安装了基本的 XGBoost 库,但是现在我们需要安装 XGBoost4J 绑定。为此,请执行以下步骤:
- 首先,确保设置了
JAVA_HOME环境变量,并且它指向您的 JDK - 然后,转到
jvm-packages目录 - 最后,在这里运行
mvn -DskipTests install
最后一个命令构建 XGBoost4J JNI 绑定,然后编译 Java 代码并将所有内容发布到本地 Maven 存储库。
现在,为了在我们的项目中使用 XGBoost4J,我们需要做的就是包含以下依赖项:
<dependency>
<groupId>ml.dmlc</groupId>
<artifactId>xgboost4j</artifactId>
<version>0.7</version>
</dependency>
OS X 的安装过程非常相似。然而,当涉及到 Windows 时,情况就更复杂了。
要为 Windows 构建它,我们需要首先从 sourceforge.net/projects/mi… 64 位 GCC 编译器。安装时,选择x86_64架构而不是i686很重要,因为 XGBoost 只支持 64 位平台。如果由于某种原因,安装程序不工作,我们可以直接从goo.gl/CVcb8d下载带有二进制文件的x86_64-6.2.0-release-posix-seh-rt_v5-rev1.7z档案,然后解压。
在 Windows 上构建 XGBoost 时,避免在目录名中使用空格是很重要的。因此,最好在根目录下创建一个文件夹,例如C:/soft,并从那里执行所有的安装。
接下来,我们克隆 XGBoost 并make它。这里我们假设您使用 Git Windows 控制台:
git clone --recursive https://github.com/dmlc/xgboost
PATH=/c/soft/mingw64/bin/:$PATH
alias make='mingw32-make'
cp make/mingw64.mk config.mk
make -j4
最后,我们需要构建 XGBoost4J JNI 二进制文件。你需要 JDK 的内容。但是,在 Windows 中有一个问题:默认情况下,JDK 安装到Program Files文件夹中,其中有一个空格,这将在安装过程中导致问题。一个可能的解决方案是将 JDK 复制到其他地方。
完成这些之后,我们就可以开始构建库了:
export JAVA_HOME=/c/soft/jdk1.8.0_102
make jvm
cd jvm-packages
mvn -DskipTests install
如果您的 Maven 抱怨样式并中止构建,您可以通过传递-Dcheckstyle.skip标志来禁用它:
mvn -DskipTests -Dcheckstyle.skip install
成功执行这一步后,XGBoost4J 库应该发布到本地 maven 存储库中,我们可以使用之前使用的依赖项。
要测试库是否构建正确,请尝试执行这行代码:
Class<Booster> boosterClass = Booster.class;
如果您看到代码正确终止,那么您就准备好了。然而,如果你得到类似于xgboost4j.dll: Can't find dependent libraries的消息UnsatisfiedLinkError,那么确保mingw64/bin文件夹在系统变量PATH上。
实践中的 XGBoost
在我们成功地构建并安装了这个库之后,我们可以用它来创建机器学习模型,在这一章中,我们将讨论三种情况:二进制分类、回归和学习对模型进行排序。我们还将讨论熟悉的用例:预测一个 URL 是否在第一页或搜索引擎结果中,预测计算机的性能,以及为我们自己的搜索引擎排名。
分类的 XGBoost
现在让我们最后用它来解决一个分类问题! 在第四章、监督学习-分类和回归中,我们试图预测一个 URL 是否有可能出现在搜索结果的第一页。之前,我们创建了一个特殊的对象来保存这些特性:
public class RankedPage {
private String url;
private int position;
private int page;
private int titleLength;
private int bodyContentLength;
private boolean queryInTitle;
private int numberOfHeaders;
private int numberOfLinks;
public boolean isHttps();
public boolean isComDomain();
public boolean isOrgDomain();
public boolean isNetDomain();
public int getNumberOfSlashes();
}
如您所见,有许多功能,但没有一个真正涉及到文本。如果你还记得的话,有了这些特性,我们在一个测试集上实现了大约 0.58 的 AUC。
首先,让我们试着用 XGBoost 重现这个结果。因为这是一个二元分类,我们将目标参数设置为binary:logistic,由于我们上次使用 AUC 进行评估,我们将坚持这一选择,并将eval_metric设置为auc。我们通过地图设置参数:
Map<String, Object> params = new HashMap<>();
params.put("objective", "binary:logistic");
params.put("eval_metric", "logloss");
params.put("nthread", 8);
params.put("seed", 42);
params.put("silent", 1);
// default values:
params.put("eta", 0.3);
params.put("gamma", 0);
params.put("max_depth", 6);
params.put("min_child_weight", 1);
params.put("max_delta_step", 0);
params.put("subsample", 1);
params.put("colsample_bytree", 1);
params.put("colsample_bylevel", 1);
params.put("lambda", 1);
params.put("alpha", 0);
params.put("tree_method", "approx");
这里,除了物镜、eval_metric、nthread、seed和silent之外,大多数参数都被设置为默认值。
如您所见,XGBoost 是梯度增强机器算法的一个非常可配置的实现,有许多参数我们可以更改。我们不会在这里包括所有的参数;完整列表可以参考https://github . com/dmlc/xgboost/blob/master/doc/parameter . MD的官方文档。在本章中,我们将只使用基于树的方法,所以让我们回顾一下它们的一些参数:
| 参数名称 | 范围 | 描述 |
| nthread | 1 及以上 | 这是构建树时要使用的线程数 |
| eta | 从 0 到 1 | 这是集合中每个模型的权重 |
| max_depth | 1 及以上 | 这是每棵树的最大深度 |
| min_child_weight | 1 及以上 | 这是每片叶子的最小观察值 |
| subsample | 从 0 到 1 | 这是每一步要用的观察值的一部分 |
| colsample_bytree | 从 0 到 1 | 这是每一步要使用的功能的一部分 |
| objective | | 这定义了任务(回归或分类) |
| eval_metric | | 这是任务的评估指标 |
| seed | 整数 | 这为再现性埋下了种子 |
| silent | 0 或 1 | 这里,1 在训练期间关闭调试输出 |
然后,我们读取数据并创建训练集、验证集和测试集。我们已经为此准备了特殊的函数,我们也将在这里使用:
Dataset dataset = readData();
Split split = dataset.trainTestSplit(0.2);
Dataset trainFull = split.getTrain();
Dataset test = split.getTest();
Split trainSplit = trainFull.trainTestSplit(0.2);
Dataset train = trainSplit.getTrain();
Dataset val = trainSplit.getTest();
之前,我们将标准化(或 Z 分数转换)应用于我们的数据。对于基于树的算法,包括 XGBoost,这是不需要的:这些方法对这种单调变换不敏感,所以我们可以跳过这一步。
接下来,我们需要将数据集包装成 XGBoost 的内部格式:DMatrix。让我们为此创建一个实用方法:
public static DMatrix wrapData(Dataset data) throws XGBoostError {
int nrow = data.length();
double[][] X = data.getX();
double[] y = data.getY();
List<LabeledPoint> points = new ArrayList<>();
for (int i = 0; i < nrow; i++) {
float label = (float) y[i];
float[] floatRow = asFloat(X[i]);
LabeledPoint point = LabeledPoint.fromDenseVector(label, floatRow);
points.add(point);
}
String cacheInfo = "";
return new DMatrix(points.iterator(), cacheInfo);
}
现在我们可以用它来包装数据集:
DMatrix dtrain = XgbUtils.wrapData(train);
DMatrix dval = XgbUtils.wrapData(val);
XGBoost 为我们提供了一种便捷的方式,通过所谓的监视列表来监控模型的性能。本质上,这类似于学习曲线,我们可以看到评估指标在每一步是如何发展的。如果在培训过程中,我们看到培训和评估指标的值明显不同,那么这可能表明我们可能会过度适应。同样,如果在某个步骤中,验证指标开始下降,而训练指标值持续增加,那么我们会过度拟合。
观察列表是通过映射定义的,在映射中,我们将某个名称与我们感兴趣的每个数据集相关联:
Map<String, DMatrix> watches = ImmutableMap.of("train", dtrain, "val", dval);
现在我们准备训练一个 XGBoost 模型:
int nrounds = 30;
IObjective obj = null;
IEvaluation eval = null;
Booster model = XGBoost.train(dtrain, params, nrounds, watches, obj, eval);
可以在 XGBoost 中提供定制的目标和评估函数,但是因为我们只使用标准的,所以这些参数被设置为 null。
正如我们所讨论的,可以通过观察列表来监控训练过程,这是您将在训练过程中看到的内容:在每一步,它将对所提供的数据集计算评估函数,并将值输出到控制台:
[0] train-auc:0.735058 val-auc:0.533165
[1] train-auc:0.804517 val-auc:0.576641
[2] train-auc:0.842617 val-auc:0.561298
[3] train-auc:0.860178 val-auc:0.567264
[4] train-auc:0.875294 val-auc:0.570171
[5] train-auc:0.888918 val-auc:0.575836
[6] train-auc:0.896271 val-auc:0.573969
[7] train-auc:0.904762 val-auc:0.577094
[8] train-auc:0.907462 val-auc:0.580005
[9] train-auc:0.911556 val-auc:0.580033
[10] train-auc:0.922488 val-auc:0.575021
[11] train-auc:0.929859 val-auc:0.579274
[12] train-auc:0.934084 val-auc:0.580852
[13] train-auc:0.941198 val-auc:0.577722
[14] train-auc:0.951749 val-auc:0.582231
[15] train-auc:0.952837 val-auc:0.579925
如果在训练期间,我们想要构建大量的树,那么消化从控制台输出的文本是很困难的,而可视化这些曲线通常会有所帮助。然而,在我们的例子中,我们只有 30 次迭代,所以可以对性能做出一些判断。如果我们仔细观察,我们可能会注意到,在步骤 8 中,验证分数停止增加,而训练分数仍然越来越好。我们可以由此得出的结论是,在某个点上,它开始过度拟合。为了避免这种情况,我们在进行预测时只能使用前九棵树:
boolean outputMargin = true;
int treeLimit = 9;
float[][] res = model.predict(dval, outputMargin, treeLimit);
请注意两点:
- 如果我们将
outputMargin设置为 false,那么将返回未标准化的值,而不是概率。将其设置为 true 会将逻辑转换应用于值,并确保结果看起来像概率。 - 结果是一个二维的 floats 数组,而不是一维的 doubles 数组。
让我们写一个将这些结果转换成双精度的效用函数:
public static double[] unwrapToDouble(float[][] floatResults) {
int n = floatResults.length;
double[] result = new double[n];
for (int i = 0; i < n; i++) {
result[i] = floatResults[i][0];
}
return result;
}
现在,我们可以使用之前开发的其他方法,例如,检查 AUC 的方法:
double[] predict = XgbUtils.unwrapToDouble(res);
double auc = Metrics.auc(val.getY(), predict);
System.out.printf("auc: %.4f%n", auc);
如果我们没有在 predict 中指定树的数量,那么默认情况下,它会使用所有可用的树并执行值的规范化:
float[][] res = model.predict(dval);
double[] predict = unwrapToDouble(res);
double auc = Metrics.auc(val.getY(), predict);
System.out.printf("auc: %.4f%n", auc);
在前面的章节中,我们已经为 K-Fold 交叉验证创建了一些代码。我们也可以在这里使用它:
int numFolds = 3;
List<Split> kfold = trainFull.kfold(numFolds);
double aucs = 0;
for (Split cvSplit : kfold) {
DMatrix dtrain = XgbUtils.wrapData(cvSplit.getTrain());
Dataset validation = cvSplit.getTest();
DMatrix dval = XgbUtils.wrapData(validation);
Map<String, DMatrix> watches = ImmutableMap.of("train", dtrain, "val", dval);
Booster model = XGBoost.train(dtrain, params, nrounds, watches, obj, eval);
float[][] res = model.predict(dval);
double[] predict = unwrapToDouble(res);
double auc = Metrics.auc(validation.getY(), predict);
System.out.printf("fold auc: %.4f%n", auc);
aucs = aucs + auc;
}
aucs = aucs / numFolds;
System.out.printf("cv auc: %.4f%n", aucs);
然而,XGBoost 具有执行交叉验证的内置功能:我们所需要做的就是提供DMatrix,然后它将分割数据并自动运行评估。下面是我们如何使用它:
DMatrix dtrainfull = wrapData(trainFull);
int nfold = 3;
String[] metric = {"auc"};
XGBoost.crossValidation(dtrainfull, params, nrounds, nfold, metric, obj, eval);
我们将看到以下评估日志:
[0] cv-test-auc:0.556261 cv-train-auc:0.714733
[1] cv-test-auc:0.578281 cv-train-auc:0.762113
[2] cv-test-auc:0.584887 cv-train-auc:0.792096
[3] cv-test-auc:0.592273 cv-train-auc:0.824534
[4] cv-test-auc:0.593516 cv-train-auc:0.841793
[5] cv-test-auc:0.593855 cv-train-auc:0.856439
[6] cv-test-auc:0.593967 cv-train-auc:0.875119
[7] cv-test-auc:0.588910 cv-train-auc:0.887434
[8] cv-test-auc:0.592887 cv-train-auc:0.897417
[9] cv-test-auc:0.589738 cv-train-auc:0.906296
[10] cv-test-auc:0.588782 cv-train-auc:0.915271
[11] cv-test-auc:0.586081 cv-train-auc:0.924716
[12] cv-test-auc:0.586461 cv-train-auc:0.935201
[13] cv-test-auc:0.584988 cv-train-auc:0.940725
[14] cv-test-auc:0.586363 cv-train-auc:0.945656
[15] cv-test-auc:0.585908 cv-train-auc:0.951073
在我们选择了最佳参数(本例中为树的数量)后,我们可以对整个训练数据部分重新训练模型,然后在测试中对其进行评估:
int bestNRounds = 9;
Map<String, DMatring> watches = Collections.singletonMap("dtrainfull", dtrainfull);
Booster model = XGBoost.train(dtrainfull, params, bestNRounds, watches, obj, eval);
DMatrix dtest = XgbUtils.wrapData(test);
float[][] res = model.predict(dtest);
double[] predict = XgbUtils.unwrapToDouble(res);
double auc = Metrics.auc(test.getY(), predict);
System.out.printf("final auc: %.4f%n", auc);
最后,我们可以保存模型并在以后使用它:
Path path = Paths.get("xgboost.bin");
try (OutputStream os = Files.newOutputStream(path)) {
model.saveModel(os);
}
读取保存的模型也很简单:
Path path = Paths.get("xgboost.bin");
try (InputStream is = Files.newInputStream(path)) {
Booster model = XGBoost.loadModel(is);
}
这些模型与其他 XGBoost 绑定兼容。因此,我们可以用 Python 或 R 训练一个模型,然后将其导入 Java——或者反过来。
这里,我们仅使用默认参数,这些参数通常并不理想。让我们看看如何修改它们以获得最佳性能。
参数调谐
到目前为止,我们已经讨论了使用 XGBoost 执行交叉验证的三种方法:保留数据集、手动 K 折叠和 XGBoost K 折叠。这些方法中的任何一种都可以用来选择最佳性能。
来自 XGBoost 的实现通常更适合这个任务,因为它们可以显示每一步的性能,并且一旦发现学习曲线偏离太多,可以手动停止训练过程。
如果您的数据集相对较大(例如,超过 100k 个示例),那么简单地选择一个拒绝的数据集可能是最好和最快的选择。另一方面,如果您的数据集较小,执行 K -Fold 交叉验证可能是个好主意。
一旦我们决定了验证方案,我们就可以开始调整模型了。由于 XGBoost 有很多参数,这是一个相当复杂的问题,因为尝试所有可能的组合在计算上是不可行的。然而,有一些方法可能有助于获得相当好的性能。
一般的方法是一次改变一个参数,然后使用观察列表运行训练过程。这样做时,我们会密切监视验证值,并记录最大值。最后,我们选择给出最佳验证性能的参数组合。如果两个组合提供了可比较的性能,那么我们应该选择更简单的一个(例如,深度较浅,树叶中有更多实例,等等)。
下面是一个调整参数的算法:
- 首先,为树的数量选择一个非常大的值,比如 2,000 或 3,000。当您看到验证分数停止增长或开始下降时,千万不要增长所有这些树并停止训练过程。
- 采用默认参数,一次更改一个参数。
- 如果你的数据集很小,在开始时选择一个更小的
eta可能是有意义的,例如 0.1。如果数据集足够大,那么默认值就可以了。 - 首先,我们调整
depth参数。使用默认值(6)训练模型,然后尝试使用小值(3)和大值(10)。根据哪个表现更好,往合适的方向走。 - 一旦确定了树的深度,尝试改变
subsample参数。首先,尝试默认值(1),然后尝试将其减少到 0.8、0.6 和 0.4,然后将其移动到适当的方向。通常,0.6-0.7 左右的值相当好。 - 接下来,调
colsample_bytree。该方法与二次抽样的方法相同,0.6-0.7 左右的值也能很好地工作。 - 现在,我们调整
min_child_weight.你可以尝试 10,20,50 这样的值,然后移动到合适的方向。 - 最后,将
eta设置为某个小值(比如 0.01、0.05 或 0.1,这取决于数据集的大小),并查看验证性能停止增长的迭代次数。使用此数字选择最终模型的迭代次数。
有其他方法可以做到这一点。例如:
- 将
depth初始化为 10,eta初始化为 0.1,min_child_weight初始化为 5 - 如前所述,首先通过尝试更小和更大的值来找到最佳的
depth - 然后,调整
subsample参数 - 之后,调
min_child_weight - 最后一个要调整的参数是
colsample_bytree - 最后,我们将
eta设置为一个较小的数字,并观察验证性能来选择树的数量
这些都是简单的试探法,不涉及许多可用的参数,但它们仍然可以给出一个相当好的模型。您还可以调整正则化参数,如gamma、alpha和beta。例如,对于较高的depth值(大于 10),您可能希望稍微增加gamma参数,看看会发生什么。
不幸的是,这些算法都不能 100%保证找到最佳解决方案,但你应该尝试一下,找到你个人最喜欢的一个——它可能是这些算法的组合,甚至可能是完全不同的。
如果您没有大量数据,并且不想手动调整参数,那么可以尝试随机设置参数,重复多次,并基于交叉验证选择最佳模型。这被称为随机搜索参数优化:它不需要手动调整,并且在实践中通常工作良好。
开始的时候可能看起来很困难,所以不要担心。在做了几次之后,你会对这些参数如何相互依赖以及什么是调整它们的最佳方式有一些直觉。
文本特征
在前一章中,我们学习了很多可以应用于文本数据的东西,并在构建搜索引擎时使用了一些想法。让我们将这些特征纳入我们的模型,看看我们的 AUC 如何变化。
回想一下,我们之前创建了这些功能:
- 查询和文档的文本字段(如标题、正文内容以及 h1、h2 和 h3 标题)之间的 TF-IDF 空间中的余弦相似性
- 查询和所有其他文本字段之间的 LSA 相似性
我们在上一章中也使用了手套功能,但是我们在这里将跳过它们。此外,我们不会在本章中包括前面功能的实现。有关如何操作的信息,请参考第 6 章、使用文本-自然语言处理和信息检索。
一旦我们添加了特性,我们就可以对参数进行一些调整了。例如,我们最终可以使用这些参数:
Map<String, Object> params = XgbUtils.defaultParams();
params.put("eval_metric", "auc");
params.put("colsample_bytree", 0.5);
params.put("max_depth", 3);
params.put("min_child_weight", 30);
params.put("subsample", 0.7);
params.put("eta", 0.01);
这里,XgbUtils.defaultParams()是一个 helper 函数,它创建一个 map,其中的一些参数设置为它们的默认值,然后我们可以修改其中的一些参数。例如,由于性能不是很好,这里很容易过度拟合,我们生长深度为 3 的较小的树,并要求在叶节点中至少有 30 个观察值。最后,我们将学习率参数eta设置为一个小值,因为数据集不是很大。
有了这些特征,我们现在可以实现 64.4%的 AUC。这离好的性能还差得很远,但是比没有任何特性的前一个版本提高了 5%,这是一个相当大的进步。
为了避免重复,我们省略了许多代码。如果你觉得有点迷茫,随时欢迎查看章节的代码包了解详情。
特征重要性
最后,我们还可以看到哪些特性对模型的贡献最大,哪些不太重要,并根据它们的性能对我们的特性进行排序。XGBoost 实现了一个这样的特性的重要度量,称为 FScore,,它是一个特性被模型使用的次数。
要提取 FScore,我们首先需要创建一个特性映射:一个包含特性名称的文件:
List<String> featureNames = columnNames(dataframe);
String fmap = "feature_map.fmap";
try (PrintWriter printWriter = new PrintWriter(fileName)) {
for (int i = 0; i < featureNames.size(); i++) {
printWriter.print(i);
printWriter.print('t');
printWriter.print(featureNames.get(i));
printWriter.print('t');
printWriter.print("q");
printWriter.println();
}
}
在这段代码中,我们首先调用函数columnNames(此处不存在),它从 joinery 数据帧中提取列名。然后,我们创建一个文件,在每一行我们首先打印特性名称,然后是一个字母q,这意味着该特性是定量的,而不是一个i指标。
然后,我们调用一个名为getFeatureScore的方法,该方法获取特征映射文件并返回特征在映射中的重要性。得到它后,我们可以根据它们的值对地图条目进行排序,这将产生一个按重要性排序的要素列表:
Map<String, Integer> scores = model.getFeatureScore(fmap);
Comparator<Map.Entry<String, Integer>> byValue = Map.Entry.comparingByValue();
scores.entrySet().stream().sorted(byValue.reversed()).forEach(System.out::println);
对于具有文本特征的分类模型,它将产生以下输出:
numberOfLinks=17
queryBodyLsi=15
queryTitleLsi=14
bodyContentLength=13
numberOfHeaders=10
queryBodySimilarity=10
urlLength=7
queryTitleSimilarity=6
https=3
domainOrg=1
numberOfSlashes=1
我们看到这些新特性对模型非常重要。我们也看到像domainOrg或numberOfSlashes这样的特性很少被使用,我们包含的许多特性甚至不在这个列表中。这意味着我们可以安全地从我们的模型中排除这些特征,并在没有它们的情况下重新训练模型。
FScore 不是基于树的方法唯一可用的特性重要性度量,但是 XGBoost 库只提供这个分数。有一些外部库,比如 XGBFI(github.com/Far0n/xgbfi),它们可以使用模型转储来计算增益、加权 FScore 等指标,而且这些分数通常会提供更多信息。
XGBoost 不仅在分类方面很好,在回归方面也很出色。接下来,我们将看到如何使用 XGBoost。
XGBoost 用于回归
梯度提升是一个非常通用的模型:它可以处理分类和回归任务。要使用它来解决回归问题,我们需要做的就是改变目标和评估标准。
对于二进制分类,我们使用了binary:logistic目标,但是对于回归,我们只是将其更改为reg:linear。谈到评估,有以下内置评估指标:
- 均方根误差(设置
eval_metric至rmse - 平均绝对偏差(设置
eval_metric至mae
除了这些变化之外,基于树的模型的其他参数是完全相同的!我们可以遵循相同的方法来调整参数,只是现在我们将监视一个不同的指标。
在第 4 章、监督学习-分类和回归中,我们使用了矩阵乘法性能数据来说明回归问题。让我们再次使用同一个数据集,这次使用 XGBoost 来构建模型。
为了加快速度,我们可以从第五章、无监督学习-聚类和降维中获取缩减的数据集。然而,在第 6 章、处理文本自然语言处理和信息检索中,我们为 SVD 创建了一个特殊的类:TruncatedSVD。所以,让我们用它来降低这个数据集的维数:
Dataset dataset = ... // read the data
StandardizationPreprocessor preprocessor = StandardizationPreprocessor.train(dataset);
dataset = preprocessor.transform(dataset);
Split trainTestSplit = dataset.shuffleSplit(0.3);
Dataset allTrain = trainTestSplit.getTrain();
Split split = allTrain.trainTestSplit(0.3);
Dataset train = split.getTrain();
Dataset val = split.getTest();
TruncatedSVD svd = new TruncatedSVD(100, false)
svd.fit(train);
train = dimred(train, svd);
val = dimred(val, svd);
你应该还记得第 5 章、无监督学习-聚类和降维中的内容,如果我们要通过 SVD 用 PCA 降低数据集的维度,我们需要在此之前标准化数据,下面是我们读取数据后发生的情况。我们进行通常的训练-验证-测试分离,并减少所有数据集的维度。dimred函数只是包装从 SVD 调用transform方法,然后将结果放回一个Dataset类。
现在,让我们使用 XGBoost:
DMatrix dtrain = XgbUtils.wrapData(train);
DMatrix dval = XgbUtils.wrapData(val);
Map<String, DMatrix> watches = ImmutableMap.of("train", dtrain, "val", dval);
IObjective obj = null;
IEvaluation eval = null;
Map<String, Object> params = XgbUtils.defaultParams();
params.put("objective", "reg:linear");
params.put("eval_metric", "rmse");
int nrounds = 100;
Booster model = XGBoost.train(dtrain, params, nrounds, watches, obj, eval);
在这里,我们将数据集包装到DMatrix中,然后创建一个观察列表,最后将objective和eval_metric参数设置为合适的值。现在我们可以训练模型了。
让我们来看看监视列表的输出(为了简洁起见,我们将只显示每 10 条记录):
[0] train-rmse:21.223036 val-rmse:18.009176
[9] train-rmse:3.584128 val-rmse:5.860992
[19] train-rmse:1.430081 val-rmse:5.104758
[29] train-rmse:1.117103 val-rmse:5.004717
[39] train-rmse:0.914069 val-rmse:4.989938
[49] train-rmse:0.777749 val-rmse:4.982237
[59] train-rmse:0.667336 val-rmse:4.976982
[69] train-rmse:0.583321 val-rmse:4.967544
[79] train-rmse:0.533318 val-rmse:4.969896
[89] train-rmse:0.476646 val-rmse:4.967906
[99] train-rmse:0.422991 val-rmse:4.970358
我们可以看到,验证误差在第 50 棵树附近停止下降,然后又开始增加。因此,让我们将模型限制为 50 棵树,并将此模型应用于测试数据:
DMatrix dtrainall = XgbUtils.wrapData(allTrain);
watches = ImmutableMap.of("trainall", dtrainall);
nrounds = 50;
model = XGBoost.train(dtrainall, params, nrounds, watches, obj, eval);
然后,我们可以将该模型应用于测试数据,并查看最终性能:
Dataset test = trainTestSplit.getTest();
double[] predict = XgbUtils.predict(model, test);
double testRmse = rmse(test.getY(), predict);
System.out.printf("test rmse: %.4f%n", testRmse);
这里,XgbUtils.predict将一个数据集转换成DMatrix,然后调用 predict 方法,最后将 floats 数组转换成 doubles。执行代码后,我们将看到以下内容:
test rmse: 4.2573
回想一下,以前它大约是 15,所以使用 XGBoost 比使用线性回归好三倍多!
注意,在原始数据集中有分类变量,当我们使用 One-Hot-Encoding(通过来自 joinery 数据框的toModelMatrix方法)时,得到的矩阵是稀疏的。此外,我们然后用 PCA 压缩这个数据。但是,XGBoost 也可以处理稀疏数据,所以我们用这个例子来说明如何做到这一点。
在第 5 章、无监督学习——聚类和维度缩减中,我们创建了一个用于执行 One-Hot-Encoding 的类:我们使用它将分类变量从 Smile 转换为SparseDataset类的对象。现在我们可以使用这个方法来创建这样的SparseDataset,然后从它为 XGBoost 构造一个DMatrix对象。
因此,让我们创建一个将SparseDataset转换成DMatrix的方法:
public static DMatrix wrapData(SparseDataset data) {
int nrow = data.size();
List<LabeledPoint> points = new ArrayList<>();
for (int i = 0; i < nrow; i++) {
Datum<SparseArray> datum = data.get(i);
float label = (float) datum.y;
SparseArray array = datum.x;
int size = array.size();
int[] indices = new int[size];
float[] values = new float[size];
int idx = 0;
for (Entry e : array) {
indices[idx] = e.i;
values[idx] = (float) e.x;
idx++;
}
LabeledPoint point =
LabeledPoint.fromSparseVector(label, indices, values);
points.add(point);
}
String cacheInfo = "";
return new DMatrix(points.iterator(), cacheInfo);
}
这里,代码与我们用于密集矩阵的代码非常相似,但是现在我们调用fromSparseVector工厂方法而不是fromDenseVector。为了使用它,我们将SparseDataset的每一行转换成一个索引数组和值数组,然后用它们创建一个LabeledPoint实例,我们用它来创建一个DMatrix实例。
转换之后,我们在其上运行 XGBoost 模型:
SparseDataset sparse = readData();
DMatrix dfull = XgbUtils.wrapData(sparse);
Map<String, Object> params = XgbUtils.defaultParams();
params.put("objective", "reg:linear");
params.put("eval_metric", "rmse");
int nrounds = 100;
int nfold = 3;
String[] metric = {"rmse"};
XGBoost.crossValidation(dfull, params, nrounds, nfold, metric, null, null);
当我们运行这个时,我们看到 RMSE 达到 17.549534,之后就再也没有下降。这是意料之中的,因为我们只有一小部分功能;这些特征都是绝对的,而且并不是所有的特征都能提供很多信息。不过,这很好地说明了我们如何将 XGBoost 用于稀疏数据集。
除了分类和回归,XGBoost 还为创建排名模型提供了特殊的支持,现在我们将看看如何使用它。
XGBoost 用于学习排名
我们的搜索引擎变得非常强大。以前,我们使用 Lucene 来快速检索文档,然后使用机器学习模型来重新排序它们。通过这样做,我们解决了一个排名问题。在给出一个查询和一组文档之后,我们需要对所有文档进行排序,使得与查询最相关的文档具有最高的等级。
以前,我们将这个问题作为分类来处理:我们建立了一个二元分类模型来分离相关和不相关的文档,并使用文档相关的概率来进行排序。这种方法在实践中相当有效,但是有一个限制:它一次只考虑一个元素,并且将其他文档完全隔离。换句话说,当决定一个文档是否相关时,我们只看这个特定文档的特征,而不看其他文档的特征。
相反,我们可以做的是查看文档相对于彼此的位置。然后,对于每个查询,我们可以形成一个文档组,我们考虑这个特定的查询,并优化所有这些组内的排名。
LambdaMART 是运用这一理念的一款车型的名字。它查看文档对,并考虑文档对中文档的相对顺序。如果顺序是错误的(一个不相关的文档比一个相关的文档排名更高),那么模型引入一个惩罚,并且在训练期间我们想要使这个惩罚尽可能小。
LambdaMART 中的 MART 代表多元可加回归树,所以是基于树的方法。XGBoost 也实现了这个算法。要使用它,我们将目标设置为rank:pairwise,然后将评估标准设置为以下之一:
ndcg:表示归一化贴现累计收益ndcg@n:在 N 的 NDCG 是列表的第一个 N 元素,并在上面评估 NDCGmap:表示平均精度map@n:这是在每个组的第一个 N 个元素处评估的地图
出于我们的目的,详细了解这些指标做什么并不重要;现在,知道一个指标的值越高越好就足够了。然而,这两种度量之间有一个重要的区别:MAP 只能处理二进制(0/1)标签,而 NDCG 可以处理序数(0,1,2,...)标签。
当我们构建分类器时,我们只有两个标签:阳性(1)和阴性(0)。扩展标签以包括更多相关度可能是有意义的。例如,我们可以按以下方式分配标签:
- 首先,3 个 URL 的相关性为 3
- 第一页上的其他 URL 的相关性为 2
- 第二页和第三页上剩余的相关 URL 的相关性为 1
- 所有不相关的文档都标有 0
正如我们已经提到的,NDCG 可以处理这样的序数标签,所以我们将使用它进行评估。为了实现这个相关性赋值,我们可以使用之前使用的RankedPage类,并创建以下方法:
private static int relevanceLabel(RankedPage page) {
if (page.getPage() == 0) {
if (page.getPosition() < 3) {
return 3;
} else {
return 2;
}
}
return 1;
}
我们可以对一个查询中的所有文档使用这种方法,而对所有其他文档,我们只需指定相关性为 0。除了这个方法之外,用于创建和提取特征的其余代码保持不变,因此为了简洁起见,我们将省略这些代码。
一旦数据准备好了,我们就将Dataset包装到DMatrix中。当这样做时,我们需要指定组,在每个组中我们将优化排名。在我们的例子中,我们通过查询对数据进行分组。
XGBoost 希望属于同一个组的对象顺序连续,所以它需要一个组大小的数组。例如,假设我们的数据集中有 12 个对象:4 个来自组 1,3 个来自组 2,5 个来自组 3:
然后,size 数组应该包含这些组的大小:[4, 3, 5]。
这里,qid是查询的 ID:一个整数,我们将它与每个查询关联起来:
让我们首先创建一个用于计算数组大小的效用函数:
private static int[] groups(List<Integer> queryIds) {
Multiset<Integer> groupSizes = LinkedHashMultiset.create(queryIds);
return groupSizes.entrySet().stream().mapToInt(e -> e.getCount()).toArray();
}
这个方法接受一个查询 ID 列表,然后计算每个 ID 出现的次数。为此,我们使用了来自番石榴的 multiset。multiset 的这种特殊实现记住了元素插入的顺序,因此当取回计数时,顺序被保留。
现在,我们可以为两个数据集指定组大小:
DMatrix dtrain = XgbUtils.wrapData(trainDataset);
int[] trainGroups = queryGroups(trainFeatures.col("queryId"));
dtrain.setGroup(trainGroups);
DMatrix dtest = XgbUtils.wrapData(testDataset);
int[] testGroups = queryGroups(testFeatures.col("queryId"));
dtest.setGroup(testGroups);
我们准备训练一个模型:
Map<String, DMatrix> watches = ImmutableMap.of("train", dtrain, "test", dtest);
IObjective obj = null;
IEvaluation eval = null;
Map<String, Object> params = XgbUtils.defaultParams();
params.put("objective", "rank:pairwise");
params.put("eval_metric", "ndcg@30");
int nrounds = 500;
Booster model = XGBoost.train(dtrain, params, nrounds, watches, obj, eval);
在这里,我们将目标改为rank:pairwise,因为我们对解决排名问题感兴趣。我们还将评估指标设置为ndcg@30,这意味着我们只想查看前 30 个文档的 NDCG,并不真正关心 30 个之后的文档。其原因是搜索引擎的用户很少查看搜索结果的第二页和第三页,并且他们很可能会越过第三页,因此我们只考虑搜索结果的前三页。也就是说,我们只对前 30 个文档感兴趣,所以我们只查看 30 个文档中的 NDCG。
正如我们之前所做的,我们从默认参数开始,并经历与分类或回归相同的参数调整过程。
我们可以对其进行一些调整,例如,使用以下参数:
Map<String, Object> params = XgbUtils.defaultParams();
params.put("objective", "rank:pairwise");
params.put("eval_metric", "ndcg@30");
params.put("colsample_bytree", 0.5);
params.put("max_depth", 4);
params.put("min_child_weight", 30);
params.put("subsample", 0.7);
params.put("eta", 0.02);
有了这组参数,我们看到,在大约第 220 次迭代时达到了保留数据的 0.632 的最佳 NDCG@30,因此我们不应该生长超过 220 棵树。
现在我们可以用 XGBoost 模型转储器保存模型,并在 Lucene 中使用它。为此,我们需要使用和以前一样的代码,几乎不做任何改动;我们唯一需要改变的是模型。也就是说,唯一的区别是,我们需要加载 XGBoost 模型,而不是加载随机的森林模型。之后,我们只需遵循相同的过程:用 Lucene 检索前 100 个文档,并用新的 XGBoost 模型对它们重新排序。
因此,使用 XGBoost,我们能够考虑每个查询组中文档的相对顺序,并使用这些信息进一步改进模型。
摘要
在这一章中,我们学习了极限梯度提升——梯度提升机器的一种实现。我们学习了如何安装库,然后我们申请解决各种监督学习问题:分类、回归和排序。
当数据结构化时,XGBoost 大放异彩:当有可能从我们的数据中提取好的特征并将这些特征放入表格格式时。然而,在某些情况下,数据很难结构化。比如在处理图像或者声音的时候,需要付出很大的努力来提取有用的特征。但是,我们不一定要自己进行特征提取,相反,我们可以使用神经网络模型,它可以自己学习最佳特征。
在下一章,我们将看看 deep learning 4j——一个面向 Java 的深度学习库。
八、使用 DeepLearning4J 的深度学习
在前一章中,我们介绍了极限梯度增强(XGBoost)——一个实现梯度增强机器算法的库。这个库为许多监督机器学习问题提供了最先进的性能。然而,XGBoost 只有在数据已经结构化并且有很好的手工特性时才会大放异彩。
功能工程过程通常非常复杂,需要付出大量努力,尤其是在涉及图像、声音或视频等非结构化信息时。这是深度学习算法通常优于其他算法的领域,包括 XGBoost 他们不需要手工制作的特性,并且能够自己学习数据的结构。
在这一章中,我们将研究 Java 的深度学习库——deep learning 4j。这个库允许我们轻松地指定能够处理图像等非结构化数据的复杂神经网络架构。特别是,我们将研究卷积神经网络——一种非常适合图像的特殊神经网络。
本章将涵盖以下内容:
- DeepLearning4J 背后的引擎
- 用于手写数字识别的简单神经网络
- 用于数字识别的具有卷积层的深度网络
- 一种用于对带有狗和猫的图像进行分类的模型
本章结束时,你将学习如何运行 DeepLearning4J,将其应用于图像识别问题,并使用 AWS 和 GPU 加速。
神经网络和深度学习 4J
神经网络通常是在结构化数据集上提供合理性能的良好模型,但它们不一定比其他模型更好。然而,在处理非结构化数据时,它们通常是最好的。
在本章中,我们将研究一个用于设计深度神经网络的 Java 库,名为 DeepLearning4j。但在我们这样做之前,我们首先将研究它的后端-ND4J,它完成所有的数字计算和繁重的工作。
用于 Java 的 ND4J - N 维数组
DeepLearning4j 依赖 ND4J 来执行线性代数运算,如矩阵乘法。以前,我们讨论过很多这样的库,例如,Apache Commons Math 或 Matrix Toolkit Java。为什么我们还需要另一个线性代数库?
这有两个原因。首先,这些库通常只处理向量和矩阵,但对于深度学习,我们需要张量。一个张量是向量和矩阵向多维的推广;我们可以把向量看成一维张量,把矩阵看成二维张量。对于深度学习来说,这很重要,因为我们有图像,图像是三维的;它们不仅有高度和宽度,还有多个通道。
ND4J 还有一个相当重要的原因是它的 GPU 支持;所有的运算都可以在图形处理器上执行,图形处理器被设计成并行处理大量复杂的线性代数运算,这对于加速神经网络的训练非常有帮助。
因此,在进入 DeepLearning4j 之前,让我们快速浏览一下 ND4J 的一些基础知识,即使知道深度神经网络是如何实现的细节并不重要,但它对于其他目的也是有用的。
像往常一样,我们首先需要包含对pom文件的依赖:
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-native-platform</artifactId>
<version>0.7.1</version>
</dependency>
这将根据您的平台下载 Linux、MacOS 或 Windows 的 CPU 版本。请注意,对于 Linux,您可能需要安装 OpenBLAS。这通常非常容易,例如,对于 Ubuntu Linux,您可以通过执行以下命令来安装它:
sudo apt-get install libopenblas-dev
在将这个库包含到pom文件并安装了依赖项之后,我们就可以开始使用它了。
ND4J 的接口很大程度上受 NumPy 的启发,NumPy 是 Python 的一个数值库。如果你已经知道 NumPy,你会很快认出 ND4J 中的熟悉之处。
让我们从创建 ND4J 阵列开始。假设,我们想要创建一个用 1(或 0)填充的5 x 10数组。这很简单,为此,我们可以使用Nd4j类中的1和0实用程序方法:
INDArray ones = Nd4j.ones(5, 10);
INDArray zeros = Nd4j.zeros(5, 10);
如果我们已经有了一个 doubles 数组,那么将它们包装成Nd4j就很容易了:
Random rnd = new Random(10);
double[] doubles = rnd.doubles(100).toArray();
INDArray arr1d = Nd4j.create(doubles);
创建数组时,我们可以指定结果形状。假设我们想把这个有100个元素的数组放到一个10 x 10矩阵中。我们需要做的就是在创建数组时指定形状:
INDArray arr2d = Nd4j.create(doubles, new int[] { 10, 10 });
或者,我们可以在创建数组后对其进行整形:
INDArray reshaped = arr1d.reshape(10, 10);
任何维度的任何数组都可以用reshape方法重新整形为一维数组:
INDArray reshaped1d = reshaped.reshape(1, -1);
注意,我们这里用的是-1;这样我们要求 ND4J 自动推断元素的正确数量。
如果我们有一个双精度的二维 Java 数组,那么有一个特殊的语法将它们包装到 ND4J 中:
double[][] doubles = new double[3][];
doubles[0] = rnd.doubles(5).toArray();
doubles[1] = rnd.doubles(5).toArray();
doubles[2] = rnd.doubles(5).toArray();
INDArray arr2d = Nd4j.create(doubles);
同样,我们可以从 doubles 创建一个三维 ND4J 数组:
double[] doubles = rnd.doubles(3 * 5 * 5).toArray();
INDArray arr3d = Nd4j.create(doubles, new int[] { 3, 5, 5 });
到目前为止,我们使用 Java 的Random类来生成随机数,但是我们可以使用 ND4J 的方法:
int seed = 0;
INDArray rand = Nd4j.rand(new int[] { 5, 5 }, seed);
此外,我们还可以指定一个分布,从中抽取值:
double mean = 0.5;
double std = 0.2;
INDArray rand = Nd4j.rand(new int[] { 3, 5, 5 }, new NormalDistribution(mean, std));
正如我们前面提到的,三维张量对于表示图像很有用。通常,一个图像是一个三维数组,其中维数是channels * height * width的个数,值的范围通常是从 0 到 255。
让我们用三个通道生成一个类似图像的大小为2 * 5的数组:
double[] picArray = rnd.doubles(3 * 2 * 5).map(d -> Math.round(d * 255)).toArray();
INDArray pic = Nd4j.create(picArray).reshape(3, 2, 5);
如果我们打印这个数组,我们将看到如下所示的内容:
[[[51.00, 230.00, 225.00, 146.00, 244.00],
[64.00, 147.00, 25.00, 12.00, 230.00]],
[[145.00, 160.00, 57.00, 202.00, 143.00],
[170.00, 91.00, 181.00, 94.00, 92.00]],
[[193.00, 43.00, 248.00, 211.00, 27.00],
[68.00, 139.00, 115.00, 44.00, 97.00]]]
这里,输出首先按通道分组,内部我们分别有每个通道的像素值信息。要获得一个特定的频道,我们可以使用get方法:
for (int i = 0; i < 3; i++) {
INDArray channel = pic.get(NDArrayIndex.point(i));
System.out.println(channel);
}
或者,如果我们对列从 2 ^第到 3 ^第的第 0 ^个通道的所有行感兴趣,我们可以使用get方法以这种方式访问数组的这个特定部分:
INDArray slice = pic.get(NDArrayIndex.point(0), NDArrayIndex.all(), NDArrayIndex.interval(2, 4));
System.out.println(slice);
以下是输出:
[[225.00, 146.00],
[25.00, 12.00]]
这个库有更多的东西,比如点积、矩阵乘法等等。这个功能与我们已经详细介绍过的类似库非常相似,所以我们在这里不再重复。
现在,让我们从神经网络开始!
深度学习中的神经网络
在学习了 ND4J 的一些基础知识后,我们现在准备开始使用 DeepLearning4j,并用它创建神经网络。
你可能已经知道,神经网络是我们将单个神经元分层堆叠的模型。在预测阶段,每个神经元获得一些输入,对其进行处理,并将结果转发给下一层。我们从接收原始数据的输入层开始,逐渐将值向前推至输出层,输出层将包含给定输入的模型预测。
具有一个隐藏层的神经网络可能如下所示:
DeepLearning4J 让我们可以轻松设计这样的网络。如果我们采用上图中的网络,并尝试用 DeepLearning4j 实现它,我们可能会得到如下结果:
DenseLayer input = new DenseLayer.Builder().nIn(n).nOut(6).build();
nnet.layer(0, input);
OutputLayer output = new OutputLayer.Builder().nIn(6).nOut(k).build();
nnet.layer(1, output);
如你所见,阅读和理解并不难。所以,让我们使用它;为此,我们首先需要指定它对pom.xml文件的依赖性:
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>deeplearning4j-core</artifactId>
<version>0.7.1</version>
</dependency>
注意,DeepLearning4j 和 ND4J 的版本必须相同。
为了便于说明,我们将使用 MNIST 数据集;该数据集包含从 0 到 9 的手写数字图像,目标是预测图像中给出的数字:
这个数据集非常有名。创建一个识别数字的模型通常可以作为神经网络和深度学习的 Hello World 。
本章从一个只有一个内层的简单网络开始。由于所有图像都是28 * 28像素,输入层应该有28 * 28个神经元(图片是灰度的,所以只有一个通道)。为了能够将图片输入到网络中,我们首先需要将展开成一个一维数组:
我们已经知道,使用 ND4J,这是非常容易做到的;我们只是调用reshape(1, -1)。然而,我们不需要这样做;DeepLearning4J 会自动处理,为我们重塑输入。
接下来,我们创建一个内层,我们可以从 1000 个神经元开始。既然有 10 个数字,那么输出层的神经元数应该等于 10。
现在,让我们在 DeepLearning4J 中实现这个网络。由于 MNIST 是一个非常受欢迎的数据集,库已经为它提供了一个方便的加载器,所以我们需要做的就是使用下面的代码:
int batchSize = 128;
int seed = 1;
DataSetIterator mnistTrain = new MnistDataSetIterator(batchSize, true, seed);
DataSetIterator mnistTest = new MnistDataSetIterator(batchSize, false, seed);
对于训练部分,有 50000 个标记的例子,有 10000 个测试的例子。为了迭代它们,我们使用 DeepLearning4j 的抽象- DataSetIterator。它在这里做的是获取整个数据集,洗牌,然后将它分成 128 张图片的批次。
我们准备批次的原因是神经网络通常使用随机梯度下降 ( SGD )进行训练,并且训练是分批进行的;我们取一批,在上面训练一个模型,更新权重,然后取下一批。取一个批次并在其上训练一个模型被称为迭代,迭代所有可用的训练批次被称为时期。
获得数据后,我们可以指定网络的训练配置:
NeuralNetConfiguration.Builder config = new NeuralNetConfiguration.Builder();
config.seed(seed);
config.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT);
config.learningRate(0.005);
config.regularization(true).l2(0.0001);
在这段代码中,我们说我们希望使用 SGD 进行训练,学习率为0.005,L2 正则化为0.0001。SGD 是一个合理的默认值,你应该坚持使用它。
学习率是最重要的训练配置参数。如果我们把它设置得太高,那么训练过程将会发散,如果它太小,在收敛之前将会花费很多时间。为了选择最佳的学习速率,我们通常为诸如 0.1、0.01、0.001,...,0.000001,看看神经网络什么时候停止发散。
我们在这里使用的另一个东西是 L2 正则化。L1 和 L2 正则化的工作方式与逻辑回归等线性模型完全相同——它们通过减小权重来避免过度拟合,而 L1 则确保了解的稀疏性。
然而,有专门针对神经网络的正则化策略——dropout 和 dropconnect,它们在每次训练迭代中使网络的随机部分静音。我们可以在配置中为整个网络指定它们:
config.dropOut(0.1);
但是更好的方法是在每一层指定它们——我们将在后面看到如何做。
一旦我们完成了训练配置,我们就可以继续指定网络的架构,也就是说,比如它的层和每层中神经元的数量。
为此我们得到了一个ListBuilder类的对象:
ListBuilder architecture = config.list();
现在,让我们添加第一层:
DenseLayer.Builder innerLayer = new DenseLayer.Builder();
innerLayer.nIn(28 * 28);
innerLayer.nOut(1000);
innerLayer.activation("tanh");
innerLayer.weightInit(WeightInit.UNIFORM);
architecture.layer(0, innerLayer.build());
正如我们之前讨论的,输入层中神经元的数量应该等于图像的大小,即 28 乘以 28。由于内层有 1000 个神经元,所以这一层的输出是 1000 个。
此外,我们在这里指定激活函数和权重初始化策略。
激活函数是应用于每个神经元输出的非线性变换。可以有几种激活功能:
| 激活 | Plot |
| 线性:无激活 | |
| 乙状结肠:
[0, 1]范围 | |
| tanh:
[-1, 1]范围 | |
| 备注:t0]范围 |
|
| leaky 指出:
[-infinity, infinity] | |
对于这个例子,我们使用了tanh,这是深度学习之前的浅层网络的默认选项。然而,对于深层网络,ReLU 激活通常应该是优选的,因为它们解决了消失梯度问题。
Vanishing gradient is a problem that occurs during the training of neural networks. For training, we calculate the gradient--the direction which we need to follow, and update the weights based on that. This problem occurs when we use sigmoid or tanh activations in deep networks--the first layers (processed last during optimization) have a very small gradient and do not get updated at all.
不过 ReLU 有时候也会有一个问题,叫做死 ReLU ,可以使用 LeakyReLU 等其他激活函数解决。
如果 ReLU 函数的输入为负,那么输出正好为零,这意味着在许多情况下神经元没有被激活。此外,在训练导数时,在这种情况下,is 为零,因此跟随梯度可能永远不会更新权重。这就是所谓的死亡问题,许多神经元从未被激活并死亡。这个问题可以使用 LeakyReLU 激活来解决,它不是总是输出负值的零,而是输出非常小的东西,所以仍然可以计算梯度。
我们在这里指定的另一件事是权重初始化。通常,在我们训练一个网络之前,我们需要初始化参数,一些初始化比另一些更好,但是,通常,这是特定于情况的,并且通常我们需要尝试几种方法,然后选择一种特定的方法。
| 权重初始化方法 | 评论 |
| WeightInit.ZERO | 在这里,所有的权重都被设置为零。不建议这样做。 |
| WeightInit.UNIFORM | 这里,权重被设置为[-a, a]范围内的统一值,其中a取决于神经元的数量。 |
| WeightInit.XAVIER | 这是有方差的高斯分布,它取决于神经元的数量。如果有疑问,请使用此初始化。 |
| WeightInit.RELU | 这是比XAVIER中方差更高的高斯分布。它有助于解决垂死的 ReLU 问题。 |
| WeightInit.DISTRIBUTION | 这允许您指定将从中对权重进行采样的任何分布。在这种情况下,分布是这样设置的:layer.setDist(new NormalDistribution(0, 0.01));。 |
| 其他人 | 还有其他权重初始化策略,参见WeightInit类的 JavaDocs。 |
UNIFORM和XAVIER方法通常是很好的起点;先试试它们,看看它们是否能产生好的结果。如果没有,那就尝试实验,选择一些其他的方法。
如果你遇到了将死的 ReLU 问题,那么最好使用WeightInit.RELU初始化方法。否则,使用WeightInit.XAVIER。
接下来,我们指定输出层:
architecture.layer(1, outputLayer.build());
对于输出层,我们需要指定loss函数——训练时我们希望用网络优化的函数。有多种选择,但最常见的如下:
LossFunction.NEGATIVELOGLIKELIHOOD,也就是LogLoss。用这个来分类。LossFunction.MSE,即均方误差。用它来回归。
你可能已经注意到,这里我们使用了一个不同的激活函数- softmax,我们以前没有涉及过这个激活。这是将sigmoid函数推广到多个类。如果我们有一个二元分类问题,并且我们只想预测一个值,属于正类的概率,那么我们使用一个sigmoid。但是如果我们的问题是多类的,或者我们为二分类问题输出两个值,那么我们需要使用 softmax。如果我们解决回归问题,那么我们使用线性激活函数。
| 输出激活 | 何时使用 |
| sigmoid | 二元分类 |
| softmax | 多类分类 |
| linear | 回归 |
现在,当我们建立了体系结构后,我们就可以从中构建网络了:
MultiLayerNetwork nn = new MultiLayerNetwork(architecture.build());
nn.init();
监控训练进度并将分数视为模型训练通常是有用的,为此我们可以使用ScoreIterationListener——它订阅模型,并在每次迭代后输出新的训练分数:
nn.setListeners(new ScoreIterationListener(1));
现在我们准备训练网络:
int numEpochs = 10;
for (int i = 0; i < numEpochs; i++) {
nn.fit(mnistTrain);
}
在这里,我们对网络进行 10 个时期的训练,也就是说,我们对整个训练数据集迭代 10 次,如果你记得的话,每个时期由许多 128 大小的批次组成。
一旦训练完成,我们就可以在测试中评估模型的性能。为此,我们创建一个特殊的类型为Evaluation的对象,然后我们迭代测试集的批次,并将模型应用于每一批次。每次我们这样做的时候,我们都会更新Evaluation对象,它跟踪整体性能。
一旦训练完成,我们就可以评估模型的性能。为此,我们创建一个类型为Evaluation的特殊对象,然后迭代验证数据集,并将模型应用于每一批。结果由Evaluation类记录,最后我们可以看到结果:
while (mnistTest.hasNext()) {
DataSet next = mnistTest.next();
INDArray output = nn.output(next.getFeatures());
eval.eval(next.getLabels(), output);
}
System.out.println(eval.stats());
如果我们运行它 10 个时期,它将产生这个:
Accuracy: 0.9
Precision: 0.8989
Recall: 0.8985
F1 Score: 0.8987
因此,性能并不令人印象深刻,为了提高性能,我们可以修改架构,例如,添加另一个内层:
DenseLayer.Builder innerLayer1 = new DenseLayer.Builder();
innerLayer1.nIn(numrow * numcol);
innerLayer1.nOut(1000);
innerLayer1.activation("tanh");
innerLayer1.dropOut(0.5);
innerLayer1.weightInit(WeightInit.UNIFORM);
architecture.layer(0, innerLayer1.build());
DenseLayer.Builder innerLayer2 = new DenseLayer.Builder();
innerLayer2.nIn(1000);
innerLayer2.nOut(2000);
innerLayer2.activation("tanh");
innerLayer2.dropOut(0.5);
innerLayer2.weightInit(WeightInit.UNIFORM);
architecture.layer(1, innerLayer2.build());
LossFunction loss = LossFunction.NEGATIVELOGLIKELIHOOD;
OutputLayer.Builder outputLayer = new OutputLayer.Builder(loss);
outputLayer.nIn(2000);
outputLayer.nOut(10);
outputLayer.activation("softmax");
outputLayer.weightInit(WeightInit.UNIFORM);
architecture.layer(2, outputLayer.build());
正如你所看到的,这里我们在第一层和输出层之间添加了一个额外的层,带有2000神经元。我们还为每个图层添加了 dropout,以实现正则化。
通过这种设置,我们可以获得稍好的精度:
Accuracy: 0.9124
Precision: 0.9116
Recall: 0.9112
F1 Score: 0.9114
当然,改善只是边缘性的,网络还远远没有调好。为了改善它,我们可以使用 ReLU 激活,内斯特罗夫的动量 0.9 左右的更新程序,以及 XAVIER 的权重初始化。这应该给出高于 95%的准确度。事实上,在来自官方 DeepLearning4j 知识库的示例中,您可以找到一个非常好的网络;寻找名为MLPMnistSingleLayerExample.java的类。
在我们的例子中,我们使用经典的神经网络;它们相当浅(也就是说,它们没有很多层),并且所有层都是完全连接的。虽然对于小规模的问题,这可能已经足够好了,但通常最好使用卷积神经网络来执行图像识别任务,这些网络考虑到了图像结构,可以实现更好的性能。
卷积神经网络
正如我们已经多次提到的,神经网络可以自己完成特征工程部分,这对于图像尤其有用。现在我们将最终看到这一点。为此,我们将使用卷积神经网络,它们是一种特殊的神经网络,使用特殊的卷积层。它们非常适合图像处理。
在通常的神经网络中,各层是完全连接的,这意味着一层的每个神经元都连接到前一层的所有神经元。对于 MNIST 的数字图像来说,这没什么大不了的,但是对于更大的图像来说,这就成问题了。想象一下,我们需要处理大小为300 x 300的图像;在这种情况下,输入层将有 90,000 个神经元。那么,如果下一层也有 9 万个神经元,那么这两层之间就会有90000 x 90000连接,这显然是很多的。
然而,在图像中,每个像素只有一小部分是重要的。因此,前面的问题可以通过只考虑每个像素的小邻域来解决,这正是卷积层所做的;在里面,他们保存了一套小尺寸的过滤器。然后,我们在图像上滑动一个窗口,并计算窗口中的内容与每个过滤器的相似性:
过滤器是这些卷积层中的神经元,它们是在训练阶段学习的,与通常的全连接情况类似。
当我们在图像上滑动窗口时,我们计算内容与过滤器的相似性,这是它们之间的点积。对于每个窗口,我们将结果写入输出。当所考虑的区域与过滤器相似时,我们说过滤器被激活。显然,如果相似,点积将倾向于产生更高的值。
由于图像通常有多个通道,我们实际上处理的是维度的体积(或 3D 张量):通道数、高度、宽度和宽度。当图像通过卷积层时,每个滤波器被依次应用,作为输出,我们有维度体积滤波器数量乘以高度乘以宽度。当我们将这样的层堆叠在彼此之上时,我们得到一系列的体积:
除了卷积层之外,另一种层类型对于卷积网络也很重要,即下采样层或汇集层。这一层的目的是降低输入的维数,通常每边降低 2 倍,所以总共降低 4 倍。通常,我们使用最大池,在缩减采样时保持最大值:
我们这样做的原因是为了减少我们网络的参数数量,这使得训练速度大大加快。
当这样的层获得体积时,它仅改变高度和宽度,而不改变过滤器的数量。通常,我们将池层放在卷积层之后,并且通常组织架构,使两个卷积层之后跟随一个池层:
然后,在某种程度上,在我们添加了足够的卷积层之后,我们切换到全连接层,这与我们在常见网络中的层类型相同。最后,我们有了输出层,就像之前一样。
让我们继续 MNIST 的例子,但这次让我们训练一个卷积神经网络来识别数字。对于这个任务,有一个著名的架构叫做 LeNet(由 Yann LeCun 研究员创建),让我们来实现它。我们将基于他们的资源库中可用的官方 DeepLearning4j 示例来给出我们的示例。
该架构如下所示:
5 x 5带 20 个滤波器的卷积层- 最大池化
5 x 5带 50 个滤波器的卷积层- 最大池化
- 具有 500 个神经元的完全连接的层
- 使用 softmax 输出图层
所以这个网络有六层。
像以前一样,首先,我们指定网络的训练配置:
NeuralNetConfiguration.Builder config = new NeuralNetConfiguration.Builder();
config.seed(seed);
config.regularization(true).l2(0.0005);
config.learningRate(0.01);
config.weightInit(WeightInit.XAVIER);
config.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT);
config.updater(Updater.NESTEROVS).momentum(0.9);
这里几乎没有什么新东西,除了Updater;我们使用内斯特罗夫的更新,动量设置为0.9。这样做的目的是加快收敛。
现在我们可以创建架构了:
ListBuilder architect = config.list();
首先,卷积层:
ConvolutionLayer cnn1 = new ConvolutionLayer.Builder(5, 5)
.name("cnn1")
.nIn(nChannels)
.stride(1, 1)
.nOut(20)
.activation("identity")
.build();
architect.layer(0, cnn1);
这里,在构建器的构造函数中,我们指定了过滤器的维数,即5 x 5。然后将nIn参数设置为输入图像的通道数,对于 MNIST 为 1,它们都是灰度图像。nOut参数指定了该图层的滤镜数量。stride 参数指定了我们在图像上滑动窗口的步骤,通常设置为1。最后,该层不使用任何激活。
架构中的下一层是池层:
SubsamplingLayer pool1 = new SubsamplingLayer.Builder(PoolingType.MAX)
.name("pool1")
.kernelSize(2, 2)
.stride(2, 2)
.build();
architect.layer(1, pool1);
当我们创建这一层时,我们首先指定我们想要缩减采样的方式,我们使用MAX,因为我们对最大池感兴趣。还有其他选项,如AVG平均值和SUM,但它们在实践中并不常用。
这一层有两个参数——kernelSize参数,它是我们在图片上滑动的窗口的大小,以及 stride 参数,它是我们在滑动窗口时采取的步骤。通常,这些值被设置为2。
然后,我们添加下一个卷积层和一个池层:
ConvolutionLayer cnn2 = new ConvolutionLayer.Builder(5, 5)
.name("cnn2")
.stride(1, 1)
.nOut(50)
.activation("identity")
.build();
architect.layer(2, cnn2);
SubsamplingLayer pool2 = new SubsamplingLayer.Builder(PoolingType.MAX)
.name("pool2")
.kernelSize(2, 2)
.stride(2, 2)
.build();
architect.layer(3, pool2);
最后,我们创建全连接层和输出层:
DenseLayer dense1 = new DenseLayer.Builder()
.name("dense1")
.activation("relu")
.nOut(500)
.build();
architect.layer(4, dense1);
OutputLayer output = new OutputLayer.Builder(LossFunction.NEGATIVELOGLIKELIHOOD)
.name("output")
.nOut(outputNum)
.activation("softmax")
.build();
architect.layer(5, output);
对于最后两层,对我们来说没有什么新的,我们不使用任何新的参数。
最后,在训练之前,我们需要告诉优化器输入是一幅图片,这是通过指定输入类型来完成的:
architect.setInputType(InputType.convolutionalFlat(height, width, nChannels));
有了这个,我们就可以开始训练了:
for (int i = 0; i < nEpochs; i++) {
model.fit(mnistTrain);
Evaluation eval = new Evaluation(outputNum);
while (mnistTest.hasNext()) {
DataSet ds = mnistTest.next();
INDArray out = model.output(ds.getFeatureMatrix(), false);
eval.eval(ds.getLabels(), out);
}
System.out.println(eval.stats());
mnistTest.reset();
}
对于这种架构,网络在一个历元之后可以达到的准确度是 97%,这明显好于我们之前的尝试。但训练它 10 个纪元后,准确率达到 99%。
猫和狗的深度学习
虽然 MNIST 是一个非常好的教育数据集,但它非常小。我们来看一个不同的图像识别问题:给定一张图片,我们想预测图片上是猫还是狗。
为此,我们将使用 kaggle 上一场比赛中的猫狗图片数据集,该数据集可以从www.kaggle.com/c/dogs-vs-c…下载。
我们先从读取数据开始。
读取数据
对于狗对猫的比赛,有两个数据集;训练,用 25000 张狗和猫的图片,各占 50%,测试。出于本章的目的,我们只需要下载训练数据集。下载完成后,在某个地方解压。
文件名如下所示:
| dog.9993.jpg
dog.9994.jpg
| cat.10000.jpg
cat.10001.jpg
|
| |
|
标签(dog或cat)被编码到文件名中。
如您所知,我们通常做的第一件事是将数据分成训练集和验证集。因为我们这里所有的都是文件的集合,所以我们只是得到所有的文件名,然后把它们分成两部分——训练和验证。
为此,我们可以使用这个简单的脚本:
File trainDir = new File(root, "train");
double valFrac = 0.2;
long seed = 1;
Iterator<File> files = FileUtils.iterateFiles(trainDir, new String[] { "jpg" }, false);
List<File> all = Lists.newArrayList(files);
Random random = new Random(seed);
Collections.shuffle(all, random);
int trainSize = (int) (all.size() * (1 - valFrac));
List<File> train = all.subList(0, trainSize);
copyTo(train, new File(root, "train_cv"));
List<File> val = all.subList(trainSize, all.size());
copyTo(val, new File(root, "val_cv"));
在代码中,我们使用 Apache Commons IO 中的FileUtils.iterateFiles方法迭代训练目录中的所有.jpg文件。然后我们把所有这些文件放到一个列表里,洗牌,把它们分成 80%和 20%的部分。
copyTo方法只是将文件复制到指定的目录中:
private static void copyTo(List<File> pics, File dir) {
for (File pic : pics) {
FileUtils.copyFileToDirectory(pic, dir);
}
}
在这里,FileUtils.copyFileToDirectory方法也来自 Apache Commons IO。
为了使用这些数据来训练网络,我们需要做很多事情。它们如下:
- 获取每张图片的路径
- 获取标签(文件名中的
dog或cat) - 调整输入的大小,使每张图片都具有相同的大小
- 对图像应用一些标准化
- 从中创建
DataSetIterator
获取每个图片的路径很容易,我们已经知道如何做,我们可以像以前一样在 Commons IO 中使用相同的方法。但是现在我们需要为每个文件获取URI,因为 DeepLearning4j 数据集迭代器期望的是文件的URI,而不是文件本身。为此,我们创建了一个助手方法:
private static List<URI> readImages(File dir) {
Iterator<File> files = FileUtils.iterateFiles(dir,
new String[] { "jpg" }, false);
List<URI> all = new ArrayList<>();
while (files.hasNext()) {
File next = files.next();
all.add(next.toURI());
}
return all;
}
从文件名中获取类名(dog或cat)是通过实现PathLabelGenerator接口来完成的:
private static class FileNamePartLabelGenerator implements PathLabelGenerator {
@Override
public Writable getLabelForPath(String path) {
File file = new File(path);
String name = file.getName();
String[] split = name.split(Pattern.quote("."));
return new Text(split[0]);
}
@Override
public Writable getLabelForPath(URI uri) {
return getLabelForPath(new File(uri).toString());
}
}
在里面,我们只是用.分割文件名,然后取结果的第一个元素。
最后,我们创建一个方法,它接受一个列表URI并创建一个DataSetIterator:
private static DataSetIterator datasetIterator(List<URI> uris)
throws IOException {
CollectionInputSplit train = new CollectionInputSplit(uris);
PathLabelGenerator labelMaker = new FileNamePartLabelGenerator();
ImageRecordReader trainRecordReader = new ImageRecordReader(HEIGHT, WIDTH, CHANNELS, labelMaker);
trainRecordReader.initialize(train);
return new RecordReaderDataSetIterator(trainRecordReader, BATCH_SIZE, 1, NUM_CLASSES);
}
该方法使用一些常量,我们用以下值对其进行初始化:
HEIGHT = 128;
WIDTH = 128;
CHANNELS = 3;
BATCH_SIZE = 30;
NUM_CLASSES = 2;
ImageRecordReader将使用HEIGHT和WIDTH参数将图像调整到指定的形式,如果是灰度,它将人为地为其创建 RGB 通道。BATCH_SIZE指定了在训练过程中我们将一次考虑多少张图像。
在线性模型中,规范化起着重要的作用,有助于模型更快地收敛。对于神经网络也是如此,所以我们需要对图像进行归一化。为此,我们可以使用一个特殊的内置类ImagePreProcessingScaler。DataSetIterator可以有一个预处理器,所以我们把这个定标器放在那里:
DataSetIterator dataSet = datasetIterator(valUris);
ImagePreProcessingScaler preprocessor = new ImagePreProcessingScaler(0, 1);
dataSet.setPreProcessor(preprocessor);
这样,数据准备工作就完成了,我们可以继续创建模型。
创建模型
对于模型的架构,我们将使用 VGG 网络的变体。这个架构取自论坛的一个公开可用的脚本(https://www . ka ggle . com/jeffd 23/dogs-vs-cats-redux-kernels-edition/catdognet-keras-conv net-starter),这里我们将把这个例子改编成 DeepLearning4j。
VGG 是在 2014 年 image net 挑战赛中获得第二名的模型,它仅使用 3 x 3 和 2 x 2 卷积滤波器。
使用现有的架构总是一个好主意,因为它可以解决很多时间——自己想出一个好的架构是一项具有挑战性的任务。
我们将使用的架构如下:
- 两层
3 x 3卷积与 32 个滤波器 - 最大池化
- 两层
3 x 3卷积与 64 个滤波器 - 最大池化
- 两层
3 x 3卷积用 128 个滤波器 - 最大池化
- 具有 512 个神经元的全连接层
- 一个有 256 个神经元的全连接层
- 激活 softmax 的输出层
对于我们的例子,我们将对所有卷积和全连接层使用 ReLU 激活。为了避免垂死的 ReLU 问题,我们将使用WeightInit.RELU权重初始化方案。在我们的实验中,不使用它,网络倾向于产生相同的结果,不管它接收什么输入。
首先,我们从配置开始:
NeuralNetConfiguration.Builder config = new NeuralNetConfiguration.Builder();
config.seed(SEED);
config.weightInit(WeightInit.RELU);
config.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT);
config.learningRate(0.001);
config.updater(Updater.RMSPROP);
config.rmsDecay(0.99);
有些参数现在应该已经很熟悉了,但是这里有两个新东西——RMSPROP更新器和rmsDecay参数。使用它们可以让我们在训练时自适应地改变学习速度。开始时,学习率较大,我们向最小值迈出较大的步伐,但当我们训练并接近最小值时,它会降低学习率,我们迈出较小的步伐。
通过尝试不同的值如 0.1、0.001 和 0.0001 并观察网络何时停止发散来选择学习率。这很容易发现,因为当发散时,训练误差变化很大,然后开始输出无穷大或NaN。
现在我们指定架构。
首先,我们创建卷积层和池层:
int l = 0;
ListBuilder network = config.list();
ConvolutionLayer cnn1 = new ConvolutionLayer.Builder(3, 3)
.name("cnn1")
.stride(1, 1)
.nIn(3).nOut(32)
.activation("relu").build();
network.layer(l++, cnn1);
ConvolutionLayer cnn2 = new ConvolutionLayer.Builder(3, 3)
.name("cnn2")
.stride(1, 1)
.nIn(32).nOut(32)
.activation("relu").build();
network.layer(l++, cnn2);
SubsamplingLayer pool1 = new SubsamplingLayer.Builder(PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.name("pool1").build();
network.layer(l++, pool1);
ConvolutionLayer cnn3 = new ConvolutionLayer.Builder(3, 3)
.name("cnn3")
.stride(1, 1)
.nIn(32).nOut(64)
.activation("relu").build();
network.layer(l++, cnn3);
ConvolutionLayer cnn4 = new ConvolutionLayer.Builder(3, 3)
.name("cnn4")
.stride(1, 1)
.nIn(64).nOut(64)
.activation("relu").build();
network.layer(l++, cnn4);
SubsamplingLayer pool2 = new SubsamplingLayer.Builder(PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.name("pool2").build();
network.layer(l++, pool2);
ConvolutionLayer cnn5 = new ConvolutionLayer.Builder(3, 3)
.name("cnn5")
.stride(1, 1)
.nIn(64).nOut(128)
.activation("relu").build();
network.layer(l++, cnn5);
ConvolutionLayer cnn6 = new ConvolutionLayer.Builder(3, 3)
.name("cnn6")
.stride(1, 1)
.nIn(128).nOut(128)
.activation("relu").build();
network.layer(l++, cnn6);
SubsamplingLayer pool3 = new SubsamplingLayer.Builder(PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.name("pool3").build();
network.layer(l++, pool3);
这里对我们来说应该没有什么新鲜的。然后我们创建完全连接的层和输出:
DenseLayer dense1 = new DenseLayer.Builder()
.name("ffn1")
.nOut(512).build();
network.layer(l++, dense1);
DenseLayer dense2 = new DenseLayer.Builder()
.name("ffn2")
.nOut(256).build();
network.layer(l++, dense2);
OutputLayer output = new OutputLayer.Builder(LossFunction.NEGATIVELOGLIKELIHOOD)
.name("output")
.nOut(2)
.activation("softmax").build();
network.layer(l++, output);
最后,如前所述,我们指定输入大小:
network.setInputType(InputType.convolutionalFlat(HEIGHT, WIDTH, CHANNELS));
现在,我们从该架构创建模型,并指定用于列车监控目的的分数监听器:
MultiLayerNetwork model = new MultiLayerNetwork(network.build())
ScoreIterationListener scoreListener = new ScoreIterationListener(1);
model.setListeners(scoreListener);
至于训练,发生的方式与我们之前所做的完全相同——我们训练模型几个时期:
List<URI> trainUris = readImages(new File(root, "train_cv"));
DataSetIterator trainSet = datasetIterator(trainUris);
trainSet.setPreProcessor(preprocessor);
for (int epoch = 0; epoch < 10; epoch++) {
model.fit(trainSet);
}
ModelSerializer.writeModel(model, new File("model.zip"), true);
最后,我们还将模型保存到一个 ZIP 存档中,其中有三个文件——模型的系数,配置(参数和架构)在一个.json文件中,以及更新器的配置——以防我们希望在未来继续训练模型(最后一个参数true告诉我们保存它,使用false我们不能继续训练)。
然而,这里的性能监控非常原始,我们只观察训练错误,根本不观察验证错误。接下来,我们将了解更多性能监控选项。
监控性能
我们之前为监视所做的是添加监听器,它在每次迭代后输出模型的训练分数:
MultiLayerNetwork model = new MultiLayerNetwork(network.build())
ScoreIterationListener scoreListener = new ScoreIterationListener(1);
model.setListeners(scoreListener);
这将使您对模型的性能有所了解,但仅限于训练数据,但我们通常需要更多的数据——至少了解验证集的性能对于了解我们是否开始过度拟合是有用的。
那么,让我们来看看验证数据集:
DataSetIterator valSet = datasetIterator(valUris);
valSet.setPreProcessor(preprocessor);
为了训练,以前我们只是将数据集迭代器传递给 fit 函数。我们可以通过获取所有训练数据,在每个时期之前对其进行洗牌,并将其分成多个部分来改进这一过程,每个部分等于 20 个批次。在每个块上的训练完成后,我们可以迭代验证集,并查看模型的当前验证性能。
在代码中,它看起来像这样:
for (int epoch = 0; epoch < 20000; epoch++) {
ArrayList<URI> uris = new ArrayList<>(trainUris);
Collections.shuffle(uris);
List<List<URI>> partitions = Lists.partition(uris, BATCH_SIZE * 20);
for (List<URI> set : partitions) {
DataSetIterator trainSet = datasetIterator(set);
trainSet.setPreProcessor(preprocessor);
model.fit(trainSet);
showTrainPredictions(trainSet, model);
showLogloss(model, valSet, epoch);
}
saveModel(model, epoch);
}
所以在这里,我们将URI进行洗牌,并将其划分为 20 个批次的列表。对于分区,我们使用 Google Guava 的Lists.partition方法。从每个这样的分区,我们创建一个数据集迭代器,并使用它来训练模型,然后,在每个块之后,我们查看验证分数,以确保网络不会过度拟合。
此外,查看网络对其刚刚接受训练的数据的预测是有帮助的,尤其是检查网络是否正在学习任何东西。我们在showTrainPredictions方法内部做这件事。如果不同的输入有不同的预测,那么这是一个好现象。此外,您可能希望了解预测与实际标签的接近程度。
此外,我们在每个时期结束时保存模型,以防出错,我们可以训练流程。如果您注意到了,我们将历元的数量设置为一个很高的数字,因此在某些时候我们可以停止训练(例如,当我们从日志中看到我们开始过度拟合时),并只采用最后一个好的模型。
让我们看看这些方法是如何实现的:
private static void showTrainPredictions(DataSetIterator trainSet,
MultiLayerNetwork model) {
trainSet.reset();
DataSet ds = trainSet.next();
INDArray pred = model.output(ds.getFeatureMatrix(), false);
pred = pred.get(NDArrayIndex.all(), NDArrayIndex.point(0));
System.out.println("train pred: " + pred);
}
showLogLoss方法很简单,但是由于迭代器的原因有点冗长。它执行以下操作:
- 检查认证数据集中的所有批次
- 记录每批的预测和真实标签
- 将所有预测放在一个双数组中,并对实际标签进行同样的操作
- 使用我们在第 4 章、监督学习-分类和回归中编写的代码计算测井曲线损失。
为了简洁起见,我们在这里省略了确切的代码,但是欢迎您查看代码包。
保存模型很简单,我们已经知道如何做了。这里我们只是在文件名中添加了一些关于纪元编号的额外信息:
private static void saveModel(MultiLayerNetwork model, int epoch) throws IOException {
File locationToSave = new File("models", "cats_dogs_" + epoch + ".zip");
boolean saveUpdater = true;
ModelSerializer.writeModel(model, locationToSave, saveUpdater);
}
现在,当我们有大量信息要监控时,从日志中理解所有信息变得相当困难。为了让我们的生活更轻松,DeepLearning4j 附带了一个特殊的图形仪表盘来进行监控。
这是仪表板的外观:
让我们将它添加到代码中。首先,我们需要向我们的pom添加一个额外的依赖项:
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>deeplearning4j-ui_2.10</artifactId>
<version>0.7.1</version>
</dependency>
它是用 Scala 写的,这也是为什么结尾有_2.10后缀的原因,它告诉我们这个版本是用 Scala 2.10 写的。因为我们在 Java 中使用它,所以这对我们来说无关紧要,所以我们可以选择任何我们想要的版本。
接下来,我们可以创建 UI 服务器的实例,并为网络创建一个特殊的侦听器,它将订阅网络的更新:
UIServer uiServer = UIServer.getInstance();
StatsStorage statsStorage = new InMemoryStatsStorage();
uiServer.attach(statsStorage);
StatsListener statsListener = new StatsListener(statsStorage);
我们以与使用ScoreIterationListener相同的方式使用它,我们通过setListeners方法将它添加到模型中:
MultiLayerNetwork model = createNetwork();
ScoreIterationListener scoreListener = new ScoreIterationListener(1);
model.setListeners(scoreListener, statsListener);
有了这些改变,当我们运行代码的时候,它就启动了 UI 服务器,我们打开浏览器去http://localhost:9000就能看到;这将显示前面代码中的仪表板。
这些图表很有用。最有用的是显示每次迭代的模型得分的图表。这是训练分数,与我们在ScoreIterationListener的日志中看到的分数相同,看起来像这样:
根据这个图表,我们可以了解模型在训练过程中的行为——训练过程是否稳定,或者模型是否在学习任何东西。理想情况下,我们应该看到如前面截图所示的下降趋势。如果分数没有下降,那么可能是网络配置有问题,比如学习率太小,不好好初始化权重或者正则化太多。如果分数有提高,那么最有可能的问题就是学习率过大。
其他图表也允许监控训练过程。“参数比率”图表以对数标度显示了每次迭代之间的参数变化(即,-3.0 对应于 0.001 次迭代之间的变化)。如果你看到变化太低,例如低于-6.0,那么,很可能,网络没有学到任何东西。
最后,有一个图表显示了所有激活的标准偏差。我们可能需要这样做的原因是为了检测所谓的消失和爆发激活:
消失激活问题与消失渐变问题相关。对于一些激活,输入结果的变化几乎没有输出的变化,梯度几乎为零,所以神经元没有更新,所以它的激活消失。爆炸式激活则相反,激活分数不断增长,直至达到无穷大。
在此界面中,我们还可以在 Models 选项卡上看到完整的网络。这是我们模型的一部分:
如果我们单击每个单独的层,我们可以看到该特定层的一些图表。
使用这些工具,我们可以密切监控模型的性能,并在发现异常情况时调整训练过程和参数。
数据扩充
对于这个问题,我们只有 25000 个训练样本。对于深度学习模型来说,这个数据量通常不足以捕捉所有细节。无论我们的网络有多复杂,我们花了多少时间来调整它,在某些时候 25,000 个例子都不足以进一步提高性能。
通常,获取更多数据非常昂贵,或者根本不可能。但是我们能做的是从我们已经拥有的数据中产生更多的数据,这被称为数据扩充。通常,我们通过执行以下一些转换来生成新数据:
- 旋转图像
- 翻转图像
- 随机裁剪图像
- 切换颜色通道(例如,更改红色和蓝色通道)
- 更改颜色饱和度、对比度和亮度
- 添加噪声
在这一章中,我们将会看到前三种变换——旋转、翻转和裁剪。为此,我们将使用Scalr -一个用于图像操作的库。让我们将它添加到pom文件中:
<dependency>
<groupId>org.imgscalr</groupId>
<artifactId>imgscalr-lib</artifactId>
<version>4.2</version>
</dependency>
它非常简单,只是扩展了标准的 Java API,就像 Apache Commons Lang 所做的一样——通过围绕标准功能提供有用的实用方法。
对于旋转和翻转,我们只需使用Scalr.rotate方法:
File image = new File("cat.10000.jpg");
BufferedImage src = ImageIO.read(image);
Rotation rotation = Rotation.CW_90;
BufferedImage rotated = Scalr.rotate(src, rotation);
File outputFile = new File("cat.10000_cw_90.jpg");
ImageIO.write(rotated, "jpg", outputFile);
如你所见,这很容易使用,也很直观。我们需要做的就是传递一个BufferedImage和期望的Rotation。Rotation是一个具有以下值的枚举:
Rotation.CW_90:顺时针旋转 90 度Rotation.CW_180:顺时针旋转 180 度Rotation.CW_270:顺时针旋转 270 度- 这包括水平翻转图像
- 这包括垂直翻转图像
裁剪也不难,它是通过Scalr.crop方法完成的,该方法接受四个参数——裁剪开始的位置(x和y坐标)和裁剪的大小(高度和宽度)。对于我们的问题,我们可以做的是在图像的左上角随机选择一个坐标,然后随机选择作物的高度和宽度。我们可以这样做:
int width = src.getWidth();
int x = rnd.nextInt(width / 2);
int w = (int) ((0.7 + rnd.nextDouble() / 2) * width / 2);
int height = src.getHeight();
int y = rnd.nextInt(height / 2);
int h = (int) ((0.7 + rnd.nextDouble() / 2) * height / 2);
if (x + w > width) {
w = width - x;
}
if (y + h > height) {
h = height - y;
}
BufferedImage crop = Scalr.crop(src, x, y, w, h);
这里,我们首先随机选择x和y坐标,然后选择宽度和高度。在代码中,我们选择重量和高度,使它们至少占图像的 35%——但可以达到图像的 60%。当然,您可以随意使用这些参数,将它们更改为更有意义的值。
然后,我们还检查我们是否没有克服图像边界,也就是说,作物总是停留在图像内;最后我们调用crop方法。或者,我们也可以在最后旋转或翻转裁剪后的图像。
因此,对于所有文件,它可能看起来像这样:
for (File f : all) {
BufferedImage src = ImageIO.read(f);
for (Rotation rotation : Rotation.values()) {
BufferedImage rotated = Scalr.rotate(src, rotation);
String rotatedFile = f.getName() + "_" + rotation.name() + ".jpg";
File outputFile = new File(outputDir, rotatedFile);
ImageIO.write(rotated, "jpg", outputFile);
int width = src.getWidth();
int x = rnd.nextInt(width / 2);
int w = (int) ((0.7 + rnd.nextDouble() / 2) * width / 2);
int height = src.getHeight();
int y = rnd.nextInt(height / 2);
int h = (int) ((0.7 + rnd.nextDouble() / 2) * height / 2);
if (x + w > width) {
w = width - x;
}
if (y + h > height) {
h = height - y;
}
BufferedImage crop = Scalr.crop(src, x, y, w, h);
rotated = Scalr.rotate(crop, rotation);
String cropppedFile = f.getName() + "_" + x + "_" + w + "_" +
y + "_" + h + "_" + rotation.name() + ".jpg";
outputFile = new File(outputDir, cropppedFile);
ImageIO.write(rotated, "jpg", outputFile);
}
}
在这段代码中,我们迭代了所有的训练文件,然后我们将所有的旋转应用到图像本身,并从该图像中随机裁剪。这段代码应该从每个源图像生成 10 个新图像。例如,对于下面的一只猫的图像,将生成如下 10 个图像:
我们只是简单地列出了可能的增强,如果你还记得的话,最后一个是添加随机噪声。这通常很容易实现,因此这里有一些关于您可以做什么的想法:
- 用 0 或一些随机值替换一些像素值
- 从所有值中加上或减去同一个小数字
- 生成一些具有小方差的高斯噪声,并将其添加到所有通道中
- 仅将噪声添加到图像的一部分
- 反转图像的一部分
- 向图像中添加某种随机颜色的填充正方形;颜色可以有 alpha 通道(也就是说,它可以有点透明),也可以没有
- 对图像应用强 JPG 编码
这样,您就可以虚拟地生成无限数量的数据样本来进行训练。当然,您可能不需要这么多样本,但通过使用这些技术,您可以扩充任何影像数据集,并显著提高基于此数据训练的模型的性能。
在 GPU 上运行 DeepLearning4J
正如我们之前提到的,DeepLearning4j 依赖 ND4J 进行数值计算。ND4J 是一个接口,有多种可能的实现。到目前为止,我们使用的是基于 OpenBLAS 的版本,但还有其他版本。我们还提到,ND4J 可以利用一个图形处理单元 ( GPU ),对于矩阵乘法等神经网络中使用的典型线性代数运算,它比 CPU 快得多。要使用它,我们需要获得 CUDA ND4J 后端。
CUDA 是一个用于在 NVidia 的 GPU 上执行计算的接口,它支持广泛的图形卡。在内部,ND4J 使用 CUDA 在 GPU 上运行数值计算。
如果你以前通过 BLAS 在 CPU 上执行过所有的代码,你一定注意到它有多慢。将 ND4J 后端切换到 CUDA 应该会将性能提高几个数量级。
这是通过在pom文件中包含以下依赖项来实现的:
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-cuda-7.5</artifactId>
<version>0.7.1</version>
</dependency>
这种依赖性假设您已经安装了 CUDA 7.5。
对于 CUDA 8.0,你应该把 7.5 换成 8.0:ND4J 支持 CUDA 7.5 和 CUDA 8.0。
如果您已经有一个安装了所有驱动程序的 GPU,只需添加这种依赖关系就足以使用 GPU 来训练网络,当您这样做时,您将看到性能的大幅提升。
更重要的是,您可以使用 UI 仪表板来监控 GPU 内存使用情况,如果您发现它很低,您可以尝试更好地利用它,例如,通过增加批处理大小。您可以在系统选项卡上找到该图表:
如果你没有图形处理器,但不想等待你的 CPU 处理数据,你可以很容易地租一台图形处理器计算机。有些云提供商,比如亚马逊 AWS,可以让你立即获得一台带有 GPU 的服务器,哪怕只是几个小时。
如果你从未在亚马逊 AWS 上租用过服务器,我们已经准备了简单的说明,告诉你如何在那里开始培训。
在租用服务器之前,让我们先准备好我们需要的一切;代码和数据。
对于数据,我们只需将所有文件(包括扩充的文件)放入一个归档文件中:
zip -r all-data.zip train_cv/ val_cv/
然后,我们需要构建代码,这样我们就有了所有的.jar文件,并且它们之间存在依赖关系。这是通过 Maven 的插件maven-dependency-plugin完成的。我们之前已经在第三章、探索性数据分析中使用过这个插件,所以我们将省略需要添加到我们的pom.xml文件中的 XML 配置。
现在我们使用 Maven 来编译我们的代码,并将其放入一个.jar文件中:
mvn package
在我们的例子中,项目名为chapter-08-dl4j,所以用 Maven 执行包目标会在target文件夹中创建一个chapter-08-dl4j-0.0.1-SNAPSHOT.jar文件。但是因为我们也使用了依赖插件,它创建了一个libs文件夹,在那里你可以找到所有的依赖。让我们把一切都放入一个.zip文件中:
zip -r code.zip chapter-08-dl4j-0.0.1-SNAPSHOT.jar libs/
执行准备步骤后,我们将有两个 ZIP 文件,all-data.zip和code.zip。
现在,当我们准备好程序和数据后,我们可以去aws.amazon.com登录控制台,或者创建一个帐户(如果你还没有)。进入后,选择 EC2,这将带您进入 EC2 仪表板。接下来,您可以选择您感兴趣的地区。你可以选择地理位置相近的或者最便宜的。通常,北弗吉尼亚和美国西俄勒冈相对于其他地方来说是相当便宜的。
然后,找到启动实例按钮并点击它。
如果您只在几个小时内需要一台 GPU 计算机,您可以选择创建一个 spot 实例——它们比通常的实例便宜,但它们的价格是动态的,在某些时候,如果有人愿意为您正在使用的实例支付更多费用,这样的实例可能会消亡。在启动它的时候,你可以设置一个价格阈值,如果你在那里选择了$1 这样的东西,那么这个实例应该会持续很长时间。
创建实例时,可以使用现有的 AMI,它是预安装了某些软件的系统的映像。这里最好的选择是寻找 CUDA,它会给你官方的 NVidia CUDA 7.5 映像,但你可以自由选择你想要的任何其他映像。
注意,有些阿美族不是免费的,选择时要慎重。此外,选择您可以信任的 AMI 提供者,因为有时可能会有恶意图像,它们会将计算资源用于您的任务之外的其他事情。如果有疑问,请使用 NVidia 官方图像,或者自己从头创建一个图像。
一旦选择了图像,就可以选择实例类型。对于我们的目的来说,g2.2.xlarge实例已经足够了,但是如果您愿意,还有更大更强大的实例。
接下来,你需要选择存储类型;我们不需要任何东西,可以跳过这一步。但是接下来很重要,我们在这里设置安全规则。由于 UI 仪表板运行在端口 9,000 上,我们需要打开它,这样就可以从外部访问它。然后我们可以添加一个定制的 TCP 规则,并在那里写入9000。
在这一步之后,我们就完成了,可以在查看详细信息之前启动实例了。
接下来,它会要求您为实例指定 ssh 的密钥对(.pem),如果您没有密钥对,可以创建并下载一个新的密钥对。让我们创建一个名为dl4j的密钥对,并将其保存到主文件夹中。
现在,实例已经启动,可以使用了。要访问它,请转到仪表板并找到该实例的公共 DNS,这是您可以用来从您的机器访问服务器的名称。让我们将它放入一个环境变量中:
EC2_HOST=ec2-54-205-18-41.compute-1.amazonaws.com
从现在开始,我们将假设您在 Linux 上使用 bash shell,但是它应该可以在 MacOS 或 Windows 上与 cygwin 或 MinGW 一起很好地工作。
现在,我们可以上传之前构建的.jar文件和数据。为此,我们将使用sftp。使用pem文件连接sftp客户端是这样完成的:
sftp -o IdentityFile=~/dl4j.pem ec2-user@$EC2_HOST
请注意,您应该位于包含数据和程序档案的文件夹中。然后,您可以通过执行以下命令来上传它们:
put code.zip
put all-data.zip
数据已经上传,所以现在我们可以对实例应用ssh来运行程序:
ssh -i "~/dl4j.pem" ec2-user@$EC2_HOST
我们要做的第一件事是打开档案:
unzip code.zip
unzip all-data.zip
如果由于某种原因,您的主文件夹中没有剩余的可用空间,运行df -h命令来查看是否有剩余空间。必须有其他具有可用空间的磁盘,您可以在其中存储数据。
到目前为止,我们已经打开了所有的文件,并准备好执行代码。但如果你用的是英伟达的 CUDA 7.5 AMI,它只有 Java 7 支持。因为我们使用 Java 8 编写代码,所以我们需要安装 Java 8:
sudo yum install java-1.8.0-openjdk.x86_64
当我们离开 ssh 会话时,我们不希望执行停止,所以最好在那里创建screen(如果您愿意,也可以使用tmux):
screen -R dl4j
现在我们在那里运行代码:
java8 -cp chapter-08-dl4j.jar:libs/* chapter08.catsdogs.VggCatDog ~/data
一旦你看到模型开始训练,你可以通过按下 Ctrl + A 然后按下 d 来分离屏幕。现在你可以关闭终端并使用 UI 来观看训练过程。为此,只需将EC2_HOST:9000放到浏览器中,其中EC2_HOST是实例的公共 DNS。
就是这样,现在你只需要等待一段时间,直到你的模型收敛。
一路上可能会有一些问题。
如果它说找不到openblas二进制文件,那么你有几个选择。您可以从libs文件夹中删除dl4j-nativejar,或者安装 openblas。第一个选项可能更好,因为我们不需要使用 CPU。
您可能遇到的另一个问题是缺少 NVCC 可执行文件,这是 dj4j 的 CUDA 7.5 库所需要的。解决这个问题很简单,您只需要将 CUDA 二进制文件的路径添加到 path 变量中:
PATH=/usr/local/cuda-7.5/bin:$PATH
摘要
在这一章中,我们看了如何在 Java 应用程序中使用深度学习,学习了 DeepLearning4j 库的基础知识,然后尝试将其应用于一个图像识别问题,我们希望将图像分类为狗和猫。
在下一章,我们将介绍 Apache Spark——一个用于在机器集群上分发数据科学算法的库。
九、扩展数据科学
到目前为止,我们已经讲述了许多关于数据科学的材料,我们学习了如何在 Java 中进行监督和非监督学习,如何执行文本挖掘,使用 XGBoost 和训练深度神经网络。然而,到目前为止,我们使用的大多数方法和技术都是在假设所有数据都可以存储在内存中的情况下,设计在单台机器上运行的。您应该已经知道,这是经常发生的情况:有非常大的数据集是不可能用传统的技术在典型的硬件上处理的。
在这一章中,我们将看到如何处理这样的数据集——我们将看到允许在几台机器上处理数据的工具。我们将讨论两个用例:一个是来自普通抓取的大规模 HTML 处理——网页的拷贝,另一个是社交网络的链接预测。
我们将讨论以下主题:
- Apache Hadoop MapReduce
- 通用爬网处理
- 阿帕奇火花
- 链接预测
- Spark GraphFrame 和 MLlib 库
- Apache Spark 上的 XGBoost
在本章结束时,你将学会如何使用 Hadoop 从普通抓取中提取数据,如何使用 Apache Spark 进行链接预测,以及如何在 Spark 中使用 XGBoost。
Apache Hadoop
Apache Hadoop 是一套工具,允许您将数据处理管道扩展到数千台机器。它包括:
- Hadoop MapReduce :这是一个数据处理框架
- HDFS: 这是一个分布式文件系统,允许我们在多台机器上存储数据
- YARN: 这是 MapReduce 等作业的执行程序
我们将只讨论 MapReduce,因为它是 Hadoop 的核心,并且与数据处理相关。我们不会讨论其余的内容,也不会讨论如何设置或配置 Hadoop 集群,因为这已经超出了本书的范围。如果你有兴趣了解更多,由汤姆·怀特撰写的 Hadoop:权威指南是一本深入学习这个主题的优秀书籍。
在我们的实验中,我们将使用本地模式,也就是说,我们将模拟集群,但仍然在本地机器上运行代码。这对于测试非常有用,一旦我们确定它能够正常工作,就可以将其部署到集群中,无需任何更改。
Hadoop MapReduce
正如我们已经说过的,Hadoop MapReduce 是一个库,它允许您以可扩展的方式处理数据。
MapReduce 框架中有两个主要的抽象:Map 和 Reduce。这个想法最初来自函数式编程范例,其中map和reduce是高级函数:
map:它接受一个函数和一系列元素,并依次将函数应用于每个元素。结果是一个新的序列。reduce:它也接受一个函数和一个序列,并使用这个函数处理序列,最终返回一个元素。
在本书中,我们已经相当广泛地使用了来自 Java Stream API 的 map 函数,从第二章、数据处理工具箱开始,所以你现在一定对它相当熟悉了。
在 Hadoop MapReduce 中,map和reduce函数与其前辈有些不同:
- Map 接受一个元素并返回许多键值对。它可以不返回任何东西,也可以返回一个或几个这样的对,所以它比
map更flatMap - 然后通过排序将输出按关键字分组
- 最后,
reduce接受一个组,并为每个组输出一些键-值对
通常,MapReduce 以单词计数为例进行说明:给定一个文本,我们希望计算每个单词在文本中出现的次数。解决方案如下:
map接收文本,然后将其标记化,并为每个标记输出一对(token, 1),其中token是密钥,1是关联值。reducer对所有 1 求和,这是最终计数。
我们将实现类似的东西:我们将为语料库中的每个标记创建 TF-IDF 向量,而不仅仅是计数单词。但是首先,我们需要从某个地方获取大量的文本数据。我们将使用公共爬网数据集,它包含网站的副本。
普通爬行
通用抓取(commoncrawl.org/)是过去七年从互联网上抓取的数据的储存库。它非常大,而且每个人都可以下载和分析。
当然,我们不可能全部使用它:即使一小部分也是如此之大,以至于需要一个大而强大的集群来处理它。在这一章中,将从 2016 年底的几个档案中,摘录文字 ting TF-IDF。
下载数据并不复杂,你可以在commoncrawl.org/the-data/ge…找到说明。这些数据已经存在于 S3 的存储中,因此 AWS 用户可以很容易地访问它们。然而,在这一章中,我们将通过 HTTP 下载一部分通用爬网,而不使用 AWS。
在撰写本文时,最近的数据来自 2016 年 12 月,位于s3://commoncrawl/crawl-data/CC-MAIN-2016-50。按照说明,我们首先需要获得本月各个归档文件的所有路径,它们存储在一个warc.paths.gz文件中。所以,在我们的例子中,我们对s3://commoncrawl/crawl-data/CC-MAIN-2016-50/warc.paths.gz感兴趣。
因为我们不打算使用 AWS,所以我们需要将它转换成可以通过 HTTP 下载的路径。为此,我们将s3://commoncrawl/替换为https://commoncrawl.s3.amazonaws.com:
wget https://commoncrawl.s3.amazonaws.com/crawl-data/CC-MAIN-2016-50/warc.paths.gz
让我们看看文件:
zcat warc.paths.gz | head -n 3
你会看到很多这样的行(为了简洁省略了后缀):
.../CC-MAIN-20161202170900-00000-ip-10-31-129-80.ec2.internal.warc.gz
.../CC-MAIN-20161202170900-00001-ip-10-31-129-80.ec2.internal.warc.gz
.../CC-MAIN-20161202170900-00002-ip-10-31-129-80.ec2.internal.warc.gz
为了通过 HTTP 下载它,我们再次需要将commoncrawl.s3.amazonaws.com/附加到这个文件的每一行。这可以通过 awk 轻松实现:
zcat warc.paths.gz
| head
| awk '{ print "https://commoncrawl.s3.amazonaws.com/" $0}'
> files.txt
现在我们有了这个文件的前 10 个 URL,所以我们可以下载它们:
for url in $(cat files.txt); do
wget $url;
done
为了加快速度,我们可以用 gnu-parallel 并行下载文件:
cat files.txt | parallel --gnu "wget {}"
现在我们已经下载了一些较大的数据:大约 10 个文件,每个 1GB。请注意,路径文件中大约有 50,000 行,仅 12 月份就有大约 50,000 GBs 的数据。这是大量的数据,每个人都可以在任何时候使用它!我们不会用光所有的文件,只会集中在我们已经下载的 10 个文件上。让我们用 Hadoop 来处理它们。
第一步很正常:我们需要在.pom文件中指定对 Hadoop 的依赖:
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-client</artifactId>
<version>2.7.3</version>
</dependency>
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-common</artifactId>
<version>2.7.3</version>
</dependency>
通用爬网使用 WARC 来存储 HTML 数据:这是一种存储爬网数据的特殊格式。为了能够处理它,我们需要添加一个特殊的库来读取它:
<dependency>
<groupId>org.netpreserve.commons</groupId>
<artifactId>webarchive-commons</artifactId>
<version>1.1.2</version>
</dependency>
接下来,我们需要告诉 Hadoop 如何使用这样的文件。为此,程序员通常需要提供FileRecordReader和FileImportFormat类的实现。幸运的是,有开源的实现,我们可以复制并粘贴到我们的项目中。其中一个在org.commoncrawl.warc包装的github.com/Smerity/cc-…有售。所以我们只是从那里复制WARCFileInputFormat和WARCFileRecordReader到我们的项目中。该代码也包含在本书的代码包中,以防存储库被删除。
有了这些,我们就可以开始编码了。首先,我们需要创建一个Job类:它指定将使用哪个映射器和缩减器类来运行作业,并允许我们配置如何执行该作业。所以,让我们创建一个WarcPreparationJob类,它扩展了Configured类并实现了Tool接口:
public class WarcPreparationJob extends Configured implements Tool {
public static void main(String[] args) throws Exception {
int res = ToolRunner.run(new Configuration(),
new WarcPreparationJob(), args);
System.exit(res);
}
public int run(String[] args) throws Exception {
// implementation goes here
}
}
用于Tool接口的 Java 文档信息丰富,详细描述了如何实现这样一个Job类:它覆盖了run方法,在这里它应该指定输入和输出路径以及映射器和缩减器类。
我们将稍微修改这段代码:首先,我们将有一个只有地图的作业,所以我们不需要一个缩减器。此外,因为我们正在处理文本,所以压缩输出是有用的。所以,让我们用下面的代码创建run方法。首先,我们创建一个Job类:
Job job = Job.getInstance(getConf());
现在我们来看看输入及其格式(在我们的例子中是 WARC):
Path inputPath = new Path(args[0]);
FileInputFormat.addInputPath(job, inputPath);
job.setInputFormatClass(WARCFileInputFormat.class);
接下来,我们指定输出,它是 gzipped 文本:
Path outputPath = new Path(args[1];
TextOutputFormat.setOutputPath(job, outputPath);
TextOutputFormat.setCompressOutput(job, true);
TextOutputFormat.setOutputCompressorClass(job, GzipCodec.class);
job.setOutputFormatClass(TextOutputFormat.class);
通常,输出是键-值对,但是因为我们只想处理 WARC 并从中提取文本,所以我们只输出一个键,没有值:
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(NullWritable.class);
最后,我们指定了映射器类,并说将没有缩减器:
job.setMapperClass(WarcPreparationMapper.class);
job.setNumReduceTasks(0);
现在,当我们指定了作业后,我们可以实现映射器类- WarcPreparationMapper。这个类应该扩展Mapper类。所有的映射器都应该实现map方法,所以我们的映射器应该有如下的轮廓:
public class WarcPreparationMapper extends
Mapper<Text, ArchiveReader, Text, NullWritable> {
@Override
protected void map(Text input, ArchiveReader archive, Context context)
throws IOException, InterruptedException {
// implementation goes here
}
}
map方法接收一个带有记录集合的 WARC archive,所以我们想要处理所有记录。因此,我们把下面的map法:
for (ArchiveRecord record : archive) {
process(record, context);
}
process 方法执行以下操作:从记录中提取 HTML,然后从 HTML 中提取文本,对其进行标记,最后将结果写入输出。在代码中,它看起来像这样:
String url = record.getHeader().getUrl();
String html = TextUtils.extractHtml(record);
String text = TextUtils.extractText(html);
List<String> tokens = TextUtils.tokenize(text);
String result = url + "t" + String.join(" ", tokens);
context.write(new Text(result), NullWritable.get());
在里面我们使用了三个助手函数:extractHtml、extractText和tokenize。后两个(exctractHtml和tokenize)我们已经用过几次了,所以省略它们的实现;参考第 6 章、处理文本-自然语言处理和信息检索。
第一个是extractHtml,包含以下代码:
byte[] rawData = IOUtils.toByteArray(r, r.available());
String rawContent = new String(rawData, "UTF-8");
String[] split = rawContent.split("(r?n){2}", 2);
String html = split[1].trim();
它使用 UTF-8 编码(有时可能不理想,因为不是互联网上的所有页面都使用 UTF-8 编码)将来自存档的数据转换为String,然后删除响应头,只保留剩余的 HTML。
最后,为了运行这些类,我们可以使用下面的代码:
String[] args = { /data/cc_warc", "/data/cc_warc_processed" };
ToolRunner.run(new Configuration(), new WarcPreparationJob(), args);
这里,我们手动指定“命令行”参数(在 main 方法中获得的参数),并将它们传递给ToolRunner类,该类可以在本地模型中运行 Hadoop 作业。
结果可能有色情内容。由于 Common Crawl 是对 Web 的复制,而且互联网上有大量的色情网站,所以很有可能你会在处理后的结果中看到一些色情文字。通过保留一个特殊的色情关键词列表,并丢弃所有包含这些词的文档,可以很容易地将其过滤掉。
运行此作业后,您将看到结果中有大量不同的语言。如果我们对特定的语言感兴趣,那么我们可以自动检测文档的语言,并且只保留那些我们感兴趣的语言的文档。
几个可以进行语言检测的 Java 库。其中之一是 language-detector,它可以通过下面的依赖片段包含在我们的项目中:
<dependency>
<groupId>com.optimaize.languagedetector</groupId>
<artifactId>language-detector</artifactId>
<version>0.5</version>
</dependency>
毫不奇怪,这个库使用机器学习来检测语言。因此,要使用它,我们需要做的第一件事是加载模型:
List<LanguageProfile> languageProfiles =
new LanguageProfileReader().readAllBuiltIn();
LanguageDetector detector = LanguageDetectorBuilder.create(NgramExtractors.standard())
.withProfiles(languageProfiles)
.build();
我们可以这样使用它:
Optional<LdLocale> result = detector.detect(text);
String language = "unk";
if (result.isPresent()) {
language = result.get().getLanguage();
}
这样,我们可以只保留英语(或任何其他语言)的文章,而丢弃其他的。因此,让我们从下载的文件中提取文本:
String lang = detectLanguage(text.get());
if (lang.equals("en")) {
// process the data
}
这里,detectLanguage是一个方法,它包含了检测文本语言的代码:我们之前写了这个代码。
一旦我们处理了 WARC 文件并从中提取了文本,我们就可以为语料库中的每个令牌计算 IDF。为此,我们需要首先计算测向文档频率。这与字数统计示例非常相似:
- 首先,我们需要一个为文档中每个不同的单词输出
1的mapper - 然后
reducer将所有的数字相加,得出最终的数字
该作业将处理我们刚刚从通用爬网解析的文档。
让我们创建映射器。它将在map方法中包含以下代码:
String doc = value.toString();
String[] split = doc.split("t");
String joinedTokens = split[1];
Set<String> tokens = Sets.newHashSet(joinedTokens.split(" "));
LongWritable one = new LongWritable(1);
for (String token : tokens) {
context.write(new Text(token), one);
}
映射器的输入是一个Text对象(名为value,它包含 URL 和令牌。我们使用HashSet分割令牌,只保留不同的令牌。最后,对于每个不同的令牌,我们编写1。
为了计算 IDF,我们通常需要知道 N :我们语料库中的文档数量。有两种方法可以得到它。首先,我们可以使用计数器:创建一个计数器,并为每个成功处理的文档递增。这很容易做到。
第一步是用我们希望在应用程序中使用的可能计数器创建一个特殊的enum。因为我们只需要一种类型的计数器,所以我们创建了一个只有一个元素的enum:
public static enum Counter {
DOCUMENTS;
}
第二步是使用context.getCounter()方法并递增计数器:
context.getCounter(Counter.DOCUMENTS).increment(1);
一旦作业结束,我们可以用下面的代码获得计数器的值:
Counters counters = job.getCounters();
long count = counters.findCounter(Counter.DOCUMENTS).getValue();
但是还有另一种选择:我们可以选择一个大的数字,并将其用作文档的数量。通常不需要精确,因为所有令牌的 IDF 共享相同的 N 。
现在,让我们继续进行reducer。由于映射器输出Text和一个 long(通过LongWritable),缩减器得到一个Text和一个LongWritable类上的 iterable 这是令牌和一堆 1。我们能做的只是对它们求和:
long sum = 0;
for (LongWritable cnt : values) {
sum = sum + cnt.get();
}
为了只保留频繁出现的单词,我们可以添加一个过滤器,丢弃所有不常用的单词,使结果明显变小:
if (sum > 100) {
context.write(key, new LongWritable(sum));
}
然后,我们的job类中运行它的代码将如下所示:
job.setInputFormatClass(TextInputFormat.class);
job.setOutputFormatClass(TextOutputFormat.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(LongWritable.class);
job.setMapperClass(DocumentFrequencyMapper.class);
job.setCombinerClass(DocumentFrequencyReducer.class);
job.setReducerClass(DocumentFrequencyReducer.class);
请注意,我们不仅设置了 mapper 和 reducer,还指定了一个合并器:这允许我们预聚合一些我们在 mapper 中输出的1,并且花费更少的时间对数据进行排序并在网络中发送结果。
最后,要将文档转换为 TF-IDF,我们可以创建第三个作业,再次使用 reduce-less,它将读取第一个作业的结果(在那里我们处理了 WARC 文件),并应用第二个作业的 IDF 权重。
我们希望第二个作业的输出应该非常小,以适合内存,所以我们可以做的是将文件发送到所有的映射器,在初始化期间读取它,然后检查已处理的 WARC 和先前的行。
job类的主要部分是一样的:我们输入,输出是Text——输出被压缩,reducer 任务数是0。
现在我们需要将df任务的结果发送给所有的映射器。这是通过缓存文件完成的:
Path dfInputPath = new Path(args[3]);
job.addCacheFile(new URI(dfInputPath.toUri() + "#df"));
因此,我们在这里指定了df作业结果的路径,然后将其放入缓存文件。注意最后的#df:这是我们稍后访问文件时使用的别名。
在映射器中,我们可以将所有结果读入一个映射(在设置方法中):
dfs = new HashMap<>();
File dir = new File("./df");
for (File file : dir.listFiles()) {
try (FileInputStream is = FileUtils.openInputStream(file)) {
LineIterator lines = IOUtils.lineIterator(is, StandardCharsets.UTF_8);
while (lines.hasNext()) {
String line = lines.next();
String[] split = line.split("t");
dfs.put(split[0], Integer.parseInt(split[1]));
}
}
}
这里,df是我们赋予结果文件的别名,它实际上是一个文件夹,而不是一个文件。因此,为了得到结果,我们需要检查文件夹中的每个文件,逐行读取它们,并将结果放入一个映射中。然后,我们可以在应用 IDF 权重的 map 方法中使用计数字典:
String doc = value.toString();
String[] split = doc.split("t");
String url = split[0];
List<String> tokens = Arrays.asList(split[1].split(" "));
Multiset<String> counts = HashMultiset.create(tokens);
String tfIdfTokens = counts.entrySet().stream()
.map(e -> toTfIdf(dfs, e))
.collect(Collectors.joining(" "));
Text output = new Text(url + "t" + tfIdfTokens);
context.write(output, NullWritable.get());
在这里,我们使用标记并使用Multiset来计算术语频率。接下来,我们在toTfIdf函数中将 TF 乘以 IDF:
String token = e.getElement();
int tf = e.getCount();
int df = dfs.getOrDefault(token, 100);
double idf = LOG_N - Math.log(df);
String result = String.format("%s:%.5f", token, tf * idf);
在这里,我们获得了Multiset的每个输入条目的 DF(文档频率),如果该标记不在我们的字典中,我们假设它相当罕见,因此我们为它指定默认 of 100。接下来我们算 IDF,最后算tf*idf。为了计算 IDF,我们使用LOG_N,它是我们设置为Math.log(1_000_000)的常数。
对于这个例子,一百万被选作文档的数量。即使文档的实际数量更少(大约 5k),但对于所有的令牌来说都是一样的。更重要的是,如果我们决定向索引中添加更多的文档,我们仍然可以使用相同的 N 而不用担心重新计算所有的内容。
这会产生如下所示的输出:
http://url.com/ flavors:9.21034 gluten:9.21034 specialty:14.28197 salad:18.36156 ...
您一定注意到了,每个作业的输出都保存在磁盘上。如果我们有多个任务,就像我们以前做的那样,我们需要读取数据,处理数据,然后保存回来。I/O 的开销很大,所以其中一些步骤是中间的,我们不需要保存结果。在 Hadoop 中,这是无法避免的,这就是为什么它有时会非常慢,并且会产生大量 I/O 开销。
幸运的是,有另一个库可以解决这个问题:Apache Spark。
阿帕奇火花
Apache Spark 是一个用于可伸缩数据处理的框架。它被设计得比 Hadoop 更好:它试图在内存中处理数据,而不是将中间结果保存在磁盘上。此外,它有更多的操作,不仅仅是 map 和 reduce,因此有更丰富的 API。
Apache Spark 中的主要抽象单元是弹性分布式数据集 ( RDD ),它是元素的分布式集合。与通常的集合或流的关键区别在于,rdd 可以在多台机器上并行处理,处理 Hadoop 作业的方式也是如此。
我们可以对 rdd 应用两种类型的操作:转换和操作。
- 转换:顾名思义,它只是把数据从一种形式变成另一种形式。作为输入,它们接收一个 RDD,同时也输出一个 RDD。诸如 map、flatMap 或 filter 之类的操作是变换操作的示例。
- 动作:这些接受一个 RDD 并产生其他东西,例如一个值、一个列表或一个地图,或者保存结果。动作的例子是计数和减少。
像在 Java Steam API 中一样,转换是懒惰的:它们不是立即执行的,而是链接在一起并一次性计算,不需要将中间结果保存到磁盘。在 Steam API 中,链是通过收集流来触发的,在 Spark 中也是如此:当我们执行一个动作时,这个特定动作所需的所有转换都会被执行。另一方面,如果一些转换是不需要的,那么它们将永远不会被执行,这就是为什么它们被称为懒惰。
所以让我们从 Spark 开始,首先将它的依赖项包含到.pom文件中:
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-core_2.11</artifactId>
<version>2.1.0</version>
</dependency>
在本节中,我们将使用它来计算 TF-IDF:因此我们将尝试重现我们刚刚为 Hadoop 编写的算法。
第一步是创建配置和上下文:
SparkConf conf = new SparkConf().setAppName("tfidf").setMaster("local[*]");
JavaSparkContext sc = new JavaSparkContext(conf);
这里,我们指定了 Spark 应用程序的名称以及它将连接到的服务器 URL。因为我们在本地模式下运行 Spark,所以我们把local[*]。这意味着我们建立一个本地服务器,并创建尽可能多的本地工人。
Spark 依赖于一些 Hadoop 实用程序,这使得它更难在 Windows 上运行。如果在 Windows 下运行,可能会遇到找不到 Spark 和 Hadoop 需要的winutils.exe文件的问题。要解决这个问题,请执行以下操作:
- 为 Hadoop 文件创建一个文件夹,例如
c:/tmp/hadoop(确保路径中没有空格) - 从http://public-repo-1 . Horton works . com/HDP-win-alpha/winutils . exe下载
winutils.exe - 将文件放入文件夹
c:/tmp/hadoop/bin——注意bin子目录。 - 将
HADOOP_HOME环境变量设置为c:/tmp/hadoop,或者使用以下代码:
System.setProperty("hadoop.home.dir", "c:/tmp/hadoop");
这应该能解决问题。
下一步是读取文本文件,这是我们在处理了常见的爬网文件后用 Hadoop 创建的。我们来读一下:
JavaRDD<String> textFile = sc.textFile("C:/tmp/warc");
要查看文件,我们可以使用 take 函数,该函数从 RDD 中返回前 10 个元素,然后我们将每一行打印到 stdout:
textFile.take(10).forEach(System.out::println);
现在,我们可以逐行读取文件,对于每个文档,输出它具有的所有不同的标记:
JavaPairRDD<String, Integer> dfRdd = textFile
.flatMap(line -> distinctTokens(line))
.mapToPair(t -> new Tuple2<>(t, 1))
.reduceByKey((a, b) -> a + b)
.filter(t -> t._2 >= 100);
这里,distinctToken是一个函数,它拆分行并将所有的记号放入一个集合中,只保留不同的记号。下面是它的实现方式:
private static Iterator<String> distinctTokens(String line) {
String[] split = line.split("t");
Set<String> tokens = Sets.newHashSet(split[1].split(" "));
return tokens.iterator();
}
flatMap函数需要返回一个迭代器,所以我们在最后调用集合上的迭代器方法。
接下来,我们将每一行转换成一个元组:需要这一步来告诉 Spark 我们有了键值对,所以像reduceByKey和groupByKey这样的函数是可用的。最后,我们用与之前在 Hadoop 中相同的实现来调用reduceByKey方法。在转换链的末尾,我们应用过滤器只保留足够频繁的令牌。
您可能已经注意到,与我们之前编写的 Hadoop 相比,这段代码非常简单。
现在我们可以将所有的结果放入一个Map中:因为我们应用了过滤,所以我们期望字典应该很容易放入一个内存中,即使是在普通的硬件上。我们通过使用collectAsMap函数来实现:
Map<String, Integer> dfs = dfRdd.collectAsMap();
最后,我们再次检查所有文档,这次将 TF-IDF 加权方案应用于所有令牌:
JavaRDD<String> tfIdfRdd = textFile.map(line -> {
String[] split = line.split("t");
String url = split[0];
List<String> tokens = Arrays.asList(split[1].split(" "));
Multiset<String> counts = HashMultiset.create(tokens);
String tfIdfTokens = counts.entrySet().stream()
.map(e -> toTfIdf(dfs, e))
.collect(Collectors.joining(" "));
return url + "t" + tfIdfTokens;
});
我们像以前一样解析输入,并使用来自 Guava 的Multiset来计算 TF。toTfIdf函数与之前完全相同:它从Multiset接收一个条目,通过 IDF 对其进行加权,并输出一个token:weight格式的字符串。
为了查看结果,我们可以从 RDD 中取出前 10 个令牌,并将其打印到 stdout:
tfIdfRdd.take(10).forEach(System.out::println);
最后,我们使用saveAsTextFile方法将结果保存到文本文件中:
tfIdfRdd.saveAsTextFile("c:/tmp/warc-tfidf");
正如我们所见,我们可以用少得多的代码在 Spark 中做同样的事情。更重要的是,它的效率也更高:它不需要在每一步之后都将结果保存到磁盘上,而是动态地应用所有需要的转换。这使得 Spark 在许多应用程序上比 Hadoop 快得多。
但是,也有 Hadoop 优于 Spark 的情况。Spark 试图将所有东西都保存在内存中,有时会因为这个和OutOfMemoryException一起失败。Hadoop 要简单得多:它所做的只是写入文件,然后对数据执行大型分布式合并排序。也就是说,一般来说,你应该更喜欢 Apache Spark 而不是 Hadoop MapReduce,因为 Hadoop 速度较慢且相当冗长。
在下一节中,我们将看到如何使用 Apache Spark 及其图形处理和机器学习库来解决链接预测问题。
链接预测
链路预测是预测网络中会出现哪些链路的问题。例如,我们可以在脸书或另一个社交网络中有一个友谊图,像你可能认识的人这样的功能是链接预测的一个应用。因此,我们可以看到链接预测是一个社交网络推荐系统。
*对于这个问题,我们需要找到一个数据集,其中包含一个随时间演变的图。然后,我们可以考虑这样一个图在其演变过程中的某个时刻,计算现有链接之间的一些特征,并以此为基础,预测接下来可能会出现哪些链接。因为对于这样的图,我们知道未来,我们可以使用这种知识来评估我们的模型的性能。
有许多有趣的数据集可用,但不幸的是,它们中的大多数都没有与边相关联的时间,因此不可能看到这些图表是如何随着时间的推移而发展的。这使得测试方法更加困难,但是,当然,没有时间维度也是可能的。
幸运的是,有一些带有时间戳边的数据集。对于这一章,我们将使用基于 DBLP(dblp.uni-trier.de/)数据的合著图,这是一个索引计算机科学论文的搜索引擎。该数据集可从projects.csail.mit.edu/dnd/DBLP/(dblp_coauthorship.json.gz文件)获得,它包括 1938 年至 2015 年的论文。它已经以图表的形式出现了:每条边是一对一起发表论文的作者,每条边还包含他们发表论文的年份。
该文件的前几行如下所示:
[
["Alin Deutsch", "Mary F. Fernandez", 1998],
["Alin Deutsch", "Daniela Florescu", 1998],
["Alin Deutsch", "Alon Y. Levy", 1998],
["Alin Deutsch", "Dan Suciu", 1998],
["Mary F. Fernandez", "Daniela Florescu", 1998],
让我们使用这个数据集来建立一个模型,该模型将预测谁很可能在未来成为合著者。这种模型的应用之一可以是推荐系统:对于每个作者,它可以建议可能的合作作者。* *
阅读 DBLP 图表
从这个项目开始,我们首先需要读取图形数据,为此,我们将使用 Apache Spark 和它的一些库。第一个库是 Spark Data frames,它类似于 R data frames、pandas 或 joinery,只是它们是分布式的,基于 RDDs。
让我们来看看这个数据集。第一步是创建一个特殊的类Edge来存储数据:
public class Edge implements Serializable {
private final String node1;
private final String node2;
private final int year;
// constructor and setters omitted
}
现在,让我们来读数据:
SparkConf conf = new SparkConf().setAppName("graph").setMaster("local[*]");
JavaSparkContext sc = new JavaSparkContext(conf);
JavaRDD<String> edgeFile = sc.textFile("/data/dblp/dblp_coauthorship.json.gz");
JavaRDD<Edge> edges = edgeFile.filter(s -> s.length() > 1).map(s -> {
Object[] array = JSON.std.arrayFrom(s);
String node1 = (String) array[0];
String node2 = (String) array[1];
Integer year = (Integer) array[2];
if (year == null) {
return new Edge(node1, node2, -1);
}
return new Edge(node1, node2, year);
});
设置完上下文后,我们从一个文本文件中读取数据,然后对每一行应用一个 map 函数,将其转换为Edge。为了解析 JSON,我们像前面一样使用 Jackson-Jr 库,所以确保将它添加到 pom 文件中。
注意,我们在这里还包含了一个filter:第一行和最后一行分别包含了[和],所以我们需要跳过它们。
为了检查我们是否成功地解析了数据,我们可以使用 take 方法:它获取 RDD 的头部并将其放入一个List,我们可以将它打印到控制台:
edges.take(5).forEach(System.out::println);
这将产生以下输出:
Edge [node1=Alin Deutsch, node2=Mary F. Fernandez, year=1998]
Edge [node1=Alin Deutsch, node2=Daniela Florescu, year=1998]
Edge [node1=Alin Deutsch, node2=Alon Y. Levy, year=1998]
Edge [node1=Alin Deutsch, node2=Dan Suciu, year=1998]
Edge [node1=Mary F. Fernandez, node2=Daniela Florescu, year=1998]
成功转换数据后,我们会将其放入数据框中。为此,我们将使用 Spark DataFrame,它是 Spark-SQL 包的一部分。我们可以通过以下依赖关系来包含它:
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-sql_2.11</artifactId>
<version>2.1.0</version>
</dependency>
为了从我们的RDD创建一个DataFrame,我们首先创建一个 SQL 会话,然后使用它的createDataFrame方法:
SparkSession sql = new SparkSession(sc.sc());
Dataset<Row> df = sql.createDataFrame(edges, Edge.class);
数据集中有相当多的论文。我们可以通过将它限制为仅在1990.发表的论文来缩小它,为此我们可以使用filter方法:
df = df.filter("year >= 1990");
接下来,很多作者可以有多篇论文在一起,我们感兴趣的是最早的一篇。我们可以使用min函数得到它:
df = df.groupBy("node1", "node2")
.min("year")
.withColumnRenamed("min(year)", "year");
当我们应用 min 函数时,该列被重命名为min(year),所以我们通过用withColumnRenamed函数将该列重命名回year来修复它。
为了建立任何机器学习模型,我们总是需要指定一个训练/测试分割。这个案例也不例外,所以我们把 2013 年之前的所有数据作为训练部分,之后的所有论文作为测试:
Dataset<Row> train = df.filter("year <= 2013");
Dataset<Row> test = df.filter("year >= 2014");
现在,我们可以开始提取一些将用于创建模型的特征。
从图中提取特征
我们需要提取一些特征,然后放入机器学习模型进行训练。对于这个数据集,我们拥有的所有信息就是图表本身,仅此而已:我们没有任何外部信息,比如作者的从属关系。当然,如果我们有,加到模型里也没问题。所以我们来讨论一下,单独从图中可以提取哪些特征。
对于图模型,可以有两种特征:节点特征(作者)和边特征(合著关系)。
我们可以从图节点中提取许多可能的特征。例如,除其他外,我们可以考虑以下情况:
- 度:这是这个作者拥有的合著者数量。
- 页面排名:这是一个节点的重要性。
让我们看看下图:
这里我们有两个连接的组件,节点上的数字指定了节点的度数,或者它有多少个连接。
在某种程度上,程度衡量一个节点的重要性(或中心性):它拥有的连接越多,重要性就越高。页面排名(也称为特征向量中心性)是另一种衡量重要性的方法。你可能听说过 Page Rank——它被 Google 用作排名公式的组成部分之一。网页排名背后的主要思想是,如果一个网页链接到其他重要的网页,它也必须是重要的。有时使用 Chei Rank 也是有意义的,它是页面等级的反向——它不看进来的边,而是看出去的边。
然而,我们的图是无向的:如果 A 和 B 是合著者,那么 B 和 A 也是合著者。所以,在我们的例子中,Page Rank 和 Chei Rank 是完全相同的。
还有其他一些重要的度量,比如接近中心性或中间中心性,但我们不会在本章中考虑它们。
此外,我们可以查看节点的连通分量:如果两个节点来自不同的连通分量,通常很难预测它们之间是否会有链接。(当然,如果我们包括其他特征,而不仅仅是我们可以从图中提取的特征,这就变得可能了。)
图也有边,我们可以从中提取很多信息来构建我们的模型。例如,我们可以考虑以下特征:
- 共同好友:这是共同作者的数量
- 好友总数:这是两位作者拥有的不同合著者的总数
- Jaccard 相似度:这是合著者集合的 Jaccard 相似度
- 基于节点的特征这是每个节点的页面排名的差异,最小和最大程度,等等
当然,我们可以包括许多其他特征,例如,两个作者之间的最短路径的长度应该是一个很强的预测因素,但是计算它通常需要很多时间。
节点特征
让我们首先集中于我们可以用来计算图的节点的特征。为此,我们将需要一个图形库,让我们能够轻松地计算图形特征,如程度或页面排名。对于 Apache Spark,这样的库就是 GraphX。然而,目前这个库只支持 Scala:它使用了很多 Scala 特有的特性,这使得从 Java 中使用它变得非常困难(而且经常是不可能的)。
然而,还有另一个名为 GraphFrames 的库,它试图将 GraphX 与 DataFrames 结合起来。幸运的是,它支持 Java。这个包在 Maven Central 上不可用,要使用它,我们首先需要将下面的存储库添加到我们的pom.xml:
<repository>
<id>bintray-spark</id>
<url>https://dl.bintray.com/spark-packages/maven/</url>
</repository>
接下来,让我们包括库:
<dependency>
<groupId>graphframes</groupId>
<artifactId>graphframes</artifactId>
<version>0.3.0-spark2.0-s_2.11</version>
</dependency>
我们还需要添加 GraphX 依赖项,因为 GraphFrames 依赖于它:
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-graphx_2.11</artifactId>
<version>2.1.0</version>
</dependency>
GraphFrames 库目前正在积极开发中,将来很可能会更改一些方法名。如果本章中的例子停止工作,请参考 graphframes.github.io/的官方文档。
然而,在我们实际使用 GraphFrames 之前,我们需要准备我们的数据,因为它期望数据帧遵循特定的约定。首先,它假设图是有向的,但在我们的例子中不是这样。为了克服这个问题,我们需要添加反向链接。也就是说,我们的数据集中有一个记录(A, B),我们需要添加它的反向记录(B, A)。
为此,我们可以重命名数据帧副本的列,然后对原始数据帧使用 union 函数:
Dataset<Row> dfReversed = df
.withColumnRenamed("node1", "tmp")
.withColumnRenamed("node2", "node1")
.withColumnRenamed("tmp", "node2")
.select("node1", "node2", "year")
Dataset<Row> edges = df.union(dfReversed);
接下来,我们需要为节点创建一个特殊的DataFrame。我们可以通过选择 edges 数据集的node1列,然后调用 distinct 函数来实现这一点:
Dataset<Row> nodes = edges.select("node1")
.withColumnRenamed("node1", "node")
.distinct();
GraphFrame 在接受一个带有节点的DataFrame时,希望它有一个id列,这是我们必须手动创建的。第一种选择是将node列重命名为id,并将其传递给 GraphFrame。另一种选择是创建代理 id,我们将在这里完成:
nodes = nodes.withColumn("id", functions.monotonicallyIncreasingId());
对于前面的代码,我们需要添加以下导入:
import org.apache.spark.sql.functions;
这个functions是一个实用程序类,有很多有用的 DataFrame 函数。
要查看所有这些转换后我们的数据帧是什么样子,我们可以使用show方法:
nodes.show();
它将产生以下输出(在此处和所有示例中,被截断为前六行):
+--------------------+---+
| node| id|
+--------------------+---+
| Dan Olteanu| 0|
| Manjit Borah| 1|
| Christoph Elsner| 2|
| Sagnika Sen| 3|
| Jerome Yen| 4|
| Anand Kudari| 5|
| M. Pan| 6|
+--------------------+---+
现在,让我们准备边缘。GraphFrames 要求数据帧有两个特殊的列:src和dst(分别是源和目的地)。要获得这些列的值,让我们将它们与节点数据帧连接起来,并获得数字 id:
edges = edges.join(nodes, edges.col("node2").equalTo(nodes.col("node")));
edges = edges.drop("node").withColumnRenamed("id", "dst");
edges = edges.join(nodes, edges.col("node1").equalTo(nodes.col("node")));
edges = edges.drop("node").withColumnRenamed("id", "src");
它将创建一个包含以下内容的数据帧:
+-------------+--------------------+----+-------------+----+
| node1| node2|year| dst| src|
+-------------+--------------------+----+-------------+----+
|A. A. Davydov| Eugene V. Shilnikov|2013| 51539612101|2471|
|A. A. Davydov| S. V. Sinitsyn|2011| 326417520647|2471|
|A. A. Davydov| N. Yu. Nalutin|2011| 335007452466|2471|
|A. A. Davydov| A. V. Bataev|2011| 429496733302|2471|
|A. A. Davydov|Boris N. Chetveru...|2013|1486058685923|2471|
| A. A. Sawant| M. K. Shah|2011| 231928238662|4514|
| A. A. Sawant| A. V. Shingala|2011| 644245100670|4514|
+-------------+--------------------+----+-------------+----+
最后,我们可以从节点和边数据帧创建GraphFrame:
GraphFrame gf = GraphFrame.apply(nodes, edges);
GraphFrame类允许我们使用很多图形算法。例如,计算页面排名非常简单,如下所示:
GraphFrame pageRank = gf.pageRank().resetProbability(0.1).maxIter(7).run();
Dataset<Row> pageRankNodes = pageRank.vertices();
pageRankNodes.show();
它将创建一个包含以下各列的DataFrame:
+----+-------------------+
| id| pagerank|
+----+-------------------+
| 26| 1.4394843416065657|
| 29| 1.012233852957335|
| 474| 0.7774103396731716|
| 964| 0.4443614094552203|
|1677| 0.274044687604839|
|1697| 0.493174385163372|
+----+-------------------+
计算度数甚至更简单:
Dataset<Row> degrees = gf.degrees();
这行代码将创建一个DataFrame与每个节点的度数:
+-------------+------+
| id|degree|
+-------------+------+
| 901943134694| 86|
| 171798692537| 4|
|1589137900148| 114|
| 8589935298| 86|
| 901943133299| 74|
| 292057778121| 14|
+-------------+------+
计算连接的组件也是如此:
Dataset<Row> cc = gf.connectedComponents().run();
它将创建一个DataFrame,对于每个节点,我们将获得它所属的连接组件的 ID:
+----+---------+
| id|component|
+----+---------+
| 26| 0|
| 29| 0|
| 474| 0|
| 964| 964|
|1677| 0|
|1697| 0|
+----+---------+
正如我们在这里看到的,6 个第一部分中有 5 个是第 0 部分。我们来看看这些组件的大小——可能 0th 是最大的,几乎包含了所有的东西?
为此,我们可以计算每个组件出现的次数:
Dataset<Row> cc = connectedComponents.groupBy("component").count();
cc.orderBy(functions.desc("count")).show();
我们使用groupBy函数,然后调用count方法。之后,我们按照 count 列的值以降序对数据帧进行排序。让我们看看输出:
+------------+-------+
| component| count|
+------------+-------+
| 0|1173137|
| 60129546561| 32|
| 60129543093| 30|
| 722| 29|
| 77309412270| 28|
| 34359740786| 28|
+------------+-------+
正如我们所看到的,大多数节点确实来自同一个组件。因此,在这种情况下,关于组件的信息不是很有用:几乎总是组件是0。
现在,在计算完节点特征之后,我们需要继续处理边特征。但在此之前,我们首先需要对将要计算这些特征的边进行采样。
负采样
在我们计算另一组特征,即边特征之前,我们需要首先指定我们想要为此取哪些边。因此,我们需要选择一组候选边,然后我们将在它们的基础上训练一个模型来预测一个边是否应该属于该图。换句话说,我们首先需要准备一个数据集,其中存在的边被视为正例,不存在的边被视为反例。
获得正面的例子很简单:我们只取所有的边,并给它们分配标签1。
对于反面例子来说,就更复杂了:在任何现实生活的图中,正面例子的数量都比反面例子的数量少很多。因此,我们需要找到一种方法来采样负面的例子,以便训练一个模型变得易于管理。
通常,对于链接预测问题,我们考虑两种类型的否定候选:简单的和困难的。简单的只是从相同的连接组件中采样,但困难的是一两跳之遥。因为对于我们的问题,大多数作者来自同一个组件,所以我们可以放松它,从整个图中抽取简单的否定,而不局限于同一个连接组件:
如果我们考虑前面的图,肯定的例子是容易的:我们只得到存在的边(1, 3)、(2, 3)、(3, 6),等等。对于消极的,有两种类型:simple和hard.简单的只是从所有可能不存在的边的集合中采样。这里可以是(1, 8)、(2, 7),也可以是(1, 9)。硬负边缘仅一跳之遥:(1, 2)、(1, 6)或(7, 9)是硬负的可能例子。
在我们的数据集中,我们有大约 600 万个正面的例子。为了保持训练数据或多或少的平衡,我们可以采样大约 1200 万个简单否定和大约 600 万个硬否定。那么正例与反例的比例将是 1/4。
创建正面示例非常简单:我们只需从图表中提取边,并为它们分配1.0目标:
Dataset<Row> pos = df.drop("year");
pos = pos.join(nodes, pos.col("node1").equalTo(nodes.col("node")));
pos = pos.drop("node", "node1").withColumnRenamed("id", "node1");
pos = pos.join(nodes, pos.col("node2").equalTo(nodes.col("node")));
pos = pos.drop("node", "node2").withColumnRenamed("id", "node2");
pos = pos.withColumn("target", functions.lit(1.0));
这里我们做了一些连接,用作者的 id 替换他们的名字。结果如下:
+-------------+-----+------+
| node1|node2|target|
+-------------+-----+------+
| 51539612101| 2471| 1.0|
| 429496733302| 2471| 1.0|
|1486058685923| 2471| 1.0|
|1254130450702| 4514| 1.0|
| 94489280742| 913| 1.0|
|1176821039357| 913| 1.0|
+-------------+-----+------+
接下来,我们对容易消极的人进行取样。为此,我们首先从节点的数据帧中抽取两次替换样本,一次针对node1,一次针对node2。然后,将这些列放在一个数据框中。我们可以这样取样:
Dataset<Row> nodeIds = nodes.select("id");
long nodesCount = nodeIds.count();
double fraction = 12_000_000.0 / nodesCount;
Dataset<Row> sample1 = nodeIds.sample(true, fraction, 1);
sample1 = sample1.withColumn("rnd", functions.rand(1))
.orderBy("rnd")
.drop("rnd");
Dataset<Row> sample2 = nodeIds.sample(true, fraction, 2);
sample2 = sample2.withColumn("rnd", functions.rand(2))
.orderBy("rnd")
.drop("rnd");
这里,fraction 参数指定样本应该包含的DataFrame的分数。因为我们想得到 1200 万个例子,所以我们用 1200 万除以我们拥有的节点数。然后,我们通过向每个样本添加一个具有随机数的列来对其进行混洗,并使用它来对DataFrame进行排序。在排序完成后,我们不再需要该列,因此可以将其删除。
两个样本可能具有不同的大小,因此我们需要选择最小的一个,然后将两个样本都限制在此大小,这样它们就可以连接起来:
long sample1Count = sample1.count();
long sample2Count = sample2.count();
int minSize = (int) Math.min(sample1Count, sample2Count);
sample1 = sample1.limit(minSize);
sample2 = sample2.limit(minSize);
接下来,我们要将这两个样本放在一个数据帧中。对于 DataFrame API 来说,没有简单的方法可以做到这一点,所以我们需要使用 rdd。为此,我们将DataFrame转换成JavaRDD,zip它们放在一起,然后将结果转换回单个DataFrame:
JavaRDD<Row> sample1Rdd = sample1.toJavaRDD();
JavaRDD<Row> sample2Rdd = sample2.toJavaRDD();
JavaRDD<Row> concat = sample1Rdd.zip(sample2Rdd).map(t -> {
long id1 = t._1.getLong(0);
long id2 = t._2.getLong(0);
return RowFactory.create(id1, id2);
});
StructField node1Field = DataTypes.createStructField("node1", DataTypes.LongType, false);
StructField node2Field = DataTypes.createStructField("node2", DataTypes.LongType, false);
StructType schema = DataTypes.createStructType(Arrays.asList(node1Field, node2Field));
Dataset<Row> negSimple = sql.createDataFrame(concat, schema);
为了将RDD转换成DataFrame,我们需要指定一个模式,前面的代码显示了如何做。
最后,我们将目标列添加到这个DataFrame:
negSimple = negSimple.withColumn("target", functions.lit(0.0));
这将产生以下DataFrame:
+-------------+-------------+------+
| node1| node2|target|
+-------------+-------------+------+
| 652835034825|1056561960618| 0.0|
| 386547056678| 446676601330| 0.0|
| 824633725362|1477468756129| 0.0|
|1529008363870| 274877910417| 0.0|
| 395136992117| 944892811576| 0.0|
|1657857381212|1116691503444| 0.0|
+-------------+-------------+------+
有可能通过这种方式采样,我们意外地生成了恰好在正例中的配对。但是,这样的概率相当低,可以丢弃。
我们还需要创造一些有力的反面例子——这些例子之间只有一步之遥:
在这个图中,正如我们已经讨论过的,(1, 2)、(1, 6)或(7, 9)对是硬否定例子的例子。
为了得到这样的对子,让我们首先从逻辑上阐明这个想法。我们需要对所有可能的对(B, C)进行采样,使得存在一些节点A和边(A, B)和(A, C)都存在,但是没有边(B, C)。
当我们以这种方式表述这个采样问题时,用 SQL 来表达就变得很容易了:我们需要做的只是一个自连接,并选择具有相同源但不同目的地的边。考虑下面的例子:
SELECT e1.dst as node1, e2.dst as node2
FROM Edges e1, Edges e2
WHERE e1.src = e2.src ANDe1.dst <> e2.dst;
让我们把它翻译成 Spark DataFrame API。为了进行自连接,我们首先需要创建边DataFrame的两个别名,并重命名其中的列:
Dataset<Row> e1 = edges.drop("node1", "node2", "year")
.withColumnRenamed("src", "e1_src")
.withColumnRenamed("dst", "e1_dst")
.as("e1");
Dataset<Row> e2 = edges.drop("node1", "node2", "year")
.withColumnRenamed("src", "e2_src")
.withColumnRenamed("dst", "e2_dst")
.as("e2");
现在,我们在dst不同,但src相同的条件下执行连接,然后重命名列,使它们与前面的示例一致:
Column diffDest = e1.col("e1_dst").notEqual(e2.col("e2_dst"));
Column sameSrc = e1.col("e1_src").equalTo(e2.col("e2_src"));
Dataset<Row> hardNeg = e1.join(e2, diffDest.and(sameSrc));
hardNeg = hardNeg.select("e1_dst", "e2_dst")
.withColumnRenamed("e1_dst", "node1")
.withColumnRenamed("e2_dst", "node2");
接下来,我们需要获取前 600 万条生成的边,并将其称为硬样本。然而,Spark 将这个DataFrame中的值以某种特定的顺序排列,这可能会在我们的模型中引入偏差。为了降低偏差的危害,让我们在采样过程中增加一些随机性:生成一个包含随机值的列,并只选取值大于某个数字的那些边:
hardNeg = hardNeg.withColumn("rnd", functions.rand(0));
hardNeg = hardNeg.filter("rnd >= 0.95").drop("rnd");
hardNeg = hardNeg.limit(6_000_000);
hardNeg = hardNeg.withColumn("target", functions.lit(0.0));
之后,我们只取前 6m 边,并添加target列。结果遵循与我们之前的示例相同的模式:
+------------+-------------+------+
| node1| node2|target|
+------------+-------------+------+
| 34359740336| 970662610852| 0.0|
| 34359740336| 987842479409| 0.0|
| 34359740336|1494648621189| 0.0|
| 34359740336|1554778161775| 0.0|
| 42949673538| 326417515499| 0.0|
|266287973882| 781684049287| 0.0|
+------------+-------------+------+
用union函数将它们放在一起:
Dataset<Row> trainEdges = pos.union(negSimple).union(hardNeg);
最后,让我们将 ID 与每条边相关联:
trainEdges = trainEdges.withColumn("id", functions.monotonicallyIncreasingId());
这样,我们就准备好了可以计算边缘特征的边缘。
边缘特征
我们可以计算许多边缘特征:共同朋友的数量,两个人都有的不同朋友的总数,等等。
让我们从共同的朋友这个特征开始,对于我们的问题来说,这是两位作者拥有的共同合著者的数量。为了得到它们,我们需要把我们选择的边和所有的边连接起来(两次),然后按 ID 分组,并计算每组有多少个元素。在 SQL 中,它看起来像这样:
SELECT train.id, COUNT(*)
FROM Sample train, Edges e1, Edges e2
WHERE train.node1 = e1.src AND
train.node2 = e2.src AND
e1.dst = e2.dst
GROUP BY train.id;
让我们把这个翻译成 DataFrame API。首先,我们使用连接:
Dataset<Row> join = train.join(e1,
train.col("node1").equalTo(e1.col("e1_src")));
join = join.join(e2,
join.col("node2").equalTo(e2.col("e2_src")).and(
join.col("e1_dst").equalTo(e2.col("e2_dst"))));
这里,我们重用来自负采样子部分的数据帧e1和e2。
然后,我们最后按id分组并计数:
Dataset<Row> commonFriends = join.groupBy("id").count();
commonFriends = commonFriends.withColumnRenamed("count", "commonFriends");
结果DataFrame将包含以下内容:
+-------------+-------------+
| id|commonFriends|
+-------------+-------------+
|1726578522049| 116|
| 15108| 1|
|1726581250424| 117|
| 17579| 4|
| 2669| 11|
| 3010| 73|
+-------------+-------------+
现在我们计算朋友总数,这是两位作者拥有的不同合著者的数量。在 SQL 中,它比前面的特性简单一点:
SELECT train.id, COUNT DISTINCT (e.dst)
FROM Sample train, Edges e
WHERE train.node1 = e.src
GROUP BY train.id
现在让我们把它翻译成 Spark:
Dataset<Row> e = edges.drop("node1", "node2", "year", "target");
Dataset<Row> join = train.join(e,
train.col("node1").equalTo(edges.col("src")));
totalFriends = join.select("id", "dst")
.groupBy("id")
.agg(functions.approxCountDistinct("dst").as("totalFriendsApprox"));
这里,我们使用了近似的非重复计数,因为它速度更快,并且通常给出相当准确的值。当然,有一个选项可以使用 exact count distinct:为此,我们需要使用functions.countDistinct函数。此步骤的输出如下表所示:
+-------------+------------------+
| id|totalFriendsApprox|
+-------------+------------------+
|1726580872911| 4|
| 601295447985| 4|
|1726580879317| 1|
| 858993461306| 11|
|1726578972367| 296|
|1726581766707| 296|
+-------------+------------------+
接下来,我们计算两组合著者之间的 Jaccard 相似度。我们首先为每个作者创建一个带有集合的DataFrame,然后连接并计算 jaccard。对于这个特性,没有直接的方式用 SQL 来表达,所以我们从 Spark API 开始。
为每个作者创建一组合著者很容易:我们只需使用groupBy函数,然后将functions.collect_set应用于每个组:
Dataset<Row> coAuthors = e.groupBy("src")
.agg(functions.collect_set("dst").as("others"))
.withColumnRenamed("src", "node");
现在我们加入我们的训练数据:
Dataset<Row> join = train.drop("target");
join = join.join(coAuthors, join.col("node1").equalTo(coAuthors.col("node")));
join = join.drop("node").withColumnRenamed("others", "others1");
join = join.join(coAuthors, join.col("node2").equalTo(coAuthors.col("node")));
join = join.drop("node").withColumnRenamed("others", "others2");
join = join.drop("node1", "node2");
最后,join 列有边的 ID 和每个边的合著者数组。接下来,我们检查该数据帧的每个记录,并计算 Jaccard 相似性:
JavaRDD<Row> jaccardRdd = join.toJavaRDD().map(r -> {
long id = r.getAs("id");
WrappedArray<Long> others1 = r.getAs("others1");
WrappedArray<Long> others2 = r.getAs("others2");
Set<Long> set1 = Sets.newHashSet((Long[]) others1.array());
Set<Long> set2 = Sets.newHashSet((Long[]) others2.array());
int intersection = Sets.intersection(set1, set2).size();
int union = Sets.union(set1, set2).size();
double jaccard = intersection / (union + 1.0);
return RowFactory.create(id, jaccard);
});
这里我们使用了正则化的 Jaccard 相似度:我们不是仅仅用交集除以并集,而是在分母上增加一个小的正则化因子。
这样做的原因是为了给非常小的集合更少的分数:假设每个集合都有相同的元素,那么 Jaccard 就是 1.0。通过正则化,小批量的相似性被罚分,对于这个例子,它将等于 0.5。
因为我们在这里使用了 rdd,所以我们需要将其转换回数据帧:
StructField node1Field = DataTypes.createStructField("id", DataTypes.LongType, false);
StructField node2Field = DataTypes.createStructField("jaccard", DataTypes.DoubleType, false);
StructType schema = DataTypes.createStructType(Arrays.asList(node1Field, node2Field));
Dataset<Row> jaccard = sql.createDataFrame(jaccardRdd, schema);
执行之后,我们得到一个类似这样的表:
+-------------+---------+
| id| jaccard|
+-------------+---------+
|1726581480054| 0.011|
|1726578955032| 0.058|
|1726581479913| 0.037|
|1726581479873| 0.05|
|1726581479976| 0.1|
| 1667| 0.1|
+-------------+---------+
请注意,前面的方法非常通用,我们可以遵循相同的方法来计算普通朋友和总朋友特征:分别是交集和并集的大小。更重要的是,它可以和 Jaccard 一起一次性计算。
我们的下一步是从我们已经计算的节点特征中去除边缘特征。除其他外,我们可以包括以下内容:
- 最小度数,最大度数
- 优先连接分数:节点 1 的度数乘以节点 2 的度数
- 页面等级的乘积
- 页面等级的绝对差异
- 相同的连接组件
为此,我们首先将所有节点功能连接在一起:
Dataset<Row> nodeFeatures = pageRank.join(degrees, "id")
.join(connectedComponents, "id");
nodeFeatures = nodeFeatures.withColumnRenamed("id", "node_id");
接下来,我们将刚刚创建的节点特征DataFrame与我们准备用于训练的边连接起来:
Dataset<Row> join = train.drop("target");
join = join.join(nodeFeatures,
join.col("node1").equalTo(nodeFeatures.col("node_id")));
join = join.drop("node_id")
.withColumnRenamed("pagerank", "pagerank_1")
.withColumnRenamed("degree", "degree_1")
.withColumnRenamed("component", "component_1");
join = join.join(nodeFeatures,
join.col("node2").equalTo(nodeFeatures.col("node_id")));
join = join.drop("node_id")
.withColumnRenamed("pagerank", "pagerank_2")
.withColumnRenamed("degree", "degree_2")
.withColumnRenamed("component", "component_2");
join = join.drop("node1", "node2");
现在,让我们来计算特性:
join = join
.withColumn("pagerank_mult", join.col("pagerank_1").multiply(join.col("pagerank_2")))
.withColumn("pagerank_max", functions.greatest("pagerank_1", "pagerank_2"))
.withColumn("pagerank_min", functions.least("pagerank_1", "pagerank_2"))
.withColumn("pref_attachm", join.col("degree_1").multiply(join.col("degree_2")))
.withColumn("degree_max", functions.greatest("degree_1", "degree_2"))
.withColumn("degree_min", functions.least("degree_1", "degree_2"))
.withColumn("same_comp", join.col("component_1").equalTo(join.col("component_2")));
join = join.drop("pagerank_1", "pagerank_2");
join = join.drop("degree_1", "degree_2");
join = join.drop("component_1", "component_2");
这将创建一个具有 edge ID 和七个特征的DataFrame:最小和最大页面等级,两个页面等级的乘积,最小和最大程度,两个程度的乘积(优先附件),最后,两个节点是否属于同一个组件。
现在我们已经计算完了我们想要的所有特性,所以是时候把它们都加入到一个单独的DataFrame中了:
Dataset<Row> join = train.join(commonFriends, "id")
.join(totalFriends, "id")
.join(jaccard, "id")
.join(nodeFeatures, "id");
到目前为止,我们创建了带有标注的数据集,并计算了一组要素。现在我们终于准备在它上面训练一个机器学习模型了。
使用 MLlib 和 XGBoost 进行链接预测
现在,当所有的数据都准备好并放入合适的形状时,我们可以训练一个模型,它将预测两个作者是否有可能成为合著者。为此,我们将使用二元分类器模型,该模型将被训练来预测该边在图中存在的概率。
Apache Spark 附带了一个库,它提供了几种机器学习算法的可伸缩实现。这个库叫做 MLlib。让我们把它添加到我们的pom.xml:
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-mllib_2.11</artifactId>
<version>2.1.0</version>
</dependency>
我们可以使用许多模型,包括逻辑回归、随机森林和梯度提升树。但是在我们训练任何模型之前,让我们将训练数据集分成训练集和验证集:
features = features.withColumn("rnd", functions.rand(1));
Dataset<Row> trainFeatures = features.filter("rnd < 0.8").drop("rnd");
Dataset<Row> valFeatures = features.filter("rnd >= 0.8").drop("rnd");
为了能够使用它来训练机器学习模型,我们需要将我们的数据转换为LabeledPoint对象的 RDD。为此,我们首先将数据帧转换为 RDD,然后将每一行转换为DenseVector:
List<String> columns = Arrays.asList("commonFriends", "totalFriendsApprox",
"jaccard", "pagerank_mult", "pagerank_max", "pagerank_min",
"pref_attachm", "degree_max", "degree_min", "same_comp");
JavaRDD<LabeledPoint> trainRdd = trainFeatures.toJavaRDD().map(r -> {
Vector vec = toDenseVector(columns, r);
double label = r.getAs("target");
return new LabeledPoint(label, vec);
});
columns按照我们希望将它们放入DenseVector的顺序存储我们希望用作特性的所有列名。toDenseVector函数有如下实现:
private static DenseVector toDenseVector(List<String> columns, Row r) {
int featureVecLen = columns.size();
double[] values = new double[featureVecLen];
for (int i = 0; i < featureVecLen; i++) {
Object o = r.getAs(columns.get(i));
values[i] = castToDouble(o);
}
return new DenseVector(values);
}
因为在我们的 DataFrame 中有多种类型的数据,包括int、double和boolean,我们需要能够将它们全部转换成 double。这就是castToDouble功能的作用:
private static double castToDouble(Object o) {
if (o instanceof Number) {
Number number = (Number) o;
return number.doubleValue();
}
if (o instanceof Boolean) {
Boolean bool = (Boolean) o;
if (bool) {
return 1.0;
} else {
return 0.0;
}
}
throw new IllegalArgumentException();
}
现在我们终于可以训练逻辑回归模型了:
LogisticRegressionModel logreg = new LogisticRegressionWithLBFGS()
.run(JavaRDD.toRDD(trainRdd));
完成后,我们可以评估模型有多好。
让我们浏览整个验证数据集,并对其中的每个元素进行预测:
logreg.clearThreshold();
JavaRDD<Pair<Double, Double>> predRdd = valFeatures.toJavaRDD().map(r -> {
Vector v = toDenseVector(columns, r);
double label = r.getAs("target");
double predict = logreg.predict(v);
return ImmutablePair.of(label, predict);
});
注意,我们首先需要调用clearThreshold方法——如果我们不这样做,那么模型将输出硬预测(只有 0.0 和 1.0),这将增加评估的难度。
现在,我们可以将预测和真实标签放入单独的双数组中,并使用任何二元分类评估函数,我们在第 4 章、监督学习-分类和回归中讨论过这些函数。例如,我们可以使用logLoss:
List<Pair<Double, Double>> pred = predRdd.collect();
double[] actual = pred.stream().mapToDouble(Pair::getLeft).toArray();
double[] predicted = pred.stream().mapToDouble(Pair::getRight).toArray();
double logLoss = Metrics.logLoss(actual, predicted);
System.out.printf("log loss: %.4f%n", logLoss);
这会产生以下输出:
log loss: 0.6528
这不是一个很好的性能:如果我们总是输出0.5作为预测值,那么logLoss将会是0.7,所以我们的模型比它好一点。我们可以尝试 MLlib 中的其他模型,如线性 SVM 或随机森林,看看它们是否能提供更好的性能。
但是还有另一个选择:如果你还记得第七章,极限梯度提升 XGBoost,也可以以并行模式运行,并且它可以使用 Apache Spark 来实现。所以这个问题我们试着用一下。要了解如何构建 XGBoost,请参考第七章、极限梯度增强。
为了将 Spark 版本包含到我们的项目中,我们将下面的依赖声明添加到项目中:
<dependency>
<groupId>ml.dmlc</groupId>
<artifactId>xgboost4j-spark</artifactId>
<version>0.7</version>
</dependency>
作为输入,XGBoost 也接受Vector对象的RDD。除此之外,它使用与运行在单台机器上的 XGBoost 相同的参数:模型参数、要构建的树的数量等等。代码看起来是这样的:
Map<String, Object> params = xgbParams();
int nRounds = 20;
int numWorkers = 4;
ObjectiveTrait objective = null;
EvalTrait eval = null;
boolean externalMemoryCache = false;
float nanValue = Float.NaN;
RDD<LabeledPoint> trainData = JavaRDD.toRDD(trainRdd);
XGBoostModel model = XGBoost.train(trainData, params,
nRounds, numWorkers, objective, eval, externalMemoryCache,
nanValue);
这里,xgbParams函数返回我们用于训练的 XGBoost 模型参数的Map。
注意 XGBoost Spark 包装器是用 Scala 写的,不是 Java,所以Map对象实际上是scala.collection.immutable.Map,不是java.util.Map。因此,我们还需要将一个普通的HashMap转换成 Scala Map:
HashMap<String, Object> params = new HashMap<String, Object>();
params.put("eta", 0.3);
params.put("gamma", 0);
params.put("max_depth", 6);
// ... other parameters
Map<String, Object> res = toScala(params);
这里,toScala实用程序方法是这样实现的:
private static <K, V> Map<K, V> toScala(HashMap<K, V> params) {
return JavaConversions.mapAsScalaMap(params)
.toMap(Predef.<Tuple2<K, V>>conforms());
}
它看起来有点奇怪,因为它使用了一些 Scala 特有的特性。但是我们不需要赘述,可以照原样使用。
有了这个,我们将能够训练一个分布式的 XGBoost。然而,对于评估,我们不能遵循与逻辑回归相同的方法。也就是说,我们不能将每一行转换成一个向量,然后针对这个向量运行模型,如果这样做,XGBoost 将抛出一个异常。这样做的原因是这样的操作是相当昂贵的,因为它将试图为每个向量构建DMatrix,并且它将导致显著的速度减慢。
由于我们的验证数据集不是很大,我们可以将整个RDD转换成一个DMatrix:
JavaRDD<LabeledPoint> valRdd = valFeatures.toJavaRDD().map(r -> {
float[] vec = rowToFloatArray(columns, r);
double label = r.getAs("target");
return LabeledPoint.fromDenseVector((float) label, vec);
});
List<LabeledPoint> valPoints = valRdd.collect();
DMatrix data = new DMatrix(valPoints.iterator(), null);
这里,我们遍历DataFrame的行,并将每一行转换成一个LabeledPoint类(来自ml.dmlc.xgboost4j包——不要与org.apache.spark.ml.feature.LabeledPoint混淆)。然后,我们将所有东西收集到一个列表中,并从中创建一个DMatrix。
接下来,我们获得经过训练的模型,并将其应用于这个DMatrix:
Booster xgb = model._booster();
float[][] xgbPred = xgb.predict(new ml.dmlc.xgboost4j.scala.DMatrix(data), false, 20);
然后,我们用于logLoss计算的方法期望得到 double 作为输入,所以让我们将结果转换成 double 的数组:
double[] actual = floatToDouble(data.getLabel());
double[] predicted = unwrapToDouble(xgbPred);
我们已经在第七章、极限梯度提升中使用了这些函数,它们非常简单:floatToDouble只是将一个浮点数组转换成一个双数组,unwrapToDouble将一个列的二维浮点数组转换成一维双数组。
最后,我们可以计算分数:
double logLoss = Metrics.logLoss(actual, predicted);
System.out.printf("log loss: %.4f%n", logLoss);
它显示分数是 0.497,比我们之前的分数有了很大的提高。这里,我们使用了带有默认参数的模型,这些参数通常不是最佳的。我们可以进一步调整模型,你可以在第 7 章、极限梯度提升中找到如何调整 XGBoost 的策略。
选择这里仅仅是因为它的简单性,当涉及到推荐系统时,它通常很难解释。选择一个评估指标通常是非常具体的,我们可以使用 F1 分数、 MAP ( 平均精度)、 NDCG ( 归一化折现累积收益)等分数。除此之外,我们还可以使用在线评估指标,比如用户接受了多少建议链接。
接下来,我们将看到这个模型如何用于建议链接,以及我们如何更好地评估它。
链接建议
到目前为止,我们已经详细讨论了如何为链接预测模型构建特征并训练这些模型。现在我们需要能够使用这样的模型来提出建议。再次,想想你可能认识的人脸书上的横幅——这里我们希望有类似的东西,比如作者你应该用写一篇论文。除此之外,我们将看到如何评估模型,以便在这种情况下评估结果更加直观和清晰。
第一步是在整个训练集上重新训练 XGBoost 模型,不进行训练验证拆分。这很容易做到:在进行分割之前,我们只需根据数据再次拟合模型。
接下来,我们需要处理测试数据集。如果你还记得的话,测试数据集包含了 2014 年和 2015 年发表的所有论文。为了使事情更简单,我们将选择测试用户的子集,并且只向他们提供建议。我们可以这样做:
Dataset<Row> fullTest = df.filter("year >= 2014");
Dataset<Row> testNodes = fullTest.sample(true, 0.05, 1)
.select("node1")
.dropDuplicates();
Dataset<Row> testEdges = fullTest.join(testNodes, "node1");
在这里,我们首先选择测试集,然后从中抽取节点样本——这给出了我们选择进行测试的作者列表。换句话说,只有这些作者会收到推荐。接下来,我们执行完整测试集与所选节点的连接,以获得测试期间建立的所有实际链接。我们稍后将使用这些链接进行评估。
接下来,我们用 id 替换作者的名字。我们用和以前一样的方法来做——用节点DataFrame连接它:
Dataset<Row> join = testEdges.drop("year");
join = join.join(nodes, join.col("node1").equalTo(nodes.col("node")));
join = join.drop("node", "node1").withColumnRenamed("id", "node1");
join = join.join(nodes, join.col("node2").equalTo(nodes.col("node")));
join = join.drop("node", "node2").withColumnRenamed("id", "node2");
Dataset<Row> selected = join;
接下来,我们需要选择我们将应用该模型的候选人。候选人名单应包含最有可能成为潜在合著者的作者名单(即在网络中形成链接)。选择这样的候选人最明显的方法是选择彼此相距一个跳跃点的作者——以同样的方式,我们对硬负面链接进行采样:
Dataset<Row> e1 = selected.select("node1").dropDuplicates();
Dataset<Row> e2 = edges.drop("node1", "node2", "year")
.withColumnRenamed("src", "e2_src")
.withColumnRenamed("dst", "e2_dst")
.as("e2");
Column diffDest = e1.col("node1").notEqual(e2.col("e2_dst"));
Column sameSrc = e1.col("node1").equalTo(e2.col("e2_src"));
Dataset<Row> candidates = e1.join(e2, diffDest.and(sameSrc));
candidates = candidates.select("node1", "e2_dst")
.withColumnRenamed("e2_dst", "node2");
代码几乎是相同的,除了我们不考虑所有可能的硬否定,而只考虑那些与我们预先选择的节点相关的硬否定。
我们假设这些候选人在测试期间没有成为合著者,所以我们添加了带有0.0的目标列:
candidates = candidates.withColumn("target", functions.lit(0.0));
而一般来说,这种假设是不成立的,因为我们只关注从训练阶段获得的联系,很可能其中一些联系实际上是在测试阶段形成的。
让我们通过手动添加阳性候选项,然后删除重复项来解决这个问题:
selected = selected.withColumn("target", functions.lit(1.0));
candidates = selected.union(candidates).dropDuplicates("node1", "node2");
现在,正如我们之前所做的,我们为每个候选边分配一个 id:
candidates = candidates.withColumn("id", functions.monotonicallyIncreasingId());
为了将模型应用于这些候选人,我们需要计算特征。为此,我们只需重用之前编写的代码。首先,我们计算节点特征:
Dataset<Row> nodeFeatures = nodeFeatures(sql, pageRank, connectedComponents, degrees, candidates);
这里,nodeFeatures方法接收我们在训练数据上计算的pageRank、connectedComponents和degree、DataFrame s,并为我们在candidates、DataFrame中经过的边计算所有基于节点的特征。
接下来,我们计算候选对象的基于边缘的特征:
Dataset<Row> commonFriends = calculateCommonFriends(sql, edges, candidates);
Dataset<Row> totalFriends = calculateTotalFriends(sql, edges, candidates);
Dataset<Row> jaccard = calculateJaccard(sql, edges, candidates);
我们将这些特性的实际计算放在效用方法中,并使用一组不同的边来调用它们。
最后,我们只是把所有东西连接在一起:
Dataset<Row> features = candidates.join(commonFriends, "id")
.join(totalFriends, "id")
.join(jaccard, "id")
.join(nodeFeatures, "id");
现在我们准备将 XGBoost 模型应用于这些特性。为此,我们将使用mapPartition函数:它类似于通常的map,但是它不是只接受一个项目,而是同时接受多个项目。这样,我们将同时为多个对象创建一个DMatrix,这将节省时间。
这是我们的做法。首先,我们创建一个特殊的类ScoredEdge,用于保存关于边的节点的信息、由模型分配的分数以及实际的标签:
public class ScoredEdge implements Serializable {
private long node1;
private long node2;
private double score;
private double target;
// constructor, getter and setters are omitted
}
现在我们对候选边进行评分:
JavaRDD<ScoredEdge> scoredRdd = features.toJavaRDD().mapPartitions(rows -> {
List<ScoredEdge> scoredEdges = new ArrayList<>();
List<LabeledPoint> labeled = new ArrayList<>();
while (rows.hasNext()) {
Row r = rows.next();
long node1 = r.getAs("node1");
long node2 = r.getAs("node2");
double target = r.getAs("target");
scoredEdges.add(new ScoredEdge(node1, node2, target));
float[] vec = rowToFloatArray(columns, r);
labeled.add(LabeledPoint.fromDenseVector(0.0f, vec));
}
DMatrix data = new DMatrix(labeled.iterator(), null);
float[][] xgbPred =
xgb.predict(new ml.dmlc.xgboost4j.scala.DMatrix(data), false, 20);
for (int i = 0; i < scoredEdges.size(); i++) {
double pred = xgbPred[i][0];
ScoredEdge edge = scoredEdges.get(i);
edge.setScore(pred);
}
return scoredEdges.iterator();
});
mapPartition函数中的代码执行以下操作:首先,我们检查所有输入行,并为每一行创建一个ScoredEdge类。除此之外,我们还从每一行中提取特征(与我们之前所做的完全相同)。然后我们把所有东西放进DMatrix,用 XGBoost 模型给这个矩阵的每一行打分。最后,我们给ScoredEdge对象打分。作为这一步的结果,我们得到了一个RDD,其中每个候选边都由模型评分。
接下来,我们将为每位用户推荐 10 位潜在的合著者。为此,我们根据ScoredEdge类中的node1进行分组,然后根据每个组中的分数进行排序,只保留前 10 个:
JavaPairRDD<Long, List<ScoredEdge>> topSuggestions = scoredRdd
.keyBy(s -> s.getNode1())
.groupByKey()
.mapValues(es -> takeFirst10(es));
这里,takeFirst10可以这样实现:
private static List<ScoredEdge> takeFirst10(Iterable<ScoredEdge> es) {
Ordering<ScoredEdge> byScore =
Ordering.natural().onResultOf(ScoredEdge::getScore).reverse();
return byScore.leastOf(es, 10);
}
如果你还记得的话,Ordering这里有一个来自 Google Guava 的类,它根据分数进行排序,然后取前 10 条边。
最后看看建议有多好。为此,我们检查了所有的建议,并计算了在测试期间实际形成的这些链接的数量。然后我们取所有组的平均值:
double mp10 = topSuggestions.mapToDouble(es -> {
List<ScoredEdge> es2 = es._2();
double correct = es2.stream().filter(e -> e.getTarget() == 1.0).count();
return correct / es2.size();
}).mean();
System.out.println(mp10);
它所做的是计算每个组的Precision@10(正确分类的边在前 10 个中的部分),然后取其平均值。
当我们运行它时,我们看到分数约为 30%。这不是一个很差的结果:这意味着实际上形成了 30%的推荐边。
尽管如此,这个分数还远远不够理想,还有很多方法可以提高它。在现实生活中的社交网络中,我们通常会使用额外的信息来构建模型。例如,在合著者图表的情况下,我们可以使用来自论文摘要、会议和发表论文的期刊的隶属关系、标题和文本,以及许多其他内容。如果社交图来自脸书这样的网络社交网络,我们可以使用地理信息、团体和社区,最后还有喜欢。通过包含这些信息,我们应该能够实现更好的性能。
摘要
在这一章中,我们看了处理大量数据的方法和特殊工具,如 Apache Hadoop MapReduce 和 Apache Spark。我们看到了如何使用它们来处理常见的抓取-互联网的副本,并从中计算一些有用的统计数据。最后,我们创建了一个推荐合著者的链接预测模型,并以分布式方式训练了一个 XGBoost 模型。
在下一章中,我们将探讨如何将数据科学模型部署到生产系统中。*