fitk.fisher_matrix API documentation
Description: Package for performing operations on Fisher objects. See here for documentation of `FisherMatrix`.

# Module `fitk.fisher_matrix`

Package for performing operations on Fisher objects. See here for documentation of `FisherMatrix`.

Expand source code

    """
    Package for performing operations on Fisher objects.
    See here for documentation of `FisherMatrix`.
    """

    # needed for compatibility with Python 3.7
    from __future__ import annotations

    # standard library imports
    from collections import abc
    import copy
    from itertools import \
        permutations, \
        product
    import json
    from numbers import Number
    import os
    from typing import \
        Mapping, \
        Collection, \
        Optional, \
        Union, \
        Tuple

    # third party imports
    import numpy as np
    from scipy.special import erfinv # pylint: disable=no-name-in-module
    import matplotlib.pyplot as plt
    from matplotlib.patheffects import Stroke, Normal
    from matplotlib import colors

    # first party imports
    from fitk.fisher_utils import \
        float_to_latex, \
        ParameterNotFoundError, \
        MismatchingSizeError, \
        make_default_names, \
        is_square, \
        is_symmetric, \
        is_positive_semidefinite, \
        get_index_of_other_array, \
        reindex_array, \
        get_default_rcparams, \
        jsonify

    def _process_fisher_mapping(value : abc.Mapping):
        """
        Processes a mapping/dict and returns the sanitized output.
        """
        if 'name' not in value:
            raise ValueError(
                'The mapping/dict must contain at least the key `name`'
            )

        name = value['name']
        latex_name = value.get('latex_name', name)
        fiducial = value.get('fiducial', 0)

        return dict(
            name=name,
            latex_name=latex_name,
            fiducial=fiducial,
        )

    class FisherMatrix:
        r"""
        Class for handling Fisher objects.

        Examples
        --------
        Specify a Fisher object with default names and fiducials:
        >>> fm = FisherMatrix(np.diag([5, 4]))

        The object has a friendly representation in the interactive session:
        >>> fm
        FisherMatrix(array([[5., 0.],
               [0., 4.]]), names=array(['p1', 'p2'], dtype=object), latex_names=array(['p1', 'p2'], dtype=object), fiducial=array([0., 0.]))

        The string representation is probably more readable:
        >>> print(fm)
        FisherMatrix([[5. 0.]
         [0. 4.]], names=['p1' 'p2'], latex_names=['p1' 'p2'], fiducial=[0. 0.])

        List the names:
        >>> fm.names
        array(['p1', 'p2'], dtype=object)

        List the values of the Fisher object:
        >>> fm.values
        array([[5., 0.],
               [0., 4.]])

        List the values of the fiducials:
        >>> fm.fiducial
        array([0., 0.])

        Names can be changed in bulk (ditto for fiducial and values; dimension must of course match the original):
        >>> fm = FisherMatrix(np.diag([5, 4]))
        >>> fm.names = ['x', 'y']
        >>> fm.latex_names = [r'$\mathbf{X}$', r'$\mathbf{Y}$']
        >>> fm
        FisherMatrix(array([[5., 0.],
               [0., 4.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

        We can get and set individual elements of the matrix using dict-like notation:
        >>> fm = FisherMatrix(np.diag([5, 4]), names=['x', 'y'])
        >>> fm['x', 'x']
        5.0

        The off-diagonal elements are automatically updated when using the setter:
        >>> fm = FisherMatrix(np.diag([5, 4]), names=['x', 'y'])
        >>> fm['x', 'y'] = -2
        >>> fm
        FisherMatrix(array([[ 5., -2.],
               [-2.,  4.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['x', 'y'], dtype=object), fiducial=array([0., 0.]))

        We can select submatrices by index:
        >>> fm = FisherMatrix(np.diag([1, 2, 3]), names=['x', 'y', 'z'])
        >>> fm[1:]
        FisherMatrix(array([[2., 0.],
               [0., 3.]]), names=array(['y', 'z'], dtype=object), latex_names=array(['y', 'z'], dtype=object), fiducial=array([0., 0.]))

        Fisher object with parameter names:
        >>> fm = FisherMatrix(np.diag([5, 4]), names=['x', 'y'], latex_names=['$\\mathbf{X}$', '$\\mathbf{Y}$'])
        >>> fm_with_names = FisherMatrix(np.diag([1, 2]), names=['x', 'y'])

        We can add Fisher objects (ordering of names is taken care of):
        >>> fm + fm_with_names # doctest: +SKIP
        FisherMatrix(array([[6., 0.],
               [0., 6.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

        We can also do element-wise multiplication or division:
        >>> fm * fm_with_names
        FisherMatrix(array([[5., 0.],
               [0., 8.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

        Furthermore, we can do matrix multiplication:
        >>> fm @ fm_with_names
        FisherMatrix(array([[5., 0.],
               [0., 8.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

        We can also perform standard matrix operations like the trace, eigenvalues, determinant:
        >>> fm.trace()
        9.0
        >>> fm.eigenvalues()
        array([4., 5.])
        >>> fm.determinant()
        19.999999999999996

        We can also take the matrix inverse:
        >>> fm.inverse()
        FisherMatrix(array([[0.2 , 0.  ],
               [0.  , 0.25]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

        We can drop parameters from the object:
        >>> fm.drop('x')
        FisherMatrix(array([[4.]]), names=array(['y'], dtype=object), latex_names=array(['$\\mathbf{Y}$'], dtype=object), fiducial=array([0.]))

        We can save it to a file (the returned value is the dictionary that was saved):
        >>> fm.to_file('example_matrix.json', overwrite=True)
        {'values': [[5.0, 0.0], [0.0, 4.0]], 'names': ['x', 'y'], 'latex_names': ['$\\mathbf{X}$', '$\\mathbf{Y}$'], 'fiducial': [0.0, 0.0]}

        Loading is performed by a class method `from_file`:
        >>> fm_new = FisherMatrix.from_file('example_matrix.json')
        """

        def __init__(
            self,
            values : Collection,
            names : Optional[Collection[str]] = None,
            latex_names : Optional[str] = None,
            fiducial : Optional[Collection[float]] = None,
        ):
            r"""
            Constructor for Fisher object.

            Parameters
            ----------
            values : array-like
                The values of the Fisher object.

            names : array-like iterable of `str`, default = None
                The names of the parameters.
                If not specified, defaults to `p1, ..., pn`.

            latex_names : array-like iterable of `str`, default = None
                The LaTeX names of the parameters.
                If not specified, defaults to `names`.

            fiducial : array-like iterable of `float`, default = None
                The fiducial values of the parameters. If not specified, default to
                0 for all parameters.

            metadata : Optional[dict], default = None
                any metadata associated with the Fisher object

            Raises
            ------
            * `ValueError` if the input array has the wrong dimensionality (not 2)
            * `ValueError` if the object is not square-like
            * `MismatchingSizeError` if the sizes of the array of names, values, LaTeX names and fiducials do not match

            Examples
            --------
            Specify a Fisher object without names:
            >>> FisherMatrix(np.diag([1, 2, 3]))
            FisherMatrix(array([[1., 0., 0.],
                   [0., 2., 0.],
                   [0., 0., 3.]]), names=array(['p1', 'p2', 'p3'], dtype=object), latex_names=array(['p1', 'p2', 'p3'], dtype=object), fiducial=array([0., 0., 0.]))

            Specify a Fisher object with names, LaTeX names, and fiducials:
            >>> FisherMatrix(np.diag([1, 2]), names=['alpha', 'beta'],
            ... latex_names=[r'$\alpha$', r'$\beta$'], fiducial=[-3, 2])
            FisherMatrix(array([[1., 0.],
                   [0., 2.]]), names=array(['alpha', 'beta'], dtype=object), latex_names=array(['$\\alpha$', '$\\beta$'], dtype=object), fiducial=array([-3.,  2.]))
            """

            if np.ndim(values) != 2:
                raise ValueError(
                    f'The object {values} is not 2-dimensional'
                )

            if not is_square(values):
                raise ValueError(
                    f'The object {values} is not square-like'
                )

            # try to treat it as an array-like object
            self._values = np.array(values, dtype=float)

            self._size = np.shape(self._values)[0]
            self._ndim = np.ndim(self._values)

            # setting the fiducial
            if fiducial is None:
                self._fiducial = np.zeros(self._size, dtype=float)
            else:
                self._fiducial = np.array(fiducial, dtype=float)

            # setting the names
            if names is None:
                self._names = make_default_names(self._size)
            else:
                # check they're unique
                if len(set(names)) != len(names):
                    raise MismatchingSizeError(set(names), names)

                self._names = np.array(names, dtype=object)

            # setting the pretty names (LaTeX)
            if latex_names is None:
                self._latex_names = copy.deepcopy(self._names)
            else:
                self._latex_names = np.array(latex_names, dtype=object)

            # check sizes of inputs
            if not all(
                len(_) == self._size \
                for _ in (self._names, self._fiducial, self._latex_names)
            ):
                raise MismatchingSizeError(
                    self._values[0],
                    self._names,
                    self._fiducial,
                    self._latex_names,
                )

        def rename(
            self,
            names : Mapping[str, Union[str, abc.Mapping]],
            ignore_errors : bool = False,
        ) -> FisherMatrix:
            """
            Returns a Fisher object with new names.

            Parameters
            ----------
            names : Mapping[str, Union[str, abc.Mapping]]
                a mapping (dictionary-like object) between the old names and the
                new ones. The values it maps to can either be a string (the new name), or a dict
                with keys `name`, `latex_name`, and `fiducial` (only `name` is mandatory).

            ignore_errors : bool, default = False
                if set to True, will not raise an error if a parameter doesn't exist

            Returns
            -------
            Instance of `FisherMatrix`.

            Examples
            --------
            >>> m = FisherMatrix(np.diag([1, 2, 3]))
            >>> m.rename({'p1' : 'a', 'p2' : dict(name='b', latex_name='$b$', fiducial=2)})
            FisherMatrix(array([[1., 0., 0.],
                   [0., 2., 0.],
                   [0., 0., 3.]]), names=array(['a', 'b', 'p3'], dtype=object), latex_names=array(['a', '$b$', 'p3'], dtype=object), fiducial=array([0., 2., 0.]))
            """
            # check uniqueness and size
            if len(set(names)) != len(names):
                raise MismatchingSizeError(set(names), names)

            if not ignore_errors:
                for name in names:
                    if name not in self.names:
                        raise ParameterNotFoundError(name, self.names)

            names_new = copy.deepcopy(self.names)
            latex_names_new = copy.deepcopy(self.latex_names)
            fiducial_new = copy.deepcopy(self.fiducial)

            for name, value in names.items():
                index = np.where(names_new == name)
                # it's a mapping
                if isinstance(value, abc.Mapping):
                    value = _process_fisher_mapping(value)
                    latex_names_new[index] = value['latex_name']
                    fiducial_new[index] = value['fiducial']
                    names_new[index] = value['name']
                # otherwise, it's a string
                else:
                    names_new[index] = value
                    latex_names_new[index] = value

            return self.__class__(
                self.values,
                names=names_new,
                latex_names=latex_names_new,
                fiducial=fiducial_new,
            )

        def _repr_html_(self):
            """
            Representation of the Fisher object suitable for viewing in Jupyter
            notebook environments.
            """
            header_matrix = '<thead><tr><th></th>' + (
                '<th>{}</th>' * len(self)
            ).format(*self.latex_names) + '</tr></thead>'

            body_matrix = '<tbody>'
            for index, name in enumerate(self.latex_names):
                body_matrix += f'<tr><th>{name}</th>' + (
                    '<td>{:.3f}</td>' * len(self)
                ).format(*(self.values[:, index])) + '</tr>'
            body_matrix += '</tbody>'

            html_matrix = f'<table>{header_matrix}{body_matrix}</table>'

            return html_matrix

        def __repr__(self):
            """
            Representation of the Fisher object for non-Jupyter interfaces.
            """
            return 'FisherMatrix(' \
                f'{repr(self.values)}, ' \
                f'names={repr(self.names)}, ' \
                f'latex_names={repr(self.latex_names)}, ' \
                f'fiducial={repr(self.fiducial)})'

        def __str__(self):
            """
            String representation of the Fisher object.
            """
            return 'FisherMatrix(' \
                f'{self.values}, ' \
                f'names={self.names}, ' \
                f'latex_names={self.latex_names}, ' \
                f'fiducial={self.fiducial})'

        def __getitem__(
            self,
            keys : Union[Tuple[str], slice],
        ):
            """
            Implements access to elements in the Fisher object.
            Has support for slicing.
            """
            # the object can be sliced
            if isinstance(keys, slice):
                start, stop, step = keys.indices(len(self))
                indices = (slice(start, stop, step),) * self.ndim
                sl = slice(start, stop, step)
                values = self.values[indices]
                names = self.names[sl]
                latex_names = self.latex_names[sl]
                fiducial = self.fiducial[sl]

                return self.__class__(
                    values,
                    names=names,
                    latex_names=latex_names,
                    fiducial=fiducial,
                )

            try:
                _ = iter(keys)
            except TypeError as err:
                raise TypeError(err) from err

            # the keys can be a tuple
            if isinstance(keys, tuple):
                if len(keys) != self.ndim:
                    raise ValueError(
                        f'Expected {self.ndim} arguments, got {len(keys)}'
                    )

                # error checking
                for key in keys:
                    if key not in self.names:
                        raise ParameterNotFoundError(key, self.names)

                indices = tuple(np.where(self.names == key) for key in keys)

            # otherwise, it's some generic object
            else:
                if keys not in self.names:
                    raise ParameterNotFoundError(keys, self.names)

                indices = (np.where(self.names == keys),)

            return self._values[indices][0, 0]

        def __setitem__(
            self,
            keys : Tuple[str],
            value : float,
        ):
            """
            Implements setting of elements in the Fisher object.
            Does not support slicing.
            """
            try:
                _ = iter(keys)
            except TypeError as err:
                raise err

            if len(keys) != self.ndim:
                raise ValueError(f'Got length {len(keys)}')

            # automatically raises a value error
            indices = tuple(np.where(self.names == key) for key in keys)

            temp_data = copy.deepcopy(self._values)

            if not all(index == indices[0] for index in indices):
                for permutation in list(permutations(indices)):
                    # update all symmetric parts
                    temp_data[permutation] = value
            else:
                temp_data[indices] = value

            self._values = copy.deepcopy(temp_data)

        def is_valid(self):
            """
            Checks whether the values make a valid Fisher object.
            A (square) matrix is a Fisher matrix if it satisifies the following two
            criteria:

            * it is symmetric
            * it is positive semi-definite

            Returns
            -------
            `True` or `False`

            Examples
            --------
            >>> m = FisherMatrix(np.diag([1, 2, 3]))
            >>> m.is_valid()
            True
            >>> FisherMatrix(np.diag([-1, 3])).is_valid()
            False
            """
            return \
                is_symmetric(self.values) and \
                is_positive_semidefinite(self.values)

        def imshow(
            self,
            colorbar : bool = False,
            show_values : bool = False,
            normalized : bool = False,
            colorbar_space : float = 0.02,
            colorbar_width : float = 0.05,
            colorbar_orientation : str = 'vertical',
            rc : abc.Mapping = get_default_rcparams(),
            **kwargs,
        ):
            """
            Returns the image of the Fisher object.
            """
            # TODO should only work with 2D data.
            with plt.rc_context(rc):
                fig, ax = plt.subplots(figsize=(len(self), len(self)))
                img = ax.imshow(
                    self.values,
                    interpolation='none',
                    norm=colors.CenteredNorm(),
                    **kwargs,
                )

                ax.set_xticks(np.arange(len(self)))
                ax.set_yticks(np.arange(len(self)))
                ax.set_xticklabels(self.latex_names)
                ax.set_yticklabels(self.latex_names)

                if colorbar:
                    allowed_orientations = ('vertical', 'horizontal')
                    if colorbar_orientation not in allowed_orientations:
                        raise ValueError(
                            f'\'{colorbar_orientation}\' is not one of: {allowed_orientations}'
                        )

                    if colorbar_orientation == 'vertical':
                        cax = fig.add_axes(
                            [ax.get_position().x1 + colorbar_space,
                            ax.get_position().y0 * 0.997,
                            colorbar_width,
                            ax.get_position().height]
                        )
                        fig.colorbar(img, cax=cax, orientation=colorbar_orientation)
                        cax.set_yticklabels(
                            [f'${float_to_latex(_)}$' for _ in cax.get_yticks()]
                        )
                    else:
                        cax = fig.add_axes(
                            [ax.get_position().x0,
                            ax.get_position().y1 + colorbar_space,
                            ax.get_position().width,
                            colorbar_width]
                        )
                        fig.colorbar(img, cax=cax, orientation=colorbar_orientation)
                        cax.xaxis.set_ticks_position('top')
                        cax.set_xticklabels(
                            [f'${float_to_latex(_)}$' for _ in cax.get_xticks()]
                        )

                # whether or not we want to display the actual values inside the
                # matrix
                if show_values:
                    mid_coords = np.arange(len(self))
                    for index1, index2 in product(range(len(self)), range(len(self))):
                        x = mid_coords[index1]
                        y = mid_coords[index2]
                        value = self.values[index1, index2] / \
                            np.sqrt(
                                self.values[index1, index1] \
                               *self.values[index2, index2]
                            ) if normalized \
                            else self.values[index1, index2]
                        text = ax.text(
                                x, y, f'${float_to_latex(value)}$',
                                ha='center', va='center', color='white',
                        )
                        text.set_path_effects(
                            [Stroke(linewidth=1, foreground="black"), Normal()]
                        )

            return fig

        def sort(
            self,
            **kwargs,
        ) -> FisherMatrix:
            """
            Sorts the Fisher object by name according to some criterion.

            Parameters
            ----------
            **kwargs
                all of the other keyword arguments for the Python builtin `sorted`.
                If none are specified, will sort according to the names of the parameters.
                In the special case that the value of the keyword `key` is set to
                either 'fiducial' or 'latex_names', it will sort according to those.
                In the second special case that the value of the keyword `key` is
                set to an array of integers of equal size as the Fisher object, sorts them
                according to those instead.

            Returns
            -------
            Instance of `FisherMatrix`.

            Examples
            --------
            >>> m = FisherMatrix(np.diag([3, 1, 2]), names=list('sdf'),
            ... latex_names=['hjkl', 'qwe', 'll'], fiducial=[8, 7, 3])
            >>> m.sort(key='fiducial')
            FisherMatrix(array([[2., 0., 0.],
                   [0., 1., 0.],
                   [0., 0., 3.]]), names=array(['f', 'd', 's'], dtype=object), latex_names=array(['ll', 'qwe', 'hjkl'], dtype=object), fiducial=array([3., 7., 8.]))
            >>> m.sort(key='latex_names')
            FisherMatrix(array([[3., 0., 0.],
                   [0., 2., 0.],
                   [0., 0., 1.]]), names=array(['s', 'f', 'd'], dtype=object), latex_names=array(['hjkl', 'll', 'qwe'], dtype=object), fiducial=array([8., 3., 7.]))
            """
            allowed_keys = ('fiducial', 'latex_names')
            # an integer index
            if 'key' in kwargs and all(hasattr(_, '__index__') for _ in kwargs['key']):
                index = np.array(kwargs['key'], dtype=int)
                names = self.names[index]
            # either 'fiducial' or 'latex_names'
            elif 'key' in kwargs and kwargs['key'] in allowed_keys:
                index = np.argsort(getattr(self, kwargs['key']))
                if 'reversed' in kwargs and kwargs['reversed'] is True:
                    index = np.flip(index)
                names = self.names[index]
            # something that can be passed to `sorted`
            else:
                names = sorted(self.names, **kwargs)
                index = get_index_of_other_array(self.names, names)

            latex_names = self.latex_names[index]
            fiducial = self.fiducial[index]
            values = reindex_array(self.values, index)

            return self.__class__(
                values,
                names=names,
                latex_names=latex_names,
                fiducial=fiducial,
            )

        def __eq__(self, other):
            """
            The equality operator.
            Returns `True` if the operands have the following properties:

            * are instances of FisherMatrix
            * have same names (potentially shuffled)
            * have same dimensionality
            * have same fiducials (potentially shuffled)
            * have same values (potentially shuffled)
            """
            if set(self.names) != set(other.names):
                return False

            # index for re-shuffling parameters
            index = get_index_of_other_array(self.names, other.names)

            return isinstance(other, self.__class__) \
            and self.ndim == other.ndim \
            and len(self) == len(other) \
            and set(self.names) == set(other.names) \
            and np.allclose(
                self.fiducial,
                other.fiducial[index],
            ) \
            and np.allclose(
                self.values,
                reindex_array(other.values, index),
            )

        @property
        def ndim(self):
            """
            Returns the number of dimensions of the Fisher object (for now always 2).
            """
            return np.ndim(self._values)

        def __len__(self):
            """
            Returns the number of parameters in the Fisher object.
            """
            return self._size

        @property
        def values(self):
            """
            Returns the values in the Fisher object as a numpy array.
            """
            return self._values

        @values.setter
        def values(
            self,
            value,
        ):
            """
            Setter for the values of the Fisher object.
            """
            if self.ndim != np.ndim(value):
                raise ValueError(
                    f'The dimensionality of the matrices do not match: {self.ndim} and {np.ndim(value)}'
                )
            if len(self) != len(value):
                raise MismatchingSizeError(self, value)

            if not is_square(value):
                raise ValueError(
                    f'{value} is not a square object'
                )

            self._values = np.array(value, dtype=float)

        def is_diagonal(self):
            """
            Checks whether the Fisher matrix is diagonal.

            Returns
            -------
            `True` or `False`
            """
            return np.all(self.values == np.diag(np.diagonal(self.values)))

        def diagonal(self, **kwargs):
            """
            Returns the diagonal elements of the Fisher object as a numpy array.

            Returns
            -------
            array-like of floats
            """
            return np.diag(self.values, **kwargs)

        def drop(
            self,
            *names : str,
            invert : bool = False,
            ignore_errors : bool = False,
        ) -> FisherMatrix:
            """
            Removes parameters from the Fisher object.

            Parameters
            ----------
            names : string-like
                the names of the parameters to drop.
                If passing a list or a tuple, make sure to unpack it using the
                asterisk (*).

            invert : bool, default = False
                whether to drop all the parameters NOT in names (the complement)

            ignore_errors : bool, default = False
                should non-existing parameters be ignored

            Returns
            -------
            Instance of `FisherMatrix`

            Examples
            --------
            >>> m = FisherMatrix(np.diag([1, 2, 3]))
            >>> m.drop('p1', 'p3')
            FisherMatrix(array([[2.]]), names=array(['p2'], dtype=object), latex_names=array(['p2'], dtype=object), fiducial=array([0.]))
            """
            if not ignore_errors and not set(names).issubset(set(self.names)):
                raise ValueError(
                    f'The names ({list(names)}) are not a strict subset ' \
                    f'of the parameter names in the Fisher object ({self.names}); ' \
                    'you can pass `ignore_errors=True` to ignore this error'
                )

            if ignore_errors:
                names = np.array([name for name in names if name in self.names])

            if invert is True:
                names = set(names) ^ set(self.names)

            # TODO should we remove this?
            if set(names) == set(self.names):
                raise ValueError('Unable to remove all parameters')

            index = [np.array(np.where(self.names == name), dtype=int) for name in names]

            values = self.values
            for dim in range(self.ndim):
                values = np.delete(
                    values,
                    index,
                    axis=dim,
                )

            fiducial = np.delete(self.fiducial, index)
            latex_names = np.delete(self.latex_names, index)
            names = np.delete(self.names, index)

            return self.__class__(
                values,
                names=names,
                latex_names=latex_names,
                fiducial=fiducial,
            )

        def trace(
            self,
            **kwargs,
        ):
            """
            Returns the trace of the Fisher object as a numpy array.
            """
            return np.trace(self.values, **kwargs)

        def eigenvalues(self):
            """
            Returns the eigenvalues of the Fisher object as a numpy array.
            """
            return np.linalg.eigvalsh(self.values)

        def eigenvectors(self):
            """
            Returns the right eigenvectors of the Fisher object as a numpy array.
            """
            return np.linalg.eigh(self.values)[-1]

        def condition_number(self):
            r"""
            Returns the condition number of the matrix with respect to the \(L^2\) norm.

            Examples
            --------
            >>> fm = FisherMatrix(np.diag([1, 2, 3]))
            >>> fm.condition_number()
            3.0
            """
            values = np.abs(self.eigenvalues())
            return np.max(values) / np.min(values)

        def inverse(self) -> FisherMatrix:
            """
            Returns the inverse of the Fisher matrix.

            Returns
            -------
            Instance of `FisherMatrix`

            Examples
            --------
            >>> fm = FisherMatrix(np.diag([1, 2, 5]))
            >>> fm.inverse()
            FisherMatrix(array([[1. , 0. , 0. ],
                   [0. , 0.5, 0. ],
                   [0. , 0. , 0.2]]), names=array(['p1', 'p2', 'p3'], dtype=object), latex_names=array(['p1', 'p2', 'p3'], dtype=object), fiducial=array([0., 0., 0.]))
            """
            # inverse satisfies properties of Fisher matrix, see:
            # https://math.stackexchange.com/a/26200
            return self.__class__(
                np.linalg.inv(self.values),
                names=self.names,
                latex_names=self.latex_names,
                fiducial=self.fiducial,
            )

        def determinant(self):
            """
            Returns the determinant of the matrix.

            Returns
            -------
            float
            """
            return np.linalg.det(self.values)

        def constraints(
            self,
            name : Optional[str] = None,
            marginalized : bool = True,
            sigma : Optional[float] = None,
            p : Optional[float] = None,
        ):
            r"""
            Returns the constraints on a parameter as a float, or on all of them
            as a numpy array if `name` is not specified.

            Parameters
            ----------
            name : Optional[str] = None
                the name of the parameter for which we we want the constraints

            marginalized : bool, default = True
                whether we want the marginalized or the unmarginalized
                constraints.

            sigma : Optional[float], default = None
                how many sigmas away.

            p : Optional[float], default = None
                the confidence interval (p-value).
                The relationship between `p` and `sigma` is defined via:
                \[
                    p(\sigma) = \int\limits_{\mu - \sigma}^{\mu + \sigma}
                                f(x, \mu, 1)\, \mathrm{d}x
                              = \mathrm{Erf}(\sigma / \sqrt{2})
                \]
                and therefore the inverse is simply:
                \[
                    \sigma(p) = \sqrt{2}\, \mathrm{Erf}^{-1}(p)
                \]
                The values of `p` corresponding to 1, 2, 3 `sigma` are roughly
                0.683, 0.954, and 0.997, respectively.

            Returns
            -------
            array-like of floats or single float

            Notes
            -----
            The user should specify either `sigma` or `p`, but not both
            simultaneously.
            If neither are specified, defaults to `sigma=1`.

            Examples
            --------
            Get (marginalized by default) constraints for all parameters:
            >>> m = FisherMatrix([[3, -2], [-2, 5]])
            >>> m.constraints()
            array([0.67419986, 0.52223297])

            Get the unmarginalized constraints instead:
            >>> m.constraints(marginalized=False)
            array([0.57735027, 0.4472136 ])

            Get the unmarginalized constraints for a single parameter:
            >>> m.constraints('p1', marginalized=False)
            array([0.57735027])

            Get the marginalized constraints for a single parameter:
            >>> m.constraints('p1', p=0.682689) # p-value roughly equal to 1 sigma
            array([0.67419918])
            """
            if sigma is not None and p is not None:
                raise ValueError(
                    'Cannot specify both `p` and `sigma` simultaneously'
                )

            if p is not None:
                if not 0 < p < 1:
                    raise ValueError(
                        f'The value of `p` {p} is outside of the allowed range (0, 1)'
                    )
                sigma = np.sqrt(2) * erfinv(p)
            elif sigma is None:
                sigma = 1

            if sigma <= 0:
                raise ValueError(
                    f'The value of `sigma` {sigma} is outside of the allowed range (0, infinify)'
                )

            if marginalized:
                inv = self.inverse()
                result = np.sqrt(np.diag(inv.values)) * sigma
            else:
                result = 1. / np.sqrt(np.diag(self.values)) * sigma

            if name is not None:
                if name in self.names:
                    return result[np.where(self.names == name)]
                raise ParameterNotFoundError(name, self.names)

            return result

        @property
        def fiducial(self):
            """
            Returns the fiducial values of the Fisher object as a numpy array.
            """
            return self._fiducial

        @fiducial.setter
        def fiducial(
            self,
            value,
        ):
            """
            The setter for the fiducial values of the Fisher object.
            """
            if len(value) != len(self):
                raise MismatchingSizeError(value, self)
            try:
                self._fiducial = np.array([float(_) for _ in value])
            except TypeError as err:
                raise TypeError(err) from err

        @property
        def names(self):
            """
            Returns the parameter names of the Fisher object.
            """
            return self._names

        @names.setter
        def names(
            self,
            value,
        ):
            """
            The bulk setter for the names.
            The length of the names must be the same as the one of the original
            object.
            """
            if len(set(value)) != len(self):
                raise MismatchingSizeError(set(value), self)
            self._names = np.array(value, dtype=object)

        @property
        def latex_names(self):
            """
            Returns the LaTeX names of the parameters of the Fisher object.
            """
            return self._latex_names

        @latex_names.setter
        def latex_names(
            self,
            value,
        ):
            """
            The bulk setter for the LaTeX names.
            The length of the names must be the same as the one of the original
            object.
            """
            if len(set(value)) != len(self):
                raise MismatchingSizeError(set(value), self)
            self._latex_names = np.array(value, dtype=object)

        def __add__(
            self,
            other : FisherMatrix,
        ) -> FisherMatrix:
            """
            Returns the result of adding two Fisher objects.
            """
            try:
                other = float(other)
            except TypeError as err:
                # make sure the dimensions match
                if other.ndim != self.ndim:
                    raise ValueError(
                        f'The dimensions of the objects do not match: {other.ndim} and {self.ndim}'
                    ) from err

                # make sure they have the right parameters
                if set(other.names) != set(self.names):
                    raise ValueError(
                        f'Incompatible parameter names: {other.names} and {self.names}'
                    ) from err

                index = get_index_of_other_array(self.names, other.names)

                # make sure the fiducials match
                fiducial = other.fiducial[index]

                if not np.allclose(fiducial, self.fiducial):
                    raise ValueError(
                        f'Incompatible fiducial values: {fiducial} and {self.fiducial}'
                    ) from err

                values = self.values + reindex_array(other.values, index)

                return self.__class__(
                    values,
                    names=self.names,
                    latex_names=self.latex_names,
                    fiducial=self.fiducial,
                )

            return self.__class__(
                self.values + other,
                names=self.names,
                latex_names=self.latex_names,
                fiducial=self.fiducial,
            )

        def __radd__(
            self,
            other : Union[FisherMatrix, float],
        ) -> FisherMatrix:
            """
            Addition when the Fisher object is the right operand.
            """
            return self.__add__(other)

        def __neg__(self) -> FisherMatrix:
            """
            Returns the negation of the Fisher object.
            """
            return self.__class__(
                -self.values,
                names=self.names,
                latex_names=self.latex_names,
                fiducial=self.fiducial,
            )

        def __sub__(
            self,
            other : Union[FisherMatrix, float],
        ) -> FisherMatrix:
            """
            Returns the result of subtracting two Fisher objects.
            """
            return self.__add__(-other)

        def __rsub__(
            self,
            other : Union[FisherMatrix, float],
        ) -> FisherMatrix:
            """
            Subtraction when the Fisher object is the right operand.
            """
            # this will never be called if we subtract two FisherMatrix instances,
            # so we just need to handle floats
            return -self.__add__(-other)

        def __array_ufunc__(
            self,
            ufunc,
            method,
            *inputs,
            **kwargs,
        ):
            """
            Handles numpy's universal functions.
            For a complete list and explanation, see:
            https://numpy.org/doc/stable/reference/ufuncs.html
            """
            if method == '__call__':
                scalars = []
                for i in inputs:
                    if isinstance(i, Number):
                        scalars.append(i)
                    elif isinstance(i, self.__class__):
                        scalars.append(i.values)
                    else:
                        return NotImplemented

                names = [_.names for _ in inputs if isinstance(_, self.__class__)]

                if not np.all([set(names[0]) == set(_) for _ in names]):
                    raise ValueError(
                        'Mismatching names'
                    )

                # make sure the names have the same _ordering_
                for index, _ in enumerate(inputs):
                    if isinstance(_, self.__class__):
                        scalars[index] = _.sort(
                            key=get_index_of_other_array(
                                names[0],
                                _.names,
                            )
                        ).values

                return self.__class__(
                    ufunc(*scalars, **kwargs),
                    names=self.names,
                    latex_names=self.latex_names,
                    fiducial=self.fiducial,
                )

            return NotImplemented

        def __pow__(
            self,
            other : float,
        ) -> FisherMatrix:
            """
            Raises the Fisher object to some power.
            """
            return self.__class__(
                np.power(self.values, other),
                names=self.names,
                latex_names=self.latex_names,
                fiducial=self.fiducial,
            )

        def __matmul__(
            self,
            other : FisherMatrix,
        ):
            """
            Matrix multiplies two Fisher objects.
            """
            # make sure the dimensions match
            if other.ndim != self.ndim:
                raise ValueError(
                    f'The dimensions of the objects do not match: {other.ndim} and {self.ndim}'
                )

            # make sure they have the right parameters
            if set(other.names) != set(self.names):
                raise ValueError(
                    f'Incompatible parameter names: {other.names} and {self.names}'
                )

            index = get_index_of_other_array(self.names, other.names)

            # make sure the fiducials match
            fiducial = other.fiducial[index]

            if not np.allclose(fiducial, self.fiducial):
                raise ValueError(
                    f'Incompatible fiducial values: {fiducial} and {self.fiducial}'
                )

            values = self.values @ reindex_array(other.values, index)

            return self.__class__(
                values,
                names=self.names,
                latex_names=self.latex_names,
                fiducial=self.fiducial,
            )

        def __truediv__(
            self,
            other : Union[FisherMatrix, float, int],
        ) -> FisherMatrix:
            """
            Returns the result of dividing a Fisher object by a number, or another
            Fisher object (element-wise).
            """
            # we can only divide two objects if they have the same dimensions and sizes
            try:
                other = float(other)
            except TypeError as err:
                # maybe it's a FisherMatrix
                # make sure they have the right parameters
                if set(other.names) != set(self.names):
                    raise ValueError(err) from err

                index = get_index_of_other_array(self.names, other.names)

                # make sure the fiducials match
                fiducial = other.fiducial[index]

                if not np.allclose(fiducial, self.fiducial):
                    raise ValueError(err) from err

                if other.ndim == self.ndim:
                    values = self.values / reindex_array(other.values, index)
                else:
                    raise TypeError(err) from err
            else:
                values = self.values / other

            return self.__class__(
                values,
                names=self.names,
                latex_names=self.latex_names,
                fiducial=self.fiducial,
            )

        def __mul__(
            self,
            other : Union[FisherMatrix, float, int],
        ) -> FisherMatrix:
            """
            Returns the result of multiplying a Fisher object by a number, or
            another Fisher object (element-wise).
            """
            # we can only multiply two objects if they have the same dimensions and sizes
            try:
                other = float(other)
            except TypeError as err:
                # maybe it's a FisherMatrix
                # make sure they have the right parameters
                if set(other.names) != set(self.names):
                    raise ValueError(err) from err

                index = get_index_of_other_array(self.names, other.names)

                # make sure the fiducials match
                fiducial = other.fiducial[index]

                if not np.allclose(fiducial, self.fiducial):
                    raise ValueError(err) from err

                if other.ndim == self.ndim:
                    values = self.values * reindex_array(other.values, index)
                else:
                    raise TypeError(err) from err
            else:
                values = self.values * other

            return self.__class__(
                values,
                names=self.names,
                latex_names=self.latex_names,
                fiducial=self.fiducial,
            )

        def __rmul__(
            self,
            other : Union[float, int],
        ) -> FisherMatrix:
            """
            Returns the result of multiplying a number by a Fisher object, or
            another Fisher object (element-wise).
            """
            return self.__mul__(other)

        def reparametrize(
            self,
            jacobian : Collection,
            names : Optional[Collection[str]] = None,
            latex_names : Optional[Collection[str]] = None,
            fiducial : Optional[Collection[float]] = None,
        ) -> FisherMatrix:
            """
            Returns a new Fisher object with parameters `names`, which are
            related to the old ones via the transformation `jacobian`.
            See the [Wikipedia article](https://en.wikipedia.org/w/index.php?title=Fisher_information&oldid=1063384000#Reparametrization) for more information.

            Parameters
            ----------
            transformation : array-like
                the Jacobian of the transformation

            names : array-like, default = None
                list of new names for the Fisher object. If None, uses the old
                names.

            latex_names: array-like, default = None
                list of new LaTeX names for the Fisher object. If None, and
                `names` is set, uses those instead, otherwise uses the old LaTeX names.

            fiducial : array-like, default = None
                the new values of the fiducial. If not set, defaults to old values.

            Returns
            -------
            Instance of `FisherMatrix`.

            Examples
            --------
            >>> fm = FisherMatrix(np.diag([1, 2]))
            >>> jac = [[1, 4], [3, 2]]
            >>> fm.reparametrize(jac, names=['a', 'b'])
            FisherMatrix(array([[19., 16.],
                   [16., 24.]]), names=array(['a', 'b'], dtype=object), latex_names=array(['a', 'b'], dtype=object), fiducial=array([0., 0.]))
            """
            values = np.transpose(jacobian) @ self.values @ jacobian

            if names is not None:
                if len(set(names)) != np.shape(jacobian)[-1]:
                    raise MismatchingSizeError(names)
                if latex_names is not None:
                    if len(set(latex_names)) != np.shape(jacobian)[-1]:
                        raise MismatchingSizeError(latex_names)
                else:
                    latex_names = copy.deepcopy(names)
            else:
                # we don't transform the names
                names = copy.deepcopy(self.names)
                latex_names = copy.deepcopy(self.latex_names)

            if fiducial is not None:
                if len(fiducial) != np.shape(jacobian)[-1]:
                    raise MismatchingSizeError(fiducial)
            else:
                fiducial = copy.deepcopy(self.fiducial)

            return self.__class__(
                values,
                names=names,
                latex_names=latex_names,
                fiducial=fiducial,
            )

        def to_file(
            self,
            path : str,
            *args : str,
            metadata : dict = {},
            overwrite : bool = False,
        ):
            r"""
            Saves the Fisher object to a file.
            The format used is a simple JSON file, containing at least the values of the
            Fisher object, the names of the parameters, the LaTeX names, and the
            fiducial values.

            Parameters
            ----------
            path : str
                the path to save the data to.

            args : str
                whatever other information about the object needs to be saved.
                Needs to be one of the following: `is_valid`, `eigenvalues`,
                `eigenvectors`, `trace`, `determinant`, or `constraints` (by
                default, the 1$\sigma$ marginalized constraints).

            metadata : dict, default = {}
                any metadata that should be associated to the object saved

            overwrite : bool, default = False
                whether to overwrite the file if it exists

            Returns
            -------
            The dictionary that was saved.

            Examples
            --------
            >>> fm = FisherMatrix(np.diag([1, 2]), names=['a', 'b'], latex_names=[r'$\mathbf{A}$', r'$\mathbf{B}$'])
            >>> fm.to_file('example_matrix.json', overwrite=True)
            {'values': [[1.0, 0.0], [0.0, 2.0]], 'names': ['a', 'b'], 'latex_names': ['$\\mathbf{A}$', '$\\mathbf{B}$'], 'fiducial': [0.0, 0.0]}
            >>> fm_read = FisherMatrix.from_file('example_matrix.json') # convenience function for reading it
            >>> fm == fm_read # verify it's the same object
            True
            """
            data = {
                'values' : self.values.tolist(),
                'names' : self.names.tolist(),
                'latex_names' : self.latex_names.tolist(),
                'fiducial' : self.fiducial.tolist(),
            }

            allowed_metadata = {
                'is_valid' : bool,
                'eigenvalues' : np.array,
                'eigenvectors' : np.array,
                'trace' : float,
                'determinant' : float,
                'constraints' : np.array,
            }

            for arg in args:
                if arg not in allowed_metadata:
                    raise ValueError(
                        f'name {arg} is not one of {list(allowed_metadata.keys())}'
                    )

            for arg in metadata:
                if arg in data:
                    raise ValueError(
                        f'name {arg} cannot be one of {list(data.keys())}'
                    )

            data = {
                **data,
                **{arg : jsonify(getattr(self, arg)()) for arg in args},
                **metadata,
            }

            if os.path.exists(path) and not overwrite:
                raise FileExistsError(
                    f'The file {path} already exists, please pass ' \
                    '`overwrite=True` if you wish to explicitly overwrite '
                    'the file'
                )

            with open(path, 'w', encoding='utf-8') as file_handle:
                file_handle.write(json.dumps(data, indent=4))

            return data

        def marginalize_over(
            self,
            *names : str,
            invert : bool = False,
            ignore_errors : bool = False,
        ) -> FisherMatrix:
            """
            Perform marginalization over some parameters.

            Parameters
            ----------
            names : str
                the names of the parameters to marginalize over

            invert : bool, default = False
                whether to marginalize over all the parameters NOT in names (the complement)

            ignore_errors : bool, default = False
                should non-existing parameters be ignored

            Returns
            -------
            Instance of `FisherMatrix`.

            Examples
            --------
            Generate a Fisher object using a random orthogonal matrix:
            >>> from scipy.stats import ortho_group
            >>> rm = ortho_group.rvs(5, random_state=12345)
            >>> fm = FisherMatrix(rm.T @ np.diag([1, 2, 3, 7, 6]) @ rm)

            Marginalize over some parameters:
            >>> fm.marginalize_over('p1', 'p2')
            FisherMatrix(array([[ 1.67715591, -1.01556085,  0.30020773],
                   [-1.01556085,  4.92788976,  0.91219831],
                   [ 0.30020773,  0.91219831,  3.1796454 ]]), names=array(['p3', 'p4', 'p5'], dtype=object), latex_names=array(['p3', 'p4', 'p5'], dtype=object), fiducial=array([0., 0., 0.]))

            Marginalize over all parameters which are NOT `p1` or `p2`:
            >>> fm.marginalize_over('p1', 'p2', invert=True)
            FisherMatrix(array([[ 5.04480062, -0.04490453],
                   [-0.04490453,  1.61599083]]), names=array(['p1', 'p2'], dtype=object), latex_names=array(['p1', 'p2'], dtype=object), fiducial=array([0., 0.]))
            """
            inv = self.inverse()
            if invert is True:
                names = set(names) ^ set(self.names)
            fm = inv.drop(*names, ignore_errors=ignore_errors)
            return fm.inverse()

        @classmethod
        def from_file(
            cls,
            path : str,
        ):
            """
            Reads a Fisher object from a file.

            Parameters
            ----------
            path : str
                the path to the file

            Returns
            -------
            Instance of `FisherMatrix`
            """
            with open(path, 'r', encoding='utf-8') as file_handle:
                data = json.loads(file_handle.read())

            return cls(
                data['values'],
                names=data['names'],
                latex_names=data['latex_names'],
                fiducial=data['fiducial'],
            )

## Classes

`class FisherMatrix (values: Collection, names: Optional[Collection[str]] = None, latex_names: Optional[str] = None, fiducial: Optional[Collection[float]] = None)`

  Class for handling Fisher objects.

  ## Examples

  Specify a Fisher object with default names and fiducials:

      >>> fm = FisherMatrix(np.diag([5, 4]))

  The object has a friendly representation in the interactive session:

      >>> fm
      FisherMatrix(array([[5., 0.],
             [0., 4.]]), names=array(['p1', 'p2'], dtype=object), latex_names=array(['p1', 'p2'], dtype=object), fiducial=array([0., 0.]))

  The string representation is probably more readable:

      >>> print(fm)
      FisherMatrix([[5. 0.]
       [0. 4.]], names=['p1' 'p2'], latex_names=['p1' 'p2'], fiducial=[0. 0.])

  List the names:

      >>> fm.names
      array(['p1', 'p2'], dtype=object)

  List the values of the Fisher object:

      >>> fm.values
      array([[5., 0.],
             [0., 4.]])

  List the values of the fiducials:

      >>> fm.fiducial
      array([0., 0.])

  Names can be changed in bulk (ditto for fiducial and values; dimension must of course match the original):

      >>> fm = FisherMatrix(np.diag([5, 4]))
      >>> fm.names = ['x', 'y']
      >>> fm.latex_names = [r'$\mathbf{X}$', r'$\mathbf{Y}$']
      >>> fm
      FisherMatrix(array([[5., 0.],
             [0., 4.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

  We can get and set individual elements of the matrix using dict-like notation:

      >>> fm = FisherMatrix(np.diag([5, 4]), names=['x', 'y'])
      >>> fm['x', 'x']
      5.0

  The off-diagonal elements are automatically updated when using the setter:

      >>> fm = FisherMatrix(np.diag([5, 4]), names=['x', 'y'])
      >>> fm['x', 'y'] = -2
      >>> fm
      FisherMatrix(array([[ 5., -2.],
             [-2.,  4.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['x', 'y'], dtype=object), fiducial=array([0., 0.]))

  We can select submatrices by index:

      >>> fm = FisherMatrix(np.diag([1, 2, 3]), names=['x', 'y', 'z'])
      >>> fm[1:]
      FisherMatrix(array([[2., 0.],
             [0., 3.]]), names=array(['y', 'z'], dtype=object), latex_names=array(['y', 'z'], dtype=object), fiducial=array([0., 0.]))

  Fisher object with parameter names:

      >>> fm = FisherMatrix(np.diag([5, 4]), names=['x', 'y'], latex_names=['$\\mathbf{X}$', '$\\mathbf{Y}$'])
      >>> fm_with_names = FisherMatrix(np.diag([1, 2]), names=['x', 'y'])

  We can add Fisher objects (ordering of names is taken care of):

      >>> fm + fm_with_names # doctest: +SKIP
      FisherMatrix(array([[6., 0.],
             [0., 6.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

  We can also do element-wise multiplication or division:

      >>> fm * fm_with_names
      FisherMatrix(array([[5., 0.],
             [0., 8.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

  Furthermore, we can do matrix multiplication:

      >>> fm @ fm_with_names
      FisherMatrix(array([[5., 0.],
             [0., 8.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

  We can also perform standard matrix operations like the trace, eigenvalues, determinant:

      >>> fm.trace()
      9.0
      >>> fm.eigenvalues()
      array([4., 5.])
      >>> fm.determinant()
      19.999999999999996

  We can also take the matrix inverse:

      >>> fm.inverse()
      FisherMatrix(array([[0.2 , 0.  ],
             [0.  , 0.25]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

  We can drop parameters from the object:

      >>> fm.drop('x')
      FisherMatrix(array([[4.]]), names=array(['y'], dtype=object), latex_names=array(['$\\mathbf{Y}$'], dtype=object), fiducial=array([0.]))

  We can save it to a file (the returned value is the dictionary that was saved):

      >>> fm.to_file('example_matrix.json', overwrite=True)
      {'values': [[5.0, 0.0], [0.0, 4.0]], 'names': ['x', 'y'], 'latex_names': ['$\\mathbf{X}$', '$\\mathbf{Y}$'], 'fiducial': [0.0, 0.0]}

  Loading is performed by a class method `from_file`:

      >>> fm_new = FisherMatrix.from_file('example_matrix.json')

  Constructor for Fisher object.

  ## Parameters

  `values` :`array-like`
    The values of the Fisher object.
  `names` :`array-like iterable` of `str`, default `= None`
    The names of the parameters. If not specified, defaults to `p1, …, pn`.
  `latex_names` :`array-like iterable` of `str`, default `= None`
    The LaTeX names of the parameters. If not specified, defaults to `names`.
  `fiducial` :`array-like iterable` of `float`, default `= None`
    The fiducial values of the parameters. If not specified, default to 0 for all parameters.
  `metadata` :`Optional[dict]`, default `= None`
    any metadata associated with the Fisher object

  ## Raises

  - `ValueError` if the input array has the wrong dimensionality (not 2)
  - `ValueError` if the object is not square-like
  - `MismatchingSizeError` if the sizes of the array of names, values, LaTeX names and fiducials do not match

  ## Examples

  Specify a Fisher object without names:

      >>> FisherMatrix(np.diag([1, 2, 3]))
      FisherMatrix(array([[1., 0., 0.],
             [0., 2., 0.],
             [0., 0., 3.]]), names=array(['p1', 'p2', 'p3'], dtype=object), latex_names=array(['p1', 'p2', 'p3'], dtype=object), fiducial=array([0., 0., 0.]))

  Specify a Fisher object with names, LaTeX names, and fiducials:

      >>> FisherMatrix(np.diag([1, 2]), names=['alpha', 'beta'],
      ... latex_names=[r'$\alpha$', r'$\beta$'], fiducial=[-3, 2])
      FisherMatrix(array([[1., 0.],
             [0., 2.]]), names=array(['alpha', 'beta'], dtype=object), latex_names=array(['$\\alpha$', '$\\beta$'], dtype=object), fiducial=array([-3.,  2.]))

  Expand source code

      class FisherMatrix:
          r"""
          Class for handling Fisher objects.

          Examples
          --------
          Specify a Fisher object with default names and fiducials:
          >>> fm = FisherMatrix(np.diag([5, 4]))

          The object has a friendly representation in the interactive session:
          >>> fm
          FisherMatrix(array([[5., 0.],
                 [0., 4.]]), names=array(['p1', 'p2'], dtype=object), latex_names=array(['p1', 'p2'], dtype=object), fiducial=array([0., 0.]))

          The string representation is probably more readable:
          >>> print(fm)
          FisherMatrix([[5. 0.]
           [0. 4.]], names=['p1' 'p2'], latex_names=['p1' 'p2'], fiducial=[0. 0.])

          List the names:
          >>> fm.names
          array(['p1', 'p2'], dtype=object)

          List the values of the Fisher object:
          >>> fm.values
          array([[5., 0.],
                 [0., 4.]])

          List the values of the fiducials:
          >>> fm.fiducial
          array([0., 0.])

          Names can be changed in bulk (ditto for fiducial and values; dimension must of course match the original):
          >>> fm = FisherMatrix(np.diag([5, 4]))
          >>> fm.names = ['x', 'y']
          >>> fm.latex_names = [r'$\mathbf{X}$', r'$\mathbf{Y}$']
          >>> fm
          FisherMatrix(array([[5., 0.],
                 [0., 4.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

          We can get and set individual elements of the matrix using dict-like notation:
          >>> fm = FisherMatrix(np.diag([5, 4]), names=['x', 'y'])
          >>> fm['x', 'x']
          5.0

          The off-diagonal elements are automatically updated when using the setter:
          >>> fm = FisherMatrix(np.diag([5, 4]), names=['x', 'y'])
          >>> fm['x', 'y'] = -2
          >>> fm
          FisherMatrix(array([[ 5., -2.],
                 [-2.,  4.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['x', 'y'], dtype=object), fiducial=array([0., 0.]))

          We can select submatrices by index:
          >>> fm = FisherMatrix(np.diag([1, 2, 3]), names=['x', 'y', 'z'])
          >>> fm[1:]
          FisherMatrix(array([[2., 0.],
                 [0., 3.]]), names=array(['y', 'z'], dtype=object), latex_names=array(['y', 'z'], dtype=object), fiducial=array([0., 0.]))

          Fisher object with parameter names:
          >>> fm = FisherMatrix(np.diag([5, 4]), names=['x', 'y'], latex_names=['$\\mathbf{X}$', '$\\mathbf{Y}$'])
          >>> fm_with_names = FisherMatrix(np.diag([1, 2]), names=['x', 'y'])

          We can add Fisher objects (ordering of names is taken care of):
          >>> fm + fm_with_names # doctest: +SKIP
          FisherMatrix(array([[6., 0.],
                 [0., 6.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

          We can also do element-wise multiplication or division:
          >>> fm * fm_with_names
          FisherMatrix(array([[5., 0.],
                 [0., 8.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

          Furthermore, we can do matrix multiplication:
          >>> fm @ fm_with_names
          FisherMatrix(array([[5., 0.],
                 [0., 8.]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

          We can also perform standard matrix operations like the trace, eigenvalues, determinant:
          >>> fm.trace()
          9.0
          >>> fm.eigenvalues()
          array([4., 5.])
          >>> fm.determinant()
          19.999999999999996

          We can also take the matrix inverse:
          >>> fm.inverse()
          FisherMatrix(array([[0.2 , 0.  ],
                 [0.  , 0.25]]), names=array(['x', 'y'], dtype=object), latex_names=array(['$\\mathbf{X}$', '$\\mathbf{Y}$'], dtype=object), fiducial=array([0., 0.]))

          We can drop parameters from the object:
          >>> fm.drop('x')
          FisherMatrix(array([[4.]]), names=array(['y'], dtype=object), latex_names=array(['$\\mathbf{Y}$'], dtype=object), fiducial=array([0.]))

          We can save it to a file (the returned value is the dictionary that was saved):
          >>> fm.to_file('example_matrix.json', overwrite=True)
          {'values': [[5.0, 0.0], [0.0, 4.0]], 'names': ['x', 'y'], 'latex_names': ['$\\mathbf{X}$', '$\\mathbf{Y}$'], 'fiducial': [0.0, 0.0]}

          Loading is performed by a class method `from_file`:
          >>> fm_new = FisherMatrix.from_file('example_matrix.json')
          """

          def __init__(
              self,
              values : Collection,
              names : Optional[Collection[str]] = None,
              latex_names : Optional[str] = None,
              fiducial : Optional[Collection[float]] = None,
          ):
              r"""
              Constructor for Fisher object.

              Parameters
              ----------
              values : array-like
                  The values of the Fisher object.

              names : array-like iterable of `str`, default = None
                  The names of the parameters.
                  If not specified, defaults to `p1, ..., pn`.

              latex_names : array-like iterable of `str`, default = None
                  The LaTeX names of the parameters.
                  If not specified, defaults to `names`.

              fiducial : array-like iterable of `float`, default = None
                  The fiducial values of the parameters. If not specified, default to
                  0 for all parameters.

              metadata : Optional[dict], default = None
                  any metadata associated with the Fisher object

              Raises
              ------
              * `ValueError` if the input array has the wrong dimensionality (not 2)
              * `ValueError` if the object is not square-like
              * `MismatchingSizeError` if the sizes of the array of names, values, LaTeX names and fiducials do not match

              Examples
              --------
              Specify a Fisher object without names:
              >>> FisherMatrix(np.diag([1, 2, 3]))
              FisherMatrix(array([[1., 0., 0.],
                     [0., 2., 0.],
                     [0., 0., 3.]]), names=array(['p1', 'p2', 'p3'], dtype=object), latex_names=array(['p1', 'p2', 'p3'], dtype=object), fiducial=array([0., 0., 0.]))

              Specify a Fisher object with names, LaTeX names, and fiducials:
              >>> FisherMatrix(np.diag([1, 2]), names=['alpha', 'beta'],
              ... latex_names=[r'$\alpha$', r'$\beta$'], fiducial=[-3, 2])
              FisherMatrix(array([[1., 0.],
                     [0., 2.]]), names=array(['alpha', 'beta'], dtype=object), latex_names=array(['$\\alpha$', '$\\beta$'], dtype=object), fiducial=array([-3.,  2.]))
              """

              if np.ndim(values) != 2:
                  raise ValueError(
                      f'The object {values} is not 2-dimensional'
                  )

              if not is_square(values):
                  raise ValueError(
                      f'The object {values} is not square-like'
                  )

              # try to treat it as an array-like object
              self._values = np.array(values, dtype=float)

              self._size = np.shape(self._values)[0]
              self._ndim = np.ndim(self._values)

              # setting the fiducial
              if fiducial is None:
                  self._fiducial = np.zeros(self._size, dtype=float)
              else:
                  self._fiducial = np.array(fiducial, dtype=float)

              # setting the names
              if names is None:
                  self._names = make_default_names(self._size)
              else:
                  # check they're unique
                  if len(set(names)) != len(names):
                      raise MismatchingSizeError(set(names), names)

                  self._names = np.array(names, dtype=object)

              # setting the pretty names (LaTeX)
              if latex_names is None:
                  self._latex_names = copy.deepcopy(self._names)
              else:
                  self._latex_names = np.array(latex_names, dtype=object)

              # check sizes of inputs
              if not all(
                  len(_) == self._size \
                  for _ in (self._names, self._fiducial, self._latex_names)
              ):
                  raise MismatchingSizeError(
                      self._values[0],
                      self._names,
                      self._fiducial,
                      self._latex_names,
                  )

          def rename(
              self,
              names : Mapping[str, Union[str, abc.Mapping]],
              ignore_errors : bool = False,
          ) -> FisherMatrix:
              """
              Returns a Fisher object with new names.

              Parameters
              ----------
              names : Mapping[str, Union[str, abc.Mapping]]
                  a mapping (dictionary-like object) between the old names and the
                  new ones. The values it maps to can either be a string (the new name), or a dict
                  with keys `name`, `latex_name`, and `fiducial` (only `name` is mandatory).

              ignore_errors : bool, default = False
                  if set to True, will not raise an error if a parameter doesn't exist

              Returns
              -------
              Instance of `FisherMatrix`.

              Examples
              --------
              >>> m = FisherMatrix(np.diag([1, 2, 3]))
              >>> m.rename({'p1' : 'a', 'p2' : dict(name='b', latex_name='$b$', fiducial=2)})
              FisherMatrix(array([[1., 0., 0.],
                     [0., 2., 0.],
                     [0., 0., 3.]]), names=array(['a', 'b', 'p3'], dtype=object), latex_names=array(['a', '$b$', 'p3'], dtype=object), fiducial=array([0., 2., 0.]))
              """
              # check uniqueness and size
              if len(set(names)) != len(names):
                  raise MismatchingSizeError(set(names), names)

              if not ignore_errors:
                  for name in names:
                      if name not in self.names:
                          raise ParameterNotFoundError(name, self.names)

              names_new = copy.deepcopy(self.names)
              latex_names_new = copy.deepcopy(self.latex_names)
              fiducial_new = copy.deepcopy(self.fiducial)

              for name, value in names.items():
                  index = np.where(names_new == name)
                  # it's a mapping
                  if isinstance(value, abc.Mapping):
                      value = _process_fisher_mapping(value)
                      latex_names_new[index] = value['latex_name']
                      fiducial_new[index] = value['fiducial']
                      names_new[index] = value['name']
                  # otherwise, it's a string
                  else:
                      names_new[index] = value
                      latex_names_new[index] = value

              return self.__class__(
                  self.values,
                  names=names_new,
                  latex_names=latex_names_new,
                  fiducial=fiducial_new,
              )

          def _repr_html_(self):
              """
              Representation of the Fisher object suitable for viewing in Jupyter
              notebook environments.
              """
              header_matrix = '<thead><tr><th></th>' + (
                  '<th>{}</th>' * len(self)
              ).format(*self.latex_names) + '</tr></thead>'

              body_matrix = '<tbody>'
              for index, name in enumerate(self.latex_names):
                  body_matrix += f'<tr><th>{name}</th>' + (
                      '<td>{:.3f}</td>' * len(self)
                  ).format(*(self.values[:, index])) + '</tr>'
              body_matrix += '</tbody>'

              html_matrix = f'<table>{header_matrix}{body_matrix}</table>'

              return html_matrix

          def __repr__(self):
              """
              Representation of the Fisher object for non-Jupyter interfaces.
              """
              return 'FisherMatrix(' \
                  f'{repr(self.values)}, ' \
                  f'names={repr(self.names)}, ' \
                  f'latex_names={repr(self.latex_names)}, ' \
                  f'fiducial={repr(self.fiducial)})'

          def __str__(self):
              """
              String representation of the Fisher object.
              """
              return 'FisherMatrix(' \
                  f'{self.values}, ' \
                  f'names={self.names}, ' \
                  f'latex_names={self.latex_names}, ' \
                  f'fiducial={self.fiducial})'

          def __getitem__(
              self,
              keys : Union[Tuple[str], slice],
          ):
              """
              Implements access to elements in the Fisher object.
              Has support for slicing.
              """
              # the object can be sliced
              if isinstance(keys, slice):
                  start, stop, step = keys.indices(len(self))
                  indices = (slice(start, stop, step),) * self.ndim
                  sl = slice(start, stop, step)
                  values = self.values[indices]
                  names = self.names[sl]
                  latex_names = self.latex_names[sl]
                  fiducial = self.fiducial[sl]

                  return self.__class__(
                      values,
                      names=names,
                      latex_names=latex_names,
                      fiducial=fiducial,
                  )

              try:
                  _ = iter(keys)
              except TypeError as err:
                  raise TypeError(err) from err

              # the keys can be a tuple
              if isinstance(keys, tuple):
                  if len(keys) != self.ndim:
                      raise ValueError(
                          f'Expected {self.ndim} arguments, got {len(keys)}'
                      )

                  # error checking
                  for key in keys:
                      if key not in self.names:
                          raise ParameterNotFoundError(key, self.names)

                  indices = tuple(np.where(self.names == key) for key in keys)

              # otherwise, it's some generic object
              else:
                  if keys not in self.names:
                      raise ParameterNotFoundError(keys, self.names)

                  indices = (np.where(self.names == keys),)

              return self._values[indices][0, 0]

          def __setitem__(
              self,
              keys : Tuple[str],
              value : float,
          ):
              """
              Implements setting of elements in the Fisher object.
              Does not support slicing.
              """
              try:
                  _ = iter(keys)
              except TypeError as err:
                  raise err

              if len(keys) != self.ndim:
                  raise ValueError(f'Got length {len(keys)}')

              # automatically raises a value error
              indices = tuple(np.where(self.names == key) for key in keys)

              temp_data = copy.deepcopy(self._values)

              if not all(index == indices[0] for index in indices):
                  for permutation in list(permutations(indices)):
                      # update all symmetric parts
                      temp_data[permutation] = value
              else:
                  temp_data[indices] = value

              self._values = copy.deepcopy(temp_data)

          def is_valid(self):
              """
              Checks whether the values make a valid Fisher object.
              A (square) matrix is a Fisher matrix if it satisifies the following two
              criteria:

              * it is symmetric
              * it is positive semi-definite

              Returns
              -------
              `True` or `False`

              Examples
              --------
              >>> m = FisherMatrix(np.diag([1, 2, 3]))
              >>> m.is_valid()
              True
              >>> FisherMatrix(np.diag([-1, 3])).is_valid()
              False
              """
              return \
                  is_symmetric(self.values) and \
                  is_positive_semidefinite(self.values)

          def imshow(
              self,
              colorbar : bool = False,
              show_values : bool = False,
              normalized : bool = False,
              colorbar_space : float = 0.02,
              colorbar_width : float = 0.05,
              colorbar_orientation : str = 'vertical',
              rc : abc.Mapping = get_default_rcparams(),
              **kwargs,
          ):
              """
              Returns the image of the Fisher object.
              """
              # TODO should only work with 2D data.
              with plt.rc_context(rc):
                  fig, ax = plt.subplots(figsize=(len(self), len(self)))
                  img = ax.imshow(
                      self.values,
                      interpolation='none',
                      norm=colors.CenteredNorm(),
                      **kwargs,
                  )

                  ax.set_xticks(np.arange(len(self)))
                  ax.set_yticks(np.arange(len(self)))
                  ax.set_xticklabels(self.latex_names)
                  ax.set_yticklabels(self.latex_names)

                  if colorbar:
                      allowed_orientations = ('vertical', 'horizontal')
                      if colorbar_orientation not in allowed_orientations:
                          raise ValueError(
                              f'\'{colorbar_orientation}\' is not one of: {allowed_orientations}'
                          )

                      if colorbar_orientation == 'vertical':
                          cax = fig.add_axes(
                              [ax.get_position().x1 + colorbar_space,
                              ax.get_position().y0 * 0.997,
                              colorbar_width,
                              ax.get_position().height]
                          )
                          fig.colorbar(img, cax=cax, orientation=colorbar_orientation)
                          cax.set_yticklabels(
                              [f'${float_to_latex(_)}$' for _ in cax.get_yticks()]
                          )
                      else:
                          cax = fig.add_axes(
                              [ax.get_position().x0,
                              ax.get_position().y1 + colorbar_space,
                              ax.get_position().width,
                              colorbar_width]
                          )
                          fig.colorbar(img, cax=cax, orientation=colorbar_orientation)
                          cax.xaxis.set_ticks_position('top')
                          cax.set_xticklabels(
                              [f'${float_to_latex(_)}$' for _ in cax.get_xticks()]
                          )

                  # whether or not we want to display the actual values inside the
                  # matrix
                  if show_values:
                      mid_coords = np.arange(len(self))
                      for index1, index2 in product(range(len(self)), range(len(self))):
                          x = mid_coords[index1]
                          y = mid_coords[index2]
                          value = self.values[index1, index2] / \
                              np.sqrt(
                                  self.values[index1, index1] \
                                 *self.values[index2, index2]
                              ) if normalized \
                              else self.values[index1, index2]
                          text = ax.text(
                                  x, y, f'${float_to_latex(value)}$',
                                  ha='center', va='center', color='white',
                          )
                          text.set_path_effects(
                              [Stroke(linewidth=1, foreground="black"), Normal()]
                          )

              return fig

          def sort(
              self,
              **kwargs,
          ) -> FisherMatrix:
              """
              Sorts the Fisher object by name according to some criterion.

              Parameters
              ----------
              **kwargs
                  all of the other keyword arguments for the Python builtin `sorted`.
                  If none are specified, will sort according to the names of the parameters.
                  In the special case that the value of the keyword `key` is set to
                  either 'fiducial' or 'latex_names', it will sort according to those.
                  In the second special case that the value of the keyword `key` is
                  set to an array of integers of equal size as the Fisher object, sorts them
                  according to those instead.

              Returns
              -------
              Instance of `FisherMatrix`.

              Examples
              --------
              >>> m = FisherMatrix(np.diag([3, 1, 2]), names=list('sdf'),
              ... latex_names=['hjkl', 'qwe', 'll'], fiducial=[8, 7, 3])
              >>> m.sort(key='fiducial')
              FisherMatrix(array([[2., 0., 0.],
                     [0., 1., 0.],
                     [0., 0., 3.]]), names=array(['f', 'd', 's'], dtype=object), latex_names=array(['ll', 'qwe', 'hjkl'], dtype=object), fiducial=array([3., 7., 8.]))
              >>> m.sort(key='latex_names')
              FisherMatrix(array([[3., 0., 0.],
                     [0., 2., 0.],
                     [0., 0., 1.]]), names=array(['s', 'f', 'd'], dtype=object), latex_names=array(['hjkl', 'll', 'qwe'], dtype=object), fiducial=array([8., 3., 7.]))
              """
              allowed_keys = ('fiducial', 'latex_names')
              # an integer index
              if 'key' in kwargs and all(hasattr(_, '__index__') for _ in kwargs['key']):
                  index = np.array(kwargs['key'], dtype=int)
                  names = self.names[index]
              # either 'fiducial' or 'latex_names'
              elif 'key' in kwargs and kwargs['key'] in allowed_keys:
                  index = np.argsort(getattr(self, kwargs['key']))
                  if 'reversed' in kwargs and kwargs['reversed'] is True:
                      index = np.flip(index)
                  names = self.names[index]
              # something that can be passed to `sorted`
              else:
                  names = sorted(self.names, **kwargs)
                  index = get_index_of_other_array(self.names, names)

              latex_names = self.latex_names[index]
              fiducial = self.fiducial[index]
              values = reindex_array(self.values, index)

              return self.__class__(
                  values,
                  names=names,
                  latex_names=latex_names,
                  fiducial=fiducial,
              )

          def __eq__(self, other):
              """
              The equality operator.
              Returns `True` if the operands have the following properties:

              * are instances of FisherMatrix
              * have same names (potentially shuffled)
              * have same dimensionality
              * have same fiducials (potentially shuffled)
              * have same values (potentially shuffled)
              """
              if set(self.names) != set(other.names):
                  return False

              # index for re-shuffling parameters
              index = get_index_of_other_array(self.names, other.names)

              return isinstance(other, self.__class__) \
              and self.ndim == other.ndim \
              and len(self) == len(other) \
              and set(self.names) == set(other.names) \
              and np.allclose(
                  self.fiducial,
                  other.fiducial[index],
              ) \
              and np.allclose(
                  self.values,
                  reindex_array(other.values, index),
              )

          @property
          def ndim(self):
              """
              Returns the number of dimensions of the Fisher object (for now always 2).
              """
              return np.ndim(self._values)

          def __len__(self):
              """
              Returns the number of parameters in the Fisher object.
              """
              return self._size

          @property
          def values(self):
              """
              Returns the values in the Fisher object as a numpy array.
              """
              return self._values

          @values.setter
          def values(
              self,
              value,
          ):
              """
              Setter for the values of the Fisher object.
              """
              if self.ndim != np.ndim(value):
                  raise ValueError(
                      f'The dimensionality of the matrices do not match: {self.ndim} and {np.ndim(value)}'
                  )
              if len(self) != len(value):
                  raise MismatchingSizeError(self, value)

              if not is_square(value):
                  raise ValueError(
                      f'{value} is not a square object'
                  )

              self._values = np.array(value, dtype=float)

          def is_diagonal(self):
              """
              Checks whether the Fisher matrix is diagonal.

              Returns
              -------
              `True` or `False`
              """
              return np.all(self.values == np.diag(np.diagonal(self.values)))

          def diagonal(self, **kwargs):
              """
              Returns the diagonal elements of the Fisher object as a numpy array.

              Returns
              -------
              array-like of floats
              """
              return np.diag(self.values, **kwargs)

          def drop(
              self,
              *names : str,
              invert : bool = False,
              ignore_errors : bool = False,
          ) -> FisherMatrix:
              """
              Removes parameters from the Fisher object.

              Parameters
              ----------
              names : string-like
                  the names of the parameters to drop.
                  If passing a list or a tuple, make sure to unpack it using the
                  asterisk (*).

              invert : bool, default = False
                  whether to drop all the parameters NOT in names (the complement)

              ignore_errors : bool, default = False
                  should non-existing parameters be ignored

              Returns
              -------
              Instance of `FisherMatrix`

              Examples
              --------
              >>> m = FisherMatrix(np.diag([1, 2, 3]))
              >>> m.drop('p1', 'p3')
              FisherMatrix(array([[2.]]), names=array(['p2'], dtype=object), latex_names=array(['p2'], dtype=object), fiducial=array([0.]))
              """
              if not ignore_errors and not set(names).issubset(set(self.names)):
                  raise ValueError(
                      f'The names ({list(names)}) are not a strict subset ' \
                      f'of the parameter names in the Fisher object ({self.names}); ' \
                      'you can pass `ignore_errors=True` to ignore this error'
                  )

              if ignore_errors:
                  names = np.array([name for name in names if name in self.names])

              if invert is True:
                  names = set(names) ^ set(self.names)

              # TODO should we remove this?
              if set(names) == set(self.names):
                  raise ValueError('Unable to remove all parameters')

              index = [np.array(np.where(self.names == name), dtype=int) for name in names]

              values = self.values
              for dim in range(self.ndim):
                  values = np.delete(
                      values,
                      index,
                      axis=dim,
                  )

              fiducial = np.delete(self.fiducial, index)
              latex_names = np.delete(self.latex_names, index)
              names = np.delete(self.names, index)

              return self.__class__(
                  values,
                  names=names,
                  latex_names=latex_names,
                  fiducial=fiducial,
              )

          def trace(
              self,
              **kwargs,
          ):
              """
              Returns the trace of the Fisher object as a numpy array.
              """
              return np.trace(self.values, **kwargs)

          def eigenvalues(self):
              """
              Returns the eigenvalues of the Fisher object as a numpy array.
              """
              return np.linalg.eigvalsh(self.values)

          def eigenvectors(self):
              """
              Returns the right eigenvectors of the Fisher object as a numpy array.
              """
              return np.linalg.eigh(self.values)[-1]

          def condition_number(self):
              r"""
              Returns the condition number of the matrix with respect to the \(L^2\) norm.

              Examples
              --------
              >>> fm = FisherMatrix(np.diag([1, 2, 3]))
              >>> fm.condition_number()
              3.0
              """
              values = np.abs(self.eigenvalues())
              return np.max(values) / np.min(values)

          def inverse(self) -> FisherMatrix:
              """
              Returns the inverse of the Fisher matrix.

              Returns
              -------
              Instance of `FisherMatrix`

              Examples
              --------
              >>> fm = FisherMatrix(np.diag([1, 2, 5]))
              >>> fm.inverse()
              FisherMatrix(array([[1. , 0. , 0. ],
                     [0. , 0.5, 0. ],
                     [0. , 0. , 0.2]]), names=array(['p1', 'p2', 'p3'], dtype=object), latex_names=array(['p1', 'p2', 'p3'], dtype=object), fiducial=array([0., 0., 0.]))
              """
              # inverse satisfies properties of Fisher matrix, see:
              # https://math.stackexchange.com/a/26200
              return self.__class__(
                  np.linalg.inv(self.values),
                  names=self.names,
                  latex_names=self.latex_names,
                  fiducial=self.fiducial,
              )

          def determinant(self):
              """
              Returns the determinant of the matrix.

              Returns
              -------
              float
              """
              return np.linalg.det(self.values)

          def constraints(
              self,
              name : Optional[str] = None,
              marginalized : bool = True,
              sigma : Optional[float] = None,
              p : Optional[float] = None,
          ):
              r"""
              Returns the constraints on a parameter as a float, or on all of them
              as a numpy array if `name` is not specified.

              Parameters
              ----------
              name : Optional[str] = None
                  the name of the parameter for which we we want the constraints

              marginalized : bool, default = True
                  whether we want the marginalized or the unmarginalized
                  constraints.

              sigma : Optional[float], default = None
                  how many sigmas away.

              p : Optional[float], default = None
                  the confidence interval (p-value).
                  The relationship between `p` and `sigma` is defined via:
                  \[
                      p(\sigma) = \int\limits_{\mu - \sigma}^{\mu + \sigma}
                                  f(x, \mu, 1)\, \mathrm{d}x
                                = \mathrm{Erf}(\sigma / \sqrt{2})
                  \]
                  and therefore the inverse is simply:
                  \[
                      \sigma(p) = \sqrt{2}\, \mathrm{Erf}^{-1}(p)
                  \]
                  The values of `p` corresponding to 1, 2, 3 `sigma` are roughly
                  0.683, 0.954, and 0.997, respectively.

              Returns
              -------
              array-like of floats or single float

              Notes
              -----
              The user should specify either `sigma` or `p`, but not both
              simultaneously.
              If neither are specified, defaults to `sigma=1`.

              Examples
              --------
              Get (marginalized by default) constraints for all parameters:
              >>> m = FisherMatrix([[3, -2], [-2, 5]])
              >>> m.constraints()
              array([0.67419986, 0.52223297])

              Get the unmarginalized constraints instead:
              >>> m.constraints(marginalized=False)
              array([0.57735027, 0.4472136 ])

              Get the unmarginalized constraints for a single parameter:
              >>> m.constraints('p1', marginalized=False)
              array([0.57735027])

              Get the marginalized constraints for a single parameter:
              >>> m.constraints('p1', p=0.682689) # p-value roughly equal to 1 sigma
              array([0.67419918])
              """
              if sigma is not None and p is not None:
                  raise ValueError(
                      'Cannot specify both `p` and `sigma` simultaneously'
                  )

              if p is not None:
                  if not 0 < p < 1:
                      raise ValueError(
                          f'The value of `p` {p} is outside of the allowed range (0, 1)'
                      )
                  sigma = np.sqrt(2) * erfinv(p)
              elif sigma is None:
                  sigma = 1

              if sigma <= 0:
                  raise ValueError(
                      f'The value of `sigma` {sigma} is outside of the allowed range (0, infinify)'
                  )

              if marginalized:
                  inv = self.inverse()
                  result = np.sqrt(np.diag(inv.values)) * sigma
              else:
                  result = 1. / np.sqrt(np.diag(self.values)) * sigma

              if name is not None:
                  if name in self.names:
                      return result[np.where(self.names == name)]
                  raise ParameterNotFoundError(name, self.names)

              return result

          @property
          def fiducial(self):
              """
              Returns the fiducial values of the Fisher object as a numpy array.
              """
              return self._fiducial

          @fiducial.setter
          def fiducial(
              self,
              value,
          ):
              """
              The setter for the fiducial values of the Fisher object.
              """
              if len(value) != len(self):
                  raise MismatchingSizeError(value, self)
              try:
                  self._fiducial = np.array([float(_) for _ in value])
              except TypeError as err:
                  raise TypeError(err) from err

          @property
          def names(self):
              """
              Returns the parameter names of the Fisher object.
              """
              return self._names

          @names.setter
          def names(
              self,
              value,
          ):
              """
              The bulk setter for the names.
              The length of the names must be the same as the one of the original
              object.
              """
              if len(set(value)) != len(self):
                  raise MismatchingSizeError(set(value), self)
              self._names = np.array(value, dtype=object)

          @property
          def latex_names(self):
              """
              Returns the LaTeX names of the parameters of the Fisher object.
              """
              return self._latex_names

          @latex_names.setter
          def latex_names(
              self,
              value,
          ):
              """
              The bulk setter for the LaTeX names.
              The length of the names must be the same as the one of the original
              object.
              """
              if len(set(value)) != len(self):
                  raise MismatchingSizeError(set(value), self)
              self._latex_names = np.array(value, dtype=object)

          def __add__(
              self,
              other : FisherMatrix,
          ) -> FisherMatrix:
              """
              Returns the result of adding two Fisher objects.
              """
              try:
                  other = float(other)
              except TypeError as err:
                  # make sure the dimensions match
                  if other.ndim != self.ndim:
                      raise ValueError(
                          f'The dimensions of the objects do not match: {other.ndim} and {self.ndim}'
                      ) from err

                  # make sure they have the right parameters
                  if set(other.names) != set(self.names):
                      raise ValueError(
                          f'Incompatible parameter names: {other.names} and {self.names}'
                      ) from err

                  index = get_index_of_other_array(self.names, other.names)

                  # make sure the fiducials match
                  fiducial = other.fiducial[index]

                  if not np.allclose(fiducial, self.fiducial):
                      raise ValueError(
                          f'Incompatible fiducial values: {fiducial} and {self.fiducial}'
                      ) from err

                  values = self.values + reindex_array(other.values, index)

                  return self.__class__(
                      values,
                      names=self.names,
                      latex_names=self.latex_names,
                      fiducial=self.fiducial,
                  )

              return self.__class__(
                  self.values + other,
                  names=self.names,
                  latex_names=self.latex_names,
                  fiducial=self.fiducial,
              )

          def __radd__(
              self,
              other : Union[FisherMatrix, float],
          ) -> FisherMatrix:
              """
              Addition when the Fisher object is the right operand.
              """
              return self.__add__(other)

          def __neg__(self) -> FisherMatrix:
              """
              Returns the negation of the Fisher object.
              """
              return self.__class__(
                  -self.values,
                  names=self.names,
                  latex_names=self.latex_names,
                  fiducial=self.fiducial,
              )

          def __sub__(
              self,
              other : Union[FisherMatrix, float],
          ) -> FisherMatrix:
              """
              Returns the result of subtracting two Fisher objects.
              """
              return self.__add__(-other)

          def __rsub__(
              self,
              other : Union[FisherMatrix, float],
          ) -> FisherMatrix:
              """
              Subtraction when the Fisher object is the right operand.
              """
              # this will never be called if we subtract two FisherMatrix instances,
              # so we just need to handle floats
              return -self.__add__(-other)

          def __array_ufunc__(
              self,
              ufunc,
              method,
              *inputs,
              **kwargs,
          ):
              """
              Handles numpy's universal functions.
              For a complete list and explanation, see:
              https://numpy.org/doc/stable/reference/ufuncs.html
              """
              if method == '__call__':
                  scalars = []
                  for i in inputs:
                      if isinstance(i, Number):
                          scalars.append(i)
                      elif isinstance(i, self.__class__):
                          scalars.append(i.values)
                      else:
                          return NotImplemented

                  names = [_.names for _ in inputs if isinstance(_, self.__class__)]

                  if not np.all([set(names[0]) == set(_) for _ in names]):
                      raise ValueError(
                          'Mismatching names'
                      )

                  # make sure the names have the same _ordering_
                  for index, _ in enumerate(inputs):
                      if isinstance(_, self.__class__):
                          scalars[index] = _.sort(
                              key=get_index_of_other_array(
                                  names[0],
                                  _.names,
                              )
                          ).values

                  return self.__class__(
                      ufunc(*scalars, **kwargs),
                      names=self.names,
                      latex_names=self.latex_names,
                      fiducial=self.fiducial,
                  )

              return NotImplemented

          def __pow__(
              self,
              other : float,
          ) -> FisherMatrix:
              """
              Raises the Fisher object to some power.
              """
              return self.__class__(
                  np.power(self.values, other),
                  names=self.names,
                  latex_names=self.latex_names,
                  fiducial=self.fiducial,
              )

          def __matmul__(
              self,
              other : FisherMatrix,
          ):
              """
              Matrix multiplies two Fisher objects.
              """
              # make sure the dimensions match
              if other.ndim != self.ndim:
                  raise ValueError(
                      f'The dimensions of the objects do not match: {other.ndim} and {self.ndim}'
                  )

              # make sure they have the right parameters
              if set(other.names) != set(self.names):
                  raise ValueError(
                      f'Incompatible parameter names: {other.names} and {self.names}'
                  )

              index = get_index_of_other_array(self.names, other.names)

              # make sure the fiducials match
              fiducial = other.fiducial[index]

              if not np.allclose(fiducial, self.fiducial):
                  raise ValueError(
                      f'Incompatible fiducial values: {fiducial} and {self.fiducial}'
                  )

              values = self.values @ reindex_array(other.values, index)

              return self.__class__(
                  values,
                  names=self.names,
                  latex_names=self.latex_names,
                  fiducial=self.fiducial,
              )

          def __truediv__(
              self,
              other : Union[FisherMatrix, float, int],
          ) -> FisherMatrix:
              """
              Returns the result of dividing a Fisher object by a number, or another
              Fisher object (element-wise).
              """
              # we can only divide two objects if they have the same dimensions and sizes
              try:
                  other = float(other)
              except TypeError as err:
                  # maybe it's a FisherMatrix
                  # make sure they have the right parameters
                  if set(other.names) != set(self.names):
                      raise ValueError(err) from err

                  index = get_index_of_other_array(self.names, other.names)

                  # make sure the fiducials match
                  fiducial = other.fiducial[index]

                  if not np.allclose(fiducial, self.fiducial):
                      raise ValueError(err) from err

                  if other.ndim == self.ndim:
                      values = self.values / reindex_array(other.values, index)
                  else:
                      raise TypeError(err) from err
              else:
                  values = self.values / other

              return self.__class__(
                  values,
                  names=self.names,
                  latex_names=self.latex_names,
                  fiducial=self.fiducial,
              )

          def __mul__(
              self,
              other : Union[FisherMatrix, float, int],
          ) -> FisherMatrix:
              """
              Returns the result of multiplying a Fisher object by a number, or
              another Fisher object (element-wise).
              """
              # we can only multiply two objects if they have the same dimensions and sizes
              try:
                  other = float(other)
              except TypeError as err:
                  # maybe it's a FisherMatrix
                  # make sure they have the right parameters
                  if set(other.names) != set(self.names):
                      raise ValueError(err) from err

                  index = get_index_of_other_array(self.names, other.names)

                  # make sure the fiducials match
                  fiducial = other.fiducial[index]

                  if not np.allclose(fiducial, self.fiducial):
                      raise ValueError(err) from err

                  if other.ndim == self.ndim:
                      values = self.values * reindex_array(other.values, index)
                  else:
                      raise TypeError(err) from err
              else:
                  values = self.values * other

              return self.__class__(
                  values,
                  names=self.names,
                  latex_names=self.latex_names,
                  fiducial=self.fiducial,
              )

          def __rmul__(
              self,
              other : Union[float, int],
          ) -> FisherMatrix:
              """
              Returns the result of multiplying a number by a Fisher object, or
              another Fisher object (element-wise).
              """
              return self.__mul__(other)

          def reparametrize(
              self,
              jacobian : Collection,
              names : Optional[Collection[str]] = None,
              latex_names : Optional[Collection[str]] = None,
              fiducial : Optional[Collection[float]] = None,
          ) -> FisherMatrix:
              """
              Returns a new Fisher object with parameters `names`, which are
              related to the old ones via the transformation `jacobian`.
              See the [Wikipedia article](https://en.wikipedia.org/w/index.php?title=Fisher_information&oldid=1063384000#Reparametrization) for more information.

              Parameters
              ----------
              transformation : array-like
                  the Jacobian of the transformation

              names : array-like, default = None
                  list of new names for the Fisher object. If None, uses the old
                  names.

              latex_names: array-like, default = None
                  list of new LaTeX names for the Fisher object. If None, and
                  `names` is set, uses those instead, otherwise uses the old LaTeX names.

              fiducial : array-like, default = None
                  the new values of the fiducial. If not set, defaults to old values.

              Returns
              -------
              Instance of `FisherMatrix`.

              Examples
              --------
              >>> fm = FisherMatrix(np.diag([1, 2]))
              >>> jac = [[1, 4], [3, 2]]
              >>> fm.reparametrize(jac, names=['a', 'b'])
              FisherMatrix(array([[19., 16.],
                     [16., 24.]]), names=array(['a', 'b'], dtype=object), latex_names=array(['a', 'b'], dtype=object), fiducial=array([0., 0.]))
              """
              values = np.transpose(jacobian) @ self.values @ jacobian

              if names is not None:
                  if len(set(names)) != np.shape(jacobian)[-1]:
                      raise MismatchingSizeError(names)
                  if latex_names is not None:
                      if len(set(latex_names)) != np.shape(jacobian)[-1]:
                          raise MismatchingSizeError(latex_names)
                  else:
                      latex_names = copy.deepcopy(names)
              else:
                  # we don't transform the names
                  names = copy.deepcopy(self.names)
                  latex_names = copy.deepcopy(self.latex_names)

              if fiducial is not None:
                  if len(fiducial) != np.shape(jacobian)[-1]:
                      raise MismatchingSizeError(fiducial)
              else:
                  fiducial = copy.deepcopy(self.fiducial)

              return self.__class__(
                  values,
                  names=names,
                  latex_names=latex_names,
                  fiducial=fiducial,
              )

          def to_file(
              self,
              path : str,
              *args : str,
              metadata : dict = {},
              overwrite : bool = False,
          ):
              r"""
              Saves the Fisher object to a file.
              The format used is a simple JSON file, containing at least the values of the
              Fisher object, the names of the parameters, the LaTeX names, and the
              fiducial values.

              Parameters
              ----------
              path : str
                  the path to save the data to.

              args : str
                  whatever other information about the object needs to be saved.
                  Needs to be one of the following: `is_valid`, `eigenvalues`,
                  `eigenvectors`, `trace`, `determinant`, or `constraints` (by
                  default, the 1$\sigma$ marginalized constraints).

              metadata : dict, default = {}
                  any metadata that should be associated to the object saved

              overwrite : bool, default = False
                  whether to overwrite the file if it exists

              Returns
              -------
              The dictionary that was saved.

              Examples
              --------
              >>> fm = FisherMatrix(np.diag([1, 2]), names=['a', 'b'], latex_names=[r'$\mathbf{A}$', r'$\mathbf{B}$'])
              >>> fm.to_file('example_matrix.json', overwrite=True)
              {'values': [[1.0, 0.0], [0.0, 2.0]], 'names': ['a', 'b'], 'latex_names': ['$\\mathbf{A}$', '$\\mathbf{B}$'], 'fiducial': [0.0, 0.0]}
              >>> fm_read = FisherMatrix.from_file('example_matrix.json') # convenience function for reading it
              >>> fm == fm_read # verify it's the same object
              True
              """
              data = {
                  'values' : self.values.tolist(),
                  'names' : self.names.tolist(),
                  'latex_names' : self.latex_names.tolist(),
                  'fiducial' : self.fiducial.tolist(),
              }

              allowed_metadata = {
                  'is_valid' : bool,
                  'eigenvalues' : np.array,
                  'eigenvectors' : np.array,
                  'trace' : float,
                  'determinant' : float,
                  'constraints' : np.array,
              }

              for arg in args:
                  if arg not in allowed_metadata:
                      raise ValueError(
                          f'name {arg} is not one of {list(allowed_metadata.keys())}'
                      )

              for arg in metadata:
                  if arg in data:
                      raise ValueError(
                          f'name {arg} cannot be one of {list(data.keys())}'
                      )

              data = {
                  **data,
                  **{arg : jsonify(getattr(self, arg)()) for arg in args},
                  **metadata,
              }

              if os.path.exists(path) and not overwrite:
                  raise FileExistsError(
                      f'The file {path} already exists, please pass ' \
                      '`overwrite=True` if you wish to explicitly overwrite '
                      'the file'
                  )

              with open(path, 'w', encoding='utf-8') as file_handle:
                  file_handle.write(json.dumps(data, indent=4))

              return data

          def marginalize_over(
              self,
              *names : str,
              invert : bool = False,
              ignore_errors : bool = False,
          ) -> FisherMatrix:
              """
              Perform marginalization over some parameters.

              Parameters
              ----------
              names : str
                  the names of the parameters to marginalize over

              invert : bool, default = False
                  whether to marginalize over all the parameters NOT in names (the complement)

              ignore_errors : bool, default = False
                  should non-existing parameters be ignored

              Returns
              -------
              Instance of `FisherMatrix`.

              Examples
              --------
              Generate a Fisher object using a random orthogonal matrix:
              >>> from scipy.stats import ortho_group
              >>> rm = ortho_group.rvs(5, random_state=12345)
              >>> fm = FisherMatrix(rm.T @ np.diag([1, 2, 3, 7, 6]) @ rm)

              Marginalize over some parameters:
              >>> fm.marginalize_over('p1', 'p2')
              FisherMatrix(array([[ 1.67715591, -1.01556085,  0.30020773],
                     [-1.01556085,  4.92788976,  0.91219831],
                     [ 0.30020773,  0.91219831,  3.1796454 ]]), names=array(['p3', 'p4', 'p5'], dtype=object), latex_names=array(['p3', 'p4', 'p5'], dtype=object), fiducial=array([0., 0., 0.]))

              Marginalize over all parameters which are NOT `p1` or `p2`:
              >>> fm.marginalize_over('p1', 'p2', invert=True)
              FisherMatrix(array([[ 5.04480062, -0.04490453],
                     [-0.04490453,  1.61599083]]), names=array(['p1', 'p2'], dtype=object), latex_names=array(['p1', 'p2'], dtype=object), fiducial=array([0., 0.]))
              """
              inv = self.inverse()
              if invert is True:
                  names = set(names) ^ set(self.names)
              fm = inv.drop(*names, ignore_errors=ignore_errors)
              return fm.inverse()

          @classmethod
          def from_file(
              cls,
              path : str,
          ):
              """
              Reads a Fisher object from a file.

              Parameters
              ----------
              path : str
                  the path to the file

              Returns
              -------
              Instance of `FisherMatrix`
              """
              with open(path, 'r', encoding='utf-8') as file_handle:
                  data = json.loads(file_handle.read())

              return cls(
                  data['values'],
                  names=data['names'],
                  latex_names=data['latex_names'],
                  fiducial=data['fiducial'],
              )

  ### Static methods

  `def from_file(path: str)`

    Reads a Fisher object from a file.

    ## Parameters

    `path` :`str`
      the path to the file

    ## Returns

    Instance of `FisherMatrix`

    Expand source code

        @classmethod
        def from_file(
            cls,
            path : str,
        ):
            """
            Reads a Fisher object from a file.

            Parameters
            ----------
            path : str
                the path to the file

            Returns
            -------
            Instance of `FisherMatrix`
            """
            with open(path, 'r', encoding='utf-8') as file_handle:
                data = json.loads(file_handle.read())

            return cls(
                data['values'],
                names=data['names'],
                latex_names=data['latex_names'],
                fiducial=data['fiducial'],
            )

  ### Instance variables

  `var fiducial`

    Returns the fiducial values of the Fisher object as a numpy array.

    Expand source code

        @property
        def fiducial(self):
            """
            Returns the fiducial values of the Fisher object as a numpy array.
            """
            return self._fiducial

  `var latex_names`

    Returns the LaTeX names of the parameters of the Fisher object.

    Expand source code

        @property
        def latex_names(self):
            """
            Returns the LaTeX names of the parameters of the Fisher object.
            """
            return self._latex_names

  `var names`

    Returns the parameter names of the Fisher object.

    Expand source code

        @property
        def names(self):
            """
            Returns the parameter names of the Fisher object.
            """
            return self._names

  `var ndim`

    Returns the number of dimensions of the Fisher object (for now always 2).

    Expand source code

        @property
        def ndim(self):
            """
            Returns the number of dimensions of the Fisher object (for now always 2).
            """
            return np.ndim(self._values)

  `var values`

    Returns the values in the Fisher object as a numpy array.

    Expand source code

        @property
        def values(self):
            """
            Returns the values in the Fisher object as a numpy array.
            """
            return self._values

  ### Methods

  `def condition_number(self)`

    Returns the condition number of the matrix with respect to the L^2
    norm.

    ## Examples

        >>> fm = FisherMatrix(np.diag([1, 2, 3]))
        >>> fm.condition_number()
        3.0

    Expand source code

        def condition_number(self):
            r"""
            Returns the condition number of the matrix with respect to the \(L^2\) norm.

            Examples
            --------
            >>> fm = FisherMatrix(np.diag([1, 2, 3]))
            >>> fm.condition_number()
            3.0
            """
            values = np.abs(self.eigenvalues())
            return np.max(values) / np.min(values)

  `def constraints(self, name: Optional[str] = None, marginalized: bool = True, sigma: Optional[float] = None, p: Optional[float] = None)`

    Returns the constraints on a parameter as a float, or on all of them as a numpy array if `name` is not specified.

    ## Parameters

    `name` :`Optional[str] = None`
      the name of the parameter for which we we want the constraints
    `marginalized` :`bool`, default `= True`
      whether we want the marginalized or the unmarginalized constraints.
    `sigma` :`Optional[float]`, default `= None`
      how many sigmas away.
    `p` :`Optional[float]`, default `= None`
      the confidence interval (p-value). The relationship between `p` and `sigma` is defined via: p(\sigma) = \int\limits_{\mu - \sigma}^{\mu + \sigma} f(x, \mu, 1)\, \mathrm{d}x = \mathrm{Erf}(\sigma / \sqrt{2})
      and therefore the inverse is simply: \sigma(p) = \sqrt{2}\, \mathrm{Erf}^{-1}(p)
      The values of `p` corresponding to 1, 2, 3 `sigma` are roughly 0.683, 0.954, and 0.997, respectively.

    ## Returns

    `array-like` of `floats` or `single float`

    ## Notes

    The user should specify either `sigma` or `p`, but not both simultaneously. If neither are specified, defaults to `sigma=1`.

    ## Examples

    Get (marginalized by default) constraints for all parameters:

        >>> m = FisherMatrix([[3, -2], [-2, 5]])
        >>> m.constraints()
        array([0.67419986, 0.52223297])

    Get the unmarginalized constraints instead:

        >>> m.constraints(marginalized=False)
        array([0.57735027, 0.4472136 ])

    Get the unmarginalized constraints for a single parameter:

        >>> m.constraints('p1', marginalized=False)
        array([0.57735027])

    Get the marginalized constraints for a single parameter:

        >>> m.constraints('p1', p=0.682689) # p-value roughly equal to 1 sigma
        array([0.67419918])

    Expand source code

        def constraints(
            self,
            name : Optional[str] = None,
            marginalized : bool = True,
            sigma : Optional[float] = None,
            p : Optional[float] = None,
        ):
            r"""
            Returns the constraints on a parameter as a float, or on all of them
            as a numpy array if `name` is not specified.

            Parameters
            ----------
            name : Optional[str] = None
                the name of the parameter for which we we want the constraints

            marginalized : bool, default = True
                whether we want the marginalized or the unmarginalized
                constraints.

            sigma : Optional[float], default = None
                how many sigmas away.

            p : Optional[float], default = None
                the confidence interval (p-value).
                The relationship between `p` and `sigma` is defined via:
                \[
                    p(\sigma) = \int\limits_{\mu - \sigma}^{\mu + \sigma}
                                f(x, \mu, 1)\, \mathrm{d}x
                              = \mathrm{Erf}(\sigma / \sqrt{2})
                \]
                and therefore the inverse is simply:
                \[
                    \sigma(p) = \sqrt{2}\, \mathrm{Erf}^{-1}(p)
                \]
                The values of `p` corresponding to 1, 2, 3 `sigma` are roughly
                0.683, 0.954, and 0.997, respectively.

            Returns
            -------
            array-like of floats or single float

            Notes
            -----
            The user should specify either `sigma` or `p`, but not both
            simultaneously.
            If neither are specified, defaults to `sigma=1`.

            Examples
            --------
            Get (marginalized by default) constraints for all parameters:
            >>> m = FisherMatrix([[3, -2], [-2, 5]])
            >>> m.constraints()
            array([0.67419986, 0.52223297])

            Get the unmarginalized constraints instead:
            >>> m.constraints(marginalized=False)
            array([0.57735027, 0.4472136 ])

            Get the unmarginalized constraints for a single parameter:
            >>> m.constraints('p1', marginalized=False)
            array([0.57735027])

            Get the marginalized constraints for a single parameter:
            >>> m.constraints('p1', p=0.682689) # p-value roughly equal to 1 sigma
            array([0.67419918])
            """
            if sigma is not None and p is not None:
                raise ValueError(
                    'Cannot specify both `p` and `sigma` simultaneously'
                )

            if p is not None:
                if not 0 < p < 1:
                    raise ValueError(
                        f'The value of `p` {p} is outside of the allowed range (0, 1)'
                    )
                sigma = np.sqrt(2) * erfinv(p)
            elif sigma is None:
                sigma = 1

            if sigma <= 0:
                raise ValueError(
                    f'The value of `sigma` {sigma} is outside of the allowed range (0, infinify)'
                )

            if marginalized:
                inv = self.inverse()
                result = np.sqrt(np.diag(inv.values)) * sigma
            else:
                result = 1. / np.sqrt(np.diag(self.values)) * sigma

            if name is not None:
                if name in self.names:
                    return result[np.where(self.names == name)]
                raise ParameterNotFoundError(name, self.names)

            return result

  `def determinant(self)`

    Returns the determinant of the matrix.

    ## Returns

    `float`
    Expand source code

        def determinant(self):
            """
            Returns the determinant of the matrix.

            Returns
            -------
            float
            """
            return np.linalg.det(self.values)

  `def diagonal(self, **kwargs)`

    Returns the diagonal elements of the Fisher object as a numpy array.

    ## Returns

    `array-like` of `floats`
    Expand source code

        def diagonal(self, **kwargs):
            """
            Returns the diagonal elements of the Fisher object as a numpy array.

            Returns
            -------
            array-like of floats
            """
            return np.diag(self.values, **kwargs)

  `def drop(self, *names: str, invert: bool = False, ignore_errors: bool = False) ‑> FisherMatrix`

    Removes parameters from the Fisher object.

    ## Parameters

    `names` :`string-like`
      the names of the parameters to drop. If passing a list or a tuple, make sure to unpack it using the asterisk (*).
    `invert` :`bool`, default `= False`
      whether to drop all the parameters NOT in names (the complement)
    `ignore_errors` :`bool`, default `= False`
      should non-existing parameters be ignored

    ## Returns

    Instance of `FisherMatrix`

    ## Examples

        >>> m = FisherMatrix(np.diag([1, 2, 3]))
        >>> m.drop('p1', 'p3')
        FisherMatrix(array([[2.]]), names=array(['p2'], dtype=object), latex_names=array(['p2'], dtype=object), fiducial=array([0.]))

    Expand source code

        def drop(
            self,
            *names : str,
            invert : bool = False,
            ignore_errors : bool = False,
        ) -> FisherMatrix:
            """
            Removes parameters from the Fisher object.

            Parameters
            ----------
            names : string-like
                the names of the parameters to drop.
                If passing a list or a tuple, make sure to unpack it using the
                asterisk (*).

            invert : bool, default = False
                whether to drop all the parameters NOT in names (the complement)

            ignore_errors : bool, default = False
                should non-existing parameters be ignored

            Returns
            -------
            Instance of `FisherMatrix`

            Examples
            --------
            >>> m = FisherMatrix(np.diag([1, 2, 3]))
            >>> m.drop('p1', 'p3')
            FisherMatrix(array([[2.]]), names=array(['p2'], dtype=object), latex_names=array(['p2'], dtype=object), fiducial=array([0.]))
            """
            if not ignore_errors and not set(names).issubset(set(self.names)):
                raise ValueError(
                    f'The names ({list(names)}) are not a strict subset ' \
                    f'of the parameter names in the Fisher object ({self.names}); ' \
                    'you can pass `ignore_errors=True` to ignore this error'
                )

            if ignore_errors:
                names = np.array([name for name in names if name in self.names])

            if invert is True:
                names = set(names) ^ set(self.names)

            # TODO should we remove this?
            if set(names) == set(self.names):
                raise ValueError('Unable to remove all parameters')

            index = [np.array(np.where(self.names == name), dtype=int) for name in names]

            values = self.values
            for dim in range(self.ndim):
                values = np.delete(
                    values,
                    index,
                    axis=dim,
                )

            fiducial = np.delete(self.fiducial, index)
            latex_names = np.delete(self.latex_names, index)
            names = np.delete(self.names, index)

            return self.__class__(
                values,
                names=names,
                latex_names=latex_names,
                fiducial=fiducial,
            )

  `def eigenvalues(self)`

    Returns the eigenvalues of the Fisher object as a numpy array.

    Expand source code

        def eigenvalues(self):
            """
            Returns the eigenvalues of the Fisher object as a numpy array.
            """
            return np.linalg.eigvalsh(self.values)

  `def eigenvectors(self)`

    Returns the right eigenvectors of the Fisher object as a numpy array.

    Expand source code

        def eigenvectors(self):
            """
            Returns the right eigenvectors of the Fisher object as a numpy array.
            """
            return np.linalg.eigh(self.values)[-1]

  `def imshow(self, colorbar: bool = False, show_values: bool = False, normalized: bool = False, colorbar_space: float = 0.02, colorbar_width: float = 0.05, colorbar_orientation: str = 'vertical', rc: abc.Mapping = {'mathtext.fontset': 'cm', 'font.family': 'serif'}, **kwargs)`

    Returns the image of the Fisher object.

    Expand source code

        def imshow(
            self,
            colorbar : bool = False,
            show_values : bool = False,
            normalized : bool = False,
            colorbar_space : float = 0.02,
            colorbar_width : float = 0.05,
            colorbar_orientation : str = 'vertical',
            rc : abc.Mapping = get_default_rcparams(),
            **kwargs,
        ):
            """
            Returns the image of the Fisher object.
            """
            # TODO should only work with 2D data.
            with plt.rc_context(rc):
                fig, ax = plt.subplots(figsize=(len(self), len(self)))
                img = ax.imshow(
                    self.values,
                    interpolation='none',
                    norm=colors.CenteredNorm(),
                    **kwargs,
                )

                ax.set_xticks(np.arange(len(self)))
                ax.set_yticks(np.arange(len(self)))
                ax.set_xticklabels(self.latex_names)
                ax.set_yticklabels(self.latex_names)

                if colorbar:
                    allowed_orientations = ('vertical', 'horizontal')
                    if colorbar_orientation not in allowed_orientations:
                        raise ValueError(
                            f'\'{colorbar_orientation}\' is not one of: {allowed_orientations}'
                        )

                    if colorbar_orientation == 'vertical':
                        cax = fig.add_axes(
                            [ax.get_position().x1 + colorbar_space,
                            ax.get_position().y0 * 0.997,
                            colorbar_width,
                            ax.get_position().height]
                        )
                        fig.colorbar(img, cax=cax, orientation=colorbar_orientation)
                        cax.set_yticklabels(
                            [f'${float_to_latex(_)}$' for _ in cax.get_yticks()]
                        )
                    else:
                        cax = fig.add_axes(
                            [ax.get_position().x0,
                            ax.get_position().y1 + colorbar_space,
                            ax.get_position().width,
                            colorbar_width]
                        )
                        fig.colorbar(img, cax=cax, orientation=colorbar_orientation)
                        cax.xaxis.set_ticks_position('top')
                        cax.set_xticklabels(
                            [f'${float_to_latex(_)}$' for _ in cax.get_xticks()]
                        )

                # whether or not we want to display the actual values inside the
                # matrix
                if show_values:
                    mid_coords = np.arange(len(self))
                    for index1, index2 in product(range(len(self)), range(len(self))):
                        x = mid_coords[index1]
                        y = mid_coords[index2]
                        value = self.values[index1, index2] / \
                            np.sqrt(
                                self.values[index1, index1] \
                               *self.values[index2, index2]
                            ) if normalized \
                            else self.values[index1, index2]
                        text = ax.text(
                                x, y, f'${float_to_latex(value)}$',
                                ha='center', va='center', color='white',
                        )
                        text.set_path_effects(
                            [Stroke(linewidth=1, foreground="black"), Normal()]
                        )

            return fig

  `def inverse(self) ‑> FisherMatrix`

    Returns the inverse of the Fisher matrix.

    ## Returns

    Instance of `FisherMatrix`

    ## Examples

        >>> fm = FisherMatrix(np.diag([1, 2, 5]))
        >>> fm.inverse()
        FisherMatrix(array([[1. , 0. , 0. ],
               [0. , 0.5, 0. ],
               [0. , 0. , 0.2]]), names=array(['p1', 'p2', 'p3'], dtype=object), latex_names=array(['p1', 'p2', 'p3'], dtype=object), fiducial=array([0., 0., 0.]))

    Expand source code

        def inverse(self) -> FisherMatrix:
            """
            Returns the inverse of the Fisher matrix.

            Returns
            -------
            Instance of `FisherMatrix`

            Examples
            --------
            >>> fm = FisherMatrix(np.diag([1, 2, 5]))
            >>> fm.inverse()
            FisherMatrix(array([[1. , 0. , 0. ],
                   [0. , 0.5, 0. ],
                   [0. , 0. , 0.2]]), names=array(['p1', 'p2', 'p3'], dtype=object), latex_names=array(['p1', 'p2', 'p3'], dtype=object), fiducial=array([0., 0., 0.]))
            """
            # inverse satisfies properties of Fisher matrix, see:
            # https://math.stackexchange.com/a/26200
            return self.__class__(
                np.linalg.inv(self.values),
                names=self.names,
                latex_names=self.latex_names,
                fiducial=self.fiducial,
            )

  `def is_diagonal(self)`

    Checks whether the Fisher matrix is diagonal.

    ## Returns

    `True` or `False`

    Expand source code

        def is_diagonal(self):
            """
            Checks whether the Fisher matrix is diagonal.

            Returns
            -------
            `True` or `False`
            """
            return np.all(self.values == np.diag(np.diagonal(self.values)))

  `def is_valid(self)`

    Checks whether the values make a valid Fisher object. A (square) matrix is a Fisher matrix if it satisifies the following two criteria:

    - it is symmetric
    - it is positive semi-definite

    ## Returns

    `True` or `False`

    ## Examples

        >>> m = FisherMatrix(np.diag([1, 2, 3]))
        >>> m.is_valid()
        True
        >>> FisherMatrix(np.diag([-1, 3])).is_valid()
        False

    Expand source code

        def is_valid(self):
            """
            Checks whether the values make a valid Fisher object.
            A (square) matrix is a Fisher matrix if it satisifies the following two
            criteria:

            * it is symmetric
            * it is positive semi-definite

            Returns
            -------
            `True` or `False`

            Examples
            --------
            >>> m = FisherMatrix(np.diag([1, 2, 3]))
            >>> m.is_valid()
            True
            >>> FisherMatrix(np.diag([-1, 3])).is_valid()
            False
            """
            return \
                is_symmetric(self.values) and \
                is_positive_semidefinite(self.values)

  `def marginalize_over(self, *names: str, invert: bool = False, ignore_errors: bool = False) ‑> FisherMatrix`

    Perform marginalization over some parameters.

    ## Parameters

    `names` :`str`
      the names of the parameters to marginalize over
    `invert` :`bool`, default `= False`
      whether to marginalize over all the parameters NOT in names (the complement)
    `ignore_errors` :`bool`, default `= False`
      should non-existing parameters be ignored

    ## Returns

    Instance of `FisherMatrix`.

    ## Examples

    Generate a Fisher object using a random orthogonal matrix:

        >>> from scipy.stats import ortho_group
        >>> rm = ortho_group.rvs(5, random_state=12345)
        >>> fm = FisherMatrix(rm.T @ np.diag([1, 2, 3, 7, 6]) @ rm)

    Marginalize over some parameters:

        >>> fm.marginalize_over('p1', 'p2')
        FisherMatrix(array([[ 1.67715591, -1.01556085,  0.30020773],
               [-1.01556085,  4.92788976,  0.91219831],
               [ 0.30020773,  0.91219831,  3.1796454 ]]), names=array(['p3', 'p4', 'p5'], dtype=object), latex_names=array(['p3', 'p4', 'p5'], dtype=object), fiducial=array([0., 0., 0.]))

    Marginalize over all parameters which are NOT `p1` or `p2`:

        >>> fm.marginalize_over('p1', 'p2', invert=True)
        FisherMatrix(array([[ 5.04480062, -0.04490453],
               [-0.04490453,  1.61599083]]), names=array(['p1', 'p2'], dtype=object), latex_names=array(['p1', 'p2'], dtype=object), fiducial=array([0., 0.]))

    Expand source code

        def marginalize_over(
            self,
            *names : str,
            invert : bool = False,
            ignore_errors : bool = False,
        ) -> FisherMatrix:
            """
            Perform marginalization over some parameters.

            Parameters
            ----------
            names : str
                the names of the parameters to marginalize over

            invert : bool, default = False
                whether to marginalize over all the parameters NOT in names (the complement)

            ignore_errors : bool, default = False
                should non-existing parameters be ignored

            Returns
            -------
            Instance of `FisherMatrix`.

            Examples
            --------
            Generate a Fisher object using a random orthogonal matrix:
            >>> from scipy.stats import ortho_group
            >>> rm = ortho_group.rvs(5, random_state=12345)
            >>> fm = FisherMatrix(rm.T @ np.diag([1, 2, 3, 7, 6]) @ rm)

            Marginalize over some parameters:
            >>> fm.marginalize_over('p1', 'p2')
            FisherMatrix(array([[ 1.67715591, -1.01556085,  0.30020773],
                   [-1.01556085,  4.92788976,  0.91219831],
                   [ 0.30020773,  0.91219831,  3.1796454 ]]), names=array(['p3', 'p4', 'p5'], dtype=object), latex_names=array(['p3', 'p4', 'p5'], dtype=object), fiducial=array([0., 0., 0.]))

            Marginalize over all parameters which are NOT `p1` or `p2`:
            >>> fm.marginalize_over('p1', 'p2', invert=True)
            FisherMatrix(array([[ 5.04480062, -0.04490453],
                   [-0.04490453,  1.61599083]]), names=array(['p1', 'p2'], dtype=object), latex_names=array(['p1', 'p2'], dtype=object), fiducial=array([0., 0.]))
            """
            inv = self.inverse()
            if invert is True:
                names = set(names) ^ set(self.names)
            fm = inv.drop(*names, ignore_errors=ignore_errors)
            return fm.inverse()

  `def rename(self, names: Mapping[str, Union[str, abc.Mapping]], ignore_errors: bool = False) ‑> FisherMatrix`

    Returns a Fisher object with new names.

    ## Parameters

    `names` :`Mapping[str, Union[str, abc.Mapping]]`
      a mapping (dictionary-like object) between the old names and the new ones. The values it maps to can either be a string (the new name), or a dict with keys `name`, `latex_name`, and `fiducial` (only `name` is mandatory).
    `ignore_errors` :`bool`, default `= False`
      if set to True, will not raise an error if a parameter doesn't exist

    ## Returns

    Instance of `FisherMatrix`.

    ## Examples

        >>> m = FisherMatrix(np.diag([1, 2, 3]))
        >>> m.rename({'p1' : 'a', 'p2' : dict(name='b', latex_name='$b$', fiducial=2)})
        FisherMatrix(array([[1., 0., 0.],
               [0., 2., 0.],
               [0., 0., 3.]]), names=array(['a', 'b', 'p3'], dtype=object), latex_names=array(['a', '$b$', 'p3'], dtype=object), fiducial=array([0., 2., 0.]))

    Expand source code

        def rename(
            self,
            names : Mapping[str, Union[str, abc.Mapping]],
            ignore_errors : bool = False,
        ) -> FisherMatrix:
            """
            Returns a Fisher object with new names.

            Parameters
            ----------
            names : Mapping[str, Union[str, abc.Mapping]]
                a mapping (dictionary-like object) between the old names and the
                new ones. The values it maps to can either be a string (the new name), or a dict
                with keys `name`, `latex_name`, and `fiducial` (only `name` is mandatory).

            ignore_errors : bool, default = False
                if set to True, will not raise an error if a parameter doesn't exist

            Returns
            -------
            Instance of `FisherMatrix`.

            Examples
            --------
            >>> m = FisherMatrix(np.diag([1, 2, 3]))
            >>> m.rename({'p1' : 'a', 'p2' : dict(name='b', latex_name='$b$', fiducial=2)})
            FisherMatrix(array([[1., 0., 0.],
                   [0., 2., 0.],
                   [0., 0., 3.]]), names=array(['a', 'b', 'p3'], dtype=object), latex_names=array(['a', '$b$', 'p3'], dtype=object), fiducial=array([0., 2., 0.]))
            """
            # check uniqueness and size
            if len(set(names)) != len(names):
                raise MismatchingSizeError(set(names), names)

            if not ignore_errors:
                for name in names:
                    if name not in self.names:
                        raise ParameterNotFoundError(name, self.names)

            names_new = copy.deepcopy(self.names)
            latex_names_new = copy.deepcopy(self.latex_names)
            fiducial_new = copy.deepcopy(self.fiducial)

            for name, value in names.items():
                index = np.where(names_new == name)
                # it's a mapping
                if isinstance(value, abc.Mapping):
                    value = _process_fisher_mapping(value)
                    latex_names_new[index] = value['latex_name']
                    fiducial_new[index] = value['fiducial']
                    names_new[index] = value['name']
                # otherwise, it's a string
                else:
                    names_new[index] = value
                    latex_names_new[index] = value

            return self.__class__(
                self.values,
                names=names_new,
                latex_names=latex_names_new,
                fiducial=fiducial_new,
            )

  `def reparametrize(self, jacobian: Collection, names: Optional[Collection[str]] = None, latex_names: Optional[Collection[str]] = None, fiducial: Optional[Collection[float]] = None) ‑> FisherMatrix`

    Returns a new Fisher object with parameters `names`, which are related to the old ones via the transformation `jacobian`. See the Wikipedia article for more information.

    ## Parameters

    `transformation` :`array-like`
      the Jacobian of the transformation
    `names` :`array-like`, default `= None`
      list of new names for the Fisher object. If None, uses the old names.
    `latex_names` :`array-like`, default `= None`
      list of new LaTeX names for the Fisher object. If None, and `names` is set, uses those instead, otherwise uses the old LaTeX names.
    `fiducial` :`array-like`, default `= None`
      the new values of the fiducial. If not set, defaults to old values.

    ## Returns

    Instance of `FisherMatrix`.

    ## Examples

        >>> fm = FisherMatrix(np.diag([1, 2]))
        >>> jac = [[1, 4], [3, 2]]
        >>> fm.reparametrize(jac, names=['a', 'b'])
        FisherMatrix(array([[19., 16.],
               [16., 24.]]), names=array(['a', 'b'], dtype=object), latex_names=array(['a', 'b'], dtype=object), fiducial=array([0., 0.]))

    Expand source code

        def reparametrize(
            self,
            jacobian : Collection,
            names : Optional[Collection[str]] = None,
            latex_names : Optional[Collection[str]] = None,
            fiducial : Optional[Collection[float]] = None,
        ) -> FisherMatrix:
            """
            Returns a new Fisher object with parameters `names`, which are
            related to the old ones via the transformation `jacobian`.
            See the [Wikipedia article](https://en.wikipedia.org/w/index.php?title=Fisher_information&oldid=1063384000#Reparametrization) for more information.

            Parameters
            ----------
            transformation : array-like
                the Jacobian of the transformation

            names : array-like, default = None
                list of new names for the Fisher object. If None, uses the old
                names.

            latex_names: array-like, default = None
                list of new LaTeX names for the Fisher object. If None, and
                `names` is set, uses those instead, otherwise uses the old LaTeX names.

            fiducial : array-like, default = None
                the new values of the fiducial. If not set, defaults to old values.

            Returns
            -------
            Instance of `FisherMatrix`.

            Examples
            --------
            >>> fm = FisherMatrix(np.diag([1, 2]))
            >>> jac = [[1, 4], [3, 2]]
            >>> fm.reparametrize(jac, names=['a', 'b'])
            FisherMatrix(array([[19., 16.],
                   [16., 24.]]), names=array(['a', 'b'], dtype=object), latex_names=array(['a', 'b'], dtype=object), fiducial=array([0., 0.]))
            """
            values = np.transpose(jacobian) @ self.values @ jacobian

            if names is not None:
                if len(set(names)) != np.shape(jacobian)[-1]:
                    raise MismatchingSizeError(names)
                if latex_names is not None:
                    if len(set(latex_names)) != np.shape(jacobian)[-1]:
                        raise MismatchingSizeError(latex_names)
                else:
                    latex_names = copy.deepcopy(names)
            else:
                # we don't transform the names
                names = copy.deepcopy(self.names)
                latex_names = copy.deepcopy(self.latex_names)

            if fiducial is not None:
                if len(fiducial) != np.shape(jacobian)[-1]:
                    raise MismatchingSizeError(fiducial)
            else:
                fiducial = copy.deepcopy(self.fiducial)

            return self.__class__(
                values,
                names=names,
                latex_names=latex_names,
                fiducial=fiducial,
            )

  `def sort(self, **kwargs) ‑> FisherMatrix`

    Sorts the Fisher object by name according to some criterion.

    ## Parameters

    `**kwargs`
      all of the other keyword arguments for the Python builtin `sorted`. If none are specified, will sort according to the names of the parameters. In the special case that the value of the keyword `key` is set to either 'fiducial' or 'latex_names', it will sort according to those. In the second special case that the value of the keyword `key` is set to an array of integers of equal size as the Fisher object, sorts them according to those instead.

    ## Returns

    Instance of `FisherMatrix`.

    ## Examples

        >>> m = FisherMatrix(np.diag([3, 1, 2]), names=list('sdf'),
        ... latex_names=['hjkl', 'qwe', 'll'], fiducial=[8, 7, 3])
        >>> m.sort(key='fiducial')
        FisherMatrix(array([[2., 0., 0.],
               [0., 1., 0.],
               [0., 0., 3.]]), names=array(['f', 'd', 's'], dtype=object), latex_names=array(['ll', 'qwe', 'hjkl'], dtype=object), fiducial=array([3., 7., 8.]))
        >>> m.sort(key='latex_names')
        FisherMatrix(array([[3., 0., 0.],
               [0., 2., 0.],
               [0., 0., 1.]]), names=array(['s', 'f', 'd'], dtype=object), latex_names=array(['hjkl', 'll', 'qwe'], dtype=object), fiducial=array([8., 3., 7.]))

    Expand source code

        def sort(
            self,
            **kwargs,
        ) -> FisherMatrix:
            """
            Sorts the Fisher object by name according to some criterion.

            Parameters
            ----------
            **kwargs
                all of the other keyword arguments for the Python builtin `sorted`.
                If none are specified, will sort according to the names of the parameters.
                In the special case that the value of the keyword `key` is set to
                either 'fiducial' or 'latex_names', it will sort according to those.
                In the second special case that the value of the keyword `key` is
                set to an array of integers of equal size as the Fisher object, sorts them
                according to those instead.

            Returns
            -------
            Instance of `FisherMatrix`.

            Examples
            --------
            >>> m = FisherMatrix(np.diag([3, 1, 2]), names=list('sdf'),
            ... latex_names=['hjkl', 'qwe', 'll'], fiducial=[8, 7, 3])
            >>> m.sort(key='fiducial')
            FisherMatrix(array([[2., 0., 0.],
                   [0., 1., 0.],
                   [0., 0., 3.]]), names=array(['f', 'd', 's'], dtype=object), latex_names=array(['ll', 'qwe', 'hjkl'], dtype=object), fiducial=array([3., 7., 8.]))
            >>> m.sort(key='latex_names')
            FisherMatrix(array([[3., 0., 0.],
                   [0., 2., 0.],
                   [0., 0., 1.]]), names=array(['s', 'f', 'd'], dtype=object), latex_names=array(['hjkl', 'll', 'qwe'], dtype=object), fiducial=array([8., 3., 7.]))
            """
            allowed_keys = ('fiducial', 'latex_names')
            # an integer index
            if 'key' in kwargs and all(hasattr(_, '__index__') for _ in kwargs['key']):
                index = np.array(kwargs['key'], dtype=int)
                names = self.names[index]
            # either 'fiducial' or 'latex_names'
            elif 'key' in kwargs and kwargs['key'] in allowed_keys:
                index = np.argsort(getattr(self, kwargs['key']))
                if 'reversed' in kwargs and kwargs['reversed'] is True:
                    index = np.flip(index)
                names = self.names[index]
            # something that can be passed to `sorted`
            else:
                names = sorted(self.names, **kwargs)
                index = get_index_of_other_array(self.names, names)

            latex_names = self.latex_names[index]
            fiducial = self.fiducial[index]
            values = reindex_array(self.values, index)

            return self.__class__(
                values,
                names=names,
                latex_names=latex_names,
                fiducial=fiducial,
            )

  `def to_file(self, path: str, *args: str, metadata: dict = {}, overwrite: bool = False)`

    Saves the Fisher object to a file. The format used is a simple JSON file, containing at least the values of the Fisher object, the names of the parameters, the LaTeX names, and the fiducial values.

    ## Parameters

    `path` :`str`
      the path to save the data to.
    `args` :`str`
      whatever other information about the object needs to be saved. Needs to be one of the following: `is_valid`, `eigenvalues`, `eigenvectors`, `trace`, `determinant`, or `constraints` (by default, the 1$\sigma$ marginalized constraints).
    `metadata` :`dict`, default `= {}`
      any metadata that should be associated to the object saved
    `overwrite` :`bool`, default `= False`
      whether to overwrite the file if it exists

    ## Returns

    The dictionary that was saved.

    ## Examples

        >>> fm = FisherMatrix(np.diag([1, 2]), names=['a', 'b'], latex_names=[r'$\mathbf{A}$', r'$\mathbf{B}$'])
        >>> fm.to_file('example_matrix.json', overwrite=True)
        {'values': [[1.0, 0.0], [0.0, 2.0]], 'names': ['a', 'b'], 'latex_names': ['$\\mathbf{A}$', '$\\mathbf{B}$'], 'fiducial': [0.0, 0.0]}
        >>> fm_read = FisherMatrix.from_file('example_matrix.json') # convenience function for reading it
        >>> fm == fm_read # verify it's the same object
        True

    Expand source code

        def to_file(
            self,
            path : str,
            *args : str,
            metadata : dict = {},
            overwrite : bool = False,
        ):
            r"""
            Saves the Fisher object to a file.
            The format used is a simple JSON file, containing at least the values of the
            Fisher object, the names of the parameters, the LaTeX names, and the
            fiducial values.

            Parameters
            ----------
            path : str
                the path to save the data to.

            args : str
                whatever other information about the object needs to be saved.
                Needs to be one of the following: `is_valid`, `eigenvalues`,
                `eigenvectors`, `trace`, `determinant`, or `constraints` (by
                default, the 1$\sigma$ marginalized constraints).

            metadata : dict, default = {}
                any metadata that should be associated to the object saved

            overwrite : bool, default = False
                whether to overwrite the file if it exists

            Returns
            -------
            The dictionary that was saved.

            Examples
            --------
            >>> fm = FisherMatrix(np.diag([1, 2]), names=['a', 'b'], latex_names=[r'$\mathbf{A}$', r'$\mathbf{B}$'])
            >>> fm.to_file('example_matrix.json', overwrite=True)
            {'values': [[1.0, 0.0], [0.0, 2.0]], 'names': ['a', 'b'], 'latex_names': ['$\\mathbf{A}$', '$\\mathbf{B}$'], 'fiducial': [0.0, 0.0]}
            >>> fm_read = FisherMatrix.from_file('example_matrix.json') # convenience function for reading it
            >>> fm == fm_read # verify it's the same object
            True
            """
            data = {
                'values' : self.values.tolist(),
                'names' : self.names.tolist(),
                'latex_names' : self.latex_names.tolist(),
                'fiducial' : self.fiducial.tolist(),
            }

            allowed_metadata = {
                'is_valid' : bool,
                'eigenvalues' : np.array,
                'eigenvectors' : np.array,
                'trace' : float,
                'determinant' : float,
                'constraints' : np.array,
            }

            for arg in args:
                if arg not in allowed_metadata:
                    raise ValueError(
                        f'name {arg} is not one of {list(allowed_metadata.keys())}'
                    )

            for arg in metadata:
                if arg in data:
                    raise ValueError(
                        f'name {arg} cannot be one of {list(data.keys())}'
                    )

            data = {
                **data,
                **{arg : jsonify(getattr(self, arg)()) for arg in args},
                **metadata,
            }

            if os.path.exists(path) and not overwrite:
                raise FileExistsError(
                    f'The file {path} already exists, please pass ' \
                    '`overwrite=True` if you wish to explicitly overwrite '
                    'the file'
                )

            with open(path, 'w', encoding='utf-8') as file_handle:
                file_handle.write(json.dumps(data, indent=4))

            return data

  `def trace(self, **kwargs)`

    Returns the trace of the Fisher object as a numpy array.

    Expand source code

        def trace(
            self,
            **kwargs,
        ):
            """
            Returns the trace of the Fisher object as a numpy array.
            """
            return np.trace(self.values, **kwargs)