diff --git a/selector/measures/similarity.py b/selector/measures/similarity.py index b7443592..9bf81477 100644 --- a/selector/measures/similarity.py +++ b/selector/measures/similarity.py @@ -21,7 +21,24 @@ # along with this program; if not, see # # -- -"""Similarity Module.""" +r"""Similarity Module. + +This module provides functions to calculate similarity metrics between vectors. + +Available Similarity Metrics: +----------------------------- ++-------------------+---------------------------------------------------------+-----------------------------------+ +| Metric | Similarity Formula (S) | Corresponding Distance (d) | ++===================+=========================================================+===================================+ +| Cosine | :math:`S_{cos} = \frac{a \cdot b}{\|a\| \|b\|}` | :math:`d_{cos} = 1 - S_{cos}` | ++-------------------+---------------------------------------------------------+-----------------------------------+ +| Dice (Continuous) | :math:`S_{dice} = \frac{2(a \cdot b)}{\|a\|^2+\|b\|^2}` | :math:`d_{dice} = 1 - S_{dice}` | ++-------------------+---------------------------------------------------------+-----------------------------------+ +| Tanimoto | :math:`\frac{a \cdot b}{\|a\|^2+\|b\|^2 - a \cdot b}` | :math:`d_{tan} = 1 - S_{tan}` | ++-------------------+---------------------------------------------------------+-----------------------------------+ + +Note: The Dice similarity implemented here is a continuous vector extension. +""" from itertools import combinations_with_replacement @@ -31,6 +48,8 @@ "pairwise_similarity_bit", "tanimoto", "modified_tanimoto", + "cosine", + "dice", "scaled_similarity_matrix", ] @@ -57,6 +76,8 @@ def pairwise_similarity_bit(X: np.array, metric: str) -> np.ndarray: available_methods = { "tanimoto": tanimoto, "modified_tanimoto": modified_tanimoto, + "cosine": cosine, + "dice": dice, } if metric not in available_methods: raise ValueError( @@ -195,6 +216,94 @@ def modified_tanimoto(a: np.array, b: np.array) -> float: return mt +def cosine(a: np.array, b: np.array) -> float: + r"""Compute Cosine similarity coefficient. + + For two vectors :math:`a` and :math:`b`, Cosine similarity is defined as the + dot product of the vectors divided by the product of their lengths: + + .. math:: + S_{cos}(a, b) = \frac{a \cdot b}{\|a\| \|b\|} + + The corresponding cosine distance is defined as: + + .. math:: + d_{cos}(a, b) = 1 - S_{cos}(a, b) + + Note that this corresponds to the distance metric used in ``scipy.spatial.distance.cosine``. + If either vector has a norm of zero, the similarity is defined as 0.0. + + Parameters + ---------- + a : ndarray of shape (n_features,) + The 1D feature array of sample :math:`a` in an `n_features` dimensional space. + b : ndarray of shape (n_features,) + The 1D feature array of sample :math:`b` in an `n_features` dimensional space. + + Returns + ------- + coeff : float + Cosine similarity coefficient between feature arrays :math:`a` and :math:`b`. + """ + if a.ndim != 1 or b.ndim != 1: + raise ValueError(f"Arguments a and b should be 1D arrays, got {a.ndim} and {b.ndim}") + if a.shape != b.shape: + raise ValueError( + f"Arguments a and b should have the same shape, got {a.shape} != {b.shape}" + ) + + norm_a = np.linalg.norm(a) + norm_b = np.linalg.norm(b) + + if norm_a == 0.0 or norm_b == 0.0: + return 0.0 + + return float(np.dot(a, b) / (norm_a * norm_b)) + + +def dice(a: np.array, b: np.array) -> float: + r"""Compute Dice (Sørensen-Dice) similarity coefficient. + + For two vectors :math:`a` and :math:`b`, Dice similarity is defined as: + + .. math:: + S_{dice}(a, b) = \frac{2 (a \cdot b)}{\|a\|^2 + \|b\|^2} + + The corresponding distance is: + + .. math:: + d_{dice}(a, b) = 1 - S_{dice}(a, b) + + This is a continuous vector extension of the binary Dice similarity. + If the norm of both vectors is zero, the similarity is defined as 0.0. + For vectors containing negative values, the output may fall outside the [0, 1] range. + + Parameters + ---------- + a : ndarray of shape (n_features,) + The 1D feature array of sample :math:`a` in an `n_features` dimensional space. + b : ndarray of shape (n_features,) + The 1D feature array of sample :math:`b` in an `n_features` dimensional space. + + Returns + ------- + coeff : float + Dice similarity coefficient between feature arrays :math:`a` and :math:`b`. + """ + if a.ndim != 1 or b.ndim != 1: + raise ValueError(f"Arguments a and b should be 1D arrays, got {a.ndim} and {b.ndim}") + if a.shape != b.shape: + raise ValueError( + f"Arguments a and b should have the same shape, got {a.shape} != {b.shape}" + ) + + denom = float(np.sum(a**2) + np.sum(b**2)) + if denom == 0.0: + return 0.0 + + return float(2 * np.dot(a, b) / denom) + + def scaled_similarity_matrix(X: np.array) -> np.ndarray: r"""Compute the scaled similarity matrix. diff --git a/selector/methods/tests/test_similarity.py b/selector/methods/tests/test_similarity.py index b246b619..9a1a27c4 100644 --- a/selector/methods/tests/test_similarity.py +++ b/selector/methods/tests/test_similarity.py @@ -36,6 +36,8 @@ pairwise_similarity_bit, scaled_similarity_matrix, tanimoto, + cosine, + dice, ) from selector.methods.similarity import NSimilarity, SimilarityIndex from selector.methods.tests.common import get_data_file_path @@ -1608,3 +1610,96 @@ def test_NSimilarity_esim_select(c_threshold, w_factor, sample_size, n_ary, star # check if the selected data is equal to the reference data assert all(x in ref_list for x in selected_data) + + +def test_cosine_raises(): + # check raised error when a or b is not 1D + assert_raises(ValueError, cosine, np.random.random((1, 5)), np.random.random(5)) + assert_raises(ValueError, cosine, np.random.random(3), np.random.random((1, 4))) + assert_raises(ValueError, cosine, np.random.random(4), np.random.random((3, 4))) + assert_raises(ValueError, cosine, np.random.random((3, 3)), np.random.random((2, 3))) + # check raised error when a and b don't have the same length + assert_raises(ValueError, cosine, np.random.random(3), np.random.random(5)) + assert_raises(ValueError, cosine, np.random.random(20), np.random.random(10)) + + +def test_cosine_numerical_correctness(): + """Test cosine similarity for known numerical values.""" + a = np.array([1, 0]) + b = np.array([1, 1]) + assert pytest.approx(cosine(a, b)) == 1 / np.sqrt(2) + + +def test_cosine_identical_vectors(): + """Test cosine similarity for identical vectors.""" + a = np.array([1, 2, 3]) + b = np.array([1, 2, 3]) + assert pytest.approx(cosine(a, b)) == 1.0 + + +def test_cosine_orthogonal_vectors(): + """Test cosine similarity for orthogonal vectors.""" + a = np.array([1, 0]) + b = np.array([0, 1]) + assert pytest.approx(cosine(a, b)) == 0.0 + + +def test_cosine_zero_vectors(): + """Test cosine similarity when one or both vectors are zero.""" + a = np.array([0, 0]) + b = np.array([1, 1]) + c = np.array([0, 0]) + assert cosine(a, b) == 0.0 + assert cosine(b, a) == 0.0 + assert cosine(a, c) == 0.0 + + +def test_cosine_matrix(): + """Test pairwise cosine similarity matrix.""" + x = np.array([[1, 0], [0, 1]]) + s = pairwise_similarity_bit(x, "cosine") + expected = np.array([[1.0, 0.0], [0.0, 1.0]]) + assert_equal(s, expected) + + +def test_dice_raises(): + # check raised error when a or b is not 1D + assert_raises(ValueError, dice, np.random.random((1, 5)), np.random.random(5)) + assert_raises(ValueError, dice, np.random.random(3), np.random.random((1, 4))) + assert_raises(ValueError, dice, np.random.random(4), np.random.random((3, 4))) + assert_raises(ValueError, dice, np.random.random((3, 3)), np.random.random((2, 3))) + # check raised error when a and b don't have the same length + assert_raises(ValueError, dice, np.random.random(3), np.random.random(5)) + assert_raises(ValueError, dice, np.random.random(20), np.random.random(10)) + + +def test_dice_numerical_correctness(): + """Test dice similarity for known numerical values.""" + a = np.array([1, 0, 1]) + b = np.array([1, 1, 0]) + assert pytest.approx(dice(a, b)) == 0.5 + + +def test_dice_identical_vectors(): + """Test dice similarity for identical vectors.""" + a = np.array([1, 2, 3]) + b = np.array([1, 2, 3]) + assert pytest.approx(dice(a, b)) == 1.0 + + +def test_dice_zero_vectors(): + """Test dice similarity when one or both vectors are zero.""" + a = np.array([0, 0]) + b = np.array([1, 1]) + c = np.array([0, 0]) + assert dice(a, b) == 0.0 + assert dice(b, a) == 0.0 + assert dice(a, c) == 0.0 + + +def test_dice_matrix(): + """Test pairwise dice similarity matrix.""" + x = np.array([[1, 0, 1], [1, 1, 0]]) + s = pairwise_similarity_bit(x, "dice") + expected = np.array([[1.0, 0.5], [0.5, 1.0]]) + assert_almost_equal(s, expected)