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,498 @@
|
|
|
1
|
+
"""Tests for Cox PH plan parsing and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import random
|
|
9
|
+
import shutil
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from core.cli.main import cmd_plan, cmd_analyze, cmd_lock
|
|
17
|
+
from core.database import DATA_ROOT, get_connection, init_db
|
|
18
|
+
from core.planning.study_plan import StudyPlan
|
|
19
|
+
from core.planning.lock import lock_plan
|
|
20
|
+
from core.masking.gate import unmask_study
|
|
21
|
+
from core.stats.inferential import _cox_ph_model
|
|
22
|
+
|
|
23
|
+
STUDY_ID = "test_cox_plan"
|
|
24
|
+
RAW = f"raw_{STUDY_ID}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _setup_db(extra_rows: list[tuple] | None = None):
|
|
28
|
+
study_path = DATA_ROOT / STUDY_ID
|
|
29
|
+
if study_path.exists():
|
|
30
|
+
shutil.rmtree(study_path)
|
|
31
|
+
study_path.mkdir(parents=True)
|
|
32
|
+
conn = get_connection(STUDY_ID)
|
|
33
|
+
init_db(conn)
|
|
34
|
+
conn.execute(
|
|
35
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type) VALUES (?, ?, ?, ?, ?)",
|
|
36
|
+
(STUDY_ID, "Cox PH Plan Test", "2025-01-01", str(study_path), "cohort"),
|
|
37
|
+
)
|
|
38
|
+
conn.execute(
|
|
39
|
+
f"CREATE TABLE IF NOT EXISTS {RAW} "
|
|
40
|
+
f"(row_id INTEGER PRIMARY KEY, treatment_arm TEXT, pfs_days TEXT, pfs_event TEXT, age TEXT)"
|
|
41
|
+
)
|
|
42
|
+
conn.executemany(
|
|
43
|
+
f"INSERT INTO {RAW} (treatment_arm, pfs_days, pfs_event, age) VALUES (?, ?, ?, ?)",
|
|
44
|
+
extra_rows or [
|
|
45
|
+
("A", "100", "1", "65"),
|
|
46
|
+
("A", "200", "0", "70"),
|
|
47
|
+
("B", "150", "1", "55"),
|
|
48
|
+
("B", "300", "0", "60"),
|
|
49
|
+
],
|
|
50
|
+
)
|
|
51
|
+
conn.executemany(
|
|
52
|
+
"INSERT OR REPLACE INTO variables (id, study_id, column_name, role, data_type) VALUES (?, ?, ?, ?, ?)",
|
|
53
|
+
[
|
|
54
|
+
(1, STUDY_ID, "treatment_arm", "baseline", "categorical"),
|
|
55
|
+
(2, STUDY_ID, "pfs_days", "outcome", "time_to_event"),
|
|
56
|
+
(3, STUDY_ID, "pfs_event", "outcome", "categorical"),
|
|
57
|
+
(4, STUDY_ID, "age", "baseline", "continuous"),
|
|
58
|
+
],
|
|
59
|
+
)
|
|
60
|
+
conn.commit()
|
|
61
|
+
conn.close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _plan_args(cox_ph_models=None, **kwargs):
|
|
65
|
+
values = dict(
|
|
66
|
+
study_id=STUDY_ID,
|
|
67
|
+
outcome_var_ids="3",
|
|
68
|
+
tests=[],
|
|
69
|
+
covariates="",
|
|
70
|
+
study_type="cohort",
|
|
71
|
+
comparison="PFS by treatment arm",
|
|
72
|
+
overrides=[],
|
|
73
|
+
matching_criteria="",
|
|
74
|
+
)
|
|
75
|
+
values["cox_ph_models"] = cox_ph_models or []
|
|
76
|
+
values.update(kwargs)
|
|
77
|
+
return argparse.Namespace(**values)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- valid ID/name resolution ---
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_cox_ph_accepts_valid_column_names(capsys):
|
|
84
|
+
_setup_db()
|
|
85
|
+
cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm:age:test rationale"]))
|
|
86
|
+
prov = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
|
|
87
|
+
assert prov.exists()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_cox_ph_accepts_4_part_format(capsys):
|
|
91
|
+
_setup_db()
|
|
92
|
+
cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
|
|
93
|
+
prov = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
|
|
94
|
+
assert prov.exists()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_cox_ph_accepts_5_part_format_no_rationale(capsys):
|
|
98
|
+
_setup_db()
|
|
99
|
+
cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm:age"]))
|
|
100
|
+
prov = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
|
|
101
|
+
assert prov.exists()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- invalid column name rejected at plan ---
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_cox_ph_rejects_unknown_column(capsys):
|
|
108
|
+
_setup_db()
|
|
109
|
+
with pytest.raises(SystemExit):
|
|
110
|
+
cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:nonexistent_col:treatment_arm"]))
|
|
111
|
+
err = capsys.readouterr().err
|
|
112
|
+
assert "nonexistent_col" in err
|
|
113
|
+
assert "not found" in err
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_cox_ph_rejects_unknown_covariate(capsys):
|
|
117
|
+
_setup_db()
|
|
118
|
+
with pytest.raises(SystemExit):
|
|
119
|
+
cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm:fake_cov"]))
|
|
120
|
+
err = capsys.readouterr().err
|
|
121
|
+
assert "fake_cov" in err
|
|
122
|
+
assert "not found" in err
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- non-binary event_col rejected at plan ---
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_cox_ph_rejects_non_binary_event(capsys):
|
|
129
|
+
_setup_db(extra_rows=[
|
|
130
|
+
("A", "100", "maybe", "65"),
|
|
131
|
+
("B", "150", "yes", "55"),
|
|
132
|
+
])
|
|
133
|
+
with pytest.raises(SystemExit):
|
|
134
|
+
cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
|
|
135
|
+
err = capsys.readouterr().err
|
|
136
|
+
assert "binary" in err.lower()
|
|
137
|
+
assert "maybe" in err or "yes" in err
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_cox_ph_accepts_binary_event(capsys):
|
|
141
|
+
_setup_db(extra_rows=[
|
|
142
|
+
("A", "100", "1", "65"),
|
|
143
|
+
("B", "150", "0", "55"),
|
|
144
|
+
])
|
|
145
|
+
cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
|
|
146
|
+
prov = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
|
|
147
|
+
assert prov.exists()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --- negative survival_time rejected at plan ---
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_cox_ph_rejects_negative_survival_time(capsys):
|
|
154
|
+
_setup_db(extra_rows=[
|
|
155
|
+
("A", "-5", "1", "65"),
|
|
156
|
+
("B", "150", "0", "55"),
|
|
157
|
+
])
|
|
158
|
+
with pytest.raises(SystemExit):
|
|
159
|
+
cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
|
|
160
|
+
err = capsys.readouterr().err
|
|
161
|
+
assert "negative" in err.lower()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_cox_ph_rejects_non_numeric_survival_time(capsys):
|
|
165
|
+
_setup_db(extra_rows=[
|
|
166
|
+
("A", "abc", "1", "65"),
|
|
167
|
+
("B", "150", "0", "55"),
|
|
168
|
+
])
|
|
169
|
+
with pytest.raises(SystemExit):
|
|
170
|
+
cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
|
|
171
|
+
err = capsys.readouterr().err
|
|
172
|
+
assert "not numeric" in err.lower()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --- short-form 4-part format accepted ---
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_cox_ph_4_part_no_covariates_no_rationale(capsys):
|
|
179
|
+
_setup_db()
|
|
180
|
+
cmd_plan(_plan_args(cox_ph_models=["m1:pfs_days:pfs_event:treatment_arm"]))
|
|
181
|
+
prov = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
|
|
182
|
+
assert prov.exists()
|
|
183
|
+
import json
|
|
184
|
+
plan = json.loads(prov.read_text())
|
|
185
|
+
m = plan["cox_ph_models"][0]
|
|
186
|
+
assert m["model_name"] == "m1"
|
|
187
|
+
assert m["survival_time_col"] == "pfs_days"
|
|
188
|
+
assert m["event_col"] == "pfs_event"
|
|
189
|
+
assert m["primary_treatment_col"] == "treatment_arm"
|
|
190
|
+
assert m["covariate_cols"] == []
|
|
191
|
+
assert m["rationale"] == ""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --- regression: locked-plan reload with CoxPHModel dataclass ---
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _seed_large_dataset(study_id: str, n: int = 25):
|
|
198
|
+
"""Seed N rows into raw table so EPV check passes."""
|
|
199
|
+
import random
|
|
200
|
+
rng = random.Random(42)
|
|
201
|
+
conn = get_connection(study_id)
|
|
202
|
+
raw = f"raw_{study_id}"
|
|
203
|
+
rows = []
|
|
204
|
+
for i in range(n):
|
|
205
|
+
arm = rng.choice(["A", "B"])
|
|
206
|
+
pfs = int(max(30, rng.expovariate(1/200)))
|
|
207
|
+
evt = rng.choices(["0", "1"], weights=[30, 70])[0]
|
|
208
|
+
rows.append((arm, str(pfs), evt))
|
|
209
|
+
conn.executemany(
|
|
210
|
+
f"INSERT INTO {raw} (treatment_arm, pfs_days, pfs_event) "
|
|
211
|
+
f"VALUES (?, ?, ?)",
|
|
212
|
+
rows,
|
|
213
|
+
)
|
|
214
|
+
conn.commit()
|
|
215
|
+
conn.close()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_cox_ph_regression_locked_plan_reload():
|
|
219
|
+
"""Plan → lock → reload (StudyPlan.from_dict) → unmask → analyze.
|
|
220
|
+
|
|
221
|
+
Regression: after loading a locked plan from disk, cox_ph_models entries
|
|
222
|
+
are CoxPHModel dataclass instances. cmd_analyze must handle them via
|
|
223
|
+
_model_field helper, not .get().
|
|
224
|
+
"""
|
|
225
|
+
sid = "test_cox_reload_regression"
|
|
226
|
+
study_path = DATA_ROOT / sid
|
|
227
|
+
if study_path.exists():
|
|
228
|
+
shutil.rmtree(study_path)
|
|
229
|
+
study_path.mkdir(parents=True)
|
|
230
|
+
conn = get_connection(sid)
|
|
231
|
+
init_db(conn)
|
|
232
|
+
conn.execute(
|
|
233
|
+
"INSERT INTO studies (id, name, created_at, data_dir, study_type) VALUES (?, ?, ?, ?, ?)",
|
|
234
|
+
(sid, "Cox PH Reload Regression", "2025-01-01", str(study_path), "cohort"),
|
|
235
|
+
)
|
|
236
|
+
raw = f"raw_{sid}"
|
|
237
|
+
conn.execute(f"CREATE TABLE {raw} "
|
|
238
|
+
f"(row_id INTEGER PRIMARY KEY, treatment_arm TEXT, pfs_days TEXT, "
|
|
239
|
+
f"pfs_event TEXT, age TEXT, high_risk_fish TEXT, prior_lines TEXT)")
|
|
240
|
+
conn.executemany(
|
|
241
|
+
"INSERT OR REPLACE INTO variables (id, study_id, column_name, role, data_type) VALUES (?, ?, ?, ?, ?)",
|
|
242
|
+
[
|
|
243
|
+
(1, sid, "treatment_arm", "baseline", "categorical"),
|
|
244
|
+
(2, sid, "pfs_days", "outcome", "time_to_event"),
|
|
245
|
+
(3, sid, "pfs_event", "outcome", "categorical"),
|
|
246
|
+
],
|
|
247
|
+
)
|
|
248
|
+
conn.commit()
|
|
249
|
+
conn.close()
|
|
250
|
+
|
|
251
|
+
_seed_large_dataset(sid, n=25)
|
|
252
|
+
|
|
253
|
+
# Seal outcomes so the masked shadow table exists at plan time
|
|
254
|
+
from core.masking.gate import seal_outcomes
|
|
255
|
+
seal_outcomes(sid)
|
|
256
|
+
|
|
257
|
+
# Plan with a Cox PH model (treatment_arm only, no extra covariates — avoids singular matrix with sparse levels)
|
|
258
|
+
plan_ns = argparse.Namespace(
|
|
259
|
+
study_id=sid,
|
|
260
|
+
outcome_var_ids="3",
|
|
261
|
+
tests=[],
|
|
262
|
+
covariates="",
|
|
263
|
+
study_type="cohort",
|
|
264
|
+
comparison="PFS by treatment arm",
|
|
265
|
+
overrides=[],
|
|
266
|
+
matching_criteria="",
|
|
267
|
+
cox_ph_models=["pfs_simple:pfs_days:pfs_event:treatment_arm"],
|
|
268
|
+
)
|
|
269
|
+
cmd_plan(plan_ns)
|
|
270
|
+
|
|
271
|
+
lock_ns = argparse.Namespace(study_id=sid, allow_duplicate_ids=False)
|
|
272
|
+
cmd_lock(lock_ns)
|
|
273
|
+
|
|
274
|
+
# Now reload the locked plan from disk as a fresh StudyPlan
|
|
275
|
+
locked_path = sorted(study_path.glob("study_plan.v*.locked.json"))[-1]
|
|
276
|
+
plan_data = json.loads(locked_path.read_text())
|
|
277
|
+
reloaded_plan = StudyPlan.from_dict(plan_data)
|
|
278
|
+
# Verify cox_ph_models entries are CoxPHModel instances
|
|
279
|
+
for m in reloaded_plan.cox_ph_models:
|
|
280
|
+
assert not isinstance(m, dict), "cox_ph_models must be CoxPHModel after from_dict, not dict"
|
|
281
|
+
from core.planning.study_plan import CoxPHModel
|
|
282
|
+
assert isinstance(m, CoxPHModel)
|
|
283
|
+
|
|
284
|
+
# Manually lock the reloaded plan to mimic what cmd_analyze does (load_plan)
|
|
285
|
+
path = lock_plan(sid, reloaded_plan)
|
|
286
|
+
assert path.exists()
|
|
287
|
+
|
|
288
|
+
# Unmask
|
|
289
|
+
unmask_study(sid)
|
|
290
|
+
|
|
291
|
+
# Analyze with --force to bypass EPV warning (EPV < 10 with small N)
|
|
292
|
+
analyze_ns = argparse.Namespace(
|
|
293
|
+
study_id=sid,
|
|
294
|
+
force=True,
|
|
295
|
+
post_hoc=False,
|
|
296
|
+
rerun=False,
|
|
297
|
+
)
|
|
298
|
+
cmd_analyze(analyze_ns)
|
|
299
|
+
# If we get here without AttributeError, the regression is fixed.
|
|
300
|
+
# Check results in DB
|
|
301
|
+
conn = get_connection(sid)
|
|
302
|
+
cur = conn.execute(
|
|
303
|
+
"SELECT id, test_name, statistic, p_value FROM analysis_results "
|
|
304
|
+
"WHERE study_id=? AND test_name=? ORDER BY id DESC LIMIT 1",
|
|
305
|
+
(sid, "cox_ph_model"),
|
|
306
|
+
)
|
|
307
|
+
row = cur.fetchone()
|
|
308
|
+
conn.close()
|
|
309
|
+
assert row is not None, "No Cox PH result found in DB"
|
|
310
|
+
assert row["statistic"] is not None, "Cox PH HR should not be None"
|
|
311
|
+
assert row["p_value"] is not None, "Cox PH p-value should not be None"
|
|
312
|
+
assert row["statistic"] > 0, "HR must be positive"
|
|
313
|
+
|
|
314
|
+
shutil.rmtree(study_path)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ── Interaction term tests with adequate N ──────────────────────────────
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _synthetic_interaction_data(n: int, seed: int,
|
|
321
|
+
true_interaction: bool = True) -> pd.DataFrame:
|
|
322
|
+
"""Generate synthetic survival data with a known treatment × subgroup interaction.
|
|
323
|
+
|
|
324
|
+
Parameters
|
|
325
|
+
----------
|
|
326
|
+
n : int
|
|
327
|
+
Number of patients.
|
|
328
|
+
seed : int
|
|
329
|
+
Random seed for reproducibility.
|
|
330
|
+
true_interaction : bool
|
|
331
|
+
If True, treatment effect differs by subgroup (HR_subgroup_B = 1.0).
|
|
332
|
+
If False, treatment effect is same across subgroups (no interaction).
|
|
333
|
+
|
|
334
|
+
Returns
|
|
335
|
+
-------
|
|
336
|
+
pd.DataFrame with columns:
|
|
337
|
+
pfs_days, pfs_event, treatment_arm, high_risk_fish, age
|
|
338
|
+
"""
|
|
339
|
+
rng = random.Random(seed)
|
|
340
|
+
np_rng = np.random.RandomState(seed)
|
|
341
|
+
|
|
342
|
+
data = []
|
|
343
|
+
for _ in range(n):
|
|
344
|
+
arm = rng.choice(["A", "B"])
|
|
345
|
+
fish = rng.choices(["yes", "no"], weights=[0.4, 0.6])[0]
|
|
346
|
+
age = rng.randint(40, 85)
|
|
347
|
+
|
|
348
|
+
# Baseline hazard: exp(-t/200)
|
|
349
|
+
# Base HR for treatment (arm B vs A) — always present
|
|
350
|
+
if arm == "B":
|
|
351
|
+
base_hr = 0.5 # treatment halves hazard
|
|
352
|
+
else:
|
|
353
|
+
base_hr = 1.0
|
|
354
|
+
|
|
355
|
+
# Interaction: in high-risk subgroup, treatment effect differs
|
|
356
|
+
if fish == "yes" and arm == "B":
|
|
357
|
+
if true_interaction:
|
|
358
|
+
fish_hr = 2.0 # treatment loses effectiveness in high-risk
|
|
359
|
+
else:
|
|
360
|
+
fish_hr = 1.0 # no interaction, same effect
|
|
361
|
+
else:
|
|
362
|
+
fish_hr = 1.0
|
|
363
|
+
|
|
364
|
+
hr = base_hr * fish_hr
|
|
365
|
+
|
|
366
|
+
# Generate survival time from exponential with rate=hr/200
|
|
367
|
+
t = np_rng.exponential(200.0 / hr)
|
|
368
|
+
pfs_days = max(1, int(t))
|
|
369
|
+
|
|
370
|
+
# Censor at 500 days
|
|
371
|
+
event = 1 if pfs_days < 500 else 0
|
|
372
|
+
pfs_days = min(pfs_days, 500)
|
|
373
|
+
|
|
374
|
+
data.append((pfs_days, event, arm, fish, age))
|
|
375
|
+
|
|
376
|
+
return pd.DataFrame(data, columns=["pfs_days", "pfs_event",
|
|
377
|
+
"treatment_arm", "high_risk_fish", "age"])
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class TestInteractionTerms:
|
|
381
|
+
"""Verify Cox PH interaction term detection with adequate power."""
|
|
382
|
+
|
|
383
|
+
def test_true_interaction_detected(self):
|
|
384
|
+
"""Model with interaction should recover significant interaction term (N=400)."""
|
|
385
|
+
df = _synthetic_interaction_data(400, seed=42, true_interaction=True)
|
|
386
|
+
result = _cox_ph_model(
|
|
387
|
+
df,
|
|
388
|
+
time_col="pfs_days",
|
|
389
|
+
event_col="pfs_event",
|
|
390
|
+
group_col="treatment_arm",
|
|
391
|
+
covariates=["high_risk_fish", "age"],
|
|
392
|
+
interaction_terms=[["treatment_arm", "high_risk_fish"]],
|
|
393
|
+
)
|
|
394
|
+
cov_results = result.get("params", {}).get("per_covariate_results", [])
|
|
395
|
+
int_rows = [c for c in cov_results if "(interaction)" in c.get("covariate", "")]
|
|
396
|
+
assert len(int_rows) > 0, "No interaction term found in results"
|
|
397
|
+
int_row = int_rows[0]
|
|
398
|
+
assert int_row["hr"] is not None, "Interaction HR should not be None"
|
|
399
|
+
assert int_row["wald_p"] is not None, "Interaction p-value should not be None"
|
|
400
|
+
# With true interaction built in and N=400, p should be significant
|
|
401
|
+
assert int_row["wald_p"] < 0.05, (
|
|
402
|
+
f"True interaction should be detected (p={int_row['wald_p']:.4f})"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def test_no_interaction_not_detected(self):
|
|
406
|
+
"""Model with no interaction should have non-significant interaction term."""
|
|
407
|
+
df = _synthetic_interaction_data(400, seed=43, true_interaction=False)
|
|
408
|
+
result = _cox_ph_model(
|
|
409
|
+
df,
|
|
410
|
+
time_col="pfs_days",
|
|
411
|
+
event_col="pfs_event",
|
|
412
|
+
group_col="treatment_arm",
|
|
413
|
+
covariates=["high_risk_fish", "age"],
|
|
414
|
+
interaction_terms=[["treatment_arm", "high_risk_fish"]],
|
|
415
|
+
)
|
|
416
|
+
cov_results = result.get("params", {}).get("per_covariate_results", [])
|
|
417
|
+
int_rows = [c for c in cov_results if "(interaction)" in c.get("covariate", "")]
|
|
418
|
+
assert len(int_rows) > 0, "No interaction term found in results"
|
|
419
|
+
int_row = int_rows[0]
|
|
420
|
+
# With no interaction, CI should cross 1
|
|
421
|
+
ci_lo = int_row.get("ci_lower")
|
|
422
|
+
ci_hi = int_row.get("ci_upper")
|
|
423
|
+
if ci_lo is not None and ci_hi is not None:
|
|
424
|
+
assert ci_lo <= 1 <= ci_hi, (
|
|
425
|
+
f"No-interaction CI ({ci_lo:.3f}, {ci_hi:.3f}) should cross 1"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def test_epv_counts_interaction_as_predictor(self):
|
|
429
|
+
"""EPV check should count interaction terms as additional predictors."""
|
|
430
|
+
from core.planning.test_selector import check_cox_ph_model_assumptions
|
|
431
|
+
|
|
432
|
+
# Build a small dataset so EPV < 10 is triggered
|
|
433
|
+
study_id = "test_epv_interaction"
|
|
434
|
+
study_path = DATA_ROOT / study_id
|
|
435
|
+
if study_path.exists():
|
|
436
|
+
shutil.rmtree(study_path)
|
|
437
|
+
study_path.mkdir(parents=True)
|
|
438
|
+
conn = get_connection(study_id)
|
|
439
|
+
init_db(conn)
|
|
440
|
+
conn.execute(
|
|
441
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type) VALUES (?, ?, ?, ?, ?)",
|
|
442
|
+
(study_id, "EPV Interaction Test", "2025-01-01", str(study_path), "cohort"),
|
|
443
|
+
)
|
|
444
|
+
conn.execute(
|
|
445
|
+
f"CREATE TABLE IF NOT EXISTS raw_masked_{study_id} AS "
|
|
446
|
+
"SELECT * FROM (SELECT 'A' AS treatment_arm, 'yes' AS high_risk_fish, "
|
|
447
|
+
"'1' AS pfs_event, 100 AS pfs_days, 50 AS age) WHERE 1=0"
|
|
448
|
+
)
|
|
449
|
+
# Insert 5 events across 4 patients → EPV=5/3≈1.7 with interaction
|
|
450
|
+
rows = [
|
|
451
|
+
("A", "yes", "1", 100, 50),
|
|
452
|
+
("A", "no", "0", 200, 55),
|
|
453
|
+
("B", "yes", "1", 80, 60),
|
|
454
|
+
("B", "no", "1", 150, 45),
|
|
455
|
+
]
|
|
456
|
+
for r in rows:
|
|
457
|
+
conn.execute(
|
|
458
|
+
f"INSERT INTO raw_masked_{study_id} "
|
|
459
|
+
f"(treatment_arm, high_risk_fish, pfs_event, pfs_days, age) "
|
|
460
|
+
f"VALUES (?, ?, ?, ?, ?)", r
|
|
461
|
+
)
|
|
462
|
+
conn.execute(
|
|
463
|
+
"INSERT OR REPLACE INTO variables (id, study_id, column_name, role, data_type) "
|
|
464
|
+
"VALUES (1, ?, 'treatment_arm', 'baseline', 'categorical')",
|
|
465
|
+
(study_id,),
|
|
466
|
+
)
|
|
467
|
+
conn.execute(
|
|
468
|
+
"INSERT OR REPLACE INTO variables (id, study_id, column_name, role, data_type) "
|
|
469
|
+
"VALUES (2, ?, 'pfs_days', 'outcome', 'time_to_event')",
|
|
470
|
+
(study_id,),
|
|
471
|
+
)
|
|
472
|
+
conn.execute(
|
|
473
|
+
"INSERT OR REPLACE INTO variables (id, study_id, column_name, role, data_type) "
|
|
474
|
+
"VALUES (3, ?, 'pfs_event', 'outcome', 'categorical')",
|
|
475
|
+
(study_id,),
|
|
476
|
+
)
|
|
477
|
+
conn.commit()
|
|
478
|
+
conn.close()
|
|
479
|
+
|
|
480
|
+
model = {
|
|
481
|
+
"model_name": "test_interaction",
|
|
482
|
+
"survival_time_col": "pfs_days",
|
|
483
|
+
"event_col": "pfs_event",
|
|
484
|
+
"primary_treatment_col": "treatment_arm",
|
|
485
|
+
"covariate_cols": ["high_risk_fish"],
|
|
486
|
+
"interaction_terms": [["treatment_arm", "high_risk_fish"]],
|
|
487
|
+
}
|
|
488
|
+
warnings = check_cox_ph_model_assumptions(study_id, [model])
|
|
489
|
+
warning_text = " ".join(warnings)
|
|
490
|
+
# The warning should mention 3 predictors (treatment + 1 covariate + 1 interaction)
|
|
491
|
+
assert "3 predictor" in warning_text, (
|
|
492
|
+
f"EPV warning should count 3 predictors: {warning_text}"
|
|
493
|
+
)
|
|
494
|
+
assert "EPV=" in warning_text, (
|
|
495
|
+
"EPV warning should fire when interaction adds a predictor"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
shutil.rmtree(study_path)
|