AREKORE

daikikatsuragawaのアレコレ

アウトプットはいいぞ2022

これは何?

2022年、id:daikikatsuragawaの個人的なアウトプットをまとめます。それぞれに対して「アウトプットはいいぞ」なポイントも記録します。

※以下のアドベントカレンダーに参加しています。

qiita.com

記事投稿

日頃の学びを抽象化し、コードスニペットとして整理し、記事として公開しました。

qiita.com

qiita.com

ここで感じた「アウトプットはいいぞ」なポイントは以下です。

  • 技術についての理解を深められた
  • 辞書的な扱いができる(便利)

記事投稿企画への参加+受賞

Qiitaの記事投稿企画へ参加しました。

qiita.com

そして、受賞しました🏆

zine.qiita.com

「pandera」×「外部データの読み込み」×「日本語の記事」という点で、新規性と有用性のある内容の記事を公開できたと思っています。正直、「いいね」の数は比較的少ないと感じていましたが、良い評価をしていただき、こんな高価なものまでいただきました。自分とってはトロフィーです。

jp.sharp

  • 技術についての理解を深められた
  • 自身の記事が価値のあるものだと認識できた(評価してもらえた)
  • 賞品(物理)をいただいた
  • 後述する「LTでの登壇」、「カンファレンスでの登壇」に繋げられた
    • 内容についてもだが、プロポーザルの採択につなげる根拠となった

LTでの登壇

地元で開催されている「飛騨高山Pythonの会」にて何度か発表させていただきました。

hida-python.connpass.com

資料は以下に残しています。

speakerdeck.com

ここで感じた「アウトプットはいいぞ」なポイントは以下です。

  • 技術についての理解を深められた
  • 資料という成果物が残る
  • 情報交換できるコミュニティに参加できた(関連して他コミュニティへの参加にも繋がった)
  • 後述する「カンファレンスでの登壇」に繋げられた
    • 内容についてもだが、プロポーザルの採択につなげる根拠となった

カンファレンスでの登壇

「Open Source Conference 2022 Online Kyoto」に参加し、登壇しました。関連した記事を以下に残しています。

daikikatsuragawa.hatenablog.com

「PyCon JP 2022」に参加し、登壇しました。関連した記事を以下に残しています。

daikikatsuragawa.hatenablog.com

特に、PyCon JP 2022では質疑応答が盛り上がりまして、逆に得られるものが多かったです。以下の記事も書けました。

daikikatsuragawa.hatenablog.com

ここで感じた「アウトプットはいいぞ」なポイントは以下です。

  • 技術についての理解を深められた
  • CfP(プロポーザル)で採択される経験が得られた
  • 資料という成果物が残る
  • 情報交換できるコミュニティに参加できた(関連して他コミュニティへの参加にも繋がった)
  • 業界への貢献となった(誰かの役に立った)
  • 発表と議論のサイクルにより理解が深まった

スプリントへの参加

PyCon JP 2022ではスプリントがあったのでリーダー(ただしチームは一人)として参加しました。関連した記事を以下に残しています(再掲)。

daikikatsuragawa.hatenablog.com

限られた時間で何かしらの成果を生み出す場というのがよかったです。限られた時間で何かを生み出し切るということは様々なリスクを負って進めたり、諦めたりという意思決定のサイクルを繰り返す必要があり難しいことも多いですが、面白いですね。「better than nothing」の精神で形にしきりました。

ここで感じた「アウトプットはいいぞ」なポイントは以下です。

  • 成果物を生み出せた(残る)

OSS開発

OSS開発@GitHubはしばしば実施していました。Pythonへの理解が深まったからか、自分から送るプルリクエストの内容も様々になってきました。 特に、研究系のライブラリへのコントリビュートができています。

github.com

ここで感じた「アウトプットはいいぞ」なポイントは以下です。

  • 改修にあたって関心のある技術の理解を深められた
  • 業界への貢献となった(誰かの役に立った)

特に研究系のライブラリはアイデアが素晴らしい分、その魅力を使用者にそのまま伝えるための貢献が重要だと考えています。

キュレーション

反実仮想説明法のキュレーションを始めています。

github.com

ここで感じた「アウトプットはいいぞ」なポイントは以下です。

  • 技術についての理解を深められた
  • 技術を集約できる場を生み出せた

アドベントカレンダーの作成

意外となかったことや、去年存在して欲しかったということもあり「機械学習」のアドベントカレンダーを作成しました。

qiita.com

正直、本記事公開時点では埋まっていないですが、Better than Nothingの精神です。こちらについての諸々は後日公開します。

ここで感じた「アウトプットはいいぞ」なポイントは以下です。

  • Better than Nothingの精神を磨くことができた
  • ないものを生み出せた
  • ハードルを乗り越える経験が得られた

その他

その他、アウトプットに関連して記録しておきたいことを残します。全体を通して、「新規性のあることをする」を意識して取り組めたと思っています。自信の学びを目的の一つとしてるものの、同じ、もしくは優良版の発信が世の中に存在していれば、業界としては意味のないものになります。その上で、少なからず業界への貢献もできたのかと思います。またそれを「出し惜しみせず発信と議論(FB)のサイクルを繰り返す」も意識して取り組みました。関心のある技術を知っていること自体をアイデンティティと捉えがちですが、基本出し惜しみせず発信しました。おそらく出し惜しみしていると、より技術力のある方が発信し、いつかは誰もが知る世界になってしまします。そのため、サッサと発信するようにしました。ただ、発信と議論によるサイクルにより、逆に理解も深まったり、視座が広がり、チャンスが得られたり、新たにアイデンティティの種も得られました。どんどん発信することとします。このように意味のある発信はできていることもあり、それぞれが関連付られる状態、もっというと実務にも生きる状態にできたことがよかったです。

「データに関する堅牢性と可読性を向上させるpydanticとpanderaの活用方法の提案」の質疑応答

先日、PyCon JP 2022にて「データに関する堅牢性と可読性を向上させるpydanticとpanderaの活用方法の提案」という題で発表をしました。

2022.pycon.jp

発表資料は以下です。

speakerdeck.com

とてもありがたいことに、想像以上に多くの方に発表を聞いていただくことができました。今回の発表について、確認しただけでも以下のような反響を得ることができました。ありがとうございます。

  • Twitterにおいて約50件のツイート
  • Slidoにおいて約20件の質問

ただし、質問については、発表後の時間が短かったこともあり、回答しきれないものも多く、整理した情報や意見を十分に伝えることができませんでした。そこで、本記事では、いただいた質問に基づき、質問を整理します。そして、それらの質問に対する回答という形式で、発表を補足していきます。※本記事は随時更新予定です。

質疑応答

ライブラリの仕様

Q:pydanticによってイミュータブルなクラスの定義は可能ですか?

pydanticによって、イミュータブル(つまり生成されたインスタンスの状態の変更が不可能)なクラスの定義は可能です。BaseModel(pydantic.BaseModel)内のConfigにより設定を変更することで実現します。以下2つパラメータによる設定例を紹介します。

  • allow_mutation
  • frozen

まずは、allow_mutationの設定をする場合です。allow_mutationについては以下のとおりです。

whether or not models are faux-immutable, i.e. whether setattr is allowed (default: True)

引用元:https://pydantic-docs.helpmanual.io/usage/model_config/

つまり、allow_mutation=Falseにするとイミュータブルなクラスが実現されます。以下のように設定が可能です。

from pydantic import BaseModel
 
class User(BaseModel):
    name: str
    age: int
  
    class Config:
        allow_mutation = False
 
user = User(name="パイソン 太郎", age=20)
user.name = "パイダンティック 次郎"

上記の実装は、イミュータブルなインスタンスに値の再代入を試みていますが、設定の意図通りにエラーが発生します。

(省略)

TypeError: "User" is immutable and does not support item assignment

続いてfrozenの設定をする場合です。frozenについては以下のとおりです。

setting frozen=True does everything that allow_mutation=False does, and also generates a hash() method for the model. This makes instances of the model potentially hashable if all the attributes are hashable. (default: False)

引用元:https://pydantic-docs.helpmanual.io/usage/model_config/

つまり、frozen=Trueにすると、allow_mutation=Falseになり、イミュータブルなクラスが実現されます。

from pydantic import BaseModel
 
class User(BaseModel):
    name: str
    age: int
  
    class Config:
        frozen = True
 
user = User(name="パイソン 太郎", age=20)
user.name = "パイダンティック 次郎"

上記の実装も、イミュータブルなインスタンスに値の再代入を試みていますが、設定の意図通りにエラーが発生します。

このように、pydanticで定義するクラスをイミュータブルにできます。

Q:pandasのデータフレームではstr型はobject型として扱われます。panderaでstr型のカラムの定義は可能ですか?

※当日、曖昧な回答をしてしまい申し訳ございません。以下が整理された回答です。

panderaでstr型のカラムの定義は可能です。str型を含むobject型は、str型として扱われます。それゆえ、panderaによる型のバリデーションによって弾かれることはありません。それに加え、Fieldクラスを組み合わせることで、文字列における「始まりの文字列」、「終わりの文字列」のバリデーションなども可能です。しかし、object型のうち本来str型ではないであろう要素の通過も許されてしまうという問題があります。つまり、他のint型、float型とは異なる挙動となっています。panderaのプロジェクトでは、本件に関連したIssueが公開されており議論がなされています。今後、str型に関するバリデーションについて対応されるかもしれません。

github.com

github.com

以下のような暫定対応により、他のint型、float型と同様の型のバリデーションの実現が可能です。

import pandera as pa
from pandera.typing import Series

class UserSchema(pa.SchemaModel):
    name: Series[str]
    age: Series[int]

    @pa.check("name")
    def check_value_is_string_type(cls, series: Series) -> Series:
        return series.map(lambda v: isinstance(v, str))

Q:pydanticによるバリデーション時のエラーのカスタマイズ(エラーメッセージの変更など)は可能ですか?

可能です。特にエラーメッセージについて説明します。設定方法は大きく2つあります。

まず、Configのerror_msg_templatesに設定を入れることで、エラーメッセージの上書きが可能です。

a dict used to override the default error message templates. Pass in a dictionary with keys matching the error messages you want to override (default: {})

引用元: https://pydantic-docs.helpmanual.io/usage/model_config/

例えば、整数型であるという条件を満たさない場合のエラー(type_error.integer)が発生したときのメッセージとして「整数型ではありません!」を設定しています。

from pydantic import BaseModel
 
class User(BaseModel):
    name: str
    age: int
  
    class Config:
        error_msg_templates = {
            "type_error.integer": "整数型ではありません!",
        }
 
user = User(name="パイソン 太郎", age="二十歳")

整数型ではない値を入れたため、エラーが発生されます。設定した「整数型ではありません!」というメッセージが出力されます。

(省略)

ValidationError: 1 validation error for User
age
  整数型ではありません! (type=type_error.integer)

error_msg_templatesに要素を追加することで、各種エラーにおけるメッセージの設定が可能です。 Fieldに設定した最小値の条件を満たさないエラー(value_error.number.not_ge)に対して「自然数ではありません!」というメッセージを設定します。

from pydantic import BaseModel, Field
 
class User(BaseModel):
    name: str
    age: int = Field(ge=0)
  
    class Config:
        error_msg_templates = {
            "type_error.integer": "整数型ではありません!",
            "value_error.number.not_ge": "自然数ではありません!",
        }
 
user = User(name="パイソン 太郎", age=-1)

自然数ではない値を入れたため、エラーが発生されます。設定した「自然数ではありません!」というメッセージが出力されます。

(省略)

ValidationError: 1 validation error for User
age
  自然数ではありません! (type=value_error.number.not_ge; limit_value=0)

続いて、@validatorpydantic.validator)というデコレーターを付与しているメソッドにおけるエラーに対してメッセージを設定することでもメッセージの指定が可能です。以下は、上記のコードに加え、文字列に空白を含むことを期待するモデルです。

from pydantic import BaseModel, Field, validator
 
class User(BaseModel):
    name: str
    age: int = Field(ge=0)
  
    class Config:
        error_msg_templates = {
            "type_error.integer": "整数型ではありません!",
            "value_error.number.not_ge": "自然数ではありません!",
        }
  
    @validator("name")
    def check_contain_space(cls, v):
        if " " not in v.strip(): # 前後の半角空白を無視
            raise ValueError("半角空白が含まれていません!")
        return v.strip() # 前後の半角空白を削除
 
user = User(name="パイソン太郎", age=20)

半角空白が含まれていない値を入れたため、エラーが発生されます。設定した「半角空白が含まれていません!」というメッセージが出力されます。

ValidationError: 1 validation error for User
name
  半角空白が含まれていません! (type=value_error)

以上の方法でpydanticに関するエラーメッセージの指定(上書き)が可能です。

類似するライブラリとの比較

Q:TypedDictとpydanticの違いは何ですか?

TypedDictとpydanticについて以下のように特徴を比較します。

比較軸 TypedDict pydantic
インストールの要否 標準ライブラリである。PyPIからのインストールは不要。 外部ライブラリである。PyPIからのインストールが必要。
型チェックの精度 構造の表現が可能なのでより詳細な辞書型の方チェックが可能になる。 構造の表現が可能なのでより詳細な辞書型の方チェックが可能になる。
バリデーションの実現方法 あくまで型なので、単体では特にバリデーションは実施されない。 クラスおよびフィールドの定義のみで型についてのバリデーションが実施される。Fieldモジュールにより簡単なバリデーションの実現が可能である。メソッドを定義することにより詳細なバリデーションの実現が可能である。
シリアライズ・デシリアライズの仕様(辞書型) あくまで型なので、特にシリアライズ・デシリアライズなどの振る舞いはない。 辞書型とのシリアライズ・デシリアライズが可能。辞書がネストされている場合も意図どおりインスタンスをの生成が可能である。
シリアライズ・デシリアライズの仕様 (JSON あくまで型なので、特にシリアライズ・デシリアライズなどの振る舞いはない。 JSONとのシリアライズ・デシリアライズが可能。JSONがネストされている場合も意図どおりインスタンスをの生成が可能である。

上記のように、それぞれに異なるメリット・デメリットが存在します。使用状況によって導入を検討してください。

Q:dataclassとpydanticの違いは何ですか?

dataclassとpydanticについて以下のように特徴を比較します。

比較軸 dataclass pydantic
インストールの要否 標準ライブラリである。PyPIからのインストールは不要。 外部ライブラリである。PyPIからのインストールが必要。
型チェックの精度 構造の表現が可能なのでより詳細な型チェックが可能になる。 構造の表現が可能なのでより詳細な型チェックが可能になる。
バリデーションの実現方法 クラスおよびフィールドの定義のみではバリデーションが実施されない。メソッドを定義することにより詳細なバリデーションの実現が可能である。 クラスおよびフィールドの定義のみで型についてのバリデーションが実施される。Fieldモジュールにより簡単なバリデーションの実現が可能である。メソッドを定義することにより詳細なバリデーションの実現が可能である。
シリアライズ・デシリアライズの仕様(辞書型) 辞書型へのシリアライズ・デシリアライズが可能。ただし、ネストされている要素に関してはインスタンスには変換されず、辞書型として格納される。 辞書型とのシリアライズ・デシリアライズが可能。辞書がネストされている場合も意図どおりインスタンスをの生成が可能である。
シリアライズ・デシリアライズの仕様 (JSON dataclasses-jsonという外部ライブラリを使用することでJSONとのシリアライズ・デシリアライズが可能。ネストされている場合も意図どおりインスタンスをの生成が可能である。 JSONとのシリアライズ・デシリアライズが可能。JSONがネストされている場合も意図どおりインスタンスをの生成が可能である。

上記のように、それぞれに異なるメリット・デメリットが存在します。使用状況によって導入を検討してください。

Q:dataclassからpydanticへの移行について有用な情報はありますか?

既存のdataclass(dataclasses.dataclass)を使用したコードに対して、比較的少ない変更でpydanticへ移行する時に有用なモジュールとしてdataclass(pydantic.dataclasses.dataclass)があります。

Dataclasses

If you don't want to use pydantic's BaseModel you can instead get the same data validation on standard dataclasses (introduced in Python 3.7).

引用元:https://pydantic-docs.helpmanual.io/usage/dataclasses/

@dataclassというデコレーターを付与することでバリデーションをしてくれるクラスにしてくれます。つまり、使用している@dataclassdataclasses.dataclass)というデコレーターを付与しているクラスの内容を変更する必要がなく、インポートをするモジュールの差し替えのみでpydanticの使用が可能です。

例えば、従来のdataclass(dataclasses.dataclass)を使用した以下のコードがあったとします。これは期待しない値の格納を許してしまいます。

from dataclasses import dataclass

@dataclass
class User:
     name: str
     age: int

User(name="パイソン 太郎", age="二十歳")

dataclassモジュールをdataclasses.dataclassからpydantic.dataclasses.dataclassに差し替えます。差分は以下です。

- from dataclasses import dataclass
+ from pydantic.dataclasses import dataclass

モジュールを差し替えるのみで、バリデーションが実現され、期待しない値の格納を防いでいます。

from pydantic.dataclasses import dataclass

@dataclass
class User:
     name: str
     age: int

User(name="パイソン 太郎", age="二十歳")
(省略)

ValidationError: 1 validation error for User
age
  value is not a valid integer (type=type_error.integer)

以上より、既存のdataclass(dataclasses.dataclass)を使用しているコードにおいて、モジュールをdataclass(pydantic.dataclasses.dataclass)に差し替えることで、バリデーションを実施してくれるクラスに変えることができます。

ただし、dataclass(pydantic.dataclasses.dataclass)とBaseModel(pydantic.BaseModel)の挙動が異なるようなので注意してください。

Note

Keep in mind that pydantic.dataclasses.dataclass is a drop-in replacement for dataclasses.dataclass with validation, not a replacement for pydantic.BaseModel (with a small difference in how initialization hooks work). There are cases where subclassing pydantic.BaseModel is the better choice.

For more information and discussion see pydantic/pydantic#710.

引用元:https://pydantic-docs.helpmanual.io/usage/dataclasses/

使用場面についての見解

特定の場面に対して「導入すべきか?」という質問が多く挙げられました。断定できないことが恐縮ですが、導入するか否かの意思決定については、各々個人やプロジェクトのさまざまな事情なども組み合わせて検討することをお勧めします。私自身も限られた経験の中で、導入する(したい)場面や、導入しない(したくない、できない)場面が、条件付きで導入できる場面があり、ケースバイケースだと思っています。それゆえ、以降の内容を意思決定の材料とすることをお勧めします。

Q:pydanticとpanderaによるデータのバリデーションにかかる時間を見過ごせない状況では、ウェブアプリケーション全体ではなく、バッチ処理のみに導入するという選択肢はどうですか?

おっしゃるとおり、良い選択肢だと思います。定刻に起動するバッチ処理は比較的、処理時間を気にしなくても良いと思われるので、処理時間に関する懸念は解消されやすいと思われます。場合によっては、期待しないデータを一時的に管理してしまう可能性もありますが、少なくともバッチ処理の頻度では解消できるかと思われます。理想は常にバリデーションしているべきだが、このように、例えば入り口と出口のみだったり、バッチ処理のみなど、部分的にバリデーション を用意するという工夫が考えられます。

Q:機械学習や分析など、ラビットプロトタイピングが有用な場面があります。このような場合にライブラリ(pydanticやpandera)を使用する際、慣れていない場合は開発速度が遅くなります。何か対策はありますか?

私の一意見を回答とさせていただきます。対策としては、状況によって使用するか否かの判断をしています。例に挙げていただいた機械学習や分析、特にGoogle ColaboratoryやJupyter Notebookなど、インタラクティブに実装するものに関して、その判断について考えます。まず判断材料となる要素として「pydanticとpanderaがもたらす」という価値と「慣れていない場合は開発スピードが遅くなる」というリスクを挙げます。この比重は状況によって変わってきます。まず、書き捨てのコードの場合、「使用しない」に比重を置きます。なぜなら、後から再実行しない、つまり堅牢性が重要ではないためです。それに加え、後から見返す必要がない、つまり可読性が重要ではないためです。逆に後から見返し、管理したい、再実行することを考えると、長い目では「使用する」に比重をおいた方が良いと思います。そのとき、ある程度のめどが立つまでは使用はせず、リファクタリングの段階で使用するのが良いのではないかと考えています。また、計算ミスなどが深刻な影響を与える場合、「pydanticとpanderaがもたらす価値」が上がる可能性もあるかもしれません。

Q:定義しているスキーマの対象のAPIのレスポンスや元のデータにおける仕様に変更があった場合、どのような対応をすると良いですか?後方互換性を持たせるなどですか?

通常の開発における修正と同様に、スキーマを定義し直します。ただし、データのスキーマが変化する過渡期については注意が必要です。リリースにおける切り替えの工夫だったり、おっしゃるとおり後方互換性を持たせるなどの対応が必要かと思われます。試したことはないが、Union型やOption型、可能であれば使用を避けたいが、Any型、スキーマ自体の設定の変更などにより許容するデータを調整できるため、過渡期に用意しておく後方互換性に有用な要素になるかとも思われます。

Q:現実のデータセットは必ずしも整理されているとは限らないということは承知していますが、そのような場合でもスキーマを定義、バリデーションを実施することが望ましいですか?

おっしゃるとおり、元のデータセットは必ずしも整理されているとは限りません。そこで取りうる選択肢もいくつか存在すると思われます。例えば、そのようなデータセットを整形などすると思われますが、まず一次受けのタイミングではそのまま受け取り、その後、整形に伴ってpanderaによるスキーマ定義およびバリデーションを実現するという選択肢も考えられます。もしくは、一次受けのタイミングでわかっている範囲での必要最小限のバリデーションのみ実装するという方法も考えられます。これにより、少なくともどのようなデータを扱っているのかを他者が理解することができると思われます。

Q:pydanticを使用せず、panderaのみの使用は可能ですか?

発表では類似の目的と類似の記述方法を持つ2つのライブラリとして併用することを勧めたが、それぞれ独立したライブラリなので問題ない。ただし、panderaの依存関係にpydanticが含まれているため、インストールは必要となる。どうせインストールされるのであれば、併用してみてはどうかというのが私の意見です。

Q:(本発表において)pydanticとpanderaを併用する意図はどのようなものですか?一方のみの使用でも良いのでは?

前提として、状況に応じて、一方のみの使用でも良いと思います。例えば、データフレームを扱わないのにpanderaを使用する必要はありません。逆にデータフレームを扱うような処理においては併用が有効になる可能性があります。改めて、併用を提案したい理由を列挙します。

  • スキーマ定義およびバリデーションが可能な型の範囲が増えること
  • panderaのSchemaModelを使用する場合に併用時の学習コストが少ないこと
  • 依存関係もあり他方を使用している場合リソースに関する懸念が少ないこと(使用しない理由がないこと)

経験に基づく見解

※今回は個人として発表しました。それゆえ、以降は個人の活動や経験に基づき回答します。

Q:発表内容を実際の業務や研究に活用したことがありますか?

私が個人としてPythonによるコーディングを実施する場面は以下2つです。

  • Google Colaboratoryでの分析
  • OSS(主にライブラリ)開発

Google Colaboratoryでの分析では「何度か実行する」かつ「見返す」ようなコードではがあります。そのようなコードの場合、pydanticやpanderaを使用しています。ある意味ドキュメントの代わりもかねて使用しています。逆に、書き捨てのコードでは恩恵が少ないので使用しません。

OSS開発については使用しません。依存関係を増やしてしまうことが一番の理由です。対象のOSSを使用しているプロジェクトの都合なども考えると、依存関係を増やすのはハードルが高いです。ただし、堅牢性、可読性に関する課題感を抱くことはしばしばあり、開発、テスト時にできることとして、型ヒントおよびmypyの導入をしていたりはします。

Q:処理速度が遅くて困った経験はありますか?

自分には「処理速度が遅くて困った経験」がありませんでした。理由は以下で困りようもないのだと考えられます。

  • リアルタイム性が重要な実装ではない(計算時間を待てる)
  • そもそも扱うデータが大規模ではない(かもしれない)

逆にリアルタイム性が重要であったり、大規模なデータを扱う場合は、処理速度が遅く困ることになるかもしれません。

本記事のまとめ

今回の発表および皆様からのコメントを踏まえ、意思決定の事例やユースケースを、導入したか否かにかかわらず知りたいと思いました。可能であれば、類型化できるのではないかと考えています。引き続き、情報収集に努めます。情報提供や質問などあれば、以下までご連絡をください。よろしくお願いいたします。

forms.gle

PyCon JP 2022の振り返り

先日、PyCon JP 2022にスピーカーおよびスプリントリーダーとして参加してきました。

2022.pycon.jp

本記事はその記録です。

カンファレンス

1日目は不参加でした。ただし、Twitterや発表のアーカイブは閲覧し、雰囲気を感じていました。オンラインの恩恵も受けることができました。

2日目から参加しました。オフラインでの技術系のカンファレンスへの参加は初めてで、新鮮でした。企業ブースもたくさん回りました。

カンファレンス感

そして、「データに関する堅牢性と可読性を向上させるpydanticとpanderaの活用方法の提案」という内容で発表しました。

発表直前(サブのモニターがあって発表しやすい)

資料は以下に公開しています。

speakerdeck.com

発表の目的は以下でした。

  • 自分の知見を新たなコミュニティへ伝える(+エコシステムの活性化)
  • 新たなコミュニティへ伝える事により新たなフィードバックを得る

それゆえ、様々な感想や質問をいただいたことは、とてもありがたかったです。こちらについては追々まとめようと思います。

自分の発表以外に、他の方々の発表も聞いていまして、内容から発表時の伝え方や立ち振る舞いまで、様々な観点で学びが多かったです。特に、特定のプロジェクトを軸に話を展開していく方々の話は興味深く、もし次回発表するのであれば、参考にしたいと思いました。

スプリント

3日目は、スプリントに参加しました。遠方からだったこともあり、せっかく参加するのであればと思い、テーマを掲げ、リーダーとして申し込みました。自問自答の末に当初の予定から磨きがかかり、「反実仮想説明法を活用したウェブアプリケーションのMVP開発」というテーマで開発をしました。

これまで執筆した以下の記事に関連したものです。

daikikatsuragawa.hatenablog.com

daikikatsuragawa.hatenablog.com

反実仮想説明法の提供する価値が素晴らしいと思いつつも「具体的にどのように伝えるのが良いか(UI)」という観点で考えたい、可能であれば議論したいと感じていました。そこで、議論の叩きにするためのベースラインとして、まずはウェブアプリケーションとしてカタチにしきろうと思い、MVP開発という体で開発を始めました。

テーマを提案するリーダーでしたが、チームは自分一人でした。そのため、もくもくと作業をしました。「これがもくもく会か…!」とピンときてたりもしました。紆余曲折ありました。本題とは別のところであるライブラリの不具合らしき挙動も発見し、報告したり、個人的に挙動の理解を深めたりできました。ギリギリまでバタバタしましたが、なんとか見せられる状態にしました。当日の成果発表はタイトルだけ書いたスライドを用意し、基本はウェブアプリケーションを実際に動かしながらデモンストレーションをしました。以下はその時の成果発表を後日にまとめたものです。

speakerdeck.com

時間制限があると、これからの時間の取捨選択を強いられ、時間内でのベストを生み出せるのでいいですね。

また、数人の方とお話しさせていただき、それぞれの普段の抱いている課題について共有しました。これがなかなか刺激的で、熱く語り合ってしまいました。

まとめ

そんな様子でPyCon JP 2022に参加してきました。運営の方々、本当にありがとうございました。来年以降も参加したいと思い、すでにネタの思案をしています。様々な観点で、とても学びの多い機会になりました🙋

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による開発をアップデートしてください。

機械学習における反実仮想説明(Counterfactual Explanations)を生成するライブラリ“DiCE”を活用したサービス開発についての考察

個人的に、機械学習における反実仮想説明(Counterfactual Explanations)*1を生成するPythonのライブラリ“DiCE”に興味を持ちました。そして、DiCEを活用したサービス開発について考察をしました。本記事はその考察についてのメモです。

機械学習における意思決定を促進する説明

機械学習を活用した予測において予測対象者の行動を促す、つまり「意思決定の促進」を目的とする場合があります。このとき、意思決定者(予測対象者)に予測の根拠についての説明を提供することが重要です。そのゆえ、機械学習の文脈で説明についての研究、議論が進められています。

例えば、機械学習モデルの出力の解釈性を扱った手法の例としてSHAP(SHapley Additive exPlanations)というものがあります。

proceedings.neurips.cc

これは各予測の根拠として、各特徴量の貢献度(寄与度)を算出します。これを提供することで、予測に対して納得したり妥当だと感じることにつながります。このとき、その予測への信頼が高まると考えられるため「意思決定の促進」が期待されます。

反実仮想説明を生成するDiCE

DiCEとは?

上記で紹介した手法では、予測を実施したタイミングにおける、機械学習モデルの出力の解釈性として、各特徴量の貢献度などの出力が可能です。ただし、それらはあくまで事実における結果自体の根拠の説明です。もし、予測を覆したいシチュエーションだった場合、意思決定者は具体的にどの特徴量をどれだけ変動させたら良いのかが明らかではありません。意思決定者へ行動を促すためには、どれだけでもわかりやすく説明することが望ましいです。

それに対して「未来の状態を提示する」というアプローチで説明を提供するDiCEを紹介します。DiCEとは、目的とする出力を得るための反実仮想サンプルを生成するアルゴリズムおよびそれが実装されているライブラリです。DiCEはMicrosoft Researchにより開発されています。

www.microsoft.com

反実仮想、つまり「事実と反対のことを想定すること」という概念に基づき、予測結果が覆る(反対になる)特徴量の変動例を提示する説明を反事実的説明(Counterfactual Explanations)と呼びます。DiCEは反事実的説明を生成します。具体的には、DiCEは予測結果に対して、異なる結果が得られたであろう「入力の特徴量を変動させたサンプル」を生成することにより、説明を提供します。この説明を受け取った人は「どの特徴量をどれだけ変動させたら良いのか」といった指針を得ます。これにより「何にどれだけ取り組めば良いのか」といった具体的な行動を促すことが期待されます。「サンプルを生成することによる直接的な材料提供」という点において、他の類似するアルゴリズムとは異なります。

具体的なDiCEの活用シーンについて、README.rstなどでも紹介されている「金融会社におけるローン審査モデル」にDiCEを適用する例を説明します。

github.com

前提として、ある金融会社では、機械学習モデルの分類アルゴリズムを活用したローンの審査をサービスとして提供しているとします。これにDiCEを組み合わせてみると、DiCEは「ローンを申請したが拒否された人」に対して、特徴量を変動させてローンの申請が承認されるサンプルを提示します。例えばサービスは「もしあなたの収入が今より10,000ドル高かった場合、ローンを認めます。」と説明します。この説明を受けた人は、 「どの特徴量をどれだけ変動させたら良いのか」といった指針を得ることが可能です。指針を得ることにより「具体的に何をしたら良いのか」を想像できます。このように、DiCEは 予測に貢献した重要な特徴だけを提供するのではなく、反実仮想を考慮し、意思決定の対象者が望ましい結果を得るために次に何をすべきかを決定するのに役立ちます 。

また、DiCEは実現可能性やドメインの制約を考慮できます。事実サンプルと反実仮想サンプルのベクトルとしての距離を考慮し、実現可能性が高い反実仮想サンプルを提供します。そして、ドメインの制約を考慮して、特定の特徴量を変動の対象外にしたり、特徴量の値の上限と下限を設定することなども可能です。

DiCEの使用方法と動作の紹介

プログラムよってDiCEの使用方法と動作を紹介します。DiCEはPythonで実装されており、OSSオープンソースソフトウェア)としてGitHubで公開されています。

github.com

そして、PythonサードパーティーソフトウェアリポジトリであるPyPIに登録されています。PyPIにおけるDiCEの識別子はdice_mlです。

pypi.org

題材は「タイタニック号の生存者予測」とします。これは以下の点でDiCEの例を紹介するために適していると考えられます。

  • 生存か否かという2クラス分類であること
  • 予測対象者が一方のクラス(生存)に予測されることを望むであろうこと
  • 機械学習チュートリアルとして一般的に知られている例であること

シチュエーションとしては、「生存か否かを判別を事前に判別するサービス」があったとしてその開発を考えます。このシチュエーションは多少無理がある気もしますが、ローンなど良さそうなサンプルデータを見つけられなかったこともあり、あくまでDiCEの例を紹介するためなので流してもらえると幸いです。

実行環境はGoogle Colaboratoryとします。まずはDiCEをインストールします。

!pip install dice_ml

DiCEで反実仮想説明を生成するために、以下2点が必要です。

  • 学習データ/テストデータ
  • 学習済みモデル

はじめにこれらを準備します。「タイタニック号の生存者予測」のデータはseabornから取得します。最低限の前処理を実施します。ホールドアウト検証として、学習データとテストデータに分割します。モデルはロジスティック回帰を選定しました。scikit-learnで実装されているものを利用します。余談ですが、個人的にロジスティック回帰のような予測確率を算出できる手法はDiCEとの相性が良いと考えています。理由は組み合わせることによって説明に有用な情報を増すことができる点です。

import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
import seaborn as sns

titanic_df = sns.load_dataset("titanic")

titanic_df = titanic_df.drop(columns="alive")
titanic_df["age"] = titanic_df["age"].fillna(titanic_df["age"].median())
titanic_df["sex"], _ = pd.factorize(titanic_df["sex"], sort=True)
titanic_df["embarked"], _ = pd.factorize(titanic_df["embarked"], sort=True)
titanic_df["class"], _ = pd.factorize(titanic_df["class"], sort=True)
titanic_df["who"], _ = pd.factorize(titanic_df["who"], sort=True)
titanic_df["adult_male"], _ = pd.factorize(titanic_df["adult_male"], sort=True)
titanic_df["deck"], _ = pd.factorize(titanic_df["deck"], sort=True)
titanic_df["embark_town"], _ = pd.factorize(titanic_df["embark_town"], sort=True)
titanic_df["alone"], _ = pd.factorize(titanic_df["alone"], sort=True)

X = titanic_df.drop(columns="survived")
y = titanic_df["survived"]

train_x, test_x, train_y, test_y = train_test_split(X, y, test_size=0.2, random_state=123)

model = LogisticRegression()
model.fit(train_x, train_y_bin)

ここまでで以下の用意が完了しました。

  • 学習データ(train_xtrain_y)/テストデータ(test_xtest_y
  • 学習済みモデル(model

ここからはDiCEによる準備です。dice_ml.Dataでテストデータについての設定をし、dice_ml.Modelで学習済みモデルについての設定をします。

import dice_ml

d = dice_ml.Data(dataframe = pd.concat([test_x, test_y], axis=1),
                 continuous_features=['age', 'fare'], 
                 outcome_name = "survived")

m = dice_ml.Model(model=model, 
                  backend="sklearn")

exp = dice_ml.Dice(d, m)

それでは、試しにテストデータ1件(index == 524)の反実仮想説明を生成します。パラメータについては生成する反実仮想の数をtotal_CFsに、変動対象の特徴をfeatures_to_varyに、変数のとりうる値の上限と下限をpermitted_rangeに設定します。そして、望むクラス(0)をdesired_classに設定します。

pre_counter = test_x.query('index == 524')

dice_exp = exp.generate_counterfactuals(
    pre_counter,
    total_CFs=5,
    features_to_vary=["pclass", "sibsp", "parch", "fare", "embarked", "class", "deck"],
    permitted_range = {"fare":[0, 300]},
    desired_class = 1, 
)

dice_exp.visualize_as_dataframe(show_only_changes=True)

上記のような出力がなされます。もともと、生存予測の結果は否(0)でしたが、例えば、sibspを2、fareを約8.03、classを0に変動させると生存予測の結果が変わるというサンプルが生成されたということが示されています。また、このようなサンプルがtotal_CFsに基づき、5件生成されています。またこれらは、features_to_varypermitted_rangeで設定した制約を満たすものであり、より実現性の高いものが提示されています。

本記事では概要の理解までを目的としているためここまでとします。詳しくはドキュメントをご覧ください。

interpret.ml

以上より、DiCEを活用して反実仮想説明を生成することが可能です。

DiCEを活用したサービスの例

DiCEを活用したサービスの例について考察します。ユーザの意思決定の促進を目的としたサービスにおいてDiCEは有用であると考えられます。以下の例が考えられます。

  • 金融機関における機械学習に基づくローンの審査
  • 医療における機械学習に基づく病気の判定
  • 模擬試験の結果の通知とアドバイス
  • マーケティングにおけるファンクラブ加入者の分析と増加のための施策選定

“DiCEとは?”でも説明しましたが、ローンの審査結果として融資の可否を提示するとき、DiCEは有用です。ローンの審査結果として、拒否となってしまった人がいるとします。その人はローンを受けたいため、現状の課題を解決して再び挑もうとするはずです。そのとき、何をどれだけ改善すれば良いのかという情報が得られたら、それを参考に現状の課題の改善に取り組むことができます。少し先の未来で、その方がローンの審査を通過した場合、サービス提供元も利益が得られます。このように単にユーザのためだけでなく、サービス提供元も利益が得られるといった点で、投資する価値のあるサービスであると考えられます。このようにサービス提供元、提供先のともどもに利益が考えられます。

他にも医療において、機械学習を活用して病気の判定を実施するとき、DiCEは有用です。例えば、特定の病気である、もしくはその可能性が高いと判断された人がいるとします。その人はその病気に打ち勝つために行動を起こすはずです。また、その判定が医療の現場で行われる場合であっても、医師がその病気を治療するはずです。そのとき、何をどれだけ改善すれば良いのかという情報が得られたら、それを参考に現状の課題の改善に取り組むことができます。

予備校や通信教育など学習を提供する企業による模擬試験の結果の通知とアドバイスにもDiCEが有用であると考えられます。例えば大学入試、単純な点数の和ではなく、場合によっては必要十分条件などもあるかと思われます。それらも加味した上で、ある教科の点数をこれだけ上げて、その他の教科の点数をこれだけ上げると合格可能性が高いクラスに変動するということを説明できます。今後の学びの指針を明らかにすることで、現状の課題の改善に取り組むことができます。

上記3点は主にユーザによる、ユーザのための反実仮想説明の提示、つまり主な目的をUX(ユーザーエクスペリエンス)向上として掲げている場合です。サービス提供元も利益が得られる場合もありますが、ユーザが主なトリガーです。。それに対して、サービス提供者がユーザを動かすための施策を設計する参考として反実仮想説明を扱う場合もあり得ます。例えば、ファンクラブ加入者の分析と増加のための施策の設計はそれに当たります。企業はファンクラブ加入者を増やしたいと考えているとします。そのために、ファンクラブ加入者とそうでない人の特徴を分析し、比較してどうしたら、そうでない人をファンクラブ加入者にできるかということを考えます。DiCEを活用すると、各ユーザの何をどれだけ変動させることができたらファンクラブ加入者になる、もしくはファンクラブ加入者に近づけることができるということを理解できます。これは施策の設計におけるターゲットの選定、施策内容の決定、具体的な数値目標の設定の参考として活用が可能であることが考えられます。

上記のようにさまざまなサービスにおいてDiCEは有用です。特に、ユーザの現状に基づいた反実仮想サンプルを生成するため、行動を促すハードルは低くなるはずです。また、時間や費用と相談して、最適な意思決定につなげることはできるはずです。

DiCEを活用したサービス開発における課題と解決策

DiCEを活用したサービス開発について考えたとき、いくつかの課題が考えられました。その解決策として以下の点が考えられました。

  • 設計の工夫による現実に起こりうる反実仮想説明の生成
  • 複数の反実仮想説明に基づく施策の設計

これらについて説明、考察します。

設計の工夫による現実に起こりうる反実仮想説明の生成

DiCEは、各説明変数に対して「変動対象か否か」や「変動量の幅」の設定が可能です。これをサンプルのフィルタリングに用いることで、ドメインを考慮した実現可能性の高く、存在しうるサンプルを提供します。しかし、この時、以下が課題として挙げられます。

  • モデルは特徴に潜在的に存在するルールの解釈が難しいという点
  • モデルは特徴量間の関係を解釈が難しいという点
  • フィルタリングを厳しくした場合、モデルが解釈できない珍しく重要なサンプルを見逃してしまう可能性もあるという点

このような機械に解釈させるのが難しいドメインの制約については、DiCEに限らず機械学習を活用したサービス開発全体に発生しうる課題です。それゆえ、機械学習を活用したサービス開発において、機械学習ドメインとの付き合い方がとても重要であると考えられます。また、それは機械学習やDiCEの進展に任せ切ってしまう話ではなく、サービス全体で対応すべき課題であると考えています。DiCEを活用するサービス開発において、そもそも扱っているデータが何かの誤りで存在しないものだったり、DiCEでも設定しきれないドメインにおける制約を見逃してしまい、存在しないサンプルを提示しまうことも考えられます。以下ではそんなDiCEを活用したサービス開発について記述します。特に、ドメインを考慮した反実仮想クラスの設計による対応について紹介します。DiCEを活用したサービス開発において、ドメインを考慮した反実仮想サンプルの生成の方法を提案します。主な要素は以下の2つです。

  • 特徴間の関係を加味したクラスの設計
  • 特徴の扱い方としての値オブジェクトの採用

特徴間の関係を加味したクラスの設計

ある特徴量の値が、他の特徴量の値に影響を及ぼす場合があります。例えば、人によって、値の上限と下限が違う場合です。その場合、存在し得ない状態を提示してしまう可能性があります。そんな存在し得ない状態はドメインを考慮したクラスの設計により検知できます。

具体的なプログラムの例を紹介します。先に紹介した「タイタニック号の生存者予測」の例より、費用(fare)、年齢(age)のみを抜粋します。

例えば、以下のようなクラス(Sample)を用意しておきます。これは費用と年齢に制約を与えているため、生成されたインスタンスドメインにおける制約を満たすことになります。

import dataclasses

@dataclasses.dataclass(frozen=True)
class Sample:
    fare: float
    age: int

    def __post_init__(self):
      if self.fare <= 0 or 300 <= self.fare:
        raise Exception()
      if self.age <= 0 or 100 <= self.age:
        raise Exception()
      if self.age < 20 and 150 <= self.fare:
        raise Exception()

例えば、DiCEが以下のようなドメインにおける制約を満たすサンプルを生成したとし、これを表現するインスタンスを生成してみます。

sample = Sample(150, 20)
sample 

すると問題なく生成できます。これは反実仮想説明として提示しても良いでしょう。

Sample(fare=150, age=20)

逆に、DiCEが以下のようなドメインにおける制約を満たさないサンプルを生成したとし、これを表現するインスタンスを生成してみます。

sample = Sample(150, 19)

するとこのインスタンスの生成は失敗します。これによりサービスはあり得ないサンプルを説明として提示することを回避します。

---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
<ipython-input-20-81b073a3d0d2> in <module>()
----> 1 sample = Sample(150, 19)

<string> in __init__(self, fare, age)

<ipython-input-17-cb29eedcdebf> in __post_init__(self)
     12         raise Exception()
     13       if self.age < 20 and 150 <= self.fare:
---> 14         raise Exception()

Exception: 

このように、特徴間の関係を加味したクラスの設計により、DiCEが生成した反実仮想説明に対して、現実に存在しうる反実仮想説明のみを抽出が可能であると考えられます。

特徴の扱い方としての値オブジェクトの採用

DiCEでは特徴に対して、変動の有無や変動の幅を設定できます。ただし、その設定もドメインに忠実に適応できない場合があると考えられます。それゆえ、設計でカバーすることも望ましいと考えられます。DiCEが生成した反実仮想サンプルに対して、現実に存在しうる反実仮想サンプルのみを抽出する方法として、値オブジェクトが考えられます。値オブジェクトとは、ドメイン駆動設計の要素のひとつです。簡単に説明すると、プログラムの中で、その値の振る舞いをドメインに基づくのもに制限するという設計です。上述したクラスの設計の例と同様に、これによりよりドメインに忠実に、異常な状態を検知できます。

具体的なプログラムの例を紹介します。

import dataclasses

@dataclasses.dataclass(frozen=True)
class fare:
    value: float
    def __post_init__(self):
      if self.value <= 0 or 300 <= self.value:
        raise Exception()

@dataclasses.dataclass(frozen=True)
class age:
    value: int
    def __post_init__(self):
      if self.value <= 0 or 100 <= self.value:
        raise Exception()

例えば、DiCEが以下のような全ての特徴量がドメインにおける制約を満たす値であるサンプルを生成したとし、それぞれを値オブジェクトとしてインスタンスを生成してみます。

fare = Fare(150)
fare

age = Age(20)
age

すると問題なく生成できます。これは実現可能性の高い説明として提示しても良いでしょう。

Fare(value=150)
Age(value=20)

逆に、DiCEが以下のような特徴量にドメインにおける制約を満たさない値を持つサンプルを生成したとし、それぞれを値オブジェクトとしてインスタンスを生成してみます。

fare = Fare(1000)
fare

するとこのインスタンスの生成は失敗します。これによりサービスはあり得ないサンプルを説明として提示することを回避します。

---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
<ipython-input-33-4dbe6b6b540a> in <module>()
----> 1 fare = Fare(1000)
      2 fare

<string> in __init__(self, value)

<ipython-input-27-2319445039c4> in __post_init__(self)
      6     def __post_init__(self):
      7       if self.value <= 0 or 300 <= self.value:
----> 8         raise Exception()
      9 
     10 @dataclasses.dataclass(frozen=True)

Exception: 

このように、値オブジェクトを使うことで、DiCEが生成したサンプルに対して、現実に存在しうるサンプルのみをサービスとして扱うことが可能であると考えられます。

また、値オブジェクトは上述したクラスの設計と組み合わせて、クラスが持つプロパティ全てを値オブジェクトにすると効果的であると考えられます。例えば以下のような記述が考えられます。

@dataclasses.dataclass(frozen=True)
class Sample:
  fare: Fare = fare
  age: Age = age
  
  def __post_init__(self):
      if self.age.value < 20 and 150 <= self.fare.value:
        raise Exception()
fare = Fare(150)
age = Age(20)
sample = Sample(fare, age)
sample
Sample(fare=Fare(value=150), age=Age(value=20))

これらを実施することで、機械学習を活用したサービス開発における安全性・信頼性といった品質保証の担保にも貢献が可能です。

複数の反実仮想説明に基づく施策の設計

マーケティングにおけるファンクラブ加入者の分析と増加のための施策選定」のように多くの人を動かすこと、つまり複数の意思決定の促進を目的とする場合があります。前提として、人を動かすために、施策を設計します。施策の設計のために、反実仮想説明が有用であると考えられます。この時、DiCEが生成する反実仮想説明は一人を対象とした説明です。もちろん一人の反実仮想説明を確認して、特定の説明変数の値を変化させるためにどうすれば良いのかを考えることができると思います。しかし、複数人を動かそうとした場合は反実仮想説明を生成した後、施策を設計するまでにハードルがあることが考えられます。

まずは、各反実仮想説明を理解、解釈し、要約したうえで、施策を設計する必要があります。これを実現する手法(処理)が必要です。その理由はマーケティングにおいて、複数の意思決定を促進させるための各反実仮想説明への対応が現実的ではないことです。

上述した手法が確立されたとして、次は「その施策によって意思決定を促す人の数を最大化させる要望」も生まれます。例えば、ファンクラブ加入者をX人にすることをKGI(経営目標達成指標)とした時、それに向けて設定する施策が中間数値指標としてのKPI(重要業績評価指標)となります。このKPIを設定する際、最も効果がある施策は何なのかを設定することが難しいと考えられます。

それゆえ、「複数の反実仮想説明に基づく施策の設計」に関する議論が重要になってくると考えています。以下、「複数の反実仮想説明に基づく施策の設計」に関して私が考えていることを紹介します。まずは概念図を以下に示します。

複数の反実仮想説明に基づく施策の設計の概念図

予測対象1件に対して、m件の反実仮想説明を生成し、その合計をn件とします。これは施策の設計の参考になる情報であり、これらを包括して理解、解釈することで複数の反実仮想の実現につなげる施策を設計する際のヒントとなります。反実仮想×nの生成はDiCEにより実現が可能です。議論すべきは「反実仮想×nから施策を設計する処理」です。まずは反実仮想×nを要約する必要があります。

Minimum Viable Productに基づき実用最小限の手法を考えました。処理の流れを以下に示します。

  1. 反実仮想をクラスタリング(例:k平均法)
  2. クラスタをサマライズ(例:中央値)
  3. クラスタごとにサマライズされた情報を参考にしてマーケティング担当者が施策を設計

人が介入する必要はありますが、これにより「反実仮想×nから施策を設計する処理」がなんとか実現できるのではと考えています。これをベースラインにして、これらの方法については思案・調査中です。もし有識者の方から意見をいただけるととてもありがたいです。

反実仮想説明の関連研究

事実から反実仮想まで変動させるための状態遷移の考慮

事実から反実仮想を目指す場合、そのベクトルが直線でない場合も考えられます。具体的には「暗黙的に存在している状態」を経由する必要がある場合です。これを正しく理解しないと、事実を反実仮想まで変動させるのに想定以上の苦労がかかったり、そもそも不可能だったりする可能性が考えられます。それゆえ、事実から反実仮想まで変動させるための状態遷移を考慮した手法やサービスの設計が必要になります。このような背景から、事実から反実仮想まで変動させるための状態遷移を考慮した手法を提案する研究もなされています。

富士通研究所北海道大学は、実現の可能性と順序を考慮した適切な変化方法を見いだすことが課題であると捉え、反実仮想説明の考え方に基づき、事実を反実仮想に変動させる手順として、行動自体とその実現の順序を提示する方法を提案した。

pr.fujitsu.com

今回、両者による共同研究において開発した新技術は、反実仮想説明(注5)という考えに基づき、属性変更におけるアクションとその実施順序を手順として提示します。過去の事例の分析を通して非現実的な変更を避けつつ、属性値の変更がほかの属性値に与える因果関係などの影響をAIが推定し、それに基づいて実際に利用者が変更しなければならない量を計算することで、適切な順序、かつ一番少ない労力で最適な結果が得られるアクションの提示を可能としました。

上記の研究では「順序」を考慮しているという点により、実現可能性が高い反実仮想説明の生成されます。これにより、意思決定の促進が期待できます。

本記事のまとめ

本記事では、機械学習における反実仮想説明を生成するライブラリ“DiCE”を紹介しました。また、DiCEのサービスへの活用例を紹介しました。そして、DiCEを活用したサービス開発における課題と解決策について、以下2点を紹介しました。

  • 設計の工夫による現実に起こりうる反実仮想説明の生成
  • 複数の反実仮想説明に基づく施策の設計

関連研究でも紹介しましたが、ドメインへの対応が重要だと考えられます。設計で対応できることもあるので、ぜひ検討をしてください。そして、特定のシチュエーションにおいて施策の設計が必要とされると考えられます。今後、考えていこうと思います。

本記事は追記、修正する可能性があります。ご了承ください。もし意見、質問、指摘などがあれば、以下に記載されている連絡先に連絡をいただけるととても嬉しいです。

https://daikikatsuragawa.github.io/

*1:本記事ではCounterfactual Explanationsを反実仮想説明と和訳します。他にも反事実的説明、反実仮想的説明、反実仮想的な説明として和訳されている場合があります。

アソシエーションルールマイニングに基づく推薦を民主化するライブラリ「AutoARM」をリリースするまでの記録

はじめに

先日、アソシエーションルールマイニングに基づく推薦を民主化するライブラリ「AutoARM」をリリースしました。AutoARMはPythonで実装されており、PyPIで公開されています。

pypi.org

またオープンソースソフトウェア(OSS)としてGitHubで公開されています。

github.com

ゼロからアイデアを生み出したことからリリースするところまででさまざまな経験をしました。そこで、さまざまな学びがありました。本記事はAutoARMをリリースするまでの記録です。

開発に至る動機

AutoARMの開発に至る動機は3つあります。

“アソシエーションルールマイニングに基づく推薦”の提案

開発に着手する直前、「今こそ“アソシエーションルールマイニングに基づく推薦”が活きる時代なんじゃないか?」と思っていたことです。詳細については以下のブログを読んでください。

daikikatsuragawa.hatenablog.com

簡単にまとめると以下の点で改めて有用な手法だと思っています。

  • 推薦の根拠を説明可能
  • 推薦を実施したい相手に紐づく情報がなくても推薦可能

そして、上記2点は、データ提供・活用が実施されていないサービスに対して有効だと考えられます。そして、日本におけるデータ提供・活用をブレイクスルーするひとつのキッカケになるのではないかと期待しています。

また、ライブラリになっていた場合、プライベートにおける取り組みではあるものの、現在所属している組織でも簡単に再利用が可能になります。それゆえ、現在所属している組織における新規サービス・機能の提案の火種になることも期待していました。

過去の自分(=未来の誰か)の支援

学生時代、利用状況、推薦に関する研究をしていました。そこでアソシエーションルールマイニングに基づく推薦を実装していました。

https://ieeexplore.ieee.org/document/8445832ieeexplore.ieee.org

実は当時、実装がとても苦手で、比較的多くの時間をかけて保守の手間を無視する危なっかしいスクリプトを書いていました。というのも、アソシエーションルールマイニングに基づく推薦ってライブラリとして実装されていなかった(もしくは見つからなかった)のです。

もっと前にも似たようなことがありました。テーブルデータを用意した状態で、「こんな手法で予測したい」という方針は決まっているものの、スクリプトを書けないが故に手が止まってしまうということです。この日は状況共有の機会の前日だったこともあり、すぐにでも実現したいと思い、詳しい同期にスクリプトを書いてもらいました。すると一瞬でやりたいことが実現できました。この出来事をきっかけに学術研究、特に機械学習やデータ分析を必要とするものにおいて、技術がなく実現できない、提案できないアイデアがあるんじゃないかと思っていました。ただ近年、AutoMLが提案されており、機械学習という技術は民主化されています。これにより、上述した過去の自分が助かります。過去の自分の悩みは、未来の自分や誰かの悩みです。AutoMLにより未来の誰かの問題の解決に進んでくのではないかと思っています。

AutoML開発の動機についても技術にしてもとても共感していました。そんな中、“アソシエーションルールマイニングに基づく推薦”って実装されていない、つまり民主化されていないと思いました。個人的に有用な手法だと思っているため、過去の自分(=未来の誰か)のためにも実装し、誰もが利用できる状態にすることが望ましい状態と思いました。

(オーナーとして)OSSの開発

ソフトウェアエンジニアとして、意味のあるOSSを、自分がオーナーとして開発したいと思っていました。下記より既にOSSはリリースしたことがあるのですが、やはり、自分が貢献したいと思っているデータ分析、機械学習に関して、取り組みたいと思いました。

daikikatsuragawa.hatenablog.com

とにかく、ひとつを作りきることによって得られる経験値の大きさを期待していたため、単純にやってみたかったということがあります。OSSのスタンスについてもとても共感しています。完璧なものを開発しきるというよりは、「やりたいことの提示」を一番の目的としたいと思いました。自分よりも素晴らしいソフトウェアエンジニアが世界中にいることもあり、自分の思いに共感してくれた人が何かを直してくれたら、それがOSSのあるべき姿なんじゃないかと思ったためです。そのため、Minimum Viable Product(MVP)の考えに基づき、必要最小限の実装で、体験・価値を実現したいと思いました。このような思いを持ってOSS、特にPythonのライブラリを開発したいと思いました。

ライブラリの内容の設定

上述した動機に基づき、「アソシエーションルールマイニングに基づく推薦を民主化する」といったテーマでライブラリを開発しようと考えました。そこで、既存ライブラリを調査し、以下のような内容を定めました。

  • アソシエーションルールマイニングに基づく推薦に基づく過程を実装
    • テーブルデータの入力
    • 頻出パターンマイニング
    • アソシエーションルールマイニング
    • ルールに基づく推薦
  • 過程が首尾一貫しており誰でも簡単に利用可能
    • 参考とするインタフェースはAutoGluon*1

既存ライブラリに対する新規性は以下2点です。

  • アソシエーションルールマイニングに基づく推薦を対象
  • (アソシエーションルールのライブラリに対して)推薦までを実装

以上より、開発に着手しました。

推薦の流れ

新規性としてもアピールしていた「推薦」の流れを紹介します。MVPに基づき、最もシンプルな方法はこれだなと思う方法を実装しました。インプットは推薦対象の現状のアイテムリストと、事前に出力したルール群、推薦の基準とするメトリック(信頼度またはリフト)です。アウトプットは推薦の参考になるルールN件です。流れを以下に示します。

  • ルール群の結論部を分割(多:1のルールに)
  • 入力したアイテムリストと一致する条件部を持つルールを特定
  • 入力したアイテムリストと結論部が一致するルールを除外
    • この手順はスキップも可能
  • メトリックとして指定された信頼度またはリフトでルールを並び替え
  • 上位のルールに対して結論部が重複するルールを除外
  • 指定されたサイズ(N件)のルールを出力

詳細や実装についてはREADMEやソースコードを見ていただけると幸いです。

これにより、AutoARMは推薦の参考となるルールを出力します。ミソはアイテムではなく、ルール自体を出力することです。つまり、そのアイテムを推薦する根拠を残しています。これにより、根拠を説明しつつアイテムを推薦するため、相手の行動を促す推薦サービスが実現されます。

AutoARMの紹介

上記のような過程を経て開発を進めてAutoARMが完成しました。ざっくりと紹介します。

まず始めにデータを用意しておきます。サンプルとして適当なものを生成しています。

import pandas as pd

sample_dataset = {
    'transaction_id':
    [1, 1, 1, 2, 2, 3, 3, 3, 4, 4, 5, 5, 5, 6, 6, 6, 6, 7, 7],
    'item_id': [
        "X", "Y", "Z", "X", "B", "Y", "A", "C", "A", "C", "X", "Y", "Z",
        "X", "Y", "B", "A", "X", "B"
    ],
}
df = pd.DataFrame.from_dict(sample_dataset)

つまりこのようなテーブルデータが必要です。transaction_id列、item_id列`にあたる列さえあれば他に何かあっても問題はありません。列名は後でしているため、異なっていても問題はありません。

transaction_id item_id
1 X
1 Y
1 Z
2 X
2 B
... ...
7 X
7 B

ここからAutoARMについてです。まずはインストールが必要です。Pypiで公開されているため、以下でインストールが可能です。

pip install autoarm

以下により、アソシエーションルールマイニングに基づく推薦を実現するオブジェクトを生成可能です。import文を除くと、4行です。

from autoarm import AssociationRules, Dataset, FrequentItemsets, Recommender

dataset = Dataset(df, "transaction_id", "item_id")
frequent_itemsets = FrequentItemsets(dataset, min_support=0.01)
association_rules = AssociationRules(frequent_itemsets,
                                     metric="confidence",
                                     min_threshold=0.1)
recommender = Recommender(association_rules)

以下により、推薦が可能です。

items = ["X", "Y"]
recommend_rules = recommender.recommend(items, n=3, metric="confidence")
recommend_rules

以下の表が出力されます。

rank antecedents consequents support confidence lift
1 (X, Y) (Z) 0.285714 0.666667 2.333333
2 (X) (B) 0.428571 0.600000 1.400000
3 (Y) (A) 0.285714 0.500000 1.166667

例えば、この出力に基づいて「既にあなたが購入を検討しているXとYを購入した人の66%がZも購入しています。Zを購入してみてはどうでしょうか?」といった説明を加えた推薦が実現されます。また、XとYの購入を検討している推薦を実施したい相手に紐づく情報がなくてもこのような推薦が実現されます。良いですね。

以上より、簡単にアソシエーションルールマイニングに基づく推薦が実現されます。

今後の展望

正直なことを言うと、“アソシエーションルールマイニングに基づく推薦を民主化”するためにまだまだ追加することが望ましい機能があると思っています。例えば以下です。

  • さまざまな形式の入力データの対応
  • パラメータの自動調整
  • アソシエーションルールマイニングに基づく別の推薦手法の実装

そして、OSSとしてもっとこんな状態にしたかったと思っていることがあります。例えば以下です。

  • Poetryによるプロジェクト管理
  • GitHub Actionsの有効活用
  • 値オブジェクト*2の実装

自分でガンガン開発してしまうのもいいのですが、せっかくGitHubで公開して、指針もあるため、プルリクエストを期待してひとまず手を動かすのは止めようかと思っています。特に、自分にはレビューしてくれる人がいないため、自らバグを混入してしまう可能性もあるためです。また、もっとやりたいこともあるため、世界のソフトウェアエンジニアに任せてみたいと言うこともあります。

そのため、ひとまず開発自体は小休止して、どのようにサービスに組み込むのかなどについて注力して考えようと思います。

最近のお寿司屋さん、タッチパネルでの注文が浸透していますよね。こことかで活躍しそうだなとか妄想しています。

おわりに

アソシエーションルールマイニングに基づく推薦を民主化するライブラリ「AutoARM」をリリースするまでの記録を紹介しました。是非とも使ってください!フィードバックください!コントリビュートしてください!スターください!

おまけ

本文に書くほどではないが、遺しておくべきだと感じた内容について、サクサクと記録します。

  • テストはとても大切(ユニットテスト〜運用テスト)
    • ユニットテストに何度も救われた
    • 運用テストに何度も救われた
    • しかしテスト設計はまだまだ未熟
  • AIソフトウェアのテストは非常に難しい
    • パフォーマンスの担保
    • 「AIソフトウェアのテスト」が参考になる?(めどがついたため未読)

  • 現実的な利用状況に適した速度およびアルゴリズム
    • プロトタイプ作成時はすごく遅かった(レコメンドに数分…)
    • 問題を解決するために別のライブラリ「df4loop」も誕生した

pypi.org

  • 初めてのリリース(Pypiへの公開)はすごく緊張する
    • 10分ほど精神を統一したのちヤケになって「ポチッ!」
    • 多分経験値は大きい
  • 社内LT会で少しだけ紹介した
    • 有識者・初学者それぞれから良いフィードバックが得られました
    • GUIで見せたかった…
    • サービスのイメージまで伝えたかった…
  • “サクっと”API作れるようになりたい
    • 一回テンプレートみたいなものを作るか…
    • 欲を言うとDocker、Kubernetes
  • “サクっと”ウェブアプリケーションを作れるようになりたい
    • Django
    • Flask?
    • 一回テンプレートみたいなものを作るか…(再)
    • 欲を言うとDocker、Kubernetes…(再)
  • 開発環境はほとんどGitpod+Colab!

*1:Amazon Web Services - LabsのAutoMLライブラリgithub.com

*2:ドメイン駆動設計を担う要素のひとつ

アソシエーションルールマイニングに基づく推薦のすゝめ2021

はじめに

2021年某日…「今こそ“アソシエーションルールマイニングに基づく推薦”の時代なんじゃないか!?」と感じました。その思いを綴ります。

アソシエーションルールマイニングに基づく推薦

まずはアソシエーションルールマイニングに基づく推薦についてサラッと説明します。

アソシエーションルールとはアイテム間の関連性の規則を指します。以下のように表現されます。

 \displaystyle
A \rightarrow B

これは「事象Aが起こると事象Bが起こる」という意味です。Aは条件部、Bは結論部と呼ばれます。例えば、「パンとバターを購入した人はミルクを購入する」という事象は“パンとバターを購入”→“ミルク購入”というルールとして表現されます。

任意のアソシエーションルールが有用か否かを判断する指標として、様々な評価値があります。どれだけ一般的なルールかを計る評価値として支持度(Support)があります。

 \displaystyle

支持度(Support)=
\frac{条件部(A)と結論部(B)を含むデータ数}{全データ数}

どれだけ関係の強いルールかを計る評価値として信頼度(Confidence)があります。

 \displaystyle

信頼度(Confidence)=
\frac{条件部(A)と結論部(B)を含むデータ数}{条件部(A)を含むデータ数}

信頼度に対して結論部の発生する頻度を考慮した評価値としてリフト値(Lift)があります。

 \displaystyle
リフト値(Lift)=
\frac{\frac{条件部(A)と結論部(B)を含むデータ数}{条件部(A)を含むデータ数}}{\frac{結論部(B)を含むデータ数}{全データ数}}

上記の評価値に基づいて、推薦に有用なルールを特定することが可能です。

膨大なデータからルールと評価値を出力する手法としてアソシエーションルールマイニングがあります。主に利用されているアルゴリズムであるaprioriにより、一定の支持度を超える有用なルールに絞ることも可能です。

アソシエーションルールマイニングによって得られたアソシエーションルールに基づいて以下のような順序で推薦ができると思われます。

  1. ルール群の条件部を推薦対象の現状でフィルタリング
  2. 信頼度(もしくはリフト値)に基づき結論部をランク付け
  3. 状況に合わせて上位N件を推薦

例えば、ECサイトにおいて「Aの他に何か買おうか」と思案している顧客に「Bがオススメ。(信頼度に基づき)Aの購入者の90%が同時に購入しています。」と伝えることができます。

これが本記事で紹介したい「アソシエーションルールマイニングに基づく推薦」です。

その理由(わけ)とは…

アソシエーションルールマイニング自体は素晴らしい手法です。ただし、最近発表されたものではなく、近年では様々な手法が提案されており、推薦の改善、進化が進んでいます。そんな中、2021年某日…「今こそ“アソシエーションルールマイニングに基づく推薦”の時代なんじゃないか!?」と思った理由を綴ります。その主な理由は以下2点です。

推薦の根拠を説明可能

アソシエーションルールマイニングはルールおよび評価値がシンプルで、どんな人でも理解しやすいため、推薦の根拠を説明できると思われます。Bを推薦するときに、その根拠として、「A→B」というルールに基づき、あなたがAだからという旨を伝えることができます。そして、信頼度に基づき、信頼度がX%である場合、「AのX%がBでもある」という旨も伝えることができます。「説明可能であること」は相手に行動を促すのために良い要素であると考えられます。「説明可能であること」までもサービスとして提供したい場合、有用な選択になるんじゃないかと考えています。

推薦を実施したい相手に紐づく情報がなくても推薦可能

アソシエーションルールマイニングに基づく推薦の場合、推薦を実施したい相手に紐づく情報が必要ありません。例えば、他の推薦手法では、相手の情報(生年月日、性別など)を説明変数として利用する場合があります。それに対して基本的なアソシエーションルールマイニングに基づく推薦では状態(買い物カゴの中身、閲覧履歴など)と過去のルールを組み合わせることで推薦が実現されます。つまり、匿名性が必要な場面や新規顧客にも対応可能です。

日本におけるデータ提供・活用文化の醸成に貢献?

近年モバイル決済が浸透しています。これにより消費者の特徴や行動に基づき様々な恩恵を受けることができる未来*1が想像できます。しかし、データ提供におけるプライバシーなどに関するリスクは存在するため、それを考慮した上で恩恵がない、想像できていない方々がいると言う現状*2もあるかと思います。特に上記2点は、そのような思いを持っている方々にもアプローチが可能です。それゆえ、現状を打破し、今後のデータ活用が浸透するキッカケになるのではないかと考えています。特に、データ提供・活用が実施されていないサービスに対して有効だと考えられます。そして、特に日本におけるデータ提供・活用をブレイクスルーするひとつのキッカケになるのではないかと期待しています。

おわりに

「今こそ“アソシエーションルールマイニングに基づく推薦”の時代なんじゃないか!?」と思ったその思いを綴りました。「推薦の根拠を説明可能」、「推薦を実施したい相手に紐づく情報がなくても推薦可能」という理由より、シチュエーションによってはニーズに応えられる手法なんじゃないでしょうか?今こそ、検討をしてみてはどうでしようか?