ユーニックス総合研究所

  • home
  • archives
  • janome-tateyomi

Janomeで縦読み文章生成器を作る【自然言語処理, Python】

Janomeで縦読み生成器を作る

我々が使う言語は「自然言語」と言われています。自然に発生した言語だから自然言語です。
この自然言語をプログラム的に解析する処理を「自然言語処理」といいます。

形態素解析(けいたいそかいせき)とは文章を単語ごとに分割する自然言語処理の工程の1つです。
今回はこの形態素解析を行うJanomeというライブラリを使って、縦読み文章を生成するプログラムをPythonで作ってみたいと思います。

具体的には↓を見ていきます。

  • 縦読み文章とは?
  • プログラムの出力結果
  • Janomeとは?
  • プログラムの設計
  • プログラムのソースコード
  • ソースコードの解説

縦読み文章とは?

「縦読み」の文章とは、文章の中に縦読みできるところがある文章のことを言います。
文章に別の文章を隠して相手に伝えるというネットでもよく見かける手法です。
たとえば「だいふく」という言葉を文章の中に紛れ込ませたい場合は

だって  
いま  
ふくを  
くれるって  

↑のように文章を生成します。
「だいふく」という言葉が文章の中に隠れていて、1つの文章の中に2つの文章が紛れ込んでいる状態です。
今回はこの縦読み文章をプログラムで作ろうという趣旨です。

プログラムの出力結果

今回作成するプログラムを実行すると↓のような結果になります。

縦読み文章作成器  

文章を入力してください > 大好き  
だから下へ持って来て吐き出す時は大方死んでいる  
今はそうは行かないやね  
すると客人は沓脱から敷台へ飛び上がって障子を開け放ってつかつか上り込んで来た  
今日はいやに軟化しているぜ」  

文章を入力してください > 助けて  
探偵と云うものには高等な教育を受けたものがないから事実を挙げるためには何でもする  
鈴木か、――あれがくるのかい、へえー、あれは理窟はわからんが世間的には利口な男だ  
警察の厄介にならない代りに、数でこなした者と見えて沢山いる  
天秤棒は避けざるべからざるが故に、忍ばざるべからず  

文章を入力してください >  

プログラムを実行すると↑のように最初に「縦読み文章生成器」と表示されます。
その後に「文章を入力してください >」と文章の入力を促されます。
ここで縦読みの文章を入力します。たとえば「大好き」という縦読みの文章を生成したいとします。
その場合は「大好き」と入力します。すると出力は

だから下へ持って来て吐き出す時は大方死んでいる  
今はそうは行かないやね  
すると客人は沓脱から敷台へ飛び上がって障子を開け放ってつかつか上り込んで来た  
今日はいやに軟化しているぜ」  

↑のようになります。行の先頭の言葉に注目してください。
「だ」「今」「す」「今」を縦に読むと「ダイスキ」となっているのがわかります。
「助けて」の場合は「探」「鈴」「警」「天」の縦読みで「タスケテ」となっています。
このようにこのプログラムは入力から縦読みの文章を自動生成します。

Janomeとは?

Janome(ジャノメ)とは、Pythonの形態素解析(けいたいそかいせき)を行うライブラリです。

形態素解析とは、日本語の文章を単語のリストに変換する解析のことを言います。
たとえば「大福が好き」という文章を形態素解析にかけると、

大福 / が / 好き  

↑のように文章を単語ごとに分割することができます。
このように文章を単語ごとに分割し、単語にその単語の情報を埋め込みます。
情報とは「原形」とか「品詞」とか「表層形」といった、その単語が持つ情報です。
そうしておくことで、形態素解析以降の解析、たとえば構文解析や意味解析などでこれらの単語を使って解析を行うことができるようになるという寸法です。

Janomeはこの形態素解析を行えます。
pipでインストールが可能な形態素解析ライブラリで、最近人気が出てきてます。
インストールはpipが使える環境であれば↓だけで済みます。

$ pip install janome  

Janomeの他にはMeCab(メカブ)という形態素解析ライブラリも有名です。
JanomeはこのMeCabの辞書を使って解析を行っています。
そのため速度以外の性能はMeCabと同等だと言われています。
速度的に問題がある場合はMeCabの利用を検討してみてください。

プログラムの設計

今回作成するプログラムは、大きく分けて↓のような処理に分かれています。

  • 学習(データの読み込み, 形態素解析)
  • 文章生成(入力を元に形態素解析, 行のマッチ)

このうち「学習」とは、文章生成用のデータを読み込みそれを解析することを指します。
強化学習などの学習ではなく、単純に形態素解析です。

🦝 < 「学習」とかまぎらわしいな

🐭 < 本人は気に入ってるみたいだから

学習に使うデータは今回は青空文庫で公開されている夏目漱石の「吾輩は猫である」の文章を使います。

この文章を形態素解析で解析し、そのデータを持っておくことで辞書として利用します。
これはTateyomiGeneratorクラスのload()メソッドで行います。

「文章生成」ではユーザーからの入力を受け取って、そのデータを使って文章を生成します。
文章の生成でも形態素解析を行います。
入力の「読み方」を形態素解析で取得し、この読み方にマッチする行を学習データ内から探します。
そしてマッチした行を集めて、それらを1つの文章に変形し、出力として返します。
これはTateyomiGeneratorクラスのgen()メソッドで行います。

プログラムのソースコード

今回作成したプログラムのソースコードは↓になります。
このプログラムを実行するには↓のコードをsample.pyなどで保存し、python sample.pyを実行します。
学習用の外部データとしてwagahai_wa_neko_de_aru.txtが必要で、これは夏目漱石の「吾輩は猫である」のテキストです。

"""  
縦読み文章生成器  

License: MIT  
Created at: 2021/01/15  
"""  
from janome.tokenizer import Tokenizer, Token  
import random  


class TateyomiGenerator:  
    """  
    縦読みの文章を生成するジェネレーター  
    """  
    def __init__(self):  
        self.tokenizer: Tokenizer = Tokenizer()  

    def load(self, fname: str):  
        """  
        fnameのファイルを読み込みモデルを初期化する  
        """  
        with open(fname, 'r', encoding='utf-8') as fin:  
            src = fin.read()  
            self.init_model(src)  

    def init_model(self, src: str):  
        """  
        srcを元にモデル(line_toks)を初期化する  
        モデルと言ってもただのリスト  
        """  
        self.line_toks: list = []  # モデル(辞書)  

        src = src.replace('。', '\n')  
        for line in src.split('\n'):  
            toks = list(self.tokenizer.tokenize(line))  
            if not len(toks):  
                continue  
            self.line_toks.append(toks)  

    def merge_reading(self, toks: list) -> str:  
        """  
        トークン列のreadingをまとめて文字列として返す  
        """  
        reading: str = ''  
        for tok in toks:  
            reading += tok.reading  
        return reading  

    def merge_surface(self, toks: list) -> str:  
        """  
        トークン列のsurfaceをまとめて文字列として返す  
        """  
        surface: str = ''  
        for tok in toks:  
            surface += tok.surface  
        return surface  

    def random_choice_line(self, c: str) -> str:  
        """  
        文章の先頭がcとマッチする行をランダムにlines_toksから取得する  
        """  
        lines: list = []  
        for toks in self.line_toks:  
            tok: Token = toks[0]  
            if tok.reading[0] == c:  
                surface: str = self.merge_surface(toks)  
                lines.append(surface)  

        if not len(lines):  
            return None  
        return random.choice(lines)  

    def collect_lines(self, reading: str) -> list:  
        """  
        readingにマッチする行をランダムに集めてリストとして返す  
        """  
        lines: list = []  
        for c in reading:  
            line: str = self.random_choice_line(c)  
            if line:  
                lines.append(line)  
        return lines  

    def gen(self, sentence: str) -> str:  
        """  
        sentenceを元に縦読み文章を生成する  
        """  
        toks: list = list(self.tokenizer.tokenize(sentence))  
        reading: str = self.merge_reading(toks)  
        lines: list = self.collect_lines(reading)  
        return '\n'.join(lines)  


def main():  
    print('縦読み文章作成器\n')  
    g = TateyomiGenerator()  

    # 夏目漱石の「吾輩は猫である」のテキストを読み込んでモデルを初期化する  
    # 量が多いので時間がかかるが、中間表現にしてファイルに保存しておけば速くなると思う  
    g.load('wagahai_wa_neko_de_aru.txt')  

    while True:  
        # 縦読みの入力を受ける  
        try:  
            sentence = input('文章を入力してください > ')  
        except (KeyboardInterrupt, EOFError):  
            break  

        # 入力された縦読みを元に文章を生成して表示する  
        result = g.gen(sentence)  
        print(result)  
        print()  


main()  

ソースコードの解説

簡単ですがプログラムの解説になります。

必要モジュールをインポート

必要モジュールをインポートしておきます。
今回はJanomeのモジュールであるtokenizerからTokenizerクラスとTokenクラスをインポートしておきます。
それから乱数を使うのでrandomモジュールもです。

from janome.tokenizer import Tokenizer, Token  
import random  

TokenizerクラスはJanomeの形態素解析を行うクラスです。
このクラスのメソッドtokenize()に文字列を渡すと形態素解析を行ってくれます。
その結果はジェネレーターとして返ってきて、list()tuple()などで変換することができます。
変換するとたとえばリストのトークン列になります。トークン列のトークンはTokenのことです。

Tokenクラスは形態素解析で生成するトークンの本体です。
これの属性に読み方や品詞や原形などが保存されます。

main()関数の作成

プログラムはmain()関数から始まります。

内容的には最初にTateyomiGeneratorクラスをオブジェクト(g)にしています。
そしてg.load()で学習を開始します。
load()の引数にファイル名を渡すと、load()はそのファイルから学習を開始します。
今回はwagahai_wa_neko_de_aru.txtというテキストファイルをあらかじめ作ってあります。
これは繰り返しになりますが夏目漱石の「吾輩は猫である」の文章です。
学習には時間がかかります。

学習が終わったら無限ループに入ってユーザーからinput()で入力を受けます。
input()Ctrl+Cが押された場合はKeyboardInterruptを、EOFが入力された場合はEOFErrorを送出してきます。
これらの例外をキャッチしたら無限ループからエスケープしておきます。

入力を得たら(sentence)、その入力データをg.gen()に渡します。
gen()メソッドは引数の文字列を元に縦読み文章を生成します。
生成された文章をprint()で出力してループ内の仕事はひと段落です。

def main():  
    print('縦読み文章作成器\n')  
    g = TateyomiGenerator()  

    # 夏目漱石の「吾輩は猫である」のテキストを読み込んでモデルを初期化する  
    # 量が多いので時間がかかるが、中間表現にしてファイルに保存しておけば速くなると思う  
    g.load('wagahai_wa_neko_de_aru.txt')  

    while True:  
        # 縦読みの入力を受ける  
        try:  
            sentence = input('文章を入力してください > ')  
        except (KeyboardInterrupt, EOFError):  
            break  

        # 入力された縦読みを元に文章を生成して表示する  
        result = g.gen(sentence)  
        print(result)  
        print()  


main()  

TateyomiGeneratorクラス

TateyomiGeneratorクラスは縦読み文章を生成するクラスです。
コンストラクタではself.tokenizerにJanomeのトーカナイザーを入れてあります。

class TateyomiGenerator:  
    """  
    縦読みの文章を生成するジェネレーター  
    """  
    def __init__(self):  
        self.tokenizer: Tokenizer = Tokenizer()  

load()で学習を行う

load()メソッドは引数fnameのファイルを開き、その内容のデータで学習を行います。
内容的にはファイルの中身を取得し、それをinit_model()メソッドに渡しているだけです。
エンコーディングはutf-8固定になっています。cp932のファイルを読み込みたい場合はここの値をcp932などにしてください。

    def load(self, fname: str):  
        """  
        fnameのファイルを読み込みモデルを初期化する  
        """  
        with open(fname, 'r', encoding='utf-8') as fin:  
            src = fin.read()  
            self.init_model(src)  

init_model()で学習データを初期化

init_model()は引数srcを元にモデルを初期化するメソッドです。
内容的にはsrcを形態素解析して、その結果をself.line_toksに保存します。

srcの前処理として、「。」を改行に変換しています。こうすることで「。」と改行をsplit()で分割できるようにしています。
src.split('\n')で改行ごとに文章を分割し、それをfor文で回します。
そしてその行をself.tokenizer.tokenize()で形態素解析します。
行ごとに解析を行うと、大きな処理が分割されてエラーが出にくくなります(形態素解析ライブラリ一般の話で、大きなデータを処理させようとするとよくエラーが出ることがあります)。

解析した結果はジェネレーターで返ってくるので、これをlist()でリストに変換します。
リストが空じゃなかったらself.line_toksに追加します。

self.line_toksはリストですが、中身の要素もリストです。で、その中はトークン列です。
なのでリストの入れ子になっていることになります。

    def init_model(self, src: str):  
        """  
        srcを元にモデル(line_toks)を初期化する  
        モデルと言ってもただのリスト  
        """  
        self.line_toks: list = []  # モデル(辞書)  

        src = src.replace('。', '\n')  
        for line in src.split('\n'):  
            toks = list(self.tokenizer.tokenize(line))  
            if not len(toks):  
                continue  
            self.line_toks.append(toks)  

gen()で縦読み文章を生成する

gen()メソッドは引数のsentenceを元にして縦読み文章を生成するメソッドです。
sentenceself.tokenizer.tokenize()で形態素解析してトークン列にします。
そしてそのトークン列のreading属性をself.merge_reading()でまとめます。
readingをまとめて1つの文字列にしたら、それをもとにself.collect_lines()を呼び出して縦読みにマッチする行を集めます。
行を集めたらそれらの行を改行でつなげて1つの文字列にして返します。

    def gen(self, sentence: str) -> str:  
        """  
        sentenceを元に縦読み文章を生成する  
        """  
        toks: list = list(self.tokenizer.tokenize(sentence))  
        reading: str = self.merge_reading(toks)  
        lines: list = self.collect_lines(reading)  
        return '\n'.join(lines)  

merge_reading()でreadingをまとめる

merge_reading()メソッドは引数toksのトークン列内のreading属性をまとめて1つの文字列にするメソッドです。
reading属性は単語の「読み方」が保存されている属性です。これはカタカナ表記です。

    def merge_reading(self, toks: list) -> str:  
        """  
        トークン列のreadingをまとめて文字列として返す  
        """  
        reading: str = ''  
        for tok in toks:  
            reading += tok.reading  
        return reading  

collect_lines()でマッチする行を集める

collect_lines()メソッドは引数readingにマッチする行を学習データから集めるメソッドです。
readingはそのまま縦読みの文章になるわけですが、これを1文字ずつfor文で取り出します。
そしてその文字にマッチする行をrandom_choice_line()でランダムに選択します。
行が見つかったらそれをlinesに保存します。
たとえばreadingが「オケ」だったら「オ」と「ケ」についてそれぞれマッチする行をランダムに探すという感じです。
for文が終わったらlinesを返却して終わりです。

    def collect_lines(self, reading: str) -> list:  
        """  
        readingにマッチする行をランダムに集めてリストとして返す  
        """  
        lines: list = []  
        for c in reading:  
            line: str = self.random_choice_line(c)  
            if line:  
                lines.append(line)  
        return lines  

random_choice_line()でランダムに行を選ぶ

random_choice_line()は学習データから引数cにマッチする行をランダムに選択するメソッドです。
内容的には最初にself.line_toksfor文で回します。
そしてトークン列の先頭のトークンを取り出し、それのreading属性の1文字目がcにマッチするか調べます。
マッチしたらその行は縦読みの文字にマッチしたということになります。
マッチしたトークン列をself.merge_surface()に渡し、表層形を1つの文字列にまとめます。
その文字列をlinesに保存します。

linesを生成したらrandom.choice()linesからランダムに行を選択して返します。

    def random_choice_line(self, c: str) -> str:  
        """  
        文章の先頭がcとマッチする行をランダムにlines_toksから取得する  
        """  
        lines: list = []  
        for toks in self.line_toks:  
            tok: Token = toks[0]  
            if tok.reading[0] == c:  
                surface: str = self.merge_surface(toks)  
                lines.append(surface)  

        if not len(lines):  
            return None  
        return random.choice(lines)  

merge_surface()でsurfaceをまとめる

merge_surface()は引数toksのトークン列内のsurface属性をまとめるメソッドです。
surfaceとは表層形のことで、これは文章のそのままの表記の文字列のことを指します。
このsurfaceを1つの文字列にまとめたらそれをreturnします。

    def merge_surface(self, toks: list) -> str:  
        """  
        トークン列のsurfaceをまとめて文字列として返す  
        """  
        surface: str = ''  
        for tok in toks:  
            surface += tok.surface  
        return surface  

おわりに

今回はJanomeを使って縦読み文章を生成してみました。
このプログラムを使えばこっそり秘密のメッセージを相手に送ることができます。
では最後に私からのメッセージです↓。

第三信はすこぶる風変りの光彩を放っている  
いつでも腹の中で出来てるのさ  
夫婦の愛はその一つを代表するものだから、人間は是非結婚をして、この幸福を完うしなければ天意に背く訳だと思うんだ  
来ると自分を恋っている女が有りそうな、無さそうな、世の中が面白そうな、つまらなそうな、凄いような艶っぽいような文句ばかり並べては帰る  
多病にして残喘を保つ方がよほど結構だ  
別にこれという分別も出ない  
倒れかかるたびに後足で調子をとらなくてはならぬから、一つ所にいる訳にも行かんので、台所中あちら、こちらと飛んで廻る  
今までは車屋のかみさんでも捕えて、鼻づらを松の木へこすりつけてやろうくらいにまで怒っていた主人が、突然この反古紙を読んで見たくなるのは不思議のようであるが、こう云う陽性の癇癪持ちには珍らしくない事だ  

🦝 < なんて意味のないメッセージだ

🐭 < お腹減ってるのね