Files
optimiz-rs/python/optimizr/core.py
T

368 lines
11 KiB
Python
Raw Normal View History

"""
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,
)
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:
samples = _rust_mcmc_sample(
log_likelihood_fn=log_likelihood_fn,
data=data.tolist(),
initial_params=initial_params.tolist(),
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: float = 0.8,
cr: float = 0.7,
) -> 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, default=0.8
Mutation factor (typically 0.5-2.0)
cr : float, default=0.7
Crossover probability (typically 0.1-0.9)
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,
... popsize=15,
... 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,
)
return np.array(result.x), result.fun
else:
# Pure Python fallback (scipy)
try:
from scipy.optimize import differential_evolution as scipy_de
result = scipy_de(objective_fn, bounds=bounds, maxiter=maxiter,
popsize=popsize, mutation=f, recombination=cr)
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))