fast.aiで作るハンドメイドの超簡単な手書き数字判別器

今回は、MNISTの手書き画像を用いて`３`と`７`を判別するという、超簡単な判別器を作るというベーシックな内容で書いてみました。この記事はfast.aiのライブラリを使ってみようかという人の入門になるだろうと思います。

内容を具体的に言うと、fastbookのサンプルコードにある[04_mnist_basics.ipynb](https://github.com/fastai/fastbook/blob/master/04_mnist_basics.ipynb)をベースに自分の実験を入れながら理解したところを解説します。結果から言うと、判別器の正解率は`３`の検証画像については`91.7%`、`７`の検証画像については`98.5%`でした。

この記事で参照したサンプルコードについての解説は、公式ページのlesson3後半からlesson4前半で行われていますので、興味があれば見てください。英語ですが詳細な解説はサンプルコード自体にもあります。
## 訓練/検証データのダウンロード
まず、Googleドライブに必要なライブラリをインストールし、必要なモジュールをインポートします。基本はサンプルコードと同じですが、Googleドライブのマウントが面倒なので、`fastbook.setup_book()`をコールしないよう不要な部分をコメントアウトしました。

```python
#!pip install -Uqq fastbook
#import fastbook
#fastbook.setup_book()
! [ -e /content ] && pip install -Uqq fastbook  # upgrade fastai on Google Colab

from fastai.vision.all import *
from fastbook import *

matplotlib.rc('image', cmap='Greys')
```
次は、`untar_data`関数でMNISTサンプルデータをダウンロードして解凍します。データが格納されたディレクトリを知るために戻り値を変数`path`に代入しておきます。`URLs.MNIST_SAMPLE`の定義については、[External data](https://docs.fast.ai/data.external#untar_data)を参照してください。

```python
path = untar_data(URLs.MNIST_SAMPLE)
Path.BASE_PATH = path
```
変数`path`の型は`pathlib.PosixPath`でいろいろ便利に使えますが、上記のように`Path.BASE_PATH = path`とすると、画像を展開したディレクトリ以下のディレクトリは相対パスみたいに参照できるようになります。実装が気になって調べたら、次のようになっていましたので、カレントディレクトリそのものが移動してしいるわけではありません。([https://github.com/fastai/fastcore/blob/28721554e0eab1f2974237c502912f8ba42a44a0/fastcore/xtras.py](https://github.com/fastai/fastcore/blob/28721554e0eab1f2974237c502912f8ba42a44a0/fastcore/xtras.py))

```python
@patch
def __repr__(self:Path):
    b = getattr(Path, 'BASE_PATH', None)
    if b:
        try: self = self.relative_to(b)
        except: pass
    return f"Path({self.as_posix()!r})"
```
この実装を見る限り、`Path.BASE_PATH`というクラス変数が存在する(設定する)とそのディレクトリ以下が相対表示されるようです。たとえば、`path`を表示すると`Path('.')`とカレントディレクトリのごとく表示されます。(未設定で参照するとエラーになります。)

ここまでを実行した結果が次の図です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/789dda19-6270-255d-5fe2-614a01820a6f.png)
見てのとおり、ダウンロード元を指す定数`URLs.MNIST_SAMPLE`は`https://s3.amazonaws.com/fast-ai-sample/mnist_sample.tgz`で、画像は圧縮ファイルになっています。そして、`untar_data`関数が画像を展開したディレクトリを指す`path`は`/root/.fastai/data/mnist_sample`となっていました。
### 訓練/検証データの読み込み
次に、画像を展開したディレクトリの直下を調べて訓練/検証データの場所を確認し、画像ファイルのパスリストを作成します。`path.ls()`を表示すると`train`と`valid`という2つのサブディレクトリが見つかりました。さらに、`(path/'train').ls()`を表示すると`3`と`7`という2つのサブディレクトリがあります。`valid`も同じです。

次のコードは、訓練データのパスリストを取得するもので、`threes`と`sevens`はそれぞれ`３`と`７`の手書き文字画像のパスリストです。

```python
threes = (path/'train'/'3').ls().sorted()
sevens = (path/'train'/'7').ls().sorted()
```
訓練画像の一つを適当に選び、その画像を`Image.open`関数で読み込んで表示します。データ型はこの時点で`PIL.PngImagePlugin.PngImageFile`になっており、画像のサイズは`(28,28)`です。

```python
im3_path = threes[1]
im3 = Image.open(im3_path)
im3
```
ここまでを実行すると次のようになります。ちゃんと`3`という数字が表示されるのが確認できますね。いちいち`matplotlib`などを使ってコードを書かなくても適当に表示しくれます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/cfb8c1fa-8018-5979-cafc-eef4b1db89eb.png)
3と７の訓練画像はそれぞれ`6131枚`、`6265枚`であることが分かりますね。
###テンソルへの変換
画像をテンソル(`PyTorchのTensor`)に変えると、画像の演算や切り出しが、Numpyと同じ形式で行えるようになります。たとえば、画像の一部を切り出すには次のようにします。ここでは、pythonの`Pandas`ライブラリを使って画素値を視覚的に確認しています。

```python
im3_t = tensor(im3)
df = pd.DataFrame(im3_t[4:15,4:22])
df.style.set_properties(**{'font-size':'6pt'}).background_gradient('Greys')
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/74377774-871a-a4a7-278a-5f072790800c.png)
ということで、先ほどのパスリストからテンソル型の画像のリストを作成します。`three_tensors`と`seven_tensors`は、それぞれ`3`と`7`の手書き数字画像のリストです。

```python
seven_tensors = [tensor(Image.open(o)) for o in sevens]
three_tensors = [tensor(Image.open(o)) for o in threes]
```
このコードでは、先のパスリストの各要素をテンソル型の画像に変換したリストを作成しています。下の実行結果から分かるとおり、新しいリストの要素は`torch.ByteTensor`型で、画像として表示するには先ほどと同じではダメなので`show_image`関数を使います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/05d2c325-4c30-401a-5c4c-effd418e6113.png)
## 画像と画像の差異を測る
与えられた画像が３なのか７なのかを区別するには、どちらに近いか測ればよい。そこで、一つのアイデアとして、３と７の手書き文字の訓練画像それぞれの平均画像を求めて、３と７それぞれのお手本とします。そうすると、与えられた画像がどちらのお手本に近いかという問題に置き換わったことになります。
### 平均画像の計算
準備として、３と７の訓練画像のリストを各々`(サンプル数,高さ,幅)`の3次元テンソルにします。これはリストの全要素を縦に積み重ねて一つのテンソルにするイメージです。`torch.stack`関数はそれをしています。このとき序に、画素値も`0~255`から`0.0~1.0`に規格化しておきます。

```python
stacked_sevens = torch.stack(seven_tensors).float()/255
stacked_threes = torch.stack(three_tensors).float()/255
```
次は平均画像の計算です。これは各々1ステップで計算できます。平均画像は`第0次元で平均`した`(高さ,幅)`2次元のテンソルであり一つの画像です。

```python
mean3 = stacked_threes.mean(0)
mean7 = stacked_sevens.mean(0)
```
下の図はここまでを実行した結果です。画像は`torch.FloatTensor`型の3次元テンソルになっており、先と同様に、この平均画像を画像として表示するのに`show_image`関数を使っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/798d47cb-0116-71e0-63de-578401ef01a0.png)
ちょっとぼやけてますが、これが手書き文字の３と７の訓練データを代表する画像ということになります。

### 画像の差異
次のコードは画像の差異を計算する関数です。`l1_distance_from`は画素の差の絶対値の平均を計算します。一方、`rmse_distance_from`は画素の差の2乗の平方根を計算します。ちなみに、`@patch`は、第一引数の型を持つオブジェクトのメソッドのように振る舞わせるお呪いです。(取っ払って普通に使っても問題ありません。)

```python
@patch
def l1_distance_from(self:Tensor,origin:Tensor): return (self-origin).abs().mean((-1,-2))
@patch
def rmse_distance_from(self:Tensor,origin:Tensor): return ((self-origin)**2).mean((-1,-2)).sqrt()
```
次の図は、手書き文字３の訓練画像の一つをピックアップして、3の平均画像`mean3`および7の平均画像`mean7`との差異を表示した結果です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/a72e9d56-4728-1ce3-fb36-232aab834d91.png)
どちらの関数で比較しても、`3`の平均画像に近いと分かりますね。ちなみに、最後のセルの結果を見れば分かるとおり、`l1_distance_from`と`rmse_distance_from`の計算は、fastaiが提供するPyTorchのラッパー関数`F.l1_loss`と`F.mse_loss`を使っても実装できます。

上記の2つの関数は`最後の2つの次元が(高さ,幅)であることを前提`としていることに注意してください。また、テンソルの`ブロードキャスティング`に依存しているのでその条件に従っている必要があります。テンソルのブロードキャスティングの条件に興味がある方は「[NumPyのブロードキャスト（形状の自動変換）](https://note.nkmk.me/python-numpy-broadcasting/)」または「[BROADCASTING SEMANTICS](https://pytorch.org/docs/stable/notes/broadcasting.html)」を参照ください。

判別器の性能を確認するには訓練用画像に含まれない検証用画像を使いますので、検証用画像の方も3次元テンソルに変換しておきます。ここでは途中を飛ばして一気に変換します。

```python
val3_images_tensor = torch.stack([tensor(Image.open(o)) for o in (path/'valid'/'3').ls()]).float()/255
val7_images_tensor = torch.stack([tensor(Image.open(o)) for o in (path/'valid'/'7').ls()]).float()/255
```
テンソルのブロードキャスティングが実際どう働くかは、上記で3次元テンソル化した検証用画像の類似度計算の結果をみれば分かります。

```python
valid3_dist = val3_images_tensor.l1_distance_from(mean3)
```
結果は次のとおりです。サイズ(1010, 28, 28)の3次元テンソル`val3_images_tensor`と
サイズ(28, 28)の2次元テンソル`mean3`の類似度の計算結果は、サイズ(1010)の1次元テンソルになっていて、ちゃんと1010枚の画像それぞれの類似度が計算されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/0156ea13-0d12-c985-f9e8-09de1f7578ed.png)

## 超簡単3-7判別器の作成と評価
与えられた手書き数字の画像が`3`か`7`かの判別器は、デコレーション`@patch`を含めてたった2行です。やっていることは簡単で、`mean3`との差異が`mean7`との差異より小さければ`0`、そうでなければ`1`を返すだけです。

```python
@patch
def predict(self:Tensor): return tensor(list(map(lambda x: 0 if x.l1_distance_from(mean3) < x.l1_distance_from(mean7) else 1, self)))

val3_predicted = val3_images_tensor.predict()
val7_predicted = val7_images_tensor.predict()

print(f'Corrct answer counts: 3s = {(val3_predicted==0).sum()}   7s = {(val7_predicted==1).sum()}') 
```
次は正解率を計算する関数です。

```python
@patch
def accuracy(self:Tensor, target:Tensor): return (self==target).float().mean()

print(f'Validation images 3 prediction accuracy(%): {val3_predicted.accuracy(0)*100:.3f} %\n\
Validation images 7 prediction accuracy(%): {val7_predicted.accuracy(1)*100:.3f} %')
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/ef200c60-3b1b-e917-cd33-d608fc592f28.png)
### 混同行列とクラシフィケーション・レポートの表示

下記のコードで、変数`y`と`y_pred`は、３と７両方の検証画像を合わせた正解ラベルと予測したラベルです。

```python
from sklearn.metrics import confusion_matrix, classification_report

y = [0]*len(val3_predicted) + [1]*len(val7_predicted) 
y_pred = torch.cat([val3_predicted, val7_predicted], dim=-1) 

cf_matrix = confusion_matrix(y, y_pred)
print('Confusion matrix:\n', cf_matrix)
print('\nClassification_report:\n', classification_report(y, y_pred, digits=5))
```
次の図はこのコードを実行した結果です。混同行列の表示から、３の検証画像では、1010件中正解が926件で誤判別は84件、７の検証画像では、1028件中正解が1013件で誤判別が15件だと分りますね。クラシフィケーション・レポートの`recall`は、`accuracy`関数と同じ値になっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/1d1c59b0-fe21-8b2c-4f80-705b2cfef1fe.png)
### 誤判定の画像の表示

```python
def plot_error_images(err_imgs):
  plt.figure(figsize=(24, 20))
  for i in range(len(err_imgs)):
    ax = plt.subplot(9,10, i+1)
    ax.set_title(f'{err_imgs[i].l1_distance_from(mean3):.2f} - {err_imgs[i].l1_distance_from(mean7):.2f}', color='w')
    ax.axes.xaxis.set_visible(False)
    ax.axes.yaxis.set_visible(False)
    plt.imshow(err_imgs[i])
```
3の検証画像の中で誤判定された画像を表示するには、次のように判定結果が`0`ないものを選んで表示すればOKです。

```python
plot_error_images(val3_images_tensor[val3_predicted!=0])
```
上記のコードを実行した結果です。タイトルに表示した平均画像との差異をみると、エラーになったものは差異が拮抗していますね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/e1343a0c-644e-beb6-5960-37577a6bd486.png)
同様に、７の検証画像の中から判定結果が`1`でないものを選んで表示すればOKです。こちらも平均画像との差異が拮抗しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/5425691d-ed9f-cb52-476e-e4b5dab1dd5e.png)
ということで、平均画像との差を画像として表示してみました。これをみると、大きさや太さはもちろん、ちょっとした平行移動や回転に対しても非常に弱いように感じます。まあ、画素値の差だけを使ってるだけなんで、仕方ないと言えば仕方ないですが、あらためてそれを確認できたということです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407748/b4535390-12b6-18a5-fe95-ab61effab230.png)
画像の引き算の結果は、正のところは赤、負のところは青、そして、０のところは白で表示されます。
## 関連記事
[PyTorchの自動微分を用いて関数の接線を描画してみた](https://qiita.com/kumagorou/items/6081d9cf15024c839103)
## 参考になるサイト
[Deep Learning等の精度評価において、F値（Dice）とIoU（Jaccard）のどちらを選択するべきか？](https://tech-blog.optim.co.jp/entry/2019/03/18/173000)
## fast.ai公式ページ
[Practical Deep Learning for Coders](https://course.fast.ai/)
[Lesson 3 - Deep Learning for Coders (2020)](https://course.fast.ai/videos/?lesson=3)
[Lesson 4 - Deep Learning for Coders (2020)](https://course.fast.ai/videos/?lesson=4)