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.
- deepecgkit/__init__.py +42 -0
- deepecgkit/__main__.py +6 -0
- deepecgkit/cli/__init__.py +61 -0
- deepecgkit/cli/commands/__init__.py +16 -0
- deepecgkit/cli/commands/evaluate.py +83 -0
- deepecgkit/cli/commands/info.py +73 -0
- deepecgkit/cli/commands/predict.py +94 -0
- deepecgkit/cli/commands/train.py +331 -0
- deepecgkit/cli/config.py +23 -0
- deepecgkit/cli/evaluation.py +251 -0
- deepecgkit/cli/logger.py +26 -0
- deepecgkit/cli/main.py +429 -0
- deepecgkit/cli/registry.py +32 -0
- deepecgkit/datasets/__init__.py +33 -0
- deepecgkit/datasets/af_classification.py +301 -0
- deepecgkit/datasets/base.py +146 -0
- deepecgkit/datasets/ltafdb.py +363 -0
- deepecgkit/datasets/mitbih_afdb.py +287 -0
- deepecgkit/datasets/modules.py +222 -0
- deepecgkit/datasets/preprocessing.py +208 -0
- deepecgkit/datasets/ptbxl.py +567 -0
- deepecgkit/datasets/splitting.py +225 -0
- deepecgkit/datasets/unified_af.py +316 -0
- deepecgkit/evaluation/__init__.py +40 -0
- deepecgkit/evaluation/evaluator.py +398 -0
- deepecgkit/evaluation/metrics.py +164 -0
- deepecgkit/evaluation/visualization.py +285 -0
- deepecgkit/models/__init__.py +51 -0
- deepecgkit/models/af_classifier.py +177 -0
- deepecgkit/models/convnext_v2_1d.py +183 -0
- deepecgkit/models/crnn.py +117 -0
- deepecgkit/models/deep_res_cnn.py +215 -0
- deepecgkit/models/ecg_dualnet.py +230 -0
- deepecgkit/models/fcn_wang.py +88 -0
- deepecgkit/models/gru_model.py +99 -0
- deepecgkit/models/inception_time.py +197 -0
- deepecgkit/models/kanres_wide_x.py +150 -0
- deepecgkit/models/kanres_x.py +126 -0
- deepecgkit/models/lstm_model.py +96 -0
- deepecgkit/models/mamba1d.py +232 -0
- deepecgkit/models/medformer.py +229 -0
- deepecgkit/models/resnet1d.py +189 -0
- deepecgkit/models/resnet1d_wang.py +182 -0
- deepecgkit/models/se_resnet1d.py +232 -0
- deepecgkit/models/simple_cnn.py +82 -0
- deepecgkit/models/tcn.py +178 -0
- deepecgkit/models/transformer_ecg.py +127 -0
- deepecgkit/models/xresnet1d.py +265 -0
- deepecgkit/models/xresnet1d_benchmark.py +227 -0
- deepecgkit/registry.py +97 -0
- deepecgkit/training/__init__.py +9 -0
- deepecgkit/training/train.py +171 -0
- deepecgkit/utils/__init__.py +72 -0
- deepecgkit/utils/download.py +123 -0
- deepecgkit/utils/weights.py +107 -0
- deepecgkit-0.1.0.dist-info/METADATA +99 -0
- deepecgkit-0.1.0.dist-info/RECORD +61 -0
- deepecgkit-0.1.0.dist-info/WHEEL +5 -0
- deepecgkit-0.1.0.dist-info/entry_points.txt +2 -0
- deepecgkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|
deepecgkit/cli/config.py
ADDED
|
@@ -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}")
|
deepecgkit/cli/logger.py
ADDED
|
@@ -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)
|