ラノベ1万冊読んだAIで、宮沢賢治 銀河鉄道の夜を添削してみた

この記事はDeNA Advent Calendar 2016 5日目の記事です。

入社して50日くらいのぺーぺー @haminiku(イケダ) です。 この記事では自然言語処理を利用した文章を添削する簡単なAIの実装方法を紹介します。実装を通じ自然言語処理の基礎である形態素解析の利用方法について理解して頂ければと思い執筆しました。要素技術はPython, NLTK, MeCabです。

はじめに

自然言語処理ライブラリを利用し、文章を読んだよう振る舞うAIを実装します。開発したAIを利用して、宮沢賢治 先生の銀河鉄道の夜 をラノベに近づけるために語彙力を下げる≒読み易くするという観点で添削を実施しています。さらにまったくもって恐れ多いのですが類似ラノベをDBから検索 / 比較し、ラノベ風の作品にするためにはどうすればよいのかプロット(物語)の方向性についてアドバイスを行います。

概要

nlp_python_004.png

step1. ライトノベルと一般文芸を仕分けする

ライトノベルとは一体なんでしょうか?wikipediaによると「中学生〜高校生という主なターゲットにおいて読みやすく書かれた娯楽小説」だそうです。文章の読み易さを数値化すればラノベと一般文芸を仕分けできると考えました。今回は文章検索や要約に利用するTF-IDF法に手を加え、文章の語彙力を数値化するアルゴリズム定義し利用してみました。

文章の語彙力 = (文章中の形容詞, 品詞 ユニーク数の和) / (文章中の形容詞, 品詞, 名詞 出現回数の和)

仕分けテスト用データについて

一般文芸は、著作権が切れた作品を扱う青空文庫のアクセスランキングTop.10 を参照しました。吾輩は猫である,人間失格,銀河鉄道の夜,羅生門, 鏡地獄 誰もがなじみある作品が並んでいます。

ライトノベルでは作品名は伏せますが、小説家になろう のランキングを参照しています。転生する作品や転生する作品や転移する作品やスライムが主人公の作品が中心です。

実行結果

縦軸は先ほど定義した語彙力です。ドグラ・マグラがラノベに混ざっているのは作者の巧みな手法によって繰り返しが多様されているためだと思います。平易な話し言葉中心の文章は特徴があるみたいです。 nlp_python_001.png

実装

Pythonでプログラムを組んでいきます。自然言語処理ライブラリはnltk とjanome(MeCab)を利用しています。自然言語処理のプログラムは品詞の仕分け実装が必要です。語彙力が低下するようなつらみあるコードになりがちですごくやばいです。


# -*- coding: utf-8 -*-
from __future__ import absolute_import, unicode_literals
from collections import defaultdict
from math import sqrt
import re
from janome.tokenizer import Tokenizer
import nltk
import codecs


class TFIDF(object):
    _t = None

    @classmethod
    def gen(cls, text, enable_one_char=False):
        """
        Get TF-IDF
        :param text: str
        :rtype :list[list[str, float]]
        """
        _text = cls.filter(text)
        return cls.analysis(_text, enable_one_char=enable_one_char)

    @classmethod
    def similarity(cls, tfidf1, tfidf2):
        """
        Get TF-IDF and Cosine Similarity
        cosθ = A・B/|A||B|
        :param tfidf1: list[list[str, float]]
        :param tfidf2: list[list[str, float]]
        :rtype : float
        """
        tfidf2_dict = {key: value for key, value in tfidf2}

        ab = 0  # A・B
        for key, value in tfidf1:
            value2 = tfidf2_dict.get(key)
            if value2:
                ab += float(value * value2)

        # |A| and |B|
        a = sqrt(sum([v ** 2 for k, v in tfidf1]))
        b = sqrt(sum([v ** 2 for k, v in tfidf2]))

        return float(ab / (a * b))

    @classmethod
    def analysis(cls, text, enable_one_char):
        """
        Calc TF-IDF
        textを形態素解析して名詞の数を返却(Morphological Analysis)
        :param text: str
        :rtype : dict{str: int}
        """
        result = defaultdict(int)
        result2 = {}
        count = 0
        t = cls._get_tokenizer()

        # 形態素解析
        goi_counter = 0
        goi_dict = defaultdict(int)
        for token in t.tokenize(text):
            if cls.goi_filter(token):
                goi_counter += 1
                goi_dict[token.surface] += 1

            if '名詞' not in token.part_of_speech:
                continue
            count += 1
            goi_counter += 1

            if '非自立' in token.part_of_speech:
                continue

            if '接尾' in token.part_of_speech:
                continue

            if '数' in token.part_of_speech:
                continue

            if not enable_one_char:
                if len(token.surface) == 1:
                    continue

            result[token.surface] += 1
            result2[token.surface] = token

        print "語彙力:{}".format(float(len(goi_dict.keys())) / float(goi_counter))
        # TF-IDF計算
        result3 = []
        for key in result:
            result3.append([key, result[key]])

        result3.sort(key=lambda x: x[1], reverse=True)
        result4 = []
        for r in result3[:100]:
            # print r[0], float(float(r[1])/float(count)), result2[r[0]]
            result4.append([r[0], float(float(r[1])/float(count))])
        return result4

    @classmethod
    def goi_filter(cls, token):
        """
        形容詞 副詞
        :param token:
        :return:
        """
        for ng in ['名詞', '非自立', '助詞類接続', '助詞']:
            if ng in token.part_of_speech:
                return False

        if '形容詞' in token.part_of_speech:
            return True

        if '副詞' in token.part_of_speech:
            return True
        return False


    @classmethod
    def filter(cls, text):
        """
        textをフィルターしてノイズを排除する
        :param text: str
        :rtype : str
        """
        # アルファベットと半角英数と改行とタブを排除
        text = re.sub(r'[a-zA-Z0-9¥"¥.¥,¥@]+', b'', text)
        text = re.sub(r'[!""#$%&()\*\+\-\.,\/:;<=>?@\[\\\]^_`{|}~]', b'', text)
        text = re.sub(r'[\n|\r|\t|年|月|日]', b'', text)

        # 日本語以外の文字を排除(韓国語とか中国語とかヘブライ語とか)
        jp_chartype_tokenizer = nltk.RegexpTokenizer(r'([ぁ-んー]+|[ァ-ンー]+|[\u4e00-\u9FFF]+|[ぁ-んァ-ンー\u4e00-\u9FFF]+)')
        text = ''.join(jp_chartype_tokenizer.tokenize(text))
        return text

    @classmethod
    def _get_tokenizer(cls):
        if TFIDF._t is not None:
            return TFIDF._t
        TFIDF._t = Tokenizer()
        return TFIDF._t


def main():
    novels_path = ["./aozora/novel/{}".format(x) for x in range(0, 10)]

    for i in range(0, len(novel_keys)):
        with codecs.open(novels_path[i], mode='r', encoding='utf8') as f:
            body = b''
            for x in f.readlines():
                body += x
            tfidf = TFIDF.gen(body)

if __name__ == '__main__':
    main()



# 実行結果
語彙力:0.026060296372
語彙力:0.0657764589515
語彙力:0.0502272183688
語彙力:0.0148734910569
語彙力:0.0501603298213
語彙力:0.0775347912525
...

point.

文章の語彙力を定量的に数値で評価するツールが完成しました。

小説データの取得について

小説家になろうのデータ取得には公式APIのなろうデベロッパーを利用しています。全然関係ないですがyamlの方がjsonよりデータ量少なくなるのでyaml推奨と書いてあって目から鱗でした。使ってみたらコメントや追記ができて、さらに読みやすくてyaml便利!


# API 問い合わせ例
curl http://api.syosetu.com/novelapi/api/?length=1000-&allcount=10000&title=1&lim=500&out=yaml&of=t-n-gp-ga&order=hyoka&st=501

step2. 銀河鉄道の夜 の類似ラノベを検索する

1万冊のラノベを読んで特徴解析を行うプログラムを実装します。検索や文章のレコメンド実装であるあるのTF-IDF法で文章の特徴をベクトル化して、Cosine Similarityによって類似度を計算します。

意味不明なので具体的な実装を書くと、銀河鉄道の夜に出現する頻出名詞をリストアップして全ての名詞の出現回数に対する割合を計算してグラフにマークします。ラノベ1万冊でも同様の処理を行いグラフにマークします。グラフ上でマークが近い作品は類似しているというアルゴリズムです。FIVE MOST POPULAR SIMILARITY MEASURES IMPLEMENTATION IN PYTHON が詳しくて実装の際に参考になりました。


# -*- coding: utf-8 -*-
from math import sqrt


def similarity(tfidf1, tfidf2):
    """
    Get Cosine Similarity
    cosθ = A・B/|A||B|
    :param tfidf1: list[list[str, float]]
    :param tfidf2: list[list[str, float]]
    :rtype : float
    """
    tfidf2_dict = {key: value for key, value in tfidf2}

    ab = 0  # A・B
    for key, value in tfidf1:
        value2 = tfidf2_dict.get(key)
        if value2:
            ab += float(value * value2)

    # |A| and |B|
    a = sqrt(sum([v ** 2 for k, v in tfidf1]))
    b = sqrt(sum([v ** 2 for k, v in tfidf2]))

    return float(ab / (a * b))

# 類似度計算
for l_novel in l_novels:
    point = similarity(ginga_tfidf, l_novel.tfidf)
    print "銀河鉄道の夜と{} 文章類似度:{}".format(l_novel.name, point)


# 実行結果
...
銀河鉄道の夜とN0694BC 文章類似度: 0.00782610444265
銀河鉄道の夜とN3324T 文章類似度: 0.0155622637016
銀河鉄道の夜とN1292CJ 文章類似度: 0.0
銀河鉄道の夜とN0316BI 文章類似度: 0.0057762320401
...

# 銀河鉄道の夜のTFIDF値
tfidf_pair = TFIDF.gen(body_night_of_milky)
for k, v in tfidf_pair:
  print k, v


"""
ジョバンニ 0.0415627597672
カムパネルラ 0.0213355500139
ぼく 0.0127459129953
みんな 0.0102521474093
それ 0.00886672208368
どこ 0.00886672208368
ほんとう 0.00775838182322
いま 0.00692712662787
汽車 0.00692712662787
ここ 0.00498753117207
女の子 0.00471044610695
銀河 0.00471044610695
お父さん 0.00471044610695
さっき 0.00443336104184
たくさん 0.00443336104184
こっち 0.00415627597672
うし 0.00387919091161
......
"""

point.

  • TF-IDFとCosine Similarityで、文章と文章の類似度を数値によって比較できるようになった
  • 文章毎の特徴を表す頻出名詞が取得できた

この方法は遅すぎないか?

上記実装では文章1万件のフルスキャンなので手元のPCだと3時間以上実行にかかります。これでは100万件の文章を相手にすることができません。処理を高速化する一般的な方法を紹介します。

  • 文章毎のTF-IDF計算は、文章単位で並列計算できる
  • 文章類似度計算では、共起を利用して計算を簡易化
  • 文章類似度計算では、キーワード毎の転置インデックス(逆引き索引)を作成し計算を簡易化

拙作で恐縮ですが共起転置インデックスの実装を紹介します

最も類似したラノベはどれか?

銀河鉄道の夜 と最も類似したラノベは、男子高校生が朝起きたら銀髪オッドアイの美少女に変身している作品でした。具体的な作品名については是非コードを書いて検証してみてください。(恋愛物の作品が類似度上位にきました。)

step3. 類似ラノベから頻出形容詞と副詞を抽出する

step2 では1万冊のラノベから銀河鉄道の夜と類似するラノベを抽出しました。これら作品によく登場する形容詞と副詞を抽出して、ラノベ頻出形容詞, 副詞と定義し添削時に利用します。


# 形容詞を抽出する
class TFIDF(object):
    @classmethod
    def analysis(cls, text, enable_one_char):
    ......省略
        # 形態素解析
        for token in t.tokenize(text):
            for ng in ['名詞', '非自立', '助詞類接続', '助詞']:
                if ng in token.part_of_speech:
                    continue

            if '形容詞' not in token.part_of_speech:
                continue

            if '非自立' in token.part_of_speech:
                continue

            if '接尾' in token.part_of_speech:
                continue

            if '数' in token.part_of_speech:
                continue
            count += 1

    ......省略


def main():
    novel_keys = [
        '******.txt',
        '******.txt',
        '******.txt',
        '******.txt',
        '******.txt',
    ]

    result_tfidf = defaultdict(int)
    for key in novel_keys:
        novel_path = './narou_data/novel/' + key
        novel_body = file_read(novel_path)
        for key, point in TFIDF.gen(novel_body):
            result_tfidf[key] += float(point)

    # 類似ラノベの形容詞を出力
    sorted_result = sorted(result_tfidf.items(), key=lambda x: x[1], reverse=True)
    for key, point in sorted_result:
        print key, point

step4. 宮沢賢治 先生の銀河鉄道の夜を添削する

まったくもって恐れ多いことですが、宮沢賢治 先生の作品を添削します。小説の添削には多種多様な観点がありますが今回はラノベに近づけるために語彙力を下げ読みやすい平易な文章にするという観点で添削を行います。係り受けの正しさとか適切な句読点とか主語がないまたは不明確とか。そういった観点は扱いません。

添削対象の文章と実装方法について

作品を分割し語彙力が高い箇所をピックアップして添削しました。

  • step.1 で実装したツールで文章の語彙力を計算した
  • step.3 で実装したツールで頻出形容詞と副詞を一覧で取得して一覧表示した

添削結果画面

みんな大好きbootstrap3でさっくり作ってみました。どこを修正すべきか可視化しています。

nlp_python_002.png

添削内容を文章に反映してみた

添削後の文章修正は残念ながら手動で行っています。どのくらい語彙力が低下したか数値で判るため励みになると思います。

nlp_python_003.png

類似作品の傾向要素をレコメンド

step.2 で取得した類似ラノベの特徴(TF-IDF値)をレコメンドします。またまた銀河鉄道に乗車してしまったジョバンニ。今度は隣に弟が!弟を救うため特別なチケットを求め大冒険する銀河鉄道の夜 第二幕… といった2作目を書く際には、つぎの要素を取り入れるとよりラノベ風の作品になるはずです。

類似ライトノベルの特徴: ボク, ザック, 精霊, 姉ちゃん, 貴族, 言葉, 学園, 兄ちゃん, 記憶, 魔法

さいごに

語彙力を高める方法について検索すると、ライターや作家志望向けの記事が散見されます。その記事を要約すると「ジャンルを問わず乱読せよ」です。今回は語彙力を下げるために自然言語処理を活用しました。学習対象を逆転させ一般文芸作品について学習したAIを用意すれば人が質の高い文章を生産する手助けになるのではないでしょうか。

また文章類似度の数値算出はコピペ検知手法の一つです。高速に大量の記事を定量評価する点において自然言語処理は強力なツールです。インターネットサービスを開発するプログラマーは身につけておいて損はないと思います。

最後まで読んでくださってありがとうございます。ツッコミ歓迎なので右下のSNSシェアボタンからお願いします。

明日、6日目は @ daneko0123 さんです。お楽しみに!

続きを読む
ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る