cha_kabuのNotebooks

個人的な機械学習関連勉強のアウトプット置き場です。素人の勉強録なので、こちらに辿り着いた稀有な方、情報はあまり信じない方が身のためです。

RankGaussについて勉強する

はじめに

現在参加中の某コンペで、「RankGaussが効く!」とされているのですが、慣れない手法なのでちゃんと調べてみました。kaggle本でもちゃんと紹介されているのですが、普段ニューラルネットから逃げているので全然頭に入っていませんでした…

素人が調べて書いているので、間違いもあるかと思いますので情報の信じすぎにはご注意下さい。

概要

kaggle本の説明が分かりやすかったので引用します。

数値変数を順位に変換したあと、順序を保ったまま半ば無理矢理正規分布となる様に変換する手法です。

数値の大小関係は変わらないのでGBDTなど木関係のアルゴリズムでは効果が無いようですが、特にニューラルネットでモデルを作成する際の変換として通常の標準化よりも良い性能を示すとのこと。

何で良い性能になるのかも調べてみたのですが理解できる情報に巡り合えず…むしろ、「RankGaussからMinMaxScalerに変えたらうまくいった」*1なんて記事もあったので、色々やってみる選択肢の一つとして捉えた方が良いのかも知れません。

低い理解力でメリットとデメリットを考えると、以下のあたりでしょうか。

メリット:値→順序に直すので、極端に頻度の高い値をある程度分散させられる?外れ値の影響が軽減される?

デメリット:順序尺度になってしまうので、差や比を捉えられなくなる?

実装

titanicデータセットを使って実装してみたいと思います。まずはデータの用意と簡単な前処理から。

import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
sns.set()

# データのロード
df = sns.load_dataset("titanic")

# 欠損データの削除
df = df.dropna(subset=["age"]).reset_index(drop=True)

# ヒストグラムの確認
df["age"].hist()
plt.show()

f:id:cha_kabu:20201026123257p:plain

ほぼほぼ正規分布に近いですが、若干崩れています。

ライブラリを使った実装

scikit-learnのQuantileTransformerで簡単に実装できます。DataFrameに適用する場合は不要ですが、Seriesに適用する場合はreshapeしてやる必要があります。

from sklearn.preprocessing import QuantileTransformer

# RankGaussによる変換を定義
transformer = QuantileTransformer(n_quantiles=100, random_state=0, output_distribution='normal')
transformer.fit(df["age"].values.reshape(-1,1))

# 変換後のデータを新規列に格納
df["age_quantiled"] = transformer.transform(df["age"].values.reshape(-1,1))

# ヒストグラムの確認
df["age_quantiled"].hist()
plt.show()

f:id:cha_kabu:20201026123301p:plain

正規分布っぽくなりました。

自力で実装してみる

ライブラリを使うと簡単ですが、何が起こっているのか良くわからないので、できるだけ自力で実装してみます。

なお、先に言っておくとそれっぽい結果にはなるので全然違うことをやっているわけではなさそうですが、全く同じ結果にはならなかった&他の自力実装している人のGitHubを覗くともっと難しいことをやっていたので、あくまで雰囲気を掴むためだけにチャレンジしています。実際のアルゴリズムとは異なるのでご注意ください。

まずはageをrankに変換して、各列を昇順にして結果を確認します。

# ageをrank変換
df["age_rank"] = df["age"].rank()

# 変換結果を確認
df[["age","age_rank"]].sort_values("age")
age age_rank
0.42 1.0
0.67 2.0
0.75 3.5
0.75 3.5
0.83 5.5
... ...
70.50 710.0
71.00 711.5
71.00 711.5
74.00 713.0
80.00 714.0

714 rows × 2 columns

ちゃんと順番通りにrankがついています(同じ値があると平均が取られxx.5などの順位になります)。

続いて、作成したage_rankを-1~1の範囲にスケーリングします。この際、後程処理時に-1と1に変換された値はinfになってしまうので、すごく小さな値を足して(引いて)おきます。

from sklearn.preprocessing import MinMaxScaler

# MinMaxScalerを定義
# 範囲は-1+極小値~1-極小値
scaler = MinMaxScaler(feature_range=(-1+(1e-7),1-(1e-7)))
scaler.fit(df["age_rank"].values.reshape(-1,1))

# 変換後のデータを新規列に格納
df["age_rank_scaled"] = scaler.transform(df["age_rank"].values.reshape(-1,1))

# 変換結果を確認
df[["age","age_rank","age_rank_scaled"]].sort_values("age")
age age_rank age_rank_scaled
0.42 1.0 -1.000000
0.67 2.0 -0.997195
0.75 3.5 -0.992987
0.75 3.5 -0.992987
0.83 5.5 -0.987377
... ... ...
70.50 710.0 0.988780
71.00 711.5 0.992987
71.00 711.5 0.992987
74.00 713.0 0.997195
80.00 714.0 1.000000

714 rows × 3 columns

順位が-1(+極小値)~+1(-極小値)の値に変換されました。

最後に、age_rank_scaledを逆誤差関数(誤差関数の逆関数

 {erf}^{-1}(z)=\sum_{k=0}^{\infty}\frac{c_k}{2k+1}\left(\frac{\sqrt{\pi}}{2}z\right)^{2k+1}

に通して正規分布になる様に変換します。なにやら難しい関数ですが、scipyに関数が用意されていますので、計算は機械に任せてしまいます。

from scipy.special import erfinv

# 逆誤差関数の適用
df["age_rank_scaled_norm"] = erfinv(df["age_rank_scaled"])

# ヒストグラムの確認
df["age_rank_scaled_norm"].hist()
plt.show()

f:id:cha_kabu:20201026123323p:plain

QuantileTransformerの結果と一致はしていませんが、なにやらそれっぽくなったので大体あっているはず…

さいごに

RankGaussについてまとめました。大小関係は変わらないので木のアルゴリズムには無意味というのは分かりやすいですが、何故ニューラルネットだと他のスケーリングに比べて効きやすいのかまではまだよく分かっていません…その辺は色んなところで使いながらどんなところで効く/効かないを掴んでいきたいと思います。

ProbSpace 対戦ゲームデータ分析甲子園 振り返り(最終10位)

はじめに

2020/8/19~2020/10/18の間実施されていた、ProbSpaceの対戦ゲームデータ分析甲子園(通称スプラコンペ、イカコンペ)に参加しました。

splatoonはプレイしたことはありませんでしたが、多くの攻略サイトやプレイ動画があるのでデータの理解もしやすく、とっかかり易いコンペでした。

また、なにより個人的にコンペ参加2回目(1回目はkaggleのM5)でなんとか10位に滑り込め、初メダルを獲得できた記念すべきコンペとなりました。

コンペ概要

  • splatoon2の対戦マッチングデータから勝敗を予測する1/0分類タスク
  • 主な提供データは以下
    • 対戦モード
    • 対戦ステージ
    • A1~B4の8人のプレイヤーのレベル、ランク、使用ブキ
    • 外部データの利用申請が可能で、ブキ性能等も利用可能に
  • データの提供元は非公式のデータ収集サイト

やったこと

モデル

LGBMのみを使用しました。特徴量を量産する過程で「entity embeddingが効くのでは?」との考えに至り(後述)、NNも使用した方が良いのでは…と思いましたが、付け焼刃になってしまうことは目に見えていたのでLGBMに絞りました。

catboostも「LGBMに比べて過学習になりにくい」という記事を見てトピックを参考に使用し、遜色ない精度が出ていましたが、明確なスコアの改善までには至らなかったため使用を断念しました(カテゴリ変数の変換が不要ということで、baseline作成にも便利そうなのでちゃんと使えるようになりたい)。

モデルはlobby-mode(ガチ/レギュラー)でモデルを分け、かつ使用する特徴量を組み替えたものを3つアンサンブル(重み付けのないシンプルな多数決)としました。モデルを分けることでのスコア向上は大きくありませんでしたが、わずかながらスコアが改善していたことと、学習時間も大きく変わるわけではなかったので分けることにしました。

特徴量エンジニアリング

Kaggle本に載っている様な基本的な処理を行いつつ、以下が個人的に新しい取り組み/学びでした。

one-hot encoding + count encoding

EDAのトピックでシレっと使われていましたが、通常1/0のフラグを付けるだけのone-hot encodingについて、sklearnのMultiLabelBinarizerを用いて複数列でのカウント数を格納されていました。

結果的に特徴量として使用はしませんでしたが、他にも使う機会がありそう&コードコピペで対応してしまったのでちゃんとコードを理解したいと思います。

レーティングアルゴリズム

こちらのトピックで紹介されていました。単純な勝率ではなく相性も加味してレート付けを行う(強いものに勝ったらより高く、弱いものに勝ったらそれなりに、逆もまた然り)というもの。

紹介されていたものはTrueSkillという比較的新しいアルゴリズムで、他に長年使われているものにElo ratingなどがあるようです。こちらも結果的には特徴量に使用しませんでしたが、コードもアルゴリズムもちゃんと理解しないまま使っていたので、次回勝敗予測系のコンペをやる頃までには理解しておきたいと思います。

apply

単体ブキの強さだけでなくブキの組み合わせを考慮した特徴量を作りたいと思い、カテゴリ変数を結合して1変数にまとめることを考えました。

単純に結合してしまうとA1~B4の並び次第で別の値になってしまうため、ソートしながら結合することを考え、最初for文で1行ずつ"+".join(sorted([A1~,A2~,A3~,A4~]))で無理矢理結合していたのですが、20分くらい時間がかかるので試しにapplyを使ったところ、数分で終わる様になりました。

計算量としてはどちらも全行を見るので(?)変わらない様な気がするのですが、なぜここまで差がつくのかわからなかったのでapply(ついでにmap等も)について詳しく勉強したいと思います。

文字列の類似度

applyの項で書いた通り、カテゴリ変数を文字列結合して1変数にまとめたものは、最終的にtarget encodingで数値に変換していたのですが、ブキカテゴリをまとめたものなど種類が少ない変数については良かったのですが、個別のブキについてはnullを含めて140種あり、片チームだけでも1404もの組み合わせがあることからほとんどが自身以外に同じレコードが無く、ほとんどがNanになってしまいました。

「全く同じレコードは無くともなんとか似たものを近い数値に置き換えられないか?」と考え、文字列の類似度を測る方法(レーベンシュタイン距離法など)を調べて近いものの数値と置き換えられないかと試したものの、理解&実装力が追い付かずお蔵入りとなりました。

この実装を考えていたころ、NNのentity embeddingで文字列の類似度を表現できるという情報にも辿り着いたのですが、NNに慣れていなかったためコンペ期間中の実装を諦めました。

target encoding

コンペで初めてトピックを書き、それなりにupvoteももらえたので嬉しかったです。今見直すとコードは改善点だらけですが、自分の考えの整理にもなるので今後も臆せずトピック投稿できればと思います。

特徴量として良かったのか悪かったのかというと…最後まで良く分かりませんでした。確かにスコアは改善しましたし、feature importanceを見るとTOP20に10個くらいはランクインしていたので、全く的外れではなかったのだと思いますが、冗長な特徴量が多すぎたのではと思っています。

(mode/lobby-mode) × (各プレイヤーのブキ/カテゴリー/スペシャル/サブウェポン)/(各チームのブキ他の文字列をそれぞれ結合したもの)/それらの乗算、チーム間比などで500近い特徴量ができましたが、ほとんどが特徴量選択で落としてもスコアに影響しない/場合によっては改善することも多い状況でした。

おそらくですが、カテゴリ変数で集計した勝率がtrainとtestで異なっており、valid含めたtrainにoverfit気味だったのだと思っています。

結果にそこまで影響したのかというと怪しいですが、target encodingにここまで取り組んだことがなかったので、リークに悩まされたところから脱出したりと、取り扱いに慣れたという意味では良い取り組みでした。

特徴量選択

今回、最後まで一番分からなかった部分です。feature importanceの違いは出るのですが、null importanceを見るとほとんどの特徴量が除外候補になってしまい、何を入れて何を入れない方が良いのか明確にわからず終いでした。

おそらくですが、特徴量のほとんどがカテゴリ変数をtarget encodingしたもので、多少の違いはあれどほぼ0.5に近い値で、ランダム値とほとんど差がなかったせいじゃないかと考えています。もしくはそもそも重要度のことを良くわかっていないか…コードコピペで使っていただけなので、これを機にちゃんと理解したいと思います。

結局、2000ほど特徴量を作成しましたが、各種重要度は気にせず直感的に勝敗に影響するものや冗長にならないように落としていき、100以下程度まで手動で選択したもののほうが精度があがりました。「GBDTは冗長な特徴量があっても大丈夫」とは聞きますが、時と場合によりますね。

また、直感的には重要と思われたサブウェポンやスペシャル、one-hot特徴量も基本的に除いた方が精度が高くなりました(残した他の特徴量にもよる)。おそらく、サブウェポンやスペシャルについてはブキ本体にも情報が含まれるのでoverfit気味になってしまっていたこと、one-hotも設定していたパラメータだと分割候補が多すぎてoverfit(?)になっていたのではと思います。

ガンガン特徴量を減らしてもスコアがそこまで落ちないので逆に楽しくなってしまいどこまで減らせるか試してみたのですが、6特徴量で0.553まで精度が出たので、改めて一部を除いて有効な特徴量が作れていなかったのだと思います…。

パラメータ

CV/LBを見ながら手動で調整しました。ちまちまいじっていはいましたが、基本は以下に落ち着きました。

params = {
    "objective": "binary",
    "metric": "binary_logloss",
    "boosting_type": "gbdt",
    "num_iterations": 1000,
    "max_bin": 255,
    "learning_rate": 0.05,
    "num_leaves": 31,
    "max_depth": 5,
    "min_sum_hessian_in_leaf": 1,
    "lambda_l1": 0,
    "lambda_l2": 1,
    "subsample":0.9,
    "colsample_bytree":0.9
}

min_sum_hessian_in_leaf以下を設定していなかった初期はスコアが安定しなかったのですが、特徴量がoverfitしやすいものばかりになってしまったせいか、設定することで安定的に(自分のモデルの中では)高いスコアが出せるようになりました。

CV

最初は単純なKFold(5-Fold)のSEEDひとつでやっていたのですが、ひどいときはCVとLBが±1%程度乖離していました。

最終的に、CV/LBを見ながらFold数は4、SEEDを複数個用いて予測した結果でCVを測ることで、(結果的にPBも含めて)±0.3%程度の範囲に収めることができました。

今回のvalidation方法自体は基本的な方法かと思いますが、中盤は本当にこれに悩まされていたので、Kaggleのテーブルコンペのvalidation手法をひたすら漁りつつ試行錯誤するなど、地味に学びの大きかった部分です。(validationは常に役立つ知識だと思うので別途記事にまとめたい)

少量のリーク(?)の利用

トピックで指摘したところ別々の対戦とのことでしたが、ほとんどの試合結果が同じものだったので、正直懐疑的でした。

トピックを書いた際はtrain内、test内での重複しか見ていませんでしたが、それぞれを結合してみたところ、Aチームと Bチームを入れ替えたデータも含めると(Bチーム側にもA1にあたるプレイヤーがいた?)約200ほど同じ組み合わせの試合がありました。

勝敗が異なるものもあるので実際に別々の試合だったもあったのでしょうが、別々の試合だったとしても同じ組み合わせなら同じ結果になる可能性が高いだろうと考え、予測結果に関わらずtrainで結果が見えている勝敗で上書きしたところ、LBが上がったので採用することにしました。

winner's solution

2nd

ルールが変わる2時間以内の同ブキ使用回数/異種ブキ使用回数をカウントする特徴量が効いたというもの。

「そんな特徴量があったとは!」と思い知らされました。「もう特徴量なんて似たようなのしか無いよ…」と思っていましたが、負けが続くと装備を変えたくなるプレイヤー心理は未プレイでも予測できたはずなので、発想力が足りませんでした。

実際、自分のモデルに何も考えず組み込んだだけで、0.5%程度スコアが改善&ガチのモデルのfeature importanceでチーム平均rensen_countとweapon_countのチーム比がTOP1&2に躍り出たので、特徴量選択をやり直せば0.57台も十分出せる気がします。

6th

個人的には今のところ一番ショッキングな解法…CV分割数を大きめにとること、データの拡張、Optunaなどすべて自分も試しましたがすべて効果が薄いと判断してやめた方法にも関わらず結果的には負けた…しかも読む限りでは特徴量もとても少ないどころか自分で追加した特徴量はないとのことで、何が違ったのか…「trainは水増ししてvalidはそのまま」の考えは自分には浮かばなかったので参考になります。

8th

wikiデータ手打ち…やろうとしたけど諦めました。スクレイピングにチャレンジしようかと思いましたが、wikiの構成が複雑で腰を据えて学ばないと無理そう&詳細なデータはこれ以上増えても大きなスコア改善は望めないと思ったので、やめました。

また、チームを入れ替えることによるデータセットの水増しは自分も試したものの、データセットの水増しはtarget encodingでのリークが防げなかったので諦めました。

しかし、特徴量選択のところで「特徴量の組み合わせ次第でスコア(予測結果)が全然違う」と気付けていたので、target encodingに拘らず様々なモデルを試すべきでした。

11th

「データ水増しでA1プレイヤーの特定を断念した」という同じ悩みがありました。A1プレイヤーと、target encodingのリーク防止もどうしてよいのかわからず…しかしそういった特徴量を使わずに結果を出されているので、8thの解法同様に様々なモデルを試してみるべきでした。

13th

プレイヤー毎に予測をするとは思いもしませんでした。直感的にはチームの組み合わせが加味出来ないので悪化しそうなものですが…また、target encodingで似たようなこと(A1~B4の割り振りに左右されないブキ毎の勝率を求める)をしてはいたのですが、単純な統計値よりもこの方がランクやレベルも加味出来て良い気がします。

まだ試せていませんがどの程度違いがでるか試してみたいと思います。

22th

別途トピックに挙げてくださっていたEntity Embeddingを使用したとのことで、とても真似してみたいです。NN自体に苦手意識があるのでまずはゼロから作るDeepLearningの2週目と自然言語処理編を逃げずにやりたいと思います…

別途勉強する/まとめる

  • スクレイピングの基本
  • catboostの使い方
  • count encodingとone-hot encodingの合体encodingのコードちゃんと理解する
  • 各種レーティングアルゴリズム(次対戦系のコンペやるときまでに)
  • apply/mapの使い方
  • 特徴量選択各種(feature importance, null importance etc...)の意味とコードちゃんと理解する
  • 具体的なvalidation手法をまとめる
  • Entity Embedding(というかNN)