Skip to content

Commit e63669b

Browse files
authored
Track PauliLindbladMap generators during parity_sample (#16072)
* feat: add PauliLindbladMap.parity_sample_generators This adds a new method to the `PauliLindbladMap` which is identical to its `parity_sample` except for also tracking and returning which of the map's generators resulted in the sampled Pauli operator and sign. * refactor: avoid code duplication of parity_sample * refactor: clean up PauliLindbladMap.parity_sample unittests * refactor: remove duplicate PauliLindbladMap.parity_sample With the refactoring of this PR, the Rust-level PauliLindbladMap.parity_sample method is no longer needed and can be fully replaced by PauliLindbladMap.parity_sample_generators for internal Rust API calls. Therefore, this commit removes that method and relies on the public pyo3 API to expose the PauliLindbladMap.parity_sample method. * refactor: update naming
1 parent 5189ce0 commit e63669b

3 files changed

Lines changed: 137 additions & 109 deletions

File tree

crates/quantum_info/src/pauli_lindblad_map/pauli_lindblad_map_class.rs

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// that they have been altered from the originals.
1212

1313
use hashbrown::HashSet;
14-
use numpy::{PyArray1, PyArrayMethods, ToPyArray};
14+
use numpy::{PyArray1, PyArray2, PyArrayMethods, ToPyArray};
1515
use pyo3::{
1616
IntoPyObjectExt, PyErr,
1717
exceptions::{PyTypeError, PyValueError},
@@ -386,13 +386,19 @@ impl PauliLindbladMap {
386386

387387
/// Sample sign and Pauli operator pairs from the map.
388388
/// Note that here the "sign" bool is interpreted as the exponent of (-1)^b.
389-
pub fn parity_sample(
389+
#[allow(clippy::type_complexity)]
390+
pub fn parity_sample_with_history(
390391
&self,
391392
num_samples: u64,
392393
seed: Option<u64>,
393394
scale: Option<f64>,
394395
local_scale: Option<Vec<f64>>,
395-
) -> (Vec<bool>, QubitSparsePauliList) {
396+
) -> (
397+
Vec<bool>,
398+
QubitSparsePauliList,
399+
Vec<Vec<bool>>,
400+
Vec<Vec<bool>>,
401+
) {
396402
let mut rng = match seed {
397403
Some(seed) => Pcg64Mcg::seed_from_u64(seed),
398404
None => Pcg64Mcg::try_from_rng(&mut SysRng).unwrap(),
@@ -422,13 +428,18 @@ impl PauliLindbladMap {
422428
};
423429
let mut random_signs = Vec::with_capacity(num_samples as usize);
424430
let mut random_paulis = QubitSparsePauliList::empty(self.num_qubits());
431+
let mut pauli_history = Vec::with_capacity(num_samples as usize);
432+
let mut signs_history = Vec::with_capacity(num_samples as usize);
425433

426434
for _ in 0..num_samples {
427435
let mut random_sign = false;
428436
let mut random_pauli = QubitSparsePauli::identity(self.num_qubits());
437+
let mut inner_pauli_history = vec![false; self.qubit_sparse_pauli_list.num_terms()];
438+
let mut inner_signs_history = vec![false; self.qubit_sparse_pauli_list.num_terms()];
429439

430-
for ((probability, generator), non_negative_rate) in probabilities
440+
for (((idx, probability), generator), non_negative_rate) in probabilities
431441
.iter()
442+
.enumerate()
432443
.zip(self.qubit_sparse_pauli_list.iter())
433444
.zip(non_negative_rates.iter())
434445
{
@@ -437,16 +448,21 @@ impl PauliLindbladMap {
437448
random_pauli = random_pauli.compose(&generator.to_term()).unwrap();
438449
// if rate is negative, flip random_sign
439450
random_sign = random_sign == *non_negative_rate;
451+
// keep track of sampled generator
452+
inner_pauli_history[idx] = true;
453+
inner_signs_history[idx] = *non_negative_rate;
440454
}
441455
}
442456

443457
random_signs.push(random_sign);
444458
random_paulis
445459
.add_qubit_sparse_pauli(random_pauli.view())
446460
.unwrap();
461+
pauli_history.push(inner_pauli_history);
462+
signs_history.push(inner_signs_history);
447463
}
448464

449-
(random_signs, random_paulis)
465+
(random_signs, random_paulis, pauli_history, signs_history)
450466
}
451467

452468
/// Reduce the map to its canonical form.
@@ -1578,7 +1594,8 @@ impl PyPauliLindbladMap {
15781594
seed: Option<u64>,
15791595
) -> PyResult<Bound<'py, PyTuple>> {
15801596
let inner = self.inner.read().map_err(|_| InnerReadError)?;
1581-
let (signs, paulis) = py.detach(|| inner.parity_sample(num_samples, seed, None, None));
1597+
let (signs, paulis, _, _) =
1598+
py.detach(|| inner.parity_sample_with_history(num_samples, seed, None, None));
15821599

15831600
let signs = PyArray1::from_vec(py, signs.iter().map(|b| !b).collect());
15841601
let paulis = paulis.into_pyobject(py).unwrap();
@@ -1630,15 +1647,53 @@ impl PyPauliLindbladMap {
16301647
local_scale: Option<Vec<f64>>,
16311648
) -> PyResult<Bound<'py, PyTuple>> {
16321649
let inner = self.inner.read().map_err(|_| InnerReadError)?;
1633-
let (signs, paulis) =
1634-
py.detach(|| inner.parity_sample(num_samples, seed, scale, local_scale));
1650+
let (signs, paulis, _, _) =
1651+
py.detach(|| inner.parity_sample_with_history(num_samples, seed, scale, local_scale));
16351652

16361653
let signs = PyArray1::from_vec(py, signs);
16371654
let paulis = paulis.into_pyobject(py).unwrap();
16381655

16391656
(signs, paulis).into_pyobject(py)
16401657
}
16411658

1659+
/// Sample sign and Pauli operator pairs from the map, preserving the sampled generators.
1660+
///
1661+
/// This method is identical to :meth:`parity_sample` except for also returning the information
1662+
/// which :meth:`generators` were actually sampled to yield the final Pauli operator and sign.
1663+
///
1664+
///
1665+
/// Args:
1666+
/// num_samples (int): Number of samples to draw.
1667+
/// seed (int): Random seed.
1668+
/// scale (float): Scale to apply to all rates.
1669+
/// local_scale (list[float]): Local scale to apply on a term-by-term basis.
1670+
///
1671+
/// Returns:
1672+
/// signs, qubit_sparse_pauli_list, pauli_history, signs_history: The boolean array of
1673+
/// signs, the list of qubit sparse paulis, the two dimensional boolean array
1674+
/// indicating which :meth:`generators` were sampled and the two dimensional boolean
1675+
/// array indicating their signs.
1676+
#[pyo3(signature = (num_samples, seed=None, scale=None, local_scale=None))]
1677+
pub fn parity_sample_with_history<'py>(
1678+
&self,
1679+
py: Python<'py>,
1680+
num_samples: u64,
1681+
seed: Option<u64>,
1682+
scale: Option<f64>,
1683+
local_scale: Option<Vec<f64>>,
1684+
) -> PyResult<Bound<'py, PyTuple>> {
1685+
let inner = self.inner.read().map_err(|_| InnerReadError)?;
1686+
let (signs, paulis, pauli_history, signs_history) =
1687+
py.detach(|| inner.parity_sample_with_history(num_samples, seed, scale, local_scale));
1688+
1689+
let signs = PyArray1::from_vec(py, signs);
1690+
let paulis = paulis.into_pyobject(py).unwrap();
1691+
let pauli_history = PyArray2::from_vec2(py, &pauli_history).unwrap();
1692+
let signs_history = PyArray2::from_vec2(py, &signs_history).unwrap();
1693+
1694+
(signs, paulis, pauli_history, signs_history).into_pyobject(py)
1695+
}
1696+
16421697
/// For :class:`.PauliLindbladMap` instances with purely non-negative rates, sample Pauli
16431698
/// operators from the map. If the map has negative rates, use
16441699
/// :meth:`.PauliLindbladMap.parity_sample`.
@@ -1683,7 +1738,8 @@ impl PyPauliLindbladMap {
16831738
}
16841739
}
16851740

1686-
let (_, paulis) = py.detach(|| inner.parity_sample(num_samples, seed, None, None));
1741+
let (_, paulis, _, _) =
1742+
py.detach(|| inner.parity_sample_with_history(num_samples, seed, None, None));
16871743

16881744
paulis.into_pyobject(py)
16891745
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
features_quantum_info:
3+
- Added the :meth:`.PauliLindbladMap.parity_sample_with_history` method which is
4+
identical to :meth:`.PauliLindbladMap.parity_sample` except for also tracking
5+
and returning the underlying history of Paulis and signs that resulted in the
6+
final sampled terms.

test/python/quantum_info/test_pauli_lindblad_map.py

Lines changed: 66 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,7 @@ def test_attributes_immutable(self):
458458
with self.assertRaisesRegex(ValueError, "assignment destination is read-only"):
459459
pauli_lindblad_map.rates[0] = 1.0
460460

461-
def test_generators(self):
461+
def test_with_history(self):
462462
"""Test that generators() method returns the same result as
463463
get_qubit_sparse_pauli_list_copy()."""
464464
pauli_lindblad_map = PauliLindbladMap.from_list([("IIXIZ", 2), ("IIZIX", 3)])
@@ -1239,134 +1239,100 @@ def test_signed_sample(self):
12391239
self.assertEqual(len(qubit_sparse_pauli_list), 5)
12401240
self.assertEqual(len(signs), 5)
12411241

1242-
def test_parity_sample(self):
1243-
1244-
# test all negative rates
1245-
pauli_lindblad_map = PauliLindbladMap([("X", -1.0), ("Y", -0.5)])
1246-
probs = pauli_lindblad_map.probabilities()
1242+
def _assert_parity_sample_results(
1243+
self,
1244+
plm_to_sample,
1245+
expected_signs,
1246+
num_samples=10000,
1247+
seed=12312,
1248+
scale=None,
1249+
local_scale=None,
1250+
plm_reference=None,
1251+
):
1252+
if plm_reference is None:
1253+
plm_reference = plm_to_sample
1254+
1255+
generators = plm_reference.generators()
1256+
probs = plm_reference.probabilities()
12471257
probs_dict = {
12481258
"I": probs[0] * probs[1],
12491259
"X": (1 - probs[0]) * probs[1],
12501260
"Y": probs[0] * (1 - probs[1]),
12511261
"Z": (1 - probs[0]) * (1 - probs[1]),
12521262
}
1253-
expected_signs = {"I": False, "X": True, "Y": True, "Z": False}
1254-
1255-
num_samples = 10000
1256-
signs, qubit_sparse_pauli_list = pauli_lindblad_map.parity_sample(num_samples, 12312)
12571263

1264+
signs, qubit_sparse_pauli_list, sampled_with_history, sampled_signs = (
1265+
plm_to_sample.parity_sample_with_history(
1266+
num_samples, seed, scale=scale, local_scale=local_scale
1267+
)
1268+
)
12581269
counts = {"I": 0, "X": 0, "Y": 0, "Z": 0}
1259-
for sign, q in zip(signs, qubit_sparse_pauli_list):
1270+
for sign, q, _gens, _signs in zip(
1271+
signs, qubit_sparse_pauli_list, sampled_with_history, sampled_signs
1272+
):
12601273
for symbol in counts:
12611274
if q == QubitSparsePauli(symbol):
12621275
counts[symbol] += 1
12631276
self.assertEqual(expected_signs[symbol], sign)
12641277

1265-
for symbol, count in counts.items():
1266-
self.assertTrue(np.abs(count / num_samples - probs_dict[symbol]) < 1e-2)
1278+
sampled_sign = False
1279+
sampled_pauli = QubitSparsePauli.from_label("I")
1280+
for _i, (_g, _s) in enumerate(zip(_gens, _signs)):
1281+
if _g:
1282+
sampled_sign = sampled_sign == _s
1283+
sampled_pauli = sampled_pauli.compose(generators[_i])
12671284

1268-
# test all positive rates
1269-
pauli_lindblad_map = PauliLindbladMap([("X", 1.0), ("Y", 0.5)])
1270-
probs = pauli_lindblad_map.probabilities()
1271-
probs_dict = {
1272-
"I": probs[0] * probs[1],
1273-
"X": (1 - probs[0]) * probs[1],
1274-
"Y": probs[0] * (1 - probs[1]),
1275-
"Z": (1 - probs[0]) * (1 - probs[1]),
1276-
}
1277-
expected_signs = {"I": False, "X": False, "Y": False, "Z": False}
1285+
self.assertEqual(sign, sampled_sign)
1286+
self.assertEqual(q, sampled_pauli)
12781287

1279-
num_samples = 10000
1280-
signs, qubit_sparse_pauli_list = pauli_lindblad_map.parity_sample(num_samples, 12312)
1281-
1282-
counts = {"I": 0, "X": 0, "Y": 0, "Z": 0}
1283-
for sign, q in zip(signs, qubit_sparse_pauli_list):
1284-
for symbol in counts:
1285-
if q == QubitSparsePauli(symbol):
1286-
counts[symbol] += 1
1287-
self.assertEqual(expected_signs[symbol], sign)
12881288
for symbol, count in counts.items():
12891289
self.assertTrue(np.abs(count / num_samples - probs_dict[symbol]) < 1e-2)
12901290

1291-
# test mix of positive and negative rates
1292-
pauli_lindblad_map = PauliLindbladMap([("X", 1.0), ("Y", -0.5)])
1293-
probs = pauli_lindblad_map.probabilities()
1294-
probs_dict = {
1295-
"I": probs[0] * probs[1],
1296-
"X": (1 - probs[0]) * probs[1],
1297-
"Y": probs[0] * (1 - probs[1]),
1298-
"Z": (1 - probs[0]) * (1 - probs[1]),
1299-
}
1300-
expected_signs = {"I": False, "X": False, "Y": True, "Z": True}
1301-
1302-
num_samples = 10000
1303-
signs, qubit_sparse_pauli_list = pauli_lindblad_map.parity_sample(num_samples, 12312)
1291+
def test_parity_sample(self):
13041292

1305-
counts = {"I": 0, "X": 0, "Y": 0, "Z": 0}
1306-
for sign, q in zip(signs, qubit_sparse_pauli_list):
1307-
for symbol in counts:
1308-
if q == QubitSparsePauli(symbol):
1309-
counts[symbol] += 1
1310-
self.assertEqual(expected_signs[symbol], sign)
1293+
with self.subTest("all negative rates"):
1294+
pauli_lindblad_map = PauliLindbladMap([("X", -1.0), ("Y", -0.5)])
1295+
expected_signs = {"I": False, "X": True, "Y": True, "Z": False}
13111296

1312-
for symbol, count in counts.items():
1313-
self.assertTrue(np.abs(count / num_samples - probs_dict[symbol]) < 1e-2)
1297+
self._assert_parity_sample_results(pauli_lindblad_map, expected_signs)
13141298

1315-
# test scale with mix of positive and negative rates
1316-
pauli_lindblad_map = PauliLindbladMap([("X", 1.0), ("Y", -0.5)])
1317-
pauli_lindblad_map_downscaled = PauliLindbladMap([("X", 0.5), ("Y", -0.25)])
1318-
probs = pauli_lindblad_map.probabilities()
1319-
probs_dict = {
1320-
"I": probs[0] * probs[1],
1321-
"X": (1 - probs[0]) * probs[1],
1322-
"Y": probs[0] * (1 - probs[1]),
1323-
"Z": (1 - probs[0]) * (1 - probs[1]),
1324-
}
1325-
expected_signs = {"I": False, "X": False, "Y": True, "Z": True}
1299+
with self.subTest("all positive rates"):
1300+
pauli_lindblad_map = PauliLindbladMap([("X", 1.0), ("Y", 0.5)])
1301+
expected_signs = {"I": False, "X": False, "Y": False, "Z": False}
13261302

1327-
num_samples = 10000
1328-
signs, qubit_sparse_pauli_list = pauli_lindblad_map_downscaled.parity_sample(
1329-
num_samples, 12312, scale=2.0
1330-
)
1303+
self._assert_parity_sample_results(pauli_lindblad_map, expected_signs)
13311304

1332-
counts = {"I": 0, "X": 0, "Y": 0, "Z": 0}
1333-
for sign, q in zip(signs, qubit_sparse_pauli_list):
1334-
for symbol in counts:
1335-
if q == QubitSparsePauli(symbol):
1336-
counts[symbol] += 1
1337-
self.assertEqual(expected_signs[symbol], sign)
1305+
with self.subTest("mix of positive and negative rates"):
1306+
pauli_lindblad_map = PauliLindbladMap([("X", 1.0), ("Y", -0.5)])
1307+
expected_signs = {"I": False, "X": False, "Y": True, "Z": True}
13381308

1339-
for symbol, count in counts.items():
1340-
self.assertTrue(np.abs(count / num_samples - probs_dict[symbol]) < 1e-2)
1309+
self._assert_parity_sample_results(pauli_lindblad_map, expected_signs)
13411310

1342-
# test local_scale with mix of positive and negative rates
1343-
pauli_lindblad_map = PauliLindbladMap([("X", 1.0), ("Y", -0.5)])
1344-
pauli_lindblad_map_downscaled = PauliLindbladMap([("X", 1.0), ("Y", -0.25)])
1345-
probs = pauli_lindblad_map.probabilities()
1346-
probs_dict = {
1347-
"I": probs[0] * probs[1],
1348-
"X": (1 - probs[0]) * probs[1],
1349-
"Y": probs[0] * (1 - probs[1]),
1350-
"Z": (1 - probs[0]) * (1 - probs[1]),
1351-
}
1352-
expected_signs = {"I": False, "X": False, "Y": True, "Z": True}
1311+
with self.subTest("scale with mix of positive and negative rates"):
1312+
pauli_lindblad_map = PauliLindbladMap([("X", 1.0), ("Y", -0.5)])
1313+
pauli_lindblad_map_downscaled = PauliLindbladMap([("X", 0.5), ("Y", -0.25)])
1314+
expected_signs = {"I": False, "X": False, "Y": True, "Z": True}
13531315

1354-
num_samples = 10000
1355-
signs, qubit_sparse_pauli_list = pauli_lindblad_map_downscaled.parity_sample(
1356-
num_samples, 12312, local_scale=[1.0, 2.0]
1357-
)
1316+
self._assert_parity_sample_results(
1317+
pauli_lindblad_map_downscaled,
1318+
expected_signs,
1319+
scale=2.0,
1320+
plm_reference=pauli_lindblad_map,
1321+
)
13581322

1359-
counts = {"I": 0, "X": 0, "Y": 0, "Z": 0}
1360-
for sign, q in zip(signs, qubit_sparse_pauli_list):
1361-
for symbol in counts:
1362-
if q == QubitSparsePauli(symbol):
1363-
counts[symbol] += 1
1364-
self.assertEqual(expected_signs[symbol], sign)
1323+
with self.subTest("local_scale with mix of positive and negative rates"):
1324+
pauli_lindblad_map = PauliLindbladMap([("X", 1.0), ("Y", -0.5)])
1325+
pauli_lindblad_map_downscaled = PauliLindbladMap([("X", 1.0), ("Y", -0.25)])
13651326

1366-
for symbol, count in counts.items():
1367-
self.assertTrue(np.abs(count / num_samples - probs_dict[symbol]) < 1e-2)
1327+
self._assert_parity_sample_results(
1328+
pauli_lindblad_map_downscaled,
1329+
expected_signs,
1330+
local_scale=[1.0, 2.0],
1331+
plm_reference=pauli_lindblad_map,
1332+
)
13681333

1369-
# test callable without seed
1334+
def test_parity_sample_without_seed(self):
1335+
pauli_lindblad_map = PauliLindbladMap([("X", 1.0), ("Y", -0.5)])
13701336
signs, qubit_sparse_pauli_list = pauli_lindblad_map.parity_sample(5)
13711337
self.assertTrue(isinstance(qubit_sparse_pauli_list, QubitSparsePauliList))
13721338
self.assertEqual(len(qubit_sparse_pauli_list), 5)

0 commit comments

Comments
 (0)