evalrx 0.1.2__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 (339) hide show
  1. evalrx/__init__.py +139 -0
  2. evalrx/agent_assets/__init__.py +2 -0
  3. evalrx/agent_assets/skills/README.md +28 -0
  4. evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
  5. evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
  6. evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
  7. evalrx/agent_assets/skills/nature-figure/README.md +412 -0
  8. evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
  9. evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
  10. evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
  11. evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
  12. evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
  13. evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
  14. evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
  15. evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
  16. evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
  17. evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
  18. evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
  19. evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
  20. evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
  21. evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
  22. evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
  23. evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
  24. evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
  25. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
  26. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
  27. evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
  28. evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
  29. evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
  30. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
  31. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
  32. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
  33. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
  34. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
  35. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
  36. evalrx/agent_assets/skills.py +27 -0
  37. evalrx/agent_runtime/__init__.py +78 -0
  38. evalrx/agent_runtime/_docker_runner.py +89 -0
  39. evalrx/agent_runtime/cli_runtime.py +103 -0
  40. evalrx/agent_runtime/cli_transcript.py +138 -0
  41. evalrx/agent_runtime/cli_types.py +68 -0
  42. evalrx/agent_runtime/codegen/__init__.py +5 -0
  43. evalrx/agent_runtime/codegen/runner.py +94 -0
  44. evalrx/agent_runtime/experiment_harness.py +117 -0
  45. evalrx/agent_runtime/factory.py +102 -0
  46. evalrx/agent_runtime/json_shape.py +44 -0
  47. evalrx/agent_runtime/judges/__init__.py +28 -0
  48. evalrx/agent_runtime/judges/agy.py +179 -0
  49. evalrx/agent_runtime/judges/autodetect.py +135 -0
  50. evalrx/agent_runtime/judges/claude.py +159 -0
  51. evalrx/agent_runtime/judges/codex.py +120 -0
  52. evalrx/agent_runtime/providers/__init__.py +21 -0
  53. evalrx/agent_runtime/providers/antigravity.py +31 -0
  54. evalrx/agent_runtime/providers/base.py +145 -0
  55. evalrx/agent_runtime/providers/claude_code.py +49 -0
  56. evalrx/agent_runtime/providers/codex.py +37 -0
  57. evalrx/agent_runtime/providers/gemini_cli.py +26 -0
  58. evalrx/agent_runtime/providers/kimi_cli.py +27 -0
  59. evalrx/agent_runtime/providers/opencode.py +27 -0
  60. evalrx/agent_runtime/providers/registry.py +58 -0
  61. evalrx/agent_runtime/sandbox.py +517 -0
  62. evalrx/agent_runtime/skill_audit.py +143 -0
  63. evalrx/agent_runtime/skills/__init__.py +19 -0
  64. evalrx/agent_runtime/skills/installer.py +68 -0
  65. evalrx/agent_runtime/skills/prompt_policy.py +86 -0
  66. evalrx/agent_runtime/skills/resolver.py +19 -0
  67. evalrx/analysis/__init__.py +132 -0
  68. evalrx/analysis/adjudicate.py +154 -0
  69. evalrx/analysis/analysis_module.py +361 -0
  70. evalrx/analysis/api.py +171 -0
  71. evalrx/analysis/case_studio.py +651 -0
  72. evalrx/analysis/cli.py +114 -0
  73. evalrx/analysis/dashboard.py +350 -0
  74. evalrx/analysis/eval_case_matrix.py +118 -0
  75. evalrx/analysis/eval_viz_theme.py +833 -0
  76. evalrx/analysis/explore_run.py +333 -0
  77. evalrx/analysis/explorer.py +1276 -0
  78. evalrx/analysis/failure_modes.py +607 -0
  79. evalrx/analysis/fused_pipeline.py +489 -0
  80. evalrx/analysis/holdout.py +300 -0
  81. evalrx/analysis/hypothesis_agent.py +230 -0
  82. evalrx/analysis/narration.py +177 -0
  83. evalrx/analysis/operationalize.py +442 -0
  84. evalrx/analysis/plain_language.py +42 -0
  85. evalrx/analysis/planner.py +283 -0
  86. evalrx/analysis/probe_search.py +203 -0
  87. evalrx/analysis/profile.py +268 -0
  88. evalrx/analysis/prompts/__init__.py +0 -0
  89. evalrx/analysis/prompts/explorer.py +417 -0
  90. evalrx/analysis/prompts/failure_modes.py +33 -0
  91. evalrx/analysis/prompts/holdout.py +27 -0
  92. evalrx/analysis/prompts/hypothesis_agent.py +78 -0
  93. evalrx/analysis/prompts/run_codebase.py +47 -0
  94. evalrx/analysis/prompts/stats_agent.py +72 -0
  95. evalrx/analysis/prompts/stats_tool_generator.py +43 -0
  96. evalrx/analysis/result_marker.py +47 -0
  97. evalrx/analysis/run_codebase.py +242 -0
  98. evalrx/analysis/run_view.py +205 -0
  99. evalrx/analysis/stage_views.py +93 -0
  100. evalrx/analysis/stats_agent.py +944 -0
  101. evalrx/analysis/stats_tool_agent.py +261 -0
  102. evalrx/analysis/stats_tool_generator.py +415 -0
  103. evalrx/analysis/stats_tools.py +1153 -0
  104. evalrx/analysis/trajectory_records.py +193 -0
  105. evalrx/analysis/workbench.py +431 -0
  106. evalrx/analyzers/__init__.py +42 -0
  107. evalrx/analyzers/agent/__init__.py +25 -0
  108. evalrx/analyzers/agent/counterfactual.py +84 -0
  109. evalrx/analyzers/agent/first_error_judge.py +96 -0
  110. evalrx/analyzers/agent/ignored_obs.py +81 -0
  111. evalrx/analyzers/agent/loop_detect.py +79 -0
  112. evalrx/analyzers/agent/reliability.py +165 -0
  113. evalrx/analyzers/agent/tool_shap.py +225 -0
  114. evalrx/analyzers/agent/trajectory_rubric.py +168 -0
  115. evalrx/analyzers/attention/__init__.py +19 -0
  116. evalrx/analyzers/attention/relative_attn.py +610 -0
  117. evalrx/analyzers/attention/rollout.py +73 -0
  118. evalrx/analyzers/attention/sink.py +56 -0
  119. evalrx/analyzers/attention/summary.py +190 -0
  120. evalrx/analyzers/attribution/__init__.py +6 -0
  121. evalrx/analyzers/attribution/generic_attn.py +31 -0
  122. evalrx/analyzers/attribution/gradcam.py +30 -0
  123. evalrx/analyzers/base.py +12 -0
  124. evalrx/analyzers/geometry/__init__.py +6 -0
  125. evalrx/analyzers/geometry/cka.py +70 -0
  126. evalrx/analyzers/geometry/linear_probe.py +157 -0
  127. evalrx/analyzers/hallucination/__init__.py +9 -0
  128. evalrx/analyzers/hallucination/chair.py +78 -0
  129. evalrx/analyzers/hallucination/opera.py +29 -0
  130. evalrx/analyzers/hallucination/pope.py +119 -0
  131. evalrx/analyzers/hallucination/selfcheck.py +155 -0
  132. evalrx/analyzers/hallucination/vcd.py +29 -0
  133. evalrx/analyzers/lens/__init__.py +7 -0
  134. evalrx/analyzers/lens/layer_contrast.py +133 -0
  135. evalrx/analyzers/lens/logit_lens.py +138 -0
  136. evalrx/analyzers/lens/tuned_lens.py +30 -0
  137. evalrx/analyzers/patching/__init__.py +5 -0
  138. evalrx/analyzers/patching/causal_trace.py +30 -0
  139. evalrx/analyzers/perturbation/__init__.py +23 -0
  140. evalrx/analyzers/perturbation/_shapley.py +54 -0
  141. evalrx/analyzers/perturbation/context_shap.py +174 -0
  142. evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
  143. evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
  144. evalrx/analyzers/perturbation/mm_shap.py +146 -0
  145. evalrx/analyzers/perturbation/modality_ablation.py +196 -0
  146. evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
  147. evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
  148. evalrx/analyzers/perturbation/rise.py +94 -0
  149. evalrx/analyzers/perturbation/vl_shap.py +102 -0
  150. evalrx/analyzers/reasoning/__init__.py +33 -0
  151. evalrx/analyzers/reasoning/_text.py +328 -0
  152. evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
  153. evalrx/analyzers/reasoning/arith_audit.py +226 -0
  154. evalrx/analyzers/reasoning/contamination.py +214 -0
  155. evalrx/analyzers/reasoning/knowledge_split.py +253 -0
  156. evalrx/analyzers/reasoning/self_repair.py +246 -0
  157. evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
  158. evalrx/analyzers/reasoning/termination_audit.py +258 -0
  159. evalrx/analyzers/uncertainty/__init__.py +18 -0
  160. evalrx/analyzers/uncertainty/calibration.py +174 -0
  161. evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
  162. evalrx/analyzers/uncertainty/entropy.py +90 -0
  163. evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
  164. evalrx/analyzers/uncertainty/self_consistency.py +204 -0
  165. evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
  166. evalrx/cli.py +411 -0
  167. evalrx/config.py +77 -0
  168. evalrx/contract/__init__.py +179 -0
  169. evalrx/contract/common.py +452 -0
  170. evalrx/contract/emit.py +948 -0
  171. evalrx/contract/export.py +237 -0
  172. evalrx/contract/m1.py +325 -0
  173. evalrx/contract/m2.py +317 -0
  174. evalrx/contract/m3.py +165 -0
  175. evalrx/contract/m4.py +130 -0
  176. evalrx/contract/m5.py +292 -0
  177. evalrx/contract/methodology.py +76 -0
  178. evalrx/contract/pre_m1.py +58 -0
  179. evalrx/contract/typescript.py +140 -0
  180. evalrx/core/__init__.py +85 -0
  181. evalrx/core/analyzer.py +174 -0
  182. evalrx/core/capability.py +54 -0
  183. evalrx/core/case.py +443 -0
  184. evalrx/core/experiment.py +106 -0
  185. evalrx/core/model.py +198 -0
  186. evalrx/core/pipeline.py +42 -0
  187. evalrx/core/registry.py +142 -0
  188. evalrx/core/result.py +64 -0
  189. evalrx/core/spec.py +173 -0
  190. evalrx/core/tokentype.py +165 -0
  191. evalrx/core/tool.py +92 -0
  192. evalrx/datasets/__init__.py +41 -0
  193. evalrx/datasets/base.py +68 -0
  194. evalrx/datasets/gui_os.py +52 -0
  195. evalrx/datasets/llm_qa.py +57 -0
  196. evalrx/datasets/pure_qa.py +12 -0
  197. evalrx/datasets/vlm_qa.py +695 -0
  198. evalrx/datasets/web_search_qa.py +52 -0
  199. evalrx/eval_agent/__init__.py +341 -0
  200. evalrx/eval_agent/_tools.py +81 -0
  201. evalrx/eval_agent/ab_runner.py +50 -0
  202. evalrx/eval_agent/agentic/__init__.py +43 -0
  203. evalrx/eval_agent/agentic/actions.py +216 -0
  204. evalrx/eval_agent/agentic/board.py +107 -0
  205. evalrx/eval_agent/agentic/loop.py +190 -0
  206. evalrx/eval_agent/agentic/tools.py +538 -0
  207. evalrx/eval_agent/checkpoint.py +57 -0
  208. evalrx/eval_agent/cli_agent.py +59 -0
  209. evalrx/eval_agent/cli_skills.py +5 -0
  210. evalrx/eval_agent/evolution.py +396 -0
  211. evalrx/eval_agent/git_manager.py +215 -0
  212. evalrx/eval_agent/hypothesis.py +172 -0
  213. evalrx/eval_agent/label_quarantine.py +209 -0
  214. evalrx/eval_agent/legacy.py +530 -0
  215. evalrx/eval_agent/log_schema.py +497 -0
  216. evalrx/eval_agent/loop.py +2159 -0
  217. evalrx/eval_agent/loop_reports.py +116 -0
  218. evalrx/eval_agent/model_instrumentation.py +282 -0
  219. evalrx/eval_agent/narration.py +193 -0
  220. evalrx/eval_agent/nl_runner.py +460 -0
  221. evalrx/eval_agent/orchestrator.py +61 -0
  222. evalrx/eval_agent/preregister.py +93 -0
  223. evalrx/eval_agent/prompts/__init__.py +1 -0
  224. evalrx/eval_agent/prompts/agentic.py +46 -0
  225. evalrx/eval_agent/prompts/case_discovery.py +25 -0
  226. evalrx/eval_agent/prompts/diagnosis.py +125 -0
  227. evalrx/eval_agent/prompts/experiment_writer.py +265 -0
  228. evalrx/eval_agent/prompts/explore_step.py +37 -0
  229. evalrx/eval_agent/prompts/fix_agent.py +257 -0
  230. evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
  231. evalrx/eval_agent/prompts/nl_runner.py +38 -0
  232. evalrx/eval_agent/prompts/probe_agent.py +25 -0
  233. evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
  234. evalrx/eval_agent/prompts/probe_generator.py +35 -0
  235. evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
  236. evalrx/eval_agent/report.py +58 -0
  237. evalrx/eval_agent/run_context.py +354 -0
  238. evalrx/eval_agent/run_log.schema.json +1215 -0
  239. evalrx/eval_agent/run_logger_v2.py +1764 -0
  240. evalrx/eval_agent/run_metadata.py +208 -0
  241. evalrx/eval_agent/stages/__init__.py +56 -0
  242. evalrx/eval_agent/stages/case_discovery.py +293 -0
  243. evalrx/eval_agent/stages/diagnosis.py +1017 -0
  244. evalrx/eval_agent/stages/experiment_writer.py +1634 -0
  245. evalrx/eval_agent/stages/fix_agent.py +3916 -0
  246. evalrx/eval_agent/stages/fix_internals.py +499 -0
  247. evalrx/eval_agent/stages/fix_pipeline.py +725 -0
  248. evalrx/eval_agent/stages/fix_tiers.py +187 -0
  249. evalrx/eval_agent/stages/fix_tools.py +1034 -0
  250. evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
  251. evalrx/eval_agent/stages/probe.py +439 -0
  252. evalrx/eval_agent/stages/probe_agent.py +1079 -0
  253. evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
  254. evalrx/eval_agent/stages/probe_generator.py +326 -0
  255. evalrx/eval_agent/stages/probe_search_agent.py +106 -0
  256. evalrx/eval_agent/stages/protocol.py +112 -0
  257. evalrx/eval_agent/stages/repair_catalog.py +273 -0
  258. evalrx/eval_agent/stages/surgery.py +524 -0
  259. evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
  260. evalrx/eval_agent/store.py +231 -0
  261. evalrx/logging_utils.py +112 -0
  262. evalrx/models/__init__.py +161 -0
  263. evalrx/models/_discover.py +101 -0
  264. evalrx/models/agent.py +380 -0
  265. evalrx/models/backends/__init__.py +58 -0
  266. evalrx/models/backends/api.py +169 -0
  267. evalrx/models/backends/base.py +57 -0
  268. evalrx/models/backends/gemini_compat.py +579 -0
  269. evalrx/models/backends/hf_local.py +2074 -0
  270. evalrx/models/backends/openai_compat.py +301 -0
  271. evalrx/models/backends/vllm_offline.py +116 -0
  272. evalrx/models/base.py +24 -0
  273. evalrx/models/blackbox/__init__.py +4 -0
  274. evalrx/models/blackbox/agent.py +31 -0
  275. evalrx/models/blackbox/base.py +29 -0
  276. evalrx/models/blackbox/gemini.py +279 -0
  277. evalrx/models/blackbox/llm_api.py +17 -0
  278. evalrx/models/blackbox/vlm_api.py +17 -0
  279. evalrx/models/compose.py +66 -0
  280. evalrx/models/inference.py +88 -0
  281. evalrx/models/paper_methods/__init__.py +8 -0
  282. evalrx/models/paper_methods/aad.py +53 -0
  283. evalrx/models/paper_methods/ifcd.py +204 -0
  284. evalrx/models/paper_methods/pai.py +164 -0
  285. evalrx/models/paper_methods/tcd.py +202 -0
  286. evalrx/models/paper_methods/vcd.py +45 -0
  287. evalrx/models/paper_methods/vicrop.py +137 -0
  288. evalrx/models/toolcodec.py +143 -0
  289. evalrx/models/tools/__init__.py +20 -0
  290. evalrx/models/tools/perception.py +300 -0
  291. evalrx/models/tools/visual.py +174 -0
  292. evalrx/models/whitebox/__init__.py +26 -0
  293. evalrx/models/whitebox/agent.py +31 -0
  294. evalrx/models/whitebox/base.py +24 -0
  295. evalrx/models/whitebox/qwen.py +61 -0
  296. evalrx/models/whitebox/qwen2_5_omni.py +29 -0
  297. evalrx/models/whitebox/qwen2_audio.py +25 -0
  298. evalrx/models/whitebox/qwen_omni.py +53 -0
  299. evalrx/models/whitebox/qwen_vl.py +62 -0
  300. evalrx/observability/__init__.py +21 -0
  301. evalrx/observability/envelope.py +122 -0
  302. evalrx/observability/outbox.py +111 -0
  303. evalrx/observability/tracer.py +882 -0
  304. evalrx/reporting/__init__.py +28 -0
  305. evalrx/reporting/case_study.py +947 -0
  306. evalrx/reporting/compiler.py +587 -0
  307. evalrx/reporting/dynamic.py +1882 -0
  308. evalrx/reporting/html_report.py +2225 -0
  309. evalrx/reporting/langfuse_exporter.py +38 -0
  310. evalrx/reporting/langfuse_source.py +155 -0
  311. evalrx/reporting/model.py +151 -0
  312. evalrx/reporting/run_events.py +184 -0
  313. evalrx/reporting/server.py +557 -0
  314. evalrx/reporting/stages.py +58 -0
  315. evalrx/reporting/static_export.py +142 -0
  316. evalrx/reporting/web_dist/index.html +146 -0
  317. evalrx/specs.py +727 -0
  318. evalrx/stats/__init__.py +47 -0
  319. evalrx/stats/api.py +192 -0
  320. evalrx/stats/bootstrap.py +86 -0
  321. evalrx/stats/ebh.py +27 -0
  322. evalrx/stats/evalue.py +98 -0
  323. evalrx/stats/friedman.py +138 -0
  324. evalrx/stats/mcnemar.py +40 -0
  325. evalrx/stats/multiplicity.py +159 -0
  326. evalrx/stats/subset_sampling.py +55 -0
  327. evalrx/term_links.py +43 -0
  328. evalrx/viz/__init__.py +7 -0
  329. evalrx/viz/labels.py +77 -0
  330. evalrx/viz/prompts.py +39 -0
  331. evalrx/viz/renderer.py +590 -0
  332. evalrx/viz/schema.py +36 -0
  333. evalrx/viz/style.py +134 -0
  334. evalrx-0.1.2.dist-info/METADATA +532 -0
  335. evalrx-0.1.2.dist-info/RECORD +339 -0
  336. evalrx-0.1.2.dist-info/WHEEL +5 -0
  337. evalrx-0.1.2.dist-info/entry_points.txt +3 -0
  338. evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
  339. evalrx-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,72 @@
1
+ # Model selection reference
2
+
3
+ Dense decision tables supporting `SKILL.md` steps 3-6. Read the relevant table when
4
+ you reach that step; don't load this whole file into a report.
5
+
6
+ ## Step 3 -- univariate test vs. outcome, by variable type
7
+
8
+ | Variable type | Assumptions met | Primary test | Assumptions violated / robust fallback |
9
+ |---|---|---|---|
10
+ | Categorical (vs. binary outcome) | all expected cell counts >= 5 | Chi-square test of independence | Fisher's exact test (small/sparse cells); Cochran-Mantel-Haenszel test if stratifying by a third categorical variable |
11
+ | Continuous (vs. binary outcome) | approx. normal within each group, similar variances | Two-sample t-test (Student's) | Welch's t-test (unequal variances); Mann-Whitney U / Wilcoxon rank-sum (non-normal, robust default); permutation test (small n, no distributional assumption) |
12
+ | Ordinal (vs. binary outcome) | -- | Mann-Whitney U / Wilcoxon rank-sum | Cumulative-link / ordinal logistic model if treating ordinal as a predictor in a larger model |
13
+
14
+ Effect sizes to report alongside each test: Cramer's V or odds ratio (categorical),
15
+ Cohen's d or rank-biserial correlation (continuous/ordinal).
16
+
17
+ R: `chisq.test()`, `fisher.test()`, `t.test()`, `wilcox.test()`.
18
+ Python: `scipy.stats.chi2_contingency`, `scipy.stats.fisher_exact`, `scipy.stats.ttest_ind`,
19
+ `scipy.stats.mannwhitneyu`.
20
+
21
+ ## Step 4 -- marginal screening
22
+
23
+ | Screening approach | When to use | R | Python |
24
+ |---|---|---|---|
25
+ | Univariate logistic regression per variable, rank by p-value | Default screening method | `glm(outcome ~ x, family = binomial)` | `statsmodels.api.Logit` |
26
+ | AIC improvement over null model | Comparing variables on a consistent scale, useful when p-values are similar across many variables | `AIC(model_null) - AIC(model_x)` | `model_x.aic` vs. null model `aic` |
27
+ | Reuse step-3 bivariate test p-values | Quick pass when there are few candidate variables | -- | -- |
28
+
29
+ Screening narrows the candidate list; it does not replace fitting the full
30
+ multivariable model in step 5. Keep a variable in the candidate set even if its
31
+ marginal signal is weak when subject-matter reasoning or the step-3 conditioning
32
+ check suggests a real, confounded effect.
33
+
34
+ ## Step 5 -- outcome type and clustering -> model family
35
+
36
+ | Outcome type | Observations independent? | Model | R | Python |
37
+ |---|---|---|---|---|
38
+ | Binary (success/fail) | Yes | Logistic regression (binomial GLM) | `glm(y ~ ., family = binomial)` | `statsmodels.api.Logit` / `sklearn.linear_model.LogisticRegression` (use `statsmodels` for inference/p-values) |
39
+ | Binary (success/fail) | No -- clustered/repeated (e.g. multiple examples per model/prompt/subject) | Mixed-effects logistic regression (GLMM) | `lme4::glmer(y ~ . + (1\|cluster), family = binomial)`, or `glmmTMB` | `statsmodels.genmod.bayes_mixed_glm.BinomialBayesMixedGLM`, or `pymer4` (R-backed) |
40
+ | Continuous | Yes | Linear regression | `lm(y ~ .)` | `statsmodels.api.OLS` |
41
+ | Continuous | No -- clustered/repeated | Linear mixed-effects model (LMM) | `lme4::lmer(y ~ . + (1\|cluster))` | `statsmodels.regression.mixed_linear_model.MixedLM` |
42
+ | Count | Yes | Poisson or negative binomial regression (check for overdispersion) | `glm(y ~ ., family = poisson)`, `MASS::glm.nb()` | `statsmodels.discrete.discrete_model.Poisson`, `.NegativeBinomial` |
43
+ | Count | No -- clustered/repeated | Mixed-effects Poisson/negative binomial | `glmmTMB(y ~ . + (1\|cluster), family = nbinom2)` | `statsmodels` mixed count models are limited -- R is the more mature option here |
44
+
45
+ Why not linear regression for a binary outcome: predictions can fall outside [0, 1],
46
+ and the constant-variance / normal-error assumptions behind linear regression's
47
+ inference (t-tests, F-tests, prediction intervals) don't hold for a 0/1 target.
48
+ Logistic regression models the log-odds of success directly and its errors match a
49
+ Bernoulli outcome.
50
+
51
+ When to add a random effect (mixed-effects model): observations share a grouping
52
+ structure that could induce correlated errors -- e.g. several examples generated by
53
+ the same underlying model, drawn from the same prompt template, or measured on the
54
+ same subject. Ignoring this structure understates standard errors and can produce
55
+ false-positive findings. A rule of thumb: if you can name the grouping variable and
56
+ each group contributes more than one row, consider a random intercept for it; add a
57
+ random slope only if there's reason to think the effect of a predictor itself varies
58
+ by group and there's enough data per group to estimate it.
59
+
60
+ ## Step 6 -- goodness-of-fit and diagnostics, by model
61
+
62
+ | Check | Applies to | R | Python |
63
+ |---|---|---|---|
64
+ | Hosmer-Lemeshow test | Logistic regression (few continuous predictors) | `ResourceSelection::hoslem.test()` | `statsmodels` has no built-in; implement via binned deciles of predicted probability |
65
+ | Deviance / Pearson residuals | Any GLM | `residuals(model, type = "deviance")` | `model.resid_deviance`, `model.resid_pearson` |
66
+ | Binned residual plot | Logistic regression / GLMM | `arm::binnedplot()` | plot residuals vs. fitted, binned manually |
67
+ | VIF (multicollinearity) | Any regression with >1 predictor | `car::vif(model)` | `statsmodels.stats.outliers_influence.variance_inflation_factor` |
68
+ | Influence diagnostics | Any GLM | `influence.measures(model)` | `model.get_influence()` |
69
+ | ROC / AUC | Binary outcome model | `pROC::roc()` | `sklearn.metrics.roc_curve`, `roc_auc_score` |
70
+ | Calibration plot | Binary outcome model | `rms::val.prob()` | manual: bin predicted probability, plot observed vs. predicted rate |
71
+ | Random-effect variance / ICC | GLMM/LMM | `performance::icc(model)` | `pymer4` wraps R; native Python support is limited |
72
+ | Convergence check | GLMM/LMM | inspect `model@optinfo$conv` | inspect optimizer convergence flag on the fit result |
@@ -0,0 +1,130 @@
1
+ # Step 2 starting template: explanatory-variable EDA.
2
+ #
3
+ # Given a dataframe and a list of explanatory variable columns, produce:
4
+ # - a per-variable distribution plot (histogram for continuous, bar chart for categorical)
5
+ # - an outlier / missingness summary table
6
+ # - a mixed-type correlation / association matrix among the explanatory variables
7
+ #
8
+ # This is a template to adapt to the real column names and data -- not a black box.
9
+ #
10
+ # Usage:
11
+ # Rscript explanatory_var_eda.R data.csv "age,region,score" output/
12
+
13
+ suppressMessages({
14
+ library(dplyr)
15
+ library(ggplot2)
16
+ })
17
+
18
+ infer_variable_types <- function(df, variables) {
19
+ sapply(variables, function(v) {
20
+ if (is.numeric(df[[v]]) && length(unique(df[[v]])) > 10) "continuous" else "categorical"
21
+ })
22
+ }
23
+
24
+ outlier_summary <- function(df, var) {
25
+ q <- quantile(df[[var]], c(0.25, 0.75), na.rm = TRUE)
26
+ iqr <- q[2] - q[1]
27
+ lower <- q[1] - 1.5 * iqr
28
+ upper <- q[2] + 1.5 * iqr
29
+ n_outliers <- sum(df[[var]] < lower | df[[var]] > upper, na.rm = TRUE)
30
+ data.frame(variable = var, n_outliers = n_outliers, lower_bound = lower, upper_bound = upper)
31
+ }
32
+
33
+ rare_category_summary <- function(df, var, threshold = 0.01) {
34
+ counts <- prop.table(table(df[[var]]))
35
+ rare <- names(counts[counts < threshold])
36
+ data.frame(variable = var, n_categories = length(counts), rare_categories = paste(rare, collapse = ";"))
37
+ }
38
+
39
+ missingness_summary <- function(df, variables) {
40
+ do.call(rbind, lapply(variables, function(v) {
41
+ n_missing <- sum(is.na(df[[v]]))
42
+ data.frame(variable = v, n_missing = n_missing, pct_missing = round(100 * n_missing / nrow(df), 1))
43
+ }))
44
+ }
45
+
46
+ cramers_v <- function(x, y) {
47
+ ct <- table(x, y)
48
+ chi2 <- suppressWarnings(chisq.test(ct)$statistic)
49
+ n <- sum(ct)
50
+ phi2 <- chi2 / n
51
+ r <- nrow(ct); k <- ncol(ct)
52
+ phi2_corr <- max(0, phi2 - (k - 1) * (r - 1) / (n - 1))
53
+ r_corr <- r - (r - 1)^2 / (n - 1)
54
+ k_corr <- k - (k - 1)^2 / (n - 1)
55
+ sqrt(phi2_corr / min(k_corr - 1, r_corr - 1))
56
+ }
57
+
58
+ correlation_ratio <- function(categorical, continuous) {
59
+ d <- na.omit(data.frame(cat = categorical, cont = continuous))
60
+ overall_mean <- mean(d$cont)
61
+ agg <- aggregate(cont ~ cat, d, function(g) c(n = length(g), mean = mean(g)))
62
+ ss_between <- sum(sapply(split(d$cont, d$cat), function(g) length(g) * (mean(g) - overall_mean)^2))
63
+ ss_total <- sum((d$cont - overall_mean)^2)
64
+ if (ss_total > 0) sqrt(ss_between / ss_total) else 0
65
+ }
66
+
67
+ association_matrix <- function(df, var_types) {
68
+ variables <- names(var_types)
69
+ mat <- matrix(NA, length(variables), length(variables), dimnames = list(variables, variables))
70
+ for (a in variables) {
71
+ for (b in variables) {
72
+ if (a == b) { mat[a, b] <- 1; next }
73
+ ta <- var_types[[a]]; tb <- var_types[[b]]
74
+ if (ta == "continuous" && tb == "continuous") {
75
+ mat[a, b] <- cor(df[[a]], df[[b]], use = "pairwise.complete.obs")
76
+ } else if (ta == "categorical" && tb == "categorical") {
77
+ mat[a, b] <- cramers_v(df[[a]], df[[b]])
78
+ } else {
79
+ if (ta == "categorical") mat[a, b] <- correlation_ratio(df[[a]], df[[b]])
80
+ else mat[a, b] <- correlation_ratio(df[[b]], df[[a]])
81
+ }
82
+ }
83
+ }
84
+ mat
85
+ }
86
+
87
+ plot_distribution <- function(df, var, var_type, out_dir) {
88
+ p <- if (var_type == "continuous") {
89
+ ggplot(df, aes(.data[[var]])) + geom_histogram(bins = 30, fill = "steelblue", color = "white")
90
+ } else {
91
+ ggplot(df, aes(.data[[var]])) + geom_bar(fill = "steelblue")
92
+ }
93
+ p <- p + labs(title = var, x = NULL) + theme_minimal() +
94
+ theme(panel.grid.minor = element_blank())
95
+ ggsave(file.path(out_dir, paste0("dist_", var, ".pdf")), p, width = 5, height = 3.5)
96
+ }
97
+
98
+ run <- function(csv_path, variables, out_dir) {
99
+ dir.create(out_dir, showWarnings = FALSE, recursive = TRUE)
100
+ df <- read.csv(csv_path)
101
+ var_types <- infer_variable_types(df, variables)
102
+
103
+ for (v in variables) plot_distribution(df, v, var_types[[v]], out_dir)
104
+
105
+ cont_vars <- variables[var_types == "continuous"]
106
+ cat_vars <- variables[var_types == "categorical"]
107
+ outliers <- do.call(rbind, lapply(cont_vars, outlier_summary, df = df))
108
+ rares <- do.call(rbind, lapply(cat_vars, rare_category_summary, df = df))
109
+ missing <- missingness_summary(df, variables)
110
+ assoc <- association_matrix(df, var_types)
111
+
112
+ write.csv(outliers, file.path(out_dir, "outlier_summary.csv"), row.names = FALSE)
113
+ write.csv(rares, file.path(out_dir, "rare_category_summary.csv"), row.names = FALSE)
114
+ write.csv(missing, file.path(out_dir, "missingness_summary.csv"), row.names = FALSE)
115
+ write.csv(assoc, file.path(out_dir, "association_matrix.csv"))
116
+
117
+ cat("Variable types:\n"); print(var_types)
118
+ cat("\nMissingness:\n"); print(missing)
119
+ cat("\nOutliers (continuous):\n"); print(outliers)
120
+ cat("\nRare categories (categorical):\n"); print(rares)
121
+ cat("\nAssociation matrix:\n"); print(round(assoc, 2))
122
+ }
123
+
124
+ if (sys.nframe() == 0) {
125
+ args <- commandArgs(trailingOnly = TRUE)
126
+ csv_path <- args[1]
127
+ variables <- strsplit(args[2], ",")[[1]]
128
+ out_dir <- ifelse(length(args) >= 3, args[3], "output/figures")
129
+ run(csv_path, variables, out_dir)
130
+ }
@@ -0,0 +1,150 @@
1
+ """
2
+ Step 2 starting template: explanatory-variable EDA.
3
+
4
+ Given a dataframe and a list of explanatory variable columns, produce:
5
+ - a per-variable distribution plot (histogram for continuous, bar chart for categorical)
6
+ - an outlier / missingness summary table
7
+ - a mixed-type correlation / association matrix among the explanatory variables
8
+
9
+ This is a template to adapt to the real column names and data -- not a black box.
10
+ Edit CONTINUOUS_VARS / CATEGORICAL_VARS below, or pass them in when calling the
11
+ functions from another script / notebook.
12
+
13
+ Usage:
14
+ python explanatory_var_eda.py data.csv --vars age,region,score --out output/
15
+ """
16
+
17
+ import argparse
18
+ import itertools
19
+ import os
20
+
21
+ import matplotlib.pyplot as plt
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+
26
+ def infer_variable_types(df: pd.DataFrame, variables: list[str]) -> dict[str, str]:
27
+ """Heuristic: numeric dtype with many unique values -> continuous, else categorical."""
28
+ types = {}
29
+ for v in variables:
30
+ if pd.api.types.is_numeric_dtype(df[v]) and df[v].nunique() > 10:
31
+ types[v] = "continuous"
32
+ else:
33
+ types[v] = "categorical"
34
+ return types
35
+
36
+
37
+ def outlier_summary(df: pd.DataFrame, var: str) -> dict:
38
+ """IQR rule for continuous variables."""
39
+ q1, q3 = df[var].quantile([0.25, 0.75])
40
+ iqr = q3 - q1
41
+ lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
42
+ n_outliers = ((df[var] < lower) | (df[var] > upper)).sum()
43
+ return {"variable": var, "n_outliers": int(n_outliers), "lower_bound": lower, "upper_bound": upper}
44
+
45
+
46
+ def rare_category_summary(df: pd.DataFrame, var: str, threshold: float = 0.01) -> dict:
47
+ """Flag categories making up less than `threshold` fraction of non-missing rows."""
48
+ counts = df[var].value_counts(normalize=True)
49
+ rare = counts[counts < threshold]
50
+ return {"variable": var, "n_categories": len(counts), "rare_categories": list(rare.index)}
51
+
52
+
53
+ def missingness_summary(df: pd.DataFrame, variables: list[str]) -> pd.DataFrame:
54
+ rows = []
55
+ for v in variables:
56
+ n_missing = df[v].isna().sum()
57
+ rows.append({"variable": v, "n_missing": int(n_missing), "pct_missing": round(100 * n_missing / len(df), 1)})
58
+ return pd.DataFrame(rows)
59
+
60
+
61
+ def cramers_v(x: pd.Series, y: pd.Series) -> float:
62
+ """Association between two categorical variables, bias-corrected."""
63
+ from scipy.stats import chi2_contingency
64
+
65
+ ct = pd.crosstab(x, y)
66
+ chi2 = chi2_contingency(ct)[0]
67
+ n = ct.sum().sum()
68
+ phi2 = chi2 / n
69
+ r, k = ct.shape
70
+ phi2_corr = max(0, phi2 - (k - 1) * (r - 1) / (n - 1))
71
+ r_corr = r - (r - 1) ** 2 / (n - 1)
72
+ k_corr = k - (k - 1) ** 2 / (n - 1)
73
+ return float(np.sqrt(phi2_corr / min(k_corr - 1, r_corr - 1)))
74
+
75
+
76
+ def correlation_ratio(categorical: pd.Series, continuous: pd.Series) -> float:
77
+ """Eta: association between a categorical and a continuous variable."""
78
+ df = pd.DataFrame({"cat": categorical, "cont": continuous}).dropna()
79
+ overall_mean = df["cont"].mean()
80
+ groups = df.groupby("cat")["cont"]
81
+ ss_between = sum(len(g) * (g.mean() - overall_mean) ** 2 for _, g in groups)
82
+ ss_total = ((df["cont"] - overall_mean) ** 2).sum()
83
+ return float(np.sqrt(ss_between / ss_total)) if ss_total > 0 else 0.0
84
+
85
+
86
+ def association_matrix(df: pd.DataFrame, var_types: dict[str, str]) -> pd.DataFrame:
87
+ """Mixed-type association matrix: Pearson r (cont-cont), Cramer's V (cat-cat), eta (mixed)."""
88
+ variables = list(var_types.keys())
89
+ mat = pd.DataFrame(index=variables, columns=variables, dtype=float)
90
+ for a, b in itertools.combinations_with_replacement(variables, 2):
91
+ if a == b:
92
+ mat.loc[a, b] = 1.0
93
+ continue
94
+ ta, tb = var_types[a], var_types[b]
95
+ if ta == "continuous" and tb == "continuous":
96
+ val = df[[a, b]].corr().iloc[0, 1]
97
+ elif ta == "categorical" and tb == "categorical":
98
+ val = cramers_v(df[a], df[b])
99
+ else:
100
+ cat_var, cont_var = (a, b) if ta == "categorical" else (b, a)
101
+ val = correlation_ratio(df[cat_var], df[cont_var])
102
+ mat.loc[a, b] = mat.loc[b, a] = val
103
+ return mat
104
+
105
+
106
+ def plot_distribution(df: pd.DataFrame, var: str, var_type: str, out_dir: str) -> None:
107
+ fig, ax = plt.subplots(figsize=(5, 3.5))
108
+ if var_type == "continuous":
109
+ ax.hist(df[var].dropna(), bins=30, color="steelblue", edgecolor="white")
110
+ else:
111
+ df[var].value_counts().plot(kind="bar", ax=ax, color="steelblue")
112
+ ax.set_title(var)
113
+ ax.spines[["top", "right"]].set_visible(False)
114
+ fig.tight_layout()
115
+ fig.savefig(os.path.join(out_dir, f"dist_{var}.pdf"))
116
+ plt.close(fig)
117
+
118
+
119
+ def run(csv_path: str, variables: list[str], out_dir: str) -> None:
120
+ os.makedirs(out_dir, exist_ok=True)
121
+ df = pd.read_csv(csv_path)
122
+ var_types = infer_variable_types(df, variables)
123
+
124
+ for v in variables:
125
+ plot_distribution(df, v, var_types[v], out_dir)
126
+
127
+ outliers = [outlier_summary(df, v) for v, t in var_types.items() if t == "continuous"]
128
+ rares = [rare_category_summary(df, v) for v, t in var_types.items() if t == "categorical"]
129
+ missing = missingness_summary(df, variables)
130
+ assoc = association_matrix(df, var_types)
131
+
132
+ pd.DataFrame(outliers).to_csv(os.path.join(out_dir, "outlier_summary.csv"), index=False)
133
+ pd.DataFrame(rares).to_csv(os.path.join(out_dir, "rare_category_summary.csv"), index=False)
134
+ missing.to_csv(os.path.join(out_dir, "missingness_summary.csv"), index=False)
135
+ assoc.to_csv(os.path.join(out_dir, "association_matrix.csv"))
136
+
137
+ print("Variable types:", var_types)
138
+ print("\nMissingness:\n", missing)
139
+ print("\nOutliers (continuous):\n", pd.DataFrame(outliers))
140
+ print("\nRare categories (categorical):\n", pd.DataFrame(rares))
141
+ print("\nAssociation matrix:\n", assoc.round(2))
142
+
143
+
144
+ if __name__ == "__main__":
145
+ parser = argparse.ArgumentParser(description=__doc__)
146
+ parser.add_argument("csv_path")
147
+ parser.add_argument("--vars", required=True, help="comma-separated explanatory variable column names")
148
+ parser.add_argument("--out", default="output/figures")
149
+ args = parser.parse_args()
150
+ run(args.csv_path, args.vars.split(","), args.out)
@@ -0,0 +1,181 @@
1
+ # Steps 5-7 starting template: fit a justified model for a binary outcome, test
2
+ # per-variable significance, run goodness-of-fit diagnostics, and visualize results.
3
+ #
4
+ # Given a dataframe, a binary outcome column, a list of predictor columns, and an
5
+ # optional cluster column (for repeated/clustered observations):
6
+ # - fits a plain logistic regression (glm, family = binomial) when there's no clustering
7
+ # - fits a mixed-effects logistic regression (lme4::glmer, random intercept for the
8
+ # cluster) when clustering is present -- this is the language where GLMMs are most
9
+ # mature, per references/model_selection.md
10
+ # - reports per-variable Wald and likelihood-ratio significance with odds ratios
11
+ # - runs Hosmer-Lemeshow (fixed-effects case), VIF, ROC/AUC, and a calibration check,
12
+ # plus ICC/convergence checks for the mixed-effects case
13
+ # - produces a coefficient/odds-ratio plot, an ROC curve, and a calibration plot
14
+ #
15
+ # This is a template to adapt to the real column names and data -- not a black box.
16
+ #
17
+ # Usage:
18
+ # Rscript fit_outcome_model.R data.csv outcome "age,region,score" output/ [cluster]
19
+
20
+ suppressMessages({
21
+ library(dplyr)
22
+ library(ggplot2)
23
+ library(car) # vif()
24
+ library(pROC) # roc()
25
+ })
26
+
27
+ fit_model <- function(df, outcome, predictors, cluster = NULL) {
28
+ formula_str <- paste(outcome, "~", paste(predictors, collapse = " + "))
29
+ if (is.null(cluster)) {
30
+ message("Fit plain logistic regression (observations treated as independent).")
31
+ glm(as.formula(formula_str), data = df, family = binomial)
32
+ } else {
33
+ suppressMessages(library(lme4))
34
+ message(sprintf("Observations are clustered by '%s': fit mixed-effects logistic regression",
35
+ cluster))
36
+ mixed_formula <- paste(formula_str, "+ (1 |", cluster, ")")
37
+ lme4::glmer(as.formula(mixed_formula), data = df, family = binomial,
38
+ control = lme4::glmerControl(optimizer = "bobyqa"))
39
+ }
40
+ }
41
+
42
+ is_mixed <- function(model) inherits(model, "merMod")
43
+
44
+ per_variable_significance <- function(df, outcome, predictors, full_model, cluster = NULL) {
45
+ # a categorical predictor expands to one coefficient per non-reference level (e.g.
46
+ # "regionnorth", "regionsouth") -- report one row per level, but share a single
47
+ # likelihood-ratio p-value (a joint test dropping the whole variable) across them
48
+ formula_base <- if (is_mixed(full_model)) paste("+ (1 |", cluster, ")") else ""
49
+ coef_table <- summary(full_model)$coefficients
50
+ pr_col <- grep("Pr", colnames(coef_table), value = TRUE)[1]
51
+ rows <- lapply(predictors, function(v) {
52
+ reduced_predictors <- setdiff(predictors, v)
53
+ reduced_rhs <- if (length(reduced_predictors) > 0) paste(reduced_predictors, collapse = " + ") else "1"
54
+ reduced_formula <- paste(outcome, "~", reduced_rhs, formula_base)
55
+ reduced_model <- if (is_mixed(full_model)) {
56
+ lme4::glmer(as.formula(reduced_formula), data = df, family = binomial)
57
+ } else {
58
+ glm(as.formula(reduced_formula), data = df, family = binomial)
59
+ }
60
+ lrt <- anova(reduced_model, full_model, test = "Chisq")
61
+ # stats::anova.glm names this column "Pr(>Chi)"; lme4:::anova.merMod names it
62
+ # "Pr(>Chisq)" -- look it up rather than hardcoding either spelling
63
+ lrt_p <- lrt[[grep("^Pr", colnames(lrt), value = TRUE)]][2]
64
+
65
+ coef_names <- grep(paste0("^", v), names(fixef_or_coef(full_model)), value = TRUE)
66
+ do.call(rbind, lapply(coef_names, function(coef_name) {
67
+ est <- fixef_or_coef(full_model)[[coef_name]]
68
+ se <- coef_table[coef_name, "Std. Error"]
69
+ wald_p <- coef_table[coef_name, pr_col]
70
+ data.frame(
71
+ variable = coef_name,
72
+ odds_ratio = exp(est),
73
+ ci_low = exp(est - 1.96 * se),
74
+ ci_high = exp(est + 1.96 * se),
75
+ wald_p = wald_p,
76
+ lrt_p = lrt_p
77
+ )
78
+ }))
79
+ })
80
+ do.call(rbind, rows)
81
+ }
82
+
83
+ fixef_or_coef <- function(model) if (is_mixed(model)) lme4::fixef(model) else coef(model)
84
+
85
+ hosmer_lemeshow <- function(y_true, y_pred, n_bins = 10) {
86
+ bins <- cut(y_pred, breaks = quantile(y_pred, probs = seq(0, 1, length.out = n_bins + 1)),
87
+ include.lowest = TRUE)
88
+ obs <- tapply(y_true, bins, sum)
89
+ exp <- tapply(y_pred, bins, sum)
90
+ n <- tapply(y_true, bins, length)
91
+ stat <- sum((obs - exp)^2 / (exp * (1 - exp / n)), na.rm = TRUE)
92
+ p_value <- pchisq(stat, df = n_bins - 2, lower.tail = FALSE)
93
+ list(statistic = stat, p_value = p_value)
94
+ }
95
+
96
+ plot_coefficients <- function(sig_table, out_dir) {
97
+ p <- ggplot(sig_table, aes(x = odds_ratio, y = reorder(variable, odds_ratio))) +
98
+ geom_point(color = "steelblue") +
99
+ geom_errorbarh(aes(xmin = ci_low, xmax = ci_high), height = 0.2, color = "steelblue") +
100
+ geom_vline(xintercept = 1, linetype = "dashed", color = "gray50") +
101
+ labs(x = "Odds ratio (95% CI)", y = NULL) +
102
+ theme_minimal()
103
+ ggsave(file.path(out_dir, "coefficient_plot.pdf"), p, width = 5, height = 0.5 * nrow(sig_table) + 1)
104
+ }
105
+
106
+ plot_roc <- function(y_true, y_pred, out_dir) {
107
+ roc_obj <- pROC::roc(y_true, y_pred, quiet = TRUE)
108
+ pdf(file.path(out_dir, "roc_curve.pdf"), width = 4, height = 4)
109
+ plot(roc_obj, col = "steelblue", main = sprintf("AUC = %.3f", pROC::auc(roc_obj)))
110
+ dev.off()
111
+ as.numeric(pROC::auc(roc_obj))
112
+ }
113
+
114
+ plot_calibration <- function(y_true, y_pred, out_dir, n_bins = 10) {
115
+ bins <- cut(y_pred, breaks = quantile(y_pred, probs = seq(0, 1, length.out = n_bins + 1)),
116
+ include.lowest = TRUE)
117
+ observed <- tapply(y_true, bins, mean)
118
+ predicted <- tapply(y_pred, bins, mean)
119
+ df <- data.frame(observed = observed, predicted = predicted)
120
+ p <- ggplot(df, aes(predicted, observed)) +
121
+ geom_point(color = "steelblue") + geom_line(color = "steelblue") +
122
+ geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "gray50") +
123
+ labs(x = "Mean predicted probability", y = "Observed proportion") +
124
+ theme_minimal()
125
+ ggsave(file.path(out_dir, "calibration_plot.pdf"), p, width = 4, height = 4)
126
+ }
127
+
128
+ run <- function(csv_path, outcome, predictors, out_dir, cluster = NULL) {
129
+ dir.create(out_dir, showWarnings = FALSE, recursive = TRUE)
130
+ df <- read.csv(csv_path)
131
+ df <- df[complete.cases(df[, c(outcome, predictors)]), ]
132
+
133
+ model <- fit_model(df, outcome, predictors, cluster)
134
+ print(summary(model))
135
+
136
+ sig_table <- per_variable_significance(df, outcome, predictors, model, cluster)
137
+ write.csv(sig_table, file.path(out_dir, "per_variable_significance.csv"), row.names = FALSE)
138
+ cat("\nPer-variable significance (adjusted for other predictors):\n"); print(sig_table)
139
+
140
+ y_pred <- predict(model, type = "response")
141
+
142
+ if (is_mixed(model)) {
143
+ cat("\nMixed-effects model: checking random-effect variance, ICC, and convergence.\n")
144
+ print(lme4::VarCorr(model))
145
+ if (!is.null(model@optinfo$conv$lme4$messages)) {
146
+ cat("Convergence warnings:\n"); print(model@optinfo$conv$lme4$messages)
147
+ } else {
148
+ cat("No convergence warnings.\n")
149
+ }
150
+ } else {
151
+ hl <- hosmer_lemeshow(df[[outcome]], y_pred)
152
+ cat(sprintf("\nHosmer-Lemeshow: chi2=%.3f, p=%.4g\n", hl$statistic, hl$p_value))
153
+ vif_vals <- car::vif(model)
154
+ # car::vif() returns a plain named vector when every predictor has 1 df, but a
155
+ # GVIF/Df/GVIF^(1/(2*Df)) matrix as soon as any factor predictor has >2 levels --
156
+ # use the adjusted GVIF^(1/(2*Df))^2 (comparable to a plain VIF) in that case
157
+ vif_df <- if (is.matrix(vif_vals)) {
158
+ data.frame(variable = rownames(vif_vals), vif = vif_vals[, "GVIF^(1/(2*Df))"]^2)
159
+ } else {
160
+ data.frame(variable = names(vif_vals), vif = vif_vals)
161
+ }
162
+ write.csv(vif_df, file.path(out_dir, "vif.csv"), row.names = FALSE)
163
+ cat("\nVIF:\n"); print(vif_df)
164
+ }
165
+
166
+ auc <- plot_roc(df[[outcome]], y_pred, out_dir)
167
+ plot_calibration(df[[outcome]], y_pred, out_dir)
168
+ plot_coefficients(sig_table, out_dir)
169
+ cat(sprintf("\nAUC: %.3f\n", auc))
170
+ cat(sprintf("Plots written to %s: coefficient_plot.pdf, roc_curve.pdf, calibration_plot.pdf\n", out_dir))
171
+ }
172
+
173
+ if (sys.nframe() == 0) {
174
+ args <- commandArgs(trailingOnly = TRUE)
175
+ csv_path <- args[1]
176
+ outcome <- args[2]
177
+ predictors <- strsplit(args[3], ",")[[1]]
178
+ out_dir <- ifelse(length(args) >= 4, args[4], "output")
179
+ cluster <- if (length(args) >= 5) args[5] else NULL
180
+ run(csv_path, outcome, predictors, out_dir, cluster)
181
+ }