Spaces:
Paused
Paused
| """ | |
| ╔══════════════════════════════════════════════════════════════════════════════╗ | |
| ║ QUASAR PLASTICITY PRESERVATION MODULE ║ | |
| ║ Based on: Sokar et al. 2023, Haarnoja et al. 2018, Dohare et al. 2024 ║ | |
| ╚══════════════════════════════════════════════════════════════════════════════╝ | |
| This module implements research-backed solutions to feature collapse and | |
| plasticity loss in deep reinforcement learning. | |
| REFERENCES: | |
| ----------- | |
| [1] Sokar, G. et al. (2023). "The Dormant Neuron Phenomenon in Deep RL" - ICML | |
| [2] Haarnoja, T. et al. (2018). "Soft Actor-Critic Algorithms and Applications" | |
| [3] Dohare, S. et al. (2024). "Loss of plasticity in deep continual learning" - Nature | |
| [4] Lyle, C. et al. (2023). "Understanding Plasticity in Neural Networks" - ICML | |
| [5] Nikishin, E. et al. (2023). "Deep RL with Plasticity Injection" | |
| ROOT CAUSES ADDRESSED: | |
| ---------------------- | |
| 1. Dormant Neuron Phenomenon: Neurons become inactive, reducing network capacity | |
| 2. Feature Rank Collapse: Internal representations lose diversity | |
| 3. Fixed Entropy: Static exploration coefficient fails to adapt | |
| 4. Plasticity Loss: Network loses ability to fit new data over time | |
| INTEGRATION: | |
| ------------ | |
| Add to your quasar_main4.py: | |
| from quasar_plasticity_module import PlasticityPreserver, AutoEntropyTuner, ReDo | |
| # In your training loop: | |
| plasticity = PlasticityPreserver(model, device) | |
| entropy_tuner = AutoEntropyTuner(action_dim=2, device=device) | |
| redo = ReDo(model, tau=0.1, reset_interval=1000) | |
| """ | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| import numpy as np | |
| from typing import Dict, List, Optional, Tuple | |
| from collections import deque | |
| import math | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # 1. ReDo: RECYCLING DORMANT NEURONS (Sokar et al., ICML 2023) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # | |
| # Key insight: Neurons become dormant (activations → 0) during RL training, | |
| # reducing network expressivity. Solution: detect and recycle them. | |
| # | |
| # Dormancy score: s_i = E[|h_i(x)|] / (1/H * Σ E[|h_k(x)|]) | |
| # If s_i < τ, neuron i is τ-dormant | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| class ReDo(nn.Module): | |
| """ | |
| Recycling Dormant Neurons (ReDo) - Sokar et al., ICML 2023 | |
| Periodically detects dormant neurons and reinitializes them: | |
| - Incoming weights: reinitialized from original distribution | |
| - Outgoing weights: zeroed (preserves network output initially) | |
| - Adam moments: reset for reinitialized neurons (critical!) | |
| Args: | |
| model: The neural network to monitor | |
| tau: Dormancy threshold (default 0.1, neurons with score < τ are dormant) | |
| reset_interval: Steps between dormancy checks | |
| device: torch device | |
| """ | |
| def __init__(self, model: nn.Module, tau: float = 0.1, | |
| reset_interval: int = 1000, device=None): | |
| super().__init__() | |
| self.model = model | |
| self.tau = tau | |
| self.reset_interval = reset_interval | |
| self.device = device or torch.device('cuda' if torch.cuda.is_available() else 'cpu') | |
| # Track activations for dormancy calculation | |
| self.activation_sums: Dict[str, torch.Tensor] = {} | |
| self.activation_counts: Dict[str, int] = {} | |
| self.hooks = [] | |
| self.step_count = 0 | |
| # Statistics | |
| self.dormant_history = deque(maxlen=100) | |
| self.recycled_total = 0 | |
| self._register_hooks() | |
| def _register_hooks(self): | |
| """Register forward hooks to track activations""" | |
| for name, module in self.model.named_modules(): | |
| if isinstance(module, (nn.Linear, nn.Conv2d)): | |
| hook = module.register_forward_hook( | |
| lambda m, inp, out, n=name: self._activation_hook(n, out) | |
| ) | |
| self.hooks.append(hook) | |
| def _activation_hook(self, name: str, output: torch.Tensor): | |
| """Accumulate activation statistics""" | |
| with torch.no_grad(): | |
| try: | |
| # Handle different tensor dimensions safely | |
| if output.dim() == 4: | |
| # Conv: (batch, channels, H, W) -> mean over batch, H, W | |
| act = output.abs().mean(dim=(0, 2, 3)) | |
| elif output.dim() == 3: | |
| # 3D: (batch, seq, features) -> mean over batch, seq | |
| act = output.abs().mean(dim=(0, 1)) | |
| elif output.dim() == 2: | |
| # Linear: (batch, features) -> mean over batch | |
| act = output.abs().mean(dim=0) | |
| elif output.dim() == 1: | |
| # 1D: just use as-is | |
| act = output.abs() | |
| else: | |
| return # Skip unsupported dimensions | |
| if name not in self.activation_sums: | |
| self.activation_sums[name] = torch.zeros_like(act) | |
| self.activation_counts[name] = 0 | |
| self.activation_sums[name] += act | |
| self.activation_counts[name] += 1 | |
| except Exception: | |
| pass # Skip on any error - don't crash the forward pass | |
| def compute_dormancy_scores(self) -> Dict[str, torch.Tensor]: | |
| """ | |
| Compute dormancy score for each neuron in each layer. | |
| Score = neuron_activation / layer_mean_activation | |
| Lower score = more dormant | |
| """ | |
| scores = {} | |
| for name, act_sum in self.activation_sums.items(): | |
| count = self.activation_counts[name] | |
| if count > 0: | |
| mean_act = act_sum / count | |
| layer_mean = mean_act.mean() + 1e-8 | |
| scores[name] = mean_act / layer_mean | |
| return scores | |
| def get_dormant_neurons(self) -> Dict[str, torch.Tensor]: | |
| """Get indices of dormant neurons (score < tau) per layer""" | |
| scores = self.compute_dormancy_scores() | |
| dormant = {} | |
| for name, score in scores.items(): | |
| dormant_mask = score < self.tau | |
| if dormant_mask.any(): | |
| dormant[name] = torch.where(dormant_mask)[0] | |
| return dormant | |
| def recycle_dormant_neurons(self, optimizer: torch.optim.Optimizer) -> int: | |
| """ | |
| Recycle (reinitialize) dormant neurons. | |
| Critical: Must also reset Adam momentum for recycled neurons! | |
| Returns: | |
| Number of neurons recycled | |
| """ | |
| dormant = self.get_dormant_neurons() | |
| total_recycled = 0 | |
| for name, module in self.model.named_modules(): | |
| if name not in dormant: | |
| continue | |
| dormant_indices = dormant[name] | |
| n_dormant = len(dormant_indices) | |
| if n_dormant == 0: | |
| continue | |
| if isinstance(module, nn.Linear): | |
| # Reinitialize incoming weights (rows) | |
| with torch.no_grad(): | |
| # Xavier/Kaiming initialization for incoming | |
| fan_in = module.weight.size(1) | |
| std = 1.0 / math.sqrt(fan_in) | |
| module.weight[dormant_indices].normal_(0, std) | |
| if module.bias is not None: | |
| module.bias[dormant_indices].zero_() | |
| # Zero outgoing weights (in next layer) - find connected layer | |
| # This preserves network output initially | |
| self._zero_outgoing_weights(name, dormant_indices) | |
| # Reset Adam moments for these parameters | |
| self._reset_optimizer_state(optimizer, module, dormant_indices) | |
| total_recycled += n_dormant | |
| # Reset activation tracking | |
| self.activation_sums.clear() | |
| self.activation_counts.clear() | |
| self.recycled_total += total_recycled | |
| self.dormant_history.append(total_recycled) | |
| return total_recycled | |
| def _zero_outgoing_weights(self, layer_name: str, indices: torch.Tensor): | |
| """Zero the outgoing weights from recycled neurons in the next layer""" | |
| found_current = False | |
| for name, module in self.model.named_modules(): | |
| if name == layer_name: | |
| found_current = True | |
| continue | |
| if found_current and isinstance(module, nn.Linear): | |
| # This is the next linear layer | |
| with torch.no_grad(): | |
| module.weight[:, indices] = 0 | |
| break | |
| def _reset_optimizer_state(self, optimizer: torch.optim.Optimizer, | |
| module: nn.Module, indices: torch.Tensor): | |
| """ | |
| Reset Adam momentum/variance for recycled neurons. | |
| CRITICAL: Without this, Adam will immediately push recycled neurons | |
| back into dormancy using old momentum! | |
| """ | |
| for param in [module.weight, module.bias]: | |
| if param is None: | |
| continue | |
| for group in optimizer.param_groups: | |
| if param in [p for p in group['params']]: | |
| state = optimizer.state.get(param, {}) | |
| if 'exp_avg' in state: | |
| state['exp_avg'][indices] = 0 | |
| if 'exp_avg_sq' in state: | |
| state['exp_avg_sq'][indices] = 0 | |
| def step(self, optimizer: torch.optim.Optimizer) -> Optional[int]: | |
| """ | |
| Call this after each training step. | |
| Returns number of recycled neurons if recycling occurred. | |
| """ | |
| self.step_count += 1 | |
| if self.step_count % self.reset_interval == 0: | |
| n_recycled = self.recycle_dormant_neurons(optimizer) | |
| if n_recycled > 0: | |
| print(f"🔄 ReDo: Recycled {n_recycled} dormant neurons " | |
| f"(total: {self.recycled_total})") | |
| return n_recycled | |
| return None | |
| def get_dormancy_ratio(self) -> float: | |
| """Get fraction of neurons that are currently dormant""" | |
| dormant = self.get_dormant_neurons() | |
| total_dormant = sum(len(d) for d in dormant.values()) | |
| total_neurons = sum( | |
| m.weight.size(0) for m in self.model.modules() | |
| if isinstance(m, nn.Linear) | |
| ) | |
| return total_dormant / max(total_neurons, 1) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # 2. AUTOMATIC ENTROPY TUNING (Haarnoja et al., 2018 - SAC) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # | |
| # Key insight: Fixed entropy coefficient fails because optimal entropy varies | |
| # across states and training phases. Solution: constrained optimization. | |
| # | |
| # Instead of: max_π E[r + α*H(π)] with fixed α | |
| # We solve: max_π E[r] subject to E[H(π)] ≥ H_target | |
| # | |
| # This naturally increases α when policy is too deterministic, | |
| # and decreases α when policy is exploring enough. | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| class AutoEntropyTuner(nn.Module): | |
| """ | |
| Automatic Entropy Temperature Tuning from SAC (Haarnoja et al., 2018) | |
| Learns the entropy coefficient α to maintain target entropy level. | |
| α_loss = -α * (log π(a|s) + H_target) | |
| When entropy < target: α increases → more exploration reward | |
| When entropy > target: α decreases → focus on task reward | |
| Args: | |
| action_dim: Number of actions (for discrete) or action dimensions | |
| target_entropy: Target entropy level (default: -action_dim for continuous, | |
| 0.5 * log(action_dim) for discrete) | |
| initial_alpha: Starting value for α | |
| lr: Learning rate for α optimization | |
| device: torch device | |
| """ | |
| def __init__(self, action_dim: int, target_entropy: float = None, | |
| initial_alpha: float = 0.2, lr: float = 3e-4, | |
| device=None, discrete: bool = True): | |
| super().__init__() | |
| self.device = device or torch.device('cuda' if torch.cuda.is_available() else 'cpu') | |
| self.discrete = discrete | |
| # Target entropy: for discrete actions, use fraction of max entropy | |
| if target_entropy is None: | |
| if discrete: | |
| # Target = 50% of maximum entropy (log(action_dim)) | |
| self.target_entropy = 0.5 * np.log(action_dim) | |
| else: | |
| # For continuous: -action_dim (heuristic from SAC paper) | |
| self.target_entropy = -action_dim | |
| else: | |
| self.target_entropy = target_entropy | |
| # Learnable log(α) - optimize in log space for stability | |
| self.log_alpha = nn.Parameter( | |
| torch.tensor(np.log(initial_alpha), dtype=torch.float32, device=self.device) | |
| ) | |
| self.optimizer = torch.optim.Adam([self.log_alpha], lr=lr) | |
| # Statistics | |
| self.alpha_history = deque(maxlen=1000) | |
| self.entropy_history = deque(maxlen=1000) | |
| def alpha(self) -> torch.Tensor: | |
| """Current entropy coefficient""" | |
| return self.log_alpha.exp() | |
| def compute_entropy(self, action_probs: torch.Tensor) -> torch.Tensor: | |
| """ | |
| Compute entropy of action distribution. | |
| For discrete: H = -Σ p(a) log p(a) | |
| """ | |
| # Clamp for numerical stability | |
| probs = torch.clamp(action_probs, min=1e-8, max=1.0) | |
| log_probs = torch.log(probs) | |
| entropy = -(probs * log_probs).sum(dim=-1) | |
| return entropy | |
| def update(self, action_probs: torch.Tensor) -> Tuple[float, float]: | |
| """ | |
| Update α based on current policy entropy. | |
| Args: | |
| action_probs: (batch, action_dim) action probabilities | |
| Returns: | |
| (alpha_loss, current_alpha) | |
| """ | |
| with torch.no_grad(): | |
| entropy = self.compute_entropy(action_probs).mean() | |
| self.entropy_history.append(entropy.item()) | |
| # α loss: increase α when entropy is below target | |
| # L(α) = E[-α * (log π(a|s) + H_target)] | |
| # = α * (H_target - H(π)) [since -log π ≈ H for the sampled action] | |
| alpha_loss = self.alpha * (self.target_entropy - entropy.detach()) | |
| self.optimizer.zero_grad() | |
| alpha_loss.backward() | |
| self.optimizer.step() | |
| current_alpha = self.alpha.item() | |
| self.alpha_history.append(current_alpha) | |
| return alpha_loss.item(), current_alpha | |
| def get_entropy_bonus(self, action_probs: torch.Tensor) -> torch.Tensor: | |
| """ | |
| Compute entropy bonus for actor loss. | |
| actor_loss = -Q(s,a) - α * H(π(·|s)) | |
| """ | |
| entropy = self.compute_entropy(action_probs) | |
| return self.alpha.detach() * entropy | |
| def get_stats(self) -> Dict[str, float]: | |
| """Get current statistics""" | |
| return { | |
| 'alpha': self.alpha.item(), | |
| 'target_entropy': self.target_entropy, | |
| 'mean_entropy': np.mean(self.entropy_history) if self.entropy_history else 0, | |
| 'entropy_gap': np.mean(self.entropy_history) - self.target_entropy if self.entropy_history else 0 | |
| } | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # 3. CONTINUAL BACKPROPAGATION (Dohare et al., Nature 2024) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # | |
| # Key insight: Standard networks lose plasticity over time. Solution: | |
| # reinitialize a tiny proportion of least-used units on EACH step. | |
| # | |
| # This is like neurogenesis - continuously injecting fresh capacity. | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| class ContinualBackprop: | |
| """ | |
| Continual Backpropagation (Dohare et al., Nature 2024) | |
| On each step, reinitialize a small fraction of neurons with | |
| lowest utility (contribution to output). | |
| Utility = |outgoing_weights| * recent_activation_magnitude | |
| Args: | |
| model: Neural network | |
| replacement_rate: Fraction of neurons to replace per step (e.g., 0.001) | |
| maturity_threshold: Steps before a neuron can be replaced | |
| device: torch device | |
| """ | |
| def __init__(self, model: nn.Module, replacement_rate: float = 0.001, | |
| maturity_threshold: int = 1000, device=None): | |
| self.model = model | |
| self.replacement_rate = replacement_rate | |
| self.maturity_threshold = maturity_threshold | |
| self.device = device or torch.device('cuda' if torch.cuda.is_available() else 'cpu') | |
| # Track neuron age (steps since last reinitialization) | |
| self.neuron_ages: Dict[str, torch.Tensor] = {} | |
| self.activation_magnitudes: Dict[str, torch.Tensor] = {} | |
| self._initialize_tracking() | |
| def _initialize_tracking(self): | |
| """Initialize age and activation tracking for all neurons""" | |
| for name, module in self.model.named_modules(): | |
| if isinstance(module, nn.Linear): | |
| n_neurons = module.weight.size(0) | |
| self.neuron_ages[name] = torch.zeros(n_neurons, device=self.device) | |
| self.activation_magnitudes[name] = torch.ones(n_neurons, device=self.device) | |
| def compute_utility(self, name: str, module: nn.Linear) -> torch.Tensor: | |
| """ | |
| Compute utility score for each neuron. | |
| Utility = ||outgoing_weights|| * activation_magnitude | |
| """ | |
| # Find outgoing weight magnitude (if this isn't the last layer) | |
| outgoing_mag = torch.ones(module.weight.size(0), device=self.device) | |
| found = False | |
| for n, m in self.model.named_modules(): | |
| if found and isinstance(m, nn.Linear): | |
| # This is the next layer - get column norms | |
| outgoing_mag = m.weight.abs().sum(dim=0)[:module.weight.size(0)] | |
| break | |
| if n == name: | |
| found = True | |
| activation_mag = self.activation_magnitudes.get( | |
| name, torch.ones(module.weight.size(0), device=self.device) | |
| ) | |
| return outgoing_mag * activation_mag | |
| def step(self, optimizer: torch.optim.Optimizer) -> int: | |
| """ | |
| Perform continual backprop step: replace lowest-utility mature neurons. | |
| Returns: | |
| Number of neurons replaced | |
| """ | |
| total_replaced = 0 | |
| for name, module in self.model.named_modules(): | |
| if not isinstance(module, nn.Linear): | |
| continue | |
| if name not in self.neuron_ages: | |
| continue | |
| n_neurons = module.weight.size(0) | |
| ages = self.neuron_ages[name] | |
| # Increment ages | |
| ages += 1 | |
| # Find mature neurons (old enough to be replaced) | |
| mature_mask = ages >= self.maturity_threshold | |
| if not mature_mask.any(): | |
| continue | |
| # Compute utility for mature neurons | |
| utility = self.compute_utility(name, module) | |
| utility[~mature_mask] = float('inf') # Don't replace immature neurons | |
| # Number to replace | |
| n_replace = max(1, int(n_neurons * self.replacement_rate)) | |
| n_replace = min(n_replace, mature_mask.sum().item()) | |
| if n_replace == 0: | |
| continue | |
| # Get indices of lowest-utility neurons | |
| _, indices = torch.topk(utility, n_replace, largest=False) | |
| # Reinitialize | |
| with torch.no_grad(): | |
| fan_in = module.weight.size(1) | |
| std = 1.0 / math.sqrt(fan_in) | |
| module.weight[indices].normal_(0, std) | |
| if module.bias is not None: | |
| module.bias[indices].zero_() | |
| # Reset ages for replaced neurons | |
| ages[indices] = 0 | |
| # Reset optimizer state | |
| self._reset_optimizer_state(optimizer, module, indices) | |
| total_replaced += n_replace | |
| return total_replaced | |
| def _reset_optimizer_state(self, optimizer, module, indices): | |
| """Reset Adam state for replaced neurons""" | |
| for param in [module.weight, module.bias]: | |
| if param is None: | |
| continue | |
| state = optimizer.state.get(param, {}) | |
| if 'exp_avg' in state: | |
| state['exp_avg'][indices] = 0 | |
| if 'exp_avg_sq' in state: | |
| state['exp_avg_sq'][indices] = 0 | |
| def update_activations(self, name: str, activations: torch.Tensor): | |
| """Update activation magnitude tracking (call from forward hook)""" | |
| with torch.no_grad(): | |
| mag = activations.abs().mean(dim=0) | |
| if name in self.activation_magnitudes: | |
| # Exponential moving average | |
| self.activation_magnitudes[name] = ( | |
| 0.99 * self.activation_magnitudes[name] + 0.01 * mag | |
| ) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # 4. FEATURE RANK PRESERVATION (Lyle et al., 2023; Kumar et al., 2020) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # | |
| # Key insight: Feature rank collapse (representations become low-rank) | |
| # indicates loss of expressivity. Solution: regularize to maintain rank. | |
| # | |
| # Effective rank = exp(entropy of singular values) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| class FeatureRankRegularizer: | |
| """ | |
| Feature Rank Regularization to prevent representation collapse. | |
| Monitors effective rank of layer activations and adds regularization | |
| loss when rank drops below threshold. | |
| Effective rank = exp(H(σ/||σ||_1)) where σ are singular values | |
| Based on: Lyle et al. 2023, Kumar et al. 2020 | |
| """ | |
| def __init__(self, model: nn.Module, min_rank_ratio: float = 0.5, | |
| reg_weight: float = 0.01, device=None): | |
| self.model = model | |
| self.min_rank_ratio = min_rank_ratio # Minimum fraction of max rank | |
| self.reg_weight = reg_weight | |
| self.device = device or torch.device('cuda' if torch.cuda.is_available() else 'cpu') | |
| self.activation_buffer: Dict[str, List[torch.Tensor]] = {} | |
| self.rank_history: Dict[str, deque] = {} | |
| def compute_effective_rank(self, activations: torch.Tensor) -> float: | |
| """ | |
| Compute effective rank of activation matrix. | |
| effective_rank = exp(entropy of normalized singular values) | |
| """ | |
| if activations.dim() > 2: | |
| activations = activations.flatten(start_dim=1) | |
| # SVD | |
| try: | |
| _, s, _ = torch.svd(activations, compute_uv=False) | |
| except: | |
| return activations.size(1) # Return max rank on failure | |
| # Normalize singular values to form probability distribution | |
| s_norm = s / (s.sum() + 1e-8) | |
| # Shannon entropy | |
| entropy = -(s_norm * torch.log(s_norm + 1e-8)).sum() | |
| # Effective rank = exp(entropy) | |
| return torch.exp(entropy).item() | |
| def compute_rank_loss(self, activations: torch.Tensor, | |
| layer_name: str) -> torch.Tensor: | |
| """ | |
| Compute regularization loss to maintain feature rank. | |
| Loss = max(0, min_rank - current_rank) / max_rank | |
| """ | |
| if activations.dim() > 2: | |
| activations = activations.flatten(start_dim=1) | |
| max_rank = min(activations.size(0), activations.size(1)) | |
| min_rank = self.min_rank_ratio * max_rank | |
| current_rank = self.compute_effective_rank(activations) | |
| # Track history | |
| if layer_name not in self.rank_history: | |
| self.rank_history[layer_name] = deque(maxlen=100) | |
| self.rank_history[layer_name].append(current_rank) | |
| # Loss if rank is too low | |
| if current_rank < min_rank: | |
| # Encourage higher rank through covariance regularization | |
| # Penalize correlation between features | |
| centered = activations - activations.mean(dim=0, keepdim=True) | |
| cov = (centered.T @ centered) / (activations.size(0) - 1) | |
| # Off-diagonal elements (correlations) should be small | |
| off_diag = cov - torch.diag(torch.diag(cov)) | |
| rank_loss = self.reg_weight * off_diag.pow(2).mean() | |
| return rank_loss | |
| return torch.tensor(0.0, device=self.device) | |
| def get_rank_stats(self) -> Dict[str, float]: | |
| """Get current rank statistics per layer""" | |
| stats = {} | |
| for name, history in self.rank_history.items(): | |
| if history: | |
| stats[f'{name}_effective_rank'] = np.mean(history) | |
| return stats | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # 5. UNIFIED PLASTICITY PRESERVER (Combines all methods) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| class PlasticityPreserver: | |
| """ | |
| Unified plasticity preservation combining multiple research-backed methods. | |
| Integrates: | |
| 1. ReDo (dormant neuron recycling) | |
| 2. Automatic entropy tuning | |
| 3. Feature rank regularization | |
| 4. Continual backpropagation | |
| Usage: | |
| preserver = PlasticityPreserver(model, action_dim=2, device=device) | |
| # In training loop: | |
| loss = critic_loss + actor_loss | |
| loss += preserver.get_regularization_loss(activations) | |
| # After optimizer step: | |
| preserver.step(optimizer, action_probs) | |
| """ | |
| def __init__(self, model: nn.Module, action_dim: int = 2, | |
| device=None, config: dict = None): | |
| self.device = device or torch.device('cuda' if torch.cuda.is_available() else 'cpu') | |
| self.model = model | |
| # Default config | |
| cfg = { | |
| 'enable_redo': True, | |
| 'redo_tau': 0.1, | |
| 'redo_interval': 1000, | |
| 'enable_auto_entropy': True, | |
| 'target_entropy_ratio': 0.5, # Fraction of max entropy | |
| 'enable_rank_reg': True, | |
| 'min_rank_ratio': 0.5, | |
| 'rank_reg_weight': 0.01, | |
| 'enable_continual_backprop': False, # More aggressive | |
| 'replacement_rate': 0.001, | |
| } | |
| if config: | |
| cfg.update(config) | |
| self.config = cfg | |
| # Initialize components | |
| if cfg['enable_redo']: | |
| self.redo = ReDo(model, tau=cfg['redo_tau'], | |
| reset_interval=cfg['redo_interval'], device=device) | |
| else: | |
| self.redo = None | |
| if cfg['enable_auto_entropy']: | |
| target_entropy = cfg['target_entropy_ratio'] * np.log(action_dim) | |
| self.entropy_tuner = AutoEntropyTuner( | |
| action_dim=action_dim, target_entropy=target_entropy, device=device | |
| ) | |
| else: | |
| self.entropy_tuner = None | |
| if cfg['enable_rank_reg']: | |
| self.rank_reg = FeatureRankRegularizer( | |
| model, min_rank_ratio=cfg['min_rank_ratio'], | |
| reg_weight=cfg['rank_reg_weight'], device=device | |
| ) | |
| else: | |
| self.rank_reg = None | |
| if cfg['enable_continual_backprop']: | |
| self.continual_bp = ContinualBackprop( | |
| model, replacement_rate=cfg['replacement_rate'], device=device | |
| ) | |
| else: | |
| self.continual_bp = None | |
| self.step_count = 0 | |
| def get_entropy_bonus(self, action_probs: torch.Tensor) -> torch.Tensor: | |
| """Get entropy bonus for actor loss with auto-tuned coefficient""" | |
| if self.entropy_tuner is not None: | |
| return self.entropy_tuner.get_entropy_bonus(action_probs) | |
| return torch.tensor(0.0, device=self.device) | |
| def get_alpha(self) -> float: | |
| """Get current entropy coefficient""" | |
| if self.entropy_tuner is not None: | |
| return self.entropy_tuner.alpha.item() | |
| return 0.1 # Default | |
| def get_regularization_loss(self, activations: Dict[str, torch.Tensor]) -> torch.Tensor: | |
| """Get combined regularization loss from all components""" | |
| total_loss = torch.tensor(0.0, device=self.device) | |
| if self.rank_reg is not None: | |
| for name, act in activations.items(): | |
| total_loss += self.rank_reg.compute_rank_loss(act, name) | |
| return total_loss | |
| def step(self, optimizer: torch.optim.Optimizer, | |
| action_probs: torch.Tensor = None) -> Dict[str, any]: | |
| """ | |
| Call after each training step. | |
| Args: | |
| optimizer: The optimizer being used | |
| action_probs: Current action probabilities (for entropy tuning) | |
| Returns: | |
| Dictionary of metrics | |
| """ | |
| self.step_count += 1 | |
| metrics = {} | |
| # ReDo: recycle dormant neurons | |
| if self.redo is not None: | |
| n_recycled = self.redo.step(optimizer) | |
| if n_recycled: | |
| metrics['neurons_recycled'] = n_recycled | |
| metrics['dormancy_ratio'] = self.redo.get_dormancy_ratio() | |
| # Auto entropy tuning | |
| if self.entropy_tuner is not None and action_probs is not None: | |
| alpha_loss, alpha = self.entropy_tuner.update(action_probs) | |
| metrics['alpha'] = alpha | |
| metrics['alpha_loss'] = alpha_loss | |
| metrics.update(self.entropy_tuner.get_stats()) | |
| # Continual backprop | |
| if self.continual_bp is not None: | |
| n_replaced = self.continual_bp.step(optimizer) | |
| metrics['neurons_replaced'] = n_replaced | |
| # Rank stats | |
| if self.rank_reg is not None: | |
| metrics.update(self.rank_reg.get_rank_stats()) | |
| return metrics | |
| def get_diagnostics(self) -> str: | |
| """Get human-readable diagnostics""" | |
| lines = ["=" * 60, "PLASTICITY DIAGNOSTICS", "=" * 60] | |
| if self.redo: | |
| ratio = self.redo.get_dormancy_ratio() | |
| lines.append(f"Dormancy ratio: {ratio:.2%} {'⚠️ HIGH' if ratio > 0.3 else '✅'}") | |
| lines.append(f"Total neurons recycled: {self.redo.recycled_total}") | |
| if self.entropy_tuner: | |
| stats = self.entropy_tuner.get_stats() | |
| lines.append(f"Entropy α: {stats['alpha']:.4f}") | |
| lines.append(f"Mean entropy: {stats['mean_entropy']:.4f} " | |
| f"(target: {stats['target_entropy']:.4f})") | |
| gap = stats['entropy_gap'] | |
| if gap < -0.1: | |
| lines.append(f"⚠️ Entropy below target by {-gap:.4f} - increasing exploration") | |
| elif gap > 0.1: | |
| lines.append(f"✅ Entropy above target by {gap:.4f}") | |
| if self.rank_reg: | |
| stats = self.rank_reg.get_rank_stats() | |
| for name, rank in stats.items(): | |
| lines.append(f"{name}: {rank:.1f}") | |
| lines.append("=" * 60) | |
| return "\n".join(lines) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # INTEGRATION EXAMPLE | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def integrate_with_quasar_training(): | |
| """ | |
| Example of how to integrate this module with QUASAR training loop. | |
| Add this to your _train_on_batch method: | |
| """ | |
| example_code = ''' | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # In your __init__ or setup: | |
| # ═══════════════════════════════════════════════════════════════════ | |
| from quasar_plasticity_module import PlasticityPreserver | |
| self.plasticity = PlasticityPreserver( | |
| model=self.model, # Your neural network | |
| action_dim=2, # BUY/SELL | |
| device=self.device, | |
| config={ | |
| 'enable_redo': True, | |
| 'redo_tau': 0.1, # Dormancy threshold | |
| 'redo_interval': 1000, # Check every 1000 steps | |
| 'enable_auto_entropy': True, | |
| 'target_entropy_ratio': 0.5, # 50% of max entropy | |
| 'enable_rank_reg': True, | |
| 'min_rank_ratio': 0.3, # Minimum 30% of max rank | |
| } | |
| ) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # In your training loop (replace fixed entropy bonus): | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # OLD (fixed entropy): | |
| # entropy_coef = self._get_entropy_coef() # Decaying coefficient | |
| # entropy_bonus = -entropy_coef * mean_entropy | |
| # NEW (auto-tuned): | |
| entropy_bonus = self.plasticity.get_entropy_bonus(action_probs) | |
| # Add to actor loss | |
| actor_loss = actor_loss - entropy_bonus.mean() # Maximize entropy | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # After optimizer.step(): | |
| # ═══════════════════════════════════════════════════════════════════ | |
| metrics = self.plasticity.step(self.optimizer, action_probs) | |
| # Log metrics | |
| if self.step_count % 1000 == 0: | |
| print(self.plasticity.get_diagnostics()) | |
| ''' | |
| return example_code | |
| if __name__ == "__main__": | |
| print("QUASAR Plasticity Module") | |
| print("=" * 60) | |
| print("Based on research from:") | |
| print(" [1] Sokar et al. 2023 - ReDo (ICML)") | |
| print(" [2] Haarnoja et al. 2018 - SAC Auto Entropy") | |
| print(" [3] Dohare et al. 2024 - Continual Backprop (Nature)") | |
| print(" [4] Lyle et al. 2023 - Feature Rank (ICML)") | |
| print("=" * 60) | |
| print("\nIntegration example:") | |
| print(integrate_with_quasar_training()) | |