Shows how a test’s positive predictive value depends on disease prevalence, not just its sensitivity and specificity.
Author
Apurva Nakade
Published
August 1, 2026
A screening mammogram in U.S. community practice has about 87% sensitivity and 92% specificity (Lee et al., 2023): if you have cancer it comes back positive 87% of the time, and if you don’t, it comes back negative 92% of the time. About 7 in 1,000 women screened have breast cancer.
If you test positive, what are the chances you have the condition? This is the test’s positive predictive value (PPV). Make a guess before reading on — the answer may surprise you.
// VM is the shared utility library, loaded globally via _includes/head-scripts.html (js/**). This page uses VM.ui.renderTable and VM.sampling.seededRandom.VM =window.VM
vmTheme = Generators.observe(notify => VM.plotting.onThemeChange(notify))chartColors = VM.plotting.colors(vmTheme)config = ({colors: {infected: chartColors.alt,healthy: chartColors.ink },grid: {// 1,000 people, not 6,000 dots: at the real 0.7% prevalence the seven// people who have the condition have to be seven individually visible// marks, which they can't be as 0.7% of a 720px-wide point cloud. This// is why natural-frequency pictures (Gigerenzer) are "per 1,000" --// small enough for the rarest group to be a countable handful, large// enough to hold a 0.7% prevalence at all.population:1000,columns:50,// The SVG's coordinate width; it scales to the column's full width via// CSS, so this only sets the aspect ratio and the dot size relative to it.width:720,// A negative result is drawn as a faded hollow ring, so the solid// positives -- the people the page is about -- stand out from a crowd// of 900-odd cleared ones instead of competing with it. Full-strength// rings read as a second, equally busy pattern on top of the first.// Each further test fades the already-cleared people by this factor// again (see fadeOpacity), so the picture layers: still solid, cleared// by the latest test, cleared before that.negativeOpacity:0.3 },// How many tests the "Tests" slider runs to. Every round's split// is computed up front (see splits), so raising this is a one-line// change; the fade steps get faint fast, though, so past three the// earliest cleared people are effectively invisible.maxTests:3})
// shuffleKeys: one random number per person, generated once at load. The// shuffled layout places people in order of this key, so a person keeps the// same slot as the sliders move -- raising prevalence adds red dots without// reshuffling the ones already there -- and a subset (everyone who tested// positive) keeps its members' relative positions. Seeded from the current// time rather than a fixed constant: unlike the other URL-shareable inputs// on this page, which layout a person happens to land in isn't part of the// picture worth reproducing from a shared link, so each visit (and each// reload) draws its own layout instead.shuffleKeys = {const random = VM.sampling.seededRandom(Date.now())const keys = []for (let i =0; i < config.grid.population; i++) keys.push(random())return keys}
functionsplitByTest(infected, healthy, sensitivity, specificity) {const truePositives =Math.round(infected * sensitivity)const falseNegatives = infected - truePositivesconst falsePositives =Math.round(healthy * (1- specificity))const trueNegatives = healthy - falsePositivesreturn { infected, healthy, truePositives, falseNegatives, falsePositives, trueNegatives,positives: truePositives + falsePositives,negatives: falseNegatives + trueNegatives }}// ppvOf/npvOf: the exact values, from the probabilities rather than from// the rounded counts in the picture. Those are the products Bayes' theorem// multiplies -- P(positive and infected) = prevalence x sensitivity, and so// on -- whereas "6 of 85" is those products rounded to whole people, and// can differ from the exact value by a rounding (at 0.6% prevalence, the// old 2D-mammography default, the picture gave 5/114 = 4.4% against an// exact 4.6%); the closing section quotes the exact one.functionppvOf(prevalence, sensitivity, specificity) {const truePositive = prevalence * sensitivityconst falsePositive = (1- prevalence) * (1- specificity)return truePositive / (truePositive + falsePositive)}functionnpvOf(prevalence, sensitivity, specificity) {const trueNegative = (1- prevalence) * specificityconst falseNegative = prevalence * (1- sensitivity)return trueNegative / (trueNegative + falseNegative)}// formatPct: PPV/NPV are 0/0 when a group is empty (e.g. prevalence or// sensitivity dragged to an extreme) -- guard against rendering "NaN%".functionformatPct(value) {if (!Number.isFinite(value)) return"n/a"return (value *100).toFixed(1) +"%"}// paramNumber: reads a URL query parameter as a number for a slider's// initial value, falling back to `fallback` when the parameter is absent// or not a valid number (e.g. a hand-edited or stale link).functionparamNumber(params, key, fallback) {const raw = params.get(key)if (raw ===null) return fallbackconst parsed =Number(raw)if (!Number.isFinite(parsed)) return fallbackreturn parsed}// makePeople: one record per person, in category order -- everyone with// the condition first (caught cases, then missed ones), then everyone// without (false alarms, then correct negatives). `keys[i]` is person i's// fixed shuffle key. `clearedRound` is the test that returned this// person's first negative result -- 1 here, for the first test -- or null// while they are still testing positive; applyRetest fills in the later// rounds.functionmakePeople(split, keys) {const people = []const add = (count, infected, clearedRound) => {for (let i =0; i < count; i++) { people.push({infected, clearedRound,key: keys[people.length]}) } }add(split.truePositives,true,null)add(split.falseNegatives,true,1)add(split.falsePositives,false,null)add(split.trueNegatives,false,1)return people}// applyRetest: gives everyone still positive after round `round - 1` their// result from test `round`, in place. The same people stay in the same// slots -- nobody is re-laid out -- and among each group (the true// positives, the false positives) the first `split.truePositives` /// `split.falsePositives` of them in category order stay positive; the// rest are cleared in this round.functionapplyRetest(people, split, round) {let infectedKept =0let healthyKept =0for (const person of people) {if (person.clearedRound!==null) continueif (person.infected) { infectedKept++if (infectedKept > split.truePositives) person.clearedRound= round } else { healthyKept++if (healthyKept > split.falsePositives) person.clearedRound= round } }return people}// fadeOpacity: how faded a person cleared in `clearedRound` is drawn after// `rounds` tests -- one step of negativeOpacity per test since they were// cleared, so the latest test's negatives are the most visible rings and// earlier ones recede behind them. A missed case (`infected` and cleared)// never fades: the fade is there to push the crowd of correctly cleared// people into the background, and the one the test got wrong is exactly// the dot the reader should still be able to find.functionfadeOpacity(rounds, clearedRound, infected) {if (infected) return1returnMath.pow(config.grid.negativeOpacity, rounds - clearedRound +1)}
functionpersonTooltip(person, rounds) {let lines = ["Does not have the condition"]if (person.infected) lines = ["Has the condition"]for (let round =1; round <= rounds; round++) {if (person.clearedRound!==null&& person.clearedRound< round) { lines.push(`Test ${round}: not retested`)break }let outcome ="positive"if (person.clearedRound=== round) outcome ="negative" lines.push(`Test ${round}: ${outcome}`) }return lines.join("\n")}// tooltipElement: the page's single tooltip node, created on first use and// appended to <body> rather than inside a cell -- looked up by id each// time, since cells re-run and must not create a second one. While the// chart block is fullscreened it is moved inside that block instead: the// Fullscreen API paints only the fullscreened element's own subtree on// top, so a body-level tooltip -- whatever its z-index -- would sit// underneath the picture it annotates. Moved back to <body> on the first// hover after leaving fullscreen.functiontooltipElement() {let tip =document.getElementById("ppv-tooltip")if (tip ===null) { tip =document.createElement("div") tip.id="ppv-tooltip" tip.setAttribute("role","tooltip") tip.hidden=true }let host =document.bodyif (document.fullscreenElement!==null) host =document.fullscreenElementif (tip.parentNode!== host) host.appendChild(tip)return tip}// attachTooltip: shows each circle's data-tip text beside the pointer while// it is over that circle, and hides it in the gaps between dots and when// the pointer leaves the grid. Delegated on the <svg>, one listener set per// grid instead of three per circle; pointer events cover mouse and touch// (a tap fires pointerover) alike. The tooltip is offset down-right of the// pointer and flipped to the other side near the viewport's right or// bottom edge so it never runs off screen.functionattachTooltip(svg) {const offset =12const show = (event) => {const text =event.target.dataset?.tipconst tip =tooltipElement()if (text ===undefined) { tip.hidden=truereturn } tip.textContent= text tip.hidden=falsemove(event) }const move = (event) => {const tip =tooltipElement()if (tip.hidden) returnlet left =event.clientX+ offsetlet top =event.clientY+ offsetif (left + tip.offsetWidth>window.innerWidth) left =event.clientX- offset - tip.offsetWidthif (top + tip.offsetHeight>window.innerHeight) top =event.clientY- offset - tip.offsetHeight tip.style.left=`${left}px` tip.style.top=`${top}px` }const hide = () => { tooltipElement().hidden=true } svg.addEventListener("pointerover", show) svg.addEventListener("pointermove", move) svg.addEventListener("pointerleave", hide)}// drawGrid: one dot per person, laid out column-major on a grid. Hue is the// person's true condition; a solid dot is still testing positive, a hollow// one has been cleared, faded by how many tests ago (fadeOpacity).// options.rounds is how many tests have been applied: 0 draws the bare// population with every dot solid -- the test is what hollows out the// people it clears, so whoever is still solid afterwards is a positive// result. Every slider position draws the same people in the same slots,// so the reader can follow one dot as the tests are applied. Hovering a// dot shows personTooltip's text (attachTooltip) -- not via an SVG <title>,// which the browser only surfaces after the pointer has sat still on the// element for about a second, a delay any drift across a grid of 1,000// tiny dots keeps resetting, so it almost never actually appeared.//// Returns an inline SVG with a viewBox, stretched to the column's full// width by CSS -- so the grid is always page-wide, whatever the column// width, rather than a fixed 720px block sitting left-aligned in it.functiondrawGrid(people, options = {}) {const rounds = options.rounds??1const width = config.grid.widthconst count = people.lengthconst columns = config.grid.columnsconst rows =Math.ceil(count / columns)const pitch = width / columnsconst height = rows * pitchconst radius = pitch *0.36const ringWidth =Math.max(1.2, radius *0.28)// Slot order: by fixed shuffle key, or by category (the order people// arrive in from makePeople).const order = []for (let i =0; i < count; i++) order.push(i)if (layout ==="shuffled") { order.sort((a, b) => people[a].key- people[b].key) }let circles =""for (let slot =0; slot < count; slot++) {const person = people[order[slot]]const cx = (Math.floor(slot / rows) +0.5) * pitchconst cy = ((slot % rows) +0.5) * pitchlet color = config.colors.healthyif (person.infected) color = config.colors.infected// A literal newline inside a quoted attribute is kept as-is by the HTML// parser; the tooltip's white-space: pre-line turns it into a line break.const tip =`data-tip="${personTooltip(person, rounds)}"`if (rounds >0&& person.clearedRound!==null&& person.clearedRound<= rounds) {const opacity =fadeOpacity(rounds, person.clearedRound, person.infected) circles +=`<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${(radius - ringWidth /2).toFixed(2)}" fill="none" stroke="${color}" stroke-width="${ringWidth.toFixed(2)}" opacity="${opacity.toFixed(3)}" ${tip}/>` } else { circles +=`<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${radius.toFixed(2)}" fill="${color}" ${tip}/>` } }// Returned inside a <div>, not as the bare <svg>: Quarto's OJS output// inspects a bare SVGSVGElement as a value ("SVGSVGElement {}") instead// of inserting it, whereas an HTMLElement is inserted as a node.const holder =document.createElement("div") holder.className="ppv-grid" holder.innerHTML=`<svg viewBox="0 0 ${width}${height.toFixed(2)}" xmlns="http://www.w3.org/2000/svg" role="img">${circles}</svg>`const svg = holder.firstElementChild// A hollow ring has fill="none", and by default only its painted stroke// is hoverable -- the hole in the middle would show no tooltip. "all"// (inherited by every circle) makes the whole disc a target either way. svg.style.pointerEvents="all"attachTooltip(svg)return holder}// swatch: the inline legend mark used in the explanation panels -- a solid// dot, or a hollow ring at the given fade -- matching how the grids draw// people.functionswatch(color, filled, opacity =1) {let cls ="ppv-swatch"if (filled) cls +=" filled"returnhtml`<i class="${cls}" style="color: ${color}; opacity: ${opacity}"></i>`}// legendPanel: the panel under the grid -- a title, then one row per kind of// dot in the picture (`rows` is [{color, filled, opacity, count, label}]),// and an optional closing note (PPV and so on). Two rows may share a// swatch: the picture draws a person missed by the first test and one// missed by the second identically, and the legend lists them apart// rather than summing them into one number the picture can't show.functionlegendPanel(title, rows, note) {const cells = []for (const row of rows) { cells.push(swatch(row.color, row.filled, row.opacity??1)) cells.push(html`<span class="ppv-legend-count">${row.count}</span>`) cells.push(html`<span>${row.label}</span>`) }let noteNode =""if (note) noteNode =html`<div class="ppv-legend-note">${note}</div>`returnhtml`<div class="ojs-panel"><strong>${title}</strong><div class="ppv-legend-rows">${cells}</div>${noteNode}</div>`}
viewof prevalence = {const params =newURLSearchParams(window.location.search)// Step 0.001 is one person in the 1,000, and the real 6.6 per 1,000// (see References) rounds to the default of 7 -- the grid can show seven// people, where the old point cloud had to start at 1% to show anything// at all.return Inputs.range([0,1], {step:0.001,value:paramNumber(params,"prevalence",0.007),label:"Prevalence"})}
// urlSync: mirrors the current control values into the URL's query string// on every change, so the address bar stays a shareable link. Runs// directly off the reactive values (no "Plot" commit step, unlike the// text-expression fields on other pages) since redrawing a thousand// circles is cheap enough to redo on every drag tick.urlSync = {const params =newURLSearchParams(window.location.search) params.set("prevalence",String(prevalence)) params.set("sensitivity",String(sensitivity)) params.set("specificity",String(specificity)) params.set("layout", layout) params.set("tests",String(tests)) history.replaceState(null,"",`${window.location.pathname}?${params}`)}
infectedCount =Math.round(prevalence * config.grid.population)// splits[r]: how the people going into test r split under it, for r from// 1 to maxTests (splits[0] is unused, so the index is the round number).// Test 1 is given to the whole population; each later test only to the// previous round's positives, whose split by condition is exact (they// *are* the true and false positives), so only that test's outcome is// rounded again.splits = {const list = [null] list.push(splitByTest(infectedCount, config.grid.population- infectedCount, sensitivity, specificity))for (let round =2; round <= config.maxTests; round++) {const previous = list[round -1] list.push(splitByTest(previous.truePositives, previous.falsePositives, sensitivity, specificity)) }return list}// ppvs[r]: the exact PPV after r positive tests, with ppvs[0] the raw// prevalence -- so ppvs[r - 1] is also the effective prevalence going into// test r, which is what makes each repeat test start from a sharper// population than the last. npvs[r] likewise, for a negative on test r.ppvs = {const list = [prevalence]for (let round =1; round <= config.maxTests; round++) { list.push(ppvOf(list[round -1], sensitivity, specificity)) }return list}npvs = {const list = [null]for (let round =1; round <= config.maxTests; round++) { list.push(npvOf(ppvs[round -1], sensitivity, specificity)) }return list}// people: every person's result from every test, in one array -- the grid// draws it with rounds: tests, so every slider position is the same people// in the same slots. Built without depending on `tests`, so scrubbing the// slider only redraws; it doesn't recompute anyone's results.people = {const list =makePeople(splits[1], shuffleKeys)for (let round =2; round <= config.maxTests; round++) {applyRetest(list, splits[round], round) }return list}
viewof tests = {const params =newURLSearchParams(window.location.search)let initial =paramNumber(params,"tests",0) initial =Math.min(config.maxTests,Math.max(0,Math.round(initial)))// 0 is the bare population, every dot solid; each step applies one more// test to whoever is still positive. Lives in the chart-controls bar// (see CLAUDE.md) so it sits directly above the picture it scrubs, and// so slider-play.js gives it a play button that sweeps the tests.const slider = Inputs.range([0, config.maxTests], {step:1,value: initial,label:"Tests"})// .ojs-fill lifts Observable's 360px cap on the form so the track takes// the width the layout toggle leaves, as every other page's step slider// does; without it the label, play button and readout leave ~90px of track. slider.classList.add("ojs-fill")return slider}
viewof layout = {const params =newURLSearchParams(window.location.search)let initial ="shuffled"if (params.get("layout") ==="sorted") initial ="sorted"// Shuffled is the honest default -- in a real population you can't tell// who has the condition by where they're standing. Sorted groups each// category into a block, which is easier to count. Unlabelled, and in// the bar beside the tests slider (the .ojs-row's last div, so it keeps// its natural width while the slider fills the rest): the two options// name themselves, and it changes only how the picture is drawn.return Inputs.radio(newMap([["Shuffled","shuffled"], ["Sorted","sorted"]]), {value: initial})}
drawGrid(people, {rounds: tests})
ordinal = ["zero","one","two","three","four","five"]functionlegendTitle(rounds) {if (rounds ===0) return"The population, before any test"if (rounds ===1) return"After one test"return`After ${ordinal[rounds]} tests, each given to the previous test's positives`}functionlegendRows(rounds) {const red = config.colors.infectedconst dark = config.colors.healthyconst rows = []if (rounds ===0) { rows.push({color: red,filled:true,count: infectedCount,label:`with the condition (${formatPct(prevalence)})`}) rows.push({color: dark,filled:true,count: config.grid.population- infectedCount,label:"without the condition"})return rows }let positive ="tested positive"if (rounds ===2) positive ="positive on both tests"if (rounds >=3) positive =`positive on all ${ordinal[rounds]} tests`const last = splits[rounds] rows.push({color: red,filled:true,count: last.truePositives,label:`with the condition, ${positive}`}) rows.push({color: dark,filled:true,count: last.falsePositives,label:`without the condition, ${positive} (false alarm)`})// The cleared people, one row per round, latest first -- matching the// picture, where the latest round's rings are the most visible.for (let round = rounds; round >=1; round--) {let missed =`missed by test ${round}`if (rounds ===1) missed ="tested negative (missed)"if (round < rounds) missed +=" (not retested)" rows.push({color: red,filled:false,count: splits[round].falseNegatives,label:`with the condition, ${missed}`}) }for (let round = rounds; round >=1; round--) {let cleared =`cleared by test ${round}`if (rounds ===1) cleared ="tested negative (cleared)"if (round < rounds) cleared +=" (not retested)" rows.push({color: dark,filled:false,opacity:fadeOpacity(rounds, round,false),count: splits[round].trueNegatives,label:`without the condition, ${cleared}`}) }return rows}functionlegendNote(rounds) {if (rounds ===0) {returnhtml`Move the <em>Tests</em> slider to give everyone a test.` }const last = splits[rounds]if (rounds ===1) {returnhtml`${last.positives} tested positive. Positive predictive value, the chance you have the condition given a positive test: <strong>${formatPct(ppvs[1])}</strong>. Negative predictive value: <strong>${formatPct(npvs[1])}</strong>.` }const previous = splits[rounds -1]returnhtml`Only the ${previous.positives} still positive after test ${rounds -1} were retested; ${previous.truePositives} of them have the condition, so the effective prevalence going in was ${formatPct(ppvs[rounds -1])}. Positive predictive value after ${ordinal[rounds]} positive tests: <strong>${formatPct(ppvs[rounds])}</strong>. Negative predictive value of test ${rounds}: <strong>${formatPct(npvs[rounds])}</strong>.`}
// revealAnswer: answers the question the page opens with, using the// sliders' current values rather than a hardcoded number -- so dragging// Prevalence/Sensitivity/Specificity above updates the answer, not just// the grid and the summary table. The verdict flips at 50%, since with// the sliders moved far enough a positive can be more likely right than// wrong.functionrevealAnswer(ppvAfterOneTest, prevalence, sensitivity, specificity) {let verdict ="most positive results are false alarms"if (ppvAfterOneTest >=0.5) verdict ="a positive result is more likely right than wrong"returnhtml`<p>With ${formatPct(prevalence)} prevalence, ${formatPct(sensitivity)} sensitivity and ${formatPct(specificity)} specificity, the positive predictive value is <strong>${formatPct(ppvAfterOneTest)}</strong>: ${verdict}.</p>`}
At the default settings that’s about 7%, far lower than most people guess. When a condition is rare, the healthy group is so much larger that even a small false-positive rate among them outnumbers the true positives among the few who are sick: a handful of red in a crowd of dark.
Doctors get it wrong too
If you guessed high, you’re in good company. When researchers asked 60 staff and students at Harvard Medical School about a disease with a prevalence of 1 in 1,000 and a test with a 5% false-positive rate, nearly half answered 95%; only 11 gave the correct answer, about 2% (Casscells, Schoenberger & Graboys, 1978). In a mammography version of the question, most physicians estimated about 75% when the answer was 7.5% (Eddy, 1982). Judging a positive by the test’s accuracy alone, while ignoring how rare the condition is, is called the base-rate fallacy.
How the numbers are presented matters. Given percentages, only 10% of physicians answered correctly; given the same numbers as natural frequencies (“10 out of 1,000 women…”), 46% did (Hoffrage & Gigerenzer, 1998). That’s why this page shows 1,000 people as dots.
Why retesting helps
A second test goes only to the people who tested positive, and among them the condition is far more common: its prevalence is the first test’s PPV. The same test, applied to that group, gives a much higher PPV, as the Tests slider and the summary table show. Bayes’ rule is this update, with the denominator split into the two ways a test can come back positive:
A positive mammogram is not a diagnosis; it starts a three-round process that works the same way, with the PPV out of each round becoming the prevalence going into the next.
Screening mammogram flags about 8% of women for a closer look (recall)
Diagnostic imaging additional views, ultrasound or MRI clear about four in five of those recalled
Biopsy confirms cancer in about a third of the remaining cases
Per 1,000 women screened in Lee et al.’s data, 83 are recalled, about 18 go on to biopsy, and 6 have cancer: the PPV rises from 7% after the mammogram to 32% after biopsy.
Same test, different population
// prevalenceLink(value, label): an inline link that moves the Prevalence// slider to `value` in place -- setting the view and dispatching "input" is// how OJS picks up a programmatic change -- then scrolls the app into view// so the reader sees the grid redraw. Also shows one test if none has been// run, so the legend under the grid states the new PPV. The href is a// fallback for a middle-click or a new tab.functionprevalenceLink(value, label) {const link =html`<a href="?prevalence=${value}&tests=1">${label}</a>` link.addEventListener("click",event=> {event.preventDefault()const prevalenceView = viewof prevalence prevalenceView.value= value prevalenceView.dispatchEvent(newEvent("input", {bubbles:true}))const testsView = viewof testsif (testsView.value===0) { testsView.value=1 testsView.dispatchEvent(newEvent("input", {bubbles:true})) }document.querySelector(".vm-app").scrollIntoView({behavior:"smooth"}) })return link}
peakPrevalenceLink =prevalenceLink(0.326,"up to 32.6%")
todayPrevalenceLink =prevalenceLink(0.005,"down to 0.5%")
At the height of the pandemic, COVID-19 prevalence reached 32.6%; today it is under 1%. Slide Prevalence, then , leaving Sensitivity and Specificity where they are. What happens to the PPV?
Now suppose you ran a study today, found that most people who tested positive did not have COVID-19, and reported that the test is bad. Is that a valid claim?
References
Casscells, W., Schoenberger, A., & Graboys, T. B. (1978). Interpretation by Physicians of Clinical Laboratory Results. New England Journal of Medicine, 299(18), 999–1001. https://doi.org/10.1056/NEJM197811022991808.
Eddy, D. M. (1982). Probabilistic Reasoning in Clinical Medicine: Problems and Opportunities. In D. Kahneman, P. Slovic, & A. Tversky (Eds.), Judgment under Uncertainty: Heuristics and Biases (pp. 249–267). Cambridge University Press. https://doi.org/10.1017/CBO9780511809477.019.
Lee, C. I., Abraham, L., Miglioretti, D. L., Stout, N. K., Kerlikowske, K., Henderson, L. M., Tosteson, A. N. A., Bissell, M. C. S., Onega, T., Sprague, B. L., Sabatino, S. A., Lawson, M. B., Lowry, K. P., Buist, D. S. M., & Breast Cancer Surveillance Consortium (2023). National Performance Benchmarks for Screening Digital Breast Tomosynthesis: Update from the Breast Cancer Surveillance Consortium. Radiology, 307(4), e222499. https://doi.org/10.1148/radiol.222499. Source of this page’s sensitivity (87.4%), specificity (92.2%) and prevalence (5.8 cancers detected plus 0.8 missed per 1,000 screens), plus the recall rate (8.3%) and the PPV after screening (6.9%) and after biopsy (32.2%), from 458,175 U.S. screening examinations, 2011–2018.
Analytics
Visual Math Lab would like to count page visits. It tells us which
topics are being read, so that effort goes where it is most useful.
No advertising, no personal information, and nothing shared with third
parties. The mathematics you enter never leaves your browser.
What is collected.
No cookies are stored by this site, at any point, for any purpose. This
notice is shown because GDPR and the ePrivacy Directive require opt-in
consent before any tracking technology runs, cookies or not.
Built for a bigger screen
This site is meant for larger screens. If a plot isn't fully visible, tap its fullscreen button.