diff --git a/README.md b/README.md index c8cc4dff..0382f2fd 100644 --- a/README.md +++ b/README.md @@ -249,3 +249,5 @@ The library is distributed under the 3-Clause BSD license. [36] Xiao, Zhiqing, Wang, Haobo, Jin, Ying, Feng, Lei, Chen, Gang, Huang, Fei, Zhao, Junbo.[SPA: A Graph Spectral Alignment Perspective for Domain Adaptation](https://arxiv.org/pdf/2310.17594). In Neurips, 2023. [37] Xie, Renchunzi, Odonnat, Ambroise, Feofanov, Vasilii, Deng, Weijian, Zhang, Jianfeng and An, Bo. [MaNo: Exploiting Matrix Norm for Unsupervised Accuracy Estimation Under Distribution Shifts](https://arxiv.org/pdf/2405.18979). In NeurIPS, 2024. + +[38] Liang, Hu, Feng. [Do We Really Need to Access the Source Data? Source Hypothesis Transfer for Unsupervised Domain Adaptation](https://arxiv.org/abs/2002.08546). diff --git a/examples/deep/plot_test_time.py b/examples/deep/plot_test_time.py new file mode 100644 index 00000000..602914f2 --- /dev/null +++ b/examples/deep/plot_test_time.py @@ -0,0 +1,130 @@ +""" +Training setup for deep test time method. +========================================== + +This example illustrates the use of deep test timeS methods in Skada. +on a simple image classification task. +""" + +# Author: Marion Pavaux +# Théo Gnassounou +# +# License: BSD 3-Clause +# sphinx_gallery_thumbnail_number = 4 + +# %% +import torch +from torch.utils.data import DataLoader + +from skada.datasets import load_mnist_usps +from skada.deep.base import DeepDADataset +from skada.deep.losses import ( + CrossEntropyLabelSmooth, + shot_full_loss, +) +from skada.deep.modules import SHOTNet +from skada.deep.utils import get_estimated_label_from_centroids + +NUM_CLASSES = 2 + +# %% +# Load the image datasets +# ---------------------------------------------------------------------------- + +sub_dataset = load_mnist_usps(n_classes=NUM_CLASSES, n_samples=0.5) +dataset = DeepDADataset(*sub_dataset) +source_dataset = dataset.select_source() +target_dataset = dataset.select_target() + +# %% +# Training parameters +# ---------------------------------------------------------------------------- + +max_epochs = 10 +batch_size = 256 +lr = 1e-4 +device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + +# %% +# Training with torch +# ---------------------------------------------------------------------------- + + +model = SHOTNet(class_num=NUM_CLASSES).to(device) +optimizer = torch.optim.Adam(model.parameters(), lr=lr) +source_dataloader = DataLoader(source_dataset, batch_size=batch_size, shuffle=True) +loss_fn = CrossEntropyLabelSmooth(num_classes=NUM_CLASSES) + +print("Training loop") +# Training loop +for epoch in range(max_epochs): + model.train() + running_loss = 0.0 + for batch_idx, (inputs, labels) in enumerate(source_dataloader): + inputs, labels = inputs, labels.to(device) + + # Zero the gradients + optimizer.zero_grad() + + # Forward pass + outputs = model(inputs["X"].to(device)) + loss = loss_fn(outputs, labels) + + # Backward pass and optimization + loss.backward() + optimizer.step() + + running_loss += loss.item() + print(f"Epoch: {epoch} - Loss: {running_loss / batch_idx}") + +target_pred = model(target_dataset.X.to(device)).cpu().detach().numpy().argmax(axis=1) +accuracy_no_adapation = (target_dataset.y.numpy() == target_pred).mean() +print(f"Accuracy on target domain without domain adaptation: {accuracy_no_adapation}") + +model.classifier.eval() +optimizer = torch.optim.Adam( + [ + {"params": model.feature_extractor.named_parameters()}, + {"params": model.bottleneck.named_parameters()}, + ], + lr=lr, +) +dataloader_target = DataLoader(target_dataset, batch_size=batch_size, shuffle=True) +interval_iter = 1 + +print("Adaptation loop") +# Adaptation loop +for epoch in range(max_epochs): + model.train() + running_loss = 0.0 + for inputs, labels in dataloader_target: + inputs, labels = inputs, labels.to(device) + + # Zero the gradients + optimizer.zero_grad() + + if batch_idx % interval_iter == 0: + with torch.no_grad(): + features_full = model.bottleneck( + model.feature_extractor(target_dataset.X.to(device)) + ) + outputs_full = model.classifier(features_full) + estimated_labels = get_estimated_label_from_centroids( + outputs_full, features_full + ) + + # Forward pass + outputs = model(inputs["X"].to(device)) + loss = shot_full_loss(estimated_labels[inputs["sample_idx"]], outputs) + + # Backward pass and optimization + loss.backward() + optimizer.step() + + running_loss += loss.item() + print(f"Epoch: {epoch} - Loss: {running_loss / batch_idx}") + +target_pred = model(target_dataset.X.to(device)).cpu().detach().numpy().argmax(axis=1) +accuracy_no_adapation = (target_dataset.y.numpy() == target_pred).mean() +print(f"Accuracy on target domain with domain adaptation: {accuracy_no_adapation}") diff --git a/skada/deep/_test_time.py b/skada/deep/_test_time.py new file mode 100644 index 00000000..888509ff --- /dev/null +++ b/skada/deep/_test_time.py @@ -0,0 +1,240 @@ +# Author : Maxence Barneche +# +# License: BSD-3-Clause + +import torch + +from skada.deep.base import BaseDALoss, DomainAwareModule, DomainAwareNet +from skada.deep.losses import CrossEntropyLabelSmooth, shot_full_loss, softmax_entropy + + +class TestTimeCriterion(torch.nn.Module): + def __init__( + self, + base_criterion, + adapt_criterion, + reg=1, + reduction="mean", + train_on_target=False, + ): + super().__init__() + self.base_criterion = base_criterion + self.adapt_criterion = adapt_criterion + self.reg = reg + self.train_on_target = train_on_target + + # Update the reduce parameter for both criteria if specified + if hasattr(self.base_criterion, "reduction"): + self.base_criterion.reduction = reduction + + # TODO: implement losses between source and target + # that are sum of the losses of each sample + # if hasattr(self.adapt_criterion, 'reduction'): + # self.adapt_criterion.reduction = reduction + + def forward( + self, + y_pred, + y_true, + ): + """ + Parameters + ---------- + y_pred : + Prediction of the labels. + y_true : + The true labels. Available for source, masked for target. + """ + if self.train_on_target: + # In finetune mode, we only compute the base loss + return self.adapt_criterion( + y_s=y_true, + y_pred=y_pred, + ) + else: + # In adapt mode, we compute the adaptation loss + return self.base_criterion(y_pred, y_true) + + +class TestTimeNet(DomainAwareNet): + def __init__( + self, + module, + criterion: "TestTimeCriterion", + optimizer_adapt=None, + epochs_adapt=None, + params_to_adapt=None, + layers_to_adapt=None, + **kwargs, + ): + super().__init__(module, criterion=criterion, **kwargs) + self.optimizer_adapt = optimizer_adapt + self.epochs_adapt = epochs_adapt + self.params_to_adapt = params_to_adapt + self.layers_to_adapt = layers_to_adapt + + def fit(self, X, y=None, sample_domain=None, sample_weight=None, **fit_params): + print("Training model on source domain...") + X = self._prepare_input(X, y, sample_domain, sample_weight) + X = X.select_source() + self.criterion.train_on_target = False + self.partial_fit(X, None, **fit_params) + return self + + def fit_adapt( + self, X, sample_domain, sample_weight=None, pseudo_labels=None, **fit_params + ): + print("Adapting model to target domain...") + if self.optimizer_adapt is not None: + self.initialize_adapt_optimizer() + X = self._prepare_input(X, None, sample_domain, sample_weight) + X = X.select_target() + X.y = pseudo_labels if pseudo_labels is not None else X.y + self.criterion.train_on_target = True + self.freeze_all_params() + self.parameters_to_adapt( + param_name=self.params_to_adapt, layer_name=self.layers_to_adapt + ) + self.partial_fit(X, None, epochs=self.epochs_adapt, **fit_params) + return self + + def initialize_adapt_optimizer(self): + named_parameters = self.get_all_learnable_params() + args, kwargs = self.get_params_for_optimizer( + "adapt_optimizer", named_parameters + ) + self.optimizer_ = self.optimizer_adapt(*args, **kwargs) + return self + + def unfreeze_params(self, module, param_name=None): + """ + Unfreeze the parameters of a given module from the net. + + Parameters + ---------- + param_name : str, list(str), optional + The name of the parameter to unfreeze. + If 'all', unfreeze all parameters of the module. + If None, unfreeze no parameter of the module. + If a list, unfreeze all parameters from the list. + """ + # Pseudocode for freezing parameters of a specific layer + if param_name is None: + param_name = [] + if param_name == "all": + param_name = [name for name, _ in module.named_parameters()] + elif isinstance(param_name, str): + param_name = [param_name] + + for name, param in module.named_parameters(): + if name in param_name: + param.requires_grad = False + + return self + + def parameters_to_adapt(self, param_name=None, layer_name=None): + """ + Unfreeze the parameters of the module. + Choose the parameters to adapt during the adaptation phase based on + the layer names and parameter names. + + Parameters + ---------- + layer_name : str, list(str), optional + The name of the layer to unfreeze. + If 'all', unfreeze all layers of the module. + If None, unfreeze params from no layers. + If a list, unfreeze all layers in the list. + param_name : str, list(str), list(list(str)) optional + The name of the parameter to unfreeze. + If None, unfreeze all parameters of the layer. + If a list, unfreeze all parameters from the list in every layer given. + If a list of lists, unfreeze the parameters with the given names in + the corresponding layers. + """ + # if None, freeze parameters from all layers of the net + if layer_name is None: + layer_name = [] + elif layer_name == "all": + layer_name = [name for name, _ in self.module.named_modules()] + elif isinstance(layer_name, str): + layer_name = [layer_name] + + if isinstance(param_name, str): + param_name = [param_name] * len(layer_name) + + for module_name, module in self.module.named_modules(): + if module_name in layer_name: + self.unfreeze_params(module=module, param_name=param_name) + return self + + def freeze_all_params(self): + """Freeze all parameters of the module.""" + for _, mod in self.module.named_modules(): + for _, param in mod.named_parameters(): + param.requires_grad = False + return self + + +class TentLoss(BaseDALoss): + def __init__(self): + super().__init__() + + def forward(self, y): + loss = softmax_entropy(y) + return loss + + +def Tent( + module, + layer_name, + base_criterion=None, + **kwargs, +): + if base_criterion is None: + base_criterion = torch.nn.CrossEntropyLoss() + + net = TestTimeNet( + module=DomainAwareModule(module, layer_name), + params_to_adapt=["weight", "bias"], + layers_to_adapt="all", + criterion=TestTimeCriterion, + criterion__base_criterion=base_criterion, + criterion__adapt_criterion=TentLoss(), + criterion__reg=1, + **kwargs, + ) + + return net + + +class ShotLoss(BaseDALoss): + def __init__(self): + super().__init__() + + def forward(self, y_pred, pred_label): + loss = shot_full_loss(y_estimate_t=y_pred, y_pred_t=pred_label) + return loss + + +def Shot( + module, + layer_name, + base_criterion=CrossEntropyLabelSmooth, + **kwargs, +): + if base_criterion is None: + base_criterion = torch.nn.CrossEntropyLoss() + + net = TestTimeNet( + module=DomainAwareModule(module, layer_name), + params_to_adapt=["weight", "bias"], + layers_to_adapt="all", + criterion=TestTimeCriterion, + criterion__base_criterion=base_criterion, + criterion__adapt_criterion=ShotLoss(), + criterion__reg=1, + **kwargs, + ) + + return net diff --git a/skada/deep/base.py b/skada/deep/base.py index 40364b89..de8f4368 100644 --- a/skada/deep/base.py +++ b/skada/deep/base.py @@ -1032,7 +1032,9 @@ def _if_given_dict(self, d: dict, y, sample_domain, sample_weight): self._initialize(X, y, sample_domain, sample_weight) def _initialize(self, X:torch.Tensor, y, sample_domain, sample_weight): - if sample_domain is None or len(sample_domain) == 0: + if sample_domain is None or ( + hasattr(sample_domain, '__len__') and len(sample_domain) == 0 + ): sample_domain = _DEFAULT_SAMPLE_DOMAIN_ if isinstance(sample_domain, int): sample_domain = torch.full((X.shape[0],), sample_domain) diff --git a/skada/deep/callbacks.py b/skada/deep/callbacks.py index 9fc7e6b7..f08a86ee 100644 --- a/skada/deep/callbacks.py +++ b/skada/deep/callbacks.py @@ -8,7 +8,54 @@ from skorch.utils import to_tensor from skada.deep.base import DomainAwareNet -from skada.deep.utils import SphericalKMeans +from skada.deep.utils import SphericalKMeans, get_estimated_label_from_centroids + + +class PseudoLabeling(Callback): + """Callback to compute pseudo-labels for target domain samples. + + This callback computes pseudo-labels for the target domain samples at the end of + each epoch. The pseudo-labels are computed using the memory features and outputs + stored in the adaptation criterion. + + """ + + def on_epoch_end( + self, + net: DomainAwareNet, + fc_layer: str, + dataset_target=None, + every_n_epochs: int = 1, + **kwargs, + ): + """TODO Work in progress: Compute pseudo-labels at the end of each epoch. + + Parameters + ---------- + net : NeuralNet + The neural network being trained. + dataset_train : Dataset, optional + The training dataset. + **kwargs : dict + Additional arguments passed to the callback. + """ + X = net._prepare_input(dataset_target) + + # Keep only target samples + target = X.select_target() + X_t = target.X + + if net.criterion__adapt_criterion.n_epochs % every_n_epochs == 0: + with torch.no_grad(): + features_t = net.predict_features(X_t) + features_t = torch.tensor(features_t, device=net.device) + outputs_t = eval(f"net.{fc_layer}")(features_t) + + # Compute pseudo-labels using get_estimated_label + pseudo_labels = get_estimated_label_from_centroids(outputs_t, features_t) + + # Store pseudo-labels in the adaptation criterion + net.criterion__adapt_criterion.pseudo_labels = pseudo_labels class ComputeSourceCentroids(Callback): diff --git a/skada/deep/losses.py b/skada/deep/losses.py index 98c70c4c..d628d9f8 100644 --- a/skada/deep/losses.py +++ b/skada/deep/losses.py @@ -11,12 +11,169 @@ import skorch # noqa: F401 import torch # noqa: F401 import torch.nn.functional as F + +# from scipy.spatial.distance import cdist +from torch.nn import CrossEntropyLoss, LogSoftmax, Module, Softmax from torch.nn.functional import mse_loss from skada.deep.base import BaseDALoss from skada.deep.utils import SphericalKMeans +class CrossEntropyLabelSmooth(Module): + """Estimate the cross-entropy label smooth loss for SHOT method [38]_. + + References + ---------- + .. [38] https://doi.org/10.48550/arXiv.2002.08546 + """ + + def __init__(self, num_classes, epsilon=0.1, size_average=True): + """Init method""" + super().__init__() + self.num_classes = num_classes + self.epsilon = epsilon + self.size_average = size_average + self.logsoftmax = LogSoftmax(dim=1) + + def forward(self, y_pred_s, y_s): + """Estimate the cross-entropy label smooth loss for SHOT method [38]_. + + Parameters + ---------- + y_s : tensor + labels of the source data. Shape (n_samples, n_classes). + y_pred_s : tensor + predictions of the source data. Shape (n_samples, n_classes). + + Returns + ------- + loss : float + The loss of the method. + """ + log_probs = self.logsoftmax(y_pred_s) + y_s = torch.zeros(log_probs.size(), device=y_s.device).scatter_( + 1, y_s.unsqueeze(1), 1 + ) + y_s = (1 - self.epsilon) * y_s + self.epsilon / self.num_classes + if self.size_average: + loss = (-y_s * log_probs).mean(0).sum() + else: + loss = (-y_s * log_probs).sum(1) + return loss + + +def cross_entropy_label_smooth_loss( + y_s: torch.Tensor, + y_pred_s: torch.Tensor, + num_classes: int, + epsilon: float = 0.1, + size_average: bool = True, +) -> float: + """Estimate the crpss-entropy label smooth loss loss for SHOT method [38]_. + + Parameters + ---------- + y_s : tensor + labels of the source data. Shape (n_samples, n_classes). + y_pred_s : tensor + predictions of the source data. Shape (n_samples, n_classes). + num_classes: int + number of classes to predict. + epsilon: float + smoothing parameter + size_average: bool + + Returns + ------- + loss : float + The loss of the method. + """ + log_probs = LogSoftmax(dim=1)(y_pred_s) + y_s = torch.zeros(log_probs.size()).scatter_(1, y_s.unsqueeze(1), 1) + y_s = (1 - epsilon) * y_s + epsilon / num_classes + if size_average: + loss = (-y_s * log_probs).mean(0).sum() + else: + loss = (-y_s * log_probs).sum(1) + return loss + + +def entropy_loss(y_pred_t_softmax: torch.Tensor) -> float: + """Estimate the entropy loss for SHOT method [38]_. + + Parameters + ---------- + y_pred_t_softmax : tensor + softmaxed predictions of the target data. Shape (n_samples, n_classes). + + Returns + ------- + loss : float + The loss of the method. + + References + ---------- + .. [38] https://doi.org/10.48550/arXiv.2002.08546 + """ + entropy = -y_pred_t_softmax * torch.log(y_pred_t_softmax + 1e-5) + return torch.mean(torch.sum(entropy, axis=1)) + + +def diversity_promoting_loss(y_pred_t_softmax: torch.Tensor) -> float: + """Estimate the diversity promoting loss for SHOT method [38]_. + + Parameters + ---------- + y_pred_t_softmax : tensor + softmaxed predictions of the target data. Shape (n_samples, n_classes). + + Returns + ------- + loss : float + The loss of the method. + + References + ---------- + .. [38] https://doi.org/10.48550/arXiv.2002.08546 + """ + msoftmax = y_pred_t_softmax.mean(dim=0) + return -torch.sum(msoftmax * torch.log(msoftmax + 1e-5)) + + +def shot_full_loss( + y_estimate_t: torch.Tensor, + y_pred_t: torch.Tensor, + entropy_weight: float = 1, + div_weight: float = 1, + class_weight: float = 0.1, +) -> float: + """Estimate the full loss for SHOT method [38]_. + + Parameters + ---------- + y_estimate_t : tensor + estimated labels of the target data, obtained from self-supervised + pseudo-labeling strategy. Shape (n_samples, n_classes). + y_pred_t : tensor + predictions of the target data. Shape (n_samples, n_classes). + + Returns + ------- + loss : float + The loss of the method. + + References + ---------- + .. [38] https://doi.org/10.48550/arXiv.2002.08546 + """ + softmax_y_pred_t = Softmax(dim=1)(y_pred_t) + entropy = entropy_loss(softmax_y_pred_t) + div = diversity_promoting_loss(softmax_y_pred_t) + class_term = CrossEntropyLoss()(y_pred_t, y_estimate_t) + return entropy_weight * entropy + div_weight * div + class_term * class_weight + + def deepcoral_loss(features, features_target, assume_centered=False): """Estimate the Frobenius norm divide by 4*n**2 for DeepCORAL method [12]_. @@ -346,7 +503,9 @@ def forward( **kwargs, ): """Compute the domain adaptation loss""" - return 0 + res = torch.tensor(0, dtype=torch.float32) + res.requires_grad = True + return res def probability_scaling(logits, temperature=1): @@ -513,3 +672,9 @@ def nap_loss(features_t, y_pred_t, memory_features, memory_outputs, sample_idx_t classifier_loss = torch.sum(weight_ * loss_) / (torch.sum(weight_).item() + 1e-7) return classifier_loss + + +def softmax_entropy(x: torch.Tensor): + """Compute the entropy of the softmax probabilities.""" + loss = x.softmax(1) * x.log_softmax(1) + return -torch.sum(loss, axis=1) diff --git a/skada/deep/modules.py b/skada/deep/modules.py index 3563d1a4..76a61706 100644 --- a/skada/deep/modules.py +++ b/skada/deep/modules.py @@ -6,6 +6,7 @@ # # License: BSD 3-Clause import torch +import torch.nn.utils.weight_norm as weightNorm from torch import nn from torch.autograd import Function @@ -226,3 +227,114 @@ def forward(self, x, sample_weight=None): x = self.dropout2(x) output = self.fc2(x) return output + + +class ShotFeatureExtractor(nn.Module): + """ + Feature extractor for SHOT. + LeNetBase architecture with two convolutional layers, + followed by two max pooling layers and a dropout layer. + The output is flattened to a vector. + """ + + def __init__(self): + super().__init__() + self.conv_params = nn.Sequential( + nn.Conv2d(1, 20, kernel_size=5), + nn.MaxPool2d(2), + nn.ReLU(), + nn.Conv2d(20, 50, kernel_size=5), + nn.Dropout2d(p=0.5), + nn.MaxPool2d(2), + nn.ReLU(), + ) + self.in_features = 50 * 4 * 4 + + def forward(self, x): + """Forward pass through the bottleneck layer.""" + x = self.conv_params(x) + x = x.view(x.size(0), -1) + return x + + +def init_weights(m): + """Initialize weights of layers using appropriate initialization schemes. + + Parameters + ---------- + m : nn.Module + The layer to initialize. + """ + classname = m.__class__.__name__ + if classname.find("Conv2d") != -1 or classname.find("ConvTranspose2d") != -1: + nn.init.kaiming_uniform_(m.weight) + nn.init.zeros_(m.bias) + elif classname.find("BatchNorm") != -1: + nn.init.normal_(m.weight, 1.0, 0.02) + nn.init.zeros_(m.bias) + elif classname.find("Linear") != -1: + nn.init.xavier_normal_(m.weight) + nn.init.zeros_(m.bias) + + +class FeatBottleneck(nn.Module): + """Bottleneck layer for SHOT architecture.""" + + def __init__(self, feature_dim, bottleneck_dim=256, type="ori"): + super().__init__() + self.bn = nn.BatchNorm1d(bottleneck_dim, affine=True) + self.dropout = nn.Dropout(p=0.5) + self.bottleneck = nn.Linear(feature_dim, bottleneck_dim) + self.bottleneck.apply(init_weights) + self.type = type + + def forward(self, x): + """Forward pass through the bottleneck layer.""" + x = self.bottleneck(x) + if self.type == "bn": + x = self.bn(x) + x = self.dropout(x) + return x + + +class FeatClassifier(nn.Module): + """Classifier layer for SHOT architecture.""" + + def __init__(self, class_num, bottleneck_dim=256, type="linear"): + super().__init__() + if type == "linear": + self.fc = nn.Linear(bottleneck_dim, class_num) + else: + self.fc = weightNorm(nn.Linear(bottleneck_dim, class_num), name="weight") + self.fc.apply(init_weights) + + def forward(self, x): + """Forward pass through the classifier layer.""" + x = self.fc(x) + return x + + +class SHOTNet(nn.Module): + """SHOT Network. + This network consists of a feature extractor, a bottleneck, + and a classifier. The feature extractor is a LeNetBase architecture. + """ + + def __init__(self, class_num, feature_dim=256, type="ori"): + super().__init__() + self.feature_extractor = ShotFeatureExtractor() + self.bottleneck = FeatBottleneck( + feature_dim=self.feature_extractor.in_features, + bottleneck_dim=feature_dim, + type=type, + ) + self.classifier = FeatClassifier( + class_num=class_num, bottleneck_dim=feature_dim, type="linear" + ) + + def forward(self, x): + """XXX add docstring here.""" + x = self.feature_extractor(x) + x = self.bottleneck(x) + x = self.classifier(x) + return x diff --git a/skada/deep/tests/test_deep_test_time.py b/skada/deep/tests/test_deep_test_time.py new file mode 100644 index 00000000..c881955c --- /dev/null +++ b/skada/deep/tests/test_deep_test_time.py @@ -0,0 +1,122 @@ +# Author : Maxence Barneche +# +# License: BSD-3-Clause + +import pytest + +torch = pytest.importorskip("torch") + +import numpy as np +from torch.optim import Adam +from torch.utils.data import DataLoader + +from skada.datasets import make_shifted_datasets +from skada.deep._test_time import Shot, Tent, TestTimeCriterion, TestTimeNet +from skada.deep.base import DomainAwareModule +from skada.deep.losses import TestLoss +from skada.deep.modules import SHOTNet, ToyModule2D + + +@pytest.mark.parametrize( + "epochs_adapt, optimizer_adapt, params_to_adapt, layers_to_adapt", + [ + (None, None, None, None), # Default test + (3, None, None, None), # Test with specific adaptation epochs given + (None, Adam, None, None), + (None, None, ["weight"], ["dropout"]), + (None, None, "all", "all"), + ], # Test with Adam optimizer for adaptation +) +def test_test_time_net(epochs_adapt, optimizer_adapt, params_to_adapt, layers_to_adapt): + num_features = 10 + module = ToyModule2D(num_features=num_features) + criterion = TestTimeCriterion(torch.nn.CrossEntropyLoss(), TestLoss()) + + n_samples = 20 + dataset = make_shifted_datasets( + n_samples_source=n_samples, + n_samples_target=n_samples, + shift="conditional_shift", + noise=0.1, + random_state=42, + ) + X, y, sample_domain = dataset + X = X.astype(np.float32) + + method = TestTimeNet( + DomainAwareModule(module, "dropout"), + epochs_adapt=epochs_adapt, + iterator_train=DataLoader, + criterion=criterion, + params_to_adapt=params_to_adapt, + layers_to_adapt=layers_to_adapt, + batch_size=10, + max_epochs=2, + train_split=None, + optimizer_adapt=optimizer_adapt, + ) + + method.fit(X, y, sample_domain) + method.fit_adapt(X, y, sample_domain) + + +def test_tent(): + num_features = 10 + module = ToyModule2D(num_features=num_features) + + n_samples = 20 + dataset = make_shifted_datasets( + n_samples_source=n_samples, + n_samples_target=n_samples, + shift="conditional_shift", + noise=0.1, + random_state=42, + ) + X, y, sample_domain = dataset + X = X.astype(np.float32) + + method = Tent( + DomainAwareModule(module, "dropout"), + "dropout", + epochs_adapt=3, + iterator_train=DataLoader, + batch_size=10, + max_epochs=2, + train_split=None, + ) + + method.fit(X, y, sample_domain) + method.fit_adapt(X, y, sample_domain) + + +def test_shot(): + num_features = 10 + n_classes = 3 + module = SHOTNet(num_features=num_features) + + n_samples = 20 + dataset = make_shifted_datasets( + n_samples_source=n_samples, + n_samples_target=n_samples, + shift="conditional_shift", + noise=0.1, + random_state=42, + n_classes=n_classes, + n_features=num_features, + ) + X, y, sample_domain = dataset + X = X.astype(np.float32) + + method = Shot( + DomainAwareModule(module, "dropout"), + "dropout", + epochs_adapt=3, + iterator_train=DataLoader, + batch_size=10, + max_epochs=2, + train_split=None, + ) + + ## To Do: To modify for this example + method.fit(X, y, sample_domain) + method.fit_adapt(X, y, sample_domain, pseudo_labels=None) diff --git a/skada/deep/utils.py b/skada/deep/utils.py index 40fed402..2302c5b5 100644 --- a/skada/deep/utils.py +++ b/skada/deep/utils.py @@ -6,11 +6,13 @@ import numbers from functools import partial +import numpy as np from skorch.utils import _identity import torch -from torch.nn import CrossEntropyLoss +from torch.nn import CrossEntropyLoss, Softmax from torch.nn.functional import cosine_similarity from sklearn.utils.validation import check_is_fitted +from scipy.spatial.distance import cdist def _get_intermediate_layers(intermediate_layers, layer_name): @@ -333,3 +335,45 @@ def _compute_dissimilarity_loss(self, X, centroids): dissimilarity_loss = dissimilarities.sum().item() return dissimilarity_loss + + +def get_estimated_label_from_centroids( + y_pred_t: torch.Tensor, y_features_t: torch.Tensor +) -> int: + """Estimate the entropy loss for SHOT method [38]_. + + Parameters + ---------- + y_pred_t : tensor + predictions of the target data. Shape (n_samples, n_classes). + y_features_t : tensor + output of the model just before the classifier. Shape (n_samples, n_classes). + + Returns + ------- + y_estimate_t : int + The estimated target prediction. + + References + ---------- + .. [38] https://doi.org/10.48550/arXiv.2002.08546 + """ + softmax_y_pred_t = Softmax(dim=1)(y_pred_t) + y_features_t_numpy = y_features_t.cpu().numpy() + n_features = y_pred_t.size(1) + + pred_label = 0 + for round in range(1): + if round == 0: + y_estimate_t = ( + softmax_y_pred_t.float().cpu().numpy() + if round == 0 + else np.eye(n_features)[pred_label] + ) + centroids = y_estimate_t.transpose().dot(y_features_t_numpy) / ( + 1e-8 + y_estimate_t.sum(axis=0)[:, None] + ) + cosine_distance = cdist(y_features_t_numpy, centroids, "cosine") + pred_label = cosine_distance.argmin(axis=1) + + return torch.LongTensor(pred_label.astype(int)).to(y_pred_t.device)