Extended Czjzek fitting of ¹³⁹La MAS NMR of La₀.₂Y₁.₈Si₂2O₇

The following is a demonstration on how to fit tensor distributions to an experimental spectrum using a structure-forward approach. The dataset was acquired from a sample of \(\text{La}_{0.2}\text{Y}_{1.8}\text{Si}_2\text{O}_7\) silicate glass and shared by Fernańdez-Carrioń et al. [1].

from mrsimulator import Simulator, SpinSystem, Site
from mrsimulator.method.lib import BlochDecayCTSpectrum
from mrsimulator.models import ExtCzjzekDistribution
from mrsimulator.simulator import Isotope
from mrsimulator.utils import get_spectral_dimensions
from mrsimulator import signal_processor as sp

import numpy as np
import csdmpy as cp
import lmfit
from lmfit import Minimizer
import matplotlib.pyplot as plt

Import the dataset

host = "http://ssnmr.org/sites/default/files/"
filename = "mrsimulator/La bc - LaY90 cpmg.csdf"
experiment = cp.load(host + filename).real
experiment.x[0].to("ppm", "nmr_frequency_ratio")
experiment /= experiment.max()

plt.figure(figsize=(4, 3))
ax = plt.subplot(projection="csdm")
ax.plot(experiment, "k", alpha=0.5)
plt.grid()
plt.tight_layout()
plt.show()
plot 6 139La Ext Czjzek

Calculate Noise Standard Deviation

loc = np.where(experiment.dimensions[0].coordinates < -5000e-6)
sigma = experiment[loc].std()
print(sigma)
0.006770052015781403

Setup Method and Processor

Next setup a method which simulates the experiment, again using the get_spectral_dimensions() function to get the spectral dimension parameters from the experimental dataset. We also create a signal processor object which will scale the simulated spectrum.

spec_dims = get_spectral_dimensions(experiment)
method = BlochDecayCTSpectrum(
    channels=["139La"], magnetic_flux_density=17.6, spectral_dimensions=spec_dims
)

processor = sp.SignalProcessor(operations=[sp.Scale(factor=1000)])

Create a Lineshape Kernel

An approach for fitting model values to the experimental dataset could be computing the spectrum from a list of spin system objects with tensor parameters pulled from an Extended Czjzek model during each round of fitting, but this would be inefficient! The grid of \(\text{Cq}\) and \(\eta_q\) points will not change during the fit; we are interested in the probability distribution across these points.

We therefore define a function which pre-computes a lineshape kernel given a range of \(\text{Cq}\) and \(\eta_q\) values, an NMR method, and an isotope which, for this example, is "139La".

def make_kernel(pos, method, isotope):
    """Pre-computes the kernel to use when fitting the experimental spectrum

    Arguments:
        (np.array) pos: list of Numpy array of cq and eta points
        (Method) method: mrsimulator Method object used to simulate the spectra
        (str) isotope: Isotope of the site

    Returns:
        Lineshape kernel as a numpy array
    """
    Cq, eta = np.meshgrid(pos[0], pos[1], indexing="xy")

    spin_systems = [
        SpinSystem(sites=[Site(isotope=isotope, quadrupolar=dict(Cq=cq_, eta=e_))])
        for cq_, e_ in zip(Cq.ravel(), eta.ravel())
    ]
    sim = Simulator(spin_systems=spin_systems, methods=[method])
    sim.config.number_of_sidebands = 4
    sim.config.decompose_spectrum = "spin_system"
    sim.run(pack_as_csdm=False)  # Will return spectrum as numpy array, not CSDM object

    amp = sim.methods[0].simulation.real
    return amp.T


# Create ranges to construct cq and eta grid points
cq_range = (np.linspace(0, 100, num=100) * 0.8 + 25) * 1e6  # in Hz
eta_range = np.arange(21) / 20
pos = [cq_range, eta_range]
kernel = make_kernel(pos, method, "139La")

Make spectrum from kernel and model values

Next define a function which takes a LMFIT Parameters object, the pre-computed kernel and a signal processor object and returns a CSDM object holding the guess spectrum. Here the Parameters object holds attributes from the ExtCzjzekDistribution class which is used to create the probability density function, \({\bf f}\). Then \({\bf f}\) is multiplied with the kernel, \({\bf K}\), to produce a spectrum, \({\bf s}\).

(41)\[{\bf s} = {\bf K \cdot f},\]

A constant isotropic chemical shift, whose value is also in the Parameters object, is applied to \({\bf s}\) using the Fourier shift relation. Finally, the spectrum is scaled and returned.

def make_spectrum_from_parameters(params, kernel, processor, pos, distribution):
    """Makes a spectrum with values given in a parameters object and a pre-computed
    kernel.

    Arguments:
        (Parameters) params: LMFIT Parameters object with values
        (np.array) kernel: Pre-computed spectrum kernel
        (sp.SignalProcessor) processor: SignalProcessor to apply to spectrum

    Returns:
        CSDM object of spectrum
    """
    # Extract values from parameters object
    values = params.valuesdict()
    Cq = values["dist_Cq"]
    eta = values["dist_eta"]
    eps = values["dist_eps"]
    iso_shift = values["dist_iso_shift"]

    # Setup model object and get the amplitude
    distribution.symmetric_tensor.Cq = Cq
    distribution.symmetric_tensor.eta = eta
    distribution.eps = eps
    _, _, amp = distribution.pdf(pos=pos)

    # Create spectra by dotting the amplitude distribution with the kernel
    dist = np.dot(kernel, amp.ravel())

    # Pack numpy array as csdm object and apply signal processing
    guess_dataset = cp.CSDM(
        dimensions=experiment.x,
        dependent_variables=[cp.as_dependent_variable(dist)],
    )

    # Calculate isotropic shift in Hz
    larmor_freq = Isotope(symbol="139La").gyromagnetic_ratio * 17.6
    iso_shift_in_hz = larmor_freq * iso_shift

    # Apply isotropic shift using FFT shift theorem
    guess_dataset = guess_dataset.fft()
    time_coords = guess_dataset.x[0].coordinates.value
    guess_dataset.y[0].components[0] *= np.exp(
        -np.pi * 2j * iso_shift_in_hz * time_coords
    )
    guess_dataset = guess_dataset.fft()

    # Apply signal processor and return
    processor.operations[0].factor = values["sp_scale_factor"]
    guess_dataset = processor.apply_operations(guess_dataset)
    return guess_dataset.real


def residuals(exp_spectra, simulated_spectra):
    """Returns the difference between exp_spectra and simulated_spectra"""
    return exp_spectra - simulated_spectra

Make initial guess

Next pack the attributes to be fit into a Parameters object and plot the initial guess spectrum.

Note

If you adapt this example to your own dataset, make sure the initial guess is decently good, otherwise LMFIT is likely to fall into a local minima.

params = lmfit.Parameters()
params.add("dist_Cq", value=49.5e6)  # Hz
params.add("dist_eta", value=0.55, min=0, max=1)
params.add("dist_eps", value=0.1, min=0)
params.add("dist_iso_shift", value=350)  # ppm
params.add("sp_scale_factor", value=3.8e3, min=0)

# Plot the initial guess spectrum along with the experimental data
distribution = ExtCzjzekDistribution(
    symmetric_tensor={"Cq": params["dist_Cq"], "eta": params["dist_eta"]},
    eps=params["dist_eps"],
)
guess_distribution = distribution.pdf(pos, size=400_000, pack_as_csdm=True)

initial_guess_spectrum = make_spectrum_from_parameters(
    params, kernel, processor, pos, distribution
)
residual_spectrum = residuals(experiment, initial_guess_spectrum)

plt.figure(figsize=(4, 3))
ax = plt.subplot(projection="csdm")
ax.plot(experiment.real, "k", alpha=0.5, label="Experiment")
ax.plot(initial_guess_spectrum.real, "r", alpha=0.3, label="Guess")
ax.plot(residual_spectrum.real, "b", alpha=0.3, label="Residuals")
plt.legend()
plt.grid()
plt.title("Initial Guess")
plt.tight_layout()
plt.show()
Initial Guess

Guess distribution

plt.figure(figsize=(4, 3))
ax = plt.subplot(projection="csdm")
ax.imshow(guess_distribution, interpolation="none", cmap="gist_ncar_r", aspect="auto")
ax.set_xlabel("Cq / Hz")
ax.set_ylabel(r"$\eta$")
plt.tight_layout()
plt.show()
plot 6 139La Ext Czjzek

Least-squares minimization with LMFIT

Now define minimization function per the LMFIT specifications which will return the difference between the experimental and guess spectrum scaled by the noise standard deviation which was calculated in a previous cell.

def minimization_function(
    params, experiment, processor, kernel, pos, distribution, sigma=sigma
):
    guess_spectrum = make_spectrum_from_parameters(
        params, kernel, processor, pos, distribution
    )
    residual_spectrum = residuals(experiment, guess_spectrum)
    return residual_spectrum.y[0].components[0].real / sigma

Sinice the probabilty distribution is generated from a sparsely sampled from a 5D second rank tensor parameter space, we increase the diff_step size from machine precession to avoid approaching local minima from noise.

scipy_minimization_kwargs = dict(
    diff_step=1e-4,  # Increase step size from machine precession
    gtol=1e-10,  # Decrease global convergence requirement (default 1e-8)
    xtol=1e-10,  # Decrease variable convergence requirement (default 1e-8)
    verbose=2,  # Print minimization info during each step
    loss="linear",
)

minner = Minimizer(
    minimization_function,
    params,
    fcn_args=(experiment, processor, kernel, pos, distribution),
    **scipy_minimization_kwargs,
)
result = minner.minimize(method="least_squares")
best_fit_params = result.params  # Grab the Parameters object from the best fit
result
   Iteration     Total nfev        Cost      Cost reduction    Step norm     Optimality
       0              1         5.6265e+03                                    5.18e+04
       1              2         5.2571e+02      5.10e+03       1.32e+05       7.54e+03
       2              3         2.0109e+02      3.25e+02       6.33e+05       2.25e+03
       3              4         1.7796e+02      2.31e+01       1.43e+04       2.09e+02
       4              5         1.7612e+02      1.84e+00       9.92e+04       2.00e+02
       5              7         1.7598e+02      1.45e-01       1.51e+04       1.92e+02
       6              8         1.7512e+02      8.63e-01       1.44e+04       6.22e+01
       7             20         1.7512e+02      0.00e+00       0.00e+00       6.22e+01
`xtol` termination condition is satisfied.
Function evaluations 20, initial cost 5.6265e+03, final cost 1.7512e+02, first-order optimality 6.22e+01.

Fit Result



Plot the best-fit solution

Finally, plot the best fit spectrum and the residuals.

final_fit = make_spectrum_from_parameters(
    best_fit_params, kernel, processor, pos, distribution
)
residual_spectrum = residuals(experiment, final_fit)

plt.figure(figsize=(4, 3))
ax = plt.subplot(projection="csdm")
ax.plot(experiment, "k", alpha=0.5, label="Experiment")
ax.plot(final_fit, "r", alpha=0.3, label="Fit")
ax.plot(residual_spectrum, "b", alpha=0.3, label="Residuals")
plt.legend()
ax.set_xlim(-11000, 9000)
plt.grid()
plt.title("Best Fit")
plt.tight_layout()
plt.show()
Best Fit

Best fit probability distribution

prob = distribution.pdf(pos, pack_as_csdm=True)
plt.figure(figsize=(4, 3))
ax = plt.subplot(projection="csdm")
ax.imshow(prob, interpolation="none", cmap="gist_ncar_r", aspect="auto")
ax.set_xlabel("Cq / Hz")
ax.set_ylabel(r"$\eta$")
plt.tight_layout()
plt.show()
plot 6 139La Ext Czjzek

Total running time of the script: (0 minutes 8.685 seconds)

Gallery generated by Sphinx-Gallery