diff --git a/PHPSkills/Numerics/BasicMath.php b/PHPSkills/Numerics/BasicMath.php index b487d0e..37f8a1a 100644 --- a/PHPSkills/Numerics/BasicMath.php +++ b/PHPSkills/Numerics/BasicMath.php @@ -14,4 +14,11 @@ function square($x) { return $x * $x; } + +function sum($itemsToSum, $funcName ) +{ + $mappedItems = array_map($funcName, $itemsToSum); + return array_sum($mappedItems); +} + ?> \ No newline at end of file diff --git a/PHPSkills/TrueSkill/TruncatedGaussianCorrectionFunctions.php b/PHPSkills/TrueSkill/TruncatedGaussianCorrectionFunctions.php index 7789b44..2d331ef 100644 --- a/PHPSkills/TrueSkill/TruncatedGaussianCorrectionFunctions.php +++ b/PHPSkills/TrueSkill/TruncatedGaussianCorrectionFunctions.php @@ -103,7 +103,7 @@ class TruncatedGaussianCorrectionFunctions // the multiplicative correction of a double-sided truncated Gaussian with unit variance public static function wWithinMarginScaled($teamPerformanceDifference, $drawMargin, $c) { - return self::wWithinMargin(teamPerformanceDifference/c, drawMargin/c); + return self::wWithinMargin($teamPerformanceDifference/$c, $drawMargin/$c); } // From F#: @@ -119,7 +119,7 @@ class TruncatedGaussianCorrectionFunctions return 1.0; } - $vt = vWithinMargin($teamPerformanceDifferenceAbsoluteValue, $drawMargin); + $vt = self::vWithinMargin($teamPerformanceDifferenceAbsoluteValue, $drawMargin); return $vt*$vt + ( diff --git a/PHPSkills/TrueSkill/TwoPlayerTrueSkillCalculator.php b/PHPSkills/TrueSkill/TwoPlayerTrueSkillCalculator.php index 34928b3..dea523c 100644 --- a/PHPSkills/TrueSkill/TwoPlayerTrueSkillCalculator.php +++ b/PHPSkills/TrueSkill/TwoPlayerTrueSkillCalculator.php @@ -2,6 +2,7 @@ namespace Moserware\Skills\TrueSkill; +require_once(dirname(__FILE__) . "/../Guard.php"); require_once(dirname(__FILE__) . "/../PairwiseComparison.php"); require_once(dirname(__FILE__) . "/../RankSorter.php"); require_once(dirname(__FILE__) . "/../Rating.php"); @@ -16,6 +17,7 @@ require_once(dirname(__FILE__) . "/../Numerics/BasicMath.php"); require_once(dirname(__FILE__) . "/DrawMargin.php"); require_once(dirname(__FILE__) . "/TruncatedGaussianCorrectionFunctions.php"); +use Moserware\Skills\Guard; use Moserware\Skills\PairwiseComparison; use Moserware\Skills\RankSorter; use Moserware\Skills\Rating; @@ -44,7 +46,8 @@ class TwoPlayerTrueSkillCalculator extends SkillCalculator array $teams, array $teamRanks) { - // Basic argument checking + // Basic argument checking + Guard::argumentNotNull($gameInfo, "gameInfo"); $this->validateTeamCountAndPlayersCountPerTeam($teams); // Make sure things are in order @@ -136,7 +139,8 @@ class TwoPlayerTrueSkillCalculator extends SkillCalculator /// public function calculateMatchQuality($gameInfo, array $teams) - { + { + Guard::argumentNotNull($gameInfo, "gameInfo"); $this->validateTeamCountAndPlayersCountPerTeam($teams); $team1 = $teams[0]; diff --git a/PHPSkills/TrueSkill/TwoTeamTrueSkillCalculator.php b/PHPSkills/TrueSkill/TwoTeamTrueSkillCalculator.php new file mode 100644 index 0000000..5ce33e4 --- /dev/null +++ b/PHPSkills/TrueSkill/TwoTeamTrueSkillCalculator.php @@ -0,0 +1,222 @@ + +/// 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. +/// +class TwoTeamTrueSkillCalculator extends SkillCalculator +{ + public function __construct() + { + parent::__construct(SkillCalculatorSupportedOptions::NONE, TeamsRange::exactly(2), PlayersRange::atLeast(1)); + } + + public function calculateNewRatings($gameInfo, + array $teams, + array $teamRanks) + { + Guard::argumentNotNull($gameInfo, "gameInfo"); + $this->validateTeamCountAndPlayersCountPerTeam($teams); + + RankSorter::sort($teams, $teamRanks); + + $team1 = $teams[0]; + $team2 = $teams[1]; + + $wasDraw = ($teamRanks[0] == $teamRanks[1]); + + $results = new RatingContainer(); + + self::updatePlayerRatings($gameInfo, + $results, + $team1, + $team2, + $wasDraw ? PairwiseComparison::DRAW : PairwiseComparison::WIN); + + self::updatePlayerRatings($gameInfo, + $results, + $team2, + $team1, + $wasDraw ? PairwiseComparison::DRAW : PairwiseComparison::LOSE); + + return $results; + } + + private static function updatePlayerRatings(GameInfo $gameInfo, + RatingContainer &$newPlayerRatings, + Team $selfTeam, + Team $otherTeam, + $selfToOtherTeamComparison) + { + $drawMargin = DrawMargin::getDrawMarginFromDrawProbability($gameInfo->getDrawProbability(), + $gameInfo->getBeta()); + + $betaSquared = square($gameInfo->getBeta()); + $tauSquared = square($gameInfo->getDynamicsFactor()); + + $totalPlayers = $selfTeam->count() + $otherTeam->count(); + + $meanGetter = + function($currentRating) + { + return $currentRating->getMean(); + }; + + $selfMeanSum = sum($selfTeam->getAllRatings(), $meanGetter); + $otherTeamMeanSum = sum($otherTeam->getAllRatings(), $meanGetter); + + $varianceGetter = + function($currentRating) + { + return square($currentRating->getStandardDeviation()); + }; + + $c = sqrt( + sum($selfTeam->getAllRatings(), $varianceGetter) + + + sum($otherTeam->getAllRatings(), $varianceGetter) + + + $totalPlayers*$betaSquared); + + $winningMean = $selfMeanSum; + $losingMean = $otherTeamMeanSum; + + switch ($selfToOtherTeamComparison) + { + case PairwiseComparison::WIN: + case PairwiseComparison::DRAW: + // NOP + break; + case PairwiseComparison::LOSE: + $winningMean = $otherTeamMeanSum; + $losingMean = $selfMeanSum; + break; + } + + $meanDelta = $winningMean - $losingMean; + + if ($selfToOtherTeamComparison != PairwiseComparison::DRAW) + { + // non-draw case + $v = TruncatedGaussianCorrectionFunctions::vExceedsMarginScaled($meanDelta, $drawMargin, $c); + $w = TruncatedGaussianCorrectionFunctions::wExceedsMarginScaled($meanDelta, $drawMargin, $c); + $rankMultiplier = (int) $selfToOtherTeamComparison; + } + else + { + // assume draw + $v = TruncatedGaussianCorrectionFunctions::vWithinMarginScaled($meanDelta, $drawMargin, $c); + $w = TruncatedGaussianCorrectionFunctions::wWithinMarginScaled($meanDelta, $drawMargin, $c); + $rankMultiplier = 1; + } + + foreach ($selfTeam->getAllPlayers() as $selfTeamCurrentPlayer) + { + $previousPlayerRating = $selfTeam->getRating($selfTeamCurrentPlayer); + + $meanMultiplier = (square($previousPlayerRating->getStandardDeviation()) + $tauSquared)/$c; + $stdDevMultiplier = (square($previousPlayerRating->getStandardDeviation()) + $tauSquared)/square($c); + + $playerMeanDelta = ($rankMultiplier*$meanMultiplier*$v); + $newMean = $previousPlayerRating->getMean() + $playerMeanDelta; + + $newStdDev = + sqrt((square($previousPlayerRating->getStandardDeviation()) + $tauSquared)*(1 - $w*$stdDevMultiplier)); + + $newPlayerRatings->setRating($selfTeamCurrentPlayer, new Rating($newMean, $newStdDev)); + } + } + + /// + public function calculateMatchQuality($gameInfo, array $teams) + { + Guard::argumentNotNull($gameInfo, "gameInfo"); + $this->validateTeamCountAndPlayersCountPerTeam($teams); + + // We've verified that there's just two teams + $team1Ratings = $teams[0]->getAllRatings(); + $team1Count = count($team1Ratings); + + $team2Ratings = $teams[1]->getAllRatings(); + $team2Count = count($team2Ratings); + + $totalPlayers = $team1Count + $team2Count; + + $betaSquared = square($gameInfo->getBeta()); + + $meanGetter = + function($currentRating) + { + return $currentRating->getMean(); + }; + + $varianceGetter = + function($currentRating) + { + return square($currentRating->getStandardDeviation()); + }; + + $team1MeanSum = sum($team1Ratings, $meanGetter); + $team1StdDevSquared = sum($team1Ratings, $varianceGetter); + + $team2MeanSum = sum($team2Ratings, $meanGetter); + $team2SigmaSquared = sum($team2Ratings, $varianceGetter); + + // 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. + + $sqrtPart + = sqrt( + ($totalPlayers*$betaSquared) + / + ($totalPlayers*$betaSquared + $team1StdDevSquared + $team2SigmaSquared) + ); + + $expPart + = exp( + (-1*square($team1MeanSum - $team2MeanSum)) + / + (2*($totalPlayers*$betaSquared + $team1StdDevSquared + $team2SigmaSquared)) + ); + + return $expPart*$sqrtPart; + } +} +?> diff --git a/UnitTests/TrueSkill/TrueSkillCalculatorTests.php b/UnitTests/TrueSkill/TrueSkillCalculatorTests.php index d420ddd..b1cffa1 100644 --- a/UnitTests/TrueSkill/TrueSkillCalculatorTests.php +++ b/UnitTests/TrueSkill/TrueSkillCalculatorTests.php @@ -1,12 +1,14 @@ calculateMatchQuality($gameInfo, $teams)); } + private static function twoPlayerTestDrawn($testClass, SkillCalculator $calculator) + { + $player1 = new Player(1); + $player2 = new Player(2); + + $gameInfo = new GameInfo(); + + $team1 = new Team($player1, $gameInfo->getDefaultRating()); + $team2 = new Team($player2, $gameInfo->getDefaultRating()); + + $teams = Teams::concat($team1, $team2); + $newRatings = $calculator->calculateNewRatings($gameInfo, $teams, array(1, 1)); + + $player1NewRating = $newRatings->getRating($player1); + self::assertRating($testClass, 25.0, 6.4575196623173081, $player1NewRating); + + $player2NewRating = $newRatings->getRating($player2); + self::assertRating($testClass, 25.0, 6.4575196623173081, $player2NewRating); + + self::assertMatchQuality($testClass, 0.447, $calculator->calculateMatchQuality($gameInfo, $teams)); + } + + private static function twoPlayerChessTestNotDrawn($testClass, SkillCalculator $calculator) + { + // Inspired by a real bug :-) + $player1 = new Player(1); + $player2 = new Player(2); + $gameInfo = new GameInfo(1200.0, 1200.0 / 3.0, 200.0, 1200.0 / 300.0, 0.03); + + $team1 = new Team($player1, new Rating(1301.0007, 42.9232)); + $team2 = new Team($player2, new Rating(1188.7560, 42.5570)); + + $newRatings = $calculator->calculateNewRatings($gameInfo, Teams::concat($team1, $team2), array(1, 2)); + + $player1NewRating = $newRatings->getRating($player1); + self::assertRating($testClass, 1304.7820836053318, 42.843513887848658, $player1NewRating); + + $player2NewRating = $newRatings->getRating($player2); + self::assertRating($testClass, 1185.0383099003536, 42.485604606897752, $player2NewRating); + } + + private static function oneOnOneMassiveUpsetDrawTest($testClass, SkillCalculator $calculator) + { + $player1 = new Player(1); + + $gameInfo = new GameInfo(); + + $team1 = new Team($player1, $gameInfo->getDefaultRating()); + + $player2 = new Player(2); + + $team2 = new Team($player2, new Rating(50, 12.5)); + + $teams = Teams::concat($team1, $team2); + + $newRatingsWinLose = $calculator->calculateNewRatings($gameInfo, $teams, array(1, 1)); + + // Winners + self::assertRating($testClass, 31.662, 7.137, $newRatingsWinLose->getRating($player1)); + + // Losers + self::assertRating($testClass, 35.010, 7.910, $newRatingsWinLose->getRating($player2)); + + self::assertMatchQuality($testClass, 0.110, $calculator->calculateMatchQuality($gameInfo, $teams)); + } + + //------------------------------------------------------------------------------ + // Two Team Tests + //------------------------------------------------------------------------------ + + private static function twoOnTwoSimpleTest($testClass, SkillCalculator $calculator) + { + $player1 = new Player(1); + $player2 = new Player(2); + + $gameInfo = new GameInfo(); + + $team1 = new Team(); + $team1->addPlayer($player1, $gameInfo->getDefaultRating()); + $team1->addPlayer($player2, $gameInfo->getDefaultRating()); + + $player3 = new Player(3); + $player4 = new Player(4); + + $team2 = new Team(); + $team2->addPlayer($player3, $gameInfo->getDefaultRating()); + $team2->addPlayer($player4, $gameInfo->getDefaultRating()); + + $teams = Teams::concat($team1, $team2); + $newRatingsWinLose = $calculator->calculateNewRatings($gameInfo, $teams, array(1, 2)); + + // Winners + self::assertRating($testClass, 28.108, 7.774, $newRatingsWinLose->getRating($player1)); + self::assertRating($testClass, 28.108, 7.774, $newRatingsWinLose->getRating($player2)); + + // Losers + self::assertRating($testClass, 21.892, 7.774, $newRatingsWinLose->getRating($player3)); + self::assertRating($testClass, 21.892, 7.774, $newRatingsWinLose->getRating($player4)); + + self::assertMatchQuality($testClass, 0.447, $calculator->calculateMatchQuality($gameInfo, $teams)); + } + + + private static function assertRating($testClass, $expectedMean, $expectedStandardDeviation, $actual) { $testClass->assertEquals($expectedMean, $actual->getMean(), '', self::ERROR_TOLERANCE_TRUESKILL); diff --git a/UnitTests/TrueSkill/TwoTeamTrueSkillCalculatorTest.php b/UnitTests/TrueSkill/TwoTeamTrueSkillCalculatorTest.php new file mode 100644 index 0000000..cd9aac7 --- /dev/null +++ b/UnitTests/TrueSkill/TwoTeamTrueSkillCalculatorTest.php @@ -0,0 +1,27 @@ +addTest( new TwoTeamTrueSkillCalculatorTest("testTwoTeamTrueSkillCalculator")); + +\PHPUnit_TextUI_TestRunner::run($testSuite); +?>