Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
hubkrieb committed Jun 10, 2024
0 parents commit ccd8d1b
Show file tree
Hide file tree
Showing 10 changed files with 548 additions and 0 deletions.
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# LoL Win Probabilities

LoL Win Probabilities is a project inspired by the work of the LoL Esports team in partnership with AWS on Win Probability for Worlds 2023 (article [here](https://lolesports.com/article/dev-diary-win-probability-powered-by-aws-at-worlds/blt403ee07f98e2e0fc))

## What is LoL Win Probabilities

LoL Win Probabilities uses Machine Learning to estimate a team's probability to win the game at any time of the game. The win probability is calculated by comparing the current game state with historical games in similar situations taking a large amount of metrics such as the game time and the gold difference into account.

It helps to better understand the anatomy of a given game and it gives insights on the value and efficiency of different strategies and objectives during the game.

<img src="images/winprob_chart.png" height="600">

## How does LoL Win Probabilities work

LoL Win Probabilities is based on a Machine Learning algorithm named XGBoost that is pre-trained on a large dataset of S14 Challenger SoloQ games from EUW, KR and NA servers.

The model takes into account a large set of features that represent the state of the game with as much details as possible.

* Game time
* Gold% (for each player)
* XP% (for each player)
* \# of players alive on each team
* \# of turrets taken (each type of turret is counted separately)
* Time before inhibitor respawn
* Herald
* \# of Void Grubs
* \# of Dragons (each elemental dragon counted separately)
* Dragon Soul
* Elder buff timer (for each player)
* \# of players with Elder buff
* Nashor buff timer (for each player)
* \# of players with Nashor buff

## Win Probability vs Gold Difference

The current go-to metric to estimate which team is more likely to win a game is the gold difference. The win probability takes more elements of the game into account (XP, dragons, void grubs...) and provides a more accurate and readable way to follow the state of a game. The win probability is more intuitive and doesn't need additional game knowledge to be understood whereas gold difference often needs more context. A win probability of 40% has always the same meaning during the game whereas a +2k gold difference has a different impact depending on the game time.

## Further Work

Further improvements can be made to the win probability model by better representing the state of the game. For example a good addition would be a team composition feature which would take champions scaling, team synergies and counters into account. Another axis to explore is to evaluate the impact of individual plays or decisions throughout the game to help players and teams to take the decision that would give them the best edge on the opponent.
Binary file added images/winprob_chart.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 39 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from sqlalchemy import create_engine
import os
import pandas as pd
from src.data.utils import get_games
from src.data.dataset import get_competitive_match_data
from src.data.features import get_features
from src.models.inference import predict
from src.models.utils import init_model

def main():
""" Update the database with new games data """

engine = create_engine(os.environ.get('DATABASE_URL'))

oldest_date = pd.to_datetime(pd.read_sql_query('SELECT MAX("DateTime_UTC") AS latest_date FROM competitive_games', con = engine)['latest_date'].values[0]).strftime('%y-%m-%d %H:%M:%S')
leagues = pd.read_sql_query('SELECT "League" FROM leagues', con = engine)['League'].values
new_games = get_games(leagues, oldest_date)
new_games = pd.DataFrame.from_dict([item["title"] for item in new_games])
new_games.to_csv('data/new_games.csv', index = None)
new_games = new_games.drop(columns = ['DateTime UTC__precision'])
new_games = new_games.rename(columns = {'N GameInMatch' : 'NGameInMatch', 'DateTime UTC' : 'DateTime_UTC', 'Name' : 'Tournament'})
new_games['DateTime_UTC'] = pd.to_datetime(new_games['DateTime_UTC'])
new_dataset = pd.DataFrame.from_dict(get_competitive_match_data(new_games['RiotPlatformGameId'].values, new_games['RiotVersion']))
new_features = get_features(new_dataset)
model = init_model('models/winprob_soloq_competitive.json')
preds = predict(model, new_features.drop(columns = ['match_id', 'winning_team']).values)
new_predictions = pd.DataFrame()
new_predictions['Id'] = new_dataset['match_id'] + '_' + new_features['minute'].astype(str)
new_predictions['RiotPlatformGameId'] = new_dataset['match_id']
new_predictions['Minute'] = new_features['minute']
new_predictions['GoldDiff'] = new_dataset[[f'player{i}_gold' for i in range(1, 6)]].sum(axis = 1) - new_dataset[[f'player{i}_gold' for i in range(6, 11)]].sum(axis = 1)
new_predictions['WinProbability'] = preds[:, 0]
new_games.to_sql('competitive_games', engine, if_exists = 'append', index = None)
new_predictions.to_sql('competitive_predictions', engine, if_exists = 'append', index = None)



if __name__ == '__main__':
main()
212 changes: 212 additions & 0 deletions src/data/dataset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
from src.data.utils import riot_api_request
import pandas as pd
import mwrogue.esports_client as ec

RIOT_API_KEY = 'RGAPI-faec5e54-6e5a-4dd8-a3ab-fc8746fdae31'

server_mapping = {'euw1' : 'europe', 'na1' : 'americas', 'kr' : 'asia'}

def get_puuids(server, tier):

url = f'https://{server}.api.riotgames.com/lol/league/v4/{tier}leagues/by-queue/RANKED_SOLO_5x5?api_key={RIOT_API_KEY}'

response = riot_api_request(url)
data = response.json()

def get_puuid(summonerId):
response = riot_api_request(f'https://{server}.api.riotgames.com/lol/summoner/v4/summoners/{summonerId}?api_key={RIOT_API_KEY}')
data = response.json()
return data['puuid']

puuids = [get_puuid(player['summonerId']) for player in data['entries']]

return puuids

def get_match_ids(server, puuids, count, start_time):

match_ids = []

for puuid in puuids:
url = f'https://{server_mapping[server]}.api.riotgames.com/lol/match/v5/matches/by-puuid/{puuid}/ids?startTime={start_time}&queue=420&type=ranked&start=0&count={count}&api_key={RIOT_API_KEY}'
response = riot_api_request(url)
data = response.json()
match_ids += data

return list(set(match_ids))

def get_soloq_match_data(match_ids, existing_dataset = None):

if existing_dataset is not None:
dataset = existing_dataset.to_dict('records')
else:
dataset = []

for i, match_id in enumerate(match_ids):
print(match_id)
server = match_id.split('_')[0].lower()
match_url = f'https://{server_mapping[server]}.api.riotgames.com/lol/match/v5/matches/{match_id}?api_key={RIOT_API_KEY}'
try:
match_response = riot_api_request(match_url)
except Exception as e:
print(e)
else:
match_data = match_response.json()
try:
end_of_game_result = match_data['info']['endOfGameResult']
except:
end_of_game_result = 'GameComplete'
if end_of_game_result == 'GameComplete' and match_data['info']['participants'][0]['gameEndedInEarlySurrender'] == False and match_data['info']['participants'][0]['gameEndedInSurrender'] == False and match_data['info']['gameDuration'] > 300:
winning_team = match_data['info']['teams'][0]['teamId'] // 100 if match_data['info']['teams'][0]['win'] else match_data['info']['teams'][1]['teamId'] // 100
champion_picks = {}
for i in range(10):
champion_picks[f'player{i + 1}_champion'] = match_data['info']['participants'][i]['championName']

timeline_url = f'https://{server_mapping[server]}.api.riotgames.com/lol/match/v5/matches/{match_id}/timeline?api_key={RIOT_API_KEY}'
timeline_response = riot_api_request(timeline_url)
timeline = timeline_response.json()
timeline_data = get_match_timeline_data(match_id, timeline, winning_team, champion_picks)
dataset += timeline_data
if i % 100 == 0:
dataset_df = pd.DataFrame.from_dict(dataset)
dataset_df.to_csv('data/dataset.csv', index = None)
return dataset

def get_competitive_match_data(rpg_ids, riot_versions, existing_dataset = None):

if existing_dataset is not None:
dataset = existing_dataset.to_dict('records')
else:
dataset = []

client = ec.EsportsClient('lol')

for i in range(len(rpg_ids)):
rpg_id = rpg_ids[i]
riot_version = riot_versions[i]
if rpg_id is not None and riot_version is not None:
print(rpg_id)
riot_version = int(riot_version)
timeline = {}
data, timeline['info'] = client.get_data_and_timeline(rpg_id, riot_version)
winning_team = 1 if data['teams'][0]['win'] else 2
champion_picks = {}
for i in range(10):
champion_picks[f'player{i + 1}_champion'] = data['participants'][i]['championName']
timeline_data = get_match_timeline_data(rpg_id, timeline, winning_team, champion_picks)
dataset += timeline_data
else:
print('None RiotPlatformGameId')
return dataset


def get_match_timeline_data(match_id, timeline, winning_team, champion_picks):

"""Base Respawn Wait time (BRW)"""
brw = [6, 6, 8, 8, 10, 12, 16, 21, 26, 32.5, 35, 37.5, 40, 42.5, 45, 47.5, 50, 52.5]

def get_tif(timestamp):
"""Get the Time Increase Factor (TIF) for Respawn Wait time"""
minutes = timestamp // 60000
if minutes < 15:
tif = 0
elif minutes < 30:
tif = 0 + 2 * (minutes - 15) * 0.425 / 100
elif minutes < 45:
tif = 12.75 + 2 * (minutes - 30) * 0.3 / 100
else:
tif = 21.75 + 2 * (minutes - 45) * 1.45 / 100
return tif

rows = []

respawn_tracker = {'player1_respawn' : 0, 'player2_respawn' : 0, 'player3_respawn' : 0, 'player4_respawn' : 0, 'player5_respawn' : 0, 'player6_respawn' : 0, 'player7_respawn' : 0, 'player8_respawn' : 0, 'player9_respawn' : 0, 'player10_respawn' : 0}
turret_tracker = {'team1_outerTurret' : 0, 'team1_innerTurret' : 0, 'team1_baseTurret' : 0, 'team1_nexusTurret' : 0, 'team2_outerTurret' : 0, 'team2_innerTurret' : 0, 'team2_baseTurret' : 0, 'team2_nexusTurret' : 0}
inhibitor_respawn_tracker = {'team1_topInhibitor' : 0, 'team1_midInhibitor' : 0, 'team1_botInhibitor' : 0, 'team2_topInhibitor' : 0, 'team2_midInhibitor' : 0, 'team2_botInhibitor' : 0}
dragon_tracker = {'team1_airDragon' : 0, 'team1_fireDragon' : 0, 'team1_hextechDragon' : 0, 'team1_chemtechDragon' : 0, 'team1_earthDragon' : 0, 'team1_waterDragon' : 0, 'team2_airDragon' : 0, 'team2_fireDragon' : 0, 'team2_hextechDragon' : 0, 'team2_chemtechDragon' : 0, 'team2_earthDragon' : 0, 'team2_waterDragon' : 0}
dragon_soul_tracker = {'team1_airDragonSoul' : 0, 'team1_fireDragonSoul' : 0, 'team1_hextechDragonSoul' : 0, 'team1_chemtechDragonSoul' : 0, 'team1_earthDragonSoul' : 0, 'team1_waterDragonSoul' : 0, 'team2_airDragonSoul' : 0, 'team2_fireDragonSoul' : 0, 'team2_hextechDragonSoul' : 0, 'team2_chemtechDragonSoul' : 0, 'team2_earthDragonSoul' : 0, 'team2_waterDragonSoul' : 0}
elder_dragon_tracker = {'team1_elderDragon' : 0, 'team2_elderDragon' : 0}
elder_buff_tracker = {'player1_elderDragon' : 0, 'player2_elderDragon' : 0, 'player3_elderDragon' : 0, 'player4_elderDragon' : 0, 'player5_elderDragon' : 0, 'player6_elderDragon' : 0, 'player7_elderDragon' : 0, 'player8_elderDragon' : 0, 'player9_elderDragon' : 0, 'player10_elderDragon' : 0}
nashor_tracker = {'team1_nashor' : 0, 'team2_nashor' : 0}
nashor_buff_tracker = {'player1_nashor' : 0, 'player2_nashor' : 0, 'player3_nashor' : 0, 'player4_nashor' : 0, 'player5_nashor' : 0, 'player6_nashor' : 0, 'player7_nashor' : 0, 'player8_nashor' : 0, 'player9_nashor' : 0, 'player10_nashor' : 0}
grub_tracker = {'team1_grub' : 0, 'team2_grub' : 0}
herald_tracker = {'team1_herald' : 0, 'team2_herald' : 0}

current_stats = {}
current_stats['match_id'] = match_id
current_stats['winning_team'] = winning_team
# Do not consider the last frame that corresponds to end game (nothing to predict and high bias with nexus turret destroyed?)
for frame in timeline['info']['frames'][:-1]:
current_stats['timestamp'] = frame['timestamp']
for event in frame['events']:
if event['type'] == 'LEVEL_UP':
current_stats[f'player{event["participantId"]}_level'] = event['level']
elif event['type'] == 'CHAMPION_KILL':
victim_id = event['victimId']
victim_level = current_stats[f'player{victim_id}_level']
kill_timestamp = event['timestamp']
respawn_time = (brw[victim_level - 1] + brw[victim_level - 1] * get_tif(kill_timestamp)) * 1000
respawn_tracker[f'player{int(victim_id)}_respawn'] = kill_timestamp + respawn_time
# Remove Nashor and Elder Dragon buffs
nashor_buff_tracker[f'player{int(victim_id)}_nashor'] = 0
elder_buff_tracker[f'player{int(victim_id)}_elderDragon'] = 0
elif event['type'] == 'BUILDING_KILL':
if event['buildingType'] == 'TOWER_BUILDING':
turret_type = event['towerType'].split('_')[0].lower()
turret_tracker[f'team{event["teamId"] // 100}_{turret_type}Turret'] += 1
elif event['buildingType'] == 'INHIBITOR_BUILDING':
lane = event['laneType'].split('_')[0].lower()
inhibitor_respawn_tracker[f'team{event["teamId"] // 100}_{lane}Inhibitor'] = event['timestamp'] + 5 * 60000
elif event['type'] == 'ELITE_MONSTER_KILL':
if event['monsterType'] == 'DRAGON':
if event['monsterSubType'] == 'ELDER_DRAGON':
elder_dragon_tracker[f'team{event["killerTeamId"] // 100}_elderDragon'] = event['timestamp'] + 150 * 1000
# Check players alive to give buff
if event["killerTeamId"] == 100:
for i in range(1, 6):
if respawn_tracker[f'player{i}_respawn'] <= event['timestamp']:
elder_buff_tracker[f'player{i}_elderDragon'] = event['timestamp'] + 150 * 1000
elif event["killerTeamId"] == 200:
for i in range(6, 11):
if respawn_tracker[f'player{i}_respawn'] <= event['timestamp']:
elder_buff_tracker[f'player{i}_elderDragon'] = event['timestamp'] + 150 * 1000
else:
dragon_type = event['monsterSubType'].split('_')[0].lower()
dragon_tracker[f'team{event["killerTeamId"] // 100}_{dragon_type}Dragon'] += 1
if dragon_tracker[f'team{event["killerTeamId"] // 100}_airDragon'] + dragon_tracker[f'team{event["killerTeamId"] // 100}_fireDragon'] + dragon_tracker[f'team{event["killerTeamId"] // 100}_hextechDragon'] + dragon_tracker[f'team{event["killerTeamId"] // 100}_chemtechDragon'] + dragon_tracker[f'team{event["killerTeamId"] // 100}_earthDragon'] + dragon_tracker[f'team{event["killerTeamId"] // 100}_waterDragon'] == 4:
dragon_soul_tracker[f'team{event["killerTeamId"] // 100}_{dragon_type}DragonSoul'] = 1
elif event['monsterType'] == 'BARON_NASHOR':
nashor_tracker[f'team{event["killerTeamId"] // 100}_nashor'] = event['timestamp'] + 180 * 1000
# Check players alive to give buff
if event["killerTeamId"] == 100:
for i in range(1, 6):
if respawn_tracker[f'player{i}_respawn'] <= event['timestamp']:
nashor_buff_tracker[f'player{i}_nashor'] = event['timestamp'] + 180 * 1000
elif event["killerTeamId"] == 200:
for i in range(6, 11):
if respawn_tracker[f'player{i}_respawn'] <= event['timestamp']:
nashor_buff_tracker[f'player{i}_nashor'] = event['timestamp'] + 180 * 1000
elif event['monsterType'] == 'HORDE':
if event['killerTeamId'] in [100, 200]:
grub_tracker[f'team{event["killerTeamId"] // 100}_grub'] += 1
elif event['monsterType'] == 'RIFTHERALD':
if event['killerTeamId'] in [100, 200]:
herald_tracker[f'team{event["killerTeamId"] // 100}_herald'] = 1
elif event['type'] == 'ITEM_DESTROYED':
if event['itemId'] == 3513:
herald_tracker[f'team{int(event["participantId"] > 5) + 1}_herald'] = 0


participantFrames = frame['participantFrames']

for i in range(1, 11):
participant = str(i)
current_stats[f'player{participant}_gold'] = participantFrames[participant]['totalGold']
current_stats[f'player{participant}_xp'] = participantFrames[participant]['xp']
current_stats[f'player{participant}_level'] = participantFrames[participant]['level']

if respawn_tracker[f'player{int(participant)}_respawn'] > frame['timestamp']:
current_stats[f'player{participant}_isAlive'] = 0
else:
current_stats[f'player{participant}_isAlive'] = 1
rows.append({**current_stats, **champion_picks, **respawn_tracker, **turret_tracker, **inhibitor_respawn_tracker, **dragon_tracker, **dragon_soul_tracker, **elder_dragon_tracker, **elder_buff_tracker, **nashor_tracker, **nashor_buff_tracker, **herald_tracker, **grub_tracker})
return rows
Loading

0 comments on commit ccd8d1b

Please sign in to comment.