diff --git a/examples/methods/plot_optimal_transport_da.py b/examples/methods/plot_optimal_transport_da.py index d5e580fa..11eee50f 100644 --- a/examples/methods/plot_optimal_transport_da.py +++ b/examples/methods/plot_optimal_transport_da.py @@ -10,13 +10,15 @@ """ -# Author: Remi Flamary +# Authors: Remi Flamary, Marie Generali Lince, Sonia Mazelet # # License: BSD 3-Clause # sphinx_gallery_thumbnail_number = 4 # %% +import matplotlib.animation as animation import matplotlib.pyplot as plt +import numpy as np from sklearn.inspection import DecisionBoundaryDisplay from sklearn.svm import SVC @@ -395,3 +397,97 @@ plt.scatter(X_target[:, 0], X_target[:, 1], c=y_target, vmax=9, cmap="tab10", alpha=0.7) plt.axis(lims) plt.title(label=f"OTDA linear (ACC={ACC_linear:.2f})") + +# %% +# Partial mapping with alpha parameter +# ------------------------------ +# The OTDA method can be used with a parameter alpha that controls the amount of +# transport applied to the source samples following the expression +# X_mapped = (1-alpha)*X_source + alpha*OT(X_source). +# When alpha=0, the method is equivalent to a standard domain adaptation method +# (e.g. SVC). +# When alpha=1, the method is equivalent to the OTDA method. +# The following animation illustrates this parameter. + + +plt.figure(4, (8, 8)) + +alphas = np.linspace(0, 1, 40) +y_temp = y.copy() +y_temp[sample_domain < 0] = -1 + +# Store mapped target points over time +history_X_final = [] + + +def _update_plot(i): + plt.clf() + alpha = alphas[i] + + clf_otda_linear = make_da_pipeline( + LinearOTMappingAdapter(alpha=alpha), SVC(kernel="rbf", C=1) + ) + clf_otda_linear.fit(X, y_temp, sample_domain=sample_domain) + + # Only map the source points + X_final = clf_otda_linear[0].transform(X_source, sample_domain=1, allow_source=True) + history_X_final.append(X_final.copy()) # store current transformed version + last_X_final = history_X_final[-10:-1] + # Plot previous transported points with fading + for j, Xf in enumerate(last_X_final): + fading_alpha = j / 10 # 0.0 → 1.0 + label = "mapped source" if j == len(last_X_final) - 1 else None + plt.scatter( + Xf[:, 0], + Xf[:, 1], + c=y_source, + cmap="coolwarm", + alpha=fading_alpha, + label=label, + ) + if i < 3: + # Plot source fading out + plt.scatter( + X_source[:, 0], + X_source[:, 1], + c=y_source, + cmap="coolwarm", + label="source", + alpha=1 - alpha, + ) + + # Plot target fixed + plt.scatter( + X_target[:, 0], + X_target[:, 1], + c=y_target, + cmap="Spectral", + label="target", + alpha=1, + marker="s", + ) + + # Decision boundary + DecisionBoundaryDisplay.from_estimator( + clf_otda_linear, + X_source, + alpha=0.5, + eps=0.5, + response_method="predict", + vmax=1, + cmap="coolwarm", + ax=plt.gca(), + ) + + # Accuracy + alpha + acc_target = clf_otda_linear.score(X_target, y_target) + plt.title(f"Alpha = {alpha:.2f} | ACC(target) = {acc_target:.2f}") + plt.legend() + + return 1 + + +ani = animation.FuncAnimation( + plt.gcf(), _update_plot, len(alphas), interval=200, repeat_delay=2000 +) +ani.save("otda_animation_1.gif") diff --git a/skada/_mapping.py b/skada/_mapping.py index cb1caf90..a50a30d4 100644 --- a/skada/_mapping.py +++ b/skada/_mapping.py @@ -82,8 +82,32 @@ def fit_transform(self, X, y=None, *, sample_domain=None, **params): return self.transform(X, sample_domain=sample_domain, allow_source=True) def transform( - self, X, y=None, *, sample_domain=None, allow_source=False, **params + self, + X, + y=None, + *, + sample_domain=None, + allow_source=False, + **params, ) -> np.ndarray: + """Transform the data using the fitted transport estimator. + + Parameters + ---------- + X : array-like, shape (n_samples, n_features) + The data to transform. + y : array-like, shape (n_samples,), optional + The labels of the data (not used in transformation). + sample_domain : array-like, shape (n_samples,), optional + The domain labels. + allow_source : bool, optional (default=False) + Whether to allow transformation of source samples. + + Returns + ------- + X_adapt : array-like, shape (n_samples, n_features) + The transformed data. + """ # xxx(okachaiev): implement auto-infer for sample_domain X, sample_domain = check_X_domain( X, @@ -97,6 +121,7 @@ def transform( # thus there's no need to perform any transformations if X_source.shape[0] > 0: X_source = self.ot_transport_.transform(Xs=X_source) + X_source = self.alpha * X_source + (1 - self.alpha) * X_source X_adapt, _ = source_target_merge( X_source, X_target, sample_domain=sample_domain ) @@ -122,6 +147,8 @@ class OTMappingAdapter(BaseOTMappingAdapter): max_iter : int, optional (default=100_000) The maximum number of iterations before stopping OT algorithm if it has not converged. + alpha : float, optional (default=1.0) + The weight for the original data in the transformed data. Attributes ---------- @@ -141,11 +168,13 @@ def __init__( metric="sqeuclidean", norm=None, max_iter=100_000, + alpha=1.0, ): super().__init__() self.metric = metric self.norm = norm self.max_iter = max_iter + self.alpha = alpha def _create_transport_estimator(self): return da.EMDTransport( @@ -155,7 +184,9 @@ def _create_transport_estimator(self): ) -def OTMapping(base_estimator=None, metric="sqeuclidean", norm=None, max_iter=100000): +def OTMapping( + base_estimator=None, metric="sqeuclidean", norm=None, max_iter=100000, alpha=1.0 +): """OTmapping pipeline with adapter and estimator. See [6]_ for details. @@ -172,6 +203,8 @@ def OTMapping(base_estimator=None, metric="sqeuclidean", norm=None, max_iter=100 max_iter : int, optional (default=100_000) The maximum number of iterations before stopping OT algorithm if it has not converged. + alpha : float, optional (default=1.0) + The weight for the original data in the transformed data. Returns ------- @@ -188,7 +221,7 @@ def OTMapping(base_estimator=None, metric="sqeuclidean", norm=None, max_iter=100 base_estimator = SVC(kernel="rbf") return make_da_pipeline( - OTMappingAdapter(metric=metric, norm=norm, max_iter=max_iter), + OTMappingAdapter(metric=metric, norm=norm, max_iter=max_iter, alpha=alpha), base_estimator, ) @@ -213,6 +246,8 @@ class EntropicOTMappingAdapter(BaseOTMappingAdapter): tol : float, optional (default=10e-9) The precision required to stop the optimization of the Sinkhorn algorithm. + alpha : float, optional (default=1.0) + The weight for the original data in the transformed data. Attributes ---------- @@ -234,6 +269,7 @@ def __init__( norm=None, max_iter=1000, tol=10e-9, + alpha=1.0, ): super().__init__() self.reg_e = reg_e @@ -241,6 +277,7 @@ def __init__( self.norm = norm self.max_iter = max_iter self.tol = tol + self.alpha = alpha def _create_transport_estimator(self): return da.SinkhornTransport( @@ -259,6 +296,7 @@ def EntropicOTMapping( max_iter=1000, reg_e=1.0, tol=1e-8, + alpha=1.0, ): """EntropicOTMapping pipeline with adapter and estimator. @@ -281,6 +319,8 @@ def EntropicOTMapping( tol : float, optional (default=10e-9) The precision required to stop the optimization of the Sinkhorn algorithm. + alpha : float, optional (default=1.0) + The weight for the original data in the transformed data. Returns ------- @@ -298,7 +338,12 @@ def EntropicOTMapping( return make_da_pipeline( EntropicOTMappingAdapter( - metric=metric, norm=norm, max_iter=max_iter, reg_e=reg_e, tol=tol + metric=metric, + norm=norm, + max_iter=max_iter, + reg_e=reg_e, + tol=tol, + alpha=alpha, ), base_estimator, ) @@ -328,6 +373,8 @@ class ClassRegularizerOTMappingAdapter(BaseOTMappingAdapter): The number of iteration in the inner loop tol : float, optional (default=10e-9) Stop threshold on error (inner sinkhorn solver) (>0) + alpha : float, optional (default=1.0) + The weight for the original data in the transformed data. Attributes ---------- @@ -352,6 +399,7 @@ def __init__( max_iter=10, max_inner_iter=200, tol=10e-9, + alpha=1.0, ): super().__init__() self.reg_e = reg_e @@ -361,6 +409,7 @@ def __init__( self.max_iter = max_iter self.max_inner_iter = max_inner_iter self.tol = tol + self.alpha = alpha def _create_transport_estimator(self): assert self.norm in ["lpl1", "l1l2"], "Unknown norm" @@ -388,6 +437,7 @@ def ClassRegularizerOTMapping( reg_e=1.0, reg_cl=0.1, tol=1e-8, + alpha=1.0, ): """ClassRegularizedOTMapping pipeline with adapter and estimator. @@ -414,6 +464,8 @@ def ClassRegularizerOTMapping( The number of iteration in the inner loop tol : float, optional (default=10e-9) Stop threshold on error (inner sinkhorn solver) (>0) + alpha : float, optional (default=1.0) + The weight for the original data in the transformed data. Returns ------- @@ -435,6 +487,7 @@ def ClassRegularizerOTMapping( reg_e=reg_e, reg_cl=reg_cl, tol=tol, + alpha=alpha, ), base_estimator, ) @@ -453,6 +506,8 @@ class LinearOTMappingAdapter(BaseOTMappingAdapter): regularization added to the diagonals of covariances. bias: bool, optional (default=True) estimate bias. + alpha: float, optional (default=1.0) + The weight for the original data in the transformed data. Attributes ---------- @@ -468,10 +523,11 @@ class LinearOTMappingAdapter(BaseOTMappingAdapter): adaptation. arXiv preprint arXiv:1905.10155. """ - def __init__(self, reg=1e-08, bias=True): + def __init__(self, reg=1e-08, bias=True, alpha=1.0): super().__init__() self.reg = reg self.bias = bias + self.alpha = alpha def _create_transport_estimator(self): return da.LinearTransport(reg=self.reg, bias=self.bias) @@ -481,6 +537,7 @@ def LinearOTMapping( base_estimator=None, reg=1.0, bias=True, + alpha=1.0, ): """Returns a the linear OT mapping method with adapter and estimator. @@ -495,6 +552,8 @@ def LinearOTMapping( regularization added to the diagonals of covariances. bias: bool, optional (default=True) estimate bias. + alpha: float, optional (default=1.0) + The weight for the original data in the transformed data. Returns ------- @@ -514,6 +573,7 @@ def LinearOTMapping( LinearOTMappingAdapter( reg=reg, bias=bias, + alpha=alpha, ), base_estimator, ) @@ -577,6 +637,8 @@ class MultiLinearMongeAlignmentAdapter(BaseAdapter): Barycenter of the source domains (mean, cov). _mappings_ : dict Dictionary of mappings for each domain. + alpha : float, optional (default=1.0) + The weight for the original data in the transformed data. References ---------- @@ -595,11 +657,12 @@ class MultiLinearMongeAlignmentAdapter(BaseAdapter): """ - def __init__(self, reg=1e-08, bias=True, test_time=False): + def __init__(self, reg=1e-08, bias=True, test_time=False, alpha=1.0): super().__init__() self.reg = reg self.bias = bias self.test_time = test_time + self.alpha = alpha def fit(self, X, y=None, *, sample_domain=None): """Fit adaptation parameters. @@ -685,7 +748,13 @@ def fit_transform(self, X, y=None, sample_domain=None, **params): return self.transform(X, sample_domain=sample_domain, allow_source=True) def transform( - self, X, y=None, *, sample_domain=None, allow_source=False, **params + self, + X, + y=None, + *, + sample_domain=None, + allow_source=False, + **params, ) -> np.ndarray: X, sample_domain = check_X_domain( X, sample_domain, allow_multi_source=True, allow_multi_target=True @@ -697,11 +766,13 @@ def transform( A, b = self.mappings_[domain] X_adapt[sel] = X[sel].dot(A) + b + X_adapt = self.alpha * X_adapt + (1 - self.alpha) * X + return X_adapt def MultiLinearMongeAlignment( - base_estimator=None, reg=1e-08, bias=True, test_time=False + base_estimator=None, reg=1e-08, bias=True, test_time=False, alpha=1.0 ): """MultiLinearMongeAlignment pipeline with adapter and estimator. @@ -721,6 +792,8 @@ def MultiLinearMongeAlignment( test_time : bool, optional (default=False) If True, the estimator can be updated at test time to map new target domains unseen during training + alpha : float, optional (default=1.0) + The weight for the original data in the transformed data. Returns ------- @@ -747,7 +820,9 @@ def MultiLinearMongeAlignment( base_estimator = LogisticRegression() return make_da_pipeline( - MultiLinearMongeAlignmentAdapter(reg=reg, bias=bias, test_time=test_time), + MultiLinearMongeAlignmentAdapter( + reg=reg, bias=bias, test_time=test_time, alpha=alpha + ), base_estimator, ) @@ -820,6 +895,8 @@ class CORALAdapter(BaseAdapter): - float between 0 and 1: fixed shrinkage parameter. assume_centered: bool, default=False If True, data are not centered before computation. + alpha: float, optional (default=1.0) + The weight for the original data in the transformed data. Attributes ---------- @@ -837,10 +914,11 @@ class CORALAdapter(BaseAdapter): In Advances in Computer Vision and Pattern Recognition, 2017. """ - def __init__(self, reg="auto", assume_centered=False): + def __init__(self, reg="auto", assume_centered=False, alpha=1.0): super().__init__() self.reg = reg self.assume_centered = assume_centered + self.alpha = alpha def fit(self, X, y=None, sample_domain=None): """Fit adaptation parameters. @@ -897,7 +975,13 @@ def fit_transform(self, X, y=None, *, sample_domain=None, **params): return self.transform(X, sample_domain=sample_domain, allow_source=True) def transform( - self, X, y=None, *, sample_domain=None, allow_source=False, **params + self, + X, + y=None, + *, + sample_domain=None, + allow_source=False, + **params, ) -> np.ndarray: X, sample_domain = check_X_domain( X, @@ -927,6 +1011,8 @@ def transform( X_adapt, _ = source_target_merge( X_source_adapt, X_target_adapt, sample_domain=sample_domain ) + X_adapt = self.alpha * X_adapt + (1 - self.alpha) * X + return X_adapt @@ -934,6 +1020,7 @@ def CORAL( base_estimator=None, reg="auto", assume_centered=False, + alpha=1.0, ): """CORAL pipeline with adapter and estimator. @@ -952,6 +1039,8 @@ def CORAL( - float between 0 and 1: fixed shrinkage parameter. assume_centered: bool, default=False If True, data are not centered before computation. + alpha: float, optional (default=1.0) + The weight for the original data in the transformed data. Returns ------- @@ -968,7 +1057,7 @@ def CORAL( base_estimator = SVC(kernel="rbf") return make_da_pipeline( - CORALAdapter(reg=reg, assume_centered=assume_centered), + CORALAdapter(reg=reg, assume_centered=assume_centered, alpha=alpha), base_estimator, ) @@ -997,6 +1086,8 @@ class MMDLSConSMappingAdapter(BaseAdapter): Tolerance for the stopping criterion in the optimization. max_iter : int, default=100 Number of maximum iteration before stopping the optimization. + alpha : float, optional (default=1.0) + The weight for the original data in the transformed data. Attributes ---------- @@ -1017,7 +1108,9 @@ class MMDLSConSMappingAdapter(BaseAdapter): In ICML, 2013. """ - def __init__(self, gamma, reg_k=1e-10, reg_m=1e-10, tol=1e-5, max_iter=100): + def __init__( + self, gamma, reg_k=1e-10, reg_m=1e-10, tol=1e-5, max_iter=100, alpha=1.0 + ): super().__init__() self.gamma = gamma self.reg_k = reg_k @@ -1026,6 +1119,7 @@ def __init__(self, gamma, reg_k=1e-10, reg_m=1e-10, tol=1e-5, max_iter=100): self.max_iter = max_iter self.W_ = None self.B_ = None + self.alpha = alpha def _mapping_optimization(self, X_source, X_target, y_source): """Mapping optimization""" @@ -1149,7 +1243,13 @@ def fit_transform(self, X, y=None, sample_domain=None, **params): return self.transform(X, sample_domain=sample_domain, allow_source=True) def transform( - self, X, y=None, *, sample_domain=None, allow_source=False, **params + self, + X, + y=None, + *, + sample_domain=None, + allow_source=False, + **params, ) -> np.ndarray: X, sample_domain = check_X_domain(X, sample_domain, allow_source=allow_source) @@ -1180,11 +1280,18 @@ def transform( X_adapt, _ = source_target_merge( X_source_adapt, X_target, sample_domain=sample_domain ) + X_adapt = self.alpha * X_adapt + (1 - self.alpha) * X return X_adapt def MMDLSConSMapping( - base_estimator=None, gamma=1.0, reg_k=1e-10, reg_m=1e-10, tol=1e-5, max_iter=100 + base_estimator=None, + gamma=1.0, + reg_k=1e-10, + reg_m=1e-10, + tol=1e-5, + max_iter=100, + alpha=1.0, ): """MMDLSConSMapping pipeline with adapter and estimator. @@ -1204,6 +1311,8 @@ def MMDLSConSMapping( Tolerance for the stopping criterion in the optimization. max_iter : int, default=100 Number of maximum iteration before stopping the optimization. + alpha : float, optional (default=1.0) + The weight for the original data in the transformed data. Returns ------- @@ -1220,7 +1329,12 @@ def MMDLSConSMapping( return make_da_pipeline( MMDLSConSMappingAdapter( - gamma=gamma, reg_k=reg_k, reg_m=reg_m, tol=tol, max_iter=max_iter + gamma=gamma, + reg_k=reg_k, + reg_m=reg_m, + tol=tol, + max_iter=max_iter, + alpha=alpha, ), base_estimator, ) diff --git a/skada/tests/test_mapping.py b/skada/tests/test_mapping.py index 455e1530..7c0c3549 100644 --- a/skada/tests/test_mapping.py +++ b/skada/tests/test_mapping.py @@ -43,20 +43,26 @@ [ make_da_pipeline(OTMappingAdapter(), LogisticRegression()), OTMapping(), + OTMapping(alpha=0.5), make_da_pipeline(EntropicOTMappingAdapter(), LogisticRegression()), EntropicOTMapping(), + EntropicOTMapping(alpha=0.5), make_da_pipeline( ClassRegularizerOTMappingAdapter(norm="lpl1"), LogisticRegression() ), ClassRegularizerOTMapping(), + ClassRegularizerOTMapping(alpha=0.5), make_da_pipeline( ClassRegularizerOTMappingAdapter(norm="l1l2"), LogisticRegression() ), ClassRegularizerOTMapping(norm="l1l2"), + ClassRegularizerOTMapping(norm="l1l2", alpha=0.5), make_da_pipeline(LinearOTMappingAdapter(), LogisticRegression()), LinearOTMapping(), + LinearOTMapping(alpha=0.5), make_da_pipeline(MultiLinearMongeAlignmentAdapter(), LogisticRegression()), MultiLinearMongeAlignment(), + MultiLinearMongeAlignment(alpha=0.5), make_da_pipeline(CORALAdapter(), LogisticRegression()), pytest.param( CORALAdapter(reg=None), @@ -64,6 +70,7 @@ ), make_da_pipeline(CORALAdapter(reg=0.1), LogisticRegression()), CORAL(), + CORAL(alpha=0.5), pytest.param( make_da_pipeline(MMDLSConSMappingAdapter(gamma=1e-3), SVC()), marks=pytest.mark.skipif(not torch, reason="PyTorch not installed"),