diff options
| author | YurenHao0426 <Blackhao0426@gmail.com> | 2026-07-22 19:55:05 -0500 |
|---|---|---|
| committer | YurenHao0426 <Blackhao0426@gmail.com> | 2026-07-22 19:55:05 -0500 |
| commit | 661496c172a53d09c59951c642f1a468abb7f07e (patch) | |
| tree | 8555fcd5fa14bb619f4be843a96c3ae22e173020 | |
| parent | 0008f2cd96c06bea98f43560867d93967c597711 (diff) | |
mechanism: separate BCI role from performance velocity
| -rw-r--r-- | ORAL_B_RECOVERY.md | 74 | ||||
| -rw-r--r-- | THEORY.md | 57 | ||||
| -rw-r--r-- | experiments/bci_smoke.py | 50 | ||||
| -rw-r--r-- | experiments/verify_theory.py | 41 | ||||
| -rw-r--r-- | sdil/bci.py | 38 |
5 files changed, 255 insertions, 5 deletions
diff --git a/ORAL_B_RECOVERY.md b/ORAL_B_RECOVERY.md new file mode 100644 index 0000000..5880101 --- /dev/null +++ b/ORAL_B_RECOVERY.md @@ -0,0 +1,74 @@ +# Oral-B recovery: temporal-difference somato-dendritic innovation + +This branch follows the complete negative result in `ORAL_B.md`; it does not +reinterpret or overwrite that preregistration. It is motivated by Fig. 5 and +Extended Data Fig. 13 of Francioni et al. (2026): P+ versus P- SD residuals +separate epochs by the sign of recent error change, whereas absolute error +magnitude alone does not separate the populations. + +## Structural diagnosis + +The failed model always calibrated its vectorizer against the instantaneous +squared-loss descent direction `e_t s_i`. Its P+ minus P- residual contrast is +therefore proportional to current error magnitude. Conditional on lower +current error during improving epochs, the preregistered sign-inversion index +must be negative. Supplying error velocity as a second regressor cannot alter +the target being regressed. All 36 negative signs are consistent with this +structural mismatch. + +## Candidate mechanism + +The recovery factorizes the instruction into two locally obtainable terms: + +1. a slowly learned per-cell causal-role coefficient `m_i`, estimated from + sparse antithetic perturbations and the scalar BCI cursor difference; +2. the within-episode performance innovation + `delta_t = |e_(t-1)| - |e_t|`, reset at episode boundaries. + +The task instruction is `m_i delta_t`. Ordinary soma-predictable apical +traffic is added before the same per-cell neutral predictor, so the plasticity +signal remains the somato-dendritic innovation rather than a directly supplied +role label. Forward updates use only current presynaptic context, local +postsynaptic gain, and that innovation. The first recovery is plasticity-only +(`kappa=0`): the empirical residual points along observed performance change, +not necessarily a corrective online-control direction. + +For perturbation vector `xi`, the role target is + +```text +q_i = [(z(h + sigma xi) - z(h - sigma xi)) / (2 sigma)] xi_i, +``` + +whose expectation is the unknown causal role `s_i`. This consumes scalar +cursor observations rather than gradients or reverse-mode differentiation. + +## R0 mechanics gate + +R0 generates no task endpoint and touches no development or confirmation +environment. It passes only if deterministic checks establish all of: + +- the mean perturbation role target has cosine above 0.99 with the analytic + role used only by the diagnostic; +- the local vectorizer update moves toward that role without reading it; +- the neutral predictor exactly removes affine soma traffic in the controlled + synthetic check; +- episode-initial performance velocity is zero; +- perfect role identification gives a strictly positive P+/P- error-change + sign-inversion index; and +- all original BCI pairing, lesion-mask, decoder, and finite checks still pass. + +R0 is implemented by `performance_velocity` in `sdil/bci.py` and audited by +`experiments/bci_smoke.py` plus `experiments/verify_theory.py`. Passing R0 +only permits a separately committed training-only structural screen after the +D4 accept confirmation closes. No success-rate endpoint, hyperparameter +selection, or oral-B score change is authorized by R0. + +## Boundary for the next gate + +Before any recovery task run, R1 must freeze its development-only task seeds, +forward/vectorizer rates, role-estimator cadence, cost accounting, and a stop +rule requiring both positive derivative sign and actual learning. A sign that +is positive merely because `delta_t` was inserted is not evidence of credit +assignment. Confirmation seeds 10--15 from the original oral-B protocol +remain untouched unless an R1 candidate passes every frozen learning, +innovation, role-vectorization, and plasticity-lesion requirement. @@ -632,6 +632,59 @@ the loss-producing part of training. Endpoint alignment must be reported with the alignment trajectory and task loss. This is a standard tracking fact used to audit the mirror baseline, not an SDIL novelty claim. +### Why instantaneous gradient feedback cannot reproduce the BCI derivative sign + +The failed oral-B model calibrated its apical vectorizer to the instantaneous +negative loss gradient. Let `e_t>0` be the target-minus-cursor error during an +active trial and let `s_i` be the signed causal role of cell `i` (`s_i>0` for +P+, `s_i<0` for P-). With squared loss, the perfect calibration target is + +```text +r_i(t) = e_t s_i. +``` + +Consequently the P+ minus P- residual contrast is a positive constant times +`e_t`. Whenever current error is smaller in error-reduction epochs than in +error-increase epochs, the Harnett separability statistic + +```text +0.5 * [E(P+ - P- | error decreases) + - E(P+ - P- | error increases)] +``` + +is negative. Adding error velocity as an input feature does not fix this: +regression against `e_t s_i` still assigns the causal coefficient to error +magnitude and has no reason to assign it to velocity. The complete negative +36-run screen is therefore a structural falsification of that target, not a +learning-rate failure. + +A derivative-faithful candidate separates causal-role identification from the +temporal performance modulator. With Rademacher perturbations `xi`, observe +only the scalar cursor under antithetic state interventions: + +```text +z+ = s^T (h + sigma xi), z- = s^T (h - sigma xi), +q_i = ((z+ - z-) / (2 sigma)) xi_i. +``` + +Then `E[q_i]=s_i`; no task loss, gradient, transpose weight, or reverse graph +is used. Define the within-episode performance innovation + +```text +delta_t = |e_(t-1)| - |e_t|, +r_i(t) = delta_t * m_i, m_i approximately s_i. +``` + +Now the same separability statistic is positive by construction when both +improving and worsening epochs occur. More importantly, this is not merely a +metric relabelling: `delta_t` is the scalar outcome that modulates the local +eligibility update, while sparse causal perturbations amortize the +neuron-specific role vector `m`. Whether that three-factor temporal rule +actually learns, preserves residual decorrelation, and survives a plasticity +lesion remains an empirical gate. Direct online control is not implied; in +fact applying `r` as an immediate state correction would reinforce both good +and bad velocity signs and is therefore excluded from the first recovery. + ## 4. Hardware-independent complexity Let `H` be the number of hidden layers, `K` the perturbation directions per @@ -675,4 +728,6 @@ quadratic finite-sigma bias law; the smooth-descent step threshold; the conditional-projection Pythagorean identity and norm-matching direction invariance; neutral predictor convergence across `eta_P` and update counts; the multiplicative residual-coupling operator and its unstable positive mode; -and the task-fit teaching-signal absorption predicted above. +the task-fit teaching-signal absorption predicted above; and the BCI +instantaneous-gradient sign conflict versus the temporal-difference role +factorization. diff --git a/experiments/bci_smoke.py b/experiments/bci_smoke.py index 5cd5b65..4724d26 100644 --- a/experiments/bci_smoke.py +++ b/experiments/bci_smoke.py @@ -93,6 +93,55 @@ def check_phase_masks_and_velocity_reset(): print("BCI online/plasticity phase masks and episode velocity reset: exact") +def check_temporal_difference_role_vectorizer(): + """Role perturbations times performance change give the Harnett sign.""" + cfg = BCIConfig( + days=2, episodes_per_day=4096, steps_per_episode=2, + feedback="performance_velocity", vectorizer_eta=0.5) + model = BCISDIL(cfg, model_seed=12) + generator = torch.Generator().manual_seed(13) + soma = 0.2 * torch.randn( + cfg.episodes_per_day, cfg.n_neurons, generator=generator) + xi = torch.empty_like(soma).bernoulli_( + 0.5, generator=generator).mul_(2).sub_(1) + target = model.causal_role_targets(soma, xi) + mean_target = target.mean(0) + cosine = torch.nn.functional.cosine_similarity( + mean_target, model.role, dim=0).item() + assert cosine > 0.99 + + active = torch.ones(cfg.episodes_per_day, dtype=torch.bool) + model.A.zero_() + model.update_vectorizer( + torch.ones(cfg.episodes_per_day, 1), + torch.zeros(cfg.episodes_per_day, cfg.n_neurons), + target, active) + learned_cosine = torch.nn.functional.cosine_similarity( + model.A[:, 0], model.role, dim=0).item() + assert learned_cosine > 0.99 + + # With the neutral predictor exact, r_i = role_i * delta_error. Hence the + # P+ minus P- residual is positive in improving epochs and negative in + # worsening epochs, independent of instantaneous error magnitude. + model.A[:, 0].copy_(model.role) + model.P.copy_(model.coupling) + model.P_bias.zero_() + previous = torch.tensor([0.8, 0.4, 0.9, 0.3]) + current = torch.tensor([0.6, 0.5, 0.5, 0.6]) + delta = model.feedback_features(current, previous) + _, innovation, _, _ = model.apical_components(soma[:4], delta) + difference = (innovation[:, :cfg.n_plus].mean(1) + - innovation[:, cfg.n_plus:cfg.n_plus + cfg.n_minus].mean(1)) + improving = delta[:, 0] > 0 + worsening = delta[:, 0] < 0 + sign_index = 0.5 * ( + difference[improving].mean() - difference[worsening].mean()) + assert sign_index > 0 + print( + "BCI temporal-difference role vectorizer: " + f"cos={cosine:.6f}, sign_index={sign_index.item():.6f}") + + def check_grouped_decoders_and_metrics(): generator = torch.Generator().manual_seed(9) groups = torch.arange(200) @@ -136,5 +185,6 @@ if __name__ == "__main__": check_causal_estimator() check_predictor_identification() check_phase_masks_and_velocity_reset() + check_temporal_difference_role_vectorizer() check_grouped_decoders_and_metrics() print("ALL BCI MECHANICS CHECKS PASSED") diff --git a/experiments/verify_theory.py b/experiments/verify_theory.py index 21a7c06..c00a091 100644 --- a/experiments/verify_theory.py +++ b/experiments/verify_theory.py @@ -348,6 +348,46 @@ def check_kolen_pollack_difference_dynamics(): assert maximum_error < 3e-15 +def check_bci_error_derivative_structure(): + """Separate causal role from temporal performance innovation.""" + rng = np.random.default_rng(641) + role = np.concatenate((np.full(5, 0.2), np.full(5, -0.2), + np.zeros(30))) + examples = 200_000 + xi = 2.0 * rng.integers(0, 2, size=(examples, role.size)) - 1.0 + cursor_direction = xi @ role + role_estimate = (cursor_direction[:, None] * xi).mean(axis=0) + role_error = float(np.abs(role_estimate - role).max()) + role_cosine = float(role_estimate @ role / ( + np.linalg.norm(role_estimate) * np.linalg.norm(role))) + + # Positive target error throughout the BCI trial. Improving events have + # smaller current error; worsening events have larger current error. + previous = np.asarray((0.8, 0.7, 0.6, 0.5, 0.4, 0.3)) + current = np.asarray((0.6, 0.5, 0.4, 0.7, 0.6, 0.5)) + improvement = previous - current + improving = improvement > 0 + worsening = improvement < 0 + + def sign_index(modulator): + residual = modulator[:, None] * role[None, :] + difference = residual[:, :5].mean(1) - residual[:, 5:10].mean(1) + return 0.5 * (difference[improving].mean() + - difference[worsening].mean()) + + instantaneous_index = float(sign_index(current)) + temporal_difference_index = float(sign_index(improvement)) + print("\nBCI ERROR-DERIVATIVE STRUCTURE") + print(f"role_estimator_cosine={role_cosine:.9f} " + f"max_error={role_error:.3e} " + f"instantaneous_index={instantaneous_index:+.6f} " + f"td_index={temporal_difference_index:+.6f}") + assert role_cosine > 0.999 + assert role_error < 0.005 + assert instantaneous_index < 0.0 + assert temporal_difference_index > 0.0 + + def main(): check_simultaneous_variance() check_sigma_bias() @@ -359,6 +399,7 @@ def main(): check_dynamic_neutral_projection() check_intermittent_feedback_tracking() check_kolen_pollack_difference_dynamics() + check_bci_error_derivative_structure() print("\nALL THEORY CHECKS PASSED") diff --git a/sdil/bci.py b/sdil/bci.py index c8408e3..2978e5a 100644 --- a/sdil/bci.py +++ b/sdil/bci.py @@ -37,10 +37,11 @@ class BCIConfig: @property def feedback_dim(self): - return 1 if self.feedback == "error" else 2 + return 1 if self.feedback in ("error", "performance_velocity") else 2 def validate(self): - if self.feedback not in ("error", "error_velocity"): + if self.feedback not in ( + "error", "error_velocity", "performance_velocity"): raise ValueError(f"unknown BCI feedback: {self.feedback}") if self.n_plus < 1 or self.n_minus < 1 or self.n_background < 1: raise ValueError("all BCI populations must be nonempty") @@ -141,6 +142,8 @@ class BCISDIL: return error.unsqueeze(1) improvement_velocity = (torch.zeros_like(error) if previous_abs_error is None else previous_abs_error - error.abs()) + if self.cfg.feedback == "performance_velocity": + return improvement_velocity.unsqueeze(1) return torch.stack((error, improvement_velocity), dim=1) def apical_components(self, soma, feedback): @@ -162,6 +165,20 @@ class BCISDIL: directional_derivative = (loss_plus - loss_minus) / (2.0 * sigma) return -directional_derivative.unsqueeze(1) * perturbations + def causal_role_targets(self, soma, perturbations): + """Estimate each cell's signed causal effect on the BCI cursor. + + Unlike ``causal_targets``, this target contains no instantaneous error + magnitude. A scalar antithetic cursor difference is tagged by the + locally available perturbation at each cell; its expectation is the + experimenter-unknown causal role vector. + """ + sigma = self.cfg.perturb_sigma + plus = (soma + sigma * perturbations) @ self.role + minus = (soma - sigma * perturbations) @ self.role + directional_derivative = (plus - minus) / (2.0 * sigma) + return directional_derivative.unsqueeze(1) * perturbations + def exact_causal_direction(self, soma, target): """Analytic diagnostic only; never used by a learning update.""" error = target - soma @ self.role @@ -185,6 +202,15 @@ class BCISDIL: def update_vectorizer(self, feedback, innovation, causal_target, active): if not bool(active.any()): return + if self.cfg.feedback == "performance_velocity": + # Role identification and performance modulation are deliberately + # separated. The former is an amortized node-perturbation estimate + # of dz/dh_i; the latter enters apical activity only through the + # within-episode performance innovation in ``feedback``. + target = causal_target[active].mean(0) + self.A[:, 0] += self.cfg.vectorizer_eta * ( + target - self.A[:, 0]) + return c = feedback[active] calibration_error = causal_target[active] - innovation[active] self.A += self.cfg.vectorizer_eta * ( @@ -228,8 +254,12 @@ class BCISDIL: and global_step % self.cfg.perturb_every == 0) causal_target = None if did_perturb: - causal_target = self.causal_targets( - base_soma, self.cfg.target, perturbations) + if self.cfg.feedback == "performance_velocity": + causal_target = self.causal_role_targets( + base_soma, perturbations) + else: + causal_target = self.causal_targets( + base_soma, self.cfg.target, perturbations) # All updates see the same pre-update state. The order below only # mutates parameters after every required local quantity is stored. |
