""" Solver Module
Solver provide a solve(match: SMatch) method to assign a Score to the match. Typicaly a WIN/LOSE/DRAW in case of 'versus' matches
"""
from typing import List, Optional, Callable
from typeguard import typechecked
from rstt import Duel
from rstt.stypes import Score
import rstt.utils.functions as uf
import rstt.config as cfg
import random
'''
TODO:
- Extend match to Many-Versus-Many match
- Extend match to Free-for-all
- LEVEL_MIXTURES: define differents ways to mix levels in a teams, sum/avg/median/ and set a parameters to tune it solvers
- Create const value for standard score (maybe enum types) i.e Score.win := [1,0]| Score.lose := [0,1]/ Score.draw := [0.5, 0.5]
- Work on Score type
- Add predict() to solvers (and to the stypes.Solver Protocol ?)
'''
WIN = [1.0, 0.0]
"""Default Score Value indicating a 'win' for the first memeber of a 'versus' Match"""
LOSE = [0.0, 1.0]
"""Default Score Value indicating a 'lose' for the first memeber of a 'versus' Match"""
DRAW = [0.5, 0.5]
"""Default Score Value indicating a 'draw' between the two opponents in a 'versus' Match"""
[docs]
class BetterWin:
@typechecked
def __init__(self, with_draw: bool = False):
"""BetterWin Solver
Implements a deterministic Score generator.
BetterWin always assign a Win to the best (highest level) participant of a match.
.. warning::
Only Supports Duel at the moment.
Parameters
----------
with_draw : bool, optional
Wether a draw should be assigned to the game in case of equals level, by default False.
When False, there is a 'home advantage policy' meaning that in case of equals levels, the first team of the match wins.
"""
self.with_draw = with_draw
[docs]
@typechecked
def solve(self, duel: Duel, *args, **kwars) -> None:
level1, level2 = duel.player1().level(), duel.player2().level()
if level1 > level2:
score = WIN
elif level1 < level2:
score = LOSE
elif self.with_draw:
score = DRAW
else:
# 'home advantage policy'
score = WIN
duel._Match__set_result(result=score)
[docs]
class ScoreProb:
@typechecked
def __init__(self, scores: List[Score], func: Callable[[Duel], Score]):
"""General Purpose Solver
A ScoreProb
Parameters
----------
scores : List[Score]
A list of possible match outcomes.
func : Callable[[Duel], Score]
A function taking as input a Duel and producing Score probabilities
"""
self.scores = scores
self.probabilities = func
# ??? can we simply use generic typing, is the code general enough to work for arbitrary SMatch and not just Duel
# BUG: incompatible func typing in __init__ and .solve() usage -> write a bunch of test
# FIXME: Should the doc match __init__ signature or the .solve usage ...
[docs]
@typechecked
def solve(self, duel: Duel, *args, **kwars) -> None:
score = random.choices(population=self.scores,
# !!! THE F* is going on here, func should return a Score
weights=self.probabilities(duel),
k=1)[0]
duel._Match__set_result(score)
[docs]
class WeightedScore(ScoreProb):
@typechecked
def __init__(self, scores: List[Score], weights: List[float]):
"""Weighted Score assignement
With this Solver, A score is randomly chosed form a list of options based on weighted.
Parameters
----------
scores : List[Score]
A list of possible match outcomes.
weights : List[float]
The corresponding weight associated to each Score.
Raises
------
ValueError
An error is raised when the scores and weights length are not equal.
"""
if len(scores) != len(weights):
msg = f"length of scores ({len(scores)}) does not match length of weights ({len(weights)})"
raise ValueError(msg)
super().__init__(scores=scores, func=lambda x: weights)
[docs]
class CoinFlip(WeightedScore):
def __init__(self):
"""Random Solver
Behave like a coin flip, a win or a lose is randomly generated with no regards to any Match details.
"""
super().__init__(scores=[WIN, LOSE], weights=[0.5, 0.5])
[docs]
class BradleyTerry(ScoreProb):
def __init__(self):
"""Bradley-Terry model
Implements the famous pairwise model comparaison `probabilistic model <https://en.wikipedia.org/wiki/Bradley–Terry_model>`_.
It is a ScoreProb Solver where the probability function that a player A with level a, beats a player B with level b, is defined as
P(A win against B) := a/(a + b)
"""
super().__init__(scores=[WIN, LOSE], func=self.__probabilities)
def __probabilities(self, duel: Duel) -> List[float]:
level1 = duel.teams()[0][0].level()
level2 = duel.teams()[1][0].level()
prob = uf.bradleyterry(level1, level2)
return [prob, 1-prob]
[docs]
class LogSolver(ScoreProb):
@typechecked
def __init__(self, base: Optional[float] = None, lc: Optional[float] = None):
"""Elo like Solver
The LogSolver implements a standard reparametrization of the Bradley-Terry model that matches Elo rating system.
In practice it is a ScoreProb with a probability function illustrated on `wismuth <https://wismuth.com/elo/calculator.html>`_.
FOr a player A with level a, and a Player B with level b, it is defined by the logistic function:
P(A wins against B) = 1/(1+base^( (b-a) / lc))
Parameters
----------
base : Optional[float], optional
The base in the logistic function, by default 10
lc : Optional[float], optional
The constant in the logistic function, by default 400
.. note::
Default constant in the RSTT package ensure that the LogSolver probabilities matches the expected Score by :class:`rstt.ranking.inferer.Elo`.
Which means that perfectly accurate predictions are possible when combining both in simulation.
"""
super().__init__(scores=[WIN, LOSE], func=self.__probabilities)
self.base = base if base is not None else cfg.LOGSOLVER_BASE
self.lc = lc if lc is not None else cfg.LOGSOLVER_LC
def __probabilities(self, duel: Duel) -> List[float]:
level1 = duel.teams()[0][0].level()
level2 = duel.teams()[1][0].level()
prob = uf.logistic_elo(
base=self.base, diff=level1-level2, constant=self.lc)
return [prob, 1-prob]