16 files: doc backticks (60+), const fn (20+), safety comments (25+), needless borrows, div_ceil, dead fields, split multi-op unsafe blocks, let..else patterns, else-if-without-else, redundant casts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
405 lines
14 KiB
Rust
405 lines
14 KiB
Rust
//! Dueling Q-Network Architecture
|
|
//!
|
|
//! Implements the Dueling DQN architecture from "Dueling Network Architectures for Deep
|
|
//! Reinforcement Learning" (Wang et al., 2016).
|
|
//!
|
|
//! ## Architecture
|
|
//!
|
|
//! ```text
|
|
//! State [state_dim] -> Shared Features [hidden_dim]
|
|
//! | |
|
|
//! Value V(s) Advantage A(s,a)
|
|
//! [1 scalar] [num_actions]
|
|
//! | |
|
|
//! Q(s,a) = V(s) + A(s,a) - mean(A(s,.))
|
|
//! ```
|
|
//!
|
|
//! ## Key Features
|
|
//!
|
|
//! - **Separate Value/Advantage Streams**: Decomposes Q-values into state value and advantage
|
|
//! - **Mean Subtraction**: Ensures identifiability (zero mean advantage)
|
|
//! - **Improved Learning**: Better gradient flow and sample efficiency (+10-20%)
|
|
//! - **Numerical Stability**: Xavier initialization for all layers
|
|
//!
|
|
//! ## Mathematical Formulation
|
|
//!
|
|
//! `Q(s,a) = V(s) + [A(s,a) - (1/|A|) * sum_a' A(s,a')]`
|
|
//!
|
|
//! Where:
|
|
//! - V(s): State value function (scalar)
|
|
//! - A(s,a): Advantage function (per-action)
|
|
//! - mean(A(s,.)): Average advantage across all actions (ensures zero mean)
|
|
|
|
use std::sync::Arc;
|
|
|
|
use cudarc::cublas::CudaBlas;
|
|
use cudarc::driver::CudaStream;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use ml_core::cuda_autograd::{GpuLinear, GpuTensor, GpuVarStore};
|
|
use ml_core::MLError;
|
|
|
|
/// Configuration for Dueling Q-Network
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DuelingConfig {
|
|
/// Input state dimension
|
|
pub state_dim: usize,
|
|
/// Number of actions
|
|
pub num_actions: usize,
|
|
/// Shared feature layer dimensions
|
|
pub shared_hidden_dims: Vec<usize>,
|
|
/// Value stream hidden dimension
|
|
pub value_hidden_dim: usize,
|
|
/// Advantage stream hidden dimension
|
|
pub advantage_hidden_dim: usize,
|
|
/// `LeakyReLU` negative slope
|
|
pub leaky_relu_alpha: f64,
|
|
/// Dropout rate applied after shared layer activations (0.0 = no dropout)
|
|
#[serde(default)]
|
|
pub dropout_rate: f64,
|
|
}
|
|
|
|
impl DuelingConfig {
|
|
/// Create default dueling config from basic parameters
|
|
pub const fn new(
|
|
state_dim: usize,
|
|
num_actions: usize,
|
|
shared_hidden_dims: Vec<usize>,
|
|
value_hidden_dim: usize,
|
|
advantage_hidden_dim: usize,
|
|
) -> Self {
|
|
Self {
|
|
state_dim,
|
|
num_actions,
|
|
shared_hidden_dims,
|
|
value_hidden_dim,
|
|
advantage_hidden_dim,
|
|
leaky_relu_alpha: 0.01,
|
|
dropout_rate: 0.0,
|
|
}
|
|
}
|
|
|
|
/// Create from `DQNConfig` parameters
|
|
///
|
|
/// All `hidden_dims` become shared feature layers. The value and advantage
|
|
/// heads branch off after the last shared layer, each with its own
|
|
/// `dueling_hidden_dim`-wide hidden layer. This matches the CUDA
|
|
/// experience-collection kernel which hardcodes 2 shared layers.
|
|
pub fn from_dqn_params(
|
|
state_dim: usize,
|
|
num_actions: usize,
|
|
hidden_dims: &[usize],
|
|
dueling_hidden_dim: usize,
|
|
leaky_relu_alpha: f64,
|
|
) -> Self {
|
|
Self {
|
|
state_dim,
|
|
num_actions,
|
|
shared_hidden_dims: hidden_dims.to_vec(),
|
|
value_hidden_dim: dueling_hidden_dim,
|
|
advantage_hidden_dim: dueling_hidden_dim,
|
|
leaky_relu_alpha,
|
|
dropout_rate: 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dueling Q-Network with separate value and advantage streams.
|
|
///
|
|
/// Weights stored in `GpuVarStore` with `GpuLinear` layers using cuBLAS sgemm.
|
|
/// Forward pass runs entirely on GPU (cold path downloads to host for activation/mean ops).
|
|
#[allow(missing_debug_implementations)]
|
|
pub struct DuelingQNetwork {
|
|
/// Shared feature extraction layers
|
|
shared_layers: Vec<GpuLinear>,
|
|
|
|
/// Value stream layers
|
|
value_fc: GpuLinear,
|
|
value_out: GpuLinear, // Output: [batch, 1]
|
|
|
|
/// Advantage stream layers
|
|
advantage_fc: GpuLinear,
|
|
advantage_out: GpuLinear, // Output: [batch, num_actions]
|
|
|
|
/// Configuration
|
|
config: DuelingConfig,
|
|
|
|
/// Native CUDA weight storage
|
|
store: GpuVarStore,
|
|
|
|
/// cuBLAS handle for sgemm
|
|
cublas: CudaBlas,
|
|
|
|
/// CUDA stream
|
|
stream: Arc<CudaStream>,
|
|
}
|
|
|
|
impl DuelingQNetwork {
|
|
/// Create new dueling Q-network
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `config` - Dueling network configuration
|
|
/// * `stream` - CUDA stream for all operations
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// New `DuelingQNetwork` instance with Xavier-initialized weights
|
|
pub fn new(config: DuelingConfig, stream: Arc<CudaStream>) -> Result<Self, MLError> {
|
|
let mut store = GpuVarStore::new(Arc::clone(&stream));
|
|
|
|
// Build shared feature layers
|
|
let mut shared_layers = Vec::new();
|
|
let mut current_dim = config.state_dim;
|
|
|
|
for (i, &hidden_dim) in config.shared_hidden_dims.iter().enumerate() {
|
|
let layer = store.linear(
|
|
&format!("shared_{i}"),
|
|
current_dim,
|
|
hidden_dim,
|
|
)?;
|
|
shared_layers.push(layer);
|
|
current_dim = hidden_dim;
|
|
}
|
|
|
|
// Value stream
|
|
let value_fc = store.linear("value_fc", current_dim, config.value_hidden_dim)?;
|
|
let value_out = store.linear("value_out", config.value_hidden_dim, 1)?;
|
|
|
|
// Advantage stream
|
|
let advantage_fc = store.linear("advantage_fc", current_dim, config.advantage_hidden_dim)?;
|
|
let advantage_out = store.linear("advantage_out", config.advantage_hidden_dim, config.num_actions)?;
|
|
|
|
let cublas = CudaBlas::new(Arc::clone(&stream))
|
|
.map_err(|e| MLError::ModelError(format!("cuBLAS init: {e}")))?;
|
|
|
|
Ok(Self {
|
|
shared_layers,
|
|
value_fc,
|
|
value_out,
|
|
advantage_fc,
|
|
advantage_out,
|
|
config,
|
|
store,
|
|
cublas,
|
|
stream,
|
|
})
|
|
}
|
|
|
|
/// Forward pass through dueling network (cold path).
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `state` - State data as flat f32 slice, shape [`batch_size` * `state_dim`]
|
|
/// * `batch_size` - Number of samples in the batch
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Q-values as flat f32 Vec, shape [`batch_size` * `num_actions`]
|
|
///
|
|
/// # Mathematical Formula
|
|
///
|
|
/// Q(s,a) = V(s) + [A(s,a) - mean(A(s,.))]
|
|
pub fn forward(&self, state: &[f32], batch_size: usize) -> Result<Vec<f32>, MLError> {
|
|
let state_dim = self.config.state_dim;
|
|
let num_actions = self.config.num_actions;
|
|
let alpha = self.config.leaky_relu_alpha as f32;
|
|
|
|
// Upload state to GPU
|
|
let mut h = GpuTensor::from_host(state, vec![batch_size, state_dim], &self.stream)?;
|
|
|
|
// Shared feature extraction with LeakyReLU
|
|
for layer in &self.shared_layers {
|
|
let (out, _acts) = layer.forward(&h, &self.store, &self.cublas, &self.stream)?;
|
|
// LeakyReLU on host (cold path)
|
|
let mut host = out.to_host(&self.stream)?;
|
|
for v in host.iter_mut() {
|
|
if *v < 0.0 { *v *= alpha; }
|
|
}
|
|
h = GpuTensor::from_host(&host, out.shape().to_vec(), &self.stream)?;
|
|
}
|
|
|
|
// Value stream: V(s) -> [batch, 1]
|
|
let (v_hidden, _) = self.value_fc.forward(&h, &self.store, &self.cublas, &self.stream)?;
|
|
let mut v_host = v_hidden.to_host(&self.stream)?;
|
|
for v in v_host.iter_mut() { if *v < 0.0 { *v *= alpha; } }
|
|
let v_gpu = GpuTensor::from_host(&v_host, vec![batch_size, self.config.value_hidden_dim], &self.stream)?;
|
|
let (v_out_gpu, _) = self.value_out.forward(&v_gpu, &self.store, &self.cublas, &self.stream)?;
|
|
let v_out = v_out_gpu.to_host(&self.stream)?; // [batch_size * 1]
|
|
|
|
// Advantage stream: A(s,a) -> [batch, num_actions]
|
|
let (a_hidden, _) = self.advantage_fc.forward(&h, &self.store, &self.cublas, &self.stream)?;
|
|
let mut a_host = a_hidden.to_host(&self.stream)?;
|
|
for v in a_host.iter_mut() { if *v < 0.0 { *v *= alpha; } }
|
|
let a_gpu = GpuTensor::from_host(&a_host, vec![batch_size, self.config.advantage_hidden_dim], &self.stream)?;
|
|
let (a_out_gpu, _) = self.advantage_out.forward(&a_gpu, &self.store, &self.cublas, &self.stream)?;
|
|
let a_out = a_out_gpu.to_host(&self.stream)?; // [batch_size * num_actions]
|
|
|
|
// Combine: Q(s,a) = V(s) + A(s,a) - mean(A(s,.))
|
|
let mut q_values = vec![0.0_f32; batch_size * num_actions];
|
|
for b in 0..batch_size {
|
|
let v = v_out[b]; // V(s) scalar for this sample
|
|
|
|
// Compute mean advantage for this sample
|
|
let a_start = b * num_actions;
|
|
let a_slice = &a_out[a_start..a_start + num_actions];
|
|
let a_mean: f32 = a_slice.iter().sum::<f32>() / num_actions as f32;
|
|
|
|
// Q(s,a) = V(s) + A(s,a) - mean(A)
|
|
for a in 0..num_actions {
|
|
q_values[b * num_actions + a] = v + a_slice[a] - a_mean;
|
|
}
|
|
}
|
|
|
|
Ok(q_values)
|
|
}
|
|
|
|
/// Forward pass for a single state (convenience).
|
|
pub fn forward_single(&self, state: &[f32]) -> Result<Vec<f32>, MLError> {
|
|
self.forward(state, 1)
|
|
}
|
|
|
|
/// Get `GpuVarStore` for weight serialization
|
|
pub const fn store(&self) -> &GpuVarStore {
|
|
&self.store
|
|
}
|
|
|
|
/// Get mutable `GpuVarStore` for weight updates
|
|
pub const fn store_mut(&mut self) -> &mut GpuVarStore {
|
|
&mut self.store
|
|
}
|
|
|
|
/// Get configuration
|
|
pub const fn config(&self) -> &DuelingConfig {
|
|
&self.config
|
|
}
|
|
|
|
/// Copy weights from another dueling network
|
|
pub fn copy_weights_from(&mut self, other: &DuelingQNetwork) -> Result<(), MLError> {
|
|
let exported = other.store.export_to_host()?; // gpu-exit: checkpoint weight export
|
|
self.store.import_from_host(&exported)
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::items_after_test_module)]
|
|
#[cfg(test)]
|
|
#[allow(clippy::unnecessary_wraps)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_stream() -> Arc<CudaStream> {
|
|
cudarc::driver::CudaContext::new(0)
|
|
.expect("CUDA required")
|
|
.new_stream()
|
|
.expect("CUDA stream")
|
|
}
|
|
|
|
#[test]
|
|
fn test_dueling_network_creation() -> anyhow::Result<()> {
|
|
let config = DuelingConfig::new(
|
|
32, // state_dim
|
|
5, // num_actions (5 exposure levels)
|
|
vec![256, 128], // shared_hidden_dims
|
|
64, // value_hidden_dim
|
|
64, // advantage_hidden_dim
|
|
);
|
|
|
|
let network = DuelingQNetwork::new(config, make_stream())?;
|
|
|
|
assert_eq!(network.shared_layers.len(), 2);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_dueling_forward_pass() -> anyhow::Result<()> {
|
|
let config = DuelingConfig::new(32, 5, vec![256, 128], 64, 64);
|
|
let stream = make_stream();
|
|
let network = DuelingQNetwork::new(config, Arc::clone(&stream))?;
|
|
|
|
// Create batch of states
|
|
let batch_size = 4;
|
|
let state_data: Vec<f32> = (0..batch_size * 32).map(|i| (i as f32 * 0.01).sin()).collect();
|
|
|
|
// Forward pass
|
|
let q_values = network.forward(&state_data, batch_size)?;
|
|
|
|
// Check output length
|
|
assert_eq!(q_values.len(), batch_size * 5);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_dueling_mean_subtraction() -> anyhow::Result<()> {
|
|
// Test that mean(A) is correctly subtracted, ensuring zero-mean advantage
|
|
let config = DuelingConfig::new(4, 3, vec![8], 4, 4);
|
|
let stream = make_stream();
|
|
let network = DuelingQNetwork::new(config, Arc::clone(&stream))?;
|
|
|
|
// Simple state
|
|
let state = vec![1.0_f32; 4];
|
|
|
|
// Forward pass
|
|
let q_values = network.forward_single(&state)?;
|
|
|
|
// Q-values should be valid (no NaN/Inf)
|
|
for &q in &q_values {
|
|
assert!(q.is_finite(), "Q-value should be finite, got {}", q);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_dueling_from_dqn_params() -> anyhow::Result<()> {
|
|
let config = DuelingConfig::from_dqn_params(
|
|
32, // state_dim
|
|
5, // num_actions (5 exposure levels)
|
|
&[256, 128, 64], // hidden_dims
|
|
64, // dueling_hidden_dim
|
|
0.01, // leaky_relu_alpha
|
|
);
|
|
|
|
assert_eq!(config.state_dim, 32);
|
|
assert_eq!(config.num_actions, 5);
|
|
assert_eq!(config.shared_hidden_dims, vec![256, 128, 64]); // All hidden dims become shared layers
|
|
assert_eq!(config.value_hidden_dim, 64);
|
|
assert_eq!(config.advantage_hidden_dim, 64);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_dueling_weight_copy() -> anyhow::Result<()> {
|
|
let config = DuelingConfig::new(8, 3, vec![16], 8, 8);
|
|
let stream = make_stream();
|
|
|
|
let network1 = DuelingQNetwork::new(config.clone(), Arc::clone(&stream))?;
|
|
let mut network2 = DuelingQNetwork::new(config, Arc::clone(&stream))?;
|
|
|
|
// Copy weights
|
|
network2.copy_weights_from(&network1)?;
|
|
|
|
// Verify same output for same input
|
|
let state = vec![1.0_f32; 8];
|
|
let q1 = network1.forward_single(&state)?;
|
|
let q2 = network2.forward_single(&state)?;
|
|
|
|
for (v1, v2) in q1.iter().zip(q2.iter()) {
|
|
assert!((v1 - v2).abs() < 1e-5, "Q-values should match after copy");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// Manual Debug implementation for DuelingQNetwork
|
|
impl std::fmt::Debug for DuelingQNetwork {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("DuelingQNetwork")
|
|
.field("config", &self.config)
|
|
.field("num_shared_layers", &self.shared_layers.len())
|
|
.finish()
|
|
}
|
|
}
|