Are gifted players less athletic? Collider bias and the academy
Pick the best 200 of 5,000 young players and, among those picked, the more technical turn out to be the less athletic, even when the two have nothing to do with each other. How the way you choose your data can create a link that was never there, with a simulator and a real check on league tables.
Beginner Part 16 of Statistics Through Football
Contents
The football question
Are technically gifted footballers less athletic, or have we fooled ourselves with the data?
Picture every young footballer a club's scouts have rated across their region over several seasons: 5,000 of them, all hoping to get into its academy. Each has been scored on two things: technical ability (passing, dribbling, ball control) and physical ability (pace, strength, endurance).
Across all 5,000 there's virtually no link between the two. Knowing a player is technically gifted tells you next to nothing about how athletic they are.
Over those seasons the academy takes the best 200, on a mix of both. A player who's a bit weaker in one area can still get in by being exceptional in the other.
Run the same analysis on just those 200 and something odd turns up: a strong negative link. The more technical the player, the less athletic they tend to be, and the other way round. It's tempting to think we've found something fundamental about elite footballers. We haven't. We've walked straight into collider bias.
You can try it yourself in the Academy Selection Simulator: change how the academy picks and watch the link come and go.
The concept
The measure of a link here is the correlation (Pearson's r): 0 means no link, −1 a perfect "more of one, less of the other", +1 a perfect "more of both".
A collider is something caused by two other things. Getting into the academy is caused by technique and by athleticism, so it's a collider. Look only at the players who got in, or "control for" getting in, and the two causes become linked, even if they had nothing to do with each other. Statisticians call this collider bias. The version where you select on the collider, as the academy does, is also known as Berkson's paradox, after the doctor who spotted it in hospital patients in 1946.
It's a cousin of two traps covered elsewhere on the site, and worth keeping apart from them. Survivorship bias looks only at the ones that lasted, like the midfield partnerships that seemed to gel. Selection bias compares groups that were chosen differently, like the substitutions managers make after a bad spell. Collider bias is more specific: the filter depends on both of the things you're comparing.
One academy, 200 places in 5,000
I ran a quick Python simulation with 5,000 made-up footballers. Technique and athleticism were drawn independently, so there was no underlying link between them at all. Then I picked the top 200 on their combined score.
| Group | Correlation |
|---|---|
| All 5,000 players | +0.03 |
| The 200 picked | −0.81 |
That's near enough zero to strongly negative, without changing a single player's ability. The only thing that changed was who we were looking at.
So what actually happened?
We selected the players on the two things we were trying to study. A brilliant technician could get in without being much of an athlete, and a real athlete could get in with fairly average technique. Nobody gets in by being ordinary at both. Inside the academy, one strength is covering for the other, and that's what produces the negative link. In the chart, the picked players fill a band across the top right, and a band like that can only slope down.
What makes it worse, and what makes it go away
One academy is one draw of the dice, so the snippet repeats each version over 60 different intakes of 5,000 players and averages them.
- The pickier the academy, the stronger the false link. Take half the players and it's −0.47. Take the top 200 and it's −0.78; the top 50, −0.82.
- The totals don't matter, the share picked does. A single age group at a smaller club, 12 picked from 300 rated, gives the same link on average (−0.76). One small intake just wobbles more from year to year.
- Picking on technique alone creates nothing (−0.01). Nobody can trade one ability for the other, so no link appears.
- Neither does a minimum in both (−0.02): everyone above the same bar for technique and for athleticism. You still pick about 200 players, but a brilliant technician who can't run doesn't get in.
- A different mix changes the size, not the story. Weight it 70% technique, 30% athleticism, and the top 200 come out at −0.67.
- It can turn a real link round. Suppose technique and athleticism really do go together, at +0.3 across all 5,000. The top 200 still come out at −0.62, and at −0.45 when the real link is +0.5. The selection hasn't just invented a link. It has hidden a real one and shown the opposite.
So it's not selection as such that does it. It's selecting on a score where one thing can make up for the other.
Does it happen in real football?
League tables are a selection of exactly this kind. Points come from scoring goals and from keeping them out, and a side can finish high with a great attack and a so-so defence, or the other way round. So split the table in two and the link between attack and defence should weaken in each half, even if it's strong across the whole league.
The test uses every Scottish Championship season from 2000/01 to 2025/26: ten clubs a season, 260 team-seasons. Attack is goals scored a game and defence is goals kept out a game, each measured against that season's average, so a free-scoring season doesn't flatter everyone in it.
Across all 260, attack and defence go together strongly: +0.59. Good sides tend to be good at both. Within each half of the table the link is much weaker: +0.39 in the top five and +0.16 in the bottom five.
That might mean struggling sides really do choose between attacking and defending. But the snippet checks what selection alone would do: 1,000 made-up leagues with the same +0.59 link and nothing else going on, each split by points the same way. Each half comes out at about +0.27, and anywhere from about +0.10 to +0.44 in 95 leagues out of 100.
Both real halves sit inside those ranges. Splitting the table by points is enough on its own to explain why the link weakens, so it says nothing either way about whether bottom-half sides play differently. The Premiership shows the same thing: outside Celtic and Rangers, attack and defence go together at +0.41, but only +0.08 within the bottom six. (The Old Firm are left out of that comparison because they're so much stronger than everyone else that they'd make any link look stronger.)
Why it matters
Think about the data we use all the time. Players in a top league have already come through years of selection. Studies of successful transfers tend to leave out the ones who never settled. Academy graduates get compared without the players who didn't make it. Managers still in a job get analysed, and the ones who were sacked quietly drop out.
None of that automatically means collider bias; most of it is survivorship. It becomes collider bias when the filter depends on the very things you're comparing. If a club signs players who either score a lot or create a lot, its squad will show scorers who don't create and creators who don't score, whatever the link in the wider game.
This is also why adding more variables to a model doesn't automatically make it better. "Controlling for" a collider, comparing players only with others who got into the academy, or teams only with others who finished in the same half, does exactly what looking only at the picked players does. Control for the wrong thing and you can introduce the bias you were trying to avoid. Do football stats mislead? covers the opposite trap, the hidden cause you should control for. Telling the two apart means asking which way the arrows run: does the third thing cause both of yours, or do both of yours cause it?
One analyst looks at the 200 academy players and concludes technique and athleticism are negatively related. Another looks at all 5,000 and finds nothing. Both correlations are calculated correctly. The difference is that one analyst knows what the selection did to the data.
So before you decide what makes a great player, ask how the players you're studying ended up in your dataset in the first place. How your data was collected and filtered matters every bit as much as which model you run on it.
Limitations
- The players are made up. Real academies weigh far more than two things, and real technique and athleticism may well be linked. The simulation shows what selection does to whatever link there is, not what the real link is.
- Selection alone explains the tables, but that doesn't rule out real differences. Bottom-half sides might trade attack for defence a little; the data can't tell that apart from the selection's effect, because both push the same way.
- Goals, not chances. Goals scored and conceded include plenty of luck. Expected goals would measure attack and defence more cleanly, but the Scottish results files don't have them.
- The selection-alone check is rough. It treats a season as ten teams with a fixed link between attack and defence, and splits the table on attack plus defence exactly. Real tables are decided by points, with draws and luck in who finishes where.
Reproduce the analysis
The first two parts need nothing but Python 3. For the third, save the Championship and Premiership results files (SC1 and SC0) from football-data.co.uk, which the download script fetches under the names the snippet expects, in the same folder. It takes about 20 seconds:
Show the Python99 lines, ready to copy and run.
# From Football Data Science by Bryan McGuire. Free to use with credit.
# https://www.footballdatascience.co.uk/learn/collider-bias
import csv
import random
import statistics as st
def r(pairs): # Pearson's correlation between the two numbers in each pair
return st.correlation([a for a, b in pairs], [b for a, b in pairs])
def intake(seed, link=0.0, n=5000): # (technique, athleticism) for n players, linked by `link`
rng = random.Random(seed)
players = []
for _ in range(n):
t = rng.gauss(0, 1)
players.append((t, link * t + (1 - link ** 2) ** 0.5 * rng.gauss(0, 1)))
return players
def top(players, places, weight=0.5): # the academy's pick: best on a mix of the two
return sorted(players, key=lambda p: weight * p[0] + (1 - weight) * p[1], reverse=True)[:places]
# 1. One academy
players = intake(seed=16)
picked = top(players, 200)
print(f"All 5,000 players: r = {r(players):+.3f}")
print(f"The 200 picked: r = {r(picked):+.3f}")
# 2. Other academies, averaged over 60 intakes so one lucky draw doesn't decide it
def average(choose, link=0.0):
return st.mean(r(choose(intake(seed, link))) for seed in range(1, 61))
def both(players, places=200): # the same minimum in each, set so about `places` pass
share = (places / len(players)) ** 0.5
cut_t = sorted(t for t, a in players)[int(len(players) * (1 - share))]
cut_a = sorted(a for t, a in players)[int(len(players) * (1 - share))]
return [(t, a) for t, a in players if t >= cut_t and a >= cut_a]
print("\nAverage r among those picked, 60 intakes")
for places in (2500, 1000, 200, 50):
print(f" top {places:>4} on the combined score {average(lambda p: top(p, places)):+.2f}")
print(f" top 200 on technique only {average(lambda p: top(p, 200, weight=1)):+.2f}")
print(f" a minimum in both (about 200) {average(both):+.2f}")
print(f" top 200, weighted 70/30 to technique {average(lambda p: top(p, 200, weight=0.7)):+.2f}")
print(f" top 12 of only 300 rated {average(lambda p: top(p[:300], 12)):+.2f}")
for link in (0.3, 0.5):
print(f" top 200, true link {link:+.1f} {average(lambda p: top(p, 200), link):+.2f}")
# 3. Real tables: attack and defence, each measured against its own season
def team_seasons(div):
rows = []
for y in range(2000, 2026):
season = {}
with open(f"{div}_{y % 100:02d}{(y + 1) % 100:02d}.csv", encoding="latin-1") as f:
for m in csv.DictReader(f):
if not m.get("FTHG"):
continue
hg, ag = int(m["FTHG"]), int(m["FTAG"])
for team, scored, let_in in ((m["HomeTeam"], hg, ag), (m["AwayTeam"], ag, hg)):
team = {"Raith Rvs": "Raith"}.get(team, team) # one club, two spellings in 2004/05
s = season.setdefault(team, [0, 0, 0, 0])
s[0] += 1; s[1] += scored; s[2] += let_in
s[3] += 3 if scored > let_in else 1 if scored == let_in else 0
teams = [(name, gf / n, ga / n, pts / n) for name, (n, gf, ga, pts) in season.items()]
mf, sf = st.mean(t[1] for t in teams), st.pstdev(t[1] for t in teams)
ma, sa = st.mean(t[2] for t in teams), st.pstdev(t[2] for t in teams)
teams.sort(key=lambda t: -t[3])
for place, (name, gf, ga, ppg) in enumerate(teams, 1):
rows.append({"team": name, "place": place, "size": len(teams),
"attack": (gf - mf) / sf, "defence": (ma - ga) / sa}) # defence: goals kept out
return rows
def pairs(rows):
return [(x["attack"], x["defence"]) for x in rows]
def selection_alone(link, teams=10, seasons=26, runs=1000, seed=1):
"""r in each half of the table if attack and defence were linked by `link` and nothing else happened."""
rng, out = random.Random(seed), {"top": [], "bottom": []}
for _ in range(runs):
halves = {"top": [], "bottom": []}
for _ in range(seasons):
table = sorted(intake(rng.random(), link, teams), key=lambda p: p[0] + p[1], reverse=True)
halves["top"] += table[:teams // 2]
halves["bottom"] += table[teams // 2:]
for h in out:
out[h].append(r(halves[h]))
return {h: (st.mean(v), sorted(v)[25], sorted(v)[974]) for h, v in out.items()}
print("\nChampionship 2000/01-2025/26: attack v defence")
ch = team_seasons("SC1")
link = r(pairs(ch))
print(f" all {len(ch)} team-seasons r = {link:+.2f}")
alone = selection_alone(link)
for half, rows in (("top", [x for x in ch if x["place"] <= 5]), ("bottom", [x for x in ch if x["place"] > 5])):
m, lo, hi = alone[half]
print(f" {half:>6} five ({len(rows)}) r = {r(pairs(rows)):+.2f} selection alone: {m:+.2f} ({lo:+.2f} to {hi:+.2f})")
print("\nPremiership 2000/01-2025/26")
pr = team_seasons("SC0")
rest = [x for x in pr if x["team"] not in ("Celtic", "Rangers")]
print(f" all {len(pr)} team-seasons r = {r(pairs(pr)):+.2f}")
print(f" without Celtic and Rangers ({len(rest)}) r = {r(pairs(rest)):+.2f}")
print(f" bottom six ({sum(x['place'] > 6 for x in pr)}) r = {r(pairs([x for x in pr if x['place'] > 6])):+.2f}")
Further reading
- Berkson's paradox, Wikipedia. The selection version of collider bias, with Berkson's original hospital example and others.
- Collider (statistics), Wikipedia. What a collider is in a cause-and-effect diagram, and why conditioning on one creates a link between its causes.
- Julia Rohrer, "Thinking Clearly About Correlations and Causation: Graphical Causal Models for Observational Data", Advances in Methods and Practices in Psychological Science (2018). A readable guide to confounders and colliders, and to which variables to control for.