はじめに (対象読者・この記事でわかること)
この記事は、JavaScriptでテキストデータや数値データの類似度を測るためにコサイン類似度を使おうとしている方、または既に実装しているが意図しない結果に悩んでいる方を対象にしています。
この記事を読むことで、コサイン類似度の基本的な計算方法を再確認し、特にJavaScript環境でその計算結果が期待通りにならない主な原因(データの前処理、浮動小数点数誤差、データの構造問題)とその具体的な解決策を理解できます。これらの知識を習得することで、より堅牢で正確なコサイン類似度計算を実装できるようになるでしょう。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。 * JavaScriptの基本的な構文(配列、関数、ループなど) * 線形代数の基礎(ベクトル、内積、ノルムの概念) * 簡単な自然言語処理の概念(単語のベクトル表現など)
コサイン類似度とは?そしてなぜ期待を裏切るのか?
コサイン類似度は、主にデータ間の「類似性」を測るために用いられる指標です。2つのベクトルがなす角度のコサイン値として定義され、その値は-1から1の範囲を取ります。1に近いほどベクトル間の方向が近く、類似度が高いと判断されます。
コサイン類似度の計算式
コサイン類似度は、2つのベクトル A と B の内積を、それぞれのベクトルのノルム(大きさ)の積で割ることで算出されます。
similarity(A, B) = (A ⋅ B) / (||A|| * ||B||)
- 内積 (Dot Product):
A ⋅ B = Σ(Ai * Bi)(各要素の積の合計) - ノルム (Magnitude):
||A|| = sqrt(Σ(Ai^2))(ベクトルの大きさ、ユークリッド距離)
この計算式自体はシンプルですが、いざJavaScriptで実装し、現実のデータに適用すると、「なぜか期待通りの類似度にならない」「計算結果が直感と違う」といった問題に直面することが少なくありません。
期待と異なる結果が出る主な背景には、大きく分けて以下の2点があります。
- データそのものの問題: 人間が「似ている」と感じるデータと、数学的に「似ている」と判断されるデータの間にはギャップがあることがあります。これは主に、データの前処理が不適切または不足していることによって引き起こされます。例えば、「走る」と「走った」が同じ意味でも、適切な前処理がなければ異なるベクトルとして扱われます。
- 計算上の問題: プログラミング言語が数値を扱う際の特性、特に浮動小数点数演算の誤差が、ごくわずかながら計算結果に影響を及ぼし、それが累積して類似度判定にずれを生じさせることがあります。
次のセクションでは、これらの具体的な原因と、JavaScriptでコサイン類似度を正確に扱うための実践的なアプローチを詳しく見ていきましょう。
JavaScriptでコサイン類似度を正確に扱うための実践的アプローチ
ここでは、基本的なコサイン類似度関数の実装から始め、期待と異なる結果が出た際の具体的な原因と対策について、コード例を交えて詳しく解説します。
ステップ1: 基本的なコサイン類似度関数の実装
まずは、コサイン類似度を計算するためのJavaScript関数を実装します。
Javascript/** * 2つのベクトルの内積を計算します。 * @param {number[]} vecA - 最初のベクトル。 * @param {number[]} vecB - 2番目のベクトル。 * @returns {number} 内積の値。 */ function dotProduct(vecA, vecB) { let product = 0; // ベクトルの要素数が異なる場合はエラーハンドリングが必要ですが、 // この関数では簡易的に同じ長さであることを前提とします。 for (let i = 0; i < vecA.length; i++) { product += vecA[i] * vecB[i]; } return product; } /** * ベクトルのノルム(大きさ)を計算します。 * @param {number[]} vec - 計算対象のベクトル。 * @returns {number} ノルムの値。 */ function magnitude(vec) { let sumSq = 0; for (let i = 0; i < vec.length; i++) { sumSq += vec[i] * vec[i]; } return Math.sqrt(sumSq); } /** * 2つのベクトルのコサイン類似度を計算します。 * @param {number[]} vecA - 最初のベクトル。 * @param {number[]} vecB - 2番目のベクトル。 * @returns {number} コサイン類似度の値。 * @throws {Error} ベクトルの次元が異なる場合。 */ function cosineSimilarity(vecA, vecB) { if (vecA.length !== vecB.length) { throw new Error("ベクトルの次元が異なります。同じ次元のベクトルを入力してください。"); } const dot = dotProduct(vecA, vecB); const magA = magnitude(vecA); const magB = magnitude(vecB); // ゼロベクトルが含まれる場合、類似度を0とします(定義による)。 if (magA === 0 || magB === 0) { return 0; } return dot / (magA * magB); } // 例: 基本的な使用 const vector1 = [1, 2, 3]; const vector2 = [4, 5, 6]; const vector3 = [1, 1, 1]; // vector1とは方向が近い const vector4 = [-1, -2, -3]; // vector1と完全に逆方向 console.log("Vector1 と Vector2 のコサイン類似度:", cosineSimilarity(vector1, vector2)); // 約 0.97 console.log("Vector1 と Vector3 のコサイン類似度:", cosineSimilarity(vector1, vector3)); // 約 0.92 console.log("Vector1 と Vector4 のコサイン類似度:", cosineSimilarity(vector1, vector4)); // -1 (完全に逆方向) // 非常にわずかな違いがある場合 const vector5 = [1, 0, 0]; const vector6 = [1, 0.0000000000000001, 0]; console.log("Vector5 と Vector6 のコサイン類似度:", cosineSimilarity(vector5, vector6)); // 1 に非常に近い値 (浮動小数点誤差の影響)
この基本関数は正しく動作しますが、実際のデータで「期待と違う結果」が出るのは、主に以下の原因が考えられます。
ステップ2: 期待と異なる結果の原因と対策
原因1: データの前処理の不足・不適切さ
特にテキストデータをベクトル化して類似度を測る場合、前処理は結果の精度に決定的な影響を与えます。数値データの場合も、異なるスケールのデータを扱う際には正規化が重要になります。
説明: * テキストデータ: 「犬」と「いぬ」や「dog」は人間にとっては同じ意味ですが、コンピュータ上では異なる文字列として扱われます。また、「は」「を」といった助詞(ストップワード)は文書の内容を識別する上であまり重要でないことが多いですが、これらがベクトルに含まれると類似度計算にノイズを与えてしまいます。ステミング(単語の語幹への変換)やレンマタイゼーション(単語を基本形に戻す)も同様に重要です。さらに、TF-IDFのような重み付け手法を用いないと、頻繁に出現するが重要でない単語(例:「の」)が過剰に評価されることがあります。 * 数値データ: 年齢(例: 0〜100)と所得(例: 0〜1億円)のように、値の範囲(スケール)が大きく異なる複数の数値項目をベクトルとして扱う場合、値の大きな項目が類似度計算に支配的な影響を与えてしまうことがあります。
対策: * テキストデータの前処理例: ```javascript function preprocessText(text) { // 1. 小文字化 let processedText = text.toLowerCase(); // 2. 句読点・数字・特殊記号の除去 (アルファベットとスペースのみ残す) processedText = processedText.replace(/[^a-z\s]/g, ''); // 3. 複数のスペースを1つにまとめる processedText = processedText.replace(/\s+/g, ' ').trim(); // 4. ストップワードの除去 (簡易的な例) const stopwords = new Set(['a', 'an', 'the', 'is', 'and', 'or', 'to', 'in', 'of']); const tokens = processedText.split(' ').filter(word => !stopwords.has(word)); return tokens.join(' '); // 再び文字列に戻すか、単語配列として返す }
// 例えば、単語の出現頻度に基づいてベクトル化する場合
function createVectorFromText(text, vocabulary) {
const tokens = preprocessText(text).split(' ');
const vector = new Array(vocabulary.length).fill(0);
tokens.forEach(token => {
const index = vocabulary.indexOf(token);
if (index !== -1) {
vector[index]++;
}
});
return vector;
}
const vocab = ['apple', 'banana', 'orange', 'sweet', 'fruit', 'good'];
const text1 = "Apple is a sweet fruit.";
const text2 = "Banana and orange are good fruits.";
const text3 = "This is a good apple.";
const vecText1 = createVectorFromText(text1, vocab); // [1, 0, 0, 1, 1, 0] (apple, sweet, fruit)
const vecText2 = createVectorFromText(text2, vocab); // [0, 1, 1, 0, 1, 1] (banana, orange, fruit, good)
const vecText3 = createVectorFromText(text3, vocab); // [1, 0, 0, 0, 0, 1] (apple, good)
console.log("「Apple is a sweet fruit.」と「This is a good apple.」の類似度:", cosineSimilarity(vecText1, vecText3));
// 前処理なしでは似ていないと判断される可能性のある文書が、適切にベクトル化され類似度が高くなる
```
-
数値データの正規化例 (Min-Maxスケーリング): ```javascript function minMaxNormalize(value, min, max) { return (value - min) / (max - min); }
// 例: 年齢と収入という異なるスケールのデータ // データポイント: [年齢, 収入] const dataPoints = [ [30, 5000000], // Aさん [32, 5500000], // Bさん (Aさんと似ているはず) [60, 4000000] // Cさん ];
// 各特徴量の最大値と最小値を事前に把握 const minAge = 0, maxAge = 100; const minIncome = 0, maxIncome = 10000000;
const normalizedDataPoints = dataPoints.map(point => [ minMaxNormalize(point[0], minAge, maxAge), minMaxNormalize(point[1], minIncome, maxIncome) ]);
console.log("正規化後のAさんとBさんの類似度:", cosineSimilarity(normalizedDataPoints[0], normalizedDataPoints[1])); console.log("正規化後のAさんとCさんの類似度:", cosineSimilarity(normalizedDataPoints[0], normalizedDataPoints[2])); // 正規化することで、年齢と収入のスケールの違いによる影響が緩和され、より適切な類似度が算出される。 ```
原因2: 浮動小数点数誤差
JavaScriptの数値はIEEE 754倍精度浮動小数点数で表現されるため、ごくわずかな誤差が生じることがあります。
説明:
特に、0.1 + 0.2 が 0.3 ではなく 0.30000000000000004 になるような誤差は有名です。コサイン類似度の計算では、内積やノルムの計算で多くの加算・乗算が行われるため、この誤差が累積し、最終的な類似度結果が微妙にずれることがあります。例えば、本来なら1.0になるべき類似度が0.9999999999999999になったり、逆に1.0000000000000001になったりすることがあります。この誤差が、===による厳密な比較を行う際に問題となることがあります。
対策: * 丸め処理: 計算結果を適切な小数点以下で丸めることで、見た目の誤差を解消できます。ただし、丸めすぎると情報が失われる可能性もあるため注意が必要です。 ```javascript // 計算結果を小数点以下 N桁で丸める function roundCosineSimilarity(similarity, decimalPlaces = 15) { return parseFloat(similarity.toFixed(decimalPlaces)); }
const vecA = [1, 0];
const vecB = [1, 0.00000000000000001]; // ごくわずかな違い
let sim = cosineSimilarity(vecA, vecB);
console.log("誤差を含む類似度:", sim); // 1.0000000000000002 など
console.log("丸めた類似度 (15桁):", roundCosineSimilarity(sim)); // 1
```
-
許容誤差 (Epsilon) による比較: 厳密な等価比較 (
===) の代わりに、ごくわずかな差を許容する比較を行います。 ```javascript const EPSILON = Number.EPSILON; // JavaScriptに組み込まれている最小差分function areSimilarEnough(val1, val2, epsilon = EPSILON) { return Math.abs(val1 - val2) < epsilon; }
console.log("1 と 0.9999999999999999 を厳密に比較:", 1 === 0.9999999999999999); // false console.log("1 と 0.9999999999999999 を許容誤差で比較:", areSimilarEnough(1, 0.9999999999999999)); // true
`` * **高精度計算ライブラリの利用:** 非常に高い精度が求められる場合は、decimal.jsやbig.js` のような任意精度演算ライブラリの使用を検討します。これらは文字列として数値を扱い、浮動小数点誤差を回避しますが、パフォーマンスコストは増加します。
原因3: ベクトルの次元不一致やデータ形式の問題
コサイン類似度は、比較対象のベクトルが同じ次元(要素数)である必要があります。また、ベクトル内に非数値データや NaN、Infinity が含まれていると、計算が破綻します。
説明:
例えば、あるテキストは300個の単語でベクトル化され、別のテキストは250個の単語でベクトル化された場合、そのままではコサイン類似度を計算できません。また、データ処理のミスや欠損値の扱いの誤りにより、ベクトル要素に NaN や Infinity が混入すると、計算結果が NaN になります。
対策: * 入力バリデーション: コサイン類似度関数を呼び出す前に、ベクトルの長さが一致しているか、すべての要素が有限な数値であるかをチェックします。 ```javascript function isValidVector(vec) { if (!Array.isArray(vec)) return false; if (vec.length === 0) return false; // 空のベクトルは計算できない return vec.every(num => typeof num === 'number' && Number.isFinite(num)); }
function cosineSimilarityRobust(vecA, vecB) {
if (!isValidVector(vecA) || !isValidVector(vecB)) {
throw new Error("入力されたベクトルが無効です。数値の配列であることを確認してください。");
}
if (vecA.length !== vecB.length) {
throw new Error("ベクトルの次元が異なります。同じ次元のベクトルを入力してください。");
}
// ... (以降はステップ1のcosineSimilarity関数と同じロジック)
const dot = dotProduct(vecA, vecB);
const magA = magnitude(vecA);
const magB = magnitude(vecB);
if (magA === 0 || magB === 0) {
return 0;
}
return dot / (magA * magB);
}
// 例
try {
console.log(cosineSimilarityRobust([1, 2, 3], [1, 2])); // エラー
console.log(cosineSimilarityRobust([1, 2, NaN], [4, 5, 6])); // エラー
} catch (e) {
console.error("エラーが発生しました:", e.message);
}
```
- データクレンジング: ベクトル生成の段階で、欠損値の補完や異常値の除去など、データの品質を保証する処理を徹底します。
原因4: データの内容とアルゴリズムのミスマッチ
コサイン類似度はベクトルの「方向」のみに注目し、「大きさ」は考慮しません。これが、あなたの意図する「類似度」と異なる場合があります。
説明:
例えば、ベクトル A = [1, 1] と B = [100, 100] のコサイン類似度は 1.0 です。これは、両者が同じ方向を指しているためです。しかし、もしあなたが「数値的な差が小さいデータほど似ている」と考えている場合、[1, 1] と [2, 2] の方が [1, 1] と [100, 100] よりも似ていると直感的に思うかもしれません。この場合、コサイン類似度は適切ではありません。
対策:
* コサイン類似度の特性を理解する: コサイン類似度が「方向の類似度」であることを再確認し、あなたの解決したい問題に本当に適しているか検討します。
* 他の類似度指標の検討: 「大きさ」も考慮したい場合は、ユークリッド距離(Euclidean distance)やマンハッタン距離(Manhattan distance)など、他の距離指標や類似度指標の使用を検討します。
* ユークリッド距離: distance(A, B) = sqrt(Σ(Ai - Bi)^2)。値が小さいほど似ている。
* 相関係数: データの線形関係の強さを示す。
目的に合わせて、適切な指標を選択することが重要です。
ハマった点やエラー解決
コサイン類似度で「思った通りの結果と違う」という場合に、よく遭遇するハマりポイントは次のようなものです。
- テキストの類似度測定で、意味的に同じなのに類似度が低い:
- 例:「JavaScriptの学習方法」と「Java Scriptの勉強法」を比較したら、類似度が低い。
- 原因: 前処理不足(小文字化、スペースの正規化、同義語処理、ステミングなど)。
- 解決策: テキストデータに対し、正規化(小文字化、句読点除去)、単語分割、ストップワード除去、必要に応じてステミングやTF-IDFなど、目的に合った適切な前処理パイプラインを構築する。
- 計算結果がわずかに1.0にならない、または0.0にならない:
- 例:全く同じベクトル同士を比較しても、類似度が
0.9999999999999999になる。 - 原因: 浮動小数点数演算の精度誤差。
- 解決策: 計算結果の丸め処理 (
toFixed()) や、比較には許容誤差 (Number.EPSILON) を用いる。
- 例:全く同じベクトル同士を比較しても、類似度が
- 特定の入力ベクトルで
NaNが返される:- 例:
cosineSimilarity([1, 2, 3], [4, 5])のように、次元が異なるベクトルを入力した。 - 例:
cosineSimilarity([1, NaN], [2, 3])のように、ベクトルに非数値が含まれていた。 - 原因: ベクトルの次元不一致、または非数値(NaN, Infinity)の混入。
- 解決策: 関数内で入力ベクトルの次元チェックと要素の数値チェック (
Number.isFinite()) を厳密に行う。
- 例:
まとめ
本記事では、JavaScriptでコサイン類似度を計算する際に「思った通りの結果と違う」という問題に直面する理由と、その解決策について解説しました。
- データの前処理の重要性: 特にテキストデータや異なるスケールの数値データにおいて、適切な正規化やクリーニングが類似度結果の正確性を大きく左右します。
- 浮動小数点数誤差への対応: JavaScriptの数値計算における微細な誤差が結果に影響を与える可能性があるため、丸め処理や許容誤差を用いた比較が有効です。
- ベクトルの健全性チェック: 次元の一致や、
NaN、Infinityなどの不正な値の排除が、計算の安定性と正確性を保証します。 - コサイン類似度の特性理解: コサイン類似度は「方向」の類似度であり、「大きさ」は考慮しない点を理解し、目的と合致しない場合は他の類似度指標も検討することが重要です。
これらのポイントを押さえることで、JavaScriptにおけるコサイン類似度の計算をより正確かつ意図通りに活用できるようになるでしょう。
今後は、より高度なテキストベクトル化(Word2Vec, BERT埋め込みなど)や、これらの技術を組み合わせた実践的なアプリケーション開発についても記事にする予定です。
参考資料
- コサイン類似度 - Wikipedia
- JavaScriptでの数値の精度問題 (MDN Web Docs)
- decimal.js - 任意精度算術ライブラリ
- 自然言語処理の基礎(ステミング、レンマタイゼーション、TF-IDFなど)