太陽がまぶしかったから

C'etait a cause du soleil.

青空文庫の形態素解析データから tf-idf を計量して文芸作品連想クイズを生成する

15Stepで踏破 自然言語処理アプリケーション開発入門 (StepUp!選書)

青空文庫の形態素解析データを作りたい

 例えば『お好み焼きの戦前史 第二版』においても過去文献類をスキャンしたり、ネット上の文献をダウンロードすることで作成した電子テキストデータ群を解析することで料理方法の初出や普及の過程を明らかにしており、電子テキストデータ群の準備こそが重要な研究材料となっている。

 上記においてEPUB電子書籍データの日本語形態素解析を行ったが、青空文庫にある電子書籍の解析も行いたい。

 青空文庫はテキストファイルに独自の注釈記法を追加して書かれているが、XHTML版のファイルも用意されているため、こちらの解析を行っていく。

青空文庫からXHTMLファイルを取得して解析

  • src/aozora.py
(前略)

    def request(self, url):
        res = requests.get(url)
        res.raise_for_status()
        res.encoding = 'shift_jis'
        return res.text.encode('shift_jis')

    def read(self, path):
        with open(path, 'r', encoding='shift_jis') as f:
            return f.read().encode('shift_jis')

    def extract(self, text):
        cleaner = lxml.html.clean.Cleaner(
            page_structure=False,
            remove_tags=['ruby', 'br'],
            kill_tags=['rt', 'rp']
        )
        html = cleaner.clean_html(text).decode('utf-8')
        html = lxml.html.fromstring(html)
        return html.find_class('main_text')[0].text_content()

(後略)

 インターネットまたはローカルファイルからShift JIS 形式で取得した青空文庫の XHMTL ファイルに lxml.html を利用して不要なタグを除去。CSS の main_text class 以下を utf-8 に変換して取得。

 テキスト抽出後は上記の環境の mecab で品詞を取り出して単語出現数を計測。

tf-idf で単語の重要度を計測

 計測した情報を元に文書内における重要単語を抽出したい。重要単語のスコアリングを行うために一般的に使われる手法として tf-idf が挙げられる。

tf-idfは、文書中に含まれる単語の重要度を評価する手法の1つであり、主に情報検索やトピック分析などの分野で用いられている。
tf-idfは、tf(英: Term Frequency、単語の出現頻度)とidf(英: Inverse Document Frequency、逆文書頻度)の二つの指標に基づいて計算される。

 tf は 特定単語の出現数 / 全体の単語数 を計算したもので idf は log( 解析対象文書数 / 特定単語を含む文書数 ) で計算できる。logを使うのは文書数が大きくなった場合の影響を下げるため。tf-idf をこれらを掛け合わせたもの。要するに言えば、「特定の文書内だけに頻出する単語はその文章を特徴づける単語である」と推定できるわけだ。

tf-idf 計量データから文芸作品連想クイズを生成する

 tf は単純に特定の文書の全単語出現数で割れば出せるが、 idf を効率的に出すためにはどのようにすれば良いのか。自分としては以下のプログラムを作成した。

  • src/aozora_tf_idf.py
(前略)

    def docs_freq(self, docs_path):
        freq_list = []
        for path in docs_path:
            freq = self.freq(path)
            freq['doc_id'] = path
            freq_list.append(freq)

        # 単語・文書ID(path)でまとめる
        docs_freq = pandas.concat(freq_list)
        docs_freq = docs_freq.groupby(['term', 'doc_id']).sum()
        docs_freq = docs_freq.reset_index()
        return docs_freq

    def main(self, args):
        self.logger.info(f'{__file__} {__version__} {args}')

        # TF作成
        target = self.freq(args.path)
        target = self.aozora.term_freq(target)

        # 全文書データ作成
        docs_path = glob.glob('../work/*.html')
        docs_freq = self.docs_freq(docs_path)

        # 単語出現文書数
        target['doc_freq'] = target['term'].map(
            lambda t: (docs_freq['term'] == t).sum()
        )

        # IDF
        doc_num = len(docs_path)
        target['idf'] = target['doc_freq'].map(
            lambda df: math.log(doc_num / df)
        )

        # tf-idf
        target['tf-idf'] = target['term_freq'] * target['idf']

        # 出力用整形
        out = target.sort_values('tf-idf', ascending=True).tail(20)
        for c in ['tf-idf', 'term_freq', 'idf']:
            out[c] = out[c].round(3)

(後略)

 まずは glob を使って特定のパスの配下にある html リストを作成。今回は江戸川乱歩の短編作品20篇ほどを解析対象文書群とした。それらの文書群を 単語, 文書PATH でグルーピング。特定文書内に出現する単語で検索できた行数を Document Frequency として逆数計算の分母とする。

 なお、解析対象文書を読み込む度に pandas.Dataframe の append() を利用して行を結合していくと処理がかなり遅くなってしまうので、 list に格納してから pandas.concat() で一気に UNION ALL 相当の動作をさせてからグルーピングしている。

 最終的に計算した tf と idf の積をとってスコアを出す。今回はスコアの上位20位を取得して、カウントダウン形式で出力する。

文芸作品連想クイズの結果

term  info1   info2   freq    term_freq   doc_freq    idf tf-idf
兄さん   名詞  一般  6   0.002   4   1.749   0.003
娘 名詞  固有名詞    19  0.006   13  0.571   0.003
額 名詞  一般  12  0.004   9   0.938   0.004
汽車  名詞  一般  12  0.004   9   0.938   0.004
私達  名詞  固有名詞    9   0.003   6   1.344   0.004
古風  名詞  形容動詞語幹  8   0.003   5   1.526   0.004
双眼鏡   名詞  一般  6   0.002   3   2.037   0.004
馬車鉄道    名詞  固有名詞    4   0.001   1   3.135   0.004
覗き  名詞  一般  14  0.005   7   1.19    0.005
蜃気楼   名詞  一般  7   0.002   2   2.442   0.006
魚津  名詞  固有名詞    6   0.002   1   3.135   0.006
白髪  名詞  一般  7   0.002   1   3.135   0.007
さかさ   名詞  一般  9   0.003   2   2.442   0.007
老人  名詞  一般  37  0.012   11  0.738   0.009
眼鏡  名詞  一般  15  0.005   3   2.037   0.01
様 名詞  非自立   91  0.029   15  0.427   0.013
私 名詞  代名詞   118 0.038   16  0.363   0.014
遠眼鏡   名詞  一般  17  0.005   1   3.135   0.017
押絵  名詞  一般  29  0.009   1   3.135   0.029
兄 名詞  一般  78  0.025   5   1.526   0.038

 今回の連想クイズを読んでいくと、自分の場合は「額」「汽車」「双眼鏡」あたりでピンときて「蜃気楼」で確定したところ。一回でも当該作品を読んだ事があれば『押絵と旅する男』であることが分かるだろう。最大ヒントの「押絵」まであるし。

 tf-idf の弱点として「私」や「兄」などの一度出てくると頻出するが、どの作品に出るわけでもない単語が強くなりすぎる事が挙げられる。「僕」や「姉」しか出てこない作品もあるので idf がそこまで悪くならないのだ。この辺りはヒューステリックな一般語フィルタも必要になってくるかもしれない。

遠眼鏡 押絵覗き見 蜃気楼

 これを利用することで上記のように作品をミニマルに表現する俳句を詠んでみたり、自身が好む作品にのみ頻出しがちな単語を定量的に知ったりができるので、なかなか楽しい。ちなみに『D坂の殺人事件』であっても、「アイスクリーム」「生傷」「蕎麦屋」などの印象的な単語がちゃんと選ばれる。