summaryrefslogtreecommitdiff
path: root/sdil/shared_feedback.py
blob: 9a38e0f97192d219d4f0304f8e54f1c38d0529d6 (plain)
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
"""Minimal endogenous shared-apical-path feasibility model.

The context field in this module is part of the forward computation and is
required to solve the conditional task.  During learning, the same apical
measurement contains that ordinary field plus reciprocal KP instruction.
There is no generated nuisance or bias term.
"""

from dataclasses import dataclass
import math

import torch
import torch.nn.functional as F


CONDITIONS = ("oracle", "raw_shared", "innovation", "matched_raw")


def conditional_selector_data(n, seed, device="cpu"):
    """Balanced contextual selector task with no context on the basal input."""
    if n % 2:
        raise ValueError("conditional selector data size must be even")
    generator = torch.Generator(device="cpu").manual_seed(seed)
    x = torch.randn(n, 2, generator=generator)
    z = torch.arange(n, dtype=torch.long).remainder(2)
    permutation = torch.randperm(n, generator=generator)
    x, z = x[permutation], z[permutation]
    selected = x.gather(1, z[:, None]).squeeze(1)
    y = (selected > 0).long()
    return x.to(device), z.to(device), y.to(device)


@dataclass(frozen=True)
class SharedFeedbackConfig:
    width: int = 64
    hidden_layers: int = 2
    learning_rate: float = 0.03
    reciprocal_learning_rate: float = 0.03
    momentum: float = 0.9
    weight_decay: float = 1e-4
    context_scale: float = 1.0


class SharedFeedbackNet:
    """Context-conditioned MLP with independently stored reciprocal weights."""

    def __init__(self, config=SharedFeedbackConfig(), seed=3101,
                 device="cpu", dtype=torch.float32):
        if config.hidden_layers < 1:
            raise ValueError("shared-feedback model needs a hidden population")
        self.config = config
        self.device = torch.device(device)
        self.dtype = dtype
        sizes = [2] + [config.width] * config.hidden_layers + [2]
        generator = torch.Generator(device="cpu").manual_seed(seed)
        reciprocal_generator = torch.Generator(device="cpu").manual_seed(seed + 1)
        context_generator = torch.Generator(device="cpu").manual_seed(seed + 2)

        def normal(shape, scale, source):
            return (torch.randn(*shape, generator=source) * scale).to(
                device=self.device, dtype=dtype)

        self.W = [normal((sizes[i + 1], sizes[i]),
                         1.0 / math.sqrt(sizes[i]), generator)
                  for i in range(len(sizes) - 1)]
        # Q[i] corresponds to W[i] and has the same storage orientation.  Q[0]
        # is absent because the basal input does not need a transported field.
        self.Q = [None] + [normal(tuple(self.W[i].shape),
                                  1.0 / math.sqrt(sizes[i]),
                                  reciprocal_generator)
                           for i in range(1, len(self.W))]
        self.C = [normal((config.width, 2),
                         config.context_scale / math.sqrt(2.0),
                         context_generator)
                  for _ in range(config.hidden_layers)]
        self.P = [torch.zeros(config.width, device=self.device, dtype=dtype)
                  for _ in range(config.hidden_layers)]
        self.P_bias = [torch.zeros_like(value) for value in self.P]

        self.mW = [torch.zeros_like(value) for value in self.W]
        self.mQ = [None] + [torch.zeros_like(value) for value in self.Q[1:]]

    def clone(self):
        copied = SharedFeedbackNet(
            self.config, seed=0, device=self.device, dtype=self.dtype)
        for name in ("W", "Q", "C", "P", "P_bias", "mW", "mQ"):
            source = getattr(self, name)
            target = []
            for value in source:
                target.append(None if value is None else value.clone())
            setattr(copied, name, target)
        return copied

    def context_fields(self, z, enabled=True):
        onehot = F.one_hot(z, num_classes=2).to(self.dtype)
        if not enabled:
            return [torch.zeros((z.shape[0], self.config.width),
                                device=self.device, dtype=self.dtype)
                    for _ in self.C]
        return [onehot @ projection.t() for projection in self.C]

    def forward(self, x, z, context_enabled=True):
        fields = self.context_fields(z, enabled=context_enabled)
        h = [x]
        u = []
        for layer in range(self.config.hidden_layers):
            value = h[-1] @ self.W[layer].t() + fields[layer]
            u.append(value)
            h.append(torch.tanh(value))
        logits = h[-1] @ self.W[-1].t()
        h.append(logits)
        return {"h": h, "u": u, "context": fields, "logits": logits}

    def predictor(self, layer, soma):
        return self.P[layer] * soma + self.P_bias[layer]

    @torch.no_grad()
    def fit_neutral_predictor(self, x, z):
        """Per-cell affine neutral fit; labels and instruction are not inputs."""
        state = self.forward(x, z)
        reports = []
        for layer, target in enumerate(state["context"]):
            soma = state["h"][layer + 1]
            soma_centered = soma - soma.mean(0)
            target_centered = target - target.mean(0)
            variance = soma_centered.square().mean(0)
            covariance = (soma_centered * target_centered).mean(0)
            slope = covariance / variance.clamp_min(1e-8)
            intercept = target.mean(0) - slope * soma.mean(0)
            self.P[layer].copy_(slope)
            self.P_bias[layer].copy_(intercept)
            prediction = slope * soma + intercept
            residual = target - prediction
            target_ss = target_centered.square().sum(0)
            residual_ss = residual.square().sum(0)
            valid = target_ss > 1e-12
            r2 = 1.0 - residual_ss[valid] / target_ss[valid]
            reports.append({
                "mean_per_cell_r2": float(r2.mean()),
                "context_rms": float(target.square().mean().sqrt()),
                "residual_context_rms_ratio": float(
                    residual.square().mean().sqrt()
                    / target.square().mean().sqrt().clamp_min(1e-12)),
                "neutral_observations": int(x.shape[0]),
                "instruction_observations": 0,
            })
        return reports


def select_shared_signal(net, layer, instruction, context, soma, condition):
    raw = instruction + context
    innovation = raw - net.predictor(layer, soma)
    if condition == "oracle":
        used = instruction
    elif condition == "raw_shared":
        used = raw
    elif condition == "innovation":
        used = innovation
    elif condition == "matched_raw":
        scale = (innovation.norm(dim=1, keepdim=True)
                 / raw.norm(dim=1, keepdim=True).clamp_min(1e-12))
        used = raw * scale
    elif condition == "exact_subtraction":
        used = raw - context
    else:
        raise ValueError(f"unknown shared-feedback condition: {condition}")
    return used, raw, innovation


@torch.no_grad()
def shared_feedback_step(net, x, z, y, condition):
    """One manual modified-KP step with a shared apical measurement."""
    state = net.forward(x, z)
    h, u, context = state["h"], state["u"], state["context"]
    probabilities = torch.softmax(state["logits"], dim=1)
    output_instruction = F.one_hot(y, num_classes=2).to(net.dtype) - probabilities
    batch = x.shape[0]

    hidden_deltas = [None] * net.config.hidden_layers
    used_fields = [None] * net.config.hidden_layers
    raw_fields = [None] * net.config.hidden_layers
    innovation_fields = [None] * net.config.hidden_layers
    child_delta = output_instruction
    for layer in reversed(range(net.config.hidden_layers)):
        instruction = child_delta @ net.Q[layer + 1]
        used, raw, innovation = select_shared_signal(
            net, layer, instruction, context[layer], h[layer + 1], condition)
        delta = used * (1.0 - torch.tanh(u[layer]).square())
        hidden_deltas[layer] = delta
        used_fields[layer] = used
        raw_fields[layer] = raw
        innovation_fields[layer] = innovation
        child_delta = delta

    directions = [hidden_deltas[0].t() @ h[0] / batch]
    for layer in range(1, net.config.hidden_layers):
        directions.append(hidden_deltas[layer].t() @ h[layer] / batch)
    directions.append(output_instruction.t() @ h[-2] / batch)

    for layer, direction in enumerate(directions):
        net.mW[layer].mul_(net.config.momentum).add_(
            direction - net.config.weight_decay * net.W[layer])
        net.W[layer].add_(net.mW[layer], alpha=net.config.learning_rate)
        if layer > 0:
            net.mQ[layer].mul_(net.config.momentum).add_(
                direction - net.config.weight_decay * net.Q[layer])
            net.Q[layer].add_(
                net.mQ[layer], alpha=net.config.reciprocal_learning_rate)

    loss = F.cross_entropy(state["logits"], y)
    return float(loss), {
        "directions": directions,
        "used": used_fields,
        "raw": raw_fields,
        "innovation": innovation_fields,
        "context": context,
    }


@torch.no_grad()
def evaluate_shared_feedback(net, x, z, y, context_enabled=True,
                             batch_size=512):
    correct = 0
    total_loss = 0.0
    for start in range(0, x.shape[0], batch_size):
        stop = min(start + batch_size, x.shape[0])
        logits = net.forward(
            x[start:stop], z[start:stop], context_enabled=context_enabled)["logits"]
        total_loss += float(F.cross_entropy(
            logits, y[start:stop], reduction="sum"))
        correct += int((logits.argmax(1) == y[start:stop]).sum())
    return {
        "accuracy": correct / x.shape[0],
        "loss": total_loss / x.shape[0],
    }