Skip to content

Crossover operators - #86

Open
light-weaver wants to merge 30 commits into
multi-objective:mainfrom
light-weaver:crossover-operators
Open

light-weaver wants to merge 30 commits into
multi-objective:mainfrom
light-weaver:crossover-operators

Conversation

@light-weaver

Copy link
Copy Markdown

This PR adds nine crossover operators for evolutionary algorithms. They are implemented in C (c/crossover.c) and exposed in Python. They are ported from DESDEO's desdeo.emo, rewritten as plain functions that take and return numpy arrays.

I am not very familiar with C these days, so Claude was used for porting. I did check the C code for the actual operators themselves to verify their correctness. Moreover, I added some documentation pages inspired from DESDEO's to again verify operator behaviour.

New functions

Function Offspring per operation Parents per operation
uniform_crossover 2 2
single_point_crossover 2 2
blend_alpha_crossover 1 2
single_arithmetic_crossover 2 2
local_crossover 2 2
bounded_exponential_crossover 2 2
de_rand_1_bin_crossover 1 1, plus 3 others chosen at random
de_current_1_crossover 1 1, plus 2 others chosen at random
parent_centric_crossover 1 1, plus 2 others chosen at random

Operators with 2 parents per operation take a matrix parents and cross over rows 2i and 2i+1. With an odd number of rows, the last row is crossed over with the first one, and the extra offspring is dropped, so the output has as many rows as the input. Operators with 1 parent per operation take population and optional row indices targets, and return one offspring per target. Except for blend_alpha_crossover, offspring k comes from parent k (or targets[k]), which matters for parent-centric operators.

Bounds are optional (lower, upper; None means unbounded). seed follows the existing convention (_get_seed_for_c), and the C code draws from rng.h. The docs recommend passing one numpy.random.Generator to every call inside an evolutionary algorithm.

Points for review

  • DE is split into two functions. de_rand_1_bin_crossover is DE/rand/1/bin (Storn & Price 1997), the variant used by GDE3 and DEMO. de_current_1_crossover is the operator of MOEA/D-DE (Li & Zhang 2009). The name current/1 comes from Tanabe & Ishibuchi (2019), who credit Gong et al. (2017). Both variants are implemented in many MOEA frameworks.
  • The api for different operators is slightly different, depending on the way the operator behaves. I could technically give them the same API. However, I think that the users (a) should wrap these methods to suit their software frameworks and (b) be very aware of the differences between these operators. What do you think?
  • SBX is not included. More on this below.
  • Python only for now. The functions are not exported from libmoocore.so and not wrapped in R. c/Makefile lists crossover.c, but make all does not compile. I am unfamiliar with this part of the library so it would be nice if you can help with this part.
  • c/rng.h gets a small rng_integer() helper.
  • Seven references are not in the IRIDIA bib repository, so the docstrings cite them in plain text with DOIs: Thakur et al. 2014, Deep & Thakur 2007, Deb et al. 2002, Dumitrescu et al. 2000, Picek et al. 2013, Tanabe & Ishibuchi 2019 and Gong et al. 2017. Ten other keys are added to bibkeys.txt, and both REFERENCES.bib files are regenerated with update_bib.sh. I could send another pull request to the bib repo.

Documentation

A new reference page, "Variation operators", and a gallery example, plot_crossover.py. The example shows where each operator places the offspring of two parents, sweeps the parameters of BEX and BLX-α, and applies every operator to three population distributions: a round cloud, a diagonal band and three disjoint clusters. I also compared the implementation against DESDEO and pymoo implementations in terms of computation time. This implementation is unsurprisingly faster, though a majority of the cost is not due to the operators themselves, but because of the cost of converting between datastructures (e.g., between polars and numpy in case of DESDEO). I chose not to include these results in the docs as we will probably move to moocore anyway, even though we probably won't see much performance benefits.

Testing

  • python/tests/test_crossover.py has 148 test cases: output shapes, odd numbers of parents, seeding, input immutability, operator-specific properties and error messages.

  • The tests pass on Python 3.10 (release build) and 3.12 (debug build with -Werror), and under ASan and UBSan. crossover.c compiles cleanly with gcc and clang using the CI warning flags.

  • I compared the outputs with DESDEO's implementations using two-sample KS tests on 20 000 offspring per operator, and the distributions agree. The exact values differ because DESDEO draws from numpy's PCG64 while moocore uses MT19937.

  • Speed of 1000 calls of parent_centric_crossover:

    Population × variables moocore DESDEO pymoo
    100 × 30 0.054 s 0.27 s 0.54 s
    1000 × 100 1.3 s 2.3 s 8.3 s

The issue with SBX

SBX is by far the most popular crossover operator. It is also by the the operator with most distinct variants floating out in the wild. @gialmisi can elaborate more on this matter. In DESDEO, we are currently trying to provide all reasonable (and many unreasonable) variants of SBX. However, if moocore is supposed to provide a "canonical" implementation, I think it should only have one implementation called "SBX". I don't think I should unilaterally decide which SBX is canonical.

@MLopez-Ibanez

Copy link
Copy Markdown
Contributor

I think it would be interesting to know how these compare with the operators provided by pymoo and DEAP in terms of speed and in terms of return value.

@MLopez-Ibanez

Copy link
Copy Markdown
Contributor

Yes, please send a pull request to the bib repo. References should have a DOI so the CI can validate it.

@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.50820% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.02%. Comparing base (574e5d5) to head (091913c).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #86      +/-   ##
==========================================
+ Coverage   77.86%   80.02%   +2.15%     
==========================================
  Files          70       73       +3     
  Lines        7590     8200     +610     
  Branches     1477     1549      +72     
==========================================
+ Hits         5910     6562     +652     
+ Misses       1312     1262      -50     
- Partials      368      376       +8     
Flag Coverage Δ
R 72.24% <ø> (ø)
python 77.92% <99.50%> (+2.79%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
C 75.90% <98.70%> (+1.87%) ⬆️
R 81.54% <ø> (ø)
Python tests 100.00% <100.00%> (ø)
Python src 83.82% <100.00%> (+2.05%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@MLopez-Ibanez

Copy link
Copy Markdown
Contributor

Points for review

* DE is split into two functions. `de_rand_1_bin_crossover` is DE/rand/1/bin (Storn & Price 1997), the variant used by GDE3 and DEMO. `de_current_1_crossover` is the operator of MOEA/D-DE (Li & Zhang 2009). The name current/1 comes from Tanabe & Ishibuchi (2019), who credit Gong et al. (2017). Both variants are implemented in many MOEA frameworks.

I guess all these are being used and tested in DESDEO. Are they used in any other package like pymoo, jMetal, DEAP, etc. ?

* The api for different operators is slightly different, depending on the way the operator behaves. I could technically give them the same API. However, I think that the users (a) should wrap these methods to suit their software frameworks and (b) be _very_ aware of the differences between these operators. What do you think?

That sounds fine as long as the differences are not arbitrary (e.g., similar arguments should have similar names, documentation and positions).

But the real test for this concept is to actually compare these implementations with the ones in other packages. Are they faster/slower? Do the results differ significantly?

* SBX is not included. More on this below.

That's perfectly fine. I'd rather add operators incrementally. It is a lot to review at once.

* Python only for now. The functions are not exported from `libmoocore.so` and not wrapped in R. `c/Makefile` lists `crossover.c`, but `make all` does not compile. I am unfamiliar with this part of the library so it would be nice if you can help with this part.

Don't worry about R, we can ask copilot to do that once the Python part is completed. The shared library built for C, Python and R is not the same one (and it is compiled with different flags). Do not worry about c/Makefile for now.

* Seven references are not in the IRIDIA bib repository, so the docstrings cite them in plain text with DOIs: Thakur et al. 2014, Deep & Thakur 2007, Deb et al. 2002, Dumitrescu et al. 2000, Picek et al. 2013, Tanabe & Ishibuchi 2019 and Gong et al. 2017. Ten other keys are added to `bibkeys.txt`, and both `REFERENCES.bib` files are regenerated with `update_bib.sh`. I could send another pull request to the bib repo.

Please do so.

@MLopez-Ibanez

Copy link
Copy Markdown
Contributor
* Speed of 1000 calls of `parent_centric_crossover`:

It should be fairly easy to convert this to a benchmark that tests each crossover operator over increasing number of parents and increasing number of variables (two separate plots per operator). See https://github.com/multi-objective/moocore/tree/main/python/benchmarks

@light-weaver

Copy link
Copy Markdown
Author

I think it would be interesting to know how these compare with the operators provided by pymoo and DEAP in terms of speed and in terms of return value.

Added a new benchmark page for this. Originally benchmarks were on the homepage which blew up the length of that page.

Yes, please send a pull request to the bib repo. References should have a DOI so the CI can validate it.

will do.

I guess all these are being used and tested in DESDEO. Are they used in any other package like pymoo, jMetal, DEAP, etc. ?

Added comparisons.

That sounds fine as long as the differences are not arbitrary (e.g., similar arguments should have similar names, documentation and positions).

The only major difference is that some crossover calls the array of decision variables "parents", some call it "population". That's because they treat these arrays very differently. I feel like anyone using moocore should know the difference and then act accordingly, but we can unify that terminology.

@light-weaver

Copy link
Copy Markdown
Author

A nice to have feature that I didn't end up implementing yet: Many of these crossover operators do not respect decision variable bounds. There are many strategies to deal with this: clipping at the bounds, resampling using the operator, uniformly resampling within the bounds, or just not caring about the bounds at all. Different packages use different strategies, DESDEO and platemo use clipping, so I ended up using that here too. However, we could make choosing the strategy an input argument too.

I just didn't want to complicate things too much in this initial PR.

@github-actions

Copy link
Copy Markdown

📚 R docs preview for this PR

@MLopez-Ibanez

Copy link
Copy Markdown
Contributor

The documentation is failing to build.

I'd prefer to keep the current benchmarks on the main page. You can add a link from it to the crossover benchmarks like #82

@MLopez-Ibanez

Copy link
Copy Markdown
Contributor

@light-weaver I think I fixed the documentation problem. Before you work more on this, let me have a chat with Giovanni. I don't see anything moo-specific here, so we are opening the door to adding stuff to moocore that is not about multiple objectives and there is still a lot to add that is MOO-specific: https://github.com/multi-objective/moocore/blob/main/TODO

Perhaps this would fit better in a new package called evocore. It would probably be easy for AI to create it based on moocore.

@MLopez-Ibanez

Copy link
Copy Markdown
Contributor

@light-weaver I think I fixed the documentation problem.

It doesn't work. Something is different from my local environment.

Copilot AI and others added 18 commits October 26, 2026 18:20
* python/pyproject.toml (docs): require sphinx>=9 for on-disk intersphinx inventory cache.
* ‎.github/workflows/python.yml (build-doc): Cache only python/doc/_build/doctrees/__intersphinx_cache__/

Co-authored-by: MLopez-Ibanez <2620021+MLopez-Ibanez@users.noreply.github.com>
 * c/crossover.c, c/crossover.h: New.
 * c/rng.h (rng_integer): New.
 * c/Makefile: Add crossover.c and crossover.h.
 * c/NEWS.md: Document.
 * python/src/moocore/_crossover.py: New.
 * python/src/moocore/__init__.py: Export the crossover functions.
 * python/src/moocore/_docsubstitute.py (COMMON_PARAMS): Add parents,
   pair_offspring, population, targets, lower_bound and upper_bound.
 * python/src/moocore/_ffi_build.py: Compile crossover.c.
 * python/src/moocore/libmoocore.h: Declare the crossover functions.
 * python/tests/test_crossover.py: New.
 * bibkeys.txt (BezLopStu2015emode, EibSmi2003, EshSch1992, Goldberg89,
   Hol75, KukLam2005:gde3, LiZha2009:moead-de, RobFil05demo, StoPri1997:de,
   Syswerda89): New.
 * python/doc/source/reference/functions.variation.rst: New.
 * python/doc/source/reference/index.rst: Add it.
 * python/doc/source/index.rst: Mention crossover operators.
 * python/doc/source/whatsnew/index.rst: Document.
 * python/examples/plot_crossover.py: New.
 * python/doc/source/REFERENCES.bib: Regenerate.
 * r/inst/REFERENCES.bib: Regenerate.
…dren

 * python/doc/source/reference/functions.variation.rst: List all crossover
   functions together and add a table of children and parents per operation.
 * python/examples/plot_crossover.py: Do not use the group names.
 * c/crossover.c (bex_child): Rename to bex_offspring.
 (get_pair): Rename c1 and c2 to off1 and off2.
 Update comments.
 * c/crossover.h: Update comment.
 * python/src/moocore/_crossover.py: Update docstrings.
 * python/src/moocore/_docsubstitute.py (COMMON_PARAMS): Likewise.
 * python/tests/test_crossover.py: Rename tests and variables.
 * python/doc/source/reference/functions.variation.rst: Update.
 * python/examples/plot_crossover.py (mate): Rename to cross_over.
 Update comments.
 * python/src/moocore/_crossover.py (de_current_1_crossover): Cite Tanabe
   and Ishibuchi (2019) and Gong et al. (2017) for the name current/1.
 * python/doc/source/reference/functions.variation.rst: Likewise.
 * python/src/moocore/_crossover.py (de_rand_1_bin_crossover): Say that
   Storn and Price recommend F = 0.5 as a first choice.
 (de_current_1_crossover): Reword the crossover rate.
 * python/tests/test_crossover.py (test_de_current_1_crossover_two_rows):
   Pass scaling_factor explicitly.
 * python/doc/source/reference/functions.variation.rst: Introduce mutation
   and crossover.  Explain the order of the offspring.
 * python/examples/plot_crossover.py: Add figures of every crossover
   operator applied to a round and to a diagonal population.  Remove the
   inheritance profile and the separate DE and PCX figure.  Reword.
…eed the operators in an evolutionary algorithm.
 * python/doc/source/reference/functions.variation.rst: Say that variation
   operators are used in evolutionary algorithms.
 * python/examples/plot_crossover.py: Note that local crossover stays in
   the box spanned by the parents.  Explain why uniform and single-point
   crossover look alike in two dimensions.  Reword the captions.
 * c/crossover.c (copy_parents): New.
 (single_point_crossover, blend_alpha_crossover, local_crossover): Add
 pair_prob.
 (bounded_exponential_crossover): Take swap_prob before pair_prob.  Use
 copy_parents.
 * c/crossover.h: Likewise.
 * python/src/moocore/_crossover.py (single_point_crossover)
 (blend_alpha_crossover, local_crossover): Add pair_prob.
 (bounded_exponential_crossover): Take swap_prob before pair_prob.
 (de_current_1_crossover): Call the step vector the mutant.
 (parent_centric_crossover): Say how the orthogonal step differs from
 equation (2) of Deb et al. (2002).
 * python/src/moocore/_docsubstitute.py (COMMON_PARAMS): Add swap_prob,
 pair_prob, scaling_factor and crossover_rate.
 * python/src/moocore/libmoocore.h: Update the crossover prototypes.
 * python/tests/test_crossover.py: Test pair_prob for every operator with
 two parents.
 * python/benchmarks/bench_crossover.py: New.  Time each crossover
 operator against pymoo, DEAP, jMetalPy and DESDEO for an increasing
 number of parents and of variables.
 * python/benchmarks/compare_crossover.py: New.  Compare the offspring
 of each operator with those of the other packages.
 * python/pyproject.toml (benchmarks): Add deap.
light-weaver and others added 12 commits October 26, 2026 18:34
 * python/doc/source/benchmarks.inc.rst: Add the crossover operators.
 * python/doc/source/_static/bench/crossover_bench-*-time.png: New.
 * python/benchmarks/bench.py (Bench.plots): New argument add_name.
 * python/benchmarks/bench_crossover.py: Title each plot with the operator
 and the quantity that varies.
 * python/src/moocore/_crossover.py (_check_within_bounds): New.  Also
 reject NaN.
 (blend_alpha_crossover): Require the parents to be within the bounds.
 Say what Eshelman and Schaffer recommend and do differently.
 (single_arithmetic_crossover): Say that this is the case alpha = 1/2 of
 the operator of Eiben and Smith.
 (bounded_exponential_crossover): Describe the two sides of the
 distribution.  Say which settings are not from the paper.
 (de_rand_1_bin_crossover): Quote Storn and Price on CR in full.
 (de_current_1_crossover): Say where the distinct donors come from.
 (parent_centric_crossover): Correct the comparison with equation (2) of
 Deb et al. (2002).
 * python/tests/test_crossover.py (test_errors_operator_specific): Test
 parents outside the bounds in blend_alpha_crossover and NaN parents.
Print and save to a CSV file one matrix of KS p-values per operator, and
use 100 000 offspring per package.
 * python/doc/source/benchmarks.inc.rst: Split into ...
 * python/doc/source/benchmarks/index.rst: ... this,
 * python/doc/source/benchmarks/dominance.rst: ... this,
 * python/doc/source/benchmarks/indicators.rst: ... this,
 * python/doc/source/benchmarks/crossover/*.rst: ... and one page per
 crossover operator, with a matrix of KS test results.
 * python/doc/source/benchmarks-links.inc.rst: New.
 * python/doc/source/_static/bench/crossover_ks-*.csv: New.
 * python/doc/source/index.rst: Link to the benchmarks instead of
 including them.
 * python/doc/source/my_unused_images.py (check_unused_images): Also
 resolve image paths relative to the source directory.
 * python/benchmarks/README.md: Update the link to the benchmarks.
Added type ignore comment for unresolved import.
@github-actions

Copy link
Copy Markdown

📚 Python docs preview for this PR

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants