From cb46631ff869a2125937772b9dda4b5029e490bb Mon Sep 17 00:00:00 2001 From: Jeff Moser Date: Thu, 18 Mar 2010 07:39:48 -0400 Subject: [PATCH] Initial version of Moserware.Skills TrueSkill calculator to go along with my Computing Your Skill blog post --- Skills.sln | 26 + Skills.suo | Bin 0 -> 64512 bytes Skills/Elo/DuellingEloCalculator.cs | 138 +++ Skills/Elo/EloRating.cs | 14 + Skills/Elo/FideEloCalculator.cs | 31 + Skills/Elo/FideKFactor.cs | 36 + Skills/Elo/GaussianEloCalculator.cs | 27 + Skills/Elo/GaussianKFactor.cs | 20 + Skills/Elo/KFactor.cs | 22 + Skills/Elo/TwoPlayerEloCalculator.cs | 79 ++ Skills/FactorGraphs/Factor.cs | 94 ++ Skills/FactorGraphs/FactorGraph.cs | 9 + Skills/FactorGraphs/FactorGraphLayer.cs | 108 ++ Skills/FactorGraphs/FactorList.cs | 47 + Skills/FactorGraphs/Message.cs | 30 + Skills/FactorGraphs/Schedule.cs | 105 ++ Skills/FactorGraphs/Variable.cs | 58 + Skills/FactorGraphs/VariableFactory.cs | 42 + Skills/GameInfo.cs | 49 + Skills/Guard.cs | 36 + Skills/ISupportPartialPlay.cs | 13 + Skills/ISupportPartialUpdate.cs | 10 + Skills/License.txt | 37 + Skills/Numerics/GaussianDistribution.cs | 240 +++++ Skills/Numerics/Matrix.cs | 520 +++++++++ Skills/Numerics/Range.cs | 49 + Skills/PairwiseComparison.cs | 15 + Skills/PartialPlay.cs | 26 + Skills/Player.cs | 129 +++ Skills/PlayersRange.cs | 22 + Skills/Properties/AssemblyInfo.cs | 45 + Skills/README.txt | 58 + Skills/RankSorter.cs | 76 ++ Skills/Rating.cs | 94 ++ Skills/SkillCalculator.cs | 93 ++ Skills/Skills.csproj | 116 ++ Skills/Skills.suo | Bin 0 -> 142336 bytes Skills/Team.cs | 90 ++ Skills/TeamsRange.cs | 22 + Skills/TrueSkill/DrawMargin.cs | 23 + .../FactorGraphTrueSkillCalculator.cs | 156 +++ Skills/TrueSkill/Factors/GaussianFactor.cs | 33 + .../Factors/GaussianGreaterThanFactor.cs | 75 ++ .../Factors/GaussianLikelihoodFactor.cs | 72 ++ .../TrueSkill/Factors/GaussianPriorFactor.cs | 39 + .../Factors/GaussianWeightedSumFactor.cs | 252 +++++ .../TrueSkill/Factors/GaussianWithinFactor.cs | 73 ++ .../IteratedTeamDifferencesInnerLayer.cs | 192 ++++ ...ayerPerformancesToTeamPerformancesLayer.cs | 68 ++ .../Layers/PlayerPriorValuesToSkillsLayer.cs | 63 ++ .../Layers/PlayerSkillsToPerformancesLayer.cs | 64 ++ .../Layers/TeamDifferencesComparisonLayer.cs | 38 + ...mancesToTeamPerformanceDifferencesLayer.cs | 43 + .../Layers/TrueSkillFactorGraphLayer.cs | 20 + Skills/TrueSkill/TrueSkillFactorGraph.cs | 119 +++ .../TruncatedGaussianCorrectionFunctions.cs | 188 ++++ .../TrueSkill/TwoPlayerTrueSkillCalculator.cs | 150 +++ .../TrueSkill/TwoTeamTrueSkillCalculator.cs | 173 +++ Skills/TrueSkillCalculator.cs | 45 + Skills/bin/Debug/Moserware.Skills.dll | Bin 0 -> 63488 bytes Skills/bin/Debug/Moserware.Skills.pdb | Bin 0 -> 247296 bytes UnitTests/Elo/DuellingEloTest.cs | 51 + UnitTests/Elo/EloAssert.cs | 37 + UnitTests/Elo/FideEloCalculatorTest.cs | 36 + UnitTests/Elo/GaussianEloCalculatorTest.cs | 26 + .../Numerics/GaussianDistributionTests.cs | 68 ++ UnitTests/Numerics/MatrixTests.cs | 186 ++++ UnitTests/Properties/AssemblyInfo.cs | 35 + UnitTests/README.txt | 23 + UnitTests/RankSorterTest.cs | 34 + UnitTests/TrueSkill/DrawMarginTest.cs | 27 + .../FactorGraphTrueSkillCalculatorTests.cs | 22 + .../TrueSkill/TrueSkillCalculatorTests.cs | 987 ++++++++++++++++++ .../TwoPlayerTrueSkillCalculatorTest.cs | 20 + .../TwoTeamTrueSkillCalculatorTest.cs | 19 + UnitTests/UnitTests.csproj | 80 ++ UnitTests/UnitTests.csproj.user | 9 + 77 files changed, 6172 insertions(+) create mode 100644 Skills.sln create mode 100644 Skills.suo create mode 100644 Skills/Elo/DuellingEloCalculator.cs create mode 100644 Skills/Elo/EloRating.cs create mode 100644 Skills/Elo/FideEloCalculator.cs create mode 100644 Skills/Elo/FideKFactor.cs create mode 100644 Skills/Elo/GaussianEloCalculator.cs create mode 100644 Skills/Elo/GaussianKFactor.cs create mode 100644 Skills/Elo/KFactor.cs create mode 100644 Skills/Elo/TwoPlayerEloCalculator.cs create mode 100644 Skills/FactorGraphs/Factor.cs create mode 100644 Skills/FactorGraphs/FactorGraph.cs create mode 100644 Skills/FactorGraphs/FactorGraphLayer.cs create mode 100644 Skills/FactorGraphs/FactorList.cs create mode 100644 Skills/FactorGraphs/Message.cs create mode 100644 Skills/FactorGraphs/Schedule.cs create mode 100644 Skills/FactorGraphs/Variable.cs create mode 100644 Skills/FactorGraphs/VariableFactory.cs create mode 100644 Skills/GameInfo.cs create mode 100644 Skills/Guard.cs create mode 100644 Skills/ISupportPartialPlay.cs create mode 100644 Skills/ISupportPartialUpdate.cs create mode 100644 Skills/License.txt create mode 100644 Skills/Numerics/GaussianDistribution.cs create mode 100644 Skills/Numerics/Matrix.cs create mode 100644 Skills/Numerics/Range.cs create mode 100644 Skills/PairwiseComparison.cs create mode 100644 Skills/PartialPlay.cs create mode 100644 Skills/Player.cs create mode 100644 Skills/PlayersRange.cs create mode 100644 Skills/Properties/AssemblyInfo.cs create mode 100644 Skills/README.txt create mode 100644 Skills/RankSorter.cs create mode 100644 Skills/Rating.cs create mode 100644 Skills/SkillCalculator.cs create mode 100644 Skills/Skills.csproj create mode 100644 Skills/Skills.suo create mode 100644 Skills/Team.cs create mode 100644 Skills/TeamsRange.cs create mode 100644 Skills/TrueSkill/DrawMargin.cs create mode 100644 Skills/TrueSkill/FactorGraphTrueSkillCalculator.cs create mode 100644 Skills/TrueSkill/Factors/GaussianFactor.cs create mode 100644 Skills/TrueSkill/Factors/GaussianGreaterThanFactor.cs create mode 100644 Skills/TrueSkill/Factors/GaussianLikelihoodFactor.cs create mode 100644 Skills/TrueSkill/Factors/GaussianPriorFactor.cs create mode 100644 Skills/TrueSkill/Factors/GaussianWeightedSumFactor.cs create mode 100644 Skills/TrueSkill/Factors/GaussianWithinFactor.cs create mode 100644 Skills/TrueSkill/Layers/IteratedTeamDifferencesInnerLayer.cs create mode 100644 Skills/TrueSkill/Layers/PlayerPerformancesToTeamPerformancesLayer.cs create mode 100644 Skills/TrueSkill/Layers/PlayerPriorValuesToSkillsLayer.cs create mode 100644 Skills/TrueSkill/Layers/PlayerSkillsToPerformancesLayer.cs create mode 100644 Skills/TrueSkill/Layers/TeamDifferencesComparisonLayer.cs create mode 100644 Skills/TrueSkill/Layers/TeamPerformancesToTeamPerformanceDifferencesLayer.cs create mode 100644 Skills/TrueSkill/Layers/TrueSkillFactorGraphLayer.cs create mode 100644 Skills/TrueSkill/TrueSkillFactorGraph.cs create mode 100644 Skills/TrueSkill/TruncatedGaussianCorrectionFunctions.cs create mode 100644 Skills/TrueSkill/TwoPlayerTrueSkillCalculator.cs create mode 100644 Skills/TrueSkill/TwoTeamTrueSkillCalculator.cs create mode 100644 Skills/TrueSkillCalculator.cs create mode 100644 Skills/bin/Debug/Moserware.Skills.dll create mode 100644 Skills/bin/Debug/Moserware.Skills.pdb create mode 100644 UnitTests/Elo/DuellingEloTest.cs create mode 100644 UnitTests/Elo/EloAssert.cs create mode 100644 UnitTests/Elo/FideEloCalculatorTest.cs create mode 100644 UnitTests/Elo/GaussianEloCalculatorTest.cs create mode 100644 UnitTests/Numerics/GaussianDistributionTests.cs create mode 100644 UnitTests/Numerics/MatrixTests.cs create mode 100644 UnitTests/Properties/AssemblyInfo.cs create mode 100644 UnitTests/README.txt create mode 100644 UnitTests/RankSorterTest.cs create mode 100644 UnitTests/TrueSkill/DrawMarginTest.cs create mode 100644 UnitTests/TrueSkill/FactorGraphTrueSkillCalculatorTests.cs create mode 100644 UnitTests/TrueSkill/TrueSkillCalculatorTests.cs create mode 100644 UnitTests/TrueSkill/TwoPlayerTrueSkillCalculatorTest.cs create mode 100644 UnitTests/TrueSkill/TwoTeamTrueSkillCalculatorTest.cs create mode 100644 UnitTests/UnitTests.csproj create mode 100644 UnitTests/UnitTests.csproj.user diff --git a/Skills.sln b/Skills.sln new file mode 100644 index 0000000..7e5e0f9 --- /dev/null +++ b/Skills.sln @@ -0,0 +1,26 @@ + +Microsoft Visual Studio Solution File, Format Version 10.00 +# Visual Studio 2008 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Skills", "Skills\Skills.csproj", "{15AD1345-984C-48ED-AF9A-2EAB44E5AA2B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "UnitTests\UnitTests.csproj", "{6F80946D-AC8B-4063-8588-96841C18BF0A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {15AD1345-984C-48ED-AF9A-2EAB44E5AA2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {15AD1345-984C-48ED-AF9A-2EAB44E5AA2B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15AD1345-984C-48ED-AF9A-2EAB44E5AA2B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {15AD1345-984C-48ED-AF9A-2EAB44E5AA2B}.Release|Any CPU.Build.0 = Release|Any CPU + {6F80946D-AC8B-4063-8588-96841C18BF0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F80946D-AC8B-4063-8588-96841C18BF0A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F80946D-AC8B-4063-8588-96841C18BF0A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F80946D-AC8B-4063-8588-96841C18BF0A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/Skills.suo b/Skills.suo new file mode 100644 index 0000000000000000000000000000000000000000..e468e1956d38ed06a520638028bb9842ac359d0f GIT binary patch literal 64512 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 literal 0 HcmV?d00001 diff --git a/Skills/Elo/DuellingEloCalculator.cs b/Skills/Elo/DuellingEloCalculator.cs new file mode 100644 index 0000000..d135608 --- /dev/null +++ b/Skills/Elo/DuellingEloCalculator.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Moserware.Skills.Elo +{ + public class DuellingEloCalculator : SkillCalculator + { + private readonly TwoPlayerEloCalculator _TwoPlayerEloCalc; + + public DuellingEloCalculator(TwoPlayerEloCalculator twoPlayerEloCalculator) + : base(SupportedOptions.None, TeamsRange.AtLeast(2), PlayersRange.AtLeast(1)) + { + _TwoPlayerEloCalc = twoPlayerEloCalculator; + } + + public override IDictionary CalculateNewRatings(GameInfo gameInfo, IEnumerable> teams, params int[] teamRanks) + { + // On page 6 of the TrueSkill paper, the authors write: + // "When we had to process a team game or a game with more than two teams we used + // the so-called *duelling* heuristic: For each player, compute the Δ's in comparison + // to all other players based on the team outcome of the player and every other player and + // perform an update with the average of the Δ's." + // This implements that algorithm. + + ValidateTeamCountAndPlayersCountPerTeam(teams); + RankSorter.Sort(ref teams, ref teamRanks); + + var teamsList = teams.ToList(); + + var deltas = new Dictionary>(); + + for(int ixCurrentTeam = 0; ixCurrentTeam < teamsList.Count; ixCurrentTeam++) + { + for(int ixOtherTeam = 0; ixOtherTeam < teamsList.Count; ixOtherTeam++) + { + if(ixOtherTeam == ixCurrentTeam) + { + // Shouldn't duel against ourself ;) + continue; + } + + var currentTeam = teamsList[ixCurrentTeam]; + var otherTeam = teamsList[ixOtherTeam]; + + // Remember that bigger numbers mean worse rank (e.g. other-current is what we want) + var comparison = (PairwiseComparison) Math.Sign(teamRanks[ixOtherTeam] - teamRanks[ixCurrentTeam]); + + foreach(var currentTeamPlayerRatingPair in currentTeam) + { + foreach(var otherTeamPlayerRatingPair in otherTeam) + { + UpdateDuels(gameInfo, deltas, + currentTeamPlayerRatingPair.Key, currentTeamPlayerRatingPair.Value, + otherTeamPlayerRatingPair.Key, otherTeamPlayerRatingPair.Value, + comparison); + + } + } + } + } + + var result = new Dictionary(); + + foreach(var currentTeam in teamsList) + { + foreach(var currentTeamPlayerPair in currentTeam) + { + var currentPlayerAverageDuellingDelta = deltas[currentTeamPlayerPair.Key].Values.Average(); + result[currentTeamPlayerPair.Key] = new EloRating(currentTeamPlayerPair.Value.Mean + currentPlayerAverageDuellingDelta); + } + } + + return result; + } + + private void UpdateDuels(GameInfo gameInfo, + IDictionary> duels, + TPlayer player1, Rating player1Rating, + TPlayer player2, Rating player2Rating, + PairwiseComparison weakToStrongComparison) + { + + var duelOutcomes = _TwoPlayerEloCalc.CalculateNewRatings(gameInfo, + Teams.Concat( + new Team(player1, player1Rating), + new Team(player2, player2Rating)), + (weakToStrongComparison == PairwiseComparison.Win) ? new int[] { 1, 2 } + : (weakToStrongComparison == PairwiseComparison.Lose) ? new int[] { 2, 1 } + : new int[] { 1, 1}); + + + UpdateDuelInfo(duels, player1, player1Rating, duelOutcomes[player1], player2); + UpdateDuelInfo(duels, player2, player2Rating, duelOutcomes[player2], player1); + } + + private static void UpdateDuelInfo(IDictionary> duels, + TPlayer self, Rating selfBeforeRating, Rating selfAfterRating, + TPlayer opponent ) + { + IDictionary selfToOpponentDuelDeltas; + + if(!duels.TryGetValue(self, out selfToOpponentDuelDeltas)) + { + selfToOpponentDuelDeltas = new Dictionary(); + duels[self] = selfToOpponentDuelDeltas; + } + + selfToOpponentDuelDeltas[opponent] = selfAfterRating.Mean - selfBeforeRating.Mean; + } + + public override double CalculateMatchQuality(GameInfo gameInfo, IEnumerable> teams) + { + // HACK! Need a better algorithm, this is just to have something there and it isn't good + double minQuality = 1.0; + + var teamList = teams.ToList(); + + for(int ixCurrentTeam = 0; ixCurrentTeam < teamList.Count; ixCurrentTeam++) + { + EloRating currentTeamAverageRating = new EloRating(teamList[ixCurrentTeam].Values.Average(r => r.Mean)); + var currentTeam = new Team(new Player(ixCurrentTeam), currentTeamAverageRating); + + for(int ixOtherTeam = ixCurrentTeam + 1; ixOtherTeam < teamList.Count; ixOtherTeam++) + { + EloRating otherTeamAverageRating = new EloRating(teamList[ixOtherTeam].Values.Average(r => r.Mean)); + var otherTeam = new Team(new Player(ixOtherTeam), otherTeamAverageRating); + + minQuality = Math.Min(minQuality, + _TwoPlayerEloCalc.CalculateMatchQuality(gameInfo, + Teams.Concat(currentTeam, otherTeam))); + } + } + + return minQuality; + } + } +} \ No newline at end of file diff --git a/Skills/Elo/EloRating.cs b/Skills/Elo/EloRating.cs new file mode 100644 index 0000000..e408c43 --- /dev/null +++ b/Skills/Elo/EloRating.cs @@ -0,0 +1,14 @@ + +namespace Moserware.Skills.Elo +{ + /// + /// An Elo rating represented by a single number (mean). + /// + public class EloRating : Rating + { + public EloRating(double rating) + : base(rating, 0) + { + } + } +} diff --git a/Skills/Elo/FideEloCalculator.cs b/Skills/Elo/FideEloCalculator.cs new file mode 100644 index 0000000..cc3623e --- /dev/null +++ b/Skills/Elo/FideEloCalculator.cs @@ -0,0 +1,31 @@ +using System; + +namespace Moserware.Skills.Elo +{ + // Including ELO's scheme as a simple comparison. + // See http://en.wikipedia.org/wiki/Elo_rating_system#Theory + // for more details + public class FideEloCalculator : TwoPlayerEloCalculator + { + public FideEloCalculator() + : this(new FideKFactor()) + { + } + + public FideEloCalculator(FideKFactor kFactor) + : base(kFactor) + { + } + + protected override double GetPlayerWinProbability(GameInfo gameInfo, double playerRating, double opponentRating) + { + double ratingDifference = opponentRating - playerRating; + + return 1.0 + / + ( + 1.0 + Math.Pow(10.0, ratingDifference / (2 * gameInfo.Beta)) + ); + } + } +} \ No newline at end of file diff --git a/Skills/Elo/FideKFactor.cs b/Skills/Elo/FideKFactor.cs new file mode 100644 index 0000000..f3c16b2 --- /dev/null +++ b/Skills/Elo/FideKFactor.cs @@ -0,0 +1,36 @@ + +namespace Moserware.Skills.Elo +{ + // see http://ratings.fide.com/calculator_rtd.phtml for details + public class FideKFactor : KFactor + { + public FideKFactor() + { + } + + public override double GetValueForRating(double rating) + { + if (rating < 2400) + { + return 15; + } + + return 10; + } + + /// + /// Indicates someone who has played less than 30 games. + /// + public class Provisional : FideKFactor + { + public Provisional() + { + } + + public override double GetValueForRating(double rating) + { + return 25; + } + } + } +} diff --git a/Skills/Elo/GaussianEloCalculator.cs b/Skills/Elo/GaussianEloCalculator.cs new file mode 100644 index 0000000..9e9a012 --- /dev/null +++ b/Skills/Elo/GaussianEloCalculator.cs @@ -0,0 +1,27 @@ +using System; +using Moserware.Numerics; + +namespace Moserware.Skills.Elo +{ + public class GaussianEloCalculator : TwoPlayerEloCalculator + { + // From the paper + private static readonly KFactor StableKFactor = new KFactor(24); + + public GaussianEloCalculator() + : base(StableKFactor) + { + } + + protected override double GetPlayerWinProbability(GameInfo gameInfo, double playerRating, double opponentRating) + { + double ratingDifference = playerRating - opponentRating; + + // See equation 1.1 in the TrueSkill paper + return GaussianDistribution.CumulativeTo( + ratingDifference + / + (Math.Sqrt(2) * gameInfo.Beta)); + } + } +} diff --git a/Skills/Elo/GaussianKFactor.cs b/Skills/Elo/GaussianKFactor.cs new file mode 100644 index 0000000..f2fa62a --- /dev/null +++ b/Skills/Elo/GaussianKFactor.cs @@ -0,0 +1,20 @@ +using System; + +namespace Moserware.Skills.Elo +{ + public class GaussianKFactor : KFactor + { + // From paper + const double StableDynamicsKFactor = 24.0; + + public GaussianKFactor() + : base(StableDynamicsKFactor) + { + } + + public GaussianKFactor(GameInfo gameInfo, double latestGameWeightingFactor) + : base(latestGameWeightingFactor * gameInfo.Beta * Math.Sqrt(Math.PI)) + { + } + } +} diff --git a/Skills/Elo/KFactor.cs b/Skills/Elo/KFactor.cs new file mode 100644 index 0000000..c8be739 --- /dev/null +++ b/Skills/Elo/KFactor.cs @@ -0,0 +1,22 @@ + +namespace Moserware.Skills.Elo +{ + public class KFactor + { + private double _Value; + + protected KFactor() + { + } + + public KFactor(double exactKFactor) + { + _Value = exactKFactor; + } + + public virtual double GetValueForRating(double rating) + { + return _Value; + } + } +} diff --git a/Skills/Elo/TwoPlayerEloCalculator.cs b/Skills/Elo/TwoPlayerEloCalculator.cs new file mode 100644 index 0000000..943db47 --- /dev/null +++ b/Skills/Elo/TwoPlayerEloCalculator.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Moserware.Skills.Elo +{ + public abstract class TwoPlayerEloCalculator : SkillCalculator + { + protected readonly KFactor _KFactor; + + protected TwoPlayerEloCalculator(KFactor kFactor) + : base(SupportedOptions.None, TeamsRange.Exactly(2), PlayersRange.Exactly(1)) + { + _KFactor = kFactor; + } + + public override IDictionary CalculateNewRatings(GameInfo gameInfo, IEnumerable> teams, params int[] teamRanks) + { + ValidateTeamCountAndPlayersCountPerTeam(teams); + RankSorter.Sort(ref teams, ref teamRanks); + + var result = new Dictionary(); + bool isDraw = (teamRanks[0] == teamRanks[1]); + + var player1 = teams.First().First(); + var player2 = teams.Last().First(); + + var player1Rating = player1.Value.Mean; + var player2Rating = player2.Value.Mean; + + result[player1.Key] = CalculateNewRating(gameInfo, player1Rating, player2Rating, isDraw ? PairwiseComparison.Draw : PairwiseComparison.Win); + result[player2.Key] = CalculateNewRating(gameInfo, player2Rating, player1Rating, isDraw ? PairwiseComparison.Draw : PairwiseComparison.Lose); + + return result; + } + + protected virtual EloRating CalculateNewRating(GameInfo gameInfo, double selfRating, double opponentRating, PairwiseComparison selfToOpponentComparison) + { + double expectedProbability = GetPlayerWinProbability(gameInfo, selfRating, opponentRating); + double actualProbability = GetScoreFromComparison(selfToOpponentComparison); + double k = _KFactor.GetValueForRating(selfRating); + double ratingChange = k * (actualProbability - expectedProbability); + double newRating = selfRating + ratingChange; + + return new EloRating(newRating); + } + + private static double GetScoreFromComparison(PairwiseComparison comparison) + { + switch (comparison) + { + case PairwiseComparison.Win: + return 1; + case PairwiseComparison.Draw: + return 0.5; + case PairwiseComparison.Lose: + return 0; + default: + throw new NotSupportedException(); + } + } + + protected abstract double GetPlayerWinProbability(GameInfo gameInfo, double playerRating, double opponentRating); + + public override double CalculateMatchQuality(GameInfo gameInfo, IEnumerable> teams) + { + ValidateTeamCountAndPlayersCountPerTeam(teams); + double player1Rating = teams.First().First().Value.Mean; + double player2Rating = teams.Last().First().Value.Mean; + double ratingDifference = player1Rating - player2Rating; + + // The TrueSkill paper mentions that they used s1 - s2 (rating difference) to + // determine match quality. I convert that to a percentage as a delta from 50% + // using the cumulative density function of the specific curve being used + double deltaFrom50Percent = Math.Abs(GetPlayerWinProbability(gameInfo, player1Rating, player2Rating) - 0.5); + return (0.5 - deltaFrom50Percent) / 0.5; + } + } +} diff --git a/Skills/FactorGraphs/Factor.cs b/Skills/FactorGraphs/Factor.cs new file mode 100644 index 0000000..6e7caf2 --- /dev/null +++ b/Skills/FactorGraphs/Factor.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Moserware.Skills.FactorGraphs +{ + public abstract class Factor + { + private readonly List> _Messages = new List>(); + + private readonly Dictionary, Variable> _MessageToVariableBinding = + new Dictionary, Variable>(); + + private readonly string _Name; + private readonly List> _Variables = new List>(); + + protected Factor(string name) + { + _Name = "Factor[" + name + "]"; + } + + /// Returns the log-normalization constant of that factor + public virtual double LogNormalization + { + get { return 0; } + } + + /// Returns the number of messages that the factor has + public int NumberOfMessages + { + get { return _Messages.Count; } + } + + protected ReadOnlyCollection> Variables + { + get { return _Variables.AsReadOnly(); } + } + + protected ReadOnlyCollection> Messages + { + get { return _Messages.AsReadOnly(); } + } + + /// Update the message and marginal of the i-th variable that the factor is connected to + public virtual double UpdateMessage(int messageIndex) + { + Guard.ArgumentIsValidIndex(messageIndex, _Messages.Count, "messageIndex"); + return UpdateMessage(_Messages[messageIndex], _MessageToVariableBinding[_Messages[messageIndex]]); + } + + protected virtual double UpdateMessage(Message message, Variable variable) + { + throw new NotImplementedException(); + } + + /// Resets the marginal of the variables a factor is connected to + public virtual void ResetMarginals() + { + foreach (var currentVariable in _MessageToVariableBinding.Values) + { + currentVariable.ResetToPrior(); + } + } + + /// Sends the ith message to the marginal and returns the log-normalization constant + public virtual double SendMessage(int messageIndex) + { + Guard.ArgumentIsValidIndex(messageIndex, _Messages.Count, "messageIndex"); + + Message message = _Messages[messageIndex]; + Variable variable = _MessageToVariableBinding[message]; + return SendMessage(message, variable); + } + + protected abstract double SendMessage(Message message, Variable variable); + + public abstract Message CreateVariableToMessageBinding(Variable variable); + + protected Message CreateVariableToMessageBinding(Variable variable, Message message) + { + int index = _Messages.Count; + _Messages.Add(message); + _MessageToVariableBinding[message] = variable; + _Variables.Add(variable); + + return message; + } + + public override string ToString() + { + return _Name ?? base.ToString(); + } + } +} \ No newline at end of file diff --git a/Skills/FactorGraphs/FactorGraph.cs b/Skills/FactorGraphs/FactorGraph.cs new file mode 100644 index 0000000..a7e93c5 --- /dev/null +++ b/Skills/FactorGraphs/FactorGraph.cs @@ -0,0 +1,9 @@ +namespace Moserware.Skills.FactorGraphs +{ + public class FactorGraph + where TSelf : FactorGraph + where TVariable : Variable + { + public VariableFactory VariableFactory { get; protected set; } + } +} \ No newline at end of file diff --git a/Skills/FactorGraphs/FactorGraphLayer.cs b/Skills/FactorGraphs/FactorGraphLayer.cs new file mode 100644 index 0000000..fd76b5b --- /dev/null +++ b/Skills/FactorGraphs/FactorGraphLayer.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Moserware.Skills.FactorGraphs +{ + public abstract class FactorGraphLayerBase + { + public abstract IEnumerable> UntypedFactors { get; } + public abstract void BuildLayer(); + + public virtual Schedule CreatePriorSchedule() + { + return null; + } + + public virtual Schedule CreatePosteriorSchedule() + { + return null; + } + + // HACK + + public abstract void SetRawInputVariablesGroups(object value); + public abstract object GetRawOutputVariablesGroups(); + } + + public abstract class FactorGraphLayer + : FactorGraphLayerBase + where TParentGraph : FactorGraph + where TBaseVariable : Variable + where TInputVariable : TBaseVariable + where TFactor : Factor + where TOutputVariable : TBaseVariable + { + private readonly List _LocalFactors = new List(); + private readonly List> _OutputVariablesGroups = new List>(); + private IList> _InputVariablesGroups = new List>(); + + protected FactorGraphLayer(TParentGraph parentGraph) + { + ParentFactorGraph = parentGraph; + } + + protected IList> InputVariablesGroups + { + get { return _InputVariablesGroups; } + } + + // HACK + + public TParentGraph ParentFactorGraph { get; private set; } + + public IList> OutputVariablesGroups + { + get { return _OutputVariablesGroups; } + } + + public IList LocalFactors + { + get { return _LocalFactors; } + } + + public override IEnumerable> UntypedFactors + { + get { return _LocalFactors.Cast>(); } + } + + public override void SetRawInputVariablesGroups(object value) + { + var newList = value as IList>; + if (newList == null) + { + // TODO: message + throw new ArgumentException(); + } + + _InputVariablesGroups = newList; + } + + public override object GetRawOutputVariablesGroups() + { + return _OutputVariablesGroups; + } + + protected Schedule ScheduleSequence( + IEnumerable itemsToSequence, + string nameFormat, + params object[] args) + where TSchedule : Schedule + + { + string formattedName = String.Format(nameFormat, args); + return new ScheduleSequence(formattedName, itemsToSequence); + } + + protected void AddLayerFactor(TFactor factor) + { + _LocalFactors.Add(factor); + } + + // Helper utility + protected double Square(double x) + { + return x*x; + } + } +} \ No newline at end of file diff --git a/Skills/FactorGraphs/FactorList.cs b/Skills/FactorGraphs/FactorList.cs new file mode 100644 index 0000000..07540f7 --- /dev/null +++ b/Skills/FactorGraphs/FactorList.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Moserware.Skills.FactorGraphs +{ + /// + /// Helper class for computing the factor graph's normalization constant. + /// + public class FactorList + { + private readonly List> _List = new List>(); + + public double LogNormalization + { + get + { + _List.ForEach(f => f.ResetMarginals()); + + double sumLogZ = 0.0; + + for (int i = 0; i < _List.Count; i++) + { + Factor f = _List[i]; + for (int j = 0; j < f.NumberOfMessages; j++) + { + sumLogZ += f.SendMessage(j); + } + } + + double sumLogS = _List.Aggregate(0.0, (acc, fac) => acc + fac.LogNormalization); + + return sumLogZ + sumLogS; + } + } + + public int Count + { + get { return _List.Count; } + } + + public Factor AddFactor(Factor factor) + { + _List.Add(factor); + return factor; + } + } +} \ No newline at end of file diff --git a/Skills/FactorGraphs/Message.cs b/Skills/FactorGraphs/Message.cs new file mode 100644 index 0000000..85f8c90 --- /dev/null +++ b/Skills/FactorGraphs/Message.cs @@ -0,0 +1,30 @@ +using System; + +namespace Moserware.Skills.FactorGraphs +{ + public class Message + { + private readonly string _NameFormat; + private readonly object[] _NameFormatArgs; + + public Message() + : this(default(T), null, null) + { + } + + public Message(T value, string nameFormat, params object[] args) + + { + _NameFormat = nameFormat; + _NameFormatArgs = args; + Value = value; + } + + public T Value { get; set; } + + public override string ToString() + { + return (_NameFormat == null) ? base.ToString() : String.Format(_NameFormat, _NameFormatArgs); + } + } +} \ No newline at end of file diff --git a/Skills/FactorGraphs/Schedule.cs b/Skills/FactorGraphs/Schedule.cs new file mode 100644 index 0000000..ac9d15a --- /dev/null +++ b/Skills/FactorGraphs/Schedule.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace Moserware.Skills.FactorGraphs +{ + public abstract class Schedule + { + private readonly string _Name; + + protected Schedule(string name) + { + _Name = name; + } + + public abstract double Visit(int depth, int maxDepth); + + public double Visit() + { + return Visit(-1, 0); + } + + public override string ToString() + { + return _Name; + } + } + + public class ScheduleStep : Schedule + { + private readonly Factor _Factor; + private readonly int _Index; + + public ScheduleStep(string name, Factor factor, int index) + : base(name) + { + _Factor = factor; + _Index = index; + } + + public override double Visit(int depth, int maxDepth) + { + double delta = _Factor.UpdateMessage(_Index); + return delta; + } + } + + // TODO: Remove + public class ScheduleSequence : ScheduleSequence> + { + public ScheduleSequence(string name, IEnumerable> schedules) + : base(name, schedules) + { + } + } + + public class ScheduleSequence : Schedule + where TSchedule : Schedule + { + private readonly IEnumerable _Schedules; + + public ScheduleSequence(string name, IEnumerable schedules) + : base(name) + { + _Schedules = schedules; + } + + public override double Visit(int depth, int maxDepth) + { + double maxDelta = 0; + + foreach (TSchedule currentSchedule in _Schedules) + { + maxDelta = Math.Max(currentSchedule.Visit(depth + 1, maxDepth), maxDelta); + } + + return maxDelta; + } + } + + public class ScheduleLoop : Schedule + { + private readonly double _MaxDelta; + private readonly Schedule _ScheduleToLoop; + + public ScheduleLoop(string name, Schedule scheduleToLoop, double maxDelta) + : base(name) + { + _ScheduleToLoop = scheduleToLoop; + _MaxDelta = maxDelta; + } + + public override double Visit(int depth, int maxDepth) + { + int totalIterations = 1; + double delta = _ScheduleToLoop.Visit(depth + 1, maxDepth); + while (delta > _MaxDelta) + { + delta = _ScheduleToLoop.Visit(depth + 1, maxDepth); + totalIterations++; + } + + return delta; + } + } +} \ No newline at end of file diff --git a/Skills/FactorGraphs/Variable.cs b/Skills/FactorGraphs/Variable.cs new file mode 100644 index 0000000..0ea1e40 --- /dev/null +++ b/Skills/FactorGraphs/Variable.cs @@ -0,0 +1,58 @@ +using System; + +namespace Moserware.Skills.FactorGraphs +{ + public class Variable + { + private readonly string _Name; + private readonly VariableFactory _ParentFactory; + private readonly TValue _Prior; + private int _ParentIndex; + + public Variable(string name, VariableFactory parentFactory, int parentIndex, TValue prior) + { + _Name = "Variable[" + name + "]"; + _ParentFactory = parentFactory; + _ParentIndex = parentIndex; + _Prior = prior; + ResetToPrior(); + } + + public virtual TValue Value { get; set; } + + public void ResetToPrior() + { + Value = _Prior; + } + + public override string ToString() + { + return _Name; + } + } + + public class DefaultVariable : Variable + { + public DefaultVariable() + : base("Default", null, 0, default(TValue)) + { + } + + public override TValue Value + { + get { return default(TValue); } + set { throw new NotSupportedException(); } + } + } + + public class KeyedVariable : Variable + { + public KeyedVariable(TKey key, string name, VariableFactory parentFactory, int parentIndex, TValue prior) + : base(name, parentFactory, parentIndex, prior) + { + Key = key; + } + + public TKey Key { get; private set; } + } +} \ No newline at end of file diff --git a/Skills/FactorGraphs/VariableFactory.cs b/Skills/FactorGraphs/VariableFactory.cs new file mode 100644 index 0000000..d6cb1d9 --- /dev/null +++ b/Skills/FactorGraphs/VariableFactory.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; + +namespace Moserware.Skills.FactorGraphs +{ + public class VariableFactory + { + // using a Func to encourage fresh copies in case it's overwritten + private readonly List> _CreatedVariables = new List>(); + private readonly Func _VariablePriorInitializer; + + public VariableFactory(Func variablePriorInitializer) + { + _VariablePriorInitializer = variablePriorInitializer; + } + + public Variable CreateBasicVariable(string nameFormat, params object[] args) + { + var newVar = new Variable( + String.Format(nameFormat, args), + this, + _CreatedVariables.Count, + _VariablePriorInitializer()); + + _CreatedVariables.Add(newVar); + return newVar; + } + + public KeyedVariable CreateKeyedVariable(TKey key, string nameFormat, params object[] args) + { + var newVar = new KeyedVariable( + key, + String.Format(nameFormat, args), + this, + _CreatedVariables.Count, + _VariablePriorInitializer()); + + _CreatedVariables.Add(newVar); + return newVar; + } + } +} \ No newline at end of file diff --git a/Skills/GameInfo.cs b/Skills/GameInfo.cs new file mode 100644 index 0000000..cfdddc4 --- /dev/null +++ b/Skills/GameInfo.cs @@ -0,0 +1,49 @@ +namespace Moserware.Skills +{ + /// + /// Parameters about the game for calculating the TrueSkill. + /// + public class GameInfo + { + private const double DefaultBeta = DefaultInitialMean/6.0; + private const double DefaultDrawProbability = 0.10; + private const double DefaultDynamicsFactor = DefaultInitialMean/300.0; + private const double DefaultInitialMean = 25.0; + private const double DefaultInitialStandardDeviation = DefaultInitialMean/3.0; + + public GameInfo(double initialMean, double initialStandardDeviation, double beta, double dynamicFactor, + double drawProbability) + { + InitialMean = initialMean; + InitialStandardDeviation = initialStandardDeviation; + Beta = beta; + DynamicsFactor = dynamicFactor; + DrawProbability = drawProbability; + } + + public double InitialMean { get; set; } + public double InitialStandardDeviation { get; set; } + public double Beta { get; set; } + + public double DynamicsFactor { get; set; } + public double DrawProbability { get; set; } + + public Rating DefaultRating + { + get { return new Rating(InitialMean, InitialStandardDeviation); } + } + + public static GameInfo DefaultGameInfo + { + get + { + // We return a fresh copy since we have public setters that can mutate state + return new GameInfo(DefaultInitialMean, + DefaultInitialStandardDeviation, + DefaultBeta, + DefaultDynamicsFactor, + DefaultDrawProbability); + } + } + } +} \ No newline at end of file diff --git a/Skills/Guard.cs b/Skills/Guard.cs new file mode 100644 index 0000000..deda637 --- /dev/null +++ b/Skills/Guard.cs @@ -0,0 +1,36 @@ +using System; + +namespace Moserware.Skills +{ + /// + /// Verifies argument contracts. + /// + /// These are used until .NET 4.0 ships with Contracts. For more information, + /// see http://www.moserware.com/2008/01/borrowing-ideas-from-3-interesting.html + internal static class Guard + { + public static void ArgumentNotNull(object value, string parameterName) + { + if (value == null) + { + throw new ArgumentNullException(parameterName); + } + } + + public static void ArgumentIsValidIndex(int index, int count, string parameterName) + { + if ((index < 0) || (index >= count)) + { + throw new ArgumentOutOfRangeException(parameterName); + } + } + + public static void ArgumentInRangeInclusive(double value, double min, double max, string parameterName) + { + if ((value < min) || (value > max)) + { + throw new ArgumentOutOfRangeException(parameterName); + } + } + } +} \ No newline at end of file diff --git a/Skills/ISupportPartialPlay.cs b/Skills/ISupportPartialPlay.cs new file mode 100644 index 0000000..9b781a6 --- /dev/null +++ b/Skills/ISupportPartialPlay.cs @@ -0,0 +1,13 @@ +namespace Moserware.Skills +{ + /// + /// Indicates support for allowing partial play (where a player only plays a part of the time). + /// + public interface ISupportPartialPlay + { + /// + /// Indicates the percent of the time the player should be weighted where 0.0 indicates the player didn't play and 1.0 indicates the player played 100% of the time. + /// + double PartialPlayPercentage { get; } + } +} \ No newline at end of file diff --git a/Skills/ISupportPartialUpdate.cs b/Skills/ISupportPartialUpdate.cs new file mode 100644 index 0000000..f202c61 --- /dev/null +++ b/Skills/ISupportPartialUpdate.cs @@ -0,0 +1,10 @@ +namespace Moserware.Skills +{ + public interface ISupportPartialUpdate + { + /// + /// Indicated how much of a skill update a player should receive where 0.0 represents no update and 1.0 represents 100% of the update. + /// + double PartialUpdatePercentage { get; } + } +} \ No newline at end of file diff --git a/Skills/License.txt b/Skills/License.txt new file mode 100644 index 0000000..f6c1a69 --- /dev/null +++ b/Skills/License.txt @@ -0,0 +1,37 @@ +The core ideas used in this Moserware.Skills project were described in +"TrueSkill (TM): A Bayesian Skill Rating System" available at +http://research.microsoft.com/apps/pubs/default.aspx?id=67956 + +The authors of the above paper have asked for a link to that article +as attribution in derived works. + +Some concepts were based on sample F# code that was written by Ralf Herbrich +Copyright (c) 2007, 2008 Microsoft Research Ltd, available at +http://blogs.technet.com/apg/archive/2008/06/16/trueskill-in-f.aspx + +All the C# code in this Moserware.Skills project is +Copyright (c) 2010 Jeff Moser + +Redistribution and use in source and binary forms, with or without modification, are +permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, this list + of conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY JEFF MOSER ``AS IS'' AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JEFF MOSER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation are those of the +authors and should not be interpreted as representing official policies, either expressed +or implied, of Jeff Moser. \ No newline at end of file diff --git a/Skills/Numerics/GaussianDistribution.cs b/Skills/Numerics/GaussianDistribution.cs new file mode 100644 index 0000000..d303b1b --- /dev/null +++ b/Skills/Numerics/GaussianDistribution.cs @@ -0,0 +1,240 @@ +using System; + +namespace Moserware.Numerics +{ + public class GaussianDistribution + { + // Intentionally, we're not going to derive related things, but set them all at once + // to get around some NaN issues + + private GaussianDistribution() + { + } + + public GaussianDistribution(double mean, double standardDeviation) + { + Mean = mean; + StandardDeviation = standardDeviation; + Variance = Square(StandardDeviation); + Precision = 1.0/Variance; + PrecisionMean = Precision*Mean; + } + + public double Mean { get; private set; } + public double StandardDeviation { get; private set; } + + // Precision and PrecisionMean are used because they make multiplying and dividing simpler + // (the the accompanying math paper for more details) + + public double Precision { get; private set; } + + public double PrecisionMean { get; private set; } + + private double Variance { get; set; } + + public double NormalizationConstant + { + get + { + // Great derivation of this is at http://www.astro.psu.edu/~mce/A451_2/A451/downloads/notes0.pdf + return 1.0/(Math.Sqrt(2*Math.PI)*StandardDeviation); + } + } + + public GaussianDistribution Clone() + { + var result = new GaussianDistribution(); + result.Mean = Mean; + result.StandardDeviation = StandardDeviation; + result.Variance = Variance; + result.Precision = Precision; + result.PrecisionMean = PrecisionMean; + return result; + } + + public static GaussianDistribution FromPrecisionMean(double precisionMean, double precision) + { + var gaussianDistribution = new GaussianDistribution(); + gaussianDistribution.Precision = precision; + gaussianDistribution.PrecisionMean = precisionMean; + gaussianDistribution.Variance = 1.0/precision; + gaussianDistribution.StandardDeviation = Math.Sqrt(gaussianDistribution.Variance); + gaussianDistribution.Mean = gaussianDistribution.PrecisionMean/gaussianDistribution.Precision; + return gaussianDistribution; + } + + // Although we could use equations from // For details, see http://www.tina-vision.net/tina-knoppix/tina-memo/2003-003.pdf + // for multiplication, the precision mean ones are easier to write :) + public static GaussianDistribution operator *(GaussianDistribution left, GaussianDistribution right) + { + return FromPrecisionMean(left.PrecisionMean + right.PrecisionMean, left.Precision + right.Precision); + } + + /// Computes the absolute difference between two Gaussians + public static double AbsoluteDifference(GaussianDistribution left, GaussianDistribution right) + { + return Math.Max( + Math.Abs(left.PrecisionMean - right.PrecisionMean), + Math.Sqrt(Math.Abs(left.Precision - right.Precision))); + } + + /// Computes the absolute difference between two Gaussians + public static double operator -(GaussianDistribution left, GaussianDistribution right) + { + return AbsoluteDifference(left, right); + } + + public static double LogProductNormalization(GaussianDistribution left, GaussianDistribution right) + { + if ((left.Precision == 0) || (right.Precision == 0)) + { + return 0; + } + + double varianceSum = left.Variance + right.Variance; + double meanDifference = left.Mean - right.Mean; + + double logSqrt2Pi = Math.Log(Math.Sqrt(2*Math.PI)); + return -logSqrt2Pi - (Math.Log(varianceSum)/2.0) - (Square(meanDifference)/(2.0*varianceSum)); + } + + + public static GaussianDistribution operator /(GaussianDistribution numerator, GaussianDistribution denominator) + { + return FromPrecisionMean(numerator.PrecisionMean - denominator.PrecisionMean, + numerator.Precision - denominator.Precision); + } + + public static double LogRatioNormalization(GaussianDistribution numerator, GaussianDistribution denominator) + { + if ((numerator.Precision == 0) || (denominator.Precision == 0)) + { + return 0; + } + + double varianceDifference = denominator.Variance - numerator.Variance; + double meanDifference = numerator.Mean - denominator.Mean; + + double logSqrt2Pi = Math.Log(Math.Sqrt(2*Math.PI)); + + return Math.Log(denominator.Variance) + logSqrt2Pi - Math.Log(varianceDifference)/2.0 + + Square(meanDifference)/(2*varianceDifference); + } + + private static double Square(double x) + { + return x*x; + } + + public static double At(double x) + { + return At(x, 0, 1); + } + + public static double At(double x, double mean, double standardDeviation) + { + // See http://mathworld.wolfram.com/NormalDistribution.html + // 1 -(x-mean)^2 / (2*stdDev^2) + // P(x) = ------------------- * e + // stdDev * sqrt(2*pi) + + double multiplier = 1.0/(standardDeviation*Math.Sqrt(2*Math.PI)); + double expPart = Math.Exp((-1.0*Math.Pow(x - mean, 2.0))/(2*(standardDeviation*standardDeviation))); + double result = multiplier*expPart; + return result; + } + + public static double CumulativeTo(double x, double mean, double standardDeviation) + { + double invsqrt2 = -0.707106781186547524400844362104; + double result = ErrorFunctionCumulativeTo(invsqrt2*x); + return 0.5*result; + } + + public static double CumulativeTo(double x) + { + return CumulativeTo(x, 0, 1); + } + + private static double ErrorFunctionCumulativeTo(double x) + { + // Derived from page 265 of Numerical Recipes 3rd Edition + double z = Math.Abs(x); + + double t = 2.0/(2.0 + z); + double ty = 4*t - 2; + + double[] coefficients = { + -1.3026537197817094, 6.4196979235649026e-1, + 1.9476473204185836e-2, -9.561514786808631e-3, -9.46595344482036e-4, + 3.66839497852761e-4, 4.2523324806907e-5, -2.0278578112534e-5, + -1.624290004647e-6, 1.303655835580e-6, 1.5626441722e-8, -8.5238095915e-8, + 6.529054439e-9, 5.059343495e-9, -9.91364156e-10, -2.27365122e-10, + 9.6467911e-11, 2.394038e-12, -6.886027e-12, 8.94487e-13, 3.13092e-13, + -1.12708e-13, 3.81e-16, 7.106e-15, -1.523e-15, -9.4e-17, 1.21e-16, -2.8e-17 + }; + + int ncof = coefficients.Length; + double d = 0.0; + double dd = 0.0; + + + for (int j = ncof - 1; j > 0; j--) + { + double tmp = d; + d = ty*d - dd + coefficients[j]; + dd = tmp; + } + + double ans = t*Math.Exp(-z*z + 0.5*(coefficients[0] + ty*d) - dd); + return x >= 0.0 ? ans : (2.0 - ans); + } + + + private static double InverseErrorFunctionCumulativeTo(double p) + { + // From page 265 of numerical recipes + + if (p >= 2.0) + { + return -100; + } + if (p <= 0.0) + { + return 100; + } + + double pp = (p < 1.0) ? p : 2 - p; + double t = Math.Sqrt(-2*Math.Log(pp/2.0)); // Initial guess + double x = -0.70711*((2.30753 + t*0.27061)/(1.0 + t*(0.99229 + t*0.04481)) - t); + + for (int j = 0; j < 2; j++) + { + double err = ErrorFunctionCumulativeTo(x) - pp; + x += err/(1.12837916709551257*Math.Exp(-(x*x)) - x*err); // Halley + } + + return p < 1.0 ? x : -x; + } + + public static double InverseCumulativeTo(double x, double mean, double standardDeviation) + { + // From numerical recipes, page 320 + return mean - Math.Sqrt(2)*standardDeviation*InverseErrorFunctionCumulativeTo(2*x); + } + + public static double InverseCumulativeTo(double x) + { + return InverseCumulativeTo(x, 0, 1); + } + + + public override string ToString() + { + // Debug help + return String.Format("μ={0:0.0000}, σ={1:0.0000}", + Mean, + StandardDeviation); + } + } +} \ No newline at end of file diff --git a/Skills/Numerics/Matrix.cs b/Skills/Numerics/Matrix.cs new file mode 100644 index 0000000..926065e --- /dev/null +++ b/Skills/Numerics/Matrix.cs @@ -0,0 +1,520 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Moserware.Numerics +{ + /// + /// Represents an MxN matrix with double precision values. + /// + internal class Matrix + { + protected double[][] _MatrixRowValues; + // Note: some properties like Determinant, Inverse, etc are properties instead + // of methods to make the syntax look nicer even though this sort of goes against + // Framework Design Guidelines that properties should be "cheap" since it could take + // a long time to compute these properties if the matrices are "big." + + protected Matrix() + { + } + + public Matrix(int rows, int columns, params double[] allRowValues) + { + Rows = rows; + Columns = columns; + + _MatrixRowValues = new double[rows][]; + + int currentIndex = 0; + for (int currentRow = 0; currentRow < Rows; currentRow++) + { + _MatrixRowValues[currentRow] = new double[Columns]; + + for (int currentColumn = 0; currentColumn < Columns; currentColumn++) + { + if ((allRowValues != null) && (currentIndex < allRowValues.Length)) + { + _MatrixRowValues[currentRow][currentColumn] = allRowValues[currentIndex++]; + } + } + } + } + + public Matrix(double[][] rowValues) + { + if (!rowValues.All(row => row.Length == rowValues[0].Length)) + { + throw new ArgumentException("All rows must be the same length!"); + } + + Rows = rowValues.Length; + Columns = rowValues[0].Length; + _MatrixRowValues = rowValues; + } + + protected Matrix(int rows, int columns, double[][] matrixRowValues) + { + Rows = rows; + Columns = columns; + _MatrixRowValues = matrixRowValues; + } + + public Matrix(int rows, int columns, IEnumerable> columnValues) + : this(rows, columns) + { + int columnIndex = 0; + + foreach (var currentColumn in columnValues) + { + int rowIndex = 0; + foreach (double currentColumnValue in currentColumn) + { + _MatrixRowValues[rowIndex++][columnIndex] = currentColumnValue; + } + columnIndex++; + } + } + + public int Rows { get; protected set; } + public int Columns { get; protected set; } + + public double this[int row, int column] + { + get { return _MatrixRowValues[row][column]; } + } + + public Matrix Transpose + { + get + { + // Just flip everything + var transposeMatrix = new double[Columns][]; + for (int currentRowTransposeMatrix = 0; + currentRowTransposeMatrix < Columns; + currentRowTransposeMatrix++) + { + var transposeMatrixCurrentRowColumnValues = new double[Rows]; + transposeMatrix[currentRowTransposeMatrix] = transposeMatrixCurrentRowColumnValues; + + for (int currentColumnTransposeMatrix = 0; + currentColumnTransposeMatrix < Rows; + currentColumnTransposeMatrix++) + { + transposeMatrixCurrentRowColumnValues[currentColumnTransposeMatrix] = + _MatrixRowValues[currentColumnTransposeMatrix][currentRowTransposeMatrix]; + } + } + + return new Matrix(Columns, Rows, transposeMatrix); + } + } + + private bool IsSquare + { + get { return (Rows == Columns) && Rows > 0; } + } + + public double Determinant + { + get + { + // Basic argument checking + if (!IsSquare) + { + throw new NotSupportedException("Matrix must be square!"); + } + + if (Rows == 1) + { + // Really happy path :) + return _MatrixRowValues[0][0]; + } + + if (Rows == 2) + { + // Happy path! + // Given: + // | a b | + // | c d | + // The determinant is ad - bc + double a = _MatrixRowValues[0][0]; + double b = _MatrixRowValues[0][1]; + double c = _MatrixRowValues[1][0]; + double d = _MatrixRowValues[1][1]; + return a*d - b*c; + } + + // I use the Laplace expansion here since it's straightforward to implement. + // It's O(n^2) and my implementation is especially poor performing, but the + // core idea is there. Perhaps I should replace it with a better algorithm + // later. + // See http://en.wikipedia.org/wiki/Laplace_expansion for details + + double result = 0.0; + + // I expand along the first row + for (int currentColumn = 0; currentColumn < Columns; currentColumn++) + { + double firstRowColValue = _MatrixRowValues[0][currentColumn]; + double cofactor = GetCofactor(0, currentColumn); + double itemToAdd = firstRowColValue*cofactor; + result += itemToAdd; + } + + return result; + } + } + + public Matrix Adjugate + { + get + { + if (!IsSquare) + { + throw new ArgumentException("Matrix must be square!"); + } + + // See http://en.wikipedia.org/wiki/Adjugate_matrix + if (Rows == 2) + { + // Happy path! + // Adjugate of: + // | a b | + // | c d | + // is + // | d -b | + // | -c a | + + double a = _MatrixRowValues[0][0]; + double b = _MatrixRowValues[0][1]; + double c = _MatrixRowValues[1][0]; + double d = _MatrixRowValues[1][1]; + + return new SquareMatrix(d, -b, + -c, a); + } + + // The idea is that it's the transpose of the cofactors + var result = new double[Columns][]; + + for (int currentColumn = 0; currentColumn < Columns; currentColumn++) + { + result[currentColumn] = new double[Rows]; + + for (int currentRow = 0; currentRow < Rows; currentRow++) + { + result[currentColumn][currentRow] = GetCofactor(currentRow, currentColumn); + } + } + + return new Matrix(result); + } + } + + public Matrix Inverse + { + get + { + if ((Rows == 1) && (Columns == 1)) + { + return new SquareMatrix(1.0/_MatrixRowValues[0][0]); + } + + // Take the simple approach: + // http://en.wikipedia.org/wiki/Cramer%27s_rule#Finding_inverse_matrix + return (1.0/Determinant)*Adjugate; + } + } + + public static Matrix operator *(double scalarValue, Matrix matrix) + { + int rows = matrix.Rows; + int columns = matrix.Columns; + var newValues = new double[rows][]; + + for (int currentRow = 0; currentRow < rows; currentRow++) + { + var newRowColumnValues = new double[columns]; + newValues[currentRow] = newRowColumnValues; + + for (int currentColumn = 0; currentColumn < columns; currentColumn++) + { + newRowColumnValues[currentColumn] = scalarValue*matrix._MatrixRowValues[currentRow][currentColumn]; + } + } + + return new Matrix(rows, columns, newValues); + } + + public static Matrix operator +(Matrix left, Matrix right) + { + if ((left.Rows != right.Rows) || (left.Columns != right.Columns)) + { + throw new ArgumentException("Matrices must be of the same size"); + } + + // simple addition of each item + + var resultMatrix = new double[left.Rows][]; + + for (int currentRow = 0; currentRow < left.Rows; currentRow++) + { + var rowColumnValues = new double[right.Columns]; + resultMatrix[currentRow] = rowColumnValues; + for (int currentColumn = 0; currentColumn < right.Columns; currentColumn++) + { + rowColumnValues[currentColumn] = left._MatrixRowValues[currentRow][currentColumn] + + + right._MatrixRowValues[currentRow][currentColumn]; + } + } + + return new Matrix(left.Rows, right.Columns, resultMatrix); + } + + public static Matrix operator *(Matrix left, Matrix right) + { + // Just your standard matrix multiplication. + // See http://en.wikipedia.org/wiki/Matrix_multiplication for details + + if (left.Columns != right.Rows) + { + throw new ArgumentException("The width of the left matrix must match the height of the right matrix", + "right"); + } + + int resultRows = left.Rows; + int resultColumns = right.Columns; + + var resultMatrix = new double[resultRows][]; + + for (int currentRow = 0; currentRow < resultRows; currentRow++) + { + resultMatrix[currentRow] = new double[resultColumns]; + + for (int currentColumn = 0; currentColumn < resultColumns; currentColumn++) + { + double productValue = 0; + + for (int vectorIndex = 0; vectorIndex < left.Columns; vectorIndex++) + { + double leftValue = left._MatrixRowValues[currentRow][vectorIndex]; + double rightValue = right._MatrixRowValues[vectorIndex][currentColumn]; + double vectorIndexProduct = leftValue*rightValue; + productValue += vectorIndexProduct; + } + + resultMatrix[currentRow][currentColumn] = productValue; + } + } + + return new Matrix(resultRows, resultColumns, resultMatrix); + } + + private Matrix GetMinorMatrix(int rowToRemove, int columnToRemove) + { + // See http://en.wikipedia.org/wiki/Minor_(linear_algebra) + + // I'm going to use a horribly naïve algorithm... because I can :) + var result = new double[Rows - 1][]; + int resultRow = 0; + + for (int currentRow = 0; currentRow < Rows; currentRow++) + { + if (currentRow == rowToRemove) + { + continue; + } + + result[resultRow] = new double[Columns - 1]; + + int resultColumn = 0; + + for (int currentColumn = 0; currentColumn < Columns; currentColumn++) + { + if (currentColumn == columnToRemove) + { + continue; + } + + result[resultRow][resultColumn] = _MatrixRowValues[currentRow][currentColumn]; + resultColumn++; + } + + resultRow++; + } + + return new Matrix(Rows - 1, Columns - 1, result); + } + + private double GetCofactor(int rowToRemove, int columnToRemove) + { + // See http://en.wikipedia.org/wiki/Cofactor_(linear_algebra) for details + // REVIEW: should things be reversed since I'm 0 indexed? + int sum = rowToRemove + columnToRemove; + bool isEven = (sum%2 == 0); + + if (isEven) + { + return GetMinorMatrix(rowToRemove, columnToRemove).Determinant; + } + else + { + return -1.0*GetMinorMatrix(rowToRemove, columnToRemove).Determinant; + } + } + + // Equality stuff + // See http://msdn.microsoft.com/en-us/library/ms173147.aspx + + public static bool operator ==(Matrix a, Matrix b) + { + // If both are null, or both are same instance, return true. + if (ReferenceEquals(a, b)) + { + return true; + } + + // If one is null, but not both, return false. + if (((object) a == null) || ((object) b == null)) + { + return false; + } + + if ((a.Rows != b.Rows) || (a.Columns != b.Columns)) + { + return false; + } + + const double errorTolerance = 0.0000000000001; + + for (int currentRow = 0; currentRow < a.Rows; currentRow++) + { + for (int currentColumn = 0; currentColumn < a.Columns; currentColumn++) + { + double delta = + Math.Abs(a._MatrixRowValues[currentRow][currentColumn] - + b._MatrixRowValues[currentRow][currentColumn]); + + if (delta > errorTolerance) + { + return false; + } + } + } + + return true; + } + + public static bool operator !=(Matrix a, Matrix b) + { + return !(a == b); + } + + public override int GetHashCode() + { + double result = Rows; + result += 2*Columns; + + unchecked + { + for (int currentRow = 0; currentRow < Rows; currentRow++) + { + bool eventRow = (currentRow%2) == 0; + double multiplier = eventRow ? 1.0 : 2.0; + + for (int currentColumn = 0; currentColumn < Columns; currentColumn++) + { + result += multiplier*_MatrixRowValues[currentRow][currentColumn]; + } + } + } + + // Ok, now convert that double to an int + byte[] resultBytes = BitConverter.GetBytes(result); + + var finalBytes = new byte[4]; + for (int i = 0; i < 4; i++) + { + finalBytes[i] = (byte) (resultBytes[i] ^ resultBytes[i + 4]); + } + + int hashCode = BitConverter.ToInt32(finalBytes, 0); + return hashCode; + } + + public override bool Equals(object obj) + { + var other = obj as Matrix; + if (other == null) + { + return base.Equals(obj); + } + + return this == other; + } + } + + internal class DiagonalMatrix : Matrix + { + public DiagonalMatrix(IList diagonalValues) + : base(diagonalValues.Count, diagonalValues.Count) + { + for (int i = 0; i < diagonalValues.Count; i++) + { + _MatrixRowValues[i][i] = diagonalValues[i]; + } + } + } + + internal class Vector : Matrix + { + public Vector(IList vectorValues) + : base(vectorValues.Count, 1, new IEnumerable[] {vectorValues}) + { + } + } + + internal class SquareMatrix : Matrix + { + public SquareMatrix(params double[] allValues) + { + Rows = (int) Math.Sqrt(allValues.Length); + Columns = Rows; + + int allValuesIndex = 0; + + _MatrixRowValues = new double[Rows][]; + for (int currentRow = 0; currentRow < Rows; currentRow++) + { + var currentRowValues = new double[Columns]; + _MatrixRowValues[currentRow] = currentRowValues; + + for (int currentColumn = 0; currentColumn < Columns; currentColumn++) + { + currentRowValues[currentColumn] = allValues[allValuesIndex++]; + } + } + } + } + + internal class IdentityMatrix : DiagonalMatrix + { + public IdentityMatrix(int rows) + : base(CreateDiagonal(rows)) + { + } + + private static double[] CreateDiagonal(int rows) + { + var result = new double[rows]; + for (int i = 0; i < rows; i++) + { + result[i] = 1.0; + } + + return result; + } + } +} \ No newline at end of file diff --git a/Skills/Numerics/Range.cs b/Skills/Numerics/Range.cs new file mode 100644 index 0000000..7d5b581 --- /dev/null +++ b/Skills/Numerics/Range.cs @@ -0,0 +1,49 @@ +using System; + +namespace Moserware.Skills.Numerics +{ + // The whole purpose of this class is to make the code for the SkillCalculator(s) + // look a little cleaner + + public abstract class Range where T : Range, new() + { + private static readonly T _Instance = new T(); + + protected Range(int min, int max) + { + if (min > max) + { + throw new ArgumentOutOfRangeException(); + } + + Min = min; + Max = max; + } + + public int Min { get; private set; } + public int Max { get; private set; } + protected abstract T Create(int min, int max); + + // REVIEW: It's probably bad form to have access statics via a derived class, but the syntax looks better :-) + + public static T Inclusive(int min, int max) + { + return _Instance.Create(min, max); + } + + public static T Exactly(int value) + { + return _Instance.Create(value, value); + } + + public static T AtLeast(int minimumValue) + { + return _Instance.Create(minimumValue, int.MaxValue); + } + + public bool IsInRange(int value) + { + return (Min <= value) && (value <= Max); + } + } +} \ No newline at end of file diff --git a/Skills/PairwiseComparison.cs b/Skills/PairwiseComparison.cs new file mode 100644 index 0000000..7848eb3 --- /dev/null +++ b/Skills/PairwiseComparison.cs @@ -0,0 +1,15 @@ +namespace Moserware.Skills +{ + /// + /// Represents a comparison between two players. + /// + /// + /// The actual values for the enum were chosen so that the also correspond to the multiplier for updates to means. + /// + public enum PairwiseComparison + { + Win = 1, + Draw = 0, + Lose = -1 + } +} \ No newline at end of file diff --git a/Skills/PartialPlay.cs b/Skills/PartialPlay.cs new file mode 100644 index 0000000..b24d08f --- /dev/null +++ b/Skills/PartialPlay.cs @@ -0,0 +1,26 @@ +namespace Moserware.Skills +{ + internal static class PartialPlay + { + public static double GetPartialPlayPercentage(object player) + { + // If the player doesn't support the interface, assume 1.0 == 100% + var partialPlay = player as ISupportPartialPlay; + if (partialPlay == null) + { + return 1.0; + } + + double partialPlayPercentage = partialPlay.PartialPlayPercentage; + + // HACK to get around bug near 0 + const double smallestPercentage = 0.0001; + if (partialPlayPercentage < smallestPercentage) + { + partialPlayPercentage = smallestPercentage; + } + + return partialPlayPercentage; + } + } +} \ No newline at end of file diff --git a/Skills/Player.cs b/Skills/Player.cs new file mode 100644 index 0000000..74bcad6 --- /dev/null +++ b/Skills/Player.cs @@ -0,0 +1,129 @@ +namespace Moserware.Skills +{ + /// + /// Represents a player who has a . + /// + public class Player : ISupportPartialPlay, ISupportPartialUpdate + { + private const double DefaultPartialPlayPercentage = 1.0; // = 100% play time + private const double DefaultPartialUpdatePercentage = 1.0; // = receive 100% update + private readonly T _Id; + private readonly double _PartialPlayPercentage; + + private readonly double _PartialUpdatePercentage; + + /// + /// Constructs a player. + /// + /// The identifier for the player, such as a name. + public Player(T id) + : this(id, DefaultPartialPlayPercentage, DefaultPartialUpdatePercentage) + { + } + + /// + /// Constructs a player. + /// + /// The identifier for the player, such as a name. + /// The weight percentage to give this player when calculating a new rank. + public Player(T id, double partialPlayPercentage) + : this(id, partialPlayPercentage, DefaultPartialUpdatePercentage) + { + } + + /// + /// Constructs a player. + /// + /// The identifier for the player, such as a name. + /// The weight percentage to give this player when calculating a new rank. + /// /// Indicated how much of a skill update a player should receive where 0 represents no update and 1.0 represents 100% of the update. + public Player(T id, double partialPlayPercentage, double partialUpdatePercentage) + { + // If they don't want to give a player an id, that's ok... + Guard.ArgumentInRangeInclusive(partialPlayPercentage, 0, 1.0, "partialPlayPercentage"); + Guard.ArgumentInRangeInclusive(partialUpdatePercentage, 0, 1.0, "partialUpdatePercentage"); + _Id = id; + _PartialPlayPercentage = partialPlayPercentage; + _PartialUpdatePercentage = partialUpdatePercentage; + } + + /// + /// The identifier for the player, such as a name. + /// + public T Id + { + get { return _Id; } + } + + #region ISupportPartialPlay Members + + /// + /// Indicates the percent of the time the player should be weighted where 0.0 indicates the player didn't play and 1.0 indicates the player played 100% of the time. + /// + public double PartialPlayPercentage + { + get { return _PartialPlayPercentage; } + } + + #endregion + + #region ISupportPartialUpdate Members + + /// + /// Indicated how much of a skill update a player should receive where 0.0 represents no update and 1.0 represents 100% of the update. + /// + public double PartialUpdatePercentage + { + get { return _PartialUpdatePercentage; } + } + + #endregion + + public override string ToString() + { + if (Id != null) + { + return Id.ToString(); + } + + return base.ToString(); + } + } + + /// + /// Represents a player who has a . + /// + public class Player : Player + { + /// + /// Constructs a player. + /// + /// The identifier for the player, such as a name. + public Player(object id) + : base(id) + { + } + + /// + /// Constructs a player. + /// + /// The identifier for the player, such as a name. + /// The weight percentage to give this player when calculating a new rank. + /// Indicated how much of a skill update a player should receive where 0 represents no update and 1.0 represents 100% of the update. + public Player(object id, double partialPlayPercentage) + : base(id, partialPlayPercentage) + { + } + + /// + /// Constructs a player. + /// + /// The identifier for the player, such as a name. + /// The weight percentage to give this player when calculating a new rank. + /// Indicated how much of a skill update a player should receive where 0 represents no update and 1.0 represents 100% of the update. + public Player(object id, double partialPlayPercentage, double partialUpdatePercentage) + : base(id, partialPlayPercentage, partialUpdatePercentage) + { + } + } +} \ No newline at end of file diff --git a/Skills/PlayersRange.cs b/Skills/PlayersRange.cs new file mode 100644 index 0000000..b3c2963 --- /dev/null +++ b/Skills/PlayersRange.cs @@ -0,0 +1,22 @@ +using Moserware.Skills.Numerics; + +namespace Moserware.Skills +{ + public class PlayersRange : Range + { + public PlayersRange() + : base(int.MinValue, int.MinValue) + { + } + + private PlayersRange(int min, int max) + : base(min, max) + { + } + + protected override PlayersRange Create(int min, int max) + { + return new PlayersRange(min, max); + } + } +} \ No newline at end of file diff --git a/Skills/Properties/AssemblyInfo.cs b/Skills/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..8d32e79 --- /dev/null +++ b/Skills/Properties/AssemblyInfo.cs @@ -0,0 +1,45 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. + +[assembly: AssemblyTitle("Moserware.Skills")] +[assembly: AssemblyDescription("Implementation of the TrueSkill algorithm.")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("Jeff Moser")] +[assembly: AssemblyProduct("TrueSkill Calculator")] +[assembly: AssemblyCopyright("Copyright © Jeff Moser 2010")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. + +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM + +[assembly: Guid("4326f9ed-f234-42ed-bee0-84f7757ab28f")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] + +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] + +#if DEBUG + +[assembly: InternalsVisibleTo("UnitTests")] +#endif \ No newline at end of file diff --git a/Skills/README.txt b/Skills/README.txt new file mode 100644 index 0000000..fcc9b11 --- /dev/null +++ b/Skills/README.txt @@ -0,0 +1,58 @@ +Hi there! + +Thanks for downloading this code and opening up this file. The goal of this +project is to provide an annotated reference implementation of Microsoft's +TrueSkill algorithm. + +I describe the philosophy and the buildup of the math involved in my blog post +"Computing Your Skill" available at moserware.com. + +In addition, there is a math paper that goes along with the blog post that explains +most of the more technical concepts. + +This project isn't intended to win performance tests, it's meant to be read +and understood. If you see ways to improve its clarity, please submit a patch. + +If you just want to use the TrueSkill algorithm, simply use the TrueSkillCalculator +class and enjoy. If you need examples, please look in the UnitTests\TrueSkill folder. + +If you want to understand the inner workings of the algorithm and implement it +yourself, look in the Skills\TrueSkill folder. There are three separate +implementations of the algorithm in increasing levels of difficulty: + + 1. TwoPlayerTrueSkillCalculator.cs is the easiest to follow and implement. It uses + the simple equations directly from the TrueSkill website. + 2. TwoTeamTrueSkillCalculator.cs is slightly more complicated than the two player + version and supports two teams that have at least one player each. It extends + the equations on the website and incorporates some things implied in the paper. + 3. FactorGraphTrueSkillCalculator.cs is a wholly different animal than the first two + and it is at least an order of magnitude more complex. It implements the complete + TrueSkill algorithm and builds up a "factor graph" composed of several layers. + Each layer is composed of "factors", "variables", and "messages" between the two. + + Work happens on the factor graph according to a "schedule" which can either be + a single step (e.g. sending a message from a factor to a variable) or a sequence of + steps (e.g. everything that happens in a "layer") or a loop where the schedule runs + until values start to stabilize (e.g. the bottom layer is approximated and runs until + it converges) + +TrueSkill is more general than the popular Elo algorithm. As a comparison, I implemented +the Elo algorithm using the both the bell curve (Gaussian) and curve that the FIDE chess +league uses (logistic curve). I specifically implemented them in a way to show how the +only difference among these Elo implementations is the curve. I also implemented the +"duelling" Elo calculator as implied in the paper. + +Everything else was implemented to support these classes. Note that a "player" can be an +arbitrary class. However, if that player class supports the "ISupportPartialPlay" or +"ISupportPartialUpdate" interfaces, you can add these extra parameters. The only calculator +that uses this info is the factor graph implementation. See those files for more details. + +I use this code personally to rank around 45 people, so it's important that it's accurate. +Please let me know if you find errors. Bug fix patches are strongly encouraged! Also, feel +free to fork the project for different language implementations. + +I'd love to hear from you via comments on the "Computing Your Skill" blog post. + +Have fun and enjoy! + +Jeff Moser \ No newline at end of file diff --git a/Skills/RankSorter.cs b/Skills/RankSorter.cs new file mode 100644 index 0000000..c3dc1f6 --- /dev/null +++ b/Skills/RankSorter.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Moserware.Skills +{ + /// + /// Helper class to sort ranks in non-decreasing order. + /// + internal static class RankSorter + { + /// + /// Performs an in-place sort of the in according to the in non-decreasing order. + /// + /// The types of items to sort. + /// The items to sort according to the order specified by . + /// The ranks for each item where 1 is first place. + public static void Sort(ref IEnumerable teams, ref int[] teamRanks) + { + Guard.ArgumentNotNull(teams, "teams"); + Guard.ArgumentNotNull(teamRanks, "teamRanks"); + + int lastObserverdRank = 0; + bool needToSort = false; + + foreach (int currentRank in teamRanks) + { + // We're expecting ranks to go up (e.g. 1, 2, 2, 3, ...) + // If it goes down, then we've got to sort it. + if (currentRank < lastObserverdRank) + { + needToSort = true; + break; + } + + lastObserverdRank = currentRank; + } + + if (!needToSort) + { + // Don't bother doing more work, it's already in a good order + return; + } + + // Get the existing items as an indexable list. + List itemsInList = teams.ToList(); + + // item -> rank + var itemToRank = new Dictionary(); + + for (int i = 0; i < itemsInList.Count; i++) + { + T currentItem = itemsInList[i]; + int currentItemRank = teamRanks[i]; + itemToRank[currentItem] = currentItemRank; + } + + // Now we need a place for our results... + var sortedItems = new T[teamRanks.Length]; + var sortedRanks = new int[teamRanks.Length]; + + // where are we in the result? + int currentIndex = 0; + + // Let LINQ-to-Objects to the actual sorting + foreach (var sortedKeyValuePair in itemToRank.OrderBy(pair => pair.Value)) + { + sortedItems[currentIndex] = sortedKeyValuePair.Key; + sortedRanks[currentIndex++] = sortedKeyValuePair.Value; + } + + // And we're done + teams = sortedItems; + teamRanks = sortedRanks; + } + } +} \ No newline at end of file diff --git a/Skills/Rating.cs b/Skills/Rating.cs new file mode 100644 index 0000000..429f51f --- /dev/null +++ b/Skills/Rating.cs @@ -0,0 +1,94 @@ +using System; +using Moserware.Numerics; + +namespace Moserware.Skills +{ + /// + /// Container for a player's rating. + /// + public class Rating + { + private const int ConservativeStandardDeviationMultiplier = 3; + private readonly double _ConservativeStandardDeviationMultiplier; + private readonly double _Mean; + private readonly double _StandardDeviation; + + /// + /// Constructs a rating. + /// + /// The statistical mean value of the rating (also known as μ). + /// The standard deviation of the rating (also known as σ). + public Rating(double mean, double standardDeviation) + : this(mean, standardDeviation, ConservativeStandardDeviationMultiplier) + { + } + + /// + /// Constructs a rating. + /// + /// The statistical mean value of the rating (also known as μ). + /// The standard deviation (the spread) of the rating (also known as σ). + /// The number of s to subtract from the to achieve a conservative rating. + public Rating(double mean, double standardDeviation, double conservativeStandardDeviationMultiplier) + { + _Mean = mean; + _StandardDeviation = standardDeviation; + _ConservativeStandardDeviationMultiplier = conservativeStandardDeviationMultiplier; + } + + /// + /// The statistical mean value of the rating (also known as μ). + /// + public double Mean + { + get { return _Mean; } + } + + /// + /// The standard deviation (the spread) of the rating. This is also known as σ. + /// + public double StandardDeviation + { + get { return _StandardDeviation; } + } + + /// + /// A conservative estimate of skill based on the mean and standard deviation. + /// + public double ConservativeRating + { + get { return _Mean - ConservativeStandardDeviationMultiplier*_StandardDeviation; } + } + + public static Rating GetPartialUpdate(Rating prior, Rating fullPosterior, double updatePercentage) + { + var priorGaussian = new GaussianDistribution(prior.Mean, prior.StandardDeviation); + var posteriorGaussian = new GaussianDistribution(fullPosterior.Mean, fullPosterior.StandardDeviation); + + // From a clarification email from Ralf Herbrich: + // "the idea is to compute a linear interpolation between the prior and posterior skills of each player + // ... in the canonical space of parameters" + + double precisionDifference = posteriorGaussian.Precision - priorGaussian.Precision; + double partialPrecisionDifference = updatePercentage*precisionDifference; + + double precisionMeanDifference = posteriorGaussian.PrecisionMean - priorGaussian.PrecisionMean; + double partialPrecisionMeanDifference = updatePercentage*precisionMeanDifference; + + GaussianDistribution partialPosteriorGaussion = GaussianDistribution.FromPrecisionMean( + priorGaussian.PrecisionMean + partialPrecisionMeanDifference, + priorGaussian.Precision + partialPrecisionDifference); + + return new Rating(partialPosteriorGaussion.Mean, partialPosteriorGaussion.StandardDeviation, + prior._ConservativeStandardDeviationMultiplier); + } + + public override string ToString() + { + // As a debug helper, display a localized rating: + return String.Format( + "μ={0:0.0000}, σ={1:0.0000}", + Mean, StandardDeviation); + } + } +} \ No newline at end of file diff --git a/Skills/SkillCalculator.cs b/Skills/SkillCalculator.cs new file mode 100644 index 0000000..6f70b6e --- /dev/null +++ b/Skills/SkillCalculator.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; + +namespace Moserware.Skills +{ + /// + /// Base class for all skill calculator implementations. + /// + public abstract class SkillCalculator + { + [Flags] + public enum SupportedOptions + { + None = 0x00, + PartialPlay = 0x01, + PartialUpdate = 0x02, + } + + private readonly SupportedOptions _SupportedOptions; + private readonly PlayersRange _PlayersPerTeamAllowed; + private readonly TeamsRange _TotalTeamsAllowed; + + protected SkillCalculator(SupportedOptions supportedOptions, TeamsRange totalTeamsAllowed, PlayersRange playerPerTeamAllowed) + { + _SupportedOptions = supportedOptions; + _TotalTeamsAllowed = totalTeamsAllowed; + _PlayersPerTeamAllowed = playerPerTeamAllowed; + } + + /// + /// Calculates new ratings based on the prior ratings and team ranks. + /// + /// The underlying type of the player. + /// Parameters for the game. + /// A mapping of team players and their ratings. + /// The ranks of the teams where 1 is first place. For a tie, repeat the number (e.g. 1, 2, 2) + /// All the players and their new ratings. + public abstract IDictionary CalculateNewRatings(GameInfo gameInfo, + IEnumerable + > + teams, + params int[] teamRanks); + + /// + /// Calculates the match quality as the likelihood of all teams drawing. + /// + /// The underlying type of the player. + /// Parameters for the game. + /// A mapping of team players and their ratings. + /// The quality of the match between the teams as a percentage (0% = bad, 100% = well matched). + public abstract double CalculateMatchQuality(GameInfo gameInfo, + IEnumerable> teams); + + public bool IsSupported(SupportedOptions option) + { + return (_SupportedOptions & option) == option; + } + + /// + /// Helper function to square the . + /// + /// * + protected static double Square(double value) + { + return value*value; + } + + protected void ValidateTeamCountAndPlayersCountPerTeam(IEnumerable> teams) + { + ValidateTeamCountAndPlayersCountPerTeam(teams, _TotalTeamsAllowed, _PlayersPerTeamAllowed); + } + + private static void ValidateTeamCountAndPlayersCountPerTeam( + IEnumerable> teams, TeamsRange totalTeams, PlayersRange playersPerTeam) + { + Guard.ArgumentNotNull(teams, "teams"); + int countOfTeams = 0; + foreach (var currentTeam in teams) + { + if (!playersPerTeam.IsInRange(currentTeam.Count)) + { + throw new ArgumentException(); + } + countOfTeams++; + } + + if (!totalTeams.IsInRange(countOfTeams)) + { + throw new ArgumentException(); + } + } + } +} \ No newline at end of file diff --git a/Skills/Skills.csproj b/Skills/Skills.csproj new file mode 100644 index 0000000..3cf270e --- /dev/null +++ b/Skills/Skills.csproj @@ -0,0 +1,116 @@ + + + + Debug + AnyCPU + 9.0.30729 + 2.0 + {15AD1345-984C-48ED-AF9A-2EAB44E5AA2B} + Library + Properties + Moserware.Skills + Moserware.Skills + v3.5 + 512 + + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + 3.5 + + + 3.5 + + + 3.5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Skills/Skills.suo b/Skills/Skills.suo new file mode 100644 index 0000000000000000000000000000000000000000..2d273c621e602b758643497c7dca24117bf864ee GIT binary patch literal 142336 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 + /// Helper class for working with a single team. + /// + public class Team + { + private readonly Dictionary _PlayerRatings = new Dictionary(); + + /// + /// Constructs a new team. + /// + public Team() + { + } + + /// + /// Constructs a and populates it with the specified . + /// + /// The player to add. + /// The rating of the . + public Team(TPlayer player, Rating rating) + { + AddPlayer(player, rating); + } + + /// + /// Adds the to the team. + /// + /// The player to add. + /// The rating of the . + /// The instance of the team (for chaining convenience). + public Team AddPlayer(TPlayer player, Rating rating) + { + _PlayerRatings[player] = rating; + return this; + } + + /// + /// Returns the as a simple dictionary. + /// + /// The as a simple dictionary. + public IDictionary AsDictionary() + { + return _PlayerRatings; + } + } + + /// + /// Helper class for working with a single team. + /// + public class Team : Team + { + /// + /// Constructs a new team. + /// + public Team() + { + } + + /// + /// Constructs a and populates it with the specified . + /// + /// The player to add. + /// The rating of the . + public Team(Player player, Rating rating) + : base(player, rating) + { + } + } + + /// + /// Helper class for working with multiple teams. + /// + public static class Teams + { + /// + /// Concatenates multiple teams into a list of teams. + /// + /// The teams to concatenate together. + /// A sequence of teams. + public static IEnumerable> Concat(params Team[] teams) + { + return teams.Select(t => t.AsDictionary()); + } + } +} \ No newline at end of file diff --git a/Skills/TeamsRange.cs b/Skills/TeamsRange.cs new file mode 100644 index 0000000..22c40e7 --- /dev/null +++ b/Skills/TeamsRange.cs @@ -0,0 +1,22 @@ +using Moserware.Skills.Numerics; + +namespace Moserware.Skills +{ + public class TeamsRange : Range + { + public TeamsRange() + : base(int.MinValue, int.MinValue) + { + } + + private TeamsRange(int min, int max) + : base(min, max) + { + } + + protected override TeamsRange Create(int min, int max) + { + return new TeamsRange(min, max); + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/DrawMargin.cs b/Skills/TrueSkill/DrawMargin.cs new file mode 100644 index 0000000..25335d5 --- /dev/null +++ b/Skills/TrueSkill/DrawMargin.cs @@ -0,0 +1,23 @@ +using System; +using Moserware.Numerics; + +namespace Moserware.Skills.TrueSkill +{ + internal static class DrawMargin + { + public static double GetDrawMarginFromDrawProbability(double drawProbability, double beta) + { + // Derived from TrueSkill technical report (MSR-TR-2006-80), page 6 + + // draw probability = 2 * CDF(margin/(sqrt(n1+n2)*beta)) -1 + + // implies + // + // margin = inversecdf((draw probability + 1)/2) * sqrt(n1+n2) * beta + // n1 and n2 are the number of players on each team + double margin = GaussianDistribution.InverseCumulativeTo(.5*(drawProbability + 1), 0, 1)*Math.Sqrt(1 + 1)* + beta; + return margin; + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/FactorGraphTrueSkillCalculator.cs b/Skills/TrueSkill/FactorGraphTrueSkillCalculator.cs new file mode 100644 index 0000000..8a291a0 --- /dev/null +++ b/Skills/TrueSkill/FactorGraphTrueSkillCalculator.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Moserware.Numerics; + +namespace Moserware.Skills.TrueSkill +{ + /// + /// Calculates TrueSkill using a full factor graph. + /// + internal class FactorGraphTrueSkillCalculator : SkillCalculator + { + public FactorGraphTrueSkillCalculator() + : base(SupportedOptions.PartialPlay | SupportedOptions.PartialUpdate, TeamsRange.AtLeast(2), PlayersRange.AtLeast(1)) + { + } + + public override IDictionary CalculateNewRatings(GameInfo gameInfo, + IEnumerable> teams, + params int[] teamRanks) + { + Guard.ArgumentNotNull(gameInfo, "gameInfo"); + ValidateTeamCountAndPlayersCountPerTeam(teams); + + RankSorter.Sort(ref teams, ref teamRanks); + + var factorGraph = new TrueSkillFactorGraph(gameInfo, teams, teamRanks); + factorGraph.BuildGraph(); + factorGraph.RunSchedule(); + + double probabilityOfOutcome = factorGraph.GetProbabilityOfRanking(); + + return factorGraph.GetUpdatedRatings(); + } + + + public override double CalculateMatchQuality(GameInfo gameInfo, + IEnumerable> teams) + { + // We need to create the A matrix which is the player team assigments. + List> teamAssignmentsList = teams.ToList(); + Matrix skillsMatrix = GetPlayerCovarianceMatrix(teamAssignmentsList); + Vector meanVector = GetPlayerMeansVector(teamAssignmentsList); + Matrix meanVectorTranspose = meanVector.Transpose; + + Matrix playerTeamAssignmentsMatrix = CreatePlayerTeamAssignmentMatrix(teamAssignmentsList, meanVector.Rows); + Matrix playerTeamAssignmentsMatrixTranspose = playerTeamAssignmentsMatrix.Transpose; + + double betaSquared = Square(gameInfo.Beta); + + Matrix start = meanVectorTranspose * playerTeamAssignmentsMatrix; + Matrix aTa = (betaSquared * playerTeamAssignmentsMatrixTranspose) * playerTeamAssignmentsMatrix; + Matrix aTSA = playerTeamAssignmentsMatrixTranspose * skillsMatrix * playerTeamAssignmentsMatrix; + Matrix middle = aTa + aTSA; + + Matrix middleInverse = middle.Inverse; + + Matrix end = playerTeamAssignmentsMatrixTranspose * meanVector; + + Matrix expPartMatrix = -0.5 * (start * middleInverse * end); + double expPart = expPartMatrix.Determinant; + + double sqrtPartNumerator = aTa.Determinant; + double sqrtPartDenominator = middle.Determinant; + double sqrtPart = sqrtPartNumerator / sqrtPartDenominator; + + double result = Math.Exp(expPart) * Math.Sqrt(sqrtPart); + + return result; + } + + private static Vector GetPlayerMeansVector( + IEnumerable> teamAssignmentsList) + { + // A simple vector of all the player means. + return new Vector(GetPlayerRatingValues(teamAssignmentsList, rating => rating.Mean)); + } + + private static Matrix GetPlayerCovarianceMatrix( + IEnumerable> teamAssignmentsList) + { + // This is a square matrix whose diagonal values represent the variance (square of standard deviation) of all + // players. + return + new DiagonalMatrix(GetPlayerRatingValues(teamAssignmentsList, rating => Square(rating.StandardDeviation))); + } + + // Helper function that gets a list of values for all player ratings + private static IList GetPlayerRatingValues( + IEnumerable> teamAssignmentsList, Func playerRatingFunction) + { + var playerRatingValues = new List(); + + foreach (var currentTeam in teamAssignmentsList) + { + foreach (Rating currentRating in currentTeam.Values) + { + playerRatingValues.Add(playerRatingFunction(currentRating)); + } + } + + return playerRatingValues; + } + + private static Matrix CreatePlayerTeamAssignmentMatrix( + IList> teamAssignmentsList, int totalPlayers) + { + // The team assignment matrix is often referred to as the "A" matrix. It's a matrix whose rows represent the players + // and the columns represent teams. At Matrix[row, column] represents that player[row] is on team[col] + // Positive values represent an assignment and a negative value means that we subtract the value of the next + // team since we're dealing with pairs. This means that this matrix always has teams - 1 columns. + // The only other tricky thing is that values represent the play percentage. + + // For example, consider a 3 team game where team1 is just player1, team 2 is player 2 and player 3, and + // team3 is just player 4. Furthermore, player 2 and player 3 on team 2 played 25% and 75% of the time + // (e.g. partial play), the A matrix would be: + + // A = this 4x2 matrix: + // | 1.00 0.00 | + // | -0.25 0.25 | + // | -0.75 0.75 | + // | 0.00 -1.00 | + + var playerAssignments = new List>(); + int totalPreviousPlayers = 0; + + for (int i = 0; i < teamAssignmentsList.Count - 1; i++) + { + IDictionary currentTeam = teamAssignmentsList[i]; + + // Need to add in 0's for all the previous players, since they're not + // on this team + var currentRowValues = new List(new double[totalPreviousPlayers]); + playerAssignments.Add(currentRowValues); + + foreach (var currentRating in currentTeam) + { + currentRowValues.Add(PartialPlay.GetPartialPlayPercentage(currentRating.Key)); + // indicates the player is on the team + totalPreviousPlayers++; + } + + IDictionary nextTeam = teamAssignmentsList[i + 1]; + foreach (var nextTeamPlayerPair in nextTeam) + { + // Add a -1 * playing time to represent the difference + currentRowValues.Add(-1 * PartialPlay.GetPartialPlayPercentage(nextTeamPlayerPair.Key)); + } + } + + var playerTeamAssignmentsMatrix = new Matrix(totalPlayers, teamAssignmentsList.Count - 1, playerAssignments); + + return playerTeamAssignmentsMatrix; + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Factors/GaussianFactor.cs b/Skills/TrueSkill/Factors/GaussianFactor.cs new file mode 100644 index 0000000..3417627 --- /dev/null +++ b/Skills/TrueSkill/Factors/GaussianFactor.cs @@ -0,0 +1,33 @@ +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; + +namespace Moserware.Skills.TrueSkill.Factors +{ + public abstract class GaussianFactor : Factor + { + protected GaussianFactor(string name) + : base(name) + { + } + + /// Sends the factor-graph message with and returns the log-normalization constant + protected override double SendMessage(Message message, + Variable variable) + { + GaussianDistribution marginal = variable.Value; + GaussianDistribution messageValue = message.Value; + double logZ = GaussianDistribution.LogProductNormalization(marginal, messageValue); + variable.Value = marginal*messageValue; + return logZ; + } + + public override Message CreateVariableToMessageBinding( + Variable variable) + { + return CreateVariableToMessageBinding(variable, + new Message( + GaussianDistribution.FromPrecisionMean(0, 0), + "message from {0} to {1}", this, variable)); + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Factors/GaussianGreaterThanFactor.cs b/Skills/TrueSkill/Factors/GaussianGreaterThanFactor.cs new file mode 100644 index 0000000..31c4d4a --- /dev/null +++ b/Skills/TrueSkill/Factors/GaussianGreaterThanFactor.cs @@ -0,0 +1,75 @@ +using System; +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; + +namespace Moserware.Skills.TrueSkill.Factors +{ + /// + /// Factor representing a team difference that has exceeded the draw margin. + /// + /// See the accompanying math paper for more details. + public class GaussianGreaterThanFactor : GaussianFactor + { + private readonly double _Epsilon; + + public GaussianGreaterThanFactor(double epsilon, Variable variable) + : base(String.Format("{0} > {1:0.000}", variable, epsilon)) + { + _Epsilon = epsilon; + CreateVariableToMessageBinding(variable); + } + + public override double LogNormalization + { + get + { + GaussianDistribution marginal = Variables[0].Value; + GaussianDistribution message = Messages[0].Value; + GaussianDistribution messageFromVariable = marginal/message; + return -GaussianDistribution.LogProductNormalization(messageFromVariable, message) + + + Math.Log( + GaussianDistribution.CumulativeTo((messageFromVariable.Mean - _Epsilon)/ + messageFromVariable.StandardDeviation)); + } + } + + protected override double UpdateMessage(Message message, + Variable variable) + { + GaussianDistribution oldMarginal = variable.Value.Clone(); + GaussianDistribution oldMessage = message.Value.Clone(); + GaussianDistribution messageFromVar = oldMarginal/oldMessage; + + double c = messageFromVar.Precision; + double d = messageFromVar.PrecisionMean; + + double sqrtC = Math.Sqrt(c); + + double dOnSqrtC = d/sqrtC; + + double epsilsonTimesSqrtC = _Epsilon*sqrtC; + d = messageFromVar.PrecisionMean; + + double denom = 1.0 - TruncatedGaussianCorrectionFunctions.WExceedsMargin(dOnSqrtC, epsilsonTimesSqrtC); + + double newPrecision = c/denom; + double newPrecisionMean = (d + + sqrtC* + TruncatedGaussianCorrectionFunctions.VExceedsMargin(dOnSqrtC, epsilsonTimesSqrtC))/ + denom; + + GaussianDistribution newMarginal = GaussianDistribution.FromPrecisionMean(newPrecisionMean, newPrecision); + + GaussianDistribution newMessage = oldMessage*newMarginal/oldMarginal; + + /// Update the message and marginal + message.Value = newMessage; + + variable.Value = newMarginal; + + /// Return the difference in the new marginal + return newMarginal - oldMarginal; + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Factors/GaussianLikelihoodFactor.cs b/Skills/TrueSkill/Factors/GaussianLikelihoodFactor.cs new file mode 100644 index 0000000..95d4a6d --- /dev/null +++ b/Skills/TrueSkill/Factors/GaussianLikelihoodFactor.cs @@ -0,0 +1,72 @@ +using System; +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; + +namespace Moserware.Skills.TrueSkill.Factors +{ + /// + /// Connects two variables and adds uncertainty. + /// + /// See the accompanying math paper for more details. + public class GaussianLikelihoodFactor : GaussianFactor + { + private readonly double _Precision; + + public GaussianLikelihoodFactor(double betaSquared, Variable variable1, + Variable variable2) + : base(String.Format("Likelihood of {0} going to {1}", variable2, variable1)) + { + _Precision = 1.0/betaSquared; + CreateVariableToMessageBinding(variable1); + CreateVariableToMessageBinding(variable2); + } + + public override double LogNormalization + { + get { return GaussianDistribution.LogRatioNormalization(Variables[0].Value, Messages[0].Value); } + } + + private double UpdateHelper(Message message1, Message message2, + Variable variable1, Variable variable2) + { + GaussianDistribution message1Value = message1.Value.Clone(); + GaussianDistribution message2Value = message2.Value.Clone(); + + GaussianDistribution marginal1 = variable1.Value.Clone(); + GaussianDistribution marginal2 = variable2.Value.Clone(); + + double a = _Precision/(_Precision + marginal2.Precision - message2Value.Precision); + + GaussianDistribution newMessage = GaussianDistribution.FromPrecisionMean( + a*(marginal2.PrecisionMean - message2Value.PrecisionMean), + a*(marginal2.Precision - message2Value.Precision)); + + GaussianDistribution oldMarginalWithoutMessage = marginal1/message1Value; + + GaussianDistribution newMarginal = oldMarginalWithoutMessage*newMessage; + + /// Update the message and marginal + + message1.Value = newMessage; + variable1.Value = newMarginal; + + /// Return the difference in the new marginal + return newMarginal - marginal1; + } + + public override double UpdateMessage(int messageIndex) + { + switch (messageIndex) + { + case 0: + return UpdateHelper(Messages[0], Messages[1], + Variables[0], Variables[1]); + case 1: + return UpdateHelper(Messages[1], Messages[0], + Variables[1], Variables[0]); + default: + throw new ArgumentOutOfRangeException(); + } + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Factors/GaussianPriorFactor.cs b/Skills/TrueSkill/Factors/GaussianPriorFactor.cs new file mode 100644 index 0000000..3921880 --- /dev/null +++ b/Skills/TrueSkill/Factors/GaussianPriorFactor.cs @@ -0,0 +1,39 @@ +using System; +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; + +namespace Moserware.Skills.TrueSkill.Factors +{ + /// + /// Supplies the factor graph with prior information. + /// + /// See the accompanying math paper for more details. + public class GaussianPriorFactor : GaussianFactor + { + private readonly GaussianDistribution _NewMessage; + + public GaussianPriorFactor(double mean, double variance, Variable variable) + : base(String.Format("Prior value going to {0}", variable)) + { + _NewMessage = new GaussianDistribution(mean, Math.Sqrt(variance)); + CreateVariableToMessageBinding(variable, + new Message( + GaussianDistribution.FromPrecisionMean(0, 0), "message from {0} to {1}", + this, variable)); + } + + protected override double UpdateMessage(Message message, + Variable variable) + { + GaussianDistribution oldMarginal = variable.Value.Clone(); + Message oldMessage = message; + GaussianDistribution newMarginal = + GaussianDistribution.FromPrecisionMean( + oldMarginal.PrecisionMean + _NewMessage.PrecisionMean - oldMessage.Value.PrecisionMean, + oldMarginal.Precision + _NewMessage.Precision - oldMessage.Value.Precision); + variable.Value = newMarginal; + message.Value = _NewMessage; + return oldMarginal - newMarginal; + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Factors/GaussianWeightedSumFactor.cs b/Skills/TrueSkill/Factors/GaussianWeightedSumFactor.cs new file mode 100644 index 0000000..b95d04b --- /dev/null +++ b/Skills/TrueSkill/Factors/GaussianWeightedSumFactor.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; + +namespace Moserware.Skills.TrueSkill.Factors +{ + /// + /// Factor that sums together multiple Gaussians. + /// + /// See the accompanying math paper for more details. + public class GaussianWeightedSumFactor : GaussianFactor + { + private readonly List _VariableIndexOrdersForWeights = new List(); + + // This following is used for convenience, for example, the first entry is [0, 1, 2] + // corresponding to v[0] = a1*v[1] + a2*v[2] + private readonly double[][] _Weights; + private readonly double[][] _WeightsSquared; + + public GaussianWeightedSumFactor(Variable sumVariable, + Variable[] variablesToSum) + : this(sumVariable, + variablesToSum, + variablesToSum.Select(v => 1.0).ToArray()) // By default, set the weight to 1.0 + { + } + + public GaussianWeightedSumFactor(Variable sumVariable, + Variable[] variablesToSum, double[] variableWeights) + : base(CreateName(sumVariable, variablesToSum, variableWeights)) + { + _Weights = new double[variableWeights.Length + 1][]; + _WeightsSquared = new double[_Weights.Length][]; + + // The first weights are a straightforward copy + // v_0 = a_1*v_1 + a_2*v_2 + ... + a_n * v_n + _Weights[0] = new double[variableWeights.Length]; + Array.Copy(variableWeights, _Weights[0], variableWeights.Length); + _WeightsSquared[0] = _Weights[0].Select(w => w*w).ToArray(); + + // 0..n-1 + _VariableIndexOrdersForWeights.Add(Enumerable.Range(0, 1 + variablesToSum.Length).ToArray()); + + + // The rest move the variables around and divide out the constant. + // For example: + // v_1 = (-a_2 / a_1) * v_2 + (-a3/a1) * v_3 + ... + (1.0 / a_1) * v_0 + // By convention, we'll put the v_0 term at the end + + for (int weightsIndex = 1; weightsIndex < _Weights.Length; weightsIndex++) + { + var currentWeights = new double[variableWeights.Length]; + _Weights[weightsIndex] = currentWeights; + + var variableIndices = new int[variableWeights.Length + 1]; + variableIndices[0] = weightsIndex; + + var currentWeightsSquared = new double[variableWeights.Length]; + _WeightsSquared[weightsIndex] = currentWeightsSquared; + + // keep a single variable to keep track of where we are in the array. + // This is helpful since we skip over one of the spots + int currentDestinationWeightIndex = 0; + + for (int currentWeightSourceIndex = 0; + currentWeightSourceIndex < variableWeights.Length; + currentWeightSourceIndex++) + { + // TODO: get this test to be right + if (currentWeightSourceIndex == (weightsIndex - 1)) + { + continue; + } + + double currentWeight = (-variableWeights[currentWeightSourceIndex]/variableWeights[weightsIndex - 1]); + + if (variableWeights[weightsIndex - 1] == 0) + { + // HACK: Getting around division by zero + currentWeight = 0; + } + + currentWeights[currentDestinationWeightIndex] = currentWeight; + currentWeightsSquared[currentDestinationWeightIndex] = currentWeight*currentWeight; + + variableIndices[currentDestinationWeightIndex + 1] = currentWeightSourceIndex + 1; + currentDestinationWeightIndex++; + } + + // And the final one + double finalWeight = 1.0/variableWeights[weightsIndex - 1]; + + if (variableWeights[weightsIndex - 1] == 0) + { + // HACK: Getting around division by zero + finalWeight = 0; + } + currentWeights[currentDestinationWeightIndex] = finalWeight; + currentWeightsSquared[currentDestinationWeightIndex] = finalWeight*finalWeight; + variableIndices[variableIndices.Length - 1] = 0; + _VariableIndexOrdersForWeights.Add(variableIndices); + } + + CreateVariableToMessageBinding(sumVariable); + + foreach (var currentVariable in variablesToSum) + { + CreateVariableToMessageBinding(currentVariable); + } + } + + public override double LogNormalization + { + get + { + ReadOnlyCollection> vars = Variables; + ReadOnlyCollection> messages = Messages; + + double result = 0.0; + + // We start at 1 since offset 0 has the sum + for (int i = 1; i < vars.Count; i++) + { + result += GaussianDistribution.LogRatioNormalization(vars[i].Value, messages[i].Value); + } + + return result; + } + } + + private double UpdateHelper(double[] weights, double[] weightsSquared, + IList> messages, + IList> variables) + { + // Potentially look at http://mathworld.wolfram.com/NormalSumDistribution.html for clues as + // to what it's doing + + GaussianDistribution message0 = messages[0].Value.Clone(); + GaussianDistribution marginal0 = variables[0].Value.Clone(); + + // The math works out so that 1/newPrecision = sum of a_i^2 /marginalsWithoutMessages[i] + double inverseOfNewPrecisionSum = 0.0; + double anotherInverseOfNewPrecisionSum = 0.0; + double weightedMeanSum = 0.0; + double anotherWeightedMeanSum = 0.0; + + for (int i = 0; i < weightsSquared.Length; i++) + { + // These flow directly from the paper + + inverseOfNewPrecisionSum += weightsSquared[i]/ + (variables[i + 1].Value.Precision - messages[i + 1].Value.Precision); + + GaussianDistribution diff = (variables[i + 1].Value/messages[i + 1].Value); + anotherInverseOfNewPrecisionSum += weightsSquared[i]/diff.Precision; + + weightedMeanSum += weights[i] + * + (variables[i + 1].Value.PrecisionMean - messages[i + 1].Value.PrecisionMean) + / + (variables[i + 1].Value.Precision - messages[i + 1].Value.Precision); + + anotherWeightedMeanSum += weights[i]*diff.PrecisionMean/diff.Precision; + } + + double newPrecision = 1.0/inverseOfNewPrecisionSum; + double anotherNewPrecision = 1.0/anotherInverseOfNewPrecisionSum; + + double newPrecisionMean = newPrecision*weightedMeanSum; + double anotherNewPrecisionMean = anotherNewPrecision*anotherWeightedMeanSum; + + GaussianDistribution newMessage = GaussianDistribution.FromPrecisionMean(newPrecisionMean, newPrecision); + GaussianDistribution oldMarginalWithoutMessage = marginal0/message0; + + GaussianDistribution newMarginal = oldMarginalWithoutMessage*newMessage; + + /// Update the message and marginal + + messages[0].Value = newMessage; + variables[0].Value = newMarginal; + + /// Return the difference in the new marginal + return newMarginal - marginal0; + } + + public override double UpdateMessage(int messageIndex) + { + ReadOnlyCollection> allMessages = Messages; + ReadOnlyCollection> allVariables = Variables; + + Guard.ArgumentIsValidIndex(messageIndex, allMessages.Count, "messageIndex"); + + var updatedMessages = new List>(); + var updatedVariables = new List>(); + + int[] indicesToUse = _VariableIndexOrdersForWeights[messageIndex]; + + // The tricky part here is that we have to put the messages and variables in the same + // order as the weights. Thankfully, the weights and messages share the same index numbers, + // so we just need to make sure they're consistent + for (int i = 0; i < allMessages.Count; i++) + { + updatedMessages.Add(allMessages[indicesToUse[i]]); + updatedVariables.Add(allVariables[indicesToUse[i]]); + } + + return UpdateHelper(_Weights[messageIndex], _WeightsSquared[messageIndex], updatedMessages, updatedVariables); + } + + private static string CreateName(Variable sumVariable, + IList> variablesToSum, double[] weights) + { + var sb = new StringBuilder(); + sb.Append(sumVariable.ToString()); + sb.Append(" = "); + for (int i = 0; i < variablesToSum.Count; i++) + { + bool isFirst = (i == 0); + + if (isFirst && (weights[i] < 0)) + { + sb.Append("-"); + } + + sb.Append(Math.Abs(weights[i]).ToString("0.00")); + sb.Append("*["); + sb.Append(variablesToSum[i]); + sb.Append("]"); + + bool isLast = (i == variablesToSum.Count - 1); + + if (!isLast) + { + if (weights[i + 1] >= 0) + { + sb.Append(" + "); + } + else + { + sb.Append(" - "); + } + } + } + + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Factors/GaussianWithinFactor.cs b/Skills/TrueSkill/Factors/GaussianWithinFactor.cs new file mode 100644 index 0000000..71113b7 --- /dev/null +++ b/Skills/TrueSkill/Factors/GaussianWithinFactor.cs @@ -0,0 +1,73 @@ +using System; +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; + +namespace Moserware.Skills.TrueSkill.Factors +{ + /// + /// Factor representing a team difference that has not exceeded the draw margin. + /// + /// See the accompanying math paper for more details. + public class GaussianWithinFactor : GaussianFactor + { + private readonly double _Epsilon; + + public GaussianWithinFactor(double epsilon, Variable variable) + : base(String.Format("{0} <= {1:0.000}", variable, epsilon)) + { + _Epsilon = epsilon; + CreateVariableToMessageBinding(variable); + } + + public override double LogNormalization + { + get + { + GaussianDistribution marginal = Variables[0].Value; + GaussianDistribution message = Messages[0].Value; + GaussianDistribution messageFromVariable = marginal/message; + double mean = messageFromVariable.Mean; + double std = messageFromVariable.StandardDeviation; + double z = GaussianDistribution.CumulativeTo((_Epsilon - mean)/std) + - + GaussianDistribution.CumulativeTo((-_Epsilon - mean)/std); + + return -GaussianDistribution.LogProductNormalization(messageFromVariable, message) + Math.Log(z); + } + } + + protected override double UpdateMessage(Message message, + Variable variable) + { + GaussianDistribution oldMarginal = variable.Value.Clone(); + GaussianDistribution oldMessage = message.Value.Clone(); + GaussianDistribution messageFromVariable = oldMarginal/oldMessage; + + double c = messageFromVariable.Precision; + double d = messageFromVariable.PrecisionMean; + + double sqrtC = Math.Sqrt(c); + double dOnSqrtC = d/sqrtC; + + double epsilonTimesSqrtC = _Epsilon*sqrtC; + d = messageFromVariable.PrecisionMean; + + double denominator = 1.0 - TruncatedGaussianCorrectionFunctions.WWithinMargin(dOnSqrtC, epsilonTimesSqrtC); + double newPrecision = c/denominator; + double newPrecisionMean = (d + + sqrtC* + TruncatedGaussianCorrectionFunctions.VWithinMargin(dOnSqrtC, epsilonTimesSqrtC))/ + denominator; + + GaussianDistribution newMarginal = GaussianDistribution.FromPrecisionMean(newPrecisionMean, newPrecision); + GaussianDistribution newMessage = oldMessage*newMarginal/oldMarginal; + + /// Update the message and marginal + message.Value = newMessage; + variable.Value = newMarginal; + + /// Return the difference in the new marginal + return newMarginal - oldMarginal; + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Layers/IteratedTeamDifferencesInnerLayer.cs b/Skills/TrueSkill/Layers/IteratedTeamDifferencesInnerLayer.cs new file mode 100644 index 0000000..94da9ba --- /dev/null +++ b/Skills/TrueSkill/Layers/IteratedTeamDifferencesInnerLayer.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; +using Moserware.Skills.TrueSkill.Factors; + +namespace Moserware.Skills.TrueSkill.Layers +{ + // The whole purpose of this is to do a loop on the bottom + internal class IteratedTeamDifferencesInnerLayer : + TrueSkillFactorGraphLayer + , GaussianWeightedSumFactor, Variable> + { + private readonly TeamDifferencesComparisonLayer _TeamDifferencesComparisonLayer; + + private readonly TeamPerformancesToTeamPerformanceDifferencesLayer + _TeamPerformancesToTeamPerformanceDifferencesLayer; + + public IteratedTeamDifferencesInnerLayer(TrueSkillFactorGraph parentGraph, + TeamPerformancesToTeamPerformanceDifferencesLayer + teamPerformancesToPerformanceDifferences, + TeamDifferencesComparisonLayer teamDifferencesComparisonLayer) + : base(parentGraph) + { + _TeamPerformancesToTeamPerformanceDifferencesLayer = teamPerformancesToPerformanceDifferences; + _TeamDifferencesComparisonLayer = teamDifferencesComparisonLayer; + } + + public override IEnumerable> UntypedFactors + { + get + { + return + _TeamPerformancesToTeamPerformanceDifferencesLayer.UntypedFactors.Concat( + _TeamDifferencesComparisonLayer.UntypedFactors); + } + } + + public override void BuildLayer() + { + _TeamPerformancesToTeamPerformanceDifferencesLayer.SetRawInputVariablesGroups(InputVariablesGroups); + _TeamPerformancesToTeamPerformanceDifferencesLayer.BuildLayer(); + + _TeamDifferencesComparisonLayer.SetRawInputVariablesGroups( + _TeamPerformancesToTeamPerformanceDifferencesLayer.GetRawOutputVariablesGroups()); + _TeamDifferencesComparisonLayer.BuildLayer(); + } + + public override Schedule CreatePriorSchedule() + { + Schedule loop = null; + + switch (InputVariablesGroups.Count) + { + case 0: + case 1: + throw new InvalidOperationException(); + case 2: + loop = CreateTwoTeamInnerPriorLoopSchedule(); + break; + default: + loop = CreateMultipleTeamInnerPriorLoopSchedule(); + break; + } + + // When dealing with differences, there are always (n-1) differences, so add in the 1 + int totalTeamDifferences = _TeamPerformancesToTeamPerformanceDifferencesLayer.LocalFactors.Count; + int totalTeams = totalTeamDifferences + 1; + + var innerSchedule = new ScheduleSequence( + "inner schedule", + new[] + { + loop, + new ScheduleStep( + "teamPerformanceToPerformanceDifferenceFactors[0] @ 1", + _TeamPerformancesToTeamPerformanceDifferencesLayer.LocalFactors[0], 1), + new ScheduleStep( + String.Format("teamPerformanceToPerformanceDifferenceFactors[teamTeamDifferences = {0} - 1] @ 2", + totalTeamDifferences), + _TeamPerformancesToTeamPerformanceDifferencesLayer.LocalFactors[totalTeamDifferences - 1], 2) + } + ); + + return innerSchedule; + } + + private Schedule CreateTwoTeamInnerPriorLoopSchedule() + { + return ScheduleSequence( + new[] + { + new ScheduleStep( + "send team perf to perf differences", + _TeamPerformancesToTeamPerformanceDifferencesLayer.LocalFactors[0], + 0), + new ScheduleStep( + "send to greater than or within factor", + _TeamDifferencesComparisonLayer.LocalFactors[0], + 0) + }, + "loop of just two teams inner sequence"); + } + + private Schedule CreateMultipleTeamInnerPriorLoopSchedule() + { + int totalTeamDifferences = _TeamPerformancesToTeamPerformanceDifferencesLayer.LocalFactors.Count; + + var forwardScheduleList = new List>(); + + for (int i = 0; i < totalTeamDifferences - 1; i++) + { + Schedule currentForwardSchedulePiece = + ScheduleSequence( + new Schedule[] + { + new ScheduleStep( + String.Format("team perf to perf diff {0}", + i), + _TeamPerformancesToTeamPerformanceDifferencesLayer.LocalFactors[i], 0), + new ScheduleStep( + String.Format("greater than or within result factor {0}", + i), + _TeamDifferencesComparisonLayer.LocalFactors[i], + 0), + new ScheduleStep( + String.Format("team perf to perf diff factors [{0}], 2", + i), + _TeamPerformancesToTeamPerformanceDifferencesLayer.LocalFactors[i], 2) + }, "current forward schedule piece {0}", i); + + forwardScheduleList.Add(currentForwardSchedulePiece); + } + + var forwardSchedule = + new ScheduleSequence( + "forward schedule", + forwardScheduleList); + + var backwardScheduleList = new List>(); + + for (int i = 0; i < totalTeamDifferences - 1; i++) + { + var currentBackwardSchedulePiece = new ScheduleSequence( + "current backward schedule piece", + new Schedule[] + { + new ScheduleStep( + String.Format("teamPerformanceToPerformanceDifferenceFactors[totalTeamDifferences - 1 - {0}] @ 0", + i), + _TeamPerformancesToTeamPerformanceDifferencesLayer.LocalFactors[ + totalTeamDifferences - 1 - i], 0), + new ScheduleStep( + String.Format("greaterThanOrWithinResultFactors[totalTeamDifferences - 1 - {0}] @ 0", + i), + _TeamDifferencesComparisonLayer.LocalFactors[totalTeamDifferences - 1 - i], 0), + new ScheduleStep( + String.Format("teamPerformanceToPerformanceDifferenceFactors[totalTeamDifferences - 1 - {0}] @ 1", + i), + _TeamPerformancesToTeamPerformanceDifferencesLayer.LocalFactors[ + totalTeamDifferences - 1 - i], 1) + } + ); + backwardScheduleList.Add(currentBackwardSchedulePiece); + } + + var backwardSchedule = + new ScheduleSequence( + "backward schedule", + backwardScheduleList); + + var forwardBackwardScheduleToLoop = + new ScheduleSequence( + "forward Backward Schedule To Loop", + new Schedule[] + { + forwardSchedule, backwardSchedule + }); + + const double initialMaxDelta = 0.0001; + + var loop = new ScheduleLoop( + String.Format("loop with max delta of {0}", + initialMaxDelta), + forwardBackwardScheduleToLoop, + initialMaxDelta); + + return loop; + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Layers/PlayerPerformancesToTeamPerformancesLayer.cs b/Skills/TrueSkill/Layers/PlayerPerformancesToTeamPerformancesLayer.cs new file mode 100644 index 0000000..2aae362 --- /dev/null +++ b/Skills/TrueSkill/Layers/PlayerPerformancesToTeamPerformancesLayer.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; +using Moserware.Skills.TrueSkill.Factors; + +namespace Moserware.Skills.TrueSkill.Layers +{ + internal class PlayerPerformancesToTeamPerformancesLayer : + TrueSkillFactorGraphLayer + , GaussianWeightedSumFactor, + Variable> + { + public PlayerPerformancesToTeamPerformancesLayer(TrueSkillFactorGraph parentGraph) + : base(parentGraph) + { + } + + public override void BuildLayer() + { + foreach (var currentTeam in InputVariablesGroups) + { + Variable teamPerformance = CreateOutputVariable(currentTeam); + AddLayerFactor(CreatePlayerToTeamSumFactor(currentTeam, teamPerformance)); + + // REVIEW: Does it make sense to have groups of one? + OutputVariablesGroups.Add(new[] {teamPerformance}); + } + } + + public override Schedule CreatePriorSchedule() + { + return ScheduleSequence( + from weightedSumFactor in LocalFactors + select new ScheduleStep("Perf to Team Perf Step", weightedSumFactor, 0), + "all player perf to team perf schedule"); + } + + protected GaussianWeightedSumFactor CreatePlayerToTeamSumFactor( + IList> teamMembers, Variable sumVariable) + { + return new GaussianWeightedSumFactor(sumVariable, teamMembers.ToArray(), + teamMembers.Select(v => PartialPlay.GetPartialPlayPercentage(v.Key)). + ToArray()); + } + + public override Schedule CreatePosteriorSchedule() + { + return ScheduleSequence(from currentFactor in LocalFactors + from currentIteration in + Enumerable.Range(1, currentFactor.NumberOfMessages - 1) + select new ScheduleStep( + "team sum perf @" + currentIteration, + currentFactor, + currentIteration), + "all of the team's sum iterations"); + } + + private Variable CreateOutputVariable( + IList> team) + { + string teamMemberNames = String.Join(", ", team.Select(teamMember => teamMember.Key.ToString()).ToArray()); + + return ParentFactorGraph.VariableFactory.CreateBasicVariable("Team[{0}]'s performance", teamMemberNames); + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Layers/PlayerPriorValuesToSkillsLayer.cs b/Skills/TrueSkill/Layers/PlayerPriorValuesToSkillsLayer.cs new file mode 100644 index 0000000..cec4c27 --- /dev/null +++ b/Skills/TrueSkill/Layers/PlayerPriorValuesToSkillsLayer.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; +using Moserware.Skills.TrueSkill.Factors; + +namespace Moserware.Skills.TrueSkill.Layers +{ + // We intentionally have no Posterior schedule since the only purpose here is to + internal class PlayerPriorValuesToSkillsLayer : + TrueSkillFactorGraphLayer + , GaussianPriorFactor, + KeyedVariable> + { + private readonly IEnumerable> _Teams; + + public PlayerPriorValuesToSkillsLayer(TrueSkillFactorGraph parentGraph, + IEnumerable> teams) + : base(parentGraph) + { + _Teams = teams; + } + + public override void BuildLayer() + { + foreach (var currentTeam in _Teams) + { + var currentTeamSkills = new List>(); + + foreach (var currentTeamPlayer in currentTeam) + { + KeyedVariable playerSkill = + CreateSkillOutputVariable(currentTeamPlayer.Key); + AddLayerFactor(CreatePriorFactor(currentTeamPlayer.Key, currentTeamPlayer.Value, playerSkill)); + currentTeamSkills.Add(playerSkill); + } + + OutputVariablesGroups.Add(currentTeamSkills); + } + } + + public override Schedule CreatePriorSchedule() + { + return ScheduleSequence( + from prior in LocalFactors + select new ScheduleStep("Prior to Skill Step", prior, 0), + "All priors"); + } + + private GaussianPriorFactor CreatePriorFactor(TPlayer player, Rating priorRating, + Variable skillsVariable) + { + return new GaussianPriorFactor(priorRating.Mean, + Square(priorRating.StandardDeviation) + + Square(ParentFactorGraph.GameInfo.DynamicsFactor), skillsVariable); + } + + private KeyedVariable CreateSkillOutputVariable(TPlayer key) + { + return ParentFactorGraph.VariableFactory.CreateKeyedVariable(key, "{0}'s skill", key); + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Layers/PlayerSkillsToPerformancesLayer.cs b/Skills/TrueSkill/Layers/PlayerSkillsToPerformancesLayer.cs new file mode 100644 index 0000000..1899092 --- /dev/null +++ b/Skills/TrueSkill/Layers/PlayerSkillsToPerformancesLayer.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Linq; +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; +using Moserware.Skills.TrueSkill.Factors; + +namespace Moserware.Skills.TrueSkill.Layers +{ + internal class PlayerSkillsToPerformancesLayer : + TrueSkillFactorGraphLayer + , GaussianLikelihoodFactor, + KeyedVariable> + { + public PlayerSkillsToPerformancesLayer(TrueSkillFactorGraph parentGraph) + : base(parentGraph) + { + } + + public override void BuildLayer() + { + foreach (var currentTeam in InputVariablesGroups) + { + var currentTeamPlayerPerformances = new List>(); + + foreach (var playerSkillVariable in currentTeam) + { + KeyedVariable playerPerformance = + CreateOutputVariable(playerSkillVariable.Key); + AddLayerFactor(CreateLikelihood(playerSkillVariable, playerPerformance)); + currentTeamPlayerPerformances.Add(playerPerformance); + } + + OutputVariablesGroups.Add(currentTeamPlayerPerformances); + } + } + + private GaussianLikelihoodFactor CreateLikelihood(KeyedVariable playerSkill, + KeyedVariable playerPerformance) + { + return new GaussianLikelihoodFactor(Square(ParentFactorGraph.GameInfo.Beta), playerPerformance, playerSkill); + } + + private KeyedVariable CreateOutputVariable(TPlayer key) + { + return ParentFactorGraph.VariableFactory.CreateKeyedVariable(key, "{0}'s performance", key); + } + + public override Schedule CreatePriorSchedule() + { + return ScheduleSequence( + from likelihood in LocalFactors + select new ScheduleStep("Skill to Perf step", likelihood, 0), + "All skill to performance sending"); + } + + public override Schedule CreatePosteriorSchedule() + { + return ScheduleSequence( + from likelihood in LocalFactors + select new ScheduleStep("name", likelihood, 1), + "All skill to performance sending"); + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Layers/TeamDifferencesComparisonLayer.cs b/Skills/TrueSkill/Layers/TeamDifferencesComparisonLayer.cs new file mode 100644 index 0000000..748aec0 --- /dev/null +++ b/Skills/TrueSkill/Layers/TeamDifferencesComparisonLayer.cs @@ -0,0 +1,38 @@ +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; +using Moserware.Skills.TrueSkill.Factors; + +namespace Moserware.Skills.TrueSkill.Layers +{ + internal class TeamDifferencesComparisonLayer : + TrueSkillFactorGraphLayer + , GaussianFactor, DefaultVariable> + { + private readonly double _Epsilon; + private readonly int[] _TeamRanks; + + public TeamDifferencesComparisonLayer(TrueSkillFactorGraph parentGraph, int[] teamRanks) + : base(parentGraph) + { + _TeamRanks = teamRanks; + GameInfo gameInfo = ParentFactorGraph.GameInfo; + _Epsilon = DrawMargin.GetDrawMarginFromDrawProbability(gameInfo.DrawProbability, gameInfo.Beta); + } + + public override void BuildLayer() + { + for (int i = 0; i < InputVariablesGroups.Count; i++) + { + bool isDraw = (_TeamRanks[i] == _TeamRanks[i + 1]); + Variable teamDifference = InputVariablesGroups[i][0]; + + GaussianFactor factor = + isDraw + ? (GaussianFactor) new GaussianWithinFactor(_Epsilon, teamDifference) + : new GaussianGreaterThanFactor(_Epsilon, teamDifference); + + AddLayerFactor(factor); + } + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Layers/TeamPerformancesToTeamPerformanceDifferencesLayer.cs b/Skills/TrueSkill/Layers/TeamPerformancesToTeamPerformanceDifferencesLayer.cs new file mode 100644 index 0000000..cec409d --- /dev/null +++ b/Skills/TrueSkill/Layers/TeamPerformancesToTeamPerformanceDifferencesLayer.cs @@ -0,0 +1,43 @@ +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; +using Moserware.Skills.TrueSkill.Factors; + +namespace Moserware.Skills.TrueSkill.Layers +{ + internal class TeamPerformancesToTeamPerformanceDifferencesLayer : + TrueSkillFactorGraphLayer + , GaussianWeightedSumFactor, Variable> + { + public TeamPerformancesToTeamPerformanceDifferencesLayer(TrueSkillFactorGraph parentGraph) + : base(parentGraph) + { + } + + public override void BuildLayer() + { + for (int i = 0; i < InputVariablesGroups.Count - 1; i++) + { + Variable strongerTeam = InputVariablesGroups[i][0]; + Variable weakerTeam = InputVariablesGroups[i + 1][0]; + + Variable currentDifference = CreateOutputVariable(); + AddLayerFactor(CreateTeamPerformanceToDifferenceFactor(strongerTeam, weakerTeam, currentDifference)); + + // REVIEW: Does it make sense to have groups of one? + OutputVariablesGroups.Add(new[] {currentDifference}); + } + } + + private GaussianWeightedSumFactor CreateTeamPerformanceToDifferenceFactor( + Variable strongerTeam, Variable weakerTeam, + Variable output) + { + return new GaussianWeightedSumFactor(output, new[] {strongerTeam, weakerTeam}, new[] {1.0, -1.0}); + } + + private Variable CreateOutputVariable() + { + return ParentFactorGraph.VariableFactory.CreateBasicVariable("Team performance difference"); + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/Layers/TrueSkillFactorGraphLayer.cs b/Skills/TrueSkill/Layers/TrueSkillFactorGraphLayer.cs new file mode 100644 index 0000000..fedb182 --- /dev/null +++ b/Skills/TrueSkill/Layers/TrueSkillFactorGraphLayer.cs @@ -0,0 +1,20 @@ +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; + +namespace Moserware.Skills.TrueSkill.Layers +{ + internal abstract class TrueSkillFactorGraphLayer + : + FactorGraphLayer + , GaussianDistribution, Variable, TInputVariable, + TFactor, TOutputVariable> + where TInputVariable : Variable + where TFactor : Factor + where TOutputVariable : Variable + { + public TrueSkillFactorGraphLayer(TrueSkillFactorGraph parentGraph) + : base(parentGraph) + { + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/TrueSkillFactorGraph.cs b/Skills/TrueSkill/TrueSkillFactorGraph.cs new file mode 100644 index 0000000..ffe5d0a --- /dev/null +++ b/Skills/TrueSkill/TrueSkillFactorGraph.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Moserware.Numerics; +using Moserware.Skills.FactorGraphs; +using Moserware.Skills.TrueSkill.Layers; + +namespace Moserware.Skills.TrueSkill +{ + public class TrueSkillFactorGraph : + FactorGraph, GaussianDistribution, Variable> + { + private readonly List> _Layers; + private readonly PlayerPriorValuesToSkillsLayer _PriorLayer; + + public TrueSkillFactorGraph(GameInfo gameInfo, IEnumerable> teams, int[] teamRanks) + { + _PriorLayer = new PlayerPriorValuesToSkillsLayer(this, teams); + GameInfo = gameInfo; + VariableFactory = + new VariableFactory(() => GaussianDistribution.FromPrecisionMean(0, 0)); + + _Layers = new List> + { + _PriorLayer, + new PlayerSkillsToPerformancesLayer(this), + new PlayerPerformancesToTeamPerformancesLayer(this), + new IteratedTeamDifferencesInnerLayer( + this, + new TeamPerformancesToTeamPerformanceDifferencesLayer(this), + new TeamDifferencesComparisonLayer(this, teamRanks)) + }; + } + + public GameInfo GameInfo { get; private set; } + + public void BuildGraph() + { + object lastOutput = null; + + foreach (var currentLayer in _Layers) + { + if (lastOutput != null) + { + currentLayer.SetRawInputVariablesGroups(lastOutput); + } + + currentLayer.BuildLayer(); + + lastOutput = currentLayer.GetRawOutputVariablesGroups(); + } + } + + public void RunSchedule() + { + Schedule fullSchedule = CreateFullSchedule(); + double fullScheduleDelta = fullSchedule.Visit(); + } + + public double GetProbabilityOfRanking() + { + var factorList = new FactorList(); + + foreach (var currentLayer in _Layers) + { + foreach (var currentFactor in currentLayer.UntypedFactors) + { + factorList.AddFactor(currentFactor); + } + } + + double logZ = factorList.LogNormalization; + return Math.Exp(logZ); + } + + private Schedule CreateFullSchedule() + { + var fullSchedule = new List>(); + + foreach (var currentLayer in _Layers) + { + Schedule currentPriorSchedule = currentLayer.CreatePriorSchedule(); + if (currentPriorSchedule != null) + { + fullSchedule.Add(currentPriorSchedule); + } + } + + // Casting to IEnumerable to get the LINQ Reverse() + IEnumerable> allLayers = _Layers; + + foreach (var currentLayer in allLayers.Reverse()) + { + Schedule currentPosteriorSchedule = currentLayer.CreatePosteriorSchedule(); + if (currentPosteriorSchedule != null) + { + fullSchedule.Add(currentPosteriorSchedule); + } + } + + return new ScheduleSequence("Full schedule", fullSchedule); + } + + public IDictionary GetUpdatedRatings() + { + var result = new Dictionary(); + foreach (var currentTeam in _PriorLayer.OutputVariablesGroups) + { + foreach (var currentPlayer in currentTeam) + { + result[currentPlayer.Key] = new Rating(currentPlayer.Value.Mean, + currentPlayer.Value.StandardDeviation); + } + } + + return result; + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/TruncatedGaussianCorrectionFunctions.cs b/Skills/TrueSkill/TruncatedGaussianCorrectionFunctions.cs new file mode 100644 index 0000000..252ee6a --- /dev/null +++ b/Skills/TrueSkill/TruncatedGaussianCorrectionFunctions.cs @@ -0,0 +1,188 @@ +using System; +using Moserware.Numerics; + +namespace Moserware.Skills.TrueSkill +{ + internal static class TruncatedGaussianCorrectionFunctions + { + // These functions from the bottom of page 4 of the TrueSkill paper. + + /// + /// The "V" function where the team performance difference is greater than the draw margin. + /// + /// In the reference F# implementation, this is referred to as "the additive + /// correction of a single-sided truncated Gaussian with unit variance." + /// + /// In the paper, it's referred to as just "ε". + /// + public static double VExceedsMargin(double teamPerformanceDifference, double drawMargin, double c) + { + return VExceedsMargin(teamPerformanceDifference/c, drawMargin/c); + //return GaussianDistribution.At((teamPerformanceDifference - drawMargin) / c) / GaussianDistribution.CumulativeTo((teamPerformanceDifference - drawMargin) / c); + } + + public static double VExceedsMargin(double teamPerformanceDifference, double drawMargin) + { + double denominator = GaussianDistribution.CumulativeTo(teamPerformanceDifference - drawMargin); + + if (denominator < 2.222758749e-162) + { + return -teamPerformanceDifference + drawMargin; + } + + return GaussianDistribution.At(teamPerformanceDifference - drawMargin)/denominator; + } + + /// + /// The "W" function where the team performance difference is greater than the draw margin. + /// + /// In the reference F# implementation, this is referred to as "the multiplicative + /// correction of a single-sided truncated Gaussian with unit variance." + /// + /// + /// + /// + public static double WExceedsMargin(double teamPerformanceDifference, double drawMargin, double c) + { + return WExceedsMargin(teamPerformanceDifference/c, drawMargin/c); + //var vWin = VExceedsMargin(teamPerformanceDifference, drawMargin, c); + //return vWin * (vWin + (teamPerformanceDifference - drawMargin) / c); + } + + public static double WExceedsMargin(double teamPerformanceDifference, double drawMargin) + { + double denominator = GaussianDistribution.CumulativeTo(teamPerformanceDifference - drawMargin); + + if (denominator < 2.222758749e-162) + { + if (teamPerformanceDifference < 0.0) + { + return 1.0; + } + return 0.0; + } + + double vWin = VExceedsMargin(teamPerformanceDifference, drawMargin); + return vWin*(vWin + teamPerformanceDifference - drawMargin); + } + + // the additive correction of a double-sided truncated Gaussian with unit variance + public static double VWithinMargin(double teamPerformanceDifference, double drawMargin, double c) + { + return VWithinMargin(teamPerformanceDifference/c, drawMargin/c); + //var teamPerformanceDifferenceAbsoluteValue = Math.Abs(teamPerformanceDifference); + //return (GaussianDistribution.At((-drawMargin - teamPerformanceDifferenceAbsoluteValue) / c) - GaussianDistribution.At((drawMargin - teamPerformanceDifferenceAbsoluteValue) / c)) + // / + // (GaussianDistribution.CumulativeTo((drawMargin - teamPerformanceDifferenceAbsoluteValue) / c) - GaussianDistribution.CumulativeTo((-drawMargin - teamPerformanceDifferenceAbsoluteValue) / c)); + } + + // My original: + //public static double VWithinMargin(double teamPerformanceDifference, double drawMargin) + //{ + // var teamPerformanceDifferenceAbsoluteValue = Math.Abs(teamPerformanceDifference); + // return (GaussianDistribution.At(-drawMargin - teamPerformanceDifferenceAbsoluteValue) - GaussianDistribution.At(drawMargin - teamPerformanceDifferenceAbsoluteValue)) + // / + // (GaussianDistribution.CumulativeTo(drawMargin - teamPerformanceDifferenceAbsoluteValue) - GaussianDistribution.CumulativeTo(-drawMargin - teamPerformanceDifferenceAbsoluteValue)); + //} + + // from F#: + public static double VWithinMargin(double teamPerformanceDifference, double drawMargin) + { + double teamPerformanceDifferenceAbsoluteValue = Math.Abs(teamPerformanceDifference); + double denominator = + GaussianDistribution.CumulativeTo(drawMargin - teamPerformanceDifferenceAbsoluteValue) - + GaussianDistribution.CumulativeTo(-drawMargin - teamPerformanceDifferenceAbsoluteValue); + if (denominator < 2.222758749e-162) + { + if (teamPerformanceDifference < 0.0) + { + return -teamPerformanceDifference - drawMargin; + } + + return -teamPerformanceDifference + drawMargin; + } + + double numerator = GaussianDistribution.At(-drawMargin - teamPerformanceDifferenceAbsoluteValue) - + GaussianDistribution.At(drawMargin - teamPerformanceDifferenceAbsoluteValue); + + if (teamPerformanceDifference < 0.0) + { + return -numerator/denominator; + } + + return numerator/denominator; + } + + // the multiplicative correction of a double-sided truncated Gaussian with unit variance + public static double WWithinMargin(double teamPerformanceDifference, double drawMargin, double c) + { + return WWithinMargin(teamPerformanceDifference/c, drawMargin/c); + //var teamPerformanceDifferenceAbsoluteValue = Math.Abs(teamPerformanceDifference); + //var vDraw = VWithinMargin(teamPerformanceDifferenceAbsoluteValue, drawMargin, c); + + //return (vDraw * vDraw) + // + + // ( + // ( + // ( + // ((drawMargin - teamPerformanceDifferenceAbsoluteValue) / c) + // * + // GaussianDistribution.At((drawMargin - teamPerformanceDifferenceAbsoluteValue) / c) + // ) + // + + // ( + // ((drawMargin + teamPerformanceDifferenceAbsoluteValue) / c) + // * + // GaussianDistribution.At((drawMargin + teamPerformanceDifferenceAbsoluteValue) / c) + // ) + // ) + // / + // ( + // GaussianDistribution.CumulativeTo((drawMargin - teamPerformanceDifferenceAbsoluteValue) / c) + // - + // GaussianDistribution.CumulativeTo((-drawMargin - teamPerformanceDifferenceAbsoluteValue) / c) + // ) + // ); + } + + // My original: + //public static double WWithinMargin(double teamPerformanceDifference, double drawMargin) + //{ + // var teamPerformanceDifferenceAbsoluteValue = Math.Abs(teamPerformanceDifference); + // var vDraw = VWithinMargin(teamPerformanceDifferenceAbsoluteValue, drawMargin); + // return (vDraw * vDraw) + // + + // ( + // ((drawMargin - teamPerformanceDifferenceAbsoluteValue) * GaussianDistribution.At(drawMargin - teamPerformanceDifferenceAbsoluteValue) + (drawMargin + teamPerformanceDifferenceAbsoluteValue) * GaussianDistribution.At(drawMargin + teamPerformanceDifferenceAbsoluteValue)) + // / + // (GaussianDistribution.CumulativeTo(drawMargin - teamPerformanceDifferenceAbsoluteValue) - GaussianDistribution.CumulativeTo(-drawMargin - teamPerformanceDifferenceAbsoluteValue)) + // ); + //} + + // From F#: + public static double WWithinMargin(double teamPerformanceDifference, double drawMargin) + { + double teamPerformanceDifferenceAbsoluteValue = Math.Abs(teamPerformanceDifference); + double denominator = GaussianDistribution.CumulativeTo(drawMargin - teamPerformanceDifferenceAbsoluteValue) + - + GaussianDistribution.CumulativeTo(-drawMargin - teamPerformanceDifferenceAbsoluteValue); + + if (denominator < 2.222758749e-162) + { + return 1.0; + } + + double vt = VWithinMargin(teamPerformanceDifferenceAbsoluteValue, drawMargin); + + return vt*vt + + ( + (drawMargin - teamPerformanceDifferenceAbsoluteValue) + * + GaussianDistribution.At( + drawMargin - teamPerformanceDifferenceAbsoluteValue) + - (-drawMargin - teamPerformanceDifferenceAbsoluteValue) + * + GaussianDistribution.At(-drawMargin - teamPerformanceDifferenceAbsoluteValue))/denominator; + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/TwoPlayerTrueSkillCalculator.cs b/Skills/TrueSkill/TwoPlayerTrueSkillCalculator.cs new file mode 100644 index 0000000..e494474 --- /dev/null +++ b/Skills/TrueSkill/TwoPlayerTrueSkillCalculator.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Moserware.Skills.Numerics; + +namespace Moserware.Skills.TrueSkill +{ + /// + /// Calculates the new ratings for only two players. + /// + /// + /// When you only have two players, a lot of the math simplifies. The main purpose of this class + /// is to show the bare minimum of what a TrueSkill implementation should have. + /// + public class TwoPlayerTrueSkillCalculator : SkillCalculator + { + public TwoPlayerTrueSkillCalculator() + : base(SupportedOptions.None, Range.Exactly(2), Range.Exactly(1)) + { + } + + /// + public override IDictionary CalculateNewRatings(GameInfo gameInfo, + IEnumerable + > + teams, params int[] teamRanks) + { + // Basic argument checking + Guard.ArgumentNotNull(gameInfo, "gameInfo"); + ValidateTeamCountAndPlayersCountPerTeam(teams); + + // Make sure things are in order + RankSorter.Sort(ref teams, ref teamRanks); + + // Get the teams as a list to make it easier to index + List> teamList = teams.ToList(); + + // Since we verified that each team has one player, we know the player is the first one + IDictionary winningTeam = teamList[0]; + TPlayer winner = winningTeam.Keys.First(); + Rating winnerPreviousRating = winningTeam[winner]; + + IDictionary losingTeam = teamList[1]; + TPlayer loser = losingTeam.Keys.First(); + Rating loserPreviousRating = losingTeam[loser]; + + bool wasDraw = (teamRanks[0] == teamRanks[1]); + + var results = new Dictionary(); + results[winner] = CalculateNewRating(gameInfo, winnerPreviousRating, loserPreviousRating, + wasDraw ? PairwiseComparison.Draw : PairwiseComparison.Win); + results[loser] = CalculateNewRating(gameInfo, loserPreviousRating, winnerPreviousRating, + wasDraw ? PairwiseComparison.Draw : PairwiseComparison.Lose); + + // And we're done! + return results; + } + + private static Rating CalculateNewRating(GameInfo gameInfo, Rating selfRating, Rating opponentRating, + PairwiseComparison comparison) + { + double drawMargin = DrawMargin.GetDrawMarginFromDrawProbability(gameInfo.DrawProbability, gameInfo.Beta); + + double c = + Math.Sqrt( + Square(selfRating.StandardDeviation) + + + Square(opponentRating.StandardDeviation) + + + 2*Square(gameInfo.Beta)); + + double winningMean = selfRating.Mean; + double losingMean = opponentRating.Mean; + + switch (comparison) + { + case PairwiseComparison.Win: + case PairwiseComparison.Draw: + // NOP + break; + case PairwiseComparison.Lose: + winningMean = opponentRating.Mean; + losingMean = selfRating.Mean; + break; + } + + double meanDelta = winningMean - losingMean; + + double v; + double w; + double rankMultiplier; + + if (comparison != PairwiseComparison.Draw) + { + // non-draw case + v = TruncatedGaussianCorrectionFunctions.VExceedsMargin(meanDelta, drawMargin, c); + w = TruncatedGaussianCorrectionFunctions.WExceedsMargin(meanDelta, drawMargin, c); + rankMultiplier = (int) comparison; + } + else + { + v = TruncatedGaussianCorrectionFunctions.VWithinMargin(meanDelta, drawMargin, c); + w = TruncatedGaussianCorrectionFunctions.WWithinMargin(meanDelta, drawMargin, c); + rankMultiplier = 1; + } + + double meanMultiplier = (Square(selfRating.StandardDeviation) + Square(gameInfo.DynamicsFactor))/c; + + double varianceWithDynamics = Square(selfRating.StandardDeviation) + Square(gameInfo.DynamicsFactor); + double stdDevMultiplier = varianceWithDynamics/Square(c); + + double newMean = selfRating.Mean + (rankMultiplier*meanMultiplier*v); + double newStdDev = Math.Sqrt(varianceWithDynamics*(1 - w*stdDevMultiplier)); + + return new Rating(newMean, newStdDev); + } + + /// + public override double CalculateMatchQuality(GameInfo gameInfo, + IEnumerable> teams) + { + Guard.ArgumentNotNull(gameInfo, "gameInfo"); + ValidateTeamCountAndPlayersCountPerTeam(teams); + + Rating player1Rating = teams.First().Values.First(); + Rating player2Rating = teams.Last().Values.First(); + + // We just use equation 4.1 found on page 8 of the TrueSkill 2006 paper: + double betaSquared = Square(gameInfo.Beta); + double player1SigmaSquared = Square(player1Rating.StandardDeviation); + double player2SigmaSquared = Square(player2Rating.StandardDeviation); + + // This is the square root part of the equation: + double sqrtPart = + Math.Sqrt( + (2*betaSquared) + / + (2*betaSquared + player1SigmaSquared + player2SigmaSquared)); + + // This is the exponent part of the equation: + double expPart = + Math.Exp( + (-1*Square(player1Rating.Mean - player2Rating.Mean)) + / + (2*(2*betaSquared + player1SigmaSquared + player2SigmaSquared))); + + return sqrtPart*expPart; + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkill/TwoTeamTrueSkillCalculator.cs b/Skills/TrueSkill/TwoTeamTrueSkillCalculator.cs new file mode 100644 index 0000000..8a493b8 --- /dev/null +++ b/Skills/TrueSkill/TwoTeamTrueSkillCalculator.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Moserware.Skills.Numerics; + +namespace Moserware.Skills.TrueSkill +{ + /// + /// Calculates new ratings for only two teams where each team has 1 or more players. + /// + /// + /// When you only have two teams, the math is still simple: no factor graphs are used yet. + /// + public class TwoTeamTrueSkillCalculator : SkillCalculator + { + public TwoTeamTrueSkillCalculator() + : base(SupportedOptions.None, Range.Exactly(2), Range.AtLeast(1)) + { + } + + /// + public override IDictionary CalculateNewRatings(GameInfo gameInfo, + IEnumerable + > + teams, params int[] teamRanks) + { + Guard.ArgumentNotNull(gameInfo, "gameInfo"); + ValidateTeamCountAndPlayersCountPerTeam(teams); + + RankSorter.Sort(ref teams, ref teamRanks); + + IDictionary team1 = teams.First(); + IDictionary team2 = teams.Last(); + + bool wasDraw = (teamRanks[0] == teamRanks[1]); + + var results = new Dictionary(); + + UpdatePlayerRatings(gameInfo, + results, + team1, + team2, + wasDraw ? PairwiseComparison.Draw : PairwiseComparison.Win); + + UpdatePlayerRatings(gameInfo, + results, + team2, + team1, + wasDraw ? PairwiseComparison.Draw : PairwiseComparison.Lose); + + return results; + } + + private static void UpdatePlayerRatings(GameInfo gameInfo, + IDictionary newPlayerRatings, + IDictionary selfTeam, + IDictionary otherTeam, + PairwiseComparison selfToOtherTeamComparison) + { + double drawMargin = DrawMargin.GetDrawMarginFromDrawProbability(gameInfo.DrawProbability, gameInfo.Beta); + double betaSquared = Square(gameInfo.Beta); + double tauSquared = Square(gameInfo.DynamicsFactor); + + int totalPlayers = selfTeam.Count() + otherTeam.Count(); + + double selfMeanSum = selfTeam.Values.Sum(r => r.Mean); + double otherTeamMeanSum = otherTeam.Values.Sum(r => r.Mean); + + double c = Math.Sqrt(selfTeam.Values.Sum(r => Square(r.StandardDeviation)) + + + otherTeam.Values.Sum(r => Square(r.StandardDeviation)) + + + totalPlayers*betaSquared); + + double winningMean = selfMeanSum; + double losingMean = otherTeamMeanSum; + + switch (selfToOtherTeamComparison) + { + case PairwiseComparison.Win: + case PairwiseComparison.Draw: + // NOP + break; + case PairwiseComparison.Lose: + winningMean = otherTeamMeanSum; + losingMean = selfMeanSum; + break; + } + + double meanDelta = winningMean - losingMean; + + double v; + double w; + double rankMultiplier; + + if (selfToOtherTeamComparison != PairwiseComparison.Draw) + { + // non-draw case + v = TruncatedGaussianCorrectionFunctions.VExceedsMargin(meanDelta, drawMargin, c); + w = TruncatedGaussianCorrectionFunctions.WExceedsMargin(meanDelta, drawMargin, c); + rankMultiplier = (int) selfToOtherTeamComparison; + } + else + { + // assume draw + v = TruncatedGaussianCorrectionFunctions.VWithinMargin(meanDelta, drawMargin, c); + w = TruncatedGaussianCorrectionFunctions.WWithinMargin(meanDelta, drawMargin, c); + rankMultiplier = 1; + } + + foreach (var teamPlayerRatingPair in selfTeam) + { + Rating previousPlayerRating = teamPlayerRatingPair.Value; + + double meanMultiplier = (Square(previousPlayerRating.StandardDeviation) + tauSquared)/c; + double stdDevMultiplier = (Square(previousPlayerRating.StandardDeviation) + tauSquared)/Square(c); + + double playerMeanDelta = (rankMultiplier*meanMultiplier*v); + double newMean = previousPlayerRating.Mean + playerMeanDelta; + + double newStdDev = + Math.Sqrt((Square(previousPlayerRating.StandardDeviation) + tauSquared)*(1 - w*stdDevMultiplier)); + + newPlayerRatings[teamPlayerRatingPair.Key] = new Rating(newMean, newStdDev); + } + } + + /// + public override double CalculateMatchQuality(GameInfo gameInfo, + IEnumerable> teams) + { + Guard.ArgumentNotNull(gameInfo, "gameInfo"); + ValidateTeamCountAndPlayersCountPerTeam(teams); + + // We've verified that there's just two teams + ICollection team1 = teams.First().Values; + int team1Count = team1.Count(); + + ICollection team2 = teams.Last().Values; + int team2Count = team2.Count(); + + int totalPlayers = team1Count + team2Count; + + double betaSquared = Square(gameInfo.Beta); + + double team1MeanSum = team1.Sum(r => r.Mean); + double team1StdDevSquared = team1.Sum(r => Square(r.StandardDeviation)); + + double team2MeanSum = team2.Sum(r => r.Mean); + double team2SigmaSquared = team2.Sum(r => Square(r.StandardDeviation)); + + // This comes from equation 4.1 in the TrueSkill paper on page 8 + // The equation was broken up into the part under the square root sign and + // the exponential part to make the code easier to read. + + double sqrtPart + = Math.Sqrt( + (totalPlayers*betaSquared) + / + (totalPlayers*betaSquared + team1StdDevSquared + team2SigmaSquared) + ); + + double expPart + = Math.Exp( + (-1*Square(team1MeanSum - team2MeanSum)) + / + (2*(totalPlayers*betaSquared + team1StdDevSquared + team2SigmaSquared)) + ); + + return expPart*sqrtPart; + } + } +} \ No newline at end of file diff --git a/Skills/TrueSkillCalculator.cs b/Skills/TrueSkillCalculator.cs new file mode 100644 index 0000000..9f3d05d --- /dev/null +++ b/Skills/TrueSkillCalculator.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using Moserware.Skills.TrueSkill; + +namespace Moserware.Skills +{ + /// + /// Calculates a TrueSkill rating using . + /// + public static class TrueSkillCalculator + { + // Keep a singleton around + private static readonly SkillCalculator _Calculator + = new FactorGraphTrueSkillCalculator(); + + /// + /// Calculates new ratings based on the prior ratings and team ranks. + /// + /// Parameters for the game. + /// A mapping of team players and their ratings. + /// The ranks of the teams where 1 is first place. For a tie, repeat the number (e.g. 1, 2, 2) + /// All the players and their new ratings. + public static IDictionary CalculateNewRatings(GameInfo gameInfo, + IEnumerable + > teams, + params int[] teamRanks) + { + // Just punt the work to the full implementation + return _Calculator.CalculateNewRatings(gameInfo, teams, teamRanks); + } + + /// + /// Calculates the match quality as the likelihood of all teams drawing. + /// + /// The underlying type of the player. + /// Parameters for the game. + /// A mapping of team players and their ratings. + /// The match quality as a percentage (between 0.0 and 1.0). + public static double CalculateMatchQuality(GameInfo gameInfo, + IEnumerable> teams) + { + // Just punt the work to the full implementation + return _Calculator.CalculateMatchQuality(gameInfo, teams); + } + } +} \ No newline at end of file diff --git a/Skills/bin/Debug/Moserware.Skills.dll b/Skills/bin/Debug/Moserware.Skills.dll new file mode 100644 index 0000000000000000000000000000000000000000..27ce8d265830e42b2f4eb96d643cbc7e35ffd468 GIT binary patch literal 63488 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 literal 0 HcmV?d00001 diff --git a/Skills/bin/Debug/Moserware.Skills.pdb b/Skills/bin/Debug/Moserware.Skills.pdb new file mode 100644 index 0000000000000000000000000000000000000000..5a376b1ec75ea39fbb07d83168f14e4100fc133b GIT binary patch literal 247296 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 literal 0 HcmV?d00001 diff --git a/UnitTests/Elo/DuellingEloTest.cs b/UnitTests/Elo/DuellingEloTest.cs new file mode 100644 index 0000000..baf3ff1 --- /dev/null +++ b/UnitTests/Elo/DuellingEloTest.cs @@ -0,0 +1,51 @@ +using Moserware.Skills; +using Moserware.Skills.Elo; +using NUnit.Framework; + +namespace UnitTests.Elo +{ + [TestFixture] + public class DuellingEloTest + { + private const double ErrorTolerance = 0.1; + + [Test] + public void TwoOnTwoDuellingTest() + { + var calculator = new DuellingEloCalculator(new GaussianEloCalculator()); + + var player1 = new Player(1); + var player2 = new Player(2); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, gameInfo.DefaultRating) + .AddPlayer(player2, gameInfo.DefaultRating); + + var player3 = new Player(3); + var player4 = new Player(4); + + var team2 = new Team() + .AddPlayer(player3, gameInfo.DefaultRating) + .AddPlayer(player4, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 2); + + // TODO: Verify? + AssertRating(37, newRatingsWinLose[player1]); + AssertRating(37, newRatingsWinLose[player2]); + AssertRating(13, newRatingsWinLose[player3]); + AssertRating(13, newRatingsWinLose[player4]); + + var quality = calculator.CalculateMatchQuality(gameInfo, teams); + Assert.AreEqual(1.0, quality, 0.001); + } + + private static void AssertRating(double expected, Rating actual) + { + Assert.AreEqual(expected, actual.Mean, ErrorTolerance); + } + } +} diff --git a/UnitTests/Elo/EloAssert.cs b/UnitTests/Elo/EloAssert.cs new file mode 100644 index 0000000..2242120 --- /dev/null +++ b/UnitTests/Elo/EloAssert.cs @@ -0,0 +1,37 @@ +using Moserware.Skills; +using Moserware.Skills.Elo; +using NUnit.Framework; + +namespace UnitTests.Elo +{ + internal static class EloAssert + { + private const double ErrorTolerance = 0.1; + + public static void AssertChessRating(TwoPlayerEloCalculator calculator, + double player1BeforeRating, + double player2BeforeRating, + PairwiseComparison player1Result, + double player1AfterRating, + double player2AfterRating) + { + var player1 = new Player(1); + var player2 = new Player(2); + + var teams = Teams.Concat( + new Team(player1, new EloRating(player1BeforeRating)), + new Team(player2, new EloRating(player2BeforeRating))); + + var chessGameInfo = new GameInfo(1200, 0, 200, 0, 0); + + var result = calculator.CalculateNewRatings(chessGameInfo, teams, + (player1Result == PairwiseComparison.Win) ? new[] { 1, 2 } : + (player1Result == PairwiseComparison.Lose) ? new[] { 2, 1 } : + new[] { 1, 1 }); + + + Assert.AreEqual(player1AfterRating, result[player1].Mean, ErrorTolerance); + Assert.AreEqual(player2AfterRating, result[player2].Mean, ErrorTolerance); + } + } +} diff --git a/UnitTests/Elo/FideEloCalculatorTest.cs b/UnitTests/Elo/FideEloCalculatorTest.cs new file mode 100644 index 0000000..56febae --- /dev/null +++ b/UnitTests/Elo/FideEloCalculatorTest.cs @@ -0,0 +1,36 @@ +using Moserware.Skills; +using Moserware.Skills.Elo; +using NUnit.Framework; + +namespace UnitTests.Elo +{ + [TestFixture] + public class FideEloCalculatorTest + { + [Test] + public void FideProvisionalEloCalculatorTests() + { + // verified against http://ratings.fide.com/calculator_rtd.phtml + var calc = new FideEloCalculator(new FideKFactor.Provisional()); + + EloAssert.AssertChessRating(calc, 1200, 1500, PairwiseComparison.Win, 1221.25, 1478.75); + EloAssert.AssertChessRating(calc, 1200, 1500, PairwiseComparison.Draw, 1208.75, 1491.25); + EloAssert.AssertChessRating(calc, 1200, 1500, PairwiseComparison.Lose, 1196.25, 1503.75); + } + + [Test] + public void FideNonProvisionalEloCalculatorTests() + { + // verified against http://ratings.fide.com/calculator_rtd.phtml + var calc = new FideEloCalculator(); + + EloAssert.AssertChessRating(calc, 1200, 1200, PairwiseComparison.Win, 1207.5, 1192.5); + EloAssert.AssertChessRating(calc, 1200, 1200, PairwiseComparison.Draw, 1200, 1200); + EloAssert.AssertChessRating(calc, 1200, 1200, PairwiseComparison.Lose, 1192.5, 1207.5); + + EloAssert.AssertChessRating(calc, 2600, 2500, PairwiseComparison.Win, 2603.6, 2496.4); + EloAssert.AssertChessRating(calc, 2600, 2500, PairwiseComparison.Draw, 2598.6, 2501.4); + EloAssert.AssertChessRating(calc, 2600, 2500, PairwiseComparison.Lose, 2593.6, 2506.4); + } + } +} \ No newline at end of file diff --git a/UnitTests/Elo/GaussianEloCalculatorTest.cs b/UnitTests/Elo/GaussianEloCalculatorTest.cs new file mode 100644 index 0000000..a6325e5 --- /dev/null +++ b/UnitTests/Elo/GaussianEloCalculatorTest.cs @@ -0,0 +1,26 @@ +using Moserware.Skills; +using Moserware.Skills.Elo; +using NUnit.Framework; + +namespace UnitTests.Elo +{ + [TestFixture] + public class GaussianEloCalculatorTest + { + [Test] + public void GaussianEloCalculatorTests() + { + const double defaultKFactor = 24.0; + var calc = new GaussianEloCalculator(); + + EloAssert.AssertChessRating(calc, 1200, 1200, PairwiseComparison.Win, 1212, 1188); + EloAssert.AssertChessRating(calc, 1200, 1200, PairwiseComparison.Draw, 1200, 1200); + EloAssert.AssertChessRating(calc, 1200, 1200, PairwiseComparison.Lose, 1188, 1212); + + // verified using TrueSkill paper equation + EloAssert.AssertChessRating(calc, 1200, 1000, PairwiseComparison.Win, 1200 + ((1 - 0.76024993890652326884) * defaultKFactor), 1000 - (1 - 0.76024993890652326884) * defaultKFactor); + EloAssert.AssertChessRating(calc, 1200, 1000, PairwiseComparison.Draw, 1200 - (0.76024993890652326884 - 0.5) * defaultKFactor, 1000 + (0.76024993890652326884 - 0.5) * defaultKFactor); + EloAssert.AssertChessRating(calc, 1200, 1000, PairwiseComparison.Lose, 1200 - 0.76024993890652326884 * defaultKFactor, 1000 + 0.76024993890652326884 * defaultKFactor); + } + } +} diff --git a/UnitTests/Numerics/GaussianDistributionTests.cs b/UnitTests/Numerics/GaussianDistributionTests.cs new file mode 100644 index 0000000..e0b0447 --- /dev/null +++ b/UnitTests/Numerics/GaussianDistributionTests.cs @@ -0,0 +1,68 @@ +using System; +using Moserware.Numerics; +using NUnit.Framework; + +namespace UnitTests.Numerics +{ + [TestFixture] + public class GaussianDistributionTests + { + private const double ErrorTolerance = 0.000001; + + [Test] + public void MultiplicationTests() + { + // I verified this against the formula at http://www.tina-vision.net/tina-knoppix/tina-memo/2003-003.pdf + var standardNormal = new GaussianDistribution(0, 1); + var shiftedGaussian = new GaussianDistribution(2, 3); + + var product = standardNormal * shiftedGaussian; + + Assert.AreEqual(0.2, product.Mean, ErrorTolerance); + Assert.AreEqual(3.0 / Math.Sqrt(10), product.StandardDeviation, ErrorTolerance); + + var m4s5 = new GaussianDistribution(4, 5); + var m6s7 = new GaussianDistribution(6, 7); + + var product2 = m4s5 * m6s7; + Func square = x => x*x; + + var expectedMean = (4 * square(7) + 6 * square(5)) / (square(5) + square(7)); + Assert.AreEqual(expectedMean, product2.Mean, ErrorTolerance); + + var expectedSigma = Math.Sqrt(((square(5) * square(7)) / (square(5) + square(7)))); + Assert.AreEqual(expectedSigma, product2.StandardDeviation, ErrorTolerance); + } + + [Test] + public void DivisionTests() + { + // Since the multiplication was worked out by hand, we use the same numbers but work backwards + var product = new GaussianDistribution(0.2, 3.0 / Math.Sqrt(10)); + var standardNormal = new GaussianDistribution(0, 1); + + var productDividedByStandardNormal = product / standardNormal; + Assert.AreEqual(2.0, productDividedByStandardNormal.Mean, ErrorTolerance); + Assert.AreEqual(3.0, productDividedByStandardNormal.StandardDeviation, ErrorTolerance); + + Func square = x => x * x; + var product2 = new GaussianDistribution((4 * square(7) + 6 * square(5)) / (square(5) + square(7)), Math.Sqrt(((square(5) * square(7)) / (square(5) + square(7))))); + var m4s5 = new GaussianDistribution(4,5); + var product2DividedByM4S5 = product2 / m4s5; + Assert.AreEqual(6.0, product2DividedByM4S5.Mean, ErrorTolerance); + Assert.AreEqual(7.0, product2DividedByM4S5.StandardDeviation, ErrorTolerance); + } + + [Test] + public void LogProductNormalizationTests() + { + var m4s5 = new GaussianDistribution(4, 5); + var m6s7 = new GaussianDistribution(6, 7); + + var product2 = m4s5 * m6s7; + var normConstant = 1.0 / (Math.Sqrt(2 * Math.PI) * product2.StandardDeviation); + var lpn = GaussianDistribution.LogProductNormalization(m4s5, m6s7); + + } + } +} \ No newline at end of file diff --git a/UnitTests/Numerics/MatrixTests.cs b/UnitTests/Numerics/MatrixTests.cs new file mode 100644 index 0000000..1e5ce56 --- /dev/null +++ b/UnitTests/Numerics/MatrixTests.cs @@ -0,0 +1,186 @@ +using Moserware.Numerics; +using NUnit.Framework; + +namespace UnitTests.Numerics +{ + [TestFixture] + public class MatrixTests + { + [Test] + public void TwoByTwoDeterminantTests() + { + var a = new SquareMatrix(1, 2, + 3, 4); + Assert.AreEqual(-2, a.Determinant); + + var b = new SquareMatrix(3, 4, + 5, 6); + Assert.AreEqual(-2, b.Determinant); + + var c = new SquareMatrix(1, 1, + 1, 1); + Assert.AreEqual(0, c.Determinant); + + var d = new SquareMatrix(12, 15, + 17, 21); + Assert.AreEqual(12 * 21 - 15 * 17, d.Determinant); + } + + [Test] + public void ThreeByThreeDeterminantTests() + { + var a = new SquareMatrix(1, 2, 3, + 4, 5, 6, + 7, 8, 9); + Assert.AreEqual(0, a.Determinant); + + var π = new SquareMatrix(3, 1, 4, + 1, 5, 9, + 2, 6, 5); + + // Verified against http://www.wolframalpha.com/input/?i=determinant+%7B%7B3%2C1%2C4%7D%2C%7B1%2C5%2C9%7D%2C%7B2%2C6%2C5%7D%7D + Assert.AreEqual(-90, π.Determinant); + } + + [Test] + public void FourByFourDeterminantTests() + { + var a = new SquareMatrix( 1, 2, 3, 4, + 5, 6, 7, 8, + 9, 10, 11, 12, + 13, 14, 15, 16); + + Assert.AreEqual(0, a.Determinant); + + var π = new SquareMatrix(3, 1, 4, 1, + 5, 9, 2, 6, + 5, 3, 5, 8, + 9, 7, 9, 3); + + // Verified against http://www.wolframalpha.com/input/?i=determinant+%7B+%7B3%2C1%2C4%2C1%7D%2C+%7B5%2C9%2C2%2C6%7D%2C+%7B5%2C3%2C5%2C8%7D%2C+%7B9%2C7%2C9%2C3%7D%7D + Assert.AreEqual(98, π.Determinant); + } + + [Test] + public void EightByEightDeterminantTests() + { + var a = new SquareMatrix( 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, + 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 32, 44, 45, 46, 47, 48, + 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 63, 64); + + Assert.AreEqual(0, a.Determinant); + + var π = new SquareMatrix(3, 1, 4, 1, 5, 9, 2, 6, + 5, 3, 5, 8, 9, 7, 9, 3, + 2, 3, 8, 4, 6, 2, 6, 4, + 3, 3, 8, 3, 2, 7, 9, 5, + 0, 2, 8, 8, 4, 1, 9, 7, + 1, 6, 9, 3, 9, 9, 3, 7, + 5, 1, 0, 5, 8, 2, 0, 9, + 7, 4, 9, 4, 4, 5, 9, 2); + + // Verified against http://www.wolframalpha.com/input/?i=det+%7B%7B3%2C1%2C4%2C1%2C5%2C9%2C2%2C6%7D%2C%7B5%2C3%2C5%2C8%2C9%2C7%2C9%2C3%7D%2C%7B2%2C3%2C8%2C4%2C6%2C2%2C6%2C4%7D%2C%7B3%2C3%2C8%2C3%2C2%2C7%2C9%2C5%7D%2C%7B0%2C2%2C8%2C8%2C4%2C1%2C9%2C7%7D%2C%7B1%2C6%2C9%2C3%2C9%2C9%2C3%2C7%7D%2C%7B5%2C1%2C0%2C5%2C8%2C2%2C0%2C9%7D%2C%7B7%2C4%2C9%2C4%2C4%2C5%2C9%2C2%7D%7D + Assert.AreEqual(1378143, π.Determinant); + } + + [Test] + public void EqualsTest() + { + var a = new SquareMatrix(1, 2, + 3, 4); + + var b = new SquareMatrix(1, 2, + 3, 4); + + Assert.IsTrue(a == b); + Assert.AreEqual(a, b); + + var c = new Matrix(2, 3, + 1, 2, 3, + 4, 5, 6); + + var d = new Matrix(2, 3, + 1, 2, 3, + 4, 5, 6); + + Assert.IsTrue(c == d); + Assert.AreEqual(c, d); + + var e = new Matrix(3, 2, + 1, 4, + 2, 5, + 3, 6); + + var f = e.Transpose; + Assert.IsTrue(d == f); + Assert.AreEqual(d, f); + Assert.AreEqual(d.GetHashCode(), f.GetHashCode()); + + } + + [Test] + public void AdjugateTests() + { + // From Wikipedia: http://en.wikipedia.org/wiki/Adjugate_matrix + + var a = new SquareMatrix(1, 2, + 3, 4); + + var b = new SquareMatrix( 4, -2, + -3, 1); + + Assert.AreEqual(b, a.Adjugate); + + + var c = new SquareMatrix(-3, 2, -5, + -1, 0, -2, + 3, -4, 1); + + var d = new SquareMatrix(-8, 18, -4, + -5, 12, -1, + 4, -6, 2); + + Assert.AreEqual(d, c.Adjugate); + } + + [Test] + public void InverseTests() + { + // see http://www.mathwords.com/i/inverse_of_a_matrix.htm + var a = new SquareMatrix(4, 3, + 3, 2); + + var b = new SquareMatrix(-2, 3, + 3, -4); + + var aInverse = a.Inverse; + Assert.AreEqual(b, aInverse); + + var identity2x2 = new IdentityMatrix(2); + + var aaInverse = a * aInverse; + Assert.IsTrue(identity2x2 == aaInverse); + + var c = new SquareMatrix(1, 2, 3, + 0, 4, 5, + 1, 0, 6); + + var cInverse = c.Inverse; + var d = (1.0 / 22) * new SquareMatrix(24, -12, -2, + 5, 3, -5, + -4, 2, 4); + + + Assert.IsTrue(d == cInverse); + var identity3x3 = new IdentityMatrix(3); + + var ccInverse = c * cInverse; + Assert.IsTrue(identity3x3 == ccInverse); + } + } +} \ No newline at end of file diff --git a/UnitTests/Properties/AssemblyInfo.cs b/UnitTests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..5e1fe1e --- /dev/null +++ b/UnitTests/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("UnitTests")] +[assembly: AssemblyDescription("Unit tests for Moserware.Skills")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("Jeff Moser")] +[assembly: AssemblyProduct("UnitTests")] +[assembly: AssemblyCopyright("Copyright © Jeff Moser 2010")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("ddd7d430-f9c0-45c8-9576-70418d766e1f")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/UnitTests/README.txt b/UnitTests/README.txt new file mode 100644 index 0000000..d8eea37 --- /dev/null +++ b/UnitTests/README.txt @@ -0,0 +1,23 @@ +These tests were written using NUnit 2.5.2 that is available for download +at: + +http://sourceforge.net/projects/nunit/files/NUnit%20Version%202/NUnit-2.5.2.9222.msi/download + +If you have a different version or setup, you'll need to update the path under +the UnitTests project properties by right clicking on UnitTests and then +click "properties" and then click the "debug" tab. The "start external program" +points to the NUnit test runner. + +I did it this way so you didn't need more than the express version of +Visual Studio to run. If you have a fancy test runner already, feel +free to use that. + +Additionally, it should be easy to update the tests to your tool +of choice. + +Finally, realize that these tests test *all* of the calculators +implementations. For that reason, they create a new instance of +a particular calculator. If you're using this code in your application, +you can just use the convenience helper class of "TrueSkillCalculator" +that has static methods. If you do that, you won't have to worry +about creating your own instances. \ No newline at end of file diff --git a/UnitTests/RankSorterTest.cs b/UnitTests/RankSorterTest.cs new file mode 100644 index 0000000..82ed457 --- /dev/null +++ b/UnitTests/RankSorterTest.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using Moserware.Skills; +using NUnit.Framework; + +namespace UnitTests +{ + [TestFixture] + public class RankSorterTest + { + [Test] + public void SortAlreadySortedTest() + { + IEnumerable people = new[] { "One", "Two", "Three" }; + int[] ranks = new[] { 1, 2, 3 }; + + RankSorter.Sort(ref people, ref ranks); + + CollectionAssert.AreEqual(new[] { "One", "Two", "Three" }, people); + CollectionAssert.AreEqual(new[] { 1, 2, 3 }, ranks); + } + + [Test] + public void SortUnsortedTest() + { + IEnumerable people = new[] { "Five", "Two1", "Two2", "One", "Four" }; + int[] ranks = new[] { 5, 2, 2, 1, 4 }; + + RankSorter.Sort(ref people, ref ranks); + + CollectionAssert.AreEqual(new[] { "One", "Two1", "Two2", "Four", "Five" }, people); + CollectionAssert.AreEqual(new[] { 1, 2, 2, 4, 5 }, ranks); + } + } +} \ No newline at end of file diff --git a/UnitTests/TrueSkill/DrawMarginTest.cs b/UnitTests/TrueSkill/DrawMarginTest.cs new file mode 100644 index 0000000..831c9c6 --- /dev/null +++ b/UnitTests/TrueSkill/DrawMarginTest.cs @@ -0,0 +1,27 @@ +using Moserware.Skills.TrueSkill; +using NUnit.Framework; + +namespace UnitTests.TrueSkill +{ + [TestFixture] + public class DrawMarginTest + { + private const double ErrorTolerance = .000001; + + [Test] + public void GetDrawMarginFromDrawProbabilityTest() + { + double beta = 25.0 / 6.0; + // The expected values were compared against Ralf Herbrich's implementation in F# + AssertDrawMargin(0.10, beta, 0.74046637542690541); + AssertDrawMargin(0.25, beta, 1.87760059883033); + AssertDrawMargin(0.33, beta, 2.5111010132487492); + } + + private static void AssertDrawMargin(double drawProbability, double beta, double expected) + { + double actual = DrawMargin.GetDrawMarginFromDrawProbability(drawProbability, beta); + Assert.AreEqual(expected, actual, ErrorTolerance); + } + } +} \ No newline at end of file diff --git a/UnitTests/TrueSkill/FactorGraphTrueSkillCalculatorTests.cs b/UnitTests/TrueSkill/FactorGraphTrueSkillCalculatorTests.cs new file mode 100644 index 0000000..1f15f23 --- /dev/null +++ b/UnitTests/TrueSkill/FactorGraphTrueSkillCalculatorTests.cs @@ -0,0 +1,22 @@ +using Moserware.Skills.TrueSkill; +using NUnit.Framework; + +namespace UnitTests.TrueSkill +{ + [TestFixture] + public class FactorGraphTrueSkillCalculatorTests + { + [Test] + public void FullFactorGraphCalculatorTests() + { + var calculator = new FactorGraphTrueSkillCalculator(); + + // We can test all classes + TrueSkillCalculatorTests.TestAllTwoPlayerScenarios(calculator); + TrueSkillCalculatorTests.TestAllTwoTeamScenarios(calculator); + TrueSkillCalculatorTests.TestAllMultipleTeamScenarios(calculator); + + TrueSkillCalculatorTests.TestPartialPlayScenarios(calculator); + } + } +} \ No newline at end of file diff --git a/UnitTests/TrueSkill/TrueSkillCalculatorTests.cs b/UnitTests/TrueSkill/TrueSkillCalculatorTests.cs new file mode 100644 index 0000000..33c0307 --- /dev/null +++ b/UnitTests/TrueSkill/TrueSkillCalculatorTests.cs @@ -0,0 +1,987 @@ +using Moserware.Skills; +using NUnit.Framework; + +namespace UnitTests.TrueSkill +{ + public static class TrueSkillCalculatorTests + { + private const double ErrorTolerance = 0.085; + + // These are the roll-up ones + + public static void TestAllTwoPlayerScenarios(SkillCalculator calculator) + { + TwoPlayerTestNotDrawn(calculator); + TwoPlayerTestDrawn(calculator); + OneOnOneMassiveUpsetDrawTest(calculator); + + TwoPlayerChessTestNotDrawn(calculator); + } + + public static void TestAllTwoTeamScenarios(SkillCalculator calculator) + { + OneOnTwoSimpleTest(calculator); + OneOnTwoDrawTest(calculator); + OneOnTwoSomewhatBalanced(calculator); + OneOnThreeDrawTest(calculator); + OneOnThreeSimpleTest(calculator); + OneOnSevenSimpleTest(calculator); + + TwoOnTwoSimpleTest(calculator); + TwoOnTwoUnbalancedDrawTest(calculator); + TwoOnTwoDrawTest(calculator); + TwoOnTwoUpsetTest(calculator); + + ThreeOnTwoTests(calculator); + + FourOnFourSimpleTest(calculator); + } + + public static void TestAllMultipleTeamScenarios(SkillCalculator calculator) + { + ThreeTeamsOfOneNotDrawn(calculator); + ThreeTeamsOfOneDrawn(calculator); + FourTeamsOfOneNotDrawn(calculator); + FiveTeamsOfOneNotDrawn(calculator); + EightTeamsOfOneDrawn(calculator); + EightTeamsOfOneUpset(calculator); + SixteenTeamsOfOneNotDrawn(calculator); + + TwoOnFourOnTwoWinDraw(calculator); + } + + public static void TestPartialPlayScenarios(SkillCalculator calculator) + { + OneOnTwoBalancedPartialPlay(calculator); + } + + //------------------- Actual Tests --------------------------- + // If you see more than 3 digits of precision in the decimal point, then the expected values calculated from + // F# RalfH's implementation with the same input. It didn't support teams, so team values all came from the + // online calculator at http://atom.research.microsoft.com/trueskill/rankcalculator.aspx + // + // All match quality expected values came from the online calculator + + // In both cases, there may be some discrepancy after the first decimal point. I think this is due to my implementation + // using slightly higher precision in GaussianDistribution. + + //------------------------------------------------------------------------------ + // Two Player Tests + //------------------------------------------------------------------------------ + + private static void TwoPlayerTestNotDrawn(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team(player1, gameInfo.DefaultRating); + var team2 = new Team(player2, gameInfo.DefaultRating); + var teams = Teams.Concat(team1, team2); + + var newRatings = calculator.CalculateNewRatings(gameInfo, teams, 1, 2); + + var player1NewRating = newRatings[player1]; + AssertRating(29.39583201999924, 7.171475587326186, player1NewRating); + + var player2NewRating = newRatings[player2]; + AssertRating(20.60416798000076, 7.171475587326186, player2NewRating); + + AssertMatchQuality(0.447, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void TwoPlayerTestDrawn(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team(player1, gameInfo.DefaultRating); + var team2 = new Team(player2, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2); + var newRatings = calculator.CalculateNewRatings(gameInfo, teams, 1, 1); + + var player1NewRating = newRatings[player1]; + AssertRating(25.0, 6.4575196623173081, player1NewRating); + + var player2NewRating = newRatings[player2]; + AssertRating(25.0, 6.4575196623173081, player2NewRating); + + AssertMatchQuality(0.447, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void TwoPlayerChessTestNotDrawn(SkillCalculator calculator) + { + // Inspired by a real bug :-) + var player1 = new Player(1); + var player2 = new Player(2); + var gameInfo = new GameInfo(1200.0, 1200.0 / 3.0, 200.0, 1200.0 / 300.0, 0.03); + + var team1 = new Team(player1, new Rating(1301.0007, 42.9232)); + var team2 = new Team(player2, new Rating(1188.7560, 42.5570)); + + var newRatings = calculator.CalculateNewRatings(gameInfo, Teams.Concat(team1, team2), 1, 2); + + var player1NewRating = newRatings[player1]; + AssertRating(1304.7820836053318, 42.843513887848658, player1NewRating); + + var player2NewRating = newRatings[player2]; + AssertRating(1185.0383099003536, 42.485604606897752, player2NewRating); + } + + private static void OneOnOneMassiveUpsetDrawTest(SkillCalculator calculator) + { + var player1 = new Player(1); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, gameInfo.DefaultRating); + + var player2 = new Player(2); + + var team2 = new Team() + .AddPlayer(player2, new Rating(50, 12.5)); + + var teams = Teams.Concat(team1, team2); + + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 1); + + // Winners + AssertRating(31.662, 7.137, newRatingsWinLose[player1]); + + // Losers + AssertRating(35.010, 7.910, newRatingsWinLose[player2]); + + AssertMatchQuality(0.110, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + //------------------------------------------------------------------------------ + // Two Team Tests + //------------------------------------------------------------------------------ + + private static void TwoOnTwoSimpleTest(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, gameInfo.DefaultRating) + .AddPlayer(player2, gameInfo.DefaultRating); + + var player3 = new Player(3); + var player4 = new Player(4); + + var team2 = new Team() + .AddPlayer(player3, gameInfo.DefaultRating) + .AddPlayer(player4, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 2); + + // Winners + AssertRating(28.108, 7.774, newRatingsWinLose[player1]); + AssertRating(28.108, 7.774, newRatingsWinLose[player2]); + + // Losers + AssertRating(21.892, 7.774, newRatingsWinLose[player3]); + AssertRating(21.892, 7.774, newRatingsWinLose[player4]); + + AssertMatchQuality(0.447, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void TwoOnTwoDrawTest(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, gameInfo.DefaultRating) + .AddPlayer(player2, gameInfo.DefaultRating); + + var player3 = new Player(3); + var player4 = new Player(4); + + var team2 = new Team() + .AddPlayer(player3, gameInfo.DefaultRating) + .AddPlayer(player4, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 1); + + // Winners + AssertRating(25, 7.455, newRatingsWinLose[player1]); + AssertRating(25, 7.455, newRatingsWinLose[player2]); + + // Losers + AssertRating(25, 7.455, newRatingsWinLose[player3]); + AssertRating(25, 7.455, newRatingsWinLose[player4]); + + AssertMatchQuality(0.447, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void TwoOnTwoUnbalancedDrawTest(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, new Rating(15, 8)) + .AddPlayer(player2, new Rating(20, 6)); + + var player3 = new Player(3); + var player4 = new Player(4); + + var team2 = new Team() + .AddPlayer(player3, new Rating(25, 4)) + .AddPlayer(player4, new Rating(30, 3)); + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 1); + + // Winners + AssertRating(21.570, 6.556, newRatingsWinLose[player1]); + AssertRating(23.696, 5.418, newRatingsWinLose[player2]); + + // Losers + AssertRating(23.357, 3.833, newRatingsWinLose[player3]); + AssertRating(29.075, 2.931, newRatingsWinLose[player4]); + + AssertMatchQuality(0.214, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void TwoOnTwoUpsetTest(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, new Rating(20, 8)) + .AddPlayer(player2, new Rating(25, 6)); + + var player3 = new Player(3); + var player4 = new Player(4); + + var team2 = new Team() + .AddPlayer(player3, new Rating(35, 7)) + .AddPlayer(player4, new Rating(40, 5)); + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 2); + + // Winners + AssertRating(29.698, 7.008, newRatingsWinLose[player1]); + AssertRating(30.455, 5.594, newRatingsWinLose[player2]); + + // Losers + AssertRating(27.575, 6.346, newRatingsWinLose[player3]); + AssertRating(36.211, 4.768, newRatingsWinLose[player4]); + + AssertMatchQuality(0.084, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void FourOnFourSimpleTest(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + var player3 = new Player(3); + var player4 = new Player(4); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, gameInfo.DefaultRating) + .AddPlayer(player2, gameInfo.DefaultRating) + .AddPlayer(player3, gameInfo.DefaultRating) + .AddPlayer(player4, gameInfo.DefaultRating); + + var player5 = new Player(5); + var player6 = new Player(6); + var player7 = new Player(7); + var player8 = new Player(8); + + var team2 = new Team() + .AddPlayer(player5, gameInfo.DefaultRating) + .AddPlayer(player6, gameInfo.DefaultRating) + .AddPlayer(player7, gameInfo.DefaultRating) + .AddPlayer(player8, gameInfo.DefaultRating); + + + var teams = Teams.Concat(team1, team2); + + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 2); + + // Winners + AssertRating(27.198, 8.059, newRatingsWinLose[player1]); + AssertRating(27.198, 8.059, newRatingsWinLose[player2]); + AssertRating(27.198, 8.059, newRatingsWinLose[player3]); + AssertRating(27.198, 8.059, newRatingsWinLose[player4]); + + // Losers + AssertRating(22.802, 8.059, newRatingsWinLose[player5]); + AssertRating(22.802, 8.059, newRatingsWinLose[player6]); + AssertRating(22.802, 8.059, newRatingsWinLose[player7]); + AssertRating(22.802, 8.059, newRatingsWinLose[player8]); + + AssertMatchQuality(0.447, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void OneOnTwoSimpleTest(SkillCalculator calculator) + { + var player1 = new Player(1); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, gameInfo.DefaultRating); + + var player2 = new Player(2); + var player3 = new Player(3); + + var team2 = new Team() + .AddPlayer(player2, gameInfo.DefaultRating) + .AddPlayer(player3, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 2); + + // Winners + AssertRating(33.730, 7.317, newRatingsWinLose[player1]); + + // Losers + AssertRating(16.270, 7.317, newRatingsWinLose[player2]); + AssertRating(16.270, 7.317, newRatingsWinLose[player3]); + + AssertMatchQuality(0.135, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void OneOnTwoSomewhatBalanced(SkillCalculator calculator) + { + var player1 = new Player(1); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, new Rating(40, 6)); + + var player2 = new Player(2); + var player3 = new Player(3); + + var team2 = new Team() + .AddPlayer(player2, new Rating(20, 7)) + .AddPlayer(player3, new Rating(25, 8)); + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 2); + + // Winners + AssertRating(42.744, 5.602, newRatingsWinLose[player1]); + + // Losers + AssertRating(16.266, 6.359, newRatingsWinLose[player2]); + AssertRating(20.123, 7.028, newRatingsWinLose[player3]); + + AssertMatchQuality(0.478, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void OneOnThreeSimpleTest(SkillCalculator calculator) + { + var player1 = new Player(1); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, gameInfo.DefaultRating); + + var player2 = new Player(2); + var player3 = new Player(3); + var player4 = new Player(4); + + var team2 = new Team() + .AddPlayer(player2, gameInfo.DefaultRating) + .AddPlayer(player3, gameInfo.DefaultRating) + .AddPlayer(player4, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 2); + + // Winners + AssertRating(36.337, 7.527, newRatingsWinLose[player1]); + + // Losers + AssertRating(13.663, 7.527, newRatingsWinLose[player2]); + AssertRating(13.663, 7.527, newRatingsWinLose[player3]); + AssertRating(13.663, 7.527, newRatingsWinLose[player4]); + + AssertMatchQuality(0.012, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void OneOnTwoDrawTest(SkillCalculator calculator) + { + var player1 = new Player(1); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, gameInfo.DefaultRating); + + var player2 = new Player(2); + var player3 = new Player(3); + + var team2 = new Team() + .AddPlayer(player2, gameInfo.DefaultRating) + .AddPlayer(player3, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 1); + + // Winners + AssertRating(31.660, 7.138, newRatingsWinLose[player1]); + + // Losers + AssertRating(18.340, 7.138, newRatingsWinLose[player2]); + AssertRating(18.340, 7.138, newRatingsWinLose[player3]); + + AssertMatchQuality(0.135, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void OneOnThreeDrawTest(SkillCalculator calculator) + { + var player1 = new Player(1); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, gameInfo.DefaultRating); + + var player2 = new Player(2); + var player3 = new Player(3); + var player4 = new Player(4); + + var team2 = new Team() + .AddPlayer(player2, gameInfo.DefaultRating) + .AddPlayer(player3, gameInfo.DefaultRating) + .AddPlayer(player4, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 1); + + // Winners + AssertRating(34.990, 7.455, newRatingsWinLose[player1]); + + // Losers + AssertRating(15.010, 7.455, newRatingsWinLose[player2]); + AssertRating(15.010, 7.455, newRatingsWinLose[player3]); + AssertRating(15.010, 7.455, newRatingsWinLose[player4]); + + AssertMatchQuality(0.012, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void OneOnSevenSimpleTest(SkillCalculator calculator) + { + var player1 = new Player(1); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, gameInfo.DefaultRating); + + var player2 = new Player(2); + var player3 = new Player(3); + var player4 = new Player(4); + var player5 = new Player(5); + var player6 = new Player(6); + var player7 = new Player(7); + var player8 = new Player(8); + + var team2 = new Team() + .AddPlayer(player2, gameInfo.DefaultRating) + .AddPlayer(player3, gameInfo.DefaultRating) + .AddPlayer(player4, gameInfo.DefaultRating) + .AddPlayer(player5, gameInfo.DefaultRating) + .AddPlayer(player6, gameInfo.DefaultRating) + .AddPlayer(player7, gameInfo.DefaultRating) + .AddPlayer(player8, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 2); + + // Winners + AssertRating(40.582, 7.917, newRatingsWinLose[player1]); + + // Losers + AssertRating(9.418, 7.917, newRatingsWinLose[player2]); + AssertRating(9.418, 7.917, newRatingsWinLose[player3]); + AssertRating(9.418, 7.917, newRatingsWinLose[player4]); + AssertRating(9.418, 7.917, newRatingsWinLose[player5]); + AssertRating(9.418, 7.917, newRatingsWinLose[player6]); + AssertRating(9.418, 7.917, newRatingsWinLose[player7]); + AssertRating(9.418, 7.917, newRatingsWinLose[player8]); + + AssertMatchQuality(0.000, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void ThreeOnTwoTests(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + var player3 = new Player(3); + + var team1 = new Team() + .AddPlayer(player1, new Rating(28, 7)) + .AddPlayer(player2, new Rating(27, 6)) + .AddPlayer(player3, new Rating(26, 5)); + + + var player4 = new Player(4); + var player5 = new Player(5); + + var team2 = new Team() + .AddPlayer(player4, new Rating(30, 4)) + .AddPlayer(player5, new Rating(31, 3)); + + var gameInfo = GameInfo.DefaultGameInfo; + + var teams = Teams.Concat(team1, team2); + var newRatingsWinLoseExpected = calculator.CalculateNewRatings(gameInfo, teams, 1, 2); + + // Winners + AssertRating(28.658, 6.770, newRatingsWinLoseExpected[player1]); + AssertRating(27.484, 5.856, newRatingsWinLoseExpected[player2]); + AssertRating(26.336, 4.917, newRatingsWinLoseExpected[player3]); + + // Losers + AssertRating(29.785, 3.958, newRatingsWinLoseExpected[player4]); + AssertRating(30.879, 2.983, newRatingsWinLoseExpected[player5]); + + var newRatingsWinLoseUpset = calculator.CalculateNewRatings(gameInfo, Teams.Concat(team1, team2), 2, 1); + + // Winners + AssertRating(32.012, 3.877, newRatingsWinLoseUpset[player4]); + AssertRating(32.132, 2.949, newRatingsWinLoseUpset[player5]); + + // Losers + AssertRating(21.840, 6.314, newRatingsWinLoseUpset[player1]); + AssertRating(22.474, 5.575, newRatingsWinLoseUpset[player2]); + AssertRating(22.857, 4.757, newRatingsWinLoseUpset[player3]); + + AssertMatchQuality(0.254, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + //------------------------------------------------------------------------------ + // Multiple Teams Tests + //------------------------------------------------------------------------------ + + private static void TwoOnFourOnTwoWinDraw(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team() + .AddPlayer(player1, new Rating(40,4)) + .AddPlayer(player2, new Rating(45,3)); + + var player3 = new Player(3); + var player4 = new Player(4); + var player5 = new Player(5); + var player6 = new Player(6); + + var team2 = new Team() + .AddPlayer(player3, new Rating(20, 7)) + .AddPlayer(player4, new Rating(19, 6)) + .AddPlayer(player5, new Rating(30, 9)) + .AddPlayer(player6, new Rating(10, 4)); + + var player7 = new Player(7); + var player8 = new Player(8); + + var team3 = new Team() + .AddPlayer(player7, new Rating(50,5)) + .AddPlayer(player8, new Rating(30,2)); + + + var teams = Teams.Concat(team1, team2, team3); + var newRatingsWinLose = calculator.CalculateNewRatings(gameInfo, teams, 1, 2, 2); + + // Winners + AssertRating(40.877, 3.840, newRatingsWinLose[player1]); + AssertRating(45.493, 2.934, newRatingsWinLose[player2]); + AssertRating(19.609, 6.396, newRatingsWinLose[player3]); + AssertRating(18.712, 5.625, newRatingsWinLose[player4]); + AssertRating(29.353, 7.673, newRatingsWinLose[player5]); + AssertRating(9.872, 3.891, newRatingsWinLose[player6]); + AssertRating(48.830, 4.590, newRatingsWinLose[player7]); + AssertRating(29.813, 1.976, newRatingsWinLose[player8]); + + AssertMatchQuality(0.367, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void ThreeTeamsOfOneNotDrawn(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + var player3 = new Player(3); + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team(player1, gameInfo.DefaultRating); + var team2 = new Team(player2, gameInfo.DefaultRating); + var team3 = new Team(player3, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2, team3); + var newRatings = calculator.CalculateNewRatings(gameInfo, teams, 1, 2, 3); + + var player1NewRating = newRatings[player1]; + AssertRating(31.675352419172107, 6.6559853776206905, player1NewRating); + + var player2NewRating = newRatings[player2]; + AssertRating(25.000000000003912, 6.2078966412243233, player2NewRating); + + var player3NewRating = newRatings[player3]; + AssertRating(18.324647580823971, 6.6559853776218318, player3NewRating); + + AssertMatchQuality(0.200, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void ThreeTeamsOfOneDrawn(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + var player3 = new Player(3); + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team(player1, gameInfo.DefaultRating); + var team2 = new Team(player2, gameInfo.DefaultRating); + var team3 = new Team(player3, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2, team3); + var newRatings = calculator.CalculateNewRatings(gameInfo, teams, 1, 1, 1); + + var player1NewRating = newRatings[player1]; + AssertRating(25.000, 5.698, player1NewRating); + + var player2NewRating = newRatings[player2]; + AssertRating(25.000, 5.695, player2NewRating); + + var player3NewRating = newRatings[player3]; + AssertRating(25.000, 5.698, player3NewRating); + + AssertMatchQuality(0.200, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void FourTeamsOfOneNotDrawn(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + var player3 = new Player(3); + var player4 = new Player(4); + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team(player1, gameInfo.DefaultRating); + var team2 = new Team(player2, gameInfo.DefaultRating); + var team3 = new Team(player3, gameInfo.DefaultRating); + var team4 = new Team(player4, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2, team3, team4); + + var newRatings = calculator.CalculateNewRatings(gameInfo, teams, 1, 2, 3, 4); + + var player1NewRating = newRatings[player1]; + AssertRating(33.206680965631264, 6.3481091698077057, player1NewRating); + + var player2NewRating = newRatings[player2]; + AssertRating(27.401454693843323, 5.7871629348447584, player2NewRating); + + var player3NewRating = newRatings[player3]; + AssertRating(22.598545306188374, 5.7871629348413451, player3NewRating); + + var player4NewRating = newRatings[player4]; + AssertRating(16.793319034361271, 6.3481091698144967, player4NewRating); + + AssertMatchQuality(0.089, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void FiveTeamsOfOneNotDrawn(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + var player3 = new Player(3); + var player4 = new Player(4); + var player5 = new Player(5); + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team(player1, gameInfo.DefaultRating); + var team2 = new Team(player2, gameInfo.DefaultRating); + var team3 = new Team(player3, gameInfo.DefaultRating); + var team4 = new Team(player4, gameInfo.DefaultRating); + var team5 = new Team(player5, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2, team3, team4, team5); + var newRatings = calculator.CalculateNewRatings(gameInfo, teams, 1, 2, 3, 4, 5); + + var player1NewRating = newRatings[player1]; + AssertRating(34.363135705841188, 6.1361528798112692, player1NewRating); + + var player2NewRating = newRatings[player2]; + AssertRating(29.058448805636779, 5.5358352402833413, player2NewRating); + + var player3NewRating = newRatings[player3]; + AssertRating(25.000000000031758, 5.4200805474429847, player3NewRating); + + var player4NewRating = newRatings[player4]; + AssertRating(20.941551194426314, 5.5358352402709672, player4NewRating); + + var player5NewRating = newRatings[player5]; + AssertRating(15.636864294158848, 6.136152879829349, player5NewRating); + + AssertMatchQuality(0.040, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void EightTeamsOfOneDrawn(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + var player3 = new Player(3); + var player4 = new Player(4); + var player5 = new Player(5); + var player6 = new Player(6); + var player7 = new Player(7); + var player8 = new Player(8); + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team(player1, gameInfo.DefaultRating); + var team2 = new Team(player2, gameInfo.DefaultRating); + var team3 = new Team(player3, gameInfo.DefaultRating); + var team4 = new Team(player4, gameInfo.DefaultRating); + var team5 = new Team(player5, gameInfo.DefaultRating); + var team6 = new Team(player6, gameInfo.DefaultRating); + var team7 = new Team(player7, gameInfo.DefaultRating); + var team8 = new Team(player8, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2, team3, team4, team5, team6, team7, team8); + var newRatings = calculator.CalculateNewRatings(gameInfo, teams, 1, 1, 1, 1, 1, 1, 1, 1); + + var player1NewRating = newRatings[player1]; + AssertRating(25.000, 4.592, player1NewRating); + + var player2NewRating = newRatings[player2]; + AssertRating(25.000, 4.583, player2NewRating); + + var player3NewRating = newRatings[player3]; + AssertRating(25.000, 4.576, player3NewRating); + + var player4NewRating = newRatings[player4]; + AssertRating(25.000, 4.573, player4NewRating); + + var player5NewRating = newRatings[player5]; + AssertRating(25.000, 4.573, player5NewRating); + + var player6NewRating = newRatings[player6]; + AssertRating(25.000, 4.576, player6NewRating); + + var player7NewRating = newRatings[player7]; + AssertRating(25.000, 4.583, player7NewRating); + + var player8NewRating = newRatings[player8]; + AssertRating(25.000, 4.592, player8NewRating); + + AssertMatchQuality(0.004, calculator.CalculateMatchQuality(gameInfo, teams)); + } + + private static void EightTeamsOfOneUpset(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + var player3 = new Player(3); + var player4 = new Player(4); + var player5 = new Player(5); + var player6 = new Player(6); + var player7 = new Player(7); + var player8 = new Player(8); + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team(player1, new Rating(10, 8)); + var team2 = new Team(player2, new Rating(15, 7)); + var team3 = new Team(player3, new Rating(20, 6)); + var team4 = new Team(player4, new Rating(25, 5)); + var team5 = new Team(player5, new Rating(30, 4)); + var team6 = new Team(player6, new Rating(35, 3)); + var team7 = new Team(player7, new Rating(40, 2)); + var team8 = new Team(player8, new Rating(45, 1)); + + var teams = Teams.Concat(team1, team2, team3, team4, team5, team6, team7, team8); + var newRatings = calculator.CalculateNewRatings(gameInfo, teams, 1, 2, 3, 4, 5, 6, 7, 8); + + var player1NewRating = newRatings[player1]; + AssertRating(35.135, 4.506, player1NewRating); + + var player2NewRating = newRatings[player2]; + AssertRating(32.585, 4.037, player2NewRating); + + var player3NewRating = newRatings[player3]; + AssertRating(31.329, 3.756, player3NewRating); + + var player4NewRating = newRatings[player4]; + AssertRating(30.984, 3.453, player4NewRating); + + var player5NewRating = newRatings[player5]; + AssertRating(31.751, 3.064, player5NewRating); + + var player6NewRating = newRatings[player6]; + AssertRating(34.051, 2.541, player6NewRating); + + var player7NewRating = newRatings[player7]; + AssertRating(38.263, 1.849, player7NewRating); + + var player8NewRating = newRatings[player8]; + AssertRating(44.118, 0.983, player8NewRating); + + AssertMatchQuality(0.000, calculator.CalculateMatchQuality(gameInfo,teams)); + } + + private static void SixteenTeamsOfOneNotDrawn(SkillCalculator calculator) + { + var player1 = new Player(1); + var player2 = new Player(2); + var player3 = new Player(3); + var player4 = new Player(4); + var player5 = new Player(5); + var player6 = new Player(6); + var player7 = new Player(7); + var player8 = new Player(8); + var player9 = new Player(9); + var player10 = new Player(10); + var player11 = new Player(11); + var player12 = new Player(12); + var player13 = new Player(13); + var player14 = new Player(14); + var player15 = new Player(15); + var player16 = new Player(16); + var gameInfo = GameInfo.DefaultGameInfo; + + var team1 = new Team(player1, gameInfo.DefaultRating); + var team2 = new Team(player2, gameInfo.DefaultRating); + var team3 = new Team(player3, gameInfo.DefaultRating); + var team4 = new Team(player4, gameInfo.DefaultRating); + var team5 = new Team(player5, gameInfo.DefaultRating); + var team6 = new Team(player6, gameInfo.DefaultRating); + var team7 = new Team(player7, gameInfo.DefaultRating); + var team8 = new Team(player8, gameInfo.DefaultRating); + var team9 = new Team(player9, gameInfo.DefaultRating); + var team10 = new Team(player10, gameInfo.DefaultRating); + var team11 = new Team(player11, gameInfo.DefaultRating); + var team12 = new Team(player12, gameInfo.DefaultRating); + var team13 = new Team(player13, gameInfo.DefaultRating); + var team14 = new Team(player14, gameInfo.DefaultRating); + var team15 = new Team(player15, gameInfo.DefaultRating); + var team16 = new Team(player16, gameInfo.DefaultRating); + + var newRatings = + calculator.CalculateNewRatings( + gameInfo, + Teams.Concat( + team1, team2, team3, team4, team5, + team6, team7, team8, team9, team10, + team11, team12, team13, team14, team15, + team16), + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); + + var player1NewRating = newRatings[player1]; + AssertRating(40.53945776946920, 5.27581643889050, player1NewRating); + + var player2NewRating = newRatings[player2]; + AssertRating(36.80951229454210, 4.71121217610266, player2NewRating); + + var player3NewRating = newRatings[player3]; + AssertRating(34.34726355544460, 4.52440328139991, player3NewRating); + + var player4NewRating = newRatings[player4]; + AssertRating(32.33614722608720, 4.43258628279632, player4NewRating); + + var player5NewRating = newRatings[player5]; + AssertRating(30.55048814671730, 4.38010805034365, player5NewRating); + + var player6NewRating = newRatings[player6]; + AssertRating(28.89277312234790, 4.34859291776483, player6NewRating); + + var player7NewRating = newRatings[player7]; + AssertRating(27.30952161972210, 4.33037679041216, player7NewRating); + + var player8NewRating = newRatings[player8]; + AssertRating(25.76571046519540, 4.32197078088701, player8NewRating); + + var player9NewRating = newRatings[player9]; + AssertRating(24.23428953480470, 4.32197078088703, player9NewRating); + + var player10NewRating = newRatings[player10]; + AssertRating(22.69047838027800, 4.33037679041219, player10NewRating); + + var player11NewRating = newRatings[player11]; + AssertRating(21.10722687765220, 4.34859291776488, player11NewRating); + + var player12NewRating = newRatings[player12]; + AssertRating(19.44951185328290, 4.38010805034375, player12NewRating); + + var player13NewRating = newRatings[player13]; + AssertRating(17.66385277391300, 4.43258628279643, player13NewRating); + + var player14NewRating = newRatings[player14]; + AssertRating(15.65273644455550, 4.52440328139996, player14NewRating); + + var player15NewRating = newRatings[player15]; + AssertRating(13.19048770545810, 4.71121217610273, player15NewRating); + + var player16NewRating = newRatings[player16]; + AssertRating(9.46054223053080, 5.27581643889032, player16NewRating); + } + + //------------------------------------------------------------------------------ + // Partial Play Tests + //------------------------------------------------------------------------------ + + private static void OneOnTwoBalancedPartialPlay(SkillCalculator calculator) + { + var gameInfo = GameInfo.DefaultGameInfo; + + var p1 = new Player(1); + var team1 = new Team(p1, gameInfo.DefaultRating); + + var p2 = new Player(2, 0.0); + var p3 = new Player(3, 1.00); + + var team2 = new Team() + .AddPlayer(p2, gameInfo.DefaultRating) + .AddPlayer(p3, gameInfo.DefaultRating); + + var teams = Teams.Concat(team1, team2); + var newRatings = calculator.CalculateNewRatings(gameInfo, teams, 1, 2); + var matchQuality = calculator.CalculateMatchQuality(gameInfo, teams); + + } + + //------------------------------------------------------------------------------ + // Helpers + //------------------------------------------------------------------------------ + + private static void AssertRating(double expectedMean, double expectedStandardDeviation, Rating actual) + { + Assert.AreEqual(expectedMean, actual.Mean, ErrorTolerance); + Assert.AreEqual(expectedStandardDeviation, actual.StandardDeviation, ErrorTolerance); + } + + private static void AssertMatchQuality(double expectedMatchQuality, double actualMatchQuality) + { + Assert.AreEqual(expectedMatchQuality, actualMatchQuality, 0.0005); + } + } +} \ No newline at end of file diff --git a/UnitTests/TrueSkill/TwoPlayerTrueSkillCalculatorTest.cs b/UnitTests/TrueSkill/TwoPlayerTrueSkillCalculatorTest.cs new file mode 100644 index 0000000..89bd38c --- /dev/null +++ b/UnitTests/TrueSkill/TwoPlayerTrueSkillCalculatorTest.cs @@ -0,0 +1,20 @@ +using Moserware.Skills.TrueSkill; +using NUnit.Framework; + +namespace UnitTests.TrueSkill +{ + [TestFixture] + public class TwoPlayerTrueSkillCalculatorTest + { + [Test] + public void TwoPlayerTrueSkillCalculatorTests() + { + var calculator = new TwoPlayerTrueSkillCalculator(); + + // We only support two players + TrueSkillCalculatorTests.TestAllTwoPlayerScenarios(calculator); + + // TODO: Assert failures for larger teams + } + } +} \ No newline at end of file diff --git a/UnitTests/TrueSkill/TwoTeamTrueSkillCalculatorTest.cs b/UnitTests/TrueSkill/TwoTeamTrueSkillCalculatorTest.cs new file mode 100644 index 0000000..af334b5 --- /dev/null +++ b/UnitTests/TrueSkill/TwoTeamTrueSkillCalculatorTest.cs @@ -0,0 +1,19 @@ +using Moserware.Skills.TrueSkill; +using NUnit.Framework; + +namespace UnitTests.TrueSkill +{ + [TestFixture] + public class TwoTeamTrueSkillCalculatorTest + { + [Test] + public void TwoTeamTrueSkillCalculatorTests() + { + var calculator = new TwoTeamTrueSkillCalculator(); + + // This calculator supports up to two teams with many players each + TrueSkillCalculatorTests.TestAllTwoPlayerScenarios(calculator); + TrueSkillCalculatorTests.TestAllTwoTeamScenarios(calculator); + } + } +} \ No newline at end of file diff --git a/UnitTests/UnitTests.csproj b/UnitTests/UnitTests.csproj new file mode 100644 index 0000000..ffde503 --- /dev/null +++ b/UnitTests/UnitTests.csproj @@ -0,0 +1,80 @@ + + + + Debug + AnyCPU + 9.0.30729 + 2.0 + {6F80946D-AC8B-4063-8588-96841C18BF0A} + Library + Properties + UnitTests + UnitTests + v3.5 + 512 + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + 3.5 + + + 3.5 + + + 3.5 + + + + + + + + + + + + + + + + + + + + + + {15AD1345-984C-48ED-AF9A-2EAB44E5AA2B} + Skills + + + + + + + + \ No newline at end of file diff --git a/UnitTests/UnitTests.csproj.user b/UnitTests/UnitTests.csproj.user new file mode 100644 index 0000000..68c3597 --- /dev/null +++ b/UnitTests/UnitTests.csproj.user @@ -0,0 +1,9 @@ + + + Program + C:\Program Files (x86)\NUnit 2.5.2\bin\net-2.0\nunit.exe + + + UnitTests.dll + + \ No newline at end of file