qAIR / models /classical_control_layer.py
Md. Arafat Islam
qAIR — Quantum-Inspired Multi-Hypothesis Reasoning, Gradio demo.
feaa032
Raw
History Blame Contribute Delete
4.84 kB
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from config import DROPOUT
class ClassicalControlLayer(nn.Module):
"""
Parameter-matched classical control for QuantumEvolutionLayer.
Same interface (H -> (q_state, energy)) and the same surrounding
compress / expand / fusion_gate / correction / energy_head
architecture -- the only thing that differs is what sits between
compress and expand. QuantumEvolutionLayer runs a PennyLane circuit
there; this runs a small classical MLP operating at the same width
(n_qubits*3, matching the circuit's flat measurement-vector size).
Used to isolate whether QuantumEvolutionLayer's benefit (if any)
comes from the quantum circuit's structure (superposition +
entanglement) specifically, or just from having *some* extra
nonlinear transform in that position of the pipeline. The quantum
circuit itself is a tiny fraction of QuantumEvolutionLayer's total
parameters (54 out of ~1.12M at dim=384, n_qubits=6, n_layers=3),
so keeping everything else identical and only swapping that one
small piece keeps total parameter count within a fraction of a
percent either way -- see the printed counts below.
"""
def __init__(self, dim, n_qubits=6, n_layers=3, verbose=False):
super().__init__()
self.n_qubits = n_qubits
self.compress = nn.Sequential(
nn.Linear(dim, dim),
nn.GELU(),
nn.Linear(dim, n_qubits),
)
bottleneck_dim = n_qubits * 3
# n_layers of nonlinear transform at the circuit's measurement
# width: first layer projects n_qubits -> bottleneck_dim, the
# remaining (n_layers - 1) stay at bottleneck_dim.
transform_layers = [nn.Linear(n_qubits, bottleneck_dim), nn.GELU()]
for _ in range(n_layers - 1):
transform_layers += [
nn.Linear(bottleneck_dim, bottleneck_dim),
nn.GELU(),
]
self.classical_transform = nn.Sequential(*transform_layers)
self.expand = nn.Sequential(
nn.Linear(bottleneck_dim, dim),
nn.GELU(),
nn.LayerNorm(dim),
nn.Dropout(DROPOUT),
)
self.fusion_gate = nn.Sequential(
nn.Linear(dim * 2, dim),
nn.GELU(),
nn.Linear(dim, dim),
nn.Sigmoid(),
)
self.correction = nn.Sequential(
nn.Linear(dim, dim),
nn.GELU(),
nn.Dropout(DROPOUT),
nn.Linear(dim, dim),
)
# Matches QuantumEvolutionLayer.phase_gain exactly. The phase
# itself is read off the classical bottleneck the same way the
# circuit's is read off its measurements (atan2 of two thirds of
# the vector), so the two arms differ only in what produced the
# numbers -- which is the entire point of this control.
self.phase_gain = nn.Parameter(torch.tensor(1.0))
self.energy_head = nn.Sequential(
nn.Linear(dim, dim),
nn.GELU(),
nn.LayerNorm(dim),
nn.Linear(dim, dim // 2),
nn.GELU(),
nn.Linear(dim // 2, 1),
)
self.norm = nn.LayerNorm(dim)
if verbose:
total_params = sum(p.numel() for p in self.parameters())
transform_params = sum(
p.numel() for p in self.classical_transform.parameters()
)
print(
f"[ClassicalControlLayer] total parameters: {total_params:,} "
f"(classical_transform slot: {transform_params:,})"
)
def forward(self, H):
B, K, D = H.shape
H_norm = F.normalize(H, dim=-1)
z = self.compress(H_norm.reshape(B * K, D))
# Same squashing QuantumEvolutionLayer applies before its circuit
# (there, to fit PennyLane's rotation-angle range) -- kept
# identical here so both arms see the exact same input
# distribution into the part that actually differs.
z = math.pi * torch.tanh(z)
measurements = self.classical_transform(z)
n = self.n_qubits
x_mean = measurements[:, :n].mean(dim=-1)
y_mean = measurements[:, n:2 * n].mean(dim=-1)
phase = self.phase_gain * torch.atan2(y_mean, x_mean)
q = self.expand(measurements)
H_flat = H.reshape(B * K, D)
gate = self.fusion_gate(torch.cat([H_flat, q], dim=-1))
q = gate * q + (1 - gate) * H_flat
q = self.norm(q + self.correction(q))
energy = self.energy_head(q)
energy = torch.tanh(energy).squeeze(-1)
q = q.reshape(B, K, D)
energy = energy.reshape(B, K)
phase = phase.reshape(B, K)
return q, energy, phase