表情認識モデル(EfficientNet)を育てた話

# 概要
* 表情認識ができるDNNモデルを構築しました
* とりあえずアーキテクチャを作って学習しただけのものから始めて、だいたいval_accuracyが0.8くらいまで向上させた時の備忘録です
* 転用先はこちらの記事です
    @Link
* コードはこちらをご覧ください
    https://github.com/yutobomberair/RasPi_Symphonia

# やりたいこと
* 顔の画像から感情ラベルを推定したい
* 特に"楽しい"を推定したい
* 最終的にはRasPiで実装するので、なるべく軽量なモデルで実装したい

# 環境
* ライブラリ
    * Tensorflow2.13系
    * Tensorflow LITEで量子化したかったため（まだできていない）
* アーキテクチャ
    * EfficientetB0を使用
    * 軽量であることに軸を置いてモデル選定をすると、EfficientNetまたはMobileNetの二択となった
    * MobileNetは以前使用したことがあったのでEfficientNetを試してみた
* データセット
    * FER2013を使用
        * https://www.kaggle.com/datasets/msambare/fer2013
    * 7クラスの表情画像をグレースケールで提供されているオープンデータ
    * ラベル: {"Angry", "Disgust", "Fear", "Happy", "Sad", "Surprise", "Neutral"}

# 育成開始(レベルは私の感触で恣意的に付けたものです...)
### Lv.0 モデルの誕生
* 簡単なCNNのモデルを組み、えいやと学習させてみる
* 入力前に正規化処理を実施
* optimizer="Adam", loss_function="categorical_crossentropy"
* trainとvalidationは9:1で分割
* 学習はfit関数を用いて行う（パラメータはデフォルト値）
    |引数名|役割|デフォルト値|
    |:-----:|:-----:|:-----:|
    |x|入力データ|なし(必須)|
    |y|出力ラベル|なし(必須)|
    |batch_size|1ステップあたりの学習サンプル数|32|
    |epochs|学習回数|1|
* 学習結果→val_accuracyが約0.15くらい

    ``` EmotionalClassifierLearning.py
    def __create_model(self)
        model = tf.keras.Sequential([
            tf.keras.layers.InputLayer(input_shape=(48, 48, 1)),
            tf.keras.layers.Conv2D(32, 3, activation='relu'),
            tf.keras.layers.MaxPooling2D(),
            tf.keras.layers.Conv2D(64, 3, activation='relu'),
            tf.keras.layers.MaxPooling2D(),
            tf.keras.layers.Flatten(),
            tf.keras.layers.Dense(128, activation='relu'),
            tf.keras.layers.Dense(7, activation='softmax') 
            ])
        return model
    ```

### Lv.5 callback関数の設定
* 下記3つのcallback関数を使用してきちんとパラメータを管理する
    |Callback名|説明|
    |:-----:|:-----:|
    |EarlyStopping|検証精度や損失が改善されない場合に学習を打ち切る|
    |ReduceLROnPlaeau|指定回数改善がなかった場合、自動で学習率を減少させる|
    |TensorBoard|学習経過の可視化を行う|


* fit関数のcallback引数に下記関数の返り値を指定する
    * ※loggerは別途記述しています
    * ※興味がある方はgitからコードを確認してください
* 今回からepoch数は50に変更
* 学習結果→val_accuracyが0.5くらいまで向上

    ``` EmotionalClassifierLearning.py
    def __callbacks(self):
        early_stopping = EarlyStopping(
            monitor='val_accuracy',    # 検証用ロスを監視
            min_delta=1e-3,            # 「改善」と見なす最小の変化
            patience=15,               # 改善が見られないエポック数
            verbose=1,                 # ログ出力あり
            mode='auto',               # 自動で最小化 or 最大化を判定
            baseline=None,             # 指標の初期基準値（指定しない）
            restore_best_weights=True  # 学習終了時に最良の重みに戻す
        )
        reduce_lr = ReduceLROnPlateau(
            monitor='val_accuracy',    # 検証用ロスを監視
            factor=0.5,                # 学習率を半分にする
            patience=7,                # 改善が見られないエポック数
            verbose=1,                 # ログ出力あり
            mode='auto',               # 自動判定（'min' or 'max'）
            min_delta=1e-3,            # 「改善」と見なす最小の差分
            cooldown=2,                # 学習率変更後、待機するエポック数
            min_lr=1e-6                # 最小の学習率（これ以下には下げない）
        )
        tensorboard = TensorBoard(log_dir='./TensorBoard', histogram_freq=1)
        logging_callback = LoggingCallback(self.logger)
        return [early_stopping, reduce_lr, tensorboard, logging_callback]
    ```
### Lv.25 ラベルの縮約とdata-augmentation
* ラベルの縮約
    * "楽しい"を推定したいのに対して、7クラスの分類問題は厳しすぎるのでは？と推測
    * 縮約しすぎるとそれはそれで誤検出などの問題が起きそうだったので、{’happy’, ‘neutral+surprise’, ‘angry+disgust+fear+sad’}とまとめて、
    * 新たに{'happy', 'neutral', 'negative'}の3クラス分類問題とした
* data-augmentation
    * データセットを分析してみると各ラベルのサンプル数がかなりバラバラ
    * ラベル縮約をした状態で、
    * {’happy’, ‘neutral+surprise’, ‘angry+disgust+fear+sad’}={8989, 4002+6198, 4953+547+5121+6077}
    * これだと学習に偏りが出てしまうと思ったので、左右反転・並行移動・輝度変化・コントラスト変化をつけてサンプル数を水増しを実施
* この2つが見事にハマって学習精度は→val_accuracyが0.75くらいまで向上
* だいぶ使えるレベル感になってきました
    ``` EmotionalClassifierLearning.py
    # ラベルの縮約
    def __data_loader(self, dpath, input_size):
        data = pd.read_csv(dpath / "fer2013.csv")  # kaggleから取得したCSV形式を想定
        pixels = data["pixels"].tolist()
        X = np.array([cv2.resize(np.fromstring(p, sep=' ').reshape(48, 48, 1), (input_size, input_size)) for p in pixels])
        X = X.astype('uint8')
        # 変換マップ（7→3クラス）
        conversion_map = {
            0: 2,  # Angry → Negative
            1: 2,  # Disgust → Negative
            2: 2,  # Fear → Negative
            3: 0,  # Happy → Happy
            4: 2,  # Sad → Negative
            5: 1,  # Surprise → Neutral
            6: 1   # Neutral → Neutral
        }
        Y_mapped = np.vectorize(conversion_map.get)(data["emotion"]) # remap
        # log出力
        values, counts = np.unique(Y_mapped, return_counts=True)
        self.logger.info(f"Train-Dataset->{self.emotion_label[values[0]]}:{counts[0]}, {self.emotion_label[values[1]]}:{counts[1]}, {self.emotion_label[values[2]]}:{counts[2]}")
        return X, Y_mapped

    # 水増し
    def __data_augmentation(self, X, Y):
        # Augmentationパイプライン
        augmentor = augmenters.Sequential([
            augmenters.Fliplr(0.5),             # 左右反転
            augmenters.Affine(translate_percent={"x": (-0.1, 0.1), "y": (-0.1, 0.1)}), # 平行移動
            augmenters.Add((-20, 20)),          # 輝度の変化
            augmenters.Multiply((0.8, 1.2))     # コントラスト
        ])
        # 0:Happyと1:Neutralだけを選択
        indices_to_augment_0 = np.where(np.isin(Y, [0]))[0]
        indices_to_augment_1 = np.where(np.isin(Y, [1]))[0]
        aug_0 = len(Y) - 2 * len(indices_to_augment_0) - len(indices_to_augment_1)
        aug_1 = len(Y) - len(indices_to_augment_0) - 2 * len(indices_to_augment_1)
        # データを抽出: 最大サンプル数に合わせてデータ拡張
        indices_to_augment_0_smp = np.random.choice(indices_to_augment_0, size=aug_0, replace=False)
        indices_to_augment_1_smp = np.random.choice(indices_to_augment_1, size=aug_1, replace=False)
        # augmentation
        X_aug_target_0 = X[indices_to_augment_0_smp]
        Y_aug_target_0 = Y[indices_to_augment_0_smp]
        X_augmented_0 = augmentor(images=X_aug_target_0)
        X_aug_target_1 = X[indices_to_augment_1_smp]
        Y_aug_target_1 = Y[indices_to_augment_1_smp]
        X_augmented_1 = augmentor(images=X_aug_target_1)
        X_augmented = np.concatenate([X_augmented_0, X_augmented_1], axis=0)
        Y_aug_target = np.concatenate([Y_aug_target_0, Y_aug_target_1], axis=0) 
        # 元データと結合
        X_combined = np.concatenate([X, X_augmented], axis=0)
        Y_combined = np.concatenate([Y, Y_aug_target], axis=0)
        # log出力
        values, counts = np.unique(Y_combined, return_counts=True)
        self.logger.info(f"Train-Dataset(after augmentationed)->{self.emotion_label[values[0]]}:{counts[0]}, {self.emotion_label[values[1]]}:{counts[1]}, {self.emotion_label[values[2]]}:{counts[2]}")
        return X_combined, Y_combined
    ```

### Lv.35 EfficientNet導入
* ここまではChatGPTに適当に作ってもらったモデルを使っていたので、実績のあるモデルを導入してみる
* RasPiに実装するという制約があるので、軽量という観点でモデルを探したところ、EfficientNetとMobileNetの2つがヒットし、MobileNetは触ったことがあるという理由でEfficientNetを使用
* 中でも最も軽量なB0を選択しました
    * 下記128x128でbatch=32で学習した際の目安です
    
    |モデル|パラメータ数|GPU使用メモリ|
    |:-----:|:-----:|:-----:|
    |EfficientNetB0|約5M|約1~1.5GB|
    |EfficientNetB3|約12M|約2~3GB|
    |EfficientNetB7|約66M|約8GB以上|
* 後の量子化の観点など諸々検討し、一旦192x192入力に設定
* これにより学習結果→val_accuracyが0.78程度まで向上
* 今回はここまで。
    ``` EmotionalClassifierLearning.py
    def __create_model_efficient_lite0(self, input_size=128, num_classes=3):
        inp = tf.keras.layers.Input(shape=(input_size, input_size, 1)) # 入力：128×128×3
        inp_3 = tf.keras.layers.Concatenate()([inp, inp, inp])
        base = tf.keras.applications.EfficientNetB0( # EfficientNetB0 を Lite0 相当に軽量化
            input_tensor=inp_3,
            include_top=False,
            weights=None,       # ImageNet 事前学習不要 or 利用不可なら None
            pooling='avg'
        )
        x = base.output
        x = tf.keras.layers.Dropout(0.2)(x) # Dropout で過学習防止
        out = tf.keras.layers.Dense(num_classes, activation='softmax')(x) # 出力層
        model = tf.keras.Model(inputs=inp, outputs=out, name='EfficientLite0')
        return model
    ```

# 最終精度
* そんなにバタついている印象もなく、綺麗に学習できていそう。
![スクリーンショット 2025-06-11 1.20.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3539329/2db91b58-c899-487b-bf65-8cef218833da.png)

# 感想
* 入力画像がグレースケールなのに対して、EfficientNetがRGB入力を想定している。その辺の対応関係を考えてもMobileNetを素直に使っておけば良かったかも。
* あと48x48で十分視認できる解像感だったので入力サイズにはそこまでこだわらなくて良いかもしれない。
* 一旦、頭で思いつく一般的な施策を試してみたところなのでKaggleのページなどを見てみてさらに成長させたい。
* ただ現状検知処理がかなり遅い。量子化対応は必須。
* （次は評価ConfusionMatrixとか使ってちゃんとやります）