SciPy differential evolution

This example demonstrates how to run a typical fitting task in BornAgain directly with scipy.optimize.

import numpy as np
import scipy.optimize

initial_values = np.array([4.5*nm, 13.5*nm])
bounds = [(4*nm, 6*nm), (13*nm, 15*nm)]

flat_exp_values = data.intensities().ravel()

def scalar_objective(values):
    radius, length = values
    parameters = {"radius": radius, "length": length}
    simulation = get_simulation(parameters)
    result = simulation.simulate()
    flat_sim_values = result.intensities().ravel()
    residuals = flat_sim_values - flat_exp_values
    return np.sum(residuals*residuals)

initial_objective = scalar_objective(initial_values)

result = scipy.optimize.differential_evolution(
    scalar_objective,
    bounds=bounds,
    x0=initial_values,
    maxiter=30,
    popsize=6,
    polish=True,
    seed=0)
print(initial_objective, result.fun, result.x, result.success)

differential_evolution expects a scalar objective value and returns a SciPy OptimizeResult. The x0 argument keeps the initial model in the starting population while the search explores the bounded parameter region. The complete script prints the initial and final objective values, fitted parameters, success flag, message, and number of function evaluations.

The complete script is shown below.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["bornagain>=25,<26", "scipy>=1.7"]
# ///
"""
SciPy differential evolution for BornAgain fits.
"""
import bornagain as ba
ba.require_versions("bornagain>=25,<26")
from bornagain import deg, nm
import numpy as np
import scipy.optimize


def get_sample(P):
    """
    Spheres on a hexagonal lattice, parameterized for fitting.
    """
    substrate_color = (0.28, 0.57, 0.82)
    substrate_mat = ba.RefractiveMaterial("Substrate", substrate_color, 6e-6, 2e-8)

    particle_color = (0.86, 0.24, 0.18)
    particle_mat = ba.RefractiveMaterial("Particle", particle_color, 6e-4, 2e-8)

    particle = ba.Particle(particle_mat, ba.Sphere(P["radius"]))

    lattice = ba.HexagonalLattice2D(P["length"], 0)
    struct = ba.Crystal2D(particle, lattice)
    struct.setDecayFunction(ba.Profile2DCauchy(100*nm, 100*nm, 0))

    particle_layer = ba.Layer(ba.Vacuum())
    particle_layer.deposit2D(struct)

    sample = ba.Sample()
    sample.addLayer(particle_layer)
    sample.addLayer(ba.Layer(substrate_mat))
    return sample


def get_simulation(P):
    """
    GISAS simulation for the parameterized hexagonal lattice.
    """
    n_pix = 100

    beam = ba.Beam(1e8, 0.1*nm, 0.2*deg)
    detector = ba.SphericalDetector(n_pix, -1*deg, 1*deg, n_pix, 0, 2*deg)

    simulation = ba.ScatteringSimulation(beam, get_sample(P), detector)
    return simulation


def fake_data():
    """
    Noisy synthetic data for a known hexagonal lattice.
    """
    P = {"radius": 5*nm, "length": 14*nm}
    return get_simulation(P).simulate().noisy(0.1, 0.1)


def print_result(result, initial_objective):
    """
    Prints selected fields of the SciPy OptimizeResult.
    """
    radius, length = result.x
    print(f"Success: {result.success}")
    print(f"Message: {result.message}")
    print(f"Initial objective: {initial_objective:.6g}")
    print(f"Objective: {result.fun:.6g}")
    print(f"Function evaluations: {result.nfev}")
    print(f"radius: {radius:.6g}")
    print(f"length: {length:.6g}")


if __name__ == '__main__':
    # Generate synthetic data for the fit target.
    data = fake_data()
    flat_exp_values = data.intensities().ravel()

    def scalar_objective(values):
        """
        Runs a simulation and returns the sum of squared residuals.
        """
        radius, length = values
        parameters = {"radius": radius, "length": length}
        simulation = get_simulation(parameters)
        result = simulation.simulate()
        flat_sim_values = result.intensities().ravel()
        residuals = flat_sim_values - flat_exp_values
        return np.sum(residuals*residuals)

    # Define SciPy's initial candidate and bounds.
    initial_values = np.array([4.5*nm, 13.5*nm])
    bounds = [(4*nm, 6*nm), (13*nm, 15*nm)]
    initial_objective = scalar_objective(initial_values)

    # differential_evolution expects a scalar objective value.
    result = scipy.optimize.differential_evolution(
        scalar_objective,
        bounds=bounds,
        x0=initial_values,
        maxiter=30,
        popsize=6,
        polish=True,
        seed=0)

    print_result(result, initial_objective)
    radius, length = result.x
    final_parameters = {"radius": radius, "length": length}
    ba.showSample3D(get_sample(final_parameters), sample_size=300*nm, seed=0)
auto/Examples/fit/scatter2d/scipy_basics.py