MIDRC-MELODY 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. MIDRC_MELODY/__init__.py +0 -0
  2. MIDRC_MELODY/__main__.py +4 -0
  3. MIDRC_MELODY/common/__init__.py +0 -0
  4. MIDRC_MELODY/common/data_loading.py +199 -0
  5. MIDRC_MELODY/common/data_preprocessing.py +134 -0
  6. MIDRC_MELODY/common/edit_config.py +156 -0
  7. MIDRC_MELODY/common/eod_aaod_metrics.py +292 -0
  8. MIDRC_MELODY/common/generate_eod_aaod_spiders.py +69 -0
  9. MIDRC_MELODY/common/generate_qwk_spiders.py +56 -0
  10. MIDRC_MELODY/common/matplotlib_spider.py +425 -0
  11. MIDRC_MELODY/common/plot_tools.py +132 -0
  12. MIDRC_MELODY/common/plotly_spider.py +217 -0
  13. MIDRC_MELODY/common/qwk_metrics.py +244 -0
  14. MIDRC_MELODY/common/table_tools.py +230 -0
  15. MIDRC_MELODY/gui/__init__.py +0 -0
  16. MIDRC_MELODY/gui/config_editor.py +200 -0
  17. MIDRC_MELODY/gui/data_loading.py +157 -0
  18. MIDRC_MELODY/gui/main_controller.py +154 -0
  19. MIDRC_MELODY/gui/main_window.py +545 -0
  20. MIDRC_MELODY/gui/matplotlib_spider_widget.py +204 -0
  21. MIDRC_MELODY/gui/metrics_model.py +62 -0
  22. MIDRC_MELODY/gui/plotly_spider_widget.py +56 -0
  23. MIDRC_MELODY/gui/qchart_spider_widget.py +272 -0
  24. MIDRC_MELODY/gui/shared/__init__.py +0 -0
  25. MIDRC_MELODY/gui/shared/react/__init__.py +0 -0
  26. MIDRC_MELODY/gui/shared/react/copyabletableview.py +100 -0
  27. MIDRC_MELODY/gui/shared/react/grabbablewidget.py +406 -0
  28. MIDRC_MELODY/gui/tqdm_handler.py +210 -0
  29. MIDRC_MELODY/melody.py +102 -0
  30. MIDRC_MELODY/melody_gui.py +111 -0
  31. MIDRC_MELODY/resources/MIDRC.ico +0 -0
  32. midrc_melody-0.3.3.dist-info/METADATA +151 -0
  33. midrc_melody-0.3.3.dist-info/RECORD +37 -0
  34. midrc_melody-0.3.3.dist-info/WHEEL +5 -0
  35. midrc_melody-0.3.3.dist-info/entry_points.txt +4 -0
  36. midrc_melody-0.3.3.dist-info/licenses/LICENSE +201 -0
  37. midrc_melody-0.3.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,292 @@
1
+ # Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """ EOD and AAOD metric calculation and plotting functions. """
17
+
18
+ from typing import Any, Dict, List, Optional, Tuple, Union
19
+
20
+ from joblib import delayed, Parallel
21
+ import matplotlib.pyplot as plt
22
+ import numpy as np
23
+ import pandas as pd
24
+ from sklearn.utils import resample
25
+ from tqdm import tqdm
26
+ from tqdm_joblib import tqdm_joblib
27
+
28
+ from MIDRC_MELODY.common.data_loading import check_required_columns, TestAndDemographicData
29
+ from MIDRC_MELODY.common.plot_tools import SpiderPlotData
30
+ from MIDRC_MELODY.common.matplotlib_spider import plot_spider_chart, display_figures_grid
31
+
32
+
33
+ def binarize_scores(df: pd.DataFrame, truth_col: str, ai_cols: Union[List[str], str], threshold: int = 4
34
+ ) -> pd.DataFrame:
35
+ """
36
+ Binarize scores based on a threshold for truth and AI columns.
37
+ Converts values greater than or equal to threshold to 1, else 0.
38
+
39
+ :arg df: DataFrame containing truth and test columns.
40
+ :arg truth_col: Name of the truth column.
41
+ :arg ai_cols: Name of the test column or a list of test columns.
42
+ :arg threshold: Threshold value for binarization.
43
+
44
+ :returns: DataFrame with binarized columns.
45
+ """
46
+ if not isinstance(ai_cols, list):
47
+ ai_cols = [ai_cols]
48
+ cols = [truth_col] + ai_cols
49
+ check_required_columns(df, cols)
50
+ df[cols] = (df[cols] >= threshold).astype(int)
51
+ return df
52
+
53
+
54
+ def resample_by_column(df: pd.DataFrame, col: Union[str, List[str]], seed: int) -> pd.DataFrame:
55
+ """
56
+ Resample each group in a DataFrame by the specified column
57
+ using the same seed across groups.
58
+
59
+ :arg df: DataFrame to resample.
60
+ :arg col: Column to group by.
61
+ :arg seed: Seed for reproducibility across groups.
62
+
63
+ :returns: Resampled DataFrame.
64
+ """
65
+ sampled_groups = [
66
+ resample(group_df, replace=True, n_samples=len(group_df), random_state=seed)
67
+ for _, group_df in df.groupby(col)
68
+ ]
69
+ return pd.concat(sampled_groups)
70
+
71
+
72
+ def compute_bootstrap_eod_aaod(
73
+ df: pd.DataFrame,
74
+ category: str,
75
+ ref_group: Any,
76
+ group_value: Any,
77
+ truth_col: str,
78
+ ai_columns: List[str],
79
+ seed: int
80
+ ) -> Dict[str, Tuple[float, float]]:
81
+ """
82
+ Compute bootstrap estimates for EOD and AAOD metrics.
83
+
84
+ :arg df: DataFrame containing truth and test columns.
85
+ :arg category: Column to group by.
86
+ :arg ref_group: Reference group value.
87
+ :arg group_value: Group value to compare against reference.
88
+ :arg truth_col: Name of the truth column.
89
+ :arg ai_columns: List of test columns.
90
+ :arg seed: Seed for reproducibility.
91
+
92
+ :returns: Dictionary of EOD and AAOD values for each model.
93
+ """
94
+ sample_df = resample_by_column(df, [category, truth_col], seed)
95
+ ref_df = sample_df[sample_df[category] == ref_group]
96
+ group_df = sample_df[sample_df[category] == group_value]
97
+
98
+ # Precompute truth masks for both reference and group DataFrames
99
+ ref_truth_pos = (ref_df[truth_col] == 1)
100
+ ref_truth_neg = ~ref_truth_pos
101
+ group_truth_pos = (group_df[truth_col] == 1)
102
+ group_truth_neg = ~group_truth_pos
103
+
104
+ results: Dict[str, Tuple[float, float]] = {}
105
+ for model in ai_columns:
106
+ ref_pred = (ref_df[model] == 1)
107
+ group_pred = (group_df[model] == 1)
108
+
109
+ tpr_ref = ref_pred[ref_truth_pos].sum() / ref_truth_pos.sum() if ref_truth_pos.sum() else np.nan
110
+ fpr_ref = ref_pred[ref_truth_neg].sum() / ref_truth_neg.sum() if ref_truth_neg.sum() else np.nan
111
+ tpr_group = group_pred[group_truth_pos].sum() / group_truth_pos.sum() if group_truth_pos.sum() else np.nan
112
+ fpr_group = group_pred[group_truth_neg].sum() / group_truth_neg.sum() if group_truth_neg.sum() else np.nan
113
+
114
+ eod = tpr_group - tpr_ref
115
+ aaod = 0.5 * (abs(fpr_group - fpr_ref) + abs(tpr_group - tpr_ref))
116
+ results[model] = (eod, aaod)
117
+
118
+ return results
119
+
120
+
121
+ def calculate_eod_aaod(
122
+ test_data: TestAndDemographicData
123
+ ) -> Dict[str, Dict[str, Dict[Any, Dict[str, Any]]]]:
124
+ """
125
+ Calculate EOD and AAOD metrics with bootstrap iterations for multiple categories.
126
+
127
+ :arg test_data: Test and demographic data.
128
+
129
+ :returns: Dictionary of EOD and AAOD values for each model.
130
+ """
131
+ ai_columns = test_data.test_cols
132
+ eod_aaod: Dict[str, Dict[str, Dict[Any, Dict[str, Any]]]] = {
133
+ category: {model: {} for model in ai_columns} for category in test_data.categories
134
+ }
135
+ rng = np.random.default_rng(test_data.base_seed)
136
+
137
+ for category in tqdm(test_data.categories, desc='Categories', position=0):
138
+ if category not in test_data.valid_groups:
139
+ continue
140
+
141
+ ref_group = test_data.reference_groups[category]
142
+ unique_values = test_data.matched_df[category].unique()
143
+
144
+ for group_value in tqdm(unique_values, desc=f"Category \'{category}\' Groups", leave=False, position=1):
145
+ if group_value == ref_group or group_value not in test_data.valid_groups[category]:
146
+ continue
147
+
148
+ eod_samples = {model: [] for model in ai_columns}
149
+ aaod_samples = {model: [] for model in ai_columns}
150
+
151
+ # Preassign seeds for each bootstrap iteration.
152
+ seeds = rng.integers(0, 1_000_000, size=test_data.n_iter)
153
+
154
+ with tqdm_joblib(total=test_data.n_iter, desc=f"Bootstrapping \'{group_value}\' Group", leave=False):
155
+ bootstrap_results = Parallel(n_jobs=-1)(
156
+ delayed(compute_bootstrap_eod_aaod)(
157
+ test_data.matched_df, category, ref_group, group_value, test_data.truth_col, ai_columns, seed
158
+ ) for seed in seeds
159
+ )
160
+
161
+ for result in bootstrap_results:
162
+ for model in ai_columns:
163
+ eod_samples[model].append(result[model][0])
164
+ aaod_samples[model].append(result[model][1])
165
+
166
+ for model in ai_columns:
167
+ eod_median = np.median(eod_samples[model])
168
+ aaod_median = np.median(aaod_samples[model])
169
+ eod_ci = np.percentile(eod_samples[model], [2.5, 97.5])
170
+ aaod_ci = np.percentile(aaod_samples[model], [2.5, 97.5])
171
+ eod_aaod[category][model][group_value] = {
172
+ 'eod': (eod_median, eod_ci),
173
+ 'aaod': (aaod_median, aaod_ci)
174
+ }
175
+ return eod_aaod
176
+
177
+
178
+ def extract_plot_data_eod_aaod(
179
+ eod_aaod: Dict[str, Dict[str, Dict[Any, Dict[str, Any]]]],
180
+ model: str,
181
+ metric: str = 'eod'
182
+ ) -> Tuple[List[str], List[float], List[float], List[float]]:
183
+ """
184
+ Extract groups, metric values and confidence intervals for plotting.
185
+
186
+ :arg eod_aaod: Dictionary of EOD and AAOD values for each model.
187
+ :arg model: Name of the model to extract data for.
188
+ :arg metric: Metric to extract data for (EOD or AAOD).
189
+
190
+ :returns: Tuple of groups, values, lower bounds and upper bounds.
191
+ """
192
+ groups: List[str] = []
193
+ values: List[float] = []
194
+ lower_bounds: List[float] = []
195
+ upper_bounds: List[float] = []
196
+
197
+ for category, model_data in eod_aaod.items():
198
+ if model in model_data:
199
+ for group, metric_list in model_data[model].items():
200
+ groups.append(f"{category}: {group}")
201
+ value, (lower, upper) = metric_list[metric]
202
+ values.append(value)
203
+ lower_bounds.append(lower)
204
+ upper_bounds.append(upper)
205
+
206
+ return groups, values, lower_bounds, upper_bounds
207
+
208
+
209
+ def generate_plot_data_eod_aaod(
210
+ eod_aaod: Dict[str, Dict[str, Dict[Any, Dict[str, Any]]]],
211
+ test_cols: List[str],
212
+ metrics: List[str] = ('eod', 'aaod')
213
+ ) -> Tuple[Dict[str, Dict[str, Tuple[List[str], List[float], List[float], List[float]]]],
214
+ Dict[str, float], Dict[str, float]]:
215
+ """
216
+ Generate plot data for each metric and compute global axis limits.
217
+
218
+ :arg eod_aaod: Dictionary of EOD and AAOD values for each model.
219
+ :arg test_cols: List of test columns.
220
+ :arg metrics: List of metrics to plot.
221
+
222
+ :returns: Tuple of plot data dictionary, global minimum and maximum values.
223
+ """
224
+ plot_data_dict: Dict[str, Dict[str, Tuple[List[str], List[float], List[float], List[float]]]] = {}
225
+ global_min = {}
226
+ global_max = {}
227
+
228
+ for metric in metrics:
229
+ all_values: List[float] = []
230
+ plot_data_dict[metric] = {}
231
+ for model in test_cols:
232
+ groups, values, lower, upper = extract_plot_data_eod_aaod(eod_aaod, model, metric)
233
+ plot_data_dict[metric][model] = (groups, values, lower, upper)
234
+ all_values.extend(lower + upper)
235
+
236
+ global_min[metric], global_max[metric] = min(all_values) - 0.05, max(all_values) + 0.05
237
+
238
+ return plot_data_dict, global_min, global_max
239
+
240
+
241
+ def create_spider_plot_data_eod_aaod(
242
+ plot_data_dict: Dict[str, Dict[str, Tuple[List[str], List[float], List[float], List[float]]]],
243
+ test_cols: List[str],
244
+ metrics: List[str] = ('eod', 'aaod'),
245
+ base_plot_data: Optional[SpiderPlotData] = None,
246
+ ) -> List[SpiderPlotData]:
247
+ plot_data_list: List[SpiderPlotData] = []
248
+ if base_plot_data is None:
249
+ base_plot_data = SpiderPlotData()
250
+ for metric in metrics:
251
+ for model in test_cols:
252
+ # Create a new copy based on the base instance
253
+ plot_data = SpiderPlotData(**base_plot_data.__dict__)
254
+ plot_data.metric = metric
255
+ plot_data.model_name = model
256
+ plot_data.groups, plot_data.values, plot_data.lower_bounds, plot_data.upper_bounds = \
257
+ plot_data_dict[metric][model]
258
+ plot_data_list.append(plot_data)
259
+ return plot_data_list
260
+
261
+ def plot_data_eod_aaod(
262
+ plot_data_dict: Dict[str, Dict[str, Tuple[List[str], List[float], List[float], List[float]]]],
263
+ test_cols: List[str],
264
+ metrics: List[str] = ('eod', 'aaod'),
265
+ base_plot_data: Optional[SpiderPlotData] = None,
266
+ ) -> Dict[str, List[plt.Figure]]:
267
+ """
268
+ Plot EOD and AAOD spider charts for each model.
269
+
270
+ :arg plot_data_dict: Dictionary of plot data for each metric.
271
+ :arg test_cols: List of test columns.
272
+ :arg metrics: List of metrics to plot.
273
+ :arg base_plot_data: Base SpiderPlotData instance for plot configuration.
274
+
275
+ :returns: Dictionary of generated figures for each metric.
276
+ """
277
+ plot_data_list = create_spider_plot_data_eod_aaod(plot_data_dict, test_cols, metrics, base_plot_data)
278
+ figures_dict: Dict[str, List[Any]] = {metric: [] for metric in metrics}
279
+
280
+ for plot_data in plot_data_list:
281
+ fig = plot_spider_chart(plot_data)
282
+ figures_dict[plot_data.metric].append(fig)
283
+
284
+ grid_figs = []
285
+ for figures in figures_dict.values():
286
+ grid_fig = display_figures_grid(figures)
287
+ grid_figs.append(grid_fig)
288
+
289
+ # all_figs = [fig for figs in figures_dict.values() for fig in figs] + [g for g in grid_figs if g is not None]
290
+
291
+
292
+ return figures_dict
@@ -0,0 +1,69 @@
1
+ # Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """This script generates EOD and AAOD spider plots for multiple models across different categories."""
17
+ from dataclasses import replace
18
+
19
+ import matplotlib.pyplot as plt
20
+ from pylab import get_current_fig_manager
21
+ import yaml
22
+
23
+ from MIDRC_MELODY.common.data_loading import build_test_and_demographic_data, save_pickled_data
24
+ from MIDRC_MELODY.common.eod_aaod_metrics import (binarize_scores, calculate_eod_aaod, generate_plot_data_eod_aaod,
25
+ plot_data_eod_aaod)
26
+ from MIDRC_MELODY.common.plot_tools import SpiderPlotData
27
+ from MIDRC_MELODY.common.table_tools import print_table_of_nonzero_eod_aaod
28
+
29
+
30
+ def generate_eod_aaod_spiders(cfg_path: str = "config.yaml"):
31
+ # Load configuration
32
+ with open(cfg_path, 'r', encoding='utf-8') as stream:
33
+ config = yaml.load(stream, Loader=yaml.CLoader)
34
+
35
+ # Load data
36
+ t_data = build_test_and_demographic_data(config)
37
+
38
+ # Binarize scores
39
+ threshold = config['binary threshold']
40
+ matched_df = binarize_scores(t_data.matched_df, t_data.truth_col, t_data.test_cols, threshold=threshold)
41
+ test_data = replace(t_data, matched_df=matched_df)
42
+
43
+ # Calculate EOD and AAOD
44
+ eod_aaod = calculate_eod_aaod(test_data)
45
+
46
+ # Print tables for EOD and AAOD using median values
47
+ print_table_of_nonzero_eod_aaod(eod_aaod, tablefmt="rounded_outline")
48
+
49
+ # Generate and save plots
50
+ metrics = ['eod', 'aaod']
51
+ plot_data_dict, global_min, global_max = generate_plot_data_eod_aaod(eod_aaod, test_data.test_cols, metrics=metrics)
52
+
53
+ # Save the EOD and AAOD data
54
+ for metric in metrics:
55
+ save_pickled_data(config['output'], metric, plot_data_dict[metric])
56
+
57
+ base_plot_data = SpiderPlotData(ylim_min=global_min, ylim_max=global_max, plot_config=config['plot'])
58
+ figures_dict = plot_data_eod_aaod(plot_data_dict, # noqa: F841
59
+ test_data.test_cols,
60
+ metrics=metrics,
61
+ base_plot_data=base_plot_data,
62
+ )
63
+
64
+ print("\nClose all figures to continue...", flush=True)
65
+ plt.show()
66
+
67
+
68
+ if __name__ == '__main__':
69
+ generate_eod_aaod_spiders()
@@ -0,0 +1,56 @@
1
+ # Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """This script generates QWK spider plots for multiple models across different categories."""
17
+
18
+ import matplotlib.pyplot as plt
19
+ import yaml
20
+
21
+ from MIDRC_MELODY.common.data_loading import build_test_and_demographic_data, save_pickled_data
22
+ from MIDRC_MELODY.common.qwk_metrics import (calculate_delta_kappa, calculate_kappas_and_intervals,
23
+ generate_plots_from_delta_kappas)
24
+ from MIDRC_MELODY.common.table_tools import print_table_of_nonzero_deltas
25
+
26
+
27
+ def generate_qwk_spiders(cfg_path: str = "config.yaml"):
28
+ # Load configuration
29
+ with open(cfg_path, 'r', encoding='utf-8') as stream:
30
+ config = yaml.load(stream, Loader=yaml.CLoader)
31
+
32
+ # Load data
33
+ test_data = build_test_and_demographic_data(config)
34
+
35
+ # Calculate Kappas and intervals, prints the table of Kappas and intervals
36
+ kappas, intervals = calculate_kappas_and_intervals(test_data)
37
+
38
+ # Bootstrap delta QWKs
39
+ print("Bootstrapping ∆κ, this may take a while", flush=True)
40
+ delta_kappas = calculate_delta_kappa(test_data)
41
+
42
+ # Print the table of non-zero delta Kappas
43
+ print_table_of_nonzero_deltas(delta_kappas, tablefmt="rounded_outline")
44
+
45
+ # Save the delta Kappas
46
+ save_pickled_data(config['output'], "QWK", delta_kappas)
47
+
48
+ # Generate and save plots
49
+ generate_plots_from_delta_kappas(delta_kappas, test_data.test_cols, plot_config=config['plot'])
50
+
51
+ print("\nClose all figures to continue...", flush=True)
52
+ plt.show()
53
+
54
+
55
+ if __name__ == '__main__':
56
+ generate_qwk_spiders()