コサイン距離ベースのLSHをRubyで
参考文献:Web+DB press vol.49 レコメンド特集のPart3など。
アルゴリズムの概要
詳細(特に数学的な)はぐぐれ。
モチベーションとしては、高次元における近傍点探索を高速で行いたい。まじめにやるとどう工夫しても計算量がすごいことになるので、近似で。
どうするかというと、「距離が近いと同じような値になるハッシュ関数」を使う。あるベクトルの近傍を求めたい場合、そのベクトルのハッシュと同じ(もしくは近い)値のハッシュを持つベクトルをテーブルから引いてきて返す。計算量がどうなるかはややこしいけど、とりあえず全部探すよりは速い。
で、どういう関数をハッシュとするのか。これは距離の定義によって異なる。ハミング距離、コサイン距離、ユークリッド距離などにはそういった関数の存在が知られている。
コサイン距離の場合、ランダムなベクトルをいくつか用意して、入力されたベクトルがそれらと似ているか(相関度>0か)によって0/1の列を作ってハッシュとする。近いベクトル同士は似たベクトルもだいたいおなじなので、まあそれっぽくはある(数学的詳細は略)。
探索時には、対象となるベクトルのハッシュを求める→そのハッシュに近い値を持つバケツから候補ベクトルをもってくる→距離でソートして足きり。
ハッシュ値0101は0100や1101と似てる。全部列挙するのは残念。ビットの順序をランダムにシャッフルしてソートしてから近いのを取り出す操作を複数回行う。
基準となるベクトルの数をいくつにするのかとか探索時に何回試行するのかとかで精度と計算量をコントロールできる。私はどう決めればいいのかよくわからないですけど。まあ多ければ多いほど正確で遅くなるよね。
出力の精度
出力を実際の順位と比較してみる。
ベクトルの長さは40、サンプル数は10000.
fullはO(n^2)の全探索。lshはLSHによる近似解。
# test.rb sample preparing vectors(dim=40,N=10000) constructing LSH index user system total real 5.687000 0.015000 5.702000 ( 5.703000) ==================================================== full: [[0, 0.581864900382649], [1, 0.574474091288696], [2, 0.523016426961361], [3, 0.499381700218387], [4, 0.486891924653849], [5, 0.473448941861435], [6, 0.462915299959355], [7, 0.458705998000323], [8, 0.450913275426321], [9, 0.449166354628291]] lsh: [[18, 0.437988633765385], [22, 0.425749431997503], [53, 0.390452490928918], [60, 0.38346026175299], [117, 0.347519319663936], [118, 0.347510046229593], [132, 0.341924602491841], [145, 0.337067156923535], [178, 0.326775842196073], [179, 0.326516411304021]] (以下略)
まあそれっぽい要素がとれてるんじゃないですかね(精度低いのはおいといて)
速度
N=1000
# test.rb bench preparing vectors(dim=40,N=1000) constructing LSH index user system total real 0.547000 0.000000 0.547000 ( 0.562000) listing 100 NNs with full scan(100 times) user system total real 16.078000 0.000000 16.078000 ( 16.094000) listing 100 NNs with lsh(100 times) user system total real 10.062000 0.032000 10.094000 ( 10.173000)
N=5000
# test.rb bench preparing vectors(dim=40,N=5000) constructing LSH index user system total real 2.907000 0.000000 2.907000 ( 2.985000) listing 100 NNs with full scan(100 times) user system total real 88.984000 0.016000 89.000000 ( 89.806000) listing 100 NNs with lsh(100 times) user system total real 17.656000 0.015000 17.671000 ( 17.830000)
N=10000
# test.rb bench preparing vectors(dim=40,N=10000) constructing LSH index user system total real 5.687000 0.000000 5.687000 ( 5.688000) listing 100 NNs with full scan(100 times) user system total real 181.094000 0.032000 181.126000 (181.891000) listing 100 NNs with lsh(100 times) user system total real 21.641000 0.000000 21.641000 ( 21.657000)
まあなるほど。
ところでLSHは速度と精度のトレードオフをわりとかんたんに制御できるのがいいかなーとおもいました。
まだパラメータをどう決めるのかよくわかってないけど。
ソース
# lsh.rb require 'pp' module LSH class Cosine def initialize(dim,l,&accessor) @dim=dim @accessor=accessor @vectors=gen_vectors(dim,l) @items=[] @l=l reflesh_index end def gen_vectors(dim,l) (0...l).map{ (0...dim).map{ -1+rand*2 } } end def add(item) @items.push item end def nears(item,n) vec=@accessor.call(item) h=hash_of(vec) p=7349 items_per_bucket=@items.size.to_f/@buckets.size w=(n/items_per_bucket).to_i candidates=[] 5.times { a=1+rand(p-1) b=rand(p) (((h-w) % @buckets.length)..((h+w) % @buckets.length)).each{|i| candidates.push @buckets[shuffle(i,a,b,p)] } } return candidates.flatten(1).uniq{|x|x.id}.sort_by{|x|-self.class.sim(vec,@accessor.call(x))}[0...n] end def shuffle(h,a,b,p) return (a*h+b) % p % @buckets.length end def reflesh_index @buckets=(0...2**@l).map{|i| []} @items.each{|item| h=hash_of(@accessor.call(item)) @buckets[h].push item } end def hash_of(item_vec) @vectors.inject(0){|res,vec| res*2+(self.class.posi_sim?(item_vec,vec) ? 1 : 0)} end def self.dist(v_a,v_b) 1.0-dist(v_a,v_b) end def self.posi_sim?(v_a,v_b) a=0.0 (0...v_a.size).each{|i| a+=v_a[i]*v_b[i] } return a > 0 end def self.sim(v_a,v_b) a=0.0 b=0.0 c=0.0 (0...v_a.size).each{|i| ia=v_a[i] ib=v_b[i] a+=ia*ib b+=ia*ia c+=ib*ib } return a/(Math.sqrt(b)*Math.sqrt(c)) end end end
# test.rb require 'benchmark' require 'pp' require File.join(File.dirname(__FILE__),'lsh.rb') Vec=Struct.new(:id,:vec) ## ベクトルの次元 DIM=40 ## ベクトルの個数 N=10000 ## LSHのアレ L=7 ## ベンチマーク時の繰り返し数 BENCH_LOOP=100 ## ベンチマーク時の近傍取得数 BENCH_NN_COUNT=100 def gen_vecs(dim,n) (0...n).map{|i| Vec.new(i,(0...dim).map{-1+rand*2}) } end def get_nears(v,vecs,n) sort_by_sim(v,vecs)[0...n] end def sort_by_sim(v,vecs) vecs.sort_by{|vec| -LSH::Cosine.sim(v.vec,vec.vec) } end def prepare puts "preparing vectors(dim=#{DIM},N=#{N})" @vecs=gen_vecs(DIM,N) @lsh=LSH::Cosine.new(DIM,L) {|item| item.vec } puts 'constructing LSH index' puts Benchmark::CAPTION puts Benchmark.measure { @vecs.each{|v| @lsh.add v} @lsh.reflesh_index } end case ARGV.first when 'bench' prepare puts "listing #{BENCH_NN_COUNT} NNs with full scan(#{BENCH_LOOP} times)" puts Benchmark::CAPTION puts Benchmark.measure { BENCH_LOOP.times { nears=get_nears(@vecs.choice,@vecs,BENCH_NN_COUNT) } } puts "listing #{BENCH_NN_COUNT} NNs with lsh(#{BENCH_LOOP} times)" puts Benchmark::CAPTION puts Benchmark.measure { BENCH_LOOP.times { nears=@lsh.nears(@vecs.choice,BENCH_NN_COUNT) } } when 'sample' prepare gen_vecs(DIM,3).each{|target| puts '====================================================' puts 'full:' list_with_rank=sort_by_sim(target,@vecs).to_enum.with_index.to_a pp list_with_rank[0...10].map{|v,i|[i,LSH::Cosine.sim(target.vec,v.vec)]} puts 'lsh:' pp @lsh.nears(target,10).map{|v|[list_with_rank.find{|x|v.id==x[0].id}[1],LSH::Cosine.sim(target.vec,v.vec)]} } when 'indexing' prepare else puts <<EOS usage: #{$0} bench|sample EOS end