From 00180e48ae27d9eafbd281fe56e2b3d0d80c582d Mon Sep 17 00:00:00 2001 From: Philihp Busby Date: Wed, 24 Jul 2024 21:31:15 +0000 Subject: [PATCH 1/6] add tests for predictDraw to mirror python --- src/__tests__/predict-draw.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/__tests__/predict-draw.test.ts b/src/__tests__/predict-draw.test.ts index 30ef3de..bd7df73 100644 --- a/src/__tests__/predict-draw.test.ts +++ b/src/__tests__/predict-draw.test.ts @@ -36,4 +36,33 @@ describe('predictDraw', () => { expect.assertions(1) expect(predictDraw([team1, team2, [a1], [a2], [b1]])).toBeCloseTo(0.07517247728677093, precision) }) + + it('mirrors results from python', () => { + // from https://github.com/philihp/openskill.js/issues/599 + const t1 = [rating({ mu: 25, sigma: 1 }), rating({ mu: 25, sigma: 1 })] + const t2 = [rating({ mu: 25, sigma: 1 }), rating({ mu: 25, sigma: 1 })] + expect(predictDraw([t1, t2])).toBeCloseTo(0.6608263448857606) + }) + + it('gives a low probability in a 5 team match', () => { + // from https://openskill.me/en/stable/manual.html + const p1 = rating({ mu: 35, sigma: 1.0 }) + const p2 = rating({ mu: 35, sigma: 1.0 }) + const p3 = rating({ mu: 35, sigma: 1.0 }) + const p4 = rating({ mu: 35, sigma: 1.0 }) + const p5 = rating({ mu: 35, sigma: 1.0 }) + + const team1 = [p1, p2] + const team2 = [p3, p4, p5] + expect(predictDraw([team1, team2])).toBeCloseTo(0.0002807397636509501, 10) + }) + + it('gives a higher probability with fewer players', () => { + // from https://openskill.me/en/stable/manual.html + const p1 = rating({ mu: 35, sigma: 1.0 }) + const p2 = rating({ mu: 35, sigma: 1.1 }) + const team1 = [p1] + const team2 = [p2] + expect(predictDraw([team1, team2])).toBeCloseTo(0.4868868769871696) + }) }) From 87e677c85ffc030b80760b28117db53524033b69 Mon Sep 17 00:00:00 2001 From: Philihp Busby Date: Sat, 27 Jul 2024 22:49:06 +0000 Subject: [PATCH 2/6] remove obsolete tests --- src/__tests__/predict-draw.test.ts | 31 ------------------------------ 1 file changed, 31 deletions(-) diff --git a/src/__tests__/predict-draw.test.ts b/src/__tests__/predict-draw.test.ts index bd7df73..a3d0681 100644 --- a/src/__tests__/predict-draw.test.ts +++ b/src/__tests__/predict-draw.test.ts @@ -1,42 +1,11 @@ import { rating, predictDraw } from '..' describe('predictDraw', () => { - const precision = 6 - - const a1 = rating() - const a2 = rating({ mu: 32.444, sigma: 1.123 }) - - const b1 = rating({ mu: 35.881, sigma: 0.0001 }) - const b2 = rating({ mu: 25.188, sigma: 1.421 }) - - const team1 = [a1, a2] - const team2 = [b1, b2] - it('if a tree falls in the forest', () => { expect.assertions(1) expect(predictDraw([])).toBeUndefined() }) - it('predicts 100% draw for solitaire', () => { - expect.assertions(1) - expect(predictDraw([team1])).toBeCloseTo(1, precision) - }) - - it('predicts 100% draw for self v self', () => { - expect.assertions(1) - expect(predictDraw([[b1], [b1]])).toBeCloseTo(1, precision) - }) - - it('predicts draw for two teams', () => { - expect.assertions(1) - expect(predictDraw([team1, team2])).toBeCloseTo(0.7802613510294426, precision) - }) - - it('predicts draw for three asymmetric teams', () => { - expect.assertions(1) - expect(predictDraw([team1, team2, [a1], [a2], [b1]])).toBeCloseTo(0.07517247728677093, precision) - }) - it('mirrors results from python', () => { // from https://github.com/philihp/openskill.js/issues/599 const t1 = [rating({ mu: 25, sigma: 1 }), rating({ mu: 25, sigma: 1 })] From 40c4cb40eef54676c2fd53b5ad2160e9ee1cee20 Mon Sep 17 00:00:00 2001 From: Philihp Busby Date: Tue, 30 Jul 2024 19:48:29 +0000 Subject: [PATCH 3/6] implementation of predictDraw --- eslint.config.js | 2 +- src/__tests__/predict-draw.test.ts | 67 +++++++++++++++++++++++++++--- src/predict-draw.ts | 52 ++++++++++++----------- 3 files changed, 91 insertions(+), 30 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index c4a8ad1..1d1878d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,7 +12,7 @@ export default tseslint.config( extends: [eslint.configs.recommended, eslintPluginPrettierRecommended, ...tseslint.configs.recommended], rules: { '@typescript-eslint/no-unused-vars': [ - 'error', + 'warn', { // allow unused variables if they begin with _ argsIgnorePattern: '^_', diff --git a/src/__tests__/predict-draw.test.ts b/src/__tests__/predict-draw.test.ts index a3d0681..b67519d 100644 --- a/src/__tests__/predict-draw.test.ts +++ b/src/__tests__/predict-draw.test.ts @@ -2,17 +2,22 @@ import { rating, predictDraw } from '..' describe('predictDraw', () => { it('if a tree falls in the forest', () => { - expect.assertions(1) - expect(predictDraw([])).toBeUndefined() + expect(predictDraw([])).toBe(1) }) it('mirrors results from python', () => { // from https://github.com/philihp/openskill.js/issues/599 const t1 = [rating({ mu: 25, sigma: 1 }), rating({ mu: 25, sigma: 1 })] const t2 = [rating({ mu: 25, sigma: 1 }), rating({ mu: 25, sigma: 1 })] - expect(predictDraw([t1, t2])).toBeCloseTo(0.6608263448857606) + expect(predictDraw([t1, t2])).toBe(0.2433180271619435) }) + // we use toBeCloseTo because of differences between the gaussian library we use in js and + // the statistics.NormalDist impl in py, so the conditioning of the answer is only equivalent + // to a certain degree of precision. + // + // This is known and accepted. + it('gives a low probability in a 5 team match', () => { // from https://openskill.me/en/stable/manual.html const p1 = rating({ mu: 35, sigma: 1.0 }) @@ -23,7 +28,7 @@ describe('predictDraw', () => { const team1 = [p1, p2] const team2 = [p3, p4, p5] - expect(predictDraw([team1, team2])).toBeCloseTo(0.0002807397636509501, 10) + expect(predictDraw([team1, team2])).toBeCloseTo(0.0002807397636509501, 9) }) it('gives a higher probability with fewer players', () => { @@ -32,6 +37,58 @@ describe('predictDraw', () => { const p2 = rating({ mu: 35, sigma: 1.1 }) const team1 = [p1] const team2 = [p2] - expect(predictDraw([team1, team2])).toBeCloseTo(0.4868868769871696) + expect(predictDraw([team1, team2])).toBeCloseTo(0.4868868769871696, 8) + }) + + it('returns NaN when one team of nobody', () => { + // this could be undefined, but i think that makes more work for people to guard against that response, + // while a NaN tends to passed along without halting. + expect(predictDraw([[]])).toBe(Number.NaN) + }) + + it('returns one when two teams of nobody', () => { + expect(predictDraw([[], []])).toBe(Number.NaN) + }) + + it('returns NaN when only one team', () => { + const p1 = rating({ mu: 23.096623784758727, sigma: 8.138233582011868 }) + const p2 = rating({ mu: 28.450555874288018, sigma: 8.156810439252277 }) + expect(predictDraw([[p1, p2]])).toBe(Number.NaN) + }) + + it('returns 1 when one team verses an empty team', () => { + const p2 = rating({ mu: 28.450555874288018, sigma: 8.156810439252277 }) + expect(predictDraw([[p2], []])).toBe(1) + }) + + describe('two game, 2v2 scenario with 5th defector', () => { + // these ratings come directly from python, where all players start out with baseline mu=25, sigma=25/3, then we do + // [[a,b,c], [d,e]] = rate([[a,b,c], [d,e]]) + // [[a,b], [c,d,e]] = rate([[a,b], [c,d,e]]) + const [a, b, c, d, _e] = [ + rating({ mu: 28.450555874288018, sigma: 8.156810439252277 }), + rating({ mu: 28.450555874288018, sigma: 8.156810439252277 }), + rating({ mu: 23.096623784758727, sigma: 8.138233582011868 }), + rating({ mu: 21.537948364040137, sigma: 8.155255551436932 }), + rating({ mu: 21.537948364040137, sigma: 8.155255551436932 }), + ] + + it('is a likely draw with the 5th sitting out', () => { + expect( + predictDraw([ + [a, b], + [c, d], + ]) + ).toBeCloseTo(0.09227283302635064, 7) + }) + + it('has draw probabilities with hypothetical mashups', () => { + expect( + predictDraw([ + [a, c], + [b, d], + ]) + ).toBeCloseTo(0.11489223845523855, 7) + }) }) }) diff --git a/src/predict-draw.ts b/src/predict-draw.ts index 942ef5b..a0f4472 100644 --- a/src/predict-draw.ts +++ b/src/predict-draw.ts @@ -1,36 +1,40 @@ -import { flatten } from 'ramda' +import { flatten, sum, map, addIndex, reduce } from 'ramda' import constants from './constants' -import util, { sum } from './util' +import util, { TeamRating } from './util' import { phiMajor, phiMajorInverse } from './statistics' import { Options, Team } from './types' -const predictWin = (teams: Team[], options: Options = {}) => { +const predictDraw = (teams: Team[], options: Options = {}): number => { const { teamRating } = util(options) const { BETASQ, BETA } = constants(options) - const n = teams.length - if (n === 0) return undefined - if (n === 1) return 1 + if (teams.length === 0) return 1 - const denom = (n * (n - 1)) / (n > 2 ? 1 : 2) - const teamRatings = teamRating(teams) - const drawMargin = Math.sqrt(flatten(teams).length) * BETA * phiMajorInverse((1 + 1 / n) / 2) + const totalPlayerCount = flatten(teams).length + const drawProbability = 1 / totalPlayerCount + const drawMargin = Math.sqrt(totalPlayerCount) * BETA * phiMajorInverse((1 + drawProbability) / 2) - return ( - Math.abs( - teamRatings - .map(([muA, sigmaSqA], i) => - teamRatings - .filter((_, q) => i !== q) - .map(([muB, sigmaSqB]) => { - const sigmaBar = Math.sqrt(n * BETASQ + sigmaSqA + sigmaSqB) - return phiMajor((drawMargin - muA + muB) / sigmaBar) - phiMajor((muA - muB - drawMargin) / sigmaBar) - }) - ) - .flat() - .reduce(sum, 0) - ) / denom + const teamRatings = map((team) => teamRating([team])[0], teams) + + const pairwiseProbs: number[] = addIndex(reduce)( + (qqq: number[], pairA: TeamRating, i: number): number[] => { + const [muA, sigmaSqA] = pairA + return reduce( + (ppp: number[], pairB: TeamRating): number[] => { + const [muB, sigmaSqB] = pairB + const sharedDenom = Math.sqrt(totalPlayerCount * BETASQ + sigmaSqA + sigmaSqB) + ppp.push(phiMajor((drawMargin - muA + muB) / sharedDenom) - phiMajor((muB - muA - drawMargin) / sharedDenom)) + return ppp + }, + qqq, + teamRatings.slice(i + 1) + ) + }, + [], + teamRatings ) + + return sum(pairwiseProbs) / pairwiseProbs.length } -export default predictWin +export default predictDraw From 5cd160e8be265ab36c5b2c4ce81fd99ce12b91a6 Mon Sep 17 00:00:00 2001 From: Philihp Busby Date: Wed, 24 Jul 2024 21:31:15 +0000 Subject: [PATCH 4/6] add tests for predictDraw to mirror python --- src/__tests__/predict-draw.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/__tests__/predict-draw.test.ts b/src/__tests__/predict-draw.test.ts index b67519d..363025b 100644 --- a/src/__tests__/predict-draw.test.ts +++ b/src/__tests__/predict-draw.test.ts @@ -91,4 +91,33 @@ describe('predictDraw', () => { ).toBeCloseTo(0.11489223845523855, 7) }) }) + + it('mirrors results from python', () => { + // from https://github.com/philihp/openskill.js/issues/599 + const t1 = [rating({ mu: 25, sigma: 1 }), rating({ mu: 25, sigma: 1 })] + const t2 = [rating({ mu: 25, sigma: 1 }), rating({ mu: 25, sigma: 1 })] + expect(predictDraw([t1, t2])).toBeCloseTo(0.6608263448857606) + }) + + it('gives a low probability in a 5 team match', () => { + // from https://openskill.me/en/stable/manual.html + const p1 = rating({ mu: 35, sigma: 1.0 }) + const p2 = rating({ mu: 35, sigma: 1.0 }) + const p3 = rating({ mu: 35, sigma: 1.0 }) + const p4 = rating({ mu: 35, sigma: 1.0 }) + const p5 = rating({ mu: 35, sigma: 1.0 }) + + const team1 = [p1, p2] + const team2 = [p3, p4, p5] + expect(predictDraw([team1, team2])).toBeCloseTo(0.0002807397636509501, 10) + }) + + it('gives a higher probability with fewer players', () => { + // from https://openskill.me/en/stable/manual.html + const p1 = rating({ mu: 35, sigma: 1.0 }) + const p2 = rating({ mu: 35, sigma: 1.1 }) + const team1 = [p1] + const team2 = [p2] + expect(predictDraw([team1, team2])).toBeCloseTo(0.4868868769871696) + }) }) From 577a1b95f8632257fdba06da94efb2ee33b51d95 Mon Sep 17 00:00:00 2001 From: Philihp Busby Date: Tue, 30 Jul 2024 19:53:10 +0000 Subject: [PATCH 5/6] merge mistake --- src/__tests__/predict-draw.test.ts | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/src/__tests__/predict-draw.test.ts b/src/__tests__/predict-draw.test.ts index 363025b..b67519d 100644 --- a/src/__tests__/predict-draw.test.ts +++ b/src/__tests__/predict-draw.test.ts @@ -91,33 +91,4 @@ describe('predictDraw', () => { ).toBeCloseTo(0.11489223845523855, 7) }) }) - - it('mirrors results from python', () => { - // from https://github.com/philihp/openskill.js/issues/599 - const t1 = [rating({ mu: 25, sigma: 1 }), rating({ mu: 25, sigma: 1 })] - const t2 = [rating({ mu: 25, sigma: 1 }), rating({ mu: 25, sigma: 1 })] - expect(predictDraw([t1, t2])).toBeCloseTo(0.6608263448857606) - }) - - it('gives a low probability in a 5 team match', () => { - // from https://openskill.me/en/stable/manual.html - const p1 = rating({ mu: 35, sigma: 1.0 }) - const p2 = rating({ mu: 35, sigma: 1.0 }) - const p3 = rating({ mu: 35, sigma: 1.0 }) - const p4 = rating({ mu: 35, sigma: 1.0 }) - const p5 = rating({ mu: 35, sigma: 1.0 }) - - const team1 = [p1, p2] - const team2 = [p3, p4, p5] - expect(predictDraw([team1, team2])).toBeCloseTo(0.0002807397636509501, 10) - }) - - it('gives a higher probability with fewer players', () => { - // from https://openskill.me/en/stable/manual.html - const p1 = rating({ mu: 35, sigma: 1.0 }) - const p2 = rating({ mu: 35, sigma: 1.1 }) - const team1 = [p1] - const team2 = [p2] - expect(predictDraw([team1, team2])).toBeCloseTo(0.4868868769871696) - }) }) From fac4c538e1dc4a2df64b0bcb1f0cd2b724f111d2 Mon Sep 17 00:00:00 2001 From: Philihp Busby Date: Tue, 30 Jul 2024 20:05:02 +0000 Subject: [PATCH 6/6] enforce always returning a number, even if NaN --- src/__tests__/predict-draw.test.ts | 2 +- src/predict-draw.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/__tests__/predict-draw.test.ts b/src/__tests__/predict-draw.test.ts index b67519d..2d5f581 100644 --- a/src/__tests__/predict-draw.test.ts +++ b/src/__tests__/predict-draw.test.ts @@ -2,7 +2,7 @@ import { rating, predictDraw } from '..' describe('predictDraw', () => { it('if a tree falls in the forest', () => { - expect(predictDraw([])).toBe(1) + expect(predictDraw([])).toBe(Number.NaN) }) it('mirrors results from python', () => { diff --git a/src/predict-draw.ts b/src/predict-draw.ts index a0f4472..2dc55e0 100644 --- a/src/predict-draw.ts +++ b/src/predict-draw.ts @@ -1,4 +1,4 @@ -import { flatten, sum, map, addIndex, reduce } from 'ramda' +import { flatten, sum, map, addIndex, reduce, head } from 'ramda' import constants from './constants' import util, { TeamRating } from './util' import { phiMajor, phiMajorInverse } from './statistics' @@ -8,25 +8,25 @@ const predictDraw = (teams: Team[], options: Options = {}): number => { const { teamRating } = util(options) const { BETASQ, BETA } = constants(options) - if (teams.length === 0) return 1 - const totalPlayerCount = flatten(teams).length const drawProbability = 1 / totalPlayerCount const drawMargin = Math.sqrt(totalPlayerCount) * BETA * phiMajorInverse((1 + drawProbability) / 2) - const teamRatings = map((team) => teamRating([team])[0], teams) + const teamRatings = map((team) => head(teamRating([team]))!, teams) const pairwiseProbs: number[] = addIndex(reduce)( - (qqq: number[], pairA: TeamRating, i: number): number[] => { + (outerAccum: number[], pairA: TeamRating, i: number): number[] => { const [muA, sigmaSqA] = pairA return reduce( - (ppp: number[], pairB: TeamRating): number[] => { + (innerAccum: number[], pairB: TeamRating): number[] => { const [muB, sigmaSqB] = pairB const sharedDenom = Math.sqrt(totalPlayerCount * BETASQ + sigmaSqA + sigmaSqB) - ppp.push(phiMajor((drawMargin - muA + muB) / sharedDenom) - phiMajor((muB - muA - drawMargin) / sharedDenom)) - return ppp + innerAccum.push( + phiMajor((drawMargin - muA + muB) / sharedDenom) - phiMajor((muB - muA - drawMargin) / sharedDenom) + ) + return innerAccum }, - qqq, + outerAccum, teamRatings.slice(i + 1) ) },