Skip to content

feat(train): parallelize RF SHAP explainability across CPU cores#259

Merged
zachtheyek merged 3 commits into
masterfrom
feature/shap-parallel-explainer
Jul 24, 2026
Merged

feat(train): parallelize RF SHAP explainability across CPU cores#259
zachtheyek merged 3 commits into
masterfrom
feature/shap-parallel-explainer

Conversation

@zachtheyek

@zachtheyek zachtheyek commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #258. train.py::_compute_or_load_shap_values ran shap.TreeExplainer single-threaded — shap's C TreeSHAP has no OpenMP / n_jobs, and the RF's own n_jobs doesn't apply (shap re-walks the trees itself) — so on a production 1000-tree forest the interaction pass alone took ~76 h (measured ~183 s/sample × 1500), stalling the end of every training run for days. This parallelizes it across all CPU cores.

What changed

  • aetherscan/shap_parallel.py (new, TF-free): a process-pool wrapper. Chunks samples across manager.n_processes (= cpu_count() by default) workers, each rebuilding a stock TreeExplainer on its chunk — rebuild-per-worker avoids the shap #1204 segfault from pickling a pre-built explainer; workers load the RF from its persisted joblib; BLAS threads pinned. Byte-identical to the serial result; ~40–45× on a 96-core node. Runs under a forkserver context with an empty preload so workers stay lightweight: train.py imports TF at module level, and spawn would re-import the parent's __main__ (aetherscan.main → TF → the whole training stack) into every worker — the fork server is spawned once, clean, and workers fork from it importing only shap_parallel + shap. (This was corrected from an initial spawn in a follow-up commit during self-review.)
  • train.py: _compute_or_load_shap_values drives summary + interaction + log-loss through parallel_shap; select_positive_class_shap moved to shap_parallel (shared with the workers). Expected-value uses one lightweight in-process explainer.
  • Tests: tests/unit/test_shap_parallel.py (new, TF-free) — the shape-normalization tests (relocated from test_train_utils.py) plus a parametrized MP-vs-single-process allclose correctness test for all three passes.
  • docs/TRAINING_PIPELINE.md: a SHAP-performance subsection — why CPU MP, the GPU findings (GPUTreeExplainer is faster on interaction but needs a from-source CUDA build + a < 32 distinct-features-per-path warp-lane constraint + a broken interventional log-loss path, shap #4270/#3936/#1726), and a switchover runbook. max_depth stays unbounded; the constraint is documented only as GPU context.

Validation

  • Byte-identity is enforced by the new unit test (single vs 4-worker allclose, all three passes) — CI runs it (shap is a runtime dep).
  • Empirically ~40–45× on bla0 (96 cores) during the experiment behind feat(train): parallelize RF SHAP explainability across CPU cores (the ~80h single-threaded tail) #258; RAM-cheap (~1.4 GB/worker → core-bound).
  • The .nosync TF-import deadlock blocks the full pytest suite locally, so the shape-normalization + ValueError paths were checked standalone; the MP path is covered by CI.

Notes

  • The rewritten block drops the resolved # NOTE: come back to this later questions (now answered in the docs).
  • max_depth intentionally unchanged (unbounded, sklearn default).

The RF-explainability step ran shap.TreeExplainer single-threaded (shap's C TreeSHAP has
no OpenMP / no n_jobs; the RF's own n_jobs doesn't apply — shap re-walks the trees itself),
so on a 1000-tree forest the interaction pass alone took ~76h (~183 s/sample x 1500),
stalling the end of every training run for days.

Add aetherscan.shap_parallel: a TF-free process-pool wrapper that chunks the samples across
all cores (manager.n_processes = cpu_count() by default), each worker rebuilding a stock
TreeExplainer (rebuild-per-worker avoids the shap #1204 segfault from pickling a prebuilt
explainer) with BLAS threads pinned. Byte-identical to the serial result; ~40-45x on a
96-core node. Drives all three passes (summary, interaction, log-loss).

select_positive_class_shap moves to shap_parallel (shared with the workers); its tests move
to the new TF-free tests/unit/test_shap_parallel.py alongside the MP correctness test.

docs/TRAINING_PIPELINE.md documents the CPU-vs-GPU comparison: GPUTreeExplainer is faster on
interaction but needs a from-source CUDA build + a depth<32 (distinct-features-per-path)
constraint + a broken interventional log-loss path — not worth the maintenance for a few
minutes at the end of a multi-day run. max_depth stays unbounded; the constraint is noted
only as GPU context, with a switchover runbook.

Closes #258
@github-actions github-actions Bot added the enhancement New feature or request label Jul 23, 2026
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @zachtheyek's task in 3m 56s —— View job


PR review — feat(train): parallelize RF SHAP explainability across CPU cores


Nice work. This is a well-scoped, well-justified change: the underlying problem (single-threaded TreeSHAP on a 1000-tree forest ⇒ ~76 h interaction pass) is real, the shape of the fix (chunk samples across processes, rebuild a stock TreeExplainer per worker) is the right shape, and the design constraints (TF-free workers under spawn, rebuild-per-worker to dodge shap #1204, BLAS pinning to avoid oversubscription) are all called out explicitly in the module docstring — that context alone will save a future reader a lot of time. Tests, docs, and the switch-to-GPU runbook round it out.

Four inline comments left (one substantive, three nits) — details on the PR files. Overall summary:

Correctness — 1 real concern, 1 edge case

  • train.py:2782-2784 — the "dump only if missing" fallback can silently feed a stale on-disk RF into the workers while expected_value is computed from the in-process one. Under the current flow it shouldn't fire (both fresh-train and resume paths guarantee the file matches self.rf_model.model), but the guard turns a stale-file corner case into a silent mismatch instead of an overwrite. Suggest just joblib.dump(...) unconditionally — one extra disk write per run, no more mismatch-shaped bugs. Inline comment has the details.
  • shap_parallel.py:142-143 — zero-row input falls through the max(1, min(...)) clamp into the in-process branch, then calls shap on an empty array. Not reachable via _compute_or_load_shap_values today (upstream guarantees n ≥ 1), but a small guard would make the module safe to reuse without inheriting that invariant.

Concurrency / harness fit

  • _worker_init mutates module-level _RF / _BACKGROUND in the caller process when n_workers == 1. Harmless in the current sequential call pattern (each pass overwrites the previous one), but worth being aware of if this ever gets called concurrently from the same process.
  • Not registered with ResourceManager. ProcessPoolExecutor's context manager cleans up on normal exit, and the pool is short-lived, so this is probably fine — but it does mean the _pools/_shared_memories bookkeeping in manager.py won't know about these workers if a SIGTERM lands mid-computation. Consistent with how other short-lived pools are handled elsewhere; flagging for awareness rather than as a required change.
  • BLAS pinning via os.environ.setdefault is exactly right for spawn — the child re-imports and hits _pin_worker_threads before importing numpy/shap. Worth calling out that the setdefault respects an already-set env, so a user who wants to un-pin for benchmarking can OMP_NUM_THREADS=4 ....

Tests

  • Good coverage on the shape-normalization paths (relocated cleanly from test_train_utils.py) plus the parametrized MP-vs-in-process allclose correctness check for all three passes.
  • assert_allclose(atol=1e-8) is looser than the "byte-identical" claim in the module docstring / PR body. TreeSHAP is per-sample independent so it should be bitwise equal; consider np.testing.assert_array_equal to lock the stronger claim in, or soften the wording. Inline comment on that.
  • Nice call moving TestSelectPositiveClassShap out of test_train_utils.py — that keeps the shape-normalization tests importable without dragging TF in via train.py.
  • test_more_workers_than_samples_is_safe clamps 4 workers → 2 (since n == 2), so it exercises the MP path with 1 sample per worker. Would be worth one more case that clamps all the way to n_workers == 1 (the in-process branch) — e.g. parallel_shap(rf_path, val_x[:1], "summary", 4) — to lock in that fallback too.

Docs

docs/TRAINING_PIPELINE.md addition is excellent — the "why not GPU" section (from-source CUDA build + < 32 distinct-features-per-path warp-lane constraint + broken interventional log-loss, with shap issue numbers) plus the four-step switchover runbook is exactly the level of context that keeps this decision reversible. The max_depth-unbounded rationale (Beta-VAE separability ⇒ naturally shallow trees; A/B via val-AUC as the safety check) is well-argued. Nothing to change.

Nits

  • sorted(pool.map(...), key=lambda r: r[0]) on shap_parallel.py:159 is redundant — ProcessPoolExecutor.map preserves input order. Inline suggestion posted.
  • _RAW_KINDS = ("summary", "interaction") + _LOGLOSS_KIND = "logloss" splits the "known kinds" registry across two constants; a single _KINDS = {"summary", "interaction", "logloss"} + a needs_background = kind == "logloss" predicate reads a shade cleaner. Cosmetic.
  • Docstring inconsistency between "byte-identical" (module docstring, PR body) and atol=1e-8 (test). See inline.

Approve-ish

Nothing here blocks merge in my view — the stale-RF fallback is the one I'd want addressed before merge (trivial fix), everything else is either a nit or a "consider for later." Well-documented, well-scoped, and the empirical 40-45× speedup on a 96-core node closes out #258 cleanly.
· branch

Comment thread src/aetherscan/train.py Outdated
Comment on lines +2782 to +2784
rf_path = os.path.join(self.config.model_path, f"random_forest_{tag}.joblib")
if not os.path.exists(rf_path):
joblib.dump(self.rf_model.model, rf_path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback dump could hide a stale-RF divergence. Under the current flow the file should already exist and match self.rf_model.model (either just saved by train_random_forest at L2591, or loaded from disk by try_load_rf_for_resume), so this branch usually doesn't fire. But if it ever does — e.g. the joblib is present from an earlier run under the same tag but was produced by a different model / different config — then:

  • workers load the stale on-disk RF for summary / interaction / log-loss
  • while expected_value at L2789 is computed from self.rf_model.model (the current in-process RF)

…and every downstream plot silently inherits the mismatch. The mismatch is exactly the case this defensive check was written to catch, but the if not os.path.exists guard turns "the file is stale" into a silent no-op instead of an overwrite.

Two safer options — either is a one-liner:

  • Always dump (idempotent, guarantees the file matches self.rf_model.model):
    joblib.dump(self.rf_model.model, rf_path)
  • Or assert-and-fail-fast: assert os.path.exists(rf_path), f"expected RF at {rf_path}" — trusting the upstream contract and turning the resume-path bug into a loud failure instead of a silent one.

Given the caller writes the RF unconditionally 190 lines earlier, "always dump" costs one extra disk write per training run (a few hundred MB, dwarfed by the SHAP work) in exchange for eliminating a whole class of mismatch bugs. Recommend that.

Adjacent nit: the comment on L2779-2781 is worth keeping as-is — it's exactly the kind of "why this file has to exist for workers to work" WHY that the CLAUDE.md style guide asks for.

Comment thread src/aetherscan/shap_parallel.py Outdated
Comment on lines +142 to +149
n = len(x)
n_workers = max(1, min(n_workers, n))

# n_workers == 1: run in-process (no pool) — used on single-core hosts or tiny inputs.
if n_workers == 1:
_worker_init(rf_path, background)
_, out = _worker((0, kind, x, y))
return out

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty-input edge case falls into the in-process branch and calls shap on a zero-row array. If x is empty (n == 0), min(n_workers, 0) == 0, then max(1, 0) == 1, so we run the fallback path with x empty. shap's behaviour on a zero-row input isn't well-defined (probably returns an empty array of the right rank, but has been known to raise on some versions).

Not reachable through _compute_or_load_shap_values today (the caller guarantees n_summary/n_interact ≥ 1 via min(..., n_val) with n_val > 0 by the time RF eval runs), so this is a defence-in-depth suggestion rather than a live bug. A cheap guard here would make the module safe to reuse in a context that doesn't have that upstream invariant:

n = len(x)
if n == 0:
    # Preserve dtype/rank so callers can concatenate without a special case.
    return np.empty_like(x, shape=(0, *x.shape[1:]))
n_workers = max(1, min(n_workers, n))

Or, if you'd rather stay strict, raise: if n == 0: raise ValueError("cannot explain zero samples").

Comment thread src/aetherscan/shap_parallel.py Outdated
Comment on lines +158 to +160
) as pool:
results = sorted(pool.map(_worker, tasks), key=lambda r: r[0])
return np.concatenate([r[1] for r in results], axis=0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (safe to ignore): sorted(...) is redundant. ProcessPoolExecutor.map is documented to yield results in input order, so results will already be ordered by chunk_index when tasks is emitted in order (which it is — enumerate on L152). The sort is a harmless defensive belt-and-braces, but it also mildly obscures the invariant.

If you want to keep the defensive posture, an explicit assert reads clearer than a re-sort:

results = list(pool.map(_worker, tasks))
assert [r[0] for r in results] == list(range(len(results))), "map lost input order"
return np.concatenate([r[1] for r in results], axis=0)

Or just drop the sort and trust the contract. Either way, not blocking.

1000-tree forest at production sample counts runs for hours-to-days — dominated by the interaction
pass. SHAP values are per-sample independent, though, so we chunk the samples across processes and
call a **stock** ``shap.TreeExplainer`` in each worker (not a fork of the algorithm — just
parallelism). Measured ~40-45x on a 96-core node, byte-identical to the single-threaded result.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wording nit: "byte-identical" is a stronger claim than the test enforces. The correctness test uses np.testing.assert_allclose(pooled, serial, atol=1e-8) (tests/unit/test_shap_parallel.py:88), which is tolerance-based, not bitwise. In practice TreeSHAP is per-sample independent with no cross-sample reduction, so results should be bitwise identical — but the module docstring and PR body both promise "byte-identical" while the test only enforces allclose.

Two ways to make the wording and the test agree:

  • Tighten the test to np.testing.assert_array_equal(pooled, serial) (cheap, and would fail loudly if a future shap release starts doing something non-deterministic).
  • Soften the doc: replace "byte-identical" with something like "numerically identical (all-close to machine precision)" here and in the parallel_shap docstring on L137. (Also worth updating the PR body if you go this way.)

I'd lean toward the first — if the claim is true today and cheap to enforce, locking it in with assert_array_equal protects against silent drift.

spawn re-imports the parent's __main__ (aetherscan.main -> TensorFlow -> the whole training
stack: matplotlib/umap/train.py) into every worker, so the "TF-free workers" goal was not
actually met and each worker would pay a heavy import cost. Use a forkserver context with an
empty preload: the fork server is spawned once, clean (no __main__ re-import), and workers fork
from it importing only shap_parallel + shap. Docstring updated to match.
- train.py: dump the RF unconditionally so the workers load exactly the in-process model — a stale
  on-disk RF under a reused tag would otherwise silently diverge from the expected_value.
- shap_parallel.py: raise on zero-sample input (defence-in-depth); drop the redundant post-map sort
  (ProcessPoolExecutor.map preserves input order) and the now-dead chunk index.
- docstrings/test: TreeSHAP is per-sample deterministic, so the result is bitwise-identical — the
  correctness test now uses assert_array_equal (was allclose), backing that claim.
@zachtheyek

Copy link
Copy Markdown
Owner Author

Thanks — all four addressed in 1bf2c01:

  1. Always-dump the RF (train.py). Agreed: the if not os.path.exists guard turned a stale-file case into a silent no-op, while expected_value uses the in-process model — a latent divergence. Now dumps self.rf_model.model unconditionally, so workers load exactly the model the base value is computed from. The extra ~few-hundred-MB write is dwarfed by the SHAP work, as you noted.

  2. Empty-input guard (shap_parallel.py). Added raise ValueError("cannot explain zero samples"). I went with raising rather than fabricating an empty result, because the suggested np.empty_like(x, shape=(0, *x.shape[1:])) would be the wrong rank for the interaction pass (output is (0, F, F), not (0, F)). Not reachable via the caller today, but it makes the module safe to reuse.

  3. Redundant sort (shap_parallel.py). Dropped it — and removed the now-dead chunk_index from the worker's return, since relying on ProcessPoolExecutor.map preserving input order is exactly the intended invariant. Left a one-line comment stating it.

  4. "byte-identical" vs the test. You're right the claim outran the test. But the claim is true — TreeSHAP is per-sample independent with no cross-sample reduction, so chunking is bitwise-identical, not merely close. So rather than soften the docstring I tightened the test to assert_array_equal (was allclose(atol=1e-8)), making the stronger claim test-backed.

CI was green on the prior push; this commit is lint-clean locally (the full suite can't run on the .nosync box — TF import deadlock — so the MP correctness path is covered by CI). Re-tagging for a second pass.

@claude

@zachtheyek

Copy link
Copy Markdown
Owner Author

Second-pass check: all four first-pass notes are resolved in 1bf2c01 (always-dump RF, empty-input guard, dropped the redundant sort + dead chunk_index, tightened the correctness test to assert_array_equal), CI is green across 3.10/3.11/3.12 + CodeQL + pre-commit, and the branch is rebased on master. The prior re-tag did not surface a review — requesting a fresh look at 1bf2c01, particularly the forkserver-with-empty-preload worker setup in shap_parallel.py and the always-dump change in train.py. If it reads clean, an LGTM is all I need.

@claude

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

LGTM on 1bf2c01.

Forkserver + empty preload (shap_parallel.py:158-170) — reads clean. mp.get_context("forkserver") + ctx.set_forkserver_preload([]) is the right shape: the fork server is spawned once, doesn't re-import the parent's __main__ (so it never drags in aetherscan.main → TF → matplotlib/umap/train.py), and workers fork from it importing only shap_parallel + shap on demand. ProcessPoolExecutor(mp_context=ctx, initializer=_worker_init, initargs=(rf_path, background)) correctly routes through that context, and the deferred import joblib / import shap inside the initializer + worker (with the noqa: PLC0415 justification) keeps the ordering right relative to _pin_worker_threads(). Docstring accurately explains why forkserver over spawn — future readers won't have to re-derive it.

Always-dump RF (train.py:2779-2785) — resolves the stale-file divergence cleanly. Both the workers (via rf_path) and the in-process expected_value (line 2790) now derive from the same self.rf_model.model, so the invariant holds by construction rather than by "hopefully the on-disk file matches." Comment on 2783-2784 states the why (silent divergence under a reused tag), which is exactly the right level.

Other resolutions confirmed:

  • Empty-input guard at shap_parallel.py:147-148raise ValueError("cannot explain zero samples") is the right call over synthesizing an empty result (the interaction pass would need (0, F, F), not (0, F), as you noted).
  • Redundant sort dropped at shap_parallel.py:168-169; the one-line comment stating that ProcessPoolExecutor.map preserves input order locks the invariant in the code.
  • test_parallel_matches_single_process now uses np.testing.assert_array_equal (line 89), matching the "byte-identical" claim in the module docstring and PR body.

CI: green across tests (3.10/3.11/3.12), CodeQL (actions + python), and pre-commit — all successful at 1bf2c01.

No further concerns from my side. Ship it.

@zachtheyek
zachtheyek merged commit 9241e46 into master Jul 24, 2026
8 checks passed
@zachtheyek
zachtheyek deleted the feature/shap-parallel-explainer branch July 24, 2026 04:37
@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Release note draft — Improved

Training runs now finish dramatically faster. The final SHAP explainability step — which previously ran single-threaded and could stall a production 1000-tree Random Forest run for roughly 76 hours — is now parallelized across all available CPU cores, delivering an ~40–45× speedup on a 96-core node. Results are byte-identical to the previous serial computation, so existing outputs and downstream analyses are unaffected.

Draft for the changelog; edit or drop as needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(train): parallelize RF SHAP explainability across CPU cores (the ~80h single-threaded tail)

1 participant