実践手話認識 - モデル開発編1: Macaron Netを用いた孤立手話単語認識モデル

Thumbnail image
This image is generated with ChatGPT-4 Omni, and edited by the author.
作成日: 2024年08月16日(金) 00:00
最終更新日: 2024年09月18日(水) 09:52
カテゴリ: 手話言語処理
タグ:  実践手話認識 孤立手話単語認識 Macaron Net Python

Macaron Net を用いた孤立手話単語認識モデルを紹介します.

こんにちは.高山です.
これまで様々な手話認識入門記事を紹介してきて,「一体どこまでが入門レベルなのだろう...?」と思ってしまい (^^;),タイトルを変えることにしました.

というわけで今回からは「実践手話認識 - モデル開発編」と題して記事を執筆していきたいと思います.
「実践」と言っても手話認識は技術として確立されているわけではないので,"より高度な技術や最新の研究に基づいた話題" を格好良く (^^;) 言ってみたかった,くらいにとらえてください.

さて,今回は Macaron Net [Lu'19] を用いた孤立手話単語認識モデルを実装する方法を紹介します.
Macaron Net は Transformer の構造を改良した手法で,簡単な改変で実装することができます.

実はMacaron Net の面白い点は実装ではなくて,Transformer の処理を "特徴空間内における多粒子の運動現象 (移流拡散現象) と見なして,近似精度の高いアーキテクチャを採用する"というアイデアにあります.
が,これを説明するのはかなり大変なので (というより数学部分を完全には理解できてないので(^^;)) 今回は実装と実験結果に注力して紹介します.

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

  • [Lu'19]: Y. Lu, et al., "Understanding and Improving Transformer From a Multi-Particle Dynamic System Point of View," arXiv:1906.02762, available here, 2019.

更新履歴 (大きな変更のみ記載しています)

  • 2024/09/17: タグを更新しました

1. 実験概要

図1に今回の実験内容を示します.

図1: 今回の実験内容
今回の実験内容

手話認識入門-第九回で紹介したPre-LN 構成の Transformer をベースとして,アーキテクチャを Macaron Net に変更して認識性能を比較します.
Transformer から Macaron Net への変更点は下記のとおりです.

  • Multi-Head Self-Attention (MHSA) \(\rightarrow\) Position-wise Feed Forward Network (PFFN) というブロック構成から,PFFN \(\rightarrow\) MHSA \(\rightarrow\) PFFN というブロック構成に変更する.
  • PFFN の Residual Connection (分岐との和) において,PFFN の出力に \(1/2\) をかけて足し合わせる.

その他の構成は Transformer と同様です.

2. 実験結果

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

2.1 頻度の多い10単語を学習させた結果

図2は,アーキテクチャ毎のValidation Lossと認識率の推移を示しています.

図2: 認識性能比較結果 (Top10)
認識性能比較結果 (Top10)

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

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

ロスの安定性,および認識率共に Macaron Net を用いた場合の方が良い結果になっています.

2.2 250単語を学習させた結果

データが少なくて学習が不安定になっている可能性がありますので,全データ (250単語) を学習させた場合の挙動を図5に示します.
なお,こちらの実験はメモリや処理時間の都合でColab上では実行が難しいので,ローカル環境で行いました.

データの分割方法やパラメータは10単語のときと同じです.
ただし,学習時間を短縮するためにバッチ数は256に設定しています.
また,手話認識入門の補足記事10と同様に,正規化層を切り替えたモデルも評価しました.

図2: 認識性能比較結果 (Full)
認識性能比較結果 (Full)

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

  • 青線 (Transformer-L): Pre-LN構成のTransformer
  • 橙線 (Macaron-Net-L): Macaron Net
  • 緑線 (Transformer-B): Transformerの正規化層を Batch Normalization (BN) 層に変更
  • 赤線 (Macaron-Net-B): Macaron Netの正規化層を BN層に変更
  • 紫線 (Macaron-Net-B(MHSA)): Macaron Net で MHSA ブロックの正規化層を BN層に変更

まずデフォルト設定の青線と橙線に着目すると,全データを学習させた結果ではMacaron Net を用いた場合の方が早く収束し,認識性能も僅かに良いようです.

次に,正規化層を BN層に変更したケース (緑線と赤線) では,Transformer は認識性能が向上しているのに対して,Macaron Net は劣化しています.
正規化層に対する挙動が大きく変わるのは面白いですね.

Macaron-Net-B(MHSA) は,Macaron Net の MHSA ブロックだけをBN層に変更したケースです.
Macaron-Net に対して正規化層の組み合わせを試した限りでは,この設定が一番良い性能になりました.
ただし,認識性能は Transformer-B と同程度なので,Macaron Net が明確に優れていると言うにはもう少し実験をする必要がありそうです.

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

3. 前準備

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

ここからは実装方法の説明をしていきます.
まずは,前準備としてGoogle Colabにデータセットをアップロードします. ここの工程はこれまでの記事と同じですので,既に行ったことのある方は第3.3項まで飛ばしていただいて構いません.

まず最初に,データセットの格納先からデータをダウンロードし,ご自分の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]

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

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

まず,下記のコマンドでレポジトリをダウンロードします.

!wget https://github.com/takayama-rado/trado_samples/archive/refs/tags/v0.2.zip -O master.zip
--2024-08-15 02:45:40--  https://github.com/takayama-rado/trado_samples/archive/refs/tags/v0.2.zip
...
2024-08-15 02:45:47 (12.8 MB/s) - ‘master.zip’ saved [79912224]

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

!unzip -o master.zip -d master
Archive:  master.zip
4907710e233440b5197bbae5462c3e135f8f8711
   creating: master/trado_samples-0.2/
  inflating: master/trado_samples-0.2/.gitignore
  ...

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

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

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

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

3.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
49
50
51
52
import copy
import json
import math
import os
import random
import sys
from functools import partial
from inspect import signature
from pathlib import Path
from typing import (
    Any,
    Dict,
    List
)

# Third party's modules
import cv2

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 (
    Identity,
    GPoolRecognitionHead,
    TransformerEnISLR
)
from modules_gislr.train_functions import (
    test_loop,
    val_loop,
    train_loop
)
from modules_gislr.transforms import (
    PartsBasedNormalization,
    ReplaceNan,
    SelectLandmarksAndFeature,
    ToTensor
)
【コード解説】
- 標準モジュール
  - copy: データコピーライブラリ.Macaron Netブロック内でEncoder層をコピーするために使用します.
  - json: JSONファイル制御ライブラリ.辞書ファイルのロードに使用します.
  - os: システム処理ライブラリ
  - sys: Pythonインタプリタの制御ライブラリ.
    今回はローカルモジュールに対してパスを通すために使用します.
  - functools: 関数オブジェクトを操作するためのライブラリ.
    今回はDataLoaderクラスに渡すパディング関数に対して設定値をセットするために使用します.
  - inspect.signature: オブジェクトの情報取得ライブラリ.
  - pathlib.Path: オブジェクト指向のファイルシステム機能.
    主にファイルアクセスに使います.osモジュールを使っても同様の処理は可能です.
    高山の好みでこちらのモジュールを使っています(^^;).
- 3rdパーティモジュール
  - numpy: 行列演算ライブラリ
  - torch: ニューラルネットワークライブラリ
  - torchvision: PyTorchと親和性が高い画像処理ライブラリ.
    今回はDatasetクラスに与える前処理をパッケージするために用います.
- ローカルモジュール: sys.pathにパスを追加することでロード可能
  - dataset: データセット操作用モジュール
  - defines: 各部位の追跡点,追跡点間の接続関係,およびそれらへのアクセス処理を
    定義したモジュール
  - layers: ニューラルネットワークのモデルやレイヤモジュール
  - transforms: 入出力変換処理モジュール
  - train_functions: 学習・評価処理モジュール
  - utils: 汎用処理関数モジュール

4. 認識モデルの実装

4.1 ユーティリティ関数

ここから先は,認識モデルを実装していきます.
まず最初に,正規化層の切り替えが簡単になるようにユーティリティ関数を追加します.

下記の関数でインスタンス化する正規化層を選択できるようにします.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def create_norm(norm_type, dim_model, eps=1e-5, add_bias=None):
    # The argument `bias` was added at v2.1.0.
    # So, we check whether LayerNorm has this.
    sig = signature(nn.LayerNorm)
    available_bias = bool("bias" in sig.parameters)
    if norm_type == "layer":
        if available_bias:
            norm = nn.LayerNorm(dim_model, eps=eps, bias=add_bias)
        else:
            norm = nn.LayerNorm(dim_model, eps=eps)
    elif norm_type == "batch":
        norm = nn.BatchNorm1d(dim_model, eps=eps)
    return norm

BN層と LN層では,想定している入力系列の形状が異なります.
下記の関数で正規化層の適用処理を整理しておくことで,余計な分岐を減らすことができます.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def apply_norm(norm_layer, feature):
    # `[N, T, C]`
    if isinstance(norm_layer, nn.LayerNorm):
        feature = norm_layer(feature)
    elif isinstance(norm_layer, nn.BatchNorm1d):
        # `[N, T, C] -> [N, C, T]`
        feature = feature.permute([0, 2, 1])
        feature = norm_layer(feature)
        # `[N, C, T] -> [N, T, C]`
        feature = feature.permute([0, 2, 1])
    return feature

ここでは BN層と LN層の実装例を示しましたが,分岐を追加していけば様々な正規化層を選択できるようになります.

4.2 Macaron Net encoder layer

次に下記のコードで,encoder層を実装します.

 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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
class MacaronNetEncoderLayer(nn.Module):
    def __init__(self,
                 dim_model,
                 num_heads,
                 dim_ffw,
                 dropout,
                 activation,
                 norm_type_sattn,
                 norm_type_ffw,
                 norm_eps,
                 add_bias):
        super().__init__()

        self.fc_factor = 0.5

        # =====================================================================
        # First half PFFN.
        # =====================================================================
        self.norm_ffw1 = create_norm(norm_type_ffw, dim_model, norm_eps, add_bias)
        self.ffw1 = PositionwiseFeedForward(
            dim_model=dim_model,
            dim_ffw=dim_ffw,
            dropout=dropout,
            activation=activation,
            add_bias=add_bias)
        # =====================================================================
        # MHA.
        # =====================================================================
        self.norm_sattn = create_norm(norm_type_sattn, dim_model, norm_eps, add_bias)
        self.self_attn = MultiheadAttention(
            key_dim=dim_model,
            query_dim=dim_model,
            att_dim=dim_model,
            out_dim=dim_model,
            num_heads=num_heads,
            dropout=dropout,
            add_bias=add_bias)
        # =====================================================================
        # Second half PFFN.
        # =====================================================================
        self.norm_ffw2 = create_norm(norm_type_ffw, dim_model, norm_eps, add_bias)
        self.ffw2 = PositionwiseFeedForward(
            dim_model=dim_model,
            dim_ffw=dim_ffw,
            dropout=dropout,
            activation=activation,
            add_bias=add_bias)

        self.dropout = nn.Dropout(p=dropout)

        # To store attention weights.
        self.attw = None

    def forward(self,
                feature,
                causal_mask=None,
                src_key_padding_mask=None):
        bsize, qlen = feature.shape[:2]
        if src_key_padding_mask is not None:
            san_mask = make_san_mask(src_key_padding_mask, causal_mask)
        elif causal_mask is not None:
            san_mask = causal_mask
        else:
            san_mask = None

        #################################################
        # First half PFFN.
        #################################################
        # `[N, qlen, dim_model]`
        residual = feature
        feature = apply_norm(self.norm_ffw1, feature)
        feature = self.ffw1(feature)
        feature = self.fc_factor * self.dropout(feature) + residual

        #################################################
        # MHA.
        #################################################
        # `[N, qlen, dim_model]`
        residual = feature
        feature = apply_norm(self.norm_sattn, feature)
        feature, self.attw = self.self_attn(
            key=feature,
            value=feature,
            query=feature,
            mask=san_mask)
        feature = self.dropout(feature) + residual

        #################################################
        # Second half PFFN.
        #################################################
        # `[N, qlen, dim_model]`
        residual = feature
        feature = apply_norm(self.norm_ffw2, feature)
        feature = self.ffw2(feature)
        feature = self.fc_factor * self.dropout(feature) + residual

        return feature
【コード解説】
- 引数
  - dim_model: 入力特徴量の次元数
  - num_heads: MHAのヘッド数
  - dim_ffw: PFFNの内部特徴次元数
  - dropout: Dropout層の欠落率
  - activation: 活性化関数の種別を指定 [relu/gelu/swish/silu/mish]
  - norm_type_sattn: MHSAブロックの正規化層種別を指定 [batch/layer]
  - norm_type_ffw: PFFNブロックの正規化層種別を指定 [batch/layer]
  - norm_eps: 正規化層内で0除算を避けるための定数
  - add_bias: Trueの場合,線形変換層と正規化層にバイアス項を適用.
    ただし,LN層がバイアス項に対応していない場合 (古いPyTorch) は無視します.
- 12-52行目: 初期化処理.
  - 14行目: Macaron Net では,PFFN ブロックで Residual Connection を足し合わせる
    際に係数をかけるので,ここで固定値を定義しています.
  - 19-52行目: 基本的には各層をインスタンス化するだけですが,`create_norm()`
    関数を用いて正規化層の種別を切り替えれるようにしています.
- 58-97行目: 推論処理.
  - 58-64行目: MHSA用のマスキング配列を作成
  - 70-73行目: 1段階目の PFFN ブロック適用処理.
    `apply_norm()` 関数を用いて正規化層の処理を抽象化しています.
    73行目で `fc_factor` がかけられている点に注意してください.
  - 79-86行目: MHSA ブロック 適用処理
  - 92-95行目: 2段階目の PFFN ブロック適用処理.

4.3 Macaron Net encoder block

次のコードで,Transformer-Encoderブロックを実装します.
Macaron Net は引数で渡すので,基本的には Transformer の実装がそのまま使えます.
(なので名前も変えていません)

最終段の正規化層の種別を指定する norm_type_tail 引数が加わっていること以外は,手話認識入門第九回と同様 (第5.5項をご参照ください) ですので,説明は割愛させていただきます.

 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
class TransformerEncoder(nn.Module):
    def __init__(self,
                 encoder_layer,
                 num_layers,
                 dim_model,
                 dropout_pe,
                 norm_type_tail,
                 norm_eps,
                 norm_first,
                 add_bias,
                 add_tailnorm):
        super().__init__()

        self.pos_encoder = PositionalEncoding(dim_model, dropout_pe)
        self.layers = nn.ModuleList([copy.deepcopy(encoder_layer) for _ in range(num_layers)])

        # Add LayerNorm at tail position.
        # This is applied only when norm_first is True because
        # post-normalization structure includes tail-normalization in encoder
        # layers.
        if add_tailnorm and norm_first:
            self.norm_tail = create_norm(norm_type_tail, dim_model, norm_eps, add_bias)
        else:
            self.norm_tail = Identity()

    def forward(self,
                feature,
                causal_mask,
                src_key_padding_mask):
        feature = self.pos_encoder(feature)
        for layer in self.layers:
            feature = layer(feature,
                            causal_mask,
                            src_key_padding_mask)
        feature = apply_norm(self.norm_tail, feature)
        return feature

4.4 認識モデル

次のコードで,認識モデル全体を実装します.
基本的には手話認識入門第九回と同様 (第5.6項をご参照ください) です.
ただし,正規化層の種別を指定する tren_norm_type_sattn, tren_norm_type_ffw, tren_norm_type_tail が引数に加わり,tren_norm_firstは引数から除外されています (Pre-LN構成固定なので).
また,39-48行目で MacaronNetEncoderLayer を呼び出している点に注意してください.

  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
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
class MacaronNetEnISLR(nn.Module):
    def __init__(self,
                 in_channels,
                 inter_channels,
                 out_channels,
                 activation="relu",
                 pooling_type="none",
                 tren_num_layers=1,
                 tren_num_heads=1,
                 tren_dim_ffw=256,
                 tren_dropout_pe=0.1,
                 tren_dropout=0.1,
                 tren_norm_type_sattn="layer",
                 tren_norm_type_ffw="layer",
                 tren_norm_type_tail="layer",
                 tren_norm_eps=1e-5,
                 tren_add_bias=True,
                 tren_add_tailnorm=True):
        super().__init__()

        # Feature extraction.
        self.linear = nn.Linear(in_channels, inter_channels)
        self.activation = select_reluwise_activation(activation)

        if pooling_type == "none":
            self.pooling = Identity()
        elif pooling_type == "average":
            self.pooling = nn.AvgPool2d(
                kernel_size=[2, 1],
                stride=[2, 1],
                padding=0)
        elif pooling_type == "max":
            self.pooling = nn.MaxPool2d(
                kernel_size=[2, 1],
                stride=[2, 1],
                padding=0)

        # Transformer-Encoder with Macaron-Net architecture.
        enlayer = MacaronNetEncoderLayer(
            dim_model=inter_channels,
            num_heads=tren_num_heads,
            dim_ffw=tren_dim_ffw,
            dropout=tren_dropout,
            activation=activation,
            norm_type_sattn=tren_norm_type_sattn,
            norm_type_ffw=tren_norm_type_ffw,
            norm_eps=tren_norm_eps,
            add_bias=tren_add_bias)
        self.tr_encoder = TransformerEncoder(
            encoder_layer=enlayer,
            num_layers=tren_num_layers,
            dim_model=inter_channels,
            dropout_pe=tren_dropout_pe,
            norm_type_tail=tren_norm_type_tail,
            norm_eps=tren_norm_eps,
            norm_first=True,  # Fixed for Macaron-Net.
            add_bias=tren_add_bias,
            add_tailnorm=tren_add_tailnorm)

        self.head = GPoolRecognitionHead(inter_channels, out_channels)

    def forward(self,
                feature,
                feature_causal_mask=None,
                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)
        if torch.isnan(feature).any():
            raise ValueError()
        feature = self.activation(feature)
        if torch.isnan(feature).any():
            raise ValueError()

        # Apply pooling.
        feature = self.pooling(feature)
        if feature_pad_mask is not None:
            # Cast to apply pooling.
            feature_pad_mask = feature_pad_mask.to(feature.dtype)
            feature_pad_mask = self.pooling(feature_pad_mask.unsqueeze(-1)).squeeze(-1)
            # Binarization.
            # This removes averaged signals with padding.
            feature_pad_mask = feature_pad_mask > 0.5
            if feature_causal_mask is not None:
                feature_causal_mask = make_causal_mask(feature_pad_mask)

        feature = self.tr_encoder(
            feature=feature,
            causal_mask=feature_causal_mask,
            src_key_padding_mask=feature_pad_mask)
        if torch.isnan(feature).any():
            raise ValueError()

        # `[N, T, C] -> [N, C, T]`
        logit = self.head(feature.permute([0, 2, 1]), feature_pad_mask)
        if torch.isnan(feature).any():
            raise ValueError()
        return logit

4.5 動作チェック

認識モデルの実装ができましたので,動作確認をしていきます.
次のコードでデータセットから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/2044.hdf5'), PosixPath('dataset_top10/32319.hdf5'), PosixPath('dataset_top10/18796.hdf5'), PosixPath('dataset_top10/36257.hdf5'), PosixPath('dataset_top10/62590.hdf5'), PosixPath('dataset_top10/16069.hdf5'), PosixPath('dataset_top10/29302.hdf5'), PosixPath('dataset_top10/34503.hdf5'), PosixPath('dataset_top10/37055.hdf5'), PosixPath('dataset_top10/37779.hdf5'), PosixPath('dataset_top10/27610.hdf5'), PosixPath('dataset_top10/53618.hdf5'), PosixPath('dataset_top10/49445.hdf5'), PosixPath('dataset_top10/30680.hdf5'), PosixPath('dataset_top10/22343.hdf5'), PosixPath('dataset_top10/55372.hdf5'), PosixPath('dataset_top10/26734.hdf5'), PosixPath('dataset_top10/28656.hdf5'), PosixPath('dataset_top10/61333.hdf5'), PosixPath('dataset_top10/4718.hdf5'), PosixPath('dataset_top10/25571.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, 24, 130])

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

 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
# 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)
inter_channels = 64
out_channels = VOCAB
activation = "relu"
pooling_type = "none"
tren_num_layers = 2
tren_num_heads = 2
tren_dim_ffw = 256
tren_dropout_pe = 0.1
tren_dropout = 0.1
tren_norm_type_sattn = "layer"
tren_norm_type_ffw = "layer"
tren_norm_type_tail = "layer"
tren_norm_eps = 1e-5
tren_norm_first = True  # For Transformer.
tren_add_bias = True
tren_add_tailnorm = True

model = MacaronNetEnISLR(in_channels=in_channels,
                         inter_channels=inter_channels,
                         out_channels=out_channels,
                         activation=activation,
                         pooling_type=pooling_type,
                         tren_num_layers=tren_num_layers,
                         tren_num_heads=tren_num_heads,
                         tren_dim_ffw=tren_dim_ffw,
                         tren_dropout_pe=tren_dropout_pe,
                         tren_dropout=tren_dropout,
                         tren_norm_type_sattn=tren_norm_type_sattn,
                         tren_norm_type_ffw=tren_norm_type_ffw,
                         tren_norm_type_tail=tren_norm_type_tail,
                         tren_norm_eps=tren_norm_eps,
                         tren_add_bias=tren_add_bias,
                         tren_add_tailnorm=tren_add_tailnorm)
print(model)

# Sanity check.
logit = model(feature_origin)
print(logit.shape)
attw0 = model.tr_encoder.layers[0].attw.detach().cpu().numpy()
attw1 = model.tr_encoder.layers[0].attw.detach().cpu().numpy()
print(attw0.shape, attw1.shape)
MacaronNetEnISLR(
  (linear): Linear(in_features=260, out_features=64, bias=True)
  (activation): ReLU()
  (pooling): Identity()
  (tr_encoder): TransformerEncoder(
    (pos_encoder): PositionalEncoding(
      (dropout): Dropout(p=0.1, inplace=False)
    )
    (layers): ModuleList(
      (0-1): 2 x MacaronNetEncoderLayer(
        (norm_ffw1): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
        (ffw1): PositionwiseFeedForward(
          (w_1): Linear(in_features=64, out_features=256, bias=True)
          (w_2): Linear(in_features=256, out_features=64, bias=True)
          (dropout): Dropout(p=0.1, inplace=False)
          (activation): ReLU()
        )
        (norm_sattn): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
        (self_attn): MultiheadAttention(
          (w_key): Linear(in_features=64, out_features=64, bias=True)
          (w_value): Linear(in_features=64, out_features=64, bias=True)
          (w_query): Linear(in_features=64, out_features=64, bias=True)
          (w_out): Linear(in_features=64, out_features=64, bias=True)
          (dropout_attn): Dropout(p=0.1, inplace=False)
        )
        (norm_ffw2): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
        (ffw2): PositionwiseFeedForward(
          (w_1): Linear(in_features=64, out_features=256, bias=True)
          (w_2): Linear(in_features=256, out_features=64, bias=True)
          (dropout): Dropout(p=0.1, inplace=False)
          (activation): ReLU()
        )
        (dropout): Dropout(p=0.1, inplace=False)
      )
    )
    (norm_tail): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
  )
  (head): GPoolRecognitionHead(
    (head): Linear(in_features=64, out_features=10, bias=True)
  )
)
torch.Size([2, 10])
(2, 2, 24, 24) (2, 2, 24, 24)

5. 学習と評価の実行

5.1 共通設定

では,実際に学習・評価を行います.
まずは,実験全体で共通して用いる設定値を次のコードで実装します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Set common parameters.
batch_size = 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 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)

5.2 学習・評価の実行

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
model_macaron = MacaronNetEnISLR(
    in_channels=in_channels,
    inter_channels=inter_channels,
    out_channels=out_channels,
    activation=activation,
    pooling_type=pooling_type,
    tren_num_layers=tren_num_layers,
    tren_num_heads=tren_num_heads,
    tren_dim_ffw=tren_dim_ffw,
    tren_dropout_pe=tren_dropout_pe,
    tren_dropout=tren_dropout,
    tren_norm_type_sattn=tren_norm_type_sattn,
    tren_norm_type_ffw=tren_norm_type_ffw,
    tren_norm_type_tail=tren_norm_type_tail,
    tren_norm_eps=tren_norm_eps,
    tren_add_bias=tren_add_bias,
    tren_add_tailnorm=tren_add_tailnorm)
print(model_macaron)

loss_fn = nn.CrossEntropyLoss(reduction="mean")
optimizer = torch.optim.Adam(model_macaron.parameters(), lr=lr)
MacaronNetEnISLR(
  (linear): Linear(in_features=260, out_features=64, bias=True)
  (activation): ReLU()
  (pooling): Identity()
  (tr_encoder): TransformerEncoder(
    (pos_encoder): PositionalEncoding(
      (dropout): Dropout(p=0.1, inplace=False)
    )
    (layers): ModuleList(
      (0-1): 2 x MacaronNetEncoderLayer(
        (norm_ffw1): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
        (ffw1): PositionwiseFeedForward(
          (w_1): Linear(in_features=64, out_features=256, bias=True)
          (w_2): Linear(in_features=256, out_features=64, bias=True)
          (dropout): Dropout(p=0.1, inplace=False)
          (activation): ReLU()
        )
        (norm_sattn): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
        (self_attn): MultiheadAttention(
          (w_key): Linear(in_features=64, out_features=64, bias=True)
          (w_value): Linear(in_features=64, out_features=64, bias=True)
          (w_query): Linear(in_features=64, out_features=64, bias=True)
          (w_out): Linear(in_features=64, out_features=64, bias=True)
          (dropout_attn): Dropout(p=0.1, inplace=False)
        )
        (norm_ffw2): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
        (ffw2): PositionwiseFeedForward(
          (w_1): Linear(in_features=64, out_features=256, bias=True)
          (w_2): Linear(in_features=256, out_features=64, bias=True)
          (dropout): Dropout(p=0.1, inplace=False)
          (activation): ReLU()
        )
        (dropout): Dropout(p=0.1, inplace=False)
      )
    )
    (norm_tail): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
  )
  (head): GPoolRecognitionHead(
    (head): Linear(in_features=64, 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_macaron.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_macaron, loss_fn, optimizer, device)
    val_loss = val_loop(val_dataloader, model_macaron, loss_fn, device)
    val_losses.append(val_loss)

    if (epoch+1) % eval_every_n_epochs == 0:
        acc = test_loop(test_dataloader, model_macaron, device)
        test_accs.append(acc)
train_losses_macaron = np.array(train_losses)
val_losses_macaron = np.array(val_losses)
test_accs_macaron = np.array(test_accs)

print(f"Minimum validation loss:{val_losses_macaron.min()} at {np.argmin(val_losses_macaron)+1} epoch.")
print(f"Maximum accuracy:{test_accs_macaron.max()} at {np.argmax(test_accs_macaron)*eval_every_n_epochs+1} epoch.")
Start training.
--------------------------------------------------------------------------------
Epoch 1
Start training.
loss:3.077432 [    0/ 3881]
loss:2.068993 [ 3200/ 3881]
Done. Time:4.522592245999988
Training performance: 
 Avg loss:2.044730

Start validation.
Done. Time:0.25345869099999163
Validation performance: 
 Avg loss:2.031111

Start evaluation.
Done. Time:1.2937657340000044
Test performance: 
 Accuracy:29.5%
--------------------------------------------------------------------------------
...
--------------------------------------------------------------------------------
Epoch 50
Start training.
loss:0.105367 [    0/ 3881]
loss:0.137955 [ 3200/ 3881]
Done. Time:2.9604755469999873
Training performance: 
 Avg loss:0.173550

Start validation.
Done. Time:0.2568066980000481
Validation performance: 
 Avg loss:0.736782

Start evaluation.
Done. Time:1.2513119850000294
Test performance: 
 Accuracy:78.5%
Minimum validation loss:0.6523600816726685 at 27 epoch.
Maximum accuracy:82.5 at 40 epoch.

以後,同様の処理をアーキテクチャ毎に繰り返します.
コード構成は同じですので,ここでは説明を割愛させていただきます. また,この後グラフ等の描画も行っておりますが,本記事の主要点ではないため説明を割愛させていただきます.


今回は Macaron Net を用いた孤立手話単語認識モデルを紹介しましたが,如何でしたでしょうか?
記事冒頭で述べたとおり,Macaron Net の面白い点はアーキテクチャの改良内容よりもそこに至る考え方にあります.
今回は説明を割愛させていただきましたが,もう少し理解が進んだら (大学数学を勉強し直さなければなりませんが...(^^;)) そこも記事にできたらいいなと思います.

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