비싼 k-nearest neighbors 탐색 비용을 줄이기 위하여, 정확하지는 않더라도 빠르게 query point 와 비슷한 점들을 탐색할 필요가 있습니다. Approximated Nearest Neighbor Search (ANNS) 는 이를 위한 방법들입니다. Hash function 에 기반한 Locality Sensitive Hashing (LSH) 이 대표적이지만, network 를 이용하는 방법들도 많습니다. Small-world phenomenon 을 이용하는 방법으로 random neighbor graph 와 nearest neighbor graph 를 혼합하여 indexing 을 하는 방법이 있습니다. 이번 포스트에서는 network 의 특징을 이용하여 안정적인 성능으로 최인접 이웃을 검색하는 방법에 대하여 알아봅니다.
Nearest neighbor problem
최인접이웃 (nearest neighbors)은 regression, classification 에 이용되는 가장 기본적인 모델입니다. Non-parametric regression 은 주어진 query point 와 비슷한 점들의 y 값의 평균을 이용하여 regression 을 수행합니다.
where
Nearest neighbors 방법은 classification 에서는 Naive Bayes classifier 의 오차의 2 배 이하라고도 알려져 있으며, 가장 단순한 classifier 이기 때문에 다른 classifier 연구의 base model 로도 널리 사용됩니다. 일단 아주 직관적인 방법이기 때문에 이해도 쉽습니다.
그러나 하나의 query point 가 주어질 때 reference data 에서 가장 가까운 k 개의 점을 찾기 위해서는 모든 점들과의 거리를 계산해야 합니다. 만약 n 개의 점이 존재한다면 의 비용이 드는 매우 비싼 알고리즘이라 말합니다. 그러나 데이터의 크기가 커지면 절대 이런 brute-force 방법으로 계산을 하지 않습니다.
Approximated nearest neighbor search (ANNS) 란, 정확한 k 개의 최인접이웃을 찾지는 못하더라도, 비슷한 최인접이웃을 빠르게 찾기 위한 방법입니다. 이를 위한 방법은 다양합니다. i-Distance 같은 clustering 을 기반으로 하는 방법이나 tree 기반 방법들도 이용되었습니다. 이 다양한 방법들의 공통점은 벡터 공간을 단순한 공간으로 분할하여 이해한다는 것 (vector quantization) 입니다. 이 관점에서 더 발전된 모델은 hash function 을 이용하는 Locality Sensitive Hashing (LSH) 입니다. 정답이 있을만한 지역 코드 (hash code) 를 부여하여, 해당 지역만 빠르게 탐색하는 모델입니다. LSH 에 대한 자세한 설명은 이전의 포스트를 참고하세요
Locality Sensitve Hashing 계열 (이후에 LSH 의 단점들을 많이 보완한 후속연구들이 많이 제안되었습니다) index 들이 주로 연구가 되었지만, 그 방법만 있는 것은 아닙니다. Very sparse data 에 대해서는 여전히 inverted index 도 유용하며, netowrk 기반의 index 는 안정적인 검색 성능을 보여주기도 합니다.
이번 포스트에서는 network 기반으로 작동하는 nearest neighbor search algorithm 에 대하여 살펴봅니다.
Network based Nearest Neighbor Indexer
아래 그림은 원리 설명을 위한 예시 데이터입니다. 13 개의 2 차원의 데이터입니다.
각 점마다 가장 가까운 2 개의 점들을 이어줍니다. 반드시 모든 점의 이웃의 갯수가 같을 필요는 없습니다. 작은 숫자이면 됩니다. 또한 단방향, 양방향이어도 상관없습니다. 자신과 가까운 점들을 알고 있는 것이 중요합니다.
여기서 한 가지, 우리가 풀려 하는 문제는 주어진 reference data 에 대하여 가장 가까운 점들을 빠르게 찾는 것이 아닙니다. 임의의 query point 에 대하여 빠르게 k-nearest neighbors 를 찾는 것입니다. Reference data 에 대한 k-nearest neighbor graph 를 만드는 것은 한 번의 training 입니다. Querying time 을 빠르게 만드는 것이 목표입니다.
물론 이 과정에서 NN-Descent 와 같은 neighbor graph 를 빠르게 만들어주는 알고리즘을 이용할 수도 있습니다.
두번째로, 각 점 마다 임의로 몇 개의 점을 연결합니다. Random connected graph 입니다. 이는 한 지역에서 다른 지역으로 jump 를 할 수 있는, 일종의 고속도로 역할을 합니다.
그리고 이 두 그래프 (nearest neighbor, random neighbor graph) 를 겹쳐줍니다. 이 과정까지가 indexing 입니다.
초기화: 하나의 query point 가 주어지면 p 개의 임의의 seed points 를 선택합니다. 아래 그림에서는 하나의 점 x1 을 선택하였습니다. x1 에서 이동할 수 있는 점 (nearest & random neighbor of x1) 중에서 query point 와 더 가까운 p 개의 점을 선택합니다 (아래 그림에서는 p=1 입니다).
반복 탐색: 임의로 선택하였기 때문에 query point 와 매우 먼 x1 이 선택되었고, nearest neighbor of x1 중에서는 query point 와 가까운 점이 없습니다. 대신 random neighbors 중에서 query point 와 가까운 xr 이 있습니다. 이 점을 frontier points 로 설정합니다. 현재까지 알려진 query point 와 가장 가까운 p 개의 점이라는 의미입니다. 이 점의 nearest & random neighbors 중에서 query point 와 가까운 점은 없는지 다시 탐색합니다. 점점 query point 와 가까운 점으로 frontier points 가 업데이트 됩니다.
반복 탐색 종료: 이 과정을 미리 설정된 max steps 번 만큼 반복하던지, frontier points 가 변하지 않을 때까지만 반복합니다.
p 는 k 이상이면 됩니다. 오히려 찾아야 하는 nearest neighbors 의 개수인 k 보다 더 큰 p 개의 frontier points 를 유지하는 것이 탐색을 더 빠르게 만들어줍니다.
Implementation
Index 는 index 함수와 search 함수가 필요합니다.
Skeleton
우리가 설정해야 하는 parameters 는 index 의 nearest, random neighbors 의 개수입니다. batch_size 는 brute force 방식으로 nearest neighbor 를 찾을 때 out-of-memory 를 방지하기 위한 변수입니다.
index 함수
index() 함수를 구현합니다. Random neighbors 를 만드는 법은 간단합니다. numpy.random.randint 를 이용하면 0 부터 n_data 사이의 integer 를 (n_data, num_rn) 크기의 numpy.ndarray 로 만들어줍니다. 물론 이 때 한 점에 대한 random neighbors 에 같은 점이 포함될 수도 있습니다만, 이를 까다롭게 걸러낼 필요는 없습니다. Search 과정에서 중복된 점을 제거하면 됩니다. (그리고 중복된 점이 만들어질 가능성은 정말로 매우 작습니다)
most_closest_points 함수는 우리가 곧바로 구현할 함수입니다. X 의 각 점에 대하여, 자신을 제외한 topk 의 nearest neighbor point indices 를 return 하는 함수입니다.
most_closest_points() 함수를 구현합니다. b 와 e 는 mini batch 의 시작과 끝점입니다. 이 구간의 X[b:e] 를 잘라 pairwise_distances(X[b:e], X) 를 계산합니다. b 부터 e 까지의 점 중에서 X 와 가장 가까운 점들을 찾습니다.
그런데 X 와 X 간의 거리를 계산하면 자신의 거리는 0 이기 때문에 가장 가까운 이웃에 자신이 포함됩니다. 이를 방지하기 위해서 dist_.copy().argsort(axis=1)[:,1:topk+1] 를 하였습니다. 1:topk+1 을 하면, 자신을 제외한 topk 개의 가장 가까운 점을 찾을 수 있습니다.
거리값 역시 dist_[:,1:topk+1] 를 통하여 자신을 제외한 거리를 고릅니다.
이들을 각각 idxs[b:e], dist[b:e] 에 저장합니다.
idxs, dist 는 X 의 모든 점에 대하여 자신과 가장 가까운 topk 개의 점들과 그들과의 거리값이 저장된 (n_data, topk) 크기의 numpy.ndarray 입니다.
search 함수
앞서 indexing 하는 함수를 만들었으니, 이번에는 k-nearest neighbors 를 찾는 search_neighbors() 함수를 구현해봅니다.
buffer_size 는 k 개의 nearest neighbors 를 찾는 동안의 후보인 frontier points 의 개수입니다. k 의 3 배 정도로 설정하였습니다.
initialize(query, buffer_size) 함수는 buffer_size 개만큼의 random seeds 를 선택한 다음, 그 점들과 query points 간의 거리를 계산하는 함수입니다. 이 함수는 뒤에서 구현합니다.
최대 max_steps 만큼 network 를 이동하며 frontier points 를 업데이트 합니다. Column vector 형태인 numpy.ndarray 는 numpy.concatenate 를 이용하여 붙일 수 있습니다. 이 merged column vector 에 numpy.unique 를 적용하면 중복된 값은 제거됩니다. 앞서 random neighbors 를 만들 때, 혹시라도 중복된 점들이 선택되더라도 이 과정에서 안전하게 제거됩니다. 이를 candi_idxs 라 하였습니다.
concatenate 하는 점들은 이전 step 에서 frontier points 로 선택된 idxs 와 이 idxs 의 random & nearest neighbors 입니다. Neighbor points 를 가져오는 함수 _get_neighbors() 는 이후에 구현합니다.
X[candi_idxs] 로 reference data 를 slicing 을 하여 query point 와의 pairwise distance 를 계산합니다. 이 값 역시 column vector 로 만들기 위하여 reshape(-1) 을 하였습니다.
그 뒤에는 most_closest_points() 함수를 구현할 때처럼 argsort 를 이용하여 현재의 candi_idxs 중에서 query point 와 가장 가까운 buffer_size 개의 점을 frontier points (idxs_) 로 선택합니다.
Frontier points 가 업데이트 되었는지는 dist 의 average 값이 변하였는지를 확인함으로써 해결합니다. 이 diff 값을 출력하면 수렴 속도를 확인할 수 있습니다.
앞서 언급한 _initialize() 함수를 구현합니다. numpy.random.randint() 함수를 이용하여 buffer_size 개의 integer 인 idxs 를 만듭니다. 이 역시 중복된 점이 포함되어도 괜찮습니다. 이후에 np.unique 를 이용하여 제거할 것이기 때문입니다.
임의로 선택된 점들 (refx)과 query point 와의 pairwise distance 를 계산하여 dist 에 저장합니다. 이들 (dist, idxs) 을 return 합니다.
base 들의 random & nearest neighbors 를 가져오는 _get_neighbors(base) 함수를 구현합니다.
Random 과 nearest neighbors 의 index 는 각각 self.rn, self.nn 에 있습니다. 이들을 가져온 뒤, column vector 로 만들기 위하여 reshape(-1) 을 합니다. 이 값을 numpy.concatenate() 를 이용하여 하나의 column vector로 만들어 return 합니다.
Usage
앞서 설명한 코드는 verbose mode 를 제외한 코드입니다. 작동 과정을 살펴보기 위해 몇 개의 verbose 를 넣어둔 구현체를 github에 올려두었습니다. API 는 같으니 올려둔 구현체를 기준으로 demo 를 만들어봅니다.
실험에 이용할 20 차원의 100,000 개의 인공데이터를 만듭니다.
class instance 를 만들면서 동시에 indexing 을 합니다. 아래의 값은 default vaule 입니다. 이는 사용자에 의하여 조절 가능합니다.
Verbose mode 에서는 batch_size 에 의하여 나뉘어진 mini batch 의 계산 현황과 남은 시간이 출력됩니다. Indexing 이 끝난 뒤에는 indexing 계산 시간이 출력됩니다.
Indexing ...
batch 50 / 50 done. computation time = 1370.529929 sec.
Indexing was done
현재 indexing 시간이 오래 걸리는 이유는 nearest neighbor graph 를 만들기 위하여 brute-force 로 모든 pairwise distance 를 계산하기 때문입니다. 이 부분은 NN-descent 와 같은 더 빠른 nearest neighbor graph constructor 로 대체해야 합니다.
search 에는 a query vector 를 입력하도록 되어 있습니다. Column 이 아닌 a row 가 입력되어야 sklearn.metric.pairwise_distances 를 이용할 수 있습니다. Sample 로 0 번째 row 를 reshape(1, -1) 을 이용하여 a row vector 로 만들었습니다.
search_neighbors(query, k) 함수를 이용하면 dist, idxs 만 출력됩니다.
search_neighbors() 함수는 _search_neighbors_dev() 함수를 호출한 뒤, dist, idxs 만 선택하여 return 하는 함수입니다.
_search_neighbors_dev(query, k) 함수는 network 를 이용하여 탐색되는 점들의 업데이트 과정을 살펴보기 위한 함수입니다.
infos 는 frontier points 를 업데이트하는 매 step 의 평균 거리와 이전 step 과의 거리차이가 저장된 list 입니다.
계산 시간은 process_time 에 저장되어 있습니다. 단위는 초 입니다.
# dist
array([2.98023224e-08, 2.85201523e-01, 2.95597815e-01, 3.36564885e-01,
3.56957804e-01, 3.70693699e-01, 3.74081769e-01, 3.74408702e-01,
3.87436487e-01, 4.00869548e-01]),
# idxs
array([ 0, 59013, 71981, 2622, 70642, 24061, 13159, 88382, 83117,
15864]),
# infos
[(0, 0.8117015629564891, 0.410979860874393),
(1, 0.649891623106027, 0.1618099398504621),
(2, 0.5649134791311035, 0.08497814397492354),
(3, 0.46662289235625853, 0.09829058677484492),
(4, 0.40744066573492627, 0.05918222662133227),
(5, 0.4000953222237806, 0.0073453435111456855),
(6, 0.4000953222237806, 0.0)],
# process_time [secs]
0.0019750595092773438)
Small-world phenomenon
Small-world phenomenon 은 Stanley Milgram 의 6 단계 분리 이론 실험으로 널리 알려진 network 의 특징입니다. 이 실험은 임의의 어떤 사람 (query point) 에게 편지를 한 통 씁니다. 그 다음, 나의 친구 k 명에게 “이 편지를 받아야 하는 사람 (query point)을 알 것 같은 사람”에게 이 편지를 전해주라고 부탁합니다. 편지를 부탁 받은 사람들은 편지를 k 개로 복사하여 자신의 다른 k 명의 친구에게 똑같은 부탁을 합니다. 편지가 전달된다면 그 경로에 몇 명의 사람의 손을 거쳐 전달이 되었는지를 확인합니다. 그 결과 미국 내에서 임의의 사람에게 편지가 전해지는데 평균 6 단계가 걸린다는 실험입니다.
사실 친구의 친구라면 최대 명의 사람에게 이를 물어본 것과 같습니다. 그 친구까지 고려하면 살펴볼 친구는 매우 많아집니다. 그런데 부탁을 받은 사람들은 자신의 친구들 중 임의의 k 명이 아닌, “편지를 받는 사람을 알 것 같은 사람 (query point 와 더 가까워질 수 있는 사람)”을 선택합니다. 이 과정은 우리가 살펴본 알고리즘에서 nearest & random neighbors 중에서 query point 와 더 가까운 p 개의 frontier points 를 선택하는 과정입니다.
만약 nearest neighbor graph 만 이용하였다면 아주 먼 initial points 가 선택되면 조금밖에 query point 와 가까워지지 않습니다. 그러나 random neighbors 를 연결해두었기 때문에 “편지를 받을 사람을 매우 잘 알 것 같은 의외의 인맥”에게 편지를 부탁할 수 있게 됩니다.
하지만 random neighbor graph 만을 이용하면 query point 와 어느 정도 가까워졌을 경우, 정말로 가까운 이웃을 모를 수도 있습니다.
즉, random neighbor graph 는 전체 공간을 종횡으로 횡단하며 query point 와 가까운 아주 큰 지역을 이동합니다. 그리고 query point 근처에 도착하면 nearest neighbor graph 에 의하여 조금씩 더 가까이 이동합니다. 이는 마치 “서울시 관악구” 까지는 빠르게 이동한 뒤, 관악구 내에서는 천천히 이동하며 편지를 받을 사람을 찾아가는 것과 같습니다.
그리고 기하급수적으로 탐색 영역이 넓어지기 때문에 neighbor networks 가 매우 이상하게 꼬이지 않는다면, 대체로 탐색 횟수 (num steps)는 비슷합니다. 즉, 임의의 query point 에 대하여 안정적인 탐색 속도를 보여준다는 의미입니다.
위 데이터의 2,000 개의 sample queries 에 대하여 search time 과 search steps 를 확인합니다. max stems 는 20 으로 설정하였습니다.
대부분은 7 ~ 12 번 사이에 탐색이 됩니다. 이 횟수는 nearest & random neighbors 의 개수에 따라 달라집니다. 한 step 에 여러 개의 이웃을 살펴본다면 steps 의 횟수는 조금 줄어듭니다. 하지만 그만큼 index 가 많은 정보를 저장하고 있어야 합니다.
num steps | num case |
6 | 35 |
7 | 157 |
8 | 380 |
9 | 443 |
10 | 369 |
11 | 267 |
12 | 154 |
13 | 98 |
14 | 36 |
15 | 28 |
16 | 16 |
17 | 4 |
18 | 8 |
19 | 3 |
20 | 2 |
시간도 0.001 ~ 0.002 사이에 대부분 몰려 있습니다.
search time [secs] | percentage |
---|---|
0.001015 ~ 0.001292 | 7.75 % |
0.001292 ~ 0.001569 | 34.45 % |
0.001569 ~ 0.001846 | 23.30 % |
0.001846 ~ 0.002122 | 17.65 % |
0.002122 ~ 0.002399 | 9.90 % |
0.002399 ~ 0.002676 | 3.05 % |
0.002676 ~ 0.002953 | 1.65 % |
0.002953 ~ 0.003230 | 0.70 % |
0.003230 ~ 0.003506 | 0.60 % |
0.003506 ~ 0.003783 | 0.35 % |
0.003783 ~ 0.004060 | 0.10 % |
실제로 100,000 개의 reference data 에 대하여 full pairwise distance 의 계산과 sorting 을 하였을 경우 평균 0.027 초의 시간이 걸립니다. 대략 15 ~ 25 배 정도 빠른 nearest neighbor search 가 이뤄집니다.
물론 성능은 정확하지는 않습니다. 하지만, distance 를 확인하면 큰 차이가 나지 않습니다. 더 주목해야 하는 점은, (이 포스트에서는 다루지 않았지만) 데이터의 개수가 10 배 늘어난다 하여도 network 를 이용한 탐색 시간은 10 배로 늘어나지 않습니다. 이웃의 이웃의 이웃을 한 단계만 더 살펴보아도 10 배 이상의 이웃을 살펴볼 수 있기 때문입니다. 즉, 데이터의 크기의 증가분에 따라 탐색 비용이 선형으로 증가하지 않습니다. 하지만 full pairwise distance computation 은 정직하게 데이터의 크기만큼 계산 시간이 늘어납니다.
Related posts
잠깐 언급된 NN-descent 는 neighbor search 를 위한 index 는 아닙니다. 주어진 reference data, X 에 대하여 X 간의 (approximated) k-nearest neighbor graph 를 빠르게 찾기 위한 방법입니다. 그리고 이 알고리즘의 작동 원리도 small-world phenomenon 입니다. 이에 대해서는 이후에 다른 포스트에서 다루도록 하겠습니다.