feat : add accuracy calculation

This commit is contained in:
GuillaumeSD
2024-02-28 23:29:30 +01:00
parent 43017c89ee
commit a8da159870
8 changed files with 283 additions and 677 deletions

View File

@@ -3,7 +3,11 @@ import { Game } from "@/types/game";
import { Chess } from "chess.js";
export const getFens = (game: Chess): string[] => {
return game.history({ verbose: true }).map((move) => move.before);
const history = game.history({ verbose: true });
const fens = history.map((move) => move.before);
fens.push(history[history.length - 1].after);
return fens;
};
export const getGameFromPgn = (pgn: string): Chess => {
@@ -106,3 +110,9 @@ export const getEvaluationBarValue = (
return { whiteBarPercentage, label: pEval.toFixed(1) };
};
export const getWhoIsCheckmated = (fen: string): "w" | "b" | null => {
const game = new Chess(fen);
if (!game.isCheckmate()) return null;
return game.turn();
};

View File

@@ -0,0 +1,111 @@
import {
ceilsNumber,
getHarmonicMean,
getStandardDeviation,
getWeightedMean,
} from "@/lib/helpers";
import { Accuracy, MoveEval } from "@/types/eval";
export const computeAccuracy = (moves: MoveEval[]): Accuracy => {
const movesWinPercentage = moves.map(getPositionWinPercentage);
const weights = getAccuracyWeights(movesWinPercentage);
const movesAccuracy = getMovesAccuracy(movesWinPercentage);
const whiteAccuracy = getPlayerAccuracy(movesAccuracy, weights, "white");
const blackAccuracy = getPlayerAccuracy(movesAccuracy, weights, "black");
return {
white: whiteAccuracy,
black: blackAccuracy,
};
};
const getPlayerAccuracy = (
movesAccuracy: number[],
weights: number[],
player: "white" | "black"
): number => {
const remainder = player === "white" ? 0 : 1;
const playerAccuracies = movesAccuracy.filter(
(_, index) => index % 2 === remainder
);
const playerWeights = weights.filter((_, index) => index % 2 === remainder);
const weightedMean = getWeightedMean(playerAccuracies, playerWeights);
const harmonicMean = getHarmonicMean(playerAccuracies);
return (weightedMean + harmonicMean) / 2;
};
const getAccuracyWeights = (movesWinPercentage: number[]): number[] => {
const windowSize = ceilsNumber(
Math.ceil(movesWinPercentage.length / 10),
2,
8
);
const windows: number[][] = [];
const halfWindowSize = Math.round(windowSize / 2);
for (let i = 1; i < movesWinPercentage.length; i++) {
const startIdx = i - halfWindowSize;
const endIdx = i + halfWindowSize;
if (startIdx < 0) {
windows.push(movesWinPercentage.slice(0, windowSize));
continue;
}
if (endIdx > movesWinPercentage.length) {
windows.push(movesWinPercentage.slice(-windowSize));
continue;
}
windows.push(movesWinPercentage.slice(startIdx, endIdx));
}
const weights = windows.map((window) => {
const std = getStandardDeviation(window);
return ceilsNumber(std, 0.5, 12);
});
return weights;
};
const getMovesAccuracy = (movesWinPercentage: number[]): number[] =>
movesWinPercentage.slice(1).map((winPercent, index) => {
const lastWinPercent = movesWinPercentage[index];
const winDiff = Math.abs(lastWinPercent - winPercent);
const rawAccuracy =
103.1668100711649 * Math.exp(-0.04354415386753951 * winDiff) -
3.166924740191411;
return Math.min(100, Math.max(0, rawAccuracy + 1));
});
const getPositionWinPercentage = (move: MoveEval): number => {
if (move.lines[0].cp !== undefined) {
return getWinPercentageFromCp(move.lines[0].cp);
}
if (move.lines[0].mate !== undefined) {
return getWinPercentageFromMate(move.lines[0].mate);
}
throw new Error("No cp or mate in move");
};
const getWinPercentageFromMate = (mate: number): number => {
const mateInf = mate * Infinity;
return getWinPercentageFromCp(mateInf);
};
const getWinPercentageFromCp = (cp: number): number => {
const cpCeiled = ceilsNumber(cp, -1000, 1000);
const MULTIPLIER = -0.00368208;
const winChances = 2 / (1 + Math.exp(MULTIPLIER * cpCeiled)) - 1;
return 50 + 50 * winChances;
};

View File

@@ -0,0 +1,99 @@
import { LineEval, MoveEval } from "@/types/eval";
export const parseEvaluationResults = (
results: string[],
whiteToPlay: boolean
): MoveEval => {
const parsedResults: MoveEval = {
bestMove: "",
lines: [],
};
const tempResults: Record<string, LineEval> = {};
for (const result of results) {
if (result.startsWith("bestmove")) {
const bestMove = getResultProperty(result, "bestmove");
if (bestMove) {
parsedResults.bestMove = bestMove;
}
}
if (result.startsWith("info")) {
const pv = getResultPv(result);
const multiPv = getResultProperty(result, "multipv");
const depth = getResultProperty(result, "depth");
if (!pv || !multiPv || !depth) continue;
if (
tempResults[multiPv] &&
parseInt(depth) < tempResults[multiPv].depth
) {
continue;
}
const cp = getResultProperty(result, "cp");
const mate = getResultProperty(result, "mate");
tempResults[multiPv] = {
pv,
cp: cp ? parseInt(cp) : undefined,
mate: mate ? parseInt(mate) : undefined,
depth: parseInt(depth),
multiPv: parseInt(multiPv),
};
}
}
parsedResults.lines = Object.values(tempResults).sort(sortLines);
if (!whiteToPlay) {
parsedResults.lines = parsedResults.lines.map((line) => ({
...line,
cp: line.cp ? -line.cp : line.cp,
mate: line.mate ? -line.mate : line.mate,
}));
}
return parsedResults;
};
const sortLines = (a: LineEval, b: LineEval): number => {
if (a.mate !== undefined && b.mate !== undefined) {
return a.mate - b.mate;
}
if (a.mate !== undefined) {
return -a.mate;
}
if (b.mate !== undefined) {
return b.mate;
}
return (b.cp ?? 0) - (a.cp ?? 0);
};
const getResultProperty = (
result: string,
property: string
): string | undefined => {
const splitResult = result.split(" ");
const propertyIndex = splitResult.indexOf(property);
if (propertyIndex === -1 || propertyIndex + 1 >= splitResult.length) {
return undefined;
}
return splitResult[propertyIndex + 1];
};
const getResultPv = (result: string): string[] | undefined => {
const splitResult = result.split(" ");
const pvIndex = splitResult.indexOf("pv");
if (pvIndex === -1 || pvIndex + 1 >= splitResult.length) {
return undefined;
}
return splitResult.slice(pvIndex + 1);
};

View File

@@ -2,9 +2,11 @@ import { EngineName } from "@/types/enums";
import {
EvaluatePositionWithUpdateParams,
GameEval,
LineEval,
MoveEval,
} from "@/types/eval";
import { parseEvaluationResults } from "./helpers/parseResults";
import { computeAccuracy } from "./helpers/accuracy";
import { getWhoIsCheckmated } from "../chess";
export abstract class UciEngine {
private worker: Worker;
@@ -104,14 +106,31 @@ export abstract class UciEngine {
const moves: MoveEval[] = [];
for (const fen of fens) {
const whoIsCheckmated = getWhoIsCheckmated(fen);
if (whoIsCheckmated) {
moves.push({
bestMove: "",
lines: [
{
pv: [],
depth: 0,
multiPv: 1,
mate: whoIsCheckmated === "w" ? -1 : 1,
},
],
});
continue;
}
const result = await this.evaluatePosition(fen, depth);
moves.push(result);
}
const accuracy = computeAccuracy(moves);
this.ready = true;
return {
moves,
accuracy: { white: 82.34, black: 67.49 }, // TODO: Calculate accuracy
moves: moves.slice(0, -1),
accuracy,
settings: {
engine: this.engineName,
date: new Date().toISOString(),
@@ -131,7 +150,7 @@ export abstract class UciEngine {
const whiteToPlay = fen.split(" ")[1] === "w";
return this.parseResults(results, whiteToPlay);
return parseEvaluationResults(results, whiteToPlay);
}
public async evaluatePositionWithUpdate({
@@ -148,7 +167,7 @@ export abstract class UciEngine {
const whiteToPlay = fen.split(" ")[1] === "w";
const onNewMessage = (messages: string[]) => {
const parsedResults = this.parseResults(messages, whiteToPlay);
const parsedResults = parseEvaluationResults(messages, whiteToPlay);
setPartialEval(parsedResults);
};
@@ -159,99 +178,4 @@ export abstract class UciEngine {
onNewMessage
);
}
private parseResults(results: string[], whiteToPlay: boolean): MoveEval {
const parsedResults: MoveEval = {
bestMove: "",
lines: [],
};
const tempResults: Record<string, LineEval> = {};
for (const result of results) {
if (result.startsWith("bestmove")) {
const bestMove = this.getResultProperty(result, "bestmove");
if (bestMove) {
parsedResults.bestMove = bestMove;
}
}
if (result.startsWith("info")) {
const pv = this.getResultPv(result);
const multiPv = this.getResultProperty(result, "multipv");
const depth = this.getResultProperty(result, "depth");
if (!pv || !multiPv || !depth) continue;
if (
tempResults[multiPv] &&
parseInt(depth) < tempResults[multiPv].depth
) {
continue;
}
const cp = this.getResultProperty(result, "cp");
const mate = this.getResultProperty(result, "mate");
tempResults[multiPv] = {
pv,
cp: cp ? parseInt(cp) : undefined,
mate: mate ? parseInt(mate) : undefined,
depth: parseInt(depth),
multiPv: parseInt(multiPv),
};
}
}
parsedResults.lines = Object.values(tempResults).sort(this.sortLines);
if (!whiteToPlay) {
parsedResults.lines = parsedResults.lines.map((line) => ({
...line,
cp: line.cp ? -line.cp : line.cp,
mate: line.mate ? -line.mate : line.mate,
}));
}
return parsedResults;
}
private sortLines(a: LineEval, b: LineEval): number {
if (a.mate !== undefined && b.mate !== undefined) {
return a.mate - b.mate;
}
if (a.mate !== undefined) {
return -a.mate;
}
if (b.mate !== undefined) {
return b.mate;
}
return (b.cp ?? 0) - (a.cp ?? 0);
}
private getResultProperty(
result: string,
property: string
): string | undefined {
const splitResult = result.split(" ");
const propertyIndex = splitResult.indexOf(property);
if (propertyIndex === -1 || propertyIndex + 1 >= splitResult.length) {
return undefined;
}
return splitResult[propertyIndex + 1];
}
private getResultPv(result: string): string[] | undefined {
const splitResult = result.split(" ");
const pvIndex = splitResult.indexOf("pv");
if (pvIndex === -1 || pvIndex + 1 >= splitResult.length) {
return undefined;
}
return splitResult.slice(pvIndex + 1);
}
}

View File

@@ -5,3 +5,37 @@ export const getPaddedMonth = (month: number) => {
export const capitalize = (s: string) => {
return s.charAt(0).toUpperCase() + s.slice(1);
};
export const ceilsNumber = (number: number, min: number, max: number) => {
if (number > max) return max;
if (number < min) return min;
return number;
};
export const getHarmonicMean = (array: number[]) => {
const sum = array.reduce((acc, curr) => acc + 1 / curr, 0);
return array.length / sum;
};
export const getStandardDeviation = (array: number[]) => {
const n = array.length;
const mean = array.reduce((a, b) => a + b) / n;
return Math.sqrt(
array.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n
);
};
export const getWeightedMean = (array: number[], weights: number[]) => {
if (array.length > weights.length)
throw new Error("Weights array is too short");
const weightedSum = array.reduce(
(acc, curr, index) => acc + curr * weights[index],
0
);
const weightSum = weights
.slice(0, array.length)
.reduce((acc, curr) => acc + curr, 0);
return weightedSum / weightSum;
};