Compare commits

..

21 commits

Author SHA1 Message Date
edcb230365 Merge branch 'main' of https://git.abstractnonsen.se/Hanabi/hanabi-league into minimize-loss 2024-02-06 15:43:47 +08:00
posij118
333a3726fd Clip initializations 2024-02-05 21:58:49 +08:00
posij118
1de9bd7fff Decouple reading data 2024-02-05 21:52:11 +08:00
posij118
628f7ef041 Optimize and fix index bug 2024-02-05 21:34:56 +08:00
posij118
67a7662da3 Publish results for N=50 inits 2024-02-05 21:28:48 +08:00
posij118
23a265b3c7 Remove breaking change in constants.py, move default_config instead to run locally 2024-02-05 21:27:35 +08:00
posij118
11ec939510 Add loss minimization rating algorithm with data and results 2024-02-05 16:35:26 +08:00
eb833db3ce
change database schema: include winrate 2024-01-20 13:32:06 +01:00
e6d24d7b7b
link to game sites instead of replays 2024-01-14 13:49:37 +01:00
cf9a81979a
generate game pages also for games with no endgame analysis 2024-01-14 13:48:49 +01:00
145142c4a9
improve game sites 2024-01-14 13:38:18 +01:00
96c6fc0df2
improve endgame table output 2024-01-14 13:11:02 +01:00
e4635461c1
fix table style 2024-01-14 12:57:59 +01:00
1f8d0b867c
Render endgame statistics to website 2024-01-14 00:56:41 +01:00
60aa757ba0
expand cli for endgames 2024-01-13 15:38:57 +01:00
816bf0d940
add worker thread for endgame analysis 2024-01-13 15:35:49 +01:00
20f4cfc67e
convenience function 2024-01-13 14:35:24 +01:00
bfe83d4f43
load endgame actions from db 2024-01-13 14:32:48 +01:00
5c6c8a6b14
Add functions to analyze endgames with external program 2024-01-13 14:27:45 +01:00
25cfd06f1b
change database format 2024-01-12 20:20:23 +01:00
d717a9df36
introduce endgames table 2024-01-12 19:54:27 +01:00
14 changed files with 26524 additions and 10 deletions

View file

@ -85,3 +85,7 @@ body {
.history-bullets ul { .history-bullets ul {
flex-direction: column; flex-direction: column;
} }
.endgame-table td, .endgame-table th {
border: 1px solid black;
}

25134
data/games.json Normal file

File diff suppressed because it is too large Load diff

2
deps/py-hanabi vendored

@ -1 +1 @@
Subproject commit 3ac51d574e65aff9b3420fdebd467d7b98ea1d28 Subproject commit 51e09cd94393de64e07191d6ca544139417acb3b

View file

@ -16,10 +16,15 @@ import fetch_games
import ratings import ratings
import stats import stats
import render_site import render_site
import endgames
from log_setup import logger from log_setup import logger
def subcommand_analyze_endgames():
endgames.work_thread()
def subcommand_init(force: bool, no_fetch_variants: bool): def subcommand_init(force: bool, no_fetch_variants: bool):
tables = database.get_existing_tables() tables = database.get_existing_tables()
if len(tables) > 0 and not force: if len(tables) > 0 and not force:
@ -101,6 +106,7 @@ def get_parser() -> argparse.ArgumentParser:
subparsers.add_parser('process-ratings', help="Process ratings of all games.") subparsers.add_parser('process-ratings', help="Process ratings of all games.")
subparsers.add_parser('process-stats', help="Process statistics for all players.") subparsers.add_parser('process-stats', help="Process statistics for all players.")
subparsers.add_parser('generate-site', help="Generate the website from the DB.") subparsers.add_parser('generate-site', help="Generate the website from the DB.")
subparsers.add_parser('analyze-endgames', help="Run endgame analysis on games in DB. Resource intensive!")
subparsers.add_parser('run', help="Run the automatic suite: Fetch + process games and render site.") subparsers.add_parser('run', help="Run the automatic suite: Fetch + process games and render site.")
fetch_parser = subparsers.add_parser('fetch', help='Fetch new data.') fetch_parser = subparsers.add_parser('fetch', help='Fetch new data.')
@ -122,6 +128,7 @@ def main():
'generate-site': subcommand_generate_site, 'generate-site': subcommand_generate_site,
'fetch': subcommand_fetch, 'fetch': subcommand_fetch,
'run': subcommand_run, 'run': subcommand_run,
'analyze-endgames': subcommand_analyze_endgames
}[args.command] }[args.command]
if args.verbose: if args.verbose:

View file

@ -359,3 +359,45 @@ CREATE TABLE user_statistics (
average_game_moves REAL GENERATED ALWAYS AS (CASE WHEN games_played != 0 THEN CAST(total_game_moves AS REAL) / games_played ELSE NULL END) STORED, average_game_moves REAL GENERATED ALWAYS AS (CASE WHEN games_played != 0 THEN CAST(total_game_moves AS REAL) / games_played ELSE NULL END) STORED,
PRIMARY KEY (user_id, variant_type) PRIMARY KEY (user_id, variant_type)
); );
DROP TABLE IF EXISTS endgames;
CREATE TABLE endgames (
game_id INTEGER REFERENCES games (id),
turn SMALLINT,
/**
* We want to be able to store probabilities for different actions that can be taken.
* Action type can be
0 for play actions
1 for discard actions
2 for clues
*/
action_type SMALLINT CHECK (0 <= action_type AND action_type <= 2),
/**
We store cards as (suit_index, rank) here for uniqueness of representation.
If we want to refer to known trash, we will use (0,0) as representation.
*/
suit_index SMALLINT, /* 0 for clue actions */
rank SMALLINT, /* 0 for clue actions */
enumerator BIGINT NOT NULL CHECK (enumerator >= 0),
denominator BIGINT NOT NULL CHECK (denominator > 0),
chance REAL GENERATED ALWAYS AS (CAST(enumerator AS REAL) / denominator) STORED,
PRIMARY KEY (game_id, turn, action_type, suit_index, rank)
);
/**
We store separately whether we analyzed a certain game already and what the termination reason for the analysis was:
0 if evaluation completed within specified time and memory
1 if evaluation ran into timeout
2 if evaluation was empty because state is unreachable
3 if evaluation ran out of memory
This is also necessary because for some endgames, because in case 2 we will not have data,
simply because the game replay ended too early.
To avoid re-analyzing these seeds, we mark all seeds analyzed in this table.
*/
DROP TABLE IF EXISTS endgames_analyzed;
CREATE TABLE endgames_analyzed (
game_id INTEGER REFERENCES games (id),
termination_reason SMALLINT NOT NULL,
PRIMARY KEY (game_id)
);

View file

@ -0,0 +1,468 @@
{
"players": [
{
"id": 1,
"rating": 1656.9933584592068,
"username": "asaelr",
"stdev": 176.95337811507542
},
{
"id": 6,
"rating": 1547.1931967851367,
"username": "ElenaDhynho",
"stdev": 42.422674257282964
},
{
"id": 7,
"rating": 1596.6512180445707,
"username": "Eliclax",
"stdev": 25.554046049882707
},
{
"id": 8,
"rating": 1687.0065708056572,
"username": "Fafrd",
"stdev": 26.138849069401964
},
{
"id": 9,
"rating": 1495.04192425988,
"username": "Feich59",
"stdev": 73.58522619789503
},
{
"id": 10,
"rating": 1613.9053316660961,
"username": "gw12346",
"stdev": 15.446846483972713
},
{
"id": 11,
"rating": 1534.2115497751606,
"username": "hakha",
"stdev": 17.89293430394373
},
{
"id": 12,
"rating": 1702.8665184294564,
"username": "Helana",
"stdev": 17.959192573261596
},
{
"id": 13,
"rating": 1592.3685480643976,
"username": "hnter",
"stdev": 109.75405196408813
},
{
"id": 14,
"rating": 1684.5329659244817,
"username": "inseres",
"stdev": 5.193191918103997
},
{
"id": 15,
"rating": 1678.1068142163554,
"username": "Jay",
"stdev": 11.220557444400496
},
{
"id": 16,
"rating": 1657.7422053946832,
"username": "kimbifille",
"stdev": 71.62732692961684
},
{
"id": 17,
"rating": 1571.2263147756535,
"username": "Kowalski1337",
"stdev": 59.88063309820496
},
{
"id": 18,
"rating": 1673.652278716787,
"username": "macanek",
"stdev": 37.248874749072684
},
{
"id": 19,
"rating": 1734.2840478574078,
"username": "MarkusKahlsen",
"stdev": 137.263308306954
},
{
"id": 20,
"rating": 1599.1013632247043,
"username": "Neema",
"stdev": 29.99279133170806
},
{
"id": 21,
"rating": 1535.5621323285413,
"username": "newduke",
"stdev": 28.69029514671429
},
{
"id": 22,
"rating": 1439.1271150938064,
"username": "NishaNoire",
"stdev": 135.00876312408192
},
{
"id": 25,
"rating": 1589.9273430803019,
"username": "omegaxis",
"stdev": 45.681434487254656
},
{
"id": 27,
"rating": 1719.856499810081,
"username": "pianoblook",
"stdev": 43.91423347247681
},
{
"id": 28,
"rating": 1645.6070307223388,
"username": "posij118",
"stdev": 69.56052128100919
},
{
"id": 29,
"rating": 1700.7312163612453,
"username": "purplejoe",
"stdev": 4.333849603201881
},
{
"id": 31,
"rating": 1754.8673876868077,
"username": "rahsosprout",
"stdev": 24.20149808460386
},
{
"id": 32,
"rating": 1749.8186567295743,
"username": "Ramanujan",
"stdev": 35.186989633353924
},
{
"id": 33,
"rating": 1505.8114349633183,
"username": "ricardodd",
"stdev": 63.063499555331056
},
{
"id": 34,
"rating": 1424.7454795953922,
"username": "RIMBarisax",
"stdev": 54.62925500990815
},
{
"id": 35,
"rating": 1709.7754274336994,
"username": "rz",
"stdev": 34.75350346761236
},
{
"id": 36,
"rating": 1645.505505262562,
"username": "Sagnik Saha",
"stdev": 82.56415392788715
},
{
"id": 37,
"rating": 1607.8618746654392,
"username": "sodiumdebt",
"stdev": 74.74531010470325
},
{
"id": 38,
"rating": 1799.3860289683867,
"username": "spring",
"stdev": 6.935110645012115
},
{
"id": 39,
"rating": 1547.0924841832866,
"username": "StKildaFan",
"stdev": 22.21102514134186
},
{
"id": 40,
"rating": 1676.6787745059055,
"username": "str8tsknacker",
"stdev": 50.74354553034183
},
{
"id": 41,
"rating": 1731.4770087590437,
"username": "Sturm",
"stdev": 57.142346148910896
},
{
"id": 42,
"rating": 1501.164246026532,
"username": "TimeHoodie",
"stdev": 35.10667883309847
},
{
"id": 43,
"rating": 1655.211023199,
"username": "vEnhance",
"stdev": 42.389156840017165
},
{
"id": 44,
"rating": 1533.6010216854027,
"username": "vermling",
"stdev": 90.93922443450435
},
{
"id": 45,
"rating": 1586.770150886957,
"username": "wateroffire",
"stdev": 3.9655080603422466
},
{
"id": 46,
"rating": 1614.507394752425,
"username": "WillFlame",
"stdev": 9.831543835666807
},
{
"id": 47,
"rating": 1713.6463481565615,
"username": "Yagami Black",
"stdev": 25.99563483333066
},
{
"id": 48,
"rating": 1457.1982216219058,
"username": "youisme",
"stdev": 81.81998675205648
},
{
"id": 51,
"rating": 1437.5089899938134,
"username": "Libster",
"stdev": 8.378590826005142
},
{
"id": 52,
"rating": 1672.8512878685628,
"username": "maxeymo",
"stdev": 36.0712267697332
},
{
"id": 53,
"rating": 1642.5304416034514,
"username": "joano580",
"stdev": 100.76861593939701
},
{
"id": 54,
"rating": 1550.2628143305851,
"username": "ReaverSe",
"stdev": 17.2818922023245
},
{
"id": 55,
"rating": 1642.9660148054686,
"username": "benzloeb",
"stdev": 3.4106951597543786
},
{
"id": 56,
"rating": 1598.4171994249805,
"username": "percolate",
"stdev": 30.720436445911385
},
{
"id": 58,
"rating": 1464.6446630379342,
"username": "Random Guy JCI",
"stdev": 12.310038039511529
},
{
"id": 59,
"rating": 1606.062637102755,
"username": "aara",
"stdev": 21.876906585784877
},
{
"id": 60,
"rating": 1601.4626434355132,
"username": "amattias",
"stdev": 33.079466261234664
},
{
"id": 64,
"rating": 1601.5458400329035,
"username": "wtfitsnotbutter",
"stdev": 53.56517099030838
},
{
"id": 65,
"rating": 1480.6158683098095,
"username": "Alix_Eisenhardt",
"stdev": 50.30711678640308
},
{
"id": 67,
"rating": 1617.5730419198144,
"username": "Vivarus",
"stdev": 9.56814210631875
},
{
"id": 69,
"rating": 1543.1725821109499,
"username": "FrozenStella",
"stdev": 47.349220108955066
},
{
"id": 70,
"rating": 1589.17602492783,
"username": "GameConqueror",
"stdev": 34.77764553932631
},
{
"id": 71,
"rating": 1631.0886330291135,
"username": "seungapark",
"stdev": 36.35644480232967
},
{
"id": 72,
"rating": 1544.058017455111,
"username": "sinuni_hung",
"stdev": 8.068740309638756
},
{
"id": 73,
"rating": 1652.5253246400134,
"username": "TheDaniMan",
"stdev": 43.89447022365573
},
{
"id": 79,
"rating": 1487.3404436278843,
"username": "Kaznad",
"stdev": 15.830803035745209
},
{
"id": 80,
"rating": 1392.331302957674,
"username": "Kernel",
"stdev": 42.02247099621528
},
{
"id": 81,
"rating": 1428.4800076632162,
"username": "Le Codex",
"stdev": 43.789622164916196
},
{
"id": 82,
"rating": 1521.363531664333,
"username": "Nipalup",
"stdev": 104.0370045655497
}
],
"variants": [
{
"id": 0,
"rating": 1359.6612370979074,
"num_players": 3,
"name": "No Variant",
"num_suits": 5,
"stdev": 37.51830058593259
},
{
"id": 1,
"rating": 1409.8285544138594,
"num_players": 3,
"name": "6 Suits",
"num_suits": 6,
"stdev": 56.644590533491034
},
{
"id": 51,
"rating": 1719.7607226091918,
"num_players": 3,
"name": "Clue Starved (6 Suits)",
"num_suits": 6,
"stdev": 8.202101579431847
},
{
"id": 52,
"rating": 1662.868864963005,
"num_players": 3,
"name": "Clue Starved (5 Suits)",
"num_suits": 5,
"stdev": 8.674234234615462
},
{
"id": 0,
"rating": 1439.4451347766544,
"num_players": 4,
"name": "No Variant",
"num_suits": 5,
"stdev": 2.016825515539241
},
{
"id": 1,
"rating": 1434.2720502574575,
"num_players": 4,
"name": "6 Suits",
"num_suits": 6,
"stdev": 29.836778320728673
},
{
"id": 51,
"rating": 1779.845972756269,
"num_players": 4,
"name": "Clue Starved (6 Suits)",
"num_suits": 6,
"stdev": 47.696178458669856
},
{
"id": 52,
"rating": 1701.8431047063805,
"num_players": 4,
"name": "Clue Starved (5 Suits)",
"num_suits": 5,
"stdev": 42.02213931310847
},
{
"id": 0,
"rating": 1426.7937800667978,
"num_players": 5,
"name": "No Variant",
"num_suits": 5,
"stdev": 54.921511042199526
},
{
"id": 1,
"rating": 1495.2514337712873,
"num_players": 5,
"name": "6 Suits",
"num_suits": 6,
"stdev": 43.16247744670021
},
{
"id": 51,
"rating": 1898.843058275536,
"num_players": 5,
"name": null,
"num_suits": 6,
"stdev": 22.62832823639932
},
{
"id": 52,
"rating": 1916.5106100141977,
"num_players": 5,
"name": "Clue Starved (5 Suits)",
"num_suits": 5,
"stdev": 37.61403719598357
}
]
}

View file

@ -48,5 +48,9 @@ USER_HISTORY_CACHE_TIME = 60 * 60
# Fraction of seeds which is assumed to be unwinnable # Fraction of seeds which is assumed to be unwinnable
UNWINNABLE_SEED_FRACTION = 0.02 UNWINNABLE_SEED_FRACTION = 0.02
WEBSITE_OUTPUT_DIRECTORY = 'build' WEBSITE_OUTPUT_DIRECTORY = 'build'
ENDGAME_MAX_DRAW_PILE_SIZE = 15 # Not interested in game states with more than 15 cards, this should be enough.
ENDGAME_MEMORY_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB of memory
ENDGAME_TIMEOUT_SECONDS = 60 * 15 # 15 Minutes per game by default
ENDGAME_ANALYSIS_QUERY_INTERVAL_MINUTES = 5 # Re-query database every 5 minutes

254
src/endgames.py Normal file
View file

@ -0,0 +1,254 @@
import json
import subprocess
import re
import resource
import time
from typing import List, Dict, Tuple
from dataclasses import dataclass
from pathlib import Path
import platformdirs
import psycopg2.extras
import psycopg2.errors
import hanabi.hanab_game
import hanabi.constants
import hanabi.live.compress
import constants
import games_db_interface
from database import conn_manager
from log_setup import logger
@dataclass
class EndgameAction:
turn: int
action_type: hanabi.hanab_game.ActionType
card: hanabi.hanab_game.DeckCard
enumerator: int
denominator: int
@property
def win_rate(self):
return self.enumerator / self.denominator
def analyze_and_store_game(game_id: int) -> int:
actions, return_code = analyze_game_from_db(game_id)
store_endgame_actions(game_id, actions, return_code)
return return_code
def analyze_game_from_db(game_id: int, refresh_cache=False) -> Tuple[List[EndgameAction], int]:
# In order to pass the game replay to the endgame analyzer (a C++ program), we use the hanab.live json format.
# In order to avoid to need to use the /export endpoint of hanab.live, we create the json replay ourselves from
# the information stored in the database. Note that this is partially lossy, since the GameState class we use here
# does not support stuff like notes or player names, but we don't care about this anyway and can pass the relevant
# information, i.e. deck, number of players and actions.
# We can additionally use the cache dir
filename = Path(platformdirs.user_cache_dir(constants.APP_NAME)) / "replays" / "{}.json".format(game_id)
if not filename.exists() or refresh_cache:
filename.parent.mkdir(exist_ok=True, parents=True)
game, variant_name = games_db_interface.load_game(game_id)
game_json = game.to_json()
game_json["options"]["variant"] = variant_name
with open(filename, 'w') as f:
f.write(json.dumps(game_json))
return analyze_endgame_from_file(str(filename))
def analyze_endgame_from_file(filename: str) -> Tuple[List[EndgameAction], int]:
"""
Analyzes endgame of replay specified in given file using appropriate time and memory limits.
@param filename: Name of file containing replay of game in hanab.live format
@return: List of all evaluated actions and return code why evaluation finished:
0 if evaluation completed within specified time and memory
1 if evaluation ran into timeout
2 if evaluation was empty because state is unreachable
3 if evaluation ran out of memory
No guarantee can be made on what actions are actually evaluated, these might be more or less depending on
timeouts and/or resource limitation.
@raise
"""
raw_output: bytes
return_code = 0
args = [
'./endgame-analyzer', '--file', filename,
'--draw-pile-size', str(constants.ENDGAME_MAX_DRAW_PILE_SIZE), '--list-actions', '--quiet'
]
try:
result = subprocess.run(
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=constants.ENDGAME_TIMEOUT_SECONDS,
preexec_fn=set_memory_limit
)
if result.returncode != 0:
# 2 is the return code to report that the specified game state is not reachable
# In this case, there is nothing to analyze, so we will return an empty list and normal program termination.
if result.returncode == 2:
return [], 2
# 3 is the return code used by the subprocess to indicate an out of memory exception
# Since we intentionally limited the memory, this is actually not an exception for us,
# we will simply parse the results we have and report the OOM exception.
if result.returncode == 3:
return_code = 3
else:
raise RuntimeError(
"Abnormal program termination of endgame-analyzer subprocess: Call of\n"
"{}\n"
"resulted in returncode '{}' and stderr\n"
"{}"
.format(
" ".join(args),
result.returncode,
result.stderr.decode('utf-8')
)
)
raw_output = result.stdout
except subprocess.TimeoutExpired as time_err:
return_code = 1
raw_output = time_err.stdout
# It could be that we got no output. In that case, we also cannot parse anything
output = raw_output.decode('utf-8') if raw_output else ""
pattern = r"Turn (?P<turn>\d+), (?P<type>\w+)(?:\s(?P<card>\w\w))?: (?P<enumerator>\d+)/(?P<denominator>\d+)"
return [parse_match(match.groupdict()) for match in re.finditer(pattern, output)], return_code
def set_memory_limit():
resource.setrlimit(resource.RLIMIT_DATA, (constants.ENDGAME_MEMORY_BYTES, constants.ENDGAME_MEMORY_BYTES))
def store_endgame_actions(game_id: int, endgame_actions: List[EndgameAction], result_code) -> None:
values = []
for action in endgame_actions:
values.append((game_id, action.turn, action.action_type.value, action.card.suitIndex, action.card.rank, action.enumerator, action.denominator))
# Remove duplicates (even though we expect none), otherwise this causes errors on insertion.
values = list(set(values))
conn = conn_manager.get_connection()
cur = conn.cursor()
psycopg2.extras.execute_values(
cur,
"INSERT INTO endgames "
"VALUES %s "
"ON CONFLICT (game_id, turn, action_type, suit_index, rank) "
"DO UPDATE "
"SET (enumerator, denominator) = (EXCLUDED.enumerator, EXCLUDED.denominator)",
values
)
# Mark this game as analyzed.
cur.execute(
"INSERT INTO endgames_analyzed "
"VALUES (%s, %s) "
"ON CONFLICT (game_id) "
"DO UPDATE "
"SET termination_reason = EXCLUDED.termination_reason",
(game_id, result_code)
)
conn.commit()
def load_endgame_actions(game_id: int) -> List[EndgameAction]:
cur = conn_manager.get_new_cursor()
cur.execute(
"SELECT turn, action_type, suit_index, rank, enumerator, denominator "
"FROM endgames "
"WHERE game_id = %s "
"ORDER BY turn ASC, action_type ASC, suit_index ASC, rank ASC",
(game_id,)
)
ret = []
for (turn, action_type, suit_index, rank, enumerator, denominator) in cur.fetchall():
ret.append(EndgameAction(
turn,
hanabi.hanab_game.ActionType(action_type),
hanabi.hanab_game.DeckCard(suit_index, rank),
enumerator, denominator)
)
return ret
def parse_match(action: Dict) -> EndgameAction:
turn = action["turn"]
action_type = parse_action_type(action["type"])
card = hanabi.hanab_game.DeckCard(0, 0) if action["card"] is None else parse_card(action["card"])
enumerator = action["enumerator"]
denominator = action["denominator"]
return EndgameAction(turn, action_type, card, enumerator, denominator)
def parse_action_type(action_type: str) -> hanabi.hanab_game.ActionType:
match action_type:
case "play":
return hanabi.hanab_game.ActionType.Play
case "discard":
return hanabi.hanab_game.ActionType.Discard
case "clue":
return hanabi.hanab_game.ActionType.ColorClue
raise ValueError("Failed to parse action type: {}".format(action_type))
def parse_card(card: str) -> hanabi.hanab_game.DeckCard:
assert len(card) == 2
if card == "kt":
return hanabi.hanab_game.DeckCard(0, 0)
else:
rank = int(card[1])
suit = hanabi.constants.COLOR_INITIALS.index(card[0])
assert suit is not None
return hanabi.hanab_game.DeckCard(suit, rank)
def print_action_type(action_type: hanabi.hanab_game.ActionType) -> str:
match action_type:
case hanabi.hanab_game.ActionType.Play:
return "Play"
case hanabi.hanab_game.ActionType.Discard:
return "Discard"
case hanabi.hanab_game.ActionType.RankClue:
return "Clue"
case hanabi.hanab_game.ActionType.ColorClue:
return "Clue"
case _:
return "Unknown Action"
def work_thread():
"""
Will continuously query database to analyze endgames.
@return:
"""
conn = conn_manager.get_connection()
cur = conn.cursor()
while True:
cur.execute(
"SELECT games.id "
"FROM games "
"LEFT OUTER JOIN endgames_analyzed "
" ON endgames_analyzed.game_id = games.id "
"WHERE endgames_analyzed.termination_reason IS NULL "
"ORDER BY games.league_id DESC "
"LIMIT 1",
(False,)
)
res = cur.fetchone()
if res is None:
logger.info("No game found to analyze. Going to sleep for {} Minutes".format(
constants.ENDGAME_ANALYSIS_QUERY_INTERVAL_MINUTES)
)
time.sleep(60 * constants.ENDGAME_ANALYSIS_QUERY_INTERVAL_MINUTES)
else:
(game_id, ) = res
logger.info("Analyzing endgame of game {}".format(game_id))
return_code = analyze_and_store_game(game_id)
print("Finished endgame analysis of {}: Returncode {}".format(game_id, return_code))

View file

@ -81,10 +81,15 @@ def load_deck(seed: str) -> List[hanabi.hanab_game.DeckCard]:
return deck return deck
def load_game(game_id: int) -> Tuple[hanabi.hanab_game.HanabiInstance, List[hanabi.hanab_game.Action]]: def load_game_parts(game_id: int) -> Tuple[hanabi.hanab_game.HanabiInstance, List[hanabi.hanab_game.Action], str]:
"""
Loads information on game from database
@param game_id: ID of game
@return: Instance (i.e. deck + settings) of game, list of actions, variant name
"""
cur = conn_manager.get_new_cursor() cur = conn_manager.get_new_cursor()
cur.execute( cur.execute(
"SELECT games.num_players, games.seed, variants.clue_starved " "SELECT games.num_players, games.seed, variants.clue_starved, variants.name "
"FROM games " "FROM games "
"INNER JOIN variants" "INNER JOIN variants"
" ON games.variant_id = variants.id " " ON games.variant_id = variants.id "
@ -98,10 +103,19 @@ def load_game(game_id: int) -> Tuple[hanabi.hanab_game.HanabiInstance, List[hana
raise ValueError(err_msg) raise ValueError(err_msg)
# Unpack results now # Unpack results now
(num_players, seed, clue_starved) = res (num_players, seed, clue_starved, variant_name) = res
actions = load_actions(game_id) actions = load_actions(game_id)
deck = load_deck(seed) deck = load_deck(seed)
instance = hanabi.hanab_game.HanabiInstance(deck, num_players, clue_starved=clue_starved) instance = hanabi.hanab_game.HanabiInstance(deck, num_players, clue_starved=clue_starved)
return instance, actions return instance, actions, variant_name
def load_game(game_id: int) -> Tuple[hanabi.hanab_game.GameState, str]:
instance, actions, variant_name = load_game_parts(game_id)
game = hanabi.hanab_game.GameState(instance)
for action in actions:
game.make_action(action)
return game, variant_name

406
src/minimize_loss.py Normal file
View file

@ -0,0 +1,406 @@
from typing import Iterable, List, Dict
from config import config_manager
from constants import UNWINNABLE_SEED_FRACTION
from dataclasses import dataclass
import json
import datetime
import numpy as np
from numpy.typing import NDArray
from scipy.optimize import minimize, Bounds
MAX_RATING_DIFF = 1200
RATING_PRIOR = 1600
# This allows us to do integer optimization on more fine-grained grid.
LCM_FACTOR = 60
NUM_RANDOM_INITS = 50
NUM_CV_ITERS = 50
VARIANT_DEFAULT_RATINGS = np.array([1400, 1400, 1800, 1800]).reshape((1, 4))
VARIANT_NUM_PLAYERS_MODIFIERS = np.array([0, 0, 100]).reshape((3, 1))
# As of season 1, this is [1400 1400 1800 1800 1400 1400 1800 1800 1500 1500 1900 1900].
VARIANT_RATING_PRIORS = np.ravel(
VARIANT_DEFAULT_RATINGS + VARIANT_NUM_PLAYERS_MODIFIERS
)
@dataclass
class GlobalInfo:
rated_ids: Iterable[int]
variant_ids: Iterable[int]
player_counts: Iterable[int]
user_names: Iterable[str]
game_counts: Iterable[int]
rated_id_indices_in_rating_list: Dict[int, int]
@property
def rating_list_length(self):
return len(self.rated_ids) + len(self.variant_ids) * len(self.player_counts)
@dataclass
class GameRow:
game_id: int
num_players: int
users: List[str]
user_ids: List[int]
user_rating_changes: List[float]
user_ratings_after: List[float]
variant_id: int
variant_name: str
num_suits: id
rating_type: int
seed: str
score: int
num_turns: int
datetime_finished: datetime.datetime
league_id: int
num_bdrs: int
num_crits_lost: int
game_outcomes: List[str]
variant_rating_change: float
variant_rating_after: float
def calculate_loss(
rating_list: Iterable[int],
game_list: Iterable[GameRow],
global_info: GlobalInfo,
p_win_lookup_table: NDArray[np.float32],
player_rating_priors: Iterable[int],
player_prior_weight: float,
variant_rating_priors: Iterable[int],
variant_prior_weight: float,
):
loss = np.float32(0)
for game in game_list:
team_ratings = [
rating_list[global_info.rated_id_indices_in_rating_list[user_id]]
for user_id in game["user_ids"]
]
team_rating = sum(team_ratings) / len(team_ratings)
variant_rating = rating_list[
calculate_variant_index_in_rating_list(
len(game["users"]), game["variant_id"], global_info
)
]
rating_diff = int(np.round(LCM_FACTOR * (team_rating - variant_rating)))
p_win = calculate_p_win(rating_diff, p_win_lookup_table)
game_won = game["game_outcomes"].count("Win") > 0
loss += calculate_loss_for_one_game(p_win, game_won)
# Add two dummy single-player games for each player with prior_weight * square-root of number of games weight - one won and lost
for player_rating_prior, rated_id, game_count in zip(
player_rating_priors, global_info.rated_ids, global_info.game_counts
):
team_rating = rating_list[global_info.rated_id_indices_in_rating_list[rated_id]]
variant_rating = player_rating_prior
rating_diff = int(np.round(LCM_FACTOR * (team_rating - variant_rating)))
p_win = calculate_p_win(rating_diff, p_win_lookup_table)
loss += (
np.sqrt(game_count)
* player_prior_weight
* calculate_loss_for_one_game(p_win, True)
)
loss += (
np.sqrt(game_count)
* player_prior_weight
* calculate_loss_for_one_game(p_win, False)
)
# Add two dummy single-player games for each player with prior_weight * square-root of number of games weight - one won and lost
for variant_rating, variant_rating_prior in zip(
rating_list[len(global_info.rated_ids) :],
variant_rating_priors,
):
team_rating = variant_rating_prior
rating_diff = int(np.round(LCM_FACTOR * (team_rating - variant_rating)))
p_win = calculate_p_win(rating_diff, p_win_lookup_table)
loss += (
np.sqrt(game_count)
* variant_prior_weight
* calculate_loss_for_one_game(p_win, True)
)
loss += (
np.sqrt(game_count)
* variant_prior_weight
* calculate_loss_for_one_game(p_win, False)
)
return loss
def calculate_loss_gradient(rating_list, *args):
y = calculate_loss(rating_list, *args)
eye = np.eye(len(rating_list), dtype=int)
gradient = [
calculate_loss(rating_list + eye[i], *args) - y for i in range(len(rating_list))
]
return gradient
def calculate_minloss_ratings(
game_list: List[GameRow],
global_info: GlobalInfo,
p_win_lookup_table: NDArray[np.float32],
player_rating_priors: int | Iterable[int] = RATING_PRIOR,
player_random_init_stdev: int = 0,
player_prior_weight=0.5,
variant_rating_priors: int | Iterable[int] = RATING_PRIOR,
variant_prior_weight=0.5,
variant_random_init_stdev: int = 0,
):
player_rating_priors = np.broadcast_to(
player_rating_priors, len(global_info.rated_ids)
)
variant_rating_priors = np.broadcast_to(
variant_rating_priors,
len(global_info.variant_ids) * len(global_info.player_counts),
)
prior_rating_list = np.concatenate(
(
player_rating_priors
+ np.clip(
np.random.normal(
0, player_random_init_stdev, len(player_rating_priors)
),
-3 * player_random_init_stdev,
3 * player_random_init_stdev,
),
variant_rating_priors
+ np.clip(
np.random.normal(
0, variant_random_init_stdev, len(variant_rating_priors)
),
-3 * variant_random_init_stdev,
3 * variant_random_init_stdev,
),
),
dtype=np.float32,
)
rating_list = minimize(
calculate_loss,
prior_rating_list,
(
game_list,
global_info,
p_win_lookup_table,
player_rating_priors,
player_prior_weight,
variant_rating_priors,
variant_prior_weight,
),
bounds=Bounds(RATING_PRIOR - MAX_RATING_DIFF, RATING_PRIOR + MAX_RATING_DIFF),
jac=calculate_loss_gradient,
tol=0.0001,
)
return rating_list
def calculate_p_win_lookup_table(rating_diff_indices: Iterable[int]):
lookup_table = np.full(2 * MAX_RATING_DIFF * LCM_FACTOR + 1, 0.5, dtype=np.float32)
for rating_diff_index in rating_diff_indices:
p_win = calculate_p_win(rating_diff_index, None)
lookup_table[rating_diff_index] = p_win
return lookup_table
def calculate_p_win(
rating_diff_index: int, lookup_table: NDArray[np.float32] | None = None
):
if lookup_table is None:
return (1 - UNWINNABLE_SEED_FRACTION) * (
1 / (1 + 10 ** (-(rating_diff_index / LCM_FACTOR) / 400))
)
else:
return lookup_table[rating_diff_index]
# Cross-entropy loss
def calculate_loss_for_one_game(p_win: np.float32, game_won: bool):
if game_won:
loss = -np.log(p_win)
else:
loss = -np.log(1 - p_win)
return loss
def calculate_variant_index_in_rating_list(
player_count: int, variant_id: int, global_info: GlobalInfo
):
return (
len(global_info.rated_ids)
+ global_info.player_counts.index(player_count) * len(global_info.variant_ids)
+ global_info.variant_ids.index(variant_id)
)
def calculate_player_count_and_variant_id_from_variant_index(
index: int, global_info: GlobalInfo
):
num_players = global_info.player_counts[index // len(global_info.variant_ids)]
variant_id = global_info.variant_ids[index % len(global_info.variant_ids)]
return num_players, variant_id
def write_data(random_init_rating_lists, cv_rating_lists):
with open("../results/min_loss_rating_list.json", "w") as f:
rating_list = np.average(random_init_rating_lists, axis=0)
rating_stdevs = np.std(cv_rating_lists, axis=0, ddof=1)
players = []
for rated_id, user_name, rating, rating_stdev in zip(
global_info.rated_ids, global_info.user_names, rating_list, rating_stdevs
):
players.append(
{
"id": rated_id,
"rating": rating,
"username": user_name,
"stdev": rating_stdev,
}
)
variants = []
for i, (rating, rating_stdev) in enumerate(
zip(
rating_list[len(global_info.rated_ids) :],
rating_stdevs[len(global_info.rated_ids) :],
)
):
(
num_players,
variant_id,
) = calculate_player_count_and_variant_id_from_variant_index(i, global_info)
variant_name = None
for game in game_list:
if (
len(game["users"]) == num_players
and game["variant_id"] == variant_id
):
variant_name = game["variant_name"]
num_suits = game["num_suits"]
variants.append(
{
"id": variant_id,
"rating": rating,
"num_players": num_players,
"name": variant_name,
"num_suits": num_suits,
"stdev": rating_stdev,
}
)
s = json.dumps({"players": players, "variants": variants})
f.write(s)
def read_data():
with open("../data/games.json") as game_data:
game_list = json.loads(game_data.read())
p_win_lookup_table = calculate_p_win_lookup_table(
np.arange(-MAX_RATING_DIFF * LCM_FACTOR, MAX_RATING_DIFF * LCM_FACTOR + 1)
)
config = config_manager.get_config()
rated_ids_dupes = [ID for game in game_list for ID in game["user_ids"]]
rated_ids = list(set(rated_ids_dupes))
variant_ids_dupes = [game["variant_id"] for game in game_list]
variant_ids = list(set(variant_ids_dupes))
nums_players = [game["num_players"] for game in game_list]
player_counts = list(range(config.min_player_count, config.max_player_count + 1))
game_counts = np.concatenate(
(
[rated_ids_dupes.count(ID) for ID in rated_ids],
np.zeros(len(variant_ids) * len(player_counts)),
),
)
user_names = list(np.full((len(rated_ids),), ""))
for game in game_list:
for user_id, user_name in zip(game["user_ids"], game["users"]):
user_names[rated_ids.index(user_id)] = user_name
rated_id_indices_in_rating_list = {
id: index for index, id in dict(enumerate(rated_ids)).items()
}
global_info = GlobalInfo(
rated_ids,
variant_ids,
player_counts,
user_names,
game_counts,
rated_id_indices_in_rating_list,
)
for variant_id, num_players in zip(variant_ids_dupes, nums_players):
game_counts[
calculate_variant_index_in_rating_list(num_players, variant_id, global_info)
] += 1
global_info.game_counts = game_counts
return game_list, global_info, p_win_lookup_table
if __name__ == "__main__":
game_list, global_info, p_win_lookup_table = read_data()
random_init_rating_lists = []
cv_rating_lists = []
for _ in range(NUM_RANDOM_INITS):
random_init_rating_lists.append(
calculate_minloss_ratings(
game_list,
global_info,
p_win_lookup_table,
variant_rating_priors=VARIANT_RATING_PRIORS,
player_random_init_stdev=200,
variant_random_init_stdev=200,
).x
)
for _ in range(NUM_CV_ITERS):
cv_game_list = np.random.choice(game_list, len(game_list), replace=True)
cv_rating_lists.append(
calculate_minloss_ratings(
cv_game_list,
global_info,
p_win_lookup_table,
variant_rating_priors=VARIANT_RATING_PRIORS,
player_random_init_stdev=200,
variant_random_init_stdev=200,
).x
)
write_data(random_init_rating_lists, cv_rating_lists)

View file

@ -12,16 +12,18 @@ import requests_cache
import platformdirs import platformdirs
import stats import stats
import hanabi.hanab_game
import constants import constants
import config import config
import utils import utils
from dataclasses import dataclass from dataclasses import dataclass
import endgames
import games_db_interface
from database import conn_manager from database import conn_manager
@dataclass @dataclass
class PlayerEntry: class PlayerEntry:
player_name: str player_name: str
@ -713,6 +715,111 @@ def build_unique_variants(variant_rows: List[VariantRow]):
return [row for row in variant_rows if row.num_players == config.config_manager.get_config().min_player_count] return [row for row in variant_rows if row.num_players == config.config_manager.get_config().min_player_count]
def render_game_pages(env: jinja2.Environment, out_dir: Path):
endgames = get_endgame_page_data()
template = env.get_template("game.html")
for game_id, data in endgames.items():
rendered_template = template.render(
total_games_played=get_total_games(),
total_players=get_num_players(),
latest_run=datetime.datetime.now().isoformat(),
game_id=game_id,
data=data
)
output_file = out_dir / 'game' / str(game_id) / 'index.html'
output_file.parent.mkdir(exist_ok=True, parents=True)
with open(output_file, 'w') as f:
f.write(rendered_template)
def format_endgame_action(endgame_action: endgames.EndgameAction):
return "{} {}: {}/{} ~ {}".format(
endgames.print_action_type(endgame_action.action_type),
endgame_action.card,
endgame_action.enumerator,
endgame_action.denominator,
endgame_action.action_type.value
)
@dataclass
class EndgameActionRow:
description: str
enumerator: int
denominator: int
marked: bool = False
@property
def win_rate(self):
return round(100 * self.enumerator / self.denominator, 3)
def convert_endgame_action(endgame_action: endgames.EndgameAction, game: hanabi.hanab_game.GameState, action: hanabi.hanab_game.Action) -> EndgameActionRow:
action_str = endgames.print_action_type(endgame_action.action_type)
target_str: str
if endgame_action.action_type not in [hanabi.hanab_game.ActionType.ColorClue, hanabi.hanab_game.ActionType.RankClue]:
target_str = " {}".format(endgame_action.card)
else:
target_str = ""
description = action_str + target_str
marked = False
# To simplify comparisons, we only work with color clues here. Endgame actions always consist of color clues.
if action.type == hanabi.hanab_game.ActionType.RankClue:
action.type = hanabi.hanab_game.ActionType.ColorClue
if endgame_action.action_type == action.type:
if endgame_action.action_type == hanabi.hanab_game.ActionType.ColorClue:
marked = True
else:
game_target = game.instance.deck[action.target]
if game.is_trash(game_target):
game_target = hanabi.hanab_game.DeckCard(0, 0)
if endgame_action.card == game_target:
marked = True
return EndgameActionRow(description, endgame_action.enumerator, endgame_action.denominator, marked)
def get_endgame_page_data():
cur = conn_manager.get_new_cursor()
cur.execute(
"SELECT games.id, termination_reason "
"FROM games "
"LEFT OUTER JOIN endgames_analyzed "
" ON endgames_analyzed.game_id = games.id "
)
ret = {}
for (game_id, termination_reason) in cur.fetchall():
if termination_reason is not None:
ret[game_id] = []
instance, actions, _ = games_db_interface.load_game_parts(game_id)
game = hanabi.hanab_game.GameState(instance)
endgame_actions = endgames.load_endgame_actions(game_id)
while len(endgame_actions) > 0:
# Move to current turn and update game
cur_turn = endgame_actions[0].turn
# Note the -1 here since turns on hanab.live start to count at 1
while len(game.actions) < cur_turn - 1:
action, *actions = actions
game.make_action(action)
assert len(actions) > 0
actions_this_turn: List[endgames.EndgameAction] = []
while len(endgame_actions) > 0 and endgame_actions[0].turn == cur_turn:
action, *endgame_actions = endgame_actions
actions_this_turn.append(action)
actions_this_turn.sort(key=lambda a: -a.win_rate)
best_action, *other_actions = [convert_endgame_action(a, game, actions[0]) for a in actions_this_turn]
ret[game_id].append(
(cur_turn, best_action, other_actions)
)
else:
ret[game_id] = None
return ret
def render_main_site(env: jinja2.Environment, out_dir: Path): def render_main_site(env: jinja2.Environment, out_dir: Path):
rating_lists = get_rating_lists() rating_lists = get_rating_lists()
streak_lists = get_streak_list() streak_lists = get_streak_list()
@ -842,6 +949,7 @@ def render_all():
render_main_site(env, out_dir) render_main_site(env, out_dir)
render_player_pages(env, out_dir) render_player_pages(env, out_dir)
render_variant_pages(env, out_dir) render_variant_pages(env, out_dir)
render_game_pages(env, out_dir)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -93,7 +93,7 @@ def analyze_replay(instance: hanab_game.HanabiInstance, actions: List[hanab_game
def analyze_game_and_store_stats(game_id: int): def analyze_game_and_store_stats(game_id: int):
logger.verbose("Analysing game {} for BDRs and lost crits".format(game_id)) logger.verbose("Analysing game {} for BDRs and lost crits".format(game_id))
instance, actions = games_db_interface.load_game(game_id) instance, actions, _ = games_db_interface.load_game_parts(game_id)
analysis = analyze_replay(instance, actions) analysis = analyze_replay(instance, actions)
cur = conn_manager.get_new_cursor() cur = conn_manager.get_new_cursor()

73
templates/game.html Normal file
View file

@ -0,0 +1,73 @@
{% extends "layout.html" %}
{% block navbar %}
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="/">The Hanabi Pro Hunting League</a><a class="navbar-brand" href="#"><small class="text-muted">- Endgame Statistics for {{game_id}}</small></a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" id="Back" href="/">Back</a>
</li>
</ul>
</div>
</div>
</nav>
{% endblock %}
{% block content %}
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="overview">
<div class="container my-5">
<h3>
Statistics for game {{game_id}}
</h3>
<ul>
<li>
Replay: <a href="https://hanab.live/replay/{{game_id}}">https://hanab.live/replay/{{game_id}}</a>
</li>
<li>
Shared Replay: <a href="https://hanab.live/shared-replay/{{game_id}}">https://hanab.live/shared-replay/{{game_id}}</a>
</li>
</ul>
<h4>
Endgame Analysis table
</h4>
{% if data %}
<table class="endgame-table">
<tr>
<th>Turn</th>
<th>Action</th>
<th>Fractional Probability</th>
<th>Probability</th>
</tr>
{% for (turn, best_action, other_actions) in data %}
<tr>
<td rowspan="{{ other_actions|length + 1 }}"><a href="https://hanab.live/replay/{{game_id}}#{{turn}}">{{ turn }}</a></td>
<td>{% if best_action.marked %}<b>{% endif %}{{ best_action.description }}{% if best_action.marked %}</b>{% endif %}</td>
<td>{{ best_action.enumerator }}/{{ best_action.denominator }}</td>
<td>{{ best_action.win_rate }}%</td>
</tr>
{% for action in other_actions %}
<tr>
<td>{% if action.marked %}<b>{% endif %}{{ action.description }}{% if action.marked %}</b>{% endif %}</td>
<td>{{ action.enumerator }}/{{ action.denominator }}</td>
<td>{{ action.win_rate }}%</td>
</tr>
{% endfor %}
{% endfor %}
</table>
{% else %}
Currently, there is no endgame analysis available for this game. Since the computation is resource extensive,
this might take a while, also depending on how many other games have been played recently.
<br>
Come back later to check again.
{% endif %}
</div>
</div>
</div>
{% endblock %}

View file

@ -72,7 +72,7 @@ var table_{{div_id}} = new Tabulator("#table-{{div_id}}", {
layout: "fitDataStretch", layout: "fitDataStretch",
columns: [ columns: [
{title: "Game", field: "game_id", formatter: "link", formatterParams:{ {title: "Game", field: "game_id", formatter: "link", formatterParams:{
urlPrefix: "https://hanab.live/replay/", urlPrefix: "/game/",
target:"_blank" target:"_blank"
}}, }},
{title: "Played", field: "datetime_finished", formatter: "datetime", formatterParams:{ {title: "Played", field: "datetime_finished", formatter: "datetime", formatterParams:{