Skip to content

Explanation Workflow

Martanto edited this page Aug 5, 2026 · 4 revisions

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(...).


TreeExplainer constraint

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.


Two operating modes

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

What explain() does

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.

ForecastModel.explain() signature

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,
) -> Self

Internally 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.

Standalone ExplanationModel.explain() signature

em.explain(
    save_per_seed=True,
    check_additivity=False,
    overwrite_classifier_explanation=False,
) -> Self

Standalone ExplanationModel.plot() signature

em.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 inventory

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.


Per-eruption waterfall selection

Why one waterfall per eruption day per classifier — even though the SHAP stage produces N × M explanations under the hood.

  • A ClassifierEnsemble holds N SeedEnsemble seeds per tree classifier. Each seed independently produces a per-window probability and hard prediction over M prediction windows.
  • ExplainerEnsemble.explain() runs shap.TreeExplainer once 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 by plot_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:

  1. Reads EruptionWindow.highest.random_state — the seed id of the argmax cell.
  2. Reads EruptionWindow.highest.index — the row position of the argmax cell in the per-seed probability matrix.
  3. Slices classifier_explanation.seeds[random_state].shap_values[index] to pull the matching single-row shap.Explanation.
  4. Renders it via plot_shap_waterfall under eruptions/{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.


Outputs

{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.


Cache semantics

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.


Standalone use

Reload from a saved PredictionModel .pkl

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)

Reload from a saved TrainingModel .pkl

em = ExplanationModel.from_file(
    "output/VG.OJN.00.EHZ/TrainingModel_2025-01-01_2025-07-26.pkl",
)
em.explain().plot(plot_per_seed=False)

Drive the waterfall path directly

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,
    )

Persist the explanation config

em.save_config()   # → {explanation_dir}/explanation.config.yaml

explain() 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.


ASCII quick reference

┌─────────────────────────────────────────────────────────────────┐
│             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}/            │
└─────────────────────────────────────────────────────────────────┘

Clone this wiki locally