From ce08ab5e24fc39587560e75a194e3e9a3af23176 Mon Sep 17 00:00:00 2001 From: Mikael Muszynski Date: Wed, 5 Feb 2020 05:15:20 +0100 Subject: [PATCH] Convert Player (v1/v2) and Result to structs In an effort to give names to core concepts in the library, replace the current way of passing around tuples (of varying length and content) with appropriately named structs. As a consequence of creating `Player.{V1, V2}` and `Result`, a variety of functions that extracted values out of the tuples have either been removed or changed to access struct fields instead. --- lib/glicko.ex | 58 ++++++++------- lib/glicko/player.ex | 163 ++++++++++++++++++++----------------------- lib/glicko/result.ex | 41 +++++------ test/glicko_test.exs | 20 +++--- test/player_test.exs | 24 ++++--- test/result_test.exs | 2 +- 6 files changed, 151 insertions(+), 157 deletions(-) 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)