summaryrefslogtreecommitdiff
path: root/experiments/conv_local_smoke.py
diff options
context:
space:
mode:
Diffstat (limited to 'experiments/conv_local_smoke.py')
-rw-r--r--experiments/conv_local_smoke.py149
1 files changed, 148 insertions, 1 deletions
diff --git a/experiments/conv_local_smoke.py b/experiments/conv_local_smoke.py
index 99ca3b6..1ea0cea 100644
--- a/experiments/conv_local_smoke.py
+++ b/experiments/conv_local_smoke.py
@@ -8,7 +8,8 @@ import torch.nn.functional as F
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sdil.conv import (CIFARLocalResNet, CIFARSDILResNet, ConvSDILConfig,
- conv_local_step, simultaneous_conv_node_perturbation)
+ channel_subspace_apical_calibration, conv_local_step,
+ simultaneous_conv_node_perturbation)
def architecture_checks():
@@ -266,6 +267,138 @@ def perturbation_estimator_check():
}
+def channel_subspace_estimator_check():
+ """The structured estimator targets representable base/gate moments."""
+ torch.manual_seed(71)
+ batch = 3
+ hiddens = [
+ torch.randn(batch, 2, 4, 4, dtype=torch.float64),
+ torch.randn(batch, 3, 2, 2, dtype=torch.float64),
+ ]
+ negative_gradients = [torch.randn_like(value) for value in hiddens]
+ estimated_base = [torch.zeros(
+ batch, value.shape[1], dtype=value.dtype) for value in hiddens]
+ estimated_gate = [torch.zeros_like(value) for value in estimated_base]
+ generator = torch.Generator(device="cpu").manual_seed(211)
+ directions = 4096
+ inverse_sqrt_two = 1.0 / (2.0 ** 0.5)
+ for _ in range(directions):
+ hidden_directions = []
+ base_random = []
+ gate_random = []
+ for hidden in hiddens:
+ shape = (batch, hidden.shape[1])
+ base = torch.empty(shape, dtype=hidden.dtype).bernoulli_(
+ 0.5, generator=generator).mul_(2).sub_(1)
+ gate = torch.empty_like(base).bernoulli_(
+ 0.5, generator=generator).mul_(2).sub_(1)
+ hidden_directions.append((
+ base[:, :, None, None]
+ + torch.tanh(hidden) * gate[:, :, None, None])
+ * inverse_sqrt_two)
+ base_random.append(base)
+ gate_random.append(gate)
+ # The exact loss derivative uses g=-negative_gradient and remains
+ # per-example without BatchNorm coupling.
+ directional = -sum(
+ (gradient * direction).flatten(1).sum(dim=1)
+ for gradient, direction in zip(
+ negative_gradients, hidden_directions))
+ for index, (hidden, base, gate) in enumerate(zip(
+ hiddens, base_random, gate_random)):
+ spatial = hidden.shape[2] * hidden.shape[3]
+ scale = -(2.0 ** 0.5) / (spatial * directions)
+ estimated_base[index].add_(directional[:, None] * base, alpha=scale)
+ estimated_gate[index].add_(directional[:, None] * gate, alpha=scale)
+ exact_base = [value.mean(dim=(2, 3)) for value in negative_gradients]
+ exact_gate = [(value * torch.tanh(hidden)).mean(dim=(2, 3))
+ for value, hidden in zip(negative_gradients, hiddens)]
+ estimated = torch.cat([
+ value.flatten() for pair in zip(estimated_base, estimated_gate)
+ for value in pair])
+ exact = torch.cat([
+ value.flatten() for pair in zip(exact_base, exact_gate)
+ for value in pair])
+ cosine = float(F.cosine_similarity(estimated, exact, dim=0))
+ norm_ratio = float(estimated.norm() / exact.norm())
+ assert cosine > 0.985
+ assert 0.90 < norm_ratio < 1.10
+
+ # The executable antithetic implementation must match the same structured
+ # directional derivative, not an autograd surrogate.
+ # Use a fixed nondegenerate point. Width-one, zero-bias ReLU networks can
+ # contain structurally exact-zero preactivations, where central differences
+ # and PyTorch's chosen subgradient need not agree even as sigma -> 0.
+ torch.manual_seed(3)
+ net = CIFARSDILResNet(
+ depth=8, base_width=2, seed=72, dtype=torch.float64,
+ vectorizer_mode="channel_gated")
+ x = torch.randn(2, 3, 32, 32, dtype=torch.float64)
+ y = torch.tensor([2, 8])
+ parameters = net.W + [net.W_out, net.b_out]
+ for parameter in parameters:
+ parameter.requires_grad_(True)
+ clean = net.forward(x, return_cache=True)
+ for hidden in clean["hiddens"]:
+ hidden.retain_grad()
+ loss = F.cross_entropy(clean["logits"], y)
+ loss.backward()
+ output_signal = (torch.softmax(clean["logits"].detach(), dim=1)
+ - F.one_hot(y, 10).to(torch.float64))
+ _, diagnostics = channel_subspace_apical_calibration(
+ net, x, y, clean, output_signal, sigma=1e-6,
+ n_directions=1, eta=0.0,
+ generator=torch.Generator(device="cpu").manual_seed(307),
+ return_diagnostics=True)
+ hidden_direction = diagnostics["directions"][0]["hidden"]
+ finite_difference = diagnostics["directional_derivatives"][0][
+ "scaled_directional"]
+ exact_directional = sum(
+ (x.shape[0] * hidden.grad * direction).flatten(1).sum(dim=1)
+ for hidden, direction in zip(clean["hiddens"], hidden_direction))
+ relative = ((finite_difference - exact_directional).abs()
+ / exact_directional.abs().clamp_min(1e-12))
+ assert float(relative.max()) < 2e-6
+ for parameter in parameters:
+ parameter.requires_grad_(False)
+
+ # The local A/G update must equal the full-field delta rule after replacing
+ # only its two target moments with the structured causal estimates.
+ eta = 0.0023
+ before_a = [value.clone() for value in net.A]
+ before_g = [value.clone() for value in net.A_gate]
+ expected_a = []
+ expected_g = []
+ for index, hidden in enumerate(clean["hiddens"]):
+ base = output_signal @ before_a[index].t()
+ gate_coefficient = output_signal @ before_g[index].t()
+ gate = torch.tanh(hidden.detach())
+ mean = gate.mean(dim=(2, 3))
+ second = gate.square().mean(dim=(2, 3))
+ base_error = diagnostics["target_base"][index] - (
+ base + mean * gate_coefficient)
+ gate_error = diagnostics["target_gate"][index] - (
+ mean * base + second * gate_coefficient)
+ expected_a.append(before_a[index] + eta * (
+ base_error.t() @ output_signal / x.shape[0]))
+ expected_g.append(before_g[index] + eta * (
+ gate_error.t() @ output_signal / x.shape[0]))
+ channel_subspace_apical_calibration(
+ net, x, y, clean, output_signal, sigma=1e-6,
+ n_directions=1, eta=eta,
+ generator=torch.Generator(device="cpu").manual_seed(307))
+ update_error = max(float((actual - expected).abs().max())
+ for actual, expected in zip(
+ net.A + net.A_gate, expected_a + expected_g))
+ assert update_error < 1e-14
+ return {
+ "channel_subspace_moment_cosine": cosine,
+ "channel_subspace_moment_norm_ratio": norm_ratio,
+ "channel_subspace_jvp_relative_error": float(relative.max()),
+ "channel_subspace_delta_rule_absolute_error": update_error,
+ }
+
+
def apical_learning_checks():
torch.manual_seed(11)
net = CIFARSDILResNet(depth=8, base_width=2, seed=6)
@@ -342,6 +475,19 @@ def apical_learning_checks():
for before_weight, after_weight in zip(weights_before, net.W))
assert all(not parameter.requires_grad
for parameter in net.W + [net.W_out, net.b_out])
+
+ gated_weights_before = [value.clone() for value in gated.A + gated.A_gate]
+ gated_result = conv_local_step(
+ gated, x[:2], y[:2],
+ ConvSDILConfig(
+ eta=1e-3, eta_A=1e-3, momentum=0.0, weight_decay=0.0,
+ pert_every=1, apical_calibration_mode="channel_subspace"),
+ step=0, generator=torch.Generator(device="cpu").manual_seed(17))
+ assert gated_result["did_perturb"]
+ assert all(torch.isfinite(torch.tensor(value)) for value in
+ gated_result["calibration"].values())
+ assert any(not torch.equal(before, after) for before, after in zip(
+ gated_weights_before, gated.A + gated.A_gate))
return {"apical_mse_ratio": after / before,
"gated_apical_mse_ratio": gated_after / gated_before,
"gated_vectorizer_parameter_reduction": (
@@ -356,6 +502,7 @@ def main():
report = exact_local_gradient_check()
report.update(exact_batchnorm_local_gradient_check())
report.update(perturbation_estimator_check())
+ report.update(channel_subspace_estimator_check())
report.update(apical_learning_checks())
print(report)
print("ALL CONVOLUTIONAL LOCAL-ELIGIBILITY CHECKS PASSED")