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
evalrx/viz/renderer.py
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""Host-side, deterministic chart rendering for exploratory artifacts.
|
|
2
|
+
|
|
3
|
+
The explorer (a CLI coding agent) only *proposes* chart specifications — a small
|
|
4
|
+
dict ``{name, kind, data, x, y, title}`` where ``data`` points to a CSV table it
|
|
5
|
+
wrote. This module renders those specs to PNG **on the host**, from the spec and
|
|
6
|
+
the CSV alone: it NEVER executes LLM-authored plotting code. That keeps the
|
|
7
|
+
visual layer auditable and reproducible (same spec + same CSV → same figure).
|
|
8
|
+
|
|
9
|
+
Rendering is best-effort and never raises into a pipeline:
|
|
10
|
+
- ``matplotlib`` missing → every spec is returned with a ``render_skipped`` note
|
|
11
|
+
and a free-text ``description``; callers fall back to the text.
|
|
12
|
+
- A spec that can't be rendered (missing CSV, unknown columns, empty table) is
|
|
13
|
+
returned annotated, not dropped — discovery output is preserved.
|
|
14
|
+
|
|
15
|
+
This is the single visualization core shared by every single-shot entry
|
|
16
|
+
(``evalrx explore``, the fused pipeline, and the in-loop M3 view).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import csv
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from evalrx.viz.labels import display_name
|
|
28
|
+
from evalrx.viz.style import (
|
|
29
|
+
NATURE_COLORS_FALLBACK,
|
|
30
|
+
SEMANTIC_PALETTE,
|
|
31
|
+
load_nature_style,
|
|
32
|
+
outcome_color,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_KINDS = {"bar", "line", "scatter", "timeseries", "forest"}
|
|
38
|
+
|
|
39
|
+
# Chart-type policy (agent-side counterpart: the eval-chart-style skill).
|
|
40
|
+
# "A bar's filled area means amount accumulated from zero — use bars only for
|
|
41
|
+
# counts." Rates, proportions, means and effect sizes must not be bars.
|
|
42
|
+
_COUNT_Y = re.compile(r"(count$|^n$|_n$|n_cases|ncases|num_|_num$|samples|draws|frequency|^total$)", re.I)
|
|
43
|
+
_EFFECT_Y = re.compile(r"(effect|separation|smd|odds|ratio|coefficient|importance|weight|score|mean|avg|average|delta|diff|lift|gain)", re.I)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_count_like(y: Any, ys: list[float]) -> bool:
|
|
47
|
+
"""True only when the y column plausibly holds raw counts."""
|
|
48
|
+
name = str(y or "")
|
|
49
|
+
if _EFFECT_Y.search(name):
|
|
50
|
+
return False # a mean/effect column is never a count even if integer-valued
|
|
51
|
+
if not _COUNT_Y.search(name):
|
|
52
|
+
return False
|
|
53
|
+
return all(float(v).is_integer() for v in ys)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _demote_bar(spec: dict[str, Any], y: Any, ys: list[float]) -> str:
|
|
57
|
+
"""Pick the policy-compliant kind a 'bar' spec must be rendered as instead."""
|
|
58
|
+
return "forest" if _EFFECT_Y.search(str(y or "")) else "line"
|
|
59
|
+
|
|
60
|
+
#: Column names that mean "this y is a count" (composition / count bars).
|
|
61
|
+
_COUNT_COLUMNS = {"count", "counts", "n", "n_cases", "cases", "num", "total", "freq", "frequency"}
|
|
62
|
+
#: Column names that carry the per-group / per-bin sample size.
|
|
63
|
+
_N_COLUMNS = ("n", "n_cases", "n_rows", "support", "cases", "count")
|
|
64
|
+
#: (low, high) column-name pairs the explorer may use for a 95% interval.
|
|
65
|
+
_CI_COLUMNS = (("ci_low", "ci_high"), ("ci_lo", "ci_hi"), ("ci95_low", "ci95_high"),
|
|
66
|
+
("lower", "upper"), ("lo", "hi"), ("ymin", "ymax"))
|
|
67
|
+
_RATE_WORDS = ("rate", "pct", "percent", "share", "frac", "fraction", "proportion", "prob")
|
|
68
|
+
#: Up to this many groups a group->value comparison is a dot + CI / lollipop,
|
|
69
|
+
#: never bars (eval-chart-style §0: a bar's area means "accumulated from 0").
|
|
70
|
+
_DOT_MAX_GROUPS = 3
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _count_like(y: Any, ys: list[float]) -> bool:
|
|
74
|
+
"""Count-valued y for the bar policy: a known count column (``count``,
|
|
75
|
+
``n``, ...), an ``n_<group>`` / ``num_<x>`` numerator column, or
|
|
76
|
+
:func:`_is_count_like`'s name test — and integer values in every case."""
|
|
77
|
+
name = str(y or "").lower()
|
|
78
|
+
if not ys or not all(float(v).is_integer() for v in ys):
|
|
79
|
+
return False
|
|
80
|
+
return name in _COUNT_COLUMNS or name.startswith(("n_", "num_")) or _is_count_like(y, ys)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def render_chart_specs(
|
|
84
|
+
charts: list[dict[str, Any]] | None,
|
|
85
|
+
tables_dir: str | Path | None,
|
|
86
|
+
out_dir: str | Path,
|
|
87
|
+
) -> list[dict[str, Any]]:
|
|
88
|
+
"""Render each chart spec to a PNG under *out_dir*, deterministically.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
charts: Explorer chart specs. Each is ``{name, kind, data, x, y,
|
|
92
|
+
title}`` where ``data`` is a CSV path (relative to
|
|
93
|
+
*tables_dir*, or absolute).
|
|
94
|
+
tables_dir: Directory the spec ``data`` CSVs live in (the explorer's
|
|
95
|
+
``tables/``). ``None`` resolves CSVs relative to *out_dir*'s
|
|
96
|
+
parent.
|
|
97
|
+
out_dir: Directory to write ``figures/`` PNGs into (created lazily).
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A new list of chart dicts, each a shallow copy of the input spec plus:
|
|
101
|
+
- ``figure_path``: absolute PNG path when rendered;
|
|
102
|
+
- ``description``: a one-line textual summary (always set);
|
|
103
|
+
- ``render_skipped``: reason string when no PNG was produced.
|
|
104
|
+
|
|
105
|
+
The input list is not mutated; ordering is preserved.
|
|
106
|
+
"""
|
|
107
|
+
specs = [dict(c) for c in (charts or []) if isinstance(c, dict)]
|
|
108
|
+
if not specs:
|
|
109
|
+
return specs
|
|
110
|
+
|
|
111
|
+
out_dir = Path(out_dir)
|
|
112
|
+
tdir = Path(tables_dir) if tables_dir else None
|
|
113
|
+
plt = _import_matplotlib()
|
|
114
|
+
style = load_nature_style()
|
|
115
|
+
|
|
116
|
+
rendered: list[dict[str, Any]] = []
|
|
117
|
+
for idx, spec in enumerate(specs):
|
|
118
|
+
rows, load_err = _load_table(spec.get("data"), tdir, out_dir)
|
|
119
|
+
x = spec.get("x")
|
|
120
|
+
y = spec.get("y")
|
|
121
|
+
|
|
122
|
+
if plt is None:
|
|
123
|
+
spec["render_skipped"] = "matplotlib not installed (pip install 'evalrx[viz]')"
|
|
124
|
+
spec.setdefault("description", _describe(spec, rows, x, y))
|
|
125
|
+
rendered.append(spec)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
ok, reason = _can_render(rows, x, y)
|
|
129
|
+
if not ok:
|
|
130
|
+
spec["render_skipped"] = load_err or reason
|
|
131
|
+
spec.setdefault("description", _describe(spec, rows, x, y))
|
|
132
|
+
rendered.append(spec)
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
png = _render_one(plt, spec, rows, x, y, out_dir, idx, style)
|
|
137
|
+
spec["figure_path"] = str(png)
|
|
138
|
+
spec.pop("render_skipped", None)
|
|
139
|
+
except Exception as exc: # rendering must never sink the caller
|
|
140
|
+
logger.warning("render_chart_specs: chart %d failed: %s", idx, exc)
|
|
141
|
+
spec["render_skipped"] = f"render error: {exc}"
|
|
142
|
+
# After rendering, so the caption names the form actually drawn.
|
|
143
|
+
spec.setdefault("description", _describe(spec, rows, x, y))
|
|
144
|
+
rendered.append(spec)
|
|
145
|
+
|
|
146
|
+
return rendered
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# internals
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _import_matplotlib():
|
|
154
|
+
try:
|
|
155
|
+
import matplotlib
|
|
156
|
+
|
|
157
|
+
matplotlib.use("Agg") # headless, no display needed
|
|
158
|
+
# Arial/Helvetica are usually absent on servers; DejaVu Sans is the
|
|
159
|
+
# deterministic fallback. Silence the per-figure findfont warning spam.
|
|
160
|
+
import logging as _logging
|
|
161
|
+
|
|
162
|
+
import matplotlib.pyplot as plt
|
|
163
|
+
_logging.getLogger("matplotlib.font_manager").setLevel(_logging.ERROR)
|
|
164
|
+
return plt
|
|
165
|
+
except Exception:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _load_table(
|
|
170
|
+
data: Any, tables_dir: Path | None, out_dir: Path
|
|
171
|
+
) -> tuple[list[dict[str, str]] | None, str]:
|
|
172
|
+
"""Resolve and read the spec's CSV into a list of row dicts.
|
|
173
|
+
|
|
174
|
+
Returns ``(rows, "")`` on success, ``(None, reason)`` otherwise.
|
|
175
|
+
"""
|
|
176
|
+
if not data:
|
|
177
|
+
return None, "no data table referenced"
|
|
178
|
+
if not isinstance(data, str):
|
|
179
|
+
# inline list-of-dicts table (rare; the explorer usually writes CSV)
|
|
180
|
+
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
181
|
+
return [{str(k): str(v) for k, v in r.items()} for r in data], ""
|
|
182
|
+
return None, "unsupported inline data shape"
|
|
183
|
+
|
|
184
|
+
path = Path(data)
|
|
185
|
+
candidates: list[Path] = []
|
|
186
|
+
if path.is_absolute():
|
|
187
|
+
candidates.append(path)
|
|
188
|
+
else:
|
|
189
|
+
if tables_dir is not None:
|
|
190
|
+
candidates.append(tables_dir / path) # data="tables/foo.csv"
|
|
191
|
+
candidates.append(tables_dir / path.name) # data="foo.csv"
|
|
192
|
+
candidates.append(tables_dir / "tables" / path.name) # tables_dir=sandbox
|
|
193
|
+
candidates.append(out_dir / path)
|
|
194
|
+
candidates.append(out_dir / "tables" / path.name)
|
|
195
|
+
candidates.append(out_dir.parent / path)
|
|
196
|
+
resolved = next((p for p in candidates if p.exists()), None)
|
|
197
|
+
if resolved is None:
|
|
198
|
+
return None, f"CSV not found: {data}"
|
|
199
|
+
if resolved.suffix.lower() != ".csv":
|
|
200
|
+
return None, f"not a CSV: {resolved.name}"
|
|
201
|
+
try:
|
|
202
|
+
# errors="replace" tolerates non-UTF-8 cells (VL logs carry latin-1/binary
|
|
203
|
+
# text); the broad except covers csv.Error (oversized field) so a malformed
|
|
204
|
+
# CSV degrades to a (None, reason) annotation instead of raising — the
|
|
205
|
+
# module's "never raises into a pipeline" contract.
|
|
206
|
+
with resolved.open("r", encoding="utf-8", errors="replace", newline="") as fh:
|
|
207
|
+
rows = list(csv.DictReader(fh))
|
|
208
|
+
except (OSError, UnicodeError, csv.Error) as exc:
|
|
209
|
+
return None, f"could not read {resolved.name}: {exc}"
|
|
210
|
+
if not rows:
|
|
211
|
+
return None, "empty CSV"
|
|
212
|
+
return rows, ""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _can_render(rows: list[dict[str, str]] | None, x: Any, y: Any) -> tuple[bool, str]:
|
|
216
|
+
if not rows:
|
|
217
|
+
return False, "no table data"
|
|
218
|
+
if not x or not y:
|
|
219
|
+
return False, "spec missing x or y column"
|
|
220
|
+
cols = rows[0].keys()
|
|
221
|
+
if x not in cols:
|
|
222
|
+
return False, f"x column {x!r} not in table"
|
|
223
|
+
if y not in cols:
|
|
224
|
+
return False, f"y column {y!r} not in table"
|
|
225
|
+
return True, ""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _to_float(value: str) -> float | None:
|
|
229
|
+
try:
|
|
230
|
+
return float(value)
|
|
231
|
+
except (TypeError, ValueError):
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _get_bar_colors(xs_raw: list[Any], default_palette: list[str]) -> list[str]:
|
|
236
|
+
"""Map categories to semantic palette colors (FAIL-red, PASS-green, else palette)."""
|
|
237
|
+
colors = []
|
|
238
|
+
for idx, label in enumerate(xs_raw):
|
|
239
|
+
s = str(label).strip().lower()
|
|
240
|
+
if any(w in s for w in ("fail", "broken", "error", "loss", "regression")):
|
|
241
|
+
colors.append("#d03b3b")
|
|
242
|
+
elif any(w in s for w in ("pass", "fixed", "cured", "correct", "gain", "survivor")):
|
|
243
|
+
colors.append("#0ca30c")
|
|
244
|
+
elif any(w in s for w in ("inconclusive", "warn", "unverified", "middle")):
|
|
245
|
+
colors.append("#fab219")
|
|
246
|
+
else:
|
|
247
|
+
colors.append(default_palette[idx % len(default_palette)])
|
|
248
|
+
return colors
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _render_one(plt, spec, rows, x, y, out_dir, idx, style) -> Path:
|
|
252
|
+
"""Draw one spec. The chart FORM follows the eval-chart-style policy, not
|
|
253
|
+
the spec's ``kind`` alone (the explorer may only say bar/line/scatter):
|
|
254
|
+
|
|
255
|
+
* ``kind=bar`` whose y is a count over a handful of classes (class
|
|
256
|
+
balance) -> ONE 100%-stacked composition strip;
|
|
257
|
+
* ``kind=bar`` comparing a rate/mean across <= 3 groups
|
|
258
|
+
-> horizontal dot + 95% CI (Wilson from ``n`` for a
|
|
259
|
+
rate, or the CSV's own ci_low/ci_high), lollipop
|
|
260
|
+
when no interval can be formed — never two bars;
|
|
261
|
+
* other bars -> bars, FAIL/PASS hues on outcome axes, n annotated;
|
|
262
|
+
* line / scatter -> as before, aliased axes, n annotated on lines.
|
|
263
|
+
|
|
264
|
+
Every PNG is deterministic (same spec + CSV -> byte-identical file) and the
|
|
265
|
+
chosen form is recorded in ``spec["rendered_as"]``.
|
|
266
|
+
"""
|
|
267
|
+
kind = str(spec.get("kind", "bar")).lower()
|
|
268
|
+
if kind not in _KINDS:
|
|
269
|
+
kind = "bar"
|
|
270
|
+
xs_raw = [r.get(x, "") for r in rows]
|
|
271
|
+
ys = [_to_float(r.get(y, "")) for r in rows]
|
|
272
|
+
keep = [i for i, yv in enumerate(ys) if yv is not None]
|
|
273
|
+
if not keep:
|
|
274
|
+
raise ValueError(f"y column {y!r} has no numeric values")
|
|
275
|
+
rows = [rows[i] for i in keep]
|
|
276
|
+
xs_raw = [xs_raw[i] for i in keep]
|
|
277
|
+
ys = [ys[i] for i in keep]
|
|
278
|
+
xs_num = [_to_float(v) for v in xs_raw]
|
|
279
|
+
x_is_num = all(v is not None for v in xs_num)
|
|
280
|
+
ns = _n_values(rows, y)
|
|
281
|
+
scale = _rate_scale(y, ys)
|
|
282
|
+
rate = scale is not None
|
|
283
|
+
rc = (style or {}).get("rc", {})
|
|
284
|
+
colors = (style or {}).get("colors") or NATURE_COLORS_FALLBACK
|
|
285
|
+
accent = SEMANTIC_PALETTE["ACCENT"]
|
|
286
|
+
title = str(spec.get("title") or spec.get("name") or f"chart_{idx}")
|
|
287
|
+
xlabel, ylabel = display_name(x), display_name(y)
|
|
288
|
+
spec["axis_labels"] = {"x": xlabel, "y": ylabel}
|
|
289
|
+
figures = out_dir / "figures"
|
|
290
|
+
figures.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
name = _safe_filename(spec.get("name") or spec.get("title") or f"chart_{idx}")
|
|
292
|
+
png = figures / f"{idx:02d}_{name}.png"
|
|
293
|
+
|
|
294
|
+
form = kind
|
|
295
|
+
if kind == "bar" and not x_is_num:
|
|
296
|
+
if _is_composition(spec, xs_raw, y, ys):
|
|
297
|
+
form = "composition"
|
|
298
|
+
elif 2 <= len(xs_raw) <= _DOT_MAX_GROUPS and str(y).lower() not in _COUNT_COLUMNS:
|
|
299
|
+
form = "dot_ci"
|
|
300
|
+
if kind == "bar" and form == "bar" and not _count_like(y, ys):
|
|
301
|
+
# Chart-type policy enforcement (eval-chart-style): a bar may only
|
|
302
|
+
# encode raw counts. A rate/mean/effect bar that is neither a class
|
|
303
|
+
# composition nor a <= 3-group comparison is demoted to the
|
|
304
|
+
# policy-compliant kind — line for binned rates, forest (horizontal
|
|
305
|
+
# dot plot) for ranked effects — so the rule holds even when the
|
|
306
|
+
# agent ignores it.
|
|
307
|
+
kind = _demote_bar(spec, y, ys)
|
|
308
|
+
form = kind
|
|
309
|
+
spec["kind"] = kind
|
|
310
|
+
spec["render_note"] = (
|
|
311
|
+
f"kind=bar demoted to {kind}: y column {str(y)!r} is not a count "
|
|
312
|
+
"(bars are for counts only — eval-chart-style policy)"
|
|
313
|
+
)
|
|
314
|
+
logger.warning(
|
|
315
|
+
"render_chart_specs: %r demoted bar -> %s (%r is not a count)",
|
|
316
|
+
spec.get("name"), kind, y,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Apply the nature-figure style in a scoped rc_context (no global leak; fully
|
|
320
|
+
# deterministic -> same spec + CSV yields byte-identical PNGs).
|
|
321
|
+
with plt.rc_context(rc):
|
|
322
|
+
if form == "composition":
|
|
323
|
+
fig, ax = plt.subplots(figsize=(6.4, 1.7))
|
|
324
|
+
_draw_composition(ax, xs_raw, ys, colors)
|
|
325
|
+
ax.set_title(title, fontweight="bold", loc="left")
|
|
326
|
+
elif form == "dot_ci":
|
|
327
|
+
fig, ax = plt.subplots(figsize=(6.4, 1.6 + 0.55 * len(xs_raw)))
|
|
328
|
+
form = _draw_dot_ci(ax, rows, xs_raw, ys, ns, scale, accent)
|
|
329
|
+
ax.set_xlabel(ylabel)
|
|
330
|
+
ax.set_title(title, fontweight="bold", loc="left")
|
|
331
|
+
elif kind == "forest":
|
|
332
|
+
# Horizontal dot plot for ranked effects / means: position encodes
|
|
333
|
+
# the value, no filled area faking "accumulated amount". Sorted so
|
|
334
|
+
# the strongest is on top.
|
|
335
|
+
fig, ax = plt.subplots(figsize=(6.4, 1.6 + 0.45 * len(xs_raw)))
|
|
336
|
+
order = sorted(range(len(ys)), key=lambda i: ys[i])
|
|
337
|
+
labels = [str(xs_raw[i]) for i in order]
|
|
338
|
+
vals = [ys[i] for i in order]
|
|
339
|
+
ypos = list(range(len(vals)))
|
|
340
|
+
ax.hlines(ypos, [min(0.0, v) for v in vals], vals,
|
|
341
|
+
color=SEMANTIC_PALETTE["GRID"], linewidth=1.8, zorder=2)
|
|
342
|
+
ax.axvline(0, color=SEMANTIC_PALETTE["AXIS"], linewidth=0.9, zorder=1)
|
|
343
|
+
pt_colors = [outcome_color(lab) or accent for lab in labels]
|
|
344
|
+
ax.scatter(vals, ypos, s=68, c=pt_colors, edgecolor="white",
|
|
345
|
+
linewidth=0.8, zorder=3)
|
|
346
|
+
has_neg = any(v < 0 for v in vals)
|
|
347
|
+
for yi, v in zip(ypos, vals):
|
|
348
|
+
ax.annotate(f"{v:+.2f}" if has_neg else f"{v:.2f}", (v, yi),
|
|
349
|
+
textcoords="offset points", xytext=(7, -3.5),
|
|
350
|
+
fontsize=8.5, color=SEMANTIC_PALETTE["TEXT"])
|
|
351
|
+
ax.set_yticks(ypos)
|
|
352
|
+
ax.set_yticklabels(labels)
|
|
353
|
+
ax.grid(axis="x", linewidth=0.6, alpha=0.25, zorder=0)
|
|
354
|
+
ax.set_axisbelow(True)
|
|
355
|
+
# The value axis is horizontal: swap the axis labels.
|
|
356
|
+
ax.set_xlabel(ylabel)
|
|
357
|
+
ax.set_ylabel(xlabel)
|
|
358
|
+
ax.set_title(title, fontweight="bold", loc="left")
|
|
359
|
+
else:
|
|
360
|
+
fig, ax = plt.subplots(figsize=(6.4, 4.0))
|
|
361
|
+
if kind == "scatter":
|
|
362
|
+
ax.scatter(xs_num if x_is_num else range(len(xs_raw)), ys, s=26,
|
|
363
|
+
color=accent, edgecolor="white", linewidth=0.4, zorder=3)
|
|
364
|
+
if not x_is_num:
|
|
365
|
+
ax.set_xticks(range(len(xs_raw)))
|
|
366
|
+
ax.set_xticklabels([str(v) for v in xs_raw], rotation=45, ha="right")
|
|
367
|
+
elif kind in {"line", "timeseries"}:
|
|
368
|
+
px = xs_num if x_is_num else list(range(len(xs_raw)))
|
|
369
|
+
ax.plot(px, ys, marker="o", color=accent, linewidth=1.8, markersize=5, zorder=3)
|
|
370
|
+
if not x_is_num:
|
|
371
|
+
ax.set_xticks(range(len(xs_raw)))
|
|
372
|
+
ax.set_xticklabels([str(v) for v in xs_raw], rotation=45, ha="right")
|
|
373
|
+
if ns is not None:
|
|
374
|
+
for xv, yv, nv in zip(px, ys, ns):
|
|
375
|
+
ax.annotate(f"n={nv:g}", (xv, yv), textcoords="offset points",
|
|
376
|
+
xytext=(0, 7), ha="center", fontsize=7,
|
|
377
|
+
color=SEMANTIC_PALETTE["AXIS"])
|
|
378
|
+
else: # bar (counts, or more than _DOT_MAX_GROUPS groups)
|
|
379
|
+
positions = list(range(len(xs_raw)))
|
|
380
|
+
bar_colors = [outcome_color(v) or accent for v in xs_raw]
|
|
381
|
+
ax.bar(positions, ys, color=bar_colors, width=0.72, zorder=3)
|
|
382
|
+
ax.set_xticks(positions)
|
|
383
|
+
ax.set_xticklabels([str(v) for v in xs_raw], rotation=45, ha="right")
|
|
384
|
+
is_count = str(y).lower() in _COUNT_COLUMNS
|
|
385
|
+
for pos, yv, nv in zip(positions, ys, ns or [None] * len(ys)):
|
|
386
|
+
label = (f"{yv:g}" if is_count else (f"n={nv:g}" if nv is not None else ""))
|
|
387
|
+
if label:
|
|
388
|
+
ax.annotate(label, (pos, yv), textcoords="offset points",
|
|
389
|
+
xytext=(0, 3), ha="center", fontsize=7,
|
|
390
|
+
color=SEMANTIC_PALETTE["AXIS"])
|
|
391
|
+
if kind in {"bar", "line", "timeseries"}:
|
|
392
|
+
ax.grid(axis="y", linewidth=0.6, alpha=0.25, zorder=0)
|
|
393
|
+
ax.set_axisbelow(True)
|
|
394
|
+
if rate and kind != "scatter":
|
|
395
|
+
from matplotlib.ticker import PercentFormatter
|
|
396
|
+
ax.yaxis.set_major_formatter(PercentFormatter(scale, decimals=0))
|
|
397
|
+
ax.set_xlabel(xlabel)
|
|
398
|
+
ax.set_ylabel(ylabel)
|
|
399
|
+
ax.set_title(title, fontweight="bold")
|
|
400
|
+
fig.tight_layout()
|
|
401
|
+
fig.savefig(png, dpi=160, metadata={"Software": "evalrx", "Creation Time": None})
|
|
402
|
+
plt.close(fig)
|
|
403
|
+
spec["rendered_as"] = form
|
|
404
|
+
return png
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# -- chart-form helpers -------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
def _n_values(rows, y) -> "list[float] | None":
|
|
410
|
+
"""Per-row sample size from an ``n``-like column (not the y column itself)."""
|
|
411
|
+
cols = rows[0].keys() if rows else []
|
|
412
|
+
for cand in _N_COLUMNS:
|
|
413
|
+
if cand in cols and cand != y:
|
|
414
|
+
vals = [_to_float(r.get(cand, "")) for r in rows]
|
|
415
|
+
if all(v is not None for v in vals):
|
|
416
|
+
return vals
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _ci_values(rows) -> "tuple[list[float], list[float]] | None":
|
|
421
|
+
cols = rows[0].keys() if rows else []
|
|
422
|
+
for lo, hi in _CI_COLUMNS:
|
|
423
|
+
if lo in cols and hi in cols:
|
|
424
|
+
los = [_to_float(r.get(lo, "")) for r in rows]
|
|
425
|
+
his = [_to_float(r.get(hi, "")) for r in rows]
|
|
426
|
+
if all(v is not None for v in los + his):
|
|
427
|
+
return los, his
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _rate_scale(y, ys) -> "float | None":
|
|
432
|
+
"""1.0 when *y* is a fraction-valued rate column, 100.0 when it is a
|
|
433
|
+
percent-valued one, ``None`` when it is not a rate (counts, means of
|
|
434
|
+
unbounded quantities, effect sizes)."""
|
|
435
|
+
name = str(y).lower()
|
|
436
|
+
if name in _COUNT_COLUMNS or not any(w in name for w in _RATE_WORDS):
|
|
437
|
+
return None
|
|
438
|
+
if all(0.0 <= v <= 1.0 for v in ys):
|
|
439
|
+
return 1.0
|
|
440
|
+
if all(0.0 <= v <= 100.0 for v in ys) and ("pct" in name or "percent" in name):
|
|
441
|
+
return 100.0
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _numerator_values(rows, y, ns) -> "list[float] | None":
|
|
446
|
+
"""The successes column behind a rate (``n_fail``, ``n_with_audit``, ``k``,
|
|
447
|
+
...): any ``n_<something>`` / ``k`` / ``successes`` column whose values never
|
|
448
|
+
exceed ``n``. Lets the host form a Wilson interval from the data instead of
|
|
449
|
+
guessing one."""
|
|
450
|
+
if ns is None or not rows:
|
|
451
|
+
return None
|
|
452
|
+
for col in rows[0].keys():
|
|
453
|
+
low = col.lower()
|
|
454
|
+
if col == y or low in _N_COLUMNS or low in _COUNT_COLUMNS:
|
|
455
|
+
continue
|
|
456
|
+
if not (low.startswith("n_") or low in ("k", "successes", "hits", "events")):
|
|
457
|
+
continue
|
|
458
|
+
vals = [_to_float(r.get(col, "")) for r in rows]
|
|
459
|
+
if all(v is not None and 0 <= v <= n for v, n in zip(vals, ns)):
|
|
460
|
+
return vals
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _is_composition(spec, xs_raw, y, ys) -> bool:
|
|
465
|
+
"""Class balance: a count per class over a handful of classes."""
|
|
466
|
+
if str(y).lower() not in _COUNT_COLUMNS or not (2 <= len(xs_raw) <= 6):
|
|
467
|
+
return False
|
|
468
|
+
if any(v < 0 for v in ys) or sum(ys) <= 0:
|
|
469
|
+
return False
|
|
470
|
+
text = f"{spec.get('name', '')} {spec.get('title', '')}".lower()
|
|
471
|
+
outcome_axis = all(outcome_color(v) is not None for v in xs_raw)
|
|
472
|
+
return outcome_axis or "balance" in text or "composition" in text
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _wilson(k: float, n: float, z: float = 1.959964) -> "tuple[float, float]":
|
|
476
|
+
"""Wilson score interval for k successes in n trials."""
|
|
477
|
+
if n <= 0:
|
|
478
|
+
return 0.0, 1.0
|
|
479
|
+
p = k / n
|
|
480
|
+
denom = 1 + z * z / n
|
|
481
|
+
centre = (p + z * z / (2 * n)) / denom
|
|
482
|
+
half = z * ((p * (1 - p) / n + z * z / (4 * n * n)) ** 0.5) / denom
|
|
483
|
+
return max(0.0, centre - half), min(1.0, centre + half)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _draw_composition(ax, labels, values, colors) -> None:
|
|
487
|
+
total = float(sum(values)) or 1.0
|
|
488
|
+
left = 0.0
|
|
489
|
+
for i, (lab, val) in enumerate(zip(labels, values)):
|
|
490
|
+
color = outcome_color(lab) or colors[i % len(colors)]
|
|
491
|
+
ax.barh([0], [val], left=left, color=color, height=0.6, zorder=3)
|
|
492
|
+
frac = val / total
|
|
493
|
+
text = f"{lab} {val:g} ({round(frac * 100)}%)"
|
|
494
|
+
if frac >= 0.14:
|
|
495
|
+
ax.text(left + val / 2, 0, text, ha="center", va="center",
|
|
496
|
+
color="white", fontsize=8.5, fontweight="bold")
|
|
497
|
+
else:
|
|
498
|
+
ax.text(left + val / 2, 0.42, text, ha="center", va="bottom",
|
|
499
|
+
color=SEMANTIC_PALETTE["TEXT"], fontsize=7.5)
|
|
500
|
+
left += val
|
|
501
|
+
ax.set_xlim(0, total)
|
|
502
|
+
ax.set_ylim(-0.6, 0.9)
|
|
503
|
+
ax.axis("off")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _draw_dot_ci(ax, rows, labels, values, ns, scale, accent) -> str:
|
|
507
|
+
"""Horizontal dot (+ 95% CI when one can be formed) per group; stem from 0.
|
|
508
|
+
|
|
509
|
+
The interval comes from the CSV's own ``ci_low``/``ci_high`` when present,
|
|
510
|
+
else — for a RATE with a numerator column (``n_fail``, ``n_with_audit``,
|
|
511
|
+
``k`` ...) and ``n`` — from a Wilson score interval on those counts. A
|
|
512
|
+
``mean_*`` of per-case values is not a binomial proportion, so no interval
|
|
513
|
+
is invented for it: the dot stands alone (lollipop) with its n. Returns the
|
|
514
|
+
form actually drawn: ``"dot_ci"`` or ``"lollipop"``."""
|
|
515
|
+
rate = scale is not None
|
|
516
|
+
ci = _ci_values(rows)
|
|
517
|
+
if ci is None and rate:
|
|
518
|
+
ks = _numerator_values(rows, None, ns)
|
|
519
|
+
if ks is not None:
|
|
520
|
+
los, his = [], []
|
|
521
|
+
for k, n in zip(ks, ns):
|
|
522
|
+
lo, hi = _wilson(k, n)
|
|
523
|
+
los.append(lo * scale)
|
|
524
|
+
his.append(hi * scale)
|
|
525
|
+
ci = (los, his)
|
|
526
|
+
form = "dot_ci" if ci is not None else "lollipop"
|
|
527
|
+
ypos = list(range(len(labels)))[::-1] # first CSV row on top
|
|
528
|
+
for yp, lab, val in zip(ypos, labels, values):
|
|
529
|
+
color = outcome_color(lab) or accent
|
|
530
|
+
ax.plot([0, val], [yp, yp], color=SEMANTIC_PALETTE["GRID"], linewidth=2.2, zorder=2)
|
|
531
|
+
if ci is not None:
|
|
532
|
+
i = labels.index(lab)
|
|
533
|
+
ax.plot([ci[0][i], ci[1][i]], [yp, yp], color=color, linewidth=1.6, zorder=3,
|
|
534
|
+
solid_capstyle="butt")
|
|
535
|
+
ax.plot([ci[0][i], ci[0][i]], [yp - 0.12, yp + 0.12], color=color, linewidth=1.2)
|
|
536
|
+
ax.plot([ci[1][i], ci[1][i]], [yp - 0.12, yp + 0.12], color=color, linewidth=1.2)
|
|
537
|
+
ax.scatter([val], [yp], s=60, color=color, edgecolor="white", linewidth=0.8, zorder=4)
|
|
538
|
+
shown = f"{round(val * 100 / scale)}%" if rate else f"{val:.2f}"
|
|
539
|
+
if ns is not None:
|
|
540
|
+
shown += f" (n={ns[labels.index(lab)]:g})"
|
|
541
|
+
ax.annotate(shown, (val, yp), textcoords="offset points", xytext=(0, 9),
|
|
542
|
+
ha="center", fontsize=7.5, color=SEMANTIC_PALETTE["TEXT"])
|
|
543
|
+
ax.set_yticks(ypos)
|
|
544
|
+
ax.set_yticklabels([str(v) for v in labels])
|
|
545
|
+
ax.set_ylim(-0.7, len(labels) - 0.3)
|
|
546
|
+
hi = max([v for v in values] + (list(ci[1]) if ci is not None else []))
|
|
547
|
+
lo = min([0.0] + [v for v in values] + (list(ci[0]) if ci is not None else []))
|
|
548
|
+
span = (hi - lo) or 1.0
|
|
549
|
+
ax.set_xlim(min(0.0, lo - 0.05 * span), hi + 0.18 * span)
|
|
550
|
+
ax.grid(axis="x", linewidth=0.6, alpha=0.25, zorder=0)
|
|
551
|
+
ax.set_axisbelow(True)
|
|
552
|
+
ax.spines["left"].set_visible(False)
|
|
553
|
+
ax.tick_params(axis="y", length=0)
|
|
554
|
+
if rate:
|
|
555
|
+
from matplotlib.ticker import PercentFormatter
|
|
556
|
+
ax.xaxis.set_major_formatter(PercentFormatter(scale, decimals=0))
|
|
557
|
+
return form
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _safe_filename(name: Any) -> str:
|
|
561
|
+
text = "".join(ch if (ch.isalnum() or ch in "-_") else "_" for ch in str(name)).strip("_")
|
|
562
|
+
return (text or "chart")[:48]
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
_FORM_WORDS = {
|
|
566
|
+
"composition": "composition strip of",
|
|
567
|
+
"dot_ci": "dot + 95% CI of",
|
|
568
|
+
"lollipop": "dot (lollipop) of",
|
|
569
|
+
"forest": "forest plot (ranked dots) of",
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _describe(spec: dict[str, Any], rows, x, y) -> str:
|
|
574
|
+
"""One-line textual summary of a chart, used when the image can't render
|
|
575
|
+
and as the caption M3 sees alongside the attached PNG. Names the FORM the
|
|
576
|
+
host actually drew when that is known (``rendered_as``)."""
|
|
577
|
+
existing = spec.get("description")
|
|
578
|
+
if isinstance(existing, str) and existing.strip():
|
|
579
|
+
return existing.strip()
|
|
580
|
+
kind = str(spec.get("kind", "bar")).lower()
|
|
581
|
+
form = _FORM_WORDS.get(str(spec.get("rendered_as", "")), f"{kind} of")
|
|
582
|
+
title = str(spec.get("title") or spec.get("name") or "chart")
|
|
583
|
+
if x and y:
|
|
584
|
+
body = f"{form} {y} by {x}"
|
|
585
|
+
elif x:
|
|
586
|
+
body = f"{kind} over {x}"
|
|
587
|
+
else:
|
|
588
|
+
body = f"{kind} chart"
|
|
589
|
+
n = len(rows) if rows else 0
|
|
590
|
+
return f"{title}: {body}" + (f" ({n} rows)" if n else "")
|
evalrx/viz/schema.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Typed contracts for EvalRX exploratory visualization artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, TypedDict
|
|
6
|
+
|
|
7
|
+
ChartKind = Literal["bar", "line", "scatter", "timeseries"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChartSpec(TypedDict, total=False):
|
|
11
|
+
name: str
|
|
12
|
+
kind: ChartKind
|
|
13
|
+
data: str | list[dict[str, Any]]
|
|
14
|
+
x: str
|
|
15
|
+
y: str
|
|
16
|
+
title: str
|
|
17
|
+
description: str
|
|
18
|
+
figure_path: str
|
|
19
|
+
render_skipped: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VisualPlan(TypedDict, total=False):
|
|
23
|
+
name: str
|
|
24
|
+
question: str
|
|
25
|
+
data_shape: str
|
|
26
|
+
plot_kind: str
|
|
27
|
+
fallback_kind: ChartKind
|
|
28
|
+
required_columns: list[str]
|
|
29
|
+
rationale: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ChartReading(TypedDict, total=False):
|
|
33
|
+
chart: str
|
|
34
|
+
reading: str
|
|
35
|
+
do_not_infer: str
|
|
36
|
+
|