First, What Are We Actually Trying to Build?

If you’ve ever built a team in FIFA, you know the problem. You find a few players you want, look at the total price, and start making compromises. Maybe the cheaper defender is fine. Maybe you don’t need that third expensive forward.

We wanted to do that systematically. Our budget was €350M, roughly the seventh most expensive starting eleven in the Premier League at the time. The job: pick 11 players from Europe’s top five leagues and get the highest combined performance score we could afford.

A small spoiler

The team we picked finished fifth in simulation. A much cheaper team looked even better on a “score per euro” metric. That team finished last. We’ll get to that.

Before the Math, We Need Some Players

We started with 2022–23 stats for roughly 2,500 players across the Premier League, Bundesliga, Ligue 1, Serie A, and La Liga. We joined those to Transfermarkt market values and scraped goalkeeper stats from FBRef, since our main dataset didn’t have them.

2,500+Players in the source data
10 games + 700 minutesMinimum season participation
~1,000Players eligible for the model

We also needed players who actually played. Someone can look very good over three appearances. That’s less useful when you’re asking them to start for an entire season. So we kept players with at least 10 games and 700 minutes, leaving about 1,000 candidates.

Okay, How Do We Pick the Team?

You could sort everyone by their performance score and take the top 11. Easy enough. Except you might end up with 11 forwards and no money left. We need the score and the rules to work together.

The formula below just says: add up the scores of the players we select, and make that total as large as possible. Iᵢ is player i’s score. xᵢ is 1 if we pick them and 0 if we don’t. An unselected player contributes nothing.

Maximize the team’s combined performance
max⁡x  ∑i∈SIixi\max_{x}\;\sum_{i\in S} I_i x_i

Iᵢ Player scorexᵢ 1 if selected, 0 otherwise

And here’s what a valid lineup has to look like:

Budget
Spend no more than €350M.
Formation
1 goalkeeper, 4 defenders, 3 midfielders, 3 forwards.
Average age
Between 24 and 26 years.
Experience
At least 3 players aged 27+ and 3 aged 24 or younger.
Position eligibility
Each player fills one eligible position, even if they can play several.

Those rules are the constraints. We give the scores and constraints to Gurobi, which solves the integer program. It finds the best combination under our definition of “best.” That last part matters a lot.

Full integer-program formulation

The model assigns players to positions. Let xip indicate that player i fills position p, Pi be their eligible positions, and xi indicate selection. Position quotas kp are 1, 4, 3, and 3 for goalkeeper, defense, midfield, and attack.

max⁡∑i∈SIixisubject toxi=∑p∈Pixip∀i∈S∑p∈Pixip≤1∀i∈S∑i∈Ppxip=kp∀p∑i∈Smixi≤350,000,000264≤∑i∈Saixi≤286∑i:ai≥27xi≥3,∑i:ai≤24xi≥3xip∈{0,1}\begin{aligned} \max\quad & \sum_{i\in S} I_i x_i \\ \text{subject to}\quad & x_i = \sum_{p\in P_i}x_{ip} && \forall i\in S \\ & \sum_{p\in P_i}x_{ip}\leq 1 && \forall i\in S \\ & \sum_{i\in P_p}x_{ip}=k_p && \forall p \\ & \sum_{i\in S}m_i x_i\leq 350{,}000{,}000 \\ & 264\leq\sum_{i\in S}a_i x_i\leq 286 \\ & \sum_{i:a_i\geq27}x_i\geq3,\qquad\sum_{i:a_i\leq24}x_i\geq3 \\ & x_{ip}\in\{0,1\} \end{aligned}

mᵢ is market value and aᵢ is age. The total-age limits, 264 and 286, are 11 × 24 and 11 × 26. All position sums include eligible player-position pairs only.

What Makes a Player “Good” Here?

Gurobi doesn’t know who Lewandowski is. It needs a number. We adapted a player-performance index from McHale, Scarf, and Folker (2012), averaged the inputs over a season, and left out a predictive component that would have required simulating game events for every player.

That left us with five sub-scores. Playing time matters, scoring and assisting matter, and keeping a clean sheet matters. But not equally for every position. A goalkeeper gets more credit for a clean sheet than a striker does, which seems fair.

What goes into the player index
SignalContributionCoefficient
I₁ · Team points contributionMinutes played × team points.0.375
I₂ · Minutes contributionMinutes played, independent of team points.0.125
I₃ · GoalsGoals scored.0.125
I₄ · AssistsGoals assisted.0.0625
I₅ · Clean sheetsGames without conceding; weighted by position.0.0625
Iplayer=100(0.375I1+0.125I2+0.125I3+0.0625I4+0.0625I5)I_{\mathrm{player}}=100\left(0.375I_1+0.125I_2+0.125I_3+0.0625I_4+0.0625I_5\right)

We retained the coefficients from the published index. They were not fitted to our dataset.

The five sub-score equations

Let minp be average minutes played per game and pointsavg the team’s average points per game. The denominator 90 × 11 is the total player-minutes in a full match.

Team pointsI₁ = minp / (90 × 11) × pointsavg
ParticipationI₂ = minp / (90 × 11) × 1.34
GoalsI₃ = goals × 1.039
AssistsI₄ = assists × 1.039
Clean sheetsI₅ = clean-sheets × weightp

Clean-sheet weights are 0.585 for goalkeepers, 0.365 for defenders, 0.15 for midfielders, and 0.071 for forwards. Goal-related inputs follow the report’s season-averaged adaptation.

The retained coefficients sum to 0.75 after removing the predictive component. We preserve the report’s formula; its scale is not a normalized percentage or a score out of 100.

Reference: McHale, Scarf & Folker, “On the Development of a Soccer Player Performance Rating System for the English Premier League,” Interfaces 42 (2012), 339–351. See the original report for its bibliography.

So, Who Did We Get?

Here’s the starting eleven. Total cost: €347M. Average age: about 25.9. Both within the limits, with €3M left over.

Selected players in the project's 4-3-3 formation
The team, arranged in a 4-3-3. A reminder: the model knows “defender,” not “left-back.”
From the report’s solver output, Appendix Figure 2
PositionPlayerAgeValue
GoalkeeperMarc-André ter Stegen30€35M
DefenderDanilo31€15M
DefenderGiovanni Di Lorenzo29€25M
DefenderJules Koundé24€60M
DefenderAlejandro Balde19€50M
MidfielderVincenzo Grifo29€12M
MidfielderMattia Zaccagni27€30M
MidfielderTakefusa Kubo21€25M
ForwardRobert Lewandowski34€30M
ForwardAnsu Fati20€35M
ForwardFolarin Balogun21€30M
Total market value€347M

The appendix lists Balde at €50M; one paragraph in the report says €60M. This table follows the solver output, which reconciles to the €347M total.

Original solver output showing selected players, assigned positions, ages, scores, and market values
The less glamorous version of the team photo. Same eleven, with the numbers attached.

What If We Spent Less?

€350M was our main budget, but there was no reason to stop there. What does another million buy? And how far can we cut the budget before the team gets noticeably worse?

  1. Solve the model and record the lineup’s cost.
  2. Set the next budget to that cost minus €1,000.
  3. Replace the previous budget constraint and solve again.
  4. Repeat until no feasible lineup exists.

We started without a budget constraint. The best team cost €710M. Any budget above that gives the same answer. Then we lowered the cap to the selected team’s cost minus €1,000 and solved again. All market values were multiples of €1,000, so we weren’t skipping a possible spending level. Repeating this gave us 347 budget ranges.

Optimal team index increases with budget, with smaller improvements at higher budgets
The first few increases in budget buy a lot. By the expensive end, you’re paying more for smaller improvements.
Budget-sweep pseudocode
solve without a budget limit
while the model is feasible:
    record selected team, score, and cost
    next_budget = selected_team_cost - 1,000
    replace the budget constraint with cost ≤ next_budget
    solve again

A small implementation detail helped here: replacing the old budget constraint instead of adding another one each time reduced runtime from about a minute to 20 seconds. Same problem, less unnecessary work. We called this extension “cutting planes” in the report; the loop above is the procedure we used.

The ratio of optimal index to cost is highest at the low-budget end
Now divide the score by the price. Suddenly the cheap teams look very appealing.

Time to Let EA FC Decide

We put our €347M lineup into Luton Town in EA FC 24 manager career mode and simulated the next season. We only controlled the starting lineup. If someone got injured, the existing Luton substitutes had to cover. No quietly buying a better bench halfway through.

5th / 20Simulated Premier League finish
13Clean sheets
€347MSelected lineup cost
EA FC 24 league table showing the simulated team finishing fifth
Fifth place. We’ll take it.
Takefusa Kubo

Kubo played every match and led the team in goals and assists. Lewandowski finished with 11 goals and 5 assists, down from his previous season. So the biggest name wasn’t the biggest contributor.

About that €13.2M team

When we maximized score per euro across the budget ranges, the winner cost €13.2M. Great value, apparently. In simulation it finished bottom and got the manager fired. The ratio liked how little we spent. The league table was less impressed.

Obviously EA FC is not real life. One simulated season doesn’t validate a model, and we didn’t test the index against an independent set of real match results. But the contrast is useful: a team can look efficient on paper and still not be good enough to compete.

What the Model Doesn’t Know

Use actual roles, not just position groups

A forward who is also eligible at midfield can be selected there because of scoring stats, even if their real midfield contribution is weak. Center-back, full-back, winger, and striker need more specific treatment.

Account for league strength

The same statistics can mean different things in different competitions. League adjustment is a proposed improvement, not part of the implemented player index.

Model combinations, not just individuals

Team chemistry, passing interactions, coaching, and adaptation to transfers are missing. Summing good individual scores does not guarantee a good team.

Broaden the scouting pool

Lower divisions and leagues outside Europe’s top five may contain undervalued players that this dataset cannot discover.

The first thing I’d fix is the position data. If we tell the solver a player can play midfield, it will believe us. It won’t notice that we’ve put an attacker there because their goals make the score look better. Better inputs before a more complicated model.

Project notes

Python, Gurobi, Pandas, and NumPy for the model and data work. EA FC 24 for the simulation.

Built with Hadas Barabash, Misha Melnyk, David Lascu, Sebastian Valencia, and Shamar Phillips.

ASCII photo credits