Navigation apps estimate speed from the phones of a few drivers: see how many users they need, and what bias does
Navigation apps collect GPS “pings” from phones at regular intervals. A car that spends twice as long on a stretch sends twice as many pings, so averaging the pings gives a space-mean speed, just like the drone. The catch: only cars running the app are counted.
viewof pen = Inputs.range([1,50], {value:5,step:1,label:"Cars running the app (%)"})viewof appBias = Inputs.range([0,20], {value:0,step:1,label:"App users drive faster by (%)"})viewof probeRun = Inputs.button("Run 300 more days")
// 300 simulated five-minute periods of 180 vehicles each. The app's estimate is the harmonic mean// of the app users' speeds (pings arrive in proportion to time spent on the road).probe = { probeRun;const randn = () =>Math.sqrt(-2*Math.log(1-Math.random())) *Math.cos(2*Math.PI*Math.random());const rows = [];let none =0;for (let r =0; r <300; r++) {let inv =0, invApp =0, nApp =0;for (let i =0; i <180; i++) {const app =Math.random() < pen /100;let v =Math.min(40,Math.max(8,25+7*randn()));if (app) v *=1+ appBias /100; inv +=1/ v;if (app) { invApp +=1/ v; nApp++; } }if (nApp ===0) { none++;continue; } rows.push({r,err: (nApp / invApp -180/ inv) *3.6}); }const rmse =Math.sqrt(d3.mean(rows, d => d.err**2));return {rows, none, rmse,bias: d3.mean(rows, d => d.err)};}
md`Each dot is one five-minute period. Typical error: **±${probe.rmse.toFixed(1)} km/h** · average error (bias): **${probe.bias>=0?"+":""}${probe.bias.toFixed(1)} km/h**${probe.none?` · in ${probe.none} of 300 periods, not a single app user passed`:""}.`
What it shows
Two lessons: too few app users gives noise, and app users who drive differently give bias. More data cures noise, never bias. Lecture 2 places this among every other traffic sensor.
theme = Generators.observe(notify => {const read = () =>notify(document.body.classList.contains("quarto-dark") ?"dark":"light");read();const watcher =newMutationObserver(read); watcher.observe(document.body, {attributes:true,attributeFilter: ["class"]});return () => watcher.disconnect();})accent = theme ==="dark"?"#c9a0ff":"#57068c"accentRange = theme ==="dark"? ["#4b3a5c","#ecdcff"] : ["#dccbee","#3a0463"]ink = theme ==="dark"?"#f2f2f2":"black"// Plot width: Plot's default 640 px, except on a phone, where a plot is drawn to fit its card// instead of being shrunk (and its text with it). Changes only when the width actually changes.plotW = Generators.observe(notify => {let last;const read = () => {const main =document.querySelector("main"), fit = main ? main.clientWidth-50:640;const w =!document.querySelector(".reveal") && fit <640?Math.max(240,Math.floor(fit)) :640;if (w !== last) notify(last = w); };read();window.addEventListener("resize", read);return () =>window.removeEventListener("resize", read);})