teaがいろいろ書くところ

分析コンペ用オレオレライブラリを作る際に考えたこと

Posted at — Oct 20, 2021

はじめに

こんにちは、teaです。最近kaggle用 (分析コンペ用) のライブラリを自作したので、作成にあたりどのようなことを意識したのかとかそういったことについて雑にまとめていきたいと思います。

補足ですが、具体的な実装はほとんど載せていません。この手のものは自分の使いやすいように作る方が絶対にいいと思うので。 (人の作ったものを使うのは流派の違いをあらゆるところで感じてしまって正直しんどい)

ただ、見せてと言われたら見せられるものについては全然お見せするので、もし気になる点とかがあれば気軽に言っていただければと思います。

それでは、本筋の話に入っていきます。


なんでライブラリ作ったの?

まず初めに、分析コンペに参加するにあたって自作ライブラリなんかを作成する必要性は微塵もありません。やろうと思えばnotebook1つだけで前処理から学習・推論までやってしまえるので。

では、なぜライブラリなんか作ったかというと、楽になるべく無駄なことを意識することなく 、コンペに取り組みたいなと思ったからです。

先述の通りnotebook1つでもコンペに参加することは可能です。しかし、そうしたnotebookは経験上、余程気を遣って実装を行わない限りゴッチャゴチャになってしまい、コードを変更をしたいときには大量のセルの中から一々該当箇所を探して修正するということを繰り返す必要があります。

別にそんなの苦じゃないしwって思う人もいるかもしれないですが、自分は極度の面倒くさがり屋なので、そういう面倒なことは避けて、なるべく脳のリソースを使わずにコードを変更できるようにしようと思ったわけです。

そういうわけで、いっそのこと全部いい感じにできるライブラリを作っちゃおうと思ったのが、ライブラリを作成した経緯です。


ライブラリ作成において特に意識した点

簡単にライブラリ作成の際に意識した点についてまとめます。

超重要

重要

ちなみに、scriptでの実行ではなくてnotebookでの実行を意識して作っています (最近はnotebook提出系のコンペも少なくないので…)

notebookで実行できるならscript実行ができるようにするのも容易なので、特に心配とかはしていません。

また、データがcsvで与えられている場合を意識して作っています。ただし、どのデータ形式のコンペであっても (実装は若干変わるけど) 根本的な思想についてはそこまで大きく変わることはないのかなとは思います。


構成について

ファイルツリーがあった方がイメージしやすいかなと思うので、先に載せておきます。

lib (名前はよしなに)
├── competitions
│        └── hogehoge_competition
│                        └── v1
│					               ├── hogehoge (コンペ依存の実装は全てここにまとめる)
│					               └── exp.ipynb
├── lib
│      └── ライブラリ本体の実装はここに
└── tests

大体イメージはできたかと思うので、ここから具体的な実装の話をしていきます。


特徴量の作成が (notebook上で見ると) 楽にできる

特徴量が少ないうちはまあいいんですが、数が多くなってくるとどうしてもゴチャってしまって個人的にはしんどみがあるので、この辺は真っ先に隠蔽したいなと考えていました。と言っても難しいことは一切していなくて、notebook上で使いたい特徴量 (群) を指定して、それを渡すだけで勝手に変換してくれるようにしたというところです。csvを読んで特徴量を生成するコードは次のような感じになっています。

train = pd.read_csv("../input/train.csv")
test = pd.read_csv("../input/test.csv")

----------------------------------------

# 使う特徴量を指定
features = ["x_feat", "y_feat", "z_feat"]

# 特徴量生成、その他の前処理的なのを全部やってくれるすごいやつ
dataset_creator = DatasetCreator(train, test, features=features)

dataset = dataset_creator.make()

----------------------------------------

# ↓のようにして各種データにアクセス可能
dataset.train_X, dataset.train_y, dataset.test_X

こうすることでnotebook上では3行でデータセットが完成するようになりました。うーん、最高ですね。


どんなライブラリを使用した場合のモデルでも同じinterfaceで扱える

これは以前から気になっていたことで、lgbmを使うのか、それともPyTorchを使うのか、はたまたTensorFlowを使うのかでガラッとコードが変わってしまって、なんかしんどいなーと思っていました。

そりゃそれぞれのライブラリの作り手の思想が違うので当たり前っちゃ当たり前なんですが、この辺のinterfaceをsklearnのライブラリみたいに統一すればもうちょい体験がよくなるんじゃないかなと思ったので、実装しました。

以下は実際に作ったinterfaceです。大体このくらいが丸いんじゃないかなーという気持ちです。

FitResult = namedtuple("FitResult", ["model", "oof_prediction", "score", "importance"])

class ModelWrapper(ABC):
    def __init__(self, config: ModelConfig, score: Callable, file_logger: Logger, std_logger: Logger) -> None:
        self.config = config # modelのconfig系全部持ってるやつ
        self.score = score # scoreingする関数
        self.file_logger = file_logger # fileにlog吐くやつ
        self.std_logger = std_logger # stdoutにlog吐くやつ

    @abstractmethod
    def build(self):
      	# 予め何かしらの処理を挟みたいならここでやる
        pass

    @abstractmethod
    def fit(
        self,
        X_train: Union[DataFrame, ndarray],
        y_train: Union[Series, ndarray],
        X_valid: Union[DataFrame, ndarray],
        y_valid: Union[Series, ndarray],
        **kwargs,
    ) -> FitResult:
        pass

    @abstractmethod
    def predict(self, X_test: Union[DataFrame, ndarray], **kwargs) -> ndarray:
        pass

    @abstractmethod
    def optimize(
        self,
        X_train: Union[DataFrame, ndarray],
        y_train: Union[Series, ndarray],
        X_valid: Union[DataFrame, ndarray],
        y_valid: Union[Series, ndarray],
        direction: str,
        n_trials: int,
        **kwargs,
    ) -> Dict:
        pass

    @abstractmethod
    def save_model(self):
        pass

sklearnのモデルを使う場合は基本的に何も考えずにこのinterfaceに従って実装すればいい感じになります。

PyTorchとかTensorFlowの場合はちょっと面倒なんですが、外部に本体を実装してbuildメソッドで初期化するようにしています。イメージとしては↓のような感じ。

HogeModelの中に書いちゃうのも悪くはないけど、なんか汚いので却下。

class TorchModel(nn.Module):
    def __init__(self):
 			# hogehoge

    def forward(self, x):
			# fugafuga
      
class HogeModel(ModelWrapper):
    def __init__(self, config: Config, score: Callable, file_logger: Logger, std_logger: Logger) -> None:
        super().__init__(config, score, file_logger, std_logger)

    def build(self):
      self.model = TorchModel()
      ...

補助的なメソッドはinterfaceになくても (ここでいうHogeModel classに) 適宜追加していくという感じで、おそらくほとんどのライブラリはこれでカバーできるようになりました。


実験まわりをいい感じにしたい

実験まわりはnotebookで全部やろうとすると結構カオスなことになるので、キレイにしたいと思っている人は少なくないと思います。

自分の場合は以下のようなinterfaceで統一することにしました。

TrainResult = namedtuple("TrainResult", 
                         ["fit_results", "oof_prediction", "score", "importance"]
                        )

TestResult = namedtuple("TestResult", ["test_prediction"])

ExperimentResult = namedtuple("ExperimentResult", 
                              ["oof_prediction", "test_prediction", "submission_df", 
                               "score", "importance", "time"]
                             )


class ExperimentConfig(ABC):
    def __init__(self) -> None:
        pass

      
class Experiment(ABC):
    def __init__(self, competition: Competition, config: ExperimentConfig) -> None:
        self.competition = competition
        self.config = config

    @abstractmethod
    def build_conf(self) -> ModelConfig:
        pass

    @abstractmethod
    def build_model(conf: ModelConfig) -> ModelWrapper:
        pass

    @abstractmethod
    def run(self) -> ExperimentResult:
        pass

    @abstractmethod
    def train(self) -> TrainResult:
        pass

    @abstractmethod
    def test(self) -> TestResult:
        pass

    @abstractmethod
    def oof_score(self) -> float:
        pass

    @abstractmethod
    def optimize(self) -> None:
        pass

    @abstractmethod
    def save_model(self) -> None:
        pass

    @abstractmethod
    def save_oof(self, oof_prediction: ndarray, score: float) -> None:
        pass

    @abstractmethod
    def save_submission(self, submission_df: DataFrame, score: float) -> None:
        pass

基本的には、コンペごと/モデルごとにこの辺を実装してnotebookではrunメソッドを叩くだけという感じで思っていただければ大丈夫です。

ちなみに、runメソッドは自分の場合は以下のような実装になります。

def run(self) -> ExperimentResult:
  timer = Timer()
  timer.start()

  train_result = self.train()

  self.config.std_logger.info("Saving models and oof ...")
  self.save_model()
  self.save_oof(train_result.oof_prediction, train_result.score)
  self.config.std_logger.info("done.")

  self.config.std_logger.info("Prediction ...")
  test_result = self.test()
  self.config.std_logger.info("done.")

  self.config.std_logger.info("Saving submission_df ...")
  submission_df = self.competition.make_submission(test_result.test_prediction)
  self.save_submission(submission_df, train_result.score)
  self.config.std_logger.info("done.")

  timer.end()

  self.config.notification.notify(f"Experiment Finished. [score: {train_result.score}, time: {timer.result}]")

  return ExperimentResult(
    oof_prediction=train_result.oof_prediction,
    test_prediction=test_result.test_prediction,
    submission_df=submission_df,
    score=train_result.score,
    importance=train_result.importance,
    time=timer.result,
  )

直感的でわかりやすいコードになっているのではないでしょうか。

ここでしれっと登場しているCompetitionというクラスですが、一応こいつは以下のようなイメージです。

class Competition(ABC):
    def __init__(
        self,
      	dataset: Dataset,
      	sample_submission: DataFrame
    ) -> None:
        self.dataset = dataset
        self.sample_submission = sample_submission

    def make_oof(self, oof_prediction: Union[ndarray, Series, DataFrame]) -> DataFrame:
        pass

    def make_submission(self, test_prediction: Union[ndarray, Series, DataFrame]) -> DataFrame:
        pass

この辺はモデル依存の部分についてはモデルを変えるごとに少々書き換えが必要ですが、それ以外の部分は基本的に使いまわせるので、コンペごとに1度実装すれば後はほぼコピペって感じです。


バージョン管理が楽

まあこれについては好みの問題な気がするんですが、個人的には実験ごとにNotionとかgitとかにメモを残すっていうのが面倒だったので、コードで管理できるようにしたという感じです。

やり方はシンプルで、notebookの頭に以下のようなnotebook用configを設置して実行して終わりです。

class NotebookConfig:
    def __init__(
        self, version: Version, logger: StdoutLogger, file_logger: FileLogger, 
      	notification: Notification, seed: int, is_local: bool
    ) -> None:
        self.version = version
        self.logger = logger
        self.file_logger = file_logger
        self.notification = notification
        self.seed = seed
        self.is_local = is_local

        self.file_logger.default(
            [
                "================Experiment==================",
                f"Version: {version.n}",
                version.description,
                "============================================",
                "",
            ]
        )

        self.notification.notify(f"Experiment [V{version.n}] Start.")
        
----------------------------------------

nb_conf = NotebookConfig(
    version = Version(
        100, 
        """
     		hogehogeを追加した
     		fugafugaを除去した
        """
    ),
    logger=StdoutLogger(),
    file_logger=FileLogger("exp"),
    notification=notification,
    seed=1,
    is_local=True
)

これを実行すると、(自分の場合は) log/exp.log というファイルが勝手に作られて、先頭に次のような実験情報が入ります。

================Experiment==================
Version: 100

     		hogehogeを追加した
     		fugafugaを除去した
        
============================================

ちなみに自分はこの file_logger をExperimentのConfigに渡していて、それをModelWrapperの中でも使いまわしているので、このlogの下に自動的に実験logが追加されていきます。こりゃあ便利。


その他

ここまで書いてきたこと以外にも意識をしたことは結構あるんですが、一々長々と説明するほどのことでもないので、箇条書きにしてまとめておきます。(定期的に更新されるかも)


コンペでの実際の運用方法について

運用方法については未だに若干悩んでいるので、もしかしたら将来的には全然違うものになっているかもしれませんが、一応紹介しておきます。

  1. competitions以下にコンペ用のディレクトリを作る

  2. v1を作って、interfaceに従ってModel、Experiment、その他諸々を実装する

  3. notebookを適当に作って実験する

  4. バージョンを変えたいなとなったときには、v1をまるっとコピーしてv2を作って、適宜変更して実験

  5. 以下4の繰り返し

初めに3まで実装するのがちょっと手間ですが、そこまでやってしまえばあとはほぼコピペ & 修正でストレスフリーに運用できるので、今のところはこれでいいかなって思っています。


さいごに

個人的には、今回紹介したようなようなことを意識して実装することで、なかなか出来の良いライブラリが完成したと (今のところは) 思っています。ですが、これらのアイデアは自分の力のみで生み出した訳ではなく、既存のpublicに公開されているライブラリやコードを参考にしたところが大きいので、これから同じようなライブラリを作ろうと思っている方は、まずそうしたコードを読んでみて、自分ならどういう風に実装したいかということを考えるところから始めてみるといいかなと思います。

長い文章でしたが、最後まで読んでいただきありがとうございました。


追記 (2021-11-09)

先日終了したGoogle Brain - Ventilator Pressure Predictionコンペで実際に利用したコードを公開しました。 本記事で紹介したライブラリを実際に用いているので、もし興味がある方は参考程度に一度覗いてみてください。

Google Brain - Ventilator Pressure Predictionコンペ サンプルコード


参考にさせていただいたライブラリ (随時追記予定)