"""
Scenario-based Self-Learning Transfer Differential Evolution (SSLT-DE)
This module implements SSLT-DE for multi-task optimization using a DQN-based
reinforcement learning framework to adaptively select among four knowledge
transfer scenarios.
References
----------
[1] Z. Yuan, G. Dai, L. Peng, M. Wang, Z. Song, and X. Chen, "Scenario-based
self-learning transfer framework for multi-task optimization problems,"
Knowledge-Based Systems, vol. 325, p. 113824, 2025.
Notes
-----
Author: Jiangtao Shen
Email: j.shen5@exeter.ac.uk
Date: 2026.02.22
Version: 1.0
"""
import time
import numpy as np
import torch
import torch.nn as nn
from tqdm import tqdm
from ddmtolab.Methods.Algo_Methods.algo_utils import *
# ---------------------------------------------------------------------------
# Dropout network hyper-parameters (MToP: SSLT/Dropout/{trainmodel,updatemodel,
# trainNet,testNet,iniA}.m). Everything below matches the reference except the
# number of mini-batch steps: MATLAB runs 80000 steps to build and 8000 steps to
# update, i.e. ~16M sample gradients per build, which is intractable here. The
# round counts are therefore scaled down by 10x while keeping the 10:1 ratio.
# ---------------------------------------------------------------------------
_BUILD_ROUNDS = 8000
_UPDATE_ROUNDS = 800
_BATCH_SIZE = 200
_LEARN_RATE = 0.01
_WEIGHT_DECAY = 1e-05
_HIDDEN = 40
_DROP_P = (0.2, 0.5)
# ============================================================================
# Helper Functions
# ============================================================================
def _matlab_round(x):
"""MATLAB round(): half away from zero (numpy rounds half to even)."""
return int(np.floor(x + 0.5)) if x >= 0 else -int(np.floor(-x + 0.5))
def _safe_div(num, den):
"""
MATLAB evaluates the ratios below without guarding the denominator (a zero
denominator yields Inf/NaN which then poisons the network inputs). Zero
denominators are mapped to 0 here so the state stays finite; every other
case is bit-identical to the reference.
"""
return float(num) / float(den) if den != 0 else 0.0
def _wasserstein_1d(u_decs, v_decs):
"""
1D Wasserstein distance between two flattened population arrays
(SSLT_DE.m ``Ws`` + ``find_interval``).
"""
u = np.sort(u_decs.ravel())
v = np.sort(v_decs.ravel())
all_vals = np.unique(np.concatenate([u, v]))
if len(all_vals) < 2:
return 0.0
u_cdf = np.searchsorted(u, all_vals[:-1], side='right') / len(u)
v_cdf = np.searchsorted(v, all_vals[:-1], side='right') / len(v)
return float(np.sum(np.abs(u_cdf - v_cdf) * np.diff(all_vals)))
def _dispersion_metric(decs, objs):
"""
Mean pairwise squared distance among the top 10% individuals
(SSLT_DE.m ``Dispersion``).
Notes
-----
The MATLAB reference indexes ``M_pop(i + 1:end)`` linearly instead of
row-wise, so ``up`` accumulates vectors of different lengths and the
function raises a size-mismatch error for any M >= 3 (M = round(0.1 * N),
i.e. M = 10 at the default N = 100). The intended scalar reading -- the
sum of squared distances over all elite pairs -- is used here.
"""
M = max(_matlab_round(0.1 * len(objs)), 1)
rank = np.argsort(objs.flatten(), kind='stable')
top = decs[rank[:M]]
if M <= 1:
return 0.0
total = 0.0
for i in range(M - 1):
diff = top[i + 1:] - top[i]
total += np.sum(diff ** 2)
return total / (M * (M - 1))
def _dispersion_type(decs, objs, decs_old, objs_old):
"""Compare dispersion: 1=decreasing, 2=same, 3=increasing."""
dm = _dispersion_metric(decs, objs)
dm_old = _dispersion_metric(decs_old, objs_old)
if dm - dm_old < 0:
return 1
elif dm - dm_old == 0:
return 2
else:
return 3
def _convergence_dist(decs_old, decs_new):
"""Euclidean distance between old and new population centers."""
c_old = np.mean(decs_old, axis=0)
c_new = np.mean(decs_new, axis=0)
return float(np.sqrt(np.sum((c_old - c_new) ** 2)))
def _smooth(decs, objs):
"""
Keep the best individual of every consecutive triple (SSLT_DE.m ``Smooth``).
MATLAB deletes the 2nd- and 3rd-best member of each triple and keeps
everything else, so any trailing individuals that do not fill a complete
triple survive.
"""
n = len(decs)
delete = set()
for i in range(0, n - 2, 3):
order = np.argsort(objs[i:i + 3].flatten(), kind='stable')
delete.add(i + order[1])
delete.add(i + order[2])
keep = [i for i in range(n) if i not in delete]
return decs[keep], objs[keep]
def _de_crossover_single(trial, target, CR):
"""
Binomial crossover for a single pair, matching MToP's ``DE_Crossover``:
the trial value is kept where rand < CR (plus one forced position) and the
target value is taken elsewhere.
"""
d = len(trial)
mask = np.random.rand(d) < CR
mask[np.random.randint(d)] = True
offspring = target.copy()
offspring[mask] = trial[mask]
return offspring
def _de_generation_sslt(parents, F, CR):
"""
DE/rand/1/bin exactly as SSLT_DE.m ``Generation``.
x1, x2 and x3 are distinct from each other but -- unlike the shared
``de_generation`` helper -- they are *not* excluded from equalling the
target index i.
"""
n, d = parents.shape
off = np.zeros((n, d))
for i in range(n):
x1 = np.random.randint(n)
x2 = np.random.randint(n)
while x2 == x1:
x2 = np.random.randint(n)
x3 = np.random.randint(n)
while x3 == x2 or x3 == x1:
x3 = np.random.randint(n)
v = parents[x1] + F * (parents[x2] - parents[x3])
off[i] = _de_crossover_single(v, parents[i], CR)
return np.clip(off, 0.0, 1.0)
def _selection_tournament(p_objs, p_cons, o_objs, o_cons):
"""
One-to-one selection matching MToP's ``Selection_Tournament`` (epsilon = 0):
the parent is replaced only if both are infeasible and the offspring has a
strictly lower CV, or both are feasible and the offspring has a strictly
lower objective. Ties always keep the parent.
"""
n = p_objs.shape[0]
p_cv = np.sum(np.maximum(0, p_cons), axis=1) \
if p_cons.shape[1] > 0 else np.zeros(n)
o_cv = np.sum(np.maximum(0, o_cons), axis=1) \
if o_cons.shape[1] > 0 else np.zeros(n)
replace_cv = (p_cv > o_cv) & (p_cv > 0) & (o_cv > 0)
equal_cv = (p_cv <= 0) & (o_cv <= 0)
replace_f = p_objs[:, 0] > o_objs[:, 0]
return (equal_cv & replace_f) | replace_cv
def _constrained_rank(objs, cons):
"""Sort ascending by constraint violation then objective (sortrows [1,2])."""
n = objs.shape[0]
cv = np.sum(np.maximum(0, cons), axis=1) if cons.shape[1] > 0 \
else np.zeros(n)
return np.lexsort((objs[:, 0], cv))
def _pad_cons(cons_real, max_c):
"""Pad a per-task constraint matrix to the unified width max_c."""
n = cons_real.shape[0]
out = np.zeros((n, max_c))
if max_c > 0 and cons_real.shape[1] > 0:
c = min(max_c, cons_real.shape[1])
out[:, :c] = cons_real[:, :c]
return out
def _normalize(X):
"""Min-max normalize columns to [-1, 1], matching MATLAB mapminmax."""
mins = X.min(axis=0)
maxs = X.max(axis=0)
rng = maxs - mins
rng[rng == 0] = 1.0
return 2.0 * (X - mins) / rng - 1.0, mins, maxs
def _normalize_apply(x, mins, maxs):
"""Apply saved min-max normalization."""
rng = maxs - mins
rng[rng == 0] = 1.0
return 2.0 * (x - mins) / rng - 1.0
# ============================================================================
# Q-Network for DQN (port of MToP SSLT/Dropout)
# ============================================================================
class _QNet(nn.Module):
"""
Port of MToP's ``trainNet``/``testNet``: 7 -> 40 (ReLU) -> 40 (tanh) -> 1,
with dropout p = 0.2 on the input and p = 0.5 on the first hidden
activation. MATLAB's ``testNet`` applies dropout at inference time as well,
so predictions are stochastic; that behaviour is reproduced here.
The reference network has 7 outputs (reward plus the 6 next-state features)
but only column 1 is ever read, and ``updatemodel`` broadcasts the scalar
Q-target onto all 7 columns. A single output is therefore equivalent.
"""
def __init__(self, input_dim=7, hidden_dim=_HIDDEN, output_dim=1):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.fc3 = nn.Linear(hidden_dim, output_dim)
self.drop_in = nn.Dropout(_DROP_P[0])
self.drop_hidden = nn.Dropout(_DROP_P[1])
# iniA.m: W ~ N(0, 1/fan_in); B{1} = 0.1 * sqrt(1/n); B{2}, B{3} random
with torch.no_grad():
for layer in (self.fc1, self.fc2, self.fc3):
fan_in = layer.weight.shape[1]
layer.weight.normal_(0.0, np.sqrt(1.0 / fan_in))
self.fc1.bias.fill_(0.1 * np.sqrt(1.0 / hidden_dim))
self.fc2.bias.normal_(0.0, np.sqrt(1.0 / hidden_dim))
self.fc3.bias.normal_(0.0, np.sqrt(1.0 / output_dim))
def forward(self, x):
x = self.drop_in(x)
x = torch.relu(self.fc1(x))
x = self.drop_hidden(x)
x = torch.tanh(self.fc2(x))
return self.fc3(x)
def _train_qnet(model, X, y, rounds):
"""Mini-batch SGD exactly as trainNet.m (0.5 * squared error, lr = 0.01)."""
weights = [model.fc1.weight, model.fc2.weight, model.fc3.weight]
biases = [model.fc1.bias, model.fc2.bias, model.fc3.bias]
optimizer = torch.optim.SGD(
[{'params': weights, 'weight_decay': _WEIGHT_DECAY / _BATCH_SIZE},
{'params': biases, 'weight_decay': 0.0}], lr=_LEARN_RATE)
X_t = torch.tensor(np.asarray(X), dtype=torch.float32)
y_t = torch.tensor(np.asarray(y), dtype=torch.float32).reshape(-1, 1)
n = X_t.shape[0]
model.train()
for _ in range(rounds):
sel = torch.randint(0, n, (_BATCH_SIZE,))
pred = model(X_t[sel])
loss = 0.5 * ((pred - y_t[sel]) ** 2).sum() / _BATCH_SIZE
optimizer.zero_grad()
loss.backward()
optimizer.step()
def _predict_qnet(model, X):
"""Forward pass with dropout active, matching testNet.m."""
model.train()
with torch.no_grad():
out = model(torch.tensor(np.asarray(X), dtype=torch.float32))
return out.numpy().reshape(-1)
# ============================================================================
# SSLT-DE Algorithm
# ============================================================================
[docs]
class SSLT_DE:
"""
Scenario-based Self-Learning Transfer Differential Evolution.
Uses a DQN-based reinforcement learning framework to adaptively select
among four knowledge transfer scenarios:
1. No transfer (standard DE/rand/1/bin)
2. Shape transfer (shift smoothed source toward target center)
3. Bi-directional transfer (DE on merged populations)
4. Domain transfer (direction-guided from best source-target difference)
Attributes
----------
algorithm_information : dict
Dictionary containing algorithm capabilities and requirements
"""
algorithm_information = {
'n_tasks': '[2, K]',
'dims': 'unequal',
'objs': 'equal',
'n_objs': '1',
'cons': 'unequal',
'n_cons': '[0, C]',
'expensive': 'False',
'knowledge_transfer': 'True',
'n': 'equal',
'max_nfes': 'equal'
}
@classmethod
def get_algorithm_information(cls, print_info=True):
return get_algorithm_information(cls, print_info)
[docs]
def __init__(self, problem, n=None, max_nfes=None,
threshold=150, gap=50, gamma=0.9, epsilon=0.8,
F=0.5, CR=0.9,
save_data=True, save_path='./Data', name='SSLT-DE',
disable_tqdm=True):
"""
Initialize SSLT-DE algorithm.
Parameters
----------
problem : MTOP
Multi-task optimization problem instance
n : int, optional
Population size per task (default: 100)
max_nfes : int, optional
Maximum number of function evaluations per task (default: 10000)
threshold : int, optional
Number of generations before building DQN (default: 150)
gap : int, optional
DQN update interval in generations (default: 50)
gamma : float, optional
Discount factor for Q-learning (default: 0.9)
epsilon : float, optional
Epsilon-greedy exploration rate (default: 0.8)
F : float, optional
DE mutation scale factor (default: 0.5)
CR : float, optional
DE crossover rate (default: 0.9)
save_data : bool, optional
Whether to save optimization data (default: True)
save_path : str, optional
Path to save results (default: './Data')
name : str, optional
Name for the experiment (default: 'SSLT-DE')
disable_tqdm : bool, optional
Whether to disable progress bar (default: True)
"""
self.problem = problem
self.n = n if n is not None else 100
self.max_nfes = max_nfes if max_nfes is not None else 10000
self.threshold = threshold
self.gap = gap
self.gamma = gamma
self.epsilon = epsilon
self.F = F
self.CR = CR
self.save_data = save_data
self.save_path = save_path
self.name = name
self.disable_tqdm = disable_tqdm
[docs]
def optimize(self):
"""
Execute the SSLT-DE algorithm.
Returns
-------
Results
Optimization results containing decision variables, objectives, and runtime
"""
start_time = time.time()
problem = self.problem
nt = problem.n_tasks
dims = problem.dims
n = self.n
max_nfes = self.max_nfes * nt
# Initialize and evaluate
decs = initialization(problem, n)
objs, cons = evaluation(problem, decs)
nfes = n * nt
# The chosen transfer action decides how many offspring each task gets,
# so the per-task counts diverge from the scalar budget counter
nfes_per_task = [n] * nt
all_decs, all_objs, all_cons = init_history(decs, objs, cons)
# Convert to unified space for cross-task operations
pop_decs, pop_cons = space_transfer(
problem=problem, decs=decs, cons=cons, type='uni', padding='mid')
pop_objs = [o.copy() for o in objs]
maxD = pop_decs[0].shape[1]
maxC = pop_cons[0].shape[1]
# Per-task DQN state
data_task = [[] for _ in range(nt)] # Experience replay buffer
model_built = [False] * nt # Whether DQN is built
count_task = [0] * nt # Update counter
q_model = [None] * nt # Q-network per task
norm_params = [None] * nt # Normalization parameters
# Store previous generation populations
pop_decs_old = [d.copy() for d in pop_decs]
pop_objs_old = [o.copy() for o in pop_objs]
# MToP increments Algo.Gen inside notTerminated before the loop body
# runs, so the first executed generation is Gen = 2.
gen = 2
pbar = tqdm(total=max_nfes, initial=nfes, desc=f"{self.name}",
disable=self.disable_tqdm)
while nfes < max_nfes:
for t in range(nt):
if nfes >= max_nfes:
break
# Random source task
s = np.random.randint(nt)
while s == t and nt > 1:
s = np.random.randint(nt)
# ============================================================
# Compute state features
# ============================================================
min_old_t = np.min(pop_objs_old[t])
min_cur_t = np.min(pop_objs[t])
min_old_s = np.min(pop_objs_old[s])
min_cur_s = np.min(pop_objs[s])
conv_target = _safe_div(min_old_t - min_cur_t, min_old_t)
conv_source = _safe_div(min_old_s - min_cur_s, min_old_s)
wsd = _wasserstein_1d(pop_decs[t], pop_decs[s])
ls_target = _dispersion_type(pop_decs[t], pop_objs[t],
pop_decs_old[t], pop_objs_old[t])
ls_source = _dispersion_type(pop_decs[s], pop_objs[s],
pop_decs_old[s], pop_objs_old[s])
pha = nfes / max_nfes
state = np.array([conv_source, conv_target, wsd,
ls_target, ls_source, pha])
# ============================================================
# Action selection
# ============================================================
if gen <= self.threshold:
action = np.random.randint(1, 5)
elif not model_built[t]:
# Build DQN model
exp = np.array(data_task[t])
X_raw = exp[:, :7]
y_raw = exp[:, 7]
X_norm, x_min, x_max = _normalize(X_raw)
y_norm, y_min, y_max = _normalize(y_raw.reshape(-1, 1))
y_norm = y_norm.flatten()
q_model[t] = _QNet()
_train_qnet(q_model[t], X_norm, y_norm, _BUILD_ROUNDS)
norm_params[t] = (x_min, x_max, y_min, y_max)
model_built[t] = True
action = np.random.randint(1, 5)
else:
# Epsilon-greedy
if np.random.rand() > self.epsilon:
action = np.random.randint(1, 5)
else:
x_min, x_max, y_min, y_max = norm_params[t]
cand = np.array([np.append(state, a)
for a in range(1, 5)])
cand = _normalize_apply(cand, x_min, x_max)
q_vals = _predict_qnet(q_model[t], cand)
action = int(np.argmax(q_vals)) + 1
# ============================================================
# Execute action
# ============================================================
pop_decs_old[t] = pop_decs[t].copy()
pop_objs_old[t] = pop_objs[t].copy()
if action == 1:
# No KT: standard DE/rand/1/bin
off_decs = _de_generation_sslt(pop_decs[t], self.F, self.CR)
off_objs, off_cons_real = evaluation_single(
problem, off_decs[:, :dims[t]], t)
off_cons = _pad_cons(off_cons_real, maxC)
nfes += n
nfes_per_task[t] += n
pbar.update(n)
# One-to-one Selection_Tournament
better = _selection_tournament(
pop_objs[t], pop_cons[t], off_objs, off_cons)
pop_decs[t][better] = off_decs[better]
pop_objs[t][better] = off_objs[better]
pop_cons[t][better] = off_cons[better]
elif action == 2:
# Shape KT: shift smoothed source toward target center.
# MATLAB does not clip the shifted decisions before
# evaluating them, so no clipping is applied here either.
sm_s_decs, sm_s_objs = _smooth(pop_decs[s], pop_objs[s])
sm_t_decs, _ = _smooth(pop_decs[t], pop_objs[t])
center_t = np.mean(sm_t_decs, axis=0)
center_s = np.mean(sm_s_decs, axis=0)
shifted = sm_s_decs + (center_t - center_s)
n_shifted = len(shifted)
sh_objs, sh_cons_real = evaluation_single(
problem, shifted[:, :dims[t]], t)
sh_cons = _pad_cons(sh_cons_real, maxC)
nfes += n_shifted
nfes_per_task[t] += n_shifted
pbar.update(n_shifted)
# Elite selection (target U shifted)
merged_decs = np.vstack([pop_decs[t], shifted])
merged_objs = np.vstack([pop_objs[t], sh_objs])
merged_cons = np.vstack([pop_cons[t], sh_cons])
sel = selection_elit(objs=merged_objs, n=n,
cons=merged_cons)
pop_decs[t] = merged_decs[sel]
pop_objs[t] = merged_objs[sel]
pop_cons[t] = merged_cons[sel]
elif action == 3:
# Bi-KT: DE on merged populations
merged = np.vstack([pop_decs[t], pop_decs[s]])
n_merged = len(merged)
off_decs = np.zeros_like(merged)
for i in range(n_merged):
# DE/current-to-rand/1 on the merged population
idxs = list(range(n_merged))
idxs.remove(i)
r1, r2, r3 = np.random.choice(idxs, 3, replace=False)
v = merged[i] + self.F * (merged[r1] - merged[i]) \
+ 0.5 * (merged[r2] - merged[r3])
off_decs[i] = _de_crossover_single(v, merged[i], self.CR)
off_decs = np.clip(off_decs, 0, 1)
off_objs, off_cons_real = evaluation_single(
problem, off_decs[:, :dims[t]], t)
off_cons = _pad_cons(off_cons_real, maxC)
nfes += n_merged
nfes_per_task[t] += n_merged
pbar.update(n_merged)
# Elite selection (target U offspring)
merged_sel = np.vstack([pop_decs[t], off_decs])
merged_sel_objs = np.vstack([pop_objs[t], off_objs])
merged_sel_cons = np.vstack([pop_cons[t], off_cons])
sel = selection_elit(objs=merged_sel_objs, n=n,
cons=merged_sel_cons)
pop_decs[t] = merged_sel[sel]
pop_objs[t] = merged_sel_objs[sel]
pop_cons[t] = merged_sel_cons[sel]
elif action == 4:
# Domain KT: direction-guided transfer
rank_s = _constrained_rank(pop_objs[s], pop_cons[s])
rank_t = _constrained_rank(pop_objs[t], pop_cons[t])
direction = pop_decs[s][rank_s[0]] - pop_decs[t][rank_t[0]]
num = max(1, _matlab_round(pha * 10))
perm = np.random.permutation(n)
off_decs = np.zeros((num, maxD))
for i in range(num):
idx = perm[i % n]
off_decs[i] = _de_crossover_single(
pop_decs[t][idx], direction, self.CR)
off_decs = np.clip(off_decs, 0, 1)
off_objs, off_cons_real = evaluation_single(
problem, off_decs[:, :dims[t]], t)
off_cons = _pad_cons(off_cons_real, maxC)
nfes += num
nfes_per_task[t] += num
pbar.update(num)
# Elite selection (target U offspring)
merged_decs = np.vstack([pop_decs[t], off_decs])
merged_objs = np.vstack([pop_objs[t], off_objs])
merged_cons = np.vstack([pop_cons[t], off_cons])
sel = selection_elit(objs=merged_objs, n=n,
cons=merged_cons)
pop_decs[t] = merged_decs[sel]
pop_objs[t] = merged_objs[sel]
pop_cons[t] = merged_cons[sel]
# ============================================================
# Compute reward and store experience
# ============================================================
fold = np.min(pop_objs_old[t])
f = np.min(pop_objs[t])
fold_mean = np.mean(pop_objs_old[t])
f_mean = np.mean(pop_objs[t])
imp_rate = _safe_div(fold - f, fold)
pop_rate = _safe_div(fold_mean - f_mean, fold_mean)
move_dis = _convergence_dist(pop_decs_old[t], pop_decs[t])
vals = np.array([imp_rate, pop_rate, move_dis])
max_val, min_val = vals.max(), vals.min()
rng = max_val - min_val
if rng != 0:
imp_rate_n = (imp_rate - min_val) / rng
pop_rate_n = (pop_rate - min_val) / rng
move_dis_n = (move_dis - min_val) / rng
else:
imp_rate_n = pop_rate_n = move_dis_n = 0.0
pha_new = nfes / max_nfes
reward = (imp_rate_n + pop_rate_n + move_dis_n) * pha_new
# New state features
conv_new_target = _safe_div(
np.min(pop_objs_old[t]) - np.min(pop_objs[t]),
np.min(pop_objs_old[t]))
conv_new_source = _safe_div(
np.min(pop_objs_old[s]) - np.min(pop_objs[s]),
np.min(pop_objs_old[s]))
wsd_new = _wasserstein_1d(pop_decs[s], pop_decs[t])
ls_new_target = _dispersion_type(pop_decs[t], pop_objs[t],
pop_decs_old[t], pop_objs_old[t])
ls_new_source = _dispersion_type(pop_decs[s], pop_objs[s],
pop_decs_old[s], pop_objs_old[s])
record = np.array([
conv_source, conv_target, wsd, ls_target, ls_source, pha, action,
reward, conv_new_source, conv_new_target, wsd_new,
ls_new_target, ls_new_source, pha_new
])
data_task[t].append(record)
if len(data_task[t]) > 500:
data_task[t].pop(0)
# ============================================================
# Update DQN periodically
# ============================================================
if model_built[t]:
count_task[t] += 1
if count_task[t] > self.gap:
exp = np.array(data_task[t])
X_raw = exp[:, :7]
rewards_raw = exp[:, 7]
X_norm, x_min, x_max = _normalize(X_raw)
# Bootstrapped target: R + gamma * max(Q)
max_q = np.max(_predict_qnet(q_model[t], X_norm))
target_q = rewards_raw + self.gamma * max_q
y_norm, y_min, y_max = _normalize(
target_q.reshape(-1, 1))
y_norm = y_norm.flatten()
norm_params[t] = (x_min, x_max, y_min, y_max)
_train_qnet(q_model[t], X_norm, y_norm, _UPDATE_ROUNDS)
count_task[t] = 0
# Record history in real space
real_decs, real_cons = space_transfer(
problem, decs=pop_decs, cons=pop_cons, type='real')
append_history(all_decs, real_decs, all_objs, pop_objs,
all_cons, real_cons)
gen += 1
pbar.close()
runtime = time.time() - start_time
results = build_save_results(
all_decs=all_decs, all_objs=all_objs, runtime=runtime,
max_nfes=nfes_per_task, all_cons=all_cons,
bounds=problem.bounds, save_path=self.save_path,
filename=self.name, save_data=self.save_data)
return results