パッケージを作成してpypiにアップロード(2020/06)
作成したライブラリをpypiに公開しようとしたが情報過多で迷子になった。
やるべきことをトップダウンに書き出せば後世の人々が救われる気がしたのでメモを残す。
困ったことがあれば公式のチュートリアルを見るのが一番。
- ライブラリの準備
必要なファイルの準備
- setup.pyを作成
- setup.cfgの作成
- LICENCEファイルの準備
- READMEの準備
配布物の作成
- sdistの準備
- bdist, wheelの準備
公開の準備
- pypiに登録
- 配布物のアップロード
ライブラリの準備
以下3つのライブラリが最低限必要 * setuptools * wheel * twine 脳死で以下のコマンドを実行すればOK。
python -m pip install setuptools wheel twine
必要なファイルの準備
LICENCEやREADMEなどは正直用意して配置するだけなので取り立てての説明は必要に無いと思うが、setup.pyとsetup.cfgに関しては歴史的な経緯で見るサイトによって様々な方法が書いてあるように思う。2020年6月の時点ではsetup.pyは最低限にして、setup.cfgに必要な情報を記載していくのがいいようだ。
setup.py
かつてのdistutilやsetuptoolsではsetup.py内に必要なメタデータをすべて記載しなければならなかったが、setuptoolsのバージョン30あたりを皮切りに自動でsetup.cfgに記載されているデータを読み込んでくれるようになったようだ。なのでsetup.pyは以下のように最低限必要な内容(3行)にして、静的な情報はsetup.cfgに記載するのが管理しやすいように思う。素人なのでよくわからないがインストール時に動的に変更したい内容などがある場合にはsetup.pyに記載するのがいいのだろうか。
#!/usr/bin/env python from setuptools import setup setup()
setup.cfg
[metadata] name = project name version = 0.0.1 author = Taro Tanaka author_email = example@example.com description = project short description long_description = file:README.md long_description_content_type = text/markdown url = https://mikebird28.hatenablog.jp/ licence = file: LICENCE python_requires = >=3.5 classifiers = Development Status :: 2 - Pre-Alpha License :: OSI Approved :: MIT License Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9
上みたいな感じで必要な情報を記載していけばいい。long_descriptionやlicenceなど情報量が多くなりそうなところは、file: という表記を使って外部ファイルを読み込むことができる。分かりづらいのはclasifiersとlong_descriptionだろうか。
classifiersに指定した情報はユーザーがpypi内で検索するときなどに使われる。Classifiers · PyPIに使用可能なclassifier一覧があるのでここからコピーして貼り付けて行けばいい。種類がありすぎてざっとしか目を通せなかったが、開発状況(アルファ、ベータなど)、自然言語(日本語、英語など)、プログラミング言語(pythonのバージョンだけでなく、CやRなどの他の言語も指定できる)、ライセンス、その他トピックなどが指定できるようだ。
long_descriptionには直接setup.cfgに記載してもfile:でREADMEを読み込んでもどちらでも大丈夫だが、MARKDOWNファイルの場合にはlong_description_content_typeでファイルの形式を明示的に指定する必要がある。
README.md
README。気合入れて書く。この記事参考にして頑張った。 deeeet.com
LICENCE
ライセンスを選ぶ。GPL、BSD、MIT等々好きなやつを選んでLICENCE.txtに記載する。
配布物の準備
これまでの作業で必要なファイルの準備が整ったので、公開に向けてパッケージを配布可能な形式に変換する。pypiでライブラリを公開するには以下2種類のアーカイブを用意する必要がある
- sdist - 必要なソースコードをそのままアーカイブしたもの
- bdist(wheel) - ソースコードをビルドしてすぐに実行可能な状態でアーカイブしたもの PEP427で定義されているので詳細は⇒ https://www.python.org/dev/peps/pep-0427/
これらのファイルをpypiにアップロードすると現在のpipではwheel形式を優先的にインストールする。
sdist
python setup.py sdist
bdist
python3 setup.py bdist_wheel
dist/以下にアーカイブされた配布用ファイルが配置されている。
- dist/package_name.whl
- dist/package_name.tar.gz
最悪sdistのみで配布して、ユーザー側でインストールするときにビルドしてもらえば最悪大丈夫?
ただwheel形式で配布することで不利益を被ることは無さそうなので、作成しない選択肢は無さそう。
pypiへの登録
pypiは本番環境とテスト環境の2つがあるので、それぞれアカウントを作成する。
- 本番環境 -
- テスト環境 - https://test.pypi.org/ アカウントの登録が終わったら認証用APIトークンを生成する。APIトークンの作成時にスコープをALLにすると警告がでるが、ALLにしないとプロジェクトの作成ができないので最初はALLにしておく(後で変えたほうがいいとは思う)。生成したトークンはホームディレクトリ以下の.pypircに保存する。以下のサイトのようにクラウドサービスのファイルにシンボリックリンク貼るのが楽。 https://qiita.com/shinichi-takii/items/e90dcf7550ef13b047b5
アカウントの準備が整ったらtwineを使ってテスト環境にパッケージをアップロードする
テスト環境へのアップロード
python -m twine upload --repository testpypi dist/*
本番環境へのアップロード
python3 -m twine upload --repository pypi dist/*
最後にpypiにログインして正しくアップロードができているかを確認すれば終了。 ちなみに今回自分が作成したパッケージは↓なので興味があれば見てみてください。現時点では使い物にならないです。 pypi.org
参考文献
Python パッケージングの標準を知ろう - Tech Blog - Recruit Lifestyle Engineer
過去に計算したDataFrameと内容が同じか高速に判定したい
同じ前処理を実行するたびに数十分取られるのが非常に辛く感じる。 特に同じ入力を入れているのに実行するたびに計算をしなおすのが馬鹿らしく感じる。 かといってDataFrameを丸々キャッシュして、関数呼び出すたびに前回のDataFrameと同じか検証するものアホらしい。
もやもやしつつfeatherとhashでうまいことやれば高速に検証できね?って思ったので頑張ってみた。
コンセプトは以下の通り 1. featherを使ってDataFrameをbytesに変換 2. 高速なハッシュアルゴリズムxxhashを使ってbytesのハッシュ値を計算。 3. 初回はハッシュ値とDataFrameを何かしらの形でファイルに保存する。 4. 2回目以降はハッシュ値を計算して、前回のハッシュ値と同じだったら保存されている計算結果を読み込み。
pyarrowを使うとfeatherの出力先にfile-like objectが使えるので、シリアライズしたDataFrameをメモリ上(io.BytesIO)に吐き出せて良い。
以下実装
import io import pyarrow.feather import xxhash import os import gc import pandas as pd def get_hash(df): fp = io.BytesIO() pyarrow.feather.write_feather(df, fp) b = fp.getvalue() fp.close() h = xxhash.xxh64(b).hexdigest() return h def dfcacher(func, df, name, path="./", verbose=False): h = get_hash(df) h_path = os.path.join(path, name+".xxhash") f_path = os.path.join(path, name+".feather") # 前回のハッシュ値を読み込み try: with open(h_path, "r") as fp: saved_h = fp.read().rstrip('\n') except FileNotFoundError: saved_h = "" if h == saved_h: # 前回とハッシュ値が変わらないのであれば計算結果をキャッシュから読み込み if verbose: print(f"Hash value of {name} is not changed. Load dataframe from cache.") del(df) gc.collect() df = pyarrow.feather.read_feather(f_path) else: if verbose: print(f"Hash value of {name} is changed.") # ハッシュ値が変わっていれば計算して計算結果を保存 df = func(df) with open(h_path, "w") as fp: fp.write(h) df.to_feather(f_path) return df # テストケース1 df1 = pd.DataFrame([[1, 2, 3], [4, 5, 6]]) df2 = pd.DataFrame([[1, 2, 3], [4, 5, 6]]) assert get_hash(df1) == get_hash(df2), "ハッシュ値が異なります" # テストケース2 df1 = pd.DataFrame([[1, 2, 3], [4, 5, 6]]) df2 = pd.DataFrame([[1, 2, 4], [4, 5, 6]]) assert get_hash(df1) != get_hash(df2), "ハッシュ値が等しいです" # テストケース3 def test_func1(df): df = df.apply(lambda x: x+2) return df df1 = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=["c1", "c2", "c3"]) df2 = pd.DataFrame([[1, 2, 4], [4, 5, 6]], columns=["c1", "c2", "c3"]) df = dfcacher(test_func1, df1, "test_func1", verbose=True) df = dfcacher(test_func1, df1, "test_func1", verbose=True) df = dfcacher(test_func1, df2, "test_func1", verbose=True)
問題点としては高速にシリアライズするためにfeatherを使ってるので、DataFrameがfeatherフォーマットの条件を満たしてないとうまく動かない。 あと余りしっかり検証していないこと。まだ実際に使ってないので使ってる途中になにか問題が出てきそうな気がする。 ただ体感としてhash値の計算はかなり高速に動作している。今度なにか機会があったら速度の検証をやろうと思っている。
深層学習+Hough変換で紙の領域検出
kaggleやるよ何て名前をつけておいて1年半くらいkaggleやってませんでした。
その間何やってたかって言うとAI使ったWebサービスを作ろうとして挫折してを繰り返してました。 今のところ何の生産物も無いっていう悲惨な状況なんですけど、しょうもないノウハウ達は手に入れたのでせめてもの手向けとして公開しようかなと思う。
① 紙の領域検出
画像の中に含まれる領域を検出するメジャーな方法としてはCanny特徴量でエッジ検出+Hough変換で直線の検出っていう組み合わせがメジャーだと思います。 ただCanny特徴量は紙以外の物体が入っていたり紙面に色々描かれていたりすると、余分なエッジまでも検出しちゃってその後の工程が失敗しちゃう可能性があったりします。 なので深層学習とかで都合よく検出できたりしないかなーって探していたら、偉大なDropboxが技術ブログを公開してました。
今のChromeには素晴らしい翻訳機能があるので紹介するまでもないとは思いますが、彼らがやってることとしては、
- 何らかの深層学習の方法(各ピクセルのエッジらしさを0~1の小数で返す)でエッジを検出
- 検出したエッジに対してHough変換で直線を検出
- 得られた直線から構成可能な四角形の候補をリストアップ
- DNNの出力(0~1の実数)を四角形の辺に沿って平均してスコアとする。
- スコアの最も高かった四角形を、紙の領域として提案
というような感じでした。 ただ肝心な「何からの深層学習の方法」ということが示されていなかったので適当にCNNを使ったエッジ検出方法を見ていたら何個か見つかりました。
HED(Hostically-Nested Edge Detection)
画像をCNNで畳み込んで行き、各レイヤーの出力をSide Layerと呼ばれる層で逆畳み込みしてまとめて結合して出力という方法。画像中に含まれるエッジのピクセルの数は非エッジのピクセルに比べて圧倒的に少ないので、エッジピクセルの数に応じて誤差関数を重み付けしたりエッジ検出に特化した工夫がなされている。詳細は参考URLを見てください。この手法のメリットはVGG16を転移学習して使うので学習コストが軽い点だと思います。逆にデメリットはネットワークの構造的に画像の情報を余り深く理解できていなさそうなとこ。ただ紙の認識では雑に紙のエッジを検出してくれれば大丈夫なので、そんなデメリットは気にしなくていい気もします。高みを目指したい人はネットワークの構造をM2DetみたいにPyramid構造とかに変更したら精度あがるんじゃないかと思う(妄想)。
論文URL: https://arxiv.org/abs/1504.06375
参考URL: http://blog.livedoor.jp/tmako123-programming/archives/51956636.html
CASENet
HEDは画像の各ピクセルに対してエッジかそうでないかを学習する手法でしたが、CASENetはラベルが付けられた複数種類のエッジを検出するための手法です(Semantic Edge Detection)。HEDは二値分類問題を前提としているので、複数種類の矩形物体を検知したい場合にはこちらを検討することになると思います。自分では試してないので何とも得いませんが、論文を読む感じ基本的なネットワークのコンセプトはHEDに近しいものを感じます(当然細部は全く異っていますが)。 参考URL: https://arxiv.org/abs/1705.09759
HEDは2015年、CASENetは2017年の論文なので最新の論文を漁ればもっと良さげな手法もあるかと思いますが、とりあえず紙を検出するくらいであれば、この辺の手法で行けるのではと思います(CASENetの方は試してないので何とも言えませんが)。
試してみた
HEDの中間層を適当に変えたり出力層のチャネル数変えたり色々改造したもの(細かい条件は忘れた)を使って実際の紙を認識できるか試してみました。 Cannyは紙だけでなくペンのエッジも拾ってるのがわかります。 一方、HED使って予測したものはペンのエッジは検出されず、紙の輪郭だけをしっかり拾っているのが確認できます(ペンの部分を補完することはできていませんが)。今回はシンプルな画像で試しましたが色々写り込んでいてCannyでうまくエッジの矩形の検出ができないときは、深層学習+Hough変換でもいいかもねっていう記事でした。
Docker使って手軽にGCEで機械学習できる環境を整えた
最近、強化学習的なとこに手を出してみたけど手元のMacbook AirじゃメモリやCPU的に厳しいのでクラウドで実行することを検討した。 最初はAmazon EC2借りようかなーと考えていたけれど、Google Compute Engine(GCE) だとプリエンプティブという格安実行モードがあることを知り、GCEで実行することにした。プリエンプティブモードで実行すると、1日しかインスタンスを実行できない、突然インスタンスが停止するなどのデメリットがある代わりに、通常価格より50%以上安くインスタンスを利用できる。
しかし毎回ファイルを転送してsshで繋いで実行するのは正直面倒くさい。そこでDockerを使って効率的にプログラムをGCEで実行する環境を整えた。 具体的には機械学習用イメージのビルド→イメージのアップロード→コンテナの起動・実行をコマンドラインからささっと実行できるようにした。
環境構築にあたって以下の2つのサービスを使用した。
Google Container Registryは使用したネットワークとストレージ分だけ課金されるので、少しでも課金額を抑えたい場合には1レジストリだけ無料で使えるDocker Hubなどを使った方がいいかもしれない。
注意: 今回使用するスクリプトはGoogleのクラウドサービスを使用するので、当然のことながら課金が必要になります。特にコンテナの実行までシェルスクリプトで実行してしまうので、変な設定で実行すると青天井の請求が来る可能性があります。ここに載っている内容を実行する場合には自己責任で実行してください。
Step.0 前準備
gcloudのインストール
Google Compute Engineをコマンドラインから使うためにglcoud
をインストールする・
ここでは導入方法の説明は省くがGCPのgcloudコマンドをインストールする - Qiitaを参考にすればインストールできると思う。
プロジェクトの作成・Google Container RegistryのAPI有効化
Google Cloud Platformにアクセスして新しいプロジェクトを作成する。 左のサイドーからContainer Registryを選択してAPIを有効化する。(Google Container Registry以外のサービスを使う人、既に有効化している人はこの作業をする必要はありません)
スクリプトで使用する用の環境変数の設定
今回の説明ではプロジェクト名や、イメージの名前を環境変数に設定して使用する(スクリプトにベタ打ちしてGithubにアップしちゃうのが怖いので)。
CLOUDML_HOST_REGIONにはasia.gcr.io
、eu.gcr.io
、us.gcr.io
などから選べる(参考:Pushing and Pulling Images | Container Registry | Google Cloud)。CLOUDML_PROJECTIDには作成した自分のプロジェクトIDを、CLOUDML_IMAGE_NAMEには適当にイメージの名前を設定する。
export CLOUDML_HOST_REGION="REGION TO UPLOAD" export CLOUDML_PROJECT_ID="YOUR PROJECT ID" export CLOUDML_IMAGE_NAME="YOUR IMAGE NAME"
Step1. Dockerfileの準備
普通にDockerfileを作成する。今回はtensorflow、keras、lightgbmのCPU版をインストールしている。また実行するファイルをビルドするときに変えられるように実行ファイル名を変数として持たせている。注意点としてpythonの引数に-u
を指定しないと出力がフラッシュされなくて、Stack Driverにログが正しく表示されないのでちゃんと指定する。
Step2. アップロード用のスクリプトの準備
DockerfileからイメージをビルドしてGoogle Container Repositoryにアップロードするスクリプトを作る。
実行したいファイルを引数に渡すとそのファイルを実行するイメージをビルドしてくれる。main.pyというスクリプトを実行するイメージを作成したい場合には以下のように実行する。
./deploy.sh main.py
実行に成功すると最後にレポジトリの名前を出力してくれる。
Step3. 実行用のスクリプトの準備
使用するイメージの名前、使うインスタンスの名前を指定するとGCE上で実行してくれるスクリプトを作る。
細かいインスタンスの設定はgcloud beta compute instances create-with-container
の引数で行う。
今回は4コアCPU、メモリ20GB、ディスクサイズ50GB、プリエンプティブ有効の設定で実行した。
他の設定で実行したい場合には一度手動でインスタンスを作成して課金額を確認したあとに
https://cloud.google.com/sdk/gcloud/reference/beta/compute/instances/create-with-containerを参考にして引数を決定するのが良いと思う。
2018年11月4日現在gcloud beta compute instances create-with-container
は実行することができるが、ベータ版のコマンドなので今後のアップデートで実行できなくなる可能性がある。
実行してみる
今回作成したスクリプトを使用してkerasのmnistを実行してみる。今回作成したスクリプト類はGitHub - mikebird28/cloudml-templateに置いてある。環境変数などを設定したあとにdeploy.shを実行すると長々とビルドメッセージが流れたあと最後にレポジトリの名前を教えてくれる。
./deploy.sh main.py
deploy.shの実行が終わったら次のようにしてコンテナを実行する。今回はインスタンス名としてtest-instance1を指定したが、自分で実行するときは好きな名前を使用して問題ない。
./exec_container.sh test-instance1 レポジトリ名
コンテナの実行に成功するとインスタンス、IPアドレス、CPU数などの詳細が出力される。ブラウザ上でGCEのインスタンス一覧を確認するとコンテナが実行されていることが確認できる。またインスタンスを選んでログを表示すればプログラムが実行されていることが確認できる。
今回はCPUしか使用してないがDockerfileとexec_container.shを修正すればGPUも使用できるようになるはず。現状exec_container.pyにバグがあるみたいで、処理が一通り終わるともう一度コンテナがはじめから実行されてしまう。時間があったら今後修正する予定。
参考
機械学習の前処理を効率的にするPreprepっていうライブラリを作った
最近kaggleは放置して機械学習を使った競馬の予想をやってるんですけど、少しパラメータを変えただけのコードを実行するだけなのに、毎回一から前処理をしていると時間がかかりすぎて死ぬという問題が発生していました。
そんな悩みを解決するために、実行するときに前処理をキャッシュしておいて、次に実行するときには以前と変化した部分だけを計算するPreprepというライブラリを作りました。
よく何言ってるかわからないと思うので実際に例を見てもらったほうが早い気がします。
import preprep import pandas as pd def func1(df): return df*2 def func2(df): return df*3 def func3(df): return df*4 def func4(df): return df*5 def func5(df,n = 1): return df*n df = pd.DataFrame([[1,2,3],[4,5,6]]) #キャッシュするディレクトリを指定してインスタンスを作成 p = preprep.Preprep("./cache_files") #前処理の関数を登録 func1からfunc3を順番に実行していく p = p.add(func1,name = "func1") p = p.add(func2,name = "func2") p = p.add(func3,name = "func3") #fit_geneで実際に前処理を実行 df = p.fit_gene(df,verbose = True) print(df.head())
1回目の出力
[*] start running graph [*] no cache exists for func1, calculate [*] no cache exists for func2, calculate [*] no cache exists for func3, calculate 0 1 2 0 24 48 72 1 96 120 144
2回目の出力
[*] start running graph [*] available cache for func1 exists, skip calculation [*] available cache for func2 exists, skip calculation [*] available cache for func3 exists, skip calculation 0 1 2 0 24 48 72 1 96 120 144
1回目に実行したときはキャッシュが存在していないので全ての前処理を計算していますが、2回目に実行するときにはキャッシュが存在しているので計算をスキップしています。キャッシュが存在している状態で、fit_geneに渡すDataFrameが変化すると次のようになります。
#DataFrameの値を変更 #df = pd.DataFrame([[1,2,3],[4,5,6]]) df = pd.DataFrame([[1,2,3],[4,5,7]]) #キャッシュするディレクトリを指定してインスタンスを作成 p = preprep.Preprep("./cache_files") #前処理の関数を登録 p = p.add(func1,name = "func1") p = p.add(func2,name = "func2") p = p.add(func3,name = "func3") #fit_geneで実際に前処理を実行 df = p.fit_gene(df,verbose = True) print(df.head())
[*] start running graph [*] saved cache for func1 exists, but dataset hash value has changed, calculate [*] saved cache for func2 exists, but dataset hash value has changed, calculate [*] saved cache for func3 exists, but dataset hash value has changed, calculate 0 1 2 0 24 48 72 1 96 120 168
ちゃんと入力されたDataFrameが変化していることを検出して計算をやり直しています。またfunc3中身を書き換えたり、他の関数に変えたりすると、func3以前はキャッシュから読み込んで、func3以降だけ計算をやり直してくれます。ちゃんと内部で依存グラフを作ってそれを解決しているので、今回のような関数を順番に実行していくだけでなく、枝分かれしたり、結合したりみたいな複雑なものでもちゃんと必要なとこだけ計算をやり直してくれます。
df = pd.DataFrame([[1,2,3],[4,5,7]]) p = preprep.Preprep("./cache_files") #func3をfunc4に変更 p = p.add(func1,name = "func1") p = p.add(func2,name = "func2") #p = p.add(func3,name = "func3") p = p.add(func4,name = "func3") #fit_geneで実際に前処理を実行 df = p.fit_gene(df,verbose = True) print(df.head())
出力
[*] start running graph [*] available cache for func1 exists, skip calculation [*] available cache for func2 exists, skip calculation [*] saved cache for func3 exists, but op hash value has changed, calculate 0 1 2 0 30 60 90 1 120 150 210
実際に自分で使っていますが、以前に比べて飛躍的に実験を回すスピードが上がったと思います。これまで前処理に毎回30分くらいかかっていましたが、preprepのおかげで変化したとこだけ実行するので今ではほとんど前処理に時間を取られません(入力するDataFrameが変わったりすると、1から計算しなおさないといけないのでやっぱり時間がかかりますが)。
インストールの方法ですがpipで配布しているので下記のコマンドでインストールできる(はず)です。
pip install preprep
まだ生まれたばっかのライブラリなのでバグが多かったりドキュメントが存在してなかったりするので今後その辺はこつこつ直していこうかなと思います。バグとか見つけたらここかgithubでぜひ教えていください。
TargetEncodingのスムーシング
はじめてのFeature Engineering で紹介したTargetEncoding, LikelihoodEncodingはカテゴリ変数の前処理としては非常に強力な手法だが、あるカテゴリに所属するデータの数が少ないと過学習の原因になってしまう可能性がある。データ数が少ないカテゴリを持つデータセットに対して効果的にTragetEncodingを使うために、この論文ではTargetEncodingのスムージングという手法を提案している。
まず通常のTargetEncoding、LikelihoodEncodingは以下のような式で表現できる。
ここで はクラスタi に所属しているデータの数、 はクラスタiに所属していて目的変数が1の数を表している。、の数が十分に大きければ過学習を気にする必要は無いかもしれないが、これらの値が小さいと間接的に目的変数の値を予想できるようになってしまうので過学習の要因になってしまう。この問題を解決するために以下のように改良された式が提案されている。
上で説明した式に加えて、、 という変数が追加されている。、 はデータの総数、はデータセット全体の中で目的変数が1の数を表している。また は 対して単調増加で、0〜1の範囲を取る関数を表している。
この式は の数が十分に大きければ第一項の影響が大きくなり普通のTargetEncodingと同じような値を示す。一方で の数が小さいと第二項の影響が大きくなりデータセット全体の目的変数が1の割合に近づく。このようにデータ数が少ないクラスタに対してはデータセット全体の値を用いることで、機械学習モデルが過学習することを防いでいる。
上の式で出てきたには次のような関数を用いることが多いようだ。
この関数はシグモイド関数的な特徴(k = 0、f = 1とすれば標準シグモイド関数)を示し、k、fの値を調整することでチューニングすることができる。
参考
- A Preprocessing Scheme for High-Cardinality Categorical Attributes in Classification and Prediction Problems https://kaggle2.blob.core.windows.net/forum-message-attachments/225952/7441/high%20cardinality%20categoricals.pdf
- Python target encoding for categorical features https://www.kaggle.com/ogrellier/python-target-encoding-for-categorical-features
はじめてのFeature Engineering
先日KaggleのAdTracking Competition に参加したが惨敗したため、特訓することにした。 Ad Tracking Competition で4位の人がFeature Engineeringについて非常にまとまった資料を公開していたので、その要約をまとめた。 勉強がてらPythonでサンプルコードを書いたので一緒においておく。
h2o-meetups/Feature Engineering.pdf at master · h2oai/h2o-meetups · GitHub
何故Feature Engineeringが必要なのか
価値のある特徴量を生成し、ノイズの原因となる特徴量を除去することでシンプルで精度のいいモデルを実現する。
たとえば下の図で、左の状態だと円形にプロットが分布していて、ニューラルネットワークやSVMなどの非線形モデルでないと分類することができない。 しかし各点を極座標で表して右のように変換すれば、非線形の手法を使わなくても決定木や線形分類器などのシンプルな手法で識別できるようになる。
具体的な手法(カテゴリ変数の場合)
多くの機械学習手法(ニューラルネットワークsvm)はカテゴリ変数を直接学習させることが出来ない。これらのモデルにカテゴリ変数を学習させたい場合には、カテゴリ変数を数値に変換する必要がある。よりモデルがカテゴリ変数の意味を理解しやすいように様々な手法が考案されている。カテゴリ変数の特徴(値に大小関係があるか、疎な入力でも問題がないか、特定の値だけが重要なのか)などを考えてどのような前処理を施すか決定することになる。
Label Encoding
単純にラベルを数字に置き換えるだけの処理。 多くの人が使ったことがあるのではないかと思う。下の例だとA→0、B→1、C→2という様に置き換えてる。pythonだとsklearnのLabelEncoderを使うのが楽。
idx | category | encoded_feature |
---|---|---|
0 | A | 0 |
1 | A | 0 |
2 | A | 0 |
3 | B | 1 |
4 | B | 1 |
5 | C | 2 |
import pandas as pd from sklearn.preprocessing import LabelEncoder df = pd.DataFrame({ "category" : ["A","A","A","B","B","C"], }) le = LabelEncoder() df["encoded_feature"] = le.fit_transform(df["category"]) df.head(10)
One Hot Encoding
カテゴリ変数の各値ごとにカラムを分けて0、1で表現する方法。下の例だとcategory_A, category_B, category_Cという列を新しく作って、Aの場合には(1, 0, 0)、Bの場合には(0, 1 ,0)、Cの場合には(0, 0, 1)と表現する。ニューラルネットワークでカテゴリ変数を表すときはOne Hot Encodingが第一選択肢になる気がする。
LabelEncodingのときと同様、One Hot Encoderがsklearnに収録されているので、それを使うのが楽。pandasにはget_dummiesというメソッドが収録されているので、それを使っても同じことが出来る。
category | category_A | category_B | category_C | |
---|---|---|---|---|
0 | A | 1 | 0 | 0 |
1 | A | 1 | 0 | 0 |
2 | A | 1 | 0 | 0 |
3 | B | 0 | 1 | 0 |
4 | B | 0 | 1 | 0 |
5 | C | 0 | 0 | 1 |
#one hot encoding sample import pandas as pd from sklearn.preprocessing import OneHotEncoder df = pd.DataFrame({ "category" : ["A","A","A","B","B","C"], }) #文字をLabel Encodingして数値に変換 le = LabelEncoder() df["encoded_feature"] = le.fit_transform(df["category"]) #One Hot Encoding ohe = OneHotEncoder() ohe_array = ohe.fit_transform(df[["encoded_feature"]]) df.drop("encoded_feature", axis = 1, inplace = True) #DataFrameに変換 ohe_df = pd.DataFrame(ohe_array.todense()).astype("uint8") ohe_df.columns = ["category_A","category_B","category_C"] #元のDataFrameと結合 df = pd.concat([df,ohe_df], axis = 1) df.head(10)
Frequency Encoding
各値の出現頻度で表現する方法。例えば以下の表におけるAの値は、3(Aの出現回数)/ 6(全体の行数)で0.5となる。pandasを使えばgroupbyとmergeを駆使して比較的に簡単に書くことが出来る。
category | category_counts | frequency_encoding | |
---|---|---|---|
0 | A | 3 | 0.500000 |
1 | A | 3 | 0.500000 |
2 | A | 3 | 0.500000 |
3 | B | 2 | 0.333333 |
4 | B | 2 | 0.333333 |
5 | C | 1 | 0.166667 |
#Frequency Encoding Sample import pandas as pd from sklearn.preprocessing import OneHotEncoder df = pd.DataFrame({ "category" : ["A","A","A","B","B","C"], }) #各カテゴリーの出現回数を計算 grouped = df.groupby("category").size().reset_index(name='category_counts') #元のデータセットにカテゴリーをキーとして結合 df = df.merge(grouped, how = "left", on = "category") df["frequency"] = df["category_counts"]/df["category_counts"].count() df.head(10)
Target Mean Encoding
Likelihood Encodingとも呼ばれる。Google検索でのヒット数的にはTarget Mean Encodingの方がメジャー。あるカテゴリ変数の値を持ち目的変数が1の個数を、その値の出現回数で割ったもの。 下の例だと、Aの出現回数は3回、カテゴリーがAで目的変数が1を持つものの出現回数は2回なのでTarget Mean Encodingの値は2/3 = 0.666 となる。
category | outcome | category_counts | outcome_counts | target_mean_encoding | |
---|---|---|---|---|---|
0 | A | 1 | 3 | 2 | 0.666667 |
1 | A | 1 | 3 | 2 | 0.666667 |
2 | A | 0 | 3 | 2 | 0.666667 |
3 | B | 1 | 2 | 1 | 0.500000 |
4 | B | 0 | 2 | 1 | 0.500000 |
5 | C | 1 | 1 | 1 | 1.000000 |
#Target Mean Sample import pandas as pd from sklearn.preprocessing import OneHotEncoder df = pd.DataFrame({ "category" : ["A","A","A","B","B","C"], "outcome" : [1,1,0,1,0,1], }) 単純に計算すれば次のようなコードになる。 #各カテゴリーともう一つの特徴量の出現回数を計算 grouped_category = df.groupby("category")["category"].count().reset_index(name='category_counts') grouped_outcome = df.groupby("category")["outcome"].sum().reset_index(name='outcome_counts') #元のデータセットにカテゴリーをキーとして結合 df = df.merge(grouped_category, how = "left", on = "category") df = df.merge(grouped_outcome, how = "left", on = "category") df["target_mean_encoding"] = df["outcome_counts"]/df["category_counts"] df.head(10)
Target Mean Encodingには過学習を防ぐための leave one out schemaという手法も提案されている。これは上の表で1列目のAに対してTarget Mean Encodingを計算するときに、自分自身の値を除いて計算する方法だ。この方法を用いて1列目のAに対してTarget Mean Encoding を計算すると(自分を除いたカテゴリーがAで目的変数が1の総数) / (自分を除いたAの出現回数)= 0.5 となる。
category | outcome | category_counts | outcome_counts | target_mean_encoding | |
---|---|---|---|---|---|
0 | A | 1 | 3 | 2 | 0.5 |
1 | A | 1 | 3 | 2 | 0.5 |
2 | A | 0 | 3 | 2 | 1.0 |
3 | B | 1 | 2 | 1 | 0.0 |
4 | B | 0 | 2 | 1 | 1.0 |
5 | C | 1 | 2 | 2 | 1.0 |
6 | C | 1 | 2 | 2 | 1.0 |
#Target Mean Sample with leave one out scheme import pandas as pd from sklearn.preprocessing import OneHotEncoder df = pd.DataFrame({ "category" : ["A","A","A","B","B","C","C"], "outcome" : [1,1,0,1,0,1,1], }) #各カテゴリーの出現回数を計算 grouped_category = df.groupby("category")["category"].count().reset_index(name='category_counts') grouped_outcome = df.groupby("category")["outcome"].sum().reset_index(name='outcome_counts') #元のデータセットにカテゴリーをキーとして結合 df = df.merge(grouped_category, how = "left", on = "category") df = df.merge(grouped_outcome, how = "left", on = "category") #計算する際に自分自身の値は除く df["target_mean_encoding"] = (df["outcome_counts"] - df["outcome"])/(df["category_counts"] - 1) df.head(10)
おわりに
4種類のカテゴリ変数の数値化に関する方法を紹介した。「カテゴリー変数に対する手法」という目次をつけておいて、時間が無くて数値変数に対する手法の紹介まで出来なかったのが申し訳ない。 もとのスライドには、Weight of Evidecneと呼ばれる手法や、具体的な特徴量抽出の手順などが記載されているので時間があったら追記するかもしれない。