ahmed taha commited on
Commit
c79be72
·
verified ·
1 Parent(s): 998a942

Upload aggregate.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. aggregate.py +314 -0
aggregate.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import contextlib
5
+ import csv
6
+ import json
7
+ from collections import defaultdict
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+
12
+
13
+ def load_all_reviews(results_dir: Path) -> dict[str, list[dict]]:
14
+ reviews_by_reviewer: dict[str, list[dict]] = {}
15
+ for path in sorted(results_dir.glob("reviews_*.csv")):
16
+ reviewer = path.stem.replace("reviews_", "")
17
+ rows: list[dict] = []
18
+ with open(path) as f:
19
+ for row in csv.DictReader(f):
20
+ rows.append(row)
21
+ if rows:
22
+ reviews_by_reviewer[reviewer] = rows
23
+ return reviews_by_reviewer
24
+
25
+
26
+ def classify_transformation(pair: dict) -> str:
27
+ age_changed = pair.get("source_age") != pair.get("target_age")
28
+ sex_changed = pair.get("source_sex") != pair.get("target_sex")
29
+ if pair.get("_transform_type"):
30
+ return str(pair["_transform_type"])
31
+ if age_changed and sex_changed:
32
+ return "intersectional"
33
+ if age_changed:
34
+ return "age_only"
35
+ if sex_changed:
36
+ return "sex_only"
37
+ return "none"
38
+
39
+
40
+ def build_rating_matrix(
41
+ reviews_by_reviewer: dict[str, list[dict]],
42
+ field: str,
43
+ num_pairs: int,
44
+ value_map: dict[str, float] | None = None,
45
+ ) -> np.ndarray:
46
+ reviewers = sorted(reviews_by_reviewer.keys())
47
+ matrix = np.full((num_pairs, len(reviewers)), np.nan)
48
+ for j, reviewer in enumerate(reviewers):
49
+ for row in reviews_by_reviewer[reviewer]:
50
+ idx = int(row["pair_index"])
51
+ if idx < num_pairs and row.get(field):
52
+ val = row[field]
53
+ if value_map and val in value_map:
54
+ matrix[idx, j] = value_map[val]
55
+ else:
56
+ with contextlib.suppress(ValueError, TypeError):
57
+ matrix[idx, j] = float(val)
58
+ return matrix
59
+
60
+
61
+ def fleiss_kappa(matrix: np.ndarray, categories: list[int | float]) -> float:
62
+ valid_rows = ~np.any(np.isnan(matrix), axis=1)
63
+ data = matrix[valid_rows]
64
+ n_subjects = data.shape[0]
65
+ n_raters = data.shape[1]
66
+
67
+ if n_subjects == 0 or n_raters < 2:
68
+ return float("nan")
69
+
70
+ counts = np.zeros((n_subjects, len(categories)))
71
+ for k_idx, k in enumerate(categories):
72
+ counts[:, k_idx] = np.sum(data == k, axis=1)
73
+
74
+ p_j = np.sum(counts, axis=0) / (n_subjects * n_raters)
75
+ p_i = (np.sum(counts**2, axis=1) - n_raters) / (n_raters * (n_raters - 1))
76
+
77
+ p_bar = np.mean(p_i)
78
+ p_e = np.sum(p_j**2)
79
+
80
+ if abs(1.0 - p_e) < 1e-10:
81
+ return 1.0
82
+
83
+ return float((p_bar - p_e) / (1.0 - p_e))
84
+
85
+
86
+ def compute_exclusions(
87
+ reviews_by_reviewer: dict[str, list[dict]],
88
+ num_pairs: int,
89
+ ) -> dict:
90
+ plausible_counts: dict[int, dict[str, int]] = defaultdict(lambda: {"Yes": 0, "No": 0})
91
+ quality_scores: dict[int, list[int]] = defaultdict(list)
92
+ reviewer_counts: dict[int, int] = defaultdict(int)
93
+
94
+ for reviews in reviews_by_reviewer.values():
95
+ for row in reviews:
96
+ idx = int(row["pair_index"])
97
+ reviewer_counts[idx] += 1
98
+ p = row.get("clinically_plausible", "Yes")
99
+ if p in ("Yes", "No"):
100
+ plausible_counts[idx][p] += 1
101
+ if row.get("quality_score"):
102
+ quality_scores[idx].append(int(row["quality_score"]))
103
+
104
+ excluded_implausible: list[int] = []
105
+ excluded_majority: list[int] = []
106
+ excluded_disputed: list[int] = []
107
+
108
+ for idx in range(num_pairs):
109
+ if reviewer_counts[idx] == 0:
110
+ continue
111
+
112
+ no_votes = plausible_counts[idx]["No"]
113
+ if no_votes > 0:
114
+ excluded_implausible.append(idx)
115
+
116
+ total = reviewer_counts[idx]
117
+ if no_votes > total / 2:
118
+ excluded_majority.append(idx)
119
+
120
+ scores = quality_scores.get(idx, [])
121
+ if len(scores) >= 3 and (max(scores) - min(scores)) >= 3 and idx not in excluded_implausible:
122
+ excluded_disputed.append(idx)
123
+
124
+ all_excluded = sorted(set(excluded_implausible + excluded_disputed))
125
+
126
+ return {
127
+ "implausible_any_rater": excluded_implausible,
128
+ "implausible_majority": excluded_majority,
129
+ "disputed_quality": excluded_disputed,
130
+ "all_excluded": all_excluded,
131
+ }
132
+
133
+
134
+ def compute_stratum_stats(
135
+ pairs: list[dict],
136
+ reviews_by_reviewer: dict[str, list[dict]],
137
+ exclusions: dict,
138
+ ) -> dict:
139
+ pair_strata: dict[int, str] = {}
140
+ for i, pair in enumerate(pairs):
141
+ pair_strata[i] = classify_transformation(pair)
142
+
143
+ all_reviews_by_pair: dict[int, list[dict]] = defaultdict(list)
144
+ for reviews in reviews_by_reviewer.values():
145
+ for row in reviews:
146
+ idx = int(row["pair_index"])
147
+ all_reviews_by_pair[idx].append(row)
148
+
149
+ strata = ["age_only", "sex_only", "intersectional"]
150
+ stats: dict[str, dict] = {}
151
+
152
+ excluded_set = set(exclusions["all_excluded"])
153
+
154
+ for stratum in strata:
155
+ indices = [i for i, s in pair_strata.items() if s == stratum]
156
+ if not indices:
157
+ stats[stratum] = {"total": 0}
158
+ continue
159
+
160
+ n_excluded = sum(1 for i in indices if i in excluded_set)
161
+ n_passed = len(indices) - n_excluded
162
+
163
+ plausible_yes = 0
164
+ plausible_total = 0
165
+ preserved_yes = 0
166
+ preserved_total = 0
167
+ quality_vals: list[int] = []
168
+
169
+ for idx in indices:
170
+ for row in all_reviews_by_pair.get(idx, []):
171
+ if row.get("clinically_plausible") in ("Yes", "No"):
172
+ plausible_total += 1
173
+ if row["clinically_plausible"] == "Yes":
174
+ plausible_yes += 1
175
+ if row.get("pathology_preserved") in ("Yes", "No"):
176
+ preserved_total += 1
177
+ if row["pathology_preserved"] == "Yes":
178
+ preserved_yes += 1
179
+ if row.get("quality_score"):
180
+ quality_vals.append(int(row["quality_score"]))
181
+
182
+ stats[stratum] = {
183
+ "total": len(indices),
184
+ "passed": n_passed,
185
+ "excluded": n_excluded,
186
+ "pass_rate": round(n_passed / len(indices), 3) if indices else 0,
187
+ "plausibility_rate": round(plausible_yes / plausible_total, 3) if plausible_total else None,
188
+ "pathology_preservation_rate": round(preserved_yes / preserved_total, 3) if preserved_total else None,
189
+ "mean_quality": round(float(np.mean(quality_vals)), 2) if quality_vals else None,
190
+ "quality_distribution": {
191
+ str(k): int(np.sum(np.array(quality_vals) == k))
192
+ for k in range(1, 6)
193
+ } if quality_vals else {},
194
+ }
195
+
196
+ return stats
197
+
198
+
199
+ def main() -> None:
200
+ parser = argparse.ArgumentParser(description="Aggregate radiologist reviews")
201
+ parser.add_argument("--pairs", type=str, required=True)
202
+ parser.add_argument("--results-dir", type=str, default="results")
203
+ parser.add_argument("--output", type=str, default="validation_report.json")
204
+ args = parser.parse_args()
205
+
206
+ with open(args.pairs) as f:
207
+ pairs = json.load(f)
208
+ num_pairs = len(pairs)
209
+
210
+ reviews_by_reviewer = load_all_reviews(Path(args.results_dir))
211
+ reviewers = sorted(reviews_by_reviewer.keys())
212
+
213
+ if not reviewers:
214
+ print("No review files found.")
215
+ return
216
+
217
+ quality_matrix = build_rating_matrix(
218
+ reviews_by_reviewer, "quality_score", num_pairs,
219
+ )
220
+ plausible_matrix = build_rating_matrix(
221
+ reviews_by_reviewer, "clinically_plausible", num_pairs,
222
+ value_map={"Yes": 1.0, "No": 0.0},
223
+ )
224
+ preserved_matrix = build_rating_matrix(
225
+ reviews_by_reviewer, "pathology_preserved", num_pairs,
226
+ value_map={"Yes": 1.0, "No": 0.0, "Uncertain": 0.5},
227
+ )
228
+
229
+ kappa_quality = fleiss_kappa(quality_matrix, categories=[1, 2, 3, 4, 5])
230
+ kappa_plausible = fleiss_kappa(plausible_matrix, categories=[0, 1])
231
+ kappa_preserved = fleiss_kappa(preserved_matrix, categories=[0, 0.5, 1])
232
+
233
+ exclusions = compute_exclusions(reviews_by_reviewer, num_pairs)
234
+ stratum_stats = compute_stratum_stats(pairs, reviews_by_reviewer, exclusions)
235
+
236
+ total_reviewed = sum(len(rows) for rows in reviews_by_reviewer.values())
237
+ per_reviewer: dict[str, dict] = {}
238
+ all_quality: list[int] = []
239
+ for reviewer, rows in reviews_by_reviewer.items():
240
+ scores = [int(r["quality_score"]) for r in rows if r.get("quality_score")]
241
+ n_implausible = sum(1 for r in rows if r.get("clinically_plausible") == "No")
242
+ n_not_preserved = sum(1 for r in rows if r.get("pathology_preserved") == "No")
243
+ per_reviewer[reviewer] = {
244
+ "reviewed": len(rows),
245
+ "mean_quality": round(float(np.mean(scores)), 2) if scores else None,
246
+ "flagged_implausible": n_implausible,
247
+ "flagged_not_preserved": n_not_preserved,
248
+ }
249
+ all_quality.extend(scores)
250
+
251
+ quality_dist = {str(k): int(np.sum(np.array(all_quality) == k)) for k in range(1, 6)} if all_quality else {}
252
+
253
+ valid_quality = quality_matrix[~np.all(np.isnan(quality_matrix), axis=1)]
254
+ mean_quality = float(np.nanmean(valid_quality)) if valid_quality.size > 0 else None
255
+
256
+ report = {
257
+ "num_pairs": num_pairs,
258
+ "num_reviewers": len(reviewers),
259
+ "reviewers": reviewers,
260
+ "total_reviews": total_reviewed,
261
+ "inter_rater_reliability": {
262
+ "fleiss_kappa_quality": round(kappa_quality, 4) if not np.isnan(kappa_quality) else None,
263
+ "fleiss_kappa_plausibility": round(kappa_plausible, 4) if not np.isnan(kappa_plausible) else None,
264
+ "fleiss_kappa_pathology_preservation": round(kappa_preserved, 4) if not np.isnan(kappa_preserved) else None,
265
+ },
266
+ "overall": {
267
+ "mean_quality_score": round(mean_quality, 2) if mean_quality else None,
268
+ "quality_distribution": quality_dist,
269
+ "pass_rate": round(
270
+ (num_pairs - len(exclusions["all_excluded"])) / num_pairs, 3
271
+ ) if num_pairs > 0 else 0,
272
+ "plausibility_rate": round(
273
+ float(np.nanmean(plausible_matrix[~np.all(np.isnan(plausible_matrix), axis=1)])), 3
274
+ ) if plausible_matrix.size > 0 else None,
275
+ "pathology_preservation_rate": round(
276
+ float(np.nanmean(preserved_matrix[~np.all(np.isnan(preserved_matrix), axis=1)])), 3
277
+ ) if preserved_matrix.size > 0 else None,
278
+ },
279
+ "by_transformation_type": stratum_stats,
280
+ "exclusions": {
281
+ "implausible_any_rater": len(exclusions["implausible_any_rater"]),
282
+ "implausible_majority_vote": len(exclusions["implausible_majority"]),
283
+ "disputed_quality": len(exclusions["disputed_quality"]),
284
+ "total_excluded": len(exclusions["all_excluded"]),
285
+ "excluded_pair_indices": exclusions["all_excluded"],
286
+ },
287
+ "per_reviewer": per_reviewer,
288
+ }
289
+
290
+ output_path = Path(args.output)
291
+ output_path.parent.mkdir(parents=True, exist_ok=True)
292
+ with open(output_path, "w") as f:
293
+ json.dump(report, f, indent=2)
294
+
295
+ exclusion_path = output_path.parent / "exclusion_list.json"
296
+ with open(exclusion_path, "w") as f:
297
+ json.dump({"excluded_pair_indices": exclusions["all_excluded"]}, f, indent=2)
298
+
299
+ print(f"Reviewers: {len(reviewers)}")
300
+ print(f"Total reviews: {total_reviewed}")
301
+ print(f"Fleiss kappa (quality): {report['inter_rater_reliability']['fleiss_kappa_quality']}")
302
+ print(f"Fleiss kappa (plausibility): {report['inter_rater_reliability']['fleiss_kappa_plausibility']}")
303
+ print(f"Fleiss kappa (pathology): {report['inter_rater_reliability']['fleiss_kappa_pathology_preservation']}")
304
+ print(f"Overall pass rate: {report['overall']['pass_rate']}")
305
+ print(f"Total excluded: {report['exclusions']['total_excluded']}")
306
+ for stratum, stats in stratum_stats.items():
307
+ if stats.get("total", 0) > 0:
308
+ print(f" {stratum}: {stats['passed']}/{stats['total']} passed ({stats['pass_rate']})")
309
+ print(f"Report: {output_path}")
310
+ print(f"Exclusion list: {exclusion_path}")
311
+
312
+
313
+ if __name__ == "__main__":
314
+ main()