Bayesian analysis of SANS data#

SANS analysis part II: priors and statistics#

You have now simulated, reduced, and carried out routine analysis of your SANS data.

In this notebook, we begin looking at model fitting probabilistically, including prior knowledge about the system or parameters under study in the analysis.

We pick up directly where the previous notebook left off, so we start by loading the same dataset again.

import utils
from sans_fitter import SANSFitter
filename = "../4-reduction/sans_iofq.dat"

Starting with the same dataset, set a sphere model and fetch the parameters, as before.

fitter = SANSFitter()
fitter.load_data(filename)
fitter.set_model("sphere")
fitter.get_params()
✓ Loaded data from ../4-reduction/sans_iofq.dat
  Q range: 0.0055 to 0.1895 Å⁻¹
  Data points: 200
  Error (dI) column: yes
  Resolution (dQ) column: no
✓ Model 'sphere' loaded successfully
  Available parameters: 5

================================================================================
Model: sphere
================================================================================
Parameter            Value        Min          Max          Vary    
--------------------------------------------------------------------------------
sld                  1            0            10           ✗       
sld_solvent          6            0            60           ✗       
radius               50           0            500          ✗       
scale                1            0            inf          ✗       
background           0            0            inf          ✗       
================================================================================

By default, some parameters are set but nothing is allowed to vary. Obviously, this will constrain our fitting algorithm excessively.

In the previous notebook, we included prior knowledge in the analysis through the use of bounded parameters.

Bounded parameters cannot have values less than some lower bound (Min) or greater than some upper bound (Max), as the probability of the parameters having these values is zero. For example, if the parameter b from a quadratic model has bounds of 0 and 10, then there is an equal probability that the value of b can be anything in between 0 and 10, and a probability of 0 outside those bounds, i.e., it has a uniform prior probability distribution.

Exercise 6: Towards Bayesian analysis#

In this exercise we will repeat the sphere fit from Part I, but treat it probabilistically: the bounds we set act as uniform priors, and instead of a single best-fit value we will obtain a posterior probability distribution for each varied parameter.

The exercise proceeds in three steps:

  1. Set sensible initial values and bounds (priors) for the sphere model, choose which parameters should vary, and confirm the input.

  2. Sample the posterior distribution using the DREAM Markov chain Monte Carlo algorithm.

  3. Inspect the results with a series of diagnostic plots, and consider what they tell you about the parameters and their uncertainties.

Start with step 1, using the same values and bounds as before:

Hide code cell content

fitter.set_param("sld", value=3, min=1, max=30, vary=False)
fitter.set_param("sld_solvent", value=6, min=1, max=30, vary=False)

fitter.set_param("radius", value=80, min=10, max=300, vary=True)
fitter.set_param("scale", value=1.4e-7, min=0, max=1, vary=True)
fitter.set_param("background", value=0.1, min=0, max=1, vary=True)

fitter.get_params()
================================================================================
Model: sphere
================================================================================
Parameter            Value        Min          Max          Vary    
--------------------------------------------------------------------------------
sld                  3            1            30           ✗       
sld_solvent          6            1            30           ✗       
radius               80           10           300          ✓       
scale                1.4e-07      0            1            ✓       
background           0.1          0            1            ✓       
================================================================================

Great: Now the model is set up, let’s introduce some new syntax for \(Bayesian\) analysis using sans-fitter.

SasView, and hence sans-fitter, have a number of optimisation algorithms built-in. One of these is DREAM, a population based algorithm.

DREAM is relatively slow; it follows a differential evolution-like process but sometimes keeps individuals which get worse with the evolution and allows these to progress as a Markov chain which converges on the equilibrium distribution, where the chain draws randomly from the posterior distribution.

Therefore, we can use the DREAM fitting algorithm to determine parameter uncertainties from our fitting process.

More can be read about DREAM in the associated publication, or in the SasView documentation.

We will begin by setting a few parameters: samples (number of points to be drawn from the Markov chain) and burn (number of iterations for the Markov chain to converge to the equilibrium distribution).

To estimate the 68% interval to two digits of precision, at least 1e5 (or 100,000) samples are needed. For the 95% interval, 1e6 (or 1,000,000) samples are needed. 1e4 samples gives a ‘quick-and-dirty’ approximation of the uncertainty.

result = fitter.fit_bayesian(samples=10000, burn=100)
fitter.plot_results(
    show_residuals=True, log_scale=True
)  # Plotting the fit and residuals is the same as before.
Initial χ² = 378.8669
Sampling posterior with BUMPS DREAM (samples=10000, burn=100 generations)...
✓ Fit completed!
Final χ² = 1.7714

Fitted parameters:
  background: 0.01781(30)
  radius: 91.17(29)
  scale: 0.001949(24)

Posterior summary:
Parameter                    Best         Mean       Median          Std                     68% CI                     95% CI
------------------------------------------------------------------------------------------------------------------------------
background              0.0178052    0.0177646    0.0177852  0.000398261     [0.0174775, 0.0180837]      [0.017152, 0.0184109]
radius                    91.1733      91.1285      91.1544      0.51595         [90.8797, 91.4488]         [90.5812, 91.7184]
scale                  0.00194893   0.00194652   0.00194742  2.94842e-05     [0.001924, 0.00197147]   [0.00189876, 0.00199501]

Parameter                 R-hat        ESS
------------------------------------------
background               1.0792        902
radius                   1.2521        866
scale                    1.1710        819
../_images/abda2dc493c6e367fdc0ef5e78a06abcbc0670a9432c0661f59026db9d3ac2c0.png

We can now look at the fitting output in detail using a variety of plots:

i. A ‘corner’ plot showing a grid of parameter distribution from the Bayesian multi-parameter analysis.

ii. A marginal posterior plot showing the probability distribution for a single, chosen parameter.

iii. A Bayesian posterior predictive 95% credible band plot, which displays the model’s predicted outcomes over a range of inputs (shaded region may be invisible depending on constraints).

iv. A parameter heatmap giving a colour-coded grid of relationships and statistical dependencies between model parameters estimated from Bayesian inference.

v. The Markov chain Monte Carlo (MCMC) trace which demonstrates how all of the parameters evolved during the fit.

First, the corner plot. Each panel on the diagonal shows the marginal posterior distribution of one varied parameter, while the off-diagonal panels show the pairwise relationships between parameters.

Look out for strongly tilted or curved shapes in the off-diagonal panels: these indicate correlated parameters, which the data cannot constrain independently of one another.

print("\nGenerating posterior pair (corner) plot...")
fitter.plot_posterior_pairs()
Generating posterior pair (corner) plot...
../_images/47444bd5c2a8308655411ee290d4e547f48b2ad46bff772b316900f8ea055f03.png

Next, we can single out one parameter of particular interest — here the sphere radius — and inspect its marginal posterior distribution in detail.

Is the distribution symmetric? Roughly Gaussian? The width of this distribution is a direct measure of the uncertainty in the radius.

print("\nGenerating marginal posterior for radius...")
fitter.plot_param_distribution("radius")
Generating marginal posterior for radius...
../_images/1711604e356fda6e68b3151fa2b947a7ed4564a42d9209e05a3290d6eee4f917.png

The posterior predictive plot propagates the parameter uncertainties back into \(I(q)\) space: the shaded band shows the 95% credible interval of the model prediction. If the parameters are tightly constrained, the band may be so narrow that it is barely visible behind the data.

print("\nGenerating posterior predictive band...")
fitter.plot_posterior_predictive(style="band")
Generating posterior predictive band...
../_images/c5262aff98ae78f96242264d6ef8dcf42bc6c64b81846fdcf6cf6cbbdc4a404d.png

The correlation heatmap condenses the off-diagonal information from the corner plot into a single colour-coded matrix of correlation coefficients. Values close to +1 or −1 flag pairs of parameters that the data cannot determine independently.

print("\nGenerating parameter correlation heatmap...")
fitter.plot_param_correlations()
Generating parameter correlation heatmap...
../_images/55713dd9bc53a9fd21b747696bced30d9db807d02b08578961ef43c77d736514.png

Finally, the MCMC trace shows how each parameter evolved during the sampling. A well-converged chain should look like stationary noise around a constant value; systematic drifts or sudden jumps suggest that more burn iterations or more samples are needed.

print("\nGenerating MCMC trace plot...")
fitter.plot_trace()
Generating MCMC trace plot...
../_images/265561f625d83cb59f676c85adb52d60868e3208dea71f640270762227752568.png

Before moving on, compare these results with the Nelder-Mead fit from the previous notebook:

  • Are the median parameter values consistent with the maximum likelihood estimates found earlier?

  • What extra information does the Bayesian analysis provide that a simple optimiser does not?

Finally, an example of how one can access the posterior data and save the fit results:

posterior = fitter.get_posterior()
print("\nSampled parameters:", posterior.labels)
print("Chain shape:", posterior.samples.shape)
print("95% credible intervals:")
for name in posterior.labels:
    low, high = posterior.ci_95[name]
    print(f"  {name}: [{low:.6g}, {high:.6g}]")

# Export the raw chain for external analysis (pandas, corner, arviz, ...)
posterior.save_posterior_csv("posterior_chain.csv")
print("\n✓ Raw posterior chain saved to posterior_chain.csv")

# The saved fit results include the credible intervals in the header
fitter.save_results("bayesian_fit_results.csv")
Sampled parameters: ['background', 'radius', 'scale']
Chain shape: (7530, 3)
95% credible intervals:
  background: [0.017152, 0.0184109]
  radius: [90.5812, 91.7184]
  scale: [0.00189876, 0.00199501]

✓ Raw posterior chain saved to posterior_chain.csv
✓ Results saved to bayesian_fit_results.csv

Exercise 7: Explore models for the data#

You are now armed with knowledge of how to set models, variable and fixed parameters and constraints, and to then fit data with straightforward and more statistically rigorous approaches.

In the previous notebook we explored this dataset using spherical and ellipsoidal fits. Can Bayesian analysis help you to distinguish between candidate models?

In this exercise, you should:

  1. Fetch the list of available models; documentation on these SasView models can be found here.

  2. Pick one, or several, models that could plausibly describe the data.

  3. For each model: create a fitter, set initial values, bounds, and which parameters vary, then run a Bayesian fit with fit_bayesian.

  4. Inspect the fit, residuals, and posterior distributions using the plotting methods from Exercise 6, and decide which model gives the most convincing description of the data.

Further work:

You could also explore the effects of limiting the fitted q-range to see how this limitation affects the statistical distributions:

fitter.set_q_range(qmin=0.1, qmax=0.3)

or try adding a simple structure factor:

fitter.set_structure_factor('hardsphere', radius_effective_mode='link_radius')
from sans_fitter import get_all_models

print(get_all_models())
['adsorbed_layer', 'barbell', 'bcc_paracrystal', 'be_polyelectrolyte', 'binary_hard_sphere', 'broad_peak', 'capped_cylinder', 'core_multi_shell', 'core_shell_bicelle', 'core_shell_bicelle_elliptical', 'core_shell_bicelle_elliptical_belt_rough', 'core_shell_cylinder', 'core_shell_ellipsoid', 'core_shell_parallelepiped', 'core_shell_sphere', 'correlation_length', 'cylinder', 'dab', 'ellipsoid', 'elliptical_cylinder', 'fcc_paracrystal', 'flexible_cylinder', 'flexible_cylinder_elliptical', 'fractal', 'fractal_core_shell', 'fuzzy_sphere', 'gauss_lorentz_gel', 'gaussian_peak', 'gel_fit', 'guinier', 'guinier_porod', 'hardsphere', 'hayter_msa', 'hollow_cylinder', 'hollow_rectangular_prism', 'hollow_rectangular_prism_thin_walls', 'lamellar', 'lamellar_hg', 'lamellar_hg_stack_caille', 'lamellar_stack_caille', 'lamellar_stack_paracrystal', 'line', 'linear_pearls', 'lorentz', 'mass_fractal', 'mass_surface_fractal', 'micromagnetic_FF_3D', 'mono_gauss_coil', 'multilayer_vesicle', 'onion', 'parallelepiped', 'peak_lorentz', 'pearl_necklace', 'poly_gauss_coil', 'polymer_excl_volume', 'polymer_micelle', 'porod', 'power_law', 'pringle', 'prism', 'raspberry', 'rectangular_prism', 'rpa', 'sc_paracrystal', 'sphere', 'spherical_sld', 'spinodal', 'squarewell', 'stacked_disks', 'star_polymer', 'stickyhardsphere', 'superball', 'surface_fractal', 'tetrahedron', 'teubner_strey', 'triaxial_ellipsoid', 'truncated_octahedron', 'truncated_tetrahedron', 'two_lorentzian', 'two_power_law', 'two_yukawa', 'unified_power_Rg', 'vesicle']

Solution:

As one example, we can try a cylinder model. A cylinder has two size parameters — a radius and a length — so, as for the ellipsoid in the previous notebook, we let both vary alongside the scale and background:

Hide code cell content

fitteri = SANSFitter()
fitteri.load_data(filename)
fitteri.set_model("cylinder")
fitteri.get_params()

fitteri.set_param("sld", value=3, min=1, max=30, vary=False)
fitteri.set_param("sld_solvent", value=6, min=1, max=30, vary=False)

fitteri.set_param("radius", value=80, min=10, max=300, vary=True)
fitteri.set_param("length", value=80, min=10, max=300, vary=True)

fitteri.set_param("scale", value=1.4e-7, min=0, max=1, vary=True)
fitteri.set_param("background", value=0.1, min=0, max=1, vary=True)

fitteri.get_params()

result = fitteri.fit_bayesian(samples=10000, burn=100)
display(fitteri.plot_results(show_residuals=True, log_scale=True))

print("\nGenerating posterior pair (corner) plot...")
display(fitteri.plot_posterior_pairs())



# We can compare this with a second candidate: a vesicle, i.e. a hollow spherical shell with a solvent-filled core,
# characterised by a core radius and a membrane thickness.
# Note that for the vesicle model the `scale` parameter is fixed at 1,
# and the particle volume fraction `volfraction` is varied instead:

fitteri = SANSFitter()
fitteri.load_data(filename)
fitteri.set_model("vesicle")
fitteri.get_params()

fitteri.set_param("sld", value=3, min=1, max=30, vary=False)
fitteri.set_param("sld_solvent", value=6, min=1, max=30, vary=False)

fitteri.set_param("radius", value=80, min=10, max=300, vary=True)
fitteri.set_param("thickness", value=10, min=5, max=100, vary=True)
fitteri.set_param("volfraction", value=0.1, min=0, max=1, vary=True)

fitteri.set_param("scale", value=1, min=0, max=1, vary=False)
fitteri.set_param("background", value=0.1, min=0, max=1, vary=True)

fitteri.get_params()

result = fitteri.fit_bayesian(samples=10000, burn=100)
display(fitteri.plot_results(show_residuals=True, log_scale=True))

print("\nGenerating posterior pair (corner) plot...")
display(fitteri.plot_posterior_pairs())

print("\nGenerating posterior predictive band...")
display(fitteri.plot_posterior_predictive(style="band"))
✓ Loaded data from ../4-reduction/sans_iofq.dat
  Q range: 0.0055 to 0.1895 Å⁻¹
  Data points: 200
  Error (dI) column: yes
  Resolution (dQ) column: no
✓ Model 'cylinder' loaded successfully
  Available parameters: 8

================================================================================
Model: cylinder
================================================================================
Parameter            Value        Min          Max          Vary    
--------------------------------------------------------------------------------
sld                  4            0            40           ✗       
sld_solvent          1            0            10           ✗       
radius               20           0            200          ✗       
length               400          0            4000         ✗       
theta                60           -180         180          ✗       
phi                  60           -360         360          ✗       
scale                1            0            inf          ✗       
background           0            0            inf          ✗       
================================================================================


================================================================================
Model: cylinder
================================================================================
Parameter            Value        Min          Max          Vary    
--------------------------------------------------------------------------------
sld                  3            1            30           ✗       
sld_solvent          6            1            30           ✗       
radius               80           10           300          ✓       
length               80           10           300          ✓       
theta                60           -180         180          ✗       
phi                  60           -360         360          ✗       
scale                1.4e-07      0            1            ✓       
background           0.1          0            1            ✓       
================================================================================


Initial χ² = 380.8048
Sampling posterior with BUMPS DREAM (samples=10000, burn=100 generations)...
✓ Fit completed!
Final χ² = 1.6025

Fitted parameters:
  background: 0.01603(37)
  length: 141.4(2.9)
  radius: 83.50(88)
  scale: 0.001998(29)

Posterior summary:
Parameter                    Best         Mean       Median          Std                     68% CI                     95% CI
------------------------------------------------------------------------------------------------------------------------------
background              0.0160307    0.0160247    0.0160116  0.000419024     [0.0156676, 0.0163946]     [0.0152226, 0.0167618]
length                    141.406      145.549      143.153      15.7627           [140.61, 146.33]         [138.491, 155.003]
radius                    83.5014      82.9404      83.0173      1.01887         [82.1485, 83.8912]         [80.2421, 84.5387]
scale                  0.00199808   0.00198891   0.00199774  7.01087e-05   [0.00196796, 0.00202525]   [0.00181021, 0.00206196]

Parameter                 R-hat        ESS
------------------------------------------
background               1.0962        438
length                   1.3883        473
radius                   1.3732        489
scale                    1.3875        374
../_images/b6d0d483be056a039e2bcee7f191891c962cdc6c767165fd6c06a06c96d6fd0d.png
Generating posterior pair (corner) plot...
../_images/d1cdbea5d3581a5ff251943c90b5835794f80cadff9a76f3b3306980985094ac.png
✓ Loaded data from ../4-reduction/sans_iofq.dat
  Q range: 0.0055 to 0.1895 Å⁻¹
  Data points: 200
  Error (dI) column: yes
  Resolution (dQ) column: no
✓ Model 'vesicle' loaded successfully
  Available parameters: 7

================================================================================
Model: vesicle
================================================================================
Parameter            Value        Min          Max          Vary    
--------------------------------------------------------------------------------
sld                  0.5          0            5            ✗       
sld_solvent          6.36         0            63.6         ✗       
volfraction          0.05         0            1            ✗       
radius               100          0            1000         ✗       
thickness            30           0            300          ✗       
scale                1            0            inf          ✗       
background           0            0            inf          ✗       
================================================================================


================================================================================
Model: vesicle
================================================================================
Parameter            Value        Min          Max          Vary    
--------------------------------------------------------------------------------
sld                  3            1            30           ✗       
sld_solvent          6            1            30           ✗       
volfraction          0.1          0            1            ✓       
radius               80           10           300          ✓       
thickness            10           5            100          ✓       
scale                1            0            1            ✗       
background           0.1          0            1            ✓       
================================================================================


Initial χ² = 100439.4342
Sampling posterior with BUMPS DREAM (samples=10000, burn=100 generations)...
✓ Fit completed!
Final χ² = 1.7788

Fitted parameters:
  background: 0.0178(12)
  radius: 10.1(4.5)
  thickness: 81.1(4.2)
  volfraction: 0.00194(13)

Posterior summary:
Parameter                    Best         Mean       Median          Std                     68% CI                     95% CI
------------------------------------------------------------------------------------------------------------------------------
background               0.017782    0.0176206    0.0177212   0.00196759     [0.0164186, 0.0186875]     [0.0126061, 0.0214958]
radius                    10.0784      17.4432      13.3432      15.5897          [10.856, 19.7863]         [10.0741, 81.9253]
thickness                 81.0524      75.4526      77.6064      9.03531         [72.0861, 80.3909]         [56.0706, 82.9372]
volfraction            0.00194074   0.00186451   0.00192717  0.000291571   [0.00174428, 0.00200134]  [0.000899379, 0.00219696]

Parameter                 R-hat        ESS
------------------------------------------
background               1.4223        311
radius                   1.3954        250
thickness                1.5649        269
volfraction              8.8586        244
../_images/bbae60bf32150e04abed9ca9fdf11e6ccada59ed9b453bce374667a5af696e13.png
Generating posterior pair (corner) plot...
../_images/df304e117ad4a3a1d833b0bfcadcf93c53fefe6932440870523bb27f21b8e966.png
Generating posterior predictive band...
../_images/12d1fbe04e42547d53a17ac2c8fb2c621acce3f01b5805a72fe417efd7b91c09.png

How do the different models compare? Do the fits, residuals, and posterior distributions single out one model as the best description of the data?

Consider also the physical plausibility of the fitted parameters: a model that fits well but requires unphysical parameter values should still be treated with suspicion.