Use tuples exclusively to represent players and results

This commit is contained in:
avitex 2017-11-17 11:08:25 +11:00
parent 61175818ad
commit faf5218189
6 changed files with 163 additions and 182 deletions

View File

@ -6,9 +6,6 @@ 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 player's new rating after a series of matches in a rating period.
iex> results = [Result.new(Player.new_v1([rating: 1400, rating_deviation: 30]), :win),
@ -16,13 +13,13 @@ defmodule Glicko do
...> 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}
{1464.0506705393013, 151.51652412385727}
Get a player's new rating when they haven't played within a rating period.
iex> player = Player.new_v1([rating: 1500, rating_deviation: 200])
iex> Glicko.new_rating(player, [], [system_constant: 0.5])
%Glicko.Player{version: :v1, rating: 1.5e3, rating_deviation: 200.27141669877065, volatility: nil}
{1.5e3, 200.27141669877065}
"""
@ -34,18 +31,6 @@ defmodule Glicko do
@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 """
@ -53,11 +38,16 @@ defmodule Glicko do
Returns the updated player with the same version given to the function.
"""
@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})
@spec new_rating(player :: Player.t, results :: list(Result.t), opts :: new_rating_opts_t) :: Player.t
def new_rating(player, results, opts \\ [])
def new_rating(player, results, opts) when tuple_size(player) == 3 do
do_new_rating(player, results, opts)
end
def new_rating(player, results, opts) when tuple_size(player) == 2 do
player
|> Player.to_v2
|> do_new_rating(results, opts)
|> Player.to_v1
end
defp do_new_rating({player_rating, player_rating_deviation, player_volatility}, [], _) do
@ -111,21 +101,16 @@ defmodule Glicko do
{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)
defp build_internal_result(ctx, result) do
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(:score, Result.score(result))
|> Map.put(:opponent_rating, Result.opponent_rating(result))
|> Map.put(:opponent_rating_deviation, Result.opponent_rating_deviation(result))
|> Map.put(:opponent_rating_deviation_g, calc_g(Result.opponent_rating_deviation(result)))
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(ctx) do
@ -218,26 +203,4 @@ 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,28 +1,33 @@
defmodule Glicko.Player do
@moduledoc """
A convenience wrapper that handles conversions between glicko versions one and two.
Provides convenience functions that handle conversions between Glicko versions one and two.
## Usage
Create a player with the default values for an unrated player.
Create a *v1* player with the default values for an unrated player.
iex> Player.new_v1
{1.5e3, 350.0}
Create a *v2* player with the default values for an unrated player.
iex> Player.new_v2
%Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06}
{0.0, 2.014761872416068, 0.06}
Create a player with custom values.
iex> Player.new_v2([rating: 1500, rating_deviation: 50, volatility: 0.05])
%Player{version: :v2, rating: 1500, rating_deviation: 50, volatility: 0.05}
iex> Player.new_v2([rating: 3.0, rating_deviation: 2.0, volatility: 0.05])
{3.0, 2.0, 0.05}
Convert a *v2* player to a *v1*. Note this drops the volatility.
iex> Player.new_v2 |> Player.to_v1
%Player{version: :v1, rating: 1.5e3, rating_deviation: 350.0, volatility: nil}
{1.5e3, 350.0}
Convert a *v1* player to a *v2*.
iex> Player.new_v1 |> Player.to_v2(0.06)
%Player{version: :v2, rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06}
{0.0, 2.014761872416068, 0.06}
Note calling `to_v1` with a *v1* player or likewise with `to_v2` and a *v2* player
will pass-through unchanged. The volatility arg in this case is ignored.
@ -38,89 +43,56 @@ defmodule Glicko.Player do
@type t :: v1_t | v2_t
@type v1_t :: %__MODULE__{
version: :v1,
rating: Glicko.rating_t,
rating_deviation: Glicko.rating_deviation_t,
volatility: nil,
}
@type v1_t :: {rating_t, rating_deviation_t}
@type v2_t :: {rating_t, rating_deviation_t, volatility_t}
@type v2_t :: %__MODULE__{
version: :v2,
rating: Glicko.rating_t,
rating_deviation: Glicko.rating_deviation_t,
volatility: Glicko.volatility_t,
}
defstruct [
:version,
:rating,
:rating_deviation,
:volatility,
]
@type version_t :: :v1 | :v2
@type rating_t :: float
@type rating_deviation_t :: float
@type volatility_t :: float
@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)
@spec initial_rating(version_t) :: rating_t
def initial_rating(_version = :v1), do: 1500.0
def initial_rating(_version = :v2), do: :v1 |> initial_rating |> 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)
@spec initial_rating_deviation(version_t) :: rating_deviation_t
def initial_rating_deviation(_version = :v1), do: 350.0
def initial_rating_deviation(_version = :v2), do: :v1 |> initial_rating_deviation |> 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
@spec initial_volatility :: volatility_t
def initial_volatility, do: 0.06
@doc """
Creates a new v1 player.
If not overriden, will use the default values for an unrated player.
"""
@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: rating,
rating_deviation: rating_deviation,
volatility: nil,
}
def new_v1(opts), do: new_v1({
@spec new_v1([rating: rating_t, rating_deviation: rating_deviation_t]) :: v1_t
def new_v1(opts \\ []) when is_list(opts), do: {
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(
{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: rating,
rating_deviation: rating_deviation,
volatility: volatility,
}
def new_v2(opts), do: new_v2({
@spec new_v2([rating: rating_t, rating_deviation: rating_deviation_t, volatility: volatility_t]) :: v2_t
def new_v2(opts \\ []) when is_list(opts), do: {
Keyword.get(opts, :rating, initial_rating(:v2)),
Keyword.get(opts, :rating_deviation, initial_rating_deviation(:v2)),
Keyword.get(opts, :volatility, initial_v2_volatility()),
})
Keyword.get(opts, :volatility, initial_volatility()),
}
@doc """
Converts a v2 player to a v1.
@ -130,25 +102,57 @@ defmodule Glicko.Player do
Note the volatility field used in a v2 player will be lost in the conversion.
"""
@spec to_v1(player :: t) :: v1_t
def to_v1(player = %__MODULE__{version: :v1}), do: player
def to_v1(player = %__MODULE__{version: :v2}), do: new_v1([
rating: player.rating |> scale_rating_to(:v1),
rating_deviation: player.rating_deviation |> scale_rating_deviation_to(:v1),
])
def to_v1({rating, rating_deviation}), do: {rating, rating_deviation}
def to_v1({rating, rating_deviation, _}), do: {
rating |> scale_rating_to(:v1),
rating_deviation |> scale_rating_deviation_to(:v1),
}
@doc """
Converts a v1 player to a v2.
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 \\ 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),
rating_deviation: player.rating_deviation |> scale_rating_deviation_to(:v2),
volatility: volatility,
])
@spec to_v2(player :: t, volatility :: volatility_t) :: v2_t
def to_v2(player, volatility \\ initial_volatility())
def to_v2({rating, rating_deviation, volatility}, _volatility), do: {rating, rating_deviation, volatility}
def to_v2({rating, rating_deviation}, volatility), do: {
rating |> scale_rating_to(:v2),
rating_deviation |> scale_rating_deviation_to(:v2),
volatility,
}
@doc """
A version agnostic method for getting a player's rating.
"""
@spec rating(player :: t, as_version :: version_t) :: rating_t
def rating(player, as_version \\ nil)
def rating({rating, _}, nil), do: rating
def rating({rating, _, _}, nil), do: rating
def rating({rating, _}, :v1), do: rating
def rating({rating, _}, :v2), do: rating |> scale_rating_to(:v2)
def rating({rating, _, _}, :v1), do: rating |> scale_rating_to(:v1)
def rating({rating, _, _}, :v2), do: rating
@doc """
A version agnostic method for getting a player's rating deviation.
"""
@spec rating_deviation(player :: t, as_version :: version_t) :: rating_deviation_t
def rating_deviation(player, as_version \\ nil)
def rating_deviation({_, rating_deviation}, nil), do: rating_deviation
def rating_deviation({_, rating_deviation, _}, nil), do: rating_deviation
def rating_deviation({_, rating_deviation}, :v1), do: rating_deviation
def rating_deviation({_, rating_deviation}, :v2), do: rating_deviation |> scale_rating_deviation_to(:v2)
def rating_deviation({_, rating_deviation, _}, :v1), do: rating_deviation |> scale_rating_deviation_to(:v1)
def rating_deviation({_, rating_deviation, _}, :v2), do: rating_deviation
@doc """
A version agnostic method for getting a player's volatility.
"""
@spec volatility(player :: t, default_volatility :: volatility_t) :: volatility_t
def volatility(player, default_volatility \\ initial_volatility())
def volatility({_, _}, default_volatility), do: default_volatility
def volatility({_, _, volatility}, _), do: volatility
@doc """
A convenience function for summarizing a player's strength as a 95%
@ -166,23 +170,23 @@ defmodule Glicko.Player do
be 95% confident about a players strength being in a small interval of values.
"""
@spec rating_interval(player :: t) :: {rating_low :: float, rating_high :: float}
def rating_interval(player), do: {
player.rating - player.rating_deviation * 2,
player.rating + player.rating_deviation * 2,
def rating_interval(player, as_version \\ nil), do: {
rating(player, as_version) - rating_deviation(player, as_version) * 2,
rating(player, as_version) + rating_deviation(player, as_version) * 2,
}
@doc """
Scales a player's rating.
"""
@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
@spec scale_rating_to(rating :: rating_t, to_version :: version_t) :: rating_t
def scale_rating_to(rating, _version = :v1), do: (rating * @magic_version_scale) + @magic_version_scale_rating
def scale_rating_to(rating, _version = :v2), do: (rating - @magic_version_scale_rating) / @magic_version_scale
@doc """
Scales a player's rating deviation.
"""
@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
@spec scale_rating_deviation_to(rating_deviation :: rating_deviation_t, to_version :: version_t) :: rating_deviation_t
def scale_rating_deviation_to(rating_deviation, _version = :v1), do: rating_deviation * @magic_version_scale
def scale_rating_deviation_to(rating_deviation, _version = :v2), do: rating_deviation / @magic_version_scale
end

View File

@ -1,41 +1,66 @@
defmodule Glicko.Result do
@moduledoc """
A convenience wrapper representing a result against an opponent.
Convenience functions for handling 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}}
iex> Result.new(opponent, 1.0)
{0.0, 2.014761872416068, 1.0}
iex> Result.new(opponent, :draw) # With shortcut
{0.0, 2.014761872416068, 0.5}
"""
alias Glicko.Player
defstruct [
:score,
:opponent,
]
@type t :: {Player.rating_t, Player.rating_deviation_t, score_t}
@type t :: %__MODULE__{score: Glicko.score_t, opponent: Glicko.player_t | Player.t}
@type score_t :: float
@type score_shortcut_t :: :loss | :draw | :win
@type result_type_t :: :loss | :draw | :win
@result_type_map %{loss: 0.0, draw: 0.5, win: 1.0}
@score_shortcut_map %{loss: 0.0, draw: 0.5, win: 1.0}
@score_shortcuts Map.keys(@score_shortcut_map)
@doc """
Creates a new Result against an opponent.
Creates a new result from an opponent rating, opponent rating deviation and score.
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))
@spec new(Player.rating_t, Player.rating_deviation_t, score_t | score_shortcut_t) :: t
def new(opponent_rating, opponent_rating_deviation, score) when is_number(score) do
{opponent_rating, opponent_rating_deviation, score}
end
def new(opponent, score) when is_number(score), do: %__MODULE__{
score: score,
opponent: opponent,
}
def new(opponent_rating, opponent_rating_deviation, score_type) when is_atom(score_type) and score_type in @score_shortcuts do
{opponent_rating, opponent_rating_deviation, Map.fetch!(@score_shortcut_map, score_type)}
end
@doc """
Creates a new result from an opponent and score.
Supports passing either `:loss`, `:draw`, or `:win` as shortcuts.
"""
@spec new(opponent :: Player.t, score :: score_t | score_shortcut_t) :: t
def new(opponent, score) do
new(Player.rating(opponent, :v2), Player.rating_deviation(opponent, :v2), score)
end
@doc """
Convenience function for accessing an opponent's rating.
"""
@spec opponent_rating(result :: Result.t) :: Player.rating_t
def opponent_rating(_result = {rating, _, _}), do: rating
@doc """
Convenience function for accessing an opponent's rating deviation.
"""
@spec opponent_rating_deviation(result :: Result.t) :: Player.rating_deviation_t
def opponent_rating_deviation(_result = {_, rating_deviation, _}), do: rating_deviation
@doc """
Convenience function for accessing the score.
"""
@spec score(result :: Result.t) :: score_t
def score(_result = {_, _, score}), do: score
end

View File

@ -11,8 +11,8 @@ defmodule GlickoTest do
@player Player.new_v1([rating: 1500, rating_deviation: 200]) |> Player.to_v2
@results [
{{1400, 30}, 1.0},
Result.new({1550, 100}, :loss),
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),
]
@ -23,17 +23,16 @@ defmodule GlickoTest do
@valid_player_rating_deviation_after_no_results 200.2714 |> Player.scale_rating_deviation_to(:v2)
test "new rating (with results)" do
%Player{rating: new_rating, rating_deviation: new_rating_deviation, volatility: new_volatility} =
Glicko.new_rating(@player, @results, [system_constant: 0.5])
player = Glicko.new_rating(@player, @results, [system_constant: 0.5])
assert_in_delta new_rating, @valid_player_rating_after_results, 1.0e-4
assert_in_delta new_rating_deviation, @valid_player_rating_deviation_after_results, 1.0e-4
assert_in_delta new_volatility, @valid_player_volatility_after_results, 1.0e-5
assert_in_delta Player.rating(player), @valid_player_rating_after_results, 1.0e-4
assert_in_delta Player.rating_deviation(player), @valid_player_rating_deviation_after_results, 1.0e-4
assert_in_delta Player.volatility(player), @valid_player_volatility_after_results, 1.0e-5
end
test "new rating (no results)" do
%Player{rating_deviation: new_rating_deviation} = Glicko.new_rating(@player, [])
player = Glicko.new_rating(@player, [])
assert_in_delta new_rating_deviation, @valid_player_rating_deviation_after_no_results, 1.0e-4
assert_in_delta Player.rating_deviation(player), @valid_player_rating_deviation_after_no_results, 1.0e-4
end
end

View File

@ -5,8 +5,8 @@ defmodule Glicko.PlayerTest do
doctest Player
@valid_v1_base %Player{version: :v1, rating: 1.0, rating_deviation: 2.0, volatility: nil}
@valid_v2_base %Player{version: :v2, rating: 1.0, rating_deviation: 2.0, volatility: 3.0}
@valid_v1_base {1.0, 2.0}
@valid_v2_base {1.0, 2.0, 3.0}
test "create v1" do
assert @valid_v1_base == Player.new_v1([rating: 1.0, rating_deviation: 2.0])
@ -17,21 +17,11 @@ defmodule Glicko.PlayerTest do
end
test "convert player v1 -> v2" do
assert %Player{
version: :v2,
rating: Player.scale_rating_to(1.0, :v2),
rating_deviation: Player.scale_rating_deviation_to(2.0, :v2),
volatility: 3.0,
} == Player.to_v2(@valid_v1_base, 3.0)
assert {Player.scale_rating_to(1.0, :v2), Player.scale_rating_deviation_to(2.0, :v2), 3.0} == Player.to_v2(@valid_v1_base, 3.0)
end
test "convert player v2 -> v1" do
assert %Player{
version: :v1,
rating: Player.scale_rating_to(1.0, :v1),
rating_deviation: Player.scale_rating_deviation_to(2.0, :v1),
volatility: nil,
} == Player.to_v1(@valid_v2_base)
assert {Player.scale_rating_to(1.0, :v1), Player.scale_rating_deviation_to(2.0, :v1)} == Player.to_v1(@valid_v2_base)
end
test "convert player v1 -> v1" do

View File

@ -10,7 +10,7 @@ defmodule Glicko.ResultTest do
@opponent Player.new_v2
@valid_game_result %Result{opponent: @opponent, score: 0.0}
@valid_game_result Result.new(@opponent, 0.0)
test "create game result" do
assert @valid_game_result == Result.new(@opponent, 0.0)