PTSBE end-to-end workflow¶
PTSBE (Pre-Trajectory Sampling with Batch Execution) is a method for sampling from noisy quantum circuits efficiently. Instead of simulating the full density matrix and sampling once per shot, PTSBE:
Traces the kernel to get the gate sequence and qubit layout.
Extracts noise sites by matching the noise model to the trace (each noisy gate becomes a noise site with a set of Kraus outcomes, e.g. I, X, Y, Z for depolarization).
Generates trajectories — each trajectory is one possible realization of noise (one Kraus outcome per site). A sampling strategy decides which trajectories to use (e.g. Exhaustive: all combinations, or Probabilistic: sample by probability).
Allocates shots across trajectories (e.g. proportional to trajectory probability, or uniform).
Runs batches — for each trajectory, the circuit is run as a noiseless circuit with that trajectory’s outcomes applied; results are collected.
Aggregates all per-trajectory counts into a single
SampleResult.
You get the same statistics as density-matrix sampling (in the limit of many shots), but with the ability to batch many shots per trajectory and to control cost via the number of trajectories. This notebook runs the full workflow with a single API call: cudaq.ptsbe.sample().
1. Set up the environment¶
Use the density-matrix simulator target (required for PTSBE). Set a random seed for reproducibility.
[1]:
import cudaq
cudaq.set_target("density-matrix-cpu")
cudaq.set_random_seed(42)
2. Define the circuit and noise model¶
Define a kernel and attach a noise model. Each gate you add to the noise model becomes a noise site when that gate appears in the circuit. For single-qubit gates (e.g. H) use DepolarizationChannel with one qubit; for CNOTs pass the qubit pair [control, target] and use Depolarization2 (two-qubit depolarization). Here we use a small Bell-style circuit with depol on the Hadamard and on the controlled-X (qubit pair [0, 1]).
[2]:
@cudaq.kernel
def bell_with_noise():
q = cudaq.qvector(2)
h(q[0])
x.ctrl(q[0], q[1])
mz(q)
noise = cudaq.NoiseModel()
noise.add_channel("h", [0], cudaq.DepolarizationChannel(0.05))
noise.add_channel("x", [0, 1], cudaq.Depolarization2(0.03))
3. Run PTSBE sampling¶
Call cudaq.ptsbe.sample() with the kernel, noise model, and shot count. Optional arguments:
sampling_strategy — how trajectories are chosen:
ExhaustiveSamplingStrategy(): use all possible trajectories (every combination of Kraus outcomes per noise site).
ProbabilisticSamplingStrategy(seed=…): sample trajectories randomly according to their probabilities; use a seed for reproducibility.
OrderedSamplingStrategy(): use the top-\(k\) trajectories by probability (highest first), up to
max_trajectories.
shot_allocation — how shots are split across the chosen trajectories:
PROPORTIONAL (default): allocate shots in proportion to each trajectory’s probability.
UNIFORM: give each trajectory the same number of shots.
LOW_WEIGHT_BIAS: bias more shots toward low-weight (fewer errors) trajectories; optional
bias_strength(default 2.0).HIGH_WEIGHT_BIAS: bias more shots toward high-weight trajectories; optional
bias_strength(default 2.0). Example:ShotAllocationStrategy(type=cudaq.ptsbe.ShotAllocationType.UNIFORM)orShotAllocationStrategy(type=cudaq.ptsbe.ShotAllocationType.LOW_WEIGHT_BIAS, bias_strength=5.0).
max_trajectories: cap the number of trajectories (useful for large shot counts).
return_execution_data (bool): If
True, the result includes trace instructions and per-trajectory data (result.ptsbe_execution_data); see section 6 at the end.
[3]:
shots = 1000000
strategy = cudaq.ptsbe.ProbabilisticSamplingStrategy(seed=42)
result = cudaq.ptsbe.sample(
bell_with_noise,
noise_model=noise,
shots_count=shots,
sampling_strategy=strategy,
)
print("PTSBE sample result:")
print(result)
print(f"Total shots: {result.get_total_shots()}")
PTSBE sample result:
{ 00:491822 01:7889 10:8035 11:492254 }
Total shots: 1000000
4. Compare with standard (density-matrix) sampling¶
To verify that PTSBE matches the usual noisy simulation, run standard cudaq.sample() with the same kernel and noise model. With enough shots, the two outcome distributions should be close (see the PTSBE accuracy validation example for a Hellinger fidelity comparison).
[4]:
result_standard = cudaq.sample(bell_with_noise, noise_model=noise, shots_count=shots)
print("Standard (density-matrix) sample result:")
print(result_standard)
print(f"Total shots: {result_standard.get_total_shots()}")
Standard (density-matrix) sample result:
{ 00:491849 01:8033 10:8003 11:492115 }
Total shots: 1000000
5. Return execution data¶
Pass return_execution_data=True to get the PTSBE execution data: the trace (gate, noise, measurement instructions) and the list of trajectories with their probabilities and shot allocations. Use result.ptsbe_execution_data and result.has_execution_data().
[5]:
result_with_data = cudaq.ptsbe.sample(
bell_with_noise,
noise_model=noise,
shots_count=shots,
sampling_strategy=strategy,
return_execution_data=True,
)
assert result_with_data.has_execution_data()
data = result_with_data.ptsbe_execution_data
Gate = cudaq.ptsbe.TraceInstructionType.Gate
Noise = cudaq.ptsbe.TraceInstructionType.Noise
Measurement = cudaq.ptsbe.TraceInstructionType.Measurement
print("Execution data:")
print(f" Instructions: {len(data.instructions)} total")
n_gate = sum(1 for i in data.instructions if i.type == Gate)
n_noise = sum(1 for i in data.instructions if i.type == Noise)
n_meas = sum(1 for i in data.instructions if i.type == Measurement)
print(f" Gates: {n_gate}, Noise: {n_noise}, Measurements: {n_meas}")
print(f" Trajectories: {len(data.trajectories)}")
total_traj_shots = sum(t.num_shots for t in data.trajectories)
print(f" Sum of trajectory shots: {total_traj_shots} (expected {shots})")
print(" Trace instructions (first 5):")
for i, inst in enumerate(data.instructions[:5]):
print(f" [{i}] type={inst.type}, name={inst.name}, targets={list(inst.targets)}")
if data.trajectories:
t0 = data.trajectories[0]
print(f" Example trajectory: id={t0.trajectory_id}, probability={t0.probability:.6f}, num_shots={t0.num_shots}")
print(" Selected trajectory (kraus_selections):")
for sel in t0.kraus_selections:
print(f" circuit_location={sel.circuit_location}, kraus_operator_index={sel.kraus_operator_index}, is_error={sel.is_error}")
Execution data:
Instructions: 6 total
Gates: 2, Noise: 2, Measurements: 2
Trajectories: 64
Sum of trajectory shots: 1000000 (expected 1000000)
Trace instructions (first 5):
[0] type=TraceInstructionType.Gate, name=h, targets=[0]
[1] type=TraceInstructionType.Noise, name=depolarization_channel, targets=[0]
[2] type=TraceInstructionType.Gate, name=x, targets=[1]
[3] type=TraceInstructionType.Noise, name=depolarization2, targets=[1, 0]
[4] type=TraceInstructionType.Measurement, name=mz, targets=[0]
Example trajectory: id=0, probability=0.921500, num_shots=921638
Selected trajectory (kraus_selections):
circuit_location=1, kraus_operator_index=0, is_error=False
circuit_location=3, kraus_operator_index=0, is_error=False
[2026-03-02 16:05:14.065] [warning] [PTSBESampleResult.cpp:20] PTSBE execution data API is experimental and may change in a future release.
6. Two API options:¶
We have two ways to sample from a noisy circuit:
``cudaq.sample(kernel, noise_model=noise, use_ptsbe=…, …)`` — The main sample API with a ``use_ptsbe`` option. When
use_ptsbe=False(or omitted), sampling uses the standard density-matrix path; whenuse_ptsbe=True, sampling uses the PTSBE (trajectory) path.``cudaq.ptsbe.sample(kernel, noise_model=noise, …)`` — The dedicated PTSBE API. There is no
use_ptsbeoption; sampling always uses the PTSBE path (trajectories, strategies, execution data, etc.).
When to choose which: Use ``cudaq.sample`` with use_ptsbe=True when you want PTSBE from the main sample API (one call, one place for all sampling). Use ``cudaq.ptsbe.sample`` when you want the dedicated PTSBE API (explicit PTSBE entry point, no flag). Both provide the same PTSBE path (large shot counts, batching, trajectory strategies, execution data).