Choosing the perfect Pokémon team with Python and PuLP
I’m currently right in the endgame of writing up my PhD thesis, much stress, so I of course have started playing Pokémon again. A worrying amount of my thoughts have gone towards working out what is the best Pokémon team of six, so I’ll use some mathematics to put my mind at ease. (Yes, I know I have more important things to do…) In particular I’m looking for:
- the strongest team
- that has resistances to as many types as possible.
In this post I’ll use some mathematics, Python’s
requests
library to gain Pokémon
data, and pulp
to solve a linear program
which will give me my perfect team.
In the Pokémon games a player adventures around catching various species of Pokémon which they can then use in battles. Each player may only carry six Pokémon with them at a time, and so finding a strong all-round team of six is vital. Pokémon have six ‘stats’ which determine their stengths: Attack, Special Attack, Defence, Special Defence, Speed and HP. Here I’ll use the Pokémon’s total base ‘stats’ to measure their overall strength.
Another important concept in the games is Pokémon ‘types’. There are 18 types: Normal, Fire, Water, Electric, Grass, Ice, Fighting, Poison, Ground, Flying, Psychic, Bug, Rock, Ghost, Dragon, Dark, Steel and Fairy. A Pokémon’s type determines its weaknesses or resistances to different attack types according to the following table:
Nor | Fir | Wat | Ele | Gra | Ice | Fig | Poi | Gro | Fly | Psy | Bug | Roc | Gho | Dra | Dar | Ste | Fai | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Nor | 1 | 1 | 1 | 1 | 1 | 1 | 2 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 1 | 1 | 1 | 1 |
Fir | 1 | 0.5 | 2 | 1 | 0.5 | 0.5 | 1 | 1 | 2 | 1 | 1 | 0.5 | 2 | 1 | 1 | 1 | 0.5 | 0.5 |
Wat | 1 | 0.5 | 0.5 | 2 | 2 | 0.5 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0.5 | 1 |
Ele | 1 | 1 | 1 | 0.5 | 1 | 1 | 1 | 1 | 2 | 0.5 | 1 | 1 | 1 | 1 | 1 | 1 | 0.5 | 1 |
Gra | 1 | 2 | 0.5 | 0.5 | 0.5 | 2 | 1 | 2 | 0.5 | 2 | 1 | 2 | 1 | 1 | 1 | 1 | 1 | 1 |
Ice | 1 | 2 | 1 | 1 | 1 | 0.5 | 2 | 1 | 1 | 1 | 1 | 1 | 2 | 1 | 1 | 1 | 2 | 1 |
Fig | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 2 | 2 | 0.5 | 0.5 | 1 | 1 | 0.5 | 1 | 2 |
Poi | 1 | 1 | 1 | 1 | 0.5 | 1 | 0.5 | 0.5 | 2 | 1 | 2 | 0.5 | 1 | 1 | 1 | 1 | 1 | 0.5 |
Gro | 1 | 1 | 2 | 0 | 2 | 2 | 1 | 0.5 | 1 | 1 | 1 | 1 | 0.5 | 1 | 1 | 1 | 1 | 1 |
Fly | 1 | 1 | 1 | 2 | 0.5 | 2 | 0.5 | 1 | 0 | 1 | 1 | 0.5 | 2 | 1 | 1 | 1 | 1 | 1 |
Psy | 1 | 1 | 1 | 1 | 1 | 1 | 0.5 | 1 | 1 | 1 | 0.5 | 2 | 1 | 2 | 1 | 2 | 1 | 1 |
Bug | 1 | 2 | 1 | 1 | 0.5 | 1 | 0.5 | 1 | 0.5 | 2 | 1 | 1 | 2 | 1 | 1 | 1 | 1 | 1 |
Roc | 0.5 | 0.5 | 2 | 1 | 2 | 1 | 2 | 0.5 | 2 | 0.5 | 1 | 1 | 1 | 1 | 1 | 1 | 2 | 1 |
Gho | 0 | 1 | 1 | 1 | 1 | 1 | 0 | 0.5 | 1 | 1 | 1 | 0.5 | 1 | 2 | 1 | 2 | 1 | 1 |
Dra | 1 | 0.5 | 0.5 | 0.5 | 0.5 | 2 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 2 | 1 | 1 | 2 |
Dar | 1 | 1 | 1 | 1 | 1 | 1 | 2 | 1 | 1 | 1 | 0 | 2 | 1 | 0.5 | 1 | 0.5 | 1 | 2 |
Ste | 0.5 | 2 | 1 | 1 | 0.5 | 0.5 | 2 | 0 | 2 | 0.5 | 0.5 | 0.5 | 0.5 | 1 | 0.5 | 1 | 0.5 | 0.5 |
Fai | 1 | 1 | 1 | 1 | 1 | 1 | 0.5 | 2 | 1 | 1 | 1 | 0.5 | 1 | 1 | 0 | 0.5 | 2 | 1 |
So looking at the first row, a Normal type Pokémon takes 1x damage from a Normal type move, but take 2x damage from a Fighting type move, and 0x damage (total immunity) from a Ghost type move.
Adding further complexity, some Pokémon are dual type, in which case effects are multiplicative: for example a Water-Ground type Pokémon would be damaged \(2 \times 0 = 0\)x by an Electric attack, and \(0.5 \times 2 = 1\)x by a Water attack, and so on.
I want the strongest (largest total base stats) team, where at least one Pokémon is resistant (damaged less than 1x) to each type.
First, let’s use Python’s requests
library, and the
Pokéapi API to get data on all Generation 1 Pokémon.
(The dictionary type_weaknesses
is a dictionary containing the rows of the
table above as lists).
Here, if a Pokémon has two types, I use numpy
to apply elementwise
multiplication to their weaknesses:
In addition I’ll restrict my team to one starter and zero legendary or pseudo-legendary Pokémon. Their numbers (Python indexing from 0) are:
Now we have all the data, let’s formulate a linear program that will maximise a team’s total stats whilst ensuring all type attacks are covered by at least one resistant Pokémon.
- Let \(X\) be a decision array of 151 binary variables, representing the decision to include that Pokémon in my team.
- Let \(T\) be an array of 151 constants representing each Pokémon’s total base stats.
- Let \(S\), \(L\) and \(P\) be the sets of starter, legendary and pseudo-legendary Pokémon respectively.
- Let \(R\) be a 151 by 18 matrix of constants, where \(R_{ij} = 1\) if Pokémon \(i\) is resistant (taking 0.5x or less damange) to attacks from type \(j\). \(R\) is constructed by:
Now the linear program becomes:
\[\text{maximise:} \quad T_i X_i\]subject to:
\[\sum X_i = 6\] \[\sum_{i \in S} X_i \leq 1\] \[\sum_{i \in L \cup P} X_i = 0\] \[\sum R_{ij} X_i \geq 1 \quad \forall \quad j\]This can be solved using pulp
.
Define the problem and the variables:
Add the objective function:
Add the constraints and solve:
Once solved, the solution \(X\) can be read:
This yields the perfect Pokémon team (Generation 1) as Charizard, Arcanine, Poliwrath, Magneton, Cloyster and Tauros!
Pokémon | Type | Resistances | Total Stats |
---|---|---|---|
Charizard | Fire/Flying | Fir, Gra, Fig, Gro, Bug, Ste, Fai | 456 |
Arcanine | Fire | Fir, Gra, Ice, Bug, Ste, Fai | 465 |
Poliwrath | Water/Fighting | Fir, Wat, Ice, Bug, Roc, Dar, Ste | 420 |
Magneton | Electric/Steel | Nor, Ele, Gra, Ice, Poi, Fly, Psy, Bug, Roc, Dra, Ste, Fai | 415 |
Cloyster | Water/Ice | Wat, Ice | 475 |
Tauros | Normal | Gho | 415 |
Interestingly this team only covers eight types: Fire, Flying, Water, Fighting, Electric, Steel, Ice and Normal. Tauros’ presence is curious, as a purely Normal type with resistance to only Ghost type Pokémon; and Arcanine’s presence is surprising, given Fire types are already covered with Charizard. In fact Arcanine is not needed for type coverage at all: Arcanine has resistance to Ice, the only resistance it has that Charizard does not, however Ice resistance is covered by Poliwrath, Cloyster and Magneton. The same is true for Cloyster. This indicates maybe only four Pokémon are needed for type coverage, and there’s slack to choose two more based on pure strength.
This method would easily generalise to include more generations, however retrieving the data would take longer.
The mathematics used here is a standard operational research technique, and is used in many other more serious applications such as nurse rostering, exam timetabling, and surgery scheduling. This linear program has 151 variables and 21 constraints. In more serious applications these may have thousands of variables and thousands of constraints, and so solvers such as PuLP become invaluable.