givp — GRASP-ILS-VND with Path Relinking
A direction-agnostic metaheuristic optimizer for continuous, integer or mixed black-box problems, available in Python (NumPy-native) Julia, and Rust. The library bundles:
- GRASP — Greedy Randomized Adaptive Search Procedure
- ILS — Iterated Local Search
- VND — Variable Neighborhood Descent (with an adaptive variant)
- Path Relinking between elite solutions
- LRU evaluation cache, convergence monitor, optional thread-parallel candidate evaluation, and a wall-clock time budget
The public API mirrors scipy.optimize: pass an objective callable, bounds and
optional configuration, get back an OptimizeResult with x, fun, nit,
nfev, success, message, direction, meta.
Table of contents
- Install
- Quick start
- Julia
- Rust
- Choosing the optimization sense
- Bounds, integer variables and mixed problems
- Object-oriented API and multi-start
- Configuration cookbook
- Inspecting progress (callback and verbose)
- Public API reference
- Glossary of hyper-parameters
- Adapting to a domain-specific model
- Comparison with other optimizers
- Troubleshooting
- License
Install
Python
From PyPI (once published):
From source (editable):
Requires Python 3.10+ and NumPy.
Julia installation
From a local clone:
Requires Julia 1.9+.
Rust Installation
Add to your Cargo.toml (once published to crates.io):
[]
= "0.5"
From source:
Requires Rust 1.70+ (edition 2021).
Quick start
return
=
# best vector found
# best objective value
# number of evaluations performed
Default behavior:
- Minimization (
minimize=True/direction="minimize"). - All variables treated as continuous.
- Default hyper-parameters (
GIVPConfig()).
Julia
The Julia port exposes the same algorithm with an idiomatic Julia API:
using GIVPOptimizer
function sphere(x::Vector{Float64})::Float64
return sum(x .^ 2)
end
result = givp(sphere, [(-5.0, 5.0) for _ in 1:10])
println(result.x) # best vector found
println(result.fun) # best objective value
println(result.nfev) # number of evaluations
Maximization:
result = givp(my_score, bounds; direction=maximize)
Configuration:
cfg = GIVPConfig(; max_iterations=50, vnd_iterations=100, time_limit=30.0)
result = givp(sphere, bounds; config=cfg, seed=42, verbose=true)
Running tests:
Running benchmarks:
Rust
The Rust port provides a zero-dependency-on-NumPy, native-performance implementation:
use ;
let sphere = ;
let bounds: = vec!;
let config = GivpConfig ;
let result = givp.unwrap;
println!;
Maximization:
use ;
let config = GivpConfig ;
Running tests:
Running benchmarks:
Choosing the optimization sense
The library is agnostic to whether you want the lowest or the highest
value of func. Two equivalent ways to declare it:
Boolean flag (recommended)
return # higher is better
=
assert ==
String flag (SciPy/Optuna compatible)
=
Both flags are accepted on givp, on GIVPOptimizer and on
GIVPConfig. Setting both simultaneously is allowed only when they
agree; conflicting values raise ValueError.
Internal note. The core algorithm always minimizes. When you ask for maximization the public API wraps your objective with a sign flip and restores the sign on
result.fun. This meansresult.funis always reported in your original sign — no need to negate it back yourself.
Bounds, integer variables and mixed problems
bounds is accepted in two equivalent forms:
# SciPy style: list of (low, high) per variable
=
# (lower, upper) tuple of two equally-sized sequences
=
By default every variable is continuous. To declare a mixed problem (some
continuous variables followed by some integer variables in the decision
vector), use integer_split on the configuration:
, = 12, 8
= * + *
= # indices >= n_cont are integer
=
Special cases:
integer_split |
Meaning |
|---|---|
None (public API default: num_vars) |
All-continuous problem. |
0 |
All-integer problem. |
n_vars |
All-continuous problem (explicit). |
k (0 < k < n) |
First k continuous, rest integer. |
Object-oriented API and multi-start
When you want to keep configuration around, run the optimizer multiple times
and track the best result automatically, use GIVPOptimizer:
=
opt.best_x and opt.best_fun always reflect the best result observed across
all run() calls, in the user's original sign.
Configuration cookbook
# 1) Fast triage (small budget, no warm-up)
=
# 2) Production-quality run with wall-clock budget
=
# 3) Expensive objective: maximize cache reuse, keep evaluations few
=
# 4) Maximization with hourly-shaped layout (3 plants × 24 hours)
=
Inspecting progress (callback and verbose)
Both givp and GIVPOptimizer accept:
verbose=True— prints per-iteration cost and cache statistics.iteration_callback=fn— callsfn(iteration_index, best_cost, best_solution)once per outer GRASP iteration. The callback receives the cost in the internal minimization sign (i.e., already sign-flipped if you asked for maximization). Useful to plot convergence or persist intermediate results.
=
=
Public API reference
givp(...) -> OptimizeResult
->
class GIVPOptimizer
Same constructor signature, exposes .run() -> OptimizeResult and tracks
.best_x, .best_fun, .history.
class GIVPConfig (dataclass)
All hyper-parameters listed in the glossary.
class OptimizeResult
| Field | Type | Meaning |
|---|---|---|
x |
np.ndarray |
Best solution vector. |
fun |
float |
Objective value at x, in the user's original sign. |
nit |
int |
GRASP outer iterations executed. |
nfev |
int |
Number of objective evaluations. |
success |
bool |
True when at least one feasible solution was produced. |
message |
str |
Human-readable termination reason. |
direction |
str |
'minimize' or 'maximize'. |
meta |
dict |
Algorithm-specific extras (cache stats, etc.). |
For backward compatibility the result is iterable: x, fun = result works.
Glossary of hyper-parameters
| Field | Default | Meaning |
|---|---|---|
max_iterations |
100 | GRASP outer iterations. |
alpha |
0.12 | Initial RCL randomization (0 = greedy, 1 = uniform). |
vnd_iterations |
200 | Maximum VND inner iterations. |
ils_iterations |
10 | Iterated Local Search loops per outer iteration. |
perturbation_strength |
4 | Magnitude of ILS perturbation (number of variables jolted). |
use_elite_pool |
True | Maintain a diverse pool of elite solutions for path relinking. |
elite_size |
7 | Maximum number of elite solutions kept. |
path_relink_frequency |
8 | Every N GRASP iterations, run path relinking on elite pairs. |
adaptive_alpha |
True | If True, alpha varies in [alpha_min, alpha_max] over iterations. |
alpha_min / alpha_max |
0.08 / 0.18 | Bounds for adaptive alpha. |
num_candidates_per_step |
20 | Candidates evaluated per construction step. |
use_cache |
True | Memoize evaluations via LRU cache. |
cache_size |
10000 | LRU cache capacity. |
early_stop_threshold |
80 | Iterations without improvement before terminating. |
use_convergence_monitor |
True | Enable diversification/restart heuristics. |
n_workers |
1 | Threads used to evaluate candidates concurrently. |
time_limit |
0.0 | Wall-clock budget in seconds (0 = unlimited). |
minimize |
None |
Boolean direction flag. True = minimize, False = maximize. |
direction |
'minimize' |
String direction flag (alternative form). |
integer_split |
None |
Index where integer variables begin in the decision vector. |
Adapting to a domain-specific model
The library knows nothing about your problem. Wrap your domain code so it
exposes a func(x: np.ndarray) -> float and a list of bounds. Penalty terms,
repair operators and constraint handling all live in your project.
Minimal pattern:
return
return # treat infeasibility as worst possible cost
return
=
For an end-to-end example with a mixed continuous/integer hydropower model,
see the SOG2 adapter in the upstream project repository
(givp.py).
Comparison with other optimizers
| Library | Sense convention | Discrete vars? | Built-in cache | Built-in time budget | Language |
|---|---|---|---|---|---|
scipy.optimize.minimize |
Always minimize | No | No | No | Python |
scipy.optimize.differential_evolution |
Always minimize | Continuous only | No | Via callback | Python |
scipy.optimize.dual_annealing |
Always minimize | No | No | maxiter only |
Python |
optuna |
Explicit (direction) |
Yes | Per-trial only | Yes (timeout) |
Python |
pygad |
Always maximize | Yes | No | No | Python |
givp |
Explicit (minimize/direction) |
Yes (mixed) | LRU cache | Yes (time_limit) |
Python+Julia+Rust |
Troubleshooting
ValueError: each element of upper must be strictly greater than lower
A bounds entry has low >= high. Even fixed values must use a strictly
positive interval ((v - 1e-9, v + 1e-9)) or be removed from the search.
ValueError: bounds length (...) does not match num_vars (...)
You passed num_vars explicitly but the bounds disagree. Drop num_vars to
let the library infer it from bounds, or fix the mismatch.
ValueError: 'minimize' and 'direction' disagree: ...
You passed both flags with conflicting values. Use one or the other (or pass
both with matching values).
Optimization converges to inf.
Your objective is raising or returning nan. The wrapper coerces non-finite
values to +inf so they are always comparable, but if every candidate is
infeasible the algorithm has nothing to improve. Lower perturbation_strength,
revisit your bounds, or relax the feasibility logic in func.
Run is too slow.
Try use_cache=True, increase cache_size, raise n_workers, lower
num_candidates_per_step, or set a time_limit. For very expensive
objectives, also reduce vnd_iterations and ils_iterations.
Final solution looks too "rough" / integer values look noisy.
Make sure integer_split is set correctly. With the default (None /
num_vars) all variables are treated as continuous and the integer-aware
neighborhoods are skipped.
License
MIT