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,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
|
+
}
|