1# Copyright 2021 The Cirq Developers
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Estimation of fidelity associated with experimental circuit executions."""
15import dataclasses
16from abc import abstractmethod, ABC
17from typing import (
18    List,
19    Optional,
20    Sequence,
21    Tuple,
22    TYPE_CHECKING,
23    Dict,
24    Iterable,
25)
26
27import numpy as np
28import pandas as pd
29import scipy.optimize
30import scipy.stats
31import sympy
32from cirq import ops, protocols
33from cirq.circuits import Circuit
34from cirq.experiments.xeb_simulation import simulate_2q_xeb_circuits
35
36if TYPE_CHECKING:
37    import cirq
38    import multiprocessing
39
40THETA_SYMBOL, ZETA_SYMBOL, CHI_SYMBOL, GAMMA_SYMBOL, PHI_SYMBOL = sympy.symbols(
41    'theta zeta chi gamma phi'
42)
43
44# TODO(#3388) Add documentation for Raises.
45# pylint: disable=missing-raises-doc
46def benchmark_2q_xeb_fidelities(
47    sampled_df: pd.DataFrame,
48    circuits: Sequence['cirq.Circuit'],
49    cycle_depths: Optional[Sequence[int]] = None,
50    param_resolver: 'cirq.ParamResolverOrSimilarType' = None,
51    pool: Optional['multiprocessing.pool.Pool'] = None,
52) -> pd.DataFrame:
53    """Simulate and benchmark two-qubit XEB circuits.
54
55    This uses the estimator from
56    `cirq.experiments.fidelity_estimation.least_squares_xeb_fidelity_from_expectations`, but
57    adapted for use on pandas DataFrames for efficient vectorized operation.
58
59    Args:
60        sampled_df: The sampled results to benchmark. This is likely produced by a call to
61            `sample_2q_xeb_circuits`.
62        circuits: The library of circuits corresponding to the sampled results in `sampled_df`.
63        cycle_depths: The sequence of cycle depths to benchmark the circuits. If not provided,
64            we use the cycle depths found in `sampled_df`. All requested `cycle_depths` must be
65            present in `sampled_df`.
66        param_resolver: If circuits contain parameters, resolve according to this ParamResolver
67            prior to simulation
68        pool: If provided, execute the simulations in parallel.
69
70    Returns:
71        A DataFrame with columns 'cycle_depth' and 'fidelity'.
72    """
73    sampled_cycle_depths = (
74        sampled_df.index.get_level_values('cycle_depth').drop_duplicates().sort_values()
75    )
76    if cycle_depths is not None:
77        if len(cycle_depths) == 0:
78            raise ValueError("`cycle_depths` should be a non-empty array_like")
79        not_in_sampled = np.setdiff1d(cycle_depths, sampled_cycle_depths)
80        if len(not_in_sampled) > 0:
81            raise ValueError(
82                f"The `cycle_depths` provided include some not "
83                f"available in `sampled_df`: {not_in_sampled}"
84            )
85        sim_cycle_depths = cycle_depths
86    else:
87        sim_cycle_depths = sampled_cycle_depths
88    simulated_df = simulate_2q_xeb_circuits(
89        circuits=circuits, cycle_depths=sim_cycle_depths, param_resolver=param_resolver, pool=pool
90    )
91    # Join the `pure_probs` onto `sampled_df`. By using 'inner', we let
92    # the `cycle_depths` argument to this function control what cycle depths are benchmarked.
93    df = sampled_df.join(simulated_df, how='inner').reset_index()
94
95    D = 4  # two qubits
96    pure_probs = np.array(df['pure_probs'].to_list())
97    sampled_probs = np.array(df['sampled_probs'].to_list())
98    df['e_u'] = np.sum(pure_probs ** 2, axis=1)
99    df['u_u'] = np.sum(pure_probs, axis=1) / D
100    df['m_u'] = np.sum(pure_probs * sampled_probs, axis=1)
101    df['y'] = df['m_u'] - df['u_u']
102    df['x'] = df['e_u'] - df['u_u']
103    df['numerator'] = df['x'] * df['y']
104    df['denominator'] = df['x'] ** 2
105
106    def per_cycle_depth(df):
107        """This function is applied per cycle_depth in the following groupby aggregation."""
108        fid_lsq = df['numerator'].sum() / df['denominator'].sum()
109        ret = {'fidelity': fid_lsq}
110
111        def _try_keep(k):
112            """If all the values for a key `k` are the same in this group, we can keep it."""
113            if k not in df.columns:
114                return  # coverage: ignore
115            vals = df[k].unique()
116            if len(vals) == 1:
117                ret[k] = vals[0]
118            else:
119                # coverage: ignore
120                raise AssertionError(
121                    f"When computing per-cycle-depth fidelity, multiple "
122                    f"values for {k} were grouped together: {vals}"
123                )
124
125        _try_keep('pair')
126        return pd.Series(ret)
127
128    if 'pair_i' in df.columns:
129        groupby_names = ['layer_i', 'pair_i', 'cycle_depth']
130    else:
131        groupby_names = ['cycle_depth']
132
133    return df.groupby(groupby_names).apply(per_cycle_depth).reset_index()
134
135
136# pylint: enable=missing-raises-doc
137class XEBCharacterizationOptions(ABC):
138    @staticmethod
139    @abstractmethod
140    def should_parameterize(op: 'cirq.Operation') -> bool:
141        """Whether to replace `op` with a parameterized version."""
142
143    @abstractmethod
144    def get_parameterized_gate(self) -> 'cirq.Gate':
145        """The parameterized gate to use."""
146
147    @abstractmethod
148    def get_initial_simplex_and_names(
149        self, initial_simplex_step_size: float = 0.1
150    ) -> Tuple[np.ndarray, List[str]]:
151        """Return an initial Nelder-Mead simplex and the names for each parameter."""
152
153
154def phased_fsim_angles_from_gate(gate: 'cirq.Gate') -> Dict[str, float]:
155    """For a given gate, return a dictionary mapping '{angle}_default' to its noiseless value
156    for the five PhasedFSim angles."""
157    defaults = {
158        'theta_default': 0.0,
159        'zeta_default': 0.0,
160        'chi_default': 0.0,
161        'gamma_default': 0.0,
162        'phi_default': 0.0,
163    }
164    if gate == ops.SQRT_ISWAP:
165        defaults['theta_default'] = -np.pi / 4
166        return defaults
167    if gate == ops.SQRT_ISWAP_INV:
168        defaults['theta_default'] = np.pi / 4
169        return defaults
170    if isinstance(gate, ops.FSimGate):
171        defaults['theta_default'] = gate.theta
172        defaults['phi_default'] = gate.phi
173        return defaults
174    if isinstance(gate, ops.PhasedFSimGate):
175        return {
176            'theta_default': gate.theta,
177            'zeta_default': gate.zeta,
178            'chi_default': gate.chi,
179            'gamma_default': gate.gamma,
180            'phi_default': gate.phi,
181        }
182
183    raise ValueError(f"Unknown default angles for {gate}.")
184
185
186@dataclasses.dataclass(frozen=True)
187class XEBPhasedFSimCharacterizationOptions(XEBCharacterizationOptions):
188    """Options for calibrating a PhasedFSim-like gate using XEB.
189
190    You may want to use more specific subclasses like `SqrtISwapXEBOptions`
191    which have sensible defaults.
192
193    Attributes:
194        characterize_theta: Whether to characterize θ angle.
195        characterize_zeta: Whether to characterize ζ angle.
196        characterize_chi: Whether to characterize χ angle.
197        characterize_gamma: Whether to characterize γ angle.
198        characterize_phi: Whether to characterize φ angle.
199        theta_default: The initial or default value to assume for the θ angle.
200        zeta_default: The initial or default value to assume for the ζ angle.
201        chi_default: The initial or default value to assume for the χ angle.
202        gamma_default: The initial or default value to assume for the γ angle.
203        phi_default: The initial or default value to assume for the φ angle.
204    """
205
206    characterize_theta: bool = True
207    characterize_zeta: bool = True
208    characterize_chi: bool = True
209    characterize_gamma: bool = True
210    characterize_phi: bool = True
211
212    theta_default: Optional[float] = None
213    zeta_default: Optional[float] = None
214    chi_default: Optional[float] = None
215    gamma_default: Optional[float] = None
216    phi_default: Optional[float] = None
217
218    def _iter_angles(self) -> Iterable[Tuple[bool, Optional[float], 'sympy.Symbol']]:
219        yield from (
220            (self.characterize_theta, self.theta_default, THETA_SYMBOL),
221            (self.characterize_zeta, self.zeta_default, ZETA_SYMBOL),
222            (self.characterize_chi, self.chi_default, CHI_SYMBOL),
223            (self.characterize_gamma, self.gamma_default, GAMMA_SYMBOL),
224            (self.characterize_phi, self.phi_default, PHI_SYMBOL),
225        )
226
227    def _iter_angles_for_characterization(self) -> Iterable[Tuple[Optional[float], 'sympy.Symbol']]:
228        yield from (
229            (default, symbol)
230            for characterize, default, symbol in self._iter_angles()
231            if characterize
232        )
233
234    def get_initial_simplex_and_names(
235        self, initial_simplex_step_size: float = 0.1
236    ) -> Tuple[np.ndarray, List[str]]:
237        """Get an initial simplex and parameter names for the optimization implied by these options.
238
239        The initial simplex initiates the Nelder-Mead optimization parameter. We
240        use the standard simplex of `x0 + s*basis_vec` where x0 is given by the
241        `xxx_default` attributes, s is `initial_simplex_step_size` and `basis_vec`
242        is a one-hot encoded vector for each parameter for which the `parameterize_xxx`
243        attribute is True.
244
245        We also return a list of parameter names so the Cirq `param_resovler`
246        can be accurately constructed during optimization.
247        """
248        x0 = []
249        names = []
250
251        for default, symbol in self._iter_angles_for_characterization():
252            if default is None:
253                raise ValueError(f'{symbol.name}_default was not set.')
254            x0.append(default)
255            names.append(symbol.name)
256
257        x0 = np.asarray(x0)
258        n_param = len(x0)
259        initial_simplex = [x0]
260        for i in range(n_param):
261            basis_vec = np.eye(1, n_param, i)[0]
262            initial_simplex += [x0 + initial_simplex_step_size * basis_vec]
263        initial_simplex = np.asarray(initial_simplex)
264
265        return initial_simplex, names
266
267    def get_parameterized_gate(self):
268        theta = THETA_SYMBOL if self.characterize_theta else self.theta_default
269        zeta = ZETA_SYMBOL if self.characterize_zeta else self.zeta_default
270        chi = CHI_SYMBOL if self.characterize_chi else self.chi_default
271        gamma = GAMMA_SYMBOL if self.characterize_gamma else self.gamma_default
272        phi = PHI_SYMBOL if self.characterize_phi else self.phi_default
273        return ops.PhasedFSimGate(theta=theta, zeta=zeta, chi=chi, gamma=gamma, phi=phi)
274
275    @staticmethod
276    def should_parameterize(op: 'cirq.Operation') -> bool:
277        return isinstance(op.gate, (ops.PhasedFSimGate, ops.ISwapPowGate, ops.FSimGate))
278
279    def defaults_set(self) -> bool:
280        """Whether the default angles are set.
281
282        This only considers angles where characterize_{angle} is True. If all such angles have
283        {angle}_default set to a value, this returns True. If none of the defaults are set,
284        this returns False. If some defaults are set, we raise an exception.
285        """
286        defaults_set = [default is not None for _, default, _ in self._iter_angles()]
287        if any(defaults_set):
288            if all(defaults_set):
289                return True
290
291            problems = [
292                symbol.name for _, default, symbol in self._iter_angles() if default is None
293            ]
294            raise ValueError(f"Some angles are set, but values for {problems} are not.")
295        return False
296
297    def with_defaults_from_gate(
298        self, gate: 'cirq.Gate', gate_to_angles_func=phased_fsim_angles_from_gate
299    ):
300        """A new Options class with {angle}_defaults inferred from `gate`.
301
302        This keeps the same settings for the characterize_{angle} booleans, but will disregard
303        any current {angle}_default values.
304        """
305        return XEBPhasedFSimCharacterizationOptions(
306            characterize_theta=self.characterize_theta,
307            characterize_zeta=self.characterize_zeta,
308            characterize_chi=self.characterize_chi,
309            characterize_gamma=self.characterize_gamma,
310            characterize_phi=self.characterize_phi,
311            **gate_to_angles_func(gate),
312        )
313
314    def _json_dict_(self):
315        return protocols.dataclass_json_dict(self)
316
317
318def SqrtISwapXEBOptions(*args, **kwargs):
319    """Options for calibrating a sqrt(ISWAP) gate using XEB."""
320    return XEBPhasedFSimCharacterizationOptions(*args, **kwargs).with_defaults_from_gate(
321        ops.SQRT_ISWAP
322    )
323
324
325def parameterize_circuit(
326    circuit: 'cirq.Circuit',
327    options: XEBCharacterizationOptions,
328) -> 'cirq.Circuit':
329    """Parameterize PhasedFSim-like gates in a given circuit according to
330    `phased_fsim_options`.
331    """
332    gate = options.get_parameterized_gate()
333    return Circuit(
334        ops.Moment(
335            gate.on(*op.qubits) if options.should_parameterize(op) else op
336            for op in moment.operations
337        )
338        for moment in circuit.moments
339    )
340
341
342QPair_T = Tuple['cirq.Qid', 'cirq.Qid']
343
344
345@dataclasses.dataclass(frozen=True)
346class XEBCharacterizationResult:
347    """The result of `characterize_phased_fsim_parameters_with_xeb`.
348
349    Attributes:
350        optimization_results: A mapping from qubit pair to the raw scipy OptimizeResult object
351        final_params: A mapping from qubit pair to a dictionary of (angle_name, angle_value)
352            key-value pairs
353        fidelities_df: A dataframe containing per-cycle_depth and per-pair fidelities after
354            fitting the characterization.
355    """
356
357    optimization_results: Dict[QPair_T, scipy.optimize.OptimizeResult]
358    final_params: Dict[QPair_T, Dict[str, float]]
359    fidelities_df: pd.DataFrame
360
361
362def characterize_phased_fsim_parameters_with_xeb(
363    sampled_df: pd.DataFrame,
364    parameterized_circuits: List['cirq.Circuit'],
365    cycle_depths: Sequence[int],
366    options: XEBCharacterizationOptions,
367    initial_simplex_step_size: float = 0.1,
368    xatol: float = 1e-3,
369    fatol: float = 1e-3,
370    verbose: bool = True,
371    pool: Optional['multiprocessing.pool.Pool'] = None,
372) -> XEBCharacterizationResult:
373    """Run a classical optimization to fit phased fsim parameters to experimental data, and
374    thereby characterize PhasedFSim-like gates.
375
376    Args:
377        sampled_df: The DataFrame of sampled two-qubit probability distributions returned
378            from `sample_2q_xeb_circuits`.
379        parameterized_circuits: The circuits corresponding to those sampled in `sampled_df`,
380            but with some gates parameterized, likely by using `parameterize_circuit`.
381        cycle_depths: The depths at which circuits were truncated.
382        options: A set of options that controls the classical optimization loop
383            for characterizing the parameterized gates.
384        initial_simplex_step_size: Set the size of the initial simplex for Nelder-Mead.
385        xatol: The `xatol` argument for Nelder-Mead. This is the absolute error for convergence
386            in the parameters.
387        fatol: The `fatol` argument for Nelder-Mead. This is the absolute error for convergence
388            in the function evaluation.
389        verbose: Whether to print progress updates.
390        pool: An optional multiprocessing pool to execute circuit simulations in parallel.
391    """
392    (pair,) = sampled_df['pair'].unique()
393    initial_simplex, names = options.get_initial_simplex_and_names(
394        initial_simplex_step_size=initial_simplex_step_size
395    )
396    x0 = initial_simplex[0]
397
398    def _mean_infidelity(angles):
399        params = dict(zip(names, angles))
400        if verbose:
401            params_str = ''
402            for name, val in params.items():
403                params_str += f'{name:5s} = {val:7.3g} '
404            print(f"Simulating with {params_str}")
405        fids = benchmark_2q_xeb_fidelities(
406            sampled_df, parameterized_circuits, cycle_depths, param_resolver=params, pool=pool
407        )
408
409        loss = 1 - fids['fidelity'].mean()
410        if verbose:
411            print(f"Loss: {loss:7.3g}", flush=True)
412        return loss
413
414    optimization_result = scipy.optimize.minimize(
415        _mean_infidelity,
416        x0=x0,
417        options={
418            'initial_simplex': initial_simplex,
419            'xatol': xatol,
420            'fatol': fatol,
421        },
422        method='nelder-mead',
423    )
424
425    final_params = dict(zip(names, optimization_result.x))
426    fidelities_df = benchmark_2q_xeb_fidelities(
427        sampled_df, parameterized_circuits, cycle_depths, param_resolver=final_params
428    )
429    return XEBCharacterizationResult(
430        optimization_results={pair: optimization_result},
431        final_params={pair: final_params},
432        fidelities_df=fidelities_df,
433    )
434
435
436@dataclasses.dataclass(frozen=True)
437class _CharacterizePhasedFsimParametersWithXebClosure:
438    """A closure object to wrap `characterize_phased_fsim_parameters_with_xeb` for use in
439    multiprocessing."""
440
441    parameterized_circuits: List['cirq.Circuit']
442    cycle_depths: Sequence[int]
443    options: XEBCharacterizationOptions
444    initial_simplex_step_size: float = 0.1
445    xatol: float = 1e-3
446    fatol: float = 1e-3
447
448    def __call__(self, sampled_df) -> XEBCharacterizationResult:
449        return characterize_phased_fsim_parameters_with_xeb(
450            sampled_df=sampled_df,
451            parameterized_circuits=self.parameterized_circuits,
452            cycle_depths=self.cycle_depths,
453            options=self.options,
454            initial_simplex_step_size=self.initial_simplex_step_size,
455            xatol=self.xatol,
456            fatol=self.fatol,
457            verbose=False,
458            pool=None,
459        )
460
461
462def characterize_phased_fsim_parameters_with_xeb_by_pair(
463    sampled_df: pd.DataFrame,
464    parameterized_circuits: List['cirq.Circuit'],
465    cycle_depths: Sequence[int],
466    options: XEBCharacterizationOptions,
467    initial_simplex_step_size: float = 0.1,
468    xatol: float = 1e-3,
469    fatol: float = 1e-3,
470    pool: Optional['multiprocessing.pool.Pool'] = None,
471) -> XEBCharacterizationResult:
472    """Run a classical optimization to fit phased fsim parameters to experimental data, and
473    thereby characterize PhasedFSim-like gates grouped by pairs.
474
475    This is appropriate if you have run parallel XEB on multiple pairs of qubits.
476
477    The optimization is done per-pair. If you have the same pair in e.g. two different
478    layers the characterization optimization will lump the data together. This is in contrast
479    with the benchmarking functionality, which will always index on `(layer_i, pair_i, pair)`.
480
481    Args:
482        sampled_df: The DataFrame of sampled two-qubit probability distributions returned
483            from `sample_2q_xeb_circuits`.
484        parameterized_circuits: The circuits corresponding to those sampled in `sampled_df`,
485            but with some gates parameterized, likely by using `parameterize_circuit`.
486        cycle_depths: The depths at which circuits were truncated.
487        options: A set of options that controls the classical optimization loop
488            for characterizing the parameterized gates.
489        initial_simplex_step_size: Set the size of the initial simplex for Nelder-Mead.
490        xatol: The `xatol` argument for Nelder-Mead. This is the absolute error for convergence
491            in the parameters.
492        fatol: The `fatol` argument for Nelder-Mead. This is the absolute error for convergence
493            in the function evaluation.
494        pool: An optional multiprocessing pool to execute pair optimization in parallel. Each
495            optimization (and the simulations therein) runs serially.
496    """
497    pairs = sampled_df['pair'].unique()
498    closure = _CharacterizePhasedFsimParametersWithXebClosure(
499        parameterized_circuits=parameterized_circuits,
500        cycle_depths=cycle_depths,
501        options=options,
502        initial_simplex_step_size=initial_simplex_step_size,
503        xatol=xatol,
504        fatol=fatol,
505    )
506    subselected_dfs = [sampled_df[sampled_df['pair'] == pair] for pair in pairs]
507    if pool is not None:
508        results = pool.map(closure, subselected_dfs)
509    else:
510        results = [closure(df) for df in subselected_dfs]
511
512    optimization_results = {}
513    all_final_params = {}
514    fid_dfs = []
515    for result in results:
516        optimization_results.update(result.optimization_results)
517        all_final_params.update(result.final_params)
518        fid_dfs.append(result.fidelities_df)
519
520    return XEBCharacterizationResult(
521        optimization_results=optimization_results,
522        final_params=all_final_params,
523        fidelities_df=pd.concat(fid_dfs),
524    )
525
526
527def exponential_decay(cycle_depths: np.ndarray, a: float, layer_fid: float) -> np.ndarray:
528    """An exponential decay for fitting.
529
530    This computes `a * layer_fid**cycle_depths`
531
532    Args:
533        cycle_depths: The various depths at which fidelity was estimated. This is the independent
534            variable in the exponential function.
535        a: A scale parameter in the exponential function.
536        layer_fid: The base of the exponent in the exponential function.
537    """
538    return a * layer_fid ** cycle_depths
539
540
541def _fit_exponential_decay(
542    cycle_depths: np.ndarray, fidelities: np.ndarray
543) -> Tuple[float, float, float, float]:
544    """Fit an exponential model fidelity = a * layer_fid**x using nonlinear least squares.
545
546    This uses `exponential_decay` as the function to fit with parameters `a` and `layer_fid`.
547
548    Args:
549        cycle_depths: The various depths at which fidelity was estimated. Each element is `x`
550            in the fit expression.
551        fidelities: The estimated fidelities for each cycle depth. Each element is `fidelity`
552            in the fit expression.
553
554    Returns:
555        a: The first fit parameter that scales the exponential function, perhaps accounting for
556            state prep and measurement (SPAM) error.
557        layer_fid: The second fit parameters which serves as the base of the exponential.
558        a_std: The standard deviation of the `a` parameter estimate.
559        layer_fid_std: The standard deviation of the `layer_fid` parameter estimate.
560    """
561    cycle_depths = np.asarray(cycle_depths)
562    fidelities = np.asarray(fidelities)
563
564    # Get initial guess by linear least squares with logarithm of model.
565    # This only works for positive fidelities. We use numpy fancy indexing
566    # with `positives` (an ndarray of bools).
567    positives = fidelities > 0
568    if np.sum(positives) <= 1:
569        # The sum of the boolean array is the number of `True` entries.
570        # For one or fewer positive values, we cannot perform the linear fit.
571        return 0, 0, np.inf, np.inf
572    cycle_depths_pos = cycle_depths[positives]
573    log_fidelities = np.log(fidelities[positives])
574    slope, intercept, _, _, _ = scipy.stats.linregress(cycle_depths_pos, log_fidelities)
575    layer_fid_0 = np.clip(np.exp(slope), 0, 1)
576    a_0 = np.clip(np.exp(intercept), 0, 1)
577
578    try:
579        (a, layer_fid), pcov = scipy.optimize.curve_fit(
580            exponential_decay,
581            cycle_depths,
582            fidelities,
583            p0=(a_0, layer_fid_0),
584            bounds=((0, 0), (1, 1)),
585        )
586    except ValueError:  # coverage: ignore
587        # coverage: ignore
588        return 0, 0, np.inf, np.inf
589
590    a_std, layer_fid_std = np.sqrt(np.diag(pcov))
591    return a, layer_fid, a_std, layer_fid_std
592
593
594def _one_unique(df, name, default):
595    """Helper function to assert that there's one unique value in a column and return it."""
596    if name not in df.columns:
597        return default
598    vals = df[name].unique()
599    assert len(vals) == 1, name
600    return vals[0]
601
602
603def fit_exponential_decays(fidelities_df: pd.DataFrame) -> pd.DataFrame:
604    """Fit exponential decay curves to a fidelities DataFrame.
605
606    Args:
607         fidelities_df: A DataFrame that is the result of `benchmark_2q_xeb_fidelities`. It
608            may contain results for multiple pairs of qubits identified by the "pair" column.
609            Each pair will be fit separately. At minimum, this dataframe must contain
610            "cycle_depth", "fidelity", and "pair" columns.
611
612    Returns:
613        A new, aggregated dataframe with index given by (pair, layer_i, pair_i); columns
614        for the fit parameters "a" and "layer_fid"; and nested "cycles_depths" and "fidelities"
615        lists (now grouped by pair).
616    """
617
618    def _per_pair(f1):
619        a, layer_fid, a_std, layer_fid_std = _fit_exponential_decay(
620            f1['cycle_depth'], f1['fidelity']
621        )
622        record = {
623            'a': a,
624            'layer_fid': layer_fid,
625            'cycle_depths': f1['cycle_depth'].values,
626            'fidelities': f1['fidelity'].values,
627            'a_std': a_std,
628            'layer_fid_std': layer_fid_std,
629        }
630        return pd.Series(record)
631
632    if 'layer_i' in fidelities_df.columns:
633        groupby = ['layer_i', 'pair_i', 'pair']
634    else:
635        groupby = ['pair']
636    return fidelities_df.groupby(groupby).apply(_per_pair)
637
638
639def before_and_after_characterization(
640    fidelities_df_0: pd.DataFrame, characterization_result: XEBCharacterizationResult
641) -> pd.DataFrame:
642    """A convenience function for horizontally stacking results pre- and post- characterization
643    optimization.
644
645    Args:
646        fidelities_df_0: A dataframe (before fitting), likely resulting from
647            `benchmark_2q_xeb_fidelities`.
648        characterization_result: The result of running a characterization. This contains the
649            second fidelities dataframe as well as the new parameters.
650
651    Returns:
652          A joined dataframe with original column names suffixed by "_0" and characterized
653          column names suffixed by "_c".
654    """
655    fit_decay_df_0 = fit_exponential_decays(fidelities_df_0)
656    fit_decay_df_c = fit_exponential_decays(characterization_result.fidelities_df)
657
658    joined_df = fit_decay_df_0.join(fit_decay_df_c, how='outer', lsuffix='_0', rsuffix='_c')
659    # Remove (layer_i, pair_i) from the index. While we keep this for `fit_exponential_decays`
660    # so the same pair can be benchmarked in different contexts, the multi-pair characterization
661    # function only keys on the pair identity. This can be seen acutely by the
662    # `characterization_result.final_params` dictionary being keyed only by the pair.
663    joined_df = joined_df.reset_index().set_index('pair')
664
665    joined_df['characterized_angles'] = [
666        characterization_result.final_params[pair] for pair in joined_df.index
667    ]
668    # Take any `final_params` (for any pair). We just need the angle names.
669    fp, *_ = characterization_result.final_params.values()
670    for angle_name in fp.keys():
671        joined_df[angle_name] = [
672            characterization_result.final_params[pair][angle_name] for pair in joined_df.index
673        ]
674    return joined_df
675