Seven games in 21 days: who plays when?
A congested run of fixtures and a rule that nobody starts three in a row. Picking the best team every game rests the stars for the cup semi-final. Treating the whole run as one scheduling problem rests them for the right games instead, and shows why they can't play every big one.
Advanced Part 9 of Decision Science Through Football
New to the notation? The symbols explained
Contents
The football question
Seven games in 21 days: league games, a cup semi-final and a European tie. The sports science staff have one rule: nobody starts three games in a row. Who should play when?
The instinct is to pick the strongest team for the next game, and worry about the one after when it comes. This part shows what that costs, and what planning the whole run at once looks like.
The concept
A scheduling problem is about what happens when: here, which players start which games, over a stretch of time, with rules that link one game to the next. It's Part 6's integer programming with a calendar attached: every player in every game is a yes or a no. And like Part 4's minutes, it's about sharing out something limited, the players' freshness.
The rule linking the games is what makes it hard. Starting a player today uses up some of his next few days, so a choice in game 1 changes what's possible in game 3.
A football example
Seven games, each with a made-up weight for how much it matters:
| Game | Competition | Weight |
|---|---|---|
| 1 | League | 1.0 |
| 2 | League | 1.0 |
| 3 | Cup semi-final | 2.0 |
| 4 | League | 1.0 |
| 5 | European tie | 1.5 |
| 6 | League, against the bottom side | 0.7 |
| 7 | League | 1.0 |
The squad has six defenders, six midfielders and three strikers, each with a made-up value in goal difference per game, from the best defender's 0.20 down to the sixth's 0.03. The team plays 4-4-2 every game.
The aim is to make the weighted total as big as possible: for every game, the value of the players who start it, times how much that game matters. The rest rule says that in any three games in a row, each player starts at most two:
$$\begin{aligned} \text{maximise } &\sum \text{weight} \times \text{starters' value} \\ \text{so that } &\text{nobody starts 3 in a row} \end{aligned}$$
In plain football
- The sum runs over all seven games.
- The weight says how much a game matters: the semi-final counts twice as much as a league game.
- The starters' value adds up the ten outfield starters' value for that game (the keeper isn't part of the rota). It's a made-up rating, not real goal difference.
- Nobody starts 3 in a row: in any run of three games, each player sits out at least one. Over seven games, nobody can start more than five.
Best team every game
Pick the strongest available team for each game in turn. The stars start games 1 and 2, then the rest rule stops them, and game 3 is the cup semi-final. They come back for games 4 and 5, rest again for game 6, and return for game 7. The weighted total is 9.64.
The plan isn't wrong about any single game. It's wrong because it never looks ahead.
Planning the whole run
The optimiser treats all seven games as one problem, with a yes-or-no for every player in every game, and solves it as an integer program with scipy's milp. Its weighted total is 10.38, a gain of 0.74, and its rota looks like this:
The stars rest for game 2, so they're fresh for the semi-final, and rest again for the European tie.
Why not the semi and Europe? Because the rule won't allow it. With a game every three days, the semi and the European tie are two games apart. A star who plays both needs three rests in the seven games, such as games 2, 4 and 6, instead of two. One of the two big games has to give, and with the semi worth 2.0 and Europe 1.5, the optimiser keeps the semi.
That's the real lesson. Picking the best team each game gets the order wrong, and no plan can have everything. The optimiser finds the best set of compromises, and it shows exactly which ones it made.
Checking the answer
There are far too many rotas to try them all: each game has hundreds of ways to pick four defenders, four midfielders and two strikers, seven games in a row. But the strikers alone can be checked by brute force: three ways to pick two of three, for seven games, is 3⁷ = 2,187 rotas. The best of them scores exactly what the optimiser gave the strikers, 2.85.
How congested is real football?
Our data can't say who started, but it does have dates. Over 26 Scottish Premiership seasons, 12.9% of a side's league games came three days or fewer after its last one. That's league games only. Cup ties and European games would push it higher for the clubs in them. Whether short rest changes results is a separate question: the midweek myth found midweek games end just like weekend ones.
Why it matters
Squads, minutes and fixtures are linked over time, so a choice now changes what's possible later. Planning one game at a time can drift into resting the stars for the biggest game. A scheduling model can't know a player's legs better than the medical staff. But given their rule and the manager's view of which games matter, it finds the best rota and shows the trade-offs it had to make. Part 10 turns to uncertainty: choosing well when you don't know what will happen, starting with shoot, pass or cross.
Limitations
- Everything about the squad is made up, including the players' values and the weights. Real values come from performance data and the medical staff's view.
- One rest rule is a simplification. Real load management tracks minutes, distance and recovery, not just starts, and substitutions change the picture.
- Values don't change. Real players get tired or find form during a run; here each player is worth the same in every game he starts.
- The weights are opinions. Change them and the plan changes, which is the point: the model makes the trade-offs visible, it doesn't decide which games matter.
Try it yourself
Make the European tie matter more than the semi: change its weight from 1.5 to 2.5. Before running the code, guess: which games will the stars rest for now?
Reproduce the analysis
This needs scipy (pip install scipy). The rota part needs nothing else. For the congestion count, download the Scottish Premiership files (SC0) for 2000/01 to 2025/26 from football-data.co.uk, saved as SC0_0001.csv and so on; they aren't rehosted on this site. Then:
Show the Python99 lines, ready to copy and run.
import csv
import glob
from datetime import datetime
from itertools import combinations, product
import numpy as np
from scipy.optimize import Bounds, LinearConstraint, milp
# Made up: seven games in 21 days, each with a weight for how much it matters.
games = ["League", "League", "Cup semi", "League", "Europe", "League v bottom", "League"]
weight = [1.0, 1.0, 2.0, 1.0, 1.5, 0.7, 1.0]
# Made up: a squad by position, with each player's value in goal difference per game; 4-4-2 every game.
squad = {"DEF": [("D1", 0.20), ("D2", 0.15), ("D3", 0.12), ("D4", 0.10), ("D5", 0.06), ("D6", 0.03)],
"MID": [("M1", 0.25), ("M2", 0.18), ("M3", 0.12), ("M4", 0.08), ("M5", 0.04), ("M6", 0.02)],
"FWD": [("F1", 0.30), ("F2", 0.15), ("F3", 0.06)]}
needed = {"DEF": 4, "MID": 4, "FWD": 2}
REST = 3 # rest rule: nobody starts all of any 3 games in a row
G = len(games)
players = [(pos, name, v) for pos, group in squad.items() for name, v in group]
def value(plan):
"""Weighted goal difference across the run: each game's weight times the value of the players who start it."""
return sum(weight[g] * v for g in range(G) for (pos, name, v) in players if name in plan[g])
def rested_ok(starts):
return all(sum(starts[g:g + REST]) < REST for g in range(G - REST + 1))
# 1. The best team every game, until the rest rule stops someone.
greedy, history = [], {name: [] for _, name, _ in players}
for g in range(G):
pick = set()
for pos, group in squad.items():
free = [n for n, v in group if sum(history[n][-(REST - 1):]) < REST - 1] # not started the last two
pick |= set(free[:needed[pos]])
for _, name, _ in players:
history[name].append(int(name in pick))
greedy.append(pick)
# 2. The optimiser: one yes-or-no for each player in each game, solved as an integer program.
n = len(players) * G
idx = lambda p, g: p * G + g
c = np.zeros(n)
for p, (_, _, v) in enumerate(players):
for g in range(G):
c[idx(p, g)] = -weight[g] * v # milp minimises, so flip the sign
rows, lo, hi = [], [], []
for g in range(G): # each position filled in each game
for pos in squad:
row = np.zeros(n)
for p, (ppos, _, _) in enumerate(players):
row[idx(p, g)] = ppos == pos
rows.append(row); lo.append(needed[pos]); hi.append(needed[pos])
for p in range(len(players)): # the rest rule, for every player and every window
for g in range(G - REST + 1):
row = np.zeros(n)
row[[idx(p, k) for k in range(g, g + REST)]] = 1
rows.append(row); lo.append(0); hi.append(REST - 1)
res = milp(c, integrality=np.ones(n), bounds=Bounds(0, 1), constraints=LinearConstraint(np.array(rows), lo, hi))
best = [{name for p, (_, name, _) in enumerate(players) if res.x[idx(p, g)] > 0.5} for g in range(G)]
no_rule = sum(weight[g] for g in range(G)) * sum(sum(v for _, v in group[:needed[pos]]) for pos, group in squad.items())
print(f"Without a rest rule (impossible): {no_rule:.2f}")
print(f"Best team every game until rested: {value(greedy):.2f}")
print(f"Optimiser: {value(best):.2f}")
print("\nWho the optimiser rests (stars = the best player in each position):")
for name in ["D1", "M1", "F1"]:
print(f" {name} rested for: {', '.join(games[g] for g in range(G) if name not in best[g])}")
for name in ["D1", "M1", "F1"]:
print(f" Greedy rests {name} for: {', '.join(games[g] for g in range(G) if name not in greedy[g])}")
# A check on the strikers alone, where every rota can be tried: 3 ways to pick 2 of 3, for 7 games.
fwd = squad["FWD"]
options = [set(c) for c in combinations([n for n, _ in fwd], 2)]
brute = max((rota for rota in product(options, repeat=G)
if all(rested_ok([int(name in rota[g]) for g in range(G)]) for name, _ in fwd)),
key=lambda rota: sum(weight[g] * v for g in range(G) for name, v in fwd if name in rota[g]))
fwd_value = lambda rota: sum(weight[g] * v for g in range(G) for name, v in fwd if name in rota[g])
print(f"\nStrikers only: {3 ** G:,} rotas tried, best {fwd_value(brute):.2f}; "
f"the optimiser's strikers {fwd_value([b & {'F1', 'F2', 'F3'} for b in best]):.2f}")
# Real: how often a Premiership side's league game came three days or fewer after its last one.
# Needs the SC0 files from football-data.co.uk for 2000/01 to 2025/26 (see above).
when = lambda d: datetime.strptime(d, "%d/%m/%Y" if len(d) == 10 else "%d/%m/%y")
short = gaps = 0
for path in sorted(glob.glob("SC0_*.csv")):
dates = {}
with open(path, encoding="latin-1") as f:
for r in csv.DictReader(f):
if r.get("FTR") in ("H", "D", "A"):
for team in (r["HomeTeam"], r["AwayTeam"]):
dates.setdefault(team, []).append(when(r["Date"]))
for ds in dates.values():
ds.sort()
gaps += len(ds) - 1
short += sum((b - a).days <= 3 for a, b in zip(ds, ds[1:]))
print(f"\nLeague games three days or fewer after the last, 2000/01 to 2025/26: {short:,} of {gaps:,} ({short / gaps:.1%})")
It prints:
Show the Text15 lines, ready to copy and run.
Without a rest rule (impossible): 13.53
Best team every game until rested: 9.64
Optimiser: 10.38
Who the optimiser rests (stars = the best player in each position):
D1 rested for: League, Europe
M1 rested for: League, Europe
F1 rested for: League, Europe
Greedy rests D1 for: Cup semi, League v bottom
Greedy rests M1 for: Cup semi, League v bottom
Greedy rests F1 for: Cup semi, League v bottom
Strikers only: 2,187 rotas tried, best 2.85; the optimiser's strikers 2.85
League games three days or fewer after the last, 2000/01 to 2025/26: 1,473 of 11,446 (12.9%)
The optimiser's rota isn't the only best one: some players are interchangeable, so a solver may return a different rota with the same total.
Further reading
- Scheduling (computing), Wikipedia. Scheduling problems in general, from computers to timetables.
- Integer programming, Wikipedia. The method behind the optimiser, as in Part 6.
- scipy.optimize.milp, the SciPy documentation for the solver used here.