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

405 lines
14 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.
//! VICReg (Variance-Invariance-Covariance Regularization) Implementation
//!
//! Self-supervised learning method that explicitly avoids collapse through three loss terms:
//! - Invariance: Similar representations for augmented views (MSE loss)
//! - Variance: Maintains variance ≥ γ in each dimension (hinge loss)
//! - Covariance: Decorrelates different dimensions (Frobenius norm of off-diagonal covariance)
//!
//! Based on "VICReg: Variance-Invariance-Covariance Regularization for Self-Supervised Learning"
//! (Bardes et al., 2021).
//!
//! Key features:
//! - No momentum encoders or large batches required
//! - Explicit variance and covariance regularization prevents collapse
//! - Expander network (projector) transforms representations
//! - Support for different backbone architectures
//! - Symmetric loss computation on both augmented views
use crate::prelude::*;
use super::byol::Backbone;
use std::sync::Arc;
use parking_lot::RwLock;
/// VICReg configuration parameters
#[derive(Debug, Clone)]
pub struct VICRegConfig {
/// Dimension of backbone output features
pub backbone_dim: usize,
/// Expander network layer dimensions
pub expander_dims: Vec<usize>,
/// λ - Coefficient for invariance loss (similarity between views)
pub sim_coeff: f32,
/// μ - Coefficient for variance loss (prevent collapse)
pub std_coeff: f32,
/// ν - Coefficient for covariance loss (decorrelate features)
pub cov_coeff: f32,
/// γ - Target standard deviation (variance regularization)
pub variance_target: f32,
/// ε - Epsilon for numerical stability
pub epsilon: f32,
}
impl Default for VICRegConfig {
fn default() -> Self {
Self {
backbone_dim: 2048,
expander_dims: vec![8192, 8192, 8192],
sim_coeff: 25.0,
std_coeff: 25.0,
cov_coeff: 1.0,
variance_target: 1.0,
epsilon: 1e-4,
}
}
}
impl VICRegConfig {
/// Create new VICReg configuration
pub fn new(backbone_dim: usize, expander_dims: Vec<usize>) -> Self {
Self {
backbone_dim,
expander_dims,
..Default::default()
}
}
/// Set backbone dimension
pub fn with_backbone_dim(mut self, backbone_dim: usize) -> Self {
self.backbone_dim = backbone_dim;
self
}
/// Set invariance loss coefficient (λ)
pub fn with_sim_coeff(mut self, sim_coeff: f32) -> Self {
self.sim_coeff = sim_coeff;
self
}
/// Set variance loss coefficient (μ)
pub fn with_std_coeff(mut self, std_coeff: f32) -> Self {
self.std_coeff = std_coeff;
self
}
/// Set covariance loss coefficient (ν)
pub fn with_cov_coeff(mut self, cov_coeff: f32) -> Self {
self.cov_coeff = cov_coeff;
self
}
/// Set variance target (γ)
pub fn with_variance_target(mut self, variance_target: f32) -> Self {
self.variance_target = variance_target;
self
}
/// Set epsilon for numerical stability
pub fn with_epsilon(mut self, epsilon: f32) -> Self {
self.epsilon = epsilon;
self
}
}
/// Expander network that maps backbone features to embedding space
/// Similar to projector but terminology from VICReg paper
#[derive(Debug)]
pub struct ExpanderNetwork {
layers: Vec<LinearLayer>,
input_dim: usize,
layer_dims: Vec<usize>,
device: Device,
}
#[derive(Debug)]
struct LinearLayer {
weight: Arc<RwLock<Tensor>>,
bias: Arc<RwLock<Tensor>>,
}
impl ExpanderNetwork {
/// Create new expander network
pub fn new(
input_dim: usize,
layer_dims: Vec<usize>,
device: &Device,
) -> Result<Self> {
let mut layers = Vec::new();
let mut current_dim = input_dim;
for &output_dim in layer_dims.iter() {
// Initialize weights with Xavier/Glorot normal initialization
let scale = (2.0 / (current_dim + output_dim) as f32).sqrt();
let weight = Tensor::randn(vec![current_dim, output_dim], DType::F32, device)?
.mul(&Tensor::full(vec![current_dim, output_dim], scale, DType::F32, device)?)?;
let bias = Tensor::zeros(vec![output_dim], device)?;
layers.push(LinearLayer {
weight: Arc::new(RwLock::new(weight)),
bias: Arc::new(RwLock::new(bias)),
});
current_dim = output_dim;
}
Ok(Self {
layers,
input_dim,
layer_dims,
device: device.clone(),
})
}
/// Forward pass through expander network
pub fn forward(&self, input: &Tensor) -> Result<Tensor> {
let mut x = input.clone();
for (i, layer) in self.layers.iter().enumerate() {
// Linear transformation
let weight = layer.weight.read();
let bias = layer.bias.read();
x = x.matmul(&*weight)?.add(&*bias)?;
// ReLU activation (except for the last layer)
if i < self.layers.len() - 1 {
x = x.relu()?;
}
}
Ok(x)
}
pub fn input_dim(&self) -> usize {
self.input_dim
}
pub fn output_dim(&self) -> usize {
*self.layer_dims.last().unwrap_or(&self.input_dim)
}
pub fn layer_dims(&self) -> &[usize] {
&self.layer_dims
}
}
/// Result of VICReg loss computation
#[derive(Debug, Clone)]
pub struct VICRegLossResult {
pub total_loss: Tensor,
pub invariance_loss: Tensor,
pub variance_loss_y1: Tensor,
pub variance_loss_y2: Tensor,
pub covariance_loss_y1: Tensor,
pub covariance_loss_y2: Tensor,
}
/// Training result from VICReg training step
#[derive(Debug, Clone)]
pub struct VICRegTrainingResult {
pub total_loss: f32,
pub invariance_loss: f32,
pub variance_loss: f32,
pub covariance_loss: f32,
}
/// VICReg trainer combining backbone and expander networks
pub struct VICRegTrainer<B: Backbone> {
backbone: B,
expander: ExpanderNetwork,
config: VICRegConfig,
device: Device,
}
impl<B: Backbone> VICRegTrainer<B> {
/// Create new VICReg trainer
pub fn new(
backbone: B,
config: VICRegConfig,
device: &Device,
) -> Result<Self> {
let backbone_dim = backbone.output_dim();
let mut config = config;
config.backbone_dim = backbone_dim;
let expander = ExpanderNetwork::new(
backbone_dim,
config.expander_dims.clone(),
device,
)?;
Ok(Self {
backbone,
expander,
config,
device: device.clone(),
})
}
/// Forward pass through both networks
pub fn forward(&self, x1: &Tensor, x2: &Tensor) -> Result<VICRegLossResult> {
// Get representations from backbone
let z1 = self.backbone.forward(x1)?;
let z2 = self.backbone.forward(x2)?;
// Transform through expander network
let y1 = self.expander.forward(&z1)?;
let y2 = self.expander.forward(&z2)?;
// Compute VICReg loss
compute_vicreg_loss(&y1, &y2, &self.config)
}
/// Training step with augmentation (simplified for test)
pub fn train_step(&mut self, images: &Tensor) -> Result<VICRegTrainingResult> {
// In practice, you would apply different augmentations here
// For testing, we'll use the same image with small noise as "augmentation"
let noise = Tensor::randn(images.shape().to_vec(), &self.device)?
.mul(&Tensor::full(images.shape().to_vec(), 0.01, DType::F32, &self.device)?)?;
let x2 = images.add(&noise)?;
let loss_result = self.forward(images, &x2)?;
// In real implementation, you would:
// 1. Compute gradients
// 2. Update parameters
// 3. Zero gradients
Ok(VICRegTrainingResult {
total_loss: loss_result.total_loss.to_scalar::<f32>()?,
invariance_loss: loss_result.invariance_loss.to_scalar::<f32>()?,
variance_loss: (loss_result.variance_loss_y1.to_scalar::<f32>()? +
loss_result.variance_loss_y2.to_scalar::<f32>()?) * 0.5,
covariance_loss: (loss_result.covariance_loss_y1.to_scalar::<f32>()? +
loss_result.covariance_loss_y2.to_scalar::<f32>()?) * 0.5,
})
}
pub fn config(&self) -> &VICRegConfig {
&self.config
}
}
/// Compute invariance loss: MSE between representations
pub fn compute_invariance_loss(y1: &Tensor, y2: &Tensor) -> Result<Tensor> {
let diff = y1.sub(y2)?;
let squared_diff = diff.mul(&diff)?;
// Mean over all dimensions
let total_elements = squared_diff.shape().iter().product::<usize>() as f32;
let sum = squared_diff.sum(None)?;
sum.div(&Tensor::full(vec![], total_elements, DType::F32, squared_diff.device())?)
}
/// Compute variance loss: Hinge loss to maintain std ≥ γ
pub fn compute_variance_loss(y: &Tensor, gamma: f32, epsilon: f32) -> Result<Tensor> {
let batch_size = y.shape()[0] as f32;
let feature_dim = y.shape()[1];
// Compute mean along batch dimension
let mean = y.sum_axis(0)?.div(&Tensor::full(vec![feature_dim], batch_size, DType::F32, y.device())?)?;
// Center the features: y - mean
let centered = y.sub(&mean.unsqueeze(0)?)?;
// Compute variance: E[(y - mean)²]
let squared_diff = centered.mul(&centered)?;
let variance = squared_diff.sum_axis(0)?.div(&Tensor::full(vec![feature_dim], batch_size, DType::F32, y.device())?)?;
// Compute standard deviation with epsilon for stability
let eps_tensor = Tensor::full(vec![feature_dim], epsilon, DType::F32, y.device())?;
let variance_eps = variance.add(&eps_tensor)?;
let std = variance_eps.sqrt()?;
// Hinge loss: ReLU(γ - std)
let gamma_tensor = Tensor::full(vec![feature_dim], gamma, DType::F32, y.device())?;
let hinge = gamma_tensor.sub(&std)?;
let hinge_loss = hinge.relu()?;
// Mean over all features
hinge_loss.mean_all()
}
/// Compute covariance loss: Frobenius norm of off-diagonal covariance matrix
pub fn compute_covariance_loss(y: &Tensor, epsilon: f32) -> Result<Tensor> {
let batch_size = y.shape()[0] as f32;
let feature_dim = y.shape()[1];
// Center the features
let mean = y.sum_axis(0)?.div(&Tensor::full(vec![feature_dim], batch_size, DType::F32, y.device())?)?;
let centered = y.sub(&mean.unsqueeze(0)?)?;
// Compute covariance matrix: (1/N) * X^T * X
let covariance = centered.transpose(0, 1)?.matmul(&centered)?
.div(&Tensor::full(vec![feature_dim, feature_dim], batch_size, DType::F32, y.device())?)?;
// Add epsilon to diagonal for numerical stability
let eps_tensor = Tensor::full(vec![feature_dim], epsilon, DType::F32, y.device())?;
let eye = create_identity_matrix(feature_dim, y.device())?;
let eps_eye = eye.mul(&eps_tensor.unsqueeze(1)?)?;
let stable_cov = covariance.add(&eps_eye)?;
// Zero out diagonal elements (we only want off-diagonal)
let off_diagonal_mask = create_off_diagonal_mask(feature_dim, y.device())?;
let off_diagonal_cov = stable_cov.mul(&off_diagonal_mask)?;
// Compute Frobenius norm of off-diagonal elements
let squared = off_diagonal_cov.mul(&off_diagonal_cov)?;
squared.sum(None)
}
/// Compute complete VICReg loss with all three terms
pub fn compute_vicreg_loss(
y1: &Tensor,
y2: &Tensor,
config: &VICRegConfig
) -> Result<VICRegLossResult> {
// Invariance loss: similarity between representations
let invariance_loss = compute_invariance_loss(y1, y2)?;
// Variance losses: prevent dimensional collapse
let variance_loss_y1 = compute_variance_loss(y1, config.variance_target, config.epsilon)?;
let variance_loss_y2 = compute_variance_loss(y2, config.variance_target, config.epsilon)?;
// Covariance losses: decorrelate features
let covariance_loss_y1 = compute_covariance_loss(y1, config.epsilon)?;
let covariance_loss_y2 = compute_covariance_loss(y2, config.epsilon)?;
// Weighted total loss
let sim_term = invariance_loss.mul(&Tensor::full(vec![], config.sim_coeff, DType::F32, y1.device())?)?;
let std_term1 = variance_loss_y1.mul(&Tensor::full(vec![], config.std_coeff, DType::F32, y1.device())?)?;
let std_term2 = variance_loss_y2.mul(&Tensor::full(vec![], config.std_coeff, DType::F32, y1.device())?)?;
let cov_term1 = covariance_loss_y1.mul(&Tensor::full(vec![], config.cov_coeff, DType::F32, y1.device())?)?;
let cov_term2 = covariance_loss_y2.mul(&Tensor::full(vec![], config.cov_coeff, DType::F32, y1.device())?)?;
let total_loss = sim_term
.add(&std_term1)?
.add(&std_term2)?
.add(&cov_term1)?
.add(&cov_term2)?;
Ok(VICRegLossResult {
total_loss,
invariance_loss,
variance_loss_y1,
variance_loss_y2,
covariance_loss_y1,
covariance_loss_y2,
})
}
/// Helper function to create identity matrix
fn create_identity_matrix(size: usize, device: &Device) -> Result<Tensor> {
let mut data = vec![0.0f32; size * size];
for i in 0..size {
data[i * size + i] = 1.0;
}
Tensor::from_slice(&data, vec![size, size], DType::F32, device)
}
/// Helper function to create off-diagonal mask (1s off-diagonal, 0s on diagonal)
fn create_off_diagonal_mask(size: usize, device: &Device) -> Result<Tensor> {
let mut data = vec![1.0f32; size * size];
for i in 0..size {
data[i * size + i] = 0.0;
}
Tensor::from_slice(&data, vec![size, size], DType::F32, device)
}