from typing import List, Optional
from typeguard import typechecked
import abc
import rstt.config as cfg
from rstt.stypes import SMatch
from rstt.player import Player
import rstt.utils.functions as uf
import numpy as np
import random
[docs]
class PlayerTVS(Player, metaclass=abc.ABCMeta):
def __init__(self, name: Optional[str] = None, level: Optional[float] = None) -> None:
"""Player with time varying level.
The class introduce a mechanism for Player to change their level during simulation while maintaining the ability to track their match properly.
Their is only one abstract method to implement when inheriting from it, the :func:`rstt.player.playerTVS.PlayerTVS._update_level`
Parameters
----------
name : str, optional
A unique name to identify the player. By default None, in this case a name is randomly generated.
level : float, optional
The level/skill/strenght of the player. By default None, in this case a level is randomly generated.
"""
super().__init__(name=name, level=level)
# ??? redundancy with self.__level_history[-1]
self.__current_level = self._BasicPlayer__level
self.__level_history = [self.__current_level]
self._Player__games = [None]
# --- getter --- #
[docs]
def level_history(self) -> List[float]:
"""Getter for the player's level's evolution.
Returns
-------
List[float]
All the level the player had in chronological order
"""
return self.__level_history
[docs]
def original_level(self) -> float:
"""The first level
Sugar for PlayerTVS.level_history()[0]
Returns
-------
float
The original level (at instanciation)
"""
return self._BasicPlayer__level
[docs]
def level_in(self, game: SMatch) -> float:
"""The level a player displayed in a given game
Parameters
----------
game : SMatch
A match to query the player's level in.
Returns
-------
float
The player's level in the given game.
"""
return self.__level_history[self._Player__games.index(game)]
# --- setter --- #
[docs]
def update_level(self, *args, **kwars) -> None:
"""Method to update the player's level
"""
self._update_level(*args, **kwars)
self.__level_history.append(self.__current_level)
self._Player__add_game(None)
# --- override --- #
[docs]
def level(self) -> float:
"""Getter method for the player's level
Returns
-------
float
The current level of the player.
"""
return self.__current_level
[docs]
def games(self) -> list[SMatch]:
"""Getter method for match the player participated in
Returns
-------
List[Match]
All the matches the player played in chronolgical order, from oldest to the most recent.
"""
return [game for game in self.games() if game is not None]
[docs]
def add_game(self, *args, **kwars) -> None:
"""Adds match to the player history
Parameters
----------
match : Match
A match to track.
Raises
------
ValueError
The match needs to be a game in which the player partipiated in and not already tracked. Either condition violated will raise an Error.
"""
super().add_game(*args, **kwars)
self.__level_history.append(self.__current_level)
[docs]
def reset(self):
# TODO: check how to match the Player.reset() param calls
self._reset_level()
super().reset()
self.__level_history.append(self.__current_level)
# --- internal mechanism --- #
def _reset_level(self) -> None:
self.__level_history = []
self.__current_level = self._BasicPlayer__level
@abc.abstractmethod
def _update_level(self) -> None:
'''change the self.__current_level value'''
[docs]
class ExponentialPlayer(PlayerTVS):
@typechecked
def __init__(self, name: Optional[str] = None,
start: Optional[float] = None,
final: Optional[float] = None,
tau: Optional[float] = None):
"""Player with a level that tends to a final value.
The transition to the final level is controlled by an exponential decay function.
Parameters
----------
name : str, optional
A unique name to identify the player. By default None, in this case a name is randomly generated.
start : float, optional
The initial level of the player. By default None, in this case a level is randomly generated.
final : float, optional
The final level of the player. By default None, in this case a level is randomly generated.
tau : float, optional
Controls how fast (number of level update) the player's level gets close to its final level.
Example
-------
The figure below shows a population of 10 players generated with .create() without specifics params.
.. image:: img/playertvs/ExponentialPlayer.pdf
:width: 800
"""
start = start if start is not None else cfg.EXPONENTIAL_START_DIST(
**cfg.EXPONENTIAL_START_ARGS)
super().__init__(name=name, level=start)
self.__final = final if final is not None else self.original_level(
) + cfg.EXPONENTIAL_DIFF_DIST(**cfg.EXPONENTIAL_DIFF_ARGS)
# parameters of the relaxation
self.__tau = tau if tau else cfg.EXPONENTIAL_TAU_DIST(
**cfg.EXPONENTIAL_TAU_ARGS)
# relaxation
self._relax = uf.exponential_decay
self.__time = 0
def _update_level(self, *args, **kwars) -> float:
self.__time += 1
self._PlayerTVS__current_level = self.__final - \
(self.__final - self._PlayerTVS__current_level) * \
self._relax(time=self.__time, tau=self.__tau)
[docs]
class LogisticPlayer(PlayerTVS):
@typechecked
def __init__(self, name: Optional[str] = None,
start: Optional[float] = None,
final: Optional[float] = None,
center_x: Optional[float] = None,
r: Optional[float] = None):
"""Player with a level that tends to a final value.
The transition to the final level is controlled by a logistic function.
Parameters
----------
name : str, optional
A unique name to identify the player. By default None, in this case a name is randomly generated.
start : float, optional
The initial level of the player. By default None, in this case a level is randomly generated.
final : float, optional
The final level of the player. By default None, in this case a level is randomly generated.
center_x : float, optional
Number of level update for the player to reach the level:=(final-start)/2, by default 100.
r : float, optional
Controls the sharpness of the level transition, by default 0.5.
Example
-------
The figure below shows a population of 10 players generated with .create() without specifics params.
.. image:: img/playertvs/LogisticPlayer.pdf
:width: 800
"""
start = start if start is not None else cfg.LOGISTIC_START_DIST(
**cfg.LOGISTIC_START_ARGS)
super().__init__(name=name, level=start)
self.__final = final if final is not None else self.original_level(
) + cfg.LOGISTIC_DIFF_DIST(**cfg.LOGISTIC_DIFF_ARGS)
self.__time = 0
self._relax = uf.verhulst
# parameters of the relaxation
center_x = center_x if center_x else cfg.LOGISTIC_CENTER_DIST(
**cfg.LOGISTIC_CENTER_ARGS)
self.__r = r if r else cfg.LOGISTIC_R_DIST(**cfg.LOGISTIC_R_ARGS)
self.__tau = uf.a_from_logistic_center(center_x, self.__r)
def _update_level(self, *args, **kwars) -> float:
self.__time += 1
self._PlayerTVS__current_level += self._relax(K=self.__final - self._PlayerTVS__current_level,
a=self.__tau, r=self.__r, t=self.__time, shift=0)
[docs]
class CyclePlayer(PlayerTVS):
@typechecked
def __init__(self, name: Optional[str] = None,
level: Optional[float] = None,
sigma: Optional[float] = None,
tau: Optional[int] = None):
"""Cycle Player
Implement the 'Cycle Model' descirbed by
Aldous D. in 'Elo ratings and the Sports Model: A Negleted Topic in Applied Probability?' [section 4.1]
Cycle player have a deterministic level evolution in cycle.
The variance of the level is given by the attribute __sigma^2,
while the attribute __tau indicates the number of game needed for the level to decrease
from its maximum to its avergae value.
Parameters
----------
name : str, optional
A unique name to identify the player. By default None, in this case a name is randomly generated.
level : float, optional
The mean level, by default None, int this case a level is randomly generated
sigma : float, optional
The standard deviation of the level, by default 1.0
tau : int, optional
The number of update needed for a level to decrease from its maximal value to its mean level, by default 100
Example
-------
The figure below shows a population of 10 players generated with .create() without specifics params.
.. image:: img/playertvs/CyclePlayer.pdf
:width: 800
"""
super().__init__(name, level)
self.__time = 0
# controls the 'variance'
self.__sigma = sigma if sigma else cfg.CYCLE_SIGMA_DIST(
**cfg.CYCLE_SIGMA_ARGS)
# controls the cycle duration
self.__tau = tau if tau else cfg.CYCLE_TAU_DIST(**cfg.CYCLE_TAU_ARGS)
def _update_level(self, *args, **kwars):
X0 = self._BasicPlayer__level
self.__time += 1
self._PlayerTVS__current_level = X0 + \
uf.deterministic_cycle(
mu=X0, sigma=self.__sigma, tau=self.__tau, time=self.__time)
[docs]
class JumpPlayer(PlayerTVS):
@typechecked
def __init__(self, name: Optional[str] = None,
level: Optional[float] = None,
sigma: Optional[float] = None,
tau: Optional[int] = None):
"""Jump Player
Implement a 'Jump Model' adapted from
Aldous D. in 'Elo ratings and the Sports Model' [section 4.3]
The implementation differs from the source document by allowing a player to 'jumpe' mulitple times as simulation progress, and not just once.
A JumpPlayer level remains constant for an amount of time given by a geometric distribution
before 'jumping' to a new level given by a Normal distribution.
In practice, calling the :func:`rstt.player.playerTVS.PlayerTVS.update_level` will often result in no level changes.
Parameters
----------
name : str, optional
A unique name to identify the player. By default None, in this case a name is randomly generated.
level : float, optional
The initial level, by default None, int this case a level is randomly generated.
sigma : float, optional
Standard deviantion of the level changes, by default 1.0.
Remark that the mean level changes is 0, as a consequences the player's level as equal chances to increase or decrease.
tau : int, optional
Parameter of the geometric distribution, by default 400.
This will tune the tendancy that a player has to stay at a level before the level is updated.
Example
-------
The figure below shows a population of 10 players generated with .create() without specifics params.
.. image:: img/playertvs/JumpPlayer.pdf
:width: 800
"""
super().__init__(name, level)
self.__sigma = sigma if sigma else cfg.JUMP_SIGMA_DIST(
**cfg.JUMP_SIGMA_ARGS)
self.__tau = tau if tau else cfg.JUMP_TAU_DIST(**cfg.JUMP_TAU_ARGS)
self.__timer = np.random.geometric(1/self.__tau)
def _update_level(self, *args, **kwars):
self.__tictac()
self.__jump()
def __tictac(self) -> None:
self.__timer -= 1
def __jump(self):
if self.__timer == 0:
# new timer
self.__timer = np.random.geometric(1/self.__tau)
# new level
self._PlayerTVS__current_level += random.gauss(0, self.__sigma)
# TODO: add the Ornstein-Uhlenbeck model from D. Aldous
# TODO: add learning effect model proposed by B. Düring & Cie
# source: https://arxiv.org/pdf/1806.06648
# source: https://arxiv.org/pdf/2204.10260