09 海面水温の長期トレンド

ERSSTv5 の月平均海面水温データを用いて、各グリッドで時間方向の線形回帰を行い、全球のトレンドマップと p 値による有意性を調べる。

講義名
データサイエンス
使用言語
Python
到達目標
各格子点で線形回帰を行い、SST の増減トレンドとその有意性を地図として表現できるようになる

1. 背景:全球海面水温は一様に変化するのか

全球平均の海面水温は長期的には上昇傾向を示すが、海のどこでも同じ速さで上がっているわけではない。ある海域では強く上昇し、別の海域では変化が小さかったり、期間によっては低下して見えることもある。

そこで今回は、全球海面水温データを格子点ごとに時間方向へ線形回帰し、海面水温のトレンドを地図として可視化する。また、求めた傾きが統計的に 0 と区別できるかどうかを p 値で判定する。

この回の狙いは、散布図 1 本で行っていた回帰計算を、全球の全格子へ拡張して考えられるようになることである。
Copernicus の全球海面水温トレンド図
全球海面水温トレンド(1982–2024)の例。赤は昇温、青は冷却を示す。
出典: Copernicus Marine Service, GLOBAL_OMI_TEMPSAL_sst_trend

Copernicus 図の Classification

項目内容
Full nameGlobal Ocean Sea Surface Temperature trend map from Observations Reprocessing
Product IDGLOBAL_OMI_TEMPSAL_sst_trend
SourceSatellite observations
Spatial extentGlobal Ocean, Latitude -89.97° to 89.98°, Longitude -179.98° to 179.98°
Spatial resolution0.05° × 0.05°
Temporal extentSince 1 Jan 1982
VariablesSST trends
Indicator familySea water temperature
Feature typeGrid
FormatNetCDF-4
Originating centreMet Office (UK)
Last metadata update25 November 2025

2. 使用データ

本演習では NOAA Extended Reconstructed SST version 5(ERSSTv5)の月平均海面水温データを使う。ファイルはこのページと同じディレクトリに置いてあるものを使う。

ERSSTv5 の特徴
このファイルには sst(time, lat, lon) が格納されている。時間は月単位、緯度は 89 点、経度は 180 点である。単位は degC である。

ERSSTv5 の仕様

項目内容
Temporal CoverageMonthly values for 1854/01 - 現在
Long-term mean1981–2010
Spatial Coverage全球 2.0° 緯度 × 2.0° 経度格子(89 × 180)
Latitude / Longitude88.0N - 88.0S, 0.0E - 358.0E
LevelSea Level
Update ScheduleMonthly
FormatCF metadata standard, NetCDF4
Missing data-9.96921e+36
NetCDF ファイルはテキストではない。pandas.read_csv ではなく、xarray.open_dataset を使って読む。

3. 計算の流れ

  1. ERSSTv5 の NetCDF ファイルを読み込む
  2. 解析したい期間を切り出す
  3. 時間を「年の小数」に変換する
  4. 各グリッドで y = ax + b の線形回帰を行う
  5. 傾き a をトレンドとして保存する
  6. 傾きの p 値を求め、有意な格子点を判定する
  7. トレンドマップと有意性マップを描く
解析期間を変えると、求まる傾きも p 値も変わる。Copernicus の図と比較したいときは、できるだけ同じ期間でそろえることが重要である。

4. Pythonでよく出る機能の意味

書き方意味
xr.open_dataset(...)NetCDF などの多次元データを読む。
ds["sst"]変数 sst を取り出す。
pd.to_datetime(...)時間情報を日時型に変換する。
np.isfinite(...)有限値かどうかを調べる。NaN を除くときに使う。
np.polyfit(x, y, 1)1次式 y = ax + b の傾きと切片を求める。
np.full(shape, np.nan)最初から NaN を入れた配列を作る。
stats.t.cdf(...)t 分布の累積分布関数。p 値の計算に使う。
ax.pcolormesh(...)格子状データを色で塗って描く。
ax.scatter(...)点を描く。有意な格子点の表示に使う。
大事な見方
今回のコードは「データを読む部分」「回帰式を計算する部分」「地図を描く部分」に分けて読むと理解しやすい。
STEP 1

5. データの読み込みと期間の指定

import numpy as np
import xarray as xr
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
import cartopy.crs as ccrs
import cartopy.feature as cfeature

ncfile = "sst.mnmean.nc"
ds = xr.open_dataset(______)

sst = ds["sst"].values.astype(float)
lat = ds["lat"].values
lon = ds["lon"].values
time = pd.to_datetime(ds["time"].values)

sst[sst < -100] = np.nan

start_year = ______
end_year = ______

mask_period = (time.year >= start_year) & (time.year <= end_year)
sst_sub = sst[mask_period, :, :]
time_sub = time[mask_period]

ポイント

このブロックで新しく出るもの

  • xarray:多次元配列データを扱うライブラリ
  • values:xarray の中身を NumPy 配列として取り出す
  • astype(float):計算しやすい型にそろえる
  • mask_period:期間選択のための真偽値配列
STEP 2

6. 年の小数への変換

回帰では時間を数値として扱いたいので、年月日ではなく「年の小数」に変換する。

years = time_sub.year + (time_sub.month - ______) / 12.0
years = years.to_numpy(dtype=float)

print(years[:5])
print(years[-5:])

ポイント

STEP 3

7. 線形回帰と p 値の計算関数

ここでは、各格子点について x = yeary = SST とし、直線 y = ax + b をあてはめる。傾き a が SST トレンドであり、単位は °C/year である。

線形回帰とp値の概念図
左:線形回帰によるトレンド計算の概念図。右:p 値(有意性)の概念図。

まず、何を検定するのか

この回の検定対象は、相関係数ではなく、回帰直線の傾きである。帰無仮説は次のように置く。

H0: a = 0
H1: a ≠ 0

a = 0 は「時間が進んでも SST が増減しない」、つまりトレンドがないことを意味する。求めた傾きが 0 から十分離れているかを t 検定で調べる。

最小二乗法で傾き â を求める

観測データを (x_i, y_i) とする。ここでは x_i が年、y_i がその格子点の SST である。回帰直線による予測値を

ŷi = â xi + b̂

と書く。観測値と予測値の差を残差という。

ei = yi − ŷi = yi − (â xi + b̂)

最小二乗法では、残差の二乗和

S(â,b̂) = Σ { yi − (â xi + b̂) }²

が最小になるように を決める。

傾き â の式が出てくるまで

まず、S で微分し、最小値の条件として 0 とおく。

∂S/∂b̂ = −2 Σ ( yi − â xi − b̂ ) = 0

したがって、

Σ yi − â Σ xi − n b̂ = 0

両辺を n で割ると、

ȳ − â x̄ − b̂ = 0

なので、切片は

b̂ = ȳ − â x̄

となる。つまり、最小二乗法で求めた回帰直線は、平均点 (x̄, ȳ) を通る。

次に、S で微分して 0 とおく。

∂S/∂â = −2 Σ xi( yi − â xi − b̂ ) = 0

したがって、

Σ xi( yi − â xi − b̂ ) = 0

ここに b̂ = ȳ − â x̄ を代入する。

Σ xi{ yi − â xi − (ȳ − â x̄) } = 0

括弧の中を整理すると、

yi − â xi − ȳ + â x̄ = (yi − ȳ) − â(xi − x̄)

ここで、

Ai = (yi − ȳ) − â(xi − x̄)

と置くと、微分条件は

Σ xiAi = 0

である。一方、 で微分した条件から、残差の和は 0 なので、

Σ Ai = 0

も成り立つ。したがって、

Σ(xi − x̄)Ai = ΣxiAi − x̄ΣAi = 0 − x̄×0 = 0

ここが、x_ix_i − x̄ に置き換えてよい理由である。勝手に置き換えているのではなく、ΣA_i = 0 が成り立つため、同じ条件として書ける。

したがって、

Σ(xi − x̄){(yi − ȳ) − â(xi − x̄)} = 0

これを展開すると、

Σ(xi − x̄)(yi − ȳ) − âΣ(xi − x̄)² = 0

よって、

â = Σ(xi − x̄)(yi − ȳ) / Σ(xi − x̄)²

この式の読み方

  • 分子:xy が一緒にどれくらい変動しているか
  • 分母:x 自体がどれくらい広がっているか
  • つまり、傾きは「x が 1 増えたとき、y が平均的にどれくらい変わるか」を表す

傾きの標準誤差 SE(â)

傾き を求めたら、次にその不確かさを見積もる。まず回帰直線から予測値を作る。

ŷi = âxi + b̂

残差の二乗和は、

SSres = Σ(yi − ŷi

である。傾きと切片の 2 つをデータから推定しているため、自由度は

df = n − 2

となる。残差分散は、

s² = SSres / (n − 2)

であり、傾きの標準誤差は、

SE(â) = √{ s² / Σ(xi − x̄)² }

である。点が直線のまわりに大きく散らばっているほど SSres が大きくなり、SE(â) も大きくなる。

傾きの t 検定

最後に、傾きが 0 からどれだけ離れているかを、傾きの不確かさで割って t 値を作る。

t = â / SE(â)

この式は、傾き を求める式ではない。傾きはすでに最小二乗法で求めてあり、その傾きが 0 からどれだけはっきり離れているかを調べる式である。

p = 2 × {1 − Ft(|t|; df)}

Pythonコードとの対応

def calc_trend_stats(x, y):
    x = np.asarray(x, dtype=float)
    y = np.asarray(y, dtype=float)

    mask = np.isfinite(x) & np.isfinite(y)
    x = x[mask]
    y = y[mask]

    n = len(x)
    if n < 3:
        return np.nan, np.nan, np.nan, np.nan, np.nan

    # 最小二乗法で y = a*x + b を求める
    a, b = np.polyfit(x, y, 1)

    # 回帰直線による予測値
    y_pred = a * x + b

    # 残差平方和と全平方和
    ss_res = np.sum((y - y_pred) ** 2)
    ss_tot = np.sum((y - np.mean(y)) ** 2)
    r2 = np.nan if ss_tot == 0 else 1 - ss_res / ss_tot

    # x と y の相関係数
    r = np.corrcoef(x, y)[0, 1]

    # 傾き a の標準誤差と t 検定
    dof = n - 2
    sxx = np.sum((x - np.mean(x)) ** 2)
    mse = ss_res / dof
    se_a = np.sqrt(mse / sxx)
    tval = a / se_a
    pval = 2 * (1 - stats.t.cdf(np.abs(tval), df=dof))

    return r, a, b, r2, pval

ポイント

重要
07 では相関係数 r の検定を行った。09 では傾き a の検定を行う。
どちらも t 分布を使うが、「何を 0 と比べているか」が違う。
STEP 4

8. 各格子点でのトレンド計算

nlat = len(lat)
nlon = len(lon)

trend = np.full((nlat, nlon), np.nan)
pval = np.full((nlat, nlon), np.nan)
rmap = np.full((nlat, nlon), np.nan)
r2map = np.full((nlat, nlon), np.nan)

min_count = ______

for i in range(nlat):
    for j in range(nlon):
        y = sst_sub[:, i, j]
        x = years

        mask = np.isfinite(y)
        if np.sum(mask) < min_count:
            continue

        r, a, b, r2, p = calc_trend_stats(x[mask], y[mask])

        trend[i, j] = ______
        pval[i, j] = ______
        rmap[i, j] = ______
        r2map[i, j] = ______

alpha = 0.05
sig_mask = pval < alpha

ポイント

確認
  • trend[i, j] は何を意味するか
  • sig_mask[i, j] が True のとき、何が言えるか
STEP 5

9. トレンドマップの描画

fig = plt.figure(figsize=(14, 6))
ax = fig.add_subplot(1, 1, 1,
                     projection=ccrs.PlateCarree(central_longitude=______))

pcm = ax.pcolormesh(
    lon, lat, trend,
    transform=ccrs.PlateCarree(),
    shading="auto",
    cmap="coolwarm",
    vmin=-0.10, vmax=0.10
)

ax.add_feature(cfeature.LAND, facecolor="0.85", edgecolor="none", zorder=3)
ax.coastlines(linewidth=0.8, zorder=4)

gl = ax.gridlines(draw_labels=True, linewidth=0.5, color="0.7", alpha=0.8)
gl.top_labels = False
gl.right_labels = False

cbar = fig.colorbar(pcm, ax=ax, pad=0.02, shrink=0.9)
cbar.set_label("Trend (°C/year)")

ax.set_title(f"Global SST Trend (ERSSTv5, {start_year}-{end_year})")

fig.tight_layout()
fig.canvas.draw()
fig.savefig("sst_trend.png", dpi=300, bbox_inches="tight", facecolor="white")
plt.show()

ポイント

STEP 6

10. p 値と有意マスクの表示

有意な格子点だけを黒点で重ねて表示する。

yy, xx = np.where(sig_mask)

ax.scatter(
    lon[xx], lat[yy],
    s=2, c="k", marker="o",
    transform=ccrs.PlateCarree(),
    zorder=5, alpha=_____
)

ポイント

11. p 値とは何か

今回の検定では、各格子点について次の帰無仮説を考えている。

H0: 傾き a = 0

つまり、「その格子点では長期トレンドがない」と仮定する。この仮定のもとで、実際に得られた傾きと同じくらい、またはそれ以上に 0 から離れた傾きが偶然に出る確率が p 値である。

見ている内容
傾き a何 ℃/年で増減しているか。変化量の大きさ。
標準誤差 SE(a)傾きの不確かさ。点が直線から散らばるほど大きくなる。
t 値傾きが、その不確かさの何倍だけ 0 から離れているか。
p 値傾き 0 という仮定のもとで、今回のような t 値が偶然に出る確率。

たとえば alpha = 0.05 としたとき、p < 0.05 なら「5%水準で有意」と呼ぶことが多い。

重要
p 値は「トレンドが存在する確率」ではない。また、「p 値が小さいほど変化量が大きい」という意味でもない。
変化量の大きさは 傾き a を見て判断し、統計的に 0 と区別できるかは p 値 を見て判断する。

12. 議論:SST変動は何で説明できるか

ここまでで、各格子点における海面水温(SST)の長期トレンドを求め、 全球平均が上昇していても、その増減の大きさや有意性は空間的に一様ではないことを確認した。 では、この空間分布の違いは何によって説明できるのだろうか。

ここで考えたい問い
なぜ、ある海域では SST が大きく変化し、別の海域ではあまり変化しないのか。

短期変動としての ENSO

前回の ENSO コンポジット解析では、El Niño と La Niña のときに 海面水温の分布がどのように変わるかを調べた。 これは、SST の年々変動、つまり比較的短い時間スケールの変動を見ている。

たとえば赤道太平洋では ENSO の影響が強く、ある年には暖かく、 別の年には冷たくなる。このような場所では、SST の分布を理解するうえで ENSO が重要な説明要因になる。

長期変化としてのトレンド

一方、このページで見ているトレンドは、数十年スケールで SST がどの方向に変化してきたかを表している。 これは、温暖化や海洋循環の長期変化など、 長い時間スケールの変動を反映している可能性がある。

したがって、トレンドマップは「この海域では長い目で見ると暖まりやすいのか、 それともあまり変化していないのか」を示している。

ENSOだけで説明できるか

ENSO コンポジットで大きな差が見られる海域では、 年々変動のかなりの部分を ENSO が説明していると考えられる。 しかし、ENSO の影響が弱い海域でも長期トレンドが強く現れることがある。 その場合、SST の変化は ENSO だけでは説明できない。

トレンドだけで説明できるか

逆に、長期トレンドが見られても、年ごとのばらつきが非常に大きい海域では、 実際の SST 変動をトレンドだけで説明することは難しい。 たとえば ENSO のような大きな年々変動が重なると、 長期的には上昇傾向でも、ある年には一時的に低温になることがある。

重要
ENSO は主に短期(年々)変動を説明し、 トレンドは主に長期変化を説明する。
つまり、海面水温の空間分布や時間変動を理解するには、 どちらか一方だけでなく、両方を合わせて考える必要がある。

この回で得たい理解

考えてみよう
  • ENSO コンポジットで差が大きかった海域は、トレンドマップではどうなっているか。
  • トレンドが大きいのに、ENSO の影響が強くなさそうな海域はどこか。
  • ENSO とトレンドのどちらでも説明しきれない海域はありそうか。

このように、SST の変動を理解するには、 短期変動(ENSO)長期変化(トレンド)を分けて考えることが重要である。 今後はさらに、海面高度や流れの情報も使いながら、 「なぜその場所で変化が起きるのか」を物理的に考えていく。

13. まとめ

前回までの散布図の回帰を「点」で理解したなら、今回は同じ計算を「面」に拡張したと考えるとわかりやすい。

14. 解答の表示

まずは上の穴埋めコードを自分で考えること。確認したい場合だけ、パスワードを入力して解答と完全スクリプトを表示する。