Skip to content

Real-world examples

Four end-to-end recipes you can paste into a script. Each one runs in under 10 seconds with default settings.

1. Continuous: Rastrigin (multimodal, 30D)

A classic benchmark that defeats naive gradient methods because it has hundreds of local minima.

import numpy as np

from givp import GIVPConfig, givp


def rastrigin(x: np.ndarray) -> float:
    n = x.size
    return 10.0 * n + float(np.sum(x**2 - 10.0 * np.cos(2.0 * np.pi * x)))


bounds = [(-5.12, 5.12)] * 30
result = givp(
    rastrigin,
    bounds,
    seed=42,
    config=GIVPConfig(max_iterations=80, ils_iterations=10),
)
print(f"best={result.fun:.4f}  nfev={result.nfev}")

2. Integer: 0/1 Knapsack

Maximise total value subject to a weight budget. The objective uses a heavy penalty so infeasible solutions are pushed away naturally.

import numpy as np

from givp import GIVPConfig, givp

values = np.array([60, 100, 120, 80, 30, 70, 45, 90])
weights = np.array([10, 20, 30, 15, 5, 18, 8, 25])
capacity = 60


def knapsack(x: np.ndarray) -> float:
    chosen = np.rint(x).astype(int)
    total_w = float((chosen * weights).sum())
    total_v = float((chosen * values).sum())
    if total_w > capacity:
        return -1e6  # heavy penalty (we are maximizing value)
    return total_v


bounds = [(0.0, 1.0)] * len(values)
result = givp(
    knapsack,
    bounds,
    minimize=False,
    seed=7,
    config=GIVPConfig(integer_split=0, max_iterations=50),
)
print("picked:", np.rint(result.x).astype(int))
print(f"value={result.fun:.0f}")

3. Mixed continuous + integer: portfolio with discrete lots

Optimize 5 continuous weights plus 3 integer "lot counts". integer_split=5 tells givp that indices [5, 6, 7] must stay integer.

import numpy as np

from givp import GIVPConfig, givp

rng = np.random.default_rng(0)
mu = rng.uniform(0.05, 0.20, size=5)
cov = np.diag(rng.uniform(0.01, 0.05, size=5))


def portfolio(x: np.ndarray) -> float:
    weights, lots = x[:5], x[5:]
    weights = np.abs(weights)
    weights /= weights.sum() if weights.sum() > 0 else 1.0
    expected_return = float(weights @ mu) + 0.001 * float(np.rint(lots).sum())
    risk = float(weights @ cov @ weights)
    # Sharpe-like: maximize return per unit of risk.
    return expected_return / np.sqrt(risk + 1e-12)


bounds = [(0.0, 1.0)] * 5 + [(0, 10)] * 3
result = givp(
    portfolio,
    bounds,
    minimize=False,
    seed=11,
    config=GIVPConfig(integer_split=5, max_iterations=60),
)
print("weights :", np.round(result.x[:5], 3))
print("lots    :", np.rint(result.x[5:]).astype(int))
print(f"sharpe~ {result.fun:.4f}")

4. Black-box: hyper-parameter tuning of an ML model

Plug any cross_val_score into the objective. Each evaluation can be expensive, so enable the cache and a wall-clock budget.

import numpy as np
from sklearn.datasets import load_iris
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import cross_val_score

from givp import GIVPConfig, givp

X, y = load_iris(return_X_y=True)


def objective(params: np.ndarray) -> float:
    learning_rate, max_depth, n_estimators = params
    model = GradientBoostingClassifier(
        learning_rate=float(learning_rate),
        max_depth=int(max_depth),
        n_estimators=int(n_estimators),
        random_state=0,
    )
    return float(cross_val_score(model, X, y, cv=3).mean())


# learning_rate continuous, max_depth + n_estimators integer
bounds = [(0.01, 0.5), (2, 10), (10, 200)]
result = givp(
    objective,
    bounds,
    minimize=False,
    seed=2024,
    config=GIVPConfig(
        integer_split=1,
        max_iterations=20,
        time_limit=60.0,
        use_cache=True,
    ),
)
print(f"best CV accuracy: {result.fun:.4f}")
print(f"hyperparams     : lr={result.x[0]:.3f}, depth={int(result.x[1])}, n={int(result.x[2])}")

5. Multi-objective: weighted-sum scalarization sweep

givp is single-objective, but you can optimize multiple goals by combining them into a scalar objective. A practical baseline is weighted sum:

[ f_w(x) = w\,f_1(x) + (1-w)\,f_2(x),\quad w \in [0, 1] ]

The sweep below runs the same problem with different w values to expose trade-offs between objective 1 (distance to target return) and objective 2 (portfolio variance surrogate).

import numpy as np

from givp import GIVPConfig, givp

target_return = 0.08
mu = np.array([0.05, 0.09, 0.12])
cov = np.array(
    [
        [0.020, 0.004, 0.002],
        [0.004, 0.030, 0.006],
        [0.002, 0.006, 0.050],
    ]
)


def project_simplex(v: np.ndarray) -> np.ndarray:
    v = np.clip(v, 0.0, None)
    s = float(v.sum())
    return v / s if s > 0 else np.array([1.0, 0.0, 0.0])


def objectives(x: np.ndarray) -> tuple[float, float]:
    w = project_simplex(x)
    ret = float(w @ mu)
    risk = float(w @ cov @ w)
    obj_return = (ret - target_return) ** 2
    obj_risk = risk
    return obj_return, obj_risk


bounds = [(0.0, 1.0)] * 3
config = GIVPConfig(max_iterations=40, ils_iterations=8)
weights = [0.0, 0.25, 0.5, 0.75, 1.0]

rows: list[tuple[float, float, float, float, np.ndarray]] = []
for alpha in weights:
    def scalarized(x: np.ndarray, a: float = alpha) -> float:
        f1, f2 = objectives(x)
        return a * f1 + (1.0 - a) * f2

    r = givp(scalarized, bounds, seed=123, config=config)
    f1, f2 = objectives(r.x)
    rows.append((alpha, f1, f2, r.fun, project_simplex(r.x)))

print("alpha | obj_return | obj_risk | scalarized | weights")
for alpha, f1, f2, f, w in rows:
    print(f"{alpha:>4.2f} | {f1:>10.6f} | {f2:>8.6f} | {f:>10.6f} | {np.round(w, 3)}")

Interpretation tips:

  • alpha -> 1.0: optimizer prioritizes return-target matching (obj_return).
  • alpha -> 0.0: optimizer prioritizes risk minimization (obj_risk).
  • Intermediate values produce compromise solutions; this is the first approximation of a Pareto front for decision support.

Limitations:

  • Weighted sum can miss non-convex Pareto regions.
  • Objective scaling matters; normalize terms before production use.
  • You should run multiple seeds to evaluate robustness of the compromise set.

6. Combinatorial: TSP-like objective via discretization

This example encodes a route permutation indirectly from a continuous vector. Each position score is optimized in [0, n-1], rounded, then converted into a permutation using argsort (stable tie handling).

import numpy as np

from givp import GIVPConfig, givp

# 6-city symmetric toy distance matrix
dist = np.array(
    [
        [0, 2, 9, 10, 7, 3],
        [2, 0, 6, 4, 3, 8],
        [9, 6, 0, 8, 5, 7],
        [10, 4, 8, 0, 6, 9],
        [7, 3, 5, 6, 0, 4],
        [3, 8, 7, 9, 4, 0],
    ],
    dtype=float,
)


def decode_permutation(x: np.ndarray) -> np.ndarray:
    # Discretize to reinforce combinatorial behavior, then map to a valid route.
    scores = np.rint(np.clip(x, 0.0, dist.shape[0] - 1)).astype(int)
    return np.argsort(scores, kind="mergesort")


def tsp_like_cost(x: np.ndarray) -> float:
    p = decode_permutation(x)
    total = 0.0
    for i in range(len(p)):
        a = int(p[i])
        b = int(p[(i + 1) % len(p)])
        total += float(dist[a, b])
    return total


bounds = [(0.0, float(dist.shape[0] - 1))] * dist.shape[0]
cfg = GIVPConfig(integer_split=dist.shape[0], max_iterations=60, ils_iterations=10)

result = givp(tsp_like_cost, bounds, seed=77, config=cfg)
route = decode_permutation(result.x)

print("route:", route.tolist())
print(f"tour length: {result.fun:.3f}")

Interpretation tips:

  • The objective is purely combinatorial after decoding, while optimization stays in givp's native continuous search space.
  • Using continuous search (integer_split=n) plus explicit rounding in the objective is a robust way to model permutation-style objectives.
  • The route is deterministic for a fixed seed and config.

7. Warm start: multiple elite seeds

When you already know several promising solutions, pass them as initial_guesses. They are validated against the bounds, deduplicated, and inserted into the elite pool before the first iteration.

import numpy as np

from givp import GIVPConfig, givp


def sphere(x: np.ndarray) -> float:
    return float(np.sum(x**2))


bounds = [(-3.0, 3.0)] * 4
warm_starts = [
    [0.1, 0.1, 0.1, 0.1],
    [1.0, 0.5, 0.2, 0.0],
    [-0.5, 0.8, -0.2, 0.3],
]

result = givp(
    sphere,
    bounds,
    seed=99,
    config=GIVPConfig(max_iterations=20, elite_size=5),
    initial_guesses=warm_starts,
)

print(f"best={result.fun:.4f}")
print("x=", np.round(result.x, 3))

Edge cases:

  • Duplicate candidates are rejected before the run starts.
  • Each seed must match the problem dimension.
  • Each coordinate must lie strictly inside the bounds, just like initial_guess.

See also


Using the components individually

The four metaheuristic building blocks are importable from givp.core and can be called independently. This is useful when you want to embed one phase inside your own search loop, test a component in isolation, or build a custom hybrid.

Note: All components work in minimization space. Pass a negated objective if you are maximizing.


GRASP — greedy randomized construction

construct_grasp samples a pool of candidate solutions and selects the best one via a Restricted Candidate List (RCL).

import numpy as np
from givp.core.grasp import construct_grasp
from givp.core.helpers import _set_integer_split

def sphere(x: np.ndarray) -> float:
    return float(np.sum(x ** 2))

num_vars = 6
lower = np.zeros(num_vars)
upper = np.ones(num_vars) * 5.0

# Tell the helpers that indices [3, 4, 5] are integer variables.
_set_integer_split(3)

solution = construct_grasp(
    num_vars=num_vars,
    lower_arr=lower,
    upper_arr=upper,
    evaluator=sphere,
    initial_guess=None,
    alpha=0.2,           # 0 = pure greedy, 1 = pure random
    seed=42,
    num_candidates_per_step=12,
)
print("constructed:", solution, "cost:", sphere(solution))

local_search_vnd improves a starting solution by cycling through flip, swap, multiflip, group, and block neighbourhoods until no gain is found.

import numpy as np
from givp.core.vnd import local_search_vnd
from givp.core.helpers import _set_integer_split

def sphere(x: np.ndarray) -> float:
    return float(np.sum(x ** 2))

_set_integer_split(3)  # first 3 indices are continuous, last 3 are integer

solution = np.array([2.5, 3.1, 0.8, 4.0, 2.0, 1.0])
lower    = np.zeros(6)
upper    = np.ones(6) * 5.0

improved = local_search_vnd(
    cost_fn=sphere,
    solution=solution,
    num_vars=6,
    max_iter=200,
    lower_arr=lower,
    upper_arr=upper,
)
print("before:", sphere(solution), "after:", sphere(improved))

Use local_search_vnd_adaptive to let the algorithm re-rank neighbourhoods based on their hit-rate for the current problem:

from givp.core.vnd import local_search_vnd_adaptive

improved = local_search_vnd_adaptive(
    cost_fn=sphere,
    solution=solution,
    num_vars=6,
    max_iter=200,
    lower_arr=lower,
    upper_arr=upper,
)

ILS — Iterated Local Search (perturbation)

ils_search wraps VND inside a perturbation loop. It shakes the current solution and re-applies VND, accepting an occasional worse result to escape local optima.

import numpy as np
from givp import GIVPConfig
from givp.core.ils import ils_search
from givp.core.helpers import _set_integer_split

def sphere(x: np.ndarray) -> float:
    return float(np.sum(x ** 2))

_set_integer_split(3)

config = GIVPConfig(
    ils_iterations=10,
    vnd_iterations=50,
    perturbation_strength=2,
)

solution = np.array([2.5, 3.1, 0.8, 4.0, 2.0, 1.0])
lower    = np.zeros(6)
upper    = np.ones(6) * 5.0

best_sol, best_cost = ils_search(
    solution=solution,
    current_cost=sphere(solution),
    num_vars=6,
    cost_fn=sphere,
    config=config,
    lower_arr=lower,
    upper_arr=upper,
)
print("ILS best cost:", best_cost)

Path Relinking — intensification between elite solutions

path_relinking walks from a source to a target solution one coordinate at a time, evaluating every intermediate point.
bidirectional_path_relinking runs both directions and returns the global best.

import numpy as np
from givp.core.pr import path_relinking, bidirectional_path_relinking
from givp.core.helpers import _set_integer_split

def sphere(x: np.ndarray) -> float:
    return float(np.sum(x ** 2))

_set_integer_split(6)  # all variables treated as continuous for PR

source = np.array([4.0, 3.0, 2.0, 1.0, 0.5, 0.1])
target = np.array([0.1, 0.2, 0.3, 0.1, 0.0, 0.0])

# Forward walk from source towards target
best_sol, best_cost = path_relinking(
    cost_fn=sphere,
    source=source,
    target=target,
    strategy="best",   # "best" picks the locally optimal step;
    seed=0,
)
print("PR best cost:", best_cost)

# Bidirectional: explores source→target and target→source
best_sol, best_cost = bidirectional_path_relinking(
    cost_fn=sphere,
    sol1=source,
    sol2=target,
)
print("Bidirectional PR best cost:", best_cost)