実践手話認識 - モデル開発編6: Temporal CNNで孤立手話単語認識モデルを改良

This image is generated with ChatGPT-4 Omni, and edited by the author.
作成日:2024年10月24日(木) 00:00
最終更新日:2024年10月25日(金) 10:14
カテゴリ:手話言語処理
タグ:  実践手話認識 孤立手話単語認識 CNN Python

入力側に近い特徴抽出層を工夫すると認識性能が改善することが多いです.今回は特徴抽出層に Temporal CNN を導入して性能改善を試みます.

こんにちは.高山です.
実践手話認識 - モデル開発編の第六回になります.

手話単語認識モデルでは,各動画フレームから特徴を抽出する方法と動画全体から特徴を抽出する方法を組み合わせて用いることが多いです.
これらの特徴抽出を本記事では,それぞれローカル特徴抽出 (local feature extraction, frame-wise feature extraction とも呼びます) とグローバル特徴抽出 (global feature extraction) と表記します.

今まで紹介してきた,RNN encoderTransformer encoder,およびそれらの派生手法は,後者のグローバル特徴抽出でしたが,今回からはローカル特徴抽出も少しづつ紹介していきたいと思います.
(今まで触れなかったのは,同時に紹介すると実装と実験が大変だからです(^^;))

今回は特に,時間軸に沿って畳み込み (正確には相互相関ですが) 処理を行う Temporal convolutional neural network (以降,TCNNと表記します) を紹介します.
実は,TCNNは既に Conformerの記事 で Convolution module (第2節をご参照ください) として紹介しています.
今回は Convolution module を少し簡略化した処理ブロックを TCNN として用いて,性能が改善するかを実験してみたいと思います.

なお,今回からは Pydanticを用いた NNモデルの実装 に即して実装を行います.
実装の考え方については,リンク先の記事をご参照ください.

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

1. 実験概要

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

Transformerベースの孤立手話単語認識モデルのブロック図を描き,今回はローカル特徴抽出ブロックを切り替えて実験することを説明しています.
今回の実験内容

手話認識入門第九回で紹介した,Pre-LN 構成の Transformer をベースとして,ローカル特徴抽出ブロックに TCNN を実装して認識性能を評価します.

2. ローカル特徴抽出ブロック

図2 に今回実験を行うローカル特徴抽出ブロックを示します.

Linear層とTCNN層のブロック図を並べて描き,処理の違いを説明する図です.画像の後に説明があります.
ローカル特徴抽出ブロック

図1(a) は Linear層をベースとしたローカル特徴抽出ブロックです.
ベースとなる Transformerで用いたローカル特徴ブロックとほぼ同じですが,Layer Normalization と Dropout を追加しています.
こちらのブロックは,基本的なパラメータ探索と,TCNN との比較に用います.

図2(b) は TCNN層をベースとしたローカル特徴抽出ブロックです.
Conformer の Convolution module から GLU 活性化関数とブロック末尾の Pointwise CNN を削除した構成になっています.

Pointwise CNN は カーネルサイズ \((1, 1)\) の CNN です.
特徴マップの赤四角に示すように,各位置の特徴だけを用いて特徴変換を行います.
この処理は,実質的には Linear 層と同様です.

Depthwise CNN は カーネルサイズ \((t, 1)\) の CNN です.
特徴マップの緑四角に示すように,各追跡点の特徴を時間方向に畳み込んで特徴抽出を行います.

Depthwise CNN は 通常の CNN と異なり,入力の各特徴次元に対して1個ずつカーネルを用意して,時空間フィルタリングだけを行います.
通常の CNN よりも簡略化された処理ですが,Linear層との差,および時間方向の処理に対する効果が分かりやすいと思い,この構成を選びました.

3. 実験結果

実装の詳細に先立って,実験結果をお見せします.

今回の実験では学習を安定させるために,ラベルスムージングと各種のデータ拡張処理を導入しています.
これらの処理については「手話認識入門」の記事で解説しているので,よろしければご一読ください.

学習エポック数は 100,バッチ数は 32,時系列の最大長は256に設定しています.

3.1 Linear層を用いたモデルの認識性能

まず先に,Linear層を用いた認識モデルの実験結果を示します.
こちらのモデルでは基本的なパラメータ探索として,内部特徴量の次元数を変更して性能を比較しました.

図3は,内部特徴量の次元数毎のValidation Lossと認識率の推移を示しています.

Linear層をローカル特徴抽出ブロックに用いた場合の,認識性能比較結果を図示したグラフです.画像前後の文章に詳細説明があります.
認識性能 (Linear ブロック)

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

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

  • 青線 (Transformer 64): 内部特徴次元数 64 の Transformer
  • 橙線 (Transformer 128): 内部特徴次元数 128 の Transformer
  • 緑線 (Transformer 192): 内部特徴次元数 192 の Transformer
  • 赤線 (Transformer 256): 内部特徴次元数 256 の Transformer
  • 紫線 (Transformer 384): 内部特徴次元数 384 の Transformer

これらのモデルでは,Linear層の特徴抽出ブロックで上記の次元数に特徴量を変換して,以降の Transformer でも同じ次元数を保って変換を行います.
ただし,Transformer の Position-wise feed forward では,一時的に倍の次元数の特徴量に変換して戻すという操作を行っています.

実験結果から,青線 (64) と橙線 (128) は明確に性能が劣っていることが分かります.
(つまり,今までの記事で使っていたパラメータはイマイチだったということですね...(^^;))

他の設定に関しては,基本的には次元数が多い方が学習初期の性能は良いですが,最終的には同じレベルに収束しています.

3.2 TCNN層を用いたモデルの認識性能

次に,TCNN層を用いた認識モデルの実験結果を示します.
前項の結果を受けて,TCNN層を用いたモデルでは内部特徴量の次元数を 192 に固定し,Depthwise CNN のカーネルサイズを変更して性能を比較しました.

図4は,カーネルサイズ毎のValidation Lossと認識率の推移を示しています.

TCNN層をローカル特徴抽出ブロックに用いた場合の,認識性能比較結果を図示したグラフです.画像前後の文章に詳細説明があります.
認識性能 (Temporal CNN ブロック)

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

  • 青線 (Transformer 192): 内部特徴次元数 192 の Transformer
  • 橙線 (CNN1D K=5 + Transformer 192): カーネルサイズ 5 の TCNN + Transformer 192
  • 緑線 (CNN1D K=9 + Transformer 192): カーネルサイズ 9 の TCNN + Transformer 192
  • 赤線 (CNN1D K=13 + Transformer 192): カーネルサイズ 13 の TCNN + Transformer 192
  • 紫線 (CNN1D K=17 + Transformer 192): カーネルサイズ 17 の TCNN + Transformer 192
  • 茶線 (CNN1D K=21 + Transformer 192): カーネルサイズ 21 の TCNN + Transformer 192

見づらくて恐縮ですが,カーネルサイズが大きいケースでは認識性能が伸びており,紫線 (K=17) が最も良い認識性能でした.
紫線 (K=17) の最大認識率は \(68.7\%\) 程度でしたので,青線 (Transformer 192) の \(65.4\%\) に対して約 \(3.3\%\) 程度性能が向上しています.

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

4. 前準備

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

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

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

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

1
2
3
from google.colab import drive

drive.mount("/content/drive")

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

# Copy to local.
!cp drive/MyDrive/Datasets/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]

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

次に,過去の記事で実装したコードをダウンロードします.
コードはGithubのsrc/modules_gislrにアップしてあります (今後の記事で使用するコードも含まれています).

まず,下記のコマンドでレポジトリをダウンロードします.
今後のアップデートを考慮してバージョン指定をしていますので注意してください.

!wget https://github.com/takayama-rado/trado_samples/archive/refs/tags/v0.4.1.zip -O master.zip
--2024-10-23 04:27:31--  https://github.com/takayama-rado/trado_samples/archive/refs/tags/v0.4.1.zip
...
2024-10-23 04:27:36 (18.9 MB/s) - ‘master.zip’ saved [80365125]

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

!unzip -o master.zip -d master
Archive:  master.zip
ab8d10d4e0745f02a625f681f21bd5f5d0038f10
   creating: master/trado_samples-0.4.1/
  inflating: master/trado_samples-0.4.1/.gitignore
  ...

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

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

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

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

4.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
53
54
55
56
57
58
59
60
61
62
63
import collections
import copy
import json
import math
import os
import sys
from functools import partial
from pathlib import Path

# Third party's modules
import numpy as np

from pydantic import (
    BaseModel,
    ConfigDict,
    Field)

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

5. ローカル特徴抽出ブロックの実装

ここから先は,ローカル特徴抽出ブロックを実装していきます.
今回は,Pydanticを用いた NNモデルの実装 で紹介したように,各レイヤのパラメータを Pydantic モデルでラップして実装をしていきます.

まず最初に,下記のコードで Pydantic モデルの設定を行います.

1
2
class ConfiguredModel(BaseModel):
    model_config = ConfigDict(extra="forbid")

上記の設定は,クラスで未定義の変数が入力された場合にエラーとする設定です.
(デフォルトでは無視する設定になっています)

ConfiguredModel を継承したクラスにパラメータを実装することで,Pydantic の検証機能を備えたパラメータクラスが実装できます.

5.1 Linear feature extraction block

では,Linear層を用いたローカル特徴抽出ブロックを実装していきます.
次のコードで設定パラメータを実装します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class LinearFeatureExtractorSettings(ConfiguredModel):
    fext_type: str = "linear"
    in_channels: int = 64
    out_channels: int = 64
    norm_type: str = Field(default="batch", pattern=r"batch|layer")
    norm_eps: float = 1e-5
    activation: str = Field(default="relu",
        pattern=r"relu|gelu|swish|silu|mish|geluacc|tanhexp")
    add_bias: bool = True
    dropout: float = 0.1
    channel_first: bool = True
    add_residual: bool = True

    def build_layer(self):
        return LinearFeatureExtractor(self)
【コード解説】
- 引数
  - fext_type: ローカル特徴抽出ブロックの識別名です.
    パラメータを保存した際の確認や,アプリケーション側で利用することを
    想定しています.
    レイヤ内では使用していません.
  - in_channels: 入力特徴量の次元数
  - out_channels: 出力特徴量の次元数
  - norm_type: 正規化層の種別を指定 [batch/layer]
  - norm_eps: LN層内で0除算を避けるための定数
  - activation: 活性化関数の種別を指定 [relu/gelu/swish/silu/mish/geluacc/tanhexp]
  - add_bias: Trueの場合,線形変換層にバイアス項を導入する
  - dropout: Dropout層の欠落率
  - channel_first: True の場合,入力特徴量が `[N, C, *...]` 形状と想定して処理を
    行う.
    False の場合,入力特徴量が `[N, *..., C]` 形状と想定して処理を行う.
  - add_residual: True の場合,Residual connection を挿入する.
    ただし,`in_channels != out_channels` の場合は処理を行わない.

norm_typeactivation は入力値を実装済みのレイヤ名に制限したいので,Field を使って型を定義しています.
pattern に入力可能な文字列を正規表現で与えることで,値を制限できます.

パラメータクラスを用いると,レイヤクラスは下記のように実装できます.

 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
class LinearFeatureExtractor(nn.Module):
    def __init__(self,
                 settings):
        super().__init__()
        assert isinstance(settings, LinearFeatureExtractorSettings)
        self.settings = settings
        self.channel_first = settings.channel_first

        self.linear = nn.Linear(
            in_features=settings.in_channels,
            out_features=settings.out_channels,
            bias=settings.add_bias)

        self.norm = create_norm(
            settings.norm_type, settings.out_channels, settings.norm_eps,
            settings.add_bias)

        self.activation = select_reluwise_activation(settings.activation)

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

        if settings.add_residual and settings.in_channels == settings.out_channels:
            self.residual = Identity()
        else:
            self.residual = Zero()

    def forward(self,
                feature):
        shape = feature.shape
        if self.channel_first:
            # `[N, C, T] -> [N, T, C]`
            if len(shape) == 3:
                feature = feature.permute([0, 2, 1])
            # `[N, C, T, *] -> [N, T, C, *] -> [N, T, C']`
            elif len(shape) == 4:
                feature = feature.permute([0, 2, 1, 3])
                feature = feature.reshape([shape[0], shape[2], -1])
            # `[N, C, T, *, *] -> [N, T, C, *, *] -> [N, T, C']`
            elif len(shape) == 5:
                feature = feature.permute([0, 2, 1, 3, 4])
                feature = feature.reshape([shape[0], shape[2], -1])
            else:
                raise NotImplementedError(f"Unsupported feature shape:{shape}.")
        res = self.residual(feature)
        feature = self.linear(feature)
        feature = apply_norm(self.norm, feature, channel_first=False)
        feature = self.activation(feature)
        feature = self.dropout(feature)
        feature = feature + res
        if self.channel_first:
            # `[N, T, C] -> [N, C, T]`
            feature = feature.permute([0, 2, 1])
        return feature
【コード解説】
- 4-25行目: 初期化処理
  - 22-25行目: `add_residual is True` の場合,Residual connection を追加します.
    ただし,`in_channels != out_channels` の場合は 0 を返して分岐を無効化しています.
- 27-53行目: 推論処理
  - 30-43行目: 多様な形状の入力に備えて,形状変更処理を適用.
  - 44-49行目: 各レイヤを順次呼び出して特徴変換.
  - 50-52行目: `channel_first` の値に併せて,出力形状を調整.

今までの実装では必要な引数がズラッと並んでいたところですが,パラメータクラスを介することでスッキリと書くことができるようになりました.

5.2 Temporal CNN feature extraction block

次に,TCNN層を用いたローカル特徴抽出ブロックを実装していきます.
次のコードで設定パラメータを実装します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class CNN1DFeatureExtractorSettings(ConfiguredModel):
    fext_type: str = "cnn1d"
    in_channels: int = 64
    out_channels: int = 64
    # Conv1D settings.
    kernel_size: int = Field(default=3, ge=3)
    stride: int = Field(default=1, ge=1)
    padding_mode: str = "zeros"
    norm_type: str = Field(default="batch",
        pattern=r"layer|batch")
    norm_eps: float = 1e-5
    activation: str = Field(default="swish",
        pattern=r"relu|gelu|swish|silu|mish|geluacc|tanhexp")
    causal: bool | None = False
    add_residual: bool = True
    add_bias: bool = True
    dropout: float = 0.1

    def model_post_init(self, __context):
        message = f"kernel_size:{self.kernel_size} must be the odd number."
        assert (self.kernel_size - 1) % 2 == 0, message

    def build_layer(self):
        return CNN1DFeatureExtractor(self)
【コード解説】
- 引数
  - fext_type: ローカル特徴抽出ブロックの識別名です.
    パラメータを保存した際の確認や,アプリケーション側で利用することを
    想定しています.
    レイヤ内では使用していません.
  - in_channels: 入力特徴量の次元数
  - out_channels: 出力特徴量の次元数
  - kernel_size: Depthwise CNN の時間方向カーネルサイズ
  - stride: Depthwise CNN の処理間隔を指定.
    例えば,stride=2 の場合は,1個飛ばしで処理を行うので出力特徴の系列長は
    半分になります.
  - padding_mode: Depthwise CNN のパディング処理種別を指定.
  - norm_type: 正規化層の種別を指定 [batch/layer]
  - norm_eps: LN層内で0除算を避けるための定数
  - activation: 活性化関数の種別を指定 [relu/gelu/swish/silu/mish/geluacc/tanhexp]
  - causal: True の場合,Causal convolution を適用します.
    この手法では未来の情報を含まないように畳み込みを行います.
    リアルタイム処理など,現在のフレームよりも過去の情報しか得られない場合の
    利用を想定しています.
  - add_residual: True の場合,Residual connection を挿入.
    ただし,`in_channels != out_channels` の場合は Pointwise CNN 分岐特徴量の
    次元数を揃えます.
  - add_bias: Trueの場合,線形変換層にバイアス項を導入する
  - dropout: Dropout層の欠落率

パラメータクラスを用いると,レイヤクラスは下記のように実装できます.

 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
class CNN1DFeatureExtractor(nn.Module):
    def __init__(self,
                 settings):
        super().__init__()
        assert isinstance(settings, CNN1DFeatureExtractorSettings)
        self.settings = settings
        self.causal = settings.causal

        if settings.causal:
            self.padding = (settings.kernel_size - 1)
        else:
            self.padding = (settings.kernel_size - 1) // 2

        self.conv_module = self._build_conv_module(settings, self.padding)
        self.norm = create_norm(settings.norm_type, settings.out_channels, settings.norm_eps)
        self.activation = select_reluwise_activation(settings.activation)
        self.dropout = nn.Dropout(p=settings.dropout)

        if settings.add_residual:
            if settings.in_channels == settings.out_channels:
                if settings.stride == 1:
                    self.residual = Identity()
                else:
                    self.residual = nn.MaxPool1d(
                        kernel_size=settings.kernel_size,
                        stride=settings.stride,
                        padding=self.padding)
            else:
                self.residual = nn.Conv1d(
                    in_channels=settings.in_channels,
                    out_channels=settings.out_channels,
                    kernel_size=1,
                    stride=settings.stride)
        else:
            self.residual = Zero()


    def _build_conv_module(self, settings, padding):
        dict_modules = collections.OrderedDict([
            # Point-wise.
            ("pconv",
             nn.Conv1d(
                in_channels=settings.in_channels,
                out_channels=settings.out_channels,
                kernel_size=1,
                stride=1,
                padding=0,
                bias=settings.add_bias)),
            # Depth-wise
            ("dconv",
             nn.Conv1d(
                in_channels=settings.out_channels,
                out_channels=settings.out_channels,
                kernel_size=settings.kernel_size,
                stride=settings.stride,
                padding=padding,
                groups=settings.out_channels,
                padding_mode=settings.padding_mode,
                bias=settings.add_bias))])

        conv_module = nn.Sequential(dict_modules)
        return conv_module

    def forward(self,
                feature):
        shape = feature.shape
        if len(shape) == 4:
            # `[N, C, T, *] -> [N, C, *, T] -> [N, C', T]`
            feature = feature.permute([0, 1, 3, 2])
            feature = feature.reshape([shape[0], -1, shape[2]])
        elif len(shape) == 5:
            # `[N, C, T, *, *] -> [N, C, *, *, T] -> [N, C', T]`
            feature = feature.permute([0, 1, 3, 4, 2])
            feature = feature.reshape([shape[0], -1, shape[2]])

        res = self.residual(feature)
        feature = self.conv_module(feature)
        feature = apply_norm(self.norm, feature, channel_first=True)
        feature = self.activation(feature)
        feature = self.dropout(feature)
        feature = feature + res
        return feature
【コード解説】
- 4-35行目: 初期化処理
  - 9-12行目: Causal convolution の場合は畳み込み範囲が過去フレーム側にずれる
    ので,パディング長を調整する必要があります.
  - 19-35行目: `add_residual is True` の場合,Residual connection を追加します.
    ただし,`in_channels == out_channels and stride > 1` の場合は,
    系列長を揃えるために,プーリングを行っています.
    また,`in_channels != out_channels` の場合は Pointwise CNN を用いて
    出力次元数を揃えています.
- 38-62行目: TCNNブロック作成処理.
  今後のTCNN拡張に備えて,`nn.Sequential` でラップして実装するようにしています.
- 64-82行目: 推論処理
  - 67-74行目: 多様な形状の入力に備えて,形状変更処理を適用.
  - 76-81行目: 各レイヤを順次呼び出して特徴変換.

今回は1個のローカル特徴抽出ブロックを用いますが,通常は複数のブロックをカスケード接続した構成が用いられます.
そのため,次の関数でブロック郡を nn.ModuleList でラップするようにしています.

この処理は親クラス (モデルなど) から呼び出されることを想定しています.
今回の場合は,TransformerEnISLR が呼び出してローカル特徴抽出ブロックを作成します.

1
2
3
4
5
6
7
8
def create_fext_module(settings):
    if settings is None:
        fext_module = nn.ModuleList(
            [Identity()])
    else:
        fext_module = nn.ModuleList(
            [setting.build_layer() for setting in settings])
    return fext_module

6. 認識モデルの実装

Colabのコードではこの後,Pydantic を用いて Transformer ベースの認識モデルをリファクタリングしています.
本記事の主要点では無いので,Transformer のリファクタリングについては補足記事で説明しています.

併せてご一読いただけたら幸いです.

7. モデルの動作確認

認識モデルの実装ができましたので,動作確認をしていきます.
次のコードでデータセットから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/22343.hdf5'), ..., PosixPath('dataset_top10/37055.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
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
_, use_landmarks = get_fullbody_landmarks()
use_features = ["x", "y"]
swap_pairs, _ = get_fullbody_swappairs()

trans_select_feature = SelectLandmarksAndFeature(landmarks=use_landmarks, features=use_features)
trans_repnan = ReplaceNan()
trans_norm = PartsBasedNormalization(align_mode="framewise", scale_mode="unique")

trans_hflip = RandomHorizontalFlip(
    apply_ratio=0.5,
    num_joints=len(use_landmarks),
    swap_pairs=swap_pairs,
    flip_center=0.5,
    feature_dim=len(use_features),
    include_conf=False
)
trans_saffine = RandomAffineTransform2D(
    apply_ratio=0.5,
    center_joints=[0, 2],
    target_joints=np.arange(0, len(use_landmarks)),
    trans_range=[-0.1, 0.1],
    scale_range=[1.0/1.5, 1.5],
    rot_range=[-30, 30],
    skew_range=[-30, 30]
)
trans_snoise = RandomNoise(
    apply_ratio=0.5,
    scale_range=[1e-3, 1e-2],
    scale_unit="asis",
    noise_type="uniform",
    target_joints=np.arange(0, len(use_landmarks)),
    feature_dim=len(use_features),
    include_conf=False
)
trans_clip =  RandomClip(
    apply_ratio=0.5,
    clip_range=[0.4, 0.6],
    min_apply_size=10
)
trans_twarp = RandomTimeWarping(
    apply_ratio=0.5,
    scale_range=[0.5, 2.0],
    min_apply_size=10
)
trans_drop = RandomDropParts(apply_ratio=0.5)

pre_transforms = Compose([trans_select_feature,
                          trans_repnan])

train_transforms = Compose([
    trans_hflip,
    trans_saffine,
    trans_snoise,
    trans_norm,
    trans_clip,
    trans_twarp,
    trans_drop,
    ToTensor()
])
val_transforms = Compose([trans_norm, ToTensor()])
test_transforms = Compose([trans_norm, 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=VOCAB)

dataset = HDF5Dataset(hdf5_files, pre_transforms=pre_transforms, transforms=train_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, 66, 130])

次のコードで Linear層をローカル特徴抽出ブロックに用いたモデルをインスタンス化して,動作チェックをします.

 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
# 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
pffn_channels = inter_channels * 2
out_channels = VOCAB

dict_linear_fext = {
    "fext_type": "linear",
    "in_channels": in_channels,
    "out_channels": inter_channels,
    "norm_type": "layer",
    "norm_eps": 1e-05,
    "activation": "relu",
    "add_bias": True,
    "dropout": 0.1,
    "channel_first": True,
    "add_residual": False
}

linear_fext_settings = [LinearFeatureExtractorSettings.model_validate(dict_linear_fext)]

islr_settings = TransformerEnISLRSettings(
    in_channels=inter_channels,
    inter_channels=inter_channels,
    out_channels=out_channels)

# Overwrite.
islr_settings.encoder_settings.num_layers = 2
islr_settings.encoder_settings.pe_settings.dim_model = inter_channels
islr_settings.enlayer_settings.dim_pffn = pffn_channels
islr_settings = TransformerEnISLRSettings.model_validate(islr_settings.model_dump())

model_linear = islr_settings.build_layer(linear_fext_settings)

# Sanity check.
print(model_linear)
logit = model_linear(feature_origin)
print(logit.shape)

11-22行目に示すとおり,ローカル特徴抽出ブロックは辞書形式からパラメータクラスへと変換して実装しています.
今回はコード上で直接設定していますが,JSON形式のファイルにパラメータを保存しておいて,読み込んで実行などもできます.

26-35行目に示すとおり,認識モデルのパラメータはまずデフォルト値でクラスを作成して,いくつかのパラメータを書き換えています.
特定の値だけ変更して残りは固定したい場合などは,このような実装方法も可能です.

print() 結果から,想定通りにモデルが作成できていることが分かります.

TransformerEnISLR(
  (fext_module): ModuleList(
    (0): LinearFeatureExtractor(
      (linear): Linear(in_features=260, out_features=64, bias=True)
      (norm): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
      (activation): ReLU()
      (dropout): Dropout(p=0.1, inplace=False)
      (residual): Zero()
    )
  )
  (tr_encoder): TransformerEncoder(
    (pos_encoder): PositionalEncoding(
      (dropout): Dropout(p=0.1, inplace=False)
    )
    (layers): ModuleList(
      (0-1): 2 x PreNormTransformerEncoderLayer(
        (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_pffn): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
        (pffn): PositionwiseFeedForward(
          (w_1): Linear(in_features=64, out_features=128, bias=True)
          (w_2): Linear(in_features=128, 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])

同様に,次のコードで TCNN層をローカル特徴抽出ブロックに用いたモデルをインスタンス化して,動作チェックをします.
ローカル特徴抽出ブロックとモデルの設定は独立しているため,islr_settings はそのまま使い回すことができます.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
dict_conv_fext = {
    "fext_type": "cnn1d",
    "in_channels": in_channels,
    "out_channels": inter_channels,
    "kernel_size": 3,
    "stride": 1,
    "norm_type": "batch",
    "norm_eps": 1e-5,
    "activation": "relu",
    "causal": False,
    "add_residual": False,
    "add_bias": True,
    "dropout": 0.1
}

conv_fext_settings = [CNN1DFeatureExtractorSettings.model_validate(dict_conv_fext)]
model_cnn1d = islr_settings.build_layer(conv_fext_settings)

# Sanity check.
print(model_cnn1d)
logit = model_cnn1d(feature_origin)
print(logit.shape)
TransformerEnISLR(
  (fext_module): ModuleList(
    (0): CNN1DFeatureExtractor(
      (conv_module): Sequential(
        (pconv): Conv1d(260, 64, kernel_size=(1,), stride=(1,))
        (dconv): Conv1d(64, 64, kernel_size=(3,), stride=(1,), padding=(1,), groups=64)
      )
      (norm): BatchNorm1d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (activation): ReLU()
      (dropout): Dropout(p=0.1, inplace=False)
      (residual): Zero()
    )
  )
  (tr_encoder): TransformerEncoder(
    (pos_encoder): PositionalEncoding(
      (dropout): Dropout(p=0.1, inplace=False)
    )
    (layers): ModuleList(
      (0-1): 2 x PreNormTransformerEncoderLayer(
        (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_pffn): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
        (pffn): PositionwiseFeedForward(
          (w_1): Linear(in_features=64, out_features=128, bias=True)
          (w_2): Linear(in_features=128, 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])

8. 学習と評価

8.1 共通設定

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 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
label_smoothing = 0.1

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=train_transforms, load_into_ram=load_into_ram)
val_dataset = HDF5Dataset(val_hdf5files, pre_transforms=pre_transforms,
    transforms=val_transforms, load_into_ram=load_into_ram)
test_dataset = HDF5Dataset(test_hdf5files, pre_transforms=pre_transforms,
    transforms=test_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)

8.2 学習・評価の実行

次のコードでモデルをインスタンス化します.
4行目に示すように,今回はラベルスムージングを有効にしています.

1
2
3
4
5
model_linear = islr_settings.build_layer(linear_fext_settings)
print(model_linear)

loss_fn = nn.CrossEntropyLoss(reduction="mean", label_smoothing=label_smoothing)
optimizer = torch.optim.Adam(model_linear.parameters(), lr=lr)
TransformerEnISLR(
  (fext_module): ModuleList(
    (0): LinearFeatureExtractor(
      (linear): Linear(in_features=260, out_features=64, bias=True)
      (norm): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
      (activation): ReLU()
      (dropout): Dropout(p=0.1, inplace=False)
      (residual): Zero()
    )
  )
  (tr_encoder): TransformerEncoder(
    (pos_encoder): PositionalEncoding(
      (dropout): Dropout(p=0.1, inplace=False)
    )
    (layers): ModuleList(
      (0-1): 2 x PreNormTransformerEncoderLayer(
        (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_pffn): LayerNorm((64,), eps=1e-05, elementwise_affine=True)
        (pffn): PositionwiseFeedForward(
          (w_1): Linear(in_features=64, out_features=128, bias=True)
          (w_2): Linear(in_features=128, 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_linear.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_linear, loss_fn, optimizer, device)
    val_loss = val_loop(val_dataloader, model_linear, loss_fn, device)
    val_losses.append(val_loss)

    if (epoch+1) % eval_every_n_epochs == 0:
        acc = test_loop(test_dataloader, model_linear, device)
        test_accs.append(acc)
train_losses_linear = np.array(train_losses)
val_losses_linear = np.array(val_losses)
test_accs_linear = np.array(test_accs)

print(f"Minimum validation loss:{val_losses_linear.min()} at {np.argmin(val_losses_linear)+1} epoch.")
print(f"Maximum accuracy:{test_accs_linear.max()} at {np.argmax(test_accs_linear)*eval_every_n_epochs+1} epoch.")
Start training.
--------------------------------------------------------------------------------
Epoch 1
Start training.
loss:4.062360 [    0/ 3881]
loss:1.856617 [ 3200/ 3881]
Done. Time:10.713473944999976
Training performance: 
 Avg loss:2.202826

Start validation.
Done. Time:0.35118736500001546
Validation performance: 
 Avg loss:1.894282

Start evaluation.
Done. Time:1.291084839000007
Test performance: 
 Accuracy:44.5%
--------------------------------------------------------------------------------
...
--------------------------------------------------------------------------------
Epoch 50
Start training.
loss:0.997563 [    0/ 3881]
loss:0.832069 [ 3200/ 3881]
Done. Time:8.466729444000066
Training performance: 
 Avg loss:0.879011

Start validation.
Done. Time:0.6205663220000588
Validation performance: 
 Avg loss:0.773825

Start evaluation.
Done. Time:1.381203117000041
Test performance: 
 Accuracy:89.0%
Minimum validation loss:0.7608617459024701 at 41 epoch.
Maximum accuracy:91.0 at 41 epoch.

以後,同様の処理を TCNN ブロックを用いたモデルでも行います.
コード構成は同じですので,ここでは説明を割愛させていただきます. また,この後グラフ等の描画も行っておりますが,本記事の主要点ではないため説明を割愛させていただきます.


今回は,Temporal CNN を用いたローカル特徴抽出ブロックを導入し,孤立手話単語認識モデルの性能改善を試みましたが,如何でしたでしょうか?

パラメータチューニングに苦戦してかなり時間がかかってしまいました(^^;)
特徴抽出は山のように手法が提案されており,どれが良いかを探すのも一苦労です.
また,良さそうに見える手法もパラメータ次第で全く効果が発揮できないこともあります.

私自身正解を知っているわけではありませんが,本記事を通じて同じような苦労をしている方に何か参考になれば幸いです.