fix : game elo estimation
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
} else {
|
||||
totalCPLBlack += ceilsNumber(diff, -1000, 1000);
|
||||
}
|
||||
flag = !flag;
|
||||
moveCount++;
|
||||
}
|
||||
previousCp = bestLine.cp;
|
||||
}
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user