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.
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
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
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.
| Paper | Meaning | In the code | Notes |
|---|---|---|---|
| tm, wm | Gauss–Legendre nodes and weights on [0,1] | t[m], w[m], n_quad | Caller-supplied. Paper fixes n=8; here it is a runtime parameter. |
| M(v) | distinct features on the root-to-leaf path | ws.path_feats L78 | Kept sorted; the paper's set A in Algorithm 1. |
| we | cover ratio nchild/nparent | folded into tree.c_acc L28 | Never stored per edge; only chain products are. |
| [x sat. e] | does the sample follow this edge | ws.act[node] L76 | The "hot" bit h ∈ {0,1}; AND-ed down each same-feature chain. |
| pe | accumulated multiplier at edge e | not stored — implied by (h, c) | pe = h / c with c = c_acc[node]. |
| 1 + αetm | the edge's factor in Hv | u_new = h*t[m] + c*(1-t[m]) | Equals c·(1 + αetm) — a rescaling, see §03. |
| e↑ | closest ancestor edge on the same feature | tree.ancestors[node] L27 | Precomputed at conversion, not discovered at runtime. |
| c (Alg. 1) | running path polynomial at the nodes | ws.A[depth] L72 | Depth-indexed rows, written in place. |
| wprod | running product of cover ratios | eliminated | Absorbed into A by the rescaling. |
| Hu | DFS return: subtree sum of Rv∅Hv(tm) | ws.E[depth] L73 | Leaf writes A·value; internal node sums its two children. |
| (pj−1)/(1+(pj−1)t) | per-feature Banzhaf ratio | ws.live_g[feature] L74 | Stored as (h - c) / u; one row of nquad per feature. |
| δe | the telescoping difference | ws.delta L75 | Pre-multiplied by wm and H: w[m]*E[m]*(g_new − g_old). |
| γP | ∏ of ratios over the chosen subset | ws.gamma[level] L77 | Level k holds the product over the current size-(k+1) prefix. |
| Φ[S] | output map, order-s interactions | out[order_offsets[s] + rank] | Flat array, per-order blocks, lexicographic rank. |
| s | interaction order | min_order … max_order | A 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)
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
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)
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)
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:
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_quad | t₁ | 1/t₁ | Measured range of live_g, sweeping c over [1e−12, 1] and both h |
|---|---|---|---|
| 8 paper | 0.019855 | 50.365 | [−50.365, +50.365] — matches the bound exactly |
| 10 repo default | 0.013047 | 76.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 − 1 | Paper: α/(1+αt₁) | Kernel: (1−c)/(t₁ + c(1−t₁)) |
|---|---|---|---|
| 1e−1 | 9 | 7.63556 | 7.63556 |
| 1e−8 | 1e+08 | 50.3649 | 50.3649 |
| 1e−100 | 1e+100 | 50.3650 | 50.3650 |
| 1e−300 | 1e+300 | 50.3650 | 50.3650 |
| 1e−320 | inf | NaN | 50.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.
| Path | H(t₈) | wprod | True A(t₈) | Alg. 1 form | Kernel form |
|---|---|---|---|---|---|
| 6 distinct features, cover 0.4 | 227 | 4.1e−3 | 0.93062 | 0.93062 | 0.93062 ✓ |
| 50 distinct features, cover 0.01 | 3.71e+99 | 1e−100 | 0.37061 | 0.37061 | 0.37061 ✓ |
| 300 distinct features, cover 0.02 | inf | 0 | 2.7534e−3 | NaN at 6 of 8 nodes | 2.7534e−3 ✓ |
| 1 feature split 10×, cover 1e−40 | inf | 0 | 0.98014 | NaN at all 8 | 0.98014 ✓ |
| 200 × 0.02, then 1 feature × 12 × 1e−30 | inf | 0 | 1.9255e−2 | NaN at all 8 | 1.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:
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.
children_left/right
features[node] = -2 and children_left[node] = -1 mark leaves.-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.-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.act it determines pe without a division.cat_size
cat_size[node] > 0 switches the node from a threshold test to set membership.< 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 node — binary_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.
(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.(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.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.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.n_quad. The per-edge weighted telescoping difference w[m]·E[m]·(g_new − g_old), computed once and reused for every subset.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.5·max_depth + 10; the true bound is 4·max_depth + 1, since expanding a node pushes five frames and immediately pops one.candidates / chosen
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:
binom— Pascal's triangle up tomax_order,(n_feats+1) × (max_order+1). Entries with k > n are left at 0, which is the correct value.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 onmin_order == 1L240 — without the guard it would alias into whatever block starts at 0.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
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)}
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); }}
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.
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_newTwo 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.
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): recomputelive_g[feature]from the ancestor's h0, c0. The feature stays inpath_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 bypath_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
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);}
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 haslevel+1members and the pivot feature makes itlevel+2. Theorder >= 2guard 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_orderand descends iforder < max_order. Withmin_order = 2, max_order = 4the 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 1 | shapiq_qshap.cpp | Kind | Consequence |
|---|---|---|---|---|
| 1 | pe = ∏ 1/we; factor (1 + αet) | u = h·t + c·(1−t) from act and c_acc | Stability | No reciprocal of a cover is ever formed. Quantities stay in [0,1]. |
| 2 | separate wprod accumulator | gone — absorbed into A | Speed | One fewer multiply per edge and one fewer live value. |
| 3 | p[f] map with UNSEEN; discover e↑ at runtime | ancestors[node], precomputed at conversion | Speed | Structural work leaves the per-sample loop; only the 1-bit act is sample-dependent. |
| 4 | save/restore p[f] on the unwind | recompute live_g from c_acc[ancestor] | Engineering | No save stack; the restore is the same arithmetic as the entry. |
| 5 | else if p[f] = 0 branch | no branch | Stability | h = 0 is not a special case in the (h,c) form. |
| 6 | ε fast-path: divide only when |p[f] − 1| > ε | always divides by u_old | Engineering | The 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. |
| 7 | recursive DFS returning a fresh vector | explicit stack, 4 stages, depth-indexed A/E rows written in place | Speed | No per-edge allocation, no stack-depth limit, fixed memory footprint. |
| 8 | one fixed order s | min_order … max_order in one traversal | Speed | Orders 1–4 cost one pass, not four. |
| 9 | γP ← ∏j∈P, recomputed per subset | gamma[level] prefix products | Speed | Each subset costs O(nquad) instead of O(s·nquad). |
| 10 | no pruning; computes the zero | cold-chain early return L215 | Speed | Skips the whole subset lattice under a chain that is already broken. |
| 11 | ∑m wmHu[m]δe[m]γP[m] inside the subset loop | wm and H pre-folded into delta once per edge | Speed | The innermost loop is a bare dot product. |
| 12 | abstract map Φ[S] | flat array, per-order blocks, O(s) lexicographic rank via cum | Engineering | Binary-compatible with shapiq's interaction lookup; no hashing. |
| 13 | one tree, one sample | roots[] forest + row batch, one shared workspace | Engineering | Allocation cost amortised over the whole ensemble and dataset. |
| 14 | se ← [x satisfies edge] | goes_left: NaN defaults, categorical sets, < vs <= | Engineering | Handles 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
clear() at L269 is belt-and-braces rather than load-bearing.node != root. It dereferences parents[node] unconditionally, so the guard is required, not defensive.reserve at L101 is generous by design; std::vector would grow correctly anyway.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.
ancestorsandc_accare inputs; nothing in this file computes them, and nothing in this local repository does either. They come from upstreamshapiq:tree/quadrature/computer.pybuildsancestorsthroughcreate_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 contiguousdoublearrays 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_shapL412 is embarrassingly parallel apart from the sharedQuadWorkspace, which would need to be per-thread. - float32. The paper's precision analysis is about the float32 output format; this kernel is
doubleend to end. - The GPU path. CPU only.