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,398 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main ECG model evaluator class.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import torch
|
|
10
|
+
from sklearn.metrics import roc_auc_score
|
|
11
|
+
from sklearn.model_selection import KFold
|
|
12
|
+
|
|
13
|
+
from deepecgkit.evaluation.metrics import (
|
|
14
|
+
calculate_classification_metrics,
|
|
15
|
+
calculate_regression_metrics,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
MAX_CLASSIFICATION_CLASSES = 20
|
|
19
|
+
TUPLE_LENGTH_2 = 2
|
|
20
|
+
TUPLE_LENGTH_3 = 3
|
|
21
|
+
MAX_BRANCHES = 12
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ECGEvaluator:
|
|
25
|
+
"""
|
|
26
|
+
Comprehensive evaluator for ECG models.
|
|
27
|
+
|
|
28
|
+
This class provides a unified interface for evaluating ECG models with
|
|
29
|
+
various metrics and analysis tools.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
metrics: List of metrics to compute
|
|
33
|
+
task_type: Type of task ("classification", "regression", "auto")
|
|
34
|
+
device: Device for model evaluation
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
>>> evaluator = ECGEvaluator(metrics=["accuracy", "auc", "f1"])
|
|
38
|
+
>>> results = evaluator.evaluate(model, test_data)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
metrics: Optional[List[str]] = None,
|
|
44
|
+
task_type: str = "auto",
|
|
45
|
+
device: str = "auto",
|
|
46
|
+
):
|
|
47
|
+
self.metrics = metrics or ["accuracy", "precision", "recall", "f1", "auc"]
|
|
48
|
+
self.task_type = task_type
|
|
49
|
+
|
|
50
|
+
if device == "auto":
|
|
51
|
+
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
52
|
+
else:
|
|
53
|
+
self.device = device
|
|
54
|
+
|
|
55
|
+
def evaluate(
|
|
56
|
+
self,
|
|
57
|
+
model: Optional[Union[torch.nn.Module, np.ndarray]] = None,
|
|
58
|
+
test_data: Optional[Union[torch.utils.data.DataLoader, np.ndarray, tuple]] = None,
|
|
59
|
+
return_predictions: bool = False,
|
|
60
|
+
y_scores: Optional[np.ndarray] = None,
|
|
61
|
+
) -> Dict[str, float]:
|
|
62
|
+
"""
|
|
63
|
+
Evaluate model performance.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
model: PyTorch model or numpy array of predictions
|
|
67
|
+
test_data: Test data loader or (predictions, targets) tuple
|
|
68
|
+
return_predictions: Whether to return predictions along with metrics
|
|
69
|
+
y_scores: Scores/probabilities for AUC calculation (optional)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Dictionary of metric values
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
if y_scores is not None:
|
|
76
|
+
self.y_scores = y_scores
|
|
77
|
+
else:
|
|
78
|
+
self.y_scores = None
|
|
79
|
+
|
|
80
|
+
predictions, targets = self._process_input_data(model, test_data)
|
|
81
|
+
metrics = self._calculate_metrics(predictions, targets)
|
|
82
|
+
|
|
83
|
+
if return_predictions:
|
|
84
|
+
return metrics, predictions
|
|
85
|
+
return metrics
|
|
86
|
+
|
|
87
|
+
def _process_input_data(
|
|
88
|
+
self,
|
|
89
|
+
model: Optional[Union[torch.nn.Module, np.ndarray]],
|
|
90
|
+
test_data: Optional[Union[torch.utils.data.DataLoader, np.ndarray, tuple]],
|
|
91
|
+
) -> tuple:
|
|
92
|
+
"""Process input data to get predictions and targets."""
|
|
93
|
+
if isinstance(model, np.ndarray):
|
|
94
|
+
return self._process_numpy_input(model, test_data)
|
|
95
|
+
return self._get_predictions(model, test_data)
|
|
96
|
+
|
|
97
|
+
def _process_numpy_input(
|
|
98
|
+
self,
|
|
99
|
+
model: np.ndarray,
|
|
100
|
+
test_data: Optional[Union[torch.utils.data.DataLoader, np.ndarray, tuple]],
|
|
101
|
+
) -> tuple:
|
|
102
|
+
"""Process input when model is a numpy array."""
|
|
103
|
+
predictions = model
|
|
104
|
+
if not isinstance(test_data, tuple):
|
|
105
|
+
return predictions, test_data
|
|
106
|
+
|
|
107
|
+
if len(test_data) == TUPLE_LENGTH_3:
|
|
108
|
+
targets = test_data[1]
|
|
109
|
+
self.y_scores = test_data[2]
|
|
110
|
+
elif len(test_data) == TUPLE_LENGTH_2:
|
|
111
|
+
targets = test_data[1]
|
|
112
|
+
else:
|
|
113
|
+
targets = test_data
|
|
114
|
+
return predictions, targets
|
|
115
|
+
|
|
116
|
+
def _calculate_metrics(self, predictions: np.ndarray, targets: np.ndarray) -> Dict[str, float]:
|
|
117
|
+
"""Calculate metrics based on predictions and targets."""
|
|
118
|
+
metrics = {}
|
|
119
|
+
for metric_name in self.metrics:
|
|
120
|
+
if metric_name in ["accuracy", "precision", "recall", "f1"]:
|
|
121
|
+
metrics[metric_name] = calculate_classification_metrics(
|
|
122
|
+
targets, predictions, metrics=[metric_name]
|
|
123
|
+
)[metric_name]
|
|
124
|
+
elif metric_name in ["mse", "mae", "r2"]:
|
|
125
|
+
metrics[metric_name] = calculate_regression_metrics(
|
|
126
|
+
targets, predictions, metrics=[metric_name]
|
|
127
|
+
)[metric_name]
|
|
128
|
+
elif metric_name == "auc":
|
|
129
|
+
if self.y_scores is None:
|
|
130
|
+
raise ValueError("y_scores required for AUC calculation")
|
|
131
|
+
scores = self.y_scores
|
|
132
|
+
n_classes = len(np.unique(targets))
|
|
133
|
+
if n_classes <= 2 and scores.ndim > 1:
|
|
134
|
+
scores = scores[:, 1]
|
|
135
|
+
metrics[metric_name] = roc_auc_score(targets, scores)
|
|
136
|
+
return metrics
|
|
137
|
+
|
|
138
|
+
def _get_predictions(
|
|
139
|
+
self,
|
|
140
|
+
model: Optional[Union[torch.nn.Module, Any]],
|
|
141
|
+
test_data: Optional[Union[torch.utils.data.DataLoader, np.ndarray, tuple]],
|
|
142
|
+
) -> tuple:
|
|
143
|
+
"""Extract predictions and targets from model and data."""
|
|
144
|
+
if model is None and isinstance(test_data, tuple):
|
|
145
|
+
return self._handle_tuple_test_data(test_data)
|
|
146
|
+
|
|
147
|
+
if isinstance(test_data, torch.utils.data.DataLoader):
|
|
148
|
+
return self._process_dataloader(model, test_data)
|
|
149
|
+
elif isinstance(test_data, tuple) and len(test_data) == TUPLE_LENGTH_2:
|
|
150
|
+
return self._process_tuple_data(model, test_data)
|
|
151
|
+
elif isinstance(test_data, np.ndarray):
|
|
152
|
+
return self._process_numpy_data(model, test_data)
|
|
153
|
+
else:
|
|
154
|
+
raise ValueError(f"Unsupported test data type: {type(test_data)}")
|
|
155
|
+
|
|
156
|
+
def _handle_tuple_test_data(self, test_data: tuple) -> tuple:
|
|
157
|
+
"""Handle test data when it's a tuple."""
|
|
158
|
+
if len(test_data) == TUPLE_LENGTH_3:
|
|
159
|
+
y_true, y_pred, y_scores = test_data
|
|
160
|
+
self.y_scores = y_scores
|
|
161
|
+
return y_pred, y_true
|
|
162
|
+
elif len(test_data) == TUPLE_LENGTH_2:
|
|
163
|
+
return test_data[0], test_data[1]
|
|
164
|
+
return test_data, None
|
|
165
|
+
|
|
166
|
+
def _process_dataloader(
|
|
167
|
+
self, model: torch.nn.Module, test_data: torch.utils.data.DataLoader
|
|
168
|
+
) -> tuple:
|
|
169
|
+
"""Process data from a DataLoader."""
|
|
170
|
+
predictions = []
|
|
171
|
+
targets = []
|
|
172
|
+
with torch.no_grad():
|
|
173
|
+
for batch in test_data:
|
|
174
|
+
if isinstance(batch, (list, tuple)) and len(batch) == TUPLE_LENGTH_2:
|
|
175
|
+
x, y = batch
|
|
176
|
+
else:
|
|
177
|
+
x, y = batch, None
|
|
178
|
+
|
|
179
|
+
if torch.is_tensor(x):
|
|
180
|
+
x = x.to(self.device)
|
|
181
|
+
|
|
182
|
+
pred = model(x)
|
|
183
|
+
|
|
184
|
+
if torch.is_tensor(pred):
|
|
185
|
+
pred = pred.cpu().numpy()
|
|
186
|
+
predictions.append(pred)
|
|
187
|
+
|
|
188
|
+
if y is not None:
|
|
189
|
+
if torch.is_tensor(y):
|
|
190
|
+
y = y.cpu().numpy()
|
|
191
|
+
targets.append(y)
|
|
192
|
+
|
|
193
|
+
return np.concatenate(predictions), np.concatenate(targets)
|
|
194
|
+
|
|
195
|
+
def _process_tuple_data(self, model: torch.nn.Module, test_data: tuple) -> tuple:
|
|
196
|
+
"""Process data when it's a tuple."""
|
|
197
|
+
x, y = test_data
|
|
198
|
+
if torch.is_tensor(x):
|
|
199
|
+
x = x.to(self.device)
|
|
200
|
+
|
|
201
|
+
with torch.no_grad():
|
|
202
|
+
pred = model(x)
|
|
203
|
+
|
|
204
|
+
if torch.is_tensor(pred):
|
|
205
|
+
pred = pred.cpu().numpy()
|
|
206
|
+
if torch.is_tensor(y):
|
|
207
|
+
y = y.cpu().numpy()
|
|
208
|
+
|
|
209
|
+
return pred, y
|
|
210
|
+
|
|
211
|
+
def _process_numpy_data(self, model: torch.nn.Module, test_data: np.ndarray) -> tuple:
|
|
212
|
+
"""Process data when it's a numpy array."""
|
|
213
|
+
x = torch.tensor(test_data, dtype=torch.float32).to(self.device)
|
|
214
|
+
|
|
215
|
+
with torch.no_grad():
|
|
216
|
+
pred = model(x)
|
|
217
|
+
|
|
218
|
+
if torch.is_tensor(pred):
|
|
219
|
+
pred = pred.cpu().numpy()
|
|
220
|
+
return pred, np.zeros(len(pred))
|
|
221
|
+
|
|
222
|
+
def _detect_task_type(self, targets: np.ndarray) -> str:
|
|
223
|
+
"""Detect whether task is classification or regression."""
|
|
224
|
+
if targets is None:
|
|
225
|
+
return "regression"
|
|
226
|
+
|
|
227
|
+
unique_values = np.unique(targets)
|
|
228
|
+
|
|
229
|
+
if len(unique_values) <= MAX_CLASSIFICATION_CLASSES and np.all(
|
|
230
|
+
np.equal(np.mod(targets, 1), 0)
|
|
231
|
+
):
|
|
232
|
+
return "classification"
|
|
233
|
+
return "regression"
|
|
234
|
+
|
|
235
|
+
def cross_validate(
|
|
236
|
+
self, model_class: type, data: Any, k_folds: int = 5, **model_kwargs
|
|
237
|
+
) -> Dict[str, List[float]]:
|
|
238
|
+
"""
|
|
239
|
+
Perform k-fold cross-validation.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
model_class: Class of model to evaluate
|
|
243
|
+
data: Dataset for cross-validation
|
|
244
|
+
k_folds: Number of folds
|
|
245
|
+
**model_kwargs: Keyword arguments for model initialization
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Dictionary of metric scores across folds
|
|
249
|
+
"""
|
|
250
|
+
if isinstance(data, tuple):
|
|
251
|
+
x, y = data
|
|
252
|
+
else:
|
|
253
|
+
raise ValueError("Data must be (X, y) tuple for cross-validation")
|
|
254
|
+
|
|
255
|
+
kf = KFold(n_splits=k_folds, shuffle=True, random_state=42)
|
|
256
|
+
fold_results = {metric: [] for metric in self.metrics}
|
|
257
|
+
|
|
258
|
+
for fold, (train_idx, val_idx) in enumerate(kf.split(x)):
|
|
259
|
+
print(f"Evaluating fold {fold + 1}/{k_folds}")
|
|
260
|
+
|
|
261
|
+
x_train, x_val = x[train_idx], x[val_idx]
|
|
262
|
+
y_train, y_val = y[train_idx], y[val_idx]
|
|
263
|
+
|
|
264
|
+
model = model_class(**model_kwargs)
|
|
265
|
+
|
|
266
|
+
if hasattr(model, "fit"):
|
|
267
|
+
model.fit(x_train, y_train)
|
|
268
|
+
|
|
269
|
+
results = self.evaluate(model, (x_val, y_val))
|
|
270
|
+
|
|
271
|
+
for metric in self.metrics:
|
|
272
|
+
if metric in results:
|
|
273
|
+
fold_results[metric].append(results[metric])
|
|
274
|
+
|
|
275
|
+
return fold_results
|
|
276
|
+
|
|
277
|
+
def bootstrap_evaluate(
|
|
278
|
+
self,
|
|
279
|
+
model: Any,
|
|
280
|
+
test_data: Any,
|
|
281
|
+
n_bootstrap: int = 1000,
|
|
282
|
+
confidence_level: float = 0.95,
|
|
283
|
+
) -> Dict[str, Dict[str, float]]:
|
|
284
|
+
"""
|
|
285
|
+
Bootstrap evaluation for confidence intervals.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
model: Trained model
|
|
289
|
+
test_data: Test dataset
|
|
290
|
+
n_bootstrap: Number of bootstrap samples
|
|
291
|
+
confidence_level: Confidence level for intervals
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Dictionary with mean, std, and confidence intervals for each metric
|
|
295
|
+
"""
|
|
296
|
+
predictions, targets = self._get_predictions(model, test_data)
|
|
297
|
+
|
|
298
|
+
if targets is None:
|
|
299
|
+
raise ValueError("Bootstrap evaluation requires ground truth targets")
|
|
300
|
+
|
|
301
|
+
n_samples = len(targets)
|
|
302
|
+
bootstrap_results = {metric: [] for metric in self.metrics}
|
|
303
|
+
|
|
304
|
+
for _ in range(n_bootstrap):
|
|
305
|
+
indices = np.random.choice(n_samples, n_samples, replace=True)
|
|
306
|
+
boot_predictions = predictions[indices]
|
|
307
|
+
boot_targets = targets[indices]
|
|
308
|
+
|
|
309
|
+
if (
|
|
310
|
+
self.task_type == "classification"
|
|
311
|
+
or self._detect_task_type(boot_targets) == "classification"
|
|
312
|
+
):
|
|
313
|
+
results = calculate_classification_metrics(
|
|
314
|
+
boot_targets, boot_predictions, metrics=self.metrics
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
results = calculate_regression_metrics(
|
|
318
|
+
boot_targets, boot_predictions, metrics=self.metrics
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
for metric in self.metrics:
|
|
322
|
+
if metric in results:
|
|
323
|
+
bootstrap_results[metric].append(results[metric])
|
|
324
|
+
|
|
325
|
+
alpha = 1 - confidence_level
|
|
326
|
+
lower_percentile = (alpha / 2) * 100
|
|
327
|
+
upper_percentile = (1 - alpha / 2) * 100
|
|
328
|
+
|
|
329
|
+
final_results = {}
|
|
330
|
+
for metric, values in bootstrap_results.items():
|
|
331
|
+
if values:
|
|
332
|
+
final_results[metric] = {
|
|
333
|
+
"mean": np.mean(values),
|
|
334
|
+
"std": np.std(values),
|
|
335
|
+
"lower_ci": np.percentile(values, lower_percentile),
|
|
336
|
+
"upper_ci": np.percentile(values, upper_percentile),
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return final_results
|
|
340
|
+
|
|
341
|
+
def generate_report(
|
|
342
|
+
self,
|
|
343
|
+
model: Any,
|
|
344
|
+
test_data: Any,
|
|
345
|
+
save_path: Optional[str] = None,
|
|
346
|
+
y_scores: Optional[np.ndarray] = None,
|
|
347
|
+
) -> pd.DataFrame:
|
|
348
|
+
"""
|
|
349
|
+
Generate comprehensive evaluation report.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
model: Trained model
|
|
353
|
+
test_data: Test dataset
|
|
354
|
+
save_path: Path to save report (optional)
|
|
355
|
+
y_scores: Scores/probabilities for AUC calculation (optional)
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
DataFrame with evaluation results
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
results = self.evaluate(model, test_data, return_predictions=True, y_scores=y_scores)
|
|
362
|
+
if isinstance(results, tuple):
|
|
363
|
+
metrics_dict = results[0]
|
|
364
|
+
else:
|
|
365
|
+
metrics_dict = results
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
bootstrap_results = self.bootstrap_evaluate(model, test_data)
|
|
369
|
+
except Exception:
|
|
370
|
+
bootstrap_results = {}
|
|
371
|
+
|
|
372
|
+
report_data = []
|
|
373
|
+
|
|
374
|
+
for metric in self.metrics:
|
|
375
|
+
if metric in metrics_dict and metric not in {"predictions", "targets"}:
|
|
376
|
+
row = {
|
|
377
|
+
"Metric": metric,
|
|
378
|
+
"Value": metrics_dict[metric],
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if metric in bootstrap_results:
|
|
382
|
+
row.update(
|
|
383
|
+
{
|
|
384
|
+
"Std": bootstrap_results[metric]["std"],
|
|
385
|
+
"95% CI Lower": bootstrap_results[metric]["lower_ci"],
|
|
386
|
+
"95% CI Upper": bootstrap_results[metric]["upper_ci"],
|
|
387
|
+
}
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
report_data.append(row)
|
|
391
|
+
|
|
392
|
+
report_df = pd.DataFrame(report_data)
|
|
393
|
+
|
|
394
|
+
if save_path:
|
|
395
|
+
report_df.to_csv(save_path, index=False)
|
|
396
|
+
print(f"Report saved to {save_path}")
|
|
397
|
+
|
|
398
|
+
return report_df
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Evaluation metrics for ECG models.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from sklearn.metrics import (
|
|
9
|
+
accuracy_score,
|
|
10
|
+
confusion_matrix,
|
|
11
|
+
matthews_corrcoef,
|
|
12
|
+
mean_absolute_error,
|
|
13
|
+
mean_squared_error,
|
|
14
|
+
precision_recall_fscore_support,
|
|
15
|
+
r2_score,
|
|
16
|
+
roc_auc_score,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
BINARY_THRESHOLD = 0.5
|
|
20
|
+
MULTICLASS_THRESHOLD = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def calculate_classification_metrics(
|
|
24
|
+
y_true: np.ndarray, y_pred: np.ndarray, metrics: Optional[List[str]] = None
|
|
25
|
+
) -> Dict[str, float]:
|
|
26
|
+
"""
|
|
27
|
+
Calculate classification metrics.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
y_true: True labels
|
|
31
|
+
y_pred: Predicted labels or probabilities
|
|
32
|
+
metrics: List of metrics to compute
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Dictionary of computed metrics
|
|
36
|
+
"""
|
|
37
|
+
if metrics is None:
|
|
38
|
+
metrics = ["accuracy", "precision", "recall", "f1", "auc", "mcc"]
|
|
39
|
+
|
|
40
|
+
results = {}
|
|
41
|
+
|
|
42
|
+
if y_pred.ndim > 1:
|
|
43
|
+
y_pred_labels = np.argmax(y_pred, axis=1)
|
|
44
|
+
y_scores = y_pred
|
|
45
|
+
elif (
|
|
46
|
+
y_pred.dtype == float
|
|
47
|
+
and np.max(y_pred) <= 1.0
|
|
48
|
+
and len(np.unique(y_true)) <= MULTICLASS_THRESHOLD
|
|
49
|
+
):
|
|
50
|
+
y_pred_labels = (y_pred > BINARY_THRESHOLD).astype(int)
|
|
51
|
+
y_scores = y_pred
|
|
52
|
+
else:
|
|
53
|
+
y_pred_labels = y_pred.astype(int)
|
|
54
|
+
y_scores = y_pred
|
|
55
|
+
|
|
56
|
+
n_classes = len(np.unique(y_true))
|
|
57
|
+
avg = "binary" if n_classes <= MULTICLASS_THRESHOLD else "macro"
|
|
58
|
+
|
|
59
|
+
def _compute_metric(metric):
|
|
60
|
+
result = np.nan
|
|
61
|
+
if metric == "accuracy":
|
|
62
|
+
result = accuracy_score(y_true, y_pred_labels)
|
|
63
|
+
elif metric in ["precision", "recall", "f1"]:
|
|
64
|
+
precision, recall, f1, _ = precision_recall_fscore_support(
|
|
65
|
+
y_true, y_pred_labels, average=avg, zero_division=0
|
|
66
|
+
)
|
|
67
|
+
if metric == "precision":
|
|
68
|
+
result = precision
|
|
69
|
+
elif metric == "recall":
|
|
70
|
+
result = recall
|
|
71
|
+
elif metric == "f1":
|
|
72
|
+
result = f1
|
|
73
|
+
elif metric == "auc":
|
|
74
|
+
if n_classes > MULTICLASS_THRESHOLD:
|
|
75
|
+
result = roc_auc_score(y_true, y_scores, multi_class="ovr")
|
|
76
|
+
else:
|
|
77
|
+
# Binary AUC expects 1D scores (positive class probability)
|
|
78
|
+
scores = y_scores[:, 1] if y_scores.ndim > 1 else y_scores
|
|
79
|
+
result = roc_auc_score(y_true, scores)
|
|
80
|
+
elif metric == "mcc":
|
|
81
|
+
result = matthews_corrcoef(y_true, y_pred_labels)
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
for metric in metrics:
|
|
85
|
+
try:
|
|
86
|
+
results[metric] = _compute_metric(metric)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
print(f"Warning: Could not compute {metric}: {e}")
|
|
89
|
+
results[metric] = np.nan
|
|
90
|
+
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def calculate_regression_metrics(
|
|
95
|
+
y_true: np.ndarray, y_pred: np.ndarray, metrics: Optional[List[str]] = None
|
|
96
|
+
) -> Dict[str, float]:
|
|
97
|
+
"""
|
|
98
|
+
Calculate regression metrics.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
y_true: True values
|
|
102
|
+
y_pred: Predicted values
|
|
103
|
+
metrics: List of metrics to compute
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Dictionary of computed metrics
|
|
107
|
+
"""
|
|
108
|
+
if metrics is None:
|
|
109
|
+
metrics = ["mse", "mae", "r2"]
|
|
110
|
+
|
|
111
|
+
results = {}
|
|
112
|
+
|
|
113
|
+
for metric in metrics:
|
|
114
|
+
try:
|
|
115
|
+
if metric in ["mse", "mean_squared_error"]:
|
|
116
|
+
results[metric] = mean_squared_error(y_true, y_pred)
|
|
117
|
+
elif metric in ["mae", "mean_absolute_error"]:
|
|
118
|
+
results[metric] = mean_absolute_error(y_true, y_pred)
|
|
119
|
+
elif metric in ["r2", "r2_score"]:
|
|
120
|
+
results[metric] = r2_score(y_true, y_pred)
|
|
121
|
+
elif metric == "rmse":
|
|
122
|
+
results[metric] = np.sqrt(mean_squared_error(y_true, y_pred))
|
|
123
|
+
except Exception as e:
|
|
124
|
+
print(f"Warning: Could not compute {metric}: {e}")
|
|
125
|
+
results[metric] = np.nan
|
|
126
|
+
|
|
127
|
+
return results
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def confusion_matrix_analysis(y_true: np.ndarray, y_pred: np.ndarray) -> Dict[str, Any]:
|
|
131
|
+
"""
|
|
132
|
+
Detailed confusion matrix analysis.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
y_true: True labels
|
|
136
|
+
y_pred: Predicted labels
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Dictionary with confusion matrix and derived metrics
|
|
140
|
+
"""
|
|
141
|
+
cm = confusion_matrix(y_true, y_pred)
|
|
142
|
+
|
|
143
|
+
n_classes = cm.shape[0]
|
|
144
|
+
precision = np.zeros(n_classes)
|
|
145
|
+
recall = np.zeros(n_classes)
|
|
146
|
+
f1 = np.zeros(n_classes)
|
|
147
|
+
|
|
148
|
+
for i in range(n_classes):
|
|
149
|
+
if cm[:, i].sum() > 0:
|
|
150
|
+
precision[i] = cm[i, i] / cm[:, i].sum()
|
|
151
|
+
if cm[i, :].sum() > 0:
|
|
152
|
+
recall[i] = cm[i, i] / cm[i, :].sum()
|
|
153
|
+
if precision[i] + recall[i] > 0:
|
|
154
|
+
f1[i] = 2 * precision[i] * recall[i] / (precision[i] + recall[i])
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"confusion_matrix": cm,
|
|
158
|
+
"per_class_precision": precision,
|
|
159
|
+
"per_class_recall": recall,
|
|
160
|
+
"per_class_f1": f1,
|
|
161
|
+
"macro_precision": np.mean(precision),
|
|
162
|
+
"macro_recall": np.mean(recall),
|
|
163
|
+
"macro_f1": np.mean(f1),
|
|
164
|
+
}
|