Basic fitting tutorial

In this section we are going to go through a complete example of fitting using BornAgain. Each step will be associated with a detailed piece of code written in Python. The script can also be found in the example section.

This example uses the same sample geometry as in Basic GISAS simulation tutorial. Cylindrical and prismatic particles are deposited on a substrate layer in equal proportion, with no interference between the particles. We consider the following parameters to be unknown:

  • the radius of the cylinders,
  • the height of the cylinders,
  • the length of the prisms’ triangular basis,
  • the height of the prisms.

Our reference data are a “noisy” two-dimensional intensity map obtained from the simulation of the same geometry with a fixed value of 5nm for all sizes of cylinders and prisms.

Importing the python libraries

6
7
import bornagain as ba
from bornagain import deg, angstrom, nm

We start with importing the BornAgain Python API and particularly the dimension units defined in BornAgain.

Building the sample

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
def get_sample(params):
    """
    Returns a sample with uncorrelated cylinders and prisms on a substrate.
    """
    cylinder_height = params["cylinder_height"]
    cylinder_radius = params["cylinder_radius"]
    prism_height = params["prism_height"]
    prism_base_edge = params["prism_base_edge"]

    # defining materials
    m_air = ba.HomogeneousMaterial("Air", 0.0, 0.0)
    m_substrate = ba.HomogeneousMaterial("Substrate", 6e-6, 2e-8)
    m_particle = ba.HomogeneousMaterial("Particle", 6e-4, 2e-8)

    # collection of particles
    cylinder_ff = ba.FormFactorCylinder(cylinder_radius, cylinder_height)
    cylinder = ba.Particle(m_particle, cylinder_ff)
    prism_ff = ba.FormFactorPrism3(prism_base_edge, prism_height)
    prism = ba.Particle(m_particle, prism_ff)
    layout = ba.ParticleLayout()
    layout.addParticle(cylinder, 0.5)
    layout.addParticle(prism, 0.5)

    # air layer with particles and substrate form multi layer
    air_layer = ba.Layer(m_air)
    air_layer.addLayout(layout)
    substrate_layer = ba.Layer(m_substrate, 0)
    multi_layer = ba.MultiLayer()
    multi_layer.addLayer(air_layer)
    multi_layer.addLayer(substrate_layer)
    return multi_layer

The function starting at line 12 creates a multilayered sample with cylinders and prisms randomly distributed on a surface. The function accepts a dictionary as input parameters and use its key/value pairs to define the particle sizes.

Creating the simulation

45
46
47
48
49
50
51
52
53
54
55
def get_simulation(params):
    """
    Returns a GISAXS simulation with beam and detector defined
    """
    simulation = ba.GISASSimulation()
    simulation.setDetectorParameters(100, -1.0*deg, 1.0*deg,
                                     100, 0.0*deg, 2.0*deg)
    simulation.setBeamParameters(1.0*angstrom, 0.2*deg, 0.0*deg)
    simulation.setBeamIntensity(1e+08)
    simulation.setSample(get_sample(params))
    return simulation

The function starting at line 45 creates the simulation object with beam, detector and user sample defined. This function is intended to be called on every minimization iteration to provide a new simulation object for every new set of fit parameter values. In the given example the set of parameters is used to construct a new sample by calling the get_sample method described earlier.

Creating the “experimental” data

58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def create_real_data():
    """
    Generating "experimental" data by running simulation with certain parameters.
    The data is saved on disk in the form of numpy array.
    """

    # default sample parameters
    params = {'cylinder_height': 5.0*nm, 'cylinder_radius': 5.0*nm,
              'prism_height': 5.0*nm, 'prism_base_edge': 5.0*nm}

    # retrieving simulated data in the form of numpy array
    simulation = get_simulation(params)
    simulation.runSimulation()
    real_data = simulation.result().array()

    # spoiling simulated data with noise to produce "real" data
    np.random.seed(0)
    noise_factor = 0.1
    noisy = np.random.normal(real_data, noise_factor*np.sqrt(real_data))
    noisy[noisy < 0.1] = 0.1

    np.savetxt("basic_fitting_tutorial_data.txt.gz", real_data)

The experimental data here is represented by a 2D numpy array with detector intensities obtained from the same simulation. The “experimental” data file is prepared by running once a simulation with default parameters, adding noise to it and saving it on disk.

Setting up the fit objective

89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
def run_fitting():

    """
    Setup simulation and fit
    """

    real_data = load_real_data()

    fit_objective = ba.FitObjective()
    fit_objective.addSimulationAndData(get_simulation, real_data, 1.0)

    # Print fit progress on every n-th iteration.
    fit_objective.initPrint(10)

    # Plot fit progress on every n-th iteration. Will slow down fit.
    fit_objective.initPlot(10)

The FitObjective created here is used to put into correspondence the real data, represented by a numpy array, and the simulation, represented by the get_simulation callable. On every fit iteration FitObjective

  • will generate a new simulation object for a given set of fit parameter values using the get_simulation callable,
  • run it to obtain simulated detector intensities,
  • calculate chi2 between simulated and real data.

Setting the fit parameters

105
106
107
108
109
    params = ba.Parameters()
    params.add("cylinder_height", 4.*nm, min=0.01)
    params.add("cylinder_radius", 6.*nm, min=0.01)
    params.add("prism_height", 4.*nm, min=0.01)
    params.add("prism_base_edge", 12.*nm, min=0.01)

The creation of fit parameters is straightforward. We give names, starting values and lower bounds to 4 fit parameters, representing the dimensions of the cylinders and prisms. The fit parameter names must coincide with the names that the get_simulation callable expects.

Creating the minimizer and running the fit

111
112
113
114
115
116
    minimizer = ba.Minimizer()
    result = minimizer.minimize(fit_objective.evaluate, params)
    fit_objective.finalize(result)

    print("Fitting completed.")
    print("chi2:", result.minValue())

The method fit_objective.evaluate provided by the FitObjective class interface is used here as an objective function. The method accepts fit parameters and returns the chi2 value, calculated between experimental and simulated images for the given values of the fit parameters.

The method is passed to the minimizer together with the initial fit parameter values. The minimizer.minimize starts a fit that will continue further without user intervention until the minimum is found or the minimizer failed to converge. The rest of the code demonstrates how to access the fit results.

See the full script in the Fit Cylinders and Prisms example.