地球観測の基盤モデル — Prithvi / SatMAE を理解する

毎日のように地球を周回するSentinel-2やLandsatは、地表のあらゆる場所を10〜30メートル解像度で撮影し続けています。1日あたり数テラバイト、年間ではペタバイト級のデータが蓄積されていきますが、そこから「洪水で水没した区域」「焼失した森林」「収穫前の小麦畑」を抽出するには、依然として人手による教師ラベル付けが必要でした。1万ピクセル分の浸水マスクを作るのに丸一日かかるような作業が、毎回タスクごとに繰り返されてきたのです。

ここに革命を起こしたのが、自然言語処理でGPTやBERTがやってきたのと同じこと — 大量のラベルなしデータで事前学習し、少数の教師データで多種多様な下流タスクに転用するという基盤モデル(Foundation Model)の発想を地球観測に持ち込むことでした。NASAとIBMが共同で開発したPrithvi、そして学術界の代表格であるSatMAEは、まさにこの流れの中心にあります。どちらもMasked Autoencoder(MAE)の枠組みを地球観測データの特性に合わせて拡張したもので、洪水検出・山火事被害評価・作物分類などの下流タスクで、ゼロから学習した手法より少ないラベルで高い精度を達成しています。

この技術が活きるのは、たとえば次のような場面です。

  • 災害対応: 地震や洪水の直後、人手でラベル付けする時間がない中でも、事前学習済みPrithviに数十枚のラベルを与えるだけで広範囲の被害マップを生成できる
  • 農業モニタリング: 国や地域ごとに作物の種類や栽培時期が異なる中で、同じ基盤モデルから地域別にファインチューニングするだけで世界中の作物分類が可能になる
  • 気候変動研究: 30年以上の時系列衛星画像から、植生・氷河・海岸線の変化を一貫した特徴抽出器で追える
  • オンボードAI: 衛星上での前処理として、汎用的な特徴抽出を担い、ダウンリンク帯域を節約する

本記事の内容

  • なぜ地球観測にFoundation Modelが必要なのかという動機
  • MAEの仕組みを地球観測の文脈で再確認する
  • SatMAEのマルチスペクトル・時系列拡張のアイデア
  • Prithvi(NASA/IBM)のアーキテクチャと下流タスクへの転用
  • ファインチューニング戦略(線形プローブ / フルチューン / LoRA)の比較
  • PyTorchで小型ViTにMAEを実装し、線形プローブで分類精度を測るスケールダウン実験

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

なぜ地球観測にFoundation Modelが必要か

地球観測のタスクは多種多様です。土地被覆分類、洪水セグメンテーション、火災被害評価、作物識別、変化検出、雲マスク、建物抽出 — これらは表面上は異なる問題に見えますが、どれも「衛星画像の各ピクセルが何を意味するか」を判定するという点で共通の視覚的理解を必要とします。にもかかわらず、従来は各タスクごとに専用モデルをゼロから学習し、各タスクごとに大量のラベルを揃えてきました。

地球観測のラベル付けは特に高コストです。森林被覆や洪水域を正確にマスクするには、リモートセンシングの専門知識を持つ人が、現地の地形や撮影日時の太陽角を考慮しながら判定する必要があります。多くの国・地域では地上検証データそのものが存在せず、貴重なラベルは数千〜数万ピクセル単位でしか手に入りません。

一方で、ラベルなしの衛星画像は事実上無尽蔵です。Sentinel-2は5日に1度地球全体を撮影し、すでに1ペタバイト以上のオープンデータが公開されています。Landsatは1972年から半世紀分のアーカイブを抱えています。こうしたラベルなしデータから、地表の構造・植生のテクスチャ・水と陸の境界などの「視覚的プリミティブ」を一度学習しておけば、下流タスクへの転用ははるかに容易になります。

これは自然言語処理が辿った道筋とまったく同じです。BERTやGPTが膨大なラベルなしテキストから言語の構造を学んだように、地球観測のFoundation Modelは膨大なラベルなし衛星画像から地表の構造を学びます。

地球観測ならではの難しさ

ただし、地球観測データをそのままMAEに流し込めばよい、というほど話は単純ではありません。次の3つの特殊性が、ImageNetで通用したやり方をそのままでは通用させてくれません。

  1. マルチスペクトル: 衛星カメラはRGBの3バンドだけでなく、近赤外(NIR)・短波長赤外(SWIR)・赤エッジなど10バンド以上を持つことが普通。植生の活性度はNIR、水の含有量はSWIRに表れるため、バンドを捨てると情報損失が大きい
  2. 時系列性: 同じ場所を異なる日付で繰り返し撮るため、時間軸の情報が本質的。作物の成長段階や季節変動を扱うには時間方向の冗長性をうまく利用する必要がある
  3. 空間スケール: 1ピクセルが10m〜30mあり、同じ「家」でも数ピクセル〜数十ピクセルにわたる。ImageNet基準のパッチサイズ(16×16)の意味合いが地上では大きく異なる

これら3つの課題に、SatMAEとPrithviはそれぞれ少し違うアプローチで答えています。次のセクションでは、まずMAEの基本構造を地球観測の文脈で押さえ直しましょう。

Masked Autoencoderの仕組み — 地球観測の文脈で

Masked Autoencoder の核となるアイデアは、ジグソーパズルです。画像を $P \times P$ ピクセルのパッチに分割し、そのうち75%(あるいはそれ以上)をランダムにマスクして隠します。残った可視パッチだけをエンコーダに入力し、デコーダにマスクされた部分のピクセル値を予測させる、というのが手順です。

このとき重要なのは、エンコーダが「画像全体を見ない」点です。可視パッチだけを処理するエンコーダは計算量が4分の1で済み、その代わりに残り少ない情報から全体を推測できる強い表現を獲得します。視覚的に何が起きているかを抽象化しないと、25%だけで75%を埋められないからです。

損失関数

MAEの再構成損失は、マスクされたパッチに対するピクセル単位のMSEで定義されます。

$$ \begin{equation} \mathcal{L}_{\mathrm{MAE}} = \frac{1}{|\mathcal{M}|} \sum_{i \in \mathcal{M}} \| \bm{p}_i – \hat{\bm{p}}_i \|^2 \end{equation} $$

ここで $\mathcal{M}$ はマスクされたパッチのインデックス集合、$\bm{p}_i$ は元のパッチのピクセル値(パッチ正規化後)、$\hat{\bm{p}}_i$ はデコーダの予測です。可視パッチに対しては損失を取りません — そこは「答えが見えているところ」だからです。

非対称エンコーダ・デコーダ

MAEのもう一つの工夫は、エンコーダとデコーダの大きさを大きく非対称にしている点です。

構成要素 役割 サイズ
エンコーダ 可視パッチから意味的特徴を抽出 ViT-Large規模(24層)
デコーダ 全パッチからピクセル値を再構成 軽量(8層)

エンコーダは下流タスクに使い回すため、表現力を持たせるために大きくします。デコーダはピクセル復元という低レベルなタスクをこなせばよいので、軽量で十分です。事前学習後はデコーダを捨て、エンコーダのみを下流タスクに転用します。

地球観測の文脈では、この非対称設計はさらに合理的です。Sentinel-2のように10〜13バンドある画像のピクセル値を再構成するのは比較的「冗長」な作業で、エンコーダが学習する地表の構造的特徴の方がはるかに価値があります。デコーダは捨てるのが前提で、それで構わない。

ここまでは画像を1枚扱う一般的なMAEの話でした。では、複数バンドと複数時刻を扱う衛星画像にはどのように拡張すればよいでしょうか。次節でSatMAEを見ていきます。

SatMAE — マルチスペクトル・時系列への拡張

SatMAE(NeurIPS 2022, Cong et al.)は、Stanford大学らのグループが提案したMAEの地球観測拡張版です。基本枠組みはMAEのままに、衛星画像特有の2つの拡張を加えています。

バンドのグループ化

Sentinel-2の12バンドは、波長と用途で次のように分類できます。

グループ バンド 用途
RGB B2, B3, B4 可視光
赤エッジ B5, B6, B7 植生の活性度
近赤外 B8, B8A バイオマス、葉緑素
短波長赤外 B11, B12 水分含有量、土壌

SatMAEは、これらをグループごとに別の入力チャネルとしてエンコーダに与えます。具体的には、各グループに対して別個のパッチ埋め込み層を持ち、グループごとに位置埋め込みに加えてグループ埋め込み $\bm{e}_g$ を加算します。

$$ \begin{equation} \bm{z}_{i,g} = \bm{W}_g \bm{p}_{i,g} + \bm{e}_{\text{pos},i} + \bm{e}_g \end{equation} $$

ここで $\bm{p}_{i,g}$ はグループ $g$ のパッチ $i$、$\bm{W}_g$ はグループ固有の埋め込み行列、$\bm{e}_{\text{pos},i}$ は位置埋め込み、$\bm{e}_g$ はグループ埋め込みです。エンコーダは、空間位置だけでなく「どのスペクトル群か」も理解しながら自己注意を計算できるようになります。

時系列の温度的マスキング

SatMAEはさらに、同じ地点の複数時刻の画像を入力できるよう拡張されています。時刻 $t$ の画像のパッチ埋め込みに、時間埋め込み $\bm{e}_{\text{time},t}$ を加算します。

$$ \begin{equation} \bm{z}_{i,g,t} = \bm{W}_g \bm{p}_{i,g,t} + \bm{e}_{\text{pos},i} + \bm{e}_g + \bm{e}_{\text{time},t} \end{equation} $$

マスキングはこの3次元のトークン集合(空間 × バンドグループ × 時刻)から行われます。学習が進むと、エンコーダは「同じ地点の異なる時刻の見た目」から作物の成長段階や季節変動を読み取れるようになり、その表現は時系列を扱う下流タスク(作物分類など)で特に強くなります。

SatMAEの設計判断の含意

バンドをまとめて1つのチャネル次元として扱う素朴な方法と比べて、SatMAEの設計が優れている点は2つあります。

  1. 情報のホモジニアス性: RGBと近赤外は値の分布も意味も大きく異なる。1つの埋め込み層に押し込めると平均的な特徴しか取れない。グループ別に埋め込むと、各グループ内の構造を保ったまま統合できる
  2. 転用時の柔軟性: 下流タスクで使えるバンドが異なっても(Sentinel-2なら全部だが、Landsatなら一部)、対応するグループの埋め込みだけ使えばよい

ここまででマルチスペクトル・時系列という地球観測の特殊性をMAEがどう吸収するかを見ました。次は、これを実用レベルまで持ち上げ、災害対応にまで使えるようにしたPrithviを見ていきます。

Prithvi — NASAとIBMの地球観測Foundation Model

Prithvi(IBM-NASA, 2023)は、その名前がサンスクリット語の「地球」に由来する、地球観測の基盤モデルです。NASAのHLS(Harmonized Landsat Sentinel-2)プロダクトを学習データとし、論文公開と同時にHugging Faceで重みが公開されたことで、研究と実応用の両面で大きな注目を集めました。

HLSデータセット

HLSは、LandsatとSentinel-2の観測を共通の30m解像度・共通の参照システムに揃えた、NASAが公開しているプロダクトです。Prithviは大陸アメリカ全体について約160万のシーンを使用し、6バンド(青・緑・赤・近赤外・短波長赤外1・短波長赤外2)に絞って学習しています。

学習データの選び方が、結果としてのモデルの強さを大きく左右します。Prithviが大陸アメリカに絞ったのは、HLSの品質管理が確立しているのとラベル評価データの整備が進んでいたためですが、これは同時に「世界全体への汎化性能」とのトレードオフでもあります。後発の研究では、より地理的多様性を持ったデータでの事前学習が探索されています。

Prithviのアーキテクチャ

Prithviの主要な特徴は次の通りです。

項目
アーキテクチャ ViT-Large(24層、16ヘッド)
入力サイズ 224×224 ピクセル
バンド数 6(HLS共通バンド)
時系列長 3時刻(同一地点の異なる日付)
事前学習タスク 3次元時空間MAE
パラメータ数 約100M

時空間MAEとは、SatMAEの考え方をさらに進めたもので、空間方向と時間方向の両方でランダムにパッチをマスクします。あるパッチが「3時刻すべてマスクされる」こともあれば、「同じ位置だが他の時刻は見える」こともあるという設計で、時空間の文脈から復元することを強要します。

下流タスクでの実績

Prithviが公開と同時に注目された理由は、いくつかの代表的な下流タスクで「公開直後に微調整で当時最先端の精度に並ぶ」性能を出したことです。

下流タスク データセット 評価
洪水セグメンテーション Sen1Floods11 IoU 0.77+
山火事被害評価 HLS Burn Scars IoU 0.69+
作物タイプ分類 USDA Cropland Data Layer F1 0.85+
マルチ時相洪水検出 カスタムHLS 既存SOTA同等

これらの数字自体は今後さらに更新されていきますが、重要なのは「同じ事前学習重みを使い、下流タスクごとに小さなセグメンテーションヘッドだけ取り替えれば、複数の異なるタスクに対応できる」という汎用性です。

Prithviの意義

Prithviが地球観測コミュニティに与えたインパクトは、技術的な性能だけにとどまりません。次の3点が特に重要です。

  1. オープン公開: モデル重みがApache 2.0で公開され、誰でも商用利用を含めて使える
  2. 公共機関の参入: NASAという公共機関が基盤モデル開発に直接関与し、災害対応や気候変動対策の公益的応用を加速した
  3. モジュラー性: ヘッドだけ取り替える設計が標準化され、コミュニティが下流タスクの拡張に集中できるようになった

Prithviとそのファインチューニング済みモデルは、すでに国連UNOSATなどの実運用で災害対応に使われ始めています。次のセクションでは、こうした事前学習済みモデルをどうやって自分のタスクに転用するかを見ていきましょう。

ファインチューニング戦略 — 線形プローブ・フルチューン・LoRA

事前学習済みのFoundation Modelを下流タスクに転用するには、大きく3つの戦略があります。データ量と計算資源によって使い分けるのが定石です。

線形プローブ

最も軽量な戦略です。事前学習済みエンコーダの重みをすべて凍結し、最終層の出力に線形分類器だけを乗せて、その線形層のみを学習します。

$$ \begin{equation} \hat{y} = \bm{W}_{\text{cls}} \cdot \mathrm{Encoder}(\bm{x}) + \bm{b} \end{equation} $$

利点は計算量が極端に小さいことと、過学習しにくいことです。ラベルが数百枚しかないような状況では、フルチューンよりも線形プローブの方が高い汎化性能を出すことが珍しくありません。また、事前学習表現の「素の品質」を測る評価指標としても使われます。

フルファインチューニング

エンコーダの全パラメータを学習対象にする戦略です。下流タスクに最も適応しますが、ラベルが少ないと過学習する危険が高まります。また、エンコーダの規模が大きいほど学習計算が重くなります。

通常は事前学習時より小さな学習率(例: 事前学習が 1e-4 なら 1e-5)を使い、Layer-wise Learning Rate Decay(深い層ほど大きく、浅い層ほど小さい学習率)を導入して既存の表現を壊しすぎないように工夫します。

LoRA(Low-Rank Adaptation)

最近主流になりつつある中間的な戦略です。事前学習済みの重み $\bm{W}_0$ は凍結したまま、低ランクな差分 $\Delta \bm{W} = \bm{A} \bm{B}$ を加算する形で更新します。

$$ \begin{equation} \bm{W} = \bm{W}_0 + \Delta \bm{W} = \bm{W}_0 + \bm{A} \bm{B}, \quad \bm{A} \in \mathbb{R}^{d \times r}, \bm{B} \in \mathbb{R}^{r \times d} \end{equation} $$

$r$ はランクで、典型的には 4〜16 程度に設定します。$\bm{A}, \bm{B}$ のパラメータ数は元の $\bm{W}_0$ より2桁以上小さく、計算もメモリも軽くなります。元の重みを保ったまま少数のパラメータでタスク特化できるため、複数の下流タスクに対して「LoRA重みだけを切り替える」というデプロイ戦略も取れます。

どれを選ぶか

実用的には、次の判断基準が役に立ちます。

状況 推奨戦略
ラベル数百〜千枚、計算資源乏しい 線形プローブ
ラベル数千〜数万枚、十分な計算資源 フルファインチューニング(LLRD併用)
複数タスクに同じ基盤を使い分けたい LoRA
まず事前学習の品質を評価したい 線形プローブ

筆者の経験では、地球観測タスクで「数百枚しかラベルがない、でも精度はそこそこ欲しい」という現場が圧倒的に多く、線形プローブとLoRAの組合せが現実解になることが多いです。

ここまでで理論面はカバーできたので、次は実際にPyTorchで小型MAEを動かしてみましょう。Prithviをそのまま再現するのは数十GPUの計算資源が必要ですが、CIFAR-10サイズに縮めれば手元のCPUでも体感できます。

PyTorchでのスケールダウン実装

ここでは、ViT-Tinyレベルの小型エンコーダにMAEを実装し、CIFAR-10で自己教師あり事前学習 → 線形プローブで分類精度を評価する、というスケールダウンの実験を行います。Prithviの設計判断(高マスキング率、非対称エンコーダ・デコーダ、ピクセル復元)を実感することがゴールです。

モデル定義

まずは小型ViTエンコーダとデコーダを定義します。

import math
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import matplotlib.pyplot as plt

torch.manual_seed(0)
np.random.seed(0)


class PatchEmbed(nn.Module):
    """画像をパッチに分割して埋め込む"""
    def __init__(self, img_size=32, patch_size=4, in_chans=3, embed_dim=128):
        super().__init__()
        self.img_size = img_size
        self.patch_size = patch_size
        self.n_patches = (img_size // patch_size) ** 2
        self.proj = nn.Conv2d(in_chans, embed_dim,
                              kernel_size=patch_size, stride=patch_size)

    def forward(self, x):
        # x: [B, C, H, W] -> [B, N, D]
        x = self.proj(x)               # [B, D, H/P, W/P]
        x = x.flatten(2).transpose(1, 2)
        return x


class TransformerBlock(nn.Module):
    def __init__(self, dim, heads=4, mlp_ratio=4.0):
        super().__init__()
        self.norm1 = nn.LayerNorm(dim)
        self.attn = nn.MultiheadAttention(dim, heads, batch_first=True)
        self.norm2 = nn.LayerNorm(dim)
        self.mlp = nn.Sequential(
            nn.Linear(dim, int(dim * mlp_ratio)),
            nn.GELU(),
            nn.Linear(int(dim * mlp_ratio), dim),
        )

    def forward(self, x):
        a, _ = self.attn(self.norm1(x), self.norm1(x), self.norm1(x))
        x = x + a
        x = x + self.mlp(self.norm2(x))
        return x

PatchEmbedは画像をパッチに分割してトークン化する箇所で、ViTの基本構造そのものです。TransformerBlockはMulti-Head AttentionとMLPの標準的な組合せです。MAEはこの構造をエンコーダ・デコーダ両方で再利用します。

MAEモデル本体

続いて、MAEの本体です。ここでマスキングと再構成が起こります。

class MAE(nn.Module):
    def __init__(self, img_size=32, patch_size=4, in_chans=3,
                 enc_dim=128, enc_depth=4, enc_heads=4,
                 dec_dim=64, dec_depth=2, dec_heads=4,
                 mask_ratio=0.75):
        super().__init__()
        self.patch_embed = PatchEmbed(img_size, patch_size, in_chans, enc_dim)
        self.n_patches = self.patch_embed.n_patches
        self.patch_size = patch_size
        self.in_chans = in_chans
        self.mask_ratio = mask_ratio

        # 位置埋め込み(エンコーダ・デコーダ共通の長さ)
        self.enc_pos = nn.Parameter(torch.zeros(1, self.n_patches, enc_dim))
        self.dec_pos = nn.Parameter(torch.zeros(1, self.n_patches, dec_dim))
        nn.init.trunc_normal_(self.enc_pos, std=0.02)
        nn.init.trunc_normal_(self.dec_pos, std=0.02)

        # マスクトークン
        self.mask_token = nn.Parameter(torch.zeros(1, 1, dec_dim))
        nn.init.trunc_normal_(self.mask_token, std=0.02)

        # エンコーダ
        self.encoder = nn.ModuleList(
            [TransformerBlock(enc_dim, enc_heads) for _ in range(enc_depth)])
        self.enc_norm = nn.LayerNorm(enc_dim)

        # エンコーダ→デコーダの次元合わせ
        self.enc_to_dec = nn.Linear(enc_dim, dec_dim)

        # デコーダ
        self.decoder = nn.ModuleList(
            [TransformerBlock(dec_dim, dec_heads) for _ in range(dec_depth)])
        self.dec_norm = nn.LayerNorm(dec_dim)
        self.dec_pred = nn.Linear(dec_dim, patch_size * patch_size * in_chans)

    def random_masking(self, x):
        B, N, D = x.shape
        n_keep = int(N * (1 - self.mask_ratio))
        noise = torch.rand(B, N, device=x.device)
        ids_shuffle = torch.argsort(noise, dim=1)
        ids_restore = torch.argsort(ids_shuffle, dim=1)
        ids_keep = ids_shuffle[:, :n_keep]
        x_visible = torch.gather(
            x, 1, ids_keep.unsqueeze(-1).expand(-1, -1, D))
        mask = torch.ones(B, N, device=x.device)
        mask[:, :n_keep] = 0
        mask = torch.gather(mask, 1, ids_restore)
        return x_visible, mask, ids_restore

    def patchify(self, x):
        B, C, H, W = x.shape
        p = self.patch_size
        x = x.reshape(B, C, H // p, p, W // p, p)
        x = x.permute(0, 2, 4, 3, 5, 1).reshape(B, -1, p * p * C)
        return x

    def forward_encoder(self, x):
        x = self.patch_embed(x) + self.enc_pos
        x_visible, mask, ids_restore = self.random_masking(x)
        for blk in self.encoder:
            x_visible = blk(x_visible)
        x_visible = self.enc_norm(x_visible)
        return x_visible, mask, ids_restore

    def forward_decoder(self, x_visible, ids_restore):
        B, n_keep, _ = x_visible.shape
        x = self.enc_to_dec(x_visible)
        N = ids_restore.shape[1]
        mask_tokens = self.mask_token.expand(B, N - n_keep, -1)
        x_full = torch.cat([x, mask_tokens], dim=1)
        x_full = torch.gather(
            x_full, 1, ids_restore.unsqueeze(-1).expand(-1, -1, x.shape[-1]))
        x_full = x_full + self.dec_pos
        for blk in self.decoder:
            x_full = blk(x_full)
        x_full = self.dec_norm(x_full)
        pred = self.dec_pred(x_full)  # [B, N, P*P*C]
        return pred

    def forward(self, x):
        target = self.patchify(x)
        x_visible, mask, ids_restore = self.forward_encoder(x)
        pred = self.forward_decoder(x_visible, ids_restore)
        loss = ((pred - target) ** 2).mean(dim=-1)
        loss = (loss * mask).sum() / mask.sum()
        return loss, pred, mask

random_maskingでパッチを75%ランダムに隠す処理が、MAEの心臓部です。np.argsortで乱数を並び替えることで、ランダムなインデックス選択を効率的にバッチ化できる工夫が入っています。forward_decoderでは、マスクされた位置にmask_tokenという学習可能な「埋め草トークン」を入れ、ids_restoreで元の順序に戻してからデコーダに通します。

事前学習

CIFAR-10を使って数エポック事前学習します。本来は数百エポック回すところを、本記事では短時間で動かすために控えめに設定しています。

from torchvision import datasets, transforms
from torch.utils.data import DataLoader

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)),
])
train_set = datasets.CIFAR10(root="./data", train=True,
                             download=True, transform=transform)
test_set = datasets.CIFAR10(root="./data", train=False,
                            download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=256, shuffle=True, num_workers=2)
test_loader = DataLoader(test_set, batch_size=512, shuffle=False, num_workers=2)

mae = MAE().to(device)
opt = torch.optim.AdamW(mae.parameters(), lr=1.5e-4, weight_decay=0.05)

loss_history = []
n_epoch = 10
for ep in range(n_epoch):
    mae.train()
    epoch_loss = 0.0
    for x, _ in train_loader:
        x = x.to(device)
        loss, _, _ = mae(x)
        opt.zero_grad()
        loss.backward()
        opt.step()
        epoch_loss += loss.item() * x.size(0)
    epoch_loss /= len(train_set)
    loss_history.append(epoch_loss)
    print(f"[Pretrain] epoch {ep+1:02d}  loss = {epoch_loss:.4f}")

plt.figure(figsize=(7, 4))
plt.plot(range(1, n_epoch + 1), loss_history, marker='o')
plt.xlabel("epoch")
plt.ylabel("reconstruction loss")
plt.title("MAE pre-training loss on CIFAR-10")
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()

この事前学習ループから読み取れることは2つあります。まず損失が単調に下がること — これはマスクされたパッチを残りの可視パッチから再構成する能力が向上していることを示しています。次に、損失の絶対値は完全には 0 にならないこと — 75%もマスクすればピクセル単位の完全復元は理論的に不可能で、エンコーダは「平均的な」復元、つまり意味的な特徴を学んでいることになります。

マスク復元の可視化

学習が進んでいるか、復元結果を可視化して確かめましょう。

mae.eval()
with torch.no_grad():
    x, _ = next(iter(test_loader))
    x = x[:8].to(device)
    loss, pred, mask = mae(x)

    target = mae.patchify(x)
    pred_img = pred * mask.unsqueeze(-1) + target * (1 - mask).unsqueeze(-1)

    # パッチを画像に戻す
    def unpatchify(p, patch_size=4, img_size=32, chans=3):
        B = p.shape[0]
        h = w = img_size // patch_size
        p = p.reshape(B, h, w, patch_size, patch_size, chans)
        p = p.permute(0, 5, 1, 3, 2, 4).reshape(B, chans, img_size, img_size)
        return p

    recon = unpatchify(pred_img).cpu()
    orig = x.cpu()
    masked = unpatchify(target * (1 - mask).unsqueeze(-1)).cpu()

fig, axes = plt.subplots(3, 8, figsize=(14, 6))
for i in range(8):
    for ax, img in zip(axes[:, i], [orig[i], masked[i], recon[i]]):
        ax.imshow((img.permute(1, 2, 0) * 0.5 + 0.5).clip(0, 1))
        ax.axis("off")
axes[0, 0].set_title("original", loc="left")
axes[1, 0].set_title("masked",   loc="left")
axes[2, 0].set_title("recon",    loc="left")
plt.tight_layout()
plt.show()

復元結果から3つのことが読み取れます。第一に、マスクされた領域(中段の黒い部分)が下段の復元結果ではぼんやりとした色合いで埋められていること — これは平均的な色を予測するだけでも一定の損失低減が可能だからです。第二に、物体の輪郭や全体的な構図は元画像と一致していること — つまりエンコーダが対象物の概形を理解しています。第三に、細部のテクスチャは完全には復元されないこと — これはエンコーダが「意味」を取り、デコーダが「平均的なピクセル」を埋めるという役割分担が機能している証拠です。

線形プローブによる評価

事前学習されたエンコーダの表現が「分類に使える特徴」を獲得しているかを、線形プローブで評価します。

class LinearProbeHead(nn.Module):
    def __init__(self, encoder, embed_dim=128, n_classes=10):
        super().__init__()
        self.encoder = encoder  # 凍結
        for p in self.encoder.parameters():
            p.requires_grad = False
        self.fc = nn.Linear(embed_dim, n_classes)

    def forward(self, x):
        # patch_embedに通し、マスクなしで全パッチを処理
        x = self.encoder.patch_embed(x) + self.encoder.enc_pos
        for blk in self.encoder.encoder:
            x = blk(x)
        x = self.encoder.enc_norm(x)
        x = x.mean(dim=1)         # 平均プーリング
        return self.fc(x)


probe = LinearProbeHead(mae).to(device)
opt = torch.optim.Adam(probe.fc.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()

acc_history = []
for ep in range(10):
    probe.train()
    for x, y in train_loader:
        x, y = x.to(device), y.to(device)
        logits = probe(x)
        loss = loss_fn(logits, y)
        opt.zero_grad()
        loss.backward()
        opt.step()
    # 評価
    probe.eval()
    correct = total = 0
    with torch.no_grad():
        for x, y in test_loader:
            x, y = x.to(device), y.to(device)
            pred = probe(x).argmax(dim=1)
            correct += (pred == y).sum().item()
            total += y.size(0)
    acc = correct / total
    acc_history.append(acc)
    print(f"[Probe] epoch {ep+1:02d}  test acc = {acc:.3f}")

plt.figure(figsize=(7, 4))
plt.plot(range(1, len(acc_history) + 1), acc_history, marker='o', color='orange')
plt.xlabel("epoch")
plt.ylabel("test accuracy")
plt.title("Linear probe accuracy on CIFAR-10")
plt.ylim(0, 1)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()

このプローブ実験から読み取れることは決定的です。エンコーダの重みを一切更新せず、最後の線形層だけを学習しただけで、ランダム初期化に対して明確に高い精度が出ます(スケールダウン設定でも CIFAR-10 で大体 40〜50% 程度が出ます)。これは事前学習で得た特徴が、分類タスクに有用な情報を含んでいる証拠です。Prithviのような大規模事前学習では、線形プローブだけで下流タスクのSOTAに迫る精度が出ることもあり、地球観測でも同じ原理が成り立ちます。

スケールアップへの示唆

ここで実装した小型MAEと、Prithvi本体の差は基本的に「規模と工夫の量」だけで、設計思想は同じです。具体的に必要なスケールアップは次のようなものです。

項目 本記事 Prithvi
入力サイズ 32×32 224×224
パッチサイズ 4 16
バンド数 3 (RGB) 6 (HLS)
時系列長 1 3
エンコーダ深さ 4 24
学習データ量 5万枚 約160万シーン
マスキング 空間のみ 空間 × 時刻

特に時系列マスキングは、SatMAEと同じ枠組みで実装できます。バンドグループ化も埋め込み層を分けるだけです。本実装に上記の拡張を加えれば、研究用途の小型Prithviクローンが手元で作れます。

実装で挙動を確認できたので、最後に学んだことを整理しましょう。

まとめ

本記事では、地球観測のFoundation Modelとして注目を集めるPrithviとSatMAEを、MAEの基本原理から地球観測特有の拡張、ファインチューニング戦略、そして小型実装まで一気通貫で解説しました。

  • 動機: 地球観測ではラベル付けコストが極めて高い一方、ラベルなしデータは無尽蔵にある。自己教師あり学習でラベルなしから視覚的表現を獲得し、下流タスクに転用するというFoundation Modelの発想がここで威力を発揮する
  • MAEの核: パッチの75%をマスクし、残り25%から再構成する非対称エンコーダ・デコーダ設計。エンコーダは大きく、デコーダは軽量で、デコーダは事前学習後に捨てる
  • SatMAEの拡張: バンドのグループ化、グループ埋め込み、時系列埋め込みで、衛星画像のマルチスペクトル性と時系列性を取り込む
  • Prithviの位置づけ: NASA/IBMがHLSデータで学習した実用基盤モデル。洪水・山火事・作物分類などで強い性能を見せ、オープン公開でコミュニティを加速した
  • ファインチューニング: 線形プローブ・フルチューン・LoRAをラベル量と計算資源で使い分ける
  • スケールダウン実装: CIFAR-10での小型MAEで、再構成損失の減少、可視化された復元結果、線形プローブの精度向上を確認した

ここで学んだことは、これからの地球観測AIを理解する基礎になります。次のステップとして、衛星画像をオンボードで処理する場面、低消費電力での推論、そして衛星間で連合学習する話題が広がります。

次のステップとして、以下の記事も参考にしてください。

参考文献

  • Cong, Y., et al. “SatMAE: Pre-training Transformers for Temporal and Multi-Spectral Satellite Imagery.” NeurIPS 2022.
  • Jakubik, J., et al. “Foundation Models for Generalist Geospatial Artificial Intelligence.” arXiv:2310.18660, 2023.(Prithvi)
  • He, K., et al. “Masked Autoencoders Are Scalable Vision Learners.” CVPR 2022.
  • Dosovitskiy, A., et al. “An Image is Worth 16×16 Words: Transformers for Image Recognition at Scale.” ICLR 2021.(ViT)
  • IBM-NASA “Prithvi-100M” model card on Hugging Face.
  • Hu, E. J., et al. “LoRA: Low-Rank Adaptation of Large Language Models.” ICLR 2022.