eval.luck
eval.luck
Dice-luck control variate for variance-reduced rollouts (XG/GNUBG/BGSage style).
At every pre-roll state s (an OpenSpiel chance node), before the dice are sampled:
.. code-block:: text
h(s, d) = GNUBG 0-ply equity after best play for roll d, signed to a fixed
player's POV -- a pure function of (s, d)
m(s) = sum_d p(d) * h(s, d) -- the 1-ply pre-roll mean
luck = h(s, d_actual) - m(s) -- the luck of the roll that happened
A rollout then reports vr = raw_game_result - sum_t luck_t.
Why this is exactly unbiased. E[luck | history] = sum_d p(d) h(s,d) - m(s) = 0 by construction, so the accumulated luck is a martingale with mean zero and subtracting it cannot move the expectation — however bad the evaluator is. Variance falls because the game result is dominated by dice luck, which the accumulated luck tracks. See David Montgomery, “Variance Reduction” (GammOnLine, Feb 2000), https://bkgm.com/articles/GOL/Feb00/var.htm.
The rule that makes it self-consistent. h must be the same function on both legs and must not depend on the move actually played. We always use GNUBG’s 0-ply best-play value for whoever is on roll — regardless of who plays, what they play, or at what ply. A weak move by either side changes no luck term; the cost shows up in the raw result and in the next pre-roll state, which is correct. This is what the project’s earlier “error-rate ppg” proxy got wrong: it charged the played move against a pre-roll value that was not that quantity’s own dice-average, so its implicit luck term was not zero-mean (biased ~0.07, dropped in commit e1940a5). It is also stricter than BGSage, which scores the played move on the actual leg against the 1-ply-best move in the mean and polices the resulting gap with a t-test.
Implementation notes, all verified against this machine’s gnubg-nn 1.1.0a6:
- The sweep goes through :func:
raccoon.eval.gnubg_adapter.best_move_equity, which uses gnubg’s native move generator and then re-evaluates the position it lands on. A 21-roll sweep costs ~2.6 ms versus ~260 ms for the equivalent loop overcandidate_equities, so variance reduction adds only ~5% to the cost of a game, and it agrees with the slow route exactly (2160/2160 rolls over 60 real pre-roll states, doubles and game-ending rolls included). :func:python_roll_valueis that slow route, kept as the test oracle. - Terminal rolls need the exact value.
candidate_equitieshard-codes+3.0for a terminal child, which is fine for ranking but would score every game-ending roll as a backgammon here.best_move_equityreports the true1.0 / 2.0 / 3.0. ply >= 1segfaults insidegnubg_nn.best_move; ply 0 sweeps thousands of boards clean. Keepply=0. This is not a practical limitation: a 0-ply leaf gives a 1-ply pre-roll mean, which is exactly what GNUBG and BGSage use for their own variance reduction.- No RNG is touched. Unlike
candidate_equities(which calls_advance_through_chanceand consumes numpy draws), the native route is pure. Turning variance reduction on therefore does not perturb play at all — same seed, same games. That is what lets a variance-reduced run and a plain run be compared as two independent samples of one process.
Functions
| Name | Description |
|---|---|
| candidate_board_equity | GNUBG’s equity of decision state state from the side-to-move’s POV. |
| pre_roll_luck | (luck, mean) for chance_action at chance node state. |
| pre_roll_values | (actions, probs, values) for every outcome of the chance node state. |
| python_roll_value | Reference route for the native sweep, over OpenSpiel’s own move generation. |
| roll_value_table | The 21-roll sweep: {(lo, hi): mover-POV best-play equity}. |
candidate_board_equity
eval.luck.candidate_board_equity(state, ply=0)GNUBG’s equity of decision state state from the side-to-move’s POV.
pre_roll_luck
eval.luck.pre_roll_luck(state, chance_action, perspective_player, ply=0)(luck, mean) for chance_action at chance node state.
luck = h(s, d) - m(s) from perspective_player’s POV. Averaged over the node’s own dice distribution this is exactly zero — that is the property the whole estimator rests on, and tests/test_luck.py asserts it directly.
pre_roll_values
eval.luck.pre_roll_values(state, perspective_player, ply=0)(actions, probs, values) for every outcome of the chance node state.
values[i] is h(s, d_i) — GNUBG’s best-play equity after outcome actions[i], signed to perspective_player’s POV on the ±3 scale. The probability-weighted mean of values is the pre-roll mean m(s).
Raises ValueError if state is not a chance node.
python_roll_value
eval.luck.python_roll_value(state, chance_action, ply=0)Reference route for the native sweep, over OpenSpiel’s own move generation.
Applies chance_action to a clone of the chance node state and returns the best-play equity from the mover’s POV, with exact terminal values — unlike candidate_equities, which reports +3.0 for every terminal child. Mirrors that function otherwise, including the recursion through the second half of a doubles turn. ~150x slower than the native route; the test oracle, not used in play.
roll_value_table
eval.luck.roll_value_table(board, ply=0)The 21-roll sweep: {(lo, hi): mover-POV best-play equity}.
board is in :func:raccoon.eval.gnubg_adapter.board_from_view layout. This is the whole cost of variance reduction — ~1.7 ms per pre-roll state.