dreamlessx commited on
Commit
74d5d6f
·
verified ·
1 Parent(s): ef63076

Upload landmarkdiff/audit.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. landmarkdiff/audit.py +342 -0
landmarkdiff/audit.py ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Clinical audit report generator for regulatory compliance.
2
+
3
+ Generates structured HTML reports summarizing safety validation results,
4
+ model performance, and Fitzpatrick equity analysis for clinical review.
5
+
6
+ Reports include:
7
+ - Safety validation pass/fail summary per patient
8
+ - Aggregate statistics by procedure and Fitzpatrick type
9
+ - Flagged cases for manual review
10
+ - Model version and configuration provenance
11
+
12
+ Usage:
13
+ from landmarkdiff.audit import AuditReporter, AuditCase
14
+
15
+ reporter = AuditReporter(model_version="0.3.0")
16
+ reporter.add_case(AuditCase(
17
+ case_id="P001",
18
+ procedure="rhinoplasty",
19
+ safety_passed=True,
20
+ identity_sim=0.87,
21
+ fitzpatrick_type="III",
22
+ ))
23
+ reporter.generate_report("audit_report.html")
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+
35
+ @dataclass
36
+ class AuditCase:
37
+ """A single patient case for audit reporting."""
38
+
39
+ case_id: str
40
+ procedure: str
41
+ safety_passed: bool
42
+ identity_sim: float = 0.0
43
+ intensity: float = 65.0
44
+ fitzpatrick_type: str = ""
45
+ warnings: list[str] = field(default_factory=list)
46
+ failures: list[str] = field(default_factory=list)
47
+ metrics: dict[str, float] = field(default_factory=dict)
48
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
49
+
50
+
51
+ @dataclass
52
+ class AuditSummary:
53
+ """Aggregate statistics for an audit report."""
54
+
55
+ total_cases: int = 0
56
+ passed_cases: int = 0
57
+ failed_cases: int = 0
58
+ flagged_cases: int = 0
59
+ pass_rate: float = 0.0
60
+ mean_identity_sim: float = 0.0
61
+ by_procedure: dict[str, dict[str, Any]] = field(default_factory=dict)
62
+ by_fitzpatrick: dict[str, dict[str, Any]] = field(default_factory=dict)
63
+
64
+
65
+ class AuditReporter:
66
+ """Generate clinical audit reports from safety validation results.
67
+
68
+ Args:
69
+ model_version: Model version string for provenance.
70
+ report_title: Title for generated reports.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ model_version: str = "0.3.0",
76
+ report_title: str = "LandmarkDiff Clinical Audit Report",
77
+ ) -> None:
78
+ self.model_version = model_version
79
+ self.report_title = report_title
80
+ self.cases: list[AuditCase] = []
81
+
82
+ def add_case(self, case: AuditCase) -> None:
83
+ """Add a case to the audit report."""
84
+ self.cases.append(case)
85
+
86
+ def add_cases(self, cases: list[AuditCase]) -> None:
87
+ """Add multiple cases."""
88
+ self.cases.extend(cases)
89
+
90
+ def clear(self) -> None:
91
+ """Clear all cases."""
92
+ self.cases.clear()
93
+
94
+ def compute_summary(self) -> AuditSummary:
95
+ """Compute aggregate statistics from all cases."""
96
+ if not self.cases:
97
+ return AuditSummary()
98
+
99
+ total = len(self.cases)
100
+ passed = sum(1 for c in self.cases if c.safety_passed)
101
+ failed = total - passed
102
+ flagged = sum(1 for c in self.cases if not c.safety_passed or c.warnings)
103
+
104
+ id_sims = [c.identity_sim for c in self.cases if c.identity_sim > 0]
105
+ mean_id = sum(id_sims) / len(id_sims) if id_sims else 0.0
106
+
107
+ # By procedure
108
+ by_proc: dict[str, dict[str, Any]] = {}
109
+ for case in self.cases:
110
+ proc = case.procedure
111
+ if proc not in by_proc:
112
+ by_proc[proc] = {"total": 0, "passed": 0, "id_sims": []}
113
+ by_proc[proc]["total"] += 1
114
+ if case.safety_passed:
115
+ by_proc[proc]["passed"] += 1
116
+ if case.identity_sim > 0:
117
+ by_proc[proc]["id_sims"].append(case.identity_sim)
118
+
119
+ for proc, stats in by_proc.items():
120
+ stats["pass_rate"] = stats["passed"] / max(stats["total"], 1)
121
+ stats["mean_identity_sim"] = (
122
+ sum(stats["id_sims"]) / len(stats["id_sims"])
123
+ if stats["id_sims"]
124
+ else 0.0
125
+ )
126
+ del stats["id_sims"]
127
+
128
+ # By Fitzpatrick type
129
+ by_fitz: dict[str, dict[str, Any]] = {}
130
+ for case in self.cases:
131
+ ft = case.fitzpatrick_type or "Unknown"
132
+ if ft not in by_fitz:
133
+ by_fitz[ft] = {"total": 0, "passed": 0, "id_sims": []}
134
+ by_fitz[ft]["total"] += 1
135
+ if case.safety_passed:
136
+ by_fitz[ft]["passed"] += 1
137
+ if case.identity_sim > 0:
138
+ by_fitz[ft]["id_sims"].append(case.identity_sim)
139
+
140
+ for ft, stats in by_fitz.items():
141
+ stats["pass_rate"] = stats["passed"] / max(stats["total"], 1)
142
+ stats["mean_identity_sim"] = (
143
+ sum(stats["id_sims"]) / len(stats["id_sims"])
144
+ if stats["id_sims"]
145
+ else 0.0
146
+ )
147
+ del stats["id_sims"]
148
+
149
+ return AuditSummary(
150
+ total_cases=total,
151
+ passed_cases=passed,
152
+ failed_cases=failed,
153
+ flagged_cases=flagged,
154
+ pass_rate=passed / total,
155
+ mean_identity_sim=mean_id,
156
+ by_procedure=by_proc,
157
+ by_fitzpatrick=by_fitz,
158
+ )
159
+
160
+ def flagged_cases(self) -> list[AuditCase]:
161
+ """Return cases that need manual review (failed or have warnings)."""
162
+ return [c for c in self.cases if not c.safety_passed or c.warnings]
163
+
164
+ def to_json(self) -> str:
165
+ """Export audit data as JSON."""
166
+ summary = self.compute_summary()
167
+ data = {
168
+ "report_title": self.report_title,
169
+ "model_version": self.model_version,
170
+ "generated_at": datetime.now(timezone.utc).isoformat(),
171
+ "summary": {
172
+ "total_cases": summary.total_cases,
173
+ "passed_cases": summary.passed_cases,
174
+ "failed_cases": summary.failed_cases,
175
+ "flagged_cases": summary.flagged_cases,
176
+ "pass_rate": round(summary.pass_rate, 4),
177
+ "mean_identity_sim": round(summary.mean_identity_sim, 4),
178
+ },
179
+ "by_procedure": {
180
+ k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()}
181
+ for k, v in summary.by_procedure.items()
182
+ },
183
+ "by_fitzpatrick": {
184
+ k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()}
185
+ for k, v in summary.by_fitzpatrick.items()
186
+ },
187
+ "cases": [
188
+ {
189
+ "case_id": c.case_id,
190
+ "procedure": c.procedure,
191
+ "safety_passed": c.safety_passed,
192
+ "identity_sim": round(c.identity_sim, 4),
193
+ "intensity": c.intensity,
194
+ "fitzpatrick_type": c.fitzpatrick_type,
195
+ "warnings": c.warnings,
196
+ "failures": c.failures,
197
+ "metrics": {k: round(v, 4) for k, v in c.metrics.items()},
198
+ "timestamp": c.timestamp,
199
+ }
200
+ for c in self.cases
201
+ ],
202
+ }
203
+ return json.dumps(data, indent=2)
204
+
205
+ def generate_report(self, output_path: str | Path) -> Path:
206
+ """Generate an HTML audit report.
207
+
208
+ Args:
209
+ output_path: Path to save the HTML report.
210
+
211
+ Returns:
212
+ Path to the generated report.
213
+ """
214
+ output_path = Path(output_path)
215
+ output_path.parent.mkdir(parents=True, exist_ok=True)
216
+
217
+ summary = self.compute_summary()
218
+ html = self._render_html(summary)
219
+
220
+ output_path.write_text(html)
221
+ return output_path
222
+
223
+ def _render_html(self, summary: AuditSummary) -> str:
224
+ """Render the audit report as HTML."""
225
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
226
+ status = "PASS" if summary.failed_cases == 0 else "REQUIRES REVIEW"
227
+ status_color = "#28a745" if summary.failed_cases == 0 else "#dc3545"
228
+
229
+ # Build procedure rows
230
+ proc_rows = ""
231
+ for proc, stats in sorted(summary.by_procedure.items()):
232
+ rate = stats["pass_rate"]
233
+ rate_color = "#28a745" if rate >= 0.95 else "#ffc107" if rate >= 0.8 else "#dc3545"
234
+ proc_rows += (
235
+ f"<tr>"
236
+ f"<td>{proc.title()}</td>"
237
+ f"<td>{stats['total']}</td>"
238
+ f"<td>{stats['passed']}</td>"
239
+ f'<td style="color:{rate_color};font-weight:bold">{rate:.1%}</td>'
240
+ f"<td>{stats['mean_identity_sim']:.4f}</td>"
241
+ f"</tr>\n"
242
+ )
243
+
244
+ # Build Fitzpatrick rows
245
+ fitz_rows = ""
246
+ for ft, stats in sorted(summary.by_fitzpatrick.items()):
247
+ rate = stats["pass_rate"]
248
+ rate_color = "#28a745" if rate >= 0.95 else "#ffc107" if rate >= 0.8 else "#dc3545"
249
+ fitz_rows += (
250
+ f"<tr>"
251
+ f"<td>{ft}</td>"
252
+ f"<td>{stats['total']}</td>"
253
+ f"<td>{stats['passed']}</td>"
254
+ f'<td style="color:{rate_color};font-weight:bold">{rate:.1%}</td>'
255
+ f"<td>{stats['mean_identity_sim']:.4f}</td>"
256
+ f"</tr>\n"
257
+ )
258
+
259
+ # Build flagged cases
260
+ flagged = self.flagged_cases()
261
+ flagged_rows = ""
262
+ for c in flagged:
263
+ issues = "; ".join(c.failures + [f"WARN: {w}" for w in c.warnings])
264
+ bg = "#fff3cd" if c.safety_passed else "#f8d7da"
265
+ flagged_rows += (
266
+ f'<tr style="background:{bg}">'
267
+ f"<td>{c.case_id}</td>"
268
+ f"<td>{c.procedure.title()}</td>"
269
+ f"<td>{c.fitzpatrick_type}</td>"
270
+ f"<td>{c.identity_sim:.4f}</td>"
271
+ f'<td>{"WARN" if c.safety_passed else "FAIL"}</td>'
272
+ f"<td>{issues}</td>"
273
+ f"</tr>\n"
274
+ )
275
+
276
+ return f"""<!DOCTYPE html>
277
+ <html lang="en">
278
+ <head>
279
+ <meta charset="utf-8">
280
+ <title>{self.report_title}</title>
281
+ <style>
282
+ body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
283
+ max-width: 1100px; margin: 0 auto; padding: 20px; color: #333; }}
284
+ h1 {{ border-bottom: 3px solid #333; padding-bottom: 10px; }}
285
+ h2 {{ color: #555; margin-top: 30px; border-bottom: 1px solid #ddd; padding-bottom: 5px; }}
286
+ table {{ border-collapse: collapse; width: 100%; margin: 15px 0; }}
287
+ th, td {{ border: 1px solid #ddd; padding: 8px 12px; text-align: left; }}
288
+ th {{ background: #f8f9fa; font-weight: 600; }}
289
+ tr:hover {{ background: #f5f5f5; }}
290
+ .status {{ display: inline-block; padding: 4px 12px; border-radius: 4px;
291
+ color: white; font-weight: bold; font-size: 18px; }}
292
+ .summary-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
293
+ gap: 15px; margin: 20px 0; }}
294
+ .summary-card {{ background: #f8f9fa; border-radius: 8px; padding: 15px; text-align: center; }}
295
+ .summary-card .value {{ font-size: 28px; font-weight: bold; color: #333; }}
296
+ .summary-card .label {{ font-size: 12px; color: #888; text-transform: uppercase; }}
297
+ .disclaimer {{ background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px;
298
+ padding: 12px; margin: 20px 0; font-size: 13px; }}
299
+ footer {{ margin-top: 40px; padding-top: 15px; border-top: 1px solid #ddd;
300
+ font-size: 12px; color: #999; }}
301
+ </style>
302
+ </head>
303
+ <body>
304
+ <h1>{self.report_title}</h1>
305
+ <p>Generated: {now} &nbsp;|&nbsp; Model version: <code>{self.model_version}</code>
306
+ &nbsp;|&nbsp; Overall status: <span class="status" style="background:{status_color}">{status}</span></p>
307
+
308
+ <div class="disclaimer">
309
+ <strong>Disclaimer:</strong> This report is for research and development purposes only.
310
+ LandmarkDiff predictions are AI-generated visualizations and do not constitute medical advice
311
+ or guarantee surgical outcomes. All predictions should be reviewed by qualified clinical professionals.
312
+ </div>
313
+
314
+ <h2>Summary</h2>
315
+ <div class="summary-grid">
316
+ <div class="summary-card"><div class="value">{summary.total_cases}</div><div class="label">Total Cases</div></div>
317
+ <div class="summary-card"><div class="value" style="color:#28a745">{summary.passed_cases}</div><div class="label">Passed</div></div>
318
+ <div class="summary-card"><div class="value" style="color:#dc3545">{summary.failed_cases}</div><div class="label">Failed</div></div>
319
+ <div class="summary-card"><div class="value" style="color:#ffc107">{summary.flagged_cases}</div><div class="label">Flagged</div></div>
320
+ <div class="summary-card"><div class="value">{summary.pass_rate:.1%}</div><div class="label">Pass Rate</div></div>
321
+ <div class="summary-card"><div class="value">{summary.mean_identity_sim:.4f}</div><div class="label">Mean ID Sim</div></div>
322
+ </div>
323
+
324
+ <h2>Performance by Procedure</h2>
325
+ <table>
326
+ <tr><th>Procedure</th><th>Total</th><th>Passed</th><th>Pass Rate</th><th>Mean ID Sim</th></tr>
327
+ {proc_rows}</table>
328
+
329
+ <h2>Equity Analysis by Fitzpatrick Type</h2>
330
+ <table>
331
+ <tr><th>Fitzpatrick Type</th><th>Total</th><th>Passed</th><th>Pass Rate</th><th>Mean ID Sim</th></tr>
332
+ {fitz_rows}</table>
333
+
334
+ {"<h2>Flagged Cases (Require Review)</h2>" if flagged_rows else ""}
335
+ {"<table><tr><th>Case ID</th><th>Procedure</th><th>Fitzpatrick</th><th>ID Sim</th><th>Status</th><th>Issues</th></tr>" + flagged_rows + "</table>" if flagged_rows else "<p>No flagged cases.</p>"}
336
+
337
+ <footer>
338
+ LandmarkDiff v{self.model_version} &mdash; Clinical Audit Report &mdash;
339
+ For research use only. Not FDA approved.
340
+ </footer>
341
+ </body>
342
+ </html>"""