Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions EvoloPy/optimizers/ACO.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
from functorch import dim
import numpy as np
from EvoloPy.solution import solution
import time
from typing import Callable, Union, List


def ACO(objf: Callable,
lb: Union[float, List[float]],
ub: Union[float, List[float]],
dim: int,
PopSize: int,
iters: int) -> solution:
"""
Ant Colony Optimization for Continuous Domains (ACO_R).

Parameters
----------
objf : callable
The objective function to be minimized.
lb : float or list
Lower bounds for decision variables.
ub : float or list
Upper bounds for decision variables.
dim : int
Problem dimension.
PopSize : int
Number of ants per iteration (population size).
iters : int
Maximum number of iterations.

Returns
-------
s : solution
Solution object containing all optimization results,
compatible with the EvoloPy framework.
"""

# ACO_R hyper-parameters
archive_size = max(50, 10 * dim) # Number of elite solutions in the archive
q = 0.5 # Locality of search (selection sharpness)
xi = 0.65 # Convergence speed (std deviation scaling)

# Initialise solution object
s = solution()

# Bounds handling - identical pattern to PSO.py
if not isinstance(lb, list) and not isinstance(lb, np.ndarray):
lb = [lb] * dim
if not isinstance(ub, list) and not isinstance(ub, np.ndarray):
ub = [ub] * dim

lb = np.array(lb)
ub = np.array(ub)

if len(lb) != dim:
lb = np.array([lb[0]] * dim)
if len(ub) != dim:
ub = np.array([ub[0]] * dim)

# Initialise the solution archive with random solutions
# Archive holds 'archive_size' solutions; each row is one solution
archive_positions = np.random.uniform(0, 1, (archive_size, dim)) * (ub - lb) + lb
archive_scores = np.array([objf(archive_positions[i, :])
for i in range(archive_size)])

# Sort archive best → worst (ascending fitness)
sort_idx = np.argsort(archive_scores)
archive_positions = archive_positions[sort_idx, :]
archive_scores = archive_scores[sort_idx]

# Pre-compute the selection weights (Gaussian rank-based)
# These weights reflect the pheromone - better-ranked solutions
# attract more ants. Weights are fixed across iterations because
# the ranking mechanism (not the weights) drives learning.
ranks = np.arange(1, archive_size + 1) # 1 = best
weights = (np.exp(-((ranks - 1) ** 2) /
(2 * (q * archive_size) ** 2))) # Gaussian decay
weights = weights / np.sum(weights) # Normalise → prob

# Tracking
convergence_curve = np.zeros(iters)

# Global best (initialised from the archive)
gBestScore = archive_scores[0]
gBest = archive_positions[0, :].copy()

# Start timing - same pattern as PSO.py and GWO.py
print('ACO is optimizing "' + objf.__name__ + '"')

timerStart = time.time()
s.startTime = time.strftime("%Y-%m-%d-%H-%M-%S")

# Main loop
for l in range(iters):

# Generate PopSize new ant solutions
new_positions = np.zeros((PopSize, dim))

for i in range(PopSize):

# Step 1 - Select a template solution from the archive
# using the pre-computed rank-based probability weights
chosen_idx = np.random.choice(archive_size, p=weights)

# Step 2 - Sample each dimension from a Gaussian centred on
# the chosen template. The standard deviation is computed as
# the weighted average distance of all archive solutions from
# the chosen template (Socha & Dorigo, 2008, Eq. 5),
# scaled by xi to control convergence speed.
new_solution = np.zeros(dim)
for d in range(dim):
# Weighted std across archive for dimension d
sigma = xi * np.sum(
np.abs(archive_positions[:, d]
- archive_positions[chosen_idx, d])
) / (archive_size - 1)
# Prevent sigma collapsing to zero (would freeze search)
if sigma == 0:
sigma = 1e-6

# Sample from Gaussian centred on chosen template
new_solution[d] = (archive_positions[chosen_idx, d]
+ sigma * np.random.randn())

# Step 3 - Clip to bounds (same pattern as PSO.py)
new_solution = np.clip(new_solution, lb, ub)
new_positions[i] = new_solution

# Evaluate fitness of all new ants
new_scores = np.array([objf(new_positions[i, :])
for i in range(PopSize)])

# Update archive - merge, re-sort, keep best archive_size
combined_positions = np.vstack([archive_positions, new_positions])
combined_scores = np.concatenate([archive_scores, new_scores])

sort_idx = np.argsort(combined_scores)
archive_positions = combined_positions[sort_idx[:archive_size], :]
archive_scores = combined_scores[sort_idx[:archive_size]]

# Update global best
if archive_scores[0] < gBestScore:
gBestScore = archive_scores[0]
gBest = archive_positions[0, :].copy()

# Record convergence - same pattern as PSO.py and GWO.py
convergence_curve[l] = gBestScore

if l % 1 == 0:
print(["At iteration " + str(l + 1)
+ " the best fitness is " + str(gBestScore)])

# End timing and populate solution object
# Exact same field names and order as PSO.py
timerEnd = time.time()
s.endTime = time.strftime("%Y-%m-%d-%H-%M-%S")
s.executionTime = timerEnd - timerStart

s.convergence = convergence_curve
s.optimizer = "ACO"
s.bestIndividual = gBest
s.best_score = gBestScore
s.objfname = objf.__name__
s.lb = lb
s.ub = ub
s.dim = dim
s.popnum = PopSize
s.maxiers = iters

return s
40 changes: 40 additions & 0 deletions changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# EvoloPy – ACO Addition

This is a clone of the [EvoloPy](https://github.com/7ossam81/EvoloPy) optimization framework, originally developed by Faris, Aljarah, Mirjalili, Castillo, and Guervós. All credit for the original framework and its algorithms belongs to the original authors.

## What was added

A new optimizer, **Ant Colony Optimization for Continuous Domains (ACO_R)**, has been implemented and added to the `optimizers` folder as `ACO.py`. The algorithm is based on the work of Socha & Dorigo (2008) and follows the same interface conventions as the existing optimizers in the package (PSO, GWO, etc.).

### Implementation notes

The core of ACO_R is a solution archive ranked by fitness. At each iteration, new candidate solutions are sampled from Gaussians centred on archive solutions, with the spread (sigma) computed as the average distance between archive members. A rank-based Gaussian weighting scheme biases selection toward better-ranked solutions.

A few corrections were made relative to a naive implementation of the algorithm:

- **Sigma formula**: The standard deviation used for Gaussian sampling follows Eq. 5 of Socha & Dorigo (2008) — an unweighted mean distance divided by `k - 1`. Using pheromone weights inside the sigma calculation (a common mistake) causes premature convergence.
- **Archive size**: Scales with problem dimension (`max(50, 10 * dim)`) rather than being fixed, which matters for higher-dimensional problems.
- **Convergence speed (`xi`)**: Set to `0.65` rather than the commonly cited `0.85`, which works better with moderate archive sizes.

## Usage

```python
from EvoloPy.optimizers.ACO import ACO

result = ACO(
objf=my_function,
lb=-100,
ub=100,
dim=30,
PopSize=50,
iters=500
)

print(result.best_score)
print(result.bestIndividual)
```

## References

- Socha, K., & Dorigo, M. (2008). Ant colony optimization for continuous domains. *European Journal of Operational Research*, 185(3), 1155–1173.
- Faris, H., Aljarah, I., Mirjalili, S., Castillo, P. A., & Guervós, J. J. M. (2016). EvoloPy: An open-source nature-inspired optimization framework in Python. *IJCCI (ECTA)*, 171–177.
4 changes: 2 additions & 2 deletions examples/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@

# Select optimizers
# "SSA","PSO","GA","BAT","FFA","GWO","WOA","MVO","MFO","CS","HHO","SCA","JAYA","DE"
optimizer = ["GA", "PSO", "GWO"]
optimizer = ["GA", "PSO", "GWO", "ACO"]

# Select benchmark function"
# "F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12","F13","F14","F15","F16","F17","F18","F19","F20","F21","F22","F23","F24"
# "ackley", "rosenbrock", "rastrigin", "griewank"
objectivefunc = ["F3", "F4"]
objectivefunc = ["F1", "F9", "F10"]

# Select number of repetitions for each experiment.
# To obtain meaningful statistical results, usually 30 independent runs are executed for each algorithm.
Expand Down
144 changes: 144 additions & 0 deletions tests/test_optimizers/test_ACO.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import sys
import os

# Get the absolute path to the EvoloPy directory
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
# Add the EvoloPy directory to the Python path
sys.path.append(base_dir)

import numpy as np
import pytest
from EvoloPy.optimizers.ACO import ACO

@pytest.fixture
def simple_bounds():
"""Scalar bounds for a 5-dimensional problem."""
return -10, 10, 5 # lb, ub, dim

@pytest.fixture
def list_bounds():
"""Per-dimension list bounds for a 5-dimensional problem."""
lb = [-10, -5, -8, -10, -3]
ub = [10, 5, 8, 10, 3]
return lb, ub, 5 # lb, ub, dim

@pytest.fixture
def sphere():
"""Simple unimodal objective — global minimum 0 at origin."""
def _sphere(x):
return np.sum(x ** 2)
_sphere.__name__ = "sphere"
return _sphere

def test_solution_fields_exist(simple_bounds, sphere):
"""ACO must return a solution object with all required fields."""
lb, ub, dim = simple_bounds
sol = ACO(sphere, lb, ub, dim, PopSize=20, iters=10)

assert hasattr(sol, "bestIndividual"), "Missing field: bestIndividual"
assert hasattr(sol, "best_score"), "Missing field: best_score"
assert hasattr(sol, "convergence"), "Missing field: convergence"
assert hasattr(sol, "optimizer"), "Missing field: optimizer"
assert hasattr(sol, "objfname"), "Missing field: objfname"
assert hasattr(sol, "startTime"), "Missing field: startTime"
assert hasattr(sol, "endTime"), "Missing field: endTime"
assert hasattr(sol, "executionTime"), "Missing field: executionTime"
assert hasattr(sol, "lb"), "Missing field: lb"
assert hasattr(sol, "ub"), "Missing field: ub"
assert hasattr(sol, "dim"), "Missing field: dim"
assert hasattr(sol, "popnum"), "Missing field: popnum"
assert hasattr(sol, "maxiers"), "Missing field: maxiers"


def test_optimizer_label(simple_bounds, sphere):
"""optimizer field must be set to 'ACO'."""
lb, ub, dim = simple_bounds
sol = ACO(sphere, lb, ub, dim, PopSize=20, iters=10)
assert sol.optimizer == "ACO"


def test_objfname(simple_bounds, sphere):
"""objfname must match the objective function name."""
lb, ub, dim = simple_bounds
sol = ACO(sphere, lb, ub, dim, PopSize=20, iters=10)
assert sol.objfname == "sphere"

def test_best_individual_shape(simple_bounds, sphere):
"""bestIndividual must have length == dim."""
lb, ub, dim = simple_bounds
sol = ACO(sphere, lb, ub, dim, PopSize=20, iters=10)
assert sol.bestIndividual.shape == (dim,), \
f"Expected shape ({dim},), got {sol.bestIndividual.shape}"


def test_convergence_shape(simple_bounds, sphere):
"""convergence curve must have length == iters."""
lb, ub, dim = simple_bounds
iters = 15
sol = ACO(sphere, lb, ub, dim, PopSize=20, iters=iters)
assert sol.convergence.shape == (iters,), \
f"Expected shape ({iters},), got {sol.convergence.shape}"

def test_best_score_is_numeric(simple_bounds, sphere):
"""best_score must be a numeric value."""
lb, ub, dim = simple_bounds
sol = ACO(sphere, lb, ub, dim, PopSize=20, iters=10)
assert isinstance(sol.best_score, (int, float, np.floating))


def test_best_score_non_negative_sphere(simple_bounds, sphere):
"""best_score on sphere must be >= 0."""
lb, ub, dim = simple_bounds
sol = ACO(sphere, lb, ub, dim, PopSize=20, iters=10)
assert sol.best_score >= 0


def test_best_individual_within_bounds(simple_bounds, sphere):
"""bestIndividual must lie within [lb, ub]."""
lb, ub, dim = simple_bounds
sol = ACO(sphere, lb, ub, dim, PopSize=20, iters=10)
assert np.all(sol.bestIndividual >= lb), "bestIndividual violates lower bound"
assert np.all(sol.bestIndividual <= ub), "bestIndividual violates upper bound"


def test_convergence_non_increasing(simple_bounds, sphere):
"""Convergence curve must be monotonically non-increasing."""
lb, ub, dim = simple_bounds
sol = ACO(sphere, lb, ub, dim, PopSize=30, iters=30)
diffs = np.diff(sol.convergence)
assert np.all(diffs <= 1e-10), \
"Convergence curve is not monotonically non-increasing"


def test_execution_time_positive(simple_bounds, sphere):
"""executionTime must be a positive number."""
lb, ub, dim = simple_bounds
sol = ACO(sphere, lb, ub, dim, PopSize=20, iters=10)
assert sol.executionTime > 0

def test_scalar_bounds_broadcast(sphere):
"""Scalar lb/ub must be broadcast correctly across all dimensions."""
dim = 8
sol = ACO(sphere, lb=-5, ub=5, dim=dim, PopSize=20, iters=10)
assert sol.bestIndividual.shape == (dim,)
assert np.all(sol.bestIndividual >= -5)
assert np.all(sol.bestIndividual <= 5)


def test_list_bounds(list_bounds, sphere):
"""Per-dimension list bounds must be respected."""
lb, ub, dim = list_bounds
sol = ACO(sphere, lb, ub, dim, PopSize=20, iters=10)
assert np.all(sol.bestIndividual >= lb), "bestIndividual violates lower bound"
assert np.all(sol.bestIndividual <= ub), "bestIndividual violates upper bound"

def test_converges_on_sphere():
"""ACO should find a near-zero solution on the sphere function."""
def sphere(x):
return np.sum(x ** 2)
sphere.__name__ = "sphere"

np.random.seed(0)
sol = ACO(sphere, lb=-100, ub=100, dim=10, PopSize=50, iters=300)
assert sol.best_score < 1.0, \
f"Expected near-zero on sphere, got {sol.best_score}"