Skip to content

Changes to definitions of named gauge optimization suites, resolve (unnecessary) warnings, robustify report generation #622

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

Merged
merged 9 commits into from
Aug 1, 2025
Merged
5 changes: 1 addition & 4 deletions pygsti/optimize/optimize.py
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The branching was unnecessary, since jac=None is the default value for jac in both this function and the scipy function we're calling.

Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,7 @@ def _basin_callback(x, f, accept):
elif method == "L-BFGS-B": opts['gtol'] = opts['ftol'] = tol # gradient norm and fractional y-tolerance
elif method == "Nelder-Mead": opts['maxfev'] = maxfev # max fn evals (note: ftol and xtol can also be set)

if method in ("BFGS", "CG", "Newton-CG", "L-BFGS-B", "TNC", "SLSQP", "dogleg", "trust-ncg"): # use jacobian
solution = _spo.minimize(fn, x0, options=opts, method=method, tol=tol, callback=callback, jac=jac)
else:
solution = _spo.minimize(fn, x0, options=opts, method=method, tol=tol, callback=callback)
solution = _spo.minimize(fn, x0, options=opts, method=method, tol=tol, callback=callback, jac=jac)

return solution

Expand Down
181 changes: 96 additions & 85 deletions pygsti/protocols/gst.py
Copy link
Contributor Author

@rileyjmurray rileyjmurray Jul 31, 2025

Choose a reason for hiding this comment

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

The overwhelming majority of changes to this file are in _update_gaugeopt_dict_from_suitename function of the GSTGaugeOptSuite class. Among these changes, only two have operational consequences:

  1. I changed it from an instance method to a static method, since "self" was never referenced.
  2. I changed a key-value pair in a dict literal that was conditionally assigned to a variable called "convert_to". The key-value pair used to be ("to_type", "full TP"), while it's now ("to_type", "full"). This has no effect on the gauge group that gets used for gauge optimization.

All other changes to _update_gaugeopt_dict_from_suitename amount to readability. Here are notes I made while convincing myself of this

Details for the morbidly curious

I'll start with notes confined entirely to _update_gaugeopt_dict_from_suitename.

If we enter the branch where convert_to gets defined at all, then we effectively return control to the calling function without leaving the indentation level at which convert_to is defined (i.e., there aren't subsequent branches after the if on line 990 that would use convert_to as defined on 995).

Observations about gg := model.default_gauge_group

  • If gg is None AND "noconversion" is not in suite_name, then line 996 will raise an attribute error when trying to read None.name. Therefore, all valid codepaths have either "noconversion" in suite_name OR gg is not None. However, if "noconversion" is in suite_name then convert_to is just set to None.
  • If gg is None then the isinstance check at line 998 will fail, and the "is not None" check at line 1006 obviously fails, and so both the if and elif are skipped. Since there is no else clause, that means if gg is None then the function will return without actually modifying gaugeopt_suite_dict.

Summary of possibilities:

  1. gg is None. In this case, the function returns with no sideeffects visible from the calling function.
  2. gg is not None, but "noconversion" is in suite_name. In this case, the value of the dict literal that I'm modifying is irrelevant, because the dict literal is never assigned to convert_to.
  3. gg is not None, and "noconversion" is NOT in suite_name. In this case, the isinstance check at line 998 fails but the not-None check at line 1006 succeeds. This means the function basically reduces to lines 1009 to 1062. The convert_to variable occurs exactly twice in that portion of code. Both occurrences are in dicts with explicit gauge groups.

The dicts that get constructed in _update_gaugeopt_dict_from_suitename get passed as kwargs in call(s) to gaugeopt_to_target. So, understanding the consequences of my changes to _update_gaugeopt_dict_from_suitename amounts to understanding how gaugeopt_to_target behaves when provided with kwargs of the kind constructed in lines 1009 -- 1062 of gst.py.

When the convert_model_to kwarg is None, the change I made to the convert_to dict in _update_gaugeopt_dict_from_suitename is obviously irrelevant.

When the convert_model_to kwarg is a dict, gaugeopt_to_target ends up passing the key-value pairs as kwargs in a call to model.convert_members_inplace. If convert_model_to has a key-value pair ("set_default_gaugegroup", True), this call to convert_members_inplace sets the model's default gauge group. HOWEVER, if we assume that gaugeopt_to_target's "gauge_group" kwarg is not None, which we CAN assume in the context of lines 1009--1062, then of gst.py, then the model's default_gauge_group member never ends up being accessed. (Verifying this requires stepping into the custom_gaugeopt function that gaugeopt_to_target ends up calling, but that verification is easy.)

Now let's look back at _update_gaugeopt_dict_from_suitename.

From the behavior outlined above, we see that a change to the to_type key of the convert_to dict has no effect on the gauge group used in gauge optimization. However, I've opted to also change convert_to's "set_default_gaugegroup" key to False, since that makes our intent clearer.

Copy link
Contributor

Choose a reason for hiding this comment

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

Awesome work, thank you for tracking this down and for the detailed summary!

Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,14 @@ class GSTGaugeOptSuite(_NicelySerializable):
given by the target model, which are used as the default when
`gaugeopt_target` is None.
"""

STANDARD_SUITENAMES = ("stdgaugeopt", "stdgaugeopt-unreliable2Q", "stdgaugeopt-tt", "stdgaugeopt-safe",
"stdgaugeopt-noconversion", "stdgaugeopt-noconversion-safe")

SPECIAL_SUITENAMES = ("varySpam", "varySpamWt", "varyValidSpamWt", "toggleValidSpam",
"varySpam-unreliable2Q", "varySpamWt-unreliable2Q",
"varyValidSpamWt-unreliable2Q", "toggleValidSpam-unreliable2Q")

@classmethod
def cast(cls, obj):
if obj is None:
Expand All @@ -872,14 +880,13 @@ def cast(cls, obj):

def __init__(self, gaugeopt_suite_names=None, gaugeopt_argument_dicts=None, gaugeopt_target=None):
super().__init__()
if gaugeopt_suite_names is not None:
if gaugeopt_suite_names == 'none':
self.gaugeopt_suite_names = None
else:
self.gaugeopt_suite_names = (gaugeopt_suite_names,) \
if isinstance(gaugeopt_suite_names, str) else tuple(gaugeopt_suite_names)
else:
if gaugeopt_suite_names is None or gaugeopt_suite_names == 'none':
self.gaugeopt_suite_names = None
elif isinstance(gaugeopt_suite_names, str):
self.gaugeopt_suite_names = (gaugeopt_suite_names,)
else:
self.gaugeopt_suite_names = tuple(gaugeopt_suite_names)


if gaugeopt_argument_dicts is not None:
self.gaugeopt_argument_dicts = gaugeopt_argument_dicts.copy()
Expand Down Expand Up @@ -985,92 +992,98 @@ def to_dictionary(self, model, unreliable_ops=(), verbosity=0):

return gaugeopt_suite_dict

def _update_gaugeopt_dict_from_suitename(self, gaugeopt_suite_dict, root_lbl, suite_name, model,
unreliable_ops, printer):
if suite_name in ("stdgaugeopt", "stdgaugeopt-unreliable2Q", "stdgaugeopt-tt", "stdgaugeopt-safe",
"stdgaugeopt-noconversion", "stdgaugeopt-noconversion-safe"):
@staticmethod
def _update_gaugeopt_dict_from_suitename(gaugeopt_suite_dict, root_lbl, suite_name, model, unreliable_ops, printer):

if suite_name in GSTGaugeOptSuite.STANDARD_SUITENAMES:

stages = [] # multi-stage gauge opt
gg = model.default_gauge_group
convert_to = {'to_type': "full TP", 'flatten_structure': True, 'set_default_gauge_group': True} \

if gg is None:
return

stages = [] # multi-stage gauge opt

from pygsti.models.gaugegroup import TrivialGaugeGroup, UnitaryGaugeGroup, \
SpamGaugeGroup, TPSpamGaugeGroup

convert_to = {'to_type': "full", 'flatten_structure': True, 'set_default_gauge_group': False} \
if ('noconversion' not in suite_name and gg.name not in ("Full", "TP")) else None

if isinstance(gg, _models.gaugegroup.TrivialGaugeGroup) and convert_to is None:
if isinstance(gg, TrivialGaugeGroup) and convert_to is None:
if suite_name == "stdgaugeopt-unreliable2Q" and model.dim == 16:
if any([gl in model.operations.keys() for gl in unreliable_ops]):
if any([gl in model.operations for gl in unreliable_ops]):
gaugeopt_suite_dict[root_lbl] = {'verbosity': printer}
else:
#just do a single-stage "trivial" gauge opts using default group
gaugeopt_suite_dict[root_lbl] = {'verbosity': printer}

elif gg is not None:
metric = 'frobeniustt' if suite_name == 'stdgaugeopt-tt' else 'frobenius'

#Stage 1: plain vanilla gauge opt to get into "right ballpark"
if gg.name in ("Full", "TP"):
stages.append(
{
'gates_metric': metric, 'spam_metric': metric,
'item_weights': {'gates': 1.0, 'spam': 1.0},
'verbosity': printer
})

#Stage 2: unitary gauge opt that tries to nail down gates (at
# expense of spam if needed)
stages.append(
{
'convert_model_to': convert_to,
'gates_metric': metric, 'spam_metric': metric,
'item_weights': {'gates': 1.0, 'spam': 0.0},
'gauge_group': _models.gaugegroup.UnitaryGaugeGroup(model.state_space,
model.basis, model.evotype),
'oob_check_interval': 1 if ('-safe' in suite_name) else 0,
'verbosity': printer
})

#Stage 3: spam gauge opt that fixes spam scaling at expense of
# non-unital parts of gates (but shouldn't affect these
# elements much since they should be small from Stage 2).
s3gg = _models.gaugegroup.SpamGaugeGroup if (gg.name == "Full") else \
_models.gaugegroup.TPSpamGaugeGroup
stages.append(
{
'convert_model_to': convert_to,
'gates_metric': metric, 'spam_metric': metric,
'item_weights': {'gates': 0.0, 'spam': 1.0},
'spam_penalty_factor': 1.0,
'gauge_group': s3gg(model.state_space, model.evotype),
'oob_check_interval': 1,
'verbosity': printer
})

if suite_name == "stdgaugeopt-unreliable2Q" and model.dim == 16:
if any([gl in model.operations.keys() for gl in unreliable_ops]):
stage2_item_weights = {'gates': 1, 'spam': 0.0}
for gl in unreliable_ops:
if gl in model.operations.keys(): stage2_item_weights[gl] = 0.01
stages_2qubit_unreliable = [stage.copy() for stage in stages] # ~deep copy of stages
istage2 = 1 if gg.name in ("Full", "TP") else 0
stages_2qubit_unreliable[istage2]['item_weights'] = stage2_item_weights
gaugeopt_suite_dict[root_lbl] = stages_2qubit_unreliable # add additional gauge opt
else:
_warnings.warn(("`unreliable2Q` was given as a gauge opt suite, but none of the"
" gate names in 'unreliable_ops', i.e., %s,"
" are present in the target model. Omitting 'single-2QUR' gauge opt.")
% (", ".join(unreliable_ops)))
return

metric = 'frobeniustt' if suite_name == 'stdgaugeopt-tt' else 'frobenius'
ss = model.state_space
et = model.evotype

# Stage 1: plain vanilla gauge opt to get into "right ballpark"
if gg.name in ("Full", "TP"):
stages.append({
'gates_metric': metric, 'spam_metric': metric,
'item_weights': {'gates': 1.0, 'spam': 1.0},
'verbosity': printer
})

# Stage 2: unitary gauge opt that tries to nail down gates (at
# expense of spam if needed)
s2gg = UnitaryGaugeGroup(ss, model.basis, et)
stages.append({
'convert_model_to': convert_to,
'gates_metric': metric, 'spam_metric': metric,
'item_weights': {'gates': 1.0, 'spam': 0.0},
'gauge_group': s2gg,
'oob_check_interval': 1 if ('-safe' in suite_name) else 0,
'verbosity': printer
})

# Stage 3: spam gauge opt that fixes spam scaling at expense of
# non-unital parts of gates (but shouldn't affect these
# elements much since they should be small from Stage 2).
s3gg = SpamGaugeGroup(ss, et) if (gg.name == "Full") else TPSpamGaugeGroup(ss, et)
stages.append({
'convert_model_to': convert_to,
'gates_metric': metric, 'spam_metric': metric,
'item_weights': {'gates': 0.0, 'spam': 1.0},
'spam_penalty_factor': 1.0,
'gauge_group': s3gg,
'oob_check_interval': 1,
'verbosity': printer
})

if suite_name == "stdgaugeopt-unreliable2Q" and model.dim == 16:
if any([gl in model.operations for gl in unreliable_ops]):
stage2_item_weights = {'gates': 1.0, 'spam': 0.0}
for gl in unreliable_ops:
if gl in model.operations:
stage2_item_weights[gl] = 0.01
stages_2qubit_unreliable = [stage.copy() for stage in stages] # ~deep copy of stages
istage2 = 1 if gg.name in ("Full", "TP") else 0
stages_2qubit_unreliable[istage2]['item_weights'] = stage2_item_weights
gaugeopt_suite_dict[root_lbl] = stages_2qubit_unreliable # add additional gauge opt
else:
gaugeopt_suite_dict[root_lbl] = stages # can be a list of stage dictionaries
_warnings.warn(("`unreliable2Q` was given as a gauge opt suite, but none of the"
" gate names in 'unreliable_ops', i.e., %s,"
" are present in the target model. Omitting 'single-2QUR' gauge opt.")
% (", ".join(unreliable_ops)))
else:
gaugeopt_suite_dict[root_lbl] = stages # can be a list of stage dictionaries

elif suite_name in ("varySpam", "varySpamWt", "varyValidSpamWt", "toggleValidSpam") or \
suite_name in ("varySpam-unreliable2Q", "varySpamWt-unreliable2Q",
"varyValidSpamWt-unreliable2Q", "toggleValidSpam-unreliable2Q"):
elif suite_name in GSTGaugeOptSuite.SPECIAL_SUITENAMES:

base_wts = {'gates': 1}
base_wts = {'gates': 1.0}
if suite_name.endswith("unreliable2Q") and model.dim == 16:
if any([gl in model.operations.keys() for gl in unreliable_ops]):
base = {'gates': 1}
if any([gl in model.operations for gl in unreliable_ops]):
base = {'gates': 1.0}
for gl in unreliable_ops:
if gl in model.operations.keys(): base[gl] = 0.01
if gl in model.operations:
base[gl] = 0.01
base_wts = base

if suite_name == "varySpam":
Expand All @@ -1097,9 +1110,6 @@ def _update_gaugeopt_dict_from_suitename(self, gaugeopt_suite_dict, root_lbl, su
'item_weights': item_weights,
'spam_penalty_factor': valid_spam, 'verbosity': printer}

elif suite_name == "unreliable2Q":
raise ValueError(("unreliable2Q is no longer a separate 'suite'. You should precede it with the suite"
" name, e.g. 'stdgaugeopt-unreliable2Q' or 'varySpam-unreliable2Q'"))
elif suite_name == 'none':
gaugeopt_suite_dict[root_lbl] = None
else:
Expand Down Expand Up @@ -2091,9 +2101,9 @@ def _add_gauge_opt(results, base_est_label, gaugeopt_suite, starting_model,
"""
printer = _baseobjs.VerbosityPrinter.create_printer(verbosity, comm)

#Get gauge optimization dictionary
gaugeopt_suite_dict = gaugeopt_suite.to_dictionary(starting_model,
unreliable_ops, printer - 1)
gaugeopt_suite_dict = gaugeopt_suite.to_dictionary(
starting_model, unreliable_ops, printer - 1
)

#Gauge optimize to list of gauge optimization parameters
for go_label, goparams in gaugeopt_suite_dict.items():
Expand Down Expand Up @@ -2505,6 +2515,7 @@ def _compute_1d_reference_values_and_name(estimate, badfit_options, gaugeopt_sui
spamdd[key] = 0.5 * _tools.optools.povm_diamonddist(gaugeopt_model, target_model, key)

dd[lbl]['SPAM'] = sum(spamdd.values())

return dd, 'diamond distance'
else:
raise ValueError("Invalid wildcard1d_reference value (%s) in bad-fit options!"
Expand Down
2 changes: 1 addition & 1 deletion pygsti/tools/basistools.py
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Style and "hygiene."

Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
import numpy as _np

from pygsti.baseobjs.basisconstructors import _basis_constructor_dict
# from ..baseobjs.basis import Basis, BuiltinBasis, DirectSumBasis
from pygsti.baseobjs import basis as _basis


@lru_cache(maxsize=1)
def basis_matrices(name_or_basis, dim, sparse=False):
"""
Expand Down
18 changes: 8 additions & 10 deletions pygsti/tools/optools.py
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changes accidentally left out of PR #615.

Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,6 @@ def psd_square_root(mat):
"""
_warnings.warn(message)
evals[evals < 0] = 0.0
tr = _np.sum(evals)
if abs(tr - 1) > __VECTOR_TOL__:
message = f"""
The PSD part of the input matrix is not trace-1 up to tolerance {__VECTOR_TOL__}.
Beware result!
"""
_warnings.warn(message)
sqrt_mat = U @ (_np.sqrt(evals).reshape((-1, 1)) * U.T.conj())
return sqrt_mat

Expand Down Expand Up @@ -1031,9 +1024,14 @@ def povm_diamonddist(model, target_model, povmlbl):
-------
float
"""
povm_mx = compute_povm_map(model, povmlbl)
target_povm_mx = compute_povm_map(target_model, povmlbl)
return diamonddist(povm_mx, target_povm_mx, target_model.basis)
try:
povm_mx = compute_povm_map(model, povmlbl)
target_povm_mx = compute_povm_map(target_model, povmlbl)
return diamonddist(povm_mx, target_povm_mx, target_model.basis)
except AssertionError as e:
assert '`dim` must be a perfect square' in str(e)
return _np.NaN


def instrument_infidelity(a, b, mx_basis):
"""
Expand Down
2 changes: 1 addition & 1 deletion test/unit/tools/test_optools.py
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This resolves a deprecation warning.

Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def test_unitary_to_pauligate(self):
# U_2Q is 4x4 unitary matrix operating on isolated two-qubit space (CX(pi) rotation)

op_2Q = ot.unitary_to_pauligate(U_2Q)
op_2Q_inv = ot.process_mx_to_unitary(bt.change_basis(op_2Q, 'pp', 'std'))
op_2Q_inv = ot.std_process_mx_to_unitary(bt.change_basis(op_2Q, 'pp', 'std'))
self.assertArraysAlmostEqual(U_2Q, op_2Q_inv)

def test_decompose_gate_matrix(self):
Expand Down