Support using tuples

This commit is contained in:
avitex 2017-11-16 22:44:01 +11:00
parent 168ec234dc
commit b6dd4c2dee
7 changed files with 219 additions and 140 deletions

View File

@ -6,11 +6,14 @@ defmodule Glicko do
## Usage
Players can be represented by either the convenience `Glicko.Player` module or a tuple (see `player_t`).
Results can be represented by either the convenience `Glicko.Result` module or a tuple (see `result_t`).
Get a players new rating after a series of matches in a rating period.
iex> results = [GameResult.new(Player.new_v1([rating: 1400, rating_deviation: 30]), :win),
...> GameResult.new(Player.new_v1([rating: 1550, rating_deviation: 100]), :loss),
...> GameResult.new(Player.new_v1([rating: 1700, rating_deviation: 300]), :loss)]
iex> results = [Result.new(Player.new_v1([rating: 1400, rating_deviation: 30]), :win),
...> Result.new(Player.new_v1([rating: 1550, rating_deviation: 100]), :loss),
...> Result.new(Player.new_v1([rating: 1700, rating_deviation: 300]), :loss)]
iex> player = Player.new_v1([rating: 1500, rating_deviation: 200])
iex> Glicko.new_rating(player, results, [system_constant: 0.5])
%Glicko.Player{version: :v1, rating: 1464.0506705393013, rating_deviation: 151.51652412385727, volatility: nil}
@ -25,12 +28,24 @@ defmodule Glicko do
alias __MODULE__.{
Player,
GameResult,
Result,
}
@default_system_constant 0.8
@default_convergence_tolerance 1.0e-7
@type version_t :: :v1 | :v2
@type rating_t :: float
@type rating_deviation_t :: float
@type volatility_t :: float
@type score_t :: float
@type player_t :: player_v1_t | player_v2_t
@type player_v1_t :: {rating :: rating_t, rating_deviation :: rating_deviation_t}
@type player_v2_t :: {rating :: rating_t, rating_deviation :: rating_deviation_t, volatility :: volatility_t}
@type result_t :: {opponent :: player_t, score :: score_t}
@type new_rating_opts_t :: [system_constant: float, convergence_tolerance: float]
@doc """
@ -38,50 +53,34 @@ defmodule Glicko do
Returns the updated player with the same version given to the function.
"""
@spec new_rating(player :: Player.t, results :: list(GameResult.t), opts :: new_rating_opts_t) :: Player.t
def new_rating(player, results, opts \\ [])
def new_rating(player = %Player{version: :v1}, results, opts) do
player
|> Player.to_v2
|> do_new_rating(results, opts)
|> Player.to_v1
end
def new_rating(player = %Player{version: :v2}, results, opts) do
do_new_rating(player, results, opts)
@spec new_rating(player :: player_t | Player.t, results :: list(result_t | Result.t), opts :: new_rating_opts_t) :: player_t | Player.t
def new_rating(player, results, opts \\ []) do
{cast_from, internal_player} = cast_player_to_internal(player)
internal_player = do_new_rating(internal_player, results, opts)
cast_internal_to_player({cast_from, internal_player})
end
defp do_new_rating(player, [], _) do
player_post_rating_deviation =
Map.new
|> Map.put(:player_rating_deviation_squared, :math.pow(player.rating_deviation, 2))
|> calc_player_pre_rating_deviation(player.volatility)
defp do_new_rating({player_rating, player_rating_deviation, player_volatility}, [], _) do
player_post_rating_deviation = calc_player_pre_rating_deviation(
:math.pow(player_rating_deviation, 2),
player_volatility
)
%{player | rating_deviation: player_post_rating_deviation}
{player_rating, player_post_rating_deviation, player_volatility}
end
defp do_new_rating(player, results, opts) do
results = Enum.map(results, fn result ->
opponent = Player.to_v2(result.opponent)
result =
Map.new
|> Map.put(:score, result.score)
|> Map.put(:opponent_rating, opponent.rating)
|> Map.put(:opponent_rating_deviation, opponent.rating_deviation)
|> Map.put(:opponent_rating_deviation_g, calc_g(opponent.rating_deviation))
Map.put(result, :e, calc_e(player.rating, result))
end)
defp do_new_rating({player_rating, player_rating_deviation, player_volatility}, results, opts) do
ctx =
Map.new
|> Map.put(:system_constant, Keyword.get(opts, :system_constant, @default_system_constant))
|> Map.put(:convergence_tolerance, Keyword.get(opts, :convergence_tolerance, @default_convergence_tolerance))
|> Map.put(:results, results)
|> Map.put(:player_rating, player.rating)
|> Map.put(:player_volatility, player.volatility)
|> Map.put(:player_rating_deviation, player.rating_deviation)
|> Map.put(:player_rating_deviation_squared, :math.pow(player.rating_deviation, 2))
|> Map.put(:player_rating, player_rating)
|> Map.put(:player_volatility, player_volatility)
|> Map.put(:player_rating_deviation, player_rating_deviation)
|> Map.put(:player_rating_deviation_squared, :math.pow(player_rating_deviation, 2))
# Init
ctx = Map.put(ctx, :results, Enum.map(results, &build_internal_result(ctx, &1)))
ctx = Map.put(ctx, :results_effect, calc_results_effect(ctx))
# Step 3
ctx = Map.put(ctx, :variance_estimate, calc_variance_estimate(ctx))
# Step 4
@ -102,21 +101,35 @@ defmodule Glicko do
# Step 5.5
ctx = Map.put(ctx, :new_player_volatility, calc_new_player_volatility(ctx))
# Step 6
ctx = Map.put(ctx, :player_pre_rating_deviation, calc_player_pre_rating_deviation(ctx, ctx.new_player_volatility))
ctx = Map.put(ctx, :player_pre_rating_deviation, calc_player_pre_rating_deviation(
ctx.player_rating_deviation_squared, ctx.new_player_volatility
))
# Step 7
ctx = Map.put(ctx, :new_player_rating_deviation, calc_new_player_rating_deviation(ctx))
ctx = Map.put(ctx, :new_player_rating, calc_new_player_rating(ctx))
Player.new_v2([
rating: ctx.new_player_rating,
rating_deviation: ctx.new_player_rating_deviation,
volatility: ctx.new_player_volatility,
])
{ctx.new_player_rating, ctx.new_player_rating_deviation, ctx.new_player_volatility}
end
defp build_internal_result(ctx, {opponent, score}) do
{_, {opponent_rating, opponent_rating_deviation, _}} = cast_player_to_internal(opponent)
result =
Map.new
|> Map.put(:score, score)
|> Map.put(:opponent_rating, opponent_rating)
|> Map.put(:opponent_rating_deviation, opponent_rating_deviation)
|> Map.put(:opponent_rating_deviation_g, calc_g(opponent_rating_deviation))
Map.put(result, :e, calc_e(ctx.player_rating, result))
end
defp build_internal_result(ctx, %Result{score: score, opponent: opponent}) do
build_internal_result(ctx, {opponent, score})
end
# Calculation of the estimated variance of the player's rating based on game outcomes
defp calc_variance_estimate(%{results: results}) do
results
defp calc_variance_estimate(ctx) do
ctx.results
|> Enum.reduce(0.0, fn result, acc ->
acc + :math.pow(result.opponent_rating_deviation_g, 2) * result.e * (1 - result.e)
end)
@ -124,7 +137,7 @@ defmodule Glicko do
end
defp calc_delta(ctx) do
calc_results_effect(ctx) * ctx.variance_estimate
ctx.results_effect * ctx.variance_estimate
end
defp calc_f(ctx, x) do
@ -142,22 +155,22 @@ defmodule Glicko do
:math.exp(a / 2)
end
defp calc_results_effect(%{results: results}) do
Enum.reduce(results, 0.0, fn result, acc ->
defp calc_results_effect(ctx) do
Enum.reduce(ctx.results, 0.0, fn result, acc ->
acc + result.opponent_rating_deviation_g * (result.score - result.e)
end)
end
defp calc_new_player_rating(ctx) do
ctx.player_rating + :math.pow(ctx.new_player_rating_deviation, 2) * calc_results_effect(ctx)
ctx.player_rating + :math.pow(ctx.new_player_rating_deviation, 2) * ctx.results_effect
end
defp calc_new_player_rating_deviation(ctx) do
1 / :math.sqrt(1 / :math.pow(ctx.player_pre_rating_deviation, 2) + 1 / ctx.variance_estimate)
end
defp calc_player_pre_rating_deviation(ctx, player_volatility) do
:math.sqrt((:math.pow(player_volatility, 2) + ctx.player_rating_deviation_squared))
defp calc_player_pre_rating_deviation(player_rating_deviation_squared, player_volatility) do
:math.sqrt((:math.pow(player_volatility, 2) + player_rating_deviation_squared))
end
defp iterative_algorithm_initial(ctx) do
@ -205,4 +218,26 @@ defmodule Glicko do
defp calc_e(player_rating, result) do
1 / (1 + :math.exp(-1 * result.opponent_rating_deviation_g * (player_rating - result.opponent_rating)))
end
defp cast_player_to_internal(player) when is_tuple(player) and tuple_size(player) == 2 do
{:v1, Player.new_v1(player) |> Player.to_v2 |> cast_player_to_internal |> elem(1)}
end
defp cast_player_to_internal(player) when is_tuple(player) and tuple_size(player) == 3 do
{:v2, player}
end
defp cast_player_to_internal(player = %Player{version: :v1}) do
{:player_v1, player |> Player.to_v2 |> cast_player_to_internal |> elem(1)}
end
defp cast_player_to_internal(player = %Player{version: :v2}) do
{:player_v2, {player.rating, player.rating_deviation, player.volatility}}
end
defp cast_internal_to_player({:v1, {rating, rating_deviation, _}}), do: {
rating |> Player.scale_rating_to(:v1),
rating_deviation |> Player.scale_rating_deviation_to(:v1),
}
defp cast_internal_to_player({:v2, player}), do: player
defp cast_internal_to_player({:player_v1, player}), do: Player.new_v2(player) |> Player.to_v1
defp cast_internal_to_player({:player_v2, player}), do: Player.new_v2(player)
end

View File

@ -1,41 +0,0 @@
defmodule Glicko.GameResult do
@moduledoc """
Provides a representation of a game result against an opponent.
## Usage
iex> opponent = Player.new_v2
iex> GameResult.new(opponent, 0.0)
%GameResult{score: 0.0, opponent: %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06}}
iex> GameResult.new(opponent, :win) # With shortcut
%GameResult{score: 1.0, opponent: %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06}}
"""
alias Glicko.Player
defstruct [
:score,
:opponent,
]
@type t :: %__MODULE__{score: float, opponent: Player.t}
@type result_type_t :: :loss | :draw | :win
@result_type_map %{loss: 0.0, draw: 0.5, win: 1.0}
@doc """
Creates a new GameResult against an opponent.
Supports passing either `:loss`, `:draw`, or `:win` as shortcuts.
"""
@spec new(opponent :: Player.t, result_type_t | float) :: t
def new(opponent, result_type) when is_atom(result_type) and result_type in [:loss, :draw, :win] do
new(opponent, Map.fetch!(@result_type_map, result_type))
end
def new(opponent, score) when is_number(score), do: %__MODULE__{
score: score,
opponent: opponent,
}
end

View File

@ -36,15 +36,21 @@ defmodule Glicko.Player do
@magic_version_scale 173.7178
@magic_version_scale_rating 1500.0
@default_v1_rating 1500.0
@default_v1_rating_deviation 350.0
@default_v2_volatility 0.06
@type t :: v1_t | v2_t
@type v1_t :: %__MODULE__{version: :v1, rating: float, rating_deviation: float, volatility: nil}
@type v2_t :: %__MODULE__{version: :v2, rating: float, rating_deviation: float, volatility: float}
@type v1_t :: %__MODULE__{
version: :v1,
rating: Glicko.rating_t,
rating_deviation: Glicko.rating_deviation_t,
volatility: nil,
}
@type v2_t :: %__MODULE__{
version: :v2,
rating: Glicko.rating_t,
rating_deviation: Glicko.rating_deviation_t,
volatility: Glicko.volatility_t,
}
defstruct [
:version,
@ -53,31 +59,68 @@ defmodule Glicko.Player do
:volatility,
]
@doc """
The recommended initial rating value for a new player.
"""
@spec initial_rating(Glicko.version_t) :: Glicko.rating_t
def initial_rating(:v1), do: 1500.0
def initial_rating(:v2), do: initial_rating(:v1) |> scale_rating_to(:v2)
@doc """
The recommended initial rating deviation value for a new player.
"""
@spec initial_rating_deviation(Glicko.version_t) :: Glicko.rating_deviation_t
def initial_rating_deviation(:v1), do: 350.0
def initial_rating_deviation(:v2), do: initial_rating_deviation(:v1) |> scale_rating_deviation_to(:v2)
@doc """
The recommended initial volatility value for a new player.
"""
@spec initial_v2_volatility :: Glicko.volatility_t
def initial_v2_volatility, do: 0.06
@doc """
Creates a new v1 player.
If not overriden, will use default values for an unrated player.
If not overriden, will use the default values for an unrated player.
"""
@spec new_v1([rating: float, rating_deviation: float]) :: v1_t
def new_v1(opts \\ []), do: %__MODULE__{
@spec new_v1(
{Glicko.rating_t, Glicko.rating_deviation_t} |
[rating: Glicko.rating_t, rating_deviation: Glicko.rating_deviation_t]
) :: v1_t
def new_v1(opts \\ [])
def new_v1({rating, rating_deviation}), do: %__MODULE__{
version: :v1,
rating: Keyword.get(opts, :rating, @default_v1_rating),
rating_deviation: Keyword.get(opts, :rating_deviation, @default_v1_rating_deviation),
rating: rating,
rating_deviation: rating_deviation,
volatility: nil,
}
def new_v1(opts), do: new_v1({
Keyword.get(opts, :rating, initial_rating(:v1)),
Keyword.get(opts, :rating_deviation, initial_rating_deviation(:v1)),
})
@doc """
Creates a new v2 player.
If not overriden, will use default values for an unrated player.
"""
@spec new_v2([rating: float, rating_deviation: float, volatility: float]) :: v2_t
def new_v2(opts \\ []), do: %__MODULE__{
@spec new_v2(
{Glicko.rating_t, Glicko.rating_deviation_t, Glicko.volatility_t} |
[rating: Glicko.rating_t, rating_deviation: Glicko.rating_deviation_t, volatility: Glicko.volatility_t]
) :: v2_t
def new_v2(opts \\ [])
def new_v2({rating, rating_deviation, volatility}), do: %__MODULE__{
version: :v2,
rating: Keyword.get(opts, :rating, @default_v1_rating |> scale_rating_to(:v2)),
rating_deviation: Keyword.get(opts, :rating_deviation, @default_v1_rating_deviation |> scale_rating_deviation_to(:v2)),
volatility: Keyword.get(opts, :volatility, @default_v2_volatility),
rating: rating,
rating_deviation: rating_deviation,
volatility: volatility,
}
def new_v2(opts), do: new_v2({
Keyword.get(opts, :rating, initial_rating(:v2)),
Keyword.get(opts, :rating_deviation, initial_rating_deviation(:v2)),
Keyword.get(opts, :volatility, initial_v2_volatility()),
})
@doc """
Converts a v2 player to a v1.
@ -99,7 +142,7 @@ defmodule Glicko.Player do
A v2 player will pass-through unchanged with the volatility arg ignored.
"""
@spec to_v2(player :: t, volatility :: float) :: v2_t
def to_v2(player, volatility \\ @default_v2_volatility)
def to_v2(player, volatility \\ initial_v2_volatility())
def to_v2(player = %__MODULE__{version: :v2}, _volatility), do: player
def to_v2(player = %__MODULE__{version: :v1}, volatility), do: new_v2([
rating: player.rating |> scale_rating_to(:v2),
@ -131,14 +174,15 @@ defmodule Glicko.Player do
@doc """
Scales a players rating.
"""
@spec scale_rating_to(rating :: float, to_version :: :v1 | :v2) :: float
@spec scale_rating_to(rating :: Glicko.rating_t, to_version :: Glicko.version_t) :: Glicko.rating_t
def scale_rating_to(rating, :v1), do: (rating * @magic_version_scale) + @magic_version_scale_rating
def scale_rating_to(rating, :v2), do: (rating - @magic_version_scale_rating) / @magic_version_scale
@doc """
Scales a players rating deviation.
"""
@spec scale_rating_deviation_to(rating_deviation :: float, to_version :: :v1 | :v2) :: float
@spec scale_rating_deviation_to(rating_deviation :: Glicko.rating_deviation_t, to_version :: Glicko.version_t) :: Glicko.rating_deviation_t
def scale_rating_deviation_to(rating_deviation, :v1), do: rating_deviation * @magic_version_scale
def scale_rating_deviation_to(rating_deviation, :v2), do: rating_deviation / @magic_version_scale
end

41
lib/glicko/result.ex Normal file
View File

@ -0,0 +1,41 @@
defmodule Glicko.Result do
@moduledoc """
A convenience wrapper representing a result against an opponent.
## Usage
iex> opponent = Player.new_v2
iex> Result.new(opponent, 0.0)
%Result{score: 0.0, opponent: %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06}}
iex> Result.new(opponent, :win) # With shortcut
%Result{score: 1.0, opponent: %Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06}}
"""
alias Glicko.Player
defstruct [
:score,
:opponent,
]
@type t :: %__MODULE__{score: Glicko.score_t, opponent: Glicko.player_t | Player.t}
@type result_type_t :: :loss | :draw | :win
@result_type_map %{loss: 0.0, draw: 0.5, win: 1.0}
@doc """
Creates a new Result against an opponent.
Supports passing either `:loss`, `:draw`, or `:win` as shortcuts.
"""
@spec new(opponent :: Glicko.player_t | Player.t, result_type_t | float) :: t
def new(opponent, result_type) when is_atom(result_type) and result_type in [:loss, :draw, :win] do
new(opponent, Map.fetch!(@result_type_map, result_type))
end
def new(opponent, score) when is_number(score), do: %__MODULE__{
score: score,
opponent: opponent,
}
end

View File

@ -1,22 +0,0 @@
defmodule Glicko.GameResultTest do
use ExUnit.Case
alias Glicko.{
Player,
GameResult,
}
doctest GameResult
@opponent Player.new_v2
@valid_game_result %GameResult{opponent: @opponent, score: 0.0}
test "create game result" do
assert @valid_game_result == GameResult.new(@opponent, 0.0)
end
test "create game result with shortcut" do
assert @valid_game_result == GameResult.new(@opponent, :loss)
end
end

View File

@ -3,7 +3,7 @@ defmodule GlickoTest do
alias Glicko.{
Player,
GameResult,
Result,
}
doctest Glicko
@ -11,9 +11,9 @@ defmodule GlickoTest do
@player Player.new_v1([rating: 1500, rating_deviation: 200]) |> Player.to_v2
@results [
GameResult.new(Player.new_v1([rating: 1400, rating_deviation: 30]), :win),
GameResult.new(Player.new_v1([rating: 1550, rating_deviation: 100]), :loss),
GameResult.new(Player.new_v1([rating: 1700, rating_deviation: 300]), :loss),
{{1400, 30}, 1.0},
Result.new({1550, 100}, :loss),
Result.new(Player.new_v1([rating: 1700, rating_deviation: 300]), :loss),
]
@valid_player_rating_after_results 1464.06 |> Player.scale_rating_to(:v2)

22
test/result_test.exs Normal file
View File

@ -0,0 +1,22 @@
defmodule Glicko.ResultTest do
use ExUnit.Case
alias Glicko.{
Player,
Result,
}
doctest Result
@opponent Player.new_v2
@valid_game_result %Result{opponent: @opponent, score: 0.0}
test "create game result" do
assert @valid_game_result == Result.new(@opponent, 0.0)
end
test "create game result with shortcut" do
assert @valid_game_result == Result.new(@opponent, :loss)
end
end