AdhocなPythonコードをProduction-readyにするために心掛けていること

こんにちは、Insight EdgeでData ScientistのKNです。Insight Edgeでは多種多様なDX案件を手掛けており、その多くでは機械学習や統計解析を用いた分析コードを作成することが求められます。 分析チームの開発言語は基本的にPythonが使われています。PoCの段階ではJupyter Notebookを用いてEDAや可視化、モデル構築がよく行われます。そして、PoCを終え期待した効果が確かめられた場合はPoCで作成したコードをそのまま採用せずに、長期運用を前提としたproduction-readyなコードに書き換える必要があります。 本記事では、主にデータ分析で使われるPythonコードを対象として、adhocな分析コードを実運用を見据えたコードに書き換える際役に立つと考えている取り組みについて紹介します。 また、本記事の内容はリファクタリングに関連はしていますが、設計方針やプログラミングスタイルについては取り上げません。それらは良いコードを書く上では重要ですが、すでに優れた書籍が数多く存在し、習得するのも時間がかかります。今回はPythonに特化して、即効性が高く、始めやすいtipsについての紹介になります。

目次

なぜ分析コードを修正する必要があるのか

結論から述べると、「コードの信頼性を向上させ、運用コストを下げるため」です。ここでの運用コストとは様々な意味合いが含まれます。 例えば、コードの可読性が低いと、コードの理解に時間がかかり、バグを誘発する原因にになります。また、ロジックの正しさの保証が不十分だと、変更の際に追加の確認作業が発生します。 さらに、データ分析では大量のデータ処理をする特徴があります。もし不正なデータが入力された場合、プログラムの挙動が問題になることがあります。実行過程でエラーが発生するならまだいいのですが、エラーが出ないまま間違った処理をしてしまい、分析の結論に影響を与えた結果、間違った意思決定を促す可能性があります。これらの問題点から、次の観点に沿ってコードを改善することが重要だと考えます。

図1:コードの改善軸と実現するための手段

「コードの可読性」、「データの保証」、「ロジックの保証」という3つの改善軸があり、それぞれに対して、「型情報の付与、フォーマッターとリンターの活用」、「バリデータの活用」、「テストの活用」で対応できると考えます。 もちろん、それぞれの関係は互いに独立ではありません。型情報を与え、型チェックを行うことでロジックやデータに対する保証は向上します。テスト前提のコードを書くことで、適度にコンポーネント化されたプログラムは可読性が高くなります。そういった相乗効果はありますが、寄与度の高さを踏まえると図のような関係になるかと思います。 以下、それぞれの項目について詳しく紹介します。

型情報の付与、フォーマッターとリンターの活用

型情報の付与

Pythonは型が無い動型言語です。それ故に、気軽に始められ、少ない記述量で動作可能なものを作成できます。データ分析現場では大変便利なツールとして、重宝されています。一方で、正しく動作可能かどうかは実際に動かして見るまで分からない面があるので、コードの可読性が低くなりがちです(ダックタイピングと呼ばれる所以です)。 Pythonは3.6以降、「型ヒント」と言って型情報を追記できる機能が追加されました。型ヒントを追加することで、コードの可読性を向上させ、コードの理解にかかる時間を減少させることができます。また、型ヒントを追加することで、IDEの補完機能を活用でき、開発効率も向上します。

# 型ヒント例

# 型ヒント追加前
def convert(texts):
    ...

# 型ヒント追加後
# 関数シグネチャの情報だけでコードの挙動が理解できるようになる。
def convert(texts: List[str]) -> Documents:
    ...

また、既存のコードに型情報を追加する際に、もし型情報を書きづらい箇所があった場合、そこにはリファクタリングする余地のある可能性が高いです。

# 型ヒントがうまく書けない例

data = {1: 'c', 'a': 2, '1': ['d', 'e']}

# Anyは任意の型を許す
# これは極端な例だが、辞書型でKeyやValueが異質な型の場合、可読性が著しく落ちる
def process(data: Dict[Any, Any]) -> Dict[Any, Any]:
    ...

process(data)

ただし、型ヒントは型情報を追加するだけで、実際には型チェックを行いません。型チェックを行うためには、mypyという解析ツール(リンター)を用いる必要があります。mypyは、型ヒントを追加したコードを解析し、型チェックを行うツールです。mypyを用いることで、型ヒントの追加漏れや、型ヒントと異なる型の値を代入している箇所を検出できます。 mypyは大変便利なツールですが、mypyを使うことで静的言語のような型保証が得られるかと言えば、それは疑わしいかと思います。mypyはあくまで型ヒントが互いに矛盾なく使われていることを保証するのみで、実行時に型ヒントとは異なる値が変数に代入されたとしても問題にはならないからです。 また、mypyを厳密に適用すると(オプションで --strict と設定)、かなり冗長に型情報を追加する必要があり、反対に可読性が低下する可能性もあります。シンプルに書きつつPythonを使用するメリットが減少してます。 さらに、自分のコードに厳密に型ヒントを強制したとしても、インポートしているサードバーティ製のライブラリに型ヒントが追加されていない場合がよくあります。代表的なライブラリでは、型情報ファイルをダウンロードできたり、型情報ファイルを自動生成できるものもありますが、手間の割に効果が限定的です。

どの程度の厳密性を適用するかは組織の方針やプロジェクトの性質から判断する必要があると感じます。また、既存のコードに対しいかにmypyを適用していくかは 本家のドキュメント に記述がありますので参考にしてみてください。

フォーマッターとリンターの活用

フォーマッターとは、コードスタイルを整えてくれるツールです。代表的なPythonのフォーマッターは、Blackというツールです。Blackは、PEP8に準拠したコードを自動生成してくれます。PEP8は、Pythonのコーディング規約です。Blackを使うことで、コードのスタイルを統一できます。それによって、コードの可読性を向上させ、コードのフォーマットでの意見の違い等余計なことに悩む必要も無くなります。また、isortと呼ばれるモジュールのインポート順をフォーマットしてくれるツールもあります。 リンターはソースコードを(実行前に)静的解析して、エラー等をチェックしてくれるツールです。先ほど紹介したmypyも型チェックのリンターでした。それ意外の代表的なリンターとしてFlake8があります。Flake8はコードのエラーチェックを行うpyflake、PEP8スタイルに準拠しているかチェックをするpycodestyle, コードの複雑度をチェックするmccabeをバンドルしたものになります。注意点としては、あくまでチェックツールなので自動的に修正してくれるわけではありません。そのため、コードスタイルの補正を先にBlackとisortで行ってから、Flake8でチェックするのが良いかと思います。

基本的にこれらのツールの適用に発生するコストは低いため、極力活用することが望ましいです。

バリデータの活用

PoCの作業中では、データにどのような値が含まれているか観察しながら分析をするので、データの正しさに対する注意、関心が怠りやすくなるかと思います。しかし、実際の本番導入以降でどのようなデータが投入されるかは未知数です。たとえ、データの内容について事前に確約していたとしても、必ずしも守られるとは限りません。

バリデータを活用することで、投入されるデータの正しさについて完全とはいかなくても、十分な保証を得ることができます。特に、実行過程のなるべく早い段階で適用することが望ましいです。早い段階でデータの不当性を見つけることで、副作用を伴う処理(DBへの書き込み等)を防ぐことができます。また、事前にデータの正当性を保証することにより、冗長なデータをチェックする処理を省き、ロジックコードは本質的な処理がメインになリます。その結果、可読性も向上し、データチェックのエラーハンドリングも減少します。

まず紹介するツールは、pydantic になります。pydanticは型情報やメタ情報を使って、実行時にデータのバリデーションを行ってくれるツールです。実行時のデータチェックを伴うデータクラスと見なすとイメージつきやすいかと思います。

# pydantic例
from pydantic import BaseModel, PositiveInt, ValidationError

class User(BaseModel):
    id: int
    name: str = 'John Doe'
    signup_ts: datetime | None
    tastes: dict[str, PositiveInt]

# この場合、idがintでない、signup_tsが無いという理由でエラーとなる
external_data = {'id': 'not an int', 'tastes': {}}  

try:
    User(**external_data)  
except ValidationError as e:
    ... 

次に、pandera の紹介になります。panderaは、pandasのデータフレームに対して、スキーマを定義してバリデーションを行うことができます。データ分析の際には、表形式のデータを扱うことが多いので便利です。

# pandera例
schema = DataFrameSchema(
    {
        "性別": Column(str, checks=Check(lambda s: s.isin(["男性", "女性"]))),
        "年齢": Column(float, nullable=True, checks=Check.gt(0)), 
        "身長": Column(float, nullable=True, checks=Check.gt(0)),
    }
)
df = base_list_schema.validate(df)

テストの活用

テストコードを書くことのメリットは色々なところで語り尽くされているので、異論はないかと思います。 ただし、通常のシステム開発と異なり、データ分析や機械学習領域では、テスト駆動開発や実装とテストをほぼ同時に進めるやり方は主流でないと(個人的には)感じています。 データ分析や機械学習のPoCの段階では、ほとんどの場合、より良いKPI(予測精度)の達成が目的となります。その実現手段は何でも良いため、様々な特徴量加工やモデル構築を試し、最も良いアプローチのみを採用し、それ以外のほとんどは無駄になることが多いです。試行錯誤中のコードに対してテストを書いて確認はできますが、結局は無駄になるコードへのテストは中々行われづらいの実情かと思います。

しかしながら、運用が決まった後は、テストコードを必ず書いたほうが良いです。なぜなら、データ加工やモデル構築の処理が確定したので、その処理の正しさを保証する必要があるからです。これは、特にリファクタリングや機能追加を行う際に重要です。本来の機能が壊れていないかことを容易に確認しながら、作業できるため信頼度が高くなります。また、他の人がコードを読む際にも、テストコードがあることでコードの意図を理解しやすくなります。これらの効果はコードの規模が大きくなるほど顕著になります。

一方で、テストはロジックの正しさをある程度保障するものですが、完全ではありません。あくまで限られたテストケースのみにおいて正しさを保障するに過ぎません。また、テストを書くこともコストかかりますし、量が増えてくるとコードの変更ごとのテスト実行の時間も無視できなくなります。さらに、プログラムの仕様を大幅に更する場合(データ分析ではよくありますが)、既存テストも書き直す必要が発生するので、変更への負荷が重くなります。

テストを全く書かないというのはありえないですが、テストをどの程度充実させるかどうかはPJの規模や性質によって変わってくると思います。私は後段の理由もあり、テストの充実度は通常のシステム開発よりも低くなっても良いと思います。データ分析、機械学習領域のテスト方針については私自身十分に結論が出ていないので、今後も検討していきたいと思います。

機械学習の場合の注意点

機械学習の場合の厄介な問題として、データ自体が流動的なため、たとえコードの処理に問題がなくても、精度悪化する場合があります。これは結果としてアプリケーションの本来の機能が達成できていないという点で問題です。 精度劣化の問題に対処するには、継続的にPJのKPIやモデルの精度を監視する体制が必要です。この作業をスポットで行う場合は手動でも問題ないかもしれませんが、日々の業務サイクルに組み込もうとすると、MLOpsの構築が必要なってきます。 テストコードでは精度の保証はできないので、ある程度のテストコードでロジックの正しさを保証しつつ、精度監視をを行うことで機械学習アプリケーションの品質を担保できます。

テストフレームワークについて

代表的なPythonのテストフレームワークは pytest です。標準ライブラリのunittestもありますが、それよりもシンプルで柔軟にテストコードを書くことができます。pytestの使い方については公式ドキュメントを参照してみてください。 最近では、ChatGPTに聞いてもそれなりの回答をしてくるので活用しない手はないです。ただし、業務コードで聞く場合は社内ポリシーに従ってください。

図2: 素数判定のテストコードをchatgptに聞いた結果

まとめ

データ分析、機械学習コードの品質を上げるために、「コードの可読性」、「データの保証」、「ロジックの保証」という3つの改善軸があることを紹介しました。

コードの可読性を容易に実現する手段として、「型情報の付与、フォーマッターとリンターの活用」を紹介しました。フォーマッターやリンターは手間が掛からず実行できるので積極的に使うべきです。mypyは厳密に適用すると大変で却って可読性が落ちるので、PJの規模や性質に応じて適用範囲を決めると良いです。

データの保証をするためのツールとして、pydanticとpanderaを紹介しました。両者ともより細かい制約を設定できます。外部データの読み込み時に適用することで、初期段階で不正を検知できますし、コードもよりシンプルになります。

プログラムのロジックを保証するために、テストを書くことが重要であることを述べました。テストを書くことで、早い段階でプログラムのエラーが検知でき、コードの変更に伴うリグレッションも防ぎ、コードの品質を担保することに繋がります。テストの充実度はPJの規模や性質に応じて決めるべきですが、テストだけではデータ分析や機械学習の品質の保証はできないので、そのためにはMLOps等のアプローチを検討することも必要です。

参考文献

ロバストPython ―クリーンで保守しやすいコードを書く