Repository: scikit-hep/pyhf

Issue:
XML ROOT Export
# Description

Given that we observe that some models still scale better in ROOT, it's reasonable to have some an companion tools to `readxml`, namely `writexml` that writes XML + ROOT files corresponding to a model given as JSON.




Discussion:
@kratsg this might be useful to comput published hf JSON in ROOT (e..g MBJ)
Yeah, this is going to be much harder to build unfortunately, but it's still possible. It's a lower priority for now.
uproot 3.0 is released that allows us to write XML+ROOT out now!

Resolving patch:
diff --git a/pyhf/commandline.py b/pyhf/commandline.py
--- a/pyhf/commandline.py
+++ b/pyhf/commandline.py
@@ -14,6 +14,14 @@
 logging.basicConfig()
 log = logging.getLogger(__name__)
 
+# This is only needed for Python 2/3 compatibility
+def ensure_dirs(path):
+    try:
+        os.makedirs(path, exist_ok=True)
+    except TypeError:
+        if not os.path.exists(path):
+            os.makedirs(path)
+
 
 @click.group(context_settings=dict(help_option_names=['-h', '--help']))
 @click.version_option(version=__version__)
@@ -61,16 +69,28 @@ def xml2json(entrypoint_xml, basedir, output_file, track_progress):
 
 @pyhf.command()
 @click.argument('workspace', default='-')
-@click.argument('xmlfile', default='-')
-@click.option('--specroot', default=click.Path(exists=True))
-@click.option('--dataroot', default=click.Path(exists=True))
-def json2xml(workspace, xmlfile, specroot, dataroot):
+@click.option('--output-dir', type=click.Path(exists=True), default='.')
+@click.option('--specroot', default='config')
+@click.option('--dataroot', default='data')
+@click.option('--resultprefix', default='FitConfig')
+def json2xml(workspace, output_dir, specroot, dataroot, resultprefix):
+    ensure_dirs(output_dir)
     with click.open_file(workspace, 'r') as specstream:
         d = json.load(specstream)
-        with click.open_file(xmlfile, 'w') as outstream:
+        ensure_dirs(os.path.join(output_dir, specroot))
+        ensure_dirs(os.path.join(output_dir, dataroot))
+        with click.open_file(
+            os.path.join(output_dir, '{0:s}.xml'.format(resultprefix)), 'w'
+        ) as outstream:
             outstream.write(
-                writexml.writexml(d, specroot, dataroot, '').decode('utf-8')
+                writexml.writexml(
+                    d,
+                    os.path.join(output_dir, specroot),
+                    os.path.join(output_dir, dataroot),
+                    resultprefix,
+                ).decode('utf-8')
             )
+
     sys.exit(0)
 
 
diff --git a/pyhf/readxml.py b/pyhf/readxml.py
--- a/pyhf/readxml.py
+++ b/pyhf/readxml.py
@@ -235,14 +235,15 @@ def process_measurements(toplvl):
                 overall_param_obj['value'] = param.attrib['Val']
 
             # might be specifying multiple parameters in the same ParamSetting
-            for param_name in param.text.split(' '):
-                # lumi will always be the first parameter
-                if param_name == 'Lumi':
-                    result['config']['parameters'][0].update(overall_param_obj)
-                else:
-                    param_obj = {'name': param_name}
-                    param_obj.update(overall_param_obj)
-                    result['config']['parameters'].append(param_obj)
+            if param.text:
+                for param_name in param.text.split(' '):
+                    # lumi will always be the first parameter
+                    if param_name == 'Lumi':
+                        result['config']['parameters'][0].update(overall_param_obj)
+                    else:
+                        param_obj = {'name': param_name}
+                        param_obj.update(overall_param_obj)
+                        result['config']['parameters'].append(param_obj)
         results.append(result)
     return results
 
diff --git a/pyhf/writexml.py b/pyhf/writexml.py
--- a/pyhf/writexml.py
+++ b/pyhf/writexml.py
@@ -1,45 +1,210 @@
+import logging
+
 import os
 import xml.etree.cElementTree as ET
+import numpy as np
+import uproot
+from uproot_methods.classes import TH1
+
+_ROOT_DATA_FILE = None
+
+log = logging.getLogger(__name__)
+
+
+def _make_hist_name(channel, sample, modifier='', prefix='hist', suffix=''):
+    return "{prefix}{middle}{suffix}".format(
+        prefix=prefix,
+        suffix=suffix,
+        middle='_'.join(filter(lambda x: x, [channel, sample, modifier])),
+    )
 
 
-def measurement(lumi, lumierr, poi, param_settings=None, name='Meas1'):
-    param_settings = param_settings or []
+def _export_root_histogram(histname, data):
+    h = TH1.from_numpy((np.asarray(data), np.arange(len(data) + 1)))
+    h._fName = histname
+    # NB: uproot crashes for some reason, figure out why later
+    # if histname in _ROOT_DATA_FILE:
+    #    raise KeyError('Duplicate key {0} being written.'.format(histname))
+    _ROOT_DATA_FILE[histname] = h
 
+
+# https://stackoverflow.com/a/4590052
+def indent(elem, level=0):
+    i = "\n" + level * "  "
+    if elem:
+        if not elem.text or not elem.text.strip():
+            elem.text = i + "  "
+        if not elem.tail or not elem.tail.strip():
+            elem.tail = i
+        for elem in elem:
+            indent(elem, level + 1)
+        if not elem.tail or not elem.tail.strip():
+            elem.tail = i
+    else:
+        if level and (not elem.tail or not elem.tail.strip()):
+            elem.tail = i
+
+
+def build_measurement(measurementspec):
+    config = measurementspec['config']
+    name = measurementspec['name']
+    poi = config['poi']
+
+    # we want to know which parameters are fixed (constant)
+    # and to additionally extract the luminosity information
+    fixed_params = []
+    lumi = 1.0
+    lumierr = 0.0
+    for parameter in config['parameters']:
+        if parameter.get('fixed', False):
+            pname = parameter['name']
+            if pname == 'lumi':
+                fixed_params.append('Lumi')
+            else:
+                fixed_params.append(pname)
+        # we found luminosity, so handle it
+        if parameter['name'] == 'lumi':
+            lumi = parameter['auxdata'][0]
+            lumierr = parameter['sigmas'][0]
+
+    # define measurement
     meas = ET.Element("Measurement", Name=name, Lumi=str(lumi), LumiRelErr=str(lumierr))
     poiel = ET.Element('POI')
     poiel.text = poi
     meas.append(poiel)
-    for s in param_settings:
-        se = ET.Element('ParamSetting', **s['attrs'])
-        se.text = ' '.join(s['params'])
+
+    # add fixed parameters (constant)
+    if fixed_params:
+        se = ET.Element('ParamSetting', Const='True')
+        se.text = ' '.join(fixed_params)
         meas.append(se)
     return meas
 
 
-def write_channel(channelspec, filename, data_rootdir):
-    # need to write channelfile here
-    with open(filename, 'w') as f:
-        channel = ET.Element('Channel', Name=channelspec['name'])
-        channel = ET.Element('Channel', Name=channelspec['name'])
-        f.write(ET.tostring(channel, encoding='utf-8').decode('utf-8'))
-    pass
+def build_modifier(modifierspec, channelname, samplename, sampledata):
+    if modifierspec['name'] == 'lumi':
+        return None
+    mod_map = {
+        'histosys': 'HistoSys',
+        'staterror': 'StatError',
+        'normsys': 'OverallSys',
+        'shapesys': 'ShapeSys',
+        'normfactor': 'NormFactor',
+        'shapefactor': 'ShapeFactor',
+    }
 
+    attrs = {'Name': modifierspec['name']}
+    if modifierspec['type'] == 'histosys':
+        attrs['HistoNameLow'] = _make_hist_name(
+            channelname, samplename, modifierspec['name'], suffix='Low'
+        )
+        attrs['HistoNameHigh'] = _make_hist_name(
+            channelname, samplename, modifierspec['name'], suffix='High'
+        )
+        _export_root_histogram(attrs['HistoNameLow'], modifierspec['data']['lo_data'])
+        _export_root_histogram(attrs['HistoNameHigh'], modifierspec['data']['hi_data'])
+    elif modifierspec['type'] == 'normsys':
+        attrs['High'] = str(modifierspec['data']['hi'])
+        attrs['Low'] = str(modifierspec['data']['lo'])
+    elif modifierspec['type'] == 'normfactor':
+        attrs['Val'] = '1'
+        attrs['High'] = '10'
+        attrs['Low'] = '0'
+    elif modifierspec['type'] == 'staterror':
+        attrs['Activate'] = 'True'
+        attrs['HistoName'] = _make_hist_name(
+            channelname, samplename, modifierspec['name']
+        )
+        # need to make this a relative uncertainty stored in ROOT file
+        _export_root_histogram(
+            attrs['HistoName'], np.divide(modifierspec['data'], sampledata).tolist()
+        )
+    elif modifierspec['type'] == 'shapesys':
+        attrs['ConstraintType'] = 'Poisson'
+        attrs['HistoName'] = _make_hist_name(
+            channelname, samplename, modifierspec['name']
+        )
+        # need to make this a relative uncertainty stored in ROOT file
+        _export_root_histogram(
+            attrs['HistoName'],
+            [np.divide(a, b) for a, b in zip(modifierspec['data'], sampledata)],
+        )
+    else:
+        log.warning(
+            'Skipping {0}({1}) for now'.format(
+                modifierspec['name'], modifierspec['type']
+            )
+        )
 
-def writexml(spec, specdir, data_rootdir, result_outputprefix):
-    combination = ET.Element("Combination", OutputFilePrefix=result_outputprefix)
+    modifier = ET.Element(mod_map[modifierspec['type']], **attrs)
+    return modifier
 
-    for c in spec['channels']:
-        channelfilename = os.path.join(specdir, 'channel_{}.xml'.format(c['name']))
-        write_channel(c, channelfilename, data_rootdir)
-        inp = ET.Element("Input")
-        inp.text = channelfilename
-        combination.append(inp)
 
-    m = measurement(
-        1,
-        0.1,
-        'SigXsecOverSM',
-        [{'attrs': {'Const': 'True'}, 'params': ['Lumi' 'alpha_syst1']}],
+def build_sample(samplespec, channelname):
+    histname = _make_hist_name(channelname, samplespec['name'])
+    attrs = {
+        'Name': samplespec['name'],
+        'HistoName': histname,
+        'InputFile': _ROOT_DATA_FILE._path,
+        'NormalizeByTheory': 'False',
+    }
+    sample = ET.Element('Sample', **attrs)
+    for modspec in samplespec['modifiers']:
+        # if lumi modifier added for this sample, need to set NormalizeByTheory
+        if modspec['type'] == 'lumi':
+            sample.attrib.update({'NormalizeByTheory': 'True'})
+        modifier = build_modifier(
+            modspec, channelname, samplespec['name'], samplespec['data']
+        )
+        if modifier is not None:
+            sample.append(modifier)
+    _export_root_histogram(histname, samplespec['data'])
+    return sample
+
+
+def build_data(dataspec, channelname):
+    histname = _make_hist_name(channelname, 'data')
+    data = ET.Element('Data', HistoName=histname, InputFile=_ROOT_DATA_FILE._path)
+    _export_root_histogram(histname, dataspec[channelname])
+    return data
+
+
+def build_channel(channelspec, dataspec):
+    channel = ET.Element(
+        'Channel', Name=channelspec['name'], InputFile=_ROOT_DATA_FILE._path
     )
-    combination.append(m)
+    if dataspec:
+        data = build_data(dataspec, channelspec['name'])
+        channel.append(data)
+    for samplespec in channelspec['samples']:
+        channel.append(build_sample(samplespec, channelspec['name']))
+    return channel
+
+
+def writexml(spec, specdir, data_rootdir, resultprefix):
+    global _ROOT_DATA_FILE
+
+    combination = ET.Element(
+        "Combination", OutputFilePrefix=os.path.join('.', specdir, resultprefix)
+    )
+
+    with uproot.recreate(os.path.join(data_rootdir, 'data.root')) as _ROOT_DATA_FILE:
+        for channelspec in spec['channels']:
+            channelfilename = os.path.join(
+                specdir, '{0:s}_{1:s}.xml'.format(resultprefix, channelspec['name'])
+            )
+            with open(channelfilename, 'w') as channelfile:
+                channel = build_channel(channelspec, spec.get('data'))
+                indent(channel)
+                channelfile.write(
+                    ET.tostring(channel, encoding='utf-8').decode('utf-8')
+                )
+
+            inp = ET.Element("Input")
+            inp.text = channelfilename
+            combination.append(inp)
+
+    for measurement in spec['toplvl']['measurements']:
+        combination.append(build_measurement(measurement))
+    indent(combination)
     return ET.tostring(combination, encoding='utf-8')


Test patch:
diff --git a/tests/test_export.py b/tests/test_export.py
new file mode 100644
--- /dev/null
+++ b/tests/test_export.py
@@ -0,0 +1,256 @@
+import pyhf
+import pyhf.writexml
+import pytest
+import json
+import xml.etree.cElementTree as ET
+
+
+def spec_staterror():
+    spec = {
+        'channels': [
+            {
+                'name': 'firstchannel',
+                'samples': [
+                    {
+                        'name': 'mu',
+                        'data': [10.0, 10.0],
+                        'modifiers': [
+                            {'name': 'mu', 'type': 'normfactor', 'data': None}
+                        ],
+                    },
+                    {
+                        'name': 'bkg1',
+                        'data': [50.0, 70.0],
+                        'modifiers': [
+                            {
+                                'name': 'stat_firstchannel',
+                                'type': 'staterror',
+                                'data': [12.0, 12.0],
+                            }
+                        ],
+                    },
+                    {
+                        'name': 'bkg2',
+                        'data': [30.0, 20.0],
+                        'modifiers': [
+                            {
+                                'name': 'stat_firstchannel',
+                                'type': 'staterror',
+                                'data': [5.0, 5.0],
+                            }
+                        ],
+                    },
+                    {'name': 'bkg3', 'data': [20.0, 15.0], 'modifiers': []},
+                ],
+            }
+        ]
+    }
+    return spec
+
+
+def spec_histosys():
+    source = json.load(open('validation/data/2bin_histosys_example2.json'))
+    spec = {
+        'channels': [
+            {
+                'name': 'singlechannel',
+                'samples': [
+                    {
+                        'name': 'signal',
+                        'data': source['bindata']['sig'],
+                        'modifiers': [
+                            {'name': 'mu', 'type': 'normfactor', 'data': None}
+                        ],
+                    },
+                    {
+                        'name': 'background',
+                        'data': source['bindata']['bkg'],
+                        'modifiers': [
+                            {
+                                'name': 'bkg_norm',
+                                'type': 'histosys',
+                                'data': {
+                                    'lo_data': source['bindata']['bkgsys_dn'],
+                                    'hi_data': source['bindata']['bkgsys_up'],
+                                },
+                            }
+                        ],
+                    },
+                ],
+            }
+        ]
+    }
+    return spec
+
+
+def spec_normsys():
+    source = json.load(open('validation/data/2bin_histosys_example2.json'))
+    spec = {
+        'channels': [
+            {
+                'name': 'singlechannel',
+                'samples': [
+                    {
+                        'name': 'signal',
+                        'data': source['bindata']['sig'],
+                        'modifiers': [
+                            {'name': 'mu', 'type': 'normfactor', 'data': None}
+                        ],
+                    },
+                    {
+                        'name': 'background',
+                        'data': source['bindata']['bkg'],
+                        'modifiers': [
+                            {
+                                'name': 'bkg_norm',
+                                'type': 'normsys',
+                                'data': {'lo': 0.9, 'hi': 1.1},
+                            }
+                        ],
+                    },
+                ],
+            }
+        ]
+    }
+    return spec
+
+
+def spec_shapesys():
+    source = json.load(open('validation/data/2bin_histosys_example2.json'))
+    spec = {
+        'channels': [
+            {
+                'name': 'singlechannel',
+                'samples': [
+                    {
+                        'name': 'signal',
+                        'data': source['bindata']['sig'],
+                        'modifiers': [
+                            {'name': 'mu', 'type': 'normfactor', 'data': None}
+                        ],
+                    },
+                    {
+                        'name': 'background',
+                        'data': source['bindata']['bkg'],
+                        'modifiers': [
+                            {'name': 'bkg_norm', 'type': 'shapesys', 'data': [10, 10]}
+                        ],
+                    },
+                ],
+            }
+        ]
+    }
+    return spec
+
+
+def test_export_measurement():
+    measurementspec = {
+        "config": {
+            "parameters": [
+                {
+                    "auxdata": [1.0],
+                    "bounds": [[0.855, 1.145]],
+                    "inits": [1.0],
+                    "name": "lumi",
+                    "sigmas": [0.029],
+                }
+            ],
+            "poi": "mu",
+        },
+        "name": "NormalMeasurement",
+    }
+    m = pyhf.writexml.build_measurement(measurementspec)
+    assert m is not None
+    assert m.attrib['Name'] == measurementspec['name']
+    assert m.attrib['Lumi'] == str(
+        measurementspec['config']['parameters'][0]['auxdata'][0]
+    )
+    assert m.attrib['LumiRelErr'] == str(
+        measurementspec['config']['parameters'][0]['sigmas'][0]
+    )
+    poi = m.find('POI')
+    assert poi is not None
+    assert poi.text == measurementspec['config']['poi']
+    paramsetting = m.find('ParamSetting')
+    assert paramsetting is None
+
+
+@pytest.mark.parametrize(
+    "spec, has_root_data, attrs",
+    [
+        (spec_staterror(), True, ['Activate', 'HistoName']),
+        (spec_histosys(), True, ['HistoNameHigh', 'HistoNameLow']),
+        (spec_normsys(), False, ['High', 'Low']),
+        (spec_shapesys(), True, ['ConstraintType', 'HistoName']),
+    ],
+    ids=['staterror', 'histosys', 'normsys', 'shapesys'],
+)
+def test_export_modifier(mocker, spec, has_root_data, attrs):
+    channelspec = spec['channels'][0]
+    channelname = channelspec['name']
+    samplespec = channelspec['samples'][1]
+    samplename = samplespec['name']
+    sampledata = samplespec['data']
+    modifierspec = samplespec['modifiers'][0]
+
+    mocker.patch('pyhf.writexml._ROOT_DATA_FILE')
+    modifier = pyhf.writexml.build_modifier(
+        modifierspec, channelname, samplename, sampledata
+    )
+    assert modifier.attrib['Name'] == modifierspec['name']
+    assert all(attr in modifier.attrib for attr in attrs)
+    assert pyhf.writexml._ROOT_DATA_FILE.__setitem__.called == has_root_data
+
+
+@pytest.mark.parametrize(
+    "spec",
+    [spec_staterror(), spec_histosys(), spec_normsys(), spec_shapesys()],
+    ids=['staterror', 'histosys', 'normsys', 'shapesys'],
+)
+def test_export_sample(mocker, spec):
+    channelspec = spec['channels'][0]
+    channelname = channelspec['name']
+    samplespec = channelspec['samples'][1]
+    samplename = samplespec['name']
+    sampledata = samplespec['data']
+
+    mocker.patch('pyhf.writexml.build_modifier', return_value=ET.Element("Modifier"))
+    mocker.patch('pyhf.writexml._ROOT_DATA_FILE')
+    sample = pyhf.writexml.build_sample(samplespec, channelname)
+    assert sample.attrib['Name'] == samplespec['name']
+    assert sample.attrib['HistoName']
+    assert sample.attrib['InputFile']
+    assert sample.attrib['NormalizeByTheory'] == str(False)
+    assert pyhf.writexml.build_modifier.called
+    assert pyhf.writexml._ROOT_DATA_FILE.__setitem__.called
+
+
+@pytest.mark.parametrize(
+    "spec",
+    [spec_staterror(), spec_histosys(), spec_normsys(), spec_shapesys()],
+    ids=['staterror', 'histosys', 'normsys', 'shapesys'],
+)
+def test_export_channel(mocker, spec):
+    channelspec = spec['channels'][0]
+    channelname = channelspec['name']
+
+    mocker.patch('pyhf.writexml.build_data', return_value=ET.Element("Data"))
+    mocker.patch('pyhf.writexml.build_sample', return_value=ET.Element("Sample"))
+    mocker.patch('pyhf.writexml._ROOT_DATA_FILE')
+    channel = pyhf.writexml.build_channel(channelspec, {})
+    assert channel.attrib['Name'] == channelspec['name']
+    assert channel.attrib['InputFile']
+    assert pyhf.writexml.build_data.called is False
+    assert pyhf.writexml.build_sample.called
+    assert pyhf.writexml._ROOT_DATA_FILE.__setitem__.called is False
+
+
+def test_export_data(mocker):
+    channelname = 'channel'
+    dataspec = {channelname: [0, 1, 2, 3]}
+
+    mocker.patch('pyhf.writexml._ROOT_DATA_FILE')
+    data = pyhf.writexml.build_data(dataspec, channelname)
+    assert data.attrib['HistoName']
+    assert data.attrib['InputFile']
+    assert pyhf.writexml._ROOT_DATA_FILE.__setitem__.called
diff --git a/tests/test_scripts.py b/tests/test_scripts.py
--- a/tests/test_scripts.py
+++ b/tests/test_scripts.py
@@ -106,8 +106,8 @@ def test_import_and_export(tmpdir, script_runner):
     )
     ret = script_runner.run(*shlex.split(command))
 
-    command = 'pyhf json2xml {0:s} --specroot {1:s} --dataroot {1:s}'.format(
-        temp.strpath, str(tmpdir)
+    command = 'pyhf json2xml {0:s} --output-dir {1:s}'.format(
+        temp.strpath, tmpdir.mkdir('output').strpath
     )
     ret = script_runner.run(*shlex.split(command))
     assert ret.success
diff --git a/tests/test_validation.py b/tests/test_validation.py
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -1,6 +1,8 @@
 import pyhf
+import pyhf.writexml, pyhf.readxml
 import json
 import pytest
+import os
 
 
 @pytest.fixture(scope='module')
@@ -723,6 +725,7 @@ def validate_hypotest(pdf, data, mu_test, expected_result, tolerance=1e-6):
 def test_validation(setup_and_tolerance):
     setup, tolerance = setup_and_tolerance
     source = setup['source']
+
     pdf = pyhf.Model(setup['spec'])
 
     if 'channels' in source:
@@ -743,3 +746,96 @@ def test_validation(setup_and_tolerance):
     validate_hypotest(
         pdf, data, setup['mu'], setup['expected']['result'], tolerance=tolerance
     )
+
+
+@pytest.mark.parametrize(
+    'toplvl, basedir',
+    [
+        (
+            'validation/xmlimport_input/config/example.xml',
+            'validation/xmlimport_input/',
+        ),
+        (
+            'validation/xmlimport_input2/config/example.xml',
+            'validation/xmlimport_input2',
+        ),
+        (
+            'validation/xmlimport_input3/config/examples/example_ShapeSys.xml',
+            'validation/xmlimport_input3',
+        ),
+    ],
+    ids=['example-one', 'example-two', 'example-three'],
+)
+def test_import_roundtrip(tmpdir, toplvl, basedir):
+    parsed_xml_before = pyhf.readxml.parse(toplvl, basedir)
+    spec = {
+        'channels': parsed_xml_before['channels'],
+        'parameters': parsed_xml_before['toplvl']['measurements'][0]['config'][
+            'parameters'
+        ],
+    }
+    pdf_before = pyhf.Model(spec, poiname='SigXsecOverSM')
+
+    tmpconfig = tmpdir.mkdir('config')
+    tmpdata = tmpdir.mkdir('data')
+    tmpxml = tmpdir.join('FitConfig.xml')
+    tmpxml.write(
+        pyhf.writexml.writexml(
+            parsed_xml_before,
+            tmpconfig.strpath,
+            tmpdata.strpath,
+            os.path.join(tmpdir.strpath, 'FitConfig'),
+        ).decode('utf-8')
+    )
+    parsed_xml_after = pyhf.readxml.parse(tmpxml.strpath, tmpdir.strpath)
+    spec = {
+        'channels': parsed_xml_after['channels'],
+        'parameters': parsed_xml_after['toplvl']['measurements'][0]['config'][
+            'parameters'
+        ],
+    }
+    pdf_after = pyhf.Model(spec, poiname='SigXsecOverSM')
+
+    data_before = [
+        binvalue
+        for k in pdf_before.spec['channels']
+        for binvalue in parsed_xml_before['data'][k['name']]
+    ] + pdf_before.config.auxdata
+
+    data_after = [
+        binvalue
+        for k in pdf_after.spec['channels']
+        for binvalue in parsed_xml_after['data'][k['name']]
+    ] + pdf_after.config.auxdata
+
+    assert data_before == data_after
+
+    init_pars_before = pdf_before.config.suggested_init()
+    init_pars_after = pdf_after.config.suggested_init()
+    assert init_pars_before == init_pars_after
+
+    par_bounds_before = pdf_before.config.suggested_bounds()
+    par_bounds_after = pdf_after.config.suggested_bounds()
+    assert par_bounds_before == par_bounds_after
+
+    CLs_obs_before, CLs_exp_set_before = pyhf.utils.hypotest(
+        1,
+        data_before,
+        pdf_before,
+        init_pars_before,
+        par_bounds_before,
+        return_expected_set=True,
+    )
+    CLs_obs_after, CLs_exp_set_after = pyhf.utils.hypotest(
+        1,
+        data_after,
+        pdf_after,
+        init_pars_after,
+        par_bounds_after,
+        return_expected_set=True,
+    )
+
+    tolerance = 1e-6
+    assert abs(CLs_obs_after - CLs_obs_before) / CLs_obs_before < tolerance
+    for result, expected_result in zip(CLs_exp_set_after, CLs_exp_set_before):
+        assert abs(result - expected_result) / expected_result < tolerance