| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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): |
| |
| weights_file = 'weights.csv' |
| sinus_rhythm = set(['426783006']) |
|
|
| |
| print('Loading weights...') |
| classes, weights = load_weights(weights_file) |
|
|
| |
| 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) |
|
|
| |
| 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 classes, auroc, auprc, auroc_classes, auprc_classes, accuracy, f_measure, f_measure_classes, challenge_metric |
|
|
| |
| 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) |
| 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) |
| 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.') |
|
|
| |
| def load_table(table_file): |
| |
| |
| |
| |
| |
| |
| |
| 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) |
|
|
| |
| 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)) |
|
|
| |
| rows = [table[0][j+1] for j in range(num_rows)] |
| cols = [table[i+1][0] for i in range(num_cols)] |
|
|
| |
| 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 |
|
|
| |
| def load_weights(weight_file): |
| |
| rows, cols, values = load_table(weight_file) |
|
|
| |
| rows = [set(row.split('|')) for row in rows] |
| cols = [set(col.split('|')) for col in cols] |
| assert(rows == cols) |
|
|
| |
| classes = rows |
| weights = values |
|
|
| return classes, weights |
|
|
| |
| def load_labels(label_files, classes): |
| |
| |
| |
| |
| num_recordings = len(label_files) |
| num_classes = len(classes) |
|
|
| |
| labels = np.zeros((num_recordings, num_classes), dtype=np.bool) |
|
|
| |
| 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 |
|
|
| |
| def load_classifier_outputs(output_files, classes): |
| |
| |
| |
| |
| |
| |
| |
| num_recordings = len(output_files) |
| num_classes = len(classes) |
|
|
| |
| binary_outputs = np.zeros((num_recordings, num_classes), dtype=np.bool) |
| scalar_outputs = np.zeros((num_recordings, num_classes), dtype=np.float64) |
|
|
| |
| for i in range(num_recordings): |
| recording_id, recording_classes, recording_binary_outputs, recording_scalar_outputs = load_outputs(output_files[i]) |
|
|
| |
| 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] |
|
|
| |
| 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) |
| if scalar_values: |
| scalar_outputs[i, j] = np.mean(scalar_values) |
|
|
| return binary_outputs, scalar_outputs |
|
|
| |
| 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) |
|
|
| |
| def compute_confusion_matrices(labels, outputs, normalize=False): |
| |
| |
| |
| |
| |
| |
| |
| 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: |
| A[j, 1, 1] += 1 |
| elif labels[i, j]==0 and outputs[i, j]==1: |
| A[j, 1, 0] += 1 |
| elif labels[i, j]==1 and outputs[i, j]==0: |
| A[j, 0, 1] += 1 |
| elif labels[i, j]==0 and outputs[i, j]==0: |
| A[j, 0, 0] += 1 |
| else: |
| 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: |
| A[j, 1, 1] += 1.0/normalization |
| elif labels[i, j]==0 and outputs[i, j]==1: |
| A[j, 1, 0] += 1.0/normalization |
| elif labels[i, j]==1 and outputs[i, j]==0: |
| A[j, 0, 1] += 1.0/normalization |
| elif labels[i, j]==0 and outputs[i, j]==0: |
| A[j, 0, 0] += 1.0/normalization |
| else: |
| raise ValueError('Error in computing the confusion matrix.') |
|
|
| return A |
|
|
| |
| 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 |
|
|
| |
| def compute_auc(labels, outputs): |
| num_recordings, num_classes = np.shape(labels) |
|
|
| |
| auroc = np.zeros(num_classes) |
| auprc = np.zeros(num_classes) |
|
|
| for k in range(num_classes): |
| |
| thresholds = np.unique(outputs[:, k]) |
| thresholds = np.append(thresholds, thresholds[-1]+1) |
| thresholds = thresholds[::-1] |
| num_thresholds = len(thresholds) |
|
|
| |
| 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) |
|
|
| |
| idx = np.argsort(outputs[:, k])[::-1] |
|
|
| |
| i = 0 |
| for j in range(1, num_thresholds): |
| |
| tp[j] = tp[j-1] |
| fp[j] = fp[j-1] |
| fn[j] = fn[j-1] |
| tn[j] = tn[j-1] |
|
|
| |
| 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 |
|
|
| |
| 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') |
|
|
| |
| |
| |
| |
| 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] |
|
|
| |
| 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 |
|
|
| |
| def compute_modified_confusion_matrix(labels, outputs): |
| |
| |
| num_recordings, num_classes = np.shape(labels) |
| A = np.zeros((num_classes, num_classes)) |
|
|
| |
| for i in range(num_recordings): |
| |
| normalization = float(max(np.sum(np.any((labels[i, :], outputs[i, :]), axis=0)), 1)) |
| |
| for j in range(num_classes): |
| |
| if labels[i, j]: |
| for k in range(num_classes): |
| if outputs[i, k]: |
| A[j, k] += 1.0/normalization |
|
|
| return A |
|
|
| |
| 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.') |
|
|
| |
| A = compute_modified_confusion_matrix(labels, outputs) |
| observed_score = np.nansum(weights * A) |
|
|
| |
| correct_outputs = labels |
| A = compute_modified_confusion_matrix(labels, correct_outputs) |
| correct_score = np.nansum(weights * A) |
|
|
| |
| 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}) |
| |
| |
| |
| |
| 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. |
| """ |
| |
| |
| |
| |
| M = multilabel_confusion_matrix(y_true, y_pred) |
|
|
| |
| |
| |
| TN = M[:, 0, 0].astype(float) |
| FP = M[:, 0, 1].astype(float) |
|
|
| |
| denominator = TN + FP |
|
|
| |
| |
| |
| per_label_specificity = np.divide( |
| TN, |
| denominator, |
| out=np.ones_like(TN), |
| where=denominator != 0 |
| ) |
|
|
| |
| macro_specificity = np.mean(per_label_specificity) |
|
|
| |
| |
| weights = denominator |
| total_weights = np.sum(weights) |
|
|
| if total_weights == 0: |
| |
| |
| |
| weighted_specificity = 1.0 |
| else: |
| |
| weighted_specificity = np.sum(per_label_specificity * weights) / total_weights |
|
|
| return macro_specificity, weighted_specificity |