Analytical Optimization Digital Twin from Experimental Trajectories

Build a digital twin of an open quantum system using analytical optimization: learn unknown Lindblad jump rates from observable time series via simulator forward modeling and CMA-ES, validate the fit on the measured traces, then deploy the learned model in Simulator to predict held-out observables.

The entry point is NoiseCharacterizer.

Note

A machine-learning pipeline with the same I/O (reference trajectories in, fitted NoiseModel out) is planned for a future release.

Note

Rates are not always uniquely identifiable from a sparse observable set. Judge a fit by trajectory overlap first; rate bars are secondary validation.

Note

Forward backends: representation="auto" (default) prefers deterministic Lindblad on small chains, then MCWF ("vector"), then TJM ("mps"). See Representation Comparison for cross-backend validation.

1. Minimal fit

Three-site transverse-field Ising chain with homogeneous Pauli noise. Pass reference_model= to simulate target trajectories internally (benchmark shortcut); for lab data use ref_expectations= instead (section 3).

 1import warnings
 2
 3import matplotlib.pyplot as plt
 4import numpy as np
 5
 6warnings.filterwarnings("ignore", message=".*special injected samples.*")
 7
 8from mqt.yaqs import AnalogSimParams, Hamiltonian, NoiseCharacterizer, NoiseModel, Observable, Simulator, State
 9
10n_sites = 3
11j_coupling = 1.0
12transverse_field = 2.0
13gamma_true = 0.08
14gamma_init = 0.35
15cma_seed = 42
16sites = list(range(n_sites))
17
18hamiltonian = Hamiltonian.ising(n_sites, J=j_coupling, g=transverse_field)
19init_state = State(n_sites, initial="zeros")
20
21fitting_observables = [
22    Observable("y", 0),
23    Observable("z", 0),
24    Observable("y", 1),
25]
26prediction_observables = [
27    Observable("x", 0),
28    Observable("x", 1),
29    Observable("x", 2),
30    Observable("z", 2),
31]
32
33sim_params = AnalogSimParams(
34    observables=fitting_observables,
35    elapsed_time=0.8,
36    dt=0.1,
37    order=1,
38    sample_timesteps=True,
39)
40
41reference_model = NoiseModel(
42    [{"name": "pauli_x", "sites": [s], "strength": gamma_true} for s in sites]
43    + [{"name": "pauli_y", "sites": [s], "strength": gamma_true} for s in sites]
44    + [{"name": "pauli_z", "sites": [s], "strength": gamma_true} for s in sites]
45)
46
47init_guess = NoiseModel(
48    [{"name": "pauli_x", "sites": [s], "strength": gamma_init} for s in sites]
49    + [{"name": "pauli_y", "sites": [s], "strength": gamma_init} for s in sites]
50    + [{"name": "pauli_z", "sites": [s], "strength": gamma_init} for s in sites]
51)
52
53rate_bounds_low = np.zeros(len(init_guess.processes))
54rate_bounds_high = np.full(len(init_guess.processes), 0.5)
55pauli_labels = ["X", "Y", "Z"]
56
57nc = NoiseCharacterizer(show_progress=False)
58result = nc.characterize(
59    hamiltonian,
60    sim_params,
61    init_state=init_state,
62    init_guess=init_guess,
63    observables=fitting_observables,
64    reference_model=reference_model,
65    x_low=rate_bounds_low,
66    x_up=rate_bounds_high,
67    sigma0=0.05,
68    popsize=8,
69    max_iter=40,
70    seed=cma_seed,
71)
72
73gamma_learned = np.array([
74    result.best_parameters[0:n_sites].mean(),
75    result.best_parameters[n_sites : 2 * n_sites].mean(),
76    result.best_parameters[2 * n_sites : 3 * n_sites].mean(),
77])
78times = result.times
79print(f"√J: {result.sqrt_loss_before():.3f}{result.sqrt_loss_after():.2e}")
80print(f"fitting trajectory RMSE: {result.trajectory_rmse():.2e}")
√J: 0.178 → 6.05e-03
fitting trajectory RMSE: 6.05e-03

2. Validate fitted dynamics and rates

 1gamma_reference = np.full(len(pauli_labels), gamma_true)
 2ref_traj = result.ref_traj
 3fit_traj = result.fit_traj
 4
 5fig, axes = plt.subplots(1, 3, figsize=(9, 2.8), gridspec_kw={"width_ratios": [1.1, 1.0, 1.0]})
 6
 7x_pos = np.arange(len(pauli_labels))
 8bar_width = 0.35
 9axes[0].bar(x_pos - bar_width / 2, gamma_reference, bar_width, label=r"$\gamma_{\mathrm{true}}$", color="0.35")
10axes[0].bar(x_pos + bar_width / 2, gamma_learned, bar_width, label="learned twin", color="C0")
11axes[0].set_xticks(x_pos, pauli_labels)
12axes[0].set_ylabel(r"$\gamma$")
13axes[0].set_title("Learned rates vs. hidden truth")
14axes[0].legend(loc="upper right", fontsize=8)
15
16fit_panels = [(0, r"$\langle Y_0\rangle$"), (1, r"$\langle Z_0\rangle$")]
17for ax, (obs_idx, ylabel) in zip(axes[1:], fit_panels, strict=True):
18    ax.plot(times, fit_traj[obs_idx], color="C0", lw=2.5, label="twin", zorder=1)
19    ax.plot(times, ref_traj[obs_idx], color="0.2", ls=":", lw=2.5, label="experiment", zorder=2)
20    ax.set_xlabel("time")
21    ax.set_ylabel(ylabel)
22    ax.set_ylim(-1.05, 1.05)
23    panel_rmse = float(np.sqrt(np.mean((fit_traj[obs_idx] - ref_traj[obs_idx]) ** 2)))
24    ax.text(0.03, 0.06, rf"RMSE={panel_rmse:.1e}", transform=ax.transAxes, fontsize=8)
25    ax.legend(loc="upper right", fontsize=8)
26
27fig.suptitle("Twin reproduces the experimental fitting observables", y=1.05, fontsize=11)
28fig.tight_layout()
../_images/2fc9299bfe4a7469b46acd50aab56d9aa8e8838ca84eed42c46c2fe066ced36b.svg

3. Experimental data

When trajectories come from the lab (or an external simulator), pass them as ref_expectations with shape (n_obs, n_times) matching observables and sim_params.times. Below we reuse the reference trajectories from section 1 as a stand-in for measured data.

 1experimental_data = np.asarray(result.ref_traj, dtype=float)
 2
 3lab_result = NoiseCharacterizer(show_progress=False).characterize(
 4    hamiltonian,
 5    sim_params,
 6    init_state=init_state,
 7    init_guess=init_guess,
 8    observables=fitting_observables,
 9    ref_expectations=experimental_data,
10    x_low=rate_bounds_low,
11    x_up=rate_bounds_high,
12    sigma0=0.05,
13    popsize=8,
14    max_iter=40,
15    seed=cma_seed,
16)
17print(f"lab-data fit RMSE: {lab_result.trajectory_rmse():.2e}")
lab-data fit RMSE: 6.05e-03

4. Predict held-out observables with the twin

Plug result.optimal_model into Simulator and compare to the hidden reference on observables not used during fitting.

 1pred_params = AnalogSimParams(
 2    observables=prediction_observables,
 3    elapsed_time=sim_params.elapsed_time,
 4    dt=sim_params.dt,
 5    order=sim_params.order,
 6    sample_timesteps=True,
 7)
 8simulator = Simulator(show_progress=False)
 9
10twin_result = simulator.run(init_state, hamiltonian, pred_params, result.optimal_model)
11truth_result = simulator.run(init_state, hamiltonian, pred_params, reference_model)
12twin_traj = np.asarray(twin_result.expectation_values, dtype=float)
13truth_traj = np.asarray(truth_result.expectation_values, dtype=float)
14
15fig, axes = plt.subplots(1, 2, figsize=(7, 2.8))
16holdout_panels = [(0, r"$\langle X_0\rangle$"), (3, r"$\langle Z_2\rangle$")]
17for ax, (obs_idx, ylabel) in zip(axes, holdout_panels, strict=True):
18    ax.plot(times, twin_traj[obs_idx], color="C0", lw=2.5, label="twin", zorder=1)
19    ax.plot(times, truth_traj[obs_idx], color="0.2", ls=":", lw=2.5, label="reference", zorder=2)
20    ax.set_xlabel("time")
21    ax.set_ylabel(ylabel)
22    ax.set_ylim(-1.05, 1.05)
23    ax.legend(loc="upper right", fontsize=8)
24
25fig.suptitle("Twin predicts observables outside the fitting set", y=1.05, fontsize=11)
26fig.tight_layout()
../_images/3bafd0986b0e0f4273edbba1bbb6779f000a16d3d50a41caeb92f8521dfb27e5.svg

5. Stochastic experimental data (MCWF)

The same workflow works with trajectory-averaged MCWF data. Increase num_traj until observables stabilize; the objective becomes stochastic.

 1mcwf_sim_params = AnalogSimParams(
 2    observables=fitting_observables,
 3    elapsed_time=0.8,
 4    dt=0.1,
 5    order=1,
 6    num_traj=32,
 7    sample_timesteps=True,
 8)
 9
10mcwf_result = NoiseCharacterizer(show_progress=False, representation="vector").characterize(
11    hamiltonian,
12    mcwf_sim_params,
13    init_state=init_state,
14    init_guess=init_guess,
15    observables=fitting_observables,
16    reference_model=reference_model,
17    x_low=rate_bounds_low,
18    x_up=rate_bounds_high,
19    sigma0=0.05,
20    popsize=8,
21    max_iter=20,
22    seed=cma_seed,
23)
24
25fig, ax = plt.subplots(figsize=(4.5, 2.8))
26obs_idx = 1
27ax.plot(times, mcwf_result.fit_traj[obs_idx], color="C0", lw=2.5, label="MCWF twin", zorder=1)
28ax.plot(times, mcwf_result.ref_traj[obs_idx], color="0.2", ls=":", lw=2.5, label="experiment", zorder=2)
29ax.set_xlabel("time")
30ax.set_ylabel(r"$\langle Z_0\rangle$")
31ax.set_ylim(-1.05, 1.05)
32ax.legend(loc="upper right", fontsize=8)
33ax.set_title(f"MCWF fit: √J → {mcwf_result.sqrt_loss_after():.2e}")
34fig.tight_layout()
../_images/a7ae6804873ad22b4506df05f54084368dc437c96b970f59ab57ffd831fdbe9f.svg

Workflow summary

Step

Action

1

Collect experimental trajectories on a fitting observable set

2

NoiseCharacterizer.characterize(..., ref_expectations=...)

3

Compare learned rates and fitted-observable dynamics to reference

4

Simulator.run with result.optimal_model on held-out observables

See also