diff --git a/lib/glicko.ex b/lib/glicko.ex index 6c1ab8c..4ab851a 100644 --- a/lib/glicko.ex +++ b/lib/glicko.ex @@ -8,30 +8,30 @@ defmodule Glicko do 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), - ...> 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> results = [Result.new(%Player.V1{rating: 1400, rating_deviation: 30}, :win), + ...> Result.new(%Player.V1{rating: 1550, rating_deviation: 100}, :loss), + ...> Result.new(%Player.V1{rating: 1700, rating_deviation: 300}, :loss)] + iex> player = %Player.V1{rating: 1500, rating_deviation: 200} iex> Glicko.new_rating(player, results, [system_constant: 0.5]) - {1464.0506705393013, 151.51652412385727} + %Player.V1{rating: 1464.0506705393013, rating_deviation: 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> player = %Player.V1{rating: 1500, rating_deviation: 200} iex> Glicko.new_rating(player, [], [system_constant: 0.5]) - {1.5e3, 200.27141669877065} + %Player.V1{rating: 1.5e3, rating_deviation: 200.27141669877065} Calculate the probability of a player winning against an opponent. - iex> player = Player.new_v1 - iex> opponent = Player.new_v1 + iex> player = %Player.V1{} + iex> opponent = %Player.V1{} iex> Glicko.win_probability(player, opponent) 0.5 Calculate the probability of a player drawing against an opponent. - iex> player = Player.new_v1 - iex> opponent = Player.new_v1 + iex> player = %Player.V1{} + iex> opponent = %Player.V1{} iex> Glicko.draw_probability(player, opponent) 1.0 @@ -117,24 +117,33 @@ defmodule Glicko do Player.t() def new_rating(player, results, opts \\ []) - def new_rating(player, results, opts) when tuple_size(player) == 3 do + def new_rating(%Player.V2{} = player, results, opts) do do_new_rating(player, results, opts) end - def new_rating(player, results, opts) when tuple_size(player) == 2 do + def new_rating(%Player.V1{} = player, results, opts) do player |> Player.to_v2() |> do_new_rating(results, opts) |> Player.to_v1() end - defp do_new_rating({player_r, player_pre_rd, player_v}, [], _) do - player_post_rd = calc_player_post_base_rd(:math.pow(player_pre_rd, 2), player_v) + defp do_new_rating(%Player.V2{} = player, [], _) do + player_post_rd = + calc_player_post_base_rd(:math.pow(player.rating_deviation, 2), player.volatility) - {player_r, player_post_rd, player_v} + %{player | rating_deviation: player_post_rd} end - defp do_new_rating({player_pre_r, player_pre_rd, player_pre_v}, results, opts) do + defp do_new_rating( + %Player.V2{ + rating: player_pre_r, + rating_deviation: player_pre_rd, + volatility: player_pre_v + }, + results, + opts + ) do sys_const = Keyword.get(opts, :system_constant, @default_system_constant) conv_tol = Keyword.get(opts, :convergence_tolerance, @default_convergence_tolerance) @@ -184,23 +193,24 @@ defmodule Glicko do player_post_rd = calc_new_player_rating_deviation(player_post_base_rd, variance_est) player_post_r = calc_new_player_rating(results_effect, player_pre_r, player_post_rd) - {player_post_r, player_post_rd, player_post_v} + %Player.V2{ + rating: player_post_r, + rating_deviation: player_post_rd, + volatility: player_post_v + } end defp result_calculations(results, player_pre_r) do {variance_estimate_acc, result_effect_acc} = Enum.reduce(results, {0.0, 0.0}, fn result, {variance_estimate_acc, result_effect_acc} -> - opponent_rd_g = - result - |> Result.opponent_rating_deviation() - |> calc_g + opponent_rd_g = calc_g(result.rating_deviation) - win_probability = calc_e(player_pre_r, Result.opponent_rating(result), opponent_rd_g) + win_probability = calc_e(player_pre_r, result.rating, opponent_rd_g) { variance_estimate_acc + :math.pow(opponent_rd_g, 2) * win_probability * (1 - win_probability), - result_effect_acc + opponent_rd_g * (Result.score(result) - win_probability) + result_effect_acc + opponent_rd_g * (result.score - win_probability) } end) diff --git a/lib/glicko/player.ex b/lib/glicko/player.ex index 5fc2960..f142025 100644 --- a/lib/glicko/player.ex +++ b/lib/glicko/player.ex @@ -6,104 +6,92 @@ defmodule Glicko.Player do Create a *v1* player with the default values for an unrated player. - iex> Player.new_v1 - {1.5e3, 350.0} + iex> %Player.V1{} + %Player.V1{rating: 1.5e3, rating_deviation: 350.0} Create a *v2* player with the default values for an unrated player. - iex> Player.new_v2 - {0.0, 2.014761872416068, 0.06} + iex> %Player.V2{} + %Player.V2{rating: 0.0, rating_deviation: 2.014761872416068, volatility: 0.06} Create a player with custom values. - iex> Player.new_v2([rating: 3.0, rating_deviation: 2.0, volatility: 0.05]) - {3.0, 2.0, 0.05} + iex> %Player.V2{rating: 3.0, rating_deviation: 2.0, volatility: 0.05} + %Player.V2{rating: 3.0, rating_deviation: 2.0, volatility: 0.05} Convert a *v2* player to a *v1*. Note this drops the volatility. - iex> Player.new_v2 |> Player.to_v1 - {1.5e3, 350.0} + iex> %Player.V2{} |> Player.to_v1 + %Player.V1{rating: 1.5e3, rating_deviation: 350.0} Convert a *v1* player to a *v2*. - iex> Player.new_v1 |> Player.to_v2(0.06) - {0.0, 2.014761872416068, 0.06} + iex> %Player.V1{} |> Player.to_v2(0.06) + %Player.V2{rating: 0.0, rating_deviation: 2.014761872416068, volatility: 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. - iex> player_v2 = Player.new_v2 + iex> player_v2 = %Player.V2{} iex> player_v2 == Player.to_v2(player_v2) true """ + defmodule V1 do + @initial_rating 1500.0 + @initial_rating_deviation 350.0 + + @type t :: %__MODULE__{ + rating: float(), + rating_deviation: float() + } + + defstruct rating: @initial_rating, + rating_deviation: @initial_rating_deviation + end + + defmodule V2 do + @magic_version_scale 173.7178 + @magic_version_scale_rating 1500.0 + + @v1_initial_rating 1500.0 + @v1_initial_rating_deviation 350.0 + + @initial_rating (@v1_initial_rating - @magic_version_scale_rating) / @magic_version_scale + @initial_rating_deviation @v1_initial_rating_deviation / @magic_version_scale + @initial_volatility 0.06 + + @type t :: %__MODULE__{ + rating: float(), + rating_deviation: float(), + volatility: float() + } + + defstruct rating: @initial_rating, + rating_deviation: @initial_rating_deviation, + volatility: @initial_volatility + end + @magic_version_scale 173.7178 @magic_version_scale_rating 1500.0 @type t :: v1 | v2 - @type v1 :: {rating, rating_deviation} - @type v2 :: {rating, rating_deviation, volatility} + @type v1 :: V1.t() + @type v2 :: V2.t() @type version :: :v1 | :v2 @type rating :: float @type rating_deviation :: float @type volatility :: float - @doc """ - The recommended initial rating value for a new player. - """ - @spec initial_rating(version) :: rating - def initial_rating(_version = :v1), do: 1500.0 - - def initial_rating(_version = :v2) do - :v1 |> initial_rating |> scale_rating_to(:v2) - end - - @doc """ - The recommended initial rating deviation value for a new player. - """ - @spec initial_rating_deviation(version) :: rating_deviation - 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) - end - @doc """ The recommended initial volatility value for a new player. """ @spec initial_volatility :: volatility 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(rating: rating, rating_deviation: rating_deviation) :: v1 - 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)) - } - end - - @doc """ - Creates a new v2 player. - - If not overriden, will use default values for an unrated player. - """ - @spec new_v2(rating: rating, rating_deviation: rating_deviation, volatility: volatility) :: v2 - 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_volatility()) - } - end - @doc """ Converts a v2 player to a v1. @@ -112,12 +100,12 @@ 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 - def to_v1({rating, rating_deviation}), do: {rating, rating_deviation} + def to_v1(%V1{} = player), do: player - def to_v1({rating, rating_deviation, _}) do - { - rating |> scale_rating_to(:v1), - rating_deviation |> scale_rating_deviation_to(:v1) + def to_v1(%V2{rating: rating, rating_deviation: rating_deviation}) do + %V1{ + rating: scale_rating_to(rating, :v1), + rating_deviation: scale_rating_deviation_to(rating_deviation, :v1) } end @@ -129,53 +117,52 @@ defmodule Glicko.Player do @spec to_v2(player :: t, volatility :: volatility) :: v2 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 + def to_v2(%V1{rating: rating, rating_deviation: rating_deviation}, volatility) do + %V2{ + rating: scale_rating_to(rating, :v2), + rating_deviation: scale_rating_deviation_to(rating_deviation, :v2), + volatility: volatility } end + def to_v2(%V2{} = player, _volatility) do + player + end + @doc """ A version agnostic method for getting a player's rating. """ @spec rating(player :: t, as_version :: version) :: rating 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 + def rating(%_{rating: rating}, nil), do: rating + def rating(%V1{rating: rating}, :v1), do: rating + def rating(%V2{rating: rating}, :v2), do: rating + def rating(%V1{rating: rating}, :v2), do: rating |> scale_rating_to(:v2) + def rating(%V2{rating: rating}, :v1), do: rating |> scale_rating_to(:v1) @doc """ A version agnostic method for getting a player's rating deviation. """ @spec rating_deviation(player :: t, as_version :: version) :: rating_deviation 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(%V1{rating_deviation: rating_deviation}, nil), do: rating_deviation + def rating_deviation(%V2{rating_deviation: rating_deviation}, nil), do: rating_deviation + def rating_deviation(%V1{rating_deviation: rating_deviation}, :v1), do: rating_deviation + def rating_deviation(%V2{rating_deviation: rating_deviation}, :v2), do: rating_deviation - def rating_deviation({_, rating_deviation}, :v2), + def rating_deviation(%V1{rating_deviation: rating_deviation}, :v2), do: rating_deviation |> scale_rating_deviation_to(:v2) - def rating_deviation({_, rating_deviation, _}, :v1), + def rating_deviation(%V2{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) :: volatility def volatility(player, default_volatility \\ initial_volatility()) - def volatility({_, _}, default_volatility), do: default_volatility - def volatility({_, _, volatility}, _), do: volatility + def volatility(%V1{}, default_volatility), do: default_volatility + def volatility(%V2{volatility: volatility}, _), do: volatility @doc """ A convenience function for summarizing a player's strength as a 95% diff --git a/lib/glicko/result.ex b/lib/glicko/result.ex index 4b9432e..b894a88 100644 --- a/lib/glicko/result.ex +++ b/lib/glicko/result.ex @@ -4,17 +4,21 @@ defmodule Glicko.Result do ## Usage - iex> opponent = Player.new_v2 + iex> opponent = %Player.V2{} iex> Result.new(opponent, 1.0) - {0.0, 2.014761872416068, 1.0} + %Result{rating: 0.0, rating_deviation: 2.014761872416068, score: 1.0} iex> Result.new(opponent, :draw) # With shortcut - {0.0, 2.014761872416068, 0.5} + %Result{rating: 0.0, rating_deviation: 2.014761872416068, score: 0.5} """ alias Glicko.Player - @type t :: {Player.rating(), Player.rating_deviation(), score} + @type t :: %__MODULE__{ + rating: Player.rating(), + rating_deviation: Player.rating_deviation(), + score: score + } @type score :: float @type score_shortcut :: :loss | :draw | :win @@ -22,6 +26,8 @@ defmodule Glicko.Result do @score_shortcut_map %{loss: 0.0, draw: 0.5, win: 1.0} @score_shortcuts Map.keys(@score_shortcut_map) + defstruct [:rating, :rating_deviation, :score] + @doc """ Creates a new result from an opponent rating, opponent rating deviation and score. @@ -31,12 +37,17 @@ defmodule Glicko.Result do """ @spec new(Player.rating(), Player.rating_deviation(), score | score_shortcut) :: t def new(opponent_rating, opponent_rating_deviation, score) when is_number(score) do - {opponent_rating, opponent_rating_deviation, score} + %__MODULE__{ + rating: opponent_rating, + rating_deviation: opponent_rating_deviation, + score: score + } end 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)} + score = Map.fetch!(@score_shortcut_map, score_type) + new(opponent_rating, opponent_rating_deviation, score) end @doc """ @@ -48,22 +59,4 @@ defmodule Glicko.Result do 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() - 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() - def opponent_rating_deviation(_result = {_, rating_deviation, _}), do: rating_deviation - - @doc """ - Convenience function for accessing the score. - """ - @spec score(result :: Result.t()) :: score - def score(_result = {_, _, score}), do: score end diff --git a/test/glicko_test.exs b/test/glicko_test.exs index 44b257a..87b1a9f 100644 --- a/test/glicko_test.exs +++ b/test/glicko_test.exs @@ -8,12 +8,12 @@ defmodule GlickoTest do doctest Glicko - @player [rating: 1500, rating_deviation: 200] |> Player.new_v1() |> Player.to_v2() + @player %Player.V1{rating: 1500, rating_deviation: 200} |> Player.to_v2() @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) + Result.new(%Player.V1{rating: 1400, rating_deviation: 30}, :win), + Result.new(%Player.V1{rating: 1550, rating_deviation: 100}, :loss), + Result.new(%Player.V1{rating: 1700, rating_deviation: 300}, :loss) ] @valid_player_rating_after_results 1464.06 |> Player.scale_rating_to(:v2) @@ -47,31 +47,31 @@ defmodule GlickoTest do describe "win probability" do test "with same ratings" do - assert Glicko.win_probability(Player.new_v1(), Player.new_v1()) == 0.5 + assert Glicko.win_probability(%Player.V1{}, %Player.V1{}) == 0.5 end test "with better opponent" do - assert Glicko.win_probability(Player.new_v1(rating: 1500), Player.new_v1(rating: 1600)) < + assert Glicko.win_probability(%Player.V1{rating: 1500}, %Player.V1{rating: 1600}) < 0.5 end test "with better player" do - assert Glicko.win_probability(Player.new_v1(rating: 1600), Player.new_v1(rating: 1500)) > + assert Glicko.win_probability(%Player.V1{rating: 1600}, %Player.V1{rating: 1500}) > 0.5 end end describe "draw probability" do test "with same ratings" do - assert Glicko.draw_probability(Player.new_v1(), Player.new_v1()) == 1 + assert Glicko.draw_probability(%Player.V1{}, %Player.V1{}) == 1 end test "with better opponent" do - assert Glicko.draw_probability(Player.new_v1(rating: 1500), Player.new_v1(rating: 1600)) < 1 + assert Glicko.draw_probability(%Player.V1{rating: 1500}, %Player.V1{rating: 1600}) < 1 end test "with better player" do - assert Glicko.draw_probability(Player.new_v1(rating: 1600), Player.new_v1(rating: 1500)) < 1 + assert Glicko.draw_probability(%Player.V1{rating: 1600}, %Player.V1{rating: 1500}) < 1 end end end diff --git a/test/player_test.exs b/test/player_test.exs index a29cb25..9c224e5 100644 --- a/test/player_test.exs +++ b/test/player_test.exs @@ -5,25 +5,30 @@ defmodule Glicko.PlayerTest do doctest Player - @valid_v1_base {1.0, 2.0} - @valid_v2_base {1.0, 2.0, 3.0} + @valid_v1_base %Player.V1{rating: 1.0, rating_deviation: 2.0} + @valid_v2_base %Player.V2{rating: 1.0, rating_deviation: 2.0, volatility: 3.0} test "create v1" do - assert @valid_v1_base == Player.new_v1(rating: 1.0, rating_deviation: 2.0) + assert @valid_v1_base == %Player.V1{rating: 1.0, rating_deviation: 2.0} end test "create v2" do - assert @valid_v2_base == Player.new_v2(rating: 1.0, rating_deviation: 2.0, volatility: 3.0) + assert @valid_v2_base == %Player.V2{rating: 1.0, rating_deviation: 2.0, volatility: 3.0} end test "convert player v1 -> v2" do - 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) + assert %Player.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) end test "convert player v2 -> v1" do - assert {Player.scale_rating_to(1.0, :v1), Player.scale_rating_deviation_to(2.0, :v1)} == - Player.to_v1(@valid_v2_base) + assert %Player.V1{ + rating: Player.scale_rating_to(1.0, :v1), + rating_deviation: Player.scale_rating_deviation_to(2.0, :v1) + } == Player.to_v1(@valid_v2_base) end test "convert player v1 -> v1" do @@ -52,8 +57,7 @@ defmodule Glicko.PlayerTest do test "rating interval" do assert {rating_low, rating_high} = - [rating: 1850, rating_deviation: 50] - |> Player.new_v2() + %Player.V2{rating: 1850, rating_deviation: 50} |> Player.rating_interval() assert_in_delta rating_low, 1750, 0.1 diff --git a/test/result_test.exs b/test/result_test.exs index 9bfc166..042a344 100644 --- a/test/result_test.exs +++ b/test/result_test.exs @@ -8,7 +8,7 @@ defmodule Glicko.ResultTest do doctest Result - @opponent Player.new_v2() + @opponent %Player.V2{} @valid_game_result Result.new(@opponent, 0.0)