LoginSignup

Why not login to Qiita and try out its useful features?

We'll deliver articles that match you.

You can read useful information later.

84
50

顔写真から自閉症を判別してみた

Last updated at Posted at 2024-07-31

はじめに

この記事では今回開発したWebアプリ、自閉症識別(後に理由を説明しますが、動作が大変モッサリです)を公開するまでの経緯や考え・思いをまとめた。
6月中旬に差し掛かる頃から、Aidemy PewmiumのAIアプリ開発コースで、Pythonを用いてアプリ開発を行えるようになることを目標に学んできた。その成果として開発したのが、顔写真から自閉症を判別するWebアプリだ。
この記事では私自身がプログラミング超初心者として、そしていち支援者として感じたことも多く綴っているため、必要に応じて適宜読み飛ばしてもらえると良いかもしれない。

このブログはAidemy Premiumのカリキュラムの一環で、受講修了条件を満たすために公開しています。

開発開始に至るまで

私はこちらの記事にあるように、保育士として児童発達支援に関わってきた。大変ではあるが非常に楽しい仕事だった。とはいえAidemyの講座受講中、成果物を何にするかをずっと考えていたが、この領域で何かやろうなんてことは全く考えていなかった。
では何故今回このようなアプリを開発することにしたかというと、見つけてしまったからと言う他ないだろう。

成果物を作る段階になった時、何を作れば良いのか全く思いつかなかった。Aidemyではチューターさんへの相談が可能なので、素直にその旨を伝えたところ、まずは使えそうなデータセットを探してみると良いということで、kaggle (データセットを探すツールの中で、私はkaggleが一番見やすく使いやすかったが、英語なのでChlomeの一括翻訳機能を用い探した)で使えそうなもの・興味のあるものを片端からリストアップした。クラゲの分類や部屋が片付いているか否か等、様々リストアップした中にあったのが、自閉症(児)の顔画像データセットだった。
正直、クラゲの分類や部屋が片付いているか否かは面白味がないと思っていた。とはいえ、自閉症を顔で分類というのは、前述の記事にも書いたが、現代日本の福祉(医療)においては異端かつ困難だと考える。機械学習を用いたところで(初心者なら尚更)困難であろうことが変わらないことは容易に想像がついた。それでもチューターさんの後押しもあり、自閉症識別のWebアプリを開発することに決めた。

目的

このアプリの開発は、「異端かつ困難だが、できたら役に立つ夢のようなツールだ」という私の興味関心が先行しているため、目的は後付けになってしまうことを先に白状しておく。

コスト削減

時間的コスト

自閉症の診断にかかる時間的コストをご存知だろうか。この時間的コストが、診断に関わる者や当事者・家族ら誰にとっても最も膨大だと私は考えている。

まず、現在日本で診断ができるのは医師のみであることを明示しておく。その上で特に小児の分野では診察の予約が取れないという話をよく聞く。診断できる医師にありつくのにそもそも時間がかかるのだ。
次に、診断に必要な情報の準備にも時間がかかる。診察でのヒアリングは勿論だが、ヒアリングのみで診断するという例はあまり聞かない。小児の場合、田中ビネーや新版K式といった検査(いわゆるIQ等が出る)を取り、その結果も含めて成育歴・生活環境・困り感等が総合的に考慮された上で診断される。検査時間はケースによるが、小一時間はかかると考えて良いだろう。更に、この検査は誰でも行えるわけではないので、検査可能な施設や病院を探し、予約を取る必要もある。そして検査をしてその場で情報がもらえるわけではなく(ざっくりと教えてもらえる場合もあるが)、検査者が検査結果を数値化し、数値や検査時の状況から所見を書き、それを後日渡すというシステムがおそらく一般的だ。
私は検査を取れないが、同僚が行っていた。所見を書く時に「〇〇さんの保護者に語弊なく伝わるだろうか」といったことをよく悩んでいた。保護者や医師に状況が適切に伝わるよう、しかし様々な配慮をしながら書かなければならない書類というのはかなり大変な作業になるだろう。更に言うとPCでの作業が苦手、あるいは環境がない検査者の場合、全て手書きで所見を出すこともあるというのだから、検査をする側の時間的コストは計り知れない。
蛇足ではあるが、かくいう私も成人期に自閉症を含めた発達障害と呼ばれる物や精神疾患等の診断のための検査を受けたことがあるが、2時間近くかかり、非常に疲れた記憶がある。時間と共に精神的な負担も大きいのが検査だ。子供が受けるとなれば(年齢によって検査の種類や内容は異なるが)、より大きな負担となることが予想される。

ここまででお分かりかと思うが、「わが子が自閉症かもしれない」と思った時、地域や運、これまでの繋がりといったケースにもよるが、どんな場合であっても少なくともその日その時に診断されることはまずないのだ。下手をすれば月単位で待つことになる。その間、不安を増幅させるような情報、あるいはレアケースへの過度な期待をさせるような情報にはいくらでもアクセスできるのが現代社会である。

費用的コスト

前述した診断や検査はもちろん無料ではない。病院や、検査の種類や行う場所等で変動するが、医療費三割負担で数百円!とはいかない。
診断に至るまでに複数の専門家が関わるので、専門家を雇う費用というのも各事業所等にはかかっている。

コストについて
私のまだまだ浅い経験で見てきたことや感じたことを元にしています。全く裏付けがないわけではないですが、各自治体や医療機関、事業所等によって制度・費用の違いが考えられます。

誤診リスクの軽減

冒頭で述べた通り、今回のアプリは私の現場での肌感だと夢のようなツールになってしまうわけではあるが、これが本格的に運用できるレベルまで到達すれば、誤診のリスク軽減にもつながるのではないかと思う。
体系化された検査も参考にしているとはいえ、所見を書くのも診断するのも人間であり、主観もあればミスもある。更にはヒアリングの情報に主観が入っていると言わずもがな診断のノイズとなってしまう。AIの場合は開発を適切に行うことができれば、主観というノイズなしに判定ができるだろう。

ただし……

自閉症は現在、自閉スペクトラム症(ASD)と呼ばれることが多い。特性はグラデーションのようになっており、こってこての自閉症から、健常者とほとんど変わらない人まで様々で、誰しもがスペクトラムの中にいると言われることもある。(私が自閉症の顔写真判定が困難だと思った大きな理由である)
更にはADHDや知的障害、ダウン症等と併発している場合もあるし、年齢が上がるとともに二次障害が出てきたり、自閉症と似た症状の別の障害を発症している可能性が高まったりする。
そう考えると、そもそも純粋な自閉症という物をどう定義するのか、分類できるのかという問題を感じないだろうか。
まとめのような文言になってしまうのだが、人の力だろうがAIの力だろうが、自閉症であると診断された後に支援を行うのは人間であるということを強調しておきたい。
「“自閉症だから”こう」「“自閉症だから”できない」なんてことはない。適切な支援を受ければできることや、周囲が気づけば長所となる特性というのはたくさんある。逆に自閉症だから誰しも特別な能力(近年ギフテッドと呼ばれるアレ)を持っているというわけでもない。
自閉症に限らず、障害を持つ人に必要な支援はいつでも個々にカスタマイズされている必要があり、そうした支援が本人や周囲を幸せにするのだと私は考えている。その支援や、支援にありつくためのサポートをする手段の一つとしてAIが活用されることに私は期待したい。AIによる識別が今後、過度な不安や期待といった決めつけや、差別・いじめ等を決して生まない、助長しないことを切に願う。

実行環境

さて、本題に入っていく。
今回の実行環境は以下の通りだ。

  • ASUS Vivobook M3604YA-MB105WS
    OS:Windows 11Home
  • モデル構築
    言語:Python3
    使用サービス:Google Colaboratory
  • Webページデザイン
    言語:html, css
    ウェブフレームワーク:Flask 3.0.3
    使用サービス:Visual Studio Code
  • アプリのデプロイ
    使用サービス:コマンドプロンプト,GitHub,Git Large File Storage, Render

今回Aidemyの講座を受講するにあたって、PCを新調した。私はiPhoneユーザーではないし、ユーザーになる予定もないので、幼いころから慣れ親しんだWindowsを選択した。ちなみにWindowsでも問題なくアプリ開発の環境が整うことに驚く程度には初心者である。(よく考えてみれば当たり前)

準備

テーマ決め

難航するかと思われたものの、チューターさんのアドバイスであっさり解決した。データセットを数時間眺めてリストアップし、チューターさんに相談して一つに絞った。
それが今回の自閉症の判別だ。

データの収集

これについてはやりたいことよりも先にデータセットを決めていたため、そのデータセットをGoogle Driveに保存するだけで終了した。偉大なる先人に感謝である。

Google colabからDriveのデータセットへアクセスできるようにする

正直これは以後の章に書くべきか悩んだが、私としてはこのあたりまではまだ準備段階だったのでこの項目に記載していく。

from google.colab import drive
drive.mount('/content/drive')

まずは上記コードをを実行し諸々の権限を許可することで、ドライブをマウントする。これで自分のGoogle Driveの中からファイルを引っ張ってくることができる。
ちなみにこの方法にたどりつくまで、どう検索したら良いのか、何が書いてあるのかがわからずに、結構な時間を使ってしまった。初心者め……

画像の前処理

AIに学習させるための前処理として、画像を使える形に整えなければならない。それに際していよいよそれらしいコードを書き始めるわけだが、基本的にはAidemy講座内の男女識別添削課題で書いたコードを転用しているので、さほど大変ではない……はずだった。

#必要なモジュール等をインポートする
import os
import cv2
import numpy as np
import tensorflow as tf


# 自閉症児・非自閉症児それぞれのファイルを取得
path_autistic = os.listdir("/content/drive/My Drive/aidemy成果物/autism/train/autistic")
path_non_autistic = os.listdir("/content/drive/My Drive/aidemy成果物/autism/train/non_autistic")


# 自閉症児・非自閉症児それぞれの画像を横50*縦50にリサイズしてリストに格納する
img_autistic = []
img_non_autistic = []

for i in range(len(path_autistic)):
    img = cv2.imread('/content/drive/My Drive/aidemy成果物/autism/train/autistic/' + path_autistic[i])
    b,g,r = cv2.split(img)
    img = cv2.merge([r,g,b])
    img = cv2.resize(img, (50,50))
    img_autistic.append(img)

for i in range(len(path_non_autistic)):
    img = cv2.imread('/content/drive/My Drive/aidemy成果物/autism/train/non_autistic/' + path_non_autistic[i])
    b,g,r = cv2.split(img)
    img = cv2.merge([r,g,b])
    img = cv2.resize(img, (50,50))
    img_non_autistic.append(img)

# 画像データのリスト2つを統合し、numpy配列に
X = np.array(img_autistic + img_non_autistic)

コードをよく見てもらうとわかるのだが、ファイル名にtrainとある。今回使用するデータセットは、学習用データとバリデーションデータ、それから検証データに分かれており、とりわけ学習用データは自閉症と非自閉症で各1263枚の写真の多くがカラーで、物によっては一辺300ピクセルを超える形で格納されており、データ量がかなり大きかった。なので一旦、学習用データの中でバリデーションデータも賄ってしまおうと思ったのだ。

しかし実行してみると二つ目のforループから一向に進まないではないか。エラーが出ていないので進んではいるはずだ。データ数が多いのだろう。それにしても終わらない。初心者はチューターさんに相談した。
その結果、とりあえずfor i in range(len(path_autistic))ここをfor i in range(len(path_autistic[:100]))等として、取り込む枚数を制限して先に進もうということになった。for i in range(len(path_non_autistic))も同様の記述に訂正した。様々なことを試しながら行う限界としては各500枚くらいだった。

また、この先にモデルの記述も入れていたのだが、一つのセルに全て納めた状態で試行錯誤すると何度もこの処理を実行することになる。つまり時間がものすごくかかることを毎回やらなければならない。この点についてもチューターさんからアドバイスをもらった。
上記コード最終行で画像の一覧はnumpy配列として数字の列に変換されている。であれば、下記のコードを追記し、以降を別のセルに記述すると良いとのことで、やってみた。

#1回の実行毎に画像を読み込むと時間がかかるので、numpy配列としてセーブ
np.save('X.npy', X)

実行しても目に見えた変化はないが、セルを追加し序盤に次の記述をする。

# 先のセルでセーブした配列を読み込む
loaded_X = np.load('X.npy')
X = loaded_X

次のセルで上記を実行するとnumpy配列として先のセルで保存された画像の情報が、numpy配列として読み込まれる。先のセルで実行した内容は次のセルに反映されるので、毎回画像を読み込んで変換してといった時間のかかる作業をしなくても良いのだ。(このコードだけを実行しても実行結果には何も出てこない)

「これで画像の準備は整った!あとは男女識別で使ったモデルを参考に(ほぼ丸パク)して、ちょちょいといじれば完成だ!」

初心者はそんなことを考えていました。あまりにも甘かった……大変なことをするってわかっていたはずなのに、なんでそんな甘いことを思ったのだろうか。
先に言うと、この画像の処理をするセルも後々いじることになる。

初期モデル

モデルを構築するにあたってまずしたことは、Aidemyの講座内で提出した課題、男女識別をするモデルをほぼそっくりそのまま持ってくることだった。内容としてはVGG16モデルを用い、転移学習を行う物だ。
以下がそのモデルの(正確には性能を評価するための諸々も入っている)コードになる。

#必要なモジュール類のインポート
import matplotlib.pyplot as plt
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.layers import Dense, Dropout, Flatten, Input
from tensorflow.keras.applications.vgg16 import VGG16
from tensorflow.keras.models import Model, Sequential
from tensorflow.keras import optimizers


# vgg16モデルを使う準備
# 重みはimagenetで学習済の重さ
# 転移学習を行うため、全結合層はFalseを指定
# 使用する画像は50*50でrgb三層ある画像
vgg16 = VGG16(weights='imagenet', include_top=False, input_shape=(50, 50, 3))


# オリジナルのモデルを構築する
# モデルはSequentialを使う
top_model = Sequential()
# オリジナルのモデルの入力層をvgg16からの出力に対応させる
top_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
# 層を追加していく
top_model.add(Dense(400, activation='relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(150, activation='relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(2, activation='sigmoid'))

# vgg16とtop_modelを結合する
model = Model(inputs=vgg16.input, outputs=top_model(vgg16.output))

# 19層までをvgg16で学習
for layer in model.layers[:19]:
    layer.trainable=False

# コンパイルする
model.compile(loss='binary_crossentropy',
              optimizer=optimizers.SGD(learning_rate=1e-4, momentum=0.9),
              metrics=['accuracy'])


# モデルに学習させる
history=model.fit(X_train, y_train,
          batch_size=25,
          epochs=150,validation_data=(X_test, y_test))

# モデルの性能評価
scores = model.evaluate(X_test, y_test, verbose=1)
print('Test loss:', scores[0])
print('Test accuracy:', scores[1])

#グラフの描画
plt.plot(history.history["accuracy"], label="acc", ls="-")
plt.plot(history.history["val_accuracy"], label="val_acc", ls="-")
plt.ylabel("accuracy")
plt.xlabel("epoch")
plt.suptitle("model", fontsize=12)
plt.legend()
plt.show()

その結果がこちら。

初期モデル.png

Test loss: 0.6393830180168152
Test accuracy: 0.6650000214576721

グラフ、伸びてはいるけど上下しすぎじゃない?損失関数も正解率も到底完成とは言えない物だ。
その後私はユニット数や層の数、dropoutの数等を調整したが、結果はおおよそ変わらなかった。今となればなんとなく想像がつくだが、データ数を増やしたりエポック数を増やしたりしても当然変わらなかった。特にエポック数に関しては、グラフの後半を見ればこれ以上の正解率の伸びは見込めなさそうだと今なら思えるが、当初の私はそれすら理解できていなかった。

そうこうしている時、あることに気づいた。
「これ、画像サイズ小さすぎだしいびつ過ぎないか?」
男女識別ならまだ特徴に大きな差があるので、小さくても差がわかりやすいかもしれない。だが今回は事前にわかっていたように、正直目で見てわかる程簡単な識別ではない。モデルにはできる限りわかりやすいデータを渡すべきだろう。
縦横が同じピクセル数である必要はないし、元のデータも縦横比は様々で、大きいものでは一辺300ピクセル超えするデータセットだ。データセット内の写真のサイズを合わせるか、モデルに渡す際の縦横比や大きさを平均的な数字でそろえるべきではないか。
今回は後者を選ぼうと思いつつ、チューターさんに相談した。
結論として、画像のサイズはやはり調整する価値があった。なので準備段階で作った先のセルも、調整しながら漏れがないように書き換える作業が発生した。
そして縦横150×120にした結果がこちらである。

初期モデル画像サイズ調整.png

Test loss: 1.524983525276184
Test accuracy: 0.6650000214576721

どうして!!!!! (心の叫び)
正解率は依然として伸びず、損失関数は増えている。
しかしチューターさんはこんな結果も想定内だったのだろう。画像サイズの件を相談した際、細々した数字を調整すること以前にVGG16以外のモデルを検討する必要もあるかもしれないということを教えてくれていた。
初心者は当然「え?VGG16以外って何があるの?どうやって探すの?」と思ったが、チューターさんはとても親切なのでQiitaで画像認識や転移学習等と調べると色々出てくるということまで教えてくれた。

学習済みモデルと層

VGG16しか知らなかった初心者の私は、さっそくQiitaで画像認識や転移学習、学習済みモデルといった心当たりのあるワードで調べ、使えそうなモデルを探した。コードを読むのも一苦労、上級者向けと思われる記事もたくさん出てくる中で、どうにかいくつかの学習済みモデルにたどり着いた。

VGG19

最初に見つけたのはVGG16の上位互換のようなモデル、VGG19だった。VGG16は16層で構成されているが、VGG19は19層でできているらしい。16ってそういうことだったのか……ちなみにVGG19を使っても特に大きな変化は見られなかった。
ただ、この“19層でできている”“16層でできている”ということを認識したことである疑問が生まれた。

# 19層までをvgg16で学習
for layer in model.layers[:19]:
    layer.trainable=False

ここまで“とりあえずやってみながら理解を深めよう精神”で取り組んできたので、なんの疑問も覚えずに書いていたこのコードの意味である。
実はこの後の層を追加していく工程、私は6層追加しているが、元々の受講内容では

top_model = Sequential()
top_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
top_model.add(Dense(256, activation='relu'))
top_model.add(Dense(10, activation='softmax'))

上記の通り、3層しか追加されていなかったのだ。16+3=19そういうことか!
この気づきの裏付けは、ChatGPTに訊いて行った。その気づきから、私はこのfor layer in model.layers[:19]:の部分も調整することにした。

EfficientNetシリーズ

次に見つけたのがこのシリーズだ。

タイトルからして強そうな記事を見て調べてみた。そもそもこの記事自体がタイトルも冒頭部分もわかりやすく、初心者にもこのモデルがかなり優秀なようだということが理解できるようになっていたのが非常にありがたかった。そうでなければ恐らく見るのをやめていて、私はこのモデルに驚くこともなかっただろう。
更には親切なことに次のバージョンを解説している記事へのリンク2021年最強になるか!?最新の画像認識モデルEfficientNetV2を解説までつけてくれているというありがたさである。偉大なる先人に大感謝だ。
あとはこのEfficientNetV2というワードで検索をかければ、活用している事例が出てくるはずなので、それを確認して記述したら良い。

ちなみにEfficientNetV2にはS/M/Lとあり、端的に言えばサイズで表されており、大きくなるほどに多くのリソースが必要になってくるが、性能も良くなってくるらしい。ただし、闇雲に大きいものを使えば必ずしも良い結果が出るというわけではないというところにモデルの選択の難しさを感じた。

そんなこんなでモデルについて調べていたら、GlobalAveragePooling2Dという層(詳細は後ほど)にも出会った。このEfficientNetV2とGlobalAveragePooling2Dを取り入れたところ、グラフが変化した。

ダウンロード.png

Test loss: 0.6244659423828125
Test accuracy: 0.6449999809265137

あ、この方向性、なんか良いんじゃない?
損失関数や正解率はそこまで変化がないものの、着実に学習している感を感じるグラフになっている。きっと今度こそ細々した数字を調整していくターンだろう。

Googlecolabとの闘い

ところでだが、ここまでの作業をGoogleColabで行ってきている私の天敵がいる。

敵.png

こうなってしまうとその日の作業は終わったも同然となってしまう。googlecolabから割り当てられたリソースを使い切ってしまい、GPUが使えなくなるからだ。
以下の画像のように、googlecolabではランタイムのタイプを変更することができる。

スクリーンショット 2024-07-28 152823.png

Googlecolabを初めて開いた時にはCPUを使って実行するように設定されているのだが、画像の処理等の負荷の大きな作業をすると驚く程処理が進まなくなる。ChatGPTの解説を嚙み砕くと、CPU,GPU,TPUにはそれぞれ得手不得手・メリットデメリットがあるが、特に画像の処理やディープラーニングではCPUよりもGPUを使った方が良いらしい。つまり、私の作業はGPUが使えること前提だと考えて良いかもしれない。

自前で環境構築することなく無料でGPUを使えることがGooglecolabの特徴の1つだが、無料で提供されるものにある程度の制限は付き物だ。ありがたみを感じつつ、クゥ……!と思いながら明日以降に試したいことをリストアップしたり、ブログを書いてみたりして過ごした。
モデルをEfficientNetV2に変更してからは特に顕著で、モデルが固まってきたと感じる頃には3回程度回したらリソースを使い切っていた。
終盤ではデータセット内の学習データ・バリデーションデータ全てを使っていたこともあり、1回が回りきらずにリソースを使い切ってしまうため、残されたログを読み、限られたリソース内で回りきり、かつ正解率や損失関数が改善する方法を調べたり考えたりする時間の方が、調整する時間よりも多かった。
思いついたことをすぐに試せずにもどかしい思いをしたものだ。

この他にも12時間の制限なんかもあり、終了の手順をきちんと踏んでいなかったために引っかかり、翌日一切作業ができなかった日もあった。そのあたりは多少わかりにくさがあると感じたので、初心者はやらかしてみて覚えるのも手だと思う。一度やらかしたことはきっと覚えるからだ。私は『ランタイムを接続解除して削除』を毎日しつこいくらい確認してから閉じるようになった。
それでも二度目をやらかした。

ちなみに課金をすれば、制限が緩くなるものの、安くはないので今回は見送った。自前での環境構築も、GPUを搭載したPCではないので見送るしかなかった。

最終的なモデル

そうこうしながら仕上がった、最終的なモデルにまつわるコードを紹介する。

学習に使う画像の前処理

#必要なモジュール等をインポートする
import os
import cv2
import numpy as np
import tensorflow as tf
import random
from tensorflow.keras.utils import to_categorical


# 自閉症児・非自閉症児それぞれのファイルを取得
path_autistic = os.listdir("/content/drive/My Drive/aidemy成果物/autism/train/autistic")
path_non_autistic = os.listdir("/content/drive/My Drive/aidemy成果物/autism/train/non_autistic")

# 自閉症児・非自閉症児それぞれの画像を横160*縦200にリサイズしてリストに格納する
img_autistic = []
img_non_autistic = []

for i in range(len(path_autistic)):
    img = cv2.imread('/content/drive/My Drive/aidemy成果物/autism/train/autistic/' + path_autistic[i])
    b,g,r = cv2.split(img)
    img = cv2.merge([r,g,b])
    img = cv2.resize(img, (160,200))
    img_autistic.append(img)

for i in range(len(path_non_autistic)):
    img = cv2.imread('/content/drive/My Drive/aidemy成果物/autism/train/non_autistic/' + path_non_autistic[i])
    b,g,r = cv2.split(img)
    img = cv2.merge([r,g,b])
    img = cv2.resize(img, (160,200))
    img_non_autistic.append(img)

# 画像データのリスト2つを統合し、numpy配列に
X = np.array(img_autistic + img_non_autistic)


#バリデーションデータも同様に処理
# 自閉症児・非自閉症児それぞれのバリデーションデータのファイルを取得
path_valid_autistic = os.listdir("/content/drive/My Drive/aidemy成果物/autism/valid/autistic")
path_valid_non_autistic = os.listdir("/content/drive/My Drive/aidemy成果物/autism/valid/non_autistic")

# 自閉症児・非自閉症児それぞれの画像を横160*縦200にリサイズしてリストに格納する
img_valid_autistic = []
img_valid_non_autistic = []

for i in range(len(path_valid_autistic)):
    img = cv2.imread('/content/drive/My Drive/aidemy成果物/autism/valid/autistic/' + path_valid_autistic[i])
    b,g,r = cv2.split(img)
    img = cv2.merge([r,g,b])
    img = cv2.resize(img, (160,200))
    img_valid_autistic.append(img)

for i in range(len(path_valid_non_autistic)):
    img = cv2.imread('/content/drive/My Drive/aidemy成果物/autism/valid/non_autistic/' + path_valid_non_autistic[i])
    b,g,r = cv2.split(img)
    img = cv2.merge([r,g,b])
    img = cv2.resize(img, (160,200))
    img_valid_non_autistic.append(img)

# 画像データのリスト2つを統合し、numpy配列に
X_valid = np.array(img_valid_autistic + img_valid_non_autistic)


#モデルを比較検討しやすくするために乱数を固定する
def fix_seed(seed):
    # random
    random.seed(seed)
    # Numpy
    np.random.seed(seed)
    # Tensorflow
    tf.random.set_seed(seed)

SEED = 42
fix_seed(SEED)

# 自閉症児を0、非自閉症児を1として目的変数を設定
y =  np.array([0]*len(img_autistic) + [1]*len(img_non_autistic))
y_valid =  np.array([0]*len(img_valid_autistic) + [1]*len(img_valid_non_autistic))

# 画像データと正解ラベルがずれないようにして、順番をランダムに入れ替える
rand_index = np.random.permutation(np.arange(len(X)))
X_train = X[rand_index]
y_train = y[rand_index]

rand_val_index = np.random.permutation(np.arange(len(X_valid)))
X_valid = X_valid[rand_val_index]
y_valid = y_valid[rand_val_index]

# 正解ラベルをone-hotの形にする
y_train = to_categorical(y_train)
y_valid = to_categorical(y_valid)


#モデルの調整毎に画像を読み込むと時間がかかるので、numpy配列をセーブ
np.save('X_train.npy', X_train)
np.save('y_train.npy', y_train)
np.save('X_valid.npy', X_valid)
np.save('y_valid.npy', y_valid)

初期段階との違いとしては

  • 画像のサイズを200×160とした
  • データセット内の学習データとバリデーションデータを全て使うようにした
  • seed値を設定した

この三点になる。変更した理由は以下の通りとなる。

画像サイズ

画像サイズは元の画像の多くが極力いびつな形にならないように5:4の比率にあたりをつけ、リソースが許す範囲で大きくした。これは大きくしすぎるとリソース問題は勿論だが、それはそれで引き延ばされたいびつな画像での学習になるため、元画像がどのようなものなのかを元に調整するべき所だ。

データ量

あるデータは全て活用したい、リソースが許すならば。許せ、許してくれと祈りながら使った。多様なデータが使えれば正確性も上がるだろう。

seed値

seed値を設定しないと、モデルを試行錯誤する上で実行毎に結果がバラバラになってしまう。これはrandomでデータがランダムな入れ替えがさるようにされている部分で、実行毎に文字通りランダムな入れ替えが発生しているためのブレだ。このブレがあると、調整したモデルと調整前のモデルの質の比較が困難になる。
このseed値(今回は42)を設定することで、ランダムな入れ替えは発生するものの、毎回同じ入れ替えパターンが用いられるようになり、変更による結果の比較が適切にできる。(今回のコードでは42番を振られた入れ替えパターン、数値を3にすれば3番を振られた入れ替えパターンが適応されるといった具合)

モデルの構築

さて、モデルに学習させる画像データが整ったので、いよいよモデルの構築である。

#必要なモジュールやモデル等をインポートする
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.layers import Dense, Dropout, Input, GlobalAveragePooling2D
from tensorflow.keras.applications.efficientnet_v2 import EfficientNetV2S
from tensorflow.keras.models import Model, Sequential
from tensorflow.keras import optimizers


#先のセルでセーブしたnumpy配列を読み込む
X_train = np.load('X_train.npy')
y_train = np.load('y_train.npy')
X_val = np.load('X_valid.npy')
y_val = np.load('y_valid.npy')


# EfficientNetV2Sモデルを使う準備
# 重みはimagenetで学習済の重さ
# 転移学習を行うため、全結合層はFalseを指定
# 使用する画像は縦200*横160でrgb三層ある画像
base_model = EfficientNetV2S(weights='imagenet', include_top=False, input_shape=(200, 160, 3))

# 後ろから20層目以降は再学習可能に設定する
for layer in base_model.layers[-20:]:
  layer.trainable = True


# オリジナルのモデルを構築する
# モデルはSequentialを使う
top_model = Sequential()

# 層を追加していく
top_model.add(GlobalAveragePooling2D())
top_model.add(Dense(400, activation='relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(2, activation='sigmoid'))


# EfficientNetV2S(base_model)とオリジナルのモデル(top_model)を結合する
model = Model(inputs=base_model.input, outputs=top_model(base_model.output))

# コンパイルする
model.compile(loss='binary_crossentropy',
              optimizer=optimizers.SGD(learning_rate=1e-4, momentum=0.9),
              metrics=["accuracy"])


# モデルに学習させる
history=model.fit(X_train, y_train,
          batch_size=30,
          epochs=92,validation_data=(X_val, y_val))

初期との違いとしては

  • 転移学習に使うモデルをVGG16からEfficientNetV2Sに変更
  • 重みの凍結をやめ、EfficientNetV2Sの後ろから20番目の層以降の重みを再学習可能にした
  • FlattenをGlovalAveragePooling2Dに変更
  • 全結合層の数を削減
  • バッチサイズの増加
  • エポック数の削減

以上が挙げられるが、変更しなかった点にも理由があるので順番に解説していきたい。
モデルの変更については先の章で触れているため割愛する。

layer.trainable=???

モデル探索の際に気づいたfor layer in base_model.layers[]: layer.trainable =の所に大きく変更を加えた。
[-20:]については正直あてずっぽうで調整していきたどり着いた。ここで触れておきたいのは、layer.trainable = Trueとした訳だ。
元々はlayer.trainable = Falseにしており、Aidemyの講座内でTrueにしたことはなかった。「でもわざわざ指定するってことは、意味があるんでしょう?」そう思って、ChatGPTに聞いたところ、転移学習に使うモデル、つまり今回はEfficientNetV2Sがどれだけ今回の目的と類似しているかで範囲やTrue,Falseを検討するらしい。
EfficientNetシリーズが人間の分類に特化したモデルであれば、Falseにしていたかもしれないが、そうではなさそうなので、一旦Trueにしてみたところ、正解率と損失関数に改善が見られた。
何をしているのか私もきちんと理解できているのか自信はないが、layer.trainable = Trueは「その層がどの程度重要かどうかをもう一度学習してみてね」という指示を出しているような物だと考えている。
そういうわけで、今回はEfficientNetV2Sモデルの後ろから20層目以降を再学習してもらうことにした。

既存モデルと新規モデルの接続

既存モデルと新規のモデルを接続する層というのが存在する。元々はFlattenという層を使っていたが、それをGlobalAveragePooling2Dへと変更した。
変更した理由だが、これはEfficientNetについて調べていた時に見たQiitaの記事第3回 橋本環奈分類機を作成してみたで使っているのを見て使ってみたら性能に改善が見られたからだ。つまり、「よくわからんが使ってみたらできちゃった!」といった具合だ。
ただこれではあまりにも正直すぎるので、FlattenとGlobalAveragePooling2Dについて、ChatGPTに訊いてみた。

転移学習においてCNNから全結合層への接続にflattenとGlobalAveragePooling2Dをそれぞれ使う場合のメリットとデメリットを説明します。
flattenを使用した場合:

  • メリット:
  1. 柔軟性: flattenを使用すると、出力を完全にフラットな形式に変換できます。これにより、異なる形状のモデルに出力を適用できる柔軟性があります。
  2. 精度: 特に画像に対しては、flattenを使用することで2次元の空間情報を失うことなく、全結合層にデータを提供できます。
  • デメリット:
  1. 過学習: 多くのパラメータが存在するため、過学習のリスクが高まる可能性があります。
  2. 特徴失失: 空間情報をフラット化するため、一部の重要な特徴が失われる可能性があります。
    GlobalAveragePooling2Dを使用した場合:
  • メリット:
  1. 位置依存性の低減: GlobalAveragePooling2Dは位置に依存しない特徴を学習しやすくします。これにより、モデルの汎化性が向上しやすくなります。
  2. 計算量の削減: 平均値を取るため、空間的な情報を保持しつつもデータの次元を削減することができます。
  • デメリット:
  1. 情報の損失: 平均値を取ることで、一部の情報が失われる可能性があります。
  2. 特徴表現の制約: GlobalAveragePooling2Dは画像データなどの平均値を計算するため、特定の特徴表現に制約がかかる可能性があります。
    どちらを選択するかはタスクやデータによって異なります。flattenは柔軟性があり、GlobalAveragePooling2Dは過学習を抑制するために有効です。そのため、データや課題に適したアプローチを選択することが重要です。

初心者、心が折れそう。
ひとまず着目すべき点としては、

flattenは柔軟性があり、GlobalAveragePooling2Dは過学習を抑制するために有効です。

ここだろう。であれば、今回GlobalAveragePooling2Dを採用することで改善が見られたということは、当時私が気づかぬところで過学習が起きていた可能性があるのかもしれない。

ChatGPTについて
ここまで散々活用しているChatGPTですが、平気で嘘をつくことがあります。
質問と回答が無限ループすることもあります。

全結合層の数

これも正直なところ、やってみたら良かったからに他ならない。層を増やしたり減らしたり、ユニット数を増やしたり減らしたり、Dropoutの引数を増やしたり減らしたり、BatchNormalaizationを採用してみたり……色々と試してみた結果、今の私にできる範囲ではこれがベストな結果をもたらしてくれたのだ。

コンパイル

最後の最後の方で気づいたのだが、ここにも変更可能な物があった。
まずlossだが、これについては今回の内容だとbinary_crossentropyが合っているようなので、そのままにした。回帰問題やカテゴリのインデックスがone-hotの形でない場合等で変更することがあるようだ。
次にoptimizerだ。ここは何度か変更を加えたが、最終的に元の形に落ち着いた。今回のような画像分類の場合、一番メジャーなのはadamらしい。ところがどっこい、これを採用した所、また妙なグラフを生み出してしまった。

Adamに変更.png

損失関数も正解率も悪くないのだが、これを悪くないと判断するのはまずい気がする。合わせて色々なところを変えてみたのだが、概ねこんな状態だったため、adamは本採用とはならなかった。
そんなわけでSGDを継続して採用していくのだが、この引数を変えたらどうなるのか試してみた。

  • learning_rate=1e-4: これは学習率を表しています。学習率は、モデルのパラメータを更新する際にどれだけの大きさで更新するかを制御します。学習率が小さいほど、収束するまでに多くの反復が必要になりますが、過学習を避けやすくなります。
  • momentum=0.9: これはモメンタム(慣性)を表しています。モメンタムは、過去の更新ステップの影響をどれだけ受け入れるかを示します。モメンタムが1に近いほど、過去の更新がより大きな影響を与えます。

というChatGPTからの情報を元にいじってみたのだが、改善が見られない、またはまだまだ伸びそうではあるのだが、リソースを使い切ってしまうため断念せざるを得ない状況に陥るなどしたため、最終的に元に戻ったのだ。
リソースの関係で断念した物をここで供養しておきたい。

optimizer=optimizers.SGD(learning_rate=1e-5, momentum=0.95)

南無。

バッチサイズ

バッチサイズについては理解できているようなできていないような状態なので、またChatGPTの回答を引用しておく。

バッチサイズを大きくすると、より多くのデータを一度に処理することができますが、メモリの制約や計算効率の面から適切なバッチサイズを選択する必要があります。一方、バッチサイズを小さくすると、モデルの更新が頻繁に行われるため、学習が安定しやすくなりますが、計算効率が低下する可能性があります。

今回試した範囲ではリソースに大きな影響を与えることはなかったが、正解率や損失関数に変化があったため、今回は30を採用している。またしても、やってみて良かったからである。試した範囲としては、20から40程だったと記憶している。

エポック数

これは当初多めの300や150に設定し、earlystop = EarlyStopping(patience=10)earlystop = EarlyStopping(patience=15)を使っていた。earlystoppingについて、Aidemyの講座内では習っていないが、先のGlobalAveragePooling2Dを発見した時の記事内で使われているのを見て使い始めた。
損失関数が指定した回数連続で改善されなかった場合、学習を早期終了するというものだ。

#15回連続で損失関数が改善しなかったら学習を終了する
earlystop = EarlyStopping(patience=15)
history=model.fit(X_train, y_train,
          batch_size=30,
          epochs=150,validation_data=(X_val, y_val),callbacks=[earlystop])

上記のようにして使う。
何故それをやめてepochs=92にしたかというと、最終的にこのモデルが良さそうだと思ったものが、150エポック終了しなかったものの、ログを見たところ92あたりで止めて良さそうだったからである。

#実行結果のログ
Epoch 1/150
85/85 [==============================] - 84s 386ms/step - loss: 0.6989 - accuracy: 0.5396 - val_loss: 0.6892 - val_accuracy: 0.5450
Epoch 2/150
85/85 [==============================] - 26s 311ms/step - loss: 0.6959 - accuracy: 0.5376 - val_loss: 0.6877 - val_accuracy: 0.5700
Epoch 3/150
85/85 [==============================] - 27s 313ms/step - loss: 0.6911 - accuracy: 0.5550 - val_loss: 0.6855 - val_accuracy: 0.5800
Epoch 4/150
85/85 [==============================] - 27s 319ms/step - loss: 0.6916 - accuracy: 0.5661 - val_loss: 0.6826 - val_accuracy: 0.6050
Epoch 5/150
85/85 [==============================] - 27s 321ms/step - loss: 0.6869 - accuracy: 0.5736 - val_loss: 0.6780 - val_accuracy: 0.6200
Epoch 6/150
85/85 [==============================] - 27s 319ms/step - loss: 0.6829 - accuracy: 0.5812 - val_loss: 0.6733 - val_accuracy: 0.6550
Epoch 7/150
85/85 [==============================] - 27s 318ms/step - loss: 0.6779 - accuracy: 0.5942 - val_loss: 0.6725 - val_accuracy: 0.6650
Epoch 8/150
85/85 [==============================] - 27s 320ms/step - loss: 0.6741 - accuracy: 0.6037 - val_loss: 0.6658 - val_accuracy: 0.6600
Epoch 9/150
85/85 [==============================] - 27s 319ms/step - loss: 0.6705 - accuracy: 0.6144 - val_loss: 0.6660 - val_accuracy: 0.6400
Epoch 10/150
85/85 [==============================] - 27s 320ms/step - loss: 0.6679 - accuracy: 0.6255 - val_loss: 0.6642 - val_accuracy: 0.6500
Epoch 11/150
85/85 [==============================] - 27s 321ms/step - loss: 0.6618 - accuracy: 0.6346 - val_loss: 0.6592 - val_accuracy: 0.6850
Epoch 12/150
85/85 [==============================] - 27s 321ms/step - loss: 0.6624 - accuracy: 0.6298 - val_loss: 0.6557 - val_accuracy: 0.6800
Epoch 13/150
85/85 [==============================] - 27s 322ms/step - loss: 0.6564 - accuracy: 0.6508 - val_loss: 0.6561 - val_accuracy: 0.6750
Epoch 14/150
85/85 [==============================] - 27s 322ms/step - loss: 0.6501 - accuracy: 0.6552 - val_loss: 0.6501 - val_accuracy: 0.6850
Epoch 15/150
85/85 [==============================] - 27s 321ms/step - loss: 0.6461 - accuracy: 0.6595 - val_loss: 0.6454 - val_accuracy: 0.7000
Epoch 16/150
85/85 [==============================] - 27s 323ms/step - loss: 0.6425 - accuracy: 0.6651 - val_loss: 0.6440 - val_accuracy: 0.6800
Epoch 17/150
85/85 [==============================] - 27s 322ms/step - loss: 0.6399 - accuracy: 0.6714 - val_loss: 0.6375 - val_accuracy: 0.7050
Epoch 18/150
85/85 [==============================] - 27s 321ms/step - loss: 0.6376 - accuracy: 0.6750 - val_loss: 0.6366 - val_accuracy: 0.7000
Epoch 19/150
85/85 [==============================] - 27s 322ms/step - loss: 0.6354 - accuracy: 0.6734 - val_loss: 0.6351 - val_accuracy: 0.7000
Epoch 20/150
85/85 [==============================] - 27s 321ms/step - loss: 0.6293 - accuracy: 0.6829 - val_loss: 0.6303 - val_accuracy: 0.7000
Epoch 21/150
85/85 [==============================] - 27s 322ms/step - loss: 0.6206 - accuracy: 0.6987 - val_loss: 0.6256 - val_accuracy: 0.7050
Epoch 22/150
85/85 [==============================] - 27s 323ms/step - loss: 0.6126 - accuracy: 0.7221 - val_loss: 0.6258 - val_accuracy: 0.7100
Epoch 23/150
85/85 [==============================] - 27s 322ms/step - loss: 0.6098 - accuracy: 0.7201 - val_loss: 0.6229 - val_accuracy: 0.6800
Epoch 24/150
85/85 [==============================] - 27s 321ms/step - loss: 0.6055 - accuracy: 0.7134 - val_loss: 0.6199 - val_accuracy: 0.6850
Epoch 25/150
85/85 [==============================] - 27s 322ms/step - loss: 0.6031 - accuracy: 0.7185 - val_loss: 0.6122 - val_accuracy: 0.7100
Epoch 26/150
85/85 [==============================] - 27s 322ms/step - loss: 0.5972 - accuracy: 0.7201 - val_loss: 0.6112 - val_accuracy: 0.7000
Epoch 27/150
85/85 [==============================] - 27s 321ms/step - loss: 0.5911 - accuracy: 0.7363 - val_loss: 0.6046 - val_accuracy: 0.7150
Epoch 28/150
85/85 [==============================] - 27s 322ms/step - loss: 0.5851 - accuracy: 0.7292 - val_loss: 0.5958 - val_accuracy: 0.7350
Epoch 29/150
85/85 [==============================] - 27s 322ms/step - loss: 0.5820 - accuracy: 0.7300 - val_loss: 0.5961 - val_accuracy: 0.7250
Epoch 30/150
85/85 [==============================] - 27s 321ms/step - loss: 0.5815 - accuracy: 0.7288 - val_loss: 0.5910 - val_accuracy: 0.7150
Epoch 31/150
85/85 [==============================] - 27s 320ms/step - loss: 0.5705 - accuracy: 0.7391 - val_loss: 0.5913 - val_accuracy: 0.7150
Epoch 32/150
85/85 [==============================] - 27s 321ms/step - loss: 0.5653 - accuracy: 0.7538 - val_loss: 0.5820 - val_accuracy: 0.7550
Epoch 33/150
85/85 [==============================] - 27s 322ms/step - loss: 0.5601 - accuracy: 0.7518 - val_loss: 0.5808 - val_accuracy: 0.7200
Epoch 34/150
85/85 [==============================] - 27s 322ms/step - loss: 0.5554 - accuracy: 0.7514 - val_loss: 0.5742 - val_accuracy: 0.7300
Epoch 35/150
85/85 [==============================] - 27s 321ms/step - loss: 0.5489 - accuracy: 0.7613 - val_loss: 0.5708 - val_accuracy: 0.7400
Epoch 36/150
85/85 [==============================] - 27s 321ms/step - loss: 0.5445 - accuracy: 0.7573 - val_loss: 0.5670 - val_accuracy: 0.7300
Epoch 37/150
85/85 [==============================] - 27s 321ms/step - loss: 0.5429 - accuracy: 0.7609 - val_loss: 0.5644 - val_accuracy: 0.7400
Epoch 38/150
85/85 [==============================] - 27s 321ms/step - loss: 0.5369 - accuracy: 0.7676 - val_loss: 0.5620 - val_accuracy: 0.7500
Epoch 39/150
85/85 [==============================] - 27s 321ms/step - loss: 0.5312 - accuracy: 0.7763 - val_loss: 0.5551 - val_accuracy: 0.7450
Epoch 40/150
85/85 [==============================] - 27s 321ms/step - loss: 0.5228 - accuracy: 0.7767 - val_loss: 0.5531 - val_accuracy: 0.7400
Epoch 41/150
85/85 [==============================] - 27s 321ms/step - loss: 0.5223 - accuracy: 0.7755 - val_loss: 0.5527 - val_accuracy: 0.7400
Epoch 42/150
85/85 [==============================] - 27s 320ms/step - loss: 0.5078 - accuracy: 0.7846 - val_loss: 0.5375 - val_accuracy: 0.7450
Epoch 43/150
85/85 [==============================] - 27s 321ms/step - loss: 0.5089 - accuracy: 0.7755 - val_loss: 0.5464 - val_accuracy: 0.7550
Epoch 44/150
85/85 [==============================] - 27s 319ms/step - loss: 0.5043 - accuracy: 0.7866 - val_loss: 0.5375 - val_accuracy: 0.7650
Epoch 45/150
85/85 [==============================] - 27s 321ms/step - loss: 0.4943 - accuracy: 0.7894 - val_loss: 0.5359 - val_accuracy: 0.7400
Epoch 46/150
85/85 [==============================] - 27s 320ms/step - loss: 0.4979 - accuracy: 0.7811 - val_loss: 0.5317 - val_accuracy: 0.7550
Epoch 47/150
85/85 [==============================] - 27s 320ms/step - loss: 0.4894 - accuracy: 0.7886 - val_loss: 0.5235 - val_accuracy: 0.7650
Epoch 48/150
85/85 [==============================] - 27s 321ms/step - loss: 0.4891 - accuracy: 0.7846 - val_loss: 0.5292 - val_accuracy: 0.7700
Epoch 49/150
85/85 [==============================] - 27s 321ms/step - loss: 0.4757 - accuracy: 0.7957 - val_loss: 0.5203 - val_accuracy: 0.7650
Epoch 50/150
85/85 [==============================] - 27s 319ms/step - loss: 0.4749 - accuracy: 0.7933 - val_loss: 0.5113 - val_accuracy: 0.7750
Epoch 51/150
85/85 [==============================] - 27s 320ms/step - loss: 0.4712 - accuracy: 0.7973 - val_loss: 0.5120 - val_accuracy: 0.7700
Epoch 52/150
85/85 [==============================] - 27s 320ms/step - loss: 0.4637 - accuracy: 0.7930 - val_loss: 0.5118 - val_accuracy: 0.7900
Epoch 53/150
85/85 [==============================] - 27s 321ms/step - loss: 0.4584 - accuracy: 0.8096 - val_loss: 0.5140 - val_accuracy: 0.7750
Epoch 54/150
85/85 [==============================] - 27s 321ms/step - loss: 0.4465 - accuracy: 0.8064 - val_loss: 0.4991 - val_accuracy: 0.7950
Epoch 55/150
85/85 [==============================] - 27s 320ms/step - loss: 0.4510 - accuracy: 0.8048 - val_loss: 0.4983 - val_accuracy: 0.7800
Epoch 56/150
85/85 [==============================] - 27s 322ms/step - loss: 0.4434 - accuracy: 0.8127 - val_loss: 0.4945 - val_accuracy: 0.7900
Epoch 57/150
85/85 [==============================] - 27s 320ms/step - loss: 0.4422 - accuracy: 0.8048 - val_loss: 0.4894 - val_accuracy: 0.8050
Epoch 58/150
85/85 [==============================] - 27s 321ms/step - loss: 0.4317 - accuracy: 0.8191 - val_loss: 0.4915 - val_accuracy: 0.8000
Epoch 59/150
85/85 [==============================] - 27s 321ms/step - loss: 0.4275 - accuracy: 0.8124 - val_loss: 0.4824 - val_accuracy: 0.7900
Epoch 60/150
85/85 [==============================] - 27s 321ms/step - loss: 0.4357 - accuracy: 0.8052 - val_loss: 0.4856 - val_accuracy: 0.8050
Epoch 61/150
85/85 [==============================] - 27s 321ms/step - loss: 0.4260 - accuracy: 0.8116 - val_loss: 0.4801 - val_accuracy: 0.8250
Epoch 62/150
85/85 [==============================] - 27s 321ms/step - loss: 0.4220 - accuracy: 0.8274 - val_loss: 0.4767 - val_accuracy: 0.8150
Epoch 63/150
85/85 [==============================] - 27s 320ms/step - loss: 0.4209 - accuracy: 0.8072 - val_loss: 0.4734 - val_accuracy: 0.8250
Epoch 64/150
85/85 [==============================] - 27s 320ms/step - loss: 0.4141 - accuracy: 0.8290 - val_loss: 0.4730 - val_accuracy: 0.8100
Epoch 65/150
85/85 [==============================] - 27s 320ms/step - loss: 0.4055 - accuracy: 0.8302 - val_loss: 0.4687 - val_accuracy: 0.8250
Epoch 66/150
85/85 [==============================] - 27s 320ms/step - loss: 0.4049 - accuracy: 0.8290 - val_loss: 0.4651 - val_accuracy: 0.8300
Epoch 67/150
85/85 [==============================] - 27s 320ms/step - loss: 0.3941 - accuracy: 0.8397 - val_loss: 0.4619 - val_accuracy: 0.8200
Epoch 68/150
85/85 [==============================] - 27s 320ms/step - loss: 0.3914 - accuracy: 0.8440 - val_loss: 0.4638 - val_accuracy: 0.8100
Epoch 69/150
85/85 [==============================] - 27s 320ms/step - loss: 0.3809 - accuracy: 0.8416 - val_loss: 0.4631 - val_accuracy: 0.8150
Epoch 70/150
85/85 [==============================] - 27s 322ms/step - loss: 0.3816 - accuracy: 0.8357 - val_loss: 0.4561 - val_accuracy: 0.8250
Epoch 71/150
85/85 [==============================] - 27s 320ms/step - loss: 0.3786 - accuracy: 0.8428 - val_loss: 0.4527 - val_accuracy: 0.8050
Epoch 72/150
85/85 [==============================] - 27s 321ms/step - loss: 0.3867 - accuracy: 0.8357 - val_loss: 0.4552 - val_accuracy: 0.8300
Epoch 73/150
85/85 [==============================] - 27s 321ms/step - loss: 0.3711 - accuracy: 0.8456 - val_loss: 0.4496 - val_accuracy: 0.8150
Epoch 74/150
85/85 [==============================] - 27s 320ms/step - loss: 0.3773 - accuracy: 0.8535 - val_loss: 0.4482 - val_accuracy: 0.8200
Epoch 75/150
85/85 [==============================] - 27s 322ms/step - loss: 0.3607 - accuracy: 0.8567 - val_loss: 0.4486 - val_accuracy: 0.8350
Epoch 76/150
85/85 [==============================] - 27s 320ms/step - loss: 0.3586 - accuracy: 0.8531 - val_loss: 0.4471 - val_accuracy: 0.8050
Epoch 77/150
85/85 [==============================] - 27s 322ms/step - loss: 0.3537 - accuracy: 0.8535 - val_loss: 0.4424 - val_accuracy: 0.8300
Epoch 78/150
85/85 [==============================] - 27s 321ms/step - loss: 0.3512 - accuracy: 0.8571 - val_loss: 0.4432 - val_accuracy: 0.8100
Epoch 79/150
85/85 [==============================] - 27s 320ms/step - loss: 0.3500 - accuracy: 0.8567 - val_loss: 0.4425 - val_accuracy: 0.8200
Epoch 80/150
85/85 [==============================] - 27s 320ms/step - loss: 0.3484 - accuracy: 0.8618 - val_loss: 0.4323 - val_accuracy: 0.8350
Epoch 81/150
85/85 [==============================] - 27s 320ms/step - loss: 0.3350 - accuracy: 0.8686 - val_loss: 0.4345 - val_accuracy: 0.8300
Epoch 82/150
85/85 [==============================] - 27s 319ms/step - loss: 0.3376 - accuracy: 0.8595 - val_loss: 0.4337 - val_accuracy: 0.8450
Epoch 83/150
85/85 [==============================] - 27s 320ms/step - loss: 0.3353 - accuracy: 0.8622 - val_loss: 0.4286 - val_accuracy: 0.8400
Epoch 84/150
85/85 [==============================] - 27s 321ms/step - loss: 0.3338 - accuracy: 0.8634 - val_loss: 0.4330 - val_accuracy: 0.8300
Epoch 85/150
85/85 [==============================] - 27s 322ms/step - loss: 0.3278 - accuracy: 0.8670 - val_loss: 0.4296 - val_accuracy: 0.8200
Epoch 86/150
85/85 [==============================] - 27s 321ms/step - loss: 0.3399 - accuracy: 0.8583 - val_loss: 0.4280 - val_accuracy: 0.8200
Epoch 87/150
85/85 [==============================] - 27s 322ms/step - loss: 0.3135 - accuracy: 0.8737 - val_loss: 0.4282 - val_accuracy: 0.8250
Epoch 88/150
85/85 [==============================] - 27s 321ms/step - loss: 0.3212 - accuracy: 0.8674 - val_loss: 0.4237 - val_accuracy: 0.8250
Epoch 89/150
85/85 [==============================] - 27s 321ms/step - loss: 0.3139 - accuracy: 0.8777 - val_loss: 0.4187 - val_accuracy: 0.8300
Epoch 90/150
85/85 [==============================] - 27s 323ms/step - loss: 0.3152 - accuracy: 0.8709 - val_loss: 0.4174 - val_accuracy: 0.8350
Epoch 91/150
85/85 [==============================] - 27s 322ms/step - loss: 0.3101 - accuracy: 0.8737 - val_loss: 0.4215 - val_accuracy: 0.8300
Epoch 92/150
85/85 [==============================] - 27s 322ms/step - loss: 0.2990 - accuracy: 0.8816 - val_loss: 0.4156 - val_accuracy: 0.8300
Epoch 93/150
85/85 [==============================] - 27s 320ms/step - loss: 0.3035 - accuracy: 0.8773 - val_loss: 0.4226 - val_accuracy: 0.8100
Epoch 94/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2890 - accuracy: 0.8828 - val_loss: 0.4204 - val_accuracy: 0.8250
Epoch 95/150
85/85 [==============================] - 27s 321ms/step - loss: 0.2913 - accuracy: 0.8876 - val_loss: 0.4124 - val_accuracy: 0.8200
Epoch 96/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2873 - accuracy: 0.8864 - val_loss: 0.4171 - val_accuracy: 0.8300
Epoch 97/150
85/85 [==============================] - 27s 321ms/step - loss: 0.2832 - accuracy: 0.8939 - val_loss: 0.4192 - val_accuracy: 0.8300
Epoch 98/150
85/85 [==============================] - 27s 321ms/step - loss: 0.2871 - accuracy: 0.8808 - val_loss: 0.4037 - val_accuracy: 0.8300
Epoch 99/150
85/85 [==============================] - 27s 321ms/step - loss: 0.2709 - accuracy: 0.8915 - val_loss: 0.4060 - val_accuracy: 0.8200
Epoch 100/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2637 - accuracy: 0.8947 - val_loss: 0.4107 - val_accuracy: 0.8150
Epoch 101/150
85/85 [==============================] - 27s 322ms/step - loss: 0.2715 - accuracy: 0.8939 - val_loss: 0.4097 - val_accuracy: 0.8150
Epoch 102/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2744 - accuracy: 0.8915 - val_loss: 0.4042 - val_accuracy: 0.8300
Epoch 103/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2593 - accuracy: 0.8951 - val_loss: 0.4053 - val_accuracy: 0.8200
Epoch 104/150
85/85 [==============================] - 27s 321ms/step - loss: 0.2566 - accuracy: 0.9066 - val_loss: 0.4068 - val_accuracy: 0.8300
Epoch 105/150
85/85 [==============================] - 27s 322ms/step - loss: 0.2516 - accuracy: 0.9010 - val_loss: 0.4035 - val_accuracy: 0.8300
Epoch 106/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2566 - accuracy: 0.9002 - val_loss: 0.4173 - val_accuracy: 0.8250
Epoch 107/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2482 - accuracy: 0.9046 - val_loss: 0.4093 - val_accuracy: 0.8250
Epoch 108/150
85/85 [==============================] - 27s 322ms/step - loss: 0.2509 - accuracy: 0.9038 - val_loss: 0.4006 - val_accuracy: 0.8250
Epoch 109/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2438 - accuracy: 0.9050 - val_loss: 0.4061 - val_accuracy: 0.8200
Epoch 110/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2365 - accuracy: 0.9097 - val_loss: 0.4080 - val_accuracy: 0.8250
Epoch 111/150
85/85 [==============================] - 27s 322ms/step - loss: 0.2453 - accuracy: 0.9089 - val_loss: 0.4016 - val_accuracy: 0.8200
Epoch 112/150
85/85 [==============================] - 27s 321ms/step - loss: 0.2433 - accuracy: 0.9046 - val_loss: 0.4031 - val_accuracy: 0.8350
Epoch 113/150
85/85 [==============================] - 27s 322ms/step - loss: 0.2336 - accuracy: 0.9133 - val_loss: 0.3976 - val_accuracy: 0.8250
Epoch 114/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2267 - accuracy: 0.9177 - val_loss: 0.4004 - val_accuracy: 0.8300
Epoch 115/150
85/85 [==============================] - 27s 321ms/step - loss: 0.2253 - accuracy: 0.9097 - val_loss: 0.3933 - val_accuracy: 0.8250
Epoch 116/150
85/85 [==============================] - 27s 323ms/step - loss: 0.2338 - accuracy: 0.9137 - val_loss: 0.3919 - val_accuracy: 0.8450
Epoch 117/150
85/85 [==============================] - 27s 322ms/step - loss: 0.2221 - accuracy: 0.9165 - val_loss: 0.3905 - val_accuracy: 0.8250
Epoch 118/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2108 - accuracy: 0.9220 - val_loss: 0.4104 - val_accuracy: 0.8150
Epoch 119/150
85/85 [==============================] - 27s 321ms/step - loss: 0.2079 - accuracy: 0.9184 - val_loss: 0.3983 - val_accuracy: 0.8250
Epoch 120/150
85/85 [==============================] - 27s 321ms/step - loss: 0.2046 - accuracy: 0.9248 - val_loss: 0.4024 - val_accuracy: 0.8200
Epoch 121/150
85/85 [==============================] - 27s 322ms/step - loss: 0.2093 - accuracy: 0.9264 - val_loss: 0.3880 - val_accuracy: 0.8350
Epoch 122/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2085 - accuracy: 0.9252 - val_loss: 0.3958 - val_accuracy: 0.8150
Epoch 123/150
85/85 [==============================] - 27s 320ms/step - loss: 0.2054 - accuracy: 0.9248 - val_loss: 0.3963 - val_accuracy: 0.8250
Epoch 124/150
85/85 [==============================] - 27s 319ms/step - loss: 0.1832 - accuracy: 0.9327 - val_loss: 0.3897 - val_accuracy: 0.8300
Epoch 125/150
85/85 [==============================] - 27s 321ms/step - loss: 0.1935 - accuracy: 0.9347 - val_loss: 0.4071 - val_accuracy: 0.8150
Epoch 126/150
85/85 [==============================] - 27s 322ms/step - loss: 0.1834 - accuracy: 0.9315 - val_loss: 0.3946 - val_accuracy: 0.8300
Epoch 127/150
85/85 [==============================] - 27s 320ms/step - loss: 0.1859 - accuracy: 0.9335 - val_loss: 0.4051 - val_accuracy: 0.8200
Epoch 128/150
85/85 [==============================] - 27s 320ms/step - loss: 0.1942 - accuracy: 0.9252 - val_loss: 0.3927 - val_accuracy: 0.8250
Epoch 129/150
85/85 [==============================] - 27s 322ms/step - loss: 0.1860 - accuracy: 0.9287 - val_loss: 0.3957 - val_accuracy: 0.8150
Epoch 130/150
85/85 [==============================] - 27s 322ms/step - loss: 0.1777 - accuracy: 0.9378 - val_loss: 0.3906 - val_accuracy: 0.8400
Epoch 131/150
85/85 [==============================] - 27s 320ms/step - loss: 0.1802 - accuracy: 0.9378 - val_loss: 0.3930 - val_accuracy: 0.8450
Epoch 132/150
85/85 [==============================] - 27s 320ms/step - loss: 0.1747 - accuracy: 0.9398 - val_loss: 0.3928 - val_accuracy: 0.8400
Epoch 133/150
85/85 [==============================] - 27s 322ms/step - loss: 0.1741 - accuracy: 0.9371 - val_loss: 0.4009 - val_accuracy: 0.8350
Epoch 134/150
85/85 [==============================] - 27s 322ms/step - loss: 0.1719 - accuracy: 0.9434 - val_loss: 0.3988 - val_accuracy: 0.8200
Epoch 135/150
85/85 [==============================] - 27s 321ms/step - loss: 0.1734 - accuracy: 0.9367 - val_loss: 0.3908 - val_accuracy: 0.8200

損失関数(val_loss)は減少し続けているため、早期終了はされない。しかし正解率(accuracy)は減少し続けたり、増減したりと不安定だ。終盤、もしかすると更に続けることができたら改善したのかもしれないという気配は感じるが、リソースがそれを許さなかったのだから仕方がない。
故に、今回はepochs=92で終わらせようと結論付けた。

モデルの評価

最後にモデルの評価を行った。
モデルがどの程度の性能を持っているのかを把握し、公開するのか、改善が見込めるので公開を見送るのか、公開した後に更に改善するのか等を考えるのは重要なステップになる。
調整している間にも行っていた作業だが、「これでいこう!」と思った時には念のためより多くの情報を元に評価を行った。

# グラフの描画に必要なモジュール類をインポートする
import matplotlib.pyplot as plt

# figure()で表示領域を作成
fig = plt.figure()
# 今回は二つのグラフを並べて表示できるようにする
# add_subplot()の引数は行,列,場所
ax1 = fig.add_subplot(2, 2, 1)
ax2 = fig.add_subplot(2, 2, 2)

# 正解率の変化のグラフを作成
ax1.plot(history.history["accuracy"], label="acc", ls="-")
ax1.plot(history.history["val_accuracy"], label="val_acc", ls="-")
ax1.set_ylabel("acc")
ax1.set_xlabel("epoch")
ax1.set_title("model", fontsize=12)
ax1.legend()

# 損失関数の変化のグラフを作成
ax2.plot(history.history["loss"], label="loss", ls="-")
ax2.plot(history.history["val_loss"], label="val_loss", ls="-")
ax2.set_ylabel("loss")
ax2.set_xlabel("epoch")
ax2.set_title("model", fontsize=12)
ax2.legend()
plt.show()


# 損失関数と正解率を取得し表示させる
scores = model.evaluate(X_val, y_val, verbose=1)
print('test_loss:', scores[0])
print('test_accuracy:', scores[1])


# 精度・再現率・F1スコアを取得するために必要なモジュール類をインポートする
from sklearn.metrics import precision_score, recall_score, f1_score

# 各予測を取得
y_pred = model.predict(X_val)
y_pred_labels = np.argmax(y_pred, axis=1)
y_true_labels = np.argmax(y_val, axis=1)

# 精度、再現率、F1スコアの計算
precision = precision_score(y_true_labels, y_pred_labels)
recall = recall_score(y_true_labels, y_pred_labels)
f1 = f1_score(y_true_labels, y_pred_labels)

# 結果を表示
print("精度: {:.16f}".format(scores[1]))
print("再現率: {:.16f}".format(recall))
print("F1スコア: {:.16f}".format(f1))

これでモデルのエポック毎の正解率の変化と損失関数の変化はグラフで、それらの最終的な数値と精度再現率F1スコア全ての情報を可視化することができる。

test_loss: 0.415547251701355
test_accuracy: 0.8299999833106995
精度: 0.8299999833106995
再現率: 0.8300000000000000
F1スコア: 0.8300000000000000

このように出力された。
グラフは見た限り、学習データの結果(青)とある程度沿う形でバリデーションデータ(オレンジ)にも変化がみられている。正解率はもう少し早い段階から停滞が見られているが、損失関数の減少と合わせて考えると、これがギリギリの判断で良かったのではないかと思う。
ところでだが、正解率や精度、再現率、F1スコア、どれを大事にするかは目的によって異なる。正直、ずぶの素人なのだから、とりあえず正解率と損失関数だけわかれば良かったかもしれないとも思う。ここまで基本的にはそれしか参考にしてこなかったし……とはいえ、私は知りたかった。
以下にそれぞれの特徴と、私が今回これらを知りたかった理由を示しておく。

正解率
シンプルに予測が当たった確率。ただし、正解率0.5(50%)だったとした時、今回の場合“自閉症”を100%検知し、“非自閉症児”も全て“自閉症”と分類している可能性がある。それは果たして参考になるのだろうか。
精度
今回でいうところ、“自閉症”と判定された画像が、実際に“自閉症”であった確率を出してくれる。精度が重視されるのはネット通販のリコメンド等だと言われるが、今回私はこれも重視したかった。このアプリによる判別で、(冒頭でも述べたように)無暗に不安や過剰な期待を煽るようなことになりたくなかったからだ。
再現率
精度とは逆に、今回でいうところ“自閉症”の人物を、“自閉症”と判定できた確率だ。がんの検出等では見落としたくないのでこれを重視する。今回のアプリは医療の領域にも踏み込んでいることを考えると、こちらも無視はできなかった。
F1スコア
精度と再現率の調和平均で、正解率と並んで最も使われるらしい。今回は後学のため、とりあえず出しておいた。

そういうわけで、今回は全て80%超の数値を出していることが確認できた。
実は今回のデータセットを使って、同じような取り組みをしているのをいくつか見た。英語の記事だったり、私にはわからないコードだったりしたため、認識に誤りがあるかもしれないが、転移学習で80%を超えるの正解率を出した例と、イチから自分でモデルを組みF1スコア90%を出した事例があった。それを踏まえて「まあ、初心者だから、正解率70%くらいまで行けたら御の字だよね。できれば精度も同じくらい高くしたいな」と最初に思っていたので、目標達成である。

モデルのダウンロード

モデルの評価も済んだので、モデルをローカル環境にダウンロードし、アプリとして動かす準備をしていく。モデルの学習が済んだ後に以下のコードを書き加えることでダウンロードできる。

# モデルの保存の準備
#必要なモジュール類をインポートする
from google.colab import files

#モデルを保存するresultsディレクトリを作成
result_dir = 'results'
if not os.path.exists(result_dir):
    os.mkdir(result_dir)
# モデルの保存
model.save(os.path.join(result_dir, 'model.h5'))
#モデルをローカル環境にダウンロード
files.download( '/content/results/model.h5' )

ちなみに、先の章で触れたEarlyStoppingと共に、損失関数が一番良かった所で保存することも可能だが、今回は使わなかった。損失関数が改善を続けているので、リソースが許せば使った可能性はある。

アプリとしての動作を作る

さて、モデルの作成とダウンロードが完了したので、作成したモデルを使ったアプリを実装する準備をしていく。実際にはHTMLやCSSと連動させなければならない部分があるので、確認しながら進める必要があったが、まずはアプリの動作を設定していく。
特記事項はないので、コードの詳しい内容はコメントアウトを見てほしい。

# 作成したモデルを使うために必要なモジュール類をインポートする
import os
from flask import Flask, request, redirect, render_template, flash
from werkzeug.utils import secure_filename
from tensorflow.keras.preprocessing import image
import numpy as np

# 今回のモデルに使っているSequentialとEfficientNetV2Sも忘れずにインポートする
from tensorflow.keras.models import Sequential, load_model
from tensorflow.keras.applications.efficientnet_v2 import EfficientNetV2S


# 吐き出す分類をclassesで0と1として定義
classes = ["0","1"]
# モデルに渡す画像サイズを定義
image_size = 200,160
#渡された画像を保存するディレクトリを指定
UPLOAD_FOLDER = "static"
#受け付ける画像の拡張子を指定
ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif'])


# Flaskを使ってインスタンスを作成してく
app = Flask(__name__)

# ファイルの拡張子が対応しているものかを確認する関数allowed_file()を定義
def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

#学習済みモデルを読み込む
model = load_model('./model.h5')


# GET,POSTという既存のメソッドを使う
@app.route('/', methods=['GET', 'POST'])

# POST(送信ボタンが押された時)に対し行われる動作と共にどう処理をするかを定義する
def upload_file():
    if request.method == 'POST':
        # 送信ボタンが押されたが、ファイルがアップロードされていない場合のエラーメッセージを設定
        if 'file' not in request.files:
            flash('ファイルがありません')
            return redirect(request.url)
        # 送信されたファイルを取得します
        file = request.files['file']
        # ファイル名がなかった場合のエラーメッセージを設定
        if file.filename == '':
            flash('ファイルがありません')
            return redirect(request.url)
            
        # ファイルが正常に渡された場合の動作を指定していく
        if file and allowed_file(file.filename):
            filename = secure_filename(file.filename)
            file.save(os.path.join(UPLOAD_FOLDER, filename))
            filepath = os.path.join(UPLOAD_FOLDER, filename)

            # 受け取った画像を読み込み、np形式に変換
            img = image.load_img(filepath, target_size=(image_size))
            img = image.img_to_array(img)
            data = np.array([img])
            # 変換したデータをモデルに渡して予測する
            result = model.predict(data)[0]
            predicted = result.argmax()
            
            # 予測結果が0で吐き出された場合のメッセージの設定
            if predicted == 0 :
                pred_answer = "自閉症の可能性が高いです"
            # 予測結果が0以外(即ち1)で吐き出された場合のメッセージの設定
            else:
                pred_answer = "自閉症の可能性は低いです"
                
            # 判定メッセージと画像をHTMLに反映させる
            return render_template("index.html", answer=pred_answer,img_path=filename)
    
    # 動作のない場合には空欄を表示する
    return render_template("index.html",answer="")

# サーバーを動かして、上記の動作を行う
if __name__ == "__main__":
    port = int(os.environ.get('PORT', 8080))
    app.run(host ='0.0.0.0',port = port)

Webデザイン

今回のアプリはWebブラウザ上で動かしていく物になる。それにあたって、HTMLとCSSで説明等の情報を記述したり、装飾を施したりしていく。

HTML

ウェブページに表示する内容等を記述していく。
理解が足りずに苦戦した部分はあったが、特筆すべきことはないので、内容についてはコメントアウトを参考にしてほしい。

<!-- HTMLであることを宣言 -->
<!DOCTYPE html>
<!-- 日本語を使っていることを宣言 -->
<html lang="ja">


<!-- Webページの設定を行う -->
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>自閉症識別</title>
    <link rel="stylesheet" href="./static/stylesheet.css" />
  </head>


<!-- 表示部分を作っていく -->
  <body>
   <!-- ヘッダーに画像を入れて装飾 -->
    <header>
      <img
        class="header_img"
        src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjDdPjQX0XGbUqTREQ7YuQBwd4i7piPUyvygPnodWdl9t5N3emN_Hjjeyj_Lswmf39iIiC7R6jQEpXjKZM2cgEt2EvE6WPFqS4Wvq5nIxjH9QzATiHXmqjSWNaRuTlEV3gqo9XTd3WvCaBW/s800/character_rokkaku2.png"
        alt="いらすとや 黄色い六角形のキャラクター"
      />
      <!-- ヘッダーに入れるロゴ(ページタイトル)を指定 -->
      <a class="header-logo" href="#">自閉症識別</a>
    </header>
    

    <!-- ページのメイン部分の記述 -->
    <div class="main">
      <h2>顔写真を元にAIが自閉症の可能性を識別します</h2>
      <p>一人で写っている顔写真をアップロードしてください。</p>

      <!-- 画像をアップロードするボタンや、動作を設定 -->
      <form method="POST" enctype="multipart/form-data">
        <input class="file_choose" type="file" name="file" />
        <input class="btn" value="識別する" type="submit" />
      </form>
      <div class="answer">{{answer}}</div>
      {% if img_path %}
      <div class="image-container">
        <img
          class="upload_img"
          src="{{ url_for('static', filename=img_path) }}"
          alt="アップロードされた画像"
        />
      </div>
      {% endif %}

      <!-- 表示メッセージ(赤に指定) -->
      <div>
        <p>
          <span style="color: red"
            >このアプリには改善の余地があります。<br />
            現在、自閉症の診断が可能なのは医師のみです。第三者の画像を許可なく使用することや識別結果を診断の根拠とすることがないようお願いいたします。</span
          >
        </p>
      </div>
    </div>


    <!-- フッターの画像での装飾 -->
    <footer>
      <img
        class="footer_img"
        src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiM4-yLC5EzpoSrflEG1A1nOgtXdVZTmhJ1xmBCEhOVMw4CFx7D25KjYevUwifMACZdmtvq-ob37tIbyySSsB2dp0ZHUaV8ghDtQeHuSJPzCb4Lteliktrk2ETAHD8Myrgg9viQ7kRwKhy9/s800/character_maru4.png"
        alt="いらすとや ピンク色の丸い形をしたキャラクター"
      />
      <!-- copyを入れる -->
      <small>&copy; 2024 YUNA.</small>
    </footer>
  </body>
</html>

CSS

HTMLの<>で囲われた文字と連動して色や配置などを定義している。
こちらも初めての取り組みで苦戦した部分はあったが、特筆すべきことはないので、コードの記載のみとする。

header {
    background-color: #ddffff;
    height: 60px;
    margin: -8px;
    display: flex;
    flex-direction: row-reverse;
    justify-content: space-between;
  }
  
  .header-logo {
    color: #0000cd;
    font-size: 25px;
    margin: 15px 25px;
  }
  
  .header_img {
    height: 25px;
    margin: 15px 25px;
  }
  
  h2 {
    color: #444444;
    margin: 90px 0px;
    text-align: center;
  }
  
  p {
    color: #444444;
    margin: 70px 0px 30px 0px;
    text-align: center;
  }
  
  .answer {
    color: #444444;
    margin: 70px 0px 30px 0px;
    text-align: center;
  }
  .image-container {
    text-align: center;
  }
  
  .upload_img {
    max-width: 100%;
    height: auto;
  }
  
  form {
    text-align: center;
  }
  
  footer {
    background-color: #ddffff;
    height: 110px;
    margin: -8px;
    position: relative;
  }
  
  .footer_img {
    height: 25px;
    margin: 15px 25px;
  }
  
  small {
    margin: 15px 25px;
    position: absolute;
    left: 0;
    bottom: 0;
  }

requirements.txt

モデルやアプリを動かすため、様々なライブラリを使っている。今回はRenderを使いWeb上に公開するわけだが、Renderに使っている(必要な)ライブラリをインストールするように指示出しする必要があるので、以下のように記述した。

absl-py==1.4.0
astor==0.8.1
bleach==3.1.5
bottle==0.12.18
click==7.1.2
certifi==2023.7.22
chardet==3.0.4
flask==2.0.1
future==0.18.2
gast==0.4.0
grpcio==1.58.0
h5py==3.9.0
html5lib==1.1
itsdangerous==2.0
idna==3.4
Jinja2==3.0.1
line-bot-sdk==1.16.0
Markdown==3.4.4
MarkupSafe==2.1.3
numpy
oauthlib==3.2.2
pillow==7.2.0
protobuf==4.24.3
requests==2.31.0
six==1.16.0
tensorboard==2.13.0
tensorboard-data-server==0.7.1
tensorflow==2.13.0
termcolor==2.3.0
urllib3==1.26.16
Werkzeug==2.3.7
wheel==0.38.4

これらの記述は何を使ったかによって変更する必要がある。今回は、元々添削課題提出時に使用した(Aidemy側で用意されていた)物を使っている。

バージョンの話

私はPythonのバージョンとKerasのバージョンをダウングレードしている。
始めは最新版を使っていたが、Pythonは課題を行う中でエラーが発生し、ChatGPTに何が起きているのかを聞いたところ「そのバージョンは存在しない!」とよくわからないことを言われた。質問攻めにした結果、存在しないというよりも“プロトタイプが公開されている”ような状態らしかった。エラーを解決する策としては、ダウングレードであったため、現在インストール可能な中で一番古いものをインストールした。(ChatGPTにはもっと古い物を指示されたが、提供終了していた)
また、Kerasは今回のアプリ開発時にエラーが発生し、ChatGPTとチューターさんの支援の下、ダウングレードした。この時のエラーは、EfficientNetV2Sに使われている層の中に、最新版では対応していない物が含まれているために起きた物だった。
私のような初心者は、安易に最新版をインストールしがちかもしれないが、最新版が最善であるとは限らないらしい。ふと、iPhoneをアップデートしたら挙動がおかしいみたいな話をSNSで定期的に聞くのを思い出して、腑に落ちた。
また、ダウングレードもどこまで落としていくかは、経験であたりを付ける部分もありそうだが、少しずつ検証しながら行うと良いようだ。

アプリ(Webページ)の公開

ここまで作ってきた物を合わせ、いよいよウェブ上に公開していく。

ファイルの整理

まず、ここまで準備してきたモデル・動作を記述したpythonファイル・HTMLファイル・CSSファイル・requirements.txtファイルを全て一つのディレクトリ保存しておく必要がある。また、動作の仕様上、特定の名前を付けたディレクトリに保存をする必要があるファイルもある。
今回のアプリ用のファイルをまとめたディレクトリはautism_appとした。以下が中身になる。

autism_app
│ autism.py
│ model.h5
│ requirements.txt

├─static
│ stylesheet.css

├─templates
│ index.html

CSSファイルとHTMLファイルは、それぞれstaticとtemplatesというディレクトリに入れる必要がある。他はそのまま入れておいて構わない。
開発開始段階でディレクトリを作成し、ファイルを作る毎にそこへ保存しておくのが楽だろう。

GitHubとRender

GitHubとは何かというと、ファイルの保管や変更履歴等の管理なんかをしてくれるサービス……だと思っている。他の様々なサービス等とも連動できたり、複数人で行うプロジェクトの管理に重宝されたりと、プログラミングに携わる上で知っておくべきサービスだなのだろうが、ここはまだまだ不勉強だ。Qiitaの投稿もGitHubへのpushでできるのだが、初期設定が恐らくあと一息の所でできずに断念した。
Renderはサーバーを無料でも使わせてくれるサービスという認識をしている。
このあたりは本当にまだまだ理解が追い付いていないなので、今回のWebアプリ実装のために行った手順をざっくり記述する。

1. Git for Windowsのインストール(Macはターミナルから操作)
2. GitHubのアカウントを作る
3. GitHubに今回のアプリ用のディレクトリを管理させるためのリポジトリを作る
4. Renderのアカウント作成・GitHubとの(リポジトリの)連携
5. ディレクトリをGitの管理下に置く(コマンドプロンプトで行う)
6. ディレクトリをGitHubへプッシュする=Renderに反映される
7. めでたくWebアプリが全世界へ公開される

といったところだ。
が、私はまたGooglecolabとの闘いのような物に引っかかる。プッシュしようとしたところ、エラーが出た。

Git Lerge File Strage(LFS)

エラーの内容は“GitHubのファイルサイズ制限が100.00 MBなのに対し、現在のmodel.h5ファイルが160.28 MBある”という旨。そう、モデルが大きすぎたのだ。GitHubが受け付けてくれるファイルのサイズ制限を超えたのだ。ここにきてモデルの再検討!?と絶望しかけたが、これを回避する手段があるということを、エラーメッセージと共に表示してくれていた。
その回避する手段というのが、GitLFSというものだ。大きなファイルをGitLFSをインストールし、これを経由することで、GitHubに受け付けてもらえるようになる。
一筋縄では行かなかったので、チューターさんに助けてもらいながら、どうにかプッシュに成功した。

これで私の自閉症識別アプリが全世界へと公開されたわけである。

課題・感想・まとめ

初めてのWebアプリ開発を終え、一言でまとめるとしたらめちゃめちゃ大変だったの一言に尽きる。ただこれではまとめにならないので、それらしいまとめをしていこうと思う。

プログラミングについて

正直こんなに苦戦するとは思っていなかった。Googlecolabは予測変換的に次のコードの候補を表示してくれる機能があるし、ベースとなるモデルは存在していたからだ。しかし、目的や分類の難易度が変われば、当然プログラミングの難易度も変わる。冒頭で述べたように今回の内容は難しいと元からわかっていたのに、何故こんなに苦戦することに驚いているんだと自分で自分にツッコミたくなる。
ではどこに困難を感じたのかを整理すると、性能・モデル・サイズの3点が挙げられる。この3点の大変だったことと細かな課題を以下にまとめる。

性能

最初は70%程度の正解率で御の字だと思っていたが、やればやるほど欲が出た。そもそも、最初にいびつなグラフを見た時点で火が点いていた。もっと正解率を高く、もっと損失関数を小さくと、初心者のくせに突き詰めたくなったのだ。でもそう簡単に性能は上がらなかった。
それでも当初の目標を10%上回る80%の正解率を出すことができた今思うのは、やはりもっと良い性能の物を作りたいということだ。
今回、画像の処理はリサイズで行ったが、手作業で時間をかけて処理をすればより良い数値が出たかもしれない。あるいはまだ私の知らないモデルを使った転移学習や、イチから自前のモデルを作ることでより良くなる可能性もあるだろう。自前の環境構築やそれに伴うリソースの増加でできることが増えるなんてことも考えられるし、協力者がいればより多くのデータを使うことで性能を高めていける可能性もある。

モデル

モデルと性能は地続きの問題にはなる。
モデルについてはそもそもVGG16以外とは?というレベルからスタートした。講座内で学習したとはいえ、実際に使ってみると理解の浅さを突き付けられた形だ。
VGG19にしても結果は大きく変わらない、EfficientNetシリーズを使っても上手くいかない。層を変えてみたら変わったが、他の人物を識別するモデルの真似をしたら下がった。モデルをより性能が良いとされるものにしたらリソースが許さない。やっとの思いでたどり着いた細かなチューニングは、意味がない・やっぱりリソースが許さない……
思っていた以上にモデルを完成させるに至るまで時間を要した。ベースはできているのだから、少しチューニングすればどうにかなると思っていたが、本当に甘かった。習っていないアレコレ、リソース問題、バージョン問題、サイズ問題、たくさんのことに引っかかった。学習を深めていく必要性を痛感している。

モデルのサイズ

こちらもモデルと地続きのサイズ問題。
まずGooglecolabのリソースをすぐに使い切ってしまうことで、なかなか完成させることができずもどかしい思いをした。更にデプロイできないことは当然想定外だった。GitLFSなんて、講座内で全く触れていなかったと記憶しているから、修了のための課題としてこのサイズのモデルを作ることは想定されていなかったのかもしれない。逆に学習が深まったという点では良かったと思っている。
他にもサイズ問題で悩んだことがある。
最後の最後に動作確認をした際、あらゆる動作がめちゃくちゃモッサリしていたのだ。今時こんなモッサリする?というくらいモッサリである。モッサリすぎてエラーが出ることもある。無料のサーバー故、仕方のないことだということだったのであきらめたが、現場レベルで使えるものをと考えるともっと軽快に動くアプリを作りたいと思った。モデル自体を軽快な物になるように改善するのか、サーバーに課金するのか。他にも手段はありそうだ。

“自閉症識別”について

この先はかなり感覚的な話も入ってくるので、いくらか語弊があるかもしれない。現場の皆様からお叱りを受けるかもしれない。ちょっと怖い。

現場に立っていた頃から「あ、この人自閉かな?」と思う場面は多々あったし、同僚や同業者の中でも「言動や表情からなんとなくわかる」といった話は聞いた。
とはいえそれ以上に自閉症は自閉症スペクトラム障害(ASD)と言われるように、スペクトラム≒グラデーションのようになっているというのがこの業界での(私の知る限り)常識だ。自閉症の支援者・研究者・友人や家族・同僚、もちろん私もスペクトラムの中にいるということで、この記事を読んでいるあなたも例外ではないという考え方だ。
私はこのアプリを開発している間、なんならこの記事を書いている今も、文字通り寝食を忘れたことがあるし、なかなか作業を切り上げられなかったこともある。過集中や切り替えの悪さといった自閉症の特性と重なる。しかし私は自閉症ではない。ではどこからが自閉症なのかと言われても、正直わからない。
自閉症と一口に言っても、精神疾患で似たような症状が出ることもあれば、ADHDや学習障害(LD)、知的障害等と併発していることもあり、ダウン症は高確率で自閉症が併発すると言われている。成長と共にPTSDやうつ病等の精神疾患の可能性が高くなり、診断の難易度も上がる。かといって成長の個人差が大きな時期の診断は、早期療育という言葉とは裏腹に回避する医師も多い。ではいつが適切な診断時期なのかというと、それも私はわからない。個人差もあるだろう。
このアプリを開発すると決めてから今も、そもそも自閉症って何なんだろうという所に何度も立ち返ったが、特性の定義はできても、「あなたは自閉症です」という表現はできないと思った。現場で「コッテコテの自閉」といった表現を使われることがあるが、そういった感じとも感覚が違う。なので今回の識別結果のコメントは可能性が高い・低いという表現にした。それでも尚違和感はあった。

ちなみに成人期の諸々の検査の結果、自閉症の診断をされなかった私の幼少期の写真で診断を行ってみた結果がこちら。

おわかりいただけただろうか。

単にアプリの性能の問題の可能性もあります

自閉症識別アプリの可能性

プログラミングを学び、自閉症について改めて考えた上で、自閉症識別アプリの立ち位置や将来を考えてみた。

そもそも論だが、使ってもらえないことにはこのアプリに対外的な存在価値はないに等しい。周知され、利用され、そこで初めて価値が生まれるだろう。思ったよりも正解率や精度等が高かったこともあり、私のプログラミングの勉強の成果物としての価値だけで終わるには惜しい気持ちがある。できることなら多くの人の役に立ってほしいものだ。
では、多くの人の役に立つために必要なことはなんだろうか。私は周知・動作の改善・性能の向上の3点を挙げたい。

一番手っ取り早そうなのは、ひょっとしたらサーバーへの課金だけで済みそうな動作の改善だろうか。プログラミングの現場の皆様からしたら甘いと言われるかもしれないが、ここまでの取り組みでの体感としてはそんな気がしている。

周知と性能の向上は並行して行いたいというか、そういった形になるのではないかと思う。
性能は高いに越したことはないだろう。今回取り組んでみた性能の限界を打破するためには、まず、まだまだ知らないことが多いので、単純な知識の獲得は必要だろう。また、リソース問題の解決も知識によって行うのか、環境構築によって行うのかといった選択肢がある。もちろん知識と環境両方揃えられたら良いし、協力者がいればより良いはずだ。協力者から知識を得ることもできるだろう。では協力者を得るためには何が必要かというと、やはり周知だろう。こんな取り組みをしている人がいるぞということを知って、興味を持ってもらう必要がある。
更に言えば、協力者はプログラマーに限らないと考えている。今回使ったデータセットではアジア人の比率が少ない。全くないわけではないが、日本人1000人を80%程度の確率で判別できるのかはわからない。今回のアプリは日本人向けの日本語アプリなのだから、データセットには多くの日本人のデータがあることが望ましいだろう。そうすると、顔写真と診断結果の提供者という協力者もいると心強い。ただ、このデータの提供者についてはかなりハードルが高いのではないかと考えている。
自閉症に限らず発達障害という分野の研究は欧米の方が進んでいるといわれている。実際、研修やテキスト等の参考資料も海外の研究データからの引用が多い。研究がすすんでいる分、その国のデータ量が多くなると考えられる。逆にデータが多いから研究が進んでいるのかもしれないが、どちらにせよ、データが集まれば研究が進み、研究が進めばデータも集まるという循環が考えられる。とすると、こんな取り組みがあるということを周知し、この程度の結果が出ているということを知ってもらえれば、データ提供という形での協力者も得やすくなるだろう。
しかし、何度も言うように、現代日本でのこの取り組みは恐らく異端かつ困難だ。自分の利益を守るために発達障害をクローズドにするということは普通に行われている(行わざるを得ない場合もある)し、そもそも発達障害云々以前に、顔写真を(特にメジャーな企業でもない所の)研究データとして提供するということに対しての抵抗も強いだろう。そしてずっと私が抱えているモヤモヤをひとつ吐き出すと、「顔写真から自閉症を判別するアプリを作ってみました!80%程の確率で判別できます!」と業界内で発表したとして、少なくとも今の日本国内では白い目で見られるのがオチな気がしてならないのだ。なんせ異端なのだから。私はこの異端の壁がとてつもなく高く堅牢だと思っている。

さて、異端なアプリが困難を乗り越え、周知され、性能を伸ばしていったとしよう。まだ考えうる課題(いっそ伸びしろと言いたくなってきた)はある。
お気づきの方もいるかもしれないが、今回使用したデータセットは、正確に言えば自閉症“児”と健常“児”、つまり子供(どんなに上でもせいぜい中学生くらいのように見える)だけのデータセットだ。歳を重ねるごとに自閉症とその他精神疾患等との判別が難しくなるという点から、このようなデータセットになったのだろう。検証も子供のみで行っている。つまり、基本的に80%の確率で判別できるのは子供だということになる。
近年“大人の発達障害”という言葉を多く耳にするようになったと思う。幼少期に見逃された障害が、大人になってから大きな困難となってしまうのだ。大人の自閉症のデータが集まり、判別できる年齢層が広がれば、悩む人の一助となる可能性は広がる。もっと言えば、自閉症に限らず精神科の範囲の様々な疾患を網羅し、あらゆる疾患の確率を写真一枚で判別できれば、本格的な診断前に、開き直って少し頑張れるようになる人や、二の足を踏んでいた通院に踏み切れる人が出てくるかもしれない。この点の壁はやはり、グラデーションであったり、二次障害や似た症状の出る精神疾患の可能性が高まる所にあるだろう。
そもそも論になるが、自閉症か否かの診断は、現状、癌や腫瘍のように目に見える形ではない。つまりデータセットのラベリングが間違えている可能性や、どの程度の特性で診断されているのかといった問題を孕んでいる。

問題や困難が多いが、それってつまり、出来たらめちゃくちゃ役に立つ物が仕上がるということではないかとも思う。なんかすごい物に手を付けちゃったな、初心者……

最後に

序盤にも述べたことに立ち返る。
診断があろうがなかろうが、自閉症であろうがなかろうが、困難を抱える人に対して支援は必要だ。自閉症だから絶対にできない・自閉症だから必ず素晴らしい能力があるなんてことはない。必要な支援の内容や程度は人それぞれ違う。
支援の一部を手助けするのがAI等の機械になったとしても、支援や配慮は基本的に人間が行う物だ。診断の有無で支援者が支援の内容をガラッと変えることはあまりない。診断が無意味なわけではないが、“その人”を見て、“その人”に必要なことを支援するだけだからだ。
あなたも私もスペクトラムの中にいる。
大切なのは、診断そのものよりも、いかにその人個人と向き合うのかだということを、どうか覚えていてえほしい。

84
50
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up

Comments

No comments

Let's comment your feelings that are more than good

84
50

Login to continue?

Login or Sign up with social account

Login or Sign up with your email address