メインコンテンツへスキップ
見出し画像

Google Colabで時系列基盤モデルを試す②:AutonLab MOMENT

    はじめに

    前回のGoogle Timesfmに引き続き、HuggingFaceにある商用可能なライセンスの時系列基盤モデルを4つ試し、比較していきたいと思います。
    利用するデータはETTh1という電力変圧器温度に関する多変量時系列データセットです。事前学習にこのデータが含まれる可能性があるため、モデルの絶対的な評価に繋がらないことに注意してください。

    1. google/timesfm-1.0-200m

      • ダウンロード数:4.59k

      • モデルサイズ:200m

      • ライセンス:Apache-2.0

    2. AutonLab/MOMENT-1-large (今回)

      • ダウンロード数:5.79k

      • モデルサイズ:385m

      • ライセンス:MIT

    3. ibm-granite/granite-timeseries-ttm-v1

      • ダウンロード数:10.1k

      • モデルサイズ:805k (小さい!!)

      • ライセンス:Apache-2.0

    4. amazon/chronos-t5-large

      • ダウンロード数:256k (多い!!)

      • モデルサイズ:709m

      • ライセンス:Apache-2.0

    6月2日時点でダウンロード数が少ない順に実施していきます。
    今回はAutonLabのMOMENT-1です。AutonLabはカーネギーメロン大学の組織の1つのようです。
    MOMENT-1は、Granite, chronos-t5と同様にDecoder-Encoder Architectureです。chronos-t5と同様、言語モデルのT5をベースにしています。
    同時に、学習に使用したデータをTimeseries-PILEとして公開しています。

    MOMENT-1はそのチュートリアルでETTh1のデータが使用されているので、ETTh1が事前学習で利用されていることはないと思います。

    1. 推論

    ライブラリの準備

    # library install
    !pip install git+https://github.com/moment-timeseries-foundation-model/moment.git

    データ準備
    前回同様ETTh1.csvを使います。取得はこちらから可能です。

    import pandas as pd
    
    # データ読み込み
    # https://github.com/zhouhaoyi/ETDataset/blob/main/ETT-small/ETTh1.csv
    df = pd.read_csv("ETTh1.csv")
    print(len(df))
    df.head(2)

    データは以下のような形式です。OT(Oil Temperature)が目的変数となります。

    画像

    データ加工
    モデルに与える長さを512、予測する長さを96と今回はおきます。Fine Tuningに使うため、後ろから予測するデータをとっておきます。
    前回とはモデルに与えるTensorの形が異なることに注意してください。

    import torch
    
    context_length = 512
    forecast_horizon = 96
    
    # データセット分割
    df_train = df.iloc[-(context_length+forecast_horizon):-forecast_horizon]
    df_test = df.iloc[-forecast_horizon:]
    
    # 形式の変更
    train_tensor = torch.tensor(df_train[["HUFL", "HULL", "MUFL", "MULL", "LUFL", "LULL", "OT"]].values, dtype=torch.float)
    train_tensor = train_tensor.t().unsqueeze(0)
    test_tensor = torch.tensor(df_test[["HUFL", "HULL", "MUFL", "MULL", "LUFL", "LULL", "OT"]].values, dtype=torch.float)
    test_tensor = test_tensor.t().unsqueeze(0)

    モデルの取得
    task_nameには、実施するタスクに合わせてforecasting(予測)、classification(分類)、reconstruction(異常検出/補完/事前トレーニング)、embedding(埋め込み表現)を指定します。
    forecastingタスクの場合、追加で予測するデータ長であるforecast_horizonを指定します。forecast_horizonに関しては特に記載がなかったです。後述しますが、forecast_horizonはどんな値でも良いはずです。ただ、さすがに入力の長さを超えると予測精度も下がることが考えられるのでtimesfmと同様に入力長以下に抑えることが望ましいと思います。

    from momentfm import MOMENTPipeline
    
    model = MOMENTPipeline.from_pretrained(
        "AutonLab/MOMENT-1-large", 
        model_kwargs={
            'task_name': 'forecasting',
            'forecast_horizon': forecast_horizon
        },
    )
    model.init()
    model.to("cuda:0")

    model pipelineは以下のような構成です。

    MOMENTPipeline(
      (normalizer): RevIN()
      (tokenizer): Patching()
      (patch_embedding): PatchEmbedding(
        (value_embedding): Linear(in_features=8, out_features=1024, bias=False)
        (position_embedding): PositionalEmbedding()
        (dropout): Dropout(p=0.1, inplace=False)
      )
      (encoder): T5Stack(
        (embed_tokens): Embedding(32128, 1024)
        (block): ModuleList(
          (0): T5Block(
            (layer): ModuleList(
              (0): T5LayerSelfAttention(
                (SelfAttention): T5Attention(
                  (q): Linear(in_features=1024, out_features=1024, bias=False)
                  (k): Linear(in_features=1024, out_features=1024, bias=False)
                  (v): Linear(in_features=1024, out_features=1024, bias=False)
                  (o): Linear(in_features=1024, out_features=1024, bias=False)
                  (relative_attention_bias): Embedding(32, 16)
                )
                (layer_norm): T5LayerNorm()
                (dropout): Dropout(p=0.1, inplace=False)
              )
              (1): T5LayerFF(
                (DenseReluDense): T5DenseGatedActDense(
                  (wi_0): Linear(in_features=1024, out_features=2816, bias=False)
                  (wi_1): Linear(in_features=1024, out_features=2816, bias=False)
                  (wo): Linear(in_features=2816, out_features=1024, bias=False)
                  (dropout): Dropout(p=0.1, inplace=False)
                  (act): NewGELUActivation()
                )
                (layer_norm): T5LayerNorm()
                (dropout): Dropout(p=0.1, inplace=False)
              )
            )
          )
          (1-23): 23 x T5Block(
            (layer): ModuleList(
              (0): T5LayerSelfAttention(
                (SelfAttention): T5Attention(
                  (q): Linear(in_features=1024, out_features=1024, bias=False)
                  (k): Linear(in_features=1024, out_features=1024, bias=False)
                  (v): Linear(in_features=1024, out_features=1024, bias=False)
                  (o): Linear(in_features=1024, out_features=1024, bias=False)
                )
                (layer_norm): T5LayerNorm()
                (dropout): Dropout(p=0.1, inplace=False)
              )
              (1): T5LayerFF(
                (DenseReluDense): T5DenseGatedActDense(
                  (wi_0): Linear(in_features=1024, out_features=2816, bias=False)
                  (wi_1): Linear(in_features=1024, out_features=2816, bias=False)
                  (wo): Linear(in_features=2816, out_features=1024, bias=False)
                  (dropout): Dropout(p=0.1, inplace=False)
                  (act): NewGELUActivation()
                )
                (layer_norm): T5LayerNorm()
                (dropout): Dropout(p=0.1, inplace=False)
              )
            )
          )
        )
        (final_layer_norm): T5LayerNorm()
        (dropout): Dropout(p=0.1, inplace=False)
      )
      (head): ForecastingHead(
        (flatten): Flatten(start_dim=-2, end_dim=-1)
        (dropout): Dropout(p=0.1, inplace=False)
        (linear): Linear(in_features=65536, out_features=96, bias=True)
      )
    )

    forecast_horizonとして96を指定したのでout_features=96のForecastingHeadがheadとしてついています。
    実は、事前学習時にはreconstruction用のヘッドで学習されており、forecastingとclassification用のヘッドは新たにランダムに初期化される形となります。なのでforecast_horizonはどの値でも良さそうと考えました。

    また、このためforecastingとclassificationタスクはFineTuningを行わないと意味がありませんが一応ランダムに初期化されていることを確認するために推論も実施してみようと思います。

    推論

    import torch
    
    # 推論
    forecast = model(train_tensor.to("cuda:0"))
    forecast_tensor = forecast.forecast.cpu()

    推論結果の出力
    予測結果としてOT(Oil Temperature)がみたいのでchannel_idxとして6を指定します。

    import matplotlib.pyplot as plt
    
    channel_idx = 6
    time_index = 0
    
    history = train_tensor[time_index, channel_idx, :].detach().numpy()
    true = test_tensor[time_index, channel_idx, :].detach().numpy()
    pred = forecast_tensor[time_index, channel_idx, :].detach().numpy()
    
    plt.figure(figsize=(12, 4))
    
    # Plotting the first time series from history
    plt.plot(range(len(history)), history, label='History (512 timesteps)', c='darkblue')
    
    # Plotting ground truth and prediction
    num_forecasts = len(true)
    
    offset = len(history)
    plt.plot(range(offset, offset + len(true)), true, label='Ground Truth (96 timesteps)', color='darkblue', linestyle='--', alpha=0.5)
    plt.plot(range(offset, offset + len(pred)), pred, label='Forecast (96 timesteps)', color='red', linestyle='--')
    
    plt.title(f"ETTh1 (Hourly) -- (idx={time_index}, channel={channel_idx})", fontsize=18)
    plt.xlabel('Time', fontsize=14)
    plt.ylabel('Value', fontsize=14)
    plt.legend(fontsize=14)
    plt.show()

    結果は以下のようになります。Headがランダムに初期化されているので予測はされません。

    画像
    ETTh1のOT(oil temperature)の予測結果

    2. Fine Tuning

    Headのみを学習していくことになります。

    画像
    MOMENT: One Model, Multiple Tasks, Datasets & Domains

    Data Loaderの準備
    公式のチュートリアルでは事前に用意されたデータを呼び出すためのInformerDatasetというクラスを使っていますが、自前で用意したデータでFineTuningできるように書き換えています。
    また、df_testの最後のcontext_length+forecast_horizon分は最後の推論用に取り除いておきます。

    import numpy as np
    import torch
    import torch.cuda.amp
    from torch.utils.data import DataLoader, Dataset
    from torch.optim.lr_scheduler import OneCycleLR
    from tqdm import tqdm
    
    from momentfm.utils.utils import control_randomness
    from momentfm.utils.forecasting_metrics import get_forecasting_metrics
    
    
    class ETThDataset(Dataset):
        def __init__(self, data, random_seed=42, forecast_horizon=192, seq_len=512):
            self.data = data
            self.random_seed = random_seed
            self.forecast_horizon = forecast_horizon
            self.seq_len = seq_len
            
        def __len__(self):
            return len(self.data) - self.seq_len - self.forecast_horizon
        
        def __getitem__(self, idx):
            # ランダムシードを設定
            torch.manual_seed(self.random_seed)
            # シーケンスの開始位置をランダムに選択
            start_idx = torch.randint(0, len(self.data) - self.seq_len - self.forecast_horizon, (1,)).item()
            # 入力シーケンスを取得
            x = torch.tensor(self.data.iloc[start_idx:start_idx+self.seq_len, :].values, dtype=torch.float32)
            x = x.t()
            # 目標シーケンスを取得
            y = torch.tensor(self.data.iloc[start_idx+self.seq_len:start_idx+self.seq_len+self.forecast_horizon, -1].values, dtype=torch.float32)
            y = y.unsqueeze(0)
            # input_mask
            input_mask = np.ones(self.seq_len)
            return x, y, input_mask
    
    
    # ETTh1.csvを読み込む
    df = pd.read_csv("ETTh1.csv")
    # 日時の除去
    df = df.drop(columns=["date"])
    
    # データを前8割をトレーニング用、後ろ2割をテスト用に分割
    train_size = int(0.8 * len(df))
    print(train_size)
    df_train = df.iloc[:train_size]
    df_test = df.iloc[train_size:-(context_length+forecast_horizon)]
    
    # ETTDatasetを作成
    train_dataset = ETThDataset(df_train, forecast_horizon=96)
    test_dataset = ETThDataset(df_test, forecast_horizon=96)
    
    # DataLoaderの定義
    batch_size = 64
    train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
    test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=True)

    諸々の設定
    今後のGraniteやchronos-t5と条件を揃えるため、epochは1とします。

    # lossやoptim、scheduler等諸々定義
    criterion = torch.nn.MSELoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
    
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    
    cur_epoch = 0
    max_epoch = 1
    
    # Move the model to the GPU
    model = model.to(device)
    
    # Move the loss function to the GPU
    criterion = criterion.to(device)
    
    # Enable mixed precision training
    scaler = torch.cuda.amp.GradScaler()
    
    # Create a OneCycleLR scheduler
    max_lr = 1e-4
    total_steps = len(train_loader) * max_epoch
    scheduler = OneCycleLR(optimizer, max_lr=max_lr, total_steps=total_steps, pct_start=0.3)
    
    # Gradient clipping value
    max_norm = 5.0

    Fine Tuning実施
    Google ColabのT4で7分ほどかかりました。

    # FineTuning開始
    while cur_epoch < max_epoch:
        losses = []
        for timeseries, forecast, input_mask in tqdm(train_loader, total=len(train_loader)):
            # Move the data to the GPU
            timeseries = timeseries.float().to(device)
            input_mask = input_mask.to(device)
            forecast = forecast.float().to(device)
    
            with torch.cuda.amp.autocast():
                output = model(timeseries, input_mask)
            
            loss = criterion(output.forecast, forecast)
    
            # Scales the loss for mixed precision training
            scaler.scale(loss).backward()
    
            # Clip gradients
            scaler.unscale_(optimizer)
            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)
    
            scaler.step(optimizer)
            scaler.update()
            optimizer.zero_grad(set_to_none=True)
    
            losses.append(loss.item())
    
        losses = np.array(losses)
        average_loss = np.average(losses)
        print(f"Epoch {cur_epoch}: Train loss: {average_loss:.3f}")
    
        # Step the learning rate scheduler
        scheduler.step()
        cur_epoch += 1
        
        # Evaluate the model on the test split
        trues, preds, histories, losses = [], [], [], []
        model.eval()
        with torch.no_grad():
            for timeseries, forecast, input_mask in tqdm(test_loader, total=len(test_loader)):
            # Move the data to the GPU
                timeseries = timeseries.float().to(device)
                input_mask = input_mask.to(device)
                forecast = forecast.float().to(device)
    
                with torch.cuda.amp.autocast():
                    output = model(timeseries, input_mask)
                
                loss = criterion(output.forecast, forecast)                
                losses.append(loss.item())
    
                trues.append(forecast.detach().cpu().numpy())
                preds.append(output.forecast.detach().cpu().numpy())
                histories.append(timeseries.detach().cpu().numpy())
        
        losses = np.array(losses)
        average_loss = np.average(losses)
        model.train()
    
        trues = np.concatenate(trues, axis=0)
        preds = np.concatenate(preds, axis=0)
        histories = np.concatenate(histories, axis=0)
        
        metrics = get_forecasting_metrics(y=trues, y_hat=preds, reduction='mean')
    
        print(f"Epoch {cur_epoch}: Test MSE: {metrics.mse:.3f} | Test MAE: {metrics.mae:.3f}")

    3. 推論(Fine Tuning後)

    最後に、先に試したのと同じ区間で推論を再度実施してみます。

    データ準備

    import torch
    
    context_length = 512
    forecast_horizon = 96
    
    # データセット分割
    df_train = df.iloc[-(context_length+forecast_horizon):-forecast_horizon]
    df_test = df.iloc[-forecast_horizon:]
    
    # 形式の変更
    train_tensor = torch.tensor(df_train[["HUFL", "HULL", "MUFL", "MULL", "LUFL", "LULL", "OT"]].values, dtype=torch.float)
    train_tensor = train_tensor.t().unsqueeze(0)
    test_tensor = torch.tensor(df_test[["HUFL", "HULL", "MUFL", "MULL", "LUFL", "LULL", "OT"]].values, dtype=torch.float)
    test_tensor = test_tensor.t().unsqueeze(0)

    推論

    import torch
    
    # 推論
    model.eval()
    forecast = model(train_tensor.to("cuda:0"))
    forecast_tensor = forecast.forecast.cpu()

    推論結果の出力

    import matplotlib.pyplot as plt
    
    channel_idx = 6
    time_index = 0
    
    history = train_tensor[time_index, channel_idx, :].detach().numpy()
    true = test_tensor[time_index, channel_idx, :].detach().numpy()
    pred = forecast_tensor[time_index, channel_idx, :].detach().numpy()
    
    plt.figure(figsize=(12, 4))
    
    # Plotting the first time series from history
    plt.plot(range(len(history)), history, label='History (512 timesteps)', c='darkblue')
    
    # Plotting ground truth and prediction
    num_forecasts = len(true)
    
    offset = len(history)
    plt.plot(range(offset, offset + len(true)), true, label='Ground Truth (96 timesteps)', color='darkblue', linestyle='--', alpha=0.5)
    plt.plot(range(offset, offset + len(pred)), pred, label='Forecast (96 timesteps)', color='red', linestyle='--')
    
    plt.title(f"ETTh1 (Hourly) -- (idx={time_index}, channel={channel_idx})", fontsize=18)
    plt.xlabel('Time', fontsize=14)
    plt.ylabel('Value', fontsize=14)
    plt.legend(fontsize=14)
    plt.show()

    結果は以下のようになりました。

    画像
    ETTh1のOT(oil temperature)の予測結果(FT後)

    4. 結果

    結果としては、学習はしたような気はするものの学習が足りてないような予測となりました。(ほとんど公式のチュートリアルから持ってきたものの、コードに誤りがあればご連絡いただきたいです。)

    FTの結果は期待通りとはいかなかったもののHeadを付け替えて予測や分類、Embedding、異常検知と幅広いタスクに対応できるのが特徴的でtimesfmにはなかった点だと思います。
    時系列のEmbeddingというのは今まで考えたことがなかったので、今度どこかで試してみたいと思います。

    次回

    参照

     
     

    はち

     
     
    外資系IT企業勤務。最近はLLMにハマってます。

    あなたへのおすすめ