NTTドコモR&Dの技術ブログです。

時系列予測のSHAP値から、LLMで「納得できるビジネス文書」に翻訳

はじめに

本記事は NTTドコモ Advent Calendar 2025 の12月18日 の記事です。

こんにちは。NTTドコモサービスイノベーション部の淺田晃佑です。 普段はデータ分析・AI技術を活用した業務効率化や意思決定支援を行っています。

昨年度は「需要予測と新聞売り子問題による在庫最適化」をテーマにしましたが、 今年はより実務に直結する“説明の自動化”にフォーカスします。


目次

概要

ここ数年、時系列予測モデルの高度化により、

  • データサイエンティスト(以下、DS)は SHAP などを使って 「モデルがなぜこの予測を出したか」を読み解けるようになった一方で、
  • ビジネス側のメンバーにとっては SHAP の図や数値を理解するのに時間がかかる

というギャップが目立つようになりました。

また、モデルを 定常的に運用 するようになると、

  • 毎週・毎月のレポートで「今回の予測がこうなった理由」を説明する
  • モデルの振る舞いの変化をウォッチする
  • 施策やトレンドの影響を定期的に共有する

といった作業が発生します。 このとき、DS が毎回SHAPを読み解き、スライドや報告文面を手作業で書いていると レポーティング工数が無視できない負担 になってきます。

そこで今年は、時系列予測モデル(LightGBM)から得られる SHAP 値を LLM を使って「ビジネス側が一読して理解できる文書」に自動翻訳する ことで、

  • レポーティングの負担を減らす
  • モデル運用時の定常説明を半自動化する

といった実務上の課題を解決する方法を紹介します。

本記事では、以下の流れで 「時系列予測 → SHAP → ビジネス文書」 をつないでいきます。

  1. LightGBM による時系列予測
  2. SHAP による寄与度分析
  3. SHAP 値のグルーピングと構造化
  4. LLM による「ビジネス向け説明」の生成

最後には、昨年度の記事と同様、Python での再現可能なコードおよびLLMに投げるプロンプトまで掲載します。

概要まとめ


前提知識

SHAPとは

SHAP(SHapley Additive exPlanations)は、 「特徴量が予測値にどう寄与したか」を示す手法です。

SHAPの考え方に基づくと、機械学習による予測値は

  • 基準値(平均予測など)
  • 各特徴量の押し上げ / 押し下げ寄与

による分解ができます。

特にLightGBMやXGBoostのような決定木モデルでは SHAPが高速かつ安定して計算できるため、実務でよく使われます。

DSにとっては、

  • 「この予測は主に特徴量Aにより押し上がっていて、特徴量Bが少し下押ししている」

といった読み解きがしやすく、 モデルのデバッグや特徴量の重要度理解に非常に便利 です。

数式を用いたSHAPの少し厳密な理解

SHAPは、予測値を次のように分解する枠組みを採用しています。

f(x)=\phi_0+\sum_{i=1}^{M}\phi_i

ここで、 - \phi_0:特徴量を何も使わないときの平均的な予測値(baseline) - \phi_i:特徴量 i の寄与(押し上げ・押し下げの大きさ)

各寄与 \phi_i は Shapley値に基づき次で定義されます。

\phi_i=\sum_{S\subseteq N\setminus\{i\}}\frac{|S|!\,(M-|S|-1)!}{M!}\,\left(f_{S\cup\{i\}}(x)-f_S(x)\right)

ここで使われている記号の意味は以下の通りです。

  • N = \{ 1,2, \ldots,M \}:すべての特徴量の集合
  • S\subseteq N\setminus \{ i \} :特徴量 i を含まない N の部分集合
  • |S|:集合 S の要素数
  • f_S(x):「特徴量集合 S に属する特徴量のみを使って予測した場合のモデル出力」を表す関数(特徴量 S 以外は欠損扱いまたは平均で補完する)

この式が意味しているのは、 「特徴量 i を追加したときに予測がどれだけ変わるか」を、すべての追加順序で平均する という考え方です。

また、Shapley値の性質により、

  • 予測が基準値からどれだけ上下したか
  • どの特徴量がどの程度寄与したか
  • 寄与が足し算で解釈できる

という、実務で扱いやすい説明が可能になります。

LightGBMとは

LightGBM は Microsoft が開発した GBDT(Gradient Boosting Decision Tree) 系アルゴリズムの一種です。 GBDT は「弱い決定木(弱学習器)を逐次的に積み重ね、誤差を改善していく」勾配ブースティング手法であり、高い表現力と汎用性を持っています。

通常は汎用的な回帰タスク・分類タスクで使われますが、 特徴量設計を工夫することで時系列予測にも問題なく適用できます。

LightGBM は GBDT の中でも特に高速で、メモリ効率にも優れ、欠損値処理も柔軟なため実務でよく採用されるモデルです。また木構造に基づくモデルであるため、木構造SHAPが高速・安定して計算できるという利点があります。

本記事では、後段で SHAP を算出し LLM による説明文生成につなげる必要があるため、SHAP が取りやすい LightGBM を採用しています。


課題について

時系列モデルでのSHAPが難しい理由

一方で、時系列予測では特徴量が多種多様で、ビジネス側の人から見ると非常に分かりづらくなります。

  • 過去の販売数(ラグ特徴量:lag_1, lag_7, lag_28 など)
  • 移動平均
  • 曜日・休日フラグ
  • 気温・天候などの外部指標
  • 広告施策や割引施策・価格変動

そこでSHAPの可視化を見せると、次のような問題が発生します:

  • 「lag_7 の SHAP値 が +120」と言われても意味がわからない
  • one-hot 施策フラグが大量に並び混乱する
  • 寄与度の符号解釈を間違いやすい
  • 要因が“粒度の違う特徴量”として混在する

DSなら「まあそういうものだよね」と読めるのですが、 ビジネス側から見ると “謎の棒グラフ” になりがちです。


本記事での解決アプローチ

そこで本記事では、以下の方法を提案します。

  1. SHAPをグルーピングし、意味的な要因に再構成する
  2. SHAPをJSON形式に変換しLLMに渡す
  3. LLMに「ビジネス文書として自然な説明」を生成させる

この構造化の過程を一度作ってしまえば、

  • 新しいデータでモデルを回すたびに
  • SHAP を計算し
  • LLM に投げるだけで

定常レポーティング用のテキストの自動生成が可能 になります。


Pythonによる実装

sampleデータ作成

ダミーの時系列データ作成関数

import numpy as np
import pandas as pd

def create_synthetic_timeseries(n_days=730, seed=57):
    rng = np.random.default_rng(seed)
    dates = pd.date_range(
        start='2023-01-01',
        periods=n_days,
        freq='D'
    )

    df = pd.DataFrame({'date': dates})
    df['day_idx'] = np.arange(n_days)
    df['weekday'] = df['date'].dt.weekday

    # 週次の傾向
    weekday_base = df['weekday'].map({
        0: 120, 1: 130, 2: 140, 3: 135, 4: 125, 5: 80, 6: 70
    })

    # 緩やかなトレンド
    trend = 0.05 * df['day_idx']

    # 季節性(180日周期)
    seasonal = 15 * np.sin(2 * np.pi * df['day_idx'] / 180)

    # 天気(雨フラグ):ざっくり 30% くらいの確率で雨
    df['is_rain'] = (rng.random(n_days) < 0.3).astype(int)
    rain_effect = df['is_rain'] * rng.normal(-20, 5, n_days)

    # 施策フラグ:ざっくり 10% くらいの確率で施策あり
    df['is_campaign'] = (rng.random(n_days) < 0.1).astype(int)
    campaign_effect = df['is_campaign'] * rng.normal(30, 3, n_days)

    # ノイズ
    noise = rng.normal(0, 10, n_days)

    df['sales'] = (
        weekday_base
        + trend
        + seasonal
        + rain_effect
        + campaign_effect
        + noise
    ).round().clip(lower=0)

    return df

特徴量生成関数

def add_time_features(df, lags=(7, 14, 28), windows=(7, 28)):
    df = df.copy()

    # ラグ特徴量(7日前, 14日前, 28日前の売上など)
    for lag in lags:
        df[f'lag_{lag}'] = df['sales'].shift(lag)

    # 移動平均
    # 「7日前までしか分からない」という前提なので、
    # 7日シフトした系列に対して rolling を取ることで、
    # たとえば roll_mean_7 は「14~7日前」の平均になる。
    for w in windows:
        df[f'roll_mean_{w}'] = df['sales'].shift(7).rolling(w).mean()

    # 曜日ダミー
    df['weekday'] = df['date'].dt.weekday
    df = pd.concat([df, pd.get_dummies(df['weekday'], prefix='wd')], axis=1)

    return df

sampleデータの作成

df = create_synthetic_timeseries(seed=57)
features_df = add_time_features(df).dropna().reset_index(drop=True)

# trainとtestに分割、testを最後の30日間とする
train_df = features_df.iloc[:-30].copy()
test_df = features_df.iloc[-30:].copy()

# 特徴量
features = [c for c in features_df.columns if c not in ['date', 'day_idx', 'sales']]

# 目的変数
target = 'sales'

各変数の意味

今作成したデータの各カラムで使用されている単語はそれぞれ次のような意味です。

  • weekday:曜日
  • sales:販売数量(目的変数)
  • is_rain:その日が雨かどうか(1/0)
  • is_campaign:その日に施策があったかどうか(1/0)
  • lag_x:x日前の販売数量(例: lag_7 は7日前の売上)
  • roll_mean_x:7日前までのx日間移動平均
  • wd_x:その日の曜日がxであるかどうか

実際に各テーブルの中身も確認してみます。

  • df

df

  • features_df.tail(10)

features_df

  • test_df.head(10)

test_df.head(10)

可視化

時系列データの可視化もしておきます。

実績のみ可視化


LightGBMで予測

モデルの学習と推論

import lightgbm as lgb
from sklearn.metrics import mean_squared_error

# LightGBM用のデータ型に変更
train_set = lgb.Dataset(
    train_df[features],
    train_df[target]
)

params = {
    'objective': 'regression',
    'metric': 'rmse',
    'learning_rate': 0.05,
    'num_leaves': 31,
    'seed': 57, # 素数
    'verbose': -1
}

model = lgb.train(params, train_set, num_boost_round=300)

pred = model.predict(test_df[features])
test_df['pred_sales'] = pred

rmse = mean_squared_error(test_df[target], pred, squared=False)
print('RMSE:', rmse)

importanceの確認

念のため、importanceも確認しておきます。

importances_gain = model.feature_importance(importance_type='gain')
importances_split = model.feature_importance(importance_type='split')

fi_df = pd.DataFrame({
    'feature': features,
    'importance_gain': importances_gain,
    'importance_split': importances_split
}).sort_values(
    'importance_gain',
    ascending=False
).reset_index(drop=True)

print('\n=== Feature Importance (gain) ===')
print(fi_df)

features_df

曜日、7日間移動平均、施策フラグ、雨フラグなどが効いているようですね。

可視化

時系列データの予実も可視化しておきます。

予実可視化

拡大すると次のようになっています。 LightGBMでもある程度予測できていますね。

予実可視化_拡大


SHAPの計算

計算

次に、SHAP値を計算します。 LightGBMでは決定木系モデルに最適化された TreeExplainer を利用できます。

import shap

background = train_df[features].sample(200, random_state=57)
explainer = shap.TreeExplainer(model, background)

# テストデータに対する SHAP 値を計算
shap_values = explainer.shap_values(test_df[features])
base_value = explainer.expected_value

# 計算したSHAP値をテーブルに格納
shap_df = pd.DataFrame(shap_values, columns=features)
shap_df['prediction'] = test_df['pred_sales'].values
shap_df['target'] = test_df['sales'].values
shap_df['shap_sum'] = shap_df[features].sum(axis=1)

計算されたSHAP値を格納したテーブルを見ると、次のように値が入っていることが確認できます。

  • shap_df

shap_df


summary_plotの確認

念のため、summary_plotも確認して、モデル全体としてどの特徴量が効きやすいのかを可視化します。 これはLLMに「全体傾向」を説明させたい場合にも必要です。

# SHAP summary plot
import shap
import matplotlib.pyplot as plt

shap.summary_plot(
    shap_values, # TreeExplainer の SHAP 値
    test_df[features], # 対象データ
    feature_names=features,
    max_display=20 # 上位20だけ表示(必要に応じて調整)
)

shap_summary

  • weekdayを見ると週末のほうが売上が下がりそう
  • is_rainを見ると雨の日は売上が下がりそう
  • is_campaignを見ると施策がある日は売上が上がりそう

などは分かるかと思います。


SHAP値のグルーピング(LLMへ渡す前処理)

SHAP をそのまま LLM に渡すのではなく、ビジネス側にとっての意味単位でまとめてから渡します。 ここでは、SHAP を次の5つの要因にグルーピングします。

  • trend:移動平均など「直近の需要トレンド」
  • lag:過去の売上
  • weekday:曜日要因
  • rain:天気要因
  • campaign:施策要因

さらに、レポートで説明したい対象日を仮にtestデータの5日目(5レコード目)に固定し、

  1. その日の特徴量・実績・予測値を確認
  2. その日の SHAP による waterfall plot を描画
  3. その日の要因別 SHAP を JSON 化
  4. あわせて、全期間の「要因別平均寄与の大きさ」も JSON 化

という流れにします。

着目日の特徴量・実績・予測値を確認

まず、着目する日(testデータ5日目 = 2024年12月5日)の特徴量・実績・予測値などを確認しておきます。

focus_idx = 4 # 着目する日(0始まり)

print('\n=== 着目する日の情報 ===')
print('date       :', test_df['date'].iloc[focus_idx])
print('sales      :', float(test_df['sales'].iloc[focus_idx]))
print('pred_sales :', float(test_df['pred_sales'].iloc[focus_idx]))
print('is_rain    :', int(test_df['is_rain'].iloc[focus_idx]))
print('is_campaign:', int(test_df['is_campaign'].iloc[focus_idx]))

print('\n=== 着目する日の特徴量 ===')
print(test_df[features].iloc[focus_idx])

着目日の各種値


着目日のwaterfall plotの出力

予測値がどの特徴量により押し上げ/押し下げされているかを 1レコード単位で確認するため、念のためwaterfall plotも出力します。

# 特徴量ごとの SHAP 値
focus_shap_values = shap_df.loc[focus_idx, features].values
focus_feature_values = test_df[features].iloc[focus_idx].values

# SHAPのExplanation オブジェクトを作成
exp_focus = shap.Explanation(
    values=focus_shap_values,
    base_values=base_value,
    data=focus_feature_values,
    feature_names=features
)

# waterfall plot
shap.plots.waterfall(exp_focus, max_display=20)

shap_waterfall


SHAPの要因別グルーピング

特徴量をグルーピングしておきます。

def group_shap(shap_row):
    return {
        'trend': float(shap_row[[c for c in shap_row.index if 'roll_mean' in c or 'roll' in c]].sum()),
        'lag': float(shap_row[[c for c in shap_row.index if c.startswith('lag_')]].sum()),
        'weekday': float(shap_row[[c for c in shap_row.index if c.startswith('wd_')]].sum()),
        'rain': float(shap_row.get('is_rain', 0.0)),
        'campaign': float(shap_row.get('is_campaign', 0.0))
    }

# SHAPを行方向にdictとして返しているためDataFrame化する
grouped = shap_df.apply(group_shap, axis=1)
grouped_df = pd.DataFrame(grouped.tolist())
focus_grouped = grouped_df.iloc[focus_idx].to_dict() # 着目日の要因別

# 要因別の |SHAP| の平均を算出(モデル全体の傾向)
global_factors_mean_abs = grouped_df.abs().mean().to_dict()

JSON 化(LLM に渡す情報を統合)

必要な数値は算出できたため、次にLLMに渡す情報をまとめたJSONデータを作成します。

LLMに渡すJSONは以下の階層で構成します:

  1. focus:ある着目した日
  2. focus.date:着目日の年月日
  3. focus.prediction:着目日の予測値
  4. focus.baseline:平均値
  5. focus.features:着目日の特徴量の実際の値
  6. focus.shap:特徴量ごとのSHAP値
  7. focus.shap_grouped:要因別集約 SHAP
  8. global:testデータ全体について
  9. global.factors_mean_abs:モデル全体の傾向
import json

# 特徴量の値
focus_features = test_df[features].iloc[focus_idx].to_dict()

# 特徴量ごとの SHAP 値
focus_shap_detail = shap_df.loc[focus_idx, features].to_dict()

report_info_dict = {
    'focus': {
        'date': str(test_df['date'].iloc[focus_idx].date()),
        'prediction': float(shap_df['prediction'].iloc[focus_idx]),
        # 'target': float(shap_df['target'].iloc[focus_idx]),
        'baseline': float(base_value),

        'features': focus_features, # 特徴量の実際の値
        'shap': focus_shap_detail, # 特徴量ごとのSHAP
        'shap_grouped': focus_grouped # 要因別SHAP
    },
    'global': {
        'factors_mean_abs': global_factors_mean_abs
    }
}

json_data = json.dumps(report_info_dict, ensure_ascii=False)
print('\n=== LLM に渡す JSON ===')
print(json_data)

以下は、実際にsampleプログラムを動かした際に出力されたJSONの例になります。

LLMに渡すJSONデータ

{
    "focus": {
        "baseline": 129.70420420423704,
        "date": "2024-12-05",
        "features": {
            "is_campaign": 1,
            "is_rain": 0,
            "lag_14": 151.0,
            "lag_28": 164.0,
            "lag_7": 164.0,
            "roll_mean_28": 129.03571428571428,
            "roll_mean_7": 134.85714285714286,
            "wd_0": false,
            "wd_1": false,
            "wd_2": false,
            "wd_3": true,
            "wd_4": false,
            "wd_5": false,
            "wd_6": false,
            "weekday": 3
        },
        "prediction": 187.09633671788603,
        "shap": {
            "is_campaign": 23.971617983077586,
            "is_rain": 5.131267031330891,
            "lag_14": 1.9062741252595392,
            "lag_28": -1.4528200741859005,
            "lag_7": 2.216350874362472,
            "roll_mean_28": 1.1417710236011103,
            "roll_mean_7": 6.775491524604556,
            "wd_0": 0.0,
            "wd_1": -0.057085883542902095,
            "wd_2": -0.7061035883403066,
            "wd_3": 1.1333560881524243,
            "wd_4": 0.0363628440016055,
            "wd_5": -0.030977646409810163,
            "wd_6": 0.0,
            "weekday": 17.326628211737535
        },
        "shap_grouped": {
            "campaign": 23.971617983077586,
            "lag": 2.6698049254361105,
            "rain": 5.131267031330891,
            "trend": 7.917262548205667,
            "weekday": 0.3755518138610109
        }
    },
    "global": {
        "factors_mean_abs": {
            "campaign": 5.025184512469476,
            "lag": 3.522020323809431,
            "rain": 7.423590085696271,
            "trend": 8.689295353089323,
            "weekday": 1.3625403873260011
        }
    }
}


LLM による翻訳

では、前項までの方法で出力されたJSONをLLMに与えて解釈させてみます。 今回は、以下のようなプロンプトを作成しました。

LLMに投げるプロンプト例

あなたは社内向けの販売レポートを作成するシニアアナリストです。
以下の JSON には、ある1日の販売予測値と、その日の特徴量、特徴量ごとの寄与(SHAP値)、
要因別に集約された寄与、モデル全体の傾向が含まれています。

この情報をもとに、ビジネス担当者にわかりやすい文章を作成してください。

【重要仕様】
- 雨でない場合(is_rain = 0)は必ず「晴れ」と表現してください。
- 着目日の説明では「この日」ではなく、JSON 内の focus.date を
  「◯年◯月◯日」の形式でそのまま記載してください。
- 最初の段落では必ず「モデル全体の傾向」を説明してください。
- 次の段落では「◯年◯月◯日の予測がなぜその値になったのか」を説明してください。
- それぞれの段落には必ず次のような表題を付けてください:
    【モデル全体の傾向】
    【◯年◯月◯日の予測理由】
- 技術用語(SHAP, baseline など)は文章中に直接書かないでください。
- 語彙はビジネスユーザー向けに自然で平易なものにしてください。
- 箇条書きは禁止。必ず段落として記述してください。

【JSON の要点】
- focus.prediction はモデルが見込んだ販売数量
- focus.baseline は特徴量を考慮する前の「平均的な販売水準」
- prediction - baseline は、各特徴量の寄与(focus.shap)を合計したもの
- features には当日の特徴量の実際の値が入っています
  (例:lag_7 は7日前の売上、is_rain=0 は晴れ、is_campaign=1 は施策あり)
- shap には各特徴量が予測を押し上げる/押し下げる方向の寄与が入っています
- shap_grouped は trend / lag / weekday / rain / campaign の要因別に寄与をまとめたもの
- global.factors_mean_abs はモデル全体で「どの要因が効きやすいか」の指標です

【タスク1:モデル全体の傾向(150~250文字)】
- global.factors_mean_abs をもとに、モデルが需要判断で何を重視しているかを説明してください。
- 「過去の売上」「直近のトレンド」が強く効くのか、「天気」や「施策」がどの程度調整的に働いているのかなど、
  要因間の大小関係を自然な日本語でまとめてください。
- 技術用語を使わず、ビジネス現場で理解しやすい表現にしてください。

【タスク2:◯年◯月◯日の予測理由(200~300文字)】
- focus.date を必ず日付として記載してください(例:2024年12月05日)。
- prediction が baseline と比べてどの程度上振れ/下振れしているかに触れてください。
- features(特徴量の実値)と shap(寄与)を対応づけて、
  「その特徴量の値だから、この方向に寄与した」という因果関係を自然な文章にしてください。
    例:7日前の売上が高かった → 需要を強めに見込む
        晴れだった → 天気要因による落ち込みは小さい
        施策あり → 上振れ方向へ寄与
- shap_grouped を使い、要因レベルでの総括も行ってください。
- 専門用語は使わず、顧客需要の文脈で自然に説明してください。

【出力形式】
【モデル全体の傾向】
(150~250文字の段落)

【◯年◯月◯日の予測理由】
(200~300文字の段落)

【データ】
{{json_data}}

出力例

このプロンプトをChatGPT5.1に投げた結果は以下の通りでした。

【モデル全体の傾向】
全体として、直近の動きや過去の売上水準が特に強く効いており、需要の土台を形成しています。そのうえで天気が調整要因として比較的大きな影響を与え、施策も一定の押し上げ要因として働いています。一方で曜日要因は影響が小さく、日々の需給変化の補助的な役割にとどまっています。

【2024年12月05日の予測理由】
2024年12月05日の見込みは平均的な水準を大きく上回りました。7日前や14日前の売上が高めだったことに加え、直近の売上の流れも堅調であったため、需要を強めに見込みました。また、晴れであったため天気による落ち込みは小さく、施策の実施が需要を大きく押し上げました。要因別に見ると、施策と直近の動きが主な押し上げ要因となり、過去の売上や天気も上振れ方向に寄与しました。曜日の影響は小さく、全体の判断を補完する役割にとどまりました。

とりあえずまずまずの出来で、非専門家が読んでもある程度理解が可能な文章を出力できたように思います。 一方で、waterfall plotなどと比較してどちらが分かりやすいかでいうと疑問も残るように感じました。


さいごに

まとめ

今回、本記事で狙っているのは、

  • DS は SHAP を読めるが、ビジネス側には負担が大きい
  • モデル定常運用時に「説明文の作成」が毎回コストになる

という現場課題に対して、

  • SHAP を意味単位にグルーピングし
  • JSON に整形し
  • LLM に渡して説明文を自動生成する

ことで レポーティングの自動化・工数削減 を実現するアプローチでした。

  • 結果、SHAPをLLMを用いて翻訳することで、予測理由の説明を安定的・高速に生成できる点に大きな価値と可能性を感じることができました

課題と今後の展望についても記載しておきます。

課題・難しかった点

  • 特徴量の粒度が案件ごとに異なるため、SHAPの意味的グルーピングは自動化がまだ難しい
  • LLM が扱いやすいJSONに整形するまでの前処理が地味に重たい
  • 自然な文章を出すためのプロンプト調整に手間がかかった

今後の展望

  • 予測 → SHAP → JSON → LLM → Slack/Teams 通知まで含めた「説明生成の全自動化ライン」が構築できると良い
  • SHAPに加え、因果推論・反実仮想推定の結果も含められると良い
  • モデル内部構造や共起パターンを利用し、要因グルーピングを自動学習させるアプローチを検討したい(難しそう)
  • 将来的には説明にとどまらず、施策案・在庫調整案などの改善提案生成まで自動化できると良い

実行環境について

この記事の内容は、以下の環境で実行しています。

  • Python:3.11
  • lightgbm:4.3系
  • shap:0.46系
  • LLM:ChatGPT5.1