From feee72ebb40d804f8b12d7c393a30953abcef117 Mon Sep 17 00:00:00 2001 From: myssto Date: Sat, 9 Aug 2025 03:19:21 -0500 Subject: [PATCH 1/5] feat: OpenSkillModelBase.PredictRank() --- OpenSkillSharp.Tests/PredictRankTests.cs | 35 ++++++++++++++++++++++ OpenSkillSharp/IOpenSkillModel.cs | 7 +++++ OpenSkillSharp/OpenSkillModelBase.cs | 38 ++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 OpenSkillSharp.Tests/PredictRankTests.cs diff --git a/OpenSkillSharp.Tests/PredictRankTests.cs b/OpenSkillSharp.Tests/PredictRankTests.cs new file mode 100644 index 0000000..dacf4b6 --- /dev/null +++ b/OpenSkillSharp.Tests/PredictRankTests.cs @@ -0,0 +1,35 @@ +using OpenSkillSharp.Models; +using OpenSkillSharp.Rating; + +namespace OpenSkillSharp.Tests; + +public class PredictRankTests +{ + private const double Tolerance = 0.0000001; + private static readonly PlackettLuce Model = new(); + private readonly ITeam _teamA = new Team { Players = [Model.Rating(34, 0.25), Model.Rating(24, 0.5)] }; + + [Fact] + public void PredictRank_ProducesTotalProbabilityOf1() + { + List<(int rank, double probability)> rankProbabilities = Model.PredictRank([ + _teamA, + new Team { Players = [Model.Rating(32, 0.25), Model.Rating(22, 0.5)] }, + new Team { Players = [Model.Rating(30, 0.25), Model.Rating(20, 0.5)] } + ]); + + Assert.Equal(1, rankProbabilities.Sum(p => p.probability), Tolerance); + } + + [Fact] + public void PredictRank_GivenIdenticalTeams_ProducesTotalProbabilityOf1() + { + List<(int rank, double probability)> rankProbabilities = Model.PredictRank([ + _teamA, + _teamA, + _teamA + ]); + + Assert.Equal(1, rankProbabilities.Sum(p => p.probability), Tolerance); + } +} \ No newline at end of file diff --git a/OpenSkillSharp/IOpenSkillModel.cs b/OpenSkillSharp/IOpenSkillModel.cs index aa51cda..7a681a4 100644 --- a/OpenSkillSharp/IOpenSkillModel.cs +++ b/OpenSkillSharp/IOpenSkillModel.cs @@ -113,4 +113,11 @@ public IEnumerable Rate( /// A list of two or more teams. /// A number representing the odds of a draw as a percentage from 0.0 to 1.0 public double PredictDraw(IList teams); + + /// + /// Predict the shape of a match outcome. + /// + /// A list of two or more teams. + /// A list of tuples containing a team's predicted rank and the probability for each given team. + public List<(int rank, double probability)> PredictRank(IList teams); } \ No newline at end of file diff --git a/OpenSkillSharp/OpenSkillModelBase.cs b/OpenSkillSharp/OpenSkillModelBase.cs index 05c8c85..25448ab 100644 --- a/OpenSkillSharp/OpenSkillModelBase.cs +++ b/OpenSkillSharp/OpenSkillModelBase.cs @@ -177,6 +177,44 @@ public double PredictDraw(IList teams) ).Average(); } + public List<(int rank, double probability)> PredictRank(IList teams) + { + List teamRatings = CalculateTeamRatings(teams).ToList(); + + List winProbabilities = teamRatings + .Select((iTeam, iTeamIndex) => teamRatings + .Where((_, qTeamIndex) => iTeamIndex != qTeamIndex) + .Select(qTeam => Statistics.PhiMajor( + (iTeam.Mu - qTeam.Mu) / + Math.Sqrt((2 * BetaSq) + iTeam.SigmaSq + qTeam.SigmaSq) + )) + .Average() + ).ToList(); + + // Normalize probabilities + double totalProbability = winProbabilities.Sum(); + winProbabilities = winProbabilities.Select(p => p / totalProbability).ToList(); + // Sort descending, preserve original index + List<(int index, double p)> sortedProbabilities = winProbabilities + .Index() + .OrderByDescending(p => p.Item) + .ToList(); + + int curRank = 1; + int[] ranks = new int[teams.Count]; + foreach ((int index, double p) in sortedProbabilities) + { + if (index > 0 && p < sortedProbabilities[index - 1].p) + { + curRank = index + 1; + } + + ranks[index] = curRank; + } + + return ranks.Zip(winProbabilities).ToList(); + } + /// /// Creates team ratings for a game. /// From 4db92241697dafb3c4f78c9ca996b5363db12397 Mon Sep 17 00:00:00 2001 From: myssto Date: Sat, 9 Aug 2025 03:24:34 -0500 Subject: [PATCH 2/5] docs: Add remarks to IOpenSkillModel.PredictDraw --- OpenSkillSharp/IOpenSkillModel.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/OpenSkillSharp/IOpenSkillModel.cs b/OpenSkillSharp/IOpenSkillModel.cs index 7a681a4..9790854 100644 --- a/OpenSkillSharp/IOpenSkillModel.cs +++ b/OpenSkillSharp/IOpenSkillModel.cs @@ -110,6 +110,10 @@ public IEnumerable Rate( /// /// Predict how likely a match of teams of one or more players will conclude in a draw. /// + /// + /// This measure is also commonly referred to as "match quality", as the lower the probability of a draw is in + /// a game, the more "one-sided" it is considered. + /// /// A list of two or more teams. /// A number representing the odds of a draw as a percentage from 0.0 to 1.0 public double PredictDraw(IList teams); From 577c271dd7a1275a0702c2dd3cb62faf7833f3a5 Mon Sep 17 00:00:00 2001 From: myssto Date: Wed, 3 Dec 2025 22:43:33 -0600 Subject: [PATCH 3/5] docs: Add nuget link to shield --- OpenSkillSharp.sln.DotSettings | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/OpenSkillSharp.sln.DotSettings b/OpenSkillSharp.sln.DotSettings index d92cf78..6378484 100644 --- a/OpenSkillSharp.sln.DotSettings +++ b/OpenSkillSharp.sln.DotSettings @@ -4,6 +4,7 @@ True True True + True True True True diff --git a/README.md b/README.md index 573808d..5de122c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![NuGet Version](https://img.shields.io/nuget/v/OpenSkillSharp)](https://img.shields.io/nuget/v/OpenSkillSharp) +[![NuGet Version](https://img.shields.io/nuget/v/OpenSkillSharp)](https://www.nuget.org/packages/OpenSkillSharp) ![Downloads](https://img.shields.io/nuget/dt/OpenSkillSharp) ![Tests](https://img.shields.io/github/actions/workflow/status/myssto/OpenSkillSharp/ci.yml?label=tests) [![Coverage](https://img.shields.io/coverallsCoverage/github/myssto/OpenSkillSharp)](https://coveralls.io/github/myssto/OpenSkillSharp) From 4c6473a36fc866e2e9b22eaa1efdb4fc7ae69f04 Mon Sep 17 00:00:00 2001 From: myssto Date: Wed, 3 Dec 2025 23:16:56 -0600 Subject: [PATCH 4/5] fmt: Apply new formatting rules --- .editorconfig | 118 +++++++--- OpenSkillSharp.Tests/ModelUtilTests.cs | 50 ++-- .../Models/BradleyTerryPartTests.cs | 8 +- .../Models/ThurstoneMostellerPartTests.cs | 8 +- OpenSkillSharp.Tests/PredictDrawTests.cs | 65 +++--- OpenSkillSharp.Tests/PredictRankTests.cs | 18 +- OpenSkillSharp.Tests/PredictWinTests.cs | 41 ++-- .../TestingUtil/ModelTestData.cs | 60 +++-- .../Util/EnumerableExtensionTests.cs | 14 +- .../Util/RatingExtensionTests.cs | 44 ++-- OpenSkillSharp/Models/BradleyTerryFull.cs | 98 ++++---- OpenSkillSharp/Models/BradleyTerryPart.cs | 116 +++++----- OpenSkillSharp/Models/PlackettLuce.cs | 93 ++++---- .../Models/ThurstoneMostellerFull.cs | 142 +++++++----- .../Models/ThurstoneMostellerPart.cs | 158 +++++++------ OpenSkillSharp/OpenSkillModelBase.cs | 213 ++++++++++-------- OpenSkillSharp/OpenSkillSharp.csproj | 2 +- OpenSkillSharp/Util/Common.cs | 8 +- OpenSkillSharp/Util/Statistics.cs | 26 ++- README.md | 65 ++++-- 20 files changed, 788 insertions(+), 559 deletions(-) diff --git a/.editorconfig b/.editorconfig index 95c479b..6296f13 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,11 +11,39 @@ indent_size = 4 # ReSharper properties resharper_autodetect_indent_settings = true +resharper_cpp_insert_final_newline = true +resharper_csharp_wrap_after_declaration_lpar = true +resharper_csharp_wrap_after_invocation_lpar = true +resharper_csharp_wrap_arguments_style = chop_if_long +resharper_csharp_wrap_before_declaration_rpar = true +resharper_csharp_wrap_before_first_type_parameter_constraint = true +resharper_csharp_wrap_before_invocation_rpar = true +resharper_csharp_wrap_extends_list_style = chop_if_long +resharper_csharp_wrap_parameters_style = chop_if_long resharper_formatter_off_tag = @formatter:off resharper_formatter_on_tag = @formatter:on resharper_formatter_tags_enabled = true +resharper_keep_existing_declaration_parens_arrangement = false +resharper_keep_existing_embedded_arrangement = false +resharper_keep_existing_initializer_arrangement = false +resharper_keep_existing_list_patterns_arrangement = false +resharper_keep_existing_property_patterns_arrangement = false +resharper_keep_existing_switch_expression_arrangement = false +resharper_nested_ternary_style = expanded +resharper_place_accessorholder_attribute_on_same_line = false +resharper_place_accessor_attribute_on_same_line = false +resharper_place_field_attribute_on_same_line = false resharper_show_autodetect_configure_formatting_tip = false resharper_use_indent_from_vs = false +resharper_wrap_array_initializer_style = chop_if_long +resharper_wrap_before_primary_constructor_declaration_lpar = true +resharper_wrap_before_primary_constructor_declaration_rpar = true +resharper_wrap_chained_binary_expressions = chop_if_long +resharper_wrap_chained_binary_patterns = chop_if_long +resharper_wrap_chained_method_calls = chop_if_long +resharper_wrap_list_pattern = chop_if_long +resharper_xmldoc_indent_child_elements = ZeroIndent +resharper_xmldoc_indent_text = ZeroIndent resharper_xmldoc_space_before_self_closing = false resharper_xmldoc_wrap_around_elements = false @@ -44,6 +72,34 @@ resharper_web_config_module_not_resolved_highlighting = warning resharper_web_config_type_not_resolved_highlighting = warning resharper_web_config_wrong_module_highlighting = warning +# Microsoft .NET properties +csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +dotnet_naming_rule.unity_serialized_field_rule.import_to_resharper = True +dotnet_naming_rule.unity_serialized_field_rule.resharper_description = Unity serialized field +dotnet_naming_rule.unity_serialized_field_rule.resharper_guid = 5f0fdb63-c892-4d2c-9324-15c80b22a7ef +dotnet_naming_rule.unity_serialized_field_rule.severity = warning +dotnet_naming_rule.unity_serialized_field_rule.style = lower_camel_case_style +dotnet_naming_rule.unity_serialized_field_rule.symbols = unity_serialized_field_symbols +dotnet_naming_style.lower_camel_case_style.capitalization = camel_case +dotnet_naming_symbols.unity_serialized_field_symbols.applicable_accessibilities = * +dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds = +dotnet_naming_symbols.unity_serialized_field_symbols.resharper_applicable_kinds = unity_serialised_field +dotnet_naming_symbols.unity_serialized_field_symbols.resharper_required_modifiers = instance +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + #### Core EditorConfig Options #### #### .NET Coding Conventions #### @@ -132,7 +188,7 @@ csharp_style_conditional_delegate_call = true:suggestion # Modifier preferences csharp_prefer_static_anonymous_function = true:suggestion csharp_prefer_static_local_function = true:warning -csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_preferred_modifier_order = public, private, protected, internal, file, const, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, required, volatile, async:suggestion csharp_style_prefer_readonly_struct = true:suggestion csharp_style_prefer_readonly_struct_member = true:suggestion @@ -293,31 +349,31 @@ dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase dotnet_naming_symbols.interfaces.applicable_kinds = interface dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interfaces.required_modifiers = +dotnet_naming_symbols.interfaces.required_modifiers = dotnet_naming_symbols.enums.applicable_kinds = enum dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.enums.required_modifiers = +dotnet_naming_symbols.enums.required_modifiers = dotnet_naming_symbols.events.applicable_kinds = event dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.events.required_modifiers = +dotnet_naming_symbols.events.required_modifiers = dotnet_naming_symbols.methods.applicable_kinds = method dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.methods.required_modifiers = +dotnet_naming_symbols.methods.required_modifiers = dotnet_naming_symbols.properties.applicable_kinds = property dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.properties.required_modifiers = +dotnet_naming_symbols.properties.required_modifiers = dotnet_naming_symbols.public_fields.applicable_kinds = field dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal -dotnet_naming_symbols.public_fields.required_modifiers = +dotnet_naming_symbols.public_fields.required_modifiers = dotnet_naming_symbols.private_fields.applicable_kinds = field dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_fields.required_modifiers = +dotnet_naming_symbols.private_fields.required_modifiers = dotnet_naming_symbols.private_static_fields.applicable_kinds = field dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected @@ -325,15 +381,15 @@ dotnet_naming_symbols.private_static_fields.required_modifiers = static dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types_and_namespaces.required_modifiers = +dotnet_naming_symbols.types_and_namespaces.required_modifiers = dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = +dotnet_naming_symbols.non_field_members.required_modifiers = dotnet_naming_symbols.type_parameters.applicable_kinds = namespace dotnet_naming_symbols.type_parameters.applicable_accessibilities = * -dotnet_naming_symbols.type_parameters.required_modifiers = +dotnet_naming_symbols.type_parameters.required_modifiers = dotnet_naming_symbols.private_constant_fields.applicable_kinds = field dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected @@ -341,7 +397,7 @@ dotnet_naming_symbols.private_constant_fields.required_modifiers = const dotnet_naming_symbols.local_variables.applicable_kinds = local dotnet_naming_symbols.local_variables.applicable_accessibilities = local -dotnet_naming_symbols.local_variables.required_modifiers = +dotnet_naming_symbols.local_variables.required_modifiers = dotnet_naming_symbols.local_constants.applicable_kinds = local dotnet_naming_symbols.local_constants.applicable_accessibilities = local @@ -349,7 +405,7 @@ dotnet_naming_symbols.local_constants.required_modifiers = const dotnet_naming_symbols.parameters.applicable_kinds = parameter dotnet_naming_symbols.parameters.applicable_accessibilities = * -dotnet_naming_symbols.parameters.required_modifiers = +dotnet_naming_symbols.parameters.required_modifiers = dotnet_naming_symbols.public_constant_fields.applicable_kinds = field dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal @@ -365,38 +421,38 @@ dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readon dotnet_naming_symbols.local_functions.applicable_kinds = local_function dotnet_naming_symbols.local_functions.applicable_accessibilities = * -dotnet_naming_symbols.local_functions.required_modifiers = +dotnet_naming_symbols.local_functions.required_modifiers = # Naming styles -dotnet_naming_style.pascalcase.required_prefix = -dotnet_naming_style.pascalcase.required_suffix = -dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = dotnet_naming_style.pascalcase.capitalization = pascal_case dotnet_naming_style.ipascalcase.required_prefix = I -dotnet_naming_style.ipascalcase.required_suffix = -dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = dotnet_naming_style.ipascalcase.capitalization = pascal_case dotnet_naming_style.tpascalcase.required_prefix = T -dotnet_naming_style.tpascalcase.required_suffix = -dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = dotnet_naming_style.tpascalcase.capitalization = pascal_case dotnet_naming_style._camelcase.required_prefix = _ -dotnet_naming_style._camelcase.required_suffix = -dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = dotnet_naming_style._camelcase.capitalization = camel_case -dotnet_naming_style.camelcase.required_prefix = -dotnet_naming_style.camelcase.required_suffix = -dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = dotnet_naming_style.camelcase.capitalization = camel_case dotnet_naming_style.s_camelcase.required_prefix = s_ -dotnet_naming_style.s_camelcase.required_suffix = -dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = dotnet_naming_style.s_camelcase.capitalization = camel_case [{*.xml,*yml,*.har,*.inputactions,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}] @@ -404,3 +460,9 @@ indent_size = 2 [*.{appxmanifest,asax,ascx,aspx,axaml,blockshader,build,c,c++,c++m,cc,ccm,cginc,compute,cp,cpp,cppm,cs,cshtml,cu,cuh,cxx,cxxm,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,h++,hh,hlsl,hlsli,hlslinc,hp,hpp,hxx,icc,inc,inl,ino,ipp,ixx,master,ml,mli,mpp,mq4,mq5,mqh,mxx,nuspec,paml,razor,resw,resx,shader,shaderFoundry,skin,tcc,tpp,urtshader,usf,ush,uxml,vb,xaml,xamlx,xoml,xsd}] tab_width = 4 +indent_style = space +indent_size = 4 + +[{*.har,*.inputactions,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}] +indent_style = space +indent_size = 2 diff --git a/OpenSkillSharp.Tests/ModelUtilTests.cs b/OpenSkillSharp.Tests/ModelUtilTests.cs index b038b2c..3df95e5 100644 --- a/OpenSkillSharp.Tests/ModelUtilTests.cs +++ b/OpenSkillSharp.Tests/ModelUtilTests.cs @@ -24,11 +24,9 @@ public void CalculateTeamSqrtSigma() { PlackettLuce model = new(); List teamRatings = model.CalculateTeamRatings( - [ - new Team { Players = [model.Rating()] }, - new Team { Players = [model.Rating(), model.Rating()] } - ] - ).ToList(); + [new Team { Players = [model.Rating()] }, new Team { Players = [model.Rating(), model.Rating()] }] + ) + .ToList(); double teamSqrtSigma = model.CalculateTeamSqrtSigma(teamRatings); @@ -40,11 +38,18 @@ public void CalculateTeamSqrtSigma_5v5() { PlackettLuce model = new(); List teamRatings = model.CalculateTeamRatings( - [ - new Team { Players = [model.Rating(), model.Rating(), model.Rating(), model.Rating(), model.Rating()] }, - new Team { Players = [model.Rating(), model.Rating(), model.Rating(), model.Rating(), model.Rating()] } - ] - ).ToList(); + [ + new Team + { + Players = [model.Rating(), model.Rating(), model.Rating(), model.Rating(), model.Rating()] + }, + new Team + { + Players = [model.Rating(), model.Rating(), model.Rating(), model.Rating(), model.Rating()] + } + ] + ) + .ToList(); double teamSqrtSigma = model.CalculateTeamSqrtSigma(teamRatings); @@ -56,11 +61,9 @@ public void CalculateSumQ() { PlackettLuce model = new(); List teamRatings = model.CalculateTeamRatings( - [ - new Team { Players = [model.Rating()] }, - new Team { Players = [model.Rating(), model.Rating()] } - ] - ).ToList(); + [new Team { Players = [model.Rating()] }, new Team { Players = [model.Rating(), model.Rating()] }] + ) + .ToList(); double teamSqrtSigma = model.CalculateTeamSqrtSigma(teamRatings); IEnumerable sumQ = model.CalculateSumQ(teamRatings, teamSqrtSigma); @@ -73,11 +76,18 @@ public void CalculateSumQ_5v5() { PlackettLuce model = new(); List teamRatings = model.CalculateTeamRatings( - [ - new Team { Players = [model.Rating(), model.Rating(), model.Rating(), model.Rating(), model.Rating()] }, - new Team { Players = [model.Rating(), model.Rating(), model.Rating(), model.Rating(), model.Rating()] } - ] - ).ToList(); + [ + new Team + { + Players = [model.Rating(), model.Rating(), model.Rating(), model.Rating(), model.Rating()] + }, + new Team + { + Players = [model.Rating(), model.Rating(), model.Rating(), model.Rating(), model.Rating()] + } + ] + ) + .ToList(); double teamSqrtSigma = model.CalculateTeamSqrtSigma(teamRatings); List sumQ = model.CalculateSumQ(teamRatings, teamSqrtSigma).ToList(); diff --git a/OpenSkillSharp.Tests/Models/BradleyTerryPartTests.cs b/OpenSkillSharp.Tests/Models/BradleyTerryPartTests.cs index 75f7315..c59ef9d 100644 --- a/OpenSkillSharp.Tests/Models/BradleyTerryPartTests.cs +++ b/OpenSkillSharp.Tests/Models/BradleyTerryPartTests.cs @@ -169,11 +169,9 @@ public void Rate_WindowSize0_Tau0() // Act List results = windowTauModel.Rate( - [ - new Team { Players = [playerA] }, - new Team { Players = [playerB] } - ] - ).ToList(); + [new Team { Players = [playerA] }, new Team { Players = [playerB] }] + ) + .ToList(); // Assert Assertions.RatingsEqual(playerA, results.ElementAt(0).Players.ElementAt(0)); diff --git a/OpenSkillSharp.Tests/Models/ThurstoneMostellerPartTests.cs b/OpenSkillSharp.Tests/Models/ThurstoneMostellerPartTests.cs index 7eda667..994a8e7 100644 --- a/OpenSkillSharp.Tests/Models/ThurstoneMostellerPartTests.cs +++ b/OpenSkillSharp.Tests/Models/ThurstoneMostellerPartTests.cs @@ -169,11 +169,9 @@ public void Rate_WindowSize0_Tau0() // Act List results = windowTauModel.Rate( - [ - new Team { Players = [playerA] }, - new Team { Players = [playerB] } - ] - ).ToList(); + [new Team { Players = [playerA] }, new Team { Players = [playerB] }] + ) + .ToList(); // Assert Assertions.RatingsEqual(playerA, results[0].Players.ElementAt(0)); diff --git a/OpenSkillSharp.Tests/PredictDrawTests.cs b/OpenSkillSharp.Tests/PredictDrawTests.cs index 4b1fedf..7f79f96 100644 --- a/OpenSkillSharp.Tests/PredictDrawTests.cs +++ b/OpenSkillSharp.Tests/PredictDrawTests.cs @@ -11,10 +11,12 @@ public class PredictDrawTests [Fact] public void PredictDraw_PythonTestParity() { - double probability = _model.PredictDraw([ - new Team { Players = [_model.Rating(25, 1), _model.Rating(25, 1)] }, - new Team { Players = [_model.Rating(25, 1), _model.Rating(25, 1)] } - ]); + double probability = _model.PredictDraw( + [ + new Team { Players = [_model.Rating(25, 1), _model.Rating(25, 1)] }, + new Team { Players = [_model.Rating(25, 1), _model.Rating(25, 1)] } + ] + ); Assert.Equal(0.2433180271619435, probability, Tolerance); } @@ -22,10 +24,12 @@ public void PredictDraw_PythonTestParity() [Fact] public void PredictDraw_GivenUnevenTeams_ProducesLowerProbability() { - double probability = _model.PredictDraw([ - new Team { Players = [_model.Rating(35, 1), _model.Rating(35, 1)] }, - new Team { Players = [_model.Rating(35, 1), _model.Rating(35, 1), _model.Rating(35, 1)] } - ]); + double probability = _model.PredictDraw( + [ + new Team { Players = [_model.Rating(35, 1), _model.Rating(35, 1)] }, + new Team { Players = [_model.Rating(35, 1), _model.Rating(35, 1), _model.Rating(35, 1)] } + ] + ); Assert.Equal(0.0002807397636509501, probability, Tolerance); } @@ -33,10 +37,9 @@ public void PredictDraw_GivenUnevenTeams_ProducesLowerProbability() [Fact] public void PredictDraw_Given1v1OfSimilarSkill_ProducesHigherProbability() { - double probability = _model.PredictDraw([ - new Team { Players = [_model.Rating(35, 1)] }, - new Team { Players = [_model.Rating(35, 1.1)] } - ]); + double probability = _model.PredictDraw( + [new Team { Players = [_model.Rating(35, 1)] }, new Team { Players = [_model.Rating(35, 1.1)] }] + ); Assert.Equal(0.4868868769871696, probability, Tolerance); } @@ -44,24 +47,26 @@ public void PredictDraw_Given1v1OfSimilarSkill_ProducesHigherProbability() [Fact] public void PredictDraw_Given5thDefectorSittingOut_ProducesHighProbability() { - double probability = _model.PredictDraw([ - new Team - { - Players = - [ - _model.Rating(28.450555874288018, 8.156810439252277), - _model.Rating(28.450555874288018, 8.156810439252277) - ] - }, - new Team - { - Players = - [ - _model.Rating(23.096623784758727, 8.138233582011868), - _model.Rating(21.537948364040137, 8.155255551436932) - ] - } - ]); + double probability = _model.PredictDraw( + [ + new Team + { + Players = + [ + _model.Rating(28.450555874288018, 8.156810439252277), + _model.Rating(28.450555874288018, 8.156810439252277) + ] + }, + new Team + { + Players = + [ + _model.Rating(23.096623784758727, 8.138233582011868), + _model.Rating(21.537948364040137, 8.155255551436932) + ] + } + ] + ); Assert.Equal(0.09227283302635064, probability, Tolerance); } diff --git a/OpenSkillSharp.Tests/PredictRankTests.cs b/OpenSkillSharp.Tests/PredictRankTests.cs index dacf4b6..8722e82 100644 --- a/OpenSkillSharp.Tests/PredictRankTests.cs +++ b/OpenSkillSharp.Tests/PredictRankTests.cs @@ -12,11 +12,13 @@ public class PredictRankTests [Fact] public void PredictRank_ProducesTotalProbabilityOf1() { - List<(int rank, double probability)> rankProbabilities = Model.PredictRank([ - _teamA, - new Team { Players = [Model.Rating(32, 0.25), Model.Rating(22, 0.5)] }, - new Team { Players = [Model.Rating(30, 0.25), Model.Rating(20, 0.5)] } - ]); + List<(int rank, double probability)> rankProbabilities = Model.PredictRank( + [ + _teamA, + new Team { Players = [Model.Rating(32, 0.25), Model.Rating(22, 0.5)] }, + new Team { Players = [Model.Rating(30, 0.25), Model.Rating(20, 0.5)] } + ] + ); Assert.Equal(1, rankProbabilities.Sum(p => p.probability), Tolerance); } @@ -24,11 +26,7 @@ public void PredictRank_ProducesTotalProbabilityOf1() [Fact] public void PredictRank_GivenIdenticalTeams_ProducesTotalProbabilityOf1() { - List<(int rank, double probability)> rankProbabilities = Model.PredictRank([ - _teamA, - _teamA, - _teamA - ]); + List<(int rank, double probability)> rankProbabilities = Model.PredictRank([_teamA, _teamA, _teamA]); Assert.Equal(1, rankProbabilities.Sum(p => p.probability), Tolerance); } diff --git a/OpenSkillSharp.Tests/PredictWinTests.cs b/OpenSkillSharp.Tests/PredictWinTests.cs index 2ee35ab..bbe42c6 100644 --- a/OpenSkillSharp.Tests/PredictWinTests.cs +++ b/OpenSkillSharp.Tests/PredictWinTests.cs @@ -56,12 +56,9 @@ public void PredictWin_2Teams() [Fact] public void PredictWin_MultipleAsymmetricTeams() { - List probabilities = _model.PredictWin([ - _team1, - _team2, - new Team { Players = [_a2] }, - new Team { Players = [_b2] } - ]).ToList(); + List probabilities = _model + .PredictWin([_team1, _team2, new Team { Players = [_a2] }, new Team { Players = [_b2] }]) + .ToList(); Assert.Equal(0.32579822053781543, probabilities.ElementAt(0), Tolerance); Assert.Equal(0.49965489287103865, probabilities.ElementAt(1), Tolerance); @@ -73,12 +70,15 @@ public void PredictWin_MultipleAsymmetricTeams() [Fact] public void PredictWin_4PlayerFFA_VaryingSkill() { - List probabilities = _model.PredictWin([ - new Team { Players = [_model.Rating(1, 0.1)] }, - new Team { Players = [_model.Rating(2, 0.1)] }, - new Team { Players = [_model.Rating(3, 0.1)] }, - new Team { Players = [_model.Rating(4, 0.1)] } - ]).ToList(); + List probabilities = _model.PredictWin( + [ + new Team { Players = [_model.Rating(1, 0.1)] }, + new Team { Players = [_model.Rating(2, 0.1)] }, + new Team { Players = [_model.Rating(3, 0.1)] }, + new Team { Players = [_model.Rating(4, 0.1)] } + ] + ) + .ToList(); Assert.Equal(0.20281164759988402, probabilities.ElementAt(0), Tolerance); Assert.Equal(0.2341964232088598, probabilities.ElementAt(1), Tolerance); @@ -90,13 +90,16 @@ public void PredictWin_4PlayerFFA_VaryingSkill() [Fact] public void PredictWin_5PlayerFFA_WithImposter() { - List probabilities = _model.PredictWin([ - new Team { Players = [_a1] }, - new Team { Players = [_a1] }, - new Team { Players = [_a1] }, - new Team { Players = [_a2] }, - new Team { Players = [_a1] } - ]).ToList(); + List probabilities = _model.PredictWin( + [ + new Team { Players = [_a1] }, + new Team { Players = [_a1] }, + new Team { Players = [_a1] }, + new Team { Players = [_a2] }, + new Team { Players = [_a1] } + ] + ) + .ToList(); Assert.Equal(0.1790804191839367, probabilities.ElementAt(0), Tolerance); Assert.Equal(0.1790804191839367, probabilities.ElementAt(1), Tolerance); diff --git a/OpenSkillSharp.Tests/TestingUtil/ModelTestData.cs b/OpenSkillSharp.Tests/TestingUtil/ModelTestData.cs index 93de83c..a2a07b2 100644 --- a/OpenSkillSharp.Tests/TestingUtil/ModelTestData.cs +++ b/OpenSkillSharp.Tests/TestingUtil/ModelTestData.cs @@ -10,39 +10,55 @@ public class ModelTestData { private static readonly JsonSerializerOptions _serializerOptions = new() { PropertyNameCaseInsensitive = true }; - [JsonPropertyName("normal")] public Dictionary> NormalData { get; init; } = null!; + [JsonPropertyName("normal")] + public Dictionary> NormalData { get; init; } = null!; - [JsonPropertyName("ranks")] public Dictionary> RanksData { get; init; } = null!; + [JsonPropertyName("ranks")] + public Dictionary> RanksData { get; init; } = null!; - [JsonPropertyName("scores")] public Dictionary> ScoresData { get; init; } = null!; + [JsonPropertyName("scores")] + public Dictionary> ScoresData { get; init; } = null!; - [JsonPropertyName("margins")] public Dictionary> MarginsData { get; init; } = null!; + [JsonPropertyName("margins")] + public Dictionary> MarginsData { get; init; } = null!; - [JsonPropertyName("limit_sigma")] public Dictionary> LimitSigmaData { get; init; } = null!; + [JsonPropertyName("limit_sigma")] + public Dictionary> LimitSigmaData { get; init; } = null!; - [JsonPropertyName("ties")] public Dictionary> TiesData { get; init; } = null!; + [JsonPropertyName("ties")] + public Dictionary> TiesData { get; init; } = null!; - [JsonPropertyName("weights")] public Dictionary> WeightsData { get; init; } = null!; + [JsonPropertyName("weights")] + public Dictionary> WeightsData { get; init; } = null!; - [JsonPropertyName("balance")] public Dictionary> BalanceData { get; init; } = null!; + [JsonPropertyName("balance")] + public Dictionary> BalanceData { get; init; } = null!; public TestData Model { get; init; } = null!; - [JsonIgnore] public IList Normal => ConvertDictionaryData(NormalData); + [JsonIgnore] + public IList Normal => ConvertDictionaryData(NormalData); - [JsonIgnore] public IList Ranks => ConvertDictionaryData(RanksData); + [JsonIgnore] + public IList Ranks => ConvertDictionaryData(RanksData); - [JsonIgnore] public IList Scores => ConvertDictionaryData(ScoresData); + [JsonIgnore] + public IList Scores => ConvertDictionaryData(ScoresData); - [JsonIgnore] public IList Margins => ConvertDictionaryData(MarginsData); + [JsonIgnore] + public IList Margins => ConvertDictionaryData(MarginsData); - [JsonIgnore] public IList LimitSigma => ConvertDictionaryData(LimitSigmaData); + [JsonIgnore] + public IList LimitSigma => ConvertDictionaryData(LimitSigmaData); - [JsonIgnore] public IList Ties => ConvertDictionaryData(TiesData); + [JsonIgnore] + public IList Ties => ConvertDictionaryData(TiesData); - [JsonIgnore] public IList Weights => ConvertDictionaryData(WeightsData); + [JsonIgnore] + public IList Weights => ConvertDictionaryData(WeightsData); - [JsonIgnore] public IList Balance => ConvertDictionaryData(BalanceData); + [JsonIgnore] + public IList Balance => ConvertDictionaryData(BalanceData); public static ModelTestData FromJson(string model) { @@ -60,10 +76,14 @@ public static ModelTestData FromJson(string model) private static IList ConvertDictionaryData(Dictionary> data) { - return data.Select(kvp => new Team - { - Players = kvp.Value.Select(d => new OpenSkillSharp.Rating.Rating { Mu = d.Mu, Sigma = d.Sigma }) - }).Cast().ToList(); + return data.Select( + kvp => new Team + { + Players = kvp.Value.Select(d => new OpenSkillSharp.Rating.Rating { Mu = d.Mu, Sigma = d.Sigma }) + } + ) + .Cast() + .ToList(); } public class TestData diff --git a/OpenSkillSharp.Tests/Util/EnumerableExtensionTests.cs b/OpenSkillSharp.Tests/Util/EnumerableExtensionTests.cs index 8b136d2..f60fe21 100644 --- a/OpenSkillSharp.Tests/Util/EnumerableExtensionTests.cs +++ b/OpenSkillSharp.Tests/Util/EnumerableExtensionTests.cs @@ -25,20 +25,26 @@ public class EnumerableExtensionTests // Accepts 2 items new object[] { - new List { "b", "a" }, new List { 1, 0 }, new List { "a", "b" }, + new List { "b", "a" }, + new List { 1, 0 }, + new List { "a", "b" }, new List { 1, 0 } }, // Accepts 3 items new object[] { - new List { "b", "c", "a" }, new List { 1, 2, 0 }, new List { "a", "b", "c" }, + new List { "b", "c", "a" }, + new List { 1, 2, 0 }, + new List { "a", "b", "c" }, new List { 2, 0, 1 } }, // Accepts 4 items new object[] { - new List { "b", "d", "c", "a" }, new List { 1, 3, 2, 0 }, - new List { "a", "b", "c", "d" }, new List { 3, 0, 2, 1 } + new List { "b", "d", "c", "a" }, + new List { 1, 3, 2, 0 }, + new List { "a", "b", "c", "d" }, + new List { 3, 0, 2, 1 } } }; diff --git a/OpenSkillSharp.Tests/Util/RatingExtensionTests.cs b/OpenSkillSharp.Tests/Util/RatingExtensionTests.cs index d0d32e7..3ecee2d 100644 --- a/OpenSkillSharp.Tests/Util/RatingExtensionTests.cs +++ b/OpenSkillSharp.Tests/Util/RatingExtensionTests.cs @@ -23,7 +23,7 @@ public void CalculateRankings_GivenPartialScores_FallsBackToTeamIndex() Assert.Equal([2, 1, 0, 3], ranks); } - + [Fact] public void CalculateRankings_GivenInverseScores_ProperlyConvertsToRanks() { @@ -68,17 +68,15 @@ public void CalculateRankings_GivenNoTeams_ProducesEmptyList() Assert.Equal([], ranks); } - + [Fact] public void CountRankOccurrences() { PlackettLuce model = new(); List teamRatings = model.CalculateTeamRatings( - [ - new Team { Players = [model.Rating()] }, - new Team { Players = [model.Rating(), model.Rating()] } - ] - ).ToList(); + [new Team { Players = [model.Rating()] }, new Team { Players = [model.Rating(), model.Rating()] }] + ) + .ToList(); List rankOccurrences = teamRatings.CountRankOccurrences().ToList(); @@ -90,13 +88,14 @@ public void CountRankOccurrences_1TeamPerRank() { PlackettLuce model = new(); List teamRatings = model.CalculateTeamRatings( - [ - new Team { Players = [model.Rating()] }, - new Team { Players = [model.Rating(), model.Rating()] }, - new Team { Players = [model.Rating(), model.Rating()] }, - new Team { Players = [model.Rating()] } - ] - ).ToList(); + [ + new Team { Players = [model.Rating()] }, + new Team { Players = [model.Rating(), model.Rating()] }, + new Team { Players = [model.Rating(), model.Rating()] }, + new Team { Players = [model.Rating()] } + ] + ) + .ToList(); List rankOccurrences = teamRatings.CountRankOccurrences().ToList(); @@ -108,14 +107,15 @@ public void CountRankOccurrences_SharedRanks() { PlackettLuce model = new(); List teamRatings = model.CalculateTeamRatings( - [ - new Team { Players = [model.Rating()] }, - new Team { Players = [model.Rating(), model.Rating()] }, - new Team { Players = [model.Rating(), model.Rating()] }, - new Team { Players = [model.Rating()] } - ], - [1, 1, 1, 4] - ).ToList(); + [ + new Team { Players = [model.Rating()] }, + new Team { Players = [model.Rating(), model.Rating()] }, + new Team { Players = [model.Rating(), model.Rating()] }, + new Team { Players = [model.Rating()] } + ], + [1, 1, 1, 4] + ) + .ToList(); List rankOccurrences = teamRatings.CountRankOccurrences().ToList(); diff --git a/OpenSkillSharp/Models/BradleyTerryFull.cs b/OpenSkillSharp/Models/BradleyTerryFull.cs index 55ffa24..344f8bf 100644 --- a/OpenSkillSharp/Models/BradleyTerryFull.cs +++ b/OpenSkillSharp/Models/BradleyTerryFull.cs @@ -21,57 +21,63 @@ protected override IEnumerable Compute( List teamRatings = CalculateTeamRatings(teams, ranks).ToList(); List result = teamRatings - .Select((iTeam, iTeamIndex) => - { - (double omega, double delta) = teamRatings - .Index() - .Where(q => q.Index != iTeamIndex) - .Aggregate((sumOmega: 0D, sumDelta: 0D), (acc, q) => - { - (int qTeamIndex, ITeamRating qTeam) = q; - - // Margin factor adjustment - double marginFactor = 1; - if (scores is not null) - { - double scoreDiff = Math.Abs(scores[qTeamIndex] - scores[iTeamIndex]); - if (scoreDiff > 0 && Margin > 0 && scoreDiff > Margin && qTeam.Rank < iTeam.Rank) + .Select( + (iTeam, iTeamIndex) => + { + (double omega, double delta) = teamRatings + .Index() + .Where(q => q.Index != iTeamIndex) + .Aggregate( + (sumOmega: 0D, sumDelta: 0D), + (acc, q) => { - marginFactor = Math.Log(1 + (scoreDiff / Margin)); - } - } + (int qTeamIndex, ITeamRating qTeam) = q; - double ciq = Math.Sqrt(iTeam.SigmaSq + qTeam.SigmaSq + (2 * BetaSq)); - double piq = 1 / (1 + Math.Exp((qTeam.Mu - iTeam.Mu) * marginFactor / ciq)); - double sigmaToCiq = iTeam.SigmaSq / ciq; - double s = Common.Score(qTeam.Rank, iTeam.Rank); - double gamma = Gamma( - ciq, - teamRatings.Count, - iTeam.Mu, - iTeam.SigmaSq, - iTeam.Players, - iTeam.Rank, - weights?.ElementAt(iTeamIndex) - ); + // Margin factor adjustment + double marginFactor = 1; + if (scores is not null) + { + double scoreDiff = Math.Abs(scores[qTeamIndex] - scores[iTeamIndex]); + if (scoreDiff > 0 && Margin > 0 && scoreDiff > Margin && qTeam.Rank < iTeam.Rank) + { + marginFactor = Math.Log(1 + (scoreDiff / Margin)); + } + } - return ( - sumOmega: acc.sumOmega + (sigmaToCiq * (s - piq)), - sumDelta: acc.sumDelta + (gamma * sigmaToCiq / ciq * piq * (1 - piq)) + double ciq = Math.Sqrt(iTeam.SigmaSq + qTeam.SigmaSq + (2 * BetaSq)); + double piq = 1 / (1 + Math.Exp((qTeam.Mu - iTeam.Mu) * marginFactor / ciq)); + double sigmaToCiq = iTeam.SigmaSq / ciq; + double s = Common.Score(qTeam.Rank, iTeam.Rank); + double gamma = Gamma( + ciq, + teamRatings.Count, + iTeam.Mu, + iTeam.SigmaSq, + iTeam.Players, + iTeam.Rank, + weights?.ElementAt(iTeamIndex) + ); + + return ( + sumOmega: acc.sumOmega + (sigmaToCiq * (s - piq)), + sumDelta: acc.sumDelta + (gamma * sigmaToCiq / ciq * piq * (1 - piq)) + ); + } ); - }); - return new Team - { - Players = UpdatePlayerRatings( - teams[iTeamIndex], - iTeam, - omega, - delta, - weights?.ElementAtOrDefault(iTeamIndex) - ) - }; - }).ToList(); + return new Team + { + Players = UpdatePlayerRatings( + teams[iTeamIndex], + iTeam, + omega, + delta, + weights?.ElementAtOrDefault(iTeamIndex) + ) + }; + } + ) + .ToList(); AdjustPlayerMuChangeForTie(teams, teamRatings, result); diff --git a/OpenSkillSharp/Models/BradleyTerryPart.cs b/OpenSkillSharp/Models/BradleyTerryPart.cs index 615a9bb..2793683 100644 --- a/OpenSkillSharp/Models/BradleyTerryPart.cs +++ b/OpenSkillSharp/Models/BradleyTerryPart.cs @@ -25,66 +25,72 @@ protected override IEnumerable Compute( { List teamRatings = CalculateTeamRatings(teams, ranks).ToList(); - return teamRatings.Select((iTeam, iTeamIndex) => - { - // Calculate omega and delta - (double omega, double delta, int nComparisons) = teamRatings - .Index() - .Skip(Math.Max(0, iTeamIndex - WindowSize)) - .Take(Math.Min(teamRatings.Count, iTeamIndex + WindowSize + 1)) - .Where(q => q.Index != iTeamIndex) - .Aggregate((sumOmega: 0D, sumDelta: 0D, nComparisons: 0), (acc, q) => + return teamRatings.Select( + (iTeam, iTeamIndex) => { - (int qTeamIndex, ITeamRating qTeam) = q; + // Calculate omega and delta + (double omega, double delta, int nComparisons) = teamRatings + .Index() + .Skip(Math.Max(0, iTeamIndex - WindowSize)) + .Take(Math.Min(teamRatings.Count, iTeamIndex + WindowSize + 1)) + .Where(q => q.Index != iTeamIndex) + .Aggregate( + (sumOmega: 0D, sumDelta: 0D, nComparisons: 0), + (acc, q) => + { + (int qTeamIndex, ITeamRating qTeam) = q; - // Margin factor adjustment - double marginFactor = 1; - if (scores is not null) - { - double scoreDiff = Math.Abs(scores[qTeamIndex] - scores[iTeamIndex]); - if (scoreDiff > 0 && Margin > 0 && scoreDiff > Margin && qTeam.Rank < iTeam.Rank) - { - marginFactor = Math.Log(1 + (scoreDiff / Margin)); - } - } + // Margin factor adjustment + double marginFactor = 1; + if (scores is not null) + { + double scoreDiff = Math.Abs(scores[qTeamIndex] - scores[iTeamIndex]); + if (scoreDiff > 0 && Margin > 0 && scoreDiff > Margin && qTeam.Rank < iTeam.Rank) + { + marginFactor = Math.Log(1 + (scoreDiff / Margin)); + } + } - double ciq = Math.Sqrt(iTeam.SigmaSq + qTeam.SigmaSq + (2 * BetaSq)); - double piq = 1 / (1 + Math.Exp((qTeam.Mu - iTeam.Mu) * marginFactor / ciq)); - double sigmaToCiq = iTeam.SigmaSq / ciq; - double s = Common.Score(qTeam.Rank, iTeam.Rank); - double gamma = Gamma( - ciq, - teamRatings.Count, - iTeam.Mu, - iTeam.SigmaSq, - iTeam.Players, - iTeam.Rank, - weights?.ElementAt(iTeamIndex) - ); + double ciq = Math.Sqrt(iTeam.SigmaSq + qTeam.SigmaSq + (2 * BetaSq)); + double piq = 1 / (1 + Math.Exp((qTeam.Mu - iTeam.Mu) * marginFactor / ciq)); + double sigmaToCiq = iTeam.SigmaSq / ciq; + double s = Common.Score(qTeam.Rank, iTeam.Rank); + double gamma = Gamma( + ciq, + teamRatings.Count, + iTeam.Mu, + iTeam.SigmaSq, + iTeam.Players, + iTeam.Rank, + weights?.ElementAt(iTeamIndex) + ); - return ( - sumOmega: acc.sumOmega + (sigmaToCiq * (s - piq)), - sumDelta: acc.sumDelta + (gamma * sigmaToCiq / ciq * piq * (1 - piq)), - nComparisons: acc.nComparisons + 1 - ); - }); + return ( + sumOmega: acc.sumOmega + (sigmaToCiq * (s - piq)), + sumDelta: acc.sumDelta + (gamma * sigmaToCiq / ciq * piq * (1 - piq)), + nComparisons: acc.nComparisons + 1 + ); + } + ); - if (nComparisons > 0) - { - omega /= nComparisons; - delta /= nComparisons; - } + if (nComparisons > 0) + { + omega /= nComparisons; + delta /= nComparisons; + } - return new Team - { - Players = UpdatePlayerRatings( - teams[iTeamIndex], - iTeam, - omega, - delta, - weights?.ElementAtOrDefault(iTeamIndex) - ) - }; - }).ToList(); + return new Team + { + Players = UpdatePlayerRatings( + teams[iTeamIndex], + iTeam, + omega, + delta, + weights?.ElementAtOrDefault(iTeamIndex) + ) + }; + } + ) + .ToList(); } } \ No newline at end of file diff --git a/OpenSkillSharp/Models/PlackettLuce.cs b/OpenSkillSharp/Models/PlackettLuce.cs index 88e4703..dcd0ac0 100644 --- a/OpenSkillSharp/Models/PlackettLuce.cs +++ b/OpenSkillSharp/Models/PlackettLuce.cs @@ -25,52 +25,61 @@ protected override IEnumerable Compute( List rankOccurrences = teamRatings.CountRankOccurrences().ToList(); List adjustedMus = CalculateMarginAdjustedMu(teamRatings, scores).ToList(); - List result = teamRatings.Select((iTeam, iTeamIndex) => - { - // Calculate omega and delta - double iMuOverC = Math.Exp(adjustedMus[iTeamIndex] / c); - (double omega, double delta) = teamRatings - .Select((qTeam, qTeamIndex) => (qTeam, qTeamIndex)) - .Where(x => x.qTeam.Rank <= iTeam.Rank) - .Aggregate((sumOmega: 0D, sumDelta: 0D), (acc, q) => + List result = teamRatings.Select( + (iTeam, iTeamIndex) => { - (ITeamRating _, int qTeamIndex) = q; - double iMuOverCeOverSumQ = iMuOverC / sumQ[qTeamIndex]; + // Calculate omega and delta + double iMuOverC = Math.Exp(adjustedMus[iTeamIndex] / c); + (double omega, double delta) = teamRatings + .Select((qTeam, qTeamIndex) => (qTeam, qTeamIndex)) + .Where(x => x.qTeam.Rank <= iTeam.Rank) + .Aggregate( + (sumOmega: 0D, sumDelta: 0D), + (acc, q) => + { + (ITeamRating _, int qTeamIndex) = q; + double iMuOverCeOverSumQ = iMuOverC / sumQ[qTeamIndex]; - return ( - sumOmega: acc.sumOmega + ( - iTeamIndex == qTeamIndex - ? 1 - (iMuOverCeOverSumQ / rankOccurrences[qTeamIndex]) - : -1 * iMuOverCeOverSumQ / rankOccurrences[qTeamIndex] - ), - sumDelta: acc.sumDelta + - (iMuOverCeOverSumQ * (1 - iMuOverCeOverSumQ) / rankOccurrences[qTeamIndex]) - ); - }); + return ( + sumOmega: acc.sumOmega + + ( + iTeamIndex == qTeamIndex + ? 1 - (iMuOverCeOverSumQ / rankOccurrences[qTeamIndex]) + : -1 * iMuOverCeOverSumQ / rankOccurrences[qTeamIndex] + ), + sumDelta: acc.sumDelta + + (iMuOverCeOverSumQ * + (1 - iMuOverCeOverSumQ) / + rankOccurrences[qTeamIndex]) + ); + } + ); - omega *= iTeam.SigmaSq / c; - delta *= iTeam.SigmaSq / Math.Pow(c, 2); - delta *= Gamma( - c, - teamRatings.Count, - iTeam.Mu, - iTeam.SigmaSq, - iTeam.Players, - iTeam.Rank, - weights?.ElementAtOrDefault(iTeamIndex) - ); + omega *= iTeam.SigmaSq / c; + delta *= iTeam.SigmaSq / Math.Pow(c, 2); + delta *= Gamma( + c, + teamRatings.Count, + iTeam.Mu, + iTeam.SigmaSq, + iTeam.Players, + iTeam.Rank, + weights?.ElementAtOrDefault(iTeamIndex) + ); - return new Team - { - Players = UpdatePlayerRatings( - teams[iTeamIndex], - iTeam, - omega, - delta, - weights?.ElementAtOrDefault(iTeamIndex) - ) - }; - }).ToList(); + return new Team + { + Players = UpdatePlayerRatings( + teams[iTeamIndex], + iTeam, + omega, + delta, + weights?.ElementAtOrDefault(iTeamIndex) + ) + }; + } + ) + .ToList(); AdjustPlayerMuChangeForTie(teams, teamRatings, result); diff --git a/OpenSkillSharp/Models/ThurstoneMostellerFull.cs b/OpenSkillSharp/Models/ThurstoneMostellerFull.cs index fd97a81..894e186 100644 --- a/OpenSkillSharp/Models/ThurstoneMostellerFull.cs +++ b/OpenSkillSharp/Models/ThurstoneMostellerFull.cs @@ -26,74 +26,92 @@ protected override IEnumerable Compute( List teamRatings = CalculateTeamRatings(teams, ranks).ToList(); List result = teamRatings - .Select((iTeam, iTeamIndex) => - { - // Calculate omega and delta - (double omega, double delta) = teamRatings - .Index() - .Where(q => q.Index != iTeamIndex) - .Aggregate((sumOmega: 0D, sumDelta: 0D), (acc, q) => - { - (int qTeamIndex, ITeamRating qTeam) = q; + .Select( + (iTeam, iTeamIndex) => + { + // Calculate omega and delta + (double omega, double delta) = teamRatings + .Index() + .Where(q => q.Index != iTeamIndex) + .Aggregate( + (sumOmega: 0D, sumDelta: 0D), + (acc, q) => + { + (int qTeamIndex, ITeamRating qTeam) = q; - double ciq = Math.Sqrt(iTeam.SigmaSq + qTeam.SigmaSq + (2 * BetaSq)); - double deltaMu = (iTeam.Mu - qTeam.Mu) / ciq; - double sigmaToCiq = iTeam.SigmaSq / ciq; - double gamma = Gamma( - ciq, - teamRatings.Count, - iTeam.Mu, - iTeam.SigmaSq, - iTeam.Players, - iTeam.Rank, - weights?.ElementAt(iTeamIndex) - ); + double ciq = Math.Sqrt(iTeam.SigmaSq + qTeam.SigmaSq + (2 * BetaSq)); + double deltaMu = (iTeam.Mu - qTeam.Mu) / ciq; + double sigmaToCiq = iTeam.SigmaSq / ciq; + double gamma = Gamma( + ciq, + teamRatings.Count, + iTeam.Mu, + iTeam.SigmaSq, + iTeam.Players, + iTeam.Rank, + weights?.ElementAt(iTeamIndex) + ); - // Margin factor adjustment - double marginFactor = 1; - if (scores is not null) - { - double scoreDiff = Math.Abs(scores[qTeamIndex] - scores[iTeamIndex]); - if (scoreDiff > 0 && Margin > 0 && scoreDiff > Margin && qTeam.Rank < iTeam.Rank) - { - marginFactor = Math.Log(1 + (scoreDiff / Margin)); - } - } + // Margin factor adjustment + double marginFactor = 1; + if (scores is not null) + { + double scoreDiff = Math.Abs(scores[qTeamIndex] - scores[iTeamIndex]); + if (scoreDiff > 0 && Margin > 0 && scoreDiff > Margin && qTeam.Rank < iTeam.Rank) + { + marginFactor = Math.Log(1 + (scoreDiff / Margin)); + } + } - if (iTeam.Rank == qTeam.Rank) - { - return ( - sumOmega: acc.sumOmega + ( - sigmaToCiq * Statistics.VT(deltaMu * marginFactor, Epsilon / ciq) - ), - sumDelta: acc.sumDelta + ( - gamma * sigmaToCiq / ciq * Statistics.WT(deltaMu * marginFactor, Epsilon / ciq) - ) - ); - } + if (iTeam.Rank == qTeam.Rank) + { + return ( + sumOmega: acc.sumOmega + + ( + sigmaToCiq * Statistics.VT(deltaMu * marginFactor, Epsilon / ciq) + ), + sumDelta: acc.sumDelta + + ( + gamma * + sigmaToCiq / + ciq * + Statistics.WT(deltaMu * marginFactor, Epsilon / ciq) + ) + ); + } - int sign = qTeam.Rank > iTeam.Rank ? 1 : -1; - return ( - sumOmega: acc.sumOmega + ( - sign * sigmaToCiq * Statistics.V(sign * deltaMu * marginFactor, Epsilon / ciq) - ), - sumDelta: acc.sumDelta + ( - gamma * sigmaToCiq / ciq * Statistics.W(sign * deltaMu * marginFactor, Epsilon / ciq) - ) + int sign = qTeam.Rank > iTeam.Rank ? 1 : -1; + return ( + sumOmega: acc.sumOmega + + ( + sign * + sigmaToCiq * + Statistics.V(sign * deltaMu * marginFactor, Epsilon / ciq) + ), + sumDelta: acc.sumDelta + + ( + gamma * + sigmaToCiq / + ciq * + Statistics.W(sign * deltaMu * marginFactor, Epsilon / ciq) + ) + ); + } ); - }); - return new Team - { - Players = UpdatePlayerRatings( - teams[iTeamIndex], - iTeam, - omega, - delta, - weights?.ElementAtOrDefault(iTeamIndex) - ) - }; - }).ToList(); + return new Team + { + Players = UpdatePlayerRatings( + teams[iTeamIndex], + iTeam, + omega, + delta, + weights?.ElementAtOrDefault(iTeamIndex) + ) + }; + } + ) + .ToList(); AdjustPlayerMuChangeForTie(teams, teamRatings, result); diff --git a/OpenSkillSharp/Models/ThurstoneMostellerPart.cs b/OpenSkillSharp/Models/ThurstoneMostellerPart.cs index 294116c..62508af 100644 --- a/OpenSkillSharp/Models/ThurstoneMostellerPart.cs +++ b/OpenSkillSharp/Models/ThurstoneMostellerPart.cs @@ -31,82 +31,100 @@ protected override IEnumerable Compute( { List teamRatings = CalculateTeamRatings(teams, ranks).ToList(); - return teamRatings.Select((iTeam, iTeamIndex) => - { - (double omega, double delta, int nComparisons) = teamRatings - .Index() - .Skip(Math.Max(0, iTeamIndex - WindowSize)) - .Take(Math.Min(teamRatings.Count, iTeamIndex + WindowSize + 1)) - .Where(q => q.Index != iTeamIndex) - .Aggregate((sumOmega: 0D, sumDelta: 0D, nComparisons: 0), (acc, q) => + return teamRatings.Select( + (iTeam, iTeamIndex) => { - (int qTeamIndex, ITeamRating qTeam) = q; + (double omega, double delta, int nComparisons) = teamRatings + .Index() + .Skip(Math.Max(0, iTeamIndex - WindowSize)) + .Take(Math.Min(teamRatings.Count, iTeamIndex + WindowSize + 1)) + .Where(q => q.Index != iTeamIndex) + .Aggregate( + (sumOmega: 0D, sumDelta: 0D, nComparisons: 0), + (acc, q) => + { + (int qTeamIndex, ITeamRating qTeam) = q; - double ciq = 2 * Math.Sqrt(iTeam.SigmaSq + qTeam.SigmaSq + (2 * BetaSq)); - double deltaMu = (iTeam.Mu - qTeam.Mu) / ciq; - double sigmaToCiq = iTeam.SigmaSq / ciq; - double gamma = Gamma( - ciq, - teamRatings.Count, - iTeam.Mu, - iTeam.SigmaSq, - iTeam.Players, - iTeam.Rank, - weights?.ElementAt(iTeamIndex) - ); + double ciq = 2 * Math.Sqrt(iTeam.SigmaSq + qTeam.SigmaSq + (2 * BetaSq)); + double deltaMu = (iTeam.Mu - qTeam.Mu) / ciq; + double sigmaToCiq = iTeam.SigmaSq / ciq; + double gamma = Gamma( + ciq, + teamRatings.Count, + iTeam.Mu, + iTeam.SigmaSq, + iTeam.Players, + iTeam.Rank, + weights?.ElementAt(iTeamIndex) + ); - // Margin factor adjustment - double marginFactor = 1; - if (scores is not null) - { - double scoreDiff = Math.Abs(scores[qTeamIndex] - scores[iTeamIndex]); - if (scoreDiff > 0 && Margin > 0 && scoreDiff > Margin && qTeam.Rank < iTeam.Rank) - { - marginFactor = Math.Log(1 + (scoreDiff / Margin)); - } - } + // Margin factor adjustment + double marginFactor = 1; + if (scores is not null) + { + double scoreDiff = Math.Abs(scores[qTeamIndex] - scores[iTeamIndex]); + if (scoreDiff > 0 && Margin > 0 && scoreDiff > Margin && qTeam.Rank < iTeam.Rank) + { + marginFactor = Math.Log(1 + (scoreDiff / Margin)); + } + } - if (iTeam.Rank == qTeam.Rank) - { - return ( - sumOmega: acc.sumOmega + ( - sigmaToCiq * Statistics.VT(deltaMu * marginFactor, Epsilon / ciq) - ), - sumDelta: acc.sumDelta + ( - gamma * sigmaToCiq / ciq * Statistics.WT(deltaMu * marginFactor, Epsilon / ciq) - ), - nComparisons: acc.nComparisons + 1 - ); - } + if (iTeam.Rank == qTeam.Rank) + { + return ( + sumOmega: acc.sumOmega + + ( + sigmaToCiq * Statistics.VT(deltaMu * marginFactor, Epsilon / ciq) + ), + sumDelta: acc.sumDelta + + ( + gamma * + sigmaToCiq / + ciq * + Statistics.WT(deltaMu * marginFactor, Epsilon / ciq) + ), + nComparisons: acc.nComparisons + 1 + ); + } - int sign = qTeam.Rank > iTeam.Rank ? 1 : -1; - return ( - sumOmega: acc.sumOmega + ( - sign * sigmaToCiq * Statistics.V(sign * deltaMu * marginFactor, Epsilon / ciq) - ), - sumDelta: acc.sumDelta + ( - gamma * sigmaToCiq / ciq * Statistics.W(sign * deltaMu * marginFactor, Epsilon / ciq) - ), - nComparisons: acc.nComparisons + 1 - ); - }); + int sign = qTeam.Rank > iTeam.Rank ? 1 : -1; + return ( + sumOmega: acc.sumOmega + + ( + sign * + sigmaToCiq * + Statistics.V(sign * deltaMu * marginFactor, Epsilon / ciq) + ), + sumDelta: acc.sumDelta + + ( + gamma * + sigmaToCiq / + ciq * + Statistics.W(sign * deltaMu * marginFactor, Epsilon / ciq) + ), + nComparisons: acc.nComparisons + 1 + ); + } + ); - if (nComparisons > 0) - { - omega /= nComparisons; - delta /= nComparisons; - } + if (nComparisons > 0) + { + omega /= nComparisons; + delta /= nComparisons; + } - return new Team - { - Players = UpdatePlayerRatings( - teams[iTeamIndex], - iTeam, - omega, - delta, - weights?.ElementAtOrDefault(iTeamIndex) - ) - }; - }).ToList(); + return new Team + { + Players = UpdatePlayerRatings( + teams[iTeamIndex], + iTeam, + omega, + delta, + weights?.ElementAtOrDefault(iTeamIndex) + ) + }; + } + ) + .ToList(); } } \ No newline at end of file diff --git a/OpenSkillSharp/OpenSkillModelBase.cs b/OpenSkillSharp/OpenSkillModelBase.cs index 25448ab..8d6e3b2 100644 --- a/OpenSkillSharp/OpenSkillModelBase.cs +++ b/OpenSkillSharp/OpenSkillModelBase.cs @@ -46,7 +46,8 @@ public IEnumerable Rate( if (!ranks.IsEqualLengthTo(teams)) { throw new ArgumentException( - $"Arguments '{nameof(ranks)}' and '{nameof(teams)}' must be of equal length."); + $"Arguments '{nameof(ranks)}' and '{nameof(teams)}' must be of equal length." + ); } if (scores is not null) @@ -67,7 +68,8 @@ public IEnumerable Rate( if (!weights.IsEqualLengthTo(teams)) { throw new ArgumentException( - $"Arguments '{nameof(weights)}' and '{nameof(teams)}' must be of equal length."); + $"Arguments '{nameof(weights)}' and '{nameof(teams)}' must be of equal length." + ); } foreach ((int index, IList weight) in weights.Index()) @@ -75,7 +77,8 @@ public IEnumerable Rate( if (!weight.IsEqualLengthTo(teams[index].Players)) { throw new ArgumentException( - $"Size of team weights at index {index} does not match the size of the team."); + $"Size of team weights at index {index} does not match the size of the team." + ); } } } @@ -149,11 +152,15 @@ public IEnumerable PredictWin(IList teams) int n = teams.Count; int denominator = n * (n - 1) / 2; - return teamRatings.Select((teamA, idx) => teamRatings - .Where((_, idy) => idx != idy) - .Sum(teamB => Statistics.PhiMajor( - (teamA.Mu - teamB.Mu) / Math.Sqrt((n * BetaSq) + teamA.SigmaSq + teamB.SigmaSq) - )) / denominator + return teamRatings.Select( + (teamA, idx) => teamRatings + .Where((_, idy) => idx != idy) + .Sum( + teamB => Statistics.PhiMajor( + (teamA.Mu - teamB.Mu) / Math.Sqrt((n * BetaSq) + teamA.SigmaSq + teamB.SigmaSq) + ) + ) / + denominator ); } @@ -165,16 +172,20 @@ public double PredictDraw(IList teams) double drawProbability = 1D / playerCount; double drawMargin = Math.Sqrt(playerCount) * Beta * Statistics.InversePhiMajor((1 + drawProbability) / 2D); - return teamRatings.SelectMany((teamA, i) => - teamRatings - .Skip(i + 1) - .Select(teamB => - { - double denominator = Math.Sqrt((playerCount * BetaSq) + teamA.SigmaSq + teamB.SigmaSq); - return Statistics.PhiMajor((drawMargin - teamA.Mu + teamB.Mu) / denominator) - - Statistics.PhiMajor((teamB.Mu - teamA.Mu - drawMargin) / denominator); - }) - ).Average(); + return teamRatings.SelectMany( + (teamA, i) => + teamRatings + .Skip(i + 1) + .Select( + teamB => + { + double denominator = Math.Sqrt((playerCount * BetaSq) + teamA.SigmaSq + teamB.SigmaSq); + return Statistics.PhiMajor((drawMargin - teamA.Mu + teamB.Mu) / denominator) - + Statistics.PhiMajor((teamB.Mu - teamA.Mu - drawMargin) / denominator); + } + ) + ) + .Average(); } public List<(int rank, double probability)> PredictRank(IList teams) @@ -182,14 +193,18 @@ public double PredictDraw(IList teams) List teamRatings = CalculateTeamRatings(teams).ToList(); List winProbabilities = teamRatings - .Select((iTeam, iTeamIndex) => teamRatings - .Where((_, qTeamIndex) => iTeamIndex != qTeamIndex) - .Select(qTeam => Statistics.PhiMajor( - (iTeam.Mu - qTeam.Mu) / - Math.Sqrt((2 * BetaSq) + iTeam.SigmaSq + qTeam.SigmaSq) - )) - .Average() - ).ToList(); + .Select( + (iTeam, iTeamIndex) => teamRatings + .Where((_, qTeamIndex) => iTeamIndex != qTeamIndex) + .Select( + qTeam => Statistics.PhiMajor( + (iTeam.Mu - qTeam.Mu) / + Math.Sqrt((2 * BetaSq) + iTeam.SigmaSq + qTeam.SigmaSq) + ) + ) + .Average() + ) + .ToList(); // Normalize probabilities double totalProbability = winProbabilities.Sum(); @@ -230,27 +245,32 @@ public IEnumerable CalculateTeamRatings( { ranks ??= teams.CalculateRankings().ToList(); - return teams.Select((team, index) => - { - double maxOrdinal = team.Players.Max(p => p.Ordinal); - (double sumMu, double sumSigmaSq) = team.Players - .Aggregate((mu: 0D, sigmaSq: 0D), (acc, player) => - { - double balanceWeight = Balance - ? 1 + ((maxOrdinal - player.Ordinal) / (maxOrdinal + Kappa)) - : 1D; - - return ( - mu: acc.mu + (player.Mu * balanceWeight), - sigmaSq: acc.sigmaSq + Math.Pow(player.Sigma * balanceWeight, 2) + return teams.Select( + (team, index) => + { + double maxOrdinal = team.Players.Max(p => p.Ordinal); + (double sumMu, double sumSigmaSq) = team.Players + .Aggregate( + (mu: 0D, sigmaSq: 0D), + (acc, player) => + { + double balanceWeight = Balance + ? 1 + ((maxOrdinal - player.Ordinal) / (maxOrdinal + Kappa)) + : 1D; + + return ( + mu: acc.mu + (player.Mu * balanceWeight), + sigmaSq: acc.sigmaSq + Math.Pow(player.Sigma * balanceWeight, 2) + ); + } ); - }); - return new TeamRating - { - Players = team.Players, Mu = sumMu, SigmaSq = sumSigmaSq, Rank = (int)ranks[index] - }; - }); + return new TeamRating + { + Players = team.Players, Mu = sumMu, SigmaSq = sumSigmaSq, Rank = (int)ranks[index] + }; + } + ); } /// @@ -282,10 +302,12 @@ public IEnumerable CalculateSumQ( // Calculate margin adjustment for team mu values if ranks are provided List adjustedMus = CalculateMarginAdjustedMu(teamRatings, scores).ToList(); - return teamRatings.Select(qTeam => teamRatings - .Select((iTeam, iTeamIndex) => (iTeam, iTeamIndex)) - .Where(x => x.iTeam.Rank >= qTeam.Rank) - .Select(x => Math.Exp(adjustedMus[x.iTeamIndex] / c)).Sum() + return teamRatings.Select( + qTeam => teamRatings + .Select((iTeam, iTeamIndex) => (iTeam, iTeamIndex)) + .Where(x => x.iTeam.Rank >= qTeam.Rank) + .Select(x => Math.Exp(adjustedMus[x.iTeamIndex] / c)) + .Sum() ); } @@ -308,29 +330,33 @@ protected IEnumerable CalculateMarginAdjustedMu( return teamRatings.Select(t => t.Mu); } - return teamRatings.Select((qTeam, qTeamIndex) => - { - double qTeamScore = scores[qTeamIndex]; - double muAdjustment = teamRatings - .Where((_, iTeamIndex) => - qTeamIndex != iTeamIndex - && Math.Abs(qTeamScore - scores[iTeamIndex]) > 0 - ) - .Select((iTeam, iTeamIndex) => - { - double iTeamScore = scores[iTeamIndex]; - double scoreDiff = Math.Abs(qTeamScore - iTeamScore); - double marginFactor = scoreDiff > Margin && Margin > 0 - ? Math.Log(1 + (scoreDiff / Margin)) - : 1D; - - double sign = qTeamScore > iTeamScore ? 1D : -1D; - return (qTeam.Mu - iTeam.Mu) * (marginFactor - 1) * sign; - }) - .Average(); - - return qTeam.Mu + muAdjustment; - }); + return teamRatings.Select( + (qTeam, qTeamIndex) => + { + double qTeamScore = scores[qTeamIndex]; + double muAdjustment = teamRatings + .Where( + (_, iTeamIndex) => + qTeamIndex != iTeamIndex && Math.Abs(qTeamScore - scores[iTeamIndex]) > 0 + ) + .Select( + (iTeam, iTeamIndex) => + { + double iTeamScore = scores[iTeamIndex]; + double scoreDiff = Math.Abs(qTeamScore - iTeamScore); + double marginFactor = scoreDiff > Margin && Margin > 0 + ? Math.Log(1 + (scoreDiff / Margin)) + : 1D; + + double sign = qTeamScore > iTeamScore ? 1D : -1D; + return (qTeam.Mu - iTeam.Mu) * (marginFactor - 1) * sign; + } + ) + .Average(); + + return qTeam.Mu + muAdjustment; + } + ); } /// @@ -352,22 +378,28 @@ protected List UpdatePlayerRatings( IEnumerable? weights ) { - return team.Players.Select((_, jPlayerIndex) => - { - IRating modifiedPlayer = originalTeam.Players.ElementAt(jPlayerIndex); - double weight = weights?.ElementAtOrDefault(jPlayerIndex) ?? 1D; - double weightScalar = omega >= 0 - ? weight - : 1 / weight; - - modifiedPlayer.Mu += modifiedPlayer.Sigma * modifiedPlayer.Sigma / team.SigmaSq * omega * weightScalar; - modifiedPlayer.Sigma *= Math.Sqrt(Math.Max( - 1 - (modifiedPlayer.Sigma * modifiedPlayer.Sigma / team.SigmaSq * delta * weightScalar), - Kappa - )); - - return modifiedPlayer; - }).ToList(); + return team.Players.Select( + (_, jPlayerIndex) => + { + IRating modifiedPlayer = originalTeam.Players.ElementAt(jPlayerIndex); + double weight = weights?.ElementAtOrDefault(jPlayerIndex) ?? 1D; + double weightScalar = omega >= 0 + ? weight + : 1 / weight; + + modifiedPlayer.Mu += + modifiedPlayer.Sigma * modifiedPlayer.Sigma / team.SigmaSq * omega * weightScalar; + modifiedPlayer.Sigma *= Math.Sqrt( + Math.Max( + 1 - (modifiedPlayer.Sigma * modifiedPlayer.Sigma / team.SigmaSq * delta * weightScalar), + Kappa + ) + ); + + return modifiedPlayer; + } + ) + .ToList(); } /// @@ -390,8 +422,9 @@ IEnumerable processedTeams foreach (List teamIndices in rankGroups.Values.Where(g => g.Count > 1)) { - double avgMuChange = teamIndices.Average(i => - processedTeamsList[i].Players.First().Mu - originalTeams[i].Players.First().Mu + double avgMuChange = teamIndices.Average( + i => + processedTeamsList[i].Players.First().Mu - originalTeams[i].Players.First().Mu ); foreach (int teamIndex in teamIndices) diff --git a/OpenSkillSharp/OpenSkillSharp.csproj b/OpenSkillSharp/OpenSkillSharp.csproj index a70a49a..58d2e5a 100644 --- a/OpenSkillSharp/OpenSkillSharp.csproj +++ b/OpenSkillSharp/OpenSkillSharp.csproj @@ -6,7 +6,7 @@ enable OpenSkillSharp myssto - A faster, open-license alternative to Microsoft TrueSkill + A faster, open-license alternative to Microsoft TrueSkill https://github.com/myssto/OpenSkillSharp LICENSE README.md diff --git a/OpenSkillSharp/Util/Common.cs b/OpenSkillSharp/Util/Common.cs index b0a7a03..1283ad5 100644 --- a/OpenSkillSharp/Util/Common.cs +++ b/OpenSkillSharp/Util/Common.cs @@ -13,8 +13,10 @@ public static class Common /// A number representing the score comparison of the two given ranks. public static double Score(double q, double i) { - return q < i ? 0 - : q > i ? 1 - : 0.5; + return q < i + ? 0 + : q > i + ? 1 + : 0.5; } } \ No newline at end of file diff --git a/OpenSkillSharp/Util/Statistics.cs b/OpenSkillSharp/Util/Statistics.cs index d1d4b62..524d7a5 100644 --- a/OpenSkillSharp/Util/Statistics.cs +++ b/OpenSkillSharp/Util/Statistics.cs @@ -9,9 +9,9 @@ namespace OpenSkillSharp.Util; /// public static class Statistics { - private static readonly Normal Normal = new(0, 1); private const double Epsilon = 1e-10; - + private static readonly Normal Normal = new(0, 1); + /// /// Computes the cumulative distribution (CDF) of the standard normal distribution at the given number. /// @@ -35,7 +35,7 @@ public static double InversePhiMajor(double x) { return Normal.InverseCumulativeDistribution(x); } - + /// /// Computes the probability density of the standard normal distribution (PDF) at the given number. /// @@ -48,7 +48,10 @@ public static double PhiMinor(double x) /// /// The function V as defined in - /// JMLR:v12:weng11a. + /// + /// JMLR:v12:weng11a + /// + /// . ///
/// Represented by: ///
@@ -69,7 +72,10 @@ public static double V(double x, double t) /// /// The function W as defined in - /// JMLR:v12:weng11a. + /// + /// JMLR:v12:weng11a + /// + /// . ///
/// Represented by: ///
@@ -93,7 +99,10 @@ public static double W(double x, double t) /// /// The function ~V as defined in - /// JMLR:v12:weng11a. + /// + /// JMLR:v12:weng11a + /// + /// . ///
/// Represented by: ///
@@ -121,7 +130,10 @@ public static double VT(double x, double t) /// /// The function ~W as defined in - /// JMLR:v12:weng11a. + /// + /// JMLR:v12:weng11a + /// + /// . ///
/// Represented by: ///
diff --git a/README.md b/README.md index 5de122c..31585c9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ # Openskill -.NET/C# implementation of Weng-Lin Rating, as described at https://www.csie.ntu.edu.tw/~cjlin/papers/online_ranking/online_journal.pdf +.NET/C# implementation of Weng-Lin Rating, as described +at https://www.csie.ntu.edu.tw/~cjlin/papers/online_ranking/online_journal.pdf ## Installation @@ -39,7 +40,9 @@ ThurstoneMostellerPart model = new() ### Ratings -"Player ratings" are represented as objects that implement `OpenSkillSharp.Domain.Rating.IRating`. These objects contain properties which represent a gaussian curve where `Mu` represents the _mean_, and `Sigma` represents the spread or standard deviation. Create these using your model: +"Player ratings" are represented as objects that implement `OpenSkillSharp.Domain.Rating.IRating`. These objects contain +properties which represent a gaussian curve where `Mu` represents the _mean_, and `Sigma` represents the spread or +standard deviation. Create these using your model: ```cs PlackettLuce model = new(); @@ -57,7 +60,9 @@ IRating b2 = model.Rating(mu: 25.188, sigma: 6.211); >> {Rating} {Mu: 25.188, Sigma: 6.211} ``` -Ratings are updated using the `IOpenSkillModel.Rate()` method. This method takes a list of teams from a game and produces a new list of teams containing updated ratings. If `a1` and `a2` play on a team and win against a team with players `b1` and `b2`, you can update their ratings like so: +Ratings are updated using the `IOpenSkillModel.Rate()` method. This method takes a list of teams from a game and +produces a new list of teams containing updated ratings. If `a1` and `a2` play on a team and win against a team with +players `b1` and `b2`, you can update their ratings like so: ```cs List result = model.Rate( @@ -86,11 +91,14 @@ player.Ordinal >> 35.81 ``` -By default, this returns `mu - 3 * sigma`, reresenting a rating for which there is a [99.7%](https://en.wikipedia.org/wiki/68–95–99.7_rule) likelihood that the player's true rating is higher so with early games, a player's ordinal rating will usually rise, and can rise even if that player were to lose. +By default, this returns `mu - 3 * sigma`, reresenting a rating for which there is +a [99.7%](https://en.wikipedia.org/wiki/68–95–99.7_rule) likelihood that the player's true rating is higher so with +early games, a player's ordinal rating will usually rise, and can rise even if that player were to lose. ### Predicting Winners -For a given match of any number of teams, using `IOpenSkillModel.PredictWin()` will produce a list of relative odds that each of those teams will win. +For a given match of any number of teams, using `IOpenSkillModel.PredictWin()` will produce a list of relative odds that +each of those teams will win. ```cs PlackettLuce model = new(); @@ -107,7 +115,10 @@ probabilities.Sum(); ### Predicting Draws -Similarly to win prediction, using `IOpenSkillModel.PredictDraw()` will produce a single number representing the relative chance that the given teams will draw their match. The probability here should be treated as relative to other matches, but in reality the odds of an actual legal draw will be impacted by some meta-function based on the rules of the game. +Similarly to win prediction, using `IOpenSkillModel.PredictDraw()` will produce a single number representing the +relative chance that the given teams will draw their match. The probability here should be treated as relative to other +matches, but in reality the odds of an actual legal draw will be impacted by some meta-function based on the rules of +the game. ```cs double prediction = model.PredictDraw([t1, t2]); @@ -116,26 +127,40 @@ double prediction = model.PredictDraw([t1, t2]); ### Alternative Models -The recommended default model is **PlackettLuce**, which is a generalized Bradley-Terry model for _k_ >= 3 teams and typically scales the best. That considered, there are other models available with various differences between them. +The recommended default model is **PlackettLuce**, which is a generalized Bradley-Terry model for _k_ >= 3 teams and +typically scales the best. That considered, there are other models available with various differences between them. -- Bradley-Terry rating models follow a logistic distribution over a player's skill, similar to Glicko. -- Thurstone-Mosteller rating models follow a gaussian distribution similar to TrueSkill. Gaussian CDF/PDF functions differ in implementation from system to system and the accuracy of this model isn't typically as great as others but it can be tuned with custom gamma functions if you choose to do so. -- Full pairing models should have more accurate ratings over partial pairing models, however in high _k_ games (for example a 100+ person marathon), Bradley-Terry and Thurstone-Mosteller models need to do a joint probability calculation which involves a computationally expensive _k_-1 dimensional integration. In the case where players only change based on their neighbors, partial pairing is desirable. +- Bradley-Terry rating models follow a logistic distribution over a player's skill, similar to Glicko. +- Thurstone-Mosteller rating models follow a gaussian distribution similar to TrueSkill. Gaussian CDF/PDF functions + differ in implementation from system to system and the accuracy of this model isn't typically as great as others but + it can be tuned with custom gamma functions if you choose to do so. +- Full pairing models should have more accurate ratings over partial pairing models, however in high _k_ games (for + example a 100+ person marathon), Bradley-Terry and Thurstone-Mosteller models need to do a joint probability + calculation which involves a computationally expensive _k_-1 dimensional integration. In the case where players only + change based on their neighbors, partial pairing is desirable. ## References -This project is largely ported from the [openskill.py](https://github.com/vivekjoshy/openskill.py) package with changes made to bring the code style more in line with idiomatic C# principles. The vast majority of the unit tests and data for them were taken from the python package, as well as the [openskill.js](https://github.com/philihp/openskill.js) package. All of the Weng-Lin models are based off the research from this [paper](https://jmlr.org/papers/v12/weng11a.html) or are derivatives of the algorithms found in it. +This project is largely ported from the [openskill.py](https://github.com/vivekjoshy/openskill.py) package with changes +made to bring the code style more in line with idiomatic C# principles. The vast majority of the unit tests and data for +them were taken from the python package, as well as the [openskill.js](https://github.com/philihp/openskill.js) package. +All of the Weng-Lin models are based off the research from this [paper](https://jmlr.org/papers/v12/weng11a.html) or are +derivatives of the algorithms found in it. -- Julia Ibstedt, Elsa Rådahl, Erik Turesson, and Magdalena vande Voorde. Application and further development of trueskill™ ranking in sports. 2019. -- Ruby C. Weng and Chih-Jen Lin. A bayesian approximation method for online ranking. Journal of Machine Learning Research, 12(9):267–300, 2011. URL: http://jmlr.org/papers/v12/weng11a.html. +- Julia Ibstedt, Elsa Rådahl, Erik Turesson, and Magdalena vande Voorde. Application and further development of + trueskill™ ranking in sports. 2019. +- Ruby C. Weng and Chih-Jen Lin. A bayesian approximation method for online ranking. Journal of Machine Learning + Research, 12(9):267–300, 2011. URL: http://jmlr.org/papers/v12/weng11a.html. -If you are struggling with any concepts or are looking for more in-depth usage documentation, it is highly recommended to take a look at the official python documentation [here](https://openskill.me/en/stable/), as the projects are incredibly similar in structure and you will find that most examples will apply to this library as well. +If you are struggling with any concepts or are looking for more in-depth usage documentation, it is highly recommended +to take a look at the official python documentation [here](https://openskill.me/en/stable/), as the projects are +incredibly similar in structure and you will find that most examples will apply to this library as well. ## Implementations in other Languages -- [Javascript](https://github.com/philihp/openskill.js) -- [Python](https://github.com/vivekjoshy/openskill.py) -- [Elixir](https://github.com/philihp/openskill.ex) -- [Java](https://github.com/pocketcombats/openskill-java) -- [Kotlin](https://github.com/brezinajn/openskill.kt) -- [Lua](https://github.com/bstummer/openskill.lua) +- [Javascript](https://github.com/philihp/openskill.js) +- [Python](https://github.com/vivekjoshy/openskill.py) +- [Elixir](https://github.com/philihp/openskill.ex) +- [Java](https://github.com/pocketcombats/openskill-java) +- [Kotlin](https://github.com/brezinajn/openskill.kt) +- [Lua](https://github.com/bstummer/openskill.lua) From ad54173f33e16a1e25a4f80f5c7c9c57d8ec0d8e Mon Sep 17 00:00:00 2001 From: myssto Date: Sat, 20 Dec 2025 14:26:54 -0600 Subject: [PATCH 5/5] docs: Add xmldocs to enumerable extensions --- OpenSkillSharp/Util/EnumerableExtensions.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/OpenSkillSharp/Util/EnumerableExtensions.cs b/OpenSkillSharp/Util/EnumerableExtensions.cs index d7174f5..88c61e9 100644 --- a/OpenSkillSharp/Util/EnumerableExtensions.cs +++ b/OpenSkillSharp/Util/EnumerableExtensions.cs @@ -2,11 +2,24 @@ namespace OpenSkillSharp.Util; public static class EnumerableExtensions { + /// + /// Compares the length of two lists. + /// + /// Source list. + /// Target list. + /// Whether the given lists are of equal length. public static bool IsEqualLengthTo(this IEnumerable source, IEnumerable target) { return source.Count() == target.Count(); } + /// + /// Normalizes a list of numbers to a given range. + /// + /// A list of numbers. + /// Minimum value. + /// Maximum value. + /// A list of numbers normalized to the given range. public static IList Normalize(this IList source, double min, double max) { if (source.Count == 1)