Re:ゼロから始めるML生活

どちらかといえばエミリア派です

テキスト生成をやってみる

f:id:nogawanogawa:20200727195331p:plain

自然言語処理のタスクの一つにテキスト生成があります。 質問文に対する応答を生成したり、長文を短く要約したり、和文英訳したりと、テキスト生成は自然言語処理の応用の様々なところで使用されます。

今回は、このテキスト生成についてやってみたのでそのメモです。

文書生成の概要

文書生成の概要や、最近の手法については下記の記事が非常にわかりやすく、参考にさせていただきました。

ai-lab.lapras.com

上の記事によれば、

文生成とは、文・画像・その他諸々の表現を入力として、最終的に自然言語文を出力とする処理のことを言います。文生成の技術は様々な応用タスクで用いられています。 2019年現在の文・文書生成に関してのまとめ - LAPRAS AI LAB

用途は対話応答だったり、文書要約だったり様々ありますが、早い話が最終的に自然文を生成するタスクを文生成(この記事ではテキスト生成とします)です。

問題設定

テキスト生成がなにかざっくりつかめたところで、実際にやってみたいと思います。 とはいうものの、テキスト生成というだけでは問題の範囲が広すぎて実装に取りかかれないので、もう少し問題を具体化することにします。

今回は、入力した文と同じ意味の文を生成することを考えます。入力された文をもとに、多少意味が異なっていて文を言い換えることを目標とします。

データセット

今回使用したデータセットは京都大学の黒橋・河原研究室で公開しているTextual Entailment評価データで、2472件の含意関係を表したデータが含まれています。

nlp.ist.i.kyoto-u.ac.jp

今回は簡単のため、こちらのデータセットのうち含意関係を持つデータのみを抽出して使用します。*1

データを使用する際の細かな設定は下記のようにしました。

  • 推論判定が"✗"のものは除外
    • 言い換えだけを教師データとする
  • カテゴリが"~(上位→下位)"のものは"テキスト"と"仮説"を入れ替え
    • 具体的表現は抽象的表現に書き換えても通じるはずだが、その逆は成り立たないはずのため

機械学習手法

生成自体には、Pytorchのチュートリアルにもあるseq2seqを使用したいと思います。

pytorch.org

本当はReformerのようなのを使ってみたいんですが、まずはベースラインを作る意味でSeq2Seqをやってみたいと思います。

流れとしては、

実際にやってみる

ここまでで、なんとなく外堀は埋まってきたので、実際にやってみることにします。

データセットの準備

まずはデータセットの準備をしていきます。 今回のデータセットには、含意関係ではないテキストのペアが存在するので、それらを予め処理していきます。

def readfile(filename):
    """ ファイルを読み込んで、含意関係を考慮してDataFrameを作る

    Args:
        filename ([type]): [description]

    Returns:
        [pandas.DataFrame]: [読み込んだcsvのDataFrame]
    """
    df = pd.read_csv(filename, index_col=0, header=None, sep=' ')

    # "×"の除外
    df = df[df[2] != "×"]

    # "上位→下位"のときに列の入れ替え
    df_1 = df[df[1].str.contains('上位→下位')]
    df_1 = df_1.iloc[:, [0,1,3,2]]

    # 対象を結合
    df_2 = df[~df[1].str.contains('上位→下位')]
    df = pd.concat([df_1, df_2]).iloc[:, 2:]

    return df

動かしてみる

肝心のseq2seqです。ここまで来れば、Pytorchのチュートリアルとほとんど変わりませんが、一応確認してみます。

Encoderはチュートリアルで紹介されている通り、Attentionをベースにした方を使用しています。

# -*- coding: utf-8 -*-
import torch
import torch.nn as nn
import torch.nn.functional as F

MAX_LENGTH = 30

class AttnDecoderRNN(nn.Module):
    def __init__(self, hidden_size, output_size, dropout_p=0.1, max_length=MAX_LENGTH):
        super(AttnDecoderRNN, self).__init__()
        self.hidden_size = hidden_size
        self.output_size = output_size
        self.dropout_p = dropout_p
        self.max_length = max_length

        self.embedding = nn.Embedding(self.output_size, self.hidden_size)
        self.attn = nn.Linear(self.hidden_size * 2, self.max_length)
        self.attn_combine = nn.Linear(self.hidden_size * 2, self.hidden_size)
        self.dropout = nn.Dropout(self.dropout_p)
        self.gru = nn.GRU(self.hidden_size, self.hidden_size)
        self.out = nn.Linear(self.hidden_size, self.output_size)
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")


    def forward(self, input, hidden, encoder_outputs):
        embedded = self.embedding(input).view(1, 1, -1)
        embedded = self.dropout(embedded)

        attn_weights = F.softmax(
            self.attn(torch.cat((embedded[0], hidden[0]), 1)), dim=1)
        attn_applied = torch.bmm(attn_weights.unsqueeze(0),
                                 encoder_outputs.unsqueeze(0))

        output = torch.cat((embedded[0], attn_applied[0]), 1)
        output = self.attn_combine(output).unsqueeze(0)

        output = F.relu(output)
        output, hidden = self.gru(output, hidden)

        output = F.log_softmax(self.out(output[0]), dim=1)
        return output, hidden, attn_weights

    def initHidden(self):
        return torch.zeros(1, 1, self.hidden_size, device=self.device)

その他、githubを最後に載せて起きますのでそちらで確認いただければと思います。

生成した結果

学習した結果、こんな感じになりました。

Evaluation...
> つくしが出た。
= スギナが出てくる。
< 春 だ 出 て くる 。 <EOS>

> 韻文では韻律が大事だ。
= 散文では韻律は問題にしない。
< 散文 で は 韻律 は 問題 に し ない 。 <EOS>

> 補聴器をつけた。
= 耳の聞こえが悪かった。
< よく 話 が 聞こえる よう に なっ た 。 <EOS>

> 紙を捨てた。
= 紙はごみになった。
< 紙 は ごみ に なっ た 。 <EOS>

> 土地を掘ったら、土器片が見つかった。
= 古代、その土地に人が住んでいた。
< 古代 、 その た 。 <EOS>

> 主婦は、料理ができるものだ。
= 紀子は料理ができるけれど、主婦ではない。
< 葉子 は 主婦 だ から 料理 が できる 。 <EOS>

> 首相官邸の主が倒れた。
= 総理大臣が倒れた。
< 総理大臣 が 倒れ が 。 <EOS>

> 誘拐されるのは子どもだけではない。
= ペットも誘拐される。
< ペット も 誘拐 さ れる 。 <EOS>

> 彼はベジタリアンだ。
= 彼は肉は食べない。
< 彼 は 肉 は 食べ ない 。 <EOS>

> 最近は、晴雨兼用の傘がある。
= 晴れでも雨でも使える傘がある。
< 晴雨兼用 の 傘 使わ が 使わ れる 。 <EOS>

読み方としては、

  • 1行目:もとの文
  • 2行目:教師データの文(ターゲット文)
  • 3行目:実際に生成された文

となっています。

まあ、可もなく不可もなく、って感じですかね。やりたいことはできたので良しとしましょう。 教師データと同じ文が出力されているのは、若干overfit気味な気はします。 "補聴器をつけた。=> よく 話 が 聞こえる よう に なっ た 。"の変換はなかなかすごいですね。

ただ、まだちょっとアレな文もできてしまっているので、改善は必要かと思われます。

改善案としてはreformerのような新しめのアルゴリズムを使うのが良いかと思います。 その他、データを増やしたりしていけば、より自然な文にならないかなーとか勝手に期待しています。

使ったプログラム

作ったものはこちらになります。

github.com

使用する際には、上で紹介したデータセット(entail_evaluation_set.txt)をdata/に配置して、script/main.pyを実行する形になります。

参考にさせていただいた記事

下記の記事を非常に参考にさせていただきました。

hironsan.hatenablog.com

www.cl.ecei.tohoku.ac.jp

qiita.com

感想

テキスト生成だけでなく、生成系のタスクは本当に久しぶりだったのでいろいろ考えながらやってたら週末終わってました…

最近は実験管理について調べていたかと思えば、今回はガッツリNLPやってたりと、もはや自分がどこに向かっているのかよくわかりませんね。 まあ、文献・事例調査とかだけでなく、たまにはちゃんとコード見ることもあるという、そんな記事でした。

*1:意味の包含関係があるので、本当は同じ意味を生成するには包含関係まで考慮したデータセットを作る必要があります。