Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Experimental implementation of custom modifiers #1991

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ contrib = [
"matplotlib>=3.0.0",
"requests>=2.22.0",
]
experimental = ["numexpr>=2.8.0"]
backends = ["pyhf[tensorflow,torch,jax,minuit]"]
all = ["pyhf[backends,xmlio,contrib,shellcomplete]"]
all = ["pyhf[backends,xmlio,contrib,experimental,shellcomplete]"]

# Developer extras
test = [
Expand All @@ -105,7 +106,7 @@ test = [
"pytest-socket>=0.2.0", # c.f. PR #1917
]
docs = [
"pyhf[xmlio,contrib]",
"pyhf[xmlio,contrib,experimental]",
"sphinx>=7.0.0", # c.f. https://github.com/scikit-hep/pyhf/pull/2271
"sphinxcontrib-bibtex~=2.1",
"sphinx-click",
Expand Down Expand Up @@ -244,6 +245,7 @@ warn_unreachable = true
module = [
'jax.*',
'matplotlib.*',
'numexpr.*',
'scipy.*',
'tensorflow.*',
'tensorflow_probability.*',
Expand All @@ -262,6 +264,7 @@ module = [
'pyhf.cli.*',
'pyhf.modifiers.*',
'pyhf.exceptions.*',
'pyhf.experimental.*',
'pyhf.parameters.*',
'pyhf.schema.*',
'pyhf.writexml',
Expand Down
5 changes: 5 additions & 0 deletions src/pyhf/experimental/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
Experimental features for pyhf.

Modules in experimental may rapidly change with API breaking changes.
"""
181 changes: 181 additions & 0 deletions src/pyhf/experimental/modifiers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
from __future__ import annotations

import logging
from typing import Any, Callable, Sequence

import pyhf
from pyhf import events, get_backend
from pyhf.parameters import ParamViewer

log = logging.getLogger(__name__)

__all__ = ["add_custom_modifier"]


def __dir__():
return __all__

Check warning on line 16 in src/pyhf/experimental/modifiers.py

View check run for this annotation

Codecov / codecov/patch

src/pyhf/experimental/modifiers.py#L16

Added line #L16 was not covered by tests


try:
import numexpr as ne
except ModuleNotFoundError:
log.error(
"\nInstallation of the experimental extra is required to use pyhf.experimental.modifiers"
+ "\nPlease install with: python -m pip install 'pyhf[experimental]'\n",
exc_info=True,
)
raise


class BaseApplier:
...


class BaseBuilder:
...


def _allocate_new_param(
p: dict[str, Sequence[float]]
) -> dict[str, str | bool | int | Sequence[float]]:
return {
"paramset_type": "unconstrained",
"n_parameters": 1,
"is_shared": True,
"inits": p["inits"],
"bounds": p["bounds"],
"is_scalar": True,
"fixed": False,
}


def make_func(expression: str, deps: list[str]) -> Callable[[Sequence[float]], Any]:
def func(d: Sequence[float]) -> Any:
return ne.evaluate(expression, local_dict=dict(zip(deps, d)))

return func


def make_builder(
func_name: str, deps: list[str], new_params: dict[str, dict[str, Sequence[float]]]
) -> BaseBuilder:
class _builder(BaseBuilder):
is_shared = False

def __init__(self, config):
self.builder_data = {"funcs": {}}
self.config = config

def collect(self, thismod, nom):
maskval = bool(thismod)
mask = [maskval] * len(nom)
return {"mask": mask}

def append(self, key, channel, sample, thismod, defined_samp):
self.builder_data.setdefault(key, {}).setdefault(sample, {}).setdefault(
"data", {"mask": []}
)
nom = (
defined_samp["data"]
if defined_samp
else [0.0] * self.config.channel_nbins[channel]
)
moddata = self.collect(thismod, nom)
self.builder_data[key][sample]["data"]["mask"] += moddata["mask"]
if thismod:
if thismod["name"] != func_name:
print(thismod)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
print(thismod)

Copy link
Member

Choose a reason for hiding this comment

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

I've removed the prints in bb302c2 but if we need them back for debugging quickly just rever it.

self.builder_data["funcs"].setdefault(
thismod["name"], thismod["data"]["expr"]
)
self.required_parsets = {
k: [_allocate_new_param(v)] for k, v in new_params.items()
}

def finalize(self):
return self.builder_data

return _builder


def make_applier(
func_name: str, deps: list[str], new_params: dict[str, dict[str, Sequence[float]]]
) -> BaseApplier:
class _applier(BaseApplier):
name = func_name
op_code = "multiplication"

def __init__(self, modifiers, pdfconfig, builder_data, batch_size=None):
self.funcs = [make_func(v, deps) for v in builder_data["funcs"].values()]

self.batch_size = batch_size
pars_for_applier = deps
_modnames = [f"{mtype}/{m}" for m, mtype in modifiers]

parfield_shape = (
(self.batch_size, pdfconfig.npars)
if self.batch_size
else (pdfconfig.npars,)
)
self.param_viewer = ParamViewer(
parfield_shape, pdfconfig.par_map, pars_for_applier
)
self._custom_mod_mask = [
[[builder_data[modname][s]["data"]["mask"]] for s in pdfconfig.samples]
for modname in _modnames
]
self._precompute()
events.subscribe("tensorlib_changed")(self._precompute)

def _precompute(self):
tensorlib, _ = get_backend()
if not self.param_viewer.index_selection:
return

Check warning on line 133 in src/pyhf/experimental/modifiers.py

View check run for this annotation

Codecov / codecov/patch

src/pyhf/experimental/modifiers.py#L133

Added line #L133 was not covered by tests
self.custom_mod_mask = tensorlib.tile(
tensorlib.astensor(self._custom_mod_mask),
(1, 1, self.batch_size or 1, 1),
)
self.custom_mod_mask_bool = tensorlib.astensor(
self.custom_mod_mask, dtype="bool"
)
self.custom_mod_default = tensorlib.ones(self.custom_mod_mask.shape)

def apply(self, pars):
"""
Returns:
modification tensor: Shape (n_modifiers, n_global_samples, n_alphas, n_global_bin)
"""
if not self.param_viewer.index_selection:
return

Check warning on line 149 in src/pyhf/experimental/modifiers.py

View check run for this annotation

Codecov / codecov/patch

src/pyhf/experimental/modifiers.py#L149

Added line #L149 was not covered by tests
tensorlib, _ = get_backend()
if self.batch_size is None:
deps = self.param_viewer.get(pars)
print("deps", deps.shape)

Check warning on line 153 in src/pyhf/experimental/modifiers.py

View check run for this annotation

Codecov / codecov/patch

src/pyhf/experimental/modifiers.py#L152-L153

Added lines #L152 - L153 were not covered by tests
matthewfeickert marked this conversation as resolved.
Show resolved Hide resolved
results = tensorlib.astensor([f(deps) for f in self.funcs])
results = tensorlib.einsum(

Check warning on line 155 in src/pyhf/experimental/modifiers.py

View check run for this annotation

Codecov / codecov/patch

src/pyhf/experimental/modifiers.py#L155

Added line #L155 was not covered by tests
"msab,m->msab", self.custom_mod_mask, results
)
else:
deps = self.param_viewer.get(pars)
print("deps", deps.shape)
matthewfeickert marked this conversation as resolved.
Show resolved Hide resolved
results = tensorlib.astensor([f(deps) for f in self.funcs])
results = tensorlib.einsum(
"msab,ma->msab", self.custom_mod_mask, results
)
results = tensorlib.where(
self.custom_mod_mask_bool, results, self.custom_mod_default
)
return results

return _applier


def add_custom_modifier(
func_name: str, deps: list[str], new_params: dict[str, dict[str, Sequence[float]]]
) -> dict[str, tuple[BaseBuilder, BaseApplier]]:
_builder = make_builder(func_name, deps, new_params)
_applier = make_applier(func_name, deps, new_params)

modifier_set = {_applier.name: (_builder, _applier)}
modifier_set.update(**pyhf.modifiers.histfactory_set)
return modifier_set
94 changes: 94 additions & 0 deletions tests/test_experimental.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import sys
from importlib import reload
from unittest import mock

import pytest

import pyhf
import pyhf.experimental.modifiers


def test_missing_experimental_extra():
"""
Verify ModuleNotFoundError if dependencies required of the experimental
extra are not installed.
"""
with mock.patch.dict(sys.modules):
sys.modules["numexpr"] = None
with pytest.raises(ModuleNotFoundError):
reload(sys.modules["pyhf.experimental.modifiers"])


def test_add_custom_modifier(backend):
tensorlib, _ = backend

new_params = {
"m1": {"inits": (1.0,), "bounds": ((-5.0, 5.0),)},
"m2": {"inits": (1.0,), "bounds": ((-5.0, 5.0),)},
}

expanded_pyhf = pyhf.experimental.modifiers.add_custom_modifier(
"customfunc", ["m1", "m2"], new_params
)
model = pyhf.Model(
{
"channels": [
{
"name": "singlechannel",
"samples": [
{
"name": "signal",
"data": [10] * 20,
"modifiers": [
{
"name": "f2",
"type": "customfunc",
"data": {"expr": "m1"},
},
],
},
{
"name": "background",
"data": [100] * 20,
"modifiers": [
{
"name": "f1",
"type": "customfunc",
"data": {"expr": "m1+(m2**2)"},
},
],
},
],
}
]
},
modifier_set=expanded_pyhf,
poi_name="m1",
validate=False,
batch_size=1,
)

assert tensorlib.tolist(model.expected_actualdata([[1.0, 2.0]])) == [
[
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
510.0,
]
]