マルコフ連鎖っぽいので発音から可愛い言葉を作る

## はじめに
なんとなく、音の感じが可愛いなと思う単語があると思います。
そのような意味のない単語を新しく生成(創造)することはできないかと模索した話です。

## 可愛い単語
可愛い言葉(=可愛い単語)を定義するのは難しいですが、ここでは発音した時の音の感じが可愛い時、可愛い単語だと定義します。

新しい言葉をどのようにして生成しようかと考えた時に、文章を生成することができるマルコフ連鎖から着想を得て、発音ベースでマルコフ連鎖をしてみたらどうだろうかと思いました。
そこで、ここでは発音をマルコフ連鎖して可愛い単語を生成してみたいと思います。

### マルコフ連鎖による文章の生成とは
マルコフ連鎖とは、現在の状態から確率を用いて次の状態を決定する連鎖のことです。
マルコフ連鎖では、文章の集合からある単語に対して各単語が来る確率をあらかじめ学習し、その確率を元に現在の単語から次の単語をもとめることによって文を生成します。
マルコフ連鎖自体に関する詳しい説明は[マルコフ連鎖（Wikipedia）](https://ja.wikipedia.org/wiki/%E3%83%9E%E3%83%AB%E3%82%B3%E3%83%95%E9%80%A3%E9%8E%96)をご参照ください。

例えば、「私は」の次に50%の確率で「りんご」、50%の確率で「いちご」がつながると学習したとします。
ここで、「私は」を与えると、上記の確率に従いランダムで次の単語が決まります。つまり、50%の確率で「私はりんご」という言葉になり、50%の確率で「私はいちご」という言葉になるわけです。
そして、さらに「りんご」という言葉の次に70%の確率で「を食べる」、30%の確率で「を買う」が来ると学習していたとします。
そうすると、「私は」を最初に与えたとき、35%の確率で「私はりんごを食べる」に、15%の確率で「私はりんごを買う」という文章が生成されます。
(実際にマルコフ連鎖を行う際は分かち書きなどを行って生成しますが、今回は説明のためにわかりやすい部分で文を区切っています。)

### 発音をベースにマルコフ連鎖
発音をベースにマルコフ連鎖を行うというのは自分が勝手に考えた手法で、思いつきで条件を設定しているのでもう少し詰めればいい感じで生成できるようになるかもしれません。

まず、単純に発音をひらがなにします。そうして、ひらがな一文字ずつから次の文字を学習します。
例えば、「新幹線」という単語は、「しんかんせん」とかけ、下のような条件で遷移します。
```mermaid
graph TD
    し--100%-->ん
    ん--33%-->か
    か--100%-->ん
    ん--33%-->せ
    せ--100%-->ん
    ん--33%-->END
```

しかし、これでは単語数が多くなっていくと、どの音からの他の音への遷移の確率も大体平均化されていくことが予測できます。
そもそも、遷移する可能性があるのが、50*50で2500程度しかないので、マルコフ連鎖の効果というより確率に影響されやすくなると思います。

そこで、複数音からの遷移も含めて見ることにしました。今回は２音を元にした遷移を学習させます。
つまり、新幹線の例で行くと、「ん」から「か」に遷移する確率は33%ですが、同時に「しん」からは100%で「か」に遷移するという学習をさせます。
すると２音を元にした繊維は以下のようになります。
```mermaid
graph TD
    しん--100%-->か
    んか--100%-->ん
    かん--100%-->せ
    んせ--100%-->ん
    せん--100%-->END
```
また、常に２音を元にしていると学習した単語がそのまま出てくる可能性が高いため、一音で遷移する場合と２音で遷移させる場合を確率で振っています。
その確率は私が試行して大体既存の単語が出てこないぐらいに決めました。

### 実際にやってみる
実際のコードは以下になりますが、前提として可愛い単語の一覧が必要となります。
リポジトリには可愛い単語一覧は含みませんが、可愛い単語一覧のcsvから発音を生成し、それを一つのcsvにまとめる機能が含まれています。

https://github.com/shiro4710/cute-words

コード自体はリポジトリを見ればわかるのですが、記事公開時点でほとんどコメントを書いていないので、少し解説をしていきます。

#### データモデル
```python
from __future__ import annotations
import dataclasses
from dataclasses import field


@dataclasses.dataclass
class PronModel:
    pron: str
    total: int = 0
    chain_list: dict[str, int] = field(default_factory=dict)

    def add_pron(self, pr: str):
        self.total += 1

        if pr not in self.chain_list.keys():
            self.chain_list[pr] = 1
        else:
            self.chain_list[pr] += 1

    def add_model(self, model: PronModel):
        self.total += model.total

        for pr in model.chain_list.keys():
            if pr not in self.chain_list.keys():
                self.chain_list[pr] = model.chain_list[pr]
            else:
                self.chain_list[pr] += model.chain_list[pr]


@dataclasses.dataclass
class Model:
    pron_map: dict[str, PronModel] = field(default_factory=dict)
    total: int = 0

    def add_pron(self, from_pron: str, to_pron: str):
        self.total += 1
        if from_pron not in self.pron_map.keys():
            self.pron_map[from_pron] = PronModel(pron=from_pron)

        self.pron_map[from_pron].add_pron(to_pron)

```
Python3.7で追加されたData Classesを使用しています。Modelの中に、Keyを元の発音にValueを発音の遷移先一覧とその確率をもったPronModelにした`pron_map`という辞書型を持っています。
PronModelでは、keyを遷移先の発音valueをその確率を持っています。

しかし、愚直にPronModelで確立の値を持つと、新しい単語を学習するたびにその発音のPronModelの全てのvalueに対して確立を求める処理をし直す必要があるため動作が重くなってしまいます。そのため、PronModelでは遷移の総数と各音に遷移する回数を持ち回数/総数による確立の算出を単語生成時まで遅らせることにより軽量化しています。

#### マルコフ連鎖
```python
from pron_markov.model import *
import random

_start_flag = "__start"
_end_flag = "__end"


def train(train_data: list[str]) -> Model:
    model = Model()
    for word in train_data:
        sep_word = list(word)
        sep_word.append(_end_flag)

        for i, c in enumerate(sep_word):
            if i == 0:
                model.add_pron(_start_flag, c)
                continue
            model.add_pron(word[i - 1], c)

            if i != 1:
                model.add_pron(word[i - 2 : i], c)
    return model


def run(model: Model) -> str:
    ret = ""

    i_pr = _start_flag
    ii_pr = ""

    while True:
        next_pr = []
        # 一文字の探索
        pr_model = model.pron_map[i_pr]
        r = random.randrange(pr_model.total)
        sum = 0
        for k, v in pr_model.chain_list.items():
            sum += v
            if r < sum:
                next_pr.append(k)
                break
        # 二文字の探索
        if ii_pr != _start_flag:
            pr_model = model.pron_map[ii_pr + i_pr]
            r = random.randrange(pr_model.total)
            sum = 0
            for k, v in pr_model.chain_list.items():
                sum += v
                if r < sum:
                    next_pr.append(k)
                    break

        # 2:1ぐらいで一文字:二文字の遷移が選択される
        r = random.randrange(len(next_pr) + 1)
        ii_pr = i_pr
        if r < 2:
            i_pr = next_pr[0]
        else:
            i_pr = next_pr[1]

        if i_pr == _end_flag:
            break

        ret += i_pr

    return ret
```
特に特殊なことはしていませんが、コード内のコメントにあるように2:1で一音:二音の遷移がくるように設定してあります。

マルコフ連鎖の部分では、現在の音から次の音になる可能性のあるものを全て探索し、そこから学習した確率に基づきランダムに遷移させています。

####　上記のコードの利用
```python
import pron_markov as pm
import pandas as pd

word_list = []
for v in pd.read_csv("data/word_pron.csv").values:
    word_list.append(v[2])

model = pm.train(word_list)

print("input:", len(word_list))
print("model:", model.total)
print("gen:", pm.run(model))
```
上の二つのコードを`import pron_markov as pm`でインポートします。
学習に使うデータはpandasでcsvから読み込み、発音だけの配列にしておきます。
ひらがなで構成された発音だけの配列を`pm.train(発音の配列)`とすると、モデルが生成されて返ってきます。
`pm.run(モデル)`を実行すると、モデルからマルコフ連鎖もどきにより可愛い感じのする新たな単語が生成されます。

## 最後に
自分で学習させてみたデータで、実行してみた結果を載せておきます。
2文字以上の単語を30件生成してみました。与えた単語は465単語、学習した遷移は3968音です。
![実行結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740492/bd5da63f-db3d-395c-c6ac-441989493e69.png)
長音記号が多い感じですねー