NNUE Evaluation¶
The classical evaluation of Ch. Evaluation is a hand-written sum of material, piece-square, pawn-structure and mobility terms. NNUE — efficiently updatable neural network — replaces or corrects that judgement with a small neural network evaluated at every leaf. Kirin supports its own network format, .knnue, with a training and conversion pipeline in kirin/tools/.
Note
NNUE is off by default, and turning it on is a two-step operation. Reading only Use NNUE would leave you with an engine that appears to ignore the setting — see §The one thing that catches everyone, which is the single most important section of this chapter.
Why a second evaluation at all¶
A classical evaluation is a set of human hypotheses about what matters, each with a hand-tuned weight. It is fast, debuggable, and permanently limited by the fact that somebody had to think of every term. A network trained on evaluated positions learns interactions nobody enumerated — the value of a knight outpost given a specific pawn structure and king position — and it does so at a cost the search can afford, because the accumulator is updated incrementally as moves are made and unmade rather than recomputed from scratch.
The trade is that a network is opaque. When a classical term misjudges a position you can read the term; when a network does, you retrain. This is why Kirin keeps both, and why the two are combined rather than switched between (§Blend and residual: how the two evaluations combine).
The .knnue format¶
A network is a flat little-endian binary. The layout is fixed by kirin/src/engine/nnue.h:
Field |
Type |
Meaning |
|---|---|---|
magic |
|
the literal |
version |
|
format version, currently |
feature count |
|
selects the feature set (below) |
hidden size |
|
neurons in the hidden layer |
activation limit |
|
clipped-ReLU ceiling |
output bias |
|
scalar added to the output |
hidden biases |
|
one per hidden neuron |
output weights |
|
one per hidden neuron |
feature weights |
|
|
Weights are integers throughout. There is no floating point in the inference path, which keeps evaluation deterministic across machines — a property the reproducibility argument in Ch. Measuring Strength depends on, and one that a float network would quietly cost.
Feature sets¶
The feature count is not merely a size; it selects how the position is encoded:
Count |
Feature set |
Encoding |
|---|---|---|
768 |
piece-square |
$12 \times 64$: one input per (piece, square) |
13 056 |
king bucket |
piece-square, bucketed by the side-to-move king’s square |
99 072 |
HalfKA |
piece-square relative to both kings, dual perspective |
The progression is the standard one. Plain piece-square knows nothing about king safety context: a knight on f5 is scored identically whether the enemy king is on g8 or a1. King-bucketing conditions the whole board on where the king stands. HalfKA carries a separate perspective for each side, which is what lets the network learn asymmetric king-safety judgements properly, at roughly $129\times$ the weight count of the flat set.
Loading a network¶
setoption name EvalFile value networks/kirin-lichess.knnue
setoption name Use NNUE value true
setoption name NNUE Blend value 100
If loading fails the engine does not silently fall back to a broken state: getNNUELastError() carries the reason and getNNUESource() reports what is actually loaded. A bootstrap network — format-valid but deliberately not strong — can be generated with cmake --build build --target bootstrap_nnue; it exists so the loader can be smoke-tested without shipping a real net, and it should never be mistaken for one.
Blend and residual: how the two evaluations combine¶
Kirin does not choose between classical and NNUE. It combines them, in one of two modes.
Blend mode (default)¶
A straight linear interpolation, in evaluation.cpp:
return (classicalScore * (100 - nnueBlendPercent)
+ nnueScore * nnueBlendPercent) / 100;
NNUE Blend is a percentage: 0 is pure classical, 100 is pure network, and intermediate values mix. Blending is useful while a network is being evaluated — a net that is strong in the middlegame but unreliable in endgames can still contribute at 40 % without being trusted outright.
Residual mode¶
Setting NNUE Residual changes the network’s role from evaluator to corrector. The classical score becomes the base and the network supplies an adjustment:
static int applyNNUEPolicy(int classicalScore, int nnueScore) {
int correction = nnueScore;
if (nnueResidualClamp > 0) {
if (correction > nnueResidualClamp) correction = nnueResidualClamp;
if (correction < -nnueResidualClamp) correction = -nnueResidualClamp;
}
if (nnueClassicalLimit > 0
&& std::abs(classicalScore) > nnueClassicalLimit) {
correction = 0;
}
if (nnuePhaseScale) {
correction = (correction * nonPawnMaterialPhase()) / 100;
}
return classicalScore + (correction * nnueBlendPercent) / 100;
}
Three guards shape the correction, and each answers a specific failure mode:
NNUE Residual Clampbounds how far the network may move the score. A network that is wrong is usually wrong by a lot; a clamp turns a catastrophic misjudgement into a survivable one.NNUE Classical Limitzeroes the correction entirely once the classical score already exceeds a threshold. In a position that is plainly winning by three pawns, the network has nothing useful to add and everything to risk.NNUE Phase Scalescales the correction by remaining non-pawn material, fading the network out as the endgame approaches. Endgames are where networks are weakest and where exact classical rules (passed pawns, opposition) are strongest.
The one thing that catches everyone¶
Warning
NNUE Blend is the master gain in both modes, and its default is 0. Setting Use NNUE true on its own changes nothing observable: blend mode interpolates 0 % of the network, and residual mode multiplies the correction by $0/100$. Both collapse to exactly the classical score. A network that appears to have no effect is almost always this, not a bad net or a failed load.
Verify with getNNUESource() that a net is loaded, then confirm NNUE Blend is non-zero. The two settings are independent on purpose — it is what allows a net to be loaded and staged without being trusted — but the interaction is not discoverable from the option list alone.
Training pipeline¶
The pipeline lives in kirin/tools/ and is Python. It is not part of the engine build and is not required to run Kirin — only to produce a new network.
Tool |
Role |
|---|---|
|
PGN games $\rightarrow$ labelled position CSV |
|
synthesise leaf-like positions (search-consistent labels) |
|
drop noisy or unrepresentative labels |
|
train the network |
|
export to the |
|
shared format reader/writer |
|
deterministic format-valid stub net |
|
sweep blend/residual/clamp settings |
Two points are worth drawing out, because they are where the pipeline’s design differs from a naive “train on game results” approach:
Leaf-like positions matter more than game positions. The network is evaluated at search leaves, which are quiet positions reached after quiescence — not the positions that appear in a game score. Training on game positions teaches the network to evaluate a distribution it will never actually see. generate_leaf_training_csv.py exists to close that gap.
The policy sweep is a distinct step from training. Once a net exists, the blend, residual, clamp, classical-limit and phase-scale settings form a configuration space that is searched independently. A mediocre network with good policy settings routinely beats a better network used naively, and nnue_policy_sweep.py automates finding that combination. Both are then confirmed by SPRT — Ch. Measuring Strength.
Note
The step-by-step training procedure, including data formats and command lines, is documented in docs/engine-docs/nnue_training.md. That file is the operational reference and is kept alongside the tools; this chapter covers what the format is, how the two evaluations combine, and why.