Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 110 additions & 1 deletion selector/measures/similarity.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,24 @@
# along with this program; if not, see <http://www.gnu.org/licenses/>
#
# --
"""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

Expand All @@ -31,6 +48,8 @@
"pairwise_similarity_bit",
"tanimoto",
"modified_tanimoto",
"cosine",
"dice",
"scaled_similarity_matrix",
]

Expand All @@ -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(
Expand Down Expand Up @@ -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.

Expand Down
95 changes: 95 additions & 0 deletions selector/methods/tests/test_similarity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading