MXNetでmulti-input/multi-output

皆さんMXNet使っていますか? 年度初に著名データサイエンティストの記事が相次いで盛り上がった感がありましたが、もうChainerなりTensorFlowなりに移ってしまったのでしょうか…

MXNetはDeep Learningフレームワークの比較でドキュメントが弱いことをよく指摘されてるので、1ユーザとして草の根でお役立ち情報を発信していきたいです。

やりたいこと

形式の異なる複数のデータを入力として、複数の値を出力するモデルを学習したい。*1

  • Keras: Functional APIなるものを使って実現できるそうです。

Functional APIのガイド - Keras Documentation

  • Chainer: サポートされている模様。手続き的なフレームワークだと関数に通すだけなので難しいことは少なそう。

Google グループ

  • TensorFlow: サポートされていない模様。MXNetと同様に改造すればいけそう。

How to handle multi tensors input? · Issue #9 · tensorflow/serving · GitHub

参考にしたもの

MXNetで困ったら、まずはexampleを調べることをお勧めします。 英文を含めてもブログ記事の情報量は少なく、Issueには答えが書いていないものも多いです。

私もIssueを読み込みましたが、答えはexampleにありました。

mxnet/example/multi-task at master · dmlc/mxnet · GitHub

サンプルコード

github.com

  • 3ブロック目: 解像度の異なる画像2つと、ベクトル1つをインプットにしています。
x1 = np.zeros((num_train, 1, 8, 8))
x2 = np.zeros((num_train, 1, 16, 32))
x3 = np.zeros((num_train, 10))
y = np.zeros((num_train, num_cls))
z = [y[:,ii] for ii in range(y.shape[1])]
  • 6ブロック目: 公式のDataIterを使うとシンボルの名前が嫌になります。インプットのデフォルト名はdataで、アウトプットのデフォルト名はsoftmax_labelになります。出力層の名前を自分で勝手に決めるとエラーが出るのはこいつが原因です。
print(mx_dat0.provide_data)
print(mx_dat0.provide_label)
  • 8ブロック目: DataIterを改造して、名前を付けやすくしています。

  • 9ブロック目: 適当なネットワークを組みました。

data0 = mx.sym.Variable('input0')
data1 = mx.sym.Variable('input1')
data2 = mx.sym.Variable('input2')
def get_symbol(sym, prefix=''):
    net = mx.sym.BatchNorm(sym,
                           name=prefix+'_bn')
    net = mx.sym.FullyConnected(net,
                                name=prefix+'_fc',
                                num_hidden=3)
    return net
net0 = mx.sym.Flatten(data0)
net1 = mx.sym.Flatten(data1)
net2 = get_symbol(data2, 'i2')
netc  = mx.sym.Concat(*[net0, net1, net2])
fc   = []
out  = []
for ii in range(num_cls):
    fc.append(mx.sym.FullyConnected(netc,
                                    name='fc'+str(ii),
                                    num_hidden=2))
    out.append(mx.sym.SoftmaxOutput(fc[ii],
                                    name='clf'+str(ii)))
net = mx.sym.Group(out)

10ブロック目でプロットしたネットワーク図でinput1input2が見えませんが、インプットのデータを弄るとエラーが出るので、これで大丈夫のはずです。

注意

公式でもmulti-inputやmulti-outputのためのIF改善がTODOに挙げられています。 また、MXNet自体がNNVMとの機能分割で大工事中ですし、将来のバージョンアップで諸々変わってしまう可能性があります。

v1.0 Stable Release TODO List · Issue #2944 · dmlc/mxnet · GitHub

その他

MXNetのLogisticRegressionOutputは名前から想像するような動きをしてくれないので、SoftmaxOutputを使った方が良さそうです。

*1:Deep Learningは人間の脳を模倣した仕組みなので、複数種類のインプット(視覚・聴覚・記憶等)から複数のアウトプット(話す内容・身振り・手振り等)を得るためのルールを学習できます。(嘘)

分類性能とMAE

名刺お疲れ様でした。 終盤はGPUメモリ足りないとイライラしていましたが、足りないのは創意工夫でした。

私はMXNetを使って取り組んでました。multi-inputとかmulti-outputのやり方を泣きながら調べたのも良い思い出です。

その話はまた時間があればまとめます。今日は軽いネタを一つ。

本題

今回のコンペはマルチラベル問題で、評価指標はMAEでした。 サンプル数が  N で、そのインデックスを  i とします。同様にクラス数が  M で、そのインデックスを  j とします。  i 番目のサンプルの クラス  j に対するフラグを  y_{i,j}、予測値を  f_{i,j} と表します。

MAE(Mean Absolute Error)は

{\displaystyle
\mathrm{MAE} =
\frac{1}{NM} \sum_{1 \leq i \leq N} \sum_{1 \leq j \leq M}
\left| y_{i,j} - f_{i,j} \right|
}

で計算します。

このとき、 f は確率ではなくフラグにしないと殆どの場合で損するはずです。 MAEにはmedianを返すのがお約束なので、これ以上は不要な説明かもしれませんが、泥臭くやってみます。

何らかの機械学習で得た予測値  g_{i,j} があるとして、これを  \Delta_{i,j} で補正した  f_{i,j} = g_{i,j} + \Delta_{i,j} を予測値にします。

補正したことによるMAEの改善幅は

 {\displaystyle
q_{i,j} \Delta_{i,j} - \left( 1 - q_{i,j} \right) \Delta_{i,j}
= \Delta_{i,j} \left( 2 q_{i,j} - 1 \right)
}

となります。  q_{i,j} y_{i,j} = 1 となる確率とか確信度とかそういった数字です。 先の機械学習モデルがまともなものであれば、 q_{i,j} g_{i,j} で代用できるでしょう。 そうすると、 g_{i,j} \geq 0.5 では  f_{i,j} = 1 にすべきですし、 g_{i,j} \leq 0.5 では  f_{i,j} = 0 にすべきです。

したがって、評価指標は実際にはAccuracyになってしまっていたんじゃないか、というお話です。

以下のチュートリアルでも、特に説明はありませんが確率でなくフラグを出力しています。

コンテストチュートリアル(Sansan)

余談

分類器の性能をMAEで評価するのは意味がないと個人的には考えるのですが、そのようなペーパーも少ないながら発見できます。 以下のような表で比較をしていたりします。

classifier Accuracy MAE RMSE
clf1
clf2

著者がインド系の方が多いので、インドの偉い先生の好みだったりするんでしょうか。 日本人がMAEで分類性能を評価しているものを見つけてオッと思ったら指導教官がインド系の先生だったりもしました。

XGBoostにDart boosterを追加しました

はじめに

XGBoostにBoosterを追加しました。 以下のようなIssueを見つけ、興味があったので実装してみたものです。 github.com

今のところ、Dart boosterの仕様について私以外の誰も把握していないはずなので、皆さんに使って頂きたく解説記事を書きます。*1

モチベーション

論文の

Boosted Treesでは誤差を潰すために回帰木を大量に作ります。

木の数が多くなったときに残っている誤差は小さいですが、以降に構築される回帰木はその些末な誤差にフィッティングされることになります。

これは効率悪いように思われ、イテレーションの終盤においても一定の影響力を持った回帰木を作りたいということで、NN系でよく用いられる(ものとは趣きが異なるように私には思える)DropoutをBoosted Treesに転用しています。

自分の

  • XGBoostにはいつもお世話になっているのでcall-for-contributionに反応したかった
  • ライブラリ使ってるだけでしょ、という煽りが癪
  • Gitできないので5日でクビになってしまう
  • XGBoostエバンジェリストとしてSIerさんに拾ってもらいたい

実装について

DARTにおける回帰木の作り方は通常と同じです。 構築した回帰木に対するDropoutやスケーリングの処理を追加すれば良いので、回帰木をコントロールしているGradientBooster(今回は特にGBTree)を拡張するのみです。

ドロップ

Dart::PredictDropTreesをコールしています。 ここでntree_limitに0がセットされていると、既に構築した回帰木の幾つかをドロップしたleaf scoreを計算します。

したがって、学習結果を用いて予測値を得る場合にはntree_limitに正の値(num_roundと同じ数)を設定する必要があります。 ここはAPIを弄りたくなかったので複雑な仕様になっています。

スケーリング

ドロップしたleaf scoreに対して通常と同じように回帰木を構築します。

ドロップした回帰木と追加の回帰木で平均を取るためにDart::CommitModel内でNormalizeTreesをコールしています。

パラメータ

booster

dartを指定します。 内部的にはGBTreeを継承していますので、パラメータもgbtreeと同じものが設定できます。 Predメソッドでのバッファを無効化しているので、gbtreeと比べて学習が遅くなっています。

Dart boosterに固有のパラメータを以下で解説します。

sample_type

ドロップする回帰木のサンプリング方法を指定するオプションです。 デフォルトはuniformで、このとき一様にドロップする回帰木が選ばれます。 weightedに指定すると、影響力の大きな回帰木が選ばれやすくなります。

normalize_type

DARTではドロップする回帰木と新たに学習した回帰木の平均を取ります。 このときの平均の取り方を指定します。 デフォルトはtreeで、ドロップした回帰木のそれぞれ1本が新たに学習した回帰木と同じ重みを持つとして計算します。

 {\displaystyle
D = \sum_{i=1}^k F_i \sim \tilde{F}
}

 {\displaystyle
\alpha \left( \sum_{i=1}^k F_i + \frac{\lambda}{k} \tilde{F} \right)
\sim \alpha \left( 1 + \frac{\lambda}{k} \right) D = D, \quad
\alpha = \frac{k}{k + \lambda}
}

learning_rateを小さく、num_roundを大きくした場合に影響力が小さくなりすぎるので、forestというオプションを用意しています。 ドロップした回帰木の合計と新たに学習した回帰木が同じ重みを持つとして計算します。

 {\displaystyle
\alpha \left( \sum_{i=1}^k F_i + \lambda \tilde{F} \right) \sim \alpha \left( 1 +\lambda \right) D = D, \quad
\alpha = \frac{1}{1 + \lambda}
}

rate_drop

既存の回帰木がドロップされる確率を設定します。

skip_drop

論文ではlearning_rateが1で固定されているので問題となりませんが、learning_rateを小さな値に設定した場合には足踏みを繰り返して学習が進みません。 あるイテレーションではドロップせずに、通常のGBTreeのようにboostingできるような仕様にしています。 イテレーション毎にドロップをしない確率をskip_dropで設定します。

数値実験

こちら

パラメータによってはgbtreeより良い精度を出せているようです。

今後について

Boosted Treesでは通常learning_rateを小さく設定しているため、既にbaggingが効いてしまっています。 論文のアルゴリズムのように、ドロップした回帰木と同じような値を新たな回帰木に学習させて、それらの平均を取っているだけでは劇的な性能改善は望めないでしょう。

過学習は怖いですが、バリデーションのスコアを参照してセレクションを効かせるような仕組みに発展させたいです。*2

その他

  • NormalizeTreesの計算が間違っていたらご指摘ください*3
  • Boosted Trees周辺で数学的に難しくない論文があれば教えて下さい、XGBoostベースで組んでみます

*1:私の英語力の問題で、日本語記事が世界最速です。DARTでライバルに差をつけるなら今ですよ。

*2:卒業論文くらいにはちょうど良いテーマだと思いますが大学生の皆さん如何でしょう?

*3:既に、Pull requestが通ってから1日で修正をしてしまっている…

XGBoostのRNGをMTに置換える

背景

オプトDSLで開催された「ユーザー離脱予想」のコンペで入賞しました。 結構丁寧に検収をして頂くのですが、オプトの方とこちらとでどうしても結果が一致せずに困り果てていました。

Twitterでつぶやいた所、有益情報をゲットします。

random.hを調べてみたところ…

struct Random{
  inline void Seed(unsigned sd) {
    this->rseed = sd;
    #if defined(_MSC_VER)||defined(_WIN32)
    ::xgboost::random::Seed(sd);
    #endif
}

確かにWinとそれ以外で挙動が違っています。

また、見落としていたのいたのですが、公式に以下の投稿を発見しました。 github.com

そもそもrand()処理系依存なので、異なる環境で結果の一致は望むべくもないとわかりました。

モチベーション

当初の課題は解決したのですが、今度は評判の悪いrand()を使っていて良いのかという点が気になってきました。

何も考えずにMersenne Twisterを使うように教育されてきた人間なので、質の悪い乱数発生器で機械学習するというのには抵抗があります。 MCMC*1やRandom Forestでrand()を使っているものがあればゴミ扱いも免れないでしょう。 しかしながら、GBRTでは新しく学習されるツリーは前回までの学習結果に強烈に依存しているので、乱数の質が問題となりにくいようにも思われます。

とにかく、rand()を使っているせいでKaggleの順位が伸びないのであれば癪なので実験してみました。

XGBoostの改造

乱数を扱っているのは前述のrandom.hですので、ここに手を入れます。

namespace xgboost {
namespace random {
  extern std::mt19937 mt;
  extern bool use_mt;
  inline void Seed(unsigned seed) {
    srand(seed);
    mt.seed(seed);
  }
  inline void set_use_mt(unsigned use) {
    if (use > 0) {
      use_mt = true;
    } else {
      use_mt = false;
    }
  }
  inline double Uniform(void) {
    if (use_mt) {
      return static_cast<double>(mt()) / (static_cast<double>(mt.max())+1.0);
    } else {
      return static_cast<double>(rand()) / (static_cast<double>(RAND_MAX)+1.0);
    }
  }
  inline double NextDouble2(void) {
    if (use_mt) {
      return (static_cast<double>(mt())+1.0) / (static_cast<double>(mt.max())+2.0);
    } else {
      return (static_cast<double>(rand())+1.0) / (static_cast<double>(RAND_MAX)+2.0);
    }
  }
}
};

乱数発生器を切替えるために、どこかでset_use_mt()を実行しなければなりません。 seedを設定している箇所がlearner-inl.hppSetParam()にあるので、そこに処理を追加します。

if (!strcmp("seed", name)) {
  seed = atoi(val); random::Seed(seed);
}
if (!strcmp("use_mt", name)) {
  random::set_use_mt(atoi(val));
}

並列化については何も考えずに設計しているので、OpenMPはOFFにしてビルドします。

数値実験

seedを揃えてrand()版とmt()版でそれぞれ学習をします。 seedを変えてこれを繰り返し精度を比較してみます。

データはKaggleのOttoを用いることにします。

実験用のコードは以下の通り。

prm = {'colsample_bytree': 0.5,
       'eval_metric': 'mlogloss',
       'max_depth': 7,
       'num_class': 9,
       'objective': 'multi:softprob',
       'silent': 0,
       'subsample': 0.9}
num_round = 30
def fit_XGB(y, x, flg):
    loss = []
    flg  = np.in1d(x.index, idx)
    mat  = xgb.DMatrix(x[flg].values, label=y[flg].values)
    clf  = xgb.train(prm, mat, num_round)
    mat  = xgb.DMatrix(x.values, label=y.values)
    pred = clf.predict(mat)
    loss.append(cf.log_loss_multi(y[flg].values, pred[flg]))
    flg = np.logical_not(flg)
    loss.append(cf.log_loss_multi(y[flg].values, pred[flg]))
    return loss

loss0 = []
loss1 = []
num_iter = 300
for ii in range(num_iter):
    idx   = np.random.choice(x.index, int(0.5*len(x)), replace=False)
    flg   = np.in1d(x.index, idx)
    sd    = int(np.random.random_sample() * 1000000000)
    prm['seed']   = sd
    prm['use_mt'] = 0
    loss0.append(fit_XGB(y, x, flg))
    prm['use_mt'] = 1
    loss1.append(fit_XGB(y, x, flg))
loss_rd = pd.DataFrame(loss0)
loss_rd.columns = ['IN', 'OUT']
loss_mt = pd.DataFrame(loss1)
loss_mt.columns = ['IN', 'OUT']

実行結果は以下のようになりました。

rand()

項目 IN OUT
mean 0.316084 0.545039
std 0.003770 0.003840
min 0.306325 0.535338
25% 0.313512 0.542393
50% 0.316218 0.545169
75% 0.318698 0.547285
max 0.326892 0.556248

mt()

項目 IN OUT
mean 0.316162 0.545059
std 0.003549 0.003830
min 0.306115 0.535763
25% 0.313710 0.542551
50% 0.315968 0.544973
75% 0.318608 0.547787
max 0.324961 0.555478

今回はrand()版の方が良い精度となりました。

結論

試行回数300は少ない、etaパラメータが大きい等に調整の余地はありますが、乱数の質に拘っても精度への貢献は僅かだと予想されます。

本家がMersenne Twisterに替えてくれるならそれで良し、そうでなくても安心して使って良さそうです。

*1:質の悪い乱数でもMarkov Chain側で調整が効くので何とかなるような気もしてきましたが、どうなんでしょう。

Rからパラメータ付きCypherクエリを投げる

KaggleやCrowdSolvingでレコメンのコンペが開催されたときに使いたいなぁと思ってNeo4jの勉強を始めたのですが、グラフDBに適した問題がなかなか出てきません。 今回はNeo4j 2.0がリリースされた記念に記事を書いてみました。

目標

RからCypherクエリを投げて結果をデータセットにします。 いついかなる時もCypherクエリはパラメータ化すべきとのことなので、それにも従います。

使用データ

KaggleのEvent Recommendation Engine Challengeデータを使っています。 BatchInserterを使ってDBに挿入したのですが、重複レコードがたくさんあって大変でした。

CSVファイルの容量は1.5GBくらいですが、Neo4jに放り込むと5.5GBくらいになりました。

参考

Stack Overflowにあったコードをベースにしています。 こういうのも見つけましたが、ざっと眺めた感じパラメータ化はされていないようです。

コード

library(RCurl)
library(RJSONIO)
getQuery <- function (query, params) {
  h  =  basicTextGatherer()
  pf <- toJSON(list(query=query, params=params))
  curlPerform(url="localhost:7474/db/data/cypher",
              httpheader=c("Content-Type"="application/json"),
              customrequest="POST",
              postfields=pf,
              writefunction=h$update)
  result <- fromJSON(h$value())
  if (is.element("exception", names(result))) {
    dat <- result
  } else {
    dat <- data.frame(t(sapply(result$data, function(y) y)))
    if (ncol(dat)==length(result$columns)) {
      names(dat) <- result$columns
    }
  }
  return(dat)
}

参考にしたコードと異なり、クエリとパラメータをJSON形式で渡すので、httpheaderでそれを設定しています。

実行

あるイベントについて、指定したユーザの友人の内から何名がそれに参加しているかを問い合わせます。

query <-
"START u = node:users(user_id = {uid})
MATCH u-[:FOLLOW]->f-[:ATTEND]->e
RETURN u.user_id, e.event_id, count(e) LIMIT 10"
dat      <- NULL
params   <- list(uid="3197468391")
dat[[1]] <- getQuery(query, params))
params   <- list(uid="3429017717")
dat[[2]] <- getQuery(query, params))

実行すると、

[[1]]
    u.user_id e.event_id count(e)
1  3197468391  169644382        1
2  3197468391  543972501        1
3  3197468391 3969940212        1
4  3197468391 2977769484        1
5  3197468391 1704179171        1
6  3197468391  608092517        3
7  3197468391  730958187        1
8  3197468391   19341280        1
9  3197468391  445373500        1
10 3197468391 2539029764      284

[[2]]
    u.user_id e.event_id count(e)
1  3429017717 3183605169        2
2  3429017717 2180806657       46
3  3429017717 2039358442        2
4  3429017717 2412032092        2
5  3429017717 3541811987        1
6  3429017717 2368083210        1
7  3429017717 1506378274        1
8  3429017717 1177314523        1
9  3429017717 3163090701       12
10 3429017717  266513530        1

ちゃんと結果が返ってきました。僅かですがベタ書きより良いパフォーマンスも確認できました。めでたしめでたし。