見出し画像

【客数予測AI】残差を「次の予測」に活かす技術──ズレを記録し、ズレから学ぶ

はじめに:「外れた理由」を捨てていませんか?

客数予測システムを構築していると、必ずこんな場面に出くわします。

「今日の予測は200人だったのに、実際は240人来た。なぜだろう?」

多くの場合、このズレは「仕方ない誤差」として流されます。でも、このズレを丁寧に記録し、パターンを見つけることができれば、翌日以降の予測精度を上げることができます。

それを実現するのが残差(Residual)特徴量です。

この記事では「残差とは何か」という概念の説明から、実際にコードで実装するところまでを解説します。

用語について 厳密には、ここで扱う「残差」はモデルの予測値との差ではなく、移動平均や曜日中央値といった統計的な期待値との乖離量です。統計学の文脈では「残差」より「乖離量(deviation)」が正確ですが、この記事では機械学習の特徴量設計における慣用として「残差」と表記します。

1. 残差とは何か

一言で言うと

**「期待していた値と、実際の値のズレ」**です。

残差 = 実績客数 − 期待値(予測値・平均値)

たとえば、ある店舗の水曜日の平均客数が100人だとします。今週の水曜日に130人来たなら、残差は +30 です。

日付期待値(水曜平均)実績残差水曜①100人130人+30水曜②100人85人−15水曜③100人102人+2

残差がゼロに近いほど「平均通りだった日」、大きくプラスなら「何かいいことがあった日」、マイナスなら「何か悪いことがあった日」です。

残差には2種類ある

ここが核心です。残差はすべて同じ意味を持つわけではありません。

① ノイズ(ランダムなズレ) たまたまその日だけ外れた。特に意味はない。次の日には関係ない。

② シグナル(パターンのあるズレ) 何らかの理由でズレが続いている。これは意味がある。

予測に活かせるのは②のシグナルです。

たとえば、先週から連続して「水曜の期待値100人」に対して実績が 120→125→118人 と推移しているとします。これは偶然ではなく、近くに新しいオフィスビルができたとか、競合店が閉店したとか、何か構造的な変化が起きているサインかもしれません。

この「ズレが続いている」という情報を翌日の予測に渡すと、モデルが「この店舗は今ちょっと上振れしやすい状態だ」と学習できます。

残差の役割を整理する

カレンダー情報(曜日・祝日・連休)
天気情報(雨・気温)                  ──→  基本的な予測値
前年実績(同曜日比較)

         ↑ これだけでは捉えられない「今の店舗の状態」

残差(直近のズレ量)          ──→  「今の雰囲気」をモデルに伝える
残差のトレンド(ズレの方向)  ──→  「ズレが拡大・縮小中」をモデルに伝える

カレンダーや天気は「外から来る要因」、残差は**「その店舗が今どういう状態にあるか」という内部状態の代理変数**と言えます。

2. 私が実装している残差の種類

私のシステムでは、残差系の特徴量を5種類に分けて計算しています。「何を期待値とするか」を変えることで、店舗の状態を多角的に捉えるのが狙いです。

特徴量期待値の基準何を捉えるかMA残差直近7日移動平均短期的な勢いからのズレ曜日別平均残差同曜日の過去中央値曜日パターンからのズレロバスト残差外れ値除去後の移動平均台風・イベント等に引っ張られにくいズレ残差比率残差 ÷ 基準値店舗規模に依存しない相対的なズレ残差トレンド残差の直近28日傾きズレが拡大中か縮小中かの方向性

この中で最も「実務的な直感」に近く、かつ理解しやすいのが曜日別平均残差です。次のセクションでコードと一緒に詳しく解説します。残りの4つはその後にまとめて紹介します。

3. サンプルコード:曜日別平均残差

「いつもの水曜と比べて今日はどれだけズレているか」を数値化します。

データ準備

python

import pandas as pd
import numpy as np

np.random.seed(42)

stores = ['store_A', 'store_B']
dates = pd.date_range('2024-01-01', periods=180, freq='D')

rows = []
for store in stores:
    base = 100 if store == 'store_A' else 150
    for date in dates:
        weekday = date.weekday()
        weekday_effect = 1.3 if weekday >= 5 else 1.0
        # 直近30日だけ少しトレンドを上げる(シグナルを埋め込む)
        trend = 1.1 if (date - dates[0]).days > 150 else 1.0
        count = int(base * weekday_effect * trend + np.random.normal(0, 10))
        rows.append({
            '店舗コード': store,
            '営業日_dt': date,
            '客数': max(count, 0),
            'weekday_with_holiday': 99 if weekday == 6 else weekday,
        })

df = pd.DataFrame(rows)

曜日別平均残差の計算

python

WEEKDAY_WINDOW = 12  # 同曜日の過去何回分を使うか
FFILL_LIMIT    = 7   # 欠損補完の上限日数


def add_weekday_residual(df: pd.DataFrame) -> pd.DataFrame:
    """
    同曜日の過去中央値に対する残差を計算する。

    ポイント:
    - groupby に「曜日」を含めることで、月曜は月曜同士、
      水曜は水曜同士のみで比較する。
    - shift(1) で「当日を除いた」中央値を計算する(データリーク防止)。

    出力列:
        客数_median_weekday   : 同曜日の過去12回中央値
        客数_residual_weekday : 当日実績 - 同曜日中央値
    """
    df = df.sort_values(['店舗コード', 'weekday_with_holiday', '営業日_dt']).copy()

    # 同曜日の過去N回の中央値(当日除外)
    df['客数_median_weekday'] = (
        df.groupby(['店舗コード', 'weekday_with_holiday'])['客数']
        .transform(
            lambda s: s.shift(1).rolling(window=WEEKDAY_WINDOW, min_periods=1).median()
        )
    )

    df = df.sort_values(['店舗コード', '営業日_dt']).copy()

    # 残差 = 実績 - 同曜日中央値
    df['客数_residual_weekday'] = df['客数'] - df['客数_median_weekday']

    # 欠損補完(7日以内に限定)
    df['客数_residual_weekday'] = (
        df.groupby('店舗コード')['客数_residual_weekday']
        .transform(lambda s: s.ffill(limit=FFILL_LIMIT))
    )

    return df


df = add_weekday_residual(df)

# 確認(月曜日のみ抜粋)
monday = df[df['weekday_with_holiday'] == 0]
print(monday[['店舗コード', '営業日_dt', '客数', '客数_median_weekday', '客数_residual_weekday']].head(8).to_string(index=False))

出力イメージ:

 店舗コード   営業日_dt  客数  客数_median_weekday  客数_residual_weekday
  store_A  2024-01-01   95              NaN                  NaN
  store_A  2024-01-08  103             95.0                  8.0
  store_A  2024-01-15   98             99.0                 -1.0
  store_A  2024-01-22  112             98.5                 13.5
  store_A  2024-01-29   96            102.0                 -6.0

なぜ shift(1) が必要か? 予測を行うのは「今日の客数がまだわからない状態」です。なので中央値を計算するときは「昨日までのデータ」だけを使う必要があります。これを忘れると、訓練時だけ精度が高く見えて本番で全然当たらないモデルができあがります(データリーク)。

4. 私のシステムで使っているその他の残差系特徴量

コードの紹介は省きますが、私のシステムでは以下の特徴量も合わせて生成しています。曜日別残差だけでは拾えない変化を補うための、いわば「残差の多重チェック機構」です。

MA残差 直近7日の移動平均を期待値とした残差です。曜日関係なく「最近の流れからどれだけ外れたか」を表します。先週から客数が右肩上がりになっているような短期トレンドを捉えるのに向いています。

ロバスト残差 MA残差の改良版です。通常のMAは台風やイベントで客数が異常値になった日があると、その後数日間の平均値がずれてしまいます。直近28日の上位2%・下位2%を除外したMAを基準にすることで、そうした一時的な異常値に引っ張られにくくした版です。

残差比率 残差の絶対値は「100人規模の店舗で+20人」と「1000人規模の店舗で+20人」が同じ値になってしまいます。これを 残差 ÷ 前日MA7 で割り算し、規模に依存しない相対的なズレ率に変換したものです。複数店舗をまとめて1つのモデルで学習させるときに特に効果を発揮します。

残差トレンド 残差そのものだけでなく、残差が「どっちに向かっているか」を直近28日の線形回帰の傾きで表します。残差が +5→+8→+12→+18 と増え続けているならトレンド係数はプラス、+20→+15→+10→+5 と収束しているならマイナスになります。「ズレが拡大中か収束中か」という方向性の情報をモデルに渡せます。

5. 残差をモデルに渡すときの注意点

残差は「過去のズレ」として渡す 予測時点では当日の実績はまだ存在しません。モデルに渡すのは昨日時点の残差、つまり shift(1) 済みの値です。

残差は「消えていく」ことも学習させる ランダムなズレは翌日には関係ありません。残差の絶対値だけでなく、トレンド係数(方向性)も一緒に渡すことで、モデルが「このズレは続きそうか、収束しそうか」を判断できるようになります。

新店舗・開店直後は残差が使えない 履歴が少ない段階では中央値も不安定です。開店後28日程度はこれらの特徴量に頼りすぎない設計が必要です。

まとめ

残差とは「モデルが外した量」ではなく、**「まだ捉えられていない何かのシグナル」**です。

カレンダーや天気では説明できない「店舗の今の状態」を、残差という形で数値化してモデルに渡す。これが精度の壁を突き破る一つの手法です。

残差の種類何が見えるかMA残差短期的な勢いの変化曜日別残差週次パターンからの逸脱ロバスト残差イベント・異常値に左右されない安定した変化残差トレンドズレが拡大中か収束中か

「勘と経験」で「今この店は調子がいい」と感じている現場感覚を、残差という形で数値化する。それがこの特徴量の本質であり、私がこだわって設計した部分でもあります。

いいなと思ったら応援しよう!

Adi@外食×Claude×Azure 日々のデータ格闘の記録が、同じ悩みを持つ方のヒントになれば幸いです。応援いただけると、今後の執筆の大きな励みになります!いただいたチップは、更なる分析手法の調査や技術向上のための活動費に充てさせていただきます。どうぞよろしくお願いします。