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,833 @@
1
+ """
2
+ eval_viz_theme.py
3
+ =================
4
+ Drop-in styling + chart layer for FAIL-vs-PASS LLM eval analysis.
5
+
6
+ Goal: one import fixes the three recurring problems —
7
+ (1) inconsistent color / font / truncated names -> global template + semantic
8
+ palette + short-name registry
9
+ (2) overflow clipping + misallocated space -> sizing helpers (full-row vs
10
+ compact) and chart builders that pick distribution plots over two-bar plots
11
+ (3) inconsistent number / bin formatting -> fmt() and human_bins()
12
+
13
+ Targets Plotly and static report figures. A matplotlib rcParams equivalent is
14
+ at the bottom for non-Plotly callers.
15
+
16
+ Usage
17
+ -----
18
+ import eval_viz_theme as viz
19
+ viz.apply() # register + set default template once, at startup
20
+
21
+ fig = viz.violin_by_outcome(df, signal="relative_attention_max_relative_weight",
22
+ outcome="label")
23
+ st.plotly_chart(fig, use_container_width=True)
24
+
25
+ Every builder returns a plotly Figure. Nothing renders on its own.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+
32
+ import numpy as np
33
+ import pandas as pd
34
+ import plotly.express as px
35
+ import plotly.graph_objects as go
36
+ import plotly.io as pio
37
+
38
+ from evalrx.viz.labels import display_name, raw_hint
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # 1. SEMANTIC PALETTE — color encodes ROLE, never decoration.
42
+ # Reuse these names everywhere; never hardcode a hex in a chart.
43
+ # Values are the validated dataviz-skill reference palette (dataviz
44
+ # skill's references/palette.md): status colors are fixed across modes,
45
+ # only ink/surface/grid shift. apply() swaps PALETTE in place between
46
+ # _LIGHT/_DARK — see its docstring for how dark mode is detected.
47
+ # ---------------------------------------------------------------------------
48
+ _LIGHT = {
49
+ "FAIL": "#d03b3b", # status: critical
50
+ "PASS": "#0ca30c", # status: good
51
+ "SIGNIFICANT": "#0ca30c", # status: good — survived FDR / REJECT H0
52
+ "INCONCLUSIVE": "#fab219", # status: warning — did not survive
53
+ "ACCENT": "#2a78d6", # categorical slot 1 / sequential base hue
54
+ "LEAKY": "#898781", # muted ink: target-leaking signal, do not elevate
55
+ "GRID": "#e1e0d9", # hairline gridline
56
+ "AXIS": "#898781", # muted ink (axis/tick labels)
57
+ "TEXT": "#0b0b0b", # primary ink
58
+ "SURFACE": "#fcfcfb", # chart surface — sequential-scale zero end
59
+ "BAND": "rgba(42,120,214,0.12)", # CI / reference band fill
60
+ "DIVERGE_NEG": "#2a78d6", # diverging pole (negative) — blue
61
+ "DIVERGE_POS": "#e34948", # diverging pole (positive) — red
62
+ "DIVERGE_MID": "#f0efec", # diverging neutral midpoint
63
+ }
64
+ _DARK = {
65
+ "FAIL": "#d03b3b",
66
+ "PASS": "#0ca30c",
67
+ "SIGNIFICANT": "#0ca30c",
68
+ "INCONCLUSIVE": "#fab219",
69
+ "ACCENT": "#3987e5",
70
+ "LEAKY": "#898781",
71
+ "GRID": "#2c2c2a",
72
+ "AXIS": "#898781",
73
+ "TEXT": "#ffffff",
74
+ "SURFACE": "#1a1a19",
75
+ "BAND": "rgba(57,135,229,0.20)",
76
+ "DIVERGE_NEG": "#3987e5",
77
+ "DIVERGE_POS": "#e66767",
78
+ "DIVERGE_MID": "#383835",
79
+ }
80
+ PALETTE = dict(_LIGHT) # mutated in place by apply(); chart builders read this dict live
81
+
82
+ FONT = "Inter, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif"
83
+
84
+ # Map any outcome label -> role color. Extend if labels differ. Rebuilt by
85
+ # apply() alongside PALETTE so it never goes stale after a mode switch.
86
+ OUTCOME_COLORS = {
87
+ # NB: True hashes to 1 and False to 0, so the int keys cover the bool case too.
88
+ "FAIL": PALETTE["FAIL"], "fail": PALETTE["FAIL"], 1: PALETTE["FAIL"],
89
+ "PASS": PALETTE["PASS"], "pass": PALETTE["PASS"], 0: PALETTE["PASS"],
90
+ }
91
+
92
+ def outcome_color(v):
93
+ return OUTCOME_COLORS.get(v, PALETTE["ACCENT"])
94
+
95
+ def _detect_dark() -> bool:
96
+ """Static report figures use the portable light palette by default."""
97
+ return False
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # 2. SHORT-NAME REGISTRY — never print a raw signal column on an axis/header.
102
+ # short() returns the display alias; full() / tooltip kept for hover.
103
+ # ---------------------------------------------------------------------------
104
+ SHORT_NAMES = {
105
+ "relative_attention_focus_share": "Attention focus",
106
+ "relative_attention_max_relative_weight":"Max attention",
107
+ "relative_attention_mean_relative_weight":"Mean attention",
108
+ "generated_probe1_false_detection": "Sanity check",
109
+ "low_focus_share": "High focus",
110
+ "probe1_positive": "Probe positive",
111
+ }
112
+
113
+ def short(name: str) -> str:
114
+ """Display alias. Falls back to a deterministic abbreviation."""
115
+ if name in SHORT_NAMES:
116
+ return SHORT_NAMES[name]
117
+ return display_name(name, compact=True)
118
+
119
+ def full(name: str) -> str:
120
+ hint = raw_hint(name)
121
+ label = display_name(name)
122
+ return f"{label} ({hint})" if hint else label
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # 3. NUMBER + BIN FORMATTING — one global precision policy, by quantity kind.
127
+ # ---------------------------------------------------------------------------
128
+ def fmt(x, kind: str = "effect") -> str:
129
+ """
130
+ kind: 'effect'|'corr' -> 2 dp (e.g. 0.87)
131
+ 'stat'|'p' -> 3 dp (e.g. 0.082)
132
+ 'pct' -> integer % (e.g. 41%)
133
+ 'count' -> integer
134
+ 'val' -> adaptive: 2 sig-ish, trims long floats
135
+ n=25 cannot support 4 dp — never print 0.8665.
136
+ """
137
+ if x is None or (isinstance(x, float) and math.isnan(x)):
138
+ return "—"
139
+ if kind in ("effect", "corr"):
140
+ return f"{x:.2f}"
141
+ if kind in ("stat", "p"):
142
+ return f"{x:.3f}"
143
+ if kind == "pct":
144
+ return f"{round(x * 100) if x <= 1 else round(x)}%"
145
+ if kind == "count":
146
+ return f"{int(round(x))}"
147
+ # 'val'
148
+ ax = abs(x)
149
+ if ax >= 100:
150
+ return f"{x:.0f}"
151
+ if ax >= 10:
152
+ return f"{x:.1f}"
153
+ return f"{x:.2f}"
154
+
155
+ def human_bins(edges) -> list[str]:
156
+ """
157
+ Turn pandas.cut float edges into readable labels.
158
+ [113.844, 233.066, 791.774] -> ['114–233', '233–792']
159
+ Picks decimals from the data range so small ranges keep precision.
160
+ """
161
+ edges = list(edges)
162
+ span = max(edges) - min(edges)
163
+ dec = 0 if span >= 10 else (1 if span >= 1 else 2)
164
+ def f(v):
165
+ return f"{v:.{dec}f}"
166
+ return [f"{f(a)}–{f(b)}" for a, b in zip(edges[:-1], edges[1:])]
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # 4. GLOBAL TEMPLATE — register once; every chart inherits font, grid, margins.
171
+ # ---------------------------------------------------------------------------
172
+ def apply(set_default: bool = True, dark: "bool | None" = None) -> str:
173
+ """Register the plotly template. *dark* picks the light/dark palette
174
+ variant; ``None`` auto-detects via Streamlit's theme context (see
175
+ :func:`_detect_dark`), defaulting to light outside Streamlit.
176
+
177
+ Mutates the module-level ``PALETTE``/``OUTCOME_COLORS`` dicts in place
178
+ (rather than rebinding them) so every chart builder — which all read
179
+ ``PALETTE["..."]`` live, not a captured snapshot — picks up the new
180
+ values immediately.
181
+ """
182
+ if dark is None:
183
+ dark = _detect_dark()
184
+ PALETTE.clear()
185
+ PALETTE.update(_DARK if dark else _LIGHT)
186
+ OUTCOME_COLORS.clear()
187
+ OUTCOME_COLORS.update({
188
+ "FAIL": PALETTE["FAIL"], "fail": PALETTE["FAIL"], 1: PALETTE["FAIL"],
189
+ "PASS": PALETTE["PASS"], "pass": PALETTE["PASS"], 0: PALETTE["PASS"],
190
+ })
191
+ tmpl = go.layout.Template()
192
+ tmpl.layout = go.Layout(
193
+ font=dict(family=FONT, size=13, color=PALETTE["TEXT"]),
194
+ title=dict(font=dict(size=15), x=0.0, xanchor="left"),
195
+ paper_bgcolor="rgba(0,0,0,0)",
196
+ plot_bgcolor="rgba(0,0,0,0)",
197
+ colorway=[PALETTE["ACCENT"], PALETTE["FAIL"], PALETTE["PASS"],
198
+ PALETTE["SIGNIFICANT"], PALETTE["INCONCLUSIVE"]],
199
+ margin=dict(l=64, r=24, t=48, b=72),
200
+ xaxis=dict(showgrid=False, zeroline=False, ticks="outside",
201
+ linecolor=PALETTE["AXIS"], tickcolor=PALETTE["AXIS"],
202
+ tickfont=dict(size=12), automargin=True),
203
+ yaxis=dict(showgrid=True, gridcolor=PALETTE["GRID"], zeroline=False,
204
+ linecolor="rgba(0,0,0,0)", tickfont=dict(size=12),
205
+ automargin=True),
206
+ # legend below the plot so it never collides with the left-aligned title
207
+ legend=dict(orientation="h", yanchor="top", y=-0.18,
208
+ xanchor="center", x=0.5, font=dict(size=12)),
209
+ )
210
+ pio.templates["evalrx"] = tmpl
211
+ if set_default:
212
+ pio.templates.default = "evalrx"
213
+ return "evalrx"
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # 5. SIZING — kills overflow clipping and the value/space inversion.
218
+ # Distribution + scatter get a full row; counts get compact.
219
+ # ---------------------------------------------------------------------------
220
+ SIZES = {
221
+ "full": dict(height=420, width=None), # use_container_width=True
222
+ "wide": dict(height=380, width=None), # scatter / joint
223
+ "compact": dict(height=300, width=None), # counts, single comparison
224
+ "strip": dict(height=300, width=None), # small-multiples cell
225
+ }
226
+
227
+ def _size(fig: go.Figure, kind: str) -> go.Figure:
228
+ s = SIZES.get(kind, SIZES["full"])
229
+ fig.update_layout(height=s["height"], autosize=True)
230
+ return fig
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # 6. CHART BUILDERS — each one encodes a "use this, not a bar" decision.
235
+ # ---------------------------------------------------------------------------
236
+
237
+ def violin_by_outcome(df, signal, outcome="label", points="all"):
238
+ """
239
+ REPLACES the two-bar 'mean by outcome'.
240
+ Shows the full distribution + every point — so bimodality and
241
+ outlier-driven means are visible instead of hidden. Use for any
242
+ continuous signal compared across FAIL/PASS.
243
+ """
244
+ fig = go.Figure()
245
+ for grp in ["FAIL", "PASS"]:
246
+ sub = df[df[outcome].astype(str).str.upper() == grp]
247
+ if sub.empty:
248
+ continue
249
+ fig.add_trace(go.Violin(
250
+ y=sub[signal], name=grp, line_color=outcome_color(grp),
251
+ fillcolor=outcome_color(grp), opacity=0.55, meanline_visible=True,
252
+ points=points, jitter=0.35, marker=dict(size=4, opacity=0.6),
253
+ box_visible=True, spanmode="hard",
254
+ ))
255
+ fig.update_layout(title=f"{short(signal)} by outcome · full {full(signal)}",
256
+ yaxis_title=short(signal), xaxis_title="", showlegend=False,
257
+ violingap=0.25)
258
+ return _size(fig, "full")
259
+
260
+
261
+ def forest_effects(rows, label_key="signal", effect_key="effect",
262
+ lo_key="ci_lo", hi_key="ci_hi", sig_key="significant",
263
+ leaky_key="leaky"):
264
+ """
265
+ REPLACES the green/grey effect-size bar chart.
266
+ Horizontal dot + CI (forest style): keeps the REJECT-H0 color coding AND
267
+ shows the confidence interval the bar chart dropped. Greys out any signal
268
+ flagged leaky so a target-leaking signal is never visually #1.
269
+ rows: list of dicts. ci_lo/ci_hi/leaky optional.
270
+ """
271
+ rows = sorted(rows, key=lambda r: r.get(effect_key, 0))
272
+ fig = go.Figure()
273
+ for r in rows:
274
+ leaky = r.get(leaky_key, False)
275
+ if leaky:
276
+ color = PALETTE["LEAKY"]
277
+ elif r.get(sig_key, True):
278
+ color = PALETTE["SIGNIFICANT"]
279
+ else:
280
+ color = PALETTE["INCONCLUSIVE"]
281
+ y = short(r[label_key]) + (" (leaky)" if leaky else "")
282
+ lo, hi = r.get(lo_key), r.get(hi_key)
283
+ if lo is not None and hi is not None:
284
+ fig.add_trace(go.Scatter(
285
+ x=[lo, hi], y=[y, y], mode="lines",
286
+ line=dict(color=color, width=3), showlegend=False,
287
+ hoverinfo="skip"))
288
+ fig.add_trace(go.Scatter(
289
+ x=[r[effect_key]], y=[y], mode="markers+text",
290
+ marker=dict(color=color, size=11),
291
+ text=[fmt(r[effect_key], "effect")], textposition="middle right",
292
+ textfont=dict(size=11), showlegend=False,
293
+ hovertext=full(r[label_key]), hoverinfo="text"))
294
+ fig.add_vline(x=0, line=dict(color=PALETTE["AXIS"], width=1, dash="dot"))
295
+ fig.update_layout(title="Effect size (failure association) — green = REJECT H₀",
296
+ xaxis_title="effect size", yaxis_title="")
297
+ return _size(fig, "full")
298
+
299
+
300
+ def logistic_failrate(df, signal, outcome="label", n_grid=80, band=True):
301
+ """
302
+ REPLACES 'Fail rate by <binned signal>' line charts.
303
+ Keeps the x-axis continuous and fits a logistic fail-rate curve with a
304
+ sample-density rug — no arbitrary bin edges, no fake monotone line.
305
+ """
306
+ x = df[signal].to_numpy(dtype=float)
307
+ y = (df[outcome].astype(str).str.upper() == "FAIL").to_numpy(dtype=float)
308
+ # simple logistic fit via numpy (no sklearn dependency)
309
+ b0, b1 = _fit_logistic(x, y)
310
+ gx = np.linspace(np.nanmin(x), np.nanmax(x), n_grid)
311
+ gy = 1 / (1 + np.exp(-(b0 + b1 * gx)))
312
+ fig = go.Figure()
313
+ fig.add_trace(go.Scatter(x=gx, y=gy, mode="lines",
314
+ line=dict(color=PALETTE["FAIL"], width=2.5),
315
+ name="P(FAIL)"))
316
+ # density rug
317
+ fig.add_trace(go.Scatter(x=x, y=np.full_like(x, -0.04),
318
+ mode="markers",
319
+ marker=dict(color=PALETTE["AXIS"], size=4,
320
+ opacity=0.4, symbol="line-ns-open"),
321
+ name="samples", hoverinfo="x"))
322
+ fig.update_layout(title=f"Fail rate vs {short(signal)} (logistic fit)",
323
+ xaxis_title=short(signal), yaxis_title="P(FAIL)",
324
+ yaxis=dict(range=[-0.08, 1.05]), showlegend=False)
325
+ return _size(fig, "full")
326
+
327
+
328
+ def joint_scatter(df, x_sig, y_sig, outcome="label"):
329
+ """
330
+ For two continuous signals: scatter colored by outcome WITH marginal
331
+ distributions. This is the chart that was clipped off-page — give it a
332
+ full row, not a narrow column.
333
+ """
334
+ d = df.copy()
335
+ d["_grp"] = d[outcome].astype(str).str.upper()
336
+ fig = px.scatter(d, x=x_sig, y=y_sig, color="_grp",
337
+ color_discrete_map={"FAIL": PALETTE["FAIL"], "PASS": PALETTE["PASS"]},
338
+ marginal_x="histogram", marginal_y="histogram",
339
+ labels={x_sig: short(x_sig), y_sig: short(y_sig), "_grp": "outcome"})
340
+ fig.update_traces(marker=dict(size=7, opacity=0.75),
341
+ selector=dict(type="scatter"))
342
+ fig.update_traces(marker=dict(size=7, opacity=0.75),
343
+ selector=dict(type="scattergl"))
344
+ fig.update_layout(title=f"{short(x_sig)} vs {short(y_sig)} by outcome",
345
+ legend_title_text="")
346
+ return _size(fig, "wide")
347
+
348
+
349
+ def composition_bar(labels, values, title="Class balance"):
350
+ """Parts-of-a-whole as ONE slim 100%-stacked horizontal bar — the right
351
+ encoding for a handful of category counts. Two tall bars for two numbers is
352
+ chart-junk (and usually redundant with the text); a single stacked strip
353
+ reads the proportion at a glance. Each segment labels its count + percent."""
354
+ total = sum(values) or 1
355
+ fig = go.Figure()
356
+ for lab, val in zip(labels, values):
357
+ fig.add_trace(go.Bar(
358
+ y=["balance"], x=[val], orientation="h", name=str(lab),
359
+ marker_color=outcome_color(lab),
360
+ text=f"{lab} {fmt(val, 'count')} ({fmt(val / total, 'pct')})",
361
+ textposition="inside", insidetextanchor="middle",
362
+ textfont=dict(color="white", size=13)))
363
+ fig.update_layout(barmode="stack", title=title, showlegend=False,
364
+ xaxis=dict(visible=False), yaxis=dict(visible=False),
365
+ height=120, margin=dict(l=8, r=8, t=44, b=8))
366
+ return fig
367
+
368
+
369
+ def counts_bar(df, outcome="label"):
370
+ """Discrete class counts → a single 100%-stacked composition strip (see
371
+ `composition_bar`); avoids two fat bars for two numbers."""
372
+ vc = df[outcome].astype(str).str.upper().value_counts()
373
+ order = [g for g in ["FAIL", "PASS"] if g in vc.index]
374
+ return composition_bar(order, [int(vc[g]) for g in order])
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # helpers
379
+ # ---------------------------------------------------------------------------
380
+ def _fit_logistic(x, y, iters=100, lr=None):
381
+ """Tiny IRLS-free gradient logistic fit; standardizes x for stability."""
382
+ mask = ~(np.isnan(x) | np.isnan(y))
383
+ x, y = x[mask], y[mask]
384
+ mu, sd = x.mean(), x.std() or 1.0
385
+ xs = (x - mu) / sd
386
+ b0, b1 = 0.0, 0.0
387
+ lr = lr or 0.3
388
+ for _ in range(iters):
389
+ z = b0 + b1 * xs
390
+ p = 1 / (1 + np.exp(-z))
391
+ g0 = np.mean(p - y)
392
+ g1 = np.mean((p - y) * xs)
393
+ b0 -= lr * g0
394
+ b1 -= lr * g1
395
+ # unstandardize back to raw-x coefficients
396
+ return b0 - b1 * mu / sd, b1 / sd
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # 7. STATISTICAL BUILDERS — distribution diagnostics, variable relationships,
401
+ # model evaluation, decision analysis. All numpy-only (no scipy/sklearn).
402
+ # Each takes a per-case tidy DataFrame (one row per case) + signal column(s).
403
+ # ---------------------------------------------------------------------------
404
+
405
+ def _norm_ppf(p):
406
+ """Inverse standard-normal CDF (Acklam's rational approximation). Vectorized."""
407
+ p = np.asarray(p, dtype=float)
408
+ a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
409
+ 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00]
410
+ b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
411
+ 6.680131188771972e+01, -1.328068155288572e+01]
412
+ c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
413
+ -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00]
414
+ d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
415
+ 3.754408661907416e+00]
416
+ plow, phigh = 0.02425, 1 - 0.02425
417
+ out = np.zeros_like(p)
418
+ lo, hi = p < plow, p > phigh
419
+ mid = ~(lo | hi)
420
+ ql = np.sqrt(-2 * np.log(np.clip(p[lo], 1e-16, 1)))
421
+ out[lo] = (((((c[0]*ql+c[1])*ql+c[2])*ql+c[3])*ql+c[4])*ql+c[5]) / ((((d[0]*ql+d[1])*ql+d[2])*ql+d[3])*ql+1)
422
+ qh = np.sqrt(-2 * np.log(np.clip(1 - p[hi], 1e-16, 1)))
423
+ out[hi] = -(((((c[0]*qh+c[1])*qh+c[2])*qh+c[3])*qh+c[4])*qh+c[5]) / ((((d[0]*qh+d[1])*qh+d[2])*qh+d[3])*qh+1)
424
+ qm = p[mid] - 0.5
425
+ rm = qm * qm
426
+ out[mid] = (((((a[0]*rm+a[1])*rm+a[2])*rm+a[3])*rm+a[4])*rm+a[5])*qm / (((((b[0]*rm+b[1])*rm+b[2])*rm+b[3])*rm+b[4])*rm+1)
427
+ return out
428
+
429
+
430
+ def _clean(df, signal, outcome="label"):
431
+ d = df[[signal, outcome]].copy()
432
+ d[signal] = pd.to_numeric(d[signal], errors="coerce")
433
+ return d.dropna()
434
+
435
+
436
+ def _groups(d, signal, outcome="label"):
437
+ out = {}
438
+ for grp in ("FAIL", "PASS"):
439
+ vals = d[d[outcome].astype(str).str.upper() == grp][signal].to_numpy(float)
440
+ if len(vals):
441
+ out[grp] = vals
442
+ return out
443
+
444
+
445
+ # ---- distribution diagnostics --------------------------------------------
446
+
447
+ def hist_by_outcome(df, signal, outcome="label", nbins=24):
448
+ """Overlaid FAIL/PASS histograms — the actual frequency shape, not a mean."""
449
+ d = _clean(df, signal, outcome)
450
+ fig = go.Figure()
451
+ for grp, vals in _groups(d, signal, outcome).items():
452
+ fig.add_trace(go.Histogram(x=vals, name=grp, nbinsx=nbins,
453
+ marker_color=outcome_color(grp), opacity=0.6))
454
+ fig.update_layout(barmode="overlay", title=f"{short(signal)} — distribution by outcome",
455
+ xaxis_title=short(signal), yaxis_title="count")
456
+ return _size(fig, "wide")
457
+
458
+
459
+ def kde_by_outcome(df, signal, outcome="label", n_grid=200):
460
+ """Gaussian-KDE density per outcome (Scott's bandwidth) — smooth shape +
461
+ bimodality without bin-edge artifacts."""
462
+ d = _clean(df, signal, outcome)
463
+ groups = _groups(d, signal, outcome)
464
+ allv = d[signal].to_numpy(float)
465
+ if allv.size < 2:
466
+ return hist_by_outcome(df, signal, outcome)
467
+ gx = np.linspace(np.nanmin(allv), np.nanmax(allv), n_grid)
468
+ fig = go.Figure()
469
+ for grp, vals in groups.items():
470
+ if len(vals) < 2:
471
+ continue
472
+ bw = vals.std(ddof=1) * (len(vals) ** (-1 / 5)) or 1.0
473
+ dens = np.exp(-0.5 * ((gx[:, None] - vals[None, :]) / bw) ** 2).sum(1) / (len(vals) * bw * np.sqrt(2 * np.pi))
474
+ fig.add_trace(go.Scatter(x=gx, y=dens, mode="lines", name=grp, fill="tozeroy",
475
+ line=dict(color=outcome_color(grp), width=2),
476
+ fillcolor=outcome_color(grp).replace(")", ",0.18)").replace("#", "rgba(") if False else None))
477
+ fig.data[-1].update(opacity=0.85)
478
+ fig.update_layout(title=f"{short(signal)} — density by outcome (KDE)",
479
+ xaxis_title=short(signal), yaxis_title="density")
480
+ return _size(fig, "wide")
481
+
482
+
483
+ def ecdf_by_outcome(df, signal, outcome="label"):
484
+ """Empirical CDF per outcome — reads exact quantiles; the gap between curves
485
+ is the distributional separation (a visual KS)."""
486
+ d = _clean(df, signal, outcome)
487
+ fig = go.Figure()
488
+ for grp, vals in _groups(d, signal, outcome).items():
489
+ xs = np.sort(vals)
490
+ ys = np.arange(1, len(xs) + 1) / len(xs)
491
+ fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", name=grp,
492
+ line=dict(color=outcome_color(grp), width=2, shape="hv")))
493
+ fig.update_layout(title=f"{short(signal)} — empirical CDF by outcome",
494
+ xaxis_title=short(signal), yaxis_title="cumulative proportion",
495
+ yaxis=dict(range=[0, 1.02]))
496
+ return _size(fig, "wide")
497
+
498
+
499
+ def qq_normal(df, signal, outcome="label"):
500
+ """Normal Q-Q plot (per outcome) — points on the line ⇒ ~normal; curvature ⇒
501
+ skew/heavy tails, the cue that a mean is the wrong summary."""
502
+ d = _clean(df, signal, outcome)
503
+ fig = go.Figure()
504
+ allmin, allmax = [], []
505
+ for grp, vals in _groups(d, signal, outcome).items():
506
+ v = np.sort(vals)
507
+ n = len(v)
508
+ if n < 3:
509
+ continue
510
+ z = _norm_ppf((np.arange(1, n + 1) - 0.5) / n)
511
+ theo = z * v.std(ddof=1) + v.mean()
512
+ fig.add_trace(go.Scatter(x=theo, y=v, mode="markers", name=grp,
513
+ marker=dict(color=outcome_color(grp), size=6, opacity=0.7)))
514
+ allmin.append(min(theo.min(), v.min()))
515
+ allmax.append(max(theo.max(), v.max()))
516
+ if allmin:
517
+ lo, hi = min(allmin), max(allmax)
518
+ fig.add_trace(go.Scatter(x=[lo, hi], y=[lo, hi], mode="lines", showlegend=False,
519
+ line=dict(color=PALETTE["AXIS"], width=1, dash="dot")))
520
+ fig.update_layout(title=f"{short(signal)} — normal Q–Q by outcome",
521
+ xaxis_title="theoretical quantile", yaxis_title="observed quantile")
522
+ return _size(fig, "wide")
523
+
524
+
525
+ # ---- variable relationships ----------------------------------------------
526
+
527
+ def corr_heatmap(df, signals, method="pearson"):
528
+ """Correlation matrix across signals — shows redundancy/structure the
529
+ per-signal-vs-label views miss. Diverging scale centered at 0."""
530
+ cols = [s for s in signals if s in df.columns]
531
+ M = df[cols].apply(pd.to_numeric, errors="coerce").corr(method=method)
532
+ labels = [short(c) for c in cols]
533
+ fig = go.Figure(go.Heatmap(
534
+ z=M.values, x=labels, y=labels, zmin=-1, zmax=1,
535
+ colorscale=[[0, PALETTE["DIVERGE_NEG"]], [0.5, PALETTE["DIVERGE_MID"]], [1, PALETTE["DIVERGE_POS"]]],
536
+ text=[[fmt(v, "corr") for v in row] for row in M.values],
537
+ texttemplate="%{text}", textfont=dict(size=11),
538
+ colorbar=dict(title="r", thickness=12)))
539
+ fig.update_layout(title=f"Signal correlation ({method})", xaxis_title="", yaxis_title="")
540
+ return _size(fig, "wide")
541
+
542
+
543
+ def bubble(df, x_sig, y_sig, size_sig, outcome="label"):
544
+ """Scatter with a third variable encoded as marker size."""
545
+ d = df[[x_sig, y_sig, size_sig, outcome]].apply(
546
+ lambda c: pd.to_numeric(c, errors="coerce") if c.name != outcome else c).dropna()
547
+ s = d[size_sig].to_numpy(float)
548
+ srange = (s - s.min()) / (np.ptp(s) or 1)
549
+ fig = go.Figure()
550
+ d["_g"] = d[outcome].astype(str).str.upper()
551
+ for grp in ("FAIL", "PASS"):
552
+ sub = d[d["_g"] == grp]
553
+ if sub.empty:
554
+ continue
555
+ ss = (srange[d["_g"].to_numpy() == grp]) * 28 + 6
556
+ fig.add_trace(go.Scatter(x=sub[x_sig], y=sub[y_sig], mode="markers", name=grp,
557
+ marker=dict(color=outcome_color(grp), size=ss, opacity=0.6,
558
+ line=dict(color="white", width=1))))
559
+ fig.update_layout(title=f"{short(x_sig)} vs {short(y_sig)} (size = {short(size_sig)})",
560
+ xaxis_title=short(x_sig), yaxis_title=short(y_sig))
561
+ return _size(fig, "wide")
562
+
563
+
564
+ def quadrant(df, x_sig, y_sig, outcome="label", x_split=None, y_split=None):
565
+ """Scatter split into four quadrants at (median, median) — turns two signals
566
+ into a decision map; quadrant fail-rates are annotated."""
567
+ d = df[[x_sig, y_sig, outcome]].apply(
568
+ lambda c: pd.to_numeric(c, errors="coerce") if c.name != outcome else c).dropna()
569
+ xv, yv = d[x_sig].to_numpy(float), d[y_sig].to_numpy(float)
570
+ xs = float(np.median(xv)) if x_split is None else x_split
571
+ ys = float(np.median(yv)) if y_split is None else y_split
572
+ fig = go.Figure()
573
+ d["_g"] = d[outcome].astype(str).str.upper()
574
+ for grp in ("FAIL", "PASS"):
575
+ sub = d[d["_g"] == grp]
576
+ if not sub.empty:
577
+ fig.add_trace(go.Scatter(x=sub[x_sig], y=sub[y_sig], mode="markers", name=grp,
578
+ marker=dict(color=outcome_color(grp), size=7, opacity=0.7)))
579
+ fig.add_vline(x=xs, line=dict(color=PALETTE["AXIS"], width=1, dash="dot"))
580
+ fig.add_hline(y=ys, line=dict(color=PALETTE["AXIS"], width=1, dash="dot"))
581
+ fig.update_layout(title=f"{short(x_sig)} × {short(y_sig)} quadrants (split at medians)",
582
+ xaxis_title=short(x_sig), yaxis_title=short(y_sig))
583
+ return _size(fig, "wide")
584
+
585
+
586
+ # ---- model evaluation -----------------------------------------------------
587
+
588
+ def _roc(score, y):
589
+ """ROC points + AUC for a continuous score vs binary label (1=positive)."""
590
+ score = np.asarray(score, float)
591
+ y = np.asarray(y, float)
592
+ m = ~(np.isnan(score) | np.isnan(y))
593
+ score, y = score[m], y[m]
594
+ P, N = y.sum(), (1 - y).sum()
595
+ if P == 0 or N == 0:
596
+ return np.array([0, 1]), np.array([0, 1]), 0.5
597
+ order = np.argsort(-score)
598
+ ys = y[order]
599
+ tpr = np.concatenate([[0], np.cumsum(ys) / P])
600
+ fpr = np.concatenate([[0], np.cumsum(1 - ys) / N])
601
+ auc = float(np.trapezoid(tpr, fpr)) if hasattr(np, "trapezoid") else float(np.trapz(tpr, fpr))
602
+ return fpr, tpr, auc
603
+
604
+
605
+ def roc_curves(df, signals, label_col="is_fail"):
606
+ """Overlaid ROC curves — does each signal discriminate FAIL? AUC in legend.
607
+ A signal with AUC≈1.0 that *is* the label re-measured is leakage, not skill."""
608
+ sigs = [s for s in (signals if isinstance(signals, (list, tuple)) else [signals]) if s in df.columns]
609
+ y = pd.to_numeric(df[label_col], errors="coerce").to_numpy(float)
610
+ fig = go.Figure()
611
+ fig.add_trace(go.Scatter(x=[0, 1], y=[0, 1], mode="lines", showlegend=False,
612
+ line=dict(color=PALETTE["AXIS"], width=1, dash="dot")))
613
+ palette = [PALETTE["ACCENT"], PALETTE["FAIL"], PALETTE["SIGNIFICANT"], PALETTE["PASS"], PALETTE["LEAKY"]]
614
+ for i, s in enumerate(sigs):
615
+ fpr, tpr, auc = _roc(pd.to_numeric(df[s], errors="coerce").to_numpy(float), y)
616
+ if auc < 0.5: # orient so the curve reads as discrimination strength
617
+ fpr, tpr, auc = _roc(-pd.to_numeric(df[s], errors="coerce").to_numpy(float), y)
618
+ name = f"{short(s)} (AUC {fmt(auc,'corr')}, inverted)"
619
+ else:
620
+ name = f"{short(s)} (AUC {fmt(auc,'corr')})"
621
+ fig.add_trace(go.Scatter(x=fpr, y=tpr, mode="lines", name=name,
622
+ line=dict(color=palette[i % len(palette)], width=2.5)))
623
+ fig.update_layout(title="ROC — signal discrimination of FAIL",
624
+ xaxis_title="false positive rate", yaxis_title="true positive rate",
625
+ xaxis=dict(range=[-0.02, 1.02]), yaxis=dict(range=[-0.02, 1.02]))
626
+ return _size(fig, "wide")
627
+
628
+
629
+ def confusion_matrix(y_true, y_pred, pos_label="Yes", neg_label="No", title="Confusion matrix"):
630
+ """2×2 confusion heatmap from already-binary truth/pred arrays (1=positive).
631
+ Cells annotated with counts; diagonal = correct."""
632
+ yt = np.asarray(y_true, float)
633
+ yp = np.asarray(y_pred, float)
634
+ m = ~(np.isnan(yt) | np.isnan(yp))
635
+ yt, yp = yt[m], yp[m]
636
+ tp = int(((yt == 1) & (yp == 1)).sum())
637
+ fp = int(((yt == 0) & (yp == 1)).sum())
638
+ fn = int(((yt == 1) & (yp == 0)).sum())
639
+ tn = int(((yt == 0) & (yp == 0)).sum())
640
+ # rows = predicted (Yes,No), cols = actual (Yes,No)
641
+ Z = [[tp, fp], [fn, tn]]
642
+ fig = go.Figure(go.Heatmap(
643
+ z=Z, x=[f"actual {pos_label}", f"actual {neg_label}"],
644
+ y=[f"pred {pos_label}", f"pred {neg_label}"],
645
+ colorscale=[[0, PALETTE["SURFACE"]], [1, PALETTE["ACCENT"]]],
646
+ text=[[f"TP {tp}", f"FP {fp}"], [f"FN {fn}", f"TN {tn}"]],
647
+ texttemplate="%{text}", textfont=dict(size=14), showscale=False))
648
+ fig.update_layout(title=title, xaxis_title="", yaxis_title="")
649
+ return _size(fig, "compact")
650
+
651
+
652
+ def _fit_logistic_std(x, y, iters=300, lr=0.3):
653
+ """Univariate logistic slope in *standardized-x* units (comparable across
654
+ signals). Returns the standardized coefficient b1."""
655
+ m = ~(np.isnan(x) | np.isnan(y))
656
+ x, y = x[m], y[m]
657
+ if len(x) < 3 or x.std() == 0:
658
+ return 0.0
659
+ xs = (x - x.mean()) / x.std()
660
+ b0, b1 = 0.0, 0.0
661
+ for _ in range(iters):
662
+ p = 1 / (1 + np.exp(-(b0 + b1 * xs)))
663
+ b0 -= lr * np.mean(p - y)
664
+ b1 -= lr * np.mean((p - y) * xs)
665
+ return b1
666
+
667
+
668
+ def coef_plot(df, signals, label_col="is_fail", n_boot=400, seed=0):
669
+ """Standardized univariate logistic coefficients (signal→FAIL) with bootstrap
670
+ 95% CI, forest-style. Comparable across signals; dotted 0 line = no effect."""
671
+ rng = np.random.default_rng(seed)
672
+ y = pd.to_numeric(df[label_col], errors="coerce").to_numpy(float)
673
+ rows = []
674
+ for s in signals:
675
+ if s not in df.columns:
676
+ continue
677
+ x = pd.to_numeric(df[s], errors="coerce").to_numpy(float)
678
+ m = ~(np.isnan(x) | np.isnan(y))
679
+ xm, ym = x[m], y[m]
680
+ if len(xm) < 5:
681
+ continue
682
+ b = _fit_logistic_std(xm, ym)
683
+ boots = []
684
+ idx = np.arange(len(xm))
685
+ for _ in range(n_boot):
686
+ bi = rng.choice(idx, len(idx), replace=True)
687
+ boots.append(_fit_logistic_std(xm[bi], ym[bi]))
688
+ lo, hi = np.percentile(boots, [2.5, 97.5])
689
+ rows.append({"signal": s, "effect": b, "ci_lo": lo, "ci_hi": hi,
690
+ "significant": (lo > 0) or (hi < 0)})
691
+ fig = forest_effects(rows)
692
+ fig.update_layout(title="Std. logistic coefficient (signal → FAIL) ± 95% CI",
693
+ xaxis_title="standardized log-odds per SD")
694
+ return fig
695
+
696
+
697
+ def calibration_curve(y_true, y_prob, n_bins=10, title="Calibration"):
698
+ """Reliability diagram: mean predicted probability vs observed frequency per
699
+ bin. Generic — pass model P(FAIL) once logit-capturing inference is run."""
700
+ yt = np.asarray(y_true, float)
701
+ yp = np.asarray(y_prob, float)
702
+ m = ~(np.isnan(yt) | np.isnan(yp))
703
+ yt, yp = yt[m], yp[m]
704
+ edges = np.linspace(0, 1, n_bins + 1)
705
+ xs, ys, ns = [], [], []
706
+ for lo, hi in zip(edges[:-1], edges[1:]):
707
+ sel = (yp >= lo) & (yp < hi if hi < 1 else yp <= hi)
708
+ if sel.sum():
709
+ xs.append(yp[sel].mean())
710
+ ys.append(yt[sel].mean())
711
+ ns.append(int(sel.sum()))
712
+ fig = go.Figure()
713
+ fig.add_trace(go.Scatter(x=[0, 1], y=[0, 1], mode="lines", showlegend=False,
714
+ line=dict(color=PALETTE["AXIS"], width=1, dash="dot")))
715
+ fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines+markers", name="observed",
716
+ line=dict(color=PALETTE["FAIL"], width=2.5),
717
+ marker=dict(size=[6 + 24 * n / max(ns) for n in ns] if ns else 8)))
718
+ fig.update_layout(title=title, xaxis_title="mean predicted probability",
719
+ yaxis_title="observed frequency",
720
+ xaxis=dict(range=[-0.02, 1.02]), yaxis=dict(range=[-0.02, 1.02]))
721
+ return _size(fig, "wide")
722
+
723
+
724
+ # ---- combined multi-signal comparisons (collapse small-multiples) ---------
725
+
726
+ def groupstats_strip(df, signals, outcome="label"):
727
+ """ONE chart for ALL signals' FAIL-vs-PASS group means, standardized (z-scored
728
+ on the pooled distribution) so different-scale signals share an axis. One row
729
+ per signal: a dumbbell from PASS mean to FAIL mean. Replaces N two-bar/dumbbell
730
+ panels (skill §2). Hover shows the raw means."""
731
+ fig = go.Figure()
732
+ drew_pass = drew_fail = False
733
+ for s in signals:
734
+ if s not in df.columns:
735
+ continue
736
+ x = pd.to_numeric(df[s], errors="coerce")
737
+ mu, sd = x.mean(), x.std(ddof=0)
738
+ if not sd or np.isnan(sd):
739
+ continue
740
+ z = (x - mu) / sd
741
+ lab = df[outcome].astype(str).str.upper()
742
+ fz = z[lab == "FAIL"].mean()
743
+ pz = z[lab == "PASS"].mean()
744
+ fr = x[lab == "FAIL"].mean()
745
+ pr = x[lab == "PASS"].mean()
746
+ y = short(s)
747
+ fig.add_trace(go.Scatter(x=[pz, fz], y=[y, y], mode="lines",
748
+ line=dict(color=PALETTE["AXIS"], width=2),
749
+ showlegend=False, hoverinfo="skip"))
750
+ fig.add_trace(go.Scatter(x=[pz], y=[y], mode="markers", name="PASS",
751
+ marker=dict(color=PALETTE["PASS"], size=13),
752
+ legendgroup="PASS", showlegend=not drew_pass,
753
+ hovertext=f"PASS mean {fmt(pr,'val')}", hoverinfo="text"))
754
+ fig.add_trace(go.Scatter(x=[fz], y=[y], mode="markers", name="FAIL",
755
+ marker=dict(color=PALETTE["FAIL"], size=13),
756
+ legendgroup="FAIL", showlegend=not drew_fail,
757
+ hovertext=f"FAIL mean {fmt(fr,'val')}", hoverinfo="text"))
758
+ drew_pass = drew_fail = True
759
+ fig.add_vline(x=0, line=dict(color=PALETTE["AXIS"], width=1, dash="dot"))
760
+ fig.update_layout(title="Group means by signal (standardized) — FAIL vs PASS",
761
+ xaxis_title="standardized mean (pooled z-score)", yaxis_title="")
762
+ return _size(fig, "wide")
763
+
764
+
765
+ def failrate_percentile(df, signals, label_col="is_fail", n_bins=5):
766
+ """ONE chart overlaying every signal's fail-rate curve on a shared x-axis of
767
+ within-signal percentile (so different units line up). Replaces N fail-rate-by-
768
+ bin panels; marker size ∝ bin n."""
769
+ y = pd.to_numeric(df[label_col], errors="coerce").to_numpy(float)
770
+ palette = [PALETTE["ACCENT"], PALETTE["FAIL"], PALETTE["SIGNIFICANT"], PALETTE["PASS"], PALETTE["LEAKY"]]
771
+ fig = go.Figure()
772
+ qmid = (np.arange(n_bins) + 0.5) / n_bins * 100
773
+ for i, s in enumerate(signals):
774
+ if s not in df.columns:
775
+ continue
776
+ x = pd.to_numeric(df[s], errors="coerce").to_numpy(float)
777
+ m = ~(np.isnan(x) | np.isnan(y))
778
+ xx, yy = x[m], y[m]
779
+ if len(xx) < n_bins:
780
+ continue
781
+ ranks = xx.argsort().argsort() / max(len(xx) - 1, 1) # 0..1 percentile
782
+ rates, ns = [], []
783
+ for b in range(n_bins):
784
+ sel = (ranks >= b / n_bins) & (ranks < (b + 1) / n_bins if b < n_bins - 1 else ranks <= 1.0)
785
+ rates.append(float(yy[sel].mean()) if sel.any() else np.nan)
786
+ ns.append(int(sel.sum()))
787
+ sizes = [8 + 22 * (n / max(ns)) for n in ns] if max(ns) else [10] * n_bins
788
+ fig.add_trace(go.Scatter(x=qmid, y=rates, mode="lines+markers", name=short(s),
789
+ line=dict(color=palette[i % len(palette)], width=2.5),
790
+ marker=dict(size=sizes, color=palette[i % len(palette)])))
791
+ fig.update_layout(title="Fail rate vs signal percentile (all signals)",
792
+ xaxis_title="within-signal percentile", yaxis_title="fail rate",
793
+ yaxis=dict(range=[-0.05, 1.05]))
794
+ return _size(fig, "wide")
795
+
796
+
797
+ # ---- decision analysis ----------------------------------------------------
798
+
799
+ def pareto(labels, values, title="Pareto — contribution by factor"):
800
+ """Bars (desc) + cumulative % line — ranks which factors account for most of
801
+ the total (the 'vital few')."""
802
+ pairs = sorted(zip(labels, [float(v) for v in values]), key=lambda t: -t[1])
803
+ labs = [short(str(lab)) for lab, _ in pairs]
804
+ vals = [v for _, v in pairs]
805
+ total = sum(vals) or 1.0
806
+ cum = np.cumsum(vals) / total * 100
807
+ fig = go.Figure()
808
+ fig.add_trace(go.Bar(x=labs, y=vals, marker_color=PALETTE["ACCENT"], name="count",
809
+ text=[fmt(v, "count") for v in vals], textposition="outside"))
810
+ fig.add_trace(go.Scatter(x=labs, y=cum, mode="lines+markers", name="cumulative %",
811
+ yaxis="y2", line=dict(color=PALETTE["FAIL"], width=2)))
812
+ fig.update_layout(title=title, xaxis_title="", yaxis_title="count",
813
+ yaxis2=dict(title="cumulative %", overlaying="y", side="right",
814
+ range=[0, 105], showgrid=False))
815
+ return _size(fig, "wide")
816
+
817
+
818
+ # ---------------------------------------------------------------------------
819
+ # matplotlib equivalent (only if a caller uses matplotlib instead of plotly)
820
+ # ---------------------------------------------------------------------------
821
+ def matplotlib_rcparams() -> dict:
822
+ return {
823
+ "font.family": "sans-serif",
824
+ "font.sans-serif": ["Inter", "Helvetica", "Arial"],
825
+ "font.size": 12,
826
+ "axes.edgecolor": PALETTE["AXIS"],
827
+ "axes.grid": True, "axes.grid.axis": "y",
828
+ "grid.color": PALETTE["GRID"], "grid.linewidth": 0.8,
829
+ "axes.spines.top": False, "axes.spines.right": False,
830
+ "figure.facecolor": "none", "axes.facecolor": "none",
831
+ "axes.titlesize": 14, "axes.titlelocation": "left",
832
+ "legend.frameon": False,
833
+ }