ラビットチャレンジレポート 深層学習 その5

DCGAN

GAN(Generative Adversarial Network)とは

生成器と識別機を競わせて学習する生成&識別モデル。
Generator(生成器):乱数からデータを生成
Discriminator(識別器):入力データが真値(学習データ)であるかを識別する。
f:id:tibet:20211217130403p:plain

2プレイヤーのミニマックスゲームとは
  • 1人が自分の勝利する確率を最大化する作戦をとる。
  • もう一人は相手が勝利する確率を最小化する作戦をとる。
  • GANでは、価値関数Vに対し、D(識別器)が最大化、G(生成器)が最小化を行う。

 min_Gmax_DV(D,G)
ここを少し解説する。
GANの単一データの損失関数は、バイナリークロスエントロピーで表せる。
 L=-ylog\hat{y}+(1-y)log(1-\hat{y})・・・・(1)

  • 真データを扱うときは、 y=1, \hat{y}=D(x)となるので、(1)にそれぞれ代入すると、

 L=-log\lbrack D(x)\rbrack ・・・(2)

  • 生成データを扱うときは、 y=0, \hat{y}=D(G(z))となるので、これを(1)に代入する。

 L=-log\lbrack 1-D(G(z))\rbrack ・・・(3)
(1)、(2)の2つを足し合わせると、
 L=-(log\lbrack D(x) \rbrack + log\lbrack 1-D(G(z) \rbrack )
複数データをとるために期待値をとち、符号を逆にすると、価値関数は下記のようになる。
 V(D,G)=E_{x~P_{data(x)}}\lbrack logD(x)\rbrack +E_{z~pz(z)}\lbrack log(1-D(G(z)))\rbrack ・・・(4)

判別器を評価する時

D(x)は0~1の範囲を出力するが、真データが来た時、1を出力してほしい。
(4)の右辺第一項は、D(x)=1が来た時、最大化できる。
また、D(G(z))は、生成器が出力したデータの判定なので、偽データとして判定してほしい。
よって、D(G(z))=0が理想である。よって、右辺第二項もこのとき最大化できる。
結果的に、判別器を理想通りに動作させるには、価値関数V(D,G)を最大化するようパラメータを調整することになる。

生成器を評価する時

生成器の理想は、判別器が間違って判定すること(生成器の出力に1を出力すること)である。
(4)の右辺第一項は、生成器には関係ないので無視する。
右辺第二項は、D(G(z))=1を生成するのが望ましい。すなわち、右辺第二項が最小化されることが望ましい。
結果的に、生成器を理想通り動作させるには、評価関数V(D,G)を最小化するようパラメータを調整することになる。

最適化方法

具体的な最適化方法は下記のとおりである。
1. 判別器の性能を更新するよう、価値関数を最大化

  • 生成器のパラメータθgを固定
  • 真データと生成データをm個ずつサンプルする
  •  θ_dを勾配上昇法(Gradient Ascent)で更新

 \dfrac{\partial}{\partial θ_d}\dfrac{1}{m}\lbrack log \lbrack D(x) \rbrack +log \lbrack 1-D(G(z))\rbrack \rbrack

2.生成器の性能を更新するよう、価値関数を最小化

  • 判別器のパラメータθdを固定
  • 生成データをm個ずつサンプル
  • θgを勾配降下法(Gradient Descent)で更新

 \dfrac{\partial}{\partial θg}\dfrac{1}{m}\lbrack log\lbrack 1-D(G(z)) \rbrack \rbrack

なぜ生成器は本物のようなデータを生成するのか

  • 生成データが本物とそっくりな状況とは、 p_g=p_{data}であるはず。
  • 価値関数が p_g=p_{data}の時に最適化されていることを示せばよい。
  • 二つのステップで確認する。
  1. Gを固定し、価値関数が最大値をとる時のD(x)を算出
  2. 上記のD(x)を価値関数に代入し、Gが価値関数を最小化する条件を算出
ステップ1:価値関数を最大化するD(x)の値は?
  • Generatorを固定する。

 V(D,G)=E_{x~P_{data(x)}}\lbrack logD(x)\lbrack +E_{z~pz(z)}\lbrack log(1-D(G(z)))\rbrack
 =\int_x P_{data}(x)log(D(x))+p_g(x)log(1-D(x))dx
y=D(x)、a=P_{data}(x)、b=P_g(x)とおけば、
 V=alog(y)+blog(1-y)

  •  alog(y)+blog(1-y)の極致を求める。

 \dfrac{a}{y}+\dfrac{b}{1-y}(-1)=0
 y=\dfrac {a}{a+b}
y=D(x), a=pdata(x), b=pg(x)なので、
 D(x)=\dfrac{P_{data}(x)}{P_{data}(x)+p_g(x)}
これが、価値関数が最大値をとる時のD(x)の値である。

ステップ2:価値関数を最小化する条件

価値関数のD(x)を上記の値で置き換える。
 V=E_{x~p_{data}}log\lbrack\dfrac{p_data(x)}{P_data(x)+p_g(x)}\rbrack+E{x~p_g}log\lbrack 1-\dfrac{P_{data}(x)}{P_{data}(x)+p_g(x)}\rbrack
 =E_{x~pdata}log\lbrack \dfrac{p_data(x)}{P_data(x)+p_g(x)}\rbrack+E{x~p_g}log\lbrack \dfrac{P_g(x)}{P_{data}(x)+p_g(x)}\rbrack・・・(5)

  • 上記の最小値を調べる必要がある。 P_g, P_{data}は確率分布である。

JSダイバージェンスと(5)の式を比較すると非常に近似した式であることが分かる。このため(5)の最小値を求めるためにJSダイバージェンスを利用する。

  • 価値関数を変形する。

 V=2JS(P_{data}||P_g)-2log2
 min_gVはpdata=pgの時に、最小値-2log2をとる。
よって、GANの学習により、Gは本物のようなデータを生成できる。
学習ステップは下記のようになる。
f:id:tibet:20211217162907p:plain

DCGAN(Deep Convolutional GAN)

  • GANを利用した画像生成モデル
  • 生成器
    • Pooling層の代わりに転置畳み込み層を利用
    • 最終層はtanh、その他はReLU関数で活性化

f:id:tibet:20211217195938p:plain

  • 判別器
    • Pooling層の代わりに畳み込み層を使用
    • Leaky ReLU関数で活性化

ラビットチャレンジレポート 深層学習 その4

Section5 物体検知とセマンティックセグメンテーション

物体認識の種類

f:id:tibet:20211214210315p:plain

分類(Classification) 画面全体のクラスラベル
物体検知(Object Detection) 画面の中で物体を個別にボックス(bounding boxでとらえる
意味領域分割(Semantic Segmentation) ピクセルに対し単一のクラスラベル
個体領域分割(Instance Segmentation) ピクセルレベルで個別にクラスラベル

一般に上から下に行くにしたがって、タスクの難易度は上がる。

物体認識に使用されるデータセット

物体認識モデルの評価には、ベンチマークとなるデータセットが良く使われる。
データセットは、それぞれの特徴があるので、想定用途によって使い分けられる。

クラス下図 Train+val数 BOX/画像 解説
VOC12 20 11,540 2.4 VOC=Visual Object Classes 主要貢献者が2012年に亡くなったことに伴い終了
ILSVRC17 200 476,668 1.1 コンペは2017年に終了(後継はOpen Images Challenge) ImageNet(21,841クラス/1400万枚以上)のサブセット
MS COCO18 80 123,287 7.3 COCO=COmmon Object Context 物体位置推定に対する新たな評価指標を提案
OICOD18 500 1,743,042 7.0 ILSVRCの後継 ILSVRCやMS COCOとは異なるannotation process。Open Images V4(6000クラス以上/900万枚以上)のサブセット

それぞれで画像サイズも異なる。

    • PASCAL VOC:470×380
    • ImageNet:500×400
    • MS COCO:640×480
    • Open Images:一様ではない

少ないと、アイコン的な映りで、日常感とはかけ離れやすい。
大きいと、部分的な重なりなども見られ、日常生活のコンテクストと近くなる。

    • クラス数

多いと一般に情報量は多いが、多すぎると、ImageNetのように過度に細かいクラス分けになっている時がある。

各データのポジショニングマップを書くと下記のようになる。
f:id:tibet:20211214215240p:plain

評価指標

Confusionマトリクス、Precision, Recall

Confusionマトリクス
f:id:tibet:20211214220610p:plain
Precision, Recall
f:id:tibet:20211214220636p:plain
Confidenceの閾値を変化させることで、Precision-Recall Curveが描ける。
f:id:tibet:20211214220734p:plain

閾値変化に対するクラス分類と物体検出の違い

クラス分類は、閾値を変化させても総数は変わらないが、物体検出では、閾値に達したクラスのみが認識されるため、総数が変わる。
f:id:tibet:20211214221211p:plain

IoU:Intersection over Union

正解のBBOXと推論結果のBBOXの面積の合計で、双方がオーバーラップした部分の面積を割った数字である。
物体検出においては、ラベルの確信度のみでなく、物体位置の予測精度も重要なため、この指標が良く用いられる
f:id:tibet:20211214221949p:plain

物体検出におけるPrecision Recall

IoUが閾値以下のものは、FPにカウントし、検出されなかったもの(Confidenceが閾値以下のもの)は、FNにカウントする。
f:id:tibet:20211214223059p:plain

AP:Average Precision

IoUやConfidenceのある一点について閾値を定めれば、TrueとFalseを判定でき、Precision、Recallを計算できる。
しかし、モデルの評価としては、Confidenceの閾値を変化させたとき、閾値が高くても精度が高いものは指標としては高くあるべきで、ランキング評価のようなものも欲しくなる。
このための指標として、AP(Average Precision)というものが存在する。
定義としては、IoUの閾値を固定(0.5)し、Confidenceを走査することで得られるPrecise-Recall Curve(PR曲線)の下側の面積の値となる。
 AP=\int_{0}^{1}P(R)dR
物体検出のクラスの数がCの時、平均AP(mAP)は下記のように計算される。
 mAP=\frac{1}{C}\displaystyle\sum_{i=1}^{C}AP_i

  • MS COCOで導入された指標

IoU閾値を固定せず、0.5から0.95まで0.05刻みでAP&mAPを計算し、算術平均をとったもの。
 mAP_{COCO}=\frac{mAP_{0.5}+mAP_{0.55}+..+mAP_{0.95}}{10}

FPS:Flames per Second

物体検出は自動運転のようなリアルタイム処理の必要な用途もあるので、処理時間の指標も必要である。そのため、1秒当たり何フレーム処理したか(FPS)と一回の推論に必要な時間(inference time)という指標がある。
比較には、lなんのデータセットを用いたかも考慮する必要がある。
f:id:tibet:20211215001816p:plain

物体検知モデルの概説

物体検知モデルは以前からあり、SIFTという技術が主流だったが、2012年に発表されたAlexNetを皮切りに、DCNNへと主流が移り変わった。
その後の流れは、下記のようになる。
f:id:tibet:20211215165358p:plain
物体検知で、黄色と黄緑に文字が塗り分けられているが、それぞれ下記のような検出器になる。
-黄緑:2段階検出器(Two-Stage detector)

    • 候補領域の検出とクラス推定を別々に行う
    • 相対的に精度が高い傾向
    • 相対的に計算量が大きく推論も遅い傾向

-黄色:1段階検出器(One-Stage detector)

    • 候補領域の検出とクラス推定を同時に行う
    • 相対的に精度が低い傾向
    • 相対的に計算量が小さく推論が速い傾向

それぞれのネットワークの構造は下記のようになる。
入力から特徴マップを取得するところまでは同じだが、One-stage detectorは、出力のネットワークを2つに分けて、それぞれClassificationとBox regressionに分けて出力する。
Two-Stage detectorは、2つに分けた出力をさらにネットワークに入力し、Classification出力とBOXの位置出力を分けて出力する。
f:id:tibet:20211215165901p:plain

動作イメージはそれぞれ下記になる。
Two-stage detectorは、一段目のネットワークで、オブジェクトの位置を推定し、クロップする。
2段目のネットワークで、クロップしたBOXの位置情報を細かく把握し、物体の識別をする。
One-Stage detectorは、上記を一つのネットワークで行う。
f:id:tibet:20211215170326p:plain
One-Stage detectorは、Single Shot Detector(SSD)とも呼ばれる。
以下、利用が拡大しているSSDの説明をする。

SSD:Single Shot Detector

SSDのイメージとしては、下記のようにDefaultのBOXを用意し、それを変形してconfidenceを出力する形になる。
f:id:tibet:20211215173635p:plain

`SSDのネットワークアーキテクチャ

下記のVGG16のネットワークがベースになっている。
f:id:tibet:20211216132118p:plain
下記がSSDのネットワークアーキテクチャである。
特徴としては、中間層からそれぞれ直接出力するパスがあることと、VGG16のFC層2層をConv層に変更し、最後のFC層は削除しているところである。
f:id:tibet:20211216132431p:plain

特徴マップからの出力

f:id:tibet:20211216132907p:plain
SSDでは、分類と、Default Boxの位置と大きさを変更するため、マップ中の1つの特徴量における1つのDefalut Boxについての出力サイズは、
#Class+4
となる。ここで、#Classは分類するクラスの数、4というのは、オフセット項(位置の差分(ΔX, Δy)、大きさの差分(Δw, Δh)の数である。
オフセット項は、位置や大きさに関して下記のようにパラメータ化される。
位置(x,y)に対しては線形であるのに対し、大きさ(w, h)に関しては指数で効いていることに注意する。
f:id:tibet:20211216133538p:plain
マップ中の各特徴量にk個のDefault Boxを用意すると、出力サイズは
k(#Calss+4)
になる。
さらに、特徴マップのサイズがm×nであるとすると、出力サイズは
k(#Lcass+4)mn
になる。
結局、特徴マップごとに用意するDefault Boxの数は、k×m×n個となる。
SDDのデフォルトBOXの数の計算例を示す。
f:id:tibet:20211216134239p:plain
上記のネットワークで、青のパスでは、デフォルトBOXは各特徴に4つ、赤のパスでは、デフォルトBOXは各特徴に6つ用意するとする。
また、VOCデータセットでは、クラス数20に背景クラス1が考慮され、#Class=21となる。
青、赤それぞれのパスを計算すると、
4×(21+4)×38×38+4×(21+4)×3×3+4×(21+4)×1×1
6×(21+4)×19×19+6×(21+4)×10×10+6×(21+4)×5×5
合計で、8,732×(21+4)になる。
余談だが、上記のイメージ図における各フィルターのサイズだが、階層が進むにつれて物理的にフィルタが小さくなるわけではなく、サイズは一緒で解像度が低くなるようなイメージである。
フィルタのサイズが大きければ細かい特徴量が把握でき、小さければ、より全体の特徴を把握できる。

その他の工夫
  • Non-Maximum Suppression

これは、RCNNでも用いられている手法である。
同じ物体に対して、BOXが重なるような形で検出されることがあるが、この重複を解決するのがNon-Maximum SUppressionである。
具体的には、閾値のIoUをαなどと決めておき、同じ物体に対して複数重なっているBOXの中で、最もConfidenceが高いBOXに対して、IoU=α以上の値で重なっているBOXは、すべて消去するという方法でBOXの重複をなくす。

  • Hard Negative Mining

背景が指定されている検出器だと、背景を検出するBOXの数のほうが、背景ではない物体を検出するBOXより圧倒的に数が多くなってしまうことがある。
そこで、背景:非背景のBOXの数の比に制限を加えて、バランスをとるようにする手法である。
f:id:tibet:20211216135944p:plain

損失関数

損失関数は、検出位置と、分類のconfidenceと、双方に対して定義する
f:id:tibet:20211216140405p:plain

Semantic Segmentation

Semantic Segmenttationは、ピクセルごとに物体の分類を行うものである。
ネットワークの模式図は下記のようになる。
畳み込みで特徴マップのサイズを小さくし、クラス分類をしたのち、クラス数分のチャネルを持ったマップに拡張する。これをアップサンプリングという。
f:id:tibet:20211216140639p:plain
^なぜわざわざ特徴マップを小さくしてプーリングしたのち、アップサンプリングをするというステップを踏むのか
(答え)特徴マップを小さくすることで、画像全体の特徴を掴んで分類する。

アップサンプリングの仕組み

アップサンプリングの技術をDeconvolution/Transposed Convolutionと呼ぶ。
この仕組みは、畳み込みと同様、プーリングされた画像に対して、カーネルフィルタを用いる。
1.特徴マップのpixel感覚をstrideだけ空ける。
2.特徴マップの周りに(kernel size-1)-paddingだけ余白を作る。
3.畳み込み演算を行う
下図はkernel size=3, padding=1, stride=1のDeconv.による3×3の特徴マップから5×5にUp-samplingされる様子を図示している。
f:id:tibet:20211216141436p:plain
Deconvでは、poolingで失われた情報が復元されるわけではない。

  • 輪郭情報の補完

Poolingによるローカルな情報(輪郭)が失われるので、低レイヤーpooling層の出力を要素ごとに加算することで、輪郭情報を補完する。
f:id:tibet:20211216142126p:plain
f:id:tibet:20211216142303p:plain

U-net

上記に近い発想のネットワークとして、U-netがある。
Encoder部分で畳み込みで段階的に小さくした特徴マップを、Decoder部分で段階的に逆畳み込みで大きくしていくネットワークで、各ステップにスキップコネクションを使って輪郭情報を補完している。
特徴としては、低レイヤーの情報を要素ごとに加算するのではなく、チャネル方向に結合するところである。
f:id:tibet:20211216142512p:plain

Dilated Convolution

最後に、Convolution段階で受容野を広げるDilated Convolutionについて紹介する。
これはアップサンプリング時と同様の操作を畳み込み時に行うものになる。
これによって、より少ない層数で広い受容野の特徴量を獲得することが可能になる。
f:id:tibet:20211216143158p:plain

ラビットチャレンジレポート 深層学習Day4 その3

Section 5 Transformer

5-1 Seq2seq

seq2seqとは

 系列(sequence)を入力として、系列を出すもの。Encoder-Decoderモデルとも呼ばれる。
 入力系列が内部状態に変換(encode)され、内部状態から系列に変換(decode)される。
 実応用上も、入力・出力ともに系列情報的なものが多く、自然言語処理に応用される。
 -翻訳
-音声認識
-チャットボット

seq2seqの仕組み

 seq2seqはRNNを使用した言語モデルを二つ連結した形になっている。

 言語モデルとは、単語の並びに確率を与えるものである。
 単語の並びに尤度、すなわち文章として自然化を確率で評価する。
 P(w_1,...,w_m)=\displaystyle\prod_{i=1}^mP(w_i,|w_1,..,w_{i-1})
 具体的な手法としては、時刻t-1までの上方で、時刻tの事後確率を求め、同時確率を計算する。
f:id:tibet:20211212085337p:plain

  • RNN

RNNは、系列情報を処理して内部状態に変換することが出来る。
 f:id:tibet:20211212085431p:plain

各地点で次のどの単語が来れば自然(事後確率が最大)かを出力できる。
f:id:tibet:20211212085539p:plain
 言語モデルを再現するようにRNNの重みが学習されていれば、ある時点の次の単語を予測することが出来る。
 先頭単語を与えれば、文章を生成することも可能になる。

Seq2seqの構造

 Encoder側はRNN×言語モデルで内部状態を生成する。
 Decoder側はRNN×言語モデルで構造はほぼ同じだが、隠れ状態の初期値にEncoderの内部状態を受け取り、文章を生成する。
 学習に関しては、Decoderのoutput側に正解を与えれば、end2endで教師あり学習が行える。
f:id:tibet:20211212090000p:plain

  • Teacher Forcing

Q:下記のモデルで起こる問題は?
f:id:tibet:20211212090429p:plain
A:Encoder側から渡された内部状態から生成した出力 w_1を次段に入力し、その出力 w_{2}をその次の段に入力し..ということを時系列で繰り返すので、ある段で誤差が生じると、時系列が進むにつれて誤差が増幅してしまうという問題がある。

そこで、正解ラベルを直接Decoderの入力にするというTeacher Forcingの手法が考案された。
f:id:tibet:20211212090848p:plain

Q:上記のTeacher Forcingのモデルで起こる問題は?
A:実際の推論では、教師データをdecoderに与えないため、学習時にはうまく収束したモデルでも、実際の推論では期待通りの動作をしない場合があるという問題がある。

対策として、一定の確率に基づいてTeacher forcingを与える場合とそうでない場合を混ぜて学習をするというScheduled Sampling手法が考案された。

実装演習

seq2seqのコードを実装する。
まず、必要なライブラリのインポートと環境設定をする。
注意点としては、wheel.pep425tagsが最新バージョンには無いため、wheel=0.34.1にダウングレードする必要があること、pytorchは旧バージョンはリンクが消えている場合があるので、最新版をインストールすることである。

pip install wheel==0.34.1
pip install pytorch
from os import path
from wheel.pep425tags import get_abbr_impl, get_impl_ver, get_abi_tag
platform = '{}{}-{}'.format(get_abbr_impl(), get_impl_ver(), get_abi_tag())

accelerator = 'cu80' if path.exists('/opt/bin/nvidia-smi') else 'cpu'

#!pip install -q http://download.pytorch.org/whl/{accelerator}/torch-0.4.0-{platform}-linux_x86_64.whl torchvision
import torch
print(torch.__version__)
print(torch.cuda.is_available())
! wget https://www.dropbox.com/s/9narw5x4uizmehh/utils.py
! mkdir images data

# data取得
! wget https://www.dropbox.com/s/o4kyc52a8we25wy/dev.en -P data/
! wget https://www.dropbox.com/s/kdgskm5hzg6znuc/dev.ja -P data/
! wget https://www.dropbox.com/s/gyyx4gohv9v65uh/test.en -P data/
! wget https://www.dropbox.com/s/hotxwbgoe2n013k/test.ja -P data/
! wget https://www.dropbox.com/s/5lsftkmb20ay9e1/train.en -P data/
! wget https://www.dropbox.com/s/ak53qirssci6f1j/train.ja -P data/

import random
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.utils import shuffle
from nltk import bleu_score

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.nn.utils.rnn import pad_packed_sequence, pack_padded_sequence
from utils import Vocab

# デバイスの設定
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

torch.manual_seed(1)
random_state = 42

print(torch.__version__)

次にデータセットの準備をする。今回は英語-日本語の対訳コーパスであるTanaka Corpus(
Tanaka Corpus - EDRDG Wiki )を使用する。
今回は、そのうちの一部分を取り出したsmall_parallel_enja: 50k En/Ja Parallel Corpus for Testing SMT Methods ( https://github.com/odashi/small_parallel_enja )を使用する。
データの読み込みと単語の分割をする

def load_data(file_path):
    # テキストファイルからデータを読み込むメソッド
    data = []
    for line in open(file_path, encoding='utf-8'):
        words = line.strip().split()  # スペースで単語を分割
        data.append(words)
    return data
train_X = load_data('./data/train.en')
train_Y = load_data('./data/train.ja')
# 訓練データと検証データに分割
train_X, valid_X, train_Y, valid_Y = train_test_split(train_X, train_Y, test_size=0.2, random_state=random_state)

この時点で入力と教師データは以下のようになっている。

print('train data', train_X[0])
print('valid data', valid_X[0])
train data ['where', 'shall', 'we', 'eat', 'tonight', '?']
valid data ['you', 'may', 'extend', 'your', 'stay', 'in', 'tokyo', '.']

単語辞書の作成をする。
データセットに登場する各単語にIDを割り振る。
ここで、特殊文字のPADは、バッチの大きさをそろえるためにダミー。BOSは系列の始まり、EOSは系列の終わりを表す。
UNKは語彙に存在しない単語を表す。

# まず特殊トークンを定義しておく
PAD_TOKEN = '<PAD>'  # バッチ処理の際に、短い系列の末尾を埋めるために使う (Padding)
BOS_TOKEN = '<S>'  # 系列の始まりを表す (Beggining of sentence)
EOS_TOKEN = '</S>'  # 系列の終わりを表す (End of sentence)
UNK_TOKEN = '<UNK>'  # 語彙に存在しない単語を表す (Unknown)
PAD = 0
BOS = 1
EOS = 2
UNK = 3

MIN_COUNT = 2  # 語彙に含める単語の最低出現回数 再提出現回数に満たない単語はUNKに置き換えられる

# 単語をIDに変換する辞書の初期値を設定
word2id = {
    PAD_TOKEN: PAD,
    BOS_TOKEN: BOS,
    EOS_TOKEN: EOS,
    UNK_TOKEN: UNK,
    }

# 単語辞書を作成
vocab_X = Vocab(word2id=word2id)
vocab_Y = Vocab(word2id=word2id)
vocab_X.build_vocab(train_X, min_count=MIN_COUNT)
vocab_Y.build_vocab(train_Y, min_count=MIN_COUNT)

vocab_size_X = len(vocab_X.id2word)
vocab_size_Y = len(vocab_Y.id2word)
print('入力言語の語彙数:', vocab_size_X)
print('出力言語の語彙数:', vocab_size_Y)

語彙数は下記である。

入力言語の語彙数: 3725
出力言語の語彙数: 4405

vocab_X.id2wordを見るとIDが0~3の語句は特殊文字に割り当てられ、4以下が文章に出てくる単語に割り当てられている。
次にテンソルへ変換する。
まず、モデルが文章を認識できるように文章を単語IDのリストに変換する。

def sentence_to_ids(vocab, sentence):
    # 単語(str)のリストをID(int)のリストに変換する関数
    ids = [vocab.word2id.get(word, UNK) for word in sentence]
    ids += [EOS]  # EOSを加える
    return ids

train_X = [sentence_to_ids(vocab_X, sentence) for sentence in train_X]
train_Y = [sentence_to_ids(vocab_Y, sentence) for sentence in train_Y]
valid_X = [sentence_to_ids(vocab_X, sentence) for sentence in valid_X]
valid_Y = [sentence_to_ids(vocab_Y, sentence) for sentence in valid_Y]

この時点で入力と教師データは以下のようになっている。

train data [132, 321, 28, 290, 367, 12, 2]
valid data [8, 93, 3532, 36, 236, 13, 284, 4, 2]

データセットからバッチを取得するデータローダーを定義する。
この際、ながさのことなる複数の系列をバッチで並列に扱えるように、短い系列にはダミーでパディングし、パッチ内の系列の長さを合わせる。
(batch_size,max_length)サイズの行列を得るが、実際にモデルを学習する時にはバッチを跨いで各時刻ごとにすすめるので、転置する。

def pad_seq(seq, max_length):
    # 系列(seq)が指定の文長(max_length)になるように末尾をパディングする
    res = seq + [PAD for i in range(max_length - len(seq))]
    return res    


class DataLoader(object):

    def __init__(self, X, Y, batch_size, shuffle=False):
        """
        :param X: list, 入力言語の文章(単語IDのリスト)のリスト
        :param Y: list, 出力言語の文章(単語IDのリスト)のリスト
        :param batch_size: int, バッチサイズ
        :param shuffle: bool, サンプルの順番をシャッフルするか否か
        """
        self.data = list(zip(X, Y))
        self.batch_size = batch_size
        self.shuffle = shuffle
        self.start_index = 0
        
        self.reset()
    
    def reset(self):
        if self.shuffle:  # サンプルの順番をシャッフルする
            self.data = shuffle(self.data, random_state=random_state)
        self.start_index = 0  # ポインタの位置を初期化する
    
    def __iter__(self):
        return self

    def __next__(self):
        # ポインタが最後まで到達したら初期化する
        if self.start_index >= len(self.data):
            self.reset()
            raise StopIteration()

        # バッチを取得
        seqs_X, seqs_Y = zip(*self.data[self.start_index:self.start_index+self.batch_size])
        # 入力系列seqs_Xの文章の長さ順(降順)に系列ペアをソートする
        seq_pairs = sorted(zip(seqs_X, seqs_Y), key=lambda p: len(p[0]), reverse=True)
        seqs_X, seqs_Y = zip(*seq_pairs)
        # 短い系列の末尾をパディングする
        lengths_X = [len(s) for s in seqs_X]  # 後述のEncoderのpack_padded_sequenceでも用いる
        lengths_Y = [len(s) for s in seqs_Y]
        max_length_X = max(lengths_X)
        max_length_Y = max(lengths_Y)
        padded_X = [pad_seq(s, max_length_X) for s in seqs_X]
        padded_Y = [pad_seq(s, max_length_Y) for s in seqs_Y]
        # tensorに変換し、転置する
        batch_X = torch.tensor(padded_X, dtype=torch.long, device=device).transpose(0, 1)
        batch_Y = torch.tensor(padded_Y, dtype=torch.long, device=device).transpose(0, 1)

        # ポインタを更新する
        self.start_index += self.batch_size

        return batch_X, batch_Y, lengths_X

次にモデルの構築をする。
EncoderとDecoderのRNNを定義する。
PackedSequenceというクラスを使用する。これはパディング部分の計算を省略できるため、可変長の系列のバッチを効率よく計算できる。
また、出力されたテンソルはpad_packed_sequenceを用いて通常のテンソルに戻す。

まず、Encoderを構築する。
Encoder側でバッチを処理する際に、pack_padded_sequence関数によってpacked_sequenceに変換し、処理を終えた後にpad_packed_sequence関数によってtensorに戻す。

class Encoder(nn.Module):
    def __init__(self, input_size, hidden_size):
        """
        :param input_size: int, 入力言語の語彙数
        :param hidden_size: int, 隠れ層のユニット数
        """
        super(Encoder, self).__init__()
        self.hidden_size = hidden_size

        self.embedding = nn.Embedding(input_size, hidden_size, padding_idx=PAD)
        self.gru = nn.GRU(hidden_size, hidden_size)

    def forward(self, seqs, input_lengths, hidden=None):
        """
        :param seqs: tensor, 入力のバッチ, size=(max_length, batch_size)
        :param input_lengths: 入力のバッチの各サンプルの文長
        :param hidden: tensor, 隠れ状態の初期値, Noneの場合は0で初期化される
        :return output: tensor, Encoderの出力, size=(max_length, batch_size, hidden_size)
        :return hidden: tensor, Encoderの隠れ状態, size=(1, batch_size, hidden_size)
        """
        emb = self.embedding(seqs) # seqsはパディング済み
        packed = pack_padded_sequence(emb, input_lengths) # PackedSequenceオブジェクトに変換
        output, hidden = self.gru(packed, hidden)
        output, _ = pad_packed_sequence(output)
        return output, hidden

つぎにDecoder側を構築する。
今回はDecoder側でパディングを行わないので、通常のtensorのままRNNに入力する。

class Decoder(nn.Module):
    def __init__(self, hidden_size, output_size):
        """
        :param hidden_size: int, 隠れ層のユニット数
        :param output_size: int, 出力言語の語彙数
        :param dropout: float, ドロップアウト率
        """
        super(Decoder, self).__init__()
        self.hidden_size = hidden_size
        self.output_size = output_size

        self.embedding = nn.Embedding(output_size, hidden_size, padding_idx=PAD)
        self.gru = nn.GRU(hidden_size, hidden_size)
        self.out = nn.Linear(hidden_size, output_size)

    def forward(self, seqs, hidden):
        """
        :param seqs: tensor, 入力のバッチ, size=(1, batch_size)
        :param hidden: tensor, 隠れ状態の初期値, Noneの場合は0で初期化される
        :return output: tensor, Decoderの出力, size=(1, batch_size, output_size)
        :return hidden: tensor, Decoderの隠れ状態, size=(1, batch_size, hidden_size)
        """
        emb = self.embedding(seqs)
        output, hidden = self.gru(emb, hidden)
        output = self.out(output)
        return output, hidden

次に、一連の処理をまとめるEncoderDecoderのクラスを定義する。
Scheduled Samplingを採用し、一定の確率でターゲット系列を入力とするかどうかを切り替えられるようにクラスを定義しておきます。

class EncoderDecoder(nn.Module):
    """EncoderとDecoderの処理をまとめる"""
    def __init__(self, input_size, output_size, hidden_size):
        """
        :param input_size: int, 入力言語の語彙数
        :param output_size: int, 出力言語の語彙数
        :param hidden_size: int, 隠れ層のユニット数
        """
        super(EncoderDecoder, self).__init__()
        self.encoder = Encoder(input_size, hidden_size)
        self.decoder = Decoder(hidden_size, output_size)

    def forward(self, batch_X, lengths_X, max_length, batch_Y=None, use_teacher_forcing=False):
        """
        :param batch_X: tensor, 入力系列のバッチ, size=(max_length, batch_size)
        :param lengths_X: list, 入力系列のバッチ内の各サンプルの文長
        :param max_length: int, Decoderの最大文長
        :param batch_Y: tensor, Decoderで用いるターゲット系列
        :param use_teacher_forcing: Decoderでターゲット系列を入力とするフラグ
        :return decoder_outputs: tensor, Decoderの出力, 
            size=(max_length, batch_size, self.decoder.output_size)
        """
        # encoderに系列を入力(複数時刻をまとめて処理)
        _, encoder_hidden = self.encoder(batch_X, lengths_X)
        
        _batch_size = batch_X.size(1)
        # decoderの入力と隠れ層の初期状態を定義
        decoder_input = torch.tensor([BOS] * _batch_size, dtype=torch.long, device=device) # 最初の入力にはBOSを使用する
        decoder_input = decoder_input.unsqueeze(0)  # (1, batch_size)
        decoder_hidden = encoder_hidden  # Encoderの最終隠れ状態を取得

        # decoderの出力のホルダーを定義
        decoder_outputs = torch.zeros(max_length, _batch_size, self.decoder.output_size, device=device) # max_length分の固定長

        # 各時刻ごとに処理
        for t in range(max_length):
            decoder_output, decoder_hidden = self.decoder(decoder_input, decoder_hidden)
            decoder_outputs[t] = decoder_output
            # 次の時刻のdecoderの入力を決定
            if use_teacher_forcing and batch_Y is not None:  # teacher forceの場合、ターゲット系列を用いる
                decoder_input = batch_Y[t].unsqueeze(0)
            else:  # teacher forceでない場合、自身の出力を用いる
                decoder_input = decoder_output.max(-1)[1]
                
        return decoder_outputs]

次に訓練を行う。
基本的にはクロスエントロピーを損失関数として扱うが、パディングを行うとトークンが入るため、この部分の損失を計算しないように、マスクをかけます。

mce = nn.CrossEntropyLoss(size_average=False, ignore_index=PAD) # PADを無視する
def masked_cross_entropy(logits, target):
    logits_flat = logits.view(-1, logits.size(-1)) # (max_seq_len * batch_size, output_size)
    target_flat = target.view(-1) # (max_seq_len * batch_size, 1)
    return mce(logits_flat, target_flat)

学習を行う

# ハイパーパラメータの設定
num_epochs = 10
batch_size = 64
lr = 1e-3  # 学習率
teacher_forcing_rate = 0.2  # Teacher Forcingを行う確率
ckpt_path = 'model.pth'  # 学習済みのモデルを保存するパス

model_args = {
    'input_size': vocab_size_X,
    'output_size': vocab_size_Y,
    'hidden_size': 256,
}
# データローダを定義
train_dataloader = DataLoader(train_X, train_Y, batch_size=batch_size, shuffle=True)
valid_dataloader = DataLoader(valid_X, valid_Y, batch_size=batch_size, shuffle=False)

# モデルとOptimizerを定義
model = EncoderDecoder(**model_args).to(device)
optimizer = optim.Adam(model.parameters(), lr=lr)

実際に損失関数を計算する関数を定義する。

def compute_loss(batch_X, batch_Y, lengths_X, model, optimizer=None, is_train=True):
    # 損失を計算する関数
    model.train(is_train)  # train/evalモードの切替え
    
    # 一定確率でTeacher Forcingを行う
    use_teacher_forcing = is_train and (random.random() < teacher_forcing_rate)
    max_length = batch_Y.size(0)
    # 推論
    pred_Y = model(batch_X, lengths_X, max_length, batch_Y, use_teacher_forcing)
    
    # 損失関数を計算
    loss = masked_cross_entropy(pred_Y.contiguous(), batch_Y.contiguous())
    
    if is_train:  # 訓練時はパラメータを更新
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    
    batch_Y = batch_Y.transpose(0, 1).contiguous().data.cpu().tolist()
    pred = pred_Y.max(dim=-1)[1].data.cpu().numpy().T.tolist()

    return loss.item(), batch_Y, pred

LOSS以外に自然言語のモデルの性能を評価する指標で、BLEUというものがある。
BLEUは機械翻訳の分野において最も一般的な自動評価基準の一つで、予め用意した複数の参照訳と、機械翻訳モデルが出力した訳のn-gramのマッチ率に基づく指標である。

NLTK (Natural Language Tool Kit) という自然言語処理で用いられるライブラリを用いて簡単に計算することができる。

def calc_bleu(refs, hyps):
    """
    BLEUスコアを計算する関数
    :param refs: list, 参照訳。単語のリストのリスト (例: [['I', 'have', 'a', 'pen'], ...])
    :param hyps: list, モデルの生成した訳。単語のリストのリスト (例: ['I', 'have', 'a', 'pen'])
    :return: float, BLEUスコア(0~100)
    """
    refs = [[ref[:ref.index(EOS)]] for ref in refs] # EOSは評価しないで良いので切り捨てる, refsのほうは複数なのでlistが一個多くかかっている
    hyps = [hyp[:hyp.index(EOS)] if EOS in hyp else hyp for hyp in hyps]
    return 100 * bleu_score.corpus_bleu(refs, hyps)

モデルの訓練を行う。

# 訓練
best_valid_bleu = 0.

for epoch in range(1, num_epochs+1):
    train_loss = 0.
    train_refs = []
    train_hyps = []
    valid_loss = 0.
    valid_refs = []
    valid_hyps = []
    # train
    for batch in train_dataloader:
        batch_X, batch_Y, lengths_X = batch
        loss, gold, pred = compute_loss(
            batch_X, batch_Y, lengths_X, model, optimizer, 
            is_train=True
            )
        train_loss += loss
        train_refs += gold
        train_hyps += pred
    # valid
    for batch in valid_dataloader:
        batch_X, batch_Y, lengths_X = batch
        loss, gold, pred = compute_loss(
            batch_X, batch_Y, lengths_X, model, 
            is_train=False
            )
        valid_loss += loss
        valid_refs += gold
        valid_hyps += pred
    # 損失をサンプル数で割って正規化
    train_loss = np.sum(train_loss) / len(train_dataloader.data)
    valid_loss = np.sum(valid_loss) / len(valid_dataloader.data)
    # BLEUを計算
    train_bleu = calc_bleu(train_refs, train_hyps)
    valid_bleu = calc_bleu(valid_refs, valid_hyps)

    # validationデータでBLEUが改善した場合にはモデルを保存
    if valid_bleu > best_valid_bleu:
        ckpt = model.state_dict()
        torch.save(ckpt, ckpt_path)
        best_valid_bleu = valid_bleu

    print('Epoch {}: train_loss: {:5.2f}  train_bleu: {:2.2f}  valid_loss: {:5.2f}  valid_bleu: {:2.2f}'.format(
            epoch, train_loss, train_bleu, valid_loss, valid_bleu))
        
    print('-'*80)

学習結果は下記である。epochが進むにつれて、lossが下がり、bleuが上がる様子がわかる。

Epoch 1: train_loss: 52.31  train_bleu: 3.39  valid_loss: 48.69  valid_bleu: 4.53
--------------------------------------------------------------------------------
Epoch 2: train_loss: 44.39  train_bleu: 7.72  valid_loss: 44.60  valid_bleu: 8.25
--------------------------------------------------------------------------------
Epoch 3: train_loss: 40.10  train_bleu: 11.43  valid_loss: 42.04  valid_bleu: 10.51
--------------------------------------------------------------------------------
Epoch 4: train_loss: 37.07  train_bleu: 14.47  valid_loss: 40.62  valid_bleu: 11.69
--------------------------------------------------------------------------------
Epoch 5: train_loss: 35.06  train_bleu: 16.38  valid_loss: 39.91  valid_bleu: 13.65
--------------------------------------------------------------------------------
Epoch 6: train_loss: 33.08  train_bleu: 18.89  valid_loss: 39.76  valid_bleu: 15.42
--------------------------------------------------------------------------------
Epoch 7: train_loss: 31.52  train_bleu: 20.73  valid_loss: 40.81  valid_bleu: 18.62
--------------------------------------------------------------------------------
Epoch 8: train_loss: 30.28  train_bleu: 22.82  valid_loss: 39.79  valid_bleu: 16.66
--------------------------------------------------------------------------------
Epoch 9: train_loss: 28.73  train_bleu: 25.12  valid_loss: 40.29  valid_bleu: 18.01
--------------------------------------------------------------------------------
Epoch 10: train_loss: 28.03  train_bleu: 26.22  valid_loss: 40.33  valid_bleu: 16.73
--------------------------------------------------------------------------------
>||
次に、実際に学習したモデルで実際に文章を生成してみる。
>|python|
# 学習済みモデルの読み込み
ckpt = torch.load(ckpt_path) # cpuで処理する場合はmap_locationで指定する必要があります。
model.load_state_dict(ckpt)
model.eval()
def ids_to_sentence(vocab, ids):
    # IDのリストを単語のリストに変換する
    return [vocab.id2word[_id] for _id in ids]

def trim_eos(ids):
    # IDのリストからEOS以降の単語を除外する
    if EOS in ids:
        return ids[:ids.index(EOS)]
    else:
        return ids
# テストデータの読み込み
test_X = load_data('./data/dev.en')
test_Y = load_data('./data/dev.ja')

test_X = [sentence_to_ids(vocab_X, sentence) for sentence in test_X]
test_Y = [sentence_to_ids(vocab_Y, sentence) for sentence in test_Y]

test_dataloader = DataLoader(test_X, test_Y, batch_size=1, shuffle=False)
# 生成
batch_X, batch_Y, lengths_X = next(test_dataloader)
sentence_X = ' '.join(ids_to_sentence(vocab_X, batch_X.data.cpu().numpy()[:-1, 0]))
sentence_Y = ' '.join(ids_to_sentence(vocab_Y, batch_Y.data.cpu().numpy()[:-1, 0]))
print('src: {}'.format(sentence_X))
print('tgt: {}'.format(sentence_Y))

output = model(batch_X, lengths_X, max_length=20)
output = output.max(dim=-1)[1].view(-1).data.cpu().tolist()
output_sentence = ' '.join(ids_to_sentence(vocab_Y, trim_eos(output)))
output_sentence_without_trim = ' '.join(ids_to_sentence(vocab_Y, output))
print('out: {}'.format(output_sentence))
print('without trim: {}'.format(output_sentence_without_trim))

結果は下記である。それなりにターゲットに近い文章が生成できている。

src: i can 't swim at all .
tgt: 私 は 少し も 泳げ な い 。
out: 私 は 全然 泳げ な い 。
without trim: 私 は 全然 泳げ な い 。 </S> </S> </S> </S> </S> </S> </S> </S> </S> </S> </S> </S> </S>

Bleuを評価する。

# BLEUの計算
test_dataloader = DataLoader(test_X, test_Y, batch_size=1, shuffle=False)
refs_list = []
hyp_list = []

for batch in test_dataloader:
    batch_X, batch_Y, lengths_X = batch
    pred_Y = model(batch_X, lengths_X, max_length=20)
    pred = pred_Y.max(dim=-1)[1].view(-1).data.cpu().tolist()
    refs = batch_Y.view(-1).data.cpu().tolist()
    refs_list.append(refs)
    hyp_list.append(pred)
bleu = calc_bleu(refs_list, hyp_list)
print(bleu)
17.71570480086109
  • BeamSearch

テストデータにたいしてあらたな文を生成する際、これまでは各時刻で最も確率の高い単語を生成し、それを次のステップの入力として使っていた。しかし、本当にやりたいことは、文全体の尤度が最も高くなるような文章の生成である。そこで各単語だけでなく、もう少し大局的な評価をしていく必要がある。
BeamSearchでは、各時刻において一定の数Kのそれまでのスコアの高い文を保持しながら選択をおこなっていく。

Transformer

ニューラルネット機械翻訳の問題点

 ニューラルネット機械翻訳の課題として文章が長くなると精度が落ちるという点がある。
 下図は、Encoder-Decoderモデルと統計的機械翻訳モデルの文章の長さに対する精度を比較したものである。
 統計的機械翻訳モデルは、文章が長くてもBleuスコアが落ちないのに対し、Encoder-Decoderモデルでは、文章が長くなった時に顕著にBleuスコアが低下することが分かる。
 これは、翻訳元の内容を一つのベクトルで表現しているため、文長が長くなると表現力が足りなくなるからである。
f:id:tibet:20211213095249p:plain

Attention(注意機構)

 上記課題を解決するものとして、Attentionが提案された。
 これは、(翻訳タスクの場合)翻訳先の各単語を選択する際に、翻訳元の文中の隠れ状態に重みをつけて利用する機構である。言い換えると、翻訳の際に、翻訳元のどの単語に注意を当てて処理するかを決める機構である。
f:id:tibet:20211213095917p:plain
下記はAttentionの例である。
英語→フランス語の翻訳で、注意が当たっている語句が白くハイライトされている。対訳の語句が最も白くなっていることが分かるが、関連する語句(EuropianとEconomic等)も薄くハイライトされていることが分かる。
f:id:tibet:20211213110230p:plain

Attentionの仕組み

非常に簡単に言えば、Query(検索クエリ)に一致する
Keyを索引し、対応するValueを取り出す操作である。
つまり、辞書オブジェクトの機能と同じと言える。
f:id:tibet:20211213110907p:plain

Atenttionの性能

下図のように、文章が長くなっても翻訳精度は落ちない。
f:id:tibet:20211213111019p:plain

Transformer

Transformerは、"Attention is all you need"という論文で2017年6月に発表された機械学習翻訳のアルゴリズム
RNNを使わず、Attentionのみで構成されている。
当時のSOTAをRNNよりはるかに少ない計算量で実現している。
英仏(3600万文)の学習を8GPUで3.5日で完了している。

Transformerの主要モジュール

Transformerの主要モジュールは、下記の通り、Positional Encoding、Dot Product Attention、Feed Foward、Maskingである。
f:id:tibet:20211213111732p:plain

Attentionの種類

AttentionにはSource Target AttentionとSelf attentionの2種類がある。
Source Target Attentionは、Queryは外部のターゲットであり、KeyとValueは自身の入力データになる。Self-Attentionの場合は、Query, Key, Valueすべてが自身の入力データである。
f:id:tibet:20211213120258p:plain

Self-Attention

TransformerのEncoderでは、self-attentionを6層積んで各単語をエンコードする。
f:id:tibet:20211213120801p:plain
Self-Attentionでは、各単語をエンコードして重みづけしたものをそれぞれの単語の内部状態として持つ。
例えとしては、ウィンドウが文章全体に及ぶ畳み込み演算を行っているイメージである。
f:id:tibet:20211213120920p:plain

Position-Wise Feed-forward Network

 位置情報を保持したまま順伝搬させるブロック。
 2層の全結合ニューラルネットワークを用い、線形変換→ReLu→線形変換の順番で処理をする。

Scaled dot product attention

全単語に関するattentionをまとめて計算する
 d_kは、次元数である。
f:id:tibet:20211213165630p:plain

Multi Head Attention

重みパラメータの異なる8個のヘッドを使用する。
畳み込みで8つのカーネルフィルターを使用するイメージ
f:id:tibet:20211213165930p:plain

Decoder

 Encoderと同じく6層のattentionで構成されている。
 各層で2種類のattention機構を用いている。
 self-attentionは直下の層の出力へ当てんっし恩をかけている。
 未来の情報を見ないようにマスクしている。
 Encoder-Decoder attentionは入力文の情報を収集する役割である。encoderの出力へアテンションをかけている。
f:id:tibet:20211213171040p:plain

Add&Norm

 計算を効率よく行うために加えた層

  • Add(Residual Connection): 入出力の差分を学習させる層。具体的には、出力に入力をそのまま加算させるだけである。学習・テストエラーの低減効果を狙っている。
  • Norm(Layer Normalization): 各層においてバイアスを除く活性化関数への入力を平均0、分散1に正規化する。学習の高速化を狙っている。
Positional Encoding

RNNを用いないので、単語列の語順情報を追加する必要があるため、この層がある。
単語の位置情報を、下記のような三角関数エンコードする。
 PE_{(pos,2i)}=sin\left(\dfrac{pos}{10000^{2i/512}}\right)
 PE_{(pos,2i+1)}=cos\left(\dfrac{pos}{10000^{2i/512}}\right)
これらをconcatすることで、位置のソフトな2進数表現を得る。
下記はPosition Encodingした単語のイメージで、右半分はcosによる生成、左半分はsinによる生成である。
f:id:tibet:20211213193218p:plain

Attentionの可視化

Attetionを可視化した図が下記である。
左の上の文章は、it was too tiredの「it」はAnimalを強く差している。
一方で「it was too wide」では、「it」はstreetを強く差しており、attentionが文意を踏まえて注意を向けていることが分かる。
f:id:tibet:20211213193345p:plain
 

実装演習

Transformerの実装の演習をする。
transformerはRNNやCNNを使用せず、Attentionのみを用いるモデルである。
翻訳に限らない自然言語処理のあらゆるタスクで圧倒的な性能を示すことが知られている。
まず、必要なライブラリと環境を準備する。

! wget https://www.dropbox.com/s/9narw5x4uizmehh/utils.py
! mkdir images data

# data取得
! wget https://www.dropbox.com/s/o4kyc52a8we25wy/dev.en -P data/
! wget https://www.dropbox.com/s/kdgskm5hzg6znuc/dev.ja -P data/
! wget https://www.dropbox.com/s/gyyx4gohv9v65uh/test.en -P data/
! wget https://www.dropbox.com/s/hotxwbgoe2n013k/test.ja -P data/
! wget https://www.dropbox.com/s/5lsftkmb20ay9e1/train.en -P data/
! wget https://www.dropbox.com/s/ak53qirssci6f1j/train.ja -P data/

import time
import numpy as np
from sklearn.utils import shuffle
from sklearn.model_selection import train_test_split
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
%matplotlib inline

from nltk import bleu_score

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F

from utils import Vocab

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

torch.manual_seed(1)
random_state = 42

特殊文字トークンを定義する。

PAD = 0
UNK = 1
BOS = 2
EOS = 3

PAD_TOKEN = '<PAD>'
UNK_TOKEN = '<UNK>'
BOS_TOKEN = '<S>'
EOS_TOKEN = '</S>'

データセットを準備し、データローダーの定義などをする。

def load_data(file_path):
    """
    テキストファイルからデータを読み込む
    :param file_path: str, テキストファイルのパス
    :return data: list, 文章(単語のリスト)のリスト
    """
    data = []
    for line in open(file_path, encoding='utf-8'):
        words = line.strip().split()  # スペースで単語を分割
        data.append(words)
    return data
train_X = load_data('./data/train.en')
train_Y = load_data('./data/train.ja')
# 訓練データと検証データに分割
train_X, valid_X, train_Y, valid_Y = train_test_split(train_X, train_Y, test_size=0.2, random_state=random_state)
MIN_COUNT = 2  # 語彙に含める単語の最低出現回数

word2id = {
    PAD_TOKEN: PAD,
    BOS_TOKEN: BOS,
    EOS_TOKEN: EOS,
    UNK_TOKEN: UNK,
    }

vocab_X = Vocab(word2id=word2id)
vocab_Y = Vocab(word2id=word2id)
vocab_X.build_vocab(train_X, min_count=MIN_COUNT)
vocab_Y.build_vocab(train_Y, min_count=MIN_COUNT)

vocab_size_X = len(vocab_X.id2word)
vocab_size_Y = len(vocab_Y.id2word)
def sentence_to_ids(vocab, sentence):
    """
    単語のリストをインデックスのリストに変換する
    :param vocab: Vocabのインスタンス
    :param sentence: list of str
    :return indices: list of int
    """
    ids = [vocab.word2id.get(word, UNK) for word in sentence]
    ids = [BOS] + ids + [EOS]  # EOSを末尾に加える
    return ids

train_X = [sentence_to_ids(vocab_X, sentence) for sentence in train_X]
train_Y = [sentence_to_ids(vocab_Y, sentence) for sentence in train_Y]
valid_X = [sentence_to_ids(vocab_X, sentence) for sentence in valid_X]
valid_Y = [sentence_to_ids(vocab_Y, sentence) for sentence in valid_Y]

class DataLoader(object):
    def __init__(self, src_insts, tgt_insts, batch_size, shuffle=True):
        """
        :param src_insts: list, 入力言語の文章(単語IDのリスト)のリスト
        :param tgt_insts: list, 出力言語の文章(単語IDのリスト)のリスト
        :param batch_size: int, バッチサイズ
        :param shuffle: bool, サンプルの順番をシャッフルするか否か
        """
        self.data = list(zip(src_insts, tgt_insts))

        self.batch_size = batch_size
        self.shuffle = shuffle
        self.start_index = 0
        
        self.reset()
    
    def reset(self):
        if self.shuffle:
            self.data = shuffle(self.data, random_state=random_state)
        self.start_index = 0
    
    def __iter__(self):
        return self
    
    def __next__(self):

        def preprocess_seqs(seqs):
            # パディング
            max_length = max([len(s) for s in seqs])
            data = [s + [PAD] * (max_length - len(s)) for s in seqs]
            # 単語の位置を表現するベクトルを作成
            positions = [[pos+1 if w != PAD else 0 for pos, w in enumerate(seq)] for seq in data]
            # テンソルに変換
            data_tensor = torch.tensor(data, dtype=torch.long, device=device)
            position_tensor = torch.tensor(positions, dtype=torch.long, device=device)
            return data_tensor, position_tensor            

        # ポインタが最後まで到達したら初期化する
        if self.start_index >= len(self.data):
            self.reset()
            raise StopIteration()

        # バッチを取得して前処理
        src_seqs, tgt_seqs = zip(*self.data[self.start_index:self.start_index+self.batch_size])
        src_data, src_pos = preprocess_seqs(src_seqs)
        tgt_data, tgt_pos = preprocess_seqs(tgt_seqs)

        # ポインタを更新する
        self.start_index += self.batch_size

        return (src_data, src_pos), (tgt_data, tgt_pos)
  • 各モジュールの定義

1.Position Encoding
入力系列の埋め込み行列に単語の位置情報を加算する。

def position_encoding_init(n_position, d_pos_vec):
    """
    Positional Encodingのための行列の初期化を行う
    :param n_position: int, 系列長
    :param d_pos_vec: int, 隠れ層の次元数
    :return torch.tensor, size=(n_position, d_pos_vec)
    """
    # PADがある単語の位置はpos=0にしておき、position_encも0にする
    position_enc = np.array([
        [pos / np.power(10000, 2 * (j // 2) / d_pos_vec) for j in range(d_pos_vec)]
        if pos != 0 else np.zeros(d_pos_vec) for pos in range(n_position)])
    position_enc[1:, 0::2] = np.sin(position_enc[1:, 0::2])  # dim 2i
    position_enc[1:, 1::2] = np.cos(position_enc[1:, 1::2])  # dim 2i+1
    return torch.tensor(position_enc, dtype=torch.float)

Position Encodingを可視化すると次のようになる。
縦軸が単語の位置を、横軸が成分の次元を表しており、濃淡が加算される値である。
ここでは最大系列長を50、隠れ層の次元数を256とした。

pe = position_encoding_init(50, 256).numpy()
plt.figure(figsize=(16,8))
sns.heatmap(pe, cmap='Blues')
plt.show()

f:id:tibet:20211213203250p:plain

2.Attention
Scaled Dot-Product Attention
Attentionには、注意の重みを隠れ層一つのフィードフォワードネットワークで求めるAdditive Attentionと、注意の重みを内積で求めるDot-prudoct Attentionが存在する。
一般にDot-Product Attentionのほうがパラメータが少なくて高速であり、Transformerではこちらを使う。

class ScaledDotProductAttention(nn.Module):
    
    def __init__(self, d_model, attn_dropout=0.1):
        """
        :param d_model: int, 隠れ層の次元数
        :param attn_dropout: float, ドロップアウト率
        """
        super(ScaledDotProductAttention, self).__init__()
        self.temper = np.power(d_model, 0.5)  # スケーリング因子
        self.dropout = nn.Dropout(attn_dropout)
        self.softmax = nn.Softmax(dim=-1)

    def forward(self, q, k, v, attn_mask):
        """
        :param q: torch.tensor, queryベクトル, 
            size=(n_head*batch_size, len_q, d_model/n_head)
        :param k: torch.tensor, key, 
            size=(n_head*batch_size, len_k, d_model/n_head)
        :param v: torch.tensor, valueベクトル, 
            size=(n_head*batch_size, len_v, d_model/n_head)
        :param attn_mask: torch.tensor, Attentionに適用するマスク, 
            size=(n_head*batch_size, len_q, len_k)
        :return output: 出力ベクトル, 
            size=(n_head*batch_size, len_q, d_model/n_head)
        :return attn: Attention
            size=(n_head*batch_size, len_q, len_k)
        """
        # QとKの内積でAttentionの重みを求め、スケーリングする
        attn = torch.bmm(q, k.transpose(1, 2)) / self.temper  # (n_head*batch_size, len_q, len_k)
        # Attentionをかけたくない部分がある場合は、その部分を負の無限大に飛ばしてSoftmaxの値が0になるようにする
        attn.data.masked_fill_(attn_mask, -float('inf'))
        
        attn = self.softmax(attn)
        attn = self.dropout(attn)
        output = torch.bmm(attn, v)

        return output, attn

Multi-Head Attention
TransformerではAttentionを複数のヘッドで並列に扱うMulti-Head Attentionを採用している。複数のヘッドでAttentionを行うことにより、各ヘッドが異なる部分空間を処理でき、精度が向上する。

class MultiHeadAttention(nn.Module):
    def __init__(self, n_head, d_model, d_k, d_v, dropout=0.1):
        """
        :param n_head: int, ヘッド数
        :param d_model: int, 隠れ層の次元数
        :param d_k: int, keyベクトルの次元数
        :param d_v: int, valueベクトルの次元数
        :param dropout: float, ドロップアウト率
        """
        super(MultiHeadAttention, self).__init__()

        self.n_head = n_head
        self.d_k = d_k
        self.d_v = d_v

        # 各ヘッドごとに異なる重みで線形変換を行うための重み
        # nn.Parameterを使うことで、Moduleのパラメータとして登録できる. TFでは更新が必要な変数はtf.Variableでラップするのでわかりやすい
        self.w_qs = nn.Parameter(torch.empty([n_head, d_model, d_k], dtype=torch.float))
        self.w_ks = nn.Parameter(torch.empty([n_head, d_model, d_k], dtype=torch.float))
        self.w_vs = nn.Parameter(torch.empty([n_head, d_model, d_v], dtype=torch.float))
        # nn.init.xavier_normal_で重みの値を初期化
        nn.init.xavier_normal_(self.w_qs)
        nn.init.xavier_normal_(self.w_ks)
        nn.init.xavier_normal_(self.w_vs)

        self.attention = ScaledDotProductAttention(d_model)
        self.layer_norm = nn.LayerNorm(d_model) # 各層においてバイアスを除く活性化関数への入力を平均0、分散1に正則化
        self.proj = nn.Linear(n_head*d_v, d_model)  # 複数ヘッド分のAttentionの結果を元のサイズに写像するための線形層
        # nn.init.xavier_normal_で重みの値を初期化
        nn.init.xavier_normal_(self.proj.weight)
        
        self.dropout = nn.Dropout(dropout)


    def forward(self, q, k, v, attn_mask=None):
        """
        :param q: torch.tensor, queryベクトル, 
            size=(batch_size, len_q, d_model)
        :param k: torch.tensor, key, 
            size=(batch_size, len_k, d_model)
        :param v: torch.tensor, valueベクトル, 
            size=(batch_size, len_v, d_model)
        :param attn_mask: torch.tensor, Attentionに適用するマスク, 
            size=(batch_size, len_q, len_k)
        :return outputs: 出力ベクトル, 
            size=(batch_size, len_q, d_model)
        :return attns: Attention
            size=(n_head*batch_size, len_q, len_k)
            
        """
        d_k, d_v = self.d_k, self.d_v
        n_head = self.n_head

        # residual connectionのための入力 出力に入力をそのまま加算する
        residual = q

        batch_size, len_q, d_model = q.size()
        batch_size, len_k, d_model = k.size()
        batch_size, len_v, d_model = v.size()
        # 複数ヘッド化
        # torch.repeat または .repeatで指定したdimに沿って同じテンソルを作成
        q_s = q.repeat(n_head, 1, 1) # (n_head*batch_size, len_q, d_model)
        k_s = k.repeat(n_head, 1, 1) # (n_head*batch_size, len_k, d_model)
        v_s = v.repeat(n_head, 1, 1) # (n_head*batch_size, len_v, d_model)
        # ヘッドごとに並列計算させるために、n_headをdim=0に、batch_sizeをdim=1に寄せる
        q_s = q_s.view(n_head, -1, d_model) # (n_head, batch_size*len_q, d_model)
        k_s = k_s.view(n_head, -1, d_model) # (n_head, batch_size*len_k, d_model)
        v_s = v_s.view(n_head, -1, d_model) # (n_head, batch_size*len_v, d_model)

        # 各ヘッドで線形変換を並列計算(p16左側`Linear`)
        q_s = torch.bmm(q_s, self.w_qs)  # (n_head, batch_size*len_q, d_k)
        k_s = torch.bmm(k_s, self.w_ks)  # (n_head, batch_size*len_k, d_k)
        v_s = torch.bmm(v_s, self.w_vs)  # (n_head, batch_size*len_v, d_v)
        # Attentionは各バッチ各ヘッドごとに計算させるためにbatch_sizeをdim=0に寄せる
        q_s = q_s.view(-1, len_q, d_k)   # (n_head*batch_size, len_q, d_k)
        k_s = k_s.view(-1, len_k, d_k)   # (n_head*batch_size, len_k, d_k)
        v_s = v_s.view(-1, len_v, d_v)   # (n_head*batch_size, len_v, d_v)

        # Attentionを計算(p16.左側`Scaled Dot-Product Attention * h`)
        outputs, attns = self.attention(q_s, k_s, v_s, attn_mask=attn_mask.repeat(n_head, 1, 1))

        # 各ヘッドの結果を連結(p16左側`Concat`)
        # torch.splitでbatch_sizeごとのn_head個のテンソルに分割
        outputs = torch.split(outputs, batch_size, dim=0)  # (batch_size, len_q, d_model) * n_head
        # dim=-1で連結
        outputs = torch.cat(outputs, dim=-1)  # (batch_size, len_q, d_model*n_head)

        # residual connectionのために元の大きさに写像(p16左側`Linear`)
        outputs = self.proj(outputs)  # (batch_size, len_q, d_model)
        outputs = self.dropout(outputs)
        outputs = self.layer_norm(outputs + residual)

        return outputs, attns

3.Position-Wise Feed Foward Network
単語列の位置ごとに独立して処理する2層のネットワークであるPosition-Wise Feed Forward Networkを定義する。

class PositionwiseFeedForward(nn.Module):
    """
    :param d_hid: int, 隠れ層1層目の次元数
    :param d_inner_hid: int, 隠れ層2層目の次元数
    :param dropout: float, ドロップアウト率
    """
    def __init__(self, d_hid, d_inner_hid, dropout=0.1):
        super(PositionwiseFeedForward, self).__init__()
        # window size 1のconv層を定義することでPosition wiseな全結合層を実現する.
        self.w_1 = nn.Conv1d(d_hid, d_inner_hid, 1)
        self.w_2 = nn.Conv1d(d_inner_hid, d_hid, 1)
        self.layer_norm = nn.LayerNorm(d_hid)
        self.dropout = nn.Dropout(dropout)
        self.relu = nn.ReLU()

    def forward(self, x):
        """
        :param x: torch.tensor,
            size=(batch_size, max_length, d_hid)
        :return: torch.tensor,
            size=(batch_size, max_length, d_hid) 
        """
        residual = x
        output = self.relu(self.w_1(x.transpose(1, 2)))
        output = self.w_2(output).transpose(2, 1)
        output = self.dropout(output)
        return self.layer_norm(output + residual)

4.Masking
TransformerではAttentionに対して2つのマスクを定義する。
一つはkey側の系列のPADトークンに対してAttentionを行わないようにするマスク。

def get_attn_padding_mask(seq_q, seq_k):
    """
    keyのPADに対するattentionを0にするためのマスクを作成する
    :param seq_q: tensor, queryの系列, size=(batch_size, len_q)
    :param seq_k: tensor, keyの系列, size=(batch_size, len_k)
    :return pad_attn_mask: tensor, size=(batch_size, len_q, len_k)
    """
    batch_size, len_q = seq_q.size()
    batch_size, len_k = seq_k.size()
    pad_attn_mask = seq_k.data.eq(PAD).unsqueeze(1)   # (N, 1, len_k) PAD以外のidを全て0にする
    pad_attn_mask = pad_attn_mask.expand(batch_size, len_q, len_k) # (N, len_q, len_k)
    return pad_attn_mask

もう一つはDecoder側でSelf Attensionを行う際に、各時刻で未来の情報に対するAttentionを行わないようにするマスクである。

def get_attn_subsequent_mask(seq):
    """
    未来の情報に対するattentionを0にするためのマスクを作成する
    :param seq: tensor, size=(batch_size, length)
    :return subsequent_mask: tensor, size=(batch_size, length, length)
    """
    attn_shape = (seq.size(1), seq.size(1))
    # 上三角行列(diagonal=1: 対角線より上が1で下が0)
    subsequent_mask = torch.triu(torch.ones(attn_shape, dtype=torch.uint8, device=device), diagonal=1)
    subsequent_mask = subsequent_mask.repeat(seq.size(0), 1, 1)
    return subsequent_mask
  • モデルの定義

Encoder
Encoderは、Self AttentionとPosition-Wise Forward Networkからなるブロックを拭く早々繰り返すので、ブロックのクラスEnoderLayerを定義した後にEncoderを定義

class EncoderLayer(nn.Module):
    """Encoderのブロックのクラス"""
    def __init__(self, d_model, d_inner_hid, n_head, d_k, d_v, dropout=0.1):
        """
        :param d_model: int, 隠れ層の次元数
        :param d_inner_hid: int, Position Wise Feed Forward Networkの隠れ層2層目の次元数
        :param n_head: int, ヘッド数
        :param d_k: int, keyベクトルの次元数
        :param d_v: int, valueベクトルの次元数
        :param dropout: float, ドロップアウト率
        """
        super(EncoderLayer, self).__init__()
        # Encoder内のSelf-Attention
        self.slf_attn = MultiHeadAttention(
            n_head, d_model, d_k, d_v, dropout=dropout)
        # Postionwise FFN
        self.pos_ffn = PositionwiseFeedForward(d_model, d_inner_hid, dropout=dropout)
    def forward(self, enc_input, slf_attn_mask=None):
        """
        :param enc_input: tensor, Encoderの入力, 
            size=(batch_size, max_length, d_model)
        :param slf_attn_mask: tensor, Self Attentionの行列にかけるマスク, 
            size=(batch_size, len_q, len_k)
        :return enc_output: tensor, Encoderの出力, 
            size=(batch_size, max_length, d_model)
        :return enc_slf_attn: tensor, EncoderのSelf Attentionの行列, 
            size=(n_head*batch_size, len_q, len_k)
        """
        # Self-Attentionのquery, key, valueにはすべてEncoderの入力(enc_input)が入る
        enc_output, enc_slf_attn = self.slf_attn(
            enc_input, enc_input, enc_input, attn_mask=slf_attn_mask)
        enc_output = self.pos_ffn(enc_output)
        return enc_output, enc_slf_attn

class Encoder(nn.Module):
    """EncoderLayerブロックからなるEncoderのクラス"""
    def __init__(
            self, n_src_vocab, max_length, n_layers=6, n_head=8, d_k=64, d_v=64,
            d_word_vec=512, d_model=512, d_inner_hid=1024, dropout=0.1):
        """
        :param n_src_vocab: int, 入力言語の語彙数
        :param max_length: int, 最大系列長
        :param n_layers: int, レイヤー数
        :param n_head: int, ヘッド数
        :param d_k: int, keyベクトルの次元数
        :param d_v: int, valueベクトルの次元数
        :param d_word_vec: int, 単語の埋め込みの次元数
        :param d_model: int, 隠れ層の次元数
        :param d_inner_hid: int, Position Wise Feed Forward Networkの隠れ層2層目の次元数
        :param dropout: float, ドロップアウト率        
        """
        super(Encoder, self).__init__()

        n_position = max_length + 1
        self.max_length = max_length
        self.d_model = d_model

        # Positional Encodingを用いたEmbedding
        self.position_enc = nn.Embedding(n_position, d_word_vec, padding_idx=PAD)
        self.position_enc.weight.data = position_encoding_init(n_position, d_word_vec)

        # 一般的なEmbedding
        self.src_word_emb = nn.Embedding(n_src_vocab, d_word_vec, padding_idx=PAD)

        # EncoderLayerをn_layers個積み重ねる
        self.layer_stack = nn.ModuleList([
            EncoderLayer(d_model, d_inner_hid, n_head, d_k, d_v, dropout=dropout)
            for _ in range(n_layers)])

    def forward(self, src_seq, src_pos):
        """
        :param src_seq: tensor, 入力系列, 
            size=(batch_size, max_length)
        :param src_pos: tensor, 入力系列の各単語の位置情報,
            size=(batch_size, max_length)
        :return enc_output: tensor, Encoderの最終出力, 
            size=(batch_size, max_length, d_model)
        :return enc_slf_attns: list, EncoderのSelf Attentionの行列のリスト
        """
        # 一般的な単語のEmbeddingを行う
        enc_input = self.src_word_emb(src_seq)
        # Positional EncodingのEmbeddingを加算する
        enc_input += self.position_enc(src_pos)

        enc_slf_attns = []
        enc_output = enc_input
        # key(=enc_input)のPADに対応する部分のみ1のマスクを作成
        enc_slf_attn_mask = get_attn_padding_mask(src_seq, src_seq)

        # n_layers個のEncoderLayerに入力を通す
        for enc_layer in self.layer_stack:
            enc_output, enc_slf_attn = enc_layer(
                enc_output, slf_attn_mask=enc_slf_attn_mask)
            enc_slf_attns += [enc_slf_attn]

        return enc_output, enc_slf_attns
  • Decoder

Self Attention, source-target Atetntion, Position-Wise Feed Foward Networkからなるブロックを複数層繰り返すので、ブロックのクラスDecoderLayerを定義した後に、Decoderを定義する。

class DecoderLayer(nn.Module):
    """Decoderのブロックのクラス"""
    def __init__(self, d_model, d_inner_hid, n_head, d_k, d_v, dropout=0.1):
        """
        :param d_model: int, 隠れ層の次元数
        :param d_inner_hid: int, Position Wise Feed Forward Networkの隠れ層2層目の次元数
        :param n_head: int, ヘッド数
        :param d_k: int, keyベクトルの次元数
        :param d_v: int, valueベクトルの次元数
        :param dropout: float, ドロップアウト率
        """
        super(DecoderLayer, self).__init__()
        # Decoder内のSelf-Attention
        self.slf_attn = MultiHeadAttention(n_head, d_model, d_k, d_v, dropout=dropout)
        # Encoder-Decoder間のSource-Target Attention
        self.enc_attn = MultiHeadAttention(n_head, d_model, d_k, d_v, dropout=dropout)
        # Positionwise FFN
        self.pos_ffn = PositionwiseFeedForward(d_model, d_inner_hid, dropout=dropout)
def forward(self, dec_input, enc_output, slf_attn_mask=None, dec_enc_attn_mask=None):
        """
        :param dec_input: tensor, Decoderの入力, 
            size=(batch_size, max_length, d_model)
        :param enc_output: tensor, Encoderの出力, 
            size=(batch_size, max_length, d_model)
        :param slf_attn_mask: tensor, Self Attentionの行列にかけるマスク, 
            size=(batch_size, len_q, len_k)
        :param dec_enc_attn_mask: tensor, Soutce-Target Attentionの行列にかけるマスク, 
            size=(batch_size, len_q, len_k)
        :return dec_output: tensor, Decoderの出力, 
            size=(batch_size, max_length, d_model)
        :return dec_slf_attn: tensor, DecoderのSelf Attentionの行列, 
            size=(n_head*batch_size, len_q, len_k)
        :return dec_enc_attn: tensor, DecoderのSoutce-Target Attentionの行列, 
            size=(n_head*batch_size, len_q, len_k)
        """
        # Self-Attentionのquery, key, valueにはすべてDecoderの入力(dec_input)が入る
        dec_output, dec_slf_attn = self.slf_attn(
            dec_input, dec_input, dec_input, attn_mask=slf_attn_mask)
        # Source-Target-AttentionのqueryにはDecoderの出力(dec_output), key, valueにはEncoderの出力(enc_output)が入る
        dec_output, dec_enc_attn = self.enc_attn(
            dec_output, enc_output, enc_output, attn_mask=dec_enc_attn_mask)
        dec_output = self.pos_ffn(dec_output)

        return dec_output, dec_slf_attn, dec_enc_attn

class Decoder(nn.Module):
    """DecoderLayerブロックからなるDecoderのクラス"""
    def __init__(
            self, n_tgt_vocab, max_length, n_layers=6, n_head=8, d_k=64, d_v=64,
            d_word_vec=512, d_model=512, d_inner_hid=1024, dropout=0.1):
        """
        :param n_tgt_vocab: int, 出力言語の語彙数
        :param max_length: int, 最大系列長
        :param n_layers: int, レイヤー数
        :param n_head: int, ヘッド数
        :param d_k: int, keyベクトルの次元数
        :param d_v: int, valueベクトルの次元数
        :param d_word_vec: int, 単語の埋め込みの次元数
        :param d_model: int, 隠れ層の次元数
        :param d_inner_hid: int, Position Wise Feed Forward Networkの隠れ層2層目の次元数
        :param dropout: float, ドロップアウト率        
        """
        super(Decoder, self).__init__()
        n_position = max_length + 1
        self.max_length = max_length
        self.d_model = d_model

        # Positional Encodingを用いたEmbedding
        self.position_enc = nn.Embedding(
            n_position, d_word_vec, padding_idx=PAD)
        self.position_enc.weight.data = position_encoding_init(n_position, d_word_vec)
        # 一般的なEmbedding
        self.tgt_word_emb = nn.Embedding(
            n_tgt_vocab, d_word_vec, padding_idx=PAD)
        self.dropout = nn.Dropout(dropout)

        # DecoderLayerをn_layers個積み重ねる
        self.layer_stack = nn.ModuleList([
            DecoderLayer(d_model, d_inner_hid, n_head, d_k, d_v, dropout=dropout)
            for _ in range(n_layers)])

    def forward(self, tgt_seq, tgt_pos, src_seq, enc_output):
        """
        :param tgt_seq: tensor, 出力系列, 
            size=(batch_size, max_length)
        :param tgt_pos: tensor, 出力系列の各単語の位置情報,
            size=(batch_size, max_length)
        :param src_seq: tensor, 入力系列, 
            size=(batch_size, n_src_vocab)
        :param enc_output: tensor, Encoderの出力, 
            size=(batch_size, max_length, d_model)
        :return dec_output: tensor, Decoderの最終出力, 
            size=(batch_size, max_length, d_model)
        :return dec_slf_attns: list, DecoderのSelf Attentionの行列のリスト 
        :return dec_slf_attns: list, DecoderのSelf Attentionの行列のリスト
        """
        # 一般的な単語のEmbeddingを行う
        dec_input = self.tgt_word_emb(tgt_seq)
        # Positional EncodingのEmbeddingを加算する
        dec_input += self.position_enc(tgt_pos)

        # Self-Attention用のマスクを作成
        # key(=dec_input)のPADに対応する部分が1のマスクと、queryから見たkeyの未来の情報に対応する部分が1のマスクのORをとる
        dec_slf_attn_pad_mask = get_attn_padding_mask(tgt_seq, tgt_seq)  # (N, max_length, max_length)
        dec_slf_attn_sub_mask = get_attn_subsequent_mask(tgt_seq)  # (N, max_length, max_length)
        dec_slf_attn_mask = torch.gt(dec_slf_attn_pad_mask + dec_slf_attn_sub_mask, 0)  # ORをとる

        # key(=dec_input)のPADに対応する部分のみ1のマスクを作成
        dec_enc_attn_pad_mask = get_attn_padding_mask(tgt_seq, src_seq)  # (N, max_length, max_length)

        dec_slf_attns, dec_enc_attns = [], []

        dec_output = dec_input
        # n_layers個のDecoderLayerに入力を通す
        for dec_layer in self.layer_stack:
            dec_output, dec_slf_attn, dec_enc_attn = dec_layer(
                dec_output, enc_output,
                slf_attn_mask=dec_slf_attn_mask,
                dec_enc_attn_mask=dec_enc_attn_pad_mask)

            dec_slf_attns += [dec_slf_attn]
            dec_enc_attns += [dec_enc_attn]

        return dec_output, dec_slf_attns, dec_enc_attns

Transofomer全体のモデルのクラスを定義する。

class Transformer(nn.Module):
    """Transformerのモデル全体のクラス"""
    def __init__(
            self, n_src_vocab, n_tgt_vocab, max_length, n_layers=6, n_head=8,
            d_word_vec=512, d_model=512, d_inner_hid=1024, d_k=64, d_v=64,
            dropout=0.1, proj_share_weight=True):
        """
        :param n_src_vocab: int, 入力言語の語彙数
        :param n_tgt_vocab: int, 出力言語の語彙数
        :param max_length: int, 最大系列長
        :param n_layers: int, レイヤー数
        :param n_head: int, ヘッド数
        :param d_k: int, keyベクトルの次元数
        :param d_v: int, valueベクトルの次元数
        :param d_word_vec: int, 単語の埋め込みの次元数
        :param d_model: int, 隠れ層の次元数
        :param d_inner_hid: int, Position Wise Feed Forward Networkの隠れ層2層目の次元数
        :param dropout: float, ドロップアウト率        
        :param proj_share_weight: bool, 出力言語の単語のEmbeddingと出力の写像で重みを共有する        
        """
        super(Transformer, self).__init__()
        self.encoder = Encoder(
            n_src_vocab, max_length, n_layers=n_layers, n_head=n_head,
            d_word_vec=d_word_vec, d_model=d_model,
            d_inner_hid=d_inner_hid, dropout=dropout)
        self.decoder = Decoder(
            n_tgt_vocab, max_length, n_layers=n_layers, n_head=n_head,
            d_word_vec=d_word_vec, d_model=d_model,
            d_inner_hid=d_inner_hid, dropout=dropout)
        self.tgt_word_proj = nn.Linear(d_model, n_tgt_vocab, bias=False)
        nn.init.xavier_normal_(self.tgt_word_proj.weight)
        self.dropout = nn.Dropout(dropout)

        assert d_model == d_word_vec  # 各モジュールの出力のサイズは揃える

        if proj_share_weight:
            # 出力言語の単語のEmbeddingと出力の写像で重みを共有する
            assert d_model == d_word_vec
            self.tgt_word_proj.weight = self.decoder.tgt_word_emb.weight

    def get_trainable_parameters(self):
        # Positional Encoding以外のパラメータを更新する
        enc_freezed_param_ids = set(map(id, self.encoder.position_enc.parameters()))
        dec_freezed_param_ids = set(map(id, self.decoder.position_enc.parameters()))
        freezed_param_ids = enc_freezed_param_ids | dec_freezed_param_ids
        return (p for p in self.parameters() if id(p) not in freezed_param_ids)

    def forward(self, src, tgt):
        src_seq, src_pos = src
        tgt_seq, tgt_pos = tgt

        src_seq = src_seq[:, 1:]
        src_pos = src_pos[:, 1:]
        tgt_seq = tgt_seq[:, :-1]
        tgt_pos = tgt_pos[:, :-1]

        enc_output, *_ = self.encoder(src_seq, src_pos)
        dec_output, *_ = self.decoder(tgt_seq, tgt_pos, src_seq, enc_output)
        seq_logit = self.tgt_word_proj(dec_output)

        return seq_logit

学習

def compute_loss(batch_X, batch_Y, model, criterion, optimizer=None, is_train=True):
    # バッチの損失を計算
    model.train(is_train)
    
    pred_Y = model(batch_X, batch_Y)
    gold = batch_Y[0][:, 1:].contiguous()
#     gold = batch_Y[0].contiguous()
    loss = criterion(pred_Y.view(-1, pred_Y.size(2)), gold.view(-1))

    if is_train:  # 訓練時はパラメータを更新
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    gold = gold.data.cpu().numpy().tolist()
    pred = pred_Y.max(dim=-1)[1].data.cpu().numpy().tolist()

    return loss.item(), gold, pred

MAX_LENGTH = 20
batch_size = 64
num_epochs = 15
lr = 0.001
ckpt_path = 'transformer.pth'
max_length = MAX_LENGTH + 2

model_args = {
    'n_src_vocab': vocab_size_X,
    'n_tgt_vocab': vocab_size_Y,
    'max_length': max_length,
    'proj_share_weight': True,
    'd_k': 32,
    'd_v': 32,
    'd_model': 128,
    'd_word_vec': 128,
    'd_inner_hid': 256,
    'n_layers': 3,
    'n_head': 6,
    'dropout': 0.1,
}

# DataLoaderやモデルを定義
train_dataloader = DataLoader(
    train_X, train_Y, batch_size
    )
valid_dataloader = DataLoader(
    valid_X, valid_Y, batch_size, 
    shuffle=False
    )

model = Transformer(**model_args).to(device)

optimizer = optim.Adam(model.get_trainable_parameters(), lr=lr)

criterion = nn.CrossEntropyLoss(ignore_index=PAD, size_average=False).to(device)

def calc_bleu(refs, hyps):
    """
    BLEUスコアを計算する関数
    :param refs: list, 参照訳。単語のリストのリスト (例: [['I', 'have', 'a', 'pen'], ...])
    :param hyps: list, モデルの生成した訳。単語のリストのリスト (例: [['I', 'have', 'a', 'pen'], ...])
    :return: float, BLEUスコア(0~100)
    """
    refs = [[ref[:ref.index(EOS)]] for ref in refs]
    hyps = [hyp[:hyp.index(EOS)] if EOS in hyp else hyp for hyp in hyps]
    return 100 * bleu_score.corpus_bleu(refs, hyps)

# 訓練
best_valid_bleu = 0.

for epoch in range(1, num_epochs+1):
    start = time.time()
    train_loss = 0.
    train_refs = []
    train_hyps = []
    valid_loss = 0.
    valid_refs = []
    valid_hyps = []
    # train
    for batch in train_dataloader:
        batch_X, batch_Y = batch
        loss, gold, pred = compute_loss(
            batch_X, batch_Y, model, criterion, optimizer, is_train=True
            )
        train_loss += loss
        train_refs += gold
        train_hyps += pred
    # valid
    for batch in valid_dataloader:
        batch_X, batch_Y = batch
        loss, gold, pred = compute_loss(
            batch_X, batch_Y, model, criterion, is_train=False
            )
        valid_loss += loss
        valid_refs += gold
        valid_hyps += pred
    # 損失をサンプル数で割って正規化
    train_loss /= len(train_dataloader.data) 
    valid_loss /= len(valid_dataloader.data) 
    # BLEUを計算
    train_bleu = calc_bleu(train_refs, train_hyps)
    valid_bleu = calc_bleu(valid_refs, valid_hyps)

    # validationデータでBLEUが改善した場合にはモデルを保存
    if valid_bleu > best_valid_bleu:
        ckpt = model.state_dict()
        torch.save(ckpt, ckpt_path)
        best_valid_bleu = valid_bleu

    elapsed_time = (time.time()-start) / 60
    print('Epoch {} [{:.1f}min]: train_loss: {:5.2f}  train_bleu: {:2.2f}  valid_loss: {:5.2f}  valid_bleu: {:2.2f}'.format(
            epoch, elapsed_time, train_loss, train_bleu, valid_loss, valid_bleu))
    print('-'*80)

実行結果

Epoch 1 [0.8min]: train_loss: 77.55  train_bleu: 4.72  valid_loss: 41.38  valid_bleu: 11.02
--------------------------------------------------------------------------------
Epoch 2 [0.8min]: train_loss: 39.45  train_bleu: 12.25  valid_loss: 32.30  valid_bleu: 17.59
--------------------------------------------------------------------------------
Epoch 3 [0.8min]: train_loss: 32.22  train_bleu: 17.89  valid_loss: 28.26  valid_bleu: 21.75
--------------------------------------------------------------------------------
Epoch 4 [0.8min]: train_loss: 28.39  train_bleu: 21.59  valid_loss: 25.91  valid_bleu: 24.61
--------------------------------------------------------------------------------
Epoch 5 [0.8min]: train_loss: 25.89  train_bleu: 24.35  valid_loss: 24.46  valid_bleu: 26.96
--------------------------------------------------------------------------------
Epoch 6 [0.8min]: train_loss: 24.04  train_bleu: 26.66  valid_loss: 23.14  valid_bleu: 28.96
--------------------------------------------------------------------------------
Epoch 7 [0.8min]: train_loss: 22.57  train_bleu: 28.52  valid_loss: 22.37  valid_bleu: 29.68
--------------------------------------------------------------------------------
Epoch 8 [0.8min]: train_loss: 21.40  train_bleu: 30.22  valid_loss: 21.59  valid_bleu: 31.05
--------------------------------------------------------------------------------
Epoch 9 [0.8min]: train_loss: 20.37  train_bleu: 31.49  valid_loss: 20.98  valid_bleu: 31.94
--------------------------------------------------------------------------------
Epoch 10 [0.8min]: train_loss: 19.48  train_bleu: 32.70  valid_loss: 20.47  valid_bleu: 33.20
--------------------------------------------------------------------------------
Epoch 11 [0.8min]: train_loss: 18.71  train_bleu: 33.77  valid_loss: 20.19  valid_bleu: 33.62
--------------------------------------------------------------------------------
Epoch 12 [0.8min]: train_loss: 17.98  train_bleu: 35.06  valid_loss: 19.86  valid_bleu: 33.94
--------------------------------------------------------------------------------
Epoch 13 [0.8min]: train_loss: 17.35  train_bleu: 35.94  valid_loss: 19.32  valid_bleu: 34.66
--------------------------------------------------------------------------------
Epoch 14 [0.8min]: train_loss: 16.75  train_bleu: 36.75  valid_loss: 19.23  valid_bleu: 35.18
--------------------------------------------------------------------------------
Epoch 15 [0.8min]: train_loss: 16.23  train_bleu: 37.60  valid_loss: 19.11  valid_bleu: 35.40

評価用コードの作成

def test(model, src, max_length=20):
    # 学習済みモデルで系列を生成する
    model.eval()
    
    src_seq, src_pos = src
    batch_size = src_seq.size(0)
    enc_output, enc_slf_attns = model.encoder(src_seq, src_pos)
        
    tgt_seq = torch.full([batch_size, 1], BOS, dtype=torch.long, device=device)
    tgt_pos = torch.arange(1, dtype=torch.long, device=device)
    tgt_pos = tgt_pos.unsqueeze(0).repeat(batch_size, 1)

    # 時刻ごとに処理
    for t in range(1, max_length+1):
        dec_output, dec_slf_attns, dec_enc_attns = model.decoder(
            tgt_seq, tgt_pos, src_seq, enc_output)
        dec_output = model.tgt_word_proj(dec_output)
        out = dec_output[:, -1, :].max(dim=-1)[1].unsqueeze(1)
        # 自身の出力を次の時刻の入力にする
        tgt_seq = torch.cat([tgt_seq, out], dim=-1)
        tgt_pos = torch.arange(t+1, dtype=torch.long, device=device)
        tgt_pos = tgt_pos.unsqueeze(0).repeat(batch_size, 1)

    return tgt_seq[:, 1:], enc_slf_attns, dec_slf_attns, dec_enc_attns
def ids_to_sentence(vocab, ids):
    # IDのリストを単語のリストに変換する
    return [vocab.id2word[_id] for _id in ids]

def trim_eos(ids):
    # IDのリストからEOS以降の単語を除外する
    if EOS in ids:
        return ids[:ids.index(EOS)]
    else:
        return ids

学習済みモデルと、テストデータの読み込みを行う。

# 学習済みモデルの読み込み
model = Transformer(**model_args).to(device)
ckpt = torch.load(ckpt_path)
model.load_state_dict(ckpt)
# テストデータの読み込み
test_X = load_data('./data/dev.en')
test_Y = load_data('./data/dev.ja')
test_X = [sentence_to_ids(vocab_X, sentence) for sentence in test_X]
test_Y = [sentence_to_ids(vocab_Y, sentence) for sentence in test_Y]

文章を生成してみる。

test_dataloader = DataLoader(
    test_X, test_Y, 1,
    shuffle=False
    )
src, tgt = next(test_dataloader)

src_ids = src[0][0].cpu().numpy()
tgt_ids = tgt[0][0].cpu().numpy()

print('src: {}'.format(' '.join(ids_to_sentence(vocab_X, src_ids[1:-1]))))
print('tgt: {}'.format(' '.join(ids_to_sentence(vocab_Y, tgt_ids[1:-1]))))

preds, enc_slf_attns, dec_slf_attns, dec_enc_attns = test(model, src)
pred_ids = preds[0].data.cpu().numpy().tolist()
print('out: {}'.format(' '.join(ids_to_sentence(vocab_Y, trim_eos(pred_ids)))))

結果

src: show your own business .
tgt: 自分 の 事 を しろ 。
out: 君 の 商売 は 自分 の 商売 を 見せ て い る 。

Targetほどこなれてはいないものの、直訳としては大方あっている。

Bleuの評価

# BLEUの評価
test_dataloader = DataLoader(
    test_X, test_Y, 128,
    shuffle=False
    )
refs_list = []
hyp_list = []

for batch in test_dataloader:
    batch_X, batch_Y = batch
    preds, *_ = test(model, batch_X)
    preds = preds.data.cpu().numpy().tolist()
    refs = batch_Y[0].data.cpu().numpy()[:, 1:].tolist()
    refs_list += refs
    hyp_list += preds
bleu = calc_bleu(refs_list, hyp_list)
print(bleu)
24.974396035182135

計算負荷はRNNより低いにも関わらず、良好なBleu値が出た。

ラビットチャレンジレポート 深層学習Day4 その2

Section3 軽量化・高速化技術

  • 軽量化・高速化が必要な理由

 深層学習は、計算量が多く、計算機の負担が大きいネットワークである。
 しかも、年10倍程度の速度で、処理データが増加し、モデルが複雑になっている。
 対して、コンピュータの処理速度の向上は、ほぼムーアの法則(18か月で約2倍の集積度)に律速されている。
 つまり、計算機の負荷は、年々高くなる方向であり、軽量化・高速化技術は必須となっている。
 中でも、複数の計算機(ワーカー)を使用し、並列的にニューラルネットワークを計算することで効率の良い学習を行う分散深層学習は、今日の深層学習では必須の技術となっている。

3-1 データ並列化

 意やモデルを各ワーカーに子モデルとしてコピーし、データを文化として各ワーカーに計算させる手法である。
 データ並列化技術は、各モデルのパラメータの合わせ方で、同期型と非同期型がある。

同期型

1.各ワーカーの計算が終わるのを待ち、各ワーカーの勾配が出たところで勾配の平均を計算し、親モデルのパラメータを更新する。
2.パラメータ更新後の親モデルを子モデルとしてコピーする
3.1,2を繰り返すことで学習する
f:id:tibet:20211208215331p:plain

非同期型

各ワーカーはお互いの計算を待たず、各子モデルごとに更新を行う。
学習が終わった子モデルは、パラメータサーバーにPushされる。
学習が終わったワーカーが新たに学習を始めるときは、パラメータからPOPしたモデルに対して学習を始める。
f:id:tibet:20211208215928p:plain

同期型と非同期型の比較

・処理スピードはお互いのワーカーの計算を待たない非同期型のほうが速い。
・一方で非同期型は最新モデルのパラメータを利用できないので、学習が不安定になりやすく、同期型のほうが精度がよい傾向がある。
・用途としては、例えば、自前で複数のサーバを持ち、計算をコントロールできる場合は、精度のよい同期型を採用し、多数のスマホのCPUの空き能力を活用するようなコントロールが難しい場合は、非同期型を採用するような使い分けが考えられる。

3-2 モデル並列化

 親モデルを各ワーカーに分割し、それぞれのモデルを学習させる手法。すべてのデータで学習が終わった後で、一つのモデルに復元する。
 モデルが大きい時には、モデル並列化、データが大きい時には、データ並列化をするとよい。
 データ並列化では別々のコンピュータを使用することが多いが、モデル並列化は、ワーカー同士のより高速な通信が必要なので、同一コンピュータ内で、GPUコアを分けて並列化することが多い。

モデル並列の効果

モデルのパラメータが多いほど、スピードアップの効率も向上する。
f:id:tibet:20211208233552p:plain

3-3GPUによる高速化

  • CPU

 高速化なコアが少数。複雑な処理が得意

 比較的低性能なコアが多数あり、簡単な並列処理が得意。
 ニューラルネットワークの計算が非常に得意。
 もともとの目的のグラフィック以外の用途で使用されるGPUの総称をGPGPUという。

GPGPU開発環境

・CUDA: NVIDIAGPU上で並列コンピューティングを行うためのプラットフォーム
OpenCLNVIDIA社以外の会社(Intel, AMD, ARMなど)のGPUからでも使用可能。

並列化のほかには、モデルの軽量化という手法もある。
モバイル端末やIoT機器などのエッジデバイスは、一般にパソコンやサーバに比べ、性能が大きく劣るため、モデルの軽量化は有効な手法である。
軽量化の代表的な手法は下記の3つがある。
量子化
・蒸留
・プルーニング

3-3量子化(Quantization)

 計算精度を、64bit(倍精度)、32bit(単精度)、8bit(半精度)などに調整することを量子化という。
 ネットワークが大きくなると、大量のパラメータが必要になり、学習や推論に多くのメモリと演算処理が必要になる。
 通常のパラメータの64bit浮動小数点を32bitなど下位の精度に落とすことで、メモリと演算処理の削減を行える。
 量子化の利点としては、計算の高速化と小メモリ化、欠点としては精度の低下が挙げられる。

計算の高速化

 倍精度と単精度演算では演算性能が大きく違うため、量子化で精度を落とすことにより多くの計算をすることが出来る。下記のTeslaでは、計算速度が約2倍違う。
単精度では、TeslaV100では107TFLOPSに達し、大きな高速化が図れる。
 f:id:tibet:20211209201331p:plain

省メモリ化

 bit数を下げると、計算で確保するbit数がそれに応じて小さくて済むため、使用するメモリ量が少なくなる。

精度の低下

 量子化でbit数を下げると、ニューロンが表現できる少数の有効桁が少なくなり、モデルの表現力が低下する。
 ただし、実際の問題では、倍精度を単精度や半精度に落としても問題にならない場合が多い。

3-4蒸留(Distillation)

 蒸留は学習済みの精度の高いモデルの知識を軽量なモデルへ継承することで、モデルの簡略化をする手法である。
f:id:tibet:20211209210838p:plain

教師モデルと生徒モデル(蒸留の学習方法)

 教師モデルとして、学習済みの高度で複雑なモデルを用意し、生徒モデルとして軽量なモデルを用意する。
教師モデルの重みを固定し、下記のような形で、教師モデルと生徒モデルで同一の入力データを入力し、誤差を評価した重みの更新を生徒モデルだけに返す。
このようにして、教師モデルの出力に生徒モデルを近づけるよう学習する。
f:id:tibet:20211209211333p:plain

3-5プルーニング(pruning)

 ネットワークが大きくなると大量のパラメータがあるが、すべてのニューロンが精度に寄与しているわけではない。モデルの精度に寄与が少ないニューロンを削減することで、モデルを圧縮し、軽量化・高速化を見込む。

f:id:tibet:20211209213542p:plain

ニューロンの削減法

 重みが閾値以下のニューロンを削減する。
 下記の例は、重みが0.1以下のニューロンを削減している。
f:id:tibet:20211209213734p:plain

ニューロン数と精度

 下記の例は、Oxford 10 category ower datasetをCaffeNetで学習したモデルのプルーニングの閾値による各層と全体のニューロンの削減率と精度をまとめたものである。
 この例を見ると、かなり大きな削減率でも、精度はそれほど落ちていないことがわかる。
f:id:tibet:20211209214119p:plain

Section4 応用技術

4-1 MobileNets

提案の背景

 近年の画像認識タスクに用いられる最新のニューラルネットワークアーキテクチャは、多くのモバイル及び組み込みアプリケーションの実行速度を上回る高い計算資源を必要とされる。
 そこで、ネットワークのアーキテクチャを工夫し、モバイルアプリケーションの画像処理向けに同等精度で計算量を削減したネットワークを提案した。

提案手法
  • 一般的な組み込みレイヤー
    • 入力特徴マップ:H(高さ)×W(幅)×C(チャネル数)
    • 畳み込みカーネルのサイズ:K(カーネル高さ)×K(カーネル幅)×C
    • 出力チャネル数(フィルタ数):M

ストライド1でパディングを適用した場合の畳み込み計算の計算量は、H×W×K×K×C×M
f:id:tibet:20211210135525p:plain

  • MobileNetsの計算手法

 Depthwise CovolutionとPointwise Convolutionを組み合わせて軽量化を実現している。

    • Depthwise Convolution

 入力マップのチャネルごとに畳み込みを実施する。
  カーネルのフィルタ数が1。
 入力マップをそれらと結合する(入力マップのチャネル数と同じになる)
 下記のスキームでは、計算量はH×W×K×K×C (一般的な畳み込みの1/M)
f:id:tibet:20211210143159p:plain

    • Pointwise Convolution

 1×1 convとも呼ばれる。
 カーネルサイズが1×1×C、フィルタ数Mである。
 入力マップのポイントごとに組み込みを実施する。
 出力マップはフィルタ分だけ作成可能。
 下記のスキームでは出力マップの計算量は、H×W×C×M
 f:id:tibet:20211210144920p:plain

    • Depthwise Separable Convolution

 MobileNetsの計算方法は、DepthwiseとPointwiseを組み合わせた下記のDepthwise Separable Convolutionと呼ばれる方法をとっている。
1.まず、Depthwise Convolutionでチャネルごとに空間方向へ畳み込む。すなわち、チャネルごとにDk×Dk×1のサイズのフィルターをそれぞれ用いて計算を行うため、その計算量は(H×W×Dk×Dk×C)である。
2.次にDepthwise Convolutionの出力をPointwise Convolutionによってチャネル方向に畳み込む。すなわち、出力チャネルごとに1×1×Mサイズのフィルターをそれぞれ用いて計算を行うため、その計算量は(H×W×C×M)となる。

下記の図が一般的な畳み込み演算と、MobileNetsの演算を比較した図である。
一般的な畳み込み演算では、H×W×K×K×C×Mの計算量であるのに対し、MobileNetsでは、H×W×C×(K×K+M)の計算量となる。仮にK=3、M=4の場合、MobileNetsの計算量は、一般的な畳み込み演算の計算量に対し、(K×K+M)/(K×K×M)=13/36に軽量化できる。
f:id:tibet:20211210145432p:plain

4-2 DenseNet

論文タイトル:Densely Connected Convolutional Networks

概要

ニューラルネットワークでは、層が深くなるにつれて学習が難しkなるという問題があったが、前の層から層をスキップして後方の層に直接接続するというResNetのアイデアが生まれ、非常に深い層のネットワークが実現可能になった。
後方と前方の層をスキップ接続することでパフォーマンスが向上するならば、すべてのレイヤーを直接接続すればさらにパフォーマンスが向上するのではないかという発想を元に生まれたのがDenseNetである。

DenseNetのメリット
  • 勾配消失の削減
  • 特徴伝達の強化
  • 特徴の効率的な利用
  • パラメータ数の削減
  • 正則化効果の期待
DenseNetの構造

DenseNetは次のようなブロックで構成されている。
1.Inital Convolution
2.Dense Block
3.Transition layer
4.Classification layer
f:id:tibet:20211210194403p:plain

  • Dense Block

Dense Blockは、前層の入力をbatch正規化、Relu関数による変換、3×3畳み込みによる変換で処理する。
そして下記のように、前層以前の入力を合わせて処理して出力としてこの層の出力とする。
入力特徴マップのチャンネル数がl×kだった場合、出力は(l+1)×kとなる。
第l層の出力は下記のように書ける。
 xl=H_l=(\lbrack  x_0,x_1,...,x_{l-1} \rbrack )

f:id:tibet:20211210195349p:plain

kをネットワークのgrowth rateと呼ぶ。kが大きくなるほどネットワークが大きくなるため、小さな整数に設定するのが良い。
f:id:tibet:20211210200436p:plain

  • Transition layer

中間層でチャネルサイズを変更し、特徴マップのサイズを変更してダウンサンプリングを行い、次のDense Blockに繋ぐ。
f:id:tibet:20211210200817p:plain

DenseNetとResNetの比較

同じパラメータ数では、ResNetよりDenseNetのほうが誤差が小さかったという報告がある。
f:id:tibet:20211210201029p:plain
Review: DenseNet — Dense Convolutional Network (Image Classification)

4-4 Batch Norm、Layer Norm、Instanse Norm

データのばらつきを補正し、学習をしやすくするためにデータの平均を0、分散を1に修正する正規化という処理がある。

  • Batch Norm(バッチ正規化)

ミニバッチに含まれるサンプルの同一チャネルが同一分布に従うよう正規化する。バッチサイズが小さいと、効果が小さくなるという課題がある。バッチサイズはハードウェアのスペックに律速されることが多く、学習上問題になることがある。

  • Layer Norm(レイヤー正規化)

それぞれのサンプルで、すべてのチャネルで平均と分散を求めて正規化する。バッチサイズに依存しないことが特長。

 各サンプルの各チャネルごとに正規化する。コントラストの正規化に寄与し、画像のスタイル転送やテクスチャ合成タスクで利用される。
f:id:tibet:20211210201347p:plain

4-5 Wavenet

 Deepmindにより開発されたプログラム。
 生の音声波形を生成する深層学習モデルで、Pixel CNNを音声に応用したものである。

Wavenetのメインアイデア

 時系列データに対して畳み込みをするが、層が深くなるにつれて畳み込むリンクを離す。
 下記の右図では、下の層から、Dilated=1,2,4,8になっている。
  このようにすると、受容野を簡単に増やすことが出来るという利点がある。
f:id:tibet:20211210202158p:plain

問題
  • 深層学習を用いて結合確率を学習する際に、効率的に学習が行えるアーキテクチャを提案したことがWavenetの大きな貢献の一つである。提案された新しいConvolution型アーキテクチャDilated causal convolutionと呼ばれ、結合確率を効率的に学習できるようになった。
  • Dilated causal convolutionを用いた際の大きな利点は、単純なConvolution layerに比べてパラメータ数に対する受容野が広いことである。

ラビットチャレンジレポート 深層学習 Day4 その1

Section1 強化学習

1-1強化学習とは

 長期的に報酬を最大化できるように環境のなかで行動を選択できるエージェントを作ることを目標とする機械学習の一分野である。
下記のように、ある方策Πに基づいた行動をとり、状態Sの変化を観測して価値Vを報酬として受け取るというプロセスが、強化学習のイメージである。この報酬を最大化するように方策を決定する。
 f:id:tibet:20211203153813p:plain

1-2 強化学習の応用例

 囲碁やチェスなどのゲームが有名だが、実用的には、例えばマーケティングへの応用が想定できる。
 エージェントはプロフィールと購入履歴に基づいてキャンペーンメールを送る。
 行動としては、顧客ごとに送信非送信のふたつの行動を選ぶことになる。
 報酬としては、キャンペーンのコストという負の報酬とキャンペーンで生み出される売り上げという正の報酬をうけることになる。

1-3探索と利用のトレードオフ

環境について完璧な知識があれば、最適な行動について完璧な予測が可能ではないかとも考えられる。例えば、どのような顧客にキャンペーンメールを送ると、どのような行動をとるか既知の状況である。
 強化学習では、上記仮定が成り立たないと考える。不完全な知識を元に行動しながらデータを収集し、最適な行動を見つけていく。
 ここで、過去のデータを元に最適な行動のみをとり続けるという方策をとるとする。この場合は、他に存在するかもしれないベストの行動を見つけることは出来ない。これは探索が足りない状態ということが出来る。
 一方で、未知の行動のみをとり続けるという方策をとると、過去の経験が生かせない。これは経験が足りない状態ということが出来る。
 両者の方策のトレードオフを、探索と利用のトレードオフと呼ばれる。行動の方策を決める場合は、探索と利用の間のバランスをとることが必要である。

1-4強化学習イメージ

計算のため、以上の強化学習のイメージを数式で表現する。
下図のように方策πに対しては、方策関数  \Pi(s,a)を定義、価値Vに関しては、行動価値関数 [tex;: Q(s,a)]を定義する。
f:id:tibet:20211204103102p:plain

1-5 強化学習の差分

 強化学習と通常の機械学習(教師あり/なし)との違いは、学習の目標が異なることである。
 ・教師あり/なし学習では、データに含まれるパターンを見つけ出だすこと、及びそのデータから予測することが目標である。
 ・強化学習では、優れた方策を見つけることが目標である。

1-6 強化学習の歴史

 強化学習の概念は以前からあったが、深層学習以上に計算能力が必要なため、コンピューティング能力が追いついておらず、注目されない冬の時代もあった。近年はコンピューティング能力が上がり、強化学習のコンセプトが実現可能になりつつあり、注目を集めている。
 また、関数近似法とQ学習を組み合わせる手法が登場し、注目を集めている。

  • Q学習:行動価値関数を、行動するごとに更新することにより学習を進める方法
  • 関数近似法:価値関数や方策関数を価値関数近似する手法のこと

1-7価値関数

価値関数とは、価値Vを表す関数で、状態価値関数と行動価値関数の二種類がある。
状態価値関数:状態sの価値を表す関数
行動価値関数:ある状態sの時の行動aの価値を表す関数

1-8方策関数

 方策ベースの強化学習手法において、ある状態でどのような行動をとるのかの確率を与える関数  \pi (s)=aのこと。
関数の関係
  pi(s,a)は、VやQを元にどのような行動をするか決定する。
  V^{\pi}(s):状態関数 [tex: Q^{\pi}(s,a) :状態+価値関数:ゴールまで今の方策を続けたときの報酬の予測値が得られる。
 

1-9方策勾配法について

 価値関数を求め、それを元に行動を決定するアプローチもあるが、 V^{\pi} Q^{\pi}を求めるため、複雑でメモリを消費することや連続空間では扱いにくいといった課題がある。
 そのため、方策関数を直接学習するというアプローチも考えられる。これが方策勾配法である。
 θでパラメタライズされた方策関数π(s,a|θ)を考える。
 最適なθを求めるため、下記のような反復方法を考える。
  θ^{(t+1)}=θ^{(t)}+ε∇J(θ)
∇J(θ)はニューラルネットワークの誤差関数、εは学習率に相当する。θの式では加算されているが、これはθの最大値を求めるからである。
 強化学習がパラメータの最適値を求める問題となり、ニューラルネットワークを使用することが可能になった。
 J(θ)は、方策の評価関数で、方策πとその時の報酬Qを用いて、下記のように書ける。
  J(θ)=\displaystyle \sum_{a \in A} π_{θ}(a|s)Q^{π}(s,a)
 θによる微分を求めると、下記の期待値となる。
  ∇_{θ}J(θ)=E_{π_{θ}}\lbrack  (∇_{θ}logπ_{θ}(a|s)Q^{π}(s,a)) \rbrack

Section2 Alpha GO

ここでは、強化学習、及びAIに大きなインパクトを残したDeep Mindが開発したAlpha Goのアルゴリズムについて解説する。Alpha Goは2015年に人間のプロ棋士にハンディキャップなしで初めて勝利した囲碁プログラムとなった。次いで2016年に世界トップ棋士であるイ・セドル、2017年に柯潔に勝利し、コンピュータにとって、人間に打ち勝つことが最も難しいと思われた囲碁で人間に勝利したプログラムとして、世界に大きな衝撃を与えた。その後、過去の試合データを使用せず、自己対戦の強化学習のみで学習したAlpha ZeroがAlpha Goを上回る性能を示した。
ここでは、Alpha Go (Lee)とAlpha Zeroについて解説する。

Alpha Go (Lee)

Alpha Go(Lee)の学習プロセスは、下記のステップで進む。
1. 教師あり学習によるRollOutPolicyとPolicyNetの学習
2. 強化学習によるPolicyNetの学習
3. 強化学習によるValueNetの学習

Alpha Go(Lee)のPolicyNet

f:id:tibet:20211205213229p:plain
入力は、19×19の2次元で、48チャンネルである。
ネットワークは、CNNで、softMaxによって19×19の着手予想確率を出力する。
入力の48チャンネルはそれぞれ下記のような入力に対応する。
f:id:tibet:20211205213402p:plain

RollOutPolicy

PolicyNetはニューラルネットネットワークなので、盤面の推論の際の計算量が多く、時間がかかる。そこで、探索中に高速で着手確率を出すために使用されるためにRollOutPolicyという方策関数が用意されている。こちらは線形の関数のため、計算が速い(PolicyNetの約1000倍)。RollOutPolicyで打ち手を絞り込み、PolicyNetで精度の高い打ち手を出すといった使い分けが考えられる。
 RollOutPolicyの入力は以下である。
f:id:tibet:20211205215418p:plain

PolicyNetとRollOutPolicyの教師あり学習

 KGS Go Server(ネット囲碁対局サイト)の棋譜データから300万局面分の教師を用意し、教師と同じ着手を予測できるよう学習を行った。
具体的には、教師が着手した手を1とし、残りを0とした19×19次元の配列を教師とし、それを分類問題として学習した。
 この制度で作成したPolicyNetは57%程度の精度である。

PolicyNetの強化学習

 まず、PolicyNetの強化学習の過程を500Iterationごとに記録し保存して、PolicyPoolを作る。
 次に現状のPolicyNetとPolicyPoolからランダムに選択されたPolicyNetと対局シミュレーションを行い、その結果を用いて方策勾配法で学習を行う。
 現状のPolicyNet同士の対局ではなく、PolicyPoolに保存されているものとの対局を使用する理由は、対局に幅を持たせて過学習を防ごうというのが主たる理由である。
 この学習をmini batch size 128で1万回行った。

ValueNetの学習

 PolicyNetを使用して対局シミュレーションを行い、その結果の勝敗を教師として学習する。
 教師データ作成の手順は、
1. まずSL PolicyNet(教師あり学習で作成したPOlicyNet)でN手まで打つ。
2. N+1手目の手をランダムに選択し、その手で進めた局面をS(N+1)とする。
3.S(N+1)からRL PolicyNet(強化学習で作成しtあPolicyNet)で終局まで打ち、その勝敗報酬をRとする。
S(N+1)とRを教師データ対とし、損失関数を平均二乗誤差とし、回帰問題として学習した。
 この学習をmini batch size 32で5000万回行った。
 N手からN+1手からのPolicyNetを別々にしてある理由は、過学習を防ぐためである。

モンテカルロ木探索

 min-max探索やαβ探索では、盤面の価値や勝率予想値が必要だが、囲碁では
盤面価値や勝率予想値を出すことが困難とされてきた。
 そこで、盤面評価値によらず、末端評価値、つまり勝敗のみを使って探索を行うという発想で生まれた探索法である。囲碁の場合、他のボードゲームと違い、最大手数はマスの数でほぼ限定されるため、末端局面に到達しやすい。
 具体的には、現局面から末端局面までをPlayOutと呼ばれるランダムシミュレーションを多数回行い、その勝敗を集計して着手の優劣を決定する。
 また、該当手のシミュレーションが一定数を超えたら、その手を着手した後の局面をシミュレーション開始局面とするよう、探索気を成長させる。
 この探索木の成長を行うという点が、モンテカルロ木探索の優れているところである。
 モンテカルロ木探索は、この木の成長を行うことによって、一定条件下では探索結果は最善手を返すことが理論的に証明されている。
 Alpha Go(Lee)のモンテカルロ木探索は、選択、評価、バックアップ、成長という4つのステップで構成されている。

AlphaGo Zero

AlphaGo(Lee)とAlphaGo Zeroの違い

1. 教師あり学習を一切行わず、強化学習のみで作成
2. 特徴入力からヒューリスティックな要素を排除し、石の配置のみにした
3. PolicyNetとValueNetを1つのネットワークに統合した
4. Residual netを導入した
5. モンテカルロ木探索からRollOutシミュレーションをなくした

AlphaGo Zeroの学習法

 AlphaGo Zeroの学習は、自己対局による教師データの作成、学習、ネットワークの更新の3ステップで構成される。

  • 自己対局による教師データの作成

現状のネットワークでモンテカルロ木探索を用いて自己対局を行う。
 まず30手までランダムで打ち、そこから探索を行い、勝敗を決定する。
 自己対局中の各局面での着手選択確率分布と勝敗を記録する。
 教師データの形は、[局面、着手選択確率分布、勝敗]が1セットとなる。

  • 学習

 自己対局で作成した教師データを用い、学習を行う
 NetworkのPolicy部分の教師に着手選択確率分布を用い、Value部分の教師に勝敗を用いる。
 損失関数はPolicy部分はクロスエントロピーValue部分は平均二乗誤差。

  • ネットワーク更新

 学習後、現状のネットワークと学習後のネットワークとで対局テストを行い、学習後のネットワークの勝率が高かった場合、学習後のネットワークを現状のネットワークとする。
 

AlphaGo ZeroのPolicyValueNet

 入力は17チャンネルの19×19の盤面で、途中からPolicy出力とValue出力に分れる。また中間にResidual Blockを挟む。
f:id:tibet:20211207204744p:plain 

Residual Block

ネットワークにショートカット構造を追加して、勾配爆発、勾配消失を防いだもの。
Residual Networkを使うことにより、100層を超えるネットワークでも安定した学習が可能になった。
また、Redisual Networkを使うことにより層数が異なるネットワークのアンサンブルの効果を得られるという話もある。
f:id:tibet:20211207205125p:plain

  • Residual Blockの工夫
    • Bottleneck

1×1 KernelのComvolutionを利用し、1層目で次元削減を行って2層目で次元を復元する3層構造にしたもの。2層のものと比べて計算量はほぼ同じだが、1層増やせるメリットがある

  • PreActivation

ResidualBlockの並びをBatchNorm→ReLu→Convolution→BatchNorm→ReLu→Comvolution→Addとすることにより性能が上昇した

  • Network構造の工夫
    • WideResNet

ConvolutionのFilter数をk倍にしたResNet。1倍→k倍xブロック→2k倍yブロックと段階的に幅を増やしていくのが一般的。Filterを増やすことにより、浅井層数でも深い層数のものと同等以上の精度となり、またGPUをより効率的に使用できるため学習も早い。

    • PyramidNet

WideResNetで幅が広がった直後の層に、過度の負担がかかり精度を落とす原因になっているとし、段階的にではなく、各層でfilterを増やしていくResnet。

AlphaGo Zeroモンテカルロ木探索

 選択、評価及び成長、バックアップという3つのステップで構成される。
 評価及び成長時にRollOutは行わないところがAlphaGO(Lee)と異なる。



 




 

ラビットチャレンジ 深層学習Day3 その3

Section5 Seq2Seq

Seq2Seq2は、2014年にGoogleにより発表された技術であり、機械翻訳やチャットボットのようなシーケンシャルなデータの処理に向いた技術である。
基本的な構造は下図のようになっている。
f:id:tibet:20211201170823p:plain
入力データを中間データに変換するEncoderと言われる構造があり、中間層に中間データを送る。そして、中間層は、Decoderと呼ばれる構造にデータを送り、Decoderは、送られたデータを出力データに変換する。
Seq2SeqはこのようなEncoder-Decoderと呼ばれる構造をとっている。

5-1 Encoder RNN

Seq2SeqのEncoderとして用いられるEncoder RNNの説明をする。
f:id:tibet:20211201171911p:plain
ユーザーがインプットしたテキストデータ尾w、単語などのトークンに区切って渡す構造になっている。
Encoder RNNがテキストデータを処理する手順は、次のとおりである。
1. 文章を単語などに分割し、トークンごとのIDに分割するTakingと呼ばれる処理をする。
2. IDからそのトークンを表す分散表現ベクトルに変換するEmbeddingと呼ばれる処理をする。
3. 2で変換したベクトルvecをRNNに入力し、hidden stateを出力する。このhiddem stateと次の入力vecをRNNに入力してhidden stateを出力という流れを繰り返す。
4.最後のvecを入力した時のhidden stateをfinal stateとする。このfinal stateがthought vectorと呼ばれ、入力した文章の意味を示すベクトルとなる。

5-2 Decoder RNN

中間層からのデータを、単語などのトークンごとに出力データとして生成する構造。
f:id:tibet:20211201173629p:plain
Decoder RNNの処理手順は次のようになる。
1.前述のEncoder RNNのthought vectorをDocoderRNNのinitial stateとして設定し、Enbeddingを入力。tokenの生成確率を出力する。
2.生成確率に基づいてtokenをランダムに選ぶsamplingという処理を行う
3.2で選ばれたtokenをEmbeddingして、Decoder RNNの次の段の入力とする。
4.1-3を繰り返し、2で得られたtokenを文字列に直すDetokenizeという処理を行う。

  • 確認テスト

下記の選択肢から、seq2seqについて説明しているものを選べ
(1)時刻に関して順方向と逆方向のRNNを構成し、それら2つの中間層表現を特徴量として利用するものである。
(2)RNNを用いたEncoder-Decoderモデルの一種であり、機械翻訳などのモデルに使われる。
(3)構文木などの木構造に対して、隣接単語から表現ベクトル(フレーズ)を作るという演算を再帰的に行い(重みは共通)、文全体の表現ベクトルを得るニューラルネットワークである。
(4)RNNの一種であり、単純なRNNにおいて問題となる勾配消失問題をCECとゲートの概念を導入することで解決したものである。

[解答](2)
(1)→双方向RNN
(2)→seq2seq
(3)→通常のRNNなど
(4)→LSTM

  • 演習チャレンジ

機械翻訳タスクにおいて、入力は複数の単語からなる文章であり、それぞれの単語はone-hotベクトルで表現されている。Encoderにおいて、それらの単語は単語埋め込みによる特徴量に変換され、そこからRNNによって(一般にはLSTMを使うことが多い)時系列の情報を持つ特徴へとエンコードされる。以下は入力である文(文章)とを時系列の情報を持つ特徴量へとエンコードする関数である。ただし_activation関数は何らかの活性化関数を表すとする。
(き)にあてはまるコードを示せ。
f:id:tibet:20211201184239p:plain
[解答]
E.dot(w)
単語wはone-hotベクトルであり、それを単語埋め込みにより別の特徴量に変換する。これは埋め込み行列を用いてE.dot(w)と書ける。

5-3 HRED

過去n-1個の発話から次の発話を生成する技術。
seq2seqは、会話の文脈無視の応答だったが、HREDでは、前の単語の流れに即して応答されるため、より人間らしい文章が生成される。
HREDの構造は、seq2seq+context RNNである。context RNNは、Encoderのまとめた各文章をまとめて、これまでの会話コンテキスト全体を表すベクトルに変換する構造である。過去の発話の履歴を加味した応答を出来る。

HREDの課題

 HREDは確率的な多様性が字面にしかなく、会話の「流れ」のような多様性がない。そのため、同じコンテキストを与えられても、答えの内容が名毎回会話の流れとしては同じものしか出せない。
 また、HREDは短く情報量に乏しい答えをしがちで、短いよくある答えを学ぶ傾向がある。たとえば、「うん」「そうだね」など。

5-4 VHRED

HREDにVAEの潜在変数の概念を追加したもので、HREDの課題をVAEの洗剤変数の概念を追加することで解決した構造である。

  • 確認テスト

 sesq2seqとHRED、HREDとVHREDの違いを簡潔に述べよ。
[解答]
・seq2はEncoderとDecoderを接続した構成で、会話の文脈を読み取れないが、HREDは、context-RNNを導入することで、過去の会話を踏まえた文章を生成できる。
・HREDは会話の流れのような多様性がないが、VHREDは、VAEの潜在変数の概念を追加することで会話の流れをより把握できる。

5-5 VAE

VAEは変分オートエンコーダ(Variational Autoencoder)で、ニューラルネットワークを用いた生成モデルの一種である。

5-5-1 オートエンコーダ―

オートエンコーダとは、教師無し学習の一つである。
そのため学習時の入力データは、訓練データのみで教師データは利用しない。

  • オートエンコーダの構造

 オートエンコーダの構造の概念図は下記になる。
f:id:tibet:20211201211611p:plain
 入力データからencoderと呼ばれるニューラルネットワークで潜在変数zに変換し、逆にDecoderで潜在変数zから元画像を復元する。
 zの次元が入力データのより小さい場合は、次元削減とみなすことが出来る。

5-5-2 VAE

 通常のオートエンコーダの場合は、何かしら潜在変数zにデータを押し込んでいるが、その構造がどのような状態か分からない。
 VAEはこの潜在変数Zの確率分布z~N(0.1)を仮定した。それにより、データ同士の関係性が示せるようになった。

  • 確認テスト

 VAEに関する下記の説明文中の空欄に当てはまる言葉を答えよ。
 自己符号化器の潜在変数に_____を導入したもの。
[解答]
確率分布z_N(0.1)
VAEは、自己符号化器の潜在変数内で、データ同士の関連性を示すために、確率分布を導入している。
そのため、生成モデルとして使用される。

Section6 Word2vec

 RNNでは、単語のような可変長の文字列をニューラルネットワークに与えることはできないため、固定長形式で単語を表す必要がある。
 そこで単語をベクトルに変換して表現する方法が開発された。
 まず、One-hotベクトル表現がある。これは、各単語ごとに簡便なロジックで表現が出来るが、1万語あれば、1万次元のベクトルが必要になり、しかもほとんどゼロの疎なベクトルである。これは計算リソース上無駄が多いので、数百次元で単語を表現できる分散表現が考案された。

分散表現

 one-hotベクトルでは、すべてのベクトル値を1か0で表すが、分散表現では、様々な数値をとる。また、単語同士の演算も可能で、単語の類似度も計算が出来る。
 one-hotベクトルに比較して少ない次元数で表現が可能なので、データサイズを抑えることが出来る。

Word2vecの仕組み

Word2vecの基本的な仕組みは、2層のニューラルネットワークを用いて文章中の単語を予測するものである。
Word2Vecには、CBowと、skip-gramの2つのニューラルネットワークモデルが搭載されている。

  • CBOW 

周囲の単語からある単語を予測するモデル

  • Skip-gram

ある単語からその周囲の単語を予測するモデル。

Section7 Attention Mechanism

 seq2seqでは、2単語でも100単語でも固定次元ベクトルの中に入力をしなくてはならず、長い文章への対応は難しい。
 そこで、文章が長くなるほどそのシーケンスの内部表現の次元が大きくなる仕組みとしてAttention機構が考案された。これは、「入力と出力のどの単語が関連しているのか」の関連度を学習する仕組みである。
attentionは、どの要素に注目すべきかを推論し、その部分を集中的に処理することで、推論の精度を向上させる。自然言語処理で注目されたが、その後画像処理にも応用されている。

  • 確認テスト

 RNNとword2vec,seq2seqとAttentionの違いを簡潔に述べよ
[解答]
・RNNは時系列のデータを処理するのに適したネットワークである。word2vecは単語の分散表現を得るためのネットワークである。
・seq2seqは、ある時系列データから、別の時系列データを得るための手法である。Attentionは、時系列データの関連性にそれぞれ重みをつける手法である。

ラビットチャレンジ 深層学習Day3 その2

Section2 LSTM

RNNの課題

  • 勾配消失

 時間を遡るほど勾配が消失してしまうため、長い時系列の学習が困難だった。
 勾配消失は、誤差逆伝搬法で深い層のニューラルネットワークを遡る時に見られる現象で、1より小さい値になる微分値が多数乗算されることにより引き起こされる。例えば、活性化関数として使用されるシグモイド関数は、微分値は最大でも0.25にしかならないため、2つ以上乗算されると、非常に小さな値となり、勾配はほぼ消失してしまう。

  • 確認テスト

シグモイド関数微分した時、入力値が0の時に最大値をとる。その値として正しいものを選択肢から選べ。
(1)0.15
(2)0.25
(3)0.35
(4)0.45
[解答]
(2)0.25

  • 勾配爆発

 勾配消失とは逆に、勾配が層を逆伝搬するごとに指数関数的に大きくなってしまう問題。活性化関数に恒関数を用いたりすると、この問題が起こりやすい。

  • 演習チャレンジ

RNNや深いモデルでは勾配の消失または勾配爆発が起こる傾向がある。勾配爆発を防ぐために勾配のクリッピングを行うという手法がある。具体的には勾配のノルムが閾値を超えたら、勾配のノルムを閾値に正規化するというものである。以下は勾配のクリッピングを行う関数である。
(さ)にあてはまるコードをかけ
f:id:tibet:20211129184855p:plain
[解答]
gradient*rate

LSTM(Long Short Term Memory)は、RNNの勾配消失や勾配爆発という問題を解決するために考案された構造である。
次に、LSTMの全体像を示す。
f:id:tibet:20211129195005p:plain
入力から出力の間がRNNの中間層に相当する。中間層で自己回帰(青点線)していることがわかる。
中間層内の構造の工夫がLSTMである。

1-2 CEC

 LSTMの中心はCEC(Constant Error carousel:定誤差カルーセル)である。このセルは、勾配を記憶する役割を果たしている。
 具体的には、CECの中では、勾配消失及び勾配爆発の解決法として、勾配を1としている。
 δ^{t-z-1}=δ^{t-z}(Wf'(u^{t-z-1}))=1
 \dfrac{\partial E}{\partial c^{t-1}}=\dfrac{\partial E}{\partial c^t}\dfrac{\partial}{\partial c^{t-1}}(a^t-c^{t-1})=\dfrac{\partial E}{\partial c^t}

CECの課題は、入力データについて時間依存度の関係なく重みが一律のため、学習が出来ないということである。
 つまり、学習と記憶の役割を中間層内で分けている。
 学習については、入力層の重荷については入力ゲート、出力層への重みについては出力ゲートでそれぞれ担っている。

2-2入力ゲートと出力ゲート

入力ゲート

f:id:tibet:20211129210828p:plain
入力ゲートは、CECに記憶する内容を調整する機能を持つ。
具体的には入力側の変数x(t)と、出力からの一つ前の数値の回帰h(t-1)にそれぞれ重みWiとUiを掛け合わせて演算した値をCECの入力との内積演算ユニットに送る。

出力ゲート

f:id:tibet:20211129211301p:plain
出力ゲートは、ECEからの出力を調整して出力層に送る機能を持つ。
具体的には入力側の変数x(t)と、出力からの一つ前の数値の回帰h(t-1)にそれぞれ重みWoとUoを掛け合わせて演算した値をCECの出力との内積演算ユニットに送る。

このように、入力・出力ゲートを追加することで、それぞれのゲートへの入力値の重みを重み行列W,Uで可変可能とすることで、CECの課題を解決している。

2-3忘却ゲート

CECは過去のすべての情報が保管されているが、逆に不要な場合でも削除が出来ない。
そこで、過去の上方がいらなくなった場合、そのタイミングで情報を忘却するための機能として、忘却ゲートが設けられた。
f:id:tibet:20211130095950p:plain
上記の赤枠が忘却ゲートである。
忘却ゲートは、入出力にそれぞれWf, Ufの重みを乗算した関数忘却関数f(t)を作る。次にCECの一つ前の時間の出力c(t-1)と内積を取り、CEC内で入力層からの信号i(t)a(t)と線形結合をとり、下記c(t)を出力する。
 c(t)=i(t)\cdot a(t)+f(t)\cdot c(t-1)
忘却関数f(t)と内積をとることで、CECの記憶の忘却度が調整される。

  • 確認テスト

以下の文章をLSTMに入力し、空欄に当てはまる単語を予測したいとする。文中の「とても」という言葉は空欄の予測において、無くなっても影響を及ぼさないと考えられる。
このような場合、どのゲートが作用すると考えられるか。
「映画おもしろかったね。ところで、とてもおなかが空いたからなにか____。」

[解答]
文意に影響を及ぼさないため、忘却ゲートが作用していると考えられる。

  • 演習チャレンジ

以下のプログラムはLSTMの順伝搬を行うプログラムである。ただし、_sigmoid関数は、要素ごとにシグモイドを作用させる関数である。
(け)にあてはまるコードを示せ。
f:id:tibet:20211130135130p:plain
[解答]
input_gate*a+forget_gate*c

覗き穴結合

CECの保存されている過去の情報を任意のタイミングで他のノードに伝搬させたり、任意のタイミングで忘却させたりしたいが、CEC自身の値は、ゲート制御に影響を与えていないという課題がある。
そこで、覗き穴結合というパスを作り、CEC自身の値に重み行列を介して伝搬可能にした構造を設けた。
f:id:tibet:20211130135346p:plain
上図中の赤枠で囲んだパスが覗き穴結合に当たり、入力ゲート、忘却ゲート、出力ゲートそれぞれにCECに出力にVi, Vf,Voという重みを乗算して伝達している。

Section 3 GRU

 従来のLSTMでは、パラメータ数が多いため、計算負荷が大きかった。そこで、パラメータを大幅に削って計算負荷を小さくし、かつ精度は同等以上を見込める構造として、GRUが考案された。
f:id:tibet:20211130171202p:plain
GRUはリセットゲート、更新ゲート、活性化関数を持つ内部ノードからなり、CECは持っていない。
数式化のために、下記のように信号と重みの定義をする。
x(t):入力信号
W_h:入力重み
W_r:入力からリセットゲートへの重み
U_r:出力からリセットゲートへの重み
b_h(t):リセットゲートのバイアス
r(t):リセットゲートの出力信号
 U_h:出力信号の回帰への重み
 f(x):内部ノードの活性化関数
W_z:入力から更新ゲートへの重み
U_z:出力から更新ゲートへの重み
b_z(t):更新ゲートのバイアス
z(t):更新ゲートの出力信号
h(t):出力信号
まず、リセットゲートの出力は、下記のように表せる。
 r(t)=Wrx(t)+Ur\cdot h(t-1)+b_h(t)
次段の内部演算ユニットの出力は下記のようになる。
 U_h\cdot (r(t)\cdot h(t-1))
よって、活性化関数f(x)を持つ内部ノードの出力は下記のようになる。
 f(W_hx(t)+U_h\cdot (r(t)\cdot h(t-1))
また、更新ゲートの出力は下記のようになる。
 z(t)=W_zx(t)+U_z\cdot h(t-1)+b_z(t)
よって、出力層の出力h(t)は下記のように表せる。
 h(t)=z(t)\cdot h(t-1)+(1-z(t))\cdot f(W_hx(t)+U_h\cdot (r(t)\cdot h(t-1))

  • 確認テスト

LSTMとCECが抱える問題について、それぞれ簡潔に述べよ
[解答]
LSTMはパラメータが多く、計算負荷が高いため、計算に時間がかかる。CECは勾配1で記憶するが、情報処理が出来ない。そこで、周りに情報処理のためのゲートを設けるため、構造が複雑になる。

実装演習

RNNで単語予測をするネットワークをtensorflowで記述した。

import tensorflow as tf
import numpy as np
import re
import glob
import collections
import random
import pickle
import time
import datetime
import os

# logging levelを変更
tf.logging.set_verbosity(tf.logging.ERROR)

class Corpus:
    def __init__(self):
        self.unknown_word_symbol = "<???>" # 出現回数の少ない単語は未知語として定義しておく
        self.unknown_word_threshold = 3 # 未知語と定義する単語の出現回数の閾値
        self.corpus_file = "./corpus/**/*.txt"
        self.corpus_encoding = "utf-8"
        self.dictionary_filename = "./data_for_predict/word_dict.dic"
        self.chunk_size = 5
        self.load_dict()

        words = []
        for filename in glob.glob(self.corpus_file, recursive=True):
            with open(filename, "r", encoding=self.corpus_encoding) as f:

                # word breaking
                text = f.read()
                # 全ての文字を小文字に統一し、改行をスペースに変換
                text = text.lower().replace("\n", " ")
                # 特定の文字以外の文字を空文字に置換する
                text = re.sub(r"[^a-z '\-]", "", text)
                # 複数のスペースはスペース一文字に変換
                text = re.sub(r"[ ]+", " ", text)

                # 前処理: '-' で始まる単語は無視する
                words = [ word for word in text.split() if not word.startswith("-")]


        self.data_n = len(words) - self.chunk_size
        self.data = self.seq_to_matrix(words)

    def prepare_data(self):
        """
        訓練データとテストデータを準備する。
        data_n = ( text データの総単語数 ) - chunk_size
        input: (data_n, chunk_size, vocabulary_size)
        output: (data_n, vocabulary_size)
        """

        # 入力と出力の次元テンソルを準備
        all_input = np.zeros([self.chunk_size, self.vocabulary_size, self.data_n])
        all_output = np.zeros([self.vocabulary_size, self.data_n])

        # 準備したテンソルに、コーパスの one-hot 表現(self.data) のデータを埋めていく
        # i 番目から ( i + chunk_size - 1 ) 番目までの単語が1組の入力となる
        # このときの出力は ( i + chunk_size ) 番目の単語
        for i in range(self.data_n):
            all_output[:, i] = self.data[:, i + self.chunk_size] # (i + chunk_size) 番目の単語の one-hot ベクトル
            for j in range(self.chunk_size):
                all_input[j, :, i] = self.data[:, i + self.chunk_size - j - 1]

        # 後に使うデータ形式に合わせるために転置を取る
        all_input = all_input.transpose([2, 0, 1])
        all_output = all_output.transpose()

        # 訓練データ:テストデータを 4 : 1 に分割する
        training_num = ( self.data_n * 4 ) // 5
        return all_input[:training_num], all_output[:training_num], all_input[training_num:], all_output[training_num:]


    def build_dict(self):
        # コーパス全体を見て、単語の出現回数をカウントする
        counter = collections.Counter()
        for filename in glob.glob(self.corpus_file, recursive=True):
            with open(filename, "r", encoding=self.corpus_encoding) as f:

                # word breaking
                text = f.read()
                # 全ての文字を小文字に統一し、改行をスペースに変換
                text = text.lower().replace("\n", " ")
                # 特定の文字以外の文字を空文字に置換する
                text = re.sub(r"[^a-z '\-]", "", text)
                # 複数のスペースはスペース一文字に変換
                text = re.sub(r"[ ]+", " ", text)

                # 前処理: '-' で始まる単語は無視する
                words = [word for word in text.split() if not word.startswith("-")]

                counter.update(words)

        # 出現頻度の低い単語を一つの記号にまとめる
        word_id = 0
        dictionary = {}
        for word, count in counter.items():
            if count <= self.unknown_word_threshold:
                continue

            dictionary[word] = word_id
            word_id += 1
        dictionary[self.unknown_word_symbol] = word_id

        print("総単語数:", len(dictionary))

        # 辞書を pickle を使って保存しておく
        with open(self.dictionary_filename, "wb") as f:
            pickle.dump(dictionary, f)
            print("Dictionary is saved to", self.dictionary_filename)

        self.dictionary = dictionary

        print(self.dictionary)

    def load_dict(self):
        with open(self.dictionary_filename, "rb") as f:
            self.dictionary = pickle.load(f)
            self.vocabulary_size = len(self.dictionary)
            self.input_layer_size = len(self.dictionary)
            self.output_layer_size = len(self.dictionary)
            print("総単語数: ", self.input_layer_size)

    def get_word_id(self, word):
        # print(word)
        # print(self.dictionary)
        # print(self.unknown_word_symbol)
        # print(self.dictionary[self.unknown_word_symbol])
        # print(self.dictionary.get(word, self.dictionary[self.unknown_word_symbol]))
        return self.dictionary.get(word, self.dictionary[self.unknown_word_symbol])

    # 入力された単語を one-hot ベクトルにする
    def to_one_hot(self, word):
        index = self.get_word_id(word)
        data = np.zeros(self.vocabulary_size)
        data[index] = 1
        return data

    def seq_to_matrix(self, seq):
        print(seq)
        data = np.array([self.to_one_hot(word) for word in seq]) # (data_n, vocabulary_size)
        return data.transpose() # (vocabulary_size, data_n)

class Language:
    """
    input layer: self.vocabulary_size
    hidden layer: rnn_size = 30
    output layer: self.vocabulary_size
    """

    def __init__(self):
        self.corpus = Corpus()
        self.dictionary = self.corpus.dictionary
        self.vocabulary_size = len(self.dictionary) # 単語数
        self.input_layer_size = self.vocabulary_size # 入力層の数
        self.hidden_layer_size = 30 # 隠れ層の RNN ユニットの数
        self.output_layer_size = self.vocabulary_size # 出力層の数
        self.batch_size = 128 # バッチサイズ
        self.chunk_size = 5 # 展開するシーケンスの数。c_0, c_1, ..., c_(chunk_size - 1) を入力し、c_(chunk_size) 番目の単語の確率が出力される。
        self.learning_rate = 0.005 # 学習率
        self.epochs = 1000 # 学習するエポック数
        self.forget_bias = 1.0 # LSTM における忘却ゲートのバイアス
        self.model_filename = "./data_for_predict/predict_model.ckpt"
        self.unknown_word_symbol = self.corpus.unknown_word_symbol

    def inference(self, input_data, initial_state):
        """
        :param input_data: (batch_size, chunk_size, vocabulary_size) 次元のテンソル
        :param initial_state: (batch_size, hidden_layer_size) 次元の行列
        :return:
        """
        # 重みとバイアスの初期化
        hidden_w = tf.Variable(tf.truncated_normal([self.input_layer_size, self.hidden_layer_size], stddev=0.01))
        hidden_b = tf.Variable(tf.ones([self.hidden_layer_size]))
        output_w = tf.Variable(tf.truncated_normal([self.hidden_layer_size, self.output_layer_size], stddev=0.01))
        output_b = tf.Variable(tf.ones([self.output_layer_size]))

        # BasicLSTMCell, BasicRNNCell は (batch_size, hidden_layer_size) が chunk_size 数ぶんつながったリストを入力とする。
        # 現時点での入力データは (batch_size, chunk_size, input_layer_size) という3次元のテンソルなので
        # tf.transpose や tf.reshape などを駆使してテンソルのサイズを調整する。

        input_data = tf.transpose(input_data, [1, 0, 2]) # 転置。(chunk_size, batch_size, vocabulary_size)
        input_data = tf.reshape(input_data, [-1, self.input_layer_size]) # 変形。(chunk_size * batch_size, input_layer_size)
        input_data = tf.matmul(input_data, hidden_w) + hidden_b # 重みWとバイアスBを適用。 (chunk_size, batch_size, hidden_layer_size)
        input_data = tf.split(input_data, self.chunk_size, 0) # リストに分割。chunk_size * (batch_size, hidden_layer_size)

        # RNN のセルを定義する。RNN Cell の他に LSTM のセルや GRU のセルなどが利用できる。
        cell = tf.nn.rnn_cell.BasicRNNCell(self.hidden_layer_size)
        outputs, states = tf.nn.static_rnn(cell, input_data, initial_state=initial_state)
        
        # 最後に隠れ層から出力層につながる重みとバイアスを処理する
        # 最終的に softmax 関数で処理し、確率として解釈される。
        # softmax 関数はこの関数の外で定義する。
        output = tf.matmul(outputs[-1], output_w) + output_b

        return output

    def loss(self, logits, labels):
        cost = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=logits, labels=labels))

        return cost

    def training(self, cost):
        # 今回は最適化手法として Adam を選択する。
        # ここの AdamOptimizer の部分を変えることで、Adagrad, Adadelta などの他の最適化手法を選択することができる
        optimizer = tf.train.AdamOptimizer(learning_rate=self.learning_rate).minimize(cost)

        return optimizer

    def train(self):
        # 変数などの用意
        input_data = tf.placeholder("float", [None, self.chunk_size, self.input_layer_size])
        actual_labels = tf.placeholder("float", [None, self.output_layer_size])
        initial_state = tf.placeholder("float", [None, self.hidden_layer_size])

        prediction = self.inference(input_data, initial_state)
        cost = self.loss(prediction, actual_labels)
        optimizer = self.training(cost)
        correct = tf.equal(tf.argmax(prediction, 1), tf.argmax(actual_labels, 1))
        accuracy = tf.reduce_mean(tf.cast(correct, tf.float32))

        # TensorBoard で可視化するため、クロスエントロピーをサマリーに追加
        tf.summary.scalar("Cross entropy: ", cost)
        summary = tf.summary.merge_all()

        # 訓練・テストデータの用意
        # corpus = Corpus()
        trX, trY, teX, teY = self.corpus.prepare_data()
        training_num = trX.shape[0]

        # ログを保存するためのディレクトリ
        timestamp = time.time()
        dirname = datetime.datetime.fromtimestamp(timestamp).strftime("%Y%m%d%H%M%S")

        # ここから実際に学習を走らせる
        with tf.Session() as sess:
            sess.run(tf.global_variables_initializer())
            summary_writer = tf.summary.FileWriter("./log/" + dirname, sess.graph)

            # エポックを回す
            for epoch in range(self.epochs):
                step = 0
                epoch_loss = 0
                epoch_acc = 0

                # 訓練データをバッチサイズごとに分けて学習させる (= optimizer を走らせる)
                # エポックごとの損失関数の合計値や(訓練データに対する)精度も計算しておく
                while (step + 1) * self.batch_size < training_num:
                    start_idx = step * self.batch_size
                    end_idx = (step + 1) * self.batch_size

                    batch_xs = trX[start_idx:end_idx, :, :]
                    batch_ys = trY[start_idx:end_idx, :]

                    _, c, a = sess.run([optimizer, cost, accuracy],
                                       feed_dict={input_data: batch_xs,
                                                  actual_labels: batch_ys,
                                                  initial_state: np.zeros([self.batch_size, self.hidden_layer_size])
                                                  }
                                       )
                    epoch_loss += c
                    epoch_acc += a
                    step += 1

                # コンソールに損失関数の値や精度を出力しておく
                print("Epoch", epoch, "completed ouf of", self.epochs, "-- loss:", epoch_loss, " -- accuracy:",
                      epoch_acc / step)

                # Epochが終わるごとにTensorBoard用に値を保存
                summary_str = sess.run(summary, feed_dict={input_data: trX,
                                                           actual_labels: trY,
                                                           initial_state: np.zeros(
                                                               [trX.shape[0],
                                                                self.hidden_layer_size]
                                                           )
                                                           }
                                       )
                summary_writer.add_summary(summary_str, epoch)
                summary_writer.flush()

            # 学習したモデルも保存しておく
            saver = tf.train.Saver()
            saver.save(sess, self.model_filename)

            # 最後にテストデータでの精度を計算して表示する
            a = sess.run(accuracy, feed_dict={input_data: teX, actual_labels: teY,
                                              initial_state: np.zeros([teX.shape[0], self.hidden_layer_size])})
            print("Accuracy on test:", a)


    def predict(self, seq):
        """
        文章を入力したときに次に来る単語を予測する
        :param seq: 予測したい単語の直前の文字列。chunk_size 以上の単語数が必要。
        :return:
        """

        # 最初に復元したい変数をすべて定義してしまいます
        tf.reset_default_graph()
        input_data = tf.placeholder("float", [None, self.chunk_size, self.input_layer_size])
        initial_state = tf.placeholder("float", [None, self.hidden_layer_size])
        prediction = tf.nn.softmax(self.inference(input_data, initial_state))
        predicted_labels = tf.argmax(prediction, 1)

        # 入力データの作成
        # seq を one-hot 表現に変換する。
        words = [word for word in seq.split() if not word.startswith("-")]
        x = np.zeros([1, self.chunk_size, self.input_layer_size])
        for i in range(self.chunk_size):
            word = seq[len(words) - self.chunk_size + i]
            index = self.dictionary.get(word, self.dictionary[self.unknown_word_symbol])
            x[0][i][index] = 1
        feed_dict = {
            input_data: x, # (1, chunk_size, vocabulary_size)
            initial_state: np.zeros([1, self.hidden_layer_size])
        }

        # tf.Session()を用意
        with tf.Session() as sess:
            # 保存したモデルをロードする。ロード前にすべての変数を用意しておく必要がある。
            saver = tf.train.Saver()
            saver.restore(sess, self.model_filename)

            # ロードしたモデルを使って予測結果を計算
            u, v = sess.run([prediction, predicted_labels], feed_dict=feed_dict)

            keys = list(self.dictionary.keys())


            # コンソールに文字ごとの確率を表示
            for i in range(self.vocabulary_size):
                c = self.unknown_word_symbol if i == (self.vocabulary_size - 1) else keys[i]
                print(c, ":", u[0][i])

            print("Prediction:", seq + " " + ("<???>" if v[0] == (self.vocabulary_size - 1) else keys[v[0]]))

        return u[0]


def build_dict():
    cp = Corpus()
    cp.build_dict()

if __name__ == "__main__":
    #build_dict()

    ln = Language()

    # 学習するときに呼び出す
    #ln.train()

    # 保存したモデルを使って単語の予測をする
    ln.predict("some of them looks like")


結果の一部

lipoprotein : 1.3668956e-14
ldl : 1.4424414e-14
statin : 1.378644e-14
a--z : 1.3740787e-14
simvastatin : 1.413377e-14
bmi : 3.1720919e-07
covariates : 2.8344898e-06
yhl : 2.2330354e-07
yol : 9.992968e-11
obesity : 1.3501551e-09
evgfp : 6.1829963e-09
unintended : 4.6785074e-09
sizes : 2.569963e-07
obese : 1.9368042e-07
<???> : 2.9195742e-05
Prediction: some of them looks like et
  • 演習チャレンジ

GROもLSTMと同様にRNNの一種であり、単純な
RNNにおいて問題となる勾配消失問題を解決し、長期的な依存関係を学習することが出来る。LSTMに比べ変数の数やゲートの数が少なく、より単純なモデルであるが、タスクによってはLSTMより良い性能を発揮すr。以下のプログラムはGRUである。ただし、_sigmoid関数は要素ごとにシグモイド関数を作用させる関数である。
(こ)にあてはまるコードを書け。
f:id:tibet:20211130223210p:plain
[解答]
GRUの更新ゲートの出力なので、下記になる。
(1-z)*h+z*h_bar

  • 確認テスト

LSTMとGRUの違いを述べよ
[解答]
LSTMはCEC、入力ゲート、忘却ゲート、出力ゲートを備え、構成が複雑でパラメータが多い。
GRUは、リセットゲート、更新ゲートで更新され、LSTMより構成が単純でパラメータが少ない。

Section4 双方向RNN

 過去の上方だけでなく、未来の情報を加味することで、精度を向上させるモデル。文章の推敲や機械翻訳などに使用される。
f:id:tibet:20211130223758p:plain

  • 演習チャレンジ

以下は双方向RNNの順伝搬を行うプログラムである。順方向については、入力から中間層への重みW_f、1ステップ前の中間層出力から中間層への重みをU_f、逆方向に関しては同様にパラメータW_b、U_bを持ち、両者の中間表現を合わせた特徴から出力層への重みはVである。_rnn関数はRNNの順伝搬を表し中間層の系列を返す関数であるとする。(か)にあてはまるコードを示せ。
f:id:tibet:20211130223912p:plain
[解答]
hsは、順方向と逆方向の中間表現の特徴量を合わせたものなので、下記になる。

np.concatenate([h_f,h_b[::,-1]],axis=1)