pgatoula commited on
Commit
99ec8a2
·
verified ·
1 Parent(s): 7ae654f

Sync from GitHub via hub-sync

Browse files
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
- license: mit
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Interpretable Similarity of Synthetic Image Utility (IUS)
2
+ [![GitHub stars](https://img.shields.io/github/stars/innoisys/ius.svg?style=flat&label=Star)](https://github.com/innoisys/ius/)
3
+ [![Readme](https://img.shields.io/badge/README-green.svg)](README.md)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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}")