diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index f3596f77..11d1e43f 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -52,14 +52,14 @@ jobs: prefix = "rtichoke/_vendor/rtichoke_viz/" required = { f"{prefix}VENDORED_FROM", - f"{prefix}rtichoke-viz-0.6.0.tar.gz", + f"{prefix}rtichoke-viz-0.7.0.tar.gz", f"{prefix}rtichoke-viz.js", f"{prefix}rtichoke-viz.css", f"{prefix}rtichoke-viz.schema.json", f"{prefix}rtichoke-viz-v2.schema.json", } assert required <= names - assert f"{prefix}rtichoke-viz-0.5.0.tar.gz" not in names + assert f"{prefix}rtichoke-viz-0.6.0.tar.gz" not in names PY - name: Run tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 13252445..6643a5c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ +- Added opt-in canonical browser rendering for static Interventions Avoided using the verified `rtichoke_viz v0.7.0` release while preserving Plotly as the default. - Fixed Interventions Avoided to apply the per-100 scaling to the full model expression, including the false-negative penalty term. ## v0.1.36 (21/08/2026) @@ -19,4 +20,4 @@ ## v0.1.0 (27/01/2023) -- First release of `rtichoke`! \ No newline at end of file +- First release of `rtichoke`! diff --git a/src/rtichoke/_interventions_avoided_viz_spec_v2.py b/src/rtichoke/_interventions_avoided_viz_spec_v2.py new file mode 100644 index 00000000..e8b1bfd9 --- /dev/null +++ b/src/rtichoke/_interventions_avoided_viz_spec_v2.py @@ -0,0 +1,179 @@ +"""Canonical static Interventions Avoided v2 adapter. + +This module translates already-computed production Interventions Avoided +quantities into the shared rtichoke_viz contract. It deliberately does not +recompute model statistics or threshold membership. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +import polars as pl + +from rtichoke.processing.evaluation_semantics import _EvaluationMetadata + +_REQUIRED_COLUMNS = { + "reference_group", + "chosen_cutoff", + "net_benefit_interventions_avoided", + "real_positives", + "n", +} + + +def _interventions_avoided_v2_spec_from_performance_data( + performance_data: pl.DataFrame, + evaluation_metadata: Mapping[str, _EvaluationMetadata], + *, + min_p_threshold: float = 0.0, + max_p_threshold: float = 1.0, +) -> dict[str, object]: + """Build canonical static Interventions Avoided from production quantities.""" + missing = _REQUIRED_COLUMNS.difference(performance_data.columns) + if missing: + raise ValueError( + "Interventions Avoided performance data is missing columns: " + + ", ".join(sorted(missing)) + ) + + rows = ( + performance_data.filter( + pl.col("chosen_cutoff").is_finite() + & pl.col("net_benefit_interventions_avoided").is_finite() + ) + .select( + "reference_group", + "chosen_cutoff", + "net_benefit_interventions_avoided", + "real_positives", + "n", + ) + .to_dicts() + ) + + row_groups = {str(row["reference_group"]) for row in rows} + missing_metadata = row_groups.difference(evaluation_metadata) + if missing_metadata: + raise ValueError( + "Interventions Avoided rows are missing evaluation metadata: " + + ", ".join(sorted(missing_metadata)) + ) + + ordered_groups = [group for group in evaluation_metadata if group in row_groups] + evaluation_ids = { + group: f"evaluation-{index}" + for index, group in enumerate(ordered_groups, start=1) + } + series_ids = { + group: f"series-{index}" for index, group in enumerate(ordered_groups, start=1) + } + + evaluations: list[dict[str, object]] = [] + series: list[dict[str, object]] = [] + for group in ordered_groups: + metadata = evaluation_metadata[group] + evaluation: dict[str, object] = { + "id": evaluation_ids[group], + "population": metadata.population, + } + if metadata.model is not None: + evaluation["model"] = metadata.model + evaluations.append(evaluation) + + display_value = metadata.model or metadata.population + series.append( + { + "id": series_ids[group], + "evaluationId": evaluation_ids[group], + "display": { + "label": display_value, + "group": display_value, + "role": "model" if metadata.model is not None else "population", + }, + } + ) + + data = [ + { + "seriesId": series_ids[str(row["reference_group"])], + "threshold": float(row["chosen_cutoff"]), + "interventionsAvoided": float(row["net_benefit_interventions_avoided"]), + } + for row in rows + ] + + prevalence_values: dict[str, set[float]] = {} + population_thresholds: dict[str, list[float]] = {} + for row in rows: + group = str(row["reference_group"]) + population = evaluation_metadata[group].population + n = float(row["n"]) + if n <= 0: + raise ValueError("Interventions Avoided population size must be positive.") + prevalence_values.setdefault(population, set()).add( + float(row["real_positives"]) / n + ) + threshold = float(row["chosen_cutoff"]) + if 0.0 < threshold <= 1.0: + population_thresholds.setdefault(population, []).append(threshold) + + populations = list( + dict.fromkeys(metadata.population for metadata in evaluation_metadata.values()) + ) + references: list[dict[str, object]] = [ + { + "type": "horizontal", + "scope": "global", + "value": 0.0, + "label": "Treat All", + "benchmark": "treat_all", + } + ] + for population in populations: + values = prevalence_values.get(population, set()) + if not values: + continue + if len(values) != 1: + raise ValueError( + f"Population {population!r} has inconsistent prevalence values." + ) + prevalence = next(iter(values)) + thresholds = sorted(set(population_thresholds.get(population, []))) + references.append( + { + "type": "path", + "scope": "population", + "population": population, + "label": f"Treat None — {population}", + "benchmark": "treat_none", + "points": [ + { + "x": threshold, + "y": 100.0 + * ( + 1.0 + - prevalence + - prevalence * (1.0 - threshold) / threshold + ), + } + for threshold in thresholds + ], + } + ) + + return { + "schemaVersion": "2.0", + "type": "interventions_avoided", + "evaluations": evaluations, + "series": series, + "data": data, + "x": "threshold", + "y": "interventionsAvoided", + "xAxis": { + "label": "Probability Threshold", + "domain": [min_p_threshold, max_p_threshold], + }, + "yAxis": {"label": "Interventions Avoided (per 100)"}, + "references": references, + } diff --git a/src/rtichoke/_renderers.py b/src/rtichoke/_renderers.py index 3e9970f4..fae719e0 100644 --- a/src/rtichoke/_renderers.py +++ b/src/rtichoke/_renderers.py @@ -56,6 +56,7 @@ def write_html(self, path: str | Path) -> Path: "gains": "renderGainsV2", "lift": "renderLiftV2", "decision_curve": "renderDecisionCurveV2", + "interventions_avoided": "renderInterventionsAvoidedV2", }.get(str(self.spec.get("type"))) if render_export is None: raise ValueError( diff --git a/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM b/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM index ab6a6962..59558db7 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM +++ b/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM @@ -1,5 +1,5 @@ repository=https://github.com/uriahf/rtichoke_viz -release=v0.6.0 -source_commit=3abb3f07a598c3e22d5362a3f88e52bb6b52b083 -archive=rtichoke-viz-0.6.0.tar.gz -sha256=625613c7f692ff50b7757a27bb6caf84e311971bde92593141393dbd897af3a2 +release=v0.7.0 +source_commit=b3564d2824ec1791f791fda406c99b3d7865a68f +archive=rtichoke-viz-0.7.0.tar.gz +sha256=f09c30e231a8be39c2e89ba6ae39c90ed8cab67021213e17681a475066a9806e diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.6.0.tar.gz b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.6.0.tar.gz deleted file mode 100644 index 4e948e67..00000000 Binary files a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.6.0.tar.gz and /dev/null differ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.7.0.tar.gz b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.7.0.tar.gz new file mode 100644 index 00000000..f44a1f2f Binary files /dev/null and b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.7.0.tar.gz differ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json index 378b67b2..a278d988 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json @@ -3579,6 +3579,768 @@ } } ] + }, + { + "type": "object", + "allOf": [ + { + "type": "object", + "required": [ + "schemaVersion", + "evaluations", + "series", + "xAxis", + "yAxis" + ], + "properties": { + "schemaVersion": { + "const": "2.0", + "type": "string" + }, + "title": { + "type": "string" + }, + "evaluations": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "population" + ], + "properties": { + "id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "population": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + }, + "series": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "evaluationId", + "display" + ], + "properties": { + "id": { + "type": "string" + }, + "evaluationId": { + "type": "string" + }, + "horizon": { + "minimum": 0, + "type": "number" + }, + "display": { + "type": "object", + "required": [ + "label", + "group", + "role" + ], + "properties": { + "label": { + "type": "string" + }, + "group": { + "type": "string" + }, + "role": { + "anyOf": [ + { + "const": "model", + "type": "string" + }, + { + "const": "population", + "type": "string" + }, + { + "const": "evaluation", + "type": "string" + }, + { + "const": "context", + "type": "string" + } + ] + } + } + } + } + } + }, + "xAxis": { + "type": "object", + "required": [ + "label" + ], + "properties": { + "label": { + "type": "string" + }, + "domain": { + "type": "array", + "items": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "additionalItems": false, + "minItems": 2, + "maxItems": 2 + } + } + }, + "yAxis": { + "type": "object", + "required": [ + "label" + ], + "properties": { + "label": { + "type": "string" + }, + "domain": { + "type": "array", + "items": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "additionalItems": false, + "minItems": 2, + "maxItems": 2 + } + } + }, + "references": { + "type": "array", + "items": { + "anyOf": [ + { + "allOf": [ + { + "anyOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "identity", + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "horizontal", + "type": "string" + }, + "value": { + "type": "number" + }, + "label": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "vertical", + "type": "string" + }, + "value": { + "type": "number" + }, + "label": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "points" + ], + "properties": { + "type": { + "const": "path", + "type": "string" + }, + "points": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "required": [ + "x", + "y" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + } + }, + "label": { + "type": "string" + } + } + } + ] + }, + { + "type": "object", + "required": [ + "scope" + ], + "properties": { + "scope": { + "const": "global", + "type": "string" + } + } + } + ] + }, + { + "allOf": [ + { + "anyOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "identity", + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "horizontal", + "type": "string" + }, + "value": { + "type": "number" + }, + "label": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "vertical", + "type": "string" + }, + "value": { + "type": "number" + }, + "label": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "points" + ], + "properties": { + "type": { + "const": "path", + "type": "string" + }, + "points": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "required": [ + "x", + "y" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + } + }, + "label": { + "type": "string" + } + } + } + ] + }, + { + "type": "object", + "required": [ + "scope", + "population" + ], + "properties": { + "scope": { + "const": "population", + "type": "string" + }, + "population": { + "type": "string" + } + } + } + ] + }, + { + "allOf": [ + { + "anyOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "identity", + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "horizontal", + "type": "string" + }, + "value": { + "type": "number" + }, + "label": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "vertical", + "type": "string" + }, + "value": { + "type": "number" + }, + "label": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "points" + ], + "properties": { + "type": { + "const": "path", + "type": "string" + }, + "points": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "required": [ + "x", + "y" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + } + }, + "label": { + "type": "string" + } + } + } + ] + }, + { + "type": "object", + "required": [ + "scope", + "population", + "horizon" + ], + "properties": { + "scope": { + "const": "population_horizon", + "type": "string" + }, + "population": { + "type": "string" + }, + "horizon": { + "minimum": 0, + "type": "number" + } + } + } + ] + } + ] + } + } + } + }, + { + "type": "object", + "required": [ + "type", + "evaluations", + "series", + "data", + "x", + "y", + "references" + ], + "properties": { + "type": { + "const": "interventions_avoided", + "type": "string" + }, + "evaluations": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "type": "object", + "required": [ + "id", + "population" + ], + "properties": { + "id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "population": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "pattern": "^evaluation-[1-9][0-9]*$", + "type": "string" + } + } + } + ] + } + }, + "series": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "allOf": [ + { + "type": "object", + "required": [ + "id", + "evaluationId", + "display" + ], + "properties": { + "id": { + "type": "string" + }, + "evaluationId": { + "type": "string" + }, + "horizon": { + "minimum": 0, + "type": "number" + }, + "display": { + "type": "object", + "required": [ + "label", + "group", + "role" + ], + "properties": { + "label": { + "type": "string" + }, + "group": { + "type": "string" + }, + "role": { + "anyOf": [ + { + "const": "model", + "type": "string" + }, + { + "const": "population", + "type": "string" + }, + { + "const": "evaluation", + "type": "string" + }, + { + "const": "context", + "type": "string" + } + ] + } + } + } + } + }, + { + "type": "object", + "required": [ + "id", + "evaluationId" + ], + "properties": { + "id": { + "pattern": "^series-[1-9][0-9]*$", + "type": "string" + }, + "evaluationId": { + "pattern": "^evaluation-[1-9][0-9]*$", + "type": "string" + } + } + } + ] + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "seriesId", + "threshold", + "interventionsAvoided" + ], + "properties": { + "seriesId": { + "type": "string" + }, + "threshold": { + "minimum": 0, + "maximum": 1, + "type": "number" + }, + "interventionsAvoided": { + "type": "number" + } + } + } + }, + "x": { + "const": "threshold", + "type": "string" + }, + "y": { + "const": "interventionsAvoided", + "type": "string" + }, + "references": { + "minItems": 2, + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "required": [ + "type", + "value", + "scope", + "benchmark" + ], + "properties": { + "type": { + "const": "horizontal", + "type": "string" + }, + "value": { + "const": 0, + "type": "number" + }, + "label": { + "type": "string" + }, + "scope": { + "const": "global", + "type": "string" + }, + "benchmark": { + "const": "treat_all", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "points", + "scope", + "population", + "benchmark" + ], + "properties": { + "type": { + "const": "path", + "type": "string" + }, + "points": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "required": [ + "x", + "y" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + } + }, + "label": { + "type": "string" + }, + "scope": { + "const": "population", + "type": "string" + }, + "population": { + "type": "string" + }, + "benchmark": { + "const": "treat_none", + "type": "string" + } + } + } + ] + } + } + } + } + ] } ] } diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js index d4ae83d9..6a1092b2 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js @@ -3011,6 +3011,58 @@ var GainsV2SpecSchema = Type.Intersect([ }) ]); +// src/spec/v2/interventions-avoided.ts +var InterventionsAvoidedV2DatumSchema = Type.Object({ + seriesId: Type.String(), + threshold: Type.Number({ minimum: 0, maximum: 1 }), + interventionsAvoided: Type.Number() +}); +var InterventionsAvoidedV2EvaluationSchema = Type.Intersect([ + EvaluationSpecSchema, + Type.Object({ id: Type.String({ pattern: "^evaluation-[1-9][0-9]*$" }) }) +]); +var InterventionsAvoidedV2SeriesSchema = Type.Intersect([ + SeriesSpecSchema, + Type.Object({ + id: Type.String({ pattern: "^series-[1-9][0-9]*$" }), + evaluationId: Type.String({ pattern: "^evaluation-[1-9][0-9]*$" }) + }) +]); +var InterventionsAvoidedTreatAllReferenceSchema = Type.Object({ + type: Type.Literal("horizontal"), + value: Type.Literal(0), + label: Type.Optional(Type.String()), + scope: Type.Literal("global"), + benchmark: Type.Literal("treat_all") +}); +var InterventionsAvoidedTreatNoneReferenceSchema = Type.Object({ + type: Type.Literal("path"), + points: Type.Array( + Type.Object({ x: Type.Number(), y: Type.Number() }), + { minItems: 2 } + ), + label: Type.Optional(Type.String()), + scope: Type.Literal("population"), + population: Type.String(), + benchmark: Type.Literal("treat_none") +}); +var InterventionsAvoidedV2ReferenceSchema = Type.Union([ + InterventionsAvoidedTreatAllReferenceSchema, + InterventionsAvoidedTreatNoneReferenceSchema +]); +var InterventionsAvoidedV2SpecSchema = Type.Intersect([ + BaseChartV2SpecSchema, + Type.Object({ + type: Type.Literal("interventions_avoided"), + evaluations: Type.Array(InterventionsAvoidedV2EvaluationSchema, { minItems: 1 }), + series: Type.Array(InterventionsAvoidedV2SeriesSchema, { minItems: 1 }), + data: Type.Array(InterventionsAvoidedV2DatumSchema), + x: Type.Literal("threshold"), + y: Type.Literal("interventionsAvoided"), + references: Type.Array(InterventionsAvoidedV2ReferenceSchema, { minItems: 2 }) + }) +]); + // src/spec/v2/lift.ts var LiftV2DatumSchema = Type.Object({ seriesId: Type.String(), @@ -3070,7 +3122,8 @@ var RtichokeChartSpecV2Schema = Type.Union( PrecisionRecallV2SpecSchema, GainsV2SpecSchema, LiftV2SpecSchema, - DecisionCurveV2SpecSchema + DecisionCurveV2SpecSchema, + InterventionsAvoidedV2SpecSchema ], { $id: "https://rtichoke.dev/schema/viz/2.0.json", @@ -3242,6 +3295,42 @@ function assertV2ReferentialIntegrity(spec) { throw new Error("decision curve requires exactly one Treat All reference per population"); } } + if (spec.type === "interventions_avoided") { + const interventionsAvoided = spec; + const references = interventionsAvoided.references; + interventionsAvoided.evaluations.forEach((evaluation, index2) => { + const expectedId = `evaluation-${index2 + 1}`; + if (evaluation.id !== expectedId) throw new Error(`interventions avoided evaluation ids must be ordinal: expected ${expectedId}`); + const expectedDisplay = evaluation.model ?? evaluation.population; + const expectedRole = evaluation.model === void 0 ? "population" : "model"; + const series = interventionsAvoided.series[index2]; + if (!series || series.id !== `series-${index2 + 1}` || series.evaluationId !== evaluation.id) { + throw new Error("interventions avoided series must map one-to-one in evaluation order"); + } + if (series.display.label !== expectedDisplay || series.display.group !== expectedDisplay || series.display.role !== expectedRole) { + throw new Error("interventions avoided display must follow evaluation semantics"); + } + }); + if (interventionsAvoided.series.length !== interventionsAvoided.evaluations.length) throw new Error("interventions avoided requires exactly one series per evaluation"); + const treatAll = references.filter( + (reference) => "benchmark" in reference && reference.benchmark === "treat_all" + ); + if (treatAll.length !== 1) throw new Error("interventions avoided requires exactly one Treat All reference"); + if (treatAll[0].scope !== "global" || treatAll[0].type !== "horizontal" || treatAll[0].value !== 0) { + throw new Error("interventions avoided Treat All must be the global zero reference"); + } + const treatNone = references.filter( + (reference) => "benchmark" in reference && reference.benchmark === "treat_none" + ); + const treatNonePopulations = /* @__PURE__ */ new Set(); + for (const reference of treatNone) { + if (treatNonePopulations.has(reference.population)) throw new Error(`duplicate Treat None population: ${reference.population}`); + treatNonePopulations.add(reference.population); + } + if (treatNonePopulations.size !== populations.size || [...populations].some((population) => !treatNonePopulations.has(population))) { + throw new Error("interventions avoided requires exactly one Treat None reference per population"); + } + } } // src/adapters/roc.ts @@ -19414,6 +19503,56 @@ Net Benefit: ${datum2.netBenefit.toFixed(theme.tip.digits)}` return plot2; } +// src/render/interventions-avoided.ts +function renderInterventionsAvoidedV2(spec, options = {}) { + assertV2ReferentialIntegrity(spec); + const groups2 = [...new Set(spec.series.map((series) => series.display.group))]; + const resolved = resolveV2RenderOptions(groups2, options); + const { theme } = resolved; + const displayBySeries2 = new Map(spec.series.map((series) => [series.id, series.display])); + const labelByGroup = new Map(spec.series.map((series) => [series.display.group, series.display.label])); + const data = spec.data.map((datum2) => ({ + ...datum2, + group: displayBySeries2.get(datum2.seriesId).group, + label: displayBySeries2.get(datum2.seriesId).label, + title: `Series: ${displayBySeries2.get(datum2.seriesId).label} +Threshold: ${datum2.threshold.toFixed(theme.tip.digits)} +Interventions Avoided: ${datum2.interventionsAvoided.toFixed(theme.tip.digits)}` + })); + const referenceStyle = { stroke: theme.reference.color, strokeWidth: theme.reference.width, strokeDasharray: theme.reference.dash }; + const marks2 = []; + for (const reference of spec.references) { + if (reference.benchmark === "treat_all") { + marks2.push(ruleY([0], { ...referenceStyle, title: reference.label ?? "Treat All" })); + } else { + marks2.push(line(reference.points, { x: "x", y: "y", ...referenceStyle, title: reference.label ?? `Treat None \u2014 ${reference.population}` })); + } + } + marks2.push( + line(data, { x: "threshold", y: "interventionsAvoided", z: "seriesId", stroke: "group", strokeWidth: theme.line.width, strokeDasharray: theme.line.dash ?? void 0, title: "title", tip: true }), + frame2({ stroke: theme.frame.color, strokeWidth: theme.frame.width }) + ); + const axis2 = (label, domain) => ({ label, domain, grid: false, line: true, ticks: theme.axis.ticks, tickSize: theme.axis.tickSize, tickPadding: theme.axis.tickPadding, tickFormat: theme.axis.numberFormat }); + const plot2 = plot({ + width: theme.width, + height: theme.height, + marginTop: theme.margins.top, + marginRight: theme.margins.right, + marginBottom: theme.margins.bottom, + marginLeft: theme.margins.left, + style: { background: theme.background, color: theme.axis.color, fontFamily: theme.typography.fontFamily, fontSize: `${theme.typography.fontSize}px` }, + color: { legend: resolved.showLegend, domain: resolved.groups, range: resolved.colors, tickFormat: (group2) => labelByGroup.get(group2) ?? group2 }, + x: axis2(spec.xAxis.label, spec.xAxis.domain), + y: axis2(spec.yAxis.label, spec.yAxis.domain), + marks: marks2 + }); + for (const label of plot2.querySelectorAll('[aria-label$="axis label"] text')) { + label.style.fontSize = `${theme.typography.axisTitleSize}px`; + label.style.fontWeight = String(theme.typography.axisTitleWeight); + } + return plot2; +} + // src/render/performance-table.ts var MISSING = "\u2014"; function cell(document2, text2, className) { @@ -22874,6 +23013,9 @@ function renderReport(spec) { case "decision_curve": content.append(renderDecisionCurveV2(component.spec)); break; + case "interventions_avoided": + content.append(renderInterventionsAvoidedV2(component.spec)); + break; } container.append(content); root2.append(container); @@ -22958,6 +23100,13 @@ export { DisplayRoleSchema, EvaluationSpecSchema, GainsV2SpecSchema, + InterventionsAvoidedTreatAllReferenceSchema, + InterventionsAvoidedTreatNoneReferenceSchema, + InterventionsAvoidedV2DatumSchema, + InterventionsAvoidedV2EvaluationSchema, + InterventionsAvoidedV2ReferenceSchema, + InterventionsAvoidedV2SeriesSchema, + InterventionsAvoidedV2SpecSchema, LiftV2SpecSchema, OperatingPointSchema, PerformanceEvaluationContextSchema, @@ -22988,6 +23137,7 @@ export { renderCalibrationV2, renderDecisionCurveV2, renderGainsV2, + renderInterventionsAvoidedV2, renderLiftV2, renderPerformanceTable, renderPrecisionRecallV2, diff --git a/src/rtichoke/utility/decision.py b/src/rtichoke/utility/decision.py index 8e9abdc3..6142cb88 100644 --- a/src/rtichoke/utility/decision.py +++ b/src/rtichoke/utility/decision.py @@ -11,6 +11,9 @@ from rtichoke._decision_curve_viz_spec_v2 import ( _decision_curve_v2_spec_from_performance_data, ) +from rtichoke._interventions_avoided_viz_spec_v2 import ( + _interventions_avoided_v2_spec_from_performance_data, +) from rtichoke._renderers import RtichokeBrowserChart, _validate_renderer from rtichoke.performance_data.performance_data import prepare_performance_data from rtichoke.processing.binary_color_values import _apply_color_values_binary @@ -41,6 +44,23 @@ def _decision_curve_browser_chart( return RtichokeBrowserChart(spec=spec, size=size) +def _interventions_avoided_browser_chart( + performance_data: pl.DataFrame, + evaluation_metadata: dict[str, _EvaluationMetadata], + *, + size: int, + min_p_threshold: float, + max_p_threshold: float, +) -> RtichokeBrowserChart: + spec = _interventions_avoided_v2_spec_from_performance_data( + performance_data, + evaluation_metadata, + min_p_threshold=min_p_threshold, + max_p_threshold=max_p_threshold, + ) + return RtichokeBrowserChart(spec=spec, size=size) + + def _performance_data_evaluation_metadata( performance_data: pl.DataFrame, ) -> dict[str, _EvaluationMetadata]: @@ -97,16 +117,20 @@ def create_decision_curve( """Creates a Decision Curve. ``renderer="plotly"`` preserves the historical default. For static - conventional Decision Curves, ``"browser"`` and ``"rtichoke_viz"`` return - a canonical :class:`RtichokeBrowserChart` built from the already-computed - production net-benefit values. + conventional Decision Curves and static Interventions Avoided curves, + ``"browser"`` and ``"rtichoke_viz"`` return a canonical + :class:`RtichokeBrowserChart` built from already-computed production values. """ selected_renderer = _validate_renderer(renderer) if selected_renderer != "plotly": - if selected_renderer == "matplotlib" or decision_type != "conventional": + if selected_renderer == "matplotlib" or decision_type not in { + "conventional", + "interventions avoided", + }: raise ValueError( - "Static conventional Decision Curves support 'plotly', 'browser', " - "and 'rtichoke_viz' renderers." + "Static Decision Curves support 'plotly', 'browser', and " + "'rtichoke_viz' renderers for decision_type='conventional' or " + "decision_type='interventions avoided'." ) performance_data = prepare_performance_data( probs=probs, @@ -115,7 +139,15 @@ def create_decision_curve( by=by, ) evaluation_metadata = _build_evaluation_metadata(probs, reals, np.array([])) - return _decision_curve_browser_chart( + if decision_type == "conventional": + return _decision_curve_browser_chart( + performance_data, + evaluation_metadata, + size=size, + min_p_threshold=min_p_threshold, + max_p_threshold=max_p_threshold, + ) + return _interventions_avoided_browser_chart( performance_data, evaluation_metadata, size=size, @@ -163,14 +195,27 @@ def plot_decision_curve( """ selected_renderer = _validate_renderer(renderer) if selected_renderer != "plotly": - if selected_renderer == "matplotlib" or decision_type != "conventional": + if selected_renderer == "matplotlib" or decision_type not in { + "conventional", + "interventions avoided", + }: raise ValueError( - "Static conventional Decision Curves support 'plotly', 'browser', " - "and 'rtichoke_viz' renderers." + "Static Decision Curves support 'plotly', 'browser', and " + "'rtichoke_viz' renderers for decision_type='conventional' or " + "decision_type='interventions avoided'." ) - return _decision_curve_browser_chart( + evaluation_metadata = _performance_data_evaluation_metadata(performance_data) + if decision_type == "conventional": + return _decision_curve_browser_chart( + performance_data, + evaluation_metadata, + size=size, + min_p_threshold=min_p_threshold, + max_p_threshold=max_p_threshold, + ) + return _interventions_avoided_browser_chart( performance_data, - _performance_data_evaluation_metadata(performance_data), + evaluation_metadata, size=size, min_p_threshold=min_p_threshold, max_p_threshold=max_p_threshold, diff --git a/tests/test_decision_curve_browser_acceptance.py b/tests/test_decision_curve_browser_acceptance.py index 15d8b716..127ebcee 100644 --- a/tests/test_decision_curve_browser_acceptance.py +++ b/tests/test_decision_curve_browser_acceptance.py @@ -6,9 +6,10 @@ from typing import Iterator import numpy as np +import polars as pl import pytest -from rtichoke.utility.decision import create_decision_curve +from rtichoke.utility.decision import create_decision_curve, plot_decision_curve @contextmanager @@ -69,3 +70,70 @@ def test_static_decision_curve_renders_model_and_references_in_real_browser(tmp_ assert page.locator("svg").count() >= 1 assert len(errors) == 0, f"Console errors found: {errors}" browser.close() + + +def test_static_interventions_avoided_renders_geometry_references_and_axes_in_real_browser( + tmp_path: Path, +): + try: + from playwright.sync_api import sync_playwright # type: ignore[import-untyped] + except ImportError: + pytest.skip("playwright is not available") + + performance_data = pl.DataFrame( + { + "reference_group": [ + "Population A", + "Population A", + "Population B", + "Population B", + ], + "chosen_cutoff": [0.2, 0.5, 0.2, 0.5], + "net_benefit_interventions_avoided": [-25.0, 50.0, -100.0, 0.0], + "real_positives": [2, 2, 4, 4], + "n": [8, 8, 8, 8], + } + ) + chart = plot_decision_curve( + performance_data, + decision_type="interventions avoided", + min_p_threshold=0.2, + max_p_threshold=0.5, + renderer="browser", + ) + treat_none = [ + reference + for reference in chart.spec["references"] + if reference["benchmark"] == "treat_none" + ] + assert [reference["population"] for reference in treat_none] == [ + "Population A", + "Population B", + ] + chart.write_html(tmp_path / "interventions-avoided.html") + + with _serve(tmp_path) as base_url: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + errors: list[str] = [] + page.on( + "console", + lambda msg: errors.append(msg.text) + if msg.type in ["error", "warning"] + else None, + ) + page.on("pageerror", lambda err: errors.append(str(err))) + page.goto(f"{base_url}/interventions-avoided.html") + page.wait_for_selector("svg") + + content = page.content() + assert "Population A" in content + assert "Population B" in content + assert "Treat All" in content + assert "Treat None" in content + assert "Interventions Avoided (per 100)" in content + assert "Probability Threshold" in content + assert page.locator("svg").count() >= 1 + assert len(errors) == 0, f"Console errors found: {errors}" + browser.close() diff --git a/tests/test_decision_curve_v2.py b/tests/test_decision_curve_v2.py index f0279cc1..f4f51760 100644 --- a/tests/test_decision_curve_v2.py +++ b/tests/test_decision_curve_v2.py @@ -134,18 +134,11 @@ def test_precomputed_browser_input_does_not_fabricate_model_identity(): assert [item["population"] for item in browser.spec["evaluations"]] == ["a", "b"] -def test_browser_adoption_does_not_enable_interventions_avoided(): +def test_conventional_browser_behavior_remains_unchanged(): probs = {"model-a": np.array([0.9, 0.7, 0.4, 0.1])} reals = np.array([1, 1, 0, 0]) - try: - create_decision_curve( - probs, - reals, - decision_type="interventions avoided", - renderer="browser", - ) - except ValueError as error: - assert "Static conventional Decision Curves" in str(error) - else: - raise AssertionError("Interventions Avoided browser adoption is out of scope") + browser = create_decision_curve(probs, reals, renderer="browser") + + assert isinstance(browser, RtichokeBrowserChart) + assert browser.spec["type"] == "decision_curve" diff --git a/tests/test_interventions_avoided_v2.py b/tests/test_interventions_avoided_v2.py new file mode 100644 index 00000000..0a536273 --- /dev/null +++ b/tests/test_interventions_avoided_v2.py @@ -0,0 +1,196 @@ +from pathlib import Path + +import numpy as np +import polars as pl +from plotly.graph_objects import Figure + +from rtichoke._interventions_avoided_viz_spec_v2 import ( + _interventions_avoided_v2_spec_from_performance_data, +) +from rtichoke._renderers import RtichokeBrowserChart +from rtichoke.processing.evaluation_semantics import _EvaluationMetadata +from rtichoke.utility.decision import create_decision_curve, plot_decision_curve + + +def _performance_data(*, equal_prevalence: bool = False) -> pl.DataFrame: + p_a = (2, 8) + p_b = p_a if equal_prevalence else (4, 8) + rows = [] + for group, (positives, n), values in ( + ("a", p_a, (-12.5, 7.25)), + ("b", p_b, (3.0, 19.5)), + ): + for threshold, value in zip((0.2, 0.5), values): + rows.append( + { + "reference_group": group, + "chosen_cutoff": threshold, + "net_benefit_interventions_avoided": value, + "real_positives": positives, + "n": n, + } + ) + return pl.DataFrame(rows) + + +def test_shared_population_has_two_evaluations_one_treat_none_path_and_global_treat_all(): + data = _performance_data(equal_prevalence=True) + metadata = { + "a": _EvaluationMetadata("a", "a", "model-a", "population-1"), + "b": _EvaluationMetadata("b", "b", "model-b", "population-1"), + } + + spec = _interventions_avoided_v2_spec_from_performance_data(data, metadata) + + assert spec["schemaVersion"] == "2.0" + assert spec["type"] == "interventions_avoided" + assert spec["x"] == "threshold" + assert spec["y"] == "interventionsAvoided" + assert spec["yAxis"] == {"label": "Interventions Avoided (per 100)"} + assert [item["id"] for item in spec["evaluations"]] == [ + "evaluation-1", + "evaluation-2", + ] + assert [item["id"] for item in spec["series"]] == ["series-1", "series-2"] + assert all(series["id"] != series["evaluationId"] for series in spec["series"]) + assert [row["interventionsAvoided"] for row in spec["data"]] == [ + -12.5, + 7.25, + 3.0, + 19.5, + ] + + treat_all = [r for r in spec["references"] if r["benchmark"] == "treat_all"] + treat_none = [r for r in spec["references"] if r["benchmark"] == "treat_none"] + assert treat_all == [ + { + "type": "horizontal", + "scope": "global", + "value": 0.0, + "label": "Treat All", + "benchmark": "treat_all", + } + ] + assert len(treat_none) == 1 + assert treat_none[0]["scope"] == "population" + assert treat_none[0]["population"] == "population-1" + assert treat_none[0]["points"] == [ + {"x": 0.2, "y": -25.0}, + {"x": 0.5, "y": 50.0}, + ] + + +def test_distinct_populations_have_population_owned_treat_none_paths(): + data = _performance_data(equal_prevalence=False) + metadata = { + "a": _EvaluationMetadata("a", "a", "model-a", "population-a"), + "b": _EvaluationMetadata("b", "b", "model-b", "population-b"), + } + + spec = _interventions_avoided_v2_spec_from_performance_data(data, metadata) + treat_none = [r for r in spec["references"] if r["benchmark"] == "treat_none"] + + assert [r["population"] for r in treat_none] == ["population-a", "population-b"] + assert treat_none[0]["points"] != treat_none[1]["points"] + + +def test_distinct_equal_prevalence_populations_remain_distinct_reference_owners(): + data = _performance_data(equal_prevalence=True) + metadata = { + "a": _EvaluationMetadata("a", "a", None, "population-a"), + "b": _EvaluationMetadata("b", "b", None, "population-b"), + } + + spec = _interventions_avoided_v2_spec_from_performance_data(data, metadata) + treat_none = [r for r in spec["references"] if r["benchmark"] == "treat_none"] + + assert [r["population"] for r in treat_none] == ["population-a", "population-b"] + assert treat_none[0]["points"] == treat_none[1]["points"] + assert all("model" not in evaluation for evaluation in spec["evaluations"]) + assert [series["display"]["role"] for series in spec["series"]] == [ + "population", + "population", + ] + + +def test_model_values_are_copied_not_recomputed(): + data = pl.DataFrame( + { + "reference_group": ["a"], + "chosen_cutoff": [0.37], + "net_benefit_interventions_avoided": [12.3456789], + "real_positives": [2], + "n": [8], + } + ) + metadata = {"a": _EvaluationMetadata("a", "a", "model-a", "population-a")} + + spec = _interventions_avoided_v2_spec_from_performance_data(data, metadata) + + assert spec["data"] == [ + { + "seriesId": "series-1", + "threshold": 0.37, + "interventionsAvoided": 12.3456789, + } + ] + + +def test_public_browser_renderer_is_opt_in_and_plotly_remains_default(tmp_path: Path): + probs = {"model-a": np.array([0.9, 0.7, 0.4, 0.1])} + reals = np.array([1, 1, 0, 0]) + + browser = create_decision_curve( + probs, + reals, + decision_type="interventions avoided", + by=0.2, + renderer="browser", + ) + alias = create_decision_curve( + probs, + reals, + decision_type="interventions avoided", + by=0.2, + renderer="rtichoke_viz", + ) + plotly = create_decision_curve( + probs, + reals, + decision_type="interventions avoided", + by=0.2, + ) + + assert isinstance(browser, RtichokeBrowserChart) + assert isinstance(alias, RtichokeBrowserChart) + assert isinstance(plotly, Figure) + html_path = browser.write_html(tmp_path / "interventions-avoided.html") + html = html_path.read_text(encoding="utf-8") + assert "renderInterventionsAvoidedV2" in html + assert browser.spec["type"] == "interventions_avoided" + + +def test_precomputed_browser_input_does_not_fabricate_model_identity(): + browser = plot_decision_curve( + _performance_data(), + decision_type="interventions avoided", + renderer="browser", + ) + + assert isinstance(browser, RtichokeBrowserChart) + assert all("model" not in item for item in browser.spec["evaluations"]) + assert [item["population"] for item in browser.spec["evaluations"]] == ["a", "b"] + + +def test_browser_rejects_combined_or_unknown_decision_modes(): + probs = {"model-a": np.array([0.9, 0.7, 0.4, 0.1])} + reals = np.array([1, 1, 0, 0]) + + try: + create_decision_curve( + probs, reals, decision_type="combined", renderer="browser" + ) + except ValueError as error: + assert "decision_type='interventions avoided'" in str(error) + else: + raise AssertionError("Combined browser Decision Curve mode is out of scope") diff --git a/tests/test_rtichoke_viz_vendor.py b/tests/test_rtichoke_viz_vendor.py index c4bc49a8..e2bb2778 100644 --- a/tests/test_rtichoke_viz_vendor.py +++ b/tests/test_rtichoke_viz_vendor.py @@ -4,19 +4,19 @@ _VENDOR = Path(__file__).parents[1] / "src" / "rtichoke" / "_vendor" / "rtichoke_viz" -_RELEASE_DIR = "rtichoke-viz-0.6.0" -_SHA256 = "625613c7f692ff50b7757a27bb6caf84e311971bde92593141393dbd897af3a2" -_SOURCE_COMMIT = "3abb3f07a598c3e22d5362a3f88e52bb6b52b083" +_RELEASE_DIR = "rtichoke-viz-0.7.0" +_SHA256 = "f09c30e231a8be39c2e89ba6ae39c90ed8cab67021213e17681a475066a9806e" +_SOURCE_COMMIT = "b3564d2824ec1791f791fda406c99b3d7865a68f" -def test_vendored_rtichoke_viz_v060_provenance_archive_and_schemas(): +def test_vendored_rtichoke_viz_v070_provenance_archive_and_schemas(): provenance = (_VENDOR / "VENDORED_FROM").read_text() - assert "release=v0.6.0" in provenance + assert "release=v0.7.0" in provenance assert f"source_commit={_SOURCE_COMMIT}" in provenance - assert "archive=rtichoke-viz-0.6.0.tar.gz" in provenance + assert "archive=rtichoke-viz-0.7.0.tar.gz" in provenance assert f"sha256={_SHA256}" in provenance - archive = _VENDOR / "rtichoke-viz-0.6.0.tar.gz" + archive = _VENDOR / "rtichoke-viz-0.7.0.tar.gz" assert hashlib.sha256(archive.read_bytes()).hexdigest() == _SHA256 with tarfile.open(archive, "r:gz") as release: assert set(release.getnames()) == { @@ -29,7 +29,7 @@ def test_vendored_rtichoke_viz_v060_provenance_archive_and_schemas(): } manifest = release.extractfile(f"{_RELEASE_DIR}/MANIFEST") assert manifest is not None - assert manifest.read().decode() == (f"version=0.6.0\ncommit={_SOURCE_COMMIT}\n") + assert manifest.read().decode() == (f"version=0.7.0\ncommit={_SOURCE_COMMIT}\n") for filename in ( "rtichoke-viz.css", "rtichoke-viz.js", @@ -40,7 +40,7 @@ def test_vendored_rtichoke_viz_v060_provenance_archive_and_schemas(): assert packaged is not None assert (_VENDOR / filename).read_bytes() == packaged.read() - assert not (_VENDOR / "rtichoke-viz-0.5.0.tar.gz").exists() + assert not (_VENDOR / "rtichoke-viz-0.6.0.tar.gz").exists() assert (_VENDOR / "rtichoke-viz.js").stat().st_size > 0 assert (_VENDOR / "rtichoke-viz.css").stat().st_size > 0 @@ -49,9 +49,10 @@ def test_vendored_rtichoke_viz_v060_provenance_archive_and_schemas(): assert '"$id": "https://rtichoke.dev/schema/viz/1.0.json"' in v1_schema assert '"$id": "https://rtichoke.dev/schema/viz/2.0.json"' in v2_schema assert '"decision_curve"' in v2_schema + assert '"interventions_avoided"' in v2_schema -def test_v060_bundle_keeps_existing_exports_and_adds_decision_curve(): +def test_v070_bundle_keeps_existing_exports_and_adds_interventions_avoided(): bundle = (_VENDOR / "rtichoke-viz.js").read_text(encoding="utf-8") for export_name in ( "renderRoc", @@ -61,11 +62,12 @@ def test_v060_bundle_keeps_existing_exports_and_adds_decision_curve(): "renderLiftV2", "renderDecisionCurveV2", "DecisionCurveV2SpecSchema", + "renderInterventionsAvoidedV2", + "InterventionsAvoidedV2SpecSchema", "RtichokeChartSpecV2Schema", "renderPerformanceTable", "renderReport", ): assert export_name in bundle - assert "renderInterventionsAvoidedV2" not in bundle assert "Fixed Time Horizon" in bundle