k1rl-quasar / quasar_plasticity_module.py
RealComp's picture
Upload 32 files
047d482 verified
Raw
History Blame Contribute Delete
39 kB
"""
╔══════════════════════════════════════════════════════════════════════════════╗
║ 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)
@property
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())