diff --git a/README.md b/README.md index 68cde49..1a1d595 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,8 @@ print(ba_rehash) ## Bitarray similarity `fable_core.similarity` contains functions for computing the similarity of bitarrays. -It implements the Dice coefficient, Cosine similarity and the Jaccard index. +The Dice coefficient, Cosine similarity and the Jaccard index are three examples of implemented similarity measures. +Have a look at the module to see what measures are currently implemented. ```python from fable_core import similarity @@ -173,27 +174,38 @@ print(ba_1) print(ba_2) # => bitarray('01001000111110011011100100101000') -# For all similarity functions, let n1 and n2 be the amount of set bits in ba_1 and ba_2 respectively, -# and let n12 be the amount of set bits in the intersection of ba_1 and ba_2. +# For all similarity functions, let c and b be the amount of set bits in either ba_1 or ba_2. +# Let a be the amount of set bits in the intersection and d the amount bits where both bitarrays are set to 0. # In ba_1 and ba_2, there are only 3 positions where bits are set in both bitarrays. Each similarity # function will treat this a bit differently. print((ba_1 & ba_2).count()) # => 3 -# Dice coefficient (2 * n12 / (n1 + n2)) +# Dice coefficient (2 * a / (2 * a + b + c)) print(similarity.dice(ba_1, ba_2)) # => 0.2222222222222222 -# Cosine similarity (n12 / sqrt(n1 * n2)) +# Cosine similarity (a / sqrt((a + b) * (a + c))) print(similarity.cosine(ba_1, ba_2)) # => 0.22360679774997896 -# Jaccard index (n12 / (n1 + n2 - n12)) +# Jaccard index (a / (a + b + c)) print(similarity.jaccard(ba_1, ba_2)) # => 0.125 ``` +## Similarity aggregation + +`fable_core.aggregation` contains functions for aggregating multiple similarity into one. + +```python +from fable_core import aggregation + +print(aggregation.average(similarities=[0.5, 0.8], weights=[2, 1])) +# => 0.6 +``` + ## String transformation `fable_core.transform` contains factory functions for performing preprocessing on strings. diff --git a/fable_core/__init__.py b/fable_core/__init__.py index bb30b2b..acca459 100644 --- a/fable_core/__init__.py +++ b/fable_core/__init__.py @@ -1,3 +1,3 @@ -from . import bits, common, harden, phonetics_extra, similarity, transform +from . import bits, common, harden, phonetics_extra, similarity, transform, aggregation -__all__ = ["bits", "common", "harden", "phonetics_extra", "similarity", "transform"] +__all__ = ["bits", "common", "harden", "phonetics_extra", "similarity", "transform", "aggregation"] diff --git a/fable_core/aggregation.py b/fable_core/aggregation.py new file mode 100644 index 0000000..76f544d --- /dev/null +++ b/fable_core/aggregation.py @@ -0,0 +1,46 @@ +__all__ = [ + "AggregationFn", + "average", + "maximum", + "minimum", +] + +from typing import Any, Protocol + + +class AggregationFn(Protocol): + def __call__(self, similarities: list[float], /, **kwargs: Any) -> float: ... + + +def _average(similarities: list[float], /, weights: list[float] | None = None) -> float: + """ + Compute the weighted average of a similarity vector. + + Args: + similarities: list of similarities + weights: list of weights + + Returns: + weighted average of similarities + + Raises: + ValueError: if length of similarities does not equal length of weights + """ + if weights is None: + weights = [1] * len(similarities) + if len(weights) != len(similarities): + raise ValueError("There need to be as many weights as there are similarities.") + return sum(threshold * weight for threshold, weight in zip(similarities, weights, strict=True)) / sum(weights) + + +# Wrap aggregators so that the type checker does not complain. +def average(similarities: list[float], **kwargs: Any) -> float: + return _average(similarities, **kwargs) + + +def maximum(similarities: list[float], **kwargs: Any) -> float: + return max(similarities, **kwargs) + + +def minimum(similarities: list[float], **kwargs: Any) -> float: + return min(similarities, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index 82ef138..c45f9f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fable-core" -version = "0.1.5" +version = "0.2.0" description = "Facilities required for performing privacy-preserving record linkage with Bloom filters in the FABLE ecosystem." authors = [ {name = "Maximilian Jugl"}, diff --git a/tests/test_aggregation.py b/tests/test_aggregation.py new file mode 100644 index 0000000..21558bc --- /dev/null +++ b/tests/test_aggregation.py @@ -0,0 +1,22 @@ +import pytest + +from fable_core import aggregation + + +@pytest.mark.parametrize( + "similarities,weights,expected", + [ + ([0.6, 0.3, 0.6], None, 0.5), + ([0.6, 0.3, 0.6], [1, 1, 1], 0.5), + ([0.5, 0.8], [2, 1], 0.6), + ], +) +def test_average_aggregation(similarities, weights, expected): + assert aggregation.average(similarities, weights=weights) == expected + + +def test_average_value_error(): + with pytest.raises(ValueError) as e: + aggregation.average([0.5, 0.7, 0.8], weights=[2, 3.2]) + + assert "There need to be as many weights as there are similarities." in str(e.value)