Annotated source  ·  shapiq_qshap.cpp  ·  418 lines

The Quadrature-TreeSHAP Kernel

A line-by-line reading of the C++ any-order interaction kernel against Algorithm 1 of Quadrature-TreeSHAP: Depth-Independent TreeSHAP and Shapley Interactions — and an account of the fourteen places where the code deliberately parts ways with the pseudocode.

File src/gpu_linear_treeshap/cpp/shapiq_qshap.cpp Paper arXiv:2605.04497 Algorithm 1 p. 6 Upstream shapiq / quadrature_tree_shap.cc

01 — OrientationWhat the file is

Four functions, no dependencies beyond the STL. It computes all Shapley interaction values of every order in [min_order, max_order] for a batch of rows against a whole forest, in a single depth-first pass per row.

quadrature_tree_shapL395 · per batch quadrature_inferenceL257 · per row quad_extractL204 · per edge quad_enumerateL166 · per subset

The paper's Algorithm 1 is one recursive DFS over one tree for one sample at one fixed interaction order s. This file expands that in four directions at once — batch of rows, forest of trees, range of orders, iterative traversal — while shrinking the per-node arithmetic. Everything else in this document is a consequence of those four expansions plus one algebraic substitution.

The quantity being computed

The paper's chain is short. For a leaf v, the empty prediction and marginal multiplier are

Paper, Eq. 1 & Eq. 2

Rv = val(v) · ∏e ∈ path(v) we leaf value scaled by the cover ratio of every edge on the path
qvi =e: f(e)=i 1/we  if  x satisfies every split on i,  else 0 what putting feature i into the coalition multiplies the prediction by
αvj = qvj − 1,    Hv(p) =j ∈ M(v) (1 + αvj p) M(v) = distinct features on the path to v

Theorem 1 then gives the order-|S| weighted Banzhaf interaction at participation probability p in closed form (Eq. 8), and Eq. 9 recovers the Shapley interaction by integrating it over p ∈ [0,1]. Gauss–Legendre turns that integral into a finite sum over fixed nodes tm with weights wm (Eq. 10).

Everything the kernel does at runtime is an efficient evaluation of the telescoping update, Eq. 11, which the paper states for a split e on feature i with head h(e):

Paper, Eq. 11 — the update the kernel actually implements

SII(S ∪ {i}) +=m wm Hh(e)(tm)  (  (pe − 1)/(1 + (pe − 1)tm)  −  (pe↑ − 1)/(1 + (pe↑ − 1)tm)  )  ∏j ∈ S (pj − 1)/(1 + (pj − 1)tm)

Read left to right, the three coloured pieces are exactly the three things the kernel keeps in memory: the current edge's ratio (g_new), the same feature's previous ratio higher up the path (the ancestor correction), and a product of ratios over an arbitrary subset of the other live path features (gamma). Hh(e)(tm) is the subtree sum below the edge — the kernel's E.


02 — Notation bridgeEvery paper symbol, and where it lives

The single most useful table for reading the file. The kernel does not store q, α, or pe anywhere — it stores a rescaled form of each. Section 03 explains why.

PaperMeaningIn the codeNotes
tm, wmGauss–Legendre nodes and weights on [0,1]t[m], w[m], n_quadCaller-supplied. Paper fixes n=8; here it is a runtime parameter.
M(v)distinct features on the root-to-leaf pathws.path_feats L78Kept sorted; the paper's set A in Algorithm 1.
wecover ratio nchild/nparentfolded into tree.c_acc L28Never stored per edge; only chain products are.
[x sat. e]does the sample follow this edgews.act[node] L76The "hot" bit h ∈ {0,1}; AND-ed down each same-feature chain.
peaccumulated multiplier at edge enot stored — implied by (h, c)pe = h / c with c = c_acc[node].
1 + αetmthe edge's factor in Hvu_new = h*t[m] + c*(1-t[m])Equals c·(1 + αetm) — a rescaling, see §03.
e↑closest ancestor edge on the same featuretree.ancestors[node] L27Precomputed at conversion, not discovered at runtime.
c (Alg. 1)running path polynomial at the nodesws.A[depth] L72Depth-indexed rows, written in place.
wprodrunning product of cover ratioseliminatedAbsorbed into A by the rescaling.
HuDFS return: subtree sum of RvHv(tm)ws.E[depth] L73Leaf writes A·value; internal node sums its two children.
(pj−1)/(1+(pj−1)t)per-feature Banzhaf ratiows.live_g[feature] L74Stored as (h - c) / u; one row of nquad per feature.
δethe telescoping differencews.delta L75Pre-multiplied by wm and H: w[m]*E[m]*(g_new − g_old).
γP∏ of ratios over the chosen subsetws.gamma[level] L77Level k holds the product over the current size-(k+1) prefix.
Φ[S]output map, order-s interactionsout[order_offsets[s] + rank]Flat array, per-order blocks, lexicographic rank.
sinteraction ordermin_order … max_orderA range, not a single value.

03 — The core substitutionHot bit and cold factor

If you understand one thing about this file, make it this. Algorithm 1 computes with reciprocal cover ratios. The kernel never forms a reciprocal at all — and this is what the field names act ("hot") and c_acc ("cold factor") are about.

Fix a feature f and a node whose incoming edge splits on f. Let

  • h = act[node] ∈ {0,1} — does x satisfy every split on f seen so far on this path;
  • c = c_acc[node] — the product of cover ratios we over the same-feature chain up to and including this edge.

Then the paper's pe = h / c and αe = pe − 1 = (h − c)/c. Substituting:

Derivation — why u = h·t + c·(1−t)

1 + αetthe factor Algorithm 1 multiplies into c′
= 1 + (h − c)t / csubstitute αe
= ( c + (h − c)t ) / c  =  ( h·t + c·(1 − t) ) / crearrange
  u    h·t + c·(1 − t)  =  c · (1 + αet)L305–306

The kernel's u is the paper's factor scaled by the very cover product c that Algorithm 1 tracks separately in w_prod. Multiplying the u's along a path therefore produces the factor product and the cover product in one go — so w_prod simply disappears from the kernel.

The same substitution collapses the Banzhaf ratio:

Derivation — why live_g = (h − c) / u

αe / (1 + αet)  =  ((h − c)/c) / (u/c)  =  (h − c) / uL308

Numerator and denominator are both bounded by 1 in magnitude, and no reciprocal of a cover is formed. But the reason that is safer is not the obvious one — see immediately below.

Why u is the safer denominator

Worth being precise, because the intuitive reading — "reciprocals of small numbers are dangerous" — is not the mechanism. Dividing by we is well-conditioned: IEEE division carries a relative error of at most half an ulp and cannot suffer cancellation, so 1/w_e is exactly as accurate as we. Three other properties do the work.

a · The bound on live_g, derived

Fix one edge. Three quantities go in, and their ranges are all we need:

  • h = act[node] ∈ {0, 1} — exactly two values;
  • c = c_acc[node] ∈ (0, 1] — a product of cover ratios, each ≤ 1;
  • t ∈ (0, 1) — a Gauss–Legendre node, strictly interior, and fixed before the traversal starts.

Because h has only two values, the whole analysis is two cases.

Case h = 1 — the chain is hot (x satisfies every split on f so far)

u = t + c(1 − t)substitute h = 1
c(1 − t) 0  ⟹  u tlower bound — the floor
c 1  ⟹  u t + (1 − t) = 1upper bound
live_g = (1 − c) / ( t + c(1 − t) )numerator ∈ [0, 1)
c → 0:  live_g → 1/t     c → 1:  live_g → 0monotone decreasing in c

So u ∈ [t, 1] and live_g ∈ [0, 1/t]. The floor on u is t — a constant of the quadrature rule. No product of covers, however small, can push the denominator below it, because the h·t term does not involve c at all.

Case h = 0 — the chain is cold (x already failed a split on f)

u = c(1 − t),    h − c = −csubstitute h = 0
live_g = −c / ( c(1 − t) ) = −1 / (1 − t)c divides out exactly

Here u has no floor — it is proportional to c and can be arbitrarily small. It doesn't matter: the same c appears in the numerator, so the ratio is a pure function of t. The cold case is protected by exact cancellation of scale rather than by a bound, which is also why §08's pruning test is exact.

Gauss–Legendre nodes are symmetric about ½, so 1 − tn = t₁ and the two cases give the same magnitude. Combining them:

| live_g |    max( 1/t₁ , 1/(1 − tn) )  =  1 / t₁

A bound that depends only on how many quadrature points you chose — not on the tree's depth, not on its covers, not on the sample.

n_quadt₁1/t₁Measured range of live_g, sweeping c over [1e−12, 1] and both h
8 paper0.01985550.365[−50.365, +50.365]  — matches the bound exactly
10 repo default0.01304776.647[−76.647, +76.647]

b · The contrast with the paper's intermediates

The paper computes the same number, α/(1 + αt), and it is equally bounded — that is the whole point of §03's algebra. The difference is what the intermediates do on the way. With α = (h − c)/c, a shrinking cover product sends α and 1 + αt both to infinity while their quotient stays near 1/t:

c (hot edge)α = 1/c − 1Paper: α/(1+αt₁)Kernel: (1−c)/(t₁ + c(1−t₁))
1e−197.635567.63556
1e−81e+0850.364950.3649
1e−1001e+10050.365050.3650
1e−3001e+30050.365050.3650
1e−320infNaN50.3650

Read the last two rows together. The paper's form is accurate right up to the edge of the exponent range and then fails discontinuously — α overflows, 1 + αt overflows, and ∞/∞ is NaN. The kernel's form never approaches that edge, because t₁ holds the denominator up. In isolation this is a narrow win, confined to the last twenty decades. It matters because it composes: the same rescaling is what keeps the path accumulator in range, which is where the failures actually occur.

c · Two accumulators whose ranges cancel

This is the one that bites in practice. Algorithm 1 carries Hv(t) ∼ 1/∏we, which grows, and wprod = ∏we, which shrinks, and multiplies them only at the leaf. Each can leave double's range while their product sits comfortably inside it — and ∞ × 0 is NaN. The rescaling multiplies them at every edge instead, so there is one accumulator, it is bounded by 1, and it is the answer. Evaluating both forms in double at the 8-node rule against a log-space reference:

Each row is one root-to-leaf path, evaluated at the top quadrature node t₈ = 0.980. "N distinct features" means a path of depth N where every edge splits on a different feature — so D = d = N in the paper's Table 1 notation, and each feature's chain is a single edge. The fourth row is the opposite extreme: one feature split ten times. The two middle columns are the intermediates that Algorithm 1 keeps apart until the leaf.

PathH(t₈)wprodTrue A(t₈)Alg. 1 formKernel form
6 distinct features, cover 0.42274.1e−30.930620.930620.93062 ✓
50 distinct features, cover 0.013.71e+991e−1000.370610.370610.37061 ✓
300 distinct features, cover 0.02inf02.7534e−3NaN at 6 of 8 nodes2.7534e−3 ✓
1 feature split 10×, cover 1e−40inf00.98014NaN at all 80.98014 ✓
200 × 0.02, then 1 feature × 12 × 1e−30inf01.9255e−2NaN at all 81.9255e−2 ✓

In every failing row the true value is an ordinary double — only the intermediates leave the range, and they leave it in opposite directions, which is why the product would have been fine. Relative error of the kernel form against a log-space reference stays at ≤ 4e−12 across all five rows.

Read the second row carefully

50 distinct features at cover 0.01 is roughly the paper's deepest benchmark model — Fashion-MNIST sparse, D = 52, d = 48 in Table 1. Algorithm 1's form still works there. But it has spent about 100 decades of headroom in each direction out of the ~308 that double allows, and the consumption is linear in depth: log₁₀H ≈ −log₁₀wprod ≈ d · log₁₀(1/w̄). Triple the depth, or drop the mean cover by a factor of ten, and the row becomes the third row. The kernel's form has no such budget to spend — its accumulator is bounded by 1 at every depth.

d · Degrading to the limit instead of to NaN

The three properties combine into one useful behaviour. When a hot chain's cover product underflows to exactly 0, the kernel evaluates (1 − 0)/(t + 0) = 1/t — and 1/t is precisely limW→0 α/(1 + αt), the mathematically correct value at that limit. The formula does not merely avoid failing; it returns the right answer at the point where the alternative returns NaN. That is a consequence of h·t surviving when c does not, which is the same structural fact as the floor in (a).

Where the kernel still breaks

It is not unconditionally safe, and the exact-cancellation argument in (a) has a floor of its own. Measured on the cold branch at t₈: live_g is exactly −50.365 for c down to 1e−300; at the smallest denormal c = 4.94e−324 the denominator c(1−t) underflows to 0 while the numerator does not, giving −∞; at c = 0 exactly, L308 computes (0 − 0)/0 = NaN.

What makes this remote rather than routine is that c_acc is a per-feature chain product — typically a handful of terms, since it only accumulates over repeated splits on the same feature — whereas Algorithm 1's wprod is a path-global product over every edge on the path. The accumulator that realistically underflows is exactly the one the rescaling eliminates.

And upstream closes the hole explicitly. shapiq's converter builds the array as c_acc = where(isfinite(p_e) & (p_e > 0), 1.0 / p_e, 1e-300) — a hard floor of 1e−300 wherever the reciprocal would be 0 or undefined, which keeps c comfortably above the denormal range where the two failure modes above live. That line is also independent confirmation of §03's reading: c_acc is defined as 1/pe, exactly the c = h/pe the derivation predicts.

The path invariant that follows

Group the path's edges by feature. Telescoping (§07) makes the net contribution of feature f to A equal to u at its last edge, which is Wf(1 + αft) with Wf the full chain cover product. Multiplying over features:

A =f ∈ M(v) Wf(1 + αft) = ( ∏e ∈ path we ) · Hv(t)   ⟹   Eleaf = A · val(v) = Rv · Hv(t)

So E at a leaf is exactly Algorithm 1's returned c · val(v) · wprod, and E at an internal node is exactly its Hu — reached by a different route, with one fewer running product and no reciprocals.

A branch that vanishes

Algorithm 1 needs a dedicated case for a broken chain: else if p[f] = 0 then p_e ← 0; p↑ ← 0. In the (h, c) form that case is not special — h = 0 flows through u = c·(1−t) like any other value. The kernel has no corresponding branch, and §08 shows the payoff: the broken-chain case becomes an identically zero contribution that can be skipped outright.


04 — InputsQuadTree and split semantics

QuadTree L20–56 is a flat, forest-wide struct of arrays. Two of its fields are the kernel's whole preprocessing story; the rest are ordinary tree plumbing.

thresholds, features
children_left/right
Standard node arrays. features[node] = -2 and children_left[node] = -1 mark leaves.
parents
Tree parent, -1 at each root. Used only to recover the incoming edge's split feature: features[parents[node]]. This is why quad_extract is never called on a root.
ancestors
Preprocessed. The closest ancestor node whose incoming edge splits on the same feature, or -1 if this is the feature's first appearance on the path. Purely structural — independent of the sample — so it is hoisted out of the traversal entirely. This replaces Algorithm 1's runtime p[·] map with its UNSEEN sentinel and its save/restore.
c_acc
Preprocessed. The accumulated cold factor: the product of cover ratios we along the same-feature chain from its first edge down to this node. Also purely structural. Together with act it determines pe without a division.
values
Per-node prediction; only the leaf entries are read L339.
cat_values, cat_start
cat_size
CSR-style categorical split sets. cat_size[node] > 0 switches the node from a threshold test to set membership.
children_left_default
Default direction for a missing value at this node.
decision_type
Whether the converted model means < or <=. Mirrors the linear kernel's convention.

goes_left L38–55

bool goes_left(double feature_value, int node) const{    if (std::isnan(feature_value))                      // missing → model's default branch        return children_left_default[node] != 0;    if (cat_size[node] > 0)                             // categorical → set membership    {        const int64_t category = static_cast<int64_t>(feature_value);        const int64_t *begin = cat_values + cat_start[node];        return std::binary_search(begin, begin + cat_size[node], category);    }    if (decision_type == Q_LESS_THAN)        return feature_value < thresholds[node];    return feature_value <= thresholds[node];}

Algorithm 1 compresses all of this into se ← [x satisfies edge v→u]. The kernel spends fifteen lines on it because real converted models carry NaN policy, categorical sets, and two incompatible comparison conventions. Note that the categorical branch requires cat_values to be sorted per nodebinary_search is silently wrong otherwise.


05 — StateThe workspace

QuadWorkspace L65–161 is allocated once per kernel invocation and reused across every tree and every row. Nothing in the hot loop allocates.

A L72
(max_depth+2) × n_quad. Row d holds the path polynomial ∏ u for the node currently at depth d. Row 0 is reset to 1 at the start of each tree L274.
E L73
(max_depth+2) × n_quad. Row d holds the subtree sum H for the node at depth d. The +2 is because a node at depth forms a pointer to row depth+1 unconditionally L285.
live_g L74
n_feats × n_quad. For each feature currently live on the path, its Banzhaf ratio (h−c)/u. Indexed by feature, not by node — so a repeated feature overwrites its own row and must be restored on the way out.
gamma L77
max(max_order,1) × n_quad. Row k = running product of live_g over the first k+1 chosen subset members. The prefix-sharing device of §09.
delta L75
n_quad. The per-edge weighted telescoping difference w[m]·E[m]·(g_new − g_old), computed once and reused for every subset.
act L76
num_nodes bits. The hot flag. Written for both children when a node is expanded L329–330, then AND-ed with the ancestor's flag when the child is entered L292–293.
path_feats L78
Sorted distinct features on the current path — Algorithm 1's set A. A feature is inserted only at its first edge L319–321 and erased at the matching exit.
stack L85
Explicit DFS frames. Reserved at 5·max_depth + 10; the true bound is 4·max_depth + 1, since expanding a node pushes five frames and immediately pops one.
binom / order_offsets / cum
The combinatorial index, built once in the constructor. See §06.
merged_scratch
candidates / chosen
Scratch for ranking and subset enumeration. chosen is sized max_order and holds the current subset prefix.

Why indexing live_g by feature is safe

Two different live features never collide because they occupy different rows. The same feature appearing twice on a path does collide — which is exactly what the telescoping wants: the deeper edge's ratio replaces the shallower one, and on exit the kernel recomputes the ancestor's ratio from act[ancestor] and c_acc[ancestor] rather than paying for a save buffer L347–354, L377–384.


06 — OutputFlat blocks and lexicographic rank

Algorithm 1 writes into an abstract map Φ[S]. The kernel writes into one flat double* per row, and the layout is a hard contract with the Python side.

The constructor L104–134 builds three tables:

  1. binom — Pascal's triangle up to max_order, (n_feats+1) × (max_order+1). Entries with k > n are left at 0, which is the correct value.
  2. order_offsets[s] — where order s's block starts. Blocks are laid out consecutively for s = min_order … max_order, each of length C(nfeats, s). Orders outside the range keep offset 0, which is why the first-order write is gated on min_order == 1 L240 — without the guard it would alias into whatever block starts at 0.
  3. cum[r][v] = u < v C(nfeats − 1 − u, r) — the prefix sums that turn combination ranking into O(s) work instead of O(s·nfeats).

merged_rank L139–160

Given the sorted subset prefix chosen[0..size) and a pivot feature not in it, this merges the pivot into place and returns the position of the resulting tuple in itertools.combinations(range(n_feats), s) — lexicographic order.

    int s = size + 1;    int64_t rank = 0;    int prev = -1;    for (int i = 0; i < s; ++i)    {        const int64_t *cum_r = cum.data() + (size_t)(s - 1 - i) * (n_feats + 2);        rank += cum_r[merged[i]] - cum_r[prev + 1];        prev = merged[i];    }    return rank;

Each iteration counts the tuples that share the prefix merged[0..i) but have a smaller i-th element — a difference of two prefix sums rather than a loop.

Worked example — n_feats = 5, chosen = [1, 4], feature = 2

merged = [1, 2, 4],   s = 3pivot inserted at position 1
i=0, r=2:   cum₂[1] − cum₂[0] = C(4,2) − 0 = 6tuples starting with 0
i=1, r=1:   cum₁[2] − cum₁[2] = 0no room between 1 and 2
i=2, r=0:   cum₀[4] − cum₀[3] = 4 − 3 = 1the tuple (1,2,3)

rank = 7. And list(itertools.combinations(range(5), 3))[7] is (1, 2, 4). ✓ The comment at L80–83 is not decorative: the Python side pairs this array with shapiq.utils.sets.generate_interaction_lookup, and a mismatch misplaces values silently rather than raising.

Order 1 skips merged_rank entirely — the rank of the singleton {i} is i, so the write is just out[order_offsets[1] + feature] L242.


07 — Traversalquadrature_inference and the stage machine

Algorithm 1's DFS is recursive and returns a vector. quadrature_inference L257–393 is an explicit stack machine over four stages, with A and E living in depth-indexed rows instead of being passed by value.

Frame scheduling L324–336

int right = tree.children_right[node];if (left >= 0){    bool go_left = tree.goes_left(x[tree.features[node]], node);    ws.act[left]  = go_left;          // hot bit for the child edges    ws.act[right] = !go_left;    ws.stack.push_back({node,  depth,     3});   // leave      (popped 5th)    ws.stack.push_back({node,  depth,     2});   // after right (popped 4th)    ws.stack.push_back({right, depth + 1, 0});   //            (popped 3rd)    ws.stack.push_back({node,  depth,     1});   // after left  (popped 2nd)    ws.stack.push_back({left,  depth + 1, 0});   //            (popped 1st)}
pop order → left, d+1stage 0 node, dstage 1 right, d+1stage 0 node, dstage 2 node, dstage 3 descend left E[d] ← E[d+1] descend right E[d] += E[d+1] quad_extract memcpy, L365 accumulate, L370 + restore, L374–389 left subtree sum arrives in E[d+1] right subtree sum overwrites E[d+1]
The four stages. Stage 0 enters a node; stages 1 and 2 fold each child's subtree sum out of row d+1 into row d before that row is reused; stage 3 is the only place a non-leaf node contributes to the output. A leaf never reaches stages 1–3 — it writes E[d] = A[d]·value and extracts inside stage 0 L337–361.

The scheduling is what makes the depth-indexed rows safe: row d+1 is read at stage 1 before the right subtree is allowed to touch it, and row d−1 (the parent's A) is never written by any descendant, because descendants only write rows ≥ d.

Stage 0 — the edge update L287–323

int ancestor = tree.ancestors[node];if (node != root){    if (ancestor >= 0)        ws.act[node] = ws.act[node] && ws.act[ancestor];   // hot chain is transitive    int feature = tree.features[tree.parents[node]];    double h = ws.act[node] ? 1.0 : 0.0;    double c = tree.c_acc[node];    const double *A_prev = ws.A.data() + (size_t)(depth - 1) * n_quad;    double *g_row = ws.live_g.data() + (size_t)feature * n_quad;    if (ancestor >= 0)    {        double h0 = ws.act[ancestor] ? 1.0 : 0.0;        double c0 = tree.c_acc[ancestor];        for (int m = 0; m < n_quad; ++m)        {            double u_new = h  * t[m] + c  * (1.0 - t[m]);            double u_old = h0 * t[m] + c0 * (1.0 - t[m]);            A_row[m] = A_prev[m] * (u_new / u_old);      // telescope: divide the old factor out            g_row[m] = (h - c) / u_new;        }    }    else    {        for (int m = 0; m < n_quad; ++m)        {            double u_new = h * t[m] + c * (1.0 - t[m]);            A_row[m] = A_prev[m] * u_new;                // first edge on this feature            g_row[m] = (h - c) / u_new;        }        ws.path_feats.insert(/* sorted position */, feature);    }}
Algorithm 1
if p[f] = UNSEEN then
    p_e ← 1/w_e if s_e, else 0;   p↑ ← 1;
else if p[f] = 0 then
    p_e ← 0;   p↑ ← 0;
else
    p_e ← p[f]/w_e if s_e, else 0;   p↑ ← p[f];
α_e ← p_e − 1;
c′ ← c ⊙ (1 + α_e t);
if p[f] ≠ UNSEEN and |p[f] − 1| > ε then
    c′ ← c′ ⊘ (1 + (p[f] − 1) t);
Save p[f] and whether f ∈ A; set p[f] ← p_e; add f to A;

Three branches, a runtime map with a sentinel, an ε-guard against dividing by a factor near zero, and an explicit save for the unwind.

L287–323
ancestor = ancestors[node]      // precomputed
act[node] &= act[ancestor]      // hot chain
h = act[node];  c = c_acc[node]

if ancestor >= 0:
    A[d] = A[d-1] * (u_new / u_old)
else:
    A[d] = A[d-1] * u_new
    path_feats.insert(feature)

live_g[feature] = (h - c) / u_new

Two branches, no map, no sentinel, no ε fast-path. u_old is a convex combination of h0 ∈ {0,1} and c0 ∈ (0,1], so on a hot ancestor chain it is bounded below by t₁, and on a cold one it cancels against the numerator.

r n₁ n₂ n₃ split f, w₁ split g, w₃ split f, w₂ stored per node (structural, sample-independent) c_acc[n₁] = w₁ c_acc[n₂] = w₃ c_acc[n₃] = w₁·w₂ ancestors[n₃] = n₁ ancestors[n₃] effect on A A ×= u₁ A ×= u₃ A ×= u₂ / u₁ net over f: u₂ only
Telescoping over a repeated feature. Feature f splits twice. c_acc[n₃] already holds the whole chain product w₁w₂, so u₂ = h·t + w₁w₂(1−t) is the finished factor for f; the ancestors pointer supplies u₁ to divide out the partial one. Both quantities on the left are computed at model-conversion time — Algorithm 1 rediscovers them on every sample.

Leaf and unwind L337–361, L372–390

A leaf writes E[d] = A[d]·value, calls quad_extract, then restores. An internal node does the same at stage 3, after both children have folded into E[d]. The restore is identical in both places:

  • Repeated feature (ancestor >= 0): recompute live_g[feature] from the ancestor's h0, c0. The feature stays in path_feats — it is still live above.
  • First occurrence: erase the feature from path_feats. live_g[feature] is left stale, which is harmless because membership is gated by path_feats, and any sibling subtree that splits on f again will overwrite the row at its own stage 0.

This is Algorithm 1's Restore p[f]; if f was not previously active, remove f from A — with the recomputation replacing the save.

Forest and batch L271–276, L395–418

roots[] indexes into one flat node array covering the entire ensemble, so the workspace is sized once from tree.num_nodes and tree.max_depth and every tree accumulates into the same output row. act and path_feats are cleared once per row L268–269, not per tree — safe because the DFS restores them symmetrically, and because act for any node is written by its parent before it is ever read. quadrature_tree_shap then loops rows with out_stride spacing, reusing the single workspace throughout.


08 — Extractionquad_extract

L204–255. Called once per edge, with E[depth] already holding the subtree sum. It computes Eq. 11's bracket, emits the first-order value, and hands the rest to the subset enumerator.

The cold-chain early return L214–216

int ancestor = tree.ancestors[node];if (ancestor >= 0 && !ws.act[ancestor])    return;  // both telescoping terms cancel exactly once the hot chain is broken

This has no counterpart in Algorithm 1, and it is exact rather than approximate:

Why the contribution is identically zero

If act[ancestor] = false, then act[node] = false toothe AND at L293
h = h₀ = 0  ⟹  u = c(1−t),   u₀ = c₀(1−t)both edges cold
gnew = −c / (c(1−t)) = −1/(1−t) = −c₀ / (c₀(1−t)) = goldthe cold factors cancel
δ = gnew − gold = 0  for every tmindependent of the node, the feature, and the quadrature point

Algorithm 1 computes this zero and multiplies it through every subset of the active path before adding it to Φ. The kernel recognises it from one bit and skips the entire subset lattice below that edge. Note the condition is on the ancestor, not on the node: an edge that breaks a still-hot chain has h = 0 but h₀ = 1, δ ≠ 0, and must be processed.

δ and the first-order write L217–243

if (ancestor >= 0){    double h0 = ws.act[ancestor] ? 1.0 : 0.0;    double c0 = tree.c_acc[ancestor];    for (int m = 0; m < n_quad; ++m)    {        double u0 = h0 * t[m] + c0 * (1.0 - t[m]);        ws.delta[m] = w[m] * E_row[m] * (g_new[m] - (h0 - c0) / u0);        sum1 += ws.delta[m];    }}else{    for (int m = 0; m < n_quad; ++m)    {        ws.delta[m] = w[m] * E_row[m] * g_new[m];   // p↑ = 1 ⟹ the correction term is 0        sum1 += ws.delta[m];    }}if (ws.min_order == 1)    out[ws.order_offsets[1] + feature] += sum1;

Three things are folded into delta that Algorithm 1 keeps separate until the innermost loop: the quadrature weight wm, the subtree sum Hu, and the telescoping difference. As a result the per-subset cost in §09 is a bare dot product of delta against gamma, with nothing else to multiply.

sum1 — accumulated in the same pass — is the order-1 value: it is exactly Eq. 11 with S = ∅, where the empty product is 1. The kernel gets it for free rather than routing the empty subset through the enumerator.

The ancestor < 0 branch drops the correction term entirely, matching Algorithm 1's p ← 1 on a feature's first appearance: (1−1)/(1+(1−1)t) = 0.

Candidate set L244–254

candidates = path_feats \ {feature}, rebuilt per edge. This is Algorithm 1's A \ {f}. Because path_feats is kept sorted and the pivot is removed by value, candidates is sorted too — which is what lets quad_enumerate generate subsets in lexicographic order and lets merged_rank assume a sorted chosen.


09 — Enumerationquad_enumerate

L166–202. One recursion over the subset lattice of candidates that emits every order from min_order to max_order, sharing the γ product between a subset and its prefixes.

for (size_t i = start; i < candidates.size(); ++i){    int j = candidates[i];    const double *g_j = ws.live_g.data() + (size_t)j * n_quad;    double *gamma_level = ws.gamma.data() + (size_t)level * n_quad;    const double *gamma_prev = (level == 0) ? nullptr                              : ws.gamma.data() + (size_t)(level - 1) * n_quad;    for (int m = 0; m < n_quad; ++m)        gamma_level[m] = (level == 0 ? 1.0 : gamma_prev[m]) * g_j[m];   // extend the prefix    chosen[level] = j;    int order = level + 2;  // subset of size level+1 plus the pivot feature    if (order >= ws.min_order && order >= 2)    {        double contribution = 0.0;        for (int m = 0; m < n_quad; ++m)            contribution += weighted[m] * gamma_level[m];   // dot product, nothing else        int64_t rank = ws.merged_rank(chosen, level + 1, feature);        out[ws.order_offsets[order] + rank] += contribution;    }    if (order < ws.max_order)        quad_enumerate(ws, candidates, i + 1, level + 1, chosen, weighted, feature, out);}
level 0 · order 2 level 1 · order 3 level 2 · order 4 γ₀ = g_a γ₀ = g_b γ₀ = g_c γ₁ = γ₀·g_b γ₁ = γ₀·g_c γ₁ = γ₀·g_c γ₂ = γ₁·g_c → {f,a,b,c} {f,a} {f,b} {f,c} {f,a,b} {f,a,c} {f,b,c} one multiply per box, not one product per subset
γ prefix reuse. With candidates = {a, b, c} and pivot f, each lattice node extends its parent's γ row by a single live_g factor and writes one output cell. Algorithm 1's γ_P ← ∏_{j∈P} recomputes the whole product for each of the C(|A|, s−1) subsets; here a size-k subset costs O(nquad) regardless of k, and every order in range is emitted during the same descent.

Two details worth pinning down:

  • order = level + 2 — the subset has level+1 members and the pivot feature makes it level+2. The order >= 2 guard is therefore always true; it is there to keep the first-order block, which is written elsewhere, out of reach.
  • Emit and descend are independent. A node emits if order >= min_order and descends if order < max_order. With min_order = 2, max_order = 4 the same descent writes into three different output blocks.

The recursion is a genuine function call per level, but the levels are bounded by max_order, not by tree depth — so the recursion depth is a small constant even on very deep trees.


10 — The comparisonFourteen departures from Algorithm 1

Everything below is a deliberate deviation, not a divergence in what is computed. Given the same tree, sample, and quadrature rule, the kernel and Algorithm 1 produce the same numbers — up to the floating-point differences the reparametrisation is designed to create.

#Algorithm 1shapiq_qshap.cppKindConsequence
1pe = ∏ 1/we; factor (1 + αet)u = h·t + c·(1−t) from act and c_accStabilityNo reciprocal of a cover is ever formed. Quantities stay in [0,1].
2separate wprod accumulatorgone — absorbed into ASpeedOne fewer multiply per edge and one fewer live value.
3p[f] map with UNSEEN; discover e↑ at runtimeancestors[node], precomputed at conversionSpeedStructural work leaves the per-sample loop; only the 1-bit act is sample-dependent.
4save/restore p[f] on the unwindrecompute live_g from c_acc[ancestor]EngineeringNo save stack; the restore is the same arithmetic as the entry.
5else if p[f] = 0 branchno branchStabilityh = 0 is not a special case in the (h,c) form.
6ε fast-path: divide only when |p[f] − 1| > εalways divides by u_oldEngineeringThe guard skips a divisor near 1, not near 0 — an optimisation for near-null features, not a safeguard. The kernel has no equivalent no-op to skip, since u_old absorbs the cover.
7recursive DFS returning a fresh vectorexplicit stack, 4 stages, depth-indexed A/E rows written in placeSpeedNo per-edge allocation, no stack-depth limit, fixed memory footprint.
8one fixed order smin_order … max_order in one traversalSpeedOrders 1–4 cost one pass, not four.
9γP ← ∏j∈P, recomputed per subsetgamma[level] prefix productsSpeedEach subset costs O(nquad) instead of O(s·nquad).
10no pruning; computes the zerocold-chain early return L215SpeedSkips the whole subset lattice under a chain that is already broken.
11m wmHu[m]δe[m]γP[m] inside the subset loopwm and H pre-folded into delta once per edgeSpeedThe innermost loop is a bare dot product.
12abstract map Φ[S]flat array, per-order blocks, O(s) lexicographic rank via cumEngineeringBinary-compatible with shapiq's interaction lookup; no hashing.
13one tree, one sampleroots[] forest + row batch, one shared workspaceEngineeringAllocation cost amortised over the whole ensemble and dataset.
14se ← [x satisfies edge]goes_left: NaN defaults, categorical sets, < vs <=EngineeringHandles converted XGBoost / LightGBM / CatBoost models as they actually are.

The two that change the numbers

Departures 1 and 5 are not just faster paths to the same floating-point result — they produce different floating-point results, and that is the point. Algorithm 1 accumulates H (which grows like 1/∏we) and wprod (which shrinks like ∏we) separately, and multiplies them only at the leaf; each can leave double's range while their product sits well inside it. The kernel multiplies them at every edge, so the running value never leaves [0,1]. The mechanism is not that a division by a small cover was avoided — that division is well-conditioned. See §03 for the measurements.

Departures 8, 9, and 10 change the complexity. Algorithm 1 as written costs O(C(|A|, s−1) · s · n) per edge for order s; the kernel costs O(∑k C(|A|, k) · n) for all orders up to max_order together, with the cold-chain return removing edges entirely.

What is unchanged

The recursion structure (enter, descend, extract on the way out), the telescoping idea itself, the placement of the extraction at the child edge rather than the node, and the mathematical content of Eq. 11 are all carried over intact. If you read Algorithm 1 with §03's substitution in hand, the kernel is line-for-line recognisable.


11 — While readingInvariants worth trusting

A[d−1] is stable
A node at depth d reads its parent's row while descendants write only rows ≥ d. No descendant can clobber it.
E[d+1] is read in time
Stage 1 copies the left child's row out before the right child's stage 0 overwrites it. This is the only reason the frame push order matters.
act[node] is written once
By the parent at expansion, then AND-ed at entry. Each node is entered exactly once per row, so the in-place mutation at L293 is not a re-entrancy hazard.
path_feats mirrors the path
Insert at first occurrence, erase at the matching exit. On leaving a tree it is empty again, which is why the per-row clear() at L269 is belt-and-braces rather than load-bearing.
quad_extract never sees a root
Both call sites guard on node != root. It dereferences parents[node] unconditionally, so the guard is required, not defensive.
stack ≤ 4·max_depth + 1
Each expansion is +5 −1. The reserve at L101 is generous by design; std::vector would grow correctly anyway.
min_order gates order 1
order_offsets is only meaningful inside [min_order, max_order]. With min_order > 1, order_offsets[1] is 0 and the guard at L240 is what keeps the order-1 write from aliasing the first real block.

Choosing n_quad

Theorem 2 of the paper bounds the required node count for exactness at order s: n ≥ ⌈(d − s + 1)/2⌉, where d is the maximum number of unique features on a root-to-leaf path. Higher orders need fewer points, so a kernel run configured for a range of orders is governed by its smallest order — min_order, not max_order. The paper's practical finding is that 8 points reach the float32 noise floor regardless; this kernel works in double throughout and takes n_quad, t, and w from the caller, so the choice is the caller's to make.


12 — ScopeWhat the paper has that this file doesn't

  • The converter. ancestors and c_acc are inputs; nothing in this file computes them, and nothing in this local repository does either. They come from upstream shapiq: tree/quadrature/computer.py builds ancestors through create_edge_tree(), derives the Gauss–Legendre nodes and weights with _gauss_legendre_unit(), and calls this kernel from _explain_cpp(). Reading that file alongside this one is the fastest way to see what the kernel expects.
  • Explicit SIMD. §7 of the paper describes vectorising 4 points per SSE register on CPU and 32 per warp on GPU. This file writes plain for (m = 0; m < n_quad; ++m) loops over contiguous double arrays with __restrict__ on the pointers, and leaves the vectorisation to the compiler. The layout is deliberately vector-friendly — every buffer is nquad-contiguous — but the intrinsics are not here.
  • Parallelism. No OpenMP. The row loop in quadrature_tree_shap L412 is embarrassingly parallel apart from the shared QuadWorkspace, which would need to be per-thread.
  • float32. The paper's precision analysis is about the float32 output format; this kernel is double end to end.
  • The GPU path. CPU only.