diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..3c9838f40ce20ab1dbceb9144a48ed8cd325db37 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +*.zip +wandb +logs \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..38184df93ea2c09f6d527abbb7f7c804b014284c --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ + +help: ## Show help + @grep -E '^[.a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +clean: ## Clean autogenerated files + rm -rf dist + find . -type f -name "*.DS_Store" -ls -delete + find . | grep -E "(__pycache__|\.pyc|\.pyo)" | xargs rm -rf + find . | grep -E ".pytest_cache" | xargs rm -rf + find . | grep -E ".ipynb_checkpoints" | xargs rm -rf + rm -f .coverage + +clean-logs: ## Clean logs + rm -rf logs/** + +format: ## Run pre-commit hooks + pre-commit run -a + +sync: ## Merge changes from main branch to your current branch + git pull + git pull origin main + +test: ## Run not slow tests + pytest -k "not slow" + +test-full: ## Run all tests + pytest + +train: ## Train the model + python src/train.py diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..26500e215d0613859a3c2d3e4c8a2ee24885721a --- /dev/null +++ b/README.md @@ -0,0 +1,1297 @@ +
+ +# Lightning-Hydra-Template + +[![python](https://img.shields.io/badge/-Python_3.8_%7C_3.9_%7C_3.10-blue?logo=python&logoColor=white)](https://github.com/pre-commit/pre-commit) +[![pytorch](https://img.shields.io/badge/PyTorch_2.0+-ee4c2c?logo=pytorch&logoColor=white)](https://pytorch.org/get-started/locally/) +[![lightning](https://img.shields.io/badge/-Lightning_2.0+-792ee5?logo=pytorchlightning&logoColor=white)](https://pytorchlightning.ai/) +[![hydra](https://img.shields.io/badge/Config-Hydra_1.3-89b8cd)](https://hydra.cc/) +[![black](https://img.shields.io/badge/Code%20Style-Black-black.svg?labelColor=gray)](https://black.readthedocs.io/en/stable/) +[![isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
+[![tests](https://github.com/ashleve/lightning-hydra-template/actions/workflows/test.yml/badge.svg)](https://github.com/ashleve/lightning-hydra-template/actions/workflows/test.yml) +[![code-quality](https://github.com/ashleve/lightning-hydra-template/actions/workflows/code-quality-main.yaml/badge.svg)](https://github.com/ashleve/lightning-hydra-template/actions/workflows/code-quality-main.yaml) +[![codecov](https://codecov.io/gh/ashleve/lightning-hydra-template/branch/main/graph/badge.svg)](https://codecov.io/gh/ashleve/lightning-hydra-template)
+[![license](https://img.shields.io/badge/License-MIT-green.svg?labelColor=gray)](https://github.com/ashleve/lightning-hydra-template#license) +[![PRs](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/ashleve/lightning-hydra-template/pulls) +[![contributors](https://img.shields.io/github/contributors/ashleve/lightning-hydra-template.svg)](https://github.com/ashleve/lightning-hydra-template/graphs/contributors) + +A clean template to kickstart your deep learning project πŸš€βš‘πŸ”₯
+Click on [Use this template](https://github.com/ashleve/lightning-hydra-template/generate) to initialize new repository. + +_Suggestions are always welcome!_ + +
+ +
+ +## πŸ“ŒΒ Β Introduction + +**Why you might want to use it:** + +βœ… Save on boilerplate
+Easily add new models, datasets, tasks, experiments, and train on different accelerators, like multi-GPU, TPU or SLURM clusters. + +βœ… Education
+Thoroughly commented. You can use this repo as a learning resource. + +βœ… Reusability
+Collection of useful MLOps tools, configs, and code snippets. You can use this repo as a reference for various utilities. + +**Why you might not want to use it:** + +❌ Things break from time to time
+Lightning and Hydra are still evolving and integrate many libraries, which means sometimes things break. For the list of currently known problems visit [this page](https://github.com/ashleve/lightning-hydra-template/labels/bug). + +❌ Not adjusted for data engineering
+Template is not really adjusted for building data pipelines that depend on each other. It's more efficient to use it for model prototyping on ready-to-use data. + +❌ Overfitted to simple use case
+The configuration setup is built with simple lightning training in mind. You might need to put some effort to adjust it for different use cases, e.g. lightning fabric. + +❌ Might not support your workflow
+For example, you can't resume hydra-based multirun or hyperparameter search. + +> **Note**: _Keep in mind this is unofficial community project._ + +
+ +## Main Technologies + +[PyTorch Lightning](https://github.com/PyTorchLightning/pytorch-lightning) - a lightweight PyTorch wrapper for high-performance AI research. Think of it as a framework for organizing your PyTorch code. + +[Hydra](https://github.com/facebookresearch/hydra) - a framework for elegantly configuring complex applications. The key feature is the ability to dynamically create a hierarchical configuration by composition and override it through config files and the command line. + +
+ +## Main Ideas + +- [**Rapid Experimentation**](#your-superpowers): thanks to hydra command line superpowers +- [**Minimal Boilerplate**](#how-it-works): thanks to automating pipelines with config instantiation +- [**Main Configs**](#main-config): allow you to specify default training configuration +- [**Experiment Configs**](#experiment-config): allow you to override chosen hyperparameters and version control experiments +- [**Workflow**](#workflow): comes down to 4 simple steps +- [**Experiment Tracking**](#experiment-tracking): Tensorboard, W&B, Neptune, Comet, MLFlow and CSVLogger +- [**Logs**](#logs): all logs (checkpoints, configs, etc.) are stored in a dynamically generated folder structure +- [**Hyperparameter Search**](#hyperparameter-search): simple search is effortless with Hydra plugins like Optuna Sweeper +- [**Tests**](#tests): generic, easy-to-adapt smoke tests for speeding up the development +- [**Continuous Integration**](#continuous-integration): automatically test and lint your repo with Github Actions +- [**Best Practices**](#best-practices): a couple of recommended tools, practices and standards + +
+ +## Project Structure + +The directory structure of new project looks like this: + +``` +β”œβ”€β”€ .github <- Github Actions workflows +β”‚ +β”œβ”€β”€ configs <- Hydra configs +β”‚ β”œβ”€β”€ callbacks <- Callbacks configs +β”‚ β”œβ”€β”€ data <- Data configs +β”‚ β”œβ”€β”€ debug <- Debugging configs +β”‚ β”œβ”€β”€ experiment <- Experiment configs +β”‚ β”œβ”€β”€ extras <- Extra utilities configs +β”‚ β”œβ”€β”€ hparams_search <- Hyperparameter search configs +β”‚ β”œβ”€β”€ hydra <- Hydra configs +β”‚ β”œβ”€β”€ local <- Local configs +β”‚ β”œβ”€β”€ logger <- Logger configs +β”‚ β”œβ”€β”€ model <- Model configs +β”‚ β”œβ”€β”€ paths <- Project paths configs +β”‚ β”œβ”€β”€ trainer <- Trainer configs +β”‚ β”‚ +β”‚ β”œβ”€β”€ eval.yaml <- Main config for evaluation +β”‚ └── train.yaml <- Main config for training +β”‚ +β”œβ”€β”€ data <- Project data +β”‚ +β”œβ”€β”€ logs <- Logs generated by hydra and lightning loggers +β”‚ +β”œβ”€β”€ notebooks <- Jupyter notebooks. Naming convention is a number (for ordering), +β”‚ the creator's initials, and a short `-` delimited description, +β”‚ e.g. `1.0-jqp-initial-data-exploration.ipynb`. +β”‚ +β”œβ”€β”€ scripts <- Shell scripts +β”‚ +β”œβ”€β”€ src <- Source code +β”‚ β”œβ”€β”€ data <- Data scripts +β”‚ β”œβ”€β”€ models <- Model scripts +β”‚ β”œβ”€β”€ utils <- Utility scripts +β”‚ β”‚ +β”‚ β”œβ”€β”€ eval.py <- Run evaluation +β”‚ └── train.py <- Run training +β”‚ +β”œβ”€β”€ tests <- Tests of any kind +β”‚ +β”œβ”€β”€ .env.example <- Example of file for storing private environment variables +β”œβ”€β”€ .gitignore <- List of files ignored by git +β”œβ”€β”€ .pre-commit-config.yaml <- Configuration of pre-commit hooks for code formatting +β”œβ”€β”€ .project-root <- File for inferring the position of project root directory +β”œβ”€β”€ environment.yaml <- File for installing conda environment +β”œβ”€β”€ Makefile <- Makefile with commands like `make train` or `make test` +β”œβ”€β”€ pyproject.toml <- Configuration options for testing and linting +β”œβ”€β”€ requirements.txt <- File for installing python dependencies +β”œβ”€β”€ setup.py <- File for installing project as a package +└── README.md +``` + +
+ +## πŸš€Β Β Quickstart + +```bash +# clone project +git clone https://github.com/ashleve/lightning-hydra-template +cd lightning-hydra-template + +# [OPTIONAL] create conda environment +conda create -n myenv python=3.9 +conda activate myenv + +# install pytorch according to instructions +# https://pytorch.org/get-started/ + +# install requirements +pip install -r requirements.txt +``` + +Template contains example with MNIST classification.
+When running `python src/train.py` you should see something like this: + +
+ +![](https://github.com/ashleve/lightning-hydra-template/blob/resources/terminal.png) + +
+ +## ⚑  Your Superpowers + +
+Override any config parameter from command line + +```bash +python train.py trainer.max_epochs=20 model.optimizer.lr=1e-4 +``` + +> **Note**: You can also add new parameters with `+` sign. + +```bash +python train.py +model.new_param="owo" +``` + +
+ +
+Train on CPU, GPU, multi-GPU and TPU + +```bash +# train on CPU +python train.py trainer=cpu + +# train on 1 GPU +python train.py trainer=gpu + +# train on TPU +python train.py +trainer.tpu_cores=8 + +# train with DDP (Distributed Data Parallel) (4 GPUs) +python train.py trainer=ddp trainer.devices=4 + +# train with DDP (Distributed Data Parallel) (8 GPUs, 2 nodes) +python train.py trainer=ddp trainer.devices=4 trainer.num_nodes=2 + +# simulate DDP on CPU processes +python train.py trainer=ddp_sim trainer.devices=2 + +# accelerate training on mac +python train.py trainer=mps +``` + +> **Warning**: Currently there are problems with DDP mode, read [this issue](https://github.com/ashleve/lightning-hydra-template/issues/393) to learn more. + +
+ +
+Train with mixed precision + +```bash +# train with pytorch native automatic mixed precision (AMP) +python train.py trainer=gpu +trainer.precision=16 +``` + +
+ + + +
+Train model with any logger available in PyTorch Lightning, like W&B or Tensorboard + +```yaml +# set project and entity names in `configs/logger/wandb` +wandb: + project: "your_project_name" + entity: "your_wandb_team_name" +``` + +```bash +# train model with Weights&Biases (link to wandb dashboard should appear in the terminal) +python train.py logger=wandb +``` + +> **Note**: Lightning provides convenient integrations with most popular logging frameworks. Learn more [here](#experiment-tracking). + +> **Note**: Using wandb requires you to [setup account](https://www.wandb.com/) first. After that just complete the config as below. + +> **Note**: Click [here](https://wandb.ai/hobglob/template-dashboard/) to see example wandb dashboard generated with this template. + +
+ +
+Train model with chosen experiment config + +```bash +python train.py experiment=example +``` + +> **Note**: Experiment configs are placed in [configs/experiment/](configs/experiment/). + +
+ +
+Attach some callbacks to run + +```bash +python train.py callbacks=default +``` + +> **Note**: Callbacks can be used for things such as as model checkpointing, early stopping and [many more](https://pytorch-lightning.readthedocs.io/en/latest/extensions/callbacks.html#built-in-callbacks). + +> **Note**: Callbacks configs are placed in [configs/callbacks/](configs/callbacks/). + +
+ +
+Use different tricks available in Pytorch Lightning + +```yaml +# gradient clipping may be enabled to avoid exploding gradients +python train.py +trainer.gradient_clip_val=0.5 + +# run validation loop 4 times during a training epoch +python train.py +trainer.val_check_interval=0.25 + +# accumulate gradients +python train.py +trainer.accumulate_grad_batches=10 + +# terminate training after 12 hours +python train.py +trainer.max_time="00:12:00:00" +``` + +> **Note**: PyTorch Lightning provides about [40+ useful trainer flags](https://pytorch-lightning.readthedocs.io/en/latest/common/trainer.html#trainer-flags). + +
+ +
+Easily debug + +```bash +# runs 1 epoch in default debugging mode +# changes logging directory to `logs/debugs/...` +# sets level of all command line loggers to 'DEBUG' +# enforces debug-friendly configuration +python train.py debug=default + +# run 1 train, val and test loop, using only 1 batch +python train.py debug=fdr + +# print execution time profiling +python train.py debug=profiler + +# try overfitting to 1 batch +python train.py debug=overfit + +# raise exception if there are any numerical anomalies in tensors, like NaN or +/-inf +python train.py +trainer.detect_anomaly=true + +# use only 20% of the data +python train.py +trainer.limit_train_batches=0.2 \ ++trainer.limit_val_batches=0.2 +trainer.limit_test_batches=0.2 +``` + +> **Note**: Visit [configs/debug/](configs/debug/) for different debugging configs. + +
+ +
+Resume training from checkpoint + +```yaml +python train.py ckpt_path="/path/to/ckpt/name.ckpt" +``` + +> **Note**: Checkpoint can be either path or URL. + +> **Note**: Currently loading ckpt doesn't resume logger experiment, but it will be supported in future Lightning release. + +
+ +
+Evaluate checkpoint on test dataset + +```yaml +python eval.py ckpt_path="/path/to/ckpt/name.ckpt" +``` + +> **Note**: Checkpoint can be either path or URL. + +
+ +
+Create a sweep over hyperparameters + +```bash +# this will run 6 experiments one after the other, +# each with different combination of batch_size and learning rate +python train.py -m data.batch_size=32,64,128 model.lr=0.001,0.0005 +``` + +> **Note**: Hydra composes configs lazily at job launch time. If you change code or configs after launching a job/sweep, the final composed configs might be impacted. + +
+ +
+Create a sweep over hyperparameters with Optuna + +```bash +# this will run hyperparameter search defined in `configs/hparams_search/mnist_optuna.yaml` +# over chosen experiment config +python train.py -m hparams_search=mnist_optuna experiment=example +``` + +> **Note**: Using [Optuna Sweeper](https://hydra.cc/docs/next/plugins/optuna_sweeper) doesn't require you to add any boilerplate to your code, everything is defined in a [single config file](configs/hparams_search/mnist_optuna.yaml). + +> **Warning**: Optuna sweeps are not failure-resistant (if one job crashes then the whole sweep crashes). + +
+ +
+Execute all experiments from folder + +```bash +python train.py -m 'experiment=glob(*)' +``` + +> **Note**: Hydra provides special syntax for controlling behavior of multiruns. Learn more [here](https://hydra.cc/docs/next/tutorials/basic/running_your_app/multi-run). The command above executes all experiments from [configs/experiment/](configs/experiment/). + +
+ +
+Execute run for multiple different seeds + +```bash +python train.py -m seed=1,2,3,4,5 trainer.deterministic=True logger=csv tags=["benchmark"] +``` + +> **Note**: `trainer.deterministic=True` makes pytorch more deterministic but impacts the performance. + +
+ +
+Execute sweep on a remote AWS cluster + +> **Note**: This should be achievable with simple config using [Ray AWS launcher for Hydra](https://hydra.cc/docs/next/plugins/ray_launcher). Example is not implemented in this template. + +
+ + + +
+Use Hydra tab completion + +> **Note**: Hydra allows you to autocomplete config argument overrides in shell as you write them, by pressing `tab` key. Read the [docs](https://hydra.cc/docs/tutorials/basic/running_your_app/tab_completion). + +
+ +
+Apply pre-commit hooks + +```bash +pre-commit run -a +``` + +> **Note**: Apply pre-commit hooks to do things like auto-formatting code and configs, performing code analysis or removing output from jupyter notebooks. See [# Best Practices](#best-practices) for more. + +Update pre-commit hook versions in `.pre-commit-config.yaml` with: + +```bash +pre-commit autoupdate +``` + +
+ +
+Run tests + +```bash +# run all tests +pytest + +# run tests from specific file +pytest tests/test_train.py + +# run all tests except the ones marked as slow +pytest -k "not slow" +``` + +
+ +
+Use tags + +Each experiment should be tagged in order to easily filter them across files or in logger UI: + +```bash +python train.py tags=["mnist","experiment_X"] +``` + +> **Note**: You might need to escape the bracket characters in your shell with `python train.py tags=\["mnist","experiment_X"\]`. + +If no tags are provided, you will be asked to input them from command line: + +```bash +>>> python train.py tags=[] +[2022-07-11 15:40:09,358][src.utils.utils][INFO] - Enforcing tags! +[2022-07-11 15:40:09,359][src.utils.rich_utils][WARNING] - No tags provided in config. Prompting user to input tags... +Enter a list of comma separated tags (dev): +``` + +If no tags are provided for multirun, an error will be raised: + +```bash +>>> python train.py -m +x=1,2,3 tags=[] +ValueError: Specify tags before launching a multirun! +``` + +> **Note**: Appending lists from command line is currently not supported in hydra :( + +
+ +
+ +## ❀️  Contributions + +This project exists thanks to all the people who contribute. + +![Contributors](https://readme-contributors.now.sh/ashleve/lightning-hydra-template?extension=jpg&width=400&aspectRatio=1) + +Have a question? Found a bug? Missing a specific feature? Feel free to file a new issue, discussion or PR with respective title and description. + +Before making an issue, please verify that: + +- The problem still exists on the current `main` branch. +- Your python dependencies are updated to recent versions. + +Suggestions for improvements are always welcome! + +
+ +## How It Works + +All PyTorch Lightning modules are dynamically instantiated from module paths specified in config. Example model config: + +```yaml +_target_: src.models.mnist_model.MNISTLitModule +lr: 0.001 +net: + _target_: src.models.components.simple_dense_net.SimpleDenseNet + input_size: 784 + lin1_size: 256 + lin2_size: 256 + lin3_size: 256 + output_size: 10 +``` + +Using this config we can instantiate the object with the following line: + +```python +model = hydra.utils.instantiate(config.model) +``` + +This allows you to easily iterate over new models! Every time you create a new one, just specify its module path and parameters in appropriate config file.
+ +Switch between models and datamodules with command line arguments: + +```bash +python train.py model=mnist +``` + +Example pipeline managing the instantiation logic: [src/train.py](src/train.py). + +
+ +## Main Config + +Location: [configs/train.yaml](configs/train.yaml)
+Main project config contains default training configuration.
+It determines how config is composed when simply executing command `python train.py`.
+ +
+Show main project config + +```yaml +# order of defaults determines the order in which configs override each other +defaults: + - _self_ + - data: mnist.yaml + - model: mnist.yaml + - callbacks: default.yaml + - logger: null # set logger here or use command line (e.g. `python train.py logger=csv`) + - trainer: default.yaml + - paths: default.yaml + - extras: default.yaml + - hydra: default.yaml + + # experiment configs allow for version control of specific hyperparameters + # e.g. best hyperparameters for given model and datamodule + - experiment: null + + # config for hyperparameter optimization + - hparams_search: null + + # optional local config for machine/user specific settings + # it's optional since it doesn't need to exist and is excluded from version control + - optional local: default.yaml + + # debugging config (enable through command line, e.g. `python train.py debug=default) + - debug: null + +# task name, determines output directory path +task_name: "train" + +# tags to help you identify your experiments +# you can overwrite this in experiment configs +# overwrite from command line with `python train.py tags="[first_tag, second_tag]"` +# appending lists from command line is currently not supported :( +# https://github.com/facebookresearch/hydra/issues/1547 +tags: ["dev"] + +# set False to skip model training +train: True + +# evaluate on test set, using best model weights achieved during training +# lightning chooses best weights based on the metric specified in checkpoint callback +test: True + +# simply provide checkpoint path to resume training +ckpt_path: null + +# seed for random number generators in pytorch, numpy and python.random +seed: null +``` + +
+ +
+ +## Experiment Config + +Location: [configs/experiment](configs/experiment)
+Experiment configs allow you to overwrite parameters from main config.
+For example, you can use them to version control best hyperparameters for each combination of model and dataset. + +
+Show example experiment config + +```yaml +# @package _global_ + +# to execute this experiment run: +# python train.py experiment=example + +defaults: + - override /data: mnist.yaml + - override /model: mnist.yaml + - override /callbacks: default.yaml + - override /trainer: default.yaml + +# all parameters below will be merged with parameters from default configurations set above +# this allows you to overwrite only specified parameters + +tags: ["mnist", "simple_dense_net"] + +seed: 12345 + +trainer: + min_epochs: 10 + max_epochs: 10 + gradient_clip_val: 0.5 + +model: + optimizer: + lr: 0.002 + net: + lin1_size: 128 + lin2_size: 256 + lin3_size: 64 + +data: + batch_size: 64 + +logger: + wandb: + tags: ${tags} + group: "mnist" +``` + +
+ +
+ +## Workflow + +**Basic workflow** + +1. Write your PyTorch Lightning module (see [models/mnist_module.py](src/models/mnist_module.py) for example) +2. Write your PyTorch Lightning datamodule (see [data/mnist_datamodule.py](src/data/mnist_datamodule.py) for example) +3. Write your experiment config, containing paths to model and datamodule +4. Run training with chosen experiment config: + ```bash + python src/train.py experiment=experiment_name.yaml + ``` + +**Experiment design** + +_Say you want to execute many runs to plot how accuracy changes in respect to batch size._ + +1. Execute the runs with some config parameter that allows you to identify them easily, like tags: + + ```bash + python train.py -m logger=csv data.batch_size=16,32,64,128 tags=["batch_size_exp"] + ``` + +2. Write a script or notebook that searches over the `logs/` folder and retrieves csv logs from runs containing given tags in config. Plot the results. + +
+ +## Logs + +Hydra creates new output directory for every executed run. + +Default logging structure: + +``` +β”œβ”€β”€ logs +β”‚ β”œβ”€β”€ task_name +β”‚ β”‚ β”œβ”€β”€ runs # Logs generated by single runs +β”‚ β”‚ β”‚ β”œβ”€β”€ YYYY-MM-DD_HH-MM-SS # Datetime of the run +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ .hydra # Hydra logs +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ csv # Csv logs +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ wandb # Weights&Biases logs +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ checkpoints # Training checkpoints +β”‚ β”‚ β”‚ β”‚ └── ... # Any other thing saved during training +β”‚ β”‚ β”‚ └── ... +β”‚ β”‚ β”‚ +β”‚ β”‚ └── multiruns # Logs generated by multiruns +β”‚ β”‚ β”œβ”€β”€ YYYY-MM-DD_HH-MM-SS # Datetime of the multirun +β”‚ β”‚ β”‚ β”œβ”€β”€1 # Multirun job number +β”‚ β”‚ β”‚ β”œβ”€β”€2 +β”‚ β”‚ β”‚ └── ... +β”‚ β”‚ └── ... +β”‚ β”‚ +β”‚ └── debugs # Logs generated when debugging config is attached +β”‚ └── ... +``` + + + +You can change this structure by modifying paths in [hydra configuration](configs/hydra). + +
+ +## Experiment Tracking + +PyTorch Lightning supports many popular logging frameworks: [Weights&Biases](https://www.wandb.com/), [Neptune](https://neptune.ai/), [Comet](https://www.comet.ml/), [MLFlow](https://mlflow.org), [Tensorboard](https://www.tensorflow.org/tensorboard/). + +These tools help you keep track of hyperparameters and output metrics and allow you to compare and visualize results. To use one of them simply complete its configuration in [configs/logger](configs/logger) and run: + +```bash +python train.py logger=logger_name +``` + +You can use many of them at once (see [configs/logger/many_loggers.yaml](configs/logger/many_loggers.yaml) for example). + +You can also write your own logger. + +Lightning provides convenient method for logging custom metrics from inside LightningModule. Read the [docs](https://pytorch-lightning.readthedocs.io/en/latest/extensions/logging.html#automatic-logging) or take a look at [MNIST example](src/models/mnist_module.py). + +
+ +## Tests + +Template comes with generic tests implemented with `pytest`. + +```bash +# run all tests +pytest + +# run tests from specific file +pytest tests/test_train.py + +# run all tests except the ones marked as slow +pytest -k "not slow" +``` + +Most of the implemented tests don't check for any specific output - they exist to simply verify that executing some commands doesn't end up in throwing exceptions. You can execute them once in a while to speed up the development. + +Currently, the tests cover cases like: + +- running 1 train, val and test step +- running 1 epoch on 1% of data, saving ckpt and resuming for the second epoch +- running 2 epochs on 1% of data, with DDP simulated on CPU + +And many others. You should be able to modify them easily for your use case. + +There is also `@RunIf` decorator implemented, that allows you to run tests only if certain conditions are met, e.g. GPU is available or system is not windows. See the [examples](tests/test_train.py). + +
+ +## Hyperparameter Search + +You can define hyperparameter search by adding new config file to [configs/hparams_search](configs/hparams_search). + +
+Show example hyperparameter search config + +```yaml +# @package _global_ + +defaults: + - override /hydra/sweeper: optuna + +# choose metric which will be optimized by Optuna +# make sure this is the correct name of some metric logged in lightning module! +optimized_metric: "val/acc_best" + +# here we define Optuna hyperparameter search +# it optimizes for value returned from function with @hydra.main decorator +hydra: + sweeper: + _target_: hydra_plugins.hydra_optuna_sweeper.optuna_sweeper.OptunaSweeper + + # 'minimize' or 'maximize' the objective + direction: maximize + + # total number of runs that will be executed + n_trials: 20 + + # choose Optuna hyperparameter sampler + # docs: https://optuna.readthedocs.io/en/stable/reference/samplers.html + sampler: + _target_: optuna.samplers.TPESampler + seed: 1234 + n_startup_trials: 10 # number of random sampling runs before optimization starts + + # define hyperparameter search space + params: + model.optimizer.lr: interval(0.0001, 0.1) + data.batch_size: choice(32, 64, 128, 256) + model.net.lin1_size: choice(64, 128, 256) + model.net.lin2_size: choice(64, 128, 256) + model.net.lin3_size: choice(32, 64, 128, 256) +``` + +
+ +Next, execute it with: `python train.py -m hparams_search=mnist_optuna` + +Using this approach doesn't require adding any boilerplate to code, everything is defined in a single config file. The only necessary thing is to return the optimized metric value from the launch file. + +You can use different optimization frameworks integrated with Hydra, like [Optuna, Ax or Nevergrad](https://hydra.cc/docs/plugins/optuna_sweeper/). + +The `optimization_results.yaml` will be available under `logs/task_name/multirun` folder. + +This approach doesn't support resuming interrupted search and advanced techniques like prunning - for more sophisticated search and workflows, you should probably write a dedicated optimization task (without multirun feature). + +
+ +## Continuous Integration + +Template comes with CI workflows implemented in Github Actions: + +- `.github/workflows/test.yaml`: running all tests with pytest +- `.github/workflows/code-quality-main.yaml`: running pre-commits on main branch for all files +- `.github/workflows/code-quality-pr.yaml`: running pre-commits on pull requests for modified files only + +
+ +## Distributed Training + +Lightning supports multiple ways of doing distributed training. The most common one is DDP, which spawns separate process for each GPU and averages gradients between them. To learn about other approaches read the [lightning docs](https://lightning.ai/docs/pytorch/latest/advanced/speed.html). + +You can run DDP on mnist example with 4 GPUs like this: + +```bash +python train.py trainer=ddp +``` + +> **Note**: When using DDP you have to be careful how you write your models - read the [docs](https://lightning.ai/docs/pytorch/latest/advanced/speed.html). + +
+ +## Accessing Datamodule Attributes In Model + +The simplest way is to pass datamodule attribute directly to model on initialization: + +```python +# ./src/train.py +datamodule = hydra.utils.instantiate(config.data) +model = hydra.utils.instantiate(config.model, some_param=datamodule.some_param) +``` + +> **Note**: Not a very robust solution, since it assumes all your datamodules have `some_param` attribute available. + +Similarly, you can pass a whole datamodule config as an init parameter: + +```python +# ./src/train.py +model = hydra.utils.instantiate(config.model, dm_conf=config.data, _recursive_=False) +``` + +You can also pass a datamodule config parameter to your model through variable interpolation: + +```yaml +# ./configs/model/my_model.yaml +_target_: src.models.my_module.MyLitModule +lr: 0.01 +some_param: ${data.some_param} +``` + +Another approach is to access datamodule in LightningModule directly through Trainer: + +```python +# ./src/models/mnist_module.py +def on_train_start(self): + self.some_param = self.trainer.datamodule.some_param +``` + +> **Note**: This only works after the training starts since otherwise trainer won't be yet available in LightningModule. + +
+ +## Best Practices + +
+Use Miniconda + +It's usually unnecessary to install full anaconda environment, miniconda should be enough (weights around 80MB). + +Big advantage of conda is that it allows for installing packages without requiring certain compilers or libraries to be available in the system (since it installs precompiled binaries), so it often makes it easier to install some dependencies e.g. cudatoolkit for GPU support. + +It also allows you to access your environments globally which might be more convenient than creating new local environment for every project. + +Example installation: + +```bash +wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh +bash Miniconda3-latest-Linux-x86_64.sh +``` + +Update conda: + +```bash +conda update -n base -c defaults conda +``` + +Create new conda environment: + +```bash +conda create -n myenv python=3.10 +conda activate myenv +``` + +
+ +
+Use automatic code formatting + +Use pre-commit hooks to standardize code formatting of your project and save mental energy.
+Simply install pre-commit package with: + +```bash +pip install pre-commit +``` + +Next, install hooks from [.pre-commit-config.yaml](.pre-commit-config.yaml): + +```bash +pre-commit install +``` + +After that your code will be automatically reformatted on every new commit. + +To reformat all files in the project use command: + +```bash +pre-commit run -a +``` + +To update hook versions in [.pre-commit-config.yaml](.pre-commit-config.yaml) use: + +```bash +pre-commit autoupdate +``` + +
+ +
+Set private environment variables in .env file + +System specific variables (e.g. absolute paths to datasets) should not be under version control or it will result in conflict between different users. Your private keys also shouldn't be versioned since you don't want them to be leaked.
+ +Template contains `.env.example` file, which serves as an example. Create a new file called `.env` (this name is excluded from version control in .gitignore). +You should use it for storing environment variables like this: + +``` +MY_VAR=/home/user/my_system_path +``` + +All variables from `.env` are loaded in `train.py` automatically. + +Hydra allows you to reference any env variable in `.yaml` configs like this: + +```yaml +path_to_data: ${oc.env:MY_VAR} +``` + +
+ +
+Name metrics using '/' character + +Depending on which logger you're using, it's often useful to define metric name with `/` character: + +```python +self.log("train/loss", loss) +``` + +This way loggers will treat your metrics as belonging to different sections, which helps to get them organised in UI. + +
+ +
+Use torchmetrics + +Use official [torchmetrics](https://github.com/PytorchLightning/metrics) library to ensure proper calculation of metrics. This is especially important for multi-GPU training! + +For example, instead of calculating accuracy by yourself, you should use the provided `Accuracy` class like this: + +```python +from torchmetrics.classification.accuracy import Accuracy + + +class LitModel(LightningModule): + def __init__(self) + self.train_acc = Accuracy() + self.val_acc = Accuracy() + + def training_step(self, batch, batch_idx): + ... + acc = self.train_acc(predictions, targets) + self.log("train/acc", acc) + ... + + def validation_step(self, batch, batch_idx): + ... + acc = self.val_acc(predictions, targets) + self.log("val/acc", acc) + ... +``` + +Make sure to use different metric instance for each step to ensure proper value reduction over all GPU processes. + +Torchmetrics provides metrics for most use cases, like F1 score or confusion matrix. Read [documentation](https://torchmetrics.readthedocs.io/en/latest/#more-reading) for more. + +
+ +
+Follow PyTorch Lightning style guide + +The style guide is available [here](https://pytorch-lightning.readthedocs.io/en/latest/starter/style_guide.html).
+ +1. Be explicit in your init. Try to define all the relevant defaults so that the user doesn’t have to guess. Provide type hints. This way your module is reusable across projects! + + ```python + class LitModel(LightningModule): + def __init__(self, layer_size: int = 256, lr: float = 0.001): + ``` + +2. Preserve the recommended method order. + + ```python + class LitModel(LightningModule): + + def __init__(): + ... + + def forward(): + ... + + def training_step(): + ... + + def training_step_end(): + ... + + def on_train_epoch_end(): + ... + + def validation_step(): + ... + + def validation_step_end(): + ... + + def on_validation_epoch_end(): + ... + + def test_step(): + ... + + def test_step_end(): + ... + + def on_test_epoch_end(): + ... + + def configure_optimizers(): + ... + + def any_extra_hook(): + ... + ``` + +
+ +
+Version control your data and models with DVC + +Use [DVC](https://dvc.org) to version control big files, like your data or trained ML models.
+To initialize the dvc repository: + +```bash +dvc init +``` + +To start tracking a file or directory, use `dvc add`: + +```bash +dvc add data/MNIST +``` + +DVC stores information about the added file (or a directory) in a special .dvc file named data/MNIST.dvc, a small text file with a human-readable format. This file can be easily versioned like source code with Git, as a placeholder for the original data: + +```bash +git add data/MNIST.dvc data/.gitignore +git commit -m "Add raw data" +``` + +
+ +
+Support installing project as a package + +It allows other people to easily use your modules in their own projects. +Change name of the `src` folder to your project name and complete the `setup.py` file. + +Now your project can be installed from local files: + +```bash +pip install -e . +``` + +Or directly from git repository: + +```bash +pip install git+git://github.com/YourGithubName/your-repo-name.git --upgrade +``` + +So any file can be easily imported into any other file like so: + +```python +from project_name.models.mnist_module import MNISTLitModule +from project_name.data.mnist_datamodule import MNISTDataModule +``` + +
+ +
+Keep local configs out of code versioning + +Some configurations are user/machine/installation specific (e.g. configuration of local cluster, or harddrive paths on a specific machine). For such scenarios, a file [configs/local/default.yaml](configs/local/) can be created which is automatically loaded but not tracked by Git. + +For example, you can use it for a SLURM cluster config: + +```yaml +# @package _global_ + +defaults: + - override /hydra/launcher@_here_: submitit_slurm + +data_dir: /mnt/scratch/data/ + +hydra: + launcher: + timeout_min: 1440 + gpus_per_task: 1 + gres: gpu:1 + job: + env_set: + MY_VAR: /home/user/my/system/path + MY_KEY: asdgjhawi8y23ihsghsueity23ihwd +``` + +
+ +
+ +## Resources + +This template was inspired by: + +- [PyTorchLightning/deep-learning-project-template](https://github.com/PyTorchLightning/deep-learning-project-template) +- [drivendata/cookiecutter-data-science](https://github.com/drivendata/cookiecutter-data-science) +- [lucmos/nn-template](https://github.com/lucmos/nn-template) + +Other useful repositories: + +- [jxpress/lightning-hydra-template-vertex-ai](https://github.com/jxpress/lightning-hydra-template-vertex-ai) - lightning-hydra-template integration with Vertex AI hyperparameter tuning and custom training job + + + +
+ +## License + +Lightning-Hydra-Template is licensed under the MIT License. + +``` +MIT License + +Copyright (c) 2021 ashleve + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +
+
+
+
+ +**DELETE EVERYTHING ABOVE FOR YOUR PROJECT** + +______________________________________________________________________ + +
+ +# Your Project Name + +PyTorch +Lightning +Config: Hydra +Template
+[![Paper](http://img.shields.io/badge/paper-arxiv.1001.2234-B31B1B.svg)](https://www.nature.com/articles/nature14539) +[![Conference](http://img.shields.io/badge/AnyConference-year-4b44ce.svg)](https://papers.nips.cc/paper/2020) + +
+ +## Description + +What it does + +## Installation + +#### Pip + +```bash +# clone project +git clone https://github.com/YourGithubName/your-repo-name +cd your-repo-name + +# [OPTIONAL] create conda environment +conda create -n myenv python=3.9 +conda activate myenv + +# install pytorch according to instructions +# https://pytorch.org/get-started/ + +# install requirements +pip install -r requirements.txt +``` + +#### Conda + +```bash +# clone project +git clone https://github.com/YourGithubName/your-repo-name +cd your-repo-name + +# create conda environment and install dependencies +conda env create -f environment.yaml -n myenv + +# activate conda environment +conda activate myenv +``` + +## How to run + +Train model with default configuration + +```bash +# train on CPU +python src/train.py trainer=cpu + +# train on GPU +python src/train.py trainer=gpu +``` + +Train model with chosen experiment configuration from [configs/experiment/](configs/experiment/) + +```bash +python src/train.py experiment=experiment_name.yaml +``` + +You can override any parameter from command line like this + +```bash +python src/train.py trainer.max_epochs=20 data.batch_size=64 +``` diff --git a/configs/__init__.py b/configs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..56bf7f4aa4906bc0f997132708cc0826c198e4aa --- /dev/null +++ b/configs/__init__.py @@ -0,0 +1 @@ +# this file is needed here to include configs when building project as a package diff --git a/configs/callbacks/default.yaml b/configs/callbacks/default.yaml new file mode 100644 index 0000000000000000000000000000000000000000..836c6361b3157bf7046b0e8d353265371ae23081 --- /dev/null +++ b/configs/callbacks/default.yaml @@ -0,0 +1,22 @@ +defaults: + - model_checkpoint + - early_stopping + - model_summary + - rich_progress_bar + - _self_ + +model_checkpoint: + dirpath: ${paths.root_dir}/checkpoints + filename: "epoch_{epoch:03d}" + monitor: "val/psnr" + mode: "max" + save_last: True + auto_insert_metric_name: False + +early_stopping: + monitor: "val/psnr" + patience: 100 + mode: "max" + +model_summary: + max_depth: -1 diff --git a/configs/callbacks/early_stopping.yaml b/configs/callbacks/early_stopping.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c826c8d58651a5e2c7cca0e99948a9b6ccabccf3 --- /dev/null +++ b/configs/callbacks/early_stopping.yaml @@ -0,0 +1,15 @@ +# https://lightning.ai/docs/pytorch/stable/api/lightning.pytorch.callbacks.EarlyStopping.html + +early_stopping: + _target_: lightning.pytorch.callbacks.EarlyStopping + monitor: ??? # quantity to be monitored, must be specified !!! + min_delta: 0. # minimum change in the monitored quantity to qualify as an improvement + patience: 3 # number of checks with no improvement after which training will be stopped + verbose: False # verbosity mode + mode: "min" # "max" means higher metric value is better, can be also "min" + strict: True # whether to crash the training if monitor is not found in the validation metrics + check_finite: True # when set True, stops training when the monitor becomes NaN or infinite + stopping_threshold: null # stop training immediately once the monitored quantity reaches this threshold + divergence_threshold: null # stop training as soon as the monitored quantity becomes worse than this threshold + check_on_train_epoch_end: null # whether to run early stopping at the end of the training epoch + # log_rank_zero_only: False # this keyword argument isn't available in stable version diff --git a/configs/callbacks/model_checkpoint.yaml b/configs/callbacks/model_checkpoint.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bf946e88b1ecfaf96efa91428e4f38e17267b25f --- /dev/null +++ b/configs/callbacks/model_checkpoint.yaml @@ -0,0 +1,17 @@ +# https://lightning.ai/docs/pytorch/stable/api/lightning.pytorch.callbacks.ModelCheckpoint.html + +model_checkpoint: + _target_: lightning.pytorch.callbacks.ModelCheckpoint + dirpath: null # directory to save the model file + filename: null # checkpoint filename + monitor: null # name of the logged metric which determines when model is improving + verbose: False # verbosity mode + save_last: null # additionally always save an exact copy of the last checkpoint to a file last.ckpt + save_top_k: 1 # save k best models (determined by above metric) + mode: "min" # "max" means higher metric value is better, can be also "min" + auto_insert_metric_name: True # when True, the checkpoints filenames will contain the metric name + save_weights_only: False # if True, then only the model’s weights will be saved + every_n_train_steps: null # number of training steps between checkpoints + train_time_interval: null # checkpoints are monitored at the specified time interval + every_n_epochs: null # number of epochs between checkpoints + save_on_train_epoch_end: null # whether to run checkpointing at the end of the training epoch or the end of validation diff --git a/configs/callbacks/model_summary.yaml b/configs/callbacks/model_summary.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b75981d8cd5d73f61088d80495dc540274bca3d1 --- /dev/null +++ b/configs/callbacks/model_summary.yaml @@ -0,0 +1,5 @@ +# https://lightning.ai/docs/pytorch/stable/api/lightning.pytorch.callbacks.RichModelSummary.html + +model_summary: + _target_: lightning.pytorch.callbacks.RichModelSummary + max_depth: 1 # the maximum depth of layer nesting that the summary will include diff --git a/configs/callbacks/none.yaml b/configs/callbacks/none.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/configs/callbacks/rich_progress_bar.yaml b/configs/callbacks/rich_progress_bar.yaml new file mode 100644 index 0000000000000000000000000000000000000000..de6f1ccb11205a4db93645fb6f297e50205de172 --- /dev/null +++ b/configs/callbacks/rich_progress_bar.yaml @@ -0,0 +1,4 @@ +# https://lightning.ai/docs/pytorch/latest/api/lightning.pytorch.callbacks.RichProgressBar.html + +rich_progress_bar: + _target_: lightning.pytorch.callbacks.RichProgressBar diff --git a/configs/data/swim.yaml b/configs/data/swim.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f1e31b948d758a56f9a6af29b3e31b740d8d4385 --- /dev/null +++ b/configs/data/swim.yaml @@ -0,0 +1,4 @@ +_target_: swim.data.swim_data.SwimDataModule +root_dir: /home/qninh/projects/swim_/datasets/swim_data +batch_size: 4 +img_size: 64 diff --git a/configs/debug/default.yaml b/configs/debug/default.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1886902b39f1be560e314bce7b3778f95b44754c --- /dev/null +++ b/configs/debug/default.yaml @@ -0,0 +1,35 @@ +# @package _global_ + +# default debugging setup, runs 1 full epoch +# other debugging configs can inherit from this one + +# overwrite task name so debugging logs are stored in separate folder +task_name: "debug" + +# disable callbacks and loggers during debugging +callbacks: null +logger: null + +extras: + ignore_warnings: False + enforce_tags: False + +# sets level of all command line loggers to 'DEBUG' +# https://hydra.cc/docs/tutorials/basic/running_your_app/logging/ +hydra: + job_logging: + root: + level: DEBUG + + # use this to also set hydra loggers to 'DEBUG' + # verbose: True + +trainer: + max_epochs: 1 + accelerator: cpu # debuggers don't like gpus + devices: 1 # debuggers don't like multiprocessing + detect_anomaly: true # raise exception if NaN or +/-inf is detected in any tensor + +data: + num_workers: 0 # debuggers don't like multiprocessing + pin_memory: False # disable gpu memory pin diff --git a/configs/debug/fdr.yaml b/configs/debug/fdr.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7f2d34fa37c31017e749d5a4fc5ae6763e688b46 --- /dev/null +++ b/configs/debug/fdr.yaml @@ -0,0 +1,9 @@ +# @package _global_ + +# runs 1 train, 1 validation and 1 test step + +defaults: + - default + +trainer: + fast_dev_run: true diff --git a/configs/debug/limit.yaml b/configs/debug/limit.yaml new file mode 100644 index 0000000000000000000000000000000000000000..514d77fbd1475b03fff0372e3da3c2fa7ea7d190 --- /dev/null +++ b/configs/debug/limit.yaml @@ -0,0 +1,12 @@ +# @package _global_ + +# uses only 1% of the training data and 5% of validation/test data + +defaults: + - default + +trainer: + max_epochs: 3 + limit_train_batches: 0.01 + limit_val_batches: 0.05 + limit_test_batches: 0.05 diff --git a/configs/debug/overfit.yaml b/configs/debug/overfit.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9906586a67a12aa81ff69138f589a366dbe2222f --- /dev/null +++ b/configs/debug/overfit.yaml @@ -0,0 +1,13 @@ +# @package _global_ + +# overfits to 3 batches + +defaults: + - default + +trainer: + max_epochs: 20 + overfit_batches: 3 + +# model ckpt and early stopping need to be disabled during overfitting +callbacks: null diff --git a/configs/debug/profiler.yaml b/configs/debug/profiler.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2bd7da87ae23ed425ace99b09250a76a5634a3fb --- /dev/null +++ b/configs/debug/profiler.yaml @@ -0,0 +1,12 @@ +# @package _global_ + +# runs with execution time profiling + +defaults: + - default + +trainer: + max_epochs: 1 + profiler: "simple" + # profiler: "advanced" + # profiler: "pytorch" diff --git a/configs/eval.yaml b/configs/eval.yaml new file mode 100644 index 0000000000000000000000000000000000000000..be312992b2a486b04d83a54dbd8f670d94979709 --- /dev/null +++ b/configs/eval.yaml @@ -0,0 +1,18 @@ +# @package _global_ + +defaults: + - _self_ + - data: mnist # choose datamodule with `test_dataloader()` for evaluation + - model: mnist + - logger: null + - trainer: default + - paths: default + - extras: default + - hydra: default + +task_name: "eval" + +tags: ["dev"] + +# passing checkpoint path is necessary for evaluation +ckpt_path: ??? diff --git a/configs/experiment/example.yaml b/configs/experiment/example.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9a93b540ca5e3a0bd9838b00ea59b29af9470257 --- /dev/null +++ b/configs/experiment/example.yaml @@ -0,0 +1,41 @@ +# @package _global_ + +# to execute this experiment run: +# python train.py experiment=example + +defaults: + - override /data: mnist + - override /model: mnist + - override /callbacks: default + - override /trainer: default + +# all parameters below will be merged with parameters from default configurations set above +# this allows you to overwrite only specified parameters + +tags: ["mnist", "simple_dense_net"] + +seed: 12345 + +trainer: + min_epochs: 10 + max_epochs: 10 + gradient_clip_val: 0.5 + +model: + optimizer: + lr: 0.002 + net: + lin1_size: 128 + lin2_size: 256 + lin3_size: 64 + compile: false + +data: + batch_size: 64 + +logger: + wandb: + tags: ${tags} + group: "mnist" + aim: + experiment: "mnist" diff --git a/configs/extras/default.yaml b/configs/extras/default.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b9c6b622283a647fbc513166fc14f016cc3ed8a0 --- /dev/null +++ b/configs/extras/default.yaml @@ -0,0 +1,8 @@ +# disable python warnings if they annoy you +ignore_warnings: False + +# ask user for tags if none are provided in the config +enforce_tags: True + +# pretty print config tree at the start of the run using Rich library +print_config: True diff --git a/configs/hparams_search/mnist_optuna.yaml b/configs/hparams_search/mnist_optuna.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1391183ebcdec3d8f5eb61374e0719d13c7545da --- /dev/null +++ b/configs/hparams_search/mnist_optuna.yaml @@ -0,0 +1,52 @@ +# @package _global_ + +# example hyperparameter optimization of some experiment with Optuna: +# python train.py -m hparams_search=mnist_optuna experiment=example + +defaults: + - override /hydra/sweeper: optuna + +# choose metric which will be optimized by Optuna +# make sure this is the correct name of some metric logged in lightning module! +optimized_metric: "val/acc_best" + +# here we define Optuna hyperparameter search +# it optimizes for value returned from function with @hydra.main decorator +# docs: https://hydra.cc/docs/next/plugins/optuna_sweeper +hydra: + mode: "MULTIRUN" # set hydra to multirun by default if this config is attached + + sweeper: + _target_: hydra_plugins.hydra_optuna_sweeper.optuna_sweeper.OptunaSweeper + + # storage URL to persist optimization results + # for example, you can use SQLite if you set 'sqlite:///example.db' + storage: null + + # name of the study to persist optimization results + study_name: null + + # number of parallel workers + n_jobs: 1 + + # 'minimize' or 'maximize' the objective + direction: maximize + + # total number of runs that will be executed + n_trials: 20 + + # choose Optuna hyperparameter sampler + # you can choose bayesian sampler (tpe), random search (without optimization), grid sampler, and others + # docs: https://optuna.readthedocs.io/en/stable/reference/samplers.html + sampler: + _target_: optuna.samplers.TPESampler + seed: 1234 + n_startup_trials: 10 # number of random sampling runs before optimization starts + + # define hyperparameter search space + params: + model.optimizer.lr: interval(0.0001, 0.1) + data.batch_size: choice(32, 64, 128, 256) + model.net.lin1_size: choice(64, 128, 256) + model.net.lin2_size: choice(64, 128, 256) + model.net.lin3_size: choice(32, 64, 128, 256) diff --git a/configs/hydra/default.yaml b/configs/hydra/default.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a61e9b3a39a2fe08adf8c9e8ff8dc2abb9a4ef2f --- /dev/null +++ b/configs/hydra/default.yaml @@ -0,0 +1,19 @@ +# https://hydra.cc/docs/configure_hydra/intro/ + +# enable color logging +defaults: + - override hydra_logging: colorlog + - override job_logging: colorlog + +# output directory, generated dynamically on each run +run: + dir: ${paths.log_dir}/${task_name}/runs/${now:%Y-%m-%d}_${now:%H-%M-%S} +sweep: + dir: ${paths.log_dir}/${task_name}/multiruns/${now:%Y-%m-%d}_${now:%H-%M-%S} + subdir: ${hydra.job.num} + +job_logging: + handlers: + file: + # Incorporates fix from https://github.com/facebookresearch/hydra/pull/2242 + filename: ${hydra.runtime.output_dir}/${task_name}.log diff --git a/configs/local/.gitkeep b/configs/local/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/configs/logger/aim.yaml b/configs/logger/aim.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8f9f6adad7feb2780c2efd5ddb0ed053621e05f8 --- /dev/null +++ b/configs/logger/aim.yaml @@ -0,0 +1,28 @@ +# https://aimstack.io/ + +# example usage in lightning module: +# https://github.com/aimhubio/aim/blob/main/examples/pytorch_lightning_track.py + +# open the Aim UI with the following command (run in the folder containing the `.aim` folder): +# `aim up` + +aim: + _target_: aim.pytorch_lightning.AimLogger + repo: ${paths.root_dir} # .aim folder will be created here + # repo: "aim://ip_address:port" # can instead provide IP address pointing to Aim remote tracking server which manages the repo, see https://aimstack.readthedocs.io/en/latest/using/remote_tracking.html# + + # aim allows to group runs under experiment name + experiment: null # any string, set to "default" if not specified + + train_metric_prefix: "train/" + val_metric_prefix: "val/" + test_metric_prefix: "test/" + + # sets the tracking interval in seconds for system usage metrics (CPU, GPU, memory, etc.) + system_tracking_interval: 10 # set to null to disable system metrics tracking + + # enable/disable logging of system params such as installed packages, git info, env vars, etc. + log_system_params: true + + # enable/disable tracking console logs (default value is true) + capture_terminal_logs: false # set to false to avoid infinite console log loop issue https://github.com/aimhubio/aim/issues/2550 diff --git a/configs/logger/comet.yaml b/configs/logger/comet.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e0789274e2137ee6c97ca37a5d56c2b8abaf0aaa --- /dev/null +++ b/configs/logger/comet.yaml @@ -0,0 +1,12 @@ +# https://www.comet.ml + +comet: + _target_: lightning.pytorch.loggers.comet.CometLogger + api_key: ${oc.env:COMET_API_TOKEN} # api key is loaded from environment variable + save_dir: "${paths.output_dir}" + project_name: "lightning-hydra-template" + rest_api_key: null + # experiment_name: "" + experiment_key: null # set to resume experiment + offline: False + prefix: "" diff --git a/configs/logger/csv.yaml b/configs/logger/csv.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fa028e9c146430c319101ffdfce466514338591c --- /dev/null +++ b/configs/logger/csv.yaml @@ -0,0 +1,7 @@ +# csv logger built in lightning + +csv: + _target_: lightning.pytorch.loggers.csv_logs.CSVLogger + save_dir: "${paths.output_dir}" + name: "csv/" + prefix: "" diff --git a/configs/logger/many_loggers.yaml b/configs/logger/many_loggers.yaml new file mode 100644 index 0000000000000000000000000000000000000000..dd586800bdccb4e8f4b0236a181b7ddd756ba9ab --- /dev/null +++ b/configs/logger/many_loggers.yaml @@ -0,0 +1,9 @@ +# train with many loggers at once + +defaults: + # - comet + - csv + # - mlflow + # - neptune + - tensorboard + - wandb diff --git a/configs/logger/mlflow.yaml b/configs/logger/mlflow.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f8fb7e685fa27fc8141387a421b90a0b9b492d9e --- /dev/null +++ b/configs/logger/mlflow.yaml @@ -0,0 +1,12 @@ +# https://mlflow.org + +mlflow: + _target_: lightning.pytorch.loggers.mlflow.MLFlowLogger + # experiment_name: "" + # run_name: "" + tracking_uri: ${paths.log_dir}/mlflow/mlruns # run `mlflow ui` command inside the `logs/mlflow/` dir to open the UI + tags: null + # save_dir: "./mlruns" + prefix: "" + artifact_location: null + # run_id: "" diff --git a/configs/logger/neptune.yaml b/configs/logger/neptune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8233c140018ecce6ab62971beed269991d31c89b --- /dev/null +++ b/configs/logger/neptune.yaml @@ -0,0 +1,9 @@ +# https://neptune.ai + +neptune: + _target_: lightning.pytorch.loggers.neptune.NeptuneLogger + api_key: ${oc.env:NEPTUNE_API_TOKEN} # api key is loaded from environment variable + project: username/lightning-hydra-template + # name: "" + log_model_checkpoints: True + prefix: "" diff --git a/configs/logger/tensorboard.yaml b/configs/logger/tensorboard.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2bd31f6d8ba68d1f5c36a804885d5b9f9c1a9302 --- /dev/null +++ b/configs/logger/tensorboard.yaml @@ -0,0 +1,10 @@ +# https://www.tensorflow.org/tensorboard/ + +tensorboard: + _target_: lightning.pytorch.loggers.tensorboard.TensorBoardLogger + save_dir: "${paths.output_dir}/tensorboard/" + name: null + log_graph: False + default_hp_metric: True + prefix: "" + # version: "" diff --git a/configs/logger/wandb.yaml b/configs/logger/wandb.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2be91d51d5743e851ab2538f0701521fb3adbfdc --- /dev/null +++ b/configs/logger/wandb.yaml @@ -0,0 +1,16 @@ +# https://wandb.ai + +wandb: + _target_: lightning.pytorch.loggers.wandb.WandbLogger + # name: "" # name of the run (normally generated by wandb) + save_dir: "${paths.output_dir}" + offline: False + id: null # pass correct id to resume experiment! + anonymous: null # enable anonymous logging + project: "swim" + log_model: False # upload lightning ckpts + prefix: "" # a string to put at the beginning of metric keys + # entity: "" # set to name of your wandb team + group: "" + tags: [] + job_type: "" diff --git a/configs/model/autoencoder.yaml b/configs/model/autoencoder.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e2287867bf241894096f79aabcec76dd7ecf2064 --- /dev/null +++ b/configs/model/autoencoder.yaml @@ -0,0 +1,11 @@ +_target_: swim.models.autoencoder.Autoencoder + +learning_rate: 1e-4 + +channels: 128 +channel_multipliers: [1, 2, 4] +n_resnet_blocks: 1 +in_channels: 3 +out_channels: 3 +z_channels: 4 +emb_channels: 4 diff --git a/configs/paths/default.yaml b/configs/paths/default.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ec81db2d34712909a79be3e42e65efe08c35ecee --- /dev/null +++ b/configs/paths/default.yaml @@ -0,0 +1,18 @@ +# path to root directory +# this requires PROJECT_ROOT environment variable to exist +# you can replace it with "." if you want the root to be the current working directory +root_dir: ${oc.env:PROJECT_ROOT} + +# path to data directory +data_dir: ${paths.root_dir}/data/ + +# path to logging directory +log_dir: ${paths.root_dir}/logs/ + +# path to output directory, created dynamically by hydra +# path generation pattern is specified in `configs/hydra/default.yaml` +# use it to store all files generated during the run, like ckpts and metrics +output_dir: ${hydra:runtime.output_dir} + +# path to working directory +work_dir: ${hydra:runtime.cwd} diff --git a/configs/train.yaml b/configs/train.yaml new file mode 100644 index 0000000000000000000000000000000000000000..33367bafb78d84f82d6a93e9a96e24ac0aa0dc0d --- /dev/null +++ b/configs/train.yaml @@ -0,0 +1,49 @@ +# @package _global_ + +# specify here default configuration +# order of defaults determines the order in which configs override each other +defaults: + - _self_ + - data: swim + - model: autoencoder + - callbacks: default + - logger: wandb # set logger here or use command line (e.g. `python train.py logger=tensorboard`) + - trainer: gpu + - paths: default + - extras: default + - hydra: default + + # experiment configs allow for version control of specific hyperparameters + # e.g. best hyperparameters for given model and datamodule + - experiment: null + + # config for hyperparameter optimization + - hparams_search: null + + # optional local config for machine/user specific settings + # it's optional since it doesn't need to exist and is excluded from version control + - optional local: default + + # debugging config (enable through command line, e.g. `python train.py debug=default) + - debug: null + +# task name, determines output directory path +task_name: "train" + +# tags to help you identify your experiments +# you can overwrite this in experiment configs +# overwrite from command line with `python train.py tags="[first_tag, second_tag]"` +tags: ["dev"] + +# set False to skip model training +train: True + +# evaluate on test set, using best model weights achieved during training +# lightning chooses best weights based on the metric specified in checkpoint callback +test: True + +# simply provide checkpoint path to resume training +ckpt_path: null + +# seed for random number generators in pytorch, numpy and python.random +seed: 42 diff --git a/configs/trainer/cpu.yaml b/configs/trainer/cpu.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b7d6767e60c956567555980654f15e7bb673a41f --- /dev/null +++ b/configs/trainer/cpu.yaml @@ -0,0 +1,5 @@ +defaults: + - default + +accelerator: cpu +devices: 1 diff --git a/configs/trainer/ddp.yaml b/configs/trainer/ddp.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ab8f89004c399a33440f014fa27e040d4e952bc2 --- /dev/null +++ b/configs/trainer/ddp.yaml @@ -0,0 +1,9 @@ +defaults: + - default + +strategy: ddp + +accelerator: gpu +devices: 4 +num_nodes: 1 +sync_batchnorm: True diff --git a/configs/trainer/ddp_sim.yaml b/configs/trainer/ddp_sim.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8404419e5c295654967d0dfb73a7366e75be2f1f --- /dev/null +++ b/configs/trainer/ddp_sim.yaml @@ -0,0 +1,7 @@ +defaults: + - default + +# simulate DDP on CPU, useful for debugging +accelerator: cpu +devices: 2 +strategy: ddp_spawn diff --git a/configs/trainer/default.yaml b/configs/trainer/default.yaml new file mode 100644 index 0000000000000000000000000000000000000000..50905e7fdf158999e7c726edfff1a4dc16d548da --- /dev/null +++ b/configs/trainer/default.yaml @@ -0,0 +1,19 @@ +_target_: lightning.pytorch.trainer.Trainer + +default_root_dir: ${paths.output_dir} + +min_epochs: 1 # prevents early stopping +max_epochs: 10 + +accelerator: cpu +devices: 1 + +# mixed precision for extra speed-up +# precision: 16 + +# perform a validation loop every N training epochs +check_val_every_n_epoch: 1 + +# set True to to ensure deterministic results +# makes training slower but gives more reproducibility than just setting seeds +deterministic: False diff --git a/configs/trainer/gpu.yaml b/configs/trainer/gpu.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b2389510a90f5f0161cff6ccfcb4a96097ddf9a1 --- /dev/null +++ b/configs/trainer/gpu.yaml @@ -0,0 +1,5 @@ +defaults: + - default + +accelerator: gpu +devices: 1 diff --git a/configs/trainer/mps.yaml b/configs/trainer/mps.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1ecf6d5cc3a34ca127c5510f4a18e989561e38e4 --- /dev/null +++ b/configs/trainer/mps.yaml @@ -0,0 +1,5 @@ +defaults: + - default + +accelerator: mps +devices: 1 diff --git a/environment.yaml b/environment.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f74ee8c7215a1fa902ef9e5cadfaff3a5ceddaa8 --- /dev/null +++ b/environment.yaml @@ -0,0 +1,45 @@ +# reasons you might want to use `environment.yaml` instead of `requirements.txt`: +# - pip installs packages in a loop, without ensuring dependencies across all packages +# are fulfilled simultaneously, but conda achieves proper dependency control across +# all packages +# - conda allows for installing packages without requiring certain compilers or +# libraries to be available in the system, since it installs precompiled binaries + +name: myenv + +channels: + - pytorch + - conda-forge + - defaults + +# it is strongly recommended to specify versions of packages installed through conda +# to avoid situation when version-unspecified packages install their latest major +# versions which can sometimes break things + +# current approach below keeps the dependencies in the same major versions across all +# users, but allows for different minor and patch versions of packages where backwards +# compatibility is usually guaranteed + +dependencies: + - python=3.10 + - pytorch=2.* + - torchvision=0.* + - lightning=2.* + - torchmetrics=0.* + - hydra-core=1.* + - rich=13.* + - pre-commit=3.* + - pytest=7.* + + # --------- loggers --------- # + # - wandb + # - neptune-client + # - mlflow + # - comet-ml + # - aim>=3.16.2 # no lower than 3.16.2, see https://github.com/aimhubio/aim/issues/2550 + + - pip>=23 + - pip: + - hydra-optuna-sweeper + - hydra-colorlog + - rootutils diff --git a/notebooks/.gitkeep b/notebooks/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..300ebf04f0594f1c517e7017d3697490009b203f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[tool.pytest.ini_options] +addopts = [ + "--color=yes", + "--durations=0", + "--strict-markers", + "--doctest-modules", +] +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::UserWarning", +] +log_cli = "True" +markers = [ + "slow: slow tests", +] +minversion = "6.0" +testpaths = "tests/" + +[tool.coverage.report] +exclude_lines = [ + "pragma: nocover", + "raise NotImplementedError", + "raise NotImplementedError()", + "if __name__ == .__main__.:", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..ab16611210e6ac3ca14875123648e3115c56c369 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +# --------- pytorch --------- # +torch>=2.0.0 +torchvision>=0.15.0 +lightning>=2.0.0 +torchmetrics>=0.11.4 + +# --------- hydra --------- # +hydra-core==1.3.2 +hydra-colorlog==1.2.0 +hydra-optuna-sweeper==1.2.0 + +# --------- loggers --------- # +wandb +# neptune-client +# mlflow +# comet-ml +# aim>=3.16.2 # no lower than 3.16.2, see https://github.com/aimhubio/aim/issues/2550 + +# --------- others --------- # +rootutils # standardizing the project root setup +pre-commit # hooks for applying linters on commit +rich # beautiful text formatting in terminal +pytest # tests +# sh # for running bash commands in some tests (linux/macos only) diff --git a/scripts/schedule.sh b/scripts/schedule.sh new file mode 100644 index 0000000000000000000000000000000000000000..44b3da1116ef4d54e9acffee7d639d549e136d45 --- /dev/null +++ b/scripts/schedule.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Schedule execution of many runs +# Run from root folder with: bash scripts/schedule.sh + +python src/train.py trainer.max_epochs=5 logger=csv + +python src/train.py trainer.max_epochs=10 logger=csv diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..f1826cc5b642c9acad686957a1ea123eb7cf098b --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python + +from setuptools import find_packages, setup + +setup( + name="src", + version="0.0.1", + description="Describe Your Cool Project", + author="", + author_email="", + url="https://github.com/user/project", + install_requires=["lightning", "hydra-core"], + packages=find_packages(), + # use this to customize global commands available in the terminal after installing the package + entry_points={ + "console_scripts": [ + "train_command = src.train:main", + "eval_command = src.eval:main", + ] + }, +) diff --git a/swim/__init__.py b/swim/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/swim/data/__init__.py b/swim/data/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/swim/data/swim_data.py b/swim/data/swim_data.py new file mode 100644 index 0000000000000000000000000000000000000000..2b890c7140d9d4865166196f65d8af767233b629 --- /dev/null +++ b/swim/data/swim_data.py @@ -0,0 +1,145 @@ +from typing import Literal, List + +import os +import json +import torch +import torchvision.transforms as T +from torch.utils.data import Dataset, DataLoader +from PIL import Image +from lightning import LightningDataModule + + +class SwimDataset(Dataset): + def __init__( + self, + root_dir: str = "./datasets/swim_data", + split: Literal["train", "val"] = "train", + img_size: int = 512, + ): + super().__init__() + self.root_dir = root_dir + self.split_dir = os.path.join(root_dir, split) + self.img_size = img_size + + if split == "train": + self.transform = T.Compose( + [ + T.Resize(img_size), # smaller edge of image resized to img_size + T.RandomCrop(img_size), # get a random crop of img_size x img_size + T.RandomHorizontalFlip(), + T.ToTensor(), + T.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), + ] + ) + elif split == "val": + self.transform = T.Compose( + [ + T.Resize(img_size), + T.CenterCrop(img_size), + T.ToTensor(), + T.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), + ] + ) + + with open(os.path.join(self.split_dir, "labels.json"), "r") as f: + self.data = json.load(f) + + # filter out images that are both at night and have adverse weather conditions + self.data = [ + img + for img in self.data + if not (img["timeofday"] == "night" and img["weather"] != "clear") + ] + + def __len__(self): + return len(self.data) // 100 + + def __getitem__(self, idx): + data = self.data[idx] + + # load image + img_path = os.path.join(self.split_dir, "images", data["name"]) + img = Image.open(img_path).convert("RGB") + img = self.transform(img) + + # load style + if data["weather"] != "clear": + style_name = data["weather"] + elif data["timeofday"] == "night": + style_name = "night" + else: + style_name = "clear" + + # true if image has any styles + style_flag = style_name != "clear" + + # one-hot encode style + style = torch.zeros(4) + + if style_flag: + style[self.get_stylenames().index(style_name)] = 1 + + return { + "image": img, + "style": style, + "style_flag": style_flag, + } + + def get_stylenames(self) -> List[str]: + return ["rain", "snow", "fog", "night"] + + +class SwimDataModule(LightningDataModule): + def __init__( + self, + root_dir: str = "./datasets/swim_data", + batch_size: int = 1, + img_size: int = 512, + ): + super().__init__() + self.root_dir = root_dir + self.img_size = img_size + self.batch_size = batch_size + + def setup(self, stage=None): + if stage == "fit" or stage is None: + self.train_dataset = SwimDataset( + root_dir=self.root_dir, split="train", img_size=self.img_size + ) + self.val_dataset = SwimDataset( + root_dir=self.root_dir, split="val", img_size=self.img_size + ) + + def train_dataloader(self): + return DataLoader( + self.train_dataset, + batch_size=self.batch_size, + shuffle=True, + num_workers=4, + collate_fn=self.custom_collate_fn, + ) + + def val_dataloader(self): + return DataLoader( + self.val_dataset, + batch_size=self.batch_size, + shuffle=False, + num_workers=4, + collate_fn=self.custom_collate_fn, + ) + + def test_dataloader(self): + return DataLoader( + self.val_dataset, + batch_size=1, + shuffle=False, + num_workers=4, + collate_fn=self.custom_collate_fn, + ) + + @staticmethod + def custom_collate_fn(batch): + images = torch.stack([item["image"] for item in batch]) + styles = torch.stack([item["style"] for item in batch]) + style_flags = [item["style_flag"] for item in batch] + return {"images": images, "styles": styles, "style_flags": style_flags} diff --git a/swim/eval.py b/swim/eval.py new file mode 100644 index 0000000000000000000000000000000000000000..9ceffd7ff3a59bc860bb3c501f5d58a6d078a794 --- /dev/null +++ b/swim/eval.py @@ -0,0 +1,99 @@ +from typing import Any, Dict, List, Tuple + +import hydra +import rootutils +from lightning import LightningDataModule, LightningModule, Trainer +from lightning.pytorch.loggers import Logger +from omegaconf import DictConfig + +rootutils.setup_root(__file__, indicator=".project-root", pythonpath=True) +# ------------------------------------------------------------------------------------ # +# the setup_root above is equivalent to: +# - adding project root dir to PYTHONPATH +# (so you don't need to force user to install project as a package) +# (necessary before importing any local modules e.g. `from src import utils`) +# - setting up PROJECT_ROOT environment variable +# (which is used as a base for paths in "configs/paths/default.yaml") +# (this way all filepaths are the same no matter where you run the code) +# - loading environment variables from ".env" in root dir +# +# you can remove it if you: +# 1. either install project as a package or move entry files to project root dir +# 2. set `root_dir` to "." in "configs/paths/default.yaml" +# +# more info: https://github.com/ashleve/rootutils +# ------------------------------------------------------------------------------------ # + +from swim.utils import ( + RankedLogger, + extras, + instantiate_loggers, + log_hyperparameters, + task_wrapper, +) + +log = RankedLogger(__name__, rank_zero_only=True) + + +@task_wrapper +def evaluate(cfg: DictConfig) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Evaluates given checkpoint on a datamodule testset. + + This method is wrapped in optional @task_wrapper decorator, that controls the behavior during + failure. Useful for multiruns, saving info about the crash, etc. + + :param cfg: DictConfig configuration composed by Hydra. + :return: Tuple[dict, dict] with metrics and dict with all instantiated objects. + """ + assert cfg.ckpt_path + + log.info(f"Instantiating datamodule <{cfg.data._target_}>") + datamodule: LightningDataModule = hydra.utils.instantiate(cfg.data) + + log.info(f"Instantiating model <{cfg.model._target_}>") + model: LightningModule = hydra.utils.instantiate(cfg.model) + + log.info("Instantiating loggers...") + logger: List[Logger] = instantiate_loggers(cfg.get("logger")) + + log.info(f"Instantiating trainer <{cfg.trainer._target_}>") + trainer: Trainer = hydra.utils.instantiate(cfg.trainer, logger=logger) + + object_dict = { + "cfg": cfg, + "datamodule": datamodule, + "model": model, + "logger": logger, + "trainer": trainer, + } + + if logger: + log.info("Logging hyperparameters!") + log_hyperparameters(object_dict) + + log.info("Starting testing!") + trainer.test(model=model, datamodule=datamodule, ckpt_path=cfg.ckpt_path) + + # for predictions use trainer.predict(...) + # predictions = trainer.predict(model=model, dataloaders=dataloaders, ckpt_path=cfg.ckpt_path) + + metric_dict = trainer.callback_metrics + + return metric_dict, object_dict + + +@hydra.main(version_base="1.3", config_path="../configs", config_name="eval.yaml") +def main(cfg: DictConfig) -> None: + """Main entry point for evaluation. + + :param cfg: DictConfig configuration composed by Hydra. + """ + # apply extra utilities + # (e.g. ask for tags if none are provided in cfg, print cfg tree, etc.) + extras(cfg) + + evaluate(cfg) + + +if __name__ == "__main__": + main() diff --git a/swim/models/__init__.py b/swim/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/swim/models/autoencoder.py b/swim/models/autoencoder.py new file mode 100644 index 0000000000000000000000000000000000000000..26586071d055c2785793c65b4f8dbafb9a08f820 --- /dev/null +++ b/swim/models/autoencoder.py @@ -0,0 +1,195 @@ +from typing import List + +import torch +import torch.nn as nn +from lightning import LightningModule + +import wandb +from .blocks import Encoder, Decoder, GaussianDistribution +from torchmetrics import ( + PeakSignalNoiseRatio, + StructuralSimilarityIndexMeasure, + MeanSquaredError, +) + + +class Autoencoder(LightningModule): + """ + ## Autoencoder + + This consists of the encoder and decoder modules. + """ + + def __init__( + self, + channels: int, + channel_multipliers: List[int], + n_resnet_blocks: int, + in_channels: int, + out_channels: int, + z_channels: int, + emb_channels: int, + learning_rate: float = 1e-4, + ): + """ + :param encoder: is the encoder + :param decoder: is the decoder + :param emb_channels: is the number of dimensions in the quantized embedding space + :param z_channels: is the number of channels in the embedding space + """ + super().__init__() + + self.save_hyperparameters(logger=False) + + self.encoder = Encoder( + channels=channels, + channel_multipliers=channel_multipliers, + n_resnet_blocks=n_resnet_blocks, + in_channels=in_channels, + z_channels=z_channels, + ) + + self.decoder = Decoder( + channels=channels, + channel_multipliers=channel_multipliers, + n_resnet_blocks=n_resnet_blocks, + out_channels=out_channels, + z_channels=z_channels, + ) + # Convolution to map from embedding space to + # quantized embedding space moments (mean and log variance) + self.quant_conv = nn.Conv2d(z_channels, emb_channels, 1) + # Convolution to map from quantized embedding space back to + # embedding space + self.post_quant_conv = nn.Conv2d(emb_channels, z_channels, 1) + + self.train_psnr = PeakSignalNoiseRatio() + self.train_ssim = StructuralSimilarityIndexMeasure() + + self.val_psnr = PeakSignalNoiseRatio() + self.val_ssim = StructuralSimilarityIndexMeasure() + self.val_mse = MeanSquaredError() + + def encode(self, img: torch.Tensor) -> GaussianDistribution: + """ + ### Encode images to latent representation + + :param img: is the image tensor with shape `[batch_size, img_channels, img_height, img_width]` + """ + # Get embeddings with shape `[batch_size, z_channels * 2, z_height, z_height]` + z = self.encoder(img) + # Get the moments in the quantized embedding space + z = self.quant_conv(z) + # Return the distribution + # return GaussianDistribution(moments) + return z + + def decode(self, z: torch.Tensor): + """ + ### Decode images from latent representation + + :param z: is the latent representation with shape `[batch_size, emb_channels, z_height, z_height]` + """ + # Map to embedding space from the quantized representation + z = self.post_quant_conv(z) + # Decode the image of shape `[batch_size, channels, height, width]` + return self.decoder(z) + + def forward(self, img: torch.Tensor): + """ + :param img: is the image tensor with shape `[batch_size, img_channels, img_height, img_width]` + """ + # Encode the image + z = self.encode(img) + return self.decode(z) + + def training_step(self, batch, batch_idx): + """ + ### Training step + + :param batch: is the batch of data + :param batch_idx: is the index of the batch + """ + # Get the image + img = batch["images"] + recon = self.forward(img) + # Calculate the loss + loss = torch.abs(img - recon).sum() # L1 loss + + self.train_psnr(recon, img) + self.train_ssim(recon, img) + + # Log the loss + self.log( + "train/l1_loss", loss.item(), on_epoch=False, on_step=True, prog_bar=True + ) + self.log( + "train/psnr", self.train_psnr, on_epoch=True, on_step=False, prog_bar=True + ) + self.log( + "train/ssim", self.train_ssim, on_epoch=True, on_step=False, prog_bar=True + ) + + return loss + + def validation_step(self, batch, batch_idx): + """ + ### Validation step + + :param batch: is the batch of data + :param batch_idx: is the index of the batch + """ + # Get the image + img = batch["images"] + # Get the distribution + recon = self.forward(img) + + self.val_psnr(recon, img) + self.val_ssim(recon, img) + self.val_mse(recon, img) + + # Log the loss + self.log("val/psnr", self.val_psnr, on_epoch=True, on_step=False, prog_bar=True) + self.log("val/ssim", self.val_ssim, on_epoch=True, on_step=False, prog_bar=True) + self.log("val/mse", self.val_mse, on_epoch=True, on_step=False, prog_bar=True) + + if batch_idx == 0: + self.log_images(img, recon) + + # def setup(self, stage: str) -> None: + # if self.hparams.compile and stage == "fit": + # self.net = torch.compile(self.net) + + def log_images(self, ori_images, recon_images): + """ + ### Log images + """ + for ori_image, recon_image in zip(ori_images, recon_images): + ori_image = ((ori_image + 1) * 127.5).cpu().type(torch.uint8) + recon_image = ((recon_image + 1) * 127.5).cpu().type(torch.uint8) + + wandb.log( + { + "val/ori_image": [ + wandb.Image(ori_image), + ], + "val/recon_image": [ + wandb.Image(recon_image), + ], + } + ) + + def configure_optimizers(self): + """ + ### Configure optimizers + """ + encoder_params = list(self.encoder.parameters()) + decoder_params = list(self.decoder.parameters()) + other_params = list(self.quant_conv.parameters()) + list( + self.post_quant_conv.parameters() + ) + + return torch.optim.AdamW( + encoder_params + decoder_params + other_params, + lr=self.hparams.learning_rate, + ) diff --git a/swim/models/blocks.py b/swim/models/blocks.py new file mode 100644 index 0000000000000000000000000000000000000000..8840b2ebddba0d0867959a5cb505a0f521c571f8 --- /dev/null +++ b/swim/models/blocks.py @@ -0,0 +1,386 @@ +from typing import List + +import torch +import torch.nn.functional as F +from torch import nn + + +class Encoder(nn.Module): + """ + ## Encoder module + """ + + def __init__( + self, + *, + channels: int, + channel_multipliers: List[int], + n_resnet_blocks: int, + in_channels: int, + z_channels: int + ): + """ + :param channels: is the number of channels in the first convolution layer + :param channel_multipliers: are the multiplicative factors for the number of channels in the + subsequent blocks + :param n_resnet_blocks: is the number of resnet layers at each resolution + :param in_channels: is the number of channels in the image + :param z_channels: is the number of channels in the embedding space + """ + super().__init__() + + # Number of blocks of different resolutions. + # The resolution is halved at the end each top level block + n_resolutions = len(channel_multipliers) + + # Initial $3 \times 3$ convolution layer that maps the image to `channels` + self.conv_in = nn.Conv2d(in_channels, channels, 3, stride=1, padding=1) + + # Number of channels in each top level block + channels_list = [m * channels for m in [1] + channel_multipliers] + + # List of top-level blocks + self.down = nn.ModuleList() + # Create top-level blocks + for i in range(n_resolutions): + # Each top level block consists of multiple ResNet Blocks and down-sampling + resnet_blocks = nn.ModuleList() + # Add ResNet Blocks + for _ in range(n_resnet_blocks): + resnet_blocks.append(ResnetBlock(channels, channels_list[i + 1])) + channels = channels_list[i + 1] + # Top-level block + down = nn.Module() + down.block = resnet_blocks + # Down-sampling at the end of each top level block except the last + if i != n_resolutions - 1: + down.downsample = DownSample(channels) + else: + down.downsample = nn.Identity() + # + self.down.append(down) + + # Final ResNet blocks with attention + self.mid = nn.Module() + self.mid.block_1 = ResnetBlock(channels, channels) + self.mid.attn_1 = AttnBlock(channels) + self.mid.block_2 = ResnetBlock(channels, channels) + + # Map to embedding space with a $3 \times 3$ convolution + self.norm_out = normalization(channels) + self.conv_out = nn.Conv2d(channels, z_channels, 3, stride=1, padding=1) + + def forward(self, img: torch.Tensor): + """ + :param img: is the image tensor with shape `[batch_size, img_channels, img_height, img_width]` + """ + + # Map to `channels` with the initial convolution + x = self.conv_in(img) + + # Top-level blocks + for down in self.down: + # ResNet Blocks + for block in down.block: + x = block(x) + # Down-sampling + x = down.downsample(x) + + # Final ResNet blocks with attention + x = self.mid.block_1(x) + x = self.mid.attn_1(x) + x = self.mid.block_2(x) + + # Normalize and map to embedding space + x = self.norm_out(x) + x = swish(x) + x = self.conv_out(x) + + # + return x + + +class Decoder(nn.Module): + """ + ## Decoder module + """ + + def __init__( + self, + *, + channels: int, + channel_multipliers: List[int], + n_resnet_blocks: int, + out_channels: int, + z_channels: int + ): + """ + :param channels: is the number of channels in the final convolution layer + :param channel_multipliers: are the multiplicative factors for the number of channels in the + previous blocks, in reverse order + :param n_resnet_blocks: is the number of resnet layers at each resolution + :param out_channels: is the number of channels in the image + :param z_channels: is the number of channels in the embedding space + """ + super().__init__() + + # Number of blocks of different resolutions. + # The resolution is halved at the end each top level block + num_resolutions = len(channel_multipliers) + + # Number of channels in each top level block, in the reverse order + channels_list = [m * channels for m in channel_multipliers] + + # Number of channels in the top-level block + channels = channels_list[-1] + + # Initial $3 \times 3$ convolution layer that maps the embedding space to `channels` + self.conv_in = nn.Conv2d(z_channels, channels, 3, stride=1, padding=1) + + # ResNet blocks with attention + self.mid = nn.Module() + self.mid.block_1 = ResnetBlock(channels, channels) + self.mid.attn_1 = AttnBlock(channels) + self.mid.block_2 = ResnetBlock(channels, channels) + + # List of top-level blocks + self.up = nn.ModuleList() + # Create top-level blocks + for i in reversed(range(num_resolutions)): + # Each top level block consists of multiple ResNet Blocks and up-sampling + resnet_blocks = nn.ModuleList() + # Add ResNet Blocks + for _ in range(n_resnet_blocks + 1): + resnet_blocks.append(ResnetBlock(channels, channels_list[i])) + channels = channels_list[i] + # Top-level block + up = nn.Module() + up.block = resnet_blocks + # Up-sampling at the end of each top level block except the first + if i != 0: + up.upsample = UpSample(channels) + else: + up.upsample = nn.Identity() + # Prepend to be consistent with the checkpoint + self.up.insert(0, up) + + # Map to image space with a $3 \times 3$ convolution + self.norm_out = normalization(channels) + self.conv_out = nn.Conv2d(channels, out_channels, 3, stride=1, padding=1) + + def forward(self, z: torch.Tensor): + """ + :param z: is the embedding tensor with shape `[batch_size, z_channels, z_height, z_height]` + """ + + # Map to `channels` with the initial convolution + h = self.conv_in(z) + + # ResNet blocks with attention + h = self.mid.block_1(h) + h = self.mid.attn_1(h) + h = self.mid.block_2(h) + + # Top-level blocks + for up in reversed(self.up): + # ResNet Blocks + for block in up.block: + h = block(h) + # Up-sampling + h = up.upsample(h) + + # Normalize and map to image space + h = self.norm_out(h) + h = swish(h) + img = self.conv_out(h) + + # + return img + + +class GaussianDistribution: + """ + ## Gaussian Distribution + """ + + def __init__(self, parameters: torch.Tensor): + """ + :param parameters: are the means and log of variances of the embedding of shape + `[batch_size, z_channels * 2, z_height, z_height]` + """ + # Split mean and log of variance + self.mean, log_var = torch.chunk(parameters, 2, dim=1) + # Clamp the log of variances + self.log_var = torch.clamp(log_var, -30.0, 20.0) + # Calculate standard deviation + self.std = torch.exp(0.5 * self.log_var) + + def sample(self): + # Sample from the distribution + return self.mean + self.std * torch.randn_like(self.std) + + +class AttnBlock(nn.Module): + """ + ## Attention block + """ + + def __init__(self, channels: int): + """ + :param channels: is the number of channels + """ + super().__init__() + # Group normalization + self.norm = normalization(channels) + # Query, key and value mappings + self.q = nn.Conv2d(channels, channels, 1) + self.k = nn.Conv2d(channels, channels, 1) + self.v = nn.Conv2d(channels, channels, 1) + # Final $1 \times 1$ convolution layer + self.proj_out = nn.Conv2d(channels, channels, 1) + # Attention scaling factor + self.scale = channels**-0.5 + + def forward(self, x: torch.Tensor): + """ + :param x: is the tensor of shape `[batch_size, channels, height, width]` + """ + # Normalize `x` + x_norm = self.norm(x) + # Get query, key and vector embeddings + q = self.q(x_norm) + k = self.k(x_norm) + v = self.v(x_norm) + + # Reshape to query, key and vector embeedings from + # `[batch_size, channels, height, width]` to + # `[batch_size, channels, height * width]` + b, c, h, w = q.shape + q = q.view(b, c, h * w) + k = k.view(b, c, h * w) + v = v.view(b, c, h * w) + + # Compute $\underset{seq}{softmax}\Bigg(\frac{Q K^\top}{\sqrt{d_{key}}}\Bigg)$ + attn = torch.einsum("bci,bcj->bij", q, k) * self.scale + attn = F.softmax(attn, dim=2) + + # Compute $\underset{seq}{softmax}\Bigg(\frac{Q K^\top}{\sqrt{d_{key}}}\Bigg)V$ + out = torch.einsum("bij,bcj->bci", attn, v) + + # Reshape back to `[batch_size, channels, height, width]` + out = out.view(b, c, h, w) + # Final $1 \times 1$ convolution layer + out = self.proj_out(out) + + # Add residual connection + return x + out + + +class UpSample(nn.Module): + """ + ## Up-sampling layer + """ + + def __init__(self, channels: int): + """ + :param channels: is the number of channels + """ + super().__init__() + # $3 \times 3$ convolution mapping + self.conv = nn.Conv2d(channels, channels, 3, padding=1) + + def forward(self, x: torch.Tensor): + """ + :param x: is the input feature map with shape `[batch_size, channels, height, width]` + """ + # Up-sample by a factor of $2$ + x = F.interpolate(x, scale_factor=2.0, mode="nearest") + # Apply convolution + return self.conv(x) + + +class DownSample(nn.Module): + """ + ## Down-sampling layer + """ + + def __init__(self, channels: int): + """ + :param channels: is the number of channels + """ + super().__init__() + # $3 \times 3$ convolution with stride length of $2$ to down-sample by a factor of $2$ + self.conv = nn.Conv2d(channels, channels, 3, stride=2, padding=0) + + def forward(self, x: torch.Tensor): + """ + :param x: is the input feature map with shape `[batch_size, channels, height, width]` + """ + # Add padding + x = F.pad(x, (0, 1, 0, 1), mode="constant", value=0) + # Apply convolution + return self.conv(x) + + +class ResnetBlock(nn.Module): + """ + ## ResNet Block + """ + + def __init__(self, in_channels: int, out_channels: int): + """ + :param in_channels: is the number of channels in the input + :param out_channels: is the number of channels in the output + """ + super().__init__() + # First normalization and convolution layer + self.norm1 = normalization(in_channels) + self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride=1, padding=1) + # Second normalization and convolution layer + self.norm2 = normalization(out_channels) + self.conv2 = nn.Conv2d(out_channels, out_channels, 3, stride=1, padding=1) + # `in_channels` to `out_channels` mapping layer for residual connection + if in_channels != out_channels: + self.nin_shortcut = nn.Conv2d( + in_channels, out_channels, 1, stride=1, padding=0 + ) + else: + self.nin_shortcut = nn.Identity() + + def forward(self, x: torch.Tensor): + """ + :param x: is the input feature map with shape `[batch_size, channels, height, width]` + """ + + h = x + + # First normalization and convolution layer + h = self.norm1(h) + h = swish(h) + h = self.conv1(h) + + # Second normalization and convolution layer + h = self.norm2(h) + h = swish(h) + h = self.conv2(h) + + # Map and add residual + return self.nin_shortcut(x) + h + + +def swish(x: torch.Tensor): + """ + ### Swish activation + + $$x \cdot \sigma(x)$$ + """ + return x * torch.sigmoid(x) + + +def normalization(channels: int): + """ + ### Group normalization + + This is a helper function, with fixed number of groups and `eps`. + """ + return nn.GroupNorm(num_groups=32, num_channels=channels, eps=1e-6) diff --git a/swim/train.py b/swim/train.py new file mode 100644 index 0000000000000000000000000000000000000000..d2dd0b540eda6e30a52ec3a4569d89de19669561 --- /dev/null +++ b/swim/train.py @@ -0,0 +1,136 @@ +from typing import Any, Dict, List, Optional, Tuple + +import hydra +import lightning as L +import rootutils +import torch +from lightning import Callback, LightningDataModule, LightningModule, Trainer +from lightning.pytorch.loggers import Logger +from omegaconf import DictConfig + +rootutils.setup_root(__file__, indicator=".project-root", pythonpath=True) +# ------------------------------------------------------------------------------------ # +# the setup_root above is equivalent to: +# - adding project root dir to PYTHONPATH +# (so you don't need to force user to install project as a package) +# (necessary before importing any local modules e.g. `from src import utils`) +# - setting up PROJECT_ROOT environment variable +# (which is used as a base for paths in "configs/paths/default.yaml") +# (this way all filepaths are the same no matter where you run the code) +# - loading environment variables from ".env" in root dir +# +# you can remove it if you: +# 1. either install project as a package or move entry files to project root dir +# 2. set `root_dir` to "." in "configs/paths/default.yaml" +# +# more info: https://github.com/ashleve/rootutils +# ------------------------------------------------------------------------------------ # + +from swim.utils import ( + RankedLogger, + extras, + get_metric_value, + instantiate_callbacks, + instantiate_loggers, + log_hyperparameters, + task_wrapper, +) + +log = RankedLogger(__name__, rank_zero_only=True) + + +@task_wrapper +def train(cfg: DictConfig) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Trains the model. Can additionally evaluate on a testset, using best weights obtained during + training. + + This method is wrapped in optional @task_wrapper decorator, that controls the behavior during + failure. Useful for multiruns, saving info about the crash, etc. + + :param cfg: A DictConfig configuration composed by Hydra. + :return: A tuple with metrics and dict with all instantiated objects. + """ + # set seed for random number generators in pytorch, numpy and python.random + if cfg.get("seed"): + L.seed_everything(cfg.seed, workers=True) + + torch.set_float32_matmul_precision("medium") + + log.info(f"Instantiating datamodule <{cfg.data._target_}>") + datamodule: LightningDataModule = hydra.utils.instantiate(cfg.data) + + log.info(f"Instantiating model <{cfg.model._target_}>") + model: LightningModule = hydra.utils.instantiate(cfg.model) + + log.info("Instantiating callbacks...") + callbacks: List[Callback] = instantiate_callbacks(cfg.get("callbacks")) + + log.info("Instantiating loggers...") + logger: List[Logger] = instantiate_loggers(cfg.get("logger")) + + log.info(f"Instantiating trainer <{cfg.trainer._target_}>") + trainer: Trainer = hydra.utils.instantiate( + cfg.trainer, callbacks=callbacks, logger=logger + ) + + object_dict = { + "cfg": cfg, + "datamodule": datamodule, + "model": model, + "callbacks": callbacks, + "logger": logger, + "trainer": trainer, + } + + if logger: + log.info("Logging hyperparameters!") + log_hyperparameters(object_dict) + + if cfg.get("train"): + log.info("Starting training!") + trainer.fit(model=model, datamodule=datamodule, ckpt_path=cfg.get("ckpt_path")) + + train_metrics = trainer.callback_metrics + + if cfg.get("test"): + log.info("Starting testing!") + ckpt_path = trainer.checkpoint_callback.best_model_path + if ckpt_path == "": + log.warning("Best ckpt not found! Using current weights for testing...") + ckpt_path = None + trainer.test(model=model, datamodule=datamodule, ckpt_path=ckpt_path) + log.info(f"Best ckpt path: {ckpt_path}") + + test_metrics = trainer.callback_metrics + + # merge train and test metrics + metric_dict = {**train_metrics, **test_metrics} + + return metric_dict, object_dict + + +@hydra.main(version_base="1.3", config_path="../configs", config_name="train.yaml") +def main(cfg: DictConfig) -> Optional[float]: + """Main entry point for training. + + :param cfg: DictConfig configuration composed by Hydra. + :return: Optional[float] with optimized metric value. + """ + # apply extra utilities + # (e.g. ask for tags if none are provided in cfg, print cfg tree, etc.) + extras(cfg) + + # train the model + metric_dict, _ = train(cfg) + + # safely retrieve metric value for hydra-based hyperparameter optimization + metric_value = get_metric_value( + metric_dict=metric_dict, metric_name=cfg.get("optimized_metric") + ) + + # return optimized metric + return metric_value + + +if __name__ == "__main__": + main() diff --git a/swim/utils/__init__.py b/swim/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1f00e6ac475c05a1014fbeb2c7039bbef68d259a --- /dev/null +++ b/swim/utils/__init__.py @@ -0,0 +1,5 @@ +from swim.utils.instantiators import instantiate_callbacks, instantiate_loggers +from swim.utils.logging_utils import log_hyperparameters +from swim.utils.pylogger import RankedLogger +from swim.utils.rich_utils import enforce_tags, print_config_tree +from swim.utils.utils import extras, get_metric_value, task_wrapper diff --git a/swim/utils/instantiators.py b/swim/utils/instantiators.py new file mode 100644 index 0000000000000000000000000000000000000000..c4c8ff5f8f641d1a9623758fa566b139ecd5ac66 --- /dev/null +++ b/swim/utils/instantiators.py @@ -0,0 +1,56 @@ +from typing import List + +import hydra +from lightning import Callback +from lightning.pytorch.loggers import Logger +from omegaconf import DictConfig + +from swim.utils import pylogger + +log = pylogger.RankedLogger(__name__, rank_zero_only=True) + + +def instantiate_callbacks(callbacks_cfg: DictConfig) -> List[Callback]: + """Instantiates callbacks from config. + + :param callbacks_cfg: A DictConfig object containing callback configurations. + :return: A list of instantiated callbacks. + """ + callbacks: List[Callback] = [] + + if not callbacks_cfg: + log.warning("No callback configs found! Skipping..") + return callbacks + + if not isinstance(callbacks_cfg, DictConfig): + raise TypeError("Callbacks config must be a DictConfig!") + + for _, cb_conf in callbacks_cfg.items(): + if isinstance(cb_conf, DictConfig) and "_target_" in cb_conf: + log.info(f"Instantiating callback <{cb_conf._target_}>") + callbacks.append(hydra.utils.instantiate(cb_conf)) + + return callbacks + + +def instantiate_loggers(logger_cfg: DictConfig) -> List[Logger]: + """Instantiates loggers from config. + + :param logger_cfg: A DictConfig object containing logger configurations. + :return: A list of instantiated loggers. + """ + logger: List[Logger] = [] + + if not logger_cfg: + log.warning("No logger configs found! Skipping...") + return logger + + if not isinstance(logger_cfg, DictConfig): + raise TypeError("Logger config must be a DictConfig!") + + for _, lg_conf in logger_cfg.items(): + if isinstance(lg_conf, DictConfig) and "_target_" in lg_conf: + log.info(f"Instantiating logger <{lg_conf._target_}>") + logger.append(hydra.utils.instantiate(lg_conf)) + + return logger diff --git a/swim/utils/logging_utils.py b/swim/utils/logging_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..eb14660052f4dccca7400d70e5ee91b0364acf00 --- /dev/null +++ b/swim/utils/logging_utils.py @@ -0,0 +1,57 @@ +from typing import Any, Dict + +from lightning_utilities.core.rank_zero import rank_zero_only +from omegaconf import OmegaConf + +from swim.utils import pylogger + +log = pylogger.RankedLogger(__name__, rank_zero_only=True) + + +@rank_zero_only +def log_hyperparameters(object_dict: Dict[str, Any]) -> None: + """Controls which config parts are saved by Lightning loggers. + + Additionally saves: + - Number of model parameters + + :param object_dict: A dictionary containing the following objects: + - `"cfg"`: A DictConfig object containing the main config. + - `"model"`: The Lightning model. + - `"trainer"`: The Lightning trainer. + """ + hparams = {} + + cfg = OmegaConf.to_container(object_dict["cfg"]) + model = object_dict["model"] + trainer = object_dict["trainer"] + + if not trainer.logger: + log.warning("Logger not found! Skipping hyperparameter logging...") + return + + hparams["model"] = cfg["model"] + + # save number of model parameters + hparams["model/params/total"] = sum(p.numel() for p in model.parameters()) + hparams["model/params/trainable"] = sum( + p.numel() for p in model.parameters() if p.requires_grad + ) + hparams["model/params/non_trainable"] = sum( + p.numel() for p in model.parameters() if not p.requires_grad + ) + + hparams["data"] = cfg["data"] + hparams["trainer"] = cfg["trainer"] + + hparams["callbacks"] = cfg.get("callbacks") + hparams["extras"] = cfg.get("extras") + + hparams["task_name"] = cfg.get("task_name") + hparams["tags"] = cfg.get("tags") + hparams["ckpt_path"] = cfg.get("ckpt_path") + hparams["seed"] = cfg.get("seed") + + # send hparams to all loggers + for logger in trainer.loggers: + logger.log_hyperparams(hparams) diff --git a/swim/utils/pylogger.py b/swim/utils/pylogger.py new file mode 100644 index 0000000000000000000000000000000000000000..c4ee8675ebde11b2a43b0679a03cd88d9268bc71 --- /dev/null +++ b/swim/utils/pylogger.py @@ -0,0 +1,51 @@ +import logging +from typing import Mapping, Optional + +from lightning_utilities.core.rank_zero import rank_prefixed_message, rank_zero_only + + +class RankedLogger(logging.LoggerAdapter): + """A multi-GPU-friendly python command line logger.""" + + def __init__( + self, + name: str = __name__, + rank_zero_only: bool = False, + extra: Optional[Mapping[str, object]] = None, + ) -> None: + """Initializes a multi-GPU-friendly python command line logger that logs on all processes + with their rank prefixed in the log message. + + :param name: The name of the logger. Default is ``__name__``. + :param rank_zero_only: Whether to force all logs to only occur on the rank zero process. Default is `False`. + :param extra: (Optional) A dict-like object which provides contextual information. See `logging.LoggerAdapter`. + """ + logger = logging.getLogger(name) + super().__init__(logger=logger, extra=extra) + self.rank_zero_only = rank_zero_only + + def log(self, level: int, msg: str, rank: Optional[int] = None, *args, **kwargs) -> None: + """Delegate a log call to the underlying logger, after prefixing its message with the rank + of the process it's being logged from. If `'rank'` is provided, then the log will only + occur on that rank/process. + + :param level: The level to log at. Look at `logging.__init__.py` for more information. + :param msg: The message to log. + :param rank: The rank to log at. + :param args: Additional args to pass to the underlying logging function. + :param kwargs: Any additional keyword args to pass to the underlying logging function. + """ + if self.isEnabledFor(level): + msg, kwargs = self.process(msg, kwargs) + current_rank = getattr(rank_zero_only, "rank", None) + if current_rank is None: + raise RuntimeError("The `rank_zero_only.rank` needs to be set before use") + msg = rank_prefixed_message(msg, current_rank) + if self.rank_zero_only: + if current_rank == 0: + self.logger.log(level, msg, *args, **kwargs) + else: + if rank is None: + self.logger.log(level, msg, *args, **kwargs) + elif current_rank == rank: + self.logger.log(level, msg, *args, **kwargs) diff --git a/swim/utils/rich_utils.py b/swim/utils/rich_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..b80f6b75b455e9b53453d7ae41fa3548af1d342a --- /dev/null +++ b/swim/utils/rich_utils.py @@ -0,0 +1,103 @@ +from pathlib import Path +from typing import Sequence + +import rich +import rich.syntax +import rich.tree +from hydra.core.hydra_config import HydraConfig +from lightning_utilities.core.rank_zero import rank_zero_only +from omegaconf import DictConfig, OmegaConf, open_dict +from rich.prompt import Prompt + +from swim.utils import pylogger + +log = pylogger.RankedLogger(__name__, rank_zero_only=True) + + +@rank_zero_only +def print_config_tree( + cfg: DictConfig, + print_order: Sequence[str] = ( + "data", + "model", + "callbacks", + "logger", + "trainer", + "paths", + "extras", + ), + resolve: bool = False, + save_to_file: bool = False, +) -> None: + """Prints the contents of a DictConfig as a tree structure using the Rich library. + + :param cfg: A DictConfig composed by Hydra. + :param print_order: Determines in what order config components are printed. Default is ``("data", "model", + "callbacks", "logger", "trainer", "paths", "extras")``. + :param resolve: Whether to resolve reference fields of DictConfig. Default is ``False``. + :param save_to_file: Whether to export config to the hydra output folder. Default is ``False``. + """ + style = "dim" + tree = rich.tree.Tree("CONFIG", style=style, guide_style=style) + + queue = [] + + # add fields from `print_order` to queue + for field in print_order: + ( + queue.append(field) + if field in cfg + else log.warning( + f"Field '{field}' not found in config. Skipping '{field}' config printing..." + ) + ) + + # add all the other fields to queue (not specified in `print_order`) + for field in cfg: + if field not in queue: + queue.append(field) + + # generate config tree from queue + for field in queue: + branch = tree.add(field, style=style, guide_style=style) + + config_group = cfg[field] + if isinstance(config_group, DictConfig): + branch_content = OmegaConf.to_yaml(config_group, resolve=resolve) + else: + branch_content = str(config_group) + + branch.add(rich.syntax.Syntax(branch_content, "yaml")) + + # print config tree + rich.print(tree) + + # save config tree to file + if save_to_file: + with open(Path(cfg.paths.output_dir, "config_tree.log"), "w") as file: + rich.print(tree, file=file) + + +@rank_zero_only +def enforce_tags(cfg: DictConfig, save_to_file: bool = False) -> None: + """Prompts user to input tags from command line if no tags are provided in config. + + :param cfg: A DictConfig composed by Hydra. + :param save_to_file: Whether to export tags to the hydra output folder. Default is ``False``. + """ + if not cfg.get("tags"): + if "id" in HydraConfig().cfg.hydra.job: + raise ValueError("Specify tags before launching a multirun!") + + log.warning("No tags provided in config. Prompting user to input tags...") + tags = Prompt.ask("Enter a list of comma separated tags", default="dev") + tags = [t.strip() for t in tags.split(",") if t != ""] + + with open_dict(cfg): + cfg.tags = tags + + log.info(f"Tags: {cfg.tags}") + + if save_to_file: + with open(Path(cfg.paths.output_dir, "tags.log"), "w") as file: + rich.print(cfg.tags, file=file) diff --git a/swim/utils/utils.py b/swim/utils/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..63780505450d378ac041f3457ead6cc562aa8654 --- /dev/null +++ b/swim/utils/utils.py @@ -0,0 +1,121 @@ +import warnings +from importlib.util import find_spec +from typing import Any, Callable, Dict, Optional, Tuple + +from omegaconf import DictConfig + +from swim.utils import pylogger, rich_utils + +log = pylogger.RankedLogger(__name__, rank_zero_only=True) + + +def extras(cfg: DictConfig) -> None: + """Applies optional utilities before the task is started. + + Utilities: + - Ignoring python warnings + - Setting tags from command line + - Rich config printing + + :param cfg: A DictConfig object containing the config tree. + """ + # return if no `extras` config + if not cfg.get("extras"): + log.warning("Extras config not found! ") + return + + # disable python warnings + if cfg.extras.get("ignore_warnings"): + log.info("Disabling python warnings! ") + warnings.filterwarnings("ignore") + + # prompt user to input tags from command line if none are provided in the config + if cfg.extras.get("enforce_tags"): + log.info("Enforcing tags! ") + rich_utils.enforce_tags(cfg, save_to_file=True) + + # pretty print config tree using Rich library + if cfg.extras.get("print_config"): + log.info("Printing config tree with Rich! ") + rich_utils.print_config_tree(cfg, resolve=True, save_to_file=True) + + +def task_wrapper(task_func: Callable) -> Callable: + """Optional decorator that controls the failure behavior when executing the task function. + + This wrapper can be used to: + - make sure loggers are closed even if the task function raises an exception (prevents multirun failure) + - save the exception to a `.log` file + - mark the run as failed with a dedicated file in the `logs/` folder (so we can find and rerun it later) + - etc. (adjust depending on your needs) + + Example: + ``` + @utils.task_wrapper + def train(cfg: DictConfig) -> Tuple[Dict[str, Any], Dict[str, Any]]: + ... + return metric_dict, object_dict + ``` + + :param task_func: The task function to be wrapped. + + :return: The wrapped task function. + """ + + def wrap(cfg: DictConfig) -> Tuple[Dict[str, Any], Dict[str, Any]]: + # execute the task + try: + metric_dict, object_dict = task_func(cfg=cfg) + + # things to do if exception occurs + except Exception as ex: + # save exception to `.log` file + log.exception("") + + # some hyperparameter combinations might be invalid or cause out-of-memory errors + # so when using hparam search plugins like Optuna, you might want to disable + # raising the below exception to avoid multirun failure + raise ex + + # things to always do after either success or exception + finally: + # display output dir path in terminal + log.info(f"Output dir: {cfg.paths.output_dir}") + + # always close wandb run (even if exception occurs so multirun won't fail) + if find_spec("wandb"): # check if wandb is installed + import wandb + + if wandb.run: + log.info("Closing wandb!") + wandb.finish() + + return metric_dict, object_dict + + return wrap + + +def get_metric_value( + metric_dict: Dict[str, Any], metric_name: Optional[str] +) -> Optional[float]: + """Safely retrieves value of the metric logged in LightningModule. + + :param metric_dict: A dict containing metric values. + :param metric_name: If provided, the name of the metric to retrieve. + :return: If a metric name was provided, the value of the metric. + """ + if not metric_name: + log.info("Metric name is None! Skipping metric value retrieval...") + return None + + if metric_name not in metric_dict: + raise Exception( + f"Metric value not found! \n" + "Make sure metric name logged in LightningModule is correct!\n" + "Make sure `optimized_metric` name in `hparams_search` config is correct!" + ) + + metric_value = metric_dict[metric_name].item() + log.info(f"Retrieved metric value! <{metric_name}={metric_value}>") + + return metric_value diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..b5dea333ca4818bbb1a4fdd5e6e9a70a7ebad1b4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,107 @@ +"""This file prepares config fixtures for other tests.""" + +from pathlib import Path + +import pytest +import rootutils +from hydra import compose, initialize +from hydra.core.global_hydra import GlobalHydra +from omegaconf import DictConfig, open_dict + + +@pytest.fixture(scope="package") +def cfg_train_global() -> DictConfig: + """A pytest fixture for setting up a default Hydra DictConfig for training. + + :return: A DictConfig object containing a default Hydra configuration for training. + """ + with initialize(version_base="1.3", config_path="../configs"): + cfg = compose(config_name="train.yaml", return_hydra_config=True, overrides=[]) + + # set defaults for all tests + with open_dict(cfg): + cfg.paths.root_dir = str(rootutils.find_root(indicator=".project-root")) + cfg.trainer.max_epochs = 1 + cfg.trainer.limit_train_batches = 0.01 + cfg.trainer.limit_val_batches = 0.1 + cfg.trainer.limit_test_batches = 0.1 + cfg.trainer.accelerator = "cpu" + cfg.trainer.devices = 1 + cfg.data.num_workers = 0 + cfg.data.pin_memory = False + cfg.extras.print_config = False + cfg.extras.enforce_tags = False + cfg.logger = None + + return cfg + + +@pytest.fixture(scope="package") +def cfg_eval_global() -> DictConfig: + """A pytest fixture for setting up a default Hydra DictConfig for evaluation. + + :return: A DictConfig containing a default Hydra configuration for evaluation. + """ + with initialize(version_base="1.3", config_path="../configs"): + cfg = compose(config_name="eval.yaml", return_hydra_config=True, overrides=["ckpt_path=."]) + + # set defaults for all tests + with open_dict(cfg): + cfg.paths.root_dir = str(rootutils.find_root(indicator=".project-root")) + cfg.trainer.max_epochs = 1 + cfg.trainer.limit_test_batches = 0.1 + cfg.trainer.accelerator = "cpu" + cfg.trainer.devices = 1 + cfg.data.num_workers = 0 + cfg.data.pin_memory = False + cfg.extras.print_config = False + cfg.extras.enforce_tags = False + cfg.logger = None + + return cfg + + +@pytest.fixture(scope="function") +def cfg_train(cfg_train_global: DictConfig, tmp_path: Path) -> DictConfig: + """A pytest fixture built on top of the `cfg_train_global()` fixture, which accepts a temporary + logging path `tmp_path` for generating a temporary logging path. + + This is called by each test which uses the `cfg_train` arg. Each test generates its own temporary logging path. + + :param cfg_train_global: The input DictConfig object to be modified. + :param tmp_path: The temporary logging path. + + :return: A DictConfig with updated output and log directories corresponding to `tmp_path`. + """ + cfg = cfg_train_global.copy() + + with open_dict(cfg): + cfg.paths.output_dir = str(tmp_path) + cfg.paths.log_dir = str(tmp_path) + + yield cfg + + GlobalHydra.instance().clear() + + +@pytest.fixture(scope="function") +def cfg_eval(cfg_eval_global: DictConfig, tmp_path: Path) -> DictConfig: + """A pytest fixture built on top of the `cfg_eval_global()` fixture, which accepts a temporary + logging path `tmp_path` for generating a temporary logging path. + + This is called by each test which uses the `cfg_eval` arg. Each test generates its own temporary logging path. + + :param cfg_train_global: The input DictConfig object to be modified. + :param tmp_path: The temporary logging path. + + :return: A DictConfig with updated output and log directories corresponding to `tmp_path`. + """ + cfg = cfg_eval_global.copy() + + with open_dict(cfg): + cfg.paths.output_dir = str(tmp_path) + cfg.paths.log_dir = str(tmp_path) + + yield cfg + + GlobalHydra.instance().clear() diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/helpers/package_available.py b/tests/helpers/package_available.py new file mode 100644 index 0000000000000000000000000000000000000000..0afdba8dc1efd49f9d8c1a47ede62b7e206b99f3 --- /dev/null +++ b/tests/helpers/package_available.py @@ -0,0 +1,32 @@ +import platform + +import pkg_resources +from lightning.fabric.accelerators import TPUAccelerator + + +def _package_available(package_name: str) -> bool: + """Check if a package is available in your environment. + + :param package_name: The name of the package to be checked. + + :return: `True` if the package is available. `False` otherwise. + """ + try: + return pkg_resources.require(package_name) is not None + except pkg_resources.DistributionNotFound: + return False + + +_TPU_AVAILABLE = TPUAccelerator.is_available() + +_IS_WINDOWS = platform.system() == "Windows" + +_SH_AVAILABLE = not _IS_WINDOWS and _package_available("sh") + +_DEEPSPEED_AVAILABLE = not _IS_WINDOWS and _package_available("deepspeed") +_FAIRSCALE_AVAILABLE = not _IS_WINDOWS and _package_available("fairscale") + +_WANDB_AVAILABLE = _package_available("wandb") +_NEPTUNE_AVAILABLE = _package_available("neptune") +_COMET_AVAILABLE = _package_available("comet_ml") +_MLFLOW_AVAILABLE = _package_available("mlflow") diff --git a/tests/helpers/run_if.py b/tests/helpers/run_if.py new file mode 100644 index 0000000000000000000000000000000000000000..9703af425129d0225d0aeed20dedc3ed35bc7548 --- /dev/null +++ b/tests/helpers/run_if.py @@ -0,0 +1,142 @@ +"""Adapted from: + +https://github.com/PyTorchLightning/pytorch-lightning/blob/master/tests/helpers/runif.py +""" + +import sys +from typing import Any, Dict, Optional + +import pytest +import torch +from packaging.version import Version +from pkg_resources import get_distribution +from pytest import MarkDecorator + +from tests.helpers.package_available import ( + _COMET_AVAILABLE, + _DEEPSPEED_AVAILABLE, + _FAIRSCALE_AVAILABLE, + _IS_WINDOWS, + _MLFLOW_AVAILABLE, + _NEPTUNE_AVAILABLE, + _SH_AVAILABLE, + _TPU_AVAILABLE, + _WANDB_AVAILABLE, +) + + +class RunIf: + """RunIf wrapper for conditional skipping of tests. + + Fully compatible with `@pytest.mark`. + + Example: + + ```python + @RunIf(min_torch="1.8") + @pytest.mark.parametrize("arg1", [1.0, 2.0]) + def test_wrapper(arg1): + assert arg1 > 0 + ``` + """ + + def __new__( + cls, + min_gpus: int = 0, + min_torch: Optional[str] = None, + max_torch: Optional[str] = None, + min_python: Optional[str] = None, + skip_windows: bool = False, + sh: bool = False, + tpu: bool = False, + fairscale: bool = False, + deepspeed: bool = False, + wandb: bool = False, + neptune: bool = False, + comet: bool = False, + mlflow: bool = False, + **kwargs: Dict[Any, Any], + ) -> MarkDecorator: + """Creates a new `@RunIf` `MarkDecorator` decorator. + + :param min_gpus: Min number of GPUs required to run test. + :param min_torch: Minimum pytorch version to run test. + :param max_torch: Maximum pytorch version to run test. + :param min_python: Minimum python version required to run test. + :param skip_windows: Skip test for Windows platform. + :param tpu: If TPU is available. + :param sh: If `sh` module is required to run the test. + :param fairscale: If `fairscale` module is required to run the test. + :param deepspeed: If `deepspeed` module is required to run the test. + :param wandb: If `wandb` module is required to run the test. + :param neptune: If `neptune` module is required to run the test. + :param comet: If `comet` module is required to run the test. + :param mlflow: If `mlflow` module is required to run the test. + :param kwargs: Native `pytest.mark.skipif` keyword arguments. + """ + conditions = [] + reasons = [] + + if min_gpus: + conditions.append(torch.cuda.device_count() < min_gpus) + reasons.append(f"GPUs>={min_gpus}") + + if min_torch: + torch_version = get_distribution("torch").version + conditions.append(Version(torch_version) < Version(min_torch)) + reasons.append(f"torch>={min_torch}") + + if max_torch: + torch_version = get_distribution("torch").version + conditions.append(Version(torch_version) >= Version(max_torch)) + reasons.append(f"torch<{max_torch}") + + if min_python: + py_version = ( + f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + ) + conditions.append(Version(py_version) < Version(min_python)) + reasons.append(f"python>={min_python}") + + if skip_windows: + conditions.append(_IS_WINDOWS) + reasons.append("does not run on Windows") + + if tpu: + conditions.append(not _TPU_AVAILABLE) + reasons.append("TPU") + + if sh: + conditions.append(not _SH_AVAILABLE) + reasons.append("sh") + + if fairscale: + conditions.append(not _FAIRSCALE_AVAILABLE) + reasons.append("fairscale") + + if deepspeed: + conditions.append(not _DEEPSPEED_AVAILABLE) + reasons.append("deepspeed") + + if wandb: + conditions.append(not _WANDB_AVAILABLE) + reasons.append("wandb") + + if neptune: + conditions.append(not _NEPTUNE_AVAILABLE) + reasons.append("neptune") + + if comet: + conditions.append(not _COMET_AVAILABLE) + reasons.append("comet") + + if mlflow: + conditions.append(not _MLFLOW_AVAILABLE) + reasons.append("mlflow") + + reasons = [rs for cond, rs in zip(conditions, reasons) if cond] + return pytest.mark.skipif( + condition=any(conditions), + reason=f"Requires: [{' + '.join(reasons)}]", + **kwargs, + ) diff --git a/tests/helpers/run_sh_command.py b/tests/helpers/run_sh_command.py new file mode 100644 index 0000000000000000000000000000000000000000..fdd2ed633f1185dd7936924616be6a6359a7bca7 --- /dev/null +++ b/tests/helpers/run_sh_command.py @@ -0,0 +1,22 @@ +from typing import List + +import pytest + +from tests.helpers.package_available import _SH_AVAILABLE + +if _SH_AVAILABLE: + import sh + + +def run_sh_command(command: List[str]) -> None: + """Default method for executing shell commands with `pytest` and `sh` package. + + :param command: A list of shell commands as strings. + """ + msg = None + try: + sh.python(command) + except sh.ErrorReturnCode as e: + msg = e.stderr.decode() + if msg: + pytest.fail(msg=msg) diff --git a/tests/test_configs.py b/tests/test_configs.py new file mode 100644 index 0000000000000000000000000000000000000000..d7041dc78cc207489255d8618c4a2e75ba74464d --- /dev/null +++ b/tests/test_configs.py @@ -0,0 +1,37 @@ +import hydra +from hydra.core.hydra_config import HydraConfig +from omegaconf import DictConfig + + +def test_train_config(cfg_train: DictConfig) -> None: + """Tests the training configuration provided by the `cfg_train` pytest fixture. + + :param cfg_train: A DictConfig containing a valid training configuration. + """ + assert cfg_train + assert cfg_train.data + assert cfg_train.model + assert cfg_train.trainer + + HydraConfig().set_config(cfg_train) + + hydra.utils.instantiate(cfg_train.data) + hydra.utils.instantiate(cfg_train.model) + hydra.utils.instantiate(cfg_train.trainer) + + +def test_eval_config(cfg_eval: DictConfig) -> None: + """Tests the evaluation configuration provided by the `cfg_eval` pytest fixture. + + :param cfg_train: A DictConfig containing a valid evaluation configuration. + """ + assert cfg_eval + assert cfg_eval.data + assert cfg_eval.model + assert cfg_eval.trainer + + HydraConfig().set_config(cfg_eval) + + hydra.utils.instantiate(cfg_eval.data) + hydra.utils.instantiate(cfg_eval.model) + hydra.utils.instantiate(cfg_eval.trainer) diff --git a/tests/test_datamodules.py b/tests/test_datamodules.py new file mode 100644 index 0000000000000000000000000000000000000000..a9b6671927c642f1224f33f1e9d260730d239807 --- /dev/null +++ b/tests/test_datamodules.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import pytest +import torch + +from swim.data.mnist_datamodule import MNISTDataModule + + +@pytest.mark.parametrize("batch_size", [32, 128]) +def test_mnist_datamodule(batch_size: int) -> None: + """Tests `MNISTDataModule` to verify that it can be downloaded correctly, that the necessary + attributes were created (e.g., the dataloader objects), and that dtypes and batch sizes + correctly match. + + :param batch_size: Batch size of the data to be loaded by the dataloader. + """ + data_dir = "data/" + + dm = MNISTDataModule(data_dir=data_dir, batch_size=batch_size) + dm.prepare_data() + + assert not dm.data_train and not dm.data_val and not dm.data_test + assert Path(data_dir, "MNIST").exists() + assert Path(data_dir, "MNIST", "raw").exists() + + dm.setup() + assert dm.data_train and dm.data_val and dm.data_test + assert dm.train_dataloader() and dm.val_dataloader() and dm.test_dataloader() + + num_datapoints = len(dm.data_train) + len(dm.data_val) + len(dm.data_test) + assert num_datapoints == 70_000 + + batch = next(iter(dm.train_dataloader())) + x, y = batch + assert len(x) == batch_size + assert len(y) == batch_size + assert x.dtype == torch.float32 + assert y.dtype == torch.int64 diff --git a/tests/test_eval.py b/tests/test_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..d266a652329bc26a271bf747ce09034d71fd995e --- /dev/null +++ b/tests/test_eval.py @@ -0,0 +1,44 @@ +import os +from pathlib import Path + +import pytest +from hydra.core.hydra_config import HydraConfig +from omegaconf import DictConfig, open_dict + +from swim.eval import evaluate +from swim.train import train + + +@pytest.mark.slow +def test_train_eval( + tmp_path: Path, cfg_train: DictConfig, cfg_eval: DictConfig +) -> None: + """Tests training and evaluation by training for 1 epoch with `train.py` then evaluating with + `eval.py`. + + :param tmp_path: The temporary logging path. + :param cfg_train: A DictConfig containing a valid training configuration. + :param cfg_eval: A DictConfig containing a valid evaluation configuration. + """ + assert str(tmp_path) == cfg_train.paths.output_dir == cfg_eval.paths.output_dir + + with open_dict(cfg_train): + cfg_train.trainer.max_epochs = 1 + cfg_train.test = True + + HydraConfig().set_config(cfg_train) + train_metric_dict, _ = train(cfg_train) + + assert "last.ckpt" in os.listdir(tmp_path / "checkpoints") + + with open_dict(cfg_eval): + cfg_eval.ckpt_path = str(tmp_path / "checkpoints" / "last.ckpt") + + HydraConfig().set_config(cfg_eval) + test_metric_dict, _ = evaluate(cfg_eval) + + assert test_metric_dict["test/acc"] > 0.0 + assert ( + abs(train_metric_dict["test/acc"].item() - test_metric_dict["test/acc"].item()) + < 0.001 + ) diff --git a/tests/test_sweeps.py b/tests/test_sweeps.py new file mode 100644 index 0000000000000000000000000000000000000000..7856b1551df4e3d4979110ede30076e6a703976f --- /dev/null +++ b/tests/test_sweeps.py @@ -0,0 +1,107 @@ +from pathlib import Path + +import pytest + +from tests.helpers.run_if import RunIf +from tests.helpers.run_sh_command import run_sh_command + +startfile = "src/train.py" +overrides = ["logger=[]"] + + +@RunIf(sh=True) +@pytest.mark.slow +def test_experiments(tmp_path: Path) -> None: + """Test running all available experiment configs with `fast_dev_run=True.` + + :param tmp_path: The temporary logging path. + """ + command = [ + startfile, + "-m", + "experiment=glob(*)", + "hydra.sweep.dir=" + str(tmp_path), + "++trainer.fast_dev_run=true", + ] + overrides + run_sh_command(command) + + +@RunIf(sh=True) +@pytest.mark.slow +def test_hydra_sweep(tmp_path: Path) -> None: + """Test default hydra sweep. + + :param tmp_path: The temporary logging path. + """ + command = [ + startfile, + "-m", + "hydra.sweep.dir=" + str(tmp_path), + "model.optimizer.lr=0.005,0.01", + "++trainer.fast_dev_run=true", + ] + overrides + + run_sh_command(command) + + +@RunIf(sh=True) +@pytest.mark.slow +def test_hydra_sweep_ddp_sim(tmp_path: Path) -> None: + """Test default hydra sweep with ddp sim. + + :param tmp_path: The temporary logging path. + """ + command = [ + startfile, + "-m", + "hydra.sweep.dir=" + str(tmp_path), + "trainer=ddp_sim", + "trainer.max_epochs=3", + "+trainer.limit_train_batches=0.01", + "+trainer.limit_val_batches=0.1", + "+trainer.limit_test_batches=0.1", + "model.optimizer.lr=0.005,0.01,0.02", + ] + overrides + run_sh_command(command) + + +@RunIf(sh=True) +@pytest.mark.slow +def test_optuna_sweep(tmp_path: Path) -> None: + """Test Optuna hyperparam sweeping. + + :param tmp_path: The temporary logging path. + """ + command = [ + startfile, + "-m", + "hparams_search=mnist_optuna", + "hydra.sweep.dir=" + str(tmp_path), + "hydra.sweeper.n_trials=10", + "hydra.sweeper.sampler.n_startup_trials=5", + "++trainer.fast_dev_run=true", + ] + overrides + run_sh_command(command) + + +@RunIf(wandb=True, sh=True) +@pytest.mark.slow +def test_optuna_sweep_ddp_sim_wandb(tmp_path: Path) -> None: + """Test Optuna sweep with wandb logging and ddp sim. + + :param tmp_path: The temporary logging path. + """ + command = [ + startfile, + "-m", + "hparams_search=mnist_optuna", + "hydra.sweep.dir=" + str(tmp_path), + "hydra.sweeper.n_trials=5", + "trainer=ddp_sim", + "trainer.max_epochs=3", + "+trainer.limit_train_batches=0.01", + "+trainer.limit_val_batches=0.1", + "+trainer.limit_test_batches=0.1", + "logger=wandb", + ] + run_sh_command(command) diff --git a/tests/test_train.py b/tests/test_train.py new file mode 100644 index 0000000000000000000000000000000000000000..d74fffbff315840148ea9b708bae2c495108f30a --- /dev/null +++ b/tests/test_train.py @@ -0,0 +1,108 @@ +import os +from pathlib import Path + +import pytest +from hydra.core.hydra_config import HydraConfig +from omegaconf import DictConfig, open_dict + +from swim.train import train +from tests.helpers.run_if import RunIf + + +def test_train_fast_dev_run(cfg_train: DictConfig) -> None: + """Run for 1 train, val and test step. + + :param cfg_train: A DictConfig containing a valid training configuration. + """ + HydraConfig().set_config(cfg_train) + with open_dict(cfg_train): + cfg_train.trainer.fast_dev_run = True + cfg_train.trainer.accelerator = "cpu" + train(cfg_train) + + +@RunIf(min_gpus=1) +def test_train_fast_dev_run_gpu(cfg_train: DictConfig) -> None: + """Run for 1 train, val and test step on GPU. + + :param cfg_train: A DictConfig containing a valid training configuration. + """ + HydraConfig().set_config(cfg_train) + with open_dict(cfg_train): + cfg_train.trainer.fast_dev_run = True + cfg_train.trainer.accelerator = "gpu" + train(cfg_train) + + +@RunIf(min_gpus=1) +@pytest.mark.slow +def test_train_epoch_gpu_amp(cfg_train: DictConfig) -> None: + """Train 1 epoch on GPU with mixed-precision. + + :param cfg_train: A DictConfig containing a valid training configuration. + """ + HydraConfig().set_config(cfg_train) + with open_dict(cfg_train): + cfg_train.trainer.max_epochs = 1 + cfg_train.trainer.accelerator = "gpu" + cfg_train.trainer.precision = 16 + train(cfg_train) + + +@pytest.mark.slow +def test_train_epoch_double_val_loop(cfg_train: DictConfig) -> None: + """Train 1 epoch with validation loop twice per epoch. + + :param cfg_train: A DictConfig containing a valid training configuration. + """ + HydraConfig().set_config(cfg_train) + with open_dict(cfg_train): + cfg_train.trainer.max_epochs = 1 + cfg_train.trainer.val_check_interval = 0.5 + train(cfg_train) + + +@pytest.mark.slow +def test_train_ddp_sim(cfg_train: DictConfig) -> None: + """Simulate DDP (Distributed Data Parallel) on 2 CPU processes. + + :param cfg_train: A DictConfig containing a valid training configuration. + """ + HydraConfig().set_config(cfg_train) + with open_dict(cfg_train): + cfg_train.trainer.max_epochs = 2 + cfg_train.trainer.accelerator = "cpu" + cfg_train.trainer.devices = 2 + cfg_train.trainer.strategy = "ddp_spawn" + train(cfg_train) + + +@pytest.mark.slow +def test_train_resume(tmp_path: Path, cfg_train: DictConfig) -> None: + """Run 1 epoch, finish, and resume for another epoch. + + :param tmp_path: The temporary logging path. + :param cfg_train: A DictConfig containing a valid training configuration. + """ + with open_dict(cfg_train): + cfg_train.trainer.max_epochs = 1 + + HydraConfig().set_config(cfg_train) + metric_dict_1, _ = train(cfg_train) + + files = os.listdir(tmp_path / "checkpoints") + assert "last.ckpt" in files + assert "epoch_000.ckpt" in files + + with open_dict(cfg_train): + cfg_train.ckpt_path = str(tmp_path / "checkpoints" / "last.ckpt") + cfg_train.trainer.max_epochs = 2 + + metric_dict_2, _ = train(cfg_train) + + files = os.listdir(tmp_path / "checkpoints") + assert "epoch_001.ckpt" in files + assert "epoch_002.ckpt" not in files + + assert metric_dict_1["train/acc"] < metric_dict_2["train/acc"] + assert metric_dict_1["val/acc"] < metric_dict_2["val/acc"]