Predicting Overwatch League Rankings with statistics¶

Leo Fafoutis¶

Introduction¶

Overwatch 1 was a 6v6 first person shooter developed by Activision Blizzard in 2016. The game focuses on two teams trying to capture objectives or accomplish tasks. The three roles in the game (tank, damage, and support) offer a variety of playstyles within the game.

A competitive scene grew over the years and a professional league began in 2018 named The Overwatch League (OWL). There are now 20 teams all competing in a regular season ending with a playoff bracket of some sort.

In this tutorial, we want to create a way to rank individual players based on their statistics for each role. These rankings will be based on ideas commonly associated with that role as well as common statistics across all games. We can then use this “Player Impact Rating” to predict the outcome of the playoffs and compare it to the actual results. Our motivation for this experiment is us to see how well general statistics can predict a team’s placement. To gain a better idea of what a Player Impact Rating would be, the Overwatch Leagues’ analyst attempted to create a Player Impact Rating (PIR) to rank player performance across roles (Read More). In short, it uses a variety of factors to compare players across roles to see which player, by statistics, is the best. For the purposes of this tutorial, we will compare players in their respective roles, since we may want to rate healing higher to support players or eliminations higher for damage players.

However, in order to create this PIR, we need a way to rate which statistics are going to be most important in our algorithm. To figure out which stats are important, we can use efficiency metrics as well as what statistics are commonly associated with each role. We will also learn how to view data from a CSV, extract the statistics we need, and analyze it to better understand the data science pipeline.

Data Collection¶

To begin, we will be using Python along with pandas, numpy, matplotlib, seaborn, and scikit-learn to handle the data. First, we need to import the correct libraries into our file as shown below.

In [1]:
# Libraries required for this tutorial
import pandas as pd
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import PolynomialFeatures
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')

After we have imported the correct libraries, we need to get the data from the OWL 2019 playoffs. Fortunately, the OWL stats team provides all data from each season here. We can download the 2019 data and extract it into the same folder as our code. In order to read the data, we will take advantage of Pandas .read_csv() function. We use read_csv because it offers a quick, simple way to view data as a dataframe.

In [2]:
playoffs = pd.read_csv("phs_2019_playoffs.csv")
playoffs.head()
Out[2]:
start_time match_id stage map_type map_name player team stat_name hero stat_amount
0 8/31/2019 1:09 30172 Overwatch League 2019 Post-Season CONTROL Ilios Elsa Chengdu Hunters All Damage Done All Heroes 19059.670270
1 8/31/2019 1:09 30172 Overwatch League 2019 Post-Season CONTROL Ilios Elsa Chengdu Hunters Assists All Heroes 22.000000
2 8/31/2019 1:09 30172 Overwatch League 2019 Post-Season CONTROL Ilios Elsa Chengdu Hunters Average Time Alive All Heroes 302.704223
3 8/31/2019 1:09 30172 Overwatch League 2019 Post-Season CONTROL Ilios Elsa Chengdu Hunters Barrier Damage Done All Heroes 8861.540116
4 8/31/2019 1:09 30172 Overwatch League 2019 Post-Season CONTROL Ilios Elsa Chengdu Hunters Damage - Quick Melee All Heroes 152.999995

The dataset should contain over 100,000 rows with the following columns:

  • State_time → date and time of match played
  • Match_id → numerical id of the match
  • Stage → Current stage of the OWL season (in this case playoffs)
  • Map_type → OW has various map types, Control, Assault, Escort, and Hybrid
  • Map_name → Name of current map
  • Player → Player’s in game name
  • Team → Team of current player (12 teams for the playoffs)
  • Stat_name → Name of stat being calculated
  • Hero → Name of the hero the current player is on
  • Stat_amount → numerical value of the stat_name column

Data Management¶

Now that we have our data, we need to tidy it up so it is easier to use later on. Currently, there are many rows and columns that we do not need for the purposes of this tutorial. To begin, we will be removing the following columns as they do not affect a player's impact, like start time or stage (all data is from the same stage). To do this, we need to use Pandas .drop() function to remove start_time and stage. Using .drop() allows us to maintain the same dataframe we are working with as well as remove certain rows/columns in the fewest amount of lines.

In [3]:
playoffs = playoffs.drop(['start_time', 'stage'], axis=1)

We also need to remove unwanted rows that might overlap. The dataframe breaks down the ‘hero’ column into the individual heroes, then the sum of those heroes into the ‘All Heroes’ tag. We do not need the ‘All Heroes’ tag since this is a sum of all values and does not contain role specific information, which is what we need. To do this, we can set the dataframe equal to the same dataframe with 'All Heroes' removed. Using .drop() would be fine in this situation, but I have used this method to provide more options.

In [4]:
playoffs = playoffs[playoffs.hero != 'All Heroes']
playoffs.head()
Out[4]:
match_id map_type map_name player team stat_name hero stat_amount
28 30172 CONTROL Ilios Elsa Chengdu Hunters All Damage Done D.Va 13934.906430
29 30172 CONTROL Ilios Elsa Chengdu Hunters Assists D.Va 15.000000
30 30172 CONTROL Ilios Elsa Chengdu Hunters Average Time Alive D.Va 569.509017
31 30172 CONTROL Ilios Elsa Chengdu Hunters Barrier Damage Done D.Va 6703.071256
32 30172 CONTROL Ilios Elsa Chengdu Hunters Critical Hit Accuracy D.Va 0.092603

Now that we have removed unnecessary data, we need to add in some new info based on the existing data. First, we need to create a column that can identify what hero is under what role so we can classify them later on. To do this, we first need to create lists for each of the roles that contain all possible heroes within that role.

In [5]:
tank = ['Reinhardt', 'Sigma', 'D.Va', 'Wrecking Ball', 'Orisa', 'Winston', 'Zarya', 'Roadhog']
support = ['Ana', 'Baptiste', 'Brigitte', 'Lúcio', 'Mercy', 'Moira', 'Zen']
damage = ['Ashe', 'Bastion', 'McCree', 'Genji', 'Hanzo', 'Junkrat', 'Mei', 'Pharah', 'Reaper', 
          'Soldier 76', 'Sombra', 'Symmetra', 'Torbjorn', 'Tracer', 'Widowmaker', 'Doomfist']

Then, we can create a new function that goes through each row and categorizes it based on the existing values. Using Pandas .apply() function allows us to go through each row and apply a function to that data. It will then create a new column based on what was returned from the lambda expression. While there are other methods for changing row data, most require a bit of prior knowledge. Using the .apply() method offers a quick and straightforward solution.

In [6]:
# Go through each row and label tank, support, or damage if hero exists in the role array
def label_role (row):
    if row['hero'] in tank:
        return 'tank'
    if row['hero'] in support:
        return 'support'
    if row['hero'] in damage:
        return 'damage'
    return 'error'

playoffs['role'] = playoffs.apply(lambda row: label_role(row), axis=1)
In [7]:
playoffs.head()
Out[7]:
match_id map_type map_name player team stat_name hero stat_amount role
28 30172 CONTROL Ilios Elsa Chengdu Hunters All Damage Done D.Va 13934.906430 tank
29 30172 CONTROL Ilios Elsa Chengdu Hunters Assists D.Va 15.000000 tank
30 30172 CONTROL Ilios Elsa Chengdu Hunters Average Time Alive D.Va 569.509017 tank
31 30172 CONTROL Ilios Elsa Chengdu Hunters Barrier Damage Done D.Va 6703.071256 tank
32 30172 CONTROL Ilios Elsa Chengdu Hunters Critical Hit Accuracy D.Va 0.092603 tank

Now, the way the stats are portrayed in the given csv file is a bit difficult to work with. They categorize all stats under the same column rather than individual ones. To make the data easier to work with, we need to extract the relevant data from each and try to create a new array. To begin, let's create a set of unique player names we can iterate through to get each players’ individual stats. Rather than creating this list by hand, Pandas .unique() function will allow us to quickly get all unique names within a dataframe column.

In [8]:
players = playoffs.player.unique()
players
Out[8]:
array(['Elsa', 'JinMu', 'Kyo', 'YangXiaoLong', 'Yveltal', 'ameng',
       'Chara', 'Eileen', 'HOTBA', 'Rio', 'nero', 'shu', 'Happy',
       'Boombox', 'Poko', 'SADO', 'carpe', 'eqo', 'neptuNo', 'CoMa',
       'Envy', 'Gamsu', 'IZaYaKI', 'YOUNGJIN', 'diem', 'DDing', 'Bdosin',
       'Birdring', 'Fury', 'Gesture', 'Profit', 'QuaterMain', 'Fits',
       'Fleta', 'Marve1', 'Michelle', 'ryujehong', 'tobi', 'Haksal',
       'JJANU', 'SLIME', 'SeoMinSoo', 'TiZi', 'Twilight', 'BEBE', 'Bazzi',
       'GodsB', 'Guxue', 'Ria', 'iDK', 'BigG00se', 'Hydration', 'Shaz',
       'Surefour', 'Void', 'rOar', 'Adora', 'Decay', 'Guard', 'Anamo',
       'Fl0w3R', 'JJonak', 'Mano', 'MekO', 'SAEBYEOLBE', 'Libero',
       'Dogman', 'Erster', 'Gator', 'Masaa', 'Pokpo', 'babybay',
       'Choihyobin', 'STRIKER', 'Viol2t', 'moth', 'sinatraa', 'smurf',
       'Rascal', 'NUS', 'Architect', 'Nevix', 'super'], dtype=object)

Now that we have a set of names to iterate through, we need to extract all data that is relevant to rating the players. To do this, we will iterate through the dataframe using a for loop and save the sum of each player's values into an array as a tuple. This will allow us to store the data quickly and make it easier to create another dataframe for future use.

However, there is a problem that arises in this step. I mentioned we needed to extract relevant data for future use, but since we have not analyzed any data or created the PIR yet, it is difficult to know exactly what is relevant. Fortunately, we can decide on the specific information we need later and gather a general assortment of statistics in the meantime. We can ignore more meaningless statistics like 'shots fired', or things of that nature as they do not contribute to any accepted performance metrics.

Using Pandas .loc() function allows us to use our player list to search for each player's data across various rows. Then we can append them to an array that will store each player's name, role, team, time played, eliminations, final blows, assists, deaths, all damage done, hero damage done, damage blocked, damage taken, and healing done.

In [9]:
append_player = [] # Tuple array we will use to store data

# Go through each player and gather all instances of specific data. Sum each set and place into tuple array
for p in players:
    x = playoffs.loc[playoffs['player'] == p]
    role = playoffs.loc[playoffs['player'] == p]['role']
    team = playoffs.loc[playoffs['player'] == p]['team']
    
    tp = x.loc[x['stat_name'] == 'Time Played']
    time_played = tp['stat_amount'].sum()
    
    dt = x.loc[x['stat_name'] == 'Deaths']
    deaths = dt['stat_amount'].sum()
    
    elim = x.loc[x['stat_name'] == 'Eliminations']
    eliminations = elim['stat_amount'].sum()
    
    fb = x.loc[x['stat_name'] == 'Final Blows']
    final_blows = fb['stat_amount'].sum()
    
    assi = x.loc[x['stat_name'] == 'Assists']
    assists = assi['stat_amount'].sum()
    
    alldd = x.loc[x['stat_name'] == 'All Damage Done']
    all_damage_done = alldd['stat_amount'].sum()
    
    db = x.loc[x['stat_name'] == 'Damage Blocked']
    damage_blocked = db['stat_amount'].sum()
    
    dt = x.loc[x['stat_name'] == 'Damage Taken']
    damage_taken = dt['stat_amount'].sum()
    
    herodd = x.loc[x['stat_name'] == 'Hero Damage Done']
    hero_damage_done = herodd['stat_amount'].sum()
    
    he = x.loc[x['stat_name'] == 'Healing Done']
    healing_done = he['stat_amount'].sum()
    
    append_player.append([p, role.iloc[0], team.iloc[0], time_played, eliminations, final_blows, assists, deaths, 
                          all_damage_done, hero_damage_done, damage_blocked, damage_taken, healing_done])

Once we have our array of data, we can use Pandas .DataFrame() function to create a dataframe with columns and rows from our previously made array.

In [10]:
# Creating new dataframe using the tuple array from before, now maning columns based on data
player_stats = pd.DataFrame(append_player, columns = ['player', 'role', 'team', 'time_played', 'eliminations', 'final_blows', 
                                                      'assists', 'deaths', 'all_damage_done', 'hero_damage_done', 'damage_blocked', 'damage_taken', 'healing_done'])
player_stats.head()
Out[10]:
player role team time_played eliminations final_blows assists deaths all_damage_done hero_damage_done damage_blocked damage_taken healing_done
0 Elsa tank Chengdu Hunters 3498.163487 90.0 35.0 54.0 35.0 78813.946835 35643.738028 71868.736370 58973.418665 0.000000
1 JinMu damage Chengdu Hunters 3498.163487 95.0 60.0 34.0 50.0 89472.716591 43849.816894 0.000000 30350.028669 0.000000
2 Kyo support Chengdu Hunters 3498.163487 98.0 24.0 74.0 31.0 38001.950358 26834.042363 0.000000 22157.011539 64119.250791
3 YangXiaoLong damage Chengdu Hunters 3498.163487 82.0 24.0 58.0 50.0 78420.876844 32839.213115 9014.902748 27648.442155 0.000000
4 Yveltal support Chengdu Hunters 3498.163487 33.0 12.0 20.0 35.0 13237.696258 7500.577831 0.000000 19125.635731 49803.365637

Now that we have clearly separated and managed data, we can begin data analysis… almost. One problem with our data currently is it shows the totals of each category. This means players that made it deeper into the bracket will be rated higher simply because they had more chances to get elims/assists in game. To avoid this issue, the Overwatch League and Overwatch game in general has a metric that rates stats per 10 minutes. Sadly, that information is not available to the public for OWL, but using the data we have, we can apply a function across all the rows to make the values in units of *per 10 minutes.

To do this, we can use the .apply function again and go through each row and apply a function that calculates the stat per 10 minutes. The formula to do this would be:

stat(per 10 minutes) = (600 seconds * stat value) / (total time played in seconds)

We also want to get rid of any player with under 20 minutes of play time. This is a common thing in the OWL as players with under 20 minutes can play 1 map and gain unrealistic statistics.

In [11]:
# Store each column we want to calculate a per 10 mins value on
columns = ['eliminations', 'final_blows', 'assists', 'deaths', 'all_damage_done', 'hero_damage_done', 'damage_blocked', 'damage_taken', 'healing_done']

for c in columns:
    
    # Time is stored in seconds, use 600 seconds as 10 mins. Return the per 10 mins value
    def per_ten (row):
        return ((600 * row[c]) / row['time_played'])

    # Change existing row to be of units per 10 mins
    player_stats[c] = player_stats.apply(lambda row: per_ten(row), axis=1)
In [12]:
player_stats.head()
Out[12]:
player role team time_played eliminations final_blows assists deaths all_damage_done hero_damage_done damage_blocked damage_taken healing_done
0 Elsa tank Chengdu Hunters 3498.163487 15.436671 6.003150 9.262003 6.003150 13518.055480 6113.562986 12326.822911 10115.036455 0.000000
1 JinMu damage Chengdu Hunters 3498.163487 16.294264 10.291114 5.831631 8.575929 15346.232430 7521.057901 0.000000 5205.593526 0.000000
2 Kyo support Chengdu Hunters 3498.163487 16.808820 4.116446 12.692374 5.317076 6518.040194 4602.536581 0.000000 3800.338941 10997.642224
3 YangXiaoLong damage Chengdu Hunters 3498.163487 14.064523 4.116446 9.948077 8.575929 13450.636678 5632.534883 1546.223231 4742.221269 0.000000
4 Yveltal support Chengdu Hunters 3498.163487 5.660113 2.058223 3.430371 6.003150 2270.510736 1286.488386 0.000000 3280.401697 8542.202070
In [13]:
# Time stored in seconds, use 1200 seconds to remove all players with less than 20 minutes of playtime
player_stats = player_stats[player_stats['time_played'] >= 1200]
player_stats
Out[13]:
player role team time_played eliminations final_blows assists deaths all_damage_done hero_damage_done damage_blocked damage_taken healing_done
0 Elsa tank Chengdu Hunters 3498.163487 15.436671 6.003150 9.262003 6.003150 13518.055480 6113.562986 12326.822911 10115.036455 0.000000
1 JinMu damage Chengdu Hunters 3498.163487 16.294264 10.291114 5.831631 8.575929 15346.232430 7521.057901 0.000000 5205.593526 0.000000
2 Kyo support Chengdu Hunters 3498.163487 16.808820 4.116446 12.692374 5.317076 6518.040194 4602.536581 0.000000 3800.338941 10997.642224
3 YangXiaoLong damage Chengdu Hunters 3498.163487 14.064523 4.116446 9.948077 8.575929 13450.636678 5632.534883 1546.223231 4742.221269 0.000000
4 Yveltal support Chengdu Hunters 3498.163487 5.660113 2.058223 3.430371 6.003150 2270.510736 1286.488386 0.000000 3280.401697 8542.202070
... ... ... ... ... ... ... ... ... ... ... ... ... ...
76 sinatraa damage San Francisco Shock 17413.605518 23.223220 11.232596 11.990624 6.305414 13480.414974 8509.802416 154.843549 7222.756482 2.238924
77 smurf tank San Francisco Shock 22202.566157 21.997457 4.675135 17.295298 5.918235 15793.131129 5812.403495 20423.271692 9838.326945 0.000000
78 Rascal damage San Francisco Shock 6211.166533 24.053453 8.404218 15.552634 5.409612 17039.293828 8715.729860 4856.452063 5541.179793 0.000000
79 NUS support London Spitfire 3393.073868 7.073232 1.591477 5.304924 5.481755 4515.641492 1565.947588 0.000000 3173.376017 8560.525761
80 Architect damage San Francisco Shock 6186.786785 22.984468 12.122609 10.861858 4.655082 32841.269373 8755.993527 0.000000 6937.621188 0.000000

80 rows × 13 columns

Finally, let's create one final set of arrays that divides players based on role. To do this, we will create three new dataframes and only add the rows that contain ‘tank’, ‘damage’, or ‘support’ to their respective dataframe.

In [14]:
# Separate players into three individual arrays based on role value
tank_df = player_stats[player_stats['role'] == 'tank']
damage_df = player_stats[player_stats['role'] == 'damage']
support_df = player_stats[player_stats['role'] == 'support']

tank_df.head()
Out[14]:
player role team time_played eliminations final_blows assists deaths all_damage_done hero_damage_done damage_blocked damage_taken healing_done
0 Elsa tank Chengdu Hunters 3498.163487 15.436671 6.003150 9.262003 6.003150 13518.055480 6113.562986 12326.822911 10115.036455 0.0
5 ameng tank Chengdu Hunters 3498.163487 12.520856 1.372149 10.977188 8.232891 11551.170264 4740.750125 13979.642293 10826.026007 0.0
8 HOTBA tank Guangzhou Charge 8551.738904 20.416900 6.524989 13.821750 4.700798 18664.522404 10178.403621 15039.548751 8212.293321 0.0
9 Rio tank Guangzhou Charge 8551.738904 15.996747 3.016930 12.979816 5.823377 13930.162221 4611.205186 22859.774330 8461.234009 0.0
14 Poko tank Philadelphia Fusion 6457.880113 18.117400 6.132043 11.985357 4.273848 17116.883495 7250.029507 18319.923811 7227.170814 0.0

Data Analysis¶

Here we begin our analysis. This phase will include topics such as graphing data, analyzing performance metrics, and understanding which stats we need to focus on for making our role specific PIR. To understand the 2019 rankings, click here.

There is one more thing we need to understand about the statistics as well before we begin. Overwatch is a very different game when it comes to stat names. For example, an elimination can actually be an assist or a final blow. A great youtube video to understand this topic is HERE. I would highly recommend watching at least the first part of this video before moving forward to understand more about Overwatch eliminations.

For each of our roles, we want to find a set of 5 statistics we can use for our PIR. This will allow for a standardized PIR across roles to compare later. We also want to make at least two of those statisitcs role specific to better rate players. These are arbitrary values, but it is important that we decide on the template now so they are comparable later on.

Before we get into the role specific breakdown of analyzing what stats are best for each role, let's make a color palette so graphing becomes easier later on. Seaborn has a useful tool named xkcd_palette which allows you to make your own set of colors. Since our damage_df is the largest at 32, we can make a set of 32 colors to use for each role dataframe.

In [15]:
colors = ["purple", "green", "blue", "pink", "brown", "red", "light blue", "teal", "orange", "light green", "magenta", "yellow", "grey", "dark green", "dark blue", 
          "tan", "cyan", "bright green", "lilac", "hot pink", "olive green", "mustard", "periwinkle", "light pink", "plum", "brick red", "dark brown", "chartreuse", 
          "dark orange", "slate", "sea blue", "twilight blue"]
new_palette = sns.xkcd_palette(colors)

Tanks¶

Starting with tanks, we can begin by plotting Eliminations vs Deaths. In most video games, this is a common way players view stats and is a good starting point for each of our roles. Using seaborn’s lmplot feature, we can plot the data fairly easily. For this graph, being near the bottom right would be the best scenario for a player (high eliminations, low deaths):

In [16]:
# Graph each player's elimination to death value in a scatter plot. Each player will get their own color hue.
sns.lmplot(x="eliminations", y="deaths", data=tank_df, fit_reg=False, legend=False, hue='player', palette=new_palette, size=5, aspect=1.5, scatter_kws={"s": 75})
plt.legend(loc=9, bbox_to_anchor=(0.5, -0.2), ncol=5)

#Use meaningful tites
plt.title('Eliminations vs. Deaths for Tank Players')
plt.xlabel('Eliminations / 10 mins')
plt.ylabel('Deaths / 10 mins')
Out[16]:
Text(20.800000000000004, 0.5, 'Deaths / 10 mins')

One player that stands out is Ameng (top left). This player has very high deaths and is involved in almost no eliminations. On the other end of the spectrum, Choihyobin has the highest eliminations while maintaining a relatively low death rate. There seems to be a slight correlation between lower deaths equaling higher eliminations, however this relationship is minimal.

Since K/D (elim to deaths) is a vague stats independent of role, let's begin to analyze role specific information we might need. When thinking of tanks in Overwatch (or any video game), being “tanky” is an important aspect. Being able to soak up damage, get healing, and take space is a key part of the tank role. But how do you quantify a player’s ‘tankiness’?

Let's look at a few statistics we have access to, mainly damage_taken and deaths. In theory, if we know the average health of a hero the person is playing and the total damage they take in 10 minutes, we could calculate how many times that amount of damage should have killed that player. We can call this value ‘expected deaths’ and calculate it by dividing the total damage taken by a player over the average health of the hero. We can then plot the expected deaths to the actual deaths:

In [17]:
# Make new column for tank_df that takes all damage taken over average hero health
tank_df['expected_deaths'] = tank_df.apply(lambda row: (row['damage_taken'] / (425)), axis=1)
tank_df.head()
Out[17]:
player role team time_played eliminations final_blows assists deaths all_damage_done hero_damage_done damage_blocked damage_taken healing_done expected_deaths
0 Elsa tank Chengdu Hunters 3498.163487 15.436671 6.003150 9.262003 6.003150 13518.055480 6113.562986 12326.822911 10115.036455 0.0 23.800086
5 ameng tank Chengdu Hunters 3498.163487 12.520856 1.372149 10.977188 8.232891 11551.170264 4740.750125 13979.642293 10826.026007 0.0 25.473002
8 HOTBA tank Guangzhou Charge 8551.738904 20.416900 6.524989 13.821750 4.700798 18664.522404 10178.403621 15039.548751 8212.293321 0.0 19.323043
9 Rio tank Guangzhou Charge 8551.738904 15.996747 3.016930 12.979816 5.823377 13930.162221 4611.205186 22859.774330 8461.234009 0.0 19.908786
14 Poko tank Philadelphia Fusion 6457.880113 18.117400 6.132043 11.985357 4.273848 17116.883495 7250.029507 18319.923811 7227.170814 0.0 17.005108
In [18]:
# Graph each player's deaths to expected death value in a scatter plot
sns.lmplot(x="deaths", y="expected_deaths", data=tank_df, fit_reg=False, legend=False, hue='player', palette=new_palette, size=6, aspect=1, scatter_kws={"s": 75})
plt.legend(loc=9, bbox_to_anchor=(0.5, -0.2), ncol=5)

#Use meaningful tites
plt.title('Actual Deaths vs. Expected Deaths for Tank Players')
plt.xlabel('actual deaths / 10 mins')
plt.ylabel('expected deaths / 10 mins')
Out[18]:
Text(24.956250000000004, 0.5, 'expected deaths / 10 mins')

At a glance, the meaning of a player's position on the graph can be a bit confusing. To simplify it, let's place four points on each corner of our chart, top left, top right, bottom left, and bottom right. Below is a key that gives a general idea of the meaning behind a players position:

  • Close to Top Left → Takes a lot of Damage, Difficult to Eliminate
  • Close to Top Right → Takes a lot of Damage, Easy to Eliminate
  • Close to Bottom Left → Does not take a lot of Damage, Hard to Eliminate
  • Close to Bottom Right → Does not take a lot of Damage, Easy to Eliminate

If we want to quantify this value as a number, we can create a new column in our dataframe named “tankiness”. This will be the ratio of expected deaths divided by actual deaths, where a higher number is better.

In [19]:
# New column where the value is the ratio of expected deaths:actual deaths
tank_df['tankiness'] = tank_df.apply(lambda row: (row['expected_deaths'] / row['deaths']), axis=1)
tank_df.head()
Out[19]:
player role team time_played eliminations final_blows assists deaths all_damage_done hero_damage_done damage_blocked damage_taken healing_done expected_deaths tankiness
0 Elsa tank Chengdu Hunters 3498.163487 15.436671 6.003150 9.262003 6.003150 13518.055480 6113.562986 12326.822911 10115.036455 0.0 23.800086 3.964600
5 ameng tank Chengdu Hunters 3498.163487 12.520856 1.372149 10.977188 8.232891 11551.170264 4740.750125 13979.642293 10826.026007 0.0 25.473002 3.094053
8 HOTBA tank Guangzhou Charge 8551.738904 20.416900 6.524989 13.821750 4.700798 18664.522404 10178.403621 15039.548751 8212.293321 0.0 19.323043 4.110588
9 Rio tank Guangzhou Charge 8551.738904 15.996747 3.016930 12.979816 5.823377 13930.162221 4611.205186 22859.774330 8461.234009 0.0 19.908786 3.418770
14 Poko tank Philadelphia Fusion 6457.880113 18.117400 6.132043 11.985357 4.273848 17116.883495 7250.029507 18319.923811 7227.170814 0.0 17.005108 3.978875

One final metric tank players should look at is how well they can balance blocking damage for their team, while doing damage of their own. Let’s plot All Damage Done versus Damage Blocked to see where players land. In this graph, being near the bottom left is bad:

In [20]:
# Graph each players total damage to their damage blocked in a scatter plot
sns.lmplot(x="all_damage_done", y="damage_blocked", data=tank_df, fit_reg=False, legend=False, hue='player', palette=new_palette, size=6, aspect=1.25, scatter_kws={"s": 75})
plt.legend(loc=9, bbox_to_anchor=(0.5, -0.2), ncol=5)

#Use meaningful titles
plt.title('All Damage Done vs. Damage Blocked for Tank Players')
plt.xlabel('all_damage_done / 10 mins')
plt.ylabel('damage_blocked / 10 mins')
Out[20]:
Text(5.831250000000004, 0.5, 'damage_blocked / 10 mins')

Although we are still plotting individuals in the scatter plot, an interesting aggregation appears. There seems to be a diagonal line from Marve1 to Choihyobin that encloses the data points. This would suggest that a playstyle difference is at work, where certain players tend to focus on blocking damage while others focus more on damaging the opponents. There is no great way to tell which is better, which means the most important data from the graph is in the lower end. Again we see Ameng near the bottom left as well as Elsa and Fury.

Taking all these facts into consideration, let's begin to try to make our tank Player Impact Rating. To begin, we first need to normalize our data across all columns. Normalization is the process in which we can scale statistics to end up in the range of 0 and 1. (Read More on Normalization) This will allow us to compare statistics that have a larger set of values to ones with a smaller set of values. We can do this by taking advantage of the formula for normalization below:

xnorm = (x - min(x)) / range(x)

In [21]:
# Get all values we want to normalize
columns = ['eliminations', 'final_blows', 'assists', 'deaths', 'all_damage_done', 'hero_damage_done', 'damage_blocked', 'damage_taken', 'tankiness']

for c in columns:
    
    # For each row, find the range of values to calculate each stat as a percentage of the total
    def norm_data (row):
        all_values = tank_df[c]
        range_values = all_values.max() - all_values.min()
    
        norm_values = (row[c] - all_values.min()) / range_values
    
        return norm_values

    # Change existing rows to instead contain normalized values of all column specified data
    tank_df[c] = tank_df.apply(lambda row: norm_data(row), axis=1)
In [22]:
tank_df.head()
Out[22]:
player role team time_played eliminations final_blows assists deaths all_damage_done hero_damage_done damage_blocked damage_taken healing_done expected_deaths tankiness
0 Elsa tank Chengdu Hunters 3498.163487 0.279040 0.898728 0.000000 0.436798 0.262611 0.261740 0.005367 0.802440 0.0 23.800086 0.698142
5 ameng tank Chengdu Hunters 3498.163487 0.000000 0.000000 0.207243 1.000000 0.035688 0.022569 0.148063 1.000000 0.0 25.473002 0.069934
8 HOTBA tank Guangzhou Charge 8551.738904 0.755641 1.000000 0.550946 0.107842 0.856368 0.969915 0.239570 0.273732 0.0 19.323043 0.803491
9 Rio tank Guangzhou Charge 8551.738904 0.332638 0.319199 0.449217 0.391390 0.310157 0.000000 0.914731 0.342904 0.0 19.908786 0.304258
14 Poko tank Philadelphia Fusion 6457.880113 0.535582 0.923742 0.329058 0.000000 0.677814 0.459735 0.522782 0.000000 0.0 17.005108 0.708444

Once we have our new normalized values, we can begin selecting which values we will use in our tank PIR. As we saw above, two of our ‘tank specific’ values were damage blocked and tankiness. We can include these values since we know the importance of them for a tank. Since we also saw that all damage done seemed to be more of a playstyle metric and not role specific, we can include it along with deaths and eliminations. Using this idea, we can add each of these values to calculate our tank PIR and store it in a new column named ‘PIR’.

In [23]:
# For each player, returns a PIR rating from formula 
def tank_pir (row):
    
    rating = (row['eliminations']) - row['deaths'] + row['all_damage_done'] + row['damage_blocked'] + row['tankiness']
    
    return rating

#Creates new row for each player that contains their PIR score with .apply
tank_df['PIR'] = tank_df.apply(lambda row: tank_pir(row), axis=1)

Now that we have our ratings, let's take a look at where each tank player lies. Using Pandas sort_values feature, we can organize the data in increasing order and graph it with seaborn’s barplot.

In [24]:
# Convert PIR to a float so it can be sorted, then store the sorted values in a new list
tank_df.PIR = tank_df.PIR.astype(float)
tank_ranks = tank_df.sort_values('PIR')
In [25]:
# Graph rankings of tank players based on PIR
sns.set(rc={'figure.figsize':(22,8.27)})
sns.barplot(data=tank_ranks, x="player", y="PIR")
Out[25]:
<AxesSubplot:xlabel='player', ylabel='PIR'>

Looking at these rankings, there seems to be a set of players with very similar PIR. For example, we see Gator, Rio, Tizi, Pokpo and Sado all with a rating of around 1.75. This occurs in other areas of the graph as well and suggests while those players may have a similar impact on the game, the way in which they accomplish that varies. Another interesting thing to note is who is sitting at the top and bottom of the rankings. We mentioned Ameng and Choi before and now see them again at the bottom and top of the list. The range of this data is around 4, which suggests a large gap in tank PIR.

Damage¶

Moving onto damage, we can take a similar starting point by graphing the eliminations to deaths of each damage player using Seaborn’s lmplot again.

In [26]:
# Graph each damage player's eliminations and deaths in a scatter plot

sns.lmplot(x="eliminations", y="deaths", data=damage_df, fit_reg=False, legend=False, hue='player', palette=new_palette, size=6, aspect=2.5, scatter_kws={"s": 75})
plt.legend(loc=9, bbox_to_anchor=(0.5, -0.2), ncol=5)

#Use meaningful titles
plt.title('Eliminations vs. Deaths for Damage Players')
plt.xlabel('Eliminations / 10 mins')
plt.ylabel('Deaths / 10 mins')
Out[26]:
Text(25.960000000000008, 0.5, 'Deaths / 10 mins')

Looking at the players on the ends of the spectrum, we see SeoMinSoo, STRIKER, and GodsB near the higher end and Diem, YangXiaoLong, and Guard near the lower end. Compared to the tank elimination vs deaths graph, there seems to be a greater relationship between lower deaths equating to higher eliminations.

But as we mentioned before, eliminations in Overwatch 1 are calculated in a weird way. We know eliminations are a combination of both final blows and assists. For this reason, an actual final blow could be rated of higher importance than an assist, since these also include solo kills. Let’s see the breakdown of each player's ratio of final blows to assists using Seaborn. Seaborn offers a function called PairGrid which lets you graph sets of data and break down the individual aspects into their own graphs. For this graph, we want to graph total eliminations, final blows, and assists. (These values lie on the 4,5,6 column of the dataframe)

In [27]:
# Make the PairGrid
g = sns.PairGrid(damage_df.sort_values("eliminations", ascending=False),x_vars=damage_df.columns[4:7], y_vars=["player"],height=10, aspect=.55)

# Draw a dot plot using the stripplot function
g.map(sns.stripplot, size=10, orient="h", jitter=False, palette=new_palette, linewidth=1, edgecolor="w")

# Use the same x axis limits on all columns and add better labels
g.set(xlim=(0, 27), xlabel="per 10 mins", ylabel="")

# Use meaningful titles for the columns
titles = ["Eliminations", "Final Blows", "Assists"]

for ax, title in zip(g.axes.flat, titles):

    # Set a different title for each axes
    ax.set(title=title)

    # Make the grid horizontal instead of vertical
    ax.xaxis.grid(False)
    ax.yaxis.grid(True)

sns.despine(left=True, bottom=True)

Players that stand out as having a larger split of final blows to assists include Eileen, JinMu, and Fits. On the other end of the spectrum, players like Rascal, Nero, and Flower have more assists than final blows. As we can see, most players tend to have higher assists than final blows. We can also see that sorting by final blows would change the rankings much more than sorting by assists, since the increase of final blows to eliminations is much more irregular.

But just as we did with tanks, what metric would be best to rating damage players specifically? Since it is in the name, doing damage and getting eliminations seems to be the most important aspect of damage players. But dealing damage that does not equate to kills is a bit meaningless, so let’s try to create a stat that measures a damage player’s efficiency. We have access to a damage player's hero damage done as well as the average health of an enemy hero. If we take the damage they dealt to all heroes in 10 minutes and calculate how many eliminations that should have netted them, we can see their damage efficiency. To do this, we can create a new row in our damage_df and fill it with each player’s hero damage done/average hero health (In this playoffs it is calculated to be 291 based on pick rates of the 2019: Post season, found here.

In [28]:
# Create new column in damage_df where its value is hero damage over average hero health
damage_df['expected_elims'] = damage_df.apply(lambda row: (row['hero_damage_done'] / (291)), axis=1)
damage_df.head()
Out[28]:
player role team time_played eliminations final_blows assists deaths all_damage_done hero_damage_done damage_blocked damage_taken healing_done expected_elims
1 JinMu damage Chengdu Hunters 3498.163487 16.294264 10.291114 5.831631 8.575929 15346.232430 7521.057901 0.000000 5205.593526 0.0 25.845560
3 YangXiaoLong damage Chengdu Hunters 3498.163487 14.064523 4.116446 9.948077 8.575929 13450.636678 5632.534883 1546.223231 4742.221269 0.0 19.355790
7 Eileen damage Guangzhou Charge 8551.738904 19.364483 10.524175 8.699985 7.787890 11614.226976 7326.085807 0.000000 6526.211764 0.0 25.175553
10 nero damage Guangzhou Charge 5982.105991 20.561321 9.428118 10.932605 5.616751 15419.611553 8710.263406 0.000000 5008.032877 0.0 29.932177
12 Happy damage Guangzhou Charge 2569.632913 20.314186 8.172374 12.141812 5.370417 15518.655194 6757.202065 0.000000 5602.140228 0.0 23.220626

We can then calculate the damage efficiency by taking the ratio of expected eliminations to actual eliminations and placing the values into a new column in our damage_df.

In [29]:
# Create new column in damage_df where its value is the ratio of expected elims:actual elims
damage_df['damage_eff'] = damage_df.apply(lambda row: (row['expected_elims'] / row['eliminations']), axis=1)
damage_df.head()
Out[29]:
player role team time_played eliminations final_blows assists deaths all_damage_done hero_damage_done damage_blocked damage_taken healing_done expected_elims damage_eff
1 JinMu damage Chengdu Hunters 3498.163487 16.294264 10.291114 5.831631 8.575929 15346.232430 7521.057901 0.000000 5205.593526 0.0 25.845560 1.586175
3 YangXiaoLong damage Chengdu Hunters 3498.163487 14.064523 4.116446 9.948077 8.575929 13450.636678 5632.534883 1546.223231 4742.221269 0.0 19.355790 1.376214
7 Eileen damage Guangzhou Charge 8551.738904 19.364483 10.524175 8.699985 7.787890 11614.226976 7326.085807 0.000000 6526.211764 0.0 25.175553 1.300089
10 nero damage Guangzhou Charge 5982.105991 20.561321 9.428118 10.932605 5.616751 15419.611553 8710.263406 0.000000 5008.032877 0.0 29.932177 1.455752
12 Happy damage Guangzhou Charge 2569.632913 20.314186 8.172374 12.141812 5.370417 15518.655194 6757.202065 0.000000 5602.140228 0.0 23.220626 1.143074

From here, we can use a similar technique as we did with tanks and use Seaborn’s lmplot to graph the actual eliminations vs the expected eliminations. Keep in mind, for this graph having a ratio that is closest to 1 is ideal. This means you are getting about as many eliminations as your damage should net you.

In [30]:
#Graph each damage players elims to expected elims in a scatter plot
sns.lmplot(x="eliminations", y="expected_elims", data=damage_df, fit_reg=False, legend=False, hue='player', palette=new_palette, size=8, aspect=1, scatter_kws={"s": 75})
plt.legend(loc=9, bbox_to_anchor=(0.5, -0.2), ncol=5)

#Use meaningful tites
plt.title('Actual Eliminations vs. Expected Eliminations for Damage Players')
plt.xlabel('actual eliminations / 10 mins')
plt.ylabel('expected elims / 10 mins')
Out[30]:
Text(29.334999999999994, 0.5, 'expected elims / 10 mins')

Some players that stand out as having a ratio of close to 1 are eqo and carpe. Players that seem to have a low damage efficiency include Guard and diem, who deal a lot of damage, but contribute to very few eliminations.

Now that we have looked at our damage data, let’s begin creating our damage PIR. We will use the same normalization method as before, except this time we will normalize ‘damage_eff’ as well.

In [31]:
# Get each column we want to normalize
columns = ['eliminations', 'final_blows', 'assists', 'deaths', 'all_damage_done', 'hero_damage_done', 'damage_blocked', 'damage_taken', 'damage_eff']

for c in columns:
    
    # For each row, returns normalized value
    def norm_data (row):
        all_values = damage_df[c]
        range_values = all_values.max() - all_values.min()
    
        norm_values = (row[c] - all_values.min()) / range_values
    
        return norm_values

    # Change existing row to new normalized values
    damage_df[c] = damage_df.apply(lambda row: norm_data(row), axis=1)
In [32]:
damage_df.head()
Out[32]:
player role team time_played eliminations final_blows assists deaths all_damage_done hero_damage_done damage_blocked damage_taken healing_done expected_elims damage_eff
1 JinMu damage Chengdu Hunters 3498.163487 0.298727 0.771239 0.007644 1.000000 0.235032 0.479698 0.000000 0.109243 0.0 25.845560 0.679184
3 YangXiaoLong damage Chengdu Hunters 3498.163487 0.128688 0.000000 0.427866 1.000000 0.152147 0.000000 0.256597 0.004824 0.0 19.355790 0.419691
7 Eileen damage Guangzhou Charge 8551.738904 0.532861 0.800350 0.300456 0.799013 0.071850 0.430174 0.000000 0.406840 0.0 25.175553 0.325607
10 nero damage Guangzhou Charge 5982.105991 0.624131 0.663448 0.528370 0.245271 0.238240 0.781765 0.000000 0.064723 0.0 29.932177 0.517992
12 Happy damage Guangzhou Charge 2569.632913 0.605285 0.506601 0.651811 0.182444 0.242571 0.285673 0.000000 0.198603 0.0 23.220626 0.131551

Using the same method again as the tank, we can create a formula for our damage PIR and store it in a new column in our damage_df. For our damage PIR, we found that while eliminations, low deaths, and assists are all important, having higher final blows and damage efficiency means your damage is being put to good use.

In [33]:
# Returns damage PIR using formula provided
def damage_pir (row):
    
    rating = row['eliminations'] - row['deaths'] + (1 - row['damage_eff']) + row['assists'] + row['final_blows']
    
    return rating

# Create new row for PIR for each player
damage_df['PIR'] = damage_df.apply(lambda row: damage_pir(row), axis=1)

Again, we can use Pandas to sort the data and graph the rankings. This time however, since there are 31 players, we will split the graph into two sections so it is easier to view long names like SeoMinSoo or SAEBYEOLBE.

In [34]:
# Convert to float to make it easier to sort values. Store sorted values in new list.
damage_df.PIR = damage_df.PIR.astype(float)
damage_ranks = damage_df.sort_values('PIR')

#Graph first 16 players since sample size is large
sns.set(rc={'figure.figsize':(22,8.27)})
sns.barplot(data=damage_ranks.iloc[:16], x="player", y="PIR")
Out[34]:
<AxesSubplot:xlabel='player', ylabel='PIR'>
In [35]:
# Graph last 15 players, starting at the player who ranked first in graph above.
sns.set(rc={'figure.figsize':(22,8.27)})
sns.barplot(data=damage_ranks.iloc[15:], x="player", y="PIR")
Out[35]:
<AxesSubplot:xlabel='player', ylabel='PIR'>

This graph seems to be much more constant in its increase across players. While the max / min PIR for tanks was 3.0 / -0.7 respectively, the range for dps seems to be a bit larger, with SeoMinSoo having nearly a 3.5 PIR and diem a -0.4. We also can see a large jump in PIR between the bottom 4 players and the rest. The jump from JinMu to Birdring is the highest seen yet at around 0.5 points!

Support¶

Finally, we can take a look at supports. Starting as we normally do, we can graph eliminations to deaths of all support players.

In [36]:
# Graph eliminations vs deaths for each support player

sns.lmplot(x="eliminations", y="deaths", data=support_df, fit_reg=False, legend=False, hue='player', palette=new_palette, size=6, aspect=2.5, scatter_kws={"s": 100})
plt.legend(loc=9, bbox_to_anchor=(0.5, -0.2), ncol=5)

# Use relevant titles
plt.title('Eliminations vs. Deaths for Support Players')
plt.xlabel('Eliminations / 10 mins')
plt.ylabel('Deaths / 10 mins')
Out[36]:
Text(25.960000000000008, 0.5, 'Deaths / 10 mins')

Players that stand out include Viol2t and Twilight with extremely high eliminations and relatively low deaths. On the other end of the spectrum we see CoMa with relatively low deaths and eliminations. There also does not seem to be any relationship between lower deaths equaling higher eliminations.

Since we are looking for support specific stats, unlike tank and damage, we have a fairly obvious metric to use, healing done. For support, healing your team as much as possible has historically been important. Let’s use Seaborn’s barplot feature to see the general rankings on support healing.

In [37]:
f, ax = plt.subplots(figsize=(6, 15))

# Plot the total healing
sns.set_color_codes("pastel")
support_healing = support_df.sort_values("healing_done", ascending=False)
sns.barplot(x="healing_done", y="player", data=support_healing, palette=new_palette, label="healing_done", color="g")
Out[37]:
<AxesSubplot:xlabel='healing_done', ylabel='player'>

From this ranking, we can see that enormous range of healing_done across the board. While players like Shaz heal nearly 10,000 more health than players like neptuNo. We also see a small plateau in our data between moth and Masaa where the healing seems to be very similar across all players in between.

Creating our support PIR seems to be a bit more straightforward than our tank and damage ones. We are given healing as a role specific statistic with eliminations, deaths, and final blows as generally good stats to have. The trouble begins when finding what our final stat should be. As mentioned before in the Marblr video, assists are earned in a strange manner in Overwatch 1. For this reason, support players can earn assists at a much higher rate than other roles, with abilities like damage boosts. For this reason, we can use assists as our last statistic, as support players can earn them at a higher rate if they are contributing more to the game.

As we have done twice before, we will normalize the specific rows we need, and calculate the support PIR. Then we again use Seaborn to graph the rankings.

In [38]:
# Get each column we want to normalize data on
columns = ['eliminations', 'final_blows', 'assists', 'deaths', 'all_damage_done', 'hero_damage_done', 'damage_blocked', 'damage_taken', 'healing_done']

for c in columns:
    
    #Returns normalized values for each row provided
    def norm_data (row):
        all_values = support_df[c]
        range_values = all_values.max() - all_values.min()
    
        norm_values = (row[c] - all_values.min()) / range_values
    
        return norm_values
    
    #Change existing rows to instead include normalized values
    support_df[c] = support_df.apply(lambda row: norm_data(row), axis=1)
In [39]:
support_df.head()
Out[39]:
player role team time_played eliminations final_blows assists deaths all_damage_done hero_damage_done damage_blocked damage_taken healing_done
2 Kyo support Chengdu Hunters 3498.163487 0.614356 0.956099 0.535122 0.546948 0.540506 0.539765 0.0 0.794325 0.535692
4 Yveltal support Chengdu Hunters 3498.163487 0.161790 0.403061 0.106970 0.843313 0.123519 0.110728 0.0 0.508308 0.308725
6 Chara support Guangzhou Charge 8551.738904 0.282342 0.170508 0.282457 0.492887 0.494997 0.236940 0.0 0.531907 0.193884
11 shu support Guangzhou Charge 8551.738904 0.644049 1.000000 0.561383 0.280733 0.635123 0.588588 0.0 0.434874 0.569629
13 Boombox support Philadelphia Fusion 6457.880113 0.742907 0.748747 0.717184 0.296975 0.693041 0.660875 0.0 0.438454 0.570349
In [40]:
# Returns support PIR based on formula provided
def support_pir (row):
    
    rating = row['eliminations'] - row['deaths'] + row['healing_done'] + row['assists'] + row['final_blows']
    
    return rating

#Create new row in support_df that contains PIR for each player
support_df['PIR'] = support_df.apply(lambda row: support_pir(row), axis=1)
In [41]:
# Graph sorted support PIR
support_df.PIR = support_df.PIR.astype(float)
support_ranks = support_df.sort_values('PIR')
sns.set(rc={'figure.figsize':(25,8.27)})
sns.barplot(data=support_ranks, x="player", y="PIR")
Out[41]:
<AxesSubplot:xlabel='player', ylabel='PIR'>

Looking at stand out players, we see Viol2t skyrocket to the top with a PIR of nearly 4, the highest of any role! Near the lower end we see players like CoMa and Yveltal with a rating up under 0.5. The range on this data is similar to that of the tank PIR. However, unlike the tank and damage PIR rankings, no player falls below 0. Our highest jump between two data points is actually at the top with BEBE and Viol2t having nearly a 0.5 difference in PIR.

Hypothesis Testing¶

In our hypothesis testing phase, we will be comparing each of our PIR sets to see which model provides the most accurate representation of team standings. To do this, we can use something known as the Residual Sum of Squares and the Residual Standard Error). These methods involve calculating the variance and error in the residuals of a regression model. The closer to 0 the RSS/RSE is, the more accurate the model is.

Before we begin anything though, we need to organize our data one more time. First, we will use Pandas .groupby() function to group each dataframe by team and take the average PIR across each role. It is important we take the mean of the PIR since some teams may have multiple players subbing in and out.

We can then convert those groups into new dataframes which contain a team name and the team’s PIR for that role. We will also include a dataframe that contains all of the team’s PIR regardless of role. Finally, taking advantage of Pandas sort_values function again, we can rank the teams by PIR to see what their predictions would be.

In [42]:
# Average each teams PIR and store it into a new list
tank_pir_group = tank_df.groupby('team')['PIR'].mean()
damage_pir_group = damage_df.groupby('team')['PIR'].mean()
support_pir_group = support_df.groupby('team')['PIR'].mean()
In [43]:
# Create temp arrays to store info for tank, damage, support, combined
team_list = playoffs.team.unique()
temp_tank = []
temp_damage = []
temp_support = []
temp_all = []

for t in team_list:
    temp_tank.append([t, tank_pir_group[t]])
    temp_damage.append([t, damage_pir_group[t]])
    temp_support.append([t, support_pir_group[t]])
    temp_all.append([t, (tank_pir_group[t] + damage_pir_group[t] + support_pir_group[t]) / 3])
    
#Create four new dataframes with column values Team and Team_PIR
tank_team_ranks = pd.DataFrame(temp_tank, columns = ['team', 'team_PIR'])
damage_team_ranks = pd.DataFrame(temp_damage, columns = ['team', 'team_PIR'])
support_team_ranks = pd.DataFrame(temp_support, columns = ['team', 'team_PIR'])
all_team_ranks = pd.DataFrame(temp_all, columns = ['team', 'team_PIR'])
In [44]:
# Sort values in each dataframe
tank_team_ranks = tank_team_ranks.sort_values('team_PIR', ascending=False)
damage_team_ranks = damage_team_ranks.sort_values('team_PIR', ascending=False)
support_team_ranks = support_team_ranks.sort_values('team_PIR', ascending=False)
all_team_ranks = all_team_ranks.sort_values('team_PIR', ascending=False)

In order to compare these predictions with the actual results, we also need to add in a column to each dataframe containing the actual placement of each of the OWL teams. We can use Pandas .apply() function to go through each dataframe and add in the team’s actual standings.

In [45]:
# Label each team with their actual final standing position in the OWL 2019 playoffs
def label_result (row):
    if row['team'] == 'Chengdu Hunters':
        return 12
    if row['team'] == 'Philadelphia Fusion':
        return 11
    if row['team'] == 'Shanghai Dragons':
        return 10
    if row['team'] == 'Guangzhou Charge':
        return 9
    if row['team'] == 'London Spitfire':
        return 8
    if row['team'] == 'Seoul Dynasty':
        return 7
    if row['team'] == 'Los Angeles Gladiators':
        return 6
    if row['team'] == 'Atlanta Reign':
        return 5
    if row['team'] == 'Hangzhou Spark':
        return 4
    if row['team'] == 'New York Excelsior':
        return 3
    if row['team'] == 'Vancouver Titans':
        return 2
    if row['team'] == 'San Francisco Shock':
        return 1
    return 0

# Create new column to store actual standings of OWL teams
tank_team_ranks['standing'] = tank_team_ranks.apply(lambda row: label_result(row), axis=1)
damage_team_ranks['standing'] = damage_team_ranks.apply(lambda row: label_result(row), axis=1)
support_team_ranks['standing'] = support_team_ranks.apply(lambda row: label_result(row), axis=1)
all_team_ranks['standing'] = all_team_ranks.apply(lambda row: label_result(row), axis=1)

Now we can begin our testing. First, we want to go through each of our role specific PIR and draw a Polynomial line over them. We can use sklearn.preprocessing’s PolynomialFeatures to do so. Then, we will fit a Linear Regression line over that data with sklearn’s linear model - LinearRegression library. Using our new model, we can fit our prediction and plot the scatter plot to see how accurate it is. If you want to learn more about the libraries used, each can be found here:

  • Np.linspace()
  • Poly.fit_transform()
  • poly_model.predict()

Finally, we can calculate the RSS for each of the roles by subtracting the actual y values from the predictions and squaring the results. The formula for RSS can be found below:

RSS = Sum((y - pred)^2)

In [46]:
# Provide names to each for data viewing. Go through RSS for each tank, damage, and support PIR predictions. 
tank_team_ranks.name = 'tanks'
damage_team_ranks.name = 'damage'
support_team_ranks.name = 'supports'
all_team_ranks.name = 'all'
RSS = []

# Reshape value to fit Regression model
X = tank_team_ranks[['standing']]
y = tank_team_ranks['team_PIR']

# Create polynomial line
poly = PolynomialFeatures(degree=2)
poly_X = poly.fit_transform(X)

# Fit LinearRegression over poly data
poly_model = LinearRegression()
poly_model.fit(poly_X,y)

# Split values into 12 sets and predict
coefs = poly_model.coef_
standings = np.linspace(1, 12, 12).reshape(-1, 1)
poly_standings = poly.fit_transform(standings)
predictions = poly_model.predict(poly_standings)

# Graph Actual Standing to PIR. Provide relevant titles
plt.plot(standings, predictions)
plt.scatter(y=tank_team_ranks['team_PIR'], x=tank_team_ranks['standing'])
plt.title('Standing vs. PIR for Tank')
plt.xlabel('Standing')
plt.ylabel('PIR')
    
RSS.append([tank_team_ranks.name, ((y - predictions)**2).sum()])
    
print('RSS for', tank_team_ranks.name, ' = ',((y - predictions)**2).sum())
RSS for tanks  =  1.2865755991698622
In [47]:
# Reshape value to fit Regression model
X = damage_team_ranks[['standing']]
y = damage_team_ranks['team_PIR']

# Create polynomial line
poly = PolynomialFeatures(degree=2)
poly_X = poly.fit_transform(X)

# Fit LinearRegression over poly data
poly_model = LinearRegression()
poly_model.fit(poly_X,y)

# Split values into 12 sets and predict
coefs = poly_model.coef_
standings = np.linspace(1, 12, 12).reshape(-1, 1)
poly_standings = poly.fit_transform(standings)
predictions = poly_model.predict(poly_standings)

# Graph Actual Standing to PIR. Provide relevant titles
plt.plot(standings, predictions)
plt.scatter(y=damage_team_ranks['team_PIR'], x=damage_team_ranks['standing'])
plt.title('Standing vs. PIR for Damage')
plt.xlabel('Standing')
plt.ylabel('PIR')
    
RSS.append([damage_team_ranks.name, ((y - predictions)**2).sum()])
    
print('RSS for', damage_team_ranks.name, ' = ',((y - predictions)**2).sum())
RSS for damage  =  1.6823208231276627
In [48]:
# Reshape value to fit Regression model
X = support_team_ranks[['standing']]
y = support_team_ranks['team_PIR']

# Create polynomial line
poly = PolynomialFeatures(degree=2)
poly_X = poly.fit_transform(X)

# Fit LinearRegression over poly data
poly_model = LinearRegression()
poly_model.fit(poly_X,y)

# Split values into 12 sets and predict
coefs = poly_model.coef_
standings = np.linspace(1, 12, 12).reshape(-1, 1)
poly_standings = poly.fit_transform(standings)
predictions = poly_model.predict(poly_standings)

# Graph Actual Standing to PIR. Provide relevant titles
plt.plot(standings, predictions)
plt.scatter(y=support_team_ranks['team_PIR'], x=support_team_ranks['standing'])
plt.title('Standing vs. PIR for Support')
plt.xlabel('Standing')
plt.ylabel('PIR')
    
RSS.append([support_team_ranks.name, ((y - predictions)**2).sum()])
    
print('RSS for', support_team_ranks.name, ' = ',((y - predictions)**2).sum())
RSS for supports  =  0.977301972003016

Ideally, we would want each of our colored points to be as close as possible to its respective poly line as possible. This would imply our model perfectly predicts the outcome. However, as we can see from the graph, this is not the case. To find how far off we are for each role, we can use our RSS value that was calculated above and find the RSE value with the formula below:

RSE = Sqrt((1/(n-2)) * RSS)

In [49]:
import math 
for elem in RSS:
    RSE = math.sqrt((1/10) * elem[1])
    print('RSE for', elem[0], ' = ', RSE)
RSE for tanks  =  0.3586886671153498
RSE for damage  =  0.4101610443627799
RSE for supports  =  0.3126182931312587

Solving for each of the RSE values, we find that the support PIR performs the best out of the set. However, because this error value is still relatively high, lets try one final test where we aggregate each of the role’s PIR and train a model based on that.

In [50]:
# Reshape value to fit Regression model
X = all_team_ranks[['standing']]
y = all_team_ranks['team_PIR']

# Create polynomial line
poly = PolynomialFeatures(degree=2)
poly_X = poly.fit_transform(X)

# Fit LinearRegression over poly data
poly_model = LinearRegression()
poly_model.fit(poly_X,y)

# Split values into 12 sets and predict
coefs = poly_model.coef_
standings = np.linspace(1, 12, 12).reshape(-1, 1)
poly_standings = poly.fit_transform(standings)
predictions = poly_model.predict(poly_standings)

# Graph Actual Standing to PIR. Provide relevant titles
plt.plot(standings, predictions)
plt.scatter(y=all_team_ranks['team_PIR'], x=all_team_ranks['standing'])
plt.title('Standing vs. PIR for All Roles')
plt.xlabel('Standing')
plt.ylabel('PIR')

RSS = ((y - predictions)**2).sum()
    
print('RSS for all = ', RSS)
print('RSE for all = ', math.sqrt((1/10) * RSS))
RSS for all =  1.155814060890623
RSE for all =  0.33997265491368905

We see that support still is the best indicator for predicting the rankings with an RSE of around 0.3126. Meanwhile combining the roles moves us down to a 0.3399 RSE. Across all of these predictions, none of them acheive a great RSE rating, but do provide an interesting at-a-glance way of viewing the overall stage.

Communication of Insights¶

Overwatch 1 was an incredibly unique and nuanced game. On top of understanding the game itself, understanding the way in which they handle stats and professional play is another challenge. While this tutorial might have been confusing for many without a connection to Overwatch, we still managed to learn a lot about the data science pipeline. We read csv files using Pandas, cleaned up our data, and analyzed it with a variety of graphs. We also learned a little about hypothesis testing and regression models. We also saw that our support classification had the best RSS and RSE values to help predict the standings.

If you want to continue this experiment on your own, the OWL stats website offers all League data for each individual stage from 2018, its first year. There were also many things I glossed over in regard to the complexity of Overwatch. If you wanted to continue this project, taking some time to learn about the individual heroes and what they offer would be a good place to start. I hope this tutorial shed some light onto the enormous undertaking statistcal analysis can involve while also motivating you to try your own experiments. Good Luck!

In [ ]: