The Prediction Leaderboard

Premier League Predictor: FastAPI & Redis

Chapter 8 · The Prediction Leaderboard

Chapter 7 ranked the twenty clubs. This chapter ranks the four predictors — the user, the BBC expert, the BBC's AI and the week's guests — by how many points their predictions have earned across the season. It is a much smaller sorted set, four members, but it is where the shortcuts taken in Chapter 6 get tested, and one of them turns out to be wrong.

What Gets Ranked

Chapter 6 left two hashes behind. totals holds each source's season points (user, expert, ai, guest_avg_x100), and each fixture's own points sit in fixture:{id}:points. The leaderboard also shows how many correct scores (40-point predictions) and correct results (10-point predictions) each source has made, and those counts don't exist yet. Guests get no counts: their figure is an average of several people's points, so "how many exact scores" has no meaning for it, matching the relational siblings.

Mixed Units: The Problem Hiding in Chapter 6

The obvious leaderboard is ZADD leaderboard <total> <source> using the numbers already in totals. But Chapter 6 stored three of them in whole points and the guest figure in hundredths of a point, to keep HINCRBY exact.

Verified: The Guest Average Outranks Everyone
Using totals from this chapter's test season — user 180, expert 340, AI 320 points, and a guest average of 326.65 points stored as 32665:
await r.zadd("mixed", {"user": 180, "expert": 340, "ai": 320, "guest": 32665}) await r.zrevrange("mixed", 0, -1) # ['guest', 'expert', 'ai', 'user'] -- guest "wins" by a factor of about 100
32665 is a bigger number than 340, so the guest tops the table. Redis has no idea that one column is in hundredths, so nothing complains. The correct order has the expert first.

The fix is a single common unit. Everything is ranked in hundredths of a point: the guest value as it is, and the other three multiplied by 100. Fixed, the same data ranks expert 34000, guest 32665, ai 32000, user 18000.

The Score: Points, Then Correct Scores

Ties are decided by who has more correct scores. As in Chapter 7, two numbers become one, most important first, and the result is negated so that ZRANGE lists first place down:

composite = total_in_hundredths * 1000 + correct_scores # correct_scores stays below 1000 score = -composite

A season has at most 380 fixtures, so the correct-score count can't reach 1,000 and can't carry into the points. The members are just user, expert, ai and guest. Anything still tied after both keys falls back to member-name order, ascending — the same arbitrary-but-deterministic fallback as Chapter 7.

One Script Keeps Everything Consistent

Chapter 6's script already did the hard part — new-minus-old, so a repeated or corrected result never double-counts. This version extends it to also maintain the correct-score and correct-result counts (with the same deltas), rescore the leaderboard member, and announce a change:

APPLY_POINTS_V2_LUA = """ local changed = false for i = 2, #ARGV, 2 do local src = ARGV[i] local new = tonumber(ARGV[i+1]) local old = tonumber(redis.call('HGET', KEYS[1], src) or '0') redis.call('HSET', KEYS[1], src, ARGV[i+1]) -- this fixture's points if new ~= old then changed = true redis.call('HINCRBY', KEYS[2], src, new - old) -- season total moves by the difference if src ~= 'guest_avg_x100' then redis.call('HINCRBY', KEYS[4], src .. ':scores', (new == 40 and 1 or 0) - (old == 40 and 1 or 0)) redis.call('HINCRBY', KEYS[4], src .. ':results', (new == 10 and 1 or 0) - (old == 10 and 1 or 0)) end end local total = tonumber(redis.call('HGET', KEYS[2], src) or '0') local scores = tonumber(redis.call('HGET', KEYS[4], src .. ':scores') or '0') local x100, member = total * 100, src if src == 'guest_avg_x100' then x100, member = total, 'guest' end redis.call('ZADD', KEYS[3], -(x100 * 1000 + scores), member) end if changed then redis.call('PUBLISH', ARGV[1], 'updated') end return changed and 1 or 0 """

The keys are the fixture's points hash, totals, the leaderboard sorted set and a records hash (fields like user:scores). ARGV[1] is the notification channel; the rest are source, points pairs, exactly as Chapter 6 built them. A new season seeds each of the four members at score 0. The route passes the same arguments as before plus the channel name — nothing else in the scoring route changes.

Verified Against an Independent Calculation

Same method as Chapter 7. Sixty fixtures, each with predictions from the user, expert and AI plus one to three guests, all random; a random result for every fixture; then 30 random corrections. The totals, counts and order were then recomputed from scratch in plain Python from the final results:

Verified: Leaderboard, Totals and Counts All Match
totals equal the independent calculation: True {'ai': 320, 'expert': 340, 'guest_avg_x100': 32665, 'user': 180} correct-score/result counts equal: True leaderboard order equals: True expert, guest, ai, user
This checks the Redis logic — the deltas, the counts, the ranking. It does not check whether the guest average itself is accurate; that is the rounding section below.

Note the guest row's correct-score count is never used, which is why its composite is just total * 1000.

Real-Time Updates With Pub/Sub

The script ends with PUBLISH, and only when something actually changed. A browser or another process that subscribes to that channel gets a nudge the moment a result is scored. Verified:

re-scoring the same result: script returns 0, no message scoring a corrected result: script returns 1, message ('message', 'updated') subscriber that connects afterwards: nothing at all

Repeating an identical result stays silent, which comes free from the "only when changed" test. The last line is Pub/Sub's real limitation: it keeps no history. A message is delivered to whoever is subscribed right then and forgotten. So the message can't carry the data — it is only a signal to read again. A client should read the leaderboard when it connects, then subscribe, and re-read on each nudge. (Subscribing first and then reading avoids missing a change in the gap; a re-read is cheap with four members.)

Revisiting Chapter 6: The Guest Average Has a Rounding Error

Chapter 6 rounds each fixture's guest average to hundredths and sums those. That round-then-sum is not the same as summing exactly, and the difference accumulates with the number of fixtures. Averaging three guests who scored 10, 40 and 0 gives 16.6667, stored as 1667 — 0.0033 too high, once per such fixture. The worst case is half a hundredth per fixture: 0.005 × 380 = 1.9 points over a season. What actually happens was measured by simulating 2,000 seasons of 380 fixtures with one to four random guests:

Measured: Small, but Not Zero
Across the 2,000 simulated seasons, the round-then-sum guest total differed from the exact total by an average of 0.07 points and a maximum of 0.16 points. That is tiny against totals in the hundreds — but this is a leaderboard, and two sources within a fraction of a point of each other could be ranked the wrong way round by it.

There is an exact alternative. Every prediction is worth 0, 10 or 40 points, so a guest total is a multiple of 10, and 10×60 = 600 divides evenly by 1, 2, 3, 4, 5 and 6. Storing the average in sixtieths of a point makes it an exact integer for any fixture with up to six guests — checked here across 200 random cases for each guest count from one to six. Seven guests is the first count that fails (10/7 is not a whole number of sixtieths). Exercise 3 works it through.

Reading the Leaderboard

async def get_leaderboard(r): members = await r.zrange("leaderboard", 0, -1) # best first async with r.pipeline(transaction=False) as pipe: pipe.hgetall("totals") pipe.hgetall("records") totals, records = await pipe.execute() rows = [] for pos, m in enumerate(members, start=1): if m == "guest": rows.append({"pos": pos, "source": m, "points": int(totals.get("guest_avg_x100", 0)) / 100, "correct_scores": None, "correct_results": None}) else: rows.append({"pos": pos, "source": m, "points": int(totals.get(m, 0)), "correct_scores": int(records.get(f"{m}:scores", 0)), "correct_results": int(records.get(f"{m}:results", 0))}) return rows

Missing fields default to zero, as in Chapter 7. The guest's counts are None, so the client renders a dash rather than a misleading zero.

StackHow the prediction table is produced
PostgreSQL / Django / SQLite siblingsA two-pass query on each read: average guest points per fixture, then sum every source across the season
This course (Redis)Maintained at write time; the read is a range call and two hash reads. The guest's correct-score count is None in both

Hands-On Exercises

Exercise 1

Reproduce the mixed-unit bug with the chapter's season totals (user 180, expert 340, AI 320, guest 32665), report the order, then convert everything to hundredths and report the corrected order and scores.

📄 View solution
Exercise 2

Use the script to make the user and the expert finish on the same points but with different correct-score counts, then make three sources tie on both keys. Report the leaderboard for each case and say what decided it.

📄 View solution
Exercise 3

Compute the guest average in hundredths and in sixtieths for guest sets scoring [10,40,0], [40,10,10,0,0] and seven guests [10,0,0,0,0,0,0]. Report the rounding error of each, and explain why the sixtieths scheme stops working at seven guests.

📄 View solution

Chapter 8 Quick Reference

  • Rank in one unit — verified: mixing whole points with the guest's hundredths put the guest first by a factor of ~100
  • composite = total in hundredths × 1000 + correct scores, negated — guest total is already in hundredths and has no counts
  • Extend the delta script — totals, correct-score/result counts and the ZADD in one atomic call
  • Verified against an independent calculation — 60 fixtures, 30 corrections: totals, counts and order equal
  • Pub/Sub is a nudge, not a store — no message on an unchanged result; a late subscriber sees nothing, so read on connect
  • Chapter 6's rounding accumulates — measured mean 0.07, max 0.16 points per season; sixtieths are exact for up to six guests