Sync from GitHub via hub-sync
Browse files- LICENSE +21 -0
- README.md +377 -3
- configs/train_config.yaml +44 -0
- data/.gitkeep +1 -0
- data/__init__.py +0 -0
- data/data_utils.py +23 -0
- data/dataloader.py +20 -0
- data/dataset.py +38 -0
- data/loading.py +86 -0
- data/parsers.py +301 -0
- data/perceptual_transforms.py +164 -0
- datasets/.gitkeep +1 -0
- datasets_synthetic/.gitkeep +1 -0
- ius/__init__.py +0 -0
- ius/ius.py +125 -0
- ius/ius_eval_parser.py +49 -0
- model/.gitkeep +1 -0
- model/__init__.py +0 -0
- model/epu.py +147 -0
- model/module_mapping.py +31 -0
- model/register_modules.py +25 -0
- model/subnetwork.py +86 -0
- model/subnetwork_utils.py +111 -0
- requirements.txt +11 -0
- scripts/.gitkeep +1 -0
- scripts/eval_ius.py +117 -0
- scripts/infer_cb_vector.py +85 -0
- scripts/infer_epu.py +106 -0
- scripts/train_epu.py +120 -0
- utils/.gitkeep +1 -0
- utils/callbacks.py +41 -0
- utils/config_utils.py +54 -0
- utils/early_stopping.py +145 -0
- utils/eval_utils.py +78 -0
- utils/metrics.py +110 -0
- utils/omega_parser.py +109 -0
- utils/sanity_utils.py +96 -0
- utils/tensorboard.py +103 -0
- utils/train_utils.py +108 -0
- utils/trainer.py +206 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 innoisys
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,3 +1,377 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Interpretable Similarity of Synthetic Image Utility (IUS)
|
| 2 |
+
[](https://github.com/innoisys/ius/)
|
| 3 |
+
[](README.md)
|
| 4 |
+
[](https://opensource.org/licenses/MIT)
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
This repository contains the official **PyTorch** implementation of the *IUS* measure, introduced in [**"Interpretable Similarity of Synthetic Image Utility"**](https://ieeexplore.ieee.org/document/11458792) , published in **IEEE Transactions on Medical Imaging (TMI)**.
|
| 8 |
+
The original **TensorFlow** implementation will also be made available soon.
|
| 9 |
+
|
| 10 |
+
## Overview
|
| 11 |
+
**IUS** (*Interpretable Utility Similarity*) is an interpretable measure for assessing the utility of synthetic medical image datasets for downstream clinical decision support (CDS) tasks, built upon the **EPU-CNN** framework introduced in [*E pluribus unum interpretable convolutional neural networks*](https://www.nature.com/articles/s41598-023-38459-1) and available in our [previous code repository](https://github.com/innoisys/epu-cnn-torch).
|
| 12 |
+
|
| 13 |
+
## Table of Contents
|
| 14 |
+
- [Features](#features)
|
| 15 |
+
- [Installation](#installation)
|
| 16 |
+
- [Project Structure](#project-structure)
|
| 17 |
+
- [Usage](#usage)
|
| 18 |
+
- [1. Prepare Configuration](#1-prepare-configuration)
|
| 19 |
+
- [2. Supported Dataset Structures for EPU-CNN Training](#2-supported-dataset-structures-for-epu-cnn-training)
|
| 20 |
+
- [2.1 Filename-based Structure](#21-filename-based-structure)
|
| 21 |
+
- [2.2 Folder-based Structure](#22-folder-based-structure)
|
| 22 |
+
- [2.3 MedMNIST Benchmark Structure](#23-medmnist-benchmark-structure)
|
| 23 |
+
- [3. EPU-CNN Training](#3-epu-cnn-training)
|
| 24 |
+
- [4. EPU-CNN Evaluation](#4-epu-cnn-evaluation)
|
| 25 |
+
- [5. Feature contribution profile estimation](#5-feature-contribution-profile-estimation)
|
| 26 |
+
- [6. Synthetic image utility evaluation with IUS](#6-synthetic-image-utility-evaluation-with-ius)
|
| 27 |
+
- [Paper Abstract](#-paper-abstract)
|
| 28 |
+
- [Citation](#citation)
|
| 29 |
+
- [License](#-license)
|
| 30 |
+
- [TODO](#todo)
|
| 31 |
+
|
| 32 |
+
## Features
|
| 33 |
+
- YAML-based configuration for reproducible training, inference, and evaluation
|
| 34 |
+
- Flexible support for multiple training dataset structures
|
| 35 |
+
- Integrated **TensorBoard** logging for experiment monitoring
|
| 36 |
+
- Complete implementation of the **IUS** pipeline:
|
| 37 |
+
1. **EPU-CNN training** and classification performance evaluation
|
| 38 |
+
2. **Baseline feature contribution profile** estimation
|
| 39 |
+
3. **Synthetic dataset or singleton evaluation using IUS**
|
| 40 |
+
- Support for both **color** and **grayscale** medical imaging modalities
|
| 41 |
+
- Reproducible experiment management through structured saving of checkpoints, logs, timestamped results, and evaluation outputs
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
## Installation
|
| 45 |
+
Clone the repository:
|
| 46 |
+
```bash
|
| 47 |
+
git clone https://github.com/innoisys/ius.git
|
| 48 |
+
cd ius
|
| 49 |
+
```
|
| 50 |
+
Install dependencies:
|
| 51 |
+
```bash
|
| 52 |
+
pip install -r requirements.txt
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
## Project Structure
|
| 56 |
+
```
|
| 57 |
+
ius/
|
| 58 |
+
├── configs/ # YAML configuration files
|
| 59 |
+
├── data/ # Data loading and preprocessing
|
| 60 |
+
│ ├── data_utils.py # Common utilities for image/data handling
|
| 61 |
+
│ ├── dataloader.py # Dataloader definitions
|
| 62 |
+
│ ├── dataset.py # Dataset implementations
|
| 63 |
+
│ ├── loading.py # Dataset setup from YAML configuration
|
| 64 |
+
│ ├── parsers.py # Data parser implementations
|
| 65 |
+
│ └── perceptual_transforms.py # PFM (Perceptual Feature Map) generation
|
| 66 |
+
├── datasets/ # Real datasets for EPU-CNN training & baseline feature contribution profile estimation
|
| 67 |
+
├── datasets_synthetic/ # Synthetic data for IUS evaluation
|
| 68 |
+
├── ius/ # IUS implementation
|
| 69 |
+
│ ├── ius.py # IUS measure class
|
| 70 |
+
│ └── ius_eval_parser.py # Suggested synthetic data parser (not requiring label information)
|
| 71 |
+
├── model/ # EPU-CNN model implementation
|
| 72 |
+
│ ├── epu.py # Main EPU-CNN model definition
|
| 73 |
+
│ ├── module_mapping.py # Mappings from YAML config names to torch.nn layers/activations
|
| 74 |
+
│ ├── register_modules.py # Registry for configurable model components
|
| 75 |
+
│ ├── subnetworks.py # Subnetwork implementation
|
| 76 |
+
│ └── subnetwork_utilities.py # Subnetwork helper modules
|
| 77 |
+
├── results/ # Training and inference outputs
|
| 78 |
+
│ ├── cb_vectors.py # Saved baseline feature contribution profiles (from infer_cb_vector.py)
|
| 79 |
+
│ ├── checkpoints.py # Saved EPU-CNN checkpoints and training configurations (from train_epu.py)
|
| 80 |
+
│ ├── classification_performance.py # Classification performance results (from infer_epu.py)
|
| 81 |
+
│ ├── ius_eval.py # IUS evaluation results (from eval_ius.py)
|
| 82 |
+
│ └── logs.py # TensorBoard logs (from train_epu.py )
|
| 83 |
+
├── scripts/ # Training and inference scripts
|
| 84 |
+
│ ├── eval_ius.py # Runs synthetic data evaluation with IUS
|
| 85 |
+
│ ├── infer_cb_vector.py # Estimates baseline feature contribution profiles
|
| 86 |
+
│ ├── infer_epu.py # Runs EPU-CNN inference/evaluation
|
| 87 |
+
│ └── train_epu.py # Trains EPU-CNN models
|
| 88 |
+
└── utils/ # Utility functions
|
| 89 |
+
├── callbacks.py # Training callbacks
|
| 90 |
+
├── config_utils.py # YAML/configuration utilities
|
| 91 |
+
├── early_stopping.py # Early stopping logic
|
| 92 |
+
├── eval_utils.py # Utilities for EPU-CNN evaluation scripts
|
| 93 |
+
├── metrics.py # Classification performance metrics
|
| 94 |
+
├── omega_parser.py # OmegaConf-based configuration parser
|
| 95 |
+
├── sanity_utils.py # Configuration validation and sanity checks
|
| 96 |
+
├── tensorboard.py # Tensorboard utilities
|
| 97 |
+
├── train_utils.py # Training setup and helping utilities
|
| 98 |
+
└── trainer.py # Main training loop implementation
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
## Usage
|
| 102 |
+
### 1. Prepare Configuration
|
| 103 |
+
|
| 104 |
+
Create a YAML configuration file in `configs/` with the following structure:
|
| 105 |
+
|
| 106 |
+
```yaml
|
| 107 |
+
model:
|
| 108 |
+
num_subnetworks: 4 # set to 4, corresponds to number of perceptual feature maps,
|
| 109 |
+
num_classes: 1
|
| 110 |
+
epu_activation: "sigmoid"
|
| 111 |
+
subnetwork_config:
|
| 112 |
+
architecture: "base_one" # default ius backbone
|
| 113 |
+
input_channels: 1 # number of channels in perceptual feature decomposition, set to 1
|
| 114 |
+
base_channels: 32
|
| 115 |
+
fc_hidden_units: 64
|
| 116 |
+
pred_activation: "tanh"
|
| 117 |
+
data_params:
|
| 118 |
+
dataset_path: "../datasets/dataset_name"
|
| 119 |
+
images_extension: "jpg"
|
| 120 |
+
data_loading:
|
| 121 |
+
batch_size: 64
|
| 122 |
+
shuffle: true
|
| 123 |
+
num_workers: 0
|
| 124 |
+
pin_memory: false
|
| 125 |
+
persistent_workers: false
|
| 126 |
+
data_preprocessing:
|
| 127 |
+
data_mode: "rgb" # "rgb" or "grayscale"
|
| 128 |
+
data_parser: "filename" # "filename" or "folder" or "medmnist"
|
| 129 |
+
resize_dims: [128, 128]
|
| 130 |
+
medmnist_csv_file: None
|
| 131 |
+
label_mapping:
|
| 132 |
+
abnormal: 1
|
| 133 |
+
normal: 0
|
| 134 |
+
train_params:
|
| 135 |
+
mode: "binary"
|
| 136 |
+
loss: "binary_cross_entropy"
|
| 137 |
+
epochs: 200
|
| 138 |
+
optimizer: "sgd"
|
| 139 |
+
learning_rate: 0.001
|
| 140 |
+
momentum: 0.9
|
| 141 |
+
weight_decay: 0.001
|
| 142 |
+
early_stopping_patience: 30
|
| 143 |
+
early_stopping_monitor: "val_loss" # "val_loss" or "val_metrics.auc"
|
| 144 |
+
early_stopping_mode: "min" # "min" or "max"
|
| 145 |
+
log_dir: "../results/logs" # default parent path
|
| 146 |
+
checkpoint_dir: "../results/checkpoints" # default parent path
|
| 147 |
+
experiment_name: "ius_dataset_name" # desired experiment path
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
**Key Points:**
|
| 151 |
+
- For binary classification: Use `n_classes: 1`, `epu_activation: "sigmoid"`
|
| 152 |
+
- Adjust `input_size` and `batch_size` based on your GPU memory
|
| 153 |
+
- Set `label_mapping` according to your dataset classes
|
| 154 |
+
|
| 155 |
+
### 2. Supported Dataset Structures for EPU-CNN Training
|
| 156 |
+
|
| 157 |
+
EPU-CNN training supports multiple dataset organization patterns. Complete examples are provided below:
|
| 158 |
+
|
| 159 |
+
#### 2.1 Filename-based Structure
|
| 160 |
+
|
| 161 |
+
```
|
| 162 |
+
datasets/
|
| 163 |
+
├── dataset_name/
|
| 164 |
+
├── train/
|
| 165 |
+
│ ├── abnormal_001.jpg
|
| 166 |
+
│ ├── abnormal_002.jpg
|
| 167 |
+
│ ├── normal_001.jpg
|
| 168 |
+
│ ├── normal_002.jpg
|
| 169 |
+
├── validation/
|
| 170 |
+
│ ├── abnormal_003.jpg
|
| 171 |
+
│ ├── normal_003.jpg
|
| 172 |
+
└── test/
|
| 173 |
+
├── abnormal_004.jpg
|
| 174 |
+
└── normal_004.jpg
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
**Configuration Example:**
|
| 178 |
+
```yaml
|
| 179 |
+
data_params:
|
| 180 |
+
dataset_path: "../datasets/dataset_name"
|
| 181 |
+
images_extension: "jpg"
|
| 182 |
+
data_preprocessing:
|
| 183 |
+
data_mode: "rgb" # "rgb" or "grayscale"
|
| 184 |
+
data_parser: "filename"
|
| 185 |
+
resize_dims: [128, 128]
|
| 186 |
+
medmnist_csv_file: None
|
| 187 |
+
label_mapping:
|
| 188 |
+
abnormal: 1
|
| 189 |
+
normal: 0
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
**Key Points:**
|
| 193 |
+
- Requires a consistent organization across train/validation/test splits
|
| 194 |
+
- The validation folder name must be exactly "validation" (not "val" or other variants)
|
| 195 |
+
- Supported image formats include jpg, jpeg, png, etc, supported by the parser
|
| 196 |
+
- Class names defined in label_mapping must appear in filenames
|
| 197 |
+
|
| 198 |
+
#### 2.2 Folder-based Structure
|
| 199 |
+
|
| 200 |
+
```
|
| 201 |
+
datasets/
|
| 202 |
+
├── dataset_name/
|
| 203 |
+
├── train/
|
| 204 |
+
│ ├── abnormal
|
| 205 |
+
│ │ ├── image_001.jpg
|
| 206 |
+
│ │ └── image_002.jpg
|
| 207 |
+
��� └── normal
|
| 208 |
+
│ ├── image_001.jpg
|
| 209 |
+
│ └── image_002.jpg
|
| 210 |
+
├── validation/
|
| 211 |
+
│ ├── abnormal
|
| 212 |
+
│ │ └── image_003.jpg
|
| 213 |
+
│ └── normal
|
| 214 |
+
│ └── image_003.jpg
|
| 215 |
+
└── test/
|
| 216 |
+
├── abnormal
|
| 217 |
+
│ └── image_004.jpg
|
| 218 |
+
└── normal
|
| 219 |
+
└── image_004.jpg
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
**Configuration Example:**
|
| 223 |
+
```yaml
|
| 224 |
+
data_params:
|
| 225 |
+
dataset_path: "../datasets/dataset_name"
|
| 226 |
+
images_extension: "jpg"
|
| 227 |
+
data_preprocessing:
|
| 228 |
+
data_mode: "rgb" # "rgb" or "grayscale"
|
| 229 |
+
data_parser: "folder"
|
| 230 |
+
resize_dims: [128, 128]
|
| 231 |
+
medmnist_csv_file: None
|
| 232 |
+
label_mapping:
|
| 233 |
+
abnormal: 1
|
| 234 |
+
normal: 0
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
**Key Points:**
|
| 238 |
+
- Requires a consistent organization across train/validation/test splits
|
| 239 |
+
- The validation folder name must be exactly "validation" (not "val" or other variants)
|
| 240 |
+
- Supported image formats include jpg, jpeg, png, etc. supported by the parser
|
| 241 |
+
- Class names defined in label_mapping must match the class folder names
|
| 242 |
+
|
| 243 |
+
#### 2.3 MedMNIST Benchmark Structure
|
| 244 |
+
|
| 245 |
+
```
|
| 246 |
+
datasets/
|
| 247 |
+
├── pneumoniamnist/
|
| 248 |
+
│ ├── pneumoniamnist.csv
|
| 249 |
+
│ ├── test_0_0.png
|
| 250 |
+
│ ├── test_1_1.png
|
| 251 |
+
│ ├── train_0_1.png
|
| 252 |
+
│ ├── train_1_0.png
|
| 253 |
+
│ ├── train_2_0.png
|
| 254 |
+
│ ├── train_3_1.png
|
| 255 |
+
│ ├── val_0_1.png
|
| 256 |
+
│ └── val_1_0.png
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
**Configuration Example:**
|
| 260 |
+
```yaml
|
| 261 |
+
data_params:
|
| 262 |
+
dataset_path: "../datasets/pneumoniamnist"
|
| 263 |
+
images_extension: "png"
|
| 264 |
+
data_preprocessing:
|
| 265 |
+
data_mode: "rgb" # "rgb" or "grayscale"
|
| 266 |
+
data_parser: "folder"
|
| 267 |
+
resize_dims: [128, 128]
|
| 268 |
+
medmnist_csv_file: "../datasets/pneumoniamnist/pneumoniamnist.csv"
|
| 269 |
+
label_mapping:
|
| 270 |
+
pneumonia: 1
|
| 271 |
+
normal: 0
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
**Key Points:**
|
| 275 |
+
- Supports any 2D dataset included in the MedMNIST Benchmark collection.
|
| 276 |
+
- Requires the data in the original format provided by the [MedMNIST benchmark](https://github.com/MedMNIST/MedMNIST/tree/main)
|
| 277 |
+
- The CSV file should be used as downloaded, without modification.
|
| 278 |
+
- Class names defined in label_mapping must match the official class names for the corresponding dataset provided in [info](https://github.com/MedMNIST/MedMNIST/blob/main/medmnist/info.py)
|
| 279 |
+
|
| 280 |
+
### 3. EPU-CNN Training
|
| 281 |
+
To train EPU-CNN, run one of the following commands after setting up a `config.yaml` file:
|
| 282 |
+
```bash
|
| 283 |
+
|
| 284 |
+
# Basic training
|
| 285 |
+
python scripts/train_epu.py --config_filepath configs/train_config.yaml
|
| 286 |
+
|
| 287 |
+
# Training with TensorBoard monitoring
|
| 288 |
+
python scripts/train_epu.py --config_filepath configs/train_config.yaml --tensorboard
|
| 289 |
+
```
|
| 290 |
+
The script saves trained model checkpoints and YAML training config in results/checkpoints under automatically generated name as:
|
| 291 |
+
{experiment_name}_{subnetwork_backbone}_{run_id}_{timestamp}
|
| 292 |
+
|
| 293 |
+
When using the `--tensorboard` flag, the script automatically:
|
| 294 |
+
- launches TensorBoard as a subprocess
|
| 295 |
+
- sets up monitoring for the `logs` directory
|
| 296 |
+
- makes TensorBoard available at `http://localhost:6006`
|
| 297 |
+
- enables real-time monitoring of training metrics and model graphs.
|
| 298 |
+
|
| 299 |
+
### 4. EPU-CNN Evaluation
|
| 300 |
+
To assess the classification performance of a trained EPU-CNN model run:
|
| 301 |
+
```bash
|
| 302 |
+
python scripts/infer_epu.py --experiment_folder_name ius_dataset_name_base_one_0000_timestamp
|
| 303 |
+
```
|
| 304 |
+
- Argument --experiment_folder_name contains the saved checkpoint and saved YAML config used during the EPU-CNN training.
|
| 305 |
+
- The classification performance report is saved in JSON format under results/classification_performance/{experiment_folder_name}
|
| 306 |
+
|
| 307 |
+
### 5. Feature contribution profile estimation
|
| 308 |
+
For each trained EPU-CNN model instance, the baseline feature contribution profile must be estimated only once using ether:
|
| 309 |
+
```bash
|
| 310 |
+
# Estimates all baseline feature contribution profiles
|
| 311 |
+
python scripts/infer_cb_vector.py --experiment_folder_name ius_dataset_name_base_one_0000_timestamp
|
| 312 |
+
|
| 313 |
+
# Estimates the baseline feature contribution profile of a single class
|
| 314 |
+
python scripts/infer_cb_vector.py --experiment_folder_name ius_dataset_name_base_one_0000_timestamp --cb_data normal
|
| 315 |
+
```
|
| 316 |
+
- The --experiment_folder_name contains the saved checkpoint and saved YAML config used during the EPU-CNN training.
|
| 317 |
+
- If argument --cb_data is not provided, the script estimates the baseline feature contribution profile for each class defined in saved config YAML (automatically retrieved from results/checkpoints based on --experiment_folder_name)
|
| 318 |
+
|
| 319 |
+
### 6. Synthetic image utility evaluation with IUS
|
| 320 |
+
To evaluate the utility of synthetic images using IUS, run:
|
| 321 |
+
```bash
|
| 322 |
+
# For synthetic dataset IUS evaluation
|
| 323 |
+
python scripts/eval_ius.py --experiment_folder_name ius_dataset_name_base_one_0000_timestamp --cb_vector_tag normal --synthetic_images datasets_synthetic/dataset_name/normal --synthetic_img_extension png
|
| 324 |
+
|
| 325 |
+
# For a single image IUS evaluation
|
| 326 |
+
python scripts/eval_ius.py --experiment_folder_name ius_dataset_name_base_one_0000_timestamp --cb_vector_tag normal --synthetic_images datasets_synthetic/dataset_name/normal/seed_000.png --synthetic_img_extension png
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
Both commands produce two outputs saved under results/ius_eval/{experiment_folder_name}
|
| 330 |
+
1. A JSON report containing information about IUS evaluation.
|
| 331 |
+
2. A CSV file containing the estimated IUS score for each synthetic image.
|
| 332 |
+
|
| 333 |
+
**Key Points:**
|
| 334 |
+
- Synthetic data should be stored under datasets_synthetic
|
| 335 |
+
- No specific internal folder structure is required; only a valid path to a folder of images is needed.
|
| 336 |
+
- A suggested organization example is provided below
|
| 337 |
+
- Supported image formats include jpg, jpeg, png, etc, supported by the parser
|
| 338 |
+
- Class labels are neither required nor used during IUS evaluation
|
| 339 |
+
|
| 340 |
+
Suggested synthetic data structure
|
| 341 |
+
```
|
| 342 |
+
datasets_synthetic/
|
| 343 |
+
├── dataset_name/
|
| 344 |
+
├── normal/
|
| 345 |
+
├── seed_000.png
|
| 346 |
+
└── seed_001.png
|
| 347 |
+
```
|
| 348 |
+
|
| 349 |
+
## 📄 Paper Abstract
|
| 350 |
+
<p align="justify">
|
| 351 |
+
Synthetic medical image data can unlock the potential of deep learning (DL)-based clinical decision support (CDS) systems through the creation of large scale, privacy-preserving, training sets. Despite the significant progress in this field, there is still a largely unanswered research question: “How can we quantitatively assess the similarity of a synthetically generated set of images with a set of real images in a given application domain?”. Today, answers to this question are mainly provided via user evaluation studies, inception-based measures, and the classification performance achieved on synthetic images. This paper proposes a novel measure to assess the similarity between synthetically generated and real sets of images, in terms of their utility for the development of DL based CDS systems. Inspired by generalized neural additive models, and unlike inception-based measures, the proposed measure is interpretable (Interpretable Utility Similarity, IUS), explaining why a synthetic dataset could be more useful than another one in the context of a CDS system based on clinically relevant image features. The experimental results on publicly available benchmark datasets from various color medical imaging modalities including endoscopic, dermoscopic and fundus imaging, indicate that selecting synthetic images of high utility similarity using IUS can result in relative improvements of up to 54.6% in terms of classification performance. The generality of IUS for synthetic data assessment is demonstrated also for grayscale X-ray and ultrasound imaging modalities. IUS implementation is available at https://github.com/innoisys/ius.
|
| 352 |
+
</p>
|
| 353 |
+
|
| 354 |
+
## Citation
|
| 355 |
+
If you use this code or find our work useful in your research, please cite:
|
| 356 |
+
|
| 357 |
+
**APA**
|
| 358 |
+
|
| 359 |
+
P. Gatoula, G. Dimas and D. K. Iakovidis, "Interpretable Similarity of Synthetic Image Utility," in *IEEE Transactions on Medical Imaging*, doi: 10.1109/TMI.2026.3679527.
|
| 360 |
+
|
| 361 |
+
**BibTeX**
|
| 362 |
+
```bibtex
|
| 363 |
+
@article{
|
| 364 |
+
author = {Panagiota Gatoula and George Dimas and Dimitris K. Iakovidis},
|
| 365 |
+
title = {Interpretable Similarity of Synthetic Image Utility},
|
| 366 |
+
journal = {IEEE Transactions on Medical Imaging},
|
| 367 |
+
year = {2026},
|
| 368 |
+
publisher = {IEEE},
|
| 369 |
+
doi = {10.1109/TMI.2026.3679527}
|
| 370 |
+
}
|
| 371 |
+
```
|
| 372 |
+
|
| 373 |
+
## ⚖️ License
|
| 374 |
+
This project is licensed under the MIT License.
|
| 375 |
+
|
| 376 |
+
## TODO
|
| 377 |
+
- [X] Add bar-plot visualization items for the feature contribution profiles
|
configs/train_config.yaml
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
model:
|
| 2 |
+
num_subnetworks: 4 # corresponds to number of perceptual feature maps, set to 4
|
| 3 |
+
num_classes: 1
|
| 4 |
+
epu_activation: "sigmoid"
|
| 5 |
+
subnetwork_config:
|
| 6 |
+
architecture: "base_one" # default ius backbone
|
| 7 |
+
input_channels: 1 # number of channels in perceptual feature decomposition, set to 1
|
| 8 |
+
base_channels: 64
|
| 9 |
+
fc_hidden_units: 32
|
| 10 |
+
pred_activation: "tanh"
|
| 11 |
+
|
| 12 |
+
data_params:
|
| 13 |
+
dataset_path: "../datasets/kid"
|
| 14 |
+
images_extension: "png"
|
| 15 |
+
data_loading:
|
| 16 |
+
batch_size: 8
|
| 17 |
+
shuffle: true
|
| 18 |
+
num_workers: 0
|
| 19 |
+
pin_memory: false
|
| 20 |
+
persistent_workers: false
|
| 21 |
+
data_preprocessing:
|
| 22 |
+
data_mode: "rgb" # "rgb" or "grayscale"
|
| 23 |
+
data_parser: "folder" # "filename" or "folder" or "medmnist"
|
| 24 |
+
resize_dims: [ 128, 128 ]
|
| 25 |
+
medmnist_csv_file: None
|
| 26 |
+
label_mapping:
|
| 27 |
+
abnormal: 1
|
| 28 |
+
normal: 0
|
| 29 |
+
|
| 30 |
+
train_params:
|
| 31 |
+
mode: "binary"
|
| 32 |
+
loss: "binary_cross_entropy"
|
| 33 |
+
epochs: 200
|
| 34 |
+
optimizer: "sgd"
|
| 35 |
+
learning_rate: 0.001
|
| 36 |
+
momentum: 0.9
|
| 37 |
+
weight_decay: 0.001
|
| 38 |
+
early_stopping_patience: 10
|
| 39 |
+
early_stopping_monitor: "val_loss" # "val_loss" or "val_metrics.auc"
|
| 40 |
+
early_stopping_mode: "min" # "min" of "max"
|
| 41 |
+
|
| 42 |
+
log_dir: "../results/logs" # default parent path
|
| 43 |
+
checkpoint_dir: "../results/checkpoints" # default parent path
|
| 44 |
+
experiment_name: "ius_kid"
|
data/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
data/__init__.py
ADDED
|
File without changes
|
data/data_utils.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
from typing import Dict
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def load_image(img_path):
|
| 6 |
+
img = Image.open(img_path).convert('RGB')
|
| 7 |
+
return img
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class LabelTransform:
|
| 11 |
+
def __init__(self, mapping_dict: Dict):
|
| 12 |
+
self.mapping_dict = mapping_dict
|
| 13 |
+
self.mapping_dict = {k.lower(): v for k, v in mapping_dict.items()}
|
| 14 |
+
self._keys = sorted(self.mapping_dict.keys())
|
| 15 |
+
# self._keys = [item.lower() for item in self._keys]
|
| 16 |
+
|
| 17 |
+
def __call__(self, label):
|
| 18 |
+
label = label.lower()
|
| 19 |
+
assert label in self._keys, (f'label {label} not in label mapping_dict provided.'
|
| 20 |
+
f'Available keys {self._keys}')
|
| 21 |
+
return self.mapping_dict[label]
|
| 22 |
+
|
| 23 |
+
|
data/dataloader.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Union
|
| 2 |
+
from torch.utils.data import Dataset
|
| 3 |
+
from torch.utils.data import DataLoader
|
| 4 |
+
|
| 5 |
+
from utils.omega_parser import DataLoading
|
| 6 |
+
from data.dataset import EPUDataset
|
| 7 |
+
from data.loading import EPUDatasetFromConfig
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def to_dataloader(dataset: Union[Dataset, EPUDataset, EPUDatasetFromConfig],
|
| 11 |
+
loading_cfg: DataLoading) -> DataLoader:
|
| 12 |
+
|
| 13 |
+
return DataLoader(dataset,
|
| 14 |
+
batch_size=loading_cfg.batch_size,
|
| 15 |
+
shuffle=loading_cfg.shuffle,
|
| 16 |
+
num_workers=loading_cfg.num_workers,
|
| 17 |
+
pin_memory=loading_cfg.pin_memory,
|
| 18 |
+
persistent_workers=loading_cfg.persistent_workers,
|
| 19 |
+
drop_last=False
|
| 20 |
+
)
|
data/dataset.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from torch.utils.data import Dataset
|
| 2 |
+
|
| 3 |
+
from .data_utils import load_image
|
| 4 |
+
|
| 5 |
+
from .parsers import BaseParser
|
| 6 |
+
from .data_utils import LabelTransform
|
| 7 |
+
from .perceptual_transforms import PerceptualFeatureMapTransform
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class EPUDataset(Dataset):
|
| 11 |
+
def __init__(self,
|
| 12 |
+
data_parser: BaseParser,
|
| 13 |
+
perceptual_transform: PerceptualFeatureMapTransform = None,
|
| 14 |
+
label_transform: LabelTransform = None,
|
| 15 |
+
**kwargs):
|
| 16 |
+
|
| 17 |
+
self.data_parser = data_parser
|
| 18 |
+
self.transform = perceptual_transform
|
| 19 |
+
self.label_transform = label_transform
|
| 20 |
+
self.kwargs = kwargs
|
| 21 |
+
|
| 22 |
+
self.image_paths = self.data_parser.image_filenames
|
| 23 |
+
self.labels = self.data_parser.labels
|
| 24 |
+
|
| 25 |
+
def __len__(self):
|
| 26 |
+
assert len(self.image_paths) == len(self.labels), "Mismatch in image paths and labels"
|
| 27 |
+
return len(self.image_paths)
|
| 28 |
+
|
| 29 |
+
def __getitem__(self, idx):
|
| 30 |
+
img_path = self.image_paths[idx]
|
| 31 |
+
label = self.labels[idx]
|
| 32 |
+
|
| 33 |
+
img = load_image(img_path)
|
| 34 |
+
if self.transform is not None:
|
| 35 |
+
img = self.transform(img)
|
| 36 |
+
if self.label_transform is not None:
|
| 37 |
+
label = self.label_transform(label)
|
| 38 |
+
return img, label
|
data/loading.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# from torch.utils.data import Dataset
|
| 2 |
+
from typing import Union
|
| 3 |
+
|
| 4 |
+
from data.data_utils import LabelTransform
|
| 5 |
+
from utils.omega_parser import DataParams
|
| 6 |
+
from data.parsers import set_parser_class
|
| 7 |
+
from data.perceptual_transforms import PerceptualFeatureMapTransform
|
| 8 |
+
from data.dataset import EPUDataset
|
| 9 |
+
from utils.config_utils import data_cfg_to_dataparser
|
| 10 |
+
from ius.ius_eval_parser import IUSEvalParser
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# Creates an EPUDataset from IUS config.
|
| 14 |
+
# Used in EPUCNN training, classification performance eval & calculation of cb vectors
|
| 15 |
+
class EPUDatasetFromConfig:
|
| 16 |
+
def __init__(self, dataconfig: DataParams, **kwargs):
|
| 17 |
+
|
| 18 |
+
self.dataset_path = dataconfig.dataset_path
|
| 19 |
+
self.images_extension = dataconfig.images_extension
|
| 20 |
+
|
| 21 |
+
self.data_preprocessing = dataconfig.data_preprocessing
|
| 22 |
+
# self.data_loading = dataconfig.data_loading
|
| 23 |
+
|
| 24 |
+
self.group_by = kwargs.get('group_by')
|
| 25 |
+
|
| 26 |
+
def get_dataset(self, dataset_mode: str) -> EPUDataset:
|
| 27 |
+
assert dataset_mode in ["train", "validation", "test"], "Dataset mode must be either train or val or test."
|
| 28 |
+
|
| 29 |
+
# Create parser & transforms for Dataset
|
| 30 |
+
parser = set_parser_class(
|
| 31 |
+
name=self.data_preprocessing.data_parser)(
|
| 32 |
+
**data_cfg_to_dataparser(
|
| 33 |
+
dataset_path=self.dataset_path,
|
| 34 |
+
images_extension=self.images_extension,
|
| 35 |
+
data_mode=dataset_mode,
|
| 36 |
+
preprocessing_cfg=self.data_preprocessing,
|
| 37 |
+
group_by=self.group_by,
|
| 38 |
+
)
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
perceptual_transform = PerceptualFeatureMapTransform(
|
| 42 |
+
resize_dims=self.data_preprocessing.resize_dims,
|
| 43 |
+
resize_mode="bicubic",
|
| 44 |
+
data_mode=self.data_preprocessing.data_mode
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
label_transform = LabelTransform(
|
| 48 |
+
mapping_dict=self.data_preprocessing.label_mapping
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Create Dataset
|
| 52 |
+
dataset = EPUDataset(
|
| 53 |
+
data_parser=parser,
|
| 54 |
+
perceptual_transform=perceptual_transform,
|
| 55 |
+
label_transform=label_transform
|
| 56 |
+
)
|
| 57 |
+
return dataset
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# used during IUS evaluation
|
| 61 |
+
class IUSEvalDataset:
|
| 62 |
+
def __init__(self, dataconfig: DataParams, **kwargs):
|
| 63 |
+
|
| 64 |
+
self.dataset_path = dataconfig.dataset_path
|
| 65 |
+
self.images_extension = dataconfig.images_extension
|
| 66 |
+
|
| 67 |
+
self.data_preprocessing = dataconfig.data_preprocessing
|
| 68 |
+
# self.data_loading = dataconfig.data_loading
|
| 69 |
+
|
| 70 |
+
self.group_by = kwargs.get('group_by')
|
| 71 |
+
|
| 72 |
+
def get_dataset(self, parser: Union[IUSEvalParser]) -> EPUDataset:
|
| 73 |
+
|
| 74 |
+
perceptual_transform = PerceptualFeatureMapTransform(
|
| 75 |
+
resize_dims=self.data_preprocessing.resize_dims,
|
| 76 |
+
resize_mode="bicubic",
|
| 77 |
+
data_mode=self.data_preprocessing.data_mode
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Create Dataset
|
| 81 |
+
dataset = EPUDataset(
|
| 82 |
+
data_parser=parser,
|
| 83 |
+
perceptual_transform=perceptual_transform,
|
| 84 |
+
label_transform=None
|
| 85 |
+
)
|
| 86 |
+
return dataset
|
data/parsers.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import os.path
|
| 3 |
+
import pandas as pd
|
| 4 |
+
|
| 5 |
+
from glob import glob
|
| 6 |
+
from typing import List, Dict
|
| 7 |
+
from abc import ABC, abstractmethod
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
PARSER_REGISTRY = {
|
| 11 |
+
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def register_parser(name: str):
|
| 16 |
+
def decorator(cls):
|
| 17 |
+
key = name.lower()
|
| 18 |
+
if key in PARSER_REGISTRY:
|
| 19 |
+
raise ValueError(f'Parser name {key} exists already')
|
| 20 |
+
PARSER_REGISTRY[key] = cls
|
| 21 |
+
return cls
|
| 22 |
+
return decorator
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class BaseParser:
|
| 26 |
+
def __init__(self,
|
| 27 |
+
dataset_folder: str, # e.g datasets/dataset_name/
|
| 28 |
+
mode: str,
|
| 29 |
+
image_ext: str = 'jpg',
|
| 30 |
+
**kwargs
|
| 31 |
+
):
|
| 32 |
+
|
| 33 |
+
self._dataset_folder = dataset_folder
|
| 34 |
+
self._image_ext = image_ext
|
| 35 |
+
|
| 36 |
+
assert mode in ['train', 'validation', 'test'], "Mode must be one of 'train', 'val', 'test'"
|
| 37 |
+
self._mode = mode
|
| 38 |
+
# self._kwargs = kwargs
|
| 39 |
+
|
| 40 |
+
# For storing (x, y) items
|
| 41 |
+
self._img_filenames = [] # filenames / item x in dataloader
|
| 42 |
+
self._labels = [] # labels / item y in dataloader
|
| 43 |
+
|
| 44 |
+
# Methods
|
| 45 |
+
self.parse_dataset_folder()
|
| 46 |
+
|
| 47 |
+
# Validity check
|
| 48 |
+
assert len(self._img_filenames) == len(self._labels), \
|
| 49 |
+
(f"Mismatch in number of images ({len(self._img_filenames)}) and labels ({len(self._labels)}) found "
|
| 50 |
+
f"in folder {self._dataset_folder}")
|
| 51 |
+
|
| 52 |
+
self._group_by_key = kwargs.get("group_by_key", None)
|
| 53 |
+
self._group_by_value = kwargs.get("group_by_value", None)
|
| 54 |
+
self._label_mapping = kwargs.get("label_mapping", None)
|
| 55 |
+
self.group_dataset_by()
|
| 56 |
+
|
| 57 |
+
@abstractmethod
|
| 58 |
+
def parse_dataset_folder(self):
|
| 59 |
+
raise NotImplementedError("Method must be implemented in child class")
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def image_filenames(self) -> List[str]:
|
| 63 |
+
return self._img_filenames
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def labels(self) -> List[str]:
|
| 67 |
+
return self._labels
|
| 68 |
+
|
| 69 |
+
def update_image_filenames(self, filenames: List[str]) -> None:
|
| 70 |
+
self._img_filenames = filenames
|
| 71 |
+
|
| 72 |
+
def update_labels(self, labels: List[str]) -> None:
|
| 73 |
+
self._labels = labels
|
| 74 |
+
|
| 75 |
+
def group_dataset_by(self):
|
| 76 |
+
if self._group_by_key is None and self._group_by_value is None:
|
| 77 |
+
pass
|
| 78 |
+
elif self._group_by_key is not None and self._group_by_value is not None:
|
| 79 |
+
raise ValueError(
|
| 80 |
+
"Please specify either 'group_key' or 'group_value' to perform group_dataset_by, not both"
|
| 81 |
+
)
|
| 82 |
+
else:
|
| 83 |
+
if self._label_mapping is None:
|
| 84 |
+
raise ValueError(
|
| 85 |
+
"No 'label_mapping' specified for perform group_dataset_by() "
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
filtered_filenames, filtered_labels = [], []
|
| 89 |
+
|
| 90 |
+
if self._group_by_key is not None:
|
| 91 |
+
if not isinstance(self._group_by_key, list):
|
| 92 |
+
self._group_by_key = [self._group_by_key]
|
| 93 |
+
# Valid keys
|
| 94 |
+
_label_mapping_keys = self._label_mapping.keys()
|
| 95 |
+
|
| 96 |
+
# Filter (filenames & labels) if a label is in self._group_by_key and exists in label_mapping
|
| 97 |
+
for _group_key in self._group_by_key:
|
| 98 |
+
if _group_key not in _label_mapping_keys:
|
| 99 |
+
raise ValueError(
|
| 100 |
+
f"{_label_mapping_keys} does not match any available label {_group_key}"
|
| 101 |
+
)
|
| 102 |
+
# _valid_group_values = [self._label_mapping[_group_key] for _group_key in self._group_by_key]
|
| 103 |
+
for i in range(len(self.labels)):
|
| 104 |
+
if self.labels[i] in self._group_by_key:
|
| 105 |
+
filtered_filenames.append(self._img_filenames[i])
|
| 106 |
+
filtered_labels.append(self.labels[i])
|
| 107 |
+
|
| 108 |
+
elif self._group_by_value is not None:
|
| 109 |
+
_label_mapping_values = self._label_mapping.values()
|
| 110 |
+
assert self._group_by_value in _label_mapping_values, \
|
| 111 |
+
f"Label mapping dict has values {_label_mapping_values}"
|
| 112 |
+
for i in range(len(self.labels)):
|
| 113 |
+
if int(self.labels[i]) == int(self._group_by_value):
|
| 114 |
+
filtered_filenames.append(self._img_filenames[i])
|
| 115 |
+
filtered_labels.append(self.labels[i])
|
| 116 |
+
|
| 117 |
+
self.update_labels(filtered_labels)
|
| 118 |
+
self.update_image_filenames(filtered_filenames)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@register_parser('filename')
|
| 122 |
+
class FilenameParser(BaseParser):
|
| 123 |
+
"""
|
| 124 |
+
expected data structure:
|
| 125 |
+
datasets/dataset_name/mode
|
| 126 |
+
|
|
| 127 |
+
|----label_one*.image_ext
|
| 128 |
+
|----label_two*.image_ext
|
| 129 |
+
"""
|
| 130 |
+
def __init__(self,
|
| 131 |
+
dataset_folder: str, # e.g datasets/dataset_name
|
| 132 |
+
mode: str = 'train',
|
| 133 |
+
image_ext: str = 'jpg',
|
| 134 |
+
**kwargs,
|
| 135 |
+
):
|
| 136 |
+
|
| 137 |
+
self._label_mapping = kwargs.get("label_mapping", None)
|
| 138 |
+
if self._label_mapping is None:
|
| 139 |
+
raise ValueError(
|
| 140 |
+
f"No 'label_mapping' specified for data parsing in FilenameParser. "
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
self._group_by_key = kwargs.get("group_by_key", None)
|
| 144 |
+
self._group_by_value = kwargs.get("group_by_value", None)
|
| 145 |
+
if self._group_by_key is None and self._group_by_value is not None:
|
| 146 |
+
raise ValueError(
|
| 147 |
+
"Use argument 'group_key' to perform group_dataset_by() in FilenameParser "
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
super().__init__(dataset_folder=dataset_folder, # e.g dataset/train/
|
| 151 |
+
mode=mode,
|
| 152 |
+
image_ext=image_ext,
|
| 153 |
+
**kwargs)
|
| 154 |
+
|
| 155 |
+
@staticmethod
|
| 156 |
+
def extract_labels_from_file(filepath_list: List[str], label_mapping: Dict, extension: str) -> List[str]:
|
| 157 |
+
unique_labels = sorted(label_mapping.keys())
|
| 158 |
+
|
| 159 |
+
labels = []
|
| 160 |
+
for filepath in filepath_list:
|
| 161 |
+
filename = os.path.basename(filepath).lower()
|
| 162 |
+
is_match = False
|
| 163 |
+
for label_name in unique_labels:
|
| 164 |
+
# {label}*.{ext} or {label}.{ext}
|
| 165 |
+
pattern = rf"^{re.escape(label_name)}.*\.{re.escape(extension)}$"
|
| 166 |
+
if re.match(pattern, filename):
|
| 167 |
+
label = label_name # label = label_mapping[label_name] moved to Label Transform
|
| 168 |
+
labels.append(label)
|
| 169 |
+
is_match = True
|
| 170 |
+
break
|
| 171 |
+
if not is_match:
|
| 172 |
+
raise ValueError(
|
| 173 |
+
f"Filename {filepath} does not match any available label {label_mapping}"
|
| 174 |
+
)
|
| 175 |
+
return labels
|
| 176 |
+
|
| 177 |
+
def parse_dataset_folder(self) -> None:
|
| 178 |
+
self._img_filenames = sorted(
|
| 179 |
+
glob(os.path.join(self._dataset_folder, self._mode, '*.{}'.format(self._image_ext)))
|
| 180 |
+
)
|
| 181 |
+
self._labels = self.extract_labels_from_file(filepath_list=self._img_filenames,
|
| 182 |
+
label_mapping=self._label_mapping,
|
| 183 |
+
extension=self._image_ext)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@register_parser("folder")
|
| 187 |
+
class FolderParser(BaseParser):
|
| 188 |
+
"""
|
| 189 |
+
expected data structure:
|
| 190 |
+
datasets/dataset_name/mode
|
| 191 |
+
|----label_one
|
| 192 |
+
|----filename_one.image_ext
|
| 193 |
+
|----filename_two.image_ext
|
| 194 |
+
|----label_two
|
| 195 |
+
|----filename_three.image_ext
|
| 196 |
+
|----filename_four.image_ext
|
| 197 |
+
"""
|
| 198 |
+
def __init__(self,
|
| 199 |
+
dataset_folder: str, # e.g datasets/dataset_name/
|
| 200 |
+
mode: str = 'train',
|
| 201 |
+
image_ext: str = 'jpg',
|
| 202 |
+
**kwargs,
|
| 203 |
+
):
|
| 204 |
+
|
| 205 |
+
self._label_mapping = kwargs.get("label_mapping", None)
|
| 206 |
+
if self._label_mapping is None:
|
| 207 |
+
raise ValueError(
|
| 208 |
+
f"No 'label_mapping' specified for data parsing in FolderParser. "
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
self._group_by_key = kwargs.get("group_by_key", None)
|
| 212 |
+
self._group_by_value = kwargs.get("group_by_value", None)
|
| 213 |
+
if self._group_by_key is None and self._group_by_value is not None:
|
| 214 |
+
raise ValueError(
|
| 215 |
+
"Use argument 'group_by_key' to perform group_dataset_by() in FolderParser "
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
super().__init__(dataset_folder=dataset_folder, # e.g dataset/dataset_name/
|
| 219 |
+
mode=mode,
|
| 220 |
+
image_ext=image_ext,
|
| 221 |
+
**kwargs)
|
| 222 |
+
|
| 223 |
+
def parse_dataset_folder(self) -> None:
|
| 224 |
+
unique_labels = sorted(self._label_mapping.keys())
|
| 225 |
+
unique_folders = sorted(os.listdir(os.path.join(self._dataset_folder, self._mode)))
|
| 226 |
+
# unique_folders = [item.lower() for item in unique_folders]
|
| 227 |
+
if set(unique_labels) != set(unique_folders):
|
| 228 |
+
raise ValueError(
|
| 229 |
+
f"Mismatch between label_mapping and folders in {os.path.join(self._dataset_folder, self._mode)}\n"
|
| 230 |
+
f"Folders found: {unique_folders}\n"
|
| 231 |
+
f"Labels found: {unique_labels}"
|
| 232 |
+
)
|
| 233 |
+
for category_folder in unique_folders:
|
| 234 |
+
files = glob(os.path.join(self._dataset_folder, self._mode, category_folder, '*.{}'.format(self._image_ext)))
|
| 235 |
+
self._img_filenames.extend(files)
|
| 236 |
+
labels = [category_folder] * len(files)
|
| 237 |
+
self._labels.extend(labels)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@register_parser("medmnist")
|
| 241 |
+
class MedMNISTParser(BaseParser):
|
| 242 |
+
"""
|
| 243 |
+
expected data structure:
|
| 244 |
+
datasets/dataset_name/mode
|
| 245 |
+
|
|
| 246 |
+
|----filename_one.image_ext
|
| 247 |
+
|----filename_two.image_ext
|
| 248 |
+
|----filename_three.image_ext
|
| 249 |
+
|----filename_four.image_ext
|
| 250 |
+
"""
|
| 251 |
+
def __init__(self,
|
| 252 |
+
dataset_folder: str, # e.g datasets/dataset_name/
|
| 253 |
+
mode: str = 'train',
|
| 254 |
+
image_ext: str = 'jpg',
|
| 255 |
+
**kwargs,
|
| 256 |
+
):
|
| 257 |
+
|
| 258 |
+
self._csv_file = kwargs.pop("csv_file", None)
|
| 259 |
+
if self._csv_file is None:
|
| 260 |
+
raise ValueError(
|
| 261 |
+
f"No 'csv_file' specified for data parsing in CSVFileParser. "
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
self._group_by_key = kwargs.get("group_by_key", None)
|
| 265 |
+
self._group_by_value = kwargs.get("group_by_value", None)
|
| 266 |
+
if self._group_by_key is not None and self._group_by_value is None:
|
| 267 |
+
raise ValueError(
|
| 268 |
+
"Use argument 'group_by_value' to perform group_dataset_by() in MedMNISTParser "
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
super().__init__(dataset_folder=dataset_folder, # e.g dataset/train/
|
| 272 |
+
mode=mode,
|
| 273 |
+
image_ext=image_ext,
|
| 274 |
+
**kwargs)
|
| 275 |
+
|
| 276 |
+
@staticmethod
|
| 277 |
+
def read_from_medmnist_csv_file(csv_file: str, mode: str, retrieve_info: str) -> List[str]:
|
| 278 |
+
df = pd.read_csv(csv_file)
|
| 279 |
+
df.columns = ['split', 'filename', 'label']
|
| 280 |
+
df = df[df['split'] == mode.upper()]
|
| 281 |
+
return df[retrieve_info].tolist()
|
| 282 |
+
|
| 283 |
+
def parse_dataset_folder(self) -> None:
|
| 284 |
+
filenames = self.read_from_medmnist_csv_file(csv_file=self._csv_file,
|
| 285 |
+
mode=self._mode,
|
| 286 |
+
retrieve_info='filename')
|
| 287 |
+
filenames = [os.path.join(self._dataset_folder, file) for file in filenames]
|
| 288 |
+
self._img_filenames = filenames
|
| 289 |
+
|
| 290 |
+
self._labels = self.read_from_medmnist_csv_file(csv_file=self._csv_file,
|
| 291 |
+
mode=self._mode,
|
| 292 |
+
retrieve_info='label')
|
| 293 |
+
|
| 294 |
+
def set_parser_class(name):
|
| 295 |
+
name = name.lower()
|
| 296 |
+
if name not in PARSER_REGISTRY:
|
| 297 |
+
raise ValueError(
|
| 298 |
+
"Unrecognized parser class name."
|
| 299 |
+
f"Please use one of the following: {PARSER_REGISTRY.keys()}"
|
| 300 |
+
)
|
| 301 |
+
return PARSER_REGISTRY[name]
|
data/perceptual_transforms.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import numpy as np
|
| 3 |
+
import torch.nn.functional as F
|
| 4 |
+
|
| 5 |
+
import PIL.Image as Image
|
| 6 |
+
import kornia.color as Kcolor
|
| 7 |
+
import kornia.filters as Kfilters
|
| 8 |
+
|
| 9 |
+
from typing import Tuple
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def to_chw_tensor(img: Image) -> torch.Tensor:
|
| 13 |
+
if not isinstance(img, torch.Tensor):
|
| 14 |
+
img = torch.tensor(np.array(img), dtype=torch.float32)
|
| 15 |
+
# img = img.float()
|
| 16 |
+
|
| 17 |
+
# Image.mode('L')
|
| 18 |
+
if img.ndim not in (2, 3):
|
| 19 |
+
raise ValueError(f'Image shape {img.shape} is not supported')
|
| 20 |
+
elif img.ndim == 2:
|
| 21 |
+
img = img.unsqueeze(0) # [1, h, w]
|
| 22 |
+
elif img.ndim == 3:
|
| 23 |
+
img = img.permute(2, 0, 1) # [c, h, w]
|
| 24 |
+
|
| 25 |
+
if img.shape[0] not in (1, 3):
|
| 26 |
+
raise ValueError(f'Image shape {img.shape} is not supported')
|
| 27 |
+
return img
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def resize_chw_tensor(img:torch.Tensor, resize_dims: Tuple, mode: str = "bicubic") -> torch.Tensor:
|
| 31 |
+
img = img.unsqueeze(0) # [1, c, h, w]
|
| 32 |
+
img = F.interpolate(img, size=resize_dims, mode=mode)
|
| 33 |
+
return img.squeeze(0) # [c, h, w]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def min_max_normalize(img:torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
|
| 37 |
+
min_value = img.amin(dim=(-2, -1), keepdim=True)
|
| 38 |
+
max_value = img.amax(dim=(-2, -1), keepdim=True)
|
| 39 |
+
return (img - min_value) / (max_value - min_value + eps)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def grayscale_perceptual_features(image: Image,
|
| 43 |
+
resize_dims: Tuple[int, int],
|
| 44 |
+
resize_mode: str = "bicubic"
|
| 45 |
+
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 46 |
+
"""
|
| 47 |
+
PFM feature extraction used in IUS measure
|
| 48 |
+
:param image: Grayscale input image for decomposition
|
| 49 |
+
:param resize_dims: spatial dims (h,w) for feature decomposition - output resolution
|
| 50 |
+
:param resize_mode: method applied for resizing
|
| 51 |
+
:return: C-F, L-D, BAND-I, BAND-II Perceptual Feature Components
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
image = to_chw_tensor(image) # [c, h, w]
|
| 55 |
+
if image.shape[0] == 3:
|
| 56 |
+
image = Kcolor.rgb_to_grayscale(image.unsqueeze(0))
|
| 57 |
+
image = image.squeeze(0) # [c, h, w]
|
| 58 |
+
|
| 59 |
+
image = resize_chw_tensor(image, resize_dims=resize_dims, mode=resize_mode)
|
| 60 |
+
image = min_max_normalize(image)
|
| 61 |
+
|
| 62 |
+
# coarse - fine
|
| 63 |
+
coarse_fine = Kfilters.sobel(image.unsqueeze(0))
|
| 64 |
+
coarse_fine = coarse_fine.squeeze(0)
|
| 65 |
+
coarse_fine = min_max_normalize(coarse_fine)
|
| 66 |
+
|
| 67 |
+
# light - dark
|
| 68 |
+
light_dark = Kfilters.gaussian_blur2d(image.unsqueeze(0),
|
| 69 |
+
kernel_size=(7, 7),
|
| 70 |
+
sigma=(3.0, 3.0),
|
| 71 |
+
border_type="reflect")
|
| 72 |
+
light_dark = light_dark.squeeze(0)
|
| 73 |
+
light_dark = min_max_normalize(light_dark)
|
| 74 |
+
|
| 75 |
+
# band-I & band-II
|
| 76 |
+
band_one_mask = ((image >= 0.0) & (image < 0.3)).float()
|
| 77 |
+
band_two_mask = ((image >= 0.3) & (image < 0.6)).float()
|
| 78 |
+
# band_three_mask = ((image >= 0.6) & (image < 0.9)).float()
|
| 79 |
+
|
| 80 |
+
band_one = image * band_one_mask
|
| 81 |
+
band_two = image * band_two_mask
|
| 82 |
+
|
| 83 |
+
return coarse_fine, light_dark, band_one, band_two
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def rgb_perceptual_features(image: Image,
|
| 87 |
+
resize_dims: Tuple[int, int],
|
| 88 |
+
resize_mode: str = "bicubic"
|
| 89 |
+
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 90 |
+
"""
|
| 91 |
+
:param image: RGB input image for decomposition
|
| 92 |
+
:param resize_mode: method applied for resizing
|
| 93 |
+
:param resize_dims: spatial dims (h,w) for feature decomposition - output resolution
|
| 94 |
+
:return: R-G, B-Y, C-F, L-D Perceptual Feature Components
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
image = to_chw_tensor(image) # [c, h, w]
|
| 98 |
+
if image.shape[0] == 1:
|
| 99 |
+
image = image.repeat(3, 1, 1)
|
| 100 |
+
|
| 101 |
+
image = resize_chw_tensor(image, resize_dims=resize_dims, mode=resize_mode)
|
| 102 |
+
image = min_max_normalize(image)
|
| 103 |
+
|
| 104 |
+
# red-green & blue-yellow
|
| 105 |
+
lab_space_repr = Kcolor.rgb_to_lab(image.unsqueeze(0)) # [1, 3, h, w]
|
| 106 |
+
red_green = lab_space_repr[0, 1:2, :, :] # a-channel
|
| 107 |
+
blue_yellow = lab_space_repr[0, 2:3, :, :] # b-channel
|
| 108 |
+
|
| 109 |
+
red_green = min_max_normalize(red_green)
|
| 110 |
+
blue_yellow = min_max_normalize(blue_yellow)
|
| 111 |
+
|
| 112 |
+
# coarse - fine
|
| 113 |
+
grayscale = Kcolor.rgb_to_grayscale(image.unsqueeze(0)) # [1, c, h, w]
|
| 114 |
+
coarse_fine = Kfilters.sobel(grayscale)
|
| 115 |
+
coarse_fine = coarse_fine.squeeze(0)
|
| 116 |
+
coarse_fine = min_max_normalize(coarse_fine)
|
| 117 |
+
|
| 118 |
+
# light-dark
|
| 119 |
+
light_dark = Kfilters.gaussian_blur2d(grayscale,
|
| 120 |
+
kernel_size=(7, 7),
|
| 121 |
+
sigma=(3.0, 3.0),
|
| 122 |
+
border_type="reflect")
|
| 123 |
+
light_dark = light_dark.squeeze(0)
|
| 124 |
+
light_dark = min_max_normalize(light_dark)
|
| 125 |
+
|
| 126 |
+
return red_green, blue_yellow, coarse_fine, light_dark
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class PerceptualFeatureMapTransform:
|
| 130 |
+
def __init__(self,
|
| 131 |
+
resize_dims: Tuple[int, int],
|
| 132 |
+
resize_mode: str = "bicubic",
|
| 133 |
+
data_mode: str = "rgb",):
|
| 134 |
+
|
| 135 |
+
data_mode = data_mode.lower()
|
| 136 |
+
assert data_mode in ["rgb", "gray"], " data_mode must be either 'rgb' or 'gray'"
|
| 137 |
+
|
| 138 |
+
self.data_mode = data_mode
|
| 139 |
+
self.resize_dims = resize_dims
|
| 140 |
+
self.resize_mode = resize_mode
|
| 141 |
+
|
| 142 |
+
def __call__(self, image: Image) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 143 |
+
pfms = None
|
| 144 |
+
if self.data_mode == "rgb":
|
| 145 |
+
pfms = rgb_perceptual_features(image=image,
|
| 146 |
+
resize_dims=self.resize_dims,
|
| 147 |
+
resize_mode=self.resize_mode)
|
| 148 |
+
elif self.data_mode == "gray":
|
| 149 |
+
pfms = grayscale_perceptual_features(image=image,
|
| 150 |
+
resize_dims=self.resize_dims,
|
| 151 |
+
resize_mode=self.resize_mode)
|
| 152 |
+
|
| 153 |
+
pfms = torch.stack(pfms, dim=0) # [4, ch, h, w] . batched from dataloader [bs, 4, ch, h, w]
|
| 154 |
+
return pfms
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
if __name__ == "__main__":
|
| 158 |
+
import PIL.Image as Image
|
| 159 |
+
image = Image.open('../datasets/kvasir-capsule/test/erosion/5e59c7fdb16c4228_32325.jpg')
|
| 160 |
+
pfms = rgb_perceptual_features(image=image, resize_dims=(128, 128), resize_mode="bicubic")
|
| 161 |
+
collage = torch.cat(pfms, dim=-1).squeeze(0).cpu().numpy()
|
| 162 |
+
collage *= 255
|
| 163 |
+
Image.fromarray(collage.astype(np.uint8)).show()
|
| 164 |
+
|
datasets/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
datasets_synthetic/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
ius/__init__.py
ADDED
|
File without changes
|
ius/ius.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import torch
|
| 4 |
+
import numpy as np
|
| 5 |
+
import torch.nn.functional as F
|
| 6 |
+
|
| 7 |
+
from glob import glob
|
| 8 |
+
from typing import Optional, Union, List
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class IUS:
|
| 13 |
+
def __init__(self,
|
| 14 |
+
cb_path: str,
|
| 15 |
+
cb_tag: Optional[str] = None,
|
| 16 |
+
device: Optional[Union[str, torch.device]] = None,):
|
| 17 |
+
|
| 18 |
+
assert cb_path.endswith('.npy') or Path(cb_path).is_dir(), \
|
| 19 |
+
'cb_path should be an .npy file or a directory with .npy files '
|
| 20 |
+
|
| 21 |
+
# cb_path is an .npy file
|
| 22 |
+
if cb_path.endswith('.npy') and not Path(cb_path).is_dir():
|
| 23 |
+
self.cb_vector = np.load(cb_path)
|
| 24 |
+
|
| 25 |
+
if cb_tag is None:
|
| 26 |
+
# extract from .npy filename
|
| 27 |
+
match = re.match(r"cb_vector_(.*)\.npy", os.path.basename(cb_path))
|
| 28 |
+
assert match is not None, f"Could not extract cb_tag from filename: {cb_path}"
|
| 29 |
+
cb_tag = match.group(1)
|
| 30 |
+
|
| 31 |
+
self.cb_tag = cb_tag
|
| 32 |
+
self.cb_mapping = {str(self.cb_tag): self.cb_vector}
|
| 33 |
+
self.cb_has_value = True
|
| 34 |
+
print("cb_vector loaded !")
|
| 35 |
+
else:
|
| 36 |
+
# cb_path is a dir with .npy files
|
| 37 |
+
self._load_cb_vecs_from_path(cb_path)
|
| 38 |
+
|
| 39 |
+
self.device = device if device is not None else torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 40 |
+
|
| 41 |
+
def _load_cb_vecs_from_path(self, cb_path: str):
|
| 42 |
+
cb_files = glob(os.path.join(cb_path, 'cb_vector_*.npy'))
|
| 43 |
+
self.cb_mapping = {}
|
| 44 |
+
|
| 45 |
+
for cb_file in cb_files:
|
| 46 |
+
cb_vector = np.load(cb_file)
|
| 47 |
+
match = re.match(r"cb_vector_(.*)\.npy", os.path.basename(cb_file))
|
| 48 |
+
if match is None:
|
| 49 |
+
continue
|
| 50 |
+
tag = match.group(1)
|
| 51 |
+
self.cb_mapping[tag] = cb_vector
|
| 52 |
+
|
| 53 |
+
self.cb_vector = None
|
| 54 |
+
self.cb_tag = None
|
| 55 |
+
self.cb_has_value = False
|
| 56 |
+
|
| 57 |
+
def update_cb_vector(self, cb_vector: Union[torch.Tensor, np.ndarray]):
|
| 58 |
+
self.cb_vector = cb_vector
|
| 59 |
+
|
| 60 |
+
def update_cb_tag(self, cb_tag: str):
|
| 61 |
+
self.cb_tag = cb_tag
|
| 62 |
+
|
| 63 |
+
def update_cb_mapping(self, cb_vector: Union[torch.Tensor, np.ndarray], cb_tag: str):
|
| 64 |
+
self.cb_mapping[cb_tag] = cb_vector
|
| 65 |
+
|
| 66 |
+
def update_device(self, device: Union[str, torch.device]):
|
| 67 |
+
self.device = device
|
| 68 |
+
|
| 69 |
+
def update(self, cb_vector: Union[torch.Tensor, np.ndarray], cb_tag: str):
|
| 70 |
+
self.update_cb_vector(cb_vector)
|
| 71 |
+
self.update_cb_tag(cb_tag)
|
| 72 |
+
self.cb_has_value = True
|
| 73 |
+
|
| 74 |
+
# add cb_vector to cb_mapping if is a new one
|
| 75 |
+
existing_keys = list(self.cb_mapping.keys())
|
| 76 |
+
if cb_tag not in existing_keys:
|
| 77 |
+
self.update_cb_mapping(cb_vector, cb_tag)
|
| 78 |
+
|
| 79 |
+
@staticmethod
|
| 80 |
+
def _ius_score(cb_vector: torch.Tensor, c_vector: torch.Tensor) -> float:
|
| 81 |
+
ius_score = F.cosine_similarity(cb_vector, c_vector, dim=0) # cb_vec/c_vec of shape [num_subnets, fc_pred_units]
|
| 82 |
+
ius_score = ius_score.item()
|
| 83 |
+
return float(ius_score)
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
def calculate_ius_across_multiple_c(cb_vector: Union[torch.Tensor, np.ndarray],
|
| 87 |
+
c_vectors: torch.Tensor,
|
| 88 |
+
device: Union[str, torch.device]) -> List[float]:
|
| 89 |
+
ius_scores = []
|
| 90 |
+
|
| 91 |
+
if not isinstance(cb_vector, torch.Tensor):
|
| 92 |
+
cb_vector = torch.as_tensor(cb_vector) # [num_subnets, fc_pred_units]
|
| 93 |
+
# cb_vector = cb_vector.to(c_vectors.device)
|
| 94 |
+
cb_vector = cb_vector.to(device, dtype=c_vectors.dtype)
|
| 95 |
+
|
| 96 |
+
num_of_c_vecs = c_vectors.shape[0] # [fake_images, num_subnets, fc_pred_units]
|
| 97 |
+
for i in range(num_of_c_vecs):
|
| 98 |
+
c_hat_vec = c_vectors[i]
|
| 99 |
+
c_hat_vec = c_hat_vec.to(device)
|
| 100 |
+
ius_score = IUS._ius_score(cb_vector, c_hat_vec)
|
| 101 |
+
ius_scores.append(ius_score)
|
| 102 |
+
return ius_scores
|
| 103 |
+
|
| 104 |
+
def ius_measure(self, c_vectors: torch.Tensor, cb_tag: Optional[str] = None) -> List[float]:
|
| 105 |
+
# IUS init from cb_path that is a dir with multiple .npy & cb_tag is still None
|
| 106 |
+
if not self.cb_has_value:
|
| 107 |
+
assert cb_tag is not None, 'Please provide cb_tag'
|
| 108 |
+
|
| 109 |
+
existing_tags = self.cb_mapping.keys()
|
| 110 |
+
assert cb_tag in existing_tags, f'cb_tag provided is not available. Available cb_tags: {existing_tags}'
|
| 111 |
+
|
| 112 |
+
self.update_cb_tag(cb_tag)
|
| 113 |
+
cb_vector = self.cb_mapping[self.cb_tag]
|
| 114 |
+
self.update_cb_vector(cb_vector)
|
| 115 |
+
self.cb_has_value = True
|
| 116 |
+
|
| 117 |
+
# IUS init from .npy cb_path
|
| 118 |
+
print(f"Calculating IUS score for cb_tag = {self.cb_tag}")
|
| 119 |
+
ius_scores = self.calculate_ius_across_multiple_c(cb_vector=self.cb_vector,
|
| 120 |
+
c_vectors=c_vectors,
|
| 121 |
+
device=self.device)
|
| 122 |
+
return ius_scores
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
|
ius/ius_eval_parser.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import torch
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
|
| 6 |
+
from glob import glob
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import List, Union
|
| 9 |
+
|
| 10 |
+
from data.parsers import BaseParser
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
VALID_EXT = [".png", ".jpg", ".jpeg", ".tiff", ".tif",]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class IUSEvalParser(BaseParser):
|
| 17 |
+
"""
|
| 18 |
+
reads files inside a folder and creates a dummy parser (files, None)
|
| 19 |
+
labels are not required for IUS evaluation.
|
| 20 |
+
in the case where path is a single filename it creates a dummy parser (filename, None)
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
def __init__(self,
|
| 24 |
+
path: str, # e.g datasets/dataset_name
|
| 25 |
+
image_ext: str = 'jpg',
|
| 26 |
+
**kwargs,
|
| 27 |
+
):
|
| 28 |
+
|
| 29 |
+
self.single_item = False
|
| 30 |
+
self.synthetic_images_path = path
|
| 31 |
+
|
| 32 |
+
if Path(path).suffix.lower() in VALID_EXT and not Path(path).is_dir():
|
| 33 |
+
self.single_item = True
|
| 34 |
+
dataset_folder = str(Path(path).parent)
|
| 35 |
+
else:
|
| 36 |
+
dataset_folder = self.synthetic_images_path
|
| 37 |
+
|
| 38 |
+
super().__init__(dataset_folder=dataset_folder, # e.g path_to_fake_images
|
| 39 |
+
mode='test',
|
| 40 |
+
image_ext=image_ext,
|
| 41 |
+
**kwargs)
|
| 42 |
+
|
| 43 |
+
def parse_dataset_folder(self) -> None:
|
| 44 |
+
if self.single_item:
|
| 45 |
+
self._img_filenames = [self.synthetic_images_path] # [self._dataset_folder]
|
| 46 |
+
else:
|
| 47 |
+
self._img_filenames = sorted(glob(os.path.join(self.synthetic_images_path, f"*.{self._image_ext}")))
|
| 48 |
+
self._labels = [-1 for _ in range(len(self._img_filenames))] # ignored
|
| 49 |
+
|
model/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
model/__init__.py
ADDED
|
File without changes
|
model/epu.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
from torch.utils.data import DataLoader
|
| 4 |
+
|
| 5 |
+
from typing import List, Tuple, Union
|
| 6 |
+
|
| 7 |
+
from .subnetwork import Subnet
|
| 8 |
+
from .module_mapping import layer_mapping
|
| 9 |
+
from .register_modules import get_registered_model
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class BaseAdditiveNetwork(nn.Module):
|
| 13 |
+
def __init__(self,
|
| 14 |
+
subnetworks: nn.ModuleList,
|
| 15 |
+
num_classes: int,
|
| 16 |
+
activation: nn.Module
|
| 17 |
+
):
|
| 18 |
+
super(BaseAdditiveNetwork, self).__init__()
|
| 19 |
+
|
| 20 |
+
self.subnetworks = subnetworks
|
| 21 |
+
self.n_classes = num_classes
|
| 22 |
+
self.activation = activation
|
| 23 |
+
|
| 24 |
+
self.bias = nn.Parameter(torch.randn(self.n_classes), requires_grad=True)
|
| 25 |
+
|
| 26 |
+
self.interpretations = None
|
| 27 |
+
self.output = None
|
| 28 |
+
|
| 29 |
+
def forward(self, pfms: Union[Tuple[torch.Tensor], torch.Tensor], ret_raw_logits=True) -> torch.Tensor:
|
| 30 |
+
if isinstance(pfms, torch.Tensor) and pfms.ndim == 5:
|
| 31 |
+
pfms = torch.unbind(pfms, dim=1)
|
| 32 |
+
self.interpretations = [subnetwork(_x) for _x, subnetwork in zip(pfms, self.subnetworks)]
|
| 33 |
+
output = torch.sum(torch.stack(self.interpretations), dim=0) + self.bias
|
| 34 |
+
if not ret_raw_logits: # in case that ret_raw_logits = False
|
| 35 |
+
output = self.activation(output) # [bs, num_classes]
|
| 36 |
+
self.output = output
|
| 37 |
+
return output
|
| 38 |
+
|
| 39 |
+
def get_interpretations(self) -> List[torch.Tensor]:
|
| 40 |
+
return self.interpretations
|
| 41 |
+
|
| 42 |
+
def get_outputs(self) -> torch.Tensor:
|
| 43 |
+
return self.output
|
| 44 |
+
|
| 45 |
+
def get_bias(self) -> torch.Tensor:
|
| 46 |
+
return self.bias
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class EPUCNN(BaseAdditiveNetwork):
|
| 50 |
+
def __init__(self,
|
| 51 |
+
num_classes: int,
|
| 52 |
+
subnetwork_name: str,
|
| 53 |
+
num_subnetworks: int = 4,
|
| 54 |
+
epu_activation: str = "sigmoid",
|
| 55 |
+
subnet_activation: str = "tanh",
|
| 56 |
+
**kwargs,
|
| 57 |
+
):
|
| 58 |
+
|
| 59 |
+
subnet_cfg = kwargs.pop("subnet_cfg", None)
|
| 60 |
+
default_cfg = {
|
| 61 |
+
"input_channels": 3,
|
| 62 |
+
"base_channels": 64,
|
| 63 |
+
"fc_units": 32
|
| 64 |
+
}
|
| 65 |
+
if subnet_cfg is None:
|
| 66 |
+
subnet_cfg = default_cfg.copy()
|
| 67 |
+
else:
|
| 68 |
+
_unknown_key = set(subnet_cfg.keys()) - set(default_cfg.keys())
|
| 69 |
+
if _unknown_key:
|
| 70 |
+
raise ValueError(
|
| 71 |
+
f"Unused subnet_cfg key: {list(_unknown_key)}"
|
| 72 |
+
f"Expected keys: {list(default_cfg.keys())}"
|
| 73 |
+
)
|
| 74 |
+
subnet_cfg = {**default_cfg, **subnet_cfg}
|
| 75 |
+
|
| 76 |
+
subnet_class = get_registered_model(subnetwork_name)
|
| 77 |
+
subnets = nn.ModuleList(
|
| 78 |
+
[subnet_class(
|
| 79 |
+
input_channels=subnet_cfg["input_channels"],
|
| 80 |
+
base_channels=subnet_cfg["base_channels"],
|
| 81 |
+
fc_hidden_units=subnet_cfg["fc_units"],
|
| 82 |
+
fc_pred_units=num_classes,
|
| 83 |
+
pred_activation=subnet_activation,
|
| 84 |
+
) for _ in range(num_subnetworks)]
|
| 85 |
+
)
|
| 86 |
+
epu_activation = layer_mapping(epu_activation)()
|
| 87 |
+
|
| 88 |
+
super(EPUCNN, self).__init__(
|
| 89 |
+
subnetworks=subnets,
|
| 90 |
+
num_classes=num_classes,
|
| 91 |
+
activation=epu_activation,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
self.subnetworks = subnets
|
| 95 |
+
self.num_classes = num_classes
|
| 96 |
+
self.subnet_name = subnetwork_name
|
| 97 |
+
self.epu_activation = epu_activation
|
| 98 |
+
self.num_subnetworks = num_subnetworks
|
| 99 |
+
self.subnet_activation = subnet_activation
|
| 100 |
+
|
| 101 |
+
def create_baseline_feature_contribution_profile(self, data_loader: DataLoader, device: str) -> torch.Tensor:
|
| 102 |
+
# [dataloader_items_unbatched, num_subnets, num_classes]
|
| 103 |
+
profiles = self.calculate_feature_contribution_profiles(data_loader=data_loader, device=device)
|
| 104 |
+
return torch.mean(profiles, dim=0) # [num_subnets, num_classes]
|
| 105 |
+
|
| 106 |
+
def calculate_feature_contribution_profiles(self, data_loader: DataLoader, device: str) -> torch.Tensor:
|
| 107 |
+
self.eval()
|
| 108 |
+
profiles = []
|
| 109 |
+
with torch.no_grad():
|
| 110 |
+
for x, _ in data_loader:
|
| 111 |
+
x = x.to(device)
|
| 112 |
+
x_profile = self.feature_contribution_profile(x) # [batch, num_subnets, num_classes]
|
| 113 |
+
profiles.append(x_profile)
|
| 114 |
+
profiles = torch.cat(profiles, dim=0) # [dataloader_items_unbatched, num_subnets, num_classes]
|
| 115 |
+
return profiles
|
| 116 |
+
|
| 117 |
+
def feature_contribution_profile(self, pfm_tuple: Tuple[torch.Tensor]) -> torch.Tensor:
|
| 118 |
+
self.eval()
|
| 119 |
+
with torch.no_grad():
|
| 120 |
+
_ = self.forward(pfm_tuple)
|
| 121 |
+
profile = self.get_interpretations() # List of 4 (num_subnets) items each [batch, subnet_pred_units]
|
| 122 |
+
# profile = torch.stack(profile, dim=0) # [num_subnets, batch, num_classes]
|
| 123 |
+
# profile = torch.permute(profile, (1, 0, 2)) # [batch, num_subnets, num_classes]
|
| 124 |
+
profile = torch.stack(profile, dim=1) # [batch, num_subnets, num_classes]
|
| 125 |
+
return profile
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
if __name__ == '__main__':
|
| 129 |
+
cfg = {
|
| 130 |
+
"input_channels": 3,
|
| 131 |
+
"base_channels": 64,
|
| 132 |
+
"fc_units": 32
|
| 133 |
+
}
|
| 134 |
+
epu = EPUCNN(
|
| 135 |
+
num_classes=1,
|
| 136 |
+
subnetwork_name="base_one",
|
| 137 |
+
num_subnetworks=4,
|
| 138 |
+
subnet_activation="sigmoid",
|
| 139 |
+
epu_activation="tanh",
|
| 140 |
+
subnet_cfg=cfg
|
| 141 |
+
)
|
| 142 |
+
x = torch.randn(16, 4, 3, 32, 32) # [batch, num_subnets, ch, h, w]
|
| 143 |
+
y = epu(x)
|
| 144 |
+
interpretation_vec = epu.get_interpretations()
|
| 145 |
+
bias = epu.get_bias()
|
| 146 |
+
|
| 147 |
+
vector_c = epu.feature_contribution_profile(x)
|
model/module_mapping.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch.nn as nn
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def layer_mapping(layer: str) -> nn.Module:
|
| 5 |
+
layer = layer.lower()
|
| 6 |
+
|
| 7 |
+
mappings = {
|
| 8 |
+
# Normalization
|
| 9 |
+
"batch": nn.BatchNorm2d,
|
| 10 |
+
"instance": nn.InstanceNorm2d,
|
| 11 |
+
"layer": lambda c: nn.GroupNorm(1, c), # nn.LayerNorm,
|
| 12 |
+
|
| 13 |
+
# Identity
|
| 14 |
+
"none": nn.Identity,
|
| 15 |
+
"linear": nn.Identity,
|
| 16 |
+
|
| 17 |
+
# Activations
|
| 18 |
+
"relu": nn.ReLU,
|
| 19 |
+
"tanh": nn.Tanh,
|
| 20 |
+
"sigmoid": nn.Sigmoid,
|
| 21 |
+
"softmax": lambda: nn.Softmax(dim=1),
|
| 22 |
+
}
|
| 23 |
+
try:
|
| 24 |
+
return mappings[layer]
|
| 25 |
+
except KeyError as e:
|
| 26 |
+
available = list(mappings.keys())
|
| 27 |
+
raise ValueError(
|
| 28 |
+
f"{layer} not found in the existing mapping."
|
| 29 |
+
f"Existing available: {available}"
|
| 30 |
+
f"Update model.model_utils.layer_mapping()"
|
| 31 |
+
) from e
|
model/register_modules.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch.nn as nn
|
| 2 |
+
|
| 3 |
+
REGISTERED_MODELS = {}
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def register_model(name: str):
|
| 7 |
+
def decorator(model_class: nn.Module) -> nn.Module:
|
| 8 |
+
key = name.lower()
|
| 9 |
+
if key in REGISTERED_MODELS:
|
| 10 |
+
raise ValueError(
|
| 11 |
+
f'Model {name} already registered'
|
| 12 |
+
)
|
| 13 |
+
REGISTERED_MODELS[key] = model_class
|
| 14 |
+
return model_class
|
| 15 |
+
return decorator
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_registered_model(name: str) -> nn.Module:
|
| 19 |
+
key = name.lower()
|
| 20 |
+
if key not in REGISTERED_MODELS:
|
| 21 |
+
raise ValueError(
|
| 22 |
+
f'Unknown model name: {name}. '
|
| 23 |
+
f'Available models: {list(REGISTERED_MODELS.keys())}'
|
| 24 |
+
)
|
| 25 |
+
return REGISTERED_MODELS[key]
|
model/subnetwork.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
|
| 4 |
+
from .subnetwork_utils import BaseBlockConvBN, TopHead
|
| 5 |
+
from .register_modules import register_model
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class BaseSubNetwork(nn.Module):
|
| 9 |
+
def __init__(self,
|
| 10 |
+
input_channels: int,
|
| 11 |
+
base_channels: int,
|
| 12 |
+
fc_hidden_units: int,
|
| 13 |
+
fc_pred_units: int,
|
| 14 |
+
pred_activation: str,
|
| 15 |
+
):
|
| 16 |
+
super(BaseSubNetwork, self).__init__()
|
| 17 |
+
|
| 18 |
+
self.input_channels = input_channels
|
| 19 |
+
self.base_channels = base_channels
|
| 20 |
+
self.fc_hidden_units = fc_hidden_units
|
| 21 |
+
self.fc_pred_units = fc_pred_units
|
| 22 |
+
self.pred_activation = pred_activation
|
| 23 |
+
|
| 24 |
+
self.intermediate_features = None
|
| 25 |
+
|
| 26 |
+
def get_intermediate_features(self) -> torch.Tensor:
|
| 27 |
+
return self.intermediate_features
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@register_model("base_one")
|
| 31 |
+
class Subnet(BaseSubNetwork):
|
| 32 |
+
def __init__(self, input_channels=3, base_channels=32, fc_hidden_units=64, fc_pred_units=1, pred_activation="sigmoid"):
|
| 33 |
+
|
| 34 |
+
super(Subnet, self).__init__(
|
| 35 |
+
input_channels=input_channels,
|
| 36 |
+
base_channels=base_channels,
|
| 37 |
+
fc_hidden_units=fc_hidden_units,
|
| 38 |
+
fc_pred_units=fc_pred_units,
|
| 39 |
+
pred_activation=pred_activation,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
self.block_one = BaseBlockConvBN(in_ch=input_channels,
|
| 43 |
+
out_ch=base_channels,
|
| 44 |
+
conv_layers=2,
|
| 45 |
+
kernel_size=(3, 3),
|
| 46 |
+
stride=(2, 2),
|
| 47 |
+
padding=(1, 1),
|
| 48 |
+
activation="relu",
|
| 49 |
+
normalization=True,)
|
| 50 |
+
|
| 51 |
+
self.block_two = BaseBlockConvBN(in_ch=base_channels,
|
| 52 |
+
out_ch=base_channels*2,
|
| 53 |
+
conv_layers=2,
|
| 54 |
+
kernel_size=(3, 3),
|
| 55 |
+
stride=(2, 2),
|
| 56 |
+
padding=(1, 1),
|
| 57 |
+
activation="relu",
|
| 58 |
+
normalization=True,)
|
| 59 |
+
|
| 60 |
+
self.block_three = BaseBlockConvBN(in_ch=base_channels*2,
|
| 61 |
+
out_ch=base_channels*4,
|
| 62 |
+
conv_layers=3,
|
| 63 |
+
kernel_size=(3, 3),
|
| 64 |
+
stride=(2, 2),
|
| 65 |
+
padding=(1, 1),
|
| 66 |
+
activation="relu",
|
| 67 |
+
normalization=True,)
|
| 68 |
+
self.flatten = nn.Flatten()
|
| 69 |
+
|
| 70 |
+
self.head = TopHead(fc_units=fc_hidden_units,
|
| 71 |
+
num_classes=fc_pred_units,
|
| 72 |
+
hidden_layers=1,
|
| 73 |
+
dropout_rate=0.6,
|
| 74 |
+
fc_activation="relu",
|
| 75 |
+
pred_activation=pred_activation)
|
| 76 |
+
|
| 77 |
+
self.intermediate_features = None
|
| 78 |
+
|
| 79 |
+
def forward(self, x):
|
| 80 |
+
x = self.block_one(x)
|
| 81 |
+
x = self.block_two(x)
|
| 82 |
+
self.intermediate_features = self.block_two.get_block_feats()
|
| 83 |
+
x = self.block_three(x)
|
| 84 |
+
x = self.flatten(x)
|
| 85 |
+
x = self.head(x)
|
| 86 |
+
return x
|
model/subnetwork_utils.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
|
| 4 |
+
from typing import Tuple, Optional
|
| 5 |
+
|
| 6 |
+
from model.module_mapping import layer_mapping
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class BaseBlockConvBN(nn.Module):
|
| 10 |
+
def __init__(self,
|
| 11 |
+
in_ch: int,
|
| 12 |
+
out_ch: int,
|
| 13 |
+
kernel_size: Tuple[int, int] = (3, 3),
|
| 14 |
+
stride: Tuple[int, int] = (2, 2),
|
| 15 |
+
padding: Tuple[int, int] = (1, 1),
|
| 16 |
+
conv_layers: int = 2,
|
| 17 |
+
pool_layer: bool = True,
|
| 18 |
+
normalization: bool = True,
|
| 19 |
+
activation: Optional[str] = "linear",
|
| 20 |
+
# return_feats: bool = False,
|
| 21 |
+
):
|
| 22 |
+
super(BaseBlockConvBN, self).__init__()
|
| 23 |
+
|
| 24 |
+
self.conv_layers = torch.nn.ModuleList()
|
| 25 |
+
# Same params for all convolutional layers in ModuleList
|
| 26 |
+
# activation, out_channels, kh, kw, stride, padding
|
| 27 |
+
for _ in range(conv_layers):
|
| 28 |
+
self.conv_layers.append(
|
| 29 |
+
nn.Conv2d(in_channels=in_ch,
|
| 30 |
+
out_channels=out_ch,
|
| 31 |
+
kernel_size=kernel_size,
|
| 32 |
+
stride=(1, 1),
|
| 33 |
+
padding=padding,
|
| 34 |
+
bias=True)
|
| 35 |
+
)
|
| 36 |
+
in_ch = out_ch
|
| 37 |
+
self._activation = layer_mapping(activation.lower())()
|
| 38 |
+
|
| 39 |
+
self._batch_norm = nn.BatchNorm2d(out_ch) if normalization else None
|
| 40 |
+
self._max_pooling = nn.MaxPool2d(kernel_size=(2, 2), stride=stride) if pool_layer else None
|
| 41 |
+
|
| 42 |
+
self._intermediate_feats = None
|
| 43 |
+
|
| 44 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 45 |
+
for conv in self.conv_layers:
|
| 46 |
+
x = conv(x)
|
| 47 |
+
x = self._activation(x)
|
| 48 |
+
self._intermediate_feats = x
|
| 49 |
+
|
| 50 |
+
if self._max_pooling is not None:
|
| 51 |
+
x = self._max_pooling(x)
|
| 52 |
+
|
| 53 |
+
if self._batch_norm is not None:
|
| 54 |
+
x = self._batch_norm(x)
|
| 55 |
+
|
| 56 |
+
return x
|
| 57 |
+
|
| 58 |
+
def get_block_feats(self) -> torch.Tensor:
|
| 59 |
+
return self._intermediate_feats
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class TopHead(nn.Module):
|
| 63 |
+
def __init__(self,
|
| 64 |
+
# in_feats: int,
|
| 65 |
+
fc_units: int = 64,
|
| 66 |
+
num_classes: int = 1,
|
| 67 |
+
hidden_layers: int = 1,
|
| 68 |
+
fc_activation: Optional[str] = "relu",
|
| 69 |
+
pred_activation: Optional[str] = "sigmoid",
|
| 70 |
+
dropout_rate: Optional[float] = None,
|
| 71 |
+
):
|
| 72 |
+
super(TopHead, self).__init__()
|
| 73 |
+
|
| 74 |
+
self._dense_layers = nn.ModuleList()
|
| 75 |
+
|
| 76 |
+
for i in range(hidden_layers + 1):
|
| 77 |
+
if i == 0:
|
| 78 |
+
self._dense_layers.append(
|
| 79 |
+
nn.LazyLinear(out_features=fc_units, bias=True)
|
| 80 |
+
)
|
| 81 |
+
else:
|
| 82 |
+
self._dense_layers.append(
|
| 83 |
+
nn.Linear(in_features=fc_units, out_features=fc_units, bias=True)
|
| 84 |
+
)
|
| 85 |
+
self._dense_layers.append(
|
| 86 |
+
layer_mapping(fc_activation.lower())()
|
| 87 |
+
)
|
| 88 |
+
if dropout_rate is not None:
|
| 89 |
+
self._dense_layers.append(
|
| 90 |
+
nn.Dropout(dropout_rate)
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# prediction layer
|
| 94 |
+
self._dense_layers.append(
|
| 95 |
+
nn.Linear(in_features=fc_units, out_features=num_classes, bias=True),
|
| 96 |
+
)
|
| 97 |
+
self._dense_layers.append(
|
| 98 |
+
layer_mapping(pred_activation.lower())()
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 102 |
+
for layer in self._dense_layers:
|
| 103 |
+
x = layer(x)
|
| 104 |
+
return x
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
block = BaseBlockConvBN(in_ch=3, out_ch=64, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), normalization=True,
|
| 109 |
+
activation="relu", conv_layers=2)
|
| 110 |
+
dummy_input = torch.randn((1, 3, 32, 32))
|
| 111 |
+
out = block(dummy_input)
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
kornia==0.8.2
|
| 2 |
+
numpy==2.2.0
|
| 3 |
+
omegaconf==2.3.0
|
| 4 |
+
pandas==2.2.3
|
| 5 |
+
Pillow==11.0.0
|
| 6 |
+
scikit-learn==1.7.0
|
| 7 |
+
tensorboard==2.19.0
|
| 8 |
+
torch==2.6.0
|
| 9 |
+
torchvision==0.21.0
|
| 10 |
+
tqdm==4.67.1
|
| 11 |
+
|
scripts/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
scripts/eval_ius.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import torch
|
| 3 |
+
import argparse
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
# mine
|
| 11 |
+
from ius.ius import IUS
|
| 12 |
+
from ius.ius_eval_parser import IUSEvalParser
|
| 13 |
+
from data.loading import IUSEvalDataset
|
| 14 |
+
from data.dataloader import to_dataloader
|
| 15 |
+
from utils.eval_utils import EPUCNNEval
|
| 16 |
+
from utils.train_utils import create_output_folders, save_to_json
|
| 17 |
+
from utils.omega_parser import IUSConfig
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
BASE_PATH = Path(__file__).resolve().parent
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def parse_options():
|
| 24 |
+
parser = argparse.ArgumentParser()
|
| 25 |
+
parser.add_argument("--experiment_folder_name", type=str, required=True,
|
| 26 |
+
help="Folder name containing epu configuration & saved ckpt")
|
| 27 |
+
parser.add_argument("--cb_vector_tag", type=str, default=None, required=True,
|
| 28 |
+
help="cb vector data. If not specified estimates all cb_vectors")
|
| 29 |
+
parser.add_argument("--synthetic_images", type=str, default='png',
|
| 30 |
+
help="It can be either a single synthetic image file or a folder path containing multiple "
|
| 31 |
+
"synthetic images")
|
| 32 |
+
parser.add_argument("--synthetic_img_extension", type=str, default='png',
|
| 33 |
+
help="Extension of synthetic images")
|
| 34 |
+
args = parser.parse_args()
|
| 35 |
+
return args
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main():
|
| 39 |
+
args = parse_options()
|
| 40 |
+
cb_vec_tag = args.cb_vector_tag
|
| 41 |
+
synthetic_img_path = args.synthetic_images
|
| 42 |
+
synthetic_img_extension = args.synthetic_img_extension
|
| 43 |
+
experiment_folder_name = args.experiment_folder_name
|
| 44 |
+
|
| 45 |
+
# Load saved config
|
| 46 |
+
print('Loading configuration...')
|
| 47 |
+
saved_epu_folder = (BASE_PATH / "../results/checkpoints").resolve()
|
| 48 |
+
saved_epu_folder = os.path.join(saved_epu_folder, experiment_folder_name)
|
| 49 |
+
cfg_path = os.path.join(saved_epu_folder, "epu_config.yaml")
|
| 50 |
+
cfg = IUSConfig.from_yaml(cfg_path)
|
| 51 |
+
|
| 52 |
+
# Load EPU for obtaining Contribution Feature Profiles of synthetic images
|
| 53 |
+
print('Loading trained model...')
|
| 54 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 55 |
+
trained_ckpt = os.path.join(saved_epu_folder, f"ckpt_{experiment_folder_name}.pt")
|
| 56 |
+
epu = EPUCNNEval(epu_cfg=cfg.model)
|
| 57 |
+
trained_epu = epu.load_ckpt(device=device, ckpt_path=trained_ckpt)
|
| 58 |
+
|
| 59 |
+
# Load Baseline Contribution Feature Profile (cb_vector)
|
| 60 |
+
print('IUS class...')
|
| 61 |
+
saved_cb_vec_folder = (BASE_PATH / "../results/cb_vectors").resolve()
|
| 62 |
+
saved_cb_vec_folder = os.path.join(saved_cb_vec_folder, experiment_folder_name)
|
| 63 |
+
cb_vec_path = os.path.join(saved_cb_vec_folder, f"cb_vector_{cb_vec_tag}.npy")
|
| 64 |
+
ius = IUS(cb_path=cb_vec_path, cb_tag=cb_vec_tag, device=device)
|
| 65 |
+
|
| 66 |
+
# Load Synthetic Images & Estimate their Contribution Feature Profiles (c^_vectors)
|
| 67 |
+
synthetic_img_path = str((BASE_PATH / ".." / synthetic_img_path).resolve())
|
| 68 |
+
parser = IUSEvalParser(path=synthetic_img_path, image_ext=synthetic_img_extension,)
|
| 69 |
+
synthetic_dset = IUSEvalDataset(dataconfig=cfg.data_params).get_dataset(parser=parser)
|
| 70 |
+
|
| 71 |
+
cfg.data_params.data_loading.shuffle = False # update cfg.data_params.data_loading
|
| 72 |
+
cfg.data_params.data_loading.batch_size = 1
|
| 73 |
+
synthetic_dataloader = to_dataloader(dataset=synthetic_dset, loading_cfg=cfg.data_params.data_loading)
|
| 74 |
+
|
| 75 |
+
synthetic_c_vecs = trained_epu.calculate_feature_contribution_profiles(
|
| 76 |
+
data_loader=synthetic_dataloader,
|
| 77 |
+
device=device,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
ius_score_list = ius.ius_measure(c_vectors=synthetic_c_vecs)
|
| 81 |
+
synthetic_filenames = parser.image_filenames
|
| 82 |
+
|
| 83 |
+
# Setup Saving Dir
|
| 84 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 85 |
+
output_folder = str((BASE_PATH / "../results/ius_eval").resolve())
|
| 86 |
+
output_folder = os.path.join(output_folder, experiment_folder_name)
|
| 87 |
+
create_output_folders(output_folder) # skipped if exists
|
| 88 |
+
|
| 89 |
+
ius_score_pd = pd.DataFrame(
|
| 90 |
+
{
|
| 91 |
+
"filename": synthetic_filenames,
|
| 92 |
+
"ius_measure_score": ius_score_list
|
| 93 |
+
}
|
| 94 |
+
)
|
| 95 |
+
file_saved_ius_scores = os.path.join(output_folder, f"ius_scores_{timestamp}.csv")
|
| 96 |
+
ius_score_pd.to_csv(file_saved_ius_scores, index=False)
|
| 97 |
+
|
| 98 |
+
save_info_dict = {
|
| 99 |
+
"epu_ckpt": trained_ckpt,
|
| 100 |
+
"config": cfg_path,
|
| 101 |
+
"experiment_id": cfg.experiment_saved_folder_name,
|
| 102 |
+
"cb_vector_path": cb_vec_path,
|
| 103 |
+
"synthetic_images_path": synthetic_img_path,
|
| 104 |
+
"synthetic_img_extension": synthetic_img_extension,
|
| 105 |
+
"synthetic_samples_num": len(synthetic_filenames),
|
| 106 |
+
"timestamp": timestamp,
|
| 107 |
+
"ius_scores": file_saved_ius_scores,
|
| 108 |
+
}
|
| 109 |
+
json_file_path = os.path.join(output_folder, f"ius_scores_info_{timestamp}.json")
|
| 110 |
+
save_to_json(save_info_dict, json_file_path)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
if __name__ == "__main__":
|
| 114 |
+
# python - m scripts.eval_ius - -experiment_folder_name ius_dataset_name_base_one_0000_timestamp - -cb_vector_tag normal - -synthetic_images datasets_synthetic/dataset_name/normal - -synthetic_img_extension png
|
| 115 |
+
# python - m scripts.eval_ius - -experiment_folder_name ius_dataset_name_base_one_0000_timestamp - -cb_vector_tag normal - -synthetic_images datasets_synthetic/dataset_name/normal/image_0001.png - -synthetic_img_extension png
|
| 116 |
+
|
| 117 |
+
main()
|
scripts/infer_cb_vector.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import torch
|
| 3 |
+
import argparse
|
| 4 |
+
import numpy as np
|
| 5 |
+
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# mine
|
| 9 |
+
from data.loading import EPUDatasetFromConfig
|
| 10 |
+
from data.dataloader import to_dataloader
|
| 11 |
+
from utils.omega_parser import IUSConfig
|
| 12 |
+
from utils.train_utils import create_output_folders
|
| 13 |
+
from utils.eval_utils import EPUCNNEval
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
BASE_PATH = Path(__file__).resolve().parent
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def parse_options():
|
| 21 |
+
parser = argparse.ArgumentParser()
|
| 22 |
+
parser.add_argument("--experiment_folder_name", type=str, required=True,
|
| 23 |
+
help="Folder name containing epu configuration & saved ckpt "
|
| 24 |
+
"eg ius_dataset_name_base_one_0000_timestamp")
|
| 25 |
+
parser.add_argument("--cb_data", type=str, default=None,
|
| 26 |
+
help="cb vector data. If not specified estimates all cb_vectors")
|
| 27 |
+
args = parser.parse_args()
|
| 28 |
+
return args
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main():
|
| 32 |
+
args = parse_options()
|
| 33 |
+
|
| 34 |
+
# Load saved config
|
| 35 |
+
print('Loading configuration...')
|
| 36 |
+
|
| 37 |
+
saved_epu_folder = (BASE_PATH / "../results/checkpoints").resolve()
|
| 38 |
+
saved_epu_folder = os.path.join(saved_epu_folder, args.experiment_folder_name)
|
| 39 |
+
cfg_path = os.path.join(saved_epu_folder, "epu_config.yaml")
|
| 40 |
+
cfg = IUSConfig.from_yaml(cfg_path)
|
| 41 |
+
|
| 42 |
+
model_cfg, data_params = cfg.model, cfg.data_params
|
| 43 |
+
|
| 44 |
+
# Create model
|
| 45 |
+
print('Loading trained model...')
|
| 46 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 47 |
+
trained_ckpt = os.path.join(saved_epu_folder, f"ckpt_{cfg.experiment_saved_folder_name}.pt")
|
| 48 |
+
epu = EPUCNNEval(epu_cfg=cfg.model)
|
| 49 |
+
trained_epu = epu.load_ckpt(device=device, ckpt_path=trained_ckpt)
|
| 50 |
+
|
| 51 |
+
# Setup Saving Dir
|
| 52 |
+
output_folder = str((BASE_PATH / "../results/cb_vectors").resolve())
|
| 53 |
+
output_folder = os.path.join(output_folder, args.experiment_folder_name)
|
| 54 |
+
create_output_folders(output_folder) # skipped if exists
|
| 55 |
+
|
| 56 |
+
# Setup Dataset & Dataloader
|
| 57 |
+
print('Load data...')
|
| 58 |
+
|
| 59 |
+
if args.cb_data is not None:
|
| 60 |
+
group_by = [args.cb_data]
|
| 61 |
+
else:
|
| 62 |
+
group_by = data_params.data_preprocessing.label_mapping.keys()
|
| 63 |
+
|
| 64 |
+
for group_by_item in group_by:
|
| 65 |
+
data_params.dataset_path = str((BASE_PATH / data_params.dataset_path).resolve())
|
| 66 |
+
dset = EPUDatasetFromConfig(dataconfig=data_params, group_by=group_by_item)
|
| 67 |
+
# eval_dataset = dset.get_dataset(dataset_mode="test")
|
| 68 |
+
eval_dataset = dset.get_dataset(dataset_mode="validation")
|
| 69 |
+
dataloader_eval = to_dataloader(dataset=eval_dataset, loading_cfg=data_params.data_loading)
|
| 70 |
+
|
| 71 |
+
cb_vector = trained_epu.create_baseline_feature_contribution_profile(
|
| 72 |
+
data_loader=dataloader_eval,
|
| 73 |
+
device=device
|
| 74 |
+
)
|
| 75 |
+
cb_vector = cb_vector.detach().cpu().numpy()
|
| 76 |
+
cb_path = os.path.join(output_folder, f"cb_vector_{group_by_item}.npy")
|
| 77 |
+
np.save(cb_path, cb_vector)
|
| 78 |
+
print(f'cb vector saved at {cb_path}')
|
| 79 |
+
|
| 80 |
+
print('cb vector computation finished.')
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
if __name__ == "__main__":
|
| 84 |
+
# python -m scripts.infer_cb_vector --experiment_folder_name ius_dataset_name_base_one_0000_timestamp
|
| 85 |
+
main()
|
scripts/infer_epu.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import torch
|
| 3 |
+
import argparse
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
|
| 8 |
+
# mine
|
| 9 |
+
from data.loading import EPUDatasetFromConfig
|
| 10 |
+
from data.dataloader import to_dataloader
|
| 11 |
+
from model.module_mapping import layer_mapping
|
| 12 |
+
from utils.metrics import EPUMetrics
|
| 13 |
+
|
| 14 |
+
from utils.eval_utils import EPUCNNEval
|
| 15 |
+
from utils.eval_utils import InferenceRunnerEPUCNN
|
| 16 |
+
from utils.train_utils import create_output_folders, save_to_json
|
| 17 |
+
from utils.omega_parser import IUSConfig
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
BASE_PATH = Path(__file__).resolve().parent
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def parse_options():
|
| 24 |
+
parser = argparse.ArgumentParser()
|
| 25 |
+
parser.add_argument("--experiment_folder_name", type=str, required=True,
|
| 26 |
+
help="Folder name containing epu configuration & saved ckpt")
|
| 27 |
+
parser.add_argument("--data_split", type=str, default="test", choices=["train", "test", "validation"],)
|
| 28 |
+
args = parser.parse_args()
|
| 29 |
+
return args
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main():
|
| 33 |
+
args = parse_options()
|
| 34 |
+
data_split = args.data_split
|
| 35 |
+
group_by = None
|
| 36 |
+
|
| 37 |
+
# Load saved config
|
| 38 |
+
print('Loading configuration...')
|
| 39 |
+
saved_epu_folder = (BASE_PATH / "../results/checkpoints").resolve()
|
| 40 |
+
saved_epu_folder = os.path.join(saved_epu_folder, args.experiment_folder_name)
|
| 41 |
+
cfg_path = os.path.join(saved_epu_folder, "epu_config.yaml")
|
| 42 |
+
cfg = IUSConfig.from_yaml(cfg_path)
|
| 43 |
+
|
| 44 |
+
# Setup Dataset & Dataloader
|
| 45 |
+
print('Load data...')
|
| 46 |
+
dset = EPUDatasetFromConfig(dataconfig=cfg.data_params, group_by=group_by)
|
| 47 |
+
eval_dataset = dset.get_dataset(dataset_mode=data_split)
|
| 48 |
+
|
| 49 |
+
# update cfg.data_params.data_loading
|
| 50 |
+
cfg.data_params.data_loading.shuffle = False
|
| 51 |
+
cfg.data_params.data_loading.batch_size = 1
|
| 52 |
+
dataloader_eval = to_dataloader(dataset=eval_dataset, loading_cfg=cfg.data_params.data_loading)
|
| 53 |
+
|
| 54 |
+
# Setup Saving Dir
|
| 55 |
+
output_folder = str((BASE_PATH / "../results/classification_performance").resolve())
|
| 56 |
+
output_folder = os.path.join(output_folder, args.experiment_folder_name)
|
| 57 |
+
create_output_folders(output_folder) # skipped if exists
|
| 58 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 59 |
+
|
| 60 |
+
# Load model
|
| 61 |
+
print('Loading trained model...')
|
| 62 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 63 |
+
trained_ckpt = os.path.join(saved_epu_folder, f"ckpt_{cfg.experiment_saved_folder_name}.pt")
|
| 64 |
+
epu = EPUCNNEval(epu_cfg=cfg.model)
|
| 65 |
+
trained_epu = epu.load_ckpt(device=device, ckpt_path=trained_ckpt)
|
| 66 |
+
|
| 67 |
+
trained_epu = InferenceRunnerEPUCNN(epu_model=trained_epu,
|
| 68 |
+
device=device,
|
| 69 |
+
mode=cfg.train_params.mode)
|
| 70 |
+
|
| 71 |
+
# metrics = EPUMetrics(mode=cfg.train_params.mode,
|
| 72 |
+
# n_classes=cfg.model.num_classes,
|
| 73 |
+
# activation=layer_mapping(cfg.model.epu_activation)())
|
| 74 |
+
# epu_results = trained_epu.predict(dataloader=dataloader_eval,
|
| 75 |
+
# raw_logits=True, # not epu activation
|
| 76 |
+
# return_predictions=True)
|
| 77 |
+
|
| 78 |
+
epu_results = trained_epu.predict(dataloader=dataloader_eval,
|
| 79 |
+
raw_logits=False, # apply epu activation
|
| 80 |
+
return_predictions=True)
|
| 81 |
+
confidence_level = 0.5
|
| 82 |
+
metrics = EPUMetrics(mode=cfg.train_params.mode,
|
| 83 |
+
n_classes=cfg.model.num_classes,
|
| 84 |
+
confidence_level=confidence_level,
|
| 85 |
+
activation=layer_mapping("none")())
|
| 86 |
+
|
| 87 |
+
metric_scores = metrics.compute(y_true=epu_results["targets"], y_pred=epu_results["predictions"],)
|
| 88 |
+
|
| 89 |
+
save_info_dict = {
|
| 90 |
+
"epu_ckpt": trained_ckpt,
|
| 91 |
+
"config": cfg_path,
|
| 92 |
+
"data_split": data_split,
|
| 93 |
+
"batch_size": cfg.data_params.data_loading.batch_size,
|
| 94 |
+
"dataloader_samples": len(epu_results["targets"]),
|
| 95 |
+
"classification_performance": metric_scores,
|
| 96 |
+
"confidence": confidence_level,
|
| 97 |
+
"timestamp": timestamp,
|
| 98 |
+
"experiment_id": cfg.experiment_saved_folder_name,
|
| 99 |
+
}
|
| 100 |
+
json_file_path = os.path.join(output_folder, f"epu_classification_performance_{timestamp}.json")
|
| 101 |
+
save_to_json(save_info_dict, json_file_path)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
# python -m scripts.infer_epu --experiment_folder_name ius_dataset_name_base_one_0000_timestamp
|
| 106 |
+
main()
|
scripts/train_epu.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn as nn
|
| 4 |
+
import torch.optim as optim
|
| 5 |
+
import argparse
|
| 6 |
+
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
# mine
|
| 11 |
+
from utils.omega_parser import IUSConfig
|
| 12 |
+
from utils.train_utils import (create_output_folders, create_experiment_folder,
|
| 13 |
+
update_experiment_metadata, save_config_to_output_folder)
|
| 14 |
+
from utils.sanity_utils import SanityChecker
|
| 15 |
+
from utils.callbacks import setup_callbacks
|
| 16 |
+
from utils.config_utils import model_cfg_to_epucnn
|
| 17 |
+
from utils.trainer import EPUTrainer
|
| 18 |
+
from utils.metrics import EPUMetrics
|
| 19 |
+
from model.epu import EPUCNN
|
| 20 |
+
from model.module_mapping import layer_mapping
|
| 21 |
+
from data.loading import EPUDatasetFromConfig
|
| 22 |
+
from data.dataloader import to_dataloader
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
BASE_PATH = Path(__file__).resolve().parent
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def parse_options():
|
| 29 |
+
parser = argparse.ArgumentParser()
|
| 30 |
+
parser.add_argument("--config_filepath", type=str, required=True, help="Path containing configuration")
|
| 31 |
+
parser.add_argument("--tensorboard", action="store_true", help="Enable tensorboard")
|
| 32 |
+
args = parser.parse_args()
|
| 33 |
+
return args
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main():
|
| 37 |
+
args = parse_options()
|
| 38 |
+
|
| 39 |
+
print('Loading configuration...')
|
| 40 |
+
# Load configuration .yaml
|
| 41 |
+
cfg = IUSConfig.from_yaml(args.config_filepath)
|
| 42 |
+
|
| 43 |
+
# Sanity Check User's Config
|
| 44 |
+
SanityChecker(cfg).sanity_check()
|
| 45 |
+
|
| 46 |
+
# Set User's params
|
| 47 |
+
model_cfg, train_params, data_params = cfg.model, cfg.train_params, cfg.data_params
|
| 48 |
+
|
| 49 |
+
print('Setup directories...')
|
| 50 |
+
# Setup Experiment Name & Saving Directories
|
| 51 |
+
cfg.log_dir = str((BASE_PATH / cfg.log_dir).resolve())
|
| 52 |
+
cfg.checkpoint_dir = str((BASE_PATH / cfg.checkpoint_dir).resolve())
|
| 53 |
+
create_output_folders([cfg.log_dir, cfg.checkpoint_dir]) # skipped if exists
|
| 54 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 55 |
+
experiment_name = create_experiment_folder(log_root=cfg.log_dir, model=model_cfg.subnetwork_config.architecture,
|
| 56 |
+
experiment=cfg.experiment_name, timestamp=timestamp)
|
| 57 |
+
logs_folder = os.path.join(cfg.log_dir, experiment_name)
|
| 58 |
+
ckpt_folder = os.path.join(cfg.checkpoint_dir, experiment_name)
|
| 59 |
+
create_output_folders([logs_folder, ckpt_folder])
|
| 60 |
+
update_experiment_metadata(cfg, experiment_name=experiment_name, timestamp=timestamp,)
|
| 61 |
+
save_config_to_output_folder(out_folder=ckpt_folder, cfg=cfg, cfg_filename="epu_config.yaml")
|
| 62 |
+
|
| 63 |
+
# Set Device
|
| 64 |
+
print('Set device ...')
|
| 65 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 66 |
+
|
| 67 |
+
# Create model
|
| 68 |
+
print('Build detection model...')
|
| 69 |
+
epu_model = EPUCNN(**model_cfg_to_epucnn(model_cfg))
|
| 70 |
+
|
| 71 |
+
# # Setup Dataset & Dataloader
|
| 72 |
+
print('Load data...')
|
| 73 |
+
data_params.dataset_path = str((BASE_PATH / data_params.dataset_path).resolve())
|
| 74 |
+
dset = EPUDatasetFromConfig(dataconfig=data_params)
|
| 75 |
+
dataset_train = dset.get_dataset(dataset_mode="train")
|
| 76 |
+
dataset_val = dset.get_dataset(dataset_mode="validation")
|
| 77 |
+
dataloader_train = to_dataloader(dataset=dataset_train, loading_cfg=data_params.data_loading)
|
| 78 |
+
dataloader_val = to_dataloader(dataset=dataset_val, loading_cfg=data_params.data_loading)
|
| 79 |
+
|
| 80 |
+
print('Setup optimizer and callbacks ...')
|
| 81 |
+
# Setup callbacks loss & optimizer & metrics
|
| 82 |
+
calls = setup_callbacks(ckpt_path=os.path.join(ckpt_folder, f"ckpt_{experiment_name}.pt"),
|
| 83 |
+
log_dir=logs_folder,
|
| 84 |
+
early_patience=train_params.early_stopping_patience,
|
| 85 |
+
early_mode=train_params.early_stopping_mode,
|
| 86 |
+
early_monitor=train_params.early_stopping_monitor,
|
| 87 |
+
use_tensorboard=args.tensorboard,
|
| 88 |
+
) # other kwargs to pass, override defaults:
|
| 89 |
+
# delta=0, verbose=True, restore_best_weights=False,save_final_model=True)
|
| 90 |
+
# log_histograms=False, tb_port=6006, tb_browser=False
|
| 91 |
+
|
| 92 |
+
loss_fun = nn.BCEWithLogitsLoss() if train_params.mode == "binary" else nn.CrossEntropyLoss()
|
| 93 |
+
optimizer = optim.SGD(params=epu_model.parameters(),
|
| 94 |
+
lr=train_params.learning_rate,
|
| 95 |
+
momentum=train_params.momentum,
|
| 96 |
+
weight_decay=train_params.weight_decay,)
|
| 97 |
+
metrics = EPUMetrics(mode=train_params.mode,
|
| 98 |
+
n_classes=model_cfg.num_classes,
|
| 99 |
+
activation=layer_mapping(model_cfg.epu_activation)()
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# # launch training
|
| 103 |
+
print('Start training...')
|
| 104 |
+
trainer = EPUTrainer(model=epu_model,
|
| 105 |
+
device=device,
|
| 106 |
+
optimizer=optimizer,
|
| 107 |
+
criterion=loss_fun,
|
| 108 |
+
epochs=train_params.epochs,
|
| 109 |
+
train_loader=dataloader_train,
|
| 110 |
+
val_loader=dataloader_val,
|
| 111 |
+
callbacks=calls,
|
| 112 |
+
metrics=metrics,
|
| 113 |
+
checkpoint_dir=ckpt_folder,
|
| 114 |
+
)
|
| 115 |
+
trainer.train()
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
# python -m scripts.train_epu --config_filepath configs/train_config.yaml
|
| 120 |
+
main()
|
utils/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
utils/callbacks.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
|
| 3 |
+
from utils.early_stopping import EarlyStoppingCallback
|
| 4 |
+
from utils.tensorboard import TensorboardLoggerCallback
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def setup_callbacks(ckpt_path: str,
|
| 8 |
+
log_dir: str,
|
| 9 |
+
early_patience: int,
|
| 10 |
+
early_monitor: str = "val_loss",
|
| 11 |
+
early_mode: str = "min",
|
| 12 |
+
use_tensorboard: bool = False,
|
| 13 |
+
**kwargs) -> List[object]:
|
| 14 |
+
|
| 15 |
+
delta = kwargs.get("delta", 0)
|
| 16 |
+
verbose = kwargs.get("verbose", True)
|
| 17 |
+
restore_best_weights = kwargs.get("restore_best_weights", False)
|
| 18 |
+
save_final_model = kwargs.get("save_final_model", True)
|
| 19 |
+
|
| 20 |
+
es_call = EarlyStoppingCallback(patience=early_patience,
|
| 21 |
+
delta=delta,
|
| 22 |
+
checkpoint_path=ckpt_path,
|
| 23 |
+
verbose=verbose,
|
| 24 |
+
restore_best_weights=restore_best_weights,
|
| 25 |
+
monitor=early_monitor,
|
| 26 |
+
mode=early_mode,
|
| 27 |
+
save_final_model=save_final_model
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
log_histograms = kwargs.get("log_histograms", False)
|
| 31 |
+
log_images_every = kwargs.get("log_images_every", 10)
|
| 32 |
+
tb_port = kwargs.get("tb_port", 6006)
|
| 33 |
+
tb_browser = kwargs.get("tb_browser", False)
|
| 34 |
+
|
| 35 |
+
tb_logger = TensorboardLoggerCallback(log_dir=log_dir,
|
| 36 |
+
log_histograms=log_histograms,
|
| 37 |
+
launch_tb=use_tensorboard,
|
| 38 |
+
tb_port=tb_port,
|
| 39 |
+
open_tb_in_browser=tb_browser
|
| 40 |
+
)
|
| 41 |
+
return [es_call, tb_logger]
|
utils/config_utils.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from utils.omega_parser import EPUCNNParams
|
| 2 |
+
from utils.omega_parser import DataParams, DataPreprocessing
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def model_cfg_to_epucnn(model_cfg: EPUCNNParams):
|
| 6 |
+
return {
|
| 7 |
+
"num_classes": model_cfg.num_classes,
|
| 8 |
+
"subnetwork_name": model_cfg.subnetwork_config.architecture,
|
| 9 |
+
"num_subnetworks": model_cfg.num_subnetworks,
|
| 10 |
+
"epu_activation": model_cfg.epu_activation,
|
| 11 |
+
"subnet_activation": model_cfg.subnetwork_config.pred_activation,
|
| 12 |
+
"subnet_cfg": {
|
| 13 |
+
"input_channels": model_cfg.subnetwork_config.input_channels,
|
| 14 |
+
"base_channels": model_cfg.subnetwork_config.base_channels,
|
| 15 |
+
"fc_units": model_cfg.subnetwork_config.fc_hidden_units,
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def data_cfg_to_dataparser(dataset_path: str,
|
| 21 |
+
images_extension: str,
|
| 22 |
+
data_mode: str,
|
| 23 |
+
preprocessing_cfg: DataPreprocessing,
|
| 24 |
+
group_by: str = None,):
|
| 25 |
+
|
| 26 |
+
group_by_key = None
|
| 27 |
+
group_by_value = None
|
| 28 |
+
if group_by is not None:
|
| 29 |
+
if preprocessing_cfg.data_parser in ["filename", "folder"]:
|
| 30 |
+
group_by_key = group_by # eg "normal"
|
| 31 |
+
elif preprocessing_cfg.data_parser in ["medmnist"]:
|
| 32 |
+
group_by_value = preprocessing_cfg.label_mapping.get(group_by) # eg "0" from item ("normal": "0")
|
| 33 |
+
|
| 34 |
+
return {
|
| 35 |
+
"dataset_folder": dataset_path,
|
| 36 |
+
"mode": data_mode,
|
| 37 |
+
"image_ext": images_extension,
|
| 38 |
+
"label_mapping": preprocessing_cfg.label_mapping,
|
| 39 |
+
"csv_file": preprocessing_cfg.medmnist_csv_file,
|
| 40 |
+
"group_by_key": group_by_key,
|
| 41 |
+
"group_by_value": group_by_value
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# def data_cfg_to_dataparser(data_cfg: DataParams, data_mode: str = 'train'):
|
| 46 |
+
# return {
|
| 47 |
+
# "dataset_folder": data_cfg.dataset_path,
|
| 48 |
+
# "mode": data_mode,
|
| 49 |
+
# "image_ext": data_cfg.images_extension,
|
| 50 |
+
# "kwargs": {
|
| 51 |
+
# "label_mapping": data_cfg.data_preprocessing.label_mapping,
|
| 52 |
+
# "csv_file": data_cfg.data_preprocessing.medmnist_csv_file
|
| 53 |
+
# }
|
| 54 |
+
# }
|
utils/early_stopping.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class EarlyStopping(object):
|
| 6 |
+
def __init__(self, patience: int = 10, delta: float = 0, checkpoint_path: str = 'checkpoint.pt',
|
| 7 |
+
verbose: bool = True, restore_best_weights: bool = True, monitor: str = 'val_loss',
|
| 8 |
+
mode: str = 'min'):
|
| 9 |
+
"""
|
| 10 |
+
Args:
|
| 11 |
+
patience (int): How many epochs to wait after last improvement before stopping.
|
| 12 |
+
delta (float): Minimum change to qualify as an improvement.
|
| 13 |
+
checkpoint_path (str): File path to save the best model.
|
| 14 |
+
verbose (bool): If True, prints messages when improvement occurs.
|
| 15 |
+
restore_best_weights (bool): If True, loads the best weights saved during training.
|
| 16 |
+
monitor (str): Metric name to monitor (e.g., 'val_loss', 'val_f1', 'val_accuracy').
|
| 17 |
+
mode (str): 'min' if lower is better (e.g. loss), 'max' if higher is better (e.g. accuracy, f1).
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
self.patience = patience
|
| 21 |
+
self.delta = delta
|
| 22 |
+
self.checkpoint_path = checkpoint_path
|
| 23 |
+
self.verbose = verbose
|
| 24 |
+
self.restore_best_weights_flag = restore_best_weights
|
| 25 |
+
self.monitor = monitor
|
| 26 |
+
self.mode = mode
|
| 27 |
+
|
| 28 |
+
if self.mode not in ['min', 'max']:
|
| 29 |
+
raise ValueError("mode must be 'min' or 'max'")
|
| 30 |
+
|
| 31 |
+
self.counter = 0
|
| 32 |
+
self.best_score = None
|
| 33 |
+
self.best_epoch = 0
|
| 34 |
+
self.early_stop = False
|
| 35 |
+
self.best_value = float('inf') if mode == 'min' else -float('inf')
|
| 36 |
+
|
| 37 |
+
def __call__(self, current_value: float, model: nn.Module, epoch: int = None):
|
| 38 |
+
improvement = (
|
| 39 |
+
(self.mode == 'min' and current_value < self.best_value - self.delta) or
|
| 40 |
+
(self.mode == 'max' and current_value > self.best_value + self.delta)
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
if self.best_score is None:
|
| 44 |
+
self.best_score = current_value
|
| 45 |
+
self.best_epoch = epoch if epoch is not None else 0
|
| 46 |
+
self.save_checkpoint(current_value, model)
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
if improvement:
|
| 50 |
+
if self.verbose:
|
| 51 |
+
print(f"EarlyStopping (Improved): {self.monitor} {self.best_value:.4f} → {current_value:.4f}")
|
| 52 |
+
self.best_score = current_value
|
| 53 |
+
self.best_epoch = epoch if epoch is not None else 0
|
| 54 |
+
self.save_checkpoint(current_value, model)
|
| 55 |
+
self.counter = 0
|
| 56 |
+
else:
|
| 57 |
+
self.counter += 1
|
| 58 |
+
if self.verbose:
|
| 59 |
+
print(f"EarlyStopping (No Improvement): {self.counter}/{self.patience} epochs "
|
| 60 |
+
f"({self.monitor}={current_value:.4f})")
|
| 61 |
+
if self.counter >= self.patience:
|
| 62 |
+
self.early_stop = True
|
| 63 |
+
|
| 64 |
+
return self.early_stop
|
| 65 |
+
|
| 66 |
+
def save_checkpoint(self, current_value: float, model: nn.Module):
|
| 67 |
+
if self.verbose:
|
| 68 |
+
print(f"Saving new best model at {self.checkpoint_path} ({self.monitor}={current_value:.4f})")
|
| 69 |
+
torch.save(model.state_dict(), self.checkpoint_path)
|
| 70 |
+
self.best_value = current_value
|
| 71 |
+
|
| 72 |
+
def restore_best_weights(self, model: nn.Module):
|
| 73 |
+
if self.restore_best_weights_flag:
|
| 74 |
+
if torch.cuda.is_available():
|
| 75 |
+
map_location = torch.device('cuda')
|
| 76 |
+
else:
|
| 77 |
+
map_location = torch.device('cpu')
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
model.load_state_dict(torch.load(self.checkpoint_path, map_location=map_location))
|
| 81 |
+
if self.verbose:
|
| 82 |
+
print(f'Restored best model from {self.checkpoint_path} (epoch {self.best_epoch})')
|
| 83 |
+
except FileNotFoundError:
|
| 84 |
+
if self.verbose:
|
| 85 |
+
print(f'Restoring failed | No checkpoint found at {self.checkpoint_path}, cannot restore weights.')
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class EarlyStoppingCallback(object):
|
| 89 |
+
"""Adapter to use EarlyStopping as a callback in the trainer function."""
|
| 90 |
+
|
| 91 |
+
def __init__(self, patience=10, delta=0, checkpoint_path='checkpoint.pt', verbose=True, restore_best_weights=True,
|
| 92 |
+
monitor='val_loss', mode='min', save_final_model=False,
|
| 93 |
+
):
|
| 94 |
+
self.early_stopping = EarlyStopping(
|
| 95 |
+
patience=patience,
|
| 96 |
+
delta=delta,
|
| 97 |
+
checkpoint_path=checkpoint_path,
|
| 98 |
+
verbose=verbose,
|
| 99 |
+
restore_best_weights=restore_best_weights,
|
| 100 |
+
monitor=monitor,
|
| 101 |
+
mode=mode
|
| 102 |
+
)
|
| 103 |
+
self.checkpoint_path = checkpoint_path
|
| 104 |
+
self.save_final_model = save_final_model
|
| 105 |
+
|
| 106 |
+
def on_training_begin(self, state):
|
| 107 |
+
state['early_stop'] = False
|
| 108 |
+
state['best_epoch'] = 0
|
| 109 |
+
state['best_model_path'] = self.checkpoint_path
|
| 110 |
+
|
| 111 |
+
def on_validation_end(self, state):
|
| 112 |
+
epoch = state.get('epoch', None)
|
| 113 |
+
metric_name = self.early_stopping.monitor
|
| 114 |
+
# print(state.keys())
|
| 115 |
+
# print(state.get('val_metrics'))
|
| 116 |
+
|
| 117 |
+
if metric_name.startswith('val_metrics'):
|
| 118 |
+
key = metric_name.split('.')[-1]
|
| 119 |
+
metric_value = state['val_metrics'].get(key)
|
| 120 |
+
elif metric_name.startswith('train_metrics'):
|
| 121 |
+
key = metric_name.split('.')[-1]
|
| 122 |
+
metric_value = state['train_metrics'].get(key)
|
| 123 |
+
else:
|
| 124 |
+
metric_value = state.get(metric_name)
|
| 125 |
+
|
| 126 |
+
if metric_value is None:
|
| 127 |
+
raise ValueError(f"Metric '{metric_name}' not found in state dictionary.")
|
| 128 |
+
|
| 129 |
+
stop = self.early_stopping(metric_value, state['model'], epoch=epoch)
|
| 130 |
+
|
| 131 |
+
state['early_stop'] = stop
|
| 132 |
+
state['best_epoch'] = self.early_stopping.best_epoch
|
| 133 |
+
state['best_val_loss'] = self.early_stopping.best_value
|
| 134 |
+
|
| 135 |
+
def on_training_end(self, state):
|
| 136 |
+
model = state.get('model')
|
| 137 |
+
if model:
|
| 138 |
+
self.early_stopping.restore_best_weights(model)
|
| 139 |
+
print(f"Training finished. Best epoch: {self.early_stopping.best_epoch}, "
|
| 140 |
+
f"Best {self.early_stopping.monitor}: {self.early_stopping.best_value:.4f}")
|
| 141 |
+
|
| 142 |
+
if self.save_final_model:
|
| 143 |
+
final_path = self.checkpoint_path.replace(".pt", "_final.pt")
|
| 144 |
+
torch.save(model.state_dict(), final_path)
|
| 145 |
+
print(f"Final model saved at {final_path}")
|
utils/eval_utils.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from torch import load
|
| 6 |
+
from torch.utils.data import DataLoader
|
| 7 |
+
from typing import Union
|
| 8 |
+
|
| 9 |
+
from model.epu import EPUCNN
|
| 10 |
+
from utils.omega_parser import EPUCNNParams
|
| 11 |
+
from utils.config_utils import model_cfg_to_epucnn
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class EPUCNNEval(EPUCNN):
|
| 15 |
+
def __init__(self, epu_cfg: EPUCNNParams):
|
| 16 |
+
super().__init__(**model_cfg_to_epucnn(epu_cfg))
|
| 17 |
+
self.eval()
|
| 18 |
+
|
| 19 |
+
# @staticmethod
|
| 20 |
+
# def initialize_model_from_config(epu_cfg: EPUCNNParams, device: torch.device) -> EPUCNN:
|
| 21 |
+
# model = EPUCNN(**model_cfg_to_epucnn(epu_cfg))
|
| 22 |
+
# model.to(device)
|
| 23 |
+
# return model
|
| 24 |
+
|
| 25 |
+
# @staticmethod
|
| 26 |
+
# def load_ckpt(model: Union[nn.Module, EPUCNN], device: torch.device, ckpt_path: str) -> Union[nn.Module, EPUCNN]:
|
| 27 |
+
# state_dict = load(ckpt_path, map_location=device)
|
| 28 |
+
# model.load_state_dict(state_dict)
|
| 29 |
+
# model.to(device)
|
| 30 |
+
# model.eval()
|
| 31 |
+
# return model
|
| 32 |
+
|
| 33 |
+
def load_ckpt(self, device: torch.device, ckpt_path: str):
|
| 34 |
+
state_dict = load(ckpt_path, map_location=device)
|
| 35 |
+
self.load_state_dict(state_dict)
|
| 36 |
+
self.to(device)
|
| 37 |
+
self.eval()
|
| 38 |
+
return self
|
| 39 |
+
|
| 40 |
+
# @staticmethod
|
| 41 |
+
# def get_pretrained_model_from_config(epu_cfg: EPUCNNParams,
|
| 42 |
+
# device: torch.device,
|
| 43 |
+
# ckpt_path: str
|
| 44 |
+
# ):
|
| 45 |
+
# model = EPUCNNEval.initialize_model_from_config(epu_cfg, device)
|
| 46 |
+
# model = EPUCNNEval.load_ckpt(model, device, ckpt_path)
|
| 47 |
+
# return model
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class InferenceRunnerEPUCNN:
|
| 51 |
+
def __init__(self, epu_model: Union[EPUCNN, nn.Module], device: torch.device, mode: str = 'binary'):
|
| 52 |
+
self.epu_model = epu_model
|
| 53 |
+
self.device = device
|
| 54 |
+
self.mode = mode
|
| 55 |
+
|
| 56 |
+
def predict(self, dataloader: DataLoader, raw_logits=False, return_predictions: bool = False):
|
| 57 |
+
self.epu_model.eval()
|
| 58 |
+
|
| 59 |
+
all_targets = []
|
| 60 |
+
all_predictions = []
|
| 61 |
+
|
| 62 |
+
results = {}
|
| 63 |
+
|
| 64 |
+
with torch.no_grad():
|
| 65 |
+
for batch in dataloader:
|
| 66 |
+
x, y = batch
|
| 67 |
+
x = x.to(self.device)
|
| 68 |
+
y = y.to(self.device, dtype=torch.float32).unsqueeze(1) # from [bs] to [bs, 1]
|
| 69 |
+
y_hat = self.epu_model(x, ret_raw_logits=raw_logits)
|
| 70 |
+
|
| 71 |
+
all_predictions.append(y_hat.cpu().detach().numpy())
|
| 72 |
+
if return_predictions:
|
| 73 |
+
all_targets.append(y.cpu().detach().numpy())
|
| 74 |
+
|
| 75 |
+
results["predictions"] = np.concatenate(all_predictions, axis=0)
|
| 76 |
+
if return_predictions:
|
| 77 |
+
results["targets"] = np.concatenate(all_targets, axis=0)
|
| 78 |
+
return results
|
utils/metrics.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn as nn
|
| 4 |
+
|
| 5 |
+
from typing import Optional, Dict, Tuple, Union, Callable
|
| 6 |
+
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class EPUMetrics:
|
| 10 |
+
|
| 11 |
+
def __init__(self,
|
| 12 |
+
mode: str = "binary",
|
| 13 |
+
n_classes: int = 1,
|
| 14 |
+
confidence_level: float = 0.5,
|
| 15 |
+
activation: Union[nn.Module, Callable] = nn.Sigmoid(),
|
| 16 |
+
metrics_config: Optional[Dict[str, bool]] = None,
|
| 17 |
+
):
|
| 18 |
+
|
| 19 |
+
assert mode in ["binary", "multiclass"], "mode should be either 'binary' or 'multiclass'"
|
| 20 |
+
self.mode = mode
|
| 21 |
+
|
| 22 |
+
self.n_classes = n_classes
|
| 23 |
+
self.confidence = confidence_level
|
| 24 |
+
|
| 25 |
+
self.activation = activation
|
| 26 |
+
|
| 27 |
+
default_metrics = {"accuracy": True, "auc": True}
|
| 28 |
+
self.metrics_config = metrics_config if metrics_config is not None else default_metrics
|
| 29 |
+
|
| 30 |
+
self.avg_method = 'binary' if self.mode == "binary" else 'macro'
|
| 31 |
+
|
| 32 |
+
@staticmethod
|
| 33 |
+
def _to_tensor(x: Union[np.ndarray, torch.Tensor]) -> torch.Tensor:
|
| 34 |
+
if isinstance(x, torch.Tensor):
|
| 35 |
+
x = x.detach()
|
| 36 |
+
return x
|
| 37 |
+
else:
|
| 38 |
+
return torch.as_tensor(x)
|
| 39 |
+
|
| 40 |
+
@staticmethod
|
| 41 |
+
def _to_numpy(x: torch.Tensor) -> np.array:
|
| 42 |
+
x = x.detach().cpu().numpy()
|
| 43 |
+
return x
|
| 44 |
+
|
| 45 |
+
def _apply_activation(self, raw_logits: torch.Tensor) -> torch.Tensor:
|
| 46 |
+
return self.activation(raw_logits)
|
| 47 |
+
|
| 48 |
+
def _format_labels(self,
|
| 49 |
+
y_true: torch.Tensor,
|
| 50 |
+
y_pred: torch.Tensor,
|
| 51 |
+
y_prob: torch.Tensor = None) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 52 |
+
if y_prob is None:
|
| 53 |
+
y_prob = self._apply_activation(y_pred)
|
| 54 |
+
|
| 55 |
+
if self.mode == 'binary':
|
| 56 |
+
y_true = y_true.view(-1)
|
| 57 |
+
y_prob = y_prob.view(-1)
|
| 58 |
+
y_pred = (y_prob > self.confidence).to(dtype=torch.int64)
|
| 59 |
+
|
| 60 |
+
else:
|
| 61 |
+
if y_true.ndim > 1:
|
| 62 |
+
y_true = np.argmax(y_true, axis=1)
|
| 63 |
+
y_pred = np.argmax(y_pred, axis=1)
|
| 64 |
+
|
| 65 |
+
y_true = self._to_numpy(y_true)
|
| 66 |
+
y_pred = self._to_numpy(y_pred)
|
| 67 |
+
y_prob = self._to_numpy(y_prob)
|
| 68 |
+
return y_true, y_pred, y_prob
|
| 69 |
+
|
| 70 |
+
@staticmethod
|
| 71 |
+
def convert_to_onehot(labels: np.ndarray, n_classes: int) -> np.ndarray:
|
| 72 |
+
if n_classes is None:
|
| 73 |
+
raise ValueError("Number of classes is not provided")
|
| 74 |
+
labels = labels.astype(int)
|
| 75 |
+
labels = np.eye(n_classes)[labels]
|
| 76 |
+
return labels
|
| 77 |
+
|
| 78 |
+
def compute(self,
|
| 79 |
+
y_true: Union[torch.Tensor, np.ndarray],
|
| 80 |
+
y_pred: Union[torch.Tensor, np.ndarray],
|
| 81 |
+
y_prob: Optional[Union[torch.Tensor, np.ndarray]] = None
|
| 82 |
+
) -> Dict[str, float]:
|
| 83 |
+
y_true = self._to_tensor(y_true)
|
| 84 |
+
y_pred = self._to_tensor(y_pred)
|
| 85 |
+
y_prob = None if y_prob is None else self._to_tensor(y_prob)
|
| 86 |
+
|
| 87 |
+
y_true_np, y_pred_np, y_prob_np = self._format_labels(
|
| 88 |
+
y_true=y_true,
|
| 89 |
+
y_pred=y_pred, # raw_logits
|
| 90 |
+
y_prob=y_prob)
|
| 91 |
+
|
| 92 |
+
metrics = {}
|
| 93 |
+
if self.metrics_config["accuracy"]:
|
| 94 |
+
score = accuracy_score(y_true_np, y_pred_np)
|
| 95 |
+
metrics["accuracy"] = float(np.round(score, 4))
|
| 96 |
+
if self.metrics_config["auc"]:
|
| 97 |
+
try:
|
| 98 |
+
if self.avg_method == "binary":
|
| 99 |
+
score = roc_auc_score(y_true_np, y_prob_np)
|
| 100 |
+
else:
|
| 101 |
+
y_true_np = self.convert_to_onehot(y_true_np, n_classes=self.n_classes)
|
| 102 |
+
score = roc_auc_score(y_true_np, y_prob_np, multi_class='ovr')
|
| 103 |
+
metrics["auc"] = score
|
| 104 |
+
except ValueError:
|
| 105 |
+
metrics["auc"] = float('nan') # AUC cannot be calculated
|
| 106 |
+
|
| 107 |
+
return metrics
|
| 108 |
+
|
| 109 |
+
def update_confidence(self, confidence_level: float) -> None:
|
| 110 |
+
self.confidence = confidence_level
|
utils/omega_parser.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from omegaconf import OmegaConf
|
| 2 |
+
from omegaconf import MISSING
|
| 3 |
+
|
| 4 |
+
from dataclasses import dataclass, field, asdict
|
| 5 |
+
from typing import Any, Dict, Tuple, Optional
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass
|
| 9 |
+
class DataLoading:
|
| 10 |
+
batch_size: int = 64
|
| 11 |
+
shuffle: bool = True
|
| 12 |
+
num_workers: int = 0
|
| 13 |
+
pin_memory: bool = False
|
| 14 |
+
persistent_workers: bool = False
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class DataPreprocessing:
|
| 19 |
+
data_parser: str = MISSING
|
| 20 |
+
label_mapping: Dict[str, int] = field(default_factory=dict)
|
| 21 |
+
resize_dims: Tuple[int, int] = (128, 128)
|
| 22 |
+
data_mode: str = "rgb" # auto-completed from input_channels in SubnetworkParams
|
| 23 |
+
medmnist_csv_file: Optional[str] = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class DataParams:
|
| 28 |
+
dataset_path: str = MISSING
|
| 29 |
+
images_extension: str = MISSING
|
| 30 |
+
data_preprocessing: DataPreprocessing = field(default_factory=DataPreprocessing)
|
| 31 |
+
data_loading: DataLoading = field(default_factory=DataLoading)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class TrainingParams:
|
| 36 |
+
mode: str = "binary"
|
| 37 |
+
loss: str = "binary_cross_entropy"
|
| 38 |
+
epochs: int = 10
|
| 39 |
+
optimizer: str = "adam"
|
| 40 |
+
learning_rate: float = 0.001
|
| 41 |
+
momentum: float = 0.0
|
| 42 |
+
weight_decay: float = 0.001
|
| 43 |
+
early_stopping_patience: int = 4
|
| 44 |
+
early_stopping_monitor: str = "val_loss"
|
| 45 |
+
early_stopping_mode: str = "min"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class SubnetworkParams:
|
| 50 |
+
architecture: str = "base_one"
|
| 51 |
+
input_channels: int = 3
|
| 52 |
+
# fc_pred_units: int = 1 # num_classes
|
| 53 |
+
base_channels: int = 32
|
| 54 |
+
fc_hidden_units: int = 64
|
| 55 |
+
pred_activation: str = "tanh"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class EPUCNNParams:
|
| 60 |
+
num_subnetworks: int = 4
|
| 61 |
+
num_classes: int = 1
|
| 62 |
+
epu_activation: str = "sigmoid"
|
| 63 |
+
subnetwork_config: SubnetworkParams = field(default_factory=SubnetworkParams)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class IUSConfig:
|
| 68 |
+
model: EPUCNNParams = field(default_factory=EPUCNNParams)
|
| 69 |
+
train_params: TrainingParams = field(default_factory=TrainingParams)
|
| 70 |
+
data_params: DataParams = field(default_factory=DataParams)
|
| 71 |
+
log_dir: Optional[str] = "./logs"
|
| 72 |
+
checkpoint_dir: Optional[str] = "./checkpoints"
|
| 73 |
+
experiment_name: Optional[str] = "ius_experiment"
|
| 74 |
+
timestamp: Optional[str] = None
|
| 75 |
+
experiment_saved_folder_name: Optional[str] = None
|
| 76 |
+
|
| 77 |
+
@staticmethod
|
| 78 |
+
def from_yaml(filepath: str) -> "IUSConfig":
|
| 79 |
+
yaml_cfg = OmegaConf.load(filepath)
|
| 80 |
+
merged = OmegaConf.merge(OmegaConf.structured(IUSConfig), yaml_cfg)
|
| 81 |
+
merged = OmegaConf.to_object(merged)
|
| 82 |
+
if isinstance(merged, IUSConfig):
|
| 83 |
+
cfg = merged
|
| 84 |
+
else: # manually
|
| 85 |
+
cfg = IUSConfig(**merged)
|
| 86 |
+
return cfg
|
| 87 |
+
|
| 88 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 89 |
+
return {
|
| 90 |
+
"model": vars(self.model),
|
| 91 |
+
"train_params": vars(self.train_params),
|
| 92 |
+
"data_params": vars(self.data_params),
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
def __repr__(self):
|
| 96 |
+
return OmegaConf.to_yaml(OmegaConf.structured(self), resolve=True)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
cfg_file = "configs/train_config.yaml"
|
| 101 |
+
cfg = IUSConfig.from_yaml(cfg_file)
|
| 102 |
+
print(cfg.model)
|
| 103 |
+
print(cfg.train_params)
|
| 104 |
+
print(cfg.data_params)
|
| 105 |
+
|
| 106 |
+
# yaml_cfg = IUSConfig.from_yaml("checkpoints/test_experiment_unet_0032_20260203_192949/user_config.yaml")
|
| 107 |
+
# print(yaml_cfg)
|
| 108 |
+
# print(type(yaml_cfg))
|
| 109 |
+
# print(yaml_cfg.data_params.test_split)
|
utils/sanity_utils.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .omega_parser import IUSConfig
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class SanityChecker:
|
| 5 |
+
VALID_MODELS = ["base_one"]
|
| 6 |
+
VALID_DATA_MODES = ["grayscale", "rgb"]
|
| 7 |
+
VALID_DATA_PARSERS = ["filename", "folder", "medmnist"]
|
| 8 |
+
|
| 9 |
+
def __init__(self, cfg: IUSConfig):
|
| 10 |
+
self.cfg = cfg
|
| 11 |
+
|
| 12 |
+
def model_cfg(self) -> None:
|
| 13 |
+
architecture = self.cfg.model.subnetwork_config.architecture
|
| 14 |
+
|
| 15 |
+
if architecture not in self.VALID_MODELS:
|
| 16 |
+
raise ValueError(
|
| 17 |
+
f"EPU-CNN backbone implemented {self.VALID_MODELS}. Original IUS paper was using 'base_one'"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
num_classes = self.cfg.model.num_classes
|
| 21 |
+
epu_activation = self.cfg.model.epu_activation
|
| 22 |
+
|
| 23 |
+
if epu_activation == "sigmoid" and num_classes > 1:
|
| 24 |
+
raise ValueError(
|
| 25 |
+
f"epu_activation = {epu_activation} and num_classes = {num_classes}. "
|
| 26 |
+
f"For sigmoid epu_activation you have to set num_classes=1"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
num_subnets = self.cfg.model.num_subnetworks
|
| 30 |
+
if num_subnets != 4:
|
| 31 |
+
raise ValueError(
|
| 32 |
+
"IUS measure was implemented using 4 PFMS (either for grayscale or rgb modalities)"
|
| 33 |
+
"class PerceptualFeatureMapTransform in data.perceptual_transforms yields 4 PFM representations. "
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
def train_cfg(self) -> None:
|
| 37 |
+
mode = self.cfg.train_params.mode
|
| 38 |
+
loss = self.cfg.train_params.loss
|
| 39 |
+
|
| 40 |
+
if mode == "binary" and loss != "binary_cross_entropy":
|
| 41 |
+
raise ValueError(
|
| 42 |
+
f"In train_params: mode={mode}, loss={loss}. For mode = 'binary' set loss = 'binary_cross_entropy'"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
def data_preprocessing(self) -> None:
|
| 46 |
+
data_mode = self.cfg.data_params.data_preprocessing.data_mode
|
| 47 |
+
channels = self.cfg.model.subnetwork_config.input_channels
|
| 48 |
+
|
| 49 |
+
if data_mode not in self.VALID_DATA_MODES:
|
| 50 |
+
raise ValueError(
|
| 51 |
+
f"data_mode should be one of {self.VALID_DATA_MODES}. "
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
if channels != 1:
|
| 55 |
+
raise ValueError(
|
| 56 |
+
f"In train_params:input_channels={channels} But Perceptual Feature decomposition yields PFMs "
|
| 57 |
+
f"with 1 output channel. Please set input_channels=1"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
num_classes = self.cfg.model.num_classes
|
| 61 |
+
labels = set(self.cfg.data_params.data_preprocessing.label_mapping.values())
|
| 62 |
+
|
| 63 |
+
if num_classes > 1 and len(labels) != num_classes:
|
| 64 |
+
raise ValueError(
|
| 65 |
+
f"In train_params: num_classes={num_classes}, in label mapping found {labels}. "
|
| 66 |
+
)
|
| 67 |
+
if num_classes == 1 and len(labels) != 2:
|
| 68 |
+
raise ValueError(
|
| 69 |
+
f"In train_params: num_classes={num_classes}, in label mapping found {labels}. "
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
data_parser = self.cfg.data_params.data_preprocessing.data_parser
|
| 73 |
+
if data_parser not in self.VALID_DATA_PARSERS:
|
| 74 |
+
raise ValueError(
|
| 75 |
+
f"data_parser should be one of {self.VALID_DATA_PARSERS}. "
|
| 76 |
+
f"Otherwise implement your own data_parser in data.parsers and update utils.sanity_utils"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
def sanity_check(self) -> None:
|
| 80 |
+
self.model_cfg()
|
| 81 |
+
self.train_cfg()
|
| 82 |
+
self.data_preprocessing()
|
| 83 |
+
print("All sanity checks passed!")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
from .omega_parser import IUSConfig
|
| 88 |
+
cfg_file = "configs/train_config.yaml"
|
| 89 |
+
cfg = IUSConfig.from_yaml(cfg_file)
|
| 90 |
+
|
| 91 |
+
# print(cfg.model)
|
| 92 |
+
# print(cfg.train_params)
|
| 93 |
+
# print(cfg.data_params)
|
| 94 |
+
|
| 95 |
+
checker = SanityChecker(cfg)
|
| 96 |
+
checker.sanity_check()
|
utils/tensorboard.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import subprocess
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
|
| 7 |
+
from torch.utils.tensorboard import SummaryWriter
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TensorboardLogger(object):
|
| 11 |
+
def __init__(self, log_dir='runs/experiment'):
|
| 12 |
+
self.writer = SummaryWriter(log_dir=log_dir)
|
| 13 |
+
|
| 14 |
+
def log_scalar(self, tag: str, value: float, step: int):
|
| 15 |
+
self.writer.add_scalar(tag, value, step)
|
| 16 |
+
|
| 17 |
+
# def log_scalars(self, tag: str, values: dict, step: int):
|
| 18 |
+
# self.writer.add_scalars(tag, values, step)
|
| 19 |
+
|
| 20 |
+
def log_histogram(self, model:nn.Module, step: int):
|
| 21 |
+
for name, param in model.named_parameters():
|
| 22 |
+
self.writer.add_histogram(f'weights/{name}', param, step)
|
| 23 |
+
if param.grad is not None:
|
| 24 |
+
self.writer.add_histogram(f'gradients/{name}', param.grad, step)
|
| 25 |
+
|
| 26 |
+
def log_model_graph(self, model: nn.Module, input_sample: torch.Tensor):
|
| 27 |
+
self.writer.add_graph(model, input_sample)
|
| 28 |
+
|
| 29 |
+
def close(self):
|
| 30 |
+
self.writer.close()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def launch_tensorboard(log_dir: str = "runs", port: int = 6006, open_browser: bool = True):
|
| 34 |
+
"""Automatically launch TensorBoard pointing to log_dir."""
|
| 35 |
+
if not os.path.exists(log_dir):
|
| 36 |
+
os.makedirs(log_dir, exist_ok=True)
|
| 37 |
+
print(f"[TensorBoard] launching at http://localhost:{port}/")
|
| 38 |
+
|
| 39 |
+
tb_command = ["tensorboard", f"--logdir={log_dir}", f"--port={port}"]
|
| 40 |
+
if not open_browser:
|
| 41 |
+
tb_command.append("--host=127.0.0.1")
|
| 42 |
+
|
| 43 |
+
subprocess.Popen(tb_command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
| 44 |
+
time.sleep(2)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class TensorboardLoggerCallback(object):
|
| 48 |
+
"""
|
| 49 |
+
Adapter uses TensorboardLogger
|
| 50 |
+
"""
|
| 51 |
+
def __init__(self,
|
| 52 |
+
log_dir: str = 'logs/test_experiment',
|
| 53 |
+
log_histograms: bool = False,
|
| 54 |
+
launch_tb: bool = True,
|
| 55 |
+
tb_port: int = 6006,
|
| 56 |
+
open_tb_in_browser: bool = False,
|
| 57 |
+
):
|
| 58 |
+
|
| 59 |
+
self.tb_logger = TensorboardLogger(log_dir=log_dir)
|
| 60 |
+
|
| 61 |
+
self.log_histograms = log_histograms
|
| 62 |
+
|
| 63 |
+
if launch_tb:
|
| 64 |
+
launch_tensorboard(log_dir=log_dir,
|
| 65 |
+
port=tb_port,
|
| 66 |
+
open_browser=open_tb_in_browser)
|
| 67 |
+
|
| 68 |
+
self.model = None
|
| 69 |
+
|
| 70 |
+
def on_train_begin(self, model: nn.Module):
|
| 71 |
+
self.model = model
|
| 72 |
+
print("[TensorBoard] Training started, callback init")
|
| 73 |
+
|
| 74 |
+
def on_train_end(self):
|
| 75 |
+
self.tb_logger.close()
|
| 76 |
+
print("[TensorBoard] Training ended, logs saved")
|
| 77 |
+
|
| 78 |
+
def on_epoch_end(self, state):
|
| 79 |
+
epoch = state.get('epoch', 0)
|
| 80 |
+
|
| 81 |
+
# losses
|
| 82 |
+
self.tb_logger.log_scalar('Loss/train', state.get('train_loss', 0.0), epoch)
|
| 83 |
+
self.tb_logger.log_scalar('Loss/val', state.get('val_loss', 0.0), epoch)
|
| 84 |
+
|
| 85 |
+
# training & validation metrics
|
| 86 |
+
|
| 87 |
+
# for metric, value in train_metrics.items():
|
| 88 |
+
# self.tb_logger.log_scalar(f'Metrics/train/{metric}', value, epoch)
|
| 89 |
+
train_metrics = state.get('train_metrics')
|
| 90 |
+
if train_metrics is not None:
|
| 91 |
+
# self.tb_logger.log_scalars(f'Metrics/train', train_metrics, epoch)
|
| 92 |
+
for metric_name, metric_value in train_metrics.items():
|
| 93 |
+
self.tb_logger.log_scalar(f'Metrics/train/{metric_name}', metric_value, epoch)
|
| 94 |
+
val_metrics = state.get('val_metrics')
|
| 95 |
+
if val_metrics is not None:
|
| 96 |
+
for metric_name, metric_value in val_metrics.items():
|
| 97 |
+
self.tb_logger.log_scalar(f'Metrics/val/{metric_name}', metric_value, epoch)
|
| 98 |
+
|
| 99 |
+
# weights and grads
|
| 100 |
+
model = state.get('model')
|
| 101 |
+
if self.log_histograms and model is not None:
|
| 102 |
+
self.tb_logger.log_histogram(model, epoch)
|
| 103 |
+
|
utils/train_utils.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
|
| 4 |
+
from typing import Any, List, Union
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from omegaconf import OmegaConf
|
| 7 |
+
|
| 8 |
+
from utils.omega_parser import IUSConfig
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_next_experiment_id(log_root: str, base_name: str, pad: int = 4) -> str:
|
| 12 |
+
"""
|
| 13 |
+
Finds the next zero-padded experiment ID based on existing folders.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
log_root: Root directory where experiment folders are stored.
|
| 17 |
+
base_name: Name of experiment e.g., "experiment_ius"
|
| 18 |
+
pad: Number of digits to pad the ID (default: 4).
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
Zero-padded string for next experiment ID, e.g., "0000", "0001".
|
| 22 |
+
"""
|
| 23 |
+
existing = [
|
| 24 |
+
d for d in os.listdir(log_root)
|
| 25 |
+
if os.path.isdir(os.path.join(log_root, d)) and d.startswith(base_name)
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
# Extract numeric ID
|
| 29 |
+
existing_ids = []
|
| 30 |
+
for d in existing:
|
| 31 |
+
try:
|
| 32 |
+
num_str = d.split("_")[-3]
|
| 33 |
+
existing_ids.append(int(num_str))
|
| 34 |
+
except (ValueError, IndexError):
|
| 35 |
+
continue
|
| 36 |
+
|
| 37 |
+
next_id = max(existing_ids, default=-1) + 1
|
| 38 |
+
return str(next_id).zfill(pad)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def create_experiment_folder(log_root: str, model: str, experiment: str, timestamp: str = None) -> str:
|
| 42 |
+
"""
|
| 43 |
+
Creates experiment folder with professional naming:
|
| 44 |
+
experiment_0000_YYYYMMDD_HHMMSS
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
log_root: Root directory for logs.
|
| 48 |
+
task: Downstream task name.
|
| 49 |
+
model: Model architecture name.
|
| 50 |
+
dataset: Dataset name.
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
experiment_name
|
| 54 |
+
@param timestamp:
|
| 55 |
+
"""
|
| 56 |
+
base_name = f"{experiment}_{model}"
|
| 57 |
+
exp_id = get_next_experiment_id(log_root, base_name)
|
| 58 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") if timestamp is None else timestamp
|
| 59 |
+
experiment_name = f"{base_name}_{exp_id}_{timestamp}"
|
| 60 |
+
|
| 61 |
+
return experiment_name
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# def create_output_folders(log_root: str, ckpt_root: str, experiment_name: str) -> None:
|
| 65 |
+
# os.makedirs(os.path.join(log_root, experiment_name), exist_ok=True)
|
| 66 |
+
# os.makedirs(os.path.join(ckpt_root, experiment_name), exist_ok=True)
|
| 67 |
+
def create_output_folders(folder_list: Union[List[str], str]) -> None:
|
| 68 |
+
if isinstance(folder_list, str):
|
| 69 |
+
folder_list = [folder_list]
|
| 70 |
+
for folder_name in folder_list:
|
| 71 |
+
os.makedirs(folder_name, exist_ok=True)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def save_config_to_output_folder(out_folder: str, cfg: IUSConfig, cfg_filename="user_config.yaml") -> None:
|
| 75 |
+
config_path = os.path.join(out_folder, cfg_filename)
|
| 76 |
+
OmegaConf.save(config=OmegaConf.structured(cfg), f=config_path)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def update_experiment_metadata(cfg: Any,
|
| 80 |
+
experiment_name: str = None,
|
| 81 |
+
timestamp: str = None) -> None:
|
| 82 |
+
"""
|
| 83 |
+
Update experiment metadata fields. Uses given timestamp if provided,
|
| 84 |
+
otherwise does not override.
|
| 85 |
+
"""
|
| 86 |
+
if timestamp:
|
| 87 |
+
cfg.timestamp = timestamp
|
| 88 |
+
|
| 89 |
+
if experiment_name and not getattr(cfg, "experiment_name", None):
|
| 90 |
+
cfg.experiment_name = experiment_name
|
| 91 |
+
|
| 92 |
+
if hasattr(cfg, "train_params"):
|
| 93 |
+
train_params = cfg.train_params
|
| 94 |
+
train_params.experiment_name = experiment_name
|
| 95 |
+
if hasattr(cfg, "model"):
|
| 96 |
+
model_cfg = cfg.model
|
| 97 |
+
model_cfg.experiment_name = experiment_name
|
| 98 |
+
|
| 99 |
+
cfg.experiment_saved_folder_name = experiment_name
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def save_to_json(diction_to_save: dict, saving_path: str) -> None:
|
| 103 |
+
try:
|
| 104 |
+
with open(saving_path, "w") as f:
|
| 105 |
+
json.dump(diction_to_save, f, indent=4)
|
| 106 |
+
print(f"Saved json file at {saving_path}")
|
| 107 |
+
except Exception as e:
|
| 108 |
+
print(f"Failed to save json file at {saving_path}: {e}")
|
utils/trainer.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
import torch.optim as optim
|
| 4 |
+
|
| 5 |
+
from tqdm import tqdm
|
| 6 |
+
from typing import List, Any, Optional, Tuple, Dict
|
| 7 |
+
|
| 8 |
+
# mine
|
| 9 |
+
from data.dataloader import DataLoader
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class EPUTrainer:
|
| 13 |
+
def __init__(self,
|
| 14 |
+
model: nn.Module,
|
| 15 |
+
device: torch.device,
|
| 16 |
+
optimizer: optim.Optimizer,
|
| 17 |
+
criterion: nn.Module,
|
| 18 |
+
epochs: int,
|
| 19 |
+
train_loader: DataLoader,
|
| 20 |
+
val_loader: Optional[DataLoader] = None,
|
| 21 |
+
callbacks: Optional[List[object]] = None,
|
| 22 |
+
metrics: Optional = None,
|
| 23 |
+
checkpoint_dir: Optional[str] = None,
|
| 24 |
+
):
|
| 25 |
+
self.model = model
|
| 26 |
+
self.val_loader = val_loader
|
| 27 |
+
self.train_loader = train_loader
|
| 28 |
+
|
| 29 |
+
self.device = device
|
| 30 |
+
self.epochs = epochs
|
| 31 |
+
self.optimizer = optimizer
|
| 32 |
+
self.criterion = criterion
|
| 33 |
+
self.callbacks = callbacks or []
|
| 34 |
+
self.checkpoint_dir = checkpoint_dir
|
| 35 |
+
|
| 36 |
+
self.metrics_fun = metrics
|
| 37 |
+
# if self.metrics_fun is None:
|
| 38 |
+
|
| 39 |
+
# init values
|
| 40 |
+
self.best_metric = float("inf")
|
| 41 |
+
self.best_model_path = None
|
| 42 |
+
self.history = []
|
| 43 |
+
|
| 44 |
+
self.state = {"model": self.model,
|
| 45 |
+
"epoch": 0,
|
| 46 |
+
"early_stop": False,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
def train(self):
|
| 50 |
+
self.model.to(self.device)
|
| 51 |
+
|
| 52 |
+
self._on_training_begin()
|
| 53 |
+
|
| 54 |
+
for epoch in range(self.epochs):
|
| 55 |
+
self.state["epoch"] = epoch
|
| 56 |
+
self._on_epoch_begin()
|
| 57 |
+
|
| 58 |
+
train_loss, train_metrics = self._train_one_epoch()
|
| 59 |
+
val_loss, val_metrics = self._validate_epoch()
|
| 60 |
+
|
| 61 |
+
self.history.append({"epoch": epoch,
|
| 62 |
+
"train_loss": train_loss,
|
| 63 |
+
"val_loss": val_loss,
|
| 64 |
+
"train_metrics": train_metrics,
|
| 65 |
+
"val_metrics": val_metrics,}
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
self._on_epoch_end(train_loss, train_metrics, val_loss, val_metrics)
|
| 69 |
+
self._on_validation_end()
|
| 70 |
+
|
| 71 |
+
if self.state.get("early_stop", False):
|
| 72 |
+
print("Early stopping triggered.")
|
| 73 |
+
break
|
| 74 |
+
|
| 75 |
+
self._on_training_end()
|
| 76 |
+
# self._export_metrics_to_json()
|
| 77 |
+
|
| 78 |
+
def _train_one_epoch(self) -> Tuple[float, Dict[str, float]]:
|
| 79 |
+
self.model.train()
|
| 80 |
+
running_loss = 0.0
|
| 81 |
+
predictions, ground_truth = [], []
|
| 82 |
+
|
| 83 |
+
for i, sample in enumerate(tqdm(self.train_loader, desc=f"Training Epoch {self.state['epoch'] + 1}")):
|
| 84 |
+
x, y = sample
|
| 85 |
+
x = x.to(self.device)
|
| 86 |
+
y = y.to(self.device, dtype=torch.float32).unsqueeze(1) # from [bs] to [bs, 1]
|
| 87 |
+
|
| 88 |
+
self.optimizer.zero_grad()
|
| 89 |
+
|
| 90 |
+
y_hat = self.model(x, ret_raw_logits=True) # w/o EPU activation -applied internally in loss
|
| 91 |
+
loss = self.criterion(y_hat, y)
|
| 92 |
+
|
| 93 |
+
loss.backward()
|
| 94 |
+
self.optimizer.step()
|
| 95 |
+
|
| 96 |
+
running_loss += loss.item()
|
| 97 |
+
predictions.append(y_hat.detach().cpu())
|
| 98 |
+
ground_truth.append(y.detach().cpu())
|
| 99 |
+
|
| 100 |
+
for callback in self.callbacks:
|
| 101 |
+
if hasattr(callback, "on_batch_end"):
|
| 102 |
+
callback.on_batch_end(
|
| 103 |
+
{**self.state,
|
| 104 |
+
"batch": i,
|
| 105 |
+
"loss": loss.item()}
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
avg_loss = running_loss / len(self.train_loader)
|
| 109 |
+
|
| 110 |
+
metrics = {}
|
| 111 |
+
if self.metrics_fun is not None:
|
| 112 |
+
metrics = self.metrics_fun.compute(
|
| 113 |
+
y_true=torch.cat(ground_truth, axis=0),
|
| 114 |
+
y_pred=torch.cat(predictions, axis=0)
|
| 115 |
+
)
|
| 116 |
+
return avg_loss, metrics
|
| 117 |
+
|
| 118 |
+
def _validate_epoch(self) -> Tuple[float, Dict[str, float]]:
|
| 119 |
+
if self.val_loader is None:
|
| 120 |
+
return 0.0, {}
|
| 121 |
+
|
| 122 |
+
self.model.eval()
|
| 123 |
+
total_loss = 0
|
| 124 |
+
predictions, ground_truths = [], []
|
| 125 |
+
|
| 126 |
+
with torch.no_grad():
|
| 127 |
+
for sample in tqdm(self.val_loader, desc="Validating"):
|
| 128 |
+
x, y = sample
|
| 129 |
+
x = x.to(self.device)
|
| 130 |
+
y = y.to(self.device, dtype=torch.float32).unsqueeze(1) # from [bs] to [bs, 1]
|
| 131 |
+
y_hat = self.model(x, ret_raw_logits=True)
|
| 132 |
+
loss = self.criterion(y_hat, y)
|
| 133 |
+
|
| 134 |
+
total_loss += loss.item()
|
| 135 |
+
predictions.append(y_hat.detach().cpu())
|
| 136 |
+
ground_truths.append(y.detach().cpu())
|
| 137 |
+
|
| 138 |
+
avg_loss = total_loss / len(self.val_loader)
|
| 139 |
+
metrics = {}
|
| 140 |
+
if self.metrics_fun is not None:
|
| 141 |
+
metrics = self.metrics_fun.compute(
|
| 142 |
+
y_true=torch.cat(ground_truths, axis=0),
|
| 143 |
+
y_pred=torch.cat(predictions, axis=0)
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
return avg_loss, metrics
|
| 147 |
+
|
| 148 |
+
def _on_training_begin(self):
|
| 149 |
+
for callback in self.callbacks:
|
| 150 |
+
if hasattr(callback, "on_training_begin"):
|
| 151 |
+
callback.on_training_begin(self.state)
|
| 152 |
+
|
| 153 |
+
def _on_epoch_begin(self):
|
| 154 |
+
for callback in self.callbacks:
|
| 155 |
+
if hasattr(callback, "on_epoch_begin"):
|
| 156 |
+
callback.on_epoch_begin(self.state)
|
| 157 |
+
|
| 158 |
+
def _on_epoch_end(self, train_loss, train_metrics, val_loss, val_metrics):
|
| 159 |
+
# update state
|
| 160 |
+
self.state.update(
|
| 161 |
+
{"train_loss": train_loss,
|
| 162 |
+
"val_loss": val_loss,
|
| 163 |
+
"train_metrics": train_metrics,
|
| 164 |
+
"val_metrics": val_metrics,
|
| 165 |
+
}
|
| 166 |
+
)
|
| 167 |
+
# print losses
|
| 168 |
+
print(f"Epoch {self.state['epoch'] + 1} | "
|
| 169 |
+
f"Train loss: {train_loss:.4f} | Validation Loss: {val_loss:.4f}")
|
| 170 |
+
|
| 171 |
+
# print metrics
|
| 172 |
+
if train_metrics is not None:
|
| 173 |
+
train_metrics_str = " | ".join([f"{k}: {v:.4f}" for k, v in train_metrics.items()])
|
| 174 |
+
print(f"Train metrics:\t\t {train_metrics_str}")
|
| 175 |
+
if val_metrics:
|
| 176 |
+
val_metrics_str = " | ".join([f"{k}: {v:.4f}" for k, v in val_metrics.items()])
|
| 177 |
+
print(f"Validation metrics:\t {val_metrics_str}")
|
| 178 |
+
|
| 179 |
+
# exec callbacks
|
| 180 |
+
for callback in self.callbacks:
|
| 181 |
+
if hasattr(callback, "on_epoch_end"):
|
| 182 |
+
callback.on_epoch_end(self.state)
|
| 183 |
+
|
| 184 |
+
def _on_validation_end(self,):
|
| 185 |
+
for callback in self.callbacks:
|
| 186 |
+
if hasattr(callback, "on_validation_end"):
|
| 187 |
+
# print(self.state)
|
| 188 |
+
callback.on_validation_end(self.state)
|
| 189 |
+
|
| 190 |
+
def _on_training_end(self):
|
| 191 |
+
for callback in self.callbacks:
|
| 192 |
+
if hasattr(callback, "on_training_end"):
|
| 193 |
+
callback.on_training_end(self.state)
|
| 194 |
+
|
| 195 |
+
def get_model(self) -> torch.nn.Module:
|
| 196 |
+
return self.model
|
| 197 |
+
|
| 198 |
+
def get_metrics(self):
|
| 199 |
+
return self.metrics_fun
|
| 200 |
+
|
| 201 |
+
# def _export_metrics_to_json(self):
|
| 202 |
+
# if self.checkpoint_dir is not None:
|
| 203 |
+
# metrics_path = os.path.join(self.checkpoint_dir, "metrics.json")
|
| 204 |
+
# with open(metrics_path, "w") as f:
|
| 205 |
+
# json.dump(self.history, f, indent=4)
|
| 206 |
+
# print(f"Metrics exported to {metrics_path}")
|