diff --git a/README.md b/README.md index e0b09f8..281d9af 100644 --- a/README.md +++ b/README.md @@ -19,44 +19,12 @@ Then, you can import the module `my_pytorch_kit`. ## Usage This package revolves around the [`BaseModel`](my_pytorch_kit/model/models.py), [`Trainer`](my_pytorch_kit/train/train.py) and [`Evaluator`](my_pytorch_kit/evaluation/evaluation.py) classes, which are extended to model, train and evaluate a model respectively. -```mermaid -graph TD - subgraph "Usage Workflow" - A["Define Model
(extends BaseModel)"] - B["Define Data
(Dataset / DataLoader)"] - G["Extras
(Optimizer, Tensorboard)"] - C["Initialize Trainer
(extends Trainer)"] - D["Initialize Evaluator
(extends Evaluator)"] - E["trainer.train()"] - F["evaluator.evaluate()"] - G["Intitialize Tuner
(Hyperparameter Tuning)"] - H["tuner.tune()"] - I["Provided Architectures
(Classifier, AE, VAE, ...)"] - end - %% Define node relationships - A --> C - B --> C - I --> A - A --> D - B --> D - C --> E - D --> F - C --> G - G --> H +

+ +

- %% Style the nodes - style A fill:#fbe,stroke:#333,stroke-width:2px - style B fill:#fbe,stroke:#333,stroke-width:2px - style G fill:#ffc,stroke:#333,stroke-width:2px - style C fill:#cde,stroke:#333,stroke-width:2px - style D fill:#cde,stroke:#333,stroke-width:2px - style E fill:#cfc,stroke:#333,stroke-width:2px - style F fill:#cfc,stroke:#333,stroke-width:2px - style G fill:#cde,stroke:#333,stroke-width:2px - style H fill:#cfc,stroke:#333,stroke-width:2px -``` Furthermore, this package provides architecture implementations and modelling utilities. Currently implemented architectures include: diff --git a/docs/assets/vae_4_generation.png b/docs/assets/vae_4_generation.png index 93cd45e..3547349 100644 Binary files a/docs/assets/vae_4_generation.png and b/docs/assets/vae_4_generation.png differ diff --git a/examples/mnist/classifier/classifier.py b/examples/mnist/classifier/classifier.py index 3062ece..ff4f384 100644 --- a/examples/mnist/classifier/classifier.py +++ b/examples/mnist/classifier/classifier.py @@ -58,7 +58,7 @@ def evaluate_batch(self, model, batch): trainer.train(optimizer, **hparams) - model.save_model("models/classifier.pt") + model.save_model("models/classifier.pt", hparams) else: model.load_model("models/classifier.pt") diff --git a/examples/mnist/classifier/example.py b/examples/mnist/classifier/example.py index adbe4d1..53a01cb 100644 --- a/examples/mnist/classifier/example.py +++ b/examples/mnist/classifier/example.py @@ -92,7 +92,7 @@ def accumulate_result(self, result): def get_result(self): return self.result - def on_eval(self): + def on_eval(self, model): self.batch_count = 0 #++++++++++++++ Here comes the main code ++++++++++++++++# diff --git a/examples/mnist/classifier/tune.py b/examples/mnist/classifier/tune.py index 75224fa..b840a55 100644 --- a/examples/mnist/classifier/tune.py +++ b/examples/mnist/classifier/tune.py @@ -85,7 +85,8 @@ num_search=num_search, ranks_considered=ranks_considered, check_multiplicant=check_multiplier, - mode='dynamic') + mode='dynamic', + model_path='models/mnist_random_dynamic_') result = evaluator.evaluate(model, test_loader) print(f"Model accuracy on test set doing random dynamic search: {result*100:.2f}%") diff --git a/examples/mnist/vae/vae.py b/examples/mnist/vae/vae.py index c5c7a20..73662d0 100644 --- a/examples/mnist/vae/vae.py +++ b/examples/mnist/vae/vae.py @@ -20,25 +20,26 @@ def kl_annealing(epoch, epochs, model, **kwargs): train = input("Train new model? (y/[n]) ").lower() == "y" hparams = { - "learning_rate": 1e-3, + "learning_rate": 3e-3, "batch_size": 64, "epochs": 15, "optimizer_method": "Adam", # "optimizer_kwargs": {"weight_decay": 1e-4}, "loss_func": nn.BCELoss(reduction="sum"), "alpha": 1e-1, - "beta": 1.5e-3, - "classifier_loss_weight": 1.2, + "beta": 1.5e-1, + "classifier_loss_weight": 12, "start_beta": 1e-3, "final_beta": 4e-2, "feature_space": (64, 7, 7), - "latent_dim": 4, + "latent_dim": 2, "sample_input_shape": (1, 1, 28, 28), "classifier_num_layers": 3, # "epoch_function": kl_annealing } model = ImageVAESemiSupervised(**hparams) + model.proper_weight_init() # model = ImageVAE(**hparams) torchsummary.summary(model, (1, 28, 28)) @@ -64,7 +65,10 @@ def kl_annealing(epoch, epochs, model, **kwargs): model.save_model("models/vae.pt", hparams) else: - model.load_model("models/vae.pt") + path = input("Enter path to model (default: models/vae.pt) :") + if path == "": + path = "models/vae.pt" + model.load_model(path) model.eval() diff --git a/examples/mnist/vae/vae_semi_generate.py b/examples/mnist/vae/vae_semi_generate.py index 5c3d897..40199bd 100644 --- a/examples/mnist/vae/vae_semi_generate.py +++ b/examples/mnist/vae/vae_semi_generate.py @@ -4,13 +4,25 @@ import os from my_pytorch_kit.model.vae import ImageVAESemiSupervised +from my_pytorch_kit.model.classifier import ImageClassifier +from my_pytorch_kit.model.ae import ImageAE -def generate_samples(model, latent_dim, num_samples=20): +def generate_samples(model, latent_dim, num_samples=20, check_classifier=False, check_ae=False): """ Generates num_samples samples from the model, plotting them in a grid with their labels """ + classifier = None + if check_classifier: + classifier = ImageClassifier() + classifier.load_model("models/classifier.pt") + + ae = None + if check_ae: + ae = ImageAE() + ae.load_model("models/ae.pt") + num_samples = int(num_samples) n_rows = num_samples // 10 n_cols = 10 @@ -20,13 +32,40 @@ def generate_samples(model, latent_dim, num_samples=20): z = torch.randn((num_samples, latent_dim)) images, labels = model.generate(z) + cl_labels = None + if classifier: + cl_labels = classifier(images) + cl_labels = torch.argmax(cl_labels, dim=1) + + batch_recon_losses = None + if ae: + reconstruction = ae(images) + recon_losses = torch.nn.BCELoss(reduction="none")(reconstruction, images) + batch_recon_losses = recon_losses.mean(dim=(1, 2, 3)) + plt.figure(figsize=(10, 10)) for i in range(num_samples): plt.subplot(n_rows, n_cols, i + 1) plt.imshow(images[i].view(28, 28).cpu().numpy(), cmap="Greys_r") - label = torch.argmax(labels[i]).item() + label = f"{torch.argmax(labels[i]).item()}" + if cl_labels is not None: + label += f" ({cl_labels[i].item()})" + if recon_losses is not None: + label += f" ({batch_recon_losses[i].item():.2f})" + plt.title(label) plt.axis("off") + + extra_text = "Generator label" + if cl_labels is not None: + extra_text += " (Classifier label)" + if recon_losses is not None: + extra_text += " (Reconstruction loss)" + plt.text(-100, 50, extra_text, ha="center", va="center") + + print(labels.shape, cl_labels.shape) + print(torch.nn.CrossEntropyLoss()(labels, cl_labels).item()) + plt.show() @@ -76,7 +115,7 @@ def generate_dataset(model, num_samples, latent_dim, std_divider=1.5, batch_size model.load_model("models/vae_semi_3.pt") - generate_samples(model, hparams["latent_dim"]) + generate_samples(model, hparams["latent_dim"], check_classifier=True, check_ae=True) gen_dataset = input("Generate dataset? (y/[n]): ").lower() == "y" diff --git a/examples/mnist/vae/vae_semi_tune.py b/examples/mnist/vae/vae_semi_tune.py new file mode 100644 index 0000000..87cdbdd --- /dev/null +++ b/examples/mnist/vae/vae_semi_tune.py @@ -0,0 +1,148 @@ + +import torch +import torch.nn as nn +import tqdm + +from my_pytorch_kit.model.vae import ImageVAESemiSupervised +from my_pytorch_kit.train.tune import Tuner +from my_pytorch_kit.evaluation.evaluation import Evaluator +from my_pytorch_kit.model.classifier import ImageClassifier +from my_pytorch_kit.model.ae import ImageAE +from mnist.utils.mnist_utils import get_mnist_loaders +from mnist.autoencoder.ae_plots import plot_latent, plot_reconstructions, plot_label_clusters + + +class GenerationEvaluator(Evaluator): + + + def __init__(self, + latent_dim = 2, + num_samples = 1000, + batch_size = 64, + ce_weight = 1, + bce_weight = 2.5): + super().__init__() + self.batch_count = 0 + self.result = 0 + + self.classifier = ImageClassifier() + self.classifier.load_model("models/classifier.pt") + + self.ae = ImageAE() + self.ae.load_model("models/ae.pt") + + self.latent_dim = latent_dim + self.num_samples = num_samples + self.batch_size = batch_size + + self.bce = nn.BCELoss(reduction="mean") + self.ce = nn.CrossEntropyLoss(reduction="mean", label_smoothing=0.1) + + self.ce_weight = ce_weight + self.bce_weight = bce_weight + + def on_eval(self, model): + + print("\nEvaluating...") + generated_count = 0 + + acc = 0 + + model.eval() + pbar = tqdm.tqdm(total=self.num_samples // self.batch_size + 1) + with torch.no_grad(): + while generated_count < self.num_samples: + + next_batch_size = (self.num_samples - generated_count) % self.batch_size + if next_batch_size == 0: + next_batch_size = self.batch_size + + + z = torch.randn((next_batch_size, self.latent_dim)) + images, labels = model.generate(z) + + cl_labels = self.classifier(images) + cl_labels = torch.argmax(cl_labels, dim=1) + + ce_loss = self.ce(labels, cl_labels) + + + reconstruction = self.ae(images) + recon_losses = self.bce(reconstruction, images) + + loss = self.bce_weight * recon_losses.item() + self.ce_weight * ce_loss.item() + + acc += loss + + generated_count += next_batch_size + + pbar.update(1) + self.result = acc + + def evaluate_batch(self, model, batch): + return 0 + + def accumulate_result(self, result): + return + + def get_result(self): + return self.result + +def kl_annealing(epoch, epochs, model, **kwargs): + model.beta = model.beta + (kwargs["final_beta"] - kwargs["start_beta"]) / (epochs - kwargs["beta_start_delay"]) + + +if __name__ == '__main__': + + hparams = { + "learning_rate": ((1e-4, 1e-2), "log"), + "batch_size": 64, + "epochs": 10, + "patience": 5, + "optimizer_method": "Adam", + # "optimizer_kwargs": {"weight_decay": 1e-4}, + "loss_func": nn.BCELoss(reduction="sum"), + "alpha": ((0, 10), "float"), + "beta": ((0, 10), "float"), + "classifier_loss_weight": ((0, 10), "float"), + "start_beta": ((0, 1), "float"), + "final_beta": ((1, 10), "float"), + "beta_start_delay": ((4, 12), "int"), + "feature_space": ([(64, 7, 7)], "item"), + "latent_dim": 2, + "sample_input_shape": ([(1, 1, 28, 28)], "item"), + "classifier_num_layers": 3, + "use_scheduler": ((True, False, False), "item"), + "use_grad_clip": ((True, False, False), "item"), + "gamma": ((0.8, 0.95), "log"), + "epoch_function": ((kl_annealing, None, None), "item"), + } + + + train_loader, val_loader, test_loader = get_mnist_loaders(hparams["batch_size"]) + + evaluator = GenerationEvaluator() + + tuner = Tuner(ImageVAESemiSupervised) + + num_search = 500 + ranks_considered = 15 + check_multiplier = 3 + + # train model + model, best_config, results = tuner.tune(train_loader, val_loader, hparams, + evaluator=evaluator, + num_search=num_search, + ranks_considered=ranks_considered, + check_multiplicant=check_multiplier, + mode='dynamic', model_path='models/mnist_vae_semi_tuned_') + + model.eval() + + model.save_model("models/mnist_vae_semi_tuned_best.pt", best_config) + + plot_reconstructions(model, test_loader) + + distribution = plot_label_clusters(model.encoder, test_loader) + + plot_latent(model.decoder, dist=distribution, stds=2) diff --git a/examples/mnist/vae/vae_with_ae.py b/examples/mnist/vae/vae_with_ae.py new file mode 100644 index 0000000..bc537e0 --- /dev/null +++ b/examples/mnist/vae/vae_with_ae.py @@ -0,0 +1,74 @@ + +import torch.nn as nn +import torchsummary + +from my_pytorch_kit.model.vae import ImageVAEwithAE +from my_pytorch_kit.train.train import Trainer +from my_pytorch_kit.train.optimizers import get_optimizer_total_optimizer +from mnist.utils.mnist_utils import get_mnist_loaders +from mnist.autoencoder.ae_plots import plot_latent, plot_reconstructions, plot_label_clusters + + +def kl_annealing(epoch, epochs, model, **kwargs): + if epoch < kwargs["beta_start_delay"]: + return + model.beta = model.beta + (kwargs["final_beta"] - kwargs["start_beta"]) / (epochs - kwargs["beta_start_delay"]) + + +if __name__ == '__main__': + + train = input("Train new model? (y/[n]) ").lower() == "y" + + hparams = { + "learning_rate": 1e-3, + "batch_size": 64, + "epochs": 15, + "optimizer_method": "Adam", + # "optimizer_kwargs": {"weight_decay": 1e-4}, + "loss_func": nn.BCELoss(reduction="sum"), + "alpha": 3, + "beta": 1, + "ae_reconstruction_loss_weight": 1, + "start_beta": 0, + "final_beta": 3e-1, + "beta_start_delay": 8, + "feature_space": (64, 7, 7), + "latent_dim": 2, + "sample_input_shape": (1, 1, 28, 28), + # "epoch_function": kl_annealing + } + + model = ImageVAEwithAE(**hparams) + + torchsummary.summary(model, (1, 28, 28)) + + train_loader, val_loader, test_loader = get_mnist_loaders(hparams["batch_size"]) + + optimizer = get_optimizer_total_optimizer(model, use_scheduler=False, use_grad_clip=False, **hparams) + + trainer = Trainer(model, train_loader, val_loader, **hparams) + + + if train: + load_model = input("Load model? (y/[n]) ").lower() == "y" + + if load_model: + path = input("Enter path to model (default: models/vae_with_ae.pt) :") + if path == "": + path = "models/vae_with_ae.pt" + model.load_model(path) + + trainer.train(optimizer, **hparams) + + model.save_model("models/vae_with_ae.pt", hparams) + + else: + model.load_model("models/vae_with_ae.pt") + + model.eval() + + plot_reconstructions(model, test_loader) + + distribution = plot_label_clusters(model.encoder, test_loader) + + plot_latent(model.decoder, dist=distribution, stds=2) diff --git a/examples/models/classifier.pt b/examples/models/classifier.pt index ad7deb7..88b0caf 100644 Binary files a/examples/models/classifier.pt and b/examples/models/classifier.pt differ diff --git a/examples/models/mnist_vae_semi_tuned.pt b/examples/models/mnist_vae_semi_tuned.pt new file mode 100644 index 0000000..fad6628 Binary files /dev/null and b/examples/models/mnist_vae_semi_tuned.pt differ diff --git a/examples/models/vae.pt b/examples/models/vae.pt index 3c914af..8ca2963 100644 Binary files a/examples/models/vae.pt and b/examples/models/vae.pt differ diff --git a/examples/models/vae_with_ae.pt b/examples/models/vae_with_ae.pt new file mode 100644 index 0000000..75936cc Binary files /dev/null and b/examples/models/vae_with_ae.pt differ diff --git a/my_pytorch_kit/evaluation/evaluation.py b/my_pytorch_kit/evaluation/evaluation.py index 1db62f0..3c080c4 100644 --- a/my_pytorch_kit/evaluation/evaluation.py +++ b/my_pytorch_kit/evaluation/evaluation.py @@ -57,9 +57,14 @@ def get_result(self) -> Any: pass - def on_eval(self): + def on_eval(self, model): """ Gets called before evaluation begins. + + Parameters + ---------- + model: torch.nn.Module + The model to evaluate. """ pass @@ -80,7 +85,7 @@ def evaluate(self, model, data_loader) -> Any: Any The result metric. """ - self.on_eval() + self.on_eval(model) model.eval() for batch in data_loader: result = self.evaluate_batch(model, batch) diff --git a/my_pytorch_kit/evaluation/reconstruction.py b/my_pytorch_kit/evaluation/reconstruction.py index 5b543d2..e08de46 100644 --- a/my_pytorch_kit/evaluation/reconstruction.py +++ b/my_pytorch_kit/evaluation/reconstruction.py @@ -36,7 +36,7 @@ def accumulate_result(self, result): def get_result(self): return self.acc - def on_eval(self): + def on_eval(self, model): if self.only_accumulate: self.acc = [] else: diff --git a/my_pytorch_kit/model/models.py b/my_pytorch_kit/model/models.py index c3af817..ccdbc4c 100644 --- a/my_pytorch_kit/model/models.py +++ b/my_pytorch_kit/model/models.py @@ -3,7 +3,7 @@ from abc import abstractmethod import os import inspect -from typing import Optional +from typing import Optional, Union, Dict class BaseModel(nn.Module): """ @@ -42,7 +42,7 @@ def __init_subclass__(cls, **kwargs): ) @abstractmethod - def calc_loss(self, batch, criterion, **kwargs) -> torch.Tensor: + def calc_loss(self, batch, criterion, **kwargs) -> Union[torch.Tensor, Dict[str, torch.Tensor]]: """ Calculates the loss for a given batch and criterion. @@ -59,6 +59,11 @@ def calc_loss(self, batch, criterion, **kwargs) -> torch.Tensor: ------- torch.Tensor The loss for the batch. + OR + dict[str, torch.Tensor] + A dictionary of losses. + Losses should be keyed by name. + The total loss should be keyed by 'loss'. """ pass diff --git a/my_pytorch_kit/model/vae.py b/my_pytorch_kit/model/vae.py index 72f698c..5c1f36d 100644 --- a/my_pytorch_kit/model/vae.py +++ b/my_pytorch_kit/model/vae.py @@ -6,6 +6,7 @@ from my_pytorch_kit.model.models import BaseModel from my_pytorch_kit.model.utils import ConvArchitect, AffineArchitect +from my_pytorch_kit.model.ae import ImageAE class ImageVAE(BaseModel): @@ -46,6 +47,7 @@ def __init__(self, self.alpha = alpha self.beta = beta + self.loss_softmax = nn.Softmax() self.encoder = VariationalEncoder(input_shape, encoder_num_layers, latent_dim, feature_space) self.decoder = VariationalDecoder(input_shape, decoder_num_layers, latent_dim, feature_space) @@ -67,9 +69,20 @@ def calc_loss(self, batch, criterion): kl_div = self.kl_loss(self.params) batch_size = x.size(0) - loss = (self.alpha * reconstruction_loss / batch_size) + (self.beta * kl_div / batch_size) + alpha, beta = self.loss_softmax(torch.tensor([self.alpha, self.beta], dtype=float)).split(1) - return loss + reconstruction_loss = reconstruction_loss * alpha / batch_size + kl_div = kl_div * beta / batch_size + + loss = reconstruction_loss + kl_div + + loss_dict = { + "loss": loss, + "reconstruction_loss": reconstruction_loss, + "kl_div": kl_div + } + + return loss_dict def kl_loss(self, params): """ @@ -101,6 +114,32 @@ def __init__(self, classifier_activation = nn.PReLU(), classifier_loss_weight = 1e-2, **kwargs): + """ + Initializes the Variational Autoencoder. + + Parameters + ---------- + input_shape: tuple + Shape of the input image. + encoder_num_layers: int + Number of encoder layers. + decoder_num_layers: int + Number of decoder layers. + latent_dim: int + Dimension of the latent space. + feature_space: tuple + Shape of the output of the convolutional layers. + beta: float + Weight of the KL divergence loss. + classifier_num_layers: int + Number of layers in the classifier. + classifier_activation: nn.Module + Activation function for the classifier. + classifier_loss_weight: float + Weight of the classifier loss. + """ + + super().__init__( input_shape = input_shape, encoder_num_layers = encoder_num_layers, @@ -117,6 +156,8 @@ def __init__(self, self.classifier = AffineArchitect().build(latent_dim, num_classes, classifier_activation, classifier_num_layers) self.cel = nn.CrossEntropyLoss() + self.proper_weight_init() + def forward(self, x): params = self.encoder(x) @@ -135,18 +176,134 @@ def generate(self, z): def calc_loss(self, batch, criterion): x, y = batch x_hat, y_hat = self.forward(x) + reconstruction_loss = criterion(x_hat, x) kl_div = self.kl_loss(self.params) - one_hot = nn.functional.one_hot(y, self.classifier_num_classes).float() classifier_loss = self.cel(y_hat, one_hot) batch_size = x.size(0) - loss = (self.alpha * reconstruction_loss / batch_size) + (self.beta * kl_div / batch_size) + (self.classifier_loss_weight * classifier_loss / batch_size) + alpha, beta, classifier_loss_weight = self.loss_softmax(torch.tensor([self.alpha, self.beta, self.classifier_loss_weight], dtype=float)).split(1) + + reconstruction_loss = (alpha * reconstruction_loss / batch_size) + kl_div = (beta * kl_div / batch_size) + classifier_loss = (classifier_loss_weight * classifier_loss / batch_size) + + loss = reconstruction_loss + kl_div + classifier_loss + + loss_dict = { + "loss": loss, + "reconstruction_loss": reconstruction_loss, + "kl_div": kl_div, + "classifier_loss": classifier_loss + } + + return loss_dict + + +class ImageVAEwithAE(ImageVAE): + """ + Variational autoencoder, in which autoencoder reconstruction loss is added. + The autoencoder runs on the output of the VAE, ensuring that the VAE is forced + to produce images that can be represented in the latent space of the autoencoder. + """ + + def __init__( + self, + *, + input_shape = (1, 28, 28), + encoder_num_layers = 3, + decoder_num_layers = 3, + latent_dim = 2, + feature_space = (8, 3, 3), + alpha = 1, + beta = 1e-2, + ae_class = ImageAE, + ae_path = 'models/ae.pt', + ae_reconstruction_loss_weight = 1e-2, + ae_reconstruction_loss_criterion = nn.BCELoss(reduction="mean"), + **kwargs + ): + """ + Initializes the Variational Autoencoder. + + Parameters + ---------- + input_shape: tuple + Shape of the input image. + encoder_num_layers: int + Number of encoder layers. + decoder_num_layers: int + Number of decoder layers. + latent_dim: int + Dimension of the latent space. + feature_space: tuple + Shape of the output of the convolutional layers. + beta: float + Weight of the KL divergence loss. + ae_class: nn.Module + Autoencoder class + ae_path: str + Path to the autoencoder model + ae_reconstruction_loss_weight: float + Weight of the autoencoder reconstruction loss + ae_reconstruction_loss_criterion: nn.Module + Criterion for the autoencoder reconstruction loss + """ + super().__init__( + input_shape = input_shape, + encoder_num_layers = encoder_num_layers, + decoder_num_layers = decoder_num_layers, + latent_dim = latent_dim, + feature_space = feature_space, + alpha = alpha, + beta = beta, + **kwargs + ) + + self.ae = ae_class() + self.ae.load_model(ae_path) + for param in self.ae.parameters(): + param.requires_grad = False + + self.ae_loss_weight = ae_reconstruction_loss_weight + self.ae_criterion = ae_reconstruction_loss_criterion + + def forward(self, x): + params = self.encoder(x) + self.params = params + z = self.sampler(params) + x_hat = self.decoder(z) + x_hat = self.sigmoid(x_hat) + return x_hat + + def calc_loss(self, batch, criterion): + x, _ = batch + x_hat = self.forward(x) + + # get losses + reconstruction_loss = criterion(x_hat, x) + kl_div = self.kl_loss(self.params) + ae_recon_loss = self.ae_criterion(self.ae(x_hat), x_hat) + + alpha, beta, ae_loss_weight = self.loss_softmax(torch.tensor([self.alpha, self.beta, self.ae_loss_weight], dtype=float)).split(1) + batch_size = x.size(0) + + # then weight it + reconstruction_loss = reconstruction_loss * alpha / batch_size + kl_div = kl_div * beta / batch_size + ae_recon_loss = ae_recon_loss * ae_loss_weight / batch_size - return loss + loss = reconstruction_loss + kl_div + ae_recon_loss + loss_dict = { + "loss": loss, + "reconstruction_loss": reconstruction_loss, + "kl_div": kl_div, + "ae_recon_loss": ae_recon_loss + } + return loss_dict class VariationalEncoder(nn.Module): """ diff --git a/my_pytorch_kit/train/train.py b/my_pytorch_kit/train/train.py index 4f53505..8be53db 100644 --- a/my_pytorch_kit/train/train.py +++ b/my_pytorch_kit/train/train.py @@ -56,9 +56,6 @@ def __init__(self, if sample_input_shape is not None: self.tb_logger.add_graph(self.model, torch.randn(*sample_input_shape)) - else: - print("Sample input shape not given. Not adding graph to tensorboard logger.") - def train(self, optimizer: TotalOptimizer, @@ -132,7 +129,11 @@ def train(self, training_loop = create_tqdm_bar(self.train_loader, desc=f'Training Epoch [{epoch + 1}/{epochs}]') for train_iteration, batch in training_loop: optimizer.zero_grad() - loss = self.model.calc_loss(batch, loss_func) + returned_loss = self.model.calc_loss(batch, loss_func) + if type(returned_loss) is dict: + loss = returned_loss['loss'] + else: + loss = returned_loss loss.backward() optimizer.step() @@ -146,13 +147,24 @@ def train(self, # Update the tensorboard logger. self.add_tb_scalar(f'{name}/train_loss', loss.item(), epoch * len(self.train_loader) + train_iteration) + if type(returned_loss) is dict: + for loss_name, loss_value in returned_loss.items(): + if loss_name != 'loss': + self.add_tb_scalar(f'{name}/loss_components/{loss_name}', loss_value.item() / loss.item(), epoch * len(self.train_loader) + train_iteration) + + + # VALIDATION self.model.eval() val_loop = create_tqdm_bar(self.val_loader, desc=f'Validation Epoch [{epoch + 1}/{epochs}]') with torch.no_grad(): for val_iteration, batch in val_loop: - loss = self.model.calc_loss(batch, loss_func) + returned_loss = self.model.calc_loss(batch, loss_func) + if type(returned_loss) is dict: + loss = returned_loss['loss'] + else: + loss = returned_loss validation_loss.append(loss.item()) last_cutoff_loss = np.mean(validation_loss[-loss_cutoff:]) @@ -162,6 +174,7 @@ def train(self, # Update the tensorboard logger. self.add_tb_scalar(f'{name}/val_loss', last_cutoff_loss, epoch * len(self.val_loader) + val_iteration) + # best val loss check total_val_loss = np.mean(validation_loss) if total_val_loss < best_val_loss: diff --git a/my_pytorch_kit/train/tune.py b/my_pytorch_kit/train/tune.py index c569627..f04d867 100644 --- a/my_pytorch_kit/train/tune.py +++ b/my_pytorch_kit/train/tune.py @@ -4,8 +4,11 @@ from typing import List, Dict, Tuple import torch from itertools import product +import pprint +import os from my_pytorch_kit.train.train import Trainer +from my_pytorch_kit.evaluation.evaluation import Evaluator from my_pytorch_kit.train.optimizers import get_optimizer_total_optimizer @@ -24,7 +27,13 @@ def __init__(self, model_class, trainer_class = Trainer): self.trainer_class = trainer_class - def tune(self, train_loader, val_loader, parameter_space, mode = 'grid', **kwargs): + def tune(self, + train_loader: torch.utils.data.DataLoader, + val_loader: torch.utils.data.DataLoader, + parameter_space: Dict[str, List] | Dict[str, Tuple[List, str]], + mode: str = 'grid', + evaluator: Evaluator = None, + **kwargs): """ Calls the appropriate search method and returns the best model. @@ -39,19 +48,34 @@ def tune(self, train_loader, val_loader, parameter_space, mode = 'grid', **kwarg Should be Dict[str, List] or Dict[str, Tuple[List, str]]. mode: str 'grid', 'random' or 'dynamic' + evaluator: Evaluator + Evaluator class. Will be used instead of validation loss if given. + Its evaluate() should return a single metric. + + Returns + ------- + best_model: torch.nn.Module + The model performing best on validation set + best_config: dict + The hyperparameter config that performed best + results: list + List of tuples (config, val_loss) """ if mode == 'grid': - return self.grid_search(train_loader, val_loader, parameter_space) + return self.grid_search(train_loader, val_loader, parameter_space, evaluator=evaluator) elif mode == 'random': - return self.random_search(train_loader, val_loader, parameter_space, **kwargs) + return self.random_search(train_loader, val_loader, parameter_space, evaluator=evaluator, **kwargs) elif mode == 'dynamic': - return self.random_dynamic_search(train_loader, val_loader, parameter_space, **kwargs) + return self.random_dynamic_search(train_loader, val_loader, parameter_space, evaluator=evaluator, **kwargs) else: raise ValueError("Invalid mode: {}".format(mode)) - def grid_search(self, train_loader, val_loader, grid_search_spaces: Dict[str, List]): + def grid_search(self, train_loader, + val_loader, + grid_search_spaces: Dict[str, List], + evaluator: Evaluator = None): """ A simple grid search. Searches all combinations of hyperparameters in grid_search_spaces. @@ -66,6 +90,9 @@ def grid_search(self, train_loader, val_loader, grid_search_spaces: Dict[str, Li Hyperparameter search spaces for grid search. Specifies the possible values for each hyperparameter, e.g. {'lr': [1e-3, 1e-4, 1e-5], 'batch_size': [32, 64, 128]} + evaluator: Evaluator + Evaluator class. Will be used instead of validation loss if given. + Its evaluate() should return a single metric. Returns ------- @@ -86,12 +113,15 @@ def grid_search(self, train_loader, val_loader, grid_search_spaces: Dict[str, Li for instance in product(*grid_search_spaces.values()): configs.append(dict(zip(grid_search_spaces.keys(), instance))) - return self.find_best_config(configs, train_loader, val_loader) + return self.find_best_config(configs, train_loader, val_loader, evaluator) - def random_search(self, train_loader, val_loader, + def random_search(self, train_loader, + val_loader, random_search_spaces: Dict[str, Tuple[List, str]], - num_search: int = 10, **kwargs): + num_search: int = 10, + evaluator: Evaluator = None, + **kwargs): """ Samples num_search hyper parameter sets within the provided search spaces and returns the best model. @@ -106,6 +136,9 @@ def random_search(self, train_loader, val_loader, Hyperparameter search spaces for random search num_search: int Number of hyperparameter configs to sample (default: 10) + evaluator: Evaluator + Evaluator class. Will be used instead of validation loss if given. + Its evaluate() should return a single metric. Returns ------- @@ -126,12 +159,16 @@ def random_search(self, train_loader, val_loader, for _ in range(num_search): configs.append(self.random_search_spaces_to_config(random_search_spaces)) - return self.find_best_config(configs, train_loader, val_loader) + return self.find_best_config(configs, train_loader, val_loader, evaluator) def random_dynamic_search(self, train_loader, val_loader, random_search_spaces: Dict[str, Tuple[List, str]], - num_search=10, ranks_considered=15, - check_multiplicant=2, **kwargs): + num_search=10, + ranks_considered=15, + check_multiplicant=2, + evaluator: Evaluator = None, + model_path = None, + **kwargs): """ Samples num_search hyper parameter sets within the provided search space, reducing the search space dynamically by looking at the best results, @@ -152,6 +189,12 @@ def random_dynamic_search(self, train_loader, val_loader, Number of best configs to consider check_multiplicant: int Multiplicant of ranks_considered + evaluator: Evaluator + Evaluator class. Will be used instead of validation loss if given. + Its evaluate() should return a single metric. + model_path : str + Saving path of the model. If given, saves all models in the ranking via + model_path.pt. Returns ------- @@ -163,19 +206,48 @@ def random_dynamic_search(self, train_loader, val_loader, List of tuples (config, val_loss) """ + + # some preprocessing + for key, value in random_search_spaces.items(): + if not isinstance(value, tuple): + random_search_spaces[key] = ([value], 'item') + config_count = 0 configs = [] - # stores tuples of (val_loss, config, model) + # stores tuples of (val_loss, config, model, config_id) ranking = [] configs.append(self.random_search_spaces_to_config(random_search_spaces)) results = [] + def update_ranking(ranking, val_loss, config, model, config_id): + ranking.append((val_loss, config, model, config_id)) + ranking.sort(key=lambda x: x[0]) + new_ranking = ranking[:ranks_considered] + dropped = ranking[ranks_considered:] + + if model_path is not None: + for i in range(len(new_ranking)): + file_name = model_path + str(new_ranking[i][3]) + ".pt" + # check if the file already exists + if not os.path.isfile(file_name): + model.save_model(file_name) + for i in range(len(dropped)): + file_name = model_path + str(dropped[i][3]) + ".pt" + # delete the file if it exists + if os.path.isfile(file_name): + os.remove(file_name) + return new_ranking + + def print_ranking(ranking): + print("\n" + "-"*30 + " RANKING " + "-"*30) for i in range(len(ranking)): - print("Rank {} with best loss {:.4f}: {}".format(i + 1, ranking[i][0], ranking[i][1])) + print("Config #{}: Rank {} with best loss {:.4f}:".format(ranking[i][3], i + 1, ranking[i][0])) + pprint.pprint(ranking[i][1]) + print("-"*69 + "\n") def adapt_search_space(ranking, random_search_space): for name, (rng, mode) in random_search_space.items(): @@ -183,15 +255,18 @@ def adapt_search_space(ranking, random_search_space): new_min = min(ranking, key=lambda x: x[1][name])[1][name] new_max = max(ranking, key=lambda x: x[1][name])[1][name] random_search_space[name] = ([new_min, new_max], mode) - print("New search space:", random_search_space) + print("New search space:") + pprint.pprint(random_search_space) return random_search_space while config_count < num_search: try: config = configs[-1] - print("\nEvaluating Config #{} [of {}]:\n".format( - (config_count), num_search), config) + print("\nEvaluating Config #{} [of {}]:".format( + (config_count), num_search)) + pprint.pprint(config) + print() model = self.model_class(**config) @@ -201,13 +276,13 @@ def adapt_search_space(ranking, random_search_space): val_loss = trainer.train(optimizer, **config) + if evaluator is not None: + val_loss = evaluator.evaluate(model, val_loader) + # add into ranking - if len(ranking) == 0: - ranking.append((val_loss, config, model)) - else: - ranking.append((val_loss, config, model)) - ranking.sort(key=lambda x: x[0]) - ranking = ranking[:ranks_considered] + ranking = update_ranking(ranking, val_loss, config, model, config_count) + + results.append(val_loss) print_ranking(ranking) @@ -219,17 +294,25 @@ def adapt_search_space(ranking, random_search_space): configs.append(self.random_search_spaces_to_config(random_search_spaces)) except KeyboardInterrupt: break + except Exception as e: + print("During model training, got exception: ") + print(e) best_val, best_model, \ best_config = ranking[0][0], ranking[0][2], ranking[0][1] print("\nSearch done. Best Val Loss = {}".format(best_val)) - print("Best Config:", best_config) + print("Best Config #{}: ".format(ranking[0][3])) + pprint.pprint(best_config) return best_model, best_config, list(zip(configs, results)) - def find_best_config(self, configs: List[Dict], train_loader: torch.utils.data.DataLoader, - val_loader: torch.utils.data.DataLoader) -> Tuple[torch.nn.Module, Dict, List]: + def find_best_config(self, + configs: List[Dict], + train_loader: torch.utils.data.DataLoader, + val_loader: torch.utils.data.DataLoader, + evaluator: Evaluator = None + ) -> Tuple[torch.nn.Module, Dict, List]: """ Get a list of hyperparameter configs for random search or grid search, trains a model on all configs and returns the one performing best @@ -243,6 +326,9 @@ def find_best_config(self, configs: List[Dict], train_loader: torch.utils.data.D The training data loader. val_loader: torch.utils.data.DataLoader The validation data loader. + evaluator: Evaluator + Evaluator class. Will be used instead of validation loss if given. + Its evaluate() should return a single metric. Returns ------- @@ -271,6 +357,9 @@ def find_best_config(self, configs: List[Dict], train_loader: torch.utils.data.D val_loss = trainer.train(optimizer, **configs[i]) + if evaluator is not None: + val_loss = evaluator.evaluate(model, val_loader) + results.append(val_loss) if val_loss < best_val: @@ -279,8 +368,10 @@ def find_best_config(self, configs: List[Dict], train_loader: torch.utils.data.D best_model = model - print("\nSearch done. Best Val Loss = {}".format(best_val)) - print("Best Config:", best_config) + print("\nSearch done. Best Metric = {}".format(best_val)) + print("Best Config:") + pprint.pprint(best_config) + return best_model, best_config, list(zip(configs, results))