9月6日の売上を予測するために、店舗ごとに日付順で並べ、売上をshift(1)でずらして前日値を作ります。人工データの店舗001で入った値は20。ところが、この20は9月4日の売上でした。

9月5日の行が抜けていたため、1行戻ると9月4日に着きます。shift(1)は日付を見ず、前の行の値を返しています。

値が同じNaNでも、見ている日が違う

少しさかのぼって、店舗001の9月3日を見ます。その前の2日は、売上が不明な日と、行そのものがない日でした。

日付記録の状態売上
9月1日行はあるが、売上が不明NaN
9月2日行がない—
9月3日行も売上もある10

9月3日から1行戻ると9月1日で、値はNaN。本当の前日である9月2日も、行がないのでNaNになります。「NaN同士は一致」として値だけを比べると、このずれを見逃します。

参照した日付まで残しておけば、調べる記録を選べます。9月1日なら売上の値、9月2日なら行が抜けた理由です。

欲しいのは前日か、前の観測か

過去の値を予測用の列にしたものをラグ特徴量と呼びます。「前日」を使うのか、「前の観測」を使うのかで作り方は変わります。

定休日に行を作らない店舗で、前回の営業日の売上が欲しければ、行ベースのshift(1)が使えます。ただし、営業日の記録が漏れなく並んでいることが条件です。記録漏れまで飛び越すと、前回の営業日ではなくなります。

ここから作るのは「前日」の値です。カレンダー上の日付(以下、暦日)に1日1行をそろえてからずらします。

1店舗分を暦日にそろえて作り直す

想定しているのは、毎朝その日の売上を予測し、前日分の実績はすでに確定している運用です。実績が遅れて届く場合や、一度に数日先まで予測する場合は、使える過去の値が変わるため、この例をそのままは使えません。

店舗001の7行を使い、9月1日から9日までを埋めます。売上は下のsalesに入れました。9月2日と5日は行がなく、7日の0は休業と分かっている値です。

1店舗・1日1行・日付の昇順・重複なしの入力で、pandas 3.0.5を使います。対象期間は業務上の範囲を指定し、観測された最初と最後の日付からは決めません。

import pandas as pd

sales = pd.Series([float("nan"), 10., 20., 20., 0., 30., 40.],
    index=pd.to_datetime(["2026-09-01", "2026-09-03", "2026-09-04",
                          "2026-09-06", "2026-09-07", "2026-09-08", "2026-09-09"]))
daily = sales.reindex(pd.date_range("2026-09-01", "2026-09-09", freq="D"))
comparison = pd.DataFrame({
    "sales": sales,
    "previous_row_date": sales.index.to_series().shift(1),
    "previous_day": sales.index - pd.Timedelta(days=1),
    "row_lag1": sales.shift(1),
    "calendar_lag1": daily.shift(1).reindex(sales.index),
    "row_past3": sales.shift(1).rolling(3, min_periods=3).mean(),
    "calendar_past3": daily.shift(1).rolling(3, min_periods=3).mean().reindex(sales.index),
})
print(comparison.to_string())

対象期間の全日付でreindexすると、行がなかった9月2日と5日にNaNの行ができます。そろえた表でshift(1)すれば、どの行でも1つ前の行が前日になります。直前3日平均は、ずらした後の値にrolling(3, min_periods=3)をかけて作るので、予測する当日の売上は含みません。min_periods=3は、3日分の値がすべて分かる日だけ平均を出す指定です。

補った日の売上を0で埋めないのは、行がないだけでは売上0だったのか記録が漏れたのか分からないからです。9月7日の休業の0は、入力に0として書かれているので0として使われます。そのため、ここでの3日平均は営業日ではなく暦日あたりの平均です。

9月3日・6日の前日値に加え、3日平均も並べるとこうなりました。

日付見た値行ベース暦日ベース
9月3日前日値NaN(9月1日を参照)NaN(9月2日を参照)
9月6日前日値20(9月4日の値)NaN(9月5日は行なし)
9月7日直前3日平均16.666667(9月3日・4日・6日)NaN(9月5日は行なし)
9月9日直前3日平均16.66666716.666667

9月7日の行ベースの平均は、欠けた9月5日を飛び越えて、より古い9月3日の値を使っていました。9月9日のように直前3日に抜けがなければ、2つの作り方は同じ値になります。

店舗が増えても、店舗ごとにこの処理を行います。ここまでの例は前日値と3日平均を同じ日次データから作っていますが、前日値だけなら別の作り方もあります。

前日値だけなら、日付をずらして結合する

前日値だけが必要なら、shift(1, freq="D")を使う方法もあります。日付をインデックスにした系列でこう書くと、値は動かさず日付のラベルを1日進めた系列が返ります。これを元の店舗・日付に結合すれば、前日の行がない日はNaNになります。別の人工データ5種類すべてで、この方法の前日値は日次にそろえた版と一致しました。

ただし返り値は元と同じ行数で、欠けた日の行は補われません。直前3暦日の平均まで同じ表で作りたい場合は、前節のように日次へそろえるほうが扱いやすくなります。速度や予測精度の比較はしていません。

shift・rollingの指定と、5種類のデータでの比較

Series.shiftは、freqを指定しなければ値を行の位置でずらします。rolling(3)も、整数を渡すと3行の窓になります。どちらも日付の間隔は見ていません。

別の人工データ5種類でも、行ベースと暦日ベースの前日値・3日平均を比較しました。抜けのない連続した日付、休業の0を含むデータ、行はあって値だけがNaNのデータでは、2つの作り方の結果は一致しました。差が出たのは、日付の行が抜けたデータ(抜けを他の条件と組み合わせたデータを含む)だけです。行があって値だけが不明な場合は、前の行と前日が同じ日なので、ずれは起きません。

前日値と直前3日平均は、ここまでの短い例で作れます。複数店舗のCSVでずれた行を一覧にしたい場合は、以下の補足に監査関数と特徴量を結合する手順をまとめました。

補足:複数店舗のCSVで使う

複数店舗のCSVから、ずれた行と不足日を調べる

前半の短いコードには、日付の重複・書式や店舗の混在を調べる検査がありません。複数店舗のCSVを調べるために、その検査を含むaudit_daily_lagsを用意しました。元の各行に参照日と不足理由を付けた監査表auditと、店舗ごとの集計summaryを返します。元の売上から行ベースと暦日ベースの値を計算して比べる関数で、すでに作成した特徴量の列を検査するものではありません。

必要なライブラリを入れます。

python -m pip install "pandas==3.0.5" "numpy==2.4.6"

監査関数のファイルlag_audit.pyをダウンロードし、自分のスクリプトと同じフォルダへ保存します。次の例は、列名が店舗・日付・売上のCSVを読む場合です。

import pandas as pd
from lag_audit import audit_daily_lags

df = pd.read_csv("sales.csv", dtype={"店舗": str},
                 keep_default_na=False, na_values={"売上": [""]}).rename(columns={
    "店舗": "store", "日付": "date", "売上": "sales",
})
periods = {"001": ("2026-09-01", "2026-09-09"),
           "NA": ("2026-09-03", "2026-09-09")}
audit, summary = audit_daily_lags(df, periods)
print(audit.loc[audit["date_mismatch"], [
    "store", "date", "previous_observed_date", "expected_previous_date",
    "row_lag1", "calendar_lag1",
]])
print(summary)
audit.to_csv("lag-audit.csv", index=False)

全行の結果と不足日のリストはlag-audit.csvに残ります。手元のCSVに合わせて、ファイル名とrenameの左側の列名、dtype・na_valuesのキーを変えてください。右側のstore・date・salesは関数が使う列名なので、そのままにします。

店舗列は文字列として読み、001やNAが数値や欠損に変わらないようにしています。売上の欠損が空欄以外なら、na_valuesの売上側を合わせます。periodsには全店舗の対象期間を指定してください。

periodsは、データに現れる最初と最後の日付から自動で決めないでください。例えば開業日が9月1日なのに最初の行が9月3日なら、9月1日と2日は「行が抜けた日」であって、「対象期間より前の日」ではありません。観測の最小・最大を使うと、この区別が消えます。

入力は1店舗1日1行、日付はタイムゾーンなしのYYYY-MM-DDが前提です。同じ店舗・日付の行が複数あると1店舗1日1行に集約してください、03/09/2026のような書式は日付文字列はYYYY-MM-DDで指定してくださいで止まります。重複を合計するか最後の値を使うかはデータによって違うので、関数の中では決めていません。

前の観測日が前日とずれている箇所は、date_mismatchがTrueの行で確認します。previous_observed_dateとexpected_previous_dateを見比べると、どの日を飛び越したかが分かります。ただし、各店舗の最初の行は前の観測がないため、このフラグはFalseです。最初の行の履歴不足はno_previous_observation、3日平均に必要な履歴の不足はmissing_dates・unknown_value_dates・outside_period_datesも確認してください。前日値への置き換えには、後のdaily_featuresを使います。

入力エラーの対応と、監査表・店舗別集計の全項目

periodsとの食い違いでも止まります。

  • 対象期間外のレコードがあります:データの日付と、その店舗の開始日・終了日を照合します。エラーを消すためだけに期間を広げないでください
  • 全店舗の対象期間を指定してください:データの店舗とperiodsの店舗がそろっているか、店舗コードの先頭の0やNAという文字が読み込みで崩れていないかを確かめます

監査表の列と、次に確かめること

auditは元のCSVと同じ行数で、1行ごとに次の列を持ちます。

列意味
previous_observed_date行ベースのshift(1)が参照する日付(同じ店舗の1つ前の行)
expected_previous_date本来の前日(日付から1日引いた日)
row_lag1・row_past3前の観測値と、直前3観測行の平均
calendar_lag1・calendar_past3暦日でそろえて作った前日値と3日平均
date_mismatch前の行があり、その日付が前日と違う
no_previous_observationその店舗の最初の行で、前の行がない
missing_dates直前3暦日のうち、対象期間内なのに行がない日
unknown_value_dates直前3暦日のうち、行はあるが売上がNaNの日
outside_period_dates直前3暦日のうち、対象期間より前の日

不足日の3列は、記録のどこを調べるかで使い分けます。missing_datesなら、記録漏れや集計での脱落、休業日を記録しない運用がないかを調べます。unknown_value_datesなら元の売上値を探し、outside_period_datesなら対象期間を広げて履歴を足せるかを検討します。

前日値が欠ける理由だけを知りたい場合は、expected_previous_dateが3つのリストのどれに入っているかを見ます。

summaryは店舗ごとに、元の行数、date_mismatchと最初の行の件数、calendar_lag1とcalendar_past3がNaNの行数と割合を返します。割合の分母は元の行だけで、暦日にそろえるために補った行は数えません。この割合だけで0埋めや行の削除を決めず、欠けた理由を監査表で確かめてください。

暦日の前日値と3日平均を元の学習行へ付ける

前日値が必要なのにdate_mismatchが見つかったら、同じファイルのdaily_featuresで特徴量を作り直します。暦日にそろえた前日値と3日平均を、元のCSVの各行に結合します。対象期間は、店舗ごとの開始日・終了日を書いた店舗マスタのCSV(stores.csv)から読みます。

店舗,開始日,終了日
001,2026-09-01,2026-09-09
NA,2026-09-03,2026-09-09

期間は監査の例のperiodsと同じです。売上のCSV(sales.csv)・stores.csv・lag_audit.pyを同じフォルダに置き、次のコードを実行します。

import pandas as pd
from lag_audit import daily_features

df = pd.read_csv("sales.csv", dtype={"店舗": str}, keep_default_na=False,
                 na_values={"売上": [""]}).rename(
    columns={"店舗": "store", "日付": "date", "売上": "sales"})
master = pd.read_csv("stores.csv", dtype=str, keep_default_na=False)
if master["店舗"].duplicated().any():
    raise ValueError("店舗マスタの店舗が重複しています")
if set(master["店舗"]) != set(df["store"]):
    raise ValueError("店舗マスタを対象データの全店舗と一致させてください")
periods = {r["店舗"]: (r["開始日"], r["終了日"])
           for r in master.to_dict(orient="records")}
if {"calendar_lag1", "calendar_past3", "_original_order"} & set(df.columns):
    raise ValueError("追加する列名が既存列と重複しています")
daily = daily_features(df, periods)
df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d", errors="raise")
df["_original_order"] = range(len(df))
features = daily[["store", "date", "lag1", "past3"]].rename(
    columns={"lag1": "calendar_lag1", "past3": "calendar_past3"})
result = df.merge(features, on=["store", "date"], how="left",
                  validate="one_to_one", sort=False)
result = result.sort_values("_original_order").drop(columns="_original_order")
result.to_csv("sales-with-calendar-features.csv", index=False)

結合前の日付の型に気をつけてください。daily_featuresには文字列で渡して書式検査を通し、その後に元データも日付型にそろえます。店舗と日付で左結合し、validate="one_to_one"で1行に複数の特徴量が結びつくのを防ぎます。

店舗マスタの重複・店舗の不一致や、追加する列名の衝突があれば止まります。既存列は上書きしません。

保存先はsales-with-calendar-features.csvです。人工データでは元の13行と行順を保ったまま2列が加わり、値は監査表とすべて一致しました。NaNはCSVでは空欄になります。

storedatesalescalendar_lag1calendar_past3
0012026-09-0620.0空欄空欄
0012026-09-0940.030.016.666666666666668
NA2026-09-07200.0空欄空欄

店舗マスタがなければ、業務の記録から開始日・終了日を調べてstores.csvを作ってください。会社全体のマスタを使う場合は、今回の売上データに含まれる店舗だけに絞ります。

daily_featuresの返り値には、元の行があったかを示すrecord_present列もあります。これは監査用で、予測の特徴量には使わないでください。ある日の行が欠けるかどうかは、記録が集まった後で分かる情報で、予測する朝の時点で分かっている値とは限りません。

daily_featuresの処理と、監査表との対応

daily_featuresの返り値には、元にない日も含まれます。lag1が前日値、past3が直前3暦日の平均です。元のCSVには、元の行がある日だけを結合します。

  1. 日付の書式・重複・タイムゾーンなどを検査する
  2. 店舗ごとに対象期間の全日付をpd.date_range(freq="D")で作り、reindexで1日1行にそろえる。元にない日の売上はNaNになる
  3. 元の行があったかをrecord_present列に残す
  4. そろえた表でshift(1)し、その結果にrolling(3, min_periods=3)をかける

前半の小例と同じく、補った日の売上はNaNのままにします。「行がない日は休業」という業務上の決まりがあるデータなら、0で埋める判断もあり得ますが、それは関数の外で、決まりを確かめてから行います。

監査表もこの関数から作っています。audit_daily_lagsはlag1とpast3をcalendar_lag1・calendar_past3として載せるので、監査した値をそのまま元の学習行へ付けられます。calendar_lag1が参照するのはexpected_previous_dateです。

例えば店舗NAの9月6日の売上が見つかったら、その行をCSVに足して再実行します。missing_datesから6日が消え、7日の前日値も埋まります。直前3日に別の不足があれば、calendar_past3はまだNaNのままです。

2店舗・13行の監査結果と、欠損理由の内訳

店舗001にもう1店舗を加えた13行では、3行で参照日がずれていました。

店舗001は先ほどと同じ9月1日から9日のデータです。加えた店舗NAの対象期間は9月3日から9日で、6日の行がありません。4日は休業の0です。

店舗日付売上前の行の日付前日row_lag1calendar_lag1date_mismatch
0019月1日NaNなし8月31日NaNNaNFalse
0019月3日109月1日9月2日NaNNaNTrue
0019月4日209月3日9月3日1010False
0019月6日209月4日9月5日20NaNTrue
0019月7日09月6日9月6日2020False
0019月8日309月7日9月7日00False
0019月9日409月8日9月8日3030False
NA9月3日100なし9月2日NaNNaNFalse
NA9月4日09月3日9月3日100100False
NA9月5日2009月4日9月4日00False
NA9月7日2009月5日9月6日200NaNTrue
NA9月8日3009月7日9月7日200200False
NA9月9日4009月8日9月8日300300False

店舗001で見た2つのずれに加え、店舗NAの9月7日もTrueになっています。前日の9月6日が抜け、9月5日を参照していました。

この抜けは3日平均にも響きます。行ベースでは9月3日・4日・5日の100・0・200から平均100が出ますが、必要なのは9月4日・5日・6日です。6日の売上が分からないので、暦日で作った平均はNaNになります。

店舗001の9月8日の暦日の前日値は、9月7日の休業による0です。0は分かっている値なので、unknown_value_datesには入りません。

直前3日の不足を3種類に分ける

店舗001の9月3日の行では、直前3暦日の8月31日・9月1日・2日が、次のように分かれました。

  • missing_dates:9月2日(期間内なのに行がない)
  • unknown_value_dates:9月1日(行はあるが売上が不明)
  • outside_period_dates:8月31日(対象期間より前)

この行の平均を出すには、期間内の2日だけでなく、期間より前の8月31日の値も必要です。9月2日の行を補うだけでは、まだ3日分そろいません。

店舗ごとの集計

店舗元の行数date_mismatch最初の行前日値がNaN3日平均がNaN
0017213行(約43%)6行(約86%)
NA6112行(約33%)6行(100%)

店舗NAの3日平均がすべてNaNなのは、最初の3行は直前3日に対象期間より前の日を含み、残りの3行は行がない9月6日を含むためです。短い人工データなのでこの割合は大きく出ています。一般的な欠損率の目安ではありません。

詳しい検証条件

  • 環境:Python 3.11.15、pandas 3.0.5、NumPy 2.4.6、macOS arm64 CPU
  • データ:本文の2店舗・13行の人工データ。実店舗のデータではない
  • 独立した照合:店舗・日付をキーにした辞書を作り、日付をループして、行ベースの参照日と値、暦日の前日値と3日平均、直前3暦日の分類、店舗ごとの割合を計算し、関数の出力と比べた
  • 行の順番を入れ替えた入力でも監査表と集計は同じだった。同じ店舗・日付の重複と03/09/2026形式の日付は例外で止まった
  • 上の使用例と同じ読み方で、店舗名を001とNAにしたCSVを読み、店舗名が文字列のまま保たれ、結果が直接関数を呼んだ場合と一致することを確かめた。使用例はlag_audit.pyとCSVだけを置いたフォルダで実行した
  • 店舗マスタから作ったperiodsは、監査の例で直接書いた辞書と一致した。元の行の順番を入れ替えた売上データで結合しても、元の13行と行順が保たれ、付いた値は監査表と一致した。結合のコードはlag_audit.pyと2つのCSVだけを置いたフォルダでそのまま実行し、別に照合した結合結果と一致した
  • 店舗マスタの店舗の重複・不足、空欄の期間、対象期間外のレコードは、いずれも例外で止まった
  • 5種類のデータでの比較とshift(freq="D")の確認は、別の人工データで行った

適用範囲と限界

  • 確かめたのは、各特徴量がどの日付の値を参照するかです。予測精度や処理速度、実際のデータでの欠損の多さは測っていません。
  • 比較するのは標準的な店舗別・日付順の行ベース処理と暦日処理です。自分のコードでのgroupbyの付け忘れや並べ替え忘れは、直接は検出しません。
  • 日付はタイムゾーンなし・0時の日付だけを受け付けます。