Files
optimiz-rs/python/optimizr/core.py
T
Melvin Alvarez 9a8032e4ee feat(timeseries): add time-series integration helpers for financial analysis
- Implement 6 helper functions in src/timeseries_utils.rs:
  * prepare_for_hmm: Feature engineering for HMM regime detection
  * rolling_hurst_exponent: Mean-reversion detection (H < 0.5 = mean-reverting)
  * rolling_half_life: Mean-reversion speed for pairs trading
  * return_statistics: Risk metrics (mean, std, skew, kurt, sharpe)
  * create_lagged_features: ML feature matrix creation
  * rolling_correlation: Rolling correlation for pairs trading

- Add PyO3 bindings in src/timeseries_utils/python_bindings.rs:
  * All functions exposed with _py suffix
  * Proper signature decorators and error handling
  * Registered in lib.rs module system

- Update Python module exports:
  * python/optimizr/core.py: Import from _core
  * python/optimizr/__init__.py: Re-export all functions

- Create comprehensive example:
  * examples/timeseries_integration.py demonstrates all 6 functions
  * Includes integrated pairs trading workflow
  * Shows feature engineering for regime detection

- Technical details:
  * Fixed Array1<f64> type conversions for ndarray compatibility
  * Uses risk_metrics::hurst_exponent and estimate_half_life
  * Built successfully with maturin develop --release (40.93s)
  * All functions tested and working correctly

Part of Priority 3: Time-series integration helpers (Enhancement Strategy)
Addresses v0.3.0 roadmap: Bridge optimization with time-series analysis
2026-01-02 22:13:05 +01:00

430 lines
13 KiB
Python
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.
"""
Core optimization functions with Rust acceleration
"""
import warnings
from typing import Callable, List, Tuple, Optional
import numpy as np
# Try to import Rust backend
try:
from optimizr._core import (
mcmc_sample as _rust_mcmc_sample,
differential_evolution as _rust_differential_evolution,
grid_search as _rust_grid_search,
mutual_information as _rust_mutual_information,
shannon_entropy as _rust_shannon_entropy,
sparse_pca_py,
box_tao_decomposition_py,
elastic_net_py,
hurst_exponent_py,
compute_risk_metrics_py,
estimate_half_life_py,
bootstrap_returns_py,
# Time-series utilities
prepare_for_hmm_py,
rolling_hurst_exponent_py,
rolling_half_life_py,
return_statistics_py,
create_lagged_features_py,
rolling_correlation_py,
)
RUST_AVAILABLE = True
except ImportError:
RUST_AVAILABLE = False
warnings.warn(
"Rust backend not available. Using pure Python fallbacks. "
"Install with 'pip install optimizr' to enable Rust acceleration.",
RuntimeWarning
)
def mcmc_sample(
log_likelihood_fn: Callable[[List[float], List[float]], float],
data: np.ndarray,
initial_params: np.ndarray,
param_bounds: List[Tuple[float, float]],
n_samples: int = 10000,
burn_in: int = 1000,
proposal_std: float = 0.1,
) -> np.ndarray:
"""
MCMC Metropolis-Hastings sampler.
Generates samples from a target distribution using the Metropolis-Hastings
algorithm with Gaussian random walk proposals.
Parameters
----------
log_likelihood_fn : callable
Function that computes log P(data | params). Should accept
(params: list, data: list) and return float.
data : np.ndarray
Observed data (passed to log_likelihood_fn)
initial_params : np.ndarray
Starting parameter values
param_bounds : list of (float, float)
[(min, max), ...] bounds for each parameter
n_samples : int, default=10000
Number of samples to generate (after burn-in)
burn_in : int, default=1000
Number of initial samples to discard
proposal_std : float, default=0.1
Standard deviation of Gaussian proposals
Returns
-------
samples : np.ndarray
Array of shape (n_samples, n_params) with parameter samples
Examples
--------
>>> def log_likelihood(params, data):
... mu, sigma = params
... residuals = (data - mu) / sigma
... return -0.5 * np.sum(residuals**2) - len(data) * np.log(sigma)
>>> data = np.random.randn(100) + 2.0
>>> samples = mcmc_sample(
... log_likelihood_fn=log_likelihood,
... data=data,
... initial_params=np.array([0.0, 1.0]),
... param_bounds=[(-10, 10), (0.1, 10)],
... n_samples=10000,
... burn_in=1000
... )
>>> print(f"Posterior mean: {np.mean(samples[:, 0]):.2f}")
"""
if RUST_AVAILABLE:
# Convert to lists if numpy arrays
data_list = data.tolist() if hasattr(data, 'tolist') else list(data)
params_list = initial_params.tolist() if hasattr(initial_params, 'tolist') else list(initial_params)
samples = _rust_mcmc_sample(
log_likelihood_fn=log_likelihood_fn,
data=data_list,
initial_params=params_list,
param_bounds=param_bounds,
n_samples=n_samples,
burn_in=burn_in,
proposal_std=proposal_std,
)
return np.array(samples)
else:
# Pure Python fallback
return _mcmc_sample_python(
log_likelihood_fn, data, initial_params, param_bounds,
n_samples, burn_in, proposal_std
)
def differential_evolution(
objective_fn: Callable[[np.ndarray], float],
bounds: List[Tuple[float, float]],
popsize: int = 15,
maxiter: int = 1000,
f: Optional[float] = None,
cr: Optional[float] = None,
strategy: str = "rand1",
seed: Optional[int] = None,
tol: float = 1e-6,
atol: float = 1e-8,
track_history: bool = False,
parallel: bool = False,
adaptive: bool = False,
constraint_penalty: float = 1000.0,
) -> Tuple[np.ndarray, float]:
"""
Differential Evolution global optimizer.
Population-based stochastic optimization effective for non-convex,
multimodal objective functions.
Parameters
----------
objective_fn : callable
Function to minimize: f(x) -> float where x is np.ndarray
bounds : list of (float, float)
[(min, max), ...] bounds for each parameter
popsize : int, default=15
Population size multiplier (total size = popsize × n_params)
maxiter : int, default=1000
Maximum number of generations
f : float, optional
Mutation factor (typically 0.5-2.0). If None, uses 0.8
cr : float, optional
Crossover probability (typically 0.1-0.9). If None, uses 0.7
strategy : str, default="rand1"
Mutation strategy: "rand1", "best1", "currenttobest1", "rand2", "best2"
seed : int, optional
Random seed for reproducibility
tol : float, default=1e-6
Convergence tolerance for function value changes
atol : float, default=1e-8
Absolute convergence tolerance
track_history : bool, default=False
Whether to track convergence history
parallel : bool, default=False
Whether to use parallel evaluation (not supported for Python callbacks)
adaptive : bool, default=False
Whether to use adaptive jDE parameter control
constraint_penalty : float, default=1000.0
Penalty for constraint violations
Returns
-------
x : np.ndarray
Best parameters found
fun : float
Best objective value (minimum)
Examples
--------
>>> def rosenbrock(x):
... return sum(100*(x[i+1] - x[i]**2)**2 + (1-x[i])**2
... for i in range(len(x)-1))
>>> result = differential_evolution(
... objective_fn=rosenbrock,
... bounds=[(-5, 5)] * 10,
... strategy="best1",
... adaptive=True,
... maxiter=1000
... )
>>> print(f"Minimum: {result[1]:.6f} at {result[0]}")
"""
if RUST_AVAILABLE:
result = _rust_differential_evolution(
objective_fn=objective_fn,
bounds=bounds,
popsize=popsize,
maxiter=maxiter,
f=f,
cr=cr,
strategy=strategy,
seed=seed,
tol=tol,
atol=atol,
track_history=track_history,
parallel=parallel,
adaptive=adaptive,
constraint_penalty=constraint_penalty,
)
return np.array(result.x), result.fun
else:
# Pure Python fallback (scipy)
try:
from scipy.optimize import differential_evolution as scipy_de
mutation = f if f is not None else 0.8
recombination = cr if cr is not None else 0.7
result = scipy_de(
objective_fn,
bounds=bounds,
maxiter=maxiter,
popsize=popsize,
mutation=mutation,
recombination=recombination,
seed=seed,
tol=tol,
atol=atol
)
return result.x, result.fun
except ImportError:
raise ImportError(
"Rust backend not available and scipy not installed. "
"Install scipy or build OptimizR with Rust support."
)
def grid_search(
objective_fn: Callable[[np.ndarray], float],
bounds: List[Tuple[float, float]],
n_points: int = 10,
) -> Tuple[np.ndarray, float]:
"""
Grid search optimizer.
Exhaustively evaluates objective function at all points on a regular grid.
Parameters
----------
objective_fn : callable
Function to maximize: f(x) -> float where x is np.ndarray
bounds : list of (float, float)
[(min, max), ...] bounds for each parameter
n_points : int, default=10
Number of grid points per dimension
Returns
-------
x : np.ndarray
Best parameters found
fun : float
Best objective value (maximum)
Examples
--------
>>> def objective(x):
... return -(x[0]**2 + x[1]**2) # Peak at (0, 0)
>>> result = grid_search(
... objective_fn=objective,
... bounds=[(-5, 5), (-5, 5)],
... n_points=50
... )
>>> print(f"Maximum: {result[1]:.6f} at {result[0]}")
"""
if RUST_AVAILABLE:
result = _rust_grid_search(
objective_fn=objective_fn,
bounds=bounds,
n_points=n_points,
)
return np.array(result.x), result.fun
else:
# Pure Python fallback
return _grid_search_python(objective_fn, bounds, n_points)
def mutual_information(
x: np.ndarray,
y: np.ndarray,
n_bins: int = 10,
) -> float:
"""
Compute mutual information between two variables.
I(X;Y) = H(X) + H(Y) - H(X,Y)
Measures how much knowing one variable reduces uncertainty about the other.
Parameters
----------
x : np.ndarray
Sample values from first variable
y : np.ndarray
Sample values from second variable (must be same length as x)
n_bins : int, default=10
Number of bins for histogram estimation
Returns
-------
mi : float
Mutual information in nats (multiply by 1/ln(2) for bits)
Examples
--------
>>> x = np.random.randn(10000)
>>> y = 2 * x + np.random.randn(10000) * 0.5
>>> mi = mutual_information(x, y, n_bins=20)
>>> print(f"MI: {mi:.4f} nats")
"""
if RUST_AVAILABLE:
return _rust_mutual_information(x.tolist(), y.tolist(), n_bins=n_bins)
else:
# Pure Python fallback
return _mutual_information_python(x, y, n_bins)
def shannon_entropy(
x: np.ndarray,
n_bins: int = 10,
) -> float:
"""
Compute Shannon entropy of a variable.
H(X) = -Σ p(x) log(p(x))
Quantifies the uncertainty/information content of a random variable.
Parameters
----------
x : np.ndarray
Sample values from the variable
n_bins : int, default=10
Number of bins for histogram estimation
Returns
-------
entropy : float
Shannon entropy in nats (multiply by 1/ln(2) for bits)
Examples
--------
>>> x_uniform = np.random.uniform(0, 1, 10000)
>>> h_uniform = shannon_entropy(x_uniform, n_bins=20)
>>> x_peaked = np.random.normal(0, 0.1, 10000)
>>> h_peaked = shannon_entropy(x_peaked, n_bins=20)
>>> print(f"Uniform: {h_uniform:.4f}, Peaked: {h_peaked:.4f}")
"""
if RUST_AVAILABLE:
return _rust_shannon_entropy(x.tolist(), n_bins=n_bins)
else:
# Pure Python fallback
return _shannon_entropy_python(x, n_bins)
# Pure Python fallback implementations
def _mcmc_sample_python(log_likelihood_fn, data, initial_params, param_bounds,
n_samples, burn_in, proposal_std):
"""Pure Python MCMC implementation"""
current_params = initial_params.copy()
samples = []
current_ll = log_likelihood_fn(current_params.tolist(), data.tolist())
for _ in range(n_samples + burn_in):
# Propose
proposed = current_params + np.random.randn(len(current_params)) * proposal_std
for i, (low, high) in enumerate(param_bounds):
proposed[i] = np.clip(proposed[i], low, high)
# Accept/reject
proposed_ll = log_likelihood_fn(proposed.tolist(), data.tolist())
if np.log(np.random.rand()) < proposed_ll - current_ll:
current_params = proposed
current_ll = proposed_ll
if len(samples) >= burn_in:
samples.append(current_params.copy())
return np.array(samples)
def _grid_search_python(objective_fn, bounds, n_points):
"""Pure Python grid search implementation"""
n_params = len(bounds)
grids = [np.linspace(low, high, n_points) for low, high in bounds]
best_params = None
best_score = float('-inf')
import itertools
for point in itertools.product(*grids):
score = objective_fn(np.array(point))
if score > best_score:
best_score = score
best_params = np.array(point)
return best_params, best_score
def _mutual_information_python(x, y, n_bins):
"""Pure Python MI implementation"""
hist_2d, x_edges, y_edges = np.histogram2d(x, y, bins=n_bins)
pxy = hist_2d / np.sum(hist_2d)
px = np.sum(pxy, axis=1)
py = np.sum(pxy, axis=0)
px_py = px[:, None] * py[None, :]
# Only compute where both are nonzero
nonzero = (pxy > 0) & (px_py > 0)
mi = np.sum(pxy[nonzero] * np.log(pxy[nonzero] / px_py[nonzero]))
return max(0.0, mi)
def _shannon_entropy_python(x, n_bins):
"""Pure Python entropy implementation"""
hist, _ = np.histogram(x, bins=n_bins)
probs = hist[hist > 0] / np.sum(hist)
return -np.sum(probs * np.log(probs))