Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Texel tuning on self-play games

Stage 16 · tag stage-16-tuning · code in crates/engine/src/tuning.rs, crates/tools/src/selfplay.rs, crates/tools/src/texel.rs

Chapters 9 and 15 gave cb2026 an evaluation with 411 pairs of numbers: piece values, 384 piece-square table entries, and the positional terms. Every one was set by hand, from chess principles and a feel for what’s reasonable. Hand-setting gets the signs right and the magnitudes roughly right. It can’t get hundreds of numbers right together, since they interact (a passed-pawn bonus and the pawn table both reward advanced pawns). This chapter lets the games decide.

The idea

An evaluation predicts the outcome of a game. Positions it scores +2 pawns should usually be won, positions near 0 should often be drawn. Given a large set of positions from real games, each labelled with the game’s result, we can measure how well the evaluation predicts, and adjust the numbers to predict better. This is Texel tuning, named after Peter Österlund’s engine Texel, which made it popular around 2014.

  1. Turn a score into an expected result with a sigmoid: σ(s) = 1 / (1 + 10^(−K·s/400)). A score of 0 expects ½, large scores expect 1 or 0.
  2. The error is the mean squared difference between expected and actual results.
  3. Fit K once, with the starting values. It only sets the scale between centipawns and results.
  4. Adjust the parameters to minimise the error.
/// The mean squared error between expected and actual results.
pub fn error(positions: &[TrainingPosition], values: &[(f64, f64)], k: f64) -> f64 {
    let total: f64 = positions
        .iter()
        .map(|p| {
            let expected = sigmoid(tuning::evaluate(&p.features, values), k);
            (p.result - expected).powi(2)
        })
        .sum();
    total / positions.len().max(1) as f64
}

The data: cb2026’s own games

The positions come from cb2026 playing itself, so no other engine’s games or data are used. The datagen tool plays thousands of short games:

  • each game starts from an opening position plus 8 random moves, so no two games are alike;
  • each move gets a fixed 6,000-node search, which is fast and gives the same games on any machine;
  • a game ends by the rules, or is adjudicated once the search sees a forced mate, or as a draw when the score stays near zero for long enough.

From each game it keeps the quiet positions: not in check, best move not a capture or promotion, no mate in sight. The evaluation is only ever asked about quiet positions, because the quiescence search resolves captures first, so those are the ones it should be fitted to.

/// Plays one self-play game from `start` and returns its quiet positions with the result.
pub fn play_game(engine: &mut Engine, start: &Position, nodes: u64) -> PlayedGame {
    engine.new_game();
    let stop = AtomicBool::new(false);
    let limits = Limits {
        nodes: Some(nodes),
        ..Limits::default()
    };
    let mut game = Game::from_position(start.clone());
    let mut recorded = Vec::new();
    let mut quiet_plies = 0;

    let result = loop {
        // The rules: is the game over?
        if let Some(outcome) = game.outcome() {
            break outcome.winner().map_or(0.5, winner);
        }
        if game.moves().len() >= MAX_PLIES {
            break 0.5;
        }

        let position = game.position();
        let mut score = None;
        let history = game.history_hashes();
        let reply = engine.search(position, &history, &limits, &stop, &mut |info| {
            score = Some(info.score)
        });
        let Some(mv) = reply.best_move else {
            break 0.5;
        };
        match score {
            // A forced mate: adjudicate.
            Some(Score::Mate(moves)) => {
                let mover = position.side_to_move();
                break winner(if moves > 0 { mover } else { !mover });
            }
            Some(Score::Centipawns(cp)) => {
                if is_quiet(position, mv) && cp.abs() < RECORD_SCORE_LIMIT {
                    // The search scores for the side to move; records keep White's side.
                    let score = match position.side_to_move() {
                        Color::White => cp,
                        Color::Black => -cp,
                    };
                    recorded.push(Recorded {
                        position: position.clone(),
                        score,
                        best_move: mv,
                    });
                }
                quiet_plies = if cp.abs() <= DRAW_ADJUDICATION_SCORE {
                    quiet_plies + 1
                } else {
                    0
                };
                if game.moves().len() >= DRAW_ADJUDICATION_PLY
                    && quiet_plies >= DRAW_ADJUDICATION_LENGTH
                {
                    break 0.5;
                }
            }
            None => {}
        }
        game.play(mv).expect("the engine plays legal moves");
    };

    PlayedGame {
        positions: recorded,
        result,
    }
}

The evaluation as a dot product

Tuning hundreds of parameters on hundreds of thousands of positions needs the error’s gradient: for each parameter, which direction reduces the error. Computing that by trying each parameter one step up and down (the original Texel method) means hundreds of full passes over the data per step. There is a much faster way.

Every term of cb2026’s evaluation has the form “this parameter, this many times, for this side”. The pawn on e4 is “pawn piece-square entry e4, once, for White”. A knight with 6 safe squares is “knight mobility, 6 − typical times”. So the evaluation can report its terms to any sink instead of adding them up:

/// One evaluation parameter: a `(middlegame, endgame)` pair in [`EvalParams`]. Every term
/// of the evaluation is "this parameter, this many times, for this side".
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Param {
    /// A piece's value.
    Material(PieceType),
    /// A piece-square table entry (table index, see [`pst_index`]).
    Pst(PieceType, usize),
    /// Mobility per safe square.
    Mobility(PieceType),
    /// A doubled pawn.
    Doubled,
    /// An isolated pawn.
    Isolated,
    /// A passed pawn on this relative rank.
    Passed(usize),
    /// The bishop pair.
    BishopPair,
    /// A rook on an open file.
    RookOpenFile,
    /// A rook on a semi-open file.
    RookSemiOpenFile,
    /// A pawn in the king's shield.
    PawnShield,
    /// The side to move.
    Tempo,
}

impl Param {
    /// How many parameters there are.
    pub const COUNT: usize = 411;

    /// The parameter's position in a flat list of all parameters, `0..COUNT`.
    pub fn index(self) -> usize {
        match self {
            Param::Material(pt) => pt.index(),
            Param::Pst(pt, square) => 6 + pt.index() * 64 + square,
            Param::Mobility(pt) => 390 + pt.index(),
            Param::Doubled => 396,
            Param::Isolated => 397,
            Param::Passed(rank) => 398 + rank,
            Param::BishopPair => 406,
            Param::RookOpenFile => 407,
            Param::RookSemiOpenFile => 408,
            Param::PawnShield => 409,
            Param::Tempo => 410,
        }
    }

    /// Every parameter, in [`Param::index`] order.
    pub fn all() -> Vec<Param> {
        let mut all = Vec::with_capacity(Param::COUNT);
        all.extend(PieceType::ALL.map(Param::Material));
        for pt in PieceType::ALL {
            all.extend((0..64).map(|square| Param::Pst(pt, square)));
        }
        all.extend(PieceType::ALL.map(Param::Mobility));
        all.extend([Param::Doubled, Param::Isolated]);
        all.extend((0..8).map(Param::Passed));
        all.extend([
            Param::BishopPair,
            Param::RookOpenFile,
            Param::RookSemiOpenFile,
            Param::PawnShield,
            Param::Tempo,
        ]);
        all
    }
}

impl EvalParams {
    /// The current value of `param`.
    pub fn value(&self, param: Param) -> MgEg {
        let t = &self.terms;
        match param {
            Param::Material(pt) => (self.mg_values[pt.index()], self.eg_values[pt.index()]),
            Param::Pst(pt, sq) => (self.mg_pst[pt.index()][sq], self.eg_pst[pt.index()][sq]),
            Param::Mobility(pt) => t.mobility[pt.index()],
            Param::Doubled => t.doubled,
            Param::Isolated => t.isolated,
            Param::Passed(rank) => t.passed[rank],
            Param::BishopPair => t.bishop_pair,
            Param::RookOpenFile => t.rook_open_file,
            Param::RookSemiOpenFile => t.rook_semi_open_file,
            Param::PawnShield => t.pawn_shield,
            Param::Tempo => t.tempo,
        }
    }

    /// Sets `param` to `value`.
    pub fn set_value(&mut self, param: Param, value: MgEg) {
        let t = &mut self.terms;
        match param {
            Param::Material(pt) => {
                self.mg_values[pt.index()] = value.0;
                self.eg_values[pt.index()] = value.1;
            }
            Param::Pst(pt, sq) => {
                self.mg_pst[pt.index()][sq] = value.0;
                self.eg_pst[pt.index()][sq] = value.1;
            }
            Param::Mobility(pt) => t.mobility[pt.index()] = value,
            Param::Doubled => t.doubled = value,
            Param::Isolated => t.isolated = value,
            Param::Passed(rank) => t.passed[rank] = value,
            Param::BishopPair => t.bishop_pair = value,
            Param::RookOpenFile => t.rook_open_file = value,
            Param::RookSemiOpenFile => t.rook_semi_open_file = value,
            Param::PawnShield => t.pawn_shield = value,
            Param::Tempo => t.tempo = value,
        }
    }
}

/// Receives the evaluation's terms as [`collect`] finds them. The evaluation itself adds up
/// their values ([`trace`]); the tuner records how often each parameter applies.
pub trait TermSink {
    /// `side` gets parameter `param`, `count` times (`count` may be negative).
    fn term(&mut self, side: Color, param: Param, count: i32);
    /// `side`'s king is in danger: a middlegame penalty that isn't a parameter times a count
    /// (it's quadratic in the attacks), so it is passed on as a value.
    fn king_danger(&mut self, side: Color, penalty: i32);
}

The engine’s own evaluation is one sink: it multiplies each term by its value and adds it up. The tuner’s sink records coefficients instead: for each parameter, how many times White gets it minus how many times Black does. After that, the middlegame and endgame scores are simple sums of coefficient × value, and the derivative of the score with respect to a parameter is just its coefficient (times its share of the phase blend). The engine and the tuner share one implementation of the terms, so they can’t drift apart, and a test checks that the coefficients reproduce the evaluation exactly.

King danger is the one term that isn’t linear: it is the square of the attack count. The tuner treats it as a constant computed with the current values and doesn’t tune it.

Fitting: gradient descent with Adam

/// The error's gradient with respect to every parameter's middlegame and endgame value.
pub fn gradient(positions: &[TrainingPosition], values: &[(f64, f64)], k: f64) -> Vec<(f64, f64)> {
    let mut grad = vec![(0.0, 0.0); values.len()];
    let scale = k * std::f64::consts::LN_10 / 400.0;
    let max_phase = f64::from(MAX_PHASE);
    for p in positions {
        let s = sigmoid(tuning::evaluate(&p.features, values), k);
        // d(error)/d(score) for this position (the mean's 1/N is applied at the end).
        let d_score = -2.0 * (p.result - s) * s * (1.0 - s) * scale;
        let mg_share = f64::from(p.features.phase) / max_phase;
        for &(index, coefficient) in &p.features.coefficients {
            let c = f64::from(coefficient) * d_score;
            let g = &mut grad[index as usize];
            g.0 += c * mg_share;
            g.1 += c * (1.0 - mg_share);
        }
    }
    let n = positions.len().max(1) as f64;
    for g in &mut grad {
        g.0 /= n;
        g.1 /= n;
    }
    grad
}

Plain gradient descent struggles here. Material appears in every position, while “knight on a8” appears in a handful, so their gradients differ by orders of magnitude, and one step size can’t suit both. Adam keeps running averages of each parameter’s gradient and of its square, and scales each parameter’s step by them. Rare and common parameters then move at similar rates.

    /// Moves `values` one step against `grad`.
    pub fn step(&mut self, values: &mut [(f64, f64)], grad: &[(f64, f64)]) {
        self.steps += 1;
        let correction1 = 1.0 - Self::BETA1.powi(self.steps);
        let correction2 = 1.0 - Self::BETA2.powi(self.steps);
        let update = |value: &mut f64, g: f64, m: &mut f64, v: &mut f64| {
            *m = Self::BETA1 * *m + (1.0 - Self::BETA1) * g;
            *v = Self::BETA2 * *v + (1.0 - Self::BETA2) * g * g;
            let m_hat = *m / correction1;
            let v_hat = *v / correction2;
            *value -= self.learning_rate * m_hat / (v_hat.sqrt() + Self::EPSILON);
        };
        for i in 0..values.len() {
            let (m, v) = (&mut self.first[i], &mut self.second[i]);
            update(&mut values[i].0, grad[i].0, &mut m.0, &mut v.0);
            update(&mut values[i].1, grad[i].1, &mut m.1, &mut v.1);
        }
    }

A unit test checks the gradient against finite differences, which is the standard guard against a sign or factor error in hand-derived gradients.

Running it

The games were the hand-crafted evaluation’s. Since stage 21 datagen plays with the built-in network unless told otherwise, so to repeat them, ask for no network (--evalfile none):

cargo run --release -p cb2026-tools --bin datagen -- --out data.txt --games 8000 --seed 2026 --evalfile none
cargo run --release -p cb2026-tools --bin datagen -- --out data2.txt --games 16000 --seed 3033 --evalfile none
cat data.txt data2.txt > data-all.txt
cargo run --release -p cb2026-tools --bin tune -- --data data-all.txt --from hand \
    --epochs 1500 --lambda 3e-9 --min-count 500

tune writes crates/engine/src/tuned.rs, a generated table of all 411 values, available as EvalParams::tuned() (and in the shell with set eval tuned). The engine’s default is still the hand-written set, EvalParams::hand_set(): the tuned values didn’t measure stronger (see “What it’s worth” below). The data file isn’t committed (it’s large, and anyone can regenerate it).

What happened: overfitting, then regularisation

The first run used 498,522 positions from 8,000 games and tuned all parameters freely for 3,000 epochs. The error dropped from 0.0845 to 0.0798, which looked like a success. The values said otherwise:

ParameterHand-set (mg, eg)First run (mg, eg)
Knight value320, 300346, 321
Knight on g8−35, −35−530, +321
White king on c8−50, −25+1246, −221

Material moved sensibly. The piece-square entries for squares a piece almost never visits went wild, because Adam moves every parameter at a similar rate whether a hundred positions support it or a hundred thousand. Those numbers fit the training positions and mean nothing anywhere else. This is overfitting.

Two standard remedies fixed it:

  • A minimum number of occurrences. A parameter seen in fewer than 500 positions keeps its starting value.
  • L2 regularisation. Each parameter is pulled back toward its starting value with strength λ, so it only moves as far as the evidence pushes it.
/// Adds L2 regularisation to `grad`: a pull of strength `lambda` back toward `start` for every
/// parameter, so a parameter only moves as far as the data can justify. Parameters seen in
/// fewer than `min_count` positions get no gradient at all and keep their starting values.
pub fn regularize(
    grad: &mut [(f64, f64)],
    values: &[(f64, f64)],
    start: &[(f64, f64)],
    seen: &[usize],
    lambda: f64,
    min_count: usize,
) {
    for i in 0..grad.len() {
        if seen[i] < min_count {
            grad[i] = (0.0, 0.0);
        } else {
            grad[i].0 += 2.0 * lambda * (values[i].0 - start[i].0);
            grad[i].1 += 2.0 * lambda * (values[i].1 - start[i].1);
        }
    }
}

To choose λ honestly, the last 10% of the data (about 50,000 positions, mostly from whole games not used for training) is held out as a validation set. Its error shows whether a fit generalises:

λTraining errorValidation error
(hand-set values)0.084320.08570
0 (no regularisation)0.079370.08704
2 × 10⁻⁹0.080140.08546
1 × 10⁻⁸0.080990.08512
3 × 10⁻⁸0.081660.08508
1 × 10⁻⁷0.082370.08514

Without regularisation, the training error fell the most but the validation error rose: the fit made predictions on new games worse. The best λ improved validation by only 0.7%. 500,000 positions from 8,000 games are not much evidence for 822 numbers.

So a second batch of 16,000 games was generated (a different random seed), for 1.5 million positions in all, and the comparison repeated:

λTraining errorValidation error
(hand-set values)0.085220.08467
00.082120.08368
1 × 10⁻⁹0.082380.08333
3 × 10⁻⁹0.082600.08328
1 × 10⁻⁸0.082940.08335
3 × 10⁻⁸0.083300.08351

With three times the data, even the unregularised fit now generalises. The best validation gain doubles to 1.7%, and the best λ is ten times weaker, because the data can now justify larger moves on its own. That’s the usual pattern: regularisation matters most when data is scarce. The values in tuned.rs come from the λ = 3 × 10⁻⁹ run.

Pitfalls

  • Overfitting. With few positions, rare parameters get fitted to noise, as the first run above shows. Hold out a validation set, regularise, and get more data.
  • Correlated data. Positions from the same game share its result, so 500,000 positions from 8,000 games carry less information than 500,000 independent ones.
  • The engine that made the data. The games were played by the engine being tuned. Repeating the cycle (generate with the tuned engine, tune again) usually helps a little more.
  • Error isn’t Elo. A lower error is a good sign, not proof. Only games decide, so the tuned values must pass an SPRT like any other change.

What it’s worth

TestResult
Tuned vs hand-set values, 8+0.08, SPRT [0, 5], cloud+310 −313 =187 over 810 games, −1.3 ± 21.0 Elo: no verdict (LLR −0.17) when the run hit the session’s two-hour limit

No measurable gain. The 95% interval runs from about −22 to +20 Elo, so the tuned values might be a little better or a little worse, but they aren’t the clear win the lower error promised. That is the “error isn’t Elo” pitfall above, met for real. Likely causes:

  • Too little data. About 1.5 million positions from 24,000 short games is small for 411 parameters; strong engines tune on hundreds of millions.
  • Strong regularisation. The settings that stopped the overfitting also kept most values close to the hand-set ones, so the evaluation barely changed.
  • The hand-set values were reasonable. Tuning helps most when the starting point is poor.

So the engine keeps the hand-set values, and this stage contributes the tools: self-play data generation, the tuner, and a tuned set anyone can try (set eval tuned, or an SPRT of their own). A later round, with more data from the stronger engine of later stages, can try again. With the hand-set values as default the stage changes nothing in play: the bench is the same as stage 15’s.

Further reading

  • Chess Programming Wiki: Texel’s Tuning Method, Automated Tuning.
  • Peter Österlund, the original description of the method on the TalkChess forum (2014).
  • Diederik Kingma and Jimmy Ba, “Adam: A Method for Stochastic Optimization”, ICLR 2015.