<아랑고DB> 프로젝트. 그래프를 통한 영화 추천시스템 만들어보기 2 (최종편)

|
정말 오래간만에 작성하는 글이다. 아랑고DB 관련 글을 진작 마무리했어야 했는데 그러지 못했다. 🤦🏻‍♂️ 이번 글에서는 지난 글에 이어 그래프를 활용한 추천 시스템을 실습하려고 한다.

1. 들어가기에 앞서

일단 지난 시간에 아랑고DB에 넣어둔 데이터를 확인해보자. 보다 복잡한 분석을 위해서는 파이썬을 통해 아랑고DB에 AQL을 날려서 데이터를 주고받고 해야하지만 여기서는 최대한 간단한 형태의 추천 시스템을 실습할 것이기 때문에 오로지 AQL을 통해서만 결과를 낼 것이다.

아랑고DB 웹 UI로 들어가서 movie_ratings 데이터베이스를 선택한 후, 좌측의 QUERIES 메뉴를 클릭하자.

웹UI 관련 설정이 기억나지 않거나 이슈가 있으면 이 글에서 Web UI 세팅 부분을 다시 살펴보자.

2. 데이터 살펴보기

이제 실제로 데이터가 잘 들어갔는지 확인해보자.

우선 각 컬렉션의 데이터 개수를 확인한다. COLLECTIONS에서 각 컬렉션을 들어가서 좌측 하단의 데이터 개수를 확인해도 되는데, 여기서는 AQL을 통해 확인해보겠다.

// AQL 하나의 리턴만 하기 때문에 아래를 각각 실행시켜줘야 함에 주의
RETURN length(rated)  // 84942

RETURN length(User) // 1000

RETURN length(Movie) // 17763

유저는 천 명으로 제한했었기 때문에 딱 1000개의 레코드가 있고, 영화는 17763개, 그리고 평가는 84942개가 있다.

이번에는 유저별, 영화별, 년도별 평가의 개수를 확인해보자.

// AQL에서 통계값을 뽑는 관련 함수는 없는  같다. 각각을 개별 계산해준다. 
LET rates = (
FOR e IN rated
    COLLECT
        user = Document(e._from).user
    AGGREGATE
        cnt = COUNT(1)
    RETURN {
        user,
        cnt
    }
)

RETURN {
    min: min(rates[*].cnt),
    max: max(rates[*].cnt),
    avg: avg(rates[*].cnt)
}

위 쿼리는 일단 유저별로 평가의 개수를 모아준 뒤에(rates), 이 배열에서 cnt의 최소, 최대, 평균값만을 다시 계산한 결과이다.

유저들은 최소 20개에서 최대 2500개(!)의 평가를 내렸고, 평균적으로 84개의 평가를 내렸다. 아마 애초에 Netflix Prize에 사용되었던 데이터이기 때문에 양질의 평가를 한 사람들만을 추려놓은 것이라 생각된다.

// 영화별 rating 개수
FOR e IN rated
    COLLECT
        movie = Document(e._to).name
    AGGREGATE
        cnt = COUNT(1)
    
    SORT cnt DESC
    
    RETURN {
        movie,
        cnt
    }

쿼리는 아까와 유사하다. 그룹을 해주는 기준만 영화 타이틀로 바뀌었을 뿐이다. 같은 통계를 내보면, 최소 1개의 평가가 있는 영화부터, 최대 640개의 평가가 있는 영화까지 다양하다. 평균적으로 22개의 평가가 있다.

오해하면 안 될 것이, 전체 Netflix의 평가가 아닌, 내가 임의로 선별한 아주 작은 데이터가 그렇다는 것이다.

// 이번에는 년도별로
FOR e IN rated
    COLLECT
        date = SUBSTRING(e.date, 0, 4)
    AGGREGATE
        cnt = COUNT(1)
    
    SORT cnt DESC
    
    RETURN {
        date,
        cnt
    }

얘는 따로 통계를 볼 필요가 없다. 위 쿼리만 실행해도 2000년~2005년 사이 년도별 개수가 나오기 때문이다. 확실히 오래된 데이터라 2005년 데이터가 가장 많다.

3. 협업 필터링(Collaborative Filtering, CF)

이번 실습에서는 추천 시스템의 한 가지 방법인 협업 필터링을 사용한다. (간단히 CF라고 줄여서 부르겠다)

CF는 가장 널리 사용되는 기법 중 하나이고, 이름 그대로 주변 사람들의 정보를 이용하여 (협업, collaborate) 예측을 만들어내는 (filtering) 기법이다.

위키피디아의 예시를 가져와보면, CF의 기본 아이디어는 아래와 같다.

  • 유저 A와 유저 B가 1번 주제에 대해 의견이 같다고 가정하자.
  • 그럼 A는 임의의 유저보다, 또다른 2,3,4.. 주제에 대해서도 B와 의견이 같을 확률이 높을 것이다.

즉, 비슷한(similar) 유저일수록 취향이 비슷할 것이고, 그 점을 기반으로 추천 값을 만들어내는 것이다.

CF에도 여러가지 종류가 있는데, 크게는 메모리 기반(Memory-based or Neighborhood-based)과 모델 기반(Model-based)로 분류된다.

  • Memory based CF : ‘메모리’ 기반에서는, 메모리에 주어진 ‘데이터’ 내에서 여러 계산을 거쳐 추천 값을 생성한다.
  • Model based CF : ‘모델’ 기반에서는, 주어진 ‘데이터’를 통해 모델을 학습시켜서 이 모델이 추천 값을 생성하도록 한다.

추천 시스템에 관해 공부하고, 설명할 내용은 정말 많이 있다. CF도 자세하게 들어갈 수 있고, 이 외의 추천 방법론, 그리고 평가가 implicit 할 때의 방법 등.. 다룰 내용이 많다. 이건 기회가 되면 따로 시리즈로 작성해보도록 해야겠다. 😃

4. 메모리 기반 CF

여기서는 메모리 기반 CF를 쓸 건데, 메모리 기반도 크게 두 가지 종류로 나뉜다.

A 유저의 아이템 B에 대한 평가를 예측한다고 할 때, 각각은 아래처럼 해석할 수 있다.

  • User-based : A 유저와 비슷한 성향을 가진 다른 유저들이 B에 대해 어떤 평가를 내렸는지 종합해본다.
  • Item-based : B 아이템과 비슷한 성향을 가진 다른 아이템들에 대해 A가 어떤 평가를 내렸는지 종합해본다.

여기서는 user-based CF를 사용할 예정이다. 다양한 가정에 따라 다양한 수식을 적용할 수 있으니 함께 찬찬히 살펴보자.

1) CF with equal similarity

가장 일반적인 형태는 아래와 같다.

[r_{u,i} = \frac{1}{N}\sum_{u \prime \in U}r_{u \prime, I}]

위 수식의 좌변은 유저 $u$가 아이템 $i$에 대해 내릴 평가를 의미한다. 이는 예측값이다.

우변은 타겟 유저와 유사한 성향을 가진 집합 $U$에 속한 $u\prime$ 들이 상품 $i$에 대해 내린 평가를 다 더한 뒤, 집합의 크기인 N으로 나누어주고 있다. 즉, 타겟 유저의 아이템 $i$에 대한 평가를 예측하기 위해, 유사한 상위 N명의 $i$에 대한 평가를 평균낸 것이다. ($i$에 대해 평가한 사람들에 한해서 걸러내기 때문에 각 아이템마다 이 그룹은 달라진다)

일단 유사도라는 값은 이미 주어진 값이라고 생각하고, 아래에서 간단히 짚고 넘어가겠다.

2) CF with different similarity

여기서 의문을 가질 수 있는 부분이 있다. 유사한 상위 N명에 대해 그들의 점수를 평균내는 것은 이해하겠는데, 이들 중 누군가는 정말 비슷한 취향을 가졌을 것이고, 누군가는 그다지 취향이 맞지 않을 수도 있다.

따라서, 유사 그룹의 점수를 평균 내는 것이 아닌, 유사도에 따라 가중 평균을 해줄 수 있다.

[r_{u,i} = k \sum_{u \prime \in U}sim(u, u\prime)r_{u\prime, i}]

위 식에서 실제로 k값은 $ 1/ \sum_{u\prime \in U}|sim(u, u\prime)| $에 해당한다. 즉 가중 평균이기 때문에 가중치들의 합으로 나눠주는 것이다.

3) CF with different similarity, rating adjusted

더 나아가, 평가 점수 자체에 대한 의문을 제기할 수 있다. A 유저는 점수를 후하게 주는 사람이고, B는 그렇지 않은 사람일 때, A의 4점과 B의 2점은 비슷한 평가 수준일 수 있다. 그럼, 개인이 점수 주는 범위를 계산해서, 잘 스케일링 할 수 있지 않을까?

[r_{u,i} = \bar r_u + k \sum_{u \prime \in U}sim(u, u\prime)(r_{u\prime, i} - \bar r_{u\prime})]

2번 식과 달라진 부분은, 유사 사용자의 평가를 그대로 쓰는 대신, 평균 중심화(mean-centered) 된 값을 가중평균하고, 거기에 타겟 사용자의 평균 점수를 다시 더해준다는 점이다.

즉, 사람마다 평가 기준이 다르기 때문에 이를 나름대로의 표준화 과정을 거친 상태에서 가중 평균을 해주고, 거기에 다시 타겟 사용자의 평균값을 더해준다는 의미이다.

4) CF in graph

CF는 구현 방식이 정말 다양하기 때문에 그래프DB의 특성을 살려서 구현을 할 수도 있다. 예를 들어, ‘유사도’라는 개념을 ‘A 사용자 노드에서 k 스텝을 랜덤 워크 했을 때, 많이 겹치는 정도’라고 정의할 수도 있다. 즉, A 사용자에서 출발해서 임의의 그래프 횡단을 했을 때, 특정 사용자에게 많이 도달할수록 유사한 타겟이라는 의미가 된다.

Disclaimer

다만, 여기서는 1번 수식을 채택한다. 모두가 동일한 가중치를 가지고 있다고 가정하고, 가장 간단한 형태의 계산만을 할 것이다. 아랑고DB에 중점을 둔 글이기 때문에 이렇게 문법을 쓸 수 있다는 것에 집중하고, 추천 시스템의 정확한 프로세스와 검증 과정은 추천 시스템 관련 글에서 다루도록 하겠다!

5. 그래프를 통한 유사도 계산

이제 실전으로 넘어가보자!

유사도를 계산하는 방법은 피어슨 상관계수를 쓰거나, 코사인 유사도를 쓰는 방법 등이 있다. 여기서는 더 구현이 편한 코사인 유사도를 사용한다.

[cos(\vec x, \vec y) = \frac{\sum_{i\in I_{xy}}r_{x,i}r_{y,i}}{\sqrt {\sum_{i \in I_x}r_{x, i}^2} \sqrt {\sum_{i \in I_y}r_{y, i}^2}}]

위 수식들을 그래프DB로 되살려보자. 여기서는 전체 데이터셋에 대한 추천값을 계산하지 않고, 임의의 타겟 사용자 1명에 대해, Top-K 영화를 추천해 볼 것이다. 유사 그룹은 30명 사이즈라고 가정하겠다.

아래 AQL은 단순 구현을 위해 만들어졌기 때문에 효율적이지 않다. 데이터 재사용이 적고, 반복 계산이 많기 때문에 시간이 오래 걸린다. 여기서는 구현 자체만을 신경썼기에 실제로 구현을 할 예정이라면 글의 마지막을 참고하자.
LET target = Document('User/1158991')

LET sim = (
	//1
    LET movies_target = (
    FOR movie, e, p IN OUTBOUND target rated
        RETURN e
    )
	
	//2
    LET movies_target_distinct = FLATTEN(UNIQUE(
        RETURN movies_target[*]._to
    ))

	//3
    LET movies_target_ratings_square = (
        RETURN SUM(movies_target[* RETURN POW(CURRENT.rating, 2)])
    )	
	
	//4
    LET users_with_same_movie = (
    FOR movie IN OUTBOUND target rated
        FOR other_user IN INBOUND movie rated
            FILTER target != other_user
            RETURN DISTINCT other_user
    )
	
	//5
    FOR other_user IN users_with_same_movie
        LET movies_other = (
        FOR movie, e, p IN OUTBOUND other_user rated
            RETURN e
        )
        LET movies_other_distinct = FLATTEN(UNIQUE(
            RETURN movies_other[*]._to
        ))
        LET movies_other_ratings_square = (
            RETURN SUM(movies_other[* RETURN POW(CURRENT.rating, 2)])
        )
        LET dot_product = SUM(
        FOR movie IN INTERSECTION(movies_target_distinct, movies_other_distinct)
            LET score_target = movies_target[* FILTER CURRENT._to == movie].rating
            LET score_other = movies_other[* FILTER CURRENT._to == movie].rating
            RETURN score_target * score_other
        )
        
        LET cosine_sim = dot_product / (SQRT(movies_target_ratings_square) * SQRT(movies_other_ratings_square))
        
        SORT cosine_sim DESC
        
        RETURN {
            other_user,
            cosine_sim
        }
)

위 쿼리는 일단 타겟 사용자(target)을 임의로 지정해준다. 그리고 이 타겟과 나머지 사용자에 대한 유사도를 계산해주고 있다.

  • 1: 타겟 사용자가 평가한 영화와 그 엣지를 모은다
  • 2: 타겟 사용자가 평가한 고유 영화를 모은다
  • 3: 타겟 사용자가 평가한 점수의 제곱의 합에 루트를 씌운 값. 즉, 코사인 유사도에서 $ \lVert \vec x\rVert $ 에 해당하는 값
  • 4: 2번에서 모은 영화를 함께 본 사용자의 목록. 어차피 함께 본 영화가 없다면 유사도가 없기 때문에 이들은 쿼리의 효율성을 위해 신경쓰지 않는다
  • 5: 4번에서 대상 유사 그룹에 대해, 그들의 $ \lVert \vec y \rVert$ 값과, 타겟과 그들의 스칼라 곱($ \vec x \cdot \vec y $)을 구한다

그리고 이어서 다음 쿼리가 진행된다 (글에서는 분리했지만, 위 쿼리에 바로 이어서 실행된다)

FOR movie IN Movie
	// 1
    LET inflow = FIRST(
    FOR v, e, p IN INBOUND movie rated
        COLLECT WITH COUNT INTO length
        RETURN length
    )
    
    FILTER inflow >= 30
    	
	// 2
    LET candidates = (
    FOR user IN INBOUND movie rated
        FILTER user != target
        RETURN DISTINCT user
    )

	// 3    
    LET predict_score = AVG(
    FOR doc IN sim
        FILTER doc.other_user IN candidates
        LIMIT 30
        FOR v, e, p IN OUTBOUND doc.other_user rated
            FILTER v == movie
            RETURN e.rating
    )
    
    SORT predict_score DESC
    
    RETURN {
        'name': movie.name, 
        predict_score
    }

이제 타겟 사용자의 전체 영화에 대한 예측 평점을 구할 차례이다.

  • 1: 전체 영화를 순회하되, 유의미한 선별을 위해 평가가 30개 이상인 영화만 남긴다 (평가가 없는 cold start 문제는 다른 방법으로 해소해 줘야 한다! 여기서는 무시함)
  • 2: 해당 영화를 본 사람들의 목록을 만든다
  • 3: 처음에 구한 (타겟, 다른 유저) 유사도를 순회하며, 함께 이 영화를 본(2번 목록에 있는) 사람들의 점수를 평균낸다. 유사 그룹은 30명으로 제한한다.

이렇게 결과값을 내면 아래 그림처럼 타겟이 좋아할만한 영화의 점수와 순위를 매길 수 있다!

Arango Results

이런 식으로 전체 유저에 대해 적용하면, 가장 단순한 형태의 CF를 구현해 볼 수 있다. 단, 그래프DB를 통해 더 깊이 들어갈 예정이라면 아래 6번은 꼭 읽어보자!

6. 그래프DB를 CF에 적용할 때의 어려움, 주의점

메모리 기반 CF에서 계산의 핵심은 유사도를 구하는 작업이다. 사용자와 사용자, 혹은 아이템과 아이템 사이의 유사도를 구해야하는데, 이는 $O(m^2)$의 복잡도를 갖는다. 따라서 대상의 수가 늘어날수록 급격하게 계산의 비용이 높아진다.

사실 그래도, 작은 규모의 데이터에서는 파이썬의 행렬 곱을 통해 유사도 행렬을 쉽게 구할 수가 있는데, 그래프DB에서는 그렇지가 않은 것 같다. 그래서 위 AQL도 반복적인 계산이 많고, 비효율적으로 구성된 것 같다. (분명 코드 개선의 여지는 있다! 근데 여기선 그게 중점이 아니어서 일단 넘어감 😂)

결론적으로, 일반적인 메모리 기반 CF를 구현하는 게 목적이라면, 그냥 파이썬을 사용하기를 추천드린다. 그래프DB를 활용하려면 유사도와 같이 계산 집약적인 작업은 파이썬으로 넘기고, 탐색 관련 작업을 그래프로 구현하는 것이다. 그렇게 하려면 그래프 초기 설계부터 잡고 들어가는 게 맞다. 즉, 사용자 A와 B 사이에 유사도 엣지를 추가하는 등의 방법을 쓰는 것!

7. 아랑고 시리즈를 마치며

이 글을 포함하여 무려 11편의 아랑고DB 시리즈가 끝이 났다. 아랑고DB를 접한 뒤, 꼭 그래프DB에 대한 가이드를 만들고 싶었기에 꾸준히 작성해왔는데, 돌아보니 시작하길 잘했다는 생각이 든다 :)

Neo4j가 주류인 상황에서 아랑고DB를 검색하는 한국 사용자가 얼마나 될까 싶지만, 그래프DB에 대한 간단한 개념을 익히기에는 아랑고DB만한 게 없다고 생각한다. 누군가에게는 가뭄의 단비처럼… 도움이 되길 바란다.

아랑고 시리즈를 읽어주셔서 감사하다는 말씀을 전하며, 저는 또 다른 글로 찾아 뵙도록 하겠습니다! 감사합니다.

  1. 아랑고DB란? 왜 쓰는가?
  2. 아랑고DB 세팅하기 on Ubuntu
  3. 아랑고DB 쉘로 붙어서 명령어 체험해보기, 실체 파악해보기
  4. AQL(Arango Query Lang) 배워보기 1
  5. AQL(Arango Query Lang) 배워보기 2 - RETURN / UPDATE
  6. AQL(Arango Query Lang) 배워보기 3 - REPLACE / UPSERT / REMOVE
  7. 그래프 개념잡기
  8. 그래프 횡단하기 Graph Traversal
  9. 데이터 모으기 COLLECT / AGGREGATE / MIN_BY, MAX_BY
  10. 프로젝트. 그래프를 통한 영화 추천시스템 만들어보기 1
  11. (지금 보고있는 글) 프로젝트. 그래프를 통한 영화 추천시스템 만들어보기 2 (최종편)

<아랑고DB> 프로젝트. 그래프를 통한 영화 추천시스템 만들어보기 1

|
아랑고DB는 배울만큼 배웠다! 이제는 실전만이 남아있다 🐱 마지막을 장식한 프로젝트에서는 Kaggle에서 가져온 영화 데이터를 아랑고DB에 맵핑하고, 실제 쿼리를 통해 추천 시스템을 만드는 것을 실습한다.

1. Kaggle이란

Kaggle 캐글은 데이터를 통해 현실의 문제를 해결하기 위한 플랫폼이다.

데이터셋을 제공하고, 상금을 걸어 세계 각지에 있는 소중규모 팀들이 챌린지를 하는데,** 원하는 결과값에 가장 가까운 팀이 상금을 차지하는 방식이다.

이러한 챌린지뿐만 아니라 수많은 데이터셋을 제공하고, 이러한 데이터셋을 다른 사람들은 어떻게 접근했고 분석했는지도 살펴볼 수 있다.

나는 그 중에서 Netflix Prize Data라는 데이터셋을 가져와 변형하였다.

내 글을 참고하여 자신만의 데이터셋을 찾아 맵핑해도 좋으나, 이러한 과정이 처음이라면 내 글을 그대로 따라하는 것을 추천한다.

나는 1 ~ 4번의 전체 데이터 파일에서, 1번 파일만 사용하였고, 그 중에서도 1000명의 유저에 대한 데이터만 샘플링하였다.

최종적으로는 아래 두 개의 파일로 정리했으니, 받아서 사용하면 된다.

  • 영화에 관한 메타 데이터 파일 : 여기
  • 사용자 평가에 관한 데이터 파일 : 여기

혹시 다운로드가 안 된다면 여기에 들어가서 직접 다운로드 해주자.

두 파일을 로컬에 저장해도 좋고, 그냥 url을 코드에 넣어서 그때그때 다운로드해도 무방하다.

2. 데이터 살펴보고 변형하기

일단 데이터를 불러와서, 최종 형태만 살펴보자.

데이터는 csv 형태이므로 pandas 패키지의 read_csv를 사용한다.

import pandas as pd

movies = pd.read_csv('https://raw.githubusercontent.com/ud803/ud803.github.io/main/public/movie_titles.csv', encoding='ISO-8859-1', header=None, names=['id', 'year', 'name'])
ratings = pd.read_csv('https://raw.githubusercontent.com/ud803/ud803.github.io/main/public/netflix.csv', encoding='ISO-8859-1', header=None, names=['user', 'movie', 'rating', 'date'])

print(movies.shape, ratings.shape)
#(17770, 3), (84942, 4)

shape 함수를 통해 데이터프레임의 사이즈를 보면, 각각 17770, 84942개의 행을 가진 것을 알 수 있다.

데이터를 더 자세히 살펴보자

# 타입 확인하기
movies.dtypes
ratings.dtypes

# NA값 확인하기
movies[movies.isna().any(axis=1)]
ratings[ratings.isna().any(axis=1)]

확인해보니 movies의 출시 연도가 float 형태로 되어있다.

정수형으로 바꾸고 NA 값도 날려주자.

# dropNA
movies = movies.dropna()

# 제작 연도를 정수형으로 바꿔준다
movies = movies.astype({'year' : 'int32'})

3. 아랑고에 넣을 데이터 설계하기

이제 가장 재미있는 일만 남았다. 이 데이터들을 어떻게 아랑고DB에 맵핑할 것인가?

모든 데이터베이스가 마찬가지이지만, 일단 내가 데이터를 어떤 형태로 저장할 것이고, 어떻게 참조(쿼리)할 것인가를 구상해야 한다.

내 계획은 이렇다. 영화 추천 시스템이니까 당연히 영화(M)라는 버텍스가 있어야 하고, 사람(U)을 나타내는 버텍스가 또 있어야 한다.

그리고 사람(U)이 영화(M)를 평가(R)하는 엣지가 중간에 존재해야 한다. TMI이지만 Neo4j Cypher에서는 이를 (User) -[rated]-> (Movie) 와 같은 직관적인 방식으로 표현한다.

거기에 더해서, 나는 영화의 출시 연도도 추천 시스템에 넣고 싶다. 결과적으로, 아래의 같은 5개의 컬렉션들이 생성되어야 한다.

  • 버텍스 컬렉션 : 영화(Movie), 사람(User), 출시연도(Year)
  • 엣지 컬렉션 : (사람)-[rated]->(영화), (영화)-[created_at]->(출시연도)

4. 아랑고에 데이터 맵핑하기

자 이제 데이터를 어떻게 넣으면 될까? 이제껏 배운 방식을 총동원하여 각각에 맞는 데이터를 넣어줘도 되지만, 우리는 이미 만들어진 패키지를 가져다 쓰면 된다.

바로 python-arango-mapper라는 패키지이며, 내가 만들었다.. 😊

맵핑할 데이터에 대한 스키마를 지정해주면, 자동으로 데이터를 넣어준다.

우선 pip을 통해 패키지를 설치해주자.

# Python 3.7 이상에서 동작한다!!

pip install python-arango-mapper
각각의 타입을 주석으로 써두었으니, 다른 데이터에 적용할 사람들은 깃허브 페이지에 가서 해당 타입에 대한 가이드를 보면서 사용법을 익히길 바란다! 😘 아랑고DB를 사용하는 사람이라면 정말로 유용하게 사용할 수 있을 것이다!
schemas = {
# Type 1
    'Movie': {
        'type': ('vertex', 'unique_vertex'),
        'collection': 'Movie',
        'unique_key': ('id',),
        'fields': {
            'name': 'name',
            'year': 'year'
        },
        'index': [
            {'field' : ('name',), 'unique' : False, 'ttl' : False}
        ]
    },
    # Type 1
    'Year': {
        'type': ('vertex', 'unique_vertex'),
        'collection': 'Year',
        'unique_key': ('year',),
        'fields': {
            'year': 'year'
        },
        'index': [
            {'field' : ('year',), 'unique' : True, 'ttl' : False}
        ]
    },
    # Type 1
    'User': {
        'type': ('vertex', 'unique_vertex'),
        'collection': 'User',
        'unique_key': ('user',),
        'fields': {
            'user': 'user'
        },
        'index': [
            {'field' : ('user',), 'unique' : True, 'ttl' : False}
        ]
    },


    # User - rated -> Movie
    # Type 3
    'rated': {
        'type': ('edge', 'unique_edge_on_event'),
        'collection': 'rated',
        'unique_key': (date,),
        '_from_collection': 'User',
        '_from': ('user',),
        '_to_collection': 'Movie',
        '_to': ('movie',),
        'fields': {
            'rating': 'rating',
            'date': 'date'
        },
        'index': []
    },

    # Movie - created_at -> Year
    # Type 2
    created_at: {
        'type': ('edge', 'unique_edge_btw_vertices'),
        'collection': 'created_at',
        '_from_collection': 'Movie',
        '_from': ('id',),
        '_to_collection': 'Year',
        '_to': ('year',),
        'fields': {
        },
        'index': []
    }
}

스키마가 복잡해보이지만, 한 번 세팅해두면 계속해서 손쉽게 사용할 수 있다.

from pam import client, database, converter

# ArangoDB에 연결
arango_conn = client.get_arango_conn(hosts="http://localhost:8529")
database_obj = database.create_and_get_database(arango_conn, 'movie_ratings', 'root', 'passwd')

# 데이터프레임을 딕셔너리의 리스트 형태로 바꿔준다
data_1 = movies.to_dict('records')
# 전체 스키마를 사용하는 것이 아니기 때문에 사용할 리스트를 지정해준다
data_1_map = ['Movie', 'created_at', 'Year']
converter.arango_converter(data_1, database_obj, schemas, data_1_map)

# Data 2 Upload
data_2 = ratings.to_dict('records')
data_2_map = ['User', 'rated']
converter.arango_converter(data_2, database_obj, schemas, data_2_map)

위 코드만 실행시켜주면 데이터는 성공적으로 들어간다!

5. 다음 단계는

이제 데이터를 모두 넣었다. pam 패키지를 통해 어떠한 데이터 종류든 손쉽게 그래프 형태로 맵핑이 가능하다!

다음 글에서는 실제 들어온 데이터를 가지고 이제껏 배워온 AQL을 뽐내는 시간을 가져보자.

그래프 탐색도 하고, 통계도 뽑아보고, 최종적으로는 추천 시스템을 만들어 볼 예정이다.

아마 다음 글이 아랑고DB 시리즈의 마지막 글이 될 것 같다 😢

애정을 가지고 작성해온 만큼, 유종의 미를 거둬야겠다!! 지금까지 글을 읽으며 따라와주신 분들께 감사하다는 말씀을 드리고 싶다 :)

6. 어디까지왔나

  1. 아랑고DB란? 왜 쓰는가?
  2. 아랑고DB 세팅하기 on Ubuntu
  3. 아랑고DB 쉘로 붙어서 명령어 체험해보기, 실체 파악해보기
  4. AQL(Arango Query Lang) 배워보기 1
  5. AQL(Arango Query Lang) 배워보기 2 - RETURN / UPDATE
  6. AQL(Arango Query Lang) 배워보기 3 - REPLACE / UPSERT / REMOVE
  7. 그래프 개념잡기
  8. 그래프 횡단하기 Graph Traversal
  9. 데이터 모으기 COLLECT / AGGREGATE / MIN_BY, MAX_BY
  10. (지금 보고있는 글)프로젝트. 그래프를 통한 영화 추천시스템 만들어보기 1
  11. 프로젝트. 그래프를 통한 영화 추천시스템 만들어보기 2 (최종편)

<파이썬> 파이썬 패키지 만들어보기

|
이번 글에서는 파이썬 패키지를 만드는 방법을 배워보려고 한다. 아랑고DB 관련 글을 작성하면서 맵퍼를 패키지화 할 필요가 있는데, 겸사겸사 글도 함께 작성하게 되었다. 정신없는 2021 연말, 연초를 거쳐 아랑고DB 맵퍼를 함께 만들다보니 오랜만에 작성하는 글이 되었다. 😂

들어가기에 앞서

이 글은 미디엄에 있는 잘 쓰여진 글PyPI 튜토리얼을 나도 실제로 따라하면서 한글로 정리하는 형태로 진행될 예정이다.

또한 모듈을 만들 정도면 기본적인 파이썬 지식이나 쉘 커맨드를 알고 있다고 가정하고 세부적인 설명은 생략한다.

본문에서는 이러한 내용도 모두 짚고 넘어가니, 여기서 설명이 부족하다면 꼭 살펴보자.

1. 모듈? 패키지?

파이썬에서 프로그램이 길어지게 되면 스크립트를 여러 파일로 분할하게 된다. 이때, .py 형태로 파일을 나누게 되고, 이들을 각각 모듈 modules 이라고 부른다.

모듈에서 선언된 모든 함수와 선언들은 다른 모듈에서 임포트 될 수 있다.

패키지는 파이썬의 모듈을 구조화하는 형태를 의미하고, 모듈들의 집합을 나타낸다.

우리는 여러 모듈로 구성된 패키지를 만들 예정이다!

심심하면 여기를 더 읽어보자.

2. 패키지 만들어보기

나는 Ubuntu Shell을 통해 해당 작업을 진행할 예정이다. 아마 일부 커맨드를 제외하고는 파이썬 명령어를 쓸 일이 더 많을 것 같으니 OS가 달라도 괜찮지 않을까..?

1) 디렉토리 구조 세팅해주기

PyPI의 튜토리얼을 보면 디렉토리 경로를 설정해주는 것부터가 시작이다. 여기서 사용자가 내 패키지를 어떤 이름으로 임포트 할 지 미리 생각해두면 좋다.

왜냐하면 실제로 패키지를 인스톨하는 pip install 패키지이름 명령어와 import 패키지이름의 명령어에서 쓰이는 이름은 다를 수 있기 때문이다.

만약 내가 A라는 패키지 이름을 원했지만, 이미 PyPI 패키지에 유사한 이름이 있는 경우 이 이름을 사용하지 못한다!

그럼에도 임포트는 A라는 이름으로 하길 원하면 패키지의 디렉토리 이름을 그렇게 설정해주면 된다.

아래는 내가 설정한 디렉토리 구조의 예시이다. 나는 아랑고DB를 쉽게 맵핑해주는 파이썬 패키지를 만들고 있다. 이름은 python-arango-mapper이며, 임포트는 pam으로 하고싶다.

python_arango_mapper/
├──LICENSE
├──README.md
├──setup.py
├──src/
│   └── pam/
│       ├──__init.py
│       ├──그리고 패키지에서 사용하는 다른 파일들

위에서 보면 패키지 루트 디렉토리에는 패키지와 관련된 메타 정보를 갖는 파일들이 포함된다.

  • LICENSE는 내 패키지의 이용 권리에 관한 disclaimer이며, 여기에서 나에게 맞는 타입을 골라서, 복사해서 텍스트 그대로 붙여넣으면 된다.
  • README.md는 깃 저장소를 최초 만들 때 생성되는 파일과 동일하다. 패키지에 대한 설명을 넣어주면 된다.
  • 그다음 가장 중요한 파일이 setup.py인데, 내 파이썬 패키지에 대한 모든 설정 정보들을 포함한다.

2) setup.py 세팅

내가 만든 setup.py는 아래와 같이 구성되어 있다.

from setuptools import find_packages, setup
import setuptools

install_requires = [
    'bleach==4.1.0',
    'certifi==2021.10.8',
    'cffi==1.15.0',
    'charset-normalizer==2.0.9',
    'colorama==0.4.4',
    'cryptography==36.0.1',
    'dataclasses==0.8',
    'docutils==0.18.1',
    'idna==3.3',
    'importlib-metadata==4.8.3',
    'jeepney==0.7.1',
    'keyring==23.4.0',
    'packaging==21.3',
    'pkg_resources==0.0.0',
    'pkginfo==1.8.2',
    'pycparser==2.21',
    'Pygments==2.10.0',
    'PyJWT==2.3.0',
    'pyparsing==3.0.6',
    'python-arango==7.3.0',
    'readme-renderer==32.0',
    'requests==2.26.0',
    'requests-toolbelt==0.9.1',
    'rfc3986==1.5.0',
    'SecretStorage==3.3.1',
    'setuptools-scm==6.3.2',
    'six==1.16.0',
    'tomli==1.2.3',
    'tqdm==4.62.3',
    'twine==3.7.1',
    'typing_extensions==4.0.1',
    'urllib3==1.26.7',
    'webencodings==0.5.1',
    'zipp==3.6.0'
]

with open("README.md", "r", encoding="utf-8") as fh:
    long_description = fh.read()

setup(
    name='python-arango-mapper',
    package_dir={"": "src"},
    packages=setuptools.find_packages(where="src"),
    python_requires=">=3.6",
    install_requires=install_requires,
    version='0.1.5',
    description='fast and easy-to-use python-arango mapper library',
    long_description=long_description,
    long_description_content_type="text/markdown",
    author='lee ui dam',
    author_email='ud803da@gmail.com',
    url='https://github.com/ud803/python-arango-mapper',
    license='GNU General Public License v3.0'
)
install_requires에 들어갈 패키지는 수동으로 하나하나 작성할 필요가 없다. 내가 테스트했던 환경에서 pip freeze 명령어를 쓰면 설치된 목록이 쭉 나온다. 불필요한 패키지는 없애고 복사해서 사용하자.
  • 패키지를 구동하기 위해 필요한 최소한의 패키지 목록을 install_requires에 문자열의 리스트로 기록해준다
  • name은 PyPI에 등록할 패키지의 정식 이름이다. 중복되거나 부적절한 경우 등록시 에러가 난다.
  • package_dirpackages는 패키지에 포함할 소스의 위치에 관한 설정이며, 나는 위처럼 해주었다.
  • version은 내가 설정하는 내 패키지의 버전을 의미한다. 나도 아직 버저닝에 익숙하지는 않지만, **동일한 버전인 경우 중복 & 수정 업로드가 불가능하다!! **
  • long_description은 PyPI 에 올라갈 내 패키지 설명 페이지에 들어갈 내용이다. long을 쓰지 않으면 README.md가 아닌 짧은 설명만 올라가길래 이렇게 설정했다

3) 배포 버전 만들기

이제 준비가 다 끝났으니, 실제 파이썬 명령어를 통해 배포 버전을 만들고, 배포해보자.

python3 명령어 뒤에 붙는 -m 옵션은 옵션 뒤에 따라오는 파이썬 패키지를 실행한다는 의미이다. 즉, 아래 명령어는 python3에서 pip 패키지를 쓸건데, 패키지의 명령어가 install이고, 그 install의 옵션이 --upgrade pip이에요 라는 뜻이다.
# pip 업그레이드
python3 -m pip install --upgrade pip

# build 패키지 업그레이드
python3 -m pip install --upgrade build

# setup.py가 있는 디렉토리에서 실행, 패키지를 빌드한다
python3 -m build

이렇게하면 python_arango_mapper/dist/ 폴더에 두 개의 파일이 생긴다. 이제 업로드해보자.

업로드 전에 테스트 PyPI실제 PyPI서 계정만 먼저 만들어주자!

PyPI는 테스트 사이트에 먼저 업로드하는 것을 권장한다.

4) 테스트 업로드하기

# twine 패키지 업그레이드
python3 -m pip install --upgrade twine

python3 -m twine upload --repository testpypi dist/*

테스트 PyPI에 성공적으로 업로드가 되었으면, 계정으로 로그인해서 내 프로젝트가 잘 들어간 것을 확인하면 된다.

5) 실제 업로드하기

테스트까지 끝났으면, 이제 정말로 세상에 내 패키지를 배포해보자.

다 동일하고, 업로드 대상 저장소만 바꿔주면 된다. 그런데 PyPI가 이미 디폴트 저장소이기 때문에 옵션만 지워준다.

# 실제 PyPI에 첫 패키지 배포!!
python3 -m twine upload dist/*

짝짝짝!! 축하한다. 드디어 내 첫 패키지가 세상에 발을 디뎠다.

3. 패키지 테스트해보기

이제 내가 만든 패키지가 잘 동작하는지 바로 확인해보자.

pip install python-arango-mapper를 통해 설치를 하고, 내가 테스트했던 코드들을 실험해보면 된다!

4. 마치며

실제 패키지를 배포할 때는 깃을 통해 버전을 관리하고, 테스트 코드를 작성하는 등의 과정을 거친다.

나는 귀찮아서.. 테스트 코드는 작성하지 않았지만 깃허브에 올려두고 코드 관리를 하고 있다.

혹시 글이 도움이 되셨다면 python-arango-mapper 깃허브에 가서 스타를 꾸욱 눌러주시면 정말 감사하겠다!! 😗

<아랑고DB> 9. 데이터 모으기 COLLECT / AGGREGATE / MIN_BY, MAX_BY

|
지난 시간에는 간단한 그래프 횡단에 대해 알아보았다. 다만 횡단의 본래 목적은 횡단 그 자체가 아닌 횡단을 하며 경로에 있는 데이터를 가져오는 데 있다. 이번에는 데이터를 모아주는 역할을 하는 COLLECT AGGREGATE 문법에 대해 배워보자!

1. 개념잡기

아랑고DB에서 COLLECT는 데이터를 그룹지어주는 연산자이다. SQL에서 GROUP BY와 유사한 역할을 한다고 이해하면 된다.

둘의 느낌적인 차이를 사용자 입장에서 설명해보면 이렇다.

  • SQL은 데이터를 한 번에 조회해서, 기준에 따라 데이터를 그룹 연산할게요~의 느낌이라면
  • AQL은 FOR 루프를 돌면서 기준에 따라 데이터를 모을게요~의 느낌이다

예시를 들면, id별 최근 방문 시간을 조회한다고 가정해보자.

//SQL
//테이블 전체 조회해서 최대 시간 뽑아주세요~
SELECT
  id,
  MAX(visit_time) AS last_visit_time 
FROM mysql.test_db.test_table
GROUP BY id
//AQL
//컬렉션 돌면서 id 최대 시간 모아주세요~
FOR data in test_collection
  COLLECT
    id = doc.id
  AGGREGATE
    last_visit_time = MAX(doc.visit_time)
  RETURN {id, visit_time}

AQL에서는 FOR 루프와 함께 COLLECT가 쓰이기 때문에, 데이터를 모은다라는 표현이 어울리는 것 같다.

2. COLLECT 문법 익히기

이제 실제 문법을 배워보자. 모든 자세한 설명은 아랑고 DB 공식 문서에 아주 잘 설명되어 있다.

COLLECT
  variableName = expression
AGGREGATE
  variableName = aggregateExpression
INTO groupsVariable	

COLLECT는 다양한 형태로 쓰이는데, 일단은 전체적으로 살펴보자.

COLLECT 바로 다음에 나오는 변수는 그룹의 기준이 되는 변수들을 의미한다. 좌변인 variableName은 사용자가 설정해주는 변수 이름이 되고, 우변에는 해당 값이 들어간다.

AGGREGATE는 필수 연산은 아니며, 그룹의 기준이 되는 변수들을 대상으로, 데이터에 어떠한 그룹 연산을 취할 것인지를 결정해주는 부분이다. 그룹별로 MIN, MAX, UNIQUE다양한 그룹 연산을 사용할 수 있다.

INTO도 필수 연산은 아니며, 그룹별로 데이터를 묶어주는 역할을 한다. 다시 말하면, FOR 루프를 돌면서 그룹에 해당하는 모든 도큐먼트를 모아주는 역할이다.

SQL에 대응해보면 아래와 같다.

  • COLLECT id = doc.id : GROUP BY id
  • AGGREGATE last_visit_time = MAX(doc.visit_time) : MAX(visit_time) AS last_visit_time
  • INTO groups : INTO는 SQL에 대응할만한 연산자가 생각나지 않는데, 굳이 하나를 만들자면 “그룹에 해당하는 모든 레코드(행)을 묶어서 하나의 어레이로 만들어주는” 연산이라고 보면 되겠다.

3. AQL 예제를 통한 사용법 익히기

여러가지 사례를 통해 문법에 익숙해져보자. 지난 글의 공항 데이터를 사용할 예정이며, 기억이 나지 않으면 요기를 다시 보자.

횡단에서 했던 것처럼, COLLECT도 뽑을 데이터가 정해졌을 때 미리 머리속에 그려보면 좋다. 아래 세 가지를 미리 생각해보고, 코드를 짜보도록 하자.

1) 그룹의 기준을 정하고 2) 뽑을 데이터를 정하고, 3) 데이터 각각이 어떤 기준으로 뽑혀야 하는지를 생각한다.

airports 데이터에서 UNIQUE한 국가만 리턴하기

두 가지 방법이 있다. DISTINCT 연산을 통해 고유값만 리턴해도 되고, COLLECT로 묶어서 그냥 리턴해줘도 된다.

둘의 차이는 여기잘 설명되어 있다.

1) 우리는 UNIQUE한 국가만을 리턴할 것이기 때문에, 그룹의 기준은 국가가 된다. 2)3) 뽑을 데이터는 따로 없고, 그룹 기준만 리턴한다.

// COLLECT 사용하고, AGGREGATE이나 INTO 사용하지 않는 예제
FOR airport IN airports
  COLLECT
    country = airport.country
  RETURN country	

// 동일한 연산
FOR airport IN airports
  RETURN DISTINCT airport.country

airports 데이터에서 국가별로 공항 이름을 리턴하기

1) 국가별 공항 이름이기 때문에 기준은 ‘국가’가 된다. 2) 뽑을 데이터는 공항 이름의 목록이다 3) 목록이기 때문에 배열 형태로 묶어줘야 한다

배열 형태로 데이터를 묶어주는 연산은 뭐가 있었을까? 일단 AGGREGATE에는 그런 기능을 수행하는 연산자가 없다.

그렇다면 남은 연산자인 INTO를 써야겠구나!

FOR airport IN airports  
  COLLECT
    country = airport.country
  INTO names = airport.name
  RETURN {
    country,
    names
    }

여기서는 공항의 이름만 사용하기 때문에 INTO에서 airport.name만을 모아왔다.

flights 데이터에서 공항별로 도착했던 비행편의 수와 비행편의 정보를 알고싶다

flights 데이터는 40만 개가 넘는 데이터가 존재하기 때문에 서버의 메모리가 작다면 LIMIT을 꼭 걸어주자!

1) 도착 공항별 데이터이기 때문에 기준은 공항이 된다 2) 뽑을 데이터는 비행이 있었던 횟수와 비행편에 대한 정보이다 3) 횟수는 개수를 세는 COUNT 연산자를 쓰면 되고, 비행편은 모아주면 되겠구나!

FOR flight IN flights
  LIMIT 10000 // 리밋!!
  COLLECT
    airport= flight._to
  AGGREGATE
    cnt = COUNT(1)
  INTO flight_infos = flight.FlightNum
  RETURN {
    airport,
    cnt,
    flight_infos
  }

위 내용에 대한 심화로, 리턴된 결과에 더하여 공항별 풀네임과 국가, 도시를 알고 싶다면 어떻게 해야할까? 답은 아래에 적어두겠음

flights 데이터에서 공항별로 최초 출발 비행편의 시간과, 최종 출발 비행편의 시간을 알고싶다.

위 문제들을 해결했다면, 이것도 상당히 쉽기 때문에 설명은 생략한다!

FOR flight IN flights
  LIMIT 10000 // 리밋!!
  COLLECT
    airport = flight._from
  AGGREGATE
    min_time = MIN(flight.DepTimeUTC),
    max_time = MAX(flight.DepTimeUTC)
  RETURN {
    airport,
    first_departure: min_time,
    last_departure: max_time
  }

4. 그 외 연산들

COLLECT에서 사용할 수 있는 유용한 연산들이 더 있다.

COUNT

그룹별로 숫자를 셀 때, 위에서처럼 COUNT()를 사용해도 되지만 AQL에서 제공해주는 다른 방법도 있다.

  • COLLECT WITH COUNT INTO countVariableName
FOR flight IN flights
  LIMIT 10000 // 리밋!!
  COLLECT WITH COUNT INTO length
  RETURN length

혹은, 배열의 길이는 LENGTH()라는 연산자로도 쉽게 계산 가능하다. 아래 코드에서 LET의 사용에 주목하자. 많이 쓰게 될 예정이다.

LET records = (
FOR flight IN flights
  LIMIT 10000 // 리밋!!
  RETURN flight
)

RETURN LENGTH(records)

MIN_BY, MAX_BY

SQL로 쿼리문을 열심히 짜다보면 시스템별로 문법이 약간씩 상이하다. 나는 주로 Presto 쿼리 엔진을 통해 작업하는데, Presto에는 MIN_BY, MAX_BY라는 유용한 함수가 있다.

얘가 어떤 역할을 하냐면, MAX_BY(a, b)로 쓰이며, “그룹별로 b값이 최대가 되는 레코드의 a값을 리턴해줘”의 역할을 해준다.

예를 들어, A라는 사람이 {‘음식’: ‘라면’, ‘시간’ : ‘아침}, {‘음식’: ‘돈가스’, ‘시간’ : ‘점심}, {‘음식’: ‘탕수육’, ‘시간’ : ‘저녁} 의 데이터를 가지고 있다고 가정해보자.

A가 가장 늦은 시간에 먹은 음식을 알려줘!라는 쿼리는 어떻게 구성해야할까? subquery를 통해 A의 가장 늦은 시간을 찾아내서, 해당 시간을 조회하는 방식도 있겠지만 MAX_BY를 쓰면 아주 간단해진다.

그냥 MAX_BY(음식, 시간)을 하게되면 시간이 최대인(가장 늦은 시간인) 데이터의 음식을 반환해주기 때문이다.

안타깝지만 AQL에도 해당 연산자는 없다. 하지만 아랑고DB 커뮤니티를 통해 아랑고DB 개발자들의 구현 방식을 배울 수 있었다. 그 값진 내용을 여기에 공유한다 :)

AQL에서 배열의 크기 비교는 element-wise하게 이루어진다. 즉, 0번째 인덱스의 값부터 비교를 해나가는 방식이다.

예를 들어, [1, 2, 3, 4]와 [-1]을 비교한다면, 0번째 인덱스의 값은 1이 크기 때문에 왼쪽의 배열이 더 크다.

[1, 2, 3, 4]와 [1, 2, 3, -1]을 비교한다면, 3번째 인덱스에 해당하는 4와 -1 중 4가 더 크기 때문에 왼쪽의 배열이 더 크다.

이 원리를 이용하면, MIN_BYMAX_BY를 구현 가능하다.

내가 알고싶은 값을 b, 기준이 되는 값을 a라고 하자. (위 예시에서는 음식이 b, 시간이 a가 되겠다)

각 레코드를 a_i, b_i로 표시한다면, 특정 그룹에 대해 N개의 데이터가 있었다면 아래처럼 될 것이다.

  • [[a_0, b_0], [a_1, b_1], …, [a_N-1, b_N-1]]

위 배열에서 최대값을 구한다면, 어떤 의미를 가질까? 배열의 크기 비교는 원소끼리의 대응이라고 했으니까, 기준값 a끼리의 비교가 된다.

즉, MAX(배열) 연산을 하게 되면, 기준값 a가 가장 큰 배열이 리턴되며, 그 배열의 b값이 우리가 찾는 값이 된다.

마찬가지로 MIN(배열) 연산을 하게 되면, 기준값 a가 가장 작은 배열이 리턴되며, b값을 쓰면 된다.

이제 아래 문제를 풀어보자. 위에서는 시간만 리턴하는 문제였지만, 이제는 시간에 대응되는 값을 찾아야 한다.

flights 데이터에서 공항별로 최초 출발 비행편의 비행번호(FlightNum)와 최종 출발 비행편의 비행번호를 알고싶다.

답을 보기 전에, 꼭 끝까지 머리를 싸매고 문제를 해결해보자.

FOR flight IN flights
  COLLECT
    airport = flight._from
  AGGREGATE
    min_by_deptime_to = MIN([flight.DepTimeUTC, flight.FlightNum]),
    max_by_deptime_to = MAX([flight.DepTimeUTC, flight.FlightNum])
    
  RETURN 
    {
      airport,
      earlies: min_by_deptime_to[1],
      latest: max_by_deptime_to[1]
     }

기준은 출발 시간이기 때문에 [출발 시간, 비행번호]의 배열에 대해 각각 MIN, MAX 연산을 취해주면 된다. 원리만 이해하면 정말 쉽다.

5. 심화 답

위에서 나온 심화 문제의 답은 아래에 있다.

핵심은, Document() 함수로 공항을 조회해 해당 결과를 리턴 값 안에 포함시켜주는 것이다.

여기서 COLLECT의 그룹 기준인 flight._to가 ArangoDB에서 _id에 해당하기 때문에, 이 값을 Document()에서 바로 호출해 접근이 가능한 원리이다.

FOR flight IN flights
  LIMIT 10000 // 리밋!!
  COLLECT
    airport= flight._to
  AGGREGATE
    cnt = COUNT(1)
  INTO flight_infos = flight.FlightNum
  RETURN {
    airport,
    cnt,
    flight_infos,
    fullname: Document(airport).name,
    country: Document(airport).country
  }

6. 어디까지 왔나

이번 시간까지 해서 아랑고DB를 사용하기 위한 기본적인 지식들은 모두 익혔다. 기초 AQL부터 그래프 횡단, COLLECT까지 모두 훑었기 때문에 이제 실전에서 잘 써먹기만 하면 된다.

이제 얼마 남지 않은 아랑고DB 관련 글들은 실제 데이터를 사용해 프로젝트를 진행하는 부분에 초점을 맞추려고 한다.

실제 프로젝트를 진행할 때, 기존 다른 형태로 저장되어 있던 데이터들은 어떻게 설계할 것이고, 손쉽게 아랑고DB에 넣을 것이며, 복잡한 데이터는 어떻게 뽑을 것인지를 다루려고 한다.

내가 현재 Python으로 구현해서 사용하고 있는 그래프 Mapper를 되도록 라이브러리화해서 사용할 예정이고, Kaggle에 있는 데이터를 하나 잡아서 프로젝트 용으로 쓸 것이다.

  1. 아랑고DB란? 왜 쓰는가?
  2. 아랑고DB 세팅하기 on Ubuntu
  3. 아랑고DB 쉘로 붙어서 명령어 체험해보기, 실체 파악해보기
  4. AQL(Arango Query Lang) 배워보기 1
  5. AQL(Arango Query Lang) 배워보기 2 - RETURN / UPDATE
  6. AQL(Arango Query Lang) 배워보기 3 - REPLACE / UPSERT / REMOVE
  7. 그래프 개념잡기
  8. 그래프 횡단하기 Graph Traversal
  9. (지금 보고있는 글) 데이터 모으기 COLLECT / AGGREGATE / MIN_BY, MAX_BY
  10. 프로젝트. 그래프를 통한 영화 추천시스템 만들어보기 1
  11. 프로젝트. 그래프를 통한 영화 추천시스템 만들어보기 2 (최종편)

<아랑고DB> 8. 그래프 횡단하기 Graph Traversal

|
이번 글에서는 드디어 그래프 횡단에 대해 배워보려고 한다. 사실상 그래프DB를 사용하는 가장 큰 이유가 바로 이 그래프 횡단의 유용함이라고 생각한다. 재밌게 배워서 알차게 써먹어보자!

1. 준비사항

마찬가지로 아랑고DB에서 제공해주는 공식 튜토리얼 문서를 기반으로 설명을 할 예정이다. 튜토리얼에서 사용되는 비행 관련 데이터셋은 여기를 눌러 다운받을 수 있다.

이 데이터는 각 공항들 사이에 존재하는 비행 경로를 그래프로 나타낸 데이터이다.

PDF파일의 24페이지를 보면, Airports 데이터셋을 import하는 방법이 나와있다. ArangoDB가 설치되어 있는 Ubuntu Shell에서 내가 진행한 방식은 아래와 같다.

curl 명령어는 이 사이트에 간결하게 잘 나와있어 참고했다.

# 임의의 경로에서 curl로 다운받는다
# -L 옵션은 리다이렉션을 끝까지 따라가는 옵션이고, 여기서의 결과를 --output에 저장한다
# 원래 일반적인 url은 curl -O {url} 하면 되는데, 얘는 리다이렉션이 걸려있어서 이렇게 다운받음
sudo curl -L https://www.arangodb.com/arangodb_graphcourse_demodata/ --output airport.zip

sudo unzip airport.zip

# airports 노드 컬렉션 임포트
arangoimport --file airports.csv --collection airports --create-collection true --type csv

# flights 엣지 컬렉션 임포트
# 아래 명령어는 컴퓨터 성능에 따라 꽤 오랜 시간이 걸릴 수 있습니다!
# ec2 t2.micro에서는 한참 걸려서 어쩔 수 없이 10000개의 행만 분리했다
# 아래 명령어는 메모리가 충분하다면 스킵해도 됩니다.
sudo split -l flights.csv flights_10000.csv

arangoimport --file flights_10000.csvaa --collection flights --create-collection true --type csv --create-collection-type edge

대량의 데이터가 있다면, 이렇게 csv 형태를 편하게 import 할 수 있다는 것도 기억해두자.

2. 데이터 살펴보기

데이터 임포트가 완료되고 나면, 아랑고 WebUI로 가서 데이터를 살펴보자. _system 데이터베이스에 디폴트로 생성되었고, (위 명령어에서 데이터베이스를 지정해주면 새로운 데이터베이스에 만들 수도 있다.) 각각 airportsflights 컬렉션에 생성되었다.

airports는 5개 국가의 공항에 대한 위치 정보를 가지고 있고, 3375개의 노드 도큐먼트로 구성되어 있다.

flights는 위 공항들을 잇는 엣지 도큐먼트이고, 출발 시간, 도착 시간, 항공편 번호 등의 비행에 관한 데이터를 가지고 있다. 약 44만 개의 데이터가 존재한다. (나는 10000개만 임포트함)

이제 이 데이터들을 기반으로 그래프 횡단에 대해 배워보자.

3. 그래프 횡단 문법

앞선 7장에서 말한 것처럼, 여기서는 ArangoDB의 anonymous graph를 사용한다.

그래프 횡단이란, 그래프의 엣지를 따라 움직이는 행위를 지칭한다. 이때 횡단에서 몇 개의 엣지를 이동하는지를 횡단의 깊이 Traversal Depth라고 부른다.

아랑고 튜토리얼에서 발췌한 아래의 그림을 보면 이해하기 쉽다.

Node and Edges

모든 그래프 관련 데이터베이스에서 사용하는 횡단 관련 문법은 상이하다. 아랑고DB의 AQL에서는 아래와 같은 문법을 사용한다.

FOR vertex[, edge[, path]]
  IN [min[..max]]
  OUTBOUND|INBOUND|ANY startVertex
  edgeCollectionName[, more...]

위 문법을 그대로 해석하면, 아래 정도가 되겠다.

startVertex를 출발점으로 잡고, 이 출발점과 연결되어 있는 edgeCollectionName에 연결된 엣지 중에서, 출발점에서 뻗어나가거나(OUTBOUND) OR 들어오거나(INBOUND) OR 둘 중 하나거나(ANY) 에 해당하는데, 깊이가 min~max 사이인 경로에 해당하는 값들을 리턴해라. 이때, 리턴하는 값들은 도착하는 노드, 엣지, 경로이다.

천천히 하나씩 뜯어보자. 일단 위 쿼리에서 대괄호[]안의 내용은 생략이 가능하다는 의미이며, 세로 라인은 또는(or)의 의미로써 상황에 맞는 것을 사용하면 된다는 뜻임

FOR vertex[, edge[, path]]

횡단에서 사용할 세 개의 변수 vertex, edge, path를 나타내는 값이다. FOR loop에서 썼던 것처럼 변수 이름은 사용자 마음임.

이때 vertex횡단 후에 도착해 있는 노드 오브젝트를 의미하며, edge는 횡단하게 될 엣지, path는 횡단하게 될 경로의 모든 노드, 엣지를 총칭한다.

여기서 edgepath는 사용해도 되고, 사용하지 않아도 된다.

// JFK 공항에서 출발했을 , 도착하는 공항들을 리턴함
LET jfk = Document('airports/JFK')
FOR v IN OUTBOUND jfk flights
  RETURN v
// JFK 공항에서 출발했을 , 도착하는 공항들과  비행 정보를 리턴함
LET jfk = Document('airports/JFK')
FOR v, e IN OUTBOUND jfk flights
  RETURN {v, e}

IN [min[..max]] OUTBOUND|INBOUND|ANY startVertex

Node and Edges

여기서 startVertex는 횡단의 출발점을 의미하며, 이 출발점에서 나가는 방향을 OUTBOUND, 들어오는 방향을 INBOUND, 둘 다 상관없이 연결되어 있기만 하면 ANY라고 지칭한다.

셋 중 하나를 골라서 쓰면 된다.

그리고 min, max는 생략 가능한데, 횡단의 깊이를 나타낸다. 생략하면 디폴트로 1..1로 설정되어 있음.

edgeCollectionName[, more…]

마지막으로 edgeCollectionName들은 횡단의 기준이 되는 엣지 컬렉션의 이름을 의미한다. 하나만 써도 되고, 컴마로 분리하여 여러 엣지를 쓸 수도 있다.

4. 그래프 횡단 Graph Traversal

그래프 문법을 보면 상당히 사용자 친화적임을 알 수 있다. 사람이 생각하는 횡단의 개념대로 쿼리를 구성할 수 있기 때문이다.

이제 실제 예제를 통해 그래프 횡단에 익숙해져보자. 아주 쉬운 예제이지만, 꼭 혼자서 시도해본 뒤 답을 보자.

생각하는 포인트는, 1)출발점 2)횡단 엣지 3)엣지의 방향 4)도착점 5)리턴값 을 미리 생각해보는 것이다.

LA국제공항(LAX)에서 한 번에 갈 수 있는 공항의 이름을 리턴해보기

FOR airport IN OUTBOUND 'airports/LAX' flights
  RETURN DISTINCT airport.name

1) 출발점은 LAX, 2) 횡단 엣지는 flights, 3) 엣지의 방향은 출발이기 때문에 OUTBOUND, 4) 도착하는 임의의 공항은 airport라고 지칭하며, 5)리턴값은 공항의 이름이기 때문에 도착하는 노드에서 속성을 가져와야겠구나.

위 예시에서 DISTINCT의 사용을 통해 고유한 공항의 이름만을 리턴하도록 해준다. DISTINCT airport라고 하게되면 오브젝트 전체를 지칭하는 것이므로 잘못된 쿼리임에 주의.

비스마르크 공항(BIS)에 도착하는 항공 번호를 10개만 리턴해보기

FOR airport, flight IN INBOUND 'airports/BIS' flights
  LIMIT 10
  RETURN flight.FlightNum

1) 출발점은 BIS (횡단의 출발점이라는 뜻, 비행의 출발점과 혼동하지 말 것), 2) 횡단 엣지는 flights, 3) 엣지의 방향은 출발 노드에 도착하는 것이니까 INBOUND, 4) 도착점은 airport라는 임의의 변수(마찬가지로 횡단의 도착점이라는 뜻), 5) 리턴값은 항공 번호이기 때문에 엣지에서 속성을 가져와야겠구나.

1월 5일 ~ 1월 7일 비스마르크 공항에서 출발하거나 도착하는 항공편의 번호, 대상 도시, 도착 시간 리턴하기

FOR airport, flight IN ANY 'airports/BIS' flights
    FILTER flight.Month == 1
    AND flight.Day >= 5
    AND flight.Day <= 7
    RETURN {
        'flight_num' : flight.FlightNum,
        'city' : airport.city,
        'arrive_time' : flight.ArrTimeUTC
    }

1)2)4)는 위와 동일. 3) 엣지의 방향은 어디든 상관없기 때문에 ANY, 5) 리턴값은 공항 이름과 항공 번호이기 때문에 노드와 엣지 모두에서 속성을 가져와야겠구나. 추가로 비행편에 대한 조건이 있으므로 엣지 속성에 필터를 걸어야겠구나.

JFK, PBI 공항으로 도착하거나 출발하는 항공편 중, 항공편 번호가 859, 860에 포함되는 것들만 리턴해라. 단, 처음에는 For LOOP을 사용하여 JFK, PBI를 찾기

FOR origin IN airports
  FILTER origin._key IN ["JFK", "PBI"]
  FOR dest, flight IN ANY origin flights
    FILTER flight.FlightNum IN [859, 860]
    RETURN {
      from: origin.name,
      to: dest.name,
      number: flight.FlightNum,
      day: flight.Day
    }

일단 FOR loop 을 통해 해당하는 공항만을 조건을 걸어준다. 나머지는 모두 동일한데, 횡단의 startVertex가 For loop의 origin 변수에 걸려서 변하게 되는 것이다!

FLL 공항에서 2번의 경로에 걸쳐 도달할 수 있는 비행 목록의 수를 세보기

그래프 횡단은 메모리를 많이 잡아먹는다. 여기서 40만개의 데이터가 있기 때문에 메모리가 적다면 서버가 과부하로 멈출 수 있다!
아직 우리는 숫자를 세는 방법을 배우지 않았다. 일단은 COLLECT WITH COUNT INTO length 라는 문법을 사용해보자.
FOR airport, flight IN 2..2 OUTBOUND 'airports/FLL' flights
COLLECT WITH COUNT INTO length
    RETURN length

2번에 걸쳐 도달하기 때문에, 깊이를 2로 맞춰주었다. 정말로 위 경로가 맞는지 보려면 아래 AQL의 결과와 비교해보면 됨.

// 위와 동일한 Traversal
FOR airport, flight IN 1..1 OUTBOUND 'airports/FLL' flights
    FOR airport_2, flight_2 IN 1..1 OUTBOUND airport flights
        COLLECT WITH COUNT INTO length
        RETURN length

5. 다른 기능들

문서를 보면 그래프 횡단에는 여러 기능들이 많다. 그래프에서 DFS(Depth-First-Search) 또는 BFS(Breadth-First-Search) 탐색을 할 수 있고, 가장 짧은 Shortest Path를 찾는 등의 설정도 가능하다.

예를 들어, BIS 공항에서 JFK 공항까지의 최단 경로를 찾는 쿼리는 아래와 같다. (엄밀히 말하면 경로의 수가 최소인 것이지, 실제 거리의 최소인지는 알 수 없다.)

LET airports = (
 FOR v IN OUTBOUND
 SHORTEST_PATH 'airports/BIS'
 TO 'airports/JFK' flights
 RETURN v
)
RETURN LENGTH(airports) - 1

6. 어디까지 왔나

이런 식으로 그래프 횡단은 아주 유용하게 사용할 수 있다. 횡단에서의 출발점, 도착점, 그리고 횡단할 엣지만 잘 지정해주면 횡단 자체는 어렵지 않다.

정말 어려운 부분은 이러한 횡단이 효율적일 수 있도록 설계하는 것과, 횡단에서 내가 필요한 데이터를 모으는 일이다.

일반적으로 위 예시들처럼 하나의 값만 리턴하고 끝나는 것이 아닌, 횡단의 과정에 있는 모든 값들을 내가 원하는 형태로 리턴하는 일이 빈번하기 때문이다.

따라서 다음 시간에는 잠깐 다시 AQL로 돌아가서, COLLECT AGGREGATE 문법에 대해 배운다. SQL로 따지면 GROUP BY 정도로 볼 수 있겠다.

  1. 아랑고DB란? 왜 쓰는가?
  2. 아랑고DB 세팅하기 on Ubuntu
  3. 아랑고DB 쉘로 붙어서 명령어 체험해보기, 실체 파악해보기
  4. AQL(Arango Query Lang) 배워보기 1
  5. AQL(Arango Query Lang) 배워보기 2 - RETURN / UPDATE
  6. AQL(Arango Query Lang) 배워보기 3 - REPLACE / UPSERT / REMOVE
  7. 그래프 개념잡기
  8. (지금 보고있는 글) 그래프 횡단하기 Graph Traversal
  9. 데이터 모으기 COLLECT / AGGREGATE / MIN_BY, MAX_BY
  10. 프로젝트. 그래프를 통한 영화 추천시스템 만들어보기 1
  11. 프로젝트. 그래프를 통한 영화 추천시스템 만들어보기 2 (최종편)