SumW2 gives unexpected errors in RooMCStudy

If I run fits with SumW2 errors, I get strange pulls for some distributions. If I run with default errors or with AsymptoticError, I recover the expected pulls (standard deviation = 1).


Reproducer:

# %%
import ROOT as r
from uncertainties import correlated_values_norm

# %%
ws = r.RooWorkspace("workspace")
x = ws.factory("x[0, 10]")
signal = ws.factory("Gaussian::signal(x, mean[3, 0, 10], sigma[2, 1, 10])")
background = ws.factory("Exponential::background(x, tau[-0.1, -10, 0])")
N_sig_m = ws.factory("N_sig_m[100, 0, 1000]")
N_bkg_m = ws.factory("N_bkg_m[100, 0, 1000]")
N_sig_p = ws.factory("N_sig_p[100, 0, 1000]")
N_bkg_p = ws.factory("N_bkg_p[100, 0, 1000]")
cat = r.RooCategory("charge", "charge", {"m": -1, "p": 1})
ws.Import(cat)
shpdict = {}
for c in dict(cat.states()).keys():
    shp = ws.factory(f"SUM::model_{c}(N_sig_{c}*signal, N_bkg_{c}*background)")
    shpdict[c] = shp.GetName()
catstr = ", ".join([f"{c}={s}" for c, s in shpdict.items()])
model = ws.factory(f"SIMUL::model({cat.GetName()}, {catstr})")


# %%
internal_constraints = {
    ws.factory(
        f"Poisson::{nm}_{c}_constraint({nm}_{c}, {ws.var(f'{nm}_{c}').getVal()})"
    )
    for nm in ("N_sig", "N_bkg")
    for c in dict(cat.states()).keys()
}
prodpdf = ws.factory(f"PROD::prodpdf(model, {','.join([c.GetName() for c in internal_constraints])})")

# %%
def do_mcstudy(errors: "str"):
    # internal_constraints needed, otherwise genParsDataSet won't be saved
    # https://github.com/root-project/root/issues/9490
    FitOptions = [
        r.RooFit.Extended(),
        r.RooFit.Save(),
        r.RooFit.Strategy(1),
    ]
    if errors == "SumW2":
        FitOptions.append(r.RooFit.SumW2Error(True))
    elif errors == "Asymptotic":
        FitOptions.append(r.RooFit.AsymptoticError(True))
    elif errors == "":
        pass
    else:
        raise ValueError(f"Unknown error type: {errors}")
    mcstudy = r.RooMCStudy(
        prodpdf,
        {x, cat},
        FitModel=model,
        Silence=True,
        Binned=True,
        Constrain={N_sig_m, N_bkg_m, N_sig_p, N_bkg_p},
        FitOptions=FitOptions,
    )
    mcstudy.generateAndFit(1_000, 0, True)
    return mcstudy

# %%
studies = {}
for err in ("", "SumW2", "Asymptotic"):
    studies[err] = do_mcstudy(err)

# %%
def asym(m, p):
    return (m - p) / (m + p)

# %%
def make_hist(mcstudy, nickname):
    h_pul = r.TH1F(f"h_pul_{nickname}", f"pulls for {nickname}", 100, -5, 5)
    gen_ds = mcstudy.genParDataSet()
    ifit = 0
    for igen in range(gen_ds.numEntries()):
        fitres = mcstudy.fitResult(igen)
        if fitres.status() != 0:
            continue
        genpars = gen_ds.get(igen)
        fitpars = mcstudy.fitParams(ifit)
        gen_a = asym(genpars["N_sig_m_gen"].getVal(), genpars["N_sig_p_gen"].getVal())
        corr = fitres.correlation(fitpars[f"N_sig_m"], fitpars[f"N_sig_p"])
        fit_m, fit_p = correlated_values_norm(
            [
                (fitpars[f"N_sig_m"].getVal(), fitpars[f"N_sig_m"].getError()),
                (fitpars[f"N_sig_p"].getVal(), fitpars[f"N_sig_p"].getError()),
            ],
            [[1, corr], [corr, 1]],
        )
        fit_a = asym(fit_m, fit_p)
        residual = fit_a - gen_a
        h_pul.Fill(residual.n / residual.s)
        ifit += 1
    return h_pul

# %%
c = r.TCanvas()
c.DivideSquare(len(studies))
h = {}
for i, (err, mcstudy) in enumerate(studies.items(), 1):
    c.cd(i)
    h[err] = hist = make_hist(mcstudy, err)
    hist.Draw()
    print(err)
    print(f"  mean: {hist.GetMean():.2f} +/- {hist.GetMeanError():.2f}")
    print(f"  std. dev.: {hist.GetStdDev():.2f} +/- {hist.GetStdDevError():.2f}")
c.Draw()

# %%
c.SaveAs("test.pdf")

Relevant output:

  mean: 0.02 +/- 0.03
  std. dev.: 1.03 +/- 0.02
SumW2
  mean: -0.01 +/- 0.02
  std. dev.: 0.68 +/- 0.02
Asymptotic
  mean: 0.08 +/- 0.03
  std. dev.: 1.00 +/- 0.02

test.pdf (18.1 KB)

Hi Michael,

Thanks for the post.
I add in the loop @moneta and @jonas . In the mean time, could you please be a bit more specific about the issue, pointing to the behaviour you believe is not expected?

Cheers,
Danilo

If the errors are calculated correctly, the standard deviation of the pulls should be 1. In this example, SumW2 errors results in just 0.7, whereas default and Asymptotic result in the expected width of 1.

Hello,

Is your data weighted ? It seems not to me, so this might explains the result you obtain.

Lorenzo

The data is generated by the RooMCStudy. Is it even possible for it to generated weighted* data?

*If I understand correctly, a RooDataHist technically does have “weights” equal to the contents of each bin and a number of entries equal to the number of bins. Is it ever appropriate to use SumW2Errors for a RooDataHist, even if the data was weighted before being binned?

Also, as confirmed in your answer #2 here, I thought SumW2Errors was not supposed affect the errors when fitting unweighted datasets…?

Hi,
Yes, SumW2Errors should not affect the errors if the data are unweighted (all weights=1).
It is probably a problem we need to investigate. Which ROOT version are you using it ?

ROOT Version: 6.28/04
Built for linuxx8664gcc on Jul 15 2023, 14:28:00

(conda-forge)

HI,

I am trying to reproduce your problem with th master, but I don’t have access to this function:
correlated_values_norm. Can you please also include it ?

Lorenzo

Apologies, here is a version without the external dependencies:

# %%
import ROOT as r

# %%
ws = r.RooWorkspace("workspace")
x = ws.factory("x[0, 10]")
signal = ws.factory("Gaussian::signal(x, mean[3, 0, 10], sigma[2, 1, 10])")
background = ws.factory("Exponential::background(x, tau[-0.1, -10, 0])")
N_sig_m = ws.factory("N_sig_m[100, 0, 1000]")
N_bkg_m = ws.factory("N_bkg_m[100, 0, 1000]")
N_sig_p = ws.factory("N_sig_p[100, 0, 1000]")
N_bkg_p = ws.factory("N_bkg_p[100, 0, 1000]")
cat = r.RooCategory("charge", "charge", {"m": -1, "p": 1})
ws.Import(cat)
shpdict = {}
for c in dict(cat.states()).keys():
    shp = ws.factory(f"SUM::model_{c}(N_sig_{c}*signal, N_bkg_{c}*background)")
    shpdict[c] = shp.GetName()
catstr = ", ".join([f"{c}={s}" for c, s in shpdict.items()])
model = ws.factory(f"SIMUL::model({cat.GetName()}, {catstr})")


# %%
internal_constraints = {
    ws.factory(
        f"Poisson::{nm}_{c}_constraint({nm}_{c}, {ws.var(f'{nm}_{c}').getVal()})"
    )
    for nm in ("N_sig", "N_bkg")
    for c in dict(cat.states()).keys()
}
prodpdf = ws.factory(f"PROD::prodpdf(model, {','.join([c.GetName() for c in internal_constraints])})")

# %%
def do_mcstudy(errors: "str"):
    # internal_constraints needed, otherwise genParsDataSet won't be saved
    # https://github.com/root-project/root/issues/9490
    FitOptions = [
        r.RooFit.Extended(),
        r.RooFit.Save(),
        r.RooFit.Strategy(1),
    ]
    if errors == "SumW2":
        FitOptions.append(r.RooFit.SumW2Error(True))
    elif errors == "Asymptotic":
        FitOptions.append(r.RooFit.AsymptoticError(True))
    elif errors == "":
        pass
    else:
        raise ValueError(f"Unknown error type: {errors}")
    mcstudy = r.RooMCStudy(
        prodpdf,
        {x, cat},
        FitModel=model,
        Silence=True,
        Binned=True,
        Constrain={N_sig_m, N_bkg_m, N_sig_p, N_bkg_p},
        FitOptions=FitOptions,
    )
    mcstudy.generateAndFit(1_000, 0, True)
    return mcstudy

# %%
studies = {}

# %%
studies[""] = do_mcstudy("")

# %%
studies["SumW2"] = do_mcstudy("SumW2")

# %%
studies["Asymptotic"] = do_mcstudy("Asymptotic")

# %%
def make_hist(mcstudy, nickname):
    h_pul = r.TH1F(f"h_pul_{nickname}", f"pulls for {nickname}", 100, -5, 5)
    gen_ds = mcstudy.genParDataSet()
    ifit = 0
    for igen in range(gen_ds.numEntries()):
        fitres = mcstudy.fitResult(igen)
        if fitres.status() != 0:
            continue
        genpars = gen_ds.get(igen)
        fitpars = mcstudy.fitParams(ifit)
        gen_r = genpars["N_sig_m_gen"].getVal() / genpars["N_sig_p_gen"].getVal()
        gen_a = (1 - gen_r) / (1 + gen_r)
        val_m, val_p = fitpars[f"N_sig_m"].getVal(), fitpars[f"N_sig_p"].getVal()
        corr = fitres.correlation(fitpars[f"N_sig_m"], fitpars[f"N_sig_p"])
        err_m, err_p = fitpars[f"N_sig_m"].getError(), fitpars[f"N_sig_p"].getError()
        val_r = val_m / val_p
        err_r = (
            val_r**2
            * (
                err_m**2 / val_m**2
                + err_p**2 / val_p**2
                - 2 * corr * err_m * err_p / (val_m * val_p)
            )
        ) ** 0.5
        val_a = (1 - val_r) / (1 + val_r)
        err_a = -2 * err_r / (1 + val_r) ** 2
        residual_value = val_a - gen_a
        residual_error = err_a
        h_pul.Fill(residual_value / residual_error)
        ifit += 1
    return h_pul

# %%
c = r.TCanvas()
c.DivideSquare(len(studies))
h = {}
for i, (err, mcstudy) in enumerate(studies.items(), 1):
    c.cd(i)
    h[err] = hist = make_hist(mcstudy, err)
    hist.Draw()
    print(err)
    print(f"  mean: {hist.GetMean():.2f} +/- {hist.GetMeanError():.2f}")
    print(f"  std. dev.: {hist.GetStdDev():.2f} +/- {hist.GetStdDevError():.2f}")
c.Draw()

# %%
c.SaveAs("test.pdf")

Relevant output:


  mean: 0.00 +/- 0.03
  std. dev.: 1.05 +/- 0.02
SumW2
  mean: -0.03 +/- 0.03
  std. dev.: 0.91 +/- 0.02
Asymptotic
  mean: 0.02 +/- 0.03
  std. dev.: 1.06 +/- 0.02

test.pdf (18.1 KB)

Looking better at your code, I see a inconsistency in the way the model is built.
If you add manually the Poisson constraints, the model should not be Extended when you generate and when you fitting. The other alternative is you remove the Poisson constraints and add Extended to the generation and fitting of the model.

Thank you for your updated code. I will try it

Lorenzo

I only have Extended in the fit options, not the creation of the RooMCStudy, so I’m not sure what the inconsistency is?

Hi, if you use Extended in the fit option, a Poisson constraint will be added to the model, but you have already a Poisson constraint specified explicitly. In this case you should remove the Extended FitOption. Or you add Extended in fit option (and also in generation, in the MCStudy option) and you remove the constraints from the model.

Lorenzo

Actually in your case you are doing a Binned fit. In RooFit there is no support for non-extended binned fit (multinomial likelihoods). You need then to remove your Poisson constraints from your model when doing a fit and a MC study.

Lorenzo

Thanks for your replies. I’m not quite sure I understand what you’re suggesting; here are a few clarifications:

  1. I have internal constraints on the yields within the pdf used to generate toy datasets because parameters without internal constraints are not saved. (See here.) It would be impossible for me to access the generated yields without constructing it this way. (These constraints are excluded from the fit pdf above.)
  2. I’m confused by your statement that using Extended in the FitOptions adds a Poisson fluctuation to the generated data. I thought one had to specify Extended in the RooMCStudy construction to get that behavior (or add it manually, as I’ve done).
  3. I’m extremely confused by your statement about non-extended binned fits. I cannot find any documentation on non-extended binned fits being unsupported, but anyway, I am doing an extended fit. (My fit model does not include internal constraints and is not a RooProdPdf, even though my generation model does and is.) Why do I need to remove the Poisson constraints?

Hi,
I am sorry for the confusion. Here are some clarifications:

  1. I was suggesting to remove the internal constraints and make the fit Extended and also the toy generation (adding the appropriate option to RooMCStudy). I understand the issue with the RooMCStudy and the generated yields, but in case of a model without explicit constraints there is no need to have a RooDataSet with generated parameters. The generated yields are part of the generated dataset.
  2. If you make the fit extended a Poisson constraints is added to your model. I never said that in that case one does not need to use the Extended option in RooMCStudy
  3. Here, apologies I was not very clear. You can have binned fit in ROOT not extended, but in your case when you define an extended pdf as in your model (a pdf that is the sum of signal and background using not fractions but number of events) you cannot have a fit not Extended (RooFit will report an error).

I think anyway you should remove the Poisson constraints because also I think is not correct to have a constraint on signal and background separately. You should have a Poisson constraint on their total (the total number of events), because this is what you observe and this constraint is added automatically by doing an extended fit.

However, I was trying your code, and it is true in 6,28 there is an issue when using Sumw2, but I don’t see it anymore in the master. This is what I get for the pulls with larger statistics:

  mean: 0.01 +/- 0.02
  std. dev.: 1.07 +/- 0.01
SumW2
  mean: 0.00 +/- 0.02
  std. dev.: 1.06 +/- 0.01
Asymptotic
  mean: 0.00 +/- 0.01
  std. dev.: 1.00 +/- 0.01

Best regards

Lorenzo

Is there a way to access the generated signal yield other than the way I am doing it?

Hi,

In your code you access, using RooMCStudy::genParDataSet, not the generated yields, but the generated constraint parameter values. In case of constraint you need to randomise these parameters, see line 357 in RooMCStudy.cxx.
When the constraints are NOT there, you don’t need to randomise parameters and for this reason RooMCStudy::genParDataSet returns a null pointer, the generated parameter values are always the same and they are the initial parameter of your model, that you can retrieve them using model.getParameters(observables).

Lorenzo

I see. I think you are suggesting something like

# %%
import ROOT as r

# %%
ws = r.RooWorkspace("workspace")
x = ws.factory("x[0, 10]")
signal = ws.factory("Gaussian::signal(x, mean[3, 0, 10], sigma[2, 1, 10])")
background = ws.factory("Exponential::background(x, tau[-0.1, -10, 0])")
cat = r.RooCategory("charge", "charge", {"m": -1, "p": 1})
ws.Import(cat)
shpdict = {}
for c in dict(cat.states()).keys():
    shp = ws.factory(
        f"SUM::model_{c}(N_sig_{c}[100, 0, 1000]*signal, N_bkg_{c}[100, 0, 1000]*background)"
    )
    shpdict[c] = shp.GetName()
catstr = ", ".join([f"{c}={s}" for c, s in shpdict.items()])
model = ws.factory(f"SIMUL::model({cat.GetName()}, {catstr})")

# %%
assert model.getParameters({x})["N_sig_m"] is ws.var("N_sig_m")

# %%
model_m, model_p = ws.var("N_sig_m").getVal(), ws.var("N_sig_p").getVal()
model_r = model_m / model_p
model_a = (1 - model_r) / (1 + model_r)
print(f"generated asymmetry: {model_a:.2e}")

# %%
mcstudy = r.RooMCStudy(
    model,
    {x, cat},
    FitModel=model,
    Silence=True,
    Binned=True,
    Extended=True,
    FitOptions=[
        r.RooFit.Extended(),
        r.RooFit.Save(),
        r.RooFit.Strategy(1),
    ],
)
n_studies = 1_000
mcstudy.generateAndFit(n_studies, 0, True)

# %%
h_res = r.TH1F("h_res", "residuals", 100, -1, 1)
h_pul = r.TH1F("h_pul", "pulls", 100, -5, 5)
ifit = 0
for igen in range(n_studies):
    fitres = mcstudy.fitResult(igen)
    if fitres.status() != 0:
        continue
    fitpars = mcstudy.fitParams(ifit)
    val_m, val_p = fitpars[f"N_sig_m"].getVal(), fitpars[f"N_sig_p"].getVal()
    corr = fitres.correlation(fitpars[f"N_sig_m"], fitpars[f"N_sig_p"])
    err_m, err_p = fitpars[f"N_sig_m"].getError(), fitpars[f"N_sig_p"].getError()
    val_r = val_m / val_p
    err_r = (
        val_r**2
        * (
            err_m**2 / val_m**2
            + err_p**2 / val_p**2
            - 2 * corr * err_m * err_p / (val_m * val_p)
        )
    ) ** 0.5
    val_a = (1 - val_r) / (1 + val_r)
    err_a = -2 * err_r / (1 + val_r) ** 2
    residual_value = val_a - model_a
    residual_error = err_a
    h_res.Fill(residual_value)
    h_pul.Fill(residual_value / residual_error)
    ifit += 1

# %%
c = r.TCanvas()
c.DivideSquare(2)
c.cd(1)
h_res.Draw()
print(f"residual mean: {h_res.GetMean():.2f} +/- {h_res.GetMeanError():.2f}")
print(f"residual std. dev.: {h_res.GetStdDev():.2f} +/- {h_res.GetStdDevError():.2f}")
c.cd(2)
h_pul.Draw()
print(f"pull mean: {h_pul.GetMean():.2f} +/- {h_pul.GetMeanError():.2f}")
print(f"pull std. dev.: {h_pul.GetStdDev():.2f} +/- {h_pul.GetStdDevError():.2f}")
c.Draw()

# %%
c.SaveAs("test.pdf")

where the asymmetry (recorded before running the study, since afterward the information is lost) is identical across all generated datasets even as the total number of events is subject to fluctuation.

This differs from what I was doing before, where the asymmetry itself varied (by subjecting the signal and background yields to independent Poisson fluctuations) between generated datasets. I think you are right–I actually want to hold the asymmetry static and vary only the total statistics of the events.

Thanks for walking me through all this.


Relevant output for posterity:

residual mean: -0.00 +/- 0.01
residual std. dev.: 0.19 +/- 0.00
pull mean: 0.03 +/- 0.03
pull std. dev.: 1.09 +/- 0.02

test.pdf (15.9 KB)