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.
- app/backend/_bootstrap.py +15 -0
- app/backend/exports/verification_script.py +19 -0
- app/backend/main.py +59 -0
- app/backend/routers/__init__.py +1 -0
- app/backend/routers/execution.py +478 -0
- app/backend/routers/ingestion.py +233 -0
- app/backend/routers/planning.py +265 -0
- app/backend/routers/reporting.py +531 -0
- app/backend/state.py +44 -0
- core/__init__.py +0 -0
- core/cli/__init__.py +0 -0
- core/cli/main.py +1705 -0
- core/database.py +62 -0
- core/ingestion/__init__.py +0 -0
- core/ingestion/csv_loader.py +191 -0
- core/ingestion/variable_classifier.py +171 -0
- core/masking/__init__.py +0 -0
- core/masking/gate.py +128 -0
- core/models.py +138 -0
- core/planning/__init__.py +0 -0
- core/planning/diagnostics.py +89 -0
- core/planning/lock.py +232 -0
- core/planning/study_plan.py +73 -0
- core/planning/test_selector.py +518 -0
- core/provenance/__init__.py +0 -0
- core/provenance/hashing.py +38 -0
- core/provenance/tracker.py +105 -0
- core/reporting/__init__.py +62 -0
- core/reporting/appendix.py +58 -0
- core/reporting/bundle.py +378 -0
- core/reporting/excel_export.py +683 -0
- core/reporting/flowchart/__init__.py +20 -0
- core/reporting/flowchart/flowchart.py +511 -0
- core/reporting/forensics.py +592 -0
- core/reporting/forest_plot.py +614 -0
- core/reporting/lineage.py +562 -0
- core/reporting/manuscript_draft.py +726 -0
- core/reporting/plots.py +568 -0
- core/reporting/strobe_checklist.py +460 -0
- core/stats/__init__.py +0 -0
- core/stats/descriptive.py +104 -0
- core/stats/inferential.py +540 -0
- core/stats/multiple_comparisons.py +62 -0
- core/stats/post_hoc.py +62 -0
- exports/verification_script.py +19 -0
- research_tool_cli-0.1.0.dist-info/METADATA +16 -0
- research_tool_cli-0.1.0.dist-info/RECORD +93 -0
- research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
- research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
- tests/__init__.py +0 -0
- tests/conftest.py +45 -0
- tests/test_amendments.py +296 -0
- tests/test_analyze_batch_robustness.py +162 -0
- tests/test_app_statistical_reporting.py +383 -0
- tests/test_benchmark_21.py +556 -0
- tests/test_bundle.py +277 -0
- tests/test_cox_ph_plan.py +498 -0
- tests/test_csv_loader.py +368 -0
- tests/test_end_to_end.py +302 -0
- tests/test_excel_export.py +164 -0
- tests/test_flowchart.py +244 -0
- tests/test_forensics.py +305 -0
- tests/test_forest_plot.py +374 -0
- tests/test_from_json.py +176 -0
- tests/test_latest_plan_version.py +164 -0
- tests/test_lineage.py +329 -0
- tests/test_lock_immutability.py +133 -0
- tests/test_m1_fk_violation.py +85 -0
- tests/test_m2_data_hash_consistency.py +40 -0
- tests/test_m3_correction_timing.py +59 -0
- tests/test_m4_dedup_field.py +59 -0
- tests/test_m5_excel_hash_scope.py +45 -0
- tests/test_m6_fisher_exact_naming.py +44 -0
- tests/test_m7_duplicate_study_plan.py +55 -0
- tests/test_m8_filter_superseded.py +58 -0
- tests/test_m9_table1_groupby.py +56 -0
- tests/test_manuscript_draft.py +289 -0
- tests/test_masking_gate.py +196 -0
- tests/test_masking_migration.py +111 -0
- tests/test_multiple_comparisons.py +56 -0
- tests/test_plan_validation.py +289 -0
- tests/test_plots.py +394 -0
- tests/test_post_hoc_tagging.py +49 -0
- tests/test_posthoc_analyze.py +203 -0
- tests/test_provenance_tracker.py +110 -0
- tests/test_roadmap_features.py +148 -0
- tests/test_stats_descriptive.py +57 -0
- tests/test_stats_inferential.py +380 -0
- tests/test_strobe_checklist.py +172 -0
- tests/test_test_selector.py +350 -0
- 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)
|