C++11 で線形分類器の分散オンライン学習器を実装してみた

自作の(非)線形分類器のオンライン学習器に,

で提案されている分散オンライン学習法 (Iterative Parameter Mixing) を実装してみた.

で調べたのがもう2年以上前のことだから今更感はんぱない.と言っても,自作の分類器に実装したのは線形学習の分散化で,多項式カーネルを使った非線形学習の分散化はパラメタを効率的に分配するのが難しく,まだ実装できていない.というか,線形学習の分散化も平均化を行う場合はパラメタの管理が面倒で,効率的な実装になかなか辿りつけなかった.
マルチコア CPU 向けの分散オンライン学習は C++11 で新たに導入された thread と lambda 式を使うことで,驚くほど簡単に実装できる.既存の実装 +10~15 行程度で済む,ということを示すために書いたコード (分散 Passive Aggressive-I アルゴリズム) は以下.該当箇所は,distributed training と書かれた行から non-distributed training までで,分散化に必要となるパラメタを合わせると14行.

// ppa1.cc ; GNU GPL version 2 copyright@id:ny23
#include <err.h>
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <vector>
#include <chrono>
#include <thread>

static const size_t MAX_LINE_LEN = 1 << 20; // max line len
static float power10[40];

typedef std::vector <std::pair <int, float> >  x_t;
typedef std::pair <int, x_t>  ex_t;
struct result_t { int pp, pn, np, nn; };

int strtoi (char*& p) {
#ifdef USE_MY_STRTOI // faster strtoi
  int i = 0;
  for (; *p >= '0' && *p <= '9'; ++p) i *= 10, i += *p, i -= '0';
  return i;
#else
  return static_cast <int> (std::strtol (p, &p, 10));
#endif
}

float strtof (char*& p) {
#ifdef USE_MY_STRTOF // faster strtof; only support floating numbers -?\d*.\d*
  float i = 0; float sign = 1.0f; char* q = 0;
  if (*p == '-') ++p, sign = -1.0f;
  while ((*p >= '0' && *p <= '9') || *p == '.')
    if (*p == '.') q = ++p; else i *= 10, i += *p, i -= '0', ++p;
  return q ? sign * i * power10[p - q] : sign * i;
#else
  return static_cast <float> (std::strtod (p, &p)); // std::strtof is slower
#endif
}

// read examples
void read_data (const char* train, std::vector <ex_t>& ex, int* imax) {
  FILE* reader = std::fopen (train, "r");
  char line[MAX_LINE_LEN];
  for (x_t x; std::fgets (line, MAX_LINE_LEN, reader) != NULL; x.clear ()) {
    char* p = line;
    const int y = static_cast <int> (std::strtol (p, &p, 10));
    float norm = 0;
    while (*++p) {
      const int   i = strtoi (p);
      const float v = strtof (++p);
      norm += v * v;
      x.emplace_back (i, v);
    }
    norm = std::sqrt (norm);
    for (auto& f : x) f.second /= norm;
    if (! x.empty ()) *imax = std::max (*imax, x.back ().first);
    ex.emplace_back (y, x);
  }
  std::fclose (reader);
}

// one epoch passive aggressive I
void train1 (const ex_t* const start, const ex_t* const end, std::vector <float>& w, const float c) {
  for (const ex_t* p = start; p != end; ++p) {
    float m = 0;
    for (const auto& f : p->second) m += w[f.first] * f.second;
    m *= p->first;
    if (m <= 1.0f) {
      const float t = p->first * std::min (c, 1.0f - m); // norm = 1
      for (const auto& f : p->second) w[f.first] += t * f.second;
    }
  }
}

// distributed passive aggressive
void train (const std::vector <ex_t> &ex, std::vector <float>& w, const long iter, const float c, const int nf, const int n) {
  w.resize (nf + 1, float (0));
  std::vector <std::thread> thr (n);
  std::vector <std::vector <float> > wv (n, w);
  for (int i = 0; i < iter; ++i) {
    if (n) { // distributed training
      for (int j = 0; j < n; ++j)
        thr[j] = std::thread ([&,i,j] () {
            const size_t nex = ex.size () / n + (ex.size () % n ? 1 : 0);
            if (i) std::copy (std::begin (w), std::end (w), std::begin (wv[j]));
            train1 (&ex[j * nex], &ex[std::min (ex.size (), (j + 1) * nex)], wv[j], c);
          });
      for (int j = 0; j < n; ++j) thr[j].join (); // parallel do
      std::fill (std::begin (w), std::end (w), float (0));
      for (size_t k = 0; k < w.size (); ++k) {
        for (const auto& w_ : wv) w[k] += w_[k];
        w[k] /= n;
      }
    } else // non-distributed training
      train1 (&ex[0], &ex[ex.size ()], w, c);
    std::fprintf (stderr, ".");
  }
}

void test (const char* test, const std::vector <float>& w, const int nf, result_t* r) {
  FILE* reader = std::fopen (test, "r");
  char line[MAX_LINE_LEN];
  r->pp = r->pn = r->np = r->nn = 0;
  for (x_t x; std::fgets (line, MAX_LINE_LEN, reader) != NULL; x.clear ()) {
    char* p = line;
    const int y = static_cast <int> (std::strtol (p, &p, 10));
    float m = 0;
    while (*++p) {
      const int i = strtoi (p);
      if (i > nf) break;
      m += w[i] * strtof (++p);
    }
    if (m >= 0) if (y > 0) ++r->pp; else ++r->np;
    else        if (y > 0) ++r->pn; else ++r->nn;
  }
  std::fclose (reader);
}

int main (int argc, char** argv) {
  if (argc < 5) ::errx (1, "Usage %s train test c iter n", argv[0]);
  const float c    = std::strtof (argv[3], NULL);
  const long  iter = std::strtol (argv[4], NULL, 10);
  const long  n    = std::strtol (argv[5], NULL, 10);
  //
  std::vector <ex_t>  ex;
  std::vector <float>  w;
  int nf = 0;
  result_t r;
  //
  for (int i = 0; i < 40; ++i) power10[i] = ::powf (0.1f, i);
  // read examples
  std::fprintf (stderr, "read: ");
  auto start = std::chrono::steady_clock::now ();
  read_data (argv[1], ex, &nf);
  auto d = std::chrono::steady_clock::now () - start;
  std::fprintf (stderr, "%.3fs\n", std::chrono::duration <double> (d).count ());
  // distributed training
  std::fprintf (stderr, "train: ");
  start = std::chrono::steady_clock::now ();
  train (ex, w, iter, c, nf, n);
  d = std::chrono::steady_clock::now () - start;
  std::fprintf (stderr, "%.3fs\n", std::chrono::duration <double> (d).count ());
  // testing
  std::fprintf (stderr, "test: ");
  start = std::chrono::steady_clock::now ();
  test (argv[2], w, nf, &r);
  d = std::chrono::steady_clock::now () - start;
  std::fprintf (stderr, "%.3fs\n", std::chrono::duration <double> (d).count ());
  std::fprintf (stderr, "acc. %2.3f%% (pp %d) (pn %d) (np %d) (nn %d)\n",
                (r.pp + r.nn) * 100.0 / (r.pp + r.pn + r.np + r.nn),
                r.pp, r.pn, r.np, r.nn);
  return 0;
}

実行してみる.c は分散数別にテストデータに対して最適化(達成できる最高精度を見る目的)注: ローカルディスクからデータを読む実験に差し替えるかも. ローカルディスクで実行しても実験結果の傾向には変化がなかったため,そのままにしておく.

// kdd2010 (algebra); http://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/binary.html#kdd2010%20%28algebra%29
// # of data: 8,407,752 / 510,302 (testing)
// # of features: 20,216,830 / 20,216,830 (testing)
> wget http://www.csie.ntu.edu.tw/\~cjlin/libsvmtools/datasets/binary/kdda.bz2 -O - 2>/dev/null | bzcat | ruby -pe '$_.sub!(/^0/, "-1")' > kdda
> wget http://www.csie.ntu.edu.tw/\~cjlin/libsvmtools/datasets/binary/kdda.t.bz2 -O - 2>/dev/null | bzcat | ruby -pe '$_.sub!(/^0/, "-1")' > kdda.t

> g++ -v 2>&1 | grep version
gcc version 4.8.0 20130118 (experimental) (GCC) 
> g++ -O2 -std=c++11 ppa1.cc -o ppa1 -pthread       

// Intel(R) Xeon(R) CPU E7- 4830  @ 2.13GHz; 32CPU 512GM RAM
// 1 CPU (no threading)
> time ./ppa1 kdda kdda.t 0.1 10 0 
read: 44.586s
train: ..........12.066s
test: 3.817s
acc. 89.497% (pp 437099) (pn 5746) (np 47852) (nn 19605)
./ppa1 kdda kdda.t 0.1 10 0  58.84s user 2.71s system 99% cpu 1:01.64 total

// 1 CPU
> time ./ppa1 kdda kdda.t 0.1 10 1
read: 43.902s
train: ..........18.610s
test: 3.286s
acc. 89.497% (pp 437099) (pn 5746) (np 47852) (nn 19605)
./ppa1 kdda kdda.t 0.1 10 1  64.03s user 2.89s system 99% cpu 1:07.02 total

// 2 CPU
> time ./ppa1 kdda kdda.t 0.2 10 2
read: 44.306s
train: ..........10.447s
test: 2.935s
acc. 89.480% (pp 437969) (pn 4876) (np 48809) (nn 18648)
./ppa1 kdda kdda.t 0.2 10 2  63.44s user 2.86s system 112% cpu 58.907 total

// 4 CPU
> time ./ppa1 kdda kdda.t 0.5 10 4
read: 43.885s
train: ..........7.230s
test: 2.322s
acc. 89.417% (pp 436614) (pn 6231) (np 47775) (nn 19682)
./ppa1 kdda kdda.t 0.5 10 4  66.49s user 2.74s system 126% cpu 54.651 total

// 8 CPU
> time ./ppa1 kdda kdda.t 0.5 10 8
read: 43.602s
train: ..........7.861s
test: 2.321s
acc. 89.324% (pp 437992) (pn 4853) (np 49626) (nn 17831)
./ppa1 kdda kdda.t 0.5 10 8  73.22s user 3.01s system 138% cpu 55.004 total

// 16 CPU
> time ./ppa1 kdda kdda.t 0.5 10 16
read: 42.683s
train: ..........15.302s
test: 2.326s
acc. 89.226% (pp 438419) (pn 4426) (np 50555) (nn 16902)
./ppa1 kdda kdda.t 0.5 10 16  96.91s user 2.99s system 162% cpu 1:01.53 total

// liblinear 1.93
> time train -c 0.01 kdda m2; time predict kdda.t m2 out
.*
optimization finished, #iter = 11
Objective value = -26420.646522
nSV = 5738586
train -c 0.01 kdda m2  200.54s user 3.96s system 96% cpu 3:31.86 total
Accuracy = 89.6197% (457331/510302)
predict kdda.t m2 out  13.37s user 1.52s system 98% cpu 15.090 total

というわけで,予想通りほとんど速くならなかった.実装がヘボいというよりは,単純に分散化される学習部本体に対して訓練例の読み込みやパラメタのコピーのコストが大き過ぎるのが問題.特に,標準形式からデータを読み込む処理 (read) のコストが大き過ぎる.
では素性ベクトルをディスクから読まず動的に生成すればよいかというと,その場合は今度は素性抽出のコストが問題になる.学習部の繰り返し回数が多ければ(学習部のコストが大きければ)意味があるのでは,と思うかもしれないが,それはオンライン学習の進化*1に逆行する考え方で,むしろ学習コストは今後さらに減る方向にあると考えた方が良い.
さらに悪いことには,Iterative Parameter Mixing では分散数を増やせば増やすほど(理論上)収束が遅くなってしまう.8CPU を使った実験ですでにその傾向が認められる.同じ繰り返し回数で分散数を増やしていくと,パラメタ平均化の効果が生じることもあり,ある程度は精度を維持できるのだけど,じきに精度は落ちていく.繰り返し回数を増やせばよいのだけど,そうすると,何のために分散化しているのかよく分からなくなる.また,分散数を増やせば増やすほど,学習部のコストが減り,相対的にパラメタのコピーコストが深刻になる.100CPU 使えば 100倍わくわく,といきたいのだけど,現実はそれにはほど遠いようだ.
素性数が少ないタスクのデータセットであれば,少なくとも学習部に関しては線形にスケールするかもしれない.しかし,そもそも素性数が少ない状況では,訓練例数を増やすことに意味が無い場合が多いような気がするので,役に立ちそうな場面が思いつかない.やはり,論文で分散化している構造学習のように学習部のコストが大きい場面で使うべき手法なのだろう.
これで終わるとあまりに惨めなので,std::strtol と std::strtod を泥臭くいじって入力の読み込みを可能な限り高速化してみた (-DUSE_MY_STRTOI, -DUSE_MY_STRTOF で有効化).

> g++ -O2 -DUSE_MY_STRTOI -DUSE_MY_STRTOF -std=c++11 ppa1.cc -o ppa1-- -pthread

// Intel(R) Xeon(R) CPU E7- 4830  @ 2.13GHz; 32CPU 512GM RAM
// 1 CPU (no threading)
> time ./ppa1-- kdda kdda.t 0.1 10 0
read: 13.930s
train: ..........12.073s
test: 0.516s
acc. 89.497% (pp 437099) (pn 5746) (np 47852) (nn 19605)
./ppa1-- kdda kdda.t 0.1 10 0  24.98s user 2.64s system 99% cpu 27.690 total

// 1 CPU
> time ./ppa1-- kdda kdda.t 0.1 10 1
read: 13.446s
train: ..........18.420s
test: 0.900s
acc. 89.497% (pp 437099) (pn 5746) (np 47852) (nn 19605)
./ppa1-- kdda kdda.t 0.1 10 1  31.36s user 2.70s system 99% cpu 34.113 total

// 2 CPU
> time ./ppa1-- kdda kdda.t 0.2 10 2
read: 13.449s
train: ..........10.440s
test: 0.654s
acc. 89.480% (pp 437969) (pn 4876) (np 48809) (nn 18648)
./ppa1-- kdda kdda.t 0.2 10 2  30.64s user 2.74s system 129% cpu 25.850 total

// 4 CPU
> time ./ppa1-- kdda kdda.t 0.5 10 4
read: 13.505s
train: ..........7.316s
test: 0.517s
acc. 89.417% (pp 436614) (pn 6231) (np 47775) (nn 19682)
./ppa1-- kdda kdda.t 0.5 10 4  34.78s user 2.68s system 165% cpu 22.567 total

// 8 CPU
> time ./ppa1-- kdda kdda.t 0.5 10 8
read: 13.458s
train: ..........7.866s
test: 0.518s
acc. 89.324% (pp 437992) (pn 4853) (np 49626) (nn 17831)
./ppa1-- kdda kdda.t 0.5 10 8  41.58s user 2.81s system 192% cpu 23.058 total

// 16 CPU
> time ./ppa1-- kdda kdda.t 0.5 10 16
read: 13.542s
train: ..........15.311s
test: 0.519s
acc. 89.226% (pp 438419) (pn 4426) (np 50555) (nn 16902)

訓練例の読み込み部を改善することで訓練時間は半減した*2.分散化による高速化も20%程度認められた.しかし,虚しい・・・.まあ,訓練例を分けるというのがそもそも筋が良くなくて,素性空間を分けるというのが正解なのだろう.

高速化を目的とするのではなく,訓練例がメモリに載らない場合に使うのなら良いのでは?と思う人がいるかもしれない.しかしその場合でも,無駄にたくさんの計算機を使う前に,訓練例をディスク上に圧縮して読み込み易い形で置いてディスクからオンラインで読むことを試す方が良いと感じる.

*1:素性の信頼度を考慮することで収束を高速化するという研究が継続的に行われている.ただし,自分の手持ちの二値素性のデータセットでは(Soft Confidence-Weighted を除いて)知る限り全ての手法でパラメタを調整することで到達できる最高精度が(平均化 Perceptron や PA-I やに比べて)顕著に劣ることもあり,あまり進化しているという印象はないのだけど.

*2:最初のコードの実装が良くなかっただけでは?と思う人がいるかもしれないが,自分の知る限り,世の中に出回っているオンライン学習の実装は,たいてい最初の std::strtol, std::strtod を使ったコードよりも遅い実装になっている.