精通 Java 数据科学(二)
五、无监督学习——聚类和降维
在前一章中,介绍了 Java 中的机器学习,并讨论了在提供标签信息的情况下如何处理监督学习问题。
然而往往没有标签信息,我们有的只是一些数据。在这种情况下,仍然可以使用机器学习,这类问题称为无监督学习;没有标签,因此没有监督。聚类分析属于这些算法中的一种。给定一些数据集,目标是从那里对项目进行分组,以便将相似的项目放入同一个组中。
此外,当有标签信息时,一些无监督学习技术可能是有用的。
例如,降维算法试图压缩数据集,以便保留大部分信息,并且数据集可以用较少的特征来表示。此外,降维对于执行聚类分析也是有用的,并且聚类分析可以用于执行降维。
我们将在本章中看到如何做到这一切。具体来说,我们将涵盖以下主题:
- 无监督的降维方法,如 PCA 和 SVD
- 聚类分析算法,如 k-means
- Java 中可用的实现
到本章结束时,你将知道如何对你拥有的数据进行聚类,以及如何使用 Smile 和其他 Java 库在 Java 中进行降维。
降维
顾名思义,降维就是降低数据集的维数。也就是说,这些技术试图压缩数据集,以便只保留最有用的信息,而丢弃其余的信息。
数据集的维度是指数据集的特征数量。当维数很高时,即有太多的特征时,由于以下原因,它可能是不好的:
- 如果特征多于数据集的项目,问题就变得难以定义,一些线性模型,如普通最小二乘法 ( OLS )回归无法处理这种情况
- 一些特征可能是相关的,并导致训练和解释模型的问题
- 一些特征可能会变得嘈杂或不相关,并使模型混乱
- 在高维空间中,距离开始变得不那么有意义了——这个问题通常被称为维数灾难
- 处理大量的特征在计算上可能是昂贵的
在高维数的情况下,我们感兴趣的是降低维数,使其变得易于管理。有几种方法可以做到这一点:
- 监督降维方法,如特征选择:我们使用关于标签的信息来帮助我们决定哪些特征是有用的,哪些是无用的
- 无监督的维数减少,例如特征提取:我们不使用关于标签的信息(或者因为我们没有或者不愿意这样做),并试图将大的特征集压缩成较小的特征集
在这一章中,我们将讨论第二种类型,即无监督降维,特别是特征提取。
无监督降维
特征提取算法背后的主要思想是,它们接受一些高维度的数据集,对其进行处理,并返回一个包含更小的新特征集的数据集。
注意,返回的特征是新的,它们是从数据中提取的或学习的。但是这种提取是以这样一种方式进行的,即数据的新表示尽可能多地保留来自原始特征的信息。换句话说,它获取用旧要素表示的数据,对其进行转换,然后返回一个包含全新要素的新数据集。
有许多用于降维的特征提取算法,包括:
- 主成分分析 ( PCA )和奇异值分解 ( SVD )
- 非负矩阵分解 ( NNMF
- 随机投影
- 局部线性嵌入 ( LLE )
- t 雪
在这一章中,我们将讨论主成分分析、奇异值分解和随机投影。其他技术不太流行,在实践中也不常用,所以我们不会在本书中讨论它们。
主成分分析
主成分分析 ( PCA )是最著名的特征提取算法。PCA 学习的新特征表示是原始特征的线性组合,使得原始数据内的变化被尽可能地保留。
让我们来看看这个算法的运行情况。首先,我们将考虑我们已经使用的性能预测数据集。对于这个问题,特征的数量比较大;在使用 one-hot-encoding 对分类变量进行编码后,有超过 1000 个特征,而只有 5000 个观察值。显然,1000 个特征对于这样小的样本量来说是相当多的,这可能会在建立机器学习模型时造成问题。
让我们看看是否可以在不损害模型性能的情况下降低数据集的维度。
但首先,让我们回忆一下 PCA 是如何工作的。通常需要完成以下步骤:
- 首先,对数据集执行均值归一化——转换数据集,使每一列的平均值为零。
- 然后,计算协方差或相关矩阵。
- 之后,进行协方差/相关矩阵的特征值分解(【EVD】)或奇异值分解 ( SVD )。
- 结果是一组主成分,每个主成分解释了部分方差。主成分通常是有序的,第一个成分解释了大部分的差异,最后一个成分解释了很少的差异。
- 在最后一步中,我们丢弃那些没有方差的成分,只保留方差大的第一主成分。为了选择要保留的成分数量,我们通常使用解释方差与总方差的累积比率。
- 我们使用这些组件通过在由这些组件形成的基础上执行原始数据的投影来压缩原始数据集。
- 完成这些步骤后,我们得到了一个包含较少要素的数据集,但原始数据集的大部分信息都保留了下来。
在 Java 中有很多方法可以实现 PCA,但是我们可以使用其中一个库,比如 Smile,它提供了现成的实现。在 Smile 中,PCA 已经执行了均值归一化,然后计算协方差矩阵,并自动决定是使用 EVD 还是奇异值分解。我们只需要给它一个数据矩阵,剩下的事情它会做。
通常,对协方差矩阵执行 PCA,但有时,当一些原始要素处于不同的比例时,解释方差的比率可能会产生误导。
例如,如果我们拥有的一个要素是以千米为单位的距离,另一个是以毫秒为单位的时间,那么第二个要素将具有更大的方差,因为第二个要素中的数字要大得多。因此,该特征将在最终组件中占主导地位。
为了克服这个问题,我们可以使用相关矩阵代替协方差矩阵,并且由于相关系数是无单位的,因此 PCA 结果不会受到不同尺度的影响。或者,我们可以对数据集中的要素进行标准化,实际上,计算协方差与计算相关性是一样的。
因此,首先我们将使用之前编写的StandardizationPreprocessor来标准化数据:
StandardizationPreprocessor preprocessor = StandardizationPreprocessor.train(dataset);
dataset = preprocessor.transform(dataset)
然后,我们可以对转换后的数据集运行 PCA,并查看累积方差:
PCA pca = new PCA(dataset.getX(), false);
double[] variance = pca.getCumulativeVarianceProportion();
System.out.println(Arrays.toString(variance));
如果我们获取输出并绘制前一百个组件,我们将看到下图:
我们可以看到,主成分解释了大约 67%的方差,累积解释率在小于 40 成分时很快达到 95%,在 61 成分时达到 99%,在 80 成分时几乎达到 100%。这意味着,如果我们只取第一个 80 分量,就足以捕获数据集中几乎所有的方差。这意味着我们应该能够安全地将 1000 多个维度的数据集压缩到 80 个维度。
我们来测试一下。首先,让我们试着不用 PCA 做 OLS。我们将采用上一章的代码:
Dataset train = trainTestSplit.getTrain();
List<Split> folds = train.shuffleKFold(3);
DescriptiveStatistics ols = crossValidate(folds, data -> {
return new OLS(data.getX(), data.getY());
});
这将打印以下输出:
ols: rmse=15.8679 ± 3.4587
现在,让我们尝试将主成分的数量限制在 95%、99%和 99.9%的水平,并看看错误会发生什么:
double[] ratios = { 0.95, 0.99, 0.999 };
for (double ratio : ratios) {
pca = pca.setProjection(ratio);
double[][] projectedX = pca.project(train.getX());
Dataset projected = new Dataset(projectedX, train.getY());
folds = projected.shuffleKFold(3);
ols = crossValidate(folds, data -> {
return new OLS(data.getX(), data.getY());
});
double mean = ols.getMean();
double std = ols.getStandardDeviation()
System.out.printf("ols (%.3f): rmse=%.4f ± %.4f%n", ratio, mean, std);
}
这会产生以下输出:
ols (0.950): rmse=18.3331 ± 3.6308
ols (0.990): rmse=16.0702 ± 3.5046
ols (0.999): rmse=15.8656 ± 3.4625
正如我们所见,保持 99.9%的 PCA 方差给出了与原始数据集上的 OLS 回归拟合相同的性能。对于该数据集,99.9%的方差仅由 84 个主成分解释,而原始数据集中有 1070 个特征。因此,我们设法在不损失任何性能的情况下,通过仅保留原始数据大小的 7.8%来减少数据的维度。
然而有时候,从性能上来说,Smile 和其他类似包的 PCA 实现并不是最好的。接下来,我们将看到为什么以及如何处理它。
截断奇异值分解
前面的代码(在本例中,使用 Smile)通过完整的 SVD 或 EVD 执行完整的 PCA。这里, full 是指它计算所有的特征值和特征向量,可能计算量很大,特别是当我们只需要前 7.8%的主成分时。然而,我们不必总是计算完整的 PCA,而是可以使用截断的 SVD。截断 SVD 只计算指定数量的主分量,通常比完整版本快得多。
Smile 还提供了截断 SVD 的实现。但是在使用之前,我们先快速修改一下 SVD。
矩阵 X 的 SVD 计算 X 的行和列的基底,使得:
***XV = US ***
这里,该等式解释如下:
- V 的列形成了 X 的行的基础
- U 的列形成了 X 的行的基础
- S 是奇异值为 X 的对角矩阵
通常,SVD 是这样写的:
于是,SVD 将矩阵 X 分解成三个矩阵 U 、 *S、*和 V 。
当 SVD 截断到维数 K 时,矩阵 U 和 V 只有 K 列,我们只计算 K 奇异值。如果我们随后将原始矩阵 X 乘以截断的 V ,或者将 S 乘以 U ,我们将获得 X 的行到这个新的 SVD 基的缩减投影。
这将把原始矩阵带到新的约简空间,我们可以使用结果作为特征而不是原始的特征。
现在,我们准备应用它。在微笑中,它看起来像这样:
double[][] X = ... // X is mean-centered
Matrix matrix = new Matrix(X);
SingularValueDecomposition svd = SingularValueDecomposition.decompose(matrix, 100);
这里,Matrix是一个来自 Smile 的类,用于存储密集矩阵。矩阵 U 、 *S、*和 V 作为二维双精度数组返回到SingularValueDecomposition对象内, U 和 V 作为一维双精度数组返回到 S 。
现在,我们需要得到数据矩阵 X 的简化表示。正如我们前面讨论的,有两种方法可以做到这一点:
- 通过计算
- 通过计算
首先,我们来看看计算。
在 Smile 中,SingularValueDecomposition的decompose方法将的返回为一个双精度一维数组,因此我们需要将其转换为矩阵形式。我们可以利用 S 是对角线的这一事实,用它来加速乘法运算。
让我们使用公共数学图书馆。对角矩阵有一个特殊的实现,所以我们将使用它,通常的数组支持矩阵用于 U 。
DiagonalMatrix S = new DiagonalMatrix(svd.getSingularValues());
Array2DRowRealMatrix U = new Array2DRowRealMatrix(svd.getU(), false);
现在我们可以将这两个矩阵相乘:
RealMatrix result = S.multiply(U.transpose()).transpose();
double[][] data = result.getData();
注意,我们不是将 U 乘以 S ,而是反方向进行,然后转置:这利用了 S 是对角线的优势,使得矩阵乘法快了很多。最后,我们提取要在 Smile 中使用的 doubles 数组。
如果我们把这个代码用于预测性能的问题,用时不到 4 秒,这还包括矩阵乘法部分。相对于完整的 PCA 版本,这是一个很大的速度提高,在我们的笔记本电脑上,需要 1 分多钟。
另一种计算投影的方法是计算。让我们再一次使用公地数学:
Array2DRowRealMatrix X = new Array2DRowRealMatrix(dataX, false);
Array2DRowRealMatrix V = new Array2DRowRealMatrix(svd.getV(), false);
double[][] data = X.multiply(V).getData();
这比计算花费的时间稍多,因为两个矩阵都不是对角矩阵。然而,速度上的差异只是微不足道的:对于性能预测问题,以这种方式计算 SVD 和降低维数花费的时间不到 5 秒。
当你使用 SVD 对训练数据进行降维时,这两种方法没有区别。然而,我们不能将方法应用于新的未知数据,因为 U 和 S 都是为矩阵 *X、*产生的,我们为其训练 SVD。相反,我们使用
方法。注意,在这种情况下, X 将是包含测试数据的新矩阵,而不是我们用于训练 SVD 的同一个 X 。
在代码中,它看起来像这样:
double[] trainX = ...;
double[] testX = ...;
Matrix matrix = new Matrix(trainX);
SingularValueDecomposition svd = SingularValueDecomposition.decompose(matrix, 100);
double[][] trainProjected = mmult(trainX, svd.getV());
double[][] testProjected = mmult(testX, svd.getV());
这里,mmult是将矩阵 X 乘以矩阵 V 的方法。
还有另一个实现细节:在 Smile 的 PCA 实现中,我们使用解释方差的比率来确定所需的维数。回想一下,我们通过在PCA对象上调用getCumulativeVarianceProportion来实现这一点,并且通常保持足够高的组件数量,以获得至少 95%或 99%的方差。
但是,由于我们直接使用 SVD,所以我们现在不知道这个比值。这意味着为了能够选择正确的维度,我们需要自己实现它。幸运的是,做起来并不复杂;首先,我们需要计算数据集的总体方差,然后计算所有主成分的方差。后者可以从奇异值中获得(矩阵 S )。奇异值对应于标准差,所以要得到方差,我们只需要对它们求平方。最后,求比值很简单,我们只需要一个除以另一个。
让我们看看它在代码中的样子。首先,我们使用 Commons Math 来计算总方差:
Array2DRowRealMatrix matrix = new Array2DRowRealMatrix(dataset.getX(), false);
int ncols = matrix.getColumnDimension();
double totalVariance = 0.0;
for (int col = 0; col < ncols; col++) {
double[] column = matrix.getColumn(col);
DescriptiveStatistics stats = new DescriptiveStatistics(column);
totalVariance = totalVariance + stats.getVariance();
}
现在,我们可以根据奇异值计算累积比率:
int nrows = X.length;
double[] singularValues = svd.getSingularValues();
double[] cumulatedRatio = new double[singularValues.length];
double acc = 0.0;
for (int i = 0; i < singularValues.length; i++) {
double s = singularValues[i];
double ratio = (s * s / nrows) / totalVariance;
acc = acc + ratio;
cumulatedRatio[i] = acc;
}
运行这段代码后,cumulatedRatio数组将包含所需的比率。结果应该与来自pca.getCumulativeVarianceProportion()的 Smile 的 PCA 实现完全相同。
分类和稀疏数据的截断奇异值分解
对于包含许多分类变量的数据集,降维非常有用,尤其是当这些变量中的每一个都有许多可能的值时。
当我们有非常高维的稀疏矩阵时,计算全奇异值分解通常是非常昂贵的。因此,截断 SVD 特别适合这种情况,在这里我们将看到如何使用它。在下一章的后面,我们会看到这对于文本数据也是非常有用的,我们将在下一章讨论这种情况。现在,我们将看看如何将它用于分类变量。
为此,我们将使用来自 Kaggle 的客户投诉数据集。你可以从这里下载:www.kaggle.com/cfpb/us-con…。
该数据集包含银行和其他金融机构的客户提交的投诉,还包含有关这些投诉的其他信息,如下所示:
- 投诉的产品可以是按揭贷款、助学贷款、*讨债、*等。有 11 种产品。
- 关于产品的举报问题,如不正确信息、*虚假陈述、*等。共有 95 种问题。
- 被投诉的公司,3000 多家。
submitted_via是投诉的发送方式,6 个可能选项,例如,网络和 电子邮件。- 州和邮政编码分别是 63 和 27,000 个可能值。
consumer_complaint_narrative是问题的自由文本描述。
我们看到在这个数据集中有大量的分类变量。正如我们在前面章节中已经讨论过的,编码分类变量的典型方式是一次热编码(也称为虚拟编码)。这个想法是,对于一个变量的每个可能的值,我们创建一个单独的特性,如果一个项目有这个特定的值,就把值1放在那里。所有其他可能值的列都有0。
实现这一点的最简单的方法是使用特性散列,这有时被称为散列技巧。
按照以下步骤可以很容易地做到这一点:
- 我们预先指定稀疏矩阵的维数,为此我们取一个相当大的数
- 然后,对于每个值,我们计算这个值的散列
- 使用散列,我们计算稀疏矩阵中的列数,并将该列的值设置为
1
所以,让我们试着去实现它。首先,我们加载数据集并只保留分类变量:
DataFrame<Object> categorical = dataframe.retain("product", "sub_product", "issue",
"sub_issue", "company_public_response", "company",
"state", "zipcode", "consumer_consent_provided",
"submitted_via");
现在,让我们实现特性散列来编码它们:
int dim = 50_000;
SparseDataset result = new SparseDataset(dim);
int ncolOriginal = categorical.size();
ListIterator<List<Object>> rows = categorical.iterrows();
while (rows.hasNext()) {
int rowIdx = rows.nextIndex();
List<Object> row = rows.next();
for (int colIdx = 0; colIdx < ncolOriginal; colIdx++) {
Object val = row.get(colIdx);
String stringValue = colIdx + "_" + Objects.toString(val);
int targetColIdx = Math.abs(stringValue.hashCode()) % dim;
result.set(rowIdx, targetColIdx, 1.0);
}
}
这里发生的事情是,我们首先创建一个SparseDataset——一个来自 Smile 的类,用于保存基于行的稀疏矩阵。接下来,我们说矩阵应该具有由变量dim指定的维度。dim的值应该足够高,这样碰撞的几率就不会很高。然而,通常情况下,如果有冲突,也没什么大不了的。
如果您将 dim 的值设置为一个非常大的数字,那么当我们稍后分解矩阵时,可能会出现一些性能问题。
特征散列是一种非常简单的方法,并且在实践中经常非常有效。还有另一种方法,实现起来更复杂,但它确保没有哈希冲突。为此,我们构建一个从所有可能的值到列索引的映射,然后构建稀疏矩阵。
构建地图将如下所示:
Map<String, Integer> valueToIndex = new HashMap<>();
List<Object> columns = new ArrayList<>(categorical.columns());
int ncol = 0;
for (Object name : columns) {
List<Object> column = categorical.col(name);
Set<Object> distinct = new HashSet<>(column);
for (Object val : distinct) {
String stringValue = Objects.toString(name) + "_" + Objects.toString(val);
valueToIndex.put(stringValue, ncol);
ncol++;
}
}
ncol变量包含列数,这是我们未来稀疏矩阵的维数。现在我们可以构建实际的矩阵。这与我们之前的内容非常相似,但是我们现在在映射中查找索引,而不是散列:
SparseDataset result = new SparseDataset(ncol);
ListIterator<List<Object>> rows = categorical.iterrows();
while (rows.hasNext()) {
int rowIdx = rows.nextIndex();
List<Object> row = rows.next();
for (int colIdx = 0; colIdx < columns.size(); colIdx++) {
Object name = columns.get(colIdx);
Object val = row.get(colIdx);
String stringValue = Objects.toString(name) + "_" + Objects.toString(val);
int targetColIdx = valueToIndex.get(stringValue);
result.set(rowIdx, targetColIdx, 1.0);
}
}
这样做之后,我们有了一个SparseDataset对象,它包含基于行格式的数据。接下来,我们需要能够将它放到 SVD 求解器中,为此我们需要将它转换成不同的基于列的格式。这是在SparseMatrix类中实现的。幸运的是,SparseDataset类中有一个特殊的方法来完成转换,所以我们使用它:
SparseMatrix matrix = dataset.toSparseMatrix();
SingularValueDecomposition svd = SingularValueDecomposition.decompose(matrix, 100);
分解相当快;计算特征散列矩阵的 SVD 花费了大约 28 秒,而通常的一次热编码花费了大约 24 秒。记住这个数据集中有 50 万行,所以速度相当不错。据我们所知,SVD 的其他 Java 实现不能提供同样的性能。
现在,当计算 SVD 时,我们需要将原始矩阵投影到缩减的空间,就像我们之前在密集矩阵的情况下所做的那样。
由于 U 和 S 都是密集的,所以投影可以完全像以前一样进行。但是 X 是稀疏的,我们需要找到一种高效地将稀疏的 X 和密集的 X 相乘的方法。
不幸的是,Smile 和 Commons Math 都没有合适的实现。因此,我们需要使用另一个库,这个问题可以用Matrix Java Toolkit(MTJ)来解决。这个库基于 netlib-java,它是 BLAS、LAPACK 和 ARPACK 等低级高性能库的包装器。你可以在它的 GitHub 页面上了解更多:【github.com/fommil/matr…](github.com/fommil/matr…)
由于我们使用 Maven,它将负责下载二进制依赖项并将它们链接到项目。我们需要做的只是指定以下依赖关系:
<dependency>
<groupId>com.googlecode.matrix-toolkits-java</groupId>
<artifactId>mtj</artifactId>
<version>1.0.2</version>
</dependency>
我们需要用两个矩阵相乘, X 和 V,,条件是 X 稀疏而 V 稠密。由于 X 位于乘法运算符的左侧,存储 X 的值的最有效方式是基于行的稀疏矩阵表示。对于 V,最有效的表示是基于列的密集矩阵。
但是在我们这样做之前,我们首先需要将 Smile 的SparseDataset转换成 MTJ 的稀疏矩阵。为此,我们使用了一个特殊的构建器:FlexCompRowMatrix类,它适合于用值填充矩阵,但不太适合乘法。一旦我们构建了矩阵,我们就把它转换成CompRowMatrix,它有一个更有效的内部表示,并且更适合于乘法目的。
我们是这样做的:
SparseDataset dataset = ... //
int ncols = dataset.ncols();
int nrows = dataset.size();
FlexCompRowMatrix builder = new FlexCompRowMatrix(nrows, ncols);
SparseArray[] array = dataset.toArray(new SparseArray[0]);
for (int rowIdx = 0; rowIdx < array.length; rowIdx++) {
Iterator<Entry> row = array[rowIdx].iterator();
while (row.hasNext()) {
Entry entry = row.next();
builder.set(rowIdx, entry.i, entry.x);
}
}
CompRowMatrix X = new CompRowMatrix(builder);
第二步是创建一个密集的矩阵。这一步更简单:
DenseMatrix V = new DenseMatrix(svd.getV());
在内部,MTJ 按列存储密集矩阵,这对于我们的目的来说是理想的。
接下来,我们需要创建一个矩阵对象,它将包含结果,然后我们将 X 乘以 V :
DenseMatrix XV = new DenseMatrix(X.numRows(), V.numColumns());
X.mult(V, XV);
最后,我们需要从结果矩阵中提取双数组数据。出于性能考虑,MTJ 将数据存储为一维双数组,因此我们需要将其转换为传统的表示形式。我们这样做:
double[] data = XV.getData();
int nrows = XV.numRows();
int ncols = XV.numColumns();
double[][] result = new double[nrows][ncols];
for (int col = 0; col < ncols; col++) {
for (int row = 0; row < nrows; row++) {
result[row][col] = data[row + col * nrows];
}
}
最后,我们得到了结果数组,它捕获了原始数据集的大部分可变性,并且我们可以将其用于需要小的密集矩阵的情况。
这种转换对于本章的第二个主题:集群特别有用。通常,我们使用距离来聚类数据点,但当涉及到高维空间时,距离不再有意义,这种现象被称为维度的曲线。然而,在缩减的 SVD 空间中,距离仍然有意义,并且当我们应用聚类分析时,结果通常更好。
这对于处理自然语言文本也是一种非常有用的方法,因为通常文本被表示为非常高维和非常稀疏的矩阵。我们将在第六章、处理文本-自然语言处理和信息检索中回到这个话题。
注意,与通常的 PCA 情况不同,我们在这里不执行均值居中。这有几个原因:
- 如果我们这样做,矩阵将变得密集,并将占用太多的内存,因此不可能在合理的时间内处理它
- 在稀疏矩阵中,平均值已经非常接近于零,因此没有必要执行平均值归一化
接下来,我们来看一种不同的降维技术,这种技术非常简单,不需要学习,而且速度非常快。
随机投影
主成分分析试图在数据中找到某种结构,并利用它来降低维数;它找到了这样一个基础,在这个基础上,原始方差的大部分被保留下来。但是,有一种替代方法,而不是试图学习基础,只是随机生成它,然后将原始数据投影到它上面。
令人惊讶的是,这个简单的想法在实践中非常有效。原因是这种变换保持了距离。这意味着,如果我们在原始空间中有两个彼此靠近的物体,那么,当我们应用投影时,它们仍然保持靠近。同样地,如果物体彼此远离,那么它们将在新的缩减空间中保持远离。
Smile 已经实现了随机投影,它接受输入维度和期望的输出维度:
double[][] X = ... // data
int inputDimension = X[0].length;
int outputDimension = 100;
smile.math.Math.setSeed(1);
RandomProjection rp = new RandomProjection(inputDimension, outputDimension);
注意,我们为随机数生成器显式设置了种子;由于随机投影的基础是随机生成的,我们希望确保可重复性。
只有在版本 1.2.1 中才可以设置种子,在撰写本文时,Maven Central 上还没有这个功能。
它通过以下方式在 Smile 中实现:
- 首先,从高斯分布中抽取一组随机向量
- 然后,通过 Gram-Schmidt 算法使向量正交,也就是说,首先使它们正交,然后将长度归一化为 1
- 投影是在这个标准正交基上进行的
让我们用它来进行性能预测,然后拟合通常的 OLS:
double[][] X = dataset.getX();
int inputDimension = X[0].length;
int outputDimension = 100;
smile.math.Math.setSeed(1);
RandomProjection rp = new RandomProjection(inputDimension, outputDimension);
double[][] projected = rp.project(X);
dataset = new Dataset(projected, dataset.getY());
Split trainTestSplit = dataset.shuffleSplit(0.3);
Dataset train = trainTestSplit.getTrain();
List<Split> folds = train.shuffleKFold(3);
DescriptiveStatistics ols = crossValidate(folds, data -> {
return new OLS(data.getX(), data.getY());
});
System.out.printf("ols: rmse=%.4f ± %.4f%n", ols.getMean(), ols.getStandardDeviation());
它非常快(在我们的笔记本电脑上不到一秒钟),并且该代码产生以下结果:
ols: rmse=15.8455 ± 3.3843
结果与平原 OLS 或 OLS 的 PCA 结果非常相似,方差为 99.9%。
然而,来自 Smile 的实现只适用于密集矩阵,在撰写本文时还不支持稀疏矩阵。因为这个方法非常简单,所以我们自己实现它并不困难。让我们实现一个生成随机基的简化版本。
为了生成基,我们从均值为零且标准差等于1 / new_dimensionality的高斯分布中采样,其中new_dimensionality是新的缩减空间的期望维度。
让我们用公地数学来计算:
NormalDistribution normal = new NormalDistribution(0.0, 1.0 / outputDimension);
normal.reseedRandomGenerator(seed);
double[][] result = new double[inputDimension][];
for (int i = 0; i < inputDimension; i++) {
result[i] = normal.sample(outputDimension);
}
这里,我们有以下参数:
inputDimension:这是我们要投影的矩阵的维数,也就是这个矩阵的列数outputDimension:这是期望的投影维度seed:这是用于再现性的随机数发生器种子
首先,让我们检查实现的合理性,并将其应用于相同的性能问题。尽管它很密集,但对于测试目的来说已经足够了:
double[][] X = dataset.getX();
int inputDimension = X[0].length;
int outputDimension = 100;
int seed = 1;
double[][] basis = Projections.randomProjection(inputDimension, outputDimension, seed);
double[][] projected = Projections.project(X, basis);
dataset = new Dataset(projected, dataset.getY());
Split trainTestSplit = dataset.shuffleSplit(0.3);
Dataset train = trainTestSplit.getTrain();
List<Split> folds = train.shuffleKFold(3);
DescriptiveStatistics ols = crossValidate(folds, data -> {
return new OLS(data.getX(), data.getY());
});
System.out.printf("ols: rmse=%.4f ± %.4f%n", ols.getMean(), ols.getStandardDeviation());
这里我们有两种方法:
- 这产生了我们之前实现的随机基础。
Projections.project:将矩阵 X 投影到基底上,通过将矩阵 X 乘以基底的矩阵来实现。
运行代码后,我们会看到以下输出:
ols: rmse=15.8771 ± 3.4332
这表明我们的实现已经通过了健全性检查,结果是有意义的,并且方法被正确地实现了。
现在我们需要改变投影方法,使其可以应用于稀疏矩阵。我们已经完成了,但是让我们再来看一下大纲:
- 将稀疏矩阵放入
RompRowMatrix、压缩行存储 ( CRS 矩阵中 - 将基础放入
DenseMatrix - 将矩阵相乘,并将结果写入
DenseMatrix - 将来自
DenseMatrix的底层数据展开成一个二维双数组
对于投诉数据集中的分类示例,如下所示:
DataFrame<Object> categorical = ... // data
SparseDataset sparse = OHE.hashingEncoding(categorical, 50_000);
double[][] basis = Projections.randomProjection(50_000, 100, 0);
double[][] proj = Projections.project(sparse, basis);
这里,我们创建了一些助手方法:
OHE.hashingEncoding:这将对来自分类数据DataFrame的分类数据进行一次热编码Projections.randomProjection:生成一个随机的基础Projections.project:这在这个生成的基础上投射我们的稀疏矩阵
我们之前已经为这些方法编写了代码,这里为了方便起见,我们将它们放在了 helper 方法中。当然,像往常一样,您可以在为本章提供的代码包中看到完整的代码。到目前为止,我们只讨论了无监督学习降维的一组技术。还有聚类分析,我们将在接下来讨论。有趣的是,聚类也可以用于降低数据集的维度,很快我们就会看到如何实现。
聚类分析
聚类或聚类分析是另一种无监督学习算法。聚类的目标是将数据组织成簇,使得相似的项目出现在同一个簇中,而不相似的项目出现在不同的簇中。
执行聚类分析有许多不同的算法系列,它们在元素分组方式上有所不同。
最常见的系列如下:
- Hierarchical :这将数据集组织成一个层次结构,例如凝聚和分裂聚类。结果通常是一个树状图。
- 分割:这将数据集分割成 K 个不相交的类——K通常是预先指定的——例如, K 意味着。
- 基于密度:基于密度区域组织项目;如果在一些密集的区域中有许多项目,它们形成一个簇,例如 DBSCAN。
- 基于图形的:这将项目之间的关系表示为图形,并应用图论中的分组算法,例如,连接组件和最小生成树。
分层方法
分层方法被认为是最简单的聚类算法;它们很容易理解和解释。聚类方法有两个家族,它们属于等级家族:
- 分裂聚类算法
- 凝聚聚类算法
在分裂法中,我们将所有数据项放入一个簇中,在每一步中,我们选取一个簇,然后将其分成两半,直到每个元素都是自己的簇。因此,这种方法有时被称为自顶向下聚类。
凝聚聚类方法是相反的;开始的时候,每个数据点都属于自己的聚类,然后在每一步,我们选择两个最接近的聚类进行合并,直到只剩下一个大的聚类。这也叫做自下而上的方法。
尽管有两种类型的层次聚类算法,但当人们说层次聚类时,他们通常指的是聚集聚类,这些算法更常见。所以让我们仔细看看它们。
在凝聚聚类中,在每一步,我们合并两个最接近的聚类,但是根据我们如何定义最接近,结果可能会有很大的不同。
合并两个集群的过程通常被称为链接,链接描述了两个集群之间的距离是如何计算的。
链接有多种类型,最常见的如下:
- 单链:两个簇之间的距离是最近的两个元素之间的距离。
- 完全连锁:两个集群之间的距离是两个最远元素之间的距离。
- 平均连锁(有时也称为 UPGMA 连锁):聚类之间的距离是质心之间的距离,其中质心是该聚类所有项目的平均值。
这些方法通常适用于较小规模的数据集,并且非常适用。但是对于较大的数据集,它们通常不太有用,并且要花很多时间才能完成。尽管如此,它甚至可以用于更大的数据集,但我们需要一些可管理规模的样本。
让我们看看例子。我们可以使用之前使用的投诉数据集,通过 One-Hot-Encoding 对分类变量进行编码。如果你还记得的话,我们然后通过使用 SVD 将带有分类变量的稀疏矩阵转化为更小维度的密集矩阵。数据集非常大,很难处理,所以我们先从其中抽取 10,000 条记录作为样本:
double[] data = ... // our data in the reduced SVD space
int size = 10000; // sample size
long seed = 0; // seed number for reproducibility
Random rnd = new Random(seed);
int[] idx = rnd.ints(0, data.length).distinct().limit(size).toArray();
double[][] sample = new double[size][];
for (int i = 0; i < size; i++) {
sample[i] = data[idx[i]];
}
data = sample;
我们在这里做的是从随机数生成器中获取一个不同的整数流,然后将其限制为 10,000。然后我们用这些整数作为样本的索引。
在准备好数据并提取样本后,我们可以尝试对该数据集进行凝聚聚类分析。我们之前讨论的大多数机器学习库都有聚类算法的实现,所以我们可以使用它们中的任何一个。因为我们已经广泛使用了 Smile,所以在本章中,我们还将使用 Smile 的实现。
当我们使用它时,首先需要指定的是联动。为了指定链接并创建一个Linkage对象,我们首先需要计算一个邻近矩阵——一个包含数据集中每对对象之间距离的矩阵。我们可以使用任何距离度量,但我们将采用最常用的一种,欧几里德距离。回想一下,欧几里得距离是两个向量之差的范数。为了有效地计算它,我们可以使用下面的分解:
我们把距离的平方表示成内积,然后分解。接下来,我们认识到这是各个向量的范数之和减去它们的乘积:
这是我们可以用来有效计算邻近矩阵的公式,邻近矩阵是每对项目之间的距离矩阵。在这个公式中,我们有对的内积,它可以通过使用矩阵乘法来有效地计算。让我们看看如何将这个公式翻译成代码。前两个部分是单独的标准,让我们来计算它们:
int nrow = data.length;
double[] squared = new double[nrow];
for (int i = 0; i < nrow; i++) {
double[] row = data[i];
double res = 0.0;
for (int j = 0; j < row.length; j++) {
res = res + row[j] * row[j];
}
squared[i] = res;
}
当涉及到内积时,它只是数据矩阵与其转置的矩阵乘法。我们可以用 Java 中的任何数学软件包来计算它。例如,使用 Commons Math:
Array2DRowRealMatrix m = new Array2DRowRealMatrix(data, false);
double[][] product = m.multiply(m.transpose()).getData();
最后,我们将这些组件放在一起计算邻近矩阵:
double[][] dist = new double[nrow][nrow];
for (int i = 0; i < nrow; i++) {
for (int j = i + 1; j < nrow; j++) {
double d = squared[i] - 2 * product[i][j] + squared[j];
dist[i][j] = dist[j][i] = d;
}
}
因为距离矩阵是对称的,所以我们可以节省时间,只在一半的索引上循环。i == j的时候不用盖案子。
我们还可以使用其他的距离度量:这对于Linkage类来说无关紧要。比如不用欧氏距离,我们可以取另一个,比如余弦距离。
余弦距离是两个向量之间相异度的另一种度量,它基于余弦相似度。余弦相似度在几何上对应于两个向量之间的角度,它是使用内积计算的:
这里的内积除以每个向量的范数。但是如果向量已经归一化了,也就是有范数,等于 1,那么余弦就是内积。如果余弦相似度等于 1,则向量完全相同。
余弦距离与余弦相似度相反:当向量相同时,它应该等于零,因此我们可以通过从 1 中减去它来计算它:
因为这里有内积,所以使用矩阵乘法很容易计算这个距离。
我们来实施吧。首先,我们对数据矩阵的每个行向量进行单位归一化:
int nrow = data.length;
double[][] normalized = new double[nrow][];
for (int i = 0; i < nrow; i++) {
double[] row = data[i].clone();
normalized[i] = row;
double norm = new ArrayRealVector(row, false).getNorm();
for (int j = 0; j < row.length; j++) {
row[j] = row[j] / norm;
}
}
现在,我们可以将归一化矩阵相乘,以获得余弦相似度:
Array2DRowRealMatrix m = new Array2DRowRealMatrix(normalized, false);
double[][] cosine = m.multiply(m.transpose()).getData();
最后,我们通过从 1:
for (int i = 0; i < nrow; i++) {
double[] row = cosine[i];
for (int j = 0; j < row.length; j++) {
row[j] = 1 - row[j];
}
}
现在,可以将计算出的矩阵传递给Linkage实例。正如我们提到的,任何距离度量都可以与层次聚类一起使用,这是一个很好的属性,是其他聚类方法通常所缺乏的。
现在让我们使用计算出的距离矩阵进行聚类:
double[][] proximity = calcualateSquaredEuclidean(data);
Linkage linkage = new UPGMALinkage(proximity);
HierarchicalClustering hc = new HierarchicalClustering(linkage);
在凝聚聚类中,我们将两个最相似的聚类合并,然后重复这个过程,直到只剩下一个聚类。这个合并过程可以用树状图来形象化。为了用 Java 绘制它,我们可以使用 Smile 自带的绘图库。
为了说明如何做到这一点,让我们首先只对几个项目进行采样并应用聚类。然后我们可以得到类似下图的东西:
在底部的 x 轴上,我们有被合并到集群中的项目。在 y 轴上,我们有聚类合并的距离。
为了创建绘图,我们使用以下代码:
Frame frame = new JFrame("Dendrogram");
frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
PlotCanvas dendrogram = Dendrogram.plot(hc.getTree(), hc.getHeight());
frame.add(dendrogram);
frame.setSize(new Dimension(1000, 1000));
frame.setLocationRelativeTo(null);
frame.setVisible(true);
当我们需要分析生成的集群时,这种可视化非常有用。因为我们知道合并完成的距离(在 y 轴上),我们可以知道从数据中提取多少个集群是有意义的。例如,在大约 21 之后,合并变得彼此相当遥远,这可能暗示有 5 个集群。
为了得到这些聚类,我们可以在某个距离阈值处切割树状图。如果一些元素在低于阈值的距离处被合并,则它们保持在相同的聚类内。否则,如果它们在阈值以上的距离处合并,它们将被视为单独的聚类。
对于前面的树状图,如果我们在 23 的高度切割,我们应该得到 5 个独立的聚类。我们可以这样做:
double height = 23.0;
int[] labels = hc.partition(height);
或者,我们可以要求特定数量的集群:
int k = 5;
int[] labels = hc.partition(k);
分层集群有几个优点:
- 它可以与任何距离函数一起工作,它所需要的只是一个距离矩阵,所以任何函数都可以用来创建矩阵
- 通过这种聚类,很容易得出聚类的数量
然而,也有一些缺点:
- 它不适用于数据集中的大量项目——距离矩阵很难适应内存。
- 它通常比其他方法慢,尤其是当使用一些链接时。
还有另一种非常流行的方法,它非常适合大型数据集,接下来我们将讨论它。
k 均值
正如我们之前提到的,凝聚聚类方法对于小数据集非常有效,但是对于大数据集却有一些问题。K -means 是另一种流行的聚类技术,它没有这个问题。
K -means 是一种聚类方法,属于聚类算法的划分家族:给定簇数 K , K -Means 将数据分割成 K 个不相交的组。 使用质心将项目分组为簇。质心代表一个聚类的“中心”,对于每个项目,我们将其分配到与其最近的质心的组中。聚类的质量通过失真来衡量——每个项目与其质心之间的距离之和。
与凝聚式集群一样,Java 中有多种实现方式可以使用 K -Means,和前面一样,我们将使用来自 Smile 的实现方式。不幸的是,它不支持稀疏矩阵,只能处理密集矩阵。如果我们想将其用于稀疏数据,我们要么需要将其转换为稠密矩阵,要么用奇异值分解或随机投影来降低其维数。
让我们再次使用投诉的分类数据集,并用 SVD 将其投射到30组件:
SingularValueDecomposition svd = SingularValueDecomposition.decompose(sparse.toSparseMatrix(), 30);
double[][] proj = Projections.project(sparse, svd.getV());
正如我们在这里看到的, K -means 在 Smile 中的实现接受四个参数:
- 我们要聚类的矩阵
- 我们想要找到的集群的数量
- 要运行的迭代次数
- 选择最佳方案之前的试验次数
K -means 优化数据集的失真,这个目标函数有很多局部最优。这意味着,根据初始配置,您可能会得到完全不同的结果,有些结果可能会比其他结果更好。这个问题可以通过多次运行 K-means 来缓解,每次都从不同的起始位置开始,然后选择具有最佳值的聚类。这就是为什么我们需要最后的参数,试验次数。
现在,让我们跑K——意思是在微笑:
int k = 10;
int maxIter = 100;
int runs = 3;
KMeans km = new KMeans(proj, k, maxIter, runs);
虽然 Smile 的实现只能处理密集矩阵,但是 JSAT 的实现没有这个限制,它可以处理任何矩阵,不管是密集的还是稀疏的。
我们在 JSAT 的做法如下:
SimpleDataSet ohe = JsatOHE.oneHotEncoding(categorical);
EuclideanDistance distance = new EuclideanDistance();
Random rand = new Random(1);
SeedSelection seedSelection = SeedSelection.RANDOM;
KMeans km = new ElkanKMeans(distance, rand, seedSelection);
List<List<DataPoint>> clustering = km.cluster(ohe);
在这段代码中,我们使用了 One-Hot-Encoding 的另一个实现,它生成稀疏的 JSAT 数据集。它非常接近我们对 Smile 的实现。关于细节,你可以看看本章代码库中的代码。
在 JSAT,K-的意思有多种实现方式。其中一个实现是ElkanKMeans,我们之前用过。来自 JSAT 的ElkanKMeans参数与 Smile 版本大相径庭:
- 首先,它采用距离函数,通常是欧几里德距离函数
- 它创建 random 类的一个实例以确保可再现性
- 它创建了为聚类选择初始种子的算法,随机是最快的,KPP(它是K-意味着++)在成本函数方面是最优的
对于稀疏矩阵,JSAT 实现太慢,所以它不适合我们的问题。对于密集矩阵,JSAT 实现产生的结果与 Smile 相当,但它也需要相当多的时间。
K-means 有一个参数 *K,*这是我们想要的聚类数。通常,想出一个好的 K 值是具有挑战性的,接下来我们将看看如何选择它。
在 K-Means 中选择 K
K -means 有一个缺点:我们需要指定集群的数量 K 。有时 K 可以从我们试图解决的领域问题中得知。例如,如果我们知道有 10 种类型的客户端,我们可能想要查找 10 个集群。
然而,我们往往没有这种领域知识。在这种情况下,我们可以使用一种通常被称为肘法的方法:
- 尝试不同的 K 值,记录每个值的失真
- 绘制每个 K 的失真
- 试着找出拐点,图中误差停止快速下降并开始缓慢下降的部分
你可以用下面的方法来做:
PrintWriter out = new PrintWriter("distortion.txt");
for (int k = 3; k < 50; k++) {
int maxIter = 100;
int runs = 3;
KMeans km = new KMeans(proj, k, maxIter, runs);
out.println(k + "/t" + km.distortion());
}
out.close();
然后,你可以用你喜欢的绘图库来绘制distortion.txt文件的内容,结果是这样的:
在这里,我们可以看到它最初下降很快,但在 15-20 左右,它开始缓慢下降。所以我们可以从这个区域中选取 K ,比如取 K = 17。
另一个解决方案是对少量数据进行采样,然后用层次聚类构建一个树状图。通过查看树状图,可以清楚地知道什么是最佳的聚类数 K 。
这两种方法都需要人的判断,很难形式化。但是还有另外一个选择——让机器学习为我们选择最好的 K 。为此,我们可以使用 X-Means,它是对 K -Means 算法的扩展。X-Means 试图使用贝叶斯信息标准 ( BIC )得分自动选择最佳 K 。
Smile 已经包含了 X-Means 的一个实现,名为XMeans,运行它很简单,如下所示:
int kmax = 300;
XMeans km = new XMeans(data, kmax);
System.out.println("selected number of clusters: " + km.getNumClusters());
这将根据 BIC 输出最佳数量的集群。JSAT 也有一个XMeans的实现,它的工作方式类似。
从来都不清楚哪种方法更好,所以您可能需要尝试每种方法,并为特定问题选择最佳方法。
除了凝聚聚类和 K -Means 之外,还有其他聚类方法,这些方法有时在实践中也是有用的。接下来,我们现在来看看其中的一个- DBSCAN。
基于密度的噪声应用空间聚类
DBSCAN 是另一种非常流行的集群技术。DBSCAN 属于基于密度的算法家族,与 K -Means 不同,它不需要事先知道聚类的数量 K 。
简而言之,DBSCAN 的工作方式如下:在每一步中,它都需要一个项目在其周围生成一个集群。
当我们从一个高密度区域中取出一个项目时,那么在当前项目附近有许多其他数据点,并且所有这些项目都被添加到聚类中。然后,对集群的每个新添加的元素重复该过程。然而,如果该区域不够密集,附近又没有那么多点,那么我们就不能形成一个聚类,并说这个项目是一个异常值。
因此,为了使 DBSCAN 工作,我们需要提供以下参数:
- 计算两个项目接近程度的距离度量
- 半径内继续增长集群的最小邻居数
- 每个点周围的半径
正如我们所看到的,我们不需要为 DBSCAN 预先指定 K 。此外,它自然会处理异常值,这可能会给像 K -Means 这样的方法带来严重的问题。 在 Smile 中有一个 DBSCAN 的实现,下面是我们如何使用它:
double[] X = ... // data
EuclideanDistance distance = new EuclideanDistance();
int minPts = 5;
double radius = 1.0;
DBScan<double[]> dbscan = new DBScan<>(X, distance, minPts, radius);
System.out.println(dbscan.getNumClusters());
int[] assignment = dbscan.getClusterLabel();
在这段代码中,我们指定了以下三个参数:距离、一个项目周围被认为是一个集群的最小点数以及半径。
完成后,我们就可以使用getClusterLabel方法来分配聚类标签。因为 DBSCAN 处理离群值,所以它们有一个特殊的集群 ID,Integer.MAX_VALUE。
凝聚聚类、 K -Means 和 DBSCAN 是最常用的聚类方法之一,当我们需要对共享某种模式的项目进行分组时,它们非常有用。然而,我们也可以使用聚类进行降维,接下来我们将看到如何进行降维。
监督学习的聚类
像降维一样,聚类也可以用于监督学习。
我们将讨论以下案例:
- 聚类作为创建额外特征的特征工程技术
- 聚类作为一种降维技术
- 作为简单分类或回归方法的聚类
作为特征的聚类
聚类可以被视为特征工程的一种方法,聚类的结果可以作为一组附加特征添加到监督模型中。
使用聚类结果的一次热编码的最简单方法如下:
- 首先,您运行一个聚类算法,结果,您将数据集分组到 K 个聚类中
- 然后,使用集群 ID 将每个数据点表示为它所属的集群
- 最后,您将 IDs 视为一个分类特征,并对其应用一次性编码。
代码看起来非常简单:
KMeans km = new KMeans(X, k, maxIter, runs);
int[] labels = km.getClusterLabel();
SparseDataset sparse = new SparseDataset(k);
for (int i = 0; i < labels.length; i++) {
sparse.set(i, labels[i], 1.0);
}
运行后,稀疏对象将包含集群 id 的一次热编码。接下来,我们可以将它添加到现有的特征中,并在其上运行常用的监督学习技术。
聚类作为降维
聚类可以被看作是一种特殊的降维。例如,如果您将数据分组到 K 个簇中,那么您可以将其压缩到 K 个质心中。一旦我们做到了这一点,每个数据点就可以表示为到每个质心的距离矢量。如果 K 小于你数据的维度,可以看作是一种降维的方式。
让我们实现这一点。首先,让我们在一些数据上运行一个 K -means。我们可以使用之前使用的性能数据集。
我们将再次使用 Smile,我们已经知道如何运行 K -means。代码如下:
double[][] X = ...; // data
int k = 60;
int maxIter = 10;
int runs = 1;
KMeans km = new KMeans(X, k, maxIter, runs);
一旦完成,就可以提取每个簇的质心。它们按行存储在二维数组中:
double[][] centroids = km.centroids();
这将返回一个由 K 行(在我们的例子中, K = 60)组成的数据集,其列数等于数据集中的要素数。
接下来,对于每个观察值,我们可以计算它离每个质心有多远。我们已经讨论了如何通过矩阵乘法有效地实现欧几里德距离,但是之前我们需要计算同一集合中每个元素之间的成对距离。然而,现在我们需要计算数据集的每个项目和每个质心之间的距离,因此我们有两组数据点。我们将稍微修改代码,以便它可以处理这种情况。
回想一下公式:
我们需要分别计算每个向量的平方范数,然后计算所有项之间的内积。
因此,如果我们将每个集合中的所有项目都作为两个矩阵 A 和 B 的行,那么我们可以使用这个公式通过矩阵乘法来计算两个矩阵之间的成对距离。
首先,我们计算规范和乘积:
double[] squaredA = squareRows(A);
double[] squaredB = squareRows(B);
Array2DRowRealMatrix mA = new Array2DRowRealMatrix(A, false);
Array2DRowRealMatrix mB = new Array2DRowRealMatrix(B, false);
double[][] product = mA.multiply(mB.transpose()).getData();
这里,squareRows函数计算矩阵的每个行向量的平方范数:
public static double[] squareRows(double[][] data) {
int nrow = data.length;
double[] squared = new double[nrow];
for (int i = 0; i < nrow; i++) {
double[] row = data[i];
double res = 0.0;
for (int j = 0; j < row.length; j++) {
res = res + row[j] * row[j];
}
squared[i] = res;
}
return squared;
}
现在,我们可以使用前面代码中的公式来计算距离:
int nrow = product.length;
int ncol = product[0].length;
double[][] distances = new double[nrow][ncol];
for (int i = 0; i < nrow; i++) {
for (int j = 0; j < ncol; j++) {
double dist = squaredA[i] - 2 * product[i][j] + squaredB[j];
distances[i][j] = Math.sqrt(dist);
}
}
如果我们把它包装成一个函数,例如,distance,我们可以这样使用它:
double[][] centroids = km.centroids();
double[][] distances = distance(X, centroids);
现在我们可以使用距离数组代替原始数据集X,例如,像这样:
OLS model = new OLS(distances, y);
请注意,它不一定必须用作降维技术。相反,我们可以用它来设计额外的功能,并将这些新功能添加到现有的功能中。
通过聚类的监督学习
非监督学习可以用作监督学习的模型,根据我们所拥有的监督问题,它可以是通过聚类的分类,或者是通过聚类的回归。
这种方法相对简单。首先,将每个项目与某个集群 ID 相关联,然后:
- 对于二元分类问题,您输出在聚类中看到正类的概率
- 对于回归,输出整个分类的平均值
让我们来看看如何进行回归。开始时,我们照常在原始数据上运行K-意味着:
int k = 250;
int maxIter = 10;
int runs = 1;
KMeans km = new KMeans(X, k, maxIter, runs);
通常选择一个相对较大的 K 是有意义的,最佳值,正如我们通常所做的,应该通过交叉验证来确定。
接下来,我们从训练数据中计算每个聚类的平均目标值。为此,我们首先按群集 ID 分组,然后计算每个组的平均值:
double[] y = ... // target variable
int[] labels = km.getClusterLabel();
Multimap<Integer, Double> groups = ArrayListMultimap.create();
for (int i = 0; i < labels.length; i++) {
groups.put(labels[i], y[i]);
}
Map<Integer, Double> meanValue = new HashMap<>();
for (int i = 0; i < k; i++) {
double mean = groups.get(i).stream()
.mapToDouble(d -> d)
.average().getAsDouble();
meanValue.put(i, mean);
}
现在,如果我们想把这个模型应用到测试数据中,我们可以用下面的方法。首先,对于每个看不见的数据项,我们找到最接近的集群 ID,然后,使用这个 ID,我们查找平均目标值。
在代码中,它看起来像这样:
double[][] testX = ... // test data
double[] testY = ... // test target
int[] testLabels = Arrays.stream(testX).mapToInt(km::predict).toArray();
double[] testPredict = Arrays.stream(testLabels)
.mapToDouble(meanValue::get)
.toArray();
现在,testPredict数组包含了来自测试数据的每个观察的预测。
此外,如果不是回归,而是有一个二进制分类问题,并且将标签保存在双精度数组中,前面的代码将输出属于基于聚类的类的概率,而不做任何更改!而testPredict数组将包含预测的概率。
估价
无监督学习最复杂的部分是评估模型的质量。很难客观地判断一个聚类是好的还是一个结果比另一个好。
有几种方法可以解决这个问题:
- 人工评估
- 使用标签信息(如果有)
- 无监督度量
人工评估
手动评估意味着手动查看结果,并使用领域专业知识来评估集群的质量以及它们是否有意义。
手动检查通常以下列方式完成:
- 对于每个集群,我们采样相同的数据点
- 然后,我们看着他们,看看他们是否应该在一起
在查看数据时,我们想问自己以下问题:
- 这些物品看起来相似吗?
- 把这些物品放在同一个组里有意义吗?
如果两个问题的答案都是肯定的,那么聚类结果是好的。此外,我们采集数据的方式也很重要。例如,在 K -means 的情况下,我们应该采样一些靠近质心的项目,以及一些远离质心的项目。然后,我们可以比较近的和远的。如果我们观察它们,仍然可以发现它们之间的一些相似之处,那么聚类就是好的。
即使我们使用其他种类的集群验证技术,这种评估也总是有意义的,并且,如果可能的话,应该总是对模型进行健全性检查。例如,如果我们将它应用于客户分离,我们总是应该手动查看两个客户在聚类中是否确实相似,否则模型结果将是无用的。
然而,很明显,这种方法非常主观,不可重复,并且不可扩展。不幸的是,有时这是唯一好的选择,并且对于许多问题,没有合适的方法来评估模型质量。然而,对于一些问题,其他更自动化的方法可以提供良好的结果,接下来我们将研究一些这样的方法。
监督评估
手动检查输出总是好的,但是可能相当麻烦。通常会有一些额外的数据,我们可以使用这些数据以更自动化的方式评估我们的聚类结果。
例如,如果我们使用聚类进行监督学习,那么我们就有了标签。例如,如果我们解决了分类问题,那么我们可以使用类别信息来测量所发现的聚类有多纯(或同质)。也就是说,我们可以看到集群中多数类与其余类的比率是多少。
如果我们拿投诉数据集来说,有一些变量我们没有用于聚类,例如:
- 及时响应:这是一个二元变量,表示公司是否及时响应投诉。
- 公司对消费者的回应:说明公司对投诉的回应。
- 消费者有争议:表示消费者是否同意响应。
潜在地,我们可能对预测这些变量感兴趣,所以我们可以使用它们作为聚类质量的指示。
例如,假设我们对预测公司的反应感兴趣。所以我们执行聚类:
int maxIter = 100;
int runs = 3;
int k = 15;
KMeans km = new KMeans(proj, k, maxIter, runs);
现在想看看它对预测反应有多大用处。让我们计算每个集群内的结果比率。
为此,我们首先按集群 ID 分组,然后计算比率:
int[] assignment = km.getClusterLabel();
List<Object> resp = data.col("company_response_to_consumer");
Multimap<Integer, String> respMap = ArrayListMultimap.create();
for (int i = 0; i < assignment.length; i++) {
int cluster = assignment[i];
respMap.put(cluster, resp.get(i).toString());
}
现在我们可以打印它,按照最频繁的值对集群中的值进行排序:
List<Integer> keys = Ordering.natural().sortedCopy(map.keySet());
for (Integer c : keys) {
System.out.print(c + ": ");
Collection<String> values = map.get(c);
Multiset<String> counts = HashMultiset.create(values);
counts = Multisets.copyHighestCountFirst(counts);
int totalSize = values.size();
for (Entry<String> e : counts.entrySet()) {
double ratio = 1.0 * e.getCount() / totalSize;
String element = e.getElement();
System.out.printf("%s=%.3f (%d), ", element, ratio, e.getCount());
}
System.out.println();
}
这是第一对集群的输出:
0: Closed with explanation=0.782 (12383), Closed with non-monetary relief=0.094 (1495)...
1: Closed with explanation=0.743 (19705), Closed with non-monetary relief=0.251 (6664)...
2: Closed with explanation=0.673 (18838), Closed with non-monetary relief=0.305 (8536)...
我们可以看到,聚类并不是真正的纯的:有一个主导类,并且在各个聚类中纯度或多或少是相同的。另一方面,我们看到类在集群中的分布是不同的。例如,在分类 2 中,30%的项目是以非货币救济结束的,而在分类 1 中,只有 9%的项目。
即使多数类本身可能没有用,但是如果我们将它用作一个特征,每个聚类内的分布对于分类模型可能是有用的。
这给我们带来了不同的评估方法;如果我们将聚类作为一种特征工程技术来使用,我们可以通过聚类提供多少性能增益来评估聚类的质量,并通过挑选增益最大的一个来选择最佳聚类。
这就给我们带来了下一个评估方法。如果我们在一些受监督的设置中使用聚类的结果(比方说,通过将它作为一种特征工程技术使用),那么我们可以通过查看它给出多少性能来评估聚类的质量。
例如,我们有一个模型,在没有任何聚类功能的情况下具有 85%的准确性。然后我们使用两种不同的聚类算法并从中提取特征,并将它们包含到模型中。来自第一个算法的特征将分数提高了 2%,而第二个算法给出了 3%的提高。那么,第二种算法更好。
最后,有一些特殊的度量标准,我们可以使用它们来评估对于一个提供的标签来说,聚类有多好。一个这样的度量是 Rand 指数和互信息。这些指标在 JSAT 实现,你可以在jsat.clustering.evaluation包中找到它们。
无监督评估
最后,当标签未知时,存在用于评估聚类质量的无监督评估分数。
我们已经提到过一个这样的度量:失真,这是每个项目和它最近的质心之间的距离的总和。还有其他指标,例如:
- 聚类内最大成对距离
- 平均成对距离
- 成对距离的平方和
这些和其他一些指标也在 JSAT 实施,你可以在jsat.clustering.evaluation.intra包中找到它们。
摘要
在这一章中,我们讨论了无监督机器学习和两个常见的无监督学习问题,维度缩减和聚类分析。我们讨论了每种类型中最常见的算法,包括 PCA 和 K-means。我们还讨论了这些算法在 Java 中的现有实现,并自己实现了其中的一些。此外,我们还讨论了一些重要的技术,比如 SVD,这在一般情况下非常有用。
前一章和这一章已经给了我们相当多的信息。通过这些章节,我们为如何使用机器学习和数据科学算法处理文本数据打下了良好的基础,这也是我们将在下一章中讨论的内容。
六、使用文本——自然语言处理和信息检索
在前两章中,我们讨论了机器学习的基础知识:我们谈到了监督和非监督问题。
在这一章中,我们将看看如何使用这些方法来处理文本信息,我们将用我们正在运行的例子来说明我们的大部分想法:构建一个搜索引擎。这里,我们将最终使用来自 HTML 的文本信息,并将其包含到机器学习模型中。
首先,我们将从自然语言处理的基础开始,自己实现一些基本思想,然后研究 NLP 库中可用的高效实现。
本章涵盖以下主题:
- 信息检索基础
- 使用 Apache Lucene 进行索引和搜索
- 自然语言处理基础
- 文本的无监督模型——降维、聚类和单词嵌入
- 文本的监督模型——文本分类和排序学习
本章结束时,你将学会如何为机器学习做简单的文本预处理,如何使用 Apache Lucene 进行索引,如何将单词转换成向量,以及如何对文本进行聚类和分类。
自然语言处理和信息检索
自然语言处理 ( NLP )是计算机科学和计算语言学的一部分,处理文本数据。对于计算机来说,文本是非结构化的,自然语言处理有助于找到结构并从中提取有用的信息。
信息检索 ( IR )是一门研究在大型非结构化数据集中搜索的学科。典型地,这些数据集是文本,并且 IR 系统帮助用户找到他们想要的。像 Google 或 Bing 这样的搜索引擎就是这种 IR 系统的例子:它们接受一个查询,并提供一个根据与该查询的相关性排序的文档集合。
通常,信息检索系统使用自然语言处理来理解文档的内容——因此,当用户需要时,可以检索这些文档。在这一章中,我们将复习用于信息检索的文本处理的基础知识。
向量空间模型-单词袋和 TF-IDF
对计算机来说,文本只是一串没有特定结构的字符。因此,我们称文本为非结构化数据。然而,对人类来说,文本当然有一个结构,我们用它来理解内容。IR 和 NLP 模型试图做的事情是相似的:它们找到文本中的结构,用它来提取那里的信息,并理解文本是关于什么的。
实现它的最简单的可能方式被称为单词包:我们获取一个文本,将其分割成单个单词(我们称之为记号,然后将该文本表示为一个无序的记号集合以及与每个记号相关联的一些权重。
让我们考虑一个例子。如果我们取一个文档,它由一个句子组成(我们使用 Java 进行数据科学,因为我们喜欢 Java) ,它可以表示如下:
(because, 1), (data, 1), (for, 1), (java, 2), (science, 1), (use, 1), (we, 2)
在这里,句子中的每个单词都根据该单词出现的次数进行加权。
现在,当我们能够以这种方式表示文档时,我们可以用它来比较一个文档和另一个文档。
例如,如果我们取另一个句子如 Java 是好的企业开发,我们可以表示如下:
(development, 1), (enterprise, 1), (for, 1), (good, 1), (java, 1)
我们可以看到这两个文档之间有一些交集,这可能意味着这两个文档是相似的,交集越高,文档越相似。
现在,如果我们认为单词是某个向量空间中的维度,权重是这些维度的值,那么我们可以将文档表示为向量:
如果我们采用这种矢量表示,我们可以用两个矢量之间的内积作为相似性的度量。的确,如果两个文档有很多共同的词,它们之间的内积就会很高,如果它们没有共享文档,内积就是零。
这个想法被称为向量空间模型,这是在许多信息检索系统中使用的:所有文档以及用户查询都被表示为向量。一旦查询和文档在同一个空间,我们可以把查询和文档之间的相似性看作它们之间的相关性。因此,我们根据文档与用户查询的相似性对文档进行排序。
从原始文本到矢量包含几个步骤。通常,它们如下:
- 首先,我们对文本进行标记化,也就是说,将它转换成单个标记的集合。
- 然后,我们去掉 is、will、to 等虚词。它们通常仅用于链接目的,没有任何重要意义。这些词被称为停用词。
- 有时我们也会将令牌转换成某种范式。例如,我们可能希望将 cat 和 cats 映射到 cat,因为这两个不同单词背后的概念是相同的。这是通过词干化或词汇化实现的。
- 最后,我们计算每个标记的权重,并将它们放入向量空间。
以前,我们使用出现的次数来加权术语;这被称为术语频率加权。然而,有些词比其他词更重要,术语频率并不总是能捕捉到这一点。
比如锤子可以比工具更重要,因为它更具体。逆文档频率是一种不同的加权方案,它惩罚一般的单词而支持特定的单词。在内部,它基于包含该术语的文档的数量,其思想是更具体的术语出现在比一般术语更少的文档中。
最后是词频和逆文档频的组合,缩写为 TF-IDF。顾名思义,令牌t的权重由两部分组成:TF 和 IDF:
weight(t) = tf(t) * idf(t)
下面是对前面等式中提到的术语的解释:
tf(t):这是令牌t在文本中出现次数的函数idf(t):这是包含令牌的文档数量的函数
定义这些函数有多种方法,但最常见的是使用以下定义:
tf(t):这是t在文档中出现的次数idf(t) = log(N / df(t)):此处df(t)为文件数,包含t,N为文件总数
以前,我们建议可以使用内积来度量文档之间的相似性。这种方法有一个问题:它是无界的,这意味着它可以接受任何正值,这使得它更难解释。此外,较长的文档往往与其他所有文档具有更高的相似性,因为它们包含更多的单词。
这个问题的解决方案是对向量内部的权重进行归一化,使其范数变为 1。然后,计算内积总会得到一个介于 0 和 1 之间的有界值,较长的文档影响较小。归一化向量之间的内积通常称为余弦相似度,因为它对应于这两个向量在向量空间中形成的角度的余弦。
向量空间模型实现
现在我们已经有了足够的背景信息,可以开始编写代码了。
首先,假设我们有一个文本文件,其中每一行都是一个文档,我们希望索引这个文件的内容并能够查询它。比如我们可以从的 https://OCW . MIT . edu/ans 7870/6/6.006/s08/lecture notes/files/t8 . Shakespeare . txt中取一些文本保存到simple-text.txt。
那么我们可以这样理解:
Path path = Paths.get("data/simple-text.txt");
List<List<String>> documents = Files.lines(path, StandardCharsets.UTF_8)
.map(line -> TextUtils.tokenize(line))
.map(line -> TextUtils.removeStopwords(line))
.collect(Collectors.toList());
我们使用标准库中的Files类,然后使用两个函数:
- 第一个是
TextUtils.tokenize,它接受一个字符串并生成一个令牌列表 - 第二个是
TextUtils.removeStopwords,删除了 a、The 等功能词
实现标记化的一个简单而天真的方法是根据正则表达式拆分字符串:
public static List<String> tokenize(String line) {
Pattern pattern = Pattern.compile("W+");
String[] split = pattern.split(line.toLowerCase());
return Arrays.stream(split)
.map(String::trim)
.filter(s -> s.length() > 2)
.collect(Collectors.toList());
}
表达式W+的意思是在所有非拉丁字符上分割字符串。当然,它将无法处理包含非拉丁字符的语言,但这是实现标记化的一种快速方法。此外,它对英语也很有效,并且可以适用于其他欧洲语言。
这里的另一件事是丢弃小于两个字符的短标记——这些标记通常是停用词,所以丢弃它们是安全的。第二个函数获取一个标记列表,并从中删除所有停用词。下面是它的实现:
Set<String> EN_STOPWORDS = ImmutableSet.of("a", "an", "and", "are", "as", "at", "be", ...
public static List<String> removeStopwords(List<String> line) {
return line.stream()
.filter(token -> !EN_STOPWORDS.contains(token))
.collect(Collectors.toList());
}
这非常简单:我们保存一组英语停用词,然后对于每个标记,我们只需检查它是否在这个集合中。你可以从 www.ranks.nl/stopwords 那里得到一份很好的英语停用词清单。
向这个管道中添加令牌规范化也很容易。现在,我们将跳过它,但是我们将在本章的后面回到它。
现在我们已经标记了文本,所以下一步是在向量空间中表示标记。让我们为它创建一个特殊的类。我们称之为CountVectorizer。
名称CountVectorizer的灵感来自 scikit-learn 中一个具有类似功能的类,sci kit-learn 是一个用 Python 进行机器学习的优秀包。如果你熟悉这个库,你可能会注意到我们有时会借用那里的名字(比如方法的名字fit()和transform())。
因为我们不能直接创建一个向量空间,它的维度由单词索引,我们将首先把所有文本中的所有不同的标记映射到某个列号。
此外,在这一步计算文档频率是有意义的,并使用它来丢弃只出现在少数文档中的标记。通常,这样的术语是拼写错误、不存在的单词或者过于罕见而对结果没有任何影响。
在代码中,它看起来像这样:
Multiset<String> df = HashMultiset.create();
documents.forEach(list -> df.addAll(Sets.newHashSet(list)));
Multiset<String> docFrequency = Multisets.filter(df, p -> df.count(p) >= minDf);
List<String> vocabulary = Ordering.natural().sortedCopy(docFrequency.elementSet());
Map<String, Integer> tokenToIndex = new HashMap<>(vocabulary.size());
for (int i = 0; i < vocabulary.size(); i++) {
tokenToIndex.put(vocabulary.get(i), i);
}
我们使用来自 Guava 的一个Multiset来计算文档频率,然后我们应用过滤,其中minDf是一个参数,它指定了最小的文档频率。在丢弃不常用的令牌后,我们将一个列号与每个剩余的相关联,并将其放入一个Map。
现在,我们可以使用文档频率来计算 IDF:
int numDocuments = documents.size();
double numDocumentsLog = Math.log(numDocuments + 1);
double[] idfs = new double[vocabulary.size()];
for (Entry<String> e : docFrequency.entrySet()) {
String token = e.getElement();
double idfValue = numDocumentsLog - Math.log(e.getCount() + 1);
idfs[tokenToIndex.get(token)] = idfValue;
}
在执行之后,idfs数组将包含我们词汇表中所有标记的 IDF 部分权重。
现在我们准备将标记化的文档放入向量空间:
int ncol = vocabulary.size();
SparseDataset tfidf = new SparseDataset(ncol);
for (int rowNo = 0; rowNo < documents.size(); rowNo++) {
List<String> doc = documents.get(rowNo);
Multiset<String> row = HashMultiset.create(doc);
for (Entry<String> e : row.entrySet()) {
String token = e.getElement();
double tf = e.getCount();
int colNo = tokenToIndex.get(token);
double idf = idfs[colNo];
tfidf.set(rowNo, colNo, tf * idf);
}
}
tfidf.unitize();
由于结果向量非常稀疏,我们使用 Smile 中的SparseDataset来存储它们。然后,对于文档中的每个令牌,我们计算它的 TF 并乘以 IDF,以获得 TF-IDF 权重。
代码的最后一行将长度规范化应用于文档向量。这样,计算向量之间的内积将得到余弦相似性得分,这是一个介于 0 和 1 之间的有界值。
现在,让我们将代码放入一个类中,这样我们可以在以后重用它:
public class CountVectorizer {
void fit(List<List<String>> documents);
SparseDataset tranform(List<List<String>> documents);
}
我们定义的函数执行以下操作:
fit创建从令牌到列号的映射,并计算 IDF- 将文档集合转换成稀疏矩阵
- 构造函数应该使用
minDf,它指定了一个令牌的最小文档频率。
现在我们可以用它来矢量化我们的数据集:
List<List<String>> documents = Files.lines(path, StandardCharsets.UTF_8)
.map(line -> TextUtils.tokenize(line))
.map(line -> TextUtils.removeStopwords(line))
.collect(Collectors.toList());
int minDf = 5;
CountVectorizer cv = new CountVectorizer(minDf);
cv.fit(documents);
SparseDataset docVectors = cv.transform(documents);
现在假设我们作为用户想要查询这个文档集合。为了能够做到这一点,我们需要实现以下内容:
- 首先,在相同的向量空间中表示一个查询:也就是说,对文档应用完全相同的过程(标记化、停用词删除等等)。
- 然后,计算查询和每个文档之间的相似度。
- 最后,使用相似性得分对文档进行排序,从最高到最低。
假设我们的查询是the probabilistic interpretation of tf-idf。然后,以类似的方式将其映射到向量空间:
List<String> query = TextUtils.tokenize("the probabilistic interpretation of tf-idf");
query = TextUtils.removeStopwords(query);
SparseDataset queryMatrix = vectorizer.transfrom(Collections.singletonList(query));
SparseArray queryVector = queryMatrix.get(0).x;
我们之前创建的方法接受文档的集合,而不是单个文档,所以首先我们将它包装到一个列表中,然后获得包含结果的矩阵的第一行。
我们现在拥有的是docVector,它是一个包含我们的文档集合的稀疏矩阵,还有queryVector,一个包含查询的稀疏向量。这样,获得相似性就很容易了:我们只需要将矩阵与向量相乘,结果将包含相似性得分。
和上一章一样,我们将利用Matrix Java Toolkit(MTJ)来解决这个问题。因为我们在做矩阵向量乘法,矩阵在左边,所以存储值的最好方式是基于行的表示。我们已经编写了一个实用方法,用于将 Smile 的SparseDataset转换为 MTJ 的CompRowMatrix。
又来了:
public static CompRowMatrix asRowMatrix(SparseDataset dataset) {
int ncols = dataset.ncols();
int nrows = dataset.size();
FlexCompRowMatrix X = new FlexCompRowMatrix(nrows, ncols);
SparseArray[] array = dataset.toArray(new SparseArray[0]);
for (int rowIdx = 0; rowIdx < array.length; rowIdx++) {
Iterator<Entry> row = array[rowIdx].iterator();
while (row.hasNext()) {
Entry entry = row.next();
X.set(rowIdx, entry.i, entry.x);
}
}
return new CompRowMatrix(X);
}
现在我们还需要从 MTJ 将一个SparseArray对象转换成一个SparseVector对象。
让我们也为此创建一个方法:
public static SparseVector asSparseVector(int dim, SparseArray vector) {
int size = vector.size();
int[] indexes = new int[size];
double[] values = new double[size];
Iterator<Entry> iterator = vector.iterator();
int idx = 0;
while (iterator.hasNext()) {
Entry entry = iterator.next();
indexes[idx] = entry.i;
values[idx] = entry.x;
idx++;
}
return new SparseVector(dim, indexes, values, false);
}
注意,我们还必须将结果向量的维数传递给这个方法。这是由于SparseArray的限制,它不存储关于它的信息。
现在我们可以使用这些方法来计算相似性:
CompRowMatrix X = asRowMatrix(docVectors);
SparseVector v = asSparseVector(docVectors.ncols(), queryVector);
DenseVector result = new DenseVector(X.numRows());
X.mult(v, result);
double[] scores = result.getData();
scores 数组现在包含每个文档的查询的余弦相似性得分。该数组的索引对应于原始文档集合的索引。也就是说,为了查看查询和第 10 个文档之间的相似性,我们查看数组的第 10 个元素。因此,我们需要根据分数对数组进行排序,同时保留原始索引。
让我们首先为它创建一个类:
public class ScoredIndex implements Comparable<ScoredIndex> {
private final int index;
private final double score;
// constructor and getters omitted
@Override
public int compareTo(ScoredIndex that) {
return -Double.compare(this.score, that.score);
}
}
这个类实现了Comparable接口,所以现在我们可以把这个类的所有对象放到一个集合中,然后进行排序。最后,集合中的第一个元素得分最高。让我们这样做:
double minScore = 0.2;
List<ScoredIndex> scored = new ArrayList<>(scores.length);
for (int idx = 0; idx < scores.length; idx++) {
double score = scores[idx];
if (score >= minScore) {
scored.add(new ScoredIndex(idx, score));
}
}
Collections.sort(scored);
我们还添加了一个 0.2 的相似性阈值来对更少的元素进行排序:我们假设低于这个分数的元素是不相关的,所以我们忽略它们。
最后,我们可以迭代结果并查看最相关的文档:
for (ScoredIndex doc : scored) {
System.out.printf("> %.4f ", doc.getScore());
List<String> document = documents.get(doc.getIndex());
System.out.println(String.join(" ", document));
}
这样,我们自己实现了一个简单的 IR 系统,完全从零开始。但是,实现相当幼稚。在现实中,有相当多的候选文档,因此用它们中的每一个来计算查询的余弦相似性是不可行的。有一种特殊的数据结构叫做倒排索引,可以用来解决这个问题,现在我们来看看它的一个实现:Apache Lucene。
索引和 Apache Lucene
之前,我们研究了如何实现一个简单的搜索引擎,但是它不能很好地适应文档的数量。
首先,它需要将查询与我们集合中的每一个文档进行比较,随着文档的增长,这变得非常耗时。然而,大多数文档与查询不相关,只有一小部分与查询相关。我们可以有把握地假设,如果一个文档与一个查询相关,那么它应该包含至少一个来自该查询的单词。这是倒排索引数据结构背后的思想:对于每个单词,它跟踪包含它的文档。当给定一个查询时,它可以快速找到至少包含一个术语的文档。
还有一个内存问题:在某些时候,文档将不再适合内存,我们需要能够将它们存储在磁盘上,并在需要时进行检索。
Apache Lucene 解决了这些问题:它实现了一个持久的倒排索引,在速度和存储方面都非常高效,并且经过了高度优化和时间验证。在第二章、数据处理工具箱中我们收集了一些原始的 HTML 数据,所以让我们用 Lucene 为它建立一个索引。
首先,我们需要将库包含到 pom 中:
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>6.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>6.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>6.2.1</version>
</dependency>
Lucene 是非常模块化的,可以只包含我们需要的东西。在我们的例子中,这是:
- 包:我们在使用 Lucene 时总是需要它
analyzers-common模块:它包含了文本处理的公共类queryparser:这是用于解析查询的模块
Lucene 提供了几种类型的索引,包括内存索引和文件系统索引。我们将使用文件系统:
File index = new File(INDEX_PATH);
FSDirectory directory = FSDirectory.open(index.toPath());
接下来,我们需要定义一个分析器:这是一个完成所有文本处理步骤的类,包括标记化、停用词移除和规范化。
StandardAnalyzer是一个基本的Analyzer,它删除了一些英语停用词,但不执行任何词干化或词汇化。它对英文文本非常有效,所以让我们用它来建立索引:
StandardAnalyzer analyzer = new StandardAnalyzer();
IndexWriter writer = new IndexWriter(directory, new IndexWriterConfig(analyzer))
现在我们准备索引文档了!
让我们来看看之前浏览过的 URL,并对它们的内容进行索引:
UrlRepository urls = new UrlRepository();
Path path = Paths.get("data/search-results.txt");
List<String> lines =
FileUtils.readLines(path.toFile(), StandardCharsets.UTF_8);
for (String line : lines) {
String[] split = line.split("t");
String url = split[3];
Optional<String> html = urls.get(url);
if (!html.isPresent()) {
continue;
}
org.jsoup.nodes.Document jsoupDoc = Jsoup.parse(html.get());
Element body = jsoupDoc.body();
if (body == null) {
continue;
}
Document doc = new Document();
doc.add(new Field("url", url, URL_FIELD));
doc.add(new Field("title", jsoupDoc.title(), URL_FIELD));
doc.add(new Field("content", body.text(), BODY_FIELD));
writer.addDocument(doc);
}
writer.commit();
writer.close();
directory.close();
让我们仔细看看这里的一些东西。首先,UrlRepository是一个类,存储我们在第二章、数据处理工具箱中创建的一些 URL 的抓取的 HTML 内容。给定一个 URL,它返回一个Optional对象,如果存储库有它的数据,它就包含响应;否则它返回一个空的Optional。
然后我们用 JSoup 解析原始 HTML 并提取标题和正文。现在我们有了文本数据,我们把它放入 Lucene Document。
Lucene 中的一个Document由字段组成,每个Field对象存储一些关于文档的信息。一个Field有一些属性,比如:
- 无论我们是否将值存储在索引中。如果我们这样做,那么以后我们可以提取内容。
- 无论我们是否将价值指数化。如果它被索引,那么它就变得可搜索,我们可以查询它。
- 不管是不是分析出来的。如果是,我们将分析器应用于内容,这样我们就可以查询单个令牌。否则只有精确匹配是可能的。
这些和其他属性保存在FieldType对象中。
例如,下面是我们如何指定URL_FIELD的属性:
FieldType field = new FieldType();
field.setTokenized(false);
field.setStored(true);
field.freeze();
这里我们说我们不想对它进行标记化,而是想将值存储在索引中。freeze()方法确保一旦我们指定了属性,它们就不能再被更改。
下面是我们如何指定BODY_FIELD:
FieldType field = new FieldType();
field.setStored(false);
field.setTokenized(true);
field.setIndexOptions(IndexOptions.DOCS_AND_FREQS);
field.freeze();
在这种情况下,我们只分析它,但不存储字段的确切内容。通过这种方式,仍然可以对其进行查询,但是由于没有存储内容,该字段在索引中占用的空间较少。
它非常快速地处理我们的数据集,并在执行后在文件系统中创建一个索引,我们可以查询它。让我们开始吧。
String userQuery = "cheap used cars";
File index = new File(INDEX_PATH);
FSDirectory directory = FSDirectory.open(index.toPath());
DirectoryReader reader = DirectoryReader.open(directory);
IndexSearcher searcher = new IndexSearcher(reader);
StandardAnalyzer analyzer = new StandardAnalyzer();
AnalyzingQueryParser parser = new AnalyzingQueryParser("content", analyzer);
Query query = parser.parse(userQuery);
TopDocs result = searcher.search(query, 10);
ScoreDoc[] scoreDocs = result.scoreDocs;
for (ScoreDoc scored : scoreDocs) {
int docId = scored.doc;
float luceneScore = scored.score;
Document doc = searcher.doc(docId);
System.out.println(luceneScore + " " + doc.get("url") + " " + doc.get("title"));
}
在这段代码中,我们首先打开索引,然后指定用于处理查询的分析器。使用这个分析器,我们解析查询,并使用解析后的查询从索引中提取前 10 个匹配的文档。我们存储了 URL 和标题,所以现在我们可以在查询时检索这些信息并呈现给用户。
自然语言处理工具
自然语言处理是计算机科学和计算语言学中处理文本的一个领域。正如我们之前看到的,信息检索使用简单的 NLP 技术来索引和检索文本信息。
但是 NLP 可以做得更多。有相当多的主要 NLP 任务,如文本摘要或机器翻译,但我们不会涵盖它们,只讨论基本的任务:
- 句子分割:给定文本,我们把它分割成句子
- **标记化:**给定一个句子,将其拆分成单独的标记
- **引理化:**给定一个令牌,我们想求出它的引理。例如,对于单词猫和猫,词条是猫。
- 词性标注(POS Tagging) :给定一系列标记,目标是确定每个标记的词性。例如,它意味着将标记动词与标记 like 相关联,或者将标记名词与标记 laptop 相关联。
- 命名实体识别(NER) :在一系列记号中,找出与命名实体相对应的记号,例如城市、国家、其他地理名称、人名等等。例如,它应该将 Paul McCartney 标记为人名,将德国标记为国名。
让我们看看实现这些基本方法的一个库:Stanford CoreNLP。
斯坦福·科伦普
Java 中有相当多成熟的 NLP 库。比如斯坦福 CoreNLP,OpenNLP,还有 GATE。我们之前介绍过的许多库都有一些 NLP 模块,例如,Smile 或 JSAT。
在本章中,我们将使用斯坦福 CoreNLP。没有特别的原因,如果需要,应该可以在任何其他库中复制这些示例。
让我们从在pom.xml中指定以下依赖关系开始:
<dependency>
<groupId>edu.stanford.nlp</groupId>
<artifactId>stanford-corenlp</artifactId>
<version>3.6.0</version>
</dependency>
<dependency>
<groupId>edu.stanford.nlp</groupId>
<artifactId>stanford-corenlp</artifactId>
<version>3.6.0</version>
<classifier>models</classifier>
</dependency>
有两个依赖项:第一个是 NLP 包本身,第二个包含第一个模块使用的模型。这些模型适用于英语,但也存在适用于其他欧洲语言(如德语或西班牙语)的模型。
这里的主要抽象是一个 StanfordCoreNLP 类,它充当处理管道。它指定了应用于原始文本的一系列步骤。
考虑下面的例子:
Properties props = new Properties();
props.put("annotators", "tokenize, ssplit, pos, lemma");
StanfordCoreNLP pipeline = new StanfordCoreNLP(props);
在这里,我们创建一个管道,它获取文本,对文本进行标记,将文本拆分成句子,对每个标记应用 POS 模型,然后找到它的词条。
我们可以这样使用它:
String text = "some text";
Annotation document = new Annotation(text);
pipeline.annotate(document);
List<Word> results = new ArrayList<>();
List<CoreLabel> tokens = document.get(TokensAnnotation.class);
for (CoreLabel tokensInfo : tokens) {
String token = tokensInfo.get(TextAnnotation.class);
String lemma = tokensInfo.get(LemmaAnnotation.class);
String pos = tokensInfo.get(PartOfSpeechAnnotation.class);
results.add(new Word(token, lemma, pos));
}
在这段代码中,Word是我们的类,它保存了关于标记的信息:表面形式(出现在文本中的形式)、引理(规范化的形式)和词性。
很容易修改管道来添加额外的步骤。例如,如果我们希望添加 NER,那么我们首先要做的是将NER添加到管道中:
Properties props = new Properties();
props.put("annotators", "tokenize, ssplit, pos, lemma, ner");
StanfordCoreNLP pipeline = new StanfordCoreNLP(props);
然后,对于每个令牌,提取相关的NER标签:
String ner = tokensInfo.get(NamedEntityTagAnnotation.class);
尽管如此,前面的代码仍然需要一些手工清理;如果我们运行它,我们可能会注意到它还输出标点符号和停用词。通过在循环中添加一些额外的检查,很容易解决这个问题:
for (CoreLabel tokensInfo : tokens) {
String token = tokensInfo.get(TextAnnotation.class);
String lemma = tokensInfo.get(LemmaAnnotation.class);
String pos = tokensInfo.get(PartOfSpeechAnnotation.class);
String ner = tokensInfo.get(NamedEntityTagAnnotation.class);
if (isPunctuation(token) || isStopword(token)) {
continue;
}
results.add(new Word(token, lemma, pos, ner));
}
isStopword方法的实现很简单:我们只需检查令牌是否在停用字词集中。检查标点符号也不难:
public static boolean isPunctuation(String token) {
char first = token.charAt(0);
return !Character.isAlphabetic(first) && !Character.isDigit(first);
}
我们只是验证String的第一个字符不是字母也不是数字。如果是这样,那么一定是标点符号。
NER 还有另一个问题,我们可能需要解决:它没有将同类的连续单词连接到一个令牌中。考虑这个例子:我叫贾斯汀比伯,我住在纽约。它将产生以下 NER 标签分配:
- 贾斯汀->人
- 比伯->人
- 新建->位置
- 约克->位置
- 其他令牌被映射到
O
我们可以用下面的代码片段连接标有相同NER标记的连续令牌:
String prevNer = "O";
List<List<Word>> groups = new ArrayList<>();
List<Word> group = new ArrayList<>();
for (Word w : words) {
String ner = w.getNer();
if (prevNer.equals(ner) && !"O".equals(ner)) {
group.add(w);
continue;
}
groups.add(group);
group = new ArrayList<>();
group.add(w);
prevNer = ner;
}
groups.add(group);
所以我们简单地检查序列,看看当前标签是否与前一个标签相同。如果是这样,那么我们停止一个组,开始下一个组。如果我们看到O,那么我们总是假设它是下一组。之后,我们只需要过滤空的组,如果需要的话,将文本字段合并成一个。
虽然这对人来说似乎没什么大不了的,但对于像纽约这样的地名来说可能很重要:这些标记合在一起与单独的标记 New 和 York 具有完全不同的含义,因此将它们作为单个标记对待可能对 IR 系统很有用。
接下来,我们将看到如何在 Apache Lucene 中利用 NLP 工具,如 Stanford CoreNLP。
定制 Apache Lucene
Apache Lucene 是一个古老且非常强大的搜索库。它写于 1999 年,从那以后,许多用户不仅采用了它,还为这个库创建了许多不同的扩展。
尽管如此,有时 Lucene 内置的 NLP 功能还不够,还需要一个专门的 NLP 库。
例如,如果我们想在标记中包含 POS 标签,或者查找命名实体,那么我们需要像 Stanford CoreNLP 这样的东西。在 Lucene 工作流中包含这样的外部专用 NLP 库并不困难,这里我们将看到如何实现。
让我们使用 StanfordNLP 库和我们在上一节中实现的标记器。我们可以用一个方法tokenize将其命名为StanfordNlpTokenizer,在这里我们将放入之前编写的用于标记化的代码。
我们可以使用这个类来标记抓取的 HTML 数据的内容。和以前一样,我们使用 JSoup 从 HTML 中提取文本,但是现在,我们不是将标题和正文直接放入文档,而是首先使用 CoreNLP 管道自己对其进行预处理。我们可以通过创建以下实用程序方法来实现,然后使用它来标记标题和正文:
public static String tokenize(StanfordNlpTokenizer tokenizer, String text) {
List<Word> tokens = tokenizer.tokenize(text);
return tokens.stream()
.map(Word::getLemma)
.map(String::toLowerCase)
.collect(Collectors.joining(" "));
}
请注意,这里我们使用了引理,而不是令牌本身,最后我们再次将所有内容放回到一个字符串中。
通过这个修改,我们可以使用 Lucene 的WhitespaceAnalyzer。与StandardAnalyzer相反,它非常简单,它所做的就是用一个空白字符分割文本。在我们的例子中,字符串已经由 CoreNLP 准备和处理,所以 Lucene 以期望的形式索引内容。
完整的修改版本将如下所示:
Analyzer analyzer = new WhitespaceAnalyzer();
IndexWriter writer =
new IndexWriter(directory, new IndexWriterConfig(analyzer));
StanfordNlpTokenizer tokenizer = new StanfordNlpTokenizer();
for (String line : lines) {
String[] split = line.split("t");
String url = split[3];
Optional<String> html = urls.get(url);
if (!html.isPresent()) {
continue;
}
org.jsoup.nodes.Document jsoupDoc = Jsoup.parse(html.get());
Element body = jsoupDoc.body();
if (body == null) {
continue;
}
String titleTokens = tokenize(tokenizer, jsoupDoc.title());
String bodyTokens = tokenize(tokenizer, body.text());
Document doc = new Document();
doc.add(new Field("url", url, URL_FIELD));
doc.add(new Field("title", titleTokens, URL_FIELD));
doc.add(new Field("content", bodyTokens, BODY_FIELD));
writer.addDocument(doc);
}
可以对某些字段使用 Lucene 的StandardAnalyzer,对其他字段使用经过定制预处理的WhitespaceAnalyzer。为此,我们需要使用PerFieldAnalyzerWrapper,在这里我们可以为每个字段指定一个特定的Analyzer。
这为我们如何预处理和分析文本提供了很大的灵活性,但是它不允许我们改变排序公式:Lucene 用来排序文档的公式。在本章的后面,我们也将看到如何做到这一点,但首先我们将看看如何在文本分析中使用机器学习。
文本的机器学习
机器学习在文本处理中起着重要的作用。它允许更好地理解隐藏在文本中的信息,并提取隐藏在那里的有用知识。我们已经从前面的章节中熟悉了机器学习模型,事实上,我们甚至已经将其中的一些用于文本,例如,来自斯坦福 CoreNLP 的 POS tagger 和 NER 都是基于机器学习的模型。
在第 4 章、监督学习-分类和回归和第 5 章、非监督学习-聚类和降维中,我们涵盖了监督和非监督机器学习问题。就文本而言,两者都在帮助组织文本或提取有用信息方面发挥着重要作用。在本节中,我们将看到如何将它们应用于文本数据。
文本的无监督学习
正如我们所知,无监督机器学习处理没有提供标签信息的情况。对于文本,这意味着只让它处理大量的文本数据,而没有关于内容的额外信息。尽管如此,它可能经常是有用的,现在我们将看到如何对文本使用降维和聚类。
潜在语义分析
潜在语义分析 ( LSA ),也称为潜在语义索引 ( LSI ),是无监督降维技术对文本数据的应用。
LSA 试图解决的问题是:
- 同义词:这意味着多个单词具有相同的意思
- 多义性:这意味着一个词有多个意思
基于术语的浅层技术(如单词袋)无法解决这些问题,因为它们只查看术语的精确原始形式。例如,像 help 和 assist 这样的词将被分配到向量空间的不同维度,即使它们在语义上非常接近。
为了解决这些问题,LSA 将文档从通常的词汇向量空间转移到其他一些语义空间,在这些空间中,意义相近的词对应于同一维度,多义词的值跨维度拆分。
这是通过查看术语-术语共现矩阵来实现的。假设是这样的:如果两个词经常在同一个语境中使用,那么它们是同义的,反之,如果一个词是多义的,那么它将在不同的语境中使用。降维技术可以检测这种共现模式,并将它们压缩到更小维度的向量空间中。
一种这样的降维技术是奇异值分解 ( SVD )。如果 X 是一个文档术语矩阵,比如我们从CountVectorizer得到的矩阵,那么 X 的 SVD 是:
XV = US
上述等式中的各项解释如下:
- V 是在术语-术语共现矩阵 X ^T X 上计算的术语的基础
- U 是在文档-文档共现矩阵 XX ^T 上计算的文档的基础
因此,通过对 X 应用截断的 SVD,我们降低了术语-术语共现矩阵 X ^T X 的维数,然后可以使用这个新的简化基 V 来表示我们的文档。
我们的文档矩阵存储在SparseDataset中。如果您还记得,我们已经在这样的对象上使用了 SVD:首先,我们将 SparseDataset 转换成基于列的SparseMatrix,然后对它应用 SVD:
SparseMatrix matrix = data.toSparseMatrix();
SingularValueDecomposition svd = SingularValueDecomposition.decompose(matrix, n);
double[][] termBasis = svd.getV();
然后下一步是把我们的矩阵投射到这个新的项基上。在前一章中,我们已经使用以下方法做到了这一点:
public static double[][] project(SparseDataset dataset, double[][] Vd) {
CompRowMatrix X = asRowMatrix(dataset);
DenseMatrix V = new DenseMatrix(Vd);
DenseMatrix XV = new DenseMatrix(X.numRows(), V.numColumns());
X.mult(V, XV);
return to2d(XV);
}
这里,asRowMatrix将SparseDataset转换成来自 MTJ 的CompRowMatrix,to2d将来自 MTJ 的密集矩阵转换成双精度的二维数组。
一旦我们将原始数据投影到 LSA 空间,它就不再是归一化的了。我们可以通过实现以下方法来解决这个问题:
public static double[][] l2RowNormalize(double[][] data) {
for (int i = 0; i < data.length; i++) {
double[] row = data[i];
ArrayRealVector vector = new ArrayRealVector(row, false);
double norm = vector.getNorm();
if (norm != 0) {
vector.mapDivideToSelf(norm);
data[i] = vector.getDataRef();
}
}
return data;
}
这里,我们对输入矩阵的每一行应用长度规范化,为此我们使用 Apache Commons Math 中的ArrayRealVector。
为了方便起见,我们可以为 LSA 创建一个特殊的类。姑且称之为TruncatedSVD,它会有如下签名:
public class TruncatedSVD {
void fit(SparseDataset data);
double[][] transform(SparseDataset data);
}
它有以下方法:
fit学习新学期基础transform通过将数据投射到已学习的基础上来减少数据的维度- 构造函数应该有两个参数:
n,期望的维数和结果是否应该被规范化
我们可以将 LSA 应用到我们的 IR 系统中:现在,代替单词袋空间中的余弦相似度,我们进入 LSA 空间并在那里计算余弦。为此,我们首先需要在索引期间将文档映射到这个空间,然后在查询期间,我们对用户查询执行相同的转换。然后,计算余弦只是一个矩阵乘法。
所以,让我们先来看看我们之前使用的代码:
List<List<String>> documents = Files.lines(path, StandardCharsets.UTF_8)
.map(line -> TextUtils.tokenize(line))
.map(line -> TextUtils.removeStopwords(line))
.collect(Collectors.toList());
int minDf = 5;
CountVectorizer cv = new CountVectorizer(minDf);
cv.fit(documents);
SparseDataset docVectors = cv.transform(documents);
现在,我们使用刚刚创建的TruncatedSVD类将docVectors映射到 LSA 空间:
int n = 150;
boolean normalize = true;
TruncatedSVD svd = new TruncatedSVD(n, normalize);
svd.fit(docVectors);
double[][] docsLsa = svd.transform(docVectors);
我们重复同样的查询:
List<String> query = TextUtils.tokenize("cheap used cars");
query = TextUtils.removeStopwords(query);
SparseDataset queryVectors = vectorizer.transfrom(Collections.singletonList(query));
double[] queryLsa = svd.transform(queryVectors)[0];
像前面一样,我们将查询包装到一个列表中,然后提取结果的第一行。然而,这里我们有一个密集的向量,而不是稀疏的。现在,剩下的是计算相似性,这只是一个矩阵向量乘法:
DenseMatrix X = new DenseMatrix(docsLsa);
DenseVector v = new DenseVector(vector);
DenseVector result = new DenseVector(X.numRows());
X.mult(v, result);
double[] scores = result.getData();
执行之后,scores 数组将包含相似性,我们可以使用 ScoredIndex 类根据这个分数对文档进行排序。这是非常有用的,所以让我们把它变成一个实用方法:
public static List<ScoredIndex> wrapAsScoredIndex(double[] scores, double minScore) {
List<ScoredIndex> scored = new ArrayList<>(scores.length);
for (int idx = 0; idx < scores.length; idx++) {
double score = scores[idx];
if (score >= minScore) {
scored.add(new ScoredIndex(idx, score));
}
}
Collections.sort(scored);
return scored;
}
最后,我们从列表中取出第一个元素,并像以前一样将它们呈现给用户。
文本聚类
在第 5 章、无监督学习——聚类和降维中,我们介绍了降维和聚类。我们已经讨论了如何对文本进行降维,但还没有谈到聚类。
文本聚类对于理解什么是文档集合也是一种有用的技术。当我们想要聚类文本时,目标类似于非文本情况:我们想要找到具有许多共同点的文档组:例如,这种组中的文档应该是关于同一主题的。在某些情况下,这对于 IR 系统是有用的。例如,如果一个主题是不明确的,我们可能希望对搜索引擎结果进行分组。
means 是一个简单而强大的聚类算法,它非常适合文本。让我们使用抓取的文本,并尝试使用 K -means 在其中找到一些话题。首先,我们加载文档并将它们矢量化。我们将使用来自 Smile 的 K -Means 实现,如果你还记得的话,它不支持稀疏矩阵,所以我们还需要降低维数。为此,我们将使用 LSA。
List<List<String>> documents = ... // read the crawl data
int minDf = 5;
CountVectorizer cv = new CountVectorizer(minDf);
cv.fit(documents);
SparseDataset docVectors = cv.transform(documents);
int n = 150;
boolean normalize = true;
TruncatedSVD svd = new TruncatedSVD(n, normalize);
svd.fit(docVectors);
double[][] docsLsa = svd.transform(docVectors);
数据是准备好的,所以我们可以应用K-意思是:
int maxIter = 100;
int runs = 3;
int k = 100;
KMeans km = new KMeans(docsLsa, k, maxIter, runs);
这里,k,你应该还记得上一章的内容,是我们想要寻找的集群的数量。这里对K的选择是相当随意的,所以可以随意试验并选择K的任何其他值。
一旦它完成了,我们可以看看结果的质心。然而,这些质心在 LSA 空间中,而不在原始项空间中。为了让他们回来,我们需要反转 LSA 变换。
为了从原始空间到 LSA 空间,我们使用了由基项构成的矩阵。因此,为了进行逆变换,我们需要该矩阵的逆。因为基是正交的,所以它的逆与转置相同,我们将用它来求 LSA 变换的逆。代码看起来是这样的:
double[][] centroids = km.centroids();
double[][] termBasis = svd.getTermBasis();
double[][] centroidsOriginal = project(centroids, t(termBasis));
以下是t方法计算转置的方式:
public static double[][] t(double[][] M) {
Array2DRowRealMatrix matrix = new Array2DRowRealMatrix(M, false);
return matrix.transpose().getData();
}
而投影法只是计算矩阵-矩阵乘法。
现在,当质心在原始空间时,我们找到每个质心最重要的项。
为此,我们取一个质心,看看最大的维度是多少:
List<String> terms = vectorizer.vocabulary();
for (int centroidId = 0; centroidId < k; centroidId++) {
double[] centroid = centroidsOriginal[centroidId];
List<ScoredIndex> scored = wrapAsScoredIndex(centroid, 0.0);
for (int i = 0; i < 20; i++) {
ScoredIndex scoredTerm = scored.get(i);
int position = scoredTerm.getIndex();
String term = terms.get(position);
System.out.print(term + ", ");
}
System.out.println();
}
这里,terms 是包含来自CountVectorizer,的维度名称的列表,而wrapAsScoredIndex是我们之前编写的函数;它接受一个双精度数组,创建一个ScoredIndex对象列表,并对其进行排序。
当您运行它时,您可能会看到类似于这些集群的内容:
| 集群 1 | 集群 2 | 集群 3 | | 血压低血压低症状心脏病的治疗 | 惠普打印机打印机打印 laserjet 支持 officejet 打印墨水软件 | 汽车汽车丰田福特本田二手宝马雪佛兰汽车日产 |
我们只取了前三组,它们显然是有意义的。也有一些聚类不太有意义,这表明该算法可以进一步调整:我们可以调整中的KK-LSA 的均值和维数。
单词嵌入
到目前为止,我们已经介绍了如何对文本数据应用降维和聚类。还有另一种类型的无监督学习,它特定于文本:单词嵌入。你可能听说过 **Word2Vec,**就是这样一种算法。
单词嵌入试图解决的问题是如何将单词嵌入到低维向量空间中,使得语义上接近的单词在这个空间中是接近的,而不同的单词是远离的。
例如,猫和狗应该离得很近,但是笔记本电脑和天空应该离得很远。
这里,我们将实现一个基于共现矩阵的单词嵌入算法。它建立在 LSA 的思想之上:在那里,我们可以用术语所包含的文档来表示它们。所以,如果两个单词包含在同一个文档中,它们应该是相关的。然而,文档对于一个单词来说是一个相当宽泛的上下文,所以我们可以把它缩小到一个句子,或者缩小到感兴趣的单词前后的几个单词。
例如,考虑下面的句子:
我们使用 Java 进行数据科学,因为我们喜欢 Java。Java 有利于企业开发。
然后,我们将文本标记化,分成句子,删除停用词,得到以下内容:
- “我们”、“使用”、“java”、“数据”、“科学”、“我们”、“喜欢”、“java”
- “java”、“好”、“企业”、“开发”
现在,假设对于这里的每个单词,我们想看看前面的两个单词和后面的两个单词是什么。这会给我们每个单词的上下文。对于本例,它将是:
- 我们->使用 java
- 用-> we;java,数据
- java -> we,使用;数据,科学
- 数据->使用,java 科学,我们
- java ->我们,喜欢
- java ->好,企业
- 好的-> Java;企业,发展
- 企业-> java,不错;发展
- 发展->好,企业
然后,我们可以建立一个共现矩阵,在这个矩阵中,每当一个单词出现在另一个单词的上下文中时,我们就将它置 1。所以,对于“我们”,我们会在“使用”和“java”上加+1,以此类推。
最后,每个单元格将显示一个单词 w [1] (来自矩阵的行)在另一个单词 w [2] (来自矩阵的列)的上下文中出现了多少次。接下来,如果我们用奇异值分解降低这个矩阵的维数,我们已经比普通的 LSA 方法有了很大的改进。
但是我们可以更进一步,用点态互信息 ( PMI )代替计数。
PMI 是衡量两个随机变量之间相关性的指标。它最初来自信息论,但在计算语言学中经常用于测量两个词之间的关联程度。它的定义如下:
它检查两个单词 w 和 v 是否偶然同时出现。如果它们是偶然发生的,那么联合概率 p(w,v) 应该等于边际概率 p(w) p(v) 的乘积,所以 PMI 为 0。但是,如果两个单词之间确实存在关联,PMI 会得到高于 0 的值,因此值越高,关联越强。
我们通常通过浏览文本并计数来估计这些概率:
- 对于边际概率,我们只计算令牌出现的次数
- 对于连接概率,我们看共生矩阵
我们使用以下公式:
p(w) = c(w) / N,其中c(w)为w在体内出现的次数,N为令牌总数p(w, v) = c(w, v) / N,其中c(w, v)是来自共生矩阵的值,N也是令牌的数量
然而,在实践中,c(w, v)、c(w)和c(v)的小值会扭曲概率,因此通常通过添加一些小数字λ来平滑它们:
p(w) = [c(w) + λ] / [N + Kλ],其中K是语料库中唯一标记的数量p(w, v) = [c(w, v) + λ] / [N + Kλ]
如果我们替换前面等式中的 PMI 公式,我们会得到以下公式:
PMI(w, v) = log [c(w, v) + λ] + log [N + Kλ] - log [c(w) + λ] - log [c(v) + λ]
所以我们能做的只是用 PMI 替换共现矩阵中的计数,然后计算这个矩阵的 SVD。在这种情况下,得到的嵌入将具有更好的质量。
现在,让我们实现它。首先,您可能已经注意到,我们需要有句子,而以前我们只有一串标记,没有句子边界的检测。我们知道,斯坦福 CoreNLP 可以做到,所以让我们创建一个管道:
Properties props = new Properties();
props.put("annotators", "tokenize, ssplit, pos, lemma");
StanfordCoreNLP pipeline = new StanfordCoreNLP(props);
我们将使用句子分割器来检测句子,然后我们将采用单词的引理而不是表面形式。
但是让我们首先创建一些有用的类。之前我们用List<List<String>>说我们传递一个文档集合,每个文档都是一个令牌序列。现在我们把每个文档拆分成句子,再把每个句子拆分成令牌,就变成了List<List<List<String>>>,有点难以理解。我们可以用一些有意义的类来代替,比如Document和Sentence:
public class Document {
private List<Sentence> sentences;
// getter, setter and constructor is omitted
}
public class Sentence {
private List<String> tokens;
// getter, setter and constructor is omitted
}
尽可能创建这样的小班。尽管在开始时看起来有些冗长,但它对以后阅读代码和理解意图非常有帮助。
现在,让我们用它们来标记一个文档。我们可以用下面的方法创建一个Tokenizer类:
public Document tokenize(String text) {
Annotation document = new Annotation(text);
pipeline.annotate(document);
List<Sentence> sentencesResult = new ArrayList<>();
List<CoreMap> sentences = document.get(SentencesAnnotation.class);
for (CoreMap sentence : sentences) {
List<CoreLabel> tokens = sentence.get(TokensAnnotation.class);
List<String> tokensResult = new ArrayList<>();
for (CoreLabel tokensInfo : tokens) {
String token = tokensInfo.get(TextAnnotation.class);
String lemma = tokensInfo.get(LemmaAnnotation.class);
if (isPunctuation(token)
|| isStopword(token)
|| lemma.length() <= 2) {
continue;
}
tokensResult.add(lemma.toLowerCase());
}
if (!tokensResult.isEmpty()) {
sentencesResult.add(new Sentence(tokensResult));
}
}
return new Document(sentencesResult);
}
因此,我们在这里将句子拆分器应用于文本,然后,对于每个句子,收集标记。我们已经看到了isPunctuation和isStopword方法——这里它们的实现和前面的一样。
然后我们可以再次使用抓取的 HTML 数据集,并对用 JSoup 提取的内容应用标记器。为了简洁起见,我们将省略这一部分。现在,我们准备从这些数据中构建共现矩阵。
与CountVectorizer中一样,第一步是应用文档频率过滤器来丢弃不常用的标记,然后构建一个将标记与某个整数相关联的映射:得到的稀疏矩阵的列数。我们已经知道怎么做了,所以我们可以跳过这一部分。
然后,为了估计p(w)和p(v),我们需要知道每个令牌出现的次数:
Multiset<String> counts = HashMultiset.create();
for (Document doc : documents) {
for (Sentence sentence : doc.getSentences()) {
counts.addAll(sentence.getTokens());
}
}
现在,我们可以开始计算共生矩阵。为此,我们可以使用 Guava 的Table类:
Table<String, String, Integer> coOccurrence = HashBasedTable.create();
for (Document doc : documents) {
for (Sentence sentence : doc.getSentences()) {
processWindow(sentence, window, coOccurrence);
}
}
这里,我们用以下内容定义processWindow函数:
List<String> tokens = sentence.getTokens();
for (int idx = 0; idx < tokens.size(); idx++) {
String token = tokens.get(idx);
Map<String, Integer> tokenRow = coOccurrence.row(token);
for (int otherIdx = idx - window;
otherIdx <= idx + window;
otherIdx++) {
if (otherIdx < 0
|| otherIdx >= tokens.size()
|| otherIdx == idx) {
continue;
}
String other = tokens.get(otherIdx);
int currentCnt = tokenRow.getOrDefault(other, 0);
tokenRow.put(other, currentCnt + 1);
}
}
这里我们在文档的每个句子上滑动一个指定大小的窗口。然后,对于该窗口中心的单词,我们查看前后的单词,并且对于它们中的每一个,将同现计数增加 1。
下一步是根据这些数据创建一个 PMI 值矩阵。像以前一样,我们将使用 Smile 的SparseDataset类来保存这些值:
int vocabularySize = vocabulary.size();
double logTotalNumTokens = Math.log(counts.size() + vocabularySize * smoothing);
SparseDataset result = new SparseDataset(vocabularySize);
for (int rowIdx = 0; rowIdx < vocabularySize; rowIdx++) {
String token = vocabulary.get(rowIdx);
double logMainTokenCount = Math.log(counts.count(token) + smoothing);
Map<String, Integer> tokenCooc = coOccurrence.row(token);
for (Entry<String, Integer> otherTokenEntry : tokenCooc.entrySet()) {
String otherToken = otherTokenEntry.getKey();
double logOtherTokenCount = Math.log(counts.count(otherToken) + smoothing);
double logCoOccCount = Math.log(otherTokenEntry.getValue() + smoothing);
double pmi = logCoOccCount + logTotalNumTokens
- logMainTokenCount - logOtherTokenCount;
if (pmi > 0) {
int colIdx = tokenToIndex.get(otherToken);
result.set(rowIdx, colIdx, pmi);
}
}
}
在这段代码中,我们只是将 PMI 公式应用于我们拥有的同现计数。最后,我们对这个矩阵执行 SVD,为此我们只需使用我们之前创建的TruncatedSVD类。
现在,我们可以看看我们训练的嵌入是否有意义。为此,我们可以选择一些术语,并为每个术语找到最相似的术语。这可以通过以下方式实现:
- 首先,对于给定的令牌,我们查找它的向量表示
- 然后,我们计算这个记号与其余向量的相似度。我们知道,这可以通过矩阵向量乘法来实现
- 最后,我们按分数对乘法的结果进行排序,并显示分数最高的记号。
到目前为止,我们已经完成了几次完全相同的过程,所以我们可以跳过代码。当然,它可以在本章的代码包中找到。
但是让我们看看结果。我们选取了几个词:猫、德和笔记本电脑,以下是最相似的几个词,根据我们刚刚训练的嵌入:
| 猫 | 德国 | 笔记本电脑 | | 0.835 宠物 | 0.829 国家 | 0.882 笔记本 | | 0.812 狗 | 0.815 移民 | 0.869 英寸超极本 | | 0.796 小猫 | 0.813 联合 | 0.866 桌面 | | 0.793 搞笑 | 0.808 个国家 | 0.865 专业版 | | 0.788 小狗 | 0.802 巴西 | 0.845 触摸屏 | | 0.762 动物 | 0.789 加拿大 | 0.842 联想 | | 0.742 庇护所 | 0.777 德语 | 0.841 游戏 | | 0.727 朋友 | 0.776 澳大利亚 | 0.836 片 | | 0.727 救援 | 0.760 欧洲 | 0.834 华硕 | | 0.726 图片 | 0.759 外国 | 0.829 macbook |
即使不理想,结果还是有意义的。通过在更多的文本数据上训练这些嵌入,或者微调诸如 SVD 的维数、最小文档频率和平滑量之类的参数,可以进一步改进它。
当训练单词嵌入时,获取更多的数据总是一个好主意。维基百科是一个很好的文本资料来源;它有多种语言版本,他们定期在 dumps.wikimedia.org/发布垃圾信息。如果维基…](commoncrawl.org/)),他们抓取互联网上… 9 章](http://chapter%209)、*缩放数据科学*中谈到常见的抓取。
最后,互联网上有很多经过预先训练的单词嵌入。
比如,你可以看看这里的收藏:github.com/3Top/word2v…。从那里加载嵌入非常容易。
为此,让我们首先创建一个类来存储向量:
public class WordEmbeddings {
private final double[][] embeddings;
private final List<String> vocabulary;
private final Map<String, Integer> tokenToIndex;
// constructor and getters are omitted
List<ScoredToken> mostSimilar(String top, int topK, double minSimilarity);
Optional<double[]> representation(String token);
}
该类具有以下字段和方法:
embeddings:这是存储向量的数组- 这是所有令牌的列表
tokenToIndex:这是从令牌到存储向量的索引的映射mostSimilar:这将返回与所提供的令牌最相似的前 K 个令牌representation:返回一个术语的向量表示,如果没有向量,则可选。不存在
当然,我们可以把基于 PMI 的嵌入放在那里。但是让我们看看如何从前面的链接中加载现有的 GloVe 和 Word2Vec 向量。
对于 Word2Vec 和 GloVe 来说,存储向量的文本文件格式非常相似,所以我们只能介绍其中一种。GloVe 稍微简单一点,我们按如下方式使用它:
- 首先,从nlp.stanford.edu/data/glove.…下载预训练的嵌入
- 拆开包装;在不同维度的相同语料库上训练了几个文件
- 让我们用
glove.6B.300d.txt
存储格式很简单;每一行都有一个标记,后面跟着一系列数字。这些数字显然是令牌的嵌入向量。让我们来读一读:
List<Pair<String, double[]>> pairs =
Files.lines(file.toPath(), StandardCharsets.UTF_8)
.parallel()
.map(String::trim)
.filter(StringUtils::isNotEmpty)
.map(line -> parseGloveTextLine(line))
.collect(Collectors.toList());
List<String> vocabulary = new ArrayList<>(pairs.size());
double[][] embeddings = new double[pairs.size()][];
for (int i = 0; i < pairs.size(); i++) {
Pair<String, double[]> pair = pairs.get(i);
vocabulary.add(pair.getLeft());
embeddings[i] = pair.getRight();
}
embeddings = l2RowNormalize(embeddings);
WordEmbeddings result = new WordEmbeddings(embeddings, vocabulary);
在这里,我们解析文本文件的每一行,然后创建词汇表并标准化向量的长度。parseGloveTextLine有以下内容:
List<String> split = Arrays.asList(line.split(" "));
String token = split.get(0);
double[] vector = split.subList(1, split.size()).stream()
.mapToDouble(Double::parseDouble).toArray();
Pair<String, double[]> result = ImmutablePair.of(token, vector);
这里,ImmutablePair是 Apache Commons Lang 中的一个对象。
让我们用同样的词,看看他们的邻居使用这些手套嵌入。这是结果:
| 猫 | 德国 | 笔记本电脑 | | - 0.682 狗
- 0.682 猫
- 0.587 宠物
- 0.541 狗
- 0.490 猫
- 0.488 猴
- 0.473 马
- 0.463 宠物
- 0.461 兔
- 0.459 豹 | - 0.749 德国
- 0.663 奥地利
- 0.646 柏林
- 0.597 欧洲
- 0.586 慕尼黑
- 0.579 波兰
- 0.577 瑞士
- 0.575 德国
- 0.559 丹麦
- 0.557 法国 | - 0.796 台笔记本电脑
- 0.673 台电脑
- 0.599 台手机
- 0.596 台电脑
- 0.580 台便携式
- 0.562 台台式
- 0.547 台手机
- 0.546 台笔记本
- 0.544 台
- 0.529 台手机 |
结果确实有意义,而且在某些情况下,它比我们训练自己的嵌入更好。
正如我们提到的,word2vec 向量的文本格式与 GloVe 向量非常相似,因此只需稍加修改就可以阅读它们。然而,有一种存储 word2vec 嵌入的二进制格式。它有点复杂,但是如果你想知道如何阅读它,看看本章的代码包。
在这一章的后面,我们将看到如何应用单词嵌入来解决监督学习问题。
文本的监督学习
有监督的机器学习方法对于文本数据也相当有用。像在通常的设置中一样,这里有标签信息,我们可以用它来理解文本中的信息。
将监督学习应用于文本的一个非常常见的例子是垃圾邮件检测:每当你点击电子邮件客户端的垃圾邮件按钮时,这些数据就会被收集起来,然后放入分类器中。然后,训练该分类器来区分垃圾邮件和非垃圾邮件。
在本节中,我们将通过两个例子来研究如何对文本使用监督方法:首先,我们将构建一个情感分析模型,然后我们将使用一个排名分类器对搜索结果进行重新排名。
文本分类
文本分类是一个问题,其中给定一组文本和标签,它训练一个模型,该模型可以为新的看不见的文本预测这些标签。这里的设置是监督学习的常用设置,只是现在我们有了文本数据。
有许多可能的分类问题,如下所示:
- 垃圾邮件检测:预测电子邮件是否是垃圾邮件
- 情感分析:预测文本的情感是正面还是负面
- 语言检测:给定一个文本,检测它的语言
几乎在所有情况下,文本分类的一般工作流程都是相似的:
- 我们对文本进行标记和矢量化
- 然后,我们拟合一个线性分类器,将每个记号视为一个特征
众所周知,如果我们对文本进行矢量化,得到的向量非常稀疏。这就是为什么使用线性模型是一个好主意:它们非常快,可以轻松处理文本数据的稀疏性和高维数。
所以让我们来解决其中一个问题。
比如我们可以拿一个情感分析问题,建立一个模型,这个模型预测文本的极性,也就是文本是正面的还是负面的。
我们可以从这里取数据:http://ai.stanford.edu/~amaas/data/sentiment/。这个数据集包含从 IMDB 中提取的 50.000 个带标签的电影评论,作者提供了预定义的训练测试分割。为了从那里存储评论,我们可以为它创建一个类:
public class SentimentRecord {
private final String id;
private final boolean train;
private final boolean label;
private final String content;
// constructor and getters omitted
}
我们不会详细讨论从归档文件中读取数据的代码,但是像往常一样,欢迎您查看代码包。
至于模型,我们将使用 LIBLINEAR——正如你已经从第四章、中知道的——监督学习——分类和回归。这是一个快速实现线性分类器的库,如逻辑回归和线性 SVM。
现在,让我们来读数据:
List<SentimentRecord> data = readFromTagGz("data/aclImdb_v1.tar.gz");
List<SentimentRecord> train = data.stream()
.filter(SentimentRecord::isTrain)
.collect(Collectors.toList());
List<List<String>> trainTokens = train.stream()
.map(r -> r.getContent())
.map(line -> TextUtils.tokenize(line))
.map(line -> TextUtils.removeStopwords(line))
.collect(Collectors.toList());
在这里,我们从归档中读取数据,然后对训练数据进行标记。接下来,我们对文本进行矢量化处理:
int minDf = 10;
CountVectorizer cv = new CountVectorizer(minDf);
cv.fit(trainTokens);
SparseDataset trainData = cv.transform(trainTokens);
到目前为止,没什么新发现。但是现在我们需要将SparseDataset转换成 LIBLINEAR 格式。让我们为此创建几个实用方法:
public static Feature[][] wrapX(SparseDataset dataset) {
int nrow = dataset.size();
Feature[][] X = new Feature[nrow][];
int i = 0;
for (Datum<SparseArray> inrow : dataset) {
X[i] = wrapRow(inrow);
i++;
}
return X;
}
public static Feature[] wrapRow(Datum<SparseArray> inrow) {
SparseArray features = inrow.x;
int nonzero = features.size();
Feature[] outrow = new Feature[nonzero];
Iterator<Entry> it = features.iterator();
for (int j = 0; j < nonzero; j++) {
Entry next = it.next();
outrow[j] = new FeatureNode(next.i + 1, next.x);
}
return outrow;
}
第一个方法wrapX,采用一个SparseDataset并创建一个二维数组的Feature对象。这是 LIBLINEAR 存储数据的格式。第二个方法是wrapRow,它获取一个特定的SparseDataset行,并将其包装成一个由Feature对象组成的一维数组。
现在,我们需要提取标签信息并创建一个描述数据的Problem类的实例:
double[] y = train.stream().mapToDouble(s -> s.getLabel() ? 1.0 : 0.0).toArray();
Problem problem = new Problem();
problem.x = wrapX(dataset);
problem.y = y;
problem.n = dataset.ncols() + 1;
problem.l = dataset.size();
然后,我们定义参数并训练模型:
Parameter param = new Parameter(SolverType.L1R_LR, 1, 0.001);
Model model = Linear.train(problem, param);
这里,我们用 L1 正则化和成本参数C=1指定一个逻辑回归模型。
线性分类器,如逻辑回归或带 L1 正则化的 SVM,非常适合处理高稀疏性问题,如文本分类。L1 惩罚确保模型收敛得非常快,此外,它还迫使解变得稀疏:也就是说,它执行特征选择,只保留最有信息的单词。
为了预测概率,我们可以创建另一个实用方法,它采用一个模型和一个测试数据集,并返回一个一维概率数组:
public static double[] predictProba(Model model, SparseDataset dataset) {
int n = dataset.size();
double[] results = new double[n];
double[] probs = new double[2];
int i = 0;
for (Datum<SparseArray> inrow : dataset) {
Feature[] row = wrapRow(inrow);
Linear.predictProbability(model, row, probs);
results[i] = probs[1];
i++;
}
return results;
}
现在我们可以测试模型了。因此,我们获取测试数据,对其进行标记化和矢量化,然后调用 predictProba 方法来检查输出。最后,我们可以使用一些评估指标(如 AUC)来评估性能。在这种特殊情况下,AUC 为 0.89,对于该数据集来说,这是相当好的性能。
学习信息检索排序
学习排序是一系列处理排序数据的算法。这个家庭是监督机器学习的一部分;为了对数据进行排序,我们需要知道哪些项目更重要,需要首先显示。
学习排名通常用于构建搜索引擎的环境中;基于一些相关性评估,我们建立了一个模型,试图将相关项目的排名高于不相关的项目。在无监督排序的情况下,例如 TF-IDF 权重上的余弦,我们通常只有一个特征,通过该特征我们对文档进行排序。然而,可能会有更多的特性,我们可能希望将它们包含在模型中,并让它以最佳的方式组合它们。
学习给模型排序有几种类型。其中一些被称为“逐点”——它们被单独应用于每个文档,并与其他训练数据隔离开来考虑。尽管这是一个严格的假设,但这些算法很容易实现,并且在实践中运行良好。通常,这相当于使用分类或回归模型,然后根据分数对项目进行排序。
让我们回到构建搜索引擎的运行示例,并在其中加入更多的特性。以前是无人监管的;我们只是根据一个特征,余弦,对项目进行了排序。但是我们可以增加更多的特征,使之成为一个监督学习的问题。
然而,为此,我们需要知道标签。我们已经有了肯定的标签:对于一个查询,我们知道大约 30 个相关的文档。但是我们不知道负面标签:我们使用的搜索引擎只返回相关的页面。所以我们需要得到反面的例子,然后就有可能训练一个二进制分类器来区分相关和不相关的页面。
有一种技术我们可以用来获得负数据,它被称为负抽样。这种想法是基于这样一种假设,即语料库中的大多数文档是不相关的,因此如果我们从那里随机抽取一些文档并说它们是不相关的,那么我们在大多数情况下都是正确的。如果一个被采样的文档变得相关,那么不会发生任何不好的事情;这只是一个嘈杂的观察,不应该影响整体结果。
因此,我们采取以下措施:
- 首先,我们读取排名数据,并根据查询对文档进行分组
- 然后,我们将查询分成两个不重叠的组:一个用于训练,一个用于验证
- 接下来,在每个组中,我们进行一个查询并随机抽取 9 个负面例子。来自否定查询的这些 URL 被分配了否定标签
- 最后,我们基于这些标记的文档/查询对训练一个模型
在阴性取样步骤中,重要的是为了训练,我们不从验证组中取阴性样本,反之亦然。如果我们只在训练/验证组中采样,那么我们可以确定我们的模型可以很好地推广到看不见的查询和文档。
负采样很容易实现,所以让我们开始吧:
private static List<String> negativeSampling(String positive, List<String> collection,
int size, Random rnd) {
Set<String> others = new HashSet<>(collection);
others.remove(positive);
List<String> othersList = new ArrayList<>(others);
Collections.shuffle(othersList, rnd);
return othersList.subList(0, size);
}
想法如下:首先,我们得到整个查询集合,并删除我们当前正在考虑的一个。然后,我们洗牌并从中挑选前 N 个。
既然我们有正面和负面的例子,我们需要提取特征,我们将把这些特征放入模型中。让我们创建一个QueryDocumentPair类,它将包含关于用户查询的信息以及关于文档的数据:
public class QueryDocumentPair {
private final String query;
private final String url;
private final String title;
private final String bodyText;
private final String allHeaders;
private final String h1;
private final String h2;
private final String h3;
// getters and constructor omitted
}
这个类的对象可以通过用 JSoup 解析 HTML 内容并提取标题、正文、所有标题文本(h1-h6)以及 h1、h2、h3 标题来创建。
我们将使用这些字段来计算特征。
例如,我们可以计算以下各项:
- 查询和所有其他文本字段之间的单词包 TF-IDF 相似度
- 查询和所有其他文本字段之间的 LSA 相似性
- 嵌入查询和标题以及 h1、h2 和 h3 标题之间的相似性。
我们已经知道如何计算前两种类型的特征:
- 我们使用
CountVectorizer分别对每个字段进行矢量化,并使用转换方法对查询进行矢量化 - 对于 LSA,我们以同样的方式使用
TruncatedSVD类;我们在文本字段上训练它,然后将其应用于查询 - 然后,我们在单词袋和 LSA 空间中计算文本字段和查询之间的余弦相似度
然而,我们没有在这里讨论最后一个,使用单词嵌入。想法如下:
- 对于查询,获取每个令牌的向量,并将它们放入一个矩阵中
- 对于标题(或其他文本字段),执行相同的操作
- 通过矩阵乘法计算每个查询向量与每个标题向量的相似度
- 查看相似性的分布,并获取该分布的一些特征,如最小值、平均值、最大值和标准偏差。我们可以使用这些值作为特征
- 此外,我们可以获取平均查询向量和平均标题向量,并计算它们之间的相似性
让我们实现这一点。首先,创建一个方法来获取令牌集合的向量:
public static double[][] wordsToVec(WordEmbeddings we, List<String> tokens) {
List<double[]> vectors = new ArrayList<>(tokens.size());
for (String token : tokens) {
Optional<double[]> vector = we.representation(token);
if (vector.isPresent()) {
vectors.add(vector.get());
}
}
int nrows = vectors.size();
double[][] result = new double[nrows][];
for (int i = 0; i < nrows; i++) {
result[i] = vectors.get(i);
}
return result;
}
这里,我们使用之前创建的WordsEmbeddings类,然后对于每个令牌,我们查找它的表示,如果它存在,我们就把它放入一个矩阵中。
然后,获得所有相似性只是两个嵌入矩阵的乘法运算:
private static double[] similarities(double[][] m1, double[][] m2) {
DenseMatrix M1 = new DenseMatrix(m1);
DenseMatrix M2 = new DenseMatrix(m2);
DenseMatrix M1M2 = new DenseMatrix(M1.numRows(), M2.numRows());
M1.transBmult(M2, M1M2);
return M1M2.getData();
}
众所周知,MTJ 将矩阵的值按列存储在一维数据数组中,之前,我们将其转换为二维数组。在这种情况下,我们并不真的需要这样做,所以我们按原样取这些值。
现在,给定一个查询列表和一个来自其他字段(例如,title)的标记列表,我们计算分布特征:
int size = query.size();
List<Double> mins = new ArrayList<>(size);
List<Double> means = new ArrayList<>(size);
List<Double> maxs = new ArrayList<>(size);
List<Double> stds = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
double[][] queryEmbed = wordsToVec(glove, query.get(i));
double[][] textEmbed = wordsToVec(glove, text.get(i));
double[] similarities = similarities(queryEmbed, textEmbed);
DescriptiveStatistics stats = new DescriptiveStatistics(similarities);
mins.add(stats.getMin());
means.add(stats.getMean());
maxs.add(stats.getMax());
stds.add(stats.getStandardDeviation());
}
当然,在这里我们可以添加更多的特性,比如 25 或 75 个百分点,但是现在这四个特性已经足够了。请注意,有时 queryEmbed 或 textEmbed 可以为空,我们需要通过向每个列表添加多个NaN实例来处理这种情况。
我们还提到了另一个有用的特征,平均向量之间的相似性。我们以类似的方式计算:
List<Double> avgCos = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
double[] avgQuery = averageVector(wordsToVec(glove, query.get(i)));
double[] avgText = averageVector(wordsToVec(glove, text.get(i)));
avgCos.add(dot(avgQuery, avgText));
}
这里,点是两个向量的内积,averageVector是这样实现的:
private static double[] averageVector(double[][] rows) {
ArrayRealVector acc = new ArrayRealVector(rows[0], true);
for (int i = 1; i < rows.length; i++) {
ArrayRealVector vec = new ArrayRealVector(rows[0], false);
acc.combineToSelf(1.0, 1.0, vec);
}
double norm = acc.getNorm();
acc.mapDivideToSelf(norm);
return acc.getDataRef();
}
一旦我们计算了所有这些特征,我们就可以把它们放入一个 doubles 数组中,并用它来训练一个分类器。有许多可能的型号可供我们选择。
例如,我们可以使用 Smile 中的随机森林分类器:通常,基于树的方法非常善于发现特征之间的复杂交互,这些方法对于学习任务排序非常有效。
还有一件事我们还没有讨论:如何评价排名结果。对于排名模型有特殊的评估指标,如平均精度 ( 图)或归一化贴现累计增益 ( NDCG ),但对于我们目前的情况 AUC 绰绰有余。回想一下,对 AUC 的一种可能解释是,它对应于随机选择的阳性样本的排名高于随机选择的阴性样本的概率。
因此,AUC 非常适合这项任务,在我们的实验中,随机森林模型实现了 98%的 AUC。在这一节中,我们省略了一些代码,但是和往常一样,完整的代码可以在代码包中找到,您可以更详细地浏览特征提取管道。
用 Lucene 重新排序
在这一章中,我们已经提到 Lucene 是可以定制的,我们已经了解了如何在 Lucene 之外进行预处理,然后将结果无缝集成到 Lucene 工作流中。
当涉及到对搜索结果重新排序时,情况或多或少是相似的。常见的方法是按原样获取 Lucene 排名,并检索前 100 个(或更多)结果。然后,我们获取这些已经检索到的文档,并将排序模型应用于此以进行重新排序。
如果我们有这样一个重新排序的模型,我们需要确保我们存储了所有用于训练的数据。在我们的例子中,它是一个QueryDocumentPair类,我们从中提取相关性特征。所以让我们创建一个索引:
FSDirectory directory = FSDirectory.open(index);
WhitespaceAnalyzer analyzer = new WhitespaceAnalyzer();
IndexWriter writer = new IndexWriter(directory, new IndexWriterConfig(analyzer));
List<HtmlDocument> docs = // read documents for indexing
for (HtmlDocument htmlDoc : docs.) {
String url, title, bodyText, ... // extract the field values
Document doc = new Document();
doc.add(new Field("url", url, URL_FIELD));
doc.add(new Field("title", title, TEXT_FIELD));
doc.add(new Field("bodyText", bodyText, TEXT_FIELD));
doc.add(new Field("allHeaders", allHeaders, TEXT_FIELD));
doc.add(new Field("h1", h1, TEXT_FIELD));
doc.add(new Field("h2", h2, TEXT_FIELD));
doc.add(new Field("h3", h3, TEXT_FIELD));
writer.addDocument(doc);
}
writer.commit();
writer.close();
directory.close();
在这段代码中,HtmlDocument是一个存储文档细节的类——它们的标题、正文、标题等等。我们遍历所有的文档,并将这些信息放入 Lucene 的索引中。
在本例中,所有字段都被存储,因为稍后在查询时,我们将需要检索这些值并使用它们来计算特性。
这样,索引就建立起来了,现在,让我们来查询它:
RandomForest rf = load("project/random-forest-model.bin");
FSDirectory directory = FSDirectory.open(index);
DirectoryReader reader = DirectoryReader.open(directory);
IndexSearcher searcher = new IndexSearcher(reader);
WhitespaceAnalyzer analyzer = new WhitespaceAnalyzer();
AnalyzingQueryParser parser = new AnalyzingQueryParser("bodyText", analyzer);
String userQuery = "cheap used cars";
Query query = parser.parse(userQuery);
TopDocs result = searcher.search(query, 100);
List<QueryDocumentPair> data = wrapResults(userQuery, searcher, result);
double[][] matrix = extractFeatures(data);
double[] probs = predict(rf, matrix);
List<ScoredIndex> scored = wrapAsScoredIndex(probs);
for (ScoredIndex idx : scored) {
QueryDocumentPair doc = data.get(idx.getIndex());
System.out.printf("%.4f: %s, %s%n", idx.getScore(), doc.getTitle(), doc.getUrl());
}
在这段代码中,我们首先读取之前训练和保存的模型,然后读取索引。接下来,用户给出一个查询,我们解析它,并从索引中检索前 100 个结果。我们需要的所有值都存储在索引中,所以我们获取它们并将它们放入QueryDocumentPair+——这是在wrapResults方法中发生的事情。然后,我们提取特征,应用随机森林模型,并在将结果呈现给用户之前,使用分数对结果进行重新排序。
在特征提取步骤,遵循我们用于训练的完全相同的程序是非常重要的。否则,模型结果可能是无意义的或误导的。实现这一点的最佳方法是创建一个特殊的方法来提取特征,并在训练模型和查询时使用它。如果你需要返回 100 个以上的结果,你可以对 Lucene 返回的前 100 个条目进行重新排序,但是对 100 个以上的条目保持原来的顺序。实际上,用户很少会超过第一页,所以到达第 100 个条目的可能性很小,所以我们通常不需要麻烦地在那里重新排序文档。
让我们仔细看看wrapResults方法的内容:
List<QueryDocumentPair> data = new ArrayList<>();
for (ScoreDoc scored : result.scoreDocs) {
int docId = scored.doc;
Document doc = searcher.doc(docId);
String url = doc.get("url");
String title = doc.get("title");
String bodyText = doc.get("bodyText");
String allHeaders = doc.get("allHeaders");
String h1 = doc.get("h1");
String h2 = doc.get("h2");
String h3 = doc.get("h3");
QueryDocumentPair pair = new QueryDocumentPair(userQuery,
url, title, bodyText, allHeaders, h1, h2, h3);
data.add(pair);
}
因为所有的字段都被存储了,所以我们可以从索引中获取它们并构建QueryDocumentPair对象。然后,我们只需应用完全相同的程序进行特征提取,并将它们放入我们的模型中。
这样,我们就创建了一个基于 Lucene 的搜索引擎,然后使用机器学习模型对查询结果进行重新排序。还有很大的进一步改进空间:可以添加更多功能或获得更多训练数据,也可以尝试使用不同的模型。在下一章,我们将讨论 XGBoost,它也可以用于学习任务排序。
摘要
在这一章中,我们涵盖了信息检索和自然语言处理领域的许多基础知识,包括信息检索的基础知识以及如何将机器学习应用于文本。在这样做的时候,我们首先实现了一个简单的搜索引擎,然后在 Apache Lucene 的基础上使用了一种学习排序的方法来实现一个工业级的 IR 模型。
在下一章,我们将看看梯度提升机器,以及 XGBoost,这种算法的一种实现。这个库为许多数据科学问题提供了最先进的性能,包括分类、回归和排序。