I was curious to see if I could determine point values for chess pieces based on endgame tablebases. I use the model that the difference of the sum of the players' pieces should determine the expected value of the game to a given player via a sigmoid function. The end result is the following:
The pawns are overrated here, because we use endgame tablebases and in endgames, pawns are much more likely to promote, and therefore more valuable. Queen to rook ratio is pretty close to 9:5, and bishops and knights are slightly weaker relatively than the normal point values associated with them. Here are the point values rescaled to make the rook be worth 5:
Code below. Download stats.json from the lichess tablebase to run the code.
import fs from "node:fs";
const stats = JSON.parse(fs.readFileSync("stats.json", "utf-8"));
// How much a cursed win counts as (cursed win = would be a win if not for the 50-move rule)
const CURSE_FACTOR = 0.5;
const data = Object.entries(stats).map(([key, e]: [string, any]) => {
const wins = e.histogram.white.wdl[2] + e.histogram.black.wdl[-2];
const cursedWins = e.histogram.white.wdl[1] + e.histogram.black.wdl[-1];
const draws = e.histogram.white.wdl[0] + e.histogram.black.wdl[0];
const blessedLosses = e.histogram.white.wdl[-1] + e.histogram.black.wdl[1];
const losses = e.histogram.white.wdl[-2] + e.histogram.black.wdl[2];
const total = wins + cursedWins + draws + blessedLosses + losses;
const gamePoints =
(wins +
CURSE_FACTOR * cursedWins -
CURSE_FACTOR * blessedLosses -
losses) /
total;
return {
material: key,
gamePoints,
};
});
type Piece = "P" | "N" | "B" | "R" | "Q";
type Points = Record<Piece, number>;
const sigmoid = (x: number) => Math.tanh(x);
const sum = (arr: number[]) => arr.reduce((a, b) => a + b, 0);
const predictGame = (points: Points, material: [string, string]) => {
const [mat1, mat2] = material;
return sigmoid(
sum([...mat1].map((p) => (p in points ? points[p as Piece] : 0))) -
sum([...mat2].map((p) => (p in points ? points[p as Piece] : 0))),
);
};
const baselineLoss = (() => {
let loss = 0;
for (const { gamePoints } of data) {
const prediction = 0;
loss += Math.pow(gamePoints - prediction, 2);
}
return loss;
})();
console.log("BASELINE LOSS", baselineLoss);
const computeLoss = (points: Points) => {
let loss = 0;
for (const { material, gamePoints } of data) {
const prediction = predictGame(
points,
material.split("v") as [string, string],
);
loss += Math.pow(gamePoints - prediction, 2);
}
return loss;
};
const optimizeInRange = (
constraints: Record<Piece, { min: number; max: number; step: number }>,
): Points => {
let bestLoss = Infinity;
let best: Points | null = null;
let i = 0;
for (
let p = constraints.P.min;
p <= constraints.P.max;
p += constraints.P.step
) {
for (
let n = constraints.N.min;
n <= constraints.N.max;
n += constraints.N.step
) {
for (
let b = constraints.B.min;
b <= constraints.B.max;
b += constraints.B.step
) {
for (
let r = constraints.R.min;
r <= constraints.R.max;
r += constraints.R.step
) {
for (
let q = constraints.Q.min;
q <= constraints.Q.max;
q += constraints.Q.step
) {
i += 1;
const pointValues = {
P: p,
N: n,
B: b,
R: r,
Q: q,
};
const loss = computeLoss(pointValues);
if (loss < bestLoss) {
best = pointValues;
bestLoss = loss;
// console.log(i, bestLoss);
}
}
}
}
}
}
return best as Points;
};
const nudge = (points: Points, epsilon: number) => {
return optimizeInRange(
Object.fromEntries(
Object.entries(points).map(([key, value]) => {
return [
key,
{
min: value - epsilon,
max: value + epsilon,
step: epsilon,
},
];
}),
) as any,
);
};
const optimizeForEpsilon = (initialPoints: Points, epsilon: number) => {
let prevLoss = 0;
let points = { ...initialPoints };
for (let i = 0; i < 1000; i += 1) {
points = nudge(points, epsilon);
const loss = computeLoss(points);
if (loss === prevLoss) {
break;
}
prevLoss = loss;
console.log(i, loss);
if (i > 998) {
console.log("TOOK TOO LONG");
}
}
return points;
};
let pointValues = {
P: 0,
N: 0,
B: 0,
R: 0,
Q: 0,
};
pointValues = optimizeForEpsilon(pointValues, 1);
pointValues = optimizeForEpsilon(pointValues, 0.1);
pointValues = optimizeForEpsilon(pointValues, 0.01);
pointValues = optimizeForEpsilon(pointValues, 0.001);
console.log(pointValues);