一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

テキストデータのかさましを実装する

はじめに

データサイエンス部の平田です。 ディープラーニングのモデルを作る際、学習データが少ないことが原因で精度が上がらない場合、データのかさまし(augmentation)を行うことがあります。

画像の場合は、オリジナルに対して回転させたりノイズを少し加えることで同じラベル付けがされている別の画像を作り出すことができ、それを学習データに加えることで頑健なモデルになります。

ただし、テキストの場合は回転させると意味不明になるのでどういう操作をしてかさましするかというのを考える必要があります。

そこで、EDA(Easy Data Augmentation)というものが考案されました。参考

  • Synonym Replacement:文中の単語の内n個、同義語に置き換える
  • Random Insertion:文中の単語をランダムに選んで同義語にしてランダムな場所にinsert、n回繰り返す
  • Random Swap:文中の二つを入れ替える n回繰り返す
  • Random Deletion:確率αでランダムにそれぞれの単語削除

上4つの操作の内どれか一つをランダムに行って一つの文から沢山の文を生成する手法です。n=α×テキストの単語数とします。αは自由に変えられるハイパーパラメータで、文章の内どのくらいの割合で単語を操作するかという意味になります。 論文中では英文のデータセットで行っていましたが今回は日本語の文で試してみました。また、同義語に置き換える方法をWordNetからとChive(Word2vec)からの2通り試しています。

実装

EDA

まず文中の単語を分けるために、分かち書きをする必要があるのですが、その前に単語を引数に取って同義語を返す関数を定義します。 Wordnetから「Japanese Wordnet and English WordNet in an sqlite3 database」をダウンロードしてきます。

conn_sqlite = sqlite3.connect("wnjpn.db")
re_alnum = re.compile(r'^[a-zA-Z0-9_]+$')

# 特定の単語を入力とした時に、類義語を検索する関数
def search_similar_words(word):
    # 問い合わせしたい単語がWordnetに存在するか確認する
    cur = conn_sqlite.execute("select wordid from word where lemma='%s'" % word)
    word_id = 99999999  #temp 
    for row in cur:
        word_id = row[0]

    # Wordnetに存在する語であるかの判定
    if word_id==99999999:
        return []

    # 入力された単語を含む概念を検索する
    cur = conn_sqlite.execute("select synset from sense where wordid='%s'" % word_id)
    synsets = []
    for row in cur:
        synsets.append(row[0])

    words = []
    for synset in synsets:
        cur3 = conn_sqlite.execute("select wordid from sense where (synset='%s' and wordid!=%s)" % (synset,word_id))
        for row3 in cur3:
            cur3_1 = conn_sqlite.execute("select lemma from word where wordid=%s" % row3[0])
            for row3_1 in cur3_1:
                words.append(row3_1[0])

        
    return list(set([w for w in words if not re.search(re_alnum, w)]))

例えば「美味しい」の同義語は、

print(search_similar_words('美味しい'))
# => ['快い', 'きれい', '素適', '善い', '好いたらしい', 'いい', '好い', 
'ナイス', '心地よい', '可愛い', '麗しい', '旨味しい', '好ましい', 
'すてき', '良い', '綺麗', 'よい']

こんな感じになります。美味しいと可愛いは同義語?というのは置いといて、ポジティブなワードが並んでいます。

実装は@pocket_kyotoさんの記事を参考にしました。英単語が混じっているので取り除く処理もしています。 のちほど比較のためにべつの同義語取得関数も作ります。

続いて文を単語に分解する分かち書きですが、使いやすいsudachiを利用することにします。

pip install sudachipy==0.5.2
pip install sudachidict_core
from sudachipy import tokenizer
from sudachipy import dictionary

tokenizer_obj = dictionary.Dictionary(dict_type="core").create()
mode = tokenizer.Tokenizer.SplitMode.A

以上でsudachiライブラリの導入ができました。上記4つの操作をランダムに行うEDAをごりごり実装していきます。同義語を抽出する単語は名詞と動詞、形容詞だけにします。また、単語を変換する際、動詞の活用は気にせず終止形だけ使うようにします。

alpha = 0.3 # 単語を操作する割合
N_AUG = 16 # 一文から増やす文章の数(倍率)、500: 16, 2000: 8, 5000: 4
def synonym_select(target_token):
    if target_token.part_of_speech()[0] == '動詞' or \
        (target_token.part_of_speech()[0] == '形容詞' and target_token.part_of_speech()[1] == '一般'):
        target_word = target_token.dictionary_form()
    else:
        target_word = target_token.surface()

    synonyms = search_similar_words(target_word)

    if len(synonyms) > 0:
        replacement = random.choice(synonyms)
    else:
        # 同義語が無かったら置換しない
        replacement = target_token.surface()
    
    return replacement

# Synonym Replacement 文中の単語の内n個、同義語に置き換える
def synonym_replacement(text_tokens, text_split, text_length):
    n = math.floor(alpha * text_length)
    indexes = [i for i in range(text_length) if text_tokens[i].part_of_speech()[0] in ['名詞', '動詞', '形容詞']]
    target_indexes = random.sample(indexes, min(n, len(indexes)))
    
    for i in target_indexes:
        target_token = text_tokens[i]
        replacement = synonym_select(target_token)
        text_split[i] = replacement
        
    return text_split

# Random Insertion 文中の単語をランダムに選んで同義語にしてランダムな場所にinsert、n回繰り返す
def random_insertion(text_tokens, text_split, text_length):
    n = math.floor(alpha * text_length)
    indexes = [i for i in range(text_length) if text_tokens[i].part_of_speech()[0] in ['名詞', '動詞', '形容詞']]
    target_indexes = random.sample(indexes, min(n, len(indexes)))
    
    for i in target_indexes:
        target_token = text_tokens[i]
        replacement = synonym_select(target_token)
        random_index = random.choice(range(text_length))
        text_split.insert(random_index, replacement)
        
    return text_split

# Random Swap 文中の二つを入れ替える n回繰り返す
def random_swap(text_split, text_length):
    if text_length < 2:
        return ''.join(text_split)
    
    n = math.floor(alpha * text_length)
    target_indexes = random.sample(range(text_length), 2)
    
    for i in range(n):
        swap = text_split[target_indexes[0]]
        text_split[target_indexes[0]] = text_split[target_indexes[1]]
        text_split[target_indexes[1]] = swap
        
    return text_split


# Random Deletion 確率pでランダムにそれぞれの単語削除
p = alpha
def random_deletion(text_split, text_length):
    return [t for t in text_split if random.random() > p]

# Easy Data Augumentation 
def eda(text_tokens, text_split, text_length):
    results = []
    for _ in range(N_AUG):
        text_tokens_c = copy.copy(text_tokens)
        text_split_c = copy.copy(text_split)
        
        random_number = random.random()
        if random_number < 1/4:
            result_arr = synonym_replacement(text_tokens_c, text_split_c, text_length)
        elif random_number < 2/4:
            result_arr = random_insertion(text_tokens_c, text_split_c, text_length)
        elif random_number < 3/4:
            result_arr = random_swap(text_split_c, text_length)
        else:
            result_arr = random_deletion(text_split_c, text_length)
            
        results.append(''.join(result_arr))
    return list(set(results))

実際に例文を入れていきましょう。

お肉がとても美味しかったです。食後のコーヒーが別料金(210円)なので、そこだけ注意です。
=>
おミートがとても美味しかったです。食後のカフェーが画然たる勘定(210円型)なので、そこだけ注目です。

「画然たる勘定」という新たな語彙が生まれました。

Chive(Word2Vec)

同義語を持ってくる別手法として、単語をベクトル化して類似度が高いものを選ぶというやり方も考えられます。今回は単語をベクトル化する手法として一番オーソドックスなWord2Vecを使い、WikipediaをコーパスとしたChiveを利用します。gensim用のv1.1 mc90 aunitをダウンロードして適切な場所に置きます。

vectors = gensim.models.KeyedVectors.load("chive-1.1-mc90-aunit.kv")
def search_similar_words(word):
    thre = 0.6
    try:
        result = vectors.most_similar(word, topn=20)
        return [r[0] for r in result if r[1] > thre]
    except:
        return []

search_similar_wordsを置き換えることでChiveバージョンの関数を定義できます。

ちなみに、同じく美味しいの同義語を見てみると

print(search_similar_words('美味しい'))
# => ['美味', '激旨', '味', '絶品', '食べる', '甘み', '焼き立て', 'サラダ', 
'うまうま', 'スープ', '食べ応え', '食感', '香ばしい', '熱々', 
'風味', 'ジューシー', '揚げ立て', '御馳走', 'あっさり味', '塩味']

こんな感じになります。WordNetよりカジュアルですし、後半は唐揚げ感あります。

差し替えたバージョンでもEDAの例を見てみます。

お肉がとても美味しかったです。食後のコーヒーが別料金(210円)なので、そこだけ注意です。
=>
1. お胸肉がとても美味しかったです。空腹の水出しが各料(210¥)なので、そこだけ呉々です。
2. お肉がとても美味しかったコーヒーショップです。
¥食後のコーヒーが別料金(210円)な違う間食のでジューシー金額、細心ソーセージそこだけ注意です。

美味しいお肉が出てくるコーヒーショップは行ってみたいですが、意味変わってる気がしますね。

評価モデル作成

実際に文を増やしたことによる効果を検証したいと思います。今回は、レストランの口コミ文だけから評価点(1~5)を予測するタスクを考え、正解データとどれだけ離れているかを調べることで定量的に効果を計算します。

BertJapaneseTokenizerと、LSTMを使用します。

# LSTMで検証
from transformers import BertJapaneseTokenizer
from keras.preprocessing.sequence import pad_sequences
tokenizer = BertJapaneseTokenizer.from_pretrained('cl-tohoku/bert-base-japanese-whole-word-masking')

MAX_WORD_COUNT = 256

x_train = pad_sequences(df_sample_train['text'].apply(lambda x: tokenizer.encode(x)), maxlen=MAX_WORD_COUNT)
y_train = df_sample_train['total_score']
x_test = pad_sequences(df_sample_test['text'].apply(lambda x: tokenizer.encode(x)), maxlen=MAX_WORD_COUNT)
y_test = df_sample_test['total_score']

以下でモデルを作成します、指標として平均二乗誤差を利用します。

# ベクトルを入力としてLSTMでregression
from keras.preprocessing.sequence import pad_sequences
from keras.layers import LSTM, Dense, Embedding, Dropout, Input
from keras.models import Model

vocabulary_size = len(tokenizer) + 1  # 学習データの語彙数+1

text_in = Input(shape=(MAX_WORD_COUNT, ), dtype='int32', name='text_in')
x = Embedding(input_dim=vocabulary_size, output_dim=32, mask_zero=True)(text_in)
x = LSTM(128, return_sequences=False)(x)
x = Dropout(0.1)(x)
out_score = Dense(units = 1, name='out_score')(x)
model = Model(inputs=text_in, outputs=out_score)

model.compile(optimizer = 'adam', loss = 'mean_squared_error')

model.summary()

モデルに実データを入れて実験です!

history = model.fit(
    x_train,
    y_train,
    batch_size=64, epochs=5,
    validation_data=(x_test, y_test)
)
min(history.history['val_loss'])

例えばval_lossが0.7だと実際の評価点と予測の評価点が平均±0.7だけ離れていることになります。小さい方がより性能が高いということになります。

結果

学習に使う文の数を500, 1000の二通りについて見てみます。 また、バッチサイズはEDAのときは64, なしのときは4にします。同義語の抽出はWordNetを使っています。

500文の場合

EDA val_loss
なし 0.795
α=0.05 0.771
α=0.1 0.783
α=0.2 0.734
α=0.3 0.730

EDA無しの時と比べてval_lossが低い(=性能が高い)ことが分かりました。また、αは大きい方が性能が良くなっています。

1000文の場合

EDA val_loss
なし 0.709
α=0.05 0.714
α=0.1 0.723
α=0.2 0.650
α=0.3 0.666

1000文の場合は学習データが増えているので全体的にval_lossは低くなっています。 また、α=0.2の場合はEDA無しと比べて性能が良くなっています。

αが低いときはEDA無しより性能が悪くなっているので、適切なαの設定は重要なことが分かります。

500文(Chive)の場合

今度はWord2Vecの事前学習済みデータChiveを使った同義語抽出に変更した場合の性能を検証してみます。

EDA val_loss
なし 0.774
α=0.05 0.766
α=0.1 0.727
α=0.2 0.725
α=0.3 0.707

こちらも性能が良くなっています(0.774 -> 0.707)。同性能が出るなら実装の手軽さからChiveを使う方が良いかもしれません。

結論

基本的にはEDAを行うことで性能が上がることが分かりました。特に文章数が少ないときほど効果的です。学習データが少ないときには試してみてはいかがでしょうか!