AREKORE

daikikatsuragawaのアレコレ

Pythonによる開発をアップデートするライブラリの紹介

2022年7月29日(金)、30日(土)に開催された「Open Source Conference 2022 Online Kyoto」に参加しました。そこで私(id:daikikatsuragawa)は「Pythonによる開発をアップデートするライブラリの紹介」というテーマで発表しました。

event.ospn.jp

発表資料は以下です。

speakerdeck.com

本記事は、発表に向けて用意した原稿を記事として再構築したものです。目次は以下です。

Pythonによる開発をアップデートするライブラリ

はじめに、Pythonについて説明します。Pythonとは「動的型付けプログラミング言語」のひとつです。その特徴として、さまざまなライブラリがオープンソースソフトウェア(OSS)として公開されていることが挙げられます。例えば、機械学習に関する実装がなされているライブラリが公開されており、それを利用することで機械学習の実現が可能です。PythonサードパーティーソフトウェアリポジトリであるPyPIには、38万を超えるソフトウェアやライブラリが登録されています。

pypi.org

そして、GitHubの調査によると、Pythonは3年連続で人気プログラミング言語の第2位にランクインしており、多くの開発者に親しまれています。

octoverse.github.com

本記事はそんなPythonの開発について紹介します。具体的には「Pythonによる開発をアップデートするライブラリ」を紹介します。

まず、本記事の背景と動機を共有します。まず、「開発における理想と現実」について説明します。みなさん、開発における理想を想像してみてください。私なら「成果物が正常に動作すること」「保守性が高いこと(複数人での開発が可能)」です。 そのような理想に対して、現実が一致しない場合があります。具体的には「成果物に不具合が生じることがあること」や「開発の保守性が高いという自信が無いこと」などが挙げられます。「理想に対して、現実が一致しない場合がある」ということは、「理想と現実にギャップが存在する」と言い換えることが可能です。このギャップを改善することで現実は理想に近づくと考えられます。そのため、ここで言うギャップを整理します。理想が「成果物が正常に動作すること」であるのに対して、現実は「成果物に不具合が生じることがあること」だったとします。この時のギャップは「開発者が不具合を埋め込んでしまうこと」です。理想が「開発の保守性が高い(複数人での開発が可能)」であるのに対して、現実は「開発の保守性が高いという自信が無いこと」だったとします。この時のギャップは「開発者が保守性の低いコーディングをする」です。このように「開発における理想と現実」にはギャップが存在しているということがわかります。

理想 現実 理想と現実のギャップ
成果物が正常に動作すること 成果物に不具合が生じることがあること 開発者が不具合を埋め込んでしまうこと
保守性が高いこと(複数人での開発が可能) 開発の保守性が高いという自信が無いこと 開発者が保守性の低いコーディングをする

ここまでで理想と現実の間にはギャップがあることがわかりました。続いて、そこでギャップを解消する手段について考えます。例えば「個人の能力の向上」が挙げられます。ただし、これは個人に依存する為、不確実性が大きいです。他に「仕組みの導入」があります。つまり、開発プロセスの改善です。これは仕組みを規約とすることで実現可能性が高くなります。以上より、開発プロセスの改善により理想と現実のギャップの解消を試みます。

開発プロセスの改善」を試みるために、理想と現実のギャップを開発プロセスの課題と捉え直します。まず「開発者が不具合を埋め込んでしまうという」という課題は「不具合を埋め込ませない開発プロセスの構築が必要」というような開発プロセスの課題として捉え直すことが可能です。また「開発者が保守性の低いコーディングをする」といった課題は「一定の保守性を担保する開発プロセスの構築が必要」というような開発プロセスの課題と捉え直すことが可能です。つまり、理想と現実のギャップというのは開発プロセスの課題と言えます。

理想と現実のギャップ 開発プロセスの改善
開発者が不具合を埋め込んでしまうこと 不具合を埋め込ませない開発プロセスの構築が必要
開発者が保守性の低いコーディングをする 一定の保守性を担保する開発プロセスの構築が必要

改めてお伝えすると、課題は開発プロセスの課題と変換が可能です。開発プロセスの課題はツールによって改善が期待されます。また、Pythonには様々なライブラリが存在します。これらを加味して、本記事では「有用なライブラリを導入し利用」することで開発プロセスの改善を考えます。以上の背景・動機により「Pythonによる開発プロセスを改善(つまり、開発をアップデート)する有用なライブラリを紹介します。

本記事で紹介するライブラリはこのようになっています。まず、pydanticです。モデルの定義に基づくバリデーションを実現します。次に、panderaです。スキーマの定義に基づくデータフレームのバリデーションを実現します。そして、hypothesisです。入出力に関するプロパティの定義に基づくProperty-based testingを実現します。最後に、streamlitです。必要最小限のウェブアプリケーションを簡単に実現します。pydantic、pandera、hypothesisはそれぞれ導入による堅牢性と可読性の向上が期待されます。streamlitは、早期に提供価値の評価を実現させます。

本記事で紹介するライブラリについて「伝えること」と「伝えないこと」は以下です。

  • 伝えること
    • ライブラリの概要
    • ライブラリの利点
    • 簡単な利用例
  • 伝えないこと
    • 詳細な利用例

それゆえ、本記事を知るキッカケとして捉えていただけると幸いです。そして、気になったら是非調べてみてください。

型ヒントとは?

以後、上記で紹介したライブラリについて紹介します。その前提として、事前に理解しておくことが望ましいPythonの「型ヒント」についての説明をします。型ヒントとは、「変数の定義」、「関数の引数や戻り値の定義」に型のヒントを付与する記法です。Pythonの3.5以降導入されています。以下、型ヒントを使ったコードです。

name: str = "パイソン 太郎"

def calculate_division(numerator: int, denominator: int) -> float:
   return numerator / denominator

例えばこのコードの場合、nameというような変数に文字列を代入しています。これがstring型だということは暗黙的に理解できるのですが、このように明示的に示すこともできます。これが型ヒントです。他にも関数の引数、戻り値の定義にもこのように型ヒントを付与が可能です。これによりこの変数や関数の引数、戻り値がどんな型を期待しているのかという情報を読み取ることが可能です。以上よりコードの可読性の向上が見込まれます

型ヒントの振る舞いについて説明します。実は、実行時に型のチェックはしません。そのため型ヒントに対して、期待していない状態だったとしてもエラーにならないです。あくまで開発者向けのコメントのような扱いです。サードパーティのツール、例えばmypyと組み合わせることで静的チェックを実現したりします。

www.mypy-lang.org

以上より型ヒントを使うこととサードパーティのツールと組み合わせることで堅牢性の向上が見込まれます。

型ヒントの記述方法について説明します。基本は大きく三つ記述方法があります。一つ目は変数に関して、直後にコロン(:)と型を記述します。

name: str = "パイソン 太郎"

続いて、関数の引数については、変数と同じで各引数の直後にコロンと型を記述します。関数の戻り値に関しては関数の定義中のコロンの直前にハイフンと大なりからなる矢印のような記号(->)と型を記述します。

def calculate_division(numerator: int, denominator: int) -> float:
   return numerator / denominator

続いて、複雑な型の指定についてです。先ほどは int型、float型、str型の例を挙げました。それに加えて、dict型やlist型などの構造を持つ型の指定も可能です。念のためお伝えすると、以降で説明に挙げるコードはPython 3.8までの記法です。まず、dict型、list型などの要素の型の指定が可能です。まず、Dictというクラスをインポートし、これを型ヒントとして使います。この時、dict型のキーおよびバリューの型の指定が可能です。

from typing import Dict

user_and_age: Dict[str, int] = {'パイソン 太郎': 20}

複数の型の指定も可能です。以下のコードでは、Unionというクラスをインポートし、型ヒントとして指定したい型を複数種、指定します。

from typing import Union

str_or_int: Union[str, int] = 0 # or パイソン 太郎"

他にも値を持っていることを必須としない変数には、必須ではない(Optional)という指定が可能です。

from typing import Optional

optional_int: Optional[int] = None # or 0

どんな値でも受け入れるという場合は、任意(Any)という指定が可能です。

from typing import Any

any: Any = 0 # or "パイソン 太郎", None, {"パイソン 太郎": 20} ...

関数の返り値が存在しない(None)という指定も可能です。

from typing import Any

def return_none(input: Any) -> None:
    return None # もしくはリターンをしない

最後に、プリミティブな型以外の指定も可能です。先ほどのDictというクラスを使った例なども該当しますが、プリミティブな型以外の指定も可能です。つまり、自作のクラスも型ヒントとしての指定が可能です。例えば、SampleClassというクラスを定義したとします。これに対して、インスタンスを生成する際、そのインスタンスの型はSampleClassです。この時、SampleClassを型ヒントとしての指定が可能です。関数の引数、返り値の場合も同様です。

class SampleClass:
   pass

sample_class: SampleClass = SampleClass()

def return_input(input: SampleClass) -> SampleClass:
   return input

型ヒントについてのまとめをお伝えします。型ヒントとは、変数の定義、関数の引数や戻り値の定義に型のヒントを付与する記法です。型ヒントの振る舞いとしては実行時に型のチェックはしません。それゆえ、たとえ誤っていてもエラーが生じません。そこで、サードパーティのツール、例えばmypyと組み合わせることで静的チェックの実現が可能です。これにより可読性と堅牢性の向上が期待されます。

以降、型ヒントを活かして可読性と堅牢性を向上させるpydanticとpanderaを紹介します。

モデルの定義に基づくバリデーションを実現するpydantic

それでは、モデルの定義に基づくバリデーションを実現するpydanticについて紹介します。

みなさん、複数の部品で構成されるシステムを開発していますか?多くのシステムは、複数の部品で構成されていると考えられます。ここでの部品とは、モジュール、クラス、メソッドなどのことです。基本的には、各部品が役割と責任を持ち、連携して動作します。つまり、各部品の入出力を把握しておけば、システムの全てを把握する必要はありません。不要です。入出力にはdict、JSONといった構造を持つものも多く使われます。

ここでは、複数の部品から構成されるシステムの開発において、そのような課題を2つ取り上げます。まずは「入出力で扱うデータの定義がわからない」という可読性の課題です。他の部品の出力を入力として受け取る場面があるかと思います。この時に他の部品の出力の構造が分からなければ、他の部品の実装を深く調べる必要があるといった可読性の課題が挙げられます。例えば部品Yが部品Zにデータを格納したJSONを渡すとします。この時、部品Zからすると、JSONなのはともかく、その構造、要素は何かが理解できない可能性があります。

また、「入出力で扱うデータが正しくない」という堅牢性の課題が挙げられます。他の部品の出力を把握しているものの期待と異なる、正しくないデータを受け取る場面を指します。例えば、受け取った年齢の値が「−1」、型が異なる、期待しているデータがないといった場合です。例えば部品Yが部品Zにデータを格納したJSONを渡すとします。この時、部品Zはどんな内容のJSONを受け取るのかを把握しているものの、受け取ったデータが正しくない可能性があります。

先に挙げた課題の解決に期待されるライブラリとしてpydanticを紹介します。pydanticとはモデルを定義することでデータのバリデーションを実現するライブラリです。それに加え、dict型やJSONとのシリアライズ/デシリアライズも可能で、部品の入出力に有用です。

pydantic-docs.helpmanual.io

PyPIで公開されており、このコマンドによりインストールが可能です。

!pip install pydantic

ライセンスはMIT Licenseです。

github.com

pydanticを導入することにより先ほどの課題の解決が期待されます。入出力で扱うデータの定義がわからないという課題については、モデルの定義をコードとして記述することにより把握が可能となります。入出力で扱うデータが正しくないという課題については、定義に基づくバリデーションにより正しいデータのみが存在する状態となります。以降、具体的なpydanticの利用方法についてお伝えします。

pydanticの利用例としてまずはモデルの定義について紹介します。以下はpydanticによりモデルを定義しているコードになります。

from pydantic import BaseModel, Field

class User(BaseModel):
   name: str
   age: int = Field(ge=20)

pydanticのモジュールであるBaseModelを継承した任意のクラスがモデルの定義となります。今回はUserというクラスを定義しました。インスタンス変数は名前(name)と年齢(age)の2つです。また、このモデルの定義のさい、型の情報に加え、有効範囲の定義などが可能です。例えば、この年齢は20歳以上の場合に受け付けます。続いて、以下はインスタンスを生成するコードです。

external_data = {
   'name': 'パイソン 太郎',
   'age': 20
}
user = User.parse_obj(external_data)

dict型の変数からインスタンスを生成するparse_objというメソッドがあります。これにより、従来、dict型やJSONを扱っていた場合、このように利用することで、扱っているデータの定義をコードから読み取ることが可能です。つまり、可読性が保たれます。続いて、別のインスタンスを生成します。試しに年齢が19歳という有効範囲外の値を持つインスタンスの生成を試みます。

external_data = {
   'name': 'パイソン 太郎',
   'age': 19
}
user = User.parse_obj(external_data)

この時、ValidationErrorというエラーが生じます。つまり、定義を満たさないインスタンスは生成されません。つまり、堅牢性が保たれます。一部を省略しますが、ValidationErrorの出力は以下のようになっています。

(省略)
ValidationError: 1 validation error for User
age
  ensure this value is greater than or equal to 20 (type=value_error.number.not_ge; limit_value=20)

年齢について「20以上という定義を満たしていない(ensure this value is greater than or equal to 20)」という旨が書かれています。このようにエラーの詳細の確認が可能です。続いて、pydanticによって定義したモデルを引数とする関数について説明します。

from pydantic import validate_arguments

@validate_arguments
def input_user(user : User) -> None:
   pass

実現するために記述する内容は大きく2つです。まずは関数に対して、引数のバリデーションを実行するデコレータであるvalidate_argumentsを付与します。もう一つはpydanticで定義したクラスを引数の型ヒントとして指定します。そして、以下が実行です。

external_data = {
   'name': 'パイソン 太郎',
   'age': 20
}
input_user(external_data)

pydanticのBaseModelを継承したクラスはdict型、JSONとのシリアライズおよびデシリアライズをしてくれるため、dict型のままでの関数への入力が可能です。以下のコードでは定義を満たすため、成功します。有効範囲外の値を持つ場合、ValidationErrorが生じます。

external_data = {
   'name': 'パイソン 太郎',
   'age': 19
}
input_user(external_data)

入力として与えるタイミング、つまり、関数内で記述した処理を実行する前にエラーが発生します。それゆえ、不要な計算は実施されません。続いて、Userというクラスを返り値とする関数について紹介します

def output_user() -> User:
    external_data = {
        'name': 'パイソン 太郎',
        'age': 20
    }
    return User.parse_obj(external_data)

この関数は内部で任意の処理をしたとして、Userインスタンスを生成し、これを返り値とします。また、部品間のやりとりが実行環境が違うという場合は、インスタンスのやり取りができないため、dict型やJSONに変換します。

output_user().dict()

このような場合でも以下のように有効範囲外の値があれば、関数実行中にエラーが生じます。

def output_user() -> User:
    external_data = {
        'name': 'パイソン 太郎',
        'age': 19
    }
    return User.parse_obj(external_data)

つまり、この関数の返り値は正常なものであるということが担保されます。ここまでは簡単な型であったり、数値の有効範囲を満たすか否かを定義し、バリデーションを実現していました。それに加え、詳細なバリデーションの実現も可能です。例えば、名前の要件として、半角空白(” ”)を含むとします。

from pydantic import BaseModel, Field, validator
import unicodedata

class User(BaseModel):
   id: int
   name: str # 半角空白を含む(※前後以外)
   age: int = Field(ge=20)
  
   @validator("name")
   def check_contain_space(cls, v):
       if " " not in v.strip(): # 前後の半角空白を無視
           raise ValueError("ensure this value contains spaces")
       return v.strip() # 前後の半角空白を削除

この要件に対するバリデーションをデコレーターとメソッドにより実現させます。まず、メソッドを用意します。今回はcheck_contain_spaceという、前後を除いて半角空白が含まれるかを判定し、含まれない場合にエラーを発生させるメソッドを用意しました。これに対し、validatorというデコレーターを付与します。ここに対象とするインスタンス変数を指定します。今回はnameを指定します。これにより、デコレーターとメソッドによるバリデーションが可能です。この記述におけるメソッドを工夫すると、詳細なバリデーションの実現が可能です。

ここまでお伝えしたpydanticについてのまとめです。まず複数の部品によって構成されるシステムの開発について各部品が役割と責任を持ち連携しているということをお伝えしました。ただ課題もあります。まだ入出力で扱うデータの定義が分からないという可読性の課題が考えられます。そして、入出力で扱うデータが正しくないという堅牢性の課題が考えられます。そのような課題を解決する手段としてpydanticを紹介しました。これはモデルの定義をすることでデータのバリデーションを実現するライブラリです。モデルの定義により、コードから要件の把握が可能です。また、定義に基づくバリデーションにより正しいデータのみが存在する状態にできます。

スキーマの定義に基づくデータフレームのバリデーションを実現するpandera

続いて、データフレームの定義に基づくバリデーションを実現するpanderaについて紹介します。

みなさん、データフレームを使っていますか?実は様々な種類が存在していますが、今回挙げるデータフレームというのはpandasというライブラリのDataFrameとします。データフレームは、表形式のデータの取り込み、加工、集計、分析に利用されるものです。データ分析や機械学習などで活躍しています。

sepal_length sepal_width petal_length petal_width target
0 5.1 3.5 1.4 0.2 0
1 4.9 3 1.4 0.2 0
2 4.7 3.2 1.3 0.2 0
3 4.6 3.1 1.5 0.2 0
4 5 3.6 1.4 0.2 0

そのようなデータフレームにおける課題を挙げます。まずは「意図していない値を格納してしまう」といった課題です。以下のデータフレームはその例です。

sepal_length sepal_width petal_length petal_width target
146 6.3 2.5 5 1.9 2
147 6.5 3 5.2 2 2
148 6.2 3.4 5.4 2.3 2
149 5.9 3 5.1 1.8 2
150 ◯△□ 3 4.35 1.3 3

例えば、数値を期待しているsepal_lengthという列に「◯△□」という文字列が格納されてしまったりします。また、targetという列は、「0」/「1」/「2」というカテゴリカルな数値を期待しています。しかし、期待されている値ではない「3」が格納されてしまっています。このように意図しない値を格納してしまうという課題が発生します。

次に「他者・未来の自分がコードから内容を読み取れない」といった課題です。例えば、以下はデータフレーム(scikit-learnより取得)を扱うコードです。

scikit-learn.org

import pandas as pd
from sklearn.datasets import load_iris

data = load_iris()
iris = pd.DataFrame(data.data, columns=data.feature_names)
iris["target"] = data.target
iris.head()

このirisという変数はデータフレーム型のインスタンスです。変数名からアヤメについての情報が格納されていることの想像が可能です。しかし、このカラム、およびその値が数値なのか文字列なのかといった要件について、コードから読み取ることは不可能です。これでは、他者がコードから詳細を読み取れず、協調作業は困難になります。それに加え、未来の自分もこの内容を思い出すのは困難でしょう。このように「他者・未来の自分がコードから内容を読み取れない」という課題が発生します。

そこでpanderaというようなライブラリを紹介します。panderaとはスキーマ(つまり、構造)の定義により、データフレームのバリデーションを実現するライブラリです。

pandera.readthedocs.io

PyPIで公開されており、このコマンドによりインストールが可能です。

!pip install pandera

ライセンスはMIT Licenseです。

github.com

panderaは先ほど挙げた課題の解決に有用です。まず「意図しない値を格納してしまう」という課題がありました。panderaを導入することによってデータフレームのバリデーションを実現し、意図しない値の格納を防ぐことが可能です。続いて、「他者・未来の自分がコードから内容を読み取れない」といった課題がありました。panderaを導入に伴いスキーマを明示的に定義することで、コードから内容の読み取りが可能です。続いて、具体的なpanderaの利用例について紹介をします。panderaによってバリデーションを実現する方法として以下2種類のクラスの選択が可能です。

DataFrameSchema SchemaModel

今回はSchemaModelというクラスを使う方法を紹介します。こちらは、スキーマの定義がpydanticとインタフェースが類似しているため、pydanticを利用している方におすすめです。

それではpanderaの利用例について説明します。まず準備としてアヤメのデータセットを準備します。説明を簡単にするために、各カラム名は単純なものに変換しています。

import pandas as pd
from sklearn.datasets import load_iris

data = load_iris()
iris = pd.DataFrame(data.data, columns=data.feature_names)
iris["target"] = data.target
iris = iris.rename(
   columns={
       "sepal length (cm)": "sepal_length",
       "sepal width (cm)": "sepal_width",
       "petal length (cm)": "petal_length",
       "petal width (cm)": "petal_width",
   }
)

先ほどのコードの実行によって、以下のようなデータフレームが生成されます。上から5件のレコードを表示させています。

iris.head()
sepal_length sepal_width petal_length petal_width target
0 5.1 3.5 1.4 0.2 0
1 4.9 3 1.4 0.2 0
2 4.7 3.2 1.3 0.2 0
3 4.6 3.1 1.5 0.2 0
4 5 3.6 1.4 0.2 0

カラムは5つです。がく片、花弁の長さ、幅はfloat型の数値です。アヤメの種類が「0」/「1」/「2」のカテゴリカルな数値として格納されています。また、データの統計情報を確認します。

iris.describe()
sepal_length sepal_width petal_length petal_width target
count 150 150 150 150 150
mean 5.843333 3.057333 3.758 1.199333 1
std 828066 0.435866 1.765298 0.762238 0.819232
min 4.3 2 1 0.1 0
25% 5.1 2.8 1.6 0.3 0
50% 5.8 3 4.35 1.3 1
75% 6.4 3.3 5.1 1.8 2
max 7.9 4.4 6.9 2.5 2

これによりそれぞれのカラムの最大値が分かります。この情報はスキーマの定義の際に使います。それではpanderaを使ってスキーマを定義します。panderaのスキーマはこのように定義します。

import pandera as pa
from pandera.typing import Series

class IrisSchema(pa.SchemaModel):
   sepal_length: Series[float] = pa.Field(gt=0, le=8)
   sepal_width: Series[float] = pa.Field(gt=0, le=5)
   petal_length: Series[float] = pa.Field(gt=0, le=7)
   petal_width: Series[float] = pa.Field(gt=0, le=3)
   target: Series[int] = pa.Field(isin=[0, 1, 2])

   class Config:
       name = "BaseSchema"
       strict = True
       coerce = True

panderaのSchemaModelを継承したIrisSchemaというクラスを定義します。そして、列ごとの要件を設定していきます。がく片、花弁の長さ、幅はfloat型の数値と設定します。それに加え、それぞれの先ほどの統計情報を参考に最小値・最大値を設定します。種類を示す列に「0」/「1」/「2」のいずれかが入ると設定します。それでは。この定義したスキーマを使ってバリデーションを実施してみます。先ほど定義したクラスのメソッドであるvalidateに対して、データフレームを入力として与えることでバリデーションが実施されます。まずは、定義の内容を満たすデータフレームを入力してみます。すると、同じデータフレームが出力されます。何事もないですが、これは問題がなかったことを意味します。これによりデータフレームのバリデーションが実現されます。

iris = IrisSchema.validate(iris)
iris.head()
sepal_length sepal_width petal_length petal_width target
0 5.1 3.5 1.4 0.2 0
1 4.9 3 1.4 0.2 0
2 4.7 3.2 1.3 0.2 0
3 4.6 3.1 1.5 0.2 0
4 5 3.6 1.4 0.2 0

次に、意図しないレコードを追加したデータフレームを用意します。ここでの意図しないレコードとして、targetに「3」を入れます。

invalid_record = {
   "sepal_length": 5.8,
   "sepal_width": 3.0,
   "petal_length": 4.35,
   "petal_width": 1.3,
   "target": 3, # invalid value
}
invalid_iris = iris.append(invalid_record, ignore_index=True)
invalid_iris["target"] = invalid_iris["target"].astype(int)

この列の定義として、「0」/「1」/「2」のいずれかにあてはまるようにしているため、意図しないレコードとなってしまいます。以下が、先ほどのレコードを追加した、バリデーション前のデータフレームです。

invalid_iris.tail()
sepal_length sepal_width petal_length petal_width target
146 6.3 2.5 5 1.9 2
147 6.5 3 5.2 2 2
148 6.2 3.4 5.4 2.3 2
149 5.9 3 5.1 1.8 2
150 5.8 3 4.35 1.3 3

インデックスが150のレコードのtargetに「3」といった意図しない値が入っています。この意図しないレコードを含むデータフレームに対して、バリデーションを実施してみます。

invalid_iris = IrisSchema.validate(invalid_iris)

すると、以下のようなSchemaErrorというエラーが発生しました。

(省略)

SchemaError: <Schema Column(name=target, type=DataType(int64))> failed element-wise validator 0:
<Check isin: isin({0, 1, 2})>
failure cases:
   index  failure_case
0    150             3

エラーの内容を読んでみると、「インデックスが150のレコードでエラーが発生している」ということが書いてあります。このように、panderaによるデータフレームのバリデーションが実現されます。

panderaのまとめです。まずは、データフレーム(pandas.DataFrame)について紹介しました。データフレームは表型式のデータの取り込み、加工、集計、分析に利用されます。 データ分析/機械学習などで活躍します。ただし、「意図しない値を格納してしまう」「他者・未来の自分がコードから内容を読み取れない」という課題を挙げました。そのような課題の解決が期待されるライブラリであるpanderaを紹介しました。これは、データフレームのスキーマ(構造)を定義することでデータフレームのバリデーションを実現するライブラリです。これにより、「バリデーションにより意図しない値の格納を防ぐ」「スキーマを明示的に記述することでコードから内容の読み取りが可能に」ということが実現されます。

入出力に関するプロパティの定義に基づくProperty-based testingを実現するhypothesis

続いて、入出力に関するプロパティの定義に基づくProperty-based testingを実現するhypothesisについて紹介します。

みなさん、単体テストは実施していますか?単体テストはプログラムを構成する小さな部品(関数/メソッド)が意図した振る舞いか否かを検証するテストです。Pythonではunittestやpytestというテスト用のフレームワークが有名です。一般的によくみられる単体テストの種類として「Example-based testing」があります。関数やメソッドに対して、任意の基準で入力値を選択し、出力値や事後状態を確認することにより振る舞いを検証する手段です。任意の基準とは、ランダムに選択したり、境界値テストに従って選択するなどを意味します。)例えば、除算するメソッドに対して、「3」と「12」という入力を与えて「4」が出力されることを確認するテストを指します。

そのようなExample-based testingにおける課題として「テストの品質がテスト作成者に依存してしまう」といったものが挙げられます。それにより、テスト作成者の想定内の内容で不十分になってしまう危険があります。例えば、境界値テストなどの手法を知らず要件を満たさない検証となったといったことが生じます。他にも、除算するメソッドに対して、適当な入出力での検証は実施しているが、分母が「0」の場合、入力が「None(null)」の場合といったエッジケースを見逃すといったことが生じます。また、冗長なテストを書いてしまう危険があります。例えば、複数の例を設定し検証したが特に意味がないといったことです。

そのような単体テストの課題に対して、Property-based testingが有用です。Property-based testingとは、入出力に関するプロパティを定義し多数の入力を与え検証する方法です。入出力に関するプロパティの例として、除算するメソッドに対して、「入力値はint型の自然数、ただし分母に0、入力にNone(null)は許可しない」、「出力はfloat型の数値」といったことが挙げられます。

そのようなProperty-based testingを実現するライブラリとしてhypothesisがあります。入出力に関するプロパティの定義に基づくProperty-based testingを実現するライブラリになっています。PyPIで公開されており、このコマンドによりインストールが可能です。

hypothesis.readthedocs.io

PyPIで公開されており、このコマンドによりインストールが可能です。

!pip install hypothesis

ライセンスはMozilla Public License Version 2.0です。

github.com

Property-based testingを実現するhypothesisは先ほど挙げた単体テストにおける課題に有用です。課題としてテストの品質がテスト作成者に依存してしまうといった課題がありました。これに対して、hypothesisおよびProperty-based testingの導入によりテスト作成者に依存しないテストの品質の担保が実現されます。

それではhypothesisの利用例についてお伝えします。まずはテスト対象の関数を用意します。

# main.py

def calculate_subtraction(a: int, b: int):
  return a - b

calculate_subtractionという関数を用意しました。概要としては、入力された2つの整数を減算します。入力は整数(int型)を2件、出力は整数(int型)になります。

calculate_subtractionに対して、単体テストの作成を試みます。プロパティは、入力がそれぞれint型の自然数xとyです。そして、xがyより大きいとします。この時、出力は0以上1以下となります。ひとまず、このプロパティの実装を試みた結果が以下です。

# test_main.py

from main import calculate_subtraction
from hypothesis import given, strategies

@given(x=strategies.integers(), y=strategies.integers())
def test_calculate_subtraction(x, y):
  assert 0 < calculate_subtraction(x, y)

これにより、入力x、yにはランダムな数字が設定され、テストは複数回実施されます。実はこの単体テストの実装には不備がありまして、その辺りの解消を試みつつhypothesisの挙動を紹介します。pytestコマンドにより実行します

pytest test_main.py

すると、AssertionErrorが生じました。

Falsifying example: test_calculate_subtraction(
   x=0, y=0,
)

(省略)

FAILED test_main.py::test_calculate_subtraction - assert 0 < 0

内容を確認すると、関数の出力がプロパティで設定した要件(0より大きい)を満たさなかった事によるエラーだということがわかります。その時の入力をみてみると、入力として想定していた自然数ではない値(0)が与えられていました。あらかじめ実装したかったプロパティが実現できていないようなので修正します。改めて実装からプロパティを考え直すと、入力を自然数としたいところ、0以下を含む整数となっていたようです。修正した結果が以下のコードです。入力x、yの最小値に1を設定し入力を自然数に限定しています。

  # test_main.py

  from main import calculate_subtraction
  from hypothesis import given, strategies

- @given(x=strategies.integers(), y=strategies.integers())
+ @given(x=strategies.integers(min_value=1), y=strategies.integers(min_value=1))
  def test_calculate_subtraction(x, y):
      assert 0 < calculate_subtraction(x, y)

pytestコマンドにより実行します。すると、AssertionErrorが生じました。

Falsifying example: test_calculate_subtraction(
   x=1, y=1,
)

(省略)

FAILED test_main.py::test_calculate_subtraction - assert 0 < 0

内容を確認すると、関数の出力がプロパティで設定した要件(0より大きい)を満たさなかった事によるエラーだということがわかります。確認すると、入力として想定していたx>yを満たさないことがわかります。改めて、以下は、xがyより大きいという条件を設定していない単体テストです。修正した結果が以下のコードです。

  # test_main.py
  
  from main import calculate_subtraction
  from hypothesis import assume, given, strategies
  
  @given(x=strategies.integers(min_value=1), y=strategies.integers(min_value=1))
  def test_calculate_subtraction(x, y):
+     assume(x > y)
       assert 0 < calculate_subtraction(x, y)

関数の入力に与える前にxとyを比較し、条件を満たすものだけ検証するようにしています。この修正した単体テストを実行してみると、問題なく通ります。このようにhypothesisにより、Property-based testingが実現されます。この時の施行回数は100回でした。具体的には、以下に挙げるような値をランダムに生成し、検証しているようです。

x y
1 30604 30261
2 109 5
3 10694612245883426162890217387823834314 9

hypothesisのまとめです。まず、Example-based testing(一般的によくみられる単体テスト)について説明しました。関数やメソッドに対して、任意の基準(例:ランダム/境界値テスト)で入力値を選択し、出力値や事後状態の確認により振る舞いを検証すると、いう手法です。「テストの品質が実装者に依存」という課題を挙げました。それに対して、Property-based testingを紹介しました。これは、入出力に関するプロパティを定義し多数の入力を与え検証する手法です。そして、hypothesisを紹介しました。これは、入出力に関するプロパティの定義に基づくProperty-based testingを実現するライブラリです。これにより、「Property-based testingによるテスト作成者に依存しないテストの品質を担保」が実現されます。

必要最小限のウェブアプリケーションを簡単に実現するstreamlit

本記事で紹介するライブラリは次が最後です。必要最小限のウェブアプリケーションを簡単に実現するstreamlitについて紹介します。

前提として、Minimum Viable Product(MVP)について説明します。MVPとは、ユーザーに必要な最小限の価値を提供できるプロダクトです。提供しようとしてる価値の評価に有用だと言われています。例えば、ユーザーに移動という価値を与えるために、自動車を開発しているとします。この時、「そもそもユーザーが移動を必要としているのか」を検証したい、とします。しかし、自動車の開発というのはとても大規模で大変なものになります。完成してから価値の評価をすることを考えます。また、「実は移動には想定していた価値がなかった」と評価されたとします。すると、それまでの労力や費用などの損失が大きくなってしまいます。そこで、移動という価値を評価するために、まずはスケートボード、自転車を開発します。これにより、そもそもの移動の価値やそれに伴い、自動車の開発の方向性を定めたりとできます。MVPとはこの時のスケートボードや自転車を指します。これらよりソフトウェア開発、特にウェブアプリケーションの開発においても、必要最小限、動作する状態にすることが望ましいと考えられます。

そこで streamlitというライブラリが有用です。streamlitは ウェブアプリケーション、特にUIの開発を簡単に実現するライブラリです。特に機械学習を扱うような大規模で複雑な開発におけるMVPの開発に有用だと考えられます。

streamlit.io

PyPIで公開されており、このコマンドによりインストールが可能です。

pip install streamlit

ライセンスはApache License 2.0です。

github.com

デモンストレーションとして、streamlitで実現する予測をするウェブアプリケーションを作成したので紹介します。概要としては、学習済みの機械学習モデルによりアヤメの品種を予測するものです。。背景(シナリオ)は、もともとは分析・検証をNotebookで実施していたがそもそも価値があるのかを評価するためにMVPを開発するものとします。ウェブアプリケーションの仕様については、がく片の長さ、がく片の幅、花弁の長さ、花弁の幅を入力とし、最も可能性が高いアヤメの品種を予測し出力するものです。デモンストレーションのために用意したものはひとつのPythonファイルのみです。以下のコマンドにより起動します。

streamlit run app.py

このように一つのPythonファイルのみでもウェブアプリケーションの実現が可能です。以下、streamlitリストによって生成したアヤメ品種予測フォームです。

アヤメ品種予測フォーム(予測前)

フォームには4つの入力欄が表示されています。これらに数値を入力します。数値を入力する際、あらかじめ設定された上限下限の制約に従う必要があります。制約から外れた値の入力はできません。そして、全ての項目を入力後に「予測」と書かれたボタンを押すと、以下のように予測結果が表示されます。

アヤメ品種予測フォーム(予測後)

予測結果の上部には入力値を表示させています。そして、下部には出力として予測されるアヤメの品種を表示させています。また、再度フォームへ入力し、予測を実行することも可能です。

先ほど紹介したようなウェブアプリケーションの実装内容について紹介します。まずは streamlitとは直接関係はないですが、機械学習モデルを作成します。

import pandas as pd
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
import streamlit as st

iris = load_iris()
x = pd.DataFrame(iris.data, columns=iris.feature_names)
y = pd.DataFrame(iris.target, columns=["target"])

model = LogisticRegression(random_state=123)
model.fit(x, y)

参考までに、ロジスティック回帰という分類手法を使ってアヤメの種類の予測を実現する機械学習モデルを作成しました。続いて、streamlitの入力フォームとボタンの作成を行います

st.title("アヤメ品種予測フォーム")

with st.form("アヤメ品種予測フォーム"):
   st.write("アヤメの詳細を入力してください。")
   sepal_length = st.number_input(
       "sepal length (cm)", min_value=0.0, max_value=8.0, value=(0.0+8.0)/2, step=1.0)
   sepal_width = st.number_input(
       "sepal width (cm)", min_value=0.0, max_value=5.0, value=(0.0+5.0)/2, step=1.0)
   petal_length = st.number_input(
       "petal length (cm)", min_value=0.0, max_value=7.0, value=(0.0+7.0)/2, step=1.0)
   petal_width = st.number_input(
       "petal width (cm)", min_value=0.0, max_value=3.0, value=(0.0+3.0)/2, step=1.0)
   submitted = st.form_submit_button("予測")

まずはtitleというメソッドにより「アヤメ品種予測フォーム」というタイトルを生成します。そして、formというメソッドによりフォームを作成します。続いて、フォームの要素としてwriteというメソッドにより補足文を作成します。そして、number_inputというメソッドにより数値入力欄、form_submit_buttonというメソッドによりフォームのサブミットボタンを用意します。数値入力欄は4項目を用意し、それぞれにラベル、下限(min_value)・上限(max_value)、初期値(value)などを設定します。ここまでで、フォームを実現します。続いて、予測結果の取得部分の作成について紹介します。

if submitted:
   st.write("## 予測結果")
   st.write("### 入力")
   input_df = pd.DataFrame(
       {
           "sepal length (cm)": sepal_length,
           "sepal width (cm)": sepal_width,
           "petal length (cm)": petal_length,
           "petal width (cm)": petal_width
       },
       index=["入力値"])
    st.write(input_df)

まず、先ほどのサブミットボタンの返り値であるsubmittedという変数に基づく条件分岐により、サブミットボタンを押した後に表示させる内容を用意します。そこで、いくつかの文字列を表示させます。また、入力された内容を整理したデータフレームも表示させます。先ほどのコードにより、サブミット後の出力を実現します。続いて、予測結果の出力部分を作成します。

   st.write("### 出力")
   pred_df = pd.DataFrame({
       "target_name": iris.target_names.tolist(),
       "probability": model.predict_proba(input_df)[0].tolist(),
   })
   target_name = pred_df.sort_values(
       "probability", ascending=False)["target_name"].tolist()[0]
   st.write(target_name)

まずは機械学習モデルに与えるデータの整理をし、予測結果として、アヤメの種類の名称を変数(target_name)に格納します。そしてこのtarget_nameを表示させます。このコードによってこの予測結果の出力を表示させることができます。

このようにウェブアプリケーションおよびMVPを実現します。そのような便利なstreamlitでしたが、MVP開発を望む前に把握したい事を整理します。元のコードとstreamlitの導入によって実現したMVPが成り立ちます。この時に気になるのが「streamlitの導入コストはどれだけか?」ということです。ここでの導入コストは実装のための学習・保守性などについて、つまり、「簡単さ」と「コード量」です。以上より、streamlitの導入コストを確認するために、「簡単さ」と「コード量」と言う観点で、先ほどのコードを見直します。ウェブアプリケーションを実現するために使ったstreamlitのメソッドについて以下に示します。

メソッド 振る舞い 論理ステップ数
title タイトルの表示 1
form フォームの作成 1
number_input フォーム内の数値入力欄の作成 4
form_submit_button フォームのサブミットボタンの作成(返り値によりサブミット後の動作の指定が可能) 2
write 文字列やデータフレームの表示 6

これらより、先ほどのウェブアプリケーションを実現するために記述していたコードは、どれもメソッドに対する振る舞いがわかりやすく、簡単だと思われます。また、メソッドは5種、論理ステップ数は14件でした。つまり、分析・検証のために記述した元のコードに加えて、簡単かつ多くないコードによるMVPの実現が期待されます。

streamlitのまとめです。MVP について説明しました。これは、ユーザーに必要最小限の価値を提供できるプロダクトであり、提供しようとしている価値の評価に有用です。そのようなMVPの開発に有用となる必要最小限のウェブアプリケーションを簡単に実現するstreamlitを紹介しました。これは、ウェブアプリケーション(特にUI)の開発を簡単に実現するライブラリです。特に機械学習などを扱うような大規模で複雑な開発におけるMVPの開発に有用です。

本記事のまとめ

最後に本記事のまとめをもって締めます。まずPython による開発をアップデートするライブラリについてお伝えしました。背景として、開発における理想と現実のギャップの解消のために開発プロセスを改善すると、良いことをお伝えしました。そこで本記事では「開発プロセスを改善つまり、開発をアップデートする有用なライブラリを紹介する」と説明しました。大きく4つのライブラリを紹介しました。まずは、pydanticという「モデルの定義に基づくバリデーションを実現」するライブラリを紹介しました。次に、panderaという「スキーマの定義に基づくデータフレームのバリデーションを実現」するライブラリを紹介しました。また、hypothesisという「入出力に関するプロパティの定義に基づくProperty-based testingを実現」するライブラリを紹介しました。最後に、streamlitという「必要最小限のウェブアプリケーションを簡単に実現」するライブラリを紹介しました。是非これらライブラリを使ってPythonによる開発をアップデートしてください。