Download evaluate_model.py from poult/ECG-Mamba-V2: direct link, hf CLI and curl.
- Browser
- Download file 19.9 kB
-
https://huggingface.co/poult/ECG-Mamba-V2/resolve/main/evaluate_model.py
- Command line
-
hf download hf://poult/ECG-Mamba-V2/evaluate_model.py
-
curl -L -o evaluate_model.py https://huggingface.co/poult/ECG-Mamba-V2/resolve/main/evaluate_model.py
19.9 kB
| #!/usr/bin/env python | |
| # This file contains functions for evaluating algorithms for the 2021 PhysioNet/ | |
| # Computing in Cardiology Challenge. You can run it as follows: | |
| # | |
| # python evaluate_model.py labels outputs scores.csv | |
| # | |
| # where 'labels' is a directory containing files with the labels, 'outputs' is a | |
| # directory containing files with the outputs from your model, and 'scores.csv' | |
| # (optional) is a collection of scores for the algorithm outputs. | |
| # | |
| # Each file of labels or outputs must have the format described on the Challenge | |
| # webpage. The scores for the algorithm outputs include the area under the | |
| # receiver-operating characteristic curve (AUROC), the area under the recall- | |
| # precision curve (AUPRC), accuracy (fraction of correct recordings), macro F- | |
| # measure, and the Challenge metric, which assigns different weights to | |
| # different misclassification errors. | |
| import os, os.path, sys, numpy as np | |
| from helper_code import get_labels, is_finite_number, load_header, load_outputs | |
| import pandas as pd | |
| from tabulate import tabulate | |
| from sklearn.metrics import multilabel_confusion_matrix | |
| def evaluate_model(label_directory, output_directory): | |
| # Identify the weights and the SNOMED CT code for the sinus rhythm class. | |
| weights_file = 'weights.csv' | |
| sinus_rhythm = set(['426783006']) | |
| # Load the scored classes and the weights for the Challenge metric. | |
| print('Loading weights...') | |
| classes, weights = load_weights(weights_file) | |
| # Load the label and output files. | |
| print('Loading label and output files...') | |
| label_files, output_files = find_challenge_files(label_directory, output_directory) | |
| labels = load_labels(label_files, classes) | |
| binary_outputs, scalar_outputs = load_classifier_outputs(output_files, classes) | |
| # Evaluate the model by comparing the labels and outputs. | |
| print('Evaluating model...') | |
| print('- AUROC and AUPRC...') | |
| auroc, auprc, auroc_classes, auprc_classes = compute_auc(labels, scalar_outputs) | |
| print('- Accuracy...') | |
| accuracy = compute_accuracy(labels, binary_outputs) | |
| print('- F-measure...') | |
| f_measure, f_measure_classes = compute_f_measure(labels, binary_outputs) | |
| print('- Challenge metric...') | |
| challenge_metric = compute_challenge_metric(weights, labels, binary_outputs, classes, sinus_rhythm) | |
| print('Done.') | |
| # Return the results. | |
| return classes, auroc, auprc, auroc_classes, auprc_classes, accuracy, f_measure, f_measure_classes, challenge_metric | |
| # Find Challenge files. | |
| def find_challenge_files(label_directory, output_directory): | |
| label_files = list() | |
| output_files = list() | |
| for label_file in sorted(os.listdir(label_directory)): | |
| label_file_path = os.path.join(label_directory, label_file) # Full path for label file | |
| if os.path.isfile(label_file_path) and label_file.lower().endswith('.hea') and not label_file.lower().startswith('.'): | |
| root, ext = os.path.splitext(label_file) | |
| output_file = root + '.csv' | |
| output_file_path = os.path.join(output_directory, output_file) # Full path for corresponding output file | |
| if os.path.isfile(output_file_path): | |
| label_files.append(label_file_path) | |
| output_files.append(output_file_path) | |
| else: | |
| raise IOError('Output file {} not found for label file {}.'.format(output_file, label_file)) | |
| if label_files and output_files: | |
| return label_files, output_files | |
| else: | |
| raise IOError('No label or output files found.') | |
| # Load a table with row and column names. | |
| def load_table(table_file): | |
| # The table should have the following form: | |
| # | |
| # , a, b, c | |
| # a, 1.2, 2.3, 3.4 | |
| # b, 4.5, 5.6, 6.7 | |
| # c, 7.8, 8.9, 9.0 | |
| # | |
| table = list() | |
| with open(table_file, 'r') as f: | |
| for i, l in enumerate(f): | |
| arrs = [arr.strip() for arr in l.split(',')] | |
| table.append(arrs) | |
| # Define the numbers of rows and columns and check for errors. | |
| num_rows = len(table)-1 | |
| if num_rows<1: | |
| raise Exception('The table {} is empty.'.format(table_file)) | |
| row_lengths = set(len(table[i])-1 for i in range(num_rows)) | |
| if len(row_lengths)!=1: | |
| raise Exception('The table {} has rows with different lengths.'.format(table_file)) | |
| num_cols = min(row_lengths) | |
| if num_cols<1: | |
| raise Exception('The table {} is empty.'.format(table_file)) | |
| # Find the row and column labels. | |
| rows = [table[0][j+1] for j in range(num_rows)] | |
| cols = [table[i+1][0] for i in range(num_cols)] | |
| # Find the entries of the table. | |
| values = np.zeros((num_rows, num_cols), dtype=np.float64) | |
| for i in range(num_rows): | |
| for j in range(num_cols): | |
| value = table[i+1][j+1] | |
| if is_finite_number(value): | |
| values[i, j] = float(value) | |
| else: | |
| values[i, j] = float('nan') | |
| return rows, cols, values | |
| # Load weights. | |
| def load_weights(weight_file): | |
| # Load the table with the weight matrix. | |
| rows, cols, values = load_table(weight_file) | |
| # Split the equivalent classes. | |
| rows = [set(row.split('|')) for row in rows] | |
| cols = [set(col.split('|')) for col in cols] | |
| assert(rows == cols) | |
| # Identify the classes and the weight matrix. | |
| classes = rows | |
| weights = values | |
| return classes, weights | |
| # Load labels from header/label files. | |
| def load_labels(label_files, classes): | |
| # The labels should have the following form: | |
| # | |
| # Dx: label_1, label_2, label_3 | |
| # | |
| num_recordings = len(label_files) | |
| num_classes = len(classes) | |
| # Use one-hot encoding for the labels. | |
| labels = np.zeros((num_recordings, num_classes), dtype=np.bool) | |
| # Iterate over the recordings. | |
| for i in range(num_recordings): | |
| header = load_header(label_files[i]) | |
| y = set(get_labels(header)) | |
| for j, x in enumerate(classes): | |
| if x & y: | |
| labels[i, j] = 1 | |
| return labels | |
| # Load outputs from output files. | |
| def load_classifier_outputs(output_files, classes): | |
| # The outputs should have the following form: | |
| # | |
| # #Record ID | |
| # diagnosis_1, diagnosis_2, diagnosis_3 | |
| # 0, 1, 1 | |
| # 0.12, 0.34, 0.56 | |
| # | |
| num_recordings = len(output_files) | |
| num_classes = len(classes) | |
| # Use one-hot encoding for the outputs. | |
| binary_outputs = np.zeros((num_recordings, num_classes), dtype=np.bool) | |
| scalar_outputs = np.zeros((num_recordings, num_classes), dtype=np.float64) | |
| # Iterate over the recordings. | |
| for i in range(num_recordings): | |
| recording_id, recording_classes, recording_binary_outputs, recording_scalar_outputs = load_outputs(output_files[i]) | |
| # Allow for equivalent classes and sanitize classifier outputs. | |
| recording_classes = [set(entry.split('|')) for entry in recording_classes] | |
| recording_binary_outputs = [1 if entry in ('1', 'True', 'true', 'T', 't') else 0 for entry in recording_binary_outputs] | |
| recording_scalar_outputs = [float(entry) if is_finite_number(entry) else 0 for entry in recording_scalar_outputs] | |
| # Allow for unordered/reordered and equivalent classes. | |
| for j, x in enumerate(classes): | |
| binary_values = list() | |
| scalar_values = list() | |
| for k, y in enumerate(recording_classes): | |
| if x & y: | |
| binary_values.append(recording_binary_outputs[k]) | |
| scalar_values.append(recording_scalar_outputs[k]) | |
| if binary_values: | |
| binary_outputs[i, j] = any(binary_values) # Define a class as positive if any of the equivalent classes is positive. | |
| if scalar_values: | |
| scalar_outputs[i, j] = np.mean(scalar_values) # Define the scalar value of a class as the mean value of the scalar values across equivalent classes. | |
| return binary_outputs, scalar_outputs | |
| # Compute recording-wise accuracy. | |
| def compute_accuracy(labels, outputs): | |
| num_recordings, num_classes = np.shape(labels) | |
| num_correct_recordings = 0 | |
| for i in range(num_recordings): | |
| if np.all(labels[i, :]==outputs[i, :]): | |
| num_correct_recordings += 1 | |
| return float(num_correct_recordings) / float(num_recordings) | |
| # Compute confusion matrices. | |
| def compute_confusion_matrices(labels, outputs, normalize=False): | |
| # Compute a binary confusion matrix for each class k: | |
| # | |
| # [TN_k FN_k] | |
| # [FP_k TP_k] | |
| # | |
| # If the normalize variable is set to true, then normalize the contributions | |
| # to the confusion matrix by the number of labels per recording. | |
| num_recordings, num_classes = np.shape(labels) | |
| if not normalize: | |
| A = np.zeros((num_classes, 2, 2)) | |
| for i in range(num_recordings): | |
| for j in range(num_classes): | |
| if labels[i, j]==1 and outputs[i, j]==1: # TP | |
| A[j, 1, 1] += 1 | |
| elif labels[i, j]==0 and outputs[i, j]==1: # FP | |
| A[j, 1, 0] += 1 | |
| elif labels[i, j]==1 and outputs[i, j]==0: # FN | |
| A[j, 0, 1] += 1 | |
| elif labels[i, j]==0 and outputs[i, j]==0: # TN | |
| A[j, 0, 0] += 1 | |
| else: # This condition should not happen. | |
| raise ValueError('Error in computing the confusion matrix.') | |
| else: | |
| A = np.zeros((num_classes, 2, 2)) | |
| for i in range(num_recordings): | |
| normalization = float(max(np.sum(labels[i, :]), 1)) | |
| for j in range(num_classes): | |
| if labels[i, j]==1 and outputs[i, j]==1: # TP | |
| A[j, 1, 1] += 1.0/normalization | |
| elif labels[i, j]==0 and outputs[i, j]==1: # FP | |
| A[j, 1, 0] += 1.0/normalization | |
| elif labels[i, j]==1 and outputs[i, j]==0: # FN | |
| A[j, 0, 1] += 1.0/normalization | |
| elif labels[i, j]==0 and outputs[i, j]==0: # TN | |
| A[j, 0, 0] += 1.0/normalization | |
| else: # This condition should not happen. | |
| raise ValueError('Error in computing the confusion matrix.') | |
| return A | |
| # Compute macro F-measure. | |
| def compute_f_measure(labels, outputs): | |
| num_recordings, num_classes = np.shape(labels) | |
| A = compute_confusion_matrices(labels, outputs) | |
| f_measure = np.zeros(num_classes) | |
| for k in range(num_classes): | |
| tp, fp, fn, tn = A[k, 1, 1], A[k, 1, 0], A[k, 0, 1], A[k, 0, 0] | |
| if 2 * tp + fp + fn: | |
| f_measure[k] = float(2 * tp) / float(2 * tp + fp + fn) | |
| else: | |
| f_measure[k] = float('nan') | |
| if np.any(np.isfinite(f_measure)): | |
| macro_f_measure = np.nanmean(f_measure) | |
| else: | |
| macro_f_measure = float('nan') | |
| return macro_f_measure, f_measure | |
| # Compute macro AUROC and macro AUPRC. | |
| def compute_auc(labels, outputs): | |
| num_recordings, num_classes = np.shape(labels) | |
| # Compute and summarize the confusion matrices for each class across at distinct output values. | |
| auroc = np.zeros(num_classes) | |
| auprc = np.zeros(num_classes) | |
| for k in range(num_classes): | |
| # We only need to compute TPs, FPs, FNs, and TNs at distinct output values. | |
| thresholds = np.unique(outputs[:, k]) | |
| thresholds = np.append(thresholds, thresholds[-1]+1) | |
| thresholds = thresholds[::-1] | |
| num_thresholds = len(thresholds) | |
| # Initialize the TPs, FPs, FNs, and TNs. | |
| tp = np.zeros(num_thresholds) | |
| fp = np.zeros(num_thresholds) | |
| fn = np.zeros(num_thresholds) | |
| tn = np.zeros(num_thresholds) | |
| fn[0] = np.sum(labels[:, k]==1) | |
| tn[0] = np.sum(labels[:, k]==0) | |
| # Find the indices that result in sorted output values. | |
| idx = np.argsort(outputs[:, k])[::-1] | |
| # Compute the TPs, FPs, FNs, and TNs for class k across thresholds. | |
| i = 0 | |
| for j in range(1, num_thresholds): | |
| # Initialize TPs, FPs, FNs, and TNs using values at previous threshold. | |
| tp[j] = tp[j-1] | |
| fp[j] = fp[j-1] | |
| fn[j] = fn[j-1] | |
| tn[j] = tn[j-1] | |
| # Update the TPs, FPs, FNs, and TNs at i-th output value. | |
| while i < num_recordings and outputs[idx[i], k] >= thresholds[j]: | |
| if labels[idx[i], k]: | |
| tp[j] += 1 | |
| fn[j] -= 1 | |
| else: | |
| fp[j] += 1 | |
| tn[j] -= 1 | |
| i += 1 | |
| # Summarize the TPs, FPs, FNs, and TNs for class k. | |
| tpr = np.zeros(num_thresholds) | |
| tnr = np.zeros(num_thresholds) | |
| ppv = np.zeros(num_thresholds) | |
| for j in range(num_thresholds): | |
| if tp[j] + fn[j]: | |
| tpr[j] = float(tp[j]) / float(tp[j] + fn[j]) | |
| else: | |
| tpr[j] = float('nan') | |
| if fp[j] + tn[j]: | |
| tnr[j] = float(tn[j]) / float(fp[j] + tn[j]) | |
| else: | |
| tnr[j] = float('nan') | |
| if tp[j] + fp[j]: | |
| ppv[j] = float(tp[j]) / float(tp[j] + fp[j]) | |
| else: | |
| ppv[j] = float('nan') | |
| # Compute AUROC as the area under a piecewise linear function with TPR/ | |
| # sensitivity (x-axis) and TNR/specificity (y-axis) and AUPRC as the area | |
| # under a piecewise constant with TPR/recall (x-axis) and PPV/precision | |
| # (y-axis) for class k. | |
| for j in range(num_thresholds-1): | |
| auroc[k] += 0.5 * (tpr[j+1] - tpr[j]) * (tnr[j+1] + tnr[j]) | |
| auprc[k] += (tpr[j+1] - tpr[j]) * ppv[j+1] | |
| # Compute macro AUROC and macro AUPRC across classes. | |
| if np.any(np.isfinite(auroc)): | |
| macro_auroc = np.nanmean(auroc) | |
| else: | |
| macro_auroc = float('nan') | |
| if np.any(np.isfinite(auprc)): | |
| macro_auprc = np.nanmean(auprc) | |
| else: | |
| macro_auprc = float('nan') | |
| return macro_auroc, macro_auprc, auroc, auprc | |
| # Compute a modified confusion matrix for multi-class, multi-label tasks. | |
| def compute_modified_confusion_matrix(labels, outputs): | |
| # Compute a binary multi-class, multi-label confusion matrix, where the rows | |
| # are the labels and the columns are the outputs. | |
| num_recordings, num_classes = np.shape(labels) | |
| A = np.zeros((num_classes, num_classes)) | |
| # Iterate over all of the recordings. | |
| for i in range(num_recordings): | |
| # Calculate the number of positive labels and/or outputs. | |
| normalization = float(max(np.sum(np.any((labels[i, :], outputs[i, :]), axis=0)), 1)) | |
| # Iterate over all of the classes. | |
| for j in range(num_classes): | |
| # Assign full and/or partial credit for each positive class. | |
| if labels[i, j]: | |
| for k in range(num_classes): | |
| if outputs[i, k]: | |
| A[j, k] += 1.0/normalization | |
| return A | |
| # Compute the evaluation metric for the Challenge. | |
| def compute_challenge_metric(weights, labels, outputs, classes, sinus_rhythm): | |
| num_recordings, num_classes = np.shape(labels) | |
| if sinus_rhythm in classes: | |
| sinus_rhythm_index = classes.index(sinus_rhythm) | |
| else: | |
| raise ValueError('The sinus rhythm class is not available.') | |
| # Compute the observed score. | |
| A = compute_modified_confusion_matrix(labels, outputs) | |
| observed_score = np.nansum(weights * A) | |
| # Compute the score for the model that always chooses the correct label(s). | |
| correct_outputs = labels | |
| A = compute_modified_confusion_matrix(labels, correct_outputs) | |
| correct_score = np.nansum(weights * A) | |
| # Compute the score for the model that always chooses the sinus rhythm class. | |
| inactive_outputs = np.zeros((num_recordings, num_classes), dtype=np.bool_) | |
| inactive_outputs[:, sinus_rhythm_index] = 1 | |
| A = compute_modified_confusion_matrix(labels, inactive_outputs) | |
| inactive_score = np.nansum(weights * A) | |
| if correct_score != inactive_score: | |
| normalized_score = float(observed_score - inactive_score) / float(correct_score - inactive_score) | |
| else: | |
| normalized_score = 0.0 | |
| return normalized_score | |
| if __name__ == '__main__': | |
| classes, auroc, auprc, auroc_classes, auprc_classes, accuracy, f_measure, f_measure_classes, challenge_metric = evaluate_model(sys.argv[1], sys.argv[2]) | |
| output_string = 'AUROC,AUPRC,Accuracy,F-measure,Challenge metric\n{:.3f},{:.3f},{:.3f},{:.3f},{:.3f}'.format(auroc, auprc, accuracy, f_measure, challenge_metric) | |
| class_output_string = 'Classes,{}\nAUROC,{}\nAUPRC,{}\nF-measure,{}'.format( | |
| ','.join('|'.join(sorted(x)) for x in classes), | |
| ','.join('{:.3f}'.format(x) for x in auroc_classes), | |
| ','.join('{:.3f}'.format(x) for x in auprc_classes), | |
| ','.join('{:.3f}'.format(x) for x in f_measure_classes)) | |
| print(output_string) | |
| with open('test_outputs/output.txt', 'w') as f: | |
| f.write(output_string) | |
| df = pd.DataFrame({'classes':classes, | |
| 'auroc_classes':auroc_classes, | |
| 'auprc_classes':auprc_classes, | |
| 'f_measure_classes':f_measure_classes}) | |
| # dxname = pd.read_csv('dx_mapping_scored.csv') | |
| # dxname = dxname[['Dx','SNOMED CT Code']] | |
| # dxname = dxname.rename(columns={'SNOMED CT Code':'classes'}) | |
| # df = df.merge(dxname,on='classes',how='left') | |
| print(tabulate(df, headers='keys', tablefmt='psql')) | |
| with open('test_outputs/table.txt', 'w') as f: | |
| f.write(tabulate(df, headers='keys', tablefmt='psql')) | |
| def multilabel_specificity_score(y_true, y_pred): | |
| """ | |
| Calculates macro and weighted specificity scores for multi-label classification. | |
| Specificity (or True Negative Rate) = TN / (TN + FP) | |
| Parameters: | |
| ----------- | |
| y_true : array-like of shape (n_samples, n_labels) | |
| True binary labels. | |
| y_pred : array-like of shape (n_samples, n_labels) | |
| Predicted binary labels. | |
| Returns: | |
| -------- | |
| macro_specificity : float | |
| The unweighted average of specificity across all labels. | |
| weighted_specificity : float | |
| The average specificity weighted by the number of true negative instances (TN + FP) | |
| for each label. | |
| """ | |
| # 1. Calculate the Multilabel Confusion Matrix (MCM) | |
| # M[k] is the 2x2 confusion matrix for label k: | |
| # [[TN, FP], | |
| # [FN, TP]] | |
| M = multilabel_confusion_matrix(y_true, y_pred) | |
| # 2. Extract TN and FP counts for all labels | |
| # TNs are M[:, 0, 0] | |
| # FPs are M[:, 0, 1] | |
| TN = M[:, 0, 0].astype(float) | |
| FP = M[:, 0, 1].astype(float) | |
| # Denominator for specificity: TN + FP (Total true negative instances) | |
| denominator = TN + FP | |
| # 3. Calculate Per-Label Specificity | |
| # Use np.divide with the 'where' argument to safely handle division by zero. | |
| # If denominator is 0 (class k has no true negative instances), the ratio is set to 1.0 (perfect score). | |
| per_label_specificity = np.divide( | |
| TN, | |
| denominator, | |
| out=np.ones_like(TN), | |
| where=denominator != 0 | |
| ) | |
| # 4. Calculate Macro-Specificity (Simple average) | |
| macro_specificity = np.mean(per_label_specificity) | |
| # 5. Calculate Weighted-Specificity | |
| # The weights for specificity are based on the total number of true negative instances (TN + FP) | |
| weights = denominator | |
| total_weights = np.sum(weights) | |
| if total_weights == 0: | |
| # If there are no true negative instances across all classes, return 1.0 or 0.0 | |
| # depending on convention. 1.0 is safer as it implies 'perfect prediction' on | |
| # the non-existent negative side. | |
| weighted_specificity = 1.0 | |
| else: | |
| # Weighted_Specificity = sum(Specificity_k * Weight_k) / sum(Weight_k) | |
| weighted_specificity = np.sum(per_label_specificity * weights) / total_weights | |
| return macro_specificity, weighted_specificity |