Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change MutablePauliString from private to public #3299

Merged
merged 10 commits into from
Sep 15, 2020
Merged

Conversation

Strilanc
Copy link
Contributor

@Strilanc Strilanc commented Sep 3, 2020

There are several times I've wanted a mutable version of pauli string. In fact, I've previously written an external variant and have seen another person do the same. So I figured it made sense to take the internal version we have and clean it up enough to expose at the top level.

  • For performance potential, the internal dictionary of mutable pauli string maps to int instead of to gate instances.
  • Fix pycharm warnings in pauli_string_test.py
  • Bump qiskit version [encountered load time error in pyton 3.8]
  • Add frozen/mutable_copy methods to cirq.PauliString
  • Add after/before methods to cirq.PauliString
  • Refactor MutablePauliString to construct from any PAULI_STRING_LIKE

- Fix pycharm warnings in pauli_string_test.py
- Bump qiskit version [encountered load time error in pyton 3.8]
- Add frozen/mutable_copy methods to cirq.PauliString
- Add after/before methods to cirq.PauliString
- Refactor MutablePauliString to construct from any PAULI_STRING_LIKE
@Strilanc Strilanc requested review from balopat and viathor September 3, 2020 23:53
@googlebot googlebot added the cla: yes Makes googlebot stop complaining. label Sep 3, 2020
@viathor
Copy link
Collaborator

viathor commented Sep 4, 2020

Lint, tests and coverage need fixing.

cirq/ops/pauli_string.py Show resolved Hide resolved
def __init__(self,
*contents: 'cirq.PAULI_STRING_LIKE',
coefficient: Union[int, float, complex] = 1,
pauli_int_dict: Optional[Dict['cirq.Qid', int]] = None):
Copy link
Collaborator

@viathor viathor Sep 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's reasonable for the user to expect to be able to pass a map from qubit objects to gate objects (as e.g. here), but here suddenly they must use ints instead of Paulis. This is made worse by the fact that the mapping from Paulis to ints is hidden in a private constant (and depending on how you think about this, the mapping isn't necessarily obvious or unique, e.g. some users might expect a 2-bit binary number where one bit maps to X and the other to Z). Having qubit-to-int map hidden inside a class is fine, but here we're making it part of the API.

Also, this API is internally inconsistent: we use ints instead of gate objects, but not instead of qubit objects.

Have you checked that using ints rather than gate instances improves performance sufficiently to justify the complications?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't checked the performance, but during multiplication it replaces quite a lot of if-else-if-else-if with a ^ b.

I actually do intend to replace the Qid type with an Any type at a later point, but avoided doing that in this PR because it will also affect PauliString pretty significantly.

This parameter isn't so much for users' convenience as it is an internal shortcut to avoid all conversions and double-checks. That's why its type is so specific. I could prefix it with _ to make it especially clear that it's more of an implementation detail, but for a class driven by performance like this one I think these choices are really part of the API. The dictionary is exposed as a field after all.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about using the shortcut internally while requiring gate objects in the API? I realize this incurs the cost of isinstance, but perhaps that's acceptable? Alternatively, we could remove that cost by making Pauli._index public, i.e. MutablePauliString.__init__ would accept gates, but then it would convert to ints internally by calling gate.pauli_index or somesuch (failure on non-Pauli gates being a documented and expected behavior).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A large part of the reason that this class exists is because of performance differential. We've had issues in the past w.r.t. equating and hashing custom objects being slow, so I wanted to avoid requiring them.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM

f"{type(contents)}, {repr(contents)}")
return NotImplemented

self.coefficient *= 1j**phase_log_i
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line and phase_log_i = 0 above (l.1221) should go into the first case of the "switch" above.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

self.coefficient *= other.coefficient
for qubit, pauli_gate_like in other.items():
pauli_int = _pauli_like_to_pauli_int(qubit, pauli_gate_like)
phase_log_i += self._imul_atom_helper(qubit, pauli_int, sign)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accumulating phase in the exponent and then raising i to the resulting total strikes me as unnecessary complication since we can just accumulate phase directly:

self.coefficient *= self._imul_atom_helper(qubit, pauli_int, phase)

This has the following advantages:

  1. Your intermediate values are easier to interpret (phase vs its logarithm).
  2. It allows you to eliminate a variable.
  3. It's immune to numerical error:
In [1]: 1j**104
Out[1]: (1+7.842691359635767e-15j)

In [2]: 1j**104 == 1.0
Out[2]: False

In [3]: p = 1.0

In [4]: for i in range(1000): 
   ...:     if i % 4 == 0: 
   ...:         assert p == 1.0 
   ...:     p *= 1j 
   ...:

Note that this requires two changes to _imul_atom_helper: replace sign with phase and change return values -1, 0, +1 to -i, 1.0, i respectively.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to keep the amount of math involving complex numbers minimized. The code looks the way it does because that's how it's written in DensePauliString which uses this form because it can be done with a handful of numpy vector operations.

Fixed the numeric precision issue by switching to self.coefficient *= 1j ** (phase_log_i & 3).

Copy link
Collaborator

@viathor viathor Sep 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why though? I mean: this is a quantum computing library ;-) If there is any place for complex math anywhere in the world, then this is it! Also, in python it's built-in, so there is really no reason to avoid it.

Look, this does simplify the code. Compare to the suggested alternative:

for qubit, pauli_gate_like in other.items():
    pauli_int = _pauli_like_to_pauli_int(qubit, pauli_gate_like)
    self.coefficient *= self._imul_atom_helper(qubit, pauli_int, phase)

The current code is made more complicated by the presence of exponentiation, bit manipulation, hardcoded constant and unnecessary variable. And on top of that its intermediate values aren't as easy to interpret.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, but I really do prefer this version.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding & 3 in the exponent to patch up the numerical issue leads to confusing code. One must consider subtleties of numerical exponentiation to appreciate why the & 3 is even here. And since it appears unnecessary it's easy to imagine that it may be removed in future, inadvertently re-introducing numerical error.

Instead of patching it up, we should just remove the unnecessary complexity - the exponentiation, the bitmasking and the temporary variable phase_log_i - and instead we should have _imul_atom_helper return the phase correction to multiply directly into self.coefficient (rather than having it return one of the logarithms).

It leads to simpler, easily understandable code, doesn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a test that will fail if the & 3 is removed.

I disagree that it's less clear. That being said, I am extremely comfortable with bit twiddling so it feels quite natural to me.

Adding ints is faster than multiplying complex numbers. Profiling:

    import cirq
    import time
    r = cirq.MutablePauliString()
    r2 = cirq.MutablePauliString()
    for i in range(1000):
        r[cirq.LineQubit(i)] = cirq.X
        r2[cirq.LineQubit(i)] = cirq.Y
    t0 = time.monotonic()
    for _ in range(1000):
        r *= r2
    t1 = time.monotonic()
    print(t1 - t0)

Using ints and bitmasks:

1.9

Using complex multiplication (factors made using 1j**sign):

2.1

Using complex multiplication (factors made using lookup from global R=[1,1j,-1,-1j] using R[sign]):

2.1

(There's some variation, but the ints are consistently lower than the complex.)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a minor performance improvement for what (in my mind) is unnecessarily complicated code, but fair enough - now at least I see one minor advantage to working in the logs.

cirq/ops/pauli_string.py Show resolved Hide resolved
return self._imul_helper_checkpoint(other, +1)

def __mul__(self, other: 'cirq.PAULI_STRING_LIKE') -> 'cirq.PauliString':
"""Multiplies two pauli-stringl-ikes together.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: misplaced hyphen

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

gate = op.gate

if isinstance(gate, clifford_gate.SingleQubitCliffordGate):
out = gate.transform(cast(cirq.Pauli, _INT_TO_PAULI[ps[0]]))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI (no action needed): Makes me think we should have a type that includes both identity and the Paulis.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed.

@Strilanc
Copy link
Contributor Author

Oh man I've spent at least a few months thinking that XY = iZ instead of -iZ, and these unit tests failing just fixed that.

Copy link
Collaborator

@viathor viathor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, if you've been thinking that XY=iZ then you've been right all along! Left-multiplication by X flips the rows, and Y with flipped rows is iZ.

cirq/ops/pauli_string.py Show resolved Hide resolved
def __init__(self,
*contents: 'cirq.PAULI_STRING_LIKE',
coefficient: Union[int, float, complex] = 1,
pauli_int_dict: Optional[Dict['cirq.Qid', int]] = None):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about using the shortcut internally while requiring gate objects in the API? I realize this incurs the cost of isinstance, but perhaps that's acceptable? Alternatively, we could remove that cost by making Pauli._index public, i.e. MutablePauliString.__init__ would accept gates, but then it would convert to ints internally by calling gate.pauli_index or somesuch (failure on non-Pauli gates being a documented and expected behavior).

cirq/ops/pauli_string.py Show resolved Hide resolved
self.coefficient *= other.coefficient
for qubit, pauli_gate_like in other.items():
pauli_int = _pauli_like_to_pauli_int(qubit, pauli_gate_like)
phase_log_i += self._imul_atom_helper(qubit, pauli_int, sign)
Copy link
Collaborator

@viathor viathor Sep 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why though? I mean: this is a quantum computing library ;-) If there is any place for complex math anywhere in the world, then this is it! Also, in python it's built-in, so there is really no reason to avoid it.

Look, this does simplify the code. Compare to the suggested alternative:

for qubit, pauli_gate_like in other.items():
    pauli_int = _pauli_like_to_pauli_int(qubit, pauli_gate_like)
    self.coefficient *= self._imul_atom_helper(qubit, pauli_int, phase)

The current code is made more complicated by the presence of exponentiation, bit manipulation, hardcoded constant and unnecessary variable. And on top of that its intermediate values aren't as easy to interpret.

self.coefficient *= other.coefficient
for qubit, pauli_gate_like in other.items():
pauli_int = _pauli_like_to_pauli_int(qubit, pauli_gate_like)
phase_log_i += self._imul_atom_helper(qubit, pauli_int, sign)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding & 3 in the exponent to patch up the numerical issue leads to confusing code. One must consider subtleties of numerical exponentiation to appreciate why the & 3 is even here. And since it appears unnecessary it's easy to imagine that it may be removed in future, inadvertently re-introducing numerical error.

Instead of patching it up, we should just remove the unnecessary complexity - the exponentiation, the bitmasking and the temporary variable phase_log_i - and instead we should have _imul_atom_helper return the phase correction to multiply directly into self.coefficient (rather than having it return one of the logarithms).

It leads to simpler, easily understandable code, doesn't it?

def __init__(self,
*contents: 'cirq.PAULI_STRING_LIKE',
coefficient: Union[int, float, complex] = 1,
pauli_int_dict: Optional[Dict['cirq.Qid', int]] = None):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM

self.coefficient *= other.coefficient
for qubit, pauli_gate_like in other.items():
pauli_int = _pauli_like_to_pauli_int(qubit, pauli_gate_like)
phase_log_i += self._imul_atom_helper(qubit, pauli_int, sign)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a minor performance improvement for what (in my mind) is unnecessarily complicated code, but fair enough - now at least I see one minor advantage to working in the logs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
cla: yes Makes googlebot stop complaining.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants