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)