Differentiable SCETlib prediction as a rabbit parameter model - #715
Draft
lucalavezzo wants to merge 24 commits into
Draft
Differentiable SCETlib prediction as a rabbit parameter model#715lucalavezzo wants to merge 24 commits into
lucalavezzo wants to merge 24 commits into
Conversation
Adds `wremnants/postprocessing/scetlib_ad/`, a rabbit ParamModel in which every theory parameter SCETlib exposes is a continuous fit parameter with exact derivatives, rather than a discrete template morph whose joint response with the others is an outer product: alpha_s, the 8 nonperturbative lambdas, the 10 theory nuisance parameters, and PDF eigenvector coefficients. Which of these exist is a property of the cache, not of the code -- the model reads `gradient_param_names()` and registers what it finds. Only the profile-scale parameters (kappaFO, kappaf, muf, transition points) are outside SCETlib's autodiff and still need template nuisances. The prediction comes from the SCETlib `autodiff-sigmaul` branch, whose `ScetlibCachedXsecTF` replays a prepared cache -- compressed bin rules for the resummed piece plus a frozen fixed-order grid for the nonsingular -- and returns exact first and second derivatives from clad. Design notes: * Derivatives are injected, not traced. SCETlib returns value, Jacobian and exact Hessian from C++, so instead of differentiating through the py_function boundary the model hands autodiff an exact local quadratic `stop_gradient(val) + J.d + 0.5 d^T K d` with `d = p - stop_gradient(p)`. Value, first and second derivative are then exact at the evaluation point while everything downstream stays pure TF. This also keeps the PyFunc behind stop_gradient, so `GradientTape.jacobian` never re-enters C++ once per fit parameter. `differentiate=through` selects the alternative so the claim is checkable; the two agree on the gradient to 1.3e-16. * One rabbit job. The exact second-derivative term is always included, so the composite Hessian is exact and the fit and postfit covariance run together. * `--jitCompile off` is required (XLA cannot compile a PyFunc) and enforced at construction with an actionable message. * `GenFold` sums cache bins onto the card's gen grid, handling a different nesting order, a signed-Y cache folded onto |Y|, and a cache finer than the fit's binning; it verifies every gen bin is exactly tiled rather than assuming. * Guards for the failure modes that are otherwise silent: cache/card binning mismatch, cache anchor vs the card's recorded nonperturbative values, template nuisances that would double-count a fitted parameter, TNPs fitted without priors, and any parameter whose Jacobian column is identically zero. Validated on a small cache: injection closure is exact in alpha_s and the lambdas, the cached rules reproduce a live SCETlib evaluation to 1e-15 (values), 1.5e-15 (gradient) and 3.4e-14 (Hessian), and TNPs fit with priors. Scripts under `scripts/rabbit/scetlib_ad/` build a cache for a card's binning, check a cache standalone, build a self-contained closure card, and validate the resummed piece against a native SCETlib production run. `conf/` carries runcards reproducing the analysis configuration -- note that the analysis order is defined by the `[TNPs]` block, so an analysis-faithful cache has 19 parameters, not 9. Not yet exercised: the reco fold path, and any production-binning cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
The production runs carry an off-peak mass bin (10-60) alongside the 60-120 one, so summing the reference's Q axis would have compared our mass window against a wider one. Select the bin whose edges match the cache's window and fail loudly if there is none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
rabbit's fit vector is not SCETlib's: it holds only the fitted parameters, POIs first, while SCETlib's holds every registered parameter in registry order. The model has to map between them inside the differentiated graph, and it was doing so with tensor_scatter_nd_update. That op's backward pass contains a gather, whose gradient TF represents as tf.IndexedSlices, and the SCETlib bridge's second-order py_function payloads call .numpy() on the incoming cotangent -- so anything past first order failed with "'IndexedSlices' object has no attribute 'numpy'". Isolated to the scatter itself: a nested-tape HVP works on a bare Variable, and fails with a scatter in front even when the scatter covers the whole vector. Replacing it with a multiplication by a constant 0/1 selection matrix is bit-identical (the entries are exactly 0 and 1) and negligible at these sizes (at most ~25 x 25), and TF's matmul gradient rule always yields a dense cotangent. This makes differentiate=through usable, which turns the straight-through default from an assertion into a measured claim: the two now agree to 1.3e-16 on the gradient, 4e-15 on HVPs and 2.4e-17 on the Hessian, and a full fit driven either way returns the same alphaS and the same uncertainty on every parameter. Second order had no cross-check at all before this. Straight-through stays the default on cost rather than correctness, since rabbit builds the postfit Hessian as t2.jacobian(grad, self.x) over the whole fit vector and pfor cannot vectorise a PyFunc; the reasoning is now written down in the module docstring and the README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Now that the fit vector is mapped with a dense matmul, letting TF differentiate the SCETlib call is not only possible but the better default: it is the ordinary TF idiom, it is what examples/matched_ad/tf_gradients.py does, and on a 6-parameter gen-level card it is faster -- 15.8 s of fit time against 40.9 s -- with identical postfit values and uncertainties and a slightly better EDM. straightthrough is kept, because the two scale oppositely in the number of FIT parameters (counting every datacard nuisance, not just ours). rabbit builds the postfit Hessian as t2.jacobian(grad, self.x) and pfor cannot vectorise a PyFunc, so `through` costs one C++ HVP sweep per fit parameter, while `straightthrough` pays one value+Jacobian and one full Hessian per distinct parameter point whatever the count. With an HVP at ~2.5x a gradient and a materialised Hessian at ~40x, the crossover is a few tens of parameters: `through` for a gen-level fit, `straightthrough` if a reco card with hundreds of nuisances makes the postfit Hessian the bottleneck. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Differentiate through the SCETlib bridge and nothing else. The straight-through surrogate, the `differentiate` option and the mode checker are removed: with the dense-matmul mapping in place, letting TF differentiate the real prediction works at every order, is the ordinary TF idiom, and is faster at these parameter counts, so carrying a second path that agrees with the first to 1e-16 was surface without a reason. The one requirement it imposes is now documented where it can be seen rather than guarded by a flag: map rabbit's fit vector into SCETlib's layout with a constant 0/1 matrix multiply, never tensor_scatter_nd_update, or everything past first order breaks while first order keeps working. Fit unchanged: alphaS 0.1195 +/- 0.00045, same uncertainty on every parameter, EDM 4.7e-21, and 12.8 s of fit time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
configure() called configure_calculation() and set_vary() but not configure_ew_parameters() or configure_fiducial_volumes(). Neither is part of configure_calculation; prod/scetlib_run/scetlib-run-qT.py -- the path every production correction was made with -- calls both. So the runcard's [Electroweak] block was silently ignored and SCETlib's defaults used instead. On the analysis card, which sets mZ = 91.1535, GammaZ = 2.4932 and custom alphaem / sin2_thw / CKM, that is a flat 1.61% normalization error, essentially independent of qT. Measured against the production driver on the reference runcard read verbatim (calculation_piece = sing), Q [60,120], Y [1.0,1.5]: before: operator() / driver = 1.01637 .. 1.01664 across qT 2 -> 10 after: operator() / driver = 1.00000000 (bit-identical, max dev 0.0) examples/matched_ad/prepare_cache.py upstream has the same omission; the docstring now says why this must not be "simplified" back to match it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Everything SCETlib exposes is now a continuous fit parameter, so the corresponding card templates can be dropped instead of double-counted. Backend (xsec_backend.py): - configure() gains diff_scales (registers scale_kappa_R, scale_x1..x3 and an inert scale_kappa_F slot via set_diff_scales(1)) and fo_resolve_muR (resolves the fixed-order muR dependence into the frozen grid, so the FO piece follows kappa_R in closed form). fo_resolve_muR must be set BEFORE the grid is built and costs ~3x on the warm. - diff_scales REFUSES muf_follows_muB = yes: with muf tied to muB a live kappa_R would move muF while the beam convolutions stay frozen at their own. - cache_param_names() peeks the npz 'names' array without loading the cache, so the calculation is configured the way the cache expects rather than the way we would prefer. A mismatch is otherwise a hard load failure -- the fingerprint hashes the names in order. Cache builder (prepare_cache_for_card.py): - --pdf-eig / --as-pair / --no-muf / --no-pdf / --grid-jobs, and build_variations() reusing the upstream helpers. - The alphaS pair rides on the EXISTING alphas slot, so one parameter moves the calculation and the PDF together. Without it alphas is a derivative at FIXED PDF, which --no-pdf now says out loud: do not quote alphaS from such a cache. Parameters (params.py) and model (param_model.py): - Name maps for the scales and the PDF eigenvector coefficients, plus resumScale / resumTransition impact groups and pdf_group(). - REPARAM: the profile scales become UNIT nuisances, since their templates encode multiplicative (kappa_R: 0.5/1/2) or asymmetric (x2: 0.35/0.6/0.75) steps that a symmetric Gaussian on the physical value cannot express. The map is exp(theta*ln2) or a quadratic; TF differentiates it so the chain rule keeps the gradient and Hessian exact. Verified bit-exact against the analytic Jacobian at theta = -1, 0, +1. - Construction asserts theta = 0 reproduces the anchor to 1e-12, so a mistyped map cannot silently shift the start point. - _check_double_counting refuses to float a direction whose card templates are still present (regex, so ^pdf\d+ catches the eigenvectors without catching pdfAlphaS), and _check_no_inert_params refuses a parameter with an identically zero derivative -- which is what scale_kappa_F is unless the cache was built with the muF member pair, and which would otherwise make the covariance singular. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
validate_reco.py -- the model's sigma_reco against the histmaker's corrected reco hist, folded through the response exactly as the fit does. Documents the four traps this comparison has: R sums helicitySig while N_gen takes UL, the ptVGen [44,100] bin is an OVERFLOW (it holds qT > 100 while sigma_SC stops there), the two sides differ by pb-vs-fb so the plots density-normalise, and hist.project() on a cropped hist silently re-adds the flow. Closes at 0.128% yield-weighted. --reference card compares against indata.norm's signal column instead (sliced start:stop, not [:nbins]), --no-match-norm drops the global scale so the ABSOLUTE normalisation is tested (0.149%), and --y-fold defaults off the GenFold's own y_convention -- a positive-side-only cache holds HALF the |Y| cross section, which cancels in the ratio construction and does not cancel absolutely. validate_variations.py -- every variation the model produces against the corresponding template from the Corr file, for all 38 labels. 28 of 38 agree at 1e-6..1e-8 above qT ~ 4; the residual is concentrated at low qT, where the nonsingular cutoff differs from the reference (ours 0.1 GeV vs --qtCutoff 1.0). compare_to_np_model.py -- AD model vs the scetlib_np model. Their centrals are NOT required to agree (different nonsingulars, and the fit only ever uses the ratio to each model's own central); what must agree is the response to a shared parameter. compare_cards.py -- row-sum audit and event-vs-matrix granularity between two cards, needing neither cache nor SCETlib. Written for the deferred uncorrected-histmaker route, but the row-sum audit stands alone as a check that marginalisation, cropping and axis order are right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
…tion status The docstring still claimed the profile-scale parameters "are outside SCETlib's autodiff and still need template nuisances". That has been false since set_diff_scales(1) was wired in: kappa_R and x1..x3 are differentiable, and kappa_F has a slot that is inert unless the cache carries the muF member pair. Replaced with the actual status, which is NOT uniform across the scale directions and should not be summarised as "validated": - TNPs reproduce their templates to 1e-4..1e-16, the NP lambdas to ~1e-3; - kappa_R reproduces kappaFO2.-kappaf0.5 to 4.5e-03 but kappaFO0.5-kappaf2. only to 4.0e-02, i.e. the down direction is 10x worse than the up direction, and that is the direction driving sigma(alpha_s) (rho = +0.93); - all three transition_points variations move the prediction the OPPOSITE way from their templates (model [1.0000,1.1593] vs reference [0.9602,1.0000], and likewise for the other two). The one that moves x1/x3 rather than x2 inverts too, so it is not a mapping slip in the validation table, and the reparametrisation map is separately verified bit-exact. Suspect a convention difference between set_diff_scales and the production transition_points setting. Documented as: do not float resumTransition* for a physics result until resolved. Also drops a comment referring to "the differentiate=through path", an option removed in 62fb588. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
max|dev| alone cannot tell a low-qT cutoff artefact from a broken response, and the two need completely different follow-up. Adds a "worst qT" column (always) and --profile (the qT profile of max|dev| over |Y|, with a bar chart). It immediately separates three different failure modes that all looked like one number before: - lambdas and TNPs: the residual is ONLY the low-qT feature. lambda21.0 goes 4.9e-03 at [0,1] -> 2.2e-03 -> 5.3e-04 -> 1.8e-04 and is at 1e-05 by 5 GeV, 1e-06 by 20. s1. has the same shape at 7.1e-04. Nothing to fix in the response; this is the known nonsingular cutoff mismatch. - kappa_R: the low-qT feature PLUS a broad shoulder. kappaFO0.5-kappaf2. is 4.0e-02 at [0,1] but stays at 5e-03..9e-03 through 3-7 GeV and ~1e-03 out to 14 GeV -- 10-100x the lambda residual at the same qT, so something beyond the cutoff is wrong. - muF: the low-qT feature PLUS a FLAT ~2e-04 pedestal at every qT out to 100, which is an offset in the response rather than a low-qT artefact. - transition points: EXACTLY 0.00e+00 below 12 GeV, then monotonically rising to 1.99e-01 at [33,44]. Not a low-qT problem at all -- it fails precisely where the resummed -> fixed-order matching lives, which is consistent with these being the matching transition points, and with the sign inversion already documented in param_model's docstring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
The main CorrZ carries no alphaS and no PDF variations at all -- they live in
two sidecars produced by the same job, `*_pdfas_CorrZ` and `*_pdfvars_CorrZ`.
Validating those two directions therefore needs more than one reference file,
so --corr now takes a list and the per-file work moved into _one_file().
Their labels do not follow the main file's convention either, so they are
resolved by pattern rather than enumerated:
* `pdfCT18ZNNLO_as_0116` / `ALPHAS_116` (HERAPDF spells it differently)
-> alphas = 0.116. NB `_as_0118` is that file's CENTRAL, not a variation,
which central_label() has to know or every ratio comes out against the
wrong denominator.
* `pdf0` is the central of the other file and `pdf(2i+1)`/`pdf(2i+2)` are
eigenvector i up/down, i.e. c_e = +-1 by construction of
build_pdf_variations. Reported as skipped, not silently passed, when the
cache was built with n_eig = 0.
Measured with these: alphaS reproduces its template to 2.0e-03 (up) and
2.4e-03 (down), worst in the lowest qT bin, which is the same low-qT feature
the scale directions show and not an alphaS-specific problem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
The transition-point response we hand the fitter has the wrong sign, and the cause is upstream rather than in this model. Making the identical physical change through the runcard with set_diff_scales off reproduces the production template to 2e-6; making it by moving the registered scale_x2 parameter with set_diff_scales(1) gives 0.966985 where the template says 1.159163 at qT [33,44] -- opposite sign, roughly -7x in slope, and linear from zero. Mechanism: the transition points move muF by ~20% (muF has its own profile over the same points) while the per-node beam convolutions stay frozen at the config's muF -- conv_probe shows they shift 7-16% over that range. kappa_R escapes it because set_muR_factor holds muF fixed by construction. It is not fixable from Python: SCETlib's muF machinery interpolates a GLOBAL member while the induced shift is per node, and DrellYan.hpp:586 shows a per-node dconv was already considered and rejected. Eliminated by measurement along the way: the REPARAM map, the label mapping in the validation table, the shared formulas::f_run, the ported node scalars (node_scalars_probe agrees to 0.00e+00), the separately inlined node_value, the compressed bin rules, calculation_piece, the frozen nonsingular, and make_theory_corr. So resumTransition2 joins 1 and 3 in DEFAULT_FROZEN. That is a KNOWN GAP, not a fix: it drops the transition-point uncertainty from the fit. It is still preferable to profiling a nuisance whose response points the wrong way, which biases the POI rather than merely mis-sizing an error. Floating one anyway stays possible -- that is how the upstream fix will be tested -- but now prints a warning, driven by a KNOWN_BAD_RESPONSE table so the reason travels with the name instead of living only in a comment. Also drops the blank line isort wants gone in param_model's import block, which is what the linting CI job has been failing on. NB run isort from a checkout with submodules populated: in a linked worktree `rabbit/` is empty, isort then classifies it third-party, and "fixing" the file there produces exactly the grouping CI rejects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
set_pdf_eig_params was never called on the sub-pieces before the rules were
built, so --pdf-eig > 0 -- the DEFAULT -- built for hours and then died inside
the extension. SCETlib signposts this correctly ("call set_pdf_eig_params on
this piece before ..."), we simply never called it, which is why every cache so
far has n_eig = 0. Now called on both pieces at build time, and again at load
from the cache's own parameter names, since it configures the CALCULATION rather
than anything the file can carry.
Also in prepare_cache_for_card.py:
* the mandatory step-one prepare() before build_bin_rules, plus a drift check
that raises if the matched values move
* --subset for contiguous (y, qT) test caches
* --members LO:HI to build one shard, with a .shard.json sidecar
* plan_variations() split out of build_variations() as the single source of
the canonical member order [eig pairs, alphaS dn/up, muF lo/hi]
build_cache_parallel.py is new: a pure-Python parser and splicer for both blobs,
merging along the BIN axis (validated to 0.000e+00 in value and Jacobian) and
along the member axis within a single build (byte-identical round trip).
Members must NEVER be split across processes. The builder is not reproducible:
four builds of the same configuration gave 357/359/359/371 nodes/bin, with site
counts differing in 9 of 10 bins, because the range splitting depends on the
worker count and each thread keeps a private integrator. A merge across builds
would be silently wrong -- the loaders check only settings, struct sizes and
version. The member stage is node-parallel anyway (145 of 200 cores busy), so
raise --threads or split BINS; forking members is ~90x slower per member.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
…the transition validate_variations.py gains --partial, comparing only the gen bins the cache actually tiles and always reporting how many were excluded, so a subset cache can be validated without pretending it covered the grid. It also plots the central comparison, with the rapidity-convention factor applied explicitly rather than divided out silently. params.py: resumTransition2 leaves DEFAULT_FROZEN now that its derivative works upstream, and KNOWN_BAD_RESPONSE is emptied. resumTransition1/3 stay frozen as a physics choice, not a workaround. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Theory corrections stay applied IN the histmaker, with the param model supplying only the ratio for the variations (settled 2026-08-25). compare_cards.py existed to compare a corrected against an uncorrected card, which is the configuration we are not pursuing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
The build was 1863 lines of WRemnants with almost nothing WRemnants in it, and
the two modules depended on each other in BOTH directions (the scheduler ran the
builder as a subprocess while the builder imported the scheduler for its merge).
Three pieces now, with the seam where the knowledge lives:
* the cache FILE -> scetlib-cms/py/scetlib_cache.py (TF-free writer, blob
readers, bin and member merges). Merging two rule blobs is an operation on
SCETlib's own serialisation format using knowledge of SCETlib's internals;
if the layout changes, their CI now finds out instead of us getting wrong
answers.
* the BUILDER -> scetlib-cms/examples/matched_ad/prepare_cache.py, driven by
an explicit bin list.
* the WRAPPER -> here: gen axes off the rabbit card, the runcard, --subset,
orchestration, thread budgeting. 849 -> 523 and 1014 -> 319 lines.
`main()` deliberately stays in the wrapper. knot_scan/ and transition_knots/
rebind plan_variations/build_variations and then call main(); had main() moved
upstream those rebinds would be SILENTLY IGNORED -- a build finishing with
default knots while its log claims the override. Steps resolve through
_resolve_steps() (globals first, upstream second) and fork_member_build takes
build_fn/write_fn so a rebound step reaches forked children.
Also drops TensorFlow from the build path, which was the point of the exercise
for wall clock: _import_scetlib() fetched sl_config, sl_variations AND
ScetlibCachedXsecTF from one try:, so configure() -- the first call any build
makes -- pulled TF. _import_cached_xsec() is now separate and evaluation-only.
Measured on a real 2-bin build, one change apart: tensorflow imported yes->no,
modules 5165->848, peak RSS 1404->681 MB, threads at exit 203->90. The
TF_NUM_*_THREADS workaround is no longer needed for a build.
Production path proven unchanged rather than assumed: the call-diff harness
extended 3 -> 7 configurations, all SAME; the writer byte-identical except three
bytes of uninitialised Bin_rule_opts padding, which two runs of the OLD writer
also differ in; backend_check identical but for timings; bin merge 0.000e+00
against both parents in value and Jacobian with the arms provably separated;
member merge 9 arrays byte-identical; and the cross-build guard still refuses,
now with a test showing two independent builds agree on everything the C++
loaders check while their nominal rules differ byte for byte.
Upstream half is MR !10; no C++ touched, so sizeof(ad::GlobalData) is unmoved
and every cache on disk stays loadable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Two related changes, both additive and both off by default. `mz_dilepton --responseGenBinning theoryCorr` writes two EXTRA histograms, nominal_prefsr_yieldsResponse and prefsr_response, on the theory correction's own gen grid, beside the untouched unfolding pair. The unfolding path is proven unaffected: with -j 1 so fill order cannot differ, 464 histograms bit-identical, 0 differing, 0 axis changes, 0 lost, exactly 2 new. `get_unfolding_dilepton_axes` gains `edges_override` so a second, finer set of gen axes can be built without touching the binning unfolding uses. `ptZgen_binning_corr` gains 110, 130 and 250 above 100. make_theory_corr rebins its inputs to their COMMON binning, so the single [100, 1300] cell collapsed the whole region regardless of what the other inputs carried. 250 is where the yield stops RECONSTRUCTING rather than where MiNNLO stops (~1 TeV): the analysis' own muon window pushes both muons past 60 GeV by qT ~ 130, and reco efficiency runs 0.019 at [100,110], 0.0011 at [120,130] and exactly 0 above 250. The top edge stays 1300 so nothing leaves the normalisation. The W binning is untouched. Why it matters, stated honestly: the correction is NOT ~1 where it stops -- it runs 1.02 at qT 46-48, 0.93 at 80-90 and back up to 0.94 at 90-100 with a 13.8% envelope, while the flow bin is exactly 1.0 over all 2106 overflow cells. But today's fit cannot see it (gen qT > 100 feeds 1.6e-07 of the fit's yield, ~ten MC events); it bites at reco ptll [44,100], i.e. the day the reco range goes past 44 GeV. This is a correctness move, not a bias fix. Agreement between the two grids is enforced in code rather than by convention: check_gen_grid_vs_correction() refuses a straddling edge or an edge past the correction's support, called from the histmaker and from the cache side, with the card carrying corr_generator so the cache-side check happens automatically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
CI's black --check flagged scripts/histmakers/mz_dilepton.py and wremnants/production/theory_corrections.py, which skipped every analysis job behind it. Formatting only, no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
setupRabbit imported the response loader from wremnants.postprocessing.scetlib_np, which this branch does not carry -- so --storeResponseMatrix (and any import of setupRabbit) was broken on the branch. Move the module into scetlib_ad, where the card path that uses it lives. load_R and has_response are unchanged; the only edit is dropping the cross-package import of GEN_AXES/RECO_AXES in favour of carrying the same tuples inline, so scetlib_ad does not depend on the superseded scetlib_np. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
A Hessian PDF member is not 1 sigma: CT18Z's are 90% CL, and the analysis may inflate on top. add_pdf_uncertainty applies pdf_inflation_factor * pdfMap[set]["scale"] to the templates; the model has to apply the same product or it inflates the PDF uncertainty by 1.645. params.pdf_set_key / pdf_coeff_scale read the factor out of theory_utils.pdfMap (never hard coded, and raising on an unknown set rather than silently returning 1). param_model resolves it from the runcard's own pdf_set plus the card's own noi, and applies it to the COEFFICIENT rather than the response: I(c) is exactly quadratic in c, so theta = +-1 evaluates SCETlib at the 68% CL point in eigenvector space, which is what a 1 sigma PDF displacement is. Pass pdf_coeff_scale=1 to switch it off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
The default fit_params excluded the theory nuisance parameters, on the
grounds that floating ten of them should be an explicit choice. That was
the wrong default: the TNPs ARE the resummation theory uncertainty of
this prediction, an analysis-faithful runcard registers all ten, and
leaving them fixed understates the uncertainty -- the more dangerous of
the two failure modes. They are not unconstrained either; each carries an
N(0,1) constraint by construction.
So the default set is now every registered direction except the frozen
shape constants, which makes it the same set as fit_params="all" ("all"
is kept: it is explicit at call sites and appears in the meta_info of
every fit run before this change). With poi_params still "alphaS", the
default configuration is now alpha_s as the POI and everything else
fitted with priors.
priors therefore has to default to True as well -- _setup_priors raises
when a TNP floats with priors off, so the first change alone would make
every default invocation fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Floating the TNPs by default does not construct: b_qqDS scales a channel that does not contribute to the Z, so its whole response is O(1e-16) and its Jacobian column is identically zero. A zero column is a zero row and column of the NLL Hessian, i.e. a singular covariance, and _check_no_inert_params refuses it -- so every default invocation raised and would have had to pass fit_params explicitly. It has to be frozen rather than "fitted with a prior": a prior would regularise the singularity away while still reporting a parameter the data cannot constrain at all. The default configuration is unchanged in intent -- alpha_s the sole POI, everything with a non-zero response floating with a prior -- and now comes out at 47 of 53 floating, priors on 46, which matches the validated explicit-fit_params configuration name for name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
…orr is for Two unrelated fixes in the same argument block. The getDatasets call had been left at a local debugging hack -- `extended=False` with the real expression commented out. That silently changes which datasets every dilepton run loads, for everyone, and has nothing to do with this PR. Restored to the base's `extended="msht20an3lo" not in args.pdfs`. --responseGenBinning's help described what the theoryCorr grid IS but not what it is FOR. Say plainly that it is the binning the alpha_s analysis uses, and why it is nevertheless not the default: it requires --poiAsNoi and at least one --theoryCorr, which a plain unfolding run need not pass. The default stays "none"; flipping it would break those runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
…tead of raising
The finer response grid is what the alpha_s analysis uses, so make it the
default. Its two preconditions stop being hard errors when it is on only
by default:
- --theoryCorr already defaults to three non-empty entries
(parsing.py), so that guard essentially never fires.
- --poiAsNoi is store_true/False, and it is the real one: outside the
poi-as-noi path there is no reco x gen histogram to put on a finer
grid, only the gen total. A default-on flag must not hard-error such
a run, so warn, write no response histograms, and carry on.
An EXPLICIT --responseGenBinning theoryCorr still raises: a user who
asked for the response deserves an error rather than a silent downgrade.
Implemented with an argparse sentinel default of None, resolved right
after parse_args, and args.responseGenBinning is set to "none" when
skipped so meta_info records what was actually done.
Scope of the new default, measured rather than assumed: the block is
inside `if args.unfolding:` and --unfolding is store_true/False, so a
default mz_dilepton run never reaches the flag. The only runs affected
are Z dilepton poi-as-noi unfolding histmakers -- the runs that want a
response matrix anyway -- and mz_dilepton is Z-only, so no W histmaker is
touched. Cost there, measured on 260826_Z_histmaker_respgrid: the two
histograms are 2.49 MB of an 86.2 MB output (2.9%), 23.5 MB in memory
before compression. --responseGenBinning none opts out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A new package
wremnants/postprocessing/scetlib_ad/providing a rabbitParamModelin which every theory parameter SCETlib exposes is a continuous fit parameter with
exact derivatives, instead of a discrete template morph whose joint response with
the others is an outer product.
alphaShas_asfor the PDF-consistent version (see below)off[TNPs]blockkappa_Rdiff_scalesx1..x3diff_scales— sign-inverted, see Validationkappa_Fdiff_scalesandhas_muf(inert without it)n_eign_eig > 0Which of these exist is a property of the cache, not of this code: the model
reads
gradient_param_names()and registers what it finds.The prediction comes from the SCETlib
autodiff-sigmaulbranch, whoseScetlibCachedXsecTFreplays a prepared cache (compressed bin rules for theresummed piece + a frozen fixed-order grid for the nonsingular) and returns exact
first and second derivatives from clad.
How it works
There is no surrogate: autodiff differentiates the real prediction.
ScetlibCachedXsecTFis an ordinary TF-differentiable function whose backward passis itself a
custom_gradientcontracting Hessian-vector products, so nestedGradientTapes work and TF drives every C++ call. The model just calls it insidethe graph, exactly as
examples/matched_ad/tf_gradients.pydoes. An earlierrevision injected a local quadratic behind
stop_gradientinstead; that path wasremoved (
62fb5881, "one differentiation path, no fallback") — there is now onepath, so there is nothing to keep in sync.
That imposes one requirement which is easy to break by accident: map rabbit's fit
vector into SCETlib's layout with a constant 0/1 matrix multiply, never
tensor_scatter_nd_update. rabbit's vector holds only the fitted parameters, POIsfirst, while SCETlib's holds every registered parameter in registry order, so some
mapping is unavoidable. A scatter's backward pass contains a gather, whose gradient
TF represents as
tf.IndexedSlices, and the bridge's second-orderpy_functionpayloads call
.numpy()on the incoming cotangent and fail on it — so anything pastfirst order breaks. The matmul is bit-identical (entries are exactly 0 and 1) and
free at these sizes (at most ~25 × 25).
Other notes:
contraction, so the composite Hessian is exact and fit + postfit covariance run
together. Confirmed on the Asimov fits below: one job,
edmval ~ 1e-27.--jitCompile offis required (XLA cannot compile aPyFunc) and isenforced at construction with an actionable message.
GenFoldsums cache bins onto the card's gen grid — handling a differentnesting order, a signed-Y cache folded onto |Y|, and a cache finer than the fit
binning — and verifies every gen bin is exactly tiled rather than assuming it.
Note it does not normalise the Y convention out of the values it returns: a
positive-side-only cache yields half the |Y| cross section. That cancels in the
ratio the fit uses, and does not cancel if you compare absolutely.
Profile scales as unit nuisances
set_diff_scales(1)makeskappa_Rand the transition points differentiable. Buttheir templates encode multiplicative (κ_R: 0.5 / 1 / 2) or asymmetric
(x2: 0.35 / 0.6 / 0.75) steps, which a symmetric Gaussian prior on the physical
value cannot express. So
params.REPARAMmaps them to unit nuisances —exp(θ·ln2)for the scales, a quadratic for the transition point — and TFdifferentiates the map, so the chain rule keeps gradient and Hessian exact.
The map itself is verified bit-exact (
0.000e+00): θ = −1/0/+1 land on thephysical values, and the analytic Jacobian column times dκ/dθ equals TF's AD
gradient per bin, with dκ/dθ = 0.6931471806 = ln2 and 0.2 respectively. (The map
being right is not the same as the underlying scale response being right — see the
transition points below.)
Methodology note for anyone re-testing this: do not validate these
derivatives with a central difference across the anchor. The value surrogate has a
knot there (
c_valforces exactness at the anchor), so a symmetric differenceaverages two different slopes and the error is flat in h over four decades —
which looks like a real failure and is not. Compare against the analytic Jacobian.
Validation
Against a validated SCETlib production run and the histmaker, on the analysis
binning (Z, ptll × yll, 210 gen bins,
cache_aspair):indata.norm, absolute (no rescaling)Per-variation response against the Corr templates, all 37 labels the reference
carries (
validate_variations.py), asmax|dev|/mean|dev|:mufdown/mufupkappaFO2.-kappaf0.5kappaFO0.5-kappaf2.transition_points*The transition-point directions disagree in sign with their templates. All three
move the prediction the opposite way from the reference:
The third moves
x1/x3rather thanx2, so this is not a mapping slip in thevalidation table, and the reparametrisation map is separately verified bit-exact.
The central values match the templates' (
x = 0.2, 0.6, 1.0). Leading suspect is aconvention difference between
set_diff_scalesand the productiontransition_pointssetting. Until it is resolved,resumTransition*must not befloated for a physics result — and note that the σ(α_s) in fit B below did float
resumTransition2, so its number and its ρ are provisional.Also worth flagging: the κ_R down direction agreeing only to 4% matters more than it
looks, because κ_R is the direction that dominates σ(α_s).
Asimov reco fits
-t -1, real card, login-node CPU, one job each:Every parameter returns exactly at truth, and the Hessian is finite and sensible.
ρ(α_s, resumScaleMuR) = +0.927. κ_R and α_s both set the strength of the
resummed logs, so they trade off almost freely in the qT shape — floating κ_R as a
continuous nuisance costs a factor 2.9 on σ(α_s). The κ_R treatment is therefore
the dominant choice for the α_s uncertainty, more than any NP λ. (Also
ρ(λ2, λ2_ν) = −0.97, the two low-qT damping knobs being near-degenerate as
expected.) Neither σ(α_s) is quotable: these caches carry no PDF eigenvectors.
Two bugs this validation caught
The AD path initially disagreed with a validated production run by 1–3%. Every
earlier test compared the AD path against itself (exact to 1e-15), so none of them
could see it. Running the same configuration through SCETlib's own production driver
split it into two independent bugs whose product reproduced the discrepancy to 1e-5
in every bin:
configure_ew_parameters/configure_fiducial_volumes/configure_calculation, so it silently used SCETlib's default EW inputs ratherthan the runcard's. Flat +1.61%. Fixed here.
DrellYan::operator()'sad::Node_sharedwas hoisted out of thenode loop, so every node reused node 0's shared sub-expressions. qT-dependent,
±1.4%. Reported and cherry-picked upstream as
b919b61(which also turned upa second occurrence).
After both:
A/driver = 0.000e+00,B_cacheON/driver = 3.028e-06.Cache contents are load-bearing — read this before quoting anything
The model registers what the cache has, and a cache built with
--no-pdfis missingdirections silently unless the card still carries the templates:
has_as = 0→ α_s is a derivative at fixed PDF. The α_s pair rides on theexisting
alphasslot so one parameter moves the calculation and the PDF;without it, do not quote α_s.
--no-pdfnow says so out loud.has_muf = 0→resumScaleMuFhas an identically zero derivative. It isrefused at fit time rather than silently doing nothing.
n_eig = 0→ no PDF uncertainty from the model, so the card'spdf*templatesmust be kept.
Measured build cost at 210 bins: rules 9.0 min, FO warm 20.6 min, and 19.4 min per
PDF member for the fixed-order variations. The α_s + μF cache (4 members) is
~1.9 h; the full 29 eigenvector pairs (62 members) is ~20 h. The member loop is
the only serial axis left — the bin loop inside it is already
_parallel_run— sothat is what sharding would have to split.
Not yet done
floating
resumTransition*at all.members). Until then the card must keep
pdf*.fit_paramsexcludesresumTNP_*. Theywork when asked for; the default is deliberate, not an oversight.
reference's (
--qtCutoff 1.0). It largely cancels in the ratio the fit uses, andis being handled separately.
the discrete
resumFOScaletemplates it would replace.Changes to the shared Z dilepton histmaker
Most of this PR is a self-contained package, but five files are shared and one of
them changes a default. Flagging it explicitly so it can be objected to.
--responseGenBinningnow defaults totheoryCorr. It adds two histograms to aZ dilepton unfolding run: the reco x gen response on the theory correction's own gen
grid (770 gen bins for the Z — 70 qT bins to 100 GeV x 11 |Y| bins, |Y| truncated
at the reco
ylledge 2.5) and the gen total on that same grid. The grid is read fromthe correction file at runtime; no bin count is hardcoded.
Who pays for it, and how much:
if args.unfolding:, and--unfoldingisstore_true/False, as is--poiAsNoi. Amz_dileptonrun thatpasses neither never reaches the flag.
the runs that want a response matrix in the first place.
mz_dileptonis Z-only, so no W histmaker is touched (those usemw_with_mu_eta_pt.py).260826_Z_histmaker_respgridby summing the two histograms' stored datasets(
nominal_prefsr_yieldsResponse2.47 MB,prefsr_response0.02 MB). They are23.5 MB in memory and compress ~9.5x because the reco x gen matrix is very sparse.
--responseGenBinning noneopts out in one flag.The two preconditions are no longer hard errors when the flag is on only by default:
--poiAsNoioff means there is no reco x gen histogram to put on a finer grid, so theresponse is skipped with a warning rather than failing a run that never asked for it.
Passing
--responseGenBinning theoryCorrexplicitly still raises, so an explicitrequest is never silently downgraded.
--responseGenPtVExtend(the qT > 100 extension, 803 bins) stays PROVISIONAL andoff by default: the corrections do not exist on the wider grid yet, so the MC would be
uncorrected there while the model predicts a corrected cross section.
Notes for review
scetlib_nppackage ofSCETlib NP continuous parameter model #701. The two
scetlib_npstrings inresponse.pyare datacard conventions(the auxiliary group name and the metadata key existing cards carry) and are
overridable via
response_group=. One optional script,compare_to_np_model.py, importsscetlib_nplazily — that script exists tocross-check the two models, so it is the one place the dependency is intended.
mismatch, cache anchor vs the card's recorded nonperturbative values, template
nuisances that would double-count a fitted parameter (regex-based, so
^pdf\d+catches the eigenvectors without catching
pdfAlphaS), TNPs fitted withoutpriors, and any parameter whose Jacobian column is identically zero.
are called out in "Changes to the shared Z dilepton histmaker" below.
isort --profile black --line-length 88,flake8 --max-line-length 88with the F-code select list, andblack --check) are clean in the CI container.pylintis not installed locally,so the pre-commit hook's final step could not be run — CI's lint job does not run
pylint either.
🤖 Generated with Claude Code
https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c