-
Notifications
You must be signed in to change notification settings - Fork 0
Explanation Workflow
The explanation stage produces per-seed SHAP explanations for a fitted
ClassifierEnsemble, never re-fitting it. It reuses the upstream
TrainingModel or PredictionModel (in-memory or from a .pkl) and
writes per-classifier ClassifierExplanation.pkl artefacts, per-seed
shap.Explanation pickles, per-seed bar / beeswarm plots, and
per-eruption waterfall plots.
Driver: ExplanationModel (src/eruption_forecast/model/explanation_model.py),
delegating SHAP work to ExplainerEnsemble
(src/eruption_forecast/ensemble/explainer_ensemble.py). Wrapped by
ForecastModel.explain(...).
ExplainerEnsemble only supports shap.TreeExplainer, which restricts
the stage to tree-based classifiers. From the 11 supported by
TrainingModel:
| Supported (tree) | Skipped (non-tree, warning logged) |
|---|---|
rf, lite-rf, gb, xgb
|
svm, lr, nn, dt, knn, nb, voting
|
Non-tree classifiers are skipped at the ExplainerEnsemble.explain()
loop with a warning so a mixed-classifier ensemble still produces SHAP
output for whichever classifiers qualify.
ExplanationModel dispatches on model.kind:
fm.explain(model="…")
│
┌─────────────┴─────────────┐
▼ ▼
model.kind == "training" model.kind == "prediction"
│ │
┌──────────────┴────────────┐ ┌───────────┴─────────────────────┐
│ Training reuse │ │ Prediction reuse │
│ │ │ │
│ features_df ← Training │ │ features_df ← PredictionModel │
│ Model.features_df │ │ .features_df │
│ labels ← TrainingModel │ │ labels ← PredictionModel.labels │
│ .labels │ │ │
│ │ │ eruption_dates: required for │
│ eruption_dates: optional │ │ waterfall plot rendering │
└──────────────┬────────────┘ └───────────────┬─────────────────┘
│ │
▼ ▼
output to explanation/training/ output to explanation/prediction/
Both modes share the same per-seed SHAP engine
(ExplainerEnsemble.explain_seed) and per-classifier
ClassifierExplanation payload.
| Mode | When to use | eruption_dates |
|---|---|---|
model="training" |
In-sample feature attribution diagnostics | optional — waterfalls skipped if missing |
model="prediction" |
Forecast-window attribution after predict()
|
required for waterfall plots |
For each SeedEnsemble in ClassifierEnsemble:
skip non-tree classifier (warn)
For each seed in SeedEnsemble.seeds:
shap.TreeExplainer(model, features_df[seed.feature_names])
→ shap.Explanation
normalise_shap_values() # pick positive-class slice
shorten_feature_name() # readable tsfresh labels
persist seed pickle → shap_values/{seed:05d}.pkl # save_per_seed=True
bundle into ClassifierExplanation
persist → ClassifierExplanation_{classifier_name}.pkl
# em.plot() phase (per-eruption waterfalls only):
For each ClassifierExplanation:
build_classifier_ensemble_summary(seed_ensemble, labels, eruption_dates)
For each EruptionWindow in the summary:
plot_shap_waterfall(seed[highest.random_state].shap_values[highest.index], ...)
Result on the instance: em.explanations: list[ClassifierExplanation].
See Per-eruption waterfall selection for how the argmax pick is chosen.
fm.explain(
model="prediction", # "training" | "prediction"
eruption_dates=None, # falls back to train() dates
save_per_seed=True,
plot_per_seed=True,
plot_aggregate=True, # aggregate bar + beeswarm per classifier
figsize=None, # auto-sized from max_display
max_display=20,
group_remaining_features=False,
dpi=150,
check_additivity=False,
overwrite_classifier_explanation=False,
output_dir=None,
overwrite=None,
n_jobs=None,
use_cache=True, # skip load path when False
verbose=None,
) -> SelfInternally calls ExplanationModel.explain(...) then .plot(...). use_cache is threaded down so use_cache=False skips the top-level ExplanationModel.load(...) short-circuit — see Cache semantics.
em.explain(
save_per_seed=True,
check_additivity=False,
overwrite_classifier_explanation=False,
) -> Selfem.plot(
figsize=None,
max_display=20,
group_remaining_features=False,
dpi=150,
plot_per_seed=True,
plot_aggregate=True,
)plot() always renders per-eruption waterfalls when eruption_dates
is available; per-seed bar + beeswarm rendering is gated on
plot_per_seed; per-classifier aggregate bar + beeswarm rendering
(stacks every seed into the NaN-padded union feature space) is gated
on plot_aggregate.
| Plot | Producer | Output stem |
|---|---|---|
| Per-seed beeswarm | ExplainerEnsemble.plot_seed() |
classifiers/{ClfName}/figures/beeswarm/{seed:05d}.png |
| Per-seed bar | ExplainerEnsemble.plot_seed() |
classifiers/{ClfName}/figures/bar/{seed:05d}.png |
| Aggregate bar (frequency-weighted mean |SHAP| across seeds) |
ExplainerEnsemble.plot_aggregate() → plot_aggregate_shap_bar()
|
classifiers/{ClfName}/figures/aggregate/bar.{png,csv} |
| Aggregate beeswarm (NaN-padded union feature space) |
ExplainerEnsemble.plot_aggregate() → plot_aggregate_shap_beeswarm()
|
classifiers/{ClfName}/figures/aggregate/beeswarm.{png,csv} |
| Per-eruption waterfall (single highest-probability seed × window per eruption day — see Per-eruption waterfall selection) |
ExplainerEnsemble.plot_waterfall() → plot_classifier_waterfall()
|
eruptions/{eruption_date}/{ClfName}_{datetime}_seed=_index=.png |
Standalone plot helpers in
src/eruption_forecast/plots/explanation_plots.py:
| Helper | Use case |
|---|---|
plot_shap_waterfall(explanation, ...) |
One waterfall for one observation |
plot_shap_beeswarm(explanation, ...) |
One beeswarm for one seed |
plot_shap_bar(explanation, ...) |
One bar plot for one seed |
plot_aggregate_shap_bar(classifier_explanation, ...) |
Frequency-weighted aggregate bar across seeds (builds importance table internally) |
plot_aggregate_shap_beeswarm(classifier_explanation, ...) |
Stacked-seeds aggregate beeswarm (builds NaN-padded union explanation internally) |
plot_classifier_waterfall(classifier_explanation, ...) |
Per-eruption highest-probability waterfall (the plot_waterfall workhorse) |
All renderers route through
plots/styles.py::shap_figure and save_figure, which closes the
matplotlib figure after saving.
Why one waterfall per eruption day per classifier — even though the SHAP stage produces N × M explanations under the hood.
- A
ClassifierEnsembleholds NSeedEnsembleseeds per tree classifier. Each seed independently produces a per-window probability and hard prediction over M prediction windows. -
ExplainerEnsemble.explain()runsshap.TreeExplaineronce per seed, so every one of those (seed × window) cells also carries its own SHAP explanation. - Rendering all N × M waterfalls per eruption day would swamp the output tree, so the pipeline collapses the grid to one waterfall per eruption day per classifier — the single (seed, window) with the highest positive-class probability inside that day.
- That collapse is done by
build_classifier_ensemble_summary(src/eruption_forecast/utils/ml.py) and consumed byplot_classifier_waterfall(src/eruption_forecast/plots/explanation_plots.py).
Diagram A — the (seed × window) matrix scoped to one eruption day.
Every cell carries both a probability p and a per-observation SHAP
explanation; the waterfall picks the argmax(p) cell across the whole
grid.
For classifier C, eruption day D:
window_0 window_1 ... window_M
seed_0 (p₀,₀, (p₀,₁, (p₀,M,
SHAP₀,₀) SHAP₀,₁) SHAP₀,M)
seed_1 (p₁,₀, (p₁,₁, (p₁,M,
SHAP₁,₀) SHAP₁,₁) SHAP₁,M)
⋮ ⋮ ⋮ ⋮
seed_N (pₙ,₀, (pₙ,₁, (pₙ,M,
SHAPₙ,₀) SHAPₙ,₁) SHAPₙ,M)
rollup ─► pick argmax(p) over all cells → one waterfall per (C, D)
Diagram B — the dataclass hierarchy that carries the rollup. After
build_classifier_ensemble_summary runs, the argmax cell above lives
at EruptionWindow.highest.
ClassifierEnsembleSummary (per classifier)
├─ highest / lowest ProbabilityPick (across all seeds × windows)
└─ eruption_windows[] one EruptionWindow per eruption date
├─ highest / lowest ProbabilityPick (across seeds, within this day window)
└─ seeds[] one SeedSummary per seed
├─ highest ProbabilityPick (top prob row for this seed, this day)
└─ lowest ProbabilityPick (bottom prob row for this seed, this day)
Diagram A is the what — why one waterfall per eruption day even
though there are N × M SHAP explanations. Diagram B is the how — the
ClassifierEnsembleSummary schema
(src/eruption_forecast/dataclass/classifier_ensemble_summary.py) that
carries the argmax once the day-window scan finishes.
How the pick reaches the waterfall. For each EruptionWindow,
plot_classifier_waterfall:
- Reads
EruptionWindow.highest.random_state— the seed id of the argmax cell. - Reads
EruptionWindow.highest.index— the row position of the argmax cell in the per-seed probability matrix. - Slices
classifier_explanation.seeds[random_state].shap_values[index]to pull the matching single-rowshap.Explanation. - Renders it via
plot_shap_waterfallundereruptions/{eruption_date}/{ClfName}_{datetime}_seed={i}_index={j}.png.
Step 3 relies on an alignment invariant: per-seed SHAP explanations are
built against the same features_df positional order the ensemble
scored, so index in the probability matrix and index in the SHAP
Explanation refer to the same observation.
Preserved but unused. EruptionWindow.seeds[] still carries a
SeedSummary per seed (with each seed's own highest and lowest picks
inside the day window) — the current waterfall path never reads it, but
it stays on the dataclass so future consumers (per-seed waterfall
grids, seed-agreement diagnostics, etc.) can iterate without rerunning
the scan.
Precondition. build_classifier_ensemble_summary requires
SeedEnsemble.probabilities to be populated — i.e. a prediction has
already run. Both operating modes satisfy this: prediction-reuse pulls
straight from PredictionModel.forecast(); training-reuse relies on
TrainingModel.fit() scoring the training samples during the ensemble
build. Calling the builder against an unpopulated ensemble raises
RuntimeError.
{station_dir}/explanation/{training|prediction}/
├── classifiers/
│ └── {ClassifierName}/ # e.g. RandomForestClassifier
│ ├── ClassifierExplanation_{ClassifierName}.pkl # bundled explanations
│ ├── shap_values/
│ │ └── {seed:05d}.pkl # per-seed shap.Explanation
│ └── figures/
│ ├── beeswarm/{seed:05d}.png # plot_per_seed=True
│ ├── bar/{seed:05d}.png # plot_per_seed=True
│ └── aggregate/ # plot_aggregate=True
│ ├── bar.png
│ ├── bar.csv # frequency-weighted importance table
│ ├── beeswarm.png
│ └── beeswarm.csv # tidy non-NaN cells for offline redraw
└── eruptions/ # sibling of classifiers/
└── {YYYY-MM-DD}/
└── {ClassifierName}_{YYYY-MM-DD_HH-MM-SS}_seed={i}_index={j}.png
Per-classifier folder names use the unslugified sklearn class name
(RandomForestClassifier), matching EvaluationModel's convention.
ExplanationModel inherits the cache layer from BaseModel. The cache
identity is content-addressable:
ExplanationModel cache identity = {
class: "ExplanationModel",
upstream_hash: hash(model_kind, classifier_names, features shape+columns,
date range),
explain_params: {save_per_seed: bool},
}
A change to the upstream ClassifierEnsemble or the feature matrix
invalidates the cache automatically. explain() calls
self.save(identity); the pickle lands at
{explanation_dir}/{hash}.ExplanationModel.pkl + matching .params.json
sidecar. Because explanation_dir is already mode-namespaced under
explanation/{training,prediction}/, training-reuse and prediction-reuse
caches never collide.
A cache hit restores self.explanations and skips the SHAP pass. The
per-seed shap_values/{seed:05d}.pkl files and per-classifier
ClassifierExplanation_*.pkl artefacts on disk are independent of the
cache pickle — they survive cache deletion and allow explain() to
short-circuit at the per-classifier level even if the top-level cache
pickle is missing.
ExplanationModel.explain() accepts a use_cache: bool = True argument
that gates the top-level cache. When use_cache=False (or when
self.overwrite is true) the load path is skipped and SHAP is
recomputed from scratch — the write is likewise skipped when the caller
also disables save_model. ForecastModel.explain(..., use_cache=...)
threads this argument straight through, so passing use_cache=False
from the wrapper truly disables the cache end-to-end.
from eruption_forecast import ExplanationModel
em = ExplanationModel.from_file(
"output/VG.OJN.00.EHZ/PredictionModel_2025-07-27_2025-08-22.pkl",
eruption_dates=["2025-08-02", "2025-08-18"],
n_jobs=4,
)
em.explain(save_per_seed=True)
em.plot(max_display=20, plot_per_seed=True)
print(em.explanations[0].classifier_name) # "RandomForestClassifier"
print(em.explanations[0].seeds[0].random_state)
print(em.explanations[0].seeds[0].shap_values.shape)em = ExplanationModel.from_file(
"output/VG.OJN.00.EHZ/TrainingModel_2025-01-01_2025-07-26.pkl",
)
em.explain().plot(plot_per_seed=False)from eruption_forecast.plots.explanation_plots import plot_classifier_waterfall
for classifier_explanation in em.explanations:
plot_classifier_waterfall(
classifier_explanation=classifier_explanation,
classifier_ensemble=em.ClassifierEnsemble,
labels=em.model.labels,
eruption_dates=["2025-08-02", "2025-08-18"],
output_dir=em.explanation_dir + "/eruptions",
max_display=20,
)em.save_config() # → {explanation_dir}/explanation.config.yamlexplain() already auto-calls save_config() after the SHAP pass +
self.save(), so a standalone explanation always leaves a YAML snapshot at
{output_dir}/explanation/{training|prediction}/explanation.config.yaml. The
path is already namespaced by upstream stage. The upstream model parameter
is intentionally omitted from the config (live model instances are not
serializable); the captured fields are eruption_dates, overwrite,
output_dir, root_dir, n_jobs, and verbose.
See Configuration.
┌─────────────────────────────────────────────────────────────────┐
│ ExplanationModel (BaseModel) │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ ExplainerEnsemble.explain() │ │
│ │ per-classifier TreeExplainer pass │ │
│ │ per-seed shap.Explanation │ │
│ │ bundle → ClassifierExplanation.pkl │ │
│ └────────────────────┬─────────────────────┘ │
│ │ cached on self.explanations │
│ ▼ │
│ em.plot() → ExplainerEnsemble.plot_seed() │
│ → ExplainerEnsemble.plot_waterfall() │
│ │
│ Output: explanation/{training|prediction}/ │
│ classifiers/{ClfName}/ + eruptions/{date}/ │
└─────────────────────────────────────────────────────────────────┘