diff --git a/README.md b/README.md index bc960515..f88407b7 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The following algorithms are currently implemented. - Sample reweighting methods (Gaussian [1], Discriminant [2], KLIEPReweight [3], DensityRatio [4], TarS [21], KMMReweight [23]) - Sample mapping methods (CORAL [5], Optimal Transport DA OTDA [6], LinearMonge [7], LS-ConS [21]) -- Subspace methods (SubspaceAlignment [8], TCA [9], Transfer Subspace Learning [27]) +- Subspace methods (SubspaceAlignment [8], TCA [9], Transfer Subspace Learning [27], CTC [29]) - Other methods (JDOT [10], DASVM [11], OT Label Propagation [28]) Any methods that can be cast as an adaptation of the input data can be used in one of two ways: @@ -207,4 +207,6 @@ The library is distributed under the 3-Clause BSD license. [27] S. Si, D. Tao and B. Geng. In IEEE Transactions on Knowledge and Data Engineering, (2010) [Bregman Divergence-Based Regularization for Transfer Subspace Learning](https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=4118b4fc7d61068b9b448fd499876d139baeec81) -[28] Solomon, J., Rustamov, R., Guibas, L., & Butscher, A. (2014, January). [Wasserstein propagation for semi-supervised learning](https://proceedings.mlr.press/v32/solomon14.pdf). In International Conference on Machine Learning (pp. 306-314). PMLR. \ No newline at end of file +[28] Solomon, J., Rustamov, R., Guibas, L., & Butscher, A. (2014, January). [Wasserstein propagation for semi-supervised learning](https://proceedings.mlr.press/v32/solomon14.pdf). In International Conference on Machine Learning (pp. 306-314). PMLR. + +[29] Gong, M., Zhang, K., Liu, T., Tao, D., Glymour, C., & Scholkopf, B. (2016). [Domain Adaptation with Conditional Transferable Components](https://proceedings.mlr.press/v48/gong16.pdf). JMLR workshop and conference proceedings, 48, 2839-2848. \ No newline at end of file diff --git a/docs/source/all.rst b/docs/source/all.rst index 57b61b1a..6a783efd 100644 --- a/docs/source/all.rst +++ b/docs/source/all.rst @@ -65,6 +65,7 @@ DAEstimators with adapters (Pipeline): SubspaceAlignment TransferComponentAnalysis TransferJointMatching + ConditionalTransferableComponents CORAL OTMapping EntropicOTMapping @@ -81,6 +82,7 @@ Adapters: TransferComponentAnalysisAdapter TransferJointMatchingAdapter TransferSubspaceLearning + ConditionalTransferableComponentsAdapter CORALAdapter OTMappingAdapter EntropicOTMappingAdapter diff --git a/examples/methods/plot_subspace.py b/examples/methods/plot_subspace.py index d85e3508..c381b35e 100644 --- a/examples/methods/plot_subspace.py +++ b/examples/methods/plot_subspace.py @@ -9,6 +9,7 @@ # Author: Ruben Bueno # Antoine Collas # Oleksii Kachaiev +# Yanis Lalou # # License: BSD 3-Clause # sphinx_gallery_thumbnail_number = 4 @@ -21,6 +22,7 @@ from sklearn.svm import SVC from skada import ( + ConditionalTransferableComponents, SubspaceAlignment, TransferComponentAnalysis, TransferJointMatching, @@ -46,6 +48,10 @@ # * :ref:`Transfer Component Analysis` # * :ref:`Transfer Joint Matching` +# * :ref:`Transfer Subspace Learning +# ` +# * :ref:`Conditional Transferable Components +# ` base_classifier = SVC() @@ -374,6 +380,28 @@ def plot_subspace_and_classifier( clf.fit(X, y, sample_domain=sample_domain) plot_subspace_and_classifier(clf, "TransferSubspaceLearning") +# %% +# Illustration of the Conditional Transferable Components method +# ------------------------------------------ +# +# The objective of Conditional Transferable Components (CTC) is to +# learn domain-invariant representations by disentangling the data +# representation into domain-specific and domain-invariant components. +# By doing so, CTC enables the transfer of knowledge from the source domain +# to the target domain while mitigating the effects of domain shift. +# +# See [29] for details: +# +# .. [29] Gong, M., Zhang, K., Liu, T., Tao, +# D., Glymour, C., & Scholkopf, B. (2016). +# Domain Adaptation with Conditional Transferable Components. +# JMLR workshop and conference proceedings, 48, 2839-2848. +# + +clf = ConditionalTransferableComponents(n_components=1) +clf.fit(X, y, sample_domain=sample_domain) +plot_subspace_and_classifier(clf, "ConditionalTransferableComponents") + # %% # Comparison of score between subspace methods: diff --git a/examples/plot_method_comparison.py b/examples/plot_method_comparison.py index b50405ca..0f4f43c4 100644 --- a/examples/plot_method_comparison.py +++ b/examples/plot_method_comparison.py @@ -25,6 +25,7 @@ from skada import ( CORAL, ClassRegularizerOTMapping, + ConditionalTransferableComponents, DensityReweight, DiscriminatorReweight, EntropicOTMapping, @@ -58,6 +59,7 @@ "Subspace Alignment", "TCA", "TSL", + "CTC", "OT mapping", "Entropic OT mapping", "Class Reg. OT mapping", @@ -82,6 +84,7 @@ SubspaceAlignment(base_estimator=SVC(), n_components=1), TransferComponentAnalysis(base_estimator=SVC(), n_components=1, mu=0.5), TransferSubspaceLearning(base_estimator=SVC(), n_components=1), + ConditionalTransferableComponents(base_estimator=SVC(), n_components=1), OTMapping(base_estimator=SVC()), EntropicOTMapping(base_estimator=SVC()), ClassRegularizerOTMapping(base_estimator=SVC()), diff --git a/skada/__init__.py b/skada/__init__.py index ce162995..679de563 100644 --- a/skada/__init__.py +++ b/skada/__init__.py @@ -49,6 +49,8 @@ TransferJointMatchingAdapter, TransferSubspaceLearning, TransferSubspaceLearningAdapter, + ConditionalTransferableComponents, + ConditionalTransferableComponentsAdapter, ) from ._ot import ( solve_jdot_regression, @@ -116,6 +118,8 @@ "TransferJointMatching", "TransferSubspaceLearningAdapter", "TransferSubspaceLearning", + "ConditionalTransferableComponentsAdapter", + "ConditionalTransferableComponents", "DASVMClassifier", "solve_jdot_regression", diff --git a/skada/_subspace.py b/skada/_subspace.py index 7de79e6c..293302fe 100644 --- a/skada/_subspace.py +++ b/skada/_subspace.py @@ -3,6 +3,7 @@ # Oleksii Kachaiev # Ruben Bueno # Antoine Collas +# Yanis Lalou # # License: BSD 3-Clause @@ -11,6 +12,7 @@ import numpy as np import scipy.linalg +from scipy.optimize import minimize from sklearn.decomposition import PCA from sklearn.metrics.pairwise import pairwise_kernels from sklearn.neighbors import KNeighborsClassifier @@ -18,9 +20,11 @@ from sklearn.utils import check_random_state from ._pipeline import make_da_pipeline +from ._utils import Y_Type, _find_y_type from .base import BaseAdapter from .utils import ( check_X_domain, + check_X_y_domain, extract_source_indices, source_target_merge, source_target_split, @@ -1021,3 +1025,352 @@ def TransferSubspaceLearning( ), base_estimator, ) + + +class ConditionalTransferableComponentsAdapter(BaseAdapter): + """Conditional Transferable Components. + + See [28]_ for details. + + Parameters + ---------- + n_components : int, default=None + The numbers of components to learn. + Should be less or equal to the number of samples + of the source and target data. + + gamma : float, default=1.0 + The gamma parameter of the RBF kernel. + + eps : float, default=1e-3 + The epsilon parameter of the RBF kernel. + + lmbd : float, default=1e-3 + The lambda parameter of the optimization problem. + + lmbd_s : float, default=1e-3 + The lambda_s parameter of the optimization problem. + + lmbd_l : float, default=1e-4 + The lambda_l parameter of the optimization problem. + + tol : float, default=1e-3 + The threshold for the differences between losses on two iteration + before the algorithm stops + + max_iter : int, default=100 + The maximal number of iteration before stopping when + fitting. + + Attributes + ---------- + W_ : array of shape (n_features, n_components) + The learned projection matrix. + + References + ---------- + .. [29] Gong, M., Zhang, K., Liu, T., Tao, + D., Glymour, C., & Scholkopf, B. (2016). + Domain Adaptation with Conditional Transferable Components. + JMLR workshop and conference proceedings, 48, 2839-2848. + """ + + def __init__( + self, + n_components=None, + gamma=1.0, + eps=1e-3, + lmbd=1e-3, + lmbd_s=1e-3, + lmbd_l=1e-4, + tol=1e-3, + max_iter=100, + ): + super().__init__() + self.n_components = n_components + self.gamma = gamma + self.eps = eps + self.lmbd = lmbd + self.lmbd_s = lmbd_s + self.lmbd_l = lmbd_l + self.max_iter = max_iter + self.tol = tol + + def _mapping_optimization(self, X_source, X_target, y_source): + """Weight optimization""" + try: + import torch + except ImportError: + raise ImportError( + "ConditionalTransferableComponentsAdapter \ + requires pytorch to be installed." + ) + + n_s, n_t = X_source.shape[0], X_target.shape[0] + D = X_source.shape[1] + + classes = np.unique(y_source) + n_c = len(classes) # Compute the cardinality of the classes + + if self.n_components is None: + n_components = min(X_source.shape[0], X_source.shape[1]) + else: + n_components = self.n_components + + # check y is discrete or continuous + self.discrete_ = _find_y_type(y_source) == Y_Type.DISCRETE + + if not self.discrete_: + raise NotImplementedError("Only discrete labels are supported") + + def compute_Beta_A_R(alpha, G, H): + classes = torch.unique(y_source) + n_c = len(classes) + + R_dis = torch.zeros((n_s, n_c), dtype=torch.float64) + for i, c in enumerate(classes): + R_dis[:, i] = (n_s / n_c) * (y_source == c).float() + + Beta = (R_dis @ alpha).reshape(-1, 1) + A = (R_dis @ G).t() + B = (R_dis @ H).t() + + return Beta, A, B, R_dis + + def func_np(alpha, W, G, H): + J_ct_con = func_torch(alpha, W, G, H) + J_ct_con = J_ct_con.detach().numpy() + return J_ct_con + + def rbf_kernel(X, Y, gamma): + """Compute the RBF kernel matrix between X and Y.""" + pairwise_distances = torch.cdist(X, Y, p=2) # Euclidean distances + kernel_matrix = torch.exp(-gamma * pairwise_distances.pow(2)) + return kernel_matrix + + def func_torch(alpha, W, G, H): + Beta, A, B, R_dis = compute_Beta_A_R(alpha, G, H) + + X_ct = A * (W.t() @ X_source.t()) + B + + K_t = rbf_kernel( + (W.t() @ X_target.t()).t(), (W.t() @ X_target.t()).t(), self.gamma + ) + K_tilde_s = rbf_kernel(X_ct.t(), X_ct.t(), self.gamma) + K_tilde_t_s = rbf_kernel((W.t() @ X_target.t()).t(), X_ct.t(), self.gamma) + L = rbf_kernel(y_source.view(-1, 1), y_source.view(-1, 1), self.gamma) + + J_ct = ( + (1 / n_s**2) * Beta.t() @ K_tilde_s @ Beta + - (2 / n_s * n_t) + * torch.ones((n_t, 1), dtype=torch.float64).t() + @ K_tilde_t_s + @ Beta + + (1 / n_t**2) + * torch.ones((n_t, 1), dtype=torch.float64).t() + @ K_t + @ torch.ones((n_t, 1), dtype=torch.float64) + ) + + Jreg = (self.lmbd_s / n_s) * torch.norm( + A - torch.ones((n_components, n_s), dtype=torch.float64), p=2 + ) + (self.lmbd_l / n_s) * torch.norm(B, p=2) + + J_ct_con = ( + J_ct + + self.lmbd + * self.eps + * torch.trace( + L + @ torch.inverse( + K_tilde_s + n_s * self.eps * torch.eye(n_s, dtype=torch.float64) + ) + ) + + Jreg + ) + + return J_ct_con + + def func_torch_constrained(alpha, W, G, H, lmdb): + # We use the same function as func_torch but with an additional + # regularization term + # Indeed we should guarantee that W is on the Grassmann manifold + # Thus we need W^TW = I_d + J_ct_con = func_torch(alpha, W, G, H) + + lagrangian = J_ct_con + lmdb * torch.norm( + W.T @ W - torch.eye(n_components), p=2 + ) + return lagrangian + + ##### + X_source = torch.tensor(X_source, dtype=torch.float64) + X_target = torch.tensor(X_target, dtype=torch.float64) + y_source = torch.tensor(y_source, dtype=torch.float64) + + alpha = torch.ones(n_c, dtype=torch.float64) + W = torch.ones((D, n_components), dtype=torch.float64) + G = torch.ones((n_c, n_components), dtype=torch.float64) + H = torch.zeros((n_c, n_components), dtype=torch.float64) + + # Initialize the Lagrange multiplier for the constraint on W + lmbd = torch.tensor(1e-3, dtype=torch.float64) + + Beta, A, B, R_dis = compute_Beta_A_R(alpha, G, H) + + for i in range(self.max_iter): + if i % 3 == 0: + # For alpha, we use quadratic programming (QP) to minimize + # Jˆct w.r.t. alpha under constraints + alpha_constraints = ( + {"type": "ineq", "fun": (lambda x: -(R_dis.detach().cpu() @ x))}, + {"type": "eq", "fun": (lambda x: np.sum(x) - 1)}, + ) + + result = minimize( + func_np, + x0=alpha, + args=(W, G, H), + method="SLSQP", + constraints=alpha_constraints, + options={"maxiter": 1}, + ) + + alpha = result.x + alpha = torch.tensor(alpha, dtype=torch.float64) + elif i % 3 == 1: + # For W, we use the gradient descent method to minimize + # Jˆct w.r.t. W under constraint + (_, W, _, _, lmbd), _ = torch_minimize( + func_torch_constrained, + (alpha, W, G, H, lmbd), + tol=self.tol, + max_iter=1, + ) + W = torch.tensor(W, dtype=torch.float64) + lmbd = torch.tensor(lmbd, dtype=torch.float64) + else: + # For G and H, we use the gradient descent method to minimize + # Jˆct w.r.t. G and H + (_, _, G, H), _ = torch_minimize( + func_torch, (alpha, W, G, H), tol=self.tol, max_iter=1 + ) + G = torch.tensor(G, dtype=torch.float64) + H = torch.tensor(H, dtype=torch.float64) + + Beta, A, B, R_dis = compute_Beta_A_R(alpha, G, H) + + return alpha, W, G, H, Beta, A, B, R_dis + + def fit(self, X, y=None, sample_domain=None, **kwargs): + X, y, sample_domain = check_X_y_domain(X, y, sample_domain) + X_source, X_target, y_source, _ = source_target_split( + X, y, sample_domain=sample_domain + ) + self.X_source_ = X_source + + ( + self.alpha_, + self.W_, + self.G_, + self.H_, + self.Beta_, + self.A_, + self.B_, + self.R_dis_, + ) = self._mapping_optimization( + X_source, + X_target, + y_source, + ) + return self + + def fit_transform(self, X, y=None, sample_domain=None, **kwargs): + self.fit(X, y, sample_domain) + return self.transform(X, y, sample_domain=sample_domain) + + def transform( + self, X, y=None, *, sample_domain=None, allow_source=True, **params + ) -> np.ndarray: + X, sample_domain = check_X_domain( + X, + sample_domain, + allow_source=allow_source, + allow_multi_source=True, + allow_multi_target=True, + ) + X_source, X_target = source_target_split(X, sample_domain=sample_domain) + + if X_source.shape[0]: + X_source = np.dot(X_source, self.W_) + if X_target.shape[0]: + X_target = np.dot(X_target, self.W_) + + X_adapt, _ = source_target_merge( + X_source, X_target, sample_domain=sample_domain + ) + return X_adapt + + +def ConditionalTransferableComponents( + base_estimator=None, + n_components=None, + gamma=1, + eps=1e-3, + lmbd=1e-3, + lmbd_s=1e-3, + lmbd_l=1e-4, + tol=1e-3, + max_iter=100, +): + """Domain Adaptation Using Conditional Transferable Components. + + Parameters + ---------- + base_estimator : object, default=None + Estimator used for fitting and prediction. + n_components : int, default=None + The number of invariant components to learn. + gamma : float, default=1 + The gamma parameter of the RBF kernel. + eps : float, default=1e-3 + The epsilon parameter of the RBF kernel. + lmbd : float, default=1e-3 + The lambda parameter of the optimization problem. + lmbd_s : float, default=1e-3 + The lambda_s parameter of the optimization problem. + lmbd_l : float, default=1e-4 + The lambda_l parameter of the optimization problem. + tol : float, default=1e-3 + The tolerance of the optimization problem. + max_iter : int, default=100 + The maximal number of iteration before stopping when fitting. + + Returns + ------- + pipeline : Pipeline + A pipeline containing a ConditionalTransferableComponentsAdapter. + + References + ---------- + .. [29] Gong, M., Zhang, K., Liu, T., Tao, + D., Glymour, C., & Scholkopf, B. (2016). + Domain Adaptation with Conditional Transferable Components. + JMLR workshop and conference proceedings, 48, 2839-2848. + """ + if base_estimator is None: + base_estimator = SVC() + + return make_da_pipeline( + ConditionalTransferableComponentsAdapter( + gamma=gamma, + n_components=n_components, + eps=eps, + lmbd=lmbd, + lmbd_s=lmbd_s, + lmbd_l=lmbd_l, + tol=tol, + max_iter=max_iter, + ), + base_estimator, + ) diff --git a/skada/tests/test_subspace.py b/skada/tests/test_subspace.py index 1e41cf20..15faa768 100644 --- a/skada/tests/test_subspace.py +++ b/skada/tests/test_subspace.py @@ -2,6 +2,7 @@ # Remi Flamary # Oleksii Kachaiev # Antoine Collas +# Yanis Lalou # # License: BSD 3-Clause @@ -16,6 +17,8 @@ torch = False from skada import ( + ConditionalTransferableComponents, + ConditionalTransferableComponentsAdapter, SubspaceAlignment, SubspaceAlignmentAdapter, TransferComponentAnalysis, @@ -68,6 +71,17 @@ ), marks=pytest.mark.skipif(not torch, reason="PyTorch not installed"), ), + pytest.param( + ConditionalTransferableComponents(n_components=1), + marks=pytest.mark.skipif(not torch, reason="PyTorch not installed"), + ), + pytest.param( + make_da_pipeline( + ConditionalTransferableComponentsAdapter(n_components=1), + LogisticRegression(), + ), + marks=pytest.mark.skipif(not torch, reason="PyTorch not installed"), + ), ], ) def test_subspace_estimator(estimator, da_dataset): @@ -114,6 +128,20 @@ def test_subspace_estimator(estimator, da_dataset): 4, marks=pytest.mark.skipif(not torch, reason="PyTorch not installed"), ), + pytest.param( + ConditionalTransferableComponentsAdapter(), + 5, + 3, + 3, + marks=pytest.mark.skipif(not torch, reason="PyTorch not installed"), + ), + pytest.param( + ConditionalTransferableComponentsAdapter(), + 2, + 3, + 2, + marks=pytest.mark.skipif(not torch, reason="PyTorch not installed"), + ), ], ) def test_subspace_default_n_components(adapter, n_samples, n_features, n_components): @@ -166,3 +194,39 @@ def test_subspace_default_n_components(adapter, n_samples, n_features, n_compone def test_instantiation_wrong_params(adapter, param_name, param_value): with pytest.raises(ValueError): adapter(**{param_name: param_value}) + + +@pytest.mark.parametrize( + "adapter", + [ + pytest.param( + ConditionalTransferableComponentsAdapter(), + marks=pytest.mark.skipif(not torch, reason="PyTorch not installed"), + ), + ], +) +def test_continuous_labels(adapter): + rng = np.random.default_rng(42) + X_source, y_source, X_target, y_target = ( + rng.standard_normal((5, 3)), + rng.standard_normal((5, 1)), + rng.standard_normal((5, 3)), + rng.standard_normal((5, 1)), + ) + + y_source = y_source.flatten() + y_target = y_target.flatten() + + dataset = DomainAwareDataset( + [ + (X_source, y_source, "s"), + (X_target, y_target, "t"), + ] + ) + + X_train, y_train, sample_domain = dataset.pack_train( + as_sources=["s"], as_targets=["t"] + ) + + with pytest.raises(NotImplementedError): + adapter.fit_transform(X_train, y_train, sample_domain=sample_domain) diff --git a/skada/utils.py b/skada/utils.py index 1ba5754a..d0de2c35 100644 --- a/skada/utils.py +++ b/skada/utils.py @@ -939,11 +939,11 @@ def closure(): if verbose: print(f"Final gradient norm: {grad_norm:.2e}") - if grad_norm > tol: - warnings.warn( - "Optimization did not converge. " - f"Final gradient maximum value: {grad_norm:.2e} > {tol:.2e}" - ) + if grad_norm > tol: + warnings.warn( + "Optimization did not converge. " + f"Final gradient maximum value: {grad_norm:.2e} > {tol:.2e}" + ) solution = [x.detach().numpy() for x in x0] if len(solution) == 1: