Repository navigation
Fix CosineSimilarity precision so identical inputs give 1.0 and distance 0.0 - #789
atharv1205 wants to merge 2 commits into
Conversation
CosineSimilarity divided the dot product by Math.sqrt(d1) * Math.sqrt(d2). Rounding each square root separately can leave the product one ulp off, so identical vectors gave 1.0000000000000002 or 0.9999999999999998 and CosineDistance returned small non-zero, even negative, values such as -2.220446049250313E-16 for "a b c". Compute dotProduct / Math.sqrt(d1 * d2) instead. For identical vectors d1 * d2 is an exact square, so the result is exactly 1.0. Clamp with Math.min(1.0, ...) so rounding on other inputs cannot exceed 1.0. Add tests that identical inputs give similarity 1.0 and distance 0.0, and that both stay within [0.0, 1.0] over generated inputs.
agammann
left a comment
There was a problem hiding this comment.
The new range test and PR description treat cosine similarity as always lying in [0, 1], but this public method accepts signed Integer vector components without a nonnegative precondition. For example, {x=1} versus {x=-1} correctly has cosine similarity -1; only the token-frequency vectors used by CosineDistance are necessarily nonnegative. Could the test name/description make that scope explicit and add a signed-vector case, so a future lower clamp does not discard valid negative similarities?
The range test only covers non-negative vectors, such as the token counts built by CosineDistance. The method accepts signed components, so add a test that negative similarities are kept: a vector versus its negation gives exactly -1.0, and opposite-signed vectors stay within [-1.0, 0.0].
|
Thanks @agammann, good point. Only the upper bound is clamped, so negative similarities are kept. I renamed the range test to |
|
Thanks, I rechecked |
What
CosineSimilarity.cosineSimilaritycan return values slightly above or below 1.0 for identical inputs, soCosineDistancereturns tiny non-zero, even negative, distances:CosineDistance.apply("a b", "a b")CosineDistance.apply("a b c", "a b c")CosineDistance.apply("a b c d e f", "a b c d e f")Over 10,000 random identical token maps, 2093 returned a similarity above 1.0 before this change.
Why
The denominator was
Math.sqrt(d1) * Math.sqrt(d2). Each square root is rounded separately and their product can be off by one ulp in either direction, sodot / normmisses 1.0 for identical vectors.How
Compute
dotProduct / Math.sqrt(d1 * d2). For identical vectorsd1 * d2is an exact square, andMath.sqrtis correctly rounded, so the result is exactly 1.0. The result is also clamped withMath.min(1.0, ...)so rounding on other inputs cannot exceed 1.0. Only the upper bound is clamped: the method accepts signed vector components, so its range is [-1.0, 1.0], and negative similarities such as{x=1}versus{x=-1}(exactly -1.0) are kept. Thed1 <= 0 || d2 <= 0branch is unchanged.Tests
CosineSimilarityTest: identical vectors of several sizes give exactly 1.0; non-negative vectors (the token countsCosineDistancebuilds) stay within [0.0, 1.0]; signed vectors keep negative similarities: a vector versus its negation gives exactly -1.0, and opposite-signed vectors stay within [-1.0, 0.0].CosineDistanceTest: identical inputs ("a", "a b", "a b c", "a b c d e f", ...) give exactly 0.0; distance stays within [0.0, 1.0] over generated inputs.mvn; that'smvnon the command line by itself.