Crossover operators in DESDEO¶
Crossover is the step in an evolutionary algorithm that turns (usually) two parent solutions into one or more offsprings. It is the main source of exploration in an EA. Mutation nudges a single solution and introduces small changes, crossover recombines information that two different solutions have discovered.
DESDEO implements nine crossover operators. They all expose the same interface, so swapping one for another is a one-line change, as long as the problem is compatible with the operator. Each operator currently is designed to handle, e.g., either a problem with all continuous variables, all binary variables, all integer variables or mixed integer/continuous variables.
This page is about what these operators do, shown through the distribution of the offspring they generate based on specific parent populations. The implementation details of these operators are presented at the end of this page.
Related reading:
- Evolutionary Algorithms in DESDEO — how operators are assembled into an algorithm.
- Pydantic interface and Using evolutionary algorithms — how to configure an operator without writing code.
Comparison at a glance¶
| Operator | Variable type | Parameters | Where the offspring land | How the bounds are respected |
|---|---|---|---|---|
SimulatedBinaryCrossover |
continuous | xover_distribution (η), xover_probability, uniform_xover_probability, truncated |
close to both parents; polynomial density in a spread factor β | the sampling distribution is truncated at the bounds, or the offspring are clipped onto them if truncated=False |
BlendAlphaCrossover |
continuous | alpha, repeats, sample_each_component |
uniformly over the parent interval, widened by α | sampling interval is intersected with the bounds |
LocalCrossover |
continuous | — | uniformly between the parents, fresh weight per variable | cannot leave the parent segment |
SingleArithmeticCrossover |
continuous | xover_probability |
one gene set to the parent mean, every other gene copied | cannot leave the parent segment |
BoundedExponentialCrossover |
continuous | lambda_ (λ), xover_probability, uniform_xover_probability |
around its own parent, exponential decay | the sampling distribution is truncated at the bounds |
DifferentialEvolutionCrossover |
continuous | scaling_factor (F), xover_probability (CR) |
on its own target, displaced by the difference of two other population members | offspring are clipped onto the bounds |
ParentCentricCrossover |
continuous | sigma_zeta, sigma_eta |
around one of three parents, along and across the direction to their centroid | offspring are clipped onto the bounds |
UniformCrossover |
continuous | uniform_xover_probability, pair_xover_probability |
on the parents; each gene independently taken from one or the other, never blended | cannot leave the parents' values, so the bounds are respected by construction |
SinglePointBinaryCrossover |
binary | — | contiguous blocks swapped at one cut point | n/a |
UniformIntegerCrossover |
integer | — | each gene independently taken from either parent | n/a |
UniformMixedIntegerCrossover |
mixed | — | as above, without rounding the continuous genes | n/a |
CompositeCrossover |
inherited | operators |
delegates to sub-operators in round-robin | inherited |
Behaviour of operators for continuous variables¶
Both parents below have every variable at $3.0$ and $7.0$ respectively, on the box $[0, 10]^2$. Each panel shows the distribution of 80000 offspring of the first variable, drawn from that same pair of parents over and over.
Some remarks on the distributions:
- Simulated binary, at its default $\eta = 30$, is the most concentrated operator on the page. Every offspring lands within $[2.06, 7.94]$ and all of them fall within $0.5$ of a parent. This behaviour is remarked upon in literature and is intended.
- Bounded exponential, at its default $\lambda = 0.1$, looks much the same: 72% of its offspring land within $0.5$ of a parent, and almost none in the middle. It differs in having thin tails that do reach the rest of the range.
- Blend alpha and local fill an interval uniformly instead. Local never leaves $[3, 7]$; blend alpha spills out to $[1, 9]$, a margin set by default $\alpha = 0.5$.
- Uniform is the degenerate case: two spikes, one on each parent, and nothing anywhere else. It is the only operator here that creates no new values, which is what makes it the reference point for what recombination contributes without interpolation.
- Single arithmetic produces three spikes, and the middle one holds half the mass. With two variables it modifies $x_1$ only half the time: those offspring land exactly on the parent mean, and the rest keep their own parent's $x_1$ untouched, a quarter on each parent.
Two operators are missing from that figure, and unavoidably so: differential evolution and parent-centric crossover recombine more than two parents, so a fixed pair does not define their behaviour. They get their own section below.
The operators fall into two categories: those that pile offspring onto the parents versus operators that fill the space between them. The rest of this page takes each operator in turn, starting with the two parent-centric ones.
Simulated binary crossover (SBX)¶
SBX is the default for every continuous algorithm in DESDEO. It was designed to reproduce, for real-valued variables, the effect single-point crossover has on binary strings: offspring that mostly resemble one parent or the other, with the occasional distant explorer.
It draws a spread factor $\beta$ from a polynomial distribution controlled by the distribution index $\eta$:
$$ P(\beta) \;\propto\; \begin{cases} \beta^{\eta} & \beta \le 1 \quad \text{(contracting)}\\[4pt] \beta^{-(\eta+2)} & \beta > 1 \quad \text{(expanding)} \end{cases} $$
Sampling it by inverting the CDF against a uniform $\mu$ gives us:
$$ \beta = \begin{cases} (2\mu)^{\frac{1}{\eta+1}} & \mu \le 0.5\\[4pt] \bigl(2 - 2\mu\bigr)^{-\frac{1}{\eta+1}} & \mu > 0.5 \end{cases} \qquad c_{1,2} = \bar{x} \mp \beta\,\frac{x_1 - x_2}{2} $$
where $\bar{x}$ is the parent mean. The density of $\beta$ peaks at $\beta = 1$, and $\beta = 1$ means "land on a parent".
Note what the second equation says: SBX is built around the parent mean, and the sign in front of $\beta$ decides which parent a given child is pulled toward. That sign is a coin flip, and it is a knob — see per-variable mixing below.
References: Deb & Agrawal (1995), Simulated binary crossover for continuous search space, Complex Systems 9(2); Deb & Gulati (2001), Design of truss-structures for minimum weight using genetic algorithms, Finite Elements in Analysis and Design 37(5).
# Built with its defaults: xover_distribution=30, xover_probability=1.0,
# uniform_xover_probability=0.5, truncated=True.
sbx = SimulatedBinaryCrossover(problem=PROBLEM, publisher=Publisher(), verbosity=1, seed=0)
η controls how far offspring stray¶
$\eta$ is the only parameter that changes the shape of the distribution, and it changes it drastically. The tail of $P(\beta)$ is a power law, so it is heavy: at $\eta = 2$ the sampled offspring of parents at $3$ and $7$ routinely fall outside $[0, 10]$ and are pulled back onto the bounds, leaving 3.2% of them sitting exactly on a bound. By $\eta = 15$ nothing reaches a bound at all, and at the default $\eta = 30$ the whole distribution has collapsed very close to the two parents.
truncated: whether the sampling distribution itself is truncated¶
Both variants return offspring inside the feasible box — that is no longer the difference between them. What differs is how they get there.
truncated=True(the default) uses the truncated formulation of Deb & Gulati, which rescales $\beta$ per variable so the offspring is generated inside the bounds in the first place. Nothing has to be clipped.truncated=Falsesamples $\beta$ from the untruncated polynomial density above, computes the offspring, and then clips whatever fell outside back onto the nearest bound.
Clipping takes all the probability mass that fell outside and stacks it onto a single value, producing a point mass sitting exactly on the bound. Truncation instead redistributes that mass smoothly across the interior, so no single value is special.
Most other frameworks — pymoo, jMetalPy, Platypus, pagmo2, and Deb's NSGA-II C code — implement the truncated
variant while simply calling it "SBX". PlatEMO on the other hand implements the untruncated variant,
also calling it "SBX". DESDEO gives you both and defaults to the truncated one, so it agrees with
the majority out of the box; set truncated=False if you want PlatEMO's behaviour.
How much it matters depends on how much mass the untruncated density puts outside the box, which in turn depends on how close the parents sit to a bound and on $\eta$. The panels below sweep the parents from hard against the lower bound to the middle of the range. They use $\eta = 5$ rather than the default $\eta = 30$, because at the default the distribution is so tight that neither variant ever reaches a bound and the two are indistinguishable unless one or both of the parents are at a bound. This behaviour can have a significant effect on the search, especially in problems where the optimum sits on a bound.
Bounded exponential crossover (BEX)¶
BEX is parent-centric in the strictest sense: each offspring is a displacement away from its own parent, not from the parent mean.
That holds at the default uniform_xover_probability = 0.0. Raising it lets the two
offspring exchange, per variable, which parent they descend from — the same
uniform-crossover component SimulatedBinaryCrossover carries under that name. At
0.5 roughly half of each offspring's variables come from the other parent's line,
which trades the strict parent-centricity above for mixing between the two lines.
$$ c = x + \beta\,|x_1 - x_2| $$
where $\beta$ comes from inverting the CDF of an exponential distribution that has been truncated at the variable bounds — one branch toward the lower bound, one toward the upper, chosen by a coin flip:
$$ \beta = \begin{cases} \lambda \log\!\bigl(e_{\text{lo}} + u\,(1 - e_{\text{lo}})\bigr), & r \le 0.5\\[4pt] -\lambda \log\!\bigl(1 - u\,(1 - e_{\text{hi}})\bigr), & r > 0.5 \end{cases} \qquad \begin{aligned} e_{\text{lo}} &= \exp\!\Bigl(\tfrac{x^{\text{lo}} - x}{\lambda\,|x_1 - x_2|}\Bigr)\\ e_{\text{hi}} &= \exp\!\Bigl(\tfrac{x - x^{\text{up}}}{\lambda\,|x_1 - x_2|}\Bigr) \end{aligned} $$
Because the truncation is built into the sampling, the offspring are inside the bounds by construction.
λ scales the exponential relative to the distance between the parents. It changes
the spread only: the median offspring sits on its own parent for every λ — again with
uniform_xover_probability at its default, since exchanging a variable moves that
offspring's median onto the other parent.
References: Thakur, Meghwani & Jalota (2014), A modified real coded genetic algorithm for constrained optimization, Applied Mathematics and Computation 235; modifying the Laplace crossover of Deep & Thakur (2007).
# Built with its defaults: lambda_=0.1, xover_probability=1.0.
bex = BoundedExponentialCrossover(problem=PROBLEM, publisher=Publisher(), verbosity=1, seed=0)
Every curve peaks on the parents and decays away from them; λ changes only how fast. At $\lambda = 0.01$ the operator is barely doing anything — every offspring is within $0.5$ of a parent. The default $\lambda = 0.1$ loosens that to 72%, and by $\lambda = 0.5$ only 29% are still near a parent and the distribution is flattening toward uniform.
What λ never does is move the peaks: the median offspring sits on its own parent at every setting. Contrast SBX, where η reshapes a polynomial density built around the parent mean, and where offspring essentially never land at the midpoint at all.
SBX and BEX at their defaults¶
The two operators share a family resemblance — both put their offspring on the parents rather than between them — but their default settings are worlds apart in how far they let the offspring wander.
At their defaults the SBX operator is much more concentrated than BEX. However, the general behaviour of the two operators is similar: both have a peak on the parents and a tail that decays away from them.
Blend alpha crossover (BLX-α)¶
BLX-α takes the interval spanned by the two parents, widens it by a fraction α of its own length at both ends, and samples uniformly from the range:
$$ c \sim U\bigl[\,c_{\min} - \alpha d,\; c_{\max} + \alpha d\,\bigr], \qquad d = |x_1 - x_2|. $$
If the bound of the above interval exceeds the variable bounds, it is clipped to the closest bound before sampling. At $\alpha = 0$ offspring are confined strictly between the parents; larger α lets them explore outside.
BLX-α is also the one operator that can produce any number offspring per pair,
via the repeats argument, which repeatedly samples from the interval.
Reference: Eshelman & Schaffer (1993), Real-coded genetic algorithms and interval-schemata, FOGA 2.
# Built with its defaults: alpha=0.5, repeats=2, sample_each_component=True.
blx = BlendAlphaCrossover(problem=PROBLEM, publisher=Publisher(), verbosity=1, seed=0)
Each α gives a flat-topped uniform distribution whose width grows with α. At $\alpha = 0$ the interval is exactly $[3, 7]$.
Uniform crossover¶
UniformCrossover inherits every variable whole from one parent or the other, with the two
offspring taking complementary choices. Nothing is interpolated, so the offspring's genes are
exactly the parents' genes and the operator can only rearrange the material already in the
population. The real-coded literature calls this discrete crossover.
rates = [0.0, 0.25, 0.5, 0.75, 1.0]
symbols = [v.symbol for v in PROBLEM.get_flattened_variables()]
print(f"{'u':>6} {'gene equals a parent':>21} {'taken from the other parent':>28}")
for rate in rates:
operator = UniformCrossover(
problem=PROBLEM, publisher=Publisher(), verbosity=2, seed=0, uniform_xover_probability=rate
)
n_pairs = 20000
population = pl.DataFrame(np.tile(np.array([PARENT_A, PARENT_B], dtype=float), (n_pairs, 1)), schema=symbols)
children = operator.do(population=population, to_mate=list(range(2 * n_pairs))).to_numpy()[:n_pairs]
parents = operator.parent_population[symbols].to_numpy()
from_first = np.isclose(children, parents[0::2])
from_second = np.isclose(children, parents[1::2])
inherited = (from_first | from_second).mean()
swapped = (from_second & ~from_first).mean()
print(f"{rate:>6.2f} {inherited:>21.3f} {swapped:>28.3f}")
u gene equals a parent taken from the other parent 0.00 1.000 0.000 0.25 1.000 0.247 0.50 1.000 0.498 0.75 1.000 0.750 1.00 1.000 1.000
Local crossover¶
Local crossover is the plain arithmetic recombination: a random convex combination of the two parents, with a fresh weight drawn for every decision variable.
$$ c_1 = \alpha x_1 + (1 - \alpha) x_2, \qquad c_2 = (1 - \alpha) x_1 + \alpha x_2, \qquad \alpha \sim U[0, 1] $$
Because α is a convex weight, offspring can never leave the segment between the parents. It takes no parameters at all.
Reference: Dumitrescu, Lazzerini, Jain & Dumitrescu (2000), Evolutionary Computation, CRC Press.
local = LocalCrossover(problem=PROBLEM, publisher=Publisher(), verbosity=1, seed=0)
Single arithmetic crossover¶
The most conservative operator here. It picks one variable position $k$ at random and replaces it, in both offspring, with the mean of the parents at that position. Every other variable is copied unchanged:
$$ c_{1,k} = c_{2,k} = \frac{x_{1,k} + x_{2,k}}{2}, \qquad c_{i,j} = x_{i,j} \;\; \text{for } j \neq k $$
xover_probability is the chance that a given pair is recombined at all; pairs
that fail the roll are passed through untouched.
Note that $k$ is drawn per pair, so on a two-variable problem $x_1$ is the chosen position only half the time. That is what produces the three spikes in the overview figure: about half the offspring sit on the parent mean, and the remaining half keep their own parent's $x_1$ exactly, split evenly between the two parents.
Reference: Picek, Jakobovic & Golub (2013), CEC.
# Built with its default: xover_probability=1.0.
single_arithmetic = SingleArithmeticCrossover(problem=PROBLEM, publisher=Publisher(), verbosity=1, seed=0)
Operators that use more than two parents¶
Every operator above is a function of a parent pair. Two are not.
Differential evolution (DE/rand/1/bin) builds each offspring from its target and three other
population members: it forms $v = x_{r_1} + F(x_{r_2} - x_{r_3})$ and then copies components of $v$
onto the target with probability xover_probability, always taking at least one so that no offspring
is a copy of its target.
Parent-centric crossover draws three parents, designates one of them the index parent, and places the offspring beside it — displaced along the direction from the parental centroid to the index parent, and across that direction by an amount set by how far the other two parents sit from it.
The practical consequence is the same for both: the step size is set by the population rather than by the mating pair. A solution far from the rest of the population is displaced no further than one inside a tight cluster, which is not true of any pairwise operator.
# These operators need a population rather than a fixed pair, so the panels below show one
# population (open circles) together with the offspring it produces. SBX is included for reference.
rng = np.random.default_rng(0)
symbols = [v.symbol for v in PROBLEM.get_flattened_variables()]
members = np.clip(rng.normal([4.0, 6.0], 1.2, size=(60, 2)), 0.2, 9.8)
population = pl.DataFrame(members, schema=symbols)
multi_parent = [
("Simulated binary", SimulatedBinaryCrossover(problem=PROBLEM, publisher=Publisher(), verbosity=1, seed=0)),
(
"Differential evolution",
DifferentialEvolutionCrossover(problem=PROBLEM, publisher=Publisher(), verbosity=1, seed=0),
),
("Parent centric", ParentCentricCrossover(problem=PROBLEM, publisher=Publisher(), verbosity=1, seed=0)),
]
fig = make_subplots(
rows=1,
cols=3,
shared_xaxes=True,
shared_yaxes=True,
horizontal_spacing=0.05,
subplot_titles=[name for name, _ in multi_parent],
)
for column, ((_name, operator), colour) in enumerate(zip(multi_parent, IDENTITY, strict=False), start=1):
offspring = np.vstack([operator.do(population=population).to_numpy() for _ in range(40)])
fig.add_trace(
go.Scatter(
x=offspring[:, 0],
y=offspring[:, 1],
mode="markers",
showlegend=False,
marker={"size": 3, "color": colour, "opacity": 0.3},
),
row=1,
col=column,
)
fig.add_trace(
go.Scatter(
x=members[:, 0],
y=members[:, 1],
mode="markers",
showlegend=False,
marker={"size": 6, "color": INK, "symbol": "circle-open"},
),
row=1,
col=column,
)
style(fig, "Offspring of one 60-member population (○), 2400 draws per operator", "", "", height=400, showlegend=False)
fig.update_xaxes(title_text="x₁", range=[0, 10])
fig.update_yaxes(title_text="x₂", range=[0, 10])
show(fig)
Per-variable mixing¶
Several continuous operators carry a second, separate mechanism inside them: a per-variable choice that is applied on top of the sampling equations above.
It shows up in these operators:
- SBX can swap individual variables between offsprings after the SBX operation,
controlled by
uniform_xover_probability. - BLX-α has the
sample_each_componentargument: one random weight per variable, or a single weight reused across the whole offspring vector. UniformCrossoveris this mechanism on its own, with no sampling equation underneath it. Itsuniform_xover_probabilitymeans exactly what SBX's does, so the two can be set to the same value and compared.
To see this at all, the offspring have to be split by which parent they descend from — pooled together, the two streams cancel out and the marginal looks symmetric no matter what this parameter is set to.
At $u = 0$ each child stays on its own parent. At $u = 1$ the roles are swapped wholesale, and every child takes after the other parent. At $u = 0.5$, the default, the two streams overlap exactly, and it is only their sum that looks like the familiar symmetric SBX picture.
Many operators also have the xover_probability argument: how many genes get recombined at all.
It is a per-variable gate applied before the mixing
above: genes that fail the roll are copied from the parent untouched.
sample_each_component in BLX-α¶
Another way in which the distrubution of operators can be changed is whether a single random weight is used for all variables, or a fresh weight is drawn for each variable. This can be controlled in BLX-α by the sample_each_component argument.
Operators for discrete variables¶
Three operators handle non-continuous variables. They share the same skeleton and differ only in how the genes are divided between the parents, and in how the values are cast.
SinglePointBinaryCrossover— binary variables. One cut point is drawn per pair, and the two parents swap everything after it. Because the cut is a single position, neighbouring genes tend to be inherited together.UniformIntegerCrossover— integer variables. Every gene is independently taken from one parent or the other, using a mask drawn fresh for each pair.UniformMixedIntegerCrossover— identical to the above, but for mixed-integer algorithms.
None of the three takes any parameters.
binary_problem = demo_problem(10, "binary", 0, 1)
single_point = SinglePointBinaryCrossover(problem=binary_problem, publisher=Publisher(), verbosity=1, seed=0)
Mating an all-zeros parent with an all-ones parent makes the inheritance pattern directly readable: for each gene position, the average offspring value is the probability that the gene came from the all-ones parent.
Uniform crossover is flat at $0.5$, i.e., every gene is an independent coin flip, so position carries no information. Single-point crossover ramps from $0$ to $1$ across the binary string: the first gene almost always comes from the first parent and the last almost always from the second, because a single cut point cannot separate the ends.
That ramp is also why single-point crossover preserves contiguous blocks of genes, and uniform crossover deliberately destroys them.
Composite crossover¶
CompositeCrossover wraps a list of operators and delegates to them in
round-robin: the first call uses the first operator, the second call the
second, and so on.
It does not apply different operators to different variable groups — every wrapped operator receives the whole decision vector. All of them therefore have to suit the same kind of problem; you cannot pair a binary operator with a continuous one here. What it gives you is a way to alternate between several recombination behaviours over successive calls, for instance mixing a parent-centric and a non-parent-centric operator.
composite = CompositeCrossover(
problem=PROBLEM,
publisher=Publisher(),
verbosity=1,
seed=0,
operators=[
SimulatedBinaryCrossover(problem=PROBLEM, publisher=Publisher(), verbosity=1, seed=0),
BlendAlphaCrossover(problem=PROBLEM, publisher=Publisher(), verbosity=1, seed=0),
],
)
Geometry in two dimensions¶
The combined effect of the probability density in one dimension and the independence of the variables can be seen in two dimensions using scatter plots. In the plot below, two parents sit at [2, 8] and [8, 2]. The above-mentioned crossover operators for continuous variables are applied to them repeatedly, and the resulting offspring are plotted. Each panel shows 1200 offspring.
- Uniform collapses to the four corners of the box the parents span, and to nothing else. Each variable is taken whole from one parent or the other, so the reachable offspring are exactly the combinations of the parents' coordinates.
- Single arithmetic collapses to just four points. It replaces exactly one coordinate with the parent mean and copies the other, so with two variables the only reachable offspring are each parent with one of its coordinates moved to the centre.
- Local fills the box spanned by the parents, and fills it evenly. Its weight is drawn afresh for each variable, so $x_1$ and $x_2$ are mixed independently and the offspring are uncorrelated. A single shared weight would give the diagonal line instead. Note that local crossover cannot go beyond the bounds of the parents.
- Blend alpha fills the same kind of box, just a wider one: α pushes the edges
out beyond the parents, up to the variable bounds. Note that this only applies if
sample_each_component=True. - BEX stays on regions close to the two parent corners and, at its default $\lambda = 0.1$.
- SBX spreads over four regions in roughly equal proportion, the two
parent corners and the two opposite ones alike. Its per-variable sign flip takes
$x_1$ from one parent and $x_2$ from the other as readily as not, so it
recombines the coordinates rather than preserving the parent's combination of
them. Its coordinate correlation is essentially zero. This is only applicable if
uniform_xover_probability > 0.0, which is the default. The value of that argument determines the proportion of offspring that lie in the regions on top of the parents vs the other corners. The more dimensions there are, the more corners there will be.
Choosing an operator¶
| If your variables are… | Use |
|---|---|
| continuous | SimulatedBinaryCrossover — the default for RVEA, NSGA-III and SMS-EMOA |
| binary | SinglePointBinaryCrossover |
| integer | UniformIntegerCrossover |
| mixed | UniformMixedIntegerCrossover — the default for the mixed-integer algorithms |
Within the continuous operators, the choice is about how much exploration you want, and about whether you want the coordinates recombined at all.
SimulatedBinaryCrossoverandBoundedExponentialCrossoverboth concentrate offspring on the parents, and at their defaults they have almost the same spread. They differ in what they do across variables: SBX mixes coordinates freely between the two parents, while BEX perturbs each parent on its own. Pick SBX when recombination is the point; pick BEX when you would rather explore around existing solutions without scrambling the combinations they represent.BlendAlphaCrossoverwith a moderate α, and SBX with a small η, explore aggressively.LocalCrossoverandSingleArithmeticCrossoverare conservative and can only contract the population.DifferentialEvolutionCrossoverandParentCentricCrossovertake their step size from the population rather than from the mating pair. Reach for them when the population's own spread is the scale you want to search at, and for DE in particular when you want a recombination whose behaviour is widely reported in the literature for comparison.
One practical note: SBX defaults to truncated=True, the formulation used by pymoo,
jMetalPy, Platypus and Deb's own NSGA-II code. Pass truncated=False for the
untruncated formulation that clips instead, as PlatEMO does.
Operators can also be selected declaratively, without importing them — see the Pydantic interface:
from desdeo.emo.options import crossover
options.template.crossover = crossover.BlendAlphaCrossoverOptions(alpha=0.5)
Implementation details¶
Everything below is shared by all nine operators, and is the reason they are interchangeable.
Construction. Every operator subclasses BaseCrossover and is built the same
way:
operator = SomeCrossover(problem=problem, publisher=publisher, verbosity=1, seed=0, ...)
Bounds and types are read from problem.get_flattened_variables(), so
TensorVariables are handled transparently as flat columns.
Randomness. Each operator owns a private numpy generator seeded from seed.
There is no shared global random state, so an operator's output depends only on its
own seed and call history.
The do method. Keyword-only, and the only method an algorithm calls:
offspring = operator.do(population=population, to_mate=None)
It returns a polars.DataFrame containing only the decision-variable columns —
objectives and constraints are not carried through.
Pairing. With to_mate=None the whole population is shuffled and paired up.
With an explicit to_mate, the order is used verbatim, so the caller controls
exactly who mates with whom. Pairing is always consecutive: entries $(0,1)$,
$(2,3)$, and so on. An odd-length mating pool is padded by repeating its first
member, and the resulting extra offspring is discarded — which is why the count
always comes out at one offspring per mated parent.
Messages. Every crossover operator is a Subscriber that only ever publishes;
none of them subscribes to anything. What is published depends on verbosity:
| verbosity | published |
|---|---|
| 0 | nothing |
| 1 | the operator's scalar parameters (XOVER_PROBABILITY, XOVER_DISTRIBUTION, ALPHA, LAMBDA) |
| 2 | the above plus the PARENTS and OFFSPRINGS dataframes |
Dataframe messages are restricted to verbosity 2 because the message layer only admits scalar payloads at verbosity 1.