逆翻訳を使って日本語テキストデータを拡張(水増し)してみた

# はじめに
こんにちは、NTTドコモ サービスイノベーション部の稲子です。
本記事は、[NTTドコモR&D Advent Calendar 2021](https://qiita.com/advent-calendar/2021/nttdocomo)の4日目の記事です。
普段の業務ではチャットボットの開発・運用に携わっています。
今回は、逆翻訳によるテキストデータの拡張方法の紹介と文書分類タスクでの実験をしていきたいと思います。

# 逆翻訳とは
逆翻訳は言語処理分野でのデータ拡張 (Data Augmentation) の手法の1つです。
データ拡張とは、既存のデータに処理を行い擬似データを生成するテクニックで、深層学習モデルの作成で大量の学習データが必要な場合によく用いられます。
逆翻訳では、下の図のように元の文を他言語で翻訳しその翻訳文を元の言語で翻訳し直すことで、元の文の意味は保ったまま表現の言い換えを行います。
![逆翻訳](https://amitness.com/images/nlp-aug-back-translation.png)

# 1. 逆翻訳でデータを水増ししてみる
本記事では、翻訳APIを用いて手軽に逆翻訳する方法をご紹介します。
それでは実際にやってみましょう。

## データの準備
今回はリクルートが提供するじゃらんnetに投稿されたポジネガラベル付きの[宿クチコミのコーパス](https://github.com/megagonlabs/jrte-corpus)を使用します。
コーパスのサンプルはこちらです。

| テキスト | ラベル |　ラベル(変換後) |
|:---|:---:|:---:|
| 出張でお世話になりました。 | 0 | 0 |
| また是非行きたいです。 | 1 | 1 |
| 残念...。 | -1 | 2 |

テキストとラベルで構成されており、テキストが宿のクチコミで、ラベルは1がポジティブ、0が中立、-1がネガティブになっています。
ラベルに-1が付与されていると実験時に利用する分類器の学習時に```IndexError: Target -1 is out of bounds.```というエラーが出るので、今回はラベルが-1の場合は2に変換します。

まずはこちらを学習データと評価データに分けていきます。

```python
import pandas as pd
import numpy as np
import random

data = pd.read_csv("pn.tsv",sep='\t', header=None)
sentences = list(data[2])
labels = list(data[1].replace(-1, 2))

dataset = []
for sent, label in zip(sentences, labels):
    dataset.append([sent, label])
train_size = int(0.9 * len(data))
np.random.shuffle(dataset)
train_data = dataset[:train_size]
test_data = dataset[train_size:]

train = open("train.tsv", mode = "w")
test = open("test.tsv", mode = "w")
for sent, label in train_data:
    train.write(sent+"\t"+str(label)+"\n")
for sent, label in test_data:
    test.write(sent+"\t"+str(label)+"\n")
```

上記のスクリプトを実行すると、下記のようなタブ区切りでテキストとラベルが並んだ`train.tsv`と`test.tsv`というファイルが出力されます。
`train.tsv`が学習データ、`test.tsv`が評価データになります。

```
ご飯とお味噌汁がセルフなので、そちらで調整かな。	0
駅に近いです。	1
```
各データのラベルごとの文数はこちらです。

| ラベル | 学習データ | 評価データ |
|:---:|---:|---:|
| 0 | 1209 | 120 |
| 1 | 3047 | 359 |
| 2 | 741 | 77 |
| 計 | 4997 | 556 |

## 逆翻訳
次に学習データのテキストを逆翻訳して拡張データを作成していきます。今回は[みらい翻訳API](https://miraitranslate.com/service/)を利用します。
下記のスクリプトの`translate_by_mirai`関数でみらい翻訳APIを呼び出して、翻訳された結果を取得しています。
```"hoge"```には取得したAPIキーを入力します。

```python
import pandas as pd

API_KEY = "hoge"
train = open("train_bt.tsv", mode = "w")
data = pd.read_csv("train.tsv",sep='\t', header=None)
sentences = list(data[0])
labels = list(data[1])
for sent, label in zip(sentences, labels):
    translated_en = translate_by_mirai(sent, API_KEY) # APIの形式は省略します
    translated_ja = translate_by_mirai(translated_en, API_KEY)# APIの形式は省略します
    if sent != translated_ja:
      train.write(translated_ja + "\t" + str(label) + "\n")
```
スクリプトを実行すると、`train.tsv`のテキストを逆翻訳した結果とテキストと元の文の対になっていたラベルが並んだ`train_bt.tsv`という拡張データが生成できます。

```
ご飯と味噌汁はセルフサービスなので、そこで調整できると思います。	0
駅の近くです。	1
```

逆翻訳した結果が元の文と同じになるような場合は、`train_bt.tsv`に出力していません。
今回の場合は、元の学習データのテキストが4997文に対して拡張データのテキストは4423文となり、元のデータの89%を拡張することができました。
元の文と翻訳した結果の例はこちらです。
逆翻訳文では、元の文とは異なる語順や別の表現の単語が生成できている事がわかります。
元の文が短い場合は、元の文→訳文、訳文→逆翻訳文のそれぞれの翻訳は正しくても、元の文と逆翻訳文の意味が異なるものも見受けられます。

| 元の文 | 訳文 | 逆翻訳文(拡張文) | ラベル　|
|:---|:---|:---|:---:|
| 部屋は特に不満はありませんでした。 | I didn't have any complaints about the room. | お部屋に不満はありませんでした。 | 0 |
| リベンジします。 | I'll take revenge. | 復讐します。 | 0 |
| 和食を選んだのですが、洋食はハムと玉子を注文してから作っていたので、何か妙に美味しそうに見えました。 | I chose Japanese food, but I made Western food after ordering ham and eggs, so it looked strangely delicious. | 和食を選びましたが、ハムと卵を注文してから洋食を作ったので、不思議と美味しそうでした。 | 1 |
| リピート決定です。 | I decided to repeat it. | 私はそれを繰り返すことにした。 | 1 |
| お風呂も良かったのですが、露天風呂が3階の風呂にしか無く少し残念でした。 | The bath was good, but it was a little disappointing that the open-air bath was only in the bath on the third floor. | お風呂は良かったのですが、露天風呂が3階のお風呂しかないのが少し残念でした。 | 2 |
| 本当に残念です。 | That's too bad. | それはいけませんね。 | 2 |

# 2. 水増ししたデータをBERT分類器に学習させて文書分類してみる
上記で作成した学習データと拡張データを合わせて BERT を用いた分類器に学習させ、文書分類タスクで実験してみます。
今回は BERT の事前学習モデルとして[東北大学の乾研究室](https://github.com/cl-tohoku/bert-japanese)が作成した```bert-base-japanese-whole-word-masking```を使用しました。
文書分類のスクリプトは[こちら](https://www.nogawanogawa.com/entry/rte)を参考にさせていただきました。

## データセットクラスの作成
まずは分類器の学習データとなる`train.tsv`と`train_bt.tsv`のテキストと評価データとなる`test.tsv`のテキストをBERTトークナイザで解析し、出力結果と各データのラベルをテンソルに変換して学習データセットと評価データセットを作成します。

```python
import pandas as pd
import torch
from torch.utils.data import TensorDataset, random_split
from torch.utils.data import DataLoader, RandomSampler, SequentialSampler
from tqdm import tqdm
from transformers import BertJapaneseTokenizer
from transformers import BertForSequenceClassification, AdamW, BertConfig

def load_file(file):
    data = pd.read_csv(file, sep='\t', header=None, engine='python')
    sentences = list(data[0])
    labels = list(data[1])
    dataset = []
    for sent, label in zip(sentences, labels):
        dataset.append([sent, label])
    return dataset

def preprocess(data):
    sentences = []
    labels = []
    for sent, label in data:
        sentences.append(sent)
        labels.append(label)
    data = sentences, labels
    tokenizer = BertJapaneseTokenizer.from_pretrained('cl-tohoku/bert-base-japanese-whole-word-masking')
    input_ids = []
    attention_masks = []
    max_length = 100
    # データセットの準備
    for sent in sentences:
        encoded_dict = tokenizer.encode_plus(
                            sent,
                            add_special_tokens = True, # Special Tokenの追加
                            truncation=True,
                            max_length = max_length,           # 文章の長さを固定（Padding/Trancatinating）
                            pad_to_max_length = True,# PADDINGで埋める
                            return_attention_mask = True,   # Attention maksの作成
                            return_tensors = 'pt',     #  Pytorch tensorsで返す
                       )

        # 単語IDを取得
        input_ids.append(encoded_dict['input_ids'])

        # Attention　maskの取得
        attention_masks.append(encoded_dict['attention_mask'])

    # リストに入ったtensorを縦方向（dim=0）へ結合
    input_ids = torch.cat(input_ids, dim=0)
    attention_masks = torch.cat(attention_masks, dim=0)
    # tenosor型に変換
    labels = torch.tensor(labels)
    return input_ids, attention_masks, labels

train_data = load_file("train.tsv")
train_bt_data = load_file("train_bt.tsv")
val_data = load_file("test.tsv")
bt_input_ids, bt_attention_masks, bt_labels = preprocess(train_data+train_bt_data)
v_input_ids, v_attention_masks, v_labels = preprocess(val_data)

# データセットクラスの作成
train_bt_dataset = TensorDataset(bt_input_ids, bt_attention_masks, bt_labels)
val_dataset = TensorDataset(v_input_ids, v_attention_masks, v_labels)

```

拡張後の学習データの各ラベルの文数はこちらです。

| ラベル | 学習データ |
|:---:|---:|
| 0 | 2273 (+1064) |
| 1 | 5710 (+2663) |  
| 2 | 1437 (+696) |
| 計 | 9420 (+4423) |

## 学習
次に、学習データセットを分類器に学習させます。
分類器は Huggingface の[`BertForSequenceClassification`](https://huggingface.co/transformers/model_doc/bert.html#bertforsequenceclassification)を利用しました。

```python
def train(data_loader, model):
      # 最適化手法の設定
      optimizer = AdamW(model.parameters(), lr=2e-5)
      model.train() # 訓練モードで実行
      train_loss = 0
      for batch in tqdm(data_loader):# train_dataloaderはword_id, mask, labelを出力
          b_input_ids = batch[0].to(device)
          b_input_mask = batch[1].to(device)
          b_labels = batch[2].to(device)
          optimizer.zero_grad()
          outputs = model(b_input_ids,
                               token_type_ids=None,
                               attention_mask=b_input_mask,
                               labels=b_labels)
          loss = outputs.loss
          loss.backward()
          torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
          optimizer.step()
          train_loss += loss.item()
      return train_loss

# データローダーの作成
batch_size = 8

# 訓練データローダー
train_bt_dataloader = DataLoader(
            train_bt_dataset,
            sampler = RandomSampler(train_bt_dataset), # ランダムにデータを取得してバッチ化
            batch_size = batch_size
        )

# 検証データローダー
validation_dataloader = DataLoader(
            val_dataset,
            sampler = SequentialSampler(val_dataset), # 順番にデータを取得してバッチ化
            batch_size = batch_size
        )

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

# BertForSequenceClassification 学習済みモデルのロード
model = BertForSequenceClassification.from_pretrained(
    "cl-tohoku/bert-base-japanese-whole-word-masking", # 日本語Pre trainedモデルの指定
    num_labels = 3, # ラベル数
    output_attentions = False, # アテンションベクトルを出力するか
    output_hidden_states = True, # 隠れ層を出力するか
)
# 学習の実行
max_epoch = 5
train_loss_ = []
test_loss_ = []

for epoch in tqdm(range(max_epoch)):
    train_ = train(data_loader, model)
    log_metric("train_loss", train_, step=epoch)
```

## 評価
最後に分類器に評価データを入力して出力される予測ラベルと正解ラベルを照らし合わせ、分類器の予測性能の評価をします。

```python
y_true = []
y_pred = []

model.eval()# 訓練モードをオフ
for batch in validation_dataloader:
    b_input_ids = batch[0].to(device)
    b_input_mask = batch[1].to(device)
    b_labels = batch[2].to(device)
    y_true.extend(b_labels.cpu().numpy())
    with torch.no_grad():
        # 学習済みモデルによる予測結果をpredsで取得
        preds = model(b_input_ids,
                            token_type_ids=None,
                            attention_mask=b_input_mask)
        y_pred.extend(np.argmax(preds[0].cpu().numpy(), axis=1))

from sklearn.metrics import classification_report
with open("output.txt", "w") as f:
        f.write(classification_report(y_true, y_pred, digits=4))
```
## 結果
元の文だけでも結構高い精度ですが、逆翻訳でデータを拡張したことでより全体の精度が上がりました。
中でも元のデータ量が少なかったラベル2では、全ての値が大きく向上しました。
このことから、逆翻訳によるデータ拡張は、データ量が少ない場合に対して特に有効であると考えられます。

- 元の文のみを学習した場合

Accuracy:0.8579

| ラベル | precision | recall　| f1-score |
|:---:|--:|--:|--:|
| 0 |  0.6953 | 0.7417 | 0.7177 |
| 1 | 0.9280 |  0.8969 | 0.9122 |
| 2 | 0.7619 | 0.8312 | 0.7950 |

- 元の文と拡張文を合わせて学習した場合

Accuracy:0.8705

| ラベル | precision | recall　| f1-score |
|:---:|--:|--:|--:|
| 0 |  0.7545 | 0.6917 | 0.7217 |
| 1 | 0.9278 | 0.9304 | 0.9291 |
| 2 | 0.8148 | 0.8571 | 0.8354 |

# まとめ
逆翻訳を用いて日本語コーパスのデータ拡張を実施し、文書分類タスクに適用してみました。
今回は日→英→日のみの逆翻訳を試しましたが、他の言語でも翻訳することでよりデータのバリエーションを増やせるかと思います。

# 参考文献
1.https://amitness.com/2020/05/data-augmentation-for-nlp/
2.https://www.nogawanogawa.com/entry/rte
3.https://qiita.com/nena0undefined/items/c2926bad07039e5540ab