ts-trueskill
Version:
Port of python trueskill package in TypeScript
426 lines (425 loc) • 17.3 kB
JavaScript
/* eslint-disable max-params */
import { add, det, exp, inv, matrix, matrix as mMatrix, multiply, transpose } from 'mathjs';
import { Gaussian } from 'ts-gaussian';
import { LikelihoodFactor, PriorFactor, SumFactor, TruncateFactor, Variable, } from './factorgraph.js';
import { Rating } from './rating.js';
/**
* Calculates a draw-margin from the given drawProbability
*/
export function calcDrawMargin(drawProbability, size, env = new TrueSkill()) {
return env.guassian.ppf((drawProbability + 1) / 2) * Math.sqrt(size) * env.beta;
}
/**
* Makes a size map of each teams.
*/
function _teamSizes(ratingGroups) {
const teamSizes = [0];
ratingGroups.map(group => teamSizes.push(group.length + teamSizes[teamSizes.length - 1]));
teamSizes.shift();
return teamSizes;
}
/**
* Implements a TrueSkill environment. An environment could have
* customized constants. Every games have not same design and may need to
* customize TrueSkill constants.
*
* For example, 60% of matches in your game have finished as draw then you
* should set ``draw_probability`` to 0.60
*
* const env = new TrueSkill(undefined, undefined, undefined, undefined, 0.6);
*
* For more details of the constants, see [The Math Behind TrueSkill by
* Jeff Moser](http://www.moserware.com/assets/computing-your-skill/The%20Math%20Behind%20TrueSkill.pdf).
*/
export class TrueSkill {
drawProbability;
guassian;
mu;
sigma;
beta;
tau;
/**
* @param mu initial mean of ratings
* @param sigma initial standard deviation of ratings
* @param beta distance that guarantees about 76% chance of winning
* @param tau dynamic factor
* @param drawProbability draw probability of the game
* @param guassian reuseable gaussian
*/
constructor(mu = 25, sigma, beta, tau, drawProbability = 0.1, guassian = new Gaussian(0, 1)) {
this.drawProbability = drawProbability;
this.guassian = guassian;
this.mu = mu;
this.sigma = sigma ?? this.mu / 3;
this.beta = beta ?? this.sigma / 2;
this.tau = tau ?? this.sigma / 100;
}
/**
* Recalculates ratings by the ranking table
*/
rate(ratingGroups, ranks = null, weights = null, minDelta = 0.0001) {
const [newRatingGroups, keys] = this._validateRatingGroups(ratingGroups);
weights = this._validateWeights(newRatingGroups, weights);
const groupSize = ratingGroups.length;
if (ranks && ranks.length !== groupSize) {
throw new Error('Wrong ranks');
}
const newRanks = ranks ? ranks : Array.from({ length: groupSize }, (_, i) => i);
// Sort rating groups by rank
const zip = [];
for (let idx = 0; idx < newRatingGroups.length; idx++) {
zip.push([newRatingGroups[idx], newRanks[idx], weights[idx]]);
}
let position = 0;
const positions = zip.map(el => {
const res = [position, el];
position++;
return res;
});
const sorting = positions.sort((a, b) => a[1][1] - b[1][1]);
const sortedRatingGroups = [];
const sortedRanks = [];
const sortedWeights = [];
for (const [_x, [g, r, w]] of sorting) {
sortedRatingGroups.push(g);
sortedRanks.push(r);
// Make weights to be greater than 0
const max = w.map((ww) => Math.max(minDelta, ww));
sortedWeights.push(max);
}
// Build factor graph
const flattenRatings = sortedRatingGroups.flat();
const flattenWeights = sortedWeights.flat();
const size = flattenRatings.length;
// Create variables
const fill = Array.from({ length: size });
const ratingVars = fill.map(() => new Variable());
const perfVars = fill.map(() => new Variable());
const teamPerfVars = Array.from({ length: groupSize }).map(() => new Variable());
const teamDiffVars = Array.from({ length: groupSize - 1 }).map(() => new Variable());
const teamSizes = _teamSizes(sortedRatingGroups);
// Layer builders
const layers = this._runSchedule(ratingVars, flattenRatings, perfVars, teamPerfVars, teamSizes, flattenWeights, teamDiffVars, sortedRanks, sortedRatingGroups, minDelta);
const transformedGroups = [];
const trimmed = [0].concat(teamSizes.slice(0, teamSizes.length - 1));
for (let idx = 0; idx < teamSizes.length; idx++) {
const group = layers
.slice(trimmed[idx], teamSizes[idx])
.map((f) => new Rating(f.v.mu, f.v.sigma));
transformedGroups.push(group);
}
const pulledTranformedGroups = [];
for (let idx = 0; idx < sorting.length; idx++) {
pulledTranformedGroups.push([sorting[idx][0], transformedGroups[idx]]);
}
const unsorting = pulledTranformedGroups.sort((a, b) => a[0] - b[0]);
if (!keys) {
return unsorting.map(k => k[1]);
}
return unsorting.map(v => ({ [keys[v[0]][0]]: v[1][0] }));
}
/**
* Calculates the match quality of the given rating groups. Result
* is the draw probability in the association::
*
* ```ts
* env = TrueSkill()
* if (env.quality([team1, team2, team3]) < 0.50) {
* console.log('This match seems to be not so fair')
* }
* ```
*/
quality(ratingGroups, weights) {
function createVarianceMatrix(flattenRatings, height, width) {
const matrix = mMatrix().resize([height, width]);
const variances = flattenRatings.map(r => r.sigma ** 2);
for (let i = 0; i < variances.length; i++) {
matrix.set([i, i], variances[i]);
}
return matrix;
}
function createRotatedAMatrix(newRatingGroups, flattenWeights) {
let t = 0;
let r = 0;
const matrix = mMatrix();
for (let i = 0; i < newRatingGroups.length - 1; i++) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
const setter = Array.from({ length: newRatingGroups[i].length }, (_, n) => n + t).map(z => {
matrix.set([r, z], flattenWeights[z]);
t += 1;
return z;
});
const x = setter[setter.length - 1] + 1;
for (let d = x; d < newRatingGroups[i + 1].length + x; d++) {
matrix.set([r, d], -flattenWeights[d]);
}
r++;
}
return matrix;
}
const [newRatingGroups, _keys] = this._validateRatingGroups(ratingGroups);
const newWeights = this._validateWeights(ratingGroups, weights);
const flattenRatings = ratingGroups.flat();
const flattenWeights = newWeights.flat();
const { length } = flattenRatings;
// A vector of all of the skill means
const meanMatrix = matrix(flattenRatings.map(r => [r.mu]));
const varianceMatrix = createVarianceMatrix(flattenRatings, length, length);
const rotatedAMatrix = createRotatedAMatrix(newRatingGroups, flattenWeights);
const aMatrix = transpose(rotatedAMatrix);
// Match quality further derivation
const modifiedRotatedAMatrix = rotatedAMatrix.map((value, _, __) => this.beta ** 2 * value);
const start = multiply(transpose(meanMatrix), aMatrix);
const ata = multiply(modifiedRotatedAMatrix, aMatrix);
const atsa = multiply(rotatedAMatrix, multiply(varianceMatrix, aMatrix));
const middle = add(ata, atsa);
const end = multiply(rotatedAMatrix, meanMatrix);
// Make result
const eArg = det(multiply(multiply([[-0.5]], multiply(start, inv(middle))), end));
const sArg = det(ata) / det(middle);
return exp(eArg) * Math.sqrt(sArg);
}
/**
* Initializes new `Rating` object, but it fixes default mu and
* sigma to the environment's.
* var env = TrueSkill(mu=0, sigma=1)
* var env.createRating()
* trueskill.Rating(mu=0.000, sigma=1.000)
*/
createRating(mu = this.mu, sigma = this.sigma) {
return new Rating(mu, sigma);
}
/**
* Returns the value of the rating exposure. It starts from 0 and
* converges to the mean. Use this as a sort key in a leaderboard
*/
expose(rating) {
const k = this.mu / this.sigma;
return rating.mu - k * rating.sigma;
}
/**
* Taken from https://github.com/sublee/trueskill/issues/1
*/
winProbability(a, b) {
const deltaMu = a.reduce((t, cur) => t + cur.mu, 0) - b.reduce((t, cur) => t + cur.mu, 0);
const sumSigma = a.reduce((t, n) => n.sigma ** 2 + t, 0) + b.reduce((t, n) => n.sigma ** 2 + t, 0);
const playerCount = a.length + b.length;
const denominator = Math.sqrt(playerCount * this.beta * this.beta + sumSigma);
return this.guassian.cdf(deltaMu / denominator);
}
/**
* The non-draw version of "V" function.
* "V" calculates a variation of a mean.
*/
_vWin(diff, drawMargin) {
const x = diff - drawMargin;
const denom = this.guassian.cdf(x);
return denom ? this.guassian.pdf(x) / denom : -x;
}
_vDraw(diff, drawMargin) {
const absDiff = Math.abs(diff);
const [a, b] = [drawMargin - absDiff, -drawMargin - absDiff];
const denom = this.guassian.cdf(a) - this.guassian.cdf(b);
const numer = this.guassian.pdf(b) - this.guassian.pdf(a);
return (denom ? numer / denom : a) * (diff < 0 ? -1 : +1);
}
/**
* The non-draw version of "W" function.
* "W" calculates a variation of a standard deviation.
*/
_wWin(diff, drawMargin) {
const x = diff - drawMargin;
const v = this._vWin(diff, drawMargin);
const w = v * (v + x);
if (w > 0 && w < 1) {
return w;
}
throw new Error('floating point error');
}
/**
* The draw version of "W" function.
*/
_wDraw(diff, drawMargin) {
const absDiff = Math.abs(diff);
const a = drawMargin - absDiff;
const b = -drawMargin - absDiff;
const denom = this.guassian.cdf(a) - this.guassian.cdf(b);
if (!denom) {
throw new Error('Floating point error');
}
const v = this._vDraw(absDiff, drawMargin);
return v ** 2 + (a * this.guassian.pdf(a) - b * this.guassian.pdf(b)) / denom;
}
/**
* Validates a ratingGroups argument. It should contain more than
* 2 groups and all groups must not be empty.
*/
_validateRatingGroups(ratingGroups) {
if (ratingGroups.length < 2) {
throw new Error('Need multiple rating groups');
}
for (const group of ratingGroups) {
if (group.length === 0) {
throw new Error('Each group must contain multiple ratings');
}
if (group instanceof Rating) {
throw new Error('Rating cannot be a rating group');
}
}
if (!Array.isArray(ratingGroups[0])) {
const keys = [];
const newRatingGroups = [];
for (const dictRatingGroup of ratingGroups) {
const keyGroup = Object.keys(dictRatingGroup);
const ratingGroup = keyGroup.map(n => dictRatingGroup[n]);
newRatingGroups.push(ratingGroup);
keys.push(keyGroup);
}
return [newRatingGroups, keys];
}
return [ratingGroups, null];
}
_validateWeights(ratingGroups, weights) {
if (!weights) {
return ratingGroups.map(n => new Array(n.length).fill(1));
}
return weights;
}
_buildRatingLayer(ratingVars, flattenRatings) {
const t = this.tau;
return ratingVars.map((n, idx) => new PriorFactor(n, flattenRatings[idx], t));
}
_buildPerfLayer(ratingVars, perfVars) {
const b = this.beta ** 2;
return ratingVars.map((n, idx) => new LikelihoodFactor(n, perfVars[idx], b));
}
_buildTeamPerfLayer(teamPerfVars, perfVars, teamSizes, flattenWeights) {
let team = 0;
return teamPerfVars.map(teamPerfVar => {
const start = team > 0 ? teamSizes[team - 1] : 0;
const end = teamSizes[team];
team += 1;
return new SumFactor(teamPerfVar, perfVars.slice(start, end), flattenWeights.slice(start, end));
});
}
_buildTeamDiffLayer(teamPerfVars, teamDiffVars) {
let team = 0;
return teamDiffVars.map(teamDiffVar => {
const sl = teamPerfVars.slice(team, team + 2);
team++;
return new SumFactor(teamDiffVar, sl, [1, -1]);
});
}
_buildTruncLayer(teamDiffVars, sortedRanks, sortedRatingGroups) {
let x = 0;
return teamDiffVars.map(teamDiffVar => {
// Static draw probability
const { drawProbability } = this;
const lengths = sortedRatingGroups.slice(x, x + 2).map(n => n.length);
const drawMargin = calcDrawMargin(drawProbability, lengths.reduce((t, n) => t + n, 0), this);
let vFunc = (a, b) => this._vWin(a, b);
let wFunc = (a, b) => this._wWin(a, b);
if (sortedRanks[x] === sortedRanks[x + 1]) {
vFunc = (a, b) => this._vDraw(a, b);
wFunc = (a, b) => this._wDraw(a, b);
}
x++;
return new TruncateFactor(teamDiffVar, vFunc, wFunc, drawMargin);
});
}
/**
* Sends messages within every nodes of the factor graph
* until the result is reliable.
*/
_runSchedule(ratingVars, flattenRatings, perfVars, teamPerfVars, teamSizes, flattenWeights, teamDiffVars, sortedRanks, sortedRatingGroups, minDelta = 0.0001) {
if (minDelta <= 0) {
throw new Error('minDelta must be greater than 0');
}
const ratingLayer = this._buildRatingLayer(ratingVars, flattenRatings);
const perfLayer = this._buildPerfLayer(ratingVars, perfVars);
const teamPerfLayer = this._buildTeamPerfLayer(teamPerfVars, perfVars, teamSizes, flattenWeights);
ratingLayer.map(f => f.down());
perfLayer.map(f => f.down());
teamPerfLayer.map(f => f.down());
// Arrow #1, #2, #3
const teamDiffLayer = this._buildTeamDiffLayer(teamPerfVars, teamDiffVars);
const truncLayer = this._buildTruncLayer(teamDiffVars, sortedRanks, sortedRatingGroups);
const teamDiffLen = teamDiffLayer.length;
for (let index = 0; index <= 10; index++) {
let delta = 0;
if (teamDiffLen === 1) {
// Only two teams
teamDiffLayer[0].down();
delta = truncLayer[0].up();
}
else {
// Multiple teams
delta = 0;
for (let z = 0; z < teamDiffLen - 1; z++) {
teamDiffLayer[z].down();
delta = Math.max(delta, truncLayer[z].up());
teamDiffLayer[z].up(1);
}
for (let z = teamDiffLen - 1; z > 0; z--) {
teamDiffLayer[z].down();
delta = Math.max(delta, truncLayer[z].up());
teamDiffLayer[z].up(0);
}
}
// Repeat until too small update
if (delta <= minDelta) {
break;
}
}
// Up both ends
teamDiffLayer[0].up(0);
teamDiffLayer[teamDiffLen - 1].up(1);
// Up the remainder of the black arrows
for (const f of teamPerfLayer) {
for (let x = 0; x < f.vars.length - 1; x++) {
f.up(x);
}
}
for (const f of perfLayer) {
f.up();
}
return ratingLayer;
}
}
/**
* A shortcut to rate just 2 players in a head-to-head match
*/
export function rate_1vs1(rating1, rating2, drawn = false, minDelta = 0.0001, env = new TrueSkill()) {
const ranks = [0, drawn ? 0 : 1];
const teams = env.rate([[rating1], [rating2]], ranks, undefined, minDelta);
return [teams[0][0], teams[1][0]];
}
/**
* A shortcut to calculate the match quality between 2 players in
* a head-to-head match
*/
export function quality_1vs1(rating1, rating2, env = new TrueSkill()) {
return env.quality([[rating1], [rating2]]);
}
/**
* A proxy function for `TrueSkill.rate` of the global environment.
*/
export function rate(ratingGroups, ranks, weights, minDelta = 0.0001, env = new TrueSkill()) {
return env.rate(ratingGroups, ranks, weights, minDelta);
}
export function winProbability(a, b, env = new TrueSkill()) {
return env.winProbability(a, b);
}
/**
* A proxy function for `TrueSkill.quality` of the global
* environment.
*/
export function quality(ratingGroups, weights, env = new TrueSkill()) {
return env.quality(ratingGroups, weights);
}
/**
* A proxy function for TrueSkill.expose of the global environment.
*/
export function expose(rating, env = new TrueSkill()) {
return env.expose(rating);
}