Skip to content

Navigation Menu

Sign in
Sign up

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 (p0,0, (p0,1, (p0,M,
 SHAP0,0) SHAP0,1) SHAP0,M)
seed_1 (p1,0, (p1,1, (p1,M,
 SHAP1,0) SHAP1,1) SHAP1,M)
 ⋮ ⋮ ⋮ ⋮
seed_N (pn,0, (pn,1, (pn,M,
 SHAPn,0) SHAPn,1) SHAPn,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.

×ばつ 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)">
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

AltStyle によって変換されたページ (->オリジナル) /