Fitting with Dependent (Linked) Parameters#

This notebook shows how to link fit parameters to one another with expressions, so that physically related quantities are tied together instead of fit independently. Linking parameters encodes known physics directly into the model and reduces the number of free parameters, making fits faster and more robust.

The example system shown here is an X-ray Photoelectron Spectroscopy (XPS) spin-orbit doublet (Au 4f). Two Gaussian–Lorentzian-product (GLP) peaks sit on an Offset + Shirley background. The two peaks are not independent: as a 4f spin-orbit doublet they share

  • a fixed intensity ratio (here 3:4),

  • a fixed energy splitting (here +3.67 eV),

  • the same line shape (width F and mixing m).

So only the first peak’s amplitude, position, and line shape are fit; all parameters of the second peak are derived from the first through expressions. See models_energy.yaml for the implementation.

The workflow mirrors 01_basic_fitting but focuses on the linked parameters (and skips slice-by-slice fitting):

  1. Select the data: load, inspect, and set the fit limits.

  2. Fit the baseline (base): fit the ground-state doublet, then confirm the links held.

  3. Global 2D fit (2D): add a time-dependence to the first peak’s position; the second peak’s position inherits it through its expression.

  4. Inspect the results.

from pathlib import Path

import numpy as np
import trspecfit

1. Load and Inspect Data#

1.1 Load Data#

Load time- and energy-resolved XPS data from CSV files. Unlike the synthetic dataset in 01_basic_fitting, this is real, measured data: Au 4f spectra acquired at beamline 9.3.1 of the Advanced Light Source, with a ±1 V sample-bias modulation at 0.25 Hz switched on at t = 0. There are no “truth” files; instead, the known physics of the Au 4f doublet and the applied modulation anchor the fit (see §4). The 2D data array is [time, energy], i.e. rows are time, columns are energy.

# Create project (YAML model files are resolved relative to this path)
project = trspecfit.Project(path=Path.cwd())

# Directory containing data files
data_folder = 'data'

# Add file to project
file = trspecfit.File(
    parent_project=project,
    path=data_folder,
    data=np.loadtxt(project.path / data_folder / 'data.csv', delimiter=','),
    energy=np.loadtxt(project.path / data_folder / 'energy.csv'),
    time=np.loadtxt(project.path / data_folder / 'time.csv')
)

1.2 Inspect Data#

Two overlapping GLP peaks (the spin-orbit doublet) sit on a Shirley background. After t = 0 the doublet position oscillates in time.

# Visualize full dataset
file.describe()
File: data [path: data]
../../../_images/1b295917867d1b37b9d91b1f9ee473d1b7ea3b05a325116b9048df2dd193c600.png

1.3 Define Fitting Region#

Set energy and time limits for the fits. They appear as black, dotted lines in the plot.

# Set fitting limits (absolute values)
file.set_fit_limits(
    energy_limits=[91, 81],  # Energy range in eV (descending binding-energy axis)
    time_limits=[-0.3, 16.5]  # Time range
)
../../../_images/2e5d5759156cc8395b0b8799d037e86eff7c6f93154789013a9a50aeb37b63f9.png

2. Fit Baseline with Linked Parameters#

2.1 Define Baseline Spectrum#

Extract the ground-state spectrum by averaging the pre-perturbation time slices.

# Define baseline using time indices
# Indices 0-3 are the four slices before t = 0; the modulation starts at t = 0,
# so the slice at index 4 (t ~ 0) must not bleed into the baseline.
file.define_baseline(
    time_start=0,
    time_stop=3,
    time_type='ind'  # Use indices (or 'abs' for absolute time values)
)
../../../_images/7581775d227565384d09757ee3f31cf3c2125a1268189191e9b6733a6ded2ba6.png

2.2 The Baseline Model and Its Linked Parameters#

The model is defined in models_energy.yaml. The first peak (GLP_01) is fit normally. The second peak (GLP_02) is linked to the first through expressions instead of being given its own free parameters:

GLP:                       # GLP_01 -- fit freely
  A:  [200, True, 0, 1000]
  x0: [84.5, True, 82, 88]
  F:  [1.0, True, 0.75, 2.5]
  m:  [0.3, True, 0, 1]
GLP:                       # GLP_02 -- derived from GLP_01
  A:  ["3/4*GLP_01_A"]     # intensity ratio
  x0: ["GLP_01_x0 + 3.67"] # energy splitting (eV)
  F:  ["GLP_01_F"]         # shared width
  m:  ["GLP_01_m"]         # shared mixing

How to reference a parameter: use its full name {component}_{NN}_{parameter}, where NN is the two-digit, auto-assigned component number (e.g. GLP_01_A). There is no $ prefix.

What you can write in an expression

Pattern

Example

Notes

Copy

"GLP_01_F"

tie a parameter to another

Scale / ratio

"3/4*GLP_01_A"

fixed intensity ratio

Offset

"GLP_01_x0 + 3.67"

fixed splitting

Several parameters

"GLP_01_A * GLP_01_x0 / 85"

arithmetic + - * / ** %

Fan-out

many peaks → one base GLP_01_A

static scaling of a shared base

Reference a dynamic base

"GLP_01_x0 + 3.67" where GLP_01_x0 is time-dependent

the expression inherits the dynamics (see §3)

What you cannot do

  • Chain through a time- or profile-varying parameter (C → B → A_dynamic). Reference the varying base directly instead.

  • Self / circular references.

  • Add dynamics or a profile to an expression parameter — add them to the base parameter it references.

See docs/design/supported_models.md for the full contract.

During the fit, the values of all fixed parameters (vary=False) and all expression parameters are derived automatically; only GLP_01’s free parameters and the background are optimized.

# Load baseline model
file.load_model(
    model_yaml='models_energy.yaml',
    model_info='base'
)

# Inspect model structure -- note the expr column for the GLP_02 parameters
# (detail=1 also plots the initial guess)
file.describe_model(model_info='base', detail=0)
Loading model to describe energy- (and time-)dependent data: base
model name: base
function: Offset from trspecfit.functions.energy
function: Shirley from trspecfit.functions.energy
function: GLP from trspecfit.functions.energy
function: GLP from trspecfit.functions.energy
all lmfit.Parameters() [flattened and sorted alphabetically]:
Name                 Value      Min      Max   Stderr     Vary     Expr Brute_Step
GLP_01_A               200        0     1000     None     True     None     None
GLP_01_F                 1     0.75      2.5     None     True     None     None
GLP_01_m               0.3        0        1     None     True     None     None
GLP_01_x0             84.5       82       88     None     True     None     None
GLP_02_A               150     -inf      inf     None    False 3/4*GLP_01_A     None
GLP_02_F                 1     -inf      inf     None    False GLP_01_F     None
GLP_02_m               0.3     -inf      inf     None    False GLP_01_m     None
GLP_02_x0            88.17     -inf      inf     None    False GLP_01_x0 + 3.67     None
Offset_y0              200        0      500     None     True     None     None
Shirley_pShirley    0.0004    1e-09        1     None     True     None     None
# Fit baseline model to data
# stages=1: single optimization
# stages=2: simplex pass, then leastsq refinement
file.fit_baseline(
    model_name='base',
    stages=2,
    try_ci=0  # skip confidence intervals: see 12_uncertainty_mcmc
)
Time initialize: 1.9311904907226562e-05 s

Results global minumum fit (method=Nelder): 
[[Variables]]
    Offset_y0:         49.8686218 +/- 0.55008686 (1.10%) (init = 200)
    Shirley_pShirley:  8.2742e-04 +/- 2.1098e-05 (2.55%) (init = 0.0004)
    GLP_01_A:          303.359715 +/- 1.29373342 (0.43%) (init = 200)
    GLP_01_x0:         84.2079279 +/- 0.00149252 (0.00%) (init = 84.5)
    GLP_01_F:          0.90331115 +/- 0.00582955 (0.65%) (init = 1)
    GLP_01_m:          0.62597716 +/- 0.02136005 (3.41%) (init = 0.3)
    GLP_02_A:          227.519786 +/- 0.97030007 (0.43%) == '3/4*GLP_01_A'
    GLP_02_x0:         87.8779279 +/- 0.00149252 (0.00%) == 'GLP_01_x0 + 3.67'
    GLP_02_F:          0.90331115 +/- 0.00582955 (0.65%) == 'GLP_01_F'
    GLP_02_m:          0.62597716 +/- 0.02136005 (3.41%) == 'GLP_01_m'
[[Correlations]] (unreported correlations are < 0.100)
    C(GLP_01_F, GLP_01_m)          = -0.6797
    C(GLP_01_A, GLP_01_F)          = -0.6535
    C(GLP_01_A, GLP_01_m)          = +0.6097
    C(Offset_y0, Shirley_pShirley) = -0.5478
    C(Offset_y0, GLP_01_m)         = -0.3969
    C(Offset_y0, GLP_01_A)         = -0.3555
    C(Shirley_pShirley, GLP_01_x0) = -0.2659
    C(Offset_y0, GLP_01_x0)        = +0.1822
Time fit (global minimum): 0.2511429786682129 s

Results local optimization fit (method=leastsq): 
[[Variables]]
    Offset_y0:         49.8687327 +/- 0.54666533 (1.10%) (init = 200)
    Shirley_pShirley:  8.2743e-04 +/- 2.1103e-05 (2.55%) (init = 0.0004)
    GLP_01_A:          303.358740 +/- 1.29232819 (0.43%) (init = 200)
    GLP_01_x0:         84.2079271 +/- 0.00148971 (0.00%) (init = 84.5)
    GLP_01_F:          0.90331475 +/- 0.00583894 (0.65%) (init = 1)
    GLP_01_m:          0.62595688 +/- 0.02110272 (3.37%) (init = 0.3)
    GLP_02_A:          227.519055 +/- 0.96924614 (0.43%) == '3/4*GLP_01_A'
    GLP_02_x0:         87.8779271 +/- 0.00148971 (0.00%) == 'GLP_01_x0 + 3.67'
    GLP_02_F:          0.90331475 +/- 0.00583894 (0.65%) == 'GLP_01_F'
    GLP_02_m:          0.62595688 +/- 0.02110272 (3.37%) == 'GLP_01_m'
[[Correlations]] (unreported correlations are < 0.100)
    C(GLP_01_F, GLP_01_m)          = -0.6817
    C(GLP_01_A, GLP_01_F)          = -0.6536
    C(GLP_01_A, GLP_01_m)          = +0.6086
    C(Offset_y0, Shirley_pShirley) = -0.5499
    C(Offset_y0, GLP_01_m)         = -0.3796
    C(Offset_y0, GLP_01_A)         = -0.3440
    C(Shirley_pShirley, GLP_01_x0) = -0.2689
    C(Offset_y0, GLP_01_x0)        = +0.1672
Time fit (local optimization): 0.006585836410522461 s
Time elapsed for baseline fit: 00.542(ss.ms)
../../../_images/32a10dcbb53adb24cd318a542e51de3a538be5e891851c46f3395ae574b7f1ba.png

2.3 Confirm the Linked Parameters#

GLP_02 was never fit independently — its parameters are computed from GLP_01 via the expressions above. Reading the results back shows the fitted base values at full precision with their uncertainties, and below each the derived GLP_02 value with the expression that produced it: the intensity ratio is exactly 3/4 and the splitting exactly 3.67 eV, with peak 2 carrying no free parameters of its own.

# Read the linked parameters back from the baseline fit results
res = file.get_parameters(fit_type='baseline').set_index('name')

# fitted base parameter (full precision, with stderr), then the value derived
# from it through the expression
for base, derived in (('GLP_01_A', 'GLP_02_A'), ('GLP_01_x0', 'GLP_02_x0')):
    v1, e1 = res.loc[base, 'value'], res.loc[base, 'stderr']
    v2, expr = res.loc[derived, 'value'], res.loc[derived, 'expr']
    print(f'{base:9} = {v1:12.6f} +/- {e1:.6f}   (fitted)')
    print(f'{derived:9} = {v2:12.6f}   = {expr}   (derived)')

A1, A2 = res.loc['GLP_01_A', 'value'], res.loc['GLP_02_A', 'value']
x1, x2 = res.loc['GLP_01_x0', 'value'], res.loc['GLP_02_x0', 'value']
print(f'\nA2 / A1 = {A2 / A1:.6f}      (3/4 exactly)')
print(f'x2 - x1 = {x2 - x1:.6f} eV   (3.67 exactly)')
GLP_01_A  =   303.358740 +/- 1.292328   (fitted)
GLP_02_A  =   227.519055   = 3/4*GLP_01_A   (derived)
GLP_01_x0 =    84.207927 +/- 0.001490   (fitted)
GLP_02_x0 =    87.877927   = GLP_01_x0 + 3.67   (derived)

A2 / A1 = 0.750000      (3/4 exactly)
x2 - x1 = 3.670000 eV   (3.67 exactly)

4. Inspect the Fit Results#

file.get_parameters(fit_type='2d') returns a pandas.DataFrame with one row per parameter. The first peak’s position now carries the sine-wave dynamics parameters (GLP_01_x0_sinFun_01_*), while GLP_02_x0 keeps its expression in the expr column. Peak 2 therefore follows peak 1 plus 3.67 eV at every time point, with no free parameters of its own.

Since this is measured data, the fit is checked against the experiment and the literature instead of a truth file. The fitted modulation frequency (f ≈ 0.2502 Hz) recovers the applied 0.25 Hz bias modulation. The fitted 4f7/2 position (≈ 84.2 eV) lands near the tabulated Au 4f7/2 binding energy of 84.0 eV. The 3.67 eV splitting and 3:4 intensity ratio enforced by the expressions are themselves the literature values for the Au 4f doublet. The fitted oscillation amplitude (≈ 0.84 eV) is smaller than the ±1 V drive: the sample’s response to the bias is damped, while the frequency carries over unchanged.

For more on accessing fit results and metadata, see 10_model_comparison.

file.get_parameters(fit_type='2d')
name value stderr init_value min max vary expr
0 Offset_y0 49.868733 0.000000 49.868733 -inf inf False NaN
1 Shirley_pShirley 0.000827 0.000000 0.000827 -inf inf False NaN
2 GLP_01_A 303.358740 0.000000 303.358740 -inf inf False NaN
3 GLP_01_x0 84.209911 0.000226 84.207927 82.000 88.00 True NaN
4 GLP_01_x0_sinFun_01_A -0.843800 0.000319 -1.000000 -5.000 5.00 True NaN
5 GLP_01_x0_sinFun_01_f 0.250166 0.000013 0.250000 0.001 10.00 True NaN
6 GLP_01_x0_sinFun_01_phi 0.398518 0.000769 0.000000 -0.750 0.75 True NaN
7 GLP_01_x0_sinFun_01_t0 0.000000 0.000000 0.000000 -inf inf False NaN
8 GLP_01_F 0.903315 0.000000 0.903315 -inf inf False NaN
9 GLP_01_m 0.625957 0.000000 0.625957 -inf inf False NaN
10 GLP_02_A 227.519055 0.000000 227.519055 -inf inf False 3/4*GLP_01_A
11 GLP_02_x0 87.879911 0.000226 87.877927 -inf inf False GLP_01_x0 + 3.67
12 GLP_02_F 0.903315 0.000000 0.903315 -inf inf False GLP_01_F
13 GLP_02_m 0.625957 0.000000 0.625957 -inf inf False GLP_01_m

Tips for Dependent Parameters#

Writing expressions

  • Reference a parameter by its full name {component}_{NN}_{parameter} (e.g. GLP_01_A); there is no $ prefix.

  • Use arithmetic (+ - * / ** %); expressions are evaluated by the same safe evaluator lmfit uses.

  • Common patterns: spin-orbit splitting, vibrational progressions, shared backgrounds, fixed intensity ratios.

Allowed vs. not allowed

  • Supported: link to other top-level parameters; static fan-out from a shared base; reference a time-dependent or profiled base directly (the expression inherits the variation).

  • Not supported: chain through a varying parameter (C → B → A_dynamic) — reference the base directly. No self / circular references. Don’t attach dynamics or profiles to an expression parameter.

  • Source of truth: docs/design/supported_models.md.

Strategy

  • Fewer free parameters → more robust, faster fits. Use links to encode known physics.

  • Fit the baseline first: the 2D model pins its line-shape and background parameters to the baseline result and fits only the dynamics.

  • Add a time law to the base parameter and let the linked parameters inherit it.

Next Steps#

Model files#

The YAML files next to this notebook, as shipped with the example.

models_energy.yaml#

# Use these models to describe the energy-dependent data
# For available functions see: /src/trspecfit/functions/energy.py

# Ground state (most parameters should be able to vary)
# The two GLP peaks form a spin-orbit doublet (auto-numbered during model loading):
# GLP_02 is fully derived from GLP_01 via expressions (no free parameters of its own).
base:
    Offset:
      y0: [200, True, 0, 500]
    Shirley:
        pShirley: [4.0E-4, True, 1.0E-9, 1.0]
    GLP:                       # GLP_01 -- the fit-varying peak
      A: [200, True, 0, 1000]
      x0: [84.5, True, 82, 88]
      F: [1.0, True, 0.75, 2.5]
      m: [0.3, True, 0, 1]
    GLP:                       # GLP_02 -- derived from GLP_01
      A: ["3/4*GLP_01_A"]      # fixed intensity ratio (3:4)
      x0: ["GLP_01_x0 + 3.67"] # fixed energy splitting (eV)
      F: ["GLP_01_F"]          # shared width
      m: ["GLP_01_m"]          # shared mixing

# 2D model fit (pinned parameters will be set to "base" Model fit result values).
# Same linked doublet; add_time_dependence() attaches a time law to GLP_01_x0 and
# GLP_02_x0 inherits it through its expression.
2D:
    Offset:
      y0: [50, False]
    Shirley:
        pShirley: [8.0E-4, False]
    GLP:                       # GLP_01 -- position becomes time-dependent
      A: [300, False]
      x0: [84.5, True, 82, 88]
      F: [1.0, False]
      m: [0.3, False]
    GLP:                       # GLP_02 -- inherits GLP_01's dynamics via x0 expr
      A: ["3/4*GLP_01_A"]      # fixed intensity ratio (3:4)
      x0: ["GLP_01_x0 + 3.67"] # fixed energy splitting (eV)
      F: ["GLP_01_F"]          # shared width
      m: ["GLP_01_m"]          # shared mixing

models_time.yaml#

# Use these models to add time-dependence to a parameter in a 2D model
# For available functions see: /src/trspecfit/functions/time.py

sine_wave:
    sinFun:
        A: [-1, True, -5, 5]
        f: [0.25, True, 1E-3, 10]
        phi: [0, True, -0.75, 0.75]
        t0: [0, False]

project.yaml#

# Project configuration for trspecfit dependent_parameters example

# Display settings (general verbosity)
show_output: 1  # 0: silent/API mode, 1: interactive/notebook mode

# Axis labels
e_label: 'Binding energy (eV)' # x -> energy
t_label: 'Time (s)' # y -> time
z_label: 'Intensity (arb. units)' # z -> intensity

# Plot settings
x_dir: 'rev'  # def(ault) or rev(erse)
x_type: 'lin'  # lin or log
y_dir: 'def'  # def(ault) or rev(erse)
y_type: 'lin'  # lin or log
z_colormap: 'RdBu' # colormap in 2D plots
z_type: 'lin'  # lin or log (2D plots)
dpi_plt: 100 # figure display dpi
dpi_save: 300 # figure save dpi
res_mult: 5 # residual multiplier in figures