【コード解説・PyTorch】手話認識入門17 - 様々な改善手法8: 時系列リサイジングによる処理軽量化

作成日: 2024年07月06日(土) 00:00
最終更新日: 2024年07月06日(土) 22:41
カテゴリ: コンピュータビジョン
タグ:  手話認識 AI コンピュータビジョン

こんにちは.高山です.
先日の記事で告知しました手話入門記事の第十七回になります.

今回はこれまでと異なり,認識モデルの処理負荷軽減に主眼を置いた手法を紹介します.
具体的には,手話動画から抽出した追跡点系列を時間方向にリサイズすることで,処理を軽量化します.

手話認識のような時系列を扱うタスクでは様々な系列長のデータを扱わなければなりません.
図1に,KaggleのGoogle Isolated Sign Language Recognition (GISLR) データセットの時系列長ヒストグラムを示します.

図1: GISLR データセットの時系列長分布
GISLR データセットの時系列長分布

青の縦棒は時系列長が一定範囲に収まるデータの頻度示しており,橙線は累積頻度を示しています.
大部分のデータは1秒から2秒程度ですが,10秒以上のデータも含まれていることが分かります.
(通常は手話単語の表出でこのような時間はかからないので,多くの場合は撮影時のトラブルか,手話を始めるまでの待機時間が長いケースです)

推論にかかる処理時間や処理負荷は系列長に比例して増加します.
そのため,このような長いデータをそのまま処理してしまうと,データが入力されてから結果を得るまでの待ち時間 (レイテンシと言います) が長くなり,メモリ容量も増えるという問題が発生します.

実際のアプリケーション開発では,(認識性能は維持して) レイテンシや処理負荷はできるだけ小さくしたいというニーズがあります.
この課題への対処方法の一つとして,時間方向にデータをリサイズする方法が挙げられます.
実装方法は色々と考えられますが,今回は下記の2手法を紹介します.

  • 前処理: 線形補間で選択的にリサイズ
  • 内部処理: Strided feature extraction

手法については次節以降で説明します.

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

1. 時系列のリサイジング方法

1.1 線形補間で選択的にリサイズ

最初に紹介する手法は,入力データの時系列長に応じて,選択的にリサイズをする手法です.
この処理は前処理で行います.
図2に手法の概要を示します.

図2: 時系列のリサイジング1: 線形補間で選択的にリサイズ
時系列のリサイジング1: 線形補間で選択的にリサイズ

この処理では予め処理後の系列長の範囲を定めておきます.
短い入力は線形補間で伸長し,長い入力は同じく線形補間を用いて縮小します.
結果として,レイテンシおよび処理負荷を (ある程度) 制御することができます.

また,系列長が一定の範囲内に収まることで,認識モデルが対処すべき系列長が限定されるため,問題が単純化される場合があります.
"場合がある"と述べたのは,系列長のバリエーションが減る分,動作速度のバリエーションが増えるためです.
認識モデルがどちらの問題を上手く処理できるかはケースバイケースになります.

1.2 Strided feature extraction

次に紹介する手法は,特徴抽出を \(N\) フレーム事に行う手法です.
一般的な呼び名が無いので,本記事では Strided feature extraction と (勝手に) 呼ぶことにします(^^;).

図3に手法の概要を示します.

図3: 時系列のリサイジング2: Strided feature extraction
時系列のリサイジング2: Strided feature extraction

Neural network でよく使われる Pooling レイヤや,Convolution レイヤは,画素やフレームなどのようにメモリ上に連続して並んだ信号に対して処理を行います.
これらのレイヤには処理間隔を制御する stride というパラメータがあります.
(この設定のレイヤをStrided pooling や Strided convolution と呼ぶ場合もあります)

図3下側の例に示すように,stride を増やして \(N\) フレーム毎に処理をすると出力特徴量は小さくなるため,結果としてリサイズを同時に行うことができます.

これらのレイヤはモデルの入力層に近い場所 (特徴抽出層など) に配置されることが多いので,後段の処理を軽量化することができます.

また,追跡点データのように隣接フレーム間で特徴量が似ている場合は,冗長な特徴量を除去されるので特徴抽出の性能向上にも繋がります.

1.3 先に結果

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

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

図4は,リサイジング方法毎のValidation Lossと認識率の推移を示しています.

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

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

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

  • 青線 (Default): Pre-LN構成のTransformer
  • 橙線 (+ R-32): 前処理で 8 - 32 フレームの範囲にリサイズ
  • 緑線 (+ R-64): 前処理で 8 - 64 フレームの範囲にリサイズ
  • 赤線 (+ R-128): 前処理で 8 - 128 フレームの範囲にリサイズ
  • 紫線 (+ R-128 + P-A): 前処理で 8 - 128 フレームの範囲にリサイズ + Strided average pooling を適用
  • 茶線 (+ R-128 + P-M): 前処理で 8 - 128 フレームの範囲にリサイズ + Strided max pooling を適用

デフォルトのモデルには,第九回の記事で紹介した,Pre-LN構成のTransformerモデルを用います.

R-128 で少しロスが不安定になっていますが,認識性能はどれも差が無いように見えますね.
今回は処理の軽量化が主眼なので,認識性能が大きく悪化していないことが重要です.

250単語を学習させた結果

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

データの分割方法やパラメータは10単語のときと同じです.
ただし,学習時間を短縮するためにバッチ数は256に設定しています.
(本来はバッチ数を変えた場合は学習率も調整した方が良いのですが,今回はママで実験を行っています)

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

全データを学習させた結果では,ほとんど差がありませんでした.
(よく見るとR-128が少し良い感じです)

この結果から,今回のタスクとデータセットではリサイジングで処理を軽量化しても,認識性能への影響は軽微であることが言えます.

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

2. 前準備

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

ここからは実装方法の説明をしていきます.
まずは,前準備としてGoogle Colabにデータセットをアップロードします. ここの工程はこれまでの記事と同じですので,既に行ったことのある方は第2.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]

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 モジュールのロード

今回は処理負荷を見積もるために,下記のコードでまずライブラリをインストールします.

!pip3 install torchinfo

torchinfo は PyTorch のモデル情報を出力するためにライブラリで,モデルのパラメータ数や計算量 (和と積の数),メモリ容量を見積もることができます.
Colaboratory やデスクトップPC のような環境では,他の処理の影響で推論時間を正確に計測することが難しいです.
そこで今回は, torchinfo の出力を見て処理が軽量化できているかを見ていきます.

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

 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
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 numpy as np

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

from torchinfo import summary

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

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
class SelectiveResize():
    def __init__(self,
                 min_tlen=8,
                 max_tlen=None):
        assert min_tlen is not None or max_tlen is not None
        self.min_tlen = min_tlen
        self.max_tlen = max_tlen

    def __call__(self,
                 data: Dict[str, Any]) -> Dict[str, Any]:

        feature = data["feature"]
        orig_shape = feature.shape
        tlen = orig_shape[1]
        resize_mode = None
        if self.min_tlen and tlen < self.min_tlen:
            resize_mode = "enlarge"
        if self.max_tlen and tlen > self.max_tlen:
            resize_mode = "shrink"
        if resize_mode is None:
            return data

        # Apply warping.
        # `[C, T, J] -> [T, C*J]`
        feature = feature.transpose([1, 0, 2]).reshape([tlen, -1])

        assert len(feature.shape) == 2, f"{feature.shape}"
        aug_tlen = self.min_tlen if resize_mode == "enlarge" else self.max_tlen

        x = np.linspace(0, tlen-1, num=aug_tlen)
        xs = np.arange(tlen)
        newfeature = matrix_interp(x, xs, feature)
        # `[T, C*J] -> [T, C, J] -> [C, T, J]`
        newfeature = newfeature.reshape([-1, orig_shape[0], orig_shape[2]])
        newfeature = newfeature.transpose([1, 0, 2])

        data["feature"] = newfeature
        return data
【コード解説】
- 引数
  - min_tlen: 最小時間長.
    入力の時間長 < `min_tlen` の場合は,線形補間でデータを伸長します.
  - max_tlen: 最大時間長.
    入力の時間長 > `max_tlen` の場合は,線形補間でデータを縮小します..
- 5-7行目: 初期化処理
- 9-38行目: リサイズ処理
  - 12-21行目: 入力の時系列長を調べ,伸縮するか (enlarge),縮小するか (shrink),
    そのままか (return) を決める
  - 25行目: 線形補間の並列処理のために,特徴量の形状を `[C, T, J] -> [T, C*J]` に変更
  - 28行目: リサイズ後の時系列長を決定
  - 30-32行目: 線形補間の適用
  - 34-35行目: 特徴量形状を `[T, C*J] -> [C, T, J]` に戻す

4. Strided poolingの実装

次に,下記のコードで Transformer 認識モデルに Strided pooling を実装します.

 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
class TransformerEnISLR(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_layer_norm_eps=1e-5,
                 tren_norm_first=True,
                 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.
        enlayer = TransformerEncoderLayer(
                dim_model=inter_channels,
                num_heads=tren_num_heads,
                dim_ffw=tren_dim_ffw,
                dropout=tren_dropout,
                activation=activation,
                layer_norm_eps=tren_layer_norm_eps,
                norm_first=tren_norm_first,
                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,
            layer_norm_eps=tren_layer_norm_eps,
            norm_first=tren_norm_first,
            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

ベースの実装は第九回の記事で説明したコード (第3.6項を参照してください) を用いており,下記の点を拡張しています.

  • 引数: pooling_type を追加 (7行目)
  • Poolingレイヤの初期化 (23-34行目)
    pooling_type の値に応じてレイヤを初期化しています.
    インスタンス化引数に stride=[2, 1] を与えることで,Pooling処理は2フレームに1回行われることになります (出力系列長は半分になります).
  • Poolingレイヤの適用 (76-85行目)
    Transformer レイヤ用のマスク配列にもPooling処理を適用して,時系列長を揃えている点に注意してください.

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)

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

選択的リサイズ処理は動的な前処理として,transforms_resize_32 (13-15行目, 最大32フレーム),transforms_resize_64 (17-19行目, 最大64フレーム),transforms_resize_128 (21-23行目, 最大128フレーム) にそれぞれ定義しています.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
_, 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_default = Compose([ToTensor()])

transforms_resize_32 = Compose([
    SelectiveResize(min_tlen=8, max_tlen=32),
    ToTensor()])

transforms_resize_64 = Compose([
    SelectiveResize(min_tlen=8, max_tlen=64),
    ToTensor()])

transforms_resize_128 = Compose([
    SelectiveResize(min_tlen=8, max_tlen=128),
    ToTensor()])

次のコードで,前処理を適用したHDF5DatasetとDataLoaderをインスタンス化し,データを取り出します.
HDF5Dataset をインスタンス化する際に,pre_transformstransforms 引数に変数を渡してデータ拡張を有効にしています (11行目).

 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)

for trans in [transforms_default, transforms_resize_32, transforms_resize_64, transforms_resize_128]:
    dataset = HDF5Dataset(hdf5_files, pre_transforms=pre_transforms, transforms=trans)
    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, 232, 130])
torch.Size([2, 2, 32, 130])
torch.Size([2, 2, 64, 130])
torch.Size([2, 2, 128, 130])

適用したリサイズ処理に応じて,時系列長が変化していることが確認できます.

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

pooling_type が新しく加わっている点に注意してください.

 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
# 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"
tren_num_layers = 2
tren_num_heads = 2
tren_dim_ffw = 256
tren_dropout_pe = 0.1
tren_dropout = 0.1
tren_layer_norm_eps = 1e-5
tren_norm_first = True
tren_add_bias = True
tren_add_tailnorm = True

pooling_type = "none"

model = TransformerEnISLR(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_layer_norm_eps=tren_layer_norm_eps,
                          tren_norm_first=tren_norm_first,
                          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)
TransformerEnISLR(
  (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 TransformerEncoderLayer(
        (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)
        )
        (ffw): 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)
        (norm1): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
        (norm2): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
      )
    )
    (norm): 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, 128, 128) (2, 2, 128, 128)

6. 処理負荷の見積もり

ここでは,torchinfo を用いてモデルの処理負荷 (見積もり値) を見ていきます. 下記のコードで,入力サイズに応じたモデル処理負荷を検査することができます.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
pooling_type = "none"
max_tlen = 543

model = TransformerEnISLR(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_layer_norm_eps=tren_layer_norm_eps,
                          tren_norm_first=tren_norm_first,
                          tren_add_bias=tren_add_bias,
                          tren_add_tailnorm=tren_add_tailnorm)
# input_size: `[N, C, T, J]`
summary(model, input_size=(batch_size, len(use_features), max_tlen, len(use_landmarks)))
 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
====================================================================================================
Layer (type:depth-idx)                             Output Shape              Param #
====================================================================================================
TransformerEnISLR                                  [2, 10]                   --
├─Linear: 1-1                                      [2, 543, 64]              16,704
├─ReLU: 1-2                                        [2, 543, 64]              --
├─Identity: 1-3                                    [2, 543, 64]              --
├─TransformerEncoder: 1-4                          [2, 543, 64]              --
│    └─PositionalEncoding: 2-1                     [2, 543, 64]              --
│        └─Dropout: 3-1                           [2, 543, 64]              --
│    └─ModuleList: 2-2                             --                        --
│        └─TransformerEncoderLayer: 3-2           [2, 543, 64]              49,984
│        └─TransformerEncoderLayer: 3-3           [2, 543, 64]              49,984
│    └─LayerNorm: 2-3                              [2, 543, 64]              128
├─GPoolRecognitionHead: 1-5                        [2, 10]                   --
│    └─Linear: 2-4                                 [2, 10]                   650
====================================================================================================
Total params: 117,450
Trainable params: 117,450
Non-trainable params: 0
Total mult-adds (M): 0.23
====================================================================================================
Input size (MB): 1.13
Forward/backward pass size (MB): 13.34
Params size (MB): 0.47
Estimated Total Size (MB): 14.94
====================================================================================================
【出力内容】
- 1-17行目: レイヤ構成,対応する出力特徴量形状,およびパラメータ数
- 18-22行目: パラメータのサマリ
  - Total params: 全パラメータ数
  - Trainable params: 学習パラメータ数
  - Non-trainable params: 非学習パラメータ数
  - Total mult-adds: 計算処理 (和と積) の数
- 23-27行目: 計算容量のサマリ
  - Input size: 入力データ容量
  - Forward/backward pass size: Forward/Backward計算に必要なメモリ容量
  - Params size: パラメータのメモリ容量 (Total params * Tensorサイズ(4) )
  - Estimated Total Size: 総計算容量

今回の実験条件で変化する箇所は,Input sizeForward/backward pass size です.
表1に各設定毎の出力を記載します (コードは冗長なので割愛させていただきます).

計算容量の比較
設定 Input (MB) F/B(MB)
Default 1.13 13.34
R-32 0.07 0.79
R-64 0.13 1.57
R-128 0.27 3.15
R-128 + P 0.27 1.64

設定内容は下記のとおりです.

  • Default: pooling_type="none", max_tlen=584
  • R-32: pooling_type="none", max_tlen=32
  • R-64: pooling_type="none", max_tlen=64
  • R-128 pooling_type="none", max_tlen=128
  • R-128 + P: pooling_type="average", max_tlen=128

Defaultでは最大サイズの入力に対する計算容量を示しています.
また,Poolingの種別で計算容量は変わらないのでここでは Average pooling の結果だけを示しています.

リサイズと Pooling 処理によって計算容量が減っていることが分かります.

7. 学習と評価の実行

7.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 cuda for computation.

7.2 学習・評価の実行

次のコードで学習・バリデーション・評価処理それぞれのためのDataLoaderクラスを作成します.
今回は,データ拡張処理の有無および種類による認識性能の違いを見たいので,実験毎にデータセットクラスをインスタンス化します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Build dataloaders.
train_dataset = HDF5Dataset(
    train_hdf5files,
    pre_transforms=pre_transforms_w_norm,
    transforms=transforms_default,
    load_into_ram=load_into_ram)
val_dataset = HDF5Dataset(
    val_hdf5files,
    pre_transforms=pre_transforms_w_norm,
    transforms=transforms_default,
    load_into_ram=load_into_ram)
test_dataset = HDF5Dataset(
    test_hdf5files,
    pre_transforms=pre_transforms_w_norm,
    transforms=transforms_default,
    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)

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
tren_norm_first = True
tren_add_tailnorm = True
pooling_type = "none"

model_default = TransformerEnISLR(
    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_layer_norm_eps=tren_layer_norm_eps,
    tren_norm_first=tren_norm_first,
    tren_add_bias=tren_add_bias,
    tren_add_tailnorm=tren_add_tailnorm)
print(model_default)

loss_fn = nn.CrossEntropyLoss(reduction="mean")
optimizer = torch.optim.Adam(model_default.parameters(), lr=lr)
TransformerEnISLR(
  (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 TransformerEncoderLayer(
        (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)
        )
        (ffw): 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)
        (norm1): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
        (norm2): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
      )
    )
    (norm): 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_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:4.126297 [    0/ 3881]
loss:1.948109 [ 3200/ 3881]
Done. Time:6.517917484999998
Training performance: 
 Avg loss:2.210513

Start validation.
Done. Time:0.2436770109999884
Validation performance: 
 Avg loss:1.985109

Start evaluation.
Done. Time:1.3543222810000088
Test performance: 
 Accuracy:30.0%
--------------------------------------------------------------------------------
...
--------------------------------------------------------------------------------
Epoch 50
Start training.
loss:0.129627 [    0/ 3881]
loss:0.115906 [ 3200/ 3881]
Done. Time:4.387751489999971
Training performance: 
 Avg loss:0.181103

Start validation.
Done. Time:0.256983469999966
Validation performance: 
 Avg loss:0.859618

Start evaluation.
Done. Time:1.1931464009999786
Test performance: 
 Accuracy:79.0%
Minimum validation loss:0.6492272232260022 at 24 epoch.
Maximum accuracy:84.0 at 43 epoch.

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


今回は前処理およびモデル内部で特徴系列を時間軸方向にリサイズすることで,処理負荷を軽減する手法を紹介しましたが,如何でしたでしょうか?

実際のアプリケーション開発では,認識性能だけでなく計算負荷も考慮する必要があります.
特徴系列をリサイズする方法はシンプルですが,簡単に計算負荷を軽減することができ,タスクによっては認識性能が向上する場合もあります.
計算負荷でお悩みの場合は,難しいモデル改良に手を出す前にリサイズ処理を試してみては如何でしょう.

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