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,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
+ }