Skip to content
Open
15 changes: 15 additions & 0 deletions analysis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,21 @@ hamiltonian.load_second_quantization("file.npz",
hamiltonian.load_second_quantization("data.h5")
```

### Accessing Pauli Strings in Python

For a `Hamiltonian` instance, request `PauliString` keys to work without committing to a dense or
sparse representation:

```python
pauli_terms = physical_hamiltonian.get_all_pauli_strings(return_as="objects")

for pauli, coefficient in pauli_terms.items():
print(pauli.to_dense(), pauli.to_sparse_dict(), coefficient)
```

Use `return_as="strings"` for dense-string keys or `return_as="tuples"` for the existing sparse
tuple keys. The `objects` form is useful when a caller needs both representations.

### Encoding as a Unitary

Currently all of our applications involve encoding the Hamiltonian ($\hat{H}$) as a time-evolution
Expand Down
103 changes: 54 additions & 49 deletions analysis/hamiltonian.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from qhat.analysis.config_types import GeneralConfiguration, HamiltonianConfiguration, value
from qhat.common.bosons_binary import BosonicBinaryEncoding
from qhat.common.MixedFermionBosonOperator import MixedFermionBosonOperator
from qhat.common.pauli_string import PauliString

logger = logging.getLogger(__name__)

Expand All @@ -38,62 +39,62 @@ def boson_to_qubit_operator(bosonic_operator, Nmax):

# -------------------------------------------------------------------------------------------------

# TODO: These are generally useful utilities. Where should they live?
def sparse_to_dense_pauli(sparse_pauli, num_qubits):
dense_pauli = ["I",] * num_qubits
for idx, op in sparse_pauli:
dense_pauli[idx] = op
return "".join(dense_pauli)
"""Compatibility wrapper for converting a sparse Pauli tuple to a string."""
return PauliString.from_sparse(sparse_pauli, num_qubits).to_dense()


def dense_to_sparse_pauli(dense_pauli):
sparse_pauli = tuple()
for idx, op in enumerate(dense_pauli):
if op in ["X", "Y", "Z"]:
sparse_pauli = (*sparse_pauli, (idx,op))
elif op != "I":
raise ValueError(f"Invalid character in dense pauli string: \"{op}\".")
return sparse_pauli
"""Compatibility wrapper for converting a Pauli string to a sparse tuple."""
return PauliString.from_dense(dense_pauli).to_sparse()

# -------------------------------------------------------------------------------------------------

class LinearCombinationOfPauliStrings:
def __init__(self, **kwargs):
self._nq = None
self._format = None
self._data = None
self._nq = kwargs["num_qubits"]
for f in [ "dense", "sparse" ]:
if f in kwargs:
if self._format is not None:
raise ValueError(
"Too many formats provided to LinearCombinationOfPauliStrings.")
self._format = f
self._data = kwargs[f]
assert isinstance(self._data, dict)
if self._format is None:
formats = [fmt for fmt in ["dense", "sparse"] if fmt in kwargs]
if not formats:
raise ValueError("No data provided to LinearCombinationOfPauliStrings.")
if len(formats) > 1:
raise ValueError("Too many formats provided to LinearCombinationOfPauliStrings.")

input_format = formats[0]
input_data = kwargs[input_format]
if not isinstance(input_data, dict):
raise TypeError("Pauli string data must be provided as a dictionary.")

identity = PauliString.from_sparse((), kwargs["num_qubits"])
self._nq = identity.num_qubits
self._data = {}
for raw_pauli, coefficient in input_data.items():
if input_format == "dense":
pauli = PauliString.from_dense(raw_pauli)
if pauli.num_qubits != self._nq:
raise ValueError(
f"Dense Pauli string {raw_pauli!r} has length "
f"{pauli.num_qubits}, expected {self._nq}."
)
else:
pauli = PauliString.from_sparse(raw_pauli, self._nq)
self._data[pauli] = self._data.get(pauli, 0.0) + coefficient

def num_qubits(self):
return self._nq

def get_pauli_strings(self):
"""Return a copy of the canonical PauliString-keyed coefficient dictionary."""
return dict(self._data)

def get_dense_pauli_strings(self):
if self._format == "dense":
return self._data
elif self._format == "sparse":
return {sparse_to_dense_pauli(pauli, self._nq) : coef
for pauli, coef in self._data.items()}
else:
raise ValueError("Invalid data format \"{self._format}\".")
return {pauli.to_dense() : coef for pauli, coef in self._data.items()}

def get_sparse_pauli_strings(self):
if self._format == "dense":
return {dense_to_sparse_pauli(pauli) : coef for pauli, coef in self._data.items()}
elif self._format == "sparse":
return self._data
else:
raise ValueError("Invalid data format \"{self._format}\".")
return {pauli.to_sparse() : coef for pauli, coef in self._data.items()}

def energy_shift(self, shift):
all_identity = tuple()
if self._format == "dense":
all_identity = sparse_to_dense_pauli(all_identity, self._nq)
identity_coefficient = self._data.get(all_identity, 0.0) + shift
self._data[all_identity] = identity_coefficient
identity = PauliString.from_sparse((), self._nq)
identity_coefficient = self._data.get(identity, 0.0) + shift
self._data[identity] = identity_coefficient

# -------------------------------------------------------------------------------------------------

Expand Down Expand Up @@ -142,6 +143,8 @@ def get_all_pauli_strings(self, return_as="tuples"):
# -- If return_as == "strings": The Pauli string is encoded as a character string, where
# each character is a Pauli matrix, explicitly including identity entries. For example,
# assuming 6 qubits, "XIIZII".
# -- If return_as == "objects": Keys are immutable PauliString instances that can provide
Comment thread
AlbertLee125 marked this conversation as resolved.
# either representation on demand.
# TODO: I'd prefer that the flag identify not the data structure but the concept: dense vs
# sparse, rather than strings vs tuples.
if return_as == "tuples":
Expand All @@ -158,16 +161,18 @@ def get_all_pauli_strings(self, return_as="tuples"):
raise TypeError(
f"Unable to generate Pauli strings from object of type \"{type(self._H)}\".")
elif return_as == "strings":
as_objects = self.get_all_pauli_strings(return_as="objects")
return {pauli.to_dense() : coef for pauli, coef in as_objects.items()}
elif return_as == "objects":
if isinstance(self._H, LinearCombinationOfPauliStrings):
return self._H.get_dense_pauli_strings()
else:
as_tuples = self.get_all_pauli_strings(return_as="tuples")
Nq = self.num_qubits()
return {sparse_to_dense_pauli(pauli, Nq) : coef
return self._H.get_pauli_strings()
as_tuples = self.get_all_pauli_strings(return_as="tuples")
Nq = self.num_qubits()
return {PauliString.from_sparse(pauli, Nq) : coef
for pauli, coef in as_tuples.items()}
else:
raise ValueError(" ".join([
"The value of return_as must be \"tuples\" or \"strings\".",
"The value of return_as must be \"tuples\", \"strings\", or \"objects\".",
f"Unable to return result as \"{return_as}\".",
]))
def get_grouped_terms(self):
Expand Down
48 changes: 37 additions & 11 deletions analysis/tests/test_pauli_hamiltonian.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
load_pauli,
sparse_to_dense_pauli,
)
from qhat.common.pauli_string import PauliString


# ==================================================================================
Expand Down Expand Up @@ -79,7 +80,7 @@ def test_dense_to_sparse_multi_op(self):

def test_dense_to_sparse_invalid_char(self):
"""Test that invalid characters raise ValueError."""
with pytest.raises(ValueError, match="Invalid character"):
with pytest.raises(ValueError, match="Invalid Pauli operator 'Q'"):
dense_to_sparse_pauli("XQZI")

def test_round_trip_conversion(self):
Expand Down Expand Up @@ -108,16 +109,18 @@ def test_dense_format_init(self):
data = {"IIII": 1.0, "XIII": 0.5}
lcps = LinearCombinationOfPauliStrings(num_qubits=4, dense=data)
assert lcps.num_qubits() == 4
assert lcps._format == "dense"
assert len(lcps._data) == 2
assert all(isinstance(pauli, PauliString) for pauli in lcps._data)
assert lcps._data[PauliString.from_dense("XIII")] == 0.5

def test_sparse_format_init(self):
"""Test initialization with sparse format."""
data = {tuple(): 1.0, ((0, 'X'),): 0.5}
lcps = LinearCombinationOfPauliStrings(num_qubits=4, sparse=data)
assert lcps.num_qubits() == 4
assert lcps._format == "sparse"
assert len(lcps._data) == 2
assert all(isinstance(pauli, PauliString) for pauli in lcps._data)
assert lcps._data[PauliString.from_dense("XIII")] == 0.5

def test_no_data_raises_error(self):
"""Test that initialization without data raises ValueError."""
Expand Down Expand Up @@ -169,29 +172,42 @@ def test_get_dense_from_sparse(self):
expected = {"IIII": 1.0, "XIII": 0.5, "IXZI": 0.3}
assert dense_result == expected

def test_equivalent_sparse_keys_are_combined(self):
"""Equivalent sparse orderings represent one Pauli string."""
sparse_data = {
((0, 'X'), (1, 'Y'), (2, 'Z')): 0.25,
((2, 'Z'), (1, 'Y'), (0, 'X')): -0.15,
}
lcps = LinearCombinationOfPauliStrings(num_qubits=4, sparse=sparse_data)

assert lcps.get_dense_pauli_strings() == {"XYZI": pytest.approx(0.10)}

def test_energy_shift_dense(self):
"""Test energy shift with dense format."""
data = {"IIII": 1.0, "XIII": 0.5}
lcps = LinearCombinationOfPauliStrings(num_qubits=4, dense=data)
lcps.energy_shift(2.5)
assert lcps._data["IIII"] == 3.5
assert lcps._data["XIII"] == 0.5
result = lcps.get_dense_pauli_strings()
assert result["IIII"] == 3.5
assert result["XIII"] == 0.5

def test_energy_shift_sparse(self):
"""Test energy shift with sparse format."""
data = {tuple(): 1.0, ((0, 'X'),): 0.5}
lcps = LinearCombinationOfPauliStrings(num_qubits=4, sparse=data)
lcps.energy_shift(2.5)
assert lcps._data[tuple()] == 3.5
assert lcps._data[((0, 'X'),)] == 0.5
result = lcps.get_sparse_pauli_strings()
assert result[tuple()] == 3.5
assert result[((0, 'X'),)] == 0.5

def test_energy_shift_adds_identity(self):
"""Test that energy shift adds identity term if not present."""
data = {"XIII": 0.5}
lcps = LinearCombinationOfPauliStrings(num_qubits=4, dense=data)
lcps.energy_shift(2.5)
assert lcps._data["IIII"] == 2.5
assert lcps._data["XIII"] == 0.5
result = lcps.get_dense_pauli_strings()
assert result["IIII"] == 2.5
assert result["XIII"] == 0.5


# ==================================================================================
Expand Down Expand Up @@ -506,6 +522,16 @@ def test_get_all_pauli_strings_strings(self, sample_hamiltonian):
assert pauli_dict["IIII"] == 2.5
assert pauli_dict["XIII"] == 0.5

def test_get_all_pauli_strings_objects(self, sample_hamiltonian):
"""Test getting representation-independent PauliString keys."""
H, _, _ = sample_hamiltonian
pauli_dict = H.get_all_pauli_strings(return_as="objects")

assert len(pauli_dict) == 10
assert all(isinstance(pauli, PauliString) for pauli in pauli_dict)
assert pauli_dict[PauliString.from_dense("IIII")] == 2.5
assert pauli_dict[PauliString.from_sparse(((0, 'X'),), 4)] == 0.5

def test_energy_bounds(self, sample_hamiltonian):
"""Test energy bounds computation."""
H, config_gen, config_ham = sample_hamiltonian
Expand Down Expand Up @@ -677,10 +703,10 @@ def test_load_json_test_file(self, config):
# Verify basic properties
assert H.num_qubits() == 4
pauli_dict = H.get_all_pauli_strings(return_as="tuples")
assert len(pauli_dict) == 10
assert len(pauli_dict) == 9

# Verify specific terms
assert pauli_dict[tuple()] == 2.5
assert pauli_dict[((0, 'X'),)] == 0.5
assert pauli_dict[((0, 'X'), (1, 'X'))] == 1.2
assert pauli_dict[((0, 'X'), (1, 'Y'), (2, 'Z'))] == 0.25
assert pauli_dict[((0, 'X'), (1, 'Y'), (2, 'Z'))] == pytest.approx(0.10)
19 changes: 19 additions & 0 deletions common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ be used directly by users.

## Pauli String and Time Evolution

### Representation-Independent Pauli Strings

`pauli_string.py` provides an immutable, hashable `PauliString` value type. Construct a value from
either a dense string or a sparse mapping, then request the representation needed by the caller:

```python
from qhat.common.pauli_string import PauliString

dense = PauliString.from_dense("IIXYIZ")
sparse = PauliString.from_sparse({2: "X", 3: "Y", 5: "Z"}, num_qubits=6)

assert dense == sparse
assert dense.to_dense() == "IIXYIZ"
assert dense.to_sparse() == ((2, "X"), (3, "Y"), (5, "Z"))
assert dense.to_sparse_dict() == {2: "X", 3: "Y", 5: "Z"}
```

Coefficients remain separate so `PauliString` values can be used as Hamiltonian dictionary keys.

### Core Evolution Modules

- **`pauli_string_evolution.py`**: Implements time evolution under a single Pauli string Hamiltonian:
Expand Down
Loading