SymbolicLight-PoC / src /model.py
symboliclight-ai's picture
Publish historical inference-only PoC
f511efd
Raw History Blame Contribute Delete
15.4 kB
#!/usr/bin/env python3
"""Inference-only model definition for the historical SymbolicLight PoC."""
import math
from dataclasses import dataclass
from typing import Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
# ============================================================================
# Configuration
# ============================================================================
@dataclass
class SymbolicLightConfig:
"""Configuration required to construct the historical PoC model."""
vocab_size: int = 32000 # Vocabulary size (reusing Qwen/Llama tokenizer)
embed_dim: int = 768 # Embedding dimension
n_layers: int = 12 # Number of SymbolicLightBlock layers
n_heads: int = 12 # Number of channels in SparseTCAM
head_dim: int = 64 # Dimension per channel (embed_dim / n_heads)
intermediate_dim: int = 2048 # Feedforward intermediate dimension
max_seq_len: int = 2048 # Maximum sequence length
dropout: float = 0.1
# --- SymbolicLight Specific Parameters ---
spike_threshold: float = 1.0 # LIF neuron firing threshold
leak_factor: float = 0.95 # Membrane potential leak factor
entropy_exit_threshold: float = 0.3 # EntropyGate early exit threshold
enable_entropy_exit: bool = False
# ============================================================================
# Inference-only spike function
# ============================================================================
def hard_spike(membrane_potential: torch.Tensor, threshold: float = 1.0) -> torch.Tensor:
"""Return the same hard-threshold spikes used by the original forward pass."""
return (membrane_potential >= threshold).float()
# ============================================================================
# ② SpikeEncoder
# ============================================================================
class SpikeEncoder(nn.Module):
"""
Converts discrete token IDs into spatio-temporal spike tensors.
Process: token_id -> Embedding -> LayerNorm -> LIF Spiking
"""
def __init__(self, config: SymbolicLightConfig):
super().__init__()
self.embedding = nn.Embedding(config.vocab_size, config.embed_dim)
self.norm = nn.LayerNorm(config.embed_dim)
self.threshold = config.spike_threshold
self.leak = config.leak_factor
# Learnable positional embedding.
self.pos_embedding = nn.Embedding(config.max_seq_len, config.embed_dim)
# Membrane potential (managed dynamically per batch)
self.register_buffer("v_mem", None)
def _init_membrane(self, shape: torch.Size, device: torch.device):
"""Initialize/reset membrane potential"""
self.v_mem = torch.zeros(shape, device=device)
def forward(self, token_ids: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Args:
token_ids: [batch, seq_len]
Returns:
spikes: [batch, seq_len, embed_dim] Sparse 0/1 spikes
continuous: [batch, seq_len, embed_dim] Continuous representation (used for residuals)
"""
B, S = token_ids.shape
positions = torch.arange(S, device=token_ids.device).unsqueeze(0)
# Embedding + Positional encoding
x = self.embedding(token_ids) + self.pos_embedding(positions)
x = self.norm(x)
# LIF spiking: process step-by-step over time (seq dimension)
self._init_membrane((B, x.size(-1)), x.device)
spikes_list = []
for t in range(S):
# Leak + Integrate
self.v_mem = self.v_mem * self.leak + x[:, t, :]
# Fire a hard-threshold spike.
spike = hard_spike(self.v_mem, self.threshold)
# Reset after firing
self.v_mem = self.v_mem * (1.0 - spike)
spikes_list.append(spike)
spikes = torch.stack(spikes_list, dim=1) # [B, S, D]
return spikes, x # Return spikes and continuous representation (continuous repr used for residual connections)
# ============================================================================
# ③a SparseTCAM — In-Memory Compute Sparse Routing (Replaces Self-Attention)
# ============================================================================
class SparseTCAM(nn.Module):
"""Historical cumulative-context routing block implemented in PyTorch."""
def __init__(self, config: SymbolicLightConfig):
super().__init__()
self.n_heads = config.n_heads
self.head_dim = config.head_dim
self.embed_dim = config.embed_dim
self.threshold = config.spike_threshold
self.leak = config.leak_factor
# TCAM weight matrix (In-memory compute: serves as both storage and computation)
self.tcam_proj = nn.Linear(config.embed_dim, config.embed_dim, bias=False)
# Output projection
self.out_proj = nn.Linear(config.embed_dim, config.embed_dim, bias=False)
self.norm = nn.LayerNorm(config.embed_dim)
self.dropout = nn.Dropout(config.dropout)
def forward(self, spikes: torch.Tensor, continuous: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Args:
spikes: [B, S, D] Input spikes (sparse 0/1)
continuous: [B, S, D] Continuous representation (for residual connections)
Returns:
out_spikes: [B, S, D] Output spikes
out_continuous: [B, S, D] Updated continuous representation
"""
B, S, D = spikes.shape
# 1. Spike mask routing: only compute at positions \"with spikes\"
# spike_mask indicates which positions have active spikes
spike_energy = spikes.sum(dim=-1) # [B, S] Spike energy at each position
active_mask = (spike_energy > 0).unsqueeze(-1).float() # [B, S, 1]
# 2. Project the spike-masked representation.
tcam_out = self.tcam_proj(spikes * active_mask)
# 3. Multi-channel information fusion (similar to multi-head but based on spike channels)
tcam_out = tcam_out.view(B, S, self.n_heads, self.head_dim)
# 4. Temporal context aggregation (using causal mask to prevent seeing future info)
# Simplified version: use cumulative mean replacing all-to-all attention
causal_cumsum = torch.cumsum(tcam_out, dim=1)
counts = torch.arange(1, S + 1, device=spikes.device).float().view(1, S, 1, 1)
context = causal_cumsum / counts
# 5. Merge channels + Output projection
context = context.view(B, S, D)
output = self.out_proj(self.dropout(context))
# 6. Residual connection + Layer normalization
out_continuous = self.norm(continuous + output)
# 7. LIF spiking output
out_spikes = hard_spike(out_continuous, self.threshold)
return out_spikes, out_continuous
# ============================================================================
# ③b EntropyGate — Entropy Gating (On-demand compute depth)
# ============================================================================
class EntropyGate(nn.Module):
"""Compute an optional entropy-based early-exit signal."""
def __init__(self, config: SymbolicLightConfig):
super().__init__()
self.threshold = config.entropy_exit_threshold
# Retained for checkpoint parameter compatibility.
self.gate = nn.Linear(config.embed_dim, 1)
def forward(self, spikes: torch.Tensor) -> Tuple[torch.Tensor, bool]:
"""
Returns:
entropy: Information entropy of the current spike stream [B]
should_exit: Whether early exit is recommended
"""
# Calculate spike firing rate (how many dimensions are activated per position)
firing_rate = spikes.mean(dim=-1) # [B, S]
# Information entropy: H = -p*log(p) - (1-p)*log(1-p)
p = firing_rate.clamp(1e-7, 1 - 1e-7)
entropy = -(p * p.log() + (1 - p) * (1 - p).log()).mean()
# Low entropy -> Can exit
should_exit = entropy.item() < self.threshold
return entropy, should_exit
# ============================================================================
# ③c FeedForward — Feed-Forward Network (with spike sparsity)
# ============================================================================
class SpikingFeedForward(nn.Module):
"""
Replaces the 2-layer MLP of Transformers.
Key difference: Intermediate layer uses LIF spike activation instead of GELU/ReLU.
"""
def __init__(self, config: SymbolicLightConfig):
super().__init__()
self.up = nn.Linear(config.embed_dim, config.intermediate_dim, bias=False)
self.down = nn.Linear(config.intermediate_dim, config.embed_dim, bias=False)
self.norm = nn.LayerNorm(config.embed_dim)
self.threshold = config.spike_threshold
self.dropout = nn.Dropout(config.dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
residual = x
h = self.up(x)
# Use LIF spikes replacing GELU (extremely sparsifying the intermediate layer)
h = hard_spike(h, self.threshold)
h = self.down(self.dropout(h))
return self.norm(residual + h)
# ============================================================================
# ③ SymbolicLightBlock — Single Layer Compute Block
# ============================================================================
class SymbolicLightBlock(nn.Module):
"""
A complete SymbolicLight layer, including:
- SparseTCAM (In-memory compute routing)
- SpikingFeedForward (Spiking feed-forward)
- EntropyGate (Entropy gating)
"""
def __init__(self, config: SymbolicLightConfig):
super().__init__()
self.tcam = SparseTCAM(config)
self.ffn = SpikingFeedForward(config)
self.entropy_gate = EntropyGate(config)
def forward(self, spikes: torch.Tensor, continuous: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, bool]:
# 1. In-memory compute routing
spikes, continuous = self.tcam(spikes, continuous)
# 2. Spiking sequence iteration
continuous = self.ffn(continuous)
# Re-spiking
spikes = hard_spike(continuous, self.tcam.threshold)
# 3. Entropy gating
_, should_exit = self.entropy_gate(spikes)
return spikes, continuous, should_exit
# ============================================================================
# ④ BayesianHead — Bayesian Output Head (Replaces Softmax)
# ============================================================================
class BayesianHead(nn.Module):
"""Output projection with a learned token-prior bias."""
def __init__(self, config: SymbolicLightConfig):
super().__init__()
self.output_proj = nn.Linear(config.embed_dim, config.vocab_size, bias=False)
# Prior probability (learnable word frequency bias)
self.log_prior = nn.Parameter(torch.zeros(config.vocab_size))
def forward(self, continuous: torch.Tensor) -> torch.Tensor:
"""
Args:
continuous: [B, S, D]
Returns:
logits: [B, S, vocab_size] token scores
"""
# Likelihood term: P(context|word)
log_likelihood = self.output_proj(continuous) # [B, S, V]
# Prior term: P(word)
# Bayesian update (log domain addition): log P(word|context) = log P(context|word) + log P(word)
logits = log_likelihood + self.log_prior.unsqueeze(0).unsqueeze(0)
return logits
# ============================================================================
# Complete Model
# ============================================================================
class SymbolicLightModel(nn.Module):
"""Historical SymbolicLight PoC model for checkpoint loading and inference."""
def __init__(self, config: SymbolicLightConfig):
super().__init__()
self.config = config
self.spike_encoder = SpikeEncoder(config)
self.blocks = nn.ModuleList([
SymbolicLightBlock(config) for _ in range(config.n_layers)
])
self.output_head = BayesianHead(config)
n_params = sum(p.numel() for p in self.parameters())
print(f"[SymbolicLight] Model initialization complete | Parameters: {n_params/1e6:.1f}M ({n_params/1e9:.3f}B)")
def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
"""
Forward inference.
Args:
token_ids: [batch, seq_len]
Returns:
logits: [batch, seq_len, vocab_size]
"""
# ② Spike encoding
spikes, continuous = self.spike_encoder(token_ids)
# ③ Layer-by-layer processing
for block in self.blocks:
spikes, continuous, should_exit = block(spikes, continuous)
# The published configuration keeps optional early exit disabled.
if should_exit and self.config.enable_entropy_exit:
break
# ④ Bayesian Output
logits = self.output_head(continuous)
return logits
@torch.no_grad()
def generate(self, prompt_ids: torch.Tensor, max_new_tokens: int = 100,
temperature: float = 0.8, top_k: int = 50) -> torch.Tensor:
"""
Autoregressive text generation
Args:
prompt_ids: [1, prompt_len] Prompt token IDs
max_new_tokens: Maximum number of new tokens to generate
temperature: Sampling temperature (higher = more random)
top_k: Sample only from the top k highest probability tokens
Returns:
Generated complete token sequence
"""
self.eval()
generated = prompt_ids.clone()
for _ in range(max_new_tokens):
# Truncate to maximum length
input_ids = generated[:, -self.config.max_seq_len:]
# Forward inference
logits = self.forward(input_ids)
# Take logits only at the last position
next_logits = logits[:, -1, :] / temperature
# Top-K sampling
if top_k > 0:
top_k_vals, _ = torch.topk(next_logits, top_k)
min_top_k = top_k_vals[:, -1].unsqueeze(-1)
next_logits[next_logits < min_top_k] = float('-inf')
# Sample next token
probs = F.softmax(next_logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
# Append to sequence
generated = torch.cat([generated, next_token], dim=1)
# EOS detection (token_id=2 is usually EOS)
if next_token.item() == 2:
break
return generated
def get_sparsity_stats(self) -> dict:
"""Return sparsity statistics for a small synthetic input."""
stats = {}
with torch.no_grad():
device = self.spike_encoder.embedding.weight.device
dummy = torch.randint(0, min(100, self.config.vocab_size), (1, 32), device=device)
spikes, _ = self.spike_encoder(dummy)
stats['encoder_sparsity'] = 1.0 - spikes.mean().item()
for i, block in enumerate(self.blocks):
spikes, _, _ = block(spikes, spikes)
stats[f'block_{i}_sparsity'] = 1.0 - spikes.mean().item()
return stats