The demo below runs the same counting query — "how many people answered YES?" — on a synthetic group of 100 users, under both the central and local models. Drag the epsilon slider to control the privacy budget and watch how the two models diverge.
<!-- Layout: legend badges, epsilon + truth sliders, run/reset buttons, bar chart -->
<div class="panel">
<div class="row-label">
<span class="badge central">Central DP</span>
<span class="sub">curator adds noise to final answer</span>
</div>
<div class="row-label">
<span class="badge local">Local DP</span>
<span class="sub">each user randomizes before sending</span>
</div>
</div>
<div class="slider-row">
<label for="eps">ε = <span id="eps-val">1.0</span></label>
<input type="range" id="eps" min="0.1" max="4" step="0.1" value="1.0" />
<span class="eps-hint">more private ← → more accurate</span>
</div>
<div class="slider-row">
<label for="truth">True YES %: <span id="truth-val">60</span>%</label>
<input type="range" id="truth" min="10" max="90" step="5" value="60" />
</div>
<div class="btns">
<button id="run-btn" type="button">Run query</button>
<button id="reset-btn" type="button" class="ghost">Reset</button>
</div>
<div id="chart" class="chart"></div>
<div id="status" class="status"></div>
/* Styles: badges, sliders, bar chart, status */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.panel { display: flex; gap: 1rem; margin-bottom: .6rem; flex-wrap: wrap; }
.row-label { display: flex; align-items: center; gap: .4rem; }
.badge { font: 700 12px system-ui; padding: .2rem .55rem; border-radius: 6px; }
.badge.central { background: #1d3557; color: #fff; }
.badge.local { background: #e63946; color: #fff; }
.sub { font-size: .78rem; color: #555; }
.slider-row { display: flex; align-items: center; gap: .6rem; margin: .35rem 0; flex-wrap: wrap; }
label { font-size: .88rem; font-weight: 600; min-width: 9rem; }
input[type=range] { flex: 1; min-width: 120px; max-width: 220px; }
.eps-hint { font-size: .72rem; color: #888; }
.btns { display: flex; gap: .5rem; margin: .6rem 0; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.chart { display: flex; gap: 4px; align-items: flex-end; height: 140px;
border-bottom: 2px solid #ccc; margin: .5rem 0; position: relative; }
.bar-wrap { display: flex; flex-direction: column; align-items: center; flex: 1; height: 100%; justify-content: flex-end; }
.bar { width: 100%; border-radius: 4px 4px 0 0; transition: height .3s; min-height: 2px; }
.bar.truth { background: #a8c5e8; }
.bar.central { background: #1d3557; }
.bar.local { background: #e63946; }
.bar-tick { font-size: .65rem; color: #666; margin-top: 3px; text-align: center; }
.status { font-size: .88rem; font-weight: 600; min-height: 1.4em; margin-top: .3rem; }
.status.ok { color: #0a7d33; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Differential privacy demo: same counting query under central and local models
// Central model: curator sees raw data, adds Laplace(1/eps) to true count.
const N = 100; // synthetic population of 100 users
// Local model: randomized response (Warner 1965, eps-DP)
// p(report=1 | truth=1) = e^eps/(e^eps+1), p(report=1 | truth=0) = 1/(e^eps+1)
// Debiasing: invert the randomized-response channel to recover an estimate of the true count
// estimate = (sum_reports - N/(e^eps+1)) * (e^eps+1)/(e^eps-1)
function laplaceNoise(scale) {
// Inverse-CDF method for Laplace(0, scale) noise — no external library needed
const u = Math.random() - 0.5;
return -scale * Math.sign(u) * Math.log(1 - 2 * Math.abs(u));
}
function centralDP(trueCount, eps) {
// Central DP: add Laplace noise scaled to 1/epsilon to the true count
return Math.round(trueCount + laplaceNoise(1 / eps));
}
function localDP(trueCount, eps) {
// Local DP: each user flips their bit with randomized response, then server debiases
const p1 = Math.exp(eps) / (Math.exp(eps) + 1);
const p0 = 1 / (Math.exp(eps) + 1);
let reports = 0;
for (let i = 0; i < N; i++) {
const truth = i < trueCount ? 1 : 0;
reports += Math.random() < (truth === 1 ? p1 : p0) ? 1 : 0;
}
// Debiasing step: invert the randomized-response channel
const estimate = (reports - N * p0) / (p1 - p0);
return Math.round(estimate);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
let runs = [];
function runQuery() {
const eps = parseFloat(document.getElementById('eps').value);
const truthPct = parseInt(document.getElementById('truth').value, 10);
const trueCount = Math.round(N * truthPct / 100);
const cAns = clamp(centralDP(trueCount, eps), 0, N);
const lAns = clamp(localDP(trueCount, eps), 0, N);
runs.push({ truth: truthPct, central: cAns, local: lAns });
if (runs.length > 12) runs.shift(); // Keep at most 12 runs in the chart
renderChart(runs, trueCount);
const cErr = Math.abs(cAns - trueCount);
const lErr = Math.abs(lAns - trueCount);
document.getElementById('status').textContent =
i18nFmt('Truth=%truth% | Central=%c% (err %cerr%) | Local=%l% (err %lerr%)', { truth: trueCount, c: cAns, l: lAns, cerr: cErr, lerr: lErr });
document.getElementById('status').className = 'status ok';
}
function renderChart(runs, trueCount) {
const chart = document.getElementById('chart');
chart.innerHTML = '';
const MAX = 100;
runs.forEach((r, i) => {
// Render three bars per run: truth (blue), central answer (dark), local answer (red)
const wrap = document.createElement('div');
wrap.className = 'bar-wrap';
const bT = document.createElement('div');
bT.className = 'bar truth';
bT.style.height = (r.truth / MAX * 130) + 'px';
bT.title = 'Truth: ' + r.truth + '%';
const bC = document.createElement('div');
bC.className = 'bar central';
bC.style.height = (clamp(r.central, 0, MAX) / MAX * 130) + 'px';
bC.title = 'Central: ' + r.central;
const bL = document.createElement('div');
bL.className = 'bar local';
bL.style.height = (clamp(r.local, 0, MAX) / MAX * 130) + 'px';
bL.title = 'Local: ' + r.local;
const tick = document.createElement('div');
tick.className = 'bar-tick';
tick.textContent = '#' + (i + 1);
wrap.appendChild(bT);
wrap.appendChild(bC);
wrap.appendChild(bL);
wrap.appendChild(tick);
chart.appendChild(wrap);
});
}
document.getElementById('run-btn').addEventListener('click', runQuery);
document.getElementById('reset-btn').addEventListener('click', () => {
runs = [];
document.getElementById('chart').innerHTML = '';
document.getElementById('status').textContent = '';
document.getElementById('status').className = 'status';
});
document.getElementById('eps').addEventListener('input', () => {
document.getElementById('eps-val').textContent =
parseFloat(document.getElementById('eps').value).toFixed(1);
});
document.getElementById('truth').addEventListener('input', () => {
document.getElementById('truth-val').textContent = document.getElementById('truth').value;
});
<!-- Layout: legend badges, epsilon + truth sliders, run/reset buttons, bar chart -->
<div class="panel">
<div class="row-label">
<span class="badge central">Central DP</span>
<span class="sub">curator adds noise to final answer</span>
</div>
<div class="row-label">
<span class="badge local">Local DP</span>
<span class="sub">each user randomizes before sending</span>
</div>
</div>
<div class="slider-row">
<label for="eps">ε = <span id="eps-val">1.0</span></label>
<input type="range" id="eps" min="0.1" max="4" step="0.1" value="1.0" />
<span class="eps-hint">more private ← → more accurate</span>
</div>
<div class="slider-row">
<label for="truth">True YES %: <span id="truth-val">60</span>%</label>
<input type="range" id="truth" min="10" max="90" step="5" value="60" />
</div>
<div class="btns">
<button id="run-btn" type="button">Run query</button>
<button id="reset-btn" type="button" class="ghost">Reset</button>
</div>
<div id="chart" class="chart"></div>
<div id="status" class="status"></div>
/* Styles: badges, sliders, bar chart, status */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.panel { display: flex; gap: 1rem; margin-bottom: .6rem; flex-wrap: wrap; }
.row-label { display: flex; align-items: center; gap: .4rem; }
.badge { font: 700 12px system-ui; padding: .2rem .55rem; border-radius: 6px; }
.badge.central { background: #1d3557; color: #fff; }
.badge.local { background: #e63946; color: #fff; }
.sub { font-size: .78rem; color: #555; }
.slider-row { display: flex; align-items: center; gap: .6rem; margin: .35rem 0; flex-wrap: wrap; }
label { font-size: .88rem; font-weight: 600; min-width: 9rem; }
input[type=range] { flex: 1; min-width: 120px; max-width: 220px; }
.eps-hint { font-size: .72rem; color: #888; }
.btns { display: flex; gap: .5rem; margin: .6rem 0; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.chart { display: flex; gap: 4px; align-items: flex-end; height: 140px;
border-bottom: 2px solid #ccc; margin: .5rem 0; position: relative; }
.bar-wrap { display: flex; flex-direction: column; align-items: center; flex: 1; height: 100%; justify-content: flex-end; }
.bar { width: 100%; border-radius: 4px 4px 0 0; transition: height .3s; min-height: 2px; }
.bar.truth { background: #a8c5e8; }
.bar.central { background: #1d3557; }
.bar.local { background: #e63946; }
.bar-tick { font-size: .65rem; color: #666; margin-top: 3px; text-align: center; }
.status { font-size: .88rem; font-weight: 600; min-height: 1.4em; margin-top: .3rem; }
.status.ok { color: #0a7d33; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Differential privacy demo: same counting query under central and local models
// Central model: curator sees raw data, adds Laplace(1/eps) to true count.
const N = 100; // synthetic population of 100 users
// Local model: randomized response (Warner 1965, eps-DP)
// p(report=1 | truth=1) = e^eps/(e^eps+1), p(report=1 | truth=0) = 1/(e^eps+1)
// Debiasing: invert the randomized-response channel to recover an estimate of the true count
// estimate = (sum_reports - N/(e^eps+1)) * (e^eps+1)/(e^eps-1)
function laplaceNoise(scale) {
// Inverse-CDF method for Laplace(0, scale) noise — no external library needed
const u = Math.random() - 0.5;
return -scale * Math.sign(u) * Math.log(1 - 2 * Math.abs(u));
}
function centralDP(trueCount, eps) {
// Central DP: add Laplace noise scaled to 1/epsilon to the true count
return Math.round(trueCount + laplaceNoise(1 / eps));
}
function localDP(trueCount, eps) {
// Local DP: each user flips their bit with randomized response, then server debiases
const p1 = Math.exp(eps) / (Math.exp(eps) + 1);
const p0 = 1 / (Math.exp(eps) + 1);
let reports = 0;
for (let i = 0; i < N; i++) {
const truth = i < trueCount ? 1 : 0;
reports += Math.random() < (truth === 1 ? p1 : p0) ? 1 : 0;
}
// Debiasing step: invert the randomized-response channel
const estimate = (reports - N * p0) / (p1 - p0);
return Math.round(estimate);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
let runs = [];
function runQuery() {
const eps = parseFloat(document.getElementById('eps').value);
const truthPct = parseInt(document.getElementById('truth').value, 10);
const trueCount = Math.round(N * truthPct / 100);
const cAns = clamp(centralDP(trueCount, eps), 0, N);
const lAns = clamp(localDP(trueCount, eps), 0, N);
runs.push({ truth: truthPct, central: cAns, local: lAns });
if (runs.length > 12) runs.shift(); // Keep at most 12 runs in the chart
renderChart(runs, trueCount);
const cErr = Math.abs(cAns - trueCount);
const lErr = Math.abs(lAns - trueCount);
document.getElementById('status').textContent =
i18nFmt('Truth=%truth% | Central=%c% (err %cerr%) | Local=%l% (err %lerr%)', { truth: trueCount, c: cAns, l: lAns, cerr: cErr, lerr: lErr });
document.getElementById('status').className = 'status ok';
}
function renderChart(runs, trueCount) {
const chart = document.getElementById('chart');
chart.innerHTML = '';
const MAX = 100;
runs.forEach((r, i) => {
// Render three bars per run: truth (blue), central answer (dark), local answer (red)
const wrap = document.createElement('div');
wrap.className = 'bar-wrap';
const bT = document.createElement('div');
bT.className = 'bar truth';
bT.style.height = (r.truth / MAX * 130) + 'px';
bT.title = 'Truth: ' + r.truth + '%';
const bC = document.createElement('div');
bC.className = 'bar central';
bC.style.height = (clamp(r.central, 0, MAX) / MAX * 130) + 'px';
bC.title = 'Central: ' + r.central;
const bL = document.createElement('div');
bL.className = 'bar local';
bL.style.height = (clamp(r.local, 0, MAX) / MAX * 130) + 'px';
bL.title = 'Local: ' + r.local;
const tick = document.createElement('div');
tick.className = 'bar-tick';
tick.textContent = '#' + (i + 1);
wrap.appendChild(bT);
wrap.appendChild(bC);
wrap.appendChild(bL);
wrap.appendChild(tick);
chart.appendChild(wrap);
});
}
document.getElementById('run-btn').addEventListener('click', runQuery);
document.getElementById('reset-btn').addEventListener('click', () => {
runs = [];
document.getElementById('chart').innerHTML = '';
document.getElementById('status').textContent = '';
document.getElementById('status').className = 'status';
});
document.getElementById('eps').addEventListener('input', () => {
document.getElementById('eps-val').textContent =
parseFloat(document.getElementById('eps').value).toFixed(1);
});
document.getElementById('truth').addEventListener('input', () => {
document.getElementById('truth-val').textContent = document.getElementById('truth').value;
});
Notice: for small ε (high privacy) the central answer stays close to the truth while the local answer bounces wildly. For large ε (low privacy) both converge, but you have given up privacy protection. The fundamental trade-off is built into the mathematics — not an implementation detail you can engineer away.
Comments
Loading comments...