The Prediction League Table: Aggregating Predictor Performance Across the Season
Premier League Predictor: FastAPI & PostgreSQL
Chapter 8 · The Prediction League Table: Aggregating Predictor Performance Across the Season
Chapter 7 answered "how good is each team." This chapter answers a genuinely different question: "how good is each predictor" — the user, the BBC's expert, that season's guests, and the BBC's AI — aggregated across every fixture Chapter 6 has scored.
Scoping Predictions to a Season, and Only Counting What's Actually Scored
predictions.points_awarded IS NOT NULL is the real gate: a prediction on a fixture that hasn't kicked off yet still has points_awarded = NULL (Chapter 6), so it never contributes to anyone's season total until a real result actually exists for it.
Three Straightforward Sources, One That Needs Two Passes
User, expert, and AI each contribute exactly one Prediction row per fixture (Chapter 5's own partial unique index guarantees it) — a single SUM(points_awarded) is their entire season total. Guest is different: Chapter 6 resolved that a fixture's own "guest" contribution is the average of however many individual guests predicted it, not a raw sum of every guest row. That means guest needs its own points averaged per fixture first, before those per-fixture averages get summed across the season — genuinely two passes of aggregation, not one.
UNION ALL requires every corresponding column across its branches to share a compatible type. non_guest_totals.correct_scores is a real integer count; guest_totals has no meaningful equivalent to put there (explained below), but a bare, untyped NULL gives PostgreSQL nothing to reconcile against the integer column from the other branch. NULL::int is an explicitly-typed null — genuinely absent, but typed as an integer specifically so the two branches line up.
predictions_scored counts real, individual Prediction rows — one per fixture, always. For guest, it counts rows from guest_per_fixture — one per fixture that had at least one guest prediction, regardless of whether that fixture had one guest or five. A season with 30 played fixtures, all with exactly one guest each, and a season with 30 played fixtures, all with three guests each, would report the identical predictions_scored: 30 for guest — a deliberate consequence of scoring "the guest slot," not "every individual guest," matching Chapter 6's own resolution directly.
Rendering the Prediction Table
row.correct_scores === null is what lets the frontend show a plain "—" for the guest row's own breakdown column, rather than a misleading "0 exact / 0 result" that would look like guests never got anything right.
Where This Course Is Headed
Promotion and relegation between seasons — real changes to season_teams, the same table both this chapter's and Chapter 7's own queries read from (Chapter 9); and a real gameweek/season selector, tying every route built across this course into one working interface (Chapter 10).
Hands-On Exercises
Explain why guest_totals needs its own separate CTE built on top of guest_per_fixture, rather than just adding a WHERE source = 'guest' branch alongside non_guest_totals using the same SUM(points_awarded) pattern.
📄 View solutionExplain why correct_scores and correct_results are left as NULL::int for the guest row instead of being computed the same way as the other three sources, and why a bare NULL (without the ::int cast) wouldn't work in this specific query.
📄 View solutionRecord and score predictions from all four sources across at least two fixtures, where one fixture has two guests and the other has only one, then call GET /api/seasons/{id}/prediction-table and confirm the guest row's predictions_scored equals 2, not 3.
📄 View solutionChapter 8 Quick Reference
- A genuinely different table from Chapter 7 — ranks predictors, not teams, built from points_awarded (Chapter 6)
- points_awarded IS NOT NULL — the real gate keeping unplayed fixtures out of anyone's season total
- User/expert/AI — a plain SUM(points_awarded) per source, one prediction per fixture, guaranteed by Chapter 5's partial unique index
- Guest — averaged per fixture first (guest_per_fixture), then summed across the season — resolving Chapter 6's own deferred question
- NULL::int — an explicitly-typed null, required so UNION ALL's branches line up on column type
- correct_scores/correct_results — meaningful for user/expert/AI, honestly left null for guest since an averaged figure was never a 40-or-10-or-0 outcome to begin with
- Next chapter: Promotion & relegation — real changes to season_teams, the table both league tables read from