[Python]Generationsメンバーの顔識別AIを作成

<h2 data-sourcepos="1:1-1:9">
<span id="はじめに" class="fragment"></span><a href="#%E8%A6%81%E7%B4%84"><i class="fa fa-link"></i></a>はじめに</h2>

<p data-sourcepos="3:1-3:189">私はPython初学者である。今回Aidemyで３ヶ月のアプリ開発コースを受講した成果を示すため、このAI及びブログを作成した。

受講に至った理由①生まれ持ちかつ見逃されていた難病とがんが最近見つかり、肉体労働には向いていないことが明らかになったから。理由②デスクワークできるようなスキルを持っておらず、今まで学んできた心理学や英語では将来食べていけないと思ったから。理由③AIやロボットに関心があり、小学生のときからプログラマーになりたいと思っていたから。

<h2 data-sourcepos="1:1-1:9">
<span id="このテーマを選んだ理由" class="fragment"></span><a href="#%E8%A6%81%E7%B4%84"><i class="fa fa-link"></i></a>このテーマを選んだ理由</h2>

自動運転技術など視空間の認識に関心があったため、とりかかりとして人物認識AIを選んだ。Generationsを選んだ理由は妹が異様にハマっていたこと、調べても既出のプログラムがなかったこと、そして私の推しがいないためである。


<h1>
<span id="目的" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>目的</h1>

人物識別AIを作り、自己のPythonスキルを研鑽する。


<h1>
<span id="開発環境" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>開発環境</h1>

使用端末　iMac Late 2015　Intel
Python 3.9.12
Tensorflow 2.9.1
Keras 2.9.0

改良後はGoogle Colaboratoryを使用

<h1>
<span id="改良前の判定" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>改良前の判定</h1>


ターミナル上で目標フォルダをカレントディレクトリに持ってくる

```
$　cd Desktop
$　cd gene_gazou
```


フォルダ内に学習済みモデルのファイルを準備していく。
そのファイル生成（モデル学習）に使用した実際のPythonコードはこちら
```python:data_prepare.py

from PIL import Image
import os, glob
import numpy as np
from PIL import ImageFile

# IOError: image file is truncated (0 bytes not processed)回避のため
ImageFile.LOAD_TRUNCATED_IMAGES = True

# indexを教師ラベルとして割り当てるため、メンバー名を指定
classes = ["kata", "kazu" ,"sano" , "komo" , "sira", "naka" , "seki"]
num_classes = len(classes)
image_size = 64
num_testdata = 25

X_train = []
X_test  = []
y_train = []
y_test  = []

for index, classlabel in enumerate(classes):
    photos_dir = "./" + classlabel
    files = glob.glob(photos_dir + "/*.jpg")
    for i, file in enumerate(files):
        image = Image.open(file)
        image = image.convert("RGB")
        image = image.resize((image_size, image_size))
        data = np.asarray(image)
        if i < num_testdata:
            X_test.append(data)
            y_test.append(index)
        else:

            # angleに代入される値
            # -20
            # -15
            # -10
            #  -5
            # 0
            # 5
            # 10
            # 15
            # 画像を5度ずつ回転
             for angle in range(-20, 20, 5):

                img_r = image.rotate(angle)
                data = np.asarray(img_r)
                X_train.append(data)
                y_train.append(index)
                # FLIP_LEFT_RIGHT　は 左右反転
                img_trains = img_r.transpose(Image.FLIP_LEFT_RIGHT)
                data = np.asarray(img_trains)
                X_train.append(data)
                y_train.append(index) # indexを教師ラベルとして割り当てる。0から順にメンバー名前を指定。
                
X_train = np.array(X_train)
X_test  = np.array(X_test)
y_train = np.array(y_train)
y_test  = np.array(y_test)

xy = (X_train, X_test, y_train, y_test)
np.save("/Users/admin/Desktop/gene_gazou", xy)
```

まずここで上記のPythonファイルをターミナルで実行。
```
$　python3 data_prepare.py
```

すると、４次元データの入ったNumpyファイルが生成される。

続いて学習済みモデルのファイルを生成していく。Pythonコードは以下。
```python:test.py

from keras.models import Sequential
from keras.layers import Conv2D, MaxPooling2D
from keras.layers import Activation, Dropout, Flatten, Dense
from tensorflow.keras.optimizers import RMSprop
from keras.utils import np_utils
import keras
import numpy as np


# indexを教師ラベルとして割り当てるため、メンバー名を指定
classes = ["kata", "kazu" ,"sano" , "komo" , "sira", "naka" , "seki"]
num_classes = len(classes)
image_size = 64

#データ読み込み
def load_data():
    X_train, X_test, y_train, y_test = np.load("/Users/admin/Desktop/gene_gazou/gene_img", allow_pickle=True)
    # 入力データの各画素値を0-1の範囲で正規化(学習コストを下げるため)
    X_train = X_train.astype("float") / 255
    X_test  = X_test.astype("float") / 255
    # to_categorical()にてラベルをone hot vector化
    y_train = np_utils.to_categorical(y_train, num_classes)
    y_test  = np_utils.to_categorical(y_test, num_classes)

    return X_train, y_train, X_test, y_test



#モデルを学習する関数の定義
def train(X, y, X_test, y_test):
    model = Sequential()

    # Xは(1200, 64, 64, 3)
    # X.shape[1:]とすることで、(64, 64, 3)となり、入力にすることが可能。
    model.add(Conv2D(32,(3,3), padding='same',input_shape=X.shape[1:]))
    model.add(Activation('relu'))
    model.add(Conv2D(32,(3,3)))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2,2)))
    model.add(Dropout(0.1))

    model.add(Conv2D(64,(3,3), padding='same'))
    model.add(Activation('relu'))
    model.add(Conv2D(64,(3,3)))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2,2)))
    model.add(Dropout(0.25))

    model.add(Flatten())
    model.add(Dense(512))
    model.add(Activation('relu'))
    model.add(Dropout(0.45))
    model.add(Dense(7)) # 7クラス分類のため7を指定
    model.add(Activation('softmax'))


    # 最適化アルゴリズムにRMSpropを利用
    opt = RMSprop(lr=0.00005, decay=1e-6)
    model.compile(loss='categorical_crossentropy',optimizer=opt,metrics=['accuracy'])
    model.fit(X, y, batch_size=28, epochs=40)
    # ファイルにKerasのモデルを保存
    model.save('/Users/admin/Desktop/gene_gazou')

    return model


#データの読み込みとモデルの学習
def main():
    # データの読み込み
    X_train, y_train, X_test, y_test = load_data()

    # モデルの学習
    model = train(X_train, y_train, X_test, y_test)

main()
```


上記コードをターミナルで実行しモデルを作成。
```
$ python3 test.py
```


そして画像の人物がGeneメンバーの誰なのか推測するためpredict.pyを実行。

```
$ python3 predict.py
```
predictコードの中身

```python:predict.py
import keras
import sys, os
import numpy as np
from PIL import Image
from keras.models import load_model

imsize = (64, 64)

testpic     = "/Users/admin/Desktop/gene_gazou/uploads/images-1.jpg"　# 検証したい画像のパス
keras_param = "/Users/admin/Desktop/gene_gazou" # 学習モデルの入っているフォルダのパス

def load_image(path):
    img = Image.open(path)
    img = img.convert('RGB')
    # 学習時に、(64, 64, 3)で学習したので、画像の縦・横は今回 変数imsizeの(64, 64)にリサイズします。
    img = img.resize(imsize)
    # 画像データをnumpy配列の形式に変更
    img = np.asarray(img)
    img = img / 255.0
    return img

model = load_model(keras_param)
img = load_image(testpic)
prd = model.predict(np.array([img]))
print(prd) # 精度の表示
prelabel = np.argmax(prd, axis=1)
if prelabel == 0:
    print(">>> kata")
elif prelabel == 1:
    print(">>> kazu")
elif prelabel == 2:
    print(">>> sano")
elif prelabel == 3:
    print(">>> komo")
elif prelabel == 4:
    print(">>> sira")
elif prelabel == 5:
    print(">>> naka")
elif prelabel == 6:
    print(">>> seki")
```


３枚の画像それぞれに顔識別AIを適用した。その画像と結果。
![スクリーンショット 2022-08-09 14.19.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780195/7c37cdb4-0108-02f1-b629-f12d53332cd0.png)× 不正解



![スクリーンショット 2022-08-09 14.24.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780195/a780b6ef-297b-d33c-3141-5318c78d39e2.png)○ 正解



![スクリーンショット 2022-08-09 14.29.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780195/87745cc3-2933-0b9d-c73e-16bdd81bf5be.png)× 不正解

となった。
ということで現状画像にもよるが、モデルの精度はあまり高くない。


<h1>
<span id="モデルの改善" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>モデルの改善</h1>

1.実行環境の変更
実行環境をGoogle colaboratoryに変更

2.画像収集
改良できることとして、まずトレーニング画像を現状の1人100枚から1050枚まで増やす。元画像は100枚から150枚へと増やす。

3.画像の水増し
増やし方は、上下左右ランダム反転・拡大・寸断・角度変更などを想定。

4.モデルの構築
VGG1６で転移学習を行うつもりであったが、精度が上がらないためEfficientnetで転移学習を行う。


5.結果表示方法の変更
本人名が表示されるように改良。

<h1>
<span id="モデル再構築" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>モデル再構築</h1>
<h2>
<span id="1.実行環境の変更" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>1.実行環境の変更</h2>

私のMacではGPUが使えないため、コードの処理に時間がかかる。そのため無料でGPUが使用可能なGoogle Colaboratoryにてコードを記述する。
![スクリーンショット 2022-08-23 12.55.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780195/18f777a6-1207-31c3-75ee-4b0ef3846d80.png)
Google Colaboratoryはブラウザ上での開発になるため、使用するファイルもブラウザから読み込める場所にアップロードしなければならない。アップロード先はGoogle Driveであり、DriveがPCの情報を取得するためにマウントをさせる必要がある。


マウントの操作は以下のコードを実行後、許可を与えるだけである。
```Python
from google.colab import drive
drive.mount('/content/drive')
```

<h2>
<span id="2.画像の収集" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>2.画像の収集</h2>

画像の収集に使用できるコードは以下の通りである。

```python
from icrawler.builtin import BingImageCrawler
search_words = ['白濱亜嵐', '小森隼', '佐野玲於', '片寄涼太', '数原龍友', '中務裕太', '関口メンディー']
dir_names = ['白濱亜嵐', '小森隼', '佐野玲於', '片寄涼太', '数原龍友', '中務裕太', '関口メンディー']
for search_word,dir_name in zip(search_words,dir_names):
  # Bing用クローラーの生成
  bing_crawler = BingImageCrawler(
      downloader_threads=5, # ダウンローダーのスレッド数
      storage={'root_dir': "保存先のディレクトリパス" + dir_name}) 

  # クロール（キーワード検索による画像収集）の実行
  bing_crawler.crawl(
      keyword=search_word, # 検索ワード
      max_num=300 )　# 各検索画像をダウンロードする最大枚数。

```

これを行うと検索ワードごとに違う枚数の画像が保存される。保存された画像の中には二人以上が写り込んでいるものや、違う人物の画像も紛れている。そのため検索ワードによっては必要枚数に満たないこともあり、最大保存枚数は300とした。集めた画像から不要なものを取り除き、それぞれ150枚となるよう削除を行った。
これで150枚×7人分、つまり1050枚が準備できた。

<h2>
<span id="3.画像の分割・読み込み・水増し" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>3.画像の分割・読み込み・水増し</h2>

まず、どこのディレクトリで作業するのかをGoogle Colaboratoryにコードとして記述する。私の場合、先ほど画像を保存したディレクトリのパスが
/content/drive/MyDrive/Colab Notebooks/gene_gazou/gene_imgだったので
```Python
%cd '/content/drive/MyDrive/Colab Notebooks/gene_gazou/gene_img'
```
と入力。
必要なモジュールを全てインポート。
```Python

import os
import cv2
import numpy as np
import matplotlib.pyplot as plt
from keras.utils.np_utils import to_categorical
from keras.layers import Dense, Dropout, Flatten, Input
from keras.applications.vgg16 import VGG16
from keras.models import Model, Sequential
from tensorflow.keras import optimizers
import glob
import tensorflow as tf
from tensorflow.keras.preprocessing.image import ImageDataGenerator
import copy
import matplotlib.pyplot as plt

```
訓練用データとテスト用データを準備していく。
途中で画像の水増しをする関係で、先にデータを分けて記述する必要がある。
```Python
path_list = []
train_image_list = [] # 白濱1-122、小森1-122...
train_num_list = []
test_image_list = []
test_num_list = []
dir_names = ['白濱亜嵐', '小森隼', '佐野玲於', '片寄涼太', '数原龍友', '中務裕太', '関口メンディー']
for dir_name in dir_names: 
  path = glob.glob('./'+dir_name+'/*.jpg')
  path_list += path
  split_value = int(len(path)*0.8) # 150の8割だけつかう。割り切れない値になる可能性もあるので無理やり整数にする。
  for tr in path[:split_value]:
    train_image_list.append(tr)
  train_num_list.append(len(path[:split_value]))
  for te in path[split_value:]:
    test_image_list.append(te)
  test_num_list.append(len(path[split_value:]))
  print(path_list)

```
画像を読み込み、今回は水増しのためImageDataGeneratorを使い、元画像をさまざまな形にランダムで変換する。それを１つの元画像につき７枚ずつ生成する。ImageDataGeneratorに画像を入れるときは４次元である必要があったため、reshapeで1次元増やし、入れた後またそれを戻している。

```python
train_list= []
for i in range(len(train_image_list)):
    img = cv2.imread(train_image_list[i])
    img = cv2.resize(img, (224,224))
    img = cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    train_list.append(img)
    plt.imshow(img)
    img = img.reshape(1,*img.shape)
    datagen1 = ImageDataGenerator(rotation_range=45., # ランダムに回転する回転
                              width_shift_range=3., # ランダムに水平方向に平行移動する、画像の横幅に対する割合
                              height_shift_range=3., # ランダムに垂直方向に平行移動する、画像の縦幅に対する割合
                              channel_shift_range=50, # 入力がRGB3チャンネルの画像の場合、R,G,Bそれぞれにランダムな値を足したり引いたりする。(0~255)
                              horizontal_flip=True # ランダムに水平方向に反転
    for i in range(7):   
      generator = datagen1.flow(img, batch_size=1)
      batch_x = generator.next()
      batch_x = batch_x.astype(np.uint8)
      batch_x = batch_x.reshape(224,224,3)
      train_list.append(batch_x)

```
生成された画像はこんな感じ
![スクリーンショット 2022-08-30 17.17.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780195/c3d0b9e5-9598-2e4c-9111-e0a90ffbf96b.png)

↓これらの画像を表示するためのコード
```python
plt.figure(figsize=(10,10))
for i in range(7):
    plt.subplot(2,4,i+1)
    plt.imshow(img_list[i].reshape(224,224,3))
    plt.tick_params(labelbottom='off')
    plt.tick_params(labelleft='off')
```

この７枚ずつ×（150枚の８割）＋元画像1枚×(150枚の８割)のリストが完成。
続いてテスト用画像データのリストを作成。こちらは水増しするとテスト用データとして成り立たないため、枚数はそのまま。
```python
test_list= []
# for i in range(len(path_list)):
for i in range(len(test_image_list)):
    img = cv2.imread(test_image_list[i])
    img = cv2.resize(img, (224,224))
    img = cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    test_list.append(img) # 追加
```

<h2>
<span id="4.モデル構築" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>4.モデル構築</h2>

画像データのリストを４次元の数字に変える。訓練用データは水増しされているため、正解ラベルのインデックス番号が8倍されている。

```python
train_X = np.array(train_list)
train_y =  np.array( [0]*train_num_list[0]*8 + [1]*train_num_list[1]*8 + [2]*train_num_list[2]*8+ [3]*train_num_list[3]*8+ [4]*train_num_list[4]*8+[5]*train_num_list[5]*8+[6]*train_num_list[6]*8)
test_X = np.array(test_list)
test_y =  np.array( [0]*test_num_list[0] + [1]*test_num_list[1] + [2]*test_num_list[2]+ [3]*test_num_list[3]+ [4]*test_num_list[4]+[5]*test_num_list[5]+[6]*test_num_list[6])

```

学習データとして使えるようにランダムにする。
```python
rand_index = np.random.permutation(np.arange(len(X)))
X = X[rand_index]
y = y[rand_index]
```


正解ラベルをone-hotの形にします
```python
train_y_hot = to_categorical(y_train)
test_y_hot = to_categorical(y_test)
```
まずモデル構築に必要なものをインポート
```Python
from tensorflow.python.ops.gen_array_ops import shape
from tensorflow.keras.applications.efficientnet import EfficientNetB0
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, GlobalAveragePooling2D, Dense
```

転移学習を行うためEfficientnetを定義
```python
inputs = Input(shape=(224,224,3))
base_model = EfficientNetB0(include_top=False,weights="imagenet",input_tensor = inputs,input_shape=(224,224,3))

base_model.trainable = False # EfficientNetの重みは学習しない
x = GlobalAveragePooling2D()(base_model.output)
x = BatchNormalization()(x)
outputs = Dense(len(dir_names),activation='softmax')(x)
```
Efficientnetとtop_modelを連結
```python
model = Model(inputs=base_model.input, outputs=outputs)
```

コンパイルする
```python
model.compile(loss='categorical_crossentropy', # どれくらい正解と離れているか 
              optimizer='adam',
              metrics=['accuracy'])
```
学習を行います val_accuracyを0.8をめざす。
```python
history=model.fit(X_train, y_train_hot, batch_size=16, epochs=10, validation_data=(X_test, y_test_hot))
```
しかし、エポックやパラメータを変更しても予測精度は5割から上がらず...
```python
Epoch 1/10
420/420 [==============================] - 21s 37ms/step - loss: 1.0594 - accuracy: 0.6310 - val_loss: 1.4595 - val_accuracy: 0.5000
Epoch 2/10
420/420 [==============================] - 15s 35ms/step - loss: 0.5072 - accuracy: 0.8301 - val_loss: 1.6657 - val_accuracy: 0.5095
Epoch 3/10
420/420 [==============================] - 14s 34ms/step - loss: 0.3719 - accuracy: 0.8801 - val_loss: 1.7120 - val_accuracy: 0.5429
Epoch 4/10
420/420 [==============================] - 15s 35ms/step - loss: 0.3002 - accuracy: 0.9018 - val_loss: 1.7778 - val_accuracy: 0.5333
Epoch 5/10
420/420 [==============================] - 14s 35ms/step - loss: 0.2567 - accuracy: 0.9205 - val_loss: 1.8829 - val_accuracy: 0.5333
Epoch 6/10
420/420 [==============================] - 14s 34ms/step - loss: 0.2234 - accuracy: 0.9292 - val_loss: 2.0648 - val_accuracy: 0.5238
Epoch 7/10
420/420 [==============================] - 15s 35ms/step - loss: 0.2163 - accuracy: 0.9299 - val_loss: 1.9511 - val_accuracy: 0.5476
Epoch 8/10
420/420 [==============================] - 14s 34ms/step - loss: 0.1857 - accuracy: 0.9409 - val_loss: 2.1386 - val_accuracy: 0.5238
Epoch 9/10
420/420 [==============================] - 14s 34ms/step - loss: 0.1824 - accuracy: 0.9372 - val_loss: 2.2183 - val_accuracy: 0.5190
Epoch 10/10
420/420 [==============================] - 14s 34ms/step - loss: 0.1698 - accuracy: 0.9430 - val_loss: 2.2089 - val_accuracy: 0.5429
```
カレントディレクトリにこのモデルを保存する
```python
model.save('./cnn.h5')
```

正解率の可視化
```python
plt.plot(history.history['loss'], label='loss', ls='-')
#plt.plot(history.history['val_accuracy'], label='val_acc', ls='-')
plt.plot(history.history['val_loss'], label='val_loss', ls='-')
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(loc='best')
plt.show()
```
![スクリーンショット 2022-08-30 16.58.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780195/09a3f42f-cae4-bf24-5fd6-90aff3c2b5b6.png)


<h1>
<span id="改良後の予測" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>改良後の予測</h1>

予測に使用するコードはこちら。

```python

import keras
import sys, os
import numpy as np
from PIL import Image
from keras.models import load_model


testpic     = "/content/drive/MyDrive/sira.jpg" # 予測したい画像のパス
keras_param = "/content/drive/MyDrive/gene_gazou/gene_img/cnn.h5"　#　学習済みモデルのパス

img = cv2.imread(testpic)
img = cv2.resize(img, (224,224))
img = cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
img = np.asarray(img)
img = img / 255

plt.imshow(img)
plt.show()
pred = np.argmax(model.predict(x.reshape(1,224,224,3)))
if pred == 0:
    print(">>> 白濱亜嵐")
elif pred == 1:
    print(">>> 小森隼")
elif pred == 2:
    print(">>> 佐野玲於")
elif pred == 3:
    print(">>> 片寄涼太")
elif pred == 4:
    print(">>> 数原龍友")
elif pred == 5:
    print(">>> 中務裕太")
elif pred == 6:
    print(">>> 関口メンディー")

```
Webアプリとして実装後、挙動を確認...
https://mnistapp12.herokuapp.com/
![スクリーンショット 2022-09-02 13.23.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780195/4d7506cf-f3e2-0974-2133-6553b5bf5372.png)
ハズレでしたが、しっかり動いているのが確認できました。
今回のモデルは精度が５６％ほどなので2、３枚試すと1枚当たってるくらいのレベルです。


<h1>
<span id="改良の余地" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>改良の余地</h1>

1.今回は時間の関係上、画像から顔のみを切り抜く操作は追加できなかった。もし、実装できれば精度は大幅に上がるだろう。顔の特徴と身体的特徴を別々に学習するものが作れたらより、精度は向上しそうだ。

2.結果の表示方法は名前と写真だけになってしまったが、元画像のどの特徴からその人物だと判定したのかを見える形で提示できれば、より使用感を楽しめると思われる。

3.訓練データが1人につき960枚あるのに対し、テストデータが30枚と明らかにバランスがおかしかった。テストデータの数は水増しなしの素の画像をもっと集めた方が良いだろう。

4.画像は毎回ランダムで違うものを生成するので学習がうまくいかないのではないかという指摘もあった。途中でnumpyデータを保存してロードする形にすれば、画像が変更されることはないので予測精度の向上につながるかもしれない。

<h1>
<span id="終わりに" class="fragment"></span><a href="#tinder%E3%81%A8%E3%81%AF"><i class="fa fa-link"></i></a>終わりに</h1>

最後まで読んで下さりありがとうございました。
これが何かの参考になれば幸いです。

また、助力してくださった講師の皆様に感謝を申し上げます。