見出し画像

第34話 オリジナルAIの実装失敗・・・そこから見えた新しい世界

約4ヶ月の学習期間を経てようやくディープラーニングを実装できるだけの知識が備わったので、学んだことを活かしてオリジナルAIを実装しました!
そしてタイトルに書いたとおり見事に失敗しました^^;

でも失敗したとはいえ、大きな学びがありました^^v
今回は次の観点からお話し
・AIを実装するまでの思考のプロセス
・プログラムの実行結果、考察
・結果から得た学び

どんなAIを作るのか

今回AIで実現したいことはこれです。ドゥルルルル〜ジャン!

「細胞を数えるAI」

・・・えっ?なにそれ?って思いますよね^^;

でもこれは、実は私がAIを始めようと思ったきっかけなのです。

ディープラーニングを勉強し始めたきっかけ

私の妻は研究者でして、開発した手法が細胞にどのような影響を与えるか評価するときに、細胞の数を数えているのです。
でもそれが非常に地味で大変。
下のような画像の青い点をひたすら数えるのです。

名称未設定

1枚の画像には細胞が1000個にも及ぶことも。しかもそれを何枚も何枚も…。

「研究の世界は泥臭い」なんて言われますが、もうそんな時代じゃありません!
高い知的生産ができる人材が、こんな付加価値のないつまらないことに時間を取られているなんて許せない!
だから「私がAIを作って妻の仕事を楽にしてあげよう」と決意したのでした。

ディープラーニング作成の方針

ディープラーニングを実装するには、以下のステップが必要になってきます。

Step 1
学習・評価するためのデータセットを作る。
Step 2
データセットをpythonで取り込む。
Step 3
畳み込みニューラルネットワークを回す。
Step 4
実行結果を確認する。

教科書ではデータセットが用意されていたので楽チンでしたが、自分でAIを作るとなるとデータセットも自分で用意することになります。

データセットを作ってみる

1枚の画像サイズは960×720ピクセル。これ1枚をまるまる学習させるのは流石にナンセンスと思い、分割してデータを作ることにします。

名称未設定

でも分割サイズはどのように設定したら良いのだろう?
というかニューラルネットワークの学習は、正解とネットワークの予測値との誤差をフィードバックするので、正解のデータを用意しなくてはなりません。でも正解の定義なんてありません、困った困った。
早くも2つの石につまづきました^^;

問題 1
分割サイズをどうしよう?
問題 2
正解をどう定義しよう?

とりあえず細胞がいくつか収まるサイズで分割すれば良いか。そして分割された画像の中に何個細胞が入っているかを正解にしてみよう。というわけで問題に対する考え方はこうなりました。

問題 1 に対する考え方
分割サイズをどうしよう? → 40×40ピクセル
問題 2 に対する考え方
正解をどう定義しよう? → 分割後の画像に入っている細胞数

では早速画像を分割してみます。
とはいってもそんなやり方なぞ当然わかるはずもなく、この工程をブレイクダウンして、その都度わからないところをググっていきます。
最終的に出来上がったのがコチラです。

import os
from PIL import Image
import numpy as np
import cv2

os.chdir("/Desktop/cellcount/")
filename = "sample"

# 切り取る画像のサイズ
trim_size = 40

# 元となる画像の読み込み
im = Image.open(filename + ".png")

#オリジナル画像の幅と高さを取得
width, height = im.size

w_lim = width // trim_size
h_lim = height // trim_size

im_data = []
for j in range(h_lim):
   for i in range(w_lim):
       im_crop = im.crop((i*trim_size, j*trim_size,
                          0+(i+1)*trim_size, (j+1)*trim_size))
       temp = np.array(im_crop)
       im_data = np.append(im_data, temp)
       im_num = '{:0=4}'.format(i)
       im_crop.save(filename + "_" + str(j) + "_"+ im_num + ".png")

print("complete")

こう書くとサクッとできちゃってる感がありますが、実際は次のような具合で試行錯誤の繰り返しです^^;

画像の読み込みはどうする?→Image.open関数か、読み込めるかやってみよう→できた。次は画像の切り出しだ。どうやるんだ?→im.crop関数か。切り出してみよう。うーん、なぜかうまくいかないぞ。→関数の使い方間違ってるじゃん。これでどうだ?できた。→最後にこれを自動で分割するぞ。縦と横にfor文を組んだらいいけど、いっぺんにやってもどうせ失敗するから最初は横方向に分割しよう。→できた。次は縦方向だ。できた。→できた画像を確認してみよう。あれ?縦と横の分割の順番がうまく行ってないぞ。→iとjの指定が逆だった。これでどうだ?→やっとできた!

さて、これで画像の分割が自動でできるようになりました。
次は正解のデータを作っていきます。分割された画像に何個細胞がいるかが正解になるわけですから、誰かが細胞を数えなくてはなりません。

誰が数えるかって?それはもちろん私です。
先ほどのソースコードを使えば一瞬で10個でも100個でも分割画像を作れるのですが、正解データを作るのはかなり時間がかかります。
心が挫けそうになりながらも100個超の画像の細胞数を数えました。正解データをcsv形式に保存し、pythonに取り込みます。

# 正解データをcsvから取り込む
data_list = np.genfromtxt("dataset_csv.csv", delimiter=',')
correct = data_list[1:,2]

このデータをpython上で「画像データのi番目⇄i番目画像の細胞数」といった具合に、画像の配列データと対応させる必要があります。実は先ほど生成した画像のファイル名は、取り込んだcsvの正解値とうまくリンクするように工夫しています。(ソートがうまく昇順になるようにファイル名につける数字を4桁にしました)

というわけでようやくデータセットの作成が完了しました。

いざディープラーニング!

先ほど生成したデータセットをpythonに読み込ませて、畳み込みディープラーニング(CNN)のソースコードに当てはめていきます。CNNの構成は次のようになります。

名称未設定

コードは、手書きの数字を文字識別するサンプルを応用して作りました。
説明は割愛しますが、ソースコードはこんな感じです。(長いのですっ飛ばしても大丈夫です)

import os
import numpy as np
import glob
import cv2
import csv
import numpy as np
import matplotlib.pyplot as plt
# from sklearn import datasets


# CNN

# データセットの読み込み
# 画像をnumpyに取り込む
filename = "sample"
image_dir = ""
search_pattern = filename + "_*.png" # sample_0000.png, sample_0001.png1…を読み込む
datas = []
l = sorted(glob.glob(os.path.join(image_dir,search_pattern)))
for image_path in l:
   # (height,width,channels)
   data = cv2.imread(image_path)
   # (1,height,width,channels)
   data_expanded = np.expand_dims(data,axis=0)
   datas.append(data_expanded)
   
# (n_samples,height,width,channels)
input_data = np.concatenate(datas,axis=0)

# 入力データの標準化
ave_input = np.average(input_data)
std_input = np.std(input_data)
input_data = (input_data - ave_input) / std_input


# 正解データをcsvから取り込む
data_list = np.genfromtxt("dataset_csv.csv", delimiter=',')
correct = data_list[1:,2]
n_data = len(correct)
correct_data = np.reshape(correct, (n_data, 1))

# 訓練データとテストデータ
index = np.arange(n_data)
index_train = index[index%2 != 0]  # 読み込んだデータのうち半分を訓練データに
index_test = index[index%2 == 0]   # 読み込んだデータのうち残り半分をテスト用データに

input_train = input_data[index_train, :]     # 訓練 入力
correct_train = correct_data[index_train]    # 訓練 正解
input_test = input_data[index_test, :]       # テスト 入力
correct_test = correct_data[index_test]      # テスト 正解

n_train = input_train.shape[0] # 訓練データのサンプル数
n_test = input_test.shape[0]   # テストデータのサンプル数

# 各設定値
img_h = 40        # 入力画像の高さ
img_w = 40        # 入力画像の幅
img_ch = 3        # 入力画像のチャンネル数

wb_width = 0.1   # 重みとバイアスの広がり具合
eta = 0.01       # 学習係数
epoch = 50
batch_size = 2
interval = 10    # 経過の表示間隔
n_sample = 200   # 誤差計測のサンプル数

# im2col
def im2col(images, flt_h, flt_w, out_h, out_w, stride, pad):
   
   n_bt, n_ch, img_h, img_w = images.shape
   
   img_pad = np.pad(images, [(0,0), (0,0), 
                             (pad,pad), (pad,pad)],
                    "constant")
   cols = np.zeros((n_bt, n_ch, flt_h, flt_w, out_h, out_w))
   
   for h in range(flt_h):
       h_lim = h + stride*out_h
       for w in range(flt_w):
           w_lim = w + stride*out_w
           cols[:, :, h, w, :, :] = img_pad[:, :, 
                                            h:h_lim:stride, 
                                            w:w_lim:stride]
           
   cols = cols.transpose(1, 2, 3, 0, 4, 5).reshape(
       n_ch*flt_h*flt_w, n_bt*out_h*out_w)
   return cols

# col2im
def col2im(cols, img_shape, flt_h, flt_w, out_h, out_w, stride, pad):
   
   n_bt, n_ch, img_h, img_w = img_shape
   
   cols = cols.reshape(n_ch, flt_h, flt_w, n_bt, 
                       out_h, out_w).transpose(3, 0, 1, 2, 4, 5)
   images = np.zeros((n_bt, n_ch, img_h+2*pad+stride-1,
                      img_w+2*pad+stride-1))
   
   for h in range(flt_h):
       h_lim = h + stride*out_h
       for w in range(flt_w):
           w_lim = w + stride*out_w
           images[:, :, h:h_lim:stride, w:w_lim:stride] += cols[:,
                                                                :,
                                                                h,
                                                                w,
                                                                :,
                                                                :,]
           
   return images[:, :, pad:img_h+pad, pad:img_w+pad]

# 畳み込み層
class ConvLayer:
   # n_bt:バッチサイズ、x_ch:入力チャンネル数
   # x_h:入力画像高さ、x_w:入力画像幅
   # n_flt:フィルタ数、flt_h:フィルタ高さ、flt_w:フィルタ幅
   # stride:ストライド幅、pad:パティング幅
   # y_ch:出力チャンネル数、y_h:出力高さ、y_w:出力幅
   
   def __init__(self, x_ch, x_h, x_w, n_flt, flt_h, flt_w,
                stride, pad):
       
       # パラメータをまとめる
       self.params = (x_ch, x_h, x_w, n_flt, flt_h, flt_w,
                      stride, pad)
       
       # フィルタとバイアスの初期値
       self.w = wb_width * np.random.randn(n_flt, x_ch,
                                           flt_h, flt_w)
       self.b = wb_width * np.random.randn(1, n_flt)
       
       self.y_ch = n_flt  # 出力チャンネル数
       self.y_h = (x_h - flt_h + 2*pad) // stride + 1 # 出力高さ
       self.y_w = (x_w - flt_w + 2*pad) // stride + 1 # 出力幅
       
       # AdaGrad用
       self.h_w = np.zeros((n_flt, x_ch, flt_h, flt_w)) + 1e-8
       self.h_b = np.zeros((1, n_flt)) + 1e-8
       
   def forward(self, x):
       n_bt = x.shape[0]
       x_ch, x_h, x_w, n_flt, flt_h, flt_w, stride, pad = self.params
       y_ch, y_h, y_w = self.y_ch, self.y_h, self.y_w
       
       # 入力画像とフィルタを行列に変換
       self.cols = im2col(x, flt_h, flt_w, y_h, y_w, stride, pad)
       self.w_cols = self.w.reshape(n_flt, x_ch*flt_h*flt_w)
       
       # 出力の計算:行列積、バイアスの加算、活性化関数
       u = np.dot(self.w_cols, self.cols).T + self.b
       self.u = u.reshape(n_bt, y_h, y_w, y_ch).transpose(0, 3, 1, 2)
       self.y = np.where(self.u <= 0, 0, self.u)
       
   def backward(self, grad_y):
       n_bt = grad_y.shape[0]
       x_ch, x_h, x_w, n_flt, flt_h, flt_w, stride, pad = self.params
       y_ch, y_h, y_w = self.y_ch, self.y_h, self.y_w
       
       # delta
       delta = grad_y * np.where(self.u <= 0, 0, 1)
       delta = delta.transpose(0,2,3,1).reshape(n_bt*y_h*y_w,y_ch)
       
       # フィルタとバイアスの勾配
       grad_w = np.dot(self.cols, delta)
       self.grad_w = grad_w.T.reshape(n_flt, x_ch, flt_h, flt_w)
       self.grad_b = np.sum(delta, axis=0)
       
       # 入力の勾配
       grad_cols = np.dot(delta, self.w_cols)
       x_shape = (n_bt, x_ch, x_h, x_w)
       self.grad_x = col2im(grad_cols.T, x_shape, flt_h, flt_w,
                            y_h, y_w, stride, pad)
       
   def update(self, eta):
       self.h_w += self.grad_w * self.grad_w
       self.w -= eta / np.sqrt(self.h_w) * self.grad_w
       
       self.h_b += self.grad_b * self.grad_b
       self.b -= eta / np.sqrt(self.h_b) * self.grad_b

# プーリング層
class PoolingLayer:
   
   # n_bt:バッチサイズ、x_ch:入力チャンネル数
   # x_h:入力画像高さ、x_w:入力画像幅
   # pool:プーリング領域のサイズ、pad:パディング幅
   # y_ch:出力チャンネル数、y_h:出力高さ、y_w:出力幅
   
   def __init__(self, x_ch, x_h, x_w, pool, pad):
       
       # パラメータをまとめる
       self.params = (x_ch, x_h, x_w, pool, pad)
       
       self.y_ch = x_ch  # 出力チャンネル数
       self.y_h = x_h//pool if x_h%pool==0 else x_h//pool+1  # 出力高さ
       self.y_w = x_w//pool if x_w%pool==0 else x_w//pool+1  # 出力幅
       
   def forward(self, x):
       n_bt = x.shape[0]
       x_ch, x_h, x_w, pool, pad = self.params
       y_ch, y_h, y_w = self.y_ch, self.y_h, self.y_w
       
       # 入力画像を行列に変換
       cols = im2col(x, pool, pool, y_h, y_w, pool, pad)
       cols = cols.T.reshape(n_bt*y_h*y_w*x_ch, pool*pool)
       
       # 出力の計算:Maxプーリング
       y = np.max(cols, axis=1)
       self.y = y.reshape(n_bt, y_h, y_w, x_ch).transpose(0, 3, 1, 2)
       
       # 最大値のインデックスを保存
       self.max_index = np.argmax(cols, axis=1)
       
   def backward(self, grad_y):
       n_bt = grad_y.shape[0]
       x_ch, x_h, x_w, pool, pad = self.params
       y_ch, y_h, y_w = self.y_ch, self.y_h, self.y_w
       
       # 出力の勾配の軸を入れ替え
       grad_y = grad_y.transpose(0, 2, 3, 1)
       
       # 行列の作成
       grad_cols = np.zeros((pool*pool, grad_y.size))
       # 各列の最大値であった要素にのみ出力の勾配を入れる
       grad_cols[self.max_index.reshape(-1),
                 np.arange(grad_y.size)] = grad_y.reshape(-1)
       grad_cols = grad_cols.reshape(pool, pool, n_bt, y_h, y_w, y_ch)
       grad_cols = grad_cols.transpose(5,0,1,2,3,4)
       grad_cols = grad_cols.reshape(y_ch*pool*pool, n_bt*y_h*y_w)
       
       # 入力の勾配
       x_shape = (n_bt, x_ch, x_h, x_w)
       self.grad_x = col2im(grad_cols, x_shape, pool, pool,
                            y_h, y_w, pool, pad)
       
# 全結合層の継承元
class BaseLayer:
   def __init__(self, n_upper, n):
       self.w = wb_width * np.random.randn(n_upper, n)
       self.b = wb_width * np.random.randn(n)
       
       self.h_w = np.zeros((n_upper, n)) + 1e-8
       self.h_b = np.zeros(n) + 1e-8
       
   def update(self, eta):
       self.h_w += self.grad_w * self.grad_w
       self.w -= eta / np.sqrt(self.h_w) * self.grad_w

       self.h_b += self.grad_b * self.grad_b
       self.b -= eta / np.sqrt(self.h_b) * self.grad_b

# 全結合中間層
class MiddleLayer(BaseLayer):
   def forward(self, x):
       self.x = x
       self.u = np.dot(x, self.w) + self.b
       self.y = np.where(self.u <= 0, 0, self.u)
       
   def backward(self, grad_y):
       delta = grad_y * np.where(self.u <= 0, 0, 1)
       
       self.grad_w = np.dot(self.x.T, delta)
       self.grad_b = np.sum(delta, axis=0)
       
       self.grad_x = np.dot(delta, self.w.T)
       
# 全結合出力層
class OutputLayer(BaseLayer):
   def forward(self, x):
       self.x = x
       u = np.dot(x, self.w) + self.b
       self.y = u
       
   def backward(self, t):
       delta = self.y - t
       
       self.grad_w = np.dot(self.x.T, delta)
       self.grad_b = np.sum(delta, axis=0)
       
       self.grad_x = np.dot(delta, self.w.T)
       
# 各層の初期化
cl_1 = ConvLayer(img_ch, img_h, img_w, 10, 2, 2, 1, 1)
pl_1 = PoolingLayer(cl_1.y_ch, cl_1.y_h, cl_1.y_w, 2, 0)

n_fc_in = pl_1.y_ch * pl_1.y_h * pl_1.y_w
ml_1 = MiddleLayer(n_fc_in, 100)
ol_1 = OutputLayer(100, 1)

# 順伝播
def forward_propagation(x):
   n_bt = x.shape[0]
   
   images = x.reshape(n_bt, img_ch, img_h, img_w)
   cl_1.forward(images)
   pl_1.forward(cl_1.y)
   
   fc_input = pl_1.y.reshape(n_bt, -1)
   ml_1.forward(fc_input)
   ol_1.forward(ml_1.y)
   
# 逆伝播
def backward_propagation(t):
   n_bt = t.shape[0]
   
   ol_1.backward(t)
   ml_1.backward(ol_1.grad_x)
   
   grad_img = ml_1.grad_x.reshape(n_bt, pl_1.y_ch,
                                  pl_1.y_h, pl_1.y_w)
   pl_1.backward(grad_img)
   cl_1.backward(pl_1.grad_x)
   
# 重みとバイアスの更新
def uppdate_wb():
   cl_1.update(eta)
   ml_1.update(eta)
   ol_1.update(eta)
   
# 二乗和誤差を計算
def get_error(t, batch_size):
   return 1.0/2.0 * np.sum(np.square(ol_1.y - t))
# サンプルを順伝播
def forward_sample(inp, correct, n_sample):
   index_rand = np.arange(len(correct))
   np.random.shuffle(index_rand)
   index_rand = index_rand[:n_sample]
   x = inp[index_rand, :]
   t = correct[index_rand]
   forward_propagation(x)
   return x, t

# 誤差の記録用
train_error_x = []
train_error_y = []
test_error_x = []
test_error_y = []

# 学習と経過の記録
n_batch = n_train // batch_size
for i in range(epoch):
   
   # 誤差の計測
   x, t = forward_sample(input_train, correct_train, n_sample)
   error_train = get_error(t, n_sample)
   
   x, t = forward_sample(input_test, correct_test, n_sample)
   error_test = get_error(t, n_sample)
   
   # 誤差の記録
   train_error_x.append(i)
   train_error_y.append(error_train)
   test_error_x.append(i)
   test_error_y.append(error_test)

   # 経過の表示
   if i%interval == 0:
       print("Epoch:" + str(i) + "/" + str(epoch),
             "Error_train:" + str(error_train),
             "Error_test:" + str(error_test))
       
   # 学習
   index_rand = np.arange(n_train)
   np.random.shuffle(index_rand)
   for j in range(n_batch):
       
       mb_index = index_rand[j*batch_size : (j+1)*batch_size]
       x = input_train[mb_index, :]
       t = correct_train[mb_index]
       
       forward_propagation(x)
       backward_propagation(t)
       uppdate_wb()
       
# 誤差の記録をグラフ表示
plt.plot(train_error_x, train_error_y, label="Train")
plt.plot(test_error_x, test_error_y, label="Test")
plt.legend()

plt.xlabel("Epochs")
plt.ylabel("Errors")
plt.show()

# 正解率の測定
x, t = forward_sample(input_train, correct_train, n_train)
count_train = np.sum(np.argmax(
   ol_1.y, axis=1) == np.argmax(t, axis=1))

x, t = forward_sample(input_test, correct_test, n_test)
count_test = np.sum(np.argmax(
   ol_1.y, axis=1) == np.argmax(t, axis=1))

print("Accuracy Train:", str(count_train/n_train*100) + "%",
    "Accuracy Test:", str(count_test/n_test*100) + "%")


# 学習済みCNNに画像判定させてみる
forward_propagation(input_test)
plt.scatter(correct_test.reshape(-1), ol_1.y.reshape(-1))
plt.show()

さて、気になる実行結果です。じゃん!

名称未設定2

ん!?これは・・・

青線は訓練データ(AIに学習させたデータ)の誤差を、オレンジ線はテストデータ(AIを評価するためのデータ)の誤差をそれぞれ表しております。
青はゼロに近づいているので学習自体はできているみたいですが、対してオレンジは誤差がめちゃくちゃ残ってます。ということはつまり、学習してもうまく予測できていないことになります。なぜだー!

ハイパーパラメータを変えてみた

ニューラルネットワークは、学習する前に自分で設定するパラメータがいくつもあります。これを調整したらあるいはうまくいくかもしれない、と思いやってみることにしました。

ニューロンを増やしてみる
ニューロンを増やすとネットワークの表現力が増えるため、うまく学習できるのではないかと考えました。
全結合層の中間層と出力層はそれぞれ100個のニューロンとしていたのを200個、1000個としてみました。
ニューロンを増やしてもバックプロパゲーションという学習アルゴリズムが自動でチューニングしてくれるので楽チンです。バックプロパゲーションさまさまです。

では結果をお示しします。じゃん!

名称未設定

うへー、よくなってなーい^^;
どうやらニューロン数の問題ではないようです。

フィルタサイズ・バッチサイズを変えてみる
ニューロン数を元に戻して違うパラメータを変えてみることにしました。

まずはフィルタサイズです。取り込んだ画像はニューラルネットワークで学習する前に畳み込み層でフィルタリングされます。このフィルタのパラメータも学習により自動調整されるのですが、大きさは予め設定した値のまま変わることはありません。

次にバッチサイズを変えてみます。これを変えると過学習(ネットワークが訓練データに適応されすぎて未知のデータをうまく予測できない事象)を抑える事ができます。とりあえず2倍のサイズにしてみました。

では結果をお示しします。じゃん!

名称未設定2

うへー、よくならなーい^^;
フィルタサイズ・バッチサイズの問題でもないようです。

一体何が原因なのか?

ハイパーパラメータを変えても、訓練データに対しては誤差が小さくなっているので学習自体はうまくいっているように思います。
では一体何が原因なんだろう?

確証はないですが、生成したデータセットに原因があったのではないかと考えています。考えられる原因を挙げてみます。

考えられる原因
・画像サイズがふさわしくない
・正解の定義がふさわしくない
・データが足りない
・データを選別してない

原因を潰し込んでいこうかと思ったのですが、場当たり的に試しても徒労に終わりそう…。ここは一旦立ち止まって、今後のあり方を考えた方が良さそうです。

見えてきた新しい世界

初めて挑戦したオリジナルAIでしたが、結果は惨敗でした。世の中そううまくはいかないってことですね。

今回、ディープラーニングのソースコードが書けるようになったからって、色んなことに当てはめるのは簡単ではないということがわかりました。ディープラーニング自体の実装はできても、意味のある結果が得られるとは限らない。ここまでやってみて、「ディープラーニングは調理方法なんだ」ということに気がつきました。

調理方法を知ったからといって、食材のことを知らないと美味しい料理は作れない。今回の失敗ではデータ(食材)の扱いがまずかったと思います。食材が充分揃ってないのに下ごしらえをしていないのに、美味しい料理ができませんよね。ディープラーニングを成功させるには、データの取り扱い方をきちんと学ぶ必要を痛感しました。
これが今回の失敗の最大の学びです。

というわけでこの失敗を通して、これからはデータの使い方について勉強しようと思います。目下ディープラーニングで実現したいことは画像処理なので、画像処理の世界に足を踏み入れていきます。(早速本をポチりました)

それとせっかくディープラーニングを約4ヶ月かけて勉強したので、『G検定』という資格にも挑戦してみようかと思います。
自他共に認めるAIエンジニアに俺はなる!



今回はオリジナルAIを実装するまでの思考のプロセス、プログラムの実行結果・考察、失敗から得た学びについてお話ししました。
ディープラーニングが実装できればなんでもできる、と勝手に抱いていた幻想は見事に打ち砕かれましたが、次に自分が目指す方向性が見えたというのは大きな成果だったと思います。
これからも勉強を続けて成長していこうと思います。

それではまた(^_^)ノシ


よろしければサポートお願いします!いただいたサポートは書籍代等に活用いたします!