Kaggleチャレンジ成果

f:id:monozukuri-bu:20201111170333j:plain

こんにちは、kanaiです。
Skywillでチームを組んでKaggleのメラノーマコンペ(SIIM-ISIC Melanoma Classification)に挑戦しました。
その結果、シルバーメダルを獲得できたので、コンペの概要やアプローチについて紹介させていただきます。

f:id:monozukuri-bu:20201111170452p:plain

コンペの概要

メラノーマコンペは皮膚の画像から皮膚癌(メラノーマ)を0~1の確率で予想するコンペです。
TFRecord, JPEG, DICOM形式の画像データと、CSV形式の表データが用意されており、これらのデータを使って予測を行います。
学習データが33,126件、テストデータが10,982件あり、ファイルサイズは合計で108.19GBあります。
また、データのうち良性が98.237%、悪性が1.763%と偏りがあります。

良性、悪性の画像の例はこちらです。
f:id:monozukuri-bu:20201111170521p:plain

このように大容量、かつ偏りのあるデータを扱うというのが今回のコンペの特徴かと思います。
評価指数にはROC-AUCスコアを使用します。

アプローチ

私たちが最終ファイルの提出までにとったアプローチの全体的なイメージはこのようなものになります。

f:id:monozukuri-bu:20201111170546p:plain

EfficientNetやLightGBM, NGBoostで作成した複数のモデルをアンサンブルにかけて最終的な提出ファイルを作成しました。
下記でもう少し具体的に説明します。

画像データの前処理

一般的な前処理

前処理としては下記のような画像のAugmentationを行いました。

f:id:monozukuri-bu:20201111170609p:plain

上記は一般的なAugmentationかと思いますが、今回は下記も行ってみました。

荒いDropout

同じサイズの小さな四角形を多数用意し、元画像を削除する手法です。
この手法により、モデルが画像全体に注意を払うようになり、過剰適合を回避する効果があります。

f:id:monozukuri-bu:20201111170631p:plain

Test Time Augmentation (TTA)

テストデータの予測時に一つの画像から複数の画像をAugmentationによって生成し、各予測のアンサンブルにより一つの予測を出力する(下記画像の場合0.25)方法です。

f:id:monozukuri-bu:20201111170650p:plain

モデルの学習・予測について

画像データの学習・予測モデルにはEfficientNetB0~B7を使用しました。
EfficientNetは2019年5月にGoogle Brainから発表された、従来より少ないパラメータで高い精度を出せるモデルです。
モデルの深さ/広さ/解像度をバランスよく調整することで上記を実現しています。
論文中のグラフを見ると、少ないパラメータで高い精度を出していることがわかると思います。

f:id:monozukuri-bu:20201111170704p:plain

Tan, Mingxing, and Quoc V. Le. "EfficientNet: Rethinking Model Scaling for Convolutional Neural Networks." arXiv preprint arXiv:1905.11946 (2019).

そしてこのEfficientNetに対して「EfficientNet → GlobalAveragePooling2D(GlobalAveragePooling2D) → 単一出力の全結合層」というモデルを構築しました。
下記Notebooksを参考に微調整を加え上記で良い結果が得られたためこの形を採用しました。
Melanoma TPU EfficientNet B5_dense_head

256×256, 384×384, 512×512のサイズの画像をEfficisentNetで学習させ、今回は外部データの使用が許可されていたのでISIC2019, 2018, 2017と、Kaggle Grand MasterのChris Deotteが作成した公開データも使用しました。
384×384のサイズで良い精度が出ていたため基本的にはこの画像サイズを採用しました。
Chris Deotteが異なる画像サイズで学習したモデルの結果をアンサンブルすると精度が良くなるという情報を発信していたので、他の画像サイズも使用しうまくいったものをアンサンブルの一つに採用しました。

学習率のスケジューリングには元々Adam+Warmupを使用していましたが、下記経緯によりコンペ後半に作成したいくつかのモデルではRAdam+ReduceLROnPlateauを採用しています。

  • Adam+Warmupでは学習初期のデータの偏りにより1エポック目のlossが大きい値から始まることがあり、粗悪な局所最適解に収束しているような場面がしばしば見られました。
  • Adamは学習の初期で適応学習率の分散が大きくなりすぎる問題があり、ヒュースティックな手法でハイパーパラメータを調整するWarmupを使用することで上記の問題を緩和できます。
  • 適応学習率の分散を自動的に抑えられるような機構をAdamに組み込んだものがRAdamとなり、RAdamに変更してみたところうまく学習が進むようになり、さらにReduceLROnPlateauも取り入れました。 ※ReduceLROnPlateauは評価値の改善が止まった時に学習率を減らす効果があります。

また、表データの学習・予測モデルにはNGBoost、LightGBMを使用しました。

CV戦略について

CV戦略としては、Triple Stratified Kfold(folds=5)を使用し下記の効果を狙いました。

  1. 学習データと検証データに同じ患者が入らないように分割することで、リークを防止する。
    一人の患者が複数の画像データを持つため、学習データと検証データに同一患者の異なる画像データが含まれるとリークが発生してしまいます。
    患者を分離することでこれを回避できます。

  2. 学習データと検証データで良性と悪性の割合のバランスをとることで、検証スコアの信頼性を向上させる。
    全データの1.8%に悪性データがあるので、各TFRecordに1.8%の悪性データを含ませ割合のバランスを合わせることで検証スコアの信頼性を向上させます。

  3. 学習データと検証データで患者のデータ数の分布のバランスをとることで、検証スコアの信頼性を向上させる。
    患者によって画像データの枚数が異なるので、 TFRecordにする際に患者ごとに持っている画像の枚数を合わせることで検証スコアの信頼性を向上させます。

不均衡データの対策

今回は悪性データが1.8%と極端に少なくデータの偏りがあったので下記対策を行いました。

Focal loss

損失関数には、今回のような不均衡なデータに有効とされる Focal loss を使用しました。


FL(p) = \begin{cases}
    -\alpha(1-p)^\gamma\log(p) & (y = 1) \\
    -(1-\alpha)p^\gamma\log(1-p) & (otherwise)
  \end{cases}

𝛼 : クラス間の重みづけをするパラメータ
𝛾 : easy exampleをdown-weightするパラメータ

Upsampling

Upsamplingを使用し、学習時の悪性データのみを2倍程度に増加させました。

アンサンブル戦略

各モデルのアンサンブルは単純に予測結果の平均をとると良い結果が得られた場合が多かったです。
その他、CVスコアを最大化させることを目的に、アンサンブルの方法の一つとして加重一般化平均を行いました。
加重一般化平均とは、下記で表される式です。


\overline{x} = (\frac{1}{n}\sum_{i=1}^n w_ix_i^p)^\frac{1}{p}

x_{i}:予測した確率のベクトル

w_{i}: w_{i}に対応する重み

p :実数

Optunaを用いてCVスコアが最大化する係数についてのチューニングを行い、CVに対応するテストデータの予測に適用しました。

評価指標がAUCの場合に有効なアンサンブルの方法はこちらのディスカッションを参考にしました。
For AUC Metric, Ensemble using Power Averaging

その他取り組んだこと

今回の場合は効果が出ない結果とはなりましたが、下記のようなことにも取り組みました。

体毛除去

体毛除去を行っているNotebooksがあったため参考にして実践してみました。
f:id:monozukuri-bu:20201111170746p:plain

長軸合わせ

論文を参考に長軸合わせを実装しました。

ただ回転処理によってサンプル数の拡張を行うよりも,メラノーマの形状特徴をより顕著に反映する腫瘍領域の長軸の位置合わせ処理を行うほうが,DCNNを用いたメラノーマの識別においては有効であると考えられる. 吉田 拓也, 彌冨 仁「深層学習を用いたメラノーマ識別における長軸位置合わせの効果」(2015)

イメージとしては下記となり、実装した処理をNotebooksで公開しています。

f:id:monozukuri-bu:20201111170806p:plain

長軸位置合わせサンプル_202007

まとめ

チームとして取り組んだ結果、シルバーメダル(Top4%)を獲得できたのはとてもうれしかったです!
また、Discussionや公開Notebookの内容をうまく取り入れられ、画像処理やディープラーニングに関して多くを学べました。
ただ、コンペのスケジュール感をもう少し意識した方がよかった、役割などを明確にしてチーム連携を効率的にすればよかったなどの課題も出てきたので、反省を活かし今後も取り組んでいきたいと思います。

参考文献

社内研修:scikit-learn基礎

f:id:monozukuri-bu:20200507165114j:plain

こんにちは、kanaiです。
社内でScikit-learnハンズオン講座を受けたので、その内容を一部ご紹介したいと思います。

scikit-learn とは

scikit-learnとはPython機械学習を行うためのオープンソースライブラリです。
回帰、分類、クラスタリングなどを行うための様々なアルゴリズムを提供しています。

動作環境

以下の環境で動作確認しています。

Python : 3.7.3
Jupyter Lab : 1.0.2
scikit-learn : 0.21.2
タスクの種類

今回の社内研修で、scikit-learnで実装したのは「回帰」と「分類」でした。

回帰(Regression)

説明変数xを用いて、xと相関関係のある目的変数yの値を説明・予測すること。

分類(Classifier)

母集団のどのクラスに分類されるか予測すること。

線形回帰

xを体重、yを身長とした時に、線形回帰を使えば以下のような式により、xの値からyの値を予測することができます。
(もちろん、身長を体重のみで説明することはできません。)


y=ax+b

このとき、それぞれの値は次のように呼ばれます。
x:説明変数
y:目的変数
a:重み
b:バイアス

上記の場合は説明変数xが1つなので単回帰分析と呼びますが、2つ以上使う場合は重回帰分析と呼びます。

それでは早速、回帰分析を試してみましょう。
必要なモジュールをインポートします。

from sklearn.datasets import load_bostonon
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split

scikit-learnには自由に使用するできるデータセットがいくつか提供されています。
その中にボストンの住宅価格のデータセットが含まれているのでロードします。

boston = load_boston()

続いて、train_test_splitでロードしたデータセットを学習用のデータとテスト用のデータに分割します。

xd, xt, yd, yt = train_test_split(boston.data, boston.target, test_size=0.2, random_state=0)

引数に分割したいデータboston.data, boston.targetを指定します。
test_sizeは全体のデータのうちテストデータに分割したい割合を指定します。
今回は0.2を指定しているので80%が学習データ、20%がテストデータに分割されます。
デフォルトではランダムでデータが分割されますが、random_stateを指定してSeed値を固定すると、常に同じように分割されます。

それではモデルを作成・予測してみましょう。

linear = LinearRegression()
linear = linear.fit(xd, yd)
pred = linear.predict(xt)

LinearRegression()でクラスをインスタンス化し、重みやバイアスが初期状態の線形回帰モデルを作成しています。
そのモデルに対してfit()で学習データを指定すると学習が行われ、重みやバイアスが初期状態から変動していきます。
predict()の引数に説明変数のテストデータを指定すれば、推定値を算出できます。
とっても簡単ですね。

それでは、どのようなモデルが作成されたのか結果を出力してみましょう。

print("決定係数:", linear.score(xt, yt)) # 性能評価の指数
print("重み  :", linear.coef_)
print("バイアス:", linear.intercept_)
決定係数: 0.724481152496214
重み  : [-1.19080493e-01  3.84348456e-02 -2.58091181e-04  2.93091559e+00
           -1.79959892e+01  4.22827605e+00 -5.68413115e-03 -1.60955285e+00
            2.65580750e-01 -1.08348030e-02 -9.19181156e-01  8.73155551e-03
           -5.05892529e-01]
バイアス: 34.39876569573389

scoreは決定係数を示しており、モデルが実測値にどのくらい適合しているかを表します。
この値が1に近いほど予測の精度が高いことになります。
coef_, intercepr_でそれぞれ線形回帰の重みとバイアスの値を出力できます。

今回行っているのは、説明変数が複数あるため、重回帰分析です。
定義式に従って、説明変数 * 重み + バイアスを計算すると、推定値に一致することが確認できます。

print(np.dot(xt[0], linear.coef_) + linear.intercept_)
その他に学んだこと

具体的にご紹介するのは、ここまでにさせていただきます。
この他には、

  • Lidge回帰、Lasso回帰、ロジスティック回帰
  • SVM、K-NN、ナイーブベイズ 、決定木
  • バギング、ぺースティング、ランダムフォレスト、勾配ブースティング
  • その他、イマドキの最新ライブラリの扱い方

などなど、本当に盛沢山な内容でした。

研修の中では教材を追っていくのに必死でしたが、きちんと復習して使いこなせるように習得していきたいと思います。

まとめ

今回はscikit-learnの社内教育を受講して、その内容を簡単にご紹介させていただきました。
scikit-learnは様々なモデルを使えるだけでなく、そのモデルを組み合わせる機能なども提供されていて、とても便利ですね。
各モデルの特徴を理解して、状況や目的に合わせて使いこなしていきたいです。

matplotlibでグラフ描画

f:id:monozukuri-bu:20200528114409p:plain

データサイエンス界隈でpythonのよく使うライブラリと言えば、numpy, pandas, matplotlibです。

今回はその matplotlib でもとりわけよく利用するグラフを描画する部分に着目し、どんなことができるのかをまとめました。

インポート

グラフ描画のためにmatplotlibを使用する場合、以下のようにインポートすることが多いです。

import matplotlib.pyplot as plt

・matplotlib
 今回解説するライブラリです。
 このライブラリを使ってグラフを作っていきます。

・pyplot
 matplotlib内のグラフ描画モジュールです。

・plt
 matplotlib.pyplotに別名をつけています。
 慣例的に、pltと名付けることが多いです。

折れ線グラフ

線グラフを描画する場合にはplot()を使用します。

plt.plot(X, Y, ls='-',lw='1', color='b',marker = '+',label='ice_sales')
plt.legend()
plt.show()

f:id:monozukuri-bu:20200528115710p:plain:w480

引数 説明 補足
X X軸 必須項目
Y Y軸 入力しない場合データ数になります。
label 凡例 plt.legend()を付けないと表示されません。
color 線の色 様々な指定方法が用意されている。
・アルファベット1文字('b', 'g', 'r', 'c', 'm', 'y', 'k', 'w')
・RGB+透過度αで指定((0.1, 0.2, 0.5, 0.3))
・名称で指定("orange", "pink", …)
参考:https://matplotlib.org/api/colors_api.html
ls 線の種類 例)'-' , '--', '-.' , ':' , 'steps'
lw 線の太さ デフォルトは1.0
marker マーカー形状 例)'+' , ',' , '.' , "o", '1' , '2' ,'3' ,'4'
散布図

散布図を記載する場合にはscatter()を使用します。

plt.scatter(data_frame.high_temp, data_frame.ice_payment,s=20, marker='o', cmap='jet',c=data_frame.ice_payment)
plt.colorbar()
plt.show()

f:id:monozukuri-bu:20200528122312p:plain:w480

引数 説明 補足
X X軸 必須項目
Y Y軸 必須項目
c X,Yの値も指定できます。
s サイズ デフォルト値:20
marker マーカー形状 デフォルトはo
cmap カラーマップ plt.colorbar()が必要
円グラフ

円グラフを描画する場合にはpie()を使用します。

x = numpy.array([400, 400, 600, 800, 200])
colors = ["yellow", "red", "blue", "green", "orange"]
ex = numpy.array([0.2, 0, 0, 0, 0])
label = ["Carbonara", "Arabian", "Pescatore", "Genovese", "Peperoncino"]
plt.pie(x,labels=label,explode = ex,startangle=90,colors=colors,explode = 0.2)
plt.show()

f:id:monozukuri-bu:20200528122503p:plain:w480

引数 説明 補足
X X軸 この値だけでも成立します。
label ラベル Xの値毎にラベルを張ります。
colors Xの値毎に色を設定します。
explode 指定したXの値を円の中心から指定値だけ離して表示 サンプルは0.2離した状態です。
棒グラフ

棒グラフを描画する場合にはbar()を使用します。

X = numpy.array([1, 2, 3, 4, 5])
Y = numpy.array([400, 400, 600, 800, 200])
Y2 = numpy.array([400, 400, 600, 800, 200])
colors = ["yellow", "red", "blue", "green", "orange"]
label = ["Carbonara", "Arabian", "Pescatore", "Genovese", "Peperoncino"]
plt.bar(X, Y,width = 0.8 , color =colors,tick_label = label,linewidth= 1,edgecolor="#000000")

f:id:monozukuri-bu:20200528122741p:plain:w480

引数 説明 補足
X X軸 必須項目
Y Y軸 必須項目
width 棒グラフ幅 デフォルトは0.8
tick_label X軸ラベル 設定しない場合はX軸が表示されます。
color 棒の色を設定します。
linewidth 枠線サイズ 設定することによって、枠線を付けることができます。
edgecolor 枠線色 colorと同様で配列型での指定も可能です。
bottom 棒グラフの下の値 例えば、100にするとすべてのY軸の始点が100になります。また、積み上げグラフ(以下参照)を作成する場合に使われます。
積み上げグラフ

パラメータのbottomに下の棒グラフのY値を入れることによって、上にグラフを積み上げることが可能です。
先ほど出力した棒グラフに対して積み上げたパターンが以下です。

X = numpy.array([1, 2, 3, 4, 5])
Y = numpy.array([400, 400, 600, 800, 200])
Y2 = numpy.array([600, 600, 400, 200, 800])
colors = ["yellow", "red", "blue", "green", "orange"]
label = ["Carbonara", "Arabian", "Pescatore", "Genovese", "Peperoncino"]
plt.bar(X, Y,width = 0.8 , color =colors,tick_label = label,linewidth= 1,edgecolor="#000000")
plt.bar(X, Y2,bottom=Y)

f:id:monozukuri-bu:20200528122820p:plain:w480

まとめ

今回は、主要なグラフの描き方をご紹介しました。
この他にも箱ひげ図やコレログラムなど、統計学でよく用いられるグラフも描画することが出来ますので、引き続き学んでいきたいと思います。

Numpyで画像処理をしよう

f:id:monozukuri-bu:20200507152833j:plain

こんにちは、kanaiです。
今回はNumpyを使って画像処理を扱っていきたいと思います。
OpenCVを使えば1行で済む処理かもしれませんが、学習のためにあえて行列で計算を行ってみます。

最初に画像の基本処理について紹介し、次に深層学習の前処理で使われるような画像データの水増し処理について紹介します。

動作環境と画像データ

以下の環境で動作確認しています。

Python       3.7.3
Jupyter Lab  1.2.6
Pillow       7.0.0
Numpy        1.18.1
Matplotlib   3.1.3
Scipy        1.4.1

なお、Jupyter Lab上で実装する前提で進めていきます。
画像は以下の素材をお借りします。

f:id:monozukuri-bu:20200507153446j:plain

モジュールのインポート

まずは使用するモジュールをインポートします。
画像の読み込みにPillow、表示にMatplotlibを使うので、Numpyと同時にインポートしておきましょう。

from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
画像の読み込み

画像データの読み込みはPillowのopen()メソッドを使います。
読み込んだデータをNumpy配列に渡してみましょう。
Numpy配列の形状を確認してみると、3次元配列が得られていることがわかります。

img = np.array(Image.open('cat.jpg'))
print(img.shape)
# (480, 720, 3)

これは左から順に、行(高さ), 列(幅), 色(カラーチャネル)を表しています。
色の並びは (R, G, B)の順番となっており、それぞれの値は0~255の範囲になっています。

ちなみに読み込み時にconvert('L')を使用するとグレースケールに変換されます。
カラーチャネルが不要になるため、先ほどとは違い2次元配列として読み込まれることがわかります。

img_gray = np.array(Image.open('cat.jpg').convert('L'))
print(img_gray.shape)
# (480, 720)
画像の保存

Image.fromarray()を使用すると、引数に指定したndarrayから画像のオブジェクトを得られます。
この画像のオブジェクトに対しsave()メソッドを実行することで画像として保存することができます。

試しに、グレースケールとして読み込んだ画像を保存してみましょう。

img_gray_obj = Image.fromarray(img_gray)
img_gray_obj.save('cat_gray.jpg')

以下のような形で保存ができました。
グレースケールならではの雰囲気を感じられますね。

f:id:monozukuri-bu:20200507155306j:plain

画像の水増し

深層学習などで画像処理に取り組む時には、画像のデータ数が重要になります。
そこで、データを増やすために元の画像を回転・拡大したり明暗の調整をすることでデータを水増しすることを考えます。

反転(水平方向)

まずは水平方向への反転処理です。
水平方向へ反転させるには、列の値が格納されている2つ目の要素を反転させれば良いですね。
そしてNumpyにおいては、スライスで[::-1]とすると反転できます。

これらを組み合わせて実装してみます。

def hrztl_flp(img):

    # 列の要素を反転させる
    return img[:, ::-1, :]

hrztl_img = hrztl_flp(img)
hrztl_img_obj = Image.fromarray(hrztl_img)
hrztl_img_obj.save('hrztl_cat.jpg')

f:id:monozukuri-bu:20200507161729j:plain

実際には画像をランダムに反転させることがあります。
下記ではrand()で0以上1未満の値をランダムに取得し、基準値(デフォルト:0.5)を下回ったら水平方向に反転させ、基準値以上なら反転せずにそのまま値を返す処理を施しています。

def hrztl_flp(img, rate=0.5):

    # 0以上1未満の値をランダムに取得
    # 基準値を下回ったら水平方向に反転させる
    if np.random.rand() < rate:
        return img[:, ::-1, :]

hrztl_img = hrztl_flp(img)
hrztl_img_obj = Image.fromarray(hrztl_img)
hrztl_img_obj.save('hrztl_cat.jpg')
反転(垂直方向)

今度はランダムに垂直方向へ反転させてみましょう。
行の要素を反転させれば良いので、画像の配列の1つ目の要素に対して[::-1]します。

def vtcl_flp(img, rate=0.5):

    if np.random.rand() < rate:
        # 行の要素を反転させる
        return img[::-1, :, :]

vtcl_img = vtcl_flp(img)
vtcl_img_obj = Image.fromarray(vtcl_img)
vtcl_img_obj.save('vtcl_cat.jpg')

f:id:monozukuri-bu:20200522095311j:plain

ただしこの時注意が必要です。
例えば👍というような画像を垂直方向に反転させると👎となり、意味が逆になってしまうため、意図した学習ができなくなってしまいます…。

垂直方向の反転に限らず、データを水増しさせる際にはどのようなモデルを作成したいか考慮した上で実施しましょう。

切り抜き

画像をランダムに切り抜きします。 今回はデフォルトのサイズを(224, 224)として切り抜きを行ってみましょう。

def rndm_crp(img, crp_size=(224, 224)):

    # 画像の形状を取得(色の情報は使わないので_に格納)
    h, w, _ = img.shape
 
    # 行、列のサイズから切り抜くサイズ(224)を引く
    # 0~引いた値の範囲でランダムにtop, leftを決める
    top = np.random.randint(0, h - crp_size[0])
    left = np.random.randint(0, w - crp_size[1])

    # top, leftの値に切り抜くサイズ(224)を足してbottom, rightを決める
    bottom = top + crp_size[0]
    right = left + crp_size[1]

    # top~bottom, left~rightの範囲で画像を切り抜く
    return img[top:bottom, left:right, :]

crp_img = rndm_crp(img)
crp_img_obj = Image.fromarray(crp_img)
crp_img_obj.save('rndm_crp_cat.jpg')

実行すると下記のように画像が切り抜かれました。

f:id:monozukuri-bu:20200507162327j:plain

切り抜き(スケール変化)

今度は画像のサイズスケールをランダムに変化させた上で切り抜きを行ってみましょう。
(256, 256) ~ (512, 512)のサイズで画像をリサイズし、リサイズされた結果に対し、先ほど定義したrndm_crpを実行します。

def scale_arg(img, scale_range=(256, 512), crop_size=224):

    # scele_rangeの範囲でランダム値を取得
    scale_size = np.random.randint(*scale_range)

    # 画像をImageオブジェクトに変換後、scale_sizeの値でリサイズ
    img = np.array(Image.fromarray(img).resize((scale_size, scale_size)))

    # リサイズされた画像からrndm_crp()でランダムに切り抜き
    return rndm_crp(img, (crop_size, crop_size))

scale_arg_img = scale_arg(img)
arg_img_obj = Image.fromarray(scale_arg_img)
arg_img_obj.save('scale_arg_cat.jpg')

リサイズされた状態で画像が切り抜かれました。

f:id:monozukuri-bu:20200507162453j:plain

回転

画像を0~360度の範囲でランダムに取得した角度で回転させてみましょう。
画像を回転させるためにはScipyのrotate()を使用します。

# 画像を回転させるためにScipyのrotateをインポート
from scipy.ndimage.interpolation import rotate

def random_rotate(img, angle_range=(0, 360)):
    
    # 画像の形状を取得(色の情報は使わないので_に格納)
    h, w, _ = img.shape

    # 回転させる角度を0~360度の範囲でランダムに取得
    angle = np.random.randint(*angle_range)
    
    # rotate()を使用して取得した角度で画像を回転させる
    img = rotate(img, angle)
    
    # 回転させることでサイズが変わるので元のサイズに戻す
    return np.array(Image.fromarray(img).resize((h, w)))

rotate_img = random_rotate(img)
rotate_img_obj = Image.fromarray(rotate_img)
rotate_img_obj.save('random_rotate_cat.jpg')

f:id:monozukuri-bu:20200507162534j:plain

見事画像を回転させられました。
回転の角度の範囲は必要なデータに合わせて調整しましょう。

マスキング

画像の一部をマスキングすることによって、正則化の効果が強くなりモデルの過学習を抑えることができます。
ランダムに取得した箇所に対して、画像の画素数の平均値でマスキングしてみましょう。

def masking(img, mask_size):

    # 画素数の平均値を取得
    mask_val = img.mean()
    
    # 画像の形状を取得(色の情報は使わないので_に格納)
    h, w, _ = img.shape
    
    # マスク対象箇所のtop, lightの値をランダムに取得し、bottom, rightの値も取得
    top = np.random.randint(0 - mask_size // 3, h - mask_size)
    left = np.random.randint(0 - mask_size // 3, w - mask_size)
    bottom = top + mask_size
    right = left + mask_size
    
    # top, leftが負の値になる可能性がある.その場合0を代入
    if top < 0:
        top = 0
    if left < 0:
        left = 0
        
    # マスク箇所の画素数を平均値とする
    img[top:bottom, left:right, :] = mask_val
    return img

mask_img = masking(img, 240)
mask_img_obj = Image.fromarray(mask_img)
mask_img_obj.save('masking_cat.jpg')

f:id:monozukuri-bu:20200507162602j:plain

画像を一部マスキングすることで正則化の効果があるなんて面白いですね。

ガンマ補正

ディスプレイやプリンタ、スキャナなどで色のついた画像を表示する際、ハードウェアはそれぞれ発色特性という癖のようなものを持っています。
例えば、あるディスプレイできれいにみえた画像が他のディスプレイでは白っぽく見えたり、逆に暗めに見えてしまったりします。
これがハードウェアの発色特性によるもので、どのくらい画像を変質させるか決めるのがガンマ値となります。

どの程度変質させるかはガンマ値をべき乗することで計算でき、下記の数式で表せます。


Output=Input^{Gamma}

ガンマ値が 1.0, 2.2 の場合のグラフはそれぞれ下記となります。

f:id:monozukuri-bu:20200507163024j:plain

このように変質してしまう画像に対し、元の状態に戻すことをガンマ補正と呼びます。
例えば、別のディスプレイで表示した時に画像が暗めに表示されるのであれば、あらかじめ明るめのデータを送ることでちょうど良い明るさで表示できます。

発色特性によってガンマ値をべき乗したものがアウトプットされてくるので、ガンマ補正を行う場合はガンマ値の逆数をべき乗すれば良いことになりますね。
なおこの際、入力値を正規化(0~1の範囲に収める)し、ガンマ補正後に正規化した分を元に戻す必要がありますので、下記の数式を適用すれば良いことになります。
※Maxは画素の最大値


Output = Max * (\frac{Input}{Max}) ^ {\frac{1}{Gamma}}

ガンマ値を2.2と仮定して実装すると下記のようになります。

# ガンマ値
gamma = 2.2
# 画素の最大値
img_max = img.max()

# ガンマ補正
img_gamma = img_max * (img / img_max) ** (1 / gamma) 
img_gamma_obj = Image.fromarray(np.uint8(img_gamma))
img_gamma_obj.save('gamma_cat.jpg')

f:id:monozukuri-bu:20200507163105j:plain

輝度調整

画像において輝度の調整を行いたい場合は、下記のような積和演算を行います。
αはコントラスト、βは明るさを指し、両方とも定数となります。


Output = α * Input + β

実装に落とし込んでみましょう。
注意点として、上記の数式をそのまま適用すると画素の最大、最小の範囲を超えてしまう値があるので、Numpyのclip()を使用することで範囲内に収まるようにします。

def chng_brt(img, alpha, beta):
    # 積和演算
    brt_img = alpha * img + beta
    
    # 画素の最大値、最小値を取得
    max = img.max()
    min = img.min()

    # 画素を最小~最大の範囲に収める
    brt_img = np.clip(brt_img, min, max)
    return Image.fromarray(np.uint8(brt_img))

まずは画像を暗くしてみます。

alpha = 0.8
beta = -50
brt_img = chng_brt(img, alpha, beta)
drk_img_obj = Image.fromarray(np.uint8(drk_img))
drk_img_obj.save('dark_cat.jpg')

f:id:monozukuri-bu:20200507163148j:plain

今度は画像を明るくしてみましょう。

alpha = 1.2
beta = 50
brt_img = chng_brt(img, alpha, beta)
brt_img_obj = Image.fromarray(np.uint8(brt_img))
brt_img_obj.save('bright_cat.jpg')

f:id:monozukuri-bu:20200507163159j:plain

まとめ

今回はNumpyを使った画像の基本処理と、水増し処理を紹介させていただきました。
学習のためにあえてNumpyを使用しましたが、OpenCVが使える環境であればもっと簡単に実装出来ると思います。

また、今回紹介したような方法を複数組み合わせて、画像のパターンを増やすことが出来ますね。
作成したいモデルに応じて、効果的な手法を取捨選択していきたいです。

単体テストを学ぼう!③ -テストしたモジュールを1つのシステムにしてみる-

f:id:monozukuri-bu:20200225110955j:plain

こんにちは、ふじもんです。

健康診断を受けてきました。
腹囲を測ったら去年よりも5cm大きくなってました。

・・・まじか。

さて、前回の記事に引き続き今回もテスト駆動開発で未実装の部分を実装します。
そして、今回はそれらを1つのシステムとして完成させます!

前回のおさらい

前回はどんなシステムを作るか仕様を決め、それに沿ってテスト駆動開発という手法で単体テストを行いながら実装を行なっていました。
そして、前回は1の部分までを実装済みです。

 1. 身長(cm)と体重(kg)をキーボードから入力する。
   ・身長、体重共に有効な値の範囲は 0 < x < 1000 とし、Trueを返す  
   ・0 以下、もしくは1000以上の数値が入力された場合はFalseを返す  
   ・入力がない場合はFalseを返す  
   ・数値に変換できない文字列が入力された場合はFalseを返す  

 2. 入力された身長(cm)と体重(kg)からBMIを計算する。

今回は復習も兼ねて2の部分を実装していこうと思います。

BMI計算部分の実装

復習ということでさくさくっと実装していきます!
前回と同じく、テストコードを書いていきます。
2の工程では計算が正しくできていることをテストします。
今回も上記の「入力された身長(cm)と体重(kg)からBMIを計算する」というだけではテストコードを書くのに不十分と気づいたため、仕様を少し詳細に決定しました。

 2. 入力された身長(cm)と体重(kg)からBMIを計算する。
   ・BMIは、一般的な定義に基づいて算出する。(体重(kg) / 身長(m)^2)  
   ・計算したBMIは小数点第二位で四捨五入し、小数点第一位までを返す。  

…早くこういう仕様を最初から決められるようになりたい。

また、入力値は前回まででテスト済みのため、今回のテストでは観点は以下としています。
・取りうる最小の値での計算結果は正しいか
・取りうる最大の値での計算結果は正しいか
・取りうる値の代表値での計算結果は正しいか
・メソッドが呼ばれなかった際の処理は正しいか

この観点でテストコードを書くと以下のようになります。

# bmi_test.py

import sys
import pathlib
sys.path.append('..')
current_dir = pathlib.Path(__file__).resolve().parent
sys.path.append(str(current_dir) + '/../')
from unittest import TestCase
from bmi_calculator.BmiCalculator import BmiCalculator

class TestBmiCalculator(TestCase):
    def test_calc_01(self):
        bmiCalc = BmiCalculator()
        bmiCalc.set_height("0.1")  # 取りうる値の最小値を入力
        bmiCalc.set_weight("0.1")
        self.assertEqual(bmiCalc.calc(), 100000)

    def test_calc_02(self):
        bmiCalc = BmiCalculator()
        bmiCalc.set_height("999.9")  # 取りうる値の最小値を入力
        bmiCalc.set_weight("999.9")
        self.assertEqual(bmiCalc.calc(), 10.0)

    def test_calc_03(self):
        bmiCalc = BmiCalculator()
        bmiCalc.set_height("170")  # 一般的な日本人男性の身長と体重
        bmiCalc.set_weight("60")
        self.assertEqual(bmiCalc.calc(), 20.8)

    def test_calc_04(self):
        bmiCalc = BmiCalculator()
        bmiCalc.set_height("170")  # set_weightメソッドが呼ばれなかった場合
        self.assertIsNone(bmiCalc.calc())

    def test_calc_05(self):
        bmiCalc = BmiCalculator()
        bmiCalc.set_weight("60")  # set_heightメソッドが呼ばれなかった場合
        self.assertIsNone(bmiCalc.calc())

    def test_calc_06(self):
        bmiCalc = BmiCalculator()  # set_weightメソッド、set_heightメソッドが呼ばれなかった場合
        self.assertIsNone(bmiCalc.calc())

それぞれのBMIを計算して気づいたのですが、身長と体重が同じでも身長が高ければ高いほど痩せ気味で低ければ低いほど太り気味になるんですね。
計算式見れば当たり前と思う方もいるかもですが、私はここで初めて気づきました。
ではプロダクションコードで実装する前に、テストコードを実行します。

bash-3.2$ python -m unittest bmi_test.py
EEEEEE
…python
(省略)
======================================================================
ERROR: test_calc_06 (bmi_test.TestBmiCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/XXXX/work/UnitTest/test/bmi_test.py", line 106, in test_calc_06
    self.assertIsNone(bmiCalc.calc())
AttributeError: 'BmiCalculator' object has no attribute 'calc'

----------------------------------------------------------------------
Ran 6 tests in 0.001s

FAILED (errors=6)

レッドであることを確認できました。
では、テストを通るようにBMI計算部分を実装していきます。

# BmiCalculator.py
import re
import math

class BmiCalculator:
    INPUT_PATTERN = r"^[\d]+[..]?[\d]?$"

    def __init__(self):
        self.height = None
        self.weight = None
        self.bmi = None

    def set_height(self, h):
        if not re.match(self.INPUT_PATTERN, h):
            return False
        
        h = float(h)
        if  not 0 < h < 1000:
            return False

        self.height = h
        return True


    def set_weight(self, w):
        if not re.match(self.INPUT_PATTERN, w):
            return False
        
        w = float(w)
        if  not 0 < w < 1000:
            return False

        self.weight = w
        return True
    
    def calc(self):
        if(self.height is None or self.weight is None):
            return None
        
        return round(self.weight / (self.height / 100) ** 2, 1)

新たにcalc関数を追加し、heightweightに値が入っていない場合は何も返さず、値が設定されていた時のみBMIを計算して返します。
また、__init__関数で、明示的にそれぞれの初期値をNoneに設定しておきます。
そしてテストコードを実行。

bash-3.2$ python -m unittest bmi_test.py
......
----------------------------------------------------------------------
Ran 6 tests in 0.001s

OK

エラーなし!失敗なし!
これで全ての機能のテストと実行が完了しましたやったーーーーー!!!

システムを完成させよう

と、ここまでやったら満足しがちな私ですが、
これだとそれぞれの機能のモジュールを作っただけになってしまいます。

せっかくテストして動作確認を行なったコードが書けたのだから、
システムとして動かせるようにしたい。
最後に作成したモジュールを繋げるmain.pyを書いて、ちゃんとシステムとして完成させましょう!

# main.py
import sys
import pathlib
sys.path.append('..')
current_dir = pathlib.Path(__file__).resolve().parent
sys.path.append(str(current_dir) + '/../')
from bmi_calculator.BmiCalculator import BmiCalculator

if __name__ == '__main__':
    
    # BmiCalculatorの生成
    bmi = BmiCalculator()

    # isHeightSuccessの初期値設定
    isHeightSuccess = False

    # isHeightSuccessがTrueになる(有効値が入力される)まで繰り返す
    while(not isHeightSuccess):
        print('Input height(cm) = ')
        height = input()
        isHeightSuccess = bmi.set_height(height)
    
    # isWeightSuccessの初期値設定
    isWeightSuccess = False

    # isWeightSuccessがTrueになる(有効値が入力される)まで繰り返す
    while(not isWeightSuccess):
        print('Input weight(kg) = ')
        weight = input()
        isWeightSuccess = bmi.set_weight(weight)
    
    # BMIの計算
    bmi_value = bmi.calc()

    # BMIが正しく計算されなかった場合はメッセージを表示して終了
    if(bmi_value is None):
        print("Please input height and weight")
        exit()
    
    # 入力された身長・体重と、計算されたBMIを表示
    print('height(cm): {}  weight(kg): {}'.format(height, weight))
    print('BMI: {}'.format(bmi_value))

こんな感じでmain.pyを書いてみました。
身長・体重ともに、有効値を入力したらTrue, 無効値を入力したらFalseを返す仕様にしていたため、有効値が入力されるまで入力メソッドをループさせます。
また、もし今後仕様変更があった場合を考慮し、BMIが正しく計算されなかった場合の処理を入れています。
これが動けば、どんなに単純なものでも立派なシステムよ…!
期待と不安の入り混じった気持ちで実行。

bash-3.2$ python main.py
Input height(cm) = 
157.8
Input weight(kg) = 
53.8
height(cm): 157.8  weight(kg): 53.8
BMI: 21.6

動いたアアアアアアアアアア!!!!!!!!!!!!!!

最後に

テストのテの字も知らずスクリプトを組んでは不安になっていた私ですが、今回テストのことを勉強して、その不安の払拭方法を知ることができました。
テスト…偉大だ…。

また、テスト駆動開発なるテストファーストな手法で行うと、今どんなシステムを作ろうとしているのか、機能は何が必要なのかを明確化でき、開発途中で迷子にならずに最後までシステムが構築できたかと思います。
何よりテストをすることにより不安が払拭され心理的負担が減るのが本当に大きいです。

今回例に出したシステムは非常に単純なシステムだったので、どんな観点でテストを行うかが考えやすかったのですが、
現場に出るともっと大規模なシステムや複雑な処理を要するプログラムだったりして、
 ・何が必要なのか
 ・どのような要件なのか
が迷子になりがちだったりします。

そのため、常に念頭に置きながらスクリプトを組むと、
おのずとテストの観点もわかり自分のスクリプトに安心できるのではないかなあと思いました。

お知らせ

株式会社スカイウイルでは、2021年度の新卒採用を行います。
私たちは、独自の教育研修制度によって、学習したことを自信をもって社内外に発信できる。
そんなエンジニア集団です。

弊社採用サイトやマイナビ2021にて情報を公開しておりますので、
興味のある方は是非一度、会社説明会へお越しください!

・採用サイト
 株式会社スカイウイル 採用サイト

マイナビ2021
 (株)スカイウイルのインターンシップ・会社概要 | マイナビ2021

単体テストを学ぼう!② -テスト駆動開発で単体テストをしてみる-

f:id:monozukuri-bu:20191127115032j:plain

こんにちは、ふじもんです。

前回の記事では開発におけるテスト、単体テストの種類、テスト駆動開発について触れました。
では、今回はいざ実践!
テスト駆動開発で実際に単体テストを実施していきます!!

テスト駆動開発の流れ

テスト駆動開発は、以下の手順を繰り返し行い、テストコードとプロダクションコードを作成していきます。

1. レッド     :まず最初に、実装要件に基づいたテストコードを書く  
2. グリーン    :テストが成功するプロダクションコードを書く  
3. リファクタリング:テストが成功する状態のコードを綺麗にしていく

テスト駆動開発テストファーストの開発手法なので、
まずはどんなテストを行うか決め、テストコードを書いてから実装していくんですね。

システムの仕様の決定

まずはどんなシステムを作るかを決めないと、開発も何もありません。
じゃあ何作ろっかなー、んーーーー。

そろそろ健康診断受けなきゃだし、健康に付随してBMIを計算するシステムとかにしてみよう。

1. 身長(cm)と体重(kg)をキーボードから入力する。  
2. 入力された身長(cm)と体重(kg)からBMIを計算する。  
3. 計算結果を出力する。

こんな感じのシンプルなシステムを作ってみます。

いざテストコード記述

よし、穴だらけかもしれないけど仕様は決まった!
いよいよテスト駆動開発とやらをやってみるぞ!!

今回はPythonで開発を行い、テストはPythonの標準モジュールであるunittestで行っていこうと思います。
ディレクトリ構成は以下のようにしました。

UnitTest
├ bmi_calculator (プロダクションコード格納場所)
│ └ BmiCalculator.py
│
└ test (テストコード格納場所)
  └ bmi_test.py

テスト駆動開発ではまず実装要件に基づいたテストコードを書くのが最初でしたね。
最初のテストコードは、こんな感じで書いてみます。

# bmi_test.py

import sys
import pathlib
sys.path.append('..')
current_dir = pathlib.Path(__file__).resolve().parent
sys.path.append(str(current_dir) + '/../')
from unittest import TestCase
from bmi_calculator.BmiCalculator import BmiCalculator

class TestBmiCalculator(TestCase):
    pass

3~7行目でプロダクションコードのパスを追加し、プロダクションコードのモジュールをインポートできるようにします。
そして11~12行目の実際のテスト部分ですが、とりあえず何もしない状態。
これで一度テストコードを実行してみます。

bash-3.2$ python -m unittest bmi_test.py
E
======================================================================
ERROR: bmi_test (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: bmi_test
Traceback (most recent call last):
  File "/Users/XXXX/anaconda3/lib/python3.6/unittest/loader.py", line 153, in loadTestsFromName
    module = __import__(module_name)
  File "/Users/XXXX/work/UnitTest/test/bmi_test.py", line 4, in <module>
    from bmi_calculator.BmiCalculator import BmiCalculator
ImportError: cannot import name 'BmiCalculator'


----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)

BmiCalculatorモジュールがないと怒られましたが、まだ作ってないので怒られて当然ですね。
これがテスト駆動開発のレッドの状態です。

では、これをグリーンにすべくテストコードが通るプロダクションコードを書きます。
とりあえずエラー内容に従い、モジュールを作成します。

# BmiCalculator.py

class BmiCalculator:
    pass

とりあえず作っただけ。
いざ実行。

bash-3.2$ python -m unittest bmi_test.py

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

OKでました!!これがグリーンの状態です。
こんな感じでひたすらテストコードとプロダクションコードを書いていくのが、テスト駆動開発のやり方のようです。

単体テストをしてみる

引き続きゴリゴリ書いていきましょう。
実装したいのは以下の3点でした。

1. 身長(cm)と体重(kg)をキーボードから入力する。  
2. 入力された身長(cm)と体重(kg)からBMIを計算する。  
3. 計算結果を出力する。

ではまず1. 身長(cm)と体重(kg)をキーボードから入力する。のテストコードを記述していきます。
ブラックボックステストは、システムの中身に関係なくインプットに対するアウトプットを確認するテストでしたね。

じゃあどのインプットに対し、どのアウトプットを正とするのか。
それを決めるためにもうちょっと仕様を詳しく決めます。

1. 身長(cm)と体重(kg)をキーボードから入力する。
・身長、体重共に有効な値の範囲は 0 < x < 1000 とし、Trueを返す
・0以下、もしくは1000以上の数値が入力された場合はFalseを返す
・入力がない場合はFalseを返す
・数値に変換できない文字列が入力された場合はFalseを返す

有効値が入力された場合はTrueを返し、無効値が入力された場合はFalseを返すようにします。
さらに、この条件のテストを行うことにより、ホワイトボックステストの分岐網羅・条件網羅もテスト可能です。

では、この仕様に合ったテストコードを書いていきます。
また、身長・体重ともに同じ仕様のため、今回は身長部分の実装のみを記載しています。

# bmi_test.py

import sys
import pathlib
sys.path.append('..')
current_dir = pathlib.Path(__file__).resolve().parent
sys.path.append(str(current_dir) + '/../')
from unittest import TestCase
from bmi_calculator.BmiCalculator import BmiCalculator

class TestBmiCalculator(TestCase):
    def test_set_height_01(self):
        bmiCalc = BmiCalculator()
        self.assertTrue(bmiCalc.set_height("60"))   # 正常値
        
    def test_set_height_02(self):
        bmiCalc = BmiCalculator()
        self.assertTrue(bmiCalc.set_height("999.9"))   # 正常値

    def test_set_height_03(self):
        bmiCalc = BmiCalculator()
        self.assertFalse(bmiCalc.set_height("1000.0"))   # 無効値

    def test_set_height_04(self):
        bmiCalc = BmiCalculator()
        self.assertTrue(bmiCalc.set_height("0.1"))   # 正常値

    def test_set_height_05(self):
        bmiCalc = BmiCalculator()
        self.assertFalse(bmiCalc.set_height("0"))   # 無効値

    def test_set_height_06(self):
        bmiCalc = BmiCalculator()
        self.assertFalse(bmiCalc.set_height("-1"))   # 無効値
    
    def test_set_height_07(self):
        bmiCalc = BmiCalculator()
        self.assertFalse(bmiCalc.set_height(""))   # 無効値
    
    def test_set_height_08(self):
        bmiCalc = BmiCalculator()
        self.assertFalse(bmiCalc.set_height("abc"))   # 無効値

test_set_height_01で正常値を確認、
test_set_height_02test_set_height_04で境界値分析、
test_set_height_05test_set_height_08で無効値を確認しています。
また、正常値と無効値を複数入力することで同値分割の確認もできます。
先にテストの条件を書いておくと、頭の中が整理されて何をすべきかわかって良いですね。

さてさて、テスト駆動開発の手順に沿ってテストコードを実行。

bash-3.2$ python -m unittest bmi_test.py
…
(省略)

======================================================================
ERROR: test_set_height_08 (bmi_test.TestBmiCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/XXXX/work/UnitTest/test/bmi_test.py", line 42, in test_set_height_08
    self.assertFalse(bmiCalc.set_height("abc"))   # 無効値
AttributeError: 'BmiCalculator' object has no attribute 'set_height'

----------------------------------------------------------------------
Ran 8 tests in 0.001s

FAILED (errors=8)

うん、想定通り。
BmiCalculatorの中にset_heightがないと怒られました。
だって、まだ作ってn(以下略)

では、テストが通るようにset_heightを作ります。
とりあえず数値かどうか判別して無効値を除外したい。

# BmiCalculator.py

import re

class BmiCalculator:
    def __init__(self):
        pass

    def set_height(self, h):
        if re.match(r"[\d]+[..]?[\d]?", h):
            h = float(h)
            return True
        else:
            return False

こんな感じでどうだ!
上記のコードではif re.match(r"^[\d]+[..]?[\d]?$", h)で小数点第一位までの数字かどうかを判別しています。
正規表現ムズカシーでもベンリー

いざテストコード実行!

bash-3.2$ python -m unittest bmi_test.py
..F.F...
======================================================================
FAIL: test_set_height_03 (bmi_test.TestBmiCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/XXXX/work/UnitTest/test/bmi_test.py", line 22, in test_set_height_03
    self.assertFalse(bmiCalc.set_height("1000.0"))   # 無効値
AssertionError: True is not false

======================================================================
FAIL: test_set_height_05 (bmi_test.TestBmiCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/XXXX/work/UnitTest/test/bmi_test.py", line 30, in test_set_height_05
    self.assertFalse(bmiCalc.set_height("0"))   # 無効値
AssertionError: True is not false

----------------------------------------------------------------------
Ran 8 tests in 0.002s

FAILED (failures=2)

test_set_height_03test_set_height_05のテストが失敗しています。
0を入力した時と1000を入力した時に無効値であるときの返り値であるFalseが返ってきてないとのこと。

正規表現で数値以外の無効値が判別できただけで満足しきってました。あぶないあぶない。
追加しましょう。

# BmiCalculator.py
import re

class BmiCalculator:
    def __init__(self):
        pass

    def set_height(self, h):
        if re.match(r"^[\d]+[..]?[\d]?$", h):
            h = float(h)
            self.height = h
            return True
            if 0 < h < 1000:
                self.height = h
                return True
            else:
                return False
        else:
            return False

if 0 < h < 1000の条件を追加し、有効値を絞りました。

さて、set_heightを修正したところでもう一度テストコードbmi_test.pyを実行してみましょう。
どきどきどきどき。

bash-3.2$ python -m unittest bmi_test.py
........
----------------------------------------------------------------------
Ran 8 tests in 0.001s

OK

オールグリーン!発進準備整いました!

リファクタリングしてみよう

グリーンとなったところで、リファクタリングをしてコードを読みやすくします。
今のプロダクションコードはこんな感じです。

# BmiCalculator.py

import re

class BmiCalculator:
    def __init__(self):
        pass

    def set_height(self, h):
        if re.match(r"^[\d]+[..]?[\d]?$", h):
            h = float(h)
            self.height = h
            if 0 < h < 1000:
                self.height = h
                return True
            else:
                return False
        else:
            return False

これをリファクタリングしていきます。
ざっとコードを見返して、もうちょっと綺麗にできそうと思ったところは以下2点。
・match関数の第一引数に条件を直書きしていて雑多に見える。
・条件分岐のネストが深い。

上記2点を考慮し、ソースをリファクタリングしたものが以下になります。

# BmiCalculator.py

import re

class BmiCalculator:
    INPUT_PATTERN = r"^[\d]+[..]?[\d]?$"

    def __init__(self):
        pass

    def set_height(self, h):
        if not re.match(self.INPUT_PATTERN, h):
            return False
        
        h = float(h)
        if  not 0 < h < 1000:
            return False

        self.height = h
        return True

ちょっとすっきり。見やすくなりました。
でもソースコード変えちゃったので、動くか心配…。
というところで、もう一度テストコードを実行してみましょう!

bash-3.2$ python -m unittest bmi_test.py
........
----------------------------------------------------------------------
Ran 8 tests in 0.001s

OK

圧倒的安心感。

まとめ

今回の記事では以下のことを学習しました。
テスト駆動開発で開発をし、ブラックボックステストを行う。
テスト駆動開発の流れは、1.レッド→2.グリーン→3.リファクタリングの順に行う。
・まず何のテストを行うかを整理することで、プロダクションコードでの実装に抜け漏れがなくなる。
単体テストを行い確実にシステムの仕様通りのコードであることを確認。
ソースコードに変更を加えても、テストコードを実行することにより動作の保証ができる。

テスト項目を整理し、それに合わせて実装、テストをすることにより、自分のコードが正しく動く自信が持てますね。
こういうことをちゃんと普段から意識するようにしなければ。
逆にちゃんと意識するようになれば、もうあんな不安な日々は過ごさなくて良いと!

次回は引き続き残りの部分を実装し、システムを完成させます!

お知らせ

株式会社スカイウイルでは、2021年度の新卒採用を行います。
私たちは、独自の教育研修制度によって、学習したことを自信をもって社内外に発信できる。
そんなエンジニア集団です。

弊社採用サイトやマイナビ2021にて情報を公開しておりますので、
興味のある方は是非一度、会社説明会へお越しください!

・採用サイト
 株式会社スカイウイル 採用サイト

マイナビ2021
 (株)スカイウイルのインターンシップ・会社概要 | マイナビ2021

Django+Javascriptでプログレスバーを実装する

f:id:monozukuri-bu:20200106131540j:plain

hashiです。
外部ツールを使いにくい環境でプログレスバーを実装する必要に駆られてしまいました。
そこで、Django+Javascriptプログレスバーを実装しましたので、その方法をまとめてみました。

全体の処理の流れ

処理の流れは以下のようになっています。
進捗状況を管理するテーブルをDB上に作成し、随時その値を確認することで進捗状況をリアルタイムで取得することができます。

f:id:monozukuri-bu:20200106132313p:plain

環境構築

では、これから実際に実装していくための、開発環境を構築しましょう。

前提

以下の環境であることを前提とします。

プロジェクト用ディレクトリ作成

以下のコマンドを実行して適当な場所にDjangoプロジェクトを格納するためのディレクトリを作成し、その中に入ります。

mkdir progress_bar  # ディレクトリ作って
cd progress_bar     # その中に入る
仮想環境の作成

既にインストールされているパッケージの影響を受けることを避けるため、新しく作った仮想環境内で1から環境構築していきます。
今回はパッケージ管理にpipenvを使用します。入っていない場合は、

pip install pipenv  # pipでインストール

を実行してください。その後、

export PIPENV_VENV_IN_PROJECT=true  # 仮想環境をプロジェクトディレクトリ配下に作るように設定
pipenv shell                        # 仮想環境作成!

これでまっさらな環境が作成できました。
これ以降のコマンドはpipenv shellを実行して仮想環境に入った状態で実行することを前提とします。

Djangoのインストール

以下のコマンドを実行してDjangoをインストールします。

pipenv install django

これで、基本的な開発環境は整いました!

Djangoの動作確認

プロジェクトを作成して、開発のベースとなるガワを作っていきます。

プロジェクトの作成

以下のコマンドを実行してprojectを作成します。

django-admin startproject config .  #今のディレクトリにprojectを作成

現在のファイル構成は以下のようになります。

progress_bar
|--.venv
|--config
| |--__init__.py
| |--settings.py
| |--urls.py
| |--wsgi.py
|--manage.py
|--Pipfile
|--Pipfile.lock
アプリの作成

manage.pyと同階層で以下のコマンドを実行します。

mkdir apps                                  #アプリ用ディレクトリ作成
cd apps                                     #その中に…
python ../manage.py startapp example_app    #アプリ作成

作成したアプリを利用できるようにするためにconfig/settings.pyに以下の内容を追記します。

# config/settings.py

INSTALLED_APPS = +["apps.example_app.apps.ExampleAppConfig"]

続けてapps/example_app/apps.pyを書き換えます。

# apps/example_app/apps.py
from django.apps import AppConfig


class ExampleAppConfig(AppConfig):
    # name = 'example_app'
    name = "apps.example_app"
トップページのviewの作成

アプリのビューを以下のように記述します。

# apps/example_app/views.py
from django.shortcuts import render

def index(request):
    """基本となるページ"""
    return render(request, "example_app/index.html")
テンプレートの作成

テンプレートにプログレスバーを埋め込む部分を用意しておきます。
Bootstrapはプログレスバーの装飾に使用します。

<!-- apps\example_app\templates\example_app\index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8" />
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">

    <!-- jQuery,Popper.js,Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"
        integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
        integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1"
        crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
        integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
        crossorigin="anonymous"></script>

    <title>プログレスバーサンプル</title>
</head>
<body>
    <div class="container">
        <div class="card">
            <div class="card-header">
                プログレスバーサンプル
            </div>
            <div class="card-body">
                <div id="progress">プログレスバー表示部分</div>
                <div id="result">処理結果表示部分</div>
                <div class="row">
                    <button class="btn btn-primary col-12" id="start_button">
                        処理の実行
                    </button>
                </div>
            </div>
        </div>
    </div>
</body>
</html>
urlの設定

まずアプリのurls.pyを作成します。

# apps/example_app/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path("", views.index, name="index"),
]

続けてプロジェクト本体のurls.pyにアプリのurlパターンを読み込ませます。

# config/urls.py
from django.contrib import admin
from django.urls import path, include

from apps.example_app import urls as example_app_url

urlpatterns = [
    path("", include(example_app_url)),
    path("admin/", admin.site.urls),
]
表示確認

表示の確認のためmanage.pyと同階層で以下のコマンドを実行したのち、ブラウザからhttp://127.0.0.1:8000/にアクセスして動作確認します。

python ../manage.py runserver   #サーバ起動

f:id:monozukuri-bu:20200106133349p:plain

これが表示されていればOKです。

これで、ガワは完成しました。
ここから、プログレスバーのロジックを実装していきます。

サーバサイド側

PythonDjango側のロジックを実装していきます。

時間のかかる処理

サンプルとして時間のかかる処理を行う関数を作成します。
0.1秒ごとに1ステップ進み、10秒後に処理が終了する関数です。
10ループごとに実行されるmake_progress_func()が行う処理は後ほど説明します。

# apps\another_app\do_something.py
import time


def slow_function(make_progress_func):
    """裏側で動いている時間のかかる処理"""

    for i in range(100):
        time.sleep(0.1)
        if i % 10 == 0:
            make_progress_func()
    return "処理完了"

早速この関数を、views.pyから呼び出せるようにしておきます。

# apps/example_app/views.pyに以下の内容を追記
from ..another_app.do_something import slow_function
進捗管理モデルの作成(Django)

進捗状況を管理するためのモデルをアプリ内のmodels.pyで定義します。

# apps/example_app/models.py
from django.db import models


class Progress(models.Model):
    """進捗管理モデル"""

    now = models.IntegerField("現在の進捗", default=0)
    total = models.IntegerField("全ステップ数", default=100)

早速、このモデルをviews.pyにインポートしておきます。

# apps/example_app/views.pyに以下の内容を追記
from .models import Progress
進捗管理インスタンス作成部分(Django)

時間のかかる処理を実行する前に呼び出される関数を定義します。
この関数はmodels.pyで定義したProgressのインスタンスを作成し、そのプライマリーキーを返します。
この関数が返すプライマリーキーをもとに様々な処理を行うことになります。

# apps/example_app/views.pyに以下の内容を追記
from django.shortcuts import HttpResponse, render, get_object_or_404

def setup(request):
    """進捗管理インスタンスを作成する"""
    progress = Progress.objects.create()
    return HttpResponse(progress.pk)
プログレスバー表示部分(Django)

プログレスバーの表示にかかわる関数を定義します。
この関数はGETパラメータのprogress_pkに紐づく進捗管理インスタンスを取得し、その進捗度合いをパーセント換算してプログレスバーのテンプレートに渡します。

# apps/example_app/views.pyに以下の内容を追記
def show_progress(request):
    """時間のかかる関数を実行する"""
    if "progress_pk" in request.GET:
        # progress_pkが指定されている場合の処理
        progress_pk = request.GET.get("progress_pk")
        progress = get_object_or_404(Progress, pk=progress_pk)
        persent = str(int(progress.now / progress.total * 100)) + "%"
        return render(request, "example_app/progress_bar.html", {"persent": persent})
    else:
        # progress_pkが指定されていない場合の処理
        return HttpResponse("エラー")
進捗を進める部分(Django)

プログレスバーの進捗度合いを進めるための関数を定義します。 この関数は引数に紐づく進捗管理インスタンスを取得し、progress.nowを10増やして更新します。

# apps/example_app/views.pyに以下の内容を追記
def make_progress(pk):
    """引数のプライマリーキーに紐づく進捗を進める"""
    progress = get_object_or_404(Progress, pk=pk)
    progress.now += 10
    progress.save()
重い処理を呼び出す部分(Django)

時間のかかる処理を呼び出す部分を定義します。
functoolsを用いて引数を固定したmake_progress(pk)を引数としてslow_function()を呼び出します。

# apps/example_app/views.pyに以下の内容を追記
import functools


def set_hikisuu(pk):
    """引数を固定する"""
    return functools.partial(make_progress, pk=pk)


def do_something(request):
    """時間のかかる関数を実行する"""
    if "progress_pk" in request.GET:
        # progress_pkが指定されている場合の処理
        progress_pk = request.GET.get("progress_pk")
        result = slow_function(set_hikisuu(progress_pk))
        return render(request, "example_app/result.html", {"result": result})
    else:
        # progress_pkが指定されていない場合の処理
        return HttpResponse("エラー")
追加分のurl設定

ここまでで定義した関数に紐づくURLを設定します。

# apps/example_app/urls.pyに以下の内容を追記
urlpatterns += [
    path("setup/", views.setup, name="setup"),
    path("show_progress/", views.show_progress, name="show_progress"),
    path("do_something/", views.do_something, name="do_something"),
]

フロントエンド側

ブラウザの表示部分をJavascriptで実装していきます。

Djangoと表示の橋渡し

JavaScript部分を追記します。処理のフローは以下の通りです。

・画面上の処理開始ボタンが押される。
/setupにリクエストし進捗管理インスタンスのプライマリーキーを取得する。
/show_progressに定期的にリクエストしプログレスバーを取得し、画面上に反映しつづける。
/do_somethingにリクエストし処理結果を取得し、画面上に反映する。

<!-- apps\example_app\templates\example_app\index.htmlに追記 -->
<script>
    //プログレスバー表示部分の初期状態
    const progresshtml = '<div id="progress">プログレスバー表示部分</div>';

    //処理開始ボタンが押された時の処理
    $("#start_button").on("click", function (event) {
        console.log("start")
        let timer_id = 0;
        let url = "{% url 'setup' %}"
        $("#start_button").attr({ "disabled": true })
        //進捗管理インスタンス作成部分
        $.get(url, {},
            function (data) {
                console.log("get")
                let pk = data
                console.log("Data Loaded: " + data);

                //プログレスバーを3秒ごとに取得開始
                timer_id = setInterval(function () { ShowProgressBar(pk) }, 3000)
                //時間のかかる処理を開始
                GetResult(pk)
            }
        );
        //プログレスバーの取得
        function ShowProgressBar(progress_pk) {
            $.get("{% url 'show_progress' %}", { progress_pk: progress_pk },
                function (data) {
                    console.log("Data Loaded: " + data);
                    $("#progress").replaceWith(data)
                }
            );
        }
        //時間のかかる処理
        function GetResult(progress_pk) {
            $.get("{% url 'do_something' %}", { progress_pk: progress_pk },
                function (data) {
                    console.log("Data Loaded: " + data);
                    //プログレスバー更新をやめる
                    clearInterval(timer_id);
                    //プログレスバー部分を元の状態に戻す
                    $("#progress").replaceWith(progresshtml)
                    //処理結果表示
                    $("#result").replaceWith(data)
                    $("#start_button").attr({ "disabled": false })
                    alert("処理完了!")
                }
            );
        }
    });
</script>
プログレスバー表示部分(テンプレート)

Bootstrap4のドキュメントを参考に、プログレスバー部分を構築します。

getbootstrap.com

<!-- apps\example_app\templates\example_app\progress_bar.html -->
<div id="progress">
    <div class="progress">
        <div class="progress-bar" style="width:{{persent}}"></div>
    </div>
</div>
重い処理の処理結果部分(テンプレート)

この記事のアプリでは単にresultの値を出力するだけのものにしておきます。

<!-- apps\example_app\templates\example_app\result.html -->
<div id="result">
    {{result}}
</div>

動作確認

現在のファイル構成は以下のようになっているはずです。

|--apps
| |--__init__.py
| |--__pycache__
| |--another_app
| | |--__init__.py
| | |--__pycache__
| | |--do_something.py
| |--example_app
| | |--__init__.py
| | |--__pycache__
| | |--admin.py
| | |--apps.py
| | |--migrations
| | |--models.py
| | |--templates
| | | |--example_app
| | | | |--index.html
| | | | |--progress_bar.html
| | | | |--result.html
| | |--tests.py
| | |--urls.py
| | |--views.py
|--config
| |--__init__.py
| |--__pycache__
| |--settings.py
| |--urls.py
| |--wsgi.py
|--db.sqlite3
|--manage.py
|--Pipfile
|--Pipfile.lock

manage.pyと同階層で以下のコマンドを実行します。

python manage.py migrate    #DBのマイグレーション
python manage.py runserver  #サーバ起動

ブラウザからhttp://127.0.0.1:8000/にアクセスして「処理の実行」ボタンを押すと少しずつプログレスバーが進み、右端に到達するあたりで処理結果が表示されるはずです。

f:id:monozukuri-bu:20200106133349p:plain f:id:monozukuri-bu:20200106135757p:plain f:id:monozukuri-bu:20200106135805p:plain f:id:monozukuri-bu:20200106135814p:plain

終わりに

時間のかかる処理を扱う関数と進捗を進める関数の結合を弱めるため、進捗を進める関数の中身を意識せずに済むように意識しました。
これによってテスト時に考慮すべき必要なケース数が少なくて済むはずです。