「scikit-learnで売上を予測してみたら、MAEがすごく小さくてうれしかったのに、本番ではまったく当たらない…」「日付つきのデータって、普通の機械学習と何が違うの?」――そんな悩み、ありますよね。

この記事では、カフェの1日ごとのコーヒー販売数を題材に、時系列データの予測を一から作ります。ポイントは「予測する時点で、まだ知らないはずの情報を使わない」こと。うっかり未来の値を使ってしまう「未来のカンニング」を、実際にコードを動かして確かめていきます。

難易度:scikit-learnで fit と predict を使ったことがある初〜中級者向け。使うのは pandas・scikit-learn・matplotlib だけです😊

時系列予測は「普通の機械学習」と何が違う?

coffee shop sales chart
coffee shop sales chart / Photo by RDNE Stock project via Pexels

時系列データとは、日付や時刻の順に並んだデータのことです。売上、気温、アクセス数などが代表例ですね。

普通の機械学習との一番の違いは、イメージとしては「答え合わせの順番が決まっている」ことです。

  • 明日の販売数を予測するとき、使えるのは今日までのデータだけ
  • なので、テストも「過去で学習 → その後の期間で答え合わせ」の順番でやる必要がある
  • データをシャッフルしたり、未来の値が混ざった特徴量を作ったりすると、評価だけが良くなる

この「評価だけが良くなる」状態をデータリークと呼びます。この記事では、よくある3つの落とし穴を順番に踏んでみます⚠️

1年分の販売データを用意する

まずは練習用のデータを作ります。実際のお店のデータの代わりに、次の要素を足し合わせた「それっぽい」数字を乱数で作りました。

  • 1年でゆるやかに増えていくトレンド
  • 冬に多く夏に少ない季節の波
  • 土日に多い曜日のクセ
  • 日々のばらつき(ノイズ)
import numpy as np
import pandas as pd

# 1年分の「カフェのコーヒー販売数(杯)」を作る
rng = np.random.default_rng(42)
dates = pd.date_range("2025-01-01", "2025-12-31", freq="D")
n = len(dates)

trend = np.linspace(0, 20, n)                      # 1年でゆるやかに+20杯
season = 15 * np.cos(2 * np.pi * (dates.dayofyear - 15) / 365)  # 冬に多く、夏に少ない
weekday_effect = np.array([-10, -5, 0, 5, 15, 40, 30])[dates.dayofweek]  # 月〜日
noise = rng.normal(0, 8, n)

sales = (120 + trend + season + weekday_effect + noise).round().astype(int)
df = pd.DataFrame({"date": dates, "sales": sales})
df.to_csv("sales.csv", index=False)

print(df.head(7).to_string(index=False))
print("日数:", len(df))

# 曜日ごとの平均を見てみる
names = ["月", "火", "水", "木", "金", "土", "日"]
by_wd = df.groupby(df["date"].dt.dayofweek)["sales"].mean().round(1)
by_wd.index = names
print("\n曜日ごとの平均販売数")
print(by_wd.to_string())

実行すると sales.csv ができて、こう表示されました。

      date  sales
2025-01-01    137
2025-01-02    131
2025-01-03    156
2025-01-04    182
2025-01-05    149
2025-01-06    115
2025-01-07    131
日数: 365

曜日ごとの平均販売数
月    119.8
火    125.4
水    130.8
木    135.4
金    145.1
土    171.0
日    157.1

土曜日がいちばん多く、月曜日がいちばん少ないですね。乱数のシードを default_rng(42) で固定しているので、同じコードなら同じ数字になります。

「昨日までの情報」だけで特徴量を作る

時系列予測では、過去の値そのものを特徴量にするのが基本です。これをラグ特徴量と呼びます。pandasの shift() を使うと、列を下に1行ずつずらせます。

ここが重要です👇

  • shift(1):1行下にずらす=昨日の値が今日の行に来る
  • shift(7):1週間前の同じ曜日の値
  • shift(1).rolling(7).mean():先にずらしてから平均するので、昨日までの7日平均になる
  • 曜日(0〜6)は「6のほうが大きい」わけではないので、get_dummies で0/1の列に分ける

あとで何度も使うので、features.py という名前で保存しておきます。

import pandas as pd


def make_features(df):
    """昨日までの情報だけで特徴量を作る"""
    df = df.copy()
    df["lag1"] = df["sales"].shift(1)                      # 昨日の販売数
    df["lag7"] = df["sales"].shift(7)                      # 1週間前の販売数
    df["mean7"] = df["sales"].shift(1).rolling(7).mean()   # 昨日までの7日平均
    df["weekday"] = df["date"].dt.dayofweek                # 0=月 … 6=日
    # 曜日は「大小」ではなく「種類」なので0/1の列に分ける
    df = pd.get_dummies(df, columns=["weekday"], prefix="wd", dtype=int)
    return df.dropna()   # shiftで空いた最初の数日を捨てる


if __name__ == "__main__":
    df = pd.read_csv("sales.csv", parse_dates=["date"])
    feat = make_features(df)
    cols = ["date", "sales", "lag1", "lag7", "mean7", "wd_5"]
    print(feat[cols].head(4).round({"mean7": 1}).to_string(index=False))
    print("行数:", len(df), "→", len(feat))
      date  sales  lag1  lag7  mean7  wd_5
2025-01-08    133 131.0 137.0  143.0     0
2025-01-09    140 133.0 131.0  142.4     0
2025-01-10    144 140.0 156.0  143.7     0
2025-01-11    183 144.0 182.0  142.0     1
行数: 365 → 358

1月11日(土)の行を見てください。lag1 が前日1月10日の販売数144、lag7 が1週間前の1月4日(土)の182になっています。wd_5(土曜日)が1なのもわかりますね。

最初の7日間は「1週間前」が存在しないので空欄(NaN)になり、dropna() で捨てています。365行が358行に減ったのはそのためです。

落とし穴①:シャッフルして分けると、成績が良く見える

いよいよ学習です。最後の8週間(11月6日〜12月31日)を「まだ来ていない未来」とみなしてテストに使います。

比べるのは次の4つです。

  • ベースライン:何も学習せず「昨日と同じ」「先週の同じ曜日と同じ」と答えるだけ
  • 線形回帰とランダムフォレスト(時間順に分割)
  • おまけに、train_test_split でシャッフルして分けたランダムフォレスト

ベースラインを先に測っておくのが大事なポイントです。モデルが「何もしない予測」に勝てていなければ、使う意味がないからです。

import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
from features import make_features

df = make_features(pd.read_csv("sales.csv", parse_dates=["date"]))
X_cols = ["lag1", "lag7", "mean7"] + [c for c in df.columns if c.startswith("wd_")]
X, y = df[X_cols], df["sales"]

# ⭕ 時間順に分ける:最後の8週間がテスト
X_train, X_test = X.iloc[:-56], X.iloc[-56:]
y_train, y_test = y.iloc[:-56], y.iloc[-56:]

models = {
    "線形回帰": LinearRegression(),
    "ランダムフォレスト": RandomForestRegressor(random_state=0),
}
# まずは何も学習しない予測(ベースライン)
print("【ベースライン】")
print(f"  昨日と同じ: MAE = {mean_absolute_error(y_test, X_test['lag1']):.1f} 杯")
print(f"  先週の同じ曜日と同じ: MAE = {mean_absolute_error(y_test, X_test['lag7']):.1f} 杯")

print("【時間順に分割】")
for name, model in models.items():
    model.fit(X_train, y_train)
    mae = mean_absolute_error(y_test, model.predict(X_test))
    print(f"  {name}: MAE = {mae:.1f} 杯")

# ❌ シャッフルして分ける(未来の日が学習側に混ざる)
Xa, Xb, ya, yb = train_test_split(X, y, test_size=56, random_state=0)
rf = RandomForestRegressor(random_state=0).fit(Xa, ya)
print("【シャッフルして分割】")
print(f"  ランダムフォレスト: MAE = {mean_absolute_error(yb, rf.predict(Xb)):.1f} 杯")

# ランダムフォレストが学習で見た販売数の範囲と、テスト期間の実際の値
print("学習データの最大値:", y_train.max(), "杯 / テスト期間の最大値:", y_test.max(), "杯")
print("ランダムフォレストの予測の最大値:", round(models["ランダムフォレスト"].predict(X_test).max(), 1), "杯")
実行結果:ベースライン・時間順分割・シャッフル分割のMAE比較
実行結果:ベースライン・時間順分割・シャッフル分割のMAE比較
【ベースライン】
  昨日と同じ: MAE = 16.5 杯
  先週の同じ曜日と同じ: MAE = 8.9 杯
【時間順に分割】
  線形回帰: MAE = 7.0 杯
  ランダムフォレスト: MAE = 12.4 杯
【シャッフルして分割】
  ランダムフォレスト: MAE = 6.7 杯
学習データの最大値: 187 杯 / テスト期間の最大値: 199 杯
ランダムフォレストの予測の最大値: 170.5 杯

結果を整理するとこうなります。

方法 MAE(平均の外れ幅)
昨日と同じ 16.5杯
先週の同じ曜日と同じ 8.9杯
線形回帰(時間順) 7.0杯
ランダムフォレスト(時間順) 12.4杯
ランダムフォレスト(シャッフル) 6.7杯 ⚠️

注目してほしいのは、同じランダムフォレストなのにシャッフルすると12.4杯→6.7杯と、まるで別物のように良く見えることです。

シャッフルすると、11〜12月の日の多くが学習側に入ります。つまり「年末はこのくらい売れる」という未来の答えを見てからテストを受けている状態です。本番で予測するときには未来のデータはないので、この6.7杯は当てになりません。

なぜランダムフォレストは線形回帰に負けたの?

「ランダムフォレストのほうが強いモデルなのでは?」と思いますよね。ここが時系列ならではのつまずきです。

  • このデータは1年かけて増えていて、テスト期間(11〜12月)は1年でいちばん販売数が多い時期
  • 学習データの最大値は187杯なのに、テスト期間には199杯の日がある
  • ランダムフォレストの予測は、学習データの値を平均したものなので、学習データの範囲を超えた値を出せない(今回の予測の最大値は170.5杯)

一方、線形回帰は「先週が多ければ今週も多い」という関係を直線で表すので、過去にない大きな値でもそのまま伸ばして予測できます。右肩上がりのデータでは、決定木系のモデルは苦手と覚えておくとよいです。

落とし穴②:特徴量に「明日」が混ざっている

2つ目の落とし穴は、特徴量を作る段階で未来が混ざるパターンです。よくあるのが、移動平均を center=True(前後の日を含めた平均)で作ってしまうケースです。

これ、実行せずに答えられますか? 「前後1日を含む3日平均」と「昨日までの7日平均」、どちらのMAEが小さくなるでしょう?

import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
from features import make_features

df = make_features(pd.read_csv("sales.csv", parse_dates=["date"]))
wd_cols = [c for c in df.columns if c.startswith("wd_")]

# ❌ 前後1日を含む3日平均(center=True)=「明日」の値が混ざる
df["mean3_center"] = df["sales"].rolling(3, center=True).mean()
df = df.dropna()
train, test = df.iloc[:-56], df.iloc[-56:]

for extra in ["mean7", "mean3_center"]:
    cols = wd_cols + [extra]
    model = LinearRegression().fit(train[cols], train["sales"])
    mae = mean_absolute_error(test["sales"], model.predict(test[cols]))
    print(f"曜日 + {extra}: MAE = {mae:.1f} 杯")

# テスト期間の最後の日はいつになった?
print("テスト期間の最終日:", test["date"].iloc[-1].date())
曜日 + mean7: MAE = 7.2 杯
曜日 + mean3_center: MAE = 5.4 杯
テスト期間の最終日: 2025-12-30

3日平均のほうが 7.2杯→5.4杯 と大きく改善しました。でもこれはカンニングです⚠️

  • rolling(3, center=True) は「昨日・今日・明日」の平均。つまり当てたい今日の値そのものと明日の値が入っている
  • その証拠に、テスト期間の最終日が12月30日に変わっています。12月31日は「明日(1月1日)」のデータがないので、平均が作れず dropna() で消えたのです

「最後の行が消えた」「予測したい日の特徴量が作れない」というのは、未来の値を使っているサインです。特徴量を作ったら、予測したい日の前日の時点で、その値がわかるか? を1つずつ確かめるクセをつけましょう。

落とし穴③:テスト期間が1回だけだと、たまたまの結果かも

ここまでは「最後の8週間」の1回だけで評価してきました。でも、その8週間がたまたま当てやすい時期だった可能性もありますよね。

そこで使うのが TimeSeriesSplit です。イメージとしては、カレンダーを少しずつ進めながら、何回も「過去で学習 → 直後でテスト」をくり返す交差検証です。普通の KFold と違って、テストは必ず学習期間より後になります。

ポイントをまとめるとこんな感じです👇

  • n_splits=5:5回くり返す
  • test_size=28:テストは毎回4週間(28日)
  • cross_val_score の cv にそのまま渡せる
  • scoring="neg_mean_absolute_error" はマイナス付きで返るので、- を付けて戻す
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
from features import make_features

df = make_features(pd.read_csv("sales.csv", parse_dates=["date"]))
X_cols = ["lag1", "lag7", "mean7"] + [c for c in df.columns if c.startswith("wd_")]
X, y = df[X_cols], df["sales"]

# 4週間ずつ5回、「過去で学習 → 直後の4週間でテスト」をくり返す
tscv = TimeSeriesSplit(n_splits=5, test_size=28)
for i, (tr, te) in enumerate(tscv.split(X), start=1):
    d = df["date"]
    print(f"{i}回目: 学習 {d.iloc[tr[0]].date()}〜{d.iloc[tr[-1]].date()}"
          f" / テスト {d.iloc[te[0]].date()}〜{d.iloc[te[-1]].date()}")

for name, model in [("線形回帰", LinearRegression()),
                    ("ランダムフォレスト", RandomForestRegressor(random_state=0))]:
    scores = -cross_val_score(model, X, y, cv=tscv, scoring="neg_mean_absolute_error")
    print(f"{name}: 各回のMAE {scores.round(1)} 平均 {scores.mean():.1f} 杯")
実行結果:TimeSeriesSplitによる5回の評価
実行結果:TimeSeriesSplitによる5回の評価
1回目: 学習 2025-01-08〜2025-08-13 / テスト 2025-08-14〜2025-09-10
2回目: 学習 2025-01-08〜2025-09-10 / テスト 2025-09-11〜2025-10-08
3回目: 学習 2025-01-08〜2025-10-08 / テスト 2025-10-09〜2025-11-05
4回目: 学習 2025-01-08〜2025-11-05 / テスト 2025-11-06〜2025-12-03
5回目: 学習 2025-01-08〜2025-12-03 / テスト 2025-12-04〜2025-12-31
線形回帰: 各回のMAE [8.6 4.8 7.2 6.4 7.3] 平均 6.8 杯
ランダムフォレスト: 各回のMAE [ 8.8  5.   8.6 10.1 10. ] 平均 8.5 杯

学習期間がだんだん伸びて、テストはいつもその直後の4週間になっているのがわかります。

  • 線形回帰は5回の平均が6.8杯。1回目は8.6杯、2回目は4.8杯と、時期によってけっこう差がある
  • ランダムフォレストは平均8.5杯で、販売数が伸びる4・5回目(11〜12月)で10杯前後まで悪化

1回のテストだけだと「4.8杯!」のような当たりの回を引いてしまうこともあります。何回かの平均とばらつきを見ると、モデルの実力がわかりやすくなります。

予測をグラフで確かめて、明日の販売数を出す

最後に、線形回帰の予測を実際の値と並べてグラフにし、全データで学習し直して2026年1月1日の販売数を予測します。

import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
from features import make_features

plt.rcParams["font.family"] = "Meiryo"   # Macは "Hiragino Sans" など

raw = pd.read_csv("sales.csv", parse_dates=["date"])
df = make_features(raw)
X_cols = ["lag1", "lag7", "mean7"] + [c for c in df.columns if c.startswith("wd_")]
train, test = df.iloc[:-56], df.iloc[-56:]

model = LinearRegression().fit(train[X_cols], train["sales"])
test = test.assign(pred=model.predict(test[X_cols]))

plt.figure(figsize=(10, 4))
plt.plot(test["date"], test["sales"], marker="o", ms=3, label="実際の販売数")
plt.plot(test["date"], test["pred"], linestyle="--", label="線形回帰の予測")
plt.plot(test["date"], test["lag7"], alpha=0.5, label="先週の同じ曜日")
plt.title("最後の8週間:実際の販売数と予測")
plt.ylabel("販売数(杯)")
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()

# 全データで学習し直して「明日(2026-01-01)」を予測する
model.fit(df[X_cols], df["sales"])
s = raw["sales"]
next_day = pd.Timestamp("2026-01-01")
row = {"lag1": s.iloc[-1], "lag7": s.iloc[-7], "mean7": s.iloc[-7:].mean()}
for i in range(7):
    row[f"wd_{i}"] = int(next_day.dayofweek == i)
pred = model.predict(pd.DataFrame([row])[X_cols])[0]
print(next_day.date(), "の曜日番号:", next_day.dayofweek, "→ 予測", round(pred), "杯")
実行結果:最後の8週間の実際の販売数と予測のグラフ
実行結果:最後の8週間の実際の販売数と予測のグラフ
2026-01-01 の曜日番号: 3 → 予測 158 杯

グラフを見ると、予測(点線)は土日の山と平日の谷をきちんと追えています。「先週の同じ曜日」(薄い線)よりも、実際の値に近い日が多いですね。

明日の予測では、特徴量を手で組み立てているところがポイントです。

  • lag1:最後の日(12月31日)の値
  • lag7:1週間前(12月25日)の値
  • mean7:最後の7日間の平均
  • 曜日:1月1日は木曜日(曜日番号3)なので wd_3 だけ1

どれも12月31日の時点でわかる値だけです。本番で作れる特徴量だけでモデルを作る――これが時系列予測でいちばん大切な考え方です。

なお、Macでは Meiryo フォントがないので、"Hiragino Sans" などに変えてください。

まとめ

  • 時系列予測では、予測する時点でわかっている情報だけを使う
  • ラグ特徴量は shift()、移動平均は shift(1).rolling(7).mean() のように先にずらしてから計算する
  • テストは時間順に分ける。シャッフルすると今回は12.4杯→6.7杯と、実力以上に良く見えた
  • center=True の移動平均は「明日」が混ざる。最後の行が消えたら要注意
  • まずは「先週の同じ曜日と同じ」などのベースラインを測り、それに勝てるかを見る
  • 右肩上がりのデータでは、ランダムフォレストは学習データの範囲を超えた値を出せない
  • TimeSeriesSplit で何回か評価して、平均とばらつきを見る

「評価が良すぎるときほど疑う」という感覚がつかめると、機械学習の結果を自信を持って説明できるようになります。お手元の売上データやアクセス数でも、ぜひ試してみてください🚀

📚 関連商品・おすすめ書籍

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

もしも

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

初心者に定番のPython入門書

Amazonで見る
実践Claude Code入門―現場で活用するためのAIコーディングの思考法

もしも

実践Claude Code入門―現場で活用するためのAIコーディングの思考法

AIコーディングの現場活用法を学ぶ一冊

Amazonで見る
Python Web開発実践入門 ―― FastAPIによるWebAPI開発と非同期処理

もしも

Python Web開発実践入門 ―― FastAPIによるWebAPI開発と非同期処理

FastAPIでWebAPI開発を実践的に学ぶ

Amazonで見る

※本記事にはアフィリエイトリンクが含まれます。

ABOUT ME
やまちゃん
これまで学生と社会人を合わせて5000人以上にプログラミング学習を指導。 ゼロからイチをわかりやすく解説する専門家として活動しており、本業ではArduinoを用いたIoT開発とロボットプログラミングが専門。 Pythonを用いたアプリ開発、ウェブアプリケーションの開発で業務の効率化をサポートしています。