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.666667 | 16.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では空欄になります。
| store | date | sales | calendar_lag1 | calendar_past3 |
|---|---|---|---|---|
| 001 | 2026-09-06 | 20.0 | 空欄 | 空欄 |
| 001 | 2026-09-09 | 40.0 | 30.0 | 16.666666666666668 |
| NA | 2026-09-07 | 200.0 | 空欄 | 空欄 |
店舗マスタがなければ、業務の記録から開始日・終了日を調べてstores.csvを作ってください。会社全体のマスタを使う場合は、今回の売上データに含まれる店舗だけに絞ります。
daily_featuresの返り値には、元の行があったかを示すrecord_present列もあります。これは監査用で、予測の特徴量には使わないでください。ある日の行が欠けるかどうかは、記録が集まった後で分かる情報で、予測する朝の時点で分かっている値とは限りません。
daily_featuresの処理と、監査表との対応
daily_featuresの返り値には、元にない日も含まれます。lag1が前日値、past3が直前3暦日の平均です。元のCSVには、元の行がある日だけを結合します。
- 日付の書式・重複・タイムゾーンなどを検査する
- 店舗ごとに対象期間の全日付を
pd.date_range(freq="D")で作り、reindexで1日1行にそろえる。元にない日の売上はNaNになる - 元の行があったかを
record_present列に残す - そろえた表で
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_lag1 | calendar_lag1 | date_mismatch |
|---|---|---|---|---|---|---|---|
| 001 | 9月1日 | NaN | なし | 8月31日 | NaN | NaN | False |
| 001 | 9月3日 | 10 | 9月1日 | 9月2日 | NaN | NaN | True |
| 001 | 9月4日 | 20 | 9月3日 | 9月3日 | 10 | 10 | False |
| 001 | 9月6日 | 20 | 9月4日 | 9月5日 | 20 | NaN | True |
| 001 | 9月7日 | 0 | 9月6日 | 9月6日 | 20 | 20 | False |
| 001 | 9月8日 | 30 | 9月7日 | 9月7日 | 0 | 0 | False |
| 001 | 9月9日 | 40 | 9月8日 | 9月8日 | 30 | 30 | False |
| NA | 9月3日 | 100 | なし | 9月2日 | NaN | NaN | False |
| NA | 9月4日 | 0 | 9月3日 | 9月3日 | 100 | 100 | False |
| NA | 9月5日 | 200 | 9月4日 | 9月4日 | 0 | 0 | False |
| NA | 9月7日 | 200 | 9月5日 | 9月6日 | 200 | NaN | True |
| NA | 9月8日 | 300 | 9月7日 | 9月7日 | 200 | 200 | False |
| NA | 9月9日 | 400 | 9月8日 | 9月8日 | 300 | 300 | False |
店舗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 | 最初の行 | 前日値がNaN | 3日平均がNaN |
|---|---|---|---|---|---|
| 001 | 7 | 2 | 1 | 3行(約43%) | 6行(約86%) |
| NA | 6 | 1 | 1 | 2行(約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時の日付だけを受け付けます。