diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index facc9e1c..49ef660e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,24 +22,18 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Install uv and Python uses: astral-sh/setup-uv@v5 with: python-version: '3.11' - - name: Set up Quarto uses: quarto-dev/quarto-actions/setup@v2 - - name: Install project and docs dependencies run: uv sync --group docs - - name: Build Great Docs site run: uv run great-docs build - - name: Export Great Tables performance table demo run: uv run marimo export html --no-include-code examples/performance_table_demo.py -o great-docs/_site/performance-table-demo.html - - name: Export Reactable performance table demo run: | uv run quarto render examples/performance_table_reactable.qmd --output performance-table-reactable.html @@ -47,7 +41,10 @@ jobs: grep -q 'Real Positive' performance-table-reactable.html mv performance-table-reactable.html great-docs/_site/performance-table-reactable.html cp -R examples/performance_table_reactable_files great-docs/_site/performance_table_reactable_files - + - name: Generate summary report demo + run: | + uv run python examples/summary_report_demo.py + mv summary-report-demo.html great-docs/_site/summary-report-demo.html - name: Publish documentation uses: JamesIves/github-pages-deploy-action@v4 with: @@ -63,29 +60,23 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Install uv and Python if: github.event.action != 'closed' uses: astral-sh/setup-uv@v5 with: python-version: '3.11' - - name: Set up Quarto if: github.event.action != 'closed' uses: quarto-dev/quarto-actions/setup@v2 - - name: Install project and docs dependencies if: github.event.action != 'closed' run: uv sync --group docs - - name: Build Great Docs preview if: github.event.action != 'closed' run: uv run great-docs build - - name: Export Great Tables performance table demo if: github.event.action != 'closed' run: uv run marimo export html --no-include-code examples/performance_table_demo.py -o great-docs/_site/performance-table-demo.html - - name: Export Reactable performance table demo if: github.event.action != 'closed' run: | @@ -94,7 +85,50 @@ jobs: grep -q 'Real Positive' performance-table-reactable.html mv performance-table-reactable.html great-docs/_site/performance-table-reactable.html cp -R examples/performance_table_reactable_files great-docs/_site/performance_table_reactable_files - + - name: Generate Python summary report demo + if: github.event.action != 'closed' + run: | + uv run python examples/summary_report_demo.py + test -s summary-report-demo.html + test -s summary-report-reference-data.csv + mv summary-report-demo.html great-docs/_site/summary-report-demo.html + - name: Set up R for reference report + if: github.event.action != 'closed' + uses: r-lib/actions/setup-r@v2 + - name: Install R reference report dependencies + if: github.event.action != 'closed' + uses: r-lib/actions/setup-r-dependencies@v2 + with: + packages: | + any::rmarkdown + any::knitr + github::uriahf/rtichoke + - name: Set up Pandoc for R Markdown + if: github.event.action != 'closed' + uses: r-lib/actions/setup-pandoc@v2 + - name: Render canonical R summary report + if: github.event.action != 'closed' + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + shell: Rscript {0} + run: | + dat <- read.csv("summary-report-reference-data.csv", check.names = FALSE) + reals <- dat[["reals"]] + probs <- list( + "Model A" = dat[["Model A"]], + "Model B" = dat[["Model B"]] + ) + rtichoke::create_summary_report( + probs = probs, + reals = list(reals), + output_file = "summary-report-r-reference.html", + output_dir = file.path(getwd(), "great-docs", "_site") + ) + stopifnot(file.info(file.path("great-docs", "_site", "summary-report-r-reference.html"))$size > 0) + - name: Record report sizes + if: github.event.action != 'closed' + run: | + wc -c great-docs/_site/summary-report-demo.html great-docs/_site/summary-report-r-reference.html | tee great-docs/_site/summary-report-sizes.txt - name: Deploy PR preview uses: rossjrw/pr-preview-action@v1 with: @@ -102,3 +136,15 @@ jobs: preview-branch: gh-pages umbrella-dir: pr-preview action: auto + - name: Verify report is in deployed documentation preview + if: github.event.action != 'closed' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + git fetch origin gh-pages + REPORT_PATH="pr-preview/pr-${PR_NUMBER}/summary-report-demo.html" + git cat-file -e "origin/gh-pages:${REPORT_PATH}" + echo "Verified ${REPORT_PATH} on gh-pages" + echo "### Summary report preview" >> "$GITHUB_STEP_SUMMARY" + echo "https://${GITHUB_REPOSITORY_OWNER}.github.io/${GITHUB_REPOSITORY#*/}/${REPORT_PATH}" >> "$GITHUB_STEP_SUMMARY" diff --git a/SUMMARY_REPORT_POC.md b/SUMMARY_REPORT_POC.md new file mode 100644 index 00000000..34023fe5 --- /dev/null +++ b/SUMMARY_REPORT_POC.md @@ -0,0 +1,5 @@ +# D3 summary report proof of concept + +This branch replaces the old R-backend `create_summary_report()` stub with a native Python binary report. It prepares the Polars performance table once and serializes only the columns needed by five D3 panels. The PR preview workflow publishes the generated example as `summary-report-demo.html`. + +The first preview deliberately uses the D3 CDN so the architecture and interaction can be reviewed before vendoring D3 into the package. A production version should bundle D3 to make the output genuinely self-contained. diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..4bb56bfd --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,9 @@ +# Benchmarks + +Run the summary-report preparation benchmark with: + +```bash +uv run python benchmarks/benchmark_summary_report.py +``` + +It compares the current repeated preparation pattern for five report panels with preparing the shared performance table once. diff --git a/benchmarks/benchmark_summary_report.py b/benchmarks/benchmark_summary_report.py new file mode 100644 index 00000000..61e2c407 --- /dev/null +++ b/benchmarks/benchmark_summary_report.py @@ -0,0 +1,30 @@ +"""Small local benchmark for repeated vs shared performance preparation.""" + +from time import perf_counter + +import numpy as np + +from rtichoke import prepare_performance_data + + +def run(n: int = 100_000, repeats: int = 5) -> None: + rng = np.random.default_rng(2026) + reals = rng.binomial(1, 0.25, n) + probs = {"model": np.clip(0.1 + 0.65 * reals + rng.normal(0, 0.18, n), 0, 1)} + + start = perf_counter() + for _ in range(repeats): + prepare_performance_data(probs, reals) + repeated = perf_counter() - start + + start = perf_counter() + performance_data = prepare_performance_data(probs, reals) + for _ in range(repeats): + _ = performance_data + shared = perf_counter() - start + + print(f"n={n:,}; repeated={repeated:.3f}s; shared={shared:.3f}s; ratio={repeated/shared:.2f}x") + + +if __name__ == "__main__": + run() diff --git a/examples/summary_report_demo.py b/examples/summary_report_demo.py new file mode 100644 index 00000000..47994bee --- /dev/null +++ b/examples/summary_report_demo.py @@ -0,0 +1,26 @@ +"""Generate the summary-report proof of concept used by PR previews.""" + +import csv + +import numpy as np + +from rtichoke import create_summary_report + +rng = np.random.default_rng(2026) +n = 800 +signal = rng.normal(size=n) +reals = rng.binomial(1, 1 / (1 + np.exp(-signal))) + +probs = { + "Model A": np.clip(1 / (1 + np.exp(-(0.9 * signal + rng.normal(0, 0.55, n)))), 0.001, 0.999), + "Model B": np.clip(1 / (1 + np.exp(-(0.6 * signal + rng.normal(0, 0.85, n)))), 0.001, 0.999), +} + +# The PR preview renders the canonical R report from this exact dataset. Using +# one serialized dataset avoids NumPy/R RNG differences obscuring visual parity. +with open("summary-report-reference-data.csv", "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(["reals", "Model A", "Model B"]) + writer.writerows(zip(reals, probs["Model A"], probs["Model B"], strict=True)) + +create_summary_report(probs, reals, output_file="summary-report-demo.html") diff --git a/scripts/reactable_embed_spike.py b/scripts/reactable_embed_spike.py new file mode 100644 index 00000000..915e991f --- /dev/null +++ b/scripts/reactable_embed_spike.py @@ -0,0 +1,56 @@ +"""Spike: export rtichoke's real Reactable performance table to standalone HTML. + +This deliberately avoids Quarto/Jupyter as report assemblers. It uses the +ipywidgets static embed protocol and the existing rtichoke Reactable renderer. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +from ipywidgets.embed import dependency_state, embed_data + +from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.performance_table_reactable import render_performance_table_reactable + + +def main() -> None: + reals = np.array([0, 0, 0, 0, 1, 1, 1, 1, 1, 1]) + probs = { + "Model A": np.array([0.05, 0.10, 0.15, 0.25, 0.35, 0.50, 0.60, 0.72, 0.82, 0.93]), + "Model B": np.array([0.10, 0.20, 0.30, 0.35, 0.40, 0.45, 0.55, 0.65, 0.75, 0.85]), + } + performance_data = prepare_performance_data( + probs=probs, + reals=reals, + stratified_by=("probability_threshold",), + by=0.05, + ) + table = render_performance_table_reactable( + performance_data=performance_data, + probs=probs, + reals=reals, + stratified_by="probability_threshold", + ) + widget = table.to_widget() + data = embed_data(views=[widget], state=dependency_state([widget])) + + html = f""" +Reactable standalone spike + + + + +

rtichoke Reactable standalone spike

+

No Quarto or running Jupyter kernel is used to view this page.

+ +""" + out = Path("reactable-standalone-spike.html") + out.write_text(html, encoding="utf-8") + print(f"Wrote {out} ({out.stat().st_size / 1024:.1f} KiB HTML payload)") + + +if __name__ == "__main__": + main() diff --git a/src/rtichoke/__init__.py b/src/rtichoke/__init__.py index 5e8b0855..e382ddcc 100644 --- a/src/rtichoke/__init__.py +++ b/src/rtichoke/__init__.py @@ -57,7 +57,7 @@ render_performance_table as render_performance_table, ) -from rtichoke.summary_report.summary_report import ( +from rtichoke.summary_report.summary_report_plotly import ( create_summary_report as create_summary_report, ) diff --git a/src/rtichoke/summary_report/calibration_renderer.README.md b/src/rtichoke/summary_report/calibration_renderer.README.md new file mode 100644 index 00000000..a11ab61d --- /dev/null +++ b/src/rtichoke/summary_report/calibration_renderer.README.md @@ -0,0 +1,7 @@ +# Calibration renderer + +`calibration_renderer.js` is the isolated D3 implementation used for calibration-parity work against the R `create_summary_report()` reference. + +The extraction keeps calibration geometry, histogram behavior, axes, legend, and hover behavior reviewable without mixing changes into discrimination, utility, or performance-table rendering. + +The next wiring step is to inject `calibration_renderer_source()` into the generated self-contained HTML in place of the inline `calibration()` implementation. Once wired, hover and visual parity changes should be made only in this renderer. diff --git a/src/rtichoke/summary_report/calibration_renderer.js b/src/rtichoke/summary_report/calibration_renderer.js new file mode 100644 index 00000000..3ba13941 --- /dev/null +++ b/src/rtichoke/summary_report/calibration_renderer.js @@ -0,0 +1,107 @@ +/* Calibration-only D3 renderer for the lightweight summary report. + * Mirrors the actual R Plotly calibration composition: a 550px figure, + * 80/20 shared-x subplot, markers+lines for discrete calibration, line-only + * smooth calibration, overlaid 0.01-wide histogram bars and horizontal legend. + */ +function calibration(type, sel) { + const c = R.calibration; + const card = d3.select(sel); + card.selectAll("*").remove(); + + const W = 550, H = 550; + const X0 = 60, X1 = 540; + const MAIN_TOP = 55, MAIN_BOTTOM = 409.9; + const HIST_TOP = 428.1, HIST_BOTTOM = 510; + const x = d3.scaleLinear().domain(c.ranges.xaxis).range([X0, X1]); + const y = d3.scaleLinear().domain(c.ranges.yaxis).range([MAIN_BOTTOM, MAIN_TOP]); + const histMax = d3.max(c.histogram, d => +d.counts) || 1; + const yHist = d3.scaleLinear().domain([0, histMax]).nice().range([HIST_BOTTOM, HIST_TOP]); + const svg = card.append("svg").attr("viewBox", `0 0 ${W} ${H}`); + + const contrastText = color => { + const hex = String(color || "#333").replace("#", ""); + if (!/^[0-9a-f]{6}$/i.test(hex)) return "white"; + const r = parseInt(hex.slice(0, 2), 16), g = parseInt(hex.slice(2, 4), 16), b = parseInt(hex.slice(4, 6), 16); + return (0.299 * r + 0.587 * g + 0.114 * b) > 170 ? "#222" : "white"; + }; + const showTip = (ev, html, color) => { + const bg = color || "#333"; + tip.style("opacity", 1).style("left", (ev.clientX + 10) + "px").style("top", (ev.clientY + 10) + "px") + .style("background", bg).style("border", "1px solid " + bg).style("border-radius", "2px") + .style("box-shadow", "none").style("padding", "6px 8px") + .style("font-family", "Open Sans, verdana, arial, sans-serif").style("font-size", "12px") + .style("line-height", "15px").style("color", contrastText(bg)).html(String(html || "")); + }; + const hideTip = () => tip.style("opacity", 0); + const groupOf = d => String(d.reference_group || ""); + const hoverColor = d => groupOf(d) === "reference_line" ? "#bebebe" : (c.colors[groupOf(d)] || "#777"); + + const defs = svg.append("defs"); + defs.append("clipPath").attr("id", `main-${type}`).append("rect").attr("x", X0).attr("y", MAIN_TOP).attr("width", X1-X0).attr("height", MAIN_BOTTOM-MAIN_TOP); + defs.append("clipPath").attr("id", `hist-${type}`).append("rect").attr("x", X0).attr("y", HIST_TOP).attr("width", X1-X0).attr("height", HIST_BOTTOM-HIST_TOP); + + // Plotly puts the calibration legend horizontally above the main panel and + // suppresses it entirely for a single model. + if (c.groups.length > 1) { + const lg = svg.append("g").attr("font-family", "Open Sans, verdana, arial, sans-serif").attr("font-size", 12); + const itemW = Math.max(82, 46 + d3.max(c.groups, g => String(g).length) * 7); + const total = itemW * c.groups.length, start = W/2-total/2; + c.groups.forEach((g,i) => { + const q=lg.append("g").attr("transform",`translate(${start+i*itemW},24)`); + q.append("line").attr("x1",5).attr("x2",35).attr("stroke",c.colors[g]).attr("stroke-width",2); + if(type === "discrete") q.append("circle").attr("cx",20).attr("cy",0).attr("r",5).attr("fill",c.colors[g]).attr("stroke-width",0); + q.append("text").attr("x",40).attr("y",4).attr("fill","#444").text(g); + }); + } + + const styleAxis = axis => { + axis.attr("font-family","Open Sans, verdana, arial, sans-serif").attr("font-size",12).attr("color","#444"); + axis.select(".domain").attr("stroke","#444"); + axis.selectAll(".tick line").attr("stroke","#444"); + }; + const ay=svg.append("g").attr("class","axis").attr("transform",`translate(${X0},0)`).call(d3.axisLeft(y).ticks(5)); + const ah=svg.append("g").attr("class","axis").attr("transform",`translate(${X0},0)`).call(d3.axisLeft(yHist).ticks(4)); + const ax=svg.append("g").attr("class","axis").attr("transform",`translate(0,${HIST_BOTTOM})`).call(d3.axisBottom(x).ticks(5)); + styleAxis(ay); styleAxis(ah); styleAxis(ax); + svg.append("text").attr("class","axis-label").attr("x",(X0+X1)/2).attr("y",548).attr("text-anchor","middle").text("Predicted"); + svg.append("text").attr("class","axis-label").attr("transform","rotate(-90)").attr("x",-(MAIN_TOP+MAIN_BOTTOM)/2).attr("y",18).attr("text-anchor","middle").text("Observed"); + + const line=d3.line().defined(d=>isFinite(+d.x)&&isFinite(+d.y)).x(d=>x(+d.x)).y(d=>y(+d.y)); + const main=svg.append("g").attr("clip-path",`url(#main-${type})`); + // Plotly's dash="dot" is visually closer to a short round dot pattern than + // the earlier equal 3/3 dash pattern. + main.append("path").datum(c.reference).attr("fill","none").attr("stroke","#bebebe").attr("stroke-width",2) + .attr("stroke-dasharray","2,4").attr("stroke-linecap","round").attr("d",line); + + const dat=type === "smooth" ? c.smooth : c.deciles; + c.groups.forEach(g => { + const a=dat.filter(d=>String(d.reference_group)===g); + main.append("path").datum(a).attr("fill","none").attr("stroke",c.colors[g]).attr("stroke-width",2) + .attr("stroke-linejoin","round").attr("stroke-linecap","round").attr("d",line); + if(type === "discrete") main.selectAll(null).data(a).enter().append("circle") + .attr("cx",d=>x(+d.x)).attr("cy",d=>y(+d.y)).attr("r",5) + .attr("fill",c.colors[g]).attr("stroke",c.colors[g]).attr("stroke-width",0) + .attr("shape-rendering","geometricPrecision"); + }); + + // R uses plotly::add_bars(width=.01, barmode="overlay") with opacity + // 1/n_groups. Use the exact bin edges rather than a hand-tuned pixel width. + const hist=svg.append("g").attr("clip-path",`url(#hist-${type})`), opacity=1/Math.max(1,c.groups.length); + c.histogram.forEach(d => { + const mid=+d.mids,left=x(mid-.005),right=x(mid+.005); + hist.append("rect").attr("x",left).attr("width",Math.max(0,right-left)).attr("y",yHist(+d.counts)).attr("height",HIST_BOTTOM-yHist(+d.counts)) + .attr("fill",c.colors[String(d.reference_group)]||"#777").attr("opacity",opacity).attr("stroke","none") + .on("mousemove",ev=>showTip(ev,d.text,hoverColor(d))).on("mouseleave",hideTip); + }); + + // Plotly scatter hover is point based. Keep the nearest-point interaction, + // but use a smaller capture radius so the tooltip does not jump to a remote + // calibration point while moving through empty plot space. + const hoverData=dat.concat(c.reference); + svg.append("rect").attr("x",X0).attr("y",MAIN_TOP).attr("width",X1-X0).attr("height",MAIN_BOTTOM-MAIN_TOP).attr("fill","transparent") + .on("mousemove",ev=>{ + const [mx,my]=d3.pointer(ev);let best=null,dist=Infinity; + hoverData.forEach(d=>{if(!isFinite(+d.x)||!isFinite(+d.y))return;const dd=(x(+d.x)-mx)**2+(y(+d.y)-my)**2;if(dd str: + """Return the lightweight D3 renderer sources embedded in the report.""" + root = Path(__file__).parent + assets = ( + root / "calibration_renderer.js", + root / "performance_table_renderer.js", + ) + return "\n".join(path.read_text(encoding="utf-8") for path in assets) diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js new file mode 100644 index 00000000..fd3a2cd5 --- /dev/null +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -0,0 +1,37 @@ +/* D3 renderer for performance and decision curves in the lightweight report. + * Mirrors the R Plotly geometry used by create_summary_report(): a 500px-wide, + * 550px-high widget with the animation slider inside the widget and no internal + * title. ROC/Lift/etc. are supplied by the tab headings. + */ +function drawRtichokeCurve(s, sel, strat) { + const card=d3.select(sel); card.selectAll("*").remove(); + card.style("width","500px").style("height","550px").style("max-width","100%").style("margin-left","0").style("margin-right","0").style("position","relative"); + const W=500,H=550,m={top:25,right:10,bottom:120,left:60}; + const svg=card.append("svg").style("width","500px").style("height","550px").style("max-width","100%").style("margin","0").attr("viewBox",`0 0 ${W} ${H}`),x=d3.scaleLinear().domain(s.x_range).range([m.left,W-m.right]),y=d3.scaleLinear().domain(s.y_range).range([H-m.bottom,m.top]); + const line=d3.line().defined(d=>isFinite(+d.x)&&isFinite(+d.y)).x(d=>x(+d.x)).y(d=>y(+d.y)); + const styleAxis=a=>{a.attr("font-family","Open Sans, verdana, arial, sans-serif").attr("font-size",12).attr("color","#444");a.select(".domain").attr("stroke","#444");a.selectAll(".tick line").attr("stroke","#444")}; + const xa=svg.append("g").attr("class","axis").attr("transform",`translate(0,${H-m.bottom})`).call(d3.axisBottom(x).ticks(6)),ya=svg.append("g").attr("class","axis").attr("transform",`translate(${m.left},0)`).call(d3.axisLeft(y).ticks(6)); styleAxis(xa);styleAxis(ya); + svg.append("text").attr("class","axis-label").attr("x",(m.left+W-m.right)/2).attr("y",H-m.bottom+42).attr("text-anchor","middle").text(s.x_label); + svg.append("text").attr("class","axis-label").attr("transform","rotate(-90)").attr("x",-(m.top+H-m.bottom)/2).attr("y",18).attr("text-anchor","middle").text(s.y_label); + const strategyColor=g=>{const k=String(g||"").toLowerCase();if(k==="treat_none")return "#808080";return s.colors[g]||"#BEBEBE"}; + const traces=[]; + d3.group(s.references,d=>String(d.reference_group)).forEach((a,g)=>{const color=strategyColor(g);svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-dasharray","2,4").attr("stroke-linecap","round").attr("d",line);traces.push(...a.map(d=>({...d,_color:color})))}); + const single=s.groups.length===1; + s.groups.forEach(g=>{const a=s.data.filter(d=>String(d.reference_group)===g),color=single?"black":(s.colors[g]||"#000");svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-linejoin","round").attr("stroke-linecap","round").attr("d",line);traces.push(...a.map(d=>({...d,_color:color})))}); + + const strataField=strat==="ppcr"?"ppcr":"chosen_cutoff"; + const strata=[...new Set(s.data.map(d=>+d[strataField]).filter(Number.isFinite))].sort((a,b)=>a-b); + if(strata.length>1){ + const wrap=card.append("div").attr("class","slider-wrap curve-slider-wrap").style("position","absolute").style("left","60px").style("bottom","12px").style("width","430px").style("max-width","calc(100% - 70px)").style("margin","0"); + const label=wrap.append("div").attr("class","slider-label curve-slider-label"); + const prefix=strat==="ppcr"?"Predicted Positives (Rate):":"Prob. Threshold:"; + const input=wrap.append("input").attr("class","curve-slider").attr("type","range").attr("min",strata[0]).attr("max",strata[strata.length-1]).attr("step",Math.max(1e-6,...strata.slice(1).map((v,i)=>v-strata[i]).filter(v=>v>0).slice(0,1))).node(); + input.value=strata[0]; + const update=()=>{const v=+input.value;label.textContent=`${prefix} ${Number.isFinite(v)?v.toFixed(2):""}`}; + input.addEventListener("input",update);update(); + } + + const contrast=color=>{const h=String(color||"#333").replace("#","");if(!/^[0-9a-f]{6}$/i.test(h))return "white";const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);return(.299*r+.587*g+.114*b)>170?"#222":"white"}; + const show=(ev,d)=>tip.style("opacity",1).style("left",(ev.clientX+10)+"px").style("top",(ev.clientY+10)+"px").style("background",d._color).style("border","1px solid "+d._color).style("color",contrast(d._color)).style("padding","6px 8px").style("border-radius","2px").style("font-family","Open Sans, verdana, arial, sans-serif").style("font-size","12px").style("line-height","15px").html(String(d.text||"")),hide=()=>tip.style("opacity",0); + svg.append("rect").attr("x",m.left).attr("y",m.top).attr("width",W-m.left-m.right).attr("height",H-m.top-m.bottom).attr("fill","transparent").on("mousemove",ev=>{const[mx,my]=d3.pointer(ev);let best=null,dist=Infinity;traces.forEach(d=>{if(!isFinite(+d.x)||!isFinite(+d.y))return;const dd=(x(+d.x)-mx)**2+(y(+d.y)-my)**2;if(dd str: + """Return the D3 performance/decision curve renderer source.""" + return Path(__file__).with_name("curve_renderer.js").read_text(encoding="utf-8") diff --git a/src/rtichoke/summary_report/microd3.js b/src/rtichoke/summary_report/microd3.js new file mode 100644 index 00000000..a2cbe6f9 --- /dev/null +++ b/src/rtichoke/summary_report/microd3.js @@ -0,0 +1,44 @@ +/* Minimal D3-compatible runtime used by rtichoke summary reports. + * Implements only the selection, linear-scale, line, axis, grouping, max and + * pointer primitives used by the report renderers. Keeping this tiny runtime + * inline makes generated reports self-contained without shipping the full D3 + * distribution or requiring a CDN at viewing time. + */ +(function(global){ + const SVG='http://www.w3.org/2000/svg'; + const svgTags=new Set(['svg','g','path','rect','circle','line','text','defs','clipPath']); + const create=(parent,tag)=>svgTags.has(tag)||parent.namespaceURI===SVG?document.createElementNS(SVG,tag):document.createElement(tag); + class Selection{ + constructor(nodes,parents=null){this.nodes=(nodes||[]).filter(Boolean);this.parents=parents||[];this._data=null;} + node(){return this.nodes[0]||null} + append(tag){const out=[];this.nodes.forEach(n=>{const e=create(n,tag);e.__data__=n.__data__;n.appendChild(e);out.push(e)});return new Selection(out,this.nodes)} + select(q){return new Selection(this.nodes.map(n=>typeof q==='function'?q.call(n,n.__data__):n.querySelector(q)).filter(Boolean),this.nodes)} + selectAll(q){let out=[];this.nodes.forEach(n=>out.push(...(typeof q==='function'?q.call(n,n.__data__):n.querySelectorAll(q))));return new Selection(out,this.nodes)} + remove(){this.nodes.forEach(n=>n.remove());return this} + attr(k,v){if(arguments.length===1)return this.node()?.getAttribute(k);this.nodes.forEach((n,i)=>{const x=typeof v==='function'?v.call(n,n.__data__,i):v;x==null?n.removeAttribute(k):n.setAttribute(k,x)});return this} + style(k,v){if(arguments.length===1)return getComputedStyle(this.node()).getPropertyValue(k);this.nodes.forEach((n,i)=>n.style.setProperty(k,typeof v==='function'?v.call(n,n.__data__,i):v));return this} + text(v){this.nodes.forEach((n,i)=>n.textContent=typeof v==='function'?v.call(n,n.__data__,i):v);return this} + html(v){this.nodes.forEach((n,i)=>n.innerHTML=typeof v==='function'?v.call(n,n.__data__,i):v);return this} + classed(k,v){this.nodes.forEach((n,i)=>n.classList.toggle(k,!!(typeof v==='function'?v.call(n,n.__data__,i):v)));return this} + datum(v){if(!arguments.length)return this.node()?.__data__;this.nodes.forEach(n=>n.__data__=v);return this} + data(v){this._data=Array.from(v||[]);return this} + enter(){return new EnterSelection(this.parents.length?this.parents:(this.nodes[0]?.parentNode?[this.nodes[0].parentNode]:[]),this._data||[])} + on(type,fn){this.nodes.forEach(n=>n.addEventListener(type,e=>fn.call(n,e,n.__data__)));return this} + call(fn,...args){fn(this,...args);return this} + each(fn){this.nodes.forEach((n,i)=>fn.call(n,n.__data__,i,this.nodes));return this} + } + class EnterSelection{ + constructor(parents,data){this.parents=parents;this.dataValues=data} + append(tag){const out=[],p=this.parents[0];if(!p)return new Selection([]);this.dataValues.forEach(d=>{const e=create(p,tag);e.__data__=d;p.appendChild(e);out.push(e)});return new Selection(out,[p])} + } + function select(q){return new Selection([typeof q==='string'?document.querySelector(q):q])} + function ticks(a,b,count=10){if(!isFinite(a)||!isFinite(b)||a===b)return[a];const span=Math.abs(b-a),raw=span/Math.max(1,count),pow=10**Math.floor(Math.log10(raw)),err=raw/pow,step=(err>=7.5?10:err>=3.5?5:err>=1.5?2:1)*pow,lo=Math.ceil(Math.min(a,b)/step)*step,hi=Math.floor(Math.max(a,b)/step)*step,out=[];for(let x=lo;x<=hi+step*1e-9;x+=step)out.push(+x.toPrecision(12));return a>b?out.reverse():out} + function scaleLinear(){let dom=[0,1],ran=[0,1];const s=x=>ran[0]+(x-dom[0])/(dom[1]-dom[0]||1)*(ran[1]-ran[0]);s.domain=function(x){if(!arguments.length)return dom.slice();dom=Array.from(x,Number);return s};s.range=function(x){if(!arguments.length)return ran.slice();ran=Array.from(x,Number);return s};s.copy=()=>scaleLinear().domain(dom).range(ran);s.ticks=(count=10)=>ticks(dom[0],dom[1],count);s.nice=()=>{const ts=ticks(dom[0],dom[1],10);if(ts.length)dom=[Math.min(dom[0],ts[0]),Math.max(dom[1],ts[ts.length-1])];return s};return s} + function fmt(x){if(Math.abs(x)>=1000||Math.abs(x)>0&&Math.abs(x)<1e-4)return x.toExponential(0);return String(+x.toFixed(6))} + function axis(scale,orient){let count=10;const fn=sel=>{const root=sel.node();if(!root)return;while(root.firstChild)root.removeChild(root.firstChild);const r=scale.range(),vals=scale.ticks(count),horizontal=orient==='bottom',domain=document.createElementNS(SVG,'path');domain.setAttribute('class','domain');domain.setAttribute('fill','none');domain.setAttribute('stroke','currentColor');domain.setAttribute('d',horizontal?`M${r[0]},0H${r[1]}`:`M0,${r[0]}V${r[1]}`);root.appendChild(domain);vals.forEach(v=>{const g=document.createElementNS(SVG,'g');g.setAttribute('class','tick');g.setAttribute('transform',horizontal?`translate(${scale(v)},0)`:`translate(0,${scale(v)})`);const l=document.createElementNS(SVG,'line');l.setAttribute('stroke','currentColor');horizontal?l.setAttribute('y2','6'):l.setAttribute('x2','-6');const t=document.createElementNS(SVG,'text');t.setAttribute('fill','currentColor');t.setAttribute('font-size','10');t.setAttribute('font-family','sans-serif');if(horizontal){t.setAttribute('y','9');t.setAttribute('dy','0.71em');t.setAttribute('text-anchor','middle')}else{t.setAttribute('x','-9');t.setAttribute('dy','0.32em');t.setAttribute('text-anchor','end')}t.textContent=fmt(v);g.append(l,t);root.appendChild(g)})};fn.ticks=n=>(count=n,fn);return fn} + function line(){let fx=d=>d[0],fy=d=>d[1],defined=()=>true;const gen=data=>{let out='',started=false;for(const d of data||[]){if(!defined(d)){started=false;continue}const x=fx(d),y=fy(d);if(!isFinite(x)||!isFinite(y)){started=false;continue}out+=(started?'L':'M')+x+','+y;started=true}return out};gen.x=f=>(fx=f,gen);gen.y=f=>(fy=f,gen);gen.defined=f=>(defined=f,gen);return gen} + function group(values,key){const m=new Map;for(const v of values||[]){const k=key(v);if(!m.has(k))m.set(k,[]);m.get(k).push(v)}return m} + function max(values,accessor=x=>x){let out=-Infinity;for(const v of values||[]){const x=+accessor(v);if(isFinite(x)&&x>out)out=x}return out===-Infinity?undefined:out} + function pointer(ev,node=ev.currentTarget){const r=node.getBoundingClientRect(),vb=node.viewBox?.baseVal;if(vb&&r.width&&r.height)return[(ev.clientX-r.left)*vb.width/r.width+vb.x,(ev.clientY-r.top)*vb.height/r.height+vb.y];return[ev.clientX-r.left,ev.clientY-r.top]} + global.d3={select,scaleLinear,line,axisBottom:s=>axis(s,'bottom'),axisLeft:s=>axis(s,'left'),group,max,pointer}; +})(globalThis); diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js new file mode 100644 index 00000000..4ef0fa5c --- /dev/null +++ b/src/rtichoke/summary_report/performance_table_renderer.js @@ -0,0 +1,175 @@ +/* R/Reactable-parity renderer for lightweight summary-report performance tables. */ +(function () { + const COLORS = ["#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#07004D", "#E6AB02", "#FE5F55", "#54494B", "#006E90", "#BC96E6", "#52050A", "#1F271B", "#BE7C4D", "#63768D", "#08A045", "#320A28", "#82FF9E", "#2176FF", "#D1603D", "#585123"]; + const PAGE_SIZE = 10; + const fmt = v => typeof v === "number" && isFinite(v) ? v.toFixed(2) : (v ?? ""); + const pct = v => typeof v === "number" && isFinite(v) ? `${(100 * v).toFixed(2)}%` : ""; + const num = v => typeof v === "number" && isFinite(v) ? v : 0; + const esc = v => String(v ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c])); + + if (!document.getElementById("rt-perf-control-style")) { + const style=document.createElement("style"); + style.id="rt-perf-control-style"; + style.textContent=` + .rt-check-inline{display:inline-flex!important;align-items:center;padding-left:0!important;margin-right:14px!important;gap:6px} + .rt-check-inline input{position:absolute!important;opacity:0;pointer-events:none;margin:0!important} + .rt-check-box{width:16px;height:16px;border:2px solid #333;border-radius:2px;display:grid;place-content:center;background:#fff;flex:0 0 auto} + .rt-check-box>span{width:10px;height:10px;transform:scale(0);transition:transform .08s linear} + .rt-dual-range{position:relative!important;height:50px!important;margin-top:2px!important} + .rt-range-track{position:absolute;left:8px;right:8px;top:29px;height:6px;background:#e1e1e1;border-radius:3px} + .rt-range-fill{position:absolute;top:0;height:6px;background:#337ab7;border-radius:3px} + .rt-range-bubble{position:absolute;top:0;transform:translateX(-50%);padding:1px 5px;min-width:34px;text-align:center;background:#337ab7;color:#fff;border-radius:3px;font-size:11px;line-height:18px;white-space:nowrap} + .rt-dual-range input[type=range]{-webkit-appearance:none;appearance:none;position:absolute!important;left:0!important;top:20px!important;width:100%!important;height:24px;margin:0!important;background:transparent!important;pointer-events:none!important;outline:none} + .rt-dual-range input[type=range]::-webkit-slider-runnable-track{height:6px;background:transparent;border:0} + .rt-dual-range input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;margin-top:-6px;border:1px solid #999;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);pointer-events:auto} + .rt-dual-range input[type=range]::-moz-range-track{height:6px;background:transparent;border:0} + .rt-dual-range input[type=range]::-moz-range-thumb{width:18px;height:18px;border:1px solid #999;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);pointer-events:auto} + .rt-range-readout{display:none!important} + `; + document.head.appendChild(style); + } + + function metricBackground(value, maxValue=1, color="lightgreen") { + if (!isFinite(+value) || maxValue <= 0) return ""; + const width = Math.min(Math.abs(+value) / maxValue, 1) * 100; + return `linear-gradient(90deg, ${color} ${width}%, transparent ${width}%)`; + } + + function nbBackground(value, maximum) { + if (!isFinite(+value) || maximum <= 0) return ""; + const width = Math.max(-1, Math.min(+value / maximum, 1)); + const position = (0.5 + width / 2) * 100; + return width >= 0 + ? `linear-gradient(90deg, transparent 50%, lightgreen 50%, lightgreen ${position}%, transparent ${position}%)` + : `linear-gradient(90deg, transparent ${position}%, pink ${position}%, pink 50%, transparent 50%)`; + } + + function confusionMatrix(r) { + const fp=num(r.false_positives), tn=num(r.true_negatives), fn=num(r.false_negatives); + const tp=r.true_positives == null ? Math.max(0, num(r.predicted_positives)-fp) : num(r.true_positives); + const total=tp+tn+fp+fn || 1; + const rows=[ + ["Predicted Positive",tp,fp,"lightgreen","pink"], + ["Predicted Negative",fn,tn,"pink","lightgreen"], + [" ",tp+fn,fp+tn,"lightgrey","lightgrey"] + ]; + const value=(x)=>`${fmt(x)} (${(100*x/total).toFixed(2)}%)`; + return `${rows.map(q=>``).join("")}
Real PositiveReal Negative
${q[0]}${value(q[1])}${value(q[2])}${value(q[1]+q[2])}
`; + } + + function render(rows, selector, isPpcr) { + const host=document.querySelector(selector); if(!host) return; + host.innerHTML=""; + const models=[...new Set(rows.map(r=>String(r.reference_group ?? "")))]; + const colors=Object.fromEntries(models.map((m,i)=>[m,COLORS[i%COLORS.length]])); + const liftMax=Math.max(1e-12,...rows.map(r=>Math.abs(num(r.lift)))); + const nbMax=Math.max(1e-12,...rows.map(r=>Math.abs(num(r.net_benefit)))); + const valueOf=r=>isPpcr?num(r.ppcr):num(r.chosen_cutoff); + const sorted=[...rows].sort((a,b)=>valueOf(a)-valueOf(b)); + const values=sorted.map(valueOf).filter(Number.isFinite); + const minValue=values.length?Math.min(...values):0, maxValue=values.length?Math.max(...values):1; + const step=Math.max(0.000001, ...values.slice(1).map((v,i)=>v-values[i]).filter(v=>v>0).slice(0,1), 0.01); + const selected=new Set(); + let lower=minValue, upper=maxValue, page=0; + + // R crosstalk::bscols(widths = c(12, 6, 12)): group selector on a + // full row, slider on a half-width row, then the full-width Reactable. + const filters=document.createElement("div"); filters.className="rt-filters"; filters.style.display="block"; filters.style.marginBottom="15px"; + const modelFilter=document.createElement("div"); modelFilter.className="rt-filter-models"; modelFilter.style.width="100%"; modelFilter.style.marginBottom="15px"; + const modelLabel=document.createElement("div"); modelLabel.className="rt-filter-label"; modelLabel.textContent="Model"; modelFilter.appendChild(modelLabel); + models.forEach((model,i)=>{ + const label=document.createElement("label"); label.className="rt-check-inline"; + const input=document.createElement("input"); input.type="checkbox"; input.value=model; + const box=document.createElement("span"); box.className="rt-check-box"; + const boxFill=document.createElement("span"); boxFill.style.background=colors[model]||COLORS[i%COLORS.length]; box.appendChild(boxFill); + const text=document.createElement("span"); text.textContent=model; label.append(input,box,text); modelFilter.appendChild(label); + input.addEventListener("change",()=>{input.checked?selected.add(model):selected.delete(model);boxFill.style.transform=input.checked?"scale(1)":"scale(0)";page=0;drawPage();}); + }); + const rangeFilter=document.createElement("div"); rangeFilter.className="rt-filter-range"; rangeFilter.style.width="50%"; rangeFilter.style.maxWidth="520px"; rangeFilter.style.minWidth="300px"; + const rangeLabel=document.createElement("div"); rangeLabel.className="rt-filter-label"; rangeLabel.textContent=isPpcr?"Predicted Positives Condition Rate (PPCR)":"Probability Threshold"; + const rangeReadout=document.createElement("span"); rangeReadout.className="rt-range-readout"; + const track=document.createElement("div"); track.className="rt-dual-range"; + const rail=document.createElement("div"); rail.className="rt-range-track"; + const fill=document.createElement("div"); fill.className="rt-range-fill"; rail.appendChild(fill); + const loBubble=document.createElement("span"), hiBubble=document.createElement("span"); loBubble.className=hiBubble.className="rt-range-bubble"; + const lo=document.createElement("input"), hi=document.createElement("input"); + [lo,hi].forEach(input=>{input.type="range";input.min=minValue;input.max=maxValue;input.step=step;}); lo.value=minValue; hi.value=maxValue; + const sync=(redraw=true)=>{ + lower=Math.min(+lo.value,+hi.value);upper=Math.max(+lo.value,+hi.value);rangeReadout.textContent=`${fmt(lower)} – ${fmt(upper)}`; + const span=maxValue-minValue||1, lp=100*(lower-minValue)/span, hp=100*(upper-minValue)/span; + fill.style.left=`${lp}%`;fill.style.width=`${Math.max(0,hp-lp)}%`; + loBubble.textContent=fmt(lower);hiBubble.textContent=fmt(upper);loBubble.style.left=`${lp}%`;hiBubble.style.left=`${hp}%`; + if(redraw){page=0;drawPage();} + }; + lo.addEventListener("input",sync); hi.addEventListener("input",sync); track.append(rail,loBubble,hiBubble,lo,hi); rangeFilter.append(rangeLabel,rangeReadout,track); + if(models.length>1) filters.appendChild(modelFilter); + filters.appendChild(rangeFilter); host.appendChild(filters); + sync(false); + + const wrap=document.createElement("div"); wrap.className="rt-perf-wrap"; + const table=document.createElement("table"); table.className="rt-perf"; + if(isPpcr) { + table.innerHTML='ModelPredicted PositivesPerformance MetricsNet BenefitSensSpecPPVNPVLift'; + } else { + table.innerHTML='Probability ThresholdModelPerformance MetricsPredicted PositivesSensSpecPPVNPVLiftNet Benefit'; + } + const body=document.createElement("tbody"); table.appendChild(body); wrap.appendChild(table); host.appendChild(wrap); + const pager=document.createElement("div"); pager.className="rt-pager"; host.appendChild(pager); + + function filteredRows() { + return sorted.filter(r=>{ + const model=String(r.reference_group ?? ""), value=valueOf(r); + return (!selected.size||selected.has(model)) && value>=lower-1e-12 && value<=upper+1e-12; + }); + } + + function drawPage() { + const filtered=filteredRows(); + const pages=Math.max(1,Math.ceil(filtered.length/PAGE_SIZE)); if(page>=pages) page=pages-1; + body.innerHTML=""; + const start=page*PAGE_SIZE, pageRows=filtered.slice(start,start+PAGE_SIZE); + let previousThreshold=null; + pageRows.forEach((r,rowIndex)=>{ + const tr=document.createElement("tr"); + const model=String(r.reference_group ?? ""); + const ppcrText=`${fmt(r.predicted_positives)} (${pct(r.ppcr)})`; + const modelCell=`${esc(model)}`; + const metrics=[["sensitivity",1],["specificity",1],["ppv",1],["npv",1],["lift",liftMax]]; + const metricCells=metrics.map(([k,m])=>`${fmt(r[k])}`).join(""); + const nbCell=`${fmt(r.net_benefit)}`; + const ppcrCell=`${ppcrText}`; + if(isPpcr) { + tr.innerHTML=`›${modelCell}${ppcrCell}${metricCells}${nbCell}`; + } else { + const threshold=fmt(r.chosen_cutoff); + const repeated=rowIndex>0 && Math.abs(num(r.chosen_cutoff)-previousThreshold)<1e-12; + const thresholdCell=`${threshold}`; + tr.innerHTML=`›${thresholdCell}${modelCell}${metricCells}${nbCell}${ppcrCell}`; + previousThreshold=num(r.chosen_cutoff); + } + const detail=document.createElement("tr"); detail.className="detail"; detail.style.display="none"; + const td=document.createElement("td"); td.colSpan=isPpcr?9:10; td.innerHTML=confusionMatrix(r); detail.appendChild(td); + tr.querySelector(".expand").addEventListener("click",e=>{const open=detail.style.display!=="none"; detail.style.display=open?"none":"table-row"; e.currentTarget.textContent=open?"›":"⌄";}); + body.appendChild(tr); body.appendChild(detail); + }); + if (pages <= 1) { pager.hidden=true; return; } + pager.hidden=false; pager.innerHTML=""; + const info=document.createElement("span"); info.className="rt-page-info"; + info.textContent=filtered.length?`${start+1}–${Math.min(start+PAGE_SIZE,filtered.length)} of ${filtered.length} rows`:`0 rows`; + const controls=document.createElement("span"); controls.className="rt-page-controls"; + const button=(label,target,disabled,current=false)=>{const b=document.createElement("button");b.textContent=label;b.disabled=disabled;b.className=current?"active":"";b.addEventListener("click",()=>{page=target;drawPage();});return b;}; + controls.appendChild(button("Previous",Math.max(0,page-1),page===0)); + for(let i=0;i{ + if (window.R && R.tables) { + render(R.tables.threshold,"#table-threshold",false); + render(R.tables.ppcr,"#table-ppcr",true); + } + }); +})(); diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css new file mode 100644 index 00000000..0ed924a8 --- /dev/null +++ b/src/rtichoke/summary_report/report_style.css @@ -0,0 +1,47 @@ +/* Authoritative R-parity stylesheet for the lightweight summary report. */ +:root { --rt-text:#333; --rt-muted:#666; --rt-border:#ddd; --rt-panel:#fff; } +* { box-sizing:border-box; } +html,body { background:#fff; color:var(--rt-text); font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; font-size:14px; line-height:1.42857143; } +body { margin:0; } +.main-container,.container,.report,main { max-width:1040px; margin-left:auto; margin-right:auto; } +.main-container { padding:20px 15px 60px; } +h1,h2,h3,h4 { color:var(--rt-text); font-family:inherit; font-weight:500; line-height:1.1; } +h1 { font-size:36px; margin:20px 0 10px; } h2 { font-size:30px; margin:20px 0 10px; } h3 { font-size:24px; margin:20px 0 10px; } +#calibration,#discrimination,#utility,#utility-decision-curve,#performance-table { margin-top:20px; margin-bottom:30px; } +#TOC,.rt-toc { margin:15px 0 28px; } #TOC ul,.rt-toc ul { margin:0; padding-left:20px; } #TOC>ul { padding-left:0; list-style:none; } #TOC a,.rt-toc a { color:#337ab7; text-decoration:none; } #TOC a:hover,.rt-toc a:hover { color:#23527c; text-decoration:underline; } +details { margin:0 0 20px; } summary { cursor:pointer; } summary p { display:inline; } .cheat { padding-top:15px; line-height:1.75; } +.metric-cheat-sheet { margin:0 0 20px; }.metric-cheat-sheet > summary { cursor:pointer; display:list-item; font-size:14px; }.metric-cheat-sheet > summary::marker { color:#555; }.metric-cheat-sheet table { width:310px; margin-top:18px; table-layout:fixed; }.metric-cheat-sheet th,.metric-cheat-sheet td { text-align:center; border-bottom:0; padding:7px; }.metric-cheat-sheet th:first-child { width:110px; text-align:left; font-weight:400; }.metric-cheat-sheet thead th { font-weight:400; background:#fff; }.metric-cheat-sheet td { width:100px; font-weight:600; }.metric-cheat-sheet .cm-tp,.metric-cheat-sheet .cm-tn,.good { background:lightgreen; font-weight:600; }.metric-cheat-sheet .cm-fp,.metric-cheat-sheet .cm-fn,.bad { background:pink; font-weight:600; } +.metric-formulas { margin:28px 0 18px; font-family:"STIXGeneral-Regular","Times New Roman",serif; font-size:16px; }.metric-formulas>div { margin:23px 0; white-space:normal; }.frac { display:inline-flex; vertical-align:middle; flex-direction:column; text-align:center; line-height:1.15; margin:0 .22em; }.frac>span:first-child { border-bottom:1px solid #333; padding:0 .18em .08em; }.frac>span:last-child { padding:.08em .18em 0; } +#prev { width:345px; max-width:100%; margin:28px 0 20px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.prevalence-row { display:grid; grid-template-columns:45px 300px; border-bottom:1px solid #eee; position:relative; }.prevalence-expander { grid-row:1; width:45px; border:0; background:#fff; color:#777; font-size:18px; cursor:pointer; }.prevalence-cell { grid-row:1; padding:7px 10px; }.prevalence-cell>strong { display:block; border-bottom:1px solid #ddd; padding-bottom:7px; margin-bottom:7px; font-weight:600; }.prevalence-value { display:flex; align-items:center; }.prevalence-track { flex:1; margin-left:8px; background:#e1e1e1; height:16px; }.prevalence-track>span { display:block; background:grey; height:16px; }.prevalence-detail { grid-column:1/3; padding:12px 45px; border-top:1px solid #eee; } +.summary-table { min-width:310px; border:1px solid #eee; }.summary-table th,.summary-table td { border-bottom:1px solid #eee; }.auc-table { width:600px; max-width:100%; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.auc-table th,.auc-table td { min-width:300px; text-align:left; }.auc-table .prevalence-track { min-width:180px; } +.nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); }.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; }.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); }.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; }.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; }.panel:not(.active) { display:none; }.chart,.panel { min-width:0; }.chart { width:550px; max-width:100%; margin:0 0 20px; } +svg { display:block; width:100%; height:auto; } #calibration svg { width:min(100%,550px); margin:0; } #discrimination svg,#utility svg,#utility-decision-curve svg { width:min(100%,500px); margin:0; }.axis text { fill:#444; font-size:12px; }.axis path,.axis line { stroke:#444; }.axis-label { fill:#444; font-size:14px; }.plot-title { color:#444; fill:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:17px; font-weight:400; line-height:1.2; text-align:center; margin:0 0 2px; }.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; }.legend span { display:inline-flex; align-items:center; gap:5px; }.legend i { width:24px; height:2px; display:inline-block; }.line { fill:none; stroke-width:2; }.ref { fill:none; stroke-width:2; stroke-dasharray:3 3; } +.slider-wrap { width:430px; max-width:calc(100% - 80px); margin:-54px auto 28px; }.slider-label { font:16px "Open Sans",verdana,arial,sans-serif; color:#444; }input[type=range] { width:100%; } +/* Plotly animation-slider look used by R performance curves. Keep this + separate from the Shiny/IonRangeSlider styling used by table filters. */ +.curve-slider-wrap { font-family:"Open Sans",verdana,arial,sans-serif; } +.curve-slider-label { margin:0 0 7px; color:#444; font-size:12px; line-height:16px; } +.curve-slider { -webkit-appearance:none; appearance:none; display:block; width:100%; height:18px; margin:0; padding:0; background:transparent; cursor:pointer; } +.curve-slider:focus { outline:none; } +.curve-slider::-webkit-slider-runnable-track { width:100%; height:4px; background:#e2e2e2; border:0; border-radius:2px; } +.curve-slider::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:12px; height:12px; margin-top:-4px; border:1px solid #777; border-radius:50%; background:#fff; box-shadow:none; } +.curve-slider::-moz-range-track { width:100%; height:4px; background:#e2e2e2; border:0; border-radius:2px; } +.curve-slider::-moz-range-thumb { width:12px; height:12px; border:1px solid #777; border-radius:50%; background:#fff; box-shadow:none; } +.curve-slider:hover::-webkit-slider-thumb { background:#f5f5f5; }.curve-slider:hover::-moz-range-thumb { background:#f5f5f5; } +.tip,.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; padding:8px 10px; background:#333; color:white; opacity:0; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } +table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; margin-bottom:20px; }th,td { padding:7px 10px; text-align:center; }thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; vertical-align:bottom; }tbody td { border-bottom:1px solid #eee; vertical-align:middle; }tbody tr:hover { background:#f5f5f5; } +.perf-wrap { overflow:auto; max-height:620px; border:1px solid #ddd; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.perf { width:100%; margin:0; border-collapse:separate; border-spacing:0; font-size:14px; }.perf th { position:sticky; top:0; background:#fff; z-index:2; white-space:nowrap; border-bottom:1px solid #ddd; font-weight:600; text-align:left; padding:8px 10px; }.perf .group-head th { text-align:center; }.perf .column-head th { top:35px; }.perf td { border-bottom:1px solid #eee; text-align:left; padding:8px 10px; white-space:nowrap; }.perf tbody tr.data-row:hover { background:#f5f5f5; }.perf .model { text-align:left; }.model-badge { display:inline-block; margin-right:8px; width:9px; height:9px; border-radius:50%; vertical-align:1px; }.metric-cell { position:relative; isolation:isolate; min-width:72px; }.metric-cell::before { content:""; position:absolute; z-index:-1; left:0; top:0; bottom:0; width:var(--bar,0%); background:var(--bar-color,lightgreen); }.expand { width:30px; cursor:pointer; font-size:18px; color:#777; text-align:center!important; }.detail td { text-align:left; background:#fff; padding:16px; }.cm-title { font-weight:600; margin-bottom:8px; }.cm { display:inline-grid; grid-template-columns:auto auto; gap:3px; margin-left:10px; }.cm span { padding:5px 10px; min-width:72px; text-align:center; }.pos { background:lightgreen; }.neg { background:pink; } +.rt-filters { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,1fr); gap:30px; align-items:end; margin:0 0 15px; font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; }.rt-filter-label { display:block; margin-bottom:4px; font-weight:700; }.rt-check-inline { position:relative; display:inline-block; padding-left:20px; margin-right:10px; font-weight:400; vertical-align:middle; cursor:pointer; }.rt-check-inline input { position:absolute; margin:2px 0 0 -20px; accent-color:var(--rt-check-color,#1b9e77); }.rt-filter-range { position:relative; }.rt-range-readout { float:right; margin-top:-24px; color:#555; font-size:12px; }.rt-dual-range { position:relative; height:32px; margin-top:4px; }.rt-dual-range input[type=range] { position:absolute; left:0; top:4px; width:100%; margin:0; background:transparent; pointer-events:none; }.rt-dual-range input[type=range]::-webkit-slider-thumb { pointer-events:auto; }.rt-dual-range input[type=range]::-moz-range-thumb { pointer-events:auto; } +.rt-perf-wrap { overflow:auto; border:1px solid #e5e5e5; border-radius:3px; background:#fff; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.rt-perf { width:100%; border-collapse:separate; border-spacing:0; margin:0; font-size:14px; }.rt-perf th,.rt-perf td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; white-space:nowrap; position:relative; }.rt-perf thead th { background:#fff; font-weight:600; color:#333; }.rt-perf .metric-group { text-align:center; border-bottom:1px solid #ddd; }.rt-perf .model-dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; vertical-align:1px; }.rt-perf .expand { width:28px; text-align:center; color:#777; cursor:pointer; font-size:18px; padding-left:6px; padding-right:6px; }.rt-perf .bar-cell { background-repeat:no-repeat; background-position:center; background-size:98% 88%; }.rt-perf .detail td { background:#fafafa; padding:16px; }.rt-conf { display:inline-table; border-collapse:collapse; margin:4px 0 4px 8px; vertical-align:middle; }.rt-conf th,.rt-conf td { padding:6px 10px; border:1px solid #eee; text-align:left; min-width:105px; }.rt-conf th { position:static; background:#fff; font-weight:600; }.rt-conf .outcome { font-weight:600; }.rt-pager { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:8px 0; font-size:13px; color:#555; }.rt-page-controls { display:flex; gap:4px; align-items:center; }.rt-page-controls button { border:1px solid transparent; background:#fff; color:#337ab7; padding:5px 9px; border-radius:3px; font:inherit; cursor:pointer; }.rt-page-controls button:hover:not(:disabled) { background:#eee; }.rt-page-controls button.active { background:#337ab7; color:#fff; }.rt-page-controls button:disabled { color:#aaa; cursor:default; } +/* Match the R template's Crosstalk checkbox styling. */ +.rt-check-inline input[type="checkbox"] { -webkit-appearance:none; appearance:none; background-color:#fff; margin:0; font:inherit; color:currentColor; width:1.15em; height:1.15em; border:.075em solid currentColor; border-radius:.15em; transform:translateY(-.075em); display:grid; place-content:center; } +.rt-check-inline input[type="checkbox"]::before { content:""; width:.65em; height:.65em; clip-path:polygon(14% 44%,0 65%,50% 100%,100% 16%,80% 0,43% 62%); transform:scale(0); transform-origin:bottom left; transition:120ms transform ease-in-out; box-shadow:inset 1em 1em var(--rt-check-color,#1b9e77); background-color:var(--rt-check-color,#1b9e77); } +.rt-check-inline input[type="checkbox"]:checked::before { transform:scale(1); } +/* Match the Shiny IonRangeSlider theme embedded by the R report. */ +.rt-range-track { top:25px !important; height:8px !important; background:linear-gradient(to bottom,#dedede -50%,#fff 150%) !important; background-color:#ededed !important; border:1px solid #ccc !important; border-radius:8px !important; } +.rt-range-fill { height:8px !important; background:#428bca !important; border-top:1px solid #428bca !important; border-bottom:1px solid #428bca !important; } +.rt-range-bubble { padding:1px 3px !important; background:#428bca !important; color:#fff !important; } +.rt-dual-range input[type=range] { top:16px !important; } +.rt-dual-range input[type=range]::-webkit-slider-thumb { width:22px !important; height:22px !important; margin-top:-7px !important; border:1px solid #ababab !important; background:#dedede !important; border-radius:22px !important; box-shadow:1px 1px 3px rgba(255,255,255,.3) !important; } +.rt-dual-range input[type=range]::-moz-range-thumb { width:22px !important; height:22px !important; border:1px solid #ababab !important; background:#dedede !important; border-radius:22px !important; box-shadow:1px 1px 3px rgba(255,255,255,.3) !important; } +@media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}.auc-table th,.auc-table td{min-width:0}.auc-table .prevalence-track{min-width:80px}.rt-filters{grid-template-columns:1fr;gap:12px}} diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index 0db544f6..8c4fb3c1 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -1,81 +1,101 @@ -""" -A module for Summary Report -""" +"""Lightweight HTML summary reports for rtichoke.""" +from __future__ import annotations +import json +from pathlib import Path +from typing import Dict, Union +import numpy as np +from rtichoke.calibration.calibration import _create_calibration_curve_list +from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.processing.plotly_helper_functions import _create_rtichoke_curve_list_binary +from rtichoke.summary_report.calibration_renderer import calibration_renderer_source -from rtichoke.processing.send_post_request_to_r_rtichoke import ( - send_requests_to_rtichoke_r, -) -from rtichoke.processing.transforms import ( - _create_list_data_to_adjust, -) -import subprocess - - -def create_summary_report(probs, reals, url_api="http://localhost:4242/"): - """Creates a summary report for rtichoke model performance. - - Parameters - ---------- - probs : Dict[str, np.ndarray] - A dictionary mapping model names to predicted probabilities. - reals : Union[np.ndarray, Dict[str, np.ndarray]] - The true outcome labels (0 or 1). - url_api : str, optional - The API endpoint URL of the R rtichoke backend. - Defaults to ``"http://localhost:4242/"``. - """ - rtichoke_response = send_requests_to_rtichoke_r( - dictionary_to_send={"probs": probs, "reals": reals}, - url_api=url_api, - endpoint="create_summary_report", - ) - print(rtichoke_response.json()[0].keys()) - - -def render_summary_report(): - """ - Render the rtichoke Summary Report using Quarto. - - Args: - probs (list): A list of probabilities. - reals (list): A list of real values. - times (list): A list of absolute numbers representing timestamps. - - Example: - probs = [0.1, 0.4, 0.8] - reals = [0, 1, 1] - times = [1, 3, 5] - render_summary_report(probs, reals, times) - - This will generate a `summary_report.html` file based on the `summary_report_template.qmd`. - """ - # Define the path to the template and output file - template_path = "aj_estimate_summary_report.qmd" - output_path = "summary_report.html" - - # Prepare the command to render the Quarto document - command = [ - "quarto", - "render", - template_path, - "--to", - "html", - "--output", - output_path, # , - # "--execute-params", - # f"probs={probs},reals={reals},times={times}", - ] - - # Execute the command - subprocess.run(command, check=True) - - -def create_data_for_summary_report(probs, reals, times, fixed_time_horizons): - stratified_by = ["probability_threshold", "ppcr"] - by = 0.1 - - list_data_to_adjust_polars = _create_list_data_to_adjust( - probs, reals, times, stratified_by=stratified_by, by=by, times_dict={} - ) - - return list_data_to_adjust_polars +_CURVES=[("roc","ROC"),("lift","Lift"),("precision recall","Precision Recall"),("gains","Gains")] +def _spec(data,strat,curve,label): + d=_create_rtichoke_curve_list_binary(performance_data=data,stratified_by=strat,curve=curve,size=500) + return {"label":label,"title":f"{label} Curve","x_label":d["x_label"],"y_label":d["y_label"],"x_range":d["axes_ranges"]["xaxis"],"y_range":d["axes_ranges"]["yaxis"],"groups":d["reference_group_keys"],"colors":d["colors_dictionary"],"data":d["performance_data_ready_for_curve"].to_dicts(),"references":d["reference_data"].to_dicts()} +def _specs(d,s): return [_spec(d,s,c,l) for c,l in _CURVES] +def _calibration(probs,reals): + d=_create_calibration_curve_list(probs,reals,size=550); colors={k:v[0] for k,v in d["colors_dictionary"].items()} + return {"deciles":d["deciles_dat"].to_dicts(),"smooth":d["smooth_dat"].to_dicts(),"reference":d["reference_data"].to_dicts(),"histogram":d["histogram_for_calibration"].to_dicts(),"ranges":d["axes_ranges"],"colors":colors,"groups":[k for k in colors if k!="reference_line"]} +def _auc(y,p): + y=np.asarray(y).ravel().astype(int); p=np.asarray(p).ravel().astype(float); pos=p[y==1]; neg=p[y==0] + return float("nan") if not len(pos) or not len(neg) else float(np.mean(pos[:,None]>neg[None,:])+.5*np.mean(pos[:,None]==neg[None,:])) +def _summaries(probs,reals): + if isinstance(reals,dict) and probs.keys()==reals.keys(): return [{"Model":k,"Prevalence":float(np.mean(reals[k])),"AUC":_auc(reals[k],probs[k])} for k in probs] + if not isinstance(reals,dict): return [{"Model":k,"Prevalence":float(np.mean(reals)),"AUC":_auc(reals,p)} for k,p in probs.items()] + return [] +def _table_data(d): + cols=["reference_group","chosen_cutoff","ppcr","sensitivity","specificity","ppv","npv","lift","predicted_positives","net_benefit","true_posititives","true_negatives","false_positives","false_negatives"] + return [{k:r.get(k) for k in cols if k in r} for r in d.to_dicts()] +def _html(payload,sums): + P=json.dumps(payload,separators=(",",":"),default=str).replace("Summary Report
+

Performance Metrics Cheat Sheet

Predicted PositivePredicted Negative
Real PositiveTPFN
Real NegativeFPTN
+

Calibration

+

Discrimination

Performance Metrics Curves

Performance Metrics Curves

+

Utility (Decision Curve)

Performance Table

''' +def create_summary_report(probs:Dict[str,np.ndarray],reals:Union[np.ndarray,Dict[str,np.ndarray]],output_file:str|Path="summary_report.html",by:float=.01)->Path: + threshold=prepare_performance_data(probs=probs,reals=reals,stratified_by=("probability_threshold",),by=by); ppcr=prepare_performance_data(probs=probs,reals=reals,stratified_by=("ppcr",),by=by) + payload={"threshold":_specs(threshold,"probability_threshold"),"ppcr":_specs(ppcr,"ppcr"),"decision":_spec(threshold,"probability_threshold","decision","Decision"),"calibration":_calibration(probs,reals),"tables":{"threshold":_table_data(threshold),"ppcr":_table_data(ppcr)}} + out=Path(output_file); out.write_text(_html(payload,_summaries(probs,reals)),encoding="utf-8"); return out \ No newline at end of file diff --git a/src/rtichoke/summary_report/summary_report_plotly.py b/src/rtichoke/summary_report/summary_report_plotly.py new file mode 100644 index 00000000..70dbabaf --- /dev/null +++ b/src/rtichoke/summary_report/summary_report_plotly.py @@ -0,0 +1,287 @@ +"""Plotly-backed chart layer for the lightweight summary report. + +The report shell, prevalence/AUROC widgets, and performance tables remain the +small self-contained HTML implementation. Charts are rendered by Plotly—the +same rendering engine used by the canonical R summary report—so visual parity +is not limited by a hand-written SVG approximation. Plotly.js is embedded +once in the generated file; no network access or new runtime dependency is +required. +""" +from __future__ import annotations + +import json +from pathlib import Path +import re +from typing import Dict, Union + +import numpy as np +from plotly.offline import get_plotlyjs + +from rtichoke.calibration.calibration import create_calibration_curve +from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.processing.plotly_helper_functions import _plot_rtichoke_curve_binary +from rtichoke.summary_report.summary_report_v2 import ( + create_summary_report as _create_lightweight_report, +) + + +_PALETTE = [ + "#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#07004D", "#E6AB02", + "#FE5F55", "#54494B", "#006E90", "#BC96E6", "#52050A", "#1F271B", + "#BE7C4D", "#63768D", "#08A045", "#320A28", "#82FF9E", "#2176FF", + "#D1603D", "#585123", +] + + +def _figure_payload(fig) -> dict: + """Return JSON-safe Plotly data/layout without duplicating Plotly.js.""" + payload = json.loads(fig.to_json()) + return {"data": payload["data"], "layout": payload["layout"]} + + +def _plotly_payload( + probs: Dict[str, np.ndarray], + reals: Union[np.ndarray, Dict[str, np.ndarray]], + by: float, +) -> dict: + threshold = prepare_performance_data( + probs=probs, + reals=reals, + stratified_by=("probability_threshold",), + by=by, + ) + ppcr = prepare_performance_data( + probs=probs, + reals=reals, + stratified_by=("ppcr",), + by=by, + ) + + def curves(data, stratified_by: str): + return [ + { + "label": "ROC", + "figure": _figure_payload( + _plot_rtichoke_curve_binary( + data, + stratified_by=stratified_by, + curve="roc", + size=500, + ) + ), + }, + { + "label": "Lift", + "figure": _figure_payload( + _plot_rtichoke_curve_binary( + data, + stratified_by=stratified_by, + curve="lift", + size=500, + ) + ), + }, + { + "label": "Precision Recall", + "figure": _figure_payload( + _plot_rtichoke_curve_binary( + data, + stratified_by=stratified_by, + curve="precision recall", + size=500, + ) + ), + }, + { + "label": "Gains", + "figure": _figure_payload( + _plot_rtichoke_curve_binary( + data, + stratified_by=stratified_by, + curve="gains", + size=500, + ) + ), + }, + ] + + return { + "smooth": _figure_payload( + create_calibration_curve( + probs=probs, + reals=reals, + calibration_type="smooth", + size=550, + ) + ), + "discrete": _figure_payload( + create_calibration_curve( + probs=probs, + reals=reals, + calibration_type="discrete", + size=550, + ) + ), + "threshold": curves(threshold, "probability_threshold"), + "ppcr": curves(ppcr, "ppcr"), + "decision": _figure_payload( + _plot_rtichoke_curve_binary( + threshold, + stratified_by="probability_threshold", + curve="decision", + size=500, + ) + ), + } + + +def _wire_page_parity( + html: str, + probs: Dict[str, np.ndarray], + reals: Union[np.ndarray, Dict[str, np.ndarray]], +) -> str: + """Match the non-chart document flow of the canonical R Markdown report.""" + formulas = """
+
Prevalence = TP + FNTP + FP + TN + FN
+
PPCR (Predicted Positives Condition Rate) = TP + FPTP + FP + TN + FN
+
Sensitivity (Recall, True Positive Rate) = TPTP + FN = TPReal Positives = Prob( Predicted Positive | Real Positive )
+
Specificity (True Negative Rate) = TNTN + FP = TNReal Negatives = Prob( Predicted Negative | Real Negative )
+
PPV (Precision) = TPTP + FP = TPPredicted Positives = Prob( Real Positive | Predicted Positive )
+
NPV = TNTN + FN = TNPredicted Negatives = Prob( Real Negative | Predicted Negative )
+
Lift = PPVPrevalence = TPTP + FPTP + FNTP + FP + TN + FN
+
Net Benefit = TPTP + FP + TN + FNFPTP + FP + TN + FN × pt1 − pt
+
""" + html, count = re.subn( + r'
.*?', + formulas, + html, + count=1, + flags=re.DOTALL, + ) + if count != 1: + raise RuntimeError("Could not locate summary-report metric formulas") + + parity_css = """ + +""" + html = html.replace("", parity_css + "", 1) + + if isinstance(reals, dict) and len(reals) > 1: + sizes = {k: int(np.asarray(reals[k]).size) for k in probs if k in reals} + sizes_json = json.dumps(sizes).replace(" +(function(){{ + if(typeof SUM==='undefined'||SUM.length<2)return; + const host=document.getElementById('prev'); if(!host)return; + const sizes={sizes_json}, palette={palette_json}; + host.classList.add('r-prevalence-multi'); host.replaceChildren(); + const header=document.createElement('div'); header.className='r-prevalence-header'; + header.innerHTML='populationPrevalence'; host.appendChild(header); + SUM.forEach((r,i)=>{{ + const p=Number(r.Prevalence), n=sizes[r.Model]||0, row=document.createElement('div'); row.className='prevalence-row'; + const exp=document.createElement('button'); exp.className='prevalence-expander'; exp.textContent='›'; exp.setAttribute('aria-label','Toggle details'); + const population=document.createElement('div'); population.className='prevalence-population'; + population.innerHTML=''+r.Model; + const cell=document.createElement('div'); cell.className='prevalence-cell'; + cell.innerHTML='
'+p.toFixed(2)+'
'; + const detail=document.createElement('div'); detail.className='prevalence-detail'; detail.hidden=true; + detail.textContent='Real Positives = '+Math.round(p*n)+', Total Population = '+n; + exp.onclick=()=>{{detail.hidden=!detail.hidden;exp.textContent=detail.hidden?'›':'⌄'}}; + row.append(exp,population,cell,detail); host.appendChild(row); + }}); +}})(); + +""" + html = html.replace("", prevalence_script + "", 1) + + return html + + +def _inject_plotly_charts(html: str, payload: dict) -> str: + plotly_js = get_plotlyjs() + encoded = json.dumps(payload, separators=(",", ":")).replace("{plotly_js} + +""" + return html.replace("", script + "", 1) + + +def create_summary_report( + probs: Dict[str, np.ndarray], + reals: Union[np.ndarray, Dict[str, np.ndarray]], + output_file: str | Path = "summary_report.html", + by: float = 0.01, +) -> Path: + """Create the self-contained R-parity summary report using native Plotly charts.""" + out = Path(output_file) + _create_lightweight_report(probs=probs, reals=reals, output_file=out, by=by) + html = out.read_text(encoding="utf-8") + html = _wire_page_parity(html, probs, reals) + html = _inject_plotly_charts(html, _plotly_payload(probs, reals, by)) + out.write_text(html, encoding="utf-8") + return out diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py new file mode 100644 index 00000000..1e87e324 --- /dev/null +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -0,0 +1,159 @@ +"""Readable integration layer for the lightweight summary report renderers.""" +from __future__ import annotations + +import json +from pathlib import Path +import re +from typing import Dict, Union + +import numpy as np + +from rtichoke.summary_report.curve_renderer import curve_renderer_source +from rtichoke.summary_report.summary_report import create_summary_report as _legacy_create_summary_report + + +def _asset_source(name: str) -> str: + return Path(__file__).with_name(name).read_text(encoding="utf-8") + + +def _style_source() -> str: + return _asset_source("report_style.css") + + +def _wire_curve_renderer(html: str) -> str: + renderer = curve_renderer_source() + marker = "function curveTabs(specs,nav,chart,strat)" + if marker not in html: + raise RuntimeError("Could not locate summary-report curve integration point") + html = html.replace(marker, renderer + "\n" + marker, 1) + + legacy_tabs = "draw(s,chart,strat)}));draw(specs[0],chart,strat)" + wired_tabs = "drawRtichokeCurve(s,chart,strat)}));drawRtichokeCurve(specs[0],chart,strat)" + if legacy_tabs not in html: + raise RuntimeError("Could not wire summary-report discrimination curves") + html = html.replace(legacy_tabs, wired_tabs, 1) + + legacy_decision = "draw(R.decision,'#decision','probability_threshold');" + if legacy_decision not in html: + raise RuntimeError("Could not wire summary-report decision curve") + html = html.replace( + legacy_decision, + "drawRtichokeCurve(R.decision,'#decision','probability_threshold');", + 1, + ) + return html + + +def _wire_performance_table_renderer(html: str) -> str: + """Use the modular Reactable-like table renderer instead of legacy inline calls.""" + renderer = _asset_source("performance_table_renderer.js") + marker = "perf(R.tables.threshold,'#table-threshold',false);perf(R.tables.ppcr,'#table-ppcr',true);" + if marker not in html: + raise RuntimeError("Could not locate summary-report performance-table integration point") + return html.replace(marker, renderer, 1) + + +def _wire_report_style(html: str) -> str: + """Replace the legacy inline CSS with the single parity stylesheet.""" + css = _style_source() + styled, count = re.subn(r"", f"", html, count=1, flags=re.DOTALL) + if count != 1: + raise RuntimeError("Could not locate summary-report style element") + return styled + + +def _wire_r_toc(html: str) -> str: + """Mirror the nested H1-H3 TOC generated by the R Markdown report.""" + heading = "

Performance Metrics Curves

" + if html.count(heading) >= 2: + html = html.replace(heading, '

Performance Metrics Curves

', 1) + html = html.replace(heading, '

Performance Metrics Curves

', 1) + + toc = """""" + wired, count = re.subn(r'
.*?
', toc, html, count=1, flags=re.DOTALL) + if count != 1: + raise RuntimeError("Could not locate summary-report table of contents") + return wired + + +def _wire_r_report_content(html: str, probs: Dict[str, np.ndarray], reals: Union[np.ndarray, Dict[str, np.ndarray]]) -> str: + formulas = """
+
Prevalence = TP + FNTP + FP + TN + FN
+
PPCR (Predicted Positives Condition Rate) = TP + FPTP + FP + TN + FN
+
Sensitivity (Recall, True Positive Rate) = TPTP + FN = TPReal Positives = Prob( Predicted Positive | Real Positive )
+
Specificity (True Negative Rate) = TNTN + FP = TNReal Negatives = Prob( Predicted Negative | Real Negative )
+
PPV (Precision) = TPTP + FP = TPPredicted Positives = Prob( Real Positive | Predicted Positive )
+
NPV = TNTN + FN = TNPredicted Negatives = Prob( Real Negative | Predicted Negative )
+
Lift = PPVPrevalence
+
Net Benefit = TPTP + FP + TN + FNFPTP + FP + TN + FN × pt1 − pt
+
""" + needle = "
" + if needle in html: + html = html.replace(needle, f"{formulas}
", 1) + + if isinstance(reals, dict): + sizes = {k: int(np.asarray(reals[k]).size) for k in probs if k in reals} + prevalence_rows = "SUM" + else: + n = int(np.asarray(reals).size) + sizes = {k: n for k in probs} + prevalence_rows = "SUM.slice(0,1)" + sizes_json = json.dumps(sizes).replace(" +(function(){{ + const palette=['#1b9e77','#d95f02','#7570b3','#e7298a','#07004D','#E6AB02','#FE5F55','#54494B','#006E90','#BC96E6','#52050A','#1F271B','#BE7C4D','#63768D','#08A045','#320A28','#82FF9E','#2176FF','#D1603D','#585123']; + const aucHost=document.getElementById('auc'); + if(aucHost&&typeof SUM!=='undefined'){{ + const showGroup=SUM.length>1; + aucHost.innerHTML=''+(showGroup?'':'')+''+SUM.map((r,i)=>{{ + const value=Number(r.AUC), valid=Number.isFinite(value), label=valid?value.toFixed(2):' ', width=valid?Math.max(0,Math.min(100,value*100)):0; + const group=showGroup?'':''; + return ''+group+''; + }}).join('')+'
ModelAUROC
'+r.Model+'
'+label+'
'; + }} + const host=document.getElementById('prev'); if(!host||typeof SUM==='undefined')return; const sizes={sizes_json}; const prevalenceRows={prevalence_rows}; + host.innerHTML=''; + prevalenceRows.forEach((r,i)=>{{ + const p=Number(r.Prevalence), n=sizes[r.Model]||0, row=document.createElement('div'); row.className='prevalence-row'; + const exp=document.createElement('button'); exp.className='prevalence-expander'; exp.textContent='›'; exp.setAttribute('aria-label','Toggle details'); + const cell=document.createElement('div'); cell.className='prevalence-cell'; + cell.innerHTML='Prevalence
'+p.toFixed(2)+'
'; + const detail=document.createElement('div'); detail.className='prevalence-detail'; detail.hidden=true; + detail.textContent='Real Positives = '+Math.round(p*n)+', Total Population = '+n; + exp.onclick=()=>{{detail.hidden=!detail.hidden;exp.textContent=detail.hidden?'›':'⌄'}}; + row.append(exp,cell,detail); host.append(row); + }}); +}})(); +""" + return html.replace("", script + "", 1) + + +def create_summary_report(probs: Dict[str, np.ndarray], reals: Union[np.ndarray, Dict[str, np.ndarray]], output_file: str | Path = "summary_report.html", by: float = 0.01) -> Path: + out = Path(output_file) + _legacy_create_summary_report(probs=probs, reals=reals, output_file=out, by=by) + html = out.read_text(encoding="utf-8") + html = _wire_curve_renderer(html) + html = _wire_performance_table_renderer(html) + html = _wire_r_toc(html) + html = _wire_r_report_content(html, probs, reals) + html = _wire_report_style(html) + out.write_text(html, encoding="utf-8") + return out diff --git a/tests/test_calibration_renderer_asset.py b/tests/test_calibration_renderer_asset.py new file mode 100644 index 00000000..9d20f2ad --- /dev/null +++ b/tests/test_calibration_renderer_asset.py @@ -0,0 +1,22 @@ +from rtichoke.summary_report.calibration_renderer import calibration_renderer_source + + +def test_calibration_renderer_asset_is_available(): + source = calibration_renderer_source() + assert "function calibration(type, sel)" in source + # Match the geometry used by the actual R summary-report calibration call. + assert "const W = 550, H = 550" in source + assert "MAIN_TOP = 55, MAIN_BOTTOM = 409.9" in source + assert "HIST_TOP = 428.1, HIST_BOTTOM = 510" in source + assert 'text("Predicted")' in source + assert 'text("Observed")' in source + assert "showTip" in source + assert "hoverColor" in source + assert "c.colors[groupOf(d)]" in source + + +def test_calibration_renderer_is_safe_to_inline_in_report_script(): + source = calibration_renderer_source() + assert "" not in source.lower() + assert "calibration('smooth'" not in source + assert 'calibration("smooth"' not in source diff --git a/tests/test_summary_report.py b/tests/test_summary_report.py new file mode 100644 index 00000000..cef289f0 --- /dev/null +++ b/tests/test_summary_report.py @@ -0,0 +1,64 @@ +import re + +import numpy as np + +from rtichoke import create_summary_report + + +def test_create_summary_report_writes_native_html(tmp_path): + probs = {"model": np.array([0.05, 0.2, 0.4, 0.7, 0.9])} + reals = np.array([0, 0, 1, 1, 1]) + output = tmp_path / "report.html" + + result = create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert result == output + for text in ( + "Summary Report", + "Performance Metrics Cheat Sheet", + "Calibration", + "Smooth", + "Discrete", + "Discrimination", + "By Probability Threshold", + "By Predicted Positives Condition Rate (PPCR)", + "ROC", + "Lift", + "Precision Recall", + "Gains", + "Utility (Decision Curve)", + "Performance Table", + "table-threshold", + "table-ppcr", + "Confusion Matrix", + ): + assert text in html + assert "perf(R.tables.threshold" not in html + assert "application/vnd.jupyter.widget-state+json" not in html + assert "application/vnd.jupyter.widget-view+json" not in html + assert "@jupyter-widgets" not in html + assert "d3.scaleLinear()" in html + assert "send_requests_to_rtichoke_r" not in html + assert "quarto" not in html.lower() + + # R's Plotly performance curves use add_lines(); the animation markers are + # intentionally invisible and exist only to provide slider frames. Keep the + # lightweight renderer line-only as well, without sampled-point or active + # cutoff markers that are not visible in the canonical R report. + assert 'attr("class","curve-current-markers")' not in html + assert 'selectAll("circle").data(a.filter' not in html + assert ".curve-slider::-webkit-slider-runnable-track" in html + + # The report must remain usable as a single offline HTML file. JavaScript + # and styling are embedded directly rather than fetched from a CDN or + # another external runtime at viewing time. + assert "cdn.jsdelivr.net" not in html + assert "unpkg.com" not in html + assert not re.search( + r']+src=["\']https?://', html, re.IGNORECASE + ) + assert not re.search( + r']+href=["\']https?://', html, re.IGNORECASE + ) + assert "Minimal D3-compatible runtime used by rtichoke summary reports" in html diff --git a/tests/test_summary_report_plotly.py b/tests/test_summary_report_plotly.py new file mode 100644 index 00000000..2c98a545 --- /dev/null +++ b/tests/test_summary_report_plotly.py @@ -0,0 +1,68 @@ +import re + +import numpy as np + +from rtichoke import create_summary_report +from rtichoke.summary_report import summary_report_plotly + + +def _tiny_payload(): + figure = {"data": [], "layout": {"width": 500, "height": 550}} + return { + "smooth": figure, + "discrete": figure, + "threshold": [{"label": "ROC", "figure": figure}], + "ppcr": [{"label": "ROC", "figure": figure}], + "decision": figure, + } + + +def test_public_export_routes_to_plotly_summary_report(): + assert create_summary_report is summary_report_plotly.create_summary_report + + +def test_plotly_chart_layer_is_self_contained(monkeypatch): + monkeypatch.setattr( + summary_report_plotly, + "get_plotlyjs", + lambda: "/*! plotly.js vTEST */", + ) + html = """ +
+
+
+
""" + + rendered = summary_report_plotly._inject_plotly_charts(html, _tiny_payload()) + + assert "plotly.js vTEST" in rendered + assert "Plotly.react(host,fig.data,fig.layout,config)" in rendered + assert "Plotly.react(chart,spec.figure.data,spec.figure.layout,config)" in rendered + assert "draw('smoothchart',RP.smooth)" in rendered + assert "draw('discretechart',RP.discrete)" in rendered + assert "draw('decision',RP.decision)" in rendered + assert not re.search(r']+src=["\']https?://', rendered, re.IGNORECASE) + assert not re.search(r']+href=["\']https?://', rendered, re.IGNORECASE) + + +def test_page_parity_adds_r_math_and_multi_population_prevalence(): + html = """ +
old
+""" + probs = { + "Population A": np.array([0.1, 0.8]), + "Population B": np.array([0.2, 0.9]), + } + reals = { + "Population A": np.array([0, 1]), + "Population B": np.array([0, 1]), + } + + rendered = summary_report_plotly._wire_page_parity(html, probs, reals) + + assert "r-math-blocks" in rendered + assert "Lift =" in rendered + assert "frac compound" in rendered + assert "r-prevalence-multi" in rendered + assert "populationPrevalence" in rendered + assert "model-badge" in rendered diff --git a/tests/test_summary_report_prevalence.py b/tests/test_summary_report_prevalence.py new file mode 100644 index 00000000..def59741 --- /dev/null +++ b/tests/test_summary_report_prevalence.py @@ -0,0 +1,105 @@ +import numpy as np + +from rtichoke.summary_report.summary_report_v2 import create_summary_report + + +def test_shared_outcomes_render_one_prevalence_population(tmp_path): + probs = { + "Model A": np.array([0.1, 0.3, 0.6, 0.8]), + "Model B": np.array([0.2, 0.4, 0.5, 0.7]), + } + reals = np.array([0, 0, 1, 1]) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert "const prevalenceRows=SUM.slice(0,1);" in html + assert "prevalenceRows.forEach" in html + + +def test_summary_report_uses_r_style_auc_widget(tmp_path): + probs = { + "Model A": np.array([0.1, 0.3, 0.6, 0.8]), + "Model B": np.array([0.2, 0.4, 0.5, 0.7]), + } + reals = np.array([0, 0, 1, 1]) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert 'class="summary-table auc-table"' in html + assert "AUROC" in html + assert "value.toFixed(2)" in html + assert "background:green" in html + assert "model-badge" in html + + +def test_summary_report_uses_paginated_modular_performance_table(tmp_path): + probs = {"Model A": np.linspace(0.01, 0.99, 20)} + reals = np.array([0, 1] * 10) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.05) + + html = output.read_text(encoding="utf-8") + assert "const PAGE_SIZE = 10;" in html + assert "rt-page-info" in html + assert "filtered.length" in html + assert "perf(R.tables.threshold,'#table-threshold',false)" not in html + + +def test_summary_report_has_r_style_performance_filters(tmp_path): + probs = { + "Model A": np.array([0.1, 0.3, 0.6, 0.8]), + "Model B": np.array([0.2, 0.4, 0.5, 0.7]), + } + reals = np.array([0, 0, 1, 1]) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert 'className="rt-filters"' in html + assert 'textContent="Model"' in html + assert '"Probability Threshold"' in html + assert '"Predicted Positives Condition Rate (PPCR)"' in html + assert 'className="rt-dual-range"' in html + assert 'className="rt-check-box"' in html + assert 'className="rt-range-bubble"' in html + assert "selected.has(model)" in html + assert 'ModelPredicted Positives' in html + + +def test_summary_report_has_r_style_curve_geometry_and_strata_slider(tmp_path): + probs = { + "Model A": np.array([0.1, 0.3, 0.6, 0.8]), + "Model B": np.array([0.2, 0.4, 0.5, 0.7]), + } + reals = np.array([0, 0, 1, 1]) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert 'const W=500,H=550,m={top:25,right:10,bottom:120,left:60}' in html + assert 'curve-slider-wrap' in html + assert '.style("bottom","12px")' in html + assert '"Predicted Positives (Rate):"' in html + assert '"Prob. Threshold:"' in html + assert 'drawRtichokeCurve(specs[0],chart,strat)' in html + assert "drawRtichokeCurve(R.decision,'#decision','probability_threshold')" in html + assert 'drawRtichokeCurve(s,chart,strat)' in html + + +def test_performance_table_recovers_tp_when_legacy_payload_omits_it(tmp_path): + probs = {"Model A": np.array([0.1, 0.4, 0.7, 0.9])} + reals = np.array([0, 1, 0, 1]) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert "r.true_positives == null" in html + assert "num(r.predicted_positives)-fp" in html diff --git a/tests/test_summary_report_shared_data.py b/tests/test_summary_report_shared_data.py new file mode 100644 index 00000000..29b5c793 --- /dev/null +++ b/tests/test_summary_report_shared_data.py @@ -0,0 +1,24 @@ +import numpy as np + +import rtichoke.summary_report.summary_report as summary_report + + +def test_summary_report_prepares_each_stratification_once(monkeypatch, tmp_path): + original = summary_report.prepare_performance_data + calls = [] + + def counted(*args, **kwargs): + calls.append(tuple(kwargs.get("stratified_by", ()))) + return original(*args, **kwargs) + + monkeypatch.setattr(summary_report, "prepare_performance_data", counted) + summary_report.create_summary_report( + {"model": np.array([0.1, 0.3, 0.6, 0.8])}, + np.array([0, 0, 1, 1]), + output_file=tmp_path / "report.html", + by=0.1, + ) + + assert calls.count(("probability_threshold",)) == 1 + assert calls.count(("ppcr",)) == 1 + assert len(calls) == 2