変分オートエンコーダでそっくりな漢字ランキングを作った

# オートエンコーダとは

データを一度モデル(エンコーダー)にかけ低次元ベクトル(潜在変数)にした後, もう一度モデル(デコーダー)にかけることで, 元のデータと同じものを再構成させる機械学習モデルをオートエンコーダーと呼びます.
しかしこの手法は, ノイズに対する頑強性がなく, ちょっと元データが異なったときに全然別の出力になるといった難点があります.

これの解決策として, 意図的に途中でノイズを加えノイズへの頑強性を増す手法(デノイジングオートエンコーダー,変分オートエンコーダー)が用いられています.

# 変分オートエンコーダとは

変分オートエンコーダーでは, エンコーダーで潜在変数の分布を求めます.
そして, その分布から潜在変数をひとつ抽出し, デコーダーで潜在変数から入力データを再構成させます.

損失関数としてはELBOと呼ばれるものが用いられており,

```math
L = L_{再構成} + \beta L_{正則化}
```
$L_{再構成}$は再構成誤差と呼ばれており, 入力データと出力データ間の対数尤度関数(例:二値交差エントロピー)に相応します.
$L_{正則化}$は潜在変数の分布がどれぐらい標準正規分布に似ているかを表す指標で, 潜在変数の分布が正規分布$\mathcal{N}(\vec{\mu},\mathrm{diag}\vec{\sigma })$とすると
```math
L_{正則化}= - \frac{1}{2} \sum_{d=1}^{次元数} (1 + \ln \sigma ^{2}_{d} - \sigma ^{2}_{d} - \mu ^{2}_{d})
```

# やったことの概要

変分オートエンコーダでは, 入力データの傾向が近いものほど潜在変数が近くに配置されるようになります.
この性質を活用することで, 入力データに漢字の画像を入れることで漢字同士の意味的な距離を測定し, そっくりな漢字ランキングを作成しました.

# 実装
## 漢字画像の作成

まずはじめに, データセットとして常用漢字である2136字の画像を作成します.
フォントはメイリオでサイズは16ピクセル×16ピクセル. 色は白地に黒の単色としました.
PILというライブラリを使えば簡単に描画することができます.
```python
from PIL import Image, ImageDraw, ImageFont
import numpy as np

L=16
img=np.zeros((L,L),dtype=np.uint8)
img=Image.fromarray(img)
draw = ImageDraw.Draw(img)
draw.font = ImageFont.truetype('C:\Windows\Fonts\MEIRYO.TTC', L)
draw.text((0,-2), "鬼", 255) #少しずれるため補正

img.show()
```
完成した画像がこちら↓
<img width="100"  src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2950973/413830a1-a34a-5d54-9b3a-06a555b11989.png">

## 変分オートエンコーダの実装

変分オートエンコーダの実装はpytorch-lightningで行いました.

```python
import torch
import torch.nn as nn
import pytorch_lightning as pl

class Net(pl.LightningModule):
    def __init__(self):
        super().__init__()
        self.batch_size = BATCH_SIZE

        self.encoding = nn.Sequential(
            nn.Flatten(),
            nn.Linear(L*L,N_HIDDEN),
            nn.ReLU(),
            nn.Linear(N_HIDDEN,N_HIDDEN),
            nn.ReLU(),
        )
        self.encoding_mu = nn.Linear(N_HIDDEN,K)
        self.encoding_logvar = nn.Linear(N_HIDDEN,K)

        self.decoding=nn.Sequential(
            nn.Linear(K,N_HIDDEN),
            nn.ReLU(),
            nn.Linear(N_HIDDEN,N_HIDDEN),
            nn.ReLU(),
            nn.Linear(N_HIDDEN,L*L),
            nn.Sigmoid()
        )
        
    def forwardEncoder(self,x):
        x=self.encoding(x)
        mu=self.encoding_mu(x)
        logvar=self.encoding_logvar(x)-100
        return mu,logvar
    def forwardDecoder(self,z):
        x=self.decoding(z)
        x=x.reshape((-1,L,L))
        return x
    def muToZ(self,mu,logvar,is_train=False):
        if is_train:#学習時はlogvarに基づきノイズを加える
            std = torch.exp(0.5 * logvar)
            return mu+torch.normal(mean=0,std=std)
        else:
            return mu
    
    def forward(self, x,is_train=False):
        mu,logvar=self.forwardEncoder(x)
        z=self.muToZ(mu,logvar,is_train=is_train)
        x_pred=self.forwardDecoder(z)
        return x_pred,mu,logvar

    def lossfun(self, x, x_pred, mu, logvar):
        cross_entropy=nn.BCELoss()(x_pred.flatten(),x.flatten())
        kl_div=-0.5*(1+logvar-mu**2-logvar.exp()).mean()
        loss=cross_entropy+BETA*kl_div #beta:正則化項の影響度 0~1
        return loss
    '''-------以下略-------'''
```

今回はエンコーダー/デコーダーの部分を単純な全結合層だけで作りましたが, 畳み込みなどをするとより精度が出るかもしれません.
logvarの初期値が大きすぎると, ノイズが激しすぎて全然学習しないので注意.
バッチサイズを気持ち多めにするとうまく学習しやすそう.

# 結果

## ちゃんと再構成できているか

左がデコード後の画像で右が元の画像です.
ちょっと怪しい部分もありますが, 無事もとの画像を再構成できているようです.
めでたし

<img width="200"  src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2950973/ae93fc0f-3be7-388a-5317-e53128a53f15.png">
</br><br>
<img width="200"  src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2950973/83857181-eb07-ccc2-56aa-cf110d46029b.png">
</br><br>
<img width="200"  src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2950973/c8e7cf1f-5023-dca4-c7ec-bdef6b6bcfc8.png">
</br><br>
<img width="200"  src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2950973/9c15d07d-9cf4-c102-25c5-f9a08efb7a9d.png">
</br><br>
<img width="200"  src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2950973/fb88628d-9f37-0c8a-1465-368abb4c54dd.png">

## そっくりな漢字ランキング

文字間の潜在変数の距離をはかることで似ている文字ランキングを作成する.
距離関数としてはユークリッド距離とコサイン距離の２つを採用します.

|順位  |ユークリッド距離  |コサイン距離  |
|---|---|---|
|1  |大 vs 太  |大 vs 太  |
|2  |了 vs 丁  |了 vs 丁  |
|3  |字 vs 学  |字 vs 学  |
|4  |送 vs 迭  |玉 vs 王  |
|5  |玉 vs 王  |送 vs 迭  |
|6  |聞 vs 間  |聞 vs 間  |
|7  |忘 vs 志  |責 vs 貴  |
|8  |衷 vs 哀  |弁 vs 井  |
|9  |関 vs 開  |忘 vs 志  |
|10  |責 vs 貴  |村 vs 材  |


ということで, 変分オートエンコーダが選ぶ最も似ている漢字は｢大｣と｢太｣でした.
｢玉｣と｢王｣のような納得の結果や, ｢忘｣と｢志｣のように言われてみると確かにというものまで色々ですが, そこそこは納得できる結果なのではないでしょうか.

以下コード

```python
with torch.no_grad():
    X_pred,mus,_ = model(X,is_train=False)
mus=np.array(mu.cpu())

delta_mu_euclid=np.ones((len(mus),len(mus)))*10000
delta_mu_cos=np.ones((len(mus),len(mus)))*10000
for i,mu_i in enumerate(mus):
    for j,mu_j in enumerate(mus):
        if i>j:
            delta_mu_euclid[j,i]=delta_mu_euclid[i,j]=((mu_i-mu_j)**2).sum()**0.5
            mu_i_norm=(mu_i**2).sum()**0.5
            mu_j_norm=(mu_j**2).sum()**0.5
            delta_mu_cos[j,i]=delta_mu_cos[i,j]=1-(mu_i/mu_i_norm)@(mu_j/mu_j_norm)

i_map,j_map=np.meshgrid(range(len(mus)),range(len(mus)))
i_map=i_map.flatten()
j_map=j_map.flatten()

itrs=np.argsort(delta_mu_euclid.flatten())
i_map[itrs],j_map[itrs],
```


# [おまけ]モーフィング

変分オートエンコーダを使うと２つの画像を自然な形で変形できるそうなのでやってみました.
佐 <--> 休

<img width="1000"  src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2950973/c6026f19-91a9-f14b-c5c2-9aa8735eca41.png">