//! Spectral convolution layers for Fourier Neural Operators. //! //! Spectral convolutions operate in the Fourier domain, enabling efficient //! learning of long-range dependencies in spatial data. These layers are the //! core building blocks of Fourier Neural Operators (FNO). //! //! ## Algorithm //! //! For SpectralConv2d: //! 1. Apply 2D FFT to input: x_freq = FFT2d(x) //! 2. Truncate to n_modes (keep only low frequencies) //! 3. Multiply by learnable weights in frequency domain //! 4. Apply inverse FFT: y = IFFT2d(x_freq * W) //! //! This is dramatically more efficient than spatial convolutions for learning //! global patterns, as multiplication in Fourier space is O(n_modes) vs O(kernel_size). use rtx_backend::Backend; use rtx_nn::generic::{GenericModule, GenericModule4D}; use rtx_tensor::generic::GenericTensor; use rtx_tensor::{ComplexTensor, Device, Tensor}; use std::fmt::Debug; /// 1D Spectral Convolution layer. /// /// Performs convolution in the Fourier domain for 1D signals. #[derive(Debug)] pub struct SpectralConv1d> { in_channels: usize, out_channels: usize, n_modes: usize, device: B::Device, // Weights are stored as [in_channels, out_channels, n_modes, 2] // where the last dimension holds [real, imag] components weights: GenericTensor, } impl> SpectralConv1d { /// Create a new 1D spectral convolution layer. /// /// # Arguments /// * `in_channels` - Number of input channels /// * `out_channels` - Number of output channels /// * `n_modes` - Number of Fourier modes to keep (frequency truncation) /// * `device` - Device to create the layer on pub fn new( in_channels: usize, out_channels: usize, n_modes: usize, device: &B::Device, ) -> Self { // Initialize weights with Xavier uniform: scale * (2 * rand - 1) let scale = (2.0 / (in_channels + out_channels) as f32).sqrt(); let weights = GenericTensor::rand([in_channels, out_channels, n_modes, 2], device) .mul_scalar(2.0 * scale) .add_scalar(-scale); Self { in_channels, out_channels, n_modes, device: device.clone(), weights, } } /// Get the number of Fourier modes. pub fn n_modes(&self) -> usize { self.n_modes } } impl> GenericModule for SpectralConv1d { fn forward(&self, _input: &GenericTensor) -> GenericTensor { panic!("Use forward_3d for 1D spectral convolution"); } fn device(&self) -> &B::Device { &self.device } } /// 2D Spectral Convolution layer. /// /// Performs convolution in the Fourier domain for 2D spatial data. /// This is the core component of the Fourier Neural Operator (FNO). /// /// # Algorithm (neuraloperator v2.0 compatible) /// /// Given input x of shape [batch, in_channels, height, width]: /// 1. Apply 2D FFT to get frequency representation /// 2. Apply weights1 to upper portion of frequency domain (low frequencies) /// 3. Apply weights2 to lower portion of frequency domain (high frequencies in h) /// 4. Apply inverse 2D FFT to return to spatial domain /// /// The learnable parameters are two sets of complex weights in frequency space, /// matching neuraloperator v2.0's dual-weight architecture. #[derive(Debug)] pub struct SpectralConv2d> { in_channels: usize, out_channels: usize, n_modes_h: usize, n_modes_w: usize, device: B::Device, /// Complex weights for upper frequency portion: [in_ch, out_ch, n_modes_h, n_modes_w, 2] weights1: GenericTensor, /// Complex weights for lower frequency portion: [in_ch, out_ch, n_modes_h, n_modes_w, 2] weights2: GenericTensor, } impl> SpectralConv2d { /// Create a new 2D spectral convolution layer. /// /// # Arguments /// * `in_channels` - Number of input channels /// * `out_channels` - Number of output channels /// * `n_modes_h` - Number of Fourier modes along height dimension /// * `n_modes_w` - Number of Fourier modes along width dimension /// * `device` - Device to create the layer on pub fn new( in_channels: usize, out_channels: usize, n_modes_h: usize, n_modes_w: usize, device: &B::Device, ) -> Self { // Xavier initialization for complex weights: scale * (2 * rand - 1) let scale = (2.0 / (in_channels + out_channels) as f32).sqrt(); // weights1: for upper portion of frequency domain let weights1 = GenericTensor::rand([in_channels, out_channels, n_modes_h, n_modes_w, 2], device) .mul_scalar(2.0 * scale) .add_scalar(-scale); // weights2: for lower portion of frequency domain let weights2 = GenericTensor::rand([in_channels, out_channels, n_modes_h, n_modes_w, 2], device) .mul_scalar(2.0 * scale) .add_scalar(-scale); Self { in_channels, out_channels, n_modes_h, n_modes_w, device: device.clone(), weights1, weights2, } } /// Get the number of Fourier modes in height dimension. pub fn n_modes_h(&self) -> usize { self.n_modes_h } /// Get the number of Fourier modes in width dimension. pub fn n_modes_w(&self) -> usize { self.n_modes_w } /// Get a reference to weights1 tensor. pub fn weights1(&self) -> &GenericTensor { &self.weights1 } /// Get a reference to weights2 tensor. pub fn weights2(&self) -> &GenericTensor { &self.weights2 } /// Get a reference to the weights tensor (returns weights1 for compatibility). #[deprecated(note = "Use weights1() or weights2() instead")] pub fn weights(&self) -> &GenericTensor { &self.weights1 } /// Extract weights1 as separate real and imaginary parts. /// /// Returns `(real, imag)` where each is a `Vec` of shape /// `[in_channels * out_channels * n_modes_h * n_modes_w]`. pub fn weights1_real_imag(&self) -> (Vec, Vec) { let data = self.weights1.to_vec(); let n_complex = self.in_channels * self.out_channels * self.n_modes_h * self.n_modes_w; let mut real = Vec::with_capacity(n_complex); let mut imag = Vec::with_capacity(n_complex); for i in 0..n_complex { real.push(data[i * 2]); imag.push(data[i * 2 + 1]); } (real, imag) } /// Extract weights2 as separate real and imaginary parts. /// /// Returns `(real, imag)` where each is a `Vec` of shape /// `[in_channels * out_channels * n_modes_h * n_modes_w]`. pub fn weights2_real_imag(&self) -> (Vec, Vec) { let data = self.weights2.to_vec(); let n_complex = self.in_channels * self.out_channels * self.n_modes_h * self.n_modes_w; let mut real = Vec::with_capacity(n_complex); let mut imag = Vec::with_capacity(n_complex); for i in 0..n_complex { real.push(data[i * 2]); imag.push(data[i * 2 + 1]); } (real, imag) } /// Get the input channels. pub fn in_channels(&self) -> usize { self.in_channels } /// Get the output channels. pub fn out_channels(&self) -> usize { self.out_channels } /// Create a spectral convolution layer with pre-defined weights. /// /// # Arguments /// * `weights1_real` - Real part of weights1 [in_ch, out_ch, n_modes_h, n_modes_w] /// * `weights1_imag` - Imaginary part of weights1 /// * `in_channels` - Number of input channels /// * `out_channels` - Number of output channels /// * `n_modes_h` - Number of Fourier modes along height /// * `n_modes_w` - Number of Fourier modes along width /// * `device` - Device to create the layer on /// /// Note: This creates weights2 as a copy of weights1 for backwards compatibility. /// Use `from_weights_dual()` for full neuraloperator v2.0 compatibility. pub fn from_weights( weights1_real: &[f32], weights1_imag: &[f32], in_channels: usize, out_channels: usize, n_modes_h: usize, n_modes_w: usize, device: &B::Device, ) -> Self { // For backwards compatibility, use the same weights for both Self::from_weights_dual( weights1_real, weights1_imag, weights1_real, weights1_imag, in_channels, out_channels, n_modes_h, n_modes_w, device, ) } /// Create a spectral convolution layer with dual pre-defined weights. /// /// This matches neuraloperator v2.0 architecture with two weight matrices. /// /// # Arguments /// * `weights1_real` - Real part of weights1 [in_ch, out_ch, n_modes_h, n_modes_w] /// * `weights1_imag` - Imaginary part of weights1 /// * `weights2_real` - Real part of weights2 [in_ch, out_ch, n_modes_h, n_modes_w] /// * `weights2_imag` - Imaginary part of weights2 /// * `in_channels` - Number of input channels /// * `out_channels` - Number of output channels /// * `n_modes_h` - Number of Fourier modes along height /// * `n_modes_w` - Number of Fourier modes along width /// * `device` - Device to create the layer on #[allow(clippy::too_many_arguments)] pub fn from_weights_dual( weights1_real: &[f32], weights1_imag: &[f32], weights2_real: &[f32], weights2_imag: &[f32], in_channels: usize, out_channels: usize, n_modes_h: usize, n_modes_w: usize, device: &B::Device, ) -> Self { let total_complex = in_channels * out_channels * n_modes_h * n_modes_w; // Interleave weights1 let mut interleaved1 = Vec::with_capacity(total_complex * 2); for i in 0..total_complex { interleaved1.push(weights1_real[i]); interleaved1.push(weights1_imag[i]); } let weights1 = GenericTensor::from_slice( &interleaved1, [in_channels, out_channels, n_modes_h, n_modes_w, 2], device, ); // Interleave weights2 let mut interleaved2 = Vec::with_capacity(total_complex * 2); for i in 0..total_complex { interleaved2.push(weights2_real[i]); interleaved2.push(weights2_imag[i]); } let weights2 = GenericTensor::from_slice( &interleaved2, [in_channels, out_channels, n_modes_h, n_modes_w, 2], device, ); Self { in_channels, out_channels, n_modes_h, n_modes_w, device: device.clone(), weights1, weights2, } } } impl> GenericModule for SpectralConv2d { fn forward(&self, _input: &GenericTensor) -> GenericTensor { panic!("Use forward_4d for 2D spectral convolution"); } fn device(&self) -> &B::Device { &self.device } } impl> GenericModule4D for SpectralConv2d { fn forward_4d(&self, input: &GenericTensor) -> GenericTensor { let shape = input.shape(); let batch = shape[0]; let in_ch = shape[1]; let height = shape[2]; let width = shape[3]; // Validate input channels match assert_eq!( in_ch, self.in_channels, "Input channels {} do not match layer in_channels {}", in_ch, self.in_channels ); // Convert GenericTensor to Vec for processing let input_data = input.to_vec(); // Allocate output buffer [batch, out_channels, height, width] let output_size = batch * self.out_channels * height * width; let mut output_data = vec![0.0f32; output_size]; // Use CPU device for ComplexTensor operations let cpu_device = Device::cpu(); // Get weight data for both weight matrices let weight1_data = self.weights1.to_vec(); let weight2_data = self.weights2.to_vec(); // Process each batch for b in 0..batch { // Process each output channel for out_c in 0..self.out_channels { // Accumulate contributions from all input channels let mut channel_sum_real = Tensor::zeros(vec![height, width], &cpu_device) .expect("Failed to create zeros"); let mut channel_sum_imag = Tensor::zeros(vec![height, width], &cpu_device) .expect("Failed to create zeros"); for in_c in 0..self.in_channels { // Extract 2D slice [height, width] for this batch and input channel let slice_start = (b * in_ch + in_c) * height * width; let slice_end = slice_start + (height * width); let slice_data = input_data[slice_start..slice_end].to_vec(); // Create real tensor and convert to ComplexTensor let real_tensor = Tensor::from_data(slice_data, vec![height, width], &cpu_device) .expect("Failed to create tensor from data"); let complex_input = ComplexTensor::::from_real(real_tensor) .expect("Failed to create complex tensor"); // Apply 2D FFT let fft_result = complex_input.fft2d().expect("Failed to perform FFT2D"); // Truncate to n_modes let modes_h = self.n_modes_h.min(height); let modes_w = self.n_modes_w.min(width); // Extract FFT data let fft_real_data = fft_result .real() .to_cpu() .expect("Failed to get FFT real data"); let fft_imag_data = fft_result .imag() .to_cpu() .expect("Failed to get FFT imag data"); // Weight offset for [in_c, out_c, :, :, :] let weight_slice_start = (in_c * self.out_channels + out_c) * self.n_modes_h * self.n_modes_w * 2; // Perform complex multiplication in frequency domain let mut freq_result_real = vec![0.0f32; height * width]; let mut freq_result_imag = vec![0.0f32; height * width]; // Apply weights1 to upper portion (low frequencies in h) // Positions [0..modes_h, 0..modes_w] for h in 0..modes_h { for w in 0..modes_w { let fft_idx = h * width + w; // Get complex FFT value at this position let fft_r = fft_real_data[fft_idx]; let fft_i = fft_imag_data[fft_idx]; // Get complex weight from weights1 let weight_idx = weight_slice_start + (h * self.n_modes_w + w) * 2; let weight_r = weight1_data[weight_idx]; let weight_i = weight1_data[weight_idx + 1]; // Complex multiplication: (a+bi) * (c+di) = (ac-bd) + (ad+bc)i let result_r = fft_r * weight_r - fft_i * weight_i; let result_i = fft_r * weight_i + fft_i * weight_r; freq_result_real[fft_idx] = result_r; freq_result_imag[fft_idx] = result_i; } } // Apply weights2 to lower portion (high frequencies in h) // Positions [height-modes_h..height, 0..modes_w] // This corresponds to negative frequencies in the FFT output if height > modes_h { for h in 0..modes_h { let fft_h = height - modes_h + h; for w in 0..modes_w { let fft_idx = fft_h * width + w; // Get complex FFT value at this position let fft_r = fft_real_data[fft_idx]; let fft_i = fft_imag_data[fft_idx]; // Get complex weight from weights2 let weight_idx = weight_slice_start + (h * self.n_modes_w + w) * 2; let weight_r = weight2_data[weight_idx]; let weight_i = weight2_data[weight_idx + 1]; // Complex multiplication let result_r = fft_r * weight_r - fft_i * weight_i; let result_i = fft_r * weight_i + fft_i * weight_r; freq_result_real[fft_idx] = result_r; freq_result_imag[fft_idx] = result_i; } } } // Create ComplexTensor from frequency domain result let freq_real_tensor = Tensor::from_data(freq_result_real, vec![height, width], &cpu_device) .expect("Failed to create frequency real tensor"); let freq_imag_tensor = Tensor::from_data(freq_result_imag, vec![height, width], &cpu_device) .expect("Failed to create frequency imag tensor"); let freq_complex = ComplexTensor::::from_real_imag(freq_real_tensor, freq_imag_tensor) .expect("Failed to create frequency complex tensor"); // Apply inverse 2D FFT let ifft_result = freq_complex.ifft2d().expect("Failed to perform IFFT2D"); // Accumulate to channel sum let ifft_real = ifft_result.real(); let ifft_imag = ifft_result.imag(); channel_sum_real = channel_sum_real .add(ifft_real) .expect("Failed to add real part"); channel_sum_imag = channel_sum_imag .add(ifft_imag) .expect("Failed to add imag part"); } // Extract real part and copy to output let final_real_data = channel_sum_real .to_cpu() .expect("Failed to get final real data"); let output_offset = (b * self.out_channels + out_c) * height * width; for i in 0..final_real_data.len() { output_data[output_offset + i] = final_real_data[i]; } } } // Convert back to GenericTensor GenericTensor::from_slice( &output_data, [batch, self.out_channels, height, width], &self.device, ) } } #[cfg(test)] mod tests { use super::*; use rtx_backend_cpu::{CpuBackend, CpuDevice}; // ==================== SpectralConv1d Tests ==================== #[test] fn test_spectral_conv1d_creation() { let device = CpuDevice::new(); let conv = SpectralConv1d::::new(4, 8, 12, &device); assert_eq!(conv.in_channels, 4); assert_eq!(conv.out_channels, 8); assert_eq!(conv.n_modes(), 12); } #[test] fn test_spectral_conv1d_weight_shape() { let device = CpuDevice::new(); let conv = SpectralConv1d::::new(3, 6, 8, &device); // Weights should be [in_ch, out_ch, n_modes, 2] assert_eq!(conv.weights.shape(), [3, 6, 8, 2]); } #[test] fn test_spectral_conv1d_weight_initialization() { let device = CpuDevice::new(); let conv = SpectralConv1d::::new(4, 8, 10, &device); // Check Xavier initialization bounds let scale = (2.0_f32 / (4.0_f32 + 8.0_f32)).sqrt(); let data = conv.weights.to_vec(); // All values should be in range [-scale, scale] for &val in &data { assert!( val >= -scale && val <= scale, "Value {} outside Xavier range [{}, {}]", val, -scale, scale ); } } // ==================== SpectralConv2d Tests ==================== #[test] fn test_spectral_conv2d_creation() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(3, 16, 12, 12, &device); assert_eq!(conv.in_channels, 3); assert_eq!(conv.out_channels, 16); assert_eq!(conv.n_modes_h(), 12); assert_eq!(conv.n_modes_w(), 12); } #[test] fn test_spectral_conv2d_weight_shape() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(4, 8, 6, 10, &device); // Both weight matrices should be [in_ch, out_ch, n_modes_h, n_modes_w, 2] assert_eq!(conv.weights1().shape(), [4, 8, 6, 10, 2]); assert_eq!(conv.weights2().shape(), [4, 8, 6, 10, 2]); } #[test] fn test_spectral_conv2d_weight_initialization() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(3, 12, 8, 8, &device); // Check Xavier initialization bounds for both weight matrices let scale = (2.0_f32 / (3.0_f32 + 12.0_f32)).sqrt(); for weights in [conv.weights1(), conv.weights2()] { let data = weights.to_vec(); // All values should be in range [-scale, scale] for &val in &data { assert!( val >= -scale && val <= scale, "Value {} outside Xavier range [{}, {}]", val, -scale, scale ); } } } #[test] fn test_spectral_conv2d_forward_shape() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(4, 8, 12, 12, &device); let input = GenericTensor::randn([2, 4, 64, 64], &device); let output = conv.forward_4d(&input); // Output should have shape [batch, out_channels, height, width] assert_eq!(output.shape(), [2, 8, 64, 64]); } #[test] fn test_spectral_conv2d_preserves_spatial_dims() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(1, 32, 8, 8, &device); let input = GenericTensor::randn([4, 1, 32, 48], &device); let output = conv.forward_4d(&input); // Spatial dimensions should be preserved assert_eq!(output.shape()[2], 32); // height assert_eq!(output.shape()[3], 48); // width } #[test] fn test_spectral_conv2d_different_modes() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(2, 4, 6, 10, &device); let input = GenericTensor::randn([1, 2, 64, 64], &device); let output = conv.forward_4d(&input); // Output channels should match conv.out_channels assert_eq!(output.shape(), [1, 4, 64, 64]); } #[test] fn test_spectral_conv2d_batch_independence() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(3, 6, 8, 8, &device); // Single sample let input1 = GenericTensor::randn([1, 3, 32, 32], &device); let output1 = conv.forward_4d(&input1); // Batch of 4 let input4 = GenericTensor::randn([4, 3, 32, 32], &device); let output4 = conv.forward_4d(&input4); // Batch dimension should be the only difference assert_eq!(output1.shape()[1..], output4.shape()[1..]); } #[test] fn test_spectral_conv2d_mode_truncation() { let device = CpuDevice::new(); // Small mode count (strong low-pass filtering) let conv_small = SpectralConv2d::::new(1, 1, 4, 4, &device); // Large mode count (weak filtering) let conv_large = SpectralConv2d::::new(1, 1, 30, 30, &device); let input = GenericTensor::randn([2, 1, 64, 64], &device); let output_small = conv_small.forward_4d(&input); let output_large = conv_large.forward_4d(&input); // Both should preserve spatial dimensions assert_eq!(output_small.shape(), [2, 1, 64, 64]); assert_eq!(output_large.shape(), [2, 1, 64, 64]); } #[test] #[should_panic(expected = "Use forward_4d for 2D spectral convolution")] fn test_spectral_conv2d_forward_panics() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(2, 4, 8, 8, &device); let input = GenericTensor::randn([4, 2], &device); let _ = conv.forward(&input); } // ==================== forward_4d Implementation Tests ==================== #[test] fn test_forward_4d_output_channels() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(3, 8, 12, 12, &device); let input = GenericTensor::randn([2, 3, 64, 64], &device); let output = conv.forward_4d(&input); // Output should transform from in_channels to out_channels assert_eq!(output.shape(), [2, 8, 64, 64]); } #[test] fn test_forward_4d_non_zero_output() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(2, 4, 8, 8, &device); // Create non-zero input let input = GenericTensor::ones([1, 2, 32, 32], &device); let output = conv.forward_4d(&input); // FFT-based transformation should produce non-zero output let output_data = output.to_vec(); let non_zero_count = output_data.iter().filter(|&&x| x.abs() > 1e-6).count(); assert!( non_zero_count > 0, "FFT transformation should produce non-zero outputs" ); } #[test] fn test_forward_4d_complex_multiplication() { let device = CpuDevice::new(); // Use small modes for easier verification let conv = SpectralConv2d::::new(1, 1, 4, 4, &device); // Create a simple input pattern (constant) let input = GenericTensor::ones([1, 1, 16, 16], &device); let output = conv.forward_4d(&input); // Output shape should be preserved assert_eq!(output.shape(), [1, 1, 16, 16]); // The output should be different from input (due to complex multiplication) let input_data = input.to_vec(); let output_data = output.to_vec(); let mut differences = 0; for (inp, out) in input_data.iter().zip(output_data.iter()) { if (inp - out).abs() > 1e-5 { differences += 1; } } assert!( differences > 0, "Complex multiplication in frequency domain should modify values" ); } #[test] fn test_forward_4d_multiple_batches() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(2, 3, 8, 8, &device); let input = GenericTensor::randn([5, 2, 32, 32], &device); let output = conv.forward_4d(&input); // Should handle multiple batches correctly assert_eq!(output.shape(), [5, 3, 32, 32]); } #[test] fn test_forward_4d_different_spatial_sizes() { let device = CpuDevice::new(); let conv = SpectralConv2d::::new(1, 2, 4, 6, &device); // Non-square spatial dimensions let input = GenericTensor::randn([1, 1, 24, 36], &device); let output = conv.forward_4d(&input); // Spatial dimensions should be preserved assert_eq!(output.shape(), [1, 2, 24, 36]); } }