Skip to content

Commit 3944ca5

Browse files
authored
Merge pull request #480 from sandialabs/bugfix-reparameterize
Bugfix reparameterize
2 parents 91f4d79 + 9cb066c commit 3944ca5

File tree

7 files changed

+267
-69
lines changed

7 files changed

+267
-69
lines changed

pygsti/modelmembers/povms/__init__.py

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
#***************************************************************************************************
1212
import functools as _functools
1313
import itertools as _itertools
14-
14+
import warnings
1515
import numpy as _np
16+
import scipy.linalg as _spl
17+
import scipy.optimize as _spo
1618

1719
from .complementeffect import ComplementPOVMEffect
1820
from .composedeffect import ComposedPOVMEffect
@@ -34,6 +36,8 @@
3436
from pygsti.baseobjs import statespace as _statespace
3537
from pygsti.tools import basistools as _bt
3638
from pygsti.tools import optools as _ot
39+
from pygsti.tools import sum_of_negative_choi_eigenvalues_gate
40+
from pygsti.baseobjs import Basis
3741

3842
# Avoid circular import
3943
import pygsti.modelmembers as _mm
@@ -418,7 +422,7 @@ def povm_type_from_op_type(op_type):
418422
return povm_type_preferences
419423

420424

421-
def convert(povm, to_type, basis, ideal_povm=None, flatten_structure=False):
425+
def convert(povm, to_type, basis, ideal_povm=None, flatten_structure=False, cp_penalty=1e-7):
422426
"""
423427
TODO: update docstring
424428
Convert a POVM to a new type of parameterization.
@@ -450,13 +454,22 @@ def convert(povm, to_type, basis, ideal_povm=None, flatten_structure=False):
450454
are separately converted, leaving the original POVM's structure
451455
unchanged. When `True`, composed and embedded operations are "flattened"
452456
into a single POVM of the requested `to_type`.
457+
458+
cp_penalty : float, optional (default 1e-7)
459+
Converting SPAM operations to an error generator representation may
460+
introduce trivial gauge degrees of freedom. These gauge degrees of freedom
461+
are called trivial because they quite literally do not change the dense representation
462+
(i.e. Hilbert-Schmidt vectors) at all. Despite being trivial, error generators along
463+
this trivial gauge orbit may be non-CP, so this cptp penalty is used to favor channels
464+
within this gauge orbit which are CPTP.
453465
454466
Returns
455467
-------
456468
POVM
457469
The converted POVM vector, usually a distinct
458470
object from the object passed as input.
459471
"""
472+
460473
to_types = to_type if isinstance(to_type, (tuple, list)) else (to_type,) # HACK to support multiple to_type values
461474
error_msgs = {}
462475

@@ -487,6 +500,7 @@ def convert(povm, to_type, basis, ideal_povm=None, flatten_structure=False):
487500
from ..operations import LindbladParameterization as _LindbladParameterization
488501
lndtype = _LindbladParameterization.cast(to_type)
489502

503+
490504
#Construct a static "base" POVM
491505
if isinstance(povm, ComputationalBasisPOVM): # special easy case
492506
base_povm = ComputationalBasisPOVM(povm.state_space.num_qubits, povm.evotype) # just copy it?
@@ -498,14 +512,105 @@ def convert(povm, to_type, basis, ideal_povm=None, flatten_structure=False):
498512
for lbl, vec in povm.items()]
499513
else:
500514
raise RuntimeError('Evotype must be compatible with Hilbert ops to use pure effects')
501-
except Exception: # try static mixed states next:
502-
base_items = [(lbl, convert_effect(vec, 'static', basis, idl.get(lbl, None), flatten_structure))
503-
for lbl, vec in povm.items()]
515+
except RuntimeError: # try static mixed states next:
516+
#if idl.get(lbl,None) is not None:
517+
518+
base_items = []
519+
for lbl, vec in povm.items():
520+
ideal_effect = idl.get(lbl,None)
521+
if ideal_effect is not None:
522+
base_items.append((lbl, convert_effect(ideal_effect, 'static', basis, ideal_effect, flatten_structure)))
523+
else:
524+
base_items.append((lbl, convert_effect(vec, 'static', basis, idl.get(lbl, None), flatten_structure)))
504525
base_povm = UnconstrainedPOVM(base_items, povm.evotype, povm.state_space)
505526

506527
proj_basis = 'PP' if povm.state_space.is_entirely_qubits else basis
507528
errorgen = _LindbladErrorgen.from_error_generator(povm.state_space.dim, lndtype, proj_basis,
508529
basis, truncate=True, evotype=povm.evotype)
530+
531+
#Collect all ideal effects
532+
base_dense_effects = []
533+
for item in base_items:
534+
dense_effect = item[1].to_dense()
535+
base_dense_effects.append(dense_effect.reshape((1,len(dense_effect))))
536+
537+
dense_ideal_povm = _np.concatenate(base_dense_effects, axis=0)
538+
539+
#Collect all noisy effects
540+
dense_effects = []
541+
for effect in povm.values():
542+
dense_effect = effect.to_dense()
543+
dense_effects.append(dense_effect.reshape((1,len(dense_effect))))
544+
545+
dense_povm = _np.concatenate(dense_effects, axis=0)
546+
547+
#It is often the case that there are more error generators than physical degrees of freedom in the POVM
548+
#We define a function which finds linear comb. of errgens that span these degrees of freedom.
549+
#This has been called "the trivial gauge", and this function is meant to avoid it
550+
def calc_physical_subspace(dense_ideal_povm, epsilon = 1e-4):
551+
552+
degrees_of_freedom = (dense_ideal_povm.shape[0] - 1) * dense_ideal_povm.shape[1]
553+
errgen = _LindbladErrorgen.from_error_generator(povm.state_space.dim, parameterization=to_type)
554+
555+
if degrees_of_freedom > errgen.num_params:
556+
warnings.warn("POVM has more degrees of freedom than the available number of parameters, representation in this parameterization is not guaranteed")
557+
exp_errgen = _ExpErrorgenOp(errgen)
558+
559+
num_errgens = errgen.num_params
560+
#TODO: Maybe we can use the num of params instead of number of matrix entries, as some of them are linearly dependent.
561+
#i.e E0 completely determines E1 if those are the only two povm elements (E0 + E1 = Identity)
562+
num_entries = dense_ideal_povm.size
563+
564+
#Compute the jacobian with respect to the error generators. This will allow us to see which
565+
#error generators change the POVM entries
566+
J = _np.zeros((num_entries,num_errgens))
567+
new_vec = _np.zeros(num_errgens)
568+
for i in range(num_errgens):
569+
570+
new_vec[i] = epsilon
571+
exp_errgen.from_vector(new_vec)
572+
new_vec[i] = 0
573+
vectorized_povm = _np.zeros(num_entries)
574+
perturbed_povm = (dense_ideal_povm @ exp_errgen.to_dense() - dense_ideal_povm)/epsilon
575+
576+
vectorized_povm = perturbed_povm.flatten(order='F')
577+
578+
J[:,i] = vectorized_povm
579+
580+
_,S,Vt = _np.linalg.svd(J, full_matrices=False)
581+
582+
#Only return nontrivial singular vectors
583+
Vt = Vt[S > 1e-13, :].reshape((-1, Vt.shape[1]))
584+
return Vt
585+
586+
587+
phys_directions = calc_physical_subspace(dense_ideal_povm)
588+
589+
#We use optimization to find the best error generator representation
590+
#we only vary physical directions, not independent error generators
591+
def _objfn(v):
592+
593+
#For some reason adding the sum_of_negative_choi_eigenvalues_gate term
594+
#resulted in minimize() sometimes choosing NaN values for v. There are
595+
#two stack exchange issues showing this problem with no solution.
596+
if _np.isnan(v).any():
597+
v = _np.zeros(len(v))
598+
599+
L_vec = _np.zeros(len(phys_directions[0]))
600+
for coeff, phys_direction in zip(v,phys_directions):
601+
L_vec += coeff * phys_direction
602+
errorgen.from_vector(L_vec)
603+
proc_matrix = _spl.expm(errorgen.to_dense())
604+
605+
return _np.linalg.norm(dense_povm - dense_ideal_povm @ proc_matrix) + cp_penalty * sum_of_negative_choi_eigenvalues_gate(proc_matrix, basis)
606+
607+
soln = _spo.minimize(_objfn, _np.zeros(len(phys_directions), 'd'), method="Nelder-Mead", options={},
608+
tol=1e-13)
609+
if not soln.success and soln.fun > 1e-6: # not "or" because success is often not set correctly
610+
raise ValueError("Failed to find an errorgen such that <ideal|exp(errorgen) = <effect|")
611+
errgen_vec = _np.linalg.lstsq(phys_directions, soln.x)[0]
612+
errorgen.from_vector(errgen_vec)
613+
509614
EffectiveExpErrorgen = _IdentityPlusErrorgenOp if lndtype.meta == '1+' else _ExpErrorgenOp
510615
return ComposedPOVM(EffectiveExpErrorgen(errorgen), base_povm, mx_basis=basis)
511616

pygsti/modelmembers/states/__init__.py

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
from .tpstate import TPState
3030
from pygsti.baseobjs import statespace as _statespace
3131
from pygsti.tools import basistools as _bt
32+
from pygsti.baseobjs import Basis
3233
from pygsti.tools import optools as _ot
34+
from pygsti.tools import sum_of_negative_choi_eigenvalues_gate
3335

3436
# Avoid circular import
3537
import pygsti.modelmembers as _mm
@@ -185,7 +187,7 @@ def state_type_from_op_type(op_type):
185187
return state_type_preferences
186188

187189

188-
def convert(state, to_type, basis, ideal_state=None, flatten_structure=False):
190+
def convert(state, to_type, basis, ideal_state=None, flatten_structure=False, cp_penalty=1e-7):
189191
"""
190192
TODO: update docstring
191193
Convert SPAM vector to a new type of parameterization.
@@ -217,6 +219,10 @@ def convert(state, to_type, basis, ideal_state=None, flatten_structure=False):
217219
are separately converted, leaving the original state's structure
218220
unchanged. When `True`, composed and embedded operations are "flattened"
219221
into a single state of the requested `to_type`.
222+
223+
cp_penalty : float, optional
224+
CPTP penalty that gets factored into the optimization to find the resulting model
225+
when converting to an error-generator type.
220226
221227
Returns
222228
-------
@@ -234,7 +240,6 @@ def convert(state, to_type, basis, ideal_state=None, flatten_structure=False):
234240
'static unitary': StaticPureState,
235241
'static clifford': ComputationalBasisState}
236242
NoneType = type(None)
237-
238243
for to_type in to_types:
239244
try:
240245
if isinstance(state, destination_types.get(to_type, NoneType)):
@@ -256,20 +261,57 @@ def convert(state, to_type, basis, ideal_state=None, flatten_structure=False):
256261
errorgen = _LindbladErrorgen.from_error_generator(state.state_space.dim, to_type, proj_basis,
257262
basis, truncate=True, evotype=state.evotype)
258263
if st is not state and not _np.allclose(st.to_dense(), state.to_dense()):
259-
#Need to set errorgen so exp(errorgen)|st> == |state>
264+
260265
dense_st = st.to_dense()
261266
dense_state = state.to_dense()
262-
267+
num_qubits = st.state_space.num_qubits
268+
269+
270+
271+
#GLND for states suffers from "trivial gauge" freedom. This function identifies
272+
#the physical directions to avoid this gauge.
273+
def calc_physical_subspace(ideal_prep, epsilon = 1e-4):
274+
errgen = _LindbladErrorgen.from_error_generator(2**(2*num_qubits), parameterization=to_type)
275+
num_errgens = errgen.num_params
276+
277+
exp_errgen = _ExpErrorgenOp(errgen)
278+
279+
#Compute the jacobian with respect to the error generators. This will allow us to see which
280+
#error generators change the POVM entries
281+
J = _np.zeros((state.num_params, num_errgens))
282+
283+
for i in range(num_errgens):
284+
new_vec = _np.zeros(num_errgens)
285+
new_vec[i] = epsilon
286+
exp_errgen.from_vector(new_vec)
287+
J[:,i] = (exp_errgen.to_dense() @ ideal_prep - ideal_prep)[1:]/epsilon
288+
289+
_,S,Vt = _np.linalg.svd(J, full_matrices=False)
290+
291+
#Only return nontrivial singular vectors
292+
Vt = Vt[S > 1e-13, :].reshape((-1, Vt.shape[1]))
293+
return Vt
294+
295+
phys_directions = calc_physical_subspace(dense_state)
296+
297+
#We use optimization to find the best error generator representation
298+
#we only vary physical directions, not independent error generators
263299
def _objfn(v):
264-
errorgen.from_vector(v)
265-
return _np.linalg.norm(_spl.expm(errorgen.to_dense()) @ dense_st - dense_state)
266-
#def callback(x): print("callbk: ",_np.linalg.norm(x),_objfn(x)) # REMOVE
267-
soln = _spo.minimize(_objfn, _np.zeros(errorgen.num_params, 'd'), method="CG", options={},
268-
tol=1e-8) # , callback=callback)
269-
#print("DEBUG: opt done: ",soln.success, soln.fun, soln.x) # REMOVE
300+
L_vec = _np.zeros(len(phys_directions[0]))
301+
for coeff, phys_direction in zip(v,phys_directions):
302+
L_vec += coeff * phys_direction
303+
errorgen.from_vector(L_vec)
304+
proc_matrix = _spl.expm(errorgen.to_dense())
305+
return _np.linalg.norm(proc_matrix @ dense_st - dense_state) + cp_penalty * sum_of_negative_choi_eigenvalues_gate(proc_matrix, basis)
306+
307+
soln = _spo.minimize(_objfn, _np.zeros(len(phys_directions), 'd'), method="Nelder-Mead", options={},
308+
tol=1e-13)
309+
270310
if not soln.success and soln.fun > 1e-6: # not "or" because success is often not set correctly
271311
raise ValueError("Failed to find an errorgen such that exp(errorgen)|ideal> = |state>")
272-
errorgen.from_vector(soln.x)
312+
313+
errgen_vec = _np.linalg.lstsq(phys_directions, soln.x)[0]
314+
errorgen.from_vector(errgen_vec)
273315

274316
EffectiveExpErrorgen = _IdentityPlusErrorgenOp if lndtype.meta == '1+' else _ExpErrorgenOp
275317
return ComposedState(st, EffectiveExpErrorgen(errorgen))
@@ -283,7 +325,6 @@ def _objfn(v):
283325
return create_from_dmvec(vec, to_type, basis, state.evotype, state.state_space)
284326

285327
except ValueError as e:
286-
#_warnings.warn('Failed to convert state to type %s with error: %s' % (to_type, e))
287328
error_msgs[to_type] = str(e) # try next to_type
288329

289330
raise ValueError("Could not convert state to to type(s): %s\n%s" % (str(to_types), str(error_msgs)))

0 commit comments

Comments
 (0)