Global optimization¶
The v0.37.0 artifacts include only differential evolution. Particle swarm, hybrid annealing and firefly require a newer coordinated release or a source checkout.
Differential evolution (DE), particle swarm (PSO) and firefly are seeded,
box-bounded population searches, available in Rust, C, Go and Python. They live in
finance-independent itofin-optimize; existing binding artifacts include it
without another optimizer installation.
Hybrid simulated annealing adds a single-chain search with local coordinate
polling. All four share bounds, budgets and result types, while retaining
separate update rules and controls. Node is not implemented.
Two entry points¶
| Need | API | Objective |
|---|---|---|
| Standalone minimization | Rust minimize, Python itofin.optimize.minimize, Go Minimize, context-free C optimizer |
Signed scalar f(x), including negative values |
| Existing model calibration | Rust OptimizationMethod, Python itofin.optimization.{DifferentialEvolution, ParticleSwarm, HybridSimulatedAnnealing, Firefly}, Go Session.NewDifferentialEvolution / Session.NewParticleSwarm / Session.NewHybridSimulatedAnnealing / Session.NewFirefly |
Existing cost function's scalar value |
The standalone API rejects an out-of-box x0. The calibration adapter instead
clamps initially valid model parameters into the effective search box before
starting; subsequent candidates still require the model constraint check.
The standalone API does not turn a scalar into a residual or minimize its
absolute value. The default QuantLib-style cost function uses residual RMS;
model calibration overrides it with sqrt(sum(weight[i] * error[i]²)). The
adapter calls that existing scalar operation directly, preserving its semantics.
This is root-RSS, not the unrooted sum of squared residuals.
Search contract¶
Population settings¶
| Setting | Meaning / default |
|---|---|
| Bounds | One finite ordered pair per coordinate; finite interval width required |
x0 |
Finite, inside the box; never silently projected |
seed |
u64, default 0; every value, including zero, is deterministic |
population_size |
Actual member count, not a dimension multiplier; default max(8, 15 * free_dimensions) |
initial_population |
Optional full population; its row count supplies an omitted size; conflicting size is rejected |
xatol |
Maximum coordinate spread divided by that coordinate's bound width; default 1e-6 |
fatol |
Maximum absolute objective spread; default 1e-8 |
maxiter / maxfev |
Completed generations / actual objective evaluations; defaults 1000 / 1,000,000 |
Explicit tolerances override Rust Common.tol, which otherwise overrides the
listed defaults. Coordinate spread uses actual physical population extrema,
divided by the finite bound width. With xatol=0, each physical coordinate must
have exactly zero spread; rounded normalization alone cannot imply convergence.
Both coordinate and objective spreads must satisfy their tolerances. PSO also
requires every free-coordinate absolute normalized velocity to be at most
xatol. These are heuristic stopping tests, not a global-optimum guarantee.
Differential evolution¶
| Control | Meaning / default |
|---|---|
mutation |
Differential weight F, finite in (0, 2]; default 0.8 |
recombination |
Binomial crossover probability, finite in [0, 1]; default 0.9 |
- Strategy: serial DE/rand/1/bin, with three distinct donors excluding the target. At least one free coordinate crosses over, even with probability zero.
- Every generation reads frozen parent points and scores. Equal objective values accept the trial, an explicit implementation tie rule.
- Out-of-box donor coordinates are uniformly resampled inside their bounds. There is no implicit polishing, parallel execution or general-constraint solver.
Particle swarm¶
| Control | Meaning / default |
|---|---|
inertia |
Previous-velocity coefficient, finite in [0, 1]; default 0.7 |
cognitive |
Personal-best attraction, finite in [0, 4]; default 1.4 |
social |
Global-best attraction, finite in [0, 4]; default 1.4 |
velocity_clamp |
Maximum absolute box-normalized velocity, finite in (0, 1]; default 0.2 |
- Serial, synchronous global-best topology: each generation reads frozen positions, personal bests and the swarm's best. All initial velocities are zero.
- Each free coordinate draws independent uniform
r1, thenr2, and computesv = inertia*v + cognitive*r1*(pbest-x)/width + social*r2*(gbest-x)/width. Velocity is clipped to[-velocity_clamp, velocity_clamp]; position moves in normalized box coordinates. These defaults and policies are ours, not a promise to reproduce another PSO library or the papers' experiments. - Crossing positions are clipped to the exact bound, with outward velocity zeroed there (absorbing bounds). Zero velocity preserves the physical coordinate.
- Personal/global bests update only on strict improvement. Equal values retain the earliest evaluated best. There is no neighborhood topology, inertia schedule, constriction-factor mode, polishing or parallel execution.
- Explicit zero inertia, cognitive or social coefficients are supported. DE controls are rejected for PSO, and PSO controls are rejected for DE.
Firefly¶
| Control | Meaning / default |
|---|---|
alpha |
Initial normalized random-step amplitude, finite in [0, 1]; default 0.25 |
beta0 |
Attraction coefficient at zero distance, finite in (0, 1]; default 1.0 |
gamma |
Normalized squared-distance decay coefficient, finite in [0, 1,000,000]; default 1.0 |
alpha_decay |
Random-step multiplier per completed generation, finite in (0, 1]; default 0.97 |
- Lower scalar cost means brighter. Every generation freezes the old population,
scores and brighter-than relations. For each firefly
i, visit strictly brighter old-generation targetsjin original row order, updatingisequentially. Eligibility stays frozen even if an intermediate move makesibrighter. Equal-score targets do not attract; there is no resorting. - Squared distance sums squared box-scaled physical coordinate differences:
distance² = sum(((target_j-current_i)/width)²)over free coordinates. Attraction usesbeta = beta0 * exp(-gamma * distance²). Each move adds independentalpha_t * (U - 0.5)noise per free coordinate. Boundary crossings reflect into the box, and fixed coordinates stay exact. Every proposal draws one uniform value per free coordinate, including whenalpha=0; unchanged physical proposals consume no objective evaluation. - Fireflies without brighter targets take one random-only move. Every distinct candidate is evaluated, even if it is worse than its starting point. The result separately archives the earliest strict best across all evaluated candidates. Thus calls per generation are not generally equal to population size; all-pairs movement has quadratic population-size cost. Initialization still charges every supplied row, including duplicate points.
alpha_tdecays after each complete generation, before its iteration callback. Termination requires coordinate and objective population spread tolerances plus random-step amplitude<= xatol. This finite-budget test is not a global-optimum guarantee. Zero noise or distance decay is supported; zero attraction is invalid.- No neighborhood mode, parallel evaluation, local polishing or imported runtime optimizer framework is used. DE, PSO and annealing controls are rejected.
Hybrid simulated annealing¶
| Control | Meaning / default |
|---|---|
initial_temperature |
Finite positive temperature in objective units; default 1.0 |
cooling_rate |
Geometric factor, finite in (0, 1); default 0.95 |
step_size |
Uniform proposal half-width divided by box width, finite in (0, 1]; default 0.25 |
local_search_interval |
Cycles between local polls, 1..=1,000,000; default 10 |
local_search_steps |
Maximum coordinate sweeps per local poll, 1..=256; default 4 |
reanneal_interval |
Cycles between temperature resets, 1..=1,000,000; default 100 |
xatol / fatol |
Normalized unsuccessful poll radius / absolute poll-cost spread; defaults 1e-6 / 1e-8 |
- One chain starts at
x0; there is no population. Each free coordinate draws a uniform displacement in[-step_size, step_size]in normalized box space. Boundary-crossing proposals reflect into the box; fixed coordinates stay exact. - Downhill and equal-cost proposals are accepted without another random draw.
An uphill proposal is accepted with probability
exp(-(trial-current)/T). Rescaling an objective therefore changes the meaning of the temperature. - Every local-search interval, poll the best evaluated point in coordinate order, positive then negative. Polls clip to the box, skip unchanged points, and retain strict improvements immediately. This is exploratory coordinate search, not the full Hooke-Jeeves pattern-move algorithm.
- An unsuccessful complete sweep halves the local radius. The radius starts at
step_size, persists across polls and is not reset by reannealing. Local search returns the chain to its best point; reannealing also resets temperature and returns to the best, without discarding that result. - A completed cycle contains the proposal and any scheduled local poll. Its
callback receives the best evaluated point. Cooling follows that callback;
periodic reannealing then resets temperature to
initial_temperature. Cooling is floored at the smallest positivef64, not zero. - Success requires a complete unsuccessful poll with radius
<= xatoland finite observed absolute cost spread<= fatol. This reports local search resolution, not a global optimum or a classical asymptotic annealing guarantee. Explicit tolerances overrideCommon.tol, then the listed defaults apply. Multimodal problems can successfully converge to a non-global local minimum; compare independently known objective values and more than one seed. - DE/PSO population controls are rejected. The same finite box, dimension and budget caps apply; population-size caps do not apply to this single-chain method.
Population initialization and shared limits¶
- Random initialization includes
x0as member zero. An explicit population is validated completely and preserved without insertingx0. - Fixed coordinates retain their exact bound. An all-fixed problem evaluates
x0once, with zero iterations, after validating all options and any explicit population. - Local SplitMix64 sampling needs no third-party optimizer or runtime dependency. Repeated seeds reproduce this implementation, not another library's trajectory.
Resource limits: at most 256 coordinates, 4096 population members and
1,000,000 population coordinates. Population size is at least four. Iteration
and evaluation budgets are capped at 1,000,000 and 10,000,000 respectively.
All four solvers require a finite box and reject general constraints.
Invalid shapes, nonfinite inputs and nonfinite interval widths are rejected before the objective runs. Python jac is unsupported; the
Rust solver never calls Objective::gradient.
Results, callbacks and errors¶
The existing OptimizeResult reports the best evaluated point, its matching
scalar value, status, success, completed generations/cycles (nit), objective
calls (nfev) and zero gradient calls (njev). Exhaustion is not success.
- Initialization consumes objective evaluations, not generations/cycles.
- Partial initialization or a partial generation/cycle can exhaust the evaluation
budget; neither increments
nitnor invokes an iteration callback. Annealing proposal and local-poll evaluations share that same budget. - Callbacks run only after a completed generation/cycle. Cancellation waits for that boundary; it wins over iteration exhaustion at the same boundary.
- Every nonfinite objective return terminates with the nonfinite status. No infinity barrier or penalty value replaces an exception or callback error.
- Python exceptions and Go errors preserve their existing propagation behavior.
Python callback
StopIterationrequests cancellation; Go usescontext.Context. - The C scalar entry point is context-free. Go objectives run outside a Session operation, so they may update quotes and reprice using existing Session APIs. Callback buffers are borrowed only for the duration of the call.
C options and ownership¶
The context-free itofin_optimize_differential_evolution and
itofin_optimize_particle_swarm accept separate bounds arrays and an optional
flattened row-major population. ItofinDifferentialEvolutionOptions and
ItofinParticleSwarmOptions contain ItofinGlobalOptions. Zero population/budget
fields select defaults;
has_xatol, has_fatol, has_mutation and has_recombination distinguish omitted
values from explicit zero. In particular, zero crossover and zero tolerances are
expressible. PSO has corresponding has_inertia, has_cognitive, has_social
and has_velocity_clamp flags; zero attraction coefficients are expressible.
Calibration uses itofin_differential_evolution_new/result or
itofin_particle_swarm_new/result on a Session. Objective release runs exactly
once, including rejected inputs and callback errors; an error return leaves the caller's result buffer untouched.
Firefly uses context-free itofin_optimize_firefly and Session
itofin_firefly_new/result, with ItofinFireflyOptions containing
ItofinGlobalOptions. Its coefficient presence flags preserve explicit values, including supported zeros.
The callback ownership and result-buffer rules above remain unchanged.
Hybrid annealing uses context-free itofin_optimize_hybrid_simulated_annealing
and Session itofin_hybrid_simulated_annealing_new/result. Bounds are passed
as separate arrays; ItofinHybridSimulatedAnnealingOptions carries schedule
controls, not ItofinGlobalOptions or a population. Presence flags distinguish
omitted schedule controls and tolerances from explicit values. An all-zero
record selects defaults. Explicit zero schedule controls are invalid; explicit
zero tolerances are valid. Zero maxiter/maxfev fields select default budgets.
Calibration limits and diagnostics¶
Construct the calibration method with bounds in free/projected parameter order, not full model order when parameters are fixed. Existing model and additional constraints still apply; the effective box intersects their limits.
The entire search box must be feasible for the model. Every candidate is checked before pricing. A violation aborts with an error and existing model rollback, rather than performing rejection sampling or inventing a penalty. Coupled domains such as GJR stationarity or the Heston Feller condition need a carefully chosen box; simple finite bounds alone do not guarantee feasibility.
EndCriteria.max_iterationsalso limits generations/cycles. Each solver's own convergence tolerances apply; the otherEndCriteriatolerances are not substituted.- Rust
last_result(), Pythonlast_result()and GoLastResult()expose exact diagnostics from the last solver invocation. Entering the adapter clears its previous result. Model/binding preflight failures before the adapter runs leave the previous solver diagnostics unchanged; they do not describe the failed fit. - Legacy
EndCriteriaTypehas no evaluation-budget status: that outcome maps toUnknown, while the method's result retainsMaxEvaluations. - Solver
nfevexcludes the model's final residual evaluation. After a completed fit, the model's reported evaluation count normally equals solvernfev + 1.
See the calibration API for method dispatch and existing weights, fixed-parameter masks and constraint arguments.
Paired executable examples¶
Differential evolution¶
The signed quadratic has analytic optimum (1.25, -0.75) and value -3.
Each example checks coordinates within 1e-5 and objective value within 1e-8.
The Python example also fits Hull-White volatility to one swaption, fixing mean
reversion. Its one-dimensional search bound is for free sigma only, and it
checks the calibration cost below 1e-6, rather than inventing a reference sigma.
"""Find a negative scalar minimum with seeded, bounded differential evolution."""
# itofin library
from itofin import Settings
from itofin.indexes import Euribor
from itofin.models import CalibrationErrorType, HullWhite, SwaptionHelper
from itofin.optimization import DifferentialEvolution, EndCriteria
from itofin.optimize import minimize
from itofin.termstructures import FlatForward
from itofin.time import Date, DayCounter, Period
def objective(x) -> float:
"""Return a signed scalar, not a residual or its absolute value."""
return (x[0] - 1.25) ** 2 + 4.0 * (x[1] + 0.75) ** 2 - 3.0
def calibrate_hull_white() -> None:
"""Fit one volatility parameter while keeping mean reversion fixed."""
settings = Settings()
settings.set_evaluation_date(Date(15, 2, 2002))
curve = FlatForward(Date(19, 2, 2002), 0.04875825, DayCounter.actual365_fixed())
index = Euribor.six_months(curve, settings)
model = HullWhite(curve, 0.05, 0.01)
helper = SwaptionHelper(
Period(1, "Years"),
Period(5, "Years"),
0.1148,
index,
Period(1, "Years"),
DayCounter.thirty360_bond_basis(),
DayCounter.actual360(),
curve,
CalibrationErrorType.RelativePriceError,
1.0,
)
method = DifferentialEvolution(
[(0.001, 0.03)],
seed=42,
population_size=8,
xatol=1e-7,
fatol=1e-10,
maxiter=200,
maxfev=2000,
)
criteria = EndCriteria(200, 50, 1e-6, 1e-8, 1e-8)
model.calibrate([helper], method, criteria, True)
result = method.last_result()
assert result is not None
assert result.success, result.message
assert model.a() == 0.05
assert 0.001 <= model.sigma() <= 0.03
assert result.fun < 1e-6
print(f"fixed a={model.a()}, calibrated sigma={model.sigma():.10f}")
print(f"calibration cost={result.fun:.10g}, solver calls={result.nfev}")
def main() -> None:
"""Check the analytic optimum and report actual evaluation counts."""
result = minimize(
objective,
[-3.0, 3.0],
method="Differential-Evolution",
bounds=[(-4.0, 4.0), (-4.0, 4.0)],
options={
"seed": 42,
"population_size": 24,
"xatol": 1e-7,
"fatol": 1e-10,
},
)
assert result.success, result.message
assert abs(result.x[0] - 1.25) < 1e-5
assert abs(result.x[1] + 0.75) < 1e-5
assert abs(result.fun + 3.0) < 1e-8
assert result.njev == 0
print(f"x={result.x}, scalar minimum={result.fun:.10f}")
print(f"generations={result.nit}, objective calls={result.nfev}")
print(result.status, result.message)
calibrate_hull_white()
if __name__ == "__main__":
main()
use std::convert::Infallible;
use itofin_optimize::{
Bounds, Common, DifferentialEvolutionOptions, GlobalOptions, Method, Problem, minimize,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut objective = |x: &[f64]| -> Result<f64, Infallible> {
Ok((x[0] - 1.25).powi(2) + 4.0 * (x[1] + 0.75).powi(2) - 3.0)
};
let problem = Problem {
x0: vec![-3.0, 3.0],
bounds: Some(Bounds {
lower: vec![-4.0, -4.0],
upper: vec![4.0, 4.0],
}),
};
let method = Method::DifferentialEvolution(DifferentialEvolutionOptions {
global: GlobalOptions {
seed: 42,
population_size: Some(24),
xatol: Some(1e-7),
fatol: Some(1e-10),
..GlobalOptions::default()
},
..DifferentialEvolutionOptions::default()
});
let result = minimize(&mut objective, &problem, &method, &Common::default())?;
assert!(result.success, "{}", result.message);
assert!((result.x[0] - 1.25).abs() < 1e-5);
assert!((result.x[1] + 0.75).abs() < 1e-5);
assert!((result.fun + 3.0).abs() < 1e-8);
assert_eq!(result.njev, 0);
println!("x={:?}, scalar minimum={:.10}", result.x, result.fun);
println!(
"generations={}, objective calls={}",
result.nit, result.nfev
);
println!("{}", result.status);
Ok(())
}
package main
import (
"context"
"fmt"
"log"
"math"
itofin "github.com/benbenbang/libitofin/sdk/go"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
xatol, fatol := 1e-7, 1e-10
objective := func(x []float64) (float64, error) {
return math.Pow(x[0]-1.25, 2) + 4*math.Pow(x[1]+0.75, 2) - 3, nil
}
result, err := itofin.Minimize(context.Background(), objective, []float64{-3, 3},
itofin.DifferentialEvolution{
Bounds: [][2]float64{{-4, 4}, {-4, 4}},
Seed: 42, PopulationSize: 24, XAtol: &xatol, FAtol: &fatol,
})
if err != nil {
return err
}
if !result.Success || math.Abs(result.X[0]-1.25) >= 1e-5 ||
math.Abs(result.X[1]+0.75) >= 1e-5 || math.Abs(result.Fun+3) >= 1e-8 || result.Njev != 0 {
return fmt.Errorf("unexpected analytic result: %+v", result)
}
fmt.Printf("x=%v, scalar minimum=%.10f\n", result.X, result.Fun)
fmt.Printf("generations=%d, objective calls=%d\n", result.Nit, result.Nfev)
fmt.Println(result.Status, result.Message)
return nil
}
Particle swarm¶
The PSO examples check the same signed quadratic and report actual generations
and objective calls. Python uses method="particle-swarm"; its calibration
example uses optimization.ParticleSwarm with the same Hull-White setup above.
The existing root-RSS calibration cost is unchanged.
"""Find a negative scalar minimum with seeded, bounded particle swarm."""
# itofin library
from itofin import Settings
from itofin.indexes import Euribor
from itofin.models import CalibrationErrorType, HullWhite, SwaptionHelper
from itofin.optimization import EndCriteria, ParticleSwarm
from itofin.optimize import minimize
from itofin.termstructures import FlatForward
from itofin.time import Date, DayCounter, Period
def objective(x) -> float:
"""Return a signed scalar, not a residual or its absolute value."""
return (x[0] - 1.25) ** 2 + 4.0 * (x[1] + 0.75) ** 2 - 3.0
def calibrate_hull_white() -> None:
"""Fit one volatility parameter while keeping mean reversion fixed."""
settings = Settings()
settings.set_evaluation_date(Date(15, 2, 2002))
curve = FlatForward(Date(19, 2, 2002), 0.04875825, DayCounter.actual365_fixed())
index = Euribor.six_months(curve, settings)
model = HullWhite(curve, 0.05, 0.01)
helper = SwaptionHelper(
Period(1, "Years"),
Period(5, "Years"),
0.1148,
index,
Period(1, "Years"),
DayCounter.thirty360_bond_basis(),
DayCounter.actual360(),
curve,
CalibrationErrorType.RelativePriceError,
1.0,
)
method = ParticleSwarm(
[(0.001, 0.03)],
seed=42,
population_size=8,
xatol=1e-7,
fatol=1e-10,
maxiter=1000,
maxfev=10000,
)
criteria = EndCriteria(1000, 50, 1e-6, 1e-8, 1e-8)
model.calibrate([helper], method, criteria, True)
result = method.last_result()
assert result is not None
assert result.success, result.message
assert model.a() == 0.05
assert 0.001 <= model.sigma() <= 0.03
assert result.fun < 1e-6
print(f"fixed a={model.a()}, calibrated sigma={model.sigma():.10f}")
print(f"calibration cost={result.fun:.10g}, solver calls={result.nfev}")
def main() -> None:
"""Check the analytic optimum and report actual evaluation counts."""
calls = 0
def counted_objective(x) -> float:
nonlocal calls
calls += 1
return objective(x)
result = minimize(
counted_objective,
[-3.0, 3.0],
method="particle-swarm",
bounds=[(-4.0, 4.0), (-4.0, 4.0)],
options={
"seed": 42,
"population_size": 24,
"xatol": 1e-7,
"fatol": 1e-10,
},
)
assert result.success, result.message
assert abs(result.x[0] - 1.25) < 1e-5
assert abs(result.x[1] + 0.75) < 1e-5
assert abs(result.fun + 3.0) < 1e-8
assert result.nfev == calls
assert result.njev == 0
print(f"x={result.x}, scalar minimum={result.fun:.10f}")
print(f"generations={result.nit}, objective calls={result.nfev}")
print(result.status, result.message)
calibrate_hull_white()
if __name__ == "__main__":
main()
use std::convert::Infallible;
use itofin_optimize::{
Bounds, Common, GlobalOptions, Method, ParticleSwarmOptions, Problem, minimize,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut calls = 0;
let mut objective = |x: &[f64]| -> Result<f64, Infallible> {
calls += 1;
Ok((x[0] - 1.25).powi(2) + 4.0 * (x[1] + 0.75).powi(2) - 3.0)
};
let problem = Problem {
x0: vec![-3.0, 3.0],
bounds: Some(Bounds {
lower: vec![-4.0, -4.0],
upper: vec![4.0, 4.0],
}),
};
let method = Method::ParticleSwarm(ParticleSwarmOptions {
global: GlobalOptions {
seed: 42,
population_size: Some(24),
xatol: Some(1e-7),
fatol: Some(1e-10),
..GlobalOptions::default()
},
..ParticleSwarmOptions::default()
});
let result = minimize(&mut objective, &problem, &method, &Common::default())?;
assert!(result.success, "{}", result.message);
assert!((result.x[0] - 1.25).abs() < 1e-5);
assert!((result.x[1] + 0.75).abs() < 1e-5);
assert!((result.fun + 3.0).abs() < 1e-8);
assert_eq!(result.nfev, calls);
assert_eq!(result.njev, 0);
println!("x={:?}, scalar minimum={:.10}", result.x, result.fun);
println!(
"generations={}, objective calls={}",
result.nit, result.nfev
);
println!("{}", result.status);
Ok(())
}
package main
import (
"context"
"fmt"
"log"
"math"
itofin "github.com/benbenbang/libitofin/sdk/go"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
xatol, fatol := 1e-7, 1e-10
calls := 0
objective := func(x []float64) (float64, error) {
calls++
return math.Pow(x[0]-1.25, 2) + 4*math.Pow(x[1]+0.75, 2) - 3, nil
}
result, err := itofin.Minimize(context.Background(), objective, []float64{-3, 3},
itofin.ParticleSwarm{
Bounds: [][2]float64{{-4, 4}, {-4, 4}},
Seed: 42, PopulationSize: 24, XAtol: &xatol, FAtol: &fatol,
})
if err != nil {
return err
}
if !result.Success || math.Abs(result.X[0]-1.25) >= 1e-5 ||
math.Abs(result.X[1]+0.75) >= 1e-5 || math.Abs(result.Fun+3) >= 1e-8 || result.Njev != 0 || result.Nfev != calls {
return fmt.Errorf("unexpected analytic result: %+v", result)
}
fmt.Printf("x=%v, scalar minimum=%.10f\n", result.X, result.Fun)
fmt.Printf("generations=%d, objective calls=%d\n", result.Nit, result.Nfev)
fmt.Println(result.Status, result.Message)
return nil
}
Hybrid simulated annealing¶
The three examples check the same signed quadratic, coordinates within 1e-5,
value within 1e-8, and actual objective-call accounting. Python uses
method="hybrid-simulated-annealing" and also fits the same one-parameter
Hull-White model with optimization.HybridSimulatedAnnealing. The temperature
acts on the model's existing root-RSS cost, not on an unrooted sum of squares
or a transformed scalar.
"""Find a negative scalar minimum with seeded, bounded hybrid annealing."""
# itofin library
from itofin import Settings
from itofin.indexes import Euribor
from itofin.models import CalibrationErrorType, HullWhite, SwaptionHelper
from itofin.optimization import EndCriteria, HybridSimulatedAnnealing
from itofin.optimize import minimize
from itofin.termstructures import FlatForward
from itofin.time import Date, DayCounter, Period
def objective(x) -> float:
"""Return a signed scalar, not a residual or its absolute value."""
return (x[0] - 1.25) ** 2 + 4.0 * (x[1] + 0.75) ** 2 - 3.0
def calibrate_hull_white() -> None:
"""Fit one volatility parameter while keeping mean reversion fixed."""
settings = Settings()
settings.set_evaluation_date(Date(15, 2, 2002))
curve = FlatForward(Date(19, 2, 2002), 0.04875825, DayCounter.actual365_fixed())
index = Euribor.six_months(curve, settings)
model = HullWhite(curve, 0.05, 0.01)
helper = SwaptionHelper(
Period(1, "Years"),
Period(5, "Years"),
0.1148,
index,
Period(1, "Years"),
DayCounter.thirty360_bond_basis(),
DayCounter.actual360(),
curve,
CalibrationErrorType.RelativePriceError,
1.0,
)
method = HybridSimulatedAnnealing(
[(0.001, 0.03)],
seed=42,
xatol=1e-7,
fatol=1e-10,
maxiter=1000,
maxfev=10000,
)
criteria = EndCriteria(1000, 50, 1e-6, 1e-8, 1e-8)
model.calibrate([helper], method, criteria, True)
result = method.last_result()
assert result is not None
assert result.success, result.message
assert model.a() == 0.05
assert 0.001 <= model.sigma() <= 0.03
assert result.fun < 1e-6
print(f"fixed a={model.a()}, calibrated sigma={model.sigma():.10f}")
print(f"calibration cost={result.fun:.10g}, solver calls={result.nfev}")
def main() -> None:
"""Check the analytic optimum and report actual evaluation counts."""
calls = 0
def counted_objective(x) -> float:
nonlocal calls
calls += 1
return objective(x)
result = minimize(
counted_objective,
[-3.0, 3.0],
method="hybrid-simulated-annealing",
bounds=[(-4.0, 4.0), (-4.0, 4.0)],
options={
"seed": 42,
"xatol": 1e-7,
"fatol": 1e-10,
},
)
assert result.success, result.message
assert abs(result.x[0] - 1.25) < 1e-5
assert abs(result.x[1] + 0.75) < 1e-5
assert abs(result.fun + 3.0) < 1e-8
assert result.nfev == calls
assert result.njev == 0
print(f"x={result.x}, scalar minimum={result.fun:.10f}")
print(f"cycles={result.nit}, objective calls={result.nfev}")
print(result.status, result.message)
calibrate_hull_white()
if __name__ == "__main__":
main()
use std::convert::Infallible;
use itofin_optimize::{Bounds, Common, HybridSimulatedAnnealingOptions, Method, Problem, minimize};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut calls = 0;
let mut objective = |x: &[f64]| -> Result<f64, Infallible> {
calls += 1;
Ok((x[0] - 1.25).powi(2) + 4.0 * (x[1] + 0.75).powi(2) - 3.0)
};
let problem = Problem {
x0: vec![-3.0, 3.0],
bounds: Some(Bounds {
lower: vec![-4.0, -4.0],
upper: vec![4.0, 4.0],
}),
};
let method = Method::HybridSimulatedAnnealing(HybridSimulatedAnnealingOptions {
seed: 42,
xatol: Some(1e-7),
fatol: Some(1e-10),
..HybridSimulatedAnnealingOptions::default()
});
let result = minimize(&mut objective, &problem, &method, &Common::default())?;
assert!(result.success, "{}", result.message);
assert!((result.x[0] - 1.25).abs() < 1e-5);
assert!((result.x[1] + 0.75).abs() < 1e-5);
assert!((result.fun + 3.0).abs() < 1e-8);
assert_eq!(result.nfev, calls);
assert_eq!(result.njev, 0);
println!("x={:?}, scalar minimum={:.10}", result.x, result.fun);
println!("cycles={}, objective calls={}", result.nit, result.nfev);
println!("{}", result.status);
Ok(())
}
package main
import (
"context"
"fmt"
"log"
"math"
itofin "github.com/benbenbang/libitofin/sdk/go"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
xatol, fatol := 1e-7, 1e-10
calls := 0
objective := func(x []float64) (float64, error) {
calls++
return math.Pow(x[0]-1.25, 2) + 4*math.Pow(x[1]+0.75, 2) - 3, nil
}
result, err := itofin.Minimize(context.Background(), objective, []float64{-3, 3},
itofin.HybridSimulatedAnnealing{
Bounds: [][2]float64{{-4, 4}, {-4, 4}},
Seed: 42, XAtol: &xatol, FAtol: &fatol,
})
if err != nil {
return err
}
if !result.Success || math.Abs(result.X[0]-1.25) >= 1e-5 ||
math.Abs(result.X[1]+0.75) >= 1e-5 || math.Abs(result.Fun+3) >= 1e-8 || result.Njev != 0 || result.Nfev != calls {
return fmt.Errorf("unexpected analytic result: %+v", result)
}
fmt.Printf("x=%v, scalar minimum=%.10f\n", result.X, result.Fun)
fmt.Printf("cycles=%d, objective calls=%d\n", result.Nit, result.Nfev)
fmt.Println(result.Status, result.Message)
return nil
}
Firefly¶
The examples check the signed quadratic and actual evaluation counts. Python
uses method="Firefly" and optimization.Firefly for the paired Hull-White fit.
The calibration scalar remains root-RSS, not the unrooted sum of squares.
"""Find a negative scalar minimum with seeded, bounded firefly search."""
# itofin library
from itofin import Settings
from itofin.indexes import Euribor
from itofin.models import CalibrationErrorType, HullWhite, SwaptionHelper
from itofin.optimization import EndCriteria, Firefly
from itofin.optimize import minimize
from itofin.termstructures import FlatForward
from itofin.time import Date, DayCounter, Period
def objective(x) -> float:
"""Return a signed scalar, not a residual or its absolute value."""
return (x[0] - 1.25) ** 2 + 4.0 * (x[1] + 0.75) ** 2 - 3.0
def calibrate_hull_white() -> None:
"""Fit one volatility parameter while keeping mean reversion fixed."""
settings = Settings()
settings.set_evaluation_date(Date(15, 2, 2002))
curve = FlatForward(Date(19, 2, 2002), 0.04875825, DayCounter.actual365_fixed())
index = Euribor.six_months(curve, settings)
model = HullWhite(curve, 0.05, 0.01)
helper = SwaptionHelper(
Period(1, "Years"),
Period(5, "Years"),
0.1148,
index,
Period(1, "Years"),
DayCounter.thirty360_bond_basis(),
DayCounter.actual360(),
curve,
CalibrationErrorType.RelativePriceError,
1.0,
)
method = Firefly(
[(0.001, 0.03)],
seed=42,
population_size=8,
xatol=1e-7,
fatol=1e-10,
maxiter=1000,
maxfev=100000,
)
criteria = EndCriteria(1000, 50, 1e-6, 1e-8, 1e-8)
model.calibrate([helper], method, criteria, True)
result = method.last_result()
assert result is not None
assert result.success, result.message
assert model.a() == 0.05
assert 0.001 <= model.sigma() <= 0.03
assert result.fun < 1e-6
print(f"fixed a={model.a()}, calibrated sigma={model.sigma():.10f}")
print(f"calibration cost={result.fun:.10g}, solver calls={result.nfev}")
def main() -> None:
"""Check the analytic optimum and report actual evaluation counts."""
calls = 0
def counted_objective(x) -> float:
nonlocal calls
calls += 1
return objective(x)
result = minimize(
counted_objective,
[-3.0, 3.0],
method="Firefly",
bounds=[(-4.0, 4.0), (-4.0, 4.0)],
options={
"seed": 42,
"population_size": 24,
"xatol": 1e-7,
"fatol": 1e-10,
},
)
assert result.success, result.message
assert abs(result.x[0] - 1.25) < 1e-5
assert abs(result.x[1] + 0.75) < 1e-5
assert abs(result.fun + 3.0) < 1e-8
assert result.nfev == calls
assert result.njev == 0
print(f"x={result.x}, scalar minimum={result.fun:.10f}")
print(f"generations={result.nit}, objective calls={result.nfev}")
print(result.status, result.message)
calibrate_hull_white()
if __name__ == "__main__":
main()
use std::convert::Infallible;
use itofin_optimize::{Bounds, Common, FireflyOptions, GlobalOptions, Method, Problem, minimize};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut calls = 0;
let mut objective = |x: &[f64]| -> Result<f64, Infallible> {
calls += 1;
Ok((x[0] - 1.25).powi(2) + 4.0 * (x[1] + 0.75).powi(2) - 3.0)
};
let problem = Problem {
x0: vec![-3.0, 3.0],
bounds: Some(Bounds {
lower: vec![-4.0, -4.0],
upper: vec![4.0, 4.0],
}),
};
let method = Method::Firefly(FireflyOptions {
global: GlobalOptions {
seed: 42,
population_size: Some(24),
xatol: Some(1e-7),
fatol: Some(1e-10),
..GlobalOptions::default()
},
..FireflyOptions::default()
});
let result = minimize(&mut objective, &problem, &method, &Common::default())?;
assert!(result.success, "{}", result.message);
assert!((result.x[0] - 1.25).abs() < 1e-5);
assert!((result.x[1] + 0.75).abs() < 1e-5);
assert!((result.fun + 3.0).abs() < 1e-8);
assert_eq!(result.nfev, calls);
assert_eq!(result.njev, 0);
println!("x={:?}, scalar minimum={:.10}", result.x, result.fun);
println!(
"generations={}, objective calls={}",
result.nit, result.nfev
);
println!("{}", result.status);
Ok(())
}
package main
import (
"context"
"fmt"
"log"
"math"
itofin "github.com/benbenbang/libitofin/sdk/go"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
xatol, fatol := 1e-7, 1e-10
calls := 0
objective := func(x []float64) (float64, error) {
calls++
return math.Pow(x[0]-1.25, 2) + 4*math.Pow(x[1]+0.75, 2) - 3, nil
}
result, err := itofin.Minimize(context.Background(), objective, []float64{-3, 3},
itofin.Firefly{
Bounds: [][2]float64{{-4, 4}, {-4, 4}},
Seed: 42, PopulationSize: 24, XAtol: &xatol, FAtol: &fatol,
})
if err != nil {
return err
}
if !result.Success || math.Abs(result.X[0]-1.25) >= 1e-5 ||
math.Abs(result.X[1]+0.75) >= 1e-5 || math.Abs(result.Fun+3) >= 1e-8 || result.Njev != 0 || result.Nfev != calls {
return fmt.Errorf("unexpected analytic result: %+v", result)
}
fmt.Printf("x=%v, scalar minimum=%.10f\n", result.X, result.Fun)
fmt.Printf("generations=%d, objective calls=%d\n", result.Nit, result.Nfev)
fmt.Println(result.Status, result.Message)
return nil
}
Algorithm references¶
Implemented independently from Storn and Price (1997), Differential Evolution: A Simple and Efficient Heuristic for Global Optimization over Continuous Spaces. The local generator follows Steele, Lea and Flood (2014), Fast Splittable Pseudorandom Number Generators.
PSO is implemented independently from Kennedy and Eberhart (1995), Particle Swarm Optimization, with the authors' paper hosted by the University of Washington, and the inertia formulation in Shi and Eberhart (1998), A Modified Particle Swarm Optimizer. Our synchronous update, zero initial velocity, absorbing bounds and convergence policies are explicit implementation choices, not unchanged paper defaults.
Hybrid annealing uses the Metropolis acceptance rule described by Kirkpatrick, Gelatt and Vecchi (1983), Optimization by Simulated Annealing, and exploratory coordinate polling inspired by Hooke and Jeeves (1961), Direct Search Solution of Numerical and Statistical Problems. Reflected uniform proposals, geometric cooling, periodic best-point reannealing, local polling and finite-budget stopping are explicit implementation policies.
Firefly attraction and random motion are independently implemented from Yang (2009), Firefly Algorithms for Multimodal Optimization. Frozen eligibility, row-ordered sequential moves, box-scaled distance, reflection, noise decay and best-point archival are explicit local policies, not a promise of matching the paper's experiments or another library's trajectory.
No upstream optimizer implementation is copied or imported. This is not a claim of clean-room provenance or QuantLib/SciPy trajectory equivalence.