NLP の Deep なモデルでノイズによる Data Augmentation を試す

# これはなに

[ミクシィグループAdvent Calendar 2019](https://qiita.com/advent-calendar/2019/mixi) 11日目の記事です。
いいネタがなかったので去年少し実験していたものを雑に再利用しました。

NLPでの Deep なモデルに対して適用できるノイズを用いた Data Augumentation の紹介と、テキスト分類のタスクで実験を行った記事です。
Kaggle など色々なところで見たことのある手法をまとめて実験してみました。
複数のモデルで実験したかったんですが GPU 的に間に合わなかったので、ゆっくり実験して追記していこうと思います。

# Data Augmentation とは

Data Augmentation は日本語でデータかさ増し、水増しなどとも言われ、与えられた学習データに変換を加え、それを新たなデータとすることで学習データ集合を拡張することを言います。
画像は特にその性質から、次のように人間から見てもほとんど理解が変わらないような変換が多くあり、Data Augmentation としてよく利用されています。

|オリジナル| Horizontal Flip | Rotate (10°) |
|---|---|---|
| ![icon_01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49409/28994e81-d3e1-0e67-4b28-dc56c755d4f4.png) | ![icon_02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49409/a4cc6db5-272d-ee38-afd4-67358f7c4952.png) | ![icon_03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49409/27edcd49-c000-815d-fbfe-d11f014c1191.png)
 |

図1: 画像での Data Augmentation の例, どれを見ても人のイラスト(?)だと分かる

# NLP での Data Augmentation

画像では各ピクセルを連続値とみなすことができる上に、自然画像は小さな変換に対して意味が不変という性質があるため、上記のような変換やノイズを用いた Data Augmentation の手法が多く利用されています。
自然言語処理では、各単語を
離散値として扱うため、画像のようにストレートな変換をしづらいという問題があります。そのため、元の文章の変換だけでなく、類義語や翻訳などの外部ツールを用いた手法も使用されています。下記にいくつか例を示します。

![data_aug_nlp_01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49409/8ef0a8fd-ec3c-cdff-ad18-e19aa0a6304c.png)
図2: NLP での Data Augmentation の例

このような手法に加えて、画像と同様にノイズを用いた Data Augmentation 手法も提案されています。離散値にノイズを付加するのは難しいため、入力となるテキストを Embedding Layer に通して得られた連続値のベクトルに対してノイズを付加します。

![data_aug_nlp_02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49409/0f1803ee-e5bf-4c8f-bbde-d305be10cfcb.png)
図3: Embeddings に Noise を付加

# ノイズの入れ方による Data Augmentation

今回試すのは次のものです。Embedding Layer に適用し、他の層は通常の Dropout を入れています。

- Bernoulli Noise (Dropout)
    - Dropout (p=0.3)
    - Word Dropout (p=0.3)
    - Semantic Dropout (p=0.3)
- Gaussian Noise
    - Additive (σ=0.1, 0.01)
    - Multiplicative (間に合わなかったので省略)
- Adversarial Noise (σ=0.1, 0.01)

## Dropout

Dropout は Deep Learning における代表的な正則化手法です。
ベルヌーイ分布からサンプリングしたマスクを使って一部のユニットの出力を 0 にすることで正則化の効果が得られます。詳しくは下記記事を見てください。
[Deep LearningにおけるDropoutの理解メモと、実際にどう効いているのか見てみる](https://qiita.com/cfiken/items/88427533ea9b501a6c10)

今回は入力となる文章の Embeddings にノイズを付加することを考えます。モデルのへの入力を Embedding Layer に通したものは次のような形状の Tensor となっています。

![data_aug_shape.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49409/4c1b48a5-de1a-97e3-5135-e17479af88cc.png)
 
実際のモデルへの入力は上図のように minibatch として入れますが、そのうち1サンプルを取り出し、Dropout を適用したものを図示すると次のようになります。

![data_aug_do.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49409/de8eb1e6-0b81-257c-3664-7d942cd9fd04.png)

## Word Dropout

Word Dropout は、名前の通り Dropout を単語レベルで適用したものです。入力として与えられた文章からランダムにサンプリングした単語について、すべての Embeddings の値を 0 にします。通常の Dropout と比べて、Word Dropout を適用した場合の図は下記のようになります。
単語をランダムで抜くとの同等なので、モデルは入力となる文章から適当な単語がなくなっても推論ができるように学習し、直感的には特定の単語だけを見るのではなく、文全体で推論を行うようになることが期待できます。

![data_aug_word.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49409/0da089a6-0dad-41f7-f152-633d68c8d0b5.png)

## Semantic Dropout

Semantic Dropout は Word Dropout の方向を Embeddings 方向に変えたもので、入力となる文章の各単語が持つ Embedding について、特定の次元をすべて 0 にします。
Embeddings の特定の次元がランダムで落とされるので、Embeddings の各次元がそれぞれ独立な特徴を持つことが期待できます。

![data_aug_semantic.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49409/a76a5687-862e-b726-8840-9e06f7ba710a.png)

## Gaussian Noise

名前の通りで、ガウス分布からサンプリングしたノイズを Embeddings に付加もしくは掛けます。
多少 Embeddings の特徴が変化しても同じような出力をするようにモデルを学習させることで、よりロバストなモデルが得られることが期待できます。

##  Adversarial Noise

Adversarial Noise は、Adversarial Training とも呼ばれる、モデルが間違いやすい方向にノイズを付加することでモデルがよりロバストとなるよう学習させる手法です。
より詳細な説明は PFN 佐藤さんの [言語処理分野における Adversarial Example](https://jsai.ixsq.nii.ac.jp/ej/?action=repository_action_common_download&item_id=10412&item_no=1&attribute_id=22&file_no=1) が分かりやすくオススメです。

# 実験

## 概要

今回は Kaggle 上で昨年開催された [Quora Insincere Questions Classification](https://www.kaggle.com/c/quora-insincere-questions-classification) のデータを使ってそれぞれのノイズの入れ方で精度に差が出るか実験を行いました。タスクは二値分類で、与えられたテキストが insincere かどうかを判定します。
Kaggle 上のテストデータは使えなかったので、事前に分割したテストデータでの F1 Score を計算しました。モデルは 5fold cross validation で学習し、アンサンブルしたものになります。
詳細については [コード](https://github.com/cfiken/data-aug-nlp) をご覧ください。

## モデル

時間の都合で今回はLSTMベースの２層の小さなモデルを使用しました。
末尾でも述べますが、他のモデルでも実験して追記予定です。

## 結果

| Type | Parameter | F1 (5回平均) |
|-------|------|------:|
| Baseline | - | 0.6978 |
| Dropout | p = 0.3 | 0.7026 |
| Word Dropout | p = 0.3 | 0.6963 |
| Semantic Dropout | p = 0.3 | 0.7024 |
| Gaussian Additive | σ = 0.1 | 0.6961 |
| Gaussian Additive | σ = 0.01 | 0.6962 |
| Adversarial Noise | σ = 0.1 | 0.6845 |
| Adversarial Noise | σ = 0.01 | 0.6939 |

微妙な差ですが、結果的には Dropout ベースのものが安定してよかったです。
モデル、タスクによるところもあるとは思いますが、今回は Gaussian や Adversarial なノイズで改善を確認することができませんでした。ハイパーパラメータをあまり広く試せていないので、もしかするともっと良い結果があるかもしれません。

## 実装のメモ

PyTorch での実装メモです。

## Dropout

```py
self.embedding_dropout = nn.Dropout(0.3)
...
def forward(self, x):
    embedding = self.embeddings(x)  # embedding layer
    return self.embedding_dropout(embedding)
```

## Word Dropout

```py
self.embedding_dropout = nn.Dropout2d(0.3)
...
def forward(self, x):
    embedding = self.embeddings(x)  # embedding layer
    return self.embedding_dropout(embedding)
```

## Semantic Dropout

```py
self.embedding_dropout = nn.Dropout2d(0.3)
...
def forward(self, x):
    embedding = self.embeddings(x)  # embedding layer
    return self.embedding_dropout(
        embedding.permute(0, 2, 1)
    ).permute(0, 2, 1)
```

## Gaussian Noise

```py

def forward(self, x):
    embedding = self.embeddings(x)  # embedding layer
    if self.training:
        embedding += torch.randn_like(embedding) * 0.1
    return self.embedding_dropout(embedding)
```

##  Adversarial Noise

```py
class Embedding(nn.Module):
    ...
    def forward(self, x, perturb=None):
        embedding = self.embeddings(x)
        embedding.requires_grad = True
        if perturb is not None:
            return embedding + perturb
        return embedding

# perturb は Gradient を計算して得る
y_pred, embedding = model(x_batch)
loss = loss_fn(y_pred, y_batch)
optimizer.zero_grad()
loss.backward()

perturb = embedding.grad
perturb = 0.1 * perturb / torch.norm(perturb, p=2)
perturb = perturb.detach()
y_pred, _ = model(x_batch, perturb)
```

## 今後

今回はいくつかの手法を試しましたが、元々は次のような疑問から実験したものでした。

- ノイズごとの効果はどのくらい違うのだろうか
- モデルのハイパーパラメータによって異なるのだろうか
- アーキテクチャの違いによって傾向が変わるだろうか
- 対象となるタスクによって異なるのだろうか

今回は最初のみ実験できました。後者については経験的にはどれも違ってくるのですが、もし「これは少なくとも試さなくて良い」ようなものが見つかれば時間削減になるかなと考えており、追って実験してみたいと思います。

以上です。なにか間違いあればご指摘いただけると助かります！