Memory Surrogate Training and Prediction

For control sequences beyond what you can simulate exhaustively, train a causal Transformer surrogate on Hamiltonian rollouts of the open system. predict() returns the reduced density matrix of the probe qubit after a control sequence.

Surrogate training requires PyTorch (pip install mqt.yaqs[torch]). Over short temporal horizons (few intervention steps), compare surrogate rollouts to Hamiltonian training targets and to exact dense or MPO process tensors built with build_process_tensor(). Environmental memory probing (characterize) is covered in Probing Environmental Memory.

Warning

Exact references scale exponentially with sequence length. Use them only over few intervention steps — short probes in time, not long open-system runs.

Setup

 1import matplotlib.pyplot as plt
 2import numpy as np
 3
 4from mqt.yaqs import AnalogSimParams, Hamiltonian, MemoryCharacterizer
 5from mqt.yaqs.characterization.memory.backends.surrogates.workflow import build_training_dataset
 6from mqt.yaqs.characterization.memory.shared.encoding import encode_rho_pauli, unpack_rho8
 7from mqt.yaqs.characterization.memory.shared.metrics import mean_trace_distance_rho8
 8from mqt.yaqs.core.data_structures.mpo import MPO
 9
10PAULI_Z = np.array([[1, 0], [0, -1]], dtype=np.complex128)
11
12
13def z_expectation(rho8_row: np.ndarray) -> float:
14    """Return ⟨Z⟩ from a packed 8-float density-matrix row."""
15    rho = unpack_rho8(rho8_row)
16    return float(np.trace(PAULI_Z @ rho).real)
17
18length = 2
19ham = Hamiltonian.ising(length=length, J=1.0, g=1.0)
20params = AnalogSimParams(dt=0.1)
21mc = MemoryCharacterizer(show_progress=False)
22num_interventions = 2
23timesteps = [0.0, 0.0, 0.0]
24intervention_style = "measure_prepare"

Use a probe + environment chain (length >= 2). Match intervention_style and timesteps between training, predict, and build_process_tensor (length num_interventions + 1).

Train a surrogate

Training fixes num_interventions on the model — the horizon the network was fit to. The settings below mirror the accuracy regression in the test suite (measure_prepare legs, short schedule).

 1model = mc.train(
 2    ham,
 3    params,
 4    num_interventions=num_interventions,
 5    n=60,
 6    seed=0,
 7    timesteps=timesteps,
 8    intervention_style=intervention_style,
 9    train_kwargs={
10        "epochs": 120,
11        "batch_size": 16,
12        "lr": 2e-3,
13        "device": "cpu",
14        "prefix_loss": "full",
15    },
16    model_kwargs={
17        "d_model": 32,
18        "nhead": 4,
19        "num_layers": 1,
20        "dim_ff": 64,
21        "dropout": 0.0,
22    },
23)

Evaluate on held-out Hamiltonian rollouts

Generate fresh training sequences with a different seed and compare the surrogate’s final-step predictions to the Hamiltonian targets used during dataset construction:

 1held_out = build_training_dataset(
 2    MPO.ising(length=length, J=1.0, g=1.0),
 3    params,
 4    num_interventions=num_interventions,
 5    n=60,
 6    seed=999,
 7    intervention_style=intervention_style,
 8    show_progress=False,
 9    timesteps=timesteps,
10)
11e_test, rho0_test, rho_true = held_out.tensors
12rho_pred = model.predict(e_test.numpy(), rho0_test.numpy(), return_numpy=True)
13
14z_true = np.array([z_expectation(row) for row in rho_true.numpy()[:, -1, :]])
15z_pred = np.array([z_expectation(row) for row in rho_pred[:, -1, :]])
16mean_td = mean_trace_distance_rho8(rho_pred[:, -1, :], rho_true.numpy()[:, -1, :])
17
18fig, ax = plt.subplots(figsize=(4.5, 4))
19ax.scatter(z_true, z_pred, s=18, alpha=0.75)
20lims = (min(z_true.min(), z_pred.min()) - 0.05, max(z_true.max(), z_pred.max()) + 0.05)
21ax.plot(lims, lims, "k--", linewidth=1)
22ax.set_xlim(lims)
23ax.set_ylim(lims)
24ax.set_xlabel(r"Hamiltonian $\langle Z \rangle$ (final step)")
25ax.set_ylabel(r"Surrogate $\langle Z \rangle$ (final step)")
26ax.set_title(rf"Held-out rollouts (mean trace distance = {mean_td:.3f})")
27ax.set_aspect("equal")
28fig.tight_layout()
../_images/82117d942d7ad219ea6443c356818a01a06c1d6e168bc75b760731931723181d.svg

Compare explicit control sequences

The usual workflow after training is to call predict() with your own control sequence and compare outcomes across choices. Train a one-leg surrogate on random unitary controls, then pass different explicit unitary lists to the same model. This mirrors the quickstart workflow (Quickstart) with the longer training budget used above:

 1unitary_timesteps = [0.0, 0.0]
 2controls_model = mc.train(
 3    ham,
 4    params,
 5    num_interventions=1,
 6    n=120,
 7    seed=2,
 8    timesteps=unitary_timesteps,
 9    intervention_style="haar",
10    train_kwargs={
11        "epochs": 120,
12        "batch_size": 16,
13        "lr": 2e-3,
14        "device": "cpu",
15        "prefix_loss": "full",
16    },
17    model_kwargs={
18        "d_model": 32,
19        "nhead": 4,
20        "num_layers": 1,
21        "dim_ff": 64,
22        "dropout": 0.0,
23    },
24)
25
26rho0_controls = np.eye(2, dtype=np.complex128) / 2.0
27hadamard = np.array([[1, 1], [1, -1]], dtype=np.complex128) / np.sqrt(2)
28pauli_x = np.array([[0, 1], [1, 0]], dtype=np.complex128)
29control_sequences = {
30    r"$\mathrm{H}$": [{"unitary": hadamard}],
31    r"$\mathrm{X}$": [{"unitary": pauli_x}],
32}
33
34pauli_ops = {
35    "X": np.array([[0, 1], [1, 0]], dtype=np.complex128),
36    "Y": np.array([[0, -1j], [1j, 0]], dtype=np.complex128),
37    "Z": np.array([[1, 0], [0, -1]], dtype=np.complex128),
38}
39expectations = {
40    label: [
41        float(np.trace(op @ mc.predict(controls_model, rho0_controls, controls, num_interventions=1)).real)
42        for op in pauli_ops.values()
43    ]
44    for label, controls in control_sequences.items()
45}
46
47pauli_names = list(pauli_ops)
48x = np.arange(len(pauli_names))
49width = 0.35
50
51fig, ax = plt.subplots(figsize=(5.5, 3.5))
52for offset, (label, values) in zip((-width / 2, width / 2), expectations.items()):
53    ax.bar(x + offset, values, width, label=f"control {label}")
54ax.set_xticks(x, pauli_names)
55ax.set_ylabel(r"$\langle P \rangle$")
56ax.set_title("Probe Pauli expectations for two control sequences")
57ax.legend(frameon=False)
58fig.tight_layout()
../_images/5f11a74deb1722a5f6939c842f90467e4ac93525be94d0be913603b5e54a2f1f.svg

Extend the per-leg list when num_interventions > 1 to probe multi-step sequences (for example [H, X]).

Validate against exact references

Build exhaustive process tensors for the same schedule. For process tensors, rho0 in predict must match pt.initial_rho (the site-0 state after the initial leg of the reference schedule).

Dense and MPO implementations should agree on identical interventions. Compare all three backends on a stochastic sequence drawn from the training style (measure_prepare here): pass a fresh np.random.default_rng(seed) to each predict call (reusing one RNG object advances its state between calls).

 1pt_dense = mc.build_process_tensor(
 2    ham, params, timesteps=timesteps, return_type="dense", num_trajectories=48,
 3)
 4pt_mpo = mc.build_process_tensor(
 5    ham, params, timesteps=timesteps, return_type="mpo", num_trajectories=48,
 6)
 7
 8rho0 = pt_dense.initial_rho
 9compare_seed = 7
10
11rho_dense = mc.predict(
12    pt_dense, rho0, intervention_style, num_interventions=num_interventions,
13    rng=np.random.default_rng(compare_seed),
14)
15rho_mpo = mc.predict(
16    pt_mpo, rho0, intervention_style, num_interventions=num_interventions,
17    rng=np.random.default_rng(compare_seed),
18)
19rho_surrogate = mc.predict(
20    model, rho0, intervention_style, num_interventions=num_interventions,
21    rng=np.random.default_rng(compare_seed),
22)
23
24pauli_labels = [r"$X$", r"$Y$", r"$Z$"]
25pauli_dense = encode_rho_pauli(rho_dense)[1:]
26pauli_mpo = encode_rho_pauli(rho_mpo)[1:]
27pauli_surrogate = encode_rho_pauli(rho_surrogate)[1:]
28x = np.arange(len(pauli_labels))
29width = 0.25
30
31fig, ax = plt.subplots(figsize=(5.5, 3.5))
32ax.bar(x - width, pauli_dense, width, label="dense", color="tab:blue")
33ax.bar(x, pauli_mpo, width, label="MPO", color="tab:orange", alpha=0.85)
34ax.bar(x + width, pauli_surrogate, width, label="surrogate", color="tab:green", alpha=0.85)
35ax.set_xticks(x, pauli_labels)
36ax.set_ylabel(r"$\langle P \rangle$")
37ax.set_title(
38    rf"Matched {intervention_style} sequence ($\|\rho_{{\mathrm{{dense}}}}-\rho_{{\mathrm{{MPO}}}}\|_F$ = "
39    rf"{np.linalg.norm(rho_dense - rho_mpo):.1e})"
40)
41ax.legend(frameon=False)
42fig.tight_layout()
../_images/8ff723d2c447f5fe30dafbdc8b962f96d9d7f41dcf40975017fbe93cc1adfc84.svg

Dense and MPO should overlap; the surrogate approximates the same draw at the reference rho0. Held-out Hamiltonian rollouts above use random probe rho0 values from data generation — a different setup than the fixed reference state baked into process tensors.

The same information functionals are available on either backend. For this short horizon, conditional mutual information is near zero while QMI grows when more past legs are included:

 1past_choices = ("all", "first", "last")
 2qmi_dense = [mc.compute_qmi(pt_dense, past=p) for p in past_choices]
 3qmi_mpo = [mc.compute_qmi(pt_mpo, past=p) for p in past_choices]
 4cmi_dense = mc.compute_cmi(pt_dense)
 5cmi_mpo = mc.compute_cmi(pt_mpo)
 6
 7fig, ax = plt.subplots(figsize=(5, 3.5))
 8ax.plot(past_choices, qmi_dense, "o-", label="QMI (dense)")
 9ax.plot(past_choices, qmi_mpo, "s--", label="QMI (MPO)", alpha=0.85)
10ax.axhline(cmi_dense, color="tab:purple", linestyle=":", linewidth=1.5, label=rf"CMI (dense) = {cmi_dense:.2e}")
11ax.axhline(cmi_mpo, color="tab:gray", linestyle="--", linewidth=1, label=rf"CMI (MPO) = {cmi_mpo:.2e}")
12ax.set_ylabel("nats")
13ax.set_xlabel(r"past legs in QMI")
14ax.set_title("Process-tensor information metrics")
15ax.legend(frameon=False, fontsize=8, loc="upper left")
16fig.tight_layout()
../_images/11371ddb9cbc38fef35cffd070ef1b6d9958f66eb7b0e704ded885687c972e87.svg

Split-cut response matrices and \(S_V(c)\) from Probing Environmental Memory probe the same memory content in an operational setting.