diff --git a/lib/glicko.ex b/lib/glicko.ex index 7b449f4..45c742c 100644 --- a/lib/glicko.ex +++ b/lib/glicko.ex @@ -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 diff --git a/lib/glicko/game_result.ex b/lib/glicko/game_result.ex deleted file mode 100644 index b67b187..0000000 --- a/lib/glicko/game_result.ex +++ /dev/null @@ -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 diff --git a/lib/glicko/player.ex b/lib/glicko/player.ex index e30da2e..44c81ba 100644 --- a/lib/glicko/player.ex +++ b/lib/glicko/player.ex @@ -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 diff --git a/lib/glicko/result.ex b/lib/glicko/result.ex new file mode 100644 index 0000000..93a7250 --- /dev/null +++ b/lib/glicko/result.ex @@ -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 diff --git a/test/game_result_test.exs b/test/game_result_test.exs deleted file mode 100644 index cbb8080..0000000 --- a/test/game_result_test.exs +++ /dev/null @@ -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 diff --git a/test/glicko_test.exs b/test/glicko_test.exs index 9f61a50..0341f68 100644 --- a/test/glicko_test.exs +++ b/test/glicko_test.exs @@ -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) diff --git a/test/result_test.exs b/test/result_test.exs new file mode 100644 index 0000000..6356a35 --- /dev/null +++ b/test/result_test.exs @@ -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