nsrek commited on
Commit
2bfefb0
·
verified ·
1 Parent(s): e4d73f9

Delete archive

Browse files
archive/classification/aggregate_subject_metrics.py DELETED
@@ -1,96 +0,0 @@
1
- import pandas as pd
2
- import numpy as np
3
- import os
4
- import re
5
- from sklearn.metrics import accuracy_score, precision_score, f1_score
6
-
7
- def get_base_subject_id(subject_id_with_beat):
8
- """
9
- Extracts the base subject ID by removing suffixes like _beatN, beatN, -index_beatN, etc.
10
- """
11
- # Regex to catch variations like _beat1, beat1, -index_beat1, INDEX_beat1, etc.
12
- # It looks for 'beat' preceded by common delimiters and captures everything before it.
13
- match = re.search(r'^(.*?)(?:[_\s-](?:index|INDEX|post|POST|checkpoint|POST PCI|POST PCI \d|POST \d)*)?(?:[_\s-]beat\d+)$', str(subject_id_with_beat))
14
- if match:
15
- return match.group(1).strip()
16
-
17
- # Fallback: just split by 'beat' if regex is too specific
18
- if 'beat' in str(subject_id_with_beat):
19
- return str(subject_id_with_beat).split('beat')[0].rstrip('_ -').strip()
20
-
21
- return str(subject_id_with_beat).strip()
22
-
23
- def calculate_metrics(df_subject):
24
- """
25
- Calculates metrics for subject-level predictions.
26
- """
27
- # Ground truth: since all beats for a subject have the same label, we just take the first
28
- y_true = df_subject['true_label_numeric'].values
29
-
30
- # Prediction based on averaged probability for Pre-Procedural MI
31
- # Assuming probability_Pre-Procedural MI >= 0.5 is Pre-Procedural MI (1)
32
- y_pred = (df_subject['avg_prob_pre_procedural'] >= 0.5).astype(int)
33
-
34
- acc = accuracy_score(y_true, y_pred)
35
- prec = precision_score(y_true, y_pred, zero_division=0)
36
- f1 = f1_score(y_true, y_pred, zero_division=0)
37
-
38
- return acc, prec, f1
39
-
40
- def main():
41
- prediction_dir = r'output/benchmark_results/ecg_matrix_dataset_segmented/model_predictions'
42
-
43
- if not os.path.exists(prediction_dir):
44
- print(f"Directory not found: {prediction_dir}")
45
- return
46
-
47
- # Filter out the combined file if it exists
48
- files = [f for f in os.listdir(prediction_dir) if f.endswith('.csv') and f != 'all_models_predictions_combined.csv']
49
-
50
- results = []
51
-
52
- print(f"{'Model':<20} | {'Accuracy':<10} | {'Precision':<10} | {'F1 Score':<10}")
53
- print("-" * 60)
54
-
55
- for file in files:
56
- model_name = file.replace('_predictions.csv', '')
57
- file_path = os.path.join(prediction_dir, file)
58
-
59
- try:
60
- df = pd.read_csv(file_path)
61
-
62
- # 1. Extract base subject ID
63
- df['base_subject_id'] = df['subject_id'].apply(get_base_subject_id)
64
-
65
- # 2. Group by base subject ID
66
- # We average the probability and take the first true label (since it should be invariant for a subject)
67
- subject_group = df.groupby('base_subject_id').agg({
68
- 'probability_Pre-Procedural MI': 'mean',
69
- 'true_label_numeric': 'first'
70
- }).reset_index()
71
-
72
- subject_group.rename(columns={'probability_Pre-Procedural MI': 'avg_prob_pre_procedural'}, inplace=True)
73
-
74
- # 3. Calculate Metrics
75
- acc, prec, f1 = calculate_metrics(subject_group)
76
-
77
- results.append({
78
- 'Model': model_name,
79
- 'Accuracy': acc,
80
- 'Precision': prec,
81
- 'F1 Score': f1
82
- })
83
-
84
- print(f"{model_name:<20} | {acc:<10.4f} | {prec:<10.4f} | {f1:<10.4f}")
85
-
86
- except Exception as e:
87
- print(f"Error processing {file}: {e}")
88
-
89
- # Save summary to CSV
90
- summary_df = pd.DataFrame(results)
91
- summary_path = os.path.join(prediction_dir, 'subject_level_metrics_summary.csv')
92
- summary_df.to_csv(summary_path, index=False)
93
- print(f"\nSummary saved to: {summary_path}")
94
-
95
- if __name__ == "__main__":
96
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
archive/classification/dataset_curate.py DELETED
@@ -1,66 +0,0 @@
1
- import os
2
- import pandas as pd
3
- from pathlib import Path
4
-
5
- def process_ecg_data_wide_format(root_folder):
6
- """
7
- Process ECG data in wide format (one row per timestamp with all leads as columns)
8
- """
9
- lead_names = ['I', 'aVR', 'V1', 'V4', 'II', 'aVL', 'V2', 'V5', 'III', 'aVF', 'V3', 'V6']
10
-
11
- all_data = []
12
- base_path = Path(root_folder)
13
-
14
- # Process both classes
15
- for class_name, class_folder in [("Pre-Procedural MI", "Pre"),
16
- ("Post-Procedural MI", "Post")]:
17
- class_path = base_path / class_folder
18
-
19
- if class_path.exists():
20
- for csv_file in class_path.glob("*.csv"):
21
- try:
22
- df = pd.read_csv(csv_file)
23
- subject_id = csv_file.stem.replace('_hr', '')
24
-
25
- # Add metadata columns
26
- df['subject_id'] = subject_id
27
- df['class'] = class_name
28
- df['filename'] = csv_file.name
29
- df['timestamp'] = range(len(df))
30
-
31
- all_data.append(df)
32
- print(f"Processed {csv_file.name}")
33
- print(class_name)
34
-
35
- except Exception as e:
36
- print(f"Error processing {csv_file.name}: {str(e)}")
37
-
38
- if all_data:
39
- # Combine all DataFrames
40
- combined_df = pd.concat(all_data, ignore_index=True)
41
-
42
- # Reorder columns to have metadata first
43
- metadata_cols = ['subject_id', 'timestamp', 'class', 'filename']
44
- signal_cols = [col for col in combined_df.columns if col not in metadata_cols]
45
- combined_df = combined_df[metadata_cols + signal_cols]
46
-
47
- return combined_df
48
- return None
49
-
50
- # Usage for wide format:
51
- if __name__ == "__main__":
52
- folder_path = "."
53
-
54
- if os.path.exists(folder_path):
55
- print("Processing ECG data in wide format...")
56
- wide_df = process_ecg_data_wide_format(folder_path)
57
-
58
- if wide_df is not None:
59
- wide_df.to_csv("combined_ecg_data_wide.csv", index=False)
60
- print("Wide format data saved to combined_ecg_data_wide.csv")
61
- print(f"Shape: {wide_df.shape}")
62
- print(f"Columns: {list(wide_df.columns)}")
63
- print("\nSample data:")
64
- print(wide_df.head())
65
- else:
66
- print(f"Folder '{folder_path}' not found.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
archive/classification/feature_analysis.py DELETED
@@ -1,93 +0,0 @@
1
- import os
2
- import pandas as pd
3
- import numpy as np
4
- from utils.mlp_feature_downsample import (
5
- perform_comprehensive_heartbeat_analysis,
6
- perform_full_signal_analysis,
7
- DEFAULT_HEARTBEAT_OUTPUT_DIR,
8
- DEFAULT_TARGET_FS,
9
- )
10
-
11
- if __name__ == "__main__":
12
-
13
- # Create output directory
14
- os.makedirs('output', exist_ok=True)
15
-
16
- # Use the processed CSV from the previous step
17
- combined_input_csv = 'data/ecg_dataset_processed.csv'
18
-
19
- # Load and verify the data
20
- df_combined = pd.read_csv(combined_input_csv)
21
- print(f"Loaded combined dataset shape: {df_combined.shape}")
22
- print(f"Unique subjects: {df_combined['subject_id'].nunique()}")
23
-
24
- print("\n" + "="*50)
25
- print("RUNNING SEGMENTED HEARTBEAT ANALYSIS (500 Hz)")
26
- print("="*50)
27
-
28
- heartbeat_results = perform_comprehensive_heartbeat_analysis(
29
- input_csv=combined_input_csv,
30
- output_dir=DEFAULT_HEARTBEAT_OUTPUT_DIR,
31
- pca_components=50,
32
- shap_samples=100,
33
- shap_background_size=50,
34
- hidden_layer_sizes=(100, 50),
35
- max_iter=1000,
36
- target_fs=500,
37
- )
38
-
39
- print("\n" + "="*50)
40
- print("RUNNING FULL SIGNAL ANALYSIS (500 Hz)")
41
- print("="*50)
42
-
43
- full_signal_results = perform_full_signal_analysis(
44
- input_csv=combined_input_csv,
45
- output_dir='output/full_signal_analysis',
46
- pca_components=50,
47
- shap_samples=100,
48
- shap_background_size=50,
49
- hidden_layer_sizes=(100, 50),
50
- max_iter=1000,
51
- target_fs=500,
52
- )
53
-
54
- # Print Summary Results
55
- print("\n" + "#" * 60)
56
- print("FINAL SUMMARY RESULTS")
57
- print("#" * 60)
58
-
59
- if heartbeat_results is not None and 'metrics' in heartbeat_results:
60
- m = heartbeat_results['metrics']
61
- print("\n--- Segmented Heartbeat Analysis ---")
62
- print(f" Accuracy: {m['accuracy']:.4f}")
63
- print(f" Precision: {m['precision']:.4f}")
64
- print(f" Recall: {m['recall']:.4f}")
65
- print(f" F1-score: {m['f1']:.4f}")
66
- print(f" Sensitivity: {m['sensitivity']:.4f}")
67
- print(f" Specificity: {m['specificity']:.4f}")
68
- print("=" * 50)
69
- '''
70
- ############################################## Supervised Classifier Benchmarking ##################################
71
- #print("\nPerforming classifier benchmarking...")
72
- benchmark_results, models = perform_benchmark(combined_input_csv)
73
-
74
- print("\nBenchmarking Results:")
75
- print(benchmark_results)
76
-
77
- # Save all results to a summary file
78
- results_summary = {
79
- #'kmeans_results': kmeans_results,
80
- 'mlp_results': mlp_results,
81
- 'benchmark_results': benchmark_results,
82
- }
83
-
84
- # Save summary to file
85
- summary_df = pd.DataFrame([
86
- #{'model': 'K-Means', 'details': str(kmeans_results)},
87
- #{'model': 'MLP', 'details': str(mlp_results)},
88
- {'model': 'Benchmark', 'details': str(benchmark_results)},
89
- ])
90
-
91
- summary_df.to_csv('output/analysis_summary.csv', index=False)
92
- print("\nAnalysis summary saved to 'output/analysis_summary.csv'")
93
- '''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
archive/classification/prepare_multiclass_data.py DELETED
@@ -1,71 +0,0 @@
1
- import pandas as pd
2
- import os
3
-
4
- if __name__ == "__main__":
5
- # ── CONFIGURATION ──
6
- # Input CSV file path
7
- input_csv = 'data/combined_ecg_matrix_data_segmented.csv'
8
-
9
- # Output CSV file path
10
- output_csv = 'data/combined_ecg_matrix_data_filtered.csv'
11
-
12
- # Option 1: Only include specific classes (set to None to use all)
13
- # e.g. ['Pre', 'Post', 'Index']
14
- include = None
15
-
16
- # Option 2: Exclude specific classes (set to None to skip)
17
- # e.g. ['Dimi']
18
- exclude = None
19
-
20
- # Option 3: Combine/merge classes under a new label (set to None to skip)
21
- # e.g. {'Surgery': ['Pre', 'Post'], 'Other': ['Dimi', 'Discharge']}
22
- combine = {'Post-Procedural MI': ['Discharge', 'Dimi', 'Post'], 'Pre-Procedural MI': ['Index', 'Pre']}
23
-
24
- print(f"Loading data from '{input_csv}'...")
25
- if not os.path.exists(input_csv):
26
- print(f"Error: Input file '{input_csv}' not found.")
27
- exit(1)
28
-
29
- df = pd.read_csv(input_csv)
30
- initial_rows = len(df)
31
- print(f"Loaded {initial_rows} rows.")
32
-
33
- if 'class' not in df.columns:
34
- print("Error: 'class' column not found in input CSV.")
35
- exit(1)
36
-
37
- print(f"\nOriginal classes: {sorted(df['class'].dropna().unique())}")
38
- for cls in sorted(df['class'].dropna().unique()):
39
- print(f" {cls}: {len(df[df['class'] == cls])} rows")
40
-
41
- # ── 1. Combine/Merge Classes ──
42
- if combine:
43
- print("\nApplying class combinations...")
44
- for new_name, old_names in combine.items():
45
- df.loc[df['class'].isin(old_names), 'class'] = new_name
46
- print(f" Combined {old_names} -> '{new_name}'")
47
-
48
- # ── 2. Include Classes ──
49
- if include:
50
- print(f"\nIncluding only: {include}")
51
- df = df[df['class'].isin(include)]
52
-
53
- # ── 3. Exclude Classes ──
54
- if exclude:
55
- print(f"\nExcluding: {exclude}")
56
- df = df[~df['class'].isin(exclude)]
57
-
58
- if len(df) == 0:
59
- print("\nERROR: No data remaining after class filtering!")
60
- exit(1)
61
-
62
- print(f"\nData processing complete. Remaining rows: {len(df)} ({(len(df)/initial_rows)*100:.1f}%)")
63
-
64
- final_classes = sorted(df['class'].dropna().unique())
65
- print(f"\nFinal classes hierarchy: {final_classes}")
66
- for cls in final_classes:
67
- print(f" {cls}: {len(df[df['class'] == cls])} rows")
68
-
69
- os.makedirs(os.path.dirname(output_csv), exist_ok=True)
70
- df.to_csv(output_csv, index=False)
71
- print(f"\nSaved filtered data to '{output_csv}'")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
archive/classification/re_plotter.py DELETED
@@ -1,494 +0,0 @@
1
- import numpy as np
2
- import pandas as pd
3
- import matplotlib.pyplot as plt
4
- import os
5
- from matplotlib.gridspec import GridSpec
6
-
7
- def replot_idealized_shap_all_leads(shap_values_csv, top_regions_csv, output_dir, n_timesteps=40, fs=500):
8
- """
9
- Replot idealized heartbeat with SHAP values for all leads below it in order of importance
10
- """
11
-
12
- # Create output directory
13
- os.makedirs(output_dir, exist_ok=True)
14
-
15
- # Load SHAP values and top regions
16
- print("Loading SHAP values and top regions...")
17
- shap_df = pd.read_csv(shap_values_csv)
18
- top_regions_df = pd.read_csv(top_regions_csv)
19
-
20
- print(f"SHAP DataFrame shape: {shap_df.shape}")
21
- print(f"Number of timesteps inferred from columns: {len([col for col in shap_df.columns if '_t' in col]) // 12}") # 12 leads
22
-
23
- # Calculate mean absolute SHAP values for each feature
24
- mean_shap_abs = np.abs(shap_df).mean(axis=0)
25
-
26
- # Extract unique leads and their overall importance
27
- leads = []
28
- lead_importance = {}
29
-
30
- for feature_name in mean_shap_abs.index:
31
- if '_t' in feature_name:
32
- lead = feature_name.split('_t')[0]
33
- if lead not in lead_importance:
34
- lead_importance[lead] = 0
35
- lead_importance[lead] += mean_shap_abs[feature_name]
36
-
37
- # Sort leads by overall importance
38
- sorted_leads = sorted(lead_importance.items(), key=lambda x: x[1], reverse=True)
39
- lead_names = [lead for lead, importance in sorted_leads]
40
- lead_importances = [importance for lead, importance in sorted_leads]
41
-
42
- print("Lead importance ranking:")
43
- for i, (lead, importance) in enumerate(sorted_leads):
44
- print(f"{i+1}. {lead}: {importance:.4f}")
45
-
46
- # Dynamically determine n_timesteps from the data
47
- actual_timesteps = infer_timesteps_from_data(shap_df, lead_names[0])
48
- print(f"Using {actual_timesteps} timesteps based on data")
49
-
50
- # Create time points in percentage (0-100%)
51
- time_points_pct = np.linspace(0, 100, actual_timesteps)
52
-
53
- # Create main plot with idealized ECG at top and all leads below
54
- create_main_comprehensive_plot(lead_names, shap_df, time_points_pct, output_dir, lead_importances, actual_timesteps)
55
-
56
- return lead_names, lead_importances
57
-
58
- def infer_timesteps_from_data(shap_df, sample_lead):
59
- """Infer number of timesteps from the data"""
60
- lead_columns = [col for col in shap_df.columns if col.startswith(sample_lead + '_t')]
61
- return len(lead_columns)
62
-
63
- def create_main_comprehensive_plot(lead_names, shap_df, time_points_pct, output_dir, lead_importances, n_timesteps):
64
- """
65
- Create main comprehensive plot with idealized ECG at top and all leads' SHAP values below
66
- """
67
- n_leads = len(lead_names)
68
-
69
- # Create larger figure with better spacing
70
- fig = plt.figure(figsize=(18, 4 + n_leads * 1.5)) # Increased size
71
-
72
- # Create grid with more space for idealized ECG and better spacing for leads
73
- gs = GridSpec(n_leads + 1, 1, figure=fig, height_ratios=[3] + [1.2] * n_leads, hspace=0.4)
74
-
75
- # Plot 1: Idealized ECG at the top
76
- ax_ideal = fig.add_subplot(gs[0])
77
- plot_idealized_heartbeat(ax_ideal, time_points_pct)
78
-
79
- # Plot 2: SHAP values for each lead below (in order of importance)
80
- for i, lead in enumerate(lead_names):
81
- ax_shap = fig.add_subplot(gs[i + 1])
82
-
83
- # Get SHAP values for this lead
84
- lead_shap_values = extract_lead_shap_values(shap_df, lead, n_timesteps)
85
-
86
- # Plot SHAP bars for this lead with cardiac cycle regions
87
- plot_lead_shap_bars(ax_shap, time_points_pct, lead_shap_values, lead, lead_importances[i], i + 1, n_leads)
88
-
89
- plt.tight_layout()
90
- plt.savefig(f'{output_dir}/idealized_with_all_leads_shap.png',
91
- dpi=300, bbox_inches='tight')
92
- plt.savefig(f'{output_dir}/idealized_with_all_leads_shap.pdf',
93
- bbox_inches='tight')
94
- plt.close()
95
-
96
- print(f"Main comprehensive plot saved: {output_dir}/idealized_with_all_leads_shap.png")
97
-
98
- def extract_lead_shap_values(shap_df, lead, n_timesteps):
99
- """
100
- Extract SHAP values for a specific lead across all timesteps
101
- """
102
- lead_shap_values = np.zeros(n_timesteps)
103
- found_features = 0
104
-
105
- for t in range(n_timesteps):
106
- feature_name = f"{lead}_t{t}"
107
- if feature_name in shap_df.columns:
108
- # Take mean absolute SHAP value for this time point
109
- lead_shap_values[t] = np.abs(shap_df[feature_name]).mean()
110
- found_features += 1
111
- else:
112
- print(f"Warning: Feature {feature_name} not found in SHAP DataFrame")
113
- lead_shap_values[t] = 0
114
-
115
- print(f"Lead {lead} - Found {found_features}/{n_timesteps} features, SHAP range: [{lead_shap_values.min():.6f}, {lead_shap_values.max():.6f}]")
116
- return lead_shap_values
117
-
118
- def plot_idealized_heartbeat(ax, time_points_pct):
119
- """
120
- Plot accurate idealized ECG heartbeat on given axis with proper waveform characteristics
121
- """
122
- # Create time array
123
- t = time_points_pct
124
-
125
- # Initialize ECG signal
126
- ideal_ecg = np.zeros_like(t, dtype=float)
127
-
128
- # Define standard ECG regions with PR segment removed and P-wave extended
129
- ecg_regions = [
130
- {'name': 'P Wave', 'description': 'Atrial Depolarization', 'start': 0, 'end': 25, 'center': 12.5, 'color': 'lightblue', 'alpha': 0.5},
131
- {'name': 'QRS Complex', 'description': 'Ventricular Depolarization', 'start': 25, 'end': 42, 'center': 33.5, 'color': 'lightgreen', 'alpha': 0.5},
132
- {'name': 'ST Segment', 'description': 'Ventricular Plateau', 'start': 42, 'end': 58, 'center': 50, 'color': 'orange', 'alpha': 0.5},
133
- {'name': 'T Wave', 'description': 'Ventricular Repolarization', 'start': 58, 'end': 83, 'center': 70.5, 'color': 'violet', 'alpha': 0.5},
134
- {'name': 'TP Segment', 'description': 'Ventricular Diastole', 'start': 83, 'end': 100, 'center': 91.5, 'color': 'saddlebrown', 'alpha': 0.5}
135
- ]
136
-
137
- # Define precise landmark positions and values FIRST to ensure consistency
138
- # We align the signal generation to these landmarks
139
- landmark_data = [
140
- # P wave peak - Center of P region
141
- {'time': 12.5, 'label': 'P', 'value': 0.25, 'description': 'Atrial Depolarization'},
142
- # Q wave nadir - Early QRS
143
- {'time': 27.0, 'label': 'Q', 'value': -0.15, 'description': 'Start of Ventricular Depolarization'},
144
- # R wave peak - Center of QRS
145
- {'time': 33.5, 'label': 'R', 'value': 1.0, 'description': 'Ventricular Depolarization Peak'},
146
- # S wave nadir - Late QRS
147
- {'time': 39.0, 'label': 'S', 'value': -0.25, 'description': 'End of Ventricular Depolarization'},
148
- # T wave peak - Center of T region
149
- {'time': 70.5, 'label': 'T', 'value': 0.35, 'description': 'Ventricular Repolarization'}
150
- ]
151
-
152
- # Helper to create Gaussian waves
153
- def gaussian(x, mu, sig, amp):
154
- return amp * np.exp(-np.power(x - mu, 2.) / (2 * np.power(sig, 2.)))
155
-
156
- # Create accurate ECG waveform components using Gaussians for natural shape
157
- for i, time_val in enumerate(t):
158
- val = 0.0
159
-
160
- # P Wave: centered at 12.5, wide
161
- val += gaussian(time_val, 12.5, 4.0, 0.25)
162
-
163
- # Q Wave: centered at 27.0, narrow
164
- val += gaussian(time_val, 27.0, 1.2, -0.15)
165
-
166
- # R Wave: centered at 33.5, narrow and tall
167
- val += gaussian(time_val, 33.5, 1.5, 1.0)
168
-
169
- # S Wave: centered at 39.0, narrow
170
- val += gaussian(time_val, 39.0, 1.2, -0.25)
171
-
172
- # T Wave: centered at 70.5, wide
173
- val += gaussian(time_val, 70.5, 6.0, 0.35)
174
-
175
- # Assign to array
176
- ideal_ecg[i] = val
177
-
178
- # Plot cardiac cycle regions with increased alpha
179
- for region in ecg_regions:
180
- ax.axvspan(region['start'], region['end'], alpha=region['alpha'], color=region['color'], zorder=0)
181
-
182
- # Add region labels - name above and description below
183
- ax.text(region['center'], -0.45, region['name'],
184
- ha='center', va='center', fontsize=9, fontweight='bold',
185
- bbox=dict(boxstyle="round,pad=0.2", facecolor='white', alpha=0.9, edgecolor=region['color']),
186
- zorder=10)
187
- ax.text(region['center'], -0.65, region['description'],
188
- ha='center', va='center', fontsize=8,
189
- bbox=dict(boxstyle="round,pad=0.2", facecolor='white', alpha=0.8, edgecolor=region['color']),
190
- zorder=10)
191
-
192
- # Plot idealized ECG
193
- ax.plot(t, ideal_ecg, 'k-', linewidth=2, label='Idealized ECG', zorder=5)
194
- ax.set_title('Idealized ECG with Standard Regions', fontsize=16, fontweight='bold', pad=20)
195
- ax.set_ylabel('Normalized Amplitude', fontsize=12)
196
- ax.grid(True, alpha=0.3, zorder=1)
197
-
198
- # Plot ECG landmarks with annotations
199
- for landmark in landmark_data:
200
- # Calculate actual value on the curve at this time
201
- # This ensures the point is exactly ON the line, even if Gaussian summation shifts it slightly
202
- curve_val = 0.0
203
- t_lm = landmark['time']
204
- curve_val += gaussian(t_lm, 12.5, 4.0, 0.25)
205
- curve_val += gaussian(t_lm, 27.0, 1.2, -0.15)
206
- curve_val += gaussian(t_lm, 33.5, 1.5, 1.0)
207
- curve_val += gaussian(t_lm, 39.0, 1.2, -0.25)
208
- curve_val += gaussian(t_lm, 70.5, 6.0, 0.35)
209
-
210
- ax.plot(t_lm, curve_val, 'ro', markersize=8, zorder=10)
211
-
212
- # Position text based on whether it's a peak or trough
213
- if landmark['value'] > 0:
214
- text_y_offset = 0.15
215
- va = 'bottom'
216
- else:
217
- text_y_offset = -0.15
218
- va = 'top'
219
-
220
- ax.annotate(landmark['label'],
221
- (t_lm, curve_val),
222
- xytext=(0, text_y_offset * 30),
223
- textcoords='offset points',
224
- ha='center', va=va,
225
- fontweight='bold',
226
- fontsize=12,
227
- bbox=dict(boxstyle="round,pad=0.3", facecolor='white', alpha=0.9, edgecolor='red'),
228
- zorder=15)
229
-
230
- ax.set_ylim(-0.8, 1.5) # Increased range to accommodate annotations
231
- ax.set_xlim(0, 100)
232
-
233
- # Remove x-axis labels and ticks for the top plot
234
- ax.set_xticklabels([])
235
- ax.set_xticks([])
236
- ax.set_xlabel('')
237
-
238
- ax.tick_params(axis='y', which='major', labelsize=10)
239
-
240
- # Add a legend for the waveform
241
- ax.legend(loc='upper right', framealpha=0.9)
242
-
243
- def plot_lead_shap_bars(ax, time_points_pct, shap_importance, lead, lead_importance, rank, total_leads):
244
- """
245
- Plot SHAP importance bars for a single lead with cardiac cycle regions
246
- """
247
- if len(shap_importance) == len(time_points_pct):
248
- # Define cardiac cycle regions with PR segment removed and P-wave extended
249
- ecg_regions = [
250
- {'name': 'P Wave', 'start': 0, 'end': 25, 'color': 'lightblue', 'alpha': 0.5},
251
- {'name': 'QRS Complex', 'start': 25, 'end': 42, 'color': 'lightgreen', 'alpha': 0.5},
252
- {'name': 'ST Segment', 'start': 42, 'end': 58, 'color': 'orange', 'alpha': 0.5},
253
- {'name': 'T Wave', 'start': 58, 'end': 83, 'color': 'violet', 'alpha': 0.5},
254
- {'name': 'TP Segment', 'start': 83, 'end': 100, 'color': 'saddlebrown', 'alpha': 0.5}
255
- ]
256
-
257
- # Plot cardiac cycle regions as background
258
- for region in ecg_regions:
259
- ax.axvspan(region['start'], region['end'], alpha=region['alpha'], color=region['color'], zorder=0)
260
-
261
- bar_width = 100 / len(time_points_pct) * 0.8
262
-
263
- # Debug info
264
- max_shap = np.max(shap_importance) if len(shap_importance) > 0 else 0
265
- print(f" {rank}. {lead} - Max SHAP: {max_shap:.6f}")
266
-
267
- # Create bars with dark grey color
268
- if max_shap > 0:
269
- bars = ax.bar(time_points_pct, shap_importance, alpha=0.8, color='darkslategray',
270
- width=bar_width, edgecolor='black', linewidth=0.5, zorder=5)
271
-
272
- # Highlight top region
273
- top_idx = np.argmax(shap_importance)
274
- top_value = shap_importance[top_idx]
275
- top_time = time_points_pct[top_idx]
276
-
277
- # Highlight the top bar with red
278
- bars[top_idx].set_color('red')
279
- bars[top_idx].set_alpha(1.0)
280
- bars[top_idx].set_edgecolor('darkred')
281
- bars[top_idx].set_linewidth(1)
282
-
283
- # Add annotation for top region
284
- if top_value > max_shap * 0.1: # Only annotate if significant
285
- ax.text(top_time, top_value * 0.9,
286
- f'{top_time:.1f}%',
287
- ha='center', va='top', fontsize=8, fontweight='bold', zorder=10,
288
- bbox=dict(boxstyle="round,pad=0.2", facecolor='white', alpha=0.9))
289
- else:
290
- # If no SHAP values, plot zeros with different color
291
- bars = ax.bar(time_points_pct, shap_importance, alpha=0.3, color='gray',
292
- width=bar_width, edgecolor='darkgray', linewidth=0.5, zorder=5)
293
-
294
- # Set labels with better formatting
295
- ax.set_ylabel(f'{lead}\n({lead_importance:.3f})\nSHAP Value', fontsize=10, rotation=0,
296
- ha='right', va='center', labelpad=10)
297
- ax.grid(True, alpha=0.3, zorder=1)
298
- ax.set_xlim(0, 100)
299
-
300
- # Remove x-axis labels and ticks for all but bottom plot
301
- if rank < total_leads:
302
- ax.set_xticklabels([])
303
- ax.set_xticks([])
304
- else:
305
- ax.set_xlabel('Cardiac Cycle (%)', fontsize=11)
306
- ax.tick_params(axis='x', labelsize=9)
307
-
308
- # Set y-axis limit based on max SHAP value with some margin
309
- max_shap_val = max_shap if max_shap > 0 else 0.001
310
- ax.set_ylim(0, max_shap_val * 1.3)
311
- ax.tick_params(axis='y', labelsize=8)
312
-
313
- # Remove y-axis ticks and labels (the numbers 1-12)
314
- ax.set_yticklabels([])
315
- ax.set_yticks([])
316
-
317
- def create_lead_importance_table(lead_names, lead_importances, output_dir):
318
- """
319
- Create a simple table showing lead importance ranking
320
- """
321
- # Create importance DataFrame
322
- importance_df = pd.DataFrame({
323
- 'Rank': range(1, len(lead_names) + 1),
324
- 'Lead': lead_names,
325
- 'Total_SHAP_Importance': lead_importances
326
- })
327
-
328
- # Save to CSV
329
- importance_df.to_csv(f'{output_dir}/lead_importance_ranking.csv', index=False)
330
-
331
- # Create simple text summary
332
- with open(f'{output_dir}/lead_importance_summary.txt', 'w') as f:
333
- f.write("Lead Importance Ranking (by Total SHAP Value)\n")
334
- f.write("=" * 50 + "\n")
335
- for i, (lead, importance) in enumerate(zip(lead_names, lead_importances)):
336
- f.write(f"{i+1:2d}. {lead:4s}: {importance:.4f}\n")
337
-
338
- print(f"Lead importance table saved: {output_dir}/lead_importance_ranking.csv")
339
- return importance_df
340
-
341
- # Function to fix other plots (avg_with_shap plots)
342
- def fix_other_plots(analysis_output_dir, n_timesteps=40):
343
- """
344
- Fix the other plots that are not showing SHAP values
345
- """
346
- print("\nFixing other plots...")
347
-
348
- # Load the necessary data
349
- shap_values_csv = f'{analysis_output_dir}/shap/shap_values_detailed.csv'
350
- top_regions_csv = f'{analysis_output_dir}/shap/top_shap_regions.csv'
351
-
352
- if not os.path.exists(shap_values_csv) or not os.path.exists(top_regions_csv):
353
- print("Required files not found. Skipping other plots.")
354
- return
355
-
356
- shap_df = pd.read_csv(shap_values_csv)
357
- top_regions_df = pd.read_csv(top_regions_csv)
358
-
359
- # Get lead names from top regions
360
- lead_names = top_regions_df['feature'].unique()
361
-
362
- # Create time points
363
- time_points_pct = np.linspace(0, 100, n_timesteps)
364
-
365
- # Recreate avg_with_shap plots for each lead
366
- for lead in lead_names:
367
- create_fixed_avg_shap_plot(lead, shap_df, time_points_pct, analysis_output_dir, n_timesteps)
368
-
369
- def create_fixed_avg_shap_plot(lead, shap_df, time_points_pct, output_dir, n_timesteps):
370
- """
371
- Create fixed avg_with_shap plot for a single lead
372
- """
373
- # Extract SHAP values
374
- lead_shap_values = extract_lead_shap_values(shap_df, lead, n_timesteps)
375
-
376
- # Create figure
377
- fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(15, 8))
378
-
379
- # Plot 1: Placeholder for average heartbeat (you would need the original data for this)
380
- ax1.text(0.5, 0.5, f'Average Heartbeats - {lead}\n(Original data required)',
381
- ha='center', va='center', transform=ax1.transAxes, fontsize=14)
382
- ax1.set_title(f'Average Heartbeats - {lead}', fontsize=16)
383
- ax1.set_ylabel('Normalized Amplitude')
384
- ax1.grid(True, alpha=0.3)
385
-
386
- # Plot 2: SHAP importance with updated regions
387
- # Define cardiac cycle regions with PR segment removed and P-wave extended
388
- ecg_regions = [
389
- {'name': 'P Wave', 'start': 0, 'end': 25, 'color': 'lightblue', 'alpha': 0.3},
390
- {'name': 'QRS Complex', 'start': 25, 'end': 42, 'color': 'lightgreen', 'alpha': 0.3},
391
- {'name': 'ST Segment', 'start': 42, 'end': 58, 'color': 'orange', 'alpha': 0.3},
392
- {'name': 'T Wave', 'start': 58, 'end': 83, 'color': 'violet', 'alpha': 0.3},
393
- {'name': 'TP Segment', 'start': 83, 'end': 100, 'color': 'saddlebrown', 'alpha': 0.3}
394
- ]
395
-
396
- # Plot cardiac cycle regions as background
397
- for region in ecg_regions:
398
- ax2.axvspan(region['start'], region['end'], alpha=region['alpha'], color=region['color'], zorder=0)
399
-
400
- bar_width = 100 / len(time_points_pct) * 0.8
401
- ax2.bar(time_points_pct, lead_shap_values, alpha=0.7, color='darkslategray',
402
- width=bar_width, label='SHAP Importance', zorder=5)
403
- ax2.set_title(f'SHAP Importance - {lead}', fontsize=16)
404
- ax2.set_xlabel('Cardiac Cycle (%)')
405
- ax2.set_ylabel('SHAP Value')
406
- ax2.grid(True, alpha=0.3)
407
- ax2.legend()
408
-
409
- plt.tight_layout()
410
- plt.savefig(f'{output_dir}/heartbeat_plots/avg_with_shap_{lead}_FIXED.png',
411
- dpi=300, bbox_inches='tight')
412
- plt.close()
413
-
414
- print(f"Fixed plot saved: {output_dir}/heartbeat_plots/avg_with_shap_{lead}_FIXED.png")
415
-
416
- # Additional function to create standalone idealized ECG plot
417
- def create_standalone_idealized_ecg_plot(output_dir, n_timesteps=40):
418
- """
419
- Create a standalone idealized ECG plot with the updated regions
420
- """
421
- time_points_pct = np.linspace(0, 100, n_timesteps)
422
-
423
- fig, ax = plt.subplots(figsize=(15, 6))
424
- plot_idealized_heartbeat(ax, time_points_pct)
425
-
426
- plt.tight_layout()
427
- plt.savefig(f'{output_dir}/standalone_idealized_ecg.png',
428
- dpi=300, bbox_inches='tight')
429
- plt.savefig(f'{output_dir}/standalone_idealized_ecg.pdf',
430
- bbox_inches='tight')
431
- plt.close()
432
-
433
- print(f"Standalone idealized ECG plot saved: {output_dir}/standalone_idealized_ecg.png")
434
-
435
- # Main execution function
436
- def main():
437
- """
438
- Main function to run the SHAP replotting
439
- """
440
- # Define paths
441
- analysis_output_dir = 'results/heartbeat_analysis_500Hz_good'
442
- shap_values_csv = f'{analysis_output_dir}/shap/shap_values_detailed.csv'
443
- top_regions_csv = f'{analysis_output_dir}/shap/top_shap_regions.csv'
444
- output_dir = f'{analysis_output_dir}/shap_replots'
445
-
446
- # Check if files exist
447
- if not os.path.exists(shap_values_csv):
448
- print(f"Error: SHAP values file not found at {shap_values_csv}")
449
- return
450
- if not os.path.exists(top_regions_csv):
451
- print(f"Error: Top regions file not found at {top_regions_csv}")
452
- return
453
-
454
- print("Starting SHAP replotting...")
455
- print(f"SHAP values file: {shap_values_csv}")
456
- print(f"Top regions file: {top_regions_csv}")
457
- print(f"Output directory: {output_dir}")
458
-
459
- # Replot all leads
460
- lead_names, lead_importances = replot_idealized_shap_all_leads(
461
- shap_values_csv, top_regions_csv, output_dir
462
- )
463
-
464
- # Create lead importance table
465
- importance_df = create_lead_importance_table(lead_names, lead_importances, output_dir)
466
-
467
- # Create standalone idealized ECG plot
468
- create_standalone_idealized_ecg_plot(output_dir)
469
-
470
- # Fix other plots
471
- fix_other_plots(analysis_output_dir)
472
-
473
- print("\n" + "="*60)
474
- print("SHAP REPLOTTING COMPLETE!")
475
- print("="*60)
476
- print(f"Results saved to: {output_dir}")
477
- print(f"Lead ranking (by importance):")
478
- for i, (lead, importance) in enumerate(zip(lead_names, lead_importances)):
479
- print(f" {i+1:2d}. {lead}: {importance:.4f}")
480
-
481
- # Alternative: Function to use in your existing code
482
- def replot_from_existing_analysis(analysis_output_dir):
483
- """
484
- Replot from existing analysis output directory
485
- """
486
- shap_values_csv = f'{analysis_output_dir}/shap/shap_values_detailed.csv'
487
- top_regions_csv = f'{analysis_output_dir}/shap/top_shap_regions.csv'
488
- output_dir = f'{analysis_output_dir}/shap_replots'
489
-
490
- return replot_idealized_shap_all_leads(shap_values_csv, top_regions_csv, output_dir)
491
-
492
- # Example usage:
493
- if __name__ == "__main__":
494
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
archive/classification/run_benchmarking.py DELETED
@@ -1,64 +0,0 @@
1
- import argparse
2
- import os
3
- from utils.classifier_benchmarking import perform_benchmark
4
-
5
- def run_benchmarking(input_file, output_folder=None):
6
- """
7
- Run benchmarking on the specified input file.
8
-
9
- Args:
10
- input_file: Path to input CSV
11
- output_folder: Directory to save results. If None, derives from input filename.
12
- """
13
- if not os.path.exists(input_file):
14
- print(f"Error: Input file '{input_file}' not found.")
15
- return
16
-
17
- # Determine output folder if not specified
18
- if output_folder is None:
19
- base_name = os.path.splitext(os.path.basename(input_file))[0]
20
- output_folder = f'output/benchmark_results/{base_name}'
21
-
22
- print(f"\n{'='*60}")
23
- print(f"STARTING BENCHMARKING")
24
- print(f"Input: {input_file}")
25
- print(f"Output: {output_folder}")
26
- print(f"{'='*60}")
27
-
28
- try:
29
- perform_benchmark(input_file, output_dir=output_folder)
30
- print(f"\nBenchmarking complete for {input_file}")
31
- print(f"Results saved to: {output_folder}")
32
- except Exception as e:
33
- print(f"\nError running benchmark: {e}")
34
- import traceback
35
- traceback.print_exc()
36
-
37
- if __name__ == "__main__":
38
- parser = argparse.ArgumentParser(description="Run ECG Classifier Benchmarking")
39
- parser.add_argument('--input', type=str, help='Path to input CSV file (optional)')
40
- parser.add_argument('--all', action='store_true', help='Run on both standard datasets (segmented and full)')
41
-
42
- args = parser.parse_args()
43
-
44
- # Default files
45
- segmented_file = 'data/segmented_heartbeats.csv'
46
- processed_file = 'data/ecg_dataset_processed.csv'
47
-
48
- # Define explicit output paths for standard runs
49
- output_segmented = 'output/benchmark_results/segmented_heartbeats'
50
- output_processed = 'output/benchmark_results/ecg_dataset_processed'
51
-
52
- if args.input:
53
- # Run on user specified file (folder derived from filename)
54
- run_benchmarking(args.input)
55
- elif args.all:
56
- # Run on both with specific folders
57
- print("Running comprehensive benchmarking on ALL datasets...")
58
- run_benchmarking(segmented_file, output_folder=output_segmented)
59
- run_benchmarking(processed_file, output_folder=output_processed)
60
- else:
61
- # Default run
62
- print("No input specified. Defaulting to processed ECG data.")
63
- print(f"Usage: python run_benchmarking.py --input <path> OR --all")
64
- run_benchmarking(processed_file, output_folder=output_processed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
archive/classification/run_classification.py DELETED
@@ -1,34 +0,0 @@
1
- from utils.ecg_classification import perform_classification_analysis
2
- import os
3
- # Import the universally defined target frequency
4
- from run_segmentation import TARGET_FS
5
-
6
- if __name__ == "__main__":
7
- # Define paths
8
- input_csv = 'data/ecg_matrix_dataset_segmented.csv'
9
- output_dir = 'output/heartbeat_analysis_matrix'
10
-
11
- # Check if input exists
12
- if not os.path.exists(input_csv):
13
- print(f"Error: Input file {input_csv} not found.")
14
- print("Please run run_segmentation.py first.")
15
- exit(1)
16
-
17
- print(f"Starting classification analysis...")
18
- print(f"Input: {input_csv}")
19
- print(f"Output Directory: {output_dir}")
20
- print(f"Target Frequency: {TARGET_FS} Hz (imported from run_segmentation.py)")
21
-
22
- # Run analysis
23
- # Normalization happens inside this function
24
- results = perform_classification_analysis(
25
- segmented_csv=input_csv,
26
- output_dir=output_dir,
27
- target_fs=TARGET_FS
28
- )
29
-
30
- if results:
31
- print("\nAnalysis successful!")
32
- print(f"Results available in {output_dir}")
33
- else:
34
- print("\nAnalysis failed.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
archive/classification/run_inference.py DELETED
@@ -1,297 +0,0 @@
1
- """
2
- Run inference on new ECG data using a previously saved model.
3
-
4
- Loads the serialised model and its metadata, applies identical preprocessing
5
- (per-subject normalisation, reshape to numpy3D), and outputs per-subject
6
- predictions with confidence scores.
7
-
8
- Usage:
9
- # MI vs Normal
10
- python run_inference.py --model mi_vs_normal --input data/new_ecg.csv
11
-
12
- # OMI vs non-OMI
13
- python run_inference.py --model omi_vs_nonomi --input data/new_ecg.csv
14
-
15
- # Custom output path
16
- python run_inference.py --model mi_vs_normal --input data/new_ecg.csv --output results/preds.csv
17
- """
18
-
19
- import os
20
- import argparse
21
- import pickle
22
- import json
23
- import numpy as np
24
- import pandas as pd
25
- from tqdm.auto import tqdm
26
-
27
- # ─── Configuration ───────────────────────────────────────────────────────────
28
-
29
- MODELS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../models/classifier_models'))
30
-
31
- # ─── Model Loading ───────────────────────────────────────────────────────────
32
-
33
-
34
- def load_model(use_case):
35
- """
36
- Load a saved model and its metadata from the models directory.
37
-
38
- Args:
39
- use_case: One of 'mi_vs_normal' or 'omi_vs_nonomi'.
40
-
41
- Returns:
42
- (model, metadata) tuple.
43
- """
44
- model_dir = os.path.join(MODELS_DIR, use_case)
45
-
46
- # Metadata
47
- metadata_path = os.path.join(model_dir, 'model_metadata.json')
48
- if not os.path.exists(metadata_path):
49
- raise FileNotFoundError(
50
- f"Model metadata not found: {metadata_path}\n"
51
- f"Have you run train_and_save_models.py first?")
52
-
53
- with open(metadata_path, 'r') as f:
54
- metadata = json.load(f)
55
-
56
- # Model pickle
57
- model_path = os.path.join(model_dir, metadata['model_file'])
58
- if not os.path.exists(model_path):
59
- raise FileNotFoundError(
60
- f"Model file not found: {model_path}\n"
61
- f"Have you run train_and_save_models.py first?")
62
-
63
- print(f"Loading {metadata['model_name']} model for {metadata['use_case']} ...")
64
- with open(model_path, 'rb') as f:
65
- model = pickle.load(f)
66
-
67
- print(f" Model loaded successfully.")
68
- print(f" Trained on: {metadata['training_data']}")
69
- print(f" Training date: {metadata['training_date']}")
70
- print(f" Expected input: {metadata['n_features']} leads x "
71
- f"{metadata['n_timesteps']} timesteps")
72
- print(f" Performance metrics on held-out test set:")
73
- metrics = metadata.get('test_metrics', {})
74
- print(f" - Accuracy: {metrics.get('accuracy', 0.0):.4f}")
75
- print(f" - Precision: {metrics.get('precision', 0.0):.4f}")
76
- print(f" - Recall: {metrics.get('recall', 0.0):.4f}")
77
- print(f" - F1 Score: {metrics.get('f1', 0.0):.4f}")
78
- print(f" - Sensitivity: {metrics.get('sensitivity', 0.0):.4f}")
79
- print(f" - Specificity: {metrics.get('specificity', 0.0):.4f}")
80
-
81
- return model, metadata
82
-
83
-
84
- # ─── Preprocessing ───────────────────────────────────────────────────────────
85
-
86
-
87
- def preprocess_data(input_csv, metadata):
88
- """
89
- Preprocess new ECG data for inference using the same pipeline as training.
90
-
91
- Steps (mirrors prepare_data() in classifier_benchmarking.py):
92
- 1. Per-subject normalisation (divide each lead by its max absolute value)
93
- 2. Reshape to numpy3D (n_subjects, n_features, n_timesteps)
94
- 3. Truncate or zero-pad each subject to the training n_timesteps
95
-
96
- Args:
97
- input_csv: Path to the input CSV. Must contain columns
98
- 'subject_id', 'timestamp', and the 12 ECG lead columns.
99
- metadata: The model_metadata dict loaded from JSON.
100
-
101
- Returns:
102
- (X, valid_subject_ids) where X has shape
103
- (n_subjects, n_features, n_timesteps).
104
- """
105
- features = metadata['features']
106
- n_timesteps = metadata['n_timesteps']
107
- n_features = metadata['n_features']
108
-
109
- # ── Load ──────────────────────────────────────────────────────────────
110
- print(f"\nLoading input data from {input_csv} ...")
111
- df = pd.read_csv(input_csv, index_col=['subject_id', 'timestamp'])
112
-
113
- missing = [f for f in features if f not in df.columns]
114
- if missing:
115
- raise ValueError(f"Missing required lead columns in input: {missing}")
116
-
117
- # ── Normalise ─────────────────────────────────────────────────────────
118
- def safe_normalize(x):
119
- max_val = x.abs().max()
120
- return x / max_val if max_val > 0 else x
121
-
122
- print("Normalising features per subject ...")
123
- df_X = df[features].copy()
124
- for col in tqdm(df_X.columns, desc="Normalising"):
125
- df_X[col] = df_X.groupby(level='subject_id')[col].transform(safe_normalize)
126
-
127
- # ── Reshape ───────────────────────────────────────────────────────────
128
- subject_ids = df_X.index.get_level_values('subject_id').unique()
129
- timesteps_per_subject = df_X.groupby(level='subject_id').size()
130
-
131
- print(f" Subjects found: {len(subject_ids)}")
132
- print(f" Timesteps per subject — "
133
- f"min: {timesteps_per_subject.min()}, "
134
- f"max: {timesteps_per_subject.max()}")
135
- print(f" Target timesteps (train): {n_timesteps}")
136
-
137
- X_list = []
138
- valid_subject_ids = []
139
- skipped = 0
140
-
141
- for subject_id in tqdm(subject_ids, desc="Reshaping subjects"):
142
- try:
143
- subject_data = df_X.xs(subject_id, level='subject_id')
144
- actual_len = len(subject_data)
145
-
146
- if actual_len >= n_timesteps:
147
- # Truncate to training length
148
- arr = subject_data.iloc[:n_timesteps].values.T
149
- else:
150
- # Zero-pad short subjects
151
- arr = np.zeros((n_features, n_timesteps))
152
- arr[:, :actual_len] = subject_data.values.T
153
- print(f" [warn] Subject {subject_id}: {actual_len} timesteps "
154
- f"→ padded to {n_timesteps}")
155
-
156
- if arr.shape == (n_features, n_timesteps):
157
- X_list.append(arr)
158
- valid_subject_ids.append(subject_id)
159
- else:
160
- skipped += 1
161
- except Exception as e:
162
- print(f" [warn] Skipped subject {subject_id}: {e}")
163
- skipped += 1
164
-
165
- if not X_list:
166
- raise ValueError("No valid subjects found in the input data!")
167
-
168
- X = np.array(X_list)
169
- print(f"\n Preprocessed shape: {X.shape} "
170
- f"({len(valid_subject_ids)} subjects, {skipped} skipped)")
171
-
172
- return X, valid_subject_ids
173
-
174
-
175
- # ─── Inference ───────────────────────────────────────────────────────────────
176
-
177
-
178
- def run_predictions(model, X, metadata, subject_ids):
179
- """
180
- Run model predictions and package them into a DataFrame.
181
-
182
- Returns:
183
- pd.DataFrame with columns:
184
- subject_id, predicted_class, confidence,
185
- probability_<class0>, probability_<class1>
186
- """
187
- print(f"\nRunning inference on {len(subject_ids)} subjects ...")
188
-
189
- # Predict labels and probabilities in threading backend to bypass Numba read-only array issue
190
- from joblib import parallel_backend
191
- with parallel_backend('threading', n_jobs=-1):
192
- y_pred = model.predict(X)
193
-
194
- # Predict probabilities (Arsenal & Rocket both support this)
195
- has_proba = False
196
- y_proba = None
197
- try:
198
- y_proba = model.predict_proba(X)
199
- has_proba = True
200
- except (AttributeError, NotImplementedError):
201
- pass
202
-
203
- # ── Build output DataFrame ────────────────────────────────────────────
204
- results = {
205
- 'subject_id': subject_ids,
206
- 'predicted_class': y_pred,
207
- }
208
-
209
- if has_proba:
210
- class_labels = list(model.classes_)
211
- for i, cls in enumerate(class_labels):
212
- results[f'probability_{cls}'] = y_proba[:, i]
213
-
214
- # Confidence = probability of the predicted class
215
- confidence = []
216
- for j in range(len(y_pred)):
217
- pred_idx = class_labels.index(y_pred[j])
218
- confidence.append(float(y_proba[j, pred_idx]))
219
- results['confidence'] = confidence
220
-
221
- return pd.DataFrame(results)
222
-
223
-
224
- # ─── Entry Point ──────────────────────────────────────────────────────────────
225
-
226
-
227
- def main():
228
- parser = argparse.ArgumentParser(
229
- description='Run ECG classification inference with a saved model',
230
- formatter_class=argparse.RawDescriptionHelpFormatter,
231
- epilog="""
232
- Examples:
233
- python run_inference.py --model mi_vs_normal_processed --input data/ptb_xl/ecg_dataset_processed.csv
234
- python run_inference.py --model mi_vs_normal_segmented --input data/ptb_xl/segmented_heartbeats.csv
235
- python run_inference.py --model omi_vs_nonomi --input data/ecg_matrix_omi_segmented_50_150_90.csv
236
- python run_inference.py --model ecg_surgery --input data/ecg_surgery_segmented_50_150_70.csv
237
- """)
238
-
239
- parser.add_argument('--model', type=str, required=True,
240
- choices=['mi_vs_normal_processed', 'mi_vs_normal_segmented', 'omi_vs_nonomi', 'ecg_surgery'],
241
- help='Which saved model to use')
242
- parser.add_argument('--input', type=str, required=True,
243
- help='Path to input CSV (subject_id, timestamp, 12 leads)')
244
- parser.add_argument('--output', type=str, default=None,
245
- help='Path to save predictions CSV '
246
- '(default: results/<model>_predictions.csv)')
247
-
248
- args = parser.parse_args()
249
-
250
- # Default output
251
- if args.output is None:
252
- os.makedirs('results', exist_ok=True)
253
- args.output = f'results/{args.model}_predictions.csv'
254
-
255
- print('=' * 60)
256
- print(' ECG CLASSIFICATION - INFERENCE')
257
- print('=' * 60)
258
-
259
- # ── Load model ────────────────────────────────────────────────────────
260
- model, metadata = load_model(args.model)
261
-
262
- # ── Preprocess ────────────────────────────────────────────────────────
263
- X, subject_ids = preprocess_data(args.input, metadata)
264
-
265
- # ── Predict ───────────────────────────────────────────────────────────
266
- results_df = run_predictions(model, X, metadata, subject_ids)
267
-
268
- # ── Save ──────────────────────────────────────────────────────────────
269
- out_dir = os.path.dirname(args.output)
270
- if out_dir:
271
- os.makedirs(out_dir, exist_ok=True)
272
- results_df.to_csv(args.output, index=False)
273
-
274
- # ── Summary ───────────────────────────────────────────────────────────
275
- print(f"\n{'=' * 60}")
276
- print(f" INFERENCE RESULTS")
277
- print(f"{'=' * 60}")
278
- print(f" Model: {metadata['model_name']} ({metadata['use_case']})")
279
- print(f" Total subjects: {len(results_df)}")
280
- print(f"\n Predicted class distribution:")
281
- for cls, count in results_df['predicted_class'].value_counts().items():
282
- print(f" {cls}: {count}")
283
-
284
- if 'confidence' in results_df.columns:
285
- conf = results_df['confidence']
286
- print(f"\n Confidence statistics:")
287
- print(f" Mean: {conf.mean():.4f}")
288
- print(f" Median: {conf.median():.4f}")
289
- print(f" Min: {conf.min():.4f}")
290
- print(f" Max: {conf.max():.4f}")
291
-
292
- print(f"\n Results saved to: {args.output}")
293
- print(f"{'=' * 60}")
294
-
295
-
296
- if __name__ == '__main__':
297
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
archive/classification/run_lead_importance_test.py DELETED
@@ -1,128 +0,0 @@
1
- import numpy as np
2
- import pandas as pd
3
- import matplotlib.pyplot as plt
4
- import os
5
- from sklearn.model_selection import train_test_split
6
- from sklearn.neural_network import MLPClassifier
7
- from sklearn.preprocessing import StandardScaler
8
- from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
9
- from tqdm.auto import tqdm
10
-
11
- # Import common configuration
12
- try:
13
- from run_segmentation import TARGET_FS
14
- except ImportError:
15
- TARGET_FS = 500 # Default fallback
16
-
17
- def run_lead_importance_test(input_csv, output_dir):
18
- """
19
- Test the accuracy of identifying MI using each ECG lead individually.
20
- """
21
- os.makedirs(output_dir, exist_ok=True)
22
-
23
- # Load segmented data
24
- print(f"Loading data from {input_csv}...")
25
- df_segmented = pd.read_csv(input_csv, index_col=['subject_id', 'timestamp'])
26
-
27
- if df_segmented.empty:
28
- print("Error: Input data is empty.")
29
- return
30
-
31
- # Define features/leads
32
- leads = ['I', 'aVR', 'V1', 'V4', 'II', 'aVL', 'V2', 'V5', 'III', 'aVF', 'V3', 'V6']
33
- label_map = {'Post-Procedural MI': 0, 'Pre-Procedural MI': 1}
34
-
35
- results = []
36
-
37
- print(f"\nEvaluating {len(leads)} leads individually at {TARGET_FS} Hz...")
38
-
39
- for lead in leads:
40
- print(f"\n>>> Testing Lead {lead}...")
41
-
42
- # 1. Prepare data for this lead
43
- df_lead = df_segmented[[lead, 'class']].copy()
44
-
45
- # Normalize per heartbeat
46
- df_lead[lead] = df_lead.groupby(level='subject_id')[lead].transform(
47
- lambda x: x / x.abs().max() if x.abs().max() > 0 else x
48
- )
49
-
50
- # Reshape to (heartbeats, timesteps)
51
- beat_ids = df_lead.index.get_level_values('subject_id').unique()
52
- timesteps_per_beat = df_lead.groupby(level='subject_id').size().min()
53
-
54
- X_list = []
55
- y_list = []
56
-
57
- for beat_id in beat_ids:
58
- beat_data = df_lead.xs(beat_id, level='subject_id')
59
- if len(beat_data) >= timesteps_per_beat:
60
- X_list.append(beat_data[lead].values[:timesteps_per_beat])
61
- y_list.append(label_map[beat_data['class'].iloc[0]])
62
-
63
- X = np.array(X_list)
64
- y = np.array(y_list)
65
-
66
- # 2. Train and Evaluate
67
- X_train, X_test, y_train, y_test = train_test_split(
68
- X, y, test_size=0.25, random_state=42, stratify=y
69
- )
70
-
71
- scaler = StandardScaler()
72
- X_train_scaled = scaler.fit_transform(X_train)
73
- X_test_scaled = scaler.transform(X_test)
74
-
75
- clf = MLPClassifier(
76
- hidden_layer_sizes=(100,),
77
- max_iter=1000,
78
- random_state=42
79
- )
80
- clf.fit(X_train_scaled, y_train)
81
-
82
- y_pred = clf.predict(X_test_scaled)
83
-
84
- # Collect metrics
85
- metrics = {
86
- 'Lead': lead,
87
- 'Accuracy': accuracy_score(y_test, y_pred),
88
- 'Precision': precision_score(y_test, y_pred, zero_division=0),
89
- 'Recall': recall_score(y_test, y_pred, zero_division=0),
90
- 'F1_Score': f1_score(y_test, y_pred, zero_division=0)
91
- }
92
- results.append(metrics)
93
- print(f" Accuracy: {metrics['Accuracy']:.4f}")
94
-
95
- # 3. Save and Visualize Results
96
- results_df = pd.DataFrame(results).sort_values(by='Accuracy', ascending=False)
97
- results_df.to_csv(f'{output_dir}/lead_accuracies.csv', index=False)
98
-
99
- print("\n" + "="*40)
100
- print("LEAD IMPORTANCE TEST RESULTS")
101
- print("="*40)
102
- print(results_df.to_string(index=False))
103
-
104
- # Plot results
105
- plt.figure(figsize=(12, 6))
106
- plt.bar(results_df['Lead'], results_df['Accuracy'], color='skyblue', edgecolor='navy')
107
- plt.axhline(y=0.5, color='red', linestyle='--', label='Baseline (Random)')
108
- plt.title(f'MLP Accuracy per Single ECG Lead ({TARGET_FS} Hz)', fontsize=15, fontweight='bold')
109
- plt.xlabel('ECG Lead', fontsize=12)
110
- plt.ylabel('Accuracy', fontsize=12)
111
- plt.ylim(0, 1.0)
112
- plt.grid(axis='y', alpha=0.3)
113
- plt.legend()
114
- plt.tight_layout()
115
- plt.savefig(f'{output_dir}/lead_accuracies_plot.png', dpi=300)
116
- plt.close()
117
-
118
- print(f"\nResults saved to {output_dir}")
119
-
120
- if __name__ == "__main__":
121
- #input_csv = 'data/segmented_heartbeats.csv'
122
- input_csv = 'data/ecg_matrix_dataset_segmented.csv'
123
- output_dir = 'output/lead_importance_test'
124
-
125
- if not os.path.exists(input_csv):
126
- print(f"Error: {input_csv} not found. Run segmentation first.")
127
- else:
128
- run_lead_importance_test(input_csv, output_dir)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
archive/classification/train_and_save_models.py DELETED
@@ -1,427 +0,0 @@
1
- import os
2
- import argparse
3
- import pickle
4
- import json
5
- import time
6
- import numpy as np
7
- import pandas as pd
8
- from sklearn.model_selection import train_test_split
9
- from imblearn.combine import SMOTETomek
10
- from sklearn.metrics import (accuracy_score, precision_score,
11
- recall_score, f1_score, confusion_matrix)
12
- from sktime.classification.kernel_based import Arsenal, RocketClassifier
13
- from sktime.classification.deep_learning.inceptiontime import InceptionTimeClassifier
14
- from tqdm.auto import tqdm
15
- import warnings
16
- warnings.filterwarnings('ignore')
17
-
18
-
19
- # ─── Configuration ───────────────────────────────────────────────────────────
20
-
21
- MODELS_DIR = 'models'
22
-
23
- FEATURES = ['I', 'aVR', 'V1', 'V4', 'II', 'aVL', 'V2', 'V5',
24
- 'III', 'aVF', 'V3', 'V6']
25
-
26
- USE_CASES = {
27
- 'mi_vs_normal_processed': {
28
- 'name': 'MI vs Normal (Processed)',
29
- 'input_csv': 'data/ptb_xl/ecg_dataset_processed.csv',
30
- 'model_class': Arsenal,
31
- 'model_name': 'Arsenal',
32
- 'model_file': 'arsenal_processed_model.pkl',
33
- 'positive_class': 'MYOCARDIAL_INFARCTION',
34
- 'negative_class': 'NORMAL',
35
- 'hyperparameters': {
36
- 'num_kernels': 10000,
37
- 'random_state': 42,
38
- 'n_jobs': 1,
39
- },
40
- },
41
- 'mi_vs_normal_segmented': {
42
- 'name': 'MI vs Normal (Segmented)',
43
- 'input_csv': 'data/ptb_xl/segmented_heartbeats.csv',
44
- 'model_class': Arsenal,
45
- 'model_name': 'Arsenal',
46
- 'model_file': 'arsenal_segmented_model.pkl',
47
- 'positive_class': 'MYOCARDIAL_INFARCTION',
48
- 'negative_class': 'NORMAL',
49
- 'hyperparameters': {
50
- 'num_kernels': 20000,
51
- 'random_state': 42,
52
- 'n_jobs': 1,
53
- },
54
- },
55
- 'omi_vs_nonomi': {
56
- 'name': 'OMI vs non-OMI',
57
- 'input_csv': 'data/ecg_matrix_omi_segmented_50_150_90.csv',
58
- 'model_class': RocketClassifier,
59
- 'model_name': 'Rocket',
60
- 'model_file': 'rocket_model.pkl',
61
- 'positive_class': 'OMI',
62
- 'negative_class': 'non-OMI',
63
- 'hyperparameters': {
64
- 'num_kernels': 50000,
65
- 'random_state': 42,
66
- 'n_jobs': -1,
67
- },
68
- },
69
- 'ecg_surgery': {
70
- 'name': 'Pre vs Post Procedural MI (Surgery)',
71
- 'input_csv': 'data/ecg_surgery_segmented_50_150_70.csv',
72
- 'model_class': InceptionTimeClassifier,
73
- 'model_name': 'InceptionTime',
74
- 'model_file': 'inceptiontime_model.pkl',
75
- 'positive_class': 'pre-procedural MI',
76
- 'negative_class': 'post-procedural MI',
77
- 'hyperparameters': {
78
- 'n_epochs': 200,
79
- 'batch_size': 64,
80
- 'random_state': 42,
81
- 'verbose': True,
82
- },
83
- },
84
- }
85
-
86
- # ─── Data Preprocessing ──────────────────────────────────────────────────────
87
-
88
- def prepare_data(df):
89
- """Prepare data for benchmarking in sktime compatible format (numpy3D)"""
90
- features = ['I', 'aVR', 'V1', 'V4', 'II', 'aVL', 'V2', 'V5', 'III', 'aVF', 'V3', 'V6']
91
-
92
- def safe_normalize(x):
93
- max_val = x.abs().max()
94
- # Prevent division by zero which yields NaNs
95
- return x / max_val if max_val > 0 else x
96
-
97
- print(" Normalizing features...")
98
- # Normalize features within each subject
99
- df_X = df[features].copy()
100
- for col in tqdm(df_X.columns, desc="Normalizing features"):
101
- df_X[col] = df_X.groupby(level='subject_id')[col].transform(safe_normalize)
102
-
103
- # Get unique subjects
104
- subject_ids = df_X.index.get_level_values('subject_id').unique()
105
-
106
- # Check timesteps per subject
107
- timesteps_per_subject = df_X.groupby(level='subject_id').size()
108
- print(f" Timesteps per subject stats:")
109
- print(f" Min: {timesteps_per_subject.min()}, Max: {timesteps_per_subject.max()}, Mean: {timesteps_per_subject.mean():.1f}")
110
-
111
- # Use minimum timesteps to ensure consistent shape
112
- n_timesteps = timesteps_per_subject.min()
113
- n_features = len(features)
114
-
115
- print(f" Using {n_timesteps} timesteps per subject (minimum)")
116
-
117
- # Manually reshape to numpy3D format: (n_instances, n_features, n_timesteps)
118
- print(" Reshaping data to numpy3D format...")
119
- X_list = []
120
- y_list = []
121
- valid_subject_ids = []
122
-
123
- for subject_id in tqdm(subject_ids, desc="Processing subjects"):
124
- try:
125
- subject_data = df_X.xs(subject_id, level='subject_id')
126
-
127
- # Ensure we have enough timesteps
128
- if len(subject_data) >= n_timesteps:
129
- # Take first n_timesteps and transpose to (n_features, n_timesteps)
130
- subject_array = subject_data.iloc[:n_timesteps].values.T
131
-
132
- # Check shape
133
- if subject_array.shape == (n_features, n_timesteps):
134
- X_list.append(subject_array)
135
-
136
- # Get label for this subject
137
- subject_label = df.loc[subject_id].iloc[0]['class']
138
- y_list.append(subject_label)
139
- valid_subject_ids.append(subject_id)
140
- else:
141
- continue
142
-
143
- except Exception as e:
144
- continue
145
-
146
- # Convert to numpy arrays
147
- X = np.array(X_list) # Shape: (n_instances, n_features, n_timesteps)
148
- y = pd.Series(y_list, index=valid_subject_ids)
149
-
150
- print(f" Final data shape: {X.shape}")
151
- print(f" Class distribution:")
152
- print(y.value_counts())
153
-
154
- # Split into train/test
155
- print(" Splitting data into train/test sets...")
156
- X_train, X_test, y_train, y_test = train_test_split(
157
- X, y, test_size=0.25, random_state=42, stratify=y
158
- )
159
-
160
- # --- SMOTE-TOMEK INJECTION ---
161
- print(" Executing SMOTE-Tomek to isolate and synthetically balance training geometries...")
162
- try:
163
- # Flatten 3D arrays to 2D for SMOTETomek: (N, features, timesteps) -> (N, features * timesteps)
164
- n_train, n_feat, n_time = X_train.shape
165
- X_train_flat = X_train.reshape(n_train, n_feat * n_time)
166
-
167
- # Balance classes
168
- smote_tomek = SMOTETomek(random_state=42)
169
- X_resampled_flat, y_resampled = smote_tomek.fit_resample(X_train_flat, y_train)
170
-
171
- # Deflate back out to precise 3D format for Sktime models
172
- n_new = X_resampled_flat.shape[0]
173
- X_train = X_resampled_flat.reshape(n_new, n_feat, n_time)
174
- y_train = y_resampled
175
-
176
- print(f" -> SMOTE Success! Training geometries augmented from {n_train} to {n_new} samples.")
177
- except Exception as e:
178
- print(f" [!] SMOTETomek failed (are you missing imbalanced-learn?): {e}")
179
- # -----------------------------
180
-
181
- print(f" Final Training set: {X_train.shape}")
182
- print(f" Final Test set: {X_test.shape}")
183
- print(f" y_train balanced distribution: {y_train.value_counts().to_dict()}")
184
- print(f" y_test strictly isolated distribution: {y_test.value_counts().to_dict()}")
185
-
186
- return X_train, X_test, y_train, y_test
187
-
188
-
189
- # ─── Training Logic ──────────────────────────────────────────────────────────
190
-
191
-
192
- def train_and_save(use_case_key, config):
193
- """
194
- Train a single model for the given use case, evaluate it, and persist
195
- both the serialised model and a metadata JSON to disk.
196
-
197
- Returns True on success, False on failure.
198
- """
199
-
200
- print(f"\n{'=' * 60}")
201
- print(f" TRAINING: {config['name']}")
202
- print(f" Model: {config['model_name']}")
203
- print(f"{'=' * 60}")
204
-
205
- input_csv = config['input_csv']
206
- if not os.path.exists(input_csv):
207
- print(f"[ERROR] Input file not found: {input_csv}")
208
- return False
209
-
210
- # ── Load data ──────────────────────────────────────────────────────────
211
- print(f"\nLoading data from {input_csv} ...")
212
- df = pd.read_csv(input_csv, index_col=['subject_id', 'timestamp'])
213
-
214
- # Map labels to Pre/Post Procedural MI if it's the surgery dataset
215
- if use_case_key == 'ecg_surgery':
216
- print(" Mapping class labels to 'pre-procedural MI' and 'post-procedural MI' ...")
217
- df['class'] = df['class'].replace({
218
- 'Pre-Surgery MI': 'pre-procedural MI',
219
- 'Post-Surgery MI': 'post-procedural MI'
220
- })
221
-
222
- print(f" DataFrame shape: {df.shape}")
223
-
224
- subject_class = df.groupby(level='subject_id').first()['class']
225
- print(f" Class distribution (subjects):\n{subject_class.value_counts().to_string()}")
226
-
227
- # ── Prepare data (normalize → reshape → split → SMOTE-Tomek) ──────────
228
- print("\nPreparing data (normalize, reshape, split, SMOTE-Tomek) ...")
229
- X_train, X_test, y_train, y_test = prepare_data(df)
230
-
231
- n_timesteps = X_train.shape[2]
232
- n_features = X_train.shape[1]
233
- print(f" n_timesteps = {n_timesteps}")
234
- print(f" n_features = {n_features}")
235
- print(f" X_train shape: {X_train.shape} | X_test shape: {X_test.shape}")
236
-
237
- # ── Initialise & train model ──────────────────────────────────────────
238
- model = config['model_class'](**config['hyperparameters'])
239
-
240
- params_str = ""
241
- if 'num_kernels' in config['hyperparameters']:
242
- params_str = f" (num_kernels={config['hyperparameters']['num_kernels']})"
243
- elif 'n_epochs' in config['hyperparameters']:
244
- params_str = f" (n_epochs={config['hyperparameters']['n_epochs']})"
245
- if 'batch_size' in config['hyperparameters']:
246
- params_str += f" (batch_size={config['hyperparameters']['batch_size']})"
247
-
248
- print(f"\nTraining {config['model_name']}{params_str} ...")
249
- train_start = time.time()
250
-
251
- from joblib import parallel_backend
252
- with parallel_backend('threading'):
253
- model.fit(X_train, y_train)
254
- train_time = time.time() - train_start
255
- print(f" Training completed in {train_time:.1f}s")
256
-
257
- # ── Evaluate on test set ──────────────────────────────────────────────
258
- print("\nEvaluating on held-out test set ...")
259
- y_pred = model.predict(X_test)
260
-
261
- positive = config['positive_class']
262
- y_test_num = (y_test == positive).astype(int)
263
- y_pred_num = (y_pred == positive).astype(int)
264
-
265
- accuracy = accuracy_score(y_test_num, y_pred_num)
266
- precision = precision_score(y_test_num, y_pred_num, zero_division=0)
267
- recall = recall_score(y_test_num, y_pred_num, zero_division=0)
268
- f1 = f1_score(y_test_num, y_pred_num, zero_division=0)
269
-
270
- cm = confusion_matrix(y_test_num, y_pred_num)
271
- if cm.shape == (2, 2):
272
- tn, fp, fn, tp = cm.flatten()
273
- sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0
274
- specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
275
- else:
276
- sensitivity = specificity = 0.0
277
-
278
- test_metrics = {
279
- 'accuracy': float(accuracy),
280
- 'precision': float(precision),
281
- 'recall': float(recall),
282
- 'f1': float(f1),
283
- 'sensitivity': float(sensitivity),
284
- 'specificity': float(specificity),
285
- 'confusion_matrix': cm.tolist(),
286
- }
287
-
288
- print(f"\n{'-' * 40}")
289
- print(f" TEST-SET RESULTS")
290
- print(f"{'-' * 40}")
291
- print(f" Accuracy: {accuracy:.4f}")
292
- print(f" Precision: {precision:.4f}")
293
- print(f" Recall: {recall:.4f}")
294
- print(f" F1 Score: {f1:.4f}")
295
- print(f" Sensitivity: {sensitivity:.4f}")
296
- print(f" Specificity: {specificity:.4f}")
297
- print(f" Confusion Matrix:\n {cm}")
298
-
299
- # ── Save model (pickle) ──────────────────────────────────────────────
300
- output_dir = os.path.join(MODELS_DIR, use_case_key)
301
- os.makedirs(output_dir, exist_ok=True)
302
-
303
- model_path = os.path.join(output_dir, config['model_file'])
304
- print(f"\nSaving model to {model_path} ...")
305
- with open(model_path, 'wb') as f:
306
- pickle.dump(model, f)
307
-
308
- model_size_mb = os.path.getsize(model_path) / (1024 * 1024)
309
- print(f" Model saved ({model_size_mb:.1f} MB)")
310
-
311
- # ── Save metadata (JSON) ─────────────────────────────────────────────
312
- metadata = {
313
- 'model_name': config['model_name'],
314
- 'model_file': config['model_file'],
315
- 'use_case': config['name'],
316
- 'positive_class': config['positive_class'],
317
- 'negative_class': config['negative_class'],
318
- 'class_labels': [config['negative_class'], config['positive_class']],
319
- 'features': FEATURES,
320
- 'n_features': int(n_features),
321
- 'n_timesteps': int(n_timesteps),
322
- 'hyperparameters': config['hyperparameters'],
323
- 'training_data': input_csv,
324
- 'training_date': time.strftime('%Y-%m-%d %H:%M:%S'),
325
- 'training_time_seconds': round(train_time, 2),
326
- 'preprocessing': {
327
- 'normalization': 'per_subject_max_abs',
328
- 'smote_tomek': True,
329
- 'test_size': 0.25,
330
- 'random_state': 42,
331
- },
332
- 'training_samples': int(X_train.shape[0]),
333
- 'test_samples': int(X_test.shape[0]),
334
- 'test_metrics': test_metrics,
335
- }
336
-
337
- metadata_path = os.path.join(output_dir, 'model_metadata.json')
338
- with open(metadata_path, 'w') as f:
339
- json.dump(metadata, f, indent=2)
340
- print(f" Metadata saved to {metadata_path}")
341
-
342
- print(f"\n[OK] {config['name']} - done.")
343
- return True
344
-
345
-
346
- # ─── Entry Point ──────────────────────────────────────────────────────────────
347
-
348
- def show_saved_metrics():
349
- """
350
- Search the MODELS_DIR directory for any saved model metadata JSON files
351
- and print a beautiful summary of their performance metrics.
352
- """
353
- print('=' * 60)
354
- print(' TRAINED ECG MODEL METRICS SUMMARY')
355
- print('=' * 60)
356
-
357
- found = False
358
- if os.path.exists(MODELS_DIR):
359
- for item in sorted(os.listdir(MODELS_DIR)):
360
- meta_path = os.path.join(MODELS_DIR, item, 'model_metadata.json')
361
- if os.path.exists(meta_path):
362
- try:
363
- with open(meta_path, 'r') as f:
364
- meta = json.load(f)
365
-
366
- found = True
367
- print(f"\nUse Case: {meta.get('use_case', 'Unknown')}")
368
- print(f"Model Class: {meta.get('model_name', 'Unknown')}")
369
- print(f"Model File: {os.path.join(MODELS_DIR, item, meta.get('model_file', ''))}")
370
- print(f"Trained On: {meta.get('training_data', 'Unknown')}")
371
- print(f"Training Date: {meta.get('training_date', 'Unknown')}")
372
- if 'training_time_seconds' in meta:
373
- print(f"Train Time: {meta['training_time_seconds']:.1f}s")
374
-
375
- metrics = meta.get('test_metrics', {})
376
- print("Test Metrics:")
377
- print(f" - Accuracy: {metrics.get('accuracy', 0.0):.4f}")
378
- print(f" - Precision: {metrics.get('precision', 0.0):.4f}")
379
- print(f" - Recall: {metrics.get('recall', 0.0):.4f}")
380
- print(f" - F1 Score: {metrics.get('f1', 0.0):.4f}")
381
- print(f" - Sensitivity: {metrics.get('sensitivity', 0.0):.4f}")
382
- print(f" - Specificity: {metrics.get('specificity', 0.0):.4f}")
383
-
384
- cm = metrics.get('confusion_matrix', None)
385
- if cm:
386
- print(f" - Confusion Matrix:\n {np.array(cm)}")
387
- print("-" * 60)
388
- except Exception as e:
389
- print(f"[Error loading metadata from {meta_path}]: {e}")
390
-
391
- if not found:
392
- print(f"\nNo saved model metadata files found under '{MODELS_DIR}/'.")
393
- print("Please train models first to generate performance metrics.")
394
- print('=' * 60)
395
-
396
-
397
- if __name__ == '__main__':
398
- parser = argparse.ArgumentParser(
399
- description='Train and save the best ECG classification models')
400
- parser.add_argument(
401
- '--use-case', type=str,
402
- choices=['mi_vs_normal_processed', 'mi_vs_normal_segmented', 'omi_vs_nonomi', 'ecg_surgery', 'all'],
403
- default='all',
404
- help='Which use case to train (default: all)')
405
- parser.add_argument(
406
- '--show-metrics', action='store_true',
407
- help='Display performance metrics of already trained/saved models and exit')
408
- args = parser.parse_args()
409
-
410
- if args.show_metrics:
411
- show_saved_metrics()
412
- else:
413
- cases = list(USE_CASES.keys()) if args.use_case == 'all' else [args.use_case]
414
-
415
- print('=' * 60)
416
- print(' ECG MODEL TRAINING & SERIALISATION')
417
- print('=' * 60)
418
-
419
- for key in cases:
420
- success = train_and_save(key, USE_CASES[key])
421
- if not success:
422
- print(f"\n[FAIL] Could not train {USE_CASES[key]['name']}")
423
-
424
- print(f"\n{'=' * 60}")
425
- print(f" ALL TRAINING COMPLETE")
426
- print(f" Models saved to: {MODELS_DIR}/")
427
- print(f"{'=' * 60}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
archive/digitization/README.md DELETED
@@ -1,21 +0,0 @@
1
- # archive/digitization
2
-
3
- Legacy ECG image digitization scripts that have been **superseded** by the
4
- Streamlit dashboard in the workspace root.
5
-
6
- ## Contents
7
-
8
- | File | Status | Reason for archival |
9
- | --- | --- | --- |
10
- | `run_ecg.py` | Legacy batch digitizer | Processes a directory tree of ECG paper scans (one CSV per image). Uses Linux-only `SIGALRM` timeouts and hard-coded model paths under `Submission code/...`. Replaced by the interactive pipeline in `backend/digitization_runner.py` invoked from `app.py`. |
11
-
12
- ## Current digitization entry point
13
-
14
- Use the Streamlit dashboard:
15
-
16
- ```bash
17
- streamlit run app.py
18
- ```
19
-
20
- Then open the **"📷 Image Digitizer"** (now **"ECG Image Digitizer"**) page and
21
- either upload an ECG scan or check the sample image option.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
archive/digitization/run_ecg.py DELETED
@@ -1,302 +0,0 @@
1
- # Force all BLAS / OpenMP thread pools to a single thread before any other
2
- # import. This must happen before numpy, torch, or cv2 are imported, otherwise
3
- # the libraries have already spawned their thread pools and the env vars have
4
- # no effect. Without this, BLAS and PyTorch worker threads compete for CPU
5
- # cores and can deadlock on multi-core servers.
6
- import os
7
- os.environ["OPENBLAS_NUM_THREADS"] = "1"
8
- os.environ["OMP_NUM_THREADS"] = "1"
9
- os.environ["MKL_NUM_THREADS"] = "1"
10
-
11
- import glob
12
- import signal
13
- import torch
14
- import pandas as pd
15
- from ultralytics import YOLO
16
- from digitization import ECGImage
17
-
18
- # -------------------------
19
- # Configuration
20
- # -------------------------
21
-
22
- # Root of the organised input dataset. Each immediate subdirectory is treated
23
- # as one hospital and must itself contain subfolders named after CATEGORIES.
24
- ORGANIZED_DIR = "../ecg_files/ECG_organized_all"
25
-
26
- # Root where output CSVs are written, mirroring the hospital/category structure
27
- # of ORGANIZED_DIR. Created automatically if it does not exist.
28
- OUTPUT_DIR = "../ecg_files/ECG_digitized"
29
-
30
- # Subfolders to process within each hospital directory.
31
- # Any subfolder not listed here is silently skipped.
32
- # Add or remove entries to match your dataset's naming convention.
33
- CATEGORIES = ["pre", "index", "post", "discharge", "dimi"]
34
-
35
- # Per-image wall-clock time limit in seconds, enforced via SIGALRM.
36
- # Images that exceed this budget are logged as timeouts and skipped.
37
- # Increase for very large images or slow CPU-only machines.
38
- # Note: SIGALRM is only available on Linux.
39
- TIMEOUT = 600
40
-
41
- # -------------------------
42
- # Torch / GPU settings
43
- # -------------------------
44
-
45
- if torch.cuda.is_available():
46
- # benchmark=True lets cuDNN auto-tune convolution algorithms at the first
47
- # forward pass. Speeds up subsequent passes when input sizes are fixed,
48
- # which is the case here (images are resized to a fixed height).
49
- torch.backends.cudnn.benchmark = True
50
-
51
- # deterministic=False allows cuDNN to use non-deterministic (but faster)
52
- # algorithms. Set to True if exact reproducibility is required.
53
- torch.backends.cudnn.deterministic = False
54
-
55
- print(f"GPU: {torch.cuda.get_device_name(0)}", flush=True)
56
- print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB\n",
57
- flush=True)
58
- else:
59
- print("No GPU found — running on CPU.\n", flush=True)
60
-
61
- # -------------------------
62
- # Timeout (Linux only)
63
- # -------------------------
64
-
65
- class TimeoutException(Exception):
66
- """Raised by timeout_handler when SIGALRM fires."""
67
- pass
68
-
69
- def timeout_handler(signum, frame):
70
- """Signal handler that converts SIGALRM into a TimeoutException.
71
-
72
- Registered below with signal.signal() so that any blocking call
73
- inside process_folder() can be interrupted after TIMEOUT seconds.
74
- """
75
- raise TimeoutException("Processing timed out")
76
-
77
- # Register the handler for SIGALRM. signal.alarm(n) starts a countdown;
78
- # if the process has not called signal.alarm(0) within n seconds, this
79
- # handler fires and raises TimeoutException.
80
- signal.signal(signal.SIGALRM, timeout_handler)
81
-
82
- # -------------------------
83
- # Load models once (on GPU if available)
84
- # -------------------------
85
-
86
- # All four models are loaded once before the processing loop so that the
87
- # weights are only read from disk once, regardless of how many images are
88
- # processed. Loading inside the loop would add several seconds of overhead
89
- # per image.
90
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
91
-
92
- print("Loading models...", flush=True)
93
-
94
- # Detection model for full lead bounding boxes (one box per lead region).
95
- box_model = YOLO("Submission code/3. Model training/runs/yolo11_full/weights/best.pt")
96
-
97
- # Classification model for lead name labels (I, II, III, aVR, aVL, aVF, V1–V6).
98
- lead_name_model = YOLO("Submission code/3. Model training/runs/yolo11_lead/weights/best.pt")
99
-
100
- # Detection model for calibration (reference) pulse boxes.
101
- pulse_model = YOLO("Submission code/3. Model training/runs/yolo11_pulse/weights/best.pt")
102
-
103
- box_model.to(DEVICE)
104
- lead_name_model.to(DEVICE)
105
- pulse_model.to(DEVICE)
106
-
107
- # The segmentation model is kept as a file path rather than a loaded model
108
- # object because it is consumed by patched_yolo_infer.MakeCropsDetectThem,
109
- # which loads it internally on each call. This avoids a second copy of the
110
- # weights living in VRAM alongside the three detection models above.
111
- segmentation_model = "Submission code/3. Model training/runs/yolo11_patch/weights/best.pt"
112
-
113
- print(f"Models loaded on {DEVICE.upper()}.\n", flush=True)
114
-
115
- # -------------------------
116
- # Process a single folder
117
- # -------------------------
118
-
119
- def process_folder(input_dir, output_dir):
120
- """Digitize all ECG images in input_dir and save CSVs to output_dir.
121
-
122
- Iterates over all .png / .jpg / .jpeg files in input_dir (sorted
123
- alphabetically). Each image is processed by ECGImage.run_full_pipeline()
124
- and the resulting signals are exported as a CSV file with the same base
125
- name as the image.
126
-
127
- Parameters
128
- ----------
129
- input_dir : str
130
- Directory containing the input ECG images.
131
- output_dir : str
132
- Directory where output CSV files are written (created if absent).
133
-
134
- Returns
135
- -------
136
- success : int
137
- Number of images successfully digitized.
138
- total : int
139
- Total number of images found in input_dir.
140
- """
141
- # Collect all supported image formats; sort for reproducible ordering.
142
- files = []
143
- for ext in ("*.png", "*.jpg", "*.jpeg"):
144
- files.extend(glob.glob(os.path.join(input_dir, ext)))
145
- files.sort()
146
-
147
- # Return early without creating the output directory if the folder is empty.
148
- if not files:
149
- return 0, 0
150
-
151
- os.makedirs(output_dir, exist_ok=True)
152
- success = errors = timeouts = 0
153
-
154
- for i, file_path in enumerate(files):
155
- base_name = os.path.splitext(os.path.basename(file_path))[0]
156
- print(f" [{i+1}/{len(files)}] {base_name}", flush=True)
157
- try:
158
- # Start the per-image countdown. If the block below takes longer
159
- # than TIMEOUT seconds, SIGALRM fires and TimeoutException is raised.
160
- signal.alarm(TIMEOUT)
161
-
162
- # Instantiate the digitizer for this image. Models are passed in
163
- # so they are not reloaded on every iteration.
164
- ecg = ECGImage(
165
- box_model=box_model,
166
- segmentation_model=segmentation_model,
167
- lead_name_model=lead_name_model,
168
- pulse_model=pulse_model,
169
- image_path=file_path,
170
- )
171
-
172
- # Run all pipeline stages: preprocessing → segmentation → YOLO
173
- # detection (×3) → scale calibration → grid construction →
174
- # signal extraction. Internally retries at different image sizes
175
- # if any detection returns empty.
176
- ecg.run_full_pipeline()
177
-
178
- # Write one CSV per image: columns = lead names, rows = time samples.
179
- ecg.save_signals_as_csv(base_name, directory=output_dir)
180
- success += 1
181
-
182
- except TimeoutException:
183
- # The image took longer than TIMEOUT seconds. Counted separately
184
- # from errors so they can be investigated independently.
185
- timeouts += 1
186
- print(f" ⏰ TIMEOUT: {base_name}", flush=True)
187
-
188
- except Exception as e:
189
- # Any other failure (bad image, detection failure, calibration
190
- # error, etc.) is caught here so the loop continues with the
191
- # remaining images.
192
- errors += 1
193
- print(f" ❌ ERROR: {base_name} → {e}", flush=True)
194
-
195
- finally:
196
- # Always cancel the alarm, even if an exception was raised, to
197
- # prevent a stale countdown from interrupting the next image.
198
- signal.alarm(0)
199
-
200
- # Release VRAM after each image to prevent fragmentation across
201
- # a long batch run. synchronize() ensures all GPU ops are complete
202
- # before empty_cache() frees the memory.
203
- if torch.cuda.is_available():
204
- try:
205
- torch.cuda.synchronize()
206
- torch.cuda.empty_cache()
207
- except Exception:
208
- pass
209
-
210
- total = len(files)
211
- print(f" → {success}/{total} ok | {errors} errors | {timeouts} timeouts\n",
212
- flush=True)
213
- return success, total
214
-
215
- # -------------------------
216
- # Main loop
217
- # -------------------------
218
-
219
- # Discover hospital directories. Each immediate child directory of
220
- # ORGANIZED_DIR is treated as one hospital; files at the root level are ignored.
221
- hospitals = sorted([
222
- name for name in os.listdir(ORGANIZED_DIR)
223
- if os.path.isdir(os.path.join(ORGANIZED_DIR, name))
224
- ])
225
-
226
- # Accumulate per-hospital/category counts for the summary report.
227
- log_rows = []
228
-
229
- print(f"Found {len(hospitals)} hospital(s). Starting digitization...\n", flush=True)
230
-
231
- for hospital in hospitals:
232
- print(f"{'='*60}", flush=True)
233
- print(f"Hospital: {hospital}", flush=True)
234
- print(f"{'='*60}", flush=True)
235
-
236
- for cat in CATEGORIES:
237
- input_dir = os.path.join(ORGANIZED_DIR, hospital, cat)
238
- output_dir = os.path.join(OUTPUT_DIR, hospital, cat)
239
-
240
- # Skip silently if this category does not exist for this hospital.
241
- if not os.path.isdir(input_dir):
242
- continue
243
-
244
- print(f" [{cat.upper()}]", flush=True)
245
- success, total = process_folder(input_dir, output_dir)
246
-
247
- # Only log categories that had at least one image, to keep the
248
- # summary table free of zero-row entries.
249
- if total > 0:
250
- log_rows.append({
251
- "hospital": hospital,
252
- "category": cat,
253
- "total" : total,
254
- "success" : success,
255
- "failed" : total - success,
256
- "rate" : f"{100 * success / total:.1f}%",
257
- })
258
-
259
- # -------------------------
260
- # Summary report
261
- # -------------------------
262
-
263
- print("\n" + "="*60, flush=True)
264
- print("DIGITIZATION SUMMARY", flush=True)
265
- print("="*60, flush=True)
266
-
267
- df = pd.DataFrame(log_rows)
268
-
269
- if df.empty:
270
- print("No images were processed.", flush=True)
271
- else:
272
- # Flat per-hospital/category breakdown.
273
- print("\nPer hospital / category:\n")
274
- print(df.to_string(index=False))
275
-
276
- # Pivot to a hospital × category matrix of successful digitizations,
277
- # with a TOTAL row and column for quick inspection.
278
- print("\n\nSuccessful digitizations — distribution:\n")
279
- success_pivot = (
280
- df.pivot_table(index="hospital", columns="category",
281
- values="success", aggfunc="sum", fill_value=0)
282
- .reindex(columns=CATEGORIES, fill_value=0) # preserve CATEGORIES column order
283
- )
284
- success_pivot["TOTAL"] = success_pivot.sum(axis=1) # row totals
285
- success_pivot.loc["TOTAL"] = success_pivot.sum(axis=0) # grand total row
286
- print(success_pivot.to_string())
287
-
288
- # Overall counts across the entire dataset.
289
- total_imgs = df["total"].sum()
290
- total_ok = df["success"].sum()
291
- total_failed = df["failed"].sum()
292
- print(f"\n✅ Overall: {total_ok}/{total_imgs} successful "
293
- f"({100*total_ok/total_imgs:.1f}%) | "
294
- f"{total_failed} failed", flush=True)
295
-
296
- # Persist the pivot table as a CSV for later analysis.
297
- summary_path = os.path.join(OUTPUT_DIR, "digitization_summary.csv")
298
- os.makedirs(OUTPUT_DIR, exist_ok=True)
299
- success_pivot.to_csv(summary_path)
300
- print(f"📄 Summary saved to {summary_path}", flush=True)
301
-
302
- print("\nAll processing complete.", flush=True)