こんにちは、teaです。最近kaggle用 (分析コンペ用) のライブラリを自作したので、作成にあたりどのようなことを意識したのかとかそういったことについて雑にまとめていきたいと思います。
補足ですが、具体的な実装はほとんど載せていません。この手のものは自分の使いやすいように作る方が絶対にいいと思うので。 (人の作ったものを使うのは流派の違いをあらゆるところで感じてしまって正直しんどい)
ただ、見せてと言われたら見せられるものについては全然お見せするので、もし気になる点とかがあれば気軽に言っていただければと思います。
それでは、本筋の話に入っていきます。
まず初めに、分析コンペに参加するにあたって自作ライブラリなんかを作成する必要性は微塵もありません。やろうと思えばnotebook1つだけで前処理から学習・推論までやってしまえるので。
では、なぜライブラリなんか作ったかというと、楽になるべく無駄なことを意識することなく 、コンペに取り組みたいなと思ったからです。
先述の通りnotebook1つでもコンペに参加することは可能です。しかし、そうしたnotebookは経験上、余程気を遣って実装を行わない限りゴッチャゴチャになってしまい、コードを変更をしたいときには大量のセルの中から一々該当箇所を探して修正するということを繰り返す必要があります。
別にそんなの苦じゃないしwって思う人もいるかもしれないですが、自分は極度の面倒くさがり屋なので、そういう面倒なことは避けて、なるべく脳のリソースを使わずにコードを変更できるようにしようと思ったわけです。
そういうわけで、いっそのこと全部いい感じにできるライブラリを作っちゃおうと思ったのが、ライブラリを作成した経緯です。
簡単にライブラリ作成の際に意識した点についてまとめます。
ゴチャっとしたコードがnotebook上に一切現れないようにする
特徴量の作成が (notebook上で見ると) 楽にできる
どんなライブラリを使用した場合のモデルでも同じinterfaceで扱える
実験まわりをいい感じにしたい
ちなみに、scriptでの実行ではなくてnotebookでの実行を意識して作っています (最近はnotebook提出系のコンペも少なくないので…)
notebookで実行できるならscript実行ができるようにするのも容易なので、特に心配とかはしていません。
また、データがcsvで与えられている場合を意識して作っています。ただし、どのデータ形式のコンペであっても (実装は若干変わるけど) 根本的な思想についてはそこまで大きく変わることはないのかなとは思います。
ファイルツリーがあった方がイメージしやすいかなと思うので、先に載せておきます。
lib (名前はよしなに)
├── competitions
│ └── hogehoge_competition
│ └── v1
│ ├── hogehoge (コンペ依存の実装は全てここにまとめる)
│ └── exp.ipynb
├── lib
│ └── ライブラリ本体の実装はここに
└── tests
大体イメージはできたかと思うので、ここから具体的な実装の話をしていきます。
特徴量が少ないうちはまあいいんですが、数が多くなってくるとどうしてもゴチャってしまって個人的にはしんどみがあるので、この辺は真っ先に隠蔽したいなと考えていました。と言っても難しいことは一切していなくて、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行でデータセットが完成するようになりました。うーん、最高ですね。
これは以前から気になっていたことで、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が追加されていきます。こりゃあ便利。
ここまで書いてきたこと以外にも意識をしたことは結構あるんですが、一々長々と説明するほどのことでもないので、箇条書きにしてまとめておきます。(定期的に更新されるかも)
全体的に抽象度高めに作る
Type Hintを絶対につける
どの環境でもコードをほぼいじらず & 面倒な手順を踏まずに使えるようにする
運用方法については未だに若干悩んでいるので、もしかしたら将来的には全然違うものになっているかもしれませんが、一応紹介しておきます。
competitions以下にコンペ用のディレクトリを作る
v1を作って、interfaceに従ってModel、Experiment、その他諸々を実装する
notebookを適当に作って実験する
バージョンを変えたいなとなったときには、v1をまるっとコピーしてv2を作って、適宜変更して実験
以下4の繰り返し
初めに3まで実装するのがちょっと手間ですが、そこまでやってしまえばあとはほぼコピペ & 修正でストレスフリーに運用できるので、今のところはこれでいいかなって思っています。
個人的には、今回紹介したようなようなことを意識して実装することで、なかなか出来の良いライブラリが完成したと (今のところは) 思っています。ですが、これらのアイデアは自分の力のみで生み出した訳ではなく、既存のpublicに公開されているライブラリやコードを参考にしたところが大きいので、これから同じようなライブラリを作ろうと思っている方は、まずそうしたコードを読んでみて、自分ならどういう風に実装したいかということを考えるところから始めてみるといいかなと思います。
長い文章でしたが、最後まで読んでいただきありがとうございました。
先日終了したGoogle Brain - Ventilator Pressure Predictionコンペで実際に利用したコードを公開しました。 本記事で紹介したライブラリを実際に用いているので、もし興味がある方は参考程度に一度覗いてみてください。
Google Brain - Ventilator Pressure Predictionコンペ サンプルコード