pyerrors.obs

   1import hashlib
   2import pickle
   3import warnings
   4from itertools import groupby
   5from typing import ClassVar
   6
   7import autograd.numpy as anp  # Thinly-wrapped numpy
   8import matplotlib.pyplot as plt
   9import numdifftools as nd
  10import numpy as np
  11import scipy
  12from autograd import jacobian
  13from scipy.stats import kurtosis, kurtosistest, skew, skewtest
  14
  15from .covobs import Covobs
  16
  17# Improve print output of numpy.ndarrays containing Obs objects.
  18np.set_printoptions(formatter={'object': str})
  19
  20
  21class Obs:
  22    """Class for a general observable.
  23
  24    Instances of Obs are the basic objects of a pyerrors error analysis.
  25    They are initialized with a list which contains arrays of samples for
  26    different ensembles/replica and another list of same length which contains
  27    the names of the ensembles/replica. Mathematical operations can be
  28    performed on instances. The result is another instance of Obs. The error of
  29    an instance can be computed with the gamma_method. Also contains additional
  30    methods for output and visualization of the error calculation.
  31
  32    Attributes
  33    ----------
  34    S_global : float
  35        Standard value for S (default 2.0)
  36    S_dict : dict
  37        Dictionary for S values. If an entry for a given ensemble
  38        exists this overwrites the standard value for that ensemble.
  39    tau_exp_global : float
  40        Standard value for tau_exp (default 0.0)
  41    tau_exp_dict : dict
  42        Dictionary for tau_exp values. If an entry for a given ensemble exists
  43        this overwrites the standard value for that ensemble.
  44    N_sigma_global : float
  45        Standard value for N_sigma (default 1.0)
  46    N_sigma_dict : dict
  47        Dictionary for N_sigma values. If an entry for a given ensemble exists
  48        this overwrites the standard value for that ensemble.
  49    """
  50    __slots__ = [
  51        'N',
  52        'N_sigma',
  53        'S',
  54        '__dict__',
  55        '_covobs',
  56        '_dvalue',
  57        '_value',
  58        'ddvalue',
  59        'deltas',
  60        'e_ddvalue',
  61        'e_drho',
  62        'e_dtauint',
  63        'e_dvalue',
  64        'e_n_dtauint',
  65        'e_n_tauint',
  66        'e_rho',
  67        'e_tauint',
  68        'e_windowsize',
  69        'idl',
  70        'names',
  71        'r_values',
  72        'reweighted',
  73        'shape',
  74        'tag',
  75        'tau_exp',
  76    ]
  77
  78    S_global = 2.0
  79    S_dict: ClassVar[dict] = {}
  80    tau_exp_global = 0.0
  81    tau_exp_dict: ClassVar[dict] = {}
  82    N_sigma_global = 1.0
  83    N_sigma_dict: ClassVar[dict] = {}
  84
  85    def __init__(self, samples, names, idl=None, **kwargs):
  86        """ Initialize Obs object.
  87
  88        Parameters
  89        ----------
  90        samples : list
  91            list of numpy arrays containing the Monte Carlo samples
  92        names : list
  93            list of strings labeling the individual samples
  94        idl : list, optional
  95            list of ranges or lists on which the samples are defined
  96        """
  97
  98        if kwargs.get("means") is None and len(samples):
  99            if len(samples) != len(names):
 100                raise ValueError('Length of samples and names incompatible.')
 101            if idl is not None:
 102                if len(idl) != len(names):
 103                    raise ValueError('Length of idl incompatible with samples and names.')
 104            name_length = len(names)
 105            if name_length > 1:
 106                if name_length != len(set(names)):
 107                    raise ValueError('Names are not unique.')
 108                if not all(isinstance(x, str) for x in names):
 109                    raise TypeError('All names have to be strings.')
 110                if len(set([o.split('|')[0] for o in names])) > 1:
 111                    raise ValueError('Cannot initialize Obs based on multiple ensembles. Please average separate Obs from each ensemble.')
 112            else:
 113                if not isinstance(names[0], str):
 114                    raise TypeError('All names have to be strings.')
 115            if min(len(x) for x in samples) <= 4:
 116                raise ValueError('Samples have to have at least 5 entries.')
 117
 118        self.names = sorted(names)
 119        self.shape = {}
 120        self.r_values = {}
 121        self.deltas = {}
 122        self._covobs = {}
 123
 124        self._value = 0
 125        self.N = 0
 126        self.idl = {}
 127        if idl is not None:
 128            for name, idx in sorted(zip(names, idl, strict=True)):
 129                if isinstance(idx, range):
 130                    self.idl[name] = idx
 131                elif isinstance(idx, (list, np.ndarray)):
 132                    dc = np.unique(np.diff(idx))
 133                    if np.any(dc < 0):
 134                        raise ValueError("Unsorted idx for idl[{}] at position {}".format(name, ' '.join(['%s' % (pos + 1) for pos in np.where(np.diff(idx) < 0)[0]])))
 135                    elif np.any(dc == 0):
 136                        raise ValueError("Duplicate entries in idx for idl[{}] at position {}".format(name, ' '.join(['%s' % (pos + 1) for pos in np.where(np.diff(idx) == 0)[0]])))
 137                    if len(dc) == 1:
 138                        self.idl[name] = range(idx[0], idx[-1] + dc[0], dc[0])
 139                    else:
 140                        self.idl[name] = list(idx)
 141                else:
 142                    raise TypeError(f'incompatible type for idl[{name}].')
 143        else:
 144            for name, sample in sorted(zip(names, samples, strict=True)):
 145                self.idl[name] = range(1, len(sample) + 1)
 146
 147        if kwargs.get("means") is not None:
 148            for name, sample, mean in sorted(zip(names, samples, kwargs.get("means"), strict=True)):
 149                self.shape[name] = len(self.idl[name])
 150                self.N += self.shape[name]
 151                self.r_values[name] = mean
 152                self.deltas[name] = sample
 153        else:
 154            for name, sample in sorted(zip(names, samples, strict=True)):
 155                self.shape[name] = len(self.idl[name])
 156                self.N += self.shape[name]
 157                if len(sample) != self.shape[name]:
 158                    raise ValueError(f'Incompatible samples and idx for {name}: {len(sample)} vs. {self.shape[name]}')
 159                self.r_values[name] = np.mean(sample)
 160                self.deltas[name] = sample - self.r_values[name]
 161                self._value += self.shape[name] * self.r_values[name]
 162            self._value /= self.N
 163
 164        self._dvalue = 0.0
 165        self.ddvalue = 0.0
 166        self.reweighted = False
 167
 168        self.tag = None
 169
 170    @property
 171    def value(self):
 172        return self._value
 173
 174    @property
 175    def dvalue(self):
 176        return self._dvalue
 177
 178    @property
 179    def e_names(self):
 180        return sorted(set([o.split('|')[0] for o in self.names]))
 181
 182    @property
 183    def cov_names(self):
 184        return sorted(set([o for o in self.covobs.keys()]))
 185
 186    @property
 187    def mc_names(self):
 188        return sorted(set([o.split('|')[0] for o in self.names if o not in self.cov_names]))
 189
 190    @property
 191    def e_content(self):
 192        res = {}
 193        for _e, e_name in enumerate(self.e_names):
 194            res[e_name] = sorted(filter(lambda x: x.startswith(e_name + '|'), self.names))
 195            if e_name in self.names:
 196                res[e_name].append(e_name)
 197        return res
 198
 199    @property
 200    def covobs(self):
 201        return self._covobs
 202
 203    def gamma_method(self, **kwargs):
 204        """Estimate the error and related properties of the Obs.
 205
 206        Parameters
 207        ----------
 208        S : float
 209            specifies a custom value for the parameter S (default 2.0).
 210            If set to 0 it is assumed that the data exhibits no
 211            autocorrelation. In this case the error estimates coincides
 212            with the sample standard error.
 213        tau_exp : float
 214            positive value triggers the critical slowing down analysis
 215            (default 0.0).
 216        N_sigma : float
 217            number of standard deviations from zero until the tail is
 218            attached to the autocorrelation function (default 1).
 219        fft : bool
 220            determines whether the fft algorithm is used for the computation
 221            of the autocorrelation function (default True)
 222        """
 223
 224        e_content = self.e_content
 225        self.e_dvalue = {}
 226        self.e_ddvalue = {}
 227        self.e_tauint = {}
 228        self.e_dtauint = {}
 229        self.e_windowsize = {}
 230        self.e_n_tauint = {}
 231        self.e_n_dtauint = {}
 232        e_gamma = {}
 233        self.e_rho = {}
 234        self.e_drho = {}
 235        self._dvalue = 0
 236        self.ddvalue = 0
 237
 238        self.S = {}
 239        self.tau_exp = {}
 240        self.N_sigma = {}
 241
 242        if kwargs.get('fft') is False:
 243            fft = False
 244        else:
 245            fft = True
 246
 247        def _parse_kwarg(kwarg_name):
 248            if kwarg_name in kwargs:
 249                tmp = kwargs.get(kwarg_name)
 250                if isinstance(tmp, (int, float)):
 251                    if tmp < 0:
 252                        raise ValueError(kwarg_name + ' has to be larger or equal to 0.')
 253                    for _e, e_name in enumerate(self.e_names):
 254                        getattr(self, kwarg_name)[e_name] = tmp
 255                else:
 256                    raise TypeError(kwarg_name + ' is not in proper format.')
 257            else:
 258                for _e, e_name in enumerate(self.e_names):
 259                    if e_name in getattr(Obs, kwarg_name + '_dict'):
 260                        getattr(self, kwarg_name)[e_name] = getattr(Obs, kwarg_name + '_dict')[e_name]
 261                    else:
 262                        getattr(self, kwarg_name)[e_name] = getattr(Obs, kwarg_name + '_global')
 263
 264        _parse_kwarg('S')
 265        _parse_kwarg('tau_exp')
 266        _parse_kwarg('N_sigma')
 267
 268        for _e, e_name in enumerate(self.mc_names):
 269            gapsize = _determine_gap(self, e_content, e_name)
 270
 271            r_length = []
 272            for r_name in e_content[e_name]:
 273                if isinstance(self.idl[r_name], range):
 274                    r_length.append(len(self.idl[r_name]) * self.idl[r_name].step // gapsize)
 275                else:
 276                    r_length.append((self.idl[r_name][-1] - self.idl[r_name][0] + 1) // gapsize)
 277
 278            e_N = np.sum([self.shape[r_name] for r_name in e_content[e_name]])
 279            w_max = max(r_length) // 2
 280            e_gamma[e_name] = np.zeros(w_max)
 281            self.e_rho[e_name] = np.zeros(w_max)
 282            self.e_drho[e_name] = np.zeros(w_max)
 283
 284            for r_name in e_content[e_name]:
 285                e_gamma[e_name] += self._calc_gamma(self.deltas[r_name], self.idl[r_name], self.shape[r_name], w_max, fft, gapsize)
 286
 287            gamma_div = np.zeros(w_max)
 288            for r_name in e_content[e_name]:
 289                gamma_div += self._calc_gamma(np.ones(self.shape[r_name]), self.idl[r_name], self.shape[r_name], w_max, fft, gapsize)
 290            gamma_div[gamma_div < 1] = 1.0
 291            e_gamma[e_name] /= gamma_div[:w_max]
 292
 293            if np.abs(e_gamma[e_name][0]) < 10 * np.finfo(float).tiny:  # Prevent division by zero
 294                self.e_tauint[e_name] = 0.5
 295                self.e_dtauint[e_name] = 0.0
 296                self.e_dvalue[e_name] = 0.0
 297                self.e_ddvalue[e_name] = 0.0
 298                self.e_windowsize[e_name] = 0
 299                continue
 300
 301            self.e_rho[e_name] = e_gamma[e_name][:w_max] / e_gamma[e_name][0]
 302            self.e_n_tauint[e_name] = np.cumsum(np.concatenate(([0.5], self.e_rho[e_name][1:])))
 303            # Make sure no entry of tauint is smaller than 0.5
 304            self.e_n_tauint[e_name][self.e_n_tauint[e_name] <= 0.5] = 0.5 + np.finfo(np.float64).eps
 305            # hep-lat/0306017 eq. (42)
 306            self.e_n_dtauint[e_name] = self.e_n_tauint[e_name] * 2 * np.sqrt(np.abs(np.arange(w_max) + 0.5 - self.e_n_tauint[e_name]) / e_N)
 307            self.e_n_dtauint[e_name][0] = 0.0
 308
 309            def _compute_drho(i, e_name=e_name, w_max=w_max, e_N=e_N):
 310                tmp = (self.e_rho[e_name][i + 1:w_max]
 311                       + np.concatenate([self.e_rho[e_name][i - 1:None if i - (w_max - 1) // 2 <= 0 else (2 * i - (2 * w_max) // 2):-1],
 312                                         self.e_rho[e_name][1:max(1, w_max - 2 * i)]])
 313                       - 2 * self.e_rho[e_name][i] * self.e_rho[e_name][1:w_max - i])
 314                self.e_drho[e_name][i] = np.sqrt(np.sum(tmp ** 2) / e_N)
 315
 316            if self.tau_exp[e_name] > 0:
 317                _compute_drho(1)
 318                texp = self.tau_exp[e_name]
 319                # Critical slowing down analysis
 320                if w_max // 2 <= 1:
 321                    raise ValueError("Need at least 8 samples for tau_exp error analysis")
 322                for n in range(1, w_max // 2):
 323                    _compute_drho(n + 1)
 324                    if (self.e_rho[e_name][n] - self.N_sigma[e_name] * self.e_drho[e_name][n]) < 0 or n >= w_max // 2 - 2:
 325                        # Bias correction hep-lat/0306017 eq. (49) included
 326                        self.e_tauint[e_name] = self.e_n_tauint[e_name][n] * (1 + (2 * n + 1) / e_N) / (1 + 1 / e_N) + texp * np.abs(self.e_rho[e_name][n + 1])  # The absolute makes sure, that the tail contribution is always positive
 327                        self.e_dtauint[e_name] = np.sqrt(self.e_n_dtauint[e_name][n] ** 2 + texp ** 2 * self.e_drho[e_name][n + 1] ** 2)
 328                        # Error of tau_exp neglected so far, missing term: self.e_rho[e_name][n + 1] ** 2 * d_tau_exp ** 2
 329                        self.e_dvalue[e_name] = np.sqrt(2 * self.e_tauint[e_name] * e_gamma[e_name][0] * (1 + 1 / e_N) / e_N)
 330                        self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt((n + 0.5) / e_N)
 331                        self.e_windowsize[e_name] = n
 332                        break
 333            else:
 334                if self.S[e_name] == 0.0:
 335                    self.e_tauint[e_name] = 0.5
 336                    self.e_dtauint[e_name] = 0.0
 337                    self.e_dvalue[e_name] = np.sqrt(e_gamma[e_name][0] / (e_N - 1))
 338                    self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt(0.5 / e_N)
 339                    self.e_windowsize[e_name] = 0
 340                else:
 341                    # Standard automatic windowing procedure
 342                    tau = self.S[e_name] / np.log((2 * self.e_n_tauint[e_name][1:] + 1) / (2 * self.e_n_tauint[e_name][1:] - 1))
 343                    g_w = np.exp(- np.arange(1, len(tau) + 1) / tau) - tau / np.sqrt(np.arange(1, len(tau) + 1) * e_N)
 344                    for n in range(1, w_max):
 345                        if g_w[n - 1] < 0 or n >= w_max - 1:
 346                            _compute_drho(n)
 347                            self.e_tauint[e_name] = self.e_n_tauint[e_name][n] * (1 + (2 * n + 1) / e_N) / (1 + 1 / e_N)  # Bias correction hep-lat/0306017 eq. (49)
 348                            self.e_dtauint[e_name] = self.e_n_dtauint[e_name][n]
 349                            self.e_dvalue[e_name] = np.sqrt(2 * self.e_tauint[e_name] * e_gamma[e_name][0] * (1 + 1 / e_N) / e_N)
 350                            self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt((n + 0.5) / e_N)
 351                            self.e_windowsize[e_name] = n
 352                            break
 353
 354            self._dvalue += self.e_dvalue[e_name] ** 2
 355            self.ddvalue += (self.e_dvalue[e_name] * self.e_ddvalue[e_name]) ** 2
 356
 357        for e_name in self.cov_names:
 358            self.e_dvalue[e_name] = np.sqrt(self.covobs[e_name].errsq())
 359            self.e_ddvalue[e_name] = 0
 360            self._dvalue += self.e_dvalue[e_name]**2
 361
 362        self._dvalue = np.sqrt(self._dvalue)
 363        if self._dvalue == 0.0:
 364            self.ddvalue = 0.0
 365        else:
 366            self.ddvalue = np.sqrt(self.ddvalue) / self._dvalue
 367
 368    gm = gamma_method
 369
 370    def _calc_gamma(self, deltas, idx, shape, w_max, fft, gapsize):
 371        """Calculate Gamma_{AA} from the deltas, which are defined on idx.
 372           idx is assumed to be a contiguous range (possibly with a stepsize != 1)
 373
 374        Parameters
 375        ----------
 376        deltas : list
 377            List of fluctuations
 378        idx : list
 379            List or range of configurations on which the deltas are defined.
 380        shape : int
 381            Number of configurations in idx.
 382        w_max : int
 383            Upper bound for the summation window.
 384        fft : bool
 385            determines whether the fft algorithm is used for the computation
 386            of the autocorrelation function.
 387        gapsize : int
 388            The target distance between two configurations. If longer distances
 389            are found in idx, the data is expanded.
 390        """
 391        gamma = np.zeros(w_max)
 392        deltas = _expand_deltas(deltas, idx, shape, gapsize)
 393        new_shape = len(deltas)
 394        if fft:
 395            max_gamma = min(new_shape, w_max)
 396            # The padding for the fft has to be even
 397            padding = new_shape + max_gamma + (new_shape + max_gamma) % 2
 398            gamma[:max_gamma] += np.fft.irfft(np.abs(np.fft.rfft(deltas, padding)) ** 2)[:max_gamma]
 399        else:
 400            for n in range(w_max):
 401                if new_shape - n >= 0:
 402                    gamma[n] += deltas[0:new_shape - n].dot(deltas[n:new_shape])
 403
 404        return gamma
 405
 406    def details(self, ens_content=True):
 407        """Output detailed properties of the Obs.
 408
 409        Parameters
 410        ----------
 411        ens_content : bool
 412            print details about the ensembles and replica if true.
 413        """
 414        if self.tag is not None:
 415            print("Description:", self.tag)
 416        if not hasattr(self, 'e_dvalue'):
 417            print(f'Result\t {self.value:3.8e}')
 418        else:
 419            if self.value == 0.0:
 420                percentage = np.nan
 421            else:
 422                percentage = np.abs(self._dvalue / self.value) * 100
 423            print(f'Result\t {self.value:3.8e} +/- {self._dvalue:3.8e} +/- {self.ddvalue:3.8e} ({percentage:3.3f}%)')
 424            if len(self.e_names) > 1:
 425                print(' Ensemble errors:')
 426            e_content = self.e_content
 427            for e_name in self.mc_names:
 428                gap = _determine_gap(self, e_content, e_name)
 429
 430                if len(self.e_names) > 1:
 431                    print('', e_name, f'\t {self.e_dvalue[e_name]:3.6e} +/- {self.e_ddvalue[e_name]:3.6e}')
 432                tau_string = " \N{GREEK SMALL LETTER TAU}_int\t " + _format_uncertainty(self.e_tauint[e_name], self.e_dtauint[e_name])
 433                tau_string += f" in units of {gap} config"
 434                if gap > 1:
 435                    tau_string += "s"
 436                if self.tau_exp[e_name] > 0:
 437                    tau_string = f"{tau_string: <45}" + f'\t(\N{GREEK SMALL LETTER TAU}_exp={self.tau_exp[e_name]:3.2f}, N_\N{GREEK SMALL LETTER SIGMA}={self.N_sigma[e_name]:g})'
 438                else:
 439                    tau_string = f"{tau_string: <45}" + f'\t(S={self.S[e_name]:3.2f})'
 440                print(tau_string)
 441            for e_name in self.cov_names:
 442                print('', e_name, f'\t {self.e_dvalue[e_name]:3.8e}')
 443        if ens_content is True:
 444            if len(self.e_names) == 1:
 445                print(self.N, 'samples in', len(self.e_names), 'ensemble:')
 446            else:
 447                print(self.N, 'samples in', len(self.e_names), 'ensembles:')
 448            my_string_list = []
 449            for key, value in sorted(self.e_content.items()):
 450                if key not in self.covobs:
 451                    my_string = '  ' + "\u00B7 Ensemble '" + key + "' "
 452                    if len(value) == 1:
 453                        my_string += f': {self.shape[value[0]]} configurations'
 454                        if isinstance(self.idl[value[0]], range):
 455                            my_string += f' (from {self.idl[value[0]].start} to {self.idl[value[0]][-1]}' + int(self.idl[value[0]].step != 1) * f' in steps of {self.idl[value[0]].step}' + ')'
 456                        else:
 457                            my_string += f' (irregular range from {self.idl[value[0]][0]} to {self.idl[value[0]][-1]})'
 458                    else:
 459                        sublist = []
 460                        for v in value:
 461                            my_substring = '    ' + "\u00B7 Replicum '" + v[len(key) + 1:] + "' "
 462                            my_substring += f': {self.shape[v]} configurations'
 463                            if isinstance(self.idl[v], range):
 464                                my_substring += f' (from {self.idl[v].start} to {self.idl[v][-1]}' + int(self.idl[v].step != 1) * f' in steps of {self.idl[v].step}' + ')'
 465                            else:
 466                                my_substring += f' (irregular range from {self.idl[v][0]} to {self.idl[v][-1]})'
 467                            sublist.append(my_substring)
 468
 469                        my_string += '\n' + '\n'.join(sublist)
 470                else:
 471                    my_string = '  ' + "\u00B7 Covobs   '" + key + "' "
 472                my_string_list.append(my_string)
 473            print('\n'.join(my_string_list))
 474
 475    def reweight(self, weight):
 476        """Reweight the obs with given rewighting factors.
 477
 478        Parameters
 479        ----------
 480        weight : Obs
 481            Reweighting factor. An Observable that has to be defined on a superset of the
 482            configurations in obs[i].idl for all i.
 483        all_configs : bool
 484            if True, the reweighted observables are normalized by the average of
 485            the reweighting factor on all configurations in weight.idl and not
 486            on the configurations in obs[i].idl. Default False.
 487        """
 488        return reweight(weight, [self])[0]
 489
 490    def is_zero_within_error(self, sigma=1):
 491        """Checks whether the observable is zero within 'sigma' standard errors.
 492
 493        Parameters
 494        ----------
 495        sigma : int
 496            Number of standard errors used for the check.
 497
 498        Works only properly when the gamma method was run.
 499        """
 500        return self.is_zero() or np.abs(self.value) <= sigma * self._dvalue
 501
 502    def is_zero(self, atol=1e-10):
 503        """Checks whether the observable is zero within a given tolerance.
 504
 505        Parameters
 506        ----------
 507        atol : float
 508            Absolute tolerance (for details see numpy documentation).
 509        """
 510        return np.isclose(0.0, self.value, 1e-14, atol) and all(np.allclose(0.0, delta, 1e-14, atol) for delta in self.deltas.values()) and all(np.allclose(0.0, delta.errsq(), 1e-14, atol) for delta in self.covobs.values())
 511
 512    def plot_tauint(self, save=None):
 513        """Plot integrated autocorrelation time for each ensemble.
 514
 515        Parameters
 516        ----------
 517        save : str
 518            saves the figure to a file named 'save' if.
 519        """
 520        if not hasattr(self, 'e_dvalue'):
 521            raise Exception('Run the gamma method first.')
 522
 523        for e, e_name in enumerate(self.mc_names):
 524            fig = plt.figure()
 525            plt.xlabel(r'$W$')
 526            plt.ylabel(r'$\tau_\mathrm{int}$')
 527            length = len(self.e_n_tauint[e_name])
 528            if self.tau_exp[e_name] > 0:
 529                base = self.e_n_tauint[e_name][self.e_windowsize[e_name]]
 530                x_help = np.arange(2 * self.tau_exp[e_name])
 531                y_help = (x_help + 1) * np.abs(self.e_rho[e_name][self.e_windowsize[e_name] + 1]) * (1 - x_help / (2 * (2 * self.tau_exp[e_name] - 1))) + base
 532                x_arr = np.arange(self.e_windowsize[e_name] + 1, self.e_windowsize[e_name] + 1 + 2 * self.tau_exp[e_name])
 533                plt.plot(x_arr, y_help, 'C' + str(e), linewidth=1, ls='--', marker=',')
 534                plt.errorbar([self.e_windowsize[e_name] + 2 * self.tau_exp[e_name]], [self.e_tauint[e_name]],
 535                             yerr=[self.e_dtauint[e_name]], fmt='C' + str(e), linewidth=1, capsize=2, marker='o', mfc=plt.rcParams['axes.facecolor'])
 536                xmax = self.e_windowsize[e_name] + 2 * self.tau_exp[e_name] + 1.5
 537                label = e_name + r', $\tau_\mathrm{exp}$=' + str(np.around(self.tau_exp[e_name], decimals=2))
 538            else:
 539                label = e_name + ', S=' + str(np.around(self.S[e_name], decimals=2))
 540                xmax = max(10.5, 2 * self.e_windowsize[e_name] - 0.5)
 541
 542            plt.errorbar(np.arange(length)[:int(xmax) + 1], self.e_n_tauint[e_name][:int(xmax) + 1], yerr=self.e_n_dtauint[e_name][:int(xmax) + 1], linewidth=1, capsize=2, label=label)
 543            plt.axvline(x=self.e_windowsize[e_name], color='C' + str(e), alpha=0.5, marker=',', ls='--')
 544            plt.legend()
 545            plt.xlim(-0.5, xmax)
 546            ylim = plt.ylim()
 547            plt.ylim(bottom=0.0, top=max(1.0, ylim[1]))
 548            plt.draw()
 549            if save:
 550                fig.savefig(save + "_" + str(e))
 551
 552    def plot_rho(self, save=None):
 553        """Plot normalized autocorrelation function time for each ensemble.
 554
 555        Parameters
 556        ----------
 557        save : str
 558            saves the figure to a file named 'save' if.
 559        """
 560        if not hasattr(self, 'e_dvalue'):
 561            raise Exception('Run the gamma method first.')
 562        for e, e_name in enumerate(self.mc_names):
 563            fig = plt.figure()
 564            plt.xlabel('W')
 565            plt.ylabel('rho')
 566            length = len(self.e_drho[e_name])
 567            plt.errorbar(np.arange(length), self.e_rho[e_name][:length], yerr=self.e_drho[e_name][:], linewidth=1, capsize=2)
 568            plt.axvline(x=self.e_windowsize[e_name], color='r', alpha=0.25, ls='--', marker=',')
 569            if self.tau_exp[e_name] > 0:
 570                plt.plot([self.e_windowsize[e_name] + 1, self.e_windowsize[e_name] + 1 + 2 * self.tau_exp[e_name]],
 571                         [self.e_rho[e_name][self.e_windowsize[e_name] + 1], 0], 'k-', lw=1)
 572                xmax = self.e_windowsize[e_name] + 2 * self.tau_exp[e_name] + 1.5
 573                plt.title('Rho ' + e_name + r', tau\_exp=' + str(np.around(self.tau_exp[e_name], decimals=2)))
 574            else:
 575                xmax = max(10.5, 2 * self.e_windowsize[e_name] - 0.5)
 576                plt.title('Rho ' + e_name + ', S=' + str(np.around(self.S[e_name], decimals=2)))
 577            plt.plot([-0.5, xmax], [0, 0], 'k--', lw=1)
 578            plt.xlim(-0.5, xmax)
 579            plt.draw()
 580            if save:
 581                fig.savefig(save + "_" + str(e))
 582
 583    def plot_rep_dist(self):
 584        """Plot replica distribution for each ensemble with more than one replicum."""
 585        if not hasattr(self, 'e_dvalue'):
 586            raise Exception('Run the gamma method first.')
 587        for _e, e_name in enumerate(self.mc_names):
 588            if len(self.e_content[e_name]) == 1:
 589                print('No replica distribution for a single replicum (', e_name, ')')
 590                continue
 591            r_length = []
 592            sub_r_mean = 0
 593            for r_name in self.e_content[e_name]:
 594                r_length.append(len(self.deltas[r_name]))
 595                sub_r_mean += self.shape[r_name] * self.r_values[r_name]
 596            e_N = np.sum(r_length)
 597            sub_r_mean /= e_N
 598            arr = np.zeros(len(self.e_content[e_name]))
 599            for r, r_name in enumerate(self.e_content[e_name]):
 600                arr[r] = (self.r_values[r_name] - sub_r_mean) / (self.e_dvalue[e_name] * np.sqrt(e_N / self.shape[r_name] - 1))
 601            plt.hist(arr, rwidth=0.8, bins=len(self.e_content[e_name]))
 602            plt.title('Replica distribution' + e_name + ' (mean=0, var=1)')
 603            plt.draw()
 604
 605    def plot_history(self, expand=True):
 606        """Plot derived Monte Carlo history for each ensemble
 607
 608        Parameters
 609        ----------
 610        expand : bool
 611            show expanded history for irregular Monte Carlo chains (default: True).
 612        """
 613        for _e, e_name in enumerate(self.mc_names):
 614            plt.figure()
 615            r_length = []
 616            tmp = []
 617            tmp_expanded = []
 618            for _r, r_name in enumerate(self.e_content[e_name]):
 619                tmp.append(self.deltas[r_name] + self.r_values[r_name])
 620                if expand:
 621                    tmp_expanded.append(_expand_deltas(self.deltas[r_name], list(self.idl[r_name]), self.shape[r_name], 1) + self.r_values[r_name])
 622                    r_length.append(len(tmp_expanded[-1]))
 623                else:
 624                    r_length.append(len(tmp[-1]))
 625            e_N = np.sum(r_length)
 626            x = np.arange(e_N)
 627            y_test = np.concatenate(tmp, axis=0)
 628            if expand:
 629                y = np.concatenate(tmp_expanded, axis=0)
 630            else:
 631                y = y_test
 632            plt.errorbar(x, y, fmt='.', markersize=3)
 633            plt.xlim(-0.5, e_N - 0.5)
 634            plt.title(e_name + f'\nskew: {skew(y_test):.3f} (p={skewtest(y_test).pvalue:.3f}), kurtosis: {kurtosis(y_test):.3f} (p={kurtosistest(y_test).pvalue:.3f})')
 635            plt.draw()
 636
 637    def plot_piechart(self, save=None):
 638        """Plot piechart which shows the fractional contribution of each
 639        ensemble to the error and returns a dictionary containing the fractions.
 640
 641        Parameters
 642        ----------
 643        save : str
 644            saves the figure to a file named 'save' if.
 645        """
 646        if not hasattr(self, 'e_dvalue'):
 647            raise Exception('Run the gamma method first.')
 648        if np.isclose(0.0, self._dvalue, atol=1e-15):
 649            raise ValueError('Error is 0.0')
 650        labels = self.e_names
 651        sizes = [self.e_dvalue[name] ** 2 for name in labels] / self._dvalue ** 2
 652        fig1, ax1 = plt.subplots()
 653        ax1.pie(sizes, labels=labels, startangle=90, normalize=True)
 654        ax1.axis('equal')
 655        plt.draw()
 656        if save:
 657            fig1.savefig(save)
 658
 659        return dict(zip(labels, sizes, strict=True))
 660
 661    def dump(self, filename, datatype="json.gz", description="", **kwargs):
 662        """Dump the Obs to a file 'name' of chosen format.
 663
 664        Parameters
 665        ----------
 666        filename : str
 667            name of the file to be saved.
 668        datatype : str
 669            Format of the exported file. Supported formats include
 670            "json.gz" and "pickle"
 671        description : str
 672            Description for output file, only relevant for json.gz format.
 673        path : str
 674            specifies a custom path for the file (default '.')
 675        """
 676        if 'path' in kwargs:
 677            file_name = kwargs.get('path') + '/' + filename
 678        else:
 679            file_name = filename
 680
 681        if datatype == "json.gz":
 682            from .input.json import dump_to_json
 683            dump_to_json([self], file_name, description=description)
 684        elif datatype == "pickle":
 685            with open(file_name + '.p', 'wb') as fb:
 686                pickle.dump(self, fb)
 687        else:
 688            raise TypeError("Unknown datatype " + str(datatype))
 689
 690    def export_jackknife(self):
 691        """Export jackknife samples from the Obs
 692
 693        Returns
 694        -------
 695        numpy.ndarray
 696            Returns a numpy array of length N + 1 where N is the number of samples
 697            for the given ensemble and replicum. The zeroth entry of the array contains
 698            the mean value of the Obs, entries 1 to N contain the N jackknife samples
 699            derived from the Obs. The current implementation only works for observables
 700            defined on exactly one ensemble and replicum. The derived jackknife samples
 701            should agree with samples from a full jackknife analysis up to O(1/N).
 702        """
 703
 704        if len(self.names) != 1:
 705            raise ValueError("'export_jackknife' is only implemented for Obs defined on one ensemble and replicum.")
 706
 707        name = self.names[0]
 708        full_data = self.deltas[name] + self.r_values[name]
 709        n = full_data.size
 710        mean = self.value
 711        tmp_jacks = np.zeros(n + 1)
 712        tmp_jacks[0] = mean
 713        tmp_jacks[1:] = (n * mean - full_data) / (n - 1)
 714        return tmp_jacks
 715
 716    def export_bootstrap(self, samples=500, random_numbers=None, save_rng=None):
 717        """Export bootstrap samples from the Obs
 718
 719        Parameters
 720        ----------
 721        samples : int
 722            Number of bootstrap samples to generate.
 723        random_numbers : np.ndarray
 724            Array of shape (samples, length) containing the random numbers to generate the bootstrap samples.
 725            If not provided the bootstrap samples are generated bashed on the md5 hash of the enesmble name.
 726        save_rng : str
 727            Save the random numbers to a file if a path is specified.
 728
 729        Returns
 730        -------
 731        numpy.ndarray
 732            Returns a numpy array of length N + 1 where N is the number of samples
 733            for the given ensemble and replicum. The zeroth entry of the array contains
 734            the mean value of the Obs, entries 1 to N contain the N import_bootstrap samples
 735            derived from the Obs. The current implementation only works for observables
 736            defined on exactly one ensemble and replicum. The derived bootstrap samples
 737            should agree with samples from a full bootstrap analysis up to O(1/N).
 738        """
 739        if len(self.names) != 1:
 740            raise ValueError("'export_boostrap' is only implemented for Obs defined on one ensemble and replicum.")
 741
 742        name = self.names[0]
 743        length = self.N
 744
 745        if random_numbers is None:
 746            seed = int(hashlib.md5(name.encode()).hexdigest(), 16) & 0xFFFFFFFF
 747            rng = np.random.default_rng(seed)
 748            random_numbers = rng.integers(0, length, size=(samples, length))
 749
 750        if save_rng is not None:
 751            np.savetxt(save_rng, random_numbers, fmt='%i')
 752
 753        proj = np.vstack([np.bincount(o, minlength=length) for o in random_numbers]) / length
 754        ret = np.zeros(samples + 1)
 755        ret[0] = self.value
 756        ret[1:] = proj @ (self.deltas[name] + self.r_values[name])
 757        return ret
 758
 759    def __float__(self):
 760        return float(self.value)
 761
 762    def __repr__(self):
 763        return 'Obs[' + str(self) + ']'
 764
 765    def __str__(self):
 766        return _format_uncertainty(self.value, self._dvalue)
 767
 768    def __format__(self, format_type):
 769        if format_type == "":
 770            significance = 2
 771        else:
 772            significance = int(float(format_type.replace("+", "").replace("-", "")))
 773        my_str = _format_uncertainty(self.value, self._dvalue,
 774                                     significance=significance)
 775        for char in ["+", " "]:
 776            if format_type.startswith(char):
 777                if my_str[0] != "-":
 778                    my_str = char + my_str
 779        return my_str
 780
 781    def __hash__(self):
 782        hash_tuple = (np.array([self.value]).astype(np.float32).data.tobytes(),)
 783        hash_tuple += tuple([o.astype(np.float32).data.tobytes() for o in self.deltas.values()])
 784        hash_tuple += tuple([np.array([o.errsq()]).astype(np.float32).data.tobytes() for o in self.covobs.values()])
 785        hash_tuple += tuple([o.encode() for o in self.names])
 786        m = hashlib.md5()
 787        [m.update(o) for o in hash_tuple]
 788        return int(m.hexdigest(), 16) & 0xFFFFFFFF
 789
 790    # Overload comparisons
 791    def __lt__(self, other):
 792        return self.value < other
 793
 794    def __le__(self, other):
 795        return self.value <= other
 796
 797    def __gt__(self, other):
 798        return self.value > other
 799
 800    def __ge__(self, other):
 801        return self.value >= other
 802
 803    def __eq__(self, other):
 804        if other is None:
 805            return False
 806        return (self - other).is_zero()
 807
 808    # Overload math operations
 809    def __add__(self, y):
 810        if isinstance(y, Obs):
 811            return derived_observable(lambda x, **kwargs: x[0] + x[1], [self, y], man_grad=[1, 1])
 812        else:
 813            if isinstance(y, np.ndarray):
 814                return np.array([self + o for o in y])
 815            elif isinstance(y, complex):
 816                return CObs(self, 0) + y
 817            elif y.__class__.__name__ in ['Corr', 'CObs']:
 818                return NotImplemented
 819            else:
 820                return derived_observable(lambda x, **kwargs: x[0] + y, [self], man_grad=[1])
 821
 822    def __radd__(self, y):
 823        return self + y
 824
 825    def __mul__(self, y):
 826        if isinstance(y, Obs):
 827            return derived_observable(lambda x, **kwargs: x[0] * x[1], [self, y], man_grad=[y.value, self.value])
 828        else:
 829            if isinstance(y, np.ndarray):
 830                return np.array([self * o for o in y])
 831            elif isinstance(y, complex):
 832                return CObs(self * y.real, self * y.imag)
 833            elif y.__class__.__name__ in ['Corr', 'CObs']:
 834                return NotImplemented
 835            else:
 836                return derived_observable(lambda x, **kwargs: x[0] * y, [self], man_grad=[y])
 837
 838    def __rmul__(self, y):
 839        return self * y
 840
 841    def __sub__(self, y):
 842        if isinstance(y, Obs):
 843            return derived_observable(lambda x, **kwargs: x[0] - x[1], [self, y], man_grad=[1, -1])
 844        else:
 845            if isinstance(y, np.ndarray):
 846                return np.array([self - o for o in y])
 847            elif y.__class__.__name__ in ['Corr', 'CObs']:
 848                return NotImplemented
 849            else:
 850                return derived_observable(lambda x, **kwargs: x[0] - y, [self], man_grad=[1])
 851
 852    def __rsub__(self, y):
 853        return -1 * (self - y)
 854
 855    def __pos__(self):
 856        return self
 857
 858    def __neg__(self):
 859        return -1 * self
 860
 861    def __truediv__(self, y):
 862        if isinstance(y, Obs):
 863            return derived_observable(lambda x, **kwargs: x[0] / x[1], [self, y], man_grad=[1 / y.value, - self.value / y.value ** 2])
 864        else:
 865            if isinstance(y, np.ndarray):
 866                return np.array([self / o for o in y])
 867            elif y.__class__.__name__ in ['Corr', 'CObs']:
 868                return NotImplemented
 869            else:
 870                return derived_observable(lambda x, **kwargs: x[0] / y, [self], man_grad=[1 / y])
 871
 872    def __rtruediv__(self, y):
 873        if isinstance(y, Obs):
 874            return derived_observable(lambda x, **kwargs: x[0] / x[1], [y, self], man_grad=[1 / self.value, - y.value / self.value ** 2])
 875        else:
 876            if isinstance(y, np.ndarray):
 877                return np.array([o / self for o in y])
 878            elif y.__class__.__name__ in ['Corr', 'CObs']:
 879                return NotImplemented
 880            else:
 881                return derived_observable(lambda x, **kwargs: y / x[0], [self], man_grad=[-y / self.value ** 2])
 882
 883    def __pow__(self, y):
 884        if isinstance(y, Obs):
 885            return derived_observable(lambda x, **kwargs: x[0] ** x[1], [self, y], man_grad=[y.value * self.value ** (y.value - 1), self.value ** y.value * np.log(self.value)])
 886        else:
 887            return derived_observable(lambda x, **kwargs: x[0] ** y, [self], man_grad=[y * self.value ** (y - 1)])
 888
 889    def __rpow__(self, y):
 890        return derived_observable(lambda x, **kwargs: y ** x[0], [self], man_grad=[y ** self.value * np.log(y)])
 891
 892    def __abs__(self):
 893        return derived_observable(lambda x: anp.abs(x[0]), [self])
 894
 895    # Overload numpy functions
 896    def sqrt(self):
 897        return derived_observable(lambda x, **kwargs: np.sqrt(x[0]), [self], man_grad=[1 / 2 / np.sqrt(self.value)])
 898
 899    def log(self):
 900        return derived_observable(lambda x, **kwargs: np.log(x[0]), [self], man_grad=[1 / self.value])
 901
 902    def exp(self):
 903        return derived_observable(lambda x, **kwargs: np.exp(x[0]), [self], man_grad=[np.exp(self.value)])
 904
 905    def sin(self):
 906        return derived_observable(lambda x, **kwargs: np.sin(x[0]), [self], man_grad=[np.cos(self.value)])
 907
 908    def cos(self):
 909        return derived_observable(lambda x, **kwargs: np.cos(x[0]), [self], man_grad=[-np.sin(self.value)])
 910
 911    def tan(self):
 912        return derived_observable(lambda x, **kwargs: np.tan(x[0]), [self], man_grad=[1 / np.cos(self.value) ** 2])
 913
 914    def arcsin(self):
 915        return derived_observable(lambda x: anp.arcsin(x[0]), [self])
 916
 917    def arccos(self):
 918        return derived_observable(lambda x: anp.arccos(x[0]), [self])
 919
 920    def arctan(self):
 921        return derived_observable(lambda x: anp.arctan(x[0]), [self])
 922
 923    def sinh(self):
 924        return derived_observable(lambda x, **kwargs: np.sinh(x[0]), [self], man_grad=[np.cosh(self.value)])
 925
 926    def cosh(self):
 927        return derived_observable(lambda x, **kwargs: np.cosh(x[0]), [self], man_grad=[np.sinh(self.value)])
 928
 929    def tanh(self):
 930        return derived_observable(lambda x, **kwargs: np.tanh(x[0]), [self], man_grad=[1 / np.cosh(self.value) ** 2])
 931
 932    def arcsinh(self):
 933        return derived_observable(lambda x: anp.arcsinh(x[0]), [self])
 934
 935    def arccosh(self):
 936        return derived_observable(lambda x: anp.arccosh(x[0]), [self])
 937
 938    def arctanh(self):
 939        return derived_observable(lambda x: anp.arctanh(x[0]), [self])
 940
 941
 942class CObs:
 943    """Class for a complex valued observable."""
 944    __slots__ = ['_imag', '_real', 'tag']
 945
 946    def __init__(self, real, imag=0.0):
 947        self._real = real
 948        self._imag = imag
 949        self.tag = None
 950
 951    @property
 952    def real(self):
 953        return self._real
 954
 955    @property
 956    def imag(self):
 957        return self._imag
 958
 959    def gamma_method(self, **kwargs):
 960        """Executes the gamma_method for the real and the imaginary part."""
 961        if isinstance(self.real, Obs):
 962            self.real.gamma_method(**kwargs)
 963        if isinstance(self.imag, Obs):
 964            self.imag.gamma_method(**kwargs)
 965
 966    def is_zero(self):
 967        """Checks whether both real and imaginary part are zero within machine precision."""
 968        return self.real == 0.0 and self.imag == 0.0
 969
 970    def conjugate(self):
 971        return CObs(self.real, -self.imag)
 972
 973    def __add__(self, other):
 974        if isinstance(other, np.ndarray):
 975            return other + self
 976        elif hasattr(other, 'real') and hasattr(other, 'imag'):
 977            return CObs(self.real + other.real,
 978                        self.imag + other.imag)
 979        else:
 980            return CObs(self.real + other, self.imag)
 981
 982    def __radd__(self, y):
 983        return self + y
 984
 985    def __sub__(self, other):
 986        if isinstance(other, np.ndarray):
 987            return -1 * (other - self)
 988        elif hasattr(other, 'real') and hasattr(other, 'imag'):
 989            return CObs(self.real - other.real, self.imag - other.imag)
 990        else:
 991            return CObs(self.real - other, self.imag)
 992
 993    def __rsub__(self, other):
 994        return -1 * (self - other)
 995
 996    def __mul__(self, other):
 997        if isinstance(other, np.ndarray):
 998            return other * self
 999        elif hasattr(other, 'real') and hasattr(other, 'imag'):
1000            if all(isinstance(i, Obs) for i in [self.real, self.imag, other.real, other.imag]):
1001                return CObs(derived_observable(lambda x, **kwargs: x[0] * x[1] - x[2] * x[3],
1002                                               [self.real, other.real, self.imag, other.imag],
1003                                               man_grad=[other.real.value, self.real.value, -other.imag.value, -self.imag.value]),
1004                            derived_observable(lambda x, **kwargs: x[2] * x[1] + x[0] * x[3],
1005                                               [self.real, other.real, self.imag, other.imag],
1006                                               man_grad=[other.imag.value, self.imag.value, other.real.value, self.real.value]))
1007            elif getattr(other, 'imag', 0) != 0:
1008                return CObs(self.real * other.real - self.imag * other.imag,
1009                            self.imag * other.real + self.real * other.imag)
1010            else:
1011                return CObs(self.real * other.real, self.imag * other.real)
1012        else:
1013            return CObs(self.real * other, self.imag * other)
1014
1015    def __rmul__(self, other):
1016        return self * other
1017
1018    def __truediv__(self, other):
1019        if isinstance(other, np.ndarray):
1020            return 1 / (other / self)
1021        elif hasattr(other, 'real') and hasattr(other, 'imag'):
1022            r = other.real ** 2 + other.imag ** 2
1023            return CObs((self.real * other.real + self.imag * other.imag) / r, (self.imag * other.real - self.real * other.imag) / r)
1024        else:
1025            return CObs(self.real / other, self.imag / other)
1026
1027    def __rtruediv__(self, other):
1028        r = self.real ** 2 + self.imag ** 2
1029        if hasattr(other, 'real') and hasattr(other, 'imag'):
1030            return CObs((self.real * other.real + self.imag * other.imag) / r, (self.real * other.imag - self.imag * other.real) / r)
1031        else:
1032            return CObs(self.real * other / r, -self.imag * other / r)
1033
1034    def __abs__(self):
1035        return np.sqrt(self.real**2 + self.imag**2)
1036
1037    def __pos__(self):
1038        return self
1039
1040    def __neg__(self):
1041        return -1 * self
1042
1043    def __eq__(self, other):
1044        return self.real == other.real and self.imag == other.imag
1045
1046    __hash__ = None
1047
1048    def __str__(self):
1049        return '(' + str(self.real) + int(self.imag >= 0.0) * '+' + str(self.imag) + 'j)'
1050
1051    def __repr__(self):
1052        return 'CObs[' + str(self) + ']'
1053
1054    def __format__(self, format_type):
1055        if format_type == "":
1056            significance = 2
1057            format_type = "2"
1058        else:
1059            significance = int(float(format_type.replace("+", "").replace("-", "")))
1060        return f"({self.real:{format_type}}{self.imag:+{significance}}j)"
1061
1062
1063def gamma_method(x, **kwargs):
1064    """Vectorized version of the gamma_method applicable to lists or arrays of Obs.
1065
1066    See docstring of pe.Obs.gamma_method for details.
1067    """
1068    return np.vectorize(lambda o: o.gm(**kwargs))(x)
1069
1070
1071gm = gamma_method
1072
1073
1074def _format_uncertainty(value, dvalue, significance=2):
1075    """Creates a string of a value and its error in paranthesis notation, e.g., 13.02(45)"""
1076    if dvalue == 0.0 or (not np.isfinite(dvalue)):
1077        return str(value)
1078    if not isinstance(significance, int):
1079        raise TypeError("significance needs to be an integer.")
1080    if significance < 1:
1081        raise ValueError("significance needs to be larger than zero.")
1082    fexp = np.floor(np.log10(dvalue))
1083    if fexp < 0.0:
1084        return '{:{form}}({:1.0f})'.format(value, dvalue * 10 ** (-fexp + significance - 1), form='.' + str(-int(fexp) + significance - 1) + 'f')
1085    elif fexp == 0.0:
1086        return f"{value:.{significance - 1}f}({dvalue:1.{significance - 1}f})"
1087    else:
1088        return f"{value:.{max(0, int(significance - fexp - 1))}f}({dvalue:2.{max(0, int(significance - fexp - 1))}f})"
1089
1090
1091def _expand_deltas(deltas, idx, shape, gapsize):
1092    """Expand deltas defined on idx to a regular range with spacing gapsize between two
1093       configurations and where holes are filled by 0.
1094       If idx is of type range, the deltas are not changed if the idx.step == gapsize.
1095
1096    Parameters
1097    ----------
1098    deltas : list
1099        List of fluctuations
1100    idx : list
1101        List or range of configs on which the deltas are defined, has to be sorted in ascending order.
1102    shape : int
1103        Number of configs in idx.
1104    gapsize : int
1105        The target distance between two configurations. If longer distances
1106        are found in idx, the data is expanded.
1107    """
1108    if isinstance(idx, range):
1109        if (idx.step == gapsize):
1110            return deltas
1111    ret = np.zeros((idx[-1] - idx[0] + gapsize) // gapsize)
1112    for i in range(shape):
1113        ret[(idx[i] - idx[0]) // gapsize] = deltas[i]
1114    return ret
1115
1116
1117def _merge_idx(idl):
1118    """Returns the union of all lists in idl as range or sorted list
1119
1120    Parameters
1121    ----------
1122    idl : list
1123        List of lists or ranges.
1124    """
1125
1126    if _check_lists_equal(idl):
1127        return idl[0]
1128
1129    idunion = sorted(set().union(*idl))
1130
1131    # Check whether idunion can be expressed as range
1132    idrange = range(idunion[0], idunion[-1] + 1, idunion[1] - idunion[0])
1133    idtest = [list(idrange), idunion]
1134    if _check_lists_equal(idtest):
1135        return idrange
1136
1137    return idunion
1138
1139
1140def _intersection_idx(idl):
1141    """Returns the intersection of all lists in idl as range or sorted list
1142
1143    Parameters
1144    ----------
1145    idl : list
1146        List of lists or ranges.
1147    """
1148
1149    if _check_lists_equal(idl):
1150        return idl[0]
1151
1152    idinter = sorted(set.intersection(*[set(o) for o in idl]))
1153
1154    # Check whether idinter can be expressed as range
1155    try:
1156        idrange = range(idinter[0], idinter[-1] + 1, idinter[1] - idinter[0])
1157        idtest = [list(idrange), idinter]
1158        if _check_lists_equal(idtest):
1159            return idrange
1160    except IndexError:
1161        pass
1162
1163    return idinter
1164
1165
1166def _expand_deltas_for_merge(deltas, idx, shape, new_idx, scalefactor):
1167    """Expand deltas defined on idx to the list of configs that is defined by new_idx.
1168       New, empty entries are filled by 0. If idx and new_idx are of type range, the smallest
1169       common divisor of the step sizes is used as new step size.
1170
1171    Parameters
1172    ----------
1173    deltas : list
1174        List of fluctuations
1175    idx : list
1176        List or range of configs on which the deltas are defined.
1177        Has to be a subset of new_idx and has to be sorted in ascending order.
1178    shape : list
1179        Number of configs in idx.
1180    new_idx : list
1181        List of configs that defines the new range, has to be sorted in ascending order.
1182    scalefactor : float
1183        An additional scaling factor that can be applied to scale the fluctuations,
1184        e.g., when Obs with differing numbers of replica are merged.
1185    """
1186    if type(idx) is range and type(new_idx) is range:
1187        if idx == new_idx:
1188            if scalefactor == 1:
1189                return deltas
1190            else:
1191                return deltas * scalefactor
1192    ret = np.zeros(new_idx[-1] - new_idx[0] + 1)
1193    for i in range(shape):
1194        ret[idx[i] - new_idx[0]] = deltas[i]
1195    return np.array([ret[new_idx[i] - new_idx[0]] for i in range(len(new_idx))]) * len(new_idx) / len(idx) * scalefactor
1196
1197
1198def derived_observable(func, data, array_mode=False, **kwargs):
1199    """Construct a derived Obs according to func(data, **kwargs) using automatic differentiation.
1200
1201    Parameters
1202    ----------
1203    func : object
1204        arbitrary function of the form func(data, **kwargs). For the
1205        automatic differentiation to work, all numpy functions have to have
1206        the autograd wrapper (use 'import autograd.numpy as anp').
1207    data : list
1208        list of Obs, e.g. [obs1, obs2, obs3].
1209    num_grad : bool
1210        if True, numerical derivatives are used instead of autograd
1211        (default False). To control the numerical differentiation the
1212        kwargs of numdifftools.step_generators.MaxStepGenerator
1213        can be used.
1214    man_grad : list
1215        manually supply a list or an array which contains the jacobian
1216        of func. Use cautiously, supplying the wrong derivative will
1217        not be intercepted.
1218
1219    Notes
1220    -----
1221    For simple mathematical operations it can be practical to use anonymous
1222    functions. For the ratio of two observables one can e.g. use
1223
1224    new_obs = derived_observable(lambda x: x[0] / x[1], [obs1, obs2])
1225    """
1226
1227    data = np.asarray(data)
1228    raveled_data = data.ravel()
1229
1230    # Workaround for matrix operations containing non Obs data
1231    if not all(isinstance(x, Obs) for x in raveled_data):
1232        for i in range(len(raveled_data)):
1233            if isinstance(raveled_data[i], (int, float)):
1234                raveled_data[i] = cov_Obs(raveled_data[i], 0.0, "###dummy_covobs###")
1235
1236    allcov = {}
1237    for o in raveled_data:
1238        for name in o.cov_names:
1239            if name in allcov:
1240                if not np.allclose(allcov[name], o.covobs[name].cov):
1241                    raise Exception(f'Inconsistent covariance matrices for {name}!')
1242            else:
1243                allcov[name] = o.covobs[name].cov
1244
1245    n_obs = len(raveled_data)
1246    new_names = sorted(set([y for x in [o.names for o in raveled_data] for y in x]))
1247    new_cov_names = sorted(set([y for x in [o.cov_names for o in raveled_data] for y in x]))
1248    new_sample_names = sorted(set(new_names) - set(new_cov_names))
1249
1250    reweighted = len(list(filter(lambda o: o.reweighted is True, raveled_data))) > 0
1251
1252    if data.ndim == 1:
1253        values = np.array([o.value for o in data])
1254    else:
1255        values = np.vectorize(lambda x: x.value)(data)
1256
1257    new_values = func(values, **kwargs)
1258
1259    multi = int(isinstance(new_values, np.ndarray))
1260
1261    new_r_values = {}
1262    new_idl_d = {}
1263    for name in new_sample_names:
1264        idl = []
1265        tmp_values = np.zeros(n_obs)
1266        for i, item in enumerate(raveled_data):
1267            tmp_values[i] = item.r_values.get(name, item.value)
1268            tmp_idl = item.idl.get(name)
1269            if tmp_idl is not None:
1270                idl.append(tmp_idl)
1271        if multi > 0:
1272            tmp_values = np.array(tmp_values).reshape(data.shape)
1273        new_r_values[name] = func(tmp_values, **kwargs)
1274        new_idl_d[name] = _merge_idx(idl)
1275
1276    def _compute_scalefactor_missing_rep(obs):
1277        """
1278        Computes the scale factor that is to be multiplied with the deltas
1279        in the case where Obs with different subsets of replica are merged.
1280        Returns a dictionary with the scale factor for each Monte Carlo name.
1281
1282        Parameters
1283        ----------
1284        obs : Obs
1285            The observable corresponding to the deltas that are to be scaled
1286        """
1287        scalef_d = {}
1288        for mc_name in obs.mc_names:
1289            mc_idl_d = [name for name in obs.idl if name.startswith(mc_name + '|')]
1290            new_mc_idl_d = [name for name in new_idl_d if name.startswith(mc_name + '|')]
1291            if len(mc_idl_d) > 0 and len(mc_idl_d) < len(new_mc_idl_d):
1292                scalef_d[mc_name] = sum([len(new_idl_d[name]) for name in new_mc_idl_d]) / sum([len(new_idl_d[name]) for name in mc_idl_d])
1293        return scalef_d
1294
1295    if 'man_grad' in kwargs:
1296        deriv = np.asarray(kwargs.get('man_grad'))
1297        if new_values.shape + data.shape != deriv.shape:
1298            raise ValueError('Manual derivative does not have correct shape.')
1299    elif kwargs.get('num_grad') is True:
1300        if multi > 0:
1301            raise NotImplementedError('Multi mode currently not supported for numerical derivative')
1302        options = {
1303            'base_step': 0.1,
1304            'step_ratio': 2.5}
1305        for key in options:
1306            kwarg = kwargs.get(key)
1307            if kwarg is not None:
1308                options[key] = kwarg
1309        tmp_df = nd.Gradient(func, order=4, **{k: v for k, v in options.items() if v is not None})(values, **kwargs)
1310        if tmp_df.size == 1:
1311            deriv = np.array([tmp_df.real])
1312        else:
1313            deriv = tmp_df.real
1314    else:
1315        deriv = jacobian(func)(values, **kwargs)
1316
1317    final_result = np.zeros(new_values.shape, dtype=object)
1318
1319    if array_mode is True:
1320
1321        class _Zero_grad:
1322            def __init__(self, N):
1323                self.grad = np.zeros((N, 1))
1324
1325        new_covobs_lengths = dict(set([y for x in [[(n, o.covobs[n].N) for n in o.cov_names] for o in raveled_data] for y in x]))
1326        d_extracted = {}
1327        g_extracted = {}
1328        for name in new_sample_names:
1329            d_extracted[name] = []
1330            ens_length = len(new_idl_d[name])
1331            for dat in data:
1332                d_extracted[name].append(np.array([_expand_deltas_for_merge(o.deltas.get(name, np.zeros(ens_length)), o.idl.get(name, new_idl_d[name]), o.shape.get(name, ens_length), new_idl_d[name], _compute_scalefactor_missing_rep(o).get(name.split('|')[0], 1)) for o in dat.reshape(np.prod(dat.shape))]).reshape((*dat.shape, ens_length)))
1333        for name in new_cov_names:
1334            g_extracted[name] = []
1335            zero_grad = _Zero_grad(new_covobs_lengths[name])
1336            for dat in data:
1337                g_extracted[name].append(np.array([o.covobs.get(name, zero_grad).grad for o in dat.reshape(np.prod(dat.shape))]).reshape((*dat.shape, new_covobs_lengths[name], 1)))
1338
1339    for i_val, new_val in np.ndenumerate(new_values):
1340        new_deltas = {}
1341        new_grad = {}
1342        if array_mode is True:
1343            for name in new_sample_names:
1344                ens_length = d_extracted[name][0].shape[-1]
1345                new_deltas[name] = np.zeros(ens_length)
1346                for i_dat, dat in enumerate(d_extracted[name]):
1347                    new_deltas[name] += np.tensordot(deriv[(*i_val, i_dat)], dat)
1348            for name in new_cov_names:
1349                new_grad[name] = 0
1350                for i_dat, dat in enumerate(g_extracted[name]):
1351                    new_grad[name] += np.tensordot(deriv[(*i_val, i_dat)], dat)
1352        else:
1353            for j_obs, obs in np.ndenumerate(data):
1354                scalef_d = _compute_scalefactor_missing_rep(obs)
1355                for name in obs.names:
1356                    if name in obs.cov_names:
1357                        new_grad[name] = new_grad.get(name, 0) + deriv[i_val + j_obs] * obs.covobs[name].grad
1358                    else:
1359                        new_deltas[name] = new_deltas.get(name, 0) + deriv[i_val + j_obs] * _expand_deltas_for_merge(obs.deltas[name], obs.idl[name], obs.shape[name], new_idl_d[name], scalef_d.get(name.split('|')[0], 1))
1360
1361        new_covobs = {name: Covobs(0, allcov[name], name, grad=new_grad[name]) for name in new_grad}
1362
1363        if not set(new_covobs.keys()).isdisjoint(new_deltas.keys()):
1364            raise ValueError('The same name has been used for deltas and covobs!')
1365        new_samples = []
1366        new_means = []
1367        new_idl = []
1368        new_names_obs = []
1369        for name in new_names:
1370            if name not in new_covobs:
1371                new_samples.append(new_deltas[name])
1372                new_idl.append(new_idl_d[name])
1373                new_means.append(new_r_values[name][i_val])
1374                new_names_obs.append(name)
1375        final_result[i_val] = Obs(new_samples, new_names_obs, means=new_means, idl=new_idl)
1376        for name in new_covobs:
1377            final_result[i_val].names.append(name)
1378        final_result[i_val]._covobs = new_covobs
1379        final_result[i_val]._value = new_val
1380        final_result[i_val].reweighted = reweighted
1381
1382    if multi == 0:
1383        final_result = final_result.item()
1384
1385    return final_result
1386
1387
1388def _reduce_deltas(deltas, idx_old, idx_new):
1389    """Extract deltas defined on idx_old on all configs of idx_new.
1390
1391    Assumes, that idx_old and idx_new are correctly defined idl, i.e., they
1392    are ordered in an ascending order.
1393
1394    Parameters
1395    ----------
1396    deltas : list
1397        List of fluctuations
1398    idx_old : list
1399        List or range of configs on which the deltas are defined
1400    idx_new : list
1401        List of configs for which we want to extract the deltas.
1402        Has to be a subset of idx_old.
1403    """
1404    if not len(deltas) == len(idx_old):
1405        raise ValueError(f'Length of deltas and idx_old have to be the same: {len(deltas)} != {len(idx_old)}')
1406    if type(idx_old) is range and type(idx_new) is range:
1407        if idx_old == idx_new:
1408            return deltas
1409    if _check_lists_equal([idx_old, idx_new]):
1410        return deltas
1411    indices = np.intersect1d(idx_old, idx_new, assume_unique=True, return_indices=True)[1]
1412    if len(indices) < len(idx_new):
1413        raise ValueError('Error in _reduce_deltas: Config of idx_new not in idx_old')
1414    return np.array(deltas)[indices]
1415
1416
1417def reweight(weight, obs, **kwargs):
1418    """Reweight a list of observables.
1419
1420    Parameters
1421    ----------
1422    weight : Obs
1423        Reweighting factor. An Observable that has to be defined on a superset of the
1424        configurations in obs[i].idl for all i.
1425    obs : list
1426        list of Obs, e.g. [obs1, obs2, obs3].
1427    all_configs : bool
1428        if True, the reweighted observables are normalized by the average of
1429        the reweighting factor on all configurations in weight.idl and not
1430        on the configurations in obs[i].idl. Default False.
1431    """
1432    result = []
1433    for i in range(len(obs)):
1434        if len(obs[i].cov_names):
1435            raise ValueError('Error: Not possible to reweight an Obs that contains covobs!')
1436        if not set(obs[i].names).issubset(weight.names):
1437            raise ValueError('Error: Ensembles do not fit')
1438        if len(obs[i].mc_names) > 1 or len(weight.mc_names) > 1:
1439            raise ValueError('Error: Cannot reweight an Obs that contains multiple ensembles.')
1440        for name in obs[i].names:
1441            if not set(obs[i].idl[name]).issubset(weight.idl[name]):
1442                raise ValueError(f'obs[{i}] has to be defined on a subset of the configs in weight.idl[{name}]!')
1443        new_samples = []
1444        w_deltas = {}
1445        for name in sorted(obs[i].names):
1446            w_deltas[name] = _reduce_deltas(weight.deltas[name], weight.idl[name], obs[i].idl[name])
1447            new_samples.append((w_deltas[name] + weight.r_values[name]) * (obs[i].deltas[name] + obs[i].r_values[name]))
1448        tmp_obs = Obs(new_samples, sorted(obs[i].names), idl=[obs[i].idl[name] for name in sorted(obs[i].names)])
1449
1450        if kwargs.get('all_configs'):
1451            new_weight = weight
1452        else:
1453            new_weight = Obs([w_deltas[name] + weight.r_values[name] for name in sorted(obs[i].names)], sorted(obs[i].names), idl=[obs[i].idl[name] for name in sorted(obs[i].names)])
1454
1455        result.append(tmp_obs / new_weight)
1456        result[-1].reweighted = True
1457
1458    return result
1459
1460
1461def correlate(obs_a, obs_b):
1462    """Correlate two observables.
1463
1464    Parameters
1465    ----------
1466    obs_a : Obs
1467        First observable
1468    obs_b : Obs
1469        Second observable
1470
1471    Notes
1472    -----
1473    Keep in mind to only correlate primary observables which have not been reweighted
1474    yet. The reweighting has to be applied after correlating the observables.
1475    Only works if a single ensemble is present in the Obs.
1476    Currently only works if ensemble content is identical (this is not strictly necessary).
1477    """
1478
1479    if len(obs_a.mc_names) > 1 or len(obs_b.mc_names) > 1:
1480        raise ValueError('Error: Cannot correlate Obs that contain multiple ensembles.')
1481    if sorted(obs_a.names) != sorted(obs_b.names):
1482        raise ValueError(f"Ensembles do not fit {set(sorted(obs_a.names)) ^ set(sorted(obs_b.names))}")
1483    if len(obs_a.cov_names) or len(obs_b.cov_names):
1484        raise ValueError('Error: Not possible to correlate Obs that contain covobs!')
1485    for name in obs_a.names:
1486        if obs_a.shape[name] != obs_b.shape[name]:
1487            raise ValueError('Shapes of ensemble', name, 'do not fit')
1488        if obs_a.idl[name] != obs_b.idl[name]:
1489            raise ValueError('idl of ensemble', name, 'do not fit')
1490
1491    if obs_a.reweighted is True:
1492        warnings.warn("The first observable is already reweighted.", RuntimeWarning, stacklevel=2)
1493    if obs_b.reweighted is True:
1494        warnings.warn("The second observable is already reweighted.", RuntimeWarning, stacklevel=2)
1495
1496    new_samples = []
1497    new_idl = []
1498    for name in sorted(obs_a.names):
1499        new_samples.append((obs_a.deltas[name] + obs_a.r_values[name]) * (obs_b.deltas[name] + obs_b.r_values[name]))
1500        new_idl.append(obs_a.idl[name])
1501
1502    o = Obs(new_samples, sorted(obs_a.names), idl=new_idl)
1503    o.reweighted = obs_a.reweighted or obs_b.reweighted
1504    return o
1505
1506
1507def covariance(obs, visualize=False, correlation=False, smooth=None, **kwargs):
1508    r'''Calculates the error covariance matrix of a set of observables.
1509
1510    WARNING: This function should be used with care, especially for observables with support on multiple
1511             ensembles with differing autocorrelations. See the notes below for details.
1512
1513    The gamma method has to be applied first to all observables.
1514
1515    Parameters
1516    ----------
1517    obs : list or numpy.ndarray
1518        List or one dimensional array of Obs
1519    visualize : bool
1520        If True plots the corresponding normalized correlation matrix (default False).
1521    correlation : bool
1522        If True the correlation matrix instead of the error covariance matrix is returned (default False).
1523    smooth : None or int
1524        If smooth is an integer 'E' between 2 and the dimension of the matrix minus 1 the eigenvalue
1525        smoothing procedure of hep-lat/9412087 is applied to the correlation matrix which leaves the
1526        largest E eigenvalues essentially unchanged and smoothes the smaller eigenvalues to avoid extremely
1527        small ones.
1528
1529    Notes
1530    -----
1531    The error covariance is defined such that it agrees with the squared standard error for two identical observables
1532    $$\operatorname{cov}(a,a)=\sum_{s=1}^N\delta_a^s\delta_a^s/N^2=\Gamma_{aa}(0)/N=\operatorname{var}(a)/N=\sigma_a^2$$
1533    in the absence of autocorrelation.
1534    The error covariance is estimated by calculating the correlation matrix assuming no autocorrelation and then rescaling the correlation matrix by the full errors including the previous gamma method estimate for the autocorrelation of the observables. The covariance at windowsize 0 is guaranteed to be positive semi-definite
1535    $$\sum_{i,j}v_i\Gamma_{ij}(0)v_j=\frac{1}{N}\sum_{s=1}^N\sum_{i,j}v_i\delta_i^s\delta_j^s v_j=\frac{1}{N}\sum_{s=1}^N\sum_{i}|v_i\delta_i^s|^2\geq 0\,,$$ for every $v\in\mathbb{R}^M$, while such an identity does not hold for larger windows/lags.
1536    For observables defined on a single ensemble our approximation is equivalent to assuming that the integrated autocorrelation time of an off-diagonal element is equal to the geometric mean of the integrated autocorrelation times of the corresponding diagonal elements.
1537    $$\tau_{\mathrm{int}, ij}=\sqrt{\tau_{\mathrm{int}, i}\times \tau_{\mathrm{int}, j}}$$
1538    This construction ensures that the estimated covariance matrix is positive semi-definite (up to numerical rounding errors).
1539    '''
1540
1541    length = len(obs)
1542
1543    max_samples = np.max([o.N for o in obs])
1544    if max_samples <= length and not [item for sublist in [o.cov_names for o in obs] for item in sublist]:
1545        warnings.warn(f"The dimension of the covariance matrix ({length}) is larger or equal to the number of samples ({max_samples}). This will result in a rank deficient matrix.", RuntimeWarning, stacklevel=2)
1546
1547    cov = np.zeros((length, length))
1548    for i in range(length):
1549        for j in range(i, length):
1550            cov[i, j] = _covariance_element(obs[i], obs[j])
1551    cov = cov + cov.T - np.diag(np.diag(cov))
1552
1553    corr = np.diag(1 / np.sqrt(np.diag(cov))) @ cov @ np.diag(1 / np.sqrt(np.diag(cov)))
1554
1555    if isinstance(smooth, int):
1556        corr = _smooth_eigenvalues(corr, smooth)
1557
1558    if visualize:
1559        plt.matshow(corr, vmin=-1, vmax=1)
1560        plt.set_cmap('RdBu')
1561        plt.colorbar()
1562        plt.draw()
1563
1564    if correlation is True:
1565        return corr
1566
1567    errors = [o.dvalue for o in obs]
1568    cov = np.diag(errors) @ corr @ np.diag(errors)
1569
1570    eigenvalues = np.linalg.eigh(cov)[0]
1571    if not np.all(eigenvalues >= 0):
1572        warnings.warn("Covariance matrix is not positive semi-definite (Eigenvalues: " + str(eigenvalues) + ")", RuntimeWarning, stacklevel=2)
1573
1574    return cov
1575
1576
1577def invert_corr_cov_cholesky(corr, inverrdiag):
1578    """Constructs a lower triangular matrix `chol` via the Cholesky decomposition of the correlation matrix `corr`
1579       and then returns the inverse covariance matrix `chol_inv` as a lower triangular matrix by solving `chol * x = inverrdiag`.
1580
1581    Parameters
1582    ----------
1583    corr : np.ndarray
1584           correlation matrix
1585    inverrdiag : np.ndarray
1586              diagonal matrix, the entries are the inverse errors of the data points considered
1587    """
1588
1589    condn = np.linalg.cond(corr)
1590    if condn > 0.1 / np.finfo(float).eps:
1591        raise ValueError(f"Cannot invert correlation matrix as its condition number exceeds machine precision ({condn:1.2e})")
1592    if condn > 1e13:
1593        warnings.warn(f"Correlation matrix may be ill-conditioned, condition number: {{{condn:1.2e}}}", RuntimeWarning, stacklevel=2)
1594    chol = np.linalg.cholesky(corr)
1595    chol_inv = scipy.linalg.solve_triangular(chol, inverrdiag, lower=True)
1596
1597    return chol_inv
1598
1599
1600def sort_corr(corr, kl, yd):
1601    """ Reorders a correlation matrix to match the alphabetical order of its underlying y data.
1602
1603    The ordering of the input correlation matrix `corr` is given by the list of keys `kl`.
1604    The input dictionary `yd` (with the same keys `kl`) must contain the corresponding y data
1605    that the correlation matrix is based on.
1606    This function sorts the list of keys `kl` alphabetically and sorts the matrix `corr`
1607    according to this alphabetical order such that the sorted matrix `corr_sorted` corresponds
1608    to the y data `yd` when arranged in an alphabetical order by its keys.
1609
1610    Parameters
1611    ----------
1612    corr : np.ndarray
1613        A square correlation matrix constructed using the order of the y data specified by `kl`.
1614        The dimensions of `corr` should match the total number of y data points in `yd` combined.
1615    kl : list of str
1616        A list of keys that denotes the order in which the y data from `yd` was used to build the
1617        input correlation matrix `corr`.
1618    yd : dict of list
1619        A dictionary where each key corresponds to a unique identifier, and its value is a list of
1620        y data points. The total number of y data points across all keys must match the dimensions
1621        of `corr`. The lists in the dictionary can be lists of Obs.
1622
1623    Returns
1624    -------
1625    np.ndarray
1626        A new, sorted correlation matrix that corresponds to the y data from `yd` when arranged alphabetically by its keys.
1627
1628    Example
1629    -------
1630    >>> import numpy as np
1631    >>> import pyerrors as pe
1632    >>> corr = np.array([[1, 0.2, 0.3], [0.2, 1, 0.4], [0.3, 0.4, 1]])
1633    >>> kl = ['b', 'a']
1634    >>> yd = {'a': [1, 2], 'b': [3]}
1635    >>> sorted_corr = pe.obs.sort_corr(corr, kl, yd)
1636    >>> print(sorted_corr)
1637    array([[1. , 0.3, 0.4],
1638           [0.3, 1. , 0.2],
1639           [0.4, 0.2, 1. ]])
1640
1641    """
1642    kl_sorted = sorted(kl)
1643
1644    posd = {}
1645    ofs = 0
1646    for _ki, k in enumerate(kl):
1647        posd[k] = [i + ofs for i in range(len(yd[k]))]
1648        ofs += len(posd[k])
1649
1650    mapping = []
1651    for k in kl_sorted:
1652        for i in range(len(yd[k])):
1653            mapping.append(posd[k][i])
1654
1655    corr_sorted = np.zeros_like(corr)
1656    for i in range(corr.shape[0]):
1657        for j in range(corr.shape[0]):
1658            corr_sorted[i][j] = corr[mapping[i]][mapping[j]]
1659
1660    return corr_sorted
1661
1662
1663def _smooth_eigenvalues(corr, E):
1664    """Eigenvalue smoothing as described in hep-lat/9412087
1665
1666    corr : np.ndarray
1667        correlation matrix
1668    E : integer
1669        Number of eigenvalues to be left substantially unchanged
1670    """
1671    if not (2 < E < corr.shape[0] - 1):
1672        raise ValueError(f"'E' has to be between 2 and the dimension of the correlation matrix minus 1 ({corr.shape[0] - 1}).")
1673    vals, vec = np.linalg.eigh(corr)
1674    lambda_min = np.mean(vals[:-E])
1675    vals[vals < lambda_min] = lambda_min
1676    vals /= np.mean(vals)
1677    return vec @ np.diag(vals) @ vec.T
1678
1679
1680def _covariance_element(obs1, obs2):
1681    """Estimates the covariance of two Obs objects, neglecting autocorrelations."""
1682
1683    def calc_gamma(deltas1, deltas2, idx1, idx2, new_idx):
1684        deltas1 = _reduce_deltas(deltas1, idx1, new_idx)
1685        deltas2 = _reduce_deltas(deltas2, idx2, new_idx)
1686        return np.sum(deltas1 * deltas2)
1687
1688    if set(obs1.names).isdisjoint(set(obs2.names)):
1689        return 0.0
1690
1691    if not hasattr(obs1, 'e_dvalue') or not hasattr(obs2, 'e_dvalue'):
1692        raise Exception('The gamma method has to be applied to both Obs first.')
1693
1694    dvalue = 0.0
1695
1696    for e_name in obs1.mc_names:
1697
1698        if e_name not in obs2.mc_names:
1699            continue
1700
1701        idl_d = {}
1702        for r_name in obs1.e_content[e_name]:
1703            if r_name not in obs2.e_content[e_name]:
1704                continue
1705            idl_d[r_name] = _intersection_idx([obs1.idl[r_name], obs2.idl[r_name]])
1706
1707        gamma = 0.0
1708
1709        for r_name in obs1.e_content[e_name]:
1710            if r_name not in obs2.e_content[e_name]:
1711                continue
1712            if len(idl_d[r_name]) == 0:
1713                continue
1714            gamma += calc_gamma(obs1.deltas[r_name], obs2.deltas[r_name], obs1.idl[r_name], obs2.idl[r_name], idl_d[r_name])
1715
1716        if gamma == 0.0:
1717            continue
1718
1719        gamma_div = 0.0
1720        for r_name in obs1.e_content[e_name]:
1721            if r_name not in obs2.e_content[e_name]:
1722                continue
1723            if len(idl_d[r_name]) == 0:
1724                continue
1725            gamma_div += np.sqrt(calc_gamma(obs1.deltas[r_name], obs1.deltas[r_name], obs1.idl[r_name], obs1.idl[r_name], idl_d[r_name]) * calc_gamma(obs2.deltas[r_name], obs2.deltas[r_name], obs2.idl[r_name], obs2.idl[r_name], idl_d[r_name]))
1726        gamma /= gamma_div
1727
1728        dvalue += gamma
1729
1730    for e_name in obs1.cov_names:
1731
1732        if e_name not in obs2.cov_names:
1733            continue
1734
1735        dvalue += np.dot(np.transpose(obs1.covobs[e_name].grad), np.dot(obs1.covobs[e_name].cov, obs2.covobs[e_name].grad)).item()
1736
1737    return dvalue
1738
1739
1740def import_jackknife(jacks, name, idl=None):
1741    """Imports jackknife samples and returns an Obs
1742
1743    Parameters
1744    ----------
1745    jacks : numpy.ndarray
1746        numpy array containing the mean value as zeroth entry and
1747        the N jackknife samples as first to Nth entry.
1748    name : str
1749        name of the ensemble the samples are defined on.
1750    """
1751    length = len(jacks) - 1
1752    prj = (np.ones((length, length)) - (length - 1) * np.identity(length))
1753    samples = jacks[1:] @ prj
1754    mean = np.mean(samples)
1755    new_obs = Obs([samples - mean], [name], idl=idl, means=[mean])
1756    new_obs._value = jacks[0]
1757    return new_obs
1758
1759
1760def import_bootstrap(boots, name, random_numbers):
1761    """Imports bootstrap samples and returns an Obs
1762
1763    Parameters
1764    ----------
1765    boots : numpy.ndarray
1766        numpy array containing the mean value as zeroth entry and
1767        the N bootstrap samples as first to Nth entry.
1768    name : str
1769        name of the ensemble the samples are defined on.
1770    random_numbers : np.ndarray
1771        Array of shape (samples, length) containing the random numbers to generate the bootstrap samples,
1772        where samples is the number of bootstrap samples and length is the length of the original Monte Carlo
1773        chain to be reconstructed.
1774    """
1775    samples, length = random_numbers.shape
1776    if samples != len(boots) - 1:
1777        raise ValueError("Random numbers do not have the correct shape.")
1778
1779    if samples < length:
1780        raise ValueError("Obs can't be reconstructed if there are fewer bootstrap samples than Monte Carlo data points.")
1781
1782    proj = np.vstack([np.bincount(o, minlength=length) for o in random_numbers]) / length
1783
1784    samples = scipy.linalg.lstsq(proj, boots[1:])[0]
1785    ret = Obs([samples], [name])
1786    ret._value = boots[0]
1787    return ret
1788
1789
1790def merge_obs(list_of_obs):
1791    """Combine all observables in list_of_obs into one new observable.
1792    This allows to merge Obs that have been computed on multiple replica
1793    of the same ensemble.
1794    If you like to merge Obs that are based on several ensembles, please
1795    average them yourself.
1796
1797    Parameters
1798    ----------
1799    list_of_obs : list
1800        list of the Obs object to be combined
1801
1802    Notes
1803    -----
1804    It is not possible to combine obs which are based on the same replicum
1805    """
1806    replist = [item for obs in list_of_obs for item in obs.names]
1807    if (len(replist) == len(set(replist))) is False:
1808        raise ValueError(f'list_of_obs contains duplicate replica: {replist!s}')
1809    if any([len(o.cov_names) for o in list_of_obs]):
1810        raise ValueError('Not possible to merge data that contains covobs!')
1811    new_dict = {}
1812    idl_dict = {}
1813    for o in list_of_obs:
1814        new_dict.update({key: o.deltas.get(key, 0) + o.r_values.get(key, 0)
1815                        for key in set(o.deltas) | set(o.r_values)})
1816        idl_dict.update({key: o.idl.get(key, 0) for key in set(o.deltas)})
1817
1818    names = sorted(new_dict.keys())
1819    o = Obs([new_dict[name] for name in names], names, idl=[idl_dict[name] for name in names])
1820    o.reweighted = np.max([oi.reweighted for oi in list_of_obs])
1821    return o
1822
1823
1824def cov_Obs(means, cov, name, grad=None):
1825    """Create an Obs based on mean(s) and a covariance matrix
1826
1827    Parameters
1828    ----------
1829    mean : list of floats or float
1830        N mean value(s) of the new Obs
1831    cov : list or array
1832        2d (NxN) Covariance matrix, 1d diagonal entries or 0d covariance
1833    name : str
1834        identifier for the covariance matrix
1835    grad : list or array
1836        Gradient of the Covobs wrt. the means belonging to cov.
1837    """
1838
1839    def covobs_to_obs(co):
1840        """Make an Obs out of a Covobs
1841
1842        Parameters
1843        ----------
1844        co : Covobs
1845            Covobs to be embedded into the Obs
1846        """
1847        o = Obs([], [], means=[])
1848        o._value = co.value
1849        o.names.append(co.name)
1850        o._covobs[co.name] = co
1851        o._dvalue = np.sqrt(co.errsq())
1852        return o
1853
1854    ol = []
1855    if isinstance(means, (float, int)):
1856        means = [means]
1857
1858    for i in range(len(means)):
1859        ol.append(covobs_to_obs(Covobs(means[i], cov, name, pos=i, grad=grad)))
1860    if ol[0].covobs[name].N != len(means):
1861        raise ValueError(f'You have to provide {ol[0].N} mean values!')
1862    if len(ol) == 1:
1863        return ol[0]
1864    return ol
1865
1866
1867def _determine_gap(o, e_content, e_name):
1868    gaps = []
1869    for r_name in e_content[e_name]:
1870        if isinstance(o.idl[r_name], range):
1871            gaps.append(o.idl[r_name].step)
1872        else:
1873            gaps.append(np.min(np.diff(o.idl[r_name])))
1874
1875    gap = min(gaps)
1876    if not np.all([gi % gap == 0 for gi in gaps]):
1877        raise ValueError(f"Replica for ensemble {e_name} do not have a common spacing.", gaps)
1878
1879    return gap
1880
1881
1882def _check_lists_equal(idl):
1883    '''
1884    Use groupby to efficiently check whether all elements of idl are identical.
1885    Returns True if all elements are equal, otherwise False.
1886
1887    Parameters
1888    ----------
1889    idl : list of lists, ranges or np.ndarrays
1890    '''
1891    g = groupby([np.nditer(el) if isinstance(el, np.ndarray) else el for el in idl])
1892    if next(g, True) and not next(g, False):
1893        return True
1894    return False
class Obs:
 22class Obs:
 23    """Class for a general observable.
 24
 25    Instances of Obs are the basic objects of a pyerrors error analysis.
 26    They are initialized with a list which contains arrays of samples for
 27    different ensembles/replica and another list of same length which contains
 28    the names of the ensembles/replica. Mathematical operations can be
 29    performed on instances. The result is another instance of Obs. The error of
 30    an instance can be computed with the gamma_method. Also contains additional
 31    methods for output and visualization of the error calculation.
 32
 33    Attributes
 34    ----------
 35    S_global : float
 36        Standard value for S (default 2.0)
 37    S_dict : dict
 38        Dictionary for S values. If an entry for a given ensemble
 39        exists this overwrites the standard value for that ensemble.
 40    tau_exp_global : float
 41        Standard value for tau_exp (default 0.0)
 42    tau_exp_dict : dict
 43        Dictionary for tau_exp values. If an entry for a given ensemble exists
 44        this overwrites the standard value for that ensemble.
 45    N_sigma_global : float
 46        Standard value for N_sigma (default 1.0)
 47    N_sigma_dict : dict
 48        Dictionary for N_sigma values. If an entry for a given ensemble exists
 49        this overwrites the standard value for that ensemble.
 50    """
 51    __slots__ = [
 52        'N',
 53        'N_sigma',
 54        'S',
 55        '__dict__',
 56        '_covobs',
 57        '_dvalue',
 58        '_value',
 59        'ddvalue',
 60        'deltas',
 61        'e_ddvalue',
 62        'e_drho',
 63        'e_dtauint',
 64        'e_dvalue',
 65        'e_n_dtauint',
 66        'e_n_tauint',
 67        'e_rho',
 68        'e_tauint',
 69        'e_windowsize',
 70        'idl',
 71        'names',
 72        'r_values',
 73        'reweighted',
 74        'shape',
 75        'tag',
 76        'tau_exp',
 77    ]
 78
 79    S_global = 2.0
 80    S_dict: ClassVar[dict] = {}
 81    tau_exp_global = 0.0
 82    tau_exp_dict: ClassVar[dict] = {}
 83    N_sigma_global = 1.0
 84    N_sigma_dict: ClassVar[dict] = {}
 85
 86    def __init__(self, samples, names, idl=None, **kwargs):
 87        """ Initialize Obs object.
 88
 89        Parameters
 90        ----------
 91        samples : list
 92            list of numpy arrays containing the Monte Carlo samples
 93        names : list
 94            list of strings labeling the individual samples
 95        idl : list, optional
 96            list of ranges or lists on which the samples are defined
 97        """
 98
 99        if kwargs.get("means") is None and len(samples):
100            if len(samples) != len(names):
101                raise ValueError('Length of samples and names incompatible.')
102            if idl is not None:
103                if len(idl) != len(names):
104                    raise ValueError('Length of idl incompatible with samples and names.')
105            name_length = len(names)
106            if name_length > 1:
107                if name_length != len(set(names)):
108                    raise ValueError('Names are not unique.')
109                if not all(isinstance(x, str) for x in names):
110                    raise TypeError('All names have to be strings.')
111                if len(set([o.split('|')[0] for o in names])) > 1:
112                    raise ValueError('Cannot initialize Obs based on multiple ensembles. Please average separate Obs from each ensemble.')
113            else:
114                if not isinstance(names[0], str):
115                    raise TypeError('All names have to be strings.')
116            if min(len(x) for x in samples) <= 4:
117                raise ValueError('Samples have to have at least 5 entries.')
118
119        self.names = sorted(names)
120        self.shape = {}
121        self.r_values = {}
122        self.deltas = {}
123        self._covobs = {}
124
125        self._value = 0
126        self.N = 0
127        self.idl = {}
128        if idl is not None:
129            for name, idx in sorted(zip(names, idl, strict=True)):
130                if isinstance(idx, range):
131                    self.idl[name] = idx
132                elif isinstance(idx, (list, np.ndarray)):
133                    dc = np.unique(np.diff(idx))
134                    if np.any(dc < 0):
135                        raise ValueError("Unsorted idx for idl[{}] at position {}".format(name, ' '.join(['%s' % (pos + 1) for pos in np.where(np.diff(idx) < 0)[0]])))
136                    elif np.any(dc == 0):
137                        raise ValueError("Duplicate entries in idx for idl[{}] at position {}".format(name, ' '.join(['%s' % (pos + 1) for pos in np.where(np.diff(idx) == 0)[0]])))
138                    if len(dc) == 1:
139                        self.idl[name] = range(idx[0], idx[-1] + dc[0], dc[0])
140                    else:
141                        self.idl[name] = list(idx)
142                else:
143                    raise TypeError(f'incompatible type for idl[{name}].')
144        else:
145            for name, sample in sorted(zip(names, samples, strict=True)):
146                self.idl[name] = range(1, len(sample) + 1)
147
148        if kwargs.get("means") is not None:
149            for name, sample, mean in sorted(zip(names, samples, kwargs.get("means"), strict=True)):
150                self.shape[name] = len(self.idl[name])
151                self.N += self.shape[name]
152                self.r_values[name] = mean
153                self.deltas[name] = sample
154        else:
155            for name, sample in sorted(zip(names, samples, strict=True)):
156                self.shape[name] = len(self.idl[name])
157                self.N += self.shape[name]
158                if len(sample) != self.shape[name]:
159                    raise ValueError(f'Incompatible samples and idx for {name}: {len(sample)} vs. {self.shape[name]}')
160                self.r_values[name] = np.mean(sample)
161                self.deltas[name] = sample - self.r_values[name]
162                self._value += self.shape[name] * self.r_values[name]
163            self._value /= self.N
164
165        self._dvalue = 0.0
166        self.ddvalue = 0.0
167        self.reweighted = False
168
169        self.tag = None
170
171    @property
172    def value(self):
173        return self._value
174
175    @property
176    def dvalue(self):
177        return self._dvalue
178
179    @property
180    def e_names(self):
181        return sorted(set([o.split('|')[0] for o in self.names]))
182
183    @property
184    def cov_names(self):
185        return sorted(set([o for o in self.covobs.keys()]))
186
187    @property
188    def mc_names(self):
189        return sorted(set([o.split('|')[0] for o in self.names if o not in self.cov_names]))
190
191    @property
192    def e_content(self):
193        res = {}
194        for _e, e_name in enumerate(self.e_names):
195            res[e_name] = sorted(filter(lambda x: x.startswith(e_name + '|'), self.names))
196            if e_name in self.names:
197                res[e_name].append(e_name)
198        return res
199
200    @property
201    def covobs(self):
202        return self._covobs
203
204    def gamma_method(self, **kwargs):
205        """Estimate the error and related properties of the Obs.
206
207        Parameters
208        ----------
209        S : float
210            specifies a custom value for the parameter S (default 2.0).
211            If set to 0 it is assumed that the data exhibits no
212            autocorrelation. In this case the error estimates coincides
213            with the sample standard error.
214        tau_exp : float
215            positive value triggers the critical slowing down analysis
216            (default 0.0).
217        N_sigma : float
218            number of standard deviations from zero until the tail is
219            attached to the autocorrelation function (default 1).
220        fft : bool
221            determines whether the fft algorithm is used for the computation
222            of the autocorrelation function (default True)
223        """
224
225        e_content = self.e_content
226        self.e_dvalue = {}
227        self.e_ddvalue = {}
228        self.e_tauint = {}
229        self.e_dtauint = {}
230        self.e_windowsize = {}
231        self.e_n_tauint = {}
232        self.e_n_dtauint = {}
233        e_gamma = {}
234        self.e_rho = {}
235        self.e_drho = {}
236        self._dvalue = 0
237        self.ddvalue = 0
238
239        self.S = {}
240        self.tau_exp = {}
241        self.N_sigma = {}
242
243        if kwargs.get('fft') is False:
244            fft = False
245        else:
246            fft = True
247
248        def _parse_kwarg(kwarg_name):
249            if kwarg_name in kwargs:
250                tmp = kwargs.get(kwarg_name)
251                if isinstance(tmp, (int, float)):
252                    if tmp < 0:
253                        raise ValueError(kwarg_name + ' has to be larger or equal to 0.')
254                    for _e, e_name in enumerate(self.e_names):
255                        getattr(self, kwarg_name)[e_name] = tmp
256                else:
257                    raise TypeError(kwarg_name + ' is not in proper format.')
258            else:
259                for _e, e_name in enumerate(self.e_names):
260                    if e_name in getattr(Obs, kwarg_name + '_dict'):
261                        getattr(self, kwarg_name)[e_name] = getattr(Obs, kwarg_name + '_dict')[e_name]
262                    else:
263                        getattr(self, kwarg_name)[e_name] = getattr(Obs, kwarg_name + '_global')
264
265        _parse_kwarg('S')
266        _parse_kwarg('tau_exp')
267        _parse_kwarg('N_sigma')
268
269        for _e, e_name in enumerate(self.mc_names):
270            gapsize = _determine_gap(self, e_content, e_name)
271
272            r_length = []
273            for r_name in e_content[e_name]:
274                if isinstance(self.idl[r_name], range):
275                    r_length.append(len(self.idl[r_name]) * self.idl[r_name].step // gapsize)
276                else:
277                    r_length.append((self.idl[r_name][-1] - self.idl[r_name][0] + 1) // gapsize)
278
279            e_N = np.sum([self.shape[r_name] for r_name in e_content[e_name]])
280            w_max = max(r_length) // 2
281            e_gamma[e_name] = np.zeros(w_max)
282            self.e_rho[e_name] = np.zeros(w_max)
283            self.e_drho[e_name] = np.zeros(w_max)
284
285            for r_name in e_content[e_name]:
286                e_gamma[e_name] += self._calc_gamma(self.deltas[r_name], self.idl[r_name], self.shape[r_name], w_max, fft, gapsize)
287
288            gamma_div = np.zeros(w_max)
289            for r_name in e_content[e_name]:
290                gamma_div += self._calc_gamma(np.ones(self.shape[r_name]), self.idl[r_name], self.shape[r_name], w_max, fft, gapsize)
291            gamma_div[gamma_div < 1] = 1.0
292            e_gamma[e_name] /= gamma_div[:w_max]
293
294            if np.abs(e_gamma[e_name][0]) < 10 * np.finfo(float).tiny:  # Prevent division by zero
295                self.e_tauint[e_name] = 0.5
296                self.e_dtauint[e_name] = 0.0
297                self.e_dvalue[e_name] = 0.0
298                self.e_ddvalue[e_name] = 0.0
299                self.e_windowsize[e_name] = 0
300                continue
301
302            self.e_rho[e_name] = e_gamma[e_name][:w_max] / e_gamma[e_name][0]
303            self.e_n_tauint[e_name] = np.cumsum(np.concatenate(([0.5], self.e_rho[e_name][1:])))
304            # Make sure no entry of tauint is smaller than 0.5
305            self.e_n_tauint[e_name][self.e_n_tauint[e_name] <= 0.5] = 0.5 + np.finfo(np.float64).eps
306            # hep-lat/0306017 eq. (42)
307            self.e_n_dtauint[e_name] = self.e_n_tauint[e_name] * 2 * np.sqrt(np.abs(np.arange(w_max) + 0.5 - self.e_n_tauint[e_name]) / e_N)
308            self.e_n_dtauint[e_name][0] = 0.0
309
310            def _compute_drho(i, e_name=e_name, w_max=w_max, e_N=e_N):
311                tmp = (self.e_rho[e_name][i + 1:w_max]
312                       + np.concatenate([self.e_rho[e_name][i - 1:None if i - (w_max - 1) // 2 <= 0 else (2 * i - (2 * w_max) // 2):-1],
313                                         self.e_rho[e_name][1:max(1, w_max - 2 * i)]])
314                       - 2 * self.e_rho[e_name][i] * self.e_rho[e_name][1:w_max - i])
315                self.e_drho[e_name][i] = np.sqrt(np.sum(tmp ** 2) / e_N)
316
317            if self.tau_exp[e_name] > 0:
318                _compute_drho(1)
319                texp = self.tau_exp[e_name]
320                # Critical slowing down analysis
321                if w_max // 2 <= 1:
322                    raise ValueError("Need at least 8 samples for tau_exp error analysis")
323                for n in range(1, w_max // 2):
324                    _compute_drho(n + 1)
325                    if (self.e_rho[e_name][n] - self.N_sigma[e_name] * self.e_drho[e_name][n]) < 0 or n >= w_max // 2 - 2:
326                        # Bias correction hep-lat/0306017 eq. (49) included
327                        self.e_tauint[e_name] = self.e_n_tauint[e_name][n] * (1 + (2 * n + 1) / e_N) / (1 + 1 / e_N) + texp * np.abs(self.e_rho[e_name][n + 1])  # The absolute makes sure, that the tail contribution is always positive
328                        self.e_dtauint[e_name] = np.sqrt(self.e_n_dtauint[e_name][n] ** 2 + texp ** 2 * self.e_drho[e_name][n + 1] ** 2)
329                        # Error of tau_exp neglected so far, missing term: self.e_rho[e_name][n + 1] ** 2 * d_tau_exp ** 2
330                        self.e_dvalue[e_name] = np.sqrt(2 * self.e_tauint[e_name] * e_gamma[e_name][0] * (1 + 1 / e_N) / e_N)
331                        self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt((n + 0.5) / e_N)
332                        self.e_windowsize[e_name] = n
333                        break
334            else:
335                if self.S[e_name] == 0.0:
336                    self.e_tauint[e_name] = 0.5
337                    self.e_dtauint[e_name] = 0.0
338                    self.e_dvalue[e_name] = np.sqrt(e_gamma[e_name][0] / (e_N - 1))
339                    self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt(0.5 / e_N)
340                    self.e_windowsize[e_name] = 0
341                else:
342                    # Standard automatic windowing procedure
343                    tau = self.S[e_name] / np.log((2 * self.e_n_tauint[e_name][1:] + 1) / (2 * self.e_n_tauint[e_name][1:] - 1))
344                    g_w = np.exp(- np.arange(1, len(tau) + 1) / tau) - tau / np.sqrt(np.arange(1, len(tau) + 1) * e_N)
345                    for n in range(1, w_max):
346                        if g_w[n - 1] < 0 or n >= w_max - 1:
347                            _compute_drho(n)
348                            self.e_tauint[e_name] = self.e_n_tauint[e_name][n] * (1 + (2 * n + 1) / e_N) / (1 + 1 / e_N)  # Bias correction hep-lat/0306017 eq. (49)
349                            self.e_dtauint[e_name] = self.e_n_dtauint[e_name][n]
350                            self.e_dvalue[e_name] = np.sqrt(2 * self.e_tauint[e_name] * e_gamma[e_name][0] * (1 + 1 / e_N) / e_N)
351                            self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt((n + 0.5) / e_N)
352                            self.e_windowsize[e_name] = n
353                            break
354
355            self._dvalue += self.e_dvalue[e_name] ** 2
356            self.ddvalue += (self.e_dvalue[e_name] * self.e_ddvalue[e_name]) ** 2
357
358        for e_name in self.cov_names:
359            self.e_dvalue[e_name] = np.sqrt(self.covobs[e_name].errsq())
360            self.e_ddvalue[e_name] = 0
361            self._dvalue += self.e_dvalue[e_name]**2
362
363        self._dvalue = np.sqrt(self._dvalue)
364        if self._dvalue == 0.0:
365            self.ddvalue = 0.0
366        else:
367            self.ddvalue = np.sqrt(self.ddvalue) / self._dvalue
368
369    gm = gamma_method
370
371    def _calc_gamma(self, deltas, idx, shape, w_max, fft, gapsize):
372        """Calculate Gamma_{AA} from the deltas, which are defined on idx.
373           idx is assumed to be a contiguous range (possibly with a stepsize != 1)
374
375        Parameters
376        ----------
377        deltas : list
378            List of fluctuations
379        idx : list
380            List or range of configurations on which the deltas are defined.
381        shape : int
382            Number of configurations in idx.
383        w_max : int
384            Upper bound for the summation window.
385        fft : bool
386            determines whether the fft algorithm is used for the computation
387            of the autocorrelation function.
388        gapsize : int
389            The target distance between two configurations. If longer distances
390            are found in idx, the data is expanded.
391        """
392        gamma = np.zeros(w_max)
393        deltas = _expand_deltas(deltas, idx, shape, gapsize)
394        new_shape = len(deltas)
395        if fft:
396            max_gamma = min(new_shape, w_max)
397            # The padding for the fft has to be even
398            padding = new_shape + max_gamma + (new_shape + max_gamma) % 2
399            gamma[:max_gamma] += np.fft.irfft(np.abs(np.fft.rfft(deltas, padding)) ** 2)[:max_gamma]
400        else:
401            for n in range(w_max):
402                if new_shape - n >= 0:
403                    gamma[n] += deltas[0:new_shape - n].dot(deltas[n:new_shape])
404
405        return gamma
406
407    def details(self, ens_content=True):
408        """Output detailed properties of the Obs.
409
410        Parameters
411        ----------
412        ens_content : bool
413            print details about the ensembles and replica if true.
414        """
415        if self.tag is not None:
416            print("Description:", self.tag)
417        if not hasattr(self, 'e_dvalue'):
418            print(f'Result\t {self.value:3.8e}')
419        else:
420            if self.value == 0.0:
421                percentage = np.nan
422            else:
423                percentage = np.abs(self._dvalue / self.value) * 100
424            print(f'Result\t {self.value:3.8e} +/- {self._dvalue:3.8e} +/- {self.ddvalue:3.8e} ({percentage:3.3f}%)')
425            if len(self.e_names) > 1:
426                print(' Ensemble errors:')
427            e_content = self.e_content
428            for e_name in self.mc_names:
429                gap = _determine_gap(self, e_content, e_name)
430
431                if len(self.e_names) > 1:
432                    print('', e_name, f'\t {self.e_dvalue[e_name]:3.6e} +/- {self.e_ddvalue[e_name]:3.6e}')
433                tau_string = " \N{GREEK SMALL LETTER TAU}_int\t " + _format_uncertainty(self.e_tauint[e_name], self.e_dtauint[e_name])
434                tau_string += f" in units of {gap} config"
435                if gap > 1:
436                    tau_string += "s"
437                if self.tau_exp[e_name] > 0:
438                    tau_string = f"{tau_string: <45}" + f'\t(\N{GREEK SMALL LETTER TAU}_exp={self.tau_exp[e_name]:3.2f}, N_\N{GREEK SMALL LETTER SIGMA}={self.N_sigma[e_name]:g})'
439                else:
440                    tau_string = f"{tau_string: <45}" + f'\t(S={self.S[e_name]:3.2f})'
441                print(tau_string)
442            for e_name in self.cov_names:
443                print('', e_name, f'\t {self.e_dvalue[e_name]:3.8e}')
444        if ens_content is True:
445            if len(self.e_names) == 1:
446                print(self.N, 'samples in', len(self.e_names), 'ensemble:')
447            else:
448                print(self.N, 'samples in', len(self.e_names), 'ensembles:')
449            my_string_list = []
450            for key, value in sorted(self.e_content.items()):
451                if key not in self.covobs:
452                    my_string = '  ' + "\u00B7 Ensemble '" + key + "' "
453                    if len(value) == 1:
454                        my_string += f': {self.shape[value[0]]} configurations'
455                        if isinstance(self.idl[value[0]], range):
456                            my_string += f' (from {self.idl[value[0]].start} to {self.idl[value[0]][-1]}' + int(self.idl[value[0]].step != 1) * f' in steps of {self.idl[value[0]].step}' + ')'
457                        else:
458                            my_string += f' (irregular range from {self.idl[value[0]][0]} to {self.idl[value[0]][-1]})'
459                    else:
460                        sublist = []
461                        for v in value:
462                            my_substring = '    ' + "\u00B7 Replicum '" + v[len(key) + 1:] + "' "
463                            my_substring += f': {self.shape[v]} configurations'
464                            if isinstance(self.idl[v], range):
465                                my_substring += f' (from {self.idl[v].start} to {self.idl[v][-1]}' + int(self.idl[v].step != 1) * f' in steps of {self.idl[v].step}' + ')'
466                            else:
467                                my_substring += f' (irregular range from {self.idl[v][0]} to {self.idl[v][-1]})'
468                            sublist.append(my_substring)
469
470                        my_string += '\n' + '\n'.join(sublist)
471                else:
472                    my_string = '  ' + "\u00B7 Covobs   '" + key + "' "
473                my_string_list.append(my_string)
474            print('\n'.join(my_string_list))
475
476    def reweight(self, weight):
477        """Reweight the obs with given rewighting factors.
478
479        Parameters
480        ----------
481        weight : Obs
482            Reweighting factor. An Observable that has to be defined on a superset of the
483            configurations in obs[i].idl for all i.
484        all_configs : bool
485            if True, the reweighted observables are normalized by the average of
486            the reweighting factor on all configurations in weight.idl and not
487            on the configurations in obs[i].idl. Default False.
488        """
489        return reweight(weight, [self])[0]
490
491    def is_zero_within_error(self, sigma=1):
492        """Checks whether the observable is zero within 'sigma' standard errors.
493
494        Parameters
495        ----------
496        sigma : int
497            Number of standard errors used for the check.
498
499        Works only properly when the gamma method was run.
500        """
501        return self.is_zero() or np.abs(self.value) <= sigma * self._dvalue
502
503    def is_zero(self, atol=1e-10):
504        """Checks whether the observable is zero within a given tolerance.
505
506        Parameters
507        ----------
508        atol : float
509            Absolute tolerance (for details see numpy documentation).
510        """
511        return np.isclose(0.0, self.value, 1e-14, atol) and all(np.allclose(0.0, delta, 1e-14, atol) for delta in self.deltas.values()) and all(np.allclose(0.0, delta.errsq(), 1e-14, atol) for delta in self.covobs.values())
512
513    def plot_tauint(self, save=None):
514        """Plot integrated autocorrelation time for each ensemble.
515
516        Parameters
517        ----------
518        save : str
519            saves the figure to a file named 'save' if.
520        """
521        if not hasattr(self, 'e_dvalue'):
522            raise Exception('Run the gamma method first.')
523
524        for e, e_name in enumerate(self.mc_names):
525            fig = plt.figure()
526            plt.xlabel(r'$W$')
527            plt.ylabel(r'$\tau_\mathrm{int}$')
528            length = len(self.e_n_tauint[e_name])
529            if self.tau_exp[e_name] > 0:
530                base = self.e_n_tauint[e_name][self.e_windowsize[e_name]]
531                x_help = np.arange(2 * self.tau_exp[e_name])
532                y_help = (x_help + 1) * np.abs(self.e_rho[e_name][self.e_windowsize[e_name] + 1]) * (1 - x_help / (2 * (2 * self.tau_exp[e_name] - 1))) + base
533                x_arr = np.arange(self.e_windowsize[e_name] + 1, self.e_windowsize[e_name] + 1 + 2 * self.tau_exp[e_name])
534                plt.plot(x_arr, y_help, 'C' + str(e), linewidth=1, ls='--', marker=',')
535                plt.errorbar([self.e_windowsize[e_name] + 2 * self.tau_exp[e_name]], [self.e_tauint[e_name]],
536                             yerr=[self.e_dtauint[e_name]], fmt='C' + str(e), linewidth=1, capsize=2, marker='o', mfc=plt.rcParams['axes.facecolor'])
537                xmax = self.e_windowsize[e_name] + 2 * self.tau_exp[e_name] + 1.5
538                label = e_name + r', $\tau_\mathrm{exp}$=' + str(np.around(self.tau_exp[e_name], decimals=2))
539            else:
540                label = e_name + ', S=' + str(np.around(self.S[e_name], decimals=2))
541                xmax = max(10.5, 2 * self.e_windowsize[e_name] - 0.5)
542
543            plt.errorbar(np.arange(length)[:int(xmax) + 1], self.e_n_tauint[e_name][:int(xmax) + 1], yerr=self.e_n_dtauint[e_name][:int(xmax) + 1], linewidth=1, capsize=2, label=label)
544            plt.axvline(x=self.e_windowsize[e_name], color='C' + str(e), alpha=0.5, marker=',', ls='--')
545            plt.legend()
546            plt.xlim(-0.5, xmax)
547            ylim = plt.ylim()
548            plt.ylim(bottom=0.0, top=max(1.0, ylim[1]))
549            plt.draw()
550            if save:
551                fig.savefig(save + "_" + str(e))
552
553    def plot_rho(self, save=None):
554        """Plot normalized autocorrelation function time for each ensemble.
555
556        Parameters
557        ----------
558        save : str
559            saves the figure to a file named 'save' if.
560        """
561        if not hasattr(self, 'e_dvalue'):
562            raise Exception('Run the gamma method first.')
563        for e, e_name in enumerate(self.mc_names):
564            fig = plt.figure()
565            plt.xlabel('W')
566            plt.ylabel('rho')
567            length = len(self.e_drho[e_name])
568            plt.errorbar(np.arange(length), self.e_rho[e_name][:length], yerr=self.e_drho[e_name][:], linewidth=1, capsize=2)
569            plt.axvline(x=self.e_windowsize[e_name], color='r', alpha=0.25, ls='--', marker=',')
570            if self.tau_exp[e_name] > 0:
571                plt.plot([self.e_windowsize[e_name] + 1, self.e_windowsize[e_name] + 1 + 2 * self.tau_exp[e_name]],
572                         [self.e_rho[e_name][self.e_windowsize[e_name] + 1], 0], 'k-', lw=1)
573                xmax = self.e_windowsize[e_name] + 2 * self.tau_exp[e_name] + 1.5
574                plt.title('Rho ' + e_name + r', tau\_exp=' + str(np.around(self.tau_exp[e_name], decimals=2)))
575            else:
576                xmax = max(10.5, 2 * self.e_windowsize[e_name] - 0.5)
577                plt.title('Rho ' + e_name + ', S=' + str(np.around(self.S[e_name], decimals=2)))
578            plt.plot([-0.5, xmax], [0, 0], 'k--', lw=1)
579            plt.xlim(-0.5, xmax)
580            plt.draw()
581            if save:
582                fig.savefig(save + "_" + str(e))
583
584    def plot_rep_dist(self):
585        """Plot replica distribution for each ensemble with more than one replicum."""
586        if not hasattr(self, 'e_dvalue'):
587            raise Exception('Run the gamma method first.')
588        for _e, e_name in enumerate(self.mc_names):
589            if len(self.e_content[e_name]) == 1:
590                print('No replica distribution for a single replicum (', e_name, ')')
591                continue
592            r_length = []
593            sub_r_mean = 0
594            for r_name in self.e_content[e_name]:
595                r_length.append(len(self.deltas[r_name]))
596                sub_r_mean += self.shape[r_name] * self.r_values[r_name]
597            e_N = np.sum(r_length)
598            sub_r_mean /= e_N
599            arr = np.zeros(len(self.e_content[e_name]))
600            for r, r_name in enumerate(self.e_content[e_name]):
601                arr[r] = (self.r_values[r_name] - sub_r_mean) / (self.e_dvalue[e_name] * np.sqrt(e_N / self.shape[r_name] - 1))
602            plt.hist(arr, rwidth=0.8, bins=len(self.e_content[e_name]))
603            plt.title('Replica distribution' + e_name + ' (mean=0, var=1)')
604            plt.draw()
605
606    def plot_history(self, expand=True):
607        """Plot derived Monte Carlo history for each ensemble
608
609        Parameters
610        ----------
611        expand : bool
612            show expanded history for irregular Monte Carlo chains (default: True).
613        """
614        for _e, e_name in enumerate(self.mc_names):
615            plt.figure()
616            r_length = []
617            tmp = []
618            tmp_expanded = []
619            for _r, r_name in enumerate(self.e_content[e_name]):
620                tmp.append(self.deltas[r_name] + self.r_values[r_name])
621                if expand:
622                    tmp_expanded.append(_expand_deltas(self.deltas[r_name], list(self.idl[r_name]), self.shape[r_name], 1) + self.r_values[r_name])
623                    r_length.append(len(tmp_expanded[-1]))
624                else:
625                    r_length.append(len(tmp[-1]))
626            e_N = np.sum(r_length)
627            x = np.arange(e_N)
628            y_test = np.concatenate(tmp, axis=0)
629            if expand:
630                y = np.concatenate(tmp_expanded, axis=0)
631            else:
632                y = y_test
633            plt.errorbar(x, y, fmt='.', markersize=3)
634            plt.xlim(-0.5, e_N - 0.5)
635            plt.title(e_name + f'\nskew: {skew(y_test):.3f} (p={skewtest(y_test).pvalue:.3f}), kurtosis: {kurtosis(y_test):.3f} (p={kurtosistest(y_test).pvalue:.3f})')
636            plt.draw()
637
638    def plot_piechart(self, save=None):
639        """Plot piechart which shows the fractional contribution of each
640        ensemble to the error and returns a dictionary containing the fractions.
641
642        Parameters
643        ----------
644        save : str
645            saves the figure to a file named 'save' if.
646        """
647        if not hasattr(self, 'e_dvalue'):
648            raise Exception('Run the gamma method first.')
649        if np.isclose(0.0, self._dvalue, atol=1e-15):
650            raise ValueError('Error is 0.0')
651        labels = self.e_names
652        sizes = [self.e_dvalue[name] ** 2 for name in labels] / self._dvalue ** 2
653        fig1, ax1 = plt.subplots()
654        ax1.pie(sizes, labels=labels, startangle=90, normalize=True)
655        ax1.axis('equal')
656        plt.draw()
657        if save:
658            fig1.savefig(save)
659
660        return dict(zip(labels, sizes, strict=True))
661
662    def dump(self, filename, datatype="json.gz", description="", **kwargs):
663        """Dump the Obs to a file 'name' of chosen format.
664
665        Parameters
666        ----------
667        filename : str
668            name of the file to be saved.
669        datatype : str
670            Format of the exported file. Supported formats include
671            "json.gz" and "pickle"
672        description : str
673            Description for output file, only relevant for json.gz format.
674        path : str
675            specifies a custom path for the file (default '.')
676        """
677        if 'path' in kwargs:
678            file_name = kwargs.get('path') + '/' + filename
679        else:
680            file_name = filename
681
682        if datatype == "json.gz":
683            from .input.json import dump_to_json
684            dump_to_json([self], file_name, description=description)
685        elif datatype == "pickle":
686            with open(file_name + '.p', 'wb') as fb:
687                pickle.dump(self, fb)
688        else:
689            raise TypeError("Unknown datatype " + str(datatype))
690
691    def export_jackknife(self):
692        """Export jackknife samples from the Obs
693
694        Returns
695        -------
696        numpy.ndarray
697            Returns a numpy array of length N + 1 where N is the number of samples
698            for the given ensemble and replicum. The zeroth entry of the array contains
699            the mean value of the Obs, entries 1 to N contain the N jackknife samples
700            derived from the Obs. The current implementation only works for observables
701            defined on exactly one ensemble and replicum. The derived jackknife samples
702            should agree with samples from a full jackknife analysis up to O(1/N).
703        """
704
705        if len(self.names) != 1:
706            raise ValueError("'export_jackknife' is only implemented for Obs defined on one ensemble and replicum.")
707
708        name = self.names[0]
709        full_data = self.deltas[name] + self.r_values[name]
710        n = full_data.size
711        mean = self.value
712        tmp_jacks = np.zeros(n + 1)
713        tmp_jacks[0] = mean
714        tmp_jacks[1:] = (n * mean - full_data) / (n - 1)
715        return tmp_jacks
716
717    def export_bootstrap(self, samples=500, random_numbers=None, save_rng=None):
718        """Export bootstrap samples from the Obs
719
720        Parameters
721        ----------
722        samples : int
723            Number of bootstrap samples to generate.
724        random_numbers : np.ndarray
725            Array of shape (samples, length) containing the random numbers to generate the bootstrap samples.
726            If not provided the bootstrap samples are generated bashed on the md5 hash of the enesmble name.
727        save_rng : str
728            Save the random numbers to a file if a path is specified.
729
730        Returns
731        -------
732        numpy.ndarray
733            Returns a numpy array of length N + 1 where N is the number of samples
734            for the given ensemble and replicum. The zeroth entry of the array contains
735            the mean value of the Obs, entries 1 to N contain the N import_bootstrap samples
736            derived from the Obs. The current implementation only works for observables
737            defined on exactly one ensemble and replicum. The derived bootstrap samples
738            should agree with samples from a full bootstrap analysis up to O(1/N).
739        """
740        if len(self.names) != 1:
741            raise ValueError("'export_boostrap' is only implemented for Obs defined on one ensemble and replicum.")
742
743        name = self.names[0]
744        length = self.N
745
746        if random_numbers is None:
747            seed = int(hashlib.md5(name.encode()).hexdigest(), 16) & 0xFFFFFFFF
748            rng = np.random.default_rng(seed)
749            random_numbers = rng.integers(0, length, size=(samples, length))
750
751        if save_rng is not None:
752            np.savetxt(save_rng, random_numbers, fmt='%i')
753
754        proj = np.vstack([np.bincount(o, minlength=length) for o in random_numbers]) / length
755        ret = np.zeros(samples + 1)
756        ret[0] = self.value
757        ret[1:] = proj @ (self.deltas[name] + self.r_values[name])
758        return ret
759
760    def __float__(self):
761        return float(self.value)
762
763    def __repr__(self):
764        return 'Obs[' + str(self) + ']'
765
766    def __str__(self):
767        return _format_uncertainty(self.value, self._dvalue)
768
769    def __format__(self, format_type):
770        if format_type == "":
771            significance = 2
772        else:
773            significance = int(float(format_type.replace("+", "").replace("-", "")))
774        my_str = _format_uncertainty(self.value, self._dvalue,
775                                     significance=significance)
776        for char in ["+", " "]:
777            if format_type.startswith(char):
778                if my_str[0] != "-":
779                    my_str = char + my_str
780        return my_str
781
782    def __hash__(self):
783        hash_tuple = (np.array([self.value]).astype(np.float32).data.tobytes(),)
784        hash_tuple += tuple([o.astype(np.float32).data.tobytes() for o in self.deltas.values()])
785        hash_tuple += tuple([np.array([o.errsq()]).astype(np.float32).data.tobytes() for o in self.covobs.values()])
786        hash_tuple += tuple([o.encode() for o in self.names])
787        m = hashlib.md5()
788        [m.update(o) for o in hash_tuple]
789        return int(m.hexdigest(), 16) & 0xFFFFFFFF
790
791    # Overload comparisons
792    def __lt__(self, other):
793        return self.value < other
794
795    def __le__(self, other):
796        return self.value <= other
797
798    def __gt__(self, other):
799        return self.value > other
800
801    def __ge__(self, other):
802        return self.value >= other
803
804    def __eq__(self, other):
805        if other is None:
806            return False
807        return (self - other).is_zero()
808
809    # Overload math operations
810    def __add__(self, y):
811        if isinstance(y, Obs):
812            return derived_observable(lambda x, **kwargs: x[0] + x[1], [self, y], man_grad=[1, 1])
813        else:
814            if isinstance(y, np.ndarray):
815                return np.array([self + o for o in y])
816            elif isinstance(y, complex):
817                return CObs(self, 0) + y
818            elif y.__class__.__name__ in ['Corr', 'CObs']:
819                return NotImplemented
820            else:
821                return derived_observable(lambda x, **kwargs: x[0] + y, [self], man_grad=[1])
822
823    def __radd__(self, y):
824        return self + y
825
826    def __mul__(self, y):
827        if isinstance(y, Obs):
828            return derived_observable(lambda x, **kwargs: x[0] * x[1], [self, y], man_grad=[y.value, self.value])
829        else:
830            if isinstance(y, np.ndarray):
831                return np.array([self * o for o in y])
832            elif isinstance(y, complex):
833                return CObs(self * y.real, self * y.imag)
834            elif y.__class__.__name__ in ['Corr', 'CObs']:
835                return NotImplemented
836            else:
837                return derived_observable(lambda x, **kwargs: x[0] * y, [self], man_grad=[y])
838
839    def __rmul__(self, y):
840        return self * y
841
842    def __sub__(self, y):
843        if isinstance(y, Obs):
844            return derived_observable(lambda x, **kwargs: x[0] - x[1], [self, y], man_grad=[1, -1])
845        else:
846            if isinstance(y, np.ndarray):
847                return np.array([self - o for o in y])
848            elif y.__class__.__name__ in ['Corr', 'CObs']:
849                return NotImplemented
850            else:
851                return derived_observable(lambda x, **kwargs: x[0] - y, [self], man_grad=[1])
852
853    def __rsub__(self, y):
854        return -1 * (self - y)
855
856    def __pos__(self):
857        return self
858
859    def __neg__(self):
860        return -1 * self
861
862    def __truediv__(self, y):
863        if isinstance(y, Obs):
864            return derived_observable(lambda x, **kwargs: x[0] / x[1], [self, y], man_grad=[1 / y.value, - self.value / y.value ** 2])
865        else:
866            if isinstance(y, np.ndarray):
867                return np.array([self / o for o in y])
868            elif y.__class__.__name__ in ['Corr', 'CObs']:
869                return NotImplemented
870            else:
871                return derived_observable(lambda x, **kwargs: x[0] / y, [self], man_grad=[1 / y])
872
873    def __rtruediv__(self, y):
874        if isinstance(y, Obs):
875            return derived_observable(lambda x, **kwargs: x[0] / x[1], [y, self], man_grad=[1 / self.value, - y.value / self.value ** 2])
876        else:
877            if isinstance(y, np.ndarray):
878                return np.array([o / self for o in y])
879            elif y.__class__.__name__ in ['Corr', 'CObs']:
880                return NotImplemented
881            else:
882                return derived_observable(lambda x, **kwargs: y / x[0], [self], man_grad=[-y / self.value ** 2])
883
884    def __pow__(self, y):
885        if isinstance(y, Obs):
886            return derived_observable(lambda x, **kwargs: x[0] ** x[1], [self, y], man_grad=[y.value * self.value ** (y.value - 1), self.value ** y.value * np.log(self.value)])
887        else:
888            return derived_observable(lambda x, **kwargs: x[0] ** y, [self], man_grad=[y * self.value ** (y - 1)])
889
890    def __rpow__(self, y):
891        return derived_observable(lambda x, **kwargs: y ** x[0], [self], man_grad=[y ** self.value * np.log(y)])
892
893    def __abs__(self):
894        return derived_observable(lambda x: anp.abs(x[0]), [self])
895
896    # Overload numpy functions
897    def sqrt(self):
898        return derived_observable(lambda x, **kwargs: np.sqrt(x[0]), [self], man_grad=[1 / 2 / np.sqrt(self.value)])
899
900    def log(self):
901        return derived_observable(lambda x, **kwargs: np.log(x[0]), [self], man_grad=[1 / self.value])
902
903    def exp(self):
904        return derived_observable(lambda x, **kwargs: np.exp(x[0]), [self], man_grad=[np.exp(self.value)])
905
906    def sin(self):
907        return derived_observable(lambda x, **kwargs: np.sin(x[0]), [self], man_grad=[np.cos(self.value)])
908
909    def cos(self):
910        return derived_observable(lambda x, **kwargs: np.cos(x[0]), [self], man_grad=[-np.sin(self.value)])
911
912    def tan(self):
913        return derived_observable(lambda x, **kwargs: np.tan(x[0]), [self], man_grad=[1 / np.cos(self.value) ** 2])
914
915    def arcsin(self):
916        return derived_observable(lambda x: anp.arcsin(x[0]), [self])
917
918    def arccos(self):
919        return derived_observable(lambda x: anp.arccos(x[0]), [self])
920
921    def arctan(self):
922        return derived_observable(lambda x: anp.arctan(x[0]), [self])
923
924    def sinh(self):
925        return derived_observable(lambda x, **kwargs: np.sinh(x[0]), [self], man_grad=[np.cosh(self.value)])
926
927    def cosh(self):
928        return derived_observable(lambda x, **kwargs: np.cosh(x[0]), [self], man_grad=[np.sinh(self.value)])
929
930    def tanh(self):
931        return derived_observable(lambda x, **kwargs: np.tanh(x[0]), [self], man_grad=[1 / np.cosh(self.value) ** 2])
932
933    def arcsinh(self):
934        return derived_observable(lambda x: anp.arcsinh(x[0]), [self])
935
936    def arccosh(self):
937        return derived_observable(lambda x: anp.arccosh(x[0]), [self])
938
939    def arctanh(self):
940        return derived_observable(lambda x: anp.arctanh(x[0]), [self])

Class for a general observable.

Instances of Obs are the basic objects of a pyerrors error analysis. They are initialized with a list which contains arrays of samples for different ensembles/replica and another list of same length which contains the names of the ensembles/replica. Mathematical operations can be performed on instances. The result is another instance of Obs. The error of an instance can be computed with the gamma_method. Also contains additional methods for output and visualization of the error calculation.

Attributes
  • S_global (float): Standard value for S (default 2.0)
  • S_dict (dict): Dictionary for S values. If an entry for a given ensemble exists this overwrites the standard value for that ensemble.
  • tau_exp_global (float): Standard value for tau_exp (default 0.0)
  • tau_exp_dict (dict): Dictionary for tau_exp values. If an entry for a given ensemble exists this overwrites the standard value for that ensemble.
  • N_sigma_global (float): Standard value for N_sigma (default 1.0)
  • N_sigma_dict (dict): Dictionary for N_sigma values. If an entry for a given ensemble exists this overwrites the standard value for that ensemble.
Obs(samples, names, idl=None, **kwargs)
 86    def __init__(self, samples, names, idl=None, **kwargs):
 87        """ Initialize Obs object.
 88
 89        Parameters
 90        ----------
 91        samples : list
 92            list of numpy arrays containing the Monte Carlo samples
 93        names : list
 94            list of strings labeling the individual samples
 95        idl : list, optional
 96            list of ranges or lists on which the samples are defined
 97        """
 98
 99        if kwargs.get("means") is None and len(samples):
100            if len(samples) != len(names):
101                raise ValueError('Length of samples and names incompatible.')
102            if idl is not None:
103                if len(idl) != len(names):
104                    raise ValueError('Length of idl incompatible with samples and names.')
105            name_length = len(names)
106            if name_length > 1:
107                if name_length != len(set(names)):
108                    raise ValueError('Names are not unique.')
109                if not all(isinstance(x, str) for x in names):
110                    raise TypeError('All names have to be strings.')
111                if len(set([o.split('|')[0] for o in names])) > 1:
112                    raise ValueError('Cannot initialize Obs based on multiple ensembles. Please average separate Obs from each ensemble.')
113            else:
114                if not isinstance(names[0], str):
115                    raise TypeError('All names have to be strings.')
116            if min(len(x) for x in samples) <= 4:
117                raise ValueError('Samples have to have at least 5 entries.')
118
119        self.names = sorted(names)
120        self.shape = {}
121        self.r_values = {}
122        self.deltas = {}
123        self._covobs = {}
124
125        self._value = 0
126        self.N = 0
127        self.idl = {}
128        if idl is not None:
129            for name, idx in sorted(zip(names, idl, strict=True)):
130                if isinstance(idx, range):
131                    self.idl[name] = idx
132                elif isinstance(idx, (list, np.ndarray)):
133                    dc = np.unique(np.diff(idx))
134                    if np.any(dc < 0):
135                        raise ValueError("Unsorted idx for idl[{}] at position {}".format(name, ' '.join(['%s' % (pos + 1) for pos in np.where(np.diff(idx) < 0)[0]])))
136                    elif np.any(dc == 0):
137                        raise ValueError("Duplicate entries in idx for idl[{}] at position {}".format(name, ' '.join(['%s' % (pos + 1) for pos in np.where(np.diff(idx) == 0)[0]])))
138                    if len(dc) == 1:
139                        self.idl[name] = range(idx[0], idx[-1] + dc[0], dc[0])
140                    else:
141                        self.idl[name] = list(idx)
142                else:
143                    raise TypeError(f'incompatible type for idl[{name}].')
144        else:
145            for name, sample in sorted(zip(names, samples, strict=True)):
146                self.idl[name] = range(1, len(sample) + 1)
147
148        if kwargs.get("means") is not None:
149            for name, sample, mean in sorted(zip(names, samples, kwargs.get("means"), strict=True)):
150                self.shape[name] = len(self.idl[name])
151                self.N += self.shape[name]
152                self.r_values[name] = mean
153                self.deltas[name] = sample
154        else:
155            for name, sample in sorted(zip(names, samples, strict=True)):
156                self.shape[name] = len(self.idl[name])
157                self.N += self.shape[name]
158                if len(sample) != self.shape[name]:
159                    raise ValueError(f'Incompatible samples and idx for {name}: {len(sample)} vs. {self.shape[name]}')
160                self.r_values[name] = np.mean(sample)
161                self.deltas[name] = sample - self.r_values[name]
162                self._value += self.shape[name] * self.r_values[name]
163            self._value /= self.N
164
165        self._dvalue = 0.0
166        self.ddvalue = 0.0
167        self.reweighted = False
168
169        self.tag = None

Initialize Obs object.

Parameters
  • samples (list): list of numpy arrays containing the Monte Carlo samples
  • names (list): list of strings labeling the individual samples
  • idl (list, optional): list of ranges or lists on which the samples are defined
S_global = 2.0
S_dict: ClassVar[dict] = {}
tau_exp_global = 0.0
tau_exp_dict: ClassVar[dict] = {}
N_sigma_global = 1.0
N_sigma_dict: ClassVar[dict] = {}
names
shape
r_values
deltas
N
idl
ddvalue
reweighted
tag
value
171    @property
172    def value(self):
173        return self._value
dvalue
175    @property
176    def dvalue(self):
177        return self._dvalue
e_names
179    @property
180    def e_names(self):
181        return sorted(set([o.split('|')[0] for o in self.names]))
cov_names
183    @property
184    def cov_names(self):
185        return sorted(set([o for o in self.covobs.keys()]))
mc_names
187    @property
188    def mc_names(self):
189        return sorted(set([o.split('|')[0] for o in self.names if o not in self.cov_names]))
e_content
191    @property
192    def e_content(self):
193        res = {}
194        for _e, e_name in enumerate(self.e_names):
195            res[e_name] = sorted(filter(lambda x: x.startswith(e_name + '|'), self.names))
196            if e_name in self.names:
197                res[e_name].append(e_name)
198        return res
covobs
200    @property
201    def covobs(self):
202        return self._covobs
def gamma_method(self, **kwargs):
204    def gamma_method(self, **kwargs):
205        """Estimate the error and related properties of the Obs.
206
207        Parameters
208        ----------
209        S : float
210            specifies a custom value for the parameter S (default 2.0).
211            If set to 0 it is assumed that the data exhibits no
212            autocorrelation. In this case the error estimates coincides
213            with the sample standard error.
214        tau_exp : float
215            positive value triggers the critical slowing down analysis
216            (default 0.0).
217        N_sigma : float
218            number of standard deviations from zero until the tail is
219            attached to the autocorrelation function (default 1).
220        fft : bool
221            determines whether the fft algorithm is used for the computation
222            of the autocorrelation function (default True)
223        """
224
225        e_content = self.e_content
226        self.e_dvalue = {}
227        self.e_ddvalue = {}
228        self.e_tauint = {}
229        self.e_dtauint = {}
230        self.e_windowsize = {}
231        self.e_n_tauint = {}
232        self.e_n_dtauint = {}
233        e_gamma = {}
234        self.e_rho = {}
235        self.e_drho = {}
236        self._dvalue = 0
237        self.ddvalue = 0
238
239        self.S = {}
240        self.tau_exp = {}
241        self.N_sigma = {}
242
243        if kwargs.get('fft') is False:
244            fft = False
245        else:
246            fft = True
247
248        def _parse_kwarg(kwarg_name):
249            if kwarg_name in kwargs:
250                tmp = kwargs.get(kwarg_name)
251                if isinstance(tmp, (int, float)):
252                    if tmp < 0:
253                        raise ValueError(kwarg_name + ' has to be larger or equal to 0.')
254                    for _e, e_name in enumerate(self.e_names):
255                        getattr(self, kwarg_name)[e_name] = tmp
256                else:
257                    raise TypeError(kwarg_name + ' is not in proper format.')
258            else:
259                for _e, e_name in enumerate(self.e_names):
260                    if e_name in getattr(Obs, kwarg_name + '_dict'):
261                        getattr(self, kwarg_name)[e_name] = getattr(Obs, kwarg_name + '_dict')[e_name]
262                    else:
263                        getattr(self, kwarg_name)[e_name] = getattr(Obs, kwarg_name + '_global')
264
265        _parse_kwarg('S')
266        _parse_kwarg('tau_exp')
267        _parse_kwarg('N_sigma')
268
269        for _e, e_name in enumerate(self.mc_names):
270            gapsize = _determine_gap(self, e_content, e_name)
271
272            r_length = []
273            for r_name in e_content[e_name]:
274                if isinstance(self.idl[r_name], range):
275                    r_length.append(len(self.idl[r_name]) * self.idl[r_name].step // gapsize)
276                else:
277                    r_length.append((self.idl[r_name][-1] - self.idl[r_name][0] + 1) // gapsize)
278
279            e_N = np.sum([self.shape[r_name] for r_name in e_content[e_name]])
280            w_max = max(r_length) // 2
281            e_gamma[e_name] = np.zeros(w_max)
282            self.e_rho[e_name] = np.zeros(w_max)
283            self.e_drho[e_name] = np.zeros(w_max)
284
285            for r_name in e_content[e_name]:
286                e_gamma[e_name] += self._calc_gamma(self.deltas[r_name], self.idl[r_name], self.shape[r_name], w_max, fft, gapsize)
287
288            gamma_div = np.zeros(w_max)
289            for r_name in e_content[e_name]:
290                gamma_div += self._calc_gamma(np.ones(self.shape[r_name]), self.idl[r_name], self.shape[r_name], w_max, fft, gapsize)
291            gamma_div[gamma_div < 1] = 1.0
292            e_gamma[e_name] /= gamma_div[:w_max]
293
294            if np.abs(e_gamma[e_name][0]) < 10 * np.finfo(float).tiny:  # Prevent division by zero
295                self.e_tauint[e_name] = 0.5
296                self.e_dtauint[e_name] = 0.0
297                self.e_dvalue[e_name] = 0.0
298                self.e_ddvalue[e_name] = 0.0
299                self.e_windowsize[e_name] = 0
300                continue
301
302            self.e_rho[e_name] = e_gamma[e_name][:w_max] / e_gamma[e_name][0]
303            self.e_n_tauint[e_name] = np.cumsum(np.concatenate(([0.5], self.e_rho[e_name][1:])))
304            # Make sure no entry of tauint is smaller than 0.5
305            self.e_n_tauint[e_name][self.e_n_tauint[e_name] <= 0.5] = 0.5 + np.finfo(np.float64).eps
306            # hep-lat/0306017 eq. (42)
307            self.e_n_dtauint[e_name] = self.e_n_tauint[e_name] * 2 * np.sqrt(np.abs(np.arange(w_max) + 0.5 - self.e_n_tauint[e_name]) / e_N)
308            self.e_n_dtauint[e_name][0] = 0.0
309
310            def _compute_drho(i, e_name=e_name, w_max=w_max, e_N=e_N):
311                tmp = (self.e_rho[e_name][i + 1:w_max]
312                       + np.concatenate([self.e_rho[e_name][i - 1:None if i - (w_max - 1) // 2 <= 0 else (2 * i - (2 * w_max) // 2):-1],
313                                         self.e_rho[e_name][1:max(1, w_max - 2 * i)]])
314                       - 2 * self.e_rho[e_name][i] * self.e_rho[e_name][1:w_max - i])
315                self.e_drho[e_name][i] = np.sqrt(np.sum(tmp ** 2) / e_N)
316
317            if self.tau_exp[e_name] > 0:
318                _compute_drho(1)
319                texp = self.tau_exp[e_name]
320                # Critical slowing down analysis
321                if w_max // 2 <= 1:
322                    raise ValueError("Need at least 8 samples for tau_exp error analysis")
323                for n in range(1, w_max // 2):
324                    _compute_drho(n + 1)
325                    if (self.e_rho[e_name][n] - self.N_sigma[e_name] * self.e_drho[e_name][n]) < 0 or n >= w_max // 2 - 2:
326                        # Bias correction hep-lat/0306017 eq. (49) included
327                        self.e_tauint[e_name] = self.e_n_tauint[e_name][n] * (1 + (2 * n + 1) / e_N) / (1 + 1 / e_N) + texp * np.abs(self.e_rho[e_name][n + 1])  # The absolute makes sure, that the tail contribution is always positive
328                        self.e_dtauint[e_name] = np.sqrt(self.e_n_dtauint[e_name][n] ** 2 + texp ** 2 * self.e_drho[e_name][n + 1] ** 2)
329                        # Error of tau_exp neglected so far, missing term: self.e_rho[e_name][n + 1] ** 2 * d_tau_exp ** 2
330                        self.e_dvalue[e_name] = np.sqrt(2 * self.e_tauint[e_name] * e_gamma[e_name][0] * (1 + 1 / e_N) / e_N)
331                        self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt((n + 0.5) / e_N)
332                        self.e_windowsize[e_name] = n
333                        break
334            else:
335                if self.S[e_name] == 0.0:
336                    self.e_tauint[e_name] = 0.5
337                    self.e_dtauint[e_name] = 0.0
338                    self.e_dvalue[e_name] = np.sqrt(e_gamma[e_name][0] / (e_N - 1))
339                    self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt(0.5 / e_N)
340                    self.e_windowsize[e_name] = 0
341                else:
342                    # Standard automatic windowing procedure
343                    tau = self.S[e_name] / np.log((2 * self.e_n_tauint[e_name][1:] + 1) / (2 * self.e_n_tauint[e_name][1:] - 1))
344                    g_w = np.exp(- np.arange(1, len(tau) + 1) / tau) - tau / np.sqrt(np.arange(1, len(tau) + 1) * e_N)
345                    for n in range(1, w_max):
346                        if g_w[n - 1] < 0 or n >= w_max - 1:
347                            _compute_drho(n)
348                            self.e_tauint[e_name] = self.e_n_tauint[e_name][n] * (1 + (2 * n + 1) / e_N) / (1 + 1 / e_N)  # Bias correction hep-lat/0306017 eq. (49)
349                            self.e_dtauint[e_name] = self.e_n_dtauint[e_name][n]
350                            self.e_dvalue[e_name] = np.sqrt(2 * self.e_tauint[e_name] * e_gamma[e_name][0] * (1 + 1 / e_N) / e_N)
351                            self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt((n + 0.5) / e_N)
352                            self.e_windowsize[e_name] = n
353                            break
354
355            self._dvalue += self.e_dvalue[e_name] ** 2
356            self.ddvalue += (self.e_dvalue[e_name] * self.e_ddvalue[e_name]) ** 2
357
358        for e_name in self.cov_names:
359            self.e_dvalue[e_name] = np.sqrt(self.covobs[e_name].errsq())
360            self.e_ddvalue[e_name] = 0
361            self._dvalue += self.e_dvalue[e_name]**2
362
363        self._dvalue = np.sqrt(self._dvalue)
364        if self._dvalue == 0.0:
365            self.ddvalue = 0.0
366        else:
367            self.ddvalue = np.sqrt(self.ddvalue) / self._dvalue

Estimate the error and related properties of the Obs.

Parameters
  • S (float): specifies a custom value for the parameter S (default 2.0). If set to 0 it is assumed that the data exhibits no autocorrelation. In this case the error estimates coincides with the sample standard error.
  • tau_exp (float): positive value triggers the critical slowing down analysis (default 0.0).
  • N_sigma (float): number of standard deviations from zero until the tail is attached to the autocorrelation function (default 1).
  • fft (bool): determines whether the fft algorithm is used for the computation of the autocorrelation function (default True)
def gm(self, **kwargs):
204    def gamma_method(self, **kwargs):
205        """Estimate the error and related properties of the Obs.
206
207        Parameters
208        ----------
209        S : float
210            specifies a custom value for the parameter S (default 2.0).
211            If set to 0 it is assumed that the data exhibits no
212            autocorrelation. In this case the error estimates coincides
213            with the sample standard error.
214        tau_exp : float
215            positive value triggers the critical slowing down analysis
216            (default 0.0).
217        N_sigma : float
218            number of standard deviations from zero until the tail is
219            attached to the autocorrelation function (default 1).
220        fft : bool
221            determines whether the fft algorithm is used for the computation
222            of the autocorrelation function (default True)
223        """
224
225        e_content = self.e_content
226        self.e_dvalue = {}
227        self.e_ddvalue = {}
228        self.e_tauint = {}
229        self.e_dtauint = {}
230        self.e_windowsize = {}
231        self.e_n_tauint = {}
232        self.e_n_dtauint = {}
233        e_gamma = {}
234        self.e_rho = {}
235        self.e_drho = {}
236        self._dvalue = 0
237        self.ddvalue = 0
238
239        self.S = {}
240        self.tau_exp = {}
241        self.N_sigma = {}
242
243        if kwargs.get('fft') is False:
244            fft = False
245        else:
246            fft = True
247
248        def _parse_kwarg(kwarg_name):
249            if kwarg_name in kwargs:
250                tmp = kwargs.get(kwarg_name)
251                if isinstance(tmp, (int, float)):
252                    if tmp < 0:
253                        raise ValueError(kwarg_name + ' has to be larger or equal to 0.')
254                    for _e, e_name in enumerate(self.e_names):
255                        getattr(self, kwarg_name)[e_name] = tmp
256                else:
257                    raise TypeError(kwarg_name + ' is not in proper format.')
258            else:
259                for _e, e_name in enumerate(self.e_names):
260                    if e_name in getattr(Obs, kwarg_name + '_dict'):
261                        getattr(self, kwarg_name)[e_name] = getattr(Obs, kwarg_name + '_dict')[e_name]
262                    else:
263                        getattr(self, kwarg_name)[e_name] = getattr(Obs, kwarg_name + '_global')
264
265        _parse_kwarg('S')
266        _parse_kwarg('tau_exp')
267        _parse_kwarg('N_sigma')
268
269        for _e, e_name in enumerate(self.mc_names):
270            gapsize = _determine_gap(self, e_content, e_name)
271
272            r_length = []
273            for r_name in e_content[e_name]:
274                if isinstance(self.idl[r_name], range):
275                    r_length.append(len(self.idl[r_name]) * self.idl[r_name].step // gapsize)
276                else:
277                    r_length.append((self.idl[r_name][-1] - self.idl[r_name][0] + 1) // gapsize)
278
279            e_N = np.sum([self.shape[r_name] for r_name in e_content[e_name]])
280            w_max = max(r_length) // 2
281            e_gamma[e_name] = np.zeros(w_max)
282            self.e_rho[e_name] = np.zeros(w_max)
283            self.e_drho[e_name] = np.zeros(w_max)
284
285            for r_name in e_content[e_name]:
286                e_gamma[e_name] += self._calc_gamma(self.deltas[r_name], self.idl[r_name], self.shape[r_name], w_max, fft, gapsize)
287
288            gamma_div = np.zeros(w_max)
289            for r_name in e_content[e_name]:
290                gamma_div += self._calc_gamma(np.ones(self.shape[r_name]), self.idl[r_name], self.shape[r_name], w_max, fft, gapsize)
291            gamma_div[gamma_div < 1] = 1.0
292            e_gamma[e_name] /= gamma_div[:w_max]
293
294            if np.abs(e_gamma[e_name][0]) < 10 * np.finfo(float).tiny:  # Prevent division by zero
295                self.e_tauint[e_name] = 0.5
296                self.e_dtauint[e_name] = 0.0
297                self.e_dvalue[e_name] = 0.0
298                self.e_ddvalue[e_name] = 0.0
299                self.e_windowsize[e_name] = 0
300                continue
301
302            self.e_rho[e_name] = e_gamma[e_name][:w_max] / e_gamma[e_name][0]
303            self.e_n_tauint[e_name] = np.cumsum(np.concatenate(([0.5], self.e_rho[e_name][1:])))
304            # Make sure no entry of tauint is smaller than 0.5
305            self.e_n_tauint[e_name][self.e_n_tauint[e_name] <= 0.5] = 0.5 + np.finfo(np.float64).eps
306            # hep-lat/0306017 eq. (42)
307            self.e_n_dtauint[e_name] = self.e_n_tauint[e_name] * 2 * np.sqrt(np.abs(np.arange(w_max) + 0.5 - self.e_n_tauint[e_name]) / e_N)
308            self.e_n_dtauint[e_name][0] = 0.0
309
310            def _compute_drho(i, e_name=e_name, w_max=w_max, e_N=e_N):
311                tmp = (self.e_rho[e_name][i + 1:w_max]
312                       + np.concatenate([self.e_rho[e_name][i - 1:None if i - (w_max - 1) // 2 <= 0 else (2 * i - (2 * w_max) // 2):-1],
313                                         self.e_rho[e_name][1:max(1, w_max - 2 * i)]])
314                       - 2 * self.e_rho[e_name][i] * self.e_rho[e_name][1:w_max - i])
315                self.e_drho[e_name][i] = np.sqrt(np.sum(tmp ** 2) / e_N)
316
317            if self.tau_exp[e_name] > 0:
318                _compute_drho(1)
319                texp = self.tau_exp[e_name]
320                # Critical slowing down analysis
321                if w_max // 2 <= 1:
322                    raise ValueError("Need at least 8 samples for tau_exp error analysis")
323                for n in range(1, w_max // 2):
324                    _compute_drho(n + 1)
325                    if (self.e_rho[e_name][n] - self.N_sigma[e_name] * self.e_drho[e_name][n]) < 0 or n >= w_max // 2 - 2:
326                        # Bias correction hep-lat/0306017 eq. (49) included
327                        self.e_tauint[e_name] = self.e_n_tauint[e_name][n] * (1 + (2 * n + 1) / e_N) / (1 + 1 / e_N) + texp * np.abs(self.e_rho[e_name][n + 1])  # The absolute makes sure, that the tail contribution is always positive
328                        self.e_dtauint[e_name] = np.sqrt(self.e_n_dtauint[e_name][n] ** 2 + texp ** 2 * self.e_drho[e_name][n + 1] ** 2)
329                        # Error of tau_exp neglected so far, missing term: self.e_rho[e_name][n + 1] ** 2 * d_tau_exp ** 2
330                        self.e_dvalue[e_name] = np.sqrt(2 * self.e_tauint[e_name] * e_gamma[e_name][0] * (1 + 1 / e_N) / e_N)
331                        self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt((n + 0.5) / e_N)
332                        self.e_windowsize[e_name] = n
333                        break
334            else:
335                if self.S[e_name] == 0.0:
336                    self.e_tauint[e_name] = 0.5
337                    self.e_dtauint[e_name] = 0.0
338                    self.e_dvalue[e_name] = np.sqrt(e_gamma[e_name][0] / (e_N - 1))
339                    self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt(0.5 / e_N)
340                    self.e_windowsize[e_name] = 0
341                else:
342                    # Standard automatic windowing procedure
343                    tau = self.S[e_name] / np.log((2 * self.e_n_tauint[e_name][1:] + 1) / (2 * self.e_n_tauint[e_name][1:] - 1))
344                    g_w = np.exp(- np.arange(1, len(tau) + 1) / tau) - tau / np.sqrt(np.arange(1, len(tau) + 1) * e_N)
345                    for n in range(1, w_max):
346                        if g_w[n - 1] < 0 or n >= w_max - 1:
347                            _compute_drho(n)
348                            self.e_tauint[e_name] = self.e_n_tauint[e_name][n] * (1 + (2 * n + 1) / e_N) / (1 + 1 / e_N)  # Bias correction hep-lat/0306017 eq. (49)
349                            self.e_dtauint[e_name] = self.e_n_dtauint[e_name][n]
350                            self.e_dvalue[e_name] = np.sqrt(2 * self.e_tauint[e_name] * e_gamma[e_name][0] * (1 + 1 / e_N) / e_N)
351                            self.e_ddvalue[e_name] = self.e_dvalue[e_name] * np.sqrt((n + 0.5) / e_N)
352                            self.e_windowsize[e_name] = n
353                            break
354
355            self._dvalue += self.e_dvalue[e_name] ** 2
356            self.ddvalue += (self.e_dvalue[e_name] * self.e_ddvalue[e_name]) ** 2
357
358        for e_name in self.cov_names:
359            self.e_dvalue[e_name] = np.sqrt(self.covobs[e_name].errsq())
360            self.e_ddvalue[e_name] = 0
361            self._dvalue += self.e_dvalue[e_name]**2
362
363        self._dvalue = np.sqrt(self._dvalue)
364        if self._dvalue == 0.0:
365            self.ddvalue = 0.0
366        else:
367            self.ddvalue = np.sqrt(self.ddvalue) / self._dvalue

Estimate the error and related properties of the Obs.

Parameters
  • S (float): specifies a custom value for the parameter S (default 2.0). If set to 0 it is assumed that the data exhibits no autocorrelation. In this case the error estimates coincides with the sample standard error.
  • tau_exp (float): positive value triggers the critical slowing down analysis (default 0.0).
  • N_sigma (float): number of standard deviations from zero until the tail is attached to the autocorrelation function (default 1).
  • fft (bool): determines whether the fft algorithm is used for the computation of the autocorrelation function (default True)
def details(self, ens_content=True):
407    def details(self, ens_content=True):
408        """Output detailed properties of the Obs.
409
410        Parameters
411        ----------
412        ens_content : bool
413            print details about the ensembles and replica if true.
414        """
415        if self.tag is not None:
416            print("Description:", self.tag)
417        if not hasattr(self, 'e_dvalue'):
418            print(f'Result\t {self.value:3.8e}')
419        else:
420            if self.value == 0.0:
421                percentage = np.nan
422            else:
423                percentage = np.abs(self._dvalue / self.value) * 100
424            print(f'Result\t {self.value:3.8e} +/- {self._dvalue:3.8e} +/- {self.ddvalue:3.8e} ({percentage:3.3f}%)')
425            if len(self.e_names) > 1:
426                print(' Ensemble errors:')
427            e_content = self.e_content
428            for e_name in self.mc_names:
429                gap = _determine_gap(self, e_content, e_name)
430
431                if len(self.e_names) > 1:
432                    print('', e_name, f'\t {self.e_dvalue[e_name]:3.6e} +/- {self.e_ddvalue[e_name]:3.6e}')
433                tau_string = " \N{GREEK SMALL LETTER TAU}_int\t " + _format_uncertainty(self.e_tauint[e_name], self.e_dtauint[e_name])
434                tau_string += f" in units of {gap} config"
435                if gap > 1:
436                    tau_string += "s"
437                if self.tau_exp[e_name] > 0:
438                    tau_string = f"{tau_string: <45}" + f'\t(\N{GREEK SMALL LETTER TAU}_exp={self.tau_exp[e_name]:3.2f}, N_\N{GREEK SMALL LETTER SIGMA}={self.N_sigma[e_name]:g})'
439                else:
440                    tau_string = f"{tau_string: <45}" + f'\t(S={self.S[e_name]:3.2f})'
441                print(tau_string)
442            for e_name in self.cov_names:
443                print('', e_name, f'\t {self.e_dvalue[e_name]:3.8e}')
444        if ens_content is True:
445            if len(self.e_names) == 1:
446                print(self.N, 'samples in', len(self.e_names), 'ensemble:')
447            else:
448                print(self.N, 'samples in', len(self.e_names), 'ensembles:')
449            my_string_list = []
450            for key, value in sorted(self.e_content.items()):
451                if key not in self.covobs:
452                    my_string = '  ' + "\u00B7 Ensemble '" + key + "' "
453                    if len(value) == 1:
454                        my_string += f': {self.shape[value[0]]} configurations'
455                        if isinstance(self.idl[value[0]], range):
456                            my_string += f' (from {self.idl[value[0]].start} to {self.idl[value[0]][-1]}' + int(self.idl[value[0]].step != 1) * f' in steps of {self.idl[value[0]].step}' + ')'
457                        else:
458                            my_string += f' (irregular range from {self.idl[value[0]][0]} to {self.idl[value[0]][-1]})'
459                    else:
460                        sublist = []
461                        for v in value:
462                            my_substring = '    ' + "\u00B7 Replicum '" + v[len(key) + 1:] + "' "
463                            my_substring += f': {self.shape[v]} configurations'
464                            if isinstance(self.idl[v], range):
465                                my_substring += f' (from {self.idl[v].start} to {self.idl[v][-1]}' + int(self.idl[v].step != 1) * f' in steps of {self.idl[v].step}' + ')'
466                            else:
467                                my_substring += f' (irregular range from {self.idl[v][0]} to {self.idl[v][-1]})'
468                            sublist.append(my_substring)
469
470                        my_string += '\n' + '\n'.join(sublist)
471                else:
472                    my_string = '  ' + "\u00B7 Covobs   '" + key + "' "
473                my_string_list.append(my_string)
474            print('\n'.join(my_string_list))

Output detailed properties of the Obs.

Parameters
  • ens_content (bool): print details about the ensembles and replica if true.
def reweight(self, weight):
476    def reweight(self, weight):
477        """Reweight the obs with given rewighting factors.
478
479        Parameters
480        ----------
481        weight : Obs
482            Reweighting factor. An Observable that has to be defined on a superset of the
483            configurations in obs[i].idl for all i.
484        all_configs : bool
485            if True, the reweighted observables are normalized by the average of
486            the reweighting factor on all configurations in weight.idl and not
487            on the configurations in obs[i].idl. Default False.
488        """
489        return reweight(weight, [self])[0]

Reweight the obs with given rewighting factors.

Parameters
  • weight (Obs): Reweighting factor. An Observable that has to be defined on a superset of the configurations in obs[i].idl for all i.
  • all_configs (bool): if True, the reweighted observables are normalized by the average of the reweighting factor on all configurations in weight.idl and not on the configurations in obs[i].idl. Default False.
def is_zero_within_error(self, sigma=1):
491    def is_zero_within_error(self, sigma=1):
492        """Checks whether the observable is zero within 'sigma' standard errors.
493
494        Parameters
495        ----------
496        sigma : int
497            Number of standard errors used for the check.
498
499        Works only properly when the gamma method was run.
500        """
501        return self.is_zero() or np.abs(self.value) <= sigma * self._dvalue

Checks whether the observable is zero within 'sigma' standard errors.

Parameters
  • sigma (int): Number of standard errors used for the check.
  • Works only properly when the gamma method was run.
def is_zero(self, atol=1e-10):
503    def is_zero(self, atol=1e-10):
504        """Checks whether the observable is zero within a given tolerance.
505
506        Parameters
507        ----------
508        atol : float
509            Absolute tolerance (for details see numpy documentation).
510        """
511        return np.isclose(0.0, self.value, 1e-14, atol) and all(np.allclose(0.0, delta, 1e-14, atol) for delta in self.deltas.values()) and all(np.allclose(0.0, delta.errsq(), 1e-14, atol) for delta in self.covobs.values())

Checks whether the observable is zero within a given tolerance.

Parameters
  • atol (float): Absolute tolerance (for details see numpy documentation).
def plot_tauint(self, save=None):
513    def plot_tauint(self, save=None):
514        """Plot integrated autocorrelation time for each ensemble.
515
516        Parameters
517        ----------
518        save : str
519            saves the figure to a file named 'save' if.
520        """
521        if not hasattr(self, 'e_dvalue'):
522            raise Exception('Run the gamma method first.')
523
524        for e, e_name in enumerate(self.mc_names):
525            fig = plt.figure()
526            plt.xlabel(r'$W$')
527            plt.ylabel(r'$\tau_\mathrm{int}$')
528            length = len(self.e_n_tauint[e_name])
529            if self.tau_exp[e_name] > 0:
530                base = self.e_n_tauint[e_name][self.e_windowsize[e_name]]
531                x_help = np.arange(2 * self.tau_exp[e_name])
532                y_help = (x_help + 1) * np.abs(self.e_rho[e_name][self.e_windowsize[e_name] + 1]) * (1 - x_help / (2 * (2 * self.tau_exp[e_name] - 1))) + base
533                x_arr = np.arange(self.e_windowsize[e_name] + 1, self.e_windowsize[e_name] + 1 + 2 * self.tau_exp[e_name])
534                plt.plot(x_arr, y_help, 'C' + str(e), linewidth=1, ls='--', marker=',')
535                plt.errorbar([self.e_windowsize[e_name] + 2 * self.tau_exp[e_name]], [self.e_tauint[e_name]],
536                             yerr=[self.e_dtauint[e_name]], fmt='C' + str(e), linewidth=1, capsize=2, marker='o', mfc=plt.rcParams['axes.facecolor'])
537                xmax = self.e_windowsize[e_name] + 2 * self.tau_exp[e_name] + 1.5
538                label = e_name + r', $\tau_\mathrm{exp}$=' + str(np.around(self.tau_exp[e_name], decimals=2))
539            else:
540                label = e_name + ', S=' + str(np.around(self.S[e_name], decimals=2))
541                xmax = max(10.5, 2 * self.e_windowsize[e_name] - 0.5)
542
543            plt.errorbar(np.arange(length)[:int(xmax) + 1], self.e_n_tauint[e_name][:int(xmax) + 1], yerr=self.e_n_dtauint[e_name][:int(xmax) + 1], linewidth=1, capsize=2, label=label)
544            plt.axvline(x=self.e_windowsize[e_name], color='C' + str(e), alpha=0.5, marker=',', ls='--')
545            plt.legend()
546            plt.xlim(-0.5, xmax)
547            ylim = plt.ylim()
548            plt.ylim(bottom=0.0, top=max(1.0, ylim[1]))
549            plt.draw()
550            if save:
551                fig.savefig(save + "_" + str(e))

Plot integrated autocorrelation time for each ensemble.

Parameters
  • save (str): saves the figure to a file named 'save' if.
def plot_rho(self, save=None):
553    def plot_rho(self, save=None):
554        """Plot normalized autocorrelation function time for each ensemble.
555
556        Parameters
557        ----------
558        save : str
559            saves the figure to a file named 'save' if.
560        """
561        if not hasattr(self, 'e_dvalue'):
562            raise Exception('Run the gamma method first.')
563        for e, e_name in enumerate(self.mc_names):
564            fig = plt.figure()
565            plt.xlabel('W')
566            plt.ylabel('rho')
567            length = len(self.e_drho[e_name])
568            plt.errorbar(np.arange(length), self.e_rho[e_name][:length], yerr=self.e_drho[e_name][:], linewidth=1, capsize=2)
569            plt.axvline(x=self.e_windowsize[e_name], color='r', alpha=0.25, ls='--', marker=',')
570            if self.tau_exp[e_name] > 0:
571                plt.plot([self.e_windowsize[e_name] + 1, self.e_windowsize[e_name] + 1 + 2 * self.tau_exp[e_name]],
572                         [self.e_rho[e_name][self.e_windowsize[e_name] + 1], 0], 'k-', lw=1)
573                xmax = self.e_windowsize[e_name] + 2 * self.tau_exp[e_name] + 1.5
574                plt.title('Rho ' + e_name + r', tau\_exp=' + str(np.around(self.tau_exp[e_name], decimals=2)))
575            else:
576                xmax = max(10.5, 2 * self.e_windowsize[e_name] - 0.5)
577                plt.title('Rho ' + e_name + ', S=' + str(np.around(self.S[e_name], decimals=2)))
578            plt.plot([-0.5, xmax], [0, 0], 'k--', lw=1)
579            plt.xlim(-0.5, xmax)
580            plt.draw()
581            if save:
582                fig.savefig(save + "_" + str(e))

Plot normalized autocorrelation function time for each ensemble.

Parameters
  • save (str): saves the figure to a file named 'save' if.
def plot_rep_dist(self):
584    def plot_rep_dist(self):
585        """Plot replica distribution for each ensemble with more than one replicum."""
586        if not hasattr(self, 'e_dvalue'):
587            raise Exception('Run the gamma method first.')
588        for _e, e_name in enumerate(self.mc_names):
589            if len(self.e_content[e_name]) == 1:
590                print('No replica distribution for a single replicum (', e_name, ')')
591                continue
592            r_length = []
593            sub_r_mean = 0
594            for r_name in self.e_content[e_name]:
595                r_length.append(len(self.deltas[r_name]))
596                sub_r_mean += self.shape[r_name] * self.r_values[r_name]
597            e_N = np.sum(r_length)
598            sub_r_mean /= e_N
599            arr = np.zeros(len(self.e_content[e_name]))
600            for r, r_name in enumerate(self.e_content[e_name]):
601                arr[r] = (self.r_values[r_name] - sub_r_mean) / (self.e_dvalue[e_name] * np.sqrt(e_N / self.shape[r_name] - 1))
602            plt.hist(arr, rwidth=0.8, bins=len(self.e_content[e_name]))
603            plt.title('Replica distribution' + e_name + ' (mean=0, var=1)')
604            plt.draw()

Plot replica distribution for each ensemble with more than one replicum.

def plot_history(self, expand=True):
606    def plot_history(self, expand=True):
607        """Plot derived Monte Carlo history for each ensemble
608
609        Parameters
610        ----------
611        expand : bool
612            show expanded history for irregular Monte Carlo chains (default: True).
613        """
614        for _e, e_name in enumerate(self.mc_names):
615            plt.figure()
616            r_length = []
617            tmp = []
618            tmp_expanded = []
619            for _r, r_name in enumerate(self.e_content[e_name]):
620                tmp.append(self.deltas[r_name] + self.r_values[r_name])
621                if expand:
622                    tmp_expanded.append(_expand_deltas(self.deltas[r_name], list(self.idl[r_name]), self.shape[r_name], 1) + self.r_values[r_name])
623                    r_length.append(len(tmp_expanded[-1]))
624                else:
625                    r_length.append(len(tmp[-1]))
626            e_N = np.sum(r_length)
627            x = np.arange(e_N)
628            y_test = np.concatenate(tmp, axis=0)
629            if expand:
630                y = np.concatenate(tmp_expanded, axis=0)
631            else:
632                y = y_test
633            plt.errorbar(x, y, fmt='.', markersize=3)
634            plt.xlim(-0.5, e_N - 0.5)
635            plt.title(e_name + f'\nskew: {skew(y_test):.3f} (p={skewtest(y_test).pvalue:.3f}), kurtosis: {kurtosis(y_test):.3f} (p={kurtosistest(y_test).pvalue:.3f})')
636            plt.draw()

Plot derived Monte Carlo history for each ensemble

Parameters
  • expand (bool): show expanded history for irregular Monte Carlo chains (default: True).
def plot_piechart(self, save=None):
638    def plot_piechart(self, save=None):
639        """Plot piechart which shows the fractional contribution of each
640        ensemble to the error and returns a dictionary containing the fractions.
641
642        Parameters
643        ----------
644        save : str
645            saves the figure to a file named 'save' if.
646        """
647        if not hasattr(self, 'e_dvalue'):
648            raise Exception('Run the gamma method first.')
649        if np.isclose(0.0, self._dvalue, atol=1e-15):
650            raise ValueError('Error is 0.0')
651        labels = self.e_names
652        sizes = [self.e_dvalue[name] ** 2 for name in labels] / self._dvalue ** 2
653        fig1, ax1 = plt.subplots()
654        ax1.pie(sizes, labels=labels, startangle=90, normalize=True)
655        ax1.axis('equal')
656        plt.draw()
657        if save:
658            fig1.savefig(save)
659
660        return dict(zip(labels, sizes, strict=True))

Plot piechart which shows the fractional contribution of each ensemble to the error and returns a dictionary containing the fractions.

Parameters
  • save (str): saves the figure to a file named 'save' if.
def dump(self, filename, datatype='json.gz', description='', **kwargs):
662    def dump(self, filename, datatype="json.gz", description="", **kwargs):
663        """Dump the Obs to a file 'name' of chosen format.
664
665        Parameters
666        ----------
667        filename : str
668            name of the file to be saved.
669        datatype : str
670            Format of the exported file. Supported formats include
671            "json.gz" and "pickle"
672        description : str
673            Description for output file, only relevant for json.gz format.
674        path : str
675            specifies a custom path for the file (default '.')
676        """
677        if 'path' in kwargs:
678            file_name = kwargs.get('path') + '/' + filename
679        else:
680            file_name = filename
681
682        if datatype == "json.gz":
683            from .input.json import dump_to_json
684            dump_to_json([self], file_name, description=description)
685        elif datatype == "pickle":
686            with open(file_name + '.p', 'wb') as fb:
687                pickle.dump(self, fb)
688        else:
689            raise TypeError("Unknown datatype " + str(datatype))

Dump the Obs to a file 'name' of chosen format.

Parameters
  • filename (str): name of the file to be saved.
  • datatype (str): Format of the exported file. Supported formats include "json.gz" and "pickle"
  • description (str): Description for output file, only relevant for json.gz format.
  • path (str): specifies a custom path for the file (default '.')
def export_jackknife(self):
691    def export_jackknife(self):
692        """Export jackknife samples from the Obs
693
694        Returns
695        -------
696        numpy.ndarray
697            Returns a numpy array of length N + 1 where N is the number of samples
698            for the given ensemble and replicum. The zeroth entry of the array contains
699            the mean value of the Obs, entries 1 to N contain the N jackknife samples
700            derived from the Obs. The current implementation only works for observables
701            defined on exactly one ensemble and replicum. The derived jackknife samples
702            should agree with samples from a full jackknife analysis up to O(1/N).
703        """
704
705        if len(self.names) != 1:
706            raise ValueError("'export_jackknife' is only implemented for Obs defined on one ensemble and replicum.")
707
708        name = self.names[0]
709        full_data = self.deltas[name] + self.r_values[name]
710        n = full_data.size
711        mean = self.value
712        tmp_jacks = np.zeros(n + 1)
713        tmp_jacks[0] = mean
714        tmp_jacks[1:] = (n * mean - full_data) / (n - 1)
715        return tmp_jacks

Export jackknife samples from the Obs

Returns
  • numpy.ndarray: Returns a numpy array of length N + 1 where N is the number of samples for the given ensemble and replicum. The zeroth entry of the array contains the mean value of the Obs, entries 1 to N contain the N jackknife samples derived from the Obs. The current implementation only works for observables defined on exactly one ensemble and replicum. The derived jackknife samples should agree with samples from a full jackknife analysis up to O(1/N).
def export_bootstrap(self, samples=500, random_numbers=None, save_rng=None):
717    def export_bootstrap(self, samples=500, random_numbers=None, save_rng=None):
718        """Export bootstrap samples from the Obs
719
720        Parameters
721        ----------
722        samples : int
723            Number of bootstrap samples to generate.
724        random_numbers : np.ndarray
725            Array of shape (samples, length) containing the random numbers to generate the bootstrap samples.
726            If not provided the bootstrap samples are generated bashed on the md5 hash of the enesmble name.
727        save_rng : str
728            Save the random numbers to a file if a path is specified.
729
730        Returns
731        -------
732        numpy.ndarray
733            Returns a numpy array of length N + 1 where N is the number of samples
734            for the given ensemble and replicum. The zeroth entry of the array contains
735            the mean value of the Obs, entries 1 to N contain the N import_bootstrap samples
736            derived from the Obs. The current implementation only works for observables
737            defined on exactly one ensemble and replicum. The derived bootstrap samples
738            should agree with samples from a full bootstrap analysis up to O(1/N).
739        """
740        if len(self.names) != 1:
741            raise ValueError("'export_boostrap' is only implemented for Obs defined on one ensemble and replicum.")
742
743        name = self.names[0]
744        length = self.N
745
746        if random_numbers is None:
747            seed = int(hashlib.md5(name.encode()).hexdigest(), 16) & 0xFFFFFFFF
748            rng = np.random.default_rng(seed)
749            random_numbers = rng.integers(0, length, size=(samples, length))
750
751        if save_rng is not None:
752            np.savetxt(save_rng, random_numbers, fmt='%i')
753
754        proj = np.vstack([np.bincount(o, minlength=length) for o in random_numbers]) / length
755        ret = np.zeros(samples + 1)
756        ret[0] = self.value
757        ret[1:] = proj @ (self.deltas[name] + self.r_values[name])
758        return ret

Export bootstrap samples from the Obs

Parameters
  • samples (int): Number of bootstrap samples to generate.
  • random_numbers (np.ndarray): Array of shape (samples, length) containing the random numbers to generate the bootstrap samples. If not provided the bootstrap samples are generated bashed on the md5 hash of the enesmble name.
  • save_rng (str): Save the random numbers to a file if a path is specified.
Returns
  • numpy.ndarray: Returns a numpy array of length N + 1 where N is the number of samples for the given ensemble and replicum. The zeroth entry of the array contains the mean value of the Obs, entries 1 to N contain the N import_bootstrap samples derived from the Obs. The current implementation only works for observables defined on exactly one ensemble and replicum. The derived bootstrap samples should agree with samples from a full bootstrap analysis up to O(1/N).
def sqrt(self):
897    def sqrt(self):
898        return derived_observable(lambda x, **kwargs: np.sqrt(x[0]), [self], man_grad=[1 / 2 / np.sqrt(self.value)])
def log(self):
900    def log(self):
901        return derived_observable(lambda x, **kwargs: np.log(x[0]), [self], man_grad=[1 / self.value])
def exp(self):
903    def exp(self):
904        return derived_observable(lambda x, **kwargs: np.exp(x[0]), [self], man_grad=[np.exp(self.value)])
def sin(self):
906    def sin(self):
907        return derived_observable(lambda x, **kwargs: np.sin(x[0]), [self], man_grad=[np.cos(self.value)])
def cos(self):
909    def cos(self):
910        return derived_observable(lambda x, **kwargs: np.cos(x[0]), [self], man_grad=[-np.sin(self.value)])
def tan(self):
912    def tan(self):
913        return derived_observable(lambda x, **kwargs: np.tan(x[0]), [self], man_grad=[1 / np.cos(self.value) ** 2])
def arcsin(self):
915    def arcsin(self):
916        return derived_observable(lambda x: anp.arcsin(x[0]), [self])
def arccos(self):
918    def arccos(self):
919        return derived_observable(lambda x: anp.arccos(x[0]), [self])
def arctan(self):
921    def arctan(self):
922        return derived_observable(lambda x: anp.arctan(x[0]), [self])
def sinh(self):
924    def sinh(self):
925        return derived_observable(lambda x, **kwargs: np.sinh(x[0]), [self], man_grad=[np.cosh(self.value)])
def cosh(self):
927    def cosh(self):
928        return derived_observable(lambda x, **kwargs: np.cosh(x[0]), [self], man_grad=[np.sinh(self.value)])
def tanh(self):
930    def tanh(self):
931        return derived_observable(lambda x, **kwargs: np.tanh(x[0]), [self], man_grad=[1 / np.cosh(self.value) ** 2])
def arcsinh(self):
933    def arcsinh(self):
934        return derived_observable(lambda x: anp.arcsinh(x[0]), [self])
def arccosh(self):
936    def arccosh(self):
937        return derived_observable(lambda x: anp.arccosh(x[0]), [self])
def arctanh(self):
939    def arctanh(self):
940        return derived_observable(lambda x: anp.arctanh(x[0]), [self])
N_sigma
S
e_ddvalue
e_drho
e_dtauint
e_dvalue
e_n_dtauint
e_n_tauint
e_rho
e_tauint
e_windowsize
tau_exp
class CObs:
 943class CObs:
 944    """Class for a complex valued observable."""
 945    __slots__ = ['_imag', '_real', 'tag']
 946
 947    def __init__(self, real, imag=0.0):
 948        self._real = real
 949        self._imag = imag
 950        self.tag = None
 951
 952    @property
 953    def real(self):
 954        return self._real
 955
 956    @property
 957    def imag(self):
 958        return self._imag
 959
 960    def gamma_method(self, **kwargs):
 961        """Executes the gamma_method for the real and the imaginary part."""
 962        if isinstance(self.real, Obs):
 963            self.real.gamma_method(**kwargs)
 964        if isinstance(self.imag, Obs):
 965            self.imag.gamma_method(**kwargs)
 966
 967    def is_zero(self):
 968        """Checks whether both real and imaginary part are zero within machine precision."""
 969        return self.real == 0.0 and self.imag == 0.0
 970
 971    def conjugate(self):
 972        return CObs(self.real, -self.imag)
 973
 974    def __add__(self, other):
 975        if isinstance(other, np.ndarray):
 976            return other + self
 977        elif hasattr(other, 'real') and hasattr(other, 'imag'):
 978            return CObs(self.real + other.real,
 979                        self.imag + other.imag)
 980        else:
 981            return CObs(self.real + other, self.imag)
 982
 983    def __radd__(self, y):
 984        return self + y
 985
 986    def __sub__(self, other):
 987        if isinstance(other, np.ndarray):
 988            return -1 * (other - self)
 989        elif hasattr(other, 'real') and hasattr(other, 'imag'):
 990            return CObs(self.real - other.real, self.imag - other.imag)
 991        else:
 992            return CObs(self.real - other, self.imag)
 993
 994    def __rsub__(self, other):
 995        return -1 * (self - other)
 996
 997    def __mul__(self, other):
 998        if isinstance(other, np.ndarray):
 999            return other * self
1000        elif hasattr(other, 'real') and hasattr(other, 'imag'):
1001            if all(isinstance(i, Obs) for i in [self.real, self.imag, other.real, other.imag]):
1002                return CObs(derived_observable(lambda x, **kwargs: x[0] * x[1] - x[2] * x[3],
1003                                               [self.real, other.real, self.imag, other.imag],
1004                                               man_grad=[other.real.value, self.real.value, -other.imag.value, -self.imag.value]),
1005                            derived_observable(lambda x, **kwargs: x[2] * x[1] + x[0] * x[3],
1006                                               [self.real, other.real, self.imag, other.imag],
1007                                               man_grad=[other.imag.value, self.imag.value, other.real.value, self.real.value]))
1008            elif getattr(other, 'imag', 0) != 0:
1009                return CObs(self.real * other.real - self.imag * other.imag,
1010                            self.imag * other.real + self.real * other.imag)
1011            else:
1012                return CObs(self.real * other.real, self.imag * other.real)
1013        else:
1014            return CObs(self.real * other, self.imag * other)
1015
1016    def __rmul__(self, other):
1017        return self * other
1018
1019    def __truediv__(self, other):
1020        if isinstance(other, np.ndarray):
1021            return 1 / (other / self)
1022        elif hasattr(other, 'real') and hasattr(other, 'imag'):
1023            r = other.real ** 2 + other.imag ** 2
1024            return CObs((self.real * other.real + self.imag * other.imag) / r, (self.imag * other.real - self.real * other.imag) / r)
1025        else:
1026            return CObs(self.real / other, self.imag / other)
1027
1028    def __rtruediv__(self, other):
1029        r = self.real ** 2 + self.imag ** 2
1030        if hasattr(other, 'real') and hasattr(other, 'imag'):
1031            return CObs((self.real * other.real + self.imag * other.imag) / r, (self.real * other.imag - self.imag * other.real) / r)
1032        else:
1033            return CObs(self.real * other / r, -self.imag * other / r)
1034
1035    def __abs__(self):
1036        return np.sqrt(self.real**2 + self.imag**2)
1037
1038    def __pos__(self):
1039        return self
1040
1041    def __neg__(self):
1042        return -1 * self
1043
1044    def __eq__(self, other):
1045        return self.real == other.real and self.imag == other.imag
1046
1047    __hash__ = None
1048
1049    def __str__(self):
1050        return '(' + str(self.real) + int(self.imag >= 0.0) * '+' + str(self.imag) + 'j)'
1051
1052    def __repr__(self):
1053        return 'CObs[' + str(self) + ']'
1054
1055    def __format__(self, format_type):
1056        if format_type == "":
1057            significance = 2
1058            format_type = "2"
1059        else:
1060            significance = int(float(format_type.replace("+", "").replace("-", "")))
1061        return f"({self.real:{format_type}}{self.imag:+{significance}}j)"

Class for a complex valued observable.

CObs(real, imag=0.0)
947    def __init__(self, real, imag=0.0):
948        self._real = real
949        self._imag = imag
950        self.tag = None
tag
real
952    @property
953    def real(self):
954        return self._real
imag
956    @property
957    def imag(self):
958        return self._imag
def gamma_method(self, **kwargs):
960    def gamma_method(self, **kwargs):
961        """Executes the gamma_method for the real and the imaginary part."""
962        if isinstance(self.real, Obs):
963            self.real.gamma_method(**kwargs)
964        if isinstance(self.imag, Obs):
965            self.imag.gamma_method(**kwargs)

Executes the gamma_method for the real and the imaginary part.

def is_zero(self):
967    def is_zero(self):
968        """Checks whether both real and imaginary part are zero within machine precision."""
969        return self.real == 0.0 and self.imag == 0.0

Checks whether both real and imaginary part are zero within machine precision.

def conjugate(self):
971    def conjugate(self):
972        return CObs(self.real, -self.imag)
def gamma_method(x, **kwargs):
1064def gamma_method(x, **kwargs):
1065    """Vectorized version of the gamma_method applicable to lists or arrays of Obs.
1066
1067    See docstring of pe.Obs.gamma_method for details.
1068    """
1069    return np.vectorize(lambda o: o.gm(**kwargs))(x)

Vectorized version of the gamma_method applicable to lists or arrays of Obs.

See docstring of pe.Obs.gamma_method for details.

def gm(x, **kwargs):
1064def gamma_method(x, **kwargs):
1065    """Vectorized version of the gamma_method applicable to lists or arrays of Obs.
1066
1067    See docstring of pe.Obs.gamma_method for details.
1068    """
1069    return np.vectorize(lambda o: o.gm(**kwargs))(x)

Vectorized version of the gamma_method applicable to lists or arrays of Obs.

See docstring of pe.Obs.gamma_method for details.

def derived_observable(func, data, array_mode=False, **kwargs):
1199def derived_observable(func, data, array_mode=False, **kwargs):
1200    """Construct a derived Obs according to func(data, **kwargs) using automatic differentiation.
1201
1202    Parameters
1203    ----------
1204    func : object
1205        arbitrary function of the form func(data, **kwargs). For the
1206        automatic differentiation to work, all numpy functions have to have
1207        the autograd wrapper (use 'import autograd.numpy as anp').
1208    data : list
1209        list of Obs, e.g. [obs1, obs2, obs3].
1210    num_grad : bool
1211        if True, numerical derivatives are used instead of autograd
1212        (default False). To control the numerical differentiation the
1213        kwargs of numdifftools.step_generators.MaxStepGenerator
1214        can be used.
1215    man_grad : list
1216        manually supply a list or an array which contains the jacobian
1217        of func. Use cautiously, supplying the wrong derivative will
1218        not be intercepted.
1219
1220    Notes
1221    -----
1222    For simple mathematical operations it can be practical to use anonymous
1223    functions. For the ratio of two observables one can e.g. use
1224
1225    new_obs = derived_observable(lambda x: x[0] / x[1], [obs1, obs2])
1226    """
1227
1228    data = np.asarray(data)
1229    raveled_data = data.ravel()
1230
1231    # Workaround for matrix operations containing non Obs data
1232    if not all(isinstance(x, Obs) for x in raveled_data):
1233        for i in range(len(raveled_data)):
1234            if isinstance(raveled_data[i], (int, float)):
1235                raveled_data[i] = cov_Obs(raveled_data[i], 0.0, "###dummy_covobs###")
1236
1237    allcov = {}
1238    for o in raveled_data:
1239        for name in o.cov_names:
1240            if name in allcov:
1241                if not np.allclose(allcov[name], o.covobs[name].cov):
1242                    raise Exception(f'Inconsistent covariance matrices for {name}!')
1243            else:
1244                allcov[name] = o.covobs[name].cov
1245
1246    n_obs = len(raveled_data)
1247    new_names = sorted(set([y for x in [o.names for o in raveled_data] for y in x]))
1248    new_cov_names = sorted(set([y for x in [o.cov_names for o in raveled_data] for y in x]))
1249    new_sample_names = sorted(set(new_names) - set(new_cov_names))
1250
1251    reweighted = len(list(filter(lambda o: o.reweighted is True, raveled_data))) > 0
1252
1253    if data.ndim == 1:
1254        values = np.array([o.value for o in data])
1255    else:
1256        values = np.vectorize(lambda x: x.value)(data)
1257
1258    new_values = func(values, **kwargs)
1259
1260    multi = int(isinstance(new_values, np.ndarray))
1261
1262    new_r_values = {}
1263    new_idl_d = {}
1264    for name in new_sample_names:
1265        idl = []
1266        tmp_values = np.zeros(n_obs)
1267        for i, item in enumerate(raveled_data):
1268            tmp_values[i] = item.r_values.get(name, item.value)
1269            tmp_idl = item.idl.get(name)
1270            if tmp_idl is not None:
1271                idl.append(tmp_idl)
1272        if multi > 0:
1273            tmp_values = np.array(tmp_values).reshape(data.shape)
1274        new_r_values[name] = func(tmp_values, **kwargs)
1275        new_idl_d[name] = _merge_idx(idl)
1276
1277    def _compute_scalefactor_missing_rep(obs):
1278        """
1279        Computes the scale factor that is to be multiplied with the deltas
1280        in the case where Obs with different subsets of replica are merged.
1281        Returns a dictionary with the scale factor for each Monte Carlo name.
1282
1283        Parameters
1284        ----------
1285        obs : Obs
1286            The observable corresponding to the deltas that are to be scaled
1287        """
1288        scalef_d = {}
1289        for mc_name in obs.mc_names:
1290            mc_idl_d = [name for name in obs.idl if name.startswith(mc_name + '|')]
1291            new_mc_idl_d = [name for name in new_idl_d if name.startswith(mc_name + '|')]
1292            if len(mc_idl_d) > 0 and len(mc_idl_d) < len(new_mc_idl_d):
1293                scalef_d[mc_name] = sum([len(new_idl_d[name]) for name in new_mc_idl_d]) / sum([len(new_idl_d[name]) for name in mc_idl_d])
1294        return scalef_d
1295
1296    if 'man_grad' in kwargs:
1297        deriv = np.asarray(kwargs.get('man_grad'))
1298        if new_values.shape + data.shape != deriv.shape:
1299            raise ValueError('Manual derivative does not have correct shape.')
1300    elif kwargs.get('num_grad') is True:
1301        if multi > 0:
1302            raise NotImplementedError('Multi mode currently not supported for numerical derivative')
1303        options = {
1304            'base_step': 0.1,
1305            'step_ratio': 2.5}
1306        for key in options:
1307            kwarg = kwargs.get(key)
1308            if kwarg is not None:
1309                options[key] = kwarg
1310        tmp_df = nd.Gradient(func, order=4, **{k: v for k, v in options.items() if v is not None})(values, **kwargs)
1311        if tmp_df.size == 1:
1312            deriv = np.array([tmp_df.real])
1313        else:
1314            deriv = tmp_df.real
1315    else:
1316        deriv = jacobian(func)(values, **kwargs)
1317
1318    final_result = np.zeros(new_values.shape, dtype=object)
1319
1320    if array_mode is True:
1321
1322        class _Zero_grad:
1323            def __init__(self, N):
1324                self.grad = np.zeros((N, 1))
1325
1326        new_covobs_lengths = dict(set([y for x in [[(n, o.covobs[n].N) for n in o.cov_names] for o in raveled_data] for y in x]))
1327        d_extracted = {}
1328        g_extracted = {}
1329        for name in new_sample_names:
1330            d_extracted[name] = []
1331            ens_length = len(new_idl_d[name])
1332            for dat in data:
1333                d_extracted[name].append(np.array([_expand_deltas_for_merge(o.deltas.get(name, np.zeros(ens_length)), o.idl.get(name, new_idl_d[name]), o.shape.get(name, ens_length), new_idl_d[name], _compute_scalefactor_missing_rep(o).get(name.split('|')[0], 1)) for o in dat.reshape(np.prod(dat.shape))]).reshape((*dat.shape, ens_length)))
1334        for name in new_cov_names:
1335            g_extracted[name] = []
1336            zero_grad = _Zero_grad(new_covobs_lengths[name])
1337            for dat in data:
1338                g_extracted[name].append(np.array([o.covobs.get(name, zero_grad).grad for o in dat.reshape(np.prod(dat.shape))]).reshape((*dat.shape, new_covobs_lengths[name], 1)))
1339
1340    for i_val, new_val in np.ndenumerate(new_values):
1341        new_deltas = {}
1342        new_grad = {}
1343        if array_mode is True:
1344            for name in new_sample_names:
1345                ens_length = d_extracted[name][0].shape[-1]
1346                new_deltas[name] = np.zeros(ens_length)
1347                for i_dat, dat in enumerate(d_extracted[name]):
1348                    new_deltas[name] += np.tensordot(deriv[(*i_val, i_dat)], dat)
1349            for name in new_cov_names:
1350                new_grad[name] = 0
1351                for i_dat, dat in enumerate(g_extracted[name]):
1352                    new_grad[name] += np.tensordot(deriv[(*i_val, i_dat)], dat)
1353        else:
1354            for j_obs, obs in np.ndenumerate(data):
1355                scalef_d = _compute_scalefactor_missing_rep(obs)
1356                for name in obs.names:
1357                    if name in obs.cov_names:
1358                        new_grad[name] = new_grad.get(name, 0) + deriv[i_val + j_obs] * obs.covobs[name].grad
1359                    else:
1360                        new_deltas[name] = new_deltas.get(name, 0) + deriv[i_val + j_obs] * _expand_deltas_for_merge(obs.deltas[name], obs.idl[name], obs.shape[name], new_idl_d[name], scalef_d.get(name.split('|')[0], 1))
1361
1362        new_covobs = {name: Covobs(0, allcov[name], name, grad=new_grad[name]) for name in new_grad}
1363
1364        if not set(new_covobs.keys()).isdisjoint(new_deltas.keys()):
1365            raise ValueError('The same name has been used for deltas and covobs!')
1366        new_samples = []
1367        new_means = []
1368        new_idl = []
1369        new_names_obs = []
1370        for name in new_names:
1371            if name not in new_covobs:
1372                new_samples.append(new_deltas[name])
1373                new_idl.append(new_idl_d[name])
1374                new_means.append(new_r_values[name][i_val])
1375                new_names_obs.append(name)
1376        final_result[i_val] = Obs(new_samples, new_names_obs, means=new_means, idl=new_idl)
1377        for name in new_covobs:
1378            final_result[i_val].names.append(name)
1379        final_result[i_val]._covobs = new_covobs
1380        final_result[i_val]._value = new_val
1381        final_result[i_val].reweighted = reweighted
1382
1383    if multi == 0:
1384        final_result = final_result.item()
1385
1386    return final_result

Construct a derived Obs according to func(data, **kwargs) using automatic differentiation.

Parameters
  • func (object): arbitrary function of the form func(data, **kwargs). For the automatic differentiation to work, all numpy functions have to have the autograd wrapper (use 'import autograd.numpy as anp').
  • data (list): list of Obs, e.g. [obs1, obs2, obs3].
  • num_grad (bool): if True, numerical derivatives are used instead of autograd (default False). To control the numerical differentiation the kwargs of numdifftools.step_generators.MaxStepGenerator can be used.
  • man_grad (list): manually supply a list or an array which contains the jacobian of func. Use cautiously, supplying the wrong derivative will not be intercepted.
Notes

For simple mathematical operations it can be practical to use anonymous functions. For the ratio of two observables one can e.g. use

new_obs = derived_observable(lambda x: x[0] / x[1], [obs1, obs2])

def reweight(weight, obs, **kwargs):
1418def reweight(weight, obs, **kwargs):
1419    """Reweight a list of observables.
1420
1421    Parameters
1422    ----------
1423    weight : Obs
1424        Reweighting factor. An Observable that has to be defined on a superset of the
1425        configurations in obs[i].idl for all i.
1426    obs : list
1427        list of Obs, e.g. [obs1, obs2, obs3].
1428    all_configs : bool
1429        if True, the reweighted observables are normalized by the average of
1430        the reweighting factor on all configurations in weight.idl and not
1431        on the configurations in obs[i].idl. Default False.
1432    """
1433    result = []
1434    for i in range(len(obs)):
1435        if len(obs[i].cov_names):
1436            raise ValueError('Error: Not possible to reweight an Obs that contains covobs!')
1437        if not set(obs[i].names).issubset(weight.names):
1438            raise ValueError('Error: Ensembles do not fit')
1439        if len(obs[i].mc_names) > 1 or len(weight.mc_names) > 1:
1440            raise ValueError('Error: Cannot reweight an Obs that contains multiple ensembles.')
1441        for name in obs[i].names:
1442            if not set(obs[i].idl[name]).issubset(weight.idl[name]):
1443                raise ValueError(f'obs[{i}] has to be defined on a subset of the configs in weight.idl[{name}]!')
1444        new_samples = []
1445        w_deltas = {}
1446        for name in sorted(obs[i].names):
1447            w_deltas[name] = _reduce_deltas(weight.deltas[name], weight.idl[name], obs[i].idl[name])
1448            new_samples.append((w_deltas[name] + weight.r_values[name]) * (obs[i].deltas[name] + obs[i].r_values[name]))
1449        tmp_obs = Obs(new_samples, sorted(obs[i].names), idl=[obs[i].idl[name] for name in sorted(obs[i].names)])
1450
1451        if kwargs.get('all_configs'):
1452            new_weight = weight
1453        else:
1454            new_weight = Obs([w_deltas[name] + weight.r_values[name] for name in sorted(obs[i].names)], sorted(obs[i].names), idl=[obs[i].idl[name] for name in sorted(obs[i].names)])
1455
1456        result.append(tmp_obs / new_weight)
1457        result[-1].reweighted = True
1458
1459    return result

Reweight a list of observables.

Parameters
  • weight (Obs): Reweighting factor. An Observable that has to be defined on a superset of the configurations in obs[i].idl for all i.
  • obs (list): list of Obs, e.g. [obs1, obs2, obs3].
  • all_configs (bool): if True, the reweighted observables are normalized by the average of the reweighting factor on all configurations in weight.idl and not on the configurations in obs[i].idl. Default False.
def correlate(obs_a, obs_b):
1462def correlate(obs_a, obs_b):
1463    """Correlate two observables.
1464
1465    Parameters
1466    ----------
1467    obs_a : Obs
1468        First observable
1469    obs_b : Obs
1470        Second observable
1471
1472    Notes
1473    -----
1474    Keep in mind to only correlate primary observables which have not been reweighted
1475    yet. The reweighting has to be applied after correlating the observables.
1476    Only works if a single ensemble is present in the Obs.
1477    Currently only works if ensemble content is identical (this is not strictly necessary).
1478    """
1479
1480    if len(obs_a.mc_names) > 1 or len(obs_b.mc_names) > 1:
1481        raise ValueError('Error: Cannot correlate Obs that contain multiple ensembles.')
1482    if sorted(obs_a.names) != sorted(obs_b.names):
1483        raise ValueError(f"Ensembles do not fit {set(sorted(obs_a.names)) ^ set(sorted(obs_b.names))}")
1484    if len(obs_a.cov_names) or len(obs_b.cov_names):
1485        raise ValueError('Error: Not possible to correlate Obs that contain covobs!')
1486    for name in obs_a.names:
1487        if obs_a.shape[name] != obs_b.shape[name]:
1488            raise ValueError('Shapes of ensemble', name, 'do not fit')
1489        if obs_a.idl[name] != obs_b.idl[name]:
1490            raise ValueError('idl of ensemble', name, 'do not fit')
1491
1492    if obs_a.reweighted is True:
1493        warnings.warn("The first observable is already reweighted.", RuntimeWarning, stacklevel=2)
1494    if obs_b.reweighted is True:
1495        warnings.warn("The second observable is already reweighted.", RuntimeWarning, stacklevel=2)
1496
1497    new_samples = []
1498    new_idl = []
1499    for name in sorted(obs_a.names):
1500        new_samples.append((obs_a.deltas[name] + obs_a.r_values[name]) * (obs_b.deltas[name] + obs_b.r_values[name]))
1501        new_idl.append(obs_a.idl[name])
1502
1503    o = Obs(new_samples, sorted(obs_a.names), idl=new_idl)
1504    o.reweighted = obs_a.reweighted or obs_b.reweighted
1505    return o

Correlate two observables.

Parameters
  • obs_a (Obs): First observable
  • obs_b (Obs): Second observable
Notes

Keep in mind to only correlate primary observables which have not been reweighted yet. The reweighting has to be applied after correlating the observables. Only works if a single ensemble is present in the Obs. Currently only works if ensemble content is identical (this is not strictly necessary).

def covariance(obs, visualize=False, correlation=False, smooth=None, **kwargs):
1508def covariance(obs, visualize=False, correlation=False, smooth=None, **kwargs):
1509    r'''Calculates the error covariance matrix of a set of observables.
1510
1511    WARNING: This function should be used with care, especially for observables with support on multiple
1512             ensembles with differing autocorrelations. See the notes below for details.
1513
1514    The gamma method has to be applied first to all observables.
1515
1516    Parameters
1517    ----------
1518    obs : list or numpy.ndarray
1519        List or one dimensional array of Obs
1520    visualize : bool
1521        If True plots the corresponding normalized correlation matrix (default False).
1522    correlation : bool
1523        If True the correlation matrix instead of the error covariance matrix is returned (default False).
1524    smooth : None or int
1525        If smooth is an integer 'E' between 2 and the dimension of the matrix minus 1 the eigenvalue
1526        smoothing procedure of hep-lat/9412087 is applied to the correlation matrix which leaves the
1527        largest E eigenvalues essentially unchanged and smoothes the smaller eigenvalues to avoid extremely
1528        small ones.
1529
1530    Notes
1531    -----
1532    The error covariance is defined such that it agrees with the squared standard error for two identical observables
1533    $$\operatorname{cov}(a,a)=\sum_{s=1}^N\delta_a^s\delta_a^s/N^2=\Gamma_{aa}(0)/N=\operatorname{var}(a)/N=\sigma_a^2$$
1534    in the absence of autocorrelation.
1535    The error covariance is estimated by calculating the correlation matrix assuming no autocorrelation and then rescaling the correlation matrix by the full errors including the previous gamma method estimate for the autocorrelation of the observables. The covariance at windowsize 0 is guaranteed to be positive semi-definite
1536    $$\sum_{i,j}v_i\Gamma_{ij}(0)v_j=\frac{1}{N}\sum_{s=1}^N\sum_{i,j}v_i\delta_i^s\delta_j^s v_j=\frac{1}{N}\sum_{s=1}^N\sum_{i}|v_i\delta_i^s|^2\geq 0\,,$$ for every $v\in\mathbb{R}^M$, while such an identity does not hold for larger windows/lags.
1537    For observables defined on a single ensemble our approximation is equivalent to assuming that the integrated autocorrelation time of an off-diagonal element is equal to the geometric mean of the integrated autocorrelation times of the corresponding diagonal elements.
1538    $$\tau_{\mathrm{int}, ij}=\sqrt{\tau_{\mathrm{int}, i}\times \tau_{\mathrm{int}, j}}$$
1539    This construction ensures that the estimated covariance matrix is positive semi-definite (up to numerical rounding errors).
1540    '''
1541
1542    length = len(obs)
1543
1544    max_samples = np.max([o.N for o in obs])
1545    if max_samples <= length and not [item for sublist in [o.cov_names for o in obs] for item in sublist]:
1546        warnings.warn(f"The dimension of the covariance matrix ({length}) is larger or equal to the number of samples ({max_samples}). This will result in a rank deficient matrix.", RuntimeWarning, stacklevel=2)
1547
1548    cov = np.zeros((length, length))
1549    for i in range(length):
1550        for j in range(i, length):
1551            cov[i, j] = _covariance_element(obs[i], obs[j])
1552    cov = cov + cov.T - np.diag(np.diag(cov))
1553
1554    corr = np.diag(1 / np.sqrt(np.diag(cov))) @ cov @ np.diag(1 / np.sqrt(np.diag(cov)))
1555
1556    if isinstance(smooth, int):
1557        corr = _smooth_eigenvalues(corr, smooth)
1558
1559    if visualize:
1560        plt.matshow(corr, vmin=-1, vmax=1)
1561        plt.set_cmap('RdBu')
1562        plt.colorbar()
1563        plt.draw()
1564
1565    if correlation is True:
1566        return corr
1567
1568    errors = [o.dvalue for o in obs]
1569    cov = np.diag(errors) @ corr @ np.diag(errors)
1570
1571    eigenvalues = np.linalg.eigh(cov)[0]
1572    if not np.all(eigenvalues >= 0):
1573        warnings.warn("Covariance matrix is not positive semi-definite (Eigenvalues: " + str(eigenvalues) + ")", RuntimeWarning, stacklevel=2)
1574
1575    return cov

Calculates the error covariance matrix of a set of observables.

WARNING: This function should be used with care, especially for observables with support on multiple ensembles with differing autocorrelations. See the notes below for details.

The gamma method has to be applied first to all observables.

Parameters
  • obs (list or numpy.ndarray): List or one dimensional array of Obs
  • visualize (bool): If True plots the corresponding normalized correlation matrix (default False).
  • correlation (bool): If True the correlation matrix instead of the error covariance matrix is returned (default False).
  • smooth (None or int): If smooth is an integer 'E' between 2 and the dimension of the matrix minus 1 the eigenvalue smoothing procedure of hep-lat/9412087 is applied to the correlation matrix which leaves the largest E eigenvalues essentially unchanged and smoothes the smaller eigenvalues to avoid extremely small ones.
Notes

The error covariance is defined such that it agrees with the squared standard error for two identical observables $$\operatorname{cov}(a,a)=\sum_{s=1}^N\delta_a^s\delta_a^s/N^2=\Gamma_{aa}(0)/N=\operatorname{var}(a)/N=\sigma_a^2$$ in the absence of autocorrelation. The error covariance is estimated by calculating the correlation matrix assuming no autocorrelation and then rescaling the correlation matrix by the full errors including the previous gamma method estimate for the autocorrelation of the observables. The covariance at windowsize 0 is guaranteed to be positive semi-definite $$\sum_{i,j}v_i\Gamma_{ij}(0)v_j=\frac{1}{N}\sum_{s=1}^N\sum_{i,j}v_i\delta_i^s\delta_j^s v_j=\frac{1}{N}\sum_{s=1}^N\sum_{i}|v_i\delta_i^s|^2\geq 0\,,$$ for every $v\in\mathbb{R}^M$, while such an identity does not hold for larger windows/lags. For observables defined on a single ensemble our approximation is equivalent to assuming that the integrated autocorrelation time of an off-diagonal element is equal to the geometric mean of the integrated autocorrelation times of the corresponding diagonal elements. $$\tau_{\mathrm{int}, ij}=\sqrt{\tau_{\mathrm{int}, i}\times \tau_{\mathrm{int}, j}}$$ This construction ensures that the estimated covariance matrix is positive semi-definite (up to numerical rounding errors).

def invert_corr_cov_cholesky(corr, inverrdiag):
1578def invert_corr_cov_cholesky(corr, inverrdiag):
1579    """Constructs a lower triangular matrix `chol` via the Cholesky decomposition of the correlation matrix `corr`
1580       and then returns the inverse covariance matrix `chol_inv` as a lower triangular matrix by solving `chol * x = inverrdiag`.
1581
1582    Parameters
1583    ----------
1584    corr : np.ndarray
1585           correlation matrix
1586    inverrdiag : np.ndarray
1587              diagonal matrix, the entries are the inverse errors of the data points considered
1588    """
1589
1590    condn = np.linalg.cond(corr)
1591    if condn > 0.1 / np.finfo(float).eps:
1592        raise ValueError(f"Cannot invert correlation matrix as its condition number exceeds machine precision ({condn:1.2e})")
1593    if condn > 1e13:
1594        warnings.warn(f"Correlation matrix may be ill-conditioned, condition number: {{{condn:1.2e}}}", RuntimeWarning, stacklevel=2)
1595    chol = np.linalg.cholesky(corr)
1596    chol_inv = scipy.linalg.solve_triangular(chol, inverrdiag, lower=True)
1597
1598    return chol_inv

Constructs a lower triangular matrix chol via the Cholesky decomposition of the correlation matrix corr and then returns the inverse covariance matrix chol_inv as a lower triangular matrix by solving chol * x = inverrdiag.

Parameters
  • corr (np.ndarray): correlation matrix
  • inverrdiag (np.ndarray): diagonal matrix, the entries are the inverse errors of the data points considered
def sort_corr(corr, kl, yd):
1601def sort_corr(corr, kl, yd):
1602    """ Reorders a correlation matrix to match the alphabetical order of its underlying y data.
1603
1604    The ordering of the input correlation matrix `corr` is given by the list of keys `kl`.
1605    The input dictionary `yd` (with the same keys `kl`) must contain the corresponding y data
1606    that the correlation matrix is based on.
1607    This function sorts the list of keys `kl` alphabetically and sorts the matrix `corr`
1608    according to this alphabetical order such that the sorted matrix `corr_sorted` corresponds
1609    to the y data `yd` when arranged in an alphabetical order by its keys.
1610
1611    Parameters
1612    ----------
1613    corr : np.ndarray
1614        A square correlation matrix constructed using the order of the y data specified by `kl`.
1615        The dimensions of `corr` should match the total number of y data points in `yd` combined.
1616    kl : list of str
1617        A list of keys that denotes the order in which the y data from `yd` was used to build the
1618        input correlation matrix `corr`.
1619    yd : dict of list
1620        A dictionary where each key corresponds to a unique identifier, and its value is a list of
1621        y data points. The total number of y data points across all keys must match the dimensions
1622        of `corr`. The lists in the dictionary can be lists of Obs.
1623
1624    Returns
1625    -------
1626    np.ndarray
1627        A new, sorted correlation matrix that corresponds to the y data from `yd` when arranged alphabetically by its keys.
1628
1629    Example
1630    -------
1631    >>> import numpy as np
1632    >>> import pyerrors as pe
1633    >>> corr = np.array([[1, 0.2, 0.3], [0.2, 1, 0.4], [0.3, 0.4, 1]])
1634    >>> kl = ['b', 'a']
1635    >>> yd = {'a': [1, 2], 'b': [3]}
1636    >>> sorted_corr = pe.obs.sort_corr(corr, kl, yd)
1637    >>> print(sorted_corr)
1638    array([[1. , 0.3, 0.4],
1639           [0.3, 1. , 0.2],
1640           [0.4, 0.2, 1. ]])
1641
1642    """
1643    kl_sorted = sorted(kl)
1644
1645    posd = {}
1646    ofs = 0
1647    for _ki, k in enumerate(kl):
1648        posd[k] = [i + ofs for i in range(len(yd[k]))]
1649        ofs += len(posd[k])
1650
1651    mapping = []
1652    for k in kl_sorted:
1653        for i in range(len(yd[k])):
1654            mapping.append(posd[k][i])
1655
1656    corr_sorted = np.zeros_like(corr)
1657    for i in range(corr.shape[0]):
1658        for j in range(corr.shape[0]):
1659            corr_sorted[i][j] = corr[mapping[i]][mapping[j]]
1660
1661    return corr_sorted

Reorders a correlation matrix to match the alphabetical order of its underlying y data.

The ordering of the input correlation matrix corr is given by the list of keys kl. The input dictionary yd (with the same keys kl) must contain the corresponding y data that the correlation matrix is based on. This function sorts the list of keys kl alphabetically and sorts the matrix corr according to this alphabetical order such that the sorted matrix corr_sorted corresponds to the y data yd when arranged in an alphabetical order by its keys.

Parameters
  • corr (np.ndarray): A square correlation matrix constructed using the order of the y data specified by kl. The dimensions of corr should match the total number of y data points in yd combined.
  • kl (list of str): A list of keys that denotes the order in which the y data from yd was used to build the input correlation matrix corr.
  • yd (dict of list): A dictionary where each key corresponds to a unique identifier, and its value is a list of y data points. The total number of y data points across all keys must match the dimensions of corr. The lists in the dictionary can be lists of Obs.
Returns
  • np.ndarray: A new, sorted correlation matrix that corresponds to the y data from yd when arranged alphabetically by its keys.
Example
>>> import numpy as np
>>> import pyerrors as pe
>>> corr = np.array([[1, 0.2, 0.3], [0.2, 1, 0.4], [0.3, 0.4, 1]])
>>> kl = ['b', 'a']
>>> yd = {'a': [1, 2], 'b': [3]}
>>> sorted_corr = pe.obs.sort_corr(corr, kl, yd)
>>> print(sorted_corr)
array([[1. , 0.3, 0.4],
       [0.3, 1. , 0.2],
       [0.4, 0.2, 1. ]])
def import_jackknife(jacks, name, idl=None):
1741def import_jackknife(jacks, name, idl=None):
1742    """Imports jackknife samples and returns an Obs
1743
1744    Parameters
1745    ----------
1746    jacks : numpy.ndarray
1747        numpy array containing the mean value as zeroth entry and
1748        the N jackknife samples as first to Nth entry.
1749    name : str
1750        name of the ensemble the samples are defined on.
1751    """
1752    length = len(jacks) - 1
1753    prj = (np.ones((length, length)) - (length - 1) * np.identity(length))
1754    samples = jacks[1:] @ prj
1755    mean = np.mean(samples)
1756    new_obs = Obs([samples - mean], [name], idl=idl, means=[mean])
1757    new_obs._value = jacks[0]
1758    return new_obs

Imports jackknife samples and returns an Obs

Parameters
  • jacks (numpy.ndarray): numpy array containing the mean value as zeroth entry and the N jackknife samples as first to Nth entry.
  • name (str): name of the ensemble the samples are defined on.
def import_bootstrap(boots, name, random_numbers):
1761def import_bootstrap(boots, name, random_numbers):
1762    """Imports bootstrap samples and returns an Obs
1763
1764    Parameters
1765    ----------
1766    boots : numpy.ndarray
1767        numpy array containing the mean value as zeroth entry and
1768        the N bootstrap samples as first to Nth entry.
1769    name : str
1770        name of the ensemble the samples are defined on.
1771    random_numbers : np.ndarray
1772        Array of shape (samples, length) containing the random numbers to generate the bootstrap samples,
1773        where samples is the number of bootstrap samples and length is the length of the original Monte Carlo
1774        chain to be reconstructed.
1775    """
1776    samples, length = random_numbers.shape
1777    if samples != len(boots) - 1:
1778        raise ValueError("Random numbers do not have the correct shape.")
1779
1780    if samples < length:
1781        raise ValueError("Obs can't be reconstructed if there are fewer bootstrap samples than Monte Carlo data points.")
1782
1783    proj = np.vstack([np.bincount(o, minlength=length) for o in random_numbers]) / length
1784
1785    samples = scipy.linalg.lstsq(proj, boots[1:])[0]
1786    ret = Obs([samples], [name])
1787    ret._value = boots[0]
1788    return ret

Imports bootstrap samples and returns an Obs

Parameters
  • boots (numpy.ndarray): numpy array containing the mean value as zeroth entry and the N bootstrap samples as first to Nth entry.
  • name (str): name of the ensemble the samples are defined on.
  • random_numbers (np.ndarray): Array of shape (samples, length) containing the random numbers to generate the bootstrap samples, where samples is the number of bootstrap samples and length is the length of the original Monte Carlo chain to be reconstructed.
def merge_obs(list_of_obs):
1791def merge_obs(list_of_obs):
1792    """Combine all observables in list_of_obs into one new observable.
1793    This allows to merge Obs that have been computed on multiple replica
1794    of the same ensemble.
1795    If you like to merge Obs that are based on several ensembles, please
1796    average them yourself.
1797
1798    Parameters
1799    ----------
1800    list_of_obs : list
1801        list of the Obs object to be combined
1802
1803    Notes
1804    -----
1805    It is not possible to combine obs which are based on the same replicum
1806    """
1807    replist = [item for obs in list_of_obs for item in obs.names]
1808    if (len(replist) == len(set(replist))) is False:
1809        raise ValueError(f'list_of_obs contains duplicate replica: {replist!s}')
1810    if any([len(o.cov_names) for o in list_of_obs]):
1811        raise ValueError('Not possible to merge data that contains covobs!')
1812    new_dict = {}
1813    idl_dict = {}
1814    for o in list_of_obs:
1815        new_dict.update({key: o.deltas.get(key, 0) + o.r_values.get(key, 0)
1816                        for key in set(o.deltas) | set(o.r_values)})
1817        idl_dict.update({key: o.idl.get(key, 0) for key in set(o.deltas)})
1818
1819    names = sorted(new_dict.keys())
1820    o = Obs([new_dict[name] for name in names], names, idl=[idl_dict[name] for name in names])
1821    o.reweighted = np.max([oi.reweighted for oi in list_of_obs])
1822    return o

Combine all observables in list_of_obs into one new observable. This allows to merge Obs that have been computed on multiple replica of the same ensemble. If you like to merge Obs that are based on several ensembles, please average them yourself.

Parameters
  • list_of_obs (list): list of the Obs object to be combined
Notes

It is not possible to combine obs which are based on the same replicum

def cov_Obs(means, cov, name, grad=None):
1825def cov_Obs(means, cov, name, grad=None):
1826    """Create an Obs based on mean(s) and a covariance matrix
1827
1828    Parameters
1829    ----------
1830    mean : list of floats or float
1831        N mean value(s) of the new Obs
1832    cov : list or array
1833        2d (NxN) Covariance matrix, 1d diagonal entries or 0d covariance
1834    name : str
1835        identifier for the covariance matrix
1836    grad : list or array
1837        Gradient of the Covobs wrt. the means belonging to cov.
1838    """
1839
1840    def covobs_to_obs(co):
1841        """Make an Obs out of a Covobs
1842
1843        Parameters
1844        ----------
1845        co : Covobs
1846            Covobs to be embedded into the Obs
1847        """
1848        o = Obs([], [], means=[])
1849        o._value = co.value
1850        o.names.append(co.name)
1851        o._covobs[co.name] = co
1852        o._dvalue = np.sqrt(co.errsq())
1853        return o
1854
1855    ol = []
1856    if isinstance(means, (float, int)):
1857        means = [means]
1858
1859    for i in range(len(means)):
1860        ol.append(covobs_to_obs(Covobs(means[i], cov, name, pos=i, grad=grad)))
1861    if ol[0].covobs[name].N != len(means):
1862        raise ValueError(f'You have to provide {ol[0].N} mean values!')
1863    if len(ol) == 1:
1864        return ol[0]
1865    return ol

Create an Obs based on mean(s) and a covariance matrix

Parameters
  • mean (list of floats or float): N mean value(s) of the new Obs
  • cov (list or array): 2d (NxN) Covariance matrix, 1d diagonal entries or 0d covariance
  • name (str): identifier for the covariance matrix
  • grad (list or array): Gradient of the Covobs wrt. the means belonging to cov.