Skip to content

Commit

Permalink
feat: Add setup for custom modifiers (#1625)
Browse files Browse the repository at this point in the history
* This API breaking change allows for the start of addition of custom
modifiers.
* `pyhf.workspace.Workspace.parameters` are fully removed from the
API through removal from `pyhf.mixins`. Instead, parameters is added
as an attribute to the model config.
* Remove the modifier registry and in place add {modifier}_builder
classes for the modifiers. Additionally add
pyhf.modifiers.histfactory_set which is the map of all the
predefined modifiers that HistFactory specifies.
* All workspace parameters related tests are removed and tests are
added for the change to model.config
* The change from workspace.parameters to model.config.parameters
is made in all affected example notebooks.
* Apply a tiny relative tolerance change in the regression tests to
increase to rel=1.5e-5. The resulting tolerance is on the order of 1e-7
and so the change is inconsequential.
  • Loading branch information
lukasheinrich authored Oct 15, 2021
1 parent 121903b commit 6f4ca1a
Show file tree
Hide file tree
Showing 22 changed files with 706 additions and 752 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@
}
],
"source": [
"print(f'Samples:\\n {workspace.samples}')\n",
"print(f'Parameters:\\n {workspace.parameters}')"
"print(f'Samples:\\n {pdf.config.samples}')\n",
"print(f'Parameters:\\n {pdf.config.parameters}')"
]
},
{
Expand Down
21 changes: 12 additions & 9 deletions src/pyhf/cli/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from pyhf.workspace import Workspace
from pyhf import modifiers
from pyhf import parameters
from pyhf import utils

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -72,14 +73,16 @@ def inspect(workspace, output_file, measurement):
]
result['modifiers'] = dict(ws.modifiers)

parset_descr = {
parameters.paramsets.unconstrained: 'unconstrained',
parameters.paramsets.constrained_by_normal: 'constrained_by_normal',
parameters.paramsets.constrained_by_poisson: 'constrained_by_poisson',
}

model = ws.model()

result['parameters'] = sorted(
(
parname,
modifiers.registry[result['modifiers'][parname]]
.required_parset([], [])['paramset_type']
.__name__,
)
for parname in ws.parameters
(k, parset_descr[type(v['paramset'])]) for k, v in model.config.par_map.items()
)
result['systematics'] = [
(
Expand All @@ -97,7 +100,7 @@ def inspect(workspace, output_file, measurement):

maxlen_channels = max(map(len, ws.channels))
maxlen_samples = max(map(len, ws.samples))
maxlen_parameters = max(map(len, ws.parameters))
maxlen_parameters = max(map(len, [p for p, _ in result['parameters']]))
maxlen_measurements = max(map(lambda x: len(x[0]), result['measurements']))
maxlen = max(
[maxlen_channels, maxlen_samples, maxlen_parameters, maxlen_measurements]
Expand Down Expand Up @@ -174,7 +177,7 @@ def inspect(workspace, output_file, measurement):
'--modifier-type',
default=[],
multiple=True,
type=click.Choice(modifiers.uncombined.keys()),
type=click.Choice(modifiers.histfactory_set.keys()),
)
@click.option('--measurement', default=[], multiple=True, metavar='<MEASUREMENT>...')
def prune(
Expand Down
2 changes: 1 addition & 1 deletion src/pyhf/infer/calculators.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ def expected_value(self, nsigma):
>>> samples = normal.sample((100,))
>>> dist = pyhf.infer.calculators.EmpiricalDistribution(samples)
>>> dist.expected_value(nsigma=1)
6.15094381209505
6.15094381209...
>>> import pyhf
>>> import numpy.random as random
Expand Down
3 changes: 0 additions & 3 deletions src/pyhf/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.channels = []
self.samples = []
self.parameters = []
self.modifiers = []
# keep track of the width of each channel (how many bins)
self.channel_nbins = {}
Expand All @@ -30,7 +29,6 @@ def __init__(self, *args, **kwargs):
for sample in channel['samples']:
self.samples.append(sample['name'])
for modifier_def in sample['modifiers']:
self.parameters.append(modifier_def['name'])
self.modifiers.append(
(
modifier_def['name'], # mod name
Expand All @@ -40,7 +38,6 @@ def __init__(self, *args, **kwargs):

self.channels = sorted(list(set(self.channels)))
self.samples = sorted(list(set(self.samples)))
self.parameters = sorted(list(set(self.parameters)))
self.modifiers = sorted(list(set(self.modifiers)))
self.channel_nbins = {
channel: self.channel_nbins[channel] for channel in self.channels
Expand Down
219 changes: 26 additions & 193 deletions src/pyhf/modifiers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,214 +1,47 @@
import logging

from pyhf import exceptions
from pyhf import get_backend

log = logging.getLogger(__name__)

registry = {}


def validate_modifier_structure(modifier):
"""
Check if given object contains the right structure for modifiers
"""
required_methods = ['required_parset']

for method in required_methods:
if not hasattr(modifier, method):
raise exceptions.InvalidModifier(
f'Expected {method:s} method on modifier {modifier.__name__:s}'
)
return True


def add_to_registry(
cls, cls_name=None, constrained=False, pdf_type='normal', op_code='addition'
):
"""
Consistent add_to_registry() function that handles actually adding thing to the registry.
Raises an error if the name to register for the modifier already exists in the registry,
or if the modifier does not have the right structure.
"""
global registry
cls_name = cls_name or cls.__name__
if cls_name in registry:
raise KeyError(f'The modifier name "{cls_name:s}" is already taken.')
# validate the structure
validate_modifier_structure(cls)
# set is_constrained
cls.is_constrained = constrained
if constrained:
tensorlib, _ = get_backend()
if not hasattr(tensorlib, pdf_type):
raise exceptions.InvalidModifier(
f'The specified pdf_type "{pdf_type:s}" is not valid for {cls_name:s}({cls.__name__:s}). See pyhf.tensor documentation for available pdfs.'
)
cls.pdf_type = pdf_type
else:
cls.pdf_type = None

if op_code not in ['addition', 'multiplication']:
raise exceptions.InvalidModifier(
f'The specified op_code "{op_code:s}" is not valid for {cls_name:s}({cls.__name__:s}). See pyhf.modifier documentation for available operation codes.'
)
cls.op_code = op_code

registry[cls_name] = cls


def modifier(*args, **kwargs):
"""
Decorator for registering modifiers. To flag the modifier as a constrained modifier, add `constrained=True`.
Args:
name (:obj:`str`): the name of the modifier to use. Use the class name by default. (default: None)
constrained (:obj:`bool`): whether the modifier is constrained or not. (default: False)
pdf_type (:obj:`str): the name of the pdf to use from tensorlib if constrained. (default: normal)
op_code (:obj:`str`): the name of the operation the modifier performs on the data (e.g. addition, multiplication)
Returns:
modifier
Raises:
ValueError: too many keyword arguments, or too many arguments, or wrong arguments
TypeError: provided name is not a string
pyhf.exceptions.InvalidModifier: object does not have necessary modifier structure
"""
#
# Examples:
#
# >>> @modifiers.modifier
# >>> ... class myCustomModifier(object):
# >>> ... @classmethod
# >>> ... def required_parset(cls, sample_data, modifier_data): pass
#
# >>> @modifiers.modifier(name='myCustomNamer')
# >>> ... class myCustomModifier(object):
# >>> ... @classmethod
# >>> ... def required_parset(cls, sample_data, modifier_data): pass
#
# >>> @modifiers.modifier(constrained=False)
# >>> ... class myUnconstrainedModifier(object):
# >>> ... @classmethod
# >>> ... def required_parset(cls, sample_data, modifier_data): pass
# >>> ...
# >>> myUnconstrainedModifier.pdf_type
# None
#
# >>> @modifiers.modifier(constrained=True, pdf_type='poisson')
# >>> ... class myConstrainedCustomPoissonModifier(object):
# >>> ... @classmethod
# >>> ... def required_parset(cls, sample_data, modifier_data): pass
# >>> ...
# >>> myConstrainedCustomGaussianModifier.pdf_type
# 'poisson'
#
# >>> @modifiers.modifier(constrained=True)
# >>> ... class myCustomModifier(object):
# >>> ... @classmethod
# >>> ... def required_parset(cls, sample_data, modifier_data): pass
#
# >>> @modifiers.modifier(op_code='multiplication')
# >>> ... class myMultiplierModifier(object):
# >>> ... @classmethod
# >>> ... def required_parset(cls, sample_data, modifier_data): pass
# >>> ...
# >>> myMultiplierModifier.op_code
# 'multiplication'

def _modifier(name, constrained, pdf_type, op_code):
def wrapper(cls):
add_to_registry(
cls,
cls_name=name,
constrained=constrained,
pdf_type=pdf_type,
op_code=op_code,
)
return cls

return wrapper

name = kwargs.pop('name', None)
constrained = bool(kwargs.pop('constrained', False))
pdf_type = str(kwargs.pop('pdf_type', 'normal'))
op_code = str(kwargs.pop('op_code', 'addition'))
# check for unparsed keyword arguments
if kwargs:
raise ValueError(f'Unparsed keyword arguments {kwargs.keys()}')
# check to make sure the given name is a string, if passed in one
if not isinstance(name, str) and name is not None:
raise TypeError(f'@modifier must be given a string. You gave it {type(name)}')

if not args:
# called like @modifier(name='foo', constrained=False, pdf_type='normal', op_code='addition')
return _modifier(name, constrained, pdf_type, op_code)
if len(args) == 1:
# called like @modifier
if not callable(args[0]):
raise ValueError('You must decorate a callable python object')
add_to_registry(
args[0],
cls_name=name,
constrained=constrained,
pdf_type=pdf_type,
op_code=op_code,
)
return args[0]
raise ValueError(
f'@modifier must be called with only keyword arguments, @modifier(name=\'foo\'), or no arguments, @modifier; ({len(args):d} given)'
)


from pyhf.modifiers.histosys import histosys, histosys_combined
from pyhf.modifiers.lumi import lumi, lumi_combined
from pyhf.modifiers.normfactor import normfactor, normfactor_combined
from pyhf.modifiers.normsys import normsys, normsys_combined
from pyhf.modifiers.shapefactor import shapefactor, shapefactor_combined
from pyhf.modifiers.shapesys import shapesys, shapesys_combined
from pyhf.modifiers.staterror import staterror, staterror_combined

uncombined = {
'histosys': histosys,
'lumi': lumi,
'normfactor': normfactor,
'normsys': normsys,
'shapefactor': shapefactor,
'shapesys': shapesys,
'staterror': staterror,
}

combined = {
'histosys': histosys_combined,
'lumi': lumi_combined,
'normfactor': normfactor_combined,
'normsys': normsys_combined,
'shapefactor': shapefactor_combined,
'shapesys': shapesys_combined,
'staterror': staterror_combined,
}
from pyhf.modifiers.histosys import histosys_builder, histosys_combined
from pyhf.modifiers.lumi import lumi_builder, lumi_combined
from pyhf.modifiers.normfactor import normfactor_builder, normfactor_combined
from pyhf.modifiers.normsys import normsys_builder, normsys_combined
from pyhf.modifiers.shapefactor import shapefactor_builder, shapefactor_combined
from pyhf.modifiers.shapesys import shapesys_builder, shapesys_combined
from pyhf.modifiers.staterror import staterror_builder, staterror_combined

__all__ = [
"combined",
"histfactory_set",
"histosys",
"histosys_builder",
"histosys_combined",
"lumi",
"lumi_builder",
"lumi_combined",
"normfactor",
"normfactor_builder",
"normfactor_combined",
"normsys",
"normsys_builder",
"normsys_combined",
"shapefactor",
"shapefactor_builder",
"shapefactor_combined",
"shapesys",
"shapesys_builder",
"shapesys_combined",
"staterror",
"staterror_builder",
"staterror_combined",
]


def __dir__():
return __all__


histfactory_set = {
"histosys": (histosys_builder, histosys_combined),
"lumi": (lumi_builder, lumi_combined),
"normfactor": (normfactor_builder, normfactor_combined),
"normsys": (normsys_builder, normsys_combined),
"shapefactor": (shapefactor_builder, shapefactor_combined),
"shapesys": (shapesys_builder, shapesys_combined),
"staterror": (staterror_builder, staterror_combined),
}
Loading

0 comments on commit 6f4ca1a

Please sign in to comment.