顔画像から年代を推定するアプリを作ってみた

<font color="green">＊このブログはAidemy Premiumのカリキュラムの一環で、受講修了条件を満たすために公開しています。</font>
# 自己紹介
イタリアンが好きなただの会社員です。AIについて何も知らないなーと思って、いくつかAI関連資格を取得しましたが、知識があっても現場で応用できないことに気づいたので、Aidemyさんの「アプリ作成講座」を受講しました。今回は作成したアプリの内容について話します。

# 本アプリ作成に至った経緯
##### 迷い道
現場で応用できるようにと意気込んだ手前、しっかりしたアプリを作ろうと思っていました。講座を受け始めた時はこういうの作りたいな、これは役に立ちそうだな、と思い巡らしていました。ところがどっこい、学べば学ぶほど実現出来る出来ないがはっきりしてきて、しばらく悩むことになります。物は試しとYouTubeのサムネイルから再生数を予測したり、10円玉の大きさから物の大きさを測るコインメジャーだったりを作ろうとしましたが、うまくいかず断念しました。いっそエンターテインメント系に振り切ろうと思って、ボディービルダーの画像からポーズ名を予測しようとしました。しかし、これも途中で断念...

##### あるデータとの出会い
壁を前にするたびに自分の無能さを痛感し、簡単なアプリにしようかなとkaggleのデータセットを眺めていたら、ふと目に入った物がありました。年齢_性別_人種で分けられた顔画像のデータセットです。顔から年齢を予測するアプリは結構ありますが、男女や年齢で分けて、モデルが画像のどこを見てそう判断したかを分析するのは価値があると思い、本アプリ作成に取り組むこととなりました。今回はアジア人のみで学習し、Grad-CAMについては別の記事でお話しします。

# データについて
##### データソース
こちらがkaggleで見つけたデータです。
https://www.kaggle.com/datasets/shanmukh05/agedetection
画像データ名が【年齢-性別-人種】になっていて、それぞれ番号が割り振られています。全部で23,700個の顔画像が入っています。

[**年齢**]
0～116
[**性別**]
0：男性
1：女性
[**人種**]
0：White
1：Black
2：Asian
3：Indian
4：Others (like Hispanic, Latino, Middle Eastern)

例）27_1_2_***　→　アジア人の27歳の女性
今回はアジア人が対象なので、人種2のみのデータで学習をしました。

##### 軽くデータ分析
アジア人のみだとデータは3433個、男性46%、女性54%となっていました。下の図は年齢別のデータ数のグラフです。
![age.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3681019/aa3374ad-0e99-5acd-74cb-9e9ce75b57b6.png)
1歳辺りと20代が多く、40歳以降はどの年代も25個ぐらいのデータ数となっていました。結構ばらつきが大きいので、前処理が必要ですね。

# アプリ作成
##### コード
Google Colaboratory上でkagle APIでデータを取得し、train70%とtest30%に分けてから以下のコードを実行しました。アプリをRenderにデプロイする関係上、データ容量を抑えるためにMobileNetを転移学習させました。
~~~
import os
import cv2
import numpy as np
import matplotlib.pyplot as plt
from keras.applications import MobileNet
from keras.models import Model
from keras.layers import GlobalAveragePooling2D, Dense, Dropout
from keras.optimizers import Adam
from keras.preprocessing.image import ImageDataGenerator
from sklearn.model_selection import train_test_split
from keras.utils import load_img, img_to_array
from keras.metrics import RootMeanSquaredError
from sklearn.preprocessing import LabelEncoder
from keras.utils import to_categorical

# 画像フォルダのパス
train_path = '/content/dataset/dataset/train'
test_path = '/content/dataset/dataset/test'

# 画像格納用の空のリスト
train_images = []
test_images = []

# 年齢ラベル格納用の空のリスト
train_ages = []
test_ages = []

# 画像と年齢ラベルを取得
def load_data(data_dir):
    data = []
    labels = []

    for filename in os.listdir(data_dir):
        # 画像ファイル名の最初の文字がラベル
        label = int(filename.split('_')[0])  
        
        # 0～30歳の場合
        if 0 <= label < 30 and labels.count(label) < 8:
            img = cv2.imread(os.path.join(data_dir, filename))
            img = cv2.resize(img, (224, 224))  # 画像サイズを調整
            img = img.astype(np.float32) / 255.0  # 画像データを0から1の範囲に正規化
            data.append(img)
            labels.append(label)

        # 30～60歳の場合
        if 30 <= label < 60 and labels.count(label) < 10:
            img = cv2.imread(os.path.join(data_dir, filename))
            img = cv2.resize(img, (224, 224))  # 画像サイズを調整
            img = img.astype(np.float32) / 255.0  # 画像データを0から1の範囲に正規化
            data.append(img)
            labels.append(label)

        # 60歳～の場合
        elif label >= 60:
            img = cv2.imread(os.path.join(data_dir, filename))
            img = cv2.resize(img, (224, 224))  # 画像サイズを調整
            img = img.astype(np.float32) / 255.0  # 画像データを0から1の範囲に正規化
            data.append(img)
            labels.append(label)
    return np.array(data), np.array(labels)

# 画像データを収集
train_data, train_label = load_data(train_path)
test_data, test_label = load_data(test_path)

# 新しいラベルを生成
def generate_labels(labels):
    new_labels = []
    for age in labels:
        if age >= 0 and age < 30:
            new_labels.append("0～30歳")
        if age >= 30 and age < 60:
            new_labels.append("30～60歳")
        if age >= 60:
            new_labels.append("60歳以上")
    return new_labels

# 年代ごとの新しいラベルを生成
train_label_age_category = generate_labels(train_label)
test_label_age_category = generate_labels(test_label)

# ラベルを数値にエンコード
label_encoder = LabelEncoder()
train_label_encoded = label_encoder.fit_transform(train_label_age_category)
test_label_encoded = label_encoder.transform(test_label_age_category)

# クラスの数を取得
num_classes = len(set(train_label_age_category))

# ラベルをカテゴリカルに変換
train_label_categorical = to_categorical(train_label_encoded, num_classes=num_classes)
test_label_categorical = to_categorical(test_label_encoded, num_classes=num_classes)

#学習データ増し増し
train_datagen = ImageDataGenerator(
    rotation_range=10,
    width_shift_range=0.1,
    height_shift_range=0.1,
    shear_range=0.1,
    zoom_range=0.1,
    horizontal_flip=True,
    fill_mode='nearest'
    )

train_datagen.fit(train_data)

# ベースモデル読み込み（全結合層を除く）
base_model = MobileNet(weights='imagenet', include_top=False)

x = base_model.output
x = GlobalAveragePooling2D()(x)
x = Dropout(0.7)(x)
x = Dense(512, activation='relu')(x)
x = Dropout(0.5)(x)
x = Dense(256, activation='relu')(x)
x = Dropout(0.5)(x)
x = Dense(256, activation='relu')(x)
predictions = Dense(num_classes, activation='softmax')(x)

model = Model(inputs=base_model.input, outputs=predictions)

# ベースモデルの層を凍結
for layer in base_model.layers:
    layer.trainable = False

batch_size = 32
epoch = 150

# モデルをコンパイル
model.compile(optimizer=Adam(), loss="categorical_crossentropy", metrics=["accuracy"])

# トレーニング履歴を保存
history = model.fit(train_data, train_label_categorical, batch_size=batch_size, epochs=epoch, validation_data=(test_data, test_label_categorical))

# トレーニングとテストの損失を取得
train_accuracy = history.history['accuracy']
test_accuracy = history.history['val_accuracy']
train_loss = history.history['loss']
test_loss = history.history['val_loss']

plt.figure(figsize=(12, 6))

# 精度の可視化
plt.subplot(1, 2, 1)
plt.plot(train_accuracy, label='Training Accuracy', marker='o')
plt.plot(test_accuracy, label='Validation Accuracy', marker='o')
plt.title('Training and Validation Accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()
plt.grid(True)

# 損失の可視化
plt.subplot(1, 2, 2)
plt.plot(train_loss, label='Training Loss', marker='o')
plt.plot(test_loss, label='Validation Loss', marker='o')
plt.title('Training and Validation Loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.grid(True)

plt.show()
~~~

当初は年齢を直接予測するモデルを作ろうとしていましたが、全く精度が上がらず、タスクをどんどん簡単にしていった結果、0歳～30歳、30歳～60歳、60歳以上という分類タスクである程度良い精度が得られました。上述のように、データにばらつきがあるので、年齢ごとに学習データを抑えるようにしました。具体的には0～30歳はそれぞれ7枚以下、30～60歳はそれぞれ9枚以下、60歳以上は制限なしとしました。

##### 精度
結果的に77%の正解率でストップしました。MobileNetV2や他のモデルだと同じ条件下ですぐに過学習しました。モデルによって得手不得手があるのがよく分かりました。

![精度と損失.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3681019/cb08fbfa-ab24-1e98-1452-48df515b81f7.png)

##### 混同行列
予測と正解の混同行列のヒートマップです。縦軸が正解（actual）、横軸が予測（predict）です。0、1、2はそれぞれ0歳～30歳、30歳～60歳、60歳以上を表しています。

![混同行列.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3681019/0b37fa14-b58b-796a-4325-e68d67b1db39.png)

2の予測を見ると全て正しく予測していますが、正解の2を見ると1に30個予測されていました。つまり60歳未満を60歳以上と予測することはありませんが、60歳以上を30歳～60歳と予測してしまう可能性があるということになります。若く見える人もいるということで、ある意味この間違いは喜ばしいかもしれません。一方、0歳～30歳を30歳～60歳と予測してしまうのも16個あり、これは全く喜ばしくありません。30歳～60歳をさらに10歳ごとに分割したり、60歳ではなく50歳を境目にしたり、工夫のし甲斐がありますね。

# 検証
データセットから適当に選んだ以下の画像を予測してみます。

![38_1_2_20170116161943473.jpg.chip.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3681019/73613c2d-0e89-31cb-1e1a-45d7925c9b5d.jpeg)

結果は、0～30歳と出ました。正解は38歳なので、不正解です。やはり30～60歳は判別が難しいのかもしれません。

# まとめと今後やりたいこと
kaggleで見つけた顔のデータセットを使って、0～30歳、30～60歳、60歳以上のどれなのかを判別するモデルを作りました。正解率は77%で、精度を上げるには0～30歳や60歳以上を30～60歳と予測してしまうことを改善する必要がありそうです。モデルの大きさや年齢のばらつき、画像内容そのものなど、色々工夫できるものはやってみようと思います。

ここまで読んでいただき、ありがとうございました。
次の記事ではGrad-CAMについて話す予定です。
次回もお楽しみに。