Food 101データセットを用いたアプリ制作の記録(Python・機械学習)

### おことわり
　本記事は株式会社アイデミーの個人を対象としたPython特化型のオンラインプログラミングスクール [Aidemy Premium](https://aidemy.net/grit/premium/ "AIが未経験からでも学べる Python専門プログラミングスクールAidemy Premium") AIアプリ開発講座のカリキュラムの一環により、作成された記事です。

# はじめに

　2022年11月30日にOpenAI社が生成型AI「ChatGPT」を発表、社会へ広く知れ渡ったことを機に、AIに関する情報に触れる機会が、それ以前と比べて飛躍的に増えました。私は情シス関係の仕事(社内ヘルプデスク職)に就いている現在30代半ばの者ですが、小学校高学年〜中学校卒業の頃まで友人の家で出会った将棋に、自戦記などを綴ったホームページをYahoo!ジオシティーズに公開、3年ほど更新し続けたくらいのめり込んだことがありました。2012〜2017年に行われた人間とAIの対局(電王戦)や、Abemaの将棋番組(対局中継)に映る現局面のAIの評価(形勢判断や次の候補手など)、プロ棋士の先生方がされるAI(将棋ソフト)に関する話(PCスペックの違いが日々の勉強に使用するAIの出力の違いを生み、ひいては対局結果に直結するなど)など、将棋をきっかけにAIという言葉に日常的に触れていたこともあり、ChatGPTの登場以前からAIに対して、親近感や興味を持っておりました。

　ChatGPTが登場し、自ら活用するとともにその傾向は強くなり、またChatGPTの技術を活用した英会話アプリSpeakを使い始めたこともあって、個人の生活においても、AIはもはや日常生活に欠かせないものとなりました。

　そんな中でアイデミーさんのAIアプリ開発講座の存在を知り、面白そう！と思ったことをきっかけに、今回受講することにいたしました(6ヶ月コース、2024/3/4〜)。プログラミング経験はこれまでHTMLやCSS、JS、Railsなど、独学や他社スクールを通じて半年間ほどありますが、そのほとんどは既に忘れてしまい(笑)、**Python・機械学習に取り組むのは今回が初めて、実務経験もなし**というレベル感です。本記事はその最終カリキュラムで取り組んだ**アプリ制作の記録**です。

# 目次

* [はじめに](#はじめに)
* [目次](#目次)
* [開発環境](#開発環境)
* [アプリ概要](#アプリ概要)
* [テーマ選定](#テーマ選定)
* [データの準備・クレンジング](#データの準備・クレンジング)
* [モデルの生成・学習・評価](#モデルの生成・学習・評価)
* [気付きと振り返り](#気付きと振り返り)
* [おわりに](#おわりに)
* [参考リンク](#参考リンク)

# 開発環境

　SoC：Apple M3
　PC：MacBook Air 2024 (8C CPU, 10C GPU, 24GB unified memory, 1TB SSD storage)
　OS：macOS Sonoma 14.5
　Python：3.9.19
　Google Colaboratory (Runtime：T4 GPU)
　Visual Studio Code：1.91.1
 
# アプリ概要

　今回制作したアプリは、**料理の分類アプリ**です。

　[Food Classifier](https://food-101-app.onrender.com/ "Food Classifier")

　Food 101データセットを用いており、その名の通り、元のデータセットは101種類の料理画像で構成されています。今回はその中で個人的にいいなと思った6種類の料理(チキンカレー, アイスクリーム, ピザ, ラーメン, ショートケーキ, 寿司)を分類するアプリを制作しました。

　[Food 101](https://www.kaggle.com/datasets/dansbecker/food-101 "Food 101")
 
　![food-101.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3839847/a9383a22-cf34-8574-7e74-8e34f08a3494.jpeg)

# テーマ選定

　当初は ①興味・関心があること ②データサンプルが多いこと ③データを収集しやすいことの3軸をベースに、コンビニでよくアイスを買って食べる私自身の原体験から、既存メーカーアイスの分類アプリというテーマで取り組んでおりました(実際にGoogleのクローニングによる画像収集まで終えておりました)。しかしG検定の勉強を進める中で、成果物のテーマに商標権(具体的な商品名)が絡むものを果たして使ってよいのかとふと疑問を持ち、アイデミーのチューターさんと相談した結果、類似の上記テーマで制作することにしました。

# データの準備・クレンジング

　元データ(画像)の準備はデータセットのダウンロード→Googleドライブへのアップロードで完了しました。Food 101は料理1種類につき1000枚のデータセットのため、今回は6000枚の画像を用いています。後の工程の話になりますが、今回はカリキュラムに倣ってVGG16を用いた転移学習でモデルを生成するので、クレンジング作業ではこれらの画像をVGG16用に224×224へリサイズします。

　元画像のほとんどは正方形ではなく、画像の縦横比を無視して正方形にリサイズすると画像が歪み、モデルの精度に悪影響を与える可能性があるとのことでしたので、縦横比を保ちながらリサイズする手段として黒いパディングを追加した上、224×224へリサイズしました。リサイズ後のデータ保存においては、カリキュラムで学習した画像処理ライブラリのOpenCV(cv2.imwrite)を最初に使用した際、RGB(赤・緑・青)ではなくBGR(青・緑・赤)の順番で書き込むOpenCVの仕様により写真が青を基調とした形で保存されてしまったため、今回はPillowを使用しました。

(クレンジング前)

 ![チキンカレー(加工前).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3839847/e0b6c860-b392-6b97-88eb-f04817e21942.jpeg "クレンジング前")
 
 (クレンジング後)　　　　　　　　(OpenCVで保存)
 
 ![チキンカレー(加工後).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3839847/fe5e97cf-0797-3e0a-f1a5-dd808fd7f28f.jpeg "クレンジング後")　　　![チキンカレー(OpenCVで保存).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3839847/f2d1c67d-77ae-6fd6-3c35-1d4de01c3471.jpeg "OpenCVで保存")

　また可読性や保守性、再利用性などの観点から、データクレンジング用のコードはモデル生成〜学習〜評価用のコードと分けて記述しました。

```python:data_cleansing.ipynb
#ライブラリの読み込み
import os
from google.colab import drive
from PIL import Image

# Googleドライブをマウントする
drive.mount('/content/drive')

def resize_and_pad(img, size, pad_color=(0, 0, 0)):
    # 画像の高さと幅を取得
    h, w = img.size

    # スケールを計算してリサイズ
    scale = size / max(h, w)
    new_h, new_w = int(h * scale), int(w * scale)
    resized_img = img.resize((new_h, new_w), Image.ANTIALIAS)

    # パディング
    new_img = Image.new("RGB", (size, size), pad_color)
    new_img.paste(resized_img, ((size - new_h) // 2, (size - new_w) // 2))

    return new_img

# 画像ディレクトリのパス
# raw/~, processed/~ の ~ 部分のみ編集して使用
input_dir = '/content/drive/MyDrive/Food-101/data/raw/chicken_curry'
output_dir = '/content/drive/MyDrive/Food-101/data/processed/chicken_curry'

# 画像のサイズ
image_size = 224

# 画像ファイルを処理
for filename in os.listdir(input_dir):
    if filename.endswith(".jpg") or filename.endswith(".png"):
        img_path = os.path.join(input_dir, filename)
        img = Image.open(img_path).convert("RGB")

        # 画像をリサイズしてパディング
        new_img = resize_and_pad(img, image_size)

        # 処理した画像を保存
        new_img_path = os.path.join(output_dir, filename)
        new_img.save(new_img_path)
```

# モデルの生成・学習・評価

　前述したように、今回はVGG16を用いた転移学習を行います。転移学習とは、ある領域について大量のデータですでに学習されたモデルを用いて別領域についての学習を行い、モデルを生成する手法・技術のことで、小規模なデータセットでもモデルを生成できる、学習時間や計算資源を短縮・節約できるといった点で有効です。VGG16はそのモデルの中の1つで、オックスフォード大学のチームが作成、2014年のILSVRC(画像認識コンペ)で2位になった、畳み込み13層＋全結合層3層＝16層で構成されるニューラルネットワークです。

```python:model_make.ipynb
# ライブラリの読み込み
import os
import cv2
import numpy as np
import matplotlib.pyplot as plt
from google.colab import drive
from sklearn.model_selection import train_test_split
from keras.utils import to_categorical
from keras.applications.vgg16 import VGG16
from keras.layers import Activation, Input, Dense, Dropout, Flatten
from keras.models import Model, Sequential
from keras.regularizers import l2
from keras.callbacks import EarlyStopping
from keras import optimizers

# Googleドライブのマウント
drive.mount('/content/drive')

# データセットの読み込み
data_dir = '/content/drive/MyDrive/Food-101/data/processed'
categories = os.listdir(data_dir)
X = [] # 画像
y = [] # ラベル

for label, category in enumerate(categories):
    category_path = os.path.join(data_dir, category)
    image_files = os.listdir(category_path)
    for image_file in image_files:
        image_path = os.path.join(category_path, image_file)
        img = cv2.imread(image_path)
        b,g,r = cv2.split(img)
        img = cv2.merge([r,g,b])
        X.append(img)
        y.append(label)

X = np.array(X)
y = np.array(y)

# データセットのシャッフル
rand_index = np.random.permutation(np.arange(len(X)))
X = X[rand_index]
y = y[rand_index]

# データセットの分割
X_train = X[:int(len(X)*0.8)]
y_train = y[:int(len(y)*0.8)]
X_test = X[int(len(X)*0.8):]
y_test = y[int(len(y)*0.8):]

# ラベルデータのone-hotエンコーディング
y_train = to_categorical(y_train)
y_test = to_categorical(y_test)

# VGG16の読み込み
vgg16 = VGG16(include_top=False, weights='imagenet', input_shape=(224, 224, 3))

# top_model(全結合層)の構築
top_model = Sequential()
top_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
top_model.add(Dense(256, activation='relu', kernel_regularizer=l2(0.01)))
top_model.add(Dropout(0.5))
top_model.add(Dense(6, activation='softmax'))

# VGG16とtop_modelの連結
model = Model(inputs=vgg16.input, outputs=top_model(vgg16.output))

# VGG16の特徴抽出部分（畳み込み層とプーリング層）の重みを固定
for layer in model.layers[:19]:
    layer.trainable = False

# モデルのコンパイル
model.compile(
    loss='categorical_crossentropy', # 損失関数
    optimizer=optimizers.SGD(learning_rate=1e-4, momentum=0.9), # 最適化関数
    metrics=['accuracy'] # 評価指標(メトリクス)
)

# 早期終了のコールバック
early_stopping = EarlyStopping(
    monitor='val_loss', # 監視値の設定
    patience=10, # 改善を示さないエポックがX回続いたら終了
    restore_best_weights=True, # 最終的なモデルの重み=最も精度が高いモデルのものに設定
    verbose=1 # verbose=1でログ出力
)

# モデルの学習
history = model.fit(
    X_train, y_train,
    epochs=50,
    batch_size=64,
    verbose=1,
    validation_data=(X_test, y_test),
    callbacks=[early_stopping]
)

# モデルの評価
scores = model.evaluate(
    X_test, y_test,
    batch_size=128,
    verbose=1
)

print('Test loss:', scores[0])
print('Test accuracy:', scores[1])

# accuracy, val_accuracyのプロット
plt.plot(history.history["accuracy"], label="accuracy", ls="-", marker="o")
plt.plot(history.history["val_accuracy"], label="val_accuracy", ls="-", marker="x")
plt.xlabel("epoch")
plt.ylabel("accuracy")
plt.legend(loc="best")
plt.show()

# loss, val_lossのプロット
plt.plot(history.history["loss"], label="loss", ls="-", marker="o")
plt.plot(history.history["val_loss"], label="val_loss", ls="-", marker="x")
plt.xlabel("epoch")
plt.ylabel("loss")
plt.legend(loc="best")
plt.show()

# モデルの保存
model.save('/content/drive/MyDrive/Food-101/modelv7.h5')
```
　今回の制作工程では最終的にモデルを8回生成し、val_accuracy(検証データに対する正解率)の変化を確認しました(上記コードは最終的に使用した7回目生成時(v7)のものです)。各モデルの生成にあたって調整したパラメーターと正解率の遷移は以下の通りです。

| モデル | 分類数 | データ数 | エポック数 | バッチサイズ | 正解率 |
|:--:|--:|-----:|----:|----:|-------|
| v1 | 6 | 100  | 10  | 32  | 47.5% |
| v2 | 6 | 300  | 50  | 128 | 65.6% |
| v3 | 6 | 300  | 30  | 128 | 55.6% |
| v4 | 6 | 500  | 30  | 32  | 68.7% |
| v5 | 6 | 800  | 30  | 128 | 70.6% |
| v6 | 6 | 1000 | 100 | 256 | 65.6% |
| v7 | 6 | 1000 | 50  | 64  | 74.1% |
| v8 | 5 | 1000 | 100 | 64  | 75.5% |

　v1モデルはテスト生成、v2モデルは過学習の兆候が見られたため、v3モデル以降ではGPTの提案を受けてL2正則化と早期終了(アーリーストッピング)のコールバックを追加、その後は徐々に料理1種類あたりのデータ数を増やしたり、バッチサイズを調整しながら正解率の変化を確認していきました。

```python:L2正則化
top_model.add(Dense(256, activation='relu', kernel_regularizer=l2(0.01)))
```

```python:早期終了のコールバック
# 早期終了のコールバック
early_stopping = EarlyStopping(
    monitor='val_loss', # 監視値の設定
    patience=10, # 改善を示さないエポックがX回続いたらループ終了
    restore_best_weights=True, # 最終的なモデルの重み=最も精度が高いモデルのものに設定
    verbose=1 # verbose=1でログ出力
)

# モデルの学習
history = model.fit(
    X_train, y_train,
    epochs=50,
    batch_size=64,
    verbose=1,
    validation_data=(X_test, y_test),
    callbacks=[early_stopping]
)
```

　v7モデルができた時点で、成果物としての必要要件は満たせる正解率になったかなと思い、GitHubへのアップロードを試みましたが、ファイルサイズ110.3MBのモデルに対して100MBまでのファイルしかアップロード不可とエラーが返ってきたため、分類数を6→5にしてv8モデルを生成しました。しかし残念ながら2KBしかサイズに変化が見られなかったため、Git LFSを用いてそのままGitHubにアップロードすることにしました。最終的にはv7モデルの正解率74.1%という数字で着地しました。

**エポック数と正解率の遷移(v7モデル)**
(青：訓練データに対する正解率、橙：検証データに対する正解率)

![accuracy-epoch.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3839847/8bd0f8ca-d597-5559-2463-d3a77f8b1d6c.png)

**エポック数と損失値の遷移(v7モデル)**
(青：訓練データに対する損失値、橙：検証データに対する損失値)
(損失値：教師データ(正解)と出力データ(予測)の差分、小さいほど良い)

![loss-epoch.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3839847/caaf75ec-0aa0-68e6-c1c6-1fccb504445f.png)

　今回のモデル生成においては、データ数と正解率には概ね正の相関があると言ってよい結果になったかと思います。また個人的にはこれらの工程を進めた中で、モデルの学習時間削減に大きく繋がった早期終了が強く印象に残っており、AIのゴッドファーザーと呼ばれるジェフリー・ヒントン氏が「Beautiful FREE LUNCH(※)」と早期終了を表現した理由も、少しばかりですが理解できたような気がしました。笑

　※「あらゆる問題で性能の良い汎用最適化戦略は理論上不可能」であることを示す定理、ノーフリーランチ定理を意識して発せられた言葉。ここでは「労力やコストをほとんどかけずに、大きな効果や成果を得ることができる手法」の意と考えられます。

# 気付きと振り返り

### テーマ選定
　個人的には2週間以上を要し、時間をかけすぎてしまったかなと制作が終わった今となっては思います。1度書いたコードは再利用できるので、マインド的な要素があり難しさもあるのですが、まずはライトな気持ちでテーマを決め、取り組み始められたらよかったと思いました(特にWeb上に公開されたデータセットでデータの準備が可能な場合)。

### モデルの生成・学習・評価
　特に拘っていたわけではないのですが、制作に充てられる時間の関係もあり、今回は転移学習に用いるモデルや損失関数、最適化関数を変更せずに制作を進めました。最終的には正解率74.1%のモデル生成で着地しましたが、こうした要素にも変更を加えたときに正解率がどう変化するのか、さらに正解率の良いモデルを生成できるのかについては、今後さらに深く突き詰めてみたいと思いました。

### サービス課金×3
　細々した話ですが(笑)、今回このアプリを制作した上で、必要に迫られたなどの理由から以下の3つのサービスに課金しました。
 
　Google One(スタンダードプラン)：Googleドライブへのデータ保存量が増えるかと思い、ストレージを予め15GB→200GBにアップグレードして制作に着手しました。しかし実際には3GBも使わず、特にアップグレードは不要でした。
 
　Colab Pro：Google Colaboratory上でモデル生成を複数回実行したり、ランタイムも常時接続状態だったからか、7回目のモデル生成時に無料枠のコンピューティングユニットがなくなっていることに気がつき(無料枠・コンピューティングユニットという概念の存在を知り)、コードの実行が不能だったため、1ヶ月のみですが課金しました。Colabのセッションを都度切断したり、リソースを定期的に確認したりして、コンピューティングユニットの残数を意識したほうがよいように思いました。

　GitHub(Git LFSデータパック)：Renderへのデプロイ成功までに24回の失敗があり、その工程の中で都度GitHub(Git LFS)からのクローニングがRenderへ行われた結果、Git LFSの帯域幅(LFSストレージからダウンロードしたファイルの転送量)が無料枠の1GBを超え、クローニング不能になってしまい、データパックを購入しました。デプロイの失敗はrequirements.txt内に記述するライブラリバージョンの依存関係に起因するもので、避けることが難しいように感じられたので、無課金でアプリ制作を進める方針にするなら、そもそもモデルファイルを100MB以内に押さえてGit LFSを使用しないほうがよいように思いました。

　3つのサービスを合わせてもスポットで2000円程度の金額なので、金額は個人的に大した問題ではなかったのですが、今までできていたColab上でのコード実行やRenderへのデプロイ時クローニングができなくなったときに心理的な負荷や焦りを感じたり、原因の確認に時間を要したことから、今後新たに勉強を始められる方の参考になればと思います。

# おわりに

　今回のアプリ制作では完成度はどうあれ、1つのアプリを形にして公開することを目標に取り組みました。自ら書いたコードからモデルが生成されたり、アップロードした画像に対してアプリが期待した結果を出力したりしたときは感動し、最後にRenderへのデプロイが完了したときは大きな達成感・充実感を感じました。一方で、アプリ制作に取り組むほど自らが知っていることの少なさや理解度の低さ、知識が線になっていない感覚を覚えたことについては、今後の課題であるとも感じました。こうした感覚を今回アプリ制作を通じて得ることができたことは、とてもよい経験になりました。

　記憶が確かであれば、今回のAIアプリ開発との出会いは、Instagramに流れてきたアイデミーではない会社のデータ分析講座広告を見て、ググって他の講座を検索した結果アイデミーを知り、今回受講したAIアプリ開発講座を知って申し込みをしたという流れによるものでした。こうしたひょんな出来事から、今回AIアプリ開発の世界を知ることができたことは、私にとって幸運な出来事でした。今後はAIアプリ開発を自分の仕事にすることを1つの目標に、E資格受験に向けた勉強や、受講期間終了後も対象講座を自由に受講できる [Aidemy Premium](https://aidemy.net/grit/premium/ "AIが未経験からでも学べる Python専門プログラミングスクールAidemy Premium") (6ヶ月プラン・9ヶ月プラン) の学び放題制度を活用したデータ分析・自然言語処理講座の受講などの勉強を続け、より深くAIの世界にのめり込んでいきたいと思っています。

　本記事がAIアプリ開発に携わる方・これから携わろうとしている方にとって、何か1つでも参考になっておりましたら、執筆者としてこの上ない喜びです。また初学者のため、執筆内容に誤りなどございましたら、ご指摘・コメント賜れますと幸いです。(もちろん参考になったなど、記事に関するコメント・感想も大歓迎です！)
 
　ここまでお付き合いいただき、ありがとうございました。

 # 参考リンク

　[普通の画像データセットに飽きたら、Food-101はいかが？ #Python - Qiita](https://qiita.com/koshian2/items/b2d9c03ece95cf5f280a "普通の画像データセットに飽きたら、Food-101はいかが？ #Python - Qiita")
 　[Food-101：料理カラー写真（アップルパイや餃子など）の画像データセット：AI・機械学習のデータセット辞典 - ＠IT](https://atmarkit.itmedia.co.jp/ait/spv/2012/02/news020.html "Food-101：料理カラー写真（アップルパイや餃子など）の画像データセット：AI・機械学習のデータセット辞典 - ＠IT")