AI 物見遊山 - 自然言語処理編 - LSTM モデル学習 その肆

自然言語処理編 - LSTM モデル学習 その肆

アイキャッチ画像

前の記事では、モデル学習の改善を行った。 この記事では、推論の正解率改善を体験する。 改善と言っても、既に機能として存在するので、パラメータを変更するだけとなる。 試す機能は、LSTM の多層化と、双方向化とする。

LSTM の多層化

LSTM の層数を 2 にするため、 PosTaggerLSTM の引数に num_layers=2 を追加することと、 保存するモデルファイル名を変えている。 それ以外は、変更していない。

train_layers.py

import bz2
import logging

import numpy as np
import time
import torch
from torch.utils.data import TensorDataset, DataLoader
from torch.utils.tensorboard import SummaryWriter

from pos_tagger_lstm import PosTaggerLSTM

# ログ設定
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)

# 処理済みの単語を重複なく保持する入出力ファイル名
WORD_FILE = 'lstm-word.txt.bz2'
# 処理済みの品詞を重複なく保持する入出力ファイル名
POS_FILE = 'lstm-pos.txt.bz2'

# 学習用訓練データ出力ファイル名
X_TRAIN_FILE = 'lstm-optim-x-train.npy'
# 学習用教師データ出力ファイル名
Y_TRAIN_FILE = 'lstm-optim-y-train.npy'

# バッチサイズ
BATCH_SIZE = 128
# 学習率
LEARNING_RATE = 1.0
# エポック数
EPOCHS = 10


def main():
    """LSTM モデル学習を行う"""
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    # 単語 ID 辞書を読み込む
    with bz2.open(WORD_FILE, 'rt', encoding='utf-8') as f:
        word_ids = {k: int(v) for k, v in (line.split('\t') for line in f)}
    # 品詞 ID 辞書を読み込む
    with bz2.open(POS_FILE, 'rt', encoding='utf-8') as f:
        pos_ids = {k: int(v) for k, v in (line.split('\t') for line in f)}

    # 訓練データファイルを読み込む
    x_train = np.load(X_TRAIN_FILE)
    x_train_tensor = torch.from_numpy(x_train)
    # 教師データファイルを読み込む
    y_train = np.load(Y_TRAIN_FILE)
    y_train_tensor = torch.from_numpy(y_train)

    # バッチ処理用にデータを準備する
    train_dataset = TensorDataset(x_train_tensor, y_train_tensor)
    train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, pin_memory=True)

    print(f'word_ids: {len(word_ids)}, pos_ids: {len(pos_ids)}, x_train: {len(x_train)}, y_train: {len(y_train)}')

    # モデルに、単語 ID 数と、品詞 ID 数を指定する
    # LSTM の層数を 2 に指定する
    model = PosTaggerLSTM(len(word_ids) + 1, len(pos_ids), 100, num_layers=2).to(device)
    # 最適化アルゴリズムに確率的勾配降下法を設定する
    optimizer = torch.optim.SGD(model.parameters(), lr=LEARNING_RATE)
    # 学習率スケジューラを設定する
    # 監視指標(scheduler.step 関数で渡す値)の変化率が threshold 値を下回ると、学習率に factor を欠けた値で学習率を更新する
    # threshold と比較する値(変化率)は、(前回の監視指標 - 現在の監視指標) / 前回の監視指標 となる
    scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.9, patience=0,
                                                           threshold=0.2, min_lr=0.01)
    # 損失関数に交差エントロピー損失を設定する
    # ignore_index=-1 で、教師データのパディング値である、-1 を無視する
    criterion = torch.nn.CrossEntropyLoss(ignore_index=-1)

    # TensorBoard ログ書き込み用
    writer = SummaryWriter()
    # 前回エポックの損失値(デバッグ表示用)
    epoch_loss_old = 0.00001

    for epoch in range(EPOCHS):
        epoch_start_time = time.time()
        # 損失の合計値
        loss_sum = 0.0
        for i, (x, y) in enumerate(train_loader):
            x = x.to(device)
            y = y.to(device)
            # 訓練する
            output = model(x)
            # 損失値(予測結果と正解データとの間の誤差)を計算する
            loss = criterion(output.view(-1, output.size(2)), y.view(-1))
            # 勾配を初期化する
            optimizer.zero_grad()
            # 勾配を計算する
            loss.backward()
            # 予測結果と正解データとの誤差 (損失) を小さくするように、モデルのパラメータを調整する
            optimizer.step()
            # 損失の合計値を更新する
            loss_sum += loss.item() * len(y)

            if i >= 1 and i % (1000 // BATCH_SIZE) == 0:
                print(f'{i * BATCH_SIZE}, Loss: {loss_sum / ((i + 1) * BATCH_SIZE):.4f}')

        epoch_loss = loss_sum / len(x_train)
        # 学習率を取得する
        lr = optimizer.param_groups[0]['lr']

        # TensorBoard 用ログを書き込む
        writer.add_scalar('Loss', loss_sum / len(x_train), epoch + 1)
        writer.add_scalar('Learning Rate', lr, epoch + 1)
        writer.flush()

        # 学習率を更新する(スケジューラによって引数が異なる)
        scheduler.step(epoch_loss)
        # この値が、スケジューラの threshold 値を下回ると、スケジューラが学習率が変更する
        print(f'rel_change: {(epoch_loss_old - epoch_loss) / epoch_loss_old}')
        epoch_loss_old = epoch_loss

        # モデルの保存
        outfile = f'lstm-layer2-epoch{epoch + 1:02d}.model'
        torch.save({
            'epoch': epoch + 1,
            'model_state_dict': model.state_dict(),
            'optimizer_state_dict': optimizer.state_dict(),
            'scheduler_state_dict': scheduler.state_dict(),
        }, outfile)
        elapsed_time = time.time() - epoch_start_time
        print(
            f'Epoch: {epoch + 1}, Loss: {loss_sum / len(x_train):.4f}, Learning Rate: {lr:.6f}, Time: {elapsed_time:.2f} s')


if __name__ == '__main__':
    start_time = time.time()
    main()
    print(f'経過時間: {time.time() - start_time:.2f} s')

以下のような実行結果となった。 学習は、T4 GPU で行っている。

Epoch: 1, Loss: 1.0046, Learning Rate: 1.000000, Time: 15.48 s
Epoch: 2, Loss: 0.4056, Learning Rate: 1.000000, Time: 14.40 s
Epoch: 3, Loss: 0.2609, Learning Rate: 1.000000, Time: 14.75 s
Epoch: 4, Loss: 0.2015, Learning Rate: 1.000000, Time: 15.25 s
Epoch: 5, Loss: 0.1669, Learning Rate: 1.000000, Time: 15.51 s
Epoch: 6, Loss: 0.1423, Learning Rate: 0.900000, Time: 15.12 s
Epoch: 7, Loss: 0.1272, Learning Rate: 0.900000, Time: 14.85 s
Epoch: 8, Loss: 0.1150, Learning Rate: 0.810000, Time: 15.09 s
Epoch: 9, Loss: 0.1056, Learning Rate: 0.729000, Time: 14.81 s
Epoch: 10, Loss: 0.0990, Learning Rate: 0.729000, Time: 14.88 s

推論をする

変更は、学習時と同様に、 PosTaggerLSTM の引数に num_layers=2 を追加することと、 読み込みモデルデータを変えている。

infer_layers.py

import bz2
import logging

import numpy as np
import time
import torch
from torch.utils.data import TensorDataset, DataLoader

from pos_tagger_lstm import PosTaggerLSTM

# ログ設定
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)

# 処理済みの単語を重複なく保持する入出力ファイル名
WORD_FILE = 'lstm-word.txt.bz2'
# 処理済みの品詞を重複なく保持する入出力ファイル名
POS_FILE = 'lstm-pos.txt.bz2'

# テスト用訓練データ出力ファイル名
X_TEST_FILE = 'lstm-optim-x-test.npy'
# テスト用教師データ出力ファイル名
Y_TEST_FILE = 'lstm-optim-y-test.npy'

# モデルファイル名
MODEL_FILE = 'lstm-layer2-epoch10.model'

# バッチサイズ
BATCH_SIZE = 128


def main():
    """
    LSTM モデル推論を行う
    """
    print('Loading datas...')
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    # 単語 ID 辞書を読み込む
    with bz2.open(WORD_FILE, 'rt', encoding='utf-8') as f:
        word_ids = {line.split('\t')[0]: int(line.split('\t')[1]) for line in f}
    # 品詞 ID 辞書を読み込む
    with bz2.open(POS_FILE, 'rt', encoding='utf-8') as f:
        pos_ids = {line.split('\t')[0]: int(line.split('\t')[1]) for line in f}

    # テスト用訓練データファイルを読み込む
    x_test = np.load(X_TEST_FILE)
    x_test_tensor = torch.from_numpy(x_test)
    # テスト用教師データファイルを読み込む
    y_test = np.load(Y_TEST_FILE)
    y_test_tensor = torch.from_numpy(y_test)

    train_dataset = TensorDataset(x_test_tensor, y_test_tensor)
    train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=False, pin_memory=True)

    model = PosTaggerLSTM(len(word_ids) + 1, len(pos_ids), 100, num_layers=2).to(device)
    checkpoint = torch.load(MODEL_FILE, weights_only=True, map_location=device)
    model.load_state_dict(checkpoint['model_state_dict'])

    data_count = 0
    model.eval()
    print('Start inference...')
    with torch.no_grad():
        start_time = time.time()
        ok = 0
        for x, y in train_loader:
            x = x.to(device)
            y = y.to(device)
            output = model(x)
            # 推論結果から、回答を取り出す
            ans = torch.argmax(output, dim=2)
            # 教師データから、パディング値を除外するための配列を作成する(パディング値なら False となる配列)
            mask = y != -1
            # 回答から、正解でかつ、パディングデータでないものを抽出する
            # * mask の部分は、AND 条件と判定される。
            ok += ((ans == y) * mask).sum().item()
            # mask の値が、True の項目合計数を求める
            data_count += mask.sum().item()
        print(f'OK: {ok}, Total: {data_count}, Accuracy: {ok / data_count:.4f}')
        elapsed_time = time.time() - start_time
        print(f'経過時間: {elapsed_time:.2f} s ({data_count / elapsed_time:.0f} times/s)')


if __name__ == '__main__':
    main()

以下のような実行結果となった。 推論は、CPU で行っている。

OK: 424544, Total: 440767, Accuracy: 0.9632
経過時間: 6.95 s (63419 times/s)

LSTM の多層化と双方向化の組み合わせ

LSTM モデルの多層化と双方向化を試す。 PosTaggerLSTM の引数に num_layers=2bidirectional=True を追加することと、 保存するモデルファイル名を変えている。

train_layers_bidirect.py

import bz2
import logging

import numpy as np
import time
import torch
from torch.utils.data import TensorDataset, DataLoader
from torch.utils.tensorboard import SummaryWriter

from pos_tagger_lstm import PosTaggerLSTM

# ログ設定
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)

# 処理済みの単語を重複なく保持する入出力ファイル名
WORD_FILE = 'lstm-word.txt.bz2'
# 処理済みの品詞を重複なく保持する入出力ファイル名
POS_FILE = 'lstm-pos.txt.bz2'

# 学習用訓練データ出力ファイル名
X_TRAIN_FILE = 'lstm-optim-x-train.npy'
# 学習用教師データ出力ファイル名
Y_TRAIN_FILE = 'lstm-optim-y-train.npy'

# バッチサイズ
BATCH_SIZE = 128
# 学習率
LEARNING_RATE = 1.0
# エポック数
EPOCHS = 10


def main():
    """LSTM モデル学習を行う"""
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    # 単語 ID 辞書を読み込む
    with bz2.open(WORD_FILE, 'rt', encoding='utf-8') as f:
        word_ids = {k: int(v) for k, v in (line.split('\t') for line in f)}
    # 品詞 ID 辞書を読み込む
    with bz2.open(POS_FILE, 'rt', encoding='utf-8') as f:
        pos_ids = {k: int(v) for k, v in (line.split('\t') for line in f)}

    # 訓練データファイルを読み込む
    x_train = np.load(X_TRAIN_FILE)
    x_train_tensor = torch.from_numpy(x_train)
    # 教師データファイルを読み込む
    y_train = np.load(Y_TRAIN_FILE)
    y_train_tensor = torch.from_numpy(y_train)

    # バッチ処理用にデータを準備する
    train_dataset = TensorDataset(x_train_tensor, y_train_tensor)
    train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, pin_memory=True)

    print(f'word_ids: {len(word_ids)}, pos_ids: {len(pos_ids)}, x_train: {len(x_train)}, y_train: {len(y_train)}')

    # モデルに、単語 ID 数と、品詞 ID 数を指定する
    # LSTM の層数を 2 に指定する
    model = PosTaggerLSTM(len(word_ids) + 1, len(pos_ids), 100, num_layers=2, bidirectional=True).to(device)
    # 最適化アルゴリズムに確率的勾配降下法を設定する
    optimizer = torch.optim.SGD(model.parameters(), lr=LEARNING_RATE)
    # 学習率スケジューラを設定する
    # 監視指標(scheduler.step 関数で渡す値)の変化率が threshold 値を下回ると、学習率に factor を欠けた値で学習率を更新する
    # threshold と比較する値(変化率)は、(前回の監視指標 - 現在の監視指標) / 前回の監視指標 となる
    scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.9, patience=0,
                                                           threshold=0.2, min_lr=0.01)
    # 損失関数に交差エントロピー損失を設定する
    # ignore_index=-1 で、教師データのパディング値である、-1 を無視する
    criterion = torch.nn.CrossEntropyLoss(ignore_index=-1)

    # TensorBoard ログ書き込み用
    writer = SummaryWriter()
    # 前回エポックの損失値(デバッグ表示用)
    epoch_loss_old = 0.00001

    for epoch in range(EPOCHS):
        epoch_start_time = time.time()
        # 損失の合計値
        loss_sum = 0.0
        for i, (x, y) in enumerate(train_loader):
            x = x.to(device)
            y = y.to(device)
            # 訓練する
            output = model(x)
            # 損失値(予測結果と正解データとの間の誤差)を計算する
            loss = criterion(output.view(-1, output.size(2)), y.view(-1))
            # 勾配を初期化する
            optimizer.zero_grad()
            # 勾配を計算する
            loss.backward()
            # 予測結果と正解データとの誤差 (損失) を小さくするように、モデルのパラメータを調整する
            optimizer.step()
            # 損失の合計値を更新する
            loss_sum += loss.item() * len(y)

            if i >= 1 and i % (1000 // BATCH_SIZE) == 0:
                print(f'{i * BATCH_SIZE}, Loss: {loss_sum / ((i + 1) * BATCH_SIZE):.4f}')

        epoch_loss = loss_sum / len(x_train)
        # 学習率を取得する
        lr = optimizer.param_groups[0]['lr']

        # TensorBoard 用ログを書き込む
        writer.add_scalar('Loss', loss_sum / len(x_train), epoch + 1)
        writer.add_scalar('Learning Rate', lr, epoch + 1)
        writer.flush()

        # 学習率を更新する(スケジューラによって引数が異なる)
        scheduler.step(epoch_loss)
        # この値が、スケジューラの threshold 値を下回ると、スケジューラが学習率が変更する
        print(f'rel_change: {(epoch_loss_old - epoch_loss) / epoch_loss_old}')
        epoch_loss_old = epoch_loss

        # モデルの保存
        outfile = f'lstm-layer2-bidirect-epoch{epoch + 1:02d}.model'
        torch.save({
            'epoch': epoch + 1,
            'model_state_dict': model.state_dict(),
            'optimizer_state_dict': optimizer.state_dict(),
            'scheduler_state_dict': scheduler.state_dict(),
        }, outfile)
        elapsed_time = time.time() - epoch_start_time
        print(
            f'Epoch: {epoch + 1}, Loss: {loss_sum / len(x_train):.4f}, Learning Rate: {lr:.6f}, Time: {elapsed_time:.2f} s')


if __name__ == '__main__':
    start_time = time.time()
    main()
    print(f'経過時間: {time.time() - start_time:.2f} s')

以下のような実行結果となった。 学習は、T4 GPU で行っている。 双方向化すると、学習時間が倍程度になる。

Epoch: 1, Loss: 0.8890, Learning Rate: 1.000000, Time: 30.97 s
Epoch: 2, Loss: 0.3174, Learning Rate: 1.000000, Time: 31.21 s
Epoch: 3, Loss: 0.2007, Learning Rate: 1.000000, Time: 33.28 s
Epoch: 4, Loss: 0.1483, Learning Rate: 1.000000, Time: 33.60 s
Epoch: 5, Loss: 0.1189, Learning Rate: 1.000000, Time: 33.17 s
Epoch: 6, Loss: 0.0974, Learning Rate: 0.900000, Time: 33.59 s
Epoch: 7, Loss: 0.0850, Learning Rate: 0.900000, Time: 33.46 s
Epoch: 8, Loss: 0.0740, Learning Rate: 0.810000, Time: 33.63 s
Epoch: 9, Loss: 0.0668, Learning Rate: 0.810000, Time: 33.62 s
Epoch: 10, Loss: 0.0595, Learning Rate: 0.729000, Time: 33.60 s

推論をする

変更は、学習時と同様に、 PosTaggerLSTM の引数に num_layers=2bidirectional=True を追加することと、 保存するモデルファイル名を変えている。

infer_layers_bidirect.py

import bz2
import logging

import numpy as np
import time
import torch
from torch.utils.data import TensorDataset, DataLoader

from pos_tagger_lstm import PosTaggerLSTM

# ログ設定
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)

# 処理済みの単語を重複なく保持する入出力ファイル名
WORD_FILE = 'lstm-word.txt.bz2'
# 処理済みの品詞を重複なく保持する入出力ファイル名
POS_FILE = 'lstm-pos.txt.bz2'

# テスト用訓練データ出力ファイル名
X_TEST_FILE = 'lstm-optim-x-test.npy'
# テスト用教師データ出力ファイル名
Y_TEST_FILE = 'lstm-optim-y-test.npy'

# モデルファイル名
MODEL_FILE = 'lstm-layer2-bidirect-epoch10.model'

# バッチサイズ
BATCH_SIZE = 128


def main():
    """
    LSTM モデル推論を行う
    """
    print('Loading datas...')
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    # 単語 ID 辞書を読み込む
    with bz2.open(WORD_FILE, 'rt', encoding='utf-8') as f:
        word_ids = {line.split('\t')[0]: int(line.split('\t')[1]) for line in f}
    # 品詞 ID 辞書を読み込む
    with bz2.open(POS_FILE, 'rt', encoding='utf-8') as f:
        pos_ids = {line.split('\t')[0]: int(line.split('\t')[1]) for line in f}

    # テスト用訓練データファイルを読み込む
    x_test = np.load(X_TEST_FILE)
    x_test_tensor = torch.from_numpy(x_test)
    # テスト用教師データファイルを読み込む
    y_test = np.load(Y_TEST_FILE)
    y_test_tensor = torch.from_numpy(y_test)

    train_dataset = TensorDataset(x_test_tensor, y_test_tensor)
    train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=False, pin_memory=True)

    model = PosTaggerLSTM(len(word_ids) + 1, len(pos_ids), 100, num_layers=2, bidirectional=True).to(device)
    checkpoint = torch.load(MODEL_FILE, weights_only=True, map_location=device)
    model.load_state_dict(checkpoint['model_state_dict'])

    data_count = 0
    model.eval()
    print('Start inference...')
    with torch.no_grad():
        start_time = time.time()
        ok = 0
        for x, y in train_loader:
            x = x.to(device)
            y = y.to(device)
            output = model(x)
            # 推論結果から、回答を取り出す
            ans = torch.argmax(output, dim=2)
            # 教師データから、パディング値を除外するための配列を作成する(パディング値なら False となる配列)
            mask = y != -1
            # 回答から、正解でかつ、パディングデータでないものを抽出する
            # * mask の部分は、AND 条件と判定される。
            ok += ((ans == y) * mask).sum().item()
            # mask の値が、True の項目合計数を求める
            data_count += mask.sum().item()
        print(f'OK: {ok}, Total: {data_count}, Accuracy: {ok / data_count:.4f}')
        elapsed_time = time.time() - start_time
        print(f'経過時間: {elapsed_time:.2f} s ({data_count / elapsed_time:.0f} times/s)')


if __name__ == '__main__':
    main()

以下のような実行結果となった。

CPU で実行した場合

OK: 428196, Total: 440767, Accuracy: 0.9715
経過時間: 16.66 s (26464 times/s)

T4 GPU で実行した場合

OK: 428196, Total: 440767, Accuracy: 0.9715
経過時間: 4.96 s (88865 times/s)

まとめ

これまで、学習したモデルの正解率を図にしてみる。 レイヤー二層化と、双方向化をしたケースが、最も正解率が高い結果となった。 全体的に、エポック 10 で、正解率が下がっているので、過学習気味なのかもしれない。

2468100.860.880.90.920.940.960.98modelsimpleoptimlayer_2layer_2+bidirect正解率epochaccuracy

推論に掛かる時間は、simple の場合は、5 秒程度で、 二層化+双方向化で、18 秒程度に増加している。 一回の推論は、十分高速であるが、改善の可能性を検討してみたい。 次の記事では推論の効率化を体験してみる。