移動平均のNaNを調べるときは、その行の窓に入った値を並べ、有効値を数えます。 rolling(3).mean()は既定で有効値が3個必要です。先頭だけでなく、3行の中に欠損がある途中の位置でもNaNになります。
対象は、日報や観測ログをpandasで集計し、移動平均が途中で途切れる理由を知りたい人です。架空の6行で、行が足りない場合と、行はあるのに値が足りない場合を分けます。
同じ6行で、NaNが出る場所を読む
架空の受付記録を日付順に並べる
下表は、受付件数を記録した架空のログです。4日の行には受付記録がありません。3日の欠損とは別です。5日の0は、観測を実施して受付がなかったことを表します。
| 日付 | 受付件数 | 意味 |
|---|---|---|
| 11月1日 | 8 | 観測済み |
| 11月2日 | 4 | 観測済み |
| 11月3日 | NaN | 行はあるが値は未取得 |
| 11月5日 | 0 | 観測した結果が0 |
| 11月6日 | 6 | 観測済み |
| 11月7日 | 12 | 観測済み |
先に何を予測・報告するかも決めておきます。ここでは「各行の終了時点で、その行を含む過去の平均」を求めます。朝の時点で当日の受付件数を見積もるなら、当日終了後に分かる値を入力へ含めることはできません。
import pandas as pd
days = pd.to_datetime(["2026-11-01", "2026-11-02", "2026-11-03",
"2026-11-05", "2026-11-06", "2026-11-07"])
received = pd.Series([8, 4, None, 0, 6, 12], index=days, name="received")
assert received.index.is_monotonic_increasing
windowは窓、min_periodsは結果を出す条件
整数のwindow=3では、右端の行と直前2行を候補にします。先頭の位置でも、存在する範囲で窓は作られます。ただし、有効値の数が条件を下回る間は平均を表示しません。
pandasの窓関数ガイドとrollingの公式リファレンス(2026年11月9日確認)には、min_periodsは有効値の最小個数で、整数の窓では省略時に窓幅と同じ、時間の窓では1と説明されています。方式を比べるときはmin_periodsを明示すると、窓の変更と集計条件の変更を分けて見られます。
three_rows = received.rolling(3)
report = pd.DataFrame({
"received": received,
"valid_in_3_rows": received.rolling(3, min_periods=1).count(),
"require_3": three_rows.mean(),
"require_2": received.rolling(3, min_periods=2).mean(),
})
assert report["valid_in_3_rows"].tolist() == [1, 2, 2, 2, 2, 3]
assert report["require_3"].notna().tolist() == [False] * 5 + [True]
assert report["require_2"].iloc[1:].tolist() == [6, 6, 2, 3, 6]
print(report)
先頭不足と途中の欠損を見分ける
1日は窓に8しか入らず、2日も8と4しかありません。これが先頭不足です。3日には3行がそろいますが、窓は8・4・欠損なので、有効値は2個です。5日の窓は4・欠損・0、6日は欠損・0・6です。どちらにも3行あり、条件を満たさない理由は欠損です。
| 終了日 | 窓に入る値 | 有効件数 | 3件必要 | 2件必要 |
|---|---|---|---|---|
| 11月1日 | 8 | 1 | NaN | NaN |
| 11月2日 | 8、4 | 2 | NaN | 6 |
| 11月3日 | 8、4、欠損 | 2 | NaN | 6 |
| 11月5日 | 4、欠損、0 | 2 | NaN | 2 |
| 11月6日 | 欠損、0、6 | 2 | NaN | 3 |
| 11月7日 | 0、6、12 | 3 | 6 | 6 |
rolling(3).count()にも省略時の条件がかかるため、検算用のカウントではmin_periods=1を指定しました。空の位置を含むログでも、この列が何を数えているかを説明しやすくなります。カウント列の0は「窓に有効値がない」、元の受付件数の0は「有効値として数える」という違いがあります。
min_periodsを下げても欠損値は復元しない
5日にmin_periods=2で表示される2は、4と0の平均です。欠損を含めた3日分の値が判明したわけではありません。逆に、先に欠損を0で埋めれば4・0・0の3値で割るため、約1.33になります。2と約1.33は集計の目的が違います。
| やりたいこと | 処理 | 11月5日の読み方 |
|---|---|---|
| 十分にそろうまで表示を待つ | min_periods=3 | 欠損のため算出を保留 |
| 少数でも速報値を出す | min_periods=2+件数表示 | 観測できた2件の平均は2 |
| 未観測を0と記録する規則がある | 根拠を確認してから補完 | 0を含める前提で約1.33 |
結果が同じ6でも、11月3日は2件平均、11月7日は3件平均です。表の利用者がこれらを区別できるように、例えば「平均6(有効2/3)」と表記します。件数が少ない日の線を別の色にする方法もありますが、平均値の列そのものへ文字を混ぜる必要はありません。
3行と3日を切り替えて、集計の意図を確かめる
日付が飛ぶと整数の窓と時間の窓がずれる
11月5日の直近3行は2日・3日・5日です。一方、rolling("3D", closed="right")では「5日の時刻から3日前を除き、5日の時刻を含む」範囲になります。全ての時刻を日付の0時にそろえたこの例では、ちょうど3日前の2日は対象外です。
by_rows = received.rolling(3, min_periods=1).mean()
by_time = received.rolling("3D", min_periods=1, closed="right").mean()
nov5 = pd.Timestamp("2026-11-05")
assert by_rows.loc[nov5] == 2
assert by_time.loc[nov5] == 0
print(pd.DataFrame({"3 rows": by_rows, "3 days": by_time}))
時間の窓には4日の行が自動で追加されません。「全日がそろった3日間」という要件なら、1日1行になるように集計した上で日付を補い、行がなかった日の意味を決めます。単純な営業日集計と、休日も含めた暦日の集計は同じ表に混ぜないようにします。
朝に使う指標なら当日の値を含めない
当日の受付件数を朝の報告に使いたいとき、当日の値を含む平均はその時点で作れません。整数の窓で「直前3観測」を求める場合は、値を1行ずらしてから窓を作ります。時間の窓で「当日を除く直前3日」が要件なら、終了点を除く方法を使います。この例の11月7日では、前者が3日・5日・6日、後者が5日・6日を参照します。3日の値は欠損なので、どちらも有効値0と6の平均は3です。結果が同じでも対象範囲が一致したとは判断できません。
row_history = received.shift(1).rolling(3, min_periods=1).mean()
day_history = received.rolling("3D", min_periods=1, closed="left").mean()
nov7 = pd.Timestamp("2026-11-07")
assert row_history.loc[nov7] == 3
assert day_history.loc[nov7] == 3
assert by_rows.loc[nov7] == 6
現実のログでは、6日分が7日朝にまだ確定していないこともあります。日付の比較だけで判断せず、利用可能になる時刻を確認します。例えば、集計担当が翌日の正午までに確認する運用なら、朝の指標から前日分も外す必要があるかもしれません。
店舗が切り替わる境界も検算する
複数拠点のログを1本のSeriesとして計算すると、B拠点の最初の窓にA拠点の最後の値が入り得ます。拠点と日付で並べ、拠点ごとに窓を作ると、B拠点の先頭はBだけの実績で計算できます。
logs = pd.DataFrame({"site": ["A", "A", "B", "B"],
"day": pd.to_datetime(["2026-11-01", "2026-11-02"] * 2),
"received": [8, 4, 30, 24]})
logs = logs.sort_values(["site", "day"])
logs["avg_2"] = logs.groupby("site")["received"].transform(
lambda values: values.rolling(2, min_periods=2).mean()
)
assert logs["avg_2"].isna().tolist() == [True, False, True, False]
assert logs["avg_2"].dropna().tolist() == [6, 27]
同じ日付に複数行ある場合は、先に日単位の件数へ集約するのか、1行を一つの観測として数えるのかを決めます。拠点を表すキー自体の欠損も別に点検してください。groupbyで欠損キーと合計を検算する手順と合わせて、窓を作る前の行数を確認できます。
実際に渡す表には、窓を再現できる情報を残す
平均の値だけでなく、終了日、有効件数、使用した窓、必要件数を残します。時間の窓ならタイムゾーンや終了点も記録します。元の列を保持して、問題の日付を1行選べば「どの値が入っていくつで割ったか」を説明できる形にします。
報告用の表では、数値の欠損を0へ置き換える前に担当者へ確認します。0件と未取得が混ざっているなら、件数と状態を別の列にします。過去のデータを後日修正する運用では、同じ平均を再計算して差分を確認できるよう、更新時点と元データの範囲も残します。
独学では、11月3日の欠損を仮に10へ変え、11月5日の3行平均が14÷3になることを確認してみてください。11月6日は10・0・6で16÷3になり、11月7日の平均6は変わりません。影響する範囲まで予想できれば、実データで値が変わったときにも見直す場所が分かります。
Pythonの集計を基礎から学び直すとき
欠損や日付の扱いを自分で説明できないなら、まず小さな表で集計結果を検算します。Python WinnerのPythonマスターは、Pythonの基礎からデータ分析の基礎までをマンツーマンで学ぶ講座です(2026年11月9日公式確認)。相談する場合は、欠損を含む表の集計や、自分で書いた処理の添削を扱えるか聞いておくと判断しやすくなります。
よくある質問
Q. rolling(3)で先頭2行がNaNになるのは不具合ですか?
現在行を含めて3件そろうまではNaNになるのが通常です。途中にも欠損があれば、3行そろっていても有効値が3件未満の窓はNaNになります。
Q. min_periods=1なら必ず値が出ますか?
有効値が1個以上のときだけ平均が出ます。窓の中が全て欠損なら、平均もNaNです。
Q. 0は有効件数に入りますか?
入ります。観測して得た0と未観測の欠損を区別してください。
Q. rolling(3)は3日平均ですか?
現在行を含む3行です。日付が飛ぶ場合や同日に複数行がある場合は、3日間と一致しません。
Q. center=TrueにするとNaNがなくなりますか?
窓の結果を置く位置と参照する範囲が変わりますが、必要な有効件数の条件は残ります。未来の値を使えない予測では、前後の値を含む窓に注意してください。
Q. 元の件数の列を平均で上書きしてよいですか?
検算できるよう元の列と平均の列を分け、有効件数も残すと、後から判断を確認できます。