File size: 6,068 Bytes
9ede8c0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | """
A k-NN lookup table over real (encoded state, outcome) pairs from actually-
played self-play games, consulted alongside the learned value head at
decision time (see `search._evaluate_with_memory` / `adversarial_search.py`).
Distinct from the trained model itself -- nothing here is learned, it's a
cache of real experience the model can fall back on when its own value
estimate might be shaky.
Precedented by episodic control (Blundell et al., "Model-Free Episodic
Control"; Pritzel et al., "Neural Episodic Control") and case-based
reasoning, not a novel mechanism.
"""
import torch
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
class EpisodicMemory:
"""Stores (z, outcome) pairs -- z is a REAL encoded state's latent
(never a predicted/imagined one, so memory never compounds its own
errors), outcome is that state's real remaining-steps-to-win (for a
won game) or a fixed penalty (for a lost/drawn one) -- same label scale
as the value head, so blending stays consistent."""
def __init__(self):
self._zs = []
self._outcomes = []
self._state_keys = []
self._seen = {} # state_key -> index, for exact-match dedup
self._trust_scale = None
def __len__(self):
return len(self._zs)
def add(self, z, outcome, state_key=None):
"""`state_key`: an optional hashable identity for the real state
this (z, outcome) came from. When given and already stored, this
is a revisit of the exact same state -- keep whichever copy has
the better (smaller) outcome instead of appending a duplicate."""
if state_key is not None and state_key in self._seen:
idx = self._seen[state_key]
if outcome < self._outcomes[idx]:
self._zs[idx] = z.detach().to("cpu")
self._outcomes[idx] = float(outcome)
self._trust_scale = None
return
self._zs.append(z.detach().to("cpu"))
self._outcomes.append(float(outcome))
self._state_keys.append(state_key)
if state_key is not None:
self._seen[state_key] = len(self._zs) - 1
self._trust_scale = None
def _stacked(self):
return torch.stack(self._zs).to(DEVICE), torch.tensor(self._outcomes, device=DEVICE)
@torch.no_grad()
def trust_scale(self, sample_size=300):
"""A self-calibrating distance scale for this memory's own latent
space: the median nearest-OTHER-neighbor distance among a random
subsample of stored points. A query distance much smaller than
this means "genuinely close match found"; much larger means
"nothing like this was ever stored." Self-calibrating per
checkpoint/latent-dim rather than a hand-picked constant."""
if self._trust_scale is not None:
return self._trust_scale
n = len(self._zs)
if n < 2:
self._trust_scale = 1.0
return self._trust_scale
Z, _outcomes = self._stacked()
if n > sample_size:
idx = torch.randperm(n, device=DEVICE)[:sample_size]
sample = Z[idx]
else:
sample = Z
dists = torch.cdist(sample, Z)
dists = torch.where(dists > 1e-6, dists, torch.full_like(dists, float("inf")))
nn_dist = dists.min(dim=1).values
nn_dist = nn_dist[torch.isfinite(nn_dist)]
self._trust_scale = nn_dist.median().item() if len(nn_dist) > 0 else 1.0
return self._trust_scale
@torch.no_grad()
def query_batch(self, zs, k=5):
"""zs: [B, latent_dim]. Returns (blended_estimates [B], trust [B]).
Each row's blend weights its k nearest stored neighbors by inverse
distance. `trust` is `exp(-mean_distance / trust_scale)` -- a 0..1
confidence already normalized against this memory's own typical
spacing, so callers can scale their blend weight by it directly."""
Z, outcomes = self._stacked()
zq = zs.detach().to(DEVICE)
dists = torch.cdist(zq, Z)
k = min(k, len(self._zs))
topk_dists, topk_idx = torch.topk(dists, k, largest=False, dim=1)
topk_outcomes = outcomes[topk_idx]
weights = 1.0 / (topk_dists + 1e-2)
weights = weights / weights.sum(dim=1, keepdim=True)
blended = (weights * topk_outcomes).sum(dim=1)
mean_dist = topk_dists.mean(dim=1)
trust = torch.exp(-mean_dist / self.trust_scale())
return blended, trust
@torch.no_grad()
def add_trajectory_from_real_path(model, normalizer, memory, path_states, env=None):
"""Adds every state of an actually-played, WON trajectory as a positive
(attraction) example -- outcome = real distance to the end of this
path."""
from .train_utils import states_to_tensor
dedup = env is not None and env.discrete_state
T = len(path_states) - 1
for t, s in enumerate(path_states):
observed = env.observe(s) if env is not None else s
z = normalizer.normalize(states_to_tensor([observed]).to(DEVICE))
z = model.encode(z)[0]
memory.add(z, T - t, state_key=s if dedup else None)
@torch.no_grad()
def add_negative_trajectory_from_real_path(model, normalizer, memory, path_states, penalty, env=None):
"""Adds every state of a LOST/drawn trajectory as a negative
(repulsion) example -- every state gets the SAME fixed penalty label,
deliberately uniform across the whole walk. No new retrieval mechanism
needed: `EpisodicMemory.query_batch`'s existing k-NN blend already
treats a nearby HIGH-outcome entry as repulsion by construction, the
exact mirror of how a low one acts as attraction."""
from .train_utils import states_to_tensor
dedup = env is not None and env.discrete_state
for s in path_states:
observed = env.observe(s) if env is not None else s
z = normalizer.normalize(states_to_tensor([observed]).to(DEVICE))
z = model.encode(z)[0]
memory.add(z, float(penalty), state_key=s if dedup else None)
|