image-classification-tools 0.5.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,315 @@
1
+ '''Hyperparameter optimization utilities for CNN models using Optuna.
2
+
3
+ This module provides functions for building configurable CNN architectures
4
+ and running hyperparameter optimization with Optuna.
5
+ '''
6
+
7
+ from typing import Callable
8
+
9
+ import optuna
10
+ import torch
11
+ import torch.nn as nn
12
+ import torch.optim as optim
13
+ from torch.utils.data import DataLoader
14
+ from torchvision import datasets
15
+
16
+ from image_classification_tools.pytorch.data import (
17
+ load_dataset, prepare_splits, create_dataloaders
18
+ )
19
+
20
+
21
+ def create_cnn(
22
+ n_conv_blocks: int,
23
+ initial_filters: int,
24
+ n_fc_layers: int,
25
+ conv_dropout_rate: float,
26
+ fc_dropout_rate: float,
27
+ num_classes: int,
28
+ in_channels: int = 3
29
+ ) -> nn.Sequential:
30
+ '''Create a CNN with configurable architecture.
31
+
32
+ This function builds a flexible CNN architecture with conv blocks that
33
+ progressively double filters (initial_filters -> 2x -> 4x -> 8x, etc.).
34
+ Each conv block contains: 2 Conv layers + BatchNorm + ReLU + MaxPool + Dropout.
35
+ Uses adaptive pooling before classifier to handle variable spatial dimensions.
36
+
37
+ Args:
38
+ n_conv_blocks: Number of convolutional blocks (1-5)
39
+ initial_filters: Number of filters in first conv block (doubles each block)
40
+ n_fc_layers: Number of fully connected layers before output (1-4)
41
+ conv_dropout_rate: Dropout probability after convolutional blocks
42
+ fc_dropout_rate: Dropout probability in fully connected layers
43
+ num_classes: Number of output classes (required)
44
+ in_channels: Number of input channels (default: 3 for RGB images)
45
+
46
+ Returns:
47
+ nn.Sequential model
48
+ '''
49
+
50
+ layers = []
51
+ current_channels = in_channels
52
+
53
+ # Convolutional blocks
54
+ for block_idx in range(n_conv_blocks):
55
+ out_channels = initial_filters * (2 ** block_idx)
56
+
57
+ # First conv in block
58
+ layers.append(nn.Conv2d(current_channels, out_channels, kernel_size=3, padding=1))
59
+ layers.append(nn.BatchNorm2d(out_channels))
60
+ layers.append(nn.ReLU())
61
+
62
+ # Second conv in block
63
+ layers.append(nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1))
64
+ layers.append(nn.BatchNorm2d(out_channels))
65
+ layers.append(nn.ReLU())
66
+
67
+ # Pooling and dropout
68
+ layers.append(nn.MaxPool2d(2, 2))
69
+ layers.append(nn.Dropout(conv_dropout_rate))
70
+
71
+ current_channels = out_channels
72
+
73
+ # Classifier with adaptive pooling
74
+ layers.append(nn.AdaptiveAvgPool2d((1, 1)))
75
+ layers.append(nn.Flatten())
76
+
77
+ # Generate FC layer sizes by halving from current_channels
78
+ fc_sizes = []
79
+ current_fc_size = current_channels // 2
80
+ for _ in range(n_fc_layers):
81
+ fc_sizes.append(max(32, current_fc_size)) # Minimum 32 units
82
+ current_fc_size //= 2
83
+
84
+ # Add FC layers
85
+ in_features = current_channels
86
+ for fc_size in fc_sizes:
87
+ layers.append(nn.Linear(in_features, fc_size))
88
+ layers.append(nn.ReLU())
89
+ layers.append(nn.Dropout(fc_dropout_rate))
90
+ in_features = fc_size
91
+
92
+ # Output layer
93
+ layers.append(nn.Linear(in_features, num_classes))
94
+
95
+ return nn.Sequential(*layers)
96
+
97
+
98
+ def train_trial(
99
+ model: nn.Module,
100
+ optimizer: optim.Optimizer,
101
+ criterion: nn.Module,
102
+ train_loader: DataLoader,
103
+ val_loader: DataLoader,
104
+ n_epochs: int,
105
+ trial: optuna.Trial
106
+ ) -> float:
107
+ '''Train a model for a single Optuna trial with pruning support.
108
+
109
+ Args:
110
+ model: PyTorch model to train
111
+ optimizer: Optimizer for training
112
+ criterion: Loss function
113
+ train_loader: DataLoader for training data
114
+ val_loader: DataLoader for validation data
115
+ n_epochs: Number of epochs to train
116
+ trial: Optuna trial object for reporting and pruning
117
+
118
+ Returns:
119
+ Best validation accuracy achieved during training
120
+ '''
121
+ best_val_accuracy = 0.0
122
+
123
+ for epoch in range(n_epochs):
124
+
125
+ # Training phase
126
+ model.train()
127
+
128
+ for images, labels in train_loader:
129
+ optimizer.zero_grad()
130
+ outputs = model(images)
131
+ loss = criterion(outputs, labels)
132
+ loss.backward()
133
+ optimizer.step()
134
+
135
+ # Validation phase
136
+ model.eval()
137
+ val_correct = 0
138
+ val_total = 0
139
+
140
+ with torch.no_grad():
141
+ for images, labels in val_loader:
142
+ outputs = model(images)
143
+ _, predicted = torch.max(outputs.data, 1)
144
+ val_total += labels.size(0)
145
+ val_correct += (predicted == labels).sum().item()
146
+
147
+ val_accuracy = 100 * val_correct / val_total
148
+ best_val_accuracy = max(best_val_accuracy, val_accuracy)
149
+
150
+ # Report intermediate value for pruning
151
+ trial.report(val_accuracy, epoch)
152
+
153
+ # Prune unpromising trials
154
+ if trial.should_prune():
155
+ raise optuna.TrialPruned()
156
+
157
+ return best_val_accuracy
158
+
159
+
160
+ def create_objective(
161
+ data_dir,
162
+ transform,
163
+ n_epochs: int,
164
+ device: torch.device,
165
+ num_classes: int,
166
+ in_channels: int = 3,
167
+ search_space: dict = None
168
+ ) -> Callable[[optuna.Trial], float]:
169
+ '''Create an Optuna objective function for CNN hyperparameter optimization.
170
+
171
+ This factory function creates a closure that captures the data loading parameters
172
+ and training configuration, returning an objective function suitable for Optuna.
173
+
174
+ Args:
175
+ data_dir: Directory containing training data
176
+ transform: Transform to apply to both training and validation data
177
+ n_epochs: Number of epochs per trial
178
+ device: Device to train on (cuda or cpu)
179
+ num_classes: Number of output classes (required, e.g., 10 for CIFAR-10)
180
+ in_channels: Number of input channels (default: 3 for RGB images, 1 for grayscale)
181
+ search_space: Dictionary defining hyperparameter search space (default: None)
182
+
183
+ Returns:
184
+ Objective function for optuna.Study.optimize()
185
+
186
+ Example:
187
+ >>> objective = create_objective(
188
+ ... data_dir='data/',
189
+ ... transform=transform,
190
+ ... n_epochs=50,
191
+ ... device=device,
192
+ ... num_classes=10
193
+ ... )
194
+ >>> study = optuna.create_study(direction='maximize')
195
+ >>> study.optimize(objective, n_trials=100)
196
+ '''
197
+
198
+ # Default search space if none provided
199
+ if search_space is None:
200
+ search_space = {
201
+ 'batch_size': [64, 128, 256, 512],
202
+ 'n_conv_blocks': (1, 5),
203
+ 'initial_filters': [16, 32, 64, 128],
204
+ 'n_fc_layers': (1, 4),
205
+ 'conv_dropout_rate': (0.1, 0.5),
206
+ 'fc_dropout_rate': (0.3, 0.7),
207
+ 'learning_rate': (1e-5, 1e-2, 'log'),
208
+ 'optimizer': ['Adam', 'SGD', 'RMSprop'],
209
+ 'sgd_momentum': (0.8, 0.99),
210
+ 'weight_decay': (1e-6, 1e-3, 'log')
211
+ }
212
+
213
+ def objective(trial: optuna.Trial) -> float:
214
+ '''Optuna objective function for CNN hyperparameter optimization.'''
215
+
216
+ # Suggest hyperparameters from search space
217
+ batch_size = trial.suggest_categorical('batch_size', search_space['batch_size'])
218
+ n_conv_blocks = trial.suggest_int('n_conv_blocks', *search_space['n_conv_blocks'])
219
+ initial_filters = trial.suggest_categorical('initial_filters', search_space['initial_filters'])
220
+ n_fc_layers = trial.suggest_int('n_fc_layers', *search_space['n_fc_layers'])
221
+ conv_dropout_rate = trial.suggest_float('conv_dropout_rate', *search_space['conv_dropout_rate'])
222
+ fc_dropout_rate = trial.suggest_float('fc_dropout_rate', *search_space['fc_dropout_rate'])
223
+
224
+ # Handle learning rate with optional log scale
225
+ lr_params = search_space['learning_rate']
226
+ learning_rate = trial.suggest_float('learning_rate', lr_params[0], lr_params[1],
227
+ log=(lr_params[2] == 'log' if len(lr_params) > 2 else False))
228
+
229
+ optimizer_name = trial.suggest_categorical('optimizer', search_space['optimizer'])
230
+
231
+ # Weight decay
232
+ wd_params = search_space['weight_decay']
233
+ weight_decay = trial.suggest_float('weight_decay', wd_params[0], wd_params[1],
234
+ log=(wd_params[2] == 'log' if len(wd_params) > 2 else False))
235
+
236
+ # Create data loaders with suggested batch size
237
+ # Load datasets
238
+ train_dataset = load_dataset(
239
+ data_source=datasets.CIFAR10,
240
+ transform=transform,
241
+ train=True,
242
+ download=False,
243
+ root=data_dir
244
+ )
245
+
246
+ test_dataset = load_dataset(
247
+ data_source=datasets.CIFAR10,
248
+ transform=transform,
249
+ train=False,
250
+ download=False,
251
+ root=data_dir
252
+ )
253
+
254
+ # Prepare splits
255
+ train_dataset, val_dataset, _ = prepare_splits(
256
+ train_dataset=train_dataset,
257
+ test_dataset=test_dataset,
258
+ val_size=10000
259
+ )
260
+
261
+ # Create dataloaders with memory preloading
262
+ train_loader, val_loader, _ = create_dataloaders(
263
+ train_dataset, val_dataset, val_dataset,
264
+ batch_size=batch_size,
265
+ preload_to_memory=(device is not None),
266
+ device=device
267
+ )
268
+
269
+ # Create model with suggested architecture
270
+ model = create_cnn(
271
+ n_conv_blocks=n_conv_blocks,
272
+ initial_filters=initial_filters,
273
+ n_fc_layers=n_fc_layers,
274
+ conv_dropout_rate=conv_dropout_rate,
275
+ fc_dropout_rate=fc_dropout_rate,
276
+ num_classes=num_classes,
277
+ in_channels=in_channels
278
+ ).to(device)
279
+
280
+ # Define optimizer
281
+ if optimizer_name == 'Adam':
282
+ optimizer = optim.Adam(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
283
+
284
+ elif optimizer_name == 'SGD':
285
+ momentum = trial.suggest_float('sgd_momentum', *search_space['sgd_momentum'])
286
+ optimizer = optim.SGD(model.parameters(), lr=learning_rate,
287
+ momentum=momentum, weight_decay=weight_decay)
288
+
289
+ else: # RMSprop
290
+ optimizer = optim.RMSprop(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
291
+
292
+ criterion = nn.CrossEntropyLoss()
293
+
294
+ # Train model and return best validation accuracy
295
+ try:
296
+ return train_trial(
297
+ model=model,
298
+ optimizer=optimizer,
299
+ criterion=criterion,
300
+ train_loader=train_loader,
301
+ val_loader=val_loader,
302
+ n_epochs=n_epochs,
303
+ trial=trial
304
+ )
305
+
306
+ except RuntimeError as e:
307
+ # Catch architecture errors (e.g., dimension mismatches)
308
+ raise optuna.TrialPruned(f'RuntimeError with params: {trial.params} - {str(e)}')
309
+
310
+ except torch.cuda.OutOfMemoryError:
311
+ # Clear CUDA cache and skip this trial
312
+ torch.cuda.empty_cache()
313
+ raise optuna.TrialPruned(f'CUDA OOM with params: {trial.params}')
314
+
315
+ return objective
@@ -0,0 +1,311 @@
1
+ '''Plotting functions for image classification models.
2
+
3
+ This module provides visualization utilities for analyzing image classification models,
4
+ including sample images, learning curves, confusion matrices, and evaluation metrics.
5
+ '''
6
+
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ from torch.utils.data import Dataset
10
+
11
+
12
+ def plot_sample_images(
13
+ dataset: Dataset,
14
+ class_names: list[str],
15
+ nrows: int = 2,
16
+ ncols: int = 5,
17
+ figsize: tuple[float, float] | None = None
18
+ ) -> tuple[plt.Figure, np.ndarray]:
19
+ '''Plot sample images from a dataset.
20
+
21
+ Automatically handles both grayscale (1 channel) and RGB (3 channel) images.
22
+
23
+ Args:
24
+ dataset: PyTorch dataset containing (image, label) tuples.
25
+ class_names: List of class names for labeling.
26
+ nrows: Number of rows in the grid.
27
+ ncols: Number of columns in the grid.
28
+ figsize: Figure size (width, height). Defaults to (ncols*1.5, nrows*1.5).
29
+
30
+ Returns:
31
+ Tuple of (figure, axes array).
32
+ '''
33
+ if figsize is None:
34
+ figsize = (ncols * 1.5, nrows * 1.5)
35
+
36
+ fig, axes = plt.subplots(nrows=nrows, ncols=ncols, figsize=figsize)
37
+ axes = axes.flatten()
38
+
39
+ for i, ax in enumerate(axes):
40
+ # Get image and label from dataset
41
+ img, label = dataset[i]
42
+
43
+ # Unnormalize for plotting
44
+ img = img * 0.5 + 0.5
45
+ img = img.numpy()
46
+
47
+ # Handle grayscale vs RGB images
48
+ if img.shape[0] == 1:
49
+
50
+ # Grayscale: squeeze channel dimension
51
+ img = img.squeeze()
52
+ ax.imshow(img, cmap='gray')
53
+
54
+ else:
55
+
56
+ # RGB: transpose from (C, H, W) to (H, W, C)
57
+ img = np.transpose(img, (1, 2, 0))
58
+ ax.imshow(img)
59
+
60
+ ax.set_title(class_names[label])
61
+ ax.axis('off')
62
+
63
+ plt.tight_layout()
64
+
65
+ return fig, axes
66
+
67
+
68
+ def plot_learning_curves(
69
+ history: dict[str, list[float]],
70
+ figsize: tuple[float, float] = (10, 4)
71
+ ) -> tuple[plt.Figure, np.ndarray]:
72
+ '''Plot training and validation loss and accuracy curves.
73
+
74
+ Args:
75
+ history: Dictionary containing 'train_loss', 'val_loss',
76
+ 'train_accuracy', and 'val_accuracy' lists.
77
+ figsize: Figure size (width, height).
78
+
79
+ Returns:
80
+ Tuple of (figure, axes array).
81
+ '''
82
+ fig, axes = plt.subplots(1, 2, figsize=figsize)
83
+
84
+ axes[0].set_title('Loss')
85
+ axes[0].plot(history['train_loss'], label='Train')
86
+ axes[0].plot(history['val_loss'], label='Validation')
87
+ axes[0].set_xlabel('Epoch')
88
+ axes[0].set_ylabel('Loss (cross-entropy)')
89
+ axes[0].legend(loc='best')
90
+
91
+ axes[1].set_title('Accuracy')
92
+ axes[1].plot(history['train_accuracy'], label='Train')
93
+ axes[1].plot(history['val_accuracy'], label='Validation')
94
+ axes[1].set_xlabel('Epoch')
95
+ axes[1].set_ylabel('Accuracy (%)')
96
+ axes[1].legend(loc='best')
97
+
98
+ plt.tight_layout()
99
+
100
+ return fig, axes
101
+
102
+
103
+ def plot_confusion_matrix(
104
+ true_labels: np.ndarray,
105
+ predictions: np.ndarray,
106
+ class_names: list[str],
107
+ figsize: tuple[float, float] = (8, 8),
108
+ cmap: str = 'Blues'
109
+ ) -> tuple[plt.Figure, plt.Axes]:
110
+ '''Plot a confusion matrix heatmap.
111
+
112
+ Args:
113
+ true_labels: Array of true class labels.
114
+ predictions: Array of predicted class labels.
115
+ class_names: List of class names for labeling.
116
+ figsize: Figure size (width, height).
117
+ cmap: Colormap for the heatmap.
118
+
119
+ Returns:
120
+ Tuple of (figure, axes).
121
+ '''
122
+ from sklearn.metrics import confusion_matrix
123
+
124
+ cm = confusion_matrix(true_labels, predictions)
125
+
126
+ fig, ax = plt.subplots(figsize=figsize)
127
+
128
+ ax.set_title('Confusion matrix')
129
+ im = ax.imshow(cm, cmap=cmap)
130
+
131
+ # Add labels
132
+ ax.set_xticks(range(len(class_names)))
133
+ ax.set_yticks(range(len(class_names)))
134
+ ax.set_xticklabels(class_names, rotation=45, ha='right')
135
+ ax.set_yticklabels(class_names)
136
+ ax.set_xlabel('Predicted label')
137
+ ax.set_ylabel('True label')
138
+
139
+ # Add text annotations
140
+ for i in range(len(class_names)):
141
+ for j in range(len(class_names)):
142
+ color = 'white' if cm[i, j] > cm.max() / 2 else 'black'
143
+ ax.text(j, i, str(cm[i, j]), ha='center', va='center', color=color)
144
+
145
+ plt.tight_layout()
146
+
147
+ return fig, ax
148
+
149
+
150
+ def plot_class_probability_distributions(
151
+ all_probs: np.ndarray,
152
+ class_names: list[str],
153
+ nrows: int = 2,
154
+ ncols: int = 5,
155
+ figsize: tuple[float, float] = (12, 4),
156
+ bins: int = 50,
157
+ color: str = 'black'
158
+ ) -> tuple[plt.Figure, np.ndarray]:
159
+ '''Plot predicted probability distributions for each class.
160
+
161
+ Args:
162
+ all_probs: Array of shape (n_samples, n_classes) with predicted probabilities.
163
+ class_names: List of class names for labeling.
164
+ nrows: Number of rows in the subplot grid.
165
+ ncols: Number of columns in the subplot grid.
166
+ figsize: Figure size (width, height).
167
+ bins: Number of histogram bins.
168
+ color: Histogram bar color.
169
+
170
+ Returns:
171
+ Tuple of (figure, axes array).
172
+ '''
173
+ fig, axes = plt.subplots(nrows=nrows, ncols=ncols, figsize=figsize)
174
+
175
+ fig.suptitle('Predicted probability distributions by class', fontsize=14, y=1.02)
176
+ fig.supxlabel('Predicted probability', fontsize=12)
177
+ fig.supylabel('Count', fontsize=12)
178
+
179
+ axes = axes.flatten()
180
+
181
+ for i, (ax, class_name) in enumerate(zip(axes, class_names)):
182
+ # Get probabilities for this class across all samples
183
+ class_probs = all_probs[:, i]
184
+
185
+ # Plot histogram
186
+ ax.hist(class_probs, bins=bins, color=color)
187
+ ax.set_title(class_name)
188
+ ax.set_xlim(0, 1)
189
+
190
+ plt.tight_layout()
191
+
192
+ return fig, axes
193
+
194
+
195
+ def plot_evaluation_curves(
196
+ true_labels: np.ndarray,
197
+ all_probs: np.ndarray,
198
+ class_names: list[str],
199
+ figsize: tuple[float, float] = (12, 5)
200
+ ) -> tuple[plt.Figure, tuple[plt.Axes, plt.Axes]]:
201
+ '''Plot ROC and Precision-Recall curves for multi-class classification.
202
+
203
+ Args:
204
+ true_labels: Array of true class labels.
205
+ all_probs: Array of shape (n_samples, n_classes) with predicted probabilities.
206
+ class_names: List of class names for labeling.
207
+ figsize: Figure size (width, height).
208
+
209
+ Returns:
210
+ Tuple of (figure, (ax1, ax2)).
211
+ '''
212
+ from sklearn.metrics import roc_curve, auc, precision_recall_curve, average_precision_score
213
+ from sklearn.preprocessing import label_binarize
214
+
215
+ # Binarize true labels for one-vs-rest evaluation
216
+ y_test_bin = label_binarize(true_labels, classes=range(len(class_names)))
217
+
218
+ # Create figure with ROC and PR curves side by side
219
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize)
220
+
221
+ # Plot ROC curves for each class
222
+ ax1.set_title('ROC curves (one-vs-rest)')
223
+
224
+ for i, class_name in enumerate(class_names):
225
+ fpr, tpr, _ = roc_curve(y_test_bin[:, i], all_probs[:, i])
226
+ roc_auc = auc(fpr, tpr)
227
+ ax1.plot(fpr, tpr, label=class_name)
228
+
229
+ ax1.plot([0, 1], [0, 1], 'k--', label='random classifier')
230
+ ax1.set_xlabel('False positive rate')
231
+ ax1.set_ylabel('True positive rate')
232
+ ax1.legend(loc='lower right', fontsize=12)
233
+ ax1.set_xlim([0, 1])
234
+ ax1.set_ylim([0, 1.05])
235
+
236
+ # Plot Precision-Recall curves for each class
237
+ ax2.set_title('Precision-recall curves (one-vs-rest)')
238
+
239
+ for i, class_name in enumerate(class_names):
240
+ precision, recall, _ = precision_recall_curve(y_test_bin[:, i], all_probs[:, i])
241
+ ap = average_precision_score(y_test_bin[:, i], all_probs[:, i])
242
+ ax2.plot(recall, precision)
243
+
244
+ # Random classifier baseline (horizontal line at class prevalence = 1/num_classes)
245
+ baseline = 1 / len(class_names)
246
+ ax2.axhline(y=baseline, color='k', linestyle='--')
247
+
248
+ ax2.set_xlabel('Recall')
249
+ ax2.set_ylabel('Precision')
250
+ ax2.set_xlim([0, 1])
251
+ ax2.set_ylim([0, 1.05])
252
+
253
+ plt.tight_layout()
254
+
255
+ return fig, (ax1, ax2)
256
+
257
+
258
+ def plot_optimization_results(
259
+ study,
260
+ figsize: tuple[float, float] = (12, 4)
261
+ ) -> tuple[plt.Figure, np.ndarray]:
262
+ '''Plot Optuna optimization history and hyperparameter importance.
263
+
264
+ Args:
265
+ study: Optuna study object with completed trials.
266
+ figsize: Figure size (width, height).
267
+
268
+ Returns:
269
+ Tuple of (figure, axes array).
270
+ '''
271
+ import optuna
272
+
273
+ fig, axes = plt.subplots(1, 2, figsize=figsize)
274
+
275
+ # Optimization history
276
+ axes[0].set_title('Optimization History')
277
+
278
+ trial_numbers = [t.number for t in study.trials if t.value is not None]
279
+ trial_values = [t.value for t in study.trials if t.value is not None]
280
+
281
+ axes[0].plot(trial_numbers, trial_values, 'ko-', alpha=0.6)
282
+ axes[0].axhline(
283
+ y=study.best_value,
284
+ color='r', linestyle='--', label=f'Best: {study.best_value:.2f}%'
285
+ )
286
+ axes[0].set_xlabel('Trial')
287
+ axes[0].set_ylabel('Validation Accuracy (%)')
288
+ axes[0].legend()
289
+
290
+ # Hyperparameter importance (if enough trials completed)
291
+ axes[1].set_title('Hyperparameter Importance')
292
+ completed_trials = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
293
+
294
+ if len(completed_trials) >= 5:
295
+ importance = optuna.importance.get_param_importances(study)
296
+ params = list(importance.keys())
297
+ values = list(importance.values())
298
+
299
+ axes[1].set_xlabel('Importance')
300
+ axes[1].barh(params, values, color='black')
301
+
302
+ else:
303
+ axes[1].text(
304
+ 0.5, 0.5,
305
+ 'Not enough completed trials\nfor importance analysis',
306
+ ha='center', va='center', transform=axes[1].transAxes
307
+ )
308
+
309
+ plt.tight_layout()
310
+
311
+ return fig, axes