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.
- image_classification_tools/README.md +152 -0
- image_classification_tools/__init__.py +6 -0
- image_classification_tools/pytorch/__init__.py +46 -0
- image_classification_tools/pytorch/data.py +416 -0
- image_classification_tools/pytorch/evaluation.py +38 -0
- image_classification_tools/pytorch/hyperparameter_optimization.py +315 -0
- image_classification_tools/pytorch/plotting.py +311 -0
- image_classification_tools/pytorch/training.py +256 -0
- image_classification_tools/tensorflow/__init__.py +0 -0
- image_classification_tools-0.5.6.dist-info/METADATA +187 -0
- image_classification_tools-0.5.6.dist-info/RECORD +13 -0
- image_classification_tools-0.5.6.dist-info/WHEEL +4 -0
- image_classification_tools-0.5.6.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|