Running a Refinement (in detail)

This tutorial walks through running a refinement in more detail than the Argon a-to-z tutorial. The following tutorials in brackets also provide more detail on creating the simulation setup for this tutorial.

By the end of this tutorial, a reader familiar with MD simulation should be capable of applying MDMC refinements to their own datasets and simulations.

In order to refine potential parameters the following is required:

If you would like more detail on these, please see the tutorials in brackets; the actual setup of the simulation will be done quite concisely here.

[ ]:
import os
# This setting tells MDMC how many physical cores on your computer it should use
# for simulation and refinement calculations.
os.environ["OMP_NUM_THREADS"] = "4"

Refinement

By ‘refinement’ we mean optimising a simulation to match an experimental dataset. We do this by running the simulation with some given parameters (for example, with a Lennard-Jones potential these parameters are the sigma (\(\sigma\)) and epsilon (\(\varepsilon\)) that define the potential energy), and calculating the figure of merit (FoM); this is simply a number representing how different our simulation is to our dataset.

The goal of the refinement is to minimize the figure of merit (make it as close to zero as possible); we do this by tweaking the parameters and seeing how this changes the figure of merit. Over time, the MDMC Minimizer uses this to build up an understanding of how the simulation changes when the parameters change, and thus find what combination of parameters produces the smallest figure of merit. We call this a “parameter space”; for two parameters (say, the Lennard-Jones \(\sigma\) and \(\varepsilon\)), picture a topographical map with \(\sigma\) on the x-axis, \(\varepsilon\) on the y-axis, and the hills and valleys corresponding to the figure of merit at those values. Our minimizer aims to find the lowest valley on the map.

This is done in a variety of ways by different minimisers; for example, the Metropolis-Hastings minimizer ("MMC" in MDMC) will ‘walk around’ the parameter space and backtrack if it finds the figure of merit increasing in that direction; or the Gaussian process minimizer ("GPR" in MDMC) will aim to ‘map out’ the parameter space to find where the lowest points are.

Please see the relevant explanation pages if you would like to learn more about minimizers or the figure of merit.

Experimental datasets

MDMC’s support for experimental datasets requires two things: the relevant Observable (e.g. dynamic structure factor \(S(Q,w)\)) must be available, and a Reader for the specific file format of the data must be implemented (e.g. the LAMPSQw Reader, which reads the LAMP output file format for dynamic structure factors). If this is not the case for a dataset you’d like to use, please file an issue in the MDMC github repository or contact support@mdmcproject.org.

Each experimental dataset must be specified by a Python dictionary containing the following:

  • file_name : A string with the file name of the experimental data

  • type : A string specifying the type of Observable which describes the data (e.g. SQw if the data is the dynamic structure factor, PDF if the data is the pair distribution function).

  • reader : A string specifying the name of the ObservableReader which will be used to read the file.

  • weight : A float which determines the relative importance weighting of this dataset to other datasets, if multiple datasets are being refined at the same time.

  • rescale_factor, optional : A float which rescales the experimental data linearly in order to match the absolute scale of the simulated data when calculating the Figure of Merit (FoM). Default is 1., i.e. no change to the data.

  • auto_scale, optional : A boolean specifying whether to automatically set the rescale_factor at each step of the refinement to the value which minimizes the FoM. Default is False.

  • use_FFT, optional : A boolean specifying whether to use the Fast Fourier Transform (FFT) in the calculation of variables from MD, which are faster but can impose restrictions on the uniformity of variables. Default is True.

  • resolution : a one-line dict of format {'file' : str} or {key : float}. if {'file' : str}, the str should be a file in the same format as file_name containing results of a vanadium sample which is used to determine instrument energy resolution for this dataset. If {key : float}, the key should be the type of numeric resolution, with the key being the function type (e.g. 'gaussian' or 'lorentzian'), and the float the FWHM for that function.

For this tutorial we are using Johan Qvist et al (2011)’s data for supercooled water.

[ ]:
# Dataset from: Johan Qvist et al, J. Chem. Phys. 134, 144508 (2011)
QENS = {'file_name':'data/263K05Awat_LAMP',
        'type':'SQw',
        'reader':'LAMPSQw',
        'weight':1.,
        'auto_scale':True,
        'use_FFT':False,
        'resolution':{'file': 'data/262p7K0A5van_LAMP'}}

As the dataset we are using has both negative energy values and non-uniform spacing, the default behaviour of use_FFT=True would cause the data to be interpolated as part of the refinement process. Setting this to False allows us to preserve the original energy values.

The default behaviour of the scaling (rescale_factor=1., auto_scale=False) assumes that the dataset provided has been properly scaled and normalised for the refinement process. This is the preferred way of using MDMC, and arbitrary or automatic rescaling should be undertaken with care. For example, using auto_scale to determine the scaling does not take into account any physical aspects of scaling the data, such as the presence or absence of background events from peaks outside its range.

The specific manner in which the weight applies to calculating the figure of merit (FoM) depends on the particular FoM which is used for the refinement. By default this is a least-squares difference weighted by the experimental error (StandardFoMCalculator):

\(FoM_{total} = \sum_{i} FoM_{i}\)

where the sum is over the number of experimental datasets and we calculate a weighted FoM for the \(i\)-th dataset as follows:

\(FoM_{i} = w_{i} \sum_{j} \left( \frac{D_{j}^{exp} - D_{j}^{sim}}{\sigma_{j}^{exp}} \right)^2\)

\(D_{j}\) is an observable, \(\sigma_{j}\) is the error associated with the observable, and \(exp\) and \(sim\) refer to the experimental and simulated data respectively. \(D_{j}\) and \(\sigma_{j}\) are N-dimensional arrays of all the data points for a dataset.

The exp_datasets parameter which is passed to Control is a list of all dataset dictionaries. In this instance there is only a single experimental dataset:

[ ]:
exp_datasets = [QENS]

If another dataset were to be included, for example the pair distribution function, this would require its own dictionary:

[ ]:
n_diffraction = {'file_name':'data/water_PDF',
                 'type':'PDF',
                 'reader':'ASCII',
                 'weight':1.,
                 'auto_scale':True,
                 'resolution': {'gaussian': 84}}
two_exp_datasets = [QENS, n_diffraction]

Controlling the refinement

As in the Running a Simulation guide, we use a Universe filled with SPCE water molecules. This simulation uses a force field for interactions, and also features constraint algorithms and long-range interaction solvers; a lot more advanced than the simulation used for argon!

As this minimizes and equilibrates a small simulation, it may take a small while to execute.

[ ]:
from MDMC.MD import *

universe = Universe(dimensions=21.75, constraint_algorithm=Shake(1e-4, 100), electrostatic_solver=PPPM(accuracy=1e-5))
H1 = Atom('H')
H2 = Atom('H', position=(0., 1.63298, 0.))
O = Atom('O', position=(0., 0.81649, 0.57736))
H_coulombic = Coulombic(atoms=[H1, H2], cutoff=10.)
O_coulombic = Coulombic(atoms=O, cutoff=10.)
water_mol = Molecule(position=(0, 0, 0),
                     velocity=(0, 0, 0),
                     atoms=[H1, H2, O],
                     interactions=[Bond((H1, O), (H2, O), constrained=True),
                                   BondAngle(H1, O, H2, constrained=True)],
                     name='water')
universe.fill(water_mol, num_density=0.03356718472021752)
O_dispersion = Dispersion(universe, [O.atom_type, O.atom_type], cutoff=10., vdw_tail_correction=True)
universe.add_force_field('SPCE')

Our Simulation object also sets a specific thermostat and barostat for MD simulation, and various other settings. See Running a Simulation for more details.

[ ]:
simulation = Simulation(universe, engine='lammps', time_step=1., temperature=300.,
                        pressure=101325., traj_step=10, thermostat='nose',
                        barostat='nose', t_damp=100, p_damp=1000)

simulation.minimize(100, minimize_every=10)
simulation.run(1000, equilibration=True)
simulation.run(2000)

Controlling the parameters

The Parameters of the Universe can have various restrictions placed on them for the purposes of refinement.

[ ]:
universe = simulation.universe
fit_parameters = universe.parameters
print(fit_parameters['epsilon'])

The value of one Parameter can be tied to that of another if there is some physical reason why it should be dependent on it. For example, in a water molecule setting the charge of the oxygen to be -2 times that of the hydrogen:

[ ]:
fit_parameters['charge'][0].set_tie(fit_parameters['charge'][1], ' * - 2')
print(fit_parameters['charge'][0])

The value of a Parameter can have constraints set to limit it’s value to a certain range:

[ ]:
fit_parameters['epsilon'].constraints = (0.6, 0.7)
print(fit_parameters['epsilon'])

Finally, a Parameter can be fixed outright to whatever value it currently has.

[ ]:
fit_parameters['equilibrium_state'][0].fixed = True
print(fit_parameters['equilibrium_state'][0])

Note that Parameters that are fixed, tied, or equal to 0 cannot be refined and so will not be passed to the Minimizer when the Control object is created. If constraints are set then refinement will occur however any proposed values that happen to lie outside the range of the constraints will be clipped.

Restricting the value of a Parameter in these ways applies to the Monte Carlo aspect of MDMC. Namely, it affects how the Parameter is allowed to change between refinement steps. It should not be confused with the use of constraint_algorithms when creating a Universe (see Building a Universe). The latter controls whether or not aspects of the Universe such as bonds are treated as rigid or not during molecular dynamics. It is entirely possible to have a rigid bond but allow the length of that bond to change between refinement steps, or conversely have a bond that is free to oscillate during MD but the equilibrium length is not altered as part of the refinement.

Controlling the figure of merit

The figure of merit (FoM) used in the refinement process to assess the goodness of fit between MD and experimental data can be configured using a dictionary with the following key/value pairs: - error : {'exp', 'none'} Whether to weight the FoM using the experimental errors of the data or not (respectively). The former results in values with larger (relative) error contributing less to the overall FoM. - norm : {'data_points', 'dof', 'none'} Whether to normalise the FoM using the total number of data points, the degrees of freedom (for the chi-squared figure of merit, this is number of data points minus the number of varying parameters), or not at all.

If not provided then the FoM defaults to using the first options, namely:

[ ]:
FoM_options = {'error':'exp', 'norm':'data_points'}

Currently, the only option for the overall form of the FoM is that of a chi-squared test. Details of the implementation can be found at this explanation page.

Running the refinement

The Control object sets up and runs the refinement (using Control.refine). It uses the experimental datasets to refine the fitting Parameters for the specified Simulation:

[ ]:
# Assuming a Universe called universe and a Simulation called simulation have been created
from MDMC.control import Control

control = Control(simulation=simulation,
                  exp_datasets=exp_datasets,
                  fit_parameters=fit_parameters,
                  MC_norm=1.0,
                  minimizer_type="GPO",
                  FoM_options = FoM_options,
                  MD_steps=424620,
                  equilibration_steps=1000,
                  results_filename='results_output_filename.csv',
                  data_printer='ipython')

Note that most of these parameters are what we’ve been setting up so far, and we’re just plugging them into the Control object which brings everything together and communicates between the components. See the guide on running a simulation for more information on the Control parameters.

During the refinement, in addition to running the simulation to calculate Observables, the Universe can be equilibrated during refinement by providing a number of equilibration_steps that will be run in between the refinement steps. In general what constitutes an appropriate amount of equilibration will depend on both the Universe and the Parameters being varied. However, provided the changes caused by the refinement process are small, less equilibration should be needed between steps than would be when first creating the Simulation object. equilibration_steps is an optional argument, with a default value of 0 (no equilibration).

[ ]:
# So that the MD simulation size can be minimized, the Q min is increased and
# the Q resolution is reduced.
import numpy as np
exp_obs = control.observable_pairs[0].exp_obs
Q_slice = slice(6, len(exp_obs.Q), 2)
Q = exp_obs.Q[Q_slice]
E = exp_obs.E
# copy the updated Q values back to the control.observable
control.observable_pairs[0].exp_obs.independent_variables = {'E':E, 'Q':Q}
control.observable_pairs[0].MD_obs.independent_variables = {'E':E, 'Q':Q}

Resolution Function

As the dataset included a vanadium sample, this will be used to determine the instrument resolution as a function of momentum. This function is then used in calculations involving that corresponding dataset. Note for numerically defined reoslutions, the resolution function is not accessible as an array. The resolution function can be accessed from the Observable (note that it is transformed into the time domain to enable it to be applied multiplicatively to FQt rather than convolving with SQw):

[ ]:
exp_obs = control.observable_pairs[0].exp_obs
#help(exp_obs.resolution)
resolution_function = exp_obs.resolution.resolution_function
# Note that for multidimensional resolution functions, the innermost independent variable should be passed first
t = np.linspace(0, 1e5)
resolution_array = resolution_function(t, Q)

The real and imaginary components of this can then be plotted as a 3D surface (note that for a Gaussian lineshape in the energy domain, one would expect no imaginary components and another Gaussian shape in the time domain):

[ ]:
%matplotlib notebook

from matplotlib import cm
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

def plot_SQw(Q, E, SQw):
    fig = plt.figure()
    ax = fig.add_subplot(projection='3d')
    X, Y = np.meshgrid(E, Q)
    surf = ax.plot_surface(X, Y, SQw, cmap=cm.viridis)
    plt.show()
[ ]:
plot_SQw(Q, t, np.real(resolution_array))
plot_SQw(Q, t, np.imag(resolution_array))

Note that for values outside the original range of the vanadium sample, nearest neighbour extrapolation is used.

When applied to calculated data the resolution window is normalised to have a value of 1 at time zero for all momentum. As the zero time component is directly proportional to the integral in the energy domain, this is equivalent to enforcing that the static structure factor of vanadium is constant for all momentum.

Once normalised in this way, this can be compared to the resolution from assuming a Gaussian lineshape with a FWHM of 84 ueV at a specific Q value:

[ ]:
from MDMC.common.constants import h
sigma_e = 2 * np.sqrt(2 * np.log(2)) * 0.084
# h is in units of eV s whereas system units are meV fs, so apply a factor of 1e3 * 1e15 to convert it
sigma_t = h * 1e18 / sigma_e
control.observable_pairs[0].exp_obs.t = t
time_resolution = exp_obs._calculate_resolution_window(resolution_function)
Q_index = 10

plt.figure()
plt.plot(t, np.real(time_resolution)[Q_index], label='Vanadium Resolution')
plt.plot(t, np.exp(-0.5 * (t / sigma_t) ** 2), label='Gaussian Resolution')
plt.legend()

Refinement Output

To refine the specified fit_parameters, run control.refine with the number of refinement steps specified.

If 0 steps are passed, an MD simulation is performed, the simulation Observable is calculated, and the FoM is calculated, however the fit_parameters are not modified. This can be used to determine the existing agreement between a simulation with the current fit_parameters and the experimental data.

As a refinement is running, it will print information from each step as it goes. By default this is to the terminal (stdout). The specific information depends on which minimizer is selected, however it will always include the current Figure of Merit and the values of the refined parameters. This information will also be saved to a CSV file named results[time].csv where [time] is a string of the current date and time.

Upon the completion of the refinement, the final parameter values will be output to the terminal. If auto_scale is set for any of the datasets, then the relevant scale factor(s) will also be printed. For stochastic minimization algorithms, such as MMC (Metropolis Monte Carlo), these values may not be the parameter values which resulted in the minimum Figure of Merit; however it is highly probable that these values will produce a Figure of Merit which is not significantly larger than the minimum achieved.

[ ]:
control.refine(n_steps=3)

Refinement plotting

If MDMC is being run in a Jupyter notebook, and matplotlib and ipywidgets are installed, it is also possible to dynamically plot one or more variables while the refinement is running. For example, it might be desirable to see how the FoM and one or more potential parameters change over time:

[ ]:
# Install matplotlib and ipywidgets if they are not already installed
%pip install matplotlib
%pip install ipywidgets

from MDMC.utilities.plotting import plot_progress
charge_parameter = fit_parameters['charge'][0].name
control = plot_progress(control, ['FoM', charge_parameter])
control.refine(3)

If dynamic plotting is used, only the last 5 steps of the text will be shown, so that the plots are always visible. For the same reason, it is recommended that a maximum of 8 plots are displayed at any time.

FoM and Parameter Plotting

After a refinement has completed, the results can be read in and plotted with pandas. Unlike dynamic plotting, this can be done whether or not the refinement has been run within a Jupyter notebook, but it still requires matplotlib to be installed:

[ ]:
import pandas as pd
results = pd.read_csv(control.results_filename)

# If only the y variable is provided (e.g. 'FoM'), it is plotted against the number of steps)
results.plot(y='FoM')
[ ]:
# The x axis can also be specified
results.plot(x='FoM', y=charge_parameter)