deepecgkit 0.1.0__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.
Files changed (61) hide show
  1. deepecgkit/__init__.py +42 -0
  2. deepecgkit/__main__.py +6 -0
  3. deepecgkit/cli/__init__.py +61 -0
  4. deepecgkit/cli/commands/__init__.py +16 -0
  5. deepecgkit/cli/commands/evaluate.py +83 -0
  6. deepecgkit/cli/commands/info.py +73 -0
  7. deepecgkit/cli/commands/predict.py +94 -0
  8. deepecgkit/cli/commands/train.py +331 -0
  9. deepecgkit/cli/config.py +23 -0
  10. deepecgkit/cli/evaluation.py +251 -0
  11. deepecgkit/cli/logger.py +26 -0
  12. deepecgkit/cli/main.py +429 -0
  13. deepecgkit/cli/registry.py +32 -0
  14. deepecgkit/datasets/__init__.py +33 -0
  15. deepecgkit/datasets/af_classification.py +301 -0
  16. deepecgkit/datasets/base.py +146 -0
  17. deepecgkit/datasets/ltafdb.py +363 -0
  18. deepecgkit/datasets/mitbih_afdb.py +287 -0
  19. deepecgkit/datasets/modules.py +222 -0
  20. deepecgkit/datasets/preprocessing.py +208 -0
  21. deepecgkit/datasets/ptbxl.py +567 -0
  22. deepecgkit/datasets/splitting.py +225 -0
  23. deepecgkit/datasets/unified_af.py +316 -0
  24. deepecgkit/evaluation/__init__.py +40 -0
  25. deepecgkit/evaluation/evaluator.py +398 -0
  26. deepecgkit/evaluation/metrics.py +164 -0
  27. deepecgkit/evaluation/visualization.py +285 -0
  28. deepecgkit/models/__init__.py +51 -0
  29. deepecgkit/models/af_classifier.py +177 -0
  30. deepecgkit/models/convnext_v2_1d.py +183 -0
  31. deepecgkit/models/crnn.py +117 -0
  32. deepecgkit/models/deep_res_cnn.py +215 -0
  33. deepecgkit/models/ecg_dualnet.py +230 -0
  34. deepecgkit/models/fcn_wang.py +88 -0
  35. deepecgkit/models/gru_model.py +99 -0
  36. deepecgkit/models/inception_time.py +197 -0
  37. deepecgkit/models/kanres_wide_x.py +150 -0
  38. deepecgkit/models/kanres_x.py +126 -0
  39. deepecgkit/models/lstm_model.py +96 -0
  40. deepecgkit/models/mamba1d.py +232 -0
  41. deepecgkit/models/medformer.py +229 -0
  42. deepecgkit/models/resnet1d.py +189 -0
  43. deepecgkit/models/resnet1d_wang.py +182 -0
  44. deepecgkit/models/se_resnet1d.py +232 -0
  45. deepecgkit/models/simple_cnn.py +82 -0
  46. deepecgkit/models/tcn.py +178 -0
  47. deepecgkit/models/transformer_ecg.py +127 -0
  48. deepecgkit/models/xresnet1d.py +265 -0
  49. deepecgkit/models/xresnet1d_benchmark.py +227 -0
  50. deepecgkit/registry.py +97 -0
  51. deepecgkit/training/__init__.py +9 -0
  52. deepecgkit/training/train.py +171 -0
  53. deepecgkit/utils/__init__.py +72 -0
  54. deepecgkit/utils/download.py +123 -0
  55. deepecgkit/utils/weights.py +107 -0
  56. deepecgkit-0.1.0.dist-info/METADATA +99 -0
  57. deepecgkit-0.1.0.dist-info/RECORD +61 -0
  58. deepecgkit-0.1.0.dist-info/WHEEL +5 -0
  59. deepecgkit-0.1.0.dist-info/entry_points.txt +2 -0
  60. deepecgkit-0.1.0.dist-info/licenses/LICENSE +201 -0
  61. deepecgkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,331 @@
1
+ """Train and resume commands."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Optional
6
+
7
+ import matplotlib
8
+ import pytorch_lightning as pl
9
+ import torch
10
+ from pytorch_lightning.callbacks import EarlyStopping, ModelCheckpoint
11
+ from pytorch_lightning.loggers import CSVLogger
12
+
13
+ from deepecgkit.cli.evaluation import _save_multi_label_evaluation, _save_single_label_evaluation
14
+ from deepecgkit.cli.logger import CLILogger
15
+ from deepecgkit.cli.registry import (
16
+ DATASET_NAMES,
17
+ DATASET_NUM_CLASSES,
18
+ MODEL_NAMES,
19
+ MODEL_WEIGHTS,
20
+ _get_dataset_registry,
21
+ _get_model_registry,
22
+ )
23
+ from deepecgkit.datasets import ECGDataModule
24
+ from deepecgkit.evaluation.visualization import plot_training_curves
25
+ from deepecgkit.training import ECGLitModel
26
+ from deepecgkit.utils.weights import load_pretrained_weights
27
+
28
+
29
+ def train(
30
+ model_name: str,
31
+ dataset_name: str,
32
+ data_dir: Optional[str] = None,
33
+ output_dir: Optional[str] = None,
34
+ weights: Optional[str] = None,
35
+ epochs: int = 50,
36
+ batch_size: int = 32,
37
+ learning_rate: float = 1e-3,
38
+ val_split: float = 0.2,
39
+ test_split: float = 0.1,
40
+ num_workers: int = 4,
41
+ accelerator: str = "auto",
42
+ devices: int = 1,
43
+ force_download: bool = False,
44
+ early_stopping_patience: int = 10,
45
+ seed: int = 42,
46
+ multi_label: bool = False,
47
+ sampling_rate: Optional[int] = None,
48
+ binary_classification: bool = False,
49
+ normalization: Optional[str] = None,
50
+ logger: Optional[CLILogger] = None,
51
+ ) -> int:
52
+ """Train a model on a dataset."""
53
+ logger = logger or CLILogger()
54
+
55
+ model_registry = _get_model_registry()
56
+ dataset_registry = _get_dataset_registry()
57
+
58
+ if model_name not in model_registry:
59
+ logger.error(f"Unknown model: {model_name}")
60
+ logger.info(f"Available models: {', '.join(MODEL_NAMES)}")
61
+ return 1
62
+
63
+ if dataset_name not in dataset_registry:
64
+ logger.error(f"Unknown dataset: {dataset_name}")
65
+ logger.info(f"Available datasets: {', '.join(DATASET_NAMES)}")
66
+ return 1
67
+
68
+ pl.seed_everything(seed)
69
+
70
+ if output_dir is None:
71
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
72
+ output_path = Path("runs") / f"{timestamp}-{model_name}-{dataset_name}"
73
+ else:
74
+ output_path = Path(output_dir)
75
+ output_path.mkdir(parents=True, exist_ok=True)
76
+
77
+ dataset_class = dataset_registry[dataset_name]
78
+ model_class = model_registry[model_name]
79
+
80
+ dataset_kwargs: Dict[str, Any] = {"force_download": force_download}
81
+ if data_dir:
82
+ dataset_kwargs["data_dir"] = data_dir
83
+
84
+ af_datasets = {"mitbih-afdb", "ltafdb", "unified-af"}
85
+
86
+ if dataset_name == "ptbxl":
87
+ dataset_kwargs["task"] = "superclass"
88
+ dataset_kwargs["multi_label"] = multi_label
89
+ if sampling_rate is not None:
90
+ dataset_kwargs["sampling_rate"] = sampling_rate
91
+ dataset_kwargs["use_high_resolution"] = sampling_rate > 100
92
+ if normalization is not None:
93
+ dataset_kwargs["normalization"] = normalization
94
+
95
+ if dataset_name in af_datasets:
96
+ dataset_kwargs["binary_classification"] = binary_classification
97
+
98
+ try:
99
+ logger.info(f"Creating data module with {dataset_name} dataset...")
100
+ data_module = ECGDataModule(
101
+ dataset_class=dataset_class,
102
+ batch_size=batch_size,
103
+ num_workers=num_workers,
104
+ val_split=val_split,
105
+ test_split=test_split,
106
+ seed=seed,
107
+ stratify=True,
108
+ download=True,
109
+ dataset_kwargs=dataset_kwargs,
110
+ )
111
+ data_module.setup(stage="fit")
112
+ except FileNotFoundError:
113
+ logger.error(f"Dataset '{dataset_name}' not found.")
114
+ if data_dir:
115
+ logger.info(f"Verify the data directory exists: {data_dir}")
116
+ return 1
117
+ except Exception as e:
118
+ logger.error(f"Failed to create data module: {e}")
119
+ return 1
120
+
121
+ sample_signal, _ = data_module.dataset[0]
122
+ input_channels = sample_signal.shape[0]
123
+ num_classes = DATASET_NUM_CLASSES.get(dataset_name, 4)
124
+ if binary_classification and dataset_name in af_datasets:
125
+ num_classes = 2
126
+
127
+ # BCE loss expects a single logit output for binary classification
128
+ model_output_size = (
129
+ 1 if (binary_classification and dataset_name in af_datasets) else num_classes
130
+ )
131
+
132
+ pos_weight = None
133
+ if multi_label:
134
+ try:
135
+ train_ds = data_module.train_dataset
136
+ all_labels = torch.stack([train_ds[i][1] for i in range(len(train_ds))])
137
+ pos_counts = all_labels.sum(dim=0)
138
+ neg_counts = len(all_labels) - pos_counts
139
+ pos_weight = (neg_counts / pos_counts.clamp(min=1)).tolist()
140
+ logger.info(f"Multi-label pos_weight: {[f'{w:.2f}' for w in pos_weight]}")
141
+ except Exception as e:
142
+ logger.warning(f"Could not compute pos_weight: {e}")
143
+ pos_weight = None
144
+
145
+ logger.info(
146
+ f"Creating {model_name} model with {input_channels} input channel(s) and {num_classes} output classes..."
147
+ )
148
+ model = model_class(input_channels=input_channels, output_size=model_output_size)
149
+
150
+ if weights:
151
+ try:
152
+ weights_path = Path(weights)
153
+ if weights_path.exists():
154
+ logger.info(f"Loading weights from {weights}...")
155
+ state_dict = torch.load(weights, map_location="cpu", weights_only=True)
156
+ elif any(weights in w for ws in MODEL_WEIGHTS.values() for w in ws):
157
+ logger.info(f"Loading pretrained weights '{weights}'...")
158
+ state_dict = load_pretrained_weights(weights, map_location="cpu")
159
+ else:
160
+ all_weights = [w for ws in MODEL_WEIGHTS.values() for w in ws]
161
+ logger.error(f"Weights '{weights}' not found.")
162
+ logger.info(f"Available pretrained weights: {', '.join(all_weights)}")
163
+ logger.info("Or provide a valid file path to a checkpoint.")
164
+ return 1
165
+ model.load_state_dict(state_dict, strict=False)
166
+ logger.info("Weights loaded successfully.")
167
+ except Exception as e:
168
+ logger.error(f"Failed to load weights: {e}")
169
+ return 1
170
+
171
+ train_config = {
172
+ "learning_rate": learning_rate,
173
+ "scheduler": {"factor": 0.5, "patience": 5},
174
+ "binary_classification": num_classes == 2,
175
+ "multi_label": multi_label,
176
+ "task_type": "classification",
177
+ "pos_weight": pos_weight,
178
+ }
179
+
180
+ lit_model = ECGLitModel(model=model, train_config=train_config)
181
+
182
+ checkpoint_callback = ModelCheckpoint(
183
+ dirpath=output_path / "checkpoints",
184
+ filename=f"{model_name}-{dataset_name}-{{epoch:02d}}-{{val_loss:.4f}}",
185
+ monitor="val_loss",
186
+ mode="min",
187
+ save_top_k=3,
188
+ )
189
+
190
+ early_stopping = EarlyStopping(
191
+ monitor="val_loss",
192
+ patience=early_stopping_patience,
193
+ mode="min",
194
+ )
195
+
196
+ csv_logger = CSVLogger(save_dir=output_path, name="training_logs")
197
+
198
+ trainer = pl.Trainer(
199
+ max_epochs=epochs,
200
+ accelerator=accelerator,
201
+ devices=devices,
202
+ callbacks=[checkpoint_callback, early_stopping],
203
+ logger=csv_logger,
204
+ default_root_dir=output_path,
205
+ enable_progress_bar=not logger.quiet,
206
+ )
207
+
208
+ try:
209
+ logger.info(f"Starting training for {epochs} epochs...")
210
+ trainer.fit(lit_model, data_module)
211
+
212
+ if data_module.test_dataloader() is not None:
213
+ logger.info("Running test evaluation...")
214
+ trainer.test(lit_model, data_module)
215
+
216
+ logger.info(f"Training complete. Checkpoints saved to {output_path}")
217
+
218
+ try:
219
+ matplotlib.use("Agg")
220
+
221
+ class_names = None
222
+ if hasattr(data_module, "dataset") and hasattr(data_module.dataset, "class_names"):
223
+ class_names = data_module.dataset.class_names
224
+
225
+ metrics_file = Path(csv_logger.log_dir) / "metrics.csv"
226
+ if metrics_file.exists():
227
+ plot_training_curves(
228
+ str(metrics_file),
229
+ save_dir=str(output_path),
230
+ )
231
+ logger.info(f"Loss plot saved to {output_path / 'loss.png'}")
232
+ logger.info(f"Accuracy plot saved to {output_path / 'accuracy.png'}")
233
+
234
+ y_pred, y_true, y_prob = lit_model.get_test_results()
235
+
236
+ if y_pred is not None:
237
+ is_multi_label = y_true.ndim > 1
238
+
239
+ if is_multi_label:
240
+ _save_multi_label_evaluation(
241
+ y_true,
242
+ y_pred,
243
+ y_prob,
244
+ model_name,
245
+ dataset_name,
246
+ class_names,
247
+ output_path,
248
+ logger,
249
+ )
250
+ else:
251
+ _save_single_label_evaluation(
252
+ y_true,
253
+ y_pred,
254
+ y_prob,
255
+ model_name,
256
+ dataset_name,
257
+ class_names,
258
+ output_path,
259
+ logger,
260
+ )
261
+
262
+ except Exception as e:
263
+ logger.warning(f"Post-training evaluation failed: {e}")
264
+
265
+ return 0
266
+ except KeyboardInterrupt:
267
+ logger.warning("Training interrupted by user.")
268
+ return 130
269
+ except Exception as e:
270
+ logger.error(f"Training failed: {e}")
271
+ return 1
272
+
273
+
274
+ def resume(
275
+ checkpoint: str,
276
+ epochs: Optional[int] = None,
277
+ output_dir: Optional[str] = None,
278
+ accelerator: str = "auto",
279
+ devices: int = 1,
280
+ early_stopping_patience: int = 10,
281
+ logger: Optional[CLILogger] = None,
282
+ ) -> int:
283
+ """Resume training from a checkpoint."""
284
+ logger = logger or CLILogger()
285
+
286
+ checkpoint_path = Path(checkpoint)
287
+ if not checkpoint_path.exists():
288
+ logger.error(f"Checkpoint not found: {checkpoint}")
289
+ return 1
290
+
291
+ try:
292
+ logger.info(f"Loading checkpoint from {checkpoint}...")
293
+ lit_model = ECGLitModel.load_from_checkpoint(checkpoint)
294
+
295
+ output_path = Path(output_dir) if output_dir else checkpoint_path.parent
296
+ output_path.mkdir(parents=True, exist_ok=True)
297
+
298
+ checkpoint_callback = ModelCheckpoint(
299
+ dirpath=output_path,
300
+ filename="resumed-{epoch:02d}-{val_loss:.4f}",
301
+ monitor="val_loss",
302
+ mode="min",
303
+ save_top_k=3,
304
+ )
305
+
306
+ early_stopping = EarlyStopping(
307
+ monitor="val_loss",
308
+ patience=early_stopping_patience,
309
+ mode="min",
310
+ )
311
+
312
+ trainer_kwargs: Dict[str, Any] = {
313
+ "accelerator": accelerator,
314
+ "devices": devices,
315
+ "callbacks": [checkpoint_callback, early_stopping],
316
+ "default_root_dir": output_path,
317
+ "enable_progress_bar": not logger.quiet,
318
+ }
319
+ if epochs:
320
+ trainer_kwargs["max_epochs"] = epochs
321
+
322
+ trainer = pl.Trainer(**trainer_kwargs)
323
+
324
+ logger.info("Resuming training...")
325
+ trainer.fit(lit_model, ckpt_path=checkpoint)
326
+
327
+ logger.info(f"Training complete. Checkpoints saved to {output_path}")
328
+ return 0
329
+ except Exception as e:
330
+ logger.error(f"Failed to resume training: {e}")
331
+ return 1
@@ -0,0 +1,23 @@
1
+ """Configuration file loading for the CLI."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+
7
+ import yaml
8
+
9
+
10
+ def load_config(config_path: str) -> Dict[str, Any]:
11
+ """Load configuration from YAML or JSON file."""
12
+ path = Path(config_path)
13
+ if not path.exists():
14
+ raise FileNotFoundError(f"Config file not found: {config_path}")
15
+
16
+ content = path.read_text()
17
+
18
+ if path.suffix in (".yaml", ".yml"):
19
+ return yaml.safe_load(content)
20
+ elif path.suffix == ".json":
21
+ return json.loads(content)
22
+ else:
23
+ raise ValueError(f"Unsupported config format: {path.suffix}. Use .yaml, .yml, or .json")
@@ -0,0 +1,251 @@
1
+ """Evaluation artifact saving helpers for the CLI."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import matplotlib.pyplot as plt
7
+ import numpy as np
8
+ import pandas as pd
9
+ import seaborn as sns
10
+ from sklearn.calibration import calibration_curve
11
+ from sklearn.metrics import confusion_matrix, precision_recall_fscore_support, roc_auc_score
12
+
13
+ from deepecgkit.cli.logger import CLILogger
14
+ from deepecgkit.evaluation.metrics import (
15
+ calculate_classification_metrics,
16
+ confusion_matrix_analysis,
17
+ )
18
+ from deepecgkit.evaluation.visualization import (
19
+ plot_calibration_curve,
20
+ plot_confusion_matrix,
21
+ )
22
+
23
+
24
+ def _save_single_label_evaluation(
25
+ y_true: np.ndarray,
26
+ y_pred: np.ndarray,
27
+ y_prob: np.ndarray,
28
+ model_name: str,
29
+ dataset_name: str,
30
+ class_names: Optional[list],
31
+ output_path: Path,
32
+ logger: CLILogger,
33
+ ) -> None:
34
+ """Save evaluation artifacts for single-label classification."""
35
+ plot_confusion_matrix(
36
+ y_true,
37
+ y_pred,
38
+ class_names=class_names,
39
+ title=f"{model_name} on {dataset_name}",
40
+ save_path=str(output_path / "confusion_matrix.png"),
41
+ )
42
+ logger.info(f"Confusion matrix saved to {output_path / 'confusion_matrix.png'}")
43
+
44
+ plot_calibration_curve(
45
+ y_true,
46
+ y_prob,
47
+ class_names=class_names,
48
+ title=f"{model_name} on {dataset_name} - Calibration",
49
+ save_dir=str(output_path),
50
+ )
51
+ logger.info(f"Calibration curve saved to {output_path / 'calibration_curve.png'}")
52
+ logger.info(f"Prediction distribution saved to {output_path / 'prediction_distribution.png'}")
53
+
54
+ display_names = class_names or [f"Class {i}" for i in range(y_prob.shape[1])]
55
+ predictions_path = output_path / "predictions.txt"
56
+ with open(predictions_path, "w") as f:
57
+ header = "index\ttrue_label\tpredicted_label"
58
+ for name in display_names:
59
+ header += f"\tprob_{name}"
60
+ f.write(header + "\n")
61
+ for idx in range(len(y_true)):
62
+ row = f"{idx}\t{y_true[idx]}\t{y_pred[idx]}"
63
+ for c in range(y_prob.shape[1]):
64
+ row += f"\t{y_prob[idx, c]:.6f}"
65
+ f.write(row + "\n")
66
+ logger.info(f"Test predictions saved to {predictions_path}")
67
+
68
+ cm_results = confusion_matrix_analysis(y_true, y_pred)
69
+ overall_acc = (y_pred == y_true).mean()
70
+
71
+ logger.info("\nTest Results:")
72
+ logger.info("=" * 60)
73
+ logger.info(f" Overall Accuracy: {overall_acc:.4f}")
74
+ logger.info(f" Macro Precision: {cm_results['macro_precision']:.4f}")
75
+ logger.info(f" Macro Recall: {cm_results['macro_recall']:.4f}")
76
+ logger.info(f" Macro F1: {cm_results['macro_f1']:.4f}")
77
+ logger.info("")
78
+ logger.info(" Per-Class Results:")
79
+ logger.info(f" {'Class':<15} {'Precision':>10} {'Recall':>10} {'F1':>10}")
80
+ logger.info(f" {'-' * 45}")
81
+
82
+ display_names = class_names or [f"Class {i}" for i in range(len(cm_results["per_class_f1"]))]
83
+ for i, name in enumerate(display_names):
84
+ logger.info(
85
+ f" {name:<15} "
86
+ f"{cm_results['per_class_precision'][i]:>10.4f} "
87
+ f"{cm_results['per_class_recall'][i]:>10.4f} "
88
+ f"{cm_results['per_class_f1'][i]:>10.4f}"
89
+ )
90
+ logger.info("=" * 60)
91
+
92
+ cls_metrics = calculate_classification_metrics(y_true, y_prob)
93
+ metrics_df = pd.DataFrame(
94
+ [
95
+ {
96
+ "model": model_name,
97
+ "dataset": dataset_name,
98
+ "accuracy": cls_metrics.get("accuracy", np.nan),
99
+ "precision": cls_metrics.get("precision", np.nan),
100
+ "recall": cls_metrics.get("recall", np.nan),
101
+ "f1": cls_metrics.get("f1", np.nan),
102
+ "auroc": cls_metrics.get("auc", np.nan),
103
+ "mcc": cls_metrics.get("mcc", np.nan),
104
+ }
105
+ ]
106
+ )
107
+ metrics_csv_path = output_path / "classification_metrics.csv"
108
+ metrics_df.to_csv(metrics_csv_path, index=False)
109
+ logger.info(f"Classification metrics saved to {metrics_csv_path}")
110
+
111
+
112
+ def _save_multi_label_evaluation(
113
+ y_true: np.ndarray,
114
+ y_pred: np.ndarray,
115
+ y_prob: np.ndarray,
116
+ model_name: str,
117
+ dataset_name: str,
118
+ class_names: Optional[list],
119
+ output_path: Path,
120
+ logger: CLILogger,
121
+ ) -> None:
122
+ """Save evaluation artifacts for multi-label classification."""
123
+
124
+ num_classes = y_true.shape[1]
125
+ display_names = class_names or [f"Class {i}" for i in range(num_classes)]
126
+
127
+ # Per-class predictions file
128
+ predictions_path = output_path / "predictions.txt"
129
+ with open(predictions_path, "w") as f:
130
+ header = "index"
131
+ for name in display_names:
132
+ header += f"\ttrue_{name}\tpred_{name}\tprob_{name}"
133
+ f.write(header + "\n")
134
+ for idx in range(len(y_true)):
135
+ row = str(idx)
136
+ for c in range(num_classes):
137
+ row += f"\t{y_true[idx, c]}\t{y_pred[idx, c]}\t{y_prob[idx, c]:.6f}"
138
+ f.write(row + "\n")
139
+ logger.info(f"Test predictions saved to {predictions_path}")
140
+
141
+ # Per-class binary confusion matrices
142
+ ncols = min(num_classes, 3)
143
+ nrows = (num_classes + ncols - 1) // ncols
144
+ fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 4 * nrows))
145
+ axes_flat = np.array(axes).flatten() if num_classes > 1 else [axes]
146
+
147
+ for i in range(num_classes):
148
+ cm = confusion_matrix(y_true[:, i], y_pred[:, i], labels=[0, 1])
149
+ sns.heatmap(
150
+ cm,
151
+ annot=True,
152
+ fmt="d",
153
+ cmap="Blues",
154
+ xticklabels=["Neg", "Pos"],
155
+ yticklabels=["Neg", "Pos"],
156
+ ax=axes_flat[i],
157
+ )
158
+ axes_flat[i].set_title(display_names[i])
159
+ axes_flat[i].set_ylabel("True")
160
+ axes_flat[i].set_xlabel("Predicted")
161
+
162
+ for i in range(num_classes, len(axes_flat)):
163
+ axes_flat[i].set_visible(False)
164
+
165
+ fig.suptitle(f"{model_name} on {dataset_name} - Per-Class Confusion Matrices")
166
+ fig.tight_layout()
167
+ fig.savefig(str(output_path / "confusion_matrix.png"), dpi=300, bbox_inches="tight")
168
+ plt.close(fig)
169
+ logger.info(f"Confusion matrices saved to {output_path / 'confusion_matrix.png'}")
170
+
171
+ # Calibration curves (per-class, using multi-hot targets directly)
172
+ plt.figure(figsize=(8, 6))
173
+ for i in range(num_classes):
174
+ if y_true[:, i].sum() == 0:
175
+ continue
176
+ try:
177
+ fraction_pos, mean_pred = calibration_curve(
178
+ y_true[:, i],
179
+ y_prob[:, i],
180
+ n_bins=10,
181
+ strategy="uniform",
182
+ )
183
+ plt.plot(mean_pred, fraction_pos, "s-", label=display_names[i])
184
+ except ValueError:
185
+ continue
186
+ plt.plot([0, 1], [0, 1], "k--", label="Perfectly calibrated")
187
+ plt.xlabel("Mean Predicted Probability")
188
+ plt.ylabel("Fraction of Positives")
189
+ plt.title(f"{model_name} on {dataset_name} - Calibration")
190
+ plt.legend(loc="lower right", fontsize="small")
191
+ plt.grid(True, alpha=0.3)
192
+ plt.tight_layout()
193
+ plt.savefig(str(output_path / "calibration_curve.png"), dpi=300, bbox_inches="tight")
194
+ plt.close()
195
+ logger.info(f"Calibration curve saved to {output_path / 'calibration_curve.png'}")
196
+
197
+ # Per-class metrics
198
+ precision, recall, f1, _ = precision_recall_fscore_support(
199
+ y_true,
200
+ y_pred,
201
+ average=None,
202
+ zero_division=0,
203
+ )
204
+ macro_p, macro_r, macro_f1, _ = precision_recall_fscore_support(
205
+ y_true,
206
+ y_pred,
207
+ average="macro",
208
+ zero_division=0,
209
+ )
210
+ sample_acc = (y_pred == y_true).all(axis=1).mean()
211
+ label_acc = (y_pred == y_true).mean()
212
+
213
+ try:
214
+ auroc = roc_auc_score(y_true, y_prob, average="macro")
215
+ except ValueError:
216
+ auroc = np.nan
217
+
218
+ logger.info("\nTest Results (Multi-Label):")
219
+ logger.info("=" * 60)
220
+ logger.info(f" Exact Match Accuracy: {sample_acc:.4f}")
221
+ logger.info(f" Label Accuracy: {label_acc:.4f}")
222
+ logger.info(f" Macro Precision: {macro_p:.4f}")
223
+ logger.info(f" Macro Recall: {macro_r:.4f}")
224
+ logger.info(f" Macro F1: {macro_f1:.4f}")
225
+ logger.info(f" Macro AUROC: {auroc:.4f}")
226
+ logger.info("")
227
+ logger.info(" Per-Class Results:")
228
+ logger.info(f" {'Class':<15} {'Precision':>10} {'Recall':>10} {'F1':>10}")
229
+ logger.info(f" {'-' * 45}")
230
+
231
+ for i, name in enumerate(display_names):
232
+ logger.info(f" {name:<15} {precision[i]:>10.4f} {recall[i]:>10.4f} {f1[i]:>10.4f}")
233
+ logger.info("=" * 60)
234
+
235
+ metrics_df = pd.DataFrame(
236
+ [
237
+ {
238
+ "model": model_name,
239
+ "dataset": dataset_name,
240
+ "exact_match_accuracy": sample_acc,
241
+ "label_accuracy": label_acc,
242
+ "precision": macro_p,
243
+ "recall": macro_r,
244
+ "f1": macro_f1,
245
+ "auroc": auroc,
246
+ }
247
+ ]
248
+ )
249
+ metrics_csv_path = output_path / "classification_metrics.csv"
250
+ metrics_df.to_csv(metrics_csv_path, index=False)
251
+ logger.info(f"Classification metrics saved to {metrics_csv_path}")
@@ -0,0 +1,26 @@
1
+ """CLI logger with verbosity control."""
2
+
3
+ import sys
4
+
5
+
6
+ class CLILogger:
7
+ """Simple logger with verbosity control."""
8
+
9
+ def __init__(self, verbose: bool = False, quiet: bool = False):
10
+ self.verbose = verbose
11
+ self.quiet = quiet
12
+
13
+ def info(self, msg: str) -> None:
14
+ if not self.quiet:
15
+ print(msg)
16
+
17
+ def debug(self, msg: str) -> None:
18
+ if self.verbose and not self.quiet:
19
+ print(f"[DEBUG] {msg}")
20
+
21
+ def error(self, msg: str) -> None:
22
+ print(f"[ERROR] {msg}", file=sys.stderr)
23
+
24
+ def warning(self, msg: str) -> None:
25
+ if not self.quiet:
26
+ print(f"[WARNING] {msg}", file=sys.stderr)