Files
foxhunt/crates/ml/src/features/sample_weights.rs
jgrusewski 7d1b0f232f refactor(ml): move core types to ml-core + dedup MLError (5a)
Move all inline type definitions from ml/src/lib.rs to ml-core:
MLError, MLResult, Trade, MarketRegime, HealthStatus, Features,
MLModel trait, ModelRegistry, ParallelExecutor, LatencyOptimizer,
TrainingMetrics, ValidationMetrics, InferenceResult, ModelMetadata.

Dedup: consolidate ConfigError{reason}/ConfigurationError(msg) into
single ConfigError(String) tuple variant (was 2 variants, 174 refs).

Cleanup: convert create_hft_* free functions to associated methods
(HFTPerformanceProfile::ultra_low_latency(), ParallelExecutor::hft()).

ml facade re-exports via `pub use ml_core::*`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:13:41 +01:00

363 lines
11 KiB
Rust

//! Sample Weights Calculator
//!
//! Implements sample weighting for addressing label imbalance and temporal decay,
//! based on MLFinLab methodology to reduce overfitting.
//!
//! ## Weighting Schemes
//!
//! 1. **Temporal Decay**: Recent samples weighted higher using exponential decay
//! - Weight(t) = decay_factor^(days_old)
//! - Typical decay_factor: 0.95 per day
//!
//! 2. **Label Balancing**: Balance class distribution
//! - Weight(label) = 1 / count(label)
//! - Prevents model from favoring majority class
//!
//! 3. **Combined**: Both temporal decay and label balancing
//! - Weight = temporal_weight * balance_weight
//!
//! ## Usage
//!
//! ```rust
//! use ml::features::sample_weights::{SampleWeightCalculator, WeightingScheme};
//! use ml::labeling::meta_labeling::primary_model::Label;
//! use chrono::Utc;
//!
//! let calculator = SampleWeightCalculator::new(
//! 0.95, // decay_factor
//! WeightingScheme::Combined, // scheme
//! );
//!
//! let labels = vec![Label::Buy, Label::Sell, Label::Hold];
//! let timestamps = vec![Utc::now(); 3];
//!
//! let weights = calculator.calculate(&labels, &timestamps)?;
//! // weights sum to 1.0, ready for model training
//! ```
use crate::labeling::meta_labeling::primary_model::Label;
use crate::MLError;
use chrono::{DateTime, Utc};
use std::collections::HashMap;
/// Weighting scheme for sample weight calculation
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeightingScheme {
/// Only temporal decay (recent samples weighted higher)
TemporalDecay,
/// Only label balancing (balance class distribution)
LabelBalancing,
/// Both temporal decay and label balancing
Combined,
}
/// Sample weight calculator for ML training
///
/// Computes sample weights to address:
/// - Label imbalance (buy/sell/hold distribution)
/// - Temporal decay (recent samples more relevant)
/// - Numerical stability (normalized weights sum to 1.0)
#[derive(Debug, Clone)]
pub struct SampleWeightCalculator {
/// Exponential decay factor per day (typically 0.95)
/// - decay_factor < 1.0: past weighted less (typical)
/// - decay_factor = 1.0: no temporal weighting
/// - decay_factor > 1.0: future weighted less (unusual but valid)
decay_factor: f64,
/// Weighting scheme to apply
scheme: WeightingScheme,
}
impl SampleWeightCalculator {
/// Create a new sample weight calculator
///
/// # Arguments
///
/// * `decay_factor` - Exponential decay per day (typically 0.95)
/// * `scheme` - Weighting scheme to apply
///
/// # Example
///
/// ```rust
/// use ml::features::sample_weights::{SampleWeightCalculator, WeightingScheme};
///
/// let calculator = SampleWeightCalculator::new(0.95, WeightingScheme::Combined);
/// ```
pub fn new(decay_factor: f64, scheme: WeightingScheme) -> Self {
Self {
decay_factor,
scheme,
}
}
/// Calculate sample weights
///
/// # Arguments
///
/// * `labels` - Label for each sample (Buy/Sell/Hold)
/// * `timestamps` - Timestamp for each sample
///
/// # Returns
///
/// Vector of weights normalized to sum to 1.0, or error if inputs are invalid
///
/// # Errors
///
/// - Empty inputs
/// - Mismatched lengths
/// - Invalid decay factor (0.0 or negative)
///
/// # Example
///
/// ```rust
/// use ml::features::sample_weights::{SampleWeightCalculator, WeightingScheme};
/// use ml::labeling::meta_labeling::primary_model::Label;
/// use chrono::Utc;
///
/// let calculator = SampleWeightCalculator::new(0.95, WeightingScheme::Combined);
/// let labels = vec![Label::Buy, Label::Sell, Label::Hold];
/// let timestamps = vec![Utc::now(); 3];
///
/// let weights = calculator.calculate(&labels, &timestamps)?;
/// assert!((weights.iter().sum::<f64>() - 1.0).abs() < 1e-6);
/// # Ok::<(), ml::MLError>(())
/// ```
pub fn calculate(
&self,
labels: &[Label],
timestamps: &[DateTime<Utc>],
) -> Result<Vec<f64>, MLError> {
// Validate inputs
if labels.is_empty() || timestamps.is_empty() {
return Err(MLError::ConfigError("Labels and timestamps cannot be empty".to_owned()));
}
if labels.len() != timestamps.len() {
return Err(MLError::ConfigError(format!(
"Labels length ({}) must match timestamps length ({})",
labels.len(),
timestamps.len()
)));
}
if self.decay_factor <= 0.0 {
return Err(MLError::ConfigError(format!("Decay factor must be positive, got {}", self.decay_factor)));
}
let n = labels.len();
// Initialize weights to 1.0
let mut weights = vec![1.0; n];
// Apply temporal decay if needed
if matches!(
self.scheme,
WeightingScheme::TemporalDecay | WeightingScheme::Combined
) {
self.apply_temporal_decay(&mut weights, timestamps)?;
}
// Apply label balancing if needed
if matches!(
self.scheme,
WeightingScheme::LabelBalancing | WeightingScheme::Combined
) {
self.apply_label_balancing(&mut weights, labels)?;
}
// Normalize weights to sum to 1.0
self.normalize_weights(&mut weights)?;
Ok(weights)
}
/// Apply temporal decay to weights
fn apply_temporal_decay(
&self,
weights: &mut [f64],
timestamps: &[DateTime<Utc>],
) -> Result<(), MLError> {
// Find the latest timestamp (most recent)
let latest_time = timestamps
.iter()
.max()
.ok_or_else(|| MLError::ConfigError("No timestamps provided".to_owned()))?;
// Apply exponential decay based on age in days
for (weight, timestamp) in weights.iter_mut().zip(timestamps.iter()) {
let duration = *latest_time - *timestamp;
let days_old = duration.num_days() as f64;
// Weight = decay_factor^days_old
// For decay_factor = 0.95, this means:
// - 1 day old: weight = 0.95
// - 2 days old: weight = 0.95^2 = 0.9025
// - 30 days old: weight = 0.95^30 ≈ 0.215
let decay_weight = self.decay_factor.powf(days_old);
*weight *= decay_weight;
}
Ok(())
}
/// Apply label balancing to weights
fn apply_label_balancing(&self, weights: &mut [f64], labels: &[Label]) -> Result<(), MLError> {
// Count occurrences of each label
let mut label_counts: HashMap<Label, usize> = HashMap::new();
for label in labels {
*label_counts.entry(*label).or_insert(0) += 1;
}
// Apply inverse frequency weighting
// Weight = 1 / count(label)
// This ensures that:
// - Rare labels get higher weight
// - Common labels get lower weight
// - Total weight per class is approximately equal
for (weight, label) in weights.iter_mut().zip(labels.iter()) {
let count = label_counts
.get(label)
.ok_or_else(|| MLError::ConfigError(format!("Label {:?} not found in counts", label)))?;
let balance_factor = 1.0 / (*count as f64);
*weight *= balance_factor;
}
Ok(())
}
/// Normalize weights to sum to 1.0
fn normalize_weights(&self, weights: &mut [f64]) -> Result<(), MLError> {
let sum: f64 = weights.iter().sum();
if sum <= 0.0 {
return Err(MLError::ConfigError(format!("Weight sum must be positive, got {}", sum)));
}
// Normalize: divide each weight by the sum
for weight in weights.iter_mut() {
*weight /= sum;
}
Ok(())
}
/// Get the decay factor
pub fn decay_factor(&self) -> f64 {
self.decay_factor
}
/// Get the weighting scheme
pub fn scheme(&self) -> WeightingScheme {
self.scheme
}
}
impl Default for SampleWeightCalculator {
fn default() -> Self {
Self {
decay_factor: 0.95,
scheme: WeightingScheme::Combined,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn test_basic_creation() {
let calculator = SampleWeightCalculator::new(0.95, WeightingScheme::Combined);
assert_eq!(calculator.decay_factor(), 0.95);
assert_eq!(calculator.scheme(), WeightingScheme::Combined);
}
#[test]
fn test_default() {
let calculator = SampleWeightCalculator::default();
assert_eq!(calculator.decay_factor(), 0.95);
assert_eq!(calculator.scheme(), WeightingScheme::Combined);
}
#[test]
fn test_single_sample() {
let calculator = SampleWeightCalculator::new(0.95, WeightingScheme::Combined);
let labels = vec![Label::Buy];
let timestamps = vec![Utc::now()];
let weights = calculator.calculate(&labels, &timestamps).unwrap();
assert_eq!(weights.len(), 1);
assert!((weights[0] - 1.0).abs() < 1e-10);
}
#[test]
fn test_temporal_decay_monotonic() {
let calculator = SampleWeightCalculator::new(0.95, WeightingScheme::TemporalDecay);
// Create samples with increasing age
let labels = vec![Label::Buy; 5];
let base_time = Utc::now();
let timestamps: Vec<DateTime<Utc>> =
(0..5).map(|i| base_time - Duration::days(i)).collect();
let weights = calculator.calculate(&labels, &timestamps).unwrap();
// Weights should be monotonically decreasing as samples get older
for i in 0..weights.len() - 1 {
assert!(
weights[i] >= weights[i + 1],
"Weight[{}] = {} should be >= Weight[{}] = {}",
i,
weights[i],
i + 1,
weights[i + 1]
);
}
}
#[test]
fn test_label_balancing_effect() {
let calculator = SampleWeightCalculator::new(1.0, WeightingScheme::LabelBalancing);
// 3 Buy, 1 Sell, 1 Hold
let labels = vec![Label::Buy, Label::Buy, Label::Buy, Label::Sell, Label::Hold];
let timestamps = vec![Utc::now(); 5];
let weights = calculator.calculate(&labels, &timestamps).unwrap();
// Sell and Hold should have higher weights than Buy
assert!(weights[3] > weights[0]); // Sell > Buy
assert!(weights[4] > weights[0]); // Hold > Buy
}
#[test]
fn test_normalization() {
let schemes = vec![
WeightingScheme::TemporalDecay,
WeightingScheme::LabelBalancing,
WeightingScheme::Combined,
];
for scheme in schemes {
let calculator = SampleWeightCalculator::new(0.95, scheme);
let labels = vec![Label::Buy, Label::Sell, Label::Hold];
let timestamps = vec![Utc::now(); 3];
let weights = calculator.calculate(&labels, &timestamps).unwrap();
let sum: f64 = weights.iter().sum();
assert!(
(sum - 1.0).abs() < 1e-10,
"Weights should sum to 1.0 for scheme {:?}, got {}",
scheme,
sum
);
}
}
}