Implement RNR rating adjustment

This commit is contained in:
Kiana Sheibani 2023-10-04 17:58:54 -04:00
parent 1b603bf727
commit 2e3bd017f3
Signed by: toki
GPG key ID: 6CB106C25E86A9F7
5 changed files with 240 additions and 75 deletions

View file

@ -7,7 +7,8 @@ network of relative advantages between players.
Once the advantage network is generated, StartRNR can be used to predict the Once the advantage network is generated, StartRNR can be used to predict the
probability of a player winning a match, generate provably optimal seedings for probability of a player winning a match, generate provably optimal seedings for
tournaments, and create rankings of players automatically. tournaments, inspect the match history of two players, and create competitive
rankings automatically.
**All of these features work for any game, in any region, without restriction.** **All of these features work for any game, in any region, without restriction.**

View file

@ -8,6 +8,7 @@ use std::time::SystemTime;
pub struct DatasetMetadata { pub struct DatasetMetadata {
pub last_sync: Timestamp, pub last_sync: Timestamp,
pub game_id: VideogameId, pub game_id: VideogameId,
pub game_name: String, pub game_name: String,
pub state: Option<String>, pub state: Option<String>,
@ -33,9 +34,11 @@ pub fn open_datasets(config_dir: &Path) -> sqlite::Result<Connection> {
let path = datasets_path(config_dir).unwrap(); let path = datasets_path(config_dir).unwrap();
let query = " let query = "
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS datasets ( CREATE TABLE IF NOT EXISTS datasets (
name TEXT UNIQUE NOT NULL, name TEXT UNIQUE NOT NULL,
last_sync INTEGER DEFAULT 1, last_sync INTEGER NOT NULL,
game_id INTEGER NOT NULL, game_id INTEGER NOT NULL,
game_name TEXT NOT NULL, game_name TEXT NOT NULL,
state TEXT state TEXT
@ -82,7 +85,8 @@ pub fn list_datasets(connection: &Connection) -> sqlite::Result<Vec<(String, Dat
pub fn delete_dataset(connection: &Connection, dataset: &str) -> sqlite::Result<()> { pub fn delete_dataset(connection: &Connection, dataset: &str) -> sqlite::Result<()> {
let query = format!( let query = format!(
r#"DELETE FROM datasets WHERE name = '{0}'; r#"DELETE FROM datasets WHERE name = '{0}';
DROP TABLE "dataset_{0}";"#, DROP TABLE "dataset_{0}_players";
DROP TABLE "dataset_{0}_network";"#,
dataset dataset
); );
@ -97,11 +101,25 @@ pub fn new_dataset(
let query1 = r#"INSERT INTO datasets (name, game_id, game_name, state) let query1 = r#"INSERT INTO datasets (name, game_id, game_name, state)
VALUES (?, ?, ?, ?)"#; VALUES (?, ?, ?, ?)"#;
let query2 = format!( let query2 = format!(
r#" CREATE TABLE "dataset_{0}" ( r#"
CREATE TABLE "dataset_{0}_players" (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT, name TEXT,
prefix TEXT, prefix TEXT
elo REAL NOT NULL );
CREATE TABLE "dataset_{0}_network" (
player_A INTEGER NOT NULL,
player_B INTEGER NOT NULL,
advantage REAL NOT NULL,
sets_A INTEGER NOT NULL,
sets_B INTEGER NOT NULL,
games_A INTEGER NOT NULL,
games_B INTEGER NOT NULL,
UNIQUE (player_A, player_B),
CHECK (player_A < player_B),
FOREIGN KEY(player_A, player_B) REFERENCES "dataset_{0}_players"
ON DELETE CASCADE
) STRICT;"#, ) STRICT;"#,
dataset dataset
); );
@ -165,7 +183,7 @@ pub fn add_players(
teams: &Teams<PlayerData>, teams: &Teams<PlayerData>,
) -> sqlite::Result<()> { ) -> sqlite::Result<()> {
let query = format!( let query = format!(
r#"INSERT OR IGNORE INTO "dataset_{}" VALUES (?, ?, ?, 1500)"#, r#"INSERT OR IGNORE INTO "dataset_{}_players" VALUES (?, ?, ?)"#,
dataset dataset
); );
@ -180,43 +198,160 @@ pub fn add_players(
}) })
} }
pub fn get_ratings( pub fn get_advantage(
connection: &Connection, connection: &Connection,
dataset: &str, dataset: &str,
teams: &Teams<PlayerData>, player1: PlayerId,
) -> sqlite::Result<Teams<(PlayerId, f64)>> { player2: PlayerId,
let query = format!(r#"SELECT id, elo FROM "dataset_{}" WHERE id = ?"#, dataset); ) -> sqlite::Result<f64> {
if player1 == player2 {
return Ok(0.0);
}
teams let query = format!(
.iter() r#"SELECT iif(:a > :b, -advantage, advantage) FROM "dataset_{}_network"
.map(|team| { WHERE player_A = min(:a, :b) AND player_B = max(:a, :b)"#,
team.iter() dataset
.map(|data| { );
let mut statement = connection.prepare(&query)?;
statement.bind((1, data.id.0 as i64))?; let mut statement = connection.prepare(&query)?;
statement.next()?; statement.bind((":a", player1.0 as i64))?;
Ok((data.id, statement.read::<f64, _>("elo")?)) statement.bind((":b", player2.0 as i64))?;
}) statement.next()?;
.try_collect() statement.read::<f64, _>("advantage")
}
pub fn adjust_advantage(
connection: &Connection,
dataset: &str,
player1: PlayerId,
player2: PlayerId,
adjust: f64,
) -> sqlite::Result<()> {
let query = format!(
r#"UPDATE "dataset_{}_network"
SET advantage = advantage + iif(:a > :b, -:v, :v)
WHERE player_A = min(:a, :b) AND player_B = max(:a, :b)"#,
dataset
);
let mut statement = connection.prepare(&query)?;
statement.bind((":a", player1.0 as i64))?;
statement.bind((":b", player2.0 as i64))?;
statement.bind((":v", adjust))?;
statement.into_iter().try_for_each(|x| x.map(|_| ()))
}
pub fn adjust_advantages(
connection: &Connection,
dataset: &str,
player: PlayerId,
adjust: f64,
) -> sqlite::Result<()> {
let query = format!(
r#"UPDATE "dataset_{}_network"
SET advantage = advantage + iif(:pl = player_A, -:v, :v)
WHERE player_A = :pl OR player_B = :pl"#,
dataset
);
let mut statement = connection.prepare(&query)?;
statement.bind((":pl", player.0 as i64))?;
statement.bind((":v", adjust))?;
statement.into_iter().try_for_each(|x| x.map(|_| ()))
}
pub fn get_edges(
connection: &Connection,
dataset: &str,
player: PlayerId,
) -> sqlite::Result<Vec<(PlayerId, f64)>> {
let query = format!(
r#"SELECT iif(:pl = player_B, player_A, player_B) AS id, iif(:pl = player_B, -advantage, advantage) AS advantage
FROM "dataset_{}_network"
WHERE player_A = :pl OR player_B = :pl"#,
dataset
);
connection
.prepare(&query)?
.into_iter()
.bind((":pl", player.0 as i64))?
.map(|r| {
let r_ = r?;
Ok((
PlayerId(r_.read::<i64, _>("id") as u64),
r_.read::<f64, _>("advantage"),
))
}) })
.try_collect() .try_collect()
} }
pub fn update_ratings( pub fn get_path_advantage(
connection: &Connection, connection: &Connection,
dataset: &str, dataset: &str,
elos: Teams<(PlayerId, f64)>, players: &[PlayerId],
) -> sqlite::Result<()> { ) -> sqlite::Result<f64> {
let query = format!( players.windows(2).try_fold(0.0, |acc, [a, b]| {
r#"UPDATE "dataset_{}" SET elo = :elo WHERE id = :id"#, Ok(acc + get_advantage(connection, dataset, *a, *b)?)
dataset
);
elos.into_iter().try_for_each(|team| {
team.into_iter().try_for_each(|(id, elo)| {
let mut statement = connection.prepare(&query)?;
statement.bind((":elo", elo))?;
statement.bind((":id", id.0 as i64))?;
statement.into_iter().try_for_each(|x| x.map(|_| ()))
})
}) })
} }
pub fn hypothetical_advantage(
connection: &Connection,
dataset: &str,
player1: PlayerId,
player2: PlayerId,
) -> sqlite::Result<f64> {
if player1 == player2 {
return Ok(0.0);
}
let mut paths: Vec<Vec<(Vec<PlayerId>, f64)>> = vec![vec![(vec![player1], 0.0)]];
for _ in 2..=6 {
let new_paths = paths.last().unwrap().into_iter().cloned().try_fold(
Vec::new(),
|mut acc, (path, adv)| {
acc.extend(
get_edges(connection, dataset, *path.last().unwrap())?
.into_iter()
.map(|(x, next_adv)| {
let mut path = path.clone();
path.extend_one(x);
(path, adv + next_adv)
}),
);
Ok(acc)
},
)?;
paths.extend_one(new_paths);
}
let mut shortest_len = 0;
Ok(paths[1..]
.into_iter()
.enumerate()
.map(|(i, ps)| {
let num_ps = ps.len();
if num_ps == 0 {
return 0.0;
}
if shortest_len == 0 {
shortest_len = i + 1;
}
ps.into_iter()
.filter_map(|(path, adv)| {
if *path.last().unwrap() == player2 {
Some(adv)
} else {
None
}
})
.sum::<f64>()
/ num_ps as f64
* (0.5_f64.powi((i - shortest_len) as i32))
})
.sum())
}

View file

@ -1,4 +1,5 @@
#![feature(iterator_try_collect)] #![feature(iterator_try_collect)]
#![feature(extend_one)]
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::io::{self, Write}; use std::io::{self, Write};
@ -51,7 +52,7 @@ AUTH_TOKEN, or in a text file '<CONFIG_DIR>/auth.txt'."
value_name = "DIR", value_name = "DIR",
global = true, global = true,
help = "Config directory", help = "Config directory",
long_help = "This option overrides the default config directory. long_help = "This flag overrides the default config directory.
If this directory does not exist, it will be created and a database file will If this directory does not exist, it will be created and a database file will
be initialized within it." be initialized within it."
)] )]
@ -67,9 +68,9 @@ enum Subcommands {
}, },
#[command( #[command(
about = "Sync player ratings", about = "Sync player ratings",
long_about = "Pull recent tournament data off of start.gg and use it to update each player's long_about = "Pull recent tournament data off of start.gg and use it to
stored ratings. This command will automatically keep track of the last time each update the network. This command will automatically keep track of the last time each
dataset was synced." dataset was synced to ensure that each tournament is only accounted for once."
)] )]
Sync { Sync {
#[arg( #[arg(

View file

@ -52,19 +52,19 @@ pub fn get_auth_token(config_dir: &Path) -> Option<String> {
#[cynic(graphql_type = "ID")] #[cynic(graphql_type = "ID")]
pub struct VideogameId(pub u64); pub struct VideogameId(pub u64);
#[derive(cynic::Scalar, Debug, Copy, Clone)] #[derive(cynic::Scalar, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[cynic(graphql_type = "ID")] #[cynic(graphql_type = "ID")]
pub struct EventId(pub u64); pub struct EventId(pub u64);
#[derive(cynic::Scalar, Debug, Copy, Clone)] #[derive(cynic::Scalar, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[cynic(graphql_type = "ID")] #[cynic(graphql_type = "ID")]
pub struct EntrantId(pub u64); pub struct EntrantId(pub u64);
#[derive(cynic::Scalar, Debug, Copy, Clone)] #[derive(cynic::Scalar, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[cynic(graphql_type = "ID")] #[cynic(graphql_type = "ID")]
pub struct PlayerId(pub u64); pub struct PlayerId(pub u64);
#[derive(cynic::Scalar, Debug, Copy, Clone)] #[derive(cynic::Scalar, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Timestamp(pub u64); pub struct Timestamp(pub u64);
// Query machinery // Query machinery
@ -89,6 +89,7 @@ where
.header("Authorization", String::from("Bearer ") + auth_token) .header("Authorization", String::from("Bearer ") + auth_token)
.run_graphql(Builder::build(vars)); .run_graphql(Builder::build(vars));
// If query fails to reach server, retry up to 10 times
for _ in 1..10 { for _ in 1..10 {
if response.is_ok() { if response.is_ok() {
break; break;

View file

@ -8,31 +8,31 @@ use sqlite::*;
// Score calculation // Score calculation
/// Calculate the collective expected score for each team. // /// Calculate the collective expected score for each team.
fn expected_scores(ratings: &Teams<&mut f64>) -> Vec<f64> { // fn expected_scores(ratings: &Teams<&mut f64>) -> Vec<f64> {
let qs: Vec<f64> = ratings // let qs: Vec<f64> = ratings
.into_iter() // .into_iter()
.map(|es| 10_f64.powf(es.iter().map(|x| **x).sum::<f64>() / es.len() as f64 / 400.0)) // .map(|es| 10_f64.powf(es.iter().map(|x| **x).sum::<f64>() / es.len() as f64 / 400.0))
.collect(); // .collect();
let sumq: f64 = qs.iter().sum(); // let sumq: f64 = qs.iter().sum();
qs.into_iter().map(|q| q / sumq).collect() // qs.into_iter().map(|q| q / sumq).collect()
} // }
/// Adjust the ratings of each player based on who won. // /// Adjust the ratings of each player based on who won.
fn adjust_ratings(ratings: Teams<&mut f64>, winner: usize) { // fn adjust_ratings(ratings: Teams<&mut f64>, winner: usize) {
let exp_scores = expected_scores(&ratings); // let exp_scores = expected_scores(&ratings);
ratings // ratings
.into_iter() // .into_iter()
.zip(exp_scores.into_iter()) // .zip(exp_scores.into_iter())
.enumerate() // .enumerate()
.for_each(|(i, (es, exp_sc))| { // .for_each(|(i, (es, exp_sc))| {
let len = es.len() as f64; // let len = es.len() as f64;
let score = f64::from(winner == i); // let score = f64::from(winner == i);
es.into_iter() // es.into_iter()
.for_each(|e| *e += 40.0 * (score - exp_sc) / len); // .for_each(|e| *e += 40.0 * (score - exp_sc) / len);
}) // })
} // }
// Extract set data // Extract set data
@ -135,14 +135,41 @@ fn update_from_set(connection: &Connection, dataset: &str, results: SetData) ->
let players_data = results.teams; let players_data = results.teams;
add_players(connection, dataset, &players_data)?; add_players(connection, dataset, &players_data)?;
let mut elos = get_ratings(connection, dataset, &players_data)?; // Singles matches are currently not supported
adjust_ratings( if players_data.len() != 2 || players_data[0].len() != 1 || players_data[1].len() != 1 {
elos.iter_mut() return Ok(());
.map(|v| v.iter_mut().map(|x| &mut x.1).collect()) }
.collect(),
results.winner, let mut it = players_data.into_iter();
); let player1 = it.next().unwrap()[0].id;
update_ratings(connection, dataset, elos) let player2 = it.next().unwrap()[0].id;
let advantage = get_advantage(connection, dataset, player1, player2)
.or_else(|_| hypothetical_advantage(connection, dataset, player1, player2))?;
let adjust = 40.0 * (1.0 - 1.0 / (1.0 + 10_f64.powf(advantage / 400.0)));
if results.winner == 0 {
adjust_advantages(connection, dataset, player1, 0.5 * adjust)?;
adjust_advantages(connection, dataset, player2, -0.5 * adjust)?;
adjust_advantage(
connection,
dataset,
player1,
player2,
-2.0 * (1.0 - 0.5) * adjust,
)?;
} else {
adjust_advantages(connection, dataset, player1, -0.5 * adjust)?;
adjust_advantages(connection, dataset, player2, 0.5 * adjust)?;
adjust_advantage(
connection,
dataset,
player1,
player2,
2.0 * (1.0 - 0.5) * adjust,
)?;
}
Ok(())
} }
pub fn sync_dataset( pub fn sync_dataset(