前回までの記事で、HANTS などを用いた時系列データの補正手法を学びました。しかし実際の解析では、補正だけでなく、時系列データの特徴を把握することも重要になります。
そこで本記事では、時系列分解の代表的な手法である STL を紹介します。時系列分解とは、時系列データに含まれる成分(トレンド・季節性・残差など)を推定する方法です。
この分解結果を活用すると、衛星データにおける時系列解析の基盤を身につけることが出来ます。
Python 3.12.7, Jupyter Notebook, statsmodels 0.14.4
時系列分解の意義
多くの実世界のデータは、時間的な変化を伴った時系列データとして記録されます。需要予測、気象予報、株価分析など、時系列データを分析する分野は多岐にわたります。
時系列データは大抵以下のような構造が含まれています。
- トレンド(Trend) : 長期的に増減する動き
- 季節成分(Seasonality) : 1年や1か月など一定の周期で繰り返す変動
- 残差(Residual) : 不規則な変動やノイズなど
これらの成分を分解して扱えると、データの理解が格段に進み、将来予測や異常検知に応用する際にも有利になります。
伝統的な時系列分解モデル
加法モデル(Additive Model)
古典的な加法モデルによる時系列分解では、時系列 $y_t$ を以下のように表します。
$y_t = T_t + S_t + R_t$
- $y_t$: 観測値 (Observed series)
- $T_t$: トレンド成分 (Trend)
- $S_t$: 季節成分 (Seasonality)
- $R_t$: 残差 (Residual)
利用される場面
- 季節成分やトレンド成分の変動幅が、データの水準(値の大きさ)に依存しない場合によく利用されます。
- データの変動が一定で、全体の水準に関係なく一定の幅で変動する場合に有効です。
問題点
- データの水準に比例して変動幅が変化する場合(データが増加するにつれて季節変動の幅も増加する場合など)、加法モデルでは適切に表現されにくいです。
乗法モデル(Multiplicative Model)
一方、乗法モデルでは、
$y_t = T_t × S_t × R_t$
の形をとります。ログを取ることで加法モデルと類似の形に変換しやすい点も特徴です。
利用される場面
- 季節成分やトレンド成分の変動幅が、データの水準に比例して変化する場合によく利用されます。
- データの水準が高くなると変動幅も大きくなり、低くなると変動幅も小さくなるような場合に有効です。
問題点
- データにゼロや負の値が含まれる場合適用できません。
- データの変動幅が一定である場合、乗法モデルは過剰な適合を引き起こす可能性があります。
こうした伝統的な加法モデル・乗法モデルと比べて、より柔軟性の高い分解法としてしばしば用いられるのが STL(Seasonal and Trend decomposition using Loess)です。
STLとは?
STL(Seasonal and Trend decomposition using Loess)は、Clevelandら(1990)が提案した時系列分解手法であり、時系列を「トレンド」「季節成分」「残差」に分解します。STL の “Loess” は、平滑化手法の一種である局所多項式回帰 (Loess) を指します。STL は、こうした局所回帰による柔軟な平滑化と、繰り返し(ループ)処理を組み合わせることで、頑健で高度な分解を可能にしています。
Loessによる平滑化
STL で用いられる Loessは、ある局所近傍のデータに対して低次の多項式回帰を当てはめる局所回帰手法です。
- 急激な変動や非線形なパターンが含まれるデータでも、局所的にフィットするため、柔軟にトレンド・季節性を捉えることができます。
- グローバルに一次回帰や二次回帰をかけるのではなく、データの近傍だけを取り出してそこに小さな多項式モデルを適用し、平滑な曲線を得るというのが特徴です。
繰り返し処理(inner loop と outer loop)
STL は、いくつかのループ処理によってトレンドと季節成分を段階的に洗練していく点が特筆されます。大きく分けて、外側ループ (outer loop) と 内側ループ (inner loop) の 2 層のループ処理があります。
- 初期トレンドや初期季節成分の仮定
- まず大まかなトレンドと季節性を推定する初期値を設定します。
- 季節成分の推定(inner loop)
- トレンドを差し引いた残差に対して、Loess を用いて季節成分を平滑化・推定します。
- ここで行う平滑化ステップを数回繰り返し、より安定した季節成分を得ます。
- トレンド成分の推定(inner loop)
- 今度は推定した季節成分を差し引いた残差を、再び Loess にかけ、長期的な傾向(トレンド)を更新します。
- 季節成分推定と同様に、数回の反復を行うことで、より滑らかなトレンドを得ます。
- 外れ値へのロバスト化(重みづけの変更)(outer loop)
- 推定されたトレンドと季節性をもとに、一時的・極端な外れ値に対して重みづけを変更します。
- 外れ値の影響を小さくし、その結果をもとに再度内側ループを回すことで、外れ値に強い分解を可能にします。
- 2~4 を数回繰り返し、収束させる
- 各ステップで少しずつトレンドと季節性を修正していき、最終的に安定した分解結果へ収束させます。
このように、STL ではinner loopとouter loopを組み合わせることで、トレンドと季節性を段階的に分離し、外れ値の影響も抑えつつ、柔軟かつ頑健な時系列分解を行います。
STLの実装方法
以上述べたことを基に、下記のPythonコードをJupyterで実行します。本記事では、Pythonライブラリのstatsmodels
を利用したSTLの実装について紹介します。
import ee
import matplotlib.pyplot as plt
import pandas as pd
from statsmodels.tsa.seasonal import STL
# GEEの認証(必要に応じてコメントアウトを外す)
# ee.Authenticate()
ee.Initialize()
# 緯度経度、データを扱う衛星の設定
location_lonlat = [140.219017, 35.757631]
stat_region = ee.Geometry.Point(location_lonlat)
sentinel2 = (
ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')
.filterDate('2019', '2024')
.filterBounds(stat_region)
.filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20))
)
# 関心領域を切り取る関数を定義
def reduce_region_function(img):
stat = img.reduceRegion(
reducer=ee.Reducer.mean(),
geometry=stat_region,
scale=10
)
return ee.Feature(stat_region, stat)\
.set({'millis': img.date().millis()})
# GEEデータを辞書型に変換する関数を定義
def fc_to_dict(fc):
prop_names = fc.first().propertyNames()
prop_lists = fc.reduceColumns(
reducer=ee.Reducer.toList().repeat(prop_names.size()),
selectors=prop_names
).get('list')
return ee.Dictionary.fromLists(prop_names, prop_lists)
# NDVI計算用関数を定義
def calc_ndvi(image):
ndvi = image.normalizedDifference(['B8', 'B4']).rename('NDVI')
return image.addBands(ndvi)
# データフレーム成型用関数を定義
def add_date_info(df):
# millis から Timestamp(日時型)を作成
df['Timestamp'] = pd.to_datetime(df['millis'], unit='ms')
# 年・月・日カラムを作成
df['Year'] = df['Timestamp'].dt.year
df['Month'] = df['Timestamp'].dt.month
df['Day'] = df['Timestamp'].dt.day
# YYYY-MM-DD 形式のカラムを追加
df['Date'] = df['Timestamp'].dt.strftime('%Y-%m-%d')
return df
# NDVIを計算
ndvi = sentinel2.map(calc_ndvi)
ndvi_stat_fc = (
ee.FeatureCollection(ndvi.map(reduce_region_function))
.filter(ee.Filter.notNull(["NDVI"]))
)
# 衛星データを辞書型に変換
ndvi_dict = fc_to_dict(ndvi_stat_fc).getInfo()
ndvi_df = pd.DataFrame(ndvi_dict)
# 日付情報を追加し、Date 列をインデックスに設定
ndvi_df = add_date_info(ndvi_df)
ndvi_df['Date'] = pd.to_datetime(ndvi_df['Date'])
ndvi_df.set_index('Date', inplace=True)
# 月末 (ME) を基準にリサンプリングし、平均を取る
ndvi_monthly = ndvi_df['NDVI'].resample('ME').mean()
# STLによる分解
result = STL(ndvi_monthly.dropna(), period=12, robust=True).fit()
observed = result.observed
trend = result.trend
seasonal = result.seasonal
resid = result.resid
# 可視化
fig, axes = plt.subplots(4, 1, figsize=(8, 8), sharex=True)
# 1) Observed
axes[0].plot(observed.index, observed.values, label='Observed')
axes[0].set_ylabel("NDVI")
# 2) Trend
axes[1].plot(trend.index, trend.values, color='C1', label='Trend')
axes[1].set_ylabel("Trend")
# 3) Seasonal
axes[2].plot(seasonal.index, seasonal.values, color='C2', label='Seasonal')
axes[2].set_ylabel("Season")
# 4) Residual
axes[3].plot(resid.index, resid.values, 'o', color='C3', label='Resid', markersize=3)
axes[3].axhline(y=0, color='black', linewidth=1)
axes[3].set_ylabel("Resid")
# Y 軸ラベル位置の調整
for ax in axes:
ax.yaxis.set_label_coords(-0.1, 0.5)
plt.tight_layout()
plt.show()
実行結果
以下のようなグラフが出力されれば成功です。
青の元データに対して、STLで分解したそれぞれの成分は下記のような特徴があることがわかります。
- トレンド: 2019年〜2021年頃にかけて上昇傾向が強まり、2022年以降はやや減少傾向。
- 季節性: 一年周期でプラス・マイナスに振れる特徴が明確に見られる。NDVI特有の生育期・非生育期のパターンをよく捉えている。
- 残差: トレンドと季節性を取り除いたあとでも一定のばらつきはあるが、明確な偏りは比較的小さい。
特に季節性ははっきりとした12ヶ月周期を持つと言えそうです。
コードの解説
上に書いたソースコードの解説をしていきます。
NDVIの計算
# 緯度経度、データを扱う衛星の設定
location_lonlat = [140.219017, 35.757631]
stat_region = ee.Geometry.Point(location_lonlat)
sentinel2 = (
ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')
.filterDate('2019', '2024')
.filterBounds(stat_region)
.filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20))
)
# 関心領域を切り取る関数を定義
def reduce_region_function(img):
stat = img.reduceRegion(
reducer=ee.Reducer.mean(),
geometry=stat_region,
scale=10
)
return ee.Feature(stat_region, stat)\
.set({'millis': img.date().millis()})
# GEEデータを辞書型に変換する関数を定義
def fc_to_dict(fc):
prop_names = fc.first().propertyNames()
prop_lists = fc.reduceColumns(
reducer=ee.Reducer.toList().repeat(prop_names.size()),
selectors=prop_names
).get('list')
return ee.Dictionary.fromLists(prop_names, prop_lists)
# NDVI計算用関数を定義
def calc_ndvi(image):
ndvi = image.normalizedDifference(['B8', 'B4']).rename('NDVI')
return image.addBands(ndvi)
# データフレーム成型用関数を定義
def add_date_info(df):
# millis から Timestamp(日時型)を作成
df['Timestamp'] = pd.to_datetime(df['millis'], unit='ms')
# 年・月・日カラムを作成
df['Year'] = df['Timestamp'].dt.year
df['Month'] = df['Timestamp'].dt.month
df['Day'] = df['Timestamp'].dt.day
# YYYY-MM-DD 形式のカラムを追加
df['Date'] = df['Timestamp'].dt.strftime('%Y-%m-%d')
return df
# NDVIを計算
ndvi = sentinel2.map(calc_ndvi)
ndvi_stat_fc = (
ee.FeatureCollection(ndvi.map(reduce_region_function))
.filter(ee.Filter.notNull(["NDVI"]))
)
# 衛星データを辞書型に変換
ndvi_dict = fc_to_dict(ndvi_stat_fc).getInfo()
ndvi_df = pd.DataFrame(ndvi_dict)
# 日付情報を追加し、Date 列をインデックスに設定
ndvi_df = add_date_info(ndvi_df)
ndvi_df['Date'] = pd.to_datetime(ndvi_df['Date'])
ndvi_df.set_index('Date', inplace=True)
# 月末 (ME) を基準にリサンプリングし、平均を取る
ndvi_monthly = ndvi_df['NDVI'].resample('ME').mean()
この処理は、Google Earth Engine (GEE) を使って特定地域の Sentinel-2 衛星データから NDVI (正規化植生指数) を計算し、それを pandas の DataFrame として扱いやすい形式に整形したあと、月末単位でリサンプリングして平均値を算出するまでの流れを示しています。基本的にはこちらの記事と同様です。
STL分解
# STLによる分解
result = STL(ndvi_monthly.dropna(), period=12, robust=True).fit()
observed = result.observed
trend = result.trend
seasonal = result.seasonal
resid = result.resid
この処理では衛星データのSTL分解を行います。statsmodels.tsa.seasonal.STL
には多くのオプションパラメータがありますが、ここではとくに重要度が高いperiod
と robust
を設定しました。
まず period
は、どれくらいの周期を「季節成分」として捉えるかを定義するための引数です。たとえば月次データにおいて「1年=12ヶ月」のサイクルを想定したいのであれば、period=12
と指定します。今回は元データがほぼ一年周期のサイクルを示していたので、12
を指定しました。
このパラメータを適切に選ぶことで、季節成分が想定通りにうまく抽出されます。反対に期間が合わない値を与えると、「ずれた周期で季節を拾ってしまう」「トレンドとの分離がうまくいかない」などの不自然な結果になることがあります。
例えば、今回のデータでperiod=6
で分解した場合、period=6
の図のようになります。トレンドが細かく波打ち、残差が大きくなっていることがわかります。時系列データのトレンドや季節性に注目したい場合、残差が大きくなったperiod=6
の条件はあまり適していないと言えそうです。
次に robust
は、外れ値など極端な値に対して強い推定を行うかどうかを制御します。robust=True
にすると外れ値への重みづけが小さくなり、結果として外れ値が分解に与える影響が抑制されます。とくに衛星観測データのように一時的なノイズや雲などが多い場合、ロバスト推定を有効にしておくことで全体のトレンドや季節成分の推定が安定しやすくなります。逆にロバスト化をオフにしていると、外れ値が強く反映されて季節成分が歪んだり、トレンドが大きく跳ねたりすることがあります。
今回のデータでは、被雲率のフィルターや月平均処理を行っているため、大きな外れ値がほとんど見られず、あまり可視的な差が見られませんでした。
最後に
時系列分解において、伝統的な加法モデルや乗法モデルは分解の枠組みとしてはシンプルですが、実際のデータの動きが複雑になるほど「季節パターンの形状が毎年変わる」「外れ値が多い」などの課題が表面化します。STL は、Loess の局所回帰でトレンドと季節成分を交互に推定しつつ、ロバスト化も考慮するため、複雑性や外れ値に強い手法といわれています。
今後、需要予測や気象分析などで「伝統的なモデルではうまく表現しきれない非線形パターン」や「年々変化する季節パターン」を扱う際には、STL の導入を検討してみてください。データの理解や予測の正確性が大きく向上するはずです。
参考文献
- Cleveland, Robert B., et al. “STL: A seasonal-trend decomposition.” J. off. Stat 6.1 (1990): 3-73.
- https://www.statsmodels.org/dev/generated/statsmodels.tsa.seasonal.STL.html