research-tool-cli 0.1.0__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 (93) hide show
  1. app/backend/_bootstrap.py +15 -0
  2. app/backend/exports/verification_script.py +19 -0
  3. app/backend/main.py +59 -0
  4. app/backend/routers/__init__.py +1 -0
  5. app/backend/routers/execution.py +478 -0
  6. app/backend/routers/ingestion.py +233 -0
  7. app/backend/routers/planning.py +265 -0
  8. app/backend/routers/reporting.py +531 -0
  9. app/backend/state.py +44 -0
  10. core/__init__.py +0 -0
  11. core/cli/__init__.py +0 -0
  12. core/cli/main.py +1705 -0
  13. core/database.py +62 -0
  14. core/ingestion/__init__.py +0 -0
  15. core/ingestion/csv_loader.py +191 -0
  16. core/ingestion/variable_classifier.py +171 -0
  17. core/masking/__init__.py +0 -0
  18. core/masking/gate.py +128 -0
  19. core/models.py +138 -0
  20. core/planning/__init__.py +0 -0
  21. core/planning/diagnostics.py +89 -0
  22. core/planning/lock.py +232 -0
  23. core/planning/study_plan.py +73 -0
  24. core/planning/test_selector.py +518 -0
  25. core/provenance/__init__.py +0 -0
  26. core/provenance/hashing.py +38 -0
  27. core/provenance/tracker.py +105 -0
  28. core/reporting/__init__.py +62 -0
  29. core/reporting/appendix.py +58 -0
  30. core/reporting/bundle.py +378 -0
  31. core/reporting/excel_export.py +683 -0
  32. core/reporting/flowchart/__init__.py +20 -0
  33. core/reporting/flowchart/flowchart.py +511 -0
  34. core/reporting/forensics.py +592 -0
  35. core/reporting/forest_plot.py +614 -0
  36. core/reporting/lineage.py +562 -0
  37. core/reporting/manuscript_draft.py +726 -0
  38. core/reporting/plots.py +568 -0
  39. core/reporting/strobe_checklist.py +460 -0
  40. core/stats/__init__.py +0 -0
  41. core/stats/descriptive.py +104 -0
  42. core/stats/inferential.py +540 -0
  43. core/stats/multiple_comparisons.py +62 -0
  44. core/stats/post_hoc.py +62 -0
  45. exports/verification_script.py +19 -0
  46. research_tool_cli-0.1.0.dist-info/METADATA +16 -0
  47. research_tool_cli-0.1.0.dist-info/RECORD +93 -0
  48. research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
  49. research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
  52. tests/__init__.py +0 -0
  53. tests/conftest.py +45 -0
  54. tests/test_amendments.py +296 -0
  55. tests/test_analyze_batch_robustness.py +162 -0
  56. tests/test_app_statistical_reporting.py +383 -0
  57. tests/test_benchmark_21.py +556 -0
  58. tests/test_bundle.py +277 -0
  59. tests/test_cox_ph_plan.py +498 -0
  60. tests/test_csv_loader.py +368 -0
  61. tests/test_end_to_end.py +302 -0
  62. tests/test_excel_export.py +164 -0
  63. tests/test_flowchart.py +244 -0
  64. tests/test_forensics.py +305 -0
  65. tests/test_forest_plot.py +374 -0
  66. tests/test_from_json.py +176 -0
  67. tests/test_latest_plan_version.py +164 -0
  68. tests/test_lineage.py +329 -0
  69. tests/test_lock_immutability.py +133 -0
  70. tests/test_m1_fk_violation.py +85 -0
  71. tests/test_m2_data_hash_consistency.py +40 -0
  72. tests/test_m3_correction_timing.py +59 -0
  73. tests/test_m4_dedup_field.py +59 -0
  74. tests/test_m5_excel_hash_scope.py +45 -0
  75. tests/test_m6_fisher_exact_naming.py +44 -0
  76. tests/test_m7_duplicate_study_plan.py +55 -0
  77. tests/test_m8_filter_superseded.py +58 -0
  78. tests/test_m9_table1_groupby.py +56 -0
  79. tests/test_manuscript_draft.py +289 -0
  80. tests/test_masking_gate.py +196 -0
  81. tests/test_masking_migration.py +111 -0
  82. tests/test_multiple_comparisons.py +56 -0
  83. tests/test_plan_validation.py +289 -0
  84. tests/test_plots.py +394 -0
  85. tests/test_post_hoc_tagging.py +49 -0
  86. tests/test_posthoc_analyze.py +203 -0
  87. tests/test_provenance_tracker.py +110 -0
  88. tests/test_roadmap_features.py +148 -0
  89. tests/test_stats_descriptive.py +57 -0
  90. tests/test_stats_inferential.py +380 -0
  91. tests/test_strobe_checklist.py +172 -0
  92. tests/test_test_selector.py +350 -0
  93. tests/test_variable_classifier.py +124 -0
@@ -0,0 +1,540 @@
1
+ """Inferential statistics — runs pre-registered tests from the locked plan.
2
+
3
+ Uses scipy, statsmodels, and lifelines. Every function returns a standard
4
+ result dict so callers can consume uniformly.
5
+
6
+ URO (Unified Result Object) keys:
7
+ test_name, statistic, p_value, ci_lower, ci_upper, params,
8
+ effect_size: dict {"metric": str, "value": float} | None
9
+ sample_counts: dict {"n_total": int, "n_analyzed": int, "n_excluded": int}
10
+ """
11
+
12
+ from __future__ import annotations
13
+ import logging
14
+ import re
15
+ from typing import Optional
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ from scipy import stats as sp_stats
20
+ from scipy.stats import chi2_contingency, fisher_exact, ttest_ind, ttest_rel
21
+ from lifelines.exceptions import ConvergenceError
22
+ from scipy.stats import mannwhitneyu, f_oneway, kruskal
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Lazy-import lifelines only when needed (slow import)
27
+
28
+
29
+ def _uro(*, n_analyzed: int, n_total: int | None = None,
30
+ **fields) -> dict:
31
+ """Build a URO dict with defaults for common fields."""
32
+ uro = {
33
+ "test_name": None,
34
+ "statistic": None,
35
+ "p_value": None,
36
+ "ci_lower": None,
37
+ "ci_upper": None,
38
+ "params": {},
39
+ "effect_size": None,
40
+ "sample_counts": {
41
+ "n_total": n_total or n_analyzed,
42
+ "n_analyzed": n_analyzed,
43
+ "n_excluded": (n_total or n_analyzed) - n_analyzed,
44
+ },
45
+ }
46
+ uro.update(fields)
47
+ return uro
48
+
49
+
50
+ def _cohens_d(g1: pd.Series, g2: pd.Series) -> float:
51
+ """Cohen's d for two independent groups (pooled SD)."""
52
+ n1, n2 = len(g1), len(g2)
53
+ s1, s2 = g1.var(ddof=1), g2.var(ddof=1)
54
+ pooled = np.sqrt(((n1 - 1) * s1 + (n2 - 1) * s2) / (n1 + n2 - 2))
55
+ if pooled == 0:
56
+ return 0.0
57
+ return float((g1.mean() - g2.mean()) / pooled)
58
+
59
+
60
+ def _cramers_v(chi2: float, n: int, r: int, c: int) -> float:
61
+ """Cramér's V for chi-square test of association."""
62
+ k = min(r - 1, c - 1)
63
+ if k <= 0 or n == 0:
64
+ return 0.0
65
+ return float(np.sqrt(chi2 / (n * k)))
66
+
67
+
68
+ def run_test(
69
+ test_name: str,
70
+ data: pd.DataFrame,
71
+ outcome_col: str,
72
+ group_col: str,
73
+ time_col: Optional[str] = None,
74
+ event_col: Optional[str] = None,
75
+ covariates: Optional[list[str]] = None,
76
+ **kwargs,
77
+ ) -> dict:
78
+ """Dispatch to the appropriate test function.
79
+
80
+ Returns a URO (Unified Result Object) dict.
81
+ """
82
+ if test_name in ("chi_square",):
83
+ return _chi_square(data, outcome_col, group_col)
84
+ elif test_name == "fishers_exact":
85
+ return _fishers_exact(data, outcome_col, group_col)
86
+ elif test_name == "t_test":
87
+ return _t_test(data, outcome_col, group_col, paired=False)
88
+ elif test_name == "paired_t_test":
89
+ return _t_test(data, outcome_col, group_col, paired=True)
90
+ elif test_name == "mann_whitney_u":
91
+ return _mann_whitney(data, outcome_col, group_col)
92
+ elif test_name == "wilcoxon_signed_rank":
93
+ return _wilcoxon(data, outcome_col, group_col)
94
+ elif test_name == "anova":
95
+ return _anova(data, outcome_col, group_col)
96
+ elif test_name == "kruskal_wallis":
97
+ return _kruskal_wallis(data, outcome_col, group_col)
98
+ elif test_name == "kaplan_meier_logrank":
99
+ return _kaplan_meier_logrank(data, time_col, event_col, group_col)
100
+ elif test_name == "cox_proportional_hazards":
101
+ return _cox_ph(data, time_col, event_col, group_col, covariates or [])
102
+ elif test_name == "cox_ph_model":
103
+ var_types = kwargs.get("var_types", {})
104
+ interaction_terms = kwargs.get("interaction_terms", [])
105
+ try:
106
+ return _cox_ph_model(data, time_col, event_col, group_col, covariates or [],
107
+ var_types=var_types, interaction_terms=interaction_terms)
108
+ except ConvergenceError as e:
109
+ return _uro(
110
+ test_name=test_name, n_analyzed=len(data),
111
+ params={"error": f"ConvergenceError: {e}"},
112
+ status="error",
113
+ )
114
+ else:
115
+ return _uro(test_name=test_name, n_analyzed=len(data),
116
+ params={"error": f"Unknown test: {test_name}"})
117
+
118
+
119
+ def _chi_square(data: pd.DataFrame, outcome_col: str, group_col: str) -> dict:
120
+ ct = pd.crosstab(data[group_col], data[outcome_col])
121
+ stat, p, dof, expected = chi2_contingency(ct)
122
+ n = len(data)
123
+ return _uro(
124
+ test_name="chi_square", statistic=float(stat), p_value=float(p),
125
+ n_analyzed=n,
126
+ effect_size={
127
+ "metric": "Cramér's V",
128
+ "value": _cramers_v(float(stat), n, ct.shape[0], ct.shape[1]),
129
+ },
130
+ params={"dof": int(dof)},
131
+ )
132
+
133
+
134
+ def _fishers_exact(data: pd.DataFrame, outcome_col: str, group_col: str) -> dict:
135
+ ct = pd.crosstab(data[group_col], data[outcome_col])
136
+ if ct.shape != (2, 2):
137
+ return _uro(
138
+ test_name="fishers_exact", n_analyzed=len(data),
139
+ params={"error": "Fisher's exact requires a 2x2 table"},
140
+ )
141
+ odds_ratio, p = fisher_exact(ct)
142
+ return _uro(
143
+ test_name="fishers_exact", statistic=float(odds_ratio),
144
+ p_value=float(p), n_analyzed=len(data),
145
+ effect_size={"metric": "Odds Ratio", "value": float(odds_ratio)},
146
+ )
147
+
148
+
149
+ def _t_test(data: pd.DataFrame, outcome_col: str, group_col: str,
150
+ paired: bool = False) -> dict:
151
+ groups = [g for _, g in data.groupby(group_col)[outcome_col]]
152
+ if len(groups) != 2:
153
+ return _uro(
154
+ test_name="t_test", n_analyzed=len(data),
155
+ params={"error": "t-test requires exactly 2 groups"},
156
+ )
157
+ g1, g2 = groups
158
+ g1 = pd.to_numeric(g1, errors="coerce").dropna()
159
+ g2 = pd.to_numeric(g2, errors="coerce").dropna()
160
+ if paired:
161
+ n = min(len(g1), len(g2))
162
+ stat, p = ttest_rel(g1.iloc[:n], g2.iloc[:n])
163
+ test = "paired_t_test"
164
+ es = None
165
+ diff = g1.iloc[:n] - g2.iloc[:n]
166
+ mean_diff = diff.mean()
167
+ se = diff.std(ddof=1) / (n ** 0.5)
168
+ df = n - 1
169
+ else:
170
+ stat, p = ttest_ind(g1, g2, equal_var=False)
171
+ test = "t_test"
172
+ es = {"metric": "Cohen's d", "value": _cohens_d(g1, g2)}
173
+ mean_diff = g1.mean() - g2.mean()
174
+ v1, v2 = g1.var(ddof=1), g2.var(ddof=1)
175
+ n1, n2 = len(g1), len(g2)
176
+ se = ((v1 / n1) + (v2 / n2)) ** 0.5
177
+ df_num = ((v1 / n1) + (v2 / n2)) ** 2
178
+ df_den = ((v1 / n1) ** 2 / (n1 - 1)) + ((v2 / n2) ** 2 / (n2 - 1))
179
+ df = df_num / df_den if df_den > 0 else 1.0
180
+ from scipy.stats import t as t_dist
181
+ t_crit = t_dist.ppf(0.975, df)
182
+ ci_lower = mean_diff - t_crit * se
183
+ ci_upper = mean_diff + t_crit * se
184
+ n_total = len(g1) + len(g2)
185
+ return _uro(
186
+ test_name=test, statistic=float(stat), p_value=float(p),
187
+ n_analyzed=n_total, effect_size=es,
188
+ ci_lower=float(ci_lower), ci_upper=float(ci_upper),
189
+ params={"n1": len(g1), "n2": len(g2), "df": float(df)},
190
+ )
191
+
192
+
193
+ def _mann_whitney(data: pd.DataFrame, outcome_col: str, group_col: str) -> dict:
194
+ groups = [g for _, g in data.groupby(group_col)[outcome_col]]
195
+ if len(groups) != 2:
196
+ return _uro(
197
+ test_name="mann_whitney_u", n_analyzed=len(data),
198
+ params={"error": "Mann-Whitney requires exactly 2 groups"},
199
+ )
200
+ g1 = pd.to_numeric(groups[0], errors="coerce").dropna()
201
+ g2 = pd.to_numeric(groups[1], errors="coerce").dropna()
202
+ stat, p = mannwhitneyu(g1, g2)
203
+ return _uro(
204
+ test_name="mann_whitney_u", statistic=float(stat), p_value=float(p),
205
+ n_analyzed=len(g1) + len(g2),
206
+ params={"n1": len(g1), "n2": len(g2)},
207
+ )
208
+
209
+
210
+ def _wilcoxon(data: pd.DataFrame, outcome_col: str, group_col: str) -> dict:
211
+ groups = [g for _, g in data.groupby(group_col)[outcome_col]]
212
+ if len(groups) != 2:
213
+ return _uro(
214
+ test_name="wilcoxon_signed_rank", n_analyzed=len(data),
215
+ params={"error": "Wilcoxon requires exactly 2 groups"},
216
+ )
217
+ g1 = pd.to_numeric(groups[0], errors="coerce").dropna()
218
+ g2 = pd.to_numeric(groups[1], errors="coerce").dropna()
219
+ n = min(len(g1), len(g2))
220
+ stat, p = sp_stats.wilcoxon(g1.iloc[:n], g2.iloc[:n])
221
+ return _uro(
222
+ test_name="wilcoxon_signed_rank", statistic=float(stat),
223
+ p_value=float(p), n_analyzed=n,
224
+ params={"n": n},
225
+ )
226
+
227
+
228
+ def _anova(data: pd.DataFrame, outcome_col: str, group_col: str) -> dict:
229
+ groups = [pd.to_numeric(g, errors="coerce").dropna()
230
+ for _, g in data.groupby(group_col)[outcome_col]]
231
+ stat, p = f_oneway(*groups)
232
+ n = sum(len(g) for g in groups)
233
+ return _uro(
234
+ test_name="anova", statistic=float(stat), p_value=float(p),
235
+ n_analyzed=n,
236
+ params={"k": len(groups)},
237
+ )
238
+
239
+
240
+ def _kruskal_wallis(data: pd.DataFrame, outcome_col: str, group_col: str) -> dict:
241
+ groups = [pd.to_numeric(g, errors="coerce").dropna()
242
+ for _, g in data.groupby(group_col)[outcome_col]]
243
+ stat, p = kruskal(*groups)
244
+ return _uro(
245
+ test_name="kruskal_wallis", statistic=float(stat), p_value=float(p),
246
+ n_analyzed=sum(len(g) for g in groups),
247
+ params={"k": len(groups)},
248
+ )
249
+
250
+
251
+ def _kaplan_meier_logrank(data: pd.DataFrame, time_col: str, event_col: str,
252
+ group_col: str) -> dict:
253
+ from lifelines import KaplanMeierFitter
254
+ from lifelines.statistics import logrank_test
255
+
256
+ if not event_col or event_col not in data.columns:
257
+ raise ValueError(
258
+ f"Cannot run kaplan_meier_logrank on '{time_col}': "
259
+ f"no linked event/censoring column found. "
260
+ f"A time-to-event variable requires both a duration column "
261
+ f"and an event indicator column."
262
+ )
263
+
264
+ data = data.copy()
265
+ data[time_col] = pd.to_numeric(data[time_col], errors="coerce")
266
+ data[event_col] = pd.to_numeric(data[event_col], errors="coerce")
267
+
268
+ groups = data[group_col].unique()
269
+ if len(groups) != 2:
270
+ return _uro(
271
+ test_name="kaplan_meier_logrank", n_analyzed=len(data),
272
+ params={"error": "Log-rank requires exactly 2 groups"},
273
+ )
274
+
275
+ g0 = data[data[group_col] == groups[0]].dropna(subset=[time_col, event_col])
276
+ g1 = data[data[group_col] == groups[1]].dropna(subset=[time_col, event_col])
277
+
278
+ result = logrank_test(
279
+ g0[time_col], g1[time_col],
280
+ event_observed_A=g0[event_col], event_observed_B=g1[event_col],
281
+ )
282
+ n = len(g0) + len(g1)
283
+ return _uro(
284
+ test_name="kaplan_meier_logrank",
285
+ statistic=float(result.test_statistic),
286
+ p_value=float(result.p_value), n_analyzed=n,
287
+ params={"n0": len(g0), "n1": len(g1),
288
+ "groups": [str(groups[0]), str(groups[1])]},
289
+ )
290
+
291
+
292
+ def _cox_ph(data: pd.DataFrame, time_col: str, event_col: str,
293
+ group_col: str, covariates: list[str]) -> dict:
294
+ """Legacy univariate Cox PH (for backward compatibility with test suite)."""
295
+ from lifelines import CoxPHFitter
296
+
297
+ if not event_col or event_col not in data.columns:
298
+ raise ValueError(
299
+ f"Cannot run cox_proportional_hazards on '{time_col}': "
300
+ f"no linked event/censoring column found. "
301
+ f"A time-to-event variable requires both a duration column "
302
+ f"and an event indicator column."
303
+ )
304
+
305
+ df = data[[time_col, event_col, group_col] + covariates].copy()
306
+ for c in df.columns:
307
+ df[c] = pd.to_numeric(df[c], errors="coerce")
308
+ df = df.dropna()
309
+
310
+ if df.empty:
311
+ return _uro(
312
+ test_name="cox_proportional_hazards", n_analyzed=0,
313
+ params={"error": "No valid data after dropping NAs"},
314
+ )
315
+
316
+ cph = CoxPHFitter()
317
+ cph.fit(df, duration_col=time_col, event_col=event_col)
318
+ hr = cph.hazard_ratios_.get(group_col, None)
319
+ ci = cph.confidence_intervals_.loc[group_col] if group_col in cph.confidence_intervals_.index else None
320
+ p = cph.summary.loc[group_col, "p"] if group_col in cph.summary.index else None
321
+
322
+ # Post-fit proportional-hazards diagnostic (Schoenfeld residuals)
323
+ diagnostics = None
324
+ try:
325
+ from lifelines.statistics import proportional_hazard_test
326
+ results = proportional_hazard_test(cph, df, time_transform="km")
327
+ # Build per-covariate summary
328
+ diag_rows = []
329
+ for cov_name in results.summary.index:
330
+ row = results.summary.loc[cov_name]
331
+ diag_rows.append({
332
+ "covariate": str(cov_name),
333
+ "test_statistic": float(row.get("test_statistic", 0)),
334
+ "p_value": float(row.get("p", 1.0)),
335
+ })
336
+ diagnostics = {"test": "Schoenfeld residuals", "covariates": diag_rows}
337
+ except Exception:
338
+ diagnostics = {"test": "Schoenfeld residuals", "error": "Could not compute"}
339
+
340
+ return _uro(
341
+ test_name="cox_proportional_hazards",
342
+ statistic=float(hr) if hr is not None else None,
343
+ p_value=float(p) if p is not None else None,
344
+ ci_lower=np.exp(float(ci.iloc[0])) if ci is not None else None,
345
+ ci_upper=np.exp(float(ci.iloc[1])) if ci is not None else None,
346
+ n_analyzed=len(df),
347
+ effect_size={
348
+ "metric": "Hazard Ratio",
349
+ "value": float(hr),
350
+ } if hr is not None else None,
351
+ params={"covariates": covariates, "assumption_diagnostics": diagnostics},
352
+ )
353
+
354
+
355
+ def _cox_ph_model(data: pd.DataFrame, time_col: str, event_col: str,
356
+ group_col: str, covariates: list[str],
357
+ var_types: dict[str, str] | None = None,
358
+ interaction_terms: list[list[str]] | None = None) -> dict:
359
+ """Multivariable Cox PH model returning per-covariate HRs, CIs, and overall model stats.
360
+ interaction_terms: list of [var_a, var_b] pairs for a*b expansion.
361
+ """
362
+ from lifelines import CoxPHFitter
363
+
364
+ interaction_terms = interaction_terms or []
365
+
366
+ if not event_col or event_col not in data.columns:
367
+ raise ValueError(
368
+ f"Cannot run cox_ph_model on '{time_col}': "
369
+ f"no linked event/censoring column found. "
370
+ f"A time-to-event variable requires both a duration column "
371
+ f"and an event indicator column."
372
+ )
373
+
374
+ # All columns needed
375
+ all_cols = [time_col, event_col, group_col] + covariates
376
+ for pair in interaction_terms:
377
+ for v in pair:
378
+ if v not in all_cols:
379
+ all_cols.append(v)
380
+ df = data[all_cols].copy()
381
+
382
+ var_types = var_types or {}
383
+ numeric_cols = [time_col, event_col]
384
+ for c in covariates:
385
+ dtype = var_types.get(c)
386
+ if dtype == "continuous" or (dtype is None and data[c].dtype != 'object'):
387
+ numeric_cols.append(c)
388
+ for c in numeric_cols:
389
+ if c in df.columns:
390
+ df[c] = pd.to_numeric(df[c], errors="coerce")
391
+
392
+ df = df.dropna()
393
+
394
+ if df.empty:
395
+ return _uro(
396
+ test_name="cox_ph_model", n_analyzed=0,
397
+ params={"error": "No valid data after dropping NAs"},
398
+ )
399
+
400
+ # Build formula: group_col + covariates + interaction terms (a*b)
401
+ formula_terms = [group_col] + covariates
402
+ for pair in interaction_terms:
403
+ formula_terms.append(f"{pair[0]} * {pair[1]}")
404
+ formula = " + ".join(formula_terms)
405
+
406
+ cph = CoxPHFitter()
407
+ cph.fit(df, duration_col=time_col, event_col=event_col, formula=formula)
408
+
409
+ # --- Primary treatment effect ---
410
+ hr = None
411
+ ci = None
412
+ p = None
413
+ for cov in cph.summary.index:
414
+ if cov.startswith(group_col) or cov == group_col:
415
+ hr = cph.hazard_ratios_.get(cov, None)
416
+ if cov in cph.confidence_intervals_.index:
417
+ ci = cph.confidence_intervals_.loc[cov]
418
+ p = cph.summary.loc[cov, "p"]
419
+ break
420
+
421
+ # --- Per-covariate results ---
422
+ covariate_results = []
423
+ for cov in [group_col] + covariates:
424
+ matching_idx = [idx for idx in cph.summary.index if idx.startswith(cov) or idx == cov]
425
+ if matching_idx:
426
+ idx = matching_idx[0]
427
+ row = cph.summary.loc[idx]
428
+ cov_hr = cph.hazard_ratios_.get(idx, None)
429
+ cov_ci = cph.confidence_intervals_.loc[idx] if idx in cph.confidence_intervals_.index else None
430
+
431
+ ref_level = None
432
+ tested_level = None
433
+ if idx != cov:
434
+ m2 = re.search(r'\[T\.(.+)\]$', idx)
435
+ if m2:
436
+ tested_level = m2.group(1)
437
+ uniq = sorted(str(v) for v in data[cov].dropna().unique()) if cov in data.columns else []
438
+ ref_candidates = [v for v in uniq if v != tested_level]
439
+ ref_level = ref_candidates[0] if ref_candidates else None
440
+
441
+ covariate_results.append({
442
+ "covariate": cov,
443
+ "hr": float(cov_hr) if cov_hr is not None else None,
444
+ "ci_lower": np.exp(float(cov_ci.iloc[0])) if cov_ci is not None else None,
445
+ "ci_upper": np.exp(float(cov_ci.iloc[1])) if cov_ci is not None else None,
446
+ "wald_p": float(row["p"]) if "p" in row else None,
447
+ "coef": float(row["coef"]) if "coef" in row else None,
448
+ "se": float(row["se"]) if "se" in row else None,
449
+ "z": float(row["z"]) if "z" in row else None,
450
+ "reference_level": ref_level,
451
+ "tested_level": tested_level,
452
+ })
453
+
454
+ # --- Interaction terms ---
455
+ for pair in interaction_terms:
456
+ int_name = f"{pair[0]}:{pair[1]}"
457
+ # lifelines puts categorical level suffixes: treatment_arm[T.B]:high_risk_fish[T.yes]
458
+ pattern = rf"^{re.escape(pair[0])}(?:\[.*?\])?:{re.escape(pair[1])}(?:\[.*?\])?$"
459
+ matching = [idx for idx in cph.summary.index if re.match(pattern, idx)]
460
+ for idx in matching:
461
+ row = cph.summary.loc[idx]
462
+ covariate_results.append({
463
+ "covariate": f"{int_name} (interaction)",
464
+ "hr": float(cph.hazard_ratios_.get(idx, 0)) if cph.hazard_ratios_.get(idx) else None,
465
+ "ci_lower": np.exp(float(cph.confidence_intervals_.loc[idx].iloc[0])) if idx in cph.confidence_intervals_.index else None,
466
+ "ci_upper": np.exp(float(cph.confidence_intervals_.loc[idx].iloc[1])) if idx in cph.confidence_intervals_.index else None,
467
+ "wald_p": float(row["p"]) if "p" in row else None,
468
+ "coef": float(row["coef"]) if "coef" in row else None,
469
+ "se": float(row["se"]) if "se" in row else None,
470
+ "z": float(row["z"]) if "z" in row else None,
471
+ "reference_level": None,
472
+ "tested_level": None,
473
+ })
474
+
475
+ # --- Overall model statistics ---
476
+ lr_test_p = None
477
+ lr_stat = None
478
+ try:
479
+ lr_test = cph.log_likelihood_ratio_test()
480
+ lr_test_p = float(lr_test.p_value)
481
+ lr_stat = float(lr_test.test_statistic)
482
+ except Exception:
483
+ logger.warning("LR test extraction failed", exc_info=True)
484
+ lr_test_p = None
485
+ lr_stat = None
486
+
487
+ concordance = float(cph.concordance_index_) if hasattr(cph, "concordance_index_") and cph.concordance_index_ else None
488
+
489
+ # --- Proportional hazards diagnostics (Schoenfeld residuals) ---
490
+ diagnostics = None
491
+ try:
492
+ from lifelines.statistics import proportional_hazard_test
493
+ results = proportional_hazard_test(cph, df, time_transform="km")
494
+ diag_rows = []
495
+ for cov_name in results.summary.index:
496
+ row = results.summary.loc[cov_name]
497
+ diag_rows.append({
498
+ "covariate": str(cov_name),
499
+ "test_statistic": float(row.get("test_statistic", 0)),
500
+ "p_value": float(row.get("p", 1.0)),
501
+ })
502
+ # Flag any violations
503
+ violations = [r for r in diag_rows if r["p_value"] < 0.05]
504
+ if violations:
505
+ diag_rows.append({
506
+ "warning": f"PH assumption violated for {len(violations)} covariate(s) (p<0.05). "
507
+ f"Consider time-varying coefficients or stratification for: "
508
+ f"{', '.join(v['covariate'] for v in violations)}"
509
+ })
510
+ diagnostics = {"test": "Schoenfeld residuals", "covariates": diag_rows}
511
+ except Exception as e:
512
+ diagnostics = {"test": "Schoenfeld residuals", "error": f"Could not compute: {e}"}
513
+
514
+ # --- Missing data handling ---
515
+ n_total = len(data)
516
+ n_analyzed = len(df)
517
+ n_excluded = n_total - n_analyzed
518
+
519
+ return _uro(
520
+ test_name="cox_ph_model",
521
+ statistic=float(lr_stat) if lr_stat is not None else None,
522
+ p_value=float(p) if p is not None else None,
523
+ ci_lower=np.exp(float(ci.iloc[0])) if ci is not None else None,
524
+ ci_upper=np.exp(float(ci.iloc[1])) if ci is not None else None,
525
+ n_analyzed=n_analyzed,
526
+ n_total=n_total,
527
+ effect_size={
528
+ "metric": "Hazard Ratio (primary treatment)",
529
+ "value": float(hr),
530
+ } if hr is not None else None,
531
+ params={
532
+ "covariates": covariates,
533
+ "formula": formula,
534
+ "per_covariate_results": covariate_results,
535
+ "lr_test_p_value": lr_test_p,
536
+ "concordance_index": concordance,
537
+ "assumption_diagnostics": diagnostics,
538
+ "missing_data": {"n_total": n_total, "n_analyzed": n_analyzed, "n_excluded": n_excluded},
539
+ },
540
+ )
@@ -0,0 +1,62 @@
1
+ """Multiple comparisons correction.
2
+
3
+ Applied automatically whenever more than one test is run in a single analysis
4
+ pass — never leave this to the researcher to remember.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from scipy.stats import false_discovery_control
11
+
12
+
13
+ def bonferroni(p_values: list[float], alpha: float = 0.05) -> list[float]:
14
+ """Bonferroni correction. Simplest, most conservative."""
15
+ n = len(p_values)
16
+ if n == 0:
17
+ return []
18
+ return [min(p * n, 1.0) for p in p_values]
19
+
20
+
21
+ def holm_bonferroni(p_values: list[float], alpha: float = 0.05) -> list[float]:
22
+ """Holm-Bonferroni step-down correction. Less conservative than Bonferroni."""
23
+ n = len(p_values)
24
+ if n == 0:
25
+ return []
26
+ sorted_idx = np.argsort(p_values)
27
+ sorted_p = np.array(p_values)[sorted_idx]
28
+ adjusted = np.zeros(n)
29
+ for i, p in enumerate(sorted_p):
30
+ adjusted[i] = min(p * (n - i), 1.0)
31
+ # Enforce monotonicity
32
+ for i in range(n - 2, -1, -1):
33
+ adjusted[i] = max(adjusted[i], adjusted[i + 1])
34
+ result = np.zeros(n)
35
+ result[sorted_idx] = adjusted
36
+ return result.tolist()
37
+
38
+
39
+ def benjamini_hochberg(p_values: list[float], alpha: float = 0.05) -> list[float]:
40
+ """Benjamini-Hochberg FDR correction. Controls false discovery rate."""
41
+ return false_discovery_control(p_values, method="bh").tolist()
42
+
43
+
44
+ def correct(p_values: list[float], method: str = "bonferroni") -> list[float]:
45
+ """Apply multiple comparisons correction.
46
+
47
+ Parameters
48
+ ----------
49
+ p_values : list[float]
50
+ method : str — "bonferroni" (default), "holm_bonferroni", or "benjamini_hochberg"
51
+
52
+ Returns
53
+ -------
54
+ list[float] — adjusted p-values
55
+ """
56
+ if method == "bonferroni":
57
+ return bonferroni(p_values)
58
+ elif method == "holm_bonferroni":
59
+ return holm_bonferroni(p_values)
60
+ elif method == "benjamini_hochberg":
61
+ return benjamini_hochberg(p_values)
62
+ return p_values
core/stats/post_hoc.py ADDED
@@ -0,0 +1,62 @@
1
+ """Post-hoc analysis tagging.
2
+
3
+ Any analysis NOT in the locked plan gets tagged EXPLORATORY_POST_HOC
4
+ everywhere it appears — in the data model, in any generated table, and in
5
+ the manuscript draft. This tag must be impossible to silently strip.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from dataclasses import dataclass
10
+ from typing import Optional, Any
11
+
12
+
13
+ EXPLORATORY_POST_HOC = "EXPLORATORY_POST_HOC"
14
+
15
+
16
+ @dataclass
17
+ class PostHocResult:
18
+ """A result wrapper that carries the EXPLORATORY_POST_HOC tag.
19
+
20
+ The tag is embedded in the data model, not just in presentation.
21
+ Any attempt to strip it must be explicit and intentional.
22
+ """
23
+ test_name: str
24
+ statistic: Optional[float]
25
+ p_value: Optional[float]
26
+ adjusted_p_value: Optional[float] = None
27
+ ci_lower: Optional[float] = None
28
+ ci_upper: Optional[float] = None
29
+ params: dict = None
30
+ tag: str = EXPLORATORY_POST_HOC # cannot be unset without creating a new object
31
+
32
+ def to_dict(self) -> dict:
33
+ return {
34
+ "test_name": self.test_name,
35
+ "statistic": self.statistic,
36
+ "p_value": self.p_value,
37
+ "adjusted_p_value": self.adjusted_p_value,
38
+ "ci_lower": self.ci_lower,
39
+ "ci_upper": self.ci_upper,
40
+ "params": self.params or {},
41
+ "tag": self.tag, # always present
42
+ }
43
+
44
+ def is_post_hoc(self) -> bool:
45
+ return True
46
+
47
+
48
+ def run_post_hoc(fn, *args, **kwargs) -> PostHocResult:
49
+ """Wrap any test function and tag the result as EXPLORATORY_POST_HOC.
50
+
51
+ The underlying function returns a dict with keys:
52
+ test_name, statistic, p_value, ci_lower, ci_upper, params
53
+ """
54
+ result = fn(*args, **kwargs)
55
+ return PostHocResult(
56
+ test_name=result.get("test_name", "unknown"),
57
+ statistic=result.get("statistic"),
58
+ p_value=result.get("p_value"),
59
+ ci_lower=result.get("ci_lower"),
60
+ ci_upper=result.get("ci_upper"),
61
+ params=result.get("params"),
62
+ )
@@ -0,0 +1,19 @@
1
+ """
2
+ Standalone audit-binder verification script.
3
+ Checks (per DECISIONS.md §7.2):
4
+ 1. Hash-chain integrity: H0 -> H1[-> Hn] -> Hexec unbroken.
5
+ 2. Protocol audit: no post-hoc parameter changes between lock and execution.
6
+ 3. Deterministic re-fit within a relative tolerance of ~1e-4.
7
+ """
8
+ import json, hashlib, sys
9
+
10
+ def check_chain(manifest):
11
+ print("Checking hash chain...")
12
+ print(f" H0 = {manifest['h0']}")
13
+ print(f" H1..Hn = {manifest['plan_chain']}")
14
+ print(f" Hexec = {manifest['hexec']}")
15
+
16
+ if __name__ == "__main__":
17
+ with open("manifest.json") as f:
18
+ m = json.load(f)
19
+ check_chain(m)