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 33 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
9 changes: 5 additions & 4 deletions modules/puzzle/src/main/PuzzleFinisher.scala
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,12 @@ final private[puzzle] class PuzzleFinisher(
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(
val ratings = Set(u1, u2)
val results = glicko2.BinaryRatingPeriodResults(
List(
if win.yes then glicko2.GameResult(u1, u2, false)
else glicko2.GameResult(u2, u1, false)
if win.yes then glicko2.BinaryResult(u1, u2, false)
else glicko2.BinaryResult(u2, u1, false)
ornicar marked this conversation as resolved.
Show resolved Hide resolved
)
)
try calculator.updateRatings(results)
try calculator.updateRatings(ratings, 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
4 changes: 4 additions & 0 deletions modules/rating/src/main/glicko2/Rating.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ final class Rating(
*/
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) =
Expand Down
42 changes: 29 additions & 13 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,15 +49,17 @@ 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
def updateRatings(
players: Iterable[Rating],
results: RatingPeriodResults[?],
skipDeviationIncrease: Boolean = false
) =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is strange, or I'm misunderstanding something.

This updateRatings function now takes an additional argument players.

That info used to come from results which also contains the players as results.players.

Which means there are now 2 sources of truth for the players. Both players and results.players.

I see that results.players has been made private and is completely unused (?!) but it is still a code smell in my opinion, and a potential source of bugs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, this PR exposes what in my mind was previously a code smell: RatingCalculator.scala looking at results.players to figure out "which ratings need to be updated?" was unnecessarily complex (besides that previous code being confusing in other ways with trying to figure out if a player played any games in that RatingPeriodResults or not).

Tutor could have exercises with fixed ratings: in fact, in chess literature (books, magazines, CDs, other training materials) exercises are provided with "if you get this score, we estimate your rating is X." But this PR might be prematurely forcing us to make that design decision.

Copy link
Contributor Author

@ddugovic ddugovic Nov 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For others' benefit; we discussed on stream that we don't think results.players needs to exist, but it's an artifact from a previous reference implementation prematurely optimized straight from java hell. https://xkcd.com/2347/
image

Copy link
Contributor Author

@ddugovic ddugovic Nov 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding result.players, class Result is perhaps the world's least efficient implementation mapping Pair[Rating, Pair[OpponentRating, Score]] or maybe Map[Rating, Pair[OpponentRating, Score]] (for a pair of players playing a game, for a player and a puzzle, or for a player attempting a fixed-rating tutor exercise). At present my head hurts trying to think about it.

Copy link
Contributor Author

@ddugovic ddugovic Nov 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait... Result should actually be Tuple[OpponentRating, Score] and there's no need for any functions. Sure, a game has a winner and a loser, so a game should produce a Pair[Result].

players.foreach { player =>
val elapsedRatingPeriods = if skipDeviationIncrease then 0 else 1
if results.getResults(player).sizeIs > 0 then
Expand Down Expand Up @@ -175,13 +185,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, player)),
result
.getOpponent(player)
.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage, player).negate),
result.getOpponent(player).getGlicko2RatingDeviation
)
* (1.0 - E(
player.getGlicko2Rating,
result.getOpponent(player).getGlicko2Rating,
player.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage, player)),
result
.getOpponent(player)
.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage, player).negate),
result.getOpponent(player).getGlicko2RatingDeviation
)))
1 / v
Expand All @@ -202,8 +216,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, player)),
result
.getOpponent(player)
.getGlicko2RatingWithAdvantage(result.getAdvantage(advantage, player).negate),
result.getOpponent(player).getGlicko2RatingDeviation
)))
outcomeBasedRating
Expand Down
8 changes: 5 additions & 3 deletions modules/rating/src/main/glicko2/RatingPeriodResults.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ package lila.rating.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

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

class FloatingRatingPeriodResults(val results: List[FloatingResult])
final class FloatingRatingPeriodResults(val results: List[FloatingResult])
extends RatingPeriodResults[FloatingResult]

final class GameRatingPeriodResults(val results: List[DuelResult]) extends RatingPeriodResults[DuelResult]
62 changes: 32 additions & 30 deletions modules/rating/src/main/glicko2/Result.scala
Original file line number Diff line number Diff line change
@@ -1,51 +1,53 @@
package lila.rating.glicko2

import chess.Outcome

trait Result:

def getScore(player: Rating): Double
val first: Rating
val second: Rating

def getAdvantage(advantage: ColorAdvantage, player: Rating): ColorAdvantage =
if player == first then advantage.map(_ / 2.0d) else advantage.map(_ / 2.0d).negate

def getOpponent(player: Rating): Rating
def getScore(player: Rating): Double

def participated(player: Rating): Boolean
def getOpponent(player: Rating) = if player == first then second else first

def players: List[Rating]
def participated(player: Rating): Boolean = player == first || player == second

// score from 0 (opponent wins) to 1 (player wins)
class FloatingResult(player: Rating, opponent: Rating, score: Float) extends Result:
private def players: List[Rating] = List(first, second)

def getScore(p: Rating) = if p == player then score else 1 - score
final class BinaryResult(val first: Rating, val second: Rating, score: Boolean) extends Result:
private val POINTS_FOR_WIN = 1.0d
private val POINTS_FOR_LOSS = 0.0d

def getOpponent(p: Rating) = if p == player then opponent else player
def getScore(p: Rating): Double =
if p == first then if score then POINTS_FOR_WIN else POINTS_FOR_LOSS
else if score then POINTS_FOR_LOSS
else POINTS_FOR_WIN

def participated(p: Rating) = p == player || p == opponent
// ASSUME score is between unit interval [0.0d, 1.0d]
final class FloatingResult(val first: Rating, val second: Rating, score: Double) extends Result:
private val ONE_HUNDRED_PCT = 1.0d

def players = List(player, opponent)
def getScore(p: Rating): Double =
if p == first then score else ONE_HUNDRED_PCT - score

final class GameResult(winner: Rating, loser: Rating, isDraw: Boolean) extends Result:
final class DuelResult(val first: Rating, val second: Rating, outcome: Outcome) 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
private val POINTS_FOR_LOSS = 0.0d

/** Returns the "score" for a match.
*
* @param player
* @return
* 1 for a win, 0.5 for a draw and 0 for a loss
* 1 for a first player win, 0.5 for a draw and 0 for a first player 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"
def getScore(player: Rating): Double = outcome.winner match
case Some(chess.White) => if player == first then POINTS_FOR_WIN else POINTS_FOR_LOSS
case Some(chess.Black) => if player == first then POINTS_FOR_LOSS else POINTS_FOR_WIN
case _ => POINTS_FOR_DRAW

override def toString = s"$first vs $second = $outcome"
Loading