fix : game elo estimation

This commit is contained in:
GuillaumeSD
2025-05-09 23:52:20 +02:00
parent 2cc8b48b08
commit e37152e651
8 changed files with 136 additions and 70 deletions

View File

@@ -78,8 +78,12 @@ const getAccuracyWeights = (movesWinPercentage: number[]): number[] => {
const getMovesAccuracy = (movesWinPercentage: number[]): number[] => const getMovesAccuracy = (movesWinPercentage: number[]): number[] =>
movesWinPercentage.slice(1).map((winPercent, index) => { movesWinPercentage.slice(1).map((winPercent, index) => {
const lastWinPercent = movesWinPercentage[index]; const lastWinPercent = movesWinPercentage[index];
const winDiff = Math.abs(lastWinPercent - winPercent); const isWhiteMove = index % 2 === 0;
const winDiff = isWhiteMove
? Math.max(0, lastWinPercent - winPercent)
: Math.max(0, winPercent - lastWinPercent);
// Source: https://github.com/lichess-org/lila/blob/a320a93b68dabee862b8093b1b2acdfe132b9966/modules/analyse/src/main/AccuracyPercent.scala#L44
const rawAccuracy = const rawAccuracy =
103.1668100711649 * Math.exp(-0.04354415386753951 * winDiff) - 103.1668100711649 * Math.exp(-0.04354415386753951 * winDiff) -
3.166924740191411; 3.166924740191411;

View File

@@ -1,54 +1,91 @@
import { ceilsNumber } from "@/lib/math"; import { ceilsNumber } from "@/lib/math";
import { EstimatedElo, PositionEval } from "@/types/eval"; import { EstimatedElo, PositionEval } from "@/types/eval";
export const estimateEloFromEngineOutput = ( export const computeEstimatedElo = (
positions: PositionEval[],
whiteElo?: number,
blackElo?: number
): EstimatedElo | undefined => {
if (positions.length < 2) {
return undefined;
}
const { whiteCpl, blackCpl } = getPlayersAverageCpl(positions);
const whiteEstimatedElo = getEloFromRatingAndCpl(
whiteCpl,
whiteElo ?? blackElo
);
const blackEstimatedElo = getEloFromRatingAndCpl(
blackCpl,
blackElo ?? whiteElo
);
return { white: whiteEstimatedElo, black: blackEstimatedElo };
};
const getPositionCp = (position: PositionEval): number => {
const line = position.lines[0];
if (line.cp !== undefined) {
return ceilsNumber(line.cp, -1000, 1000);
}
if (line.mate !== undefined) {
return ceilsNumber(line.mate * Infinity, -1000, 1000);
}
throw new Error("No cp or mate in line");
};
const getPlayersAverageCpl = (
positions: PositionEval[] positions: PositionEval[]
): EstimatedElo => { ): { whiteCpl: number; blackCpl: number } => {
try { let previousCp = getPositionCp(positions[0]);
if (!positions || positions.length === 0) {
return { white: null, black: null };
}
let totalCPLWhite = 0; const { whiteCpl, blackCpl } = positions.slice(1).reduce(
let totalCPLBlack = 0; (acc, position, index) => {
let moveCount = 0; const cp = getPositionCp(position);
let previousCp = null;
let flag = true; if (index % 2 === 0) {
for (const moveAnalysis of positions) { acc.whiteCpl += cp > previousCp ? 0 : Math.min(previousCp - cp, 1000);
if (moveAnalysis.lines && moveAnalysis.lines.length > 0) {
const bestLine = moveAnalysis.lines[0];
if (bestLine.cp !== undefined) {
if (previousCp !== null) {
const diff = Math.abs(bestLine.cp - previousCp);
if (flag) {
totalCPLWhite += ceilsNumber(diff, -1000, 1000);
} else { } else {
totalCPLBlack += ceilsNumber(diff, -1000, 1000); acc.blackCpl += cp < previousCp ? 0 : Math.min(cp - previousCp, 1000);
}
flag = !flag;
moveCount++;
}
previousCp = bestLine.cp;
}
}
} }
if (moveCount === 0) { previousCp = cp;
return { white: null, black: null }; return acc;
} },
{ whiteCpl: 0, blackCpl: 0 }
);
const averageCPLWhite = totalCPLWhite / Math.ceil(moveCount / 2); return {
const averageCPLBlack = totalCPLBlack / Math.floor(moveCount / 2); whiteCpl: whiteCpl / Math.ceil((positions.length - 1) / 2),
blackCpl: blackCpl / Math.floor((positions.length - 1) / 2),
};
};
const estimateElo = (averageCPL: number) => // Source: https://lichess.org/forum/general-chess-discussion/how-to-estimate-your-elo-for-a-game-using-acpl-and-what-it-realistically-means
3100 * Math.exp(-0.01 * averageCPL); const getEloFromAverageCpl = (averageCpl: number) =>
3100 * Math.exp(-0.01 * averageCpl);
const whiteElo = estimateElo(Math.abs(averageCPLWhite)); const getAverageCplFromElo = (elo: number) =>
const blackElo = estimateElo(Math.abs(averageCPLBlack)); -100 * Math.log(Math.min(elo, 3100) / 3100);
return { white: whiteElo, black: blackElo }; const getEloFromRatingAndCpl = (
} catch (error) { gameCpl: number,
console.error("Error estimating Elo: ", error); rating: number | undefined
return { white: null, black: null }; ): number => {
const eloFromCpl = getEloFromAverageCpl(gameCpl);
if (!rating) return eloFromCpl;
const expectedCpl = getAverageCplFromElo(rating);
const cplDiff = gameCpl - expectedCpl;
if (cplDiff === 0) return eloFromCpl;
if (cplDiff > 0) {
return rating * Math.exp(-0.005 * cplDiff);
} else {
return rating / Math.exp(-0.005 * -cplDiff);
} }
}; };

View File

@@ -22,9 +22,10 @@ const getWinPercentageFromMate = (mate: number): number => {
return getWinPercentageFromCp(mateInf); return getWinPercentageFromCp(mateInf);
}; };
// Source: https://github.com/lichess-org/lila/blob/a320a93b68dabee862b8093b1b2acdfe132b9966/modules/analyse/src/main/WinPercent.scala#L27
const getWinPercentageFromCp = (cp: number): number => { const getWinPercentageFromCp = (cp: number): number => {
const cpCeiled = ceilsNumber(cp, -1000, 1000); const cpCeiled = ceilsNumber(cp, -1000, 1000);
const MULTIPLIER = -0.00368208; const MULTIPLIER = -0.00368208; // Source : https://github.com/lichess-org/lila/pull/11148
const winChances = 2 / (1 + Math.exp(MULTIPLIER * cpCeiled)) - 1; const winChances = 2 / (1 + Math.exp(MULTIPLIER * cpCeiled)) - 1;
return 50 + 50 * winChances; return 50 + 50 * winChances;
}; };

View File

@@ -1,6 +1,5 @@
import { EngineName } from "@/types/enums"; import { EngineName } from "@/types/enums";
import { import {
EstimatedElo,
EvaluateGameParams, EvaluateGameParams,
EvaluatePositionWithUpdateParams, EvaluatePositionWithUpdateParams,
GameEval, GameEval,
@@ -14,7 +13,7 @@ import { computeAccuracy } from "./helpers/accuracy";
import { getIsStalemate, getWhoIsCheckmated } from "../chess"; import { getIsStalemate, getWhoIsCheckmated } from "../chess";
import { getLichessEval } from "../lichess"; import { getLichessEval } from "../lichess";
import { getMovesClassification } from "./helpers/moveClassification"; import { getMovesClassification } from "./helpers/moveClassification";
import { estimateEloFromEngineOutput } from "./helpers/estimateElo"; import { computeEstimatedElo } from "./helpers/estimateElo";
import { EngineWorker, WorkerJob } from "@/types/engine"; import { EngineWorker, WorkerJob } from "@/types/engine";
export class UciEngine { export class UciEngine {
@@ -217,6 +216,7 @@ export class UciEngine {
depth = 16, depth = 16,
multiPv = this.multiPv, multiPv = this.multiPv,
setEvaluationProgress, setEvaluationProgress,
playersRatings,
}: EvaluateGameParams): Promise<GameEval> { }: EvaluateGameParams): Promise<GameEval> {
this.throwErrorIfNotReady(); this.throwErrorIfNotReady();
setEvaluationProgress?.(1); setEvaluationProgress?.(1);
@@ -278,7 +278,11 @@ export class UciEngine {
fens fens
); );
const accuracy = computeAccuracy(positions); const accuracy = computeAccuracy(positions);
const estimatedElo: EstimatedElo = estimateEloFromEngineOutput(positions); const estimatedElo = computeEstimatedElo(
positions,
playersRatings?.white,
playersRatings?.black
);
this.isReady = true; this.isReady = true;
return { return {

View File

@@ -10,11 +10,12 @@ import {
engineMultiPvAtom, engineMultiPvAtom,
engineNameAtom, engineNameAtom,
gameAtom, gameAtom,
gameEvalAtom,
} from "../../states"; } from "../../states";
import LineEvaluation from "./lineEvaluation"; import LineEvaluation from "./lineEvaluation";
import { useCurrentPosition } from "../../hooks/useCurrentPosition"; import { useCurrentPosition } from "../../hooks/useCurrentPosition";
import { LineEval } from "@/types/eval"; import { LineEval } from "@/types/eval";
import Accuracies from "./accuracies"; import PlayersMetric from "./playersMetric";
import MoveInfo from "./moveInfo"; import MoveInfo from "./moveInfo";
import Opening from "./opening"; import Opening from "./opening";
@@ -24,6 +25,7 @@ export default function AnalysisTab(props: GridProps) {
const position = useCurrentPosition(engineName); const position = useCurrentPosition(engineName);
const game = useAtomValue(gameAtom); const game = useAtomValue(gameAtom);
const board = useAtomValue(boardAtom); const board = useAtomValue(boardAtom);
const gameEval = useAtomValue(gameEvalAtom);
const boardHistory = board.history(); const boardHistory = board.history();
const gameHistory = game.history(); const gameHistory = game.history();
@@ -57,8 +59,21 @@ export default function AnalysisTab(props: GridProps) {
: { overflow: "hidden", overflowY: "auto", ...props.sx } : { overflow: "hidden", overflowY: "auto", ...props.sx }
} }
> >
<Accuracies params={"accurecy"} /> {gameEval && (
<Accuracies params={"rating"} /> <PlayersMetric
title="Accuracy"
whiteValue={`${gameEval.accuracy.white.toFixed(1)} %`}
blackValue={`${gameEval.accuracy.black.toFixed(1)} %`}
/>
)}
{gameEval?.estimatedElo && (
<PlayersMetric
title="Game Rating"
whiteValue={Math.round(gameEval.estimatedElo.white)}
blackValue={Math.round(gameEval.estimatedElo.black)}
/>
)}
<MoveInfo /> <MoveInfo />

View File

@@ -1,15 +1,16 @@
import { Grid2 as Grid, Typography } from "@mui/material"; import { Grid2 as Grid, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import { gameEvalAtom } from "../../states";
type props = {
params: "accurecy" | "rating";
};
export default function Accuracies(props: props) { interface Props {
const gameEval = useAtomValue(gameEvalAtom); title: string;
whiteValue: string | number;
if (!gameEval) return null; blackValue: string | number;
}
export default function PlayersMetric({
title,
whiteValue,
blackValue,
}: Props) {
return ( return (
<Grid <Grid
container container
@@ -27,14 +28,10 @@ export default function Accuracies(props: props) {
fontWeight="bold" fontWeight="bold"
border="1px solid #424242" border="1px solid #424242"
> >
{props.params === "accurecy" {whiteValue}
? `${gameEval?.accuracy.white.toFixed(1)} %`
: `${Math.round(gameEval?.estimatedElo.white as number)}`}
</Typography> </Typography>
<Typography align="center"> <Typography align="center">{title}</Typography>
{props.params === "accurecy" ? "Accuracies" : "Estimated Elo"}
</Typography>
<Typography <Typography
align="center" align="center"
sx={{ backgroundColor: "black", color: "white" }} sx={{ backgroundColor: "black", color: "white" }}
@@ -44,9 +41,7 @@ export default function Accuracies(props: props) {
fontWeight="bold" fontWeight="bold"
border="1px solid #424242" border="1px solid #424242"
> >
{props.params === "accurecy" {blackValue}
? `${gameEval?.accuracy.black.toFixed(1)} %`
: `${Math.round(gameEval?.estimatedElo.black as number)}`}
</Typography> </Typography>
</Grid> </Grid>
); );

View File

@@ -16,6 +16,7 @@ import { useEngine } from "@/hooks/useEngine";
import { logAnalyticsEvent } from "@/lib/firebase"; import { logAnalyticsEvent } from "@/lib/firebase";
import { SavedEvals } from "@/types/eval"; import { SavedEvals } from "@/types/eval";
import { useEffect, useCallback } from "react"; import { useEffect, useCallback } from "react";
import { usePlayersData } from "@/hooks/usePlayersData";
export default function AnalyzeButton() { export default function AnalyzeButton() {
const engineName = useAtomValue(engineNameAtom); const engineName = useAtomValue(engineNameAtom);
@@ -29,6 +30,7 @@ export default function AnalyzeButton() {
const [gameEval, setEval] = useAtom(gameEvalAtom); const [gameEval, setEval] = useAtom(gameEvalAtom);
const game = useAtomValue(gameAtom); const game = useAtomValue(gameAtom);
const setSavedEvals = useSetAtom(savedEvalsAtom); const setSavedEvals = useSetAtom(savedEvalsAtom);
const { white, black } = usePlayersData(gameAtom);
const readyToAnalyse = const readyToAnalyse =
engine?.getIsReady() && game.history().length > 0 && !evaluationProgress; engine?.getIsReady() && game.history().length > 0 && !evaluationProgress;
@@ -48,6 +50,10 @@ export default function AnalyzeButton() {
depth: engineDepth, depth: engineDepth,
multiPv: engineMultiPv, multiPv: engineMultiPv,
setEvaluationProgress, setEvaluationProgress,
playersRatings: {
white: white?.rating,
black: black?.rating,
},
}); });
setEval(newGameEval); setEval(newGameEval);
@@ -84,6 +90,8 @@ export default function AnalyzeButton() {
gameFromUrl, gameFromUrl,
setGameEval, setGameEval,
setSavedEvals, setSavedEvals,
white.rating,
black.rating,
]); ]);
// Automatically analyze when a new game is loaded and ready to analyze // Automatically analyze when a new game is loaded and ready to analyze

View File

@@ -22,9 +22,10 @@ export interface Accuracy {
} }
export interface EstimatedElo { export interface EstimatedElo {
white: number | null; white: number;
black: number | null; black: number;
} }
export interface EngineSettings { export interface EngineSettings {
engine: EngineName; engine: EngineName;
depth: number; depth: number;
@@ -35,7 +36,7 @@ export interface EngineSettings {
export interface GameEval { export interface GameEval {
positions: PositionEval[]; positions: PositionEval[];
accuracy: Accuracy; accuracy: Accuracy;
estimatedElo: EstimatedElo; estimatedElo?: EstimatedElo;
settings: EngineSettings; settings: EngineSettings;
} }
@@ -59,6 +60,7 @@ export interface EvaluateGameParams {
depth?: number; depth?: number;
multiPv?: number; multiPv?: number;
setEvaluationProgress?: (value: number) => void; setEvaluationProgress?: (value: number) => void;
playersRatings?: { white?: number; black?: number };
} }
export interface SavedEval { export interface SavedEval {