用 Java 計算兩個向量的餘弦相似度
1. 概述
在本教程中,我們將學習如何在 Java 中計算兩個向量的餘弦相似度。我們將首先使用傳統的循環方法和更現代的Stream方法,用 Java 原生實作核心數學運算。最後,我們將看到使用ND4J函式庫如何簡化這項任務。
餘弦相似度是資料科學和資訊檢索中的關鍵指標。它衡量兩個非零向量之間夾角的餘弦值,從而有效地確定它們的相似程度。當兩個向量之間的夾角為 0 度時,相似度為 1(方向相同);當夾角為 90 度時,相似度為 0(沒有關係)。
2. 原生 Java 實現
餘弦相似度的公式依賴向量的點積(分子)和它們模的乘積(分母):
C = (A⋅B) / (∥A∥⋅∥B∥)
為了保持程式碼簡潔,我們將使用一個實用方法來計算所有三個部分。我們將在該方法內部處理向量長度和零值檢查:
static double calculateCosineSimilarity(double[] vectorA, double[] vectorB) {
if (vectorA == null || vectorB == null || vectorA.length != vectorB.length || vectorA.length == 0) {
throw new IllegalArgumentException("Vectors must be non-null, non-empty, and of the same length.");
}
double dotProduct = 0.0;
double magnitudeA = 0.0;
double magnitudeB = 0.0;
for (int i = 0; i < vectorA.length; i++) {
dotProduct += vectorA[i] * vectorB[i];
magnitudeA += vectorA[i] * vectorA[i];
magnitudeB += vectorB[i] * vectorB[i];
}
double finalMagnitudeA = Math.sqrt(magnitudeA);
double finalMagnitudeB = Math.sqrt(magnitudeB);
if (finalMagnitudeA == 0.0 || finalMagnitudeB == 0.0) {
return 0.0;
}
return dotProduct / (finalMagnitudeA * finalMagnitudeB);
}
我們的測試案例將使用兩個簡單的向量, [3, 4]和[5, 12] ,我們知道它們應該產生大約0.969的相似度:
static final double[] VECTOR_A = {3, 4};
static final double[] VECTOR_B = {5, 12};
static final double EXPECTED_SIMILARITY = 0.9692307692307692;
讓我們驗證一下原生循環實作是否能達到預期的高相似度分數:
@Test
void givenTwoHighlySimilarVectors_whenCalculatedNatively_thenReturnsHighSimilarityScore() {
double actualSimilarity = calculateCosineSimilarity(VECTOR_A, VECTOR_B);
assertEquals(EXPECTED_SIMILARITY, actualSimilarity, 1e-15);
}
我們在斷言中使用了 1e-15 的容差,因為浮點運算可能會引入較小的精度誤差。
3. 使用 Java Streams 進行本地實現
為了實現更函數式的實現,我們可以使用 Java 8 的Stream操作來重寫計算過程。我們將使用IntStream遍歷索引並執行相同的數學邏輯,只是採用更聲明式的寫入:
public static double calculateCosineSimilarityWithStreams(double[] vectorA, double[] vectorB) {
if (vectorA == null || vectorB == null || vectorA.length != vectorB.length || vectorA.length == 0) {
throw new IllegalArgumentException("Vectors must be non-null, non-empty, and of the same length.");
}
double dotProduct = IntStream.range(0, vectorA.length).mapToDouble(i -> vectorA[i] * vectorB[i]).sum();
double magnitudeA = Arrays.stream(vectorA).map(v -> v * v).sum();
double magnitudeB = IntStream.range(0, vectorA.length).mapToDouble(i -> vectorB[i] * vectorB[i]).sum();
double finalMagnitudeA = Math.sqrt(magnitudeA);
double finalMagnitudeB = Math.sqrt(magnitudeB);
if (finalMagnitudeA == 0.0 || finalMagnitudeB == 0.0) {
return 0.0;
}
return dotProduct / (finalMagnitudeA * finalMagnitudeB);
}
這種方法性能略低於傳統循環,但因其簡潔性而更受歡迎。我們將使用Streams的 reduce 運算來計算點積和幅度。
讓我們驗證一下基於流的計算是否能得出相同的預期結果:
@Test
void givenTwoHighlySimilarVectors_whenCalculatedNativelyWithStreams_thenReturnsHighSimilarityScore() {
double actualSimilarity = calculateCosineSimilarityWithStreams(VECTOR_A, VECTOR_B);
assertEquals(EXPECTED_SIMILARITY, actualSimilarity, 1e-15);
}
使用流進行複雜的數學運算可以保持程式碼簡潔,使其更易於閱讀和維護。
4. 使用ND4J進行高效能運算
對於小型單執行緒操作,原生實作已經足夠,但如果我們處理大型資料集、進行深度學習或需要 GPU 加速,則應該使用像 ND4J(Java 數值資料庫)這樣的專用數值庫。 ND4J提供卓越的性能,並且是 Deeplearning4j 生態系統的基石。
我們需要在pom.xml 檔案中加入nd4j-api依賴項:
<properties>
<nd4j.version>1.0.0-M2.1</nd4j.version>
</properties>
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-api</artifactId>
<version>${nd4j.version}</version>
</dependency>
ND4J 使用INDArray類別來表示向量和矩陣。我們將把雙精度arrays轉換為INDArray對象,然後使用框架提供的專用餘弦相似度運算:
@Test
void givenTwoHighlySimilarVectors_whenCalculatedNativelyWithCommonsMath_thenReturnsHighSimilarityScore() {
INDArray vec1 = Nd4j.create(VECTOR_A);
INDArray vec2 = Nd4j.create(VECTOR_B);
CosineSimilarity cosSim = new CosineSimilarity(vec1, vec2);
double actualSimilarity = Nd4j.getExecutioner().exec(cosSim).getDouble(0);
assertEquals(EXPECTED_SIMILARITY, actualSimilarity, 1e-15);
}
使用*Nd4j.getExecutioner().exec()*是必要的,因為 ND4J 將數學運算卸載到底層執行設備,該設備可以是 CPU 或 GPU。
5. 結論
本文介紹了在 Java 中計算餘弦相似度的實用方法。我們看到,可以使用傳統的循環或更現代的 Java Stream API 自行實作核心邏輯。
最終,對於處理大數據的生產程式碼而言,最佳選擇是像 ND4J 這樣高度優化的程式庫,它提供了卓越的效能和 GPU 功能。
本文的完整程式碼可在 GitHub 上找到。