【Python】発音で検索できるタイ語辞書アプリを作った

## まとめ
* ローマ字で検索できるタイ語辞書アプリを作った。
webアプリ：https://huggingface.co/spaces/konbraphat51/RomanDictionary
レポジトリ：https://github.com/konbraphat51/RomanDictionary

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/71a74e3b-5c98-ee20-45c5-85277618225c.png)


* 処理の流れは
    * スクレイピングで単語一覧を取得
    * ディープラーニングライブラリ`pythainlp`でローマ字化
    * 動的計画法（レーベンシュタイン距離）の要領で検索ワードと距離を調べ、近い順に表示
    
* タイ語のようなスペルが難しい言語でも、聞いたままの発音でそのまま検索できる。

## 導入
現在タイに旅行していますが、まあタイ語の単語が分からない。一応大学ではタイ語をけっこう熱心に勉強し、一通りの文法は把握しているのですが、語彙力の問題はいつまでもついてきます。

そこで、辞書で調べたくとも、タイ語はスペルが超難しい。
例えば`กขคฆ`は全てカ行の子音、`ตถทธ`は全てタ行の子音というように、素人目では同じ発音でも子音文字が一意に決まらない。（タイ人は発音の息の出方とか高低で分かったりするらしいです）
また、各子音には「高・中・低」があり、そこで「調音記号」が加わって発音の高低がややこしくなる。
たとえば
`ไม่`は否定文の副詞
`ไหม`は疑問文の副詞
`ไหม้`は「燃える」
`ใหม่`は「新しい」
`ไม้`は「木材」
ですが、これら全てカタカナで書くと「マイ」です。違うのは高低だけです。

にわかタイ語erなので発音の高低なんて全く識別できない。<strong>分かるのは発音カタカナだけ。</strong>そんなときに使えるローマ字検索辞書アプリを作成しました。

## 実装
### データ作り
辞書アプリですから、さすがに辞書データが必要です。これを取得・整形する`DataMaker`クラスを作成しました。
#### 単語取得
さすがに外国人相手に難解単語を使う方はいないでしょうので、「基本単語」のデータセットを探します。
ありました：https://3000mostcommonwords.com/list-of-3000-most-common-thai-words-in-english/
こちらをお借りします。
ダウンロードができないので、HTMLを解析したまとめて取得します。
これは静的ページなので、`requests`ライブラリでアクセスし、`BeautifulSoup4`ライブラリで単語を掘り当てる。
```Python:DataMaker
    def scrape(should_save = False):
        res = requests.get("https://3000mostcommonwords.com/list-of-3000-most-common-thai-words-in-english/")
        soup = BeautifulSoup(res.content, "html.parser")

        temp_table = []

        tr_even = soup.find_all("tr", class_="even")
        tr_odd = soup.find_all("tr", class_="odd")

        trs = tr_even + tr_odd

        for tr in trs:
            try:
                tds = tr.find_all("td")
                #第一正規形になるように
                engs = tds[1].get_text().split(", ")
                th = tds[4].get_text()
                
                for eng in engs:
                    temp_table.append([eng, th])
            except:
                pass
                    
        df = pd.DataFrame(temp_table, columns=["Eng", "Th"])

        if should_save:
            df.to_csv(Consts.datamaker_folder + "list_simple_Th.csv", index=False)
            
        return df
```
こんな感じで、対象の単語リストページのHTMLに深く依存したコードになります。

#### ローマ字化
タイ文字からローマ字化するとなるとルール書くだけで旅行丸々潰れるぞ～と思っていたら、タイ語自然言語処理ライブラリ`pythainlp`にすでにその関数がありました。ありがたい！使えるメソッドにルールベースのモデルとディープラーニングモデルがありますが、ルールベースの方に対応できない文字が来るとエラーが発生していたので、ディープラーニングモデル`thai2rom`を採用。
```Python:DataMaker
    def romanize_df(df, should_save = False):
        romanized = []
        for th in df["Th"]:
            try:
                romanized.append(romanize(th, "thai2rom"))
            except:
                print(th)
            
        df["Romanized"] = romanized
        
        if should_save:
            df.to_csv(Consts.datamaker_folder + "list_romanized_Th.csv", index=False)
    
        return df
```

### データ作り結果
こんな感じのCSVファイルができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/4b72dbeb-0287-669f-293a-d1ef2535071b.png)

### 検索機
タイ語のローマ字を完全一致で検索するのはかなり難しい。少なくとも僕には口をすぼめた「オー」と口を開いた「オー」を正確に聞き分けられません。
なので、検索した文字列に**だいたい似ている**検索メソッドを考える必要があります。
ここで登場するのが、競プロ本やら[アルゴリズムの講義](https://yatani.jp/teaching/doku.php?id=2022algorithms:start)で頻出の「レーベンシュタイン距離」

> 1文字の挿入・削除・置換によって、一方の文字列をもう一方の文字列に変形するのに必要な手順の最小回数として定義される ([Wikipedia](https://ja.wikipedia.org/wiki/%E3%83%AC%E3%83%BC%E3%83%99%E3%83%B3%E3%82%B7%E3%83%A5%E3%82%BF%E3%82%A4%E3%83%B3%E8%B7%9D%E9%9B%A2)より)

定義だけ見ると「めっちゃ計算するんじゃね？」と思うかもしれないが、**動的計画法**を使うことで高速に計算できます。具体的な仕組みはこちらを参照：https://qiita.com/k-yokota/items/1b01716620a23f184a6e

ここで、少し本アプリの用途向けにカスタムしてみたいと思います。
例えばレーベンシュタイン距離が1の「カ(ka)とマ(ma)」なんてなかなか聞き間違えることはないのに対して、同じく距離が1の「アー(aa)とア(a)」はけっこう聞き間違える、というか人の話し方に依存する。
聞き間違えやすい方を似ている、すなわち距離を他と短くしたいです。
例えば「アーとア」の距離は0.5にする、などという具合で。

そこで、挿入・削除・編集について、対象の文字とその1つ前の文字を取得し、それに応じてルールベースでカスタムしたコストを返す、という関数を挟みます。

これがDPテーブルのコードとして
```python:DPテーブル抜粋
        for cnt_i in range(1, i_n):
            for cnt_a in range(1, a_n):
                a_add_cost = dp[cnt_i][cnt_a-1] + self.cost_add(_a[cnt_a], _a[cnt_a-1])
                i_add_cost = dp[cnt_i-1][cnt_a] + self.cost_add(_i[cnt_i], _i[cnt_i-1])
                change_cost = dp[cnt_i-1][cnt_a-1] + self.cost_change(_i[cnt_i], _i[cnt_i-1], _a[cnt_a], _a[cnt_a-1])
```

先ほどの短母音・長母音のコストを0.5にする、という処理は
```python:Seacher
    def cost_change(self, i_letter, i_letter_former, a_letter, a_letter_former):
        if i_letter == a_letter:
            return 0.0
        #短母音と長母音の間違え
        elif (i_letter == i_letter_former) or (a_letter_former == a_letter):
            return 0.5
        else:
            return 1.0
```
このように。

こうして、全単語について計算を行っても、3000単語、一語だいたい10文字だとしたら時間計算量は一回の入力で30万のオーダーになります。これぐらい全然許容できますね。単語数が格段に多い場合はしんどいですが。

こうして距離を計算すれば、あとは小さい順にソートするだけです。

### ビュアー
Pythonアプリをネット上で無料でデプロイできるHugging Face Spacesを使いたいので、Gradioでビュアーを記述
```python:Viewer
import gradio as gr
from RomanDictionary.Seacher import Searcher

searcher = Searcher()
searcher.import_data("Th2Eng")

app = gr.Interface(
    fn=searcher.search,
    inputs=["text", gr.Radio(["Eng", "Romanized", "Th"])],
    outputs=gr.DataFrame()
)
```
初めてですが、簡単すぎて震えますね。

## いかがだったでしょうか
タイ語を文法は分かるものの単語だけ分からない方はぜひこのアプリを、他言語で似たような状況にある方はぜひこれをフォークして編集してください。（`DataMaker`をその言語に対応させるだけで大丈夫です）