Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rate games accounting for first move advantage (fix #6818) #16281

Closed
wants to merge 53 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
d9d4b2a
Rate games with first move advantage (fix #6818)
ddugovic Oct 27, 2024
aa180ce
Code cleanup (reorder parameters)
ddugovic Oct 27, 2024
6194f07
Allow advantage even for BinaryResult
ddugovic Oct 27, 2024
1f2aff7
Code cleanup
ddugovic Oct 28, 2024
31ce96c
Code cleanup
ddugovic Oct 28, 2024
d22d638
Reduce first move advantage to 7.786 Elo
ddugovic Oct 30, 2024
8b5182f
Merge branch 'master' into glicko-first-move
ornicar Oct 30, 2024
759d6d4
Merge branch 'master' into glicko-first-move
ornicar Oct 30, 2024
e3ee697
Merge branch 'master' into glicko-first-move
ornicar Oct 30, 2024
3f6bbb6
sbt scalafmtAll
ornicar Oct 30, 2024
09692bf
Remove function getParticipants
ddugovic Oct 31, 2024
697a23b
sbt scalafmtAll
ddugovic Oct 31, 2024
421560c
Merge branch 'master' into glicko-first-move
ornicar Oct 31, 2024
da2b525
scala tweaks while reviewing
ornicar Oct 31, 2024
ef02272
type safety for glicko2.ColorAdvantage
ornicar Oct 31, 2024
d31bc4a
Rename GameResult to DuelResult
ddugovic Oct 31, 2024
300d191
Merge branch 'master' into glicko-first-move
ornicar Nov 1, 2024
2742c8c
Define crazyhouse first move advantage as 15.171 Elo
ddugovic Nov 2, 2024
887fea5
Merge branch 'master' into glicko-first-move
ornicar Nov 2, 2024
3e254fd
fix round perf updater doesn't use color advantages
ornicar Nov 2, 2024
8e89323
tweak scala imports
ornicar Nov 2, 2024
657c1e6
move color advantage constants
ornicar Nov 2, 2024
aaf28c4
Merge branch 'master' into glicko-first-move
ornicar Nov 3, 2024
e4195d4
Merge branch 'master' into glicko-first-move
ornicar Nov 4, 2024
49a6d9e
Revert tutor changes (WIP)
ddugovic Nov 17, 2024
69b6e34
Reintroduce FloatingResult
ddugovic Nov 17, 2024
41a260b
Fix compile errors
ddugovic Nov 17, 2024
9e31c15
Add code comment (lack of unit interval type)
ddugovic Nov 17, 2024
9ea827f
Merge branch 'master' into glicko-first-move
ornicar Nov 17, 2024
3e92964
unused code and scala syntax
ornicar Nov 17, 2024
f6e8bb4
better recover from erased users
ornicar Nov 17, 2024
850f2f8
rename Glicko.calculator
ornicar Nov 17, 2024
2774b03
use chess.Outcome for glicko Result
ornicar Nov 17, 2024
685e069
Delete bug (WIP)
ddugovic Nov 17, 2024
2f0db66
Delete bug (WIP)
ddugovic Nov 17, 2024
fad133d
Delete bug (WIP)
ddugovic Nov 17, 2024
6270982
Delete bug
ddugovic Nov 17, 2024
3083ac3
Attempt to fix type errors
ddugovic Nov 17, 2024
82a7c74
Attempt to fix type errors
ddugovic Nov 17, 2024
31543bd
Attempt to fix type errors
ddugovic Nov 17, 2024
32e91cf
Attempt to fix type errors
ddugovic Nov 17, 2024
3f71c58
Attempt to fix type errors
ddugovic Nov 18, 2024
85ecdac
Attempt to fix type errors
ddugovic Nov 18, 2024
f9e34be
Attempt to fix type errors
ddugovic Nov 18, 2024
d10fc58
Attempt to fix type errors
ddugovic Nov 18, 2024
0b4948e
scalafmtAll
ddugovic Nov 18, 2024
1bc6e04
Attempt to fix type errors
ddugovic Nov 18, 2024
aa374d3
Strongly type result color
ddugovic Nov 18, 2024
22a850a
Make RatingPeriodResults immutable
ddugovic Nov 18, 2024
e366158
Revert "Make RatingPeriodResults immutable"
ddugovic Nov 18, 2024
485d459
Make Rating immutable
ddugovic Nov 18, 2024
9aecdde
Rating facade WIP
ddugovic Nov 18, 2024
8b752af
Rating facade
ddugovic Nov 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions modules/puzzle/src/main/PuzzleFinisher.scala
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@ final private[puzzle] class PuzzleFinisher(
)
(round, none, perf)
case None =>
val userRating = perf.toRating
val puzzleRating = glicko2.Rating(
val userOldRating = perf.toRating
val puzzleOldRating = glicko2.Rating(
puzzle.glicko.rating.atLeast(lila.rating.Glicko.minRating.value),
puzzle.glicko.deviation,
puzzle.glicko.volatility,
puzzle.plays,
none
)
updateRatings(userRating, puzzleRating, win)
val (userRating, puzzleRating) = computeRatings(userOldRating, puzzleOldRating, win)
userApi
.dubiousPuzzle(me.userId, perf)
.map: dubiousPuzzleRating =>
Expand Down Expand Up @@ -204,12 +204,16 @@ final private[puzzle] class PuzzleFinisher(
def incPuzzlePlays(puzzleId: PuzzleId): Funit =
colls.puzzle.map(_.incFieldUnchecked($id(puzzleId), Puzzle.BSONFields.plays))

private def updateRatings(u1: glicko2.Rating, u2: glicko2.Rating, win: PuzzleWin): Unit =
val results = glicko2.GameRatingPeriodResults(
List(
if win.yes then glicko2.GameResult(u1, u2, false)
else glicko2.GameResult(u2, u1, false)
private def computeRatings(u1: glicko2.Rating, u2: glicko2.Rating, win: PuzzleWin): Set[glicko2.Rating] =
val results = glicko2.RatingPeriodResults[glicko2.BinaryResult](
u1 -> List(
if win.yes then glicko2.BinaryResult(u2, true)
else glicko2.BinaryResult(u2, false)
),
u2 -> List(
if win.yes then glicko2.BinaryResult(u1, false)
else glicko2.BinaryResult(u1, true)
)
)
try calculator.updateRatings(results)
try calculator.computeRatings(results)
catch case e: Exception => logger.error("finisher", e)
7 changes: 5 additions & 2 deletions modules/rating/src/main/Glicko.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import lila.core.perf.Perf
import lila.core.rating.Glicko
import lila.db.BSON
import lila.rating.PerfExt.*
import lila.rating.glicko2.ColorAdvantage

object GlickoExt:

Expand Down Expand Up @@ -80,10 +81,12 @@ object Glicko:
// rating that can be lost or gained with a single game
val maxRatingDelta = 700

val system = glicko2.RatingCalculator(glicko2.Tau.default, ratingPeriodsPerDay)
val calculator = glicko2.RatingCalculator(glicko2.Tau.default, ratingPeriodsPerDay)
def calculatorWithAdvantage(advantage: ColorAdvantage) =
glicko2.RatingCalculator(glicko2.Tau.default, ratingPeriodsPerDay, advantage)

def liveDeviation(p: Perf, reverse: Boolean): Double = {
system.previewDeviation(p.toRating, nowInstant, reverse)
calculator.previewDeviation(p.toRating, nowInstant, reverse)
}.atLeast(minDeviation).atMost(maxDeviation)

given glickoHandler: BSONDocumentHandler[Glicko] = new BSON[Glicko]:
Expand Down
44 changes: 25 additions & 19 deletions modules/rating/src/main/glicko2/Rating.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,33 @@ package lila.rating.glicko2

// rewrite from java https://github.com/goochjs/glicko2
final class Rating(
var rating: Double,
var ratingDeviation: Double,
var volatility: Double,
var numberOfResults: Int,
var lastRatingPeriodEnd: Option[java.time.Instant] = None
val rating: Double,
val ratingDeviation: Double,
val volatility: Double,
val numberOfResults: Int,
val lastRatingPeriodEnd: Option[java.time.Instant] = None
):

import RatingCalculator.*

// the following variables are used to hold values temporarily whilst running calculations
private[glicko2] var workingRating: Double = scala.compiletime.uninitialized
private[glicko2] var workingRatingDeviation: Double = scala.compiletime.uninitialized
private[glicko2] var workingVolatility: Double = scala.compiletime.uninitialized
private[glicko2] var workingRating: Double = 0d
private[glicko2] var workingRatingDeviation: Double = 0d
private[glicko2] var workingVolatility: Double = 0d

/** Return the average skill value of the player scaled down to the scale used by the algorithm's internal
* workings.
*/
def getGlicko2Rating: Double = convertRatingToGlicko2Scale(this.rating)

def getGlicko2RatingWithAdvantage(advantage: ColorAdvantage): Double = convertRatingToGlicko2Scale(
this.rating + advantage.value
)

/** Set the average skill value, taking in a value in Glicko2 scale.
*/
def setGlicko2Rating(r: Double) =
rating = convertRatingToOriginalGlickoScale(r)
private def setGlicko2Rating(r: Double) =
convertRatingToOriginalGlickoScale(r)

/** Return the rating deviation of the player scaled down to the scale used by the algorithm's internal
* workings.
Expand All @@ -33,20 +37,22 @@ final class Rating(

/** Set the rating deviation, taking in a value in Glicko2 scale.
*/
def setGlicko2RatingDeviation(rd: Double) =
ratingDeviation = convertRatingDeviationToOriginalGlickoScale(rd)
private def setGlicko2RatingDeviation(rd: Double) =
convertRatingDeviationToOriginalGlickoScale(rd)

/** Used by the calculation engine, to move interim calculations into their "proper" places.
*/
def finaliseRating() =
setGlicko2Rating(workingRating)
setGlicko2RatingDeviation(workingRatingDeviation)
volatility = workingVolatility
workingRatingDeviation = 0d
workingRating = 0d
workingVolatility = 0d
Rating(
convertRatingToOriginalGlickoScale(workingRating),
convertRatingDeviationToOriginalGlickoScale(workingRatingDeviation),
workingVolatility,
numberOfResults,
lastRatingPeriodEnd
)


override def toString = s"$rating / $ratingDeviation / $volatility / $numberOfResults"

def incrementNumberOfResults(increment: Int) =
numberOfResults = numberOfResults + increment
Rating(rating, ratingDeviation, volatility, numberOfResults + increment, lastRatingPeriodEnd)
50 changes: 32 additions & 18 deletions modules/rating/src/main/glicko2/RatingCalculator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ opaque type Tau = Double
object Tau extends OpaqueDouble[Tau]:
val default: Tau = 0.75d

opaque type ColorAdvantage = Double
object ColorAdvantage extends OpaqueDouble[ColorAdvantage]:
val zero: ColorAdvantage = 0d
val standard: ColorAdvantage = 7.786d
val crazyhouse: ColorAdvantage = 15.171d
extension (c: ColorAdvantage) def negate: ColorAdvantage = -c

opaque type RatingPeriodsPerDay = Double
object RatingPeriodsPerDay extends OpaqueDouble[RatingPeriodsPerDay]:
val default: RatingPeriodsPerDay = 0d
Expand All @@ -29,7 +36,8 @@ object RatingCalculator:

final class RatingCalculator(
tau: Tau = Tau.default,
ratingPeriodsPerDay: RatingPeriodsPerDay = RatingPeriodsPerDay.default
ratingPeriodsPerDay: RatingPeriodsPerDay = RatingPeriodsPerDay.default,
advantage: ColorAdvantage = ColorAdvantage.zero
):

import RatingCalculator.*
Expand All @@ -41,19 +49,19 @@ final class RatingCalculator(

private val ratingPeriodsPerMilli: Double = ratingPeriodsPerDay.value * DAYS_PER_MILLI

/** <p>Run through all players within a resultset and calculate their new ratings.</p> <p>Players within the
* resultset who did not compete during the rating period will have see their deviation increase (in line
* with Prof Glickman's paper).</p> <p>Note that this method will clear the results held in the association
* resultset.</p>
/** Run through all players within a resultset and calculate their new ratings. Players within the resultset
* who did not compete during the rating period will have see their deviation increase (in line with Prof
* Glickman's paper). Note that this method will clear the results held in the association resultset.
*
* @param results
*/
def updateRatings(results: RatingPeriodResults[?], skipDeviationIncrease: Boolean = false) =
val players = results.getParticipants
players.foreach { player =>
def computeRatings(
results: RatingPeriodResults[Result],
skipDeviationIncrease: Boolean = false
): Set[Rating] =
results.map { case (player, results) =>
val elapsedRatingPeriods = if skipDeviationIncrease then 0 else 1
if results.getResults(player).sizeIs > 0 then
calculateNewRating(player, results.getResults(player), elapsedRatingPeriods)
if results.sizeIs > 0 then calculateNewRating(player, results, elapsedRatingPeriods)
else
// if a player does not compete during the rating period, then only Step 6 applies.
// the player's rating and volatility parameters remain the same but deviation increases
Expand All @@ -64,7 +72,7 @@ final class RatingCalculator(
}

// now iterate through the participants and confirm their new ratings
players.foreach { _.finaliseRating() }
results.keySet.map { _.finaliseRating() }

/** This is the formula defined in step 6. It is also used for players who have not competed during the
* rating period.
Expand All @@ -91,7 +99,7 @@ final class RatingCalculator(
* @param results
* @param elapsedRatingPeriods
*/
def calculateNewRating(player: Rating, results: List[Result], elapsedRatingPeriods: Double): Unit =
def calculateNewRating(player: Rating, results: List[Result], elapsedRatingPeriods: Double): Rating =
val phi = player.getGlicko2RatingDeviation
val sigma = player.volatility
val a = Math.log(Math.pow(sigma, 2))
Expand Down Expand Up @@ -175,13 +183,17 @@ final class RatingCalculator(
for result <- results do
v = v + ((Math.pow(g(result.getOpponent(player).getGlicko2RatingDeviation), 2))
* E(
player.getGlicko2Rating,
result.getOpponent(player).getGlicko2Rating,
player.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage)),
result
.getOpponent(player)
.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage).negate),
result.getOpponent(player).getGlicko2RatingDeviation
)
* (1.0 - E(
player.getGlicko2Rating,
result.getOpponent(player).getGlicko2Rating,
player.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage)),
result
.getOpponent(player)
.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage).negate),
result.getOpponent(player).getGlicko2RatingDeviation
)))
1 / v
Expand All @@ -202,8 +214,10 @@ final class RatingCalculator(
outcomeBasedRating = outcomeBasedRating
+ (g(result.getOpponent(player).getGlicko2RatingDeviation)
* (result.getScore(player) - E(
player.getGlicko2Rating,
result.getOpponent(player).getGlicko2Rating,
player.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage)),
result
.getOpponent(player)
.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage).negate),
result.getOpponent(player).getGlicko2RatingDeviation
)))
outcomeBasedRating
Expand Down
12 changes: 4 additions & 8 deletions modules/rating/src/main/glicko2/RatingPeriodResults.scala
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
package lila.rating.glicko2

// rewrite from java https://github.com/goochjs/glicko2
trait RatingPeriodResults[R <: Result]():
val results: List[R]
def getResults(player: Rating): List[R] = results.filter(_.participated(player))
def getParticipants: Set[Rating] = results.flatMap(_.players).toSet
type RatingPeriodResults[R <: Result] = Map[Rating, List[R]]

class GameRatingPeriodResults(val results: List[GameResult]) extends RatingPeriodResults[GameResult]

class FloatingRatingPeriodResults(val results: List[FloatingResult])
extends RatingPeriodResults[FloatingResult]
object RatingPeriodResults:
def apply[R <: Result](a: (Rating, List[R])) = Map[Rating, List[R]](a)
def apply[R <: Result](a: (Rating, List[R]), b: (Rating, List[R])) = Map[Rating, List[R]](a, b)
53 changes: 11 additions & 42 deletions modules/rating/src/main/glicko2/Result.scala
Original file line number Diff line number Diff line change
@@ -1,51 +1,20 @@
package lila.rating.glicko2

trait Result:
import chess.{ Color, White }

def getScore(player: Rating): Double
// ASSUME score is between unit interval [0.0d, 1.0d]
class Result(opponent: Rating, score: Double):

def getOpponent(player: Rating): Rating
def getAdvantage(advantage: ColorAdvantage): ColorAdvantage = advantage.map(_ / 2.0d)

def participated(player: Rating): Boolean
def getScore(player: Rating): Double = score

def players: List[Rating]
def getOpponent(player: Rating): Rating = opponent

// score from 0 (opponent wins) to 1 (player wins)
class FloatingResult(player: Rating, opponent: Rating, score: Float) extends Result:
class BinaryResult(val opponent: Rating, val win: Boolean)
extends Result(opponent, if win then 1.0d else 0.0d)

def getScore(p: Rating) = if p == player then score else 1 - score
class DuelResult(val opponent: Rating, val score: Double, color: Color) extends Result(opponent, score):

def getOpponent(p: Rating) = if p == player then opponent else player

def participated(p: Rating) = p == player || p == opponent

def players = List(player, opponent)

final class GameResult(winner: Rating, loser: Rating, isDraw: Boolean) extends Result:
private val POINTS_FOR_WIN = 1.0d
private val POINTS_FOR_LOSS = 0.0d
private val POINTS_FOR_DRAW = 0.5d

def players = List(winner, loser)

def participated(player: Rating) = player == winner || player == loser

/** Returns the "score" for a match.
*
* @param player
* @return
* 1 for a win, 0.5 for a draw and 0 for a loss
* @throws IllegalArgumentException
*/
def getScore(player: Rating): Double =
if isDraw then POINTS_FOR_DRAW
else if winner == player then POINTS_FOR_WIN
else if loser == player then POINTS_FOR_LOSS
else throw new IllegalArgumentException("Player did not participate in match");

def getOpponent(player: Rating) =
if winner == player then loser
else if loser == player then winner
else throw new IllegalArgumentException("Player did not participate in match");

override def toString = s"$winner vs $loser = $isDraw"
override def getAdvantage(advantage: ColorAdvantage): ColorAdvantage =
if color == White then advantage.map(_ / 2.0d) else advantage.map(_ / 2.0d).negate
Loading
Loading