【コード解説・PyTorch】手話認識入門8 - シンプルなAttention層の適用

著者: Natsuki Takayama
作成日: 2024年03月12日(火) 00:00
最終更新日: 2024年03月21日(木) 14:55
カテゴリ: コンピュータビジョン

こんにちは.高山です.
先日の記事で告知しました手話入門記事の第八回になります.
第五回第六回第七回とRecurrent Neural Network (RNN) を用いた孤立手話単語認識モデルを紹介してきました.
今回からTransformerを用いた孤立手話単語認識モデルを紹介する予定だったのですが,先にAttentionについて説明した方が良いかなと思い予定を変更しました.

Attentionは特徴量の重要な箇所に重み付けをする手法で,これまでに様々な手法が提案されています.
文献[Bahdanau'15, Luong'15]で提案された手法が特に有名です.
これらの手法は翻訳タスクで提案されており,手話翻訳でも応用例があります[Camgoz'18].

翻訳タスク向けのAttentionはEncoder-Decoderというアーキテクチャに基づいており,少し複雑です.
今回は,"特徴量の重み付け" という部分に注力したいので,よりシンプルなAttention層を使って認識モデルの挙動を見ていきます.

Encoder-Decoderアーキテクチャについては,また別記事で取り上げたいと思います.
(なお,Encoder-Decoderアーキテクチャを孤立手話単語認識に用いることは問題なく可能です)

今回解説するスクリプトはGitHub上に公開しています
色々な実験を行っている都合で,CPUで動かした場合は結構時間がかるのでご注意ください.

  • [Bahdanau'15]: D. Bahdanau, et al., "Neural Machine Translation by Jointly Learning to Align and Translate," ICLR 2015.
  • [Luong'15]: M.-T. Luong, et al., "Effective Approaches to Attention-based Neural Machine Translation," EMNLP 2015.
  • [Camgoz'18]: N. C. Camgoz, et al., "Neural Sign Language Translation," CVPR 2018.

1. 概要

1.1 今回説明する内容

実装の詳細に先立って,今回紹介する内容の概要を説明したいと思います.
図1は,先日の記事で説明した機械学習モデル構築のワークフローの何処が今回の説明箇所に該当するかを示しています.

図1: 学習モデル構築のワークフローと紹介箇所
学習モデル構築のワークフローと紹介箇所

今回は認識モデルを改良するので,モデルの学習・評価処理に該当します.

第五回の記事では,SRNN層を用いたモデルを紹介しました.
図2に示すように今回は,このモデルにAttention層を加えて認識モデルの挙動を見ていきます.

図2: Attention層を追加したSRNN認識モデル
Attention層を追加したSRNN認識モデル
  • [Amershi'19]: S. Amershi, et al., "Software Engineering for Machine Learning: A Case Study," IEEE/ACM ICSE-SEIP 2019.

1.2 シンプルなアテンション層

図3に,今回用いるアテンション層の処理構成を示します.

図3: シンプルなアテンション層
シンプルなアテンション層

基本的な構成は,SRNNの出力特徴系列 \(\boldsymbol{H}\) から重み系列 \(\boldsymbol{A}\) を算出し,重み付けした特徴系列 \(\boldsymbol{H} \circ \boldsymbol{A}\) を返すという処理になっています.

重み系列を算出するためにまず,線形変換を適用して次元圧縮を行います.
次元圧縮後は特徴次元が1になります.

次に,次元圧縮後の重み系列をスケーリングします.
スケーリングのやり方は色々な考え方があると思いますが,下記の2種類は比較的よく使われます.

  • Sigmoid関数: \(a_t \in [0, 1], 0 \leq \sum_t a_t \leq T\)
  • Softmax関数: \(a_t \in [0, 1], \sum_t a_t = 1\)

Sigmoid関数,Softmax関数共に,各フレームに対して \(0 \leq a_t \leq 1\) となる重みを生成します.
Sigmoid関数はフレーム毎に独立して重みを計算します.
この処理はLSTMやGRU内部で使われているゲートと同じで,重要でないフレームの重みを下げるように働きます.

Softmax関数は,全体の重みの合計が1になるように正規化します.
正規化後の値はフレーム間の相対的な重要度になるため,全体を考慮した重みつけが可能になります.
ただし,正規化後は値のスケール感が大きく変わるため,重みの総和が変わらないように後処理で再度スケーリングする場合もあります.


1.3 先に結果

2節以降では,いつも通り実装の紹介をしながら実験結果をお見せします.
コード紹介記事の方針として記事単体で全処理が分かるように書いており,少し冗長な展開が続きますので結果を先にお見せしたいと思います.

図4は,Attention層の種別毎のValidation Lossと認識率の推移を示しています.

図4: 認識性能比較結果: Attention層の追加
認識性能比較結果: Attention層追加

横軸は学習・評価ループの繰り返し数 (Epoch) を示します.
縦軸はそれぞれの評価指標を示します.

各線の色と実験条件の関係は次のとおりです.

  • 青線 (Default): Stacked - Bidirectional RNN
  • 橙線 (+ Sigmoid): Stacked - Bidirectional RNN + Attention (Sigmoid関数を適用)
  • 緑線 (+ Sigmoid with PS): Stacked - Bidirectional RNN + Attention (Sigmoid関数を適用後に再度スケール)
  • 赤線 (+ Softmax): Stacked - Bidirectional RNN + Attention (Softmax関数を適用)
  • 紫線 (+ Softmax with PS): Stacked - Bidirectional RNN + Attention (Softmax関数を適用後に再度スケール)

かなり微妙ですが...,Sigmoid関数を利用した場合に少しだけ認識性能が向上しているように見えます(^^;).
一方で,Softmaxを適用した場合は明確に認識性能が落ちていることが分かります.
後処理で再度スケーリングを行う方法は今回の実験では効果が見られませんでしたが,他の実験条件では性能が改善する場合もありました.
挙動が複雑になって学習が不安定になってしまったのが原因と思われます.

なお,今回の実験では話を簡単にするために,実験条件以外のパラメータは固定にし,乱数の制御もしていません.
必ずしも同様の結果になるわけではないので,ご了承ください.

他の細かな点については,後半に説明します.


2. 前準備

2.1 データセットのダウンロード

ここからは実装方法の説明をしていきます.
まずは,前準備としてGoogle Colabにデータセットをアップロードします.

以前までは,gdown を用いてダウンロードしていたのですが,このやり方ですと多数の方がアクセスした際にトラブルになるようなので (多数のご利用ありがとうございます!),セットアップの方法を少し変えました.

まず最初に,データセットの格納先からデータをダウンロードし,ご自分のGoogle driveへアップロードしてください.

次のコードでGoogle driveをColabへマウントします.
Google Driveのマウント方法については,補足記事にも記載してあります.

1
2
3
from google.colab import drive

drive.mount("/content/drive")

ドライブ内のファイルをColabへコピーします.
パスはアップロード先を設定する必要があります.

# Copy to local.
!cp [path_to_dataset]/gislr_dataset_top10.zip gislr_top10.zip

データセットはZIP形式になっているので unzip コマンドで解凍します.

!unzip gislr_top10.zip
Archive:  gislr_top10.zip
   creating: dataset_top10/
  inflating: dataset_top10/16069.hdf5
  ...
  inflating: dataset_top10/sign_to_prediction_index_map.json

成功すると dataset_top10 以下にデータが解凍されます.
HDF5ファイルはデータ本体で,手話者毎にファイルが別れています.
JSONファイルは辞書ファイルで,TXTファイルは本データセットのライセンスです.

!ls dataset_top10
16069.hdf5  25571.hdf5  29302.hdf5  36257.hdf5  49445.hdf5  62590.hdf5
18796.hdf5  26734.hdf5  30680.hdf5  37055.hdf5  53618.hdf5  LICENSE.txt
2044.hdf5   27610.hdf5  32319.hdf5  37779.hdf5  55372.hdf5  sign_to_prediction_index_map.json
22343.hdf5  28656.hdf5  34503.hdf5  4718.hdf5   61333.hdf5

単語辞書には単語名と数値の関係が10単語分定義されています.

!cat dataset_top10/sign_to_prediction_index_map.json
{
    "listen": 0,
    "look": 1,
    "shhh": 2,
    "donkey": 3,
    "mouse": 4,
    "duck": 5,
    "uncle": 6,
    "hear": 7,
    "pretend": 8,
    "cow": 9
}

ライセンスはオリジナルと同様に,CC-BY 4.0 としています.

!cat dataset_top10/LICENSE.txt
The dataset provided by Natsuki Takayama (Takayama Research and Development Office) is licensed under CC-BY 4.0.
Author: Copyright 2024 Natsuki Takayama
Title: GISLR Top 10 dataset
Original licenser: Deaf Professional Arts Network and the Georgia Institute of Technology
Modification
- Extract 10 most frequent words.
- Packaged into HDF5 format.

次のコードでサンプルを確認します.
サンプルは辞書型のようにキーバリュー形式で保存されており,下記のように階層化されています.

- サンプルID (トップ階層のKey)
  |- feature: 入力特徴量で `[C(=3), T, J(=543)]` 形状.C,T,Jは,それぞれ特徴次元,フレーム数,追跡点数です.
  |- token: 単語ラベル値で `[1]` 形状.0から9の数値です.
1
2
3
4
5
6
7
8
9
with h5py.File("dataset_top10/16069.hdf5", "r") as fread:
    keys = list(fread.keys())
    print(keys)
    group = fread[keys[0]]
    print(group.keys())
    feature = group["feature"][:]
    token = group["token"][:]
    print(feature.shape)
    print(token)
['1109479272', '11121526', ..., '976754415']
<KeysViewHDF5 ['feature', 'token']>
(3, 23, 543)
[1]

2.2 モジュールのダウンロード

次に,過去の記事で実装したコードをダウンロードします.
本項は前回までに紹介した内容と同じですので,飛ばしていただいても構いません. コードはGithubのsrc/modules_gislrにアップしてあります (今後の記事で使用するコードも含まれています).

まず,下記のコマンドでレポジトリをダウンロードします.
(目的のディレクトリだけダウンロードする方法はまだ調査中です(^^;))

!wget https://github.com/takayama-rado/trado_samples/archive/master.zip
--2024-01-21 11:01:47--  https://github.com/takayama-rado/trado_samples/archive/master.zip
Resolving github.com (github.com)... 140.82.112.3
...
2024-01-21 11:01:51 (19.4 MB/s) - ‘master.zip’ saved [75710869]

ダウンロードしたリポジトリを解凍します.

!unzip -o master.zip -d master
Archive:  master.zip
641b06a0ca7f5430a945a53b4825e22b5f3b8eb6
   creating: master/trado_samples-main/
  inflating: master/trado_samples-main/.gitignore
  ...

モジュールのディレクトリをカレントディレクトリに移動します.

!mv master/trado_samples-main/src/modules_gislr .

他のファイルは不要なので削除します.

!rm -rf master master.zip gislr_top10.zip
!ls
dataset_top10 drive modules_gislr  sample_data

2.3 モジュールのロード

主要な処理の実装に先立って,下記のコードでモジュールをロードします.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import copy
import json
import math
import os
import sys
from functools import partial
from pathlib import Path
from typing import (
    Any,
    Dict
)

# Third party's modules
import matplotlib.pyplot as plt

import numpy as np

import torch
from torch import nn
from torch.nn import functional as F
from torch.utils.data import (
    DataLoader)

from torchvision.transforms import Compose

# Local modules
sys.path.append("modules_gislr")
from modules_gislr.dataset import (
    HDF5Dataset,
    merge_padded_batch)
from modules_gislr.defines import (
    get_fullbody_landmarks
)
from modules_gislr.layers import (
    GPoolRecognitionHead,
    RNNEncoder
)
from modules_gislr.train_functions import (
    test_loop,
    val_loop,
    train_loop
)
from modules_gislr.transforms import (
    PartsBasedNormalization,
    ReplaceNan,
    SelectLandmarksAndFeature,
    ToTensor
)
【コード解説】
- 標準モジュール
  - copy: データコピーライブラリ.モデルへの入力データをコピーするために使用します.
  - json: JSONファイル制御ライブラリ.辞書ファイルのロードに使用します.
  - math: 数学計算処理ライブラリ
  - os: OS機能へのインターフェースライブラリ.CPUによる並列処理用スレッド数を決めるために使用します.
  - sys: Pythonインタプリタの制御ライブラリ.
    今回はローカルモジュールに対してパスを通すために使用します.
  - functools: 関数オブジェクトを操作するためのライブラリ.
    今回はDataLoaderクラスに渡すパディング関数に対して設定値をセットするために使用します.
  - pathlib.Path: オブジェクト指向のファイルシステム機能.
    主にファイルアクセスに使います.osモジュールを使っても同様の処理は可能です.
    高山の好みでこちらのモジュールを使っています(^^;).
  - typing: 関数などに型アノテーションを行う機能.
    ここでは型を忘れやすい関数に付けていますが,本来は全てアノテーションをした方が良いでしょう(^^;).
- 3rdパーティモジュール
  - matplotlib: グラフ描画ライブラリ
  - numpy: 行列演算ライブラリ
  - torch: ニューラルネットワークライブラリ
  - torchvision: PyTorchと親和性が高い画像処理ライブラリ.
    今回はDatasetクラスに与える前処理をパッケージするために用います.
- ローカルモジュール: sys.pathにパスを追加することでロード可能
  - dataset: データセット操作用モジュール
  - defines: 各部位の追跡点,追跡点間の接続関係,およびそれらへのアクセス処理を
    定義したモジュール
  - layers: ニューラルネットワークのモデルやレイヤモジュール
  - train_functions: 学習・評価処理モジュール
  - transforms: 入出力変換処理モジュール

3. 認識モデルの実装

3.1 Toy Attention層

ここから先は,認識モデルを実装していきます.
まず,今回実装するAttention層の挙動を確認するために,トイモデルを実装して出力を描画してみます.

次のコードは,Attentionの計算処理を実装しています.
線形変換層を共通にしたいので,引数として与えるようにしています.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def calc_attention(inputs, linear, attention_type, post_scale):
    if attention_type == "sigmoid":
        scale = torch.nn.Sigmoid()
    else:
        scale = torch.nn.Softmax(dim=1)

    attw = linear(inputs)
    attw = scale(attw).clone()

    if post_scale:
        tlength = inputs.shape[1]
        coef = tlength / attw.sum(dim=1, keepdims=True)
        attw = attw * coef
    attw = attw.detach().cpu().numpy()
    attw = attw.reshape([-1])
    return attw
【コード解説】
- 引数:
  - inputs: 入力特徴量
  - linear: 線形変換層.`[1, T, C] -> [1, T, 1]`
  - attention_type: Attentionの種別を指定.[sigmoid/softmax]
  - post_scale: Trueの場合,後処理で再度スケーリングを行う
- 2-5行目: スケーリングレイヤのインスタンス化
- 7-8行目: 線形変換とスケーリングの適用.`attw` をインライン処理するとBackward計算で
  エラーになる場合があったので,`clone()` で`attw` をコピーしています.
- 10-13行目: 再スケーリング処理.$tlengths = sum(attw)$ となるようにスケーリングしています.
- 14-16行目: 後の描画処理のために,1次元Numpy配列に変換して返す

次のコードでAttention層の出力をグラフに描画します.
グラフの縦軸を揃えて1枚の図に収めたかったので,各レイヤの出力とラベル値をまとめて渡すようにしています.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def plot_attention(params):
    attws = params["attws"]
    names = params["names"]

    xs = np.arange(1, attws[0].shape[0] + 1)
    ymax = max([attw.max() for attw in attws])

    for attw, name in zip(attws, names):
        plt.plot(xs, attw, marker=".", label=name)
        plt.xlabel("Frames")
        plt.ylabel("Attention weight")
        plt.ylim([0.0, ymax])
    plt.legend(loc="upper center", bbox_to_anchor=(0.5, 1.1), ncol=len(names))
    plt.show()

次のコードで実際に計算を行い,グラフを描画します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
inputs = torch.rand([1, 10, 5], dtype=torch.float32)
linear = torch.nn.Linear(inputs.shape[-1], 1)

attw_sig_wops = calc_attention(copy.deepcopy(inputs), linear, "sigmoid", False)
attw_sig_wps = calc_attention(copy.deepcopy(inputs), linear, "sigmoid", True)
attw_smax_wops = calc_attention(copy.deepcopy(inputs), linear, "softmax", False)
attw_smax_wps = calc_attention(copy.deepcopy(inputs), linear, "softmax", True)

print("=" * 80)
print("Sigmoid_WOPS")
print(attw_sig_wops)

print("=" * 80)
print("Sigmoid_WPS")
print(attw_sig_wps)

print("=" * 80)
print("Softmax_WOPS")
print(attw_smax_wops)

print("=" * 80)
print("Softmax_WPS")
print(attw_smax_wps)

params = {
    "attws": [
        attw_sig_wops,
        attw_sig_wps,
        attw_smax_wops,
        attw_smax_wps],
    "names": [
        "Sigmoid",
        "Sigmoid (with PS)",
        "Softmax",
        "Softmax (with PS)"]}

plot_attention(params)
================================================================================
Sigmoid_WOPS
[0.35091656 0.32962146 0.3631432  0.34693557 0.4110619  0.38649064
 0.4181261  0.3945444  0.38976938 0.3776075 ]
================================================================================
Sigmoid_WPS
[0.9312537  0.87474126 0.9637004  0.92068905 1.090866   1.0256593
 1.1096127  1.0470321  1.0343603  1.0020854 ]
================================================================================
Softmax_WOPS
[0.08895836 0.08090564 0.09382521 0.08741304 0.11484735 0.10365763
 0.11823928 0.10722524 0.10509865 0.09982968]
================================================================================
Softmax_WPS
[0.8895834  0.8090562  0.93825185 0.87413025 1.1484733  1.0365762
 1.1823926  1.0722522  1.0509863  0.9982966 ]

Colabファイル上ではここでグラフが描画されます.
本記事では,結果説明の節でまとめて紹介したいと思います.


3.2 Attention層

3.1項で説明したトイモデルを参考にして,時系列方向に対して重み付けを行うAttention層を実装します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class TemporalAttention(nn.Module):
    def __init__(self,
                 in_channels,
                 attention_type,
                 post_scale):
        super().__init__()
        assert attention_type in ["sigmoid", "softmax"]
        self.linear = nn.Linear(in_channels, 1)
        self.attention_type = attention_type

        if attention_type == "sigmoid":
            self.scale_layer = nn.Sigmoid()
        elif attention_type == "softmax":
            self.scale_layer = nn.Softmax(dim=1)

        self.neg_inf = None
        self.post_scale = post_scale

    def calc_attw(self, attw, mask):
        # Initialize masking value.
        if self.neg_inf is None:
            self.neg_inf = float(np.finfo(
                torch.tensor(0, dtype=attw.dtype).numpy().dtype).min)
        if mask is not None:
            attw = attw.masked_fill_(mask[:, :, None] == 0, self.neg_inf)
        attw = self.scale_layer(attw)
        if self.post_scale:
            if mask is None:
                tlength = torch.tensor(attw.shape[1], dtype=attw.dtype, device=attw.device)
                tlength = tlength.reshape([1, 1, 1])
            else:
                tlength = mask.sum(dim=1)
                tlength = tlength.reshape([-1, 1, 1])
            scale = tlength / attw.sum(dim=1, keepdims=True)
            attw = attw * scale
        return attw

    def forward(self, feature, mask=None):
        # `[N, T, C]`
        attw = self.linear(feature)
        attw = self.calc_attw(attw, mask)
        feature = attw * feature
        return feature, attw
【コード解説】
- 引数
  - in_channels: 入力特徴量の次元数
  - attention_type: Attention計算の種別を指定 [sigmoid/softmax]
  - post_scale: Trueの場合,後処理で再度スケーリングを行う
- 6-17行目: 初期化処理
- 19-36行目: Attention計算処理
  - 21-25行目: Padding箇所のマスキング.
    Sigmoid関数やSoftmax関数は通常のマスキング値 (0など) を有効な数値に変換してしまうので,
    計算後の重みが0になるように,負の最小値を設定し,Padding箇所に代入してます.
    入力の型をハードコーディングすると移植性が悪くなるので,最初の入力の時に判定する
    ように実装しています.
- 26行目: スケーリングレイヤの適用
- 27-35行目: 後処理の再スケーリングを適用
  - 28-30行目: マスクが `None` の場合(テスト時など),入力長をスケーリングに利用
  - 31-33行目: マスクが入力された場合は,Padding箇所を除いた長さをスケーリングに利用
  - 34-35行目: 重みの合計値が `tlength` になるようにスケーリング
- 38-43行目: 推論処理

3.3 認識モデル

次に,認識モデル全体を実装します.

まず,次のコードでAttention層を使わない場合のプレースホルダーレイヤを実装します.
このようなレイヤを用意しておくと,推論処理でレイヤ種別毎に分岐をする必要が無くなるので,コードがシンプルになります.
同様のレイヤはPyTorchにありますが,複数の入力に対応していないため自前で実装しています.
入力をそのまま返すだけですので,説明は割愛させていただきます.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Identity(nn.Module):
    """Place holder layer to return identity vector.
    """
    # This design is on purpose.
    # pylint: disable=unused-argument
    def __init__(self, *args, **kwargs):
        super().__init__()

    def forward(self, feature, *args, **kwargs):
        """Perform forward computation.
        """
        return feature

次に,認識モデルにAttention層を追加して推論時に呼び出すように改良します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
class RNNISLR(nn.Module):
    def __init__(self,
                 in_channels,
                 hidden_channels,
                 out_channels,
                 rnn_type="lstm",
                 rnn_num_layers=1,
                 rnn_activation="tanh",
                 rnn_bidir=False,
                 rnn_dropout=0.1,
                 masking_type="both",
                 attention_type="none",
                 attention_post_scale=False):
        super().__init__()
        assert rnn_type in ["srnn", "lstm", "gru"]
        assert masking_type in ["none", "rnn", "head", "both"]
        assert attention_type in ["none", "sigmoid", "softmax"]

        self.linear = nn.Linear(in_channels, hidden_channels)
        self.activation = nn.ReLU()

        apply_mask = True if masking_type in ["rnn", "both"] else False
        self.rnn = RNNEncoder(
            in_channels=hidden_channels,
            out_channels=hidden_channels,
            rnn_type=rnn_type,
            num_layers=rnn_num_layers,
            activation=rnn_activation,
            bidir=rnn_bidir,
            dropout=rnn_dropout,
            apply_mask=apply_mask)

        if attention_type != "none":
            if rnn_bidir:
                self.att = TemporalAttention(hidden_channels * 2, attention_type,
                                             post_scale=attention_post_scale)
            else:
                self.att = TemporalAttention(hidden_channels, attention_type,
                                             post_scale=attention_post_scale)
        else:
            self.att = Identity()
        self.attw = None

        if rnn_bidir:
            self.head = GPoolRecognitionHead(hidden_channels * 2, out_channels)
        else:
            self.head = GPoolRecognitionHead(hidden_channels, out_channels)

        self.masking_type = masking_type

    def forward(self, feature, feature_pad_mask=None):
        # Feature extraction.
        # `[N, C, T, J] -> [N, T, C, J] -> [N, T, C*J] -> [N, T, C']`
        N, C, T, J = feature.shape
        feature = feature.permute([0, 2, 1, 3])
        feature = feature.reshape(N, T, -1)

        feature = self.linear(feature)
        feature = self.activation(feature)

        hidden_seqs, last_hstate = self.rnn(feature, feature_pad_mask)[:2]

        # Apply attention.
        hidden_seqs = self.att(hidden_seqs, feature_pad_mask)
        if isinstance(hidden_seqs, (tuple, list)):
            hidden_seqs, self.attw = hidden_seqs[0], hidden_seqs[1]

        # `[N, T, C'] -> [N, C', T]`
        feature = hidden_seqs.permute(0, 2, 1)

        if feature_pad_mask is not None and self.masking_type in ["head", "both"]:
            logit = self.head(feature, feature_pad_mask)
        else:
            logit = self.head(feature)
        return logit
【コード解説】
- 引数
  - in_channels: 入力特徴量の次元数
  - hidden_channels: RNN層の次元数.
    rnn_bidir=Trueの場合,内部では設定値の倍次元の特徴量を出力します.
  - out_channels: 出力特徴量の次元数.単語応答値を出力したいので,全単語数と同じにします.
  - rnn_type: RNN層の種別を指定 [srnn/lstm/gru]
  - rnn_num_layers: RNN層の数
  - rnn_activation: RNN層内の活性化関数.
    ["tanh"/"relu"]で指定します.
  - rnn_bidir: Trueの場合,Bidirectional RNNを使用
  - rnn_dropout: Dropoutレイヤの欠落率
  - masking_type: 指定に応じて,マスキングを適用します.
    - none: マスキングは行わない
    - rnn: RNN層だけマスキングを行う
    - head: 出力層だけマスキングを行う
    - both: RNN層と出力層でマスキングを行う
  - attention_type: Attention層の種別を指定 [none, sigmoid, softmax]
  - attention_post_scale: Trueの場合,Attention層内で再スケーリングを行う
- 14-49行目: 初期化処理
  - 22-31行目: RNN層でマスキングを行う場合は,`apply_mask = True` として,`RNNEncoder` に渡します.
  - 33-39行目: `attention_type` の設定に応じて,レイヤを作成します.
    また,`rnn_bidir` の設定に応じて,入力次元を調整しています.
  - 44-47行目: `rnn_bidir` の設定に応じて,出力層の入力次元を調整しています.
- 51-75行目: 推論処理
  - 64-66行目: Attention層を適用
    Identityレイヤを適用した場合は,戻り値は `feature` になっています.
    一方,Attention層を適用した場合は,戻り値が`(feature, attw)` のリストに
    なっているので分離して合わせます.
    また,Attention出力の確認用に `self.attw` に重みを保持するようにしています.
  - 71-72行目: 出力層でマスキングを行う場合は,`feature_pad_mask` を渡します.

3.4 動作チェック

認識モデルの実装ができましたので,動作確認をしていきます.
次のコードでデータセットからHDF5ファイルとJSONファイルのパスを読み込みます.

1
2
3
4
5
6
7
8
# Access check.
dataset_dir = Path("dataset_top10")
files = list(dataset_dir.iterdir())
dictionary = [fin for fin in files if ".json" in fin.name][0]
hdf5_files = [fin for fin in files if ".hdf5" in fin.name]

print(dictionary)
print(hdf5_files)
dataset_top10/sign_to_prediction_index_map.json
[PosixPath('dataset_top10/49445.hdf5'), ..., PosixPath('dataset_top10/62590.hdf5')]

次のコードで辞書ファイルをロードして,認識対象の単語数を格納します.

1
2
3
4
5
# Load dictionary.
with open(dictionary, "r") as fread:
    key2token = json.load(fread)

VOCAB = len(key2token)

次のコードで前処理を定義します.
今回は,以前に説明した追跡点の選定と,追跡点の正規化を前処理として適用して実験を行います.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
_, use_landmarks = get_fullbody_landmarks()
use_features = ["x", "y"]
trans_select_feature = SelectLandmarksAndFeature(landmarks=use_landmarks, features=use_features)
trans_repnan = ReplaceNan()
trans_norm = PartsBasedNormalization(align_mode="framewise", scale_mode="unique")

pre_transforms = Compose([trans_select_feature,
                          trans_repnan,
                          trans_norm])
transforms = Compose([ToTensor()])

次のコードで,前処理を適用したHDF5DatasetとDataLoaderをインスタンス化し,データを取り出します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
batch_size = 2
feature_shape = (len(use_features), -1, len(use_landmarks))
token_shape = (1,)
merge_fn = partial(merge_padded_batch,
                   feature_shape=feature_shape,
                   token_shape=token_shape,
                   feature_padding_val=0.0,
                   token_padding_val=0)

dataset = HDF5Dataset(hdf5_files, pre_transforms=pre_transforms, transforms=transforms)

dataloader = DataLoader(dataset, batch_size=batch_size, collate_fn=merge_fn)
try:
    data = next(iter(dataloader))
    feature_origin = data["feature"]

    print(feature_origin.shape)
except Exception as inst:
    print(inst)
torch.Size([2, 2, 28, 130])

次のコードでモデルをインスタンス化して,動作チェックをします.
追跡点抽出の結果,入力追跡点数は130で,各追跡点はXY座標値を持っていますので,入力次元数は260になります.
出力次元数は単語数なので10になります.
また,SRNN層の次元数は64に設定しています.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Define model.
# in_channels: J * C (130*2=260)
#   J: use_landmarks (130)
#   C: use_channels (2)
# out_channels: 10
in_channels = len(use_landmarks) * len(use_features)
hidden_channels = 64
out_channels = VOCAB
rnn_type="srnn"
rnn_num_layers=2
rnn_bidir=True
rnn_dropout=0.1
masking_type="both"
attention_type = "sigmoid"
attention_post_scale = True

model = RNNISLR(in_channels=in_channels,
                hidden_channels=hidden_channels,
                out_channels=out_channels,
                rnn_type=rnn_type,
                rnn_num_layers=rnn_num_layers,
                rnn_bidir=rnn_bidir,
                masking_type=masking_type,
                attention_type=attention_type,
                attention_post_scale=attention_post_scale)
print(model)

# Sanity check.
logit = model(feature_origin)
print(logit.shape)
attw = model.attw.detach().cpu().numpy()
print(attw.max())
print(attw.sum())
RNNISLR(
  (linear): Linear(in_features=260, out_features=64, bias=True)
  (activation): ReLU()
  (rnn): RNNEncoder(
    (rnn): RNN(64, 64, num_layers=2, batch_first=True, dropout=0.1, bidirectional=True)
  )
  (att): TemporalAttention(
    (linear): Linear(in_features=128, out_features=1, bias=True)
    (scale_layer): Sigmoid()
  )
  (head): GPoolRecognitionHead(
    (head): Linear(in_features=128, out_features=10, bias=True)
  )
)
torch.Size([2, 10])
1.0563259
176.0

4. 学習と評価の実行

4.1 共通設定

では,実際に学習・評価を行います.

まずは,実験全体で共通して用いる設定値を次のコードで実装します.
今まではデータローディングのスレッド数をハードコーディングしていたのですが,今回からCPU数を調べて設定するようにしました (5行目).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Set common parameters.
batch_size = 256 if torch.cuda.is_available() else 32
load_into_ram = True
test_pid = 16069
num_workers = os.cpu_count()
print(f"Using {num_workers} cores for data loading.")
lr = 3e-4

epochs = 50
eval_every_n_epochs = 1
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using {device} for computation.")

train_hdf5files = [fin for fin in hdf5_files if str(test_pid) not in fin.name]
val_hdf5files = [fin for fin in hdf5_files if str(test_pid) in fin.name]
test_hdf5files = [fin for fin in hdf5_files if str(test_pid) in fin.name]

_, use_landmarks = get_fullbody_landmarks()
use_features = ["x", "y"]
Using cpu for computation.Using 2 cores for data loading.
Using cpu for computation.

次のコードで学習・バリデーション・評価処理それぞれのためのDataLoaderクラスを作成します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Build dataloaders.
train_dataset = HDF5Dataset(train_hdf5files, pre_transforms=pre_transforms,
    transforms=transforms, load_into_ram=load_into_ram)
val_dataset = HDF5Dataset(val_hdf5files, pre_transforms=pre_transforms,
    transforms=transforms, load_into_ram=load_into_ram)
test_dataset = HDF5Dataset(test_hdf5files, pre_transforms=pre_transforms,
    transforms=transforms, load_into_ram=load_into_ram)

train_dataloader = DataLoader(train_dataset, batch_size=batch_size, collate_fn=merge_fn, num_workers=num_workers, shuffle=True)
val_dataloader = DataLoader(val_dataset, batch_size=batch_size, collate_fn=merge_fn, num_workers=num_workers, shuffle=False)
test_dataloader = DataLoader(test_dataset, batch_size=1, collate_fn=merge_fn, num_workers=num_workers, shuffle=False)

4.2 学習・評価の実行

次のコードでモデルをインスタンス化します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
rnn_type="srnn"
rnn_num_layers = 2
rnn_bidir = True
masking_type = "both"
attention_type = "none"
attention_post_scale = False

model_default = RNNISLR(in_channels=in_channels,
                    hidden_channels=hidden_channels,
                    out_channels=out_channels,
                    rnn_type=rnn_type,
                    rnn_num_layers=rnn_num_layers,
                    rnn_bidir=rnn_bidir,
                    masking_type=masking_type,
                    attention_type=attention_type,
                    attention_post_scale=attention_post_scale)
print(model_default)

loss_fn = nn.CrossEntropyLoss(reduction="mean")
optimizer = torch.optim.Adam(model_default.parameters(), lr=lr)
RNNISLR(
  (linear): Linear(in_features=260, out_features=64, bias=True)
  (activation): ReLU()
  (rnn): RNNEncoder(
    (rnn): RNN(64, 64, num_layers=2, batch_first=True, dropout=0.1, bidirectional=True)
  )
  (att): Identity()
  (head): GPoolRecognitionHead(
    (head): Linear(in_features=128, out_features=10, bias=True)
  )
)

次のコードで学習・評価処理を行います.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Train, validation, and evaluation.
model_default.to(device)

train_losses = []
val_losses = []
test_accs = []
print("Start training.")
for epoch in range(epochs):
    print("-" * 80)
    print(f"Epoch {epoch+1}")

    train_losses = train_loop(train_dataloader, model_default, loss_fn, optimizer, device)
    val_loss = val_loop(val_dataloader, model_default, loss_fn, device)
    val_losses.append(val_loss)

    if (epoch+1) % eval_every_n_epochs == 0:
        acc = test_loop(test_dataloader, model_default, device)
        test_accs.append(acc)
train_losses_default = np.array(train_losses)
val_losses_default = np.array(val_losses)
test_accs_default = np.array(test_accs)

print(f"Minimum validation loss:{val_losses_default.min()} at {np.argmin(val_losses_default)+1} epoch.")
print(f"Maximum accuracy:{test_accs_default.max()} at {np.argmax(test_accs_default)*eval_every_n_epochs+1} epoch.")
Start training.
--------------------------------------------------------------------------------
Epoch 1
Start training.
loss:2.433032 [    0/ 3881]
loss:1.738605 [ 3200/ 3881]
Done. Time:33.71714749399999
Training performance: 
 Avg loss:1.871103

Start validation.
Done. Time:0.8706382180000105
Validation performance: 
 Avg loss:1.700689

Start evaluation.
Done. Time:2.578130967999982
Test performance: 
 Accuracy:45.5%
--------------------------------------------------------------------------------
...
--------------------------------------------------------------------------------
Epoch 50
Start training.
loss:0.151472 [    0/ 3881]
loss:0.459793 [ 3200/ 3881]
Done. Time:36.00044210999977
Training performance: 
 Avg loss:0.201471

Start validation.
Done. Time:0.8019000720000804
Validation performance: 
 Avg loss:0.820689

Start evaluation.
Done. Time:2.526682146999974
Test performance: 
 Accuracy:75.5%
Minimum validation loss:0.6299299086843219 at 13 epoch.
Maximum accuracy:79.0 at 13 epoch.

以後,同様の処理をAttention層の種別毎に繰り返します.
コード構成は同じですので,ここでは説明を割愛させていただきます.


4.3 実験結果

認識性能比較結果

レイヤ構成毎の認識結果を図5に示します.
こちらは,図4の再掲図です.

図5 (再掲): 認識性能比較結果: Attention層の追加
認識性能比較結果: Attention層の追加

横軸は学習・評価ループの繰り返し数 (Epoch) を示します.
縦軸はそれぞれの評価指標を示します.

各線の色と実験条件の関係は次のとおりです.

  • 青線 (Default): Stacked - Bidirectional RNN
  • 橙線 (+ Sigmoid): Stacked - Bidirectional RNN + Attention (Sigmoid関数を適用)
  • 緑線 (+ Sigmoid with PS): Stacked - Bidirectional RNN + Attention (Sigmoid関数を適用後に再度スケール)
  • 赤線 (+ Softmax): Stacked - Bidirectional RNN + Attention (Softmax関数を適用)
  • 紫線 (+ Softmax with PS): Stacked - Bidirectional RNN + Attention (Softmax関数を適用後に再度スケール)

かなり微妙ですが...,Sigmoid関数を利用した場合に少しだけ認識性能が向上しているように見えます(^^;).
今回はAttentionの文脈でSigmoid関数を利用していますが,実態としてはゲートなので,LSTMの出力層に近い役割として機能していると考えられます.

Softmaxを適用した場合は明確に認識性能が落ちていることが分かります.
今回は出力層の直前にAttention層を配置する構成にしたので,値のスケール変化が認識率に大きく影響してしまったようです.

後処理で再度スケーリングを行う方法は,今回の実験では効果が見られませんでした.
GRUを利用してデータ数を増やした場合では認識性能の改善が見られましたので,挙動が複雑になって学習が不安定になってしまったのが原因と思われます.

Attention層の出力

最後に,図6にAttention層の出力を示します.

図6: Attention層の出力
Attention層の出力

図6(a) 3.1節で解説した,Toy Attention層の出力を示しています.
図6(b) は学習済みのAttention層に,実データを入力した場合の出力を示しています.

横軸はフレーム数を示し,縦軸はAttentionの出力値を示します.
各線の色と実験条件の関係は次のとおりです.

  • 青線 (Sigmoid): Scaling処理にSigmoid関数を使用
  • 橙線 (Sigmoid with PS): Scaling処理にSigmoid関数を使用し,さらに出力合計値がフレーム数になるように再スケール
  • 緑線 (Softmax): Scaling処理にSoftmax関数を使用
  • 赤線 (Softmax with PS): Scaling処理にSoftmax関数を使用し,さらに出力合計値がフレーム数になるように再スケール

Sigmoid関数を使用した場合は,不要成分を抑制するゲートとして働いています.
図6(b) の青線の挙動が分かりやすいです.

一方,Sigmoid関数適用後に再スケールをした場合は,重要な部分を強調 (1より高い値) し,そうでない部分を抑制 (1より低い値) するような重み付けを行います.
図6(a) の青線と橙線を比較すると分かりやすいです.
図6(b) の橙線ではより強調された重み付けをしている様子が示されています.
Attention後も出力合計値は変わらないようにしているため,このような出力になります.

Softmax関数を使用した場合も重み付けとして働きますが,この場合は出力合計値が1になるようにスケールされます.
図6(a), (b) 共に,緑線の出力は他の出力とスケール感が大きく異なることが分かります.

Softmax関数適用後に再スケールをした場合は,Sigmoid関数後のそれと同じような挙動になります.
図6(b) の橙線と赤線は微妙に挙動が異なりますが,これは線形変換レイヤが異なる影響が大きいです.


今回はシンプルなAttention層を適用して,特徴量の重み付け処理について解説しましたが如何でしたでしょうか?
最初はSoftmaxを適用したのですがそこで性能が伸びない結果となり,原因の調査と他の処理方法との比較等々をやり出す羽目になって時間がかかってしまいました(^^;).
(最近,こんな反省ばかりしてますね(^^;))

Attention層は最近では標準技術の一つになり,様々な手法が提案されています.
一見では特性や効果が分かりづらい手法もありますので,実際に使用する場合はAttentionの出力を確認できるような実装を行うと理解がしやすいと思います.

今回紹介した話が,これから手話認識を勉強してみようとお考えの方に何か参考になれば幸いです.