From 4b7cdecf948c986273bf31606c19508522f10ee5 Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Wed, 15 Sep 2021 19:15:42 +0200 Subject: [PATCH 01/57] Remove 3.9 from compatibility --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 46e5d2c9..c32755ff 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,7 +41,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ['3.6', '3.7', '3.8', '3.9'] + python-version: ['3.6', '3.7', '3.8'] steps: - uses: actions/checkout@v2 - name: Set up Python From 0147c0c1601484251ab035fcfb66ca661e7b030a Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 17 Sep 2021 11:50:07 +0200 Subject: [PATCH 02/57] FIrst draft of bilinear mixin --- metric_learn/base_metric.py | 52 +++++++++++++++++++++++++++++++++++++ metric_learn/oasis.py | 20 ++++++++++++++ test_bilinear.py | 15 +++++++++++ 3 files changed, 87 insertions(+) create mode 100644 metric_learn/oasis.py create mode 100644 test_bilinear.py diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 721d7ba0..f5600bb1 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -160,6 +160,58 @@ def transform(self, X): Input data transformed to the metric space by :math:`XL^{\\top}` """ +class BilinearMixin(BaseMetricLearner, metaclass=ABCMeta): + + def score_pairs(self, pairs): + r""" + Parameters + ---------- + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the metric learner uses a + preprocessor. + + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The learned Mahalanobis distance for every pair. + """ + check_is_fitted(self, ['preprocessor_', 'components_']) + pairs = check_input(pairs, type_of_inputs='tuples', + preprocessor=self.preprocessor_, + estimator=self, tuple_size=2) + return np.dot(np.dot(pairs[:, 1, :], self.components_), pairs[:, 0, :].T) + + def get_metric(self): + check_is_fitted(self, 'components_') + components = self.components_.copy() + + def metric_fun(u, v): + """This function computes the metric between u and v, according to the + previously learned metric. + + Parameters + ---------- + u : array-like, shape=(n_features,) + The first point involved in the distance computation. + + v : array-like, shape=(n_features,) + The second point involved in the distance computation. + + Returns + ------- + distance : float + The distance between u and v according to the new metric. + """ + u = validate_vector(u) + v = validate_vector(v) + return np.dot(np.dot(u, components), v.T) + + return metric_fun + + def get_bilinear_matrix(self): + check_is_fitted(self, 'components_') + return self.components_ class MahalanobisMixin(BaseMetricLearner, MetricTransformer, metaclass=ABCMeta): diff --git a/metric_learn/oasis.py b/metric_learn/oasis.py new file mode 100644 index 00000000..746b6d10 --- /dev/null +++ b/metric_learn/oasis.py @@ -0,0 +1,20 @@ +from .base_metric import BilinearMixin +import numpy as np + +class OASIS(BilinearMixin): + + def __init__(self, preprocessor=None): + super().__init__(preprocessor=preprocessor) + + def fit(self, X, y): + """ + Fit OASIS model + + Parameters + ---------- + X : (n x d) array of samples + y : (n) data labels + """ + X = self._prepare_inputs(X, y, ensure_min_samples=2) + self.components_ = np.identity(np.shape(X[0])[-1]) # Identity matrix + return self \ No newline at end of file diff --git a/test_bilinear.py b/test_bilinear.py new file mode 100644 index 00000000..ad03bb48 --- /dev/null +++ b/test_bilinear.py @@ -0,0 +1,15 @@ +from metric_learn.oasis import OASIS +import numpy as np + +def test_toy_distance(): + u = np.array([0, 1, 2]) + v = np.array([3, 4, 5]) + + mixin = OASIS() + mixin.fit([u, v], [0, 0]) + #mixin.components_ = np.array([[1, 0, 0],[0, 1, 0],[0, 0, 1]]) + + dist = mixin.score_pairs([[u, v]]) + print(dist) + +test_toy_distance() \ No newline at end of file From ec09f59a5d463359b86a6dd003b1a511ea8ed9c3 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 17 Sep 2021 11:54:29 +0200 Subject: [PATCH 03/57] Fix score_pairs --- metric_learn/base_metric.py | 3 ++- test_bilinear.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index f5600bb1..1778a995 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -180,7 +180,8 @@ def score_pairs(self, pairs): pairs = check_input(pairs, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=2) - return np.dot(np.dot(pairs[:, 1, :], self.components_), pairs[:, 0, :].T) + + return [np.dot(np.dot(u, self.components_), v.T) for u,v in zip(pairs[:, 1, :], pairs[:, 0, :])] def get_metric(self): check_is_fitted(self, 'components_') diff --git a/test_bilinear.py b/test_bilinear.py index ad03bb48..5e82f78c 100644 --- a/test_bilinear.py +++ b/test_bilinear.py @@ -9,7 +9,7 @@ def test_toy_distance(): mixin.fit([u, v], [0, 0]) #mixin.components_ = np.array([[1, 0, 0],[0, 1, 0],[0, 0, 1]]) - dist = mixin.score_pairs([[u, v]]) + dist = mixin.score_pairs([[u, v],[v, u]]) print(dist) test_toy_distance() \ No newline at end of file From ec493976d7e2ca5d3b4f67d1be7a2d5eb116ae6f Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 17 Sep 2021 14:33:00 +0200 Subject: [PATCH 04/57] Two implementations for score_pairs --- metric_learn/base_metric.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 1778a995..1bd1edea 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -180,8 +180,14 @@ def score_pairs(self, pairs): pairs = check_input(pairs, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=2) - - return [np.dot(np.dot(u, self.components_), v.T) for u,v in zip(pairs[:, 1, :], pairs[:, 0, :])] + + # Note: For bilinear order matters, dist(a,b) != dist(b,a) + # We always choose first pair first, then second pair + # (In contrast with Mahalanobis implementation) + + # I dont know wich implementation performs better + return np.diagonal(np.dot(np.dot(pairs[:, 0, :], self.components_), pairs[:, 1, :].T)) + return [np.dot(np.dot(u.T, self.components_), v) for u,v in zip(pairs[:, 0, :], pairs[:, 1, :])] def get_metric(self): check_is_fitted(self, 'components_') @@ -206,7 +212,7 @@ def metric_fun(u, v): """ u = validate_vector(u) v = validate_vector(v) - return np.dot(np.dot(u, components), v.T) + return np.dot(np.dot(u.T, components), v) return metric_fun From 2f3c3e152f5e2c70da83a10012f72b649ad2ffa1 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 17 Sep 2021 14:33:17 +0200 Subject: [PATCH 05/57] Generalized toy tests --- metric_learn/oasis.py | 8 +++++++- test_bilinear.py | 22 ++++++++++++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/metric_learn/oasis.py b/metric_learn/oasis.py index 746b6d10..5bfe73d6 100644 --- a/metric_learn/oasis.py +++ b/metric_learn/oasis.py @@ -16,5 +16,11 @@ def fit(self, X, y): y : (n) data labels """ X = self._prepare_inputs(X, y, ensure_min_samples=2) - self.components_ = np.identity(np.shape(X[0])[-1]) # Identity matrix + + # Handmade dummy fit + #self.components_ = np.identity(np.shape(X[0])[-1]) # Identity matrix + #self.components_ = np.array([[2,4,6], [6,4,2], [1, 2, 3]]) + + # Dummy fit + self.components_ = np.random.rand(np.shape(X[0])[-1], np.shape(X[0])[-1]) return self \ No newline at end of file diff --git a/test_bilinear.py b/test_bilinear.py index 5e82f78c..919f80f5 100644 --- a/test_bilinear.py +++ b/test_bilinear.py @@ -1,15 +1,25 @@ from metric_learn.oasis import OASIS import numpy as np +from numpy.testing import assert_array_almost_equal def test_toy_distance(): - u = np.array([0, 1, 2]) - v = np.array([3, 4, 5]) + d = 100 + + u = np.random.rand(d) + v = np.random.rand(d) mixin = OASIS() - mixin.fit([u, v], [0, 0]) - #mixin.components_ = np.array([[1, 0, 0],[0, 1, 0],[0, 0, 1]]) + mixin.fit([u, v], [0, 0]) # Dummy fit - dist = mixin.score_pairs([[u, v],[v, u]]) - print(dist) + # The distances must match, whether calc with get_metric() or score_pairs() + dist1 = mixin.score_pairs([[u, v], [v, u]]) + dist2 = [mixin.get_metric()(u, v), mixin.get_metric()(v, u)] + + u_v = (np.dot(np.dot(u.T, mixin.get_bilinear_matrix()), v)) + v_u = (np.dot(np.dot(v.T, mixin.get_bilinear_matrix()), u)) + desired = [u_v, v_u] + + assert_array_almost_equal(dist1, desired) + assert_array_almost_equal(dist2, desired) test_toy_distance() \ No newline at end of file From c21d283a594271c2b3c601578002bf6b5eef9bb7 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 17 Sep 2021 14:51:58 +0200 Subject: [PATCH 06/57] Handmade tests incorporated --- metric_learn/oasis.py | 4 ---- test_bilinear.py | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/metric_learn/oasis.py b/metric_learn/oasis.py index 5bfe73d6..39d1406d 100644 --- a/metric_learn/oasis.py +++ b/metric_learn/oasis.py @@ -17,10 +17,6 @@ def fit(self, X, y): """ X = self._prepare_inputs(X, y, ensure_min_samples=2) - # Handmade dummy fit - #self.components_ = np.identity(np.shape(X[0])[-1]) # Identity matrix - #self.components_ = np.array([[2,4,6], [6,4,2], [1, 2, 3]]) - # Dummy fit self.components_ = np.random.rand(np.shape(X[0])[-1], np.shape(X[0])[-1]) return self \ No newline at end of file diff --git a/test_bilinear.py b/test_bilinear.py index 919f80f5..fe18adf7 100644 --- a/test_bilinear.py +++ b/test_bilinear.py @@ -3,6 +3,7 @@ from numpy.testing import assert_array_almost_equal def test_toy_distance(): + # Random generalized test for 2 points d = 100 u = np.random.rand(d) @@ -22,4 +23,20 @@ def test_toy_distance(): assert_array_almost_equal(dist1, desired) assert_array_almost_equal(dist2, desired) + # Handmade example + u = np.array([0, 1 ,2]) + v = np.array([3, 4, 5]) + + mixin.components_= np.array([[2,4,6], [6,4,2], [1, 2, 3]]) + dists = mixin.score_pairs([[u, v], [v, u]]) + assert_array_almost_equal(dists, [96, 120]) + + # Symetric example + u = np.array([0, 1 ,2]) + v = np.array([3, 4, 5]) + + mixin.components_= np.identity(3) # Identity matrix + dists = mixin.score_pairs([[u, v], [v, u]]) + assert_array_almost_equal(dists, [14, 14]) + test_toy_distance() \ No newline at end of file From dbe2a7a9ba30ab82ad9a7734d748301c617aeae0 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Tue, 21 Sep 2021 14:08:40 +0200 Subject: [PATCH 07/57] Fix identation for bilinear --- metric_learn/base_metric.py | 11 +++++++---- metric_learn/oasis.py | 8 +++++--- test_bilinear.py | 18 ++++++++++-------- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 1bd1edea..3f2b93cd 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -160,6 +160,7 @@ def transform(self, X): Input data transformed to the metric space by :math:`XL^{\\top}` """ + class BilinearMixin(BaseMetricLearner, metaclass=ABCMeta): def score_pairs(self, pairs): @@ -180,14 +181,15 @@ def score_pairs(self, pairs): pairs = check_input(pairs, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=2) - # Note: For bilinear order matters, dist(a,b) != dist(b,a) # We always choose first pair first, then second pair # (In contrast with Mahalanobis implementation) - # I dont know wich implementation performs better - return np.diagonal(np.dot(np.dot(pairs[:, 0, :], self.components_), pairs[:, 1, :].T)) - return [np.dot(np.dot(u.T, self.components_), v) for u,v in zip(pairs[:, 0, :], pairs[:, 1, :])] + return np.diagonal(np.dot( + np.dot(pairs[:, 0, :], self.components_), + pairs[:, 1, :].T)) + return np.array([np.dot(np.dot(u.T, self.components_), v) + for u, v in zip(pairs[:, 0, :], pairs[:, 1, :])]) def get_metric(self): check_is_fitted(self, 'components_') @@ -220,6 +222,7 @@ def get_bilinear_matrix(self): check_is_fitted(self, 'components_') return self.components_ + class MahalanobisMixin(BaseMetricLearner, MetricTransformer, metaclass=ABCMeta): r"""Mahalanobis metric learning algorithms. diff --git a/metric_learn/oasis.py b/metric_learn/oasis.py index 39d1406d..15f62fd2 100644 --- a/metric_learn/oasis.py +++ b/metric_learn/oasis.py @@ -1,6 +1,7 @@ from .base_metric import BilinearMixin import numpy as np + class OASIS(BilinearMixin): def __init__(self, preprocessor=None): @@ -16,7 +17,8 @@ def fit(self, X, y): y : (n) data labels """ X = self._prepare_inputs(X, y, ensure_min_samples=2) - + # Dummy fit - self.components_ = np.random.rand(np.shape(X[0])[-1], np.shape(X[0])[-1]) - return self \ No newline at end of file + self.components_ = np.random.rand( + np.shape(X[0])[-1], np.shape(X[0])[-1]) + return self diff --git a/test_bilinear.py b/test_bilinear.py index fe18adf7..f18833ec 100644 --- a/test_bilinear.py +++ b/test_bilinear.py @@ -2,6 +2,7 @@ import numpy as np from numpy.testing import assert_array_almost_equal + def test_toy_distance(): # Random generalized test for 2 points d = 100 @@ -10,33 +11,34 @@ def test_toy_distance(): v = np.random.rand(d) mixin = OASIS() - mixin.fit([u, v], [0, 0]) # Dummy fit + mixin.fit([u, v], [0, 0]) # Dummy fit # The distances must match, whether calc with get_metric() or score_pairs() dist1 = mixin.score_pairs([[u, v], [v, u]]) dist2 = [mixin.get_metric()(u, v), mixin.get_metric()(v, u)] - + u_v = (np.dot(np.dot(u.T, mixin.get_bilinear_matrix()), v)) v_u = (np.dot(np.dot(v.T, mixin.get_bilinear_matrix()), u)) desired = [u_v, v_u] - + assert_array_almost_equal(dist1, desired) assert_array_almost_equal(dist2, desired) # Handmade example - u = np.array([0, 1 ,2]) + u = np.array([0, 1, 2]) v = np.array([3, 4, 5]) - mixin.components_= np.array([[2,4,6], [6,4,2], [1, 2, 3]]) + mixin.components_ = np.array([[2, 4, 6], [6, 4, 2], [1, 2, 3]]) dists = mixin.score_pairs([[u, v], [v, u]]) assert_array_almost_equal(dists, [96, 120]) # Symetric example - u = np.array([0, 1 ,2]) + u = np.array([0, 1, 2]) v = np.array([3, 4, 5]) - mixin.components_= np.identity(3) # Identity matrix + mixin.components_ = np.identity(3) # Identity matrix dists = mixin.score_pairs([[u, v], [v, u]]) assert_array_almost_equal(dists, [14, 14]) -test_toy_distance() \ No newline at end of file + +test_toy_distance() From ee5c5ee0b95f113820a12f65d4dc13ffcbe2cc57 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Tue, 21 Sep 2021 17:23:42 +0200 Subject: [PATCH 08/57] Add performance test to choose between two methods for bilinear calc --- test_bilinear.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/test_bilinear.py b/test_bilinear.py index f18833ec..bd61bce8 100644 --- a/test_bilinear.py +++ b/test_bilinear.py @@ -1,6 +1,7 @@ from metric_learn.oasis import OASIS import numpy as np from numpy.testing import assert_array_almost_equal +from timeit import default_timer as timer def test_toy_distance(): @@ -41,4 +42,52 @@ def test_toy_distance(): assert_array_almost_equal(dists, [14, 14]) -test_toy_distance() +def test_bilinar_properties(): + d = 100 + + u = np.random.rand(d) + v = np.random.rand(d) + + mixin = OASIS() + mixin.fit([u, v], [0, 0]) # Dummy fit + + dist1 = mixin.score_pairs([[u, u], [v, v], [u, v], [v, u]]) + + print(dist1) + + +def test_performace(): + + features = int(1e4) + samples = int(1e3) + + a = [np.random.rand(features) for i in range(samples)] + b = [np.random.rand(features) for i in range(samples)] + pairs = np.array([(aa, bb) for aa, bb in zip(a, b)]) + components = np.identity(features) + + def op_1(pairs, components): + return np.diagonal(np.dot( + np.dot(pairs[:, 0, :], components), + pairs[:, 1, :].T)) + + def op_2(pairs, components): + return np.array([np.dot(np.dot(u.T, components), v) + for u, v in zip(pairs[:, 0, :], pairs[:, 1, :])]) + + # Test first method + start = timer() + op_1(pairs, components) + end = timer() + print(f'First method took {end - start}') + + # Test second method + start = timer() + op_2(pairs, components) + end = timer() + print(f'Second method took {end - start}') + + +# test_toy_distance() +# test_bilinar_properties() +test_performace() From 9a10e0611b9191742fec101d6e18fc28e2da7ce9 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 22 Sep 2021 13:40:03 +0200 Subject: [PATCH 09/57] Found an efficient way to compute Bilinear Sim for n pairs --- metric_learn/base_metric.py | 7 +------ test_bilinear.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 3f2b93cd..e809e264 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -184,12 +184,7 @@ def score_pairs(self, pairs): # Note: For bilinear order matters, dist(a,b) != dist(b,a) # We always choose first pair first, then second pair # (In contrast with Mahalanobis implementation) - # I dont know wich implementation performs better - return np.diagonal(np.dot( - np.dot(pairs[:, 0, :], self.components_), - pairs[:, 1, :].T)) - return np.array([np.dot(np.dot(u.T, self.components_), v) - for u, v in zip(pairs[:, 0, :], pairs[:, 1, :])]) + return (np.dot(pairs[:, 0, :], self.components_) * pairs[:, 1, :]).sum(-1) def get_metric(self): check_is_fitted(self, 'components_') diff --git a/test_bilinear.py b/test_bilinear.py index bd61bce8..c72a0f0c 100644 --- a/test_bilinear.py +++ b/test_bilinear.py @@ -59,7 +59,7 @@ def test_bilinar_properties(): def test_performace(): features = int(1e4) - samples = int(1e3) + samples = int(1e4) a = [np.random.rand(features) for i in range(samples)] b = [np.random.rand(features) for i in range(samples)] @@ -75,6 +75,9 @@ def op_2(pairs, components): return np.array([np.dot(np.dot(u.T, components), v) for u, v in zip(pairs[:, 0, :], pairs[:, 1, :])]) + def op_3(pairs, components): + return (np.dot(pairs[:, 0, :], components) * pairs[:, 1, :]).sum(-1) + # Test first method start = timer() op_1(pairs, components) @@ -86,8 +89,9 @@ def op_2(pairs, components): op_2(pairs, components) end = timer() print(f'Second method took {end - start}') - - -# test_toy_distance() -# test_bilinar_properties() -test_performace() + + # Test second method + start = timer() + op_3(pairs, components) + end = timer() + print(f'Third method took {end - start}') \ No newline at end of file From b1edc46ca14e473a0373964fab6714ea4da6f419 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 22 Sep 2021 14:08:11 +0200 Subject: [PATCH 10/57] Update method's descriptions --- metric_learn/base_metric.py | 62 ++++++++++++++++++++++++++++++------- test_bilinear.py | 6 ++-- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index e809e264..2033065f 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -162,20 +162,50 @@ def transform(self, X): class BilinearMixin(BaseMetricLearner, metaclass=ABCMeta): + r"""Bilinear similarity learning algorithms. + + Algorithm that learns a Bilinear (pseudo) similarity :math:`s_M(x, x')`, + defined between two column vectors :math:`x` and :math:`x'` by: :math: + `s_M(x, x') = x M x'`, where :math:`M` is a learned matrix. This matrix + is not guaranteed to be symmetric nor positive semi-definite (PSD). Thus + it cannot be seen as learning a linear transformation of the original + space like Mahalanobis learning algorithms. + + Attributes + ---------- + components_ : `numpy.ndarray`, shape=(n_components, n_features) + The learned bilinear matrix ``M``. + """ def score_pairs(self, pairs): - r""" + r"""Returns the learned Bilinear similarity between pairs. + + This similarity is defined as: :math:`s_M(x, x') = x M x'` + where ``M`` is the learned Bilinear matrix, for every pair of points + ``x`` and ``x'``. + Parameters ---------- pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) 3D Array of pairs to score, with each row corresponding to two points, - for 2D array of indices of pairs if the metric learner uses a + for 2D array of indices of pairs if the similarity learner uses a preprocessor. Returns ------- scores : `numpy.ndarray` of shape=(n_pairs,) - The learned Mahalanobis distance for every pair. + The learned Bilinear similarity for every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the similarity + between two points. The difference with `score_pairs` is that it works + on two 1D arrays and cannot use a preprocessor. Besides, the returned + function is independent of the similarity learner and hence is not + modified if the similarity learner is. + + :ref:`Bilinear_similarity` : The section of the project documentation + that describes Bilinear similarity. """ check_is_fitted(self, ['preprocessor_', 'components_']) pairs = check_input(pairs, type_of_inputs='tuples', @@ -184,36 +214,44 @@ def score_pairs(self, pairs): # Note: For bilinear order matters, dist(a,b) != dist(b,a) # We always choose first pair first, then second pair # (In contrast with Mahalanobis implementation) - return (np.dot(pairs[:, 0, :], self.components_) * pairs[:, 1, :]).sum(-1) + return np.sum(np.dot(pairs[:, 0, :], self.components_) * pairs[:, 1, :], + axis=-1) def get_metric(self): check_is_fitted(self, 'components_') components = self.components_.copy() - def metric_fun(u, v): - """This function computes the metric between u and v, according to the - previously learned metric. + def similarity_fun(u, v): + """This function computes the similarity between u and v, according to the + previously learned similarity. Parameters ---------- u : array-like, shape=(n_features,) - The first point involved in the distance computation. + The first point involved in the similarity computation. v : array-like, shape=(n_features,) - The second point involved in the distance computation. + The second point involved in the similarity computation. Returns ------- - distance : float - The distance between u and v according to the new metric. + similarity : float + The similarity between u and v according to the new similarity. """ u = validate_vector(u) v = validate_vector(v) return np.dot(np.dot(u.T, components), v) - return metric_fun + return similarity_fun def get_bilinear_matrix(self): + """Returns a copy of the Bilinear matrix learned by the similarity learner. + + Returns + ------- + M : `numpy.ndarray`, shape=(n_features, n_features) + The copy of the learned Bilinear matrix. + """ check_is_fitted(self, 'components_') return self.components_ diff --git a/test_bilinear.py b/test_bilinear.py index c72a0f0c..83e7fcfd 100644 --- a/test_bilinear.py +++ b/test_bilinear.py @@ -76,7 +76,8 @@ def op_2(pairs, components): for u, v in zip(pairs[:, 0, :], pairs[:, 1, :])]) def op_3(pairs, components): - return (np.dot(pairs[:, 0, :], components) * pairs[:, 1, :]).sum(-1) + return np.sum(np.dot(pairs[:, 0, :], components) * pairs[:, 1, :], + axis=-1) # Test first method start = timer() @@ -89,9 +90,8 @@ def op_3(pairs, components): op_2(pairs, components) end = timer() print(f'Second method took {end - start}') - # Test second method start = timer() op_3(pairs, components) end = timer() - print(f'Third method took {end - start}') \ No newline at end of file + print(f'Third method took {end - start}') From ae562e684ff42d6da5828e4770bdf2dbff9ce7e5 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 22 Sep 2021 14:56:38 +0200 Subject: [PATCH 11/57] Following the correct testing structure --- metric_learn/oasis.py | 24 --------- test/test_bilinear_mixin.py | 64 ++++++++++++++++++++++++ test_bilinear.py | 97 ------------------------------------- 3 files changed, 64 insertions(+), 121 deletions(-) delete mode 100644 metric_learn/oasis.py create mode 100644 test/test_bilinear_mixin.py delete mode 100644 test_bilinear.py diff --git a/metric_learn/oasis.py b/metric_learn/oasis.py deleted file mode 100644 index 15f62fd2..00000000 --- a/metric_learn/oasis.py +++ /dev/null @@ -1,24 +0,0 @@ -from .base_metric import BilinearMixin -import numpy as np - - -class OASIS(BilinearMixin): - - def __init__(self, preprocessor=None): - super().__init__(preprocessor=preprocessor) - - def fit(self, X, y): - """ - Fit OASIS model - - Parameters - ---------- - X : (n x d) array of samples - y : (n) data labels - """ - X = self._prepare_inputs(X, y, ensure_min_samples=2) - - # Dummy fit - self.components_ = np.random.rand( - np.shape(X[0])[-1], np.shape(X[0])[-1]) - return self diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py new file mode 100644 index 00000000..93a0b8c5 --- /dev/null +++ b/test/test_bilinear_mixin.py @@ -0,0 +1,64 @@ +from metric_learn.base_metric import BilinearMixin +import numpy as np +from numpy.testing import assert_array_almost_equal + +class IdentityBilinearMixin(BilinearMixin): + """A simple Identity bilinear mixin that returns an identity matrix + M as learned. Can change M for a random matrix calling random_M. + Class for testing purposes. + """ + def __init__(self, preprocessor=None): + super().__init__(preprocessor=preprocessor) + + def fit(self, X, y): + X, y = self._prepare_inputs(X, y, ensure_min_samples=2) + self.d = np.shape(X[0])[-1] + self.components_ = np.identity(self.d) + return self + + def random_M(self): + self.components_ = np.random.rand(self.d, self.d) + +def test_same_similarity_with_two_methods(): + d = 100 + u = np.random.rand(d) + v = np.random.rand(d) + mixin = IdentityBilinearMixin() + mixin.fit([u, v], [0, 0]) # Dummy fit + mixin.random_M() + + # The distances must match, whether calc with get_metric() or score_pairs() + dist1 = mixin.score_pairs([[u, v], [v, u]]) + dist2 = [mixin.get_metric()(u, v), mixin.get_metric()(v, u)] + + assert_array_almost_equal(dist1, dist2) + +def test_check_correctness_similarity(): + d = 100 + u = np.random.rand(d) + v = np.random.rand(d) + mixin = IdentityBilinearMixin() + mixin.fit([u, v], [0, 0]) # Dummy fit + dist1 = mixin.score_pairs([[u, v], [v, u]]) + u_v = np.dot(np.dot(u.T, np.identity(d)), v) + v_u = np.dot(np.dot(v.T, np.identity(d)), u) + desired = [u_v, v_u] + assert_array_almost_equal(dist1, desired) + +def test_check_handmade_example(): + u = np.array([0, 1, 2]) + v = np.array([3, 4, 5]) + mixin = IdentityBilinearMixin() + mixin.fit([u, v], [0, 0]) + c = np.array([[2, 4, 6], [6, 4, 2], [1, 2, 3]]) + mixin.components_ = c # Force a components_ + dists = mixin.score_pairs([[u, v], [v, u]]) + assert_array_almost_equal(dists, [96, 120]) + +def test_check_handmade_symmetric_example(): + u = np.array([0, 1, 2]) + v = np.array([3, 4, 5]) + mixin = IdentityBilinearMixin() + mixin.fit([u, v], [0, 0]) + dists = mixin.score_pairs([[u, v], [v, u]]) + assert_array_almost_equal(dists, [14, 14]) \ No newline at end of file diff --git a/test_bilinear.py b/test_bilinear.py deleted file mode 100644 index 83e7fcfd..00000000 --- a/test_bilinear.py +++ /dev/null @@ -1,97 +0,0 @@ -from metric_learn.oasis import OASIS -import numpy as np -from numpy.testing import assert_array_almost_equal -from timeit import default_timer as timer - - -def test_toy_distance(): - # Random generalized test for 2 points - d = 100 - - u = np.random.rand(d) - v = np.random.rand(d) - - mixin = OASIS() - mixin.fit([u, v], [0, 0]) # Dummy fit - - # The distances must match, whether calc with get_metric() or score_pairs() - dist1 = mixin.score_pairs([[u, v], [v, u]]) - dist2 = [mixin.get_metric()(u, v), mixin.get_metric()(v, u)] - - u_v = (np.dot(np.dot(u.T, mixin.get_bilinear_matrix()), v)) - v_u = (np.dot(np.dot(v.T, mixin.get_bilinear_matrix()), u)) - desired = [u_v, v_u] - - assert_array_almost_equal(dist1, desired) - assert_array_almost_equal(dist2, desired) - - # Handmade example - u = np.array([0, 1, 2]) - v = np.array([3, 4, 5]) - - mixin.components_ = np.array([[2, 4, 6], [6, 4, 2], [1, 2, 3]]) - dists = mixin.score_pairs([[u, v], [v, u]]) - assert_array_almost_equal(dists, [96, 120]) - - # Symetric example - u = np.array([0, 1, 2]) - v = np.array([3, 4, 5]) - - mixin.components_ = np.identity(3) # Identity matrix - dists = mixin.score_pairs([[u, v], [v, u]]) - assert_array_almost_equal(dists, [14, 14]) - - -def test_bilinar_properties(): - d = 100 - - u = np.random.rand(d) - v = np.random.rand(d) - - mixin = OASIS() - mixin.fit([u, v], [0, 0]) # Dummy fit - - dist1 = mixin.score_pairs([[u, u], [v, v], [u, v], [v, u]]) - - print(dist1) - - -def test_performace(): - - features = int(1e4) - samples = int(1e4) - - a = [np.random.rand(features) for i in range(samples)] - b = [np.random.rand(features) for i in range(samples)] - pairs = np.array([(aa, bb) for aa, bb in zip(a, b)]) - components = np.identity(features) - - def op_1(pairs, components): - return np.diagonal(np.dot( - np.dot(pairs[:, 0, :], components), - pairs[:, 1, :].T)) - - def op_2(pairs, components): - return np.array([np.dot(np.dot(u.T, components), v) - for u, v in zip(pairs[:, 0, :], pairs[:, 1, :])]) - - def op_3(pairs, components): - return np.sum(np.dot(pairs[:, 0, :], components) * pairs[:, 1, :], - axis=-1) - - # Test first method - start = timer() - op_1(pairs, components) - end = timer() - print(f'First method took {end - start}') - - # Test second method - start = timer() - op_2(pairs, components) - end = timer() - print(f'Second method took {end - start}') - # Test second method - start = timer() - op_3(pairs, components) - end = timer() - print(f'Third method took {end - start}') From 7ebc0262e915b460d83b10db15c39cc01243ab0c Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 22 Sep 2021 15:16:59 +0200 Subject: [PATCH 12/57] Fix identation --- test/test_bilinear_mixin.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index 93a0b8c5..c6707cc8 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -2,6 +2,7 @@ import numpy as np from numpy.testing import assert_array_almost_equal + class IdentityBilinearMixin(BilinearMixin): """A simple Identity bilinear mixin that returns an identity matrix M as learned. Can change M for a random matrix calling random_M. @@ -15,17 +16,18 @@ def fit(self, X, y): self.d = np.shape(X[0])[-1] self.components_ = np.identity(self.d) return self - + def random_M(self): self.components_ = np.random.rand(self.d, self.d) + def test_same_similarity_with_two_methods(): d = 100 u = np.random.rand(d) v = np.random.rand(d) mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) # Dummy fit - mixin.random_M() + mixin.fit([u, v], [0, 0]) + mixin.random_M() # Dummy fit # The distances must match, whether calc with get_metric() or score_pairs() dist1 = mixin.score_pairs([[u, v], [v, u]]) @@ -33,32 +35,35 @@ def test_same_similarity_with_two_methods(): assert_array_almost_equal(dist1, dist2) + def test_check_correctness_similarity(): d = 100 u = np.random.rand(d) v = np.random.rand(d) mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) # Dummy fit + mixin.fit([u, v], [0, 0]) # Identity fit dist1 = mixin.score_pairs([[u, v], [v, u]]) u_v = np.dot(np.dot(u.T, np.identity(d)), v) v_u = np.dot(np.dot(v.T, np.identity(d)), u) desired = [u_v, v_u] assert_array_almost_equal(dist1, desired) + def test_check_handmade_example(): u = np.array([0, 1, 2]) v = np.array([3, 4, 5]) mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) + mixin.fit([u, v], [0, 0]) # Identity fit c = np.array([[2, 4, 6], [6, 4, 2], [1, 2, 3]]) - mixin.components_ = c # Force a components_ + mixin.components_ = c # Force components_ dists = mixin.score_pairs([[u, v], [v, u]]) assert_array_almost_equal(dists, [96, 120]) + def test_check_handmade_symmetric_example(): u = np.array([0, 1, 2]) v = np.array([3, 4, 5]) mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) + mixin.fit([u, v], [0, 0]) # Identity fit dists = mixin.score_pairs([[u, v], [v, u]]) - assert_array_almost_equal(dists, [14, 14]) \ No newline at end of file + assert_array_almost_equal(dists, [14, 14]) From 1d752f784e6fda9bd99f667ec6076bfe84e77069 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Thu, 23 Sep 2021 10:11:11 +0200 Subject: [PATCH 13/57] Add more tests. Fix 4 to 2 identation --- metric_learn/base_metric.py | 126 +++++++++++++++--------------- test/test_bilinear_mixin.py | 152 ++++++++++++++++++++++++------------ 2 files changed, 167 insertions(+), 111 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 2033065f..78856f74 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -178,82 +178,82 @@ class BilinearMixin(BaseMetricLearner, metaclass=ABCMeta): """ def score_pairs(self, pairs): - r"""Returns the learned Bilinear similarity between pairs. + r"""Returns the learned Bilinear similarity between pairs. - This similarity is defined as: :math:`s_M(x, x') = x M x'` - where ``M`` is the learned Bilinear matrix, for every pair of points - ``x`` and ``x'``. + This similarity is defined as: :math:`s_M(x, x') = x M x'` + where ``M`` is the learned Bilinear matrix, for every pair of points + ``x`` and ``x'``. - Parameters - ---------- - pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) - 3D Array of pairs to score, with each row corresponding to two points, - for 2D array of indices of pairs if the similarity learner uses a - preprocessor. + Parameters + ---------- + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the similarity learner uses a + preprocessor. - Returns - ------- - scores : `numpy.ndarray` of shape=(n_pairs,) - The learned Bilinear similarity for every pair. - - See Also - -------- - get_metric : a method that returns a function to compute the similarity - between two points. The difference with `score_pairs` is that it works - on two 1D arrays and cannot use a preprocessor. Besides, the returned - function is independent of the similarity learner and hence is not - modified if the similarity learner is. - - :ref:`Bilinear_similarity` : The section of the project documentation - that describes Bilinear similarity. - """ - check_is_fitted(self, ['preprocessor_', 'components_']) - pairs = check_input(pairs, type_of_inputs='tuples', - preprocessor=self.preprocessor_, - estimator=self, tuple_size=2) - # Note: For bilinear order matters, dist(a,b) != dist(b,a) - # We always choose first pair first, then second pair - # (In contrast with Mahalanobis implementation) - return np.sum(np.dot(pairs[:, 0, :], self.components_) * pairs[:, 1, :], - axis=-1) + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The learned Bilinear similarity for every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the similarity + between two points. The difference with `score_pairs` is that it works + on two 1D arrays and cannot use a preprocessor. Besides, the returned + function is independent of the similarity learner and hence is not + modified if the similarity learner is. + + :ref:`Bilinear_similarity` : The section of the project documentation + that describes Bilinear similarity. + """ + check_is_fitted(self, ['preprocessor_', 'components_']) + pairs = check_input(pairs, type_of_inputs='tuples', + preprocessor=self.preprocessor_, + estimator=self, tuple_size=2) + # Note: For bilinear order matters, dist(a,b) != dist(b,a) + # We always choose first pair first, then second pair + # (In contrast with Mahalanobis implementation) + return np.sum(np.dot(pairs[:, 0, :], self.components_) * pairs[:, 1, :], + axis=-1) def get_metric(self): - check_is_fitted(self, 'components_') - components = self.components_.copy() + check_is_fitted(self, 'components_') + components = self.components_.copy() - def similarity_fun(u, v): - """This function computes the similarity between u and v, according to the - previously learned similarity. + def similarity_fun(u, v): + """This function computes the similarity between u and v, according to the + previously learned similarity. - Parameters - ---------- - u : array-like, shape=(n_features,) - The first point involved in the similarity computation. + Parameters + ---------- + u : array-like, shape=(n_features,) + The first point involved in the similarity computation. - v : array-like, shape=(n_features,) - The second point involved in the similarity computation. + v : array-like, shape=(n_features,) + The second point involved in the similarity computation. - Returns - ------- - similarity : float - The similarity between u and v according to the new similarity. - """ - u = validate_vector(u) - v = validate_vector(v) - return np.dot(np.dot(u.T, components), v) + Returns + ------- + similarity : float + The similarity between u and v according to the new similarity. + """ + u = validate_vector(u) + v = validate_vector(v) + return np.dot(np.dot(u.T, components), v) - return similarity_fun + return similarity_fun def get_bilinear_matrix(self): - """Returns a copy of the Bilinear matrix learned by the similarity learner. + """Returns a copy of the Bilinear matrix learned by the similarity learner. - Returns - ------- - M : `numpy.ndarray`, shape=(n_features, n_features) - The copy of the learned Bilinear matrix. - """ - check_is_fitted(self, 'components_') - return self.components_ + Returns + ------- + M : `numpy.ndarray`, shape=(n_features, n_features) + The copy of the learned Bilinear matrix. + """ + check_is_fitted(self, 'components_') + return self.components_ class MahalanobisMixin(BaseMetricLearner, MetricTransformer, diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index c6707cc8..0aa629ed 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -1,69 +1,125 @@ +from itertools import product from metric_learn.base_metric import BilinearMixin import numpy as np from numpy.testing import assert_array_almost_equal - +import pytest +from metric_learn._util import make_context +from sklearn import clone +from sklearn.cluster import DBSCAN class IdentityBilinearMixin(BilinearMixin): - """A simple Identity bilinear mixin that returns an identity matrix - M as learned. Can change M for a random matrix calling random_M. - Class for testing purposes. - """ - def __init__(self, preprocessor=None): - super().__init__(preprocessor=preprocessor) + """A simple Identity bilinear mixin that returns an identity matrix + M as learned. Can change M for a random matrix calling random_M. + Class for testing purposes. + """ + def __init__(self, preprocessor=None): + super().__init__(preprocessor=preprocessor) - def fit(self, X, y): - X, y = self._prepare_inputs(X, y, ensure_min_samples=2) - self.d = np.shape(X[0])[-1] - self.components_ = np.identity(self.d) - return self + def fit(self, X, y): + X, y = self._prepare_inputs(X, y, ensure_min_samples=2) + self.d = np.shape(X[0])[-1] + self.components_ = np.identity(self.d) + return self - def random_M(self): - self.components_ = np.random.rand(self.d, self.d) + def random_M(self): + self.components_ = np.random.rand(self.d, self.d) def test_same_similarity_with_two_methods(): - d = 100 - u = np.random.rand(d) - v = np.random.rand(d) - mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) - mixin.random_M() # Dummy fit + d = 100 + u = np.random.rand(d) + v = np.random.rand(d) + mixin = IdentityBilinearMixin() + mixin.fit([u, v], [0, 0]) + mixin.random_M() # Dummy fit - # The distances must match, whether calc with get_metric() or score_pairs() - dist1 = mixin.score_pairs([[u, v], [v, u]]) - dist2 = [mixin.get_metric()(u, v), mixin.get_metric()(v, u)] + # The distances must match, whether calc with get_metric() or score_pairs() + dist1 = mixin.score_pairs([[u, v], [v, u]]) + dist2 = [mixin.get_metric()(u, v), mixin.get_metric()(v, u)] - assert_array_almost_equal(dist1, dist2) + assert_array_almost_equal(dist1, dist2) def test_check_correctness_similarity(): - d = 100 - u = np.random.rand(d) - v = np.random.rand(d) - mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) # Identity fit - dist1 = mixin.score_pairs([[u, v], [v, u]]) - u_v = np.dot(np.dot(u.T, np.identity(d)), v) - v_u = np.dot(np.dot(v.T, np.identity(d)), u) - desired = [u_v, v_u] - assert_array_almost_equal(dist1, desired) + d = 100 + u = np.random.rand(d) + v = np.random.rand(d) + mixin = IdentityBilinearMixin() + mixin.fit([u, v], [0, 0]) # Identity fit + dist1 = mixin.score_pairs([[u, v], [v, u]]) + dist2 = [mixin.get_metric()(u, v), mixin.get_metric()(v, u)] + u_v = np.dot(np.dot(u.T, np.identity(d)), v) + v_u = np.dot(np.dot(v.T, np.identity(d)), u) + desired = [u_v, v_u] + assert_array_almost_equal(dist1, desired) # score_pairs + assert_array_almost_equal(dist2, desired) # get_metric def test_check_handmade_example(): - u = np.array([0, 1, 2]) - v = np.array([3, 4, 5]) - mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) # Identity fit - c = np.array([[2, 4, 6], [6, 4, 2], [1, 2, 3]]) - mixin.components_ = c # Force components_ - dists = mixin.score_pairs([[u, v], [v, u]]) - assert_array_almost_equal(dists, [96, 120]) + u = np.array([0, 1, 2]) + v = np.array([3, 4, 5]) + mixin = IdentityBilinearMixin() + mixin.fit([u, v], [0, 0]) # Identity fit + c = np.array([[2, 4, 6], [6, 4, 2], [1, 2, 3]]) + mixin.components_ = c # Force components_ + dists = mixin.score_pairs([[u, v], [v, u]]) + assert_array_almost_equal(dists, [96, 120]) def test_check_handmade_symmetric_example(): - u = np.array([0, 1, 2]) - v = np.array([3, 4, 5]) - mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) # Identity fit - dists = mixin.score_pairs([[u, v], [v, u]]) - assert_array_almost_equal(dists, [14, 14]) + u = np.array([0, 1, 2]) + v = np.array([3, 4, 5]) + mixin = IdentityBilinearMixin() + mixin.fit([u, v], [0, 0]) # Identity fit + dists = mixin.score_pairs([[u, v], [v, u]]) + assert_array_almost_equal(dists, [14, 14]) + + +def test_score_pairs_finite(): + d = 100 + u = np.random.rand(d) + v = np.random.rand(d) + mixin = IdentityBilinearMixin() + mixin.fit([u, v], [0, 0]) + mixin.random_M() # Dummy fit + n = 100 + X = np.array([np.random.rand(d) for i in range(n)]) + pairs = np.array(list(product(X, X))) + assert np.isfinite(mixin.score_pairs(pairs)).all() + + +def test_score_pairs_dim(): + # scoring of 3D arrays should return 1D array (several tuples), + # and scoring of 2D arrays (one tuple) should return an error (like + # scikit-learn's error when scoring 1D arrays) + d = 100 + u = np.random.rand(d) + v = np.random.rand(d) + mixin = IdentityBilinearMixin() + mixin.fit([u, v], [0, 0]) + mixin.random_M() # Dummy fit + n = 100 + X = np.array([np.random.rand(d) for i in range(n)]) + tuples = np.array(list(product(X, X))) + assert mixin.score_pairs(tuples).shape == (tuples.shape[0],) + context = make_context(mixin) + msg = ("3D array of formed tuples expected{}. Found 2D array " + "instead:\ninput={}. Reshape your data and/or use a preprocessor.\n" + .format(context, tuples[1])) + with pytest.raises(ValueError) as raised_error: + mixin.score_pairs(tuples[1]) + assert str(raised_error.value) == msg + + +def test_check_scikitlearn_compatibility(): + d = 100 + u = np.random.rand(d) + v = np.random.rand(d) + mixin = IdentityBilinearMixin() + mixin.fit([u, v], [0, 0]) + mixin.random_M() # Dummy fit + + n = 100 + X = np.array([np.random.rand(d) for i in range(n)]) + clustering = DBSCAN(metric=mixin.get_metric()) + clustering.fit(X) \ No newline at end of file From 45c9b97ea9384e085d4e5914b69471f695bea703 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Thu, 23 Sep 2021 10:14:07 +0200 Subject: [PATCH 14/57] Minor flake8 fix --- test/test_bilinear_mixin.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index 0aa629ed..ca17718b 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -4,9 +4,9 @@ from numpy.testing import assert_array_almost_equal import pytest from metric_learn._util import make_context -from sklearn import clone from sklearn.cluster import DBSCAN + class IdentityBilinearMixin(BilinearMixin): """A simple Identity bilinear mixin that returns an identity matrix M as learned. Can change M for a random matrix calling random_M. @@ -55,6 +55,7 @@ def test_check_correctness_similarity(): assert_array_almost_equal(dist1, desired) # score_pairs assert_array_almost_equal(dist2, desired) # get_metric + def test_check_handmade_example(): u = np.array([0, 1, 2]) v = np.array([3, 4, 5]) @@ -104,8 +105,8 @@ def test_score_pairs_dim(): assert mixin.score_pairs(tuples).shape == (tuples.shape[0],) context = make_context(mixin) msg = ("3D array of formed tuples expected{}. Found 2D array " - "instead:\ninput={}. Reshape your data and/or use a preprocessor.\n" - .format(context, tuples[1])) + "instead:\ninput={}. Reshape your data and/or use a preprocessor.\n" + .format(context, tuples[1])) with pytest.raises(ValueError) as raised_error: mixin.score_pairs(tuples[1]) assert str(raised_error.value) == msg @@ -122,4 +123,4 @@ def test_check_scikitlearn_compatibility(): n = 100 X = np.array([np.random.rand(d) for i in range(n)]) clustering = DBSCAN(metric=mixin.get_metric()) - clustering.fit(X) \ No newline at end of file + clustering.fit(X) From 407f910e7269f030498dee22241252d80cd65293 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Thu, 23 Sep 2021 10:44:41 +0200 Subject: [PATCH 15/57] Commented each test --- test/test_bilinear_mixin.py | 72 ++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index ca17718b..5d8b9e72 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -16,23 +16,42 @@ def __init__(self, preprocessor=None): super().__init__(preprocessor=preprocessor) def fit(self, X, y): + """ + Checks input's format. Sets M matrix to identity of shape (d,d) + where d is the dimension of the input. + """ X, y = self._prepare_inputs(X, y, ensure_min_samples=2) self.d = np.shape(X[0])[-1] self.components_ = np.identity(self.d) return self def random_M(self): + """ + Changes the matrix M for a random one of shape (d,d) + """ self.components_ = np.random.rand(self.d, self.d) -def test_same_similarity_with_two_methods(): +def identity_fit(d=100): + """ + Creates two d-dimentional arrays. Fits an IdentityBilinearMixin() + and then returns the two arrays and the mixin. Testing purposes + """ d = 100 u = np.random.rand(d) v = np.random.rand(d) mixin = IdentityBilinearMixin() mixin.fit([u, v], [0, 0]) - mixin.random_M() # Dummy fit + return u, v, mixin + +def test_same_similarity_with_two_methods(): + """" + Tests that score_pairs() and get_metric() give consistent results. + In both cases, the results must match for the same input. + """ + u, v, mixin = identity_fit() + mixin.random_M() # Dummy fit # The distances must match, whether calc with get_metric() or score_pairs() dist1 = mixin.score_pairs([[u, v], [v, u]]) dist2 = [mixin.get_metric()(u, v), mixin.get_metric()(v, u)] @@ -41,11 +60,13 @@ def test_same_similarity_with_two_methods(): def test_check_correctness_similarity(): + """ + Tests the correctness of the results made from socre_paris() and + get_metric(). Results are compared with the real bilinear similarity + calculated in-place. + """ d = 100 - u = np.random.rand(d) - v = np.random.rand(d) - mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) # Identity fit + u, v, mixin = identity_fit(d) dist1 = mixin.score_pairs([[u, v], [v, u]]) dist2 = [mixin.get_metric()(u, v), mixin.get_metric()(v, u)] @@ -57,6 +78,10 @@ def test_check_correctness_similarity(): def test_check_handmade_example(): + """ + Checks that score_pairs() result is correct comparing it with a + handmade example. + """ u = np.array([0, 1, 2]) v = np.array([3, 4, 5]) mixin = IdentityBilinearMixin() @@ -68,6 +93,11 @@ def test_check_handmade_example(): def test_check_handmade_symmetric_example(): + """ + When the Bilinear matrix is the identity. The similarity + between two arrays must be equal: S(u,v) = S(v,u). Also + checks the random case: when the matrix is pd and symetric. + """ u = np.array([0, 1, 2]) v = np.array([3, 4, 5]) mixin = IdentityBilinearMixin() @@ -77,11 +107,13 @@ def test_check_handmade_symmetric_example(): def test_score_pairs_finite(): + """ + Checks for 'n' score_pairs() of 'd' dimentions, that all + similarities are finite numbers, not NaN, +inf or -inf. + Considering a random M for bilinear similarity. + """ d = 100 - u = np.random.rand(d) - v = np.random.rand(d) - mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) + u, v, mixin = identity_fit(d) mixin.random_M() # Dummy fit n = 100 X = np.array([np.random.rand(d) for i in range(n)]) @@ -90,14 +122,13 @@ def test_score_pairs_finite(): def test_score_pairs_dim(): - # scoring of 3D arrays should return 1D array (several tuples), - # and scoring of 2D arrays (one tuple) should return an error (like - # scikit-learn's error when scoring 1D arrays) + """ + Scoring of 3D arrays should return 1D array (several tuples), + and scoring of 2D arrays (one tuple) should return an error (like + scikit-learn's error when scoring 1D arrays) + """ d = 100 - u = np.random.rand(d) - v = np.random.rand(d) - mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) + u, v, mixin = identity_fit() mixin.random_M() # Dummy fit n = 100 X = np.array([np.random.rand(d) for i in range(n)]) @@ -113,11 +144,10 @@ def test_score_pairs_dim(): def test_check_scikitlearn_compatibility(): + """Check that the similarity returned by get_metric() is compatible with + scikit-learn's algorithms using a custom metric, DBSCAN for instance""" d = 100 - u = np.random.rand(d) - v = np.random.rand(d) - mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) + u, v, mixin = identity_fit(d) mixin.random_M() # Dummy fit n = 100 From 80c9085c378529f765ab3be70f32c30dc8ed289a Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Thu, 23 Sep 2021 11:40:32 +0200 Subject: [PATCH 16/57] All tests have been generalized --- test/test_bilinear_mixin.py | 100 +++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index 5d8b9e72..e07f379c 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -5,7 +5,10 @@ import pytest from metric_learn._util import make_context from sklearn.cluster import DBSCAN +from sklearn.datasets import make_spd_matrix +from sklearn.utils import check_random_state +RNG = check_random_state(0) class IdentityBilinearMixin(BilinearMixin): """A simple Identity bilinear mixin that returns an identity matrix @@ -15,14 +18,17 @@ class IdentityBilinearMixin(BilinearMixin): def __init__(self, preprocessor=None): super().__init__(preprocessor=preprocessor) - def fit(self, X, y): + def fit(self, X, y, random=False): """ Checks input's format. Sets M matrix to identity of shape (d,d) where d is the dimension of the input. """ X, y = self._prepare_inputs(X, y, ensure_min_samples=2) self.d = np.shape(X[0])[-1] - self.components_ = np.identity(self.d) + if random: + self.components_ = np.random.rand(self.d, self.d) + else: + self.components_ = np.identity(self.d) return self def random_M(self): @@ -32,29 +38,34 @@ def random_M(self): self.components_ = np.random.rand(self.d, self.d) -def identity_fit(d=100): +def identity_fit(d=100, n=100, n_pairs=None, random=False): """ - Creates two d-dimentional arrays. Fits an IdentityBilinearMixin() - and then returns the two arrays and the mixin. Testing purposes + Creates 'n' d-dimentional arrays. Also generates 'n_pairs' + sampled from the 'n' arrays. Fits an IdentityBilinearMixin() + and then returns the arrays, the pairs and the mixin. Only + generates the pairs if n_pairs is not None """ - d = 100 - u = np.random.rand(d) - v = np.random.rand(d) + X = np.array([np.random.rand(d) for _ in range(n)]) mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) - return u, v, mixin + mixin.fit(X, [0 for _ in range(n)], random=random) + if n_pairs is not None: + random_pairs = [[X[RNG.randint(0, n)], X[RNG.randint(0, n)]] + for _ in range(n_pairs)] + else: + random_pairs = None + return X, random_pairs, mixin def test_same_similarity_with_two_methods(): """" Tests that score_pairs() and get_metric() give consistent results. In both cases, the results must match for the same input. + Tests it for 'n_pairs' sampled from 'n' d-dimentional arrays. """ - u, v, mixin = identity_fit() - mixin.random_M() # Dummy fit - # The distances must match, whether calc with get_metric() or score_pairs() - dist1 = mixin.score_pairs([[u, v], [v, u]]) - dist2 = [mixin.get_metric()(u, v), mixin.get_metric()(v, u)] + d, n, n_pairs= 100, 100, 1000 + _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) + dist1 = mixin.score_pairs(random_pairs) + dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] assert_array_almost_equal(dist1, dist2) @@ -65,14 +76,12 @@ def test_check_correctness_similarity(): get_metric(). Results are compared with the real bilinear similarity calculated in-place. """ - d = 100 - u, v, mixin = identity_fit(d) - dist1 = mixin.score_pairs([[u, v], [v, u]]) - dist2 = [mixin.get_metric()(u, v), mixin.get_metric()(v, u)] - - u_v = np.dot(np.dot(u.T, np.identity(d)), v) - v_u = np.dot(np.dot(v.T, np.identity(d)), u) - desired = [u_v, v_u] + d, n, n_pairs= 100, 100, 1000 + _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) + dist1 = mixin.score_pairs(random_pairs) + dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] + desired = [np.dot(np.dot(p[0].T, mixin.components_), p[1]) for p in random_pairs] + assert_array_almost_equal(dist1, desired) # score_pairs assert_array_almost_equal(dist2, desired) # get_metric @@ -98,13 +107,20 @@ def test_check_handmade_symmetric_example(): between two arrays must be equal: S(u,v) = S(v,u). Also checks the random case: when the matrix is pd and symetric. """ - u = np.array([0, 1, 2]) - v = np.array([3, 4, 5]) - mixin = IdentityBilinearMixin() - mixin.fit([u, v], [0, 0]) # Identity fit - dists = mixin.score_pairs([[u, v], [v, u]]) - assert_array_almost_equal(dists, [14, 14]) + # Random pairs for M = Identity + d, n, n_pairs= 100, 100, 1000 + _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs) + pairs_reverse = [[p[1], p[0]] for p in random_pairs] + dist1 = mixin.score_pairs(random_pairs) + dist2 = mixin.score_pairs(pairs_reverse) + assert_array_almost_equal(dist1, dist2) + # Random pairs for M = spd Matrix + spd_matrix = make_spd_matrix(d, random_state=RNG) + mixin.components_ = spd_matrix + dist1 = mixin.score_pairs(random_pairs) + dist2 = mixin.score_pairs(pairs_reverse) + assert_array_almost_equal(dist1, dist2) def test_score_pairs_finite(): """ @@ -112,13 +128,10 @@ def test_score_pairs_finite(): similarities are finite numbers, not NaN, +inf or -inf. Considering a random M for bilinear similarity. """ - d = 100 - u, v, mixin = identity_fit(d) - mixin.random_M() # Dummy fit - n = 100 - X = np.array([np.random.rand(d) for i in range(n)]) - pairs = np.array(list(product(X, X))) - assert np.isfinite(mixin.score_pairs(pairs)).all() + d, n, n_pairs= 100, 100, 1000 + _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) + dist1 = mixin.score_pairs(random_pairs) + assert np.isfinite(dist1).all() def test_score_pairs_dim(): @@ -127,11 +140,8 @@ def test_score_pairs_dim(): and scoring of 2D arrays (one tuple) should return an error (like scikit-learn's error when scoring 1D arrays) """ - d = 100 - u, v, mixin = identity_fit() - mixin.random_M() # Dummy fit - n = 100 - X = np.array([np.random.rand(d) for i in range(n)]) + d, n, n_pairs= 100, 100, 1000 + X, _, mixin = identity_fit(d=d, n=n, n_pairs=None, random=True) tuples = np.array(list(product(X, X))) assert mixin.score_pairs(tuples).shape == (tuples.shape[0],) context = make_context(mixin) @@ -146,11 +156,7 @@ def test_score_pairs_dim(): def test_check_scikitlearn_compatibility(): """Check that the similarity returned by get_metric() is compatible with scikit-learn's algorithms using a custom metric, DBSCAN for instance""" - d = 100 - u, v, mixin = identity_fit(d) - mixin.random_M() # Dummy fit - - n = 100 - X = np.array([np.random.rand(d) for i in range(n)]) + d, n= 100, 100 + X, _, mixin = identity_fit(d=d, n=n, n_pairs=None, random=True) clustering = DBSCAN(metric=mixin.get_metric()) clustering.fit(X) From 90ac5504b5957cdab43325217ab8f3d660d102dd Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Thu, 23 Sep 2021 11:43:22 +0200 Subject: [PATCH 17/57] Fix flake8 identation --- test/test_bilinear_mixin.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index e07f379c..dd65715d 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -10,6 +10,7 @@ RNG = check_random_state(0) + class IdentityBilinearMixin(BilinearMixin): """A simple Identity bilinear mixin that returns an identity matrix M as learned. Can change M for a random matrix calling random_M. @@ -50,7 +51,7 @@ def identity_fit(d=100, n=100, n_pairs=None, random=False): mixin.fit(X, [0 for _ in range(n)], random=random) if n_pairs is not None: random_pairs = [[X[RNG.randint(0, n)], X[RNG.randint(0, n)]] - for _ in range(n_pairs)] + for _ in range(n_pairs)] else: random_pairs = None return X, random_pairs, mixin @@ -62,7 +63,7 @@ def test_same_similarity_with_two_methods(): In both cases, the results must match for the same input. Tests it for 'n_pairs' sampled from 'n' d-dimentional arrays. """ - d, n, n_pairs= 100, 100, 1000 + d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) dist1 = mixin.score_pairs(random_pairs) dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] @@ -76,11 +77,12 @@ def test_check_correctness_similarity(): get_metric(). Results are compared with the real bilinear similarity calculated in-place. """ - d, n, n_pairs= 100, 100, 1000 + d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) dist1 = mixin.score_pairs(random_pairs) dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] - desired = [np.dot(np.dot(p[0].T, mixin.components_), p[1]) for p in random_pairs] + desired = [np.dot(np.dot(p[0].T, mixin.components_), p[1]) + for p in random_pairs] assert_array_almost_equal(dist1, desired) # score_pairs assert_array_almost_equal(dist2, desired) # get_metric @@ -108,7 +110,7 @@ def test_check_handmade_symmetric_example(): checks the random case: when the matrix is pd and symetric. """ # Random pairs for M = Identity - d, n, n_pairs= 100, 100, 1000 + d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs) pairs_reverse = [[p[1], p[0]] for p in random_pairs] dist1 = mixin.score_pairs(random_pairs) @@ -122,13 +124,14 @@ def test_check_handmade_symmetric_example(): dist2 = mixin.score_pairs(pairs_reverse) assert_array_almost_equal(dist1, dist2) + def test_score_pairs_finite(): """ Checks for 'n' score_pairs() of 'd' dimentions, that all similarities are finite numbers, not NaN, +inf or -inf. Considering a random M for bilinear similarity. """ - d, n, n_pairs= 100, 100, 1000 + d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) dist1 = mixin.score_pairs(random_pairs) assert np.isfinite(dist1).all() @@ -140,7 +143,7 @@ def test_score_pairs_dim(): and scoring of 2D arrays (one tuple) should return an error (like scikit-learn's error when scoring 1D arrays) """ - d, n, n_pairs= 100, 100, 1000 + d, n = 100, 100 X, _, mixin = identity_fit(d=d, n=n, n_pairs=None, random=True) tuples = np.array(list(product(X, X))) assert mixin.score_pairs(tuples).shape == (tuples.shape[0],) @@ -156,7 +159,7 @@ def test_score_pairs_dim(): def test_check_scikitlearn_compatibility(): """Check that the similarity returned by get_metric() is compatible with scikit-learn's algorithms using a custom metric, DBSCAN for instance""" - d, n= 100, 100 + d, n = 100, 100 X, _, mixin = identity_fit(d=d, n=n, n_pairs=None, random=True) clustering = DBSCAN(metric=mixin.get_metric()) clustering.fit(X) From 68eeda9d3022b245dcbeab6c3f8ecd4075855402 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Thu, 23 Sep 2021 11:48:43 +0200 Subject: [PATCH 18/57] Minor details --- test/test_bilinear_mixin.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index dd65715d..1947ef9a 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -13,16 +13,17 @@ class IdentityBilinearMixin(BilinearMixin): """A simple Identity bilinear mixin that returns an identity matrix - M as learned. Can change M for a random matrix calling random_M. - Class for testing purposes. + M as learned. Can change M for a random matrix specifying random=True + at fit(). Class for testing purposes. """ def __init__(self, preprocessor=None): super().__init__(preprocessor=preprocessor) def fit(self, X, y, random=False): """ - Checks input's format. Sets M matrix to identity of shape (d,d) - where d is the dimension of the input. + Checks input's format. If random=False, sets M matrix to + identity of shape (d,d) where d is the dimension of the input. + Otherwise, a random (d,d) matrix is set. """ X, y = self._prepare_inputs(X, y, ensure_min_samples=2) self.d = np.shape(X[0])[-1] @@ -32,19 +33,14 @@ def fit(self, X, y, random=False): self.components_ = np.identity(self.d) return self - def random_M(self): - """ - Changes the matrix M for a random one of shape (d,d) - """ - self.components_ = np.random.rand(self.d, self.d) - def identity_fit(d=100, n=100, n_pairs=None, random=False): """ Creates 'n' d-dimentional arrays. Also generates 'n_pairs' sampled from the 'n' arrays. Fits an IdentityBilinearMixin() and then returns the arrays, the pairs and the mixin. Only - generates the pairs if n_pairs is not None + generates the pairs if n_pairs is not None. If random=True, + the matrix M fitted will be random. """ X = np.array([np.random.rand(d) for _ in range(n)]) mixin = IdentityBilinearMixin() @@ -107,7 +103,7 @@ def test_check_handmade_symmetric_example(): """ When the Bilinear matrix is the identity. The similarity between two arrays must be equal: S(u,v) = S(v,u). Also - checks the random case: when the matrix is pd and symetric. + checks the random case: when the matrix is spd and symetric. """ # Random pairs for M = Identity d, n, n_pairs = 100, 100, 1000 @@ -128,8 +124,8 @@ def test_check_handmade_symmetric_example(): def test_score_pairs_finite(): """ Checks for 'n' score_pairs() of 'd' dimentions, that all - similarities are finite numbers, not NaN, +inf or -inf. - Considering a random M for bilinear similarity. + similarities are finite numbers: not NaN, +inf or -inf. + Considers a random M for bilinear similarity. """ d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) From c47797c895359d65a90e6c7cb5969bd82737773d Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Wed, 15 Sep 2021 19:15:42 +0200 Subject: [PATCH 19/57] Remove 3.9 from compatibility --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 46e5d2c9..c32755ff 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,7 +41,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ['3.6', '3.7', '3.8', '3.9'] + python-version: ['3.6', '3.7', '3.8'] steps: - uses: actions/checkout@v2 - name: Set up Python From e07b11a41e6334ee220cb9da6ae93b91107b41f6 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 1 Oct 2021 17:19:14 +0200 Subject: [PATCH 20/57] First draft of refactoring BaseMetricLearner and Mahalanobis Learner --- metric_learn/base_metric.py | 129 +++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 721d7ba0..7cfe4dc9 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -9,6 +9,7 @@ import numpy as np from abc import ABCMeta, abstractmethod from ._util import ArrayIndexer, check_input, validate_vector +import warnings class BaseMetricLearner(BaseEstimator, metaclass=ABCMeta): @@ -27,7 +28,8 @@ def __init__(self, preprocessor=None): @abstractmethod def score_pairs(self, pairs): - """Returns the score between pairs + """Deprecated. + Returns the score between pairs (can be a similarity, or a distance/metric depending on the algorithm) Parameters @@ -49,6 +51,57 @@ def score_pairs(self, pairs): learner is. """ + @abstractmethod + def pair_similarity(self, pairs): + """Returns the similarity score between pairs. Depending on the algorithm, + this function can return the direct learned similarity score between pairs, + or it can return the inverse of the distance learned between two pairs. + + Parameters + ---------- + pairs : `numpy.ndarray`, shape=(n_samples, 2, n_features) + 3D array of pairs. + + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The score of every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the metric between + two points. The difference with `score_pairs` is that it works on two 1D + arrays and cannot use a preprocessor. Besides, the returned function is + independent of the metric learner and hence is not modified if the metric + learner is. + """ + + @abstractmethod + def pair_distance(self, pairs): + """Returns the distance score between pairs. Depending on the algorithm, + this function can return the direct learned distance (or pseudo-distance) + score between pairs, or it can return the inverse score of the similarity + learned between two pairs. + + Parameters + ---------- + pairs : `numpy.ndarray`, shape=(n_samples, 2, n_features) + 3D array of pairs. + + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The score of every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the metric between + two points. The difference with `score_pairs` is that it works on two 1D + arrays and cannot use a preprocessor. Besides, the returned function is + independent of the metric learner and hence is not modified if the metric + learner is. + """ + def _check_preprocessor(self): """Initializes the preprocessor""" if _is_arraylike(self.preprocessor): @@ -182,7 +235,79 @@ class MahalanobisMixin(BaseMetricLearner, MetricTransformer, """ def score_pairs(self, pairs): - r"""Returns the learned Mahalanobis distance between pairs. + r"""Deprecated. + + Returns the learned Mahalanobis distance between pairs. + + This distance is defined as: :math:`d_M(x, x') = \sqrt{(x-x')^T M (x-x')}` + where ``M`` is the learned Mahalanobis matrix, for every pair of points + ``x`` and ``x'``. This corresponds to the euclidean distance between + embeddings of the points in a new space, obtained through a linear + transformation. Indeed, we have also: :math:`d_M(x, x') = \sqrt{(x_e - + x_e')^T (x_e- x_e')}`, with :math:`x_e = L x` (See + :class:`MahalanobisMixin`). + + Parameters + ---------- + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the metric learner uses a + preprocessor. + + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The learned Mahalanobis distance for every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the metric between + two points. The difference with `score_pairs` is that it works on two 1D + arrays and cannot use a preprocessor. Besides, the returned function is + independent of the metric learner and hence is not modified if the metric + learner is. + + :ref:`mahalanobis_distances` : The section of the project documentation + that describes Mahalanobis Distances. + """ + dpr_msg = ("score_pairs will be deprecated in the next release. " + "Use pair_similarity to compute similarities, or " + "pair_distances to compute distances.") + warnings.warn(dpr_msg, category=FutureWarning) + return self.pair_distance(pairs) + + def pair_similarity(self, pairs): + """ + Returns the inverse of the learned Mahalanobis distance between pairs. + + Parameters + ---------- + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the metric learner uses a + preprocessor. + + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The inverse of the learned Mahalanobis distance for every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the metric between + two points. The difference with `score_pairs` is that it works on two 1D + arrays and cannot use a preprocessor. Besides, the returned function is + independent of the metric learner and hence is not modified if the metric + learner is. + + :ref:`mahalanobis_distances` : The section of the project documentation + that describes Mahalanobis Distances. + """ + return -1 * self.pair_distance(pairs) + + def pair_distance(self, pairs): + """ + Returns the learned Mahalanobis distance between pairs. This distance is defined as: :math:`d_M(x, x') = \sqrt{(x-x')^T M (x-x')}` where ``M`` is the learned Mahalanobis matrix, for every pair of points From 8210acd02ccb1a3387fa422c5d400b44f6410d53 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 6 Oct 2021 12:07:38 +0200 Subject: [PATCH 21/57] Avoid warning related to score_pairs deprecation in tests of pair_calibraiton --- metric_learn/base_metric.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 7cfe4dc9..9bece1ef 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -486,7 +486,7 @@ def decision_function(self, pairs): pairs = check_input(pairs, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return - self.score_pairs(pairs) + return - self.pair_distance(pairs) def score(self, pairs, y): """Computes score of pairs similarity prediction. @@ -756,8 +756,8 @@ def decision_function(self, triplets): triplets = check_input(triplets, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return (self.score_pairs(triplets[:, [0, 2]]) - - self.score_pairs(triplets[:, :2])) + return (self.pair_distance(triplets[:, [0, 2]]) - + self.pair_distance(triplets[:, :2])) def score(self, triplets): """Computes score on input triplets. @@ -841,8 +841,8 @@ def decision_function(self, quadruplets): quadruplets = check_input(quadruplets, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return (self.score_pairs(quadruplets[:, 2:]) - - self.score_pairs(quadruplets[:, :2])) + return (self.pair_distance(quadruplets[:, 2:]) - + self.pair_distance(quadruplets[:, :2])) def score(self, quadruplets): """Computes score on input quadruplets From 11b5df62b4d69477cd8c4c140744826201e2c810 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 6 Oct 2021 12:07:50 +0200 Subject: [PATCH 22/57] Minor fix --- metric_learn/base_metric.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 9bece1ef..62694c88 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -239,11 +239,11 @@ def score_pairs(self, pairs): Returns the learned Mahalanobis distance between pairs. - This distance is defined as: :math:`d_M(x, x') = \sqrt{(x-x')^T M (x-x')}` + This distance is defined as: :math:`d_M(x, x') = \\sqrt{(x-x')^T M (x-x')}` where ``M`` is the learned Mahalanobis matrix, for every pair of points ``x`` and ``x'``. This corresponds to the euclidean distance between embeddings of the points in a new space, obtained through a linear - transformation. Indeed, we have also: :math:`d_M(x, x') = \sqrt{(x_e - + transformation. Indeed, we have also: :math:`d_M(x, x') = \\sqrt{(x_e - x_e')^T (x_e- x_e')}`, with :math:`x_e = L x` (See :class:`MahalanobisMixin`). @@ -309,11 +309,11 @@ def pair_distance(self, pairs): """ Returns the learned Mahalanobis distance between pairs. - This distance is defined as: :math:`d_M(x, x') = \sqrt{(x-x')^T M (x-x')}` + This distance is defined as: :math:`d_M(x, x') = \\sqrt{(x-x')^T M (x-x')}` where ``M`` is the learned Mahalanobis matrix, for every pair of points ``x`` and ``x'``. This corresponds to the euclidean distance between embeddings of the points in a new space, obtained through a linear - transformation. Indeed, we have also: :math:`d_M(x, x') = \sqrt{(x_e - + transformation. Indeed, we have also: :math:`d_M(x, x') = \\sqrt{(x_e - x_e')^T (x_e- x_e')}`, with :math:`x_e = L x` (See :class:`MahalanobisMixin`). From 06b71313d8ff77c61acdbe1ca84e57acbbb69f29 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 6 Oct 2021 14:35:10 +0200 Subject: [PATCH 23/57] Replaced score_pairs with pair_distance in tests --- test/test_mahalanobis_mixin.py | 22 +++++++++++----------- test/test_pairs_classifiers.py | 6 +++--- test/test_sklearn_compat.py | 2 +- test/test_utils.py | 16 ++++++++-------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index e3d981a4..2ba78a57 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -27,7 +27,7 @@ @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) -def test_score_pairs_pairwise(estimator, build_dataset): +def test_pair_distance_pairwise(estimator, build_dataset): # Computing pairwise scores should return a euclidean distance matrix. input_data, labels, _, X = build_dataset() n_samples = 20 @@ -36,7 +36,7 @@ def test_score_pairs_pairwise(estimator, build_dataset): set_random_state(model) model.fit(*remove_y(estimator, input_data, labels)) - pairwise = model.score_pairs(np.array(list(product(X, X))))\ + pairwise = model.pair_distance(np.array(list(product(X, X))))\ .reshape(n_samples, n_samples) check_is_distance_matrix(pairwise) @@ -51,8 +51,8 @@ def test_score_pairs_pairwise(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) -def test_score_pairs_toy_example(estimator, build_dataset): - # Checks that score_pairs works on a toy example +def test_pair_distance_toy_example(estimator, build_dataset): + # Checks that pair_distance works on a toy example input_data, labels, _, X = build_dataset() n_samples = 20 X = X[:n_samples] @@ -64,24 +64,24 @@ def test_score_pairs_toy_example(estimator, build_dataset): distances = np.sqrt(np.sum((embedded_pairs[:, 1] - embedded_pairs[:, 0])**2, axis=-1)) - assert_array_almost_equal(model.score_pairs(pairs), distances) + assert_array_almost_equal(model.pair_distance(pairs), distances) @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) -def test_score_pairs_finite(estimator, build_dataset): +def test_pair_distance_finite(estimator, build_dataset): # tests that the score is finite input_data, labels, _, X = build_dataset() model = clone(estimator) set_random_state(model) model.fit(*remove_y(estimator, input_data, labels)) pairs = np.array(list(product(X, X))) - assert np.isfinite(model.score_pairs(pairs)).all() + assert np.isfinite(model.pair_distance(pairs)).all() @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) -def test_score_pairs_dim(estimator, build_dataset): +def test_pair_distance_dim(estimator, build_dataset): # scoring of 3D arrays should return 1D array (several tuples), # and scoring of 2D arrays (one tuple) should return an error (like # scikit-learn's error when scoring 1D arrays) @@ -90,13 +90,13 @@ def test_score_pairs_dim(estimator, build_dataset): set_random_state(model) model.fit(*remove_y(estimator, input_data, labels)) tuples = np.array(list(product(X, X))) - assert model.score_pairs(tuples).shape == (tuples.shape[0],) + assert model.pair_distance(tuples).shape == (tuples.shape[0],) context = make_context(estimator) msg = ("3D array of formed tuples expected{}. Found 2D array " "instead:\ninput={}. Reshape your data and/or use a preprocessor.\n" .format(context, tuples[1])) with pytest.raises(ValueError) as raised_error: - model.score_pairs(tuples[1]) + model.pair_distance(tuples[1]) assert str(raised_error.value) == msg @@ -140,7 +140,7 @@ def test_embed_dim(estimator, build_dataset): "instead:\ninput={}. Reshape your data and/or use a " "preprocessor.\n".format(context, X[0])) with pytest.raises(ValueError) as raised_error: - model.score_pairs(model.transform(X[0, :])) + model.pair_distance(model.transform(X[0, :])) assert str(raised_error.value) == err_msg # we test that the shape is also OK when doing dimensionality reduction if hasattr(model, 'n_components'): diff --git a/test/test_pairs_classifiers.py b/test/test_pairs_classifiers.py index 824bb622..7023fbea 100644 --- a/test/test_pairs_classifiers.py +++ b/test/test_pairs_classifiers.py @@ -49,7 +49,7 @@ def test_predict_monotonous(estimator, build_dataset, pairs_train, pairs_test, y_train, y_test = train_test_split(input_data, labels) estimator.fit(pairs_train, y_train) - distances = estimator.score_pairs(pairs_test) + distances = estimator.pair_distance(pairs_test) predictions = estimator.predict(pairs_test) min_dissimilar = np.min(distances[predictions == -1]) max_similar = np.max(distances[predictions == 1]) @@ -65,7 +65,7 @@ def test_predict_monotonous(estimator, build_dataset, def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, with_preprocessor): """Test that a NotFittedError is raised if someone tries to use - score_pairs, decision_function, get_metric, transform or + pair_distance, decision_function, get_metric, transform or get_mahalanobis_matrix on input data and the metric learner has not been fitted.""" input_data, labels, preprocessor, _ = build_dataset(with_preprocessor) @@ -73,7 +73,7 @@ def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, estimator.set_params(preprocessor=preprocessor) set_random_state(estimator) with pytest.raises(NotFittedError): - estimator.score_pairs(input_data) + estimator.pair_distance(input_data) with pytest.raises(NotFittedError): estimator.decision_function(input_data) with pytest.raises(NotFittedError): diff --git a/test/test_sklearn_compat.py b/test/test_sklearn_compat.py index 3ad69712..d6077a16 100644 --- a/test/test_sklearn_compat.py +++ b/test/test_sklearn_compat.py @@ -148,7 +148,7 @@ def test_array_like_inputs(estimator, build_dataset, with_preprocessor): pairs = np.array([[X[0], X[1]], [X[0], X[2]]]) pairs_variants, _ = generate_array_like(pairs) for pairs_variant in pairs_variants: - estimator.score_pairs(pairs_variant) + estimator.pair_distance(pairs_variant) @pytest.mark.parametrize('with_preprocessor', [True, False]) diff --git a/test/test_utils.py b/test/test_utils.py index 072b94c5..39515f78 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -834,8 +834,8 @@ def test_error_message_tuple_size(estimator, _): @pytest.mark.parametrize('estimator, _', metric_learners, ids=ids_metric_learners) -def test_error_message_t_score_pairs(estimator, _): - """tests that if you want to score_pairs on triplets for instance, it returns +def test_error_message_t_pair_distance(estimator, _): + """tests that if you want to pair_distance on triplets for instance, it returns the right error message """ estimator = clone(estimator) @@ -844,7 +844,7 @@ def test_error_message_t_score_pairs(estimator, _): triplets = np.array([[[1.3, 6.3], [3., 6.8], [6.5, 4.4]], [[1.9, 5.3], [1., 7.8], [3.2, 1.2]]]) with pytest.raises(ValueError) as raised_err: - estimator.score_pairs(triplets) + estimator.pair_distance(triplets) expected_msg = ("Tuples of 2 element(s) expected{}. Got tuples of 3 " "element(s) instead (shape=(2, 3, 2)):\ninput={}.\n" .format(make_context(estimator), triplets)) @@ -930,17 +930,17 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): method)(formed_test) assert np.array(output_with_prep == output_with_prep_formed).all() - # test score_pairs + # test pair_distance idx1 = np.array([[0, 2], [5, 3]], dtype=int) - output_with_prep = estimator_with_preprocessor.score_pairs( + output_with_prep = estimator_with_preprocessor.pair_distance( indicators_to_transform[idx1]) - output_without_prep = estimator_without_preprocessor.score_pairs( + output_without_prep = estimator_without_preprocessor.pair_distance( formed_points_to_transform[idx1]) assert np.array(output_with_prep == output_without_prep).all() - output_with_prep = estimator_with_preprocessor.score_pairs( + output_with_prep = estimator_with_preprocessor.pair_distance( indicators_to_transform[idx1]) - output_without_prep = estimator_with_prep_formed.score_pairs( + output_without_prep = estimator_with_prep_formed.pair_distance( formed_points_to_transform[idx1]) assert np.array(output_with_prep == output_without_prep).all() From d5cb8b49da42d42d2f869b9ad6116918f5528337 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 6 Oct 2021 14:35:51 +0200 Subject: [PATCH 24/57] Replace score_pairs with pair_distance inb docs. --- doc/introduction.rst | 2 +- doc/supervised.rst | 4 ++-- doc/weakly_supervised.rst | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/doc/introduction.rst b/doc/introduction.rst index 7d9f52d0..6faf021d 100644 --- a/doc/introduction.rst +++ b/doc/introduction.rst @@ -141,7 +141,7 @@ to the following resources: .. :math:`D`-dimensional learned metric space :math:`X L^{\top}`, .. in which standard Euclidean distances may be used. .. - ``transform(X)``, which applies the aforementioned transformation. -.. - ``score_pairs(pairs)`` which returns the distance between pairs of +.. - ``pair_distance(pairs)`` which returns the distance between pairs of .. points. ``pairs`` should be a 3D array-like of pairs of shape ``(n_pairs, .. 2, n_features)``, or it can be a 2D array-like of pairs indicators of .. shape ``(n_pairs, 2)`` (see section :ref:`preprocessor_section` for more diff --git a/doc/supervised.rst b/doc/supervised.rst index c6d8b68b..cde7cb86 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -69,9 +69,9 @@ Also, as explained before, our metric learners has learn a distance between points. You can use this distance in two main ways: - You can either return the distance between pairs of points using the - `score_pairs` function: + `pair_distance` function: ->>> nca.score_pairs([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]]]) +>>> nca.pair_distance([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]]]) array([0.49627072, 3.65287282]) - Or you can return a function that will return the distance (in the new diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index 174210b8..c98b9ea0 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -160,9 +160,9 @@ Also, as explained before, our metric learner has learned a distance between points. You can use this distance in two main ways: - You can either return the distance between pairs of points using the - `score_pairs` function: + `pair_distance` function: ->>> mmc.score_pairs([[[3.5, 3.6, 5.2], [5.6, 2.4, 6.7]], +>>> mmc.pair_distance([[[3.5, 3.6, 5.2], [5.6, 2.4, 6.7]], ... [[1.2, 4.2, 7.7], [2.1, 6.4, 0.9]]]) array([7.27607365, 0.88853014]) @@ -344,7 +344,7 @@ returns the `sklearn.metrics.roc_auc_score` (which is threshold-independent). .. note:: See :ref:`fit_ws` for more details on metric learners functions that are - not specific to learning on pairs, like `transform`, `score_pairs`, + not specific to learning on pairs, like `transform`, `pair_distance`, `get_metric` and `get_mahalanobis_matrix`. Algorithms @@ -691,7 +691,7 @@ of triplets that have the right predicted ordering. .. note:: See :ref:`fit_ws` for more details on metric learners functions that are - not specific to learning on pairs, like `transform`, `score_pairs`, + not specific to learning on pairs, like `transform`, `pair_distance`, `get_metric` and `get_mahalanobis_matrix`. @@ -859,7 +859,7 @@ of quadruplets have the right predicted ordering. .. note:: See :ref:`fit_ws` for more details on metric learners functions that are - not specific to learning on pairs, like `transform`, `score_pairs`, + not specific to learning on pairs, like `transform`, `pair_distance`, `get_metric` and `get_mahalanobis_matrix`. From 2f61e7bc823709f26873eb5cc5ae63c56b6af6e6 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 8 Oct 2021 11:27:41 +0200 Subject: [PATCH 25/57] Fix weird commit --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c32755ff..46e5d2c9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,7 +41,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ['3.6', '3.7', '3.8'] + python-version: ['3.6', '3.7', '3.8', '3.9'] steps: - uses: actions/checkout@v2 - name: Set up Python From 9dd38aa01e7276ef8562b61996276876b6a72a83 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 8 Oct 2021 11:30:43 +0200 Subject: [PATCH 26/57] Fix weird commit --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c32755ff..46e5d2c9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,7 +41,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ['3.6', '3.7', '3.8'] + python-version: ['3.6', '3.7', '3.8', '3.9'] steps: - uses: actions/checkout@v2 - name: Set up Python From 5f68ed2120ff24212212482c69bac1817b102631 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 8 Oct 2021 11:38:43 +0200 Subject: [PATCH 27/57] Update classifiers to use pair_similarity --- metric_learn/base_metric.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 62694c88..b317db49 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -486,7 +486,7 @@ def decision_function(self, pairs): pairs = check_input(pairs, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return - self.pair_distance(pairs) + return self.pair_similarity(pairs) def score(self, pairs, y): """Computes score of pairs similarity prediction. @@ -756,8 +756,8 @@ def decision_function(self, triplets): triplets = check_input(triplets, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return (self.pair_distance(triplets[:, [0, 2]]) - - self.pair_distance(triplets[:, :2])) + return (self.pair_similarity(triplets[:, :2]) - + self.pair_similarity(triplets[:, [0, 2]])) def score(self, triplets): """Computes score on input triplets. @@ -841,8 +841,8 @@ def decision_function(self, quadruplets): quadruplets = check_input(quadruplets, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return (self.pair_distance(quadruplets[:, 2:]) - - self.pair_distance(quadruplets[:, :2])) + return (self.pair_similarity(quadruplets[:, :2]) - + self.pair_similarity(quadruplets[:, 2:])) def score(self, quadruplets): """Computes score on input quadruplets From 3d6450b950fd94707e172e993c39ea885a1f1cd9 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 8 Oct 2021 14:06:28 +0200 Subject: [PATCH 28/57] Updated rst docs --- doc/supervised.rst | 28 ++++++++++++++++++++++++++-- doc/weakly_supervised.rst | 23 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/doc/supervised.rst b/doc/supervised.rst index cde7cb86..ea5f5d29 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -71,8 +71,8 @@ points. You can use this distance in two main ways: - You can either return the distance between pairs of points using the `pair_distance` function: ->>> nca.pair_distance([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]]]) -array([0.49627072, 3.65287282]) +>>> nca.pair_distance([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +array([0.49627072, 3.65287282, 6.06079877]) - Or you can return a function that will return the distance (in the new space) between two 1D arrays (the coordinates of the points in the original @@ -82,6 +82,29 @@ array([0.49627072, 3.65287282]) >>> metric_fun([3.5, 3.6], [5.6, 2.4]) 0.4962707194621285 +- Alternatively, you can use `pair_similarity` to return the **score** between + points, the more the **score**, the closer the pairs and vice-versa. For + Mahalanobis learners, it is equal to the inverse of the distance. + +>>> score = nca.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +>>> score +array([-0.49627072, -3.65287282, -6.06079877]) + + This is useful because `pair_similarity` matches the **score** sematic of + scikit-learn's `Classification matrics `_. + For instance, given a labeled data, you can pass the labels and the + **score** of your data to get the ROC curve. + +>>> from sklearn.metrics import roc_curve +>>> fpr, tpr, thresholds = roc_curve(['dog', 'cat', 'dog'], score, pos_label='dog') +>>> fpr +array([0., 0., 1., 1.]) +>>> tpr +array([0. , 0.5, 0.5, 1. ]) +>>> +>>> thresholds +array([ 0.50372928, -0.49627072, -3.65287282, -6.06079877]) + .. note:: If the metric learner that you use learns a :ref:`Mahalanobis distance @@ -105,6 +128,7 @@ All supervised algorithms are scikit-learn estimators scikit-learn model selection routines (`sklearn.model_selection.cross_val_score`, `sklearn.model_selection.GridSearchCV`, etc). +You can also use methods from `sklearn.metrics` that rely on y_scores. Algorithms ========== diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index c98b9ea0..66a34dff 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -175,6 +175,29 @@ array([7.27607365, 0.88853014]) >>> metric_fun([3.5, 3.6, 5.2], [5.6, 2.4, 6.7]) 7.276073646278203 +- Alternatively, you can use `pair_similarity` to return the **score** between + points, the more the **score**, the closer the pairs and vice-versa. For + Mahalanobis learners, it is equal to the inverse of the distance. + +>>> score = mmc.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +>>> score +array([-0.49627072, -3.65287282, -6.06079877]) + + This is useful because `pair_similarity` matches the **score** sematic of + scikit-learn's `Classification matrics `_. + For instance, given a labeled data, you can pass the labels and the + **score** of your data to get the ROC curve. + +>>> from sklearn.metrics import roc_curve +>>> fpr, tpr, thresholds = roc_curve(['dog', 'cat', 'dog'], score, pos_label='dog') +>>> fpr +array([0., 0., 1., 1.]) +>>> tpr +array([0. , 0.5, 0.5, 1. ]) +>>> +>>> thresholds +array([ 0.50372928, -0.49627072, -3.65287282, -6.06079877]) + .. note:: If the metric learner that you use learns a :ref:`Mahalanobis distance From 7bce493b4ad9f9f56d68d11200fc695147a9a606 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 8 Oct 2021 15:15:28 +0200 Subject: [PATCH 29/57] Fix identation --- metric_learn/base_metric.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index b317db49..59a8a302 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -756,7 +756,7 @@ def decision_function(self, triplets): triplets = check_input(triplets, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return (self.pair_similarity(triplets[:, :2]) - + return (self.pair_similarity(triplets[:, :2]) - self.pair_similarity(triplets[:, [0, 2]])) def score(self, triplets): @@ -841,7 +841,7 @@ def decision_function(self, quadruplets): quadruplets = check_input(quadruplets, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return (self.pair_similarity(quadruplets[:, :2]) - + return (self.pair_similarity(quadruplets[:, :2]) - self.pair_similarity(quadruplets[:, 2:])) def score(self, quadruplets): From 7e6584a186fd2405814caada6d9422c1e8626f6d Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Mon, 11 Oct 2021 14:07:59 +0200 Subject: [PATCH 30/57] Update docs of score_pairs, get_metric --- metric_learn/base_metric.py | 102 +++++++++++++++++++++++------------- 1 file changed, 66 insertions(+), 36 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 59a8a302..2d48ca65 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -28,7 +28,14 @@ def __init__(self, preprocessor=None): @abstractmethod def score_pairs(self, pairs): - """Deprecated. + """ + .. deprecated:: 0.6.3 Refer to `pair_distance` and `pair_similarity`. + + .. warning:: + This method will be deleted in 0.6.4. Please refer to `pair_distance` + or `pair_similarity`. This change will occur in order to add learners + that don't necessarly learn a Mahalanobis distance. + Returns the score between pairs (can be a similarity, or a distance/metric depending on the algorithm) @@ -45,17 +52,22 @@ def score_pairs(self, pairs): See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference with `score_pairs` is that it works on two 1D - arrays and cannot use a preprocessor. Besides, the returned function is - independent of the metric learner and hence is not modified if the metric - learner is. + two points. The difference with `score_pairs` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. """ @abstractmethod def pair_similarity(self, pairs): - """Returns the similarity score between pairs. Depending on the algorithm, - this function can return the direct learned similarity score between pairs, - or it can return the inverse of the distance learned between two pairs. + """ + .. versionadded:: 0.6.3 Compute the similarity score bewteen pairs + + Returns the similarity score between pairs. Depending on the algorithm, + this method can return the learned similarity score between pairs, + or the inverse of the distance learned between two pairs. The more the + score, the more similar the pairs. All learners have access to this + method. Parameters ---------- @@ -70,18 +82,21 @@ def pair_similarity(self, pairs): See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference with `score_pairs` is that it works on two 1D - arrays and cannot use a preprocessor. Besides, the returned function is - independent of the metric learner and hence is not modified if the metric - learner is. + two points. The difference with `pair_similarity` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. """ @abstractmethod def pair_distance(self, pairs): - """Returns the distance score between pairs. Depending on the algorithm, - this function can return the direct learned distance (or pseudo-distance) - score between pairs, or it can return the inverse score of the similarity - learned between two pairs. + """ + .. versionadded:: 0.6.3 Compute the distance score between pairs + + Returns the distance score between pairs. For Mahalanobis learners, it + returns the pseudo-distance bewtween pairs. It is not available for + learners that does not learn a distance or pseudo-distance, an error + will be shown instead. Parameters ---------- @@ -96,10 +111,10 @@ def pair_distance(self, pairs): See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference with `score_pairs` is that it works on two 1D - arrays and cannot use a preprocessor. Besides, the returned function is - independent of the metric learner and hence is not modified if the metric - learner is. + two points. The difference with `pair_distance` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. """ def _check_preprocessor(self): @@ -156,7 +171,9 @@ def _prepare_inputs(self, X, y=None, type_of_inputs='classic', @abstractmethod def get_metric(self): """Returns a function that takes as input two 1D arrays and outputs the - learned metric score on these two points. + learned metric score on these two points. Depending on the algorithm, it + can return the distance or similarity function between pairs. It always + returns what the specific algorithm learns. This function will be independent from the metric learner that learned it (it will not be modified if the initial metric learner is modified), @@ -189,7 +206,12 @@ def get_metric(self): See Also -------- - score_pairs : a method that returns the metric score between several pairs + pair_distance : a method that returns the distance score between several pairs + of points. Unlike `get_metric`, this is a method of the metric learner + and therefore can change if the metric learner changes. Besides, it can + use the metric learner's preprocessor, and works on concatenated arrays. + + pair_similarity : a method that returns the similarity score between several pairs of points. Unlike `get_metric`, this is a method of the metric learner and therefore can change if the metric learner changes. Besides, it can use the metric learner's preprocessor, and works on concatenated arrays. @@ -235,7 +257,14 @@ class MahalanobisMixin(BaseMetricLearner, MetricTransformer, """ def score_pairs(self, pairs): - r"""Deprecated. + r""" + .. deprecated:: 0.6.3 + This method is deprecated. Please use `pair_distance` instead. + + .. warning:: + This method will be deleted in 0.6.4. Please refer to `pair_distance` + or `pair_similarity`. This change will occur in order to add learners + that don't necessarly learn a Mahalanobis distance. Returns the learned Mahalanobis distance between pairs. @@ -262,15 +291,16 @@ def score_pairs(self, pairs): See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference with `score_pairs` is that it works on two 1D - arrays and cannot use a preprocessor. Besides, the returned function is - independent of the metric learner and hence is not modified if the metric - learner is. + two points. The difference with `score_pairs` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. :ref:`mahalanobis_distances` : The section of the project documentation that describes Mahalanobis Distances. + """ - dpr_msg = ("score_pairs will be deprecated in the next release. " + dpr_msg = ("score_pairs will be deprecated in release 0.6.3. " "Use pair_similarity to compute similarities, or " "pair_distances to compute distances.") warnings.warn(dpr_msg, category=FutureWarning) @@ -295,10 +325,10 @@ def pair_similarity(self, pairs): See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference with `score_pairs` is that it works on two 1D - arrays and cannot use a preprocessor. Besides, the returned function is - independent of the metric learner and hence is not modified if the metric - learner is. + two points. The difference with `pair_similarity` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. :ref:`mahalanobis_distances` : The section of the project documentation that describes Mahalanobis Distances. @@ -332,10 +362,10 @@ def pair_distance(self, pairs): See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference with `score_pairs` is that it works on two 1D - arrays and cannot use a preprocessor. Besides, the returned function is - independent of the metric learner and hence is not modified if the metric - learner is. + two points. The difference with `pair_distance` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. :ref:`mahalanobis_distances` : The section of the project documentation that describes Mahalanobis Distances. From 0b58f455dc6bab5557ac65c58ebb2befa67e93d9 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Mon, 11 Oct 2021 14:23:10 +0200 Subject: [PATCH 31/57] Add deprecation Test. Fix identation --- metric_learn/base_metric.py | 27 ++++++++++++++------------- test/test_base_metric.py | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 2d48ca65..b9e540c6 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -62,7 +62,7 @@ def score_pairs(self, pairs): def pair_similarity(self, pairs): """ .. versionadded:: 0.6.3 Compute the similarity score bewteen pairs - + Returns the similarity score between pairs. Depending on the algorithm, this method can return the learned similarity score between pairs, or the inverse of the distance learned between two pairs. The more the @@ -92,7 +92,7 @@ def pair_similarity(self, pairs): def pair_distance(self, pairs): """ .. versionadded:: 0.6.3 Compute the distance score between pairs - + Returns the distance score between pairs. For Mahalanobis learners, it returns the pseudo-distance bewtween pairs. It is not available for learners that does not learn a distance or pseudo-distance, an error @@ -206,15 +206,17 @@ def get_metric(self): See Also -------- - pair_distance : a method that returns the distance score between several pairs - of points. Unlike `get_metric`, this is a method of the metric learner - and therefore can change if the metric learner changes. Besides, it can - use the metric learner's preprocessor, and works on concatenated arrays. - - pair_similarity : a method that returns the similarity score between several pairs - of points. Unlike `get_metric`, this is a method of the metric learner - and therefore can change if the metric learner changes. Besides, it can - use the metric learner's preprocessor, and works on concatenated arrays. + pair_distance : a method that returns the distance score between several + pairs of points. Unlike `get_metric`, this is a method of the metric + learner and therefore can change if the metric learner changes. Besides, + it can use the metric learner's preprocessor, and works on concatenated + arrays. + + pair_similarity : a method that returns the similarity score between + several pairs of points. Unlike `get_metric`, this is a method of the + metric learner and therefore can change if the metric learner changes. + Besides, it can use the metric learner's preprocessor, and works on + concatenated arrays. """ @@ -261,7 +263,7 @@ def score_pairs(self, pairs): .. deprecated:: 0.6.3 This method is deprecated. Please use `pair_distance` instead. - .. warning:: + .. warning:: This method will be deleted in 0.6.4. Please refer to `pair_distance` or `pair_similarity`. This change will occur in order to add learners that don't necessarly learn a Mahalanobis distance. @@ -298,7 +300,6 @@ def score_pairs(self, pairs): :ref:`mahalanobis_distances` : The section of the project documentation that describes Mahalanobis Distances. - """ dpr_msg = ("score_pairs will be deprecated in release 0.6.3. " "Use pair_similarity to compute similarities, or " diff --git a/test/test_base_metric.py b/test/test_base_metric.py index 67f9b6a0..e96afe23 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -1,3 +1,4 @@ +from numpy.core.numeric import array_equal import pytest import re import unittest @@ -274,5 +275,28 @@ def test_n_components(estimator, build_dataset): 'Invalid n_components, must be in [1, {}]'.format(X.shape[1])) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners, + ids=ids_metric_learners) +def test_score_pairs_warning(estimator, build_dataset): + """Tests that score_pairs returns a FutureWarning regarding deprecation. + Also that score_pairs and pair_distance have the same behaviour""" + input_data, labels, _, X = build_dataset() + model = clone(estimator) + set_random_state(model) + + # we fit the metric learner on it and then we call score_apirs on some + # points + model.fit(*remove_y(model, input_data, labels)) + + msg = ("score_pairs will be deprecated in release 0.6.3. " + "Use pair_similarity to compute similarities, or " + "pair_distances to compute distances.") + with pytest.warns(FutureWarning) as raised_warning: + score = model.score_pairs([[X[0], X[1]], ]) + dist = model.pair_distance([[X[0], X[1]], ]) + assert array_equal(score, dist) + assert np.any([str(warning.message) == msg for warning in raised_warning]) + + if __name__ == '__main__': unittest.main() From 78a205c3d9d956c2cc28607a21799b41f25db9f8 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Mon, 11 Oct 2021 16:32:40 +0200 Subject: [PATCH 32/57] Refactor to use pair_similarity instead of score_pairs --- metric_learn/base_metric.py | 28 +++++++++++++++++++++++----- test/test_bilinear_mixin.py | 32 ++++++++++++++++---------------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 1883adb2..cb9a0947 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -255,9 +255,27 @@ class BilinearMixin(BaseMetricLearner, metaclass=ABCMeta): """ def score_pairs(self, pairs): + dpr_msg = ("score_pairs will be deprecated in release 0.6.4. " + "Use pair_similarity to compute similarities, or " + "pair_distances to compute distances.") + warnings.warn(dpr_msg, category=FutureWarning) + return self.pair_similarity(pairs) + + def pair_distance(self, pairs): + """ + Returns an error, as bilinear similarity learners don't learn a + pseudo-distance nor a distance. In consecuence, the additive inverse + of the bilinear similarity cannot be used as distance by construction. + """ + msg = ("Bilinear similarity learners don't learn a distance, thus ", + "this method is not implemented. Use pair_similarity to " + "compute similarity between pairs") + raise Exception(msg) + + def pair_similarity(self, pairs): r"""Returns the learned Bilinear similarity between pairs. - This similarity is defined as: :math:`s_M(x, x') = x M x'` + This similarity is defined as: :math:`s_M(x, x') = x^T M x'` where ``M`` is the learned Bilinear matrix, for every pair of points ``x`` and ``x'``. @@ -276,10 +294,10 @@ def score_pairs(self, pairs): See Also -------- get_metric : a method that returns a function to compute the similarity - between two points. The difference with `score_pairs` is that it works - on two 1D arrays and cannot use a preprocessor. Besides, the returned - function is independent of the similarity learner and hence is not - modified if the similarity learner is. + between two points. The difference with `pair_similarity` is that it + works on two 1D arrays and cannot use a preprocessor. Besides, the + returned function is independent of the similarity learner and hence + is not modified if the similarity learner is. :ref:`Bilinear_similarity` : The section of the project documentation that describes Bilinear similarity. diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index 1947ef9a..3783bf1c 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -55,13 +55,13 @@ def identity_fit(d=100, n=100, n_pairs=None, random=False): def test_same_similarity_with_two_methods(): """" - Tests that score_pairs() and get_metric() give consistent results. + Tests that pair_similarity() and get_metric() give consistent results. In both cases, the results must match for the same input. Tests it for 'n_pairs' sampled from 'n' d-dimentional arrays. """ d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - dist1 = mixin.score_pairs(random_pairs) + dist1 = mixin.pair_similarity(random_pairs) dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] assert_array_almost_equal(dist1, dist2) @@ -75,18 +75,18 @@ def test_check_correctness_similarity(): """ d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - dist1 = mixin.score_pairs(random_pairs) + dist1 = mixin.pair_similarity(random_pairs) dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] desired = [np.dot(np.dot(p[0].T, mixin.components_), p[1]) for p in random_pairs] - assert_array_almost_equal(dist1, desired) # score_pairs + assert_array_almost_equal(dist1, desired) # pair_similarity assert_array_almost_equal(dist2, desired) # get_metric def test_check_handmade_example(): """ - Checks that score_pairs() result is correct comparing it with a + Checks that pair_similarity() result is correct comparing it with a handmade example. """ u = np.array([0, 1, 2]) @@ -95,7 +95,7 @@ def test_check_handmade_example(): mixin.fit([u, v], [0, 0]) # Identity fit c = np.array([[2, 4, 6], [6, 4, 2], [1, 2, 3]]) mixin.components_ = c # Force components_ - dists = mixin.score_pairs([[u, v], [v, u]]) + dists = mixin.pair_similarity([[u, v], [v, u]]) assert_array_almost_equal(dists, [96, 120]) @@ -109,31 +109,31 @@ def test_check_handmade_symmetric_example(): d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs) pairs_reverse = [[p[1], p[0]] for p in random_pairs] - dist1 = mixin.score_pairs(random_pairs) - dist2 = mixin.score_pairs(pairs_reverse) + dist1 = mixin.pair_similarity(random_pairs) + dist2 = mixin.pair_similarity(pairs_reverse) assert_array_almost_equal(dist1, dist2) # Random pairs for M = spd Matrix spd_matrix = make_spd_matrix(d, random_state=RNG) mixin.components_ = spd_matrix - dist1 = mixin.score_pairs(random_pairs) - dist2 = mixin.score_pairs(pairs_reverse) + dist1 = mixin.pair_similarity(random_pairs) + dist2 = mixin.pair_similarity(pairs_reverse) assert_array_almost_equal(dist1, dist2) -def test_score_pairs_finite(): +def test_pair_similarity_finite(): """ - Checks for 'n' score_pairs() of 'd' dimentions, that all + Checks for 'n' pair_similarity() of 'd' dimentions, that all similarities are finite numbers: not NaN, +inf or -inf. Considers a random M for bilinear similarity. """ d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - dist1 = mixin.score_pairs(random_pairs) + dist1 = mixin.pair_similarity(random_pairs) assert np.isfinite(dist1).all() -def test_score_pairs_dim(): +def test_pair_similarity_dim(): """ Scoring of 3D arrays should return 1D array (several tuples), and scoring of 2D arrays (one tuple) should return an error (like @@ -142,13 +142,13 @@ def test_score_pairs_dim(): d, n = 100, 100 X, _, mixin = identity_fit(d=d, n=n, n_pairs=None, random=True) tuples = np.array(list(product(X, X))) - assert mixin.score_pairs(tuples).shape == (tuples.shape[0],) + assert mixin.pair_similarity(tuples).shape == (tuples.shape[0],) context = make_context(mixin) msg = ("3D array of formed tuples expected{}. Found 2D array " "instead:\ninput={}. Reshape your data and/or use a preprocessor.\n" .format(context, tuples[1])) with pytest.raises(ValueError) as raised_error: - mixin.score_pairs(tuples[1]) + mixin.pair_similarity(tuples[1]) assert str(raised_error.value) == msg From dde35760bed45165f9bc7cd052d50b2685347189 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 13 Oct 2021 16:20:25 +0200 Subject: [PATCH 33/57] Add more testing. Test refactor TBD --- test/test_bilinear_mixin.py | 47 +++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index 3783bf1c..ed2189e8 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -69,19 +69,22 @@ def test_same_similarity_with_two_methods(): def test_check_correctness_similarity(): """ - Tests the correctness of the results made from socre_paris() and - get_metric(). Results are compared with the real bilinear similarity - calculated in-place. + Tests the correctness of the results made from socre_paris(), + get_metric() and get_bilinear_matrix. Results are compared with + the real bilinear similarity calculated in-place. """ d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) dist1 = mixin.pair_similarity(random_pairs) dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] + dist3 = [np.dot(np.dot(p[0].T, mixin.get_bilinear_matrix()), p[1]) + for p in random_pairs] desired = [np.dot(np.dot(p[0].T, mixin.components_), p[1]) for p in random_pairs] assert_array_almost_equal(dist1, desired) # pair_similarity assert_array_almost_equal(dist2, desired) # get_metric + assert_array_almost_equal(dist3, desired) # get_metric def test_check_handmade_example(): @@ -153,9 +156,43 @@ def test_pair_similarity_dim(): def test_check_scikitlearn_compatibility(): - """Check that the similarity returned by get_metric() is compatible with - scikit-learn's algorithms using a custom metric, DBSCAN for instance""" + """ + Check that the similarity returned by get_metric() is compatible with + scikit-learn's algorithms using a custom metric, DBSCAN for instance + """ d, n = 100, 100 X, _, mixin = identity_fit(d=d, n=n, n_pairs=None, random=True) clustering = DBSCAN(metric=mixin.get_metric()) clustering.fit(X) + + +def test_check_score_pairs_deprecation_and_output(): + """ + Check that calling score_pairs shows a warning of deprecation, and also + that the output of score_pairs matches calling pair_similarity. + """ + d, n, n_pairs = 100, 100, 1000 + _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) + dpr_msg = ("score_pairs will be deprecated in release 0.6.4. " + "Use pair_similarity to compute similarities, or " + "pair_distances to compute distances.") + with pytest.warns(FutureWarning) as raised_warnings: + s1 = mixin.score_pairs(random_pairs) + s2 = mixin.pair_similarity(random_pairs) + assert_array_almost_equal(s1, s2) + assert any(str(w.message) == dpr_msg for w in raised_warnings) + + +def test_check_error_with_pair_distance(): + """ + Check that calling pair_distance is not possible with a Bilinear learner. + An Exception must be shown instead. + """ + d, n, n_pairs = 100, 100, 1000 + _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) + msg = ("Bilinear similarity learners don't learn a distance, thus ", + "this method is not implemented. Use pair_similarity to " + "compute similarity between pairs") + with pytest.raises(Exception) as e: + _ = mixin.pair_distance(random_pairs) + assert e.value.args[0] == msg From 3020110b2d4e575b8954fdac27a17f90ed563d24 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 13 Oct 2021 17:11:21 +0200 Subject: [PATCH 34/57] Tests are now parametrized --- test/test_bilinear_mixin.py | 47 +++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index ed2189e8..4a47952a 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -53,13 +53,15 @@ def identity_fit(d=100, n=100, n_pairs=None, random=False): return X, random_pairs, mixin -def test_same_similarity_with_two_methods(): +@pytest.mark.parametrize('d', [10, 300]) +@pytest.mark.parametrize('n', [10, 100]) +@pytest.mark.parametrize('n_pairs', [100, 1000]) +def test_same_similarity_with_two_methods(d, n, n_pairs): """" Tests that pair_similarity() and get_metric() give consistent results. In both cases, the results must match for the same input. Tests it for 'n_pairs' sampled from 'n' d-dimentional arrays. """ - d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) dist1 = mixin.pair_similarity(random_pairs) dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] @@ -67,13 +69,15 @@ def test_same_similarity_with_two_methods(): assert_array_almost_equal(dist1, dist2) -def test_check_correctness_similarity(): +@pytest.mark.parametrize('d', [10, 300]) +@pytest.mark.parametrize('n', [10, 100]) +@pytest.mark.parametrize('n_pairs', [100, 1000]) +def test_check_correctness_similarity(d, n, n_pairs): """ Tests the correctness of the results made from socre_paris(), get_metric() and get_bilinear_matrix. Results are compared with the real bilinear similarity calculated in-place. """ - d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) dist1 = mixin.pair_similarity(random_pairs) dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] @@ -102,14 +106,15 @@ def test_check_handmade_example(): assert_array_almost_equal(dists, [96, 120]) -def test_check_handmade_symmetric_example(): +@pytest.mark.parametrize('d', [10, 300]) +@pytest.mark.parametrize('n', [10, 100]) +@pytest.mark.parametrize('n_pairs', [100, 1000]) +def test_check_handmade_symmetric_example(d, n, n_pairs): """ When the Bilinear matrix is the identity. The similarity between two arrays must be equal: S(u,v) = S(v,u). Also checks the random case: when the matrix is spd and symetric. """ - # Random pairs for M = Identity - d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs) pairs_reverse = [[p[1], p[0]] for p in random_pairs] dist1 = mixin.pair_similarity(random_pairs) @@ -124,25 +129,28 @@ def test_check_handmade_symmetric_example(): assert_array_almost_equal(dist1, dist2) -def test_pair_similarity_finite(): +@pytest.mark.parametrize('d', [10, 300]) +@pytest.mark.parametrize('n', [10, 100]) +@pytest.mark.parametrize('n_pairs', [100, 1000]) +def test_pair_similarity_finite(d, n, n_pairs): """ Checks for 'n' pair_similarity() of 'd' dimentions, that all similarities are finite numbers: not NaN, +inf or -inf. Considers a random M for bilinear similarity. """ - d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) dist1 = mixin.pair_similarity(random_pairs) assert np.isfinite(dist1).all() -def test_pair_similarity_dim(): +@pytest.mark.parametrize('d', [10, 300]) +@pytest.mark.parametrize('n', [10, 100]) +def test_pair_similarity_dim(d, n): """ Scoring of 3D arrays should return 1D array (several tuples), and scoring of 2D arrays (one tuple) should return an error (like scikit-learn's error when scoring 1D arrays) """ - d, n = 100, 100 X, _, mixin = identity_fit(d=d, n=n, n_pairs=None, random=True) tuples = np.array(list(product(X, X))) assert mixin.pair_similarity(tuples).shape == (tuples.shape[0],) @@ -155,23 +163,26 @@ def test_pair_similarity_dim(): assert str(raised_error.value) == msg -def test_check_scikitlearn_compatibility(): +@pytest.mark.parametrize('d', [10, 300]) +@pytest.mark.parametrize('n', [10, 100]) +def test_check_scikitlearn_compatibility(d, n): """ Check that the similarity returned by get_metric() is compatible with scikit-learn's algorithms using a custom metric, DBSCAN for instance """ - d, n = 100, 100 X, _, mixin = identity_fit(d=d, n=n, n_pairs=None, random=True) clustering = DBSCAN(metric=mixin.get_metric()) clustering.fit(X) -def test_check_score_pairs_deprecation_and_output(): +@pytest.mark.parametrize('d', [10, 300]) +@pytest.mark.parametrize('n', [10, 100]) +@pytest.mark.parametrize('n_pairs', [100, 1000]) +def test_check_score_pairs_deprecation_and_output(d, n, n_pairs): """ Check that calling score_pairs shows a warning of deprecation, and also that the output of score_pairs matches calling pair_similarity. """ - d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) dpr_msg = ("score_pairs will be deprecated in release 0.6.4. " "Use pair_similarity to compute similarities, or " @@ -183,12 +194,14 @@ def test_check_score_pairs_deprecation_and_output(): assert any(str(w.message) == dpr_msg for w in raised_warnings) -def test_check_error_with_pair_distance(): +@pytest.mark.parametrize('d', [10, 300]) +@pytest.mark.parametrize('n', [10, 100]) +@pytest.mark.parametrize('n_pairs', [100, 1000]) +def test_check_error_with_pair_distance(d, n, n_pairs): """ Check that calling pair_distance is not possible with a Bilinear learner. An Exception must be shown instead. """ - d, n, n_pairs = 100, 100, 1000 _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) msg = ("Bilinear similarity learners don't learn a distance, thus ", "this method is not implemented. Use pair_similarity to " From 27466686bf72b64c4276efff99dc4bec25557aac Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 15 Oct 2021 14:14:49 +0200 Subject: [PATCH 35/57] Add bilinear in introduction --- doc/introduction.rst | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/doc/introduction.rst b/doc/introduction.rst index 6faf021d..507b22e3 100644 --- a/doc/introduction.rst +++ b/doc/introduction.rst @@ -45,7 +45,7 @@ measuring the agreement with the training data. Mahalanobis Distances ===================== -In the metric-learn package, all algorithms currently implemented learn +In the metric-learn package, most algorithms currently implemented learn so-called Mahalanobis distances. Given a real-valued parameter matrix :math:`L` of shape ``(num_dims, n_features)`` where ``n_features`` is the number features describing the data, the Mahalanobis distance associated with @@ -79,6 +79,34 @@ necessarily the identity of indiscernibles. parameterizations are equivalent. In practice, an algorithm may thus solve the metric learning problem with respect to either :math:`M` or :math:`L`. +.. _bilinear_similarity: + +Bilinear Similarity +=================== + +Some algorithms in the package don't learn a distance or pseudo-distance, but +a similarity. The idea is that two pairs are closer if their similarity value +is high, and viceversa. Given a real-valued parameter matrix :math:`W` of shape +``(n_features, n_features)`` where ``n_features`` is the number features +describing the data, the Bilinear Similarity associated with :math:`W` is +defined as follows: + +.. math:: S_W(x, x') = x^T W x' + +The matrix :math:`W` is not required to be positive semi-definite (PSD), so +none of the distance properties are satisfied: nonnegativity, identity of +indiscernibles, symmetry and triangle inequality. + +This allows some algorithms to optimize :math:`S_W` in an online manner using a +simple and efficient procedure, and thus can be applied to problems with +millions of training instances and achieves state-of-the-art performance +on an image search task using :math:`k`-NN. + +It also allows to be applied in contexts where the triangle inequality is +violated by visual judgements and the goal is to approximate perceptual +similarity. For intance, a man and a horse are both similar to a centaur, +but not to one another. + .. _use_cases: Use-cases From 920e50428d9a616508b649b4cca9d8cb613113a4 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 15 Oct 2021 14:31:34 +0200 Subject: [PATCH 36/57] Minor comment on use case --- doc/introduction.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/introduction.rst b/doc/introduction.rst index 507b22e3..03054fa7 100644 --- a/doc/introduction.rst +++ b/doc/introduction.rst @@ -131,6 +131,10 @@ examples (for code illustrating some of these use-cases, see the the data into a new embedding space before feeding it into another machine learning algorithm. +The most common use-case of metric-learn would be to learn a Mahalanobis metric, +then transform the data to the learned space, and then resolve one of the task +above. + The API of metric-learn is compatible with `scikit-learn `_, the leading library for machine learning in Python. This allows to easily pipeline metric learners with other From 7a243198a25bd3e5419a5d0426a5b4d13a0a4d3f Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 15 Oct 2021 15:10:35 +0200 Subject: [PATCH 37/57] More changes to sueprvised --- doc/supervised.rst | 72 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/doc/supervised.rst b/doc/supervised.rst index ea5f5d29..494cd9f7 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -41,11 +41,17 @@ two numbers. Fit, transform, and so on ------------------------- -The goal of supervised metric-learning algorithms is to transform +Generally, the goal of supervised metric-learning algorithms is to transform points in a new space, in which the distance between two points from the same class will be small, and the distance between two points from different -classes will be large. To do so, we fit the metric learner (example: -`NCA`). +classes will be large. + +But there are also some algorithms that learn a similarity, not a distance, +thus the points cannot be transformed into a new space. In this case, the +utility comes at using the similarity bewtween points directly. + +Mahalanobis learners can transform points into a new space, and to do so, +we fit the metric learner (example:`NCA`). >>> from metric_learn import NCA >>> nca = NCA(random_state=42) @@ -57,16 +63,22 @@ NCA(init='auto', max_iter=100, n_components=None, Now that the estimator is fitted, you can use it on new data for several purposes. -First, you can transform the data in the learned space, using `transform`: -Here we transform two points in the new embedding space. +First, your mahalanobis learner can transform the data in +the learned space, using `transform`: Here we transform two points in +the new embedding space. >>> X_new = np.array([[9.4, 4.1], [2.1, 4.4]]) >>> nca.transform(X_new) array([[ 5.91884732, 10.25406973], [ 3.1545886 , 6.80350083]]) -Also, as explained before, our metric learners has learn a distance between -points. You can use this distance in two main ways: +.. warning:: + + If you try to use `transform` with a similarity learner, an error will + appear, as you cannot transform the data using them. + +Also, as explained before, mahalanobis metric learners learn a distance +between points. You can use this distance in two main ways: - You can either return the distance between pairs of points using the `pair_distance` function: @@ -82,18 +94,38 @@ array([0.49627072, 3.65287282, 6.06079877]) >>> metric_fun([3.5, 3.6], [5.6, 2.4]) 0.4962707194621285 -- Alternatively, you can use `pair_similarity` to return the **score** between - points, the more the **score**, the closer the pairs and vice-versa. For - Mahalanobis learners, it is equal to the inverse of the distance. +For similarity learners `pair_distance` is not available, as they don't learn +a distance. Intead you use `pair_similarity` that has the same behaviour but +for similarity. + +>>> algorithm.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +array([-0.2312, 705.23, -72.8]) + +.. warning:: + + If you try to use `pair_distance` with a similarity learner, an error + will appear, as they don't learn a distance nor a pseudo-distance. + +You can also call `get_metric` with similarity learners, and you will get +a function that will return the similarity bewtween 1D arrays. + +>>> similarity_fun = algorithm.get_metric() +>>> similarity_fun([3.5, 3.6], [5.6, 2.4]) +-0.04752 + +For similarity learners and mahalanobis learners, `pair_similarity` is +available. You can interpret that this function returns the **score** +between points: the more the **score**, the closer the pairs and vice-versa. +For mahalanobis learners, it is equal to the inverse of the distance. >>> score = nca.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) >>> score array([-0.49627072, -3.65287282, -6.06079877]) - This is useful because `pair_similarity` matches the **score** sematic of - scikit-learn's `Classification matrics `_. - For instance, given a labeled data, you can pass the labels and the - **score** of your data to get the ROC curve. +This is useful because `pair_similarity` matches the **score** sematic of +scikit-learn's `Classification matrics `_. +For instance, given a labeled data, you can pass the labels and the +**score** of your data to get the ROC curve. >>> from sklearn.metrics import roc_curve >>> fpr, tpr, thresholds = roc_curve(['dog', 'cat', 'dog'], score, pos_label='dog') @@ -108,15 +140,21 @@ array([ 0.50372928, -0.49627072, -3.65287282, -6.06079877]) .. note:: If the metric learner that you use learns a :ref:`Mahalanobis distance - ` (like it is the case for all algorithms - currently in metric-learn), you can get the plain learned Mahalanobis + `, you can get the plain learned Mahalanobis matrix using `get_mahalanobis_matrix`. >>> nca.get_mahalanobis_matrix() array([[0.43680409, 0.89169412], [0.89169412, 1.9542479 ]]) -.. TODO: remove the "like it is the case etc..." if it's not the case anymore + If the metric learner that you use learns a :ref:`Bilinear similarity + `, you can get the plain learned Bilinear + matrix using `get_bilinear_matrix`. + + >>> algorithm.get_bilinear_matrix() + array([[-0.72680409, -0.153213], + [1.45542269, 7.8135546 ]]) + Scikit-learn compatibility -------------------------- From 2f8ee7615e28b2fe24911b3fec68a8aeee5f0012 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Fri, 15 Oct 2021 15:19:40 +0200 Subject: [PATCH 38/57] Changes in weakly Supervised --- doc/supervised.rst | 4 +- doc/weakly_supervised.rst | 87 ++++++++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/doc/supervised.rst b/doc/supervised.rst index 494cd9f7..c8928596 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -59,7 +59,6 @@ we fit the metric learner (example:`NCA`). NCA(init='auto', max_iter=100, n_components=None, preprocessor=None, random_state=42, tol=None, verbose=False) - Now that the estimator is fitted, you can use it on new data for several purposes. @@ -88,7 +87,8 @@ array([0.49627072, 3.65287282, 6.06079877]) - Or you can return a function that will return the distance (in the new space) between two 1D arrays (the coordinates of the points in the original - space), similarly to distance functions in `scipy.spatial.distance`. + space), similarly to distance functions in `scipy.spatial.distance`. To + do that, use the `get_metric` method. >>> metric_fun = nca.get_metric() >>> metric_fun([3.5, 3.6], [5.6, 2.4]) diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index 66a34dff..617c85ae 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -127,9 +127,16 @@ through the argument `preprocessor` (see below :ref:`fit_ws`) Fit, transform, and so on ------------------------- -The goal of weakly-supervised metric-learning algorithms is to transform -points in a new space, in which the tuple-wise constraints between points -are respected. +Generally, the goal of weakly-supervised metric-learning algorithms is to +transform points in a new space, in which the tuple-wise constraints between +points are respected. + +But there are also some algorithms that learn a similarity, not a distance, +thus the points cannot be transformed into a new space. But the goal is the +same: respect the maximum number of constraints while learning the similarity. + +Weakly-supervised mahalanobis learners can transform points into a new space, +and to do so, we fit the metric learner (example:`MMC`). >>> from metric_learn import MMC >>> mmc = MMC(random_state=42) @@ -144,18 +151,23 @@ Or alternatively (using a preprocessor): >>> mmc = MMC(preprocessor=X, random_state=42) >>> mmc.fit(pairs_indice, y) - Now that the estimator is fitted, you can use it on new data for several purposes. -First, you can transform the data in the learned space, using `transform`: -Here we transform two points in the new embedding space. +First, your mahalanobis learner can transform the data in +the learned space, using `transform`: Here we transform two points in +the new embedding space. >>> X_new = np.array([[9.4, 4.1, 4.2], [2.1, 4.4, 2.3]]) >>> mmc.transform(X_new) array([[-3.24667162e+01, 4.62622348e-07, 3.88325421e-08], [-3.61531114e+01, 4.86778289e-07, 2.12654397e-08]]) +.. warning:: + + If you try to use `transform` with a similarity learner, an error will + appear, as you cannot transform the data using them. + Also, as explained before, our metric learner has learned a distance between points. You can use this distance in two main ways: @@ -166,27 +178,47 @@ points. You can use this distance in two main ways: ... [[1.2, 4.2, 7.7], [2.1, 6.4, 0.9]]]) array([7.27607365, 0.88853014]) -- Or you can return a function that will return the distance - (in the new space) between two 1D arrays (the coordinates of the points in - the original space), similarly to distance functions in - `scipy.spatial.distance`. To do that, use the `get_metric` method. +- Or you can return a function that will return the distance (in the new + space) between two 1D arrays (the coordinates of the points in the original + space), similarly to distance functions in `scipy.spatial.distance`. To + do that, use the `get_metric` method. >>> metric_fun = mmc.get_metric() >>> metric_fun([3.5, 3.6, 5.2], [5.6, 2.4, 6.7]) 7.276073646278203 -- Alternatively, you can use `pair_similarity` to return the **score** between - points, the more the **score**, the closer the pairs and vice-versa. For - Mahalanobis learners, it is equal to the inverse of the distance. +For similarity learners `pair_distance` is not available, as they don't learn +a distance. Intead you use `pair_similarity` that has the same behaviour but +for similarity. + +>>> algorithm.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +array([-0.2312, 705.23, -72.8]) + +.. warning:: + + If you try to use `pair_distance` with a similarity learner, an error + will appear, as they don't learn a distance nor a pseudo-distance. + +You can also call `get_metric` with similarity learners, and you will get +a function that will return the similarity bewtween 1D arrays. + +>>> similarity_fun = algorithm.get_metric() +>>> similarity_fun([3.5, 3.6], [5.6, 2.4]) +-0.04752 + +For similarity learners and mahalanobis learners, `pair_similarity` is +available. You can interpret that this function returns the **score** +between points: the more the **score**, the closer the pairs and vice-versa. +For mahalanobis learners, it is equal to the inverse of the distance. >>> score = mmc.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) >>> score array([-0.49627072, -3.65287282, -6.06079877]) - This is useful because `pair_similarity` matches the **score** sematic of - scikit-learn's `Classification matrics `_. - For instance, given a labeled data, you can pass the labels and the - **score** of your data to get the ROC curve. +This is useful because `pair_similarity` matches the **score** sematic of +scikit-learn's `Classification matrics `_. +For instance, given a labeled data, you can pass the labels and the +**score** of your data to get the ROC curve. >>> from sklearn.metrics import roc_curve >>> fpr, tpr, thresholds = roc_curve(['dog', 'cat', 'dog'], score, pos_label='dog') @@ -201,16 +233,21 @@ array([ 0.50372928, -0.49627072, -3.65287282, -6.06079877]) .. note:: If the metric learner that you use learns a :ref:`Mahalanobis distance - ` (like it is the case for all algorithms - currently in metric-learn), you can get the plain Mahalanobis matrix using - `get_mahalanobis_matrix`. + `, you can get the plain learned Mahalanobis + matrix using `get_mahalanobis_matrix`. + + >>> mmc.get_mahalanobis_matrix() + array([[ 0.58603894, -5.69883982, -1.66614919], + [-5.69883982, 55.41743549, 16.20219519], + [-1.66614919, 16.20219519, 4.73697721]]) ->>> mmc.get_mahalanobis_matrix() -array([[ 0.58603894, -5.69883982, -1.66614919], - [-5.69883982, 55.41743549, 16.20219519], - [-1.66614919, 16.20219519, 4.73697721]]) + If the metric learner that you use learns a :ref:`Bilinear similarity + `, you can get the plain learned Bilinear + matrix using `get_bilinear_matrix`. -.. TODO: remove the "like it is the case etc..." if it's not the case anymore + >>> algorithm.get_bilinear_matrix() + array([[-0.72680409, -0.153213], + [1.45542269, 7.8135546 ]]) .. _sklearn_compat_ws: From 8c559705a735f2160d630b4ad4062fce27cac9ad Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Tue, 19 Oct 2021 11:59:53 +0200 Subject: [PATCH 39/57] Fixed changes requested 1 --- doc/introduction.rst | 23 ----------------------- doc/supervised.rst | 25 +++++++------------------ doc/weakly_supervised.rst | 30 +++++++++--------------------- metric_learn/base_metric.py | 20 ++++++++++---------- test/test_base_metric.py | 2 +- 5 files changed, 27 insertions(+), 73 deletions(-) diff --git a/doc/introduction.rst b/doc/introduction.rst index 6faf021d..e9ff0015 100644 --- a/doc/introduction.rst +++ b/doc/introduction.rst @@ -123,26 +123,3 @@ to the following resources: Survey `_ (2012) - **Book:** `Metric Learning `_ (2015) - -.. Methods [TO MOVE TO SUPERVISED/WEAK SECTIONS] -.. ============================================= - -.. Currently, each metric learning algorithm supports the following methods: - -.. - ``fit(...)``, which learns the model. -.. - ``get_mahalanobis_matrix()``, which returns a Mahalanobis matrix -.. - ``get_metric()``, which returns a function that takes as input two 1D - arrays and outputs the learned metric score on these two points -.. :math:`M = L^{\top}L` such that distance between vectors ``x`` and -.. ``y`` can be computed as :math:`\sqrt{\left(x-y\right)M\left(x-y\right)}`. -.. - ``components_from_metric(metric)``, which returns a transformation matrix -.. :math:`L \in \mathbb{R}^{D \times d}`, which can be used to convert a -.. data matrix :math:`X \in \mathbb{R}^{n \times d}` to the -.. :math:`D`-dimensional learned metric space :math:`X L^{\top}`, -.. in which standard Euclidean distances may be used. -.. - ``transform(X)``, which applies the aforementioned transformation. -.. - ``pair_distance(pairs)`` which returns the distance between pairs of -.. points. ``pairs`` should be a 3D array-like of pairs of shape ``(n_pairs, -.. 2, n_features)``, or it can be a 2D array-like of pairs indicators of -.. shape ``(n_pairs, 2)`` (see section :ref:`preprocessor_section` for more -.. details). \ No newline at end of file diff --git a/doc/supervised.rst b/doc/supervised.rst index ea5f5d29..6d3070dc 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -83,27 +83,17 @@ array([0.49627072, 3.65287282, 6.06079877]) 0.4962707194621285 - Alternatively, you can use `pair_similarity` to return the **score** between - points, the more the **score**, the closer the pairs and vice-versa. For - Mahalanobis learners, it is equal to the inverse of the distance. + pairs of points, the larger the **score**, the more similar the pair + and vice-versa. For Mahalanobis learners, it is equal to the opposite + of the distance. >>> score = nca.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) >>> score array([-0.49627072, -3.65287282, -6.06079877]) - This is useful because `pair_similarity` matches the **score** sematic of - scikit-learn's `Classification matrics `_. - For instance, given a labeled data, you can pass the labels and the - **score** of your data to get the ROC curve. - ->>> from sklearn.metrics import roc_curve ->>> fpr, tpr, thresholds = roc_curve(['dog', 'cat', 'dog'], score, pos_label='dog') ->>> fpr -array([0., 0., 1., 1.]) ->>> tpr -array([0. , 0.5, 0.5, 1. ]) ->>> ->>> thresholds -array([ 0.50372928, -0.49627072, -3.65287282, -6.06079877]) + This is useful because `pair_similarity` matches the **score** semantic of + scikit-learn's `Classification metrics + `_. .. note:: @@ -116,7 +106,6 @@ array([ 0.50372928, -0.49627072, -3.65287282, -6.06079877]) array([[0.43680409, 0.89169412], [0.89169412, 1.9542479 ]]) -.. TODO: remove the "like it is the case etc..." if it's not the case anymore Scikit-learn compatibility -------------------------- @@ -128,7 +117,7 @@ All supervised algorithms are scikit-learn estimators scikit-learn model selection routines (`sklearn.model_selection.cross_val_score`, `sklearn.model_selection.GridSearchCV`, etc). -You can also use methods from `sklearn.metrics` that rely on y_scores. +You can also use some of the scoring functions from `sklearn.metrics`. Algorithms ========== diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index 66a34dff..f7db61ed 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -176,27 +176,17 @@ array([7.27607365, 0.88853014]) 7.276073646278203 - Alternatively, you can use `pair_similarity` to return the **score** between - points, the more the **score**, the closer the pairs and vice-versa. For - Mahalanobis learners, it is equal to the inverse of the distance. + pairs of points, the larger the **score**, the more similar the pair + and vice-versa. For Mahalanobis learners, it is equal to the opposite + of the distance. >>> score = mmc.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) >>> score array([-0.49627072, -3.65287282, -6.06079877]) - This is useful because `pair_similarity` matches the **score** sematic of - scikit-learn's `Classification matrics `_. - For instance, given a labeled data, you can pass the labels and the - **score** of your data to get the ROC curve. - ->>> from sklearn.metrics import roc_curve ->>> fpr, tpr, thresholds = roc_curve(['dog', 'cat', 'dog'], score, pos_label='dog') ->>> fpr -array([0., 0., 1., 1.]) ->>> tpr -array([0. , 0.5, 0.5, 1. ]) ->>> ->>> thresholds -array([ 0.50372928, -0.49627072, -3.65287282, -6.06079877]) + This is useful because `pair_similarity` matches the **score** semantic of + scikit-learn's `Classification metrics + `_. .. note:: @@ -210,8 +200,6 @@ array([[ 0.58603894, -5.69883982, -1.66614919], [-5.69883982, 55.41743549, 16.20219519], [-1.66614919, 16.20219519, 4.73697721]]) -.. TODO: remove the "like it is the case etc..." if it's not the case anymore - .. _sklearn_compat_ws: Prediction and scoring @@ -368,7 +356,7 @@ returns the `sklearn.metrics.roc_auc_score` (which is threshold-independent). .. note:: See :ref:`fit_ws` for more details on metric learners functions that are not specific to learning on pairs, like `transform`, `pair_distance`, - `get_metric` and `get_mahalanobis_matrix`. + `pair_similarity`, `get_metric` and `get_mahalanobis_matrix`. Algorithms ---------- @@ -715,7 +703,7 @@ of triplets that have the right predicted ordering. .. note:: See :ref:`fit_ws` for more details on metric learners functions that are not specific to learning on pairs, like `transform`, `pair_distance`, - `get_metric` and `get_mahalanobis_matrix`. + `pair_similarity`, `get_metric` and `get_mahalanobis_matrix`. @@ -883,7 +871,7 @@ of quadruplets have the right predicted ordering. .. note:: See :ref:`fit_ws` for more details on metric learners functions that are not specific to learning on pairs, like `transform`, `pair_distance`, - `get_metric` and `get_mahalanobis_matrix`. + `pair_similarity`, `get_metric` and `get_mahalanobis_matrix`. diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 99defc97..10e873ca 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -29,12 +29,12 @@ def __init__(self, preprocessor=None): @abstractmethod def score_pairs(self, pairs): """ - .. deprecated:: 0.6.3 Refer to `pair_distance` and `pair_similarity`. + .. deprecated:: 0.7.0 Refer to `pair_distance` and `pair_similarity`. .. warning:: - This method will be deleted in 0.6.4. Please refer to `pair_distance` + This method will be removed in 0.8.0. Please refer to `pair_distance` or `pair_similarity`. This change will occur in order to add learners - that don't necessarly learn a Mahalanobis distance. + that don't necessarily learn a Mahalanobis distance. Returns the score between pairs (can be a similarity, or a distance/metric depending on the algorithm) @@ -61,7 +61,7 @@ def score_pairs(self, pairs): @abstractmethod def pair_similarity(self, pairs): """ - .. versionadded:: 0.6.3 Compute the similarity score bewteen pairs + .. versionadded:: 0.7.0 Compute the similarity score between pairs Returns the similarity score between pairs. Depending on the algorithm, this method can return the learned similarity score between pairs, @@ -91,10 +91,10 @@ def pair_similarity(self, pairs): @abstractmethod def pair_distance(self, pairs): """ - .. versionadded:: 0.6.3 Compute the distance score between pairs + .. versionadded:: 0.7.0 Compute the distance score between pairs Returns the distance score between pairs. For Mahalanobis learners, it - returns the pseudo-distance bewtween pairs. It is not available for + returns the pseudo-distance between pairs. It is not available for learners that does not learn a distance or pseudo-distance, an error will be shown instead. @@ -260,13 +260,13 @@ class MahalanobisMixin(BaseMetricLearner, MetricTransformer, def score_pairs(self, pairs): r""" - .. deprecated:: 0.6.3 + .. deprecated:: 0.7.0 This method is deprecated. Please use `pair_distance` instead. .. warning:: - This method will be deleted in 0.6.4. Please refer to `pair_distance` + This method will be removed in 0.8.0. Please refer to `pair_distance` or `pair_similarity`. This change will occur in order to add learners - that don't necessarly learn a Mahalanobis distance. + that don't necessarily learn a Mahalanobis distance. Returns the learned Mahalanobis distance between pairs. @@ -301,7 +301,7 @@ def score_pairs(self, pairs): :ref:`mahalanobis_distances` : The section of the project documentation that describes Mahalanobis Distances. """ - dpr_msg = ("score_pairs will be deprecated in release 0.6.3. " + dpr_msg = ("score_pairs will be deprecated in release 0.7.0. " "Use pair_similarity to compute similarities, or " "pair_distances to compute distances.") warnings.warn(dpr_msg, category=FutureWarning) diff --git a/test/test_base_metric.py b/test/test_base_metric.py index e96afe23..bcdb4320 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -288,7 +288,7 @@ def test_score_pairs_warning(estimator, build_dataset): # points model.fit(*remove_y(model, input_data, labels)) - msg = ("score_pairs will be deprecated in release 0.6.3. " + msg = ("score_pairs will be deprecated in release 0.7.0. " "Use pair_similarity to compute similarities, or " "pair_distances to compute distances.") with pytest.warns(FutureWarning) as raised_warning: From 787a8d16df04e05f176da61d1ad6d668872d7300 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Tue, 19 Oct 2021 15:47:29 +0200 Subject: [PATCH 40/57] Fixed changes requested 2 --- doc/supervised.rst | 10 ++--- doc/weakly_supervised.rst | 12 ++--- metric_learn/base_metric.py | 81 ++++++++++++++++++---------------- test/test_base_metric.py | 4 +- test/test_pairs_classifiers.py | 20 +++++---- test/test_sklearn_compat.py | 11 ++++- 6 files changed, 77 insertions(+), 61 deletions(-) diff --git a/doc/supervised.rst b/doc/supervised.rst index 6d3070dc..28fdf02b 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -82,18 +82,18 @@ array([0.49627072, 3.65287282, 6.06079877]) >>> metric_fun([3.5, 3.6], [5.6, 2.4]) 0.4962707194621285 -- Alternatively, you can use `pair_similarity` to return the **score** between +- Alternatively, you can use `pair_score` to return the **score** between pairs of points, the larger the **score**, the more similar the pair and vice-versa. For Mahalanobis learners, it is equal to the opposite of the distance. ->>> score = nca.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +>>> score = nca.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) >>> score array([-0.49627072, -3.65287282, -6.06079877]) - This is useful because `pair_similarity` matches the **score** semantic of - scikit-learn's `Classification metrics - `_. +This is useful because `pair_score` matches the **score** semantic of +scikit-learn's `Classification metrics +`_. .. note:: diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index f7db61ed..13346c22 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -175,16 +175,16 @@ array([7.27607365, 0.88853014]) >>> metric_fun([3.5, 3.6, 5.2], [5.6, 2.4, 6.7]) 7.276073646278203 -- Alternatively, you can use `pair_similarity` to return the **score** between +- Alternatively, you can use `pair_score` to return the **score** between pairs of points, the larger the **score**, the more similar the pair and vice-versa. For Mahalanobis learners, it is equal to the opposite of the distance. ->>> score = mmc.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +>>> score = mmc.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) >>> score array([-0.49627072, -3.65287282, -6.06079877]) - This is useful because `pair_similarity` matches the **score** semantic of + This is useful because `pair_score` matches the **score** semantic of scikit-learn's `Classification metrics `_. @@ -356,7 +356,7 @@ returns the `sklearn.metrics.roc_auc_score` (which is threshold-independent). .. note:: See :ref:`fit_ws` for more details on metric learners functions that are not specific to learning on pairs, like `transform`, `pair_distance`, - `pair_similarity`, `get_metric` and `get_mahalanobis_matrix`. + `pair_score`, `get_metric` and `get_mahalanobis_matrix`. Algorithms ---------- @@ -703,7 +703,7 @@ of triplets that have the right predicted ordering. .. note:: See :ref:`fit_ws` for more details on metric learners functions that are not specific to learning on pairs, like `transform`, `pair_distance`, - `pair_similarity`, `get_metric` and `get_mahalanobis_matrix`. + `pair_score`, `get_metric` and `get_mahalanobis_matrix`. @@ -871,7 +871,7 @@ of quadruplets have the right predicted ordering. .. note:: See :ref:`fit_ws` for more details on metric learners functions that are not specific to learning on pairs, like `transform`, `pair_distance`, - `pair_similarity`, `get_metric` and `get_mahalanobis_matrix`. + `pair_score`, `get_metric` and `get_mahalanobis_matrix`. diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 10e873ca..c04754e3 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -41,8 +41,10 @@ def score_pairs(self, pairs): Parameters ---------- - pairs : `numpy.ndarray`, shape=(n_samples, 2, n_features) - 3D array of pairs. + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the metric learner uses a + preprocessor. Returns ------- @@ -52,27 +54,29 @@ def score_pairs(self, pairs): See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference with `score_pairs` is that it works on two - 1D arrays and cannot use a preprocessor. Besides, the returned function - is independent of the metric learner and hence is not modified if the - metric learner is. + two points. The difference between `pair_score` and `pair_distance` is + that it works on two 1D arrays and cannot use a preprocessor. Besides, + the returned function is independent of the metric learner and hence is + not modified if the metric learner is. """ @abstractmethod - def pair_similarity(self, pairs): + def pair_score(self, pairs): """ .. versionadded:: 0.7.0 Compute the similarity score between pairs - Returns the similarity score between pairs. Depending on the algorithm, - this method can return the learned similarity score between pairs, - or the inverse of the distance learned between two pairs. The more the - score, the more similar the pairs. All learners have access to this + Returns the similarity score between pairs of points. Depending on the + algorithm, this method can return the learned similarity score between + pairs, or the opposite of the distance learned between pairs. The larger + the score, the more similar the pair. All learners have access to this method. Parameters ---------- - pairs : `numpy.ndarray`, shape=(n_samples, 2, n_features) - 3D array of pairs. + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the metric learner uses a + preprocessor. Returns ------- @@ -82,7 +86,7 @@ def pair_similarity(self, pairs): See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference with `pair_similarity` is that it works on two + two points. The difference with `pair_score` is that it works on two 1D arrays and cannot use a preprocessor. Besides, the returned function is independent of the metric learner and hence is not modified if the metric learner is. @@ -91,17 +95,18 @@ def pair_similarity(self, pairs): @abstractmethod def pair_distance(self, pairs): """ - .. versionadded:: 0.7.0 Compute the distance score between pairs + .. versionadded:: 0.7.0 Compute the distance between pairs - Returns the distance score between pairs. For Mahalanobis learners, it - returns the pseudo-distance between pairs. It is not available for - learners that does not learn a distance or pseudo-distance, an error - will be shown instead. + Returns the (pseudo) distance between pairs, when available. For metric + learners that do not learn a (pseudo) distance, an error is thrown + instead. Parameters ---------- - pairs : `numpy.ndarray`, shape=(n_samples, 2, n_features) - 3D array of pairs. + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the metric learner uses a + preprocessor. Returns ------- @@ -170,10 +175,10 @@ def _prepare_inputs(self, X, y=None, type_of_inputs='classic', @abstractmethod def get_metric(self): - """Returns a function that takes as input two 1D arrays and outputs the - learned metric score on these two points. Depending on the algorithm, it - can return the distance or similarity function between pairs. It always - returns what the specific algorithm learns. + """Returns a function that takes as input two 1D arrays and outputs + the value of the learned metric on these two points. Depending on the + algorithm, it can return a distance or a score function between pairs. + It always returns what the specific algorithm learns. This function will be independent from the metric learner that learned it (it will not be modified if the initial metric learner is modified), @@ -206,13 +211,13 @@ def get_metric(self): See Also -------- - pair_distance : a method that returns the distance score between several + pair_distance : a method that returns the distance between several pairs of points. Unlike `get_metric`, this is a method of the metric learner and therefore can change if the metric learner changes. Besides, it can use the metric learner's preprocessor, and works on concatenated arrays. - pair_similarity : a method that returns the similarity score between + pair_score : a method that returns the similarity score between several pairs of points. Unlike `get_metric`, this is a method of the metric learner and therefore can change if the metric learner changes. Besides, it can use the metric learner's preprocessor, and works on @@ -265,7 +270,7 @@ def score_pairs(self, pairs): .. warning:: This method will be removed in 0.8.0. Please refer to `pair_distance` - or `pair_similarity`. This change will occur in order to add learners + or `pair_score`. This change will occur in order to add learners that don't necessarily learn a Mahalanobis distance. Returns the learned Mahalanobis distance between pairs. @@ -302,14 +307,14 @@ def score_pairs(self, pairs): that describes Mahalanobis Distances. """ dpr_msg = ("score_pairs will be deprecated in release 0.7.0. " - "Use pair_similarity to compute similarities, or " + "Use pair_score to compute similarity scores, or " "pair_distances to compute distances.") warnings.warn(dpr_msg, category=FutureWarning) return self.pair_distance(pairs) - def pair_similarity(self, pairs): + def pair_score(self, pairs): """ - Returns the inverse of the learned Mahalanobis distance between pairs. + Returns the opposite of the learned Mahalanobis distance between pairs. Parameters ---------- @@ -321,12 +326,12 @@ def pair_similarity(self, pairs): Returns ------- scores : `numpy.ndarray` of shape=(n_pairs,) - The inverse of the learned Mahalanobis distance for every pair. + The opposite of the learned Mahalanobis distance for every pair. See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference with `pair_similarity` is that it works on two + two points. The difference with `pair_score` is that it works on two 1D arrays and cannot use a preprocessor. Besides, the returned function is independent of the metric learner and hence is not modified if the metric learner is. @@ -517,7 +522,7 @@ def decision_function(self, pairs): pairs = check_input(pairs, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return self.pair_similarity(pairs) + return self.pair_score(pairs) def score(self, pairs, y): """Computes score of pairs similarity prediction. @@ -787,8 +792,8 @@ def decision_function(self, triplets): triplets = check_input(triplets, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return (self.pair_similarity(triplets[:, :2]) - - self.pair_similarity(triplets[:, [0, 2]])) + return (self.pair_score(triplets[:, :2]) - + self.pair_score(triplets[:, [0, 2]])) def score(self, triplets): """Computes score on input triplets. @@ -872,8 +877,8 @@ def decision_function(self, quadruplets): quadruplets = check_input(quadruplets, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return (self.pair_similarity(quadruplets[:, :2]) - - self.pair_similarity(quadruplets[:, 2:])) + return (self.pair_score(quadruplets[:, :2]) - + self.pair_score(quadruplets[:, 2:])) def score(self, quadruplets): """Computes score on input quadruplets diff --git a/test/test_base_metric.py b/test/test_base_metric.py index bcdb4320..5a409fe1 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -284,12 +284,12 @@ def test_score_pairs_warning(estimator, build_dataset): model = clone(estimator) set_random_state(model) - # we fit the metric learner on it and then we call score_apirs on some + # We fit the metric learner on it and then we call score_pairs on some # points model.fit(*remove_y(model, input_data, labels)) msg = ("score_pairs will be deprecated in release 0.7.0. " - "Use pair_similarity to compute similarities, or " + "Use pair_score to compute similarity scores, or " "pair_distances to compute distances.") with pytest.warns(FutureWarning) as raised_warning: score = model.score_pairs([[X[0], X[1]], ]) diff --git a/test/test_pairs_classifiers.py b/test/test_pairs_classifiers.py index 7023fbea..4df3b2f1 100644 --- a/test/test_pairs_classifiers.py +++ b/test/test_pairs_classifiers.py @@ -49,14 +49,14 @@ def test_predict_monotonous(estimator, build_dataset, pairs_train, pairs_test, y_train, y_test = train_test_split(input_data, labels) estimator.fit(pairs_train, y_train) - distances = estimator.pair_distance(pairs_test) + scores = estimator.pair_score(pairs_test) predictions = estimator.predict(pairs_test) - min_dissimilar = np.min(distances[predictions == -1]) - max_similar = np.max(distances[predictions == 1]) - assert max_similar <= min_dissimilar - separator = np.mean([min_dissimilar, max_similar]) - assert (predictions[distances > separator] == -1).all() - assert (predictions[distances < separator] == 1).all() + max_dissimilar = np.max(scores[predictions == -1]) + min_similar = np.min(scores[predictions == 1]) + assert max_dissimilar <= min_similar + separator = np.mean([max_dissimilar, min_similar]) + assert (predictions[scores < separator] == -1).all() + assert (predictions[scores > separator] == 1).all() @pytest.mark.parametrize('with_preprocessor', [True, False]) @@ -65,15 +65,17 @@ def test_predict_monotonous(estimator, build_dataset, def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, with_preprocessor): """Test that a NotFittedError is raised if someone tries to use - pair_distance, decision_function, get_metric, transform or + pair_score, score_pairs, decision_function, get_metric, transform or get_mahalanobis_matrix on input data and the metric learner has not been fitted.""" input_data, labels, preprocessor, _ = build_dataset(with_preprocessor) estimator = clone(estimator) estimator.set_params(preprocessor=preprocessor) set_random_state(estimator) + with pytest.raises(NotFittedError): # Remove in 0.8.0 + estimator.score_pairs(input_data) with pytest.raises(NotFittedError): - estimator.pair_distance(input_data) + estimator.pair_score(input_data) with pytest.raises(NotFittedError): estimator.decision_function(input_data) with pytest.raises(NotFittedError): diff --git a/test/test_sklearn_compat.py b/test/test_sklearn_compat.py index d6077a16..9ad1482f 100644 --- a/test/test_sklearn_compat.py +++ b/test/test_sklearn_compat.py @@ -147,8 +147,17 @@ def test_array_like_inputs(estimator, build_dataset, with_preprocessor): pairs = np.array([[X[0], X[1]], [X[0], X[2]]]) pairs_variants, _ = generate_array_like(pairs) + msg = "" + # Todo in 0.7.0: Change 'msg' for the message that says "This learner does + # not have pair_distance" for pairs_variant in pairs_variants: - estimator.pair_distance(pairs_variant) + estimator.pair_score(pairs_variant) # All learners have pair_score + # But all of them will have pair_distance + with pytest.raises(Exception) as raised_exception: + estimator.pair_distance(pairs_variant) + if raised_exception is not None: + assert msg == raised_exception.value.args[0] + @pytest.mark.parametrize('with_preprocessor', [True, False]) From e14f956888f0b2b952450b1e2d076daa403fc8b2 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Tue, 19 Oct 2021 16:03:25 +0200 Subject: [PATCH 41/57] Add equivalence test, p_dist == p_score --- test/test_mahalanobis_mixin.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index 2ba78a57..5b1ebe2e 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -3,7 +3,7 @@ import pytest import numpy as np from numpy.linalg import LinAlgError -from numpy.testing import assert_array_almost_equal, assert_allclose +from numpy.testing import assert_array_almost_equal, assert_allclose, assert_array_equal from scipy.spatial.distance import pdist, squareform, mahalanobis from scipy.stats import ortho_group from sklearn import clone @@ -24,6 +24,24 @@ RNG = check_random_state(0) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners, + ids=ids_metric_learners) +def test_pair_distance_pair_score_equivalent(estimator, build_dataset): + """ + For Mahalanobis learners, pair_score should be equivalent to the + opposite of the pair_distance result. + """ + input_data, labels, _, X = build_dataset() + n_samples = 20 + X = X[:n_samples] + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(estimator, input_data, labels)) + + distances = model.pair_distance(np.array(list(product(X, X)))) + scores_1 = -1 * model.pair_score(np.array(list(product(X, X)))) + + assert_array_equal(distances, scores_1) @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) From 0941a320f6a71cac0fa8223ac753ce57ada69604 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Tue, 19 Oct 2021 16:36:40 +0200 Subject: [PATCH 42/57] Fix tests and identation. --- test/test_mahalanobis_mixin.py | 7 ++- test/test_pairs_classifiers.py | 2 +- test/test_sklearn_compat.py | 19 ++++---- test/test_utils.py | 79 +++++++++++++++++++++++++--------- 4 files changed, 75 insertions(+), 32 deletions(-) diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index 5b1ebe2e..f52f4592 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -3,7 +3,8 @@ import pytest import numpy as np from numpy.linalg import LinAlgError -from numpy.testing import assert_array_almost_equal, assert_allclose, assert_array_equal +from numpy.testing import assert_array_almost_equal, assert_allclose, \ + assert_array_equal from scipy.spatial.distance import pdist, squareform, mahalanobis from scipy.stats import ortho_group from sklearn import clone @@ -24,6 +25,7 @@ RNG = check_random_state(0) + @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_pair_distance_pair_score_equivalent(estimator, build_dataset): @@ -40,9 +42,10 @@ def test_pair_distance_pair_score_equivalent(estimator, build_dataset): distances = model.pair_distance(np.array(list(product(X, X)))) scores_1 = -1 * model.pair_score(np.array(list(product(X, X)))) - + assert_array_equal(distances, scores_1) + @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_pair_distance_pairwise(estimator, build_dataset): diff --git a/test/test_pairs_classifiers.py b/test/test_pairs_classifiers.py index 4df3b2f1..714cbd08 100644 --- a/test/test_pairs_classifiers.py +++ b/test/test_pairs_classifiers.py @@ -72,7 +72,7 @@ def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, estimator = clone(estimator) estimator.set_params(preprocessor=preprocessor) set_random_state(estimator) - with pytest.raises(NotFittedError): # Remove in 0.8.0 + with pytest.raises(NotFittedError): # Remove in 0.8.0 estimator.score_pairs(input_data) with pytest.raises(NotFittedError): estimator.pair_score(input_data) diff --git a/test/test_sklearn_compat.py b/test/test_sklearn_compat.py index 9ad1482f..738ddc86 100644 --- a/test/test_sklearn_compat.py +++ b/test/test_sklearn_compat.py @@ -147,17 +147,20 @@ def test_array_like_inputs(estimator, build_dataset, with_preprocessor): pairs = np.array([[X[0], X[1]], [X[0], X[2]]]) pairs_variants, _ = generate_array_like(pairs) - msg = "" - # Todo in 0.7.0: Change 'msg' for the message that says "This learner does - # not have pair_distance" + + not_implemented_msg = "" + # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says + # "This learner does not have pair_distance" + for pairs_variant in pairs_variants: - estimator.pair_score(pairs_variant) # All learners have pair_score - # But all of them will have pair_distance - with pytest.raises(Exception) as raised_exception: + estimator.pair_score(pairs_variant) # All learners have pair_score + + # But not all of them will have pair_distance + try: estimator.pair_distance(pairs_variant) - if raised_exception is not None: - assert msg == raised_exception.value.args[0] + except Exception as raised_exception: + assert raised_exception.value.args[0] == not_implemented_msg @pytest.mark.parametrize('with_preprocessor', [True, False]) diff --git a/test/test_utils.py b/test/test_utils.py index 39515f78..83bdd86a 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -834,9 +834,9 @@ def test_error_message_tuple_size(estimator, _): @pytest.mark.parametrize('estimator, _', metric_learners, ids=ids_metric_learners) -def test_error_message_t_pair_distance(estimator, _): - """tests that if you want to pair_distance on triplets for instance, it returns - the right error message +def test_error_message_t_pair_distance_or_score(estimator, _): + """Tests that if you want to pair_distance or pair_score on triplets + for instance, it returns the right error message """ estimator = clone(estimator) set_random_state(estimator) @@ -844,12 +844,22 @@ def test_error_message_t_pair_distance(estimator, _): triplets = np.array([[[1.3, 6.3], [3., 6.8], [6.5, 4.4]], [[1.9, 5.3], [1., 7.8], [3.2, 1.2]]]) with pytest.raises(ValueError) as raised_err: - estimator.pair_distance(triplets) + estimator.pair_score(triplets) expected_msg = ("Tuples of 2 element(s) expected{}. Got tuples of 3 " "element(s) instead (shape=(2, 3, 2)):\ninput={}.\n" .format(make_context(estimator), triplets)) assert str(raised_err.value) == expected_msg + not_implemented_msg = "" + # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says + # "This learner does not have pair_distance" + + # One exception will trigger for sure + with pytest.raises(Exception) as raised_exception: + estimator.pair_distance(triplets) + err_value = raised_exception.value.args[0] + assert err_value == expected_msg or err_value == not_implemented_msg + def test_preprocess_tuples_simple_example(): """Test the preprocessor on a very simple example of tuples to ensure the @@ -930,32 +940,59 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): method)(formed_test) assert np.array(output_with_prep == output_with_prep_formed).all() - # test pair_distance + # Test pair_score, all learners have it. idx1 = np.array([[0, 2], [5, 3]], dtype=int) - output_with_prep = estimator_with_preprocessor.pair_distance( + output_with_prep = estimator_with_preprocessor.pair_score( indicators_to_transform[idx1]) - output_without_prep = estimator_without_preprocessor.pair_distance( + output_without_prep = estimator_without_preprocessor.pair_score( formed_points_to_transform[idx1]) assert np.array(output_with_prep == output_without_prep).all() - output_with_prep = estimator_with_preprocessor.pair_distance( + output_with_prep = estimator_with_preprocessor.pair_score( indicators_to_transform[idx1]) - output_without_prep = estimator_with_prep_formed.pair_distance( + output_without_prep = estimator_with_prep_formed.pair_score( formed_points_to_transform[idx1]) assert np.array(output_with_prep == output_without_prep).all() - # test transform - output_with_prep = estimator_with_preprocessor.transform( - indicators_to_transform) - output_without_prep = estimator_without_preprocessor.transform( - formed_points_to_transform) - assert np.array(output_with_prep == output_without_prep).all() - - output_with_prep = estimator_with_preprocessor.transform( - indicators_to_transform) - output_without_prep = estimator_with_prep_formed.transform( - formed_points_to_transform) - assert np.array(output_with_prep == output_without_prep).all() + # Test pair_distance + not_implemented_msg = "" + # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says + # "This learner does not have pair_distance" + try: + output_with_prep = estimator_with_preprocessor.pair_distance( + indicators_to_transform[idx1]) + output_without_prep = estimator_without_preprocessor.pair_distance( + formed_points_to_transform[idx1]) + assert np.array(output_with_prep == output_without_prep).all() + + output_with_prep = estimator_with_preprocessor.pair_distance( + indicators_to_transform[idx1]) + output_without_prep = estimator_with_prep_formed.pair_distance( + formed_points_to_transform[idx1]) + assert np.array(output_with_prep == output_without_prep).all() + + except Exception as raised_exception: + assert raised_exception.value.args[0] == not_implemented_msg + + # Test transform + not_implemented_msg = "" + # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says + # "This learner does not have transform" + try: + output_with_prep = estimator_with_preprocessor.transform( + indicators_to_transform) + output_without_prep = estimator_without_preprocessor.transform( + formed_points_to_transform) + assert np.array(output_with_prep == output_without_prep).all() + + output_with_prep = estimator_with_preprocessor.transform( + indicators_to_transform) + output_without_prep = estimator_with_prep_formed.transform( + formed_points_to_transform) + assert np.array(output_with_prep == output_without_prep).all() + + except Exception as raised_exception: + assert raised_exception.value.args[0] == not_implemented_msg def test_check_collapsed_pairs_raises_no_error(): From b019d857c443d69b0c263f37ca2b03ba45f4a2a1 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 20 Oct 2021 11:26:46 +0200 Subject: [PATCH 43/57] Fixed changes requested 3 --- doc/supervised.rst | 5 ++--- doc/weakly_supervised.rst | 5 ++--- metric_learn/base_metric.py | 29 ++++++++++++++--------------- test/test_mahalanobis_mixin.py | 4 ++-- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/doc/supervised.rst b/doc/supervised.rst index 28fdf02b..e27b58ec 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -83,9 +83,8 @@ array([0.49627072, 3.65287282, 6.06079877]) 0.4962707194621285 - Alternatively, you can use `pair_score` to return the **score** between - pairs of points, the larger the **score**, the more similar the pair - and vice-versa. For Mahalanobis learners, it is equal to the opposite - of the distance. + pairs of points (the larger the score, the more similar the pair). + For Mahalanobis learners, it is equal to the opposite of the distance. >>> score = nca.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) >>> score diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index 13346c22..02ea4ef6 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -176,9 +176,8 @@ array([7.27607365, 0.88853014]) 7.276073646278203 - Alternatively, you can use `pair_score` to return the **score** between - pairs of points, the larger the **score**, the more similar the pair - and vice-versa. For Mahalanobis learners, it is equal to the opposite - of the distance. + pairs of points (the larger the score, the more similar the pair). + For Mahalanobis learners, it is equal to the opposite of the distance. >>> score = mmc.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) >>> score diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index c04754e3..84a22ad3 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -54,10 +54,10 @@ def score_pairs(self, pairs): See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference between `pair_score` and `pair_distance` is - that it works on two 1D arrays and cannot use a preprocessor. Besides, - the returned function is independent of the metric learner and hence is - not modified if the metric learner is. + two points. The difference between `score_pairs` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. """ @abstractmethod @@ -65,11 +65,10 @@ def pair_score(self, pairs): """ .. versionadded:: 0.7.0 Compute the similarity score between pairs - Returns the similarity score between pairs of points. Depending on the - algorithm, this method can return the learned similarity score between - pairs, or the opposite of the distance learned between pairs. The larger - the score, the more similar the pair. All learners have access to this - method. + Returns the similarity score between pairs of points (the larger the score, + the more similar the pair). For metric learners that learn a distance, + the score is simply the opposite of the distance between pairs. All learners + have access to this method. Parameters ---------- @@ -104,14 +103,14 @@ def pair_distance(self, pairs): Parameters ---------- pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) - 3D Array of pairs to score, with each row corresponding to two points, - for 2D array of indices of pairs if the metric learner uses a - preprocessor. + 3D Array of pairs for which to compute the distance, with each + row corresponding to two points, for 2D array of indices of pairs + if the metric learner uses a preprocessor. Returns ------- scores : `numpy.ndarray` of shape=(n_pairs,) - The score of every pair. + The distance between every pair. See Also -------- @@ -177,8 +176,8 @@ def _prepare_inputs(self, X, y=None, type_of_inputs='classic', def get_metric(self): """Returns a function that takes as input two 1D arrays and outputs the value of the learned metric on these two points. Depending on the - algorithm, it can return a distance or a score function between pairs. - It always returns what the specific algorithm learns. + algorithm, it can return a distance or a similarity function between + pairs. This function will be independent from the metric learner that learned it (it will not be modified if the initial metric learner is modified), diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index f52f4592..64ab10b4 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -41,9 +41,9 @@ def test_pair_distance_pair_score_equivalent(estimator, build_dataset): model.fit(*remove_y(estimator, input_data, labels)) distances = model.pair_distance(np.array(list(product(X, X)))) - scores_1 = -1 * model.pair_score(np.array(list(product(X, X)))) + scores = model.pair_score(np.array(list(product(X, X)))) - assert_array_equal(distances, scores_1) + assert_array_equal(distances, -1 * scores) @pytest.mark.parametrize('estimator, build_dataset', metric_learners, From 74df897171a041a5fb6d0be5c85df770792bdbec Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 20 Oct 2021 15:28:06 +0200 Subject: [PATCH 44/57] Fix identation --- metric_learn/base_metric.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 84a22ad3..edbd5ed2 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -67,8 +67,8 @@ def pair_score(self, pairs): Returns the similarity score between pairs of points (the larger the score, the more similar the pair). For metric learners that learn a distance, - the score is simply the opposite of the distance between pairs. All learners - have access to this method. + the score is simply the opposite of the distance between pairs. All + learners have access to this method. Parameters ---------- From c62a4e741728d6e6924abd45c60f1961dfcf6894 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Thu, 21 Oct 2021 10:41:36 +0200 Subject: [PATCH 45/57] Last requested changes --- test/test_base_metric.py | 2 +- test/test_mahalanobis_mixin.py | 2 +- test/test_sklearn_compat.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/test/test_base_metric.py b/test/test_base_metric.py index 5a409fe1..baa585b9 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -295,7 +295,7 @@ def test_score_pairs_warning(estimator, build_dataset): score = model.score_pairs([[X[0], X[1]], ]) dist = model.pair_distance([[X[0], X[1]], ]) assert array_equal(score, dist) - assert np.any([str(warning.message) == msg for warning in raised_warning]) + assert any([str(warning.message) == msg for warning in raised_warning]) if __name__ == '__main__': diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index 64ab10b4..e2aa1e4d 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -646,7 +646,7 @@ def test_singular_covariance_init_of_non_strict_pd(estimator, build_dataset): 'preprocessing step.') with pytest.warns(UserWarning) as raised_warning: model.fit(input_data, labels) - assert np.any([str(warning.message) == msg for warning in raised_warning]) + assert any([str(warning.message) == msg for warning in raised_warning]) M, _ = _initialize_metric_mahalanobis(X, init='covariance', random_state=RNG, return_inverse=True, diff --git a/test/test_sklearn_compat.py b/test/test_sklearn_compat.py index 738ddc86..b08fcf25 100644 --- a/test/test_sklearn_compat.py +++ b/test/test_sklearn_compat.py @@ -158,7 +158,6 @@ def test_array_like_inputs(estimator, build_dataset, with_preprocessor): # But not all of them will have pair_distance try: estimator.pair_distance(pairs_variant) - except Exception as raised_exception: assert raised_exception.value.args[0] == not_implemented_msg From 219972474000b9727ef4abdce5c90728835b7c45 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Thu, 21 Oct 2021 10:52:27 +0200 Subject: [PATCH 46/57] Replaced pair_similarity for paiir_score --- doc/supervised.rst | 6 +++--- doc/weakly_supervised.rst | 6 +++--- metric_learn/base_metric.py | 14 ++++++------- test/test_bilinear_mixin.py | 40 ++++++++++++++++++------------------- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/doc/supervised.rst b/doc/supervised.rst index d9280cc9..4ed78589 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -107,10 +107,10 @@ scikit-learn's `Classification metrics `_. For similarity learners `pair_distance` is not available, as they don't learn -a distance. Intead you use `pair_similarity` that has the same behaviour but +a distance. Intead you use `pair_score` that has the same behaviour but for similarity. ->>> algorithm.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +>>> algorithm.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) array([-0.2312, 705.23, -72.8]) .. warning:: @@ -125,7 +125,7 @@ a function that will return the similarity bewtween 1D arrays. >>> similarity_fun([3.5, 3.6], [5.6, 2.4]) -0.04752 -For similarity learners and mahalanobis learners, `pair_similarity` is +For similarity learners and mahalanobis learners, `pair_score` is available. You can interpret that this function returns the **score** between points: the more the **score**, the closer the pairs and vice-versa. For mahalanobis learners, it is equal to the inverse of the distance. diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index 4f711579..3da4745a 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -200,10 +200,10 @@ array([-0.49627072, -3.65287282, -6.06079877]) `_. For similarity learners `pair_distance` is not available, as they don't learn -a distance. Intead you use `pair_similarity` that has the same behaviour but +a distance. Intead you use `pair_score` that has the same behaviour but for similarity. ->>> algorithm.pair_similarity([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +>>> algorithm.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) array([-0.2312, 705.23, -72.8]) .. warning:: @@ -218,7 +218,7 @@ a function that will return the similarity bewtween 1D arrays. >>> similarity_fun([3.5, 3.6], [5.6, 2.4]) -0.04752 -For similarity learners and mahalanobis learners, `pair_similarity` is +For similarity learners and mahalanobis learners, `pair_score` is available. You can interpret that this function returns the **score** between points: the more the **score**, the closer the pairs and vice-versa. For mahalanobis learners, it is equal to the inverse of the distance. diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 175a04db..be50da87 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -29,11 +29,11 @@ def __init__(self, preprocessor=None): @abstractmethod def score_pairs(self, pairs): """ - .. deprecated:: 0.7.0 Refer to `pair_distance` and `pair_similarity`. + .. deprecated:: 0.7.0 Refer to `pair_distance` and `pair_score`. .. warning:: This method will be removed in 0.8.0. Please refer to `pair_distance` - or `pair_similarity`. This change will occur in order to add learners + or `pair_score`. This change will occur in order to add learners that don't necessarily learn a Mahalanobis distance. Returns the score between pairs @@ -260,10 +260,10 @@ class BilinearMixin(BaseMetricLearner, metaclass=ABCMeta): def score_pairs(self, pairs): dpr_msg = ("score_pairs will be deprecated in release 0.6.4. " - "Use pair_similarity to compute similarities, or " + "Use pair_score to compute similarities, or " "pair_distances to compute distances.") warnings.warn(dpr_msg, category=FutureWarning) - return self.pair_similarity(pairs) + return self.pair_score(pairs) def pair_distance(self, pairs): """ @@ -272,11 +272,11 @@ def pair_distance(self, pairs): of the bilinear similarity cannot be used as distance by construction. """ msg = ("Bilinear similarity learners don't learn a distance, thus ", - "this method is not implemented. Use pair_similarity to " + "this method is not implemented. Use pair_score to " "compute similarity between pairs") raise Exception(msg) - def pair_similarity(self, pairs): + def pair_score(self, pairs): r"""Returns the learned Bilinear similarity between pairs. This similarity is defined as: :math:`s_M(x, x') = x^T M x'` @@ -298,7 +298,7 @@ def pair_similarity(self, pairs): See Also -------- get_metric : a method that returns a function to compute the similarity - between two points. The difference with `pair_similarity` is that it + between two points. The difference with `pair_score` is that it works on two 1D arrays and cannot use a preprocessor. Besides, the returned function is independent of the similarity learner and hence is not modified if the similarity learner is. diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index 4a47952a..b2c1aa66 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -58,12 +58,12 @@ def identity_fit(d=100, n=100, n_pairs=None, random=False): @pytest.mark.parametrize('n_pairs', [100, 1000]) def test_same_similarity_with_two_methods(d, n, n_pairs): """" - Tests that pair_similarity() and get_metric() give consistent results. + Tests that pair_score() and get_metric() give consistent results. In both cases, the results must match for the same input. Tests it for 'n_pairs' sampled from 'n' d-dimentional arrays. """ _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - dist1 = mixin.pair_similarity(random_pairs) + dist1 = mixin.pair_score(random_pairs) dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] assert_array_almost_equal(dist1, dist2) @@ -79,21 +79,21 @@ def test_check_correctness_similarity(d, n, n_pairs): the real bilinear similarity calculated in-place. """ _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - dist1 = mixin.pair_similarity(random_pairs) + dist1 = mixin.pair_score(random_pairs) dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] dist3 = [np.dot(np.dot(p[0].T, mixin.get_bilinear_matrix()), p[1]) for p in random_pairs] desired = [np.dot(np.dot(p[0].T, mixin.components_), p[1]) for p in random_pairs] - assert_array_almost_equal(dist1, desired) # pair_similarity + assert_array_almost_equal(dist1, desired) # pair_score assert_array_almost_equal(dist2, desired) # get_metric assert_array_almost_equal(dist3, desired) # get_metric def test_check_handmade_example(): """ - Checks that pair_similarity() result is correct comparing it with a + Checks that pair_score() result is correct comparing it with a handmade example. """ u = np.array([0, 1, 2]) @@ -102,7 +102,7 @@ def test_check_handmade_example(): mixin.fit([u, v], [0, 0]) # Identity fit c = np.array([[2, 4, 6], [6, 4, 2], [1, 2, 3]]) mixin.components_ = c # Force components_ - dists = mixin.pair_similarity([[u, v], [v, u]]) + dists = mixin.pair_score([[u, v], [v, u]]) assert_array_almost_equal(dists, [96, 120]) @@ -117,35 +117,35 @@ def test_check_handmade_symmetric_example(d, n, n_pairs): """ _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs) pairs_reverse = [[p[1], p[0]] for p in random_pairs] - dist1 = mixin.pair_similarity(random_pairs) - dist2 = mixin.pair_similarity(pairs_reverse) + dist1 = mixin.pair_score(random_pairs) + dist2 = mixin.pair_score(pairs_reverse) assert_array_almost_equal(dist1, dist2) # Random pairs for M = spd Matrix spd_matrix = make_spd_matrix(d, random_state=RNG) mixin.components_ = spd_matrix - dist1 = mixin.pair_similarity(random_pairs) - dist2 = mixin.pair_similarity(pairs_reverse) + dist1 = mixin.pair_score(random_pairs) + dist2 = mixin.pair_score(pairs_reverse) assert_array_almost_equal(dist1, dist2) @pytest.mark.parametrize('d', [10, 300]) @pytest.mark.parametrize('n', [10, 100]) @pytest.mark.parametrize('n_pairs', [100, 1000]) -def test_pair_similarity_finite(d, n, n_pairs): +def test_pair_score_finite(d, n, n_pairs): """ - Checks for 'n' pair_similarity() of 'd' dimentions, that all + Checks for 'n' pair_score() of 'd' dimentions, that all similarities are finite numbers: not NaN, +inf or -inf. Considers a random M for bilinear similarity. """ _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - dist1 = mixin.pair_similarity(random_pairs) + dist1 = mixin.pair_score(random_pairs) assert np.isfinite(dist1).all() @pytest.mark.parametrize('d', [10, 300]) @pytest.mark.parametrize('n', [10, 100]) -def test_pair_similarity_dim(d, n): +def test_pair_score_dim(d, n): """ Scoring of 3D arrays should return 1D array (several tuples), and scoring of 2D arrays (one tuple) should return an error (like @@ -153,13 +153,13 @@ def test_pair_similarity_dim(d, n): """ X, _, mixin = identity_fit(d=d, n=n, n_pairs=None, random=True) tuples = np.array(list(product(X, X))) - assert mixin.pair_similarity(tuples).shape == (tuples.shape[0],) + assert mixin.pair_score(tuples).shape == (tuples.shape[0],) context = make_context(mixin) msg = ("3D array of formed tuples expected{}. Found 2D array " "instead:\ninput={}. Reshape your data and/or use a preprocessor.\n" .format(context, tuples[1])) with pytest.raises(ValueError) as raised_error: - mixin.pair_similarity(tuples[1]) + mixin.pair_score(tuples[1]) assert str(raised_error.value) == msg @@ -181,15 +181,15 @@ def test_check_scikitlearn_compatibility(d, n): def test_check_score_pairs_deprecation_and_output(d, n, n_pairs): """ Check that calling score_pairs shows a warning of deprecation, and also - that the output of score_pairs matches calling pair_similarity. + that the output of score_pairs matches calling pair_score. """ _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) dpr_msg = ("score_pairs will be deprecated in release 0.6.4. " - "Use pair_similarity to compute similarities, or " + "Use pair_score to compute similarities, or " "pair_distances to compute distances.") with pytest.warns(FutureWarning) as raised_warnings: s1 = mixin.score_pairs(random_pairs) - s2 = mixin.pair_similarity(random_pairs) + s2 = mixin.pair_score(random_pairs) assert_array_almost_equal(s1, s2) assert any(str(w.message) == dpr_msg for w in raised_warnings) @@ -204,7 +204,7 @@ def test_check_error_with_pair_distance(d, n, n_pairs): """ _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) msg = ("Bilinear similarity learners don't learn a distance, thus ", - "this method is not implemented. Use pair_similarity to " + "this method is not implemented. Use pair_score to " "compute similarity between pairs") with pytest.raises(Exception) as e: _ = mixin.pair_distance(random_pairs) From 249e0fe7ca4a250f554a92fae9f5da213d48600d Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Thu, 21 Oct 2021 10:53:28 +0200 Subject: [PATCH 47/57] Last small detail --- metric_learn/base_metric.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index edbd5ed2..e7dbd608 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -29,11 +29,11 @@ def __init__(self, preprocessor=None): @abstractmethod def score_pairs(self, pairs): """ - .. deprecated:: 0.7.0 Refer to `pair_distance` and `pair_similarity`. + .. deprecated:: 0.7.0 Refer to `pair_distance` and `pair_score`. .. warning:: This method will be removed in 0.8.0. Please refer to `pair_distance` - or `pair_similarity`. This change will occur in order to add learners + or `pair_score`. This change will occur in order to add learners that don't necessarily learn a Mahalanobis distance. Returns the score between pairs From eef13bb8bbd4fee40cf256b13a1780f8c71b627a Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Tue, 26 Oct 2021 16:41:51 +0200 Subject: [PATCH 48/57] Classifiers only test classifiers methods now. + Standard doctrings now. --- test/test_mahalanobis_mixin.py | 36 ++++++++++++++- test/test_pairs_classifiers.py | 69 ++++++++++++---------------- test/test_quadruplets_classifiers.py | 9 +++- test/test_triplets_classifiers.py | 9 +++- 4 files changed, 78 insertions(+), 45 deletions(-) diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index e2aa1e4d..724dfd4a 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -21,7 +21,9 @@ from metric_learn.exceptions import NonPSDError from test.test_utils import (ids_metric_learners, metric_learners, - remove_y, ids_classifiers) + remove_y, ids_classifiers, + pairs_learners, ids_pairs_learners) +from sklearn.exceptions import NotFittedError RNG = check_random_state(0) @@ -750,3 +752,35 @@ def test_deterministic_initialization(estimator, build_dataset): model2 = model2.fit(*remove_y(model, input_data, labels)) np.testing.assert_allclose(model1.get_mahalanobis_matrix(), model2.get_mahalanobis_matrix()) + + +@pytest.mark.parametrize('with_preprocessor', [True, False]) +@pytest.mark.parametrize('estimator, build_dataset', pairs_learners, + ids=ids_pairs_learners) +def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, + with_preprocessor): + """Test that a NotFittedError is raised if someone tries to use + pair_score, pair_distance, score_pairs, get_metric, transform or + get_mahalanobis_matrix on input data and the metric learner + has not been fitted.""" + input_data, _, preprocessor, _ = build_dataset(with_preprocessor) + estimator = clone(estimator) + estimator.set_params(preprocessor=preprocessor) + set_random_state(estimator) + with pytest.raises(NotFittedError): # TODO: Remove in 0.8.0 + msg = ("score_pairs will be deprecated in release 0.7.0. " + "Use pair_score to compute similarity scores, or " + "pair_distances to compute distances.") + with pytest.warns(FutureWarning) as raised_warning: + estimator.score_pairs(input_data) + assert any([str(warning.message) == msg for warning in raised_warning]) + with pytest.raises(NotFittedError): + estimator.pair_score(input_data) + with pytest.raises(NotFittedError): + estimator.pair_distance(input_data) + with pytest.raises(NotFittedError): + estimator.get_metric() + with pytest.raises(NotFittedError): + estimator.get_mahalanobis_matrix() + with pytest.raises(NotFittedError): + estimator.transform(input_data) diff --git a/test/test_pairs_classifiers.py b/test/test_pairs_classifiers.py index 714cbd08..274f7e5f 100644 --- a/test/test_pairs_classifiers.py +++ b/test/test_pairs_classifiers.py @@ -40,7 +40,7 @@ def test_predict_only_one_or_minus_one(estimator, build_dataset, ids=ids_pairs_learners) def test_predict_monotonous(estimator, build_dataset, with_preprocessor): - """Test that there is a threshold distance separating points labeled as + """Test that there is a threshold value separating points labeled as similar and points labeled as dissimilar """ input_data, labels, preprocessor, _ = build_dataset(with_preprocessor) estimator = clone(estimator) @@ -65,32 +65,22 @@ def test_predict_monotonous(estimator, build_dataset, def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, with_preprocessor): """Test that a NotFittedError is raised if someone tries to use - pair_score, score_pairs, decision_function, get_metric, transform or - get_mahalanobis_matrix on input data and the metric learner - has not been fitted.""" + decision_function, calibrate_threshold, set_threshold, predict + on input data and the metric learner has not been fitted.""" input_data, labels, preprocessor, _ = build_dataset(with_preprocessor) estimator = clone(estimator) estimator.set_params(preprocessor=preprocessor) set_random_state(estimator) - with pytest.raises(NotFittedError): # Remove in 0.8.0 - estimator.score_pairs(input_data) with pytest.raises(NotFittedError): - estimator.pair_score(input_data) + estimator.predict(input_data) with pytest.raises(NotFittedError): estimator.decision_function(input_data) with pytest.raises(NotFittedError): - estimator.get_metric() - with pytest.raises(NotFittedError): - estimator.transform(input_data) - with pytest.raises(NotFittedError): - estimator.get_mahalanobis_matrix() - with pytest.raises(NotFittedError): - estimator.calibrate_threshold(input_data, labels) - + estimator.score(input_data, labels) with pytest.raises(NotFittedError): estimator.set_threshold(0.5) with pytest.raises(NotFittedError): - estimator.predict(input_data) + estimator.calibrate_threshold(input_data, labels) @pytest.mark.parametrize('calibration_params', @@ -130,7 +120,7 @@ def test_fit_with_valid_threshold_params(estimator, build_dataset, ids=ids_pairs_learners) def test_threshold_different_scores_is_finite(estimator, build_dataset, with_preprocessor, kwargs): - # test that calibrating the threshold works for every metric learner + """Test that calibrating the threshold works for every metric learner""" input_data, labels, preprocessor, _ = build_dataset(with_preprocessor) estimator = clone(estimator) estimator.set_params(preprocessor=preprocessor) @@ -171,7 +161,7 @@ def test_unset_threshold(): def test_set_threshold(): - # test that set_threshold indeed sets the threshold + """Test that set_threshold indeed sets the threshold""" identity_pairs_classifier = IdentityPairsClassifier() pairs = np.array([[[0.], [1.]], [[1.], [3.]], [[2.], [5.]], [[3.], [7.]]]) y = np.array([1, 1, -1, -1]) @@ -181,8 +171,8 @@ def test_set_threshold(): def test_f_beta_1_is_f_1(): - # test that putting beta to 1 indeed finds the best threshold to optimize - # the f1_score + """Test that putting beta to 1 indeed finds the best threshold to optimize + the f1_score""" rng = np.random.RandomState(42) n_samples = 100 pairs, y = rng.randn(n_samples, 2, 5), rng.choice([-1, 1], size=n_samples) @@ -247,8 +237,8 @@ def tnr_threshold(y_true, y_pred, tpr_threshold=0.): for t in [0., 0.1, 0.5, 0.8, 1.]], ) def test_found_score_is_best_score(kwargs, scoring): - # test that when we use calibrate threshold, it will indeed be the - # threshold that have the best score + """Test that when we use calibrate threshold, it will indeed be the + threshold that have the best score""" rng = np.random.RandomState(42) n_samples = 50 pairs, y = rng.randn(n_samples, 2, 5), rng.choice([-1, 1], size=n_samples) @@ -286,11 +276,11 @@ def test_found_score_is_best_score(kwargs, scoring): for t in [0., 0.1, 0.5, 0.8, 1.]] ) def test_found_score_is_best_score_duplicates(kwargs, scoring): - # test that when we use calibrate threshold, it will indeed be the - # threshold that have the best score. It's the same as the previous test - # except this time we test that the scores are coherent even if there are - # duplicates (i.e. points that have the same score returned by - # `decision_function`). + """Test that when we use calibrate threshold, it will indeed be the + threshold that have the best score. It's the same as the previous test + except this time we test that the scores are coherent even if there are + duplicates (i.e. points that have the same score returned by + `decision_function`).""" rng = np.random.RandomState(42) n_samples = 50 pairs, y = rng.randn(n_samples, 2, 5), rng.choice([-1, 1], size=n_samples) @@ -334,8 +324,8 @@ def test_found_score_is_best_score_duplicates(kwargs, scoring): ) def test_calibrate_threshold_invalid_parameters_right_error(invalid_args, expected_msg): - # test that the right error message is returned if invalid arguments are - # given to calibrate_threshold + """Test that the right error message is returned if invalid arguments are + given to `calibrate_threshold`""" rng = np.random.RandomState(42) pairs, y = rng.randn(20, 2, 5), rng.choice([-1, 1], size=20) pairs_learner = IdentityPairsClassifier() @@ -358,8 +348,8 @@ def test_calibrate_threshold_invalid_parameters_right_error(invalid_args, # to do that) ) def test_calibrate_threshold_valid_parameters(valid_args): - # test that no warning message is returned if valid arguments are given to - # calibrate threshold + """Test that no warning message is returned if valid arguments are given to + `calibrate threshold`""" rng = np.random.RandomState(42) pairs, y = rng.randn(20, 2, 5), rng.choice([-1, 1], size=20) pairs_learner = IdentityPairsClassifier() @@ -371,8 +361,7 @@ def test_calibrate_threshold_valid_parameters(valid_args): def test_calibrate_threshold_extreme(): """Test that in the (rare) case where we should accept all points or - reject all points, this is effectively what - is done""" + reject all points, this is effectively what is done""" class MockBadPairsClassifier(MahalanobisMixin, _PairsClassifierMixin): """A pairs classifier that returns bad scores (i.e. in the inverse order @@ -470,9 +459,9 @@ def decision_function(self, pairs): ) def test_validate_calibration_params_invalid_parameters_right_error( estimator, _, invalid_args, expected_msg): - # test that the right error message is returned if invalid arguments are - # given to _validate_calibration_params, for all pairs metric learners as - # well as a mocking general identity pairs classifier and the class itself + """Test that the right error message is returned if invalid arguments are + given to `_validate_calibration_params`, for all pairs metric learners as + well as a mocking general identity pairs classifier and the class itself""" with pytest.raises(ValueError) as raised_error: estimator._validate_calibration_params(**invalid_args) assert str(raised_error.value) == expected_msg @@ -496,9 +485,9 @@ def test_validate_calibration_params_invalid_parameters_right_error( ) def test_validate_calibration_params_valid_parameters( estimator, _, valid_args): - # test that no warning message is returned if valid arguments are given to - # _validate_calibration_params for all pairs metric learners, as well as - # a mocking example, and the class itself + """Test that no warning message is returned if valid arguments are given to + `_validate_calibration_params` for all pairs metric learners, as well as + a mocking example, and the class itself""" with pytest.warns(None) as record: estimator._validate_calibration_params(**valid_args) assert len(record) == 0 @@ -509,7 +498,7 @@ def test_validate_calibration_params_valid_parameters( ids=ids_pairs_learners) def test_validate_calibration_params_invalid_parameters_error_before__fit( estimator, build_dataset): - """For all pairs metric learners (which currently all have a _fit method), + """For all pairs metric learners (which currently all have a `_fit` method), make sure that calibration parameters are validated before fitting""" estimator = clone(estimator) input_data, labels, _, _ = build_dataset() diff --git a/test/test_quadruplets_classifiers.py b/test/test_quadruplets_classifiers.py index a8319961..42bc1d64 100644 --- a/test/test_quadruplets_classifiers.py +++ b/test/test_quadruplets_classifiers.py @@ -31,14 +31,19 @@ def test_predict_only_one_or_minus_one(estimator, build_dataset, ids=ids_quadruplets_learners) def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, with_preprocessor): - """Test that a NotFittedError is raised if someone tries to predict and - the metric learner has not been fitted.""" + """Test that a NotFittedError is raised if someone tries to use the + methods: predict, decision_function and score when the metric learner + has not been fitted.""" input_data, labels, preprocessor, _ = build_dataset(with_preprocessor) estimator = clone(estimator) estimator.set_params(preprocessor=preprocessor) set_random_state(estimator) with pytest.raises(NotFittedError): estimator.predict(input_data) + with pytest.raises(NotFittedError): + estimator.decision_function(input_data) + with pytest.raises(NotFittedError): + estimator.score(input_data) @pytest.mark.parametrize('estimator, build_dataset', quadruplets_learners, diff --git a/test/test_triplets_classifiers.py b/test/test_triplets_classifiers.py index 600947e6..775f8cc0 100644 --- a/test/test_triplets_classifiers.py +++ b/test/test_triplets_classifiers.py @@ -88,14 +88,19 @@ def test_no_zero_prediction(estimator, build_dataset): ids=ids_triplets_learners) def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, with_preprocessor): - """Test that a NotFittedError is raised if someone tries to predict and - the metric learner has not been fitted.""" + """Test that a NotFittedError is raised if someone tries to use the + methods: predict, decision_function and score when the metric learner + has not been fitted.""" input_data, _, preprocessor, _ = build_dataset(with_preprocessor) estimator = clone(estimator) estimator.set_params(preprocessor=preprocessor) set_random_state(estimator) with pytest.raises(NotFittedError): estimator.predict(input_data) + with pytest.raises(NotFittedError): + estimator.decision_function(input_data) + with pytest.raises(NotFittedError): + estimator.score(input_data) @pytest.mark.parametrize('estimator, build_dataset', triplets_learners, From b952af0d055c36508ca515b845b1a4f870e4da81 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Tue, 26 Oct 2021 18:35:51 +0200 Subject: [PATCH 49/57] Work in tests. More comments. Some refactors --- metric_learn/base_metric.py | 5 +- test/metric_learn_test.py | 3 + test/test_base_metric.py | 4 + test/test_bilinear_mixin.py | 37 +++++--- test/test_components_metric_conversion.py | 4 + test/test_constraints.py | 4 + test/test_fit_transform.py | 4 + test/test_mahalanobis_mixin.py | 43 +++++---- test/test_pairs_classifiers.py | 4 + test/test_quadruplets_classifiers.py | 4 + test/test_sklearn_compat.py | 25 ++++-- test/test_triplets_classifiers.py | 4 + test/test_utils.py | 105 ++++++++++++++++++---- 13 files changed, 186 insertions(+), 60 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index be50da87..de4ca572 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -271,9 +271,8 @@ def pair_distance(self, pairs): pseudo-distance nor a distance. In consecuence, the additive inverse of the bilinear similarity cannot be used as distance by construction. """ - msg = ("Bilinear similarity learners don't learn a distance, thus ", - "this method is not implemented. Use pair_score to " - "compute similarity between pairs") + msg = ("This learner doesn't learn a distance, thus ", + "this method is not implemented. Use pair_score instead") raise Exception(msg) def pair_score(self, pairs): diff --git a/test/metric_learn_test.py b/test/metric_learn_test.py index 542e1e0a..1bad1f14 100644 --- a/test/metric_learn_test.py +++ b/test/metric_learn_test.py @@ -1,3 +1,6 @@ +""" +Tests that are specific for each learner. +""" import unittest import re import pytest diff --git a/test/test_base_metric.py b/test/test_base_metric.py index baa585b9..e932c1b8 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -1,3 +1,7 @@ +""" +Tests general things from the API: String parsing, methods like get_metric, +and deprecation warnings. +""" from numpy.core.numeric import array_equal import pytest import re diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index b2c1aa66..c40650bd 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -1,3 +1,7 @@ +""" +Tests all functionality for Bilinear learners. Correctness, use cases, +warnings, etc. +""" from itertools import product from metric_learn.base_metric import BilinearMixin import numpy as np @@ -10,11 +14,28 @@ RNG = check_random_state(0) +class RadomBilinearMixin(BilinearMixin): + """A simple Random bilinear mixin that returns an random matrix + M as learned. Class for testing purposes. + """ + def __init__(self, preprocessor=None): + super().__init__(preprocessor=preprocessor) + + def fit(self, X, y, random=False): + """ + Checks input's format. If random=False, sets M matrix to + identity of shape (d,d) where d is the dimension of the input. + Otherwise, a random (d,d) matrix is set. + """ + X, y = self._prepare_inputs(X, y, ensure_min_samples=2) + self.d = np.shape(X[0])[-1] + self.components_ = np.random.rand(self.d, self.d) + return self + class IdentityBilinearMixin(BilinearMixin): """A simple Identity bilinear mixin that returns an identity matrix - M as learned. Can change M for a random matrix specifying random=True - at fit(). Class for testing purposes. + M as learned. Class for testing purposes. """ def __init__(self, preprocessor=None): super().__init__(preprocessor=preprocessor) @@ -27,10 +48,7 @@ def fit(self, X, y, random=False): """ X, y = self._prepare_inputs(X, y, ensure_min_samples=2) self.d = np.shape(X[0])[-1] - if random: - self.components_ = np.random.rand(self.d, self.d) - else: - self.components_ = np.identity(self.d) + self.components_ = np.identity(self.d) return self @@ -44,7 +62,7 @@ def identity_fit(d=100, n=100, n_pairs=None, random=False): """ X = np.array([np.random.rand(d) for _ in range(n)]) mixin = IdentityBilinearMixin() - mixin.fit(X, [0 for _ in range(n)], random=random) + mixin.fit(X, [0 for _ in range(n)]) if n_pairs is not None: random_pairs = [[X[RNG.randint(0, n)], X[RNG.randint(0, n)]] for _ in range(n_pairs)] @@ -203,9 +221,8 @@ def test_check_error_with_pair_distance(d, n, n_pairs): An Exception must be shown instead. """ _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - msg = ("Bilinear similarity learners don't learn a distance, thus ", - "this method is not implemented. Use pair_score to " - "compute similarity between pairs") + msg = ("This learner doesn't learn a distance, thus ", + "this method is not implemented. Use pair_score instead") with pytest.raises(Exception) as e: _ = mixin.pair_distance(random_pairs) assert e.value.args[0] == msg diff --git a/test/test_components_metric_conversion.py b/test/test_components_metric_conversion.py index 5502ad90..368a2fa2 100644 --- a/test/test_components_metric_conversion.py +++ b/test/test_components_metric_conversion.py @@ -1,3 +1,7 @@ +""" +Tests for Mahalanobis learners, that the transormation matrix (L) squared +is equivalent to the Mahalanobis matrix, even in edge cases. +""" import unittest import numpy as np import pytest diff --git a/test/test_constraints.py b/test/test_constraints.py index def228d4..d9e567aa 100644 --- a/test/test_constraints.py +++ b/test/test_constraints.py @@ -1,3 +1,7 @@ +""" +Test Contrains generation for positive_negative_pairs and knn_triplets. +Also tests warnings. +""" import pytest import numpy as np from sklearn.utils import shuffle diff --git a/test/test_fit_transform.py b/test/test_fit_transform.py index d4d4bfe0..63ca421c 100644 --- a/test/test_fit_transform.py +++ b/test/test_fit_transform.py @@ -1,3 +1,7 @@ +""" +For each lerner that has `fit` and `transform`, checks that calling them +sequeatially is the same as calling fit_transform from scikit-learn. +""" import unittest import numpy as np from sklearn.datasets import load_iris diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index 724dfd4a..deca3f3b 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -1,3 +1,7 @@ +""" +Tests all functionality for Mahalanobis Learners. Correctness, use cases, +warnings, distance properties, transform, dimentions, init, etc. +""" from itertools import product import pytest @@ -51,7 +55,8 @@ def test_pair_distance_pair_score_equivalent(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_pair_distance_pairwise(estimator, build_dataset): - # Computing pairwise scores should return a euclidean distance matrix. + """Computing pairwise scores should return a euclidean distance + matrix.""" input_data, labels, _, X = build_dataset() n_samples = 20 X = X[:n_samples] @@ -75,7 +80,7 @@ def test_pair_distance_pairwise(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_pair_distance_toy_example(estimator, build_dataset): - # Checks that pair_distance works on a toy example + """Checks that `pair_distance` works on a toy example.""" input_data, labels, _, X = build_dataset() n_samples = 20 X = X[:n_samples] @@ -93,7 +98,7 @@ def test_pair_distance_toy_example(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_pair_distance_finite(estimator, build_dataset): - # tests that the score is finite + """Tests that the distance from `pair_distance` is finite""" input_data, labels, _, X = build_dataset() model = clone(estimator) set_random_state(model) @@ -105,9 +110,10 @@ def test_pair_distance_finite(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_pair_distance_dim(estimator, build_dataset): - # scoring of 3D arrays should return 1D array (several tuples), - # and scoring of 2D arrays (one tuple) should return an error (like - # scikit-learn's error when scoring 1D arrays) + """Calling `pair_distance` with 3D arrays should return 1D array + (several tuples), and calling `pair_distance` with 2D arrays + (one tuple) should return an error (like scikit-learn's error when + scoring 1D arrays)""" input_data, labels, _, X = build_dataset() model = clone(estimator) set_random_state(model) @@ -124,6 +130,8 @@ def test_pair_distance_dim(estimator, build_dataset): def check_is_distance_matrix(pairwise): + """Returns True if the matrix is positive, symmetrc, the diagonal is zero, + and if it fullfills the triangular inequality for all pairs""" assert (pairwise >= 0).all() # positivity assert np.array_equal(pairwise, pairwise.T) # symmetry assert (pairwise.diagonal() == 0).all() # identity @@ -136,7 +144,8 @@ def check_is_distance_matrix(pairwise): @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_embed_toy_example(estimator, build_dataset): - # Checks that embed works on a toy example + """Checks that embed works on a toy example. That using `transform` + is equivalent to manually multiplying Lx""" input_data, labels, _, X = build_dataset() n_samples = 20 X = X[:n_samples] @@ -150,7 +159,7 @@ def test_embed_toy_example(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_embed_dim(estimator, build_dataset): - # Checks that the the dimension of the output space is as expected + """Checks that the the dimension of the output space is as expected""" input_data, labels, _, X = build_dataset() model = clone(estimator) set_random_state(model) @@ -179,7 +188,7 @@ def test_embed_dim(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_embed_finite(estimator, build_dataset): - # Checks that embed returns vectors with finite values + """Checks that embed (transform) returns vectors with finite values""" input_data, labels, _, X = build_dataset() model = clone(estimator) set_random_state(model) @@ -190,7 +199,8 @@ def test_embed_finite(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_embed_is_linear(estimator, build_dataset): - # Checks that the embedding is linear + """Checks that the embedding is linear, i.e. linear properties of + using `tranform`""" input_data, labels, _, X = build_dataset() model = clone(estimator) set_random_state(model) @@ -249,19 +259,6 @@ def test_get_metric_is_pseudo_metric(estimator, build_dataset): np.isclose(metric(a, c), metric(a, b) + metric(b, c), rtol=1e-20)) -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) -def test_get_metric_compatible_with_scikit_learn(estimator, build_dataset): - """Check that the metric returned by get_metric is compatible with - scikit-learn's algorithms using a custom metric, DBSCAN for instance""" - input_data, labels, _, X = build_dataset() - model = clone(estimator) - set_random_state(model) - model.fit(*remove_y(estimator, input_data, labels)) - clustering = DBSCAN(metric=model.get_metric()) - clustering.fit(X) - - @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_get_squared_metric(estimator, build_dataset): diff --git a/test/test_pairs_classifiers.py b/test/test_pairs_classifiers.py index 274f7e5f..88e16be1 100644 --- a/test/test_pairs_classifiers.py +++ b/test/test_pairs_classifiers.py @@ -1,3 +1,7 @@ +""" +Tests all functionality for PairClassifiers. Methods, threshold, calibration, +warnings, correctness, use cases, etc. +""" from functools import partial import pytest diff --git a/test/test_quadruplets_classifiers.py b/test/test_quadruplets_classifiers.py index 42bc1d64..afe407ca 100644 --- a/test/test_quadruplets_classifiers.py +++ b/test/test_quadruplets_classifiers.py @@ -1,3 +1,7 @@ +""" +Tests all functionality for QuadrupletsClassifiers. Methods, warrnings, +correctness, use cases, etc. +""" import pytest from sklearn.exceptions import NotFittedError from sklearn.model_selection import train_test_split diff --git a/test/test_sklearn_compat.py b/test/test_sklearn_compat.py index a23a88d0..6b128bd9 100644 --- a/test/test_sklearn_compat.py +++ b/test/test_sklearn_compat.py @@ -148,18 +148,16 @@ def test_array_like_inputs(estimator, build_dataset, with_preprocessor): pairs = np.array([[X[0], X[1]], [X[0], X[2]]]) pairs_variants, _ = generate_array_like(pairs) - not_implemented_msg = "" - # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says - # "This learner does not have pair_distance" + msg = ("This learner doesn't learn a distance, thus ", + "this method is not implemented. Use pair_score instead") + # Test pair_score and pair_distance when available for pairs_variant in pairs_variants: - estimator.pair_score(pairs_variant) # All learners have pair_score - - # But not all of them will have pair_distance + estimator.pair_score(pairs_variant) try: estimator.pair_distance(pairs_variant) except Exception as raised_exception: - assert raised_exception.value.args[0] == not_implemented_msg + assert raised_exception.value.args[0] == msg @pytest.mark.parametrize('with_preprocessor', [True, False]) @@ -458,5 +456,18 @@ def test_dont_overwrite_parameters(estimator, build_dataset, " %s changed" % ', '.join(attrs_changed_by_fit)) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners, + ids=ids_metric_learners) +def test_get_metric_compatible_with_scikit_learn(estimator, build_dataset): + """Check that the metric returned by get_metric is compatible with + scikit-learn's algorithms using a custom metric, DBSCAN for instance""" + input_data, labels, _, X = build_dataset() + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(estimator, input_data, labels)) + clustering = DBSCAN(metric=model.get_metric()) + clustering.fit(X) + + if __name__ == '__main__': unittest.main() diff --git a/test/test_triplets_classifiers.py b/test/test_triplets_classifiers.py index 775f8cc0..ceebe5a3 100644 --- a/test/test_triplets_classifiers.py +++ b/test/test_triplets_classifiers.py @@ -1,3 +1,7 @@ +""" +Tests all functionality for TripletsClassifiers. Methods, warrnings, +correctness, use cases, etc. +""" import pytest from sklearn.exceptions import NotFittedError from sklearn.model_selection import train_test_split diff --git a/test/test_utils.py b/test/test_utils.py index 83bdd86a..b3afd535 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,3 +1,8 @@ +""" +Tests preprocesor, warnings, errors. Also made util functions to build datasets +in a general way for each learner. Here is also the list of learners of each kind +that are used as a parameters in tests in other files. Util functions. +""" import pytest from scipy.linalg import eigh, pinvh from collections import namedtuple @@ -28,6 +33,8 @@ SEED = 42 RNG = check_random_state(SEED) +# ------------------ Building dummy data for learners ------------------ + Dataset = namedtuple('Dataset', ('data target preprocessor to_transform')) # Data and target are what we will fit on. Preprocessor is the additional # data if we use a preprocessor (which should be the default ArrayIndexer), @@ -35,7 +42,16 @@ def build_classification(with_preprocessor=False): - """Basic array for testing when using a preprocessor""" + """ + Basic array 'X, y' for testing when using a preprocessor, for instance, + fir clustering. + + If no preprocesor: 'data' are raw points, 'target' are dummy labels, + 'preprocesor' is None, and 'to_transform' are points. + + If preprocessor: 'data' are point indices, 'target' are dummy labels, + 'preprocessor' are unique points, 'to_transform' are points. + """ X, y = shuffle(*make_blobs(random_state=SEED), random_state=SEED) indices = shuffle(np.arange(X.shape[0]), random_state=SEED).astype(int) @@ -46,7 +62,15 @@ def build_classification(with_preprocessor=False): def build_regression(with_preprocessor=False): - """Basic array for testing when using a preprocessor""" + """ + Basic array 'X, y' for testing when using a preprocessor, for regression. + + If no preprocesor: 'data' are raw points, 'target' are dummy labels, + 'preprocesor' is None, and 'to_transform' are points. + + If preprocessor: 'data' are point indices, 'target' are dummy labels, + 'preprocessor' are unique points, 'to_transform' are points. + """ X, y = shuffle(*make_regression(n_samples=100, n_features=5, random_state=SEED), random_state=SEED) @@ -58,6 +82,8 @@ def build_regression(with_preprocessor=False): def build_data(): + """Aux function: Returns 'X, pairs' taken from the iris dataset, where + pairs are positive and negative pairs for PairClassifiers.""" input_data, labels = load_iris(return_X_y=True) X, y = shuffle(input_data, labels, random_state=SEED) num_constraints = 50 @@ -70,7 +96,17 @@ def build_data(): def build_pairs(with_preprocessor=False): - # builds a toy pairs problem + """ + For all pair learners. + + Returns: data, target, preprocessor, to_transform. + + If no preprocesor: 'data' are raw pairs, 'target' are dummy labels, + 'preprocesor' is None, and 'to_transform' are points. + + If preprocessor: 'data' are pair indices, 'target' are dummy labels, + 'preprocessor' are unique points, 'to_transform' are points. + """ X, indices = build_data() c = np.vstack([np.column_stack(indices[:2]), np.column_stack(indices[2:])]) target = np.concatenate([np.ones(indices[0].shape[0]), @@ -85,6 +121,17 @@ def build_pairs(with_preprocessor=False): def build_triplets(with_preprocessor=False): + """ + For all triplet learners. + + Returns: data, target, preprocessor, to_transform. + + If no preprocesor: 'data' are raw triplets, 'target' are dummy labels, + 'preprocesor' is None, and 'to_transform' are points. + + If preprocessor: 'data' are triplets indices, 'target' are dummy labels, + 'preprocessor' are unique points, 'to_transform' are points. + """ input_data, labels = load_iris(return_X_y=True) X, y = shuffle(input_data, labels, random_state=SEED) constraints = Constraints(y) @@ -98,7 +145,17 @@ def build_triplets(with_preprocessor=False): def build_quadruplets(with_preprocessor=False): - # builds a toy quadruplets problem + """ + For all Quadruplets learners. + + Returns: data, target, preprocessor, to_transform. + + If no preprocesor: 'data' are raw quadruplets, 'target' are dummy labels, + 'preprocesor' is None, and 'to_transform' are points. + + If preprocessor: 'data' are quadruplets indices, 'target' are dummy labels, + 'preprocessor' are unique points, 'to_transform' are points. + """ X, indices = build_data() c = np.column_stack(indices) target = np.ones(c.shape[0]) # quadruplets targets are not used @@ -111,6 +168,7 @@ def build_quadruplets(with_preprocessor=False): # if not, we build a 3D array of quadruplets of samples return Dataset(X[c], target, None, X[c[:, 0]]) +# ------------- List of learners, separating them by kind ------------- quadruplets_learners = [(LSML(), build_quadruplets)] ids_quadruplets_learners = list(map(lambda x: x.__class__.__name__, @@ -166,6 +224,7 @@ def build_quadruplets(with_preprocessor=False): metric_learners_pipeline = pairs_learners + supervised_learners ids_metric_learners_pipeline = ids_pairs_learners + ids_supervised_learners +# ------------- Useful methods ------------- def remove_y(estimator, X, y): """Quadruplets and triplets learners have no y in fit, but to write test for @@ -850,15 +909,14 @@ def test_error_message_t_pair_distance_or_score(estimator, _): .format(make_context(estimator), triplets)) assert str(raised_err.value) == expected_msg - not_implemented_msg = "" - # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says - # "This learner does not have pair_distance" + msg = ("This learner doesn't learn a distance, thus ", + "this method is not implemented. Use pair_score instead") # One exception will trigger for sure with pytest.raises(Exception) as raised_exception: estimator.pair_distance(triplets) err_value = raised_exception.value.args[0] - assert err_value == expected_msg or err_value == not_implemented_msg + assert err_value == expected_msg or err_value == msg def test_preprocess_tuples_simple_example(): @@ -926,7 +984,7 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): estimator_with_prep_formed.set_params(preprocessor=X) estimator_with_prep_formed.fit(*remove_y(estimator, indices_train, y_train)) - # test prediction methods + # Test prediction methods for method in ["predict", "decision_function"]: if hasattr(estimator, method): output_with_prep = getattr(estimator_with_preprocessor, @@ -940,8 +998,9 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): method)(formed_test) assert np.array(output_with_prep == output_with_prep_formed).all() + idx1 = np.array([[0, 2], [5, 3]], dtype=int) # Sample + # Test pair_score, all learners have it. - idx1 = np.array([[0, 2], [5, 3]], dtype=int) output_with_prep = estimator_with_preprocessor.pair_score( indicators_to_transform[idx1]) output_without_prep = estimator_without_preprocessor.pair_score( @@ -974,11 +1033,26 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): except Exception as raised_exception: assert raised_exception.value.args[0] == not_implemented_msg + # TODO: Delete in 0.8.0 + msg = ("score_pairs will be deprecated in release 0.7.0. " + "Use pair_score to compute similarity scores, or " + "pair_distances to compute distances.") + with pytest.warns(FutureWarning) as raised_warning: + output_with_prep = estimator_with_preprocessor.score_pairs( + indicators_to_transform[idx1]) + output_without_prep = estimator_without_preprocessor.score_pairs( + formed_points_to_transform[idx1]) + assert np.array(output_with_prep == output_without_prep).all() + + output_with_prep = estimator_with_preprocessor.score_pairs( + indicators_to_transform[idx1]) + output_without_prep = estimator_with_prep_formed.score_pairs( + formed_points_to_transform[idx1]) + assert np.array(output_with_prep == output_without_prep).all() + assert any([str(warning.message) == msg for warning in raised_warning]) + # Test transform - not_implemented_msg = "" - # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says - # "This learner does not have transform" - try: + if hasattr(estimator, "transform"): output_with_prep = estimator_with_preprocessor.transform( indicators_to_transform) output_without_prep = estimator_without_preprocessor.transform( @@ -991,9 +1065,6 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): formed_points_to_transform) assert np.array(output_with_prep == output_without_prep).all() - except Exception as raised_exception: - assert raised_exception.value.args[0] == not_implemented_msg - def test_check_collapsed_pairs_raises_no_error(): """Checks that check_collapsed_pairs raises no error if no collapsed pairs From 7cc0d5e6a27c63ec61b1bad09f1e01b334928311 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 27 Oct 2021 16:13:24 +0200 Subject: [PATCH 50/57] Learner lists for M and B learners. Separated test by kind. Mock class B weakly learners. Tests refactor. --- metric_learn/base_metric.py | 6 +- test/test_base_metric.py | 12 +- test/test_bilinear_mixin.py | 85 ++++++-- test/test_mahalanobis_mixin.py | 139 +++++++----- test/test_pairs_classifiers.py | 10 +- test/test_quadruplets_classifiers.py | 10 +- test/test_sklearn_compat.py | 63 ++++-- test/test_triplets_classifiers.py | 19 +- test/test_utils.py | 309 ++++++++++++++++++--------- 9 files changed, 431 insertions(+), 222 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index de4ca572..2df9fb6c 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -259,8 +259,8 @@ class BilinearMixin(BaseMetricLearner, metaclass=ABCMeta): """ def score_pairs(self, pairs): - dpr_msg = ("score_pairs will be deprecated in release 0.6.4. " - "Use pair_score to compute similarities, or " + dpr_msg = ("score_pairs will be deprecated in release 0.7.0. " + "Use pair_score to compute similarity scores, or " "pair_distances to compute distances.") warnings.warn(dpr_msg, category=FutureWarning) return self.pair_score(pairs) @@ -305,7 +305,7 @@ def pair_score(self, pairs): :ref:`Bilinear_similarity` : The section of the project documentation that describes Bilinear similarity. """ - check_is_fitted(self, ['preprocessor_', 'components_']) + check_is_fitted(self, ['preprocessor_']) pairs = check_input(pairs, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=2) diff --git a/test/test_base_metric.py b/test/test_base_metric.py index e932c1b8..8b376c74 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -2,7 +2,6 @@ Tests general things from the API: String parsing, methods like get_metric, and deprecation warnings. """ -from numpy.core.numeric import array_equal import pytest import re import unittest @@ -282,23 +281,18 @@ def test_n_components(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_score_pairs_warning(estimator, build_dataset): - """Tests that score_pairs returns a FutureWarning regarding deprecation. - Also that score_pairs and pair_distance have the same behaviour""" + """Tests that score_pairs returns a FutureWarning regarding + deprecation for all learners""" input_data, labels, _, X = build_dataset() model = clone(estimator) set_random_state(model) - - # We fit the metric learner on it and then we call score_pairs on some - # points model.fit(*remove_y(model, input_data, labels)) msg = ("score_pairs will be deprecated in release 0.7.0. " "Use pair_score to compute similarity scores, or " "pair_distances to compute distances.") with pytest.warns(FutureWarning) as raised_warning: - score = model.score_pairs([[X[0], X[1]], ]) - dist = model.pair_distance([[X[0], X[1]], ]) - assert array_equal(score, dist) + _ = model.score_pairs([[X[0], X[1]], ]) assert any([str(warning.message) == msg for warning in raised_warning]) diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index c40650bd..57f9d54a 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -11,57 +11,102 @@ from sklearn.cluster import DBSCAN from sklearn.datasets import make_spd_matrix from sklearn.utils import check_random_state +from metric_learn.base_metric import _PairsClassifierMixin, \ + _TripletsClassifierMixin, _QuadrupletsClassifierMixin RNG = check_random_state(0) -class RadomBilinearMixin(BilinearMixin): + +class RandomBilinearLearner(BilinearMixin): """A simple Random bilinear mixin that returns an random matrix M as learned. Class for testing purposes. """ - def __init__(self, preprocessor=None): + def __init__(self, preprocessor=None, random_state=33): super().__init__(preprocessor=preprocessor) + self.random_state = random_state - def fit(self, X, y, random=False): + def fit(self, X, y): """ - Checks input's format. If random=False, sets M matrix to - identity of shape (d,d) where d is the dimension of the input. - Otherwise, a random (d,d) matrix is set. + Checks input's format. A random (d,d) matrix is set. """ X, y = self._prepare_inputs(X, y, ensure_min_samples=2) - self.d = np.shape(X[0])[-1] - self.components_ = np.random.rand(self.d, self.d) + self.d_ = np.shape(X[0])[-1] + rng = check_random_state(self.random_state) + self.components_ = rng.rand(self.d_, self.d_) return self -class IdentityBilinearMixin(BilinearMixin): +class IdentityBilinearLearner(BilinearMixin): """A simple Identity bilinear mixin that returns an identity matrix M as learned. Class for testing purposes. """ def __init__(self, preprocessor=None): super().__init__(preprocessor=preprocessor) - def fit(self, X, y, random=False): + def fit(self, X, y): """ - Checks input's format. If random=False, sets M matrix to - identity of shape (d,d) where d is the dimension of the input. - Otherwise, a random (d,d) matrix is set. + Checks input's format. Sets M matrix to identity of shape (d,d) + where d is the dimension of the input. """ X, y = self._prepare_inputs(X, y, ensure_min_samples=2) - self.d = np.shape(X[0])[-1] - self.components_ = np.identity(self.d) + self.d_ = np.shape(X[0])[-1] + self.components_ = np.identity(self.d_) + return self + + +class MockPairIdentityBilinearLearner(BilinearMixin, + _PairsClassifierMixin): + + def __init__(self, preprocessor=None): + super().__init__(preprocessor=preprocessor) + + def fit(self, pairs, y, calibration_params=None): + calibration_params = (calibration_params if calibration_params is not + None else dict()) + self._validate_calibration_params(**calibration_params) + pairs = self._prepare_inputs(pairs, type_of_inputs='tuples') + self.d_ = np.shape(pairs[0][0])[-1] + self.components_ = np.identity(self.d_) + self.calibrate_threshold(pairs, y, **calibration_params) + return self + + +class MockTripletsIdentityBilinearLearner(BilinearMixin, + _TripletsClassifierMixin): + + def __init__(self, preprocessor=None): + super().__init__(preprocessor=preprocessor) + + def fit(self, triplets): + triplets = self._prepare_inputs(triplets, type_of_inputs='tuples') + self.d_ = np.shape(triplets[0][0])[-1] + self.components_ = np.identity(self.d_) + return self + + +class MockQuadrpletsIdentityBilinearLearner(BilinearMixin, + _QuadrupletsClassifierMixin): + + def __init__(self, preprocessor=None): + super().__init__(preprocessor=preprocessor) + + def fit(self, quadruplets): + quadruplets = self._prepare_inputs(quadruplets, type_of_inputs='tuples') + self.d_ = np.shape(quadruplets[0][0])[-1] + self.components_ = np.identity(self.d_) return self def identity_fit(d=100, n=100, n_pairs=None, random=False): """ Creates 'n' d-dimentional arrays. Also generates 'n_pairs' - sampled from the 'n' arrays. Fits an IdentityBilinearMixin() + sampled from the 'n' arrays. Fits an IdentityBilinearLearner() and then returns the arrays, the pairs and the mixin. Only generates the pairs if n_pairs is not None. If random=True, the matrix M fitted will be random. """ X = np.array([np.random.rand(d) for _ in range(n)]) - mixin = IdentityBilinearMixin() + mixin = IdentityBilinearLearner() mixin.fit(X, [0 for _ in range(n)]) if n_pairs is not None: random_pairs = [[X[RNG.randint(0, n)], X[RNG.randint(0, n)]] @@ -116,7 +161,7 @@ def test_check_handmade_example(): """ u = np.array([0, 1, 2]) v = np.array([3, 4, 5]) - mixin = IdentityBilinearMixin() + mixin = IdentityBilinearLearner() mixin.fit([u, v], [0, 0]) # Identity fit c = np.array([[2, 4, 6], [6, 4, 2], [1, 2, 3]]) mixin.components_ = c # Force components_ @@ -202,8 +247,8 @@ def test_check_score_pairs_deprecation_and_output(d, n, n_pairs): that the output of score_pairs matches calling pair_score. """ _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - dpr_msg = ("score_pairs will be deprecated in release 0.6.4. " - "Use pair_score to compute similarities, or " + dpr_msg = ("score_pairs will be deprecated in release 0.7.0. " + "Use pair_score to compute similarity scores, or " "pair_distances to compute distances.") with pytest.warns(FutureWarning) as raised_warnings: s1 = mixin.score_pairs(random_pairs) diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index deca3f3b..932dc849 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -9,10 +9,10 @@ from numpy.linalg import LinAlgError from numpy.testing import assert_array_almost_equal, assert_allclose, \ assert_array_equal +from numpy.core.numeric import array_equal from scipy.spatial.distance import pdist, squareform, mahalanobis from scipy.stats import ortho_group from sklearn import clone -from sklearn.cluster import DBSCAN from sklearn.datasets import make_spd_matrix, make_blobs from sklearn.utils import check_random_state, shuffle from sklearn.utils.multiclass import type_of_target @@ -24,16 +24,37 @@ _PairsClassifierMixin) from metric_learn.exceptions import NonPSDError -from test.test_utils import (ids_metric_learners, metric_learners, - remove_y, ids_classifiers, - pairs_learners, ids_pairs_learners) +from test.test_utils import (ids_metric_learners_m, metric_learners_m, + remove_y, ids_classifiers_m, + pairs_learners_m, ids_pairs_learners_m) from sklearn.exceptions import NotFittedError RNG = check_random_state(0) -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) +def test_deprecated_score_pairs_same_result(estimator, build_dataset): + """Test that `pair_distance` and the deprecated function `score_pairs` + give the same result, while checking that the deprecation warning is + being shown""" + input_data, labels, _, X = build_dataset() + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(model, input_data, labels)) + + msg = ("score_pairs will be deprecated in release 0.7.0. " + "Use pair_score to compute similarity scores, or " + "pair_distances to compute distances.") + with pytest.warns(FutureWarning) as raised_warning: + score = model.score_pairs([[X[0], X[1]], ]) + dist = model.pair_distance([[X[0], X[1]], ]) + assert array_equal(score, dist) + assert any([str(warning.message) == msg for warning in raised_warning]) + + +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_pair_distance_pair_score_equivalent(estimator, build_dataset): """ For Mahalanobis learners, pair_score should be equivalent to the @@ -52,8 +73,8 @@ def test_pair_distance_pair_score_equivalent(estimator, build_dataset): assert_array_equal(distances, -1 * scores) -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_pair_distance_pairwise(estimator, build_dataset): """Computing pairwise scores should return a euclidean distance matrix.""" @@ -77,8 +98,8 @@ def test_pair_distance_pairwise(estimator, build_dataset): assert_array_almost_equal(squareform(pairwise), pdist(model.transform(X))) -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_pair_distance_toy_example(estimator, build_dataset): """Checks that `pair_distance` works on a toy example.""" input_data, labels, _, X = build_dataset() @@ -95,8 +116,8 @@ def test_pair_distance_toy_example(estimator, build_dataset): assert_array_almost_equal(model.pair_distance(pairs), distances) -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_pair_distance_finite(estimator, build_dataset): """Tests that the distance from `pair_distance` is finite""" input_data, labels, _, X = build_dataset() @@ -107,8 +128,8 @@ def test_pair_distance_finite(estimator, build_dataset): assert np.isfinite(model.pair_distance(pairs)).all() -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_pair_distance_dim(estimator, build_dataset): """Calling `pair_distance` with 3D arrays should return 1D array (several tuples), and calling `pair_distance` with 2D arrays @@ -141,8 +162,8 @@ def check_is_distance_matrix(pairwise): pairwise[:, np.newaxis, :] + tol).all() -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_embed_toy_example(estimator, build_dataset): """Checks that embed works on a toy example. That using `transform` is equivalent to manually multiplying Lx""" @@ -156,8 +177,8 @@ def test_embed_toy_example(estimator, build_dataset): assert_array_almost_equal(model.transform(X), embedded_points) -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_embed_dim(estimator, build_dataset): """Checks that the the dimension of the output space is as expected""" input_data, labels, _, X = build_dataset() @@ -185,8 +206,8 @@ def test_embed_dim(estimator, build_dataset): assert str(raised_error.value) == err_msg -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_embed_finite(estimator, build_dataset): """Checks that embed (transform) returns vectors with finite values""" input_data, labels, _, X = build_dataset() @@ -196,8 +217,8 @@ def test_embed_finite(estimator, build_dataset): assert np.isfinite(model.transform(X)).all() -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_embed_is_linear(estimator, build_dataset): """Checks that the embedding is linear, i.e. linear properties of using `tranform`""" @@ -212,8 +233,8 @@ def test_embed_is_linear(estimator, build_dataset): 5 * model.transform(X[:10])) -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_get_metric_equivalent_to_explicit_mahalanobis(estimator, build_dataset): """Tests that using the get_metric method of mahalanobis metric learners is @@ -232,8 +253,8 @@ def test_get_metric_equivalent_to_explicit_mahalanobis(estimator, assert_allclose(metric(a, b), expected_dist, rtol=1e-13) -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_get_metric_is_pseudo_metric(estimator, build_dataset): """Tests that the get_metric method of mahalanobis metric learners returns a pseudo-metric (metric but without one side of the equivalence of @@ -259,8 +280,8 @@ def test_get_metric_is_pseudo_metric(estimator, build_dataset): np.isclose(metric(a, c), metric(a, b) + metric(b, c), rtol=1e-20)) -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_get_squared_metric(estimator, build_dataset): """Test that the squared metric returned is indeed the square of the metric""" @@ -279,8 +300,8 @@ def test_get_squared_metric(estimator, build_dataset): rtol=1e-15) -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_components_is_2D(estimator, build_dataset): """Tests that the transformation matrix of metric learners is 2D""" input_data, labels, _, X = build_dataset() @@ -317,13 +338,13 @@ def test_components_is_2D(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', [(ml, bd) for idml, (ml, bd) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if hasattr(ml, 'n_components') and hasattr(ml, 'init')], ids=[idml for idml, (ml, _) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if hasattr(ml, 'n_components') and hasattr(ml, 'init')]) def test_init_transformation(estimator, build_dataset): @@ -410,13 +431,13 @@ def test_init_transformation(estimator, build_dataset): @pytest.mark.parametrize('n_components', [3, 5, 7, 11]) @pytest.mark.parametrize('estimator, build_dataset', [(ml, bd) for idml, (ml, bd) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if hasattr(ml, 'n_components') and hasattr(ml, 'init')], ids=[idml for idml, (ml, _) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if hasattr(ml, 'n_components') and hasattr(ml, 'init')]) def test_auto_init_transformation(n_samples, n_features, n_classes, @@ -459,7 +480,7 @@ def test_auto_init_transformation(n_samples, n_features, n_classes, input_data = input_data[:n_samples, ..., :n_features] assert input_data.shape[0] == n_samples assert input_data.shape[-1] == n_features - has_classes = model_base.__class__.__name__ in ids_classifiers + has_classes = model_base.__class__.__name__ in ids_classifiers_m if has_classes: labels = np.tile(range(n_classes), n_samples // n_classes + 1)[:n_samples] @@ -480,13 +501,13 @@ def test_auto_init_transformation(n_samples, n_features, n_classes, @pytest.mark.parametrize('estimator, build_dataset', [(ml, bd) for idml, (ml, bd) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if not hasattr(ml, 'n_components') and hasattr(ml, 'init')], ids=[idml for idml, (ml, _) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if not hasattr(ml, 'n_components') and hasattr(ml, 'init')]) def test_init_mahalanobis(estimator, build_dataset): @@ -570,12 +591,12 @@ def test_init_mahalanobis(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', [(ml, bd) for idml, (ml, bd) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if idml[:4] in ['ITML', 'SDML', 'LSML']], ids=[idml for idml, (ml, _) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if idml[:4] in ['ITML', 'SDML', 'LSML']]) def test_singular_covariance_init_or_prior_strictpd(estimator, build_dataset): """Tests that when using the 'covariance' init or prior, it returns the @@ -614,12 +635,12 @@ def test_singular_covariance_init_or_prior_strictpd(estimator, build_dataset): @pytest.mark.integration @pytest.mark.parametrize('estimator, build_dataset', [(ml, bd) for idml, (ml, bd) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if idml[:3] in ['MMC']], ids=[idml for idml, (ml, _) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if idml[:3] in ['MMC']]) def test_singular_covariance_init_of_non_strict_pd(estimator, build_dataset): """Tests that when using the 'covariance' init or prior, it returns the @@ -656,12 +677,12 @@ def test_singular_covariance_init_of_non_strict_pd(estimator, build_dataset): @pytest.mark.integration @pytest.mark.parametrize('estimator, build_dataset', [(ml, bd) for idml, (ml, bd) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if idml[:4] in ['ITML', 'SDML', 'LSML']], ids=[idml for idml, (ml, _) - in zip(ids_metric_learners, - metric_learners) + in zip(ids_metric_learners_m, + metric_learners_m) if idml[:4] in ['ITML', 'SDML', 'LSML']]) @pytest.mark.parametrize('w0', [1e-20, 0., -1e-20]) def test_singular_array_init_or_prior_strictpd(estimator, build_dataset, w0): @@ -730,8 +751,8 @@ def test_singular_array_init_of_non_strict_pd(w0): @pytest.mark.integration -@pytest.mark.parametrize('estimator, build_dataset', metric_learners, - ids=ids_metric_learners) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) def test_deterministic_initialization(estimator, build_dataset): """Test that estimators that have a prior or an init are deterministic when it is set to to random and when the random_state is fixed.""" @@ -752,8 +773,8 @@ def test_deterministic_initialization(estimator, build_dataset): @pytest.mark.parametrize('with_preprocessor', [True, False]) -@pytest.mark.parametrize('estimator, build_dataset', pairs_learners, - ids=ids_pairs_learners) +@pytest.mark.parametrize('estimator, build_dataset', pairs_learners_m, + ids=ids_pairs_learners_m) def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, with_preprocessor): """Test that a NotFittedError is raised if someone tries to use diff --git a/test/test_pairs_classifiers.py b/test/test_pairs_classifiers.py index 88e16be1..77ff8bd1 100644 --- a/test/test_pairs_classifiers.py +++ b/test/test_pairs_classifiers.py @@ -14,7 +14,8 @@ precision_score) from sklearn.model_selection import train_test_split -from test.test_utils import pairs_learners, ids_pairs_learners +from test.test_utils import pairs_learners, ids_pairs_learners, \ + pairs_learners_m, ids_pairs_learners_m from metric_learn.sklearn_shims import set_random_state from sklearn import clone import numpy as np @@ -519,11 +520,12 @@ def breaking_fun(**args): # a function that fails so that we will miss assert str(raised_error.value) == expected_msg -@pytest.mark.parametrize('estimator, build_dataset', pairs_learners, - ids=ids_pairs_learners) +@pytest.mark.parametrize('estimator, build_dataset', pairs_learners_m, + ids=ids_pairs_learners_m) def test_accuracy_toy_example(estimator, build_dataset): """Test that the accuracy works on some toy example (hence that the - prediction is OK)""" + prediction is OK). This test is designed for Mahalanobis learners only, + as the toy example uses the notion of distance.""" input_data, labels, preprocessor, X = build_dataset(with_preprocessor=False) estimator = clone(estimator) estimator.set_params(preprocessor=preprocessor) diff --git a/test/test_quadruplets_classifiers.py b/test/test_quadruplets_classifiers.py index afe407ca..65aa9538 100644 --- a/test/test_quadruplets_classifiers.py +++ b/test/test_quadruplets_classifiers.py @@ -6,7 +6,8 @@ from sklearn.exceptions import NotFittedError from sklearn.model_selection import train_test_split -from test.test_utils import quadruplets_learners, ids_quadruplets_learners +from test.test_utils import quadruplets_learners, ids_quadruplets_learners, \ + quadruplets_learners_m, ids_quadruplets_learners_m from metric_learn.sklearn_shims import set_random_state from sklearn import clone import numpy as np @@ -50,11 +51,12 @@ def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, estimator.score(input_data) -@pytest.mark.parametrize('estimator, build_dataset', quadruplets_learners, - ids=ids_quadruplets_learners) +@pytest.mark.parametrize('estimator, build_dataset', quadruplets_learners_m, + ids=ids_quadruplets_learners_m) def test_accuracy_toy_example(estimator, build_dataset): """Test that the default scoring for quadruplets (accuracy) works on some - toy example""" + toy example. This test is designed for Mahalanobis learners only, + as the toy example uses the notion of distance.""" input_data, labels, preprocessor, X = build_dataset(with_preprocessor=False) estimator = clone(estimator) estimator.set_params(preprocessor=preprocessor) diff --git a/test/test_sklearn_compat.py b/test/test_sklearn_compat.py index 6b128bd9..ad94198a 100644 --- a/test/test_sklearn_compat.py +++ b/test/test_sklearn_compat.py @@ -12,10 +12,12 @@ MMC_Supervised, RCA_Supervised, SDML_Supervised, SCML_Supervised) from sklearn import clone +from sklearn.cluster import DBSCAN import numpy as np from sklearn.model_selection import (cross_val_score, cross_val_predict, train_test_split, KFold) from test.test_utils import (metric_learners, ids_metric_learners, + metric_learners_m, ids_metric_learners_m, mock_preprocessor, tuples_learners, ids_tuples_learners, pairs_learners, ids_pairs_learners, remove_y, @@ -107,6 +109,54 @@ def generate_array_like(input_data, labels=None): return input_data_changed, labels_changed +# TODO: Find a better way to run this test and the next one, to avoid +# duplicated code. +@pytest.mark.parametrize('with_preprocessor', [True, False]) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) +def test_array_like_inputs_mahalanobis(estimator, build_dataset, + with_preprocessor): + """Test that metric-learners can have as input any array-like object. + This in particular tests `transform` and `pair_distance` for Mahalanobis + learners.""" + input_data, labels, preprocessor, X = build_dataset(with_preprocessor) + # we subsample the data for the test to be more efficient + input_data, _, labels, _ = train_test_split(input_data, labels, + train_size=40, + random_state=42) + X = X[:10] + + estimator = clone(estimator) + estimator.set_params(preprocessor=preprocessor) + set_random_state(estimator) + input_variants, label_variants = generate_array_like(input_data, labels) + for input_variant in input_variants: + for label_variant in label_variants: + estimator.fit(*remove_y(estimator, input_variant, label_variant)) + if hasattr(estimator, "predict"): + estimator.predict(input_variant) + if hasattr(estimator, "predict_proba"): + estimator.predict_proba(input_variant) # anticipation in case some + # time we have that, or if ppl want to contribute with new algorithms + # it will be checked automatically + if hasattr(estimator, "decision_function"): + estimator.decision_function(input_variant) + if hasattr(estimator, "score"): + for label_variant in label_variants: + estimator.score(*remove_y(estimator, input_variant, label_variant)) + + # Transform + X_variants, _ = generate_array_like(X) + for X_variant in X_variants: + estimator.transform(X_variant) + + # Pair distance + pairs = np.array([[X[0], X[1]], [X[0], X[2]]]) + pairs_variants, _ = generate_array_like(pairs) + for pairs_variant in pairs_variants: + estimator.pair_distance(pairs_variant) + + @pytest.mark.integration @pytest.mark.parametrize('with_preprocessor', [True, False]) @pytest.mark.parametrize('estimator, build_dataset', metric_learners, @@ -141,23 +191,12 @@ def test_array_like_inputs(estimator, build_dataset, with_preprocessor): for label_variant in label_variants: estimator.score(*remove_y(estimator, input_variant, label_variant)) - X_variants, _ = generate_array_like(X) - for X_variant in X_variants: - estimator.transform(X_variant) - pairs = np.array([[X[0], X[1]], [X[0], X[2]]]) pairs_variants, _ = generate_array_like(pairs) - msg = ("This learner doesn't learn a distance, thus ", - "this method is not implemented. Use pair_score instead") - - # Test pair_score and pair_distance when available + # Pair score for pairs_variant in pairs_variants: estimator.pair_score(pairs_variant) - try: - estimator.pair_distance(pairs_variant) - except Exception as raised_exception: - assert raised_exception.value.args[0] == msg @pytest.mark.parametrize('with_preprocessor', [True, False]) diff --git a/test/test_triplets_classifiers.py b/test/test_triplets_classifiers.py index ceebe5a3..942b2f58 100644 --- a/test/test_triplets_classifiers.py +++ b/test/test_triplets_classifiers.py @@ -7,7 +7,8 @@ from sklearn.model_selection import train_test_split import metric_learn -from test.test_utils import triplets_learners, ids_triplets_learners +from test.test_utils import triplets_learners, ids_triplets_learners, \ + triplets_learners_m, ids_triplets_learners_m from metric_learn.sklearn_shims import set_random_state from sklearn import clone import numpy as np @@ -38,13 +39,14 @@ def test_predict_only_one_or_minus_one(estimator, build_dataset, assert len(not_valid) == 0 -@pytest.mark.parametrize('estimator, build_dataset', triplets_learners, - ids=ids_triplets_learners) +@pytest.mark.parametrize('estimator, build_dataset', triplets_learners_m, + ids=ids_triplets_learners_m) def test_no_zero_prediction(estimator, build_dataset): """ Test that all predicted values are not zero, even when the distance d(x,y) and d(x,z) is the same for a triplet of the - form (x, y, z). i.e border cases. + form (x, y, z). i.e border cases for Mahalanobis distance + learners. """ triplets, _, _, X = build_dataset(with_preprocessor=False) # Force 3 dimentions only, to use cross product and get easy orthogonal vec. @@ -73,7 +75,7 @@ def test_no_zero_prediction(estimator, build_dataset): assert_array_equal(X[1], x) with pytest.raises(AssertionError): assert_array_equal(X[1], y) - # Assert the distance is the same for both + # Assert the distance is the same for both -> Wont work for b. similarity assert estimator.get_metric()(X[1], x) == estimator.get_metric()(X[1], y) # Form the three scenarios where predict() gives 0 with numpy.sign @@ -107,11 +109,12 @@ def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, estimator.score(input_data) -@pytest.mark.parametrize('estimator, build_dataset', triplets_learners, - ids=ids_triplets_learners) +@pytest.mark.parametrize('estimator, build_dataset', triplets_learners_m, + ids=ids_triplets_learners_m) def test_accuracy_toy_example(estimator, build_dataset): """Test that the default scoring for triplets (accuracy) works on some - toy example""" + toy example. This test is designed for Mahalanobis learners only, + as the toy example uses the notion of distance.""" triplets, _, _, X = build_dataset(with_preprocessor=False) estimator = clone(estimator) set_random_state(estimator) diff --git a/test/test_utils.py b/test/test_utils.py index b3afd535..8e5d0c10 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,7 +1,7 @@ """ Tests preprocesor, warnings, errors. Also made util functions to build datasets -in a general way for each learner. Here is also the list of learners of each kind -that are used as a parameters in tests in other files. Util functions. +in a general way for each learner. Here is also the list of learners of each +kind that are used as a parameters in tests in other files. Util functions. """ import pytest from scipy.linalg import eigh, pinvh @@ -22,6 +22,9 @@ LMNN, MLKR, NCA, ITML_Supervised, LSML_Supervised, MMC_Supervised, RCA_Supervised, SDML_Supervised, SCML, SCML_Supervised, Constraints) +from test.test_bilinear_mixin import RandomBilinearLearner, \ + IdentityBilinearLearner, MockPairIdentityBilinearLearner, \ + MockTripletsIdentityBilinearLearner, MockQuadrpletsIdentityBilinearLearner from metric_learn.base_metric import (ArrayIndexer, MahalanobisMixin, _PairsClassifierMixin, _TripletsClassifierMixin, @@ -44,11 +47,11 @@ def build_classification(with_preprocessor=False): """ Basic array 'X, y' for testing when using a preprocessor, for instance, - fir clustering. + for clustering. For supervised learners. If no preprocesor: 'data' are raw points, 'target' are dummy labels, 'preprocesor' is None, and 'to_transform' are points. - + If preprocessor: 'data' are point indices, 'target' are dummy labels, 'preprocessor' are unique points, 'to_transform' are points. """ @@ -64,10 +67,11 @@ def build_classification(with_preprocessor=False): def build_regression(with_preprocessor=False): """ Basic array 'X, y' for testing when using a preprocessor, for regression. - + For supervised learners. + If no preprocesor: 'data' are raw points, 'target' are dummy labels, 'preprocesor' is None, and 'to_transform' are points. - + If preprocessor: 'data' are point indices, 'target' are dummy labels, 'preprocessor' are unique points, 'to_transform' are points. """ @@ -97,13 +101,13 @@ def build_data(): def build_pairs(with_preprocessor=False): """ - For all pair learners. + For all pair weakly-supervised learners. Returns: data, target, preprocessor, to_transform. - + If no preprocesor: 'data' are raw pairs, 'target' are dummy labels, 'preprocesor' is None, and 'to_transform' are points. - + If preprocessor: 'data' are pair indices, 'target' are dummy labels, 'preprocessor' are unique points, 'to_transform' are points. """ @@ -122,13 +126,13 @@ def build_pairs(with_preprocessor=False): def build_triplets(with_preprocessor=False): """ - For all triplet learners. - + For all triplet weakly-supervised learners. + Returns: data, target, preprocessor, to_transform. - + If no preprocesor: 'data' are raw triplets, 'target' are dummy labels, 'preprocesor' is None, and 'to_transform' are points. - + If preprocessor: 'data' are triplets indices, 'target' are dummy labels, 'preprocessor' are unique points, 'to_transform' are points. """ @@ -146,13 +150,13 @@ def build_triplets(with_preprocessor=False): def build_quadruplets(with_preprocessor=False): """ - For all Quadruplets learners. - + For all Quadruplets weakly-supervised learners. + Returns: data, target, preprocessor, to_transform. - + If no preprocesor: 'data' are raw quadruplets, 'target' are dummy labels, 'preprocesor' is None, and 'to_transform' are points. - + If preprocessor: 'data' are quadruplets indices, 'target' are dummy labels, 'preprocessor' are unique points, 'to_transform' are points. """ @@ -168,64 +172,133 @@ def build_quadruplets(with_preprocessor=False): # if not, we build a 3D array of quadruplets of samples return Dataset(X[c], target, None, X[c[:, 0]]) + # ------------- List of learners, separating them by kind ------------- -quadruplets_learners = [(LSML(), build_quadruplets)] -ids_quadruplets_learners = list(map(lambda x: x.__class__.__name__, +# Mahalanobis learners +# -- Weakly Supervised +quadruplets_learners_m = [(LSML(), build_quadruplets)] +ids_quadruplets_learners_m = list(map(lambda x: x.__class__.__name__, + [learner for (learner, _) in + quadruplets_learners_m])) + +triplets_learners_m = [(SCML(), build_triplets)] +ids_triplets_learners_m = list(map(lambda x: x.__class__.__name__, + [learner for (learner, _) in + triplets_learners_m])) + +pairs_learners_m = [(ITML(max_iter=2), build_pairs), # max_iter=2 to be faster + (MMC(max_iter=2), build_pairs), # max_iter=2 to be faster + (SDML(prior='identity', balance_param=1e-5), build_pairs)] +ids_pairs_learners_m = list(map(lambda x: x.__class__.__name__, [learner for (learner, _) in - quadruplets_learners])) - -triplets_learners = [(SCML(), build_triplets)] -ids_triplets_learners = list(map(lambda x: x.__class__.__name__, + pairs_learners_m])) + +# -- Supervised +classifiers_m = [(Covariance(), build_classification), + (LFDA(), build_classification), + (LMNN(), build_classification), + (NCA(), build_classification), + (RCA(), build_classification), + (ITML_Supervised(max_iter=5), build_classification), + (LSML_Supervised(), build_classification), + (MMC_Supervised(max_iter=5), build_classification), + (RCA_Supervised(num_chunks=5), build_classification), + (SDML_Supervised(prior='identity', balance_param=1e-5), + build_classification), + (SCML_Supervised(), build_classification)] +ids_classifiers_m = list(map(lambda x: x.__class__.__name__, [learner for (learner, _) in - triplets_learners])) - -pairs_learners = [(ITML(max_iter=2), build_pairs), # max_iter=2 to be faster - (MMC(max_iter=2), build_pairs), # max_iter=2 to be faster - (SDML(prior='identity', balance_param=1e-5), build_pairs)] -ids_pairs_learners = list(map(lambda x: x.__class__.__name__, - [learner for (learner, _) in - pairs_learners])) - -classifiers = [(Covariance(), build_classification), - (LFDA(), build_classification), - (LMNN(), build_classification), - (NCA(), build_classification), - (RCA(), build_classification), - (ITML_Supervised(max_iter=5), build_classification), - (LSML_Supervised(), build_classification), - (MMC_Supervised(max_iter=5), build_classification), - (RCA_Supervised(num_chunks=5), build_classification), - (SDML_Supervised(prior='identity', balance_param=1e-5), - build_classification), - (SCML_Supervised(), build_classification)] -ids_classifiers = list(map(lambda x: x.__class__.__name__, - [learner for (learner, _) in - classifiers])) - -regressors = [(MLKR(init='pca'), build_regression)] -ids_regressors = list(map(lambda x: x.__class__.__name__, - [learner for (learner, _) in regressors])) + classifiers_m])) + +regressors_m = [(MLKR(init='pca'), build_regression)] +ids_regressors_m = list(map(lambda x: x.__class__.__name__, + [learner for (learner, _) in regressors_m])) +# -- Mahalanobis sets +tuples_learners_m = pairs_learners_m + triplets_learners_m + \ + quadruplets_learners_m +ids_tuples_learners_m = ids_pairs_learners_m + ids_triplets_learners_m \ + + ids_quadruplets_learners_m + +supervised_learners_m = classifiers_m + regressors_m +ids_supervised_learners_m = ids_classifiers_m + ids_regressors_m + +metric_learners_m = tuples_learners_m + supervised_learners_m +ids_metric_learners_m = ids_tuples_learners_m + ids_supervised_learners_m + +# Bilinear learners +# -- Weakly Supervised +quadruplets_learners_b = [(MockQuadrpletsIdentityBilinearLearner(), + build_quadruplets)] +ids_quadruplets_learners_b = list(map(lambda x: x.__class__.__name__, + [learner for (learner, _) in + quadruplets_learners_b])) + +triplets_learners_b = [(MockTripletsIdentityBilinearLearner(), build_triplets)] +ids_triplets_learners_b = list(map(lambda x: x.__class__.__name__, + [learner for (learner, _) in + triplets_learners_b])) + +pairs_learners_b = [(MockPairIdentityBilinearLearner(), build_pairs)] +ids_pairs_learners_b = list(map(lambda x: x.__class__.__name__, + [learner for (learner, _) in + pairs_learners_b])) +# -- Supervised +classifiers_b = [(RandomBilinearLearner(), build_classification), + (IdentityBilinearLearner(), build_classification)] +ids_classifiers_b = list(map(lambda x: x.__class__.__name__, + [learner for (learner, _) in + classifiers_b])) +# -- Bilinear sets +tuples_learners_b = pairs_learners_b + triplets_learners_b + \ + quadruplets_learners_b +ids_tuples_learners_b = ids_pairs_learners_b + ids_triplets_learners_b \ + + ids_quadruplets_learners_b + +supervised_learners_b = classifiers_b +ids_supervised_learners_b = ids_classifiers_b + +metric_learners_b = tuples_learners_b + supervised_learners_b +ids_metric_learners_b = ids_tuples_learners_b + ids_supervised_learners_b + +# General sets (Mahalanobis + Bilinear) +# -- Weakly Supervised learners individually +pairs_learners = pairs_learners_m + pairs_learners_b +ids_pairs_learners = ids_pairs_learners_m + ids_pairs_learners_b +triplets_learners = triplets_learners_m + triplets_learners_b +ids_triplets_learners = ids_triplets_learners_m + ids_triplets_learners_b +quadruplets_learners = quadruplets_learners_m + quadruplets_learners_b +ids_quadruplets_learners = ids_quadruplets_learners_m + \ + ids_quadruplets_learners_b + +# -- All weakly supervised learners +tuples_learners = tuples_learners_m + tuples_learners_b +ids_tuples_learners = ids_tuples_learners_m + ids_tuples_learners_b + +# -- Supervised learners +supervised_learners = supervised_learners_m + supervised_learners_b +ids_supervised_learners = ids_supervised_learners_m + ids_supervised_learners_b + +# -- Weakly Supervised + Supervised learners +metric_learners = metric_learners_m + metric_learners_b +ids_metric_learners = ids_metric_learners_m + ids_metric_learners_b + +# -- For sklearn pipeline: Pair + Supervised learners +metric_learners_pipeline = pairs_learners_m + pairs_learners_b + \ + supervised_learners_m + supervised_learners_b +ids_metric_learners_pipeline = ids_pairs_learners_m + ids_pairs_learners_b +\ + ids_supervised_learners_m + \ + ids_supervised_learners_b + +# Not used WeaklySupervisedClasses = (_PairsClassifierMixin, _TripletsClassifierMixin, _QuadrupletsClassifierMixin) -tuples_learners = pairs_learners + triplets_learners + quadruplets_learners -ids_tuples_learners = ids_pairs_learners + ids_triplets_learners \ - + ids_quadruplets_learners - -supervised_learners = classifiers + regressors -ids_supervised_learners = ids_classifiers + ids_regressors - -metric_learners = tuples_learners + supervised_learners -ids_metric_learners = ids_tuples_learners + ids_supervised_learners - -metric_learners_pipeline = pairs_learners + supervised_learners -ids_metric_learners_pipeline = ids_pairs_learners + ids_supervised_learners - # ------------- Useful methods ------------- + def remove_y(estimator, X, y): """Quadruplets and triplets learners have no y in fit, but to write test for all estimators, it is convenient to have this function, that will return X @@ -951,11 +1024,75 @@ def fun(row): assert (preprocess_points(array, fun) == expected_result).all() +# TODO: Find a better way to run this test and the next one, to avoid +# duplicated code. +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, + ids=ids_metric_learners_m) +def test_same_with_or_without_preprocessor_mahalanobis(estimator, + build_dataset): + """Test that Mahalanobis algorithms using a preprocessor behave + consistently with their no-preprocessor equivalent. Methods + `pair_distance` and `transform`. + """ + dataset_indices = build_dataset(with_preprocessor=True) + dataset_formed = build_dataset(with_preprocessor=False) + X = dataset_indices.preprocessor + indicators_to_transform = dataset_indices.to_transform + formed_points_to_transform = dataset_formed.to_transform + (indices_train, indices_test, y_train, y_test, formed_train, + formed_test) = train_test_split(dataset_indices.data, + dataset_indices.target, + dataset_formed.data, + random_state=SEED) + estimator_with_preprocessor = clone(estimator) + set_random_state(estimator_with_preprocessor) + estimator_with_preprocessor.set_params(preprocessor=X) + estimator_with_preprocessor.fit(*remove_y(estimator, indices_train, y_train)) + + estimator_without_preprocessor = clone(estimator) + set_random_state(estimator_without_preprocessor) + estimator_without_preprocessor.set_params(preprocessor=None) + estimator_without_preprocessor.fit(*remove_y(estimator, formed_train, + y_train)) + estimator_with_prep_formed = clone(estimator) + set_random_state(estimator_with_prep_formed) + estimator_with_prep_formed.set_params(preprocessor=X) + estimator_with_prep_formed.fit(*remove_y(estimator, indices_train, y_train)) + idx1 = np.array([[0, 2], [5, 3]], dtype=int) # Sample + + # Pair distance + output_with_prep = estimator_with_preprocessor.pair_distance( + indicators_to_transform[idx1]) + output_without_prep = estimator_without_preprocessor.pair_distance( + formed_points_to_transform[idx1]) + assert np.array(output_with_prep == output_without_prep).all() + + output_with_prep = estimator_with_preprocessor.pair_distance( + indicators_to_transform[idx1]) + output_without_prep = estimator_with_prep_formed.pair_distance( + formed_points_to_transform[idx1]) + assert np.array(output_with_prep == output_without_prep).all() + + # Transform + output_with_prep = estimator_with_preprocessor.transform( + indicators_to_transform) + output_without_prep = estimator_without_preprocessor.transform( + formed_points_to_transform) + assert np.array(output_with_prep == output_without_prep).all() + + output_with_prep = estimator_with_preprocessor.transform( + indicators_to_transform) + output_without_prep = estimator_with_prep_formed.transform( + formed_points_to_transform) + assert np.array(output_with_prep == output_without_prep).all() + + @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_same_with_or_without_preprocessor(estimator, build_dataset): """Test that algorithms using a preprocessor behave consistently -# with their no-preprocessor equivalent + with their no-preprocessor equivalent. Methods `pair_score`, + `score_pairs` (deprecated), `predict` and `decision_function`. """ dataset_indices = build_dataset(with_preprocessor=True) dataset_formed = build_dataset(with_preprocessor=False) @@ -984,7 +1121,7 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): estimator_with_prep_formed.set_params(preprocessor=X) estimator_with_prep_formed.fit(*remove_y(estimator, indices_train, y_train)) - # Test prediction methods + # Test prediction methods for Weakly supervised algorithms. for method in ["predict", "decision_function"]: if hasattr(estimator, method): output_with_prep = getattr(estimator_with_preprocessor, @@ -999,8 +1136,8 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): assert np.array(output_with_prep == output_with_prep_formed).all() idx1 = np.array([[0, 2], [5, 3]], dtype=int) # Sample - - # Test pair_score, all learners have it. + + # Pair score output_with_prep = estimator_with_preprocessor.pair_score( indicators_to_transform[idx1]) output_without_prep = estimator_without_preprocessor.pair_score( @@ -1013,27 +1150,7 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): formed_points_to_transform[idx1]) assert np.array(output_with_prep == output_without_prep).all() - # Test pair_distance - not_implemented_msg = "" - # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says - # "This learner does not have pair_distance" - try: - output_with_prep = estimator_with_preprocessor.pair_distance( - indicators_to_transform[idx1]) - output_without_prep = estimator_without_preprocessor.pair_distance( - formed_points_to_transform[idx1]) - assert np.array(output_with_prep == output_without_prep).all() - - output_with_prep = estimator_with_preprocessor.pair_distance( - indicators_to_transform[idx1]) - output_without_prep = estimator_with_prep_formed.pair_distance( - formed_points_to_transform[idx1]) - assert np.array(output_with_prep == output_without_prep).all() - - except Exception as raised_exception: - assert raised_exception.value.args[0] == not_implemented_msg - - # TODO: Delete in 0.8.0 + # Score pairs. TODO: Delete in 0.8.0 msg = ("score_pairs will be deprecated in release 0.7.0. " "Use pair_score to compute similarity scores, or " "pair_distances to compute distances.") @@ -1051,20 +1168,6 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): assert np.array(output_with_prep == output_without_prep).all() assert any([str(warning.message) == msg for warning in raised_warning]) - # Test transform - if hasattr(estimator, "transform"): - output_with_prep = estimator_with_preprocessor.transform( - indicators_to_transform) - output_without_prep = estimator_without_preprocessor.transform( - formed_points_to_transform) - assert np.array(output_with_prep == output_without_prep).all() - - output_with_prep = estimator_with_preprocessor.transform( - indicators_to_transform) - output_without_prep = estimator_with_prep_formed.transform( - formed_points_to_transform) - assert np.array(output_with_prep == output_without_prep).all() - def test_check_collapsed_pairs_raises_no_error(): """Checks that check_collapsed_pairs raises no error if no collapsed pairs From 5f6bdc2c60fe5ad931b1cf1242af6e02aff1da5d Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Wed, 27 Oct 2021 17:19:45 +0200 Subject: [PATCH 51/57] Moved mocks to test_utils.py, then refactor test_bilinear_mixin.py --- test/test_bilinear_mixin.py | 277 ++++++++++++--------------------- test/test_mahalanobis_mixin.py | 13 +- test/test_utils.py | 88 ++++++++++- 3 files changed, 196 insertions(+), 182 deletions(-) diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index 57f9d54a..b7e3136f 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -3,150 +3,63 @@ warnings, etc. """ from itertools import product -from metric_learn.base_metric import BilinearMixin import numpy as np from numpy.testing import assert_array_almost_equal import pytest from metric_learn._util import make_context -from sklearn.cluster import DBSCAN +from sklearn import clone from sklearn.datasets import make_spd_matrix from sklearn.utils import check_random_state -from metric_learn.base_metric import _PairsClassifierMixin, \ - _TripletsClassifierMixin, _QuadrupletsClassifierMixin +from metric_learn.sklearn_shims import set_random_state +from test.test_utils import metric_learners_b, ids_metric_learners_b, \ + remove_y, IdentityBilinearLearner, build_classification RNG = check_random_state(0) -class RandomBilinearLearner(BilinearMixin): - """A simple Random bilinear mixin that returns an random matrix - M as learned. Class for testing purposes. - """ - def __init__(self, preprocessor=None, random_state=33): - super().__init__(preprocessor=preprocessor) - self.random_state = random_state - - def fit(self, X, y): - """ - Checks input's format. A random (d,d) matrix is set. - """ - X, y = self._prepare_inputs(X, y, ensure_min_samples=2) - self.d_ = np.shape(X[0])[-1] - rng = check_random_state(self.random_state) - self.components_ = rng.rand(self.d_, self.d_) - return self - - -class IdentityBilinearLearner(BilinearMixin): - """A simple Identity bilinear mixin that returns an identity matrix - M as learned. Class for testing purposes. - """ - def __init__(self, preprocessor=None): - super().__init__(preprocessor=preprocessor) - - def fit(self, X, y): - """ - Checks input's format. Sets M matrix to identity of shape (d,d) - where d is the dimension of the input. - """ - X, y = self._prepare_inputs(X, y, ensure_min_samples=2) - self.d_ = np.shape(X[0])[-1] - self.components_ = np.identity(self.d_) - return self - - -class MockPairIdentityBilinearLearner(BilinearMixin, - _PairsClassifierMixin): - - def __init__(self, preprocessor=None): - super().__init__(preprocessor=preprocessor) - - def fit(self, pairs, y, calibration_params=None): - calibration_params = (calibration_params if calibration_params is not - None else dict()) - self._validate_calibration_params(**calibration_params) - pairs = self._prepare_inputs(pairs, type_of_inputs='tuples') - self.d_ = np.shape(pairs[0][0])[-1] - self.components_ = np.identity(self.d_) - self.calibrate_threshold(pairs, y, **calibration_params) - return self - - -class MockTripletsIdentityBilinearLearner(BilinearMixin, - _TripletsClassifierMixin): - - def __init__(self, preprocessor=None): - super().__init__(preprocessor=preprocessor) - - def fit(self, triplets): - triplets = self._prepare_inputs(triplets, type_of_inputs='tuples') - self.d_ = np.shape(triplets[0][0])[-1] - self.components_ = np.identity(self.d_) - return self - - -class MockQuadrpletsIdentityBilinearLearner(BilinearMixin, - _QuadrupletsClassifierMixin): - - def __init__(self, preprocessor=None): - super().__init__(preprocessor=preprocessor) - - def fit(self, quadruplets): - quadruplets = self._prepare_inputs(quadruplets, type_of_inputs='tuples') - self.d_ = np.shape(quadruplets[0][0])[-1] - self.components_ = np.identity(self.d_) - return self - - -def identity_fit(d=100, n=100, n_pairs=None, random=False): - """ - Creates 'n' d-dimentional arrays. Also generates 'n_pairs' - sampled from the 'n' arrays. Fits an IdentityBilinearLearner() - and then returns the arrays, the pairs and the mixin. Only - generates the pairs if n_pairs is not None. If random=True, - the matrix M fitted will be random. - """ - X = np.array([np.random.rand(d) for _ in range(n)]) - mixin = IdentityBilinearLearner() - mixin.fit(X, [0 for _ in range(n)]) - if n_pairs is not None: - random_pairs = [[X[RNG.randint(0, n)], X[RNG.randint(0, n)]] - for _ in range(n_pairs)] - else: - random_pairs = None - return X, random_pairs, mixin - - -@pytest.mark.parametrize('d', [10, 300]) -@pytest.mark.parametrize('n', [10, 100]) -@pytest.mark.parametrize('n_pairs', [100, 1000]) -def test_same_similarity_with_two_methods(d, n, n_pairs): +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_b, + ids=ids_metric_learners_b) +def test_same_similarity_with_two_methods(estimator, build_dataset): """" Tests that pair_score() and get_metric() give consistent results. In both cases, the results must match for the same input. Tests it for 'n_pairs' sampled from 'n' d-dimentional arrays. """ - _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - dist1 = mixin.pair_score(random_pairs) - dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] + input_data, labels, _, X = build_dataset() + n_samples = 20 + X = X[:n_samples] + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(estimator, input_data, labels)) + random_pairs = np.array(list(product(X, X))) + + dist1 = model.pair_score(random_pairs) + dist2 = [model.get_metric()(p[0], p[1]) for p in random_pairs] assert_array_almost_equal(dist1, dist2) -@pytest.mark.parametrize('d', [10, 300]) -@pytest.mark.parametrize('n', [10, 100]) -@pytest.mark.parametrize('n_pairs', [100, 1000]) -def test_check_correctness_similarity(d, n, n_pairs): +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_b, + ids=ids_metric_learners_b) +def test_check_correctness_similarity(estimator, build_dataset): """ Tests the correctness of the results made from socre_paris(), get_metric() and get_bilinear_matrix. Results are compared with the real bilinear similarity calculated in-place. """ - _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - dist1 = mixin.pair_score(random_pairs) - dist2 = [mixin.get_metric()(p[0], p[1]) for p in random_pairs] - dist3 = [np.dot(np.dot(p[0].T, mixin.get_bilinear_matrix()), p[1]) + input_data, labels, _, X = build_dataset() + n_samples = 20 + X = X[:n_samples] + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(estimator, input_data, labels)) + random_pairs = np.array(list(product(X, X))) + + dist1 = model.pair_score(random_pairs) + dist2 = [model.get_metric()(p[0], p[1]) for p in random_pairs] + dist3 = [np.dot(np.dot(p[0].T, model.get_bilinear_matrix()), p[1]) for p in random_pairs] - desired = [np.dot(np.dot(p[0].T, mixin.components_), p[1]) + desired = [np.dot(np.dot(p[0].T, model.components_), p[1]) for p in random_pairs] assert_array_almost_equal(dist1, desired) # pair_score @@ -154,6 +67,8 @@ def test_check_correctness_similarity(d, n, n_pairs): assert_array_almost_equal(dist3, desired) # get_metric +# This is a `hardcoded` handmade tests, to make sure the computation +# made at BilinearMixin is correct. def test_check_handmade_example(): """ Checks that pair_score() result is correct comparing it with a @@ -169,105 +84,119 @@ def test_check_handmade_example(): assert_array_almost_equal(dists, [96, 120]) -@pytest.mark.parametrize('d', [10, 300]) -@pytest.mark.parametrize('n', [10, 100]) -@pytest.mark.parametrize('n_pairs', [100, 1000]) -def test_check_handmade_symmetric_example(d, n, n_pairs): +# Note: This test needs to be `hardcoded` as the similarity martix must +# be symmetric. Running on all Bilinear learners will throw an error as +# the matrix can be non-symmetric. +def test_check_handmade_symmetric_example(): """ When the Bilinear matrix is the identity. The similarity between two arrays must be equal: S(u,v) = S(v,u). Also checks the random case: when the matrix is spd and symetric. """ - _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs) + input_data, labels, _, X = build_classification() + n_samples = 20 + X = X[:n_samples] + model = clone(IdentityBilinearLearner()) # Identity matrix + set_random_state(model) + model.fit(*remove_y(IdentityBilinearLearner(), input_data, labels)) + random_pairs = np.array(list(product(X, X))) + pairs_reverse = [[p[1], p[0]] for p in random_pairs] - dist1 = mixin.pair_score(random_pairs) - dist2 = mixin.pair_score(pairs_reverse) + dist1 = model.pair_score(random_pairs) + dist2 = model.pair_score(pairs_reverse) assert_array_almost_equal(dist1, dist2) # Random pairs for M = spd Matrix - spd_matrix = make_spd_matrix(d, random_state=RNG) - mixin.components_ = spd_matrix - dist1 = mixin.pair_score(random_pairs) - dist2 = mixin.pair_score(pairs_reverse) + spd_matrix = make_spd_matrix(X[0].shape[-1], random_state=RNG) + model.components_ = spd_matrix + dist1 = model.pair_score(random_pairs) + dist2 = model.pair_score(pairs_reverse) assert_array_almost_equal(dist1, dist2) -@pytest.mark.parametrize('d', [10, 300]) -@pytest.mark.parametrize('n', [10, 100]) -@pytest.mark.parametrize('n_pairs', [100, 1000]) -def test_pair_score_finite(d, n, n_pairs): +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_b, + ids=ids_metric_learners_b) +def test_pair_score_finite(estimator, build_dataset): """ Checks for 'n' pair_score() of 'd' dimentions, that all similarities are finite numbers: not NaN, +inf or -inf. Considers a random M for bilinear similarity. """ - _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - dist1 = mixin.pair_score(random_pairs) + input_data, labels, _, X = build_dataset() + n_samples = 20 + X = X[:n_samples] + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(estimator, input_data, labels)) + random_pairs = np.array(list(product(X, X))) + dist1 = model.pair_score(random_pairs) assert np.isfinite(dist1).all() -@pytest.mark.parametrize('d', [10, 300]) -@pytest.mark.parametrize('n', [10, 100]) -def test_pair_score_dim(d, n): +# TODO: This exact test is also in test_mahalanobis_mixin.py. Refactor needed. +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_b, + ids=ids_metric_learners_b) +def test_pair_score_dim(estimator, build_dataset): """ Scoring of 3D arrays should return 1D array (several tuples), and scoring of 2D arrays (one tuple) should return an error (like scikit-learn's error when scoring 1D arrays) """ - X, _, mixin = identity_fit(d=d, n=n, n_pairs=None, random=True) + input_data, labels, _, X = build_dataset() + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(estimator, input_data, labels)) tuples = np.array(list(product(X, X))) - assert mixin.pair_score(tuples).shape == (tuples.shape[0],) - context = make_context(mixin) + assert model.pair_score(tuples).shape == (tuples.shape[0],) + context = make_context(model) msg = ("3D array of formed tuples expected{}. Found 2D array " "instead:\ninput={}. Reshape your data and/or use a preprocessor.\n" .format(context, tuples[1])) with pytest.raises(ValueError) as raised_error: - mixin.pair_score(tuples[1]) + model.pair_score(tuples[1]) assert str(raised_error.value) == msg -@pytest.mark.parametrize('d', [10, 300]) -@pytest.mark.parametrize('n', [10, 100]) -def test_check_scikitlearn_compatibility(d, n): +# Note: Same test in test_mahalanobis_mixin.py, but wuth `pair_distance` there +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_b, + ids=ids_metric_learners_b) +def test_deprecated_score_pairs_same_result(estimator, build_dataset): """ - Check that the similarity returned by get_metric() is compatible with - scikit-learn's algorithms using a custom metric, DBSCAN for instance + Test that `pair_distance` and the deprecated function `score_pairs` + give the same result, while checking that the deprecation warning is + being shown. """ - X, _, mixin = identity_fit(d=d, n=n, n_pairs=None, random=True) - clustering = DBSCAN(metric=mixin.get_metric()) - clustering.fit(X) - + input_data, labels, _, X = build_dataset() + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(model, input_data, labels)) + random_pairs = np.array(list(product(X, X))) -@pytest.mark.parametrize('d', [10, 300]) -@pytest.mark.parametrize('n', [10, 100]) -@pytest.mark.parametrize('n_pairs', [100, 1000]) -def test_check_score_pairs_deprecation_and_output(d, n, n_pairs): - """ - Check that calling score_pairs shows a warning of deprecation, and also - that the output of score_pairs matches calling pair_score. - """ - _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) - dpr_msg = ("score_pairs will be deprecated in release 0.7.0. " - "Use pair_score to compute similarity scores, or " - "pair_distances to compute distances.") + msg = ("score_pairs will be deprecated in release 0.7.0. " + "Use pair_score to compute similarity scores, or " + "pair_distances to compute distances.") with pytest.warns(FutureWarning) as raised_warnings: - s1 = mixin.score_pairs(random_pairs) - s2 = mixin.pair_score(random_pairs) + s1 = model.score_pairs(random_pairs) + s2 = model.pair_score(random_pairs) assert_array_almost_equal(s1, s2) - assert any(str(w.message) == dpr_msg for w in raised_warnings) + assert any(str(w.message) == msg for w in raised_warnings) -@pytest.mark.parametrize('d', [10, 300]) -@pytest.mark.parametrize('n', [10, 100]) -@pytest.mark.parametrize('n_pairs', [100, 1000]) -def test_check_error_with_pair_distance(d, n, n_pairs): +@pytest.mark.parametrize('estimator, build_dataset', metric_learners_b, + ids=ids_metric_learners_b) +def test_check_error_with_pair_distance(estimator, build_dataset): """ - Check that calling pair_distance is not possible with a Bilinear learner. + Check that calling `pair_distance` is not possible with a Bilinear learner. An Exception must be shown instead. """ - _, random_pairs, mixin = identity_fit(d=d, n=n, n_pairs=n_pairs, random=True) + input_data, labels, _, X = build_dataset() + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(model, input_data, labels)) + random_pairs = np.array(list(product(X, X))) + msg = ("This learner doesn't learn a distance, thus ", "this method is not implemented. Use pair_score instead") with pytest.raises(Exception) as e: - _ = mixin.pair_distance(random_pairs) + _ = model.pair_distance(random_pairs) assert e.value.args[0] == msg diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index 932dc849..c784dd23 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -35,22 +35,25 @@ @pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, ids=ids_metric_learners_m) def test_deprecated_score_pairs_same_result(estimator, build_dataset): - """Test that `pair_distance` and the deprecated function `score_pairs` + """ + Test that `pair_distance` and the deprecated function `score_pairs` give the same result, while checking that the deprecation warning is - being shown""" + being shown. + """ input_data, labels, _, X = build_dataset() model = clone(estimator) set_random_state(model) model.fit(*remove_y(model, input_data, labels)) + random_pairs = np.array(list(product(X, X))) msg = ("score_pairs will be deprecated in release 0.7.0. " "Use pair_score to compute similarity scores, or " "pair_distances to compute distances.") with pytest.warns(FutureWarning) as raised_warning: - score = model.score_pairs([[X[0], X[1]], ]) - dist = model.pair_distance([[X[0], X[1]], ]) + score = model.score_pairs(random_pairs) + dist = model.pair_distance(random_pairs) assert array_equal(score, dist) - assert any([str(warning.message) == msg for warning in raised_warning]) + assert any([str(w.message) == msg for w in raised_warning]) @pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, diff --git a/test/test_utils.py b/test/test_utils.py index 8e5d0c10..cd55a352 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -22,10 +22,8 @@ LMNN, MLKR, NCA, ITML_Supervised, LSML_Supervised, MMC_Supervised, RCA_Supervised, SDML_Supervised, SCML, SCML_Supervised, Constraints) -from test.test_bilinear_mixin import RandomBilinearLearner, \ - IdentityBilinearLearner, MockPairIdentityBilinearLearner, \ - MockTripletsIdentityBilinearLearner, MockQuadrpletsIdentityBilinearLearner from metric_learn.base_metric import (ArrayIndexer, MahalanobisMixin, + BilinearMixin, _PairsClassifierMixin, _TripletsClassifierMixin, _QuadrupletsClassifierMixin) @@ -36,6 +34,90 @@ SEED = 42 RNG = check_random_state(SEED) + +# -------------------- Mock classes for testing ------------------------ + + +class RandomBilinearLearner(BilinearMixin): + """A simple Random bilinear mixin that returns an random matrix + M as learned. Class for testing purposes. + """ + def __init__(self, preprocessor=None, random_state=33): + super().__init__(preprocessor=preprocessor) + self.random_state = random_state + + def fit(self, X, y): + """ + Checks input's format. A random (d,d) matrix is set. + """ + X, y = self._prepare_inputs(X, y, ensure_min_samples=2) + self.d_ = np.shape(X[0])[-1] + rng = check_random_state(self.random_state) + self.components_ = rng.rand(self.d_, self.d_) + return self + + +class IdentityBilinearLearner(BilinearMixin): + """A simple Identity bilinear mixin that returns an identity matrix + M as learned. Class for testing purposes. + """ + def __init__(self, preprocessor=None): + super().__init__(preprocessor=preprocessor) + + def fit(self, X, y): + """ + Checks input's format. Sets M matrix to identity of shape (d,d) + where d is the dimension of the input. + """ + X, y = self._prepare_inputs(X, y, ensure_min_samples=2) + self.d_ = np.shape(X[0])[-1] + self.components_ = np.identity(self.d_) + return self + + +class MockPairIdentityBilinearLearner(BilinearMixin, + _PairsClassifierMixin): + + def __init__(self, preprocessor=None): + super().__init__(preprocessor=preprocessor) + + def fit(self, pairs, y, calibration_params=None): + calibration_params = (calibration_params if calibration_params is not + None else dict()) + self._validate_calibration_params(**calibration_params) + pairs = self._prepare_inputs(pairs, type_of_inputs='tuples') + self.d_ = np.shape(pairs[0][0])[-1] + self.components_ = np.identity(self.d_) + self.calibrate_threshold(pairs, y, **calibration_params) + return self + + +class MockTripletsIdentityBilinearLearner(BilinearMixin, + _TripletsClassifierMixin): + + def __init__(self, preprocessor=None): + super().__init__(preprocessor=preprocessor) + + def fit(self, triplets): + triplets = self._prepare_inputs(triplets, type_of_inputs='tuples') + self.d_ = np.shape(triplets[0][0])[-1] + self.components_ = np.identity(self.d_) + return self + + +class MockQuadrpletsIdentityBilinearLearner(BilinearMixin, + _QuadrupletsClassifierMixin): + + def __init__(self, preprocessor=None): + super().__init__(preprocessor=preprocessor) + + def fit(self, quadruplets): + quadruplets = self._prepare_inputs(quadruplets, type_of_inputs='tuples') + self.d_ = np.shape(quadruplets[0][0])[-1] + self.components_ = np.identity(self.d_) + return self + + # ------------------ Building dummy data for learners ------------------ Dataset = namedtuple('Dataset', ('data target preprocessor to_transform')) From 3bf5eaed24344ac93e698d032abb33c4112d2a77 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Mon, 8 Nov 2021 16:30:42 +0100 Subject: [PATCH 52/57] Resolved observations in interoduction.rst --- doc/introduction.rst | 58 +++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/doc/introduction.rst b/doc/introduction.rst index 170a9ec6..a2a6032b 100644 --- a/doc/introduction.rst +++ b/doc/introduction.rst @@ -4,17 +4,16 @@ What is Metric Learning? ======================== -Many approaches in machine learning require a measure of distance between data -points. Traditionally, practitioners would choose a standard distance metric +Many approaches in machine learning require a measure of distance (or similarity) +between data points. Traditionally, practitioners would choose a standard metric (Euclidean, City-Block, Cosine, etc.) using a priori knowledge of the domain. However, it is often difficult to design metrics that are well-suited to the particular data and task of interest. -Distance metric learning (or simply, metric learning) aims at -automatically constructing task-specific distance metrics from (weakly) -supervised data, in a machine learning manner. The learned distance metric can -then be used to perform various tasks (e.g., k-NN classification, clustering, -information retrieval). +Metric learning (or simply, metric learning) aims at automatically constructing +task-specific metrics from (weakly) supervised data, in a machine learning manner. +The learned metric can then be used to perform various tasks (e.g., +k-NN classification, clustering, information retrieval). Problem Setting =============== @@ -25,19 +24,19 @@ of supervision available about the training data: - :doc:`Supervised learning `: the algorithm has access to a set of data points, each of them belonging to a class (label) as in a standard classification problem. - Broadly speaking, the goal in this setting is to learn a distance metric + Broadly speaking, the goal in this setting is to learn a metric that puts points with the same label close together while pushing away points with different labels. - :doc:`Weakly supervised learning `: the algorithm has access to a set of data points with supervision only at the tuple level (typically pairs, triplets, or quadruplets of data points). A classic example of such weaker supervision is a set of - positive and negative pairs: in this case, the goal is to learn a distance + positive and negative pairs: in this case, the goal is to learn a metric that puts positive pairs close together and negative pairs far away. Based on the above (weakly) supervised data, the metric learning problem is generally formulated as an optimization problem where one seeks to find the -parameters of a distance function that optimize some objective function +parameters of a function that optimize some objective function measuring the agreement with the training data. .. _mahalanobis_distances: @@ -81,31 +80,32 @@ necessarily the identity of indiscernibles. .. _bilinear_similarity: -Bilinear Similarity -=================== +Bilinear Similarities +===================== -Some algorithms in the package don't learn a distance or pseudo-distance, but -a similarity. The idea is that two pairs are closer if their similarity value -is high, and viceversa. Given a real-valued parameter matrix :math:`W` of shape +Some algorithms in the package learn bilinear similarity functions. These +similarity functions are not pseudo-distances: they simply output real values +such that the larger the similarity value, the more similar the two examples. +Given a real-valued parameter matrix :math:`W` of shape ``(n_features, n_features)`` where ``n_features`` is the number features -describing the data, the Bilinear Similarity associated with :math:`W` is +describing the data, the bilinear similarity associated with :math:`W` is defined as follows: -.. math:: S_W(x, x') = x^T W x' +.. math:: S_W(x, x') = x^\top W x' -The matrix :math:`W` is not required to be positive semi-definite (PSD), so -none of the distance properties are satisfied: nonnegativity, identity of -indiscernibles, symmetry and triangle inequality. +The matrix :math:`W` is not required to be positive semi-definite (PSD) or +even symmetric, so the distance properties (nonnegativity, identity of +indiscernibles, symmetry and triangle inequality) do not hold in general. This allows some algorithms to optimize :math:`S_W` in an online manner using a simple and efficient procedure, and thus can be applied to problems with millions of training instances and achieves state-of-the-art performance on an image search task using :math:`k`-NN. -It also allows to be applied in contexts where the triangle inequality is -violated by visual judgements and the goal is to approximate perceptual -similarity. For intance, a man and a horse are both similar to a centaur, -but not to one another. +The absence of PSD constraint can enable the design of more efficient +algorithms. It is also relevant in applications where the underlying notion +of similarity does not satisfy the triangle inequality, as known to be the +case for visual judgments. .. _use_cases: @@ -127,13 +127,9 @@ examples (for code illustrating some of these use-cases, see the elements of a database that are semantically closest to a query element. - Dimensionality reduction: metric learning may be seen as a way to reduce the data dimension in a (weakly) supervised setting. -- More generally, the learned transformation :math:`L` can be used to project - the data into a new embedding space before feeding it into another machine - learning algorithm. - -The most common use-case of metric-learn would be to learn a Mahalanobis metric, -then transform the data to the learned space, and then resolve one of the task -above. +- More generally with Mahalanobis distances, the learned transformation :math:`L` + can be used to project the data into a new embedding space before feeding it + into another machine learning algorithm. The API of metric-learn is compatible with `scikit-learn `_, the leading library for machine From acfd54bfe0037ad8c164921bf3d6d62be3a56853 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Mon, 8 Nov 2021 18:36:47 +0100 Subject: [PATCH 53/57] Resolved all observations for supervised.rst and weakly_s.rst --- doc/supervised.rst | 104 +++++++++++++++----------------------- doc/weakly_supervised.rst | 104 +++++++++++++++----------------------- 2 files changed, 84 insertions(+), 124 deletions(-) diff --git a/doc/supervised.rst b/doc/supervised.rst index 4ed78589..80f847a0 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -41,17 +41,13 @@ two numbers. Fit, transform, and so on ------------------------- -Generally, the goal of supervised metric-learning algorithms is to transform -points in a new space, in which the distance between two points from the -same class will be small, and the distance between two points from different -classes will be large. +The goal of supervised metric learning algorithms is to learn a (distance or +similarity) metric such that two points from the same class will be similar +(e.g., have small distance) and points from different classes will be dissimilar +(e.g., have large distance). -But there are also some algorithms that learn a similarity, not a distance, -thus the points cannot be transformed into a new space. In this case, the -utility comes at using the similarity bewtween points directly. - -Mahalanobis learners can transform points into a new space, and to do so, -we fit the metric learner (example:`NCA`). +To do so, we first need to fit the supervised metric learner on a labeled dataset, +as in the example below with ``NCA``. >>> from metric_learn import NCA >>> nca = NCA(random_state=42) @@ -62,87 +58,71 @@ NCA(init='auto', max_iter=100, n_components=None, Now that the estimator is fitted, you can use it on new data for several purposes. -First, your mahalanobis learner can transform the data in -the learned space, using `transform`: Here we transform two points in -the new embedding space. - ->>> X_new = np.array([[9.4, 4.1], [2.1, 4.4]]) ->>> nca.transform(X_new) -array([[ 5.91884732, 10.25406973], - [ 3.1545886 , 6.80350083]]) - -.. warning:: - - If you try to use `transform` with a similarity learner, an error will - appear, as you cannot transform the data using them. - -Also, as explained before, mahalanobis metric learners learn a distance -between points. You can use this distance in two main ways: - -- You can either return the distance between pairs of points using the - `pair_distance` function: - ->>> nca.pair_distance([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) -array([0.49627072, 3.65287282, 6.06079877]) - -- Or you can return a function that will return the distance (in the new - space) between two 1D arrays (the coordinates of the points in the original - space), similarly to distance functions in `scipy.spatial.distance`. To - do that, use the `get_metric` method. - ->>> metric_fun = nca.get_metric() ->>> metric_fun([3.5, 3.6], [5.6, 2.4]) -0.4962707194621285 - -- Alternatively, you can use `pair_score` to return the **score** between - pairs of points (the larger the score, the more similar the pair). - For Mahalanobis learners, it is equal to the opposite of the distance. +We can now use the learned metric to **score** new pairs of points with ``pair_score`` +(the larger the score, the more similar the pair). For Mahalanobis learners, +it is equal to the opposite of the distance. >>> score = nca.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) >>> score array([-0.49627072, -3.65287282, -6.06079877]) -This is useful because `pair_score` matches the **score** semantic of +This is useful because ``pair_score`` matches the **score** semantic of scikit-learn's `Classification metrics `_. -For similarity learners `pair_distance` is not available, as they don't learn -a distance. Intead you use `pair_score` that has the same behaviour but -for similarity. +For metric learners that learn a distance metric, there is also the ``pair_distance`` +method. ->>> algorithm.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) -array([-0.2312, 705.23, -72.8]) +>>> nca.pair_distance([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +array([0.49627072, 3.65287282, 6.06079877]) .. warning:: - If you try to use `pair_distance` with a similarity learner, an error - will appear, as they don't learn a distance nor a pseudo-distance. + If you try to use ``pair_distance`` with a bilinear similarity learner, an error + will be thrown, as it does not learn a distance. + +You can also return a function that will return the metric learned. It can +compute the metric between two 1D arrays, similarly to distance functions in +`scipy.spatial.distance`. To do that, use the ``get_metric`` method. + +>>> metric_fun = nca.get_metric() +>>> metric_fun([3.5, 3.6], [5.6, 2.4]) +0.4962707194621285 -You can also call `get_metric` with similarity learners, and you will get +You can also call ``get_metric`` with bilinear similarity learners, and you will get a function that will return the similarity bewtween 1D arrays. >>> similarity_fun = algorithm.get_metric() >>> similarity_fun([3.5, 3.6], [5.6, 2.4]) -0.04752 -For similarity learners and mahalanobis learners, `pair_score` is -available. You can interpret that this function returns the **score** -between points: the more the **score**, the closer the pairs and vice-versa. -For mahalanobis learners, it is equal to the inverse of the distance. +Finally, as explained in :ref:`mahalanobis_distances`, these are equivalent to the Euclidean +distance in a transformed space, and can thus be used to transform data points in +a new embedding space. You can use ``transform`` to do so. + +>>> X_new = np.array([[9.4, 4.1], [2.1, 4.4]]) +>>> nca.transform(X_new) +array([[ 5.91884732, 10.25406973], + [ 3.1545886 , 6.80350083]]) + +.. warning:: + + If you try to use ``transform`` with a bilinear similarity learner, an error will + be thrown, as you cannot transform the data using them. .. note:: If the metric learner that you use learns a :ref:`Mahalanobis distance - `, you can get the plain learned Mahalanobis - matrix using `get_mahalanobis_matrix`. + `, you can get the learned Mahalanobis + matrix :math:`M` using `get_mahalanobis_matrix`. >>> nca.get_mahalanobis_matrix() array([[0.43680409, 0.89169412], [0.89169412, 1.9542479 ]]) - If the metric learner that you use learns a :ref:`Bilinear similarity + If the metric learner that you use learns a :ref:`bilinear similarity `, you can get the plain learned Bilinear - matrix using `get_bilinear_matrix`. + matrix :math:`W` using `get_bilinear_matrix`. >>> algorithm.get_bilinear_matrix() array([[-0.72680409, -0.153213], diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index 3da4745a..296498f4 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -127,16 +127,12 @@ through the argument `preprocessor` (see below :ref:`fit_ws`) Fit, transform, and so on ------------------------- -Generally, the goal of weakly-supervised metric-learning algorithms is to -transform points in a new space, in which the tuple-wise constraints between -points are respected. +The goal of weakly supervised metric learning algorithms is to learn a (distance +or similarity) metric such that the tuple-wise constraints between points are +respected. -But there are also some algorithms that learn a similarity, not a distance, -thus the points cannot be transformed into a new space. But the goal is the -same: respect the maximum number of constraints while learning the similarity. - -Weakly-supervised mahalanobis learners can transform points into a new space, -and to do so, we fit the metric learner (example:`MMC`). +To do so, we first need to fit the weakly supervised metric learner on a dataset +of tuples, as in the example below with ``MMC``. >>> from metric_learn import MMC >>> mmc = MMC(random_state=42) @@ -154,89 +150,73 @@ Or alternatively (using a preprocessor): Now that the estimator is fitted, you can use it on new data for several purposes. -First, your mahalanobis learner can transform the data in -the learned space, using `transform`: Here we transform two points in -the new embedding space. +We can now use the learned metric to **score** new pairs of points with ``pair_score`` +(the larger the score, the more similar the pair). For Mahalanobis learners, +it is equal to the opposite of the distance. ->>> X_new = np.array([[9.4, 4.1, 4.2], [2.1, 4.4, 2.3]]) ->>> mmc.transform(X_new) -array([[-3.24667162e+01, 4.62622348e-07, 3.88325421e-08], - [-3.61531114e+01, 4.86778289e-07, 2.12654397e-08]]) - -.. warning:: - - If you try to use `transform` with a similarity learner, an error will - appear, as you cannot transform the data using them. +>>> score = mmc.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +>>> score +array([-0.49627072, -3.65287282, -6.06079877]) -Also, as explained before, our metric learner has learned a distance between -points. You can use this distance in two main ways: +This is useful because ``pair_score`` matches the **score** semantic of +scikit-learn's `Classification metrics +`_. -- You can either return the distance between pairs of points using the - `pair_distance` function: +For metric learners that learn a distance metric, there is also the ``pair_distance`` +method. >>> mmc.pair_distance([[[3.5, 3.6, 5.2], [5.6, 2.4, 6.7]], ... [[1.2, 4.2, 7.7], [2.1, 6.4, 0.9]]]) array([7.27607365, 0.88853014]) -- Or you can return a function that will return the distance (in the new - space) between two 1D arrays (the coordinates of the points in the original - space), similarly to distance functions in `scipy.spatial.distance`. To - do that, use the `get_metric` method. +.. warning:: + + If you try to use ``pair_distance`` with a bilinear similarity learner, an error + will be thrown, as it does not learn a distance. + +You can also return a function that will return the metric learned. It can +compute the metric between two 1D arrays, similarly to distance functions in +`scipy.spatial.distance`. To do that, use the ``get_metric`` method. >>> metric_fun = mmc.get_metric() >>> metric_fun([3.5, 3.6, 5.2], [5.6, 2.4, 6.7]) 7.276073646278203 -- Alternatively, you can use `pair_score` to return the **score** between - pairs of points (the larger the score, the more similar the pair). - For Mahalanobis learners, it is equal to the opposite of the distance. - ->>> score = mmc.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) ->>> score -array([-0.49627072, -3.65287282, -6.06079877]) - - This is useful because `pair_score` matches the **score** semantic of - scikit-learn's `Classification metrics - `_. - -For similarity learners `pair_distance` is not available, as they don't learn -a distance. Intead you use `pair_score` that has the same behaviour but -for similarity. - ->>> algorithm.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) -array([-0.2312, 705.23, -72.8]) - -.. warning:: - - If you try to use `pair_distance` with a similarity learner, an error - will appear, as they don't learn a distance nor a pseudo-distance. - -You can also call `get_metric` with similarity learners, and you will get +You can also call ``get_metric``` with bilinear similarity learners, and you will get a function that will return the similarity bewtween 1D arrays. >>> similarity_fun = algorithm.get_metric() >>> similarity_fun([3.5, 3.6], [5.6, 2.4]) -0.04752 -For similarity learners and mahalanobis learners, `pair_score` is -available. You can interpret that this function returns the **score** -between points: the more the **score**, the closer the pairs and vice-versa. -For mahalanobis learners, it is equal to the inverse of the distance. +Finally, as explained in :ref:`mahalanobis_distances`, these are equivalent to the Euclidean +distance in a transformed space, and can thus be used to transform data points in +a new embedding space. You can use ``transform`` to do so. + +>>> X_new = np.array([[9.4, 4.1, 4.2], [2.1, 4.4, 2.3]]) +>>> mmc.transform(X_new) +array([[-3.24667162e+01, 4.62622348e-07, 3.88325421e-08], + [-3.61531114e+01, 4.86778289e-07, 2.12654397e-08]]) + +.. warning:: + + If you try to use ``transform`` with a bilinear similarity learner, an error will + be thrown, as you cannot transform the data using them. .. note:: If the metric learner that you use learns a :ref:`Mahalanobis distance `, you can get the plain learned Mahalanobis - matrix using `get_mahalanobis_matrix`. + matrix :math:`M` using `get_mahalanobis_matrix`. >>> mmc.get_mahalanobis_matrix() array([[ 0.58603894, -5.69883982, -1.66614919], [-5.69883982, 55.41743549, 16.20219519], [-1.66614919, 16.20219519, 4.73697721]]) - If the metric learner that you use learns a :ref:`Bilinear similarity - `, you can get the plain learned Bilinear - matrix using `get_bilinear_matrix`. + If the metric learner that you use learns a :ref:`bilinear similarity + `, you can get the learned Bilinear + matrix :math:`W` using `get_bilinear_matrix`. >>> algorithm.get_bilinear_matrix() array([[-0.72680409, -0.153213], From 69bd9feede861639319c754d5835333619c09d7f Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Tue, 9 Nov 2021 11:26:22 +0100 Subject: [PATCH 54/57] Spellcheck --- doc/supervised.rst | 10 +++++----- doc/weakly_supervised.rst | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/doc/supervised.rst b/doc/supervised.rst index 80f847a0..04a3233b 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -90,7 +90,7 @@ compute the metric between two 1D arrays, similarly to distance functions in 0.4962707194621285 You can also call ``get_metric`` with bilinear similarity learners, and you will get -a function that will return the similarity bewtween 1D arrays. +a function that will return the similarity between 1D arrays. >>> similarity_fun = algorithm.get_metric() >>> similarity_fun([3.5, 3.6], [5.6, 2.4]) @@ -139,7 +139,7 @@ All supervised algorithms are scikit-learn estimators scikit-learn model selection routines (`sklearn.model_selection.cross_val_score`, `sklearn.model_selection.GridSearchCV`, etc). -You can also use some of the scoring functions from `sklearn.metrics`. +You can also use some scoring functions from `sklearn.metrics`. Algorithms ========== @@ -271,12 +271,12 @@ the sum of probability of being correctly classified: Local Fisher Discriminant Analysis (:py:class:`LFDA `) `LFDA` is a linear supervised dimensionality reduction method which effectively combines the ideas of `Linear Discriminant Analysis ` and Locality-Preserving Projection . It is -particularly useful when dealing with multi-modality, where one ore more classes +particularly useful when dealing with multi-modality, where one or more classes consist of separate clusters in input space. The core optimization problem of LFDA is solved as a generalized eigenvalue problem. -The algorithm define the Fisher local within-/between-class scatter matrix +The algorithm defines the Fisher local within-/between-class scatter matrix :math:`\mathbf{S}^{(w)}/ \mathbf{S}^{(b)}` in a pairwise fashion: .. math:: @@ -431,7 +431,7 @@ method will look at all the samples from a different class and sample randomly a pair among them. The method will try to build `num_constraints` positive pairs and `num_constraints` negative pairs, but sometimes it cannot find enough of one of those, so forcing `same_length=True` will return both times the -minimum of the two lenghts. +minimum of the two lengths. For using quadruplets learners (see :ref:`learning_on_quadruplets`) in a supervised way, positive and negative pairs are sampled as above and diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index 296498f4..e9eeb70d 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -79,11 +79,13 @@ the number of features of each point. >>> [-2.16, +0.11, -0.02]]]) # same as tuples[1, 0, :] >>> y = np.array([-1, 1, 1, -1]) -.. warning:: This way of specifying pairs is not recommended for a large number - of tuples, as it is redundant (see the comments in the example) and hence - takes a lot of memory. Indeed each feature vector of a point will be - replicated as many times as a point is involved in a tuple. The second way - to specify pairs is more efficient +.. warning:: + + This way of specifying pairs is not recommended for a large number + of tuples, as it is redundant (see the comments in the example) and hence + takes a lot of memory. Indeed, each feature vector of a point will be + replicated as many times as a point is involved in a tuple. The second way + to specify pairs is more efficient 2D array of indicators + preprocessor @@ -145,7 +147,7 @@ Or alternatively (using a preprocessor): >>> from metric_learn import MMC >>> mmc = MMC(preprocessor=X, random_state=42) ->>> mmc.fit(pairs_indice, y) +>>> mmc.fit(pairs_indices, y) Now that the estimator is fitted, you can use it on new data for several purposes. @@ -183,7 +185,7 @@ compute the metric between two 1D arrays, similarly to distance functions in 7.276073646278203 You can also call ``get_metric``` with bilinear similarity learners, and you will get -a function that will return the similarity bewtween 1D arrays. +a function that will return the similarity between 1D arrays. >>> similarity_fun = algorithm.get_metric() >>> similarity_fun([3.5, 3.6], [5.6, 2.4]) @@ -474,7 +476,7 @@ Mahalanobis matrix :math:`\mathbf{M}`, and a log-determinant divergence between or :math:`\mathbf{\Omega}^{-1}`, where :math:`\mathbf{\Omega}` is the covariance matrix). -The formulated optimization on the semidefinite matrix :math:`\mathbf{M}` +The formulated optimization on the semi-definite matrix :math:`\mathbf{M}` is convex: .. math:: From ade34ccb49f19c063c88fedfe37d17f086c8190d Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Tue, 9 Nov 2021 12:07:00 +0100 Subject: [PATCH 55/57] Moved common test to test_base_metric.py . Refactor preprocessor test to avoid code duplication, trade-off with isinstance(). Added SCML basis that got lost in the list. --- test/test_base_metric.py | 55 ++++++++++++++++++++ test/test_bilinear_mixin.py | 50 ------------------ test/test_mahalanobis_mixin.py | 47 ----------------- test/test_utils.py | 94 +++++++++++----------------------- 4 files changed, 84 insertions(+), 162 deletions(-) diff --git a/test/test_base_metric.py b/test/test_base_metric.py index 8b376c74..d98c3c45 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -7,9 +7,13 @@ import unittest import metric_learn import numpy as np +from numpy.testing import assert_array_equal +from itertools import product from sklearn import clone from test.test_utils import ids_metric_learners, metric_learners, remove_y from metric_learn.sklearn_shims import set_random_state, SKLEARN_AT_LEAST_0_22 +from metric_learn._util import make_context +from metric_learn.base_metric import MahalanobisMixin, BilinearMixin def remove_spaces(s): @@ -296,5 +300,56 @@ def test_score_pairs_warning(estimator, build_dataset): assert any([str(warning.message) == msg for warning in raised_warning]) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners, + ids=ids_metric_learners) +def test_pair_score_dim(estimator, build_dataset): + """ + Scoring of 3D arrays should return 1D array (several tuples), + and scoring of 2D arrays (one tuple) should return an error (like + scikit-learn's error when scoring 1D arrays) + """ + input_data, labels, _, X = build_dataset() + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(estimator, input_data, labels)) + tuples = np.array(list(product(X, X))) + assert model.pair_score(tuples).shape == (tuples.shape[0],) + context = make_context(model) + msg = ("3D array of formed tuples expected{}. Found 2D array " + "instead:\ninput={}. Reshape your data and/or use a preprocessor.\n" + .format(context, tuples[1])) + with pytest.raises(ValueError) as raised_error: + model.pair_score(tuples[1]) + assert str(raised_error.value) == msg + + +@pytest.mark.parametrize('estimator, build_dataset', metric_learners, + ids=ids_metric_learners) +def test_deprecated_score_pairs_same_result(estimator, build_dataset): + """ + Test that `pari_distance` gives the same result as `score_pairs` for + Mahalanobis learnes, and the same for `pair_score` and `score_paris` + for Bilinear learners. It also checks that the deprecation warning of + `score_pairs` is being shown. + """ + input_data, labels, _, X = build_dataset() + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(model, input_data, labels)) + random_pairs = np.array(list(product(X, X))) + + msg = ("score_pairs will be deprecated in release 0.7.0. " + "Use pair_score to compute similarity scores, or " + "pair_distances to compute distances.") + with pytest.warns(FutureWarning) as raised_warnings: + s1 = model.score_pairs(random_pairs) + if isinstance(model, BilinearMixin): + s2 = model.pair_score(random_pairs) + elif isinstance(model, MahalanobisMixin): + s2 = model.pair_distance(random_pairs) + assert_array_equal(s1, s2) + assert any(str(w.message) == msg for w in raised_warnings) + + if __name__ == '__main__': unittest.main() diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index b7e3136f..1e715e0b 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -6,7 +6,6 @@ import numpy as np from numpy.testing import assert_array_almost_equal import pytest -from metric_learn._util import make_context from sklearn import clone from sklearn.datasets import make_spd_matrix from sklearn.utils import check_random_state @@ -133,55 +132,6 @@ def test_pair_score_finite(estimator, build_dataset): assert np.isfinite(dist1).all() -# TODO: This exact test is also in test_mahalanobis_mixin.py. Refactor needed. -@pytest.mark.parametrize('estimator, build_dataset', metric_learners_b, - ids=ids_metric_learners_b) -def test_pair_score_dim(estimator, build_dataset): - """ - Scoring of 3D arrays should return 1D array (several tuples), - and scoring of 2D arrays (one tuple) should return an error (like - scikit-learn's error when scoring 1D arrays) - """ - input_data, labels, _, X = build_dataset() - model = clone(estimator) - set_random_state(model) - model.fit(*remove_y(estimator, input_data, labels)) - tuples = np.array(list(product(X, X))) - assert model.pair_score(tuples).shape == (tuples.shape[0],) - context = make_context(model) - msg = ("3D array of formed tuples expected{}. Found 2D array " - "instead:\ninput={}. Reshape your data and/or use a preprocessor.\n" - .format(context, tuples[1])) - with pytest.raises(ValueError) as raised_error: - model.pair_score(tuples[1]) - assert str(raised_error.value) == msg - - -# Note: Same test in test_mahalanobis_mixin.py, but wuth `pair_distance` there -@pytest.mark.parametrize('estimator, build_dataset', metric_learners_b, - ids=ids_metric_learners_b) -def test_deprecated_score_pairs_same_result(estimator, build_dataset): - """ - Test that `pair_distance` and the deprecated function `score_pairs` - give the same result, while checking that the deprecation warning is - being shown. - """ - input_data, labels, _, X = build_dataset() - model = clone(estimator) - set_random_state(model) - model.fit(*remove_y(model, input_data, labels)) - random_pairs = np.array(list(product(X, X))) - - msg = ("score_pairs will be deprecated in release 0.7.0. " - "Use pair_score to compute similarity scores, or " - "pair_distances to compute distances.") - with pytest.warns(FutureWarning) as raised_warnings: - s1 = model.score_pairs(random_pairs) - s2 = model.pair_score(random_pairs) - assert_array_almost_equal(s1, s2) - assert any(str(w.message) == msg for w in raised_warnings) - - @pytest.mark.parametrize('estimator, build_dataset', metric_learners_b, ids=ids_metric_learners_b) def test_check_error_with_pair_distance(estimator, build_dataset): diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index 9f103340..f5a38b25 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -9,7 +9,6 @@ from numpy.linalg import LinAlgError from numpy.testing import assert_array_almost_equal, assert_allclose, \ assert_array_equal -from numpy.core.numeric import array_equal from scipy.spatial.distance import pdist, squareform, mahalanobis from scipy.stats import ortho_group from sklearn import clone @@ -32,30 +31,6 @@ RNG = check_random_state(0) -@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, - ids=ids_metric_learners_m) -def test_deprecated_score_pairs_same_result(estimator, build_dataset): - """ - Test that `pair_distance` and the deprecated function `score_pairs` - give the same result, while checking that the deprecation warning is - being shown. - """ - input_data, labels, _, X = build_dataset() - model = clone(estimator) - set_random_state(model) - model.fit(*remove_y(model, input_data, labels)) - random_pairs = np.array(list(product(X, X))) - - msg = ("score_pairs will be deprecated in release 0.7.0. " - "Use pair_score to compute similarity scores, or " - "pair_distances to compute distances.") - with pytest.warns(FutureWarning) as raised_warning: - score = model.score_pairs(random_pairs) - dist = model.pair_distance(random_pairs) - assert array_equal(score, dist) - assert any([str(w.message) == msg for w in raised_warning]) - - @pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, ids=ids_metric_learners_m) def test_pair_distance_pair_score_equivalent(estimator, build_dataset): @@ -131,28 +106,6 @@ def test_pair_distance_finite(estimator, build_dataset): assert np.isfinite(model.pair_distance(pairs)).all() -@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, - ids=ids_metric_learners_m) -def test_pair_distance_dim(estimator, build_dataset): - """Calling `pair_distance` with 3D arrays should return 1D array - (several tuples), and calling `pair_distance` with 2D arrays - (one tuple) should return an error (like scikit-learn's error when - scoring 1D arrays)""" - input_data, labels, _, X = build_dataset() - model = clone(estimator) - set_random_state(model) - model.fit(*remove_y(estimator, input_data, labels)) - tuples = np.array(list(product(X, X))) - assert model.pair_distance(tuples).shape == (tuples.shape[0],) - context = make_context(estimator) - msg = ("3D array of formed tuples expected{}. Found 2D array " - "instead:\ninput={}. Reshape your data and/or use a preprocessor.\n" - .format(context, tuples[1])) - with pytest.raises(ValueError) as raised_error: - model.pair_distance(tuples[1]) - assert str(raised_error.value) == msg - - def check_is_distance_matrix(pairwise): """Returns True if the matrix is positive, symmetrc, the diagonal is zero, and if it fullfills the triangular inequality for all pairs""" diff --git a/test/test_utils.py b/test/test_utils.py index cd55a352..5dc598a8 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -264,7 +264,7 @@ def build_quadruplets(with_preprocessor=False): [learner for (learner, _) in quadruplets_learners_m])) -triplets_learners_m = [(SCML(), build_triplets)] +triplets_learners_m = [(SCML(n_basis=320), build_triplets)] ids_triplets_learners_m = list(map(lambda x: x.__class__.__name__, [learner for (learner, _) in triplets_learners_m])) @@ -288,7 +288,7 @@ def build_quadruplets(with_preprocessor=False): (RCA_Supervised(num_chunks=5), build_classification), (SDML_Supervised(prior='identity', balance_param=1e-5), build_classification), - (SCML_Supervised(), build_classification)] + (SCML_Supervised(n_basis=80), build_classification)] ids_classifiers_m = list(map(lambda x: x.__class__.__name__, [learner for (learner, _) in classifiers_m])) @@ -1106,69 +1106,6 @@ def fun(row): assert (preprocess_points(array, fun) == expected_result).all() -# TODO: Find a better way to run this test and the next one, to avoid -# duplicated code. -@pytest.mark.parametrize('estimator, build_dataset', metric_learners_m, - ids=ids_metric_learners_m) -def test_same_with_or_without_preprocessor_mahalanobis(estimator, - build_dataset): - """Test that Mahalanobis algorithms using a preprocessor behave - consistently with their no-preprocessor equivalent. Methods - `pair_distance` and `transform`. - """ - dataset_indices = build_dataset(with_preprocessor=True) - dataset_formed = build_dataset(with_preprocessor=False) - X = dataset_indices.preprocessor - indicators_to_transform = dataset_indices.to_transform - formed_points_to_transform = dataset_formed.to_transform - (indices_train, indices_test, y_train, y_test, formed_train, - formed_test) = train_test_split(dataset_indices.data, - dataset_indices.target, - dataset_formed.data, - random_state=SEED) - estimator_with_preprocessor = clone(estimator) - set_random_state(estimator_with_preprocessor) - estimator_with_preprocessor.set_params(preprocessor=X) - estimator_with_preprocessor.fit(*remove_y(estimator, indices_train, y_train)) - - estimator_without_preprocessor = clone(estimator) - set_random_state(estimator_without_preprocessor) - estimator_without_preprocessor.set_params(preprocessor=None) - estimator_without_preprocessor.fit(*remove_y(estimator, formed_train, - y_train)) - estimator_with_prep_formed = clone(estimator) - set_random_state(estimator_with_prep_formed) - estimator_with_prep_formed.set_params(preprocessor=X) - estimator_with_prep_formed.fit(*remove_y(estimator, indices_train, y_train)) - idx1 = np.array([[0, 2], [5, 3]], dtype=int) # Sample - - # Pair distance - output_with_prep = estimator_with_preprocessor.pair_distance( - indicators_to_transform[idx1]) - output_without_prep = estimator_without_preprocessor.pair_distance( - formed_points_to_transform[idx1]) - assert np.array(output_with_prep == output_without_prep).all() - - output_with_prep = estimator_with_preprocessor.pair_distance( - indicators_to_transform[idx1]) - output_without_prep = estimator_with_prep_formed.pair_distance( - formed_points_to_transform[idx1]) - assert np.array(output_with_prep == output_without_prep).all() - - # Transform - output_with_prep = estimator_with_preprocessor.transform( - indicators_to_transform) - output_without_prep = estimator_without_preprocessor.transform( - formed_points_to_transform) - assert np.array(output_with_prep == output_without_prep).all() - - output_with_prep = estimator_with_preprocessor.transform( - indicators_to_transform) - output_without_prep = estimator_with_prep_formed.transform( - formed_points_to_transform) - assert np.array(output_with_prep == output_without_prep).all() - - @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) def test_same_with_or_without_preprocessor(estimator, build_dataset): @@ -1250,6 +1187,33 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): assert np.array(output_with_prep == output_without_prep).all() assert any([str(warning.message) == msg for warning in raised_warning]) + if isinstance(estimator, MahalanobisMixin): + # Pair distance + output_with_prep = estimator_with_preprocessor.pair_distance( + indicators_to_transform[idx1]) + output_without_prep = estimator_without_preprocessor.pair_distance( + formed_points_to_transform[idx1]) + assert np.array(output_with_prep == output_without_prep).all() + + output_with_prep = estimator_with_preprocessor.pair_distance( + indicators_to_transform[idx1]) + output_without_prep = estimator_with_prep_formed.pair_distance( + formed_points_to_transform[idx1]) + assert np.array(output_with_prep == output_without_prep).all() + + # Transform + output_with_prep = estimator_with_preprocessor.transform( + indicators_to_transform) + output_without_prep = estimator_without_preprocessor.transform( + formed_points_to_transform) + assert np.array(output_with_prep == output_without_prep).all() + + output_with_prep = estimator_with_preprocessor.transform( + indicators_to_transform) + output_without_prep = estimator_with_prep_formed.transform( + formed_points_to_transform) + assert np.array(output_with_prep == output_without_prep).all() + def test_check_collapsed_pairs_raises_no_error(): """Checks that check_collapsed_pairs raises no error if no collapsed pairs From 7cfd432e458da75306098ced90ae77d4b8804908 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Thu, 18 Nov 2021 16:45:49 +0100 Subject: [PATCH 56/57] Fix docs annotations --- doc/introduction.rst | 14 +++++++------- doc/supervised.rst | 2 +- doc/weakly_supervised.rst | 2 +- test/test_base_metric.py | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/doc/introduction.rst b/doc/introduction.rst index a2a6032b..88911e53 100644 --- a/doc/introduction.rst +++ b/doc/introduction.rst @@ -10,10 +10,10 @@ between data points. Traditionally, practitioners would choose a standard metric domain. However, it is often difficult to design metrics that are well-suited to the particular data and task of interest. -Metric learning (or simply, metric learning) aims at automatically constructing -task-specific metrics from (weakly) supervised data, in a machine learning manner. -The learned metric can then be used to perform various tasks (e.g., -k-NN classification, clustering, information retrieval). +Metric learning aims at automatically constructing task-specific metrics from +(weakly) supervised data, in a machine learning manner. The learned metric can +then be used to perform various tasks (e.g., k-NN classification, clustering, +information retrieval). Problem Setting =============== @@ -36,7 +36,7 @@ of supervision available about the training data: Based on the above (weakly) supervised data, the metric learning problem is generally formulated as an optimization problem where one seeks to find the -parameters of a function that optimize some objective function +parameters of a metric that optimize some objective function measuring the agreement with the training data. .. _mahalanobis_distances: @@ -59,8 +59,8 @@ Mahalanobis distance metric learning can thus be seen as learning a new embedding space of dimension ``num_dims``. Note that when ``num_dims`` is smaller than ``n_features``, this achieves dimensionality reduction. -Strictly speaking, Mahalanobis distances are "pseudo-metrics": they satisfy -three of the `properties of a metric `_ (non-negativity, symmetry, triangle inequality) but not necessarily the identity of indiscernibles. diff --git a/doc/supervised.rst b/doc/supervised.rst index 04a3233b..619642a9 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -58,7 +58,7 @@ NCA(init='auto', max_iter=100, n_components=None, Now that the estimator is fitted, you can use it on new data for several purposes. -We can now use the learned metric to **score** new pairs of points with ``pair_score`` +We can use the learned metric to **score** new pairs of points with ``pair_score`` (the larger the score, the more similar the pair). For Mahalanobis learners, it is equal to the opposite of the distance. diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index e9eeb70d..72a0e328 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -152,7 +152,7 @@ Or alternatively (using a preprocessor): Now that the estimator is fitted, you can use it on new data for several purposes. -We can now use the learned metric to **score** new pairs of points with ``pair_score`` +We can use the learned metric to **score** new pairs of points with ``pair_score`` (the larger the score, the more similar the pair). For Mahalanobis learners, it is equal to the opposite of the distance. diff --git a/test/test_base_metric.py b/test/test_base_metric.py index d98c3c45..6c9540b6 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -327,8 +327,8 @@ def test_pair_score_dim(estimator, build_dataset): ids=ids_metric_learners) def test_deprecated_score_pairs_same_result(estimator, build_dataset): """ - Test that `pari_distance` gives the same result as `score_pairs` for - Mahalanobis learnes, and the same for `pair_score` and `score_paris` + Test that `pair_distance` gives the same result as `score_pairs` for + Mahalanobis learnes, and the same for `pair_score` and `score_pairs` for Bilinear learners. It also checks that the deprecation warning of `score_pairs` is being shown. """ From 0ac9e7a8a02cbfadeca3efed7e3f32d5106c4fc3 Mon Sep 17 00:00:00 2001 From: mvargas33 Date: Thu, 18 Nov 2021 17:03:46 +0100 Subject: [PATCH 57/57] Second chunks of annotations. --- doc/introduction.rst | 2 +- doc/supervised.rst | 2 +- doc/weakly_supervised.rst | 2 +- metric_learn/base_metric.py | 262 +++++++++++++++++++++--------------- test/test_bilinear_mixin.py | 4 +- test/test_utils.py | 2 +- 6 files changed, 156 insertions(+), 118 deletions(-) diff --git a/doc/introduction.rst b/doc/introduction.rst index 88911e53..8c8e40fb 100644 --- a/doc/introduction.rst +++ b/doc/introduction.rst @@ -78,7 +78,7 @@ necessarily the identity of indiscernibles. parameterizations are equivalent. In practice, an algorithm may thus solve the metric learning problem with respect to either :math:`M` or :math:`L`. -.. _bilinear_similarity: +.. __bilinear_similarities: Bilinear Similarities ===================== diff --git a/doc/supervised.rst b/doc/supervised.rst index 619642a9..38324581 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -121,7 +121,7 @@ array([[ 5.91884732, 10.25406973], [0.89169412, 1.9542479 ]]) If the metric learner that you use learns a :ref:`bilinear similarity - `, you can get the plain learned Bilinear + <_bilinear_similarities>`, you can get the plain learned Bilinear matrix :math:`W` using `get_bilinear_matrix`. >>> algorithm.get_bilinear_matrix() diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index 72a0e328..9c91d422 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -217,7 +217,7 @@ array([[-3.24667162e+01, 4.62622348e-07, 3.88325421e-08], [-1.66614919, 16.20219519, 4.73697721]]) If the metric learner that you use learns a :ref:`bilinear similarity - `, you can get the learned Bilinear + <_bilinear_similarities>`, you can get the learned bilinear matrix :math:`W` using `get_bilinear_matrix`. >>> algorithm.get_bilinear_matrix() diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 3f0fa3ae..a19d3877 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -242,118 +242,6 @@ def transform(self, X): """ -class BilinearMixin(BaseMetricLearner, metaclass=ABCMeta): - r"""Bilinear similarity learning algorithms. - - Algorithm that learns a Bilinear (pseudo) similarity :math:`s_M(x, x')`, - defined between two column vectors :math:`x` and :math:`x'` by: :math: - `s_M(x, x') = x M x'`, where :math:`M` is a learned matrix. This matrix - is not guaranteed to be symmetric nor positive semi-definite (PSD). Thus - it cannot be seen as learning a linear transformation of the original - space like Mahalanobis learning algorithms. - - Attributes - ---------- - components_ : `numpy.ndarray`, shape=(n_components, n_features) - The learned bilinear matrix ``M``. - """ - - def score_pairs(self, pairs): - dpr_msg = ("score_pairs will be deprecated in release 0.7.0. " - "Use pair_score to compute similarity scores, or " - "pair_distances to compute distances.") - warnings.warn(dpr_msg, category=FutureWarning) - return self.pair_score(pairs) - - def pair_distance(self, pairs): - """ - Returns an error, as bilinear similarity learners don't learn a - pseudo-distance nor a distance. In consecuence, the additive inverse - of the bilinear similarity cannot be used as distance by construction. - """ - msg = ("This learner doesn't learn a distance, thus ", - "this method is not implemented. Use pair_score instead") - raise Exception(msg) - - def pair_score(self, pairs): - r"""Returns the learned Bilinear similarity between pairs. - - This similarity is defined as: :math:`s_M(x, x') = x^T M x'` - where ``M`` is the learned Bilinear matrix, for every pair of points - ``x`` and ``x'``. - - Parameters - ---------- - pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) - 3D Array of pairs to score, with each row corresponding to two points, - for 2D array of indices of pairs if the similarity learner uses a - preprocessor. - - Returns - ------- - scores : `numpy.ndarray` of shape=(n_pairs,) - The learned Bilinear similarity for every pair. - - See Also - -------- - get_metric : a method that returns a function to compute the similarity - between two points. The difference with `pair_score` is that it - works on two 1D arrays and cannot use a preprocessor. Besides, the - returned function is independent of the similarity learner and hence - is not modified if the similarity learner is. - - :ref:`Bilinear_similarity` : The section of the project documentation - that describes Bilinear similarity. - """ - check_is_fitted(self, ['preprocessor_']) - pairs = check_input(pairs, type_of_inputs='tuples', - preprocessor=self.preprocessor_, - estimator=self, tuple_size=2) - # Note: For bilinear order matters, dist(a,b) != dist(b,a) - # We always choose first pair first, then second pair - # (In contrast with Mahalanobis implementation) - return np.sum(np.dot(pairs[:, 0, :], self.components_) * pairs[:, 1, :], - axis=-1) - - def get_metric(self): - check_is_fitted(self, 'components_') - components = self.components_.copy() - - def similarity_fun(u, v): - """This function computes the similarity between u and v, according to the - previously learned similarity. - - Parameters - ---------- - u : array-like, shape=(n_features,) - The first point involved in the similarity computation. - - v : array-like, shape=(n_features,) - The second point involved in the similarity computation. - - Returns - ------- - similarity : float - The similarity between u and v according to the new similarity. - """ - u = validate_vector(u) - v = validate_vector(v) - return np.dot(np.dot(u.T, components), v) - - return similarity_fun - - def get_bilinear_matrix(self): - """Returns a copy of the Bilinear matrix learned by the similarity learner. - - Returns - ------- - M : `numpy.ndarray`, shape=(n_features, n_features) - The copy of the learned Bilinear matrix. - """ - check_is_fitted(self, 'components_') - return self.components_ - - class MahalanobisMixin(BaseMetricLearner, MetricTransformer, metaclass=ABCMeta): r"""Mahalanobis metric learning algorithms. @@ -568,6 +456,156 @@ def get_mahalanobis_matrix(self): return self.components_.T.dot(self.components_) +class BilinearMixin(BaseMetricLearner, metaclass=ABCMeta): + r"""Bilinear similarity learning algorithms. + + Algorithm that learns a bilinear similarity :math:`s_W(x, x')`, + defined between two column vectors :math:`x` and :math:`x'` by: :math: + `s_W(x, x') = x W x'`, where :math:`W` is a learned matrix. This matrix + is not guaranteed to be symmetric nor positive semi-definite (PSD). Thus + it cannot be seen as learning a linear transformation of the original + space like Mahalanobis learning algorithms. + + Attributes + ---------- + components_ : `numpy.ndarray`, shape=(n_components, n_features) + The learned bilinear matrix ``W``. + """ + + def score_pairs(self, pairs): + r""" + .. deprecated:: 0.7.0 + This method is deprecated. + + .. warning:: + This method will be removed in 0.8.0. Please refer to `pair_distance` + or `pair_score`. This change will occur in order to add learners + that don't necessarily learn a Mahalanobis distance. + + Returns the learned bilinear similarity between pairs. + + This similarity is defined as: :math:`s_W(x, x') = x^T W x'` + where ``W`` is the learned bilinear matrix, for every pair of points + ``x`` and ``x'``. + + Parameters + ---------- + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the metric learner uses a + preprocessor. + + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The learned bilinear similarity for every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the similarity + between two points. The difference with `pair_score` is that it + works on two 1D arrays and cannot use a preprocessor. Besides, the + returned function is independent of the similarity learner and hence + is not modified if the similarity learner is. + + :ref:`_bilinear_similarities` : The section of the project documentation + that describes bilinear similarity. + """ + dpr_msg = ("score_pairs will be deprecated in release 0.7.0. " + "Use pair_score to compute similarity scores, or " + "pair_distances to compute distances.") + warnings.warn(dpr_msg, category=FutureWarning) + return self.pair_score(pairs) + + def pair_distance(self, pairs): + """ + Returns an error, as bilinear similarity learners do not learn a + pseudo-distance nor a distance. In consecuence, the additive inverse + of the bilinear similarity cannot be used as distance by construction. + """ + msg = ("This learner does not learn a distance, thus ", + "this method is not implemented. Use pair_score instead") + raise Exception(msg) + + def pair_score(self, pairs): + r"""Returns the learned bilinear similarity between pairs. + + This similarity is defined as: :math:`s_W(x, x') = x^T W x'` + where ``W`` is the learned bilinear matrix, for every pair of points + ``x`` and ``x'``. + + Parameters + ---------- + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the similarity learner uses a + preprocessor. + + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The learned bilinear similarity for every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the similarity + between two points. The difference with `pair_score` is that it + works on two 1D arrays and cannot use a preprocessor. Besides, the + returned function is independent of the similarity learner and hence + is not modified if the similarity learner is. + + :ref:`_bilinear_similarities` : The section of the project documentation + that describes bilinear similarity. + """ + check_is_fitted(self, ['preprocessor_']) + pairs = check_input(pairs, type_of_inputs='tuples', + preprocessor=self.preprocessor_, + estimator=self, tuple_size=2) + # Note: For bilinear order matters, dist(a,b) != dist(b,a) + # We always choose first pair first, then second pair + # (In contrast with Mahalanobis implementation) + return np.sum(np.dot(pairs[:, 0, :], self.components_) * pairs[:, 1, :], + axis=-1) + + def get_metric(self): + check_is_fitted(self, 'components_') + components = self.components_.copy() + + def similarity_fun(u, v): + """This function computes the bilinear similarity between u and v, + according to the previously learned bilinear similarity. + + Parameters + ---------- + u : array-like, shape=(n_features,) + The first point involved in the similarity computation. + + v : array-like, shape=(n_features,) + The second point involved in the similarity computation. + + Returns + ------- + similarity : float + The similarity between u and v according to the new similarity. + """ + u = validate_vector(u) + v = validate_vector(v) + return np.dot(np.dot(u.T, components), v) + + return similarity_fun + + def get_bilinear_matrix(self): + """Returns a copy of the bilinear matrix learned by the similarity learner. + + Returns + ------- + M : `numpy.ndarray`, shape=(n_features, n_features) + The copy of the learned bilinear matrix. + """ + check_is_fitted(self, 'components_') + return self.components_ + + class _PairsClassifierMixin(BaseMetricLearner): """Base class for pairs learners. diff --git a/test/test_bilinear_mixin.py b/test/test_bilinear_mixin.py index 1e715e0b..0053a631 100644 --- a/test/test_bilinear_mixin.py +++ b/test/test_bilinear_mixin.py @@ -88,7 +88,7 @@ def test_check_handmade_example(): # the matrix can be non-symmetric. def test_check_handmade_symmetric_example(): """ - When the Bilinear matrix is the identity. The similarity + When the bilinear matrix is the identity. The similarity between two arrays must be equal: S(u,v) = S(v,u). Also checks the random case: when the matrix is spd and symetric. """ @@ -145,7 +145,7 @@ def test_check_error_with_pair_distance(estimator, build_dataset): model.fit(*remove_y(model, input_data, labels)) random_pairs = np.array(list(product(X, X))) - msg = ("This learner doesn't learn a distance, thus ", + msg = ("This learner does not learn a distance, thus ", "this method is not implemented. Use pair_score instead") with pytest.raises(Exception) as e: _ = model.pair_distance(random_pairs) diff --git a/test/test_utils.py b/test/test_utils.py index 5dc598a8..0ed95d6e 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1064,7 +1064,7 @@ def test_error_message_t_pair_distance_or_score(estimator, _): .format(make_context(estimator), triplets)) assert str(raised_err.value) == expected_msg - msg = ("This learner doesn't learn a distance, thus ", + msg = ("This learner does not learn a distance, thus ", "this method is not implemented. Use pair_score instead") # One exception will trigger for sure