DBScan

DBScanという技をラーニング

論文
http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.71.1980
簡単な説明
http://ibisforest.org/index.php?DBSCAN


x-means
http://d.hatena.ne.jp/m-a-o/20100610#p1
などと同じく、クラスタ数を指定する必要がないクラスタリング手法


具体的には、点集合が与えられた時、点を二種類に分類する。データ間の距離が与えられているとして、距離eps以内にN個以上の点があるような点をcore pointと呼び、N個未満しかないような点をborder pointと呼ぶ。ただし、epsとNはクラスタリング時に指定するパラメータ。つまり大雑把には、ある程度高密度にデータが集積してる点と、そうでない例外的な点を分ける。互いに距離eps以内にあるcore point同士を同じクラスターに入れるようにすると、core pointのクラスタリングができる。


一方、border pointについては、次の3つの状況
がありえる
(1)距離eps以内にあるcore pointがない
(2)距離eps以内にあるcore pointは全て同一のクラスターに属する
(3)距離eps以内にあるcore pointが複数のクラスターに属する
(1)の場合、そのborder pointはNoiseとして扱われる。(2)の場合は、近くのcore pointが属するクラスターに入れてやればよい。(3)の場合は、どうすればよいか。論文では、このケースを考慮し忘れている気がする。(3)のようなことが起きると、一意にクラスタリングできない。

(3)のようなことが起きる例として、例えば

S0 = [(0.0 , 0.0)]
S1 = [(0.99+0.1*n , 0) for n in xrange(10)]
S2 = [(-0.99-0.1*n , 0) for n in xrange(10)]
S=S0+S1+S2

のような点集合を考えて、(eps,N)=(1.0 , 3)とすればよい。距離は、普通にユークリッド距離で測る。もし、S1とS2の点が(論文Definition5の意味で)同じクラスターに入るとすると、S1の点とS2の点は互いにdensity-connectiveにならない。一方、これらがそれぞれ別のクラスターに入るとすると、S0の点は、どちらからもdensity-reachableなので、クラスターの極大性に反する。


まあ、こういうことは些細なケースであると言えなくもない。論文にあるアルゴリズムをそのまま実装すると、上記のようなケースで、S0がS1のクラスターに含まれるか、S2のクラスターに含まれるかは、点集合についてのループで最初に出現するcore pointがS1に属するかS2に属するかで決まる。些細なこととは言いつつ、(3)のような、どっちつかずが一番タチが悪くて、これがなければ別にN=1でよいともいえる。こうすれば、単に距離epsを閾値として、データを分類するだけ。実際にクラスタリングすると、大体は(3)のような奴のせいで、分かれてほしいクラスターがくっついたり、それを避けようとすると、くっついてほしいとこが分かれるジレンマに陥る。


x-meansなんかだと、各クラスターがある点の周りにランダムに分布するような時はうまくいって、クラスターの形状は超球状になるのだと思う。現実問題としては、各クラスターがある点の周りに正規分布しているものの、分散が方向に依存して、クラスター形状は超楕円的になる場合が多そうだけれど、分散が非等方的な場合は、どうすればよいのか知らない


一方で、距離関数の作り方の都合とかから、各クラスターがある点の周りに正規分布してくれるとは言えないようなケースも多々あると思う。DBScanは、そういう場合に有効であることを期待されている。

Nとepsをどう決定するのかというのは、当然の疑問で、論文にも最後の方に怪しいことが書いてあるが、とりあえず、自動的に決定できないらしい。

とりあえず、適当に実装

def dbscan(Pts , distance , eps , minPts):
  visited = set([])
  Noise = set([])
  clusters = []
  for P in Pts:
    if P in visited:continue
    visited.add(P)
    seeds = [q for q in Pts if distance(P,q)<eps and P!=q]
    if len(seeds)<minPts:
       Noise.add(P)
    else:
       cluster = [P]
       while 1:
         if len(seeds)==0:break
         Q = seeds.pop()
         if not (Q in visited):
            N = [q for q in Pts if distance(Q,q)<eps and Q!=q]
            if len(N)>=minPts:
               seeds += N
         if not (Q in visited) or (Q in Noise):
            cluster.append(Q)
            visited.add(Q)
            if Q in Noise:Noise.remove(Q)
       clusters.append( cluster )
  return (clusters,Noise)

if__name__=="__main__":
   sqrt = __import__("math").sqrt
   S0 = [(0.0 , 0.0)]
   S1 = [(0.99+0.1*n , 0) for n in xrange(10)]
   S2 = [(-0.99-0.1*n , 0) for n in xrange(10)]
   S=S0+S1+S2
   dbscan(S , lambda x,y:sqrt((x[0]-y[0])**2+(x[1]-y[1])**2) , 1.0 , 3)

謎のベンチマーク

個々の言語で最適化するんじゃなくて、なるべく"同じ"コードで性能を測定。速いコードがほしいなら、Cで書くよって話なので、教科書のアルゴリズムを、そのまま素朴に実装しました的な感じ。

Haskellpythonのようにパラダイムの違う言語間では、同じ書き方をすると、一方が不利になったりするので、C/C++SchemeHaskellはついでで、主にLua/LuaJIT/V8/pythonあたりの差が見たかった。

処理系 空ループ 空末尾再帰 fib(35) mandelbrot集合 挿入ソート tarai
gcc-4.1.2(optionなし) 44ms 205ms 46ms 1ms以下
gcc-4.1.2(-O3) 72ms 30ms 1ms以下
Lua-5.1.4 190ms 1920ms 4300ms 559ms 24390ms 2939ms
LuaJIT 2.0.0 10ms 30ms 379ms 31ms 223ms 280ms
python-2.4 795ms 9768ms 3703ms 48185ms 6756ms
python-2.4+psyco 19ms 444ms 398ms 2472ms 244ms
V8 85ms 498ms 311ms 49ms 340ms
Gauche 0.8.14 1209ms 2551ms 2234ms 1805ms
GHC 6.10.1 941ms <1ms
LuaJIT 2.0.0(jit.off) 66ms 439ms 1242ms 160ms 6000ms 730ms

注釈。
・空ループ(10^7回)
地味に面白い結果になった。
gcc(-O3)に結果がないのは、最適化オプションを付けると、空ループが消えてしまうから。で、LuaJITがgccを上回ってるのは、gccがループ変数をレジスターに割り当ててくれないせい。register修飾子とかつけても、あほなコードを吐くので、asmで書いたら、9msとかになった。多分LuaJITも同じようなコード生成しているのかね。速度的にはそんな感じだけど

pythonは若干謎で、関数の中にループを書かないと倍くらい遅いし、psycoも高速化してくれない。人の書いたpythonコード見てると、if __name__=="__main__"の中にコードを直書きするのではなく、main関数を別に作ってる人がいて、趣味の問題かと思ってたけど、こんな差があるのか。けどまあ、psycoだとV8より速い。

あと、Lua/python/JSのいずれでも、whileで回すより、forで回す方が速い。フーン


・空末尾再帰(10^7回)
空ループを末尾再帰版。gaucheの空ループを、どうしようかと思ったのだけど、なるべく類似性の高いコードで比較するという原則に従って別項目として扱うべきかと思った。しかし、殆どの処理系が再帰限界の前に屈したのだった。だらしない。LuaJITは、なんかイカサマしてるんじゃないかと思うくらいの速さ。


・fib(35)
35番目のFibonacci数。再帰とかどんな感じか的な。やっぱC/C++速い。V8/psyco/LuaJITあたりのネイティブコード野郎たちが、まあ大体同じ速度。GHCはだらしない。ネイティブコードの越えられない壁の後に、バイトコード野郎が続く。Gaucheが意外と速くて、まあでもpythonLuaより遅いねっていう


・mandelbrot集合
よくある80x80のmandelbrot集合の描画。LuaJIT異常に速いな〜っていう。まあ、単純なループの中で、簡単な浮動小数点演算をするだけなので、ループさえ最適化できるなら、C/C++に匹敵できる感じなのかもしれない


・挿入ソート
JSには、ちゃんとした配列があるけど、Luaにはテーブルしかないという差なのかね


・tarai(12,6,0)
GHCに面目躍如させたかった。わけではなく、fib(35)でLuaGaucheに負けてるのが気になったので、再帰をもう一個。やっぱり、gaucheの方が速い



psycoが結構速い。挿入ソートで、V8に大差で負けてるのは、Pythonのリストがindexアクセスが苦手とかいう点が大きい気がする。なので、pyscoはLanguage shootoutで、よい成績を出せそうではある。


それはともかく、そもそも何をしたかったかというと。LuaとかLuaJITが速いとかよく聞くんだけど、なんでなんかな?的な部分がよく分からんというのが動機だった。Lua/JS/pythonはよく似ていて、今回のコードも、{}がbegin~endになるとかの差はあっても、ほぼ同じコードで書けた(まあ、単にC/C++風ともいえる)。pythonなんかは、coroutineなし、再帰限界あり、制限されたlambdaと、機能的にはLuaに大分劣るとさえ言える。なのに、pythonやJSがLuaより遅いとしたら、その原因は、どこらへんにあるのか?(メモリ消費量もなんでそんな少なくて済むのか?というのも)という点が気になった。ていうか、本当に速いの?速いと言っても、何が得意で何が苦手なの?みたいなのを、もうちょっと知りたかった。


で、とりあえず、LuaJITは異常に速い。JITを使用しないですら、通常のLuaの3~4倍の速度。JITなしでもmandelbrot集合の描画とか、V8より速い。JITすれば最適化したC/C++並の速度が出る場合もある。公式のベンチマーク見ると、ここまで速くなったのは、2.0.0かららしい。再帰はループのようにはいかないっぽいけど、Luaはスタックオーバーフローしました!ってわけにはいかないので、そのへんで限界があるような気がしないでもない。


一体なにが起きているのかと思って、ソース見てみたところ、LuaJITは、単に普通のLuaJIT機能を付け加えたものなんだと思ってたけど、(少なくとも、LuaJIT2.0.0では)VMからして全く別物っぽく、LuaJIT VMはLuaVMと異なる命令セットを持って、VMレジスターをCPUのレジスターに割り当てるよう、VMは全部アセンブラで書くという気合の入り方。標準ライブラリもx86で速くなるようにチューニングされているっぽい。VM自体が違うので、実際のところ、何がどのくらい性能向上に貢献しているか分からないし、バイトコードを吐いてくれない/ポータビリティも失われたという点は大きなデメリットではある。


けどまあ、本気出せばここまで速くなるんだな〜というよい目標ではある。尤も、上のベンチマークだと、psycoで10~30倍くらい加速してるけど、実際のアプリケーションでは2~3倍にしか加速しないのが普通だし、Cで書いた部分で時間を食われてれば全く加速しないことも稀に良くあることなので、上のベンチマークに於ける2~3倍くらいの差は、実際のアプリケーションでは誤差程度の差にしかならないかもしれない。Luaはそもそも単体で動かすことはあまり多くないだろうし、LuaJITですらLuaの3~4倍程度だし、果たして、そこまで手間かける価値あるのか?という点は難しいとこであるけど


一方、Luaが速いという点については、どうなんだろう。確かにpythonよりは速そうではある。でも、末尾再帰再帰の速度は、gaucheに負けている。schemeでは再帰が多用される一方、Lua再帰を使うことは比較的少ないだろうので、、不利な勝負ではあるけれど。Language shootout見ても、多くの処理系がJITなりAOTなりでネイティブコード生成するので、それらより遅くても仕方ないという感がある。純粋にバイトコード生成して実行する処理系は、RubyPythonくらい。なので、Luaが速いのではなく、RubyPythonが遅いのでは?という可能性もある。


とはいえ。ループは速いので、何が起きてるか見てみる。

> cat loop.lua
for i=0,10000000 do end
> luac -l loop.lua

main <loop.lua:0,0> (6 instructions, 24 bytes at 0x9302d10)
0+ params, 4 slots, 0 upvalues, 4 locals, 3 constants, 0 functions
        1       [1]     LOADK           0 -1    ; 0
        2       [1]     LOADK           1 -2    ; 10000000
        3       [1]     LOADK           2 -3    ; 1
        4       [1]     FORPREP         0 0     ; to 5
        5       [1]     FORLOOP         0 -1    ; to 5
        6       [1]     RETURN          0 1

なんかfor文専用の命令がある。手続き型言語VMだと、これが普通だったりするのだろうか。for文の中で、何かする時は、FORPREPとFORLOOPの間に色々挟まるらしい。一方、whileで書くと

> cat loop2.lua
local c=0
while 1 do c=c+1 if c==10000000 then break end end
> luac -l loop2.lua

main <loop2.lua:0,0> (7 instructions, 28 bytes at 0x8ab4d10)
0+ params, 2 slots, 0 upvalues, 1 local, 3 constants, 0 functions
        1       [1]     LOADK           0 -1    ; 0
        2       [2]     ADD             0 0 -2  ; - 1
        3       [2]     EQ              0 0 -3  ; - 10000000
        4       [2]     JMP             -3      ; to 2
        5       [2]     JMP             1       ; to 7
        6       [2]     JMP             -5      ; to 2
        7       [2]     RETURN          0 1

これの実行時間は、上のforで書いた時の3倍くらい遅い。ADDして、EQしてJMPするので、大雑把に一命令で済むのが3命令になる感じなのか。Pythonと比較しても、1.5倍くらい速い程度。結局Luaのループが速いのは、単にc++;if(c==10000000)break;という処理を1命令でやってくれるからという部分がでかい。まあ、Luaでループを多用するであろうことを考えれば、合理的な設計ではある。ちなみに、Squirrelを、ちらっと見た感じでは、FORPREP/FORLOOPに相当する命令は見当たらなかった。

ちなみに、cをlocal変数にしないと、無茶苦茶遅くなって、for文の10倍以上遅くなる。バイトコード見ると、SETGLOBAL/GETGLOBALが沢山挟まって、ステップ数が大幅に増える。pythonには、local修飾子とかないので、Luaと比べて不利なのもこの点かな、という気はする。


ついでに末尾再帰

> cat loop3.lua
function loop(n)
  if n<10000000 then
    return loop(n+1)
  else
    return
  end
end

loop(0)
> luac -l loop3.lua

main <loop3.lua:0,0> (6 instructions, 24 bytes at 0x97c2d10)
0+ params, 2 slots, 0 upvalues, 0 locals, 2 constants, 1 function
        1       [7]     CLOSURE         0 0     ; 0x97c2e40
        2       [1]     SETGLOBAL       0 -1    ; loop
        3       [9]     GETGLOBAL       0 -1    ; loop
        4       [9]     LOADK           1 -2    ; 0
        5       [9]     CALL            0 2 1
        6       [9]     RETURN          0 1

function <loop3.lua:1,7> (9 instructions, 36 bytes at 0x97c2e40)
1 param, 3 slots, 0 upvalues, 1 local, 3 constants, 0 functions
        1       [2]     LT              0 0 -1  ; - 10000000
        2       [2]     JMP             5       ; to 8
        3       [3]     GETGLOBAL       1 -2    ; loop
        4       [3]     ADD             2 0 -3  ; - 1
        5       [3]     TAILCALL        1 2 0
        6       [3]     RETURN          1 0
        7       [3]     JMP             1       ; to 9
        8       [5]     RETURN          0 1
        9       [7]     RETURN          0 1

Gaucheの場合

gosh> (define (loop n) (if (< n 10000000) (loop (+ n 1)) '()))
loop
gosh> (disasm loop)
main_code (name=loop, code=0x9fd6ed0, size=11, const=1, stack=4):
args: #f
     0 CONST 10000000
     2 LREF-VAL0-BNLT(0,0) 9    ; (< n 10000000)
     4 LREF0                    ; n
     5 NUMADDI(1)               ; (+ n 1)
     6 PUSH-GREF-TAIL-CALL(1) #<identifier user#loop>; (loop (+ n 1))
     8 RET
     9 CONSTN
    10 RET
#<undef>

Luaと同じような命令が生成される。実行速度も、Luaの1.5倍程度。まあでも、Schemeが、末尾再帰最適化があればループはいらんと言っても、速度的には厳しいものがあるのかな〜という感じではある。とはいえ、LuaJITではJITすると、whileより末尾再帰の方が速くなったりするけど

最後に、Pythonのfor文

>>> def test():
...   for _ in xrange(10000000):pass
...
>>> __import__('dis').dis(test)
  2           0 SETUP_LOOP              20 (to 23)
              3 LOAD_GLOBAL              0 (xrange)
              6 LOAD_CONST               1 (10000000)
              9 CALL_FUNCTION            1
             12 GET_ITER
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_FAST               0 (_)
             19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK
        >>   23 LOAD_CONST               0 (None)
             26 RETURN_VALUE

もっと速くてもいい気はするけど、謎


色々見てると、Squirrelはどうなんだろうとか、LLVM-luaはどうなんだとか、気になってきたけど、キリがないので、このへんで。


全部貼ると多いので、pythonLuaソースコードだけ。pythonに慣れると、endしかない行とか、}しかない行とかが、とてもうざく感じる

続きを読む