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 over candidate_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_value is that slow route, kept as the test oracle.
  • Terminal rolls need the exact value. candidate_equities hard-codes +3.0 for a terminal child, which is fine for ranking but would score every game-ending roll as a backgammon here. best_move_equity reports the true 1.0 / 2.0 / 3.0.
  • ply >= 1 segfaults inside gnubg_nn.best_move; ply 0 sweeps thousands of boards clean. Keep ply=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_chance and 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.