欠損値処理シリーズ 第8回:
時系列データの欠損値補完 — LOCF / NOCB / 線形補間

欠損値処理シリーズ 第8回:時系列データの欠損値補完 — LOCF / NOCB / 線形補間

ここまでの第5〜7回では、Titanic データのような 順序を持たないデータ の補完を扱ってきました。

第5回:単変量補完① — 定数・任意値での補完(pandas と SimpleImputer)
https://www.salesanalytics.co.jp/datascience/datascience339/

第6回:単変量補完② — 平均・中央値・最頻値での補完
https://www.salesanalytics.co.jp/datascience/datascience340/

第7回:単変量補完③ — ランダムサンプル補完で分布を保つ
https://www.salesanalytics.co.jp/datascience/datascience341/

今回は少し趣を変えて、時系列データ(time series data) の欠損値補完を取り上げます。

時系列データとは、株価・気温・売上のように 時間の順序に沿って並んだデータ のことです。

昨日の値と今日の値は近い」「全体に増加トレンドがある」といった 時間的な構造 を持つため、これまでの手法(平均値補完など)をそのまま使うと、その構造を壊してしまいます。

時系列特有の補完手法である LOCF・NOCB・線形補間 を、サンプルデータを使って一つずつ可視化しながら学んでいきましょう。

なぜ時系列データは特別なのか

これまで扱ってきた手法と、時系列データの違いを整理しておきます。

観点 通常のデータ(Titanic等) 時系列データ
行の順序 意味を持たない(並べ替え可) 時間順で意味を持つ(並べ替え不可)
隣接する値の関係 特に関係なし 隣の時点と強く相関する
補完の発想 列全体の代表値や分布を使う 前後の時点の値 を使う

たとえば、ある日の気温が欠損していたら、「全期間の平均気温」で埋めるより「前日の気温」や「前後の気温の中間」で埋めるほうが、はるかに自然です。

これが時系列補完の基本的な考え方です。

 

補足:時系列補完の主な手法

今回扱うのは次の手法です。

  • ゼロ埋め / 平均埋め:時系列構造を無視した素朴な方法(比較のため)
  • LOCF(Last Observation Carried Forward):直前の値で埋める
  • NOCB(Next Observation Carried Backward):直後の値で埋める
  • 線形補間(linear interpolation):前後の値を直線で結んで埋める

 

サンプルデータの作成

時系列の欠損補完を学ぶため、週次の売上データを人工的に作成します。

以下、コードです。

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# 2023年の週次の日付インデックスを作成
date_range = pd.date_range(
    start='2023-01-01', 
    end='2023-12-31', 
    freq='W'
)

# 再現性のため乱数の種を固定
np.random.seed(42)

# 12000〜15000のランダムな売上データを生成
sales = np.random.uniform(
    12000, 
    15000, 
    size=len(date_range)
).round(2)

# データフレームにまとめ、日付をインデックスに設定
ts = pd.DataFrame({
    'Date': date_range, 
    'Sales': sales
})
ts.set_index('Date', inplace=True)

print(f"データ件数: {len(ts)}件")
print(ts.head())
  • pd.date_range(start, end, freq='W'):指定期間の 週次(Weekly) の日付を生成します
  • np.random.uniform(12000, 15000, ...):12000〜15000の一様乱数で売上を生成
  • ts.set_index('Date', inplace=True):日付列をインデックスにすることで「時系列データ」になります

 

以下、実行結果です。

データ件数: 53件
               Sales
Date                
2023-01-01  13123.62
2023-01-08  14852.14
2023-01-15  14195.98
2023-01-22  13795.98
2023-01-29  12468.06

 

2023年の各週の売上が並んだ時系列データができあがりました。

このデータに、ランダムな位置で欠損を作ります。

以下、コードです。

ts_missing = ts.copy()

# ランダムに10箇所を欠損させる
np.random.seed(42)
n_missing = 10
missing_idx = np.random.choice(
    len(ts_missing), 
    size=n_missing, 
    replace=False
)
ts_missing.iloc[missing_idx, 0] = np.nan

print(f"欠損数: {ts_missing['Sales'].isnull().sum()}件")
print("\n末尾11週の様子:")
print(ts_missing['Sales'].tail(11))
  • np.random.choice(..., replace=False):重複なしで10箇所のインデックスを選ぶ
  • ts_missing.iloc[missing_idx, 0] = np.nan:選んだ位置を欠損にする

 

以下、実行結果です。

欠損数: 10件

末尾11週の様子:
Date
2023-10-22    12103.17
2023-10-29         NaN
2023-11-05    12776.34
2023-11-12    13987.57
2023-11-19    12935.13
2023-11-26         NaN
2023-12-03    13640.13
2023-12-10    12554.56
2023-12-17         NaN
2023-12-24    14325.40
2023-12-31    14818.50
Name: Sales, dtype: float64

 

実行すると、10箇所が NaN になった時系列データができます。

末尾11週を見ると、いくつかの週で売上が欠損していることが確認できます。

 

ダメな方法:ゼロ埋め・平均埋め(比較用)

まずは、これまでの回で学んだ「時系列を無視した方法」を試して、その問題点を可視化します。

一つ目は、欠損をゼロで埋めいく方法です。

以下、コードです。

# 欠損付きデータ
ts_zero = ts.copy()
ts_zero.iloc[missing_idx, 0] = np.nan

# 欠損値をゼロで埋める
ts_zero['Sales'] = ts_zero['Sales'].fillna(0)

# グラフで可視化
fig, ax = plt.subplots(figsize=(11, 4))
# 元のデータのプロット
ax.plot(
    ts.index, 
    ts['Sales'], 
    color='dodgerblue', 
    label='Original', 
    linewidth=1.5
)
# 欠損値をゼロで埋めた場合のプロット
ax.plot(
    ts_zero.index, 
    ts_zero['Sales'],
    color='darkorange', 
    label='Filled with 0', 
    linestyle='--',
    linewidth=1.5
)
ax.set_title('Fill NaN with 0 (Bad Example)')
ax.set_xlabel('Date')
ax.set_ylabel('Sales ($)')
ax.legend()
plt.tight_layout()
plt.show()
  • fillna(0):欠損をゼロで埋める
  • ax.plot(...):折れ線グラフで元データと補完後を重ねて描画

 

以下、実行結果です。

 

欠損箇所だけ グラフが0まで急降下する 不自然なギザギザが現れます。

売上が12000〜15000の範囲なのに、欠損週だけ0になるのは明らかにおかしいですね。

これは時系列データに対して 絶対にやってはいけない補完 の典型例です。

 

二つ目は、欠損を平均で埋めいく方法です。

以下、コードです。

# 欠損付きデータ
ts_mean = ts.copy()
ts_mean.iloc[missing_idx, 0] = np.nan

# 全体の平均値を計算
avg_sales = ts_mean['Sales'].mean()

# 欠損値を平均値で埋める
ts_mean['Sales'] = ts_mean['Sales'].fillna(avg_sales)

# グラフで可視化
fig, ax = plt.subplots(figsize=(11, 4))
# 元のデータのプロット
ax.plot(
    ts.index, 
    ts['Sales'], 
    color='dodgerblue', 
    label='Original', 
    linewidth=1.5
)
# 欠損値を平均値で埋めた場合のプロット
ax.plot(
    ts_mean.index, 
    ts_mean['Sales'],
    color='darkorange', 
    label='Filled with Mean', 
    linestyle='--',
    linewidth=1.5
)
ax.set_title('Fill NaN with Mean')
ax.set_xlabel('Date')
ax.set_ylabel('Sales ($)')
ax.legend()
plt.tight_layout()
plt.show()

 

以下、実行結果です。

 

欠損箇所が すべて同じ高さ(平均値) に揃ってしまい、その週だけ平らになります。

ゼロ埋めよりはマシですが、前後の値とのつながりが不自然です。

時系列データには、前後の値を活かす補完が必要 だということがよくわかります。

 

LOCF(Last Observation Carried Forward)

LOCF は「直前の観測値をそのまま引き継ぐ」方法です。

「Last Observation Carried Forward(最後の観測値を前方に運ぶ)」の略で、欠損が出たら、その直前の値で埋めます。

pandas では ffill()(forward fill)メソッドで実装します。

以下、コードです。

# 欠損付きデータ
ts_locf = ts.copy()
ts_locf.iloc[missing_idx, 0] = np.nan

# LOCFで欠損値を補完
ts_locf['Sales'] = ts_locf['Sales'].ffill()

# グラフで可視化
fig, ax = plt.subplots(figsize=(11, 4))
# 元のデータのプロット
ax.plot(
    ts.index, 
    ts['Sales'], 
    color='dodgerblue',
    label='Original', 
    linewidth=1.5
)
# LOCFで補完したデータのプロット
ax.plot(
    ts_locf.index, 
    ts_locf['Sales'],
    color='darkorange', 
    label='LOCF (ffill)', 
    linestyle='--',
    linewidth=1.5
)
ax.set_title('LOCF - Fill NaN with ffill')
ax.set_xlabel('Date')
ax.set_ylabel('Sales ($)')
ax.legend()
plt.tight_layout()
plt.show()

# 補完後の値を確認
print(ts_locf['Sales'].tail(11))
  • ffill():直前の有効な値で欠損を埋める(forward fill)
  • グラフでは、欠損箇所が 直前の値の高さで横ばい になる

 

以下、実行結果です。

Date
2023-10-22 12103.17
2023-10-29 12103.17
2023-11-05 12776.34
2023-11-12 13987.57
2023-11-19 12935.13
2023-11-26 12935.13
2023-12-03 13640.13
2023-12-10 12554.56
2023-12-17 12554.56
2023-12-24 14325.40
2023-12-31 14818.50
Name: Sales, dtype: float64

 

欠損箇所が直前の値のまま 階段状(水平に伸びる) になります。

値が急に変わらず、直前の状態が続くと仮定する手法です。

 

LOCF が向く場面

「測定しなかった間も状態は変わっていない」と仮定できるデータに向きます。たとえば、センサーが一時停止しても物理量が急変しない場合や、在庫数のように更新があるまで値が保持されるデータです。逆に、トレンドのあるデータでは 古い値を引きずる ため、後述のバイアスに注意が必要です。

 

NOCB(Next Observation Carried Backward)

NOCB は LOCF の逆で、「直後の観測値を後方に引き継ぐ」方法です。

「Next Observation Carried Backward(次の観測値を後方に運ぶ)」の略で、欠損が出たら、その直後の値で埋めます。

pandas では bfill()(backward fill)メソッドで実装します。

以下、コードです。

ts_nocb = ts.copy()
ts_nocb.iloc[missing_idx, 0] = np.nan

fig, ax = plt.subplots(figsize=(11, 4))
ax.plot(ts.index, ts['Sales'], color='dodgerblue',
        label='Original', linewidth=1.5)
ax.plot(ts_nocb.index, ts_nocb['Sales'].bfill(),
        color='darkorange', label='NOCB (bfill)', linewidth=1.5)
ax.set_title('NOCB - Fill NaN with bfill')
ax.set_xlabel('Date')
ax.set_ylabel('Sales ($)')
ax.legend()
plt.tight_layout()
plt.show()

ts_nocb['Sales'] = ts_nocb['Sales'].bfill()
print(ts_nocb['Sales'].tail(11))
  • bfill():直後の有効な値で欠損を埋める(backward fill)
  • グラフでは、欠損箇所が 直後の値の高さ になる

 

以下、実行結果です。

Date
2023-10-22 12103.17
2023-10-29 12776.34
2023-11-05 12776.34
2023-11-12 13987.57
2023-11-19 12935.13
2023-11-26 13640.13
2023-12-03 13640.13
2023-12-10 12554.56
2023-12-17 14325.40
2023-12-24 14325.40
2023-12-31 14818.50
Name: Sales, dtype: float64

 

LOCF とは逆向きの階段状になります。

 

LOCF と NOCB の使い分け
  • LOCF:過去の情報のみを使う。未来の情報を使わない ため、リアルタイム予測など「その時点で未来が見えない」状況に適する
  • NOCB:未来の値を使う。過去にさかのぼって埋めるため、バックテストや事後分析 など全データが揃っている状況で使う

機械学習の前処理では、未来の情報を使う NOCB はデータ漏洩のリスク がある点に注意が必要です。予測モデルでは LOCF のほうが安全なことが多いです。

 

線形補間(Linear Interpolation)

線形補間 は、欠損の 前後の値を直線で結び、その直線上の値で欠損を埋める方法です。

LOCF・NOCB のような階段状ではなく、滑らかにつながる のが特徴です。

pandas では interpolate(method='linear') で実装します。

以下、コードです。

# 欠損付きデータ
ts_interp = ts.copy()
ts_interp.iloc[missing_idx, 0] = np.nan

# 線形補間で欠損値を補完
ts_interp['Sales'] = ts_interp['Sales'].interpolate(method='linear')

# グラフで可視化
fig, ax = plt.subplots(figsize=(11, 4))
# 元のデータのプロット
ax.plot(
    ts.index, 
    ts['Sales'], 
    color='dodgerblue',
    label='Original', 
    linewidth=1.5
)
# 線形補間で補完したデータのプロット
ax.plot(
    ts_interp.index, 
    ts_interp['Sales'],
    color='darkorange', 
    label='Linear Interpolation', 
    linestyle='--',
    linewidth=1.5
)
ax.set_title('Fill NaN with Linear Interpolation')
ax.set_xlabel('Date')
ax.set_ylabel('Sales ($)')
ax.legend()
plt.tight_layout()
plt.show()

# 補完後の値を確認
print(ts_interp['Sales'].tail(11))
  • interpolate(method='linear'):前後の値を直線で結んで補完
  • 連続して欠損がある場合も、両端の値を結ぶ直線上に等間隔で配置される

 

以下、実行結果です。

Date
2023-10-22 12103.170
2023-10-29 12439.755
2023-11-05 12776.340
2023-11-12 13987.570
2023-11-19 12935.130
2023-11-26 13287.630
2023-12-03 13640.130
2023-12-10 12554.560
2023-12-17 13439.980
2023-12-24 14325.400
2023-12-31 14818.500
Name: Sales, dtype: float64

 

欠損箇所が前後の値を結ぶ なだらかな線 で埋められます。

3手法の中では最も自然な見た目になることが多く、トレンドや連続性のあるデータに適しています

 

補足:その他の補間方法

interpolate() には method='linear' 以外にも、より滑らかな曲線で補間する手法があります。

  • method='polynomial', order=2:多項式補間(曲線でフィット)
  • method='spline', order=3:スプライン補間(区分的な滑らかな曲線)
  • method='time':時間間隔を考慮した補間(不規則な日付間隔に有効)

データの性質に応じて使い分けますが、まずは linear を基本に考えるとよいでしょう。

 

4手法を一覧で比較する

最後に、LOCF・NOCB・線形補間・平均埋めを 1枚のグラフに並べて 比較しましょう。

それぞれの挙動の違いが一目でわかります。

以下、コードです。

# 欠損付きデータ
ts_cmp = ts.copy()
ts_cmp.iloc[missing_idx, 0] = np.nan

methods = {
    'Mean Fill': ts_cmp['Sales'].fillna(ts_cmp['Sales'].mean()),
    'LOCF (ffill)': ts_cmp['Sales'].ffill(),
    'NOCB (bfill)': ts_cmp['Sales'].bfill(),
    'Linear Interp.': ts_cmp['Sales'].interpolate(method='linear'),
}

# 1枚のグラフに全ての補完方法をまとめて可視化
fig, ax = plt.subplots(figsize=(11, 6))

# 元データ
ax.plot(
    ts.index,
    ts['Sales'],
    color='gray',
    label='Original',
    linewidth=1.0,
    alpha=0.9
)

# 各補完方法を同じグラフに重ねて表示
colors = {
    'Mean Fill': 'tab:orange',
    'LOCF (ffill)': 'tab:green',
    'NOCB (bfill)': 'tab:red',
    'Linear Interp.': 'tab:blue',
}

for name, filled in methods.items():
    ax.plot(
        ts_cmp.index,
        filled,
        label=name,
        color=colors[name],
        linewidth=1.5,
        linestyle='--',
        alpha=0.85
    )

# 欠損箇所を強調表示
missing_dates = ts_cmp.index[ts_cmp['Sales'].isna()]
ax.scatter(
    missing_dates,
    ts.loc[missing_dates, 'Sales'],
    color='red',
    label='Missing Positions',
    s=60,
    marker='x',
    zorder=10
)

ax.set_title('Comparison of Missing Value Imputation Methods')
ax.set_xlabel('Date')
ax.set_ylabel('Sales ($)')
ax.grid(True, alpha=0.3)
ax.legend(
    loc='best',
    bbox_to_anchor=(1, 1),
)
plt.show()

 

実行すると、4つのグラフが並びます。

  • Mean Fill:欠損箇所だけ平均値の高さに飛ぶ(前後と不連続)
  • LOCF:直前の値で水平に伸びる(階段状)
  • NOCB:直後の値で水平に伸びる(逆向きの階段状)
  • Linear Interp.:前後を直線で結び、最も滑らか

以下、実行結果です。

 

この比較から、時系列データには線形補間や LOCF/NOCB が適しており、平均埋めは不向き だということが視覚的に理解できます。

 

トレンドのあるデータでの注意点

ここまでのサンプルデータは、ランダムに変動するだけで明確なトレンド(増加・減少傾向)がありませんでした。

しかし、右肩上がりのトレンドがあるデータ では、手法選びがより重要になります。

以下、コードです。

# 増加トレンドのある時系列を作成
np.random.seed(0)
trend = np.linspace(10000, 20000, 52)  # 1万から2万へ増加
noise = np.random.normal(0, 500, 52)
trend_sales = trend + noise

date_idx = pd.date_range(start='2023-01-01', periods=52, freq='W')
ts_trend = pd.DataFrame({'Sales': trend_sales}, index=date_idx)

# 中間に連続した欠損を作る
ts_trend.iloc[15:30, 0] = np.nan

# グラフで可視化
fig, ax = plt.subplots(figsize=(11, 4))
# 元のデータのプロット
ax.plot(
    ts_trend.index, 
    trend + noise, 
    color='lightgray',
    label='True (hidden)', 
    linewidth=2
)
# LOCFで補完したデータのプロット
ax.plot(
    ts_trend.index, 
    ts_trend['Sales'].ffill(),
    color='darkorange', 
    label='LOCF', 
    linestyle='--',
    linewidth=1.5
)
# 線形補間で補完したデータのプロット
ax.plot(
    ts_trend.index, 
    ts_trend['Sales'].interpolate(method='linear'),
    color='seagreen', 
    label='Linear Interp.', 
    linestyle='--',
    linewidth=1.5
)
ax.set_title('Imputation on Trending Data')
ax.set_xlabel('Date')
ax.set_ylabel('Sales ($)')
ax.legend()
plt.tight_layout()
plt.show()
  • np.linspace(10000, 20000, 52):1万から2万へ直線的に増加するトレンドを作成
  • 15〜30週目に連続した欠損を作り、LOCF と線形補間の違いを見る

 

以下、実行結果です。

 

増加トレンドのある区間で、次のようになっています。

  • LOCF(オレンジ):欠損区間で値が 横ばいになり、トレンドから取り残される。実際の値(灰色)より低い位置で停滞してしまう
  • 線形補間(緑):欠損区間でも トレンドに沿って上昇 し、実際の値に近い

このように、トレンドのあるデータでは LOCF/NOCB がトレンドを無視してバイアスを生む ことがあります。

一方、線形補間は前後を結ぶためトレンドをある程度追従できます。

 

重要:手法選択はデータの性質次第

「どの手法が常に最良」ということはありません。データに トレンドがあるか急変があり得るか周期性があるか を見極めて選ぶ必要があります。迷ったら、複数の手法で補完したグラフを描いて、最も自然に見えるものを選ぶのが実践的です。

 

まとめ

今回のポイントを振り返りましょう。

  • 時系列データ は時間順という構造を持つため、平均値補完などをそのまま使うと不自然になる
  • ゼロ埋め・平均埋め は時系列構造を壊すので避けるべき
  • LOCF(ffill():直前の値で埋める。未来情報を使わないので予測モデル向き
  • NOCB(bfill():直後の値で埋める。未来情報を使うのでデータ漏洩に注意
  • 線形補間(interpolate(method='linear'):前後を直線で結ぶ。最も滑らかで連続性のあるデータ向き
  • トレンドのあるデータ では LOCF/NOCB がバイアスを生むため、線形補間が有利なことが多い
  • 万能の手法はなく、データの性質に応じて選び、可視化して確認する ことが重要