File size: 8,205 Bytes
44b4b5f
839df80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44b4b5f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reliability table builder: check whether your probabilities mean what they say</title>
<meta name="description" content="Paste forecast probabilities with their outcomes and get a reliability table by band, the Brier score and a plain-English verdict on calibration. Runs in the browser, nothing is uploaded.">
<style>
:root{--bg:#fbfbfd;--ink:#1a1c22;--mute:#5f6573;--line:#e1e4ea;--accent:#1f5fbf;--chip:#eaf1fc;--card:#fff}
@media (prefers-color-scheme:dark){:root{--bg:#121318;--ink:#e9ebf0;--mute:#a0a6b4;--line:#2b2e37;--accent:#7fb0ff;--chip:#1b2433;--card:#171a21}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}
main{max-width:800px;margin:0 auto;padding:40px 16px 64px}
h1{font-size:1.9rem;line-height:1.2;margin:0 0 10px}
h2{font-size:1.15rem;margin:34px 0 8px}
p{margin:0 0 14px}
.lead{color:var(--mute);font-size:1.08rem}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px 20px;margin:22px 0}
label{display:block;font-size:.9rem;color:var(--mute);margin-bottom:6px}
textarea{width:100%;min-height:150px;font:14px/1.5 ui-monospace,Consolas,monospace;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink)}
.row{display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;align-items:center}
button{font:inherit;padding:8px 14px;border-radius:10px;border:1px solid var(--line);background:var(--bg);color:var(--ink);cursor:pointer}
button.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
select{font:inherit;padding:7px 10px;border-radius:10px;border:1px solid var(--line);background:var(--bg);color:var(--ink)}
table{width:100%;border-collapse:collapse;margin-top:14px;font-variant-numeric:tabular-nums}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line)}
th{font-size:.82rem;text-transform:uppercase;letter-spacing:.04em;color:var(--mute)}
tr:last-child td{border-bottom:0}
.verdict{background:var(--chip);border-radius:12px;padding:12px 16px;margin-top:14px}
.err{color:#b3261e}
a{color:var(--accent)}
footer{margin-top:48px;color:var(--mute);font-size:.88rem;border-top:1px solid var(--line);padding-top:16px}
</style>
</head>
<body>
<main>
<h1>Reliability table builder</h1>
<p class="lead">A forecaster that says "70%" should be right about 70 times in 100. Paste your forecasts and what happened, and this page builds the table that checks it.</p>

<div class="card">
<label for="data">One line per forecast: probability (0 to 1, or a percent), then 1 if it happened or 0 if it did not. Commas, spaces or tabs between them. Extra columns are ignored.</label>
<textarea id="data" spellcheck="false">0.72,1
0.65,1
0.65,0
0.30,0
0.28,1
0.81,1
0.55,0
0.47,1
0.22,0
0.90,1
0.61,0
0.35,0
0.15,0
0.77,1
0.52,1
0.44,0
0.68,1
0.25,0
0.83,1
0.58,0</textarea>
<div class="row">
<button class="primary" id="go">Build the table</button>
<label for="bands" style="margin:0">Bands</label>
<select id="bands"><option value="5">5 (20-point bands)</option><option value="10" selected>10 (10-point bands)</option></select>
<button id="clear">Clear</button>
</div>
<p class="err" id="msg" style="margin:10px 0 0"></p>
<div id="out"></div>
</div>

<h2>How to read it</h2>
<p>Each row groups the forecasts that claimed a similar chance. <strong>Average claim</strong> is the mean probability stated in that band; <strong>observed</strong> is the share of those forecasts whose outcome happened. When the two are close in every band the forecaster is well calibrated. A band where observed sits well below the claim is overconfident; well above, underconfident.</p>
<p>The <strong>Brier score</strong> is the mean squared gap between each probability and its outcome (1 or 0). Lower is better; always guessing 50% scores 0.25 on balanced data. It rewards both calibration and sharpness, so a forecaster can be perfectly calibrated and still have a poor Brier score if it never commits.</p>
<p>Small samples swing. With 20 forecasts a band may hold two or three cases, so its observed share can only be 0%, 33%, 50% or 100%. Treat anything under about 100 forecasts per band as a sketch, not a verdict.</p>

<h2>A worked example on real data</h2>
<p>The <a href="https://betbetter.world/studies/market-calibration">market calibration study</a> ran this exact table on both sides of 18,238 settled sports markets. Every band landed within about two points of its claim, with outcomes priced below 50% happening slightly more often than claimed and those above 50% slightly less. Its open data set, one row per outcome with the margin-free probability, is a good file to paste here if you want to see what a large, well-calibrated sample looks like. Margin-free model probabilities to test the same way are available from the <a href="https://betbetter.world/api/">Bet Better API</a> with no key.</p>

<footer>Built by Bet Better Pty Ltd, Melbourne, as a teaching tool for forecast evaluation. Everything runs in your browser; nothing is uploaded. Not betting advice.</footer>
</main>
<script>
(function(){
  var data=document.getElementById('data'), out=document.getElementById('out'), msg=document.getElementById('msg'), bandsSel=document.getElementById('bands');
  function pct(x){return (x*100).toFixed(1)+'%';}
  function run(){
    out.innerHTML=''; msg.textContent='';
    var rows=[], bad=0;
    data.value.split(/\r?\n/).forEach(function(line){
      var parts=line.trim().split(/[\s,;\t]+/).filter(Boolean);
      if(parts.length<2) return;
      var p=parseFloat(parts[0]), y=parseFloat(parts[1]);
      if(isNaN(p)||isNaN(y)){bad++;return;}
      if(p>1) p=p/100;
      if(p<0||p>1||(y!==0&&y!==1)){bad++;return;}
      rows.push([p,y]);
    });
    if(rows.length<2){msg.textContent='Need at least two valid lines of "probability, outcome".';return;}
    var k=parseInt(bandsSel.value,10), w=1/k, bands=[];
    for(var i=0;i<k;i++) bands.push({lo:i*w,hi:(i+1)*w,n:0,sp:0,sy:0});
    var brier=0;
    rows.forEach(function(r){
      var i=Math.min(k-1,Math.floor(r[0]/w)); var b=bands[i]; b.n++; b.sp+=r[0]; b.sy+=r[1];
      brier+=(r[0]-r[1])*(r[0]-r[1]);
    });
    brier/=rows.length;
    var html='<table><thead><tr><th>Claimed</th><th>Forecasts</th><th>Average claim</th><th>Observed</th><th>Gap</th></tr></thead><tbody>';
    var maxGap=0, nBig=0;
    bands.forEach(function(b){
      if(!b.n) return;
      var claim=b.sp/b.n, obs=b.sy/b.n, gap=(obs-claim)*100;
      if(b.n>=10){ if(Math.abs(gap)>maxGap) maxGap=Math.abs(gap); if(Math.abs(gap)>5) nBig++; }
      html+='<tr><td>'+Math.round(b.lo*100)+' to '+Math.round(b.hi*100)+'%</td><td>'+b.n+'</td><td>'+pct(claim)+'</td><td>'+pct(obs)+'</td><td>'+(gap>=0?'+':'')+gap.toFixed(1)+' pts</td></tr>';
    });
    html+='</tbody></table>';
    var base=rows.reduce(function(a,r){return a+r[1];},0)/rows.length;
    var verdict;
    if(rows.length<50) verdict='Only '+rows.length+' forecasts: the table is a sketch. Gaps of 10 points or more are expected from noise alone at this size.';
    else if(nBig===0) verdict='Every band with at least 10 forecasts sits within 5 points of its claim. On this sample the forecasts are well calibrated.';
    else verdict=nBig+' band'+(nBig>1?'s':'')+' with at least 10 forecasts miss'+(nBig>1?'':'es')+' by more than 5 points (largest gap '+maxGap.toFixed(1)+'). Look at whether the misses are all in one direction: below the claim means overconfident, above means underconfident.';
    out.innerHTML='<div class="verdict"><strong>'+rows.length+' forecasts.</strong> Brier score '+brier.toFixed(4)+' (always guessing the base rate of '+pct(base)+' would score '+(base*(1-base)).toFixed(4)+'). '+verdict+(bad?' '+bad+' line'+(bad>1?'s':'')+' skipped.':'')+'</div>'+html;
  }
  document.getElementById('go').addEventListener('click',run);
  document.getElementById('clear').addEventListener('click',function(){data.value='';out.innerHTML='';msg.textContent='';});
  bandsSel.addEventListener('change',run);
  run();
})();
</script>
</body>
</html>