Skip to content

Commit

Permalink
Merge branch 'release/2.0.1'
Browse files Browse the repository at this point in the history
  • Loading branch information
asflierl committed Jun 5, 2022
2 parents dcac42b + 0189ea7 commit 5d5007a
Show file tree
Hide file tree
Showing 44 changed files with 12,080 additions and 862 deletions.
18 changes: 10 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ name: Continuous Integration

on:
pull_request:
branches: ['*']
branches: ['**']
push:
branches: ['*']
branches: ['**']

env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -22,19 +22,21 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest]
scala: [2.13.5]
java: [adopt@1.8]
scala: [3.1.2]
java: [temurin@11]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout current branch (full)
uses: actions/checkout@v2
with:
fetch-depth: 0

- name: Setup Java and Scala
uses: olafurpg/setup-scala@v10
- name: Setup Java (temurin@11)
if: matrix.java == 'temurin@11'
uses: actions/setup-java@v2
with:
java-version: ${{ matrix.java }}
distribution: temurin
java-version: 11

- name: Cache sbt
uses: actions/cache@v2
Expand All @@ -52,4 +54,4 @@ jobs:
run: sbt ++${{ matrix.scala }} githubWorkflowCheck

- name: Build project
run: sbt ++${{ matrix.scala }} test
run: sbt ++${{ matrix.scala }} test
2 changes: 1 addition & 1 deletion .github/workflows/clean.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ jobs:
printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size
ghapi -X DELETE $REPO/actions/artifacts/$id
done
done
done
8 changes: 8 additions & 0 deletions .scalafix.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
OrganizeImports {
groupedImports = Merge
groups = ["re:javax?\\.", "*", "scala."]
importSelectorsOrder = SymbolsFirst
importsOrder = SymbolsFirst
expandRelative = false
removeUnused = false
}
10 changes: 0 additions & 10 deletions .travis.yml

This file was deleted.

2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2015, Andreas Flierl <andreas@flierl.eu>
Copyright (c) 2022, Andreas Flierl <andreas@flierl.eu>

Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
Expand Down
220 changes: 180 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,71 +1,211 @@
# sglicko2 [![Build Status](https://github.com/asflierl/sglicko2/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/asflierl/sglicko2/actions?query=branch%3Amaster) [![Maven Central](https://img.shields.io/maven-central/v/eu.flierl/sglicko2_2.13)](https://search.maven.org/search?q=g:eu.flierl%20AND%20a:sglicko2_3) [![Join the chat at https://gitter.im/asflierl/sglicko2](https://badges.gitter.im/asflierl/sglicko2.svg)](https://gitter.im/asflierl/sglicko2?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
# sglicko2 [![Maven Central](https://img.shields.io/maven-central/v/eu.flierl/sglicko2_3)](https://search.maven.org/search?q=g:eu.flierl%20AND%20a:sglicko2_3) [![Build Status](https://github.com/asflierl/sglicko2/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/asflierl/sglicko2/actions?query=branch%3Amaster)

A small, simple & self-contained implementation of the [Glicko-2 rating algorithm](http://www.glicko.net/glicko.html) in Scala that also helps the user with maintaining a leaderboard and allows for custom scoring rules.
A small & simple implementation of the [Glicko-2 rating algorithm](http://www.glicko.net/glicko.html) in Scala.

### Setup
## Features

Version 1.7.1 is currently available for Scala 3 and 2.13, targetting Java 8.
- helps with maintaining a player leaderboard
- any type (with proper equality, like `String` or `UUID` etc.) can be used to identify a player
- allows custom scoring rules (e.g. more than 2 opponents per game, point difference instead of ternary win/loss/draw)
- rating scale can be switched between Glicko and Glicko-2
- checks important constraints – where possible, at compile-time
- only depends on the Scala standard library

## Setup

To use this library in your [SBT](http://scala-sbt.org) project, add the following to your build definition:
Version 2.0.1 is currently available for Scala 3, targetting Java 11.

To use this library in your [SBT](http://scala-sbt.org) project, add the following to your build definition:

```scala
libraryDependencies += "eu.flierl" %% "sglicko2" % "1.7.1"
libraryDependencies += "eu.flierl" %% "sglicko2" % "2.0.1"
```

### Usage
## Usage

Here's a simple, runnable example on how the library can be used. You can [experiment with it right in your browser (using Scastie)](https://scastie.scala-lang.org/asflierl/Rh8aKj7aTNapEE163WYyHA/7).
### Basics
Here's a simple, runnable example on how the library can be used. You can [experiment with it right in your browser (using Scastie)](https://scastie.scala-lang.org/asflierl/e7d2vFTpTFqxq85sIx6WDQ).

```scala
import sglicko2._, EitherOnePlayerWinsOrItsADraw._
import sglicko2.*, WinOrDraw.Ops.*

@main def run: Unit =
given Glicko2 = Glicko2()

Leaderboard.empty[String]
.after(RatingPeriod(
"Abby" winsVs "Becky",
"Abby" winsVs "Chas",
"Abby" winsVs "Dave",
"Chas" winsVs "Becky",
"Becky" tiesWith "Dave",
"Dave" winsVs "Chas"))
.rankedPlayers
.foreach(p => println(
f"${p.rank}%2d ${p.player.id}%5s: " +
f"[${p.player.confidence95.lower.value}%4.0f, " +
f"${p.player.confidence95.upper.value}%4.0f]"))
```

Output:
```
1 Abby: [1353, 2246]
2 Dave: [1054, 1946]
3 Chas: [ 954, 1846]
4 Becky: [ 854, 1747]
```

As recommended in [the original Glicko paper](http://www.glicko.net/glicko/glicko.pdf), the rating of the players is reported as a 95% confidence interval here instead of using the median / middle value (rating). So in this example, we're fairly confident that Abby's actual playing strength lies somewhere between 1353 and 2246, i.e. a player's rating is the median of the probability distribution and should not be treated like an accurate measure of actual playing strength.


object Example extends App {
val glicko2 = new Glicko2[String, EitherOnePlayerWinsOrItsADraw]
### Changing parameters

val ratingPeriod = glicko2.newRatingPeriod.withGames(
("Abby", "Becky", Player1Wins),
("Abby", "Chas", Player1Wins),
("Abby", "Dave", Player1Wins),
("Becky", "Chas", Player2Wins),
("Becky", "Dave", Draw),
("Chas", "Dave", Player2Wins))
[The Glicko-2 paper](http://www.glicko.net/glicko/glicko2.pdf) mentions two parameters that you might want to tweak to fit your particular application:

val leaderboard = glicko2.updatedLeaderboard(glicko2.newLeaderboard, ratingPeriod)
> The system constant, τ, which constrains the change in volatility over time, needs to be set prior to application of the system. Reasonable choices are between 0.3 and 1.2, though the system should be tested to decide which value results in greatest predictive accuracy. Smaller values of τ prevent the volatility measures from changing by large amounts, which in turn prevent enormous changes in ratings based on very improbable results. If the application of Glicko-2 is expected to involve extremely improbable collections of game outcomes, then τ should be set to a small value, even as small as, say, τ = 0.2.
def pretty(r: RankedPlayer[String]) =
f"${r.rank}%2d ${r.player.id}%5s ${r.player.rating}%4.0f " +
f"${r.player.deviation * 2d}%4.0f)"
and

leaderboard.rankedPlayers map pretty foreach println
}
> If the player is unrated, [...] set the player's volatility to 0.06 (this value depends on the particular application).
Both values can be set when creating the `Glicko2` instance:

```scala
given Glicko2 = Glicko2(tau = Tau[0.6d], defaultVolatility = Volatility(0.06d))
```

Output:
### Notes on syntax

For the most commonly used scoring mode (win/loss/draw), there are three syntax alternatives. Choose whichever you like best:

```scala
import sglicko2.WinOrDraw.*

// (1)
Win("Abby", "Becky")
Draw("Abby", "Becky")

import sglicko2.WinOrDraw.Ops.*

// (2)
"Abby" winsVs "Becky"
"Abby" tiesWith "Becky"

// (3)
"Abby" :>: "Becky"
"Abby" :=: "Becky"
```

The types `Tau` (the Glicko-2 system constant) and `Score` have constraints that can be checked at compile- or runtime.

```scala
Tau[-2d] // will not compile: tau must be > 0
Tau[0.6d] // will compile and return a Tau of 0.6
Tau(-2d) // will compile but return a Left[Err, ...] at runtime
Tau(0.6d) // will compile and return a Right[..., Tau] at runtime

Score[2d] // will not compile: score must be in [0d, 1d]
Score[0.5d] // will compile and return a Score of 0.5
Score(2d) // will compile and return a Score of 1
Score(-1d) // will compile and return a Score of 0
```

For more cases, check `src/test/scala/sglicko2/ConstraintsSpec.scala`.

### Choosing a scale

Technically, Glicko-2 uses its own scale that is centered around 0. However, it is not commonly used and even the paper uses the original Glicko scale that is centered around 1500 in its examples and converts to (and from) its internal scale at the beginning (and end) of the algorithm specification.

Nevertheless, this library works with the Glicko-2 scale throughout and only converts to an implictly given "output" scale, whenever the user converts a `Rating` or `Deviation` to a `Double`. An unfortunate side-effect of this is that relying on `.toString` to show the values will use the Glicko-2 scale, which is usually not what you want. Instead, you should always use the `.value` methods of these types as shown in the simple example at the beginning of this document.

You can select a different scale (Glicko is the default) when creating the `Glicko2` instance:

```scala
given Glicko2 = Glicko2(scale = Scale.Glicko)
```
1 Abby 1800 (± 455)
2 Dave 1500 (± 455)
3 Chas 1400 (± 455)
4 Becky 1300 (± 455)

Of course, you can always supply the `Scale` implicitly yourself, in case you don't have (or don't want to have) a `Glicko2` instance in scope.

### Identifying players

Many of this library's types are parameterized with a type parameter `A` to allow for any custom type to identify players. This can be as simple as a `String` or more general like a `java.util.UUID`. The only requirement is that the type implements a proper equality (i.e. `.equals(...)` method) and provides an instance of `Eq[A]` (which is just an alias for `scala.CanEqual[A, A]`) to signal that values of this type can safely be compared to each other.

The Scala standard library provides that for primitive types and some very common types. If you use your own type, you will have to provide this typeclass instance as well.

**Note:** for performance reasons, the type you use to identify players should have a performant (and of course correct) implementation of `.hashCode`. Again, this is automatically the case for primitive types, `String`, `UUID` and tuples / products / case classes of these types etc. but for your own classes, some attention to this is advised.

### Implementing custom games and scoring rules

Any type `B` can be used in a `RatingPeriod` to represent a game, as long as there is an instance of `ScoringRules[A, B]` is in implicit scope, where `A` is any type you use to identify a player. The job of scoring rules is to "explain" what an outcome of a game means "in Glicko-2 terms". The Glicko-2 algorithm expects input as three values:

1. the player that is being rated
2. their opponent
3. a score for the player being rated, given as any (fractional) value between 0 and 1, where 0 is a loss, 1 is a win and 0.5 is a draw

#### Provided implementation

Let's look at the most basic scoring rules as an example, where either one player wins or the game is a draw. This is implemented in `src/main/scala/sglicko2/WinOrDraw.scala`:

```scala
given [A: Eq]: ScoringRules[A, WinOrDraw[A]] with
def gameScores(g: WinOrDraw[A]): Iterable[(A, A, Score)] = Some(g match
case Win(winner, loser) => (winner, loser, Score[1d])
case Draw(player1, player2) => (player1, player2, Score[0.5d]))
```

You can find more example code in the test sources. The main sources should be very easy to understand, too, so don't hesitate to look at those if you have questions.
So in a single game with two players, represented e.g. by `Win("Abby", "Becky")` (Abby wins against Becky), these scoring rules will "translate" that single outcome as if Abby is being rated:

Also, if you use this library, I'd love to hear from you. Thanks <3
1. `"Abby"`
2. `"Becky"`
3. `1.0d`

### Note on earlier versions
This will be used to update the rating of Abby and her position on the leaderboard. But the second player also needs to be rated. Therefore the above values will be automatically "inverted" for the second player like this:

Earlier versions of this library are hosted on Bintray and will be available until February 1st 2022 after which Bintray/JCenter will be [retired by JFrog and completely go out of service](https://jfrog.com/blog/into-the-sunset-bintray-jcenter-gocenter-and-chartcenter/).
1. `"Becky"`
2. `"Abby"`
3. `0.0d`

**If you are still depending on these versions consider migrating as soon as possible before your build breaks in 2022.**
This "inversion" is not the responsibility of the scoring rules so you don't have to worry about it.

Versions 1.6 and 1.7 are available for Scala 2.13.
#### Games with more than 2 players

The last version to support Scala 2.11 and 2.12 was 1.5. The last version to support Scala 2.10 was 1.3.
Although, not mentioned in the paper, an interesting property of Glicko-2 is that it supports breaking down games with more than two opposing players / teams into several games of exactly two players / teams. Here's a sketch of how that could look for three players / teams:

Access them by adding the JCenter repo to your build along with the actual dependency:
```scala
case class Outcome(winner: String, second: String, last: String)

given ScoringRules[String, Outcome] with
def gameScores(o: Outcome): Iterable[(String, String, Score)] = Seq(
(o.winner, o.second, Score[1d]),
(o.winner, o.last, Score[1d]),
(o.second, o.last, Score[1d]))
```

This is essentially saying that a three-player game where Abby wins, Becky takes second place and Chas is last can be represented as three two-player games: Abby wins vs Becky, Abby wins vs Chas and Becky wins vs Chas.

#### More accurate score

One detail we can glean from the paper is that we are not limited to the three values 0, 0.5 and 1 to represent the outcome of a game. Any fractional value between 0 and 1 is fine and the Glicko-2 was made with that in mind. Considering e.g. soccer games, winning 7 : 0 is certainly a bigger win than winning 2 : 1.

Calculating a good score for these scenarios can get involved but a neat formula that works for any positive point-based outcomes was used by the Guild Wars 2 team to calculating server ratings for their "world vs world" game mode (see `src/test/scala/sglicko2/GW2ExampleSpec.scala` and `src/test/scala/sglicko2/GW2ExampleResources.scala` for more details).

Implementing the gist of that as `ScoringRules` would look like this:

```scala
resolvers += Resolver.jcenterRepo
libraryDependencies += "sglicko2" %% "sglicko2" % "1.7"
```
import scala.math.{sin, Pi}

case class Outcome(name1: String, points1: Long, name2: String, points2: Long)

given ScoringRules[String, Outcome] with
def gameScores(o: Outcome): Iterable[(String, String, Score)] =
Some(o.name1, o.name2, rateAVsB(o.points1.toDouble, o.points2.toDouble))

def rateAVsB(a: Double, b: Double) =
Score((sin((a / (a + b) - 0.5d) * Pi) + 1d) * 0.5d)
```

When calculating a custom `Score` like this, be careful that your formula does not produce `NaN` values for plausible outcomes of your game. 😇

## Enjoy!

If you use this library, I'd love to hear from you. Please feel free to reach out with feature requests or if the API is unclear. 💖
Loading

0 comments on commit 5d5007a

Please sign in to comment.