Possible bug in 2D multi-range extended fit with EvalBackend("cpu")

Possible bug in 2D multi-range extended fit with EvalBackend("cpu")

Hi,

I am observing a discrepancy between the cpu and legacy RooFit backends when performing an extended fit in multiple disjoint ranges in 2D.

The issue is reproducible with a completely standalone script, without any external input files.

I tested it with:

  • ROOT 6.36.06

  • ROOT 6.40.02

and I obtain exactly the same behaviour in both versions.

The full-range fit gives identical results with the two backends, while the multi-range sideband fit gives very different fitted yields.

Here is the minimal reproducer:

import ROOT


def main():
    ROOT.gROOT.SetBatch(True)
    ROOT.RooMsgService.instance().setGlobalKillBelow(ROOT.RooFit.ERROR)
    ROOT.RooRandom.randomGenerator().SetSeed(12345)

    ws = ROOT.RooWorkspace("ws")

    for expression in (
        "x[-5,5]",
        "y[-5,5]",
        "Gaussian::gx(x,mu[0],sigma[1])",
        "Gaussian::gy(y,mu,sigma)",
        "PROD::g(gx,gy)",
        "Uniform::u({x,y})",
        "SUM::model(ng[8000,0,30000]*g,nu[2000,0,30000]*u)",
    ):
        ws.factory(expression)

    obs = ROOT.RooArgSet(ws.var("x"), ws.var("y"))

    bounds = [(-5, -2), (-2, 2), (2, 5)]
    ranges = []

    for i, x_bounds in enumerate(bounds):
        for j, y_bounds in enumerate(bounds):
            if i == 1 and j == 1:
                continue

            name = f"sb{i}{j}"
            ranges.append(name)

            ws.var("x").setRange(name, *x_bounds)
            ws.var("y").setRange(name, *y_bounds)

    data = ws.pdf("model").generate(obs, 15000)

    n_sideband = data.sumEntries("abs(x)>2 || abs(y)>2")

    print(
        f"ROOT {ROOT.gROOT.GetVersion()}: "
        f"full={data.numEntries()}, SB={n_sideband}",
        flush=True,
    )

    results = {}

    for restricted in (False, True):
        for backend in ("cpu", "legacy"):

            # Fresh copy prevents a preceding fit from changing range settings.
            fit_ws = ws.Clone()
            model = fit_ws.pdf("model")

            options = (
                [ROOT.RooFit.Range(",".join(ranges))]
                if restricted else []
            )

            result = model.fitTo(
                data,
                ROOT.RooFit.Extended(True),
                ROOT.RooFit.Save(),
                ROOT.RooFit.PrintLevel(-1),
                ROOT.RooFit.EvalBackend(backend),
                *options,
            )

            yields = [
                fit_ws.var(n).getVal()
                for n in ("ng", "nu")
            ]

            fit_obs = ROOT.RooArgSet(
                fit_ws.var("x"),
                fit_ws.var("y"),
            )

            fractions = [
                sum(
                    fit_ws.pdf(pdf).createIntegral(
                        fit_obs,
                        ROOT.RooFit.NormSet(fit_obs),
                        ROOT.RooFit.Range(r),
                    ).getVal()
                    for r in ranges
                )
                for pdf in ("g", "u")
            ]

            expected_sb = sum(
                n * fraction
                for n, fraction in zip(yields, fractions)
            )

            label = "sidebands" if restricted else "full"

            print(
                f"{label:9s} {backend:6s}: "
                f"N_g={yields[0]:.3f}, "
                f"N_u={yields[1]:.3f}, "
                f"expected_SB={expected_sb:.3f}, "
                f"status={result.status()}, "
                f"covQual={result.covQual()}",
                flush=True,
            )

            results[restricted, backend] = yields


if __name__ == "__main__":
    main()

With ROOT 6.40.02 I get:

ROOT 6.40.02: full=15000, SB=3431.0

full      cpu   : N_g=12146.553, N_u=2854.253,
                   expected_SB=3477.756,
                   status=0, covQual=3

full      legacy: N_g=12146.553, N_u=2854.253,
                   expected_SB=3477.756,
                   status=0, covQual=3

sidebands cpu   : N_g=2818.327, N_u=729.358,
                   expected_SB=863.292,
                   status=0, covQual=3

sidebands legacy: N_g=11199.522, N_u=2899.005,
                   expected_SB=3431.129,
                   status=0, covQual=3

The full-range fits agree perfectly.

For the sideband fit, however, the legacy backend gives

expected_SB = 3431.129

which agrees with the actual number of events in the sidebands,

N_SB = 3431

while the cpu backend gives

expected_SB = 863.292.

Interestingly, the fitted yields differ essentially by a common scale factor:

11199.522 / 2818.327 ≈ 3.97
2899.005  / 729.358  ≈ 3.97

so the discrepancy looks more like a normalization / extended-term issue than a different likelihood minimum.

Both fits report

status = 0
covQual = 3

and the discrepancy is still present in ROOT 6.40.02.

Is this expected behaviour for Extended(True) combined with a comma-separated multi-range fit in more than one dimension, or is this a bug in the cpu backend normalization?

In my understanding, the fitted extended coefficients should correspond consistently to the full-range yields, with the expected number of events in the fitted sideband region obtained from the component integrals over the selected ranges.

Thanks!

Thanks for the report! It’s important that these regressions get reported, before the legacy backend is removed, so I really appreciate this.

I opened a PR to fix it, and will backport the fix to the 6.40 release branch so it will be in the next 6.40 patch release:

Hey,

The result is quite clear: the behaviour reported here was a real bug in the cpu backend’s extended-term normalization, not expected behaviour.

The original reproducer is now covered by root-project/root#23512 and has been fixed in current ROOT master. The fix is also present on the current v6-40-00-patches branch.

Below is the complete analytical explanation of both the observed factor of about 3.97 and the source-level normalization error.

1. Correct sideband fractions

The fitted region is the union of the eight boxes of the 3x3 grid, excluding the central square

[-2,2]\times[-2,2].

For the uniform component on

[-5,5]\times[-5,5],

the sideband fraction is simply the area fraction

f_U = 1-\frac{4\cdot4}{10\cdot10} = \boxed{0.84}.

For the product of two unit Gaussians truncated to [-5,5],

g(x,y)=g_x(x)g_y(y),

the probability of the central square is

P_{\mathrm{central}} = \left[ \frac{\operatorname{erf}(2/\sqrt{2})} {\operatorname{erf}(5/\sqrt{2})} \right]^2.

Therefore the correct Gaussian sideband fraction is

f_G = 1- \left[ \frac{\operatorname{erf}(2/\sqrt{2})} {\operatorname{erf}(5/\sqrt{2})} \right]^2 = \boxed{0.08892920913888092}.

Using the legacy fitted yields from the reproducer,

N_G=11199.522, \qquad N_U=2899.005,

the expected number of sideband events is

N_{\mathrm{SB}} = f_GN_G+f_UN_U,

so

N_{\mathrm{SB}} = 0.08892920913888092\cdot11199.522 + 0.84\cdot2899.005 = \boxed{3431.128834\ldots}.

This matches the observed

N_{\mathrm{SB}}=3431.

So the interpretation in the original post was correct: the fitted coefficients are full-range yields, and the expected sideband count is obtained by multiplying each yield by the corresponding component fraction in the selected union of ranges.


2. What the buggy cpu likelihood was actually doing

The surprising part is that the cpu fit also reports

status = 0
covQual = 3

despite returning

N_g = 2818.327
N_u = 729.358

which give only

f_GN_G+f_UN_U = \boxed{863.292311\ldots}

events in the physical sideband region.

The reason is that the buggy extended term was not using the physical Gaussian fraction f_G.

For the Gaussian product it effectively used

\widetilde f_G=1

instead of

f_G=0.08892920913888092,

while the uniform fraction remained

\widetilde f_U=0.84.

Now evaluate the expected number of events according to that buggy internal model:

\widetilde N_{\mathrm{SB}} = \widetilde f_GN_G+\widetilde f_UN_U.

With the cpu fitted yields,

\widetilde N_{\mathrm{SB}} = 2818.327 + 0.84\cdot729.358 = \boxed{3430.98772}.

That is essentially exactly the observed 3431 sideband events.

So Minuit was not failing.

It was successfully minimizing the likelihood that RooFit gave it.

The problem was that the extended term in that likelihood represented the wrong expected-event normalization.

This also explains why a successful

status = 0
covQual = 3

cannot diagnose this class of error: these values certify the minimization and covariance calculation of the supplied objective, not the physical correctness of how that objective was constructed.


3. Exact origin of the factor ~3.97

The fitted component ratio is approximately preserved:

r = \frac{N_U}{N_G} = \frac{729.358}{2818.327} = 0.2587911197\ldots

For a fixed component ratio r, the overall yield scale is determined by the extended term.

The scale change caused by replacing the correct fractions (f_G,f_U) by the buggy fractions (\widetilde f_G,\widetilde f_U) is therefore

K = \frac{ \widetilde f_G+r\widetilde f_U }{ f_G+rf_U }.

Substituting the values,

K = \frac{ 1+0.84(0.2587911197) }{ 0.08892920913888092+0.84(0.2587911197) } = \boxed{3.974305893\ldots}.

The two observed yield ratios are

\frac{11199.522}{2818.327} = \boxed{3.973819\ldots},

and

\frac{2899.005}{729.358} = \boxed{3.974735\ldots}.

So the apparently mysterious common factor of about 3.97 follows directly from the wrong component fraction used by the extended term.


4. Source-level cause

The problematic code was in the construction of the expected-events function for a ranged RooAddPdf.

The old implementation effectively inferred a range fraction from the reciprocal of a component integral over the coefficient reference range.

That is only valid under an additional assumption about how the compiled component is internally normalized.

Let an internal compiled representation of a physical component be

\widetilde p_k(x) = c_k\,p_k(x),

where c_k is an arbitrary constant introduced by internal normalization choices.

A physical range fraction must be invariant under this transformation.

The correct quantity is

\boxed{ f_k(R;R_0) = \frac{ \int_R \widetilde p_k(x)\,dx }{ \int_{R_0}\widetilde p_k(x)\,dx } }.

Indeed,

f_k = \frac{ c_k\int_Rp_k(x)\,dx }{ c_k\int_{R_0}p_k(x)\,dx } = \frac{ \int_Rp_k(x)\,dx }{ \int_{R_0}p_k(x)\,dx }.

The arbitrary internal normalization cancels exactly.

The old reciprocal construction did not have this invariance.

That is why it could work for some component representations and fail for the compiled RooProdPdf in the multi-range 2D case.

The ROOT fix changes the expected-event fraction to the ratio of two integrals of the same component:

  • numerator: integral over the fit/norm range;
  • denominator: integral over the coefficient reference range.

This is the correct invariant construction.

The corresponding ROOT fix is the commit

7b50e599c698c1439b9a6d59d6ff78f3a232ea3f
[RF] Fix fractions in expectedEvents for normed RooAddPdf components

and the issue is

root-project/root#23512

A regression test was added for exactly this geometry: a Gaussian product plus uniform background fitted in eight disjoint 2D sideband boxes, with the reference result calculated independently from erf() and logarithms rather than by comparing against the legacy backend.

So the original issue reported in this thread is now well identified and fixed.


5. Why this appears specifically in multi-dimensional multi-range geometry

There is a useful geometric reason why this class of bug is easy to miss.

Let the fitted region be

R = \bigcup_{j=1}^{m}R_j,

where each named range is a multidimensional box

R_j = I_{1j}\times I_{2j}\times\cdots\times I_{dj}.

For the eight-box sideband geometry in the reproducer,

R \neq \pi_x(R)\times\pi_y(R),

where \pi_x and \pi_y are the coordinate projections.

In fact,

\pi_x(R)=[-5,5], \qquad \pi_y(R)=[-5,5],

even though R excludes the central square.

Therefore all information about the central hole disappears if the joint range is replaced by its independent one-dimensional projections.

This is why the range labels have to stay coupled across observables until the joint integral has been constructed.

For a factorized density

p(x,y)=p_x(x)p_y(y),

the correct normalization over a disjoint union is

\boxed{ Z_R = \sum_j \left( \int_{I_{xj}}p_x(x)\,dx \right) \left( \int_{I_{yj}}p_y(y)\,dy \right) }.

It is not

\left[ \sum_j \int_{I_{xj}}p_x(x)\,dx \right] \left[ \sum_j \int_{I_{yj}}p_y(y)\,dy \right].

In compact form,

\boxed{ \sum_j\prod_a Z_{a,j} \neq \prod_a\sum_j Z_{a,j} }

in general.

The difference consists of cross terms that correspond to combinations of coordinate intervals that were never part of any named joint range.

This is the general mathematical boundary behind the problem.


6. A related RooProdPdf issue is still open

While checking the complete normalization path, I found a related but distinct issue that should not be confused with the now-fixed extended-term bug above.

root-project/root#11486 is still open, and the current ROOT test suite still contains

TEST(RooProdPdf, DISABLED_ChangeServerNormSetForProdPdfInAddPdf)

with the explicit note that this is still a problem.

The direct reproducer there uses

bkg.setNormRange("SB1,SB2");
bkg.getVal(normSet);

for

bkg(x,y)=p_x(x)p_y(y)

with

SB1=[-10,10]\times[-10,0],

and

SB2=[-10,0]\times[0,10].

The two 2D boxes are disjoint up to a zero-measure boundary.

But their projection onto x is

[-10,10] \quad\text{and}\quad [-10,0],

which overlap.

This is an important distinction:

disjointness of joint multidimensional ranges does not imply disjointness of their projections onto the observable subset of an individual factor.

For a uniform product density on the full 20\times20 square, the correct joint normalization fraction is simply

\frac{ 20\cdot10+10\cdot10 }{ 20\cdot20 } = \boxed{0.75}.

Therefore the correctly normalized density inside the union is

\frac{1/400}{0.75} = \boxed{\frac1{300}}.

Trying to normalize the individual factors independently with the comma-separated range list loses the joint pairing of the labels SB1 and SB2.

This is why a factor-level multi-range integral can see overlapping 1D ranges even though the original 2D regions are perfectly disjoint.

Current ROOT already contains an explicit workaround around this limitation. In the multi-range case, RooAddPdf::compileForNormSet() deliberately does not propagate the comma-separated normalization range to a RooProdPdf, with the source comment that RooProdPdf cannot deal with such a multi-range normalization range there.

So this remaining issue is not a cache-only problem. setNormRange() already sterilizes client normalization caches. The deeper problem is preserving the joint range identity through factorization.


7. General solution for RooProdPdf multi-range normalization

For a union of named disjoint regions

R=\bigcup_j R_j,

the safe construction is to keep the range index j outside the product factorization.

For a product that factorizes into independent groups G_a,

P(z)=\prod_a G_a(z_a),

define

Z_{a,j} = \int_{\pi_a(R_j)} G_a(z_a)\,dz_a.

Then the joint normalization is

\boxed{ Z_R = \sum_j \prod_a Z_{a,j}. }

The required operation order is therefore:

  1. split the comma-separated range into its named joint components;
  2. for each range name j, construct the contribution of every factor/group using that same range name;
  3. multiply the factor/group contributions for that j;
  4. only then sum over j.

In other words:

\boxed{ \text{split range} \rightarrow \text{evaluate joint component} \rightarrow \text{multiply} \rightarrow \text{sum} }

rather than

\text{split independently in every factor} \rightarrow \text{sum} \rightarrow \text{multiply}.

For components that share observables, they must first be combined into the same irreducible product group and integrated jointly. The existing RooProdPdf factorization machinery already identifies such grouped terms; the essential requirement is that the multi-range summation happens after preserving the range label through those groups.

There is also already a useful implementation precedent in RooAbsReal::createIntegral(): when given a comma-separated range, it splits the range names at the top level, creates one integral per range, checks the multidimensional ranges for overlap, and returns their sum.

The direct RooProdPdf normalization path should preserve the same semantics instead of allowing the comma-separated joint range to collapse into independent projected factor ranges.


8. Regression tests that fully cover the boundary

I would consider the normalization path fully protected only if the regression suite contains all of the following controls:

  1. Full range
    cpu and reference calculation agree.

  2. Single named rectangular range
    No multi-range composition is involved.

  3. One-dimensional disjoint multi-range
    Confirms ordinary comma-separated range handling.

  4. Two-dimensional non-Cartesian union
    For example the eight-box sideband region from this thread.

  5. L-shaped two-range union
    The SB1,SB2 geometry from #11486.

  6. Internal component-rescaling invariance
    Replacing

    p_k\rightarrow c_kp_k

    must not change any physical fraction or fitted yield.

  7. Range-order invariance
    Range("A,B,C") and Range("C,A,B") must give identical results.

  8. Equivalent graph topology
    A bare RooAddPdf and the same object wrapped in a RooProdPdf with a constraint term must give the same ranged yield semantics.

  9. Direct RooProdPdf normalization
    A direct

    prod.setNormRange("A,B");
    prod.getVal(normSet);
    

    must agree with an explicitly constructed top-level normalization

    P(z)\Big/\sum_j\int_{R_j}P(z)\,dz

    and must not fail merely because the projections of disjoint joint ranges overlap.

These tests separate the two questions cleanly:

  • #23512: wrong component fractions in the extended expected-events term — fixed;
  • #11486: direct RooProdPdf multi-range normalization / factor normalization-range dependence — still open in current master.

Conclusion

For the original question in this thread:

\boxed{\text{the observed behaviour was a bug, not expected behaviour.}}

The discrepancy is completely explained by the Gaussian component receiving an effective sideband fraction of

1

instead of

0.08892920913888092.

That single normalization error reproduces both:

  • the apparently valid internal expected event count of about 3431;
  • the observed common yield scale factor of about 3.97.

The correct construction is the normalization-invariant ratio

\boxed{ f_k(R;R_0) = \frac{\int_Rp_k}{\int_{R_0}p_k} }

and this is what the ROOT fix now implements for the extended expected-events path.

The remaining RooProdPdf multi-range issue is a separate, older problem: the joint range label must remain coupled across factors. Mathematically, the required normalization is a sum of joint products, not a product of independently projected sums.