fix : game elo estimation
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user