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.
- evalrx/__init__.py +139 -0
- evalrx/agent_assets/__init__.py +2 -0
- evalrx/agent_assets/skills/README.md +28 -0
- evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
- evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
- evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
- evalrx/agent_assets/skills/nature-figure/README.md +412 -0
- evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
- evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
- evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
- evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
- evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
- evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
- evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
- evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
- evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
- evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
- evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
- evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
- evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
- evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
- evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
- evalrx/agent_assets/skills.py +27 -0
- evalrx/agent_runtime/__init__.py +78 -0
- evalrx/agent_runtime/_docker_runner.py +89 -0
- evalrx/agent_runtime/cli_runtime.py +103 -0
- evalrx/agent_runtime/cli_transcript.py +138 -0
- evalrx/agent_runtime/cli_types.py +68 -0
- evalrx/agent_runtime/codegen/__init__.py +5 -0
- evalrx/agent_runtime/codegen/runner.py +94 -0
- evalrx/agent_runtime/experiment_harness.py +117 -0
- evalrx/agent_runtime/factory.py +102 -0
- evalrx/agent_runtime/json_shape.py +44 -0
- evalrx/agent_runtime/judges/__init__.py +28 -0
- evalrx/agent_runtime/judges/agy.py +179 -0
- evalrx/agent_runtime/judges/autodetect.py +135 -0
- evalrx/agent_runtime/judges/claude.py +159 -0
- evalrx/agent_runtime/judges/codex.py +120 -0
- evalrx/agent_runtime/providers/__init__.py +21 -0
- evalrx/agent_runtime/providers/antigravity.py +31 -0
- evalrx/agent_runtime/providers/base.py +145 -0
- evalrx/agent_runtime/providers/claude_code.py +49 -0
- evalrx/agent_runtime/providers/codex.py +37 -0
- evalrx/agent_runtime/providers/gemini_cli.py +26 -0
- evalrx/agent_runtime/providers/kimi_cli.py +27 -0
- evalrx/agent_runtime/providers/opencode.py +27 -0
- evalrx/agent_runtime/providers/registry.py +58 -0
- evalrx/agent_runtime/sandbox.py +517 -0
- evalrx/agent_runtime/skill_audit.py +143 -0
- evalrx/agent_runtime/skills/__init__.py +19 -0
- evalrx/agent_runtime/skills/installer.py +68 -0
- evalrx/agent_runtime/skills/prompt_policy.py +86 -0
- evalrx/agent_runtime/skills/resolver.py +19 -0
- evalrx/analysis/__init__.py +132 -0
- evalrx/analysis/adjudicate.py +154 -0
- evalrx/analysis/analysis_module.py +361 -0
- evalrx/analysis/api.py +171 -0
- evalrx/analysis/case_studio.py +651 -0
- evalrx/analysis/cli.py +114 -0
- evalrx/analysis/dashboard.py +350 -0
- evalrx/analysis/eval_case_matrix.py +118 -0
- evalrx/analysis/eval_viz_theme.py +833 -0
- evalrx/analysis/explore_run.py +333 -0
- evalrx/analysis/explorer.py +1276 -0
- evalrx/analysis/failure_modes.py +607 -0
- evalrx/analysis/fused_pipeline.py +489 -0
- evalrx/analysis/holdout.py +300 -0
- evalrx/analysis/hypothesis_agent.py +230 -0
- evalrx/analysis/narration.py +177 -0
- evalrx/analysis/operationalize.py +442 -0
- evalrx/analysis/plain_language.py +42 -0
- evalrx/analysis/planner.py +283 -0
- evalrx/analysis/probe_search.py +203 -0
- evalrx/analysis/profile.py +268 -0
- evalrx/analysis/prompts/__init__.py +0 -0
- evalrx/analysis/prompts/explorer.py +417 -0
- evalrx/analysis/prompts/failure_modes.py +33 -0
- evalrx/analysis/prompts/holdout.py +27 -0
- evalrx/analysis/prompts/hypothesis_agent.py +78 -0
- evalrx/analysis/prompts/run_codebase.py +47 -0
- evalrx/analysis/prompts/stats_agent.py +72 -0
- evalrx/analysis/prompts/stats_tool_generator.py +43 -0
- evalrx/analysis/result_marker.py +47 -0
- evalrx/analysis/run_codebase.py +242 -0
- evalrx/analysis/run_view.py +205 -0
- evalrx/analysis/stage_views.py +93 -0
- evalrx/analysis/stats_agent.py +944 -0
- evalrx/analysis/stats_tool_agent.py +261 -0
- evalrx/analysis/stats_tool_generator.py +415 -0
- evalrx/analysis/stats_tools.py +1153 -0
- evalrx/analysis/trajectory_records.py +193 -0
- evalrx/analysis/workbench.py +431 -0
- evalrx/analyzers/__init__.py +42 -0
- evalrx/analyzers/agent/__init__.py +25 -0
- evalrx/analyzers/agent/counterfactual.py +84 -0
- evalrx/analyzers/agent/first_error_judge.py +96 -0
- evalrx/analyzers/agent/ignored_obs.py +81 -0
- evalrx/analyzers/agent/loop_detect.py +79 -0
- evalrx/analyzers/agent/reliability.py +165 -0
- evalrx/analyzers/agent/tool_shap.py +225 -0
- evalrx/analyzers/agent/trajectory_rubric.py +168 -0
- evalrx/analyzers/attention/__init__.py +19 -0
- evalrx/analyzers/attention/relative_attn.py +610 -0
- evalrx/analyzers/attention/rollout.py +73 -0
- evalrx/analyzers/attention/sink.py +56 -0
- evalrx/analyzers/attention/summary.py +190 -0
- evalrx/analyzers/attribution/__init__.py +6 -0
- evalrx/analyzers/attribution/generic_attn.py +31 -0
- evalrx/analyzers/attribution/gradcam.py +30 -0
- evalrx/analyzers/base.py +12 -0
- evalrx/analyzers/geometry/__init__.py +6 -0
- evalrx/analyzers/geometry/cka.py +70 -0
- evalrx/analyzers/geometry/linear_probe.py +157 -0
- evalrx/analyzers/hallucination/__init__.py +9 -0
- evalrx/analyzers/hallucination/chair.py +78 -0
- evalrx/analyzers/hallucination/opera.py +29 -0
- evalrx/analyzers/hallucination/pope.py +119 -0
- evalrx/analyzers/hallucination/selfcheck.py +155 -0
- evalrx/analyzers/hallucination/vcd.py +29 -0
- evalrx/analyzers/lens/__init__.py +7 -0
- evalrx/analyzers/lens/layer_contrast.py +133 -0
- evalrx/analyzers/lens/logit_lens.py +138 -0
- evalrx/analyzers/lens/tuned_lens.py +30 -0
- evalrx/analyzers/patching/__init__.py +5 -0
- evalrx/analyzers/patching/causal_trace.py +30 -0
- evalrx/analyzers/perturbation/__init__.py +23 -0
- evalrx/analyzers/perturbation/_shapley.py +54 -0
- evalrx/analyzers/perturbation/context_shap.py +174 -0
- evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
- evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
- evalrx/analyzers/perturbation/mm_shap.py +146 -0
- evalrx/analyzers/perturbation/modality_ablation.py +196 -0
- evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
- evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
- evalrx/analyzers/perturbation/rise.py +94 -0
- evalrx/analyzers/perturbation/vl_shap.py +102 -0
- evalrx/analyzers/reasoning/__init__.py +33 -0
- evalrx/analyzers/reasoning/_text.py +328 -0
- evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
- evalrx/analyzers/reasoning/arith_audit.py +226 -0
- evalrx/analyzers/reasoning/contamination.py +214 -0
- evalrx/analyzers/reasoning/knowledge_split.py +253 -0
- evalrx/analyzers/reasoning/self_repair.py +246 -0
- evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
- evalrx/analyzers/reasoning/termination_audit.py +258 -0
- evalrx/analyzers/uncertainty/__init__.py +18 -0
- evalrx/analyzers/uncertainty/calibration.py +174 -0
- evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
- evalrx/analyzers/uncertainty/entropy.py +90 -0
- evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
- evalrx/analyzers/uncertainty/self_consistency.py +204 -0
- evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
- evalrx/cli.py +411 -0
- evalrx/config.py +77 -0
- evalrx/contract/__init__.py +179 -0
- evalrx/contract/common.py +452 -0
- evalrx/contract/emit.py +948 -0
- evalrx/contract/export.py +237 -0
- evalrx/contract/m1.py +325 -0
- evalrx/contract/m2.py +317 -0
- evalrx/contract/m3.py +165 -0
- evalrx/contract/m4.py +130 -0
- evalrx/contract/m5.py +292 -0
- evalrx/contract/methodology.py +76 -0
- evalrx/contract/pre_m1.py +58 -0
- evalrx/contract/typescript.py +140 -0
- evalrx/core/__init__.py +85 -0
- evalrx/core/analyzer.py +174 -0
- evalrx/core/capability.py +54 -0
- evalrx/core/case.py +443 -0
- evalrx/core/experiment.py +106 -0
- evalrx/core/model.py +198 -0
- evalrx/core/pipeline.py +42 -0
- evalrx/core/registry.py +142 -0
- evalrx/core/result.py +64 -0
- evalrx/core/spec.py +173 -0
- evalrx/core/tokentype.py +165 -0
- evalrx/core/tool.py +92 -0
- evalrx/datasets/__init__.py +41 -0
- evalrx/datasets/base.py +68 -0
- evalrx/datasets/gui_os.py +52 -0
- evalrx/datasets/llm_qa.py +57 -0
- evalrx/datasets/pure_qa.py +12 -0
- evalrx/datasets/vlm_qa.py +695 -0
- evalrx/datasets/web_search_qa.py +52 -0
- evalrx/eval_agent/__init__.py +341 -0
- evalrx/eval_agent/_tools.py +81 -0
- evalrx/eval_agent/ab_runner.py +50 -0
- evalrx/eval_agent/agentic/__init__.py +43 -0
- evalrx/eval_agent/agentic/actions.py +216 -0
- evalrx/eval_agent/agentic/board.py +107 -0
- evalrx/eval_agent/agentic/loop.py +190 -0
- evalrx/eval_agent/agentic/tools.py +538 -0
- evalrx/eval_agent/checkpoint.py +57 -0
- evalrx/eval_agent/cli_agent.py +59 -0
- evalrx/eval_agent/cli_skills.py +5 -0
- evalrx/eval_agent/evolution.py +396 -0
- evalrx/eval_agent/git_manager.py +215 -0
- evalrx/eval_agent/hypothesis.py +172 -0
- evalrx/eval_agent/label_quarantine.py +209 -0
- evalrx/eval_agent/legacy.py +530 -0
- evalrx/eval_agent/log_schema.py +497 -0
- evalrx/eval_agent/loop.py +2159 -0
- evalrx/eval_agent/loop_reports.py +116 -0
- evalrx/eval_agent/model_instrumentation.py +282 -0
- evalrx/eval_agent/narration.py +193 -0
- evalrx/eval_agent/nl_runner.py +460 -0
- evalrx/eval_agent/orchestrator.py +61 -0
- evalrx/eval_agent/preregister.py +93 -0
- evalrx/eval_agent/prompts/__init__.py +1 -0
- evalrx/eval_agent/prompts/agentic.py +46 -0
- evalrx/eval_agent/prompts/case_discovery.py +25 -0
- evalrx/eval_agent/prompts/diagnosis.py +125 -0
- evalrx/eval_agent/prompts/experiment_writer.py +265 -0
- evalrx/eval_agent/prompts/explore_step.py +37 -0
- evalrx/eval_agent/prompts/fix_agent.py +257 -0
- evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
- evalrx/eval_agent/prompts/nl_runner.py +38 -0
- evalrx/eval_agent/prompts/probe_agent.py +25 -0
- evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
- evalrx/eval_agent/prompts/probe_generator.py +35 -0
- evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
- evalrx/eval_agent/report.py +58 -0
- evalrx/eval_agent/run_context.py +354 -0
- evalrx/eval_agent/run_log.schema.json +1215 -0
- evalrx/eval_agent/run_logger_v2.py +1764 -0
- evalrx/eval_agent/run_metadata.py +208 -0
- evalrx/eval_agent/stages/__init__.py +56 -0
- evalrx/eval_agent/stages/case_discovery.py +293 -0
- evalrx/eval_agent/stages/diagnosis.py +1017 -0
- evalrx/eval_agent/stages/experiment_writer.py +1634 -0
- evalrx/eval_agent/stages/fix_agent.py +3916 -0
- evalrx/eval_agent/stages/fix_internals.py +499 -0
- evalrx/eval_agent/stages/fix_pipeline.py +725 -0
- evalrx/eval_agent/stages/fix_tiers.py +187 -0
- evalrx/eval_agent/stages/fix_tools.py +1034 -0
- evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
- evalrx/eval_agent/stages/probe.py +439 -0
- evalrx/eval_agent/stages/probe_agent.py +1079 -0
- evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
- evalrx/eval_agent/stages/probe_generator.py +326 -0
- evalrx/eval_agent/stages/probe_search_agent.py +106 -0
- evalrx/eval_agent/stages/protocol.py +112 -0
- evalrx/eval_agent/stages/repair_catalog.py +273 -0
- evalrx/eval_agent/stages/surgery.py +524 -0
- evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
- evalrx/eval_agent/store.py +231 -0
- evalrx/logging_utils.py +112 -0
- evalrx/models/__init__.py +161 -0
- evalrx/models/_discover.py +101 -0
- evalrx/models/agent.py +380 -0
- evalrx/models/backends/__init__.py +58 -0
- evalrx/models/backends/api.py +169 -0
- evalrx/models/backends/base.py +57 -0
- evalrx/models/backends/gemini_compat.py +579 -0
- evalrx/models/backends/hf_local.py +2074 -0
- evalrx/models/backends/openai_compat.py +301 -0
- evalrx/models/backends/vllm_offline.py +116 -0
- evalrx/models/base.py +24 -0
- evalrx/models/blackbox/__init__.py +4 -0
- evalrx/models/blackbox/agent.py +31 -0
- evalrx/models/blackbox/base.py +29 -0
- evalrx/models/blackbox/gemini.py +279 -0
- evalrx/models/blackbox/llm_api.py +17 -0
- evalrx/models/blackbox/vlm_api.py +17 -0
- evalrx/models/compose.py +66 -0
- evalrx/models/inference.py +88 -0
- evalrx/models/paper_methods/__init__.py +8 -0
- evalrx/models/paper_methods/aad.py +53 -0
- evalrx/models/paper_methods/ifcd.py +204 -0
- evalrx/models/paper_methods/pai.py +164 -0
- evalrx/models/paper_methods/tcd.py +202 -0
- evalrx/models/paper_methods/vcd.py +45 -0
- evalrx/models/paper_methods/vicrop.py +137 -0
- evalrx/models/toolcodec.py +143 -0
- evalrx/models/tools/__init__.py +20 -0
- evalrx/models/tools/perception.py +300 -0
- evalrx/models/tools/visual.py +174 -0
- evalrx/models/whitebox/__init__.py +26 -0
- evalrx/models/whitebox/agent.py +31 -0
- evalrx/models/whitebox/base.py +24 -0
- evalrx/models/whitebox/qwen.py +61 -0
- evalrx/models/whitebox/qwen2_5_omni.py +29 -0
- evalrx/models/whitebox/qwen2_audio.py +25 -0
- evalrx/models/whitebox/qwen_omni.py +53 -0
- evalrx/models/whitebox/qwen_vl.py +62 -0
- evalrx/observability/__init__.py +21 -0
- evalrx/observability/envelope.py +122 -0
- evalrx/observability/outbox.py +111 -0
- evalrx/observability/tracer.py +882 -0
- evalrx/reporting/__init__.py +28 -0
- evalrx/reporting/case_study.py +947 -0
- evalrx/reporting/compiler.py +587 -0
- evalrx/reporting/dynamic.py +1882 -0
- evalrx/reporting/html_report.py +2225 -0
- evalrx/reporting/langfuse_exporter.py +38 -0
- evalrx/reporting/langfuse_source.py +155 -0
- evalrx/reporting/model.py +151 -0
- evalrx/reporting/run_events.py +184 -0
- evalrx/reporting/server.py +557 -0
- evalrx/reporting/stages.py +58 -0
- evalrx/reporting/static_export.py +142 -0
- evalrx/reporting/web_dist/index.html +146 -0
- evalrx/specs.py +727 -0
- evalrx/stats/__init__.py +47 -0
- evalrx/stats/api.py +192 -0
- evalrx/stats/bootstrap.py +86 -0
- evalrx/stats/ebh.py +27 -0
- evalrx/stats/evalue.py +98 -0
- evalrx/stats/friedman.py +138 -0
- evalrx/stats/mcnemar.py +40 -0
- evalrx/stats/multiplicity.py +159 -0
- evalrx/stats/subset_sampling.py +55 -0
- evalrx/term_links.py +43 -0
- evalrx/viz/__init__.py +7 -0
- evalrx/viz/labels.py +77 -0
- evalrx/viz/prompts.py +39 -0
- evalrx/viz/renderer.py +590 -0
- evalrx/viz/schema.py +36 -0
- evalrx/viz/style.py +134 -0
- evalrx-0.1.2.dist-info/METADATA +532 -0
- evalrx-0.1.2.dist-info/RECORD +339 -0
- evalrx-0.1.2.dist-info/WHEEL +5 -0
- evalrx-0.1.2.dist-info/entry_points.txt +3 -0
- evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
- 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
|
+
}
|