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[] =>
movesWinPercentage.slice(1).map((winPercent, 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 =
103.1668100711649 * Math.exp(-0.04354415386753951 * winDiff) -
3.166924740191411;

View File

@@ -1,54 +1,91 @@
import { ceilsNumber } from "@/lib/math";
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[]
): EstimatedElo => {
try {
if (!positions || positions.length === 0) {
return { white: null, black: null };
}
): { whiteCpl: number; blackCpl: number } => {
let previousCp = getPositionCp(positions[0]);
let totalCPLWhite = 0;
let totalCPLBlack = 0;
let moveCount = 0;
let previousCp = null;
let flag = true;
for (const moveAnalysis of positions) {
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);
const { whiteCpl, blackCpl } = positions.slice(1).reduce(
(acc, position, index) => {
const cp = getPositionCp(position);
if (index % 2 === 0) {
acc.whiteCpl += cp > previousCp ? 0 : Math.min(previousCp - cp, 1000);
} else {
totalCPLBlack += ceilsNumber(diff, -1000, 1000);
}
flag = !flag;
moveCount++;
}
previousCp = bestLine.cp;
}
}
acc.blackCpl += cp < previousCp ? 0 : Math.min(cp - previousCp, 1000);
}
if (moveCount === 0) {
return { white: null, black: null };
}
previousCp = cp;
return acc;
},
{ whiteCpl: 0, blackCpl: 0 }
);
const averageCPLWhite = totalCPLWhite / Math.ceil(moveCount / 2);
const averageCPLBlack = totalCPLBlack / Math.floor(moveCount / 2);
return {
whiteCpl: whiteCpl / Math.ceil((positions.length - 1) / 2),
blackCpl: blackCpl / Math.floor((positions.length - 1) / 2),
};
};
const estimateElo = (averageCPL: number) =>
3100 * Math.exp(-0.01 * averageCPL);
// Source: https://lichess.org/forum/general-chess-discussion/how-to-estimate-your-elo-for-a-game-using-acpl-and-what-it-realistically-means
const getEloFromAverageCpl = (averageCpl: number) =>
3100 * Math.exp(-0.01 * averageCpl);
const whiteElo = estimateElo(Math.abs(averageCPLWhite));
const blackElo = estimateElo(Math.abs(averageCPLBlack));
const getAverageCplFromElo = (elo: number) =>
-100 * Math.log(Math.min(elo, 3100) / 3100);
return { white: whiteElo, black: blackElo };
} catch (error) {
console.error("Error estimating Elo: ", error);
return { white: null, black: null };
const getEloFromRatingAndCpl = (
gameCpl: number,
rating: number | undefined
): 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);
};
// Source: https://github.com/lichess-org/lila/blob/a320a93b68dabee862b8093b1b2acdfe132b9966/modules/analyse/src/main/WinPercent.scala#L27
const getWinPercentageFromCp = (cp: number): number => {
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;
return 50 + 50 * winChances;
};

View File

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

View File

@@ -10,11 +10,12 @@ import {
engineMultiPvAtom,
engineNameAtom,
gameAtom,
gameEvalAtom,
} from "../../states";
import LineEvaluation from "./lineEvaluation";
import { useCurrentPosition } from "../../hooks/useCurrentPosition";
import { LineEval } from "@/types/eval";
import Accuracies from "./accuracies";
import PlayersMetric from "./playersMetric";
import MoveInfo from "./moveInfo";
import Opening from "./opening";
@@ -24,6 +25,7 @@ export default function AnalysisTab(props: GridProps) {
const position = useCurrentPosition(engineName);
const game = useAtomValue(gameAtom);
const board = useAtomValue(boardAtom);
const gameEval = useAtomValue(gameEvalAtom);
const boardHistory = board.history();
const gameHistory = game.history();
@@ -57,8 +59,21 @@ export default function AnalysisTab(props: GridProps) {
: { overflow: "hidden", overflowY: "auto", ...props.sx }
}
>
<Accuracies params={"accurecy"} />
<Accuracies params={"rating"} />
{gameEval && (
<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 />

View File

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

View File

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

View File

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