【Malwareコンペで行っていた特徴量管理の話】

はじめに

本記事は2019年3月14日まで行われていた Microsoft Malware Prediction コンペの振り返り記事となります。

ただし、具体的なコンペの解放に関してはすでに参考になる素晴らしいKernelが数多く存在することや記事1, 2があること、 稀見る非常に大きなshakeがあったコンペであったこと、そして我々がそのshakeの結果として爆死したことなどを鑑みまして 具体的なコンペの解法に関する話題には本記事では言及しません。

また、今回のコンペは自分は他二人のチームメイト(mocobtさん、Shingo.Sさん)と共に参加しており、特にmocobtさんとは完全に同じリポジトリで各自開発パートを分担する形で行っていました。

よって、本記事では主に自分が担当していた特徴量の計算と管理部分の振り返り、もといコード供養の記事となります。

なお、供養するコードはGitHubのリポジトリにすでに公開済みです。ただし、未整理な点のみご了承ください。

特徴量管理の方針

参考・前置き

Kaggleにおいて、特徴量の管理方針は時折話題になり様々な手法を多くの人が日々試しているトピックです。

先達となる記事としては次に挙げるいくつかのブログがあげられます。多分に漏れず僕も参考にさせていただいております。

  1. Kaggleで使えるFeather形式を利用した特徴量管理法 amalog.hateblo.jp

  2. 【Kaggleのフォルダ構成や管理方法】タイタニック用のGitHubリポジトリを公開しました upura.hatenablog.com

  3. kaggleコンペに参加した時に行った特徴管理方法を公開します www.currypurin.com

その上で今回はMalwareコンペの

  • 一枚のCSVで構成された

  • 行数が多く

  • Categoricalな特徴列を100近くもつ

データに向けてアレンジした特徴量管理を行いました。

方針

前項を踏まえたうえで、以下の方針を考えながらコンペ中はコードを書いていました。

  • 特徴量の再計算を避ける

  • 特徴量の計算を並列化し高速化を図る

  • 新しく特徴量を作成する過程から特徴量作成以外のロジックをなるべく取り除く

  • 過去に行った実験過程を残す

また、mocobtさんの学習・予測部分に計算した特徴量の情報を引き渡す関係で 今回は特徴の名前から保存先ファイルを特定し、学習に用いる特徴量の列を読み出せるようにする必要がありました。

具体的なコード、アーキテクチャ

以下の図が今回のチームで用いていたプログラムの特に特徴量管理に関わる部分のアーキテクチャになります。

以降でそれぞれのクラスについて役割と概略を示します。

Main

基本的には次項のProcessorFactoryを呼んでいるだけです。あとは例外処理なんかをまとめていました。

ProcessorFactory

実行時の各試行の特徴量生成・学習・予測の実行管理、記録、予測データの保存を行っています。 また、学習工程の管理をしていたjsonコマンドライン引数で与えた実験番号に応じて読みだしていた部分もこちらになります。

以降のコードにも出てきますが今回のMalwareコンペのコード管理においては同じくmocobtさんと参加していた PLAsTiCCコンペの反省や名残が出てきます。

実際にこの部分のアーキテクチャを組んだmocobtさんによれば、 こちらのProcessorFactoryにおいてProcessorインスタンス化しているのは、 学習モデルを複数用いるような学習をEnd-to-Endで行うことが出来るような拡張性を持たせるためとのことです。 この点はPLAsTiCCコンペにてデータを二分割してそれぞれに対応する二つの予測器を作成していた名残です。

Processor

こちらではProcessorFactoryにて与えられた条件に従って前処理、学習、予測を行っています。

丁度この部分で自分とmocobtさんの間で大まかに開発の分担を切り分けており、前処理部分(特徴量管理部分)が自分、 学習・予測・そのままサブミットデータの作成までをmocobtさんに担当してもらっています。

前処理から学習以降のパートの間では学習以降に用いる特徴量を引き継ぐために各特徴量の名前を渡しています。 形式としては特徴量をグループ化したそのグループ名をkey、そのグループ内で学習に利用する特徴量(カラム名)のリストをvalueとした辞書となっています。

読み出しの時間的なコストを考えれば辞書を与えるようなまどろこしいことをせずに、 そのまま特徴量のデータを渡すということも検討しましたが今回は結局前述の形式をとりました。

理由としては今回は特にTargetEncodingなどのリークの懸念のある特徴量エンコーディングを行う可能性を想定していた関係でfoldごとにtrain・validate・testデータを作成していたこと、 そもそもKaggleより与えられた生データの段階で相当にサイズがあったこと、 これらを踏まえなるべく省メモリな実装を選択した方が良いと判断したことです。

Classifier

こちらは完全にmocobtさんに任せていた(丸投げしていた)パートであるため自分はざっくりしたアーキテクチャの理解程度です。

基本的にはProcessorFactoryで読みだしたjsonファイルの情報をもとに予測器の選択を行い、 Processor内で前処理パートから引き渡された特徴量情報を利用して呼び出して学習してモデルの保存、 その後今度は作成したモデルを用いた予測を行って最後にサブミットを保存、ということのはずです。

FeatureExtractor

こちらではProcessorから受け取った情報をもとに特徴量の各グループの計算と特徴量名の呼び出しを行っています。

今回、特徴量はこちらの記事を参考にグループ単位で管理していました。 また、特徴量の保存や、特徴量を計算するコードなどもこのグループ単位を利用しており、 結果としてこのモジュールでは

  • Processorから受け取った条件をグループごとに切り分け

  • 対応するモジュールを動的に読み出し

  • インスタンスを作成して特徴量の計算

を行っています。

加えて、train・validation・testのデータのパートの違いもこちらでinput及びoutputのパスを変更する形で一部隠蔽しています。 理想としてはこちらのモジュールでこのデータのパートの違いをすべて補えれば良かったのですが、 カテゴリカルエンコーディングにまつわるデータのパートの違いに関しては、後述するfeature_calculatorにて対応しています。

BaseFeature

このモジュールは実際に特徴量を計算する各モジュールの基底クラスです。

各特徴量の実際の計算ロジックに関しては派生クラスにメソッドとして記述していきます。 それらの基底クラスであるBaseFeatureではjsonファイルで指定した値に従って 派生クラスのメソッドを呼び出し、引数や計算に用いる列名などを用いて特徴量の命名・計算済みかの確認・計算・保存を行っています。

実際に計算を行っていることもあり、私が一番色々な試行錯誤を行っていたのがこのモジュールです。 どのレイヤーで並列化するのか、どうやって特徴量の管理をするのか、なるべく特徴量の作成自由度を担保しつつ共通の管理ロジックに落とし込めないものかと ぐちゃぐちゃと弄っていました。

feature_calculator

こちらのモジュールはBaseFeatureのうちから切り出したエンコーディングの実装と実際の計算を担当しているモジュールです。

コンペの前半ではエンコーディングロジックごと特徴量計算メソッドに押し込んでいたのですが、 実装が重複しがちであることやデータのパートに依ってかき分ける必要があることを踏まえ切り出しました。

各種検討事項

特徴量の保存形式

今回、特徴量の管理にあたり保存の形式は素直なCSVを用いました。 他のいくつかの保存の方法も検討しましたが下記いくつかのメリットを考慮した結果、上記の判断となりました。

  • 保存済みの特徴量のカラム名を参照して特徴量がすでに計算済みかを管理。この時、保存した特徴量のファイル全体を読み出しては効率が悪いので一行目のみを読み出していたため。

  • 特徴量をグループ単位で管理していた関係で、グループ内の特定の特徴量を読み出すにあたり、列指定して読み出すため。

  • 実際に作成した特徴量を眺めに行くためになるべく手軽にアクセスできるようにするため。

特に一つ目は用いた特徴量の命名をなるべく簡素にかつjsonから自動で作成した恩恵を最大限生かすアプローチでもありました。 一方、やはり素のCSVでは読み書きの速度は遅かったのは気になってはいました。

特徴量の生成と命名

特徴量の命名に関しては前項との兼ね合いも含め可能な限り自動で作成できないものかと検討し、管理用のjsonファイルから一意な特徴量名を作りました。 作り方そのものはほとんど管理用のjsonファイルの各項目をアンダースコアでつないだものであり、特段の工夫というほどのものはありません。

また、特徴量の生成に関して以下のような手順でなるべく具体的な実装と管理json以外書き換えの必要がないような設計を目指していました。

  • 特徴量のグループ[Group]に属する新しい特徴を生成する。

  • 元データ中のある行[column](複数の選択も考える)を加工したい。

  • 新しく実装した[method](ただしデータのパートに依らない処理)を適用した結果を与えたい。

  • [method]に付属する何らかの引数が欲しい場合は[args]として与えたい。

  • また、[encode]を適用してtrain・validate・testのパートの違いを踏まえた処理を与えたい。

という時、

  • [method]を特徴量のグループ[Group]に対応した[Group].pyBaseFeatureの派生クラス[Group]のメソッドとして以下のように実装する。
# [Group].py
  class [Group](BaseFeature):

      def [method](self, df[[column]], arg1, arg2, ...):
          # 具体的な実装
          return df[[new_columns]]
  • jsonファイルに以下のように書き加える。
    "[Group]": {
        "[method]": [
            {"columns": ["[column]"], "args": ["[args]"], "encode": ["[encode]"]}
        ]

以上で新たな特徴[Group]_[method]_[column]_[args]_[encode]を作成できるようにしていました。

命名は少なくとも自分はかなり手間取る作業であったので上手いこと管理行程に押し込めたのは便利でした。

特徴量計算の並列化

処理の高速化にあたって単純にマシンパワーでスケールできる処理の並列化はかなり一般的な処理ですが、 こと特徴量の作成に当たってはどのレイヤーで並列化するかということは検討すべき点であると考えられます。

大まかに分ければ一つの特徴量を複数のプロセスで行単位で分割して並列化するか、それとも複数の特徴量を並列して計算するかでしょうか。

今回、我々の設計では以下の理由から後者を利用しました。

  • 前述した特徴量作成の工程で並列処理を意識させる、もしくは並列処理用のライブラリの制約を加えることを避けるため。(PLAsTiCCコンペにおいてdaskでskewの計算に手間取ったことを踏まえ)

ただし、特徴量の保存をグループ単位で行っていたためすべての特徴量作成を並列化させるのではなくグループ単位で同グループ内の特徴量作成を並列化していました。

encodeの切り出し

前項までの意識と似通ったものですが特徴量作成にデータのパートに依って実装を切り替える必要がある部分として エンコーディング部分を切り出しました。

特にtrainパートの平均値やラベリングをvalidateやtestのデータに適用する際はこの切り出しが便利でした。

以下は実際に用いていたLabelEncodingの実装です。

    # feature_calculator.py
class feature_calculator():

    # hogehoge

    def LabelEncoding(self, func, columns, args):
        full_df = pd.concat([self.train_df, self.validate_df, self.test_df])[["MachineIdentifier"] + columns]

        id_set = set(full_df["MachineIdentifier"].values)
        full_df = func(full_df, **args)
        self.__calc_back_checker(full_df, id_set)

        full_df.sort_values("MachineIdentifier", inplace=True)
        full_df = full_df[["MachineIdentifier"] + [c for c in list(full_df.columns) if c != "MachineIdentifier"]]

        feature_col = [c for c in list(full_df.columns) if c != "MachineIdentifier"][0]
        labels, uniques = pd.factorize(full_df[feature_col])
        full_df[feature_col] = labels

        M_id = self.__get_part_df()["MachineIdentifier"].values.tolist()
        part_df = full_df[full_df["MachineIdentifier"].isin(M_id)]

        return part_df

    # fugafuga

反省点,改善点

リソース管理

今回のコンペでは基本的にコードを共有しつつ各自でGCP上で計算を回すという手法で管理していました。

そのうえで適当にコードを書いて並列化しても最悪インスタンスを拡張してしまえばいいかwというノリで書いた結果、 一部のグループのみ特徴量の並列化に馬鹿みたいなメモリを食うという事態が発生し、 全体でみるとメモリが非常に無駄遣いされている状況になっていました。

ただ、実際にコードを書いている状態では各種処理にどの程度メモリを食うかわからず、 例えば一時的ながら複数の特徴量をマージするとガバっとメモリを持っていかれて6時間回した計算が落ち、 翌日涙を呑むという事態が起きたのも事実です。

この辺りの管理は一つ今回の特徴量管理における課題として残っています。 何かうまい方法あれば教えていただけると幸いです。

インスタンスが落ちたときの対応

前述したようにインスタンス上で学習するにあたりもう一つ問題となる点が、 インスタンスが特徴量の計算中に落ちてしまった際の処理です。

基本的に特徴量は計算して保存し、さらに計算済みかの確認はファイルの中身を参照するという形式で行っているので 計算を再開してあげれば問題なく途中から計算を行ってくれるのですが、唯一特徴量の保存中にインスタンスが落ちた場合はこれが機能しなくなっています。 ただし、今回のコンペのデータはサイズが大きかったため保存時間も決して少ないものではありませんでした。

実際に一度コンペ中にインスタンスが落ちたタイミングが特徴量の保存だった結果(当然寝ている間に落ちたのでこのタイミングに落ちたことは気付いていませんでした。)、 特徴量計算を再開し学習のパートにまで到達したうえでうまくいってないことに気付くという事態がありました。

対策としては単純に書き込み中のフラグをどこかのファイルに残しておけばいいということなのだとは思いますが、 どうせやるならさらにそのフラグを参照して再計算の判断を行う方が良いだろうなということで未実装であり、次回の課題という所です。

学習・予測部分へのデータ引き渡し

今回の学習・予測部分へのデータのやり取りは基本的に特徴量名のみで行っていました。 しかし、一部の予測器(catboost, LightGBM)においてはカテゴリカルデータかの情報を特徴量の値とは別に要求してくるものもありました。

結果としてそれらへの対応は今回はうまく行かなかったので対応方法から検討の必要があると考えています。

後書き

以上、Malwareコンペの振り返り記事となります。

なお、本日の午前中に先だってmocobtさんによって振り返り記事が出ているので

是非ともそちらもご覧になっていただければと思います。

qiita.com

qiita.com

qiita.com

おまけ

f:id:icebee:20190331201954p:plain

f:id:icebee:20190331201845p:plain

f:id:icebee:20190331201955p:plain

f:id:icebee:20190331201848p:plain