👨‍💻

オブジェクト指向に学ぶデータサイエンスのコーディング術

2024/07/22に公開

はじめに

こんにちは。ZENKIGENデータサイエンスチーム所属のredteaです。原籍はオムロンソーシアルソリューションズ株式会社 技術創造センタですが、社外出向でZENKIGENに所属しており、数理最適化や機械学習を用いたデータの分析業務、それらの結果に基づいた顧客への提案をしております[1]

現在の所属チームでXアカウントを作りました。AIに関する情報を発信していきます。

本記事でお伝えしたいこと

多くのDS(データサイエンス)職が書くソースコードは、そのままプロダクトに載せることが難しいものが多いです[2]。この理由は、DS職に求められる成果物や職掌がプロダクト開発チームなどとは異なるからであり、ある程度は仕方がないと認識しています。しかしながら、開発したアルゴリズムや機械学習モデルをプロダクトに載せたり、分析コードを他人[3]に引き継いだりする際には、「良いコード」で実装されているに越したことはありません。DS職の人が「良いコード」を書くためにはどうすれば良いかを考え続けた結果、オブジェクト指向のアイデアがヒントになると思い至り、この記事を執筆しました。

DS職特有のコーディング事情

DS職の職務は以下に挙げるように[4]多岐にわたります。

  • 顧客データの分析検証、および報告(アナリスト)
  • ニューラルネットワークなどの機械学習モデル開発(機械学習エンジニア)
  • その他データの収集、基盤モデルの構築など(データエンジニア)

例えばアナリストの最終成果物はmatplotlibを駆使した図を綺麗にまとめたスライドであったり、仰々しい100ページ超え[5]のPDF報告書であったりします。また、機械学習エンジニアの最終成果物はPyTorchで作られた優れた予測モデルと、f1-scoreが0.8を超えることを再現するためのデータやソースコードであったりします。

いずれの業務を行う場合であっても、DS職では試行錯誤を伴うことが多く、スピード重視で数多く実験することが優先され、ソースコードの保守に時間を割きにくい傾向にあります。その際、モジュール分割せずに1つのNotebookに処理を直書きし、ある実験の後、メンテナンスしないまま実験コードを放置して次の実験へ移ったりして、後々必要になったときには解読が難しくなる場合があります。

また、DS職はPoC[6]をたくさん積み上げます。PoCは素早く(コストを抑えて)検証することが求められる(さらに悪いことにPoC止まりで製品化されないことが多い!)性質上、良いコードを書くという意識が薄れてしまいます。このような意識でPoCばかりを担当しているデータサイエンティストが、いざデータ分析結果をプロダクトに組み込むとなった際には、大きな苦労を経験すると考えています。

目指す姿(良いコードとは)

DS職の最終成果物がたとえ報告書であったりモデルの作成であったりしたとしても、読みやすく利用しやすいコードを書ければ市場価値の高い技術者になれると思っています。プロダクションコードレベルとまではいかなくとも、以下を目指します。

  • 他人に引き継ぐときや1年後の自分が見た時、どこに何が書いてあるかが明確で、処理を追いかけやすくコードを理解しやすい(可読性)
  • ソースコードがそのまま使えるクラスなっており、プロダクト実装時の移植や類似案件の分析がスムーズ(再利用性)
  • 仕様変更・機能追加・精度改善時に、コードの変更箇所が最小限になっており、 様々な要望に素早く対応できる(保守性)
  • 処理の正しさを確認でき、後任者が正しい使い方を理解できて、 バグが入りにくい(信頼性)

これらの観点は最終的に、変更に強いコードを目指すためのものです。良いコードとは、変更しやすいコードである、と言い切ってしまいます[7]

可読性

goal: どこに何が書いてあるかが明確で、処理を追いかけやすい

追加検証のために過去書いたコードや他人から引き継いだコードを読む機会は多いですよね。そして、コードを読みにくいと感じたことがある方も多いのではないでしょうか[8]。コードが読みにくくなる要因に、あらゆる.pyファイルから呼び出された関数が並んでいたり、1000行以上のモジュールから該当処理を探したりしなければならないことを挙げます。本記事では、オブジェクト指向の考え方に従って、どこに何を書くかを重点的に紹介します。

他にも、コメントの書き方、変数名の決め方、ネストを減らすなど取り上げたい[9]話題はありますが、今回はオブジェクト指向の考えとは関連が低い具体的なコーディングの話は扱いません。

再利用性

goal: ソースコードがそのまま使えるクラスになっている

DS職が書いたコードがクラスとして完成していれば、移植コストは小さく済み、スムーズなサービスインが期待できます。また、類似の分析案件に対してもクラスを流用できれば、僅かな変更で済み大幅な工数削減が見込めます。

データ分析のロジックが使いやすい独立したクラスとして実装されている状態を目指します。

保守性

goal: 仕様変更・機能追加・精度改善時に、コードの変更箇所が最小限になっている

ソフトウェアサービスは常に変更されるものです。データベースなどの利用技術が変わったり、精度改善、機能追加など、理由は様々です。DS職でよくあることの1つに、データの変更があります。単に仕様が変わるだけではなく、より高度な分析のためにデータを追加することもあります。サービスの価値を高水準で提供し続けるためには、様々な変更に柔軟に対応できることがとても重要です。

もし、ソースコードに重複があれば、変更時には重複箇所全てを漏れなく修正する必要があり、漏れればバグが発生します。一方、ロジックの重複が起きないように共通化し過ぎることにも弊害が伴います。例えば処理A, B, Cを共通化たした後、Aのみ仕様を変えないといけなくなった際に、共通化した処理を安易に修正してしまうとB, Cがバグってしまう可能性があります(密結合)。こういった事態をできるだけ起きにくくするように、分析コードが実装されている状態を目指します。

信頼性

goal: 処理の正しさを確認でき、後任者が正しい使い方を理解できる

これは書くまでもないかもしれませんが、データ分析結果が間違えていたり、想定した理論通りに計算が行われていなければ大問題です。誤った計算結果を元に導き出された意思決定ほど恐ろしいものはありません。正しい(=DS職の想定通りの)結果が得られるソフトウェアになっていることを確認するプロセスは避けて通れません。また、いくら正しく動くプログラムであっても、正しく使われなければ意図した動作が期待できません。どう使うことが想定されたプログラムなのかを誤解なく伝えることも大切です。

Key Idea

これら4つの目指す姿(可読性、保守性、再利用性、信頼性)を達成するためには、守るべき原則や考え方がたくさんありますが、本記事ではグッと絞って3つだけ守ることを提案します。その3つは以下の通りです。

この3つだけです。次章から、それぞれについて1章ずつ割いて説明していきます。ポイントが3つだけなら実践しやすいはずなので、是非試してみてください。この3つのポイントは、データ受領後の初期分析(データの集計や可視化など)の段階から導入しても良いですし、Notebook等でデータをある程度把握した後、書き換えながら実践しても良いと思っています。ご自身のやりやすいスタイルに一部でも取り込んでいただけると幸いです。

なお、多くのオブジェクト指向の説明ではJavaやC#が用いられますが、DS職を想定しているのでプログラミング言語はPythonを用います。随所にサンプルコードを抜粋していますが、全て以下のGitHubで公開しています。サンプルでは一部、kaggleの練習用でも有名なタイタニックデータを利用しています[10]

https://github.com/atsushi-green/ds-oop

https://www.kaggle.com/c/titanic

データと処理を同じところに書く(クラスにまとめる)

あるクラスに、データと処理を書くことを「同じところに書く」と表現しています。クラスにまとめて書けばオブジェクト指向だというのは乱暴な表現ですが[11]、オブジェクト指向のエッセンスを理解した上で分析対象データと、それに対する処理を「同じところに書けば」、可読性、再利用性、保守性を向上させることができます。

オブジェクト指向とは

wikipediaには以下のように書かれています。

データとプロセスを個別に扱わずに、双方を一体化したオブジェクトを基礎要素にし、メッセージと形容されるオブジェクト間の相互作用を重視して、ソフトウェア全体を構築しようとする考え方がオブジェクト指向である。

ここで書かれている「データ」とは、DS職が扱う巨大なテーブルデータや、音声や画像などのデータだけを指すのではなく、プログラミング上で変数として持つものほぼ全てを指します。

例えば、int型のpriceという価格を持つ変数と、税込価格を計算する処理があったとします。これを手続き型(オブジェクト指向ではない方法)で書くと以下のようになります[12]。この書き方だと、データ(変数)と処理(関数)がそれぞれ別々に書かれている状態です。

price: int = 1000
def calc_tax_included_price(price: int) -> int:
    return int(price * 1.1)

tax_included_price = calc_tax_included_price(price)

一方、オブジェクト指向っぽく書くと、価格クラスが定義されます[13]

class Price:
    def __init__(self, price: int):
        assert price > 0
        self.price: int = price

    def calc_tax_included_price(self) -> int:
        return int(self.price * 1.1)

price = Price(1000)
tax_included_price = price.calc_tax_included_price()

これによってデータ(self.price)と処理(calc_tax_included_price())が同じところ(クラス)に書かれている状態が実現できます。今回の目指す姿に対応するクラス化によるメリットは、以下の3つです。

  • 価格データに対する処理は全てPriceクラスにインスタンスメソッドとして実装することで、処理がどこに書いてあるかわかりやすい(可読性)
  • いろんなファイルに処理の記載がばらけないので、Priceクラスを渡せば再利用しやすい(再利用性)
  • __init__メソッドにデータが守られるべき制約(Pythonのassert[14])が書かれていて、仕様がわかりやすく、バグを早期に発見できる(保守性)

では、次節で実際にDS職のコードにクラスを導入してみます。

DS職のコードにオブジェクト指向のアイデアを導入する

オブジェクト指向におけるデータとは、プログラム上のありとあらゆる変数を対象にしていますが、今回クラス化したいのは分析対象のデータです。 クラスの作り方はFirst Class Collection[15] のように、Collection型のデータ変数をprivateに持ち、それらを対象にする処理をインスタンスメソッドとして実装します。具体例として、タイタニックデータを読み込んだDataFrameをメンバとして持つようにしてみます。なお、必ずしもデータはDataFramendarrayで持つ必要はなく、ケースバイケースで、独自の@dataclassを定義して、それを要素に持つlistのFirst Class Collectionを定義する場合もあります。

以下の例では、DataFrameを受け取って、それをprivate変数のメンバとして持つTitanicDataクラスを定義しています。重要なポイントは以下の通りです。

  • データが正しいことをassertで確認していること
  • self.__dfをprivate変数にして、外部から操作できないようにしていること(これにより、このデータに対する処理が散らばらずに、凝集度が高くなります)
  • クラスのコンストラクタにはデータを受け取り、データを読み込む関数は別で定義することで、データ形式の変更をこのクラスが吸収する

assertがあれば、このデータはどんなデータであって欲しいのか大きなヒントになります。実効的効果のないコメントよりも、実効的効果のあるassert文は動いている限り必ず正しいですし、書き手の意思が残ります。実際のデータ分析では、データの持つドメイン知識[16]assert文で書いておくことで、不正なデータを検知するだけでなく、データへの深い理解を引き継ぐことができます。

また、読み込み用のメソッドとコンストラクタを分けることで、データ仕様が変わった場合でも、read_data()さえ修正すれば他のメソッドの修正が一切必要なくなり、修正箇所を最小限に抑えることができます。

# point: クラスを定義して、データと処理を同じところに書く
class TitanicData:
    @classmethod
    def read_data(cls, filepath: Path) -> DataFrame:
        # 読み込みのための処理。決まった形のDataFrameに整形する。
        ...
        return df

    def __init__(self, df: DataFrame):
        # point: private変数としてデータを持つことで、データに対する処理が分散しない
        self.__df = df

        # point: データ件数や読み込んだ内容などの大切な情報はloggingで出力する
        logger.info(self.__df.head())

        # point: データの整合性をassert文で確認する
        # 列がちゃんとあるか確認
        assert not DeepDiff(
            self.__df.columns.tolist(),
            list(self.DATA_TYPE_MAPPING.keys()),
            ignore_order=True,
        ), f"列名が一致しません。\n{self.__df.columns.tolist()} v.s. \n{list(self.DATA_TYPE_MAPPING.keys())}"

        assert len(self.__df) > 0, "データが空です。"

    def remove_missing_Embarked(self) -> None:
        """Embarked列に欠損があるデータを削除する。"""
        num_before = len(self.__df)
        self.__df.dropna(subset=["Embarked"], inplace=True)
        logger.info(f"欠損値を削除しました。{num_before} -> {len(self.__df)}")
        # point: 想定外の動きは止まるようにしておく
        assert (
            len(self.__df) / num_before > 0.5
        ), "Embarkedをdropしたら50%以上削除されてしまいました。"
        return

    # point: 可視化処理も、「データに対する処理」なので、クラス内に定義しても良い。
    # 可視化処理はデータ仕様と結びついているので、結局共通化できないことが多く、汎用化は諦める。
    def draw_age_hist(self, savepath: Path) -> None:
        """年齢のヒストグラムを描画する。"""
        fig, ax = plt.subplots(figsize=(5, 5))
        ax.set_title("Histogram of Age")
        ax.set_xlabel("Age")
        ax.set_ylabel("Frequency")
        # point: private変数self.__dfに関する可視化
        sns.histplot(self.__df["Age"], ax=ax)
        fig.savefig(savepath)
        plt.clf()
        plt.close()
        return

    def to_train_data(self) -> tuple[ndarray[float, Any], ndarray[float, Any]]:
        # PyTorchのDataLoaderを返す仕様にする場合もありそうです。
        vaild_df = self.__df[FEATURE_COLS]
        dummied_df = to_dummy(vaild_df[FEATURE_COLS], DUMMY_COLS)

        x = dummied_df.values
        y = self.__df["Survived"]
        assert len(x) == len(y)
        return x, y

    ...(以下略)

util関数は定義してはいけない?

「データと処理を同じところに書く」を忠実に実践すると、データに関する処理はいずれかのクラスに紐づくはずなので、自然と自作util関数[17]は作られないはずです。一方で、似たデータが複数あって、それらに共通して使えるものはutil関数としてutil.pyなどに書きたくなると思います。同じ処理なのに各クラス定義ファイルに書くのはDRY原則[18]に反するという指摘は正しい場合もありますが、これはケースバイケースであり、条件付きです。そこで、util関数を定義したくなった際のガイドラインを提案します。

  • ファイル名をutil.pyにしない
  • 見かけの処理が同じなのか、ビジネスロジックが同じなのかを区別する

まず、ファイル名をutil.pyにしてしまうと、どの処理がどこに書かれているか分かりづらくなり、可読性が低下します。また、util.pyは文字通り便利すぎて、どこに書くべきか少し悩む処理は全てこのutil.pyに書かれる運命を辿ります。こうなってしまうと、どの処理がどこに書かれているかわかりづらくなるばかりか、util.py内にも似た関数がたくさん定義されたり[19]、どこからも参照されていない関数が定義[20]されたりして、管理しにくく、バグの温床にもなり得ます[21]。util関数を定義する場合は、どんなデータ(または概念)に対してのutil関数なのかを考え、適切なファイル名をつけて分割するようにしてください。

次に、「見かけの処理が同じなのかビジネスロジックが同じなのか区別する」というのはDRY原則誤用の話です。Google社もブログを出していた通り、見かけだけ似ている処理を共通化してしまうと、共通化したコードの修正時の影響範囲が広くなりすぎてしまい、保守性が下がってしまうことが非常に多いです。utilとしての共通化は慎重に検討すべきで、ある処理が変更されたとき、同時に他の箇所も変更する必要が生じる場合に限ってのみ、共通化できます。 実際この判断は難しく、この能力を鍛えるには、技術者としての成長だけではなく、むしろビジネスへの深い理解が必要不可欠です。ソフトウェアが変更されるときというのは、ほとんど[22]顧客への価値提供のためです。ビジネスを深く理解していると、どんな理由でどのような変更が生じるかのおよその見立てが立ち、この見立てが共通化して良いかどうかの判断材料になります。

責務を意識してクラスを作る

データサイエンスではいろんなデータを同時に扱うことが多いですが、いろんなデータを1つのクラスにまとめて書いてしまうと、保守(変更)が難しくなってしまう場合があり、注意が必要です。ポイントは、責務ごとにクラスを作るという観点です。言い換えると、責務が異なれば異なるクラスを作ることが重要です。

以下の例では、ある2種類のデータAとデータBがあるとして話を進めていきます。

小さなクラスに分ける

先の章に習って、データAとデータBをそれぞれ扱うためのDataAクラスとDataBクラスを定義します。これは、DataAクラスはデータAに関して責任を持つ、DataBクラスはデータBに関して責任を持たせるために定義されています。Dataクラスのような汎用的な名前でいろんなデータをまとめたクラスを定義してしまうと、多くのロジックがこのクラスに集中してしまい、保守が難しくなってしまいます。このような肥大化しすぎたクラスは神クラス(God Class)と呼ばれており、典型的なアンチパターンです。

複数データを跨ぐ処理は委譲する

複数のデータを扱う分析では、複数のデータの情報を統合して扱うことも多いです。そういった場合には、「統合する」という目的に応じたクラスを作りましょう(仮にProcessクラスと名付けます)。Processクラスの中で複数データを利用したアルゴリズムの実装を書いていきます。

この時に大切なのは、データAのみで完結する処理は統合クラスに書くのではなく、DataAクラスに書くことです。すなわち、Processクラスは、データAに関する処理はDataAクラスに任せてしまいます(これを委譲[23](delegation)と言います)。DataAクラスがデータAについて責任を持ってくれているので、ProcessクラスはデータAの詳細を知ることなくデータAを利用でき、もしsome_process()メソッドの実装に変更が生じても、Processクラスのコードに変更は生じず、保守性も向上しています[24]。これこそが、DataAクラスがデータAに対して責任を持っている状態です。

もし、責務を曖昧にし、委譲していないコードを書いてしまうと、データAに関する処理がDataAProcessに散り散りになってしまい、「どこに何が書いてあるか分からない(可読性低い)」、「ある変更が必要になったときに、コードの修正箇所が多い(保守性低い)」コードに陥ってしまいます。

# 2種類の異なるデータがあり、それぞれをクラス化している。
class DataA:
    ...()

class DataB:
    ...()

# 複数種類のデータから新たな指標を計算したり、予測することも多い。
class Process:
    def __init__(self, dataA: DataA, dataB: DataB):
        self.dataA: DataA = dataA
        self.dataB: DataB = dataB

    # good: DataAに関する処理はDataAに書き、dataAから呼び出す
    def good_process(self) -> float:
        a = self.dataA.some_process()
        b = self.dataB.some_process()
        return a + b

    # bad: DataAに関する処理をProcessに書いてしまうと、
    # DataAに関するロジックが散らばってしまい、探しにくいし、DataAに仕様変更があったときに修正漏れの恐れがある
    def bad_process(self) -> float:
        a = 0
        for data in self.dataA.data:
            a += sum(data)
            ...

        b = self.dataB.hoge / 2  # 何らかの処理でbを求める
        ...
        return a + b

責務を意識したクラスによる恩恵

責務を意識してクラス化することで、以下のメリットを得ることができます。

  • 責務を意識したクラスを作り、処理を委譲しているので、クラスを移植する際、そのクラスが持つべきメソッドが過不足なく揃っている(再利用性)
  • 責務を意識したクラスを作り、処理を委譲しているので、どこに何が書いてあるか見つけやすい(可読性)
  • 大量のメソッドが定義された神クラスがなくなり、メンテナンスしやすい(保守性)

単体テストを書く

前章までの内容を踏まえて、責務を考慮したクラス設計ができれば後一歩です。定義したクラスの各メソッドに対する単体テスト(=Unit Test)を書いてはじめてクラスの完成です。大切なポイントは、単体テストは動作確認のためだけのものではないということです。

なお、本章ではpytestというPythonのテストツールを用います。テストをこれから学ぶという方は"fixture"や"parametrize"といった用語に馴染みがないかと思いますので、例えば以下の記事をご参照ください。簡潔に分かりやすくまとめてくださっております。

https://zenn.dev/tk_resilie/articles/python_test_template

何故単体テストを書くのか

単体テストを書く理由は以下の通りです。

  • 処理が正しいことを確認する(信頼性)
  • 処理が何をしているかの例を残す(可読性)
  • 正しい設計になっているか確認する(保守性)

以下、それぞれについて各項で説明します。

処理が正しいことを確認する

DS職では前処理やアルゴリズムの実装などで複雑なロジックを書くことが多いですが、複雑なロジックはバグが混入しやすいです。今手元にあるデータに対してはバグらなくとも、新たに来るデータに対してバグることもあります。ある程度網羅的に入力パターンを想定したテストケースでの動作を確認することで、単純なミスによるバグは防ぐことができます。 pytestの@pytest.mark.parametrizeマーカーを用いて、テストケースを書き並べましょう。

処理が何をしているかの例を残す

pytestの@pytest.mark.parametrizeマーカーを用いたテストを書いていると、どんな入力に対してどんな出力が得られることを想定したコードかがよくわかります。これはコードを引き継いだ人によってはとてもありがたい情報で、テストコード自体がドキュメントの役割を同時に担います。 理想的には、ドキュメントを整備したり、適切な型アノテーションdocstring[25]によってコードの情報を残すべきですが、退化しているかもしれないコメント[26]や実行上なんの意味もない[27]型アノテーションよりも、緑色に輝く[28]テスト結果を出すテストコードの方が断然信頼できます。なにせテストコードは間違いなく「動いている」訳ですから。

これから、calc_cabin_feature()メソッドの単体テストのテストケースを例にしながら、テストケースがいかに仕様理解に有用かを具体的に説明します。calc_cabin_feature()というのは些か不親切な命名のため[29]、どのようなfeature(特徴量)をcalc(計算)するのかがわかりません。とにかくタイタニックデータにおける、Cabin[30]というデータからなんらかの特徴量を計算するメソッドであることだけは分かります。以下の@pytest.mark.parametrizeマーカーを用いたテストケースをご覧ください。このメソッドのテストケースを見れば、expected[31]が one-hot vector[32] 形式のnumpy配列を返していることが即座に分かり、注意深く読めば、Aから始まる部屋番号は(0から数えて)1番目、Bから始まる部屋番号は2番目、、、Zやその他欠損データは0番目のインデックスが1で、それ以外は0になっていることに気づけます。このように、テストケース自体が、calc_cabin_feature()の実装を読む際の重大なヒントになってくれます[33]

    # cabinの頭文字に対応するone-hot vectorが正しく作れるかを確認する
    @pytest.mark.parametrize(
        "cabin_list, expected",
        [
            (
                [
                    "A123",
                    "B45",
                    "C78",
                    "D90",
                    "E10",
                    "F35",
                    "G56",
                    "T12",
                    "存在しないキャビン",
                    "Z",
                    None,
                ],
                np.array(
                    [
                        [0, 1, 0, 0, 0, 0, 0, 0, 0],
                        [0, 0, 1, 0, 0, 0, 0, 0, 0],
                        [0, 0, 0, 1, 0, 0, 0, 0, 0],
                        [0, 0, 0, 0, 1, 0, 0, 0, 0],
                        [0, 0, 0, 0, 0, 1, 0, 0, 0],
                        [0, 0, 0, 0, 0, 0, 1, 0, 0],
                        [0, 0, 0, 0, 0, 0, 0, 1, 0],
                        [0, 0, 0, 0, 0, 0, 0, 0, 1],
                        [1, 0, 0, 0, 0, 0, 0, 0, 0],
                        [1, 0, 0, 0, 0, 0, 0, 0, 0],
                        [1, 0, 0, 0, 0, 0, 0, 0, 0],
                    ],
                ),
            ),
        ],
    )

このようなテストケースと、def calc_cabin_feature(self) -> np.ndarray[int, Any]:という引数なし、戻り値はint型のnumpy配列という入出力仕様から[34]、以下のようにcalc_cabin_feature()を利用できます。

feature = ...  # 他のなんらかの特徴量
df = TitanicData.read_training_data("titanic_data/train.csv")
titanic_data = TitanicData(df)
cabin_feature = titanic_data.calc_cabin_feature()  # 引数なしで呼び出してone-hot vectorが返ってくるらしい
input_x = np.hstack([feature, cabin_feature]))  # 繋げて機械学習の入力に使えそう

ここまでこの記事を上から順に読んでくださっているあなたは、一度もcalc_cabin_feature()メソッドの実装を読んでいませんが、ここまで仕様を理解できることが単体テストの強みの1つです。

正しい設計になっているか確認する

単体テストが書きにくいと感じ始めたら黄色信号です。単体テストが書きにくい時は、クラスやメソッドの設計が不適切な場合があります。例えばTitanicDataクラスのコンストラクタ[35]の実装を見てみましょう。クラスがファイルパスを受け取る仕様になっていると、テストをするために指定したファイルパスにファイルを置く必要があります。ただのファイル程度ならtest時のsetupで置けば良い話ですが、DBを構築したり、いろいろやりはじめると大変です。実際、プロダクトに載せるためにクラスを渡すことを想定すると、ファイルパスを受け取る仕様よりも、DataFrameそのものを受け取る仕様の方が使いやすいはずで、 テストのしにくさは外部との依存を意味し、再利用性の低さ(密結合)を発見する道標となり得ます[36]

テストしにくいコンストラクタ
# テストしにくいコンストラクタ: インスタンスを作るためにcsvファイルを用意しなければならない
class TitanicData:
    def __init__(self, csv_filepath: Path):
        try:
            self.__df = pd.read_csv(csv_filepath)
            logger.info(
                f"{csv_filepath}から{len(self.__df)}件のデータを読み込みました。"
            )
        except FileNotFoundError:
            logger.error(f"{csv_filepath}が見つかりません。")
            raise FileNotFoundError(f"{csv_filepath}が見つかりません。")
        ... (以下略)
テストしやすいコンストラクタ
# テストしやすいコンストラクタ: DataFrameさえ作ればインスタンスが作れる
class TitanicData:
    @@classmethod
    def read_data(cls, csv_filepath: Path) -> DataFrame:
        # point: 読み込む機能は別で分けておくことで、ファイルを用意しなくともクラスインスタンスを生成できるようにし、
        # テストしやすくする
        try:
            df = pd.read_csv(csv_filepath)
            logger.info(f"{csv_filepath}から{len(df)}件のデータを読み込みました。")
        except FileNotFoundError:
            logger.error(f"{csv_filepath}が見つかりません。")
            raise FileNotFoundError(f"{csv_filepath}が見つかりません。")
        return df

    def __init__(self, df: DataFrame):
        self.__df = df
        ... (以下略)

DS職のコードに単体テストを導入する

全ての関数やメソッドに対して単体テストを書く必要はありませんが、複雑なロジックを書く時には必ず書きましょう。どの程度複雑なら単体テストを書くかというのは一概には言えませんが、以下の項目を自分自身に問いただしてみると、自ずと答えは出てくると思います。

  • 同僚がこのコードをみてすぐに理解できそうか
  • 仕様変更時にロジックの修正漏れが起きやすそうか
  • どんな入力に対しても100%正しく動く自信があるか
  • 入出力の形式がシンプルか

例えば、以下のone-hot vectorをDataFrameCabin列から作成するcalc_cabin_feature()は十分複雑で、バグを書いている可能性があったり、カテゴリ数が増減したときの修正漏れが発生する恐れがあり、単体テストを書くべきメソッドです。

単体テストを書く対象のメソッド

    def calc_cabin_feature(self) -> np.ndarray[int, Any]:
        """Cabin(部屋番号)の頭文字を特徴量として追加する。"""
        # 欠損値をZで埋め、頭文字を数値に変換
        cabin_initials_digits = np.array(
            self.__df["Cabin"].fillna("Z").apply(lambda x: self.get_cabin_index(x[0])),
            dtype=int,
        )
        logger.info(f"Cabinの頭文字を数値に変換しました。{cabin_initials_digits}")
        one_hot_vector_list = []
        for cabin_initials in self.CABIN_MAPPING:
            # one-hot vectorを作る
            one_hot_vector_list.append(
                np.where(
                    cabin_initials_digits == self.CABIN_MAPPING[cabin_initials], 1, 0
                )
            )

        assert len(one_hot_vector_list) == len(self.CABIN_MAPPING)

        return np.array(one_hot_vector_list).T

単体テストの実装例

    def test_calc_cabin_feature(
        self,
        cabin_list: list[str],
        expected: list[int],
        make_random_titanic_df: callable,
    ):
        # arrange
        df = make_random_titanic_df(len(cabin_list))
        df["Cabin"] = cabin_list
        titanic_data = TitanicData(df)

        # act
        ans = titanic_data.calc_cabin_feature()

        # assert
        assert np.all(ans == np.array(expected))

蛇足ですが、make_random_titanic_dfpytestfixtureです。テストケースを作るたびに毎回タイタニックデータのDataFrameを作ったり読み込んだりするのは大変なので、以下のようにfixtureを実装し、テスト用のタイタニックデータのサンプルを、任意の件数生成できるようにしています。

fixtureの実装はこちら
import random

import pytest
from pandas import DataFrame

SURVIVED_CANDIDATES = [0, 1]
PCLASS_CANDIDATES = [1, 2, 3, None]
SEX_CANDIDATES = ["male", "female", ""]
AGE_CANDIDATES = [i for i in range(0, 100)]
SIBSP_CANDIDATES = [i for i in range(0, 10)]
PARCH_CANDIDATES = [i for i in range(0, 10)]
EMBARKED_CANDIDATES = ["S", "C", "Q", None]


@pytest.fixture
def make_random_titanic_df():
    def _make_random_titanic_df(num_data: int):
        data = {
            "PassengerId": range(1, num_data + 1),
            "Survived": random.choices((SURVIVED_CANDIDATES), k=num_data),
            "Pclass": random.choices((PCLASS_CANDIDATES), k=num_data),
            "Name": [f"Name_{i}" for i in range(1, num_data + 1)],
            "Sex": [random.choice(SEX_CANDIDATES) for _ in range(num_data)],
            "Age": random.choices((AGE_CANDIDATES), k=num_data),
            "SibSp": random.choices((SIBSP_CANDIDATES), k=num_data),
            "Parch": random.choices((PARCH_CANDIDATES), k=num_data),
            "Ticket": [f"Ticket_{i}" for i in range(1, num_data + 1)],
            "Fare": [random.uniform(0, 100) for _ in range(num_data)],
            "Cabin": [f"Cabin_{i}" for i in range(1, num_data + 1)],
            "Embarked": [random.choice(EMBARKED_CANDIDATES) for _ in range(num_data)],
        }
        return DataFrame(data)

    yield _make_random_titanic_df

単体テストの注意

単体テストがあると[37]強気にソフトウェアを変更することができます。リファクタリングでも機能追加でも、なんらかの変更後にテストを行い、既存のロジックが壊れていないことを自動的に確認できることは本当に強力です。ただ忘れてはならないことは、テストはソフトウェアが壊れていないことを確認しているわけではなく、ソフトウェアが壊れていることしか確認できないということです。テストが通ったからといって必ずしもバグがないことを意味するわけではなく、あらゆる観点のテストケースを作成し、バグを見つけた際にはそれを確認できるためのテストケースを追加し続けることが大切です。

結び

本記事では、データ分析のコードに関して、今までたくさんの失敗をしてきた私が現時点で考えていることの一部を紹介しました。今回のテックブログはあくまでも現時点の考えであり、朝令暮改のようになるかもしれないことをご了承ください。この手の話題は意見が分かれることも多いかもしれませんが、本記事がDS職のコーディングに迷っている方[38]のヒントになれれば幸いです[39]

参考文献

お知らせ

少しでも弊社にご興味を持っていただけた方は、お気軽にご連絡頂けますと幸いです。まずはカジュアルにお話を、という形でも、副業を検討したいという形でも歓迎しています。

https://hrmos.co/pages/zenkigen/jobs?jobType=FULL
https://speakerdeck.com/zenkigenforrecruit/detailed-version-recruitment-materials-for-data-scientists

脚注
  1. 執筆当時 ↩︎

  2. 私もDS職のひとりですが、多分に漏れずです。 ↩︎

  3. 半年後の自分も他人と扱います。 ↩︎

  4. DSの業務や分類には定義が様々あり一概には言えませんが、本記事では一旦これらをDSの業務とさせてください。 ↩︎

  5. これは適当に言っています。ユーモアの一つとしてお受け取りください。 ↩︎

  6. Proof of Conceptの略で、サービスに用いられる技術が実現可能かどうかを検証するプロセスのことです。 ↩︎

  7. 多くの書籍でそう書かれています。 ↩︎

  8. プロジェクトを引き継いだときの前任者のコードが読みにくいと悲しい気持ちになります。また、自分が読みにくいコードを書いていると、他人に渡す際には非常に申し訳ない気持ちになってしまいます。この不幸を少しでも緩和していきたいものです。 ↩︎

  9. これらの話題については別記事で書く気持ちはあります! ↩︎

  10. タイタニック号沈没事故は、1912年に北大西洋で起きた痛ましい海難事故です。救命ボートが乗船者全員分なく、多くの乗客乗員が亡くなりました。生存には運の要素もありましたが、性別や客室クラスなどの属性ごとに生存率の偏りがありました。kaggleの課題では、乗客の情報(名前、年齢、性別など)から生存するかどうかを予測するモデルを構築し、その予測性能を競います。 ↩︎

  11. Robert Martin氏は著書『Clean Architecture』で以下のように記しています。
    「オブジェクト指向とは、ポリモフィズムを使用することで、システムにあるすべてのソースコードの依存関係を絶対的に制御する能力」 
    従って、少なくとも単にクラスを作ることがオブジェクト指向の全てではありません。 ↩︎

  12. 税込計算で四捨五入しないなど雑な実装ですが、その辺りはオブジェクト指向の話の本質ではないのでご容赦ください。 ↩︎

  13. オブジェクト指向で開発経験のある方からすると、calc_tax_included_price()メソッドの戻り値型がPrice型ではなくint型であることに違和感を覚えるかもしれません。本記事で紹介するクラスでは、DS職が扱う巨大なデータをメンバに持つことを想定するので、新しいインスタンスを作って返すというオーバーヘッドの大きな操作はしない方針です。 ↩︎

  14. assert 条件式 と書けば、条件式が満たされていない時にAssertionErrorでプログラムがエラー終了します。これによって、想定しない実行に気づくことができます。 ↩︎

  15. リストや配列などのcollectionオブジェクトをメンバに持って、そのオブジェクトに対する操作をインスタンスメソッドとして定義したクラスのことです。詳細はこちらの解説記事がわかりやすかったです。 ↩︎

  16. 分析対象の業界に特有の知識のことです。例えばTOEICの点数が10~990点であること、6時間以上働いた人は45分以上の休憩が必要であること、サンドイッチの注文ではハムとベーコンを同時に挟むことはできないことなど、実にたくさんあります。 ↩︎

  17. あらゆるところで共通的に行われる小さな関数です。 ↩︎

  18. Don't repeat yourself ↩︎

  19. どこに何が書いてあるか分からないと、他の人が似た処理を実装していることに気づかずに、ほぼ同じ関数を重複して実装してしまう無駄の発生確率が高まります。 ↩︎

  20. util.pyだけをみても、使われている関数か使われていない関数か判断できず、メンテナンスされにくくなるのが原因です。 ↩︎

  21. 個人の経験談です。これらの根本原因は全て、どこに何が書いてあるか分からない問題に起因していると考えています。 ↩︎

  22. 利用技術の変更(データベースやフレームワークの変更)ももちろんありますが、価値を届けるための変更という意味では顧客要望に絞られます。 ↩︎

  23. あるオブジェクトの処理を他のオブジェクトに代替させるプログラミング手法です。 ↩︎

  24. もちろん、some_process()メソッドの引数や戻り値に修正が加わるとその限りではありません。 ↩︎

  25. ソースコードにコメントとしてドキュメントを残すこと。 ↩︎

  26. ソースコードの変更時に、コメントを同時に編集せず、コードとコメントが不整合な状態で放置されたコメントのことを退化コメントと呼びます。 ↩︎

  27. 型アノテーションはコメントくらいの意味しかなく、型アノテーションが間違えていてもエラー終了するようなことはありません。 ↩︎

  28. 無事passしたテストケースは緑色、passしなかったテストケースは赤色で表示されることが多いので、「緑色に輝く」と表現をしています。テスト実行後に全てが緑色に表示されるととても気分が良いですよね。 ↩︎

  29. 今回は例示のために、意図的に不親切な命名にしましたが、この程度の命名は実務でも見かける程度だと思っています。 ↩︎

  30. Cabinは部屋番号を表していて、それが生存するかどうかに効く説明変数かどうかという議論がkaggle上でなされています。部屋番号が欠損している人の生存率が低かったり、部屋番号の頭文字でおよそ救命ボートとの近さがわかったりと、生存予測に有用な特徴量になり得るそうです。 ↩︎

  31. このメソッドの期待する動作結果 ↩︎

  32. ある1要素だけ1で、残りの要素は全て0であるベクトルです。カテゴリラベルなどに対して、カテゴリ数分のサイズを持つベクトルを作り、ラベルに対応するインデックスだけ1にすることで、カテゴリラベルを機械学習モデルなどに入力しやすくなります。 ↩︎

  33. メソッドの実装を一切読まなくとも、メソッドの引数、戻り値の仕様とテストケースを見ただけでメソッド仕様が一目瞭然になっていれば最高ですね。 ↩︎

  34. Visual Studio Codeなどのエディタを用いて(型アノテーションをちゃんと書いて)いれば、メソッドや関数をマウスオーバーするだけで引数と戻り値の仕様は勝手に表示されますので、実装まで見に行く必要すらありません。 ↩︎

  35. クラスインスタンスを生成する時に実行されるメソッドのことです。 ↩︎

  36. もう少し別の言い方をすると、そもそも単体テストというのはその機能のみの動作を確認するものです。したがって、外部の状況に依存せずに単体テストは実行されるべきで、それがやりにくいということは、外部に依存したクラスになっているということです。そんな状況では、独立した完成されたクラスとは言い難く、プロダクトに載せる時に依存関係を考慮しなければならず、スムーズな移植が難しくなってしまいます。 ↩︎

  37. 本当は回帰テストがないと怖いので回帰テストが必須ですが、本記事では割愛です。 ↩︎

  38. 私自身もその一人です。 ↩︎

  39. インプットも大切ですが、何よりもアウトプットです。本記事の内容を実践しながら、良いところ悪いところを考え続けることがより良いコードに繋がると信じています。 ↩︎

ZENKIGENテックブログ

Discussion