TimeSiam — Siameseネットワークで時系列の時間的相関を学習する【ICML 2024】

衛星のテレメトリデータには時間的な規則性があります。温度センサの値は軌道周期(約90分)に従って周期的に変動し、太陽電池の出力は昼と夜で交互に変化します。「今の温度パターン」は「1軌道前の温度パターン」と強く相関しており、この時間的相関を学習できれば、異常検知(過去のパターンからの逸脱)や検索(類似パターンの発見)に直接活用できます。

既存の時系列自己教師あり学習手法(SimCLR型、TS2Vec等)は、ランダムなデータ拡張(ジッタリング、スケーリング、マスキング等)で正例ペアを生成します。しかし、ランダム拡張には根本的な問題があります — 時間構造を壊してしまうのです。時系列を切り出す位置やマスクする位置がランダムだと、上昇トレンドの途中でカットしたり、周期的パターンの位相がずれたりします。拡張後の正例ペアは元のデータの時間的な意味を必ずしも保持していません。

TimeSiam(Tsinghua大学, ICML 2024)は、この問題を根本的に解決するアプローチです。ランダム拡張の代わりに、過去のパッチ(past patch)そのものを「もう一つの視点」として活用します。Siameseネットワーク(双子ネットワーク)で現在のパッチと過去のパッチの関係を学習し、時間的相関を表現空間に直接埋め込みます。

TimeSiamを理解することは、以下のような場面で直接役立ちます。

  • テレメトリ検索の埋め込み品質向上: 類似性学習そのものがアーキテクチャの中核であるため、学習された埋め込みベクトルは時系列の時間的特性を反映し、検索インデックスの品質を直接向上させます
  • ファインチューニング不要の転移学習: TimeSiamで事前学習された表現は、予測・異常検知・分類といった下流タスクにゼロショットで転移でき、タスク固有のラベルなしで有用です

本記事の内容

  • Siameseネットワークの基礎(双子ネットワーク、距離学習)
  • 既存の時系列自己教師あり学習の問題点
  • TimeSiamのアーキテクチャ(past-as-target, lineage embeddings)
  • 時間的相関の数理的モデル化
  • 損失関数の設計
  • Pythonによる簡易Siamese時系列モデルの実装

前提知識

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

Siameseネットワークの基礎

双子ネットワークの構造

Siameseネットワーク(双子ネットワーク)は、2つの入力の類似度を学習するためのアーキテクチャです。名前の通り「双子」の構造を持ち、同じパラメータを共有する2つのエンコーダが並列に配置されます。

入力ペア $(\bm{x}_1, \bm{x}_2)$ に対して、共有エンコーダ $f_\theta$ で表現ベクトルを計算します。

$$ \bm{z}_1 = f_\theta(\bm{x}_1), \quad \bm{z}_2 = f_\theta(\bm{x}_2) $$

2つの表現の距離 $d(\bm{z}_1, \bm{z}_2)$ を計算し、入力ペアが「似ている」なら距離を小さく、「似ていない」なら距離を大きくするように学習します。

Siameseネットワークの核心はパラメータ共有です。2つのエンコーダが同じ重み $\theta$ を共有するため、入力空間の同じ変換が両方の入力に適用されます。これにより、表現空間での距離が入力空間での意味的な類似度を反映するようになります。

身近な例で言えば、顔認識で使われるFaceNetがSiameseネットワークの代表例です。同一人物の2枚の写真を入力すると埋め込みが近くなり、異なる人物の写真は遠くなるように学習します。TimeSiamはこの考え方を時系列に適用します。

距離学習の損失関数

Siameseネットワークの学習には、いくつかの損失関数が使われます。

コントラスティブ損失(Contrastive Loss):

$$ \mathcal{L} = (1 – y) \cdot \frac{1}{2} d^2 + y \cdot \frac{1}{2} \max(0, m – d)^2 $$

ここで $y = 0$ は正例ペア(似ている)、$y = 1$ は負例ペア(似ていない)、$m$ はマージンです。正例ペアは距離を0に近づけ、負例ペアは距離をマージン $m$ 以上に広げます。

トリプレット損失(Triplet Loss):

$$ \mathcal{L} = \max(0, d(\bm{z}_a, \bm{z}_p) – d(\bm{z}_a, \bm{z}_n) + m) $$

アンカー $\bm{z}_a$、正例 $\bm{z}_p$、負例 $\bm{z}_n$ の3つ組で、「正例との距離」が「負例との距離」よりもマージン $m$ 以上小さくなるように学習します。

Siameseネットワークの基礎を理解したところで、次にTimeSiamが既存手法のどのような問題を解決するか見ていきましょう。

既存手法の問題点 — ランダム拡張の限界

データ拡張が時間構造を壊す

SimCLR型の自己教師あり学習では、入力 $\bm{x}$ にランダム拡張 $\mathcal{T}$ を適用して正例ペアを生成します。画像の場合、回転・クロップ・色変換といった拡張は「同じ物体の別の見方」を生成し、意味を保持したまま多様な視点を提供します。

しかし、時系列に対するランダム拡張は問題があります。

ジッタリング(ノイズ付加): $\tilde{\bm{x}} = \bm{x} + \epsilon$, $\epsilon \sim \mathcal{N}(0, \sigma^2)$ はパターンの形状を変えないため安全ですが、表現の多様性に限りがあります。

スケーリング: $\tilde{\bm{x}} = \alpha \bm{x}$ は振幅を変えるだけで、時間構造は保持されます。

ランダムクロッピング: 系列からランダムな位置で部分系列を切り出します。しかし、切り出し位置によっては上昇トレンドの途中でカットされたり、周期パターンの位相がランダムにずれたりします。

タイムワーピング: 時間軸を非線形に伸縮させます。これは時間構造を直接操作するため、「遅い振動」が「速い振動」に変わるなど、物理的意味が変わる可能性があります。

正例ペアの意味的正当性

ランダム拡張の根本的な問題は、生成された正例ペアが「本当に類似しているか」が保証されないことです。SimCLRの画像拡張では、猫の画像を回転させても猫のままですが、時系列を時間方向にランダム操作すると、元の時系列とは異なる物理的意味を持つ系列が生成される可能性があります。

TimeSiamはこの問題を、ランダム拡張をまったく使わないアプローチで解決します。代わりに、時系列自身の過去を「もう一つの視点」として活用します。

TimeSiamのアーキテクチャ

past-as-target — 過去パッチを「もう一つの視点」として使う

TimeSiamの中核アイデアはpast-as-targetです。現在のパッチ $\bm{p}_t$(時刻 $t$ 付近の部分系列)に対して、同じ時系列の過去のパッチ $\bm{p}_{t-\Delta}$(時刻 $t – \Delta$ 付近の部分系列)を「目標(target)」として使います。

ランダム拡張の代わりに、時間方向の自然な変動がSiameseネットワークの2つの入力を提供します。

$$ \text{入力1(オンラインブランチ)}: \bm{p}_t = (x_{t}, x_{t+1}, \ldots, x_{t+P-1}) $$

$$ \text{入力2(ターゲットブランチ)}: \bm{p}_{t-\Delta} = (x_{t-\Delta}, x_{t-\Delta+1}, \ldots, x_{t-\Delta+P-1}) $$

ここで $P$ はパッチサイズ、$\Delta$ は時間ラグです。$\Delta$ はランダムに選択されますが、ある範囲 $[\Delta_{\min}, \Delta_{\max}]$ 内に制限されます。

このアプローチが優れている理由は、正例ペアの意味的正当性が物理法則によって保証されるからです。衛星の温度パターンは軌道力学によって規定されており、1軌道前のパターンと現在のパターンは物理的に関連しています。ランダム拡張と違い、データそのものが自然な正例ペアを提供します。

Lineage Embeddings — 時間的な系譜を埋め込む

past-as-targetのアイデアだけでは、「時間ラグ $\Delta$ がどれだけか」という情報がモデルに伝わりません。$\Delta = 1$(直前のパッチ)と $\Delta = 100$(遠い過去のパッチ)では、現在パッチとの関係性が異なるはずです。

TimeSiamはLineage Embeddingsを導入してこの問題を解決します。位置埋め込み(positional embedding)の時間ラグ版と考えることができます。

$$ \bm{l}_\Delta = \text{Embed}(\Delta) \in \mathbb{R}^d $$

Lineage Embeddingは時間ラグ $\Delta$ をエンコードし、ターゲットブランチの表現に加算されます。

$$ \bm{z}_{t-\Delta}’ = f_\theta(\bm{p}_{t-\Delta}) + \bm{l}_\Delta $$

これにより、同じ過去パッチでも $\Delta$ が異なれば異なる表現となり、モデルは「どれだけ前の過去か」を認識した上で時間的相関を学習できます。

Lineage Embeddingの実装はTransformerの位置埋め込みと同様で、正弦波ベースの固定埋め込みまたは学習可能な埋め込みが使われます。正弦波ベースの場合、

$$ l_{\Delta, 2i} = \sin\left(\frac{\Delta}{10000^{2i/d}}\right), \quad l_{\Delta, 2i+1} = \cos\left(\frac{\Delta}{10000^{2i/d}}\right) $$

全体のアーキテクチャ

TimeSiamの全体構造を整理すると、以下のようになります。

1. パッチ化: 入力時系列を重なりのあるパッチに分割します。各パッチは長さ $P$ の部分系列です。

2. ペア生成: 各パッチ $\bm{p}_t$ に対して、ランダムな時間ラグ $\Delta$ で過去パッチ $\bm{p}_{t-\Delta}$ を選択します。

3. Siameseエンコーディング:

オンラインブランチ: $\bm{z}_t = f_\theta(\bm{p}_t)$

ターゲットブランチ: $\bm{z}_{t-\Delta}’ = f_\xi(\bm{p}_{t-\Delta}) + \bm{l}_\Delta$

4. 予測: オンラインブランチの出力を射影ヘッド $g_\phi$ に通し、ターゲットブランチの表現を予測します。

$$ \hat{\bm{z}}_{t-\Delta} = g_\phi(\bm{z}_t) $$

5. 損失: 予測とターゲットのコサイン類似度を最大化します。

ここで重要なのは、ターゲットブランチのエンコーダ $f_\xi$ はオンラインブランチ $f_\theta$ の指数移動平均(EMA)で更新されるという点です。

$$ \xi \leftarrow \gamma \xi + (1 – \gamma) \theta $$

これはBYOL(Bootstrap Your Own Latent)と同じ手法で、負例なしで自己教師あり学習を可能にします。ターゲットブランチがゆっくり更新されることで、オンラインブランチにとって「安定した予測対象」を提供し、表現崩壊(collapse)を防止します。

損失関数

TimeSiamの損失関数は、予測 $\hat{\bm{z}}_{t-\Delta}$ とターゲット $\bm{z}_{t-\Delta}’$ の負のコサイン類似度です。

$$ \mathcal{L} = -\frac{1}{N} \sum_{i=1}^N \frac{\hat{\bm{z}}_i \cdot \bm{z}_i’}{\|\hat{\bm{z}}_i\| \|\bm{z}_i’\|} $$

さらに、対称性を持たせるため、オンラインとターゲットを入れ替えた損失も計算し、合計します。

$$ \mathcal{L}_{\text{total}} = \frac{1}{2} (\mathcal{L}_{\text{online} \to \text{target}} + \mathcal{L}_{\text{target} \to \text{online}}) $$

アーキテクチャの全体像を理解したところで、Pythonで簡易的なSiamese時系列モデルを実装し、past-as-targetの効果を実験で確認しましょう。

Pythonによる実装

簡易TimeSiamモデル

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

# --- 周期的な合成テレメトリデータの生成 ---
def generate_telemetry(n_series=20, length=512, period=90):
    """軌道周期を持つ衛星テレメトリを模擬"""
    t = np.arange(length)
    series_list = []
    for i in range(n_series):
        base_freq = 2 * np.pi / period
        amplitude = 1.0 + 0.2 * np.random.randn()
        phase = np.random.uniform(0, 2 * np.pi)
        trend = 0.001 * np.random.randn() * t
        noise = np.random.randn(length) * 0.1
        s = amplitude * np.sin(base_freq * t + phase) + trend + noise
        # 一部の系列に異常を挿入
        if i % 5 == 0:
            anomaly_start = np.random.randint(200, 400)
            s[anomaly_start:anomaly_start+20] += 3.0
        series_list.append(s)
    return np.array(series_list)

telemetry = generate_telemetry(n_series=30, length=512, period=90)

# --- パッチの抽出 ---
def extract_patch_pairs(series, patch_size=32, delta_range=(10, 100)):
    """past-as-targetでパッチペアを生成"""
    pairs = []
    for s in series:
        for t in range(delta_range[1] + patch_size, len(s) - patch_size):
            delta = np.random.randint(delta_range[0], delta_range[1])
            current = s[t:t+patch_size]
            past = s[t-delta:t-delta+patch_size]
            pairs.append((current, past, delta))
    return pairs

pairs = extract_patch_pairs(telemetry, patch_size=32, delta_range=(10, 100))
print(f"生成されたパッチペア数: {len(pairs)}")

# サンプリングして学習データにする
np.random.shuffle(pairs)
pairs = pairs[:2000]

X_current = np.array([p[0] for p in pairs])
X_past = np.array([p[1] for p in pairs])
deltas = np.array([p[2] for p in pairs])

# --- 簡易Siameseエンコーダ ---
class SimpleSiamese:
    def __init__(self, input_dim=32, latent_dim=16, lr=0.01, ema_decay=0.99):
        self.latent_dim = latent_dim
        self.lr = lr
        self.ema_decay = ema_decay

        # オンラインエンコーダ
        self.W_online = np.random.randn(input_dim, latent_dim) * 0.05
        self.b_online = np.zeros(latent_dim)

        # ターゲットエンコーダ(EMA)
        self.W_target = self.W_online.copy()
        self.b_target = self.b_online.copy()

        # 射影ヘッド
        self.W_proj = np.random.randn(latent_dim, latent_dim) * 0.05
        self.b_proj = np.zeros(latent_dim)

        # Lineage embedding(時間ラグの埋め込み)
        self.lineage_scale = np.random.randn(latent_dim) * 0.01

    def encode_online(self, x):
        z = np.tanh(x @ self.W_online + self.b_online)
        return z

    def encode_target(self, x, delta):
        z = np.tanh(x @ self.W_target + self.b_target)
        # Lineage embedding: 正弦波ベース
        lineage = np.sin(delta[:, None] * np.arange(self.latent_dim)[None, :] * 0.01)
        z = z + 0.1 * lineage
        return z

    def project(self, z):
        return np.tanh(z @ self.W_proj + self.b_proj)

    def cosine_sim(self, a, b):
        a_norm = a / (np.linalg.norm(a, axis=1, keepdims=True) + 1e-8)
        b_norm = b / (np.linalg.norm(b, axis=1, keepdims=True) + 1e-8)
        return np.sum(a_norm * b_norm, axis=1)

    def train_step(self, x_current, x_past, deltas_batch):
        # 順伝播
        z_online = self.encode_online(x_current)
        z_target = self.encode_target(x_past, deltas_batch)
        pred = self.project(z_online)

        # 損失: -cosine_similarity
        sim = self.cosine_sim(pred, z_target)
        loss = -np.mean(sim)

        # 簡易的な勾配更新
        pred_norm = pred / (np.linalg.norm(pred, axis=1, keepdims=True) + 1e-8)
        tgt_norm = z_target / (np.linalg.norm(z_target, axis=1, keepdims=True) + 1e-8)

        # 射影ヘッドの勾配
        grad_pred = -tgt_norm / len(x_current)
        dtanh_proj = 1 - pred ** 2
        grad_proj_input = grad_pred * dtanh_proj
        grad_W_proj = z_online.T @ grad_proj_input
        self.W_proj -= self.lr * grad_W_proj
        self.b_proj -= self.lr * np.mean(grad_proj_input, axis=0)

        # オンラインエンコーダの勾配
        grad_z_online = grad_proj_input @ self.W_proj.T
        dtanh = 1 - z_online ** 2
        grad_z_online *= dtanh
        grad_W_online = x_current.T @ grad_z_online
        self.W_online -= self.lr * grad_W_online
        self.b_online -= self.lr * np.mean(grad_z_online, axis=0)

        # ターゲットエンコーダのEMA更新
        self.W_target = self.ema_decay * self.W_target + (1 - self.ema_decay) * self.W_online
        self.b_target = self.ema_decay * self.b_target + (1 - self.ema_decay) * self.b_online

        return loss

# --- 学習ループ ---
model = SimpleSiamese(input_dim=32, latent_dim=16, lr=0.003)
n_epochs = 80
batch_size = 128
losses = []

for epoch in range(n_epochs):
    perm = np.random.permutation(len(X_current))
    epoch_loss = 0
    n_batches = 0
    for i in range(0, len(X_current), batch_size):
        idx = perm[i:i+batch_size]
        if len(idx) < 2:
            continue
        loss = model.train_step(X_current[idx], X_past[idx], deltas[idx])
        epoch_loss += loss
        n_batches += 1
    losses.append(epoch_loss / max(n_batches, 1))

# --- 学習曲線 ---
plt.figure(figsize=(8, 4))
plt.plot(losses, color='#00d4ff')
plt.xlabel('Epoch')
plt.ylabel('Loss (negative cosine similarity)')
plt.title('TimeSiam Training Loss')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('timesiam_loss.png', dpi=150, bbox_inches='tight')
plt.show()

学習曲線から、損失(負のコサイン類似度)が学習の進行とともに減少していることがわかります。これは、オンラインブランチの予測がターゲットブランチの表現に近づいていること、つまりモデルが「現在のパッチから過去のパッチの表現を予測する」能力を獲得していることを意味します。損失が急速に下がった後に緩やかに収束するパターンは、BYOLスタイルの自己教師あり学習で典型的に見られます。

時間ラグと埋め込みの関係を可視化

# --- 時間ラグと埋め込み距離の関係 ---
# テスト用パッチペアを様々なΔで生成
test_series = telemetry[0]
test_deltas = list(range(5, 200, 5))
embeddings_current = []
embeddings_past = []

patch_size = 32
t_center = 300

current_patch = test_series[t_center:t_center+patch_size].reshape(1, -1)
z_current = model.encode_online(current_patch)

distances = []
for d in test_deltas:
    past_patch = test_series[t_center-d:t_center-d+patch_size].reshape(1, -1)
    z_past = model.encode_target(past_patch, np.array([d]))
    cos_sim = model.cosine_sim(z_current, z_past)[0]
    distances.append(cos_sim)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# (a) 時間ラグ vs コサイン類似度
axes[0].plot(test_deltas, distances, 'o-', color='#00d4ff', markersize=3)
axes[0].set_xlabel('Time lag Δ')
axes[0].set_ylabel('Cosine similarity')
axes[0].set_title('Embedding Similarity vs Time Lag')
axes[0].axvline(x=90, color='#ff6b6b', linestyle='--', label='Orbital period (90)')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# (b) 元の時系列で対応区間を表示
t_plot = np.arange(len(test_series))
axes[1].plot(t_plot, test_series, color='gray', alpha=0.5, label='Full series')
axes[1].axvspan(t_center, t_center+patch_size, alpha=0.3, color='#00d4ff', label='Current patch')
for d in [30, 90, 180]:
    axes[1].axvspan(t_center-d, t_center-d+patch_size, alpha=0.2,
                     color='#ff6b6b' if d == 90 else '#ffd93d')
    axes[1].annotate(f'Δ={d}', xy=(t_center-d, test_series[t_center-d]),
                     fontsize=8, color='red')
axes[1].set_xlabel('Time step')
axes[1].set_ylabel('Value')
axes[1].set_title('Telemetry Series with Patch Locations')
axes[1].legend(fontsize=8)
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('timesiam_lag_analysis.png', dpi=150, bbox_inches='tight')
plt.show()

この可視化から、TimeSiamが時間的相関を学習していることが確認できます。左のグラフでは、時間ラグ $\Delta$ に対するコサイン類似度が周期的に変動しており、軌道周期(90ステップ)付近でピークが現れます。これは物理的に正しい挙動です — 1軌道前のパッチは位相がほぼ同じため高い類似度を持ちます。赤い破線が軌道周期を示しています。右のグラフは元のテレメトリ系列上でのパッチ位置を示しており、$\Delta = 90$(軌道周期)のパッチが現在パッチと同じ位相にあることが視覚的にも確認できます。

埋め込み空間のt-SNE可視化

# --- 全パッチの埋め込みを計算 ---
all_patches = []
all_positions = []
all_series_ids = []

for sid in range(min(10, len(telemetry))):
    s = telemetry[sid]
    for t in range(0, len(s) - patch_size, patch_size // 2):
        patch = s[t:t+patch_size]
        all_patches.append(patch)
        all_positions.append(t)
        all_series_ids.append(sid)

all_patches = np.array(all_patches)
all_positions = np.array(all_positions)
all_series_ids = np.array(all_series_ids)

Z = model.encode_online(all_patches)

# 簡易t-SNE(PCAで代用)
from numpy.linalg import svd
Z_centered = Z - Z.mean(axis=0)
U, S, Vt = svd(Z_centered, full_matrices=False)
Z_2d = Z_centered @ Vt[:2].T

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 系列IDで色分け
scatter = axes[0].scatter(Z_2d[:, 0], Z_2d[:, 1],
                          c=all_series_ids, cmap='tab10', alpha=0.5, s=10)
axes[0].set_title('Embeddings colored by Series ID')
axes[0].set_xlabel('PC1')
axes[0].set_ylabel('PC2')
plt.colorbar(scatter, ax=axes[0], label='Series ID')
axes[0].grid(True, alpha=0.3)

# 時刻で色分け(軌道位相)
orbital_phase = (all_positions % 90) / 90.0
scatter2 = axes[1].scatter(Z_2d[:, 0], Z_2d[:, 1],
                           c=orbital_phase, cmap='hsv', alpha=0.5, s=10)
axes[1].set_title('Embeddings colored by Orbital Phase')
axes[1].set_xlabel('PC1')
axes[1].set_ylabel('PC2')
plt.colorbar(scatter2, ax=axes[1], label='Orbital phase (0-1)')
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('timesiam_embeddings.png', dpi=150, bbox_inches='tight')
plt.show()

埋め込み空間の可視化から2つの重要な特徴が読み取れます。左のグラフ(系列IDで色分け)では、異なる系列のパッチが混在して配置されている部分があります。これは、異なる衛星でも同じ軌道位相にあるパッチは類似した埋め込みを持つことを意味し、TimeSiamが「どの系列か」ではなく「どのようなパターンか」を学習していることを示しています。右のグラフ(軌道位相で色分け)では、色のグラデーションが滑らかに変化しており、軌道位相が近いパッチほど埋め込み空間でも近くに配置されていることがわかります。これはTimeSiamの時間的相関学習が成功していることの直接的な証拠です。

まとめ

本記事では、TimeSiam(ICML 2024)が提案するSiameseネットワークベースの時系列表現学習について解説しました。

  • past-as-target: ランダム拡張の代わりに、同一系列の過去パッチを「もう一つの視点」として活用する。物理法則に基づく自然な正例ペアが得られる
  • Lineage Embeddings: 時間ラグ $\Delta$ をエンコードし、「どれだけ前の過去か」をモデルに伝える。位置埋め込みの時間ラグ版
  • BYOLスタイルの学習: ターゲットエンコーダのEMA更新により、負例なしで自己教師あり学習が可能
  • テレメトリ検索への直接的応用: 学習された埋め込みは時系列の時間的特性を反映し、検索インデックスの品質を向上させる

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