Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- spectral/__init__.py +9 -0
- spectral/eigenvalue_analyzer.py +598 -0
- spectral/gramian.py +399 -0
- spectral/horizon_predictor.py +454 -0
spectral/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Spectral analysis module for Mamba SSM eigenvalue analysis.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from mamba_spectral.spectral.eigenvalue_analyzer import SpectralAnalyzer
|
| 6 |
+
from mamba_spectral.spectral.gramian import ReachabilityGramian
|
| 7 |
+
from mamba_spectral.spectral.horizon_predictor import HorizonPredictor
|
| 8 |
+
|
| 9 |
+
__all__ = ["SpectralAnalyzer", "ReachabilityGramian", "HorizonPredictor"]
|
spectral/eigenvalue_analyzer.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Eigenvalue Analyzer for Mamba SSM.
|
| 3 |
+
|
| 4 |
+
Computes and analyzes eigenvalues of the state transition matrix to
|
| 5 |
+
understand memory dynamics and reasoning capabilities.
|
| 6 |
+
|
| 7 |
+
References:
|
| 8 |
+
[Gu2023] Gu, A., & Dao, T. (2023). Mamba: Linear-Time Sequence Modeling
|
| 9 |
+
with Selective State Spaces. arXiv:2312.00752
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
from sklearn.cluster import KMeans
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class SpectralTrajectory:
|
| 28 |
+
"""
|
| 29 |
+
Container for eigenvalue evolution over tokens.
|
| 30 |
+
|
| 31 |
+
Attributes:
|
| 32 |
+
timesteps: Token indices where measurements were taken.
|
| 33 |
+
eigenvalues: List of eigenvalue arrays at each timestep.
|
| 34 |
+
spectral_radius: Spectral radius (max |λ|) at each timestep.
|
| 35 |
+
delta_values: Discretization step Δ at each timestep.
|
| 36 |
+
layer_idx: Layer index being tracked.
|
| 37 |
+
"""
|
| 38 |
+
timesteps: List[int] = field(default_factory=list)
|
| 39 |
+
eigenvalues: List[np.ndarray] = field(default_factory=list)
|
| 40 |
+
spectral_radius: List[float] = field(default_factory=list)
|
| 41 |
+
delta_values: Optional[List[np.ndarray]] = None
|
| 42 |
+
layer_idx: Optional[int] = None
|
| 43 |
+
|
| 44 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 45 |
+
"""Convert to dictionary for serialization."""
|
| 46 |
+
return {
|
| 47 |
+
"timesteps": self.timesteps,
|
| 48 |
+
"eigenvalues": [e.tolist() for e in self.eigenvalues],
|
| 49 |
+
"spectral_radius": self.spectral_radius,
|
| 50 |
+
"delta_values": [d.tolist() for d in self.delta_values] if self.delta_values else None,
|
| 51 |
+
"layer_idx": self.layer_idx,
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass
|
| 56 |
+
class ClusterResult:
|
| 57 |
+
"""
|
| 58 |
+
Result of eigenvalue clustering.
|
| 59 |
+
|
| 60 |
+
Attributes:
|
| 61 |
+
centers: Cluster centers in complex plane.
|
| 62 |
+
labels: Cluster label for each eigenvalue.
|
| 63 |
+
sizes: Number of eigenvalues in each cluster.
|
| 64 |
+
inertia: Sum of squared distances to cluster centers.
|
| 65 |
+
"""
|
| 66 |
+
centers: np.ndarray
|
| 67 |
+
labels: np.ndarray
|
| 68 |
+
sizes: np.ndarray
|
| 69 |
+
inertia: float
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class SpectralAnalyzer:
|
| 73 |
+
"""
|
| 74 |
+
Analyzes eigenvalues of the SSM transition matrix in Mamba models.
|
| 75 |
+
|
| 76 |
+
The eigenvalues λ of the discretized matrix Ā determine:
|
| 77 |
+
- |λ| ≈ 1: Long-term memory (information preserved)
|
| 78 |
+
- |λ| ≈ 0: Fast forgetting (filters noise)
|
| 79 |
+
- |λ| > 1: Unstable dynamics (exponential growth)
|
| 80 |
+
|
| 81 |
+
For Mamba, A is diagonal, so eigenvalues are simply the diagonal elements.
|
| 82 |
+
|
| 83 |
+
Attributes:
|
| 84 |
+
wrapper: MambaWrapper instance.
|
| 85 |
+
extractor: StateExtractor for accessing A matrices.
|
| 86 |
+
device: Computation device.
|
| 87 |
+
|
| 88 |
+
Example:
|
| 89 |
+
>>> analyzer = SpectralAnalyzer(model)
|
| 90 |
+
>>> eigenvalues = analyzer.compute_eigenvalues(layer_idx=0)
|
| 91 |
+
>>> print(f"Spectral radius: {analyzer.spectral_radius(eigenvalues):.4f}")
|
| 92 |
+
|
| 93 |
+
>>> trajectory = analyzer.track_evolution(prompt_tokens)
|
| 94 |
+
>>> print(f"Radius declined from {trajectory.spectral_radius[0]:.3f} "
|
| 95 |
+
... f"to {trajectory.spectral_radius[-1]:.3f}")
|
| 96 |
+
"""
|
| 97 |
+
|
| 98 |
+
def __init__(
|
| 99 |
+
self,
|
| 100 |
+
model: Union["MambaWrapper", nn.Module],
|
| 101 |
+
device: str = "cuda",
|
| 102 |
+
) -> None:
|
| 103 |
+
"""
|
| 104 |
+
Initialize the spectral analyzer.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
model: MambaWrapper or raw Mamba model.
|
| 108 |
+
device: Device for computation ('cuda' or 'cpu').
|
| 109 |
+
"""
|
| 110 |
+
# Handle both MambaWrapper and raw model
|
| 111 |
+
from mamba_spectral.core.mamba_wrapper import MambaWrapper
|
| 112 |
+
from mamba_spectral.core.state_extractor import StateExtractor
|
| 113 |
+
|
| 114 |
+
if isinstance(model, MambaWrapper):
|
| 115 |
+
self.wrapper = model
|
| 116 |
+
else:
|
| 117 |
+
self.wrapper = MambaWrapper(model, device=device)
|
| 118 |
+
|
| 119 |
+
self.extractor = StateExtractor(self.wrapper)
|
| 120 |
+
self.device = device
|
| 121 |
+
|
| 122 |
+
logger.info(f"SpectralAnalyzer initialized on {device}")
|
| 123 |
+
|
| 124 |
+
def extract_A_matrix(
|
| 125 |
+
self,
|
| 126 |
+
layer_idx: int,
|
| 127 |
+
as_numpy: bool = False,
|
| 128 |
+
) -> Union[torch.Tensor, np.ndarray]:
|
| 129 |
+
"""
|
| 130 |
+
Extract the continuous A matrix from a specific layer.
|
| 131 |
+
|
| 132 |
+
In Mamba, A is stored as A_log and computed as A = -exp(A_log).
|
| 133 |
+
This ensures stability (negative eigenvalues → stable dynamics).
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
layer_idx: Index of the Mamba layer (0-indexed).
|
| 137 |
+
as_numpy: If True, return numpy array instead of tensor.
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
A matrix of shape [d_inner, d_state].
|
| 141 |
+
|
| 142 |
+
Example:
|
| 143 |
+
>>> A = analyzer.extract_A_matrix(layer_idx=0)
|
| 144 |
+
>>> print(f"A shape: {A.shape}")
|
| 145 |
+
"""
|
| 146 |
+
A = self.extractor.extract_A_matrix(layer_idx)
|
| 147 |
+
|
| 148 |
+
if as_numpy:
|
| 149 |
+
return A.detach().cpu().numpy()
|
| 150 |
+
return A
|
| 151 |
+
|
| 152 |
+
def discretize_A(
|
| 153 |
+
self,
|
| 154 |
+
A_continuous: torch.Tensor,
|
| 155 |
+
delta: Union[torch.Tensor, float],
|
| 156 |
+
method: str = "zoh",
|
| 157 |
+
) -> torch.Tensor:
|
| 158 |
+
"""
|
| 159 |
+
Discretize the continuous A matrix using Zero-Order Hold.
|
| 160 |
+
|
| 161 |
+
Ā = exp(Δ · A)
|
| 162 |
+
|
| 163 |
+
For diagonal A (as in Mamba), this is element-wise exponentiation.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
A_continuous: Continuous A matrix. Shape: [d_inner, d_state].
|
| 167 |
+
delta: Discretization step. Can be:
|
| 168 |
+
- float: Single value applied uniformly
|
| 169 |
+
- Tensor [d_inner]: Per-channel delta
|
| 170 |
+
- Tensor [batch, seq_len, d_inner]: Input-dependent delta
|
| 171 |
+
method: Discretization method:
|
| 172 |
+
- 'zoh': Zero-Order Hold (default, recommended)
|
| 173 |
+
- 'euler': Forward Euler (less accurate)
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
torch.Tensor: Discretized Ā matrix.
|
| 177 |
+
|
| 178 |
+
Example:
|
| 179 |
+
>>> A = analyzer.extract_A_matrix(0)
|
| 180 |
+
>>> A_bar = analyzer.discretize_A(A, delta=0.01)
|
| 181 |
+
>>> eigenvalues = analyzer.compute_eigenvalues(A_bar)
|
| 182 |
+
"""
|
| 183 |
+
return self.extractor.discretize(A_continuous, delta, method)
|
| 184 |
+
|
| 185 |
+
def compute_eigenvalues(
|
| 186 |
+
self,
|
| 187 |
+
A_matrix: Union[torch.Tensor, np.ndarray],
|
| 188 |
+
sort_by: str = "magnitude",
|
| 189 |
+
) -> np.ndarray:
|
| 190 |
+
"""
|
| 191 |
+
Compute eigenvalues of the A matrix.
|
| 192 |
+
|
| 193 |
+
For diagonal matrices (as in Mamba), eigenvalues ARE the diagonal.
|
| 194 |
+
For non-diagonal matrices, uses torch.linalg.eigvals.
|
| 195 |
+
|
| 196 |
+
Args:
|
| 197 |
+
A_matrix: State transition matrix to analyze.
|
| 198 |
+
- Diagonal: Shape [d_inner, d_state] or [d_state]
|
| 199 |
+
- Full: Shape [d_state, d_state]
|
| 200 |
+
sort_by: How to sort eigenvalues:
|
| 201 |
+
- 'magnitude': |λ| descending (default)
|
| 202 |
+
- 'real': Real part descending
|
| 203 |
+
- 'none': No sorting
|
| 204 |
+
|
| 205 |
+
Returns:
|
| 206 |
+
np.ndarray: Complex eigenvalues sorted as specified.
|
| 207 |
+
|
| 208 |
+
Note:
|
| 209 |
+
For Mamba's diagonal A, eigenvalues are real and negative
|
| 210 |
+
(since A = -exp(A_log)).
|
| 211 |
+
"""
|
| 212 |
+
if isinstance(A_matrix, torch.Tensor):
|
| 213 |
+
A_matrix = A_matrix.detach().cpu().numpy()
|
| 214 |
+
|
| 215 |
+
# Check if diagonal (Mamba case)
|
| 216 |
+
if A_matrix.ndim == 1:
|
| 217 |
+
# Already diagonal
|
| 218 |
+
eigenvalues = A_matrix.astype(np.complex128)
|
| 219 |
+
elif A_matrix.ndim == 2 and A_matrix.shape[0] != A_matrix.shape[1]:
|
| 220 |
+
# [d_inner, d_state] - flatten diagonal
|
| 221 |
+
eigenvalues = A_matrix.flatten().astype(np.complex128)
|
| 222 |
+
else:
|
| 223 |
+
# Full square matrix - compute eigvals
|
| 224 |
+
eigenvalues = np.linalg.eigvals(A_matrix)
|
| 225 |
+
|
| 226 |
+
# Sort eigenvalues
|
| 227 |
+
if sort_by == "magnitude":
|
| 228 |
+
indices = np.argsort(-np.abs(eigenvalues))
|
| 229 |
+
eigenvalues = eigenvalues[indices]
|
| 230 |
+
elif sort_by == "real":
|
| 231 |
+
indices = np.argsort(-np.real(eigenvalues))
|
| 232 |
+
eigenvalues = eigenvalues[indices]
|
| 233 |
+
# else: no sorting
|
| 234 |
+
|
| 235 |
+
return eigenvalues
|
| 236 |
+
|
| 237 |
+
def spectral_radius(
|
| 238 |
+
self,
|
| 239 |
+
eigenvalues: Union[np.ndarray, torch.Tensor],
|
| 240 |
+
) -> float:
|
| 241 |
+
"""
|
| 242 |
+
Compute the spectral radius ρ(A) = max(|λ_i|).
|
| 243 |
+
|
| 244 |
+
The spectral radius determines:
|
| 245 |
+
- ρ < 1: System is stable (states decay)
|
| 246 |
+
- ρ = 1: Marginally stable (states persist)
|
| 247 |
+
- ρ > 1: Unstable (states grow exponentially)
|
| 248 |
+
|
| 249 |
+
Args:
|
| 250 |
+
eigenvalues: Array of eigenvalues.
|
| 251 |
+
|
| 252 |
+
Returns:
|
| 253 |
+
float: Maximum absolute eigenvalue.
|
| 254 |
+
|
| 255 |
+
Example:
|
| 256 |
+
>>> eigenvalues = analyzer.compute_eigenvalues(A_bar)
|
| 257 |
+
>>> rho = analyzer.spectral_radius(eigenvalues)
|
| 258 |
+
>>> print(f"Spectral radius: {rho:.4f}")
|
| 259 |
+
>>> if rho > 1:
|
| 260 |
+
... print("WARNING: System is unstable!")
|
| 261 |
+
"""
|
| 262 |
+
if isinstance(eigenvalues, torch.Tensor):
|
| 263 |
+
eigenvalues = eigenvalues.detach().cpu().numpy()
|
| 264 |
+
|
| 265 |
+
return float(np.max(np.abs(eigenvalues)))
|
| 266 |
+
|
| 267 |
+
def spectral_gap(
|
| 268 |
+
self,
|
| 269 |
+
eigenvalues: np.ndarray,
|
| 270 |
+
) -> float:
|
| 271 |
+
"""
|
| 272 |
+
Compute the spectral gap = |λ_1| - |λ_2|.
|
| 273 |
+
|
| 274 |
+
A larger gap indicates more distinct dominant dynamics.
|
| 275 |
+
|
| 276 |
+
Args:
|
| 277 |
+
eigenvalues: Array of eigenvalues (will be sorted by magnitude).
|
| 278 |
+
|
| 279 |
+
Returns:
|
| 280 |
+
float: Difference between top two eigenvalue magnitudes.
|
| 281 |
+
"""
|
| 282 |
+
magnitudes = np.sort(np.abs(eigenvalues))[::-1]
|
| 283 |
+
|
| 284 |
+
if len(magnitudes) < 2:
|
| 285 |
+
return 0.0
|
| 286 |
+
|
| 287 |
+
return float(magnitudes[0] - magnitudes[1])
|
| 288 |
+
|
| 289 |
+
def condition_number(
|
| 290 |
+
self,
|
| 291 |
+
eigenvalues: np.ndarray,
|
| 292 |
+
) -> float:
|
| 293 |
+
"""
|
| 294 |
+
Compute condition number = |λ_max| / |λ_min|.
|
| 295 |
+
|
| 296 |
+
High condition number indicates sensitivity to perturbations.
|
| 297 |
+
|
| 298 |
+
Args:
|
| 299 |
+
eigenvalues: Array of eigenvalues.
|
| 300 |
+
|
| 301 |
+
Returns:
|
| 302 |
+
float: Ratio of largest to smallest eigenvalue magnitude.
|
| 303 |
+
"""
|
| 304 |
+
magnitudes = np.abs(eigenvalues)
|
| 305 |
+
magnitudes = magnitudes[magnitudes > 1e-10] # Filter near-zero
|
| 306 |
+
|
| 307 |
+
if len(magnitudes) == 0:
|
| 308 |
+
return float("inf")
|
| 309 |
+
|
| 310 |
+
return float(np.max(magnitudes) / np.min(magnitudes))
|
| 311 |
+
|
| 312 |
+
def eigenvalue_clustering(
|
| 313 |
+
self,
|
| 314 |
+
eigenvalues: np.ndarray,
|
| 315 |
+
n_clusters: int = 2,
|
| 316 |
+
random_state: int = 42,
|
| 317 |
+
) -> ClusterResult:
|
| 318 |
+
"""
|
| 319 |
+
Detect clustering of eigenvalues in the complex plane.
|
| 320 |
+
|
| 321 |
+
Clustering reveals "spectral engramas" - groups of eigenvalues
|
| 322 |
+
that may encode different types of information.
|
| 323 |
+
|
| 324 |
+
Args:
|
| 325 |
+
eigenvalues: Complex eigenvalues to cluster.
|
| 326 |
+
n_clusters: Number of clusters (default: 2).
|
| 327 |
+
random_state: Random seed for reproducibility.
|
| 328 |
+
|
| 329 |
+
Returns:
|
| 330 |
+
ClusterResult with:
|
| 331 |
+
- centers: Cluster center locations
|
| 332 |
+
- labels: Cluster assignment for each eigenvalue
|
| 333 |
+
- sizes: Number of eigenvalues per cluster
|
| 334 |
+
- inertia: Clustering quality metric
|
| 335 |
+
|
| 336 |
+
Example:
|
| 337 |
+
>>> result = analyzer.eigenvalue_clustering(eigenvalues, n_clusters=3)
|
| 338 |
+
>>> print(f"Cluster sizes: {result.sizes}")
|
| 339 |
+
>>> print(f"Centers: {result.centers}")
|
| 340 |
+
"""
|
| 341 |
+
# Convert complex to 2D real coordinates
|
| 342 |
+
X = np.column_stack([
|
| 343 |
+
np.real(eigenvalues),
|
| 344 |
+
np.imag(eigenvalues),
|
| 345 |
+
])
|
| 346 |
+
|
| 347 |
+
# Fit K-means
|
| 348 |
+
kmeans = KMeans(
|
| 349 |
+
n_clusters=n_clusters,
|
| 350 |
+
random_state=random_state,
|
| 351 |
+
n_init=10,
|
| 352 |
+
)
|
| 353 |
+
labels = kmeans.fit_predict(X)
|
| 354 |
+
|
| 355 |
+
# Convert centers back to complex
|
| 356 |
+
centers = kmeans.cluster_centers_[:, 0] + 1j * kmeans.cluster_centers_[:, 1]
|
| 357 |
+
|
| 358 |
+
# Count cluster sizes
|
| 359 |
+
sizes = np.bincount(labels, minlength=n_clusters)
|
| 360 |
+
|
| 361 |
+
return ClusterResult(
|
| 362 |
+
centers=centers,
|
| 363 |
+
labels=labels,
|
| 364 |
+
sizes=sizes,
|
| 365 |
+
inertia=float(kmeans.inertia_),
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
@torch.no_grad()
|
| 369 |
+
def track_evolution(
|
| 370 |
+
self,
|
| 371 |
+
prompt: Union[str, torch.Tensor],
|
| 372 |
+
layer_idx: int = 0,
|
| 373 |
+
save_every: int = 1,
|
| 374 |
+
delta_value: float = 0.01,
|
| 375 |
+
) -> SpectralTrajectory:
|
| 376 |
+
"""
|
| 377 |
+
Track eigenvalue evolution token-by-token during inference.
|
| 378 |
+
|
| 379 |
+
This is the core method for understanding how spectral properties
|
| 380 |
+
change as the model processes input.
|
| 381 |
+
|
| 382 |
+
Args:
|
| 383 |
+
prompt: Input text (string) or token IDs (tensor).
|
| 384 |
+
layer_idx: Which Mamba layer to analyze (0-indexed).
|
| 385 |
+
save_every: Record every N tokens (1 = all tokens).
|
| 386 |
+
delta_value: Default delta for discretization.
|
| 387 |
+
|
| 388 |
+
Returns:
|
| 389 |
+
SpectralTrajectory containing:
|
| 390 |
+
- timesteps: Token indices
|
| 391 |
+
- eigenvalues: Eigenvalues at each timestep
|
| 392 |
+
- spectral_radius: ρ(Ā) at each timestep
|
| 393 |
+
|
| 394 |
+
Example:
|
| 395 |
+
>>> trajectory = analyzer.track_evolution(
|
| 396 |
+
... "The capital of France is Paris, located in Europe.",
|
| 397 |
+
... layer_idx=0,
|
| 398 |
+
... )
|
| 399 |
+
>>> import matplotlib.pyplot as plt
|
| 400 |
+
>>> plt.plot(trajectory.timesteps, trajectory.spectral_radius)
|
| 401 |
+
>>> plt.xlabel("Token")
|
| 402 |
+
>>> plt.ylabel("Spectral Radius")
|
| 403 |
+
>>> plt.show()
|
| 404 |
+
"""
|
| 405 |
+
# Tokenize if string
|
| 406 |
+
if isinstance(prompt, str):
|
| 407 |
+
if self.wrapper.tokenizer is None:
|
| 408 |
+
raise ValueError("Tokenizer required for string input")
|
| 409 |
+
tokens = self.wrapper.tokenizer.encode(prompt, return_tensors="pt")
|
| 410 |
+
tokens = tokens.to(self.device)
|
| 411 |
+
else:
|
| 412 |
+
tokens = prompt.to(self.device)
|
| 413 |
+
|
| 414 |
+
trajectory = SpectralTrajectory(layer_idx=layer_idx)
|
| 415 |
+
seq_len = tokens.shape[1]
|
| 416 |
+
|
| 417 |
+
# Get base A matrix
|
| 418 |
+
A = self.extract_A_matrix(layer_idx)
|
| 419 |
+
delta = torch.tensor(delta_value, device=self.device)
|
| 420 |
+
|
| 421 |
+
# Track through sequence
|
| 422 |
+
for t in range(0, seq_len, save_every):
|
| 423 |
+
# Create partial input
|
| 424 |
+
partial_tokens = tokens[:, :t+1]
|
| 425 |
+
|
| 426 |
+
# Get discretized A for this context
|
| 427 |
+
# Note: In full implementation, would extract Δ from forward pass
|
| 428 |
+
A_bar = self.discretize_A(A, delta)
|
| 429 |
+
|
| 430 |
+
# Compute eigenvalues
|
| 431 |
+
eigenvalues = self.compute_eigenvalues(A_bar)
|
| 432 |
+
radius = self.spectral_radius(eigenvalues)
|
| 433 |
+
|
| 434 |
+
trajectory.timesteps.append(t)
|
| 435 |
+
trajectory.eigenvalues.append(eigenvalues)
|
| 436 |
+
trajectory.spectral_radius.append(radius)
|
| 437 |
+
|
| 438 |
+
logger.info(
|
| 439 |
+
f"Tracked {len(trajectory.timesteps)} timesteps. "
|
| 440 |
+
f"Radius range: [{min(trajectory.spectral_radius):.3f}, "
|
| 441 |
+
f"{max(trajectory.spectral_radius):.3f}]"
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
return trajectory
|
| 445 |
+
|
| 446 |
+
@torch.no_grad()
|
| 447 |
+
def track_evolution_with_hooks(
|
| 448 |
+
self,
|
| 449 |
+
prompt: Union[str, torch.Tensor],
|
| 450 |
+
layer_indices: Optional[List[int]] = None,
|
| 451 |
+
) -> Dict[int, SpectralTrajectory]:
|
| 452 |
+
"""
|
| 453 |
+
Track evolution using forward hooks for accurate Δ extraction.
|
| 454 |
+
|
| 455 |
+
This method registers hooks to capture the actual input-dependent
|
| 456 |
+
discretization step during forward pass.
|
| 457 |
+
|
| 458 |
+
Args:
|
| 459 |
+
prompt: Input text or tokens.
|
| 460 |
+
layer_indices: Which layers to track (None = all).
|
| 461 |
+
|
| 462 |
+
Returns:
|
| 463 |
+
Dict mapping layer_idx to SpectralTrajectory.
|
| 464 |
+
"""
|
| 465 |
+
# Tokenize
|
| 466 |
+
if isinstance(prompt, str):
|
| 467 |
+
tokens = self.wrapper.tokenizer.encode(prompt, return_tensors="pt")
|
| 468 |
+
tokens = tokens.to(self.device)
|
| 469 |
+
else:
|
| 470 |
+
tokens = prompt.to(self.device)
|
| 471 |
+
|
| 472 |
+
layers = self.wrapper.get_mamba_layers()
|
| 473 |
+
if layer_indices is None:
|
| 474 |
+
layer_indices = list(range(len(layers)))
|
| 475 |
+
|
| 476 |
+
# Storage for captured data
|
| 477 |
+
captured_delta: Dict[int, List[torch.Tensor]] = {i: [] for i in layer_indices}
|
| 478 |
+
|
| 479 |
+
def make_hook(layer_idx):
|
| 480 |
+
def hook(module, input, output):
|
| 481 |
+
# Try to extract delta from module state
|
| 482 |
+
if hasattr(module, "_delta"):
|
| 483 |
+
captured_delta[layer_idx].append(module._delta.detach().cpu())
|
| 484 |
+
return hook
|
| 485 |
+
|
| 486 |
+
# Register hooks
|
| 487 |
+
handles = []
|
| 488 |
+
for idx in layer_indices:
|
| 489 |
+
handle = self.wrapper.register_hook(idx, make_hook(idx), "forward")
|
| 490 |
+
handles.append(handle)
|
| 491 |
+
|
| 492 |
+
try:
|
| 493 |
+
# Forward pass
|
| 494 |
+
_ = self.wrapper.forward(tokens)
|
| 495 |
+
finally:
|
| 496 |
+
# Clean up hooks
|
| 497 |
+
for handle in handles:
|
| 498 |
+
handle.remove()
|
| 499 |
+
|
| 500 |
+
# Build trajectories
|
| 501 |
+
trajectories = {}
|
| 502 |
+
for layer_idx in layer_indices:
|
| 503 |
+
A = self.extract_A_matrix(layer_idx)
|
| 504 |
+
trajectory = SpectralTrajectory(layer_idx=layer_idx)
|
| 505 |
+
|
| 506 |
+
deltas = captured_delta.get(layer_idx, [])
|
| 507 |
+
if deltas:
|
| 508 |
+
for t, delta in enumerate(deltas):
|
| 509 |
+
A_bar = self.discretize_A(A, delta.to(self.device))
|
| 510 |
+
eigenvalues = self.compute_eigenvalues(A_bar)
|
| 511 |
+
|
| 512 |
+
trajectory.timesteps.append(t)
|
| 513 |
+
trajectory.eigenvalues.append(eigenvalues)
|
| 514 |
+
trajectory.spectral_radius.append(self.spectral_radius(eigenvalues))
|
| 515 |
+
else:
|
| 516 |
+
# Fallback: use default delta
|
| 517 |
+
trajectory = self.track_evolution(tokens, layer_idx)
|
| 518 |
+
|
| 519 |
+
trajectories[layer_idx] = trajectory
|
| 520 |
+
|
| 521 |
+
return trajectories
|
| 522 |
+
|
| 523 |
+
def summarize_layer(
|
| 524 |
+
self,
|
| 525 |
+
layer_idx: int,
|
| 526 |
+
delta_value: float = 0.01,
|
| 527 |
+
) -> Dict[str, Any]:
|
| 528 |
+
"""
|
| 529 |
+
Generate summary statistics for a layer's spectral properties.
|
| 530 |
+
|
| 531 |
+
Args:
|
| 532 |
+
layer_idx: Layer to analyze.
|
| 533 |
+
delta_value: Discretization step.
|
| 534 |
+
|
| 535 |
+
Returns:
|
| 536 |
+
Dictionary with:
|
| 537 |
+
- spectral_radius: ρ(Ā)
|
| 538 |
+
- spectral_gap: |λ_1| - |λ_2|
|
| 539 |
+
- condition_number: |λ_max| / |λ_min|
|
| 540 |
+
- num_stable: Count of |λ| < 1
|
| 541 |
+
- num_unstable: Count of |λ| > 1
|
| 542 |
+
- mean_magnitude: Average |λ|
|
| 543 |
+
- std_magnitude: Std of |λ|
|
| 544 |
+
"""
|
| 545 |
+
A = self.extract_A_matrix(layer_idx)
|
| 546 |
+
A_bar = self.discretize_A(A, torch.tensor(delta_value))
|
| 547 |
+
eigenvalues = self.compute_eigenvalues(A_bar)
|
| 548 |
+
magnitudes = np.abs(eigenvalues)
|
| 549 |
+
|
| 550 |
+
return {
|
| 551 |
+
"layer_idx": layer_idx,
|
| 552 |
+
"num_eigenvalues": len(eigenvalues),
|
| 553 |
+
"spectral_radius": self.spectral_radius(eigenvalues),
|
| 554 |
+
"spectral_gap": self.spectral_gap(eigenvalues),
|
| 555 |
+
"condition_number": self.condition_number(eigenvalues),
|
| 556 |
+
"num_stable": int(np.sum(magnitudes < 1)),
|
| 557 |
+
"num_unstable": int(np.sum(magnitudes > 1)),
|
| 558 |
+
"num_marginal": int(np.sum(np.isclose(magnitudes, 1, atol=0.01))),
|
| 559 |
+
"mean_magnitude": float(np.mean(magnitudes)),
|
| 560 |
+
"std_magnitude": float(np.std(magnitudes)),
|
| 561 |
+
"min_magnitude": float(np.min(magnitudes)),
|
| 562 |
+
"max_magnitude": float(np.max(magnitudes)),
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
def analyze_all_layers(
|
| 566 |
+
self,
|
| 567 |
+
delta_value: float = 0.01,
|
| 568 |
+
) -> List[Dict[str, Any]]:
|
| 569 |
+
"""
|
| 570 |
+
Analyze spectral properties of all Mamba layers.
|
| 571 |
+
|
| 572 |
+
Args:
|
| 573 |
+
delta_value: Discretization step.
|
| 574 |
+
|
| 575 |
+
Returns:
|
| 576 |
+
List of summary dictionaries, one per layer.
|
| 577 |
+
"""
|
| 578 |
+
layers = self.wrapper.get_mamba_layers()
|
| 579 |
+
results = []
|
| 580 |
+
|
| 581 |
+
for i in range(len(layers)):
|
| 582 |
+
try:
|
| 583 |
+
summary = self.summarize_layer(i, delta_value)
|
| 584 |
+
results.append(summary)
|
| 585 |
+
except Exception as e:
|
| 586 |
+
logger.warning(f"Could not analyze layer {i}: {e}")
|
| 587 |
+
results.append({"layer_idx": i, "error": str(e)})
|
| 588 |
+
|
| 589 |
+
return results
|
| 590 |
+
|
| 591 |
+
def __repr__(self) -> str:
|
| 592 |
+
return (
|
| 593 |
+
f"SpectralAnalyzer(\n"
|
| 594 |
+
f" model='{self.wrapper.model_name}',\n"
|
| 595 |
+
f" num_layers={len(self.wrapper.get_mamba_layers())},\n"
|
| 596 |
+
f" device='{self.device}'\n"
|
| 597 |
+
f")"
|
| 598 |
+
)
|
spectral/gramian.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reachability Gramian Computation for Mamba SSM.
|
| 3 |
+
|
| 4 |
+
The Reachability Gramian W_R determines which states are reachable from
|
| 5 |
+
input and quantifies the reasoning horizon of the model.
|
| 6 |
+
|
| 7 |
+
References:
|
| 8 |
+
[Antsaklis2007] Antsaklis, P. J., & Michel, A. N. (2007).
|
| 9 |
+
A Linear Systems Primer. Birkhäuser.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import torch
|
| 20 |
+
from tqdm import tqdm
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class GramianResult:
|
| 27 |
+
"""
|
| 28 |
+
Result of Gramian computation.
|
| 29 |
+
|
| 30 |
+
Attributes:
|
| 31 |
+
gramian: The reachability gramian W_R.
|
| 32 |
+
singular_values: Singular values of W_R at each step.
|
| 33 |
+
rank: Numerical rank of W_R (σ > threshold).
|
| 34 |
+
min_singular_value: Smallest singular value.
|
| 35 |
+
is_full_rank: Whether gramian has full rank.
|
| 36 |
+
horizon_reached: Step where reachability became limited.
|
| 37 |
+
"""
|
| 38 |
+
gramian: np.ndarray
|
| 39 |
+
singular_values: List[np.ndarray]
|
| 40 |
+
rank: int
|
| 41 |
+
min_singular_value: float
|
| 42 |
+
is_full_rank: bool
|
| 43 |
+
horizon_reached: Optional[int] = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class ReachabilityGramian:
|
| 47 |
+
"""
|
| 48 |
+
Computes the Reachability Gramian for SSM analysis.
|
| 49 |
+
|
| 50 |
+
The Reachability Gramian is defined as:
|
| 51 |
+
W_R(T) = Σ_{k=0}^{T-1} Ā^k · B̄ · B̄^T · (Ā^T)^k
|
| 52 |
+
|
| 53 |
+
Properties:
|
| 54 |
+
- If W_R is full rank, all states are reachable from input
|
| 55 |
+
- If W_R is singular, some states are unreachable
|
| 56 |
+
- The horizon H is where σ_min(W_R) drops below threshold
|
| 57 |
+
|
| 58 |
+
This determines the Spectral Horizon - the maximum number of
|
| 59 |
+
reasoning steps the model can effectively perform.
|
| 60 |
+
|
| 61 |
+
Example:
|
| 62 |
+
>>> gramian_calc = ReachabilityGramian(device='cuda')
|
| 63 |
+
>>> result = gramian_calc.compute(A_bar, B_bar, horizon=100)
|
| 64 |
+
>>> print(f"Rank: {result.rank}, Full rank: {result.is_full_rank}")
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
def __init__(
|
| 68 |
+
self,
|
| 69 |
+
device: str = "cuda",
|
| 70 |
+
dtype: torch.dtype = torch.float64,
|
| 71 |
+
) -> None:
|
| 72 |
+
"""
|
| 73 |
+
Initialize the Gramian calculator.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
device: Computation device.
|
| 77 |
+
dtype: Data type (float64 recommended for numerical accuracy).
|
| 78 |
+
"""
|
| 79 |
+
self.device = device
|
| 80 |
+
self.dtype = dtype
|
| 81 |
+
|
| 82 |
+
logger.info(f"ReachabilityGramian initialized on {device} with {dtype}")
|
| 83 |
+
|
| 84 |
+
def compute(
|
| 85 |
+
self,
|
| 86 |
+
A_bar: torch.Tensor,
|
| 87 |
+
B_bar: torch.Tensor,
|
| 88 |
+
horizon: int = 100,
|
| 89 |
+
threshold: float = 1e-10,
|
| 90 |
+
track_singular_values: bool = True,
|
| 91 |
+
show_progress: bool = False,
|
| 92 |
+
) -> GramianResult:
|
| 93 |
+
"""
|
| 94 |
+
Compute the Reachability Gramian iteratively.
|
| 95 |
+
|
| 96 |
+
W_R(T) = Σ_{k=0}^{T-1} Ā^k · B̄ · B̄^T · (Ā^T)^k
|
| 97 |
+
|
| 98 |
+
Uses iterative accumulation to avoid O(T × n^3) matrix multiplications.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
A_bar: Discretized state transition. Shape: [d_state, d_state] or [d_inner, d_state] (diagonal).
|
| 102 |
+
B_bar: Discretized input matrix. Shape: [d_state, d_input] or [d_state].
|
| 103 |
+
horizon: Maximum number of steps (T).
|
| 104 |
+
threshold: Singular value threshold for rank determination.
|
| 105 |
+
track_singular_values: If True, record σ at each step.
|
| 106 |
+
show_progress: Show progress bar.
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
GramianResult with gramian, singular values, and rank info.
|
| 110 |
+
|
| 111 |
+
Note:
|
| 112 |
+
For diagonal A (as in Mamba), computation is much more efficient
|
| 113 |
+
as we only need elementwise operations.
|
| 114 |
+
"""
|
| 115 |
+
# Ensure tensors are on device with proper dtype
|
| 116 |
+
A_bar = A_bar.to(device=self.device, dtype=self.dtype)
|
| 117 |
+
B_bar = B_bar.to(device=self.device, dtype=self.dtype)
|
| 118 |
+
|
| 119 |
+
# Handle diagonal A case (Mamba)
|
| 120 |
+
is_diagonal = (A_bar.ndim == 1) or (A_bar.ndim == 2 and A_bar.shape[0] != A_bar.shape[1])
|
| 121 |
+
|
| 122 |
+
if is_diagonal:
|
| 123 |
+
return self._compute_diagonal(A_bar, B_bar, horizon, threshold, track_singular_values, show_progress)
|
| 124 |
+
else:
|
| 125 |
+
return self._compute_full(A_bar, B_bar, horizon, threshold, track_singular_values, show_progress)
|
| 126 |
+
|
| 127 |
+
def _compute_diagonal(
|
| 128 |
+
self,
|
| 129 |
+
A_bar: torch.Tensor,
|
| 130 |
+
B_bar: torch.Tensor,
|
| 131 |
+
horizon: int,
|
| 132 |
+
threshold: float,
|
| 133 |
+
track_singular_values: bool,
|
| 134 |
+
show_progress: bool,
|
| 135 |
+
) -> GramianResult:
|
| 136 |
+
"""
|
| 137 |
+
Efficient computation for diagonal A matrices.
|
| 138 |
+
|
| 139 |
+
For diagonal A with elements a_i:
|
| 140 |
+
W_R = Σ_k diag(a)^k B B^T diag(a)^k
|
| 141 |
+
= Σ_k diag(a^{2k}) ⊙ (B B^T) (for B as column vector)
|
| 142 |
+
|
| 143 |
+
This allows elementwise operations instead of matrix multiplication.
|
| 144 |
+
"""
|
| 145 |
+
# Flatten to diagonal
|
| 146 |
+
if A_bar.ndim == 2:
|
| 147 |
+
a = A_bar.flatten() # [d_inner * d_state]
|
| 148 |
+
else:
|
| 149 |
+
a = A_bar
|
| 150 |
+
|
| 151 |
+
n = len(a)
|
| 152 |
+
|
| 153 |
+
# Ensure B is column vector
|
| 154 |
+
if B_bar.ndim == 1:
|
| 155 |
+
B = B_bar.view(-1, 1)
|
| 156 |
+
else:
|
| 157 |
+
B = B_bar
|
| 158 |
+
|
| 159 |
+
# Adjust dimensions if needed
|
| 160 |
+
if B.shape[0] != n:
|
| 161 |
+
# Take first n elements or pad
|
| 162 |
+
if B.shape[0] > n:
|
| 163 |
+
B = B[:n]
|
| 164 |
+
else:
|
| 165 |
+
B = torch.nn.functional.pad(B, (0, 0, 0, n - B.shape[0]))
|
| 166 |
+
|
| 167 |
+
# B B^T outer product
|
| 168 |
+
BBT = B @ B.T # [n, n]
|
| 169 |
+
|
| 170 |
+
# Initialize accumulator
|
| 171 |
+
W_R = torch.zeros(n, n, device=self.device, dtype=self.dtype)
|
| 172 |
+
|
| 173 |
+
# Powers of a (elementwise)
|
| 174 |
+
a_power = torch.ones_like(a) # a^0 = 1
|
| 175 |
+
|
| 176 |
+
# Track singular values
|
| 177 |
+
singular_values = []
|
| 178 |
+
horizon_reached = None
|
| 179 |
+
|
| 180 |
+
iterator = range(horizon)
|
| 181 |
+
if show_progress:
|
| 182 |
+
iterator = tqdm(iterator, desc="Computing Gramian")
|
| 183 |
+
|
| 184 |
+
for k in iterator:
|
| 185 |
+
# W_R += diag(a_power) @ BBT @ diag(a_power)
|
| 186 |
+
# Which is: (a_power.unsqueeze(1) * a_power.unsqueeze(0)) * BBT
|
| 187 |
+
outer = a_power.unsqueeze(1) * a_power.unsqueeze(0)
|
| 188 |
+
W_R += outer * BBT
|
| 189 |
+
|
| 190 |
+
# Update power
|
| 191 |
+
a_power = a_power * a
|
| 192 |
+
|
| 193 |
+
# Track singular values
|
| 194 |
+
if track_singular_values:
|
| 195 |
+
try:
|
| 196 |
+
s = torch.linalg.svdvals(W_R)
|
| 197 |
+
singular_values.append(s.cpu().numpy())
|
| 198 |
+
|
| 199 |
+
# Check if horizon reached
|
| 200 |
+
if horizon_reached is None and s.min() < threshold:
|
| 201 |
+
horizon_reached = k
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.warning(f"SVD failed at step {k}: {e}")
|
| 204 |
+
|
| 205 |
+
# Final analysis
|
| 206 |
+
W_R_np = W_R.cpu().numpy()
|
| 207 |
+
|
| 208 |
+
try:
|
| 209 |
+
final_sv = np.linalg.svd(W_R_np, compute_uv=False)
|
| 210 |
+
rank = int(np.sum(final_sv > threshold))
|
| 211 |
+
min_sv = float(final_sv[-1]) if len(final_sv) > 0 else 0.0
|
| 212 |
+
except Exception:
|
| 213 |
+
final_sv = np.array([])
|
| 214 |
+
rank = 0
|
| 215 |
+
min_sv = 0.0
|
| 216 |
+
|
| 217 |
+
return GramianResult(
|
| 218 |
+
gramian=W_R_np,
|
| 219 |
+
singular_values=singular_values,
|
| 220 |
+
rank=rank,
|
| 221 |
+
min_singular_value=min_sv,
|
| 222 |
+
is_full_rank=(rank == n),
|
| 223 |
+
horizon_reached=horizon_reached,
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
def _compute_full(
|
| 227 |
+
self,
|
| 228 |
+
A_bar: torch.Tensor,
|
| 229 |
+
B_bar: torch.Tensor,
|
| 230 |
+
horizon: int,
|
| 231 |
+
threshold: float,
|
| 232 |
+
track_singular_values: bool,
|
| 233 |
+
show_progress: bool,
|
| 234 |
+
) -> GramianResult:
|
| 235 |
+
"""
|
| 236 |
+
Standard computation for full (non-diagonal) A matrices.
|
| 237 |
+
|
| 238 |
+
W_R = Σ_{k=0}^{T-1} A^k B B^T (A^T)^k
|
| 239 |
+
"""
|
| 240 |
+
n = A_bar.shape[0]
|
| 241 |
+
|
| 242 |
+
# Ensure B is proper shape
|
| 243 |
+
if B_bar.ndim == 1:
|
| 244 |
+
B = B_bar.view(-1, 1)
|
| 245 |
+
else:
|
| 246 |
+
B = B_bar
|
| 247 |
+
|
| 248 |
+
BBT = B @ B.T
|
| 249 |
+
|
| 250 |
+
# Initialize
|
| 251 |
+
W_R = torch.zeros(n, n, device=self.device, dtype=self.dtype)
|
| 252 |
+
A_power = torch.eye(n, device=self.device, dtype=self.dtype)
|
| 253 |
+
|
| 254 |
+
singular_values = []
|
| 255 |
+
horizon_reached = None
|
| 256 |
+
|
| 257 |
+
iterator = range(horizon)
|
| 258 |
+
if show_progress:
|
| 259 |
+
iterator = tqdm(iterator, desc="Computing Gramian")
|
| 260 |
+
|
| 261 |
+
for k in iterator:
|
| 262 |
+
# W_R += A^k B B^T (A^T)^k
|
| 263 |
+
term = A_power @ BBT @ A_power.T
|
| 264 |
+
W_R += term
|
| 265 |
+
|
| 266 |
+
# Update A power
|
| 267 |
+
A_power = A_bar @ A_power
|
| 268 |
+
|
| 269 |
+
# Track singular values
|
| 270 |
+
if track_singular_values:
|
| 271 |
+
try:
|
| 272 |
+
s = torch.linalg.svdvals(W_R)
|
| 273 |
+
singular_values.append(s.cpu().numpy())
|
| 274 |
+
|
| 275 |
+
if horizon_reached is None and s.min() < threshold:
|
| 276 |
+
horizon_reached = k
|
| 277 |
+
except Exception as e:
|
| 278 |
+
logger.warning(f"SVD failed at step {k}: {e}")
|
| 279 |
+
|
| 280 |
+
# Final analysis
|
| 281 |
+
W_R_np = W_R.cpu().numpy()
|
| 282 |
+
|
| 283 |
+
try:
|
| 284 |
+
final_sv = np.linalg.svd(W_R_np, compute_uv=False)
|
| 285 |
+
rank = int(np.sum(final_sv > threshold))
|
| 286 |
+
min_sv = float(final_sv[-1]) if len(final_sv) > 0 else 0.0
|
| 287 |
+
except Exception:
|
| 288 |
+
rank = 0
|
| 289 |
+
min_sv = 0.0
|
| 290 |
+
final_sv = np.array([])
|
| 291 |
+
|
| 292 |
+
return GramianResult(
|
| 293 |
+
gramian=W_R_np,
|
| 294 |
+
singular_values=singular_values,
|
| 295 |
+
rank=rank,
|
| 296 |
+
min_singular_value=min_sv,
|
| 297 |
+
is_full_rank=(rank == n),
|
| 298 |
+
horizon_reached=horizon_reached,
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
def compute_controllability_matrix(
|
| 302 |
+
self,
|
| 303 |
+
A_bar: torch.Tensor,
|
| 304 |
+
B_bar: torch.Tensor,
|
| 305 |
+
n_steps: Optional[int] = None,
|
| 306 |
+
) -> Tuple[np.ndarray, int]:
|
| 307 |
+
"""
|
| 308 |
+
Compute the controllability matrix C = [B, AB, A²B, ..., A^{n-1}B].
|
| 309 |
+
|
| 310 |
+
Alternative to Gramian for checking reachability.
|
| 311 |
+
|
| 312 |
+
Args:
|
| 313 |
+
A_bar: State transition matrix.
|
| 314 |
+
B_bar: Input matrix.
|
| 315 |
+
n_steps: Number of steps (defaults to state dimension).
|
| 316 |
+
|
| 317 |
+
Returns:
|
| 318 |
+
Tuple of (controllability matrix, numerical rank).
|
| 319 |
+
"""
|
| 320 |
+
A_bar = A_bar.to(device=self.device, dtype=self.dtype)
|
| 321 |
+
B_bar = B_bar.to(device=self.device, dtype=self.dtype)
|
| 322 |
+
|
| 323 |
+
n = A_bar.shape[0]
|
| 324 |
+
if n_steps is None:
|
| 325 |
+
n_steps = n
|
| 326 |
+
|
| 327 |
+
# Ensure B is 2D
|
| 328 |
+
if B_bar.ndim == 1:
|
| 329 |
+
B = B_bar.view(-1, 1)
|
| 330 |
+
else:
|
| 331 |
+
B = B_bar
|
| 332 |
+
|
| 333 |
+
m = B.shape[1]
|
| 334 |
+
|
| 335 |
+
# Build controllability matrix
|
| 336 |
+
C = torch.zeros(n, n_steps * m, device=self.device, dtype=self.dtype)
|
| 337 |
+
|
| 338 |
+
A_power = torch.eye(n, device=self.device, dtype=self.dtype)
|
| 339 |
+
for k in range(n_steps):
|
| 340 |
+
C[:, k*m:(k+1)*m] = A_power @ B
|
| 341 |
+
A_power = A_bar @ A_power
|
| 342 |
+
|
| 343 |
+
C_np = C.cpu().numpy()
|
| 344 |
+
|
| 345 |
+
# Compute rank
|
| 346 |
+
rank = int(np.linalg.matrix_rank(C_np))
|
| 347 |
+
|
| 348 |
+
return C_np, rank
|
| 349 |
+
|
| 350 |
+
def analyze_reachability(
|
| 351 |
+
self,
|
| 352 |
+
A_bar: torch.Tensor,
|
| 353 |
+
B_bar: torch.Tensor,
|
| 354 |
+
horizon: int = 100,
|
| 355 |
+
threshold: float = 1e-10,
|
| 356 |
+
) -> Dict[str, Any]:
|
| 357 |
+
"""
|
| 358 |
+
Comprehensive reachability analysis.
|
| 359 |
+
|
| 360 |
+
Args:
|
| 361 |
+
A_bar: Discretized transition matrix.
|
| 362 |
+
B_bar: Discretized input matrix.
|
| 363 |
+
horizon: Analysis horizon.
|
| 364 |
+
threshold: Numerical threshold.
|
| 365 |
+
|
| 366 |
+
Returns:
|
| 367 |
+
Dictionary with full analysis results.
|
| 368 |
+
"""
|
| 369 |
+
# Compute gramian
|
| 370 |
+
gramian_result = self.compute(
|
| 371 |
+
A_bar, B_bar, horizon, threshold,
|
| 372 |
+
track_singular_values=True, show_progress=False,
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
# Compute controllability matrix
|
| 376 |
+
C, ctrl_rank = self.compute_controllability_matrix(A_bar, B_bar)
|
| 377 |
+
|
| 378 |
+
# Get state dimension
|
| 379 |
+
if A_bar.ndim == 1:
|
| 380 |
+
n = len(A_bar)
|
| 381 |
+
elif A_bar.ndim == 2 and A_bar.shape[0] != A_bar.shape[1]:
|
| 382 |
+
n = A_bar.numel()
|
| 383 |
+
else:
|
| 384 |
+
n = A_bar.shape[0]
|
| 385 |
+
|
| 386 |
+
return {
|
| 387 |
+
"gramian_rank": gramian_result.rank,
|
| 388 |
+
"controllability_rank": ctrl_rank,
|
| 389 |
+
"state_dimension": n,
|
| 390 |
+
"is_fully_reachable": gramian_result.is_full_rank,
|
| 391 |
+
"min_singular_value": gramian_result.min_singular_value,
|
| 392 |
+
"horizon_reached": gramian_result.horizon_reached,
|
| 393 |
+
"rank_deficit": n - gramian_result.rank,
|
| 394 |
+
"gramian": gramian_result.gramian,
|
| 395 |
+
"singular_value_trajectory": gramian_result.singular_values,
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
def __repr__(self) -> str:
|
| 399 |
+
return f"ReachabilityGramian(device='{self.device}', dtype={self.dtype})"
|
spectral/horizon_predictor.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Horizon Predictor for Mamba SSM.
|
| 3 |
+
|
| 4 |
+
Predicts the mathematical limit of reasoning (Spectral Horizon) using
|
| 5 |
+
eigenvalue analysis and reachability gramian.
|
| 6 |
+
|
| 7 |
+
References:
|
| 8 |
+
[Gu2023] Spectral analysis of state space models.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
import torch.nn as nn
|
| 20 |
+
from tqdm import tqdm
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class HorizonResult:
|
| 27 |
+
"""
|
| 28 |
+
Result of horizon prediction.
|
| 29 |
+
|
| 30 |
+
Attributes:
|
| 31 |
+
horizon: Predicted reasoning horizon (number of tokens).
|
| 32 |
+
gramian_rank: Rank of reachability gramian at horizon.
|
| 33 |
+
min_singular_value: Smallest singular value at horizon.
|
| 34 |
+
spectral_radius: Spectral radius of Ā.
|
| 35 |
+
is_reachable: Whether full state is reachable.
|
| 36 |
+
confidence: Confidence score (0-1).
|
| 37 |
+
method: Method used for prediction.
|
| 38 |
+
"""
|
| 39 |
+
horizon: int
|
| 40 |
+
gramian_rank: int
|
| 41 |
+
min_singular_value: float
|
| 42 |
+
spectral_radius: float
|
| 43 |
+
is_reachable: bool
|
| 44 |
+
confidence: float = 1.0
|
| 45 |
+
method: str = "gramian"
|
| 46 |
+
|
| 47 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 48 |
+
"""Convert to dictionary."""
|
| 49 |
+
return {
|
| 50 |
+
"horizon": self.horizon,
|
| 51 |
+
"gramian_rank": self.gramian_rank,
|
| 52 |
+
"min_singular_value": self.min_singular_value,
|
| 53 |
+
"spectral_radius": self.spectral_radius,
|
| 54 |
+
"is_reachable": self.is_reachable,
|
| 55 |
+
"confidence": self.confidence,
|
| 56 |
+
"method": self.method,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class HorizonPredictor:
|
| 61 |
+
"""
|
| 62 |
+
Predicts the Spectral Horizon - the mathematical limit of reasoning.
|
| 63 |
+
|
| 64 |
+
The Spectral Horizon is determined by when the reachability gramian
|
| 65 |
+
becomes singular, indicating that the model can no longer access
|
| 66 |
+
information from earlier in the sequence.
|
| 67 |
+
|
| 68 |
+
Theory:
|
| 69 |
+
- W_R(T) = Σ_{k=0}^{T-1} Ā^k · B̄ · B̄^T · (Ā^T)^k
|
| 70 |
+
- When σ_min(W_R) < ε, the horizon H = T is reached
|
| 71 |
+
- Beyond H, reasoning about earlier context becomes unreliable
|
| 72 |
+
|
| 73 |
+
Example:
|
| 74 |
+
>>> predictor = HorizonPredictor(model)
|
| 75 |
+
>>> result = predictor.predict_horizon(
|
| 76 |
+
... "The capital of France is",
|
| 77 |
+
... max_horizon=1000,
|
| 78 |
+
... )
|
| 79 |
+
>>> print(f"Predicted horizon: {result.horizon} tokens")
|
| 80 |
+
>>> print(f"Is fully reachable: {result.is_reachable}")
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
def __init__(
|
| 84 |
+
self,
|
| 85 |
+
model: Union["MambaWrapper", nn.Module],
|
| 86 |
+
device: str = "cuda",
|
| 87 |
+
) -> None:
|
| 88 |
+
"""
|
| 89 |
+
Initialize the horizon predictor.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
model: Mamba model (MambaWrapper or raw model).
|
| 93 |
+
device: Computation device.
|
| 94 |
+
"""
|
| 95 |
+
from mamba_spectral.core.mamba_wrapper import MambaWrapper
|
| 96 |
+
from mamba_spectral.core.state_extractor import StateExtractor
|
| 97 |
+
from mamba_spectral.spectral.eigenvalue_analyzer import SpectralAnalyzer
|
| 98 |
+
from mamba_spectral.spectral.gramian import ReachabilityGramian
|
| 99 |
+
|
| 100 |
+
if isinstance(model, MambaWrapper):
|
| 101 |
+
self.wrapper = model
|
| 102 |
+
else:
|
| 103 |
+
self.wrapper = MambaWrapper(model, device=device)
|
| 104 |
+
|
| 105 |
+
self.extractor = StateExtractor(self.wrapper)
|
| 106 |
+
self.analyzer = SpectralAnalyzer(self.wrapper, device=device)
|
| 107 |
+
self.gramian_calc = ReachabilityGramian(device=device)
|
| 108 |
+
self.device = device
|
| 109 |
+
|
| 110 |
+
logger.info(f"HorizonPredictor initialized on {device}")
|
| 111 |
+
|
| 112 |
+
def predict_horizon(
|
| 113 |
+
self,
|
| 114 |
+
prompt: Union[str, torch.Tensor],
|
| 115 |
+
layer_idx: int = 0,
|
| 116 |
+
threshold: float = 1e-6,
|
| 117 |
+
max_horizon: int = 1000,
|
| 118 |
+
delta_value: float = 0.01,
|
| 119 |
+
method: str = "gramian",
|
| 120 |
+
) -> HorizonResult:
|
| 121 |
+
"""
|
| 122 |
+
Predict the reasoning horizon for a given prompt.
|
| 123 |
+
|
| 124 |
+
Computes the Spectral Horizon by analyzing when the reachability
|
| 125 |
+
gramian becomes singular (σ_min < threshold).
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
prompt: Input text or token IDs.
|
| 129 |
+
layer_idx: Which Mamba layer to analyze.
|
| 130 |
+
threshold: Singular value threshold for horizon detection.
|
| 131 |
+
max_horizon: Maximum horizon to search.
|
| 132 |
+
delta_value: Discretization step.
|
| 133 |
+
method: Prediction method:
|
| 134 |
+
- 'gramian': Use reachability gramian (most accurate)
|
| 135 |
+
- 'spectral': Use spectral radius decay (faster)
|
| 136 |
+
- 'hybrid': Combine both methods
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
HorizonResult with predicted horizon and analysis.
|
| 140 |
+
|
| 141 |
+
Example:
|
| 142 |
+
>>> result = predictor.predict_horizon(
|
| 143 |
+
... "The key is APPLE. [distraction] Query: key was?",
|
| 144 |
+
... threshold=1e-6,
|
| 145 |
+
... )
|
| 146 |
+
>>> if result.horizon < 100:
|
| 147 |
+
... print("Warning: Limited reasoning capacity!")
|
| 148 |
+
"""
|
| 149 |
+
# Get SSM matrices
|
| 150 |
+
A = self.extractor.extract_A_matrix(layer_idx)
|
| 151 |
+
delta = torch.tensor(delta_value, device=self.device)
|
| 152 |
+
A_bar = self.extractor.discretize(A, delta)
|
| 153 |
+
|
| 154 |
+
# Create synthetic B (identity-like for analysis)
|
| 155 |
+
d_state = A.shape[-1]
|
| 156 |
+
B_bar = torch.eye(d_state, device=self.device, dtype=A_bar.dtype)[:, :min(d_state, 10)]
|
| 157 |
+
|
| 158 |
+
# Compute spectral radius
|
| 159 |
+
eigenvalues = self.analyzer.compute_eigenvalues(A_bar)
|
| 160 |
+
spectral_radius = self.analyzer.spectral_radius(eigenvalues)
|
| 161 |
+
|
| 162 |
+
if method == "gramian":
|
| 163 |
+
return self._predict_via_gramian(
|
| 164 |
+
A_bar, B_bar, threshold, max_horizon, spectral_radius
|
| 165 |
+
)
|
| 166 |
+
elif method == "spectral":
|
| 167 |
+
return self._predict_via_spectral(
|
| 168 |
+
A_bar, spectral_radius, threshold, max_horizon
|
| 169 |
+
)
|
| 170 |
+
elif method == "hybrid":
|
| 171 |
+
return self._predict_hybrid(
|
| 172 |
+
A_bar, B_bar, spectral_radius, threshold, max_horizon
|
| 173 |
+
)
|
| 174 |
+
else:
|
| 175 |
+
raise ValueError(f"Unknown method: {method}")
|
| 176 |
+
|
| 177 |
+
def _predict_via_gramian(
|
| 178 |
+
self,
|
| 179 |
+
A_bar: torch.Tensor,
|
| 180 |
+
B_bar: torch.Tensor,
|
| 181 |
+
threshold: float,
|
| 182 |
+
max_horizon: int,
|
| 183 |
+
spectral_radius: float,
|
| 184 |
+
) -> HorizonResult:
|
| 185 |
+
"""Predict horizon using reachability gramian."""
|
| 186 |
+
result = self.gramian_calc.compute(
|
| 187 |
+
A_bar, B_bar,
|
| 188 |
+
horizon=max_horizon,
|
| 189 |
+
threshold=threshold,
|
| 190 |
+
track_singular_values=True,
|
| 191 |
+
show_progress=False,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
if result.horizon_reached is not None:
|
| 195 |
+
horizon = result.horizon_reached
|
| 196 |
+
confidence = 0.95
|
| 197 |
+
else:
|
| 198 |
+
# Horizon not reached within max_horizon
|
| 199 |
+
horizon = max_horizon
|
| 200 |
+
confidence = 0.5
|
| 201 |
+
|
| 202 |
+
return HorizonResult(
|
| 203 |
+
horizon=horizon,
|
| 204 |
+
gramian_rank=result.rank,
|
| 205 |
+
min_singular_value=result.min_singular_value,
|
| 206 |
+
spectral_radius=spectral_radius,
|
| 207 |
+
is_reachable=result.is_full_rank,
|
| 208 |
+
confidence=confidence,
|
| 209 |
+
method="gramian",
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
def _predict_via_spectral(
|
| 213 |
+
self,
|
| 214 |
+
A_bar: torch.Tensor,
|
| 215 |
+
spectral_radius: float,
|
| 216 |
+
threshold: float,
|
| 217 |
+
max_horizon: int,
|
| 218 |
+
) -> HorizonResult:
|
| 219 |
+
"""
|
| 220 |
+
Predict horizon using spectral radius decay.
|
| 221 |
+
|
| 222 |
+
For ρ(Ā) < 1, memory decays as ρ^k.
|
| 223 |
+
Horizon ≈ log(threshold) / log(ρ)
|
| 224 |
+
"""
|
| 225 |
+
if spectral_radius >= 1.0:
|
| 226 |
+
# Marginally stable or unstable - infinite theoretical horizon
|
| 227 |
+
horizon = max_horizon
|
| 228 |
+
confidence = 0.3 # Low confidence for edge case
|
| 229 |
+
else:
|
| 230 |
+
# Decay estimate: ρ^H < threshold
|
| 231 |
+
# H = log(threshold) / log(ρ)
|
| 232 |
+
try:
|
| 233 |
+
horizon = int(np.log(threshold) / np.log(spectral_radius))
|
| 234 |
+
horizon = min(horizon, max_horizon)
|
| 235 |
+
horizon = max(horizon, 1)
|
| 236 |
+
confidence = 0.7
|
| 237 |
+
except (ValueError, ZeroDivisionError):
|
| 238 |
+
horizon = max_horizon
|
| 239 |
+
confidence = 0.2
|
| 240 |
+
|
| 241 |
+
return HorizonResult(
|
| 242 |
+
horizon=horizon,
|
| 243 |
+
gramian_rank=-1, # Not computed
|
| 244 |
+
min_singular_value=-1.0,
|
| 245 |
+
spectral_radius=spectral_radius,
|
| 246 |
+
is_reachable=(spectral_radius >= 0.9),
|
| 247 |
+
confidence=confidence,
|
| 248 |
+
method="spectral",
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
def _predict_hybrid(
|
| 252 |
+
self,
|
| 253 |
+
A_bar: torch.Tensor,
|
| 254 |
+
B_bar: torch.Tensor,
|
| 255 |
+
spectral_radius: float,
|
| 256 |
+
threshold: float,
|
| 257 |
+
max_horizon: int,
|
| 258 |
+
) -> HorizonResult:
|
| 259 |
+
"""Combine gramian and spectral methods."""
|
| 260 |
+
# First, quick spectral estimate
|
| 261 |
+
spectral_result = self._predict_via_spectral(
|
| 262 |
+
A_bar, spectral_radius, threshold, max_horizon
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# If spectral suggests short horizon, verify with gramian
|
| 266 |
+
if spectral_result.horizon < max_horizon // 2:
|
| 267 |
+
search_horizon = min(spectral_result.horizon * 2, max_horizon)
|
| 268 |
+
gramian_result = self._predict_via_gramian(
|
| 269 |
+
A_bar, B_bar, threshold, search_horizon, spectral_radius
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# Use gramian result if confident
|
| 273 |
+
if gramian_result.confidence > spectral_result.confidence:
|
| 274 |
+
gramian_result.method = "hybrid"
|
| 275 |
+
return gramian_result
|
| 276 |
+
|
| 277 |
+
spectral_result.method = "hybrid"
|
| 278 |
+
return spectral_result
|
| 279 |
+
|
| 280 |
+
def compute_reachability_gramian(
|
| 281 |
+
self,
|
| 282 |
+
A_bar: torch.Tensor,
|
| 283 |
+
B_bar: torch.Tensor,
|
| 284 |
+
horizon: int = 100,
|
| 285 |
+
) -> Tuple[np.ndarray, List[np.ndarray]]:
|
| 286 |
+
"""
|
| 287 |
+
Compute the Reachability Gramian.
|
| 288 |
+
|
| 289 |
+
W_R(T) = Σ_{k=0}^{T-1} Ā^k · B̄ · B̄^T · (Ā^T)^k
|
| 290 |
+
|
| 291 |
+
Args:
|
| 292 |
+
A_bar: Discretized transition matrix.
|
| 293 |
+
B_bar: Discretized input matrix.
|
| 294 |
+
horizon: Number of steps.
|
| 295 |
+
|
| 296 |
+
Returns:
|
| 297 |
+
Tuple of (gramian matrix, singular values at each step).
|
| 298 |
+
"""
|
| 299 |
+
result = self.gramian_calc.compute(
|
| 300 |
+
A_bar, B_bar, horizon,
|
| 301 |
+
track_singular_values=True,
|
| 302 |
+
)
|
| 303 |
+
return result.gramian, result.singular_values
|
| 304 |
+
|
| 305 |
+
@torch.no_grad()
|
| 306 |
+
def adversarial_cot_generator(
|
| 307 |
+
self,
|
| 308 |
+
base_prompt: str,
|
| 309 |
+
target_horizon: int = 50,
|
| 310 |
+
n_candidates: int = 10,
|
| 311 |
+
layer_idx: int = 0,
|
| 312 |
+
) -> Dict[str, Any]:
|
| 313 |
+
"""
|
| 314 |
+
Generate Chain-of-Thought that DEGRADES reasoning capacity.
|
| 315 |
+
|
| 316 |
+
This demonstrates that CoT can be adversarial by finding
|
| 317 |
+
token sequences that minimize the spectral radius.
|
| 318 |
+
|
| 319 |
+
The idea:
|
| 320 |
+
- Normal CoT keeps ρ(Ā) high (preserves memory)
|
| 321 |
+
- Adversarial CoT forces ρ(Ā) → 0 (causes forgetting)
|
| 322 |
+
|
| 323 |
+
Note:
|
| 324 |
+
Full gradient-based optimization is complex.
|
| 325 |
+
This PoC uses heuristic search over candidate tokens.
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
base_prompt: Starting prompt.
|
| 329 |
+
target_horizon: Target (reduced) horizon.
|
| 330 |
+
n_candidates: Number of candidates to try per position.
|
| 331 |
+
layer_idx: Layer to analyze.
|
| 332 |
+
|
| 333 |
+
Returns:
|
| 334 |
+
Dictionary with:
|
| 335 |
+
- adversarial_prompt: Generated adversarial CoT
|
| 336 |
+
- spectral_trajectory: ρ values over tokens
|
| 337 |
+
- original_radius: ρ of original prompt
|
| 338 |
+
- final_radius: ρ after adversarial tokens
|
| 339 |
+
"""
|
| 340 |
+
if self.wrapper.tokenizer is None:
|
| 341 |
+
raise ValueError("Tokenizer required for CoT generation")
|
| 342 |
+
|
| 343 |
+
# Encode base prompt
|
| 344 |
+
base_ids = self.wrapper.tokenizer.encode(base_prompt, return_tensors="pt")
|
| 345 |
+
base_ids = base_ids.to(self.device)
|
| 346 |
+
|
| 347 |
+
# Track original spectral radius
|
| 348 |
+
trajectory_orig = self.analyzer.track_evolution(base_ids, layer_idx)
|
| 349 |
+
original_radius = trajectory_orig.spectral_radius[-1]
|
| 350 |
+
|
| 351 |
+
# Heuristic: Find tokens that tend to reduce spectral radius
|
| 352 |
+
# These are typically: periods, padding, special characters
|
| 353 |
+
adversarial_tokens = []
|
| 354 |
+
|
| 355 |
+
# Common "forgetting" tokens (heuristic)
|
| 356 |
+
forget_tokens = [
|
| 357 |
+
"...", " ", "\n\n", "---", "___",
|
| 358 |
+
"[END]", "<pad>", "~", "...", " ",
|
| 359 |
+
]
|
| 360 |
+
|
| 361 |
+
forget_ids = []
|
| 362 |
+
for token in forget_tokens:
|
| 363 |
+
try:
|
| 364 |
+
ids = self.wrapper.tokenizer.encode(token, add_special_tokens=False)
|
| 365 |
+
forget_ids.extend(ids)
|
| 366 |
+
except Exception:
|
| 367 |
+
pass
|
| 368 |
+
|
| 369 |
+
# Remove duplicates
|
| 370 |
+
forget_ids = list(set(forget_ids))[:n_candidates]
|
| 371 |
+
|
| 372 |
+
# Build adversarial sequence
|
| 373 |
+
current_ids = base_ids.clone()
|
| 374 |
+
spectral_trajectory = [original_radius]
|
| 375 |
+
|
| 376 |
+
for step in range(min(target_horizon, 20)): # Limit steps for PoC
|
| 377 |
+
best_token = None
|
| 378 |
+
lowest_radius = float('inf')
|
| 379 |
+
|
| 380 |
+
for token_id in forget_ids:
|
| 381 |
+
# Try appending this token
|
| 382 |
+
test_ids = torch.cat([
|
| 383 |
+
current_ids,
|
| 384 |
+
torch.tensor([[token_id]], device=self.device)
|
| 385 |
+
], dim=1)
|
| 386 |
+
|
| 387 |
+
# Measure spectral radius
|
| 388 |
+
try:
|
| 389 |
+
A = self.extractor.extract_A_matrix(layer_idx)
|
| 390 |
+
A_bar = self.extractor.discretize(A, torch.tensor(0.01))
|
| 391 |
+
eigenvalues = self.analyzer.compute_eigenvalues(A_bar)
|
| 392 |
+
radius = self.analyzer.spectral_radius(eigenvalues)
|
| 393 |
+
|
| 394 |
+
if radius < lowest_radius:
|
| 395 |
+
lowest_radius = radius
|
| 396 |
+
best_token = token_id
|
| 397 |
+
except Exception:
|
| 398 |
+
continue
|
| 399 |
+
|
| 400 |
+
if best_token is not None:
|
| 401 |
+
current_ids = torch.cat([
|
| 402 |
+
current_ids,
|
| 403 |
+
torch.tensor([[best_token]], device=self.device)
|
| 404 |
+
], dim=1)
|
| 405 |
+
adversarial_tokens.append(best_token)
|
| 406 |
+
spectral_trajectory.append(lowest_radius)
|
| 407 |
+
|
| 408 |
+
# Decode result
|
| 409 |
+
adversarial_prompt = self.wrapper.tokenizer.decode(current_ids[0])
|
| 410 |
+
|
| 411 |
+
return {
|
| 412 |
+
"adversarial_prompt": adversarial_prompt,
|
| 413 |
+
"spectral_trajectory": spectral_trajectory,
|
| 414 |
+
"original_radius": original_radius,
|
| 415 |
+
"final_radius": spectral_trajectory[-1] if spectral_trajectory else original_radius,
|
| 416 |
+
"tokens_added": len(adversarial_tokens),
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
def analyze_prompt_horizon(
|
| 420 |
+
self,
|
| 421 |
+
prompt: str,
|
| 422 |
+
layer_indices: Optional[List[int]] = None,
|
| 423 |
+
) -> Dict[int, HorizonResult]:
|
| 424 |
+
"""
|
| 425 |
+
Analyze horizon across multiple layers.
|
| 426 |
+
|
| 427 |
+
Args:
|
| 428 |
+
prompt: Input text.
|
| 429 |
+
layer_indices: Layers to analyze (None = all).
|
| 430 |
+
|
| 431 |
+
Returns:
|
| 432 |
+
Dictionary mapping layer index to HorizonResult.
|
| 433 |
+
"""
|
| 434 |
+
layers = self.wrapper.get_mamba_layers()
|
| 435 |
+
if layer_indices is None:
|
| 436 |
+
layer_indices = list(range(len(layers)))
|
| 437 |
+
|
| 438 |
+
results = {}
|
| 439 |
+
for idx in layer_indices:
|
| 440 |
+
try:
|
| 441 |
+
result = self.predict_horizon(prompt, layer_idx=idx)
|
| 442 |
+
results[idx] = result
|
| 443 |
+
except Exception as e:
|
| 444 |
+
logger.warning(f"Could not analyze layer {idx}: {e}")
|
| 445 |
+
|
| 446 |
+
return results
|
| 447 |
+
|
| 448 |
+
def __repr__(self) -> str:
|
| 449 |
+
return (
|
| 450 |
+
f"HorizonPredictor(\n"
|
| 451 |
+
f" model='{self.wrapper.model_name}',\n"
|
| 452 |
+
f" device='{self.device}'\n"
|
| 453 |
+
f")"
|
| 454 |
+
)
|