From e366eb9366d37662a19e892332a512b296b3066c Mon Sep 17 00:00:00 2001 From: Nathan Simpson Date: Sat, 3 Sep 2022 00:18:04 +0200 Subject: [PATCH 01/13] feat: Promote `validate` kwarg to top-level functions in `pyhf.simplemodels` (#1858) * Add `validate` kwarg to pyhf.simplemodels.uncorrelated_background and pyhf.simplemodels.correlated_background API. This allows expert users to avoid validating their models in specific circumstances. * Add Nathan Simpson to contributors list. --- docs/contributors.rst | 1 + src/pyhf/simplemodels.py | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/contributors.rst b/docs/contributors.rst index e62e5e333c..9ee54b8578 100644 --- a/docs/contributors.rst +++ b/docs/contributors.rst @@ -28,3 +28,4 @@ Contributors include: - Lars Henkelmann - Aryan Roy - Jerry Ling +- Nathan Simpson diff --git a/src/pyhf/simplemodels.py b/src/pyhf/simplemodels.py index 8ec2a6b433..299d3176c1 100644 --- a/src/pyhf/simplemodels.py +++ b/src/pyhf/simplemodels.py @@ -9,7 +9,9 @@ def __dir__(): return __all__ -def correlated_background(signal, bkg, bkg_up, bkg_down, batch_size=None): +def correlated_background( + signal, bkg, bkg_up, bkg_down, batch_size=None, validate=True +): r""" Construct a simple single channel :class:`~pyhf.pdf.Model` with a :class:`~pyhf.modifiers.histosys` modifier representing a background @@ -23,6 +25,8 @@ def correlated_background(signal, bkg, bkg_up, bkg_down, batch_size=None): bkg_down (:obj:`list`): The background sample under a downward variation corresponding to :math:`\alpha=-1`. batch_size (:obj:`None` or :obj:`int`): Number of simultaneous (batched) Models to compute. + validate (:obj:`bool`): If :obj:`True`, validate the model before returning. + Only set this to :obj:`False` if you have an experimental use case and know what you're doing. Returns: ~pyhf.pdf.Model: The statistical model adhering to the :obj:`model.json` schema. @@ -75,10 +79,12 @@ def correlated_background(signal, bkg, bkg_up, bkg_down, batch_size=None): } ] } - return Model(spec, batch_size=batch_size) + return Model(spec, batch_size=batch_size, validate=validate) -def uncorrelated_background(signal, bkg, bkg_uncertainty, batch_size=None): +def uncorrelated_background( + signal, bkg, bkg_uncertainty, batch_size=None, validate=True +): """ Construct a simple single channel :class:`~pyhf.pdf.Model` with a :class:`~pyhf.modifiers.shapesys` modifier representing an uncorrelated @@ -106,6 +112,8 @@ def uncorrelated_background(signal, bkg, bkg_uncertainty, batch_size=None): bkg (:obj:`list`): The data in the background sample bkg_uncertainty (:obj:`list`): The statistical uncertainty on the background sample counts batch_size (:obj:`None` or :obj:`int`): Number of simultaneous (batched) Models to compute + validate (:obj:`bool`): If :obj:`True`, validate the model before returning. + Only set this to :obj:`False` if you have an experimental use case and know what you're doing. Returns: ~pyhf.pdf.Model: The statistical model adhering to the :obj:`model.json` schema @@ -138,7 +146,7 @@ def uncorrelated_background(signal, bkg, bkg_uncertainty, batch_size=None): } ] } - return Model(spec, batch_size=batch_size) + return Model(spec, batch_size=batch_size, validate=validate) # Deprecated APIs From 8db4a1fa543816984c31d164a9b74bf64f1beaa2 Mon Sep 17 00:00:00 2001 From: Giordon Stark Date: Fri, 2 Sep 2022 21:24:46 -0700 Subject: [PATCH 02/13] docs: Clarify absolute/relative for histosys (#1971) * Clarify the schema data for histosys is absolute and not relative counts in the Correlated Shape (histosys) documentation example for likelihood specification. --- docs/likelihood.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/likelihood.rst b/docs/likelihood.rst index 6bf6336224..b724a7da35 100644 --- a/docs/likelihood.rst +++ b/docs/likelihood.rst @@ -173,6 +173,9 @@ for a 2-bin channel is shown below: { "name": "mod_name", "type": "histosys", "data": {"hi_data": [20,15], "lo_data": [10, 10]} } +This example specifies the expected event rate for the high-variation of the ``histosys`` as ``[20, 15]`` (20 events in first bin, 15 events in second bin); for the low-variation as ``[10, 10]`` (10 events in first bin, 10 events in second bin). +This variation is absolute (not relative!). + Normalisation Uncertainty (normsys) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From b6e02eebe7489d40c7fb71cc0f971aa19b7c94e1 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 3 Sep 2022 06:08:37 +0000 Subject: [PATCH 03/13] =?UTF-8?q?Bump=20version:=200.7.0rc2=20=E2=86=92=20?= =?UTF-8?q?0.7.0rc3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .zenodo.json | 6 +++--- CITATION.cff | 8 ++++---- README.rst | 10 +++++----- codemeta.json | 2 +- docs/generate_jupyterlite_iframe.py | 2 +- docs/jupyterlite.rst | 2 +- src/pyhf/data/citation.bib | 6 +++--- src/pyhf/utils.py | 2 +- tbump.toml | 4 ++-- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.zenodo.json b/.zenodo.json index 0a0cbfa696..b8cb610161 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -1,8 +1,8 @@ { "description": "pure-Python HistFactory implementation with tensors and autodiff", "license": "Apache-2.0", - "title": "scikit-hep/pyhf: v0.7.0rc2", - "version": "v0.7.0rc2", + "title": "scikit-hep/pyhf: v0.7.0rc3", + "version": "v0.7.0rc3", "upload_type": "software", "creators": [ { @@ -36,7 +36,7 @@ "related_identifiers": [ { "scheme": "url", - "identifier": "https://github.com/scikit-hep/pyhf/tree/v0.7.0rc2", + "identifier": "https://github.com/scikit-hep/pyhf/tree/v0.7.0rc3", "relation": "isSupplementTo" } ] diff --git a/CITATION.cff b/CITATION.cff index f7d613351c..c37eac3dab 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -14,11 +14,11 @@ authors: given-names: "Giordon" orcid: "https://orcid.org/0000-0001-6616-3433" affiliation: "SCIPP, University of California, Santa Cruz" -title: "pyhf: v0.7.0rc2" -version: 0.7.0rc2 +title: "pyhf: v0.7.0rc3" +version: 0.7.0rc3 doi: 10.5281/zenodo.1169739 -repository-code: "https://github.com/scikit-hep/pyhf/releases/tag/v0.7.0rc2" -url: "https://pyhf.readthedocs.io/en/v0.7.0rc2/" +repository-code: "https://github.com/scikit-hep/pyhf/releases/tag/v0.7.0rc3" +url: "https://pyhf.readthedocs.io/en/v0.7.0rc3/" keywords: - python - physics diff --git a/README.rst b/README.rst index 397f5395ac..bbddfa4192 100644 --- a/README.rst +++ b/README.rst @@ -309,11 +309,11 @@ the preferred BibTeX entry for citation of ``pyhf`` includes both the @software{pyhf, author = {Lukas Heinrich and Matthew Feickert and Giordon Stark}, - title = "{pyhf: v0.7.0rc2}", - version = {0.7.0rc2}, + title = "{pyhf: v0.7.0rc3}", + version = {0.7.0rc3}, doi = {10.5281/zenodo.1169739}, url = {https://doi.org/10.5281/zenodo.1169739}, - note = {https://github.com/scikit-hep/pyhf/releases/tag/v0.7.0rc2} + note = {https://github.com/scikit-hep/pyhf/releases/tag/v0.7.0rc3} } @article{pyhf_joss, @@ -360,7 +360,7 @@ and grant `OAC-1450377 diff --git a/src/pyhf/data/citation.bib b/src/pyhf/data/citation.bib index 49815627aa..cb5c5f2749 100644 --- a/src/pyhf/data/citation.bib +++ b/src/pyhf/data/citation.bib @@ -1,10 +1,10 @@ @software{pyhf, author = {Lukas Heinrich and Matthew Feickert and Giordon Stark}, - title = "{pyhf: v0.7.0rc2}", - version = {0.7.0rc2}, + title = "{pyhf: v0.7.0rc3}", + version = {0.7.0rc3}, doi = {10.5281/zenodo.1169739}, url = {https://doi.org/10.5281/zenodo.1169739}, - note = {https://github.com/scikit-hep/pyhf/releases/tag/v0.7.0rc2} + note = {https://github.com/scikit-hep/pyhf/releases/tag/v0.7.0rc3} } @article{pyhf_joss, diff --git a/src/pyhf/utils.py b/src/pyhf/utils.py index c05dbad5ec..23f374515d 100644 --- a/src/pyhf/utils.py +++ b/src/pyhf/utils.py @@ -111,7 +111,7 @@ def citation(oneline=False): >>> import pyhf >>> pyhf.utils.citation(oneline=True) - '@software{pyhf, author = {Lukas Heinrich and Matthew Feickert and Giordon Stark}, title = "{pyhf: v0.7.0rc2}", version = {0.7.0rc2}, doi = {10.5281/zenodo.1169739}, url = {https://doi.org/10.5281/zenodo.1169739}, note = {https://github.com/scikit-hep/pyhf/releases/tag/v0.7.0rc2}}@article{pyhf_joss, doi = {10.21105/joss.02823}, url = {https://doi.org/10.21105/joss.02823}, year = {2021}, publisher = {The Open Journal}, volume = {6}, number = {58}, pages = {2823}, author = {Lukas Heinrich and Matthew Feickert and Giordon Stark and Kyle Cranmer}, title = {pyhf: pure-Python implementation of HistFactory statistical models}, journal = {Journal of Open Source Software}}' + '@software{pyhf, author = {Lukas Heinrich and Matthew Feickert and Giordon Stark}, title = "{pyhf: v0.7.0rc3}", version = {0.7.0rc3}, doi = {10.5281/zenodo.1169739}, url = {https://doi.org/10.5281/zenodo.1169739}, note = {https://github.com/scikit-hep/pyhf/releases/tag/v0.7.0rc3}}@article{pyhf_joss, doi = {10.21105/joss.02823}, url = {https://doi.org/10.21105/joss.02823}, year = {2021}, publisher = {The Open Journal}, volume = {6}, number = {58}, pages = {2823}, author = {Lukas Heinrich and Matthew Feickert and Giordon Stark and Kyle Cranmer}, title = {pyhf: pure-Python implementation of HistFactory statistical models}, journal = {Journal of Open Source Software}}' Keyword Args: oneline (:obj:`bool`): Whether to provide citation with new lines (default) or as a one-liner. diff --git a/tbump.toml b/tbump.toml index 7dfc65efe9..60204abbc2 100644 --- a/tbump.toml +++ b/tbump.toml @@ -1,7 +1,7 @@ github_url = "https://github.com/scikit-hep/pyhf/" [version] -current = "0.7.0rc2" +current = "0.7.0rc3" # Example of a semver regexp. # Make sure this matches current_version before @@ -19,7 +19,7 @@ regex = ''' [git] # The current version will get updated when tbump is run -message_template = "Bump version: 0.7.0rc2 → {new_version}" +message_template = "Bump version: 0.7.0rc3 → {new_version}" tag_template = "v{new_version}" # For each file to patch, add a [[file]] config From 0fe3434bc43f5508018483994fc2c85ce2d3e035 Mon Sep 17 00:00:00 2001 From: Giordon Stark Date: Sun, 4 Sep 2022 12:49:10 -0700 Subject: [PATCH 04/13] docs: Document Channel Summary Mixin (#1972) * Make _ChannelSummaryMixin parameter related attributes properties. * Add documentation for parameter related properties of _ModelConfig by documenting the _ChannelSummaryMixin. * Add typehints to mixins. * Update tests to mock the properties. --- docs/api.rst | 1 + pyproject.toml | 1 - src/pyhf/mixins.py | 76 +++++++++++++++++++++++++++++++---------- src/pyhf/pdf.py | 73 +++++++++++++++++++++++++++++++++------ tests/test_workspace.py | 11 ++++-- 5 files changed, 129 insertions(+), 33 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 9a4fa772f3..bf0e1c15eb 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -44,6 +44,7 @@ Making Models from PDFs ~pdf.Model ~pdf._ModelConfig + ~mixins._ChannelSummaryMixin ~workspace.Workspace ~patchset.PatchSet ~patchset.Patch diff --git a/pyproject.toml b/pyproject.toml index 2708c44b7f..035defaab5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,7 +136,6 @@ module = [ 'pyhf.compat', 'pyhf.events', 'pyhf.utils', - 'pyhf.mixins', 'pyhf.constraints', 'pyhf.pdf', 'pyhf.simplemodels', diff --git a/src/pyhf/mixins.py b/src/pyhf/mixins.py index 30a0919a48..0314188cc1 100644 --- a/src/pyhf/mixins.py +++ b/src/pyhf/mixins.py @@ -1,4 +1,9 @@ +from __future__ import annotations + import logging +from typing import Any, Sequence + +from pyhf.typing import Channel log = logging.getLogger(__name__) @@ -13,39 +18,74 @@ class _ChannelSummaryMixin: **channels: A list of channels to provide summary information about. Follows the `defs.json#/definitions/channel` schema. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Sequence[Channel]): channels = kwargs.pop('channels') super().__init__(*args, **kwargs) - self.channels = [] - self.samples = [] - self.modifiers = [] + self._channels: list[str] = [] + self._samples: list[str] = [] + self._modifiers: list[tuple[str, str]] = [] # keep track of the width of each channel (how many bins) - self.channel_nbins = {} + self._channel_nbins: dict[str, int] = {} # need to keep track in which order we added the constraints # so that we can generate correctly-ordered data for channel in channels: - self.channels.append(channel['name']) - self.channel_nbins[channel['name']] = len(channel['samples'][0]['data']) + self._channels.append(channel['name']) + self._channel_nbins[channel['name']] = len(channel['samples'][0]['data']) for sample in channel['samples']: - self.samples.append(sample['name']) + self._samples.append(sample['name']) for modifier_def in sample['modifiers']: - self.modifiers.append( + self._modifiers.append( ( modifier_def['name'], # mod name modifier_def['type'], # mod type ) ) - self.channels = sorted(list(set(self.channels))) - self.samples = sorted(list(set(self.samples))) - self.modifiers = sorted(list(set(self.modifiers))) - self.channel_nbins = { - channel: self.channel_nbins[channel] for channel in self.channels + self._channels = sorted(list(set(self._channels))) + self._samples = sorted(list(set(self._samples))) + self._modifiers = sorted(list(set(self._modifiers))) + self._channel_nbins = { + channel: self._channel_nbins[channel] for channel in self._channels } - self.channel_slices = {} + self._channel_slices = {} begin = 0 - for c in self.channels: - end = begin + self.channel_nbins[c] - self.channel_slices[c] = slice(begin, end) + for c in self._channels: + end = begin + self._channel_nbins[c] + self._channel_slices[c] = slice(begin, end) begin = end + + @property + def channels(self) -> list[str]: + """ + Ordered list of channel names in the model. + """ + return self._channels + + @property + def samples(self) -> list[str]: + """ + Ordered list of sample names in the model. + """ + return self._samples + + @property + def modifiers(self) -> list[tuple[str, str]]: + """ + Ordered list of pairs of modifier name/type in the model. + """ + return self._modifiers + + @property + def channel_nbins(self) -> dict[str, int]: + """ + Dictionary mapping channel name to number of bins in the channel. + """ + return self._channel_nbins + + @property + def channel_slices(self) -> dict[str, slice]: + """ + Dictionary mapping channel name to the bin slices in the model. + """ + return self._channel_slices diff --git a/src/pyhf/pdf.py b/src/pyhf/pdf.py index b145a89721..db59fa1c70 100644 --- a/src/pyhf/pdf.py +++ b/src/pyhf/pdf.py @@ -220,13 +220,58 @@ def __init__(self, spec, **config_kwargs): f"Unsupported options were passed in: {list(config_kwargs.keys())}." ) + # prefixed with underscore are documented via @property + self._par_order = [] + self._poi_name = None + self._poi_index = None + self._nmaindata = sum(self.channel_nbins.values()) + self._auxdata = [] + + # these are not documented properties self.par_map = {} - self.par_order = [] - self.poi_name = None - self.poi_index = None - self.auxdata = [] self.auxdata_order = [] - self.nmaindata = sum(self.channel_nbins.values()) + + @property + def par_order(self): + """ + Return an ordered list of paramset names in the model. + """ + return self._par_order + + @property + def poi_name(self): + """ + Return the name of the POI parameter in the model. + """ + return self._poi_name + + @property + def poi_index(self): + """ + Return the index of the POI parameter in the model. + """ + return self._poi_index + + @property + def auxdata(self): + """ + Return the auxiliary data in the model. + """ + return self._auxdata + + @property + def nmaindata(self): + """ + Return the length of data in the main model. + """ + return self._nmaindata + + @property + def nauxdata(self): + """ + Return the length of data in the constraint model. + """ + return len(self._auxdata) def set_parameters(self, _required_paramsets): """ @@ -240,9 +285,8 @@ def set_auxinfo(self, auxdata, auxdata_order): """ Sets a group of configuration data for the constraint terms. """ - self.auxdata = auxdata + self._auxdata = auxdata self.auxdata_order = auxdata_order - self.nauxdata = len(self.auxdata) def suggested_init(self): """ @@ -400,8 +444,8 @@ def set_poi(self, name): ) s = self.par_slice(name) assert s.stop - s.start == 1 - self.poi_name = name - self.poi_index = s.start + self._poi_name = name + self._poi_index = s.start def _create_and_register_paramsets(self, required_paramsets): next_index = 0 @@ -415,7 +459,7 @@ def _create_and_register_paramsets(self, required_paramsets): sl = slice(next_index, next_index + paramset.n_parameters) next_index = next_index + paramset.n_parameters - self.par_order.append(param_name) + self._par_order.append(param_name) self.par_map[param_name] = {'slice': sl, 'paramset': paramset} @@ -700,7 +744,7 @@ def __init__( schema.validate(self.spec, self.schema, version=self.version) # build up our representation of the specification poi_name = config_kwargs.pop('poi_name', 'mu') - self.config = _ModelConfig(self.spec, **config_kwargs) + self._config = _ModelConfig(self.spec, **config_kwargs) modifiers, _nominal_rates = _nominal_and_modifiers_from_spec( modifier_set, self.config, self.spec, self.batch_size @@ -733,6 +777,13 @@ def __init__( sizes, ['main', 'aux'], self.batch_size ) + @property + def config(self): + """ + The :class:`_ModelConfig` instance for the model. + """ + return self._config + def expected_auxdata(self, pars): """ Compute the expected value of the auxiliary measurements. diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 6f1f380307..aaa7a3b301 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -170,15 +170,20 @@ def test_get_workspace_data(workspace_factory, include_auxdata): assert w.data(m, include_auxdata=include_auxdata) -def test_get_workspace_data_bad_model(workspace_factory, caplog): +def test_get_workspace_data_bad_model(workspace_factory, caplog, mocker): w = workspace_factory() m = w.model() # the iconic fragrance of an expected failure - m.config.channels = [c.replace('channel', 'chanel') for c in m.config.channels] + + mocker.patch( + "pyhf.mixins._ChannelSummaryMixin.channels", + new_callable=mocker.PropertyMock, + return_value=["fakechannel"], + ) with caplog.at_level(logging.INFO, 'pyhf.pdf'): with pytest.raises(KeyError): assert w.data(m) - assert 'Invalid channel' in caplog.text + assert "Invalid channel" in caplog.text def test_json_serializable(workspace_factory): From e4aae10b53b8b20c70776bd9c540efda203c9264 Mon Sep 17 00:00:00 2001 From: Giordon Stark Date: Sun, 4 Sep 2022 16:37:10 -0700 Subject: [PATCH 05/13] docs: Harmonize docstring for test_stat in ToyCalculator (#1970) * Harmonize the docstring for test_stat between AsymptoticCalculator and ToyCalculator. --- src/pyhf/infer/calculators.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/pyhf/infer/calculators.py b/src/pyhf/infer/calculators.py index e0cd681acc..8e06a613cb 100644 --- a/src/pyhf/infer/calculators.py +++ b/src/pyhf/infer/calculators.py @@ -688,12 +688,14 @@ def __init__( value during minimization. test_stat (:obj:`str`): The test statistic to use as a numerical summary of the data: ``'qtilde'``, ``'q'``, or ``'q0'``. - ``'qtilde'`` (default) performs the calculation using the alternative test statistic, - :math:`\tilde{q}_{\mu}`, as defined under the Wald approximation in Equation (62) - of :xref:`arXiv:1007.1727` (:func:`~pyhf.infer.test_statistics.qmu_tilde`), ``'q'`` - performs the calculation using the test statistic :math:`q_{\mu}` - (:func:`~pyhf.infer.test_statistics.qmu`), and ``'q0'`` performs the calculation using - the discovery test statistic :math:`q_{0}` (:func:`~pyhf.infer.test_statistics.q0`). + + * ``'qtilde'``: (default) performs the calculation using the alternative test statistic, + :math:`\tilde{q}_{\mu}`, as defined under the Wald approximation in Equation (62) + of :xref:`arXiv:1007.1727` (:func:`~pyhf.infer.test_statistics.qmu_tilde`). + * ``'q'``: performs the calculation using the test statistic :math:`q_{\mu}` + (:func:`~pyhf.infer.test_statistics.qmu`). + * ``'q0'``: performs the calculation using the discovery test statistic + :math:`q_{0}` (:func:`~pyhf.infer.test_statistics.q0`). ntoys (:obj:`int`): Number of toys to use (how many times to sample the underlying distributions). track_progress (:obj:`bool`): Whether to display the `tqdm` progress bar or not (outputs to `stderr`). From 0dc22240d03e8196fe29e8ca6def039c72df6f1e Mon Sep 17 00:00:00 2001 From: Giordon Stark Date: Mon, 5 Sep 2022 11:03:43 -0700 Subject: [PATCH 06/13] feat: Add support for `_ModelConfig.set_poi(None)` to unset model POI (#1985) * Allow for `_ModelConfig.set_poi(None)` as a way to unset the POI from a model. * Add test of `model.config.set_poi` for value of the poi that are valid strings and None. --- src/pyhf/pdf.py | 7 +++++++ tests/test_public_api.py | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/pyhf/pdf.py b/src/pyhf/pdf.py index db59fa1c70..3710f5542e 100644 --- a/src/pyhf/pdf.py +++ b/src/pyhf/pdf.py @@ -429,6 +429,8 @@ def set_poi(self, name): """ Set the model parameter of interest to be model parameter ``name``. + If ``name`` is ``None``, this will unset the parameter of interest. + Example: >>> import pyhf >>> model = pyhf.simplemodels.uncorrelated_background( @@ -438,6 +440,11 @@ def set_poi(self, name): >>> model.config.poi_name 'mu' """ + if name is None: + self._poi_name = None + self._poi_index = None + return + if name not in self.parameters: raise exceptions.InvalidModel( f"The parameter of interest '{name:s}' cannot be fit as it is not declared in the model specification." diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 7a61edc06f..17eb46f0e1 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -221,3 +221,15 @@ def test_set_schema_path_context(monkeypatch): new_path = pathlib.Path('a/new/path') with pyhf.schema(new_path): assert pyhf.schema.path == new_path + + +def test_pdf_set_poi(backend): + model = pyhf.simplemodels.uncorrelated_background([5.0], [10.0], [2.5]) + assert model.config.poi_index == 0 + assert model.config.poi_name == 'mu' + model.config.set_poi('uncorr_bkguncrt') + assert model.config.poi_index == 1 + assert model.config.poi_name == 'uncorr_bkguncrt' + model.config.set_poi(None) + assert model.config.poi_index is None + assert model.config.poi_name is None From 6bbd9ff9b0b9c8e9a42e430fabd6b82afc686b69 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 6 Sep 2022 00:59:58 +0200 Subject: [PATCH 07/13] chore: [pre-commit.ci] pre-commit autoupdate (#1989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update pre-commit hooks: - github.com/psf/black: 22.6.0 → 22.8.0 - github.com/PyCQA/flake8: 5.0.2 → 5.0.4 - github.com/codespell-project/codespell: v2.1.0 → v2.2.1 * Apply typo fixes from codespell. --- .pre-commit-config.yaml | 8 ++++---- docs/faq.rst | 2 +- docs/release-notes/v0.6.0.rst | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 67e9854ef3..2d44a93509 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,7 @@ repos: - id: absolufy-imports - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 22.8.0 hooks: - id: black-jupyter @@ -46,7 +46,7 @@ repos: rev: v1.12.1 hooks: - id: blacken-docs - additional_dependencies: [black==22.6.0] + additional_dependencies: [black==22.8.0] - repo: https://github.com/asottile/yesqa rev: v1.4.0 @@ -54,7 +54,7 @@ repos: - id: yesqa - repo: https://github.com/PyCQA/flake8 - rev: 5.0.2 + rev: 5.0.4 hooks: - id: flake8 args: ["--count", "--statistics"] @@ -81,7 +81,7 @@ repos: additional_dependencies: [pyupgrade==2.37.3] - repo: https://github.com/codespell-project/codespell - rev: v2.1.0 + rev: v2.2.1 hooks: - id: codespell files: ^.*\.(py|md|rst)$ diff --git a/docs/faq.rst b/docs/faq.rst index 79a79d4f2c..9644a7c006 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -100,7 +100,7 @@ Given all these considerations, Python was chosen as the development language. How did ``pyhf`` get started? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -In 2017 Lukas Heinrich was discussing with colleauge Holger Schulz how it would be convienent +In 2017 Lukas Heinrich was discussing with colleauge Holger Schulz how it would be convenient to share and produce statistical results from LHC experiements if they were able to be created with tools that didn't require the large ``C++`` dependencies and tooling expertise as :math:`\HiFa{}`. diff --git a/docs/release-notes/v0.6.0.rst b/docs/release-notes/v0.6.0.rst index ef98802286..aa69ac388d 100644 --- a/docs/release-notes/v0.6.0.rst +++ b/docs/release-notes/v0.6.0.rst @@ -31,7 +31,7 @@ Important Notes * The documentation will now be versioned with releases on ReadTheDocs. Please use `pyhf.readthedocs.io`_ to access the documentation for the latest stable release of ``pyhf``. -* ``pyhf`` is transtioning away from Stack Overflow to `GitHub Discussions`_ for +* ``pyhf`` is transitioning away from Stack Overflow to `GitHub Discussions`_ for resolving user questions not covered in the documentation. Please check the `GitHub Discussions`_ page to search for discussions addressing your questions and to open up a new discussion if your question is not covered. From b12c9dd6493586710eae028ddaeb17ecf1f3eea4 Mon Sep 17 00:00:00 2001 From: Giordon Stark Date: Thu, 8 Sep 2022 00:35:41 +0100 Subject: [PATCH 08/13] fix: Add guards against shared shapesys paramsets (#1977) * Add checks to determine if modifiers should be shared across channels when the modifier is being built. This guards against accidental correlations across channels. e.g. shared shapesys paramsets. - Amends PR #1625 * Add tests for raised pyhf.exceptions.InvalidModel in the event that a shared shapesys paramset is introduced in the model spec. --- src/pyhf/modifiers/histosys.py | 3 +- src/pyhf/modifiers/lumi.py | 3 +- src/pyhf/modifiers/normfactor.py | 3 +- src/pyhf/modifiers/normsys.py | 3 +- src/pyhf/modifiers/shapefactor.py | 3 +- src/pyhf/modifiers/shapesys.py | 3 +- src/pyhf/modifiers/staterror.py | 3 +- src/pyhf/pdf.py | 36 +++++--- tests/test_custom_mods.py | 3 +- tests/test_pdf.py | 132 ++++++++++++++++++++++++++++++ 10 files changed, 172 insertions(+), 20 deletions(-) diff --git a/src/pyhf/modifiers/histosys.py b/src/pyhf/modifiers/histosys.py index 78b0f20074..c76170e36b 100644 --- a/src/pyhf/modifiers/histosys.py +++ b/src/pyhf/modifiers/histosys.py @@ -13,7 +13,6 @@ def required_parset(sample_data, modifier_data): return { 'paramset_type': 'constrained_by_normal', 'n_parameters': 1, - 'is_shared': True, 'is_scalar': True, 'inits': (0.0,), 'bounds': ((-5.0, 5.0),), @@ -25,6 +24,8 @@ def required_parset(sample_data, modifier_data): class histosys_builder: """Builder class for collecting histoys modifier data""" + is_shared = True + def __init__(self, config): self.builder_data = {} self.config = config diff --git a/src/pyhf/modifiers/lumi.py b/src/pyhf/modifiers/lumi.py index 27de329893..e050e53140 100644 --- a/src/pyhf/modifiers/lumi.py +++ b/src/pyhf/modifiers/lumi.py @@ -10,7 +10,6 @@ def required_parset(sample_data, modifier_data): return { 'paramset_type': 'constrained_by_normal', 'n_parameters': 1, - 'is_shared': True, 'is_scalar': True, 'inits': None, # lumi 'bounds': None, # (0, 10*lumi) @@ -23,6 +22,8 @@ def required_parset(sample_data, modifier_data): class lumi_builder: """Builder class for collecting lumi modiifier data""" + is_shared = True + def __init__(self, config): self.builder_data = {} self.config = config diff --git a/src/pyhf/modifiers/normfactor.py b/src/pyhf/modifiers/normfactor.py index c2781c6381..fb723664f0 100644 --- a/src/pyhf/modifiers/normfactor.py +++ b/src/pyhf/modifiers/normfactor.py @@ -10,7 +10,6 @@ def required_parset(sample_data, modifier_data): return { 'paramset_type': 'unconstrained', 'n_parameters': 1, - 'is_shared': True, 'is_scalar': True, 'inits': (1.0,), 'bounds': ((0, 10),), @@ -21,6 +20,8 @@ def required_parset(sample_data, modifier_data): class normfactor_builder: """Builder class for collecting normfactor modifier data""" + is_shared = True + def __init__(self, config): self.builder_data = {} self.config = config diff --git a/src/pyhf/modifiers/normsys.py b/src/pyhf/modifiers/normsys.py index 4cfef13142..5f07a7df5b 100644 --- a/src/pyhf/modifiers/normsys.py +++ b/src/pyhf/modifiers/normsys.py @@ -11,7 +11,6 @@ def required_parset(sample_data, modifier_data): return { 'paramset_type': 'constrained_by_normal', 'n_parameters': 1, - 'is_shared': True, 'is_scalar': True, 'inits': (0.0,), 'bounds': ((-5.0, 5.0),), @@ -23,6 +22,8 @@ def required_parset(sample_data, modifier_data): class normsys_builder: """Builder class for collecting normsys modifier data""" + is_shared = True + def __init__(self, config): self.builder_data = {} self.config = config diff --git a/src/pyhf/modifiers/shapefactor.py b/src/pyhf/modifiers/shapefactor.py index 6c65c2e8c9..64b0db2a48 100644 --- a/src/pyhf/modifiers/shapefactor.py +++ b/src/pyhf/modifiers/shapefactor.py @@ -12,7 +12,6 @@ def required_parset(sample_data, modifier_data): return { 'paramset_type': 'unconstrained', 'n_parameters': len(sample_data), - 'is_shared': True, 'is_scalar': False, 'inits': (1.0,) * len(sample_data), 'bounds': ((0.0, 10.0),) * len(sample_data), @@ -23,6 +22,8 @@ def required_parset(sample_data, modifier_data): class shapefactor_builder: """Builder class for collecting shapefactor modifier data""" + is_shared = True + def __init__(self, config): self.builder_data = {} self.config = config diff --git a/src/pyhf/modifiers/shapesys.py b/src/pyhf/modifiers/shapesys.py index 3d4d52e2b6..39740e4cf8 100644 --- a/src/pyhf/modifiers/shapesys.py +++ b/src/pyhf/modifiers/shapesys.py @@ -25,7 +25,6 @@ def required_parset(sample_data, modifier_data): return { "paramset_type": "constrained_by_poisson", "n_parameters": n_parameters, - "is_shared": False, "is_scalar": False, "inits": (1.0,) * n_parameters, "bounds": ((1e-10, 10.0),) * n_parameters, @@ -38,6 +37,8 @@ def required_parset(sample_data, modifier_data): class shapesys_builder: """Builder class for collecting shapesys modifier data""" + is_shared = False + def __init__(self, config): self.builder_data = {} self.config = config diff --git a/src/pyhf/modifiers/staterror.py b/src/pyhf/modifiers/staterror.py index 1e7573d729..9033d22b36 100644 --- a/src/pyhf/modifiers/staterror.py +++ b/src/pyhf/modifiers/staterror.py @@ -15,7 +15,6 @@ def required_parset(sigmas, fixed: List[bool]): return { 'paramset_type': 'constrained_by_normal', 'n_parameters': n_parameters, - 'is_shared': True, 'is_scalar': False, 'inits': (1.0,) * n_parameters, 'bounds': ((1e-10, 10.0),) * n_parameters, @@ -28,6 +27,8 @@ def required_parset(sigmas, fixed: List[bool]): class staterror_builder: """Builder class for collecting staterror modifier data""" + is_shared = True + def __init__(self, config): self.builder_data = {} self.config = config diff --git a/src/pyhf/pdf.py b/src/pyhf/pdf.py index 3710f5542e..99923afeb1 100644 --- a/src/pyhf/pdf.py +++ b/src/pyhf/pdf.py @@ -107,8 +107,16 @@ def _nominal_and_modifiers_from_spec(modifier_set, config, spec, batch_size): # We don't actually set up the modifier data here for no-ops, but we do # set up the entire structure - # helper maps channel-name/sample-name to pairs of channel-sample structs + # 1. setup nominal & modifier builders + nominal = _nominal_builder(config) + + modifiers_builders = { + key: builder(config) for key, (builder, _) in modifier_set.items() + } + + # 2. make a helper that maps channel-name/sample-name to pairs of channel-sample structs helper = {} + _keys_seen = set() for c in spec['channels']: for s in c['samples']: moddict = {} @@ -117,17 +125,21 @@ def _nominal_and_modifiers_from_spec(modifier_set, config, spec, batch_size): raise exceptions.InvalidModifier( f'{x["type"]} not among {list(modifier_set.keys())}' ) - moddict[f"{x['type']}/{x['name']}"] = x - helper.setdefault(c['name'], {})[s['name']] = (s, moddict) - - # 1. setup nominal & modifier builders - nominal = _nominal_builder(config) + key = f"{x['type']}/{x['name']}" + # check if the modifier to be built is allowed to be shared + if not modifiers_builders[x['type']].is_shared and ( + key in _keys_seen or key in moddict + ): + raise exceptions.InvalidModel( + f"Trying to add paramset {key} on {s['name']} sample in {c['name']} channel but other paramsets exist with the same name." + ) - modifiers_builders = {} - for k, (builder, applier) in modifier_set.items(): - modifiers_builders[k] = builder(config) + moddict[key] = x + helper.setdefault(c['name'], {})[s['name']] = (s, moddict) + # add in all keys seen + _keys_seen.update(moddict) - # 2. walk spec and call builders + # 3. walk spec and call builders for c in config.channels: for s in config.samples: helper_data = helper.get(c, {}).get(s) @@ -141,13 +153,13 @@ def _nominal_and_modifiers_from_spec(modifier_set, config, spec, batch_size): thismod = defined_mods.get(key) if defined_mods else None modifiers_builders[mtype].append(key, c, s, thismod, defined_samp) - # 3. finalize nominal & modifier builders + # 4. finalize nominal & modifier builders nominal_rates = nominal.finalize() finalizd_builder_data = {} for k, (builder, applier) in modifier_set.items(): finalizd_builder_data[k] = modifiers_builders[k].finalize() - # 4. collect parameters from spec and from user. + # 5. collect parameters from spec and from user. # At this point we know all constraints and so forth _required_paramsets = {} for v in list(modifiers_builders.values()): diff --git a/tests/test_custom_mods.py b/tests/test_custom_mods.py index f0f98233e3..ca83b0f002 100644 --- a/tests/test_custom_mods.py +++ b/tests/test_custom_mods.py @@ -4,6 +4,8 @@ class custom_builder: + is_shared = True + def __init__(self, pdfconfig): self.config = pdfconfig self.required_parsets = { @@ -12,7 +14,6 @@ def __init__(self, pdfconfig): 'paramset_type': 'unconstrained', 'n_parameters': 1, 'is_constrained': False, - 'is_shared': True, 'inits': (1.0,), 'bounds': ((-5, 5),), 'fixed': False, diff --git a/tests/test_pdf.py b/tests/test_pdf.py index 9e624a8268..2538b708a2 100644 --- a/tests/test_pdf.py +++ b/tests/test_pdf.py @@ -1182,3 +1182,135 @@ def test_pdf_clipping(backend): # We should be able to converge when clipping is enabled pyhf.infer.mle.fit(data, model_clip_sample) pyhf.infer.mle.fit(data, model_clip_bin) + + +def test_is_shared_paramset_shapesys_diff_sample_diff_channel(): + spec = { + "channels": [ + { + "name": "SR", + "samples": [ + { + "data": [24.0, 25.0], + "modifiers": [ + {"data": [0.1, 0.2], "name": "par", "type": "shapesys"}, + {"data": None, "name": "mu", "type": "normfactor"}, + ], + "name": "Signal", + } + ], + }, + { + "name": "CR", + "samples": [ + { + "data": [10.0], + "modifiers": [ + {"data": [0.1], "name": "par", "type": "shapesys"} + ], + "name": "Background", + } + ], + }, + ], + "measurements": [ + {"config": {"parameters": [], "poi": "mu"}, "name": "minimal_example"} + ], + "observations": [ + {"data": [24.0, 24.0], "name": "SR"}, + {"data": [10.0], "name": "CR"}, + ], + "version": "1.0.0", + } + + with pytest.raises(pyhf.exceptions.InvalidModel): + pyhf.Workspace(spec).model() + + +def test_is_shared_paramset_shapesys_diff_sample_same_channel(): + spec = { + "channels": [ + { + "name": "SR", + "samples": [ + { + "data": [50], + "modifiers": [ + { + "data": [9], + "name": "abc", + "type": "shapesys", + }, + { + "data": None, + "name": "Signal strength", + "type": "normfactor", + }, + ], + "name": "Signal", + }, + { + "data": [150], + "modifiers": [ + { + "data": [7], + "name": "abc", + "type": "shapesys", + } + ], + "name": "Background", + }, + ], + } + ], + "measurements": [{"config": {"parameters": [], "poi": ""}, "name": "meas"}], + "observations": [{"data": [160], "name": "SR"}], + "version": "1.0.0", + } + + with pytest.raises(pyhf.exceptions.InvalidModel): + pyhf.Workspace(spec).model() + + +def test_is_shared_paramset_shapesys_same_sample_same_channel(): + spec = { + "channels": [ + { + "name": "SR", + "samples": [ + { + "data": [24.0, 25.0], + "modifiers": [ + {"data": [0.1, 0.2], "name": "par2", "type": "shapesys"}, + {"data": None, "name": "mu", "type": "normfactor"}, + ], + "name": "Signal", + } + ], + }, + { + "name": "CR", + "samples": [ + { + "data": [10.0], + "modifiers": [ + {"data": [0.1], "name": "par", "type": "shapesys"}, + {"data": [0.5], "name": "par", "type": "shapesys"}, + ], + "name": "Background", + } + ], + }, + ], + "measurements": [ + {"config": {"parameters": [], "poi": "mu"}, "name": "minimal_example"} + ], + "observations": [ + {"data": [24.0, 24.0], "name": "SR"}, + {"data": [10.0], "name": "CR"}, + ], + "version": "1.0.0", + } + + with pytest.raises(pyhf.exceptions.InvalidModel): + pyhf.Workspace(spec).model() From cbd68c829748fff11bf44adac2ad9e9b15068af2 Mon Sep 17 00:00:00 2001 From: Giordon Stark Date: Thu, 8 Sep 2022 00:58:55 +0100 Subject: [PATCH 09/13] feat: Add type hints for tensor manager (#1963) * Add type hints to the tensor manager. * Add type hints to some of the events management code for the decorators used. * Expand the TensorBackend protocol and fix up the NumPy backend type hints to align properly. - Amends PR #1940. --- pyproject.toml | 1 - src/pyhf/events.py | 37 ++++---- src/pyhf/tensor/manager.py | 140 ++++++++++++++++++------------- src/pyhf/tensor/numpy_backend.py | 8 +- src/pyhf/typing.py | 13 ++- 5 files changed, 115 insertions(+), 84 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 035defaab5..3fdaf65392 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,7 +141,6 @@ module = [ 'pyhf.simplemodels', 'pyhf.probability', 'pyhf.tensor.common.*', - 'pyhf.tensor.manager.*', 'pyhf.tensor', 'pyhf.tensor.jax_backend.*', 'pyhf.tensor.tensorflow_backend.*', diff --git a/src/pyhf/events.py b/src/pyhf/events.py index 06b5cced34..f28d0a7563 100644 --- a/src/pyhf/events.py +++ b/src/pyhf/events.py @@ -1,18 +1,17 @@ +from __future__ import annotations + import weakref from functools import wraps +from typing import Callable, TypeVar, cast + +# See https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators +TCallable = TypeVar("TCallable", bound=Callable) + __events = {} __disabled_events = set() -__all__ = [ - "Callables", - "disable", - "enable", - "noop", - "register", - "subscribe", - "trigger", -] +__all__ = ["Callables", "disable", "enable", "noop", "register", "subscribe", "trigger"] def __dir__(): @@ -85,7 +84,7 @@ def __repr__(self): return f"Callables({self.callbacks})" -def subscribe(event): +def subscribe(event: str): """ Subscribe a function or object method as a callback to an event. @@ -112,14 +111,14 @@ def subscribe(event): global __events - def __decorator(func): + def __decorator(func: TCallable) -> TCallable: __events.setdefault(event, Callables()).append(func) return func - return __decorator + return cast(TCallable, __decorator) -def register(event): +def register(event: str) -> Callable[[TCallable], TCallable]: """ Register a function or object method to trigger an event. This creates two events: ``{event_name}::before`` and ``{event_name}::after``. @@ -155,9 +154,9 @@ def register(event): """ - def _register(func): + def _register(func: TCallable) -> TCallable: @wraps(func) - def register_wrapper(*args, **kwargs): + def register_wrapper(*args, **kwargs): # type: ignore trigger(f"{event:s}::before")() result = func(*args, **kwargs) trigger(f"{event:s}::after")() @@ -165,10 +164,10 @@ def register_wrapper(*args, **kwargs): return register_wrapper - return _register + return cast(TCallable, _register) -def trigger(event): +def trigger(event: str) -> Callables: """ Trigger an event if not disabled. """ @@ -177,7 +176,7 @@ def trigger(event): return noop if is_noop else __events.get(event) -def disable(event): +def disable(event: str): """ Disable an event from firing. """ @@ -185,7 +184,7 @@ def disable(event): __disabled_events.add(event) -def enable(event): +def enable(event: str): """ Enable an event to be fired if disabled. """ diff --git a/src/pyhf/tensor/manager.py b/src/pyhf/tensor/manager.py index 7eedd26a36..c29a9bd45b 100644 --- a/src/pyhf/tensor/manager.py +++ b/src/pyhf/tensor/manager.py @@ -1,18 +1,30 @@ +from __future__ import annotations + import sys -from pyhf.tensor import BackendRetriever -from pyhf import exceptions -from pyhf import events +from pyhf import events, exceptions from pyhf.optimize import OptimizerRetriever +from pyhf.tensor import BackendRetriever +from pyhf.typing import Optimizer, Protocol, TensorBackend, TypedDict + + +class State(TypedDict): + default: tuple[TensorBackend, Optimizer] + current: tuple[TensorBackend, Optimizer] + + +class HasState(Protocol): + state: State + -this = sys.modules[__name__] +this: HasState = sys.modules[__name__] this.state = { - 'default': (None, None), - 'current': (None, None), + 'default': (None, None), # type: ignore[typeddict-item] + 'current': (None, None), # type: ignore[typeddict-item] } -def get_backend(default=False): +def get_backend(default: bool = False) -> tuple[TensorBackend, Optimizer]: """ Get the current backend and the associated optimizer @@ -31,18 +43,23 @@ def get_backend(default=False): Returns: backend, optimizer """ - return this.state['default' if default else 'current'] + return this.state["default"] if default else this.state["current"] -this.state['default'] = ( - BackendRetriever.numpy_backend(), - OptimizerRetriever.scipy_optimizer(), -) +_default_backend: TensorBackend = BackendRetriever.numpy_backend() +_default_optimizer: Optimizer = OptimizerRetriever.scipy_optimizer() # type: ignore[no-untyped-call] + +this.state['default'] = (_default_backend, _default_optimizer) this.state['current'] = this.state['default'] @events.register('change_backend') -def set_backend(backend, custom_optimizer=None, precision=None, default=False): +def set_backend( + backend: str | bytes | TensorBackend, + custom_optimizer: str | bytes | Optimizer | None = None, + precision: str | bytes | None = None, + default: bool = False, +) -> None: """ Set the backend and the associated optimizer @@ -65,9 +82,9 @@ def set_backend(backend, custom_optimizer=None, precision=None, default=False): '64b' Args: - backend (:obj:`str` or `pyhf.tensor` backend): One of the supported pyhf backends: NumPy, TensorFlow, PyTorch, and JAX - custom_optimizer (`pyhf.optimize` optimizer): Optional custom optimizer defined by the user - precision (:obj:`str`): Floating point precision to use in the backend: ``64b`` or ``32b``. Default is backend dependent. + backend (:obj:`str` or :obj:`bytes` or `pyhf.tensor` backend): One of the supported pyhf backends: NumPy, TensorFlow, PyTorch, and JAX + custom_optimizer (:obj:`str` or :obj:`bytes` or `pyhf.optimize` optimizer or :obj:`None`): Optional custom optimizer defined by the user + precision (:obj:`str` or :obj:`bytes` or :obj:`None`): Floating point precision to use in the backend: ``64b`` or ``32b``. Default is backend dependent. default (:obj:`bool`): Set the backend as the default backend additionally Returns: @@ -76,51 +93,58 @@ def set_backend(backend, custom_optimizer=None, precision=None, default=False): _supported_precisions = ["32b", "64b"] backend_kwargs = {} - if isinstance(precision, (str, bytes)): + if precision: if isinstance(precision, bytes): precision = precision.decode("utf-8") + precision = precision.lower() + if precision not in _supported_precisions: + raise exceptions.Unsupported( + f"The backend precision provided is not supported: {precision:s}. Select from one of the supported precisions: {', '.join([str(v) for v in _supported_precisions])}" + ) - if isinstance(backend, (str, bytes)): - if isinstance(backend, bytes): - backend = backend.decode("utf-8") - backend = backend.lower() + backend_kwargs["precision"] = precision + + if isinstance(backend, bytes): + backend = backend.decode("utf-8") - if precision is not None: - backend_kwargs["precision"] = precision + if isinstance(backend, str): + backend = backend.lower() try: - backend = getattr(BackendRetriever, f"{backend:s}_backend")( - **backend_kwargs - ) + new_backend: TensorBackend = getattr( + BackendRetriever, f"{backend:s}_backend" + )(**backend_kwargs) except TypeError: raise exceptions.InvalidBackend( f"The backend provided is not supported: {backend:s}. Select from one of the supported backends: numpy, tensorflow, pytorch" ) + else: + new_backend = backend - _name_supported = getattr(BackendRetriever, f"{backend.name:s}_backend") + _name_supported = getattr(BackendRetriever, f"{new_backend.name:s}_backend") if _name_supported: - if not isinstance(backend, _name_supported): + if not isinstance(new_backend, _name_supported): raise AttributeError( - f"'{backend.name:s}' is not a valid name attribute for backend type {type(backend)}\n Custom backends must have names unique from supported backends" - ) - if backend.precision not in _supported_precisions: - raise exceptions.Unsupported( - f"The backend precision provided is not supported: {backend.precision:s}. Select from one of the supported precisions: {', '.join([str(v) for v in _supported_precisions])}" + f"'{new_backend.name:s}' is not a valid name attribute for backend type {type(new_backend)}\n Custom backends must have names unique from supported backends" ) + # If "precision" arg passed, it should always win # If no "precision" arg, defer to tensor backend object API if set there - if precision is not None: - if backend.precision != precision: - backend_kwargs["precision"] = precision - backend = getattr(BackendRetriever, f"{backend.name:s}_backend")( - **backend_kwargs - ) + if precision is not None and new_backend.precision != precision: + new_backend = getattr(BackendRetriever, f"{new_backend.name:s}_backend")( + **backend_kwargs + ) + + if custom_optimizer is None: + new_optimizer: Optimizer = OptimizerRetriever.scipy_optimizer() # type: ignore[no-untyped-call] + else: + if isinstance(custom_optimizer, bytes): + custom_optimizer = custom_optimizer.decode("utf-8") + + if isinstance(custom_optimizer, str): + custom_optimizer = custom_optimizer.lower() - if custom_optimizer: - if isinstance(custom_optimizer, (str, bytes)): - if isinstance(custom_optimizer, bytes): - custom_optimizer = custom_optimizer.decode("utf-8") try: new_optimizer = getattr( OptimizerRetriever, f"{custom_optimizer.lower()}_optimizer" @@ -130,31 +154,29 @@ def set_backend(backend, custom_optimizer=None, precision=None, default=False): f"The optimizer provided is not supported: {custom_optimizer}. Select from one of the supported optimizers: scipy, minuit" ) else: - _name_supported = getattr( - OptimizerRetriever, f"{custom_optimizer.name:s}_optimizer" - ) - if _name_supported: - if not isinstance(custom_optimizer, _name_supported): - raise AttributeError( - f"'{custom_optimizer.name}' is not a valid name attribute for optimizer type {type(custom_optimizer)}\n Custom optimizers must have names unique from supported optimizers" - ) new_optimizer = custom_optimizer - else: - new_optimizer = OptimizerRetriever.scipy_optimizer() + _name_supported = getattr( + OptimizerRetriever, f"{new_optimizer.name:s}_optimizer" + ) + if _name_supported: + if not isinstance(new_optimizer, _name_supported): + raise AttributeError( + f"'{new_optimizer.name}' is not a valid name attribute for optimizer type {type(new_optimizer)}\n Custom optimizers must have names unique from supported optimizers" + ) # need to determine if the tensorlib changed or the optimizer changed for events tensorlib_changed = bool( - (backend.name != this.state['current'][0].name) - | (backend.precision != this.state['current'][0].precision) + (new_backend.name != this.state['current'][0].name) + | (new_backend.precision != this.state['current'][0].precision) ) optimizer_changed = bool(this.state['current'][1] != new_optimizer) # set new backend - this.state['current'] = (backend, new_optimizer) + this.state['current'] = (new_backend, new_optimizer) if default: default_tensorlib_changed = bool( - (backend.name != this.state['default'][0].name) - | (backend.precision != this.state['default'][0].precision) + (new_backend.name != this.state['default'][0].name) + | (new_backend.precision != this.state['default'][0].precision) ) default_optimizer_changed = bool(this.state['default'][1] != new_optimizer) # trigger events @@ -171,4 +193,4 @@ def set_backend(backend, custom_optimizer=None, precision=None, default=False): if optimizer_changed: events.trigger("optimizer_changed")() # set up any other globals for backend - backend._setup() + new_backend._setup() diff --git a/src/pyhf/tensor/numpy_backend.py b/src/pyhf/tensor/numpy_backend.py index ea00a3b78f..102703b323 100644 --- a/src/pyhf/tensor/numpy_backend.py +++ b/src/pyhf/tensor/numpy_backend.py @@ -49,9 +49,9 @@ class numpy_backend(Generic[T]): __slots__ = ['name', 'precision', 'dtypemap', 'default_do_grad'] - def __init__(self, **kwargs: dict[str, str]): - self.name = 'numpy' - self.precision = kwargs.get('precision', '64b') + def __init__(self, **kwargs: str): + self.name: str = 'numpy' + self.precision: str = kwargs.get('precision', '64b') self.dtypemap: Mapping[ FloatIntOrBool, DTypeLike, # Type[np.floating[T]] | Type[np.integer[T]] | Type[np.bool_], @@ -60,7 +60,7 @@ def __init__(self, **kwargs: dict[str, str]): 'int': np.int64 if self.precision == '64b' else np.int32, 'bool': np.bool_, } - self.default_do_grad = False + self.default_do_grad: bool = False def _setup(self) -> None: """ diff --git a/src/pyhf/typing.py b/src/pyhf/typing.py index ffa10d8d58..ee874d5b17 100644 --- a/src/pyhf/typing.py +++ b/src/pyhf/typing.py @@ -26,6 +26,8 @@ "Observation", "Workspace", "Literal", + "TypedDict", + "Protocol", ) @@ -140,7 +142,16 @@ class Workspace(TypedDict): class TensorBackend(Protocol): - ... + name: str + precision: str + default_do_grad: bool + + def _setup(self) -> None: + ... + + +class Optimizer(Protocol): + name: str class PDF(Protocol): From d48f7f20852cd08da9aa3ccbdf002ec13d5f6d93 Mon Sep 17 00:00:00 2001 From: Giordon Stark Date: Fri, 9 Sep 2022 02:30:09 +0100 Subject: [PATCH 10/13] fix: Form data through loop over channels for multichannel coupled histo notebook (#1974) * Use pdf.config.channels API instead of the channel names from the spec. * Use pyhf.contrib.viz.brazil API for visualization. * Deprecate use of pylab in notebook. Co-authored-by: Matthew Feickert --- .../multichannel-coupled-histo.ipynb | 131 ++++++++---------- 1 file changed, 54 insertions(+), 77 deletions(-) diff --git a/docs/examples/notebooks/multichannel-coupled-histo.ipynb b/docs/examples/notebooks/multichannel-coupled-histo.ipynb index 8614c4f739..60665951cf 100644 --- a/docs/examples/notebooks/multichannel-coupled-histo.ipynb +++ b/docs/examples/notebooks/multichannel-coupled-histo.ipynb @@ -11,37 +11,24 @@ "cell_type": "code", "execution_count": 1, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Populating the interactive namespace from numpy and matplotlib\n" - ] - } - ], - "source": [ - "%pylab inline" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, "outputs": [], "source": [ - "import logging\n", "import json\n", + "import logging\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", "\n", "import pyhf\n", "from pyhf import Model\n", + "from pyhf.contrib.viz import brazil\n", "\n", "logging.basicConfig(level=logging.INFO)" ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -123,15 +110,15 @@ " }\n", " pdf = Model(spec)\n", " data = []\n", - " for c in pdf.spec[\"channels\"]:\n", - " data += sourcedata[c[\"name\"]][\"bindata\"][\"data\"]\n", + " for channel in pdf.config.channels:\n", + " data += sourcedata[channel][\"bindata\"][\"data\"]\n", " data = data + pdf.config.auxdata\n", " return data, pdf" ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "metadata": { "tags": [ "parameters" @@ -139,41 +126,51 @@ }, "outputs": [], "source": [ - "validation_datadir = \"../../validation/data\"" + "validation_datadir = \"../../../validation/data\"" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:pyhf.pdf:Validating spec against schema: model.json\n", + "INFO:pyhf.pdf:adding modifier coupled_histosys (1 new nuisance parameters)\n", + "INFO:pyhf.pdf:adding modifier mu (1 new nuisance parameters)\n" + ] + }, { "name": "stdout", "output_type": "stream", "text": [ - "[170.0, 220.0, 110.0, 105.0, 0.0]\n", - "parameters post unconstrained fit: [1.53170588e-12 2.21657891e+00]\n", - "parameters post constrained fit: [0. 2.21655133]\n" + "data: [110.0, 105.0, 170.0, 220.0, 0.0]\n", + "parameters post unconstrained fit: [-0.30257894 0.63607078]\n", + "parameters post constrained fit: [0.29087602 0. ]\n" ] }, { "data": { "text/plain": [ - "array([116.08275666, 133.24826999, 183.24826999, 98.08967672,\n", - " 2.21655133])" + "array([106.80676913, 104.01075137, 154.36314037, 213.86871072,\n", + " 0.29087602])" ] }, - "execution_count": 5, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "source = json.load(open(validation_datadir + \"/2bin_2channel_coupledhisto.json\"))\n", + "with open(validation_datadir + \"/2bin_2channel_coupledhisto.json\") as spec:\n", + " source = json.load(spec)\n", "\n", "data, pdf = prep_data(source[\"channels\"])\n", "\n", - "print(data)\n", + "print(f\"data: {data}\")\n", "\n", "init_pars = pdf.config.suggested_init()\n", "par_bounds = pdf.config.suggested_bounds()\n", @@ -189,21 +186,7 @@ }, { "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "def plot_results(test_mus, cls_obs, cls_exp, poi_tests, test_size=0.05):\n", - " plt.plot(poi_tests, cls_obs, c=\"k\")\n", - " for i, c in zip(range(5), [\"grey\", \"grey\", \"grey\", \"grey\", \"grey\"]):\n", - " plt.plot(poi_tests, cls_exp[i], c=c)\n", - " plt.plot(poi_tests, [test_size] * len(test_mus), c=\"r\")\n", - " plt.ylim(0, 1)" - ] - }, - { - "cell_type": "code", - "execution_count": 7, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ @@ -223,54 +206,46 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 6, "metadata": {}, "outputs": [], "source": [ - "poi_tests = np.linspace(0, 5, 61)\n", - "tests = [\n", + "test_pois = np.linspace(0, 5, 61)\n", + "results = [\n", " pyhf.infer.hypotest(\n", - " poi_test, data, pdf, init_pars, par_bounds, return_expected_set=True\n", + " test_poi, data, pdf, init_pars, par_bounds, return_expected_set=True\n", " )\n", - " for poi_test in poi_tests\n", + " for test_poi in test_pois\n", "]\n", - "cls_obs = np.array([test[0] for test in tests]).flatten()\n", - "cls_exp = [np.array([test[1][i] for test in tests]).flatten() for i in range(5)]" + "cls_obs = np.array([result[0] for result in results]).flatten()\n", + "cls_exp = [np.array([result[1][i] for result in results]).flatten() for i in range(5)]" ] }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 7, "metadata": {}, "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n" - ] - }, { "data": { "text/plain": [ - "{'exp': [0.3654675198094938,\n", - " 0.4882076670368835,\n", - " 0.683262284467055,\n", - " 0.9650584704888153,\n", - " 1.3142329292131938],\n", - " 'obs': 0.3932476110375604}" + "{'exp': [0.3379557904310767,\n", + " 0.6355433028074602,\n", + " 0.980748685945719,\n", + " 1.4196566943996904,\n", + " 1.9446272307776151],\n", + " 'obs': 1.5452056912523955}" ] }, - "execution_count": 9, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXQAAAD8CAYAAABn919SAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4xLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy8QZhcZAAAgAElEQVR4nO3deWzc553n+ff3VxdvFovFQyxSLFI8ZJ1OTEvW4WTank6sOG1lMZudOEhn0zDG3chk0Y0e7CKNbaRns2hsbw8Q9C46fTgHBjmdOMl2Kz7GiR05EymOLVmWZd2iJEoiKVEUz+JZZNWzfxR/ZYpikXWRVBW/L0AQyfrxqYcx8uGj73OJMQallFK5z1rrDiillMoODXSllMoTGuhKKZUnNNCVUipPaKArpVSe0EBXSqk8sWygi8i3ReS2iJxO8LqIyP8rIp0ickpEPpz9biqllFpOMiP0/wo8scTrB4DWuT/PAv+YebeUUkqlatlAN8b8d2BwiUcOAt8xMb8DvCKyIVsdVEoplRxnFtoIADfmfd4997WbCx8UkWeJjeIpLi5+aPPmzSm/WW9vLwBut5vKykpEJI0uK6VUbnrnnXfuGGOqFnstG4GeNGPMc8BzAB0dHeb48eMpt/HXf/3XzM7OAlBRUcGnP/1pNmzQfxAopdYHEbmW6LVsrHLpARrmfV4/97UVUVRUBEBBQQGRSIRvfetbHDt2DD2TRim13mUj0A8Bn59b7fIIMGKMuafcki1erxeAqakpnnnmGZqamnj55Zf52c9+RiQSWam3VUqp+14yyxZ/CLwJtItIt4g8IyJ/IiJ/MvfIy8AVoBP4BvDFFestUFlZGf/49u3bfPazn+Wxxx7j9OnTHDp0SEfqSql1a9kaujHm6WVeN8B/zFqPllFV9cFcwKVLl2hpaeHRRx/FGMPhw4cpLy/nscceW63uKKXUfSPndorOnwC9dOlSfET+6KOP8qEPfYjf/OY3vPvuu2vVPaWUWjM5F+h2ycUYw9DQEIODsSXyIsKTTz7Jpk2b+PnPf05nZ+dadlMppVZdzgW62+2+6/OLFy/GP3Y4HHz605+murqaF154gVu3bq1295RSas3kbKCLCD6f756RuMfj4bOf/SwFBQX84Ac/YHx8fC26qZRSqy7nAl1E4nXz2tpaurq6mJ6evuuZsrIynn76acbHx3nttdfWoptKKbXqci7QgXigV1RUEI1GuXr16j3P1NbWsnfvXk6ePMm1awk3VimlVN7IyUC3ORwOPB7PXXX0+T7ykY9QXl7OSy+9pJuOlFJ5L2cD3RhDKBRi06ZNdHZ2LrqhyOVyceDAAfr7+3nrrbfWoJdKKbV6cjLQLctCRBgcHKSlpYVQKERfX9+iz7a3t9PW1sYbb7zByMjIKvdUKaVWT04GusPhAGBwcJDW1lYgtskokSeeeAJjDK+++uqq9E8ppdZCTga6vXRxfHyc4uJiNmzYsGSgV1RU8JGPfIRz584t+ZxSSuWynAz0goICAKLRKBMTE7S2ttLd3c3k5GTC79m7dy9+v59XXnmFmZmZ1eqqUkqtmpwMdPtMdICRkRGam5sxxiy5PNHhcHDgwAGGhob0rBelVF7KyUAvKyuLfzw8PEwgEMDhcCy73ry5uZn6+nrefPNNotHoSndTKaVWVU4GekVFRfzjkZERnE4n9fX1SW0g2rt3L8PDw5w/f34lu6iUUqsupwPdGBNfitjY2MitW7fuOQZgofb2dioqKvjtb3+rl2EopfJKTga6PSm6MNCNMVy/fn3J77Usiz179tDT07Pss0oplUtyMtA9Hg8QW+ViB3pDQwOWZSVVdnnwwQcpLCzkzTffXNF+KqXUasrJQLfXoc8fobtcLurq6pIKdJfLxcMPP8yFCxe4c+fOivZVKaVWS04Guj1CN8YwMTERX1fe2NhIb29vUuvMd+3ahcPh0FG6Uipv5GSgL7y1aH4dPRqNcuPGjWXbKC4uZufOnbz33nuMjY2tSD+VUmo15WSg2yN0y4p13w70jRs3IiJJn3++Z88eIpEIx44dW5mOKqXUKsrJQLdH6E6nE4htLoJY0NfW1iYd6H6/n/b2do4dO0Y4HF6Zziql1CrJyUC3gxxiV9LNPxa3sbGR7u5uZmdnk2prz549TE5Ocvr06az3UymlVlNOBrqIICJAbE36wkCPRCL09PQk1dbGjRvx+XycOnVqRfqqlFKrJScDHT6onzudznsCHUi67CIi7Nixg2vXrukFGEqpnJazge5yueIfzw/iwsJCqqurU7oYeseOHQC8//772eugUkqtspwN9Pm7RUdHR+86PbGxsZEbN24kfTF0RUUFDQ0NnDp1Ss93UUrlrJwNdPtM9JmZGaLR6F1ryRsbG5mZmeHmzZtJt7d9+3b6+/sT3k2qlFL3u5wN9JKSEowx8eWGmdTRAbZu3YplWTo5qpTKWTkb6EVFRRhj4tv85wd6SUkJlZWVKQV6UVERra2tnD59Wi+/UErlpJwNdHtzkX3+ub25yNbY2Mj169dTCuft27cTCoXo6urKWj+VUmq15HSgiwjRaPSetegQC/Tp6Wlu376ddJttbW14PB5d7aKUykk5H+gAXq/3nkBvaGgAoLu7O+k2XS4XDzzwAGfPnk3qxEallLqf5Gyg28sWHQ4H5eXl9wS61+uluLg4pUCH2Jr0cDjMhQsXstZXpZRaDUkFuog8ISIXRKRTRL68yOsbReSwiLwrIqdE5BPZ7+rd7Bq6y+VaNNBFhEAgkHKgB4NBysrKtOyilMo5ywa6iDiArwMHgC3A0yKyZcFjfwn82BjzIeAzwD9ku6ML2SN0t9tNaWkp09PTTE1N3fVMfX09AwMDTE5OJt2uiLBt2zY6OzsZHx/Pap+VUmolJTNC3wV0GmOuGGPCwPPAwQXPGKBs7uNyoDd7XVzc/Esu7GMAFq50qa+vB0j6oC7bjh07iEajnD17NsNeKqXU6kkm0APA/CuAuue+Nt9/Bj4nIt3Ay8D/slhDIvKsiBwXkeP9/f1pdPcD9ggdiE9gLgz0urq6WIdTLLtUV1fj8/m4ePFiRn1USqnVlK1J0aeB/2qMqQc+AXxXRO5p2xjznDGmwxjTUVVVldEbzh+h26WWhYHu8Xiorq5OeYQuIrS1tXH16lW9+EIplTOSCfQeoGHe5/VzX5vvGeDHAMaYN4ECwJ+NDiYyf4Q+MjKC2+1maGjonufsidFUD91qa2sjEolw+fLljPuqlFKrIZlAPwa0ikiTiLiJTXoeWvDMdeBxABF5gFigZ1ZTWcb8EfrIyAher/eeETrE6uhTU1MMDg6m1P7GjRvxeDxadlFK5YxlA90YMwt8CXgVOEdsNcsZEfmqiDw199h/Av6DiLwH/BD4glnhc2jnj9BDoRAVFRUJAx1Sr6M7HA5aW1u5dOmSHqmrlMoJzuUfAWPMy8QmO+d/7SvzPj4L7Mtu15bmcDiwLItoNMr4+Djl5eVcvXoVY0x8BylAVVUVHo+H7u5udu7cmdJ7tLW1cfr0aXp6euK/GJRS6n6VsztFITZKn52dZXJykoqKCsLh8D1rztPdYATQ0tKCiOiuUaVUTsj5QI9Go4TDYbxeL0DCidG+vr6UV6wUFhayceNGraMrpXJCTge6PTEaiUTigZ6ojm6MSekGI1tbWxu3b99etF2llLqf5HSg2xOjxhgqKiqAxQM9EIjtg0qn7NLe3g6go3Sl1H0vpwPdPkJXRPB4PBQWFi5acikuLqaioiLlDUYAlZWVVFZWaqArpe57OR3oHo8Hy7JwOmOLdRY7F91WX1/PjRs30lqC2NbWRldXV/x2JKWUuh/ldKC73W4sy8Lj8WCMwev1LjpCh1jZZWxsjNHR0ZTfp729XXeNKqXuezkf6CKCZVlMTEzEd4suNgpPd4MRxG4/Kigo0LKLUuq+ltOBPn+3aF9fHxUVFUQiEcbGxu55tra2FofDkVYd3bKs+K7RVC6dVkqp1ZTTgT7/PJfbt28vuXTR4XBQV1eX1ggdYnX0iYmJtL9fKaVWWk4H+vwR+sDAwJKbiyBWR7958yaRSCTl92ppacGyLC5dupReZ5VSaoXldKDPH6EPDQ0tOUKHWB19dnaWvr6+lN+roKCAQCDAlStX0uusUkqtsJwO9Pkj9NHRUVwuF8XFxQkD3d5glE4dHaC5uZne3t6U7ihVSqnVktOBPn+Ebk+EJjpGF6C8vJyioiJ6e9O78rS5uRmAq1evpvX9Sim1knI60O0Run3iIrDkWnQRoa6uLu1ADwQCuN1uLbsope5LOR3o9gh9ZmYmvovT6/UyOjqacHlhXV0d/f39ad0V6nA4CAaDGuhKqftSTge6PUKPRqPMzs4CsUCPRqMJd4QGAoG0T16EWNllaGgo4b8ClFJqreR0oNsjdGNMfES+1KmLEBuhAxnX0XWUrpS63+RFoM+/cm65teglJSWUlZWlHeh+v5/S0lKdGFVK3XdyOtAty8LlcuFwOHA4HEBsJQskHqFDrOyS7tJFEaG5uZkrV67o5dFKqftKTgc6fHDiotvtxhiDw+GgrKxsyUCvq6tjaGgo7fXkzc3NTE5OcuvWrXS7rZRSWZfzge7xeHA6nTidTmZmZoCl16KD1tGVUvkp5wPd7XbHyy137twBiB+jm0imgV5SUkJ1dbUGulLqvpLzge7xeOKBbge0vRbdXsq4UEFBAT6fL+1Ah9go/dq1a/F/FSil1FrL+UCfP0K3D92yV7okuo4OMpsYhVigRyIRbty4kXYbSimVTTkf6B6PJ75scWBgAGDZUxchVnYJhUKEQqG03rexsRHLsrTsopS6b+R8oLtcrvjyQTvAl9tcBJnX0d1uNw0NDRroSqn7Rs4HusfjIRqNMj4+Hj9xsbS0FMuyltyev2HDBkQk47LLzZs3mZiYSLsNpZTKlpwPdLfbzczMDGNjY/EDuizLory8fMkRusvlorq6OuOJUdDjdJVS94ecD3T7gK6ZmZm7VrUst3QRiB+lm+6Oz7q6Ojwej5ZdlFL3hZwP9PkHdM2XbKBPTk4u+1wilmXR2NjItWvX0vp+pZTKppwPdHuE7nA4cDqd8WD3er2Mj48vee55phOjAMFgkIGBgbRXyyilVLbkfKDbI3T7TBf7fJZkVrrU1NTgcDgymhgNBoMAdHV1pd2GUkplQ84Huj1CLygoAD64W9Tn8wEwODiY8HsdDge1tbUZjdBramooKCjQQFdKrbmkAl1EnhCRCyLSKSJfTvDM/yQiZ0XkjIj8ILvdTMweoRcWFgIf7A5NJtAhVna5efNmwivrlmPX0TXQlVJrbdlAFxEH8HXgALAFeFpEtix4phX4C2CfMWYr8Gcr0NdF2SP04uJiAG7fvg3EAr6wsDC+ezSRuro6wuHwss8tpbGxkcHBwYTX3iml1GpIZoS+C+g0xlwxxoSB54GDC575D8DXjTFDAMaY29ntZmL2CH1hoANUVlYuO0IPBAIAWkdXSuW8ZAI9AMw/gap77mvztQFtInJURH4nIk8s1pCIPCsix0XkeH9/f3o9XmD+CD0cDt+1O7SysnLZkXdlZSVut1vr6EqpnJetSVEn0Ar8G+Bp4Bsi4l34kDHmOWNMhzGmo6qqKitv7HK5gNik6NjY2F3LB30+H6FQaMkjbi3LYsOGDRkFuq5HV0rdD5IJ9B6gYd7n9XNfm68bOGSMmTHGXAUuEgv4FSciuN1unE4noVDornNVUpkYvXXrFpFIJO1+BINBraMrpdZUMoF+DGgVkSYRcQOfAQ4teOZfiI3OERE/sRLMqu2Hty+5GBsbu2sjUWVlJcCyZZdAIEAkEomfp54OraMrpdbasoFujJkFvgS8CpwDfmyMOSMiXxWRp+YeexUYEJGzwGHgfzXGpL9sJEVut5toNMrU1NRdRwDYI/RkVrpAZjtGtY6ulFprzmQeMsa8DLy84GtfmfexAf587s+q83g8TE9PY4xBRJiZmcHlcuHxeCgpKVm25OL1eiksLKSnp4eOjo60+iAiBINBDXSl1JrJ+Z2iEBuhh8NhnM7Y7yd7tyjERunLBbqIEAgEMhqhQ2w9+tDQ0JJX3yml1ErJi0C3R+j2EsaFK12S2TRUV1dHf3//kod5LUfr6EqptZQXgW6P0O3NRfNH6JWVlYyPj8cvv0ikrq4OYww3b95Mux81NTUUFhZqoCul1kTeBPr09HT8cuiFJRdIbqULZDYxKiK6Hl0ptWbyItA9Hg/hcBifz0c0Gr3ryFx76eJydfSSkhLKysoyrqMHg0Gtoyul1kReBLrb7WZ2dha/38/Y2Bh37tyJv5bsCB1io/RMznQBraMrpdZOXgS6PRnq8/kYGxu7a3TscrkoKytbdoQOsTr60NBQ/JKMdFRXV2sdXSm1JvIq0MvLywmFQoyPj9/1eiorXSA7dXQNdKXUasuLQLdXtxQVFTE2NsbU1NRdryezFh0+CPRslF2Gh4e1jq6UWlV5Fegej4exsTEikchdNxBVVlYyOTm5bCmloKCAysrKrGwwAq2jK6VWV14FuojElyzOL7ske0gXxEbpmQa6nuuilFoLeRHoJSUlAHeNwNNZiw6xQA+FQnftNk2Vfa6LrkdXSq2mvAh0p9MZL7fYV9LND+SKigpEJKk6ejaupAM910UptfryItAhVnYZHx+nsLAQuHuE7nQ6KS8vTyrQa2trEZGsbDACdJSulFo1eRfopaWlwN2BDsndLwqxdevV1dUZj9C1jq6UWm15E+glJSWMj4/j9/uZmpq6pwZuL12cfwFGIvbEaDLPJqLnuiilVlveBHpxcTFjY2P4/X5CodA9I3Sfz8f09PQ9m44WEwgEmJqaYmhoKKM+NTY26j2jSqlVk1eBPjk5SWVlJaFQ6J7JyGQP6YLsbjACraMrpVZHXgU6fHCey8JRcSpr0aurq3E6nVmpo3s8Hq2jK6VWRd4Eur0WvbS0lFAoxOTk5F01cK/Xi2VZSY3QHQ4HgUCA7u7ujPpkWZae66KUWjV5E+gLz3OJRqN33VJkWRZerzepQIdYHf3mzZvMzs5m1K9gMMjg4GBGG5WUUioZeRPo9gjd3mAE3BOiyS5dBKivrycajWZ0JR3ouS5KqdWTN4Fuj9Aty4oH+WIrXZJdulhfXw+QcdmltrZW6+hKqVWRN4HudrtxOp2Ew+F4qWWxzUUzMzNJlT9KS0spLy/PWh1dV7oopVZa3gS6iFBcXMzExAQFBQXAvSWXVA7pAmhoaMg40CFWdhkYGNA6ulJqReVNoMMHu0XLy8uJRqP3jNCrq6sBuH37dlLtBQIBRkdHM94YpOvRlVKrIa8C3d4tWlVVxdTU1D2BXlJSQmFhYdKB3tDQAGgdXSmVG/Iu0O3zXMbGxu4JdBGhuro66UCvra3F4XBkpY6+ceNGDXSl1IrK20AfGRm5J9CBeKAns9LF4XBQV1eXlTp6U1MTAwMDeq6LUmrF5FWgl5SUYIzB7/czNDS06CRkdXU14XCY4eHhpNoMBAL09vYSiUQy6ltTUxMAV69ezagdpZRKJK8C3V6LXlFRQSgUYmpq6p6dnjU1NUDyE6P19fVEIhFu3bqVUd9qamooKirSQFdKrZi8CvT557nY5ZZMV7pka2LUvmf06tWrGZ2zrpRSieRVoM8/z8WuVS88Rtfj8VBeXp50oJeVlVFaWpq1Ovro6GjS58kopVQq8jLQXS5X/HKKxS6pqKmpoa+vL+l2s7XBSOvoSqmVlFeBXlhYiGVZWJYVn/RcbDRcXV3NwMBA0hOdgUCA4eHhRVfNpMLn81FWVqaBrpRaEUkFuog8ISIXRKRTRL68xHP/TkSMiHRkr4vJs7f/G2OIRqNA4kCPRqPcuXMnqXazWUdvamrSOrpSakUsG+gi4gC+DhwAtgBPi8iWRZ4rBf4UeCvbnUyFfZ6L1+slHA4vWnKxJ0aTLbvU1tZiWVbWyi6Tk5MplXyUUioZyYzQdwGdxpgrxpgw8DxwcJHn/k/g/wamsti/lM3fXDQ+Ps7AwMA9o2G/349lWUlPjLpcLmpra7WOrpS6ryUT6AHgxrzPu+e+FiciHwYajDEvLdWQiDwrIsdF5Hh/f3/KnU2GfUBXVVUVQ0NDTE9PMzk5edczDocDv9+fdKBDbD16b29vvJSTrrKyMiorKzXQlVJZl/GkqIhYwNeA/7Tcs8aY54wxHcaYjqqqqkzfelH2AV3zAztRHT3VQJ+ZmclKqaSpqYlr165lvPtUKaXmSybQe4CGeZ/Xz33NVgpsA94QkS7gEeDQWk2MFhcXE4lEqKqq4saN2D8sEgX6yMgIU1PJVYiyNTEKsUAPh8P09vZm3JZSStmSCfRjQKuINImIG/gMcMh+0RgzYozxG2OCxpgg8DvgKWPM8RXp8TLstehVVVXxssZigZ7qEQDl5eWUlpZy/fr1jPton4+uZRelVDYtG+jGmFngS8CrwDngx8aYMyLyVRF5aqU7mCp7+7/X62ViYoLS0tIlV7okG+giQmNjI11dXRkvOSwqKqK2tlYDXSmVVc5kHjLGvAy8vOBrX0nw7L/JvFvps0fopaWl8c8XG6GXl5fj8XhSqokHg0FOnz7N4OAglZWVGfUzGAxy7NgxZmZmcLlcGbWllFKQZztF4YMRuh3sDodj0UBP9bILiN0NCmTloorm5mYikUhWavJKKQV5GOhFRUXAB4E+PT3NxMTEopOfVVVVSV92AVBZWUlxcXFW7gbduHEjlmVx5cqVjNtSSinIw0C3LIuioqJ4GcM+0yXRIV1TU1OLXoSxGPsI3GzU0T0eD4FAQOvoSqmsybtAh9jofHp6murqanp6YissEy1dhOQnRiFWdgmFQov+gkjVpk2b6OnpYXx8POO2lFIqLwPd3i0aDAbjJY2lli6mOjEK2amjt7S0AHD58uWM21JKqbwMdHu3qB3oJSUliwZ6YWEhpaWlKY3Q/X4/RUVFWamj19XVUVRURGdnZ8ZtKaVU3ga6PUK/du0aPp8vYYkk1ZUudh392rVrGdfRRYSWlhYuX76sx+kqpTKWt4EeDocJBoOEw2EKCgoSXvtWXV1Nf39/SoduNTY2MjIyEp9wzURLSwsTExN6DIBSKmN5Gej2WvS6ujoAIpEIoVCIcDh8z7M1NTVEIhEGBgaSbt+uo2ej7LJp0yYALl26lHFbSqn1LS8D3V6D7vf7AeJXx2XjsguIrV8vLCzMSqAXFRURCAS0jq6UylheBro9Qre3/9tnrydauuh0OlPasTn/XJdsaGlpoaenh4mJiay0p5Ran/Iy0O0R+szMDLW1tfETEhcLdIfDQV1dXcpb8IPBIMPDw4yMjGTc39bWVkCXLyqlMpPXgW6vdLl69SpFRUUJV7o0NDRw8+ZNZmZmkn6PbJ7rossXlVLZkJeB7nQ68Xg88UDv6urC5/MlXOlSX19PNBrl5s2bSb9HTU0NBQUFWQl0EWHTpk10dnbq8kWlVNryMtDh7t2i169fx+v1Jgx0+zYi+4ajZNh19GxMjIIuX1RKZS5vA93eLdrU1MTMzAxOp5ORkRFmZ2cXfbaioiLlOnpjYyNDQ0OMjo5m3F/7GAAtuyil0pXXgW6P0IH4GvREm4EaGhro7u5OqeSRzXNd7OWLuh5dKZWudRPodpAvVUcfGxtLadWKXUfP1hG4unxRKZWJvA30kpISJicnCQQCAPHa9FKBDqnV0S3Lyupkpi5fVEplIm8D3V66GIlEqKur49q1a3g8noSBXlNTg8vlSinQIRbCY2NjKa2QSUSXLyqlMpG3gW7vEg2FQnctXUy0Ft2yLAKBQMoTo/ZkZjZq3/bpi5cuXSISiWTcnlJqfcnbQK+srATgzp07Sa1Fh1jZ5datW4se4pVIcXFxViczH3jgASYnJ7N2rIBSav3I20D3+XxYlkV/f398LXp5eTnDw8MJR78NDQ0YY1JeC97a2pq1q+Q2bdqE2+3m7NmzGbellFpf8jbQLcvC7/fT399PU1MTkUgEESEajSZcyWJPjKZadmlrawOyU3ZxuVy0tbVx/vz5lM5oV0qpvA10iB1za4/QgfhywDt37iz6fFFRET6fL+VAr62tpaSkJKtll4mJiaztQlVKrQ95Heh+v5+hoaH4yHtgYAARoaenJ+H3NDQ0cOPGjZSWIYoIra2tXL58OSuTma2trbhcLi27KKVSkteBbl9eUVhYiIhw7do1qqurlwz0+vp6JiYmEq6GSaS1tZXp6emUlz0uxuVy0drayrlz57TsopRKWl4HelVVFQAjIyMEAgG6uroIBAL09PQkHIGnc1AXQHNzM5ZlcfHixcw6PWfLli2Mj4/Hz3JXSqnl5HWgL1zp0tXVRX19PVNTUwnvEK2qqsLtdqdcR/d4PASDwazV0VtbW3E6nVp2UUolLa8D3eFw4PP54mvRr169Gj8KIFHZxbIs6uvrUw50iIXwnTt3Ui7XLMbtdsfLLnpGulIqGXkd6BAbcd++fZtgMEh3dzfl5eXLjsDr6+vp6+tLaYMRfHAWS7ZG6Vu2bGFsbEzLLkqppKyLQB8aGiIYDBKNRunt7Y3X0ROpr6/HGLPkM4uprKzE5/NltezicDi07KKUSsq6CHRjTHzFiz0x2tfXl/AO0YaGBkQkrWNxW1tbuXr1asqj+8V4PB4tuyilkrYuAh1im4aAeB09Go1y69atRb+noKCAhoaGtFastLW1EYlEsnYWywMPPEAoFEqrpq+UWl+SCnQReUJELohIp4h8eZHX/1xEzorIKRF5XUQas9/V9FRWViIiGGOwLCu+0gWW3uLf1tZGX19fwhuOEmlsbMTtdnPhwoWM+m1rb2/H4XBw5syZrLSnlMpfywa6iDiArwMHgC3A0yKyZcFj7wIdxpgdwE+Av812R9PldDrjpyzW19fT1dVFSUkJ5eXlS9bI29vbAVIepTscDjZv3szZs2cXvb80VR6Ph02bNnH27FndZKSUWlIyI/RdQKcx5ooxJgw8Dxyc/4Ax5rAxxr437XdAfXa7mZn5Z7rYpZDlJkb9fj8+ny+tssuOHTuYmprK2iajnTt3EgqF9CYjpdSSkgn0ADB/22T33NcSeQZ4ZbEXRORZETkuIsf7+/uT72WGqqqqGBgYiK9Fh1igDw8PL3nkbVtbG72LMF4AABMOSURBVF1dXUxPT6f0fk1NTZSUlHDq1KmM+m1rb2+nuLiYd955JyvtKaXyU1YnRUXkc0AH8F8We90Y85wxpsMY02FPVq4Ge6VLMBikp6eHcDicVB29vb2dSCTClStXUno/y7LYvn07ly5dysqFzw6HgwcffJCLFy8yOjqacXtKqfyUTKD3AA3zPq+f+9pdROTfAv878JQxJrUh7Qqzf3lUV1djjOHGjRts2LAhqZMXCwoK0iqd7Ny5k2g0yunTp9Pu93wPPfQQxhjefffdrLSnlMo/yQT6MaBVRJpExA18Bjg0/wER+RDwz8TC/Hb2u5kZ+zq6kpISIDbR6XK5qKmpWXKE7nA4aGlp4eLFiylPSNbU1FBTU5O1sktFRQXNzc2cOHFCJ0eVUotaNtCNMbPAl4BXgXPAj40xZ0TkqyLy1Nxj/wUoAV4QkZMicihBc2vC5XJRUVGB2+3G4XDw5ptvArE6em9v75Kbdtra2piYmEh51yjERuk9PT0JL9RI1UMPPcTo6CidnZ1ZaU8plV+SqqEbY142xrQZYzYZY/567mtfMcYcmvv43xpjaowxD879eWrpFldfdXU1Q0ND7Ny5kyNHjgCxLf7T09NLBm5LSwsikta68m3btiEiWZ8cPXHiRFbaU0rll7zfKWrz+/0MDAywf/9+3nrrLWZmZuInLy5VdiksLKSxsTGtOnppaSmbNm3i1KlTWdm6r5OjSqmlrJtAr6qqIhqN8vDDDzMxMcHJkyfx+/14PJ5lyyltbW309/endSzujh07GBkZydr9oDo5qpRKZF0FOsTWiAMcOXIEEVl2gxHEAh1S3zUKsHnzZtxuN++9917K37sYnRxVSiWybgLd7/cDMDs7S1NTE0ePHgVY9uRFiK2S8fv9aQW6y+Viy5YtnD17dsn3SIVOjiqlFrNuAt3tduP1erlz5w779u3jyJEjGGMIBAIYY+jt7V3y+9PdNQqxsks4HM7qgV06OaqUWmjdBDp8cHvR/v376evr4/Lly2zcuBHLspYN27a2NqLRaFqj4mAwSFlZWdYC2OFw8KEPfYiLFy9mbUmkUir3rbtAHxgYYO/evQAcPXqUwsJCNm3axJkzZ5ZcidLQ0EBJSQknT55M+X1FhF27dnH16tW01rMv5pFHHsHpdPLrX/86K+0ppXLfugv0SCRCbW0tXq83vh5927ZtjI6OLnl3p2VZdHR00NnZmdaouKOjg4KCAn7zm9+k3f/5iouL2b17N6dPn+b27ftuc65Sag2sq0Cvra0F4MaNG+zbty8+Mbp582acTuey56489NBDWJbFsWPHUn5vj8fD7t27uXDhAn19fal3fhF79uzB7XbrKF0pBayzQK+pqcHr9XL+/Hn27dvHuXPnuHPnDm63m/b2ds6ePUskEkn4/SUlJWzbto2TJ0+mNTm6e/du3G53/F8GmSoqKuKRRx7h7NmzCa/TU0qtH+sq0EWEzZs3c+XKFR555BEAfvvb3wKxssvExMSyF0Pv2rWLcDicVi29sLCQjo4Ozpw5w8DAQOo/wCL27NmDx+PhjTfeyEp7Sqncta4CHWKXLkciEcrLy3G73fGyS0tLCwUFBbz//vtLfn8gECAQCHDs2LG0tvPv2bMHy7Li75upgoIC9uzZw4ULF5ZdeqmUym/rLtDt1SqdnZ089NBD8fKH0+lk8+bNnD9/ftkNQLt27WJgYCCtK+FKSkr48Ic/zHvvvcfIyEhaP8NCjzzyCIWFhTpKV2qdW3eBbpddOjs72b9/P8ePH2dqagqA7du3Ew6HuXTp0pJtbN26leLiYt5+++20+mAvm7TLPZnyeDzs3buXS5cuLXnQmFIqv627QIdY2WVmZoatW7cSDoc5fvw4ENsAVFxcvOxqF4fDQUdHB5cuXWJwcDDl9/d6vezYsYMTJ04wNjaW1s+w0K5duygqKuLw4cNZaU8plXvWZaAHg0EKCwtxuVwA8bKLZVls3bqVixcvxkftidhLGNMdpe/bt4/Z2dn4ZRuZcrvd7N+/nytXrnDmzJmstKmUyi3rMtAty6K9vZ3r16+zZcuWuyYot2/fTiQS4fz580u2UVpaytatWzl58iThcDjlPvj9frZv385bb72VtY1Bu3fvpq6ujpdeeilrI3+lVO5Yl4EOsbLL9PQ0v/d7v8fRo0fjR9EGAgG8Xm9Slzvv2rWL6enptM8m//jHP47b7ebQoUNZOQrXsiw+9alPEQ6Heemll7JyqYZSKnes20Bvbm7G7XbT1NTE0NBQfEQuImzbto0rV64wPj6+ZBuBQIBgMMgbb7yR1oi4uLiYT3ziE/T09GSt9FJVVcVjjz3G+fPns3b1nVIqN6zbQHc6nbS1tTEzM4NlWfzwhz+Mv7Z9+3aMMbzzzjtLtiEifPKTn2RmZoZXXnklrX5s3bqVzZs3c/jw4aydnPjII4+wceNGXnnlFb2qTql1ZN0GOnxQdvnsZz/L3//938fDr7q6ms2bN3PkyJFlA7GyspKPfvSjnD17Nq3zzkWEJ598EpfLxb/+679mrfRy8OBBotEohw4d0tKLUuvEug70lpYWnE4njz/+OMPDw/zjP/5j/LWPf/zjGGP4xS9+sWw7e/fupbq6mpdeeimtM15KSko4cOAA3d3dvPXWWyl//2J8Ph+///u/z+XLl5f9l4ZSKj+s60B3u920tLQwODjIxz72Mb72ta8xOTkJxNaK79+/nzNnzix7vovD4eAP/uAPCIVCvP7662n1Zfv27bS1tfGrX/0qrbXti+no6KC5uZlf/OIX3Lx5MyttKqXuX+s60CFWdgmFQnzxi1/k9u3bfOtb34q/tnfvXrxeL6+88sqSpzAC1NfXs3v3bo4dO8aNGzdS7oddenE4HPzsZz9LaynkYm0ePHiQoqIivve979Hf359xm0qp+9e6D/T29nZKSkq4efMm+/fv52//9m/jYepyuXjiiSfo7+9PagPRY489Rnl5OT//+c+ZnZ1NuS9lZWUcPHiQ3t5efvSjH6XVxmJt/uEf/iEiwne/+12GhoYyblMpdX9a94Hu8Xh48skn6evr4/Of/zw3btzg+9//fvz1trY2Wlpaklqa6Ha7efLJJ+nv7+f1119PazLygQce4KmnnuLKlSu88MILy/7LIBmVlZV8/vOfZ3Z2lu985zu68kWpPCVrtQKio6PD2GeopOTP/gzSOIt8Of137jA+Ps7w0BDT4TC7Hn4YEQFgZmaG3t5eiouL8fv9y7Y1MDBAKBSi3OvF6/UiafRnNBRicGAg9p5VVWm1sdB0OMytW7dwOhzU1tbicDiy0KpSKmUPPgh/93dpfauIvGOM6VjstXU/Qrf5fD4cloXP52NycpL+eWvCXS4XZWVljI2NMbnMGS8AvspKSkpLGRkeZnhoiHR+ZZaVllLh8zE+Ps7AnTtptbGQx+2mprqa2dlZ+vr6mM3C6F8pdf9wrnUHUpbmb7XlOICxc+f48Y9/zHvvvceVK1d49/Dh+Ci9OBzme//8z4RCIT7zmc/Q3NycsC0BKo3hzRdf5MSJE+zfv5/HHnss3layyoF333iDX//613z4wx/mwIEDOJ2Z/ScrAGY6O/nej36Ey+Xi4MGDtLe3Z9SmUur+oCP0eR544AG2bt3Kzp07uXnzJt/+9rfjr7ndbr7whS9QUVHBD37wg2UP77J3kdqXaKRbU//oRz/K/v37OXHiBN/85jezcsF0S0sLf/zHf0x5eTnPP/88L7/8clYmYJVSa0sDfYEDBw5QVFTE5z73OZ599ln+6Z/+Kf5aaWkpX/jCF6itrY2P5JdiL0V86KGHOHr0KC+++OKyx/Iu1sbjjz/O008/zdjYGM899xxHjhzJeEep3+/nmWeeiS+1/MY3vqHLGpXKcbk3KboKzpw5w09+8hOGh4f5xje+wV/+5V/y5S9/Of56OBzm+eef5+rVqxw4cIBdu3Yt2Z4xhtdee43f/va3FBcX87GPfYzt27enXIKZmJjgxRdf5Ny5czQ0NPCpT30Kn8+X1s8436VLl/iXf/kXwuEwDz/8MHv27KG0tDTjdpVS2bfUpKgGegJvvfUWv/zlL5menuaFF17g4MGD/M3f/E08hGdnZ/nJT37ChQsXePDBB/noRz+K1+tdss3e3l5eeuklent7aWxs5BOf+ATV1dUp9csYw/vvvx8vk2zbto3du3ezYcOGtH9WgLGxMX75y1/y/vvvY1kWO3fuZN++fVn5haGUyh4N9DT19/fz05/+lL6+Pk6cOEFtbS3/8A//EF/uF41Gee2113j77bcxxrBz504effRRKioqErZpjOHEiRO89tprhMNhHnzwQXbu3ElDQ0NKI/aRkRGOHj3KyZMnmZmZYePGjezevZvNmzdjWelX0oaGhuLtRqNRtm7dyo4dO2hqasp4QlYplTkN9AxEIhEOHz7MkSNH4uemP/roo/zRH/0RgUAAgNHRUY4cOcKJEycwxrBjxw46OjrYsGFDwnCdmJjg9ddf59SpU8zOzlJWVsa2bdvYvn07NTU1SYf71NQU7777Lm+//TbDw8OUlpayadMmmpqaaGpqSrt0EgqF+N3vfsfx48cJh8O4XC6am5tpa2ujtbVVSzJKrZGMA11EngD+H2Kr+75pjPmbBa97gO8ADwEDwL83xnQt1WauBLrt+vXrfP/73yccDmOMobu7G2MMjz/+OE8++STl5eWMjo5y9OhR3nnnHSKRCB6Ph40bNxIMBgkGg9TU1NyzmWd6epoLFy5w+vRpLl++TDQapby8nA0bNlBbW8uGDRvYsGEDJSUlS4Z8NBrl4sWLnDp1iq6urvghY36/n2AwSFVVFZWVlfj9fsrKypL+hTE7O0tXVxcXL17k4sWLjIyMALHDy2pqau764/V6dbOSUisso0AXEQdwEfh9oBs4BjxtjDk775kvAjuMMX8iIp8B/gdjzL9fqt1cC3SIlUv6+vp48803ee+99+KhOD09TSgUwhhDYWEhPp+PoqIiotEo09PT8SN1RYSioiLKysooLy/H5/NRVlZGQUEBHo8HYwy9vb3cunWLO3fuMDw8HH/vgoICSktL7/pTXFyMx+PB4/Hgdrvjf1uWxeDgID09PXR3d9PT03PXYV9OpxOfz0dJSQlFRUUUFxfH/3a73bjdblwuV/xvp9OJw+HAsiyGhobo6uqir6+P27dvMzg4eNdyTPvns/tYWFgY//nsv91uN06n864/dvvz/4hI/G/7fz+l1rtMA30P8J+NMR+f+/wvAIwx/9e8Z16de+ZNEXECt4Aqs0TjuRjoCw0ODvLiiy9y/fp1QqEQ0WgUj8eDy+Va664lZf5/nmyF5WL/yVeybaVykWVZfOUrX0nre5cK9GRmuQLA/PNgu4HdiZ4xxsyKyAhQCdx1p5qIPAs8O/fpmIikfsVPjH9h2+uA/szrg/7M64P/r/7qr9L9mRsTvbCqyxaMMc8Bz2XajogcT/QbKl/pz7w+6M+8PqzUz5zM+rYeoGHe5/VzX1v0mbmSSzmxyVGllFKrJJlAPwa0ikiTiLiBzwCHFjxzCPif5z7+H4FfLVU/V0oplX3LllzmauJfAl4ltmzx28aYMyLyVeC4MeYQ8C3guyLSCQwSC/2VlHHZJgfpz7w+6M+8PqzIz7xmG4uUUkpll562qJRSeUIDXSml8kTOBbqIPCEiF0SkU0S+vPx35DYR+baI3BaR02vdl9UiIg0iclhEzorIGRH507Xu00oTkQIReVtE3pv7mf+Pte7TahARh4i8KyIvrnVfVoOIdInI+yJyUkSyvrMyp2royRxDkG9E5CPAGPAdY8y2te7PahCRDcAGY8wJESkF3gE+lef/nQUoNsaMiYgLOAL8qTHmd2vctRUlIn8OdABlxphPrnV/VpqIdAEdxpgV2UiVayP0XUCnMeaKMSYMPA8cXOM+rShjzH8ntnJo3TDG3DTGnJj7OAScI7YbOW+ZmLG5T11zf3JntJUGEakHngS+udZ9yRe5FuiLHUOQ1/9HX+9EJAh8CHhrbXuy8ubKDyeB28AvjTH5/jP/HfC/AZndp5hbDPALEXln7iiUrMq1QFfriIiUAD8F/swYM7rW/VlpxpiIMeZBYruxd4lI3pbYROSTwG1jzDtr3ZdVtt8Y82HgAPAf50qqWZNrgZ7MMQQqD8zVkX8KfN8Y87O17s9qMsYMA4eBJ9a6LytoH/DUXE35eeAxEfne2nZp5Rljeub+vg38f8TKyFmTa4GezDEEKsfNTRB+CzhnjPnaWvdnNYhIlYh45z4uJDbxf35te7VyjDF/YYypN8YEif3/+FfGmM+tcbdWlIgUz03yIyLFwMeArK5ey6lAN8bMAvYxBOeAHxtjzqxtr1aWiPwQeBNoF5FuEXlmrfu0CvYBf0hs1HZy7s8n1rpTK2wDcFhEThEbuPzSGLMulvKtIzXAERF5D3gbeMkY89+y+QY5tWxRKaVUYjk1QldKKZWYBrpSSuUJDXSllMoTGuhKKZUnNNCVUipPaKArpVSe0EBXSqk88f8DIqMlAb9qiyIAAAAASUVORK5CYII=\n", + "image/png": "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\n", "text/plain": [ - "
" + "
" ] }, "metadata": { @@ -280,15 +255,17 @@ } ], "source": [ - "print(\"\\n\")\n", - "plot_results(poi_tests, cls_obs, cls_exp, poi_tests)\n", - "invert_interval(poi_tests, cls_obs, cls_exp)" + "fig, ax = plt.subplots()\n", + "fig.set_size_inches(7, 5)\n", + "\n", + "artists = brazil.plot_results(test_pois, results, ax=ax)\n", + "invert_interval(test_pois, cls_obs, cls_exp)" ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -302,7 +279,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.5" + "version": "3.10.4" } }, "nbformat": 4, From 287bfae60714d293fafef0ea55e15e043b8890a5 Mon Sep 17 00:00:00 2001 From: Giordon Stark Date: Fri, 9 Sep 2022 14:26:42 +0100 Subject: [PATCH 11/13] feat: Allow schema validation with tensor types (#1665) * Teach pyhf.schema.validate about the tensors from the backends. * Define backend.array_type and backend.array_subtype to be used by the type checking in schema validation. * Define pyhf.tensor.array_types and pyhf.tensor.array_subtypes to list all types currently loaded in the session. * Add docstring example for pyhf.schema.load_schema. * Add array information to docs class summary template. --- docs/_templates/autosummary/class.rst | 10 +++- src/pyhf/schema/loader.py | 15 +++++ src/pyhf/schema/validator.py | 80 ++++++++++++++++++++++----- src/pyhf/tensor/__init__.py | 29 ++++++++++ src/pyhf/tensor/jax_backend.py | 6 ++ src/pyhf/tensor/numpy_backend.py | 10 +++- src/pyhf/tensor/pytorch_backend.py | 6 ++ src/pyhf/tensor/tensorflow_backend.py | 6 ++ src/pyhf/workspace.py | 2 +- tests/test_backends.py | 9 +++ tests/test_schema.py | 71 +++++++++++++++++++++++- 11 files changed, 226 insertions(+), 18 deletions(-) diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst index 96edc81f71..9d0e2d8726 100644 --- a/docs/_templates/autosummary/class.rst +++ b/docs/_templates/autosummary/class.rst @@ -15,6 +15,14 @@ .. autoattribute:: {{ name }}.{{ item }} {%- endfor %} {% endif %} + {% if name == 'numpy_backend' %} + {% if 'array_type' in members %} + .. autoattribute:: {{ name }}.array_type + {% endif %} + {% if 'array_subtype' in members %} + .. autoattribute:: {{ name }}.array_subtype + {% endif %} + {% endif %} {% endblock %} {% block methods %} @@ -23,7 +31,7 @@ .. rubric:: {{ _('Methods') }} {% for item in members %} - {% if item not in attributes and item not in inherited_members and not item.startswith('__') %} + {% if item not in attributes and item not in inherited_members and not item.startswith('__') and item not in ['array_type', 'array_subtype'] %} .. automethod:: {{ name }}.{{ item }} {% endif %} {%- endfor %} diff --git a/src/pyhf/schema/loader.py b/src/pyhf/schema/loader.py index 4010133374..c046bec409 100644 --- a/src/pyhf/schema/loader.py +++ b/src/pyhf/schema/loader.py @@ -19,8 +19,23 @@ def load_schema(schema_id: str): Args: schema_id (str): Relative path to schema from :attr:`pyhf.schema.path` + Example: + >>> import pyhf + >>> schema = pyhf.schema.load_schema("1.0.0/defs.json") + >>> type(schema) + + >>> schema.keys() + dict_keys(['$schema', '$id', 'definitions']) + >>> pyhf.schema.load_schema("0.0.0/defs.json") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + pyhf.exceptions.SchemaNotFound: ... + Returns: schema (dict): The loaded schema. + + Raises: + ~pyhf.exceptions.SchemaNotFound: if the provided ``schema_id`` cannot be found. """ try: return variables.SCHEMA_CACHE[ diff --git a/src/pyhf/schema/validator.py b/src/pyhf/schema/validator.py index 1f42b46b8e..1fbc36c686 100644 --- a/src/pyhf/schema/validator.py +++ b/src/pyhf/schema/validator.py @@ -1,24 +1,71 @@ +import numbers +from typing import Mapping, Union + import jsonschema + import pyhf.exceptions -from pyhf.schema.loader import load_schema +from pyhf import tensor from pyhf.schema import variables -from typing import Union, Mapping +from pyhf.schema.loader import load_schema + + +def _is_array_or_tensor(checker, instance): + """ + A helper function for allowing the validation of tensors as list types in schema validation. + + .. warning: + + This will check for valid array types using any backends that have been loaded so far. + """ + return isinstance(instance, (list, *tensor.array_types)) + +def _is_number_or_tensor_subtype(checker, instance): + """ + A helper function for allowing the validation of tensor contents as number types in schema validation. + + .. warning: + This will check for valid array subtypes using any backends that have been loaded so far. + """ + is_number = jsonschema._types.is_number(checker, instance) + if is_number: + return True + return isinstance(instance, (numbers.Number, *tensor.array_subtypes)) -def validate(spec: Mapping, schema_name: str, version: Union[str, None] = None): + +def validate( + spec: Mapping, + schema_name: str, + *, + version: Union[str, None] = None, + allow_tensors: bool = True, +): """ - Validate a provided specification against a schema. + Validate the provided instance, ``spec``, against the schema associated with ``schema_name``. Args: - spec (dict): The specification to validate. - schema_name (str): The name of the schema to use. - version (None or str): The version to use if not the default from :attr:`pyhf.schema.version`. + spec (:obj:`object`): An object instance to validate against a schema. + schema_name (:obj:`string`): The name of a schema to validate against. + See :func:`pyhf.schema.load_schema` for more details. + version (:obj:`string`): The version of the schema to use. + See :func:`pyhf.schema.load_schema` for more details. + allow_tensors (:obj:`bool`): A flag to enable or disable tensors as part of schema validation. + If enabled, tensors in the ``spec`` will be treated like python :obj:`list`. + Default: ``True``. + + Raises: + ~pyhf.exceptions.InvalidSpecification: if the provided instance does not validate against the schema. Returns: - None: schema validated fine + None: if there are no errors with the provided instance. - Raises: - pyhf.exceptions.InvalidSpecification: the specification is invalid + Example: + >>> import pyhf + >>> model = pyhf.simplemodels.uncorrelated_background( + ... signal=[12.0, 11.0], bkg=[50.0, 52.0], bkg_uncertainty=[3.0, 7.0] + ... ) + >>> pyhf.schema.validate(model.spec, "model.json") + >>> """ version = version or variables.SCHEMA_VERSION @@ -31,9 +78,16 @@ def validate(spec: Mapping, schema_name: str, version: Union[str, None] = None): referrer=f"{schema_name}", store=variables.SCHEMA_CACHE, ) - validator = jsonschema.Draft6Validator( - schema, resolver=resolver, format_checker=None - ) + + Validator = jsonschema.Draft6Validator + + if allow_tensors: + type_checker = Validator.TYPE_CHECKER.redefine( + "array", _is_array_or_tensor + ).redefine("number", _is_number_or_tensor_subtype) + Validator = jsonschema.validators.extend(Validator, type_checker=type_checker) + + validator = Validator(schema, resolver=resolver, format_checker=None) try: return validator.validate(spec) diff --git a/src/pyhf/tensor/__init__.py b/src/pyhf/tensor/__init__.py index 00a574af5c..1155461e4b 100644 --- a/src/pyhf/tensor/__init__.py +++ b/src/pyhf/tensor/__init__.py @@ -2,6 +2,19 @@ class _BackendRetriever: + __slots__ = [ + "_array_types", + "_array_subtypes", + "numpy_backend", + "jax_backend", + "pytorch_backend", + "tensorflow_backend", + ] + + def __init__(self): + self._array_types = set() + self._array_subtypes = set() + def __getattr__(self, name): if name == 'numpy_backend': from pyhf.tensor.numpy_backend import numpy_backend @@ -9,6 +22,8 @@ def __getattr__(self, name): assert numpy_backend # for autocomplete and dir() calls self.numpy_backend = numpy_backend + self._array_types.add(numpy_backend.array_type) + self._array_subtypes.add(numpy_backend.array_subtype) return numpy_backend elif name == 'jax_backend': try: @@ -17,6 +32,8 @@ def __getattr__(self, name): assert jax_backend # for autocomplete and dir() calls self.jax_backend = jax_backend + self._array_types.add(jax_backend.array_type) + self._array_subtypes.add(jax_backend.array_subtype) return jax_backend except ImportError as e: raise exceptions.ImportBackendError( @@ -30,6 +47,8 @@ def __getattr__(self, name): assert pytorch_backend # for autocomplete and dir() calls self.pytorch_backend = pytorch_backend + self._array_types.add(pytorch_backend.array_type) + self._array_subtypes.add(pytorch_backend.array_subtype) return pytorch_backend except ImportError as e: raise exceptions.ImportBackendError( @@ -43,6 +62,8 @@ def __getattr__(self, name): assert tensorflow_backend # for autocomplete and dir() calls self.tensorflow_backend = tensorflow_backend + self._array_types.add(tensorflow_backend.array_type) + self._array_subtypes.add(tensorflow_backend.array_subtype) return tensorflow_backend except ImportError as e: raise exceptions.ImportBackendError( @@ -50,6 +71,14 @@ def __getattr__(self, name): e, ) + @property + def array_types(self): + return tuple(self._array_types) + + @property + def array_subtypes(self): + return tuple(self._array_subtypes) + BackendRetriever = _BackendRetriever() __all__ = ['BackendRetriever'] diff --git a/src/pyhf/tensor/jax_backend.py b/src/pyhf/tensor/jax_backend.py index 5e4a65bc80..cec8d51851 100644 --- a/src/pyhf/tensor/jax_backend.py +++ b/src/pyhf/tensor/jax_backend.py @@ -53,6 +53,12 @@ class jax_backend: __slots__ = ['name', 'precision', 'dtypemap', 'default_do_grad'] + #: The array type for jax + array_type = jnp.DeviceArray + + #: The array content type for jax + array_subtype = jnp.DeviceArray + def __init__(self, **kwargs): self.name = 'jax' self.precision = kwargs.get('precision', '64b') diff --git a/src/pyhf/tensor/numpy_backend.py b/src/pyhf/tensor/numpy_backend.py index 102703b323..333a280ced 100644 --- a/src/pyhf/tensor/numpy_backend.py +++ b/src/pyhf/tensor/numpy_backend.py @@ -49,9 +49,15 @@ class numpy_backend(Generic[T]): __slots__ = ['name', 'precision', 'dtypemap', 'default_do_grad'] + #: The array type for numpy + array_type = np.ndarray + + #: The array content type for numpy + array_subtype = np.number + def __init__(self, **kwargs: str): - self.name: str = 'numpy' - self.precision: str = kwargs.get('precision', '64b') + self.name = "numpy" + self.precision = kwargs.get("precision", "64b") self.dtypemap: Mapping[ FloatIntOrBool, DTypeLike, # Type[np.floating[T]] | Type[np.integer[T]] | Type[np.bool_], diff --git a/src/pyhf/tensor/pytorch_backend.py b/src/pyhf/tensor/pytorch_backend.py index d82c297edf..286eeccc3f 100644 --- a/src/pyhf/tensor/pytorch_backend.py +++ b/src/pyhf/tensor/pytorch_backend.py @@ -13,6 +13,12 @@ class pytorch_backend: __slots__ = ['name', 'precision', 'dtypemap', 'default_do_grad'] + #: The array type for pytorch + array_type = torch.Tensor + + #: The array content type for pytorch + array_subtype = torch.Tensor + def __init__(self, **kwargs): self.name = 'pytorch' self.precision = kwargs.get('precision', '64b') diff --git a/src/pyhf/tensor/tensorflow_backend.py b/src/pyhf/tensor/tensorflow_backend.py index df5c3cdd0d..590096ebfa 100644 --- a/src/pyhf/tensor/tensorflow_backend.py +++ b/src/pyhf/tensor/tensorflow_backend.py @@ -11,6 +11,12 @@ class tensorflow_backend: __slots__ = ['name', 'precision', 'dtypemap', 'default_do_grad'] + #: The array type for tensorflow + array_type = tf.Tensor + + #: The array content type for tensorflow + array_subtype = tf.Tensor + def __init__(self, **kwargs): self.name = 'tensorflow' self.precision = kwargs.get('precision', '64b') diff --git a/src/pyhf/workspace.py b/src/pyhf/workspace.py index 070f3966db..2eec7bb4b9 100644 --- a/src/pyhf/workspace.py +++ b/src/pyhf/workspace.py @@ -387,7 +387,7 @@ def get_measurement(self, measurement_name=None, measurement_index=None): else: raise exceptions.InvalidMeasurement("No measurements have been defined.") - schema.validate(measurement, 'measurement.json', self.version) + schema.validate(measurement, 'measurement.json', version=self.version) return measurement def model( diff --git a/tests/test_backends.py b/tests/test_backends.py index e050633c3a..90664117ac 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -75,3 +75,12 @@ def example_op2(x): with pytest.raises(jax._src.errors.TracerArrayConversionError): jax.jacrev(example_op2)(pyhf.tensorlib.astensor([2.0, 3.0])) + + +def test_backend_array_type(backend): + assert backend[0].array_type is not None + + +def test_tensor_array_types(): + # can't really assert the content of them so easily + assert pyhf.tensor.array_types diff --git a/tests/test_schema.py b/tests/test_schema.py index 08f02ad371..965ab6bd1f 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -600,7 +600,6 @@ def test_defs_always_cached( ): """ Schema definitions should always be loaded from the local files and cached at first import. - Otherwise pyhf will crash in contexts where the jsonschema.RefResolver cannot lookup the definition by the schema-id (e.g. a cluster node without network access). """ @@ -637,3 +636,73 @@ def test_defs_always_cached( ] } pyhf.schema.validate(spec, 'model.json') # may try to access network and fail + + +def test_schema_tensor_type_allowed(backend): + tensorlib, _ = backend + spec = { + "channels": [ + { + "name": "singlechannel", + "samples": [ + { + "name": "signal", + "data": tensorlib.astensor([10]), + "modifiers": [ + {"name": "mu", "type": "normfactor", "data": None} + ], + }, + { + "name": "background", + "data": tensorlib.astensor([15]), + "modifiers": [ + { + "name": "uncorr_bkguncrt", + "type": "shapesys", + "data": tensorlib.astensor([5]), + } + ], + }, + ], + } + ] + } + assert pyhf.schema.validate(spec, "model.json") is None + + +def test_schema_tensor_type_disallowed(mocker, backend): + tensorlib, _ = backend + mocker.patch.object( + pyhf.schema.validate, + "__kwdefaults__", + {"version": None, "allow_tensors": False}, + ) + spec = { + "channels": [ + { + "name": "singlechannel", + "samples": [ + { + "name": "signal", + "data": tensorlib.astensor([10]), + "modifiers": [ + {"name": "mu", "type": "normfactor", "data": None} + ], + }, + { + "name": "background", + "data": tensorlib.astensor([15]), + "modifiers": [ + { + "name": "uncorr_bkguncrt", + "type": "shapesys", + "data": tensorlib.astensor([5]), + } + ], + }, + ], + } + ] + } + with pytest.raises(pyhf.exceptions.InvalidSpecification): + pyhf.schema.validate(spec, "model.json") From ee183ebfa9c01bfeb3b3f06d82e5b4e9bf518753 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Fri, 9 Sep 2022 17:46:48 +0200 Subject: [PATCH 12/13] fix: Guard against nan in test stat calculation (#1993) * Guard against nan from division by zero in pyhf.infer.calculators.AsymptoticCalculator.teststatistic. * Add use of 'less than or equal to' to docs for tests stats to match equations 14 and 16 of https://arxiv.org/abs/1007.1727. * Add tests to ensure that nan conditions in Issue #529 and Issue #1992 are not possible. --- src/pyhf/infer/calculators.py | 3 ++- src/pyhf/infer/test_statistics.py | 4 ++-- tests/test_infer.py | 34 +++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/pyhf/infer/calculators.py b/src/pyhf/infer/calculators.py index 8e06a613cb..ed9642e085 100644 --- a/src/pyhf/infer/calculators.py +++ b/src/pyhf/infer/calculators.py @@ -418,8 +418,9 @@ def _false_case(): teststat = (qmu - qmu_A) / (2 * self.sqrtqmuA_v) return teststat + # Use '<=' rather than '<' to avoid Issue #1992 teststat = tensorlib.conditional( - (sqrtqmu_v < self.sqrtqmuA_v), _true_case, _false_case + (sqrtqmu_v <= self.sqrtqmuA_v), _true_case, _false_case ) return tensorlib.astensor(teststat) diff --git a/src/pyhf/infer/test_statistics.py b/src/pyhf/infer/test_statistics.py index e23e87fa75..3af773d42c 100644 --- a/src/pyhf/infer/test_statistics.py +++ b/src/pyhf/infer/test_statistics.py @@ -71,7 +71,7 @@ def qmu(mu, data, pdf, init_pars, par_bounds, fixed_params, return_fitted_pars=F \begin{equation} q_{\mu} = \left\{\begin{array}{ll} - -2\ln\lambda\left(\mu\right), &\hat{\mu} < \mu,\\ + -2\ln\lambda\left(\mu\right), &\hat{\mu} \leq \mu,\\ 0, & \hat{\mu} > \mu \end{array}\right. \end{equation} @@ -160,7 +160,7 @@ def qmu_tilde( \begin{equation} \tilde{q}_{\mu} = \left\{\begin{array}{ll} - -2\ln\tilde{\lambda}\left(\mu\right), &\hat{\mu} < \mu,\\ + -2\ln\tilde{\lambda}\left(\mu\right), &\hat{\mu} \leq \mu,\\ 0, & \hat{\mu} > \mu \end{array}\right. \end{equation} diff --git a/tests/test_infer.py b/tests/test_infer.py index 92a3467585..8d06f57694 100644 --- a/tests/test_infer.py +++ b/tests/test_infer.py @@ -476,3 +476,37 @@ def test_fixed_poi(tmpdir, hypotest_args): pdf.config.param_set('mu').suggested_fixed = [True] with pytest.raises(pyhf.exceptions.InvalidModel): pyhf.infer.hypotest(*hypotest_args) + + +def test_teststat_nan_guard(): + # Example from Issue #1992 + model = pyhf.simplemodels.uncorrelated_background( + signal=[1.0], bkg=[1.0], bkg_uncertainty=[1.0] + ) + observations = [2] + test_poi = 0.0 + data = observations + model.config.auxdata + init_pars = model.config.suggested_init() + par_bounds = model.config.suggested_bounds() + fixed_params = model.config.suggested_fixed() + + test_stat = pyhf.infer.test_statistics.qmu_tilde( + test_poi, data, model, init_pars, par_bounds, fixed_params + ) + assert test_stat == pytest.approx(0.0) + asymptotic_calculator = pyhf.infer.calculators.AsymptoticCalculator( + data, model, test_stat="qtilde" + ) + # ensure not nan + assert ~np.isnan(asymptotic_calculator.teststatistic(test_poi)) + assert asymptotic_calculator.teststatistic(test_poi) == pytest.approx(0.0) + + # Example from Issue #529 + model = pyhf.simplemodels.uncorrelated_background([0.005], [28.0], [5.0]) + test_poi = 1.0 + data = [28.0] + model.config.auxdata + + test_results = pyhf.infer.hypotest( + test_poi, data, model, test_stat="qtilde", return_expected=True + ) + assert all(~np.isnan(result) for result in test_results) From c165c692af691115efa1a24264c5e192ced74f37 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Fri, 9 Sep 2022 20:44:49 +0200 Subject: [PATCH 13/13] fix: Pin codemetapy to v0.3.5 for `--no-extras` functionality (#1995) * Pin codemetapy to v0.3.5 in the 'current release' test workflow to keep the `--no-extras` CLI API option. - c.f. https://github.com/proycon/codemetapy/issues/24 * Update lower bounds for scipy and click in codemeta.json and add lower bounds for importlib-resources and typing-extensions. --- .github/workflows/release_tests.yml | 5 +++-- codemeta.json | 32 ++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release_tests.yml b/.github/workflows/release_tests.yml index df1f6d66e7..5eb47bff9f 100644 --- a/.github/workflows/release_tests.yml +++ b/.github/workflows/release_tests.yml @@ -42,8 +42,9 @@ jobs: run: | pytest tests/test_public_api.py + # FIXME: c.f. https://github.com/proycon/codemetapy/issues/24 - name: Verify requirements in codemeta.json run: | - python -m pip install jq "codemetapy>=0.3.4" + python -m pip install jq "codemetapy==0.3.5" codemetapy --no-extras pyhf > codemeta_generated.json - diff <(jq -S .softwareRequirements codemeta_generated.json) <(jq -S .softwareRequirements codemeta.json) + diff <(jq -S .softwareRequirements codemeta.json) <(jq -S .softwareRequirements codemeta_generated.json) diff --git a/codemeta.json b/codemeta.json index b51fac4378..4c8854cf84 100644 --- a/codemeta.json +++ b/codemeta.json @@ -44,7 +44,7 @@ "url": "https://pypi.org" }, "runtimePlatform": "Python 3", - "version": ">=1.4.1" + "version": ">=1.1.0" }, { "@type": "SoftwareApplication", @@ -57,7 +57,7 @@ "url": "https://pypi.org" }, "runtimePlatform": "Python 3", - "version": ">=7.0" + "version": ">=8.0.0" }, { "@type": "SoftwareApplication", @@ -110,6 +110,32 @@ }, "runtimePlatform": "Python 3", "version": ">=5.1" + }, + { + "@type": "SoftwareApplication", + "identifier": "importlib-resources", + "name": "importlib-resources", + "provider": { + "@id": "https://pypi.org", + "@type": "Organization", + "name": "The Python Package Index", + "url": "https://pypi.org" + }, + "runtimePlatform": "Python 3", + "version": ">=1.3.0" + }, + { + "@type": "SoftwareApplication", + "identifier": "typing-extensions", + "name": "typing-extensions", + "provider": { + "@id": "https://pypi.org", + "@type": "Organization", + "name": "The Python Package Index", + "url": "https://pypi.org" + }, + "runtimePlatform": "Python 3", + "version": ">=3.7.4.3" } ], "audience": [ @@ -129,5 +155,5 @@ "keywords": "physics fitting numpy scipy tensorflow pytorch jax", "developmentStatus": "4 - Beta", "applicationCategory": "Scientific/Engineering, Scientific/Engineering :: Physics", - "programmingLanguage": "Python 3, Python 3.7, Python 3.8, Python 3.9, Python 3.10, Python Implementation CPython" + "programmingLanguage": "Python 3, Python 3 Only, Python 3.7, Python 3.8, Python 3.9, Python 3.10, Python Implementation CPython" }