Files
rustytorch/crates/training/rtx-transformers/src/optimizers/sophia.rs
T
2026-03-04 00:08:42 +00:00

530 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sophia optimizer implementation
//!
//! Sophia: Second-order Clipped Stochastic Optimization (Stanford, 2023)
//!
//! Key features:
//! - Second-order information via Hessian diagonal estimation
//! - Adaptive learning with gradient clipping
//! - Parameters: lr, betas, rho (Hessian EMA), weight_decay
//! - Update rule uses Hessian for per-parameter learning rates
//! - Numerical stability with clipping
//!
//! # Mathematical Foundation
//!
//! Sophia approximates the diagonal Hessian and uses it for adaptive learning:
//! - m_t = β₁ * m_{t-1} + (1 - β₁) * g_t (momentum)
//! - h_t = ρ * h_{t-1} + (1 - ρ) * (g_t ⊙ g_t) (Hessian diagonal approximation)
//! - u_t = m_t / max(h_t, ε) (adaptive update)
//! - u_t = clip(u_t, γ) (gradient clipping)
//! - θ_t = θ_{t-1} - η * u_t (parameter update)
//!
//! # Performance Characteristics
//! - Uses second-order curvature information for better conditioning
//! - Adaptive per-parameter learning rates
//! - Numerically stable with clipping mechanisms
use crate::{Result, TransformerError};
use crate::optimizers::{Optimizer, BaseOptimizer};
use rtx_tensor::{Tensor, TensorError, DType};
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use tracing::{debug, trace};
/// Sophia optimizer configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SophiaConfig {
/// Learning rate
pub learning_rate: f64,
/// Beta1 parameter (momentum coefficient)
pub beta1: f64,
/// Beta2 parameter (second moment coefficient)
pub beta2: f64,
/// Rho parameter (Hessian EMA coefficient)
pub rho: f64,
/// Weight decay coefficient
pub weight_decay: f64,
/// Clipping threshold for gradient clipping
pub clip_threshold: f64,
/// Epsilon for numerical stability
pub eps: f64,
}
impl Default for SophiaConfig {
fn default() -> Self {
Self {
learning_rate: 1e-3,
beta1: 0.965,
beta2: 0.99,
rho: 0.04,
weight_decay: 0.0,
clip_threshold: 1.0,
eps: 1e-8,
}
}
}
/// Sophia optimizer state for a single parameter
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SophiaState {
/// Exponential moving average of gradient (momentum)
pub momentum: Tensor,
/// Exponential moving average of Hessian diagonal approximation
pub hessian_diagonal: Tensor,
/// Step count
pub step: i64,
}
/// Sophia optimizer with second-order curvature information
///
/// Sophia uses an approximation of the diagonal Hessian to provide adaptive
/// per-parameter learning rates, leading to better convergence properties
/// especially for ill-conditioned optimization problems.
///
/// # Mathematical Details
///
/// The Sophia update rule consists of several phases:
/// 1. Update momentum: m_t = β₁ * m_{t-1} + (1 - β₁) * g_t
/// 2. Update Hessian diagonal: h_t = ρ * h_{t-1} + (1 - ρ) * (g_t ⊙ g_t)
/// 3. Compute adaptive update: u_t = m_t / max(h_t, ε)
/// 4. Apply clipping: u_t = clip(u_t, γ)
/// 5. Update parameter: θ_t = θ_{t-1} - η * u_t
///
/// # Adaptive Learning
/// The Hessian diagonal approximation provides per-parameter scaling,
/// allowing the optimizer to adapt to the local curvature of the loss surface.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SophiaOptimizer {
/// Base optimizer functionality
base: BaseOptimizer,
/// Beta1 parameter (momentum coefficient)
beta1: f64,
/// Beta2 parameter (second moment coefficient)
beta2: f64,
/// Rho parameter (Hessian EMA coefficient)
rho: f64,
/// Weight decay (L2 regularization)
weight_decay: f64,
/// Clipping threshold for gradient clipping
clip_threshold: f64,
/// Epsilon for numerical stability
eps: f64,
/// Per-parameter state
state: HashMap<String, SophiaState>,
}
impl SophiaOptimizer {
/// Create a new Sophia optimizer from configuration
pub fn new(config: SophiaConfig) -> Result<Self> {
// Validate parameters
Self::validate_parameters(
config.learning_rate,
config.beta1,
config.beta2,
config.rho,
config.weight_decay,
config.clip_threshold,
config.eps,
)?;
debug!(
"Creating Sophia optimizer: lr={}, β₁={}, β₂={}, ρ={}, weight_decay={}, clip={}, ε={}",
config.learning_rate, config.beta1, config.beta2, config.rho,
config.weight_decay, config.clip_threshold, config.eps
);
Ok(Self {
base: BaseOptimizer::new(config.learning_rate),
beta1: config.beta1,
beta2: config.beta2,
rho: config.rho,
weight_decay: config.weight_decay,
clip_threshold: config.clip_threshold,
eps: config.eps,
state: HashMap::new(),
})
}
/// Validate optimizer parameters
fn validate_parameters(
learning_rate: f64,
beta1: f64,
beta2: f64,
rho: f64,
weight_decay: f64,
clip_threshold: f64,
eps: f64,
) -> Result<()> {
if learning_rate <= 0.0 {
return Err(TransformerError::generic(
format!("learning_rate {} must be positive", learning_rate)
));
}
if !(0.0..1.0).contains(&beta1) {
return Err(TransformerError::generic(
format!("beta1 {} must be in [0, 1)", beta1)
));
}
if !(0.0..1.0).contains(&beta2) {
return Err(TransformerError::generic(
format!("beta2 {} must be in [0, 1)", beta2)
));
}
if !(0.0..=1.0).contains(&rho) {
return Err(TransformerError::generic(
format!("rho {} must be in [0, 1]", rho)
));
}
if weight_decay < 0.0 {
return Err(TransformerError::generic(
format!("weight_decay {} must be non-negative", weight_decay)
));
}
if clip_threshold <= 0.0 {
return Err(TransformerError::generic(
format!("clip_threshold {} must be positive", clip_threshold)
));
}
if eps < 0.0 {
return Err(TransformerError::generic(
format!("eps {} must be non-negative", eps)
));
}
Ok(())
}
/// Get beta1 parameter
pub fn beta1(&self) -> f64 {
self.beta1
}
/// Get beta2 parameter
pub fn beta2(&self) -> f64 {
self.beta2
}
/// Get rho parameter
pub fn rho(&self) -> f64 {
self.rho
}
/// Get weight decay parameter
pub fn weight_decay(&self) -> f64 {
self.weight_decay
}
/// Get clip threshold parameter
pub fn clip_threshold(&self) -> f64 {
self.clip_threshold
}
/// Get epsilon parameter
pub fn eps(&self) -> f64 {
self.eps
}
/// Initialize state for a parameter if it doesn't exist
fn ensure_state(&mut self, param_name: &str, param: &Tensor) -> Result<()> {
if !self.state.contains_key(param_name) {
let shape = param.shape();
let device = param.device();
let momentum = Tensor::zeros(shape.clone(), device)?;
let hessian_diagonal = Tensor::zeros(shape.clone(), device)?;
let state = SophiaState {
momentum,
hessian_diagonal,
step: 0,
};
self.state.insert(param_name.to_string(), state);
trace!("Initialized Sophia state for parameter: {}", param_name);
}
Ok(())
}
/// Apply gradient clipping element-wise
fn apply_clipping(update: &Tensor, threshold: f64) -> Result<Tensor> {
// Element-wise clipping: clip(x, threshold) = max(-threshold, min(threshold, x))
let threshold_tensor = Tensor::scalar(threshold as f32, DType::F32, update.device())?;
let neg_threshold_tensor = Tensor::scalar(-threshold as f32, DType::F32, update.device())?;
// Clamp to [-threshold, threshold]
let clipped = update.clamp(&neg_threshold_tensor, &threshold_tensor)?;
Ok(clipped)
}
/// Perform Sophia parameter update (static version)
fn update_parameter_static(
param: &Tensor,
grad: &Tensor,
state: &mut SophiaState,
learning_rate: f64,
beta1: f64,
beta2: f64,
rho: f64,
weight_decay: f64,
clip_threshold: f64,
eps: f64,
) -> Result<Tensor> {
// Increment step count
state.step += 1;
trace!("Sophia step {} for parameter", state.step);
// Step 1: Update momentum m_t = β₁ * m_{t-1} + (1 - β₁) * g_t
let momentum_term = (&state.momentum * beta1)?;
let grad_term = (grad * (1.0 - beta1))?;
state.momentum = (momentum_term + grad_term)?;
// Step 2: Update Hessian diagonal approximation h_t = ρ * h_{t-1} + (1 - ρ) * (g_t ⊙ g_t)
let hessian_term = (&state.hessian_diagonal * rho)?;
let grad_squared = (grad * grad)?;
let hessian_update = (&grad_squared * (1.0 - rho))?;
state.hessian_diagonal = (hessian_term + hessian_update)?;
// Step 3: Compute adaptive update u_t = m_t / max(h_t, ε)
let eps_tensor = Tensor::scalar(eps as f32, DType::F32, state.hessian_diagonal.device())?;
let hessian_with_eps = state.hessian_diagonal.maximum(&eps_tensor)?;
let hessian_sqrt = hessian_with_eps.sqrt()?;
let adaptive_update = state.momentum.div(&hessian_sqrt)?;
// Step 4: Apply gradient clipping u_t = clip(u_t, γ)
let clipped_update = Self::apply_clipping(&adaptive_update, clip_threshold)?;
// Step 5: Apply weight decay and parameter update
let scaled_update = (&clipped_update * learning_rate)?;
let final_param = if weight_decay > 0.0 {
// L2 regularization: θ_t = θ_{t-1} - α * λ * θ_{t-1} - update
(param * (1.0 - learning_rate * weight_decay))? - &scaled_update
} else {
// Standard update: θ_t = θ_{t-1} - update
param - &scaled_update
}?;
Ok(final_param)
}
}
impl Optimizer for SophiaOptimizer {
fn step_param(&mut self, param_name: &str, param: &Tensor, grad: &Tensor) -> Result<Tensor> {
// Validate input shapes match
if param.shape() != grad.shape() {
return Err(TransformerError::shape_mismatch(
format!("Parameter shape {:?} doesn't match gradient shape {:?}",
param.shape().dims().to_vec(),
grad.shape().dims().to_vec())
));
}
// Ensure state exists
self.ensure_state(param_name, param)?;
// Get the state and perform update
let state = self.state.get_mut(param_name).unwrap();
let learning_rate = self.base.learning_rate;
let beta1 = self.beta1;
let beta2 = self.beta2;
let rho = self.rho;
let weight_decay = self.weight_decay;
let clip_threshold = self.clip_threshold;
let eps = self.eps;
// Perform update using static method
Self::update_parameter_static(
param, grad, state, learning_rate, beta1, beta2,
rho, weight_decay, clip_threshold, eps
)
}
fn learning_rate(&self) -> f64 {
self.base.learning_rate
}
fn set_learning_rate(&mut self, lr: f64) -> Result<()> {
if lr <= 0.0 {
return Err(TransformerError::generic(
format!("learning_rate {} must be positive", lr)
));
}
self.base.set_learning_rate(lr);
debug!("Updated Sophia learning rate to: {}", lr);
Ok(())
}
fn has_state(&self, param_name: &str) -> bool {
self.state.contains_key(param_name)
}
fn reset_state(&mut self, param_name: &str) -> Result<()> {
if self.state.remove(param_name).is_some() {
debug!("Reset Sophia state for parameter: {}", param_name);
}
Ok(())
}
fn reset_all_state(&mut self) {
let count = self.state.len();
self.state.clear();
debug!("Reset all Sophia state ({} parameters)", count);
}
fn get_step_count(&self, param_name: &str) -> Result<i64> {
self.state
.get(param_name)
.map(|state| state.step)
.ok_or_else(|| {
TransformerError::optimizer(format!("No state found for parameter: {}", param_name))
})
}
fn optimizer_type(&self) -> &'static str {
"Sophia"
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn store_gradients_internal(&mut self, gradients: HashMap<String, Tensor>) -> Result<()> {
debug!("Storing {} gradients for Sophia optimizer", gradients.len());
self.base.store_gradients(gradients);
Ok(())
}
fn process_stored_gradients(&mut self, learning_rate: f64) -> Result<HashMap<String, Tensor>> {
debug!("Processing {} stored gradients with lr={} using Sophia algorithm",
self.base.stored_gradients.len(), learning_rate);
// Update learning rate if different
if (self.learning_rate() - learning_rate).abs() > f64::EPSILON {
self.set_learning_rate(learning_rate)?;
}
let mut parameter_updates = HashMap::new();
// Process each stored gradient with Sophia algorithm
for (param_name, grad) in &self.base.stored_gradients {
// Initialize state if it doesn't exist
if !self.state.contains_key(param_name) {
debug!("Creating Sophia state for parameter: {}", param_name);
let shape = grad.shape();
let device = grad.device();
let momentum = Tensor::zeros(shape.clone(), device)?;
let hessian_diagonal = Tensor::zeros(shape.clone(), device)?;
let state = SophiaState {
momentum,
hessian_diagonal,
step: 0,
};
self.state.insert(param_name.clone(), state);
}
// Get mutable reference to state
let state = self.state.get_mut(param_name).unwrap();
// Increment step count
state.step += 1;
trace!("Sophia step {} for parameter {}", state.step, param_name);
// Step 1: Update momentum m_t = β₁ * m_{t-1} + (1 - β₁) * g_t
let momentum_term = (&state.momentum * self.beta1)?;
let grad_term = (grad * (1.0 - self.beta1))?;
state.momentum = (momentum_term + grad_term)?;
// Step 2: Update Hessian diagonal approximation h_t = ρ * h_{t-1} + (1 - ρ) * (g_t ⊙ g_t)
let hessian_term = (&state.hessian_diagonal * self.rho)?;
let grad_squared = (grad * grad)?;
let hessian_update = (&grad_squared * (1.0 - self.rho))?;
state.hessian_diagonal = (hessian_term + hessian_update)?;
// Step 3: Compute adaptive update u_t = m_t / max(h_t, ε)
let eps_tensor = Tensor::scalar(self.eps as f32, DType::F32, state.hessian_diagonal.device())?;
let hessian_with_eps = state.hessian_diagonal.maximum(&eps_tensor)?;
let hessian_sqrt = hessian_with_eps.sqrt()?;
let adaptive_update = state.momentum.div(&hessian_sqrt)?;
// Step 4: Apply gradient clipping u_t = clip(u_t, γ)
let clipped_update = Self::apply_clipping(&adaptive_update, self.clip_threshold)?;
// Step 5: Scale by learning rate and store negative update
let scaled_update = (&clipped_update * learning_rate)?;
let final_update = if self.weight_decay > 0.0 {
// Note: Weight decay requires parameter values, which we don't have here
debug!("Weight decay configured but parameter values not available in process_stored_gradients");
(scaled_update * -1.0)? // Negative because we want to subtract the update
} else {
(scaled_update * -1.0)? // Negative because we want to subtract the update
};
parameter_updates.insert(param_name.clone(), final_update);
debug!("Created Sophia update for parameter: {} (step {})", param_name, state.step);
}
// Clear stored gradients after processing
self.base.clear_gradients();
debug!("Generated {} parameter updates using Sophia algorithm", parameter_updates.len());
Ok(parameter_updates)
}
}
#[cfg(all(test, feature = "disabled_tests"))]
mod tests {
use super::*;
use rtx_tensor::{Device, Shape};
#[test]
fn test_sophia_parameter_validation() {
let config = SophiaConfig::default();
// Test valid parameters
assert!(SophiaOptimizer::new(config).is_ok());
// Test invalid learning rate
let mut invalid_config = SophiaConfig::default();
invalid_config.learning_rate = -0.1;
assert!(SophiaOptimizer::new(invalid_config).is_err());
}
#[test]
fn test_sophia_basic_functionality() -> Result<()> {
let config = SophiaConfig::default();
let mut optimizer = SophiaOptimizer::new(config)?;
let device = Device::cpu();
let param = Tensor::from_data(vec![1.0, 2.0], &[2], &device)?;
let grad = Tensor::from_data(vec![0.1, 0.2], &[2], &device)?;
// First step
let updated_param = optimizer.step_param("test_param", &param, &grad)?;
assert!(optimizer.has_state("test_param"));
assert_eq!(optimizer.get_step_count("test_param")?, 1);
// Verify parameter changed
let param_data = param.to_cpu()?;
let updated_data = updated_param.to_cpu()?;
assert_ne!(param_data, updated_data);
Ok(())
}
}
// Include tests if compiled with test features
#[cfg(all(test, feature = "disabled_tests"))]
#[path = "sophia_tests.rs"]
mod sophia_tests;