機械学習エンジニアの備忘録

主に自分が勉強したことのメモ

Dockerコンテナ実行時に standard_init_linux.go:219: exec user process caused: exec format error が出る

alpineをベースイメージとしてDockerfileの最後でCMDやENTORYPOINT等でシェルスクリプトを実行したときに以下のようなエラーが出た。
環境はmacOS Catalina 10.15.7、Docker desktop3.1.0

standard_init_linux.go:219: exec user process caused: exec format error

いろいろググるとarmやx86_64のアーキテクチャの違いとかが出てくるがalpineとintelCPUのmacOSなので大丈夫なはず。
結局以下のリンクの内容と同じ原因だった。

www.lewuathe.com

単にshell scriptの書き方が悪く、ファイルの先頭に #!/bin/bash を書いていなかったことが原因だった。

シェルスクリプトを修正し、再度実行すると次は以下のようなエラーが。

standard_init_linux.go:211: exec user process caused "no such file or directory"

どうやらalineではbashは使えないらしくashを使う必要があるらしい。
シェルスクリプトの先頭を#!/bin/ashにすると無事動いた。

【Kaggle】鳥コンペ振り返り

はじめに

先日終了したKaggleのCornell Birdcall Identificationに参加し、1390人中122位(Top9%)で銅メダルを獲得することができました。
www.kaggle.com

f:id:rikeiin:20200921220543p:plain

本記事では自分のソリューションと上位陣のソリューションを簡単にまとめておこうと思います。

コンペ概要

与えられた音声データにどの鳥の鳴き声が含まれているかを推定するマルチラベルの分類タスクです。
学習データとして264種類の鳥に対してそれぞれ音声ファイル(mp3)が与えられており、長さ、ファイル数ともにばらばらです。
予測の際は3地点で採取された音に対して、site1とsite2では5秒毎、site3では音声ファイルごとにラベルを予測して提出する必要があります。
今回はcode competitionだったため、推論はすべてkaggleのnotebook上で行う必要があります。

コンペ中に取り組み

コンペ終了の約1ヶ月前から公開されているEDAやdiscussionを読み始めてスタートしました。
まずはわかりやすいtawaraさんやAraiさんのnotebookを参考に学習から推論までの一連のパイプラインを作ろうとしましたが、ここが一番苦労したかもしれません。。
そもそもテストのサンプルデータとサンプルの提出用csvがわかりづらく、どう提出のcsvを作ればいいのかわかりませんでした。
さらにtest audioがnotebookのcommit中には現れずにsubmissionボタンを押してからしか出現しないという仕様?により一連のパイプラインを作るのに一週間したらほど費やしてしまいました。

この時点で残り約3週間、音声データコンペは初めてだったので公開notebookやdiscussionの情報から自分ができそうなものを試していく方針で進めていきました。

学習データはシングルラベルでテストデータはマルチラベル、サンプルのテストデータがほとんどない、ということでLBと相関があるローカルのCVが全く作れずコンペ中は暗闇の中を進んでいる気分でした。
さらに、自力では最後まで公開notebookのパブリックスコアは超えられずになかなか辛い戦いでした。。

最終的な自分のソリューション

データ準備

  • 入力は音声波形をLogmelspecに変換した二次元画像でチャンネル数は1
  • 画像サイズ512
    • 公開notebookはほとんど224のサイズを使っていましたが、今まで自分が画像コンペに参加してきた経験上入力サイズを上げるとスコアがあがることが多かったのでサイズを上げてみましたが、実際に効果がありました。

Data Augmentation

notebookで公開されていた音声波形に対するものをいくつか使用。

  • TimeShifting
  • SpeedTuning
  • AddGaussianNoise
  • PitchShift
  • Gain
  • PolarityInversion
  • StretchAudio

Model

  • EfficientNet-B4
    • B2~B5まで試してもっともスコアが良かったB4を採用

Training

  • Stratified Kfoldで5CV
  • secondary labelを0.5のラベルで含めて学習
  • Loss
    • BCEWithLogitsLoss
  • Optimizer
    • Adam (learning rate 0.001)
  • CosineAnnealingLR(T=10)
  • epoch 50

需要はないとおもいますがGithubでも公開しています。

github.com

ensemble

最終的に以下の2手法で提出

  • EfficientNet-B4 + image size 512の5fold平均
  • EfficientNet-B4 + image size 320 + secondary labelの5fold平均

試したが効かなかったこと

  • external data
    • 効果があったといっている人が多かったが自分はなぜかあまり効果がなかった。ローカルのval Lossは明らかに下るがLBのスコアはむしろ下がっていた。(自分のresampleなどの前処理がバグっていた可能性。。)
  • logmelspecに対するmixup
    • 上位陣も多数使っていたが効果がなかった。なにかコツがあるんだろうか?
  • denoise処理
    • 公開されていた音声に対するノイズ削減処理を試してみた。自分の耳で聞くと明らかに鳥の鳴き声がわかりやすくなった気がするがあまりスコア向上にはつながらなかった。また、推論時間が大幅に増えるので結局採用しなかった。

結果

最後まで公開notebookを超えられなかったので諦めていたがshake up?してぎりぎり銅圏内に入ることができました!

上位ソリューション

公開してくれているソリューションをざっとまとめて見ました。
自分が読み間違えている部分もあると思うので間違っていたらぜひ教えて下さい。

1st (https://www.kaggle.com/c/birdsong-recognition/discussion/183208)

  • based on sound event detection (SED) model
  • no external data
  • data augmentation
    • pink noise
    • gaussian noise
    • gaussian snr
    • gain (volume adjustment)
  • model
    • sedでバックボーンをdensenet121
      • パラメータを減らして過学習を防ぐ
    • attentionのclamptanh
  • training
    • mixup
    • specAugmetation
    • trainining on 30 second clip
    • bce loss & secondary label
  • threshold
    • framewise_output, clipwise_outputともに0.3
    • framewise, clipwiseともに現れた鳥を予測に
    • specAugした10TTA
  • CV vs LB
    • local CVできなかったのでLBを参考にした
  • ensemble
    • LB scoreを参考に13modelの4vote

2nd (https://www.kaggle.com/c/birdsong-recognition/discussion/183269)

  • 約20000の音声ファイルから手動で鳴き声が現れてない部分を削除した(!)
  • powerを0.5から3に。0.5だと背景ノイズが鳴き声に近くなる
  • 異なる背景ノイズをミックス
  • 0.5の確率でupper frequencyを減らした
  • 6 model ensemble

3rd (https://www.kaggle.com/c/birdsong-recognition/discussion/183199)

  • aggressiveなdata augmetation
    • gaussian noise with a sound to noise ratio up to 0.5
    • background noise
      • 鳴き声が含まれていない背景音をミックス
    • modified mixup
      • 通常のmixupと変えてラベルをそのままの強さで
        • 両方とも正しいラベルを予測させる
    • random clopではなくout-of-foldで正解予測確率が高い部分をクリップ
  • model
    • 4 model ensemble
      • resnext, resnest and external data
  • 後処理
    • しきい値0.5
    • 前後の窓のscoreも集約して確率の上位を答えとする
      • site 1,2は上位3つ
      • site3は音の長さによって

4th (https://www.kaggle.com/c/birdsong-recognition/discussion/183339)

  • use external data
  • 音の強さで鳴き声が現れていそうな部分をクロップ
  • feature extraction
    • パラメータを変えたlogmelの3チャンネル
    • secondary labels
  • model
    • efficientnetB3~B5
    • multi-sample dropout
  • training
    • data aug
      • gain
      • background noise
      • low frequency cutoff
    • mixup
  • 4 model ensemble

5th (https://www.kaggle.com/c/birdsong-recognition/discussion/183300)

  • sed model
  • data aug
    • backgrounds (all from external data thread)
    • pink and brown noise
    • pitch shift
    • low pass filtering
    • spec augments (time and frequency masking)
  • energy base crop & label smoothing (secondary label)
  • longer input (10~30sec)

反省・所感

上位のソリューションを見るにかなりアグレッシブなDataAugmentationをかけているものが多いですね。
discussionでtrainとtestのSNRがかなり違うことが議論されていたので学習データに対して大きなノイズを加えてることで対応していたようです。
自分も外部ファイルからbackground noiseを加えたりpink noiseやbrown noiseを入れたりといろいろとやれたなあと思いました。(pink noiseが効くことはdiscussionにてホストが発言していたことに気づかなかった。。)
SpecAugmentも忘れていました。。

また学習データから如何に鳥の鳴き声をクロップするかが重要だったようです。
普通にやると1ファイルからランダムに5秒クロップする方法になるんですが上位ソリューションは頑張って鳴き声が含まれてそうな部分をクロップする処理をしています。
SEDを使う場合もPeriodを10〜30秒で長くとることで鳴き声が含まれない部分をクロップしてしまうリスクを減らす効果があるようです。

やはりデータを自分の耳で聞くことは重要ですね。画像コンペのときもそうですが自分でしっかりデータを確認することの重要性を再確認しました。


最後までパブリックのnotebookスコアを超えられなくて苦しかったですが最終的に銅が取れてよかったです。
コンペの内容も面白く、音声データの扱いもかなり勉強になったので参加してよかったなと思います。

今後も面白そうなコンペがあれば参加していきたいです。

Pytorchを使っているときにlossがnanになったときに確認すること

pytorchでモデルを学習させているときにlossがnanになって時間を結構溶かしたのでメモ。

BCEではなくBCEWithLogitsLossを使っているか

2値分類問題で自分で最終レイヤをいじったモデルはnanにならなくてOSSのモデル実装を使うとなぜかnanになる現象にぶちあった。
自分が定義したモデルをよくよく見るとforward関数の最後でsigmoid関数に通しており、BCEロスを使って学習していた。
しかし、OSSの実装はforwardの最後でsigmoidを通しておらず、自分はその状態でBCEを使って学習させていた。
どうやらこれがロスがnanになってしまった原因だった。
実際にBCEWithLogitsLossを使って学習するように変更したところロスがnanにならなくなった。

PytorchでSOTAモデルの実装を集めたGithubレポジトリは最後にsigmoidを通していない場合が多い気がするので基本BCEWithLogitsLossを使ったほうがいいかもしれない。(本来はちゃんとコードを読んで実装を確認するべき)
また、

公式のページでもBCEよりBCEWithLogitsLossのほうが数値計算的に安定しているらしいので基本はBCEWithLogitsLossを使う方針でよいと思う。
ただし、その場合はpredictの際には自分でモデルの出力をsigmoid関数に通すことを忘れずに。

pytorch.org

今回自分が陥ったケースではないがロスがnanになる原因として、入力にnanが含まれている、正規化していない等の問題が考えられる。

Github + Amazon ECR + CircleCIで独自のdocker image上でpytestを自動実行する

はじめに

CI/CD環境を構築するためにしばしばJenkinsが使われますが、Jenkinsは運用・メンテナンスが面倒だったりGUIベースの設定なのでノウハウが属人化してしまいがちであるという課題があります。

そこで、SaaSを使ったモダンなCI/CD環境を作りたいという思いから今回Github + Amazon ECR + CircleCIを連携したpytestの自動テストの仕組みを作ったので、備忘録として手順を残しておきます。

ビルドパイプライン概要

今回 GithubAmazon ECR、CircleCIを組み合わせて以下のようなパイプラインを構築してみました。

f:id:rikeiin:20191226155127p:plain
ビルドパイプライン

それぞれについて解説していきます。

CircleCIでdocker imageを自動で作成し、Amazon ECRにプッシュする

パイプライン図の①②③では、Githubにコミットがプッシュされたことを検知してCircleCIがDocker imageのビルドを開始します。ビルドが完了すると作成されたイメージをAmazon ECRにプッシュします。

CircleCI

CircleCIは簡単に言うとSaaS型のCI/CDサービスです。
Jenkinsと比べるとCircleCIはSaaSなので自分で運用・構築する必要がない、YAMLファイルで設定するので設定が職人芸化しないなどのメリットがあります。
無料枠もあるので開発規模によっては無料でも全く問題なく使えるます。

circleci.com

Amazon ECR

Amazon Elastic Container RepositoryはAWSの完全マネージドのコンテナレポジトリサービスです。
Docker hubのプライベート用みたいの感じで、Docker imageの保存・管理等が簡単にできるようになります。
GCPやAzureにも同様のサービスがありますが、私は開発環境をAWSで作っているので今回ECRを採用しました。

CircleCIとECRの連携

基本的には以下の記事と同様の方法で連携できます。

www.seeds-std.co.jp

まずはAWS上でECRの作成とCircleCIからECRを操作するためのIAMユーザの作成をします。
IAMユーザの権限として"AmazonEC2ContainerRegistryFullAccess"を付与しておきます。

次にCircleCIとGithubの連携を行います。
CircleCIにアクセスして自分のGithubアカウントでログインします。
ログインすると自分のGithubレポジトリの一覧が出てくるのでCIを動かしたいレポジトリをフォローします。これで連携の準備ができました。

次にCircleCIのジョブの設定を行います。CircleCIのジョブの画面よりEnvironment Variablesを開き、ジョブ実行に必要な環境変数をセットします。

  • AWS_ACCOUNT_ID(AWSのアカウント番号)
  • AWS_ACCESS_KEY_ID(circleciユーザのアクセスキー)
  • AWS_SECRET_ACCESS_KEY(circleciユーザのシークレットアクセスキー)
  • AWS_REGION(例:ap-northeast-1)
  • AWS_ECR_ACCOUNT_URL(ECRのリポジトリURL XXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/)
  • AWS_RESOURCE_NAME_PREFIX(ECRのリポジトリ名)

GitHubにプッシュされた場合にCircleCIの動作を制御するための設定ファイルを用意します。
.circleciというフォルダを作成し、その中にconfig.ymlというファイルを作成します。
config.ymlでCircleCIの動作を制御するのですが、ECRリポジトリへのアップロードするためのOrbs(※ジョブ、コマンドなどの設定要素をまとめた共有可能なパッケージのこと)をCircleCIが公式に提供しています。
これらを利用してGitHubにプッシュされた場合、DocerkイメージをビルドしてECRにアップロードするといった内容のconfig.ymlを作成します。

https://circleci.com/orbs/registry/orb/circleci/aws-ecr?version=6.1.0


config.yml

version: 2.1
orbs:
  aws-ecr: circleci/aws-ecr@6.1.0

jobs:
  build-and-push-image:
    executor:
      name: aws-ecr/default
    steps:
      - aws-ecr/build-and-push-image:
          account-url: AWS_ECR_ACCOUNT_URL
          repo: "${AWS_RESOURCE_NAME_PREFIX}"
          region: AWS_REGION
          tag: "${CIRCLE_SHA1}"

workflows:
  version: 2
  build-and-push:
    jobs:
      - build-and-push-image:

作成したconfig.ymlをgithubにプッシュしてみましょう。
CircleCIのダッシュボード上からジョブが動いている様子が確認できます。
また、ビルドとプッシュが完了するとAWSのECR上に新しいイメージがあることが確認できるはずです。

作成したDocker imageをプルしてpytestを実行

それでは先程作成したイメージを使ってpytestを実行します。
設定は先程作成したconfig.ymlと合わせると以下のようになります。

version: 2.1
orbs:
  aws-ecr: circleci/aws-ecr@6.1.0

jobs:
  build-and-push-image:
    executor:
      name: aws-ecr/default
    steps:
      - aws-ecr/build-and-push-image:
          account-url: AWS_ECR_ACCOUNT_URL
          repo: "${AWS_RESOURCE_NAME_PREFIX}"
          region: AWS_REGION
          tag: "${CIRCLE_SHA1}"


  test:
    docker:
      - image: "${AWS_ECR_ACCOUNT_URL}/${AWS_RESOURCE_NAME_PREFIX}:${CIRCLE_SHA1}"
    steps:
      - checkout
      - run:
          name: run tests
          command: |
            pytest -v .
workflows:
  version: 2
  build-and-push-and-test:
    jobs:
      - build-and-push-image:
      - test:
          requires:
            - build-and-push-image

Githubにpytestのファイルを作成してプッシュしてあげると、CircleCI上でpytestが自動で実行されることが確認できます。
Github上でもテスト結果が以下のように確認できます。

f:id:rikeiin:20191226170225p:plain

まとめ

今回Github + Amazon ECR + CircleCIを連携して独自のDocker imageを使ったpytestの自動実行の仕組みを構築しました。
CircleCIはJenkinsより設定が圧倒的に楽でいいですね。
今後はコンテナをデプロイしてE2Eテストも自動で行うような仕組みも作りたいなと思います。

アンサンブル学習 アルゴリズム入門 〜〜プルーニング〜〜

前回の記事では、決定木による機械学習プログラムを作成しました。

rikeiin.hatenablog.com

この決定木では、作成する木の深さに上限がなくパラメータの設定を深くすればするほど複雑なモデルが作成できます。なので、単に学習データをよく再現できるモデルを作成するという観点からは、決定木アルゴリズムはメモリと計算時間が許す限り、学習データを完全に再現できるモデルを作成できます。しかし、これだと学習データに過学習してしまい、学習していないデータに対する性能が低下してしまいます。そのため、いかに過学習を防止し、汎化誤差を少なくするかが問題となりますが、本記事では決定木アルゴリズムにおける過学習を防止するための手法であるプルーニングを実装してみます。

決定木のプルーニング

プルーニングとは

プルーニング(枝刈り)は木構造をしたデータに対して適用されるアルゴリズムです。
前記事で作成した決定木アルゴリズムでは、作成される木は常に葉まで同じ深さを持つ完全二分木と呼ばれるデータ構造をしていました。
しかし、このようなモデルで深い木を作成すると無駄な判断を行うノードが増えてしまうので過学習が起こりやすくなります。そこで一度深い深さで決定木を作成した後で不要な枝を削除することで、よりシンプルな決定木を作成する手法がとられます。この木構造のデータから不要な枝を削除することをプルーニングと呼びます。

f:id:rikeiin:20191022235901p:plain
深さが2で葉の数が4つある決定木からプルーニングによって葉Dが取り除かれる例

プルーニングのルール

プルーニングのアルゴリズムでは決定木を走査して削除すべき枝と残すべき枝を選択します。どの枝を削除するべきかの判断がプルーニングアルゴリズムのキモとなるのですが、最も単純なルールは「枝を削除しても結果が変わらないならばその枝を削除する」というものになります。すなわちその枝を削除する前後でモデルを実行した結果のスコアが悪化しないならばその枝を削除するということになります。プルーニングの際に使用するスコアの計算には決定木に対する学習データと同じデータを使用する方法と学習データを決定木の学習用とプルーニング用のテストデータに二分する方法があります 。

再帰関数によるプルーニング

上記のルールをプログラムで実装すると、決定木のノードをたどりながら、次の2つのケースでノード内の枝を削除すればプルーニングのアルゴリズムを実装することができあす。

  1. 学習データのすべてが左右のノードどちらか一方に振り分ける場合
  2. ノード内どちらかの枝を削除してもノード全体のスコアが悪化しない場合

図に表すと以下のようになります。

f:id:rikeiin:20191104152704p:plain

決定木のノードをたどるには再帰関数を仕様し、与えられたノードの枝を関数内で都度更新することでプルーニングを行います。
本記事で作成する再帰関数の基本的な構造は次のようになります。

def プルーニング関数(決定木内のノード, 学習データ):
    if 左右にデータが振り分けられるか:
        # 1つの枝のみの場合、その枝を置き換える
        return プルーニング関数(枝, 学習データ)
    # 再帰呼び出しで枝を辿る
    左の枝 = プルーニング関数(左の枝, 学習データ)
    右の枝 = プルーニング関数(右の枝, 学習データ)
    # 枝刈りを行う
    if 枝刈りを行うべき:
        return ノード内の残す方の枝
    return 決定木内のノード

関数の中では一つのノードを見て再帰的にそのノードの左右の枝を更新していきます。再帰関数は枝刈り行う場合は残された枝を、そうでない場合は現在のノードを返します。そして再帰的に呼び出される関数の戻り値でノートの枝を更新することで、不要な枝を削除して行きます。

プルーニングの実装

実際にプルーニングを行うアルゴリズムを実装していきます。
ここでは、前記事で作成した決定木と同じ構造を持つPrunedTreeというクラスがあるものとして再帰関数を実装していきます。

スコアによる判定

再帰関数の名前はreducederrorとして、引数として決定木内の現在のノードを表すnode, 学習データと正解データを表すxとyを引数にとります。
まずはreducederror関数のひな形として、ノードが葉でないことを確認して、現在のノードを返す関数を実装します。

def reducederror(node, x, y):
    # ノードが葉でなかったら
    if isinstance(node, PrunedTree):
        # ここにプルーニングの処理が入る
        
    # 現在のノードを返す
    return node

次に上記の「# ここにプルーニングの処理が入る」という部分に学習データに対するノードのスコアと左右の端のスコアを計算する部分を作成します。
この関数では決定木の種類がクラス分類であるかは回帰であるかによって処理を分けますが、それは目的変数の次元数で判断できます。
クラス分類の場合、ノードそれ自体と左右の葉に対してpredict関数を呼び出した結果から、間違いの個数をカウントします。
回帰の場合は、同様に正解データとの二乗誤差を作成して、その値をスコアとします。
その後、枝を削除してもスコアが悪化しないようならスコアが良い方の枝を返すようにします。

# ここに枝刈りのコードが入る
# calculate score with train data
p1 = node.predict(x)
p2 = node.left.predict(x)
p3 = node.right.predict(x)
# if classification
if y.shape[1] > 1:
    # socre as a number of misclassifications
    ya = y.argmax(axis=1)
    d1 = np.sum(p1.argmax(axis=1) != ya)
    d2 = np.sum(p2.argmax(axis=1) != ya)
    d3 = np.sum(p3.argmax(axis=1) != ya)
else:
    # score as mean squared error
    d1 = np.mean((p1 - y) ** 2)
    d2 = np.mean((p2 - y) ** 2)
    d3 = np.mean((p3 - y) ** 2)

if d2 <= d1 or d3 <= d1:  # score is not worse with which left or right
    # return node with better score
    if d2 < d3:
        return node.left
    else:
        return node.right

決定木のプルーニング

上記の「# ここに枝刈りのコードが入る」という箇所ではノードに左右の枝が両方あるかどうかをチェックし、一つの枝のみにデータが振り分けられるのであれば、現在のノードをその枝で置き換えます。
そしてその後、関数を再帰的に呼び出して左右の枝を更新します。

feat = x[:, node.feat_index]
val = node.feat_val
l, r = node.make_split(feat, val)
if val is np.inf or len(r) == 0:
    return reducederror(node.left, x, y)
elif len(l) == 0:
    return reducederror(node.right, x, y)

# update the branch of right and left
node.left = reducederror(node.left, x[l], y[l])
node.right = reducederror(node.right, x[r], y[r])

以上で「Reduce Error」プルーニングのための再帰関数が完成します。

Critical Valueによるプルーニング

先ほど作成したReduce Errorプルーニングは、実際の実行結果をもとに処理を行うので、シンプルのアルゴリズムながら性能の良い結果を得ることができるという特徴があります。
一方でReduce Errorプルーニングは、プルーニングの際に木とその各枝に対して毎回決定木の実行を行われるため、処理時間の面で不利になるという欠点があります。決定木に対するプルーニングの処理については他にもさまざまなものがありますが、ここではプルーニングのアルゴリズムとして、もう一つ Critical Value プルーニングというアルゴリズムを実装してみます。

Critical Valueの概要

Critical Value プルーニングは決定木の学習時に使用した分割スコアを元にプルーニングの処理を行います。
決定木の学習ではすべてのノードにおいてMetrics関数の値から分割のスコアが一度求められていました。そこでプルーニングの処理を行う際には決定木全体の中での最も良い分割スコアを求め、その値をもとにある程度以下の値で分割されたノードを枝刈りすることでプルーニングの処理を行います。また削除するノードのしきい値は全ての枝のスコアから削除する枝の割合を指定することで求めます。
Critical Value プルーニングは決定木の分割のみを完成した時点で行うことができるので、必ずしも全ての葉を学習する必要はありません。葉の学習をプルーニングの後に行うことで深い階層の決定木を作成しても学習の時間が指数関数的に増加することを防ぐことができます。また決定木の分割が完成した時点でCritical Valueプルーニングを行い、さらに葉の学習が終了したらReduce Errorプルーニングを行うといったこともできます 。

f:id:rikeiin:20191109123744p:plain
Critical Valueプルーニングの例

Critical Valueの実装

それでは実際にCritical Valueを行うコードの実装をしていきます。先程と同じくpruning.pyの中に書いていきます。

全ノードのスコア

まずは決定木全体から分割時に使用した最も良いメトリクス関数の値を求めるために、すべてのノードのスコアを取得する関数を作成します。
前々回の記事でDecisionStumpクラスを作成した際に、分割で求めた良いメトリクス関数の値はそのノードの分割のスコアとしてスコア変数に保存しておきました。本記事で作成するPrunedTreeクラスもDecisionStumpクラスの派生クラスなので、同じくスコア変数から分割のスコアを取得することができます。ここで気をつけておくことは、このスコア変数が表すスコアは値が小さいほど良い値だという点です。全てのノードをたどるには先ほどと同じく再帰関数を使用し、引数に与えられたリストにノードのスコア変数の値を追加していきます。ここではgetscore()という名前ですべてのノードのスコアを取得する関数を作成します。

def getscore(node, score):
    if isinstance(node, PrunedTree):
        if node.score >= 0 and node.score is not np.inf:
            score.append(node.score)
        getscore(node.left, score)
        getscore(node.right, score)
Critical Valueを行う関数

次に、実際にプルーニングを行う関数criticalscore()を実装します。この関数も再帰関数として実装し、前節と同じ構造をしています。
今回は引数としてしきい値となるscore_maxが追加されています。

def criticalscore(node, score_max):
    if type(node) is PrunedTree:
        # pruning process
        # update the branch of right and left
        node.left = criticalscore(node.left, score_max)
        node.right = criticalscore(node.right, score_max)
        # delete node
        if node.score > score_max:
            leftisleaf = not isinstance(node.left, PrunedTree)
            rightisleaf = not isinstance(node.right, PrunedTree)
            # leave one leaf if both are leaf
            if leftisleaf and rightisleaf:
                return node.left
            # leave branch if which one is leaf
            elif leftisleaf and not rightisleaf:
                return node.right
            elif not leftisleaf and rightisleaf:
                return node.left
            # leave node with better score if both are branch
            elif node.left.score < node.right.score:
                return node.left
            else:
                return node.right

プルーニング用決定木クラスの実装

先程までは前記事で作成した決定木と同じ構造を持つPrunedTreeというクラスがあるものとして、再帰関数を実装していました。
ここではそのPrunedTreeクラスを実装していきます

プルーニング用の決定木の作成

PrunedTreeクラスは前記事で作成したDecisionTreeクラスの派生クラスとして作成します。本記事で使用するPrunedTreeクラスでは、クラス内の変数でプルーニング用の関数名を表すprunfncと、学習データとプルーニング用のテストデータを別にするかどうかを表すpruntestを作成します。ここでprunfnc変数は、文字列型で"reduce"または"critical"いずれかの値が入るものとします。またプルーニング用のテストデータを別にするかどうかを表すpruntest、 テストデータの割合を表すsplitratio、Critical Value プルーニングで使用するパーセンテージの変数であるcriticalも作成します。

class PrunedTree(DecisionTree):
    def __init__(self, prunfnc='critical', pruntest=False, splitratio=0.5, critical=0.8,
                 max_depth=5, metric=entropy.gini, leaf=ZeroRule, depth=1):
        super().__init__(max_depth=max_depth, metric=metric, leaf=leaf, depth=depth)
        self.prunfnc = prunfnc # プルーニング用関数
        self.pruntest = pruntest # プルーニング用にテストデータを取り分けるか
        self.splitratio = splitratio # プルーニング用テストデータの割合
        self.critical = critical # criticalプルーニング用のしきい値

新しいノードを作成するためのget_node()も次のようにオーバーライドしておきます。

def get_node(self):
    return PrunedTree(prunfnc=self.prunfnc, pruntest=self.pruntest, splitratio=self.splitratio, critical=self.critical,
                        max_depth=self.max_depth, metric=self.metric, leaf=self.leaf, depth=self.depth + 1)

次に学習を行うfit()関数をオーバーライドします。 fit()関数内ではまず根のノードとそうでない時で処理が異なり、根のノードの時のみプルーニング用の処理が行われます。これはPrunedTreeクラスは決定木内のノードの一つを表すのに対して、プルーニング用の関数が学習後の決定木に対して再帰的に呼び出されるためです。PrunedTreeクラスで根のノードの時には、まずプルーニングの際に枝の削除を行うかどうかを判断するためのテストデータを用意します。テストデータはself.pruntestがTrueであれば学習データからランダムにself.splitratioで指定された割合のデータをテストデータとして取り分けます。self.pruntestがFalseの場合は学習データと同じデータを使用してプルーニングの処理を行います。

def fit(self, x, y):
    # if depth=1, root node
    if self.depth == 1 and self.prunfnc is not None:
        # data for pruning
        x_t, y_t = x, y

        if self.pruntest:
            n_test = int(round(len(x) * self.splitratio))
            n_idx = np.random.permutation(len(x))
            tmpx = x[n_idx[n_test:]]
            tmpy = y[n_idx[n_test:]]
            x_t = x[n_idx[:n_test]]
            y_t = y[n_idx[:n_test]]
            x = tmpx
            y = tmpy

        # ここで決定木の学習を行う

        return self
            

上記の「 # ここで決定木の学習を行う」の部分には以下のコードが入ります。前記事でのDecisionTreeの学習アルゴリズムとほぼ同じですが、Critical Valueプルーニングの場合は葉の学習は行わず、木の分割のみ学習します。

# 決定木の学習
self.left = self.leaf()
self.right = self.leaf()
left, right = self.split_tree(x, y)
if self.depth < self.max_depth:
    self.left = self.get_node()
    self.right = self.get_node()
if self.depth < self.max_depth or self.prunfnc != 'critical':
    if len(left) > 0:
        self.left.fit(x[left], y[left])
    if len(right) > 0:
        self.right.fit(x[right], y[right])

# ここでプルーニングの処理を行う。
            

上記の「# ここでプルーニングの処理を行う」には根のノードの時にself.prunfncに入っている関数の名前から、再帰関数を呼び出してプルーニングの処理を行います。Reduce Errorプルーニングの場合、再帰関数の引数には自分自身のインスタンスとプルーニング用のテストデータを渡してreducederror()関数を呼び出します。また Critical Value プルーニングの場合、まずgetscore()関数ですべてのノードの分割の際のスコアを取得し、その数からパラメーターで指定された割合を求め、しきい値となる値を計算します。そしてそのしきい値を引数にcriticalscore()関数呼び出し、プルーニングの処理を行った後、学習させていなかった葉について改めて学習を行います。葉のみ学習を行う関数はfit_leaf()という名前で作成します。

# pruning process
# only whene depth = 1, root node
if self.depth == 1 and self.prunfnc is not None:
    if self.prunfnc == 'reduce':
        reducederror(self, x_t, y_t)
    elif self.prunfnc == 'critical':
        # get score of metrics function when training
        score = []
        getscore(self, score)
        if len(score) > 0:
            # calculate max score of branch left
            i = int(round(len(score) * self.critical))
            score_max = sorted(score)[min(i, len(score) - 1)]
            # pruning
            criticalscore(self, score_max)

        # learn leaf
        self.fit_leaf(x, y)
            

fit_leaf()関数は、すでに作成されている枝に従ってデータを分割し、枝の指しているノードが葉であればfit()関数を呼び出します。

def fit_leaf(self, x, y):
    feat = x[:, self.feat_index]
    val = self.feat_val
    l, r = self.make_split(feat, val)

    # learn only leaf
    if len(l) > 0:
        if isinstance(self.left, PrunedTree):
            self.left.fit_leaf(x[l], y[l])
        else:
            self.left.fit(x[l], y[l])
    if len(r) > 0:
        if isinstance(self.right, PrunedTree):
            self.right.fit_leaf(x[r], y[r])
        else:
            self.right.fit(x[r], y[r])
            

以上でPrunedTreeの実装ができました。
検証用データに対して学習して評価するコードは下記にあるので参考にしてみてください。

ensumble-learning-introduction/pruning.py at master · wdy06/ensumble-learning-introduction · GitHub

参考文献

作ってわかる! アンサンブル学習アルゴリズム入門

作ってわかる! アンサンブル学習アルゴリズム入門

kubernetesでsshできるpodを作る

普段自分は機械学習の研究用途でkubernetes (k8s)上のクラスターでjupyterlabを立ててそこでコーディングをしています。
なんですが、jupyterlabだと補完がいまいちだったり、pyファイルの編集においては補完が全く効かなかったりで何かいい方法はないか模索していたんですが、最近VSCodeでremote serverに接続して開発できる機能が追加されました。

code.visualstudio.com

crieit.net

この機能を使うことで、リモートのサーバにsshしてファイルを編集できますし、pythonインタープリタもサーバの環境を使えます。
つまり、dockerで機械学習ディープラーニング用の環境を作ってGPUが乗ったサーバでコンテナを立ててしまえば、Pytorchやkerasの補完がモリモリ効いたままコーディングができて実行もすぐできて超絶便利になりそうです。

単なるDockerでsshできるコンテナを建てることは簡単なんですが、Kubernetes上でやろうとすると少しハマったのでその備忘録を残しておきます。

ちなみに、単なるDockerでsshできるコンテナを建てるには以下の方法で簡単にできます。

docs.docker.jp

上記と同様のパスワード認証の方法でk8sにpodを立ててsshしようとしてもPermission Deny(Publickey, password)でアクセスできませんでした。

いろいろ調査したところ、鍵認証の方法ならsshに成功しました。
基本的なやり方は下記と同じです。

GitHub - ruediste/docker-sshd: Dockerized SSH server prepared for Kubernetes

まず、下記のようなDockerfileでssh用imageを作って適当なrepositoryにpush しておきます。

FROM ubuntu:16.04

RUN apt-get update
RUN apt-get install -y openssh-server
RUN mkdir /var/run/sshd

RUN apt-get install -y less curl iputils-ping

RUN sed -ri 's/^PermitRootLogin\s+.*/PermitRootLogin yes/' /etc/ssh/sshd_config \
&& sed -ri 's/UsePAM yes/#UsePAM yes/g' /etc/ssh/sshd_config \
&& sed -ri 's/^StrictModes\s+.*/StrictModes no/' /etc/ssh/sshd_config \
&& sed -ri 's/HostKey \/etc\/ssh\//HostKey \/etc\/ssh\/hostKeys\//g' /etc/ssh/sshd_config \
&& echo "PasswordAuthentication no" >> /etc/ssh/sshd_config \
&& echo "GatewayPorts yes" >> /etc/ssh/sshd_config

RUN rm /etc/ssh/ssh_host*

EXPOSE 22

CMD ["/usr/sbin/sshd", "-De"]

次に、ホスト鍵関連のファイルを生成するgenerateHostKeys.shのようなシェルスクリプトを作って実行します。

#!/bin/bash
rm -rf hostKeys
mkdir hostKeys
ssh-keygen -q -t rsa  -f hostKeys/ssh_host_rsa_key -N "" -C ""
ssh-keygen -q -t dsa  -f hostKeys/ssh_host_dsa_key -N "" -C ""
ssh-keygen -q -t ecdsa  -f hostKeys/ssh_host_ecdsa_key -N "" -C ""
ssh-keygen -q -t ed25519  -f hostKeys/ssh_host_ed25519_key -N "" -C ""
kubectl --namespace default delete secret ssh-host-keys
kubectl --namespace default create secret generic ssh-host-keys \
--from-file=hostKeys/ssh_host_rsa_key \
--from-file=hostKeys/ssh_host_rsa_key.pub \
--from-file=hostKeys/ssh_host_dsa_key \
--from-file=hostKeys/ssh_host_dsa_key.pub \
--from-file=hostKeys/ssh_host_ecdsa_key \
--from-file=hostKeys/ssh_host_ecdsa_key.pub \
--from-file=hostKeys/ssh_host_ed25519_key \
--from-file=hostKeys/ssh_host_ed25519_key.pub

また、ユーザ認証用の公開鍵をauthorized_keysとして保存しておきます。

次に、先ほどつくったホスト鍵ファイルたちとauthorized_keysをk8s上でセキュアに扱うためにSecretsを使います。
k8sのSecretsに関しては下記のサイトがわかりやすかったです。

ubiteku.oinker.me

上で作ったファイルたちをSecretsとして登録するUpdateKeys.shを作って実行します。

#!/bin/sh
# Update the keys stored in ssh.yaml from the authorized-keys file
kubectl --namespace default delete secret ssh-keys
kubectl --namespace default create secret generic ssh-keys --from-file=./authorized_keys

あとはk8ssshサーバを立てるマニフェストを書いて下記の設定を追加した上でpodを立ててあげればOKです。

volumeMounts:
    - mountPath: /root/.ssh/
        name: ssh-dir
      - mountPath: /etc/ssh/hostKeys/
        name: ssh-host-keys

volumes:
  - name: ssh-dir
    secret:
      secretName: ssh-keys
      defaultMode: 0600
  - name: ssh-host-keys
    secret:
      secretName: ssh-host-keys
      defaultMode: 0600

あとは普通にsshできます。

$ ssh root@<PodのIPアドレス>

アンサンブル学習 アルゴリズム入門 〜〜決定木その2〜〜

前回記事からの続きになります。
rikeiin.hatenablog.com

前回の記事で作成したDecisionStumpは、深さが1の決定木でしたが、同じ方法によるデータの分割を再帰的に行えば、より深い階層を持つ決定木が作成できます。

この記事では先ほど作成したDecisionStumpの応用として、木の深さを指定できる決定木アルゴリズムを作成します。
深さが可変の決定木アルゴリズムはDecisionTreeという名前のクラスとして作成します。このクラスは先ほど作成したDecisionStumpクラスの派生クラスとして作成することで、決定木アルゴリズムに必要となる木分割の関数をDecisionStumpクラスから継承して利用できるようにします。
まずはdtree.pyという名前のファイルを作成し次のクラスを作成します。このクラスには学習させる決定木の深さを表すmax_depth変数と再帰的に生み出される際に使用する現在のノードの深さを表すdepth変数を作成します。

import numpy as np
import support
import entropy
from zeror import ZeroRule
from linear import Linear
from dstump import DecisionStump

class DecisionTree(DecisionStump):
    def __init__(self, max_depth=5, metric=entropy.gini, leaf=ZeroRule, depth=1):
        super().__init__(metric=metric, leaf=leaf)
        self.max_depth = max_depth
        self.depth = depth

このDecisionStumpは決定木内の一つのノードを表しており、葉となるノード自分自身のクラスで置き換えることで、高さが可変の決定を作成します。
それには次のようにfit()をオーバーライドし、現在のノードの深さが最大深さに達していないならば左右の葉をget_node()から取得する新しいノードで置き換えます。
新しく子ノードとなるDecisionTreeでは引数のdepthに現在のノードの深さに1を加えた値を入れることで、現在のノードの深さを増やしていきます。
現在のノードの深さが最初に指定したmax_depthに達するとDecisionTreeクラスの動作はDecisionStumpと同じになり、左右の端に対して学習を行います。

def fit(self, x, y):
    # create leaf node of left and right
    self.left = self.leaf()
    self.right = self.leaf()
    # split data into left and right node
    left, right = self.split_tree(x, y)

    if self.depth < self.max_depth:
        if len(left) > 0:
            self.left = self.get_node()
        if len(right) > 0:
            self.right = self.get_node()

    # learn left and right node
    if len(left) > 0:
        self.left.fit(x[left], y[left])
    if len(right) > 0:
        self.right.fit(x[right], y[right])
    return self

新しいノードを生成して返すget_node()は以下の様になります。

def get_node(self):
    return DecisionTree(max_depth=self.max_depth, metric=self.metric,
                        leaf=self.leaf, depth=self.depth + 1)

DecisionTreeでは、推論を行うpredict()は親クラスのDecisionTreeがそのまま使えます。

以上でDecisionTreeの実装ができました。
検証用データに対して学習して評価するコードは下記にあるので参考にしてみてください。

dtree.py全体のコードはこちら

次の記事ではプルーニングのアルゴリズムを実装していきます。
rikeiin.hatenablog.com

参考文献

作ってわかる! アンサンブル学習アルゴリズム入門

作ってわかる! アンサンブル学習アルゴリズム入門