Drag the sliders below to set how common the disease is (prevalence) and how accurate the test is (sensitivity and specificity). The demo computes the true positive probability — the chance you actually have the disease given a positive test result — using Bayes' theorem.
<p class="hint">Adjust the sliders to set disease prevalence and test accuracy. The bar chart shows the four groups in a population of 10,000 — true positives, false positives, true negatives, and false negatives — and the big number tells you the chance you actually have the disease if you test positive.</p>
<div class="controls">
<label>Prevalence (1 in <span id="prev-label">1000</span>)
<input type="range" id="prev" min="1" max="4" step="1" value="3">
<span class="range-hint">rare ←→ common</span>
</label>
<label>Sensitivity — P(Positive | Disease): <span id="sens-label">99</span>%
<input type="range" id="sens" min="50" max="100" step="1" value="99">
</label>
<label>Specificity — P(Negative | Healthy): <span id="spec-label">99</span>%
<input type="range" id="spec" min="50" max="100" step="1" value="99">
</label>
</div>
<div class="result-box">
<div class="result-label">If you test positive, probability you have the disease:</div>
<div class="result-value" id="ppv">—</div>
</div>
<div class="chart-wrap">
<div class="bar-group">
<div class="bar tp" id="bar-tp"></div>
<div class="bar-label">True<br>Positive</div>
<div class="bar-count" id="cnt-tp">—</div>
</div>
<div class="bar-group">
<div class="bar fp" id="bar-fp"></div>
<div class="bar-label">False<br>Positive</div>
<div class="bar-count" id="cnt-fp">—</div>
</div>
<div class="bar-group">
<div class="bar tn" id="bar-tn"></div>
<div class="bar-label">True<br>Negative</div>
<div class="bar-count" id="cnt-tn">—</div>
</div>
<div class="bar-group">
<div class="bar fn" id="bar-fn"></div>
<div class="bar-label">False<br>Negative</div>
<div class="bar-count" id="cnt-fn">—</div>
</div>
</div>
<p class="formula" id="formula-text"></p>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .9rem; }
.controls label { font-size: .88rem; display: flex; flex-direction: column; gap: .2rem; color: #333; }
.controls input[type=range] { width: 100%; accent-color: #1d3557; }
.range-hint { font-size: .75rem; color: #888; }
.result-box { background: #1d3557; color: #fff; border-radius: 10px; padding: .6rem 1rem;
margin-bottom: .9rem; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.result-label { font-size: .85rem; flex: 1 1 160px; }
.result-value { font-size: 2rem; font-weight: 700; min-width: 80px; text-align: right; }
.chart-wrap { display: flex; gap: 1rem; align-items: flex-end; height: 140px; margin-bottom: .6rem; }
.bar-group { flex: 1; display: flex; flex-direction: column; align-items: center; gap: .2rem; height: 100%; justify-content: flex-end; }
.bar { width: 100%; border-radius: 5px 5px 0 0; min-height: 3px; transition: height .35s ease; }
.bar.tp { background: #2a9d5c; }
.bar.fp { background: #e63946; }
.bar.tn { background: #457b9d; }
.bar.fn { background: #f4a261; }
.bar-label { font-size: .7rem; text-align: center; color: #555; line-height: 1.3; }
.bar-count { font-size: .75rem; font-weight: 600; color: #333; }
.formula { font-size: .8rem; color: #555; line-height: 1.5; margin: 0; background: #f4f6f8;
border-radius: 6px; padding: .5rem .8rem; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Bayes' theorem demo: compute PPV (positive predictive value) given
// prevalence, sensitivity, and specificity in a population of 10,000.
// P(Disease|Positive) = (sensitivity * prevalence) /
// (sensitivity * prevalence + (1 - specificity) * (1 - prevalence))
const POP = 10000;
const PREV_MAP = [1, 10, 100, 1000]; // 1-in-X for slider values 0..3
function get(id){ return document.getElementById(id); }
function update(){
const prevIdx = parseInt(get('prev').value, 10) - 1;
const sens = parseInt(get('sens').value, 10) / 100;
const spec = parseInt(get('spec').value, 10) / 100;
const fpr = 1 - spec; // false positive rate
const oneInX = PREV_MAP[prevIdx];
const prev = 1 / oneInX;
get('prev-label').textContent = oneInX.toLocaleString();
get('sens-label').textContent = Math.round(sens * 100);
get('spec-label').textContent = Math.round(spec * 100);
// Population breakdown
const diseased = POP * prev;
const healthy = POP - diseased;
const tp = diseased * sens;
const fn = diseased * (1 - sens);
const tn = healthy * spec;
const fp = healthy * fpr;
// PPV = TP / (TP + FP)
const ppv = (tp + fp) > 0 ? tp / (tp + fp) : 0;
get('cnt-tp').textContent = Math.round(tp).toLocaleString();
get('cnt-fp').textContent = Math.round(fp).toLocaleString();
get('cnt-tn').textContent = Math.round(tn).toLocaleString();
get('cnt-fn').textContent = Math.round(fn).toLocaleString();
get('ppv').textContent = (ppv * 100).toFixed(1) + '%';
// Proportional bar heights (max bar fills ~120px)
const maxCount = Math.max(tp, fp, tn, fn, 1);
const MAX_H = 120;
['tp','fp','tn','fn'].forEach(k => {
const c = k === 'tp' ? tp : k === 'fp' ? fp : k === 'tn' ? tn : fn;
get('bar-' + k).style.height = Math.max(3, (c / maxCount) * MAX_H) + 'px';
});
// Formula text
const pPos = ((tp + fp) / POP * 100).toFixed(2);
get('formula-text').textContent =
fmt('Bayes: P(Disease | Positive) = P(Positive | Disease) × P(Disease) / P(Positive) = %v%% × %v2%% / %pPos%% ≈ %v3%%. Out of %POP% people: %round% true positives, %round2% false positives.', { v: (sens * 100).toFixed(0), v2: (prev * 100).toFixed(4), pPos: pPos, v3: (ppv * 100).toFixed(1), POP: POP.toLocaleString(), round: Math.round(tp), round2: Math.round(fp) });
console.log('PPV:', (ppv * 100).toFixed(2) + '%',
'| TP:', Math.round(tp), '| FP:', Math.round(fp),
'| prev 1 in', oneInX, '| sens', (sens*100)+'%', '| spec', (spec*100)+'%');
}
['prev','sens','spec'].forEach(id => get(id).addEventListener('input', update));
update();
<p class="hint">Adjust the sliders to set disease prevalence and test accuracy. The bar chart shows the four groups in a population of 10,000 — true positives, false positives, true negatives, and false negatives — and the big number tells you the chance you actually have the disease if you test positive.</p>
<div class="controls">
<label>Prevalence (1 in <span id="prev-label">1000</span>)
<input type="range" id="prev" min="1" max="4" step="1" value="3">
<span class="range-hint">rare ←→ common</span>
</label>
<label>Sensitivity — P(Positive | Disease): <span id="sens-label">99</span>%
<input type="range" id="sens" min="50" max="100" step="1" value="99">
</label>
<label>Specificity — P(Negative | Healthy): <span id="spec-label">99</span>%
<input type="range" id="spec" min="50" max="100" step="1" value="99">
</label>
</div>
<div class="result-box">
<div class="result-label">If you test positive, probability you have the disease:</div>
<div class="result-value" id="ppv">—</div>
</div>
<div class="chart-wrap">
<div class="bar-group">
<div class="bar tp" id="bar-tp"></div>
<div class="bar-label">True<br>Positive</div>
<div class="bar-count" id="cnt-tp">—</div>
</div>
<div class="bar-group">
<div class="bar fp" id="bar-fp"></div>
<div class="bar-label">False<br>Positive</div>
<div class="bar-count" id="cnt-fp">—</div>
</div>
<div class="bar-group">
<div class="bar tn" id="bar-tn"></div>
<div class="bar-label">True<br>Negative</div>
<div class="bar-count" id="cnt-tn">—</div>
</div>
<div class="bar-group">
<div class="bar fn" id="bar-fn"></div>
<div class="bar-label">False<br>Negative</div>
<div class="bar-count" id="cnt-fn">—</div>
</div>
</div>
<p class="formula" id="formula-text"></p>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .9rem; }
.controls label { font-size: .88rem; display: flex; flex-direction: column; gap: .2rem; color: #333; }
.controls input[type=range] { width: 100%; accent-color: #1d3557; }
.range-hint { font-size: .75rem; color: #888; }
.result-box { background: #1d3557; color: #fff; border-radius: 10px; padding: .6rem 1rem;
margin-bottom: .9rem; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.result-label { font-size: .85rem; flex: 1 1 160px; }
.result-value { font-size: 2rem; font-weight: 700; min-width: 80px; text-align: right; }
.chart-wrap { display: flex; gap: 1rem; align-items: flex-end; height: 140px; margin-bottom: .6rem; }
.bar-group { flex: 1; display: flex; flex-direction: column; align-items: center; gap: .2rem; height: 100%; justify-content: flex-end; }
.bar { width: 100%; border-radius: 5px 5px 0 0; min-height: 3px; transition: height .35s ease; }
.bar.tp { background: #2a9d5c; }
.bar.fp { background: #e63946; }
.bar.tn { background: #457b9d; }
.bar.fn { background: #f4a261; }
.bar-label { font-size: .7rem; text-align: center; color: #555; line-height: 1.3; }
.bar-count { font-size: .75rem; font-weight: 600; color: #333; }
.formula { font-size: .8rem; color: #555; line-height: 1.5; margin: 0; background: #f4f6f8;
border-radius: 6px; padding: .5rem .8rem; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Bayes' theorem demo: compute PPV (positive predictive value) given
// prevalence, sensitivity, and specificity in a population of 10,000.
// P(Disease|Positive) = (sensitivity * prevalence) /
// (sensitivity * prevalence + (1 - specificity) * (1 - prevalence))
const POP = 10000;
const PREV_MAP = [1, 10, 100, 1000]; // 1-in-X for slider values 0..3
function get(id){ return document.getElementById(id); }
function update(){
const prevIdx = parseInt(get('prev').value, 10) - 1;
const sens = parseInt(get('sens').value, 10) / 100;
const spec = parseInt(get('spec').value, 10) / 100;
const fpr = 1 - spec; // false positive rate
const oneInX = PREV_MAP[prevIdx];
const prev = 1 / oneInX;
get('prev-label').textContent = oneInX.toLocaleString();
get('sens-label').textContent = Math.round(sens * 100);
get('spec-label').textContent = Math.round(spec * 100);
// Population breakdown
const diseased = POP * prev;
const healthy = POP - diseased;
const tp = diseased * sens;
const fn = diseased * (1 - sens);
const tn = healthy * spec;
const fp = healthy * fpr;
// PPV = TP / (TP + FP)
const ppv = (tp + fp) > 0 ? tp / (tp + fp) : 0;
get('cnt-tp').textContent = Math.round(tp).toLocaleString();
get('cnt-fp').textContent = Math.round(fp).toLocaleString();
get('cnt-tn').textContent = Math.round(tn).toLocaleString();
get('cnt-fn').textContent = Math.round(fn).toLocaleString();
get('ppv').textContent = (ppv * 100).toFixed(1) + '%';
// Proportional bar heights (max bar fills ~120px)
const maxCount = Math.max(tp, fp, tn, fn, 1);
const MAX_H = 120;
['tp','fp','tn','fn'].forEach(k => {
const c = k === 'tp' ? tp : k === 'fp' ? fp : k === 'tn' ? tn : fn;
get('bar-' + k).style.height = Math.max(3, (c / maxCount) * MAX_H) + 'px';
});
// Formula text
const pPos = ((tp + fp) / POP * 100).toFixed(2);
get('formula-text').textContent =
fmt('Bayes: P(Disease | Positive) = P(Positive | Disease) × P(Disease) / P(Positive) = %v%% × %v2%% / %pPos%% ≈ %v3%%. Out of %POP% people: %round% true positives, %round2% false positives.', { v: (sens * 100).toFixed(0), v2: (prev * 100).toFixed(4), pPos: pPos, v3: (ppv * 100).toFixed(1), POP: POP.toLocaleString(), round: Math.round(tp), round2: Math.round(fp) });
console.log('PPV:', (ppv * 100).toFixed(2) + '%',
'| TP:', Math.round(tp), '| FP:', Math.round(fp),
'| prev 1 in', oneInX, '| sens', (sens*100)+'%', '| spec', (spec*100)+'%');
}
['prev','sens','spec'].forEach(id => get(id).addEventListener('input', update));
update();
Notice that even a highly accurate test produces mostly false positives when the disease is rare. The prevalence (base rate) dominates. Only when the disease is common enough does a positive result become genuinely alarming. This is the base-rate fallacy in action — our intuition ignores the prior and anchors on the test accuracy alone.
Comments
Loading comments...