Skip to content

Who plays how much? Squad minutes as a linear program

A season has a fixed number of minutes to share out, every player has limits, and the club has rules. Linear programming finds the best split, explains why the answer always sits at a corner, and says what each rule costs, including where a youth-minutes rule should take its minutes from.

Intermediate Part 4 of Decision Science Through Football

New to the notation? The symbols explained

Contents

The football question

A season has 38 matches of 90 minutes, and a manager has to share them out. Some players are better than others, some can't play every game, and the club has its own rules, such as "our young players get at least 4,000 minutes between them." Who should play how much? And if the young players have to play, whose minutes should they take?

The concept

Linear programming solves problems where the objective and every rule are straight-line sums: so many goals per minute, so many minutes in total, at most so many for this player. The decision variables can take any value in between, such as 1,380 minutes rather than "picked" or "not picked". (Yes-or-no choices come later in this series, with integer programming.)

It was developed in the 1930s and 1940s for planning production and supplies, and George Dantzig's simplex method of 1947 made large problems solvable. When Dantzig showed his method to John von Neumann, von Neumann saw at once that it was the same mathematics as his work on zero-sum games, the minimax behind the penalty game.

The key fact about linear programs fits in one picture.

Two players first

An established striker is worth 0.12 goal difference per 90 minutes and a young striker 0.05 (made-up numbers). The rules:

  • The established striker can manage at most 3,000 minutes, and the young one 3,420, every minute of the season.
  • There are 5,000 minutes to share between them.
  • The young one must play at least 1,500.

Each rule is a straight line, and together they fence off the feasible region: every split that obeys them all.

The shaded area is every split the rules allow. The numbers are the goal difference at each corner. The best, in gold, is the established striker playing all 3,000 minutes he can and the young one the other 2,000.

Because the objective is a straight-line sum too, moving across the region in any direction changes the goal difference at a steady rate. So you can always do at least as well by sliding to an edge, and then along the edge to a corner. The best answer to a linear program is always at a corner of the feasible region. Here there are only five corners to check, and the best is 3,000 minutes for the established striker and 2,000 for the young one: a goal difference of +5.1.

That's why linear programs can be solved quickly even when there are thousands of variables: a method only has to search the corners, and the simplex method moves from corner to better corner until none is better. Part 5 of this series works through it by hand.

The full squad

Here's a made-up squad of 14 outfield players for a 4-4-2. Each has a goal difference per 90 and a limit on the minutes he can manage:

Player Per 90 Most minutes
Centre-back 1 0.20 3,200
Centre-back 2 0.12 3,000
Left-back 0.10 3,100
Right-back 0.08 3,000
Veteran defender 0.06 2,000
Young defender 0.02 2,500
Midfielder 1 0.25 3,300
Midfielder 2 0.15 3,200
Midfielder 3 0.10 3,000
Midfielder 4 0.06 2,800
Young midfielder 0.05 2,500
Striker 1 0.30 3,000
Striker 2 0.14 3,000
Young striker 0.08 2,500

The linear program is

$$\begin{aligned} \text{maximise } &\sum \text{per 90} \times \frac{\text{minutes}}{90} \\ \text{so that } &\text{every position is filled} \\ &\text{nobody passes his limit} \end{aligned}$$

In plain football

  • The sum adds up every player's goal difference over the season: his value per 90 times the number of 90s he plays.
  • Every position is filled: 4 defenders, 4 midfielders and 2 strikers on the pitch for all 3,420 minutes of the season.
  • Nobody passes his limit: each player's minutes stay under the most he can manage.
  • The young players' rule is one more line: their minutes add up to at least 4,000.

With 14 variables, nobody draws the region. A solver searches its corners instead: scipy's linprog, in the snippet below.

Without the youth rule, the answer is what a manager would expect: the best player in each position plays as much as he can, then the next best, until the position is filled. The young players get only the minutes nobody better can cover, 2,220 between them. That breaks the club's 4,000 rule, so the rule is binding, as in Part 2.

With the rule, the young players play exactly 4,000 minutes. The solver finds the cheapest place to find the extra 1,780:

Player Without the rule With it
Young midfielder 1,380 2,500
Midfielder 4 2,800 1,680
Young defender 0 660
Veteran defender 1,380 720
Young striker 840 840

The extra minutes go first to the young midfielder, who's only a little worse than the player he replaces (0.05 against 0.06 per 90), then to the young defender, in place of the veteran (0.02 against 0.06). The young striker gets nothing extra, even though he's the best-rated of the three young players. Every minute he played would come from Striker 2, and the gap there (0.08 against 0.14) is the biggest. What matters isn't how good a young player is. It's how much worse he is than the player he'd replace.

The rule costs 0.42 goal difference over the season, out of +54.02 from the players without it.

The price of the rule

As in Part 2, the solver also reports a shadow price: what one more unit of the rule would cost. Here it's 0.44 goal difference for every extra 1,000 young minutes. That's the young defender's gap of 0.04 per 90 against the veteran, times 1,000 รท 90.

Shadow prices hold only for small changes. Re-solving with a 5,000-minute rule costs 0.51 more, not 0.44, because after 720 more minutes the veteran has none left to give up, and the young defender starts taking the right-back's minutes instead (a gap of 0.06). The price rises as the rule tightens, the same pattern as the chairman's goals rule in Part 2.

Why it matters

Squad rotation, youth development, travel plans, wage budgets: most club planning shares out something limited under rules, and much of it is linear, or close enough. A linear program finds the best split and, just as usefully, says what each rule costs and where the cost falls. A club that wants its young players to play can then decide where those minutes should come from, and how much it's paying, instead of finding out in May. Part 5 opens up the solver: the simplex method, by hand.

Limitations

  • The squad is made up, and real players' value per 90 is hard to measure and changes with form and partners.
  • Straight lines are an approximation. A player's value doesn't really stay the same from his first minute to his 3,000th; fatigue bends the line.
  • Minutes aren't freely divisible. Real minutes come in matches and substitutions, and a player rested for one game is a yes-or-no choice. That's integer programming, later in the series.
  • Young players' development isn't in the objective. The rule exists because the club values something the season's goal difference doesn't count. The shadow price says what that value has to be worth for the rule to pay.

Try it yourself

Before running the code, guess: if the young striker were worth 0.12 per 90 instead of 0.08, would he get any of the extra minutes? Then change his number and see. (Hint: compare him with the player he'd replace.)

Reproduce the analysis

This needs scipy (pip install scipy). It finds the two-player corners by working out where every pair of rule lines cross, then solves the full squad with and without the rule. All the numbers are made up.

Show the Python60 lines, ready to copy and run.
from itertools import combinations

from scipy.optimize import linprog

# Two players first (made up): an established striker (x minutes) and a young one (y minutes), worth 0.12 and 0.05
# goal difference per 90. Each rule is a straight line a*x + b*y <= c.
rules = [(1, 0, 3000),       # the established striker can manage 3,000 minutes
         (0, 1, 3420),       # the young one, every minute of a 38-game season
         (1, 1, 5000),       # 5,000 minutes between them
         (0, -1, -1500),     # the young one plays at least 1,500 (written as -y <= -1500)
         (-1, 0, 0)]         # nobody plays negative minutes
value = lambda x, y: (0.12 * x + 0.05 * y) / 90

corners = set()
for (a1, b1, c1), (a2, b2, c2) in combinations(rules, 2):   # a corner is where two rule lines cross...
    det = a1 * b2 - a2 * b1
    if det:
        x, y = (c1 * b2 - c2 * b1) / det, (a1 * c2 - a2 * c1) / det
        if all(a * x + b * y <= c + 1e-9 for a, b, c in rules):   # ...and every other rule still holds
            corners.add((round(x), round(y)))
for x, y in sorted(corners):
    print(f"Corner: {x:5} and {y:5} minutes, goal difference {value(x, y):+.2f}")
print("Best corner:", max(corners, key=lambda c: value(*c)))

# The squad (made up): position, goal difference per 90, most minutes he can manage, young or not.
squad = {"Centre-back 1": ("DEF", 0.20, 3200, False), "Centre-back 2": ("DEF", 0.12, 3000, False),
         "Left-back": ("DEF", 0.10, 3100, False), "Right-back": ("DEF", 0.08, 3000, False),
         "Veteran defender": ("DEF", 0.06, 2000, False), "Young defender": ("DEF", 0.02, 2500, True),
         "Midfielder 1": ("MID", 0.25, 3300, False), "Midfielder 2": ("MID", 0.15, 3200, False),
         "Midfielder 3": ("MID", 0.10, 3000, False), "Midfielder 4": ("MID", 0.06, 2800, False),
         "Young midfielder": ("MID", 0.05, 2500, True),
         "Striker 1": ("FWD", 0.30, 3000, False), "Striker 2": ("FWD", 0.14, 3000, False),
         "Young striker": ("FWD", 0.08, 2500, True)}
needed = {"DEF": 4 * 3420, "MID": 4 * 3420, "FWD": 2 * 3420}   # 4-4-2 for 38 games of 90 minutes
names = list(squad)


def solve(young_minutes):
    """Best minutes for each player, with at least `young_minutes` for the young players between them."""
    c = [-squad[n][1] / 90 for n in names]                         # linprog minimises, so flip the sign
    a_eq = [[1 if squad[n][0] == pos else 0 for n in names] for pos in needed]
    a_ub = [[-1 if squad[n][3] else 0 for n in names]]             # -(young minutes) <= -young_minutes
    res = linprog(c, A_ub=a_ub, b_ub=[-young_minutes], A_eq=a_eq, b_eq=list(needed.values()),
                  bounds=[(0, squad[n][2]) for n in names], method="highs")
    return res, -res.fun


free, gd_free = solve(0)
ruled, gd_rule = solve(4000)
print()
print("Minutes, without and with the rule of 4,000 for the young players:")
for i, n in enumerate(names):
    print(f"  {n:<17}{free.x[i]:6.0f}{ruled.x[i]:7.0f}")
young = lambda res: sum(res.x[i] for i, n in enumerate(names) if squad[n][3])
print(f"Young players' minutes: {young(free):.0f} without the rule, {young(ruled):.0f} with it")
print(f"Goal difference from the players: {gd_free:+.2f} without the rule, {gd_rule:+.2f} with it; "
      f"the rule costs {gd_free - gd_rule:.2f}")
price = -ruled.ineqlin.marginals[0]   # the solver's shadow price for the rule, per minute
print(f"Shadow price: {price * 1000:.2f} goal difference for every 1,000 young minutes required")
print(f"Check, re-solving with 1,000 more: {gd_rule - solve(5000)[1]:.2f}")

It prints:

Show the Text26 lines, ready to copy and run.
Corner:     0 and  1500 minutes, goal difference +0.83
Corner:     0 and  3420 minutes, goal difference +1.90
Corner:  1580 and  3420 minutes, goal difference +4.01
Corner:  3000 and  1500 minutes, goal difference +4.83
Corner:  3000 and  2000 minutes, goal difference +5.11
Best corner: (3000, 2000)

Minutes, without and with the rule of 4,000 for the young players:
  Centre-back 1      3200   3200
  Centre-back 2      3000   3000
  Left-back          3100   3100
  Right-back         3000   3000
  Veteran defender   1380    720
  Young defender        0    660
  Midfielder 1       3300   3300
  Midfielder 2       3200   3200
  Midfielder 3       3000   3000
  Midfielder 4       2800   1680
  Young midfielder   1380   2500
  Striker 1          3000   3000
  Striker 2          3000   3000
  Young striker       840    840
Young players' minutes: 2220 without the rule, 4000 with it
Goal difference from the players: +54.02 without the rule, +53.60 with it; the rule costs 0.42
Shadow price: 0.44 goal difference for every 1,000 young minutes required
Check, re-solving with 1,000 more: 0.51

linprog minimises, so the snippet hands it minus the goal difference and flips the signs back. The shadow price comes from the solver's marginals (in recent versions of scipy); the last line checks it by solving again.

Further reading

Get new pieces by email

An email when something new is published, and the occasional update. Unsubscribe in one click. How your email is used.