diff --git a/src/rtichoke/_renderers.py b/src/rtichoke/_renderers.py index 983da1cf..2de79394 100644 --- a/src/rtichoke/_renderers.py +++ b/src/rtichoke/_renderers.py @@ -97,32 +97,20 @@ def _render_gains_matplotlib( "Install it with `pip install 'rtichoke[matplotlib]'`." ) from error - figure = Figure(figsize=(size / 100, size / 100), dpi=100) - axis = figure.subplots() - references = spec.get("references", []) - assert isinstance(references, list) - for reference in references: - if not isinstance(reference, dict): - continue - if reference.get("type") == "identity": - x_values, y_values = [0, 1], [0, 1] - elif reference.get("type") == "path": - points = reference.get("points", []) - x_values = [point["x"] for point in points] - y_values = [point["y"] for point in points] - else: - continue - axis.plot( - x_values, - y_values, - color="#BEBEBE", - linestyle="--", - linewidth=2, - ) - series = spec.get("series", []) data = spec.get("data", []) assert isinstance(series, list) and isinstance(data, list) + horizons = list( + dict.fromkeys( + item["horizon"] for item in series if item.get("horizon") is not None + ) + ) + panels: list[float | None] = horizons or [None] + figure = Figure(figsize=(size / 100 * len(panels), size / 100), dpi=100) + axes_value = figure.subplots(1, len(panels), squeeze=False) + axes = list(axes_value[0]) + references = spec.get("references", []) + assert isinstance(references, list) display_groups = list(dict.fromkeys(item["display"]["group"] for item in series)) colors = { group: ( @@ -132,25 +120,57 @@ def _render_gains_matplotlib( ) for index, group in enumerate(display_groups) } - for item in series: - rows = [row for row in data if row["seriesId"] == item["id"]] - display = item["display"] - axis.plot( - [row["ppcr"] for row in rows], - [row["sensitivity"] for row in rows], - label=display["label"], - color=colors[display["group"]], - linewidth=2, - ) - x_axis = spec["xAxis"] y_axis = spec["yAxis"] - axis.set_xlabel(x_axis["label"]) - axis.set_ylabel(y_axis["label"]) - axis.set_xlim(*x_axis["domain"]) - axis.set_ylim(*y_axis["domain"]) - if len(series) > 1: - axis.legend() + for axis, horizon in zip(axes, panels): + for reference in references: + if not isinstance(reference, dict): + continue + if ( + reference.get("scope") == "population_horizon" + and reference.get("horizon") != horizon + ): + continue + if reference.get("type") == "identity": + x_values, y_values = [0, 1], [0, 1] + elif reference.get("type") == "path": + points = reference.get("points", []) + x_values = [point["x"] for point in points] + y_values = [point["y"] for point in points] + else: + continue + axis.plot( + x_values, + y_values, + color="#BEBEBE", + linestyle="--", + linewidth=2, + ) + + panel_series = [ + item + for item in series + if item.get("horizon") is None or item.get("horizon") == horizon + ] + for item in panel_series: + rows = [row for row in data if row["seriesId"] == item["id"]] + display = item["display"] + axis.plot( + [row["ppcr"] for row in rows], + [row["sensitivity"] for row in rows], + label=display["label"], + color=colors[display["group"]], + linewidth=2, + ) + + axis.set_xlabel(x_axis["label"]) + axis.set_ylabel(y_axis["label"]) + axis.set_xlim(*x_axis["domain"]) + axis.set_ylim(*y_axis["domain"]) + if horizon is not None: + axis.set_title(f"Fixed Time Horizon: {horizon:g}") + if len(panel_series) > 1: + axis.legend() figure.tight_layout() return figure diff --git a/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM b/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM index df5ec55d..7f2d8c53 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM +++ b/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM @@ -1,5 +1,6 @@ repository=https://github.com/uriahf/rtichoke_viz -release=v0.3.0 -source_commit=aca9188ea856167557efb20980a0b43e0481b8c8 -archive=rtichoke-viz-0.3.0.tar.gz -sha256=558f8d9e16f9544659b84e33f72511065163291a1b97a3c5511b61d1e1f0cac1 +release=v0.3.1 +source_commit=5ccde928a0bf9fa6ece2b7572687b442c57a98a9 +archive=rtichoke-viz-0.3.1.tar.gz +sha256=121aa8eb8d0f8427ecfb2c01dab0fb05668eaedf47ddcfc0cd282a7ecf1ce448 + diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.3.0.tar.gz b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.3.0.tar.gz deleted file mode 100644 index 4962a386..00000000 Binary files a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.3.0.tar.gz and /dev/null differ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.3.1.tar.gz b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.3.1.tar.gz new file mode 100644 index 00000000..ddaf5fa6 Binary files /dev/null and b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.3.1.tar.gz differ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js index 3ecf24bb..8f4f41d0 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js @@ -18637,27 +18637,86 @@ var RTICHOKE_COLORS3 = [ "#006E90", "#BC96E6" ]; -var BASE_STYLE2 = { - background: "transparent", - color: "#222", - fontFamily: "Arial, Helvetica, sans-serif", - fontSize: "13px" +var RTICHOKE_BROWSER_THEME = { + width: 600, + height: 600, + margins: { top: 28, right: 28, bottom: 58, left: 66 }, + background: "#ffffff", + frame: { color: "#444444", width: 1 }, + typography: { + fontFamily: "Arial, Helvetica, sans-serif", + fontSize: 12, + axisTitleSize: 14, + axisTitleWeight: 400, + legendSize: 12 + }, + axis: { + color: "#444444", + tickSize: 5, + tickPadding: 7, + ticks: 6, + numberFormat: ".1f" + }, + colors: RTICHOKE_COLORS3, + line: { width: 2, dash: null }, + marker: { radius: 5, fill: null, stroke: "#ffffff", strokeWidth: 1.5 }, + reference: { color: "#BEBEBE", width: 2, dash: "4,4" }, + legend: { position: "top", swatchWidth: 15, columns: null }, + tip: { digits: 3 } }; -function resolveV2RenderOptions(groupCount, options = {}) { - const width = options.width ?? 600; - const height = options.height ?? 600; - if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) { - throw new Error("Renderer width and height must be positive finite numbers"); - } - const colors = groupCount <= 1 ? ["#000000"] : [...options.colors ?? RTICHOKE_COLORS3]; - if (colors.length < groupCount) { - throw new Error("Renderer colors must contain at least one color per display group"); - } - return { width, height, colors: colors.slice(0, Math.max(groupCount, 1)) }; +function mergeTheme(options) { + const custom8 = options.theme ?? {}; + return { + ...RTICHOKE_BROWSER_THEME, + width: options.width ?? RTICHOKE_BROWSER_THEME.width, + height: options.height ?? RTICHOKE_BROWSER_THEME.height, + background: custom8.background ?? RTICHOKE_BROWSER_THEME.background, + colors: options.colors ?? RTICHOKE_BROWSER_THEME.colors, + margins: { ...RTICHOKE_BROWSER_THEME.margins, ...custom8.margins }, + frame: { ...RTICHOKE_BROWSER_THEME.frame, ...custom8.frame }, + typography: { ...RTICHOKE_BROWSER_THEME.typography, ...custom8.typography }, + axis: { ...RTICHOKE_BROWSER_THEME.axis, ...custom8.axis }, + line: { ...RTICHOKE_BROWSER_THEME.line, ...custom8.line }, + marker: { ...RTICHOKE_BROWSER_THEME.marker, ...custom8.marker }, + reference: { ...RTICHOKE_BROWSER_THEME.reference, ...custom8.reference }, + legend: { ...RTICHOKE_BROWSER_THEME.legend, ...custom8.legend }, + tip: { ...RTICHOKE_BROWSER_THEME.tip, ...custom8.tip } + }; +} +function resolveV2RenderOptions(groupsOrCount, options = {}) { + const groups2 = typeof groupsOrCount === "number" ? Array.from( + { length: groupsOrCount }, + (_, index2) => `group-${index2 + 1}` + ) : [...groupsOrCount]; + const theme = mergeTheme(options); + if (!Number.isFinite(theme.width) || theme.width <= 0 || !Number.isFinite(theme.height) || theme.height <= 0) + throw new Error( + "Renderer width and height must be positive finite numbers" + ); + if (!Number.isInteger(theme.tip.digits) || theme.tip.digits < 0 || theme.tip.digits > 20) + throw new Error("Renderer tip digits must be an integer between 0 and 20"); + const colors = groups2.length <= 1 ? ["#000000"] : [...theme.colors]; + if (colors.length < groups2.length) + throw new Error( + "Renderer colors must contain at least one color per display group" + ); + const assigned = colors.slice(0, Math.max(groups2.length, 1)); + return { + theme: { ...theme, colors: assigned }, + groups: groups2, + colors: assigned, + colorByGroup: new Map( + groups2.map((group2, index2) => [group2, assigned[index2]]) + ), + showLegend: groups2.length > 1 + }; } function displayBySeries(spec) { return new Map(spec.series.map((series) => [series.id, series.display])); } +function displayGroups(spec) { + return [...new Set(spec.series.map((series) => series.display.group))]; +} function seriesRenderData(spec, data) { const displays = displayBySeries(spec); return data.map((datum2) => ({ @@ -18666,137 +18725,356 @@ function seriesRenderData(spec, data) { label: displays.get(datum2.seriesId).label })); } -function referenceMarks(spec) { +function tooltip(digits, fields) { + return fields.filter(([, value]) => value !== void 0).map( + ([label, value]) => `${label}: ${typeof value === "number" ? value.toFixed(digits) : String(value)}` + ).join("\n"); +} +function basePlotOptions(resolved, spec) { + const { theme } = resolved; + const labelByGroup = new Map( + spec.series.map((series) => [series.display.group, series.display.label]) + ); + return { + 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 + } + }; +} +function axisOptions2(theme, label, domain) { + return { + label, + domain, + grid: false, + line: true, + ticks: theme.axis.ticks, + tickSize: theme.axis.tickSize, + tickPadding: theme.axis.tickPadding, + tickFormat: theme.axis.numberFormat + }; +} +function frameMark(theme) { + return frame2({ + stroke: theme.frame.color, + strokeWidth: theme.frame.width + }); +} +function referenceMarks(spec, theme) { + const style = { + stroke: theme.reference.color, + strokeWidth: theme.reference.width, + strokeDasharray: theme.reference.dash + }; const marks2 = []; for (const reference of spec.references ?? []) { - if (reference.type === "identity") { - marks2.push(line([{ x: 0, y: 0 }, { x: 1, y: 1 }], { - x: "x", - y: "y", - stroke: "#BEBEBE", - strokeWidth: 2, - strokeDasharray: "4,4" - })); - } else if (reference.type === "path" && reference.points) { - marks2.push(line(reference.points, { - x: "x", - y: "y", - stroke: "#BEBEBE", - strokeWidth: 2, - strokeDasharray: "4,4" - })); - } + if (reference.type === "identity") + marks2.push( + line( + [ + { x: 0, y: 0 }, + { x: 1, y: 1 } + ], + { x: "x", y: "y", ...style, title: reference.label } + ) + ); + else if (reference.type === "horizontal" && reference.value !== void 0) + marks2.push( + ruleY([reference.value], { ...style, title: reference.label }) + ); + else if (reference.type === "path" && reference.points) + marks2.push( + line(reference.points, { + x: "x", + y: "y", + ...style, + title: reference.label + }) + ); } return marks2; } -function renderRocV2(spec) { - assertV2ReferentialIntegrity(spec); - const groups2 = [...new Set(spec.series.map((series) => series.display.group))]; - const showLegend = groups2.length > 1; - const data = seriesRenderData(spec, spec.data).map((datum2) => ({ ...datum2, false_positive_rate: 1 - datum2.specificity })); - const marks2 = []; - if (spec.references?.some((reference) => reference.type === "identity")) { - marks2.push(line([{ x: 0, y: 0 }, { x: 1, y: 1 }], { x: "x", y: "y", stroke: "#BEBEBE", strokeWidth: 2 })); +function finishMarks(marks2, theme) { + marks2.push(frameMark(theme)); + return marks2; +} +function themedPlot(options, theme) { + const plot2 = plot(options); + 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); } - marks2.push(line(data, { x: "false_positive_rate", y: "sensitivity", z: "seriesId", stroke: "group", strokeWidth: 2, tip: true })); - return plot({ - width: 600, - height: 600, - marginLeft: 64, - marginBottom: 56, - style: BASE_STYLE2, - x: { label: spec.xAxis.label, domain: spec.xAxis.domain, grid: false, ticks: 6 }, - y: { label: spec.yAxis.label, domain: spec.yAxis.domain, grid: false, ticks: 6 }, - color: { legend: showLegend, range: showLegend ? RTICHOKE_COLORS3 : ["#000000"] }, - marks: marks2 - }); + for (const frame3 of plot2.querySelectorAll( + '[aria-label="frame"]' + )) { + frame3.setAttribute("stroke", theme.frame.color); + } + if (plot2 instanceof HTMLElement) { + plot2.style.fontSize = `${theme.typography.legendSize}px`; + for (const swatch of plot2.querySelectorAll( + 'svg[width="15"]' + )) { + swatch.setAttribute("width", String(theme.legend.swatchWidth)); + } + } + return plot2; } -function renderCalibrationV2(spec) { +function renderRocV2(spec, options = {}) { assertV2ReferentialIntegrity(spec); - const groups2 = [...new Set(spec.series.map((series) => series.display.group))]; - const showLegend = groups2.length > 1; - const colorRange = showLegend ? RTICHOKE_COLORS3 : ["#000000"]; - const data = seriesRenderData(spec, spec.data); - const marks2 = []; - if (spec.references?.some((reference) => reference.type === "identity")) { - marks2.push(line([{ x: 0, y: 0 }, { x: 1, y: 1 }], { x: "x", y: "y", stroke: "#BEBEBE", strokeWidth: 2, strokeDasharray: "4,4" })); - } - marks2.push(line(data, { x: "predicted", y: "observed", z: "seriesId", stroke: "group", strokeWidth: 2, tip: true })); + const resolved = resolveV2RenderOptions(displayGroups(spec), options); + const { theme } = resolved; + const data = seriesRenderData(spec, spec.data).map((datum2) => ({ + ...datum2, + false_positive_rate: 1 - datum2.specificity, + title: tooltip(theme.tip.digits, [ + ["Model", datum2.label], + ["Cutoff", datum2.cutoff], + ["Sensitivity", datum2.sensitivity], + ["Specificity", datum2.specificity] + ]) + })); + const marks2 = referenceMarks(spec, theme); + marks2.push( + line(data, { + x: "false_positive_rate", + y: "sensitivity", + z: "seriesId", + stroke: "group", + strokeWidth: theme.line.width, + strokeDasharray: theme.line.dash ?? void 0, + title: "title", + tip: true + }) + ); + return themedPlot( + { + ...basePlotOptions(resolved, spec), + x: axisOptions2(theme, spec.xAxis.label, spec.xAxis.domain), + y: axisOptions2(theme, spec.yAxis.label, spec.yAxis.domain), + marks: finishMarks(marks2, theme) + }, + theme + ); +} +function renderCalibrationV2(spec, options = {}) { + assertV2ReferentialIntegrity(spec); + const resolved = resolveV2RenderOptions(displayGroups(spec), options); + const { theme } = resolved; + const data = seriesRenderData(spec, spec.data).map((datum2) => ({ + ...datum2, + title: tooltip(theme.tip.digits, [ + ["Model", datum2.label], + ["Predicted", datum2.predicted], + ["Observed", datum2.observed], + ["Events", datum2.events], + ["Total", datum2.total] + ]) + })); + const marks2 = referenceMarks(spec, theme); + marks2.push( + line(data, { + x: "predicted", + y: "observed", + z: "seriesId", + stroke: "group", + strokeWidth: theme.line.width, + strokeDasharray: theme.line.dash ?? void 0, + title: "title", + tip: true + }) + ); const discrete = data.filter((datum2) => datum2.method === "discrete"); - if (discrete.length > 0) marks2.push(dot(discrete, { x: "predicted", y: "observed", fill: "group", stroke: "white", strokeWidth: 1.5, r: 5, tip: true })); + if (discrete.length > 0) + marks2.push( + dot(discrete, { + x: "predicted", + y: "observed", + fill: theme.marker.fill ?? "group", + stroke: theme.marker.stroke, + strokeWidth: theme.marker.strokeWidth, + r: theme.marker.radius, + title: "title", + tip: true + }) + ); const hasDistribution = (spec.distribution?.length ?? 0) > 0; - const calibration = plot({ - width: 600, - height: hasDistribution ? 480 : 600, - marginLeft: 64, - marginBottom: hasDistribution ? 16 : 56, - style: BASE_STYLE2, - x: { label: hasDistribution ? null : spec.xAxis.label, domain: spec.xAxis.domain, grid: false, ticks: 6, axis: hasDistribution ? null : "bottom" }, - y: { label: spec.yAxis.label, domain: spec.yAxis.domain, grid: false, ticks: 6 }, - color: { legend: showLegend, range: colorRange }, - marks: marks2 - }); + const mainHeight = hasDistribution ? Math.round(theme.height * 0.8) : theme.height; + const calibration = themedPlot( + { + ...basePlotOptions(resolved, spec), + height: mainHeight, + marginBottom: hasDistribution ? 8 : theme.margins.bottom, + x: hasDistribution ? { + ...axisOptions2(theme, spec.xAxis.label, spec.xAxis.domain), + axis: null, + label: null + } : axisOptions2(theme, spec.xAxis.label, spec.xAxis.domain), + y: axisOptions2(theme, spec.yAxis.label, spec.yAxis.domain), + marks: finishMarks(marks2, theme) + }, + theme + ); if (!hasDistribution || !spec.distribution) return calibration; - const distribution = seriesRenderData(spec, spec.distribution); - const histogram = plot({ - width: 600, - height: 120, - marginLeft: 64, - marginTop: 0, - marginBottom: 48, - style: BASE_STYLE2, - x: { label: spec.xAxis.label, domain: spec.xAxis.domain, grid: false, ticks: 6 }, - y: { label: null, grid: false, ticks: 3 }, - color: { legend: false, range: colorRange }, - marks: [rectY(distribution, { x1: (datum2) => datum2.midpoint - datum2.binWidth / 2, x2: (datum2) => datum2.midpoint + datum2.binWidth / 2, y: "count", fill: "group", fillOpacity: 1 / Math.max(groups2.length, 1), tip: true })] - }); + const distribution = seriesRenderData(spec, spec.distribution).map( + (datum2) => ({ + ...datum2, + title: tooltip(theme.tip.digits, [ + ["Model", datum2.label], + ["Midpoint", datum2.midpoint], + ["Count", datum2.count] + ]) + }) + ); + const histogram = themedPlot( + { + ...basePlotOptions(resolved, spec), + height: theme.height - mainHeight, + marginTop: 0, + marginBottom: theme.margins.bottom, + x: axisOptions2(theme, spec.xAxis.label, spec.xAxis.domain), + y: { + label: null, + grid: false, + ticks: 3, + tickSize: theme.axis.tickSize, + tickPadding: theme.axis.tickPadding + }, + color: { legend: false, domain: resolved.groups, range: resolved.colors }, + marks: finishMarks( + [ + rectY(distribution, { + x1: (datum2) => datum2.midpoint - datum2.binWidth / 2, + x2: (datum2) => datum2.midpoint + datum2.binWidth / 2, + y: "count", + fill: "group", + fillOpacity: 1 / Math.max(resolved.groups.length, 1), + title: "title", + tip: true + }) + ], + theme + ) + }, + theme + ); const container = document.createElement("div"); - container.style.width = "600px"; + container.className = "rtichoke-calibration"; + container.style.width = `${theme.width}px`; container.style.maxWidth = "100%"; container.append(calibration, histogram); return container; } -function renderPrecisionRecallV2(spec) { +function renderLineChart(spec, options, x2, y2) { assertV2ReferentialIntegrity(spec); - const groups2 = [...new Set(spec.series.map((series) => series.display.group))]; - const showLegend = groups2.length > 1; - const data = seriesRenderData(spec, spec.data); - const marks2 = []; - for (const reference of spec.references ?? []) if (reference.type === "horizontal" && reference.value !== void 0) marks2.push(ruleY([reference.value], { stroke: "#BEBEBE", strokeWidth: 2, strokeDasharray: "4,4" })); - marks2.push(line(data, { x: "sensitivity", y: "ppv", z: "seriesId", stroke: "group", strokeWidth: 2, tip: true })); - marks2.push(dot(data, { x: "sensitivity", y: "ppv", fill: "group", stroke: "white", strokeWidth: 1.5, r: 4, tip: true })); - return plot({ - width: 600, - height: 600, - marginLeft: 64, - marginBottom: 56, - style: BASE_STYLE2, - x: { label: spec.xAxis.label, domain: spec.xAxis.domain, grid: false, ticks: 6 }, - y: { label: spec.yAxis.label, domain: spec.yAxis.domain, grid: false, ticks: 6 }, - color: { legend: showLegend, domain: groups2, range: showLegend ? RTICHOKE_COLORS3 : ["#000000"] }, - marks: marks2 + const resolved = resolveV2RenderOptions(displayGroups(spec), options); + const { theme } = resolved; + const data = seriesRenderData( + spec, + spec.data + ).map((datum2) => { + const values2 = datum2; + return { + ...datum2, + title: tooltip(theme.tip.digits, [ + ["Model", datum2.label], + ["Cutoff", values2.cutoff], + [x2 === "ppcr" ? "PPCR" : "Sensitivity", values2[x2]], + [y2 === "ppv" ? "PPV" : "Sensitivity", values2[y2]] + ]) + }; }); + const marks2 = referenceMarks(spec, theme); + marks2.push( + line(data, { + x: x2, + y: y2, + z: "seriesId", + stroke: "group", + strokeWidth: theme.line.width, + strokeDasharray: theme.line.dash ?? void 0, + title: "title", + tip: true + }) + ); + return themedPlot( + { + ...basePlotOptions(resolved, spec), + x: axisOptions2(theme, spec.xAxis.label, spec.xAxis.domain), + y: axisOptions2(theme, spec.yAxis.label, spec.yAxis.domain), + marks: finishMarks(marks2, theme) + }, + theme + ); +} +function horizons(spec) { + return [ + ...new Set( + spec.series.map((series) => series.horizon).filter((horizon) => horizon !== void 0) + ) + ]; +} +function selectHorizonSpec(spec, horizon) { + const series = spec.series.filter( + (item) => item.horizon === void 0 || item.horizon === horizon + ); + const seriesIds = new Set(series.map((item) => item.id)); + return { + ...spec, + series, + data: spec.data.filter((datum2) => seriesIds.has(datum2.seriesId)), + references: spec.references?.filter( + (reference) => reference.scope !== "population_horizon" || reference.horizon === horizon + ) + }; +} +function renderHorizonLineChart(spec, options, x2, y2) { + const availableHorizons = horizons(spec); + if (availableHorizons.length <= 1) return renderLineChart(spec, options, x2, y2); + const container = document.createElement("div"); + container.className = "rtichoke-horizon-chart"; + const control = document.createElement("label"); + control.textContent = "Fixed Time Horizon: "; + const select = document.createElement("select"); + select.setAttribute("aria-label", "Fixed Time Horizon"); + for (const horizon of availableHorizons) { + const option = document.createElement("option"); + option.value = String(horizon); + option.textContent = String(horizon); + select.append(option); + } + control.append(select); + const chart = document.createElement("div"); + const draw = (horizon) => { + chart.replaceChildren( + renderLineChart(selectHorizonSpec(spec, horizon), options, x2, y2) + ); + }; + select.addEventListener("change", () => draw(Number(select.value))); + container.append(control, chart); + draw(availableHorizons[0]); + return container; +} +function renderPrecisionRecallV2(spec, options = {}) { + return renderLineChart(spec, options, "sensitivity", "ppv"); } function renderGainsV2(spec, options = {}) { - assertV2ReferentialIntegrity(spec); - const groups2 = [...new Set(spec.series.map((series) => series.display.group))]; - const showLegend = groups2.length > 1; - const resolved = resolveV2RenderOptions(groups2.length, options); - const data = seriesRenderData(spec, spec.data); - const marks2 = referenceMarks(spec); - marks2.push(line(data, { x: "ppcr", y: "sensitivity", z: "seriesId", stroke: "group", strokeWidth: 2, tip: true })); - marks2.push(dot(data, { x: "ppcr", y: "sensitivity", fill: "group", stroke: "white", strokeWidth: 1.5, r: 4, tip: true })); - return plot({ - width: resolved.width, - height: resolved.height, - marginLeft: 64, - marginBottom: 56, - style: BASE_STYLE2, - x: { label: spec.xAxis.label, domain: spec.xAxis.domain, grid: false, ticks: 6 }, - y: { label: spec.yAxis.label, domain: spec.yAxis.domain, grid: false, ticks: 6 }, - color: { legend: showLegend, domain: groups2, range: resolved.colors }, - marks: marks2 - }); + return renderHorizonLineChart(spec, options, "ppcr", "sensitivity"); } export { CalibrationSpecSchema, @@ -18806,6 +19084,8 @@ export { EvaluationSpecSchema, GainsV2SpecSchema, PrecisionRecallV2SpecSchema, + RTICHOKE_BROWSER_THEME, + RTICHOKE_COLORS3 as RTICHOKE_COLORS, ReferenceLineV2SpecSchema, RocSpecSchema, RocV2SpecSchema, @@ -18821,8 +19101,10 @@ export { renderPrecisionRecallV2, renderRoc, renderRocV2, + resolveV2RenderOptions, rocSpecFromRtichokePython, rocSpecFromRtichokeR, rocV2SpecFromRtichokePython, rocV2SpecFromRtichokeR }; + diff --git a/src/rtichoke/_viz_spec_v2.py b/src/rtichoke/_viz_spec_v2.py index b8e85103..7253d4b0 100644 --- a/src/rtichoke/_viz_spec_v2.py +++ b/src/rtichoke/_viz_spec_v2.py @@ -76,6 +76,185 @@ def _gains_v2_spec_from_performance_data( return spec +def _gains_times_v2_spec_from_performance_data( + performance_data: pl.DataFrame, + evaluation_metadata: Mapping[str, _EvaluationMetadata], +) -> dict[str, object]: + """Build canonical time-dependent gains from calculated production data.""" + required = _REQUIRED_GAINS_COLUMNS | { + "fixed_time_horizon", + "censoring_heuristic", + "competing_heuristic", + } + missing = required.difference(performance_data.columns) + if missing: + raise ValueError( + "Time-dependent gains performance data is missing columns: " + + ", ".join(sorted(missing)) + ) + + rows = performance_data.select( + "reference_group", + "fixed_time_horizon", + "censoring_heuristic", + "competing_heuristic", + "chosen_cutoff", + "sensitivity", + "ppcr", + ).to_dicts() + row_groups = {str(row["reference_group"]) for row in rows} + missing_metadata = row_groups.difference(evaluation_metadata) + if missing_metadata: + raise ValueError( + "Time-dependent gains 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) + } + evaluations = [] + 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) + + series_keys = list( + dict.fromkeys( + ( + str(row["reference_group"]), + float(row["fixed_time_horizon"]), + str(row["censoring_heuristic"]), + str(row["competing_heuristic"]), + ) + for row in rows + ) + ) + series_ids = { + key: f"series-{index}" for index, key in enumerate(series_keys, start=1) + } + series = [] + for key in series_keys: + group, horizon, _, _ = key + metadata = evaluation_metadata[group] + display_value = metadata.model or metadata.population + series.append( + { + "id": series_ids[key], + "evaluationId": evaluation_ids[group], + "horizon": horizon, + "display": { + "label": display_value, + "group": display_value, + "role": "model" if metadata.model is not None else "population", + }, + } + ) + + data = [] + for row in rows: + key = ( + str(row["reference_group"]), + float(row["fixed_time_horizon"]), + str(row["censoring_heuristic"]), + str(row["competing_heuristic"]), + ) + data.append( + { + "seriesId": series_ids[key], + "cutoff": row["chosen_cutoff"], + "ppcr": row["ppcr"], + "sensitivity": row["sensitivity"], + } + ) + + risks = _gains_population_horizon_risk(performance_data, evaluation_metadata) + references = [{"type": "identity", "scope": "global", "label": "Random"}] + for (population, horizon), risk in risks.items(): + references.append( + { + "type": "path", + "scope": "population_horizon", + "population": population, + "horizon": horizon, + "label": "Perfect Model", + "points": [ + {"x": 0, "y": 0}, + {"x": risk, "y": 1}, + {"x": 1, "y": 1}, + ], + } + ) + + return { + "schemaVersion": "2.0", + "type": "gains", + "evaluations": evaluations, + "series": series, + "data": data, + "x": "ppcr", + "y": "sensitivity", + "xAxis": {"label": "Predicted Positives (Rate)", "domain": [0, 1]}, + "yAxis": {"label": "Sensitivity", "domain": [0, 1]}, + "references": references, + } + + +def _gains_population_horizon_risk( + performance_data: pl.DataFrame, + evaluation_metadata: Mapping[str, _EvaluationMetadata], +) -> dict[tuple[str, float], float]: + """Map calculated cutoff-0 AJ event risk to semantic population/horizon.""" + group_risks = ( + performance_data.filter(pl.col("chosen_cutoff") == 0) + .select( + "reference_group", + "fixed_time_horizon", + (pl.col("real_positives") / pl.col("n")).alias("event_risk"), + ) + .unique() + .to_dicts() + ) + values: dict[tuple[str, float], set[float]] = {} + for row in group_risks: + group = str(row["reference_group"]) + metadata = evaluation_metadata.get(group) + if metadata is None: + continue + key = (metadata.population, float(row["fixed_time_horizon"])) + values.setdefault(key, set()).add(float(row["event_risk"])) + + populations = list( + dict.fromkeys(metadata.population for metadata in evaluation_metadata.values()) + ) + horizons = sorted( + float(value) + for value in performance_data["fixed_time_horizon"].unique().to_list() + ) + risks: dict[tuple[str, float], float] = {} + for key in ( + (population, horizon) + for horizon in horizons + for population in populations + if (population, horizon) in values + ): + candidates = values[key] + if len(candidates) != 1: + raise ValueError( + "Time-dependent gains must have one calculated event risk per " + f"population and horizon: {key[0]} at {key[1]}" + ) + risks[key] = next(iter(candidates)) + return risks + + def _gains_population_prevalence( performance_data: pl.DataFrame, evaluation_metadata: Mapping[str, _EvaluationMetadata], diff --git a/src/rtichoke/discrimination/gains.py b/src/rtichoke/discrimination/gains.py index 40a0fd16..a30b4d93 100644 --- a/src/rtichoke/discrimination/gains.py +++ b/src/rtichoke/discrimination/gains.py @@ -17,7 +17,10 @@ import numpy as np import polars as pl from rtichoke._renderers import _render_gains_v2, _validate_renderer -from rtichoke._viz_spec_v2 import _gains_v2_spec_from_performance_data +from rtichoke._viz_spec_v2 import ( + _gains_times_v2_spec_from_performance_data, + _gains_v2_spec_from_performance_data, +) from rtichoke.performance_data.performance_data import prepare_performance_data from rtichoke.processing.evaluation_semantics import _build_evaluation_metadata @@ -232,7 +235,8 @@ def create_gains_curve_times( "#D1603D", "#585123", ], -) -> Figure: + renderer: str = "plotly", +) -> Any: """Creates a time-dependent Gains curve. Generates a Gains curve for time-to-event models, which is evaluated at @@ -258,12 +262,41 @@ def create_gains_curve_times( The width and height of the plot in pixels. Defaults to 600. color_values : List[str], optional A list of hex color strings for the plot lines. + renderer : {"plotly", "matplotlib", "browser", "rtichoke_viz"}, optional + Rendering backend. Plotly remains the default production behavior. Returns ------- - Figure - A Plotly ``Figure`` object for the time-dependent Gains curve. + Figure or RtichokeBrowserChart + A Plotly or Matplotlib figure, or an offline browser chart, depending + on ``renderer``. """ + selected_renderer = _validate_renderer(renderer) + if selected_renderer != "plotly": + from rtichoke.performance_data.performance_data_times import ( + prepare_performance_data_times, + ) + + performance_data = prepare_performance_data_times( + probs, + reals, + times, + fixed_time_horizons=fixed_time_horizons, + heuristics_sets=heuristics_sets, + by=by, + stratified_by=stratified_by, + ) + evaluation_metadata = _build_evaluation_metadata(probs, reals, times) + spec = _gains_times_v2_spec_from_performance_data( + performance_data, evaluation_metadata + ) + return _render_gains_v2( + spec, + renderer=selected_renderer, + size=size, + color_values=color_values, + ) + return _create_rtichoke_plotly_curve_times_reference_safe( probs, reals, diff --git a/tests/test_rtichoke_viz_vendor.py b/tests/test_rtichoke_viz_vendor.py index aa069e3c..2e6618de 100644 --- a/tests/test_rtichoke_viz_vendor.py +++ b/tests/test_rtichoke_viz_vendor.py @@ -6,28 +6,28 @@ _VENDOR = Path(__file__).parents[1] / "src" / "rtichoke" / "_vendor" / "rtichoke_viz" -def test_vendored_rtichoke_viz_v030_provenance_archive_and_schemas(): +def test_vendored_rtichoke_viz_v031_provenance_archive_and_schemas(): provenance = (_VENDOR / "VENDORED_FROM").read_text() - assert "release=v0.3.0" in provenance - assert "source_commit=aca9188ea856167557efb20980a0b43e0481b8c8" in provenance - assert "archive=rtichoke-viz-0.3.0.tar.gz" in provenance + assert "release=v0.3.1" in provenance + assert "source_commit=5ccde928a0bf9fa6ece2b7572687b442c57a98a9" in provenance + assert "archive=rtichoke-viz-0.3.1.tar.gz" in provenance assert ( - "sha256=558f8d9e16f9544659b84e33f72511065163291a1b97a3c5511b61d1e1f0cac1" + "sha256=121aa8eb8d0f8427ecfb2c01dab0fb05668eaedf47ddcfc0cd282a7ecf1ce448" in provenance ) - archive = _VENDOR / "rtichoke-viz-0.3.0.tar.gz" + archive = _VENDOR / "rtichoke-viz-0.3.1.tar.gz" assert hashlib.sha256(archive.read_bytes()).hexdigest() == ( - "558f8d9e16f9544659b84e33f72511065163291a1b97a3c5511b61d1e1f0cac1" + "121aa8eb8d0f8427ecfb2c01dab0fb05668eaedf47ddcfc0cd282a7ecf1ce448" ) with tarfile.open(archive, "r:gz") as release: assert set(release.getnames()) == { - "rtichoke-viz-0.3.0", - "rtichoke-viz-0.3.0/MANIFEST", - "rtichoke-viz-0.3.0/rtichoke-viz.css", - "rtichoke-viz-0.3.0/rtichoke-viz.js", - "rtichoke-viz-0.3.0/rtichoke-viz.schema.json", - "rtichoke-viz-0.3.0/rtichoke-viz-v2.schema.json", + "rtichoke-viz-0.3.1", + "rtichoke-viz-0.3.1/MANIFEST", + "rtichoke-viz-0.3.1/rtichoke-viz.css", + "rtichoke-viz-0.3.1/rtichoke-viz.js", + "rtichoke-viz-0.3.1/rtichoke-viz.schema.json", + "rtichoke-viz-0.3.1/rtichoke-viz-v2.schema.json", } assert (_VENDOR / "rtichoke-viz.js").stat().st_size > 0 @@ -39,7 +39,7 @@ def test_vendored_rtichoke_viz_v030_provenance_archive_and_schemas(): assert '"$id": "https://rtichoke.dev/schema/viz/2.0.json"' in v2_schema -def test_v030_bundle_keeps_v1_and_adds_v2_browser_exports(): +def test_v031_bundle_keeps_v1_adds_v2_exports_and_time_horizon_control(): bundle = (_VENDOR / "rtichoke-viz.js").read_text(encoding="utf-8") for export_name in ( "renderRoc", @@ -49,3 +49,5 @@ def test_v030_bundle_keeps_v1_and_adds_v2_browser_exports(): "RtichokeChartSpecV2Schema", ): assert export_name in bundle + + assert "Fixed Time Horizon" in bundle diff --git a/tests/test_time_gains_v2.py b/tests/test_time_gains_v2.py new file mode 100644 index 00000000..3d01c8bd --- /dev/null +++ b/tests/test_time_gains_v2.py @@ -0,0 +1,166 @@ +from pathlib import Path + +import matplotlib.figure +import numpy as np +import plotly.graph_objects as go + +from rtichoke import create_gains_curve_times +from rtichoke._renderers import RtichokeBrowserChart +from rtichoke._viz_spec_v2 import _gains_times_v2_spec_from_performance_data +from rtichoke.performance_data.performance_data_times import ( + prepare_performance_data_times, +) +from rtichoke.processing.evaluation_semantics import ( + _SHARED_POPULATION, + _build_evaluation_metadata, +) + +HORIZONS = [5.0, 10.0] +HEURISTICS = [ + { + "censoring_heuristic": "adjusted", + "competing_heuristic": "adjusted_as_negative", + } +] + + +def _shared_inputs(): + return ( + { + "Model A": np.array([0.05, 0.15, 0.35, 0.55, 0.75, 0.95]), + "Model B": np.array([0.10, 0.25, 0.45, 0.65, 0.80, 0.90]), + }, + np.array([1, 0, 1, 0, 1, 0]), + np.array([3.0, 12.0, 8.0, 13.0, 14.0, 15.0]), + ) + + +def _spec(probs, reals, times): + performance = prepare_performance_data_times( + probs, + reals, + times, + fixed_time_horizons=HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + ) + return _gains_times_v2_spec_from_performance_data( + performance, _build_evaluation_metadata(probs, reals, times) + ) + + +def test_time_gains_uses_one_evaluation_and_series_per_model_horizon(): + probs, reals, times = _shared_inputs() + spec = _spec(probs, reals, times) + + assert len(spec["evaluations"]) == 2 + assert {evaluation["population"] for evaluation in spec["evaluations"]} == { + _SHARED_POPULATION + } + assert len(spec["series"]) == 4 + assert { + (series["display"]["group"], series["horizon"]) for series in spec["series"] + } == { + ("Model A", 5.0), + ("Model A", 10.0), + ("Model B", 5.0), + ("Model B", 10.0), + } + + perfect = spec["references"][1:] + assert len(perfect) == 2 + assert { + (reference["population"], reference["horizon"]) for reference in perfect + } == { + (_SHARED_POPULATION, 5.0), + (_SHARED_POPULATION, 10.0), + } + + +def test_equal_risk_population_horizons_remain_distinct_reference_owners(): + probs = { + "Population A": np.array([0.05, 0.2, 0.7, 0.95]), + "Population B": np.array([0.1, 0.4, 0.6, 0.9]), + } + reals = { + "Population A": np.array([1, 0, 0, 0]), + "Population B": np.array([1, 0, 0, 0]), + } + times = { + "Population A": np.array([3.0, 12.0, 13.0, 14.0]), + "Population B": np.array([3.0, 12.0, 13.0, 14.0]), + } + perfect = _spec(probs, reals, times)["references"][1:] + + assert len(perfect) == 4 + by_owner = { + (reference["population"], reference["horizon"]): reference["points"] + for reference in perfect + } + assert len(by_owner) == 4 + assert by_owner[("Population A", 5.0)] == by_owner[("Population B", 5.0)] + + +def test_censoring_and_competing_risk_reference_comes_from_performance_layer(): + probs = {"Model A": np.array([0.05, 0.2, 0.4, 0.6, 0.8, 0.95])} + reals = np.array([1, 0, 2, 1, 0, 2]) + times = np.array([2.0, 3.0, 4.0, 8.0, 12.0, 14.0]) + performance = prepare_performance_data_times( + probs, + reals, + times, + fixed_time_horizons=HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + ) + spec = _gains_times_v2_spec_from_performance_data( + performance, _build_evaluation_metadata(probs, reals, times) + ) + calculated = { + float(row["fixed_time_horizon"]): float(row["real_positives"] / row["n"]) + for row in performance.filter(performance["chosen_cutoff"] == 0) + .select("fixed_time_horizon", "real_positives", "n") + .unique() + .to_dicts() + } + + assert { + reference["horizon"]: reference["points"][1]["x"] + for reference in spec["references"][1:] + } == calculated + + +def test_time_gains_renderers_preserve_plotly_default_and_horizons(tmp_path: Path): + probs, reals, times = _shared_inputs() + default = create_gains_curve_times( + probs, reals, times, HORIZONS, heuristics_sets=HEURISTICS, by=0.25 + ) + assert isinstance(default, go.Figure) + + browser = create_gains_curve_times( + probs, + reals, + times, + HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + renderer="browser", + ) + assert isinstance(browser, RtichokeBrowserChart) + assert {series["horizon"] for series in browser.spec["series"]} == set(HORIZONS) + assert browser.write_html(tmp_path / "time-gains.html").is_file() + + matplotlib_result = create_gains_curve_times( + probs, + reals, + times, + HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + renderer="matplotlib", + ) + assert isinstance(matplotlib_result, matplotlib.figure.Figure) + assert [axis.get_title() for axis in matplotlib_result.axes] == [ + "Fixed Time Horizon: 5", + "Fixed Time Horizon: 10", + ]