본문 바로가기
  • 기술을 이야기하지만 사람을 생각합니다.
20. 인공지능과 딥러닝

Deep Learning Cookbook :: 04 위키피디아 외부 링크를 이용한 추천 시스템 구축

by WE DONE IT. 2019. 4. 6.

04 위키피디아 외부 링크를 이용한 추천 시스템 구축

 

 

 

 

[그림] Deep Learning Cookbook

 

이 책은 (주)느린생각에서 'Deep Learning Cookbook' 책을 지원 받아 이 책을 교재로 스터디를 진행하였습니다. 이 글은 Deep Learning Cookbook의 4장 <위키피디아 외부 링크를 이용한 추천 시스템 구축>를 실습을 목적으로 공부한 내용을 정리한 글입니다.


INDEX

4장에서는 간단하게(?) 추천 시스템을 구축하는 방법을 소개한다. 방법은 크게 네 가지로 나눌 수 있다.

  1. 위키피디비아의 외부 링크에서 학습셋을 추출
  2. 이 링크들을 임베딩하여 학습(training)
  3. 그 다음 SVM을 구현하여 추천 제안
  4. 영화의 평점을 예측

4.1. 데이터 수집

위키피디아에서 최신 덤프 데이터를 내려받는다. Jupyter Notebook의 데이터 폴더 안에 미리 추출된 상위 10,000편의 영화정보가 포함되어 있기 때문에 실습을 위해서 굳히 이 단계를 수행할 필요는 없다. 

 

* 위키피디아에서 최신 데이터 내려받는 방법

index = requests.get('https://dumps.wikimedia.org/enwiki/').text
soup_index = BeautifulSoup(index, 'html.parser')
dumps = [a['href'] for a in soup_index.find_all('a') 
             if a.has_attr('href') and a.text[:-1].isdigit()]
dumps

for dump_url in sorted(dumps, reverse=True):
    print(dump_url)
    dump_html = index = requests.get('https://dumps.wikimedia.org/enwiki/' + dump_url).text
    soup_dump = BeautifulSoup(dump_html, 'html.parser')
    pages_xml = [a['href'] for a in soup_dump.find_all('a') 
                 if a.has_attr('href') and a['href'].endswith('-pages-articles.xml.bz2')]
    if pages_xml:
        break
    time.sleep(0.8) #위키피디아 접근 제한에 걸리지 않도록 sleep() 함수 호출

dumps는 아래와 같이 출력된다.

['20190101/',
 '20190120/',
 '20190201/',
 '20190220/',
 '20190301/',
 '20190320/',
 '20190401/']

* 덤프 데이터 내려받기

wikipedia_dump = pages_xml[0].rsplit('/')[-1]
url = url = 'https://dumps.wikimedia.org/' + pages_xml[0] 
path = get_file(wikipedia_dump, url)
path

path 정보는 아래와 같이 출력된다.

'/Users/Username/.keras/datasets/enwiki-20190320-pages-articles.xml.bz2'

 

self._values 딕셔너리 컬랙션에 각 <page> 태그에 대해 제목과 본문의 내용을 수집하고 수집된 값으로 process_article() 함수를 호출한다. 위키피디아의 'Infobox 템플릿'을 통해서 'Flim'으로 모든 영화를 편리하게 추출할 수 있다.

Example:
{{Infobox
 | name = {{subst:PAGENAME}}
 | above      = Text in uppermost cell of infobox
 | subheader  = Subheader of the infobox
 | subheader2 = Second subheader of the infobox
 | header = (the rest of the infobox goes here)
}}
class WikiXmlHandler(xml.sax.handler.ContentHandler):
    def __init__(self):
        xml.sax.handler.ContentHandler.__init__(self)
        self._buffer = None
        self._values = {}
        self._movies = []
        self._curent_tag = None

    def characters(self, content):
        if self._curent_tag:
            self._buffer.append(content)

    def startElement(self, name, attrs):
        if name in ('title', 'text'):
            self._curent_tag = name
            self._buffer = []

    def endElement(self, name):
        if name == self._curent_tag:
            self._values[name] = ' '.join(self._buffer)

        if name == 'page':
            movie = process_article(**self._values)
            if movie:
                self._movies.append(movie)

 

mwparserfromhell() 함수를 이용해서 위키피디아 문서를 파싱할 수 있다.  

def process_article(title, text):
    rotten = [(re.findall('\d\d?\d?%', p), re.findall('\d\.\d\/\d+|$', p), p.lower().find('rotten tomatoes')) for p in text.split('\n\n')]
    rating = next(((perc[0], rating[0]) for perc, rating, idx in rotten if len(perc) == 1 and idx > -1), (None, None))
    wikicode = mwparserfromhell.parse(text)
    film = next((template for template in wikicode.filter_templates() 
                 if template.name.strip().lower() == 'infobox film'), None)
    if film:
        properties = {param.name.strip_code().strip(): param.value.strip_code().strip() 
                      for param in film.params
                      if param.value.strip_code().strip()
                     }
        links = [x.title.strip_code().strip() for x in wikicode.filter_wikilinks()]
        return (title, properties, links) + rating

bzip으로 압축한 덤프 데이터를 sax 파서에 넣는다. 마지막으로 결과를 저장하여, 데이터가 다시 필요할 때 이 작업을 다시 할 필요가 없어진다.

parser = xml.sax.make_parser()
handler = WikiXmlHandler()
parser.setContentHandler(handler)
for line in subprocess.Popen(['bzcat'], stdin=open(path), stdout=subprocess.PIPE).stdout:
    try:
        parser.feed(line)
    except StopIteration:
        break
# 에러남... NameError: name 're' is not defined
with open('generated/wp_movies.ndjson', 'wt') as fout:
    for movie in handler._movies:
         fout.write(json.dumps(movie) + '\n')

 

4.2 영화 임베딩 학습하기

수집한 데이터를 사용하여 "당신이 이걸 좋아했다면 저것도 관심있을 수 있다"와 같은 제안을 하는 방법을 소개한다. 개념 자체는 연관성 분석(association rule)과 비슷한 것 같다.

책에서는 추천 로직을 간단하게 설명해서 잘 이해가 안 돼서... 활용한 위키피디아 데이터를 기반으로 이해하기 쉽게 설명하자면 "이 감독(또는 배우, 제작사 등)의 영화를 좋아했다면 이 감독(또는 배우, 제작사 등)의 다른 영화도 좋아할 거야."가 기본적인 가정이다. 위키피디아 본문에 있는 감독, 출판사, 배우 등의 하이퍼링크도 데이터로 활용하면서 서로 연관성이 있다고 가정했기 때문이다. 

방식

이전에 수집한 영화와 링크를 기반으로 임베딩을 학습한다. 외부 링크를 커넥터로 사용하여, 동일한 페이지로 연결되는 영화는 직관적으로 비슷하다고 가정한다. 따라서 모델을 학습할 때, 어떤 영화들이 비슷하며 어떤 링크가 비슷한지도 학습할 수 있다. (예: 동일한 페이지로 연결할 경우, 같은 감독이거나 장르일 수 있다.)

#이전에 수집한 데이터를 불러옴
with open('data/wp_movies_10k.ndjson') as fin:
    movies = [json.loads(l) for l in fin]

#가정에 대한 타당성을 검증하기 위해, 영화 본문별로 포함되어 있는 링크의 개수를 확인함.
#링크 수가 영화별로 고루게 분포되어 있지 않다면 적절한 예측을 하기 어려워서 이러한 검증을 하는 것 같다.
link_counts = Counter()
for movie in movies:
    link_counts.update(movie[2])
link_counts.most_common(10)

 위 코드를 실행하면 결과는 다음과 같이 나온다.

#영화별 링크 개수 
[('Rotten Tomatoes', 9393),
 ('Category:English-language films', 5882),
 ('Category:American films', 5867),
 ('Variety (magazine)', 5450),
 ('Metacritic', 5112),
 ('Box Office Mojo', 4186),
 ('The New York Times', 3818),
 ('The Hollywood Reporter', 3553),
 ('Roger Ebert', 2707),
 ('Los Angeles Times', 2454)]

최소 세 번 이상 발생하는 링크만 유지하고, 나중에 빠른 조회를 위해 목록을 유지하는 것이 좋다.

# In 
print("movie[0]: \n", movie[0], "\n") #영화 이름
print("movie[1] \n: ", movie[1], "\n") # 영화 메타정보
print("movie[2] \n: ", movie[2], "\n") #모르겠다...1
print("movie[3] \n: ", movie[2], "\n") #모르겠다...2

# 실행결과
movie[0]: 
 Jug Face 

movie[1] 
:  {'director': 'Chad Crawford Kinkle', 'country': 'United States', 'image': 'Jug Face Movie Poster.jpg', 'name': 'Jug Face', 'cinematography': 'Chris Heinrich', 'language': 'English', 'music': 'Sean Spillane', 'editing': 'Zach Passero', 'runtime': '81 minutes', 'writer': 'Chad Crawford Kinkle', 'studio': 'Modernciné'} 

movie[2] 
:  ['Sean Bridgers', 'Lauren Ashley Carter', 'Larry Fessenden', 'Sean Young', 'Daniel Manche', 'Michael G. Crandall', 'Slamdance Film Festival', 'horror film', 'Sean Bridgers', 'Lauren Ashley Carter', 'Larry Fessenden', 'Sean Young', 'Daniel Manche', 'Lauren Ashley Carter', 'Sean Bridgers', 'Sean Young', 'Larry Fessenden', 'Daniel Manche', 'Kaitlin Cullum', 'Chip Ramsey', 'Slamdance Film Festival#Screenwriting .26 Teleplay Competitions', 'Nashville, Tennessee', 'Sean Bridgers', 'Larry Fessenden', 'Sean Young', 'Bloody Disgusting', 'Entertainment Weekly', 'face jug', 'film festival', 'Slamdance Film Festival', 'Boston Underground Film Festival', 'Nashville Film Festival', 'Video on demand', 'Gravitas Ventures', 'Variety (magazine)', 'The Numbers (website)', 'Rotten Tomatoes', 'review aggregator', 'Rotten Tomatoes', 'Bloody Disgusting', 'Bloody Disgusting', 'Dread Central', 'Dread Central', 'Shock Till You Drop', 'Shock Till You Drop', 'Fearnet', 'Fearnet', 'Variety (magazine)', 'Variety (magazine)', 'Fangoria', 'Fangoria', 'Twitch Film', 'Twitch Film', 'The New York Times', 'The New York Times', 'AllMovie', 'Rotten Tomatoes', 'Category:2013 films', 'Category:2013 horror films', 'Category:American films', 'Category:American horror films', 'Category:American independent films', 'Category:English-language films', 'Category:Incest in film', 'Category:Films shot in Tennessee'] 

movie[3] 
:  ['Sean Bridgers', 'Lauren Ashley Carter', 'Larry Fessenden', 'Sean Young', 'Daniel Manche', 'Michael G. Crandall', 'Slamdance Film Festival', 'horror film', 'Sean Bridgers', 'Lauren Ashley Carter', 'Larry Fessenden', 'Sean Young', 'Daniel Manche', 'Lauren Ashley Carter', 'Sean Bridgers', 'Sean Young', 'Larry Fessenden', 'Daniel Manche', 'Kaitlin Cullum', 'Chip Ramsey', 'Slamdance Film Festival#Screenwriting .26 Teleplay Competitions', 'Nashville, Tennessee', 'Sean Bridgers', 'Larry Fessenden', 'Sean Young', 'Bloody Disgusting', 'Entertainment Weekly', 'face jug', 'film festival', 'Slamdance Film Festival', 'Boston Underground Film Festival', 'Nashville Film Festival', 'Video on demand', 'Gravitas Ventures', 'Variety (magazine)', 'The Numbers (website)', 'Rotten Tomatoes', 'review aggregator', 'Rotten Tomatoes', 'Bloody Disgusting', 'Bloody Disgusting', 'Dread Central', 'Dread Central', 'Shock Till You Drop', 'Shock Till You Drop', 'Fearnet', 'Fearnet', 'Variety (magazine)', 'Variety (magazine)', 'Fangoria', 'Fangoria', 'Twitch Film', 'Twitch Film', 'The New York Times', 'The New York Times', 'AllMovie', 'Rotten Tomatoes', 'Category:2013 films', 'Category:2013 horror films', 'Category:American films', 'Category:American horror films', 'Category:American independent films', 'Category:English-language films', 'Category:Incest in film', 'Category:Films shot in Tennessee'] 

(이 책에서 정의하는 Count가 무언지 확인하기 위해 movie[2]를 실행해 보았다. 아마도 영화 본문에 하이퍼링크가 있는 텍스트를 모은 배열같다...)

### 일치하는 사례와 일치하지 않는 사례 라벨링 ###

# 세 번 이상 발생하는 링크만 유지 
top_links = [link for link, c in link_counts.items() if c >= 3]

# 나머지는 조회를 위해 목록만 유지
link_to_idx = {link: idx for idx, link in enumerate(top_links)} 
movie_to_idx = {movie[0]: idx for idx, movie in enumerate(movies)}

pairs = []
for movie in movies:
    pairs.extend((link_to_idx[link], movie_to_idx[movie[0]]) for link in movie[2] if link in link_to_idx)
pairs_set = set(pairs)

len(pairs), len(top_links), len(movie_to_idx)
(949544, 66913, 10000)

link_idmovie_id를 숫자로 가져와서 각각의 임베딩 계층에 넣으며, 각 입력에 대해 embedding_size 벡터를 할당한다. 그 다음 두 벡터의 내적을 모델로 출력한다. 영화 및 링크가 유사한 영화가 비슷한 위치에 있도록, 모델은 내적이 레이블에 가까울 수 있도록 가중치를 준다.

### 임베딩 후 가중치를 주어, 유사한 영화가 가까운 위치에 묶일 수 있도록 설정 ###

def movie_embedding_model(embedding_size=50):
    link = Input(name='link', shape=(1,))
    movie = Input(name='movie', shape=(1,))
    link_embedding = Embedding(name='link_embedding', 
                               input_dim=len(top_links), 
                               output_dim=embedding_size)(link)
    movie_embedding = Embedding(name='movie_embedding', 
                                input_dim=len(movie_to_idx), 
                                output_dim=embedding_size)(movie)
    dot = Dot(name='dot_product', normalize=True, axes=2)([link_embedding, movie_embedding])
    merged = Reshape((1,))(dot)
    model = Model(inputs=[link, movie], outputs=[merged])
    model.compile(optimizer='nadam', loss='mse')
    return model

model = movie_embedding_model()
model.summary()
Numpy에선 벡터의 내적, 벡터와 행렬의 곱, 행렬곱을 위해 ‘dot’ 함수를 사용한다.
‘dot’은 Numpy 모듈 함수와 배열 객체의 인스턴스 메소드에서도 이용 가능한 함수이다.

이 모델의 summary는 다음과 같다.

Instructions for updating:
Colocations handled automatically by placer.
__________________________________________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to                     
==================================================================================================
link (InputLayer)               (None, 1)            0                                            
__________________________________________________________________________________________________
movie (InputLayer)              (None, 1)            0                                            
__________________________________________________________________________________________________
link_embedding (Embedding)      (None, 1, 50)        3345650     link[0][0]                       
__________________________________________________________________________________________________
movie_embedding (Embedding)     (None, 1, 50)        500000      movie[0][0]                      
__________________________________________________________________________________________________
dot_product (Dot)               (None, 1, 1)         0           link_embedding[0][0]             
                                                                 movie_embedding[0][0]            
__________________________________________________________________________________________________
reshape_1 (Reshape)             (None, 1)            0           dot_product[0][0]                
==================================================================================================
Total params: 3,845,650
Trainable params: 3,845,650
Non-trainable params: 0
__________________________________________________________________________________________________

positive / negative 예제로 구성된 데이터를 생성기에서 생성하여 모델에 넣는다.

random.seed(5)

def batchifier(pairs, positive_samples=50, negative_ratio=10):
    batch_size = positive_samples * (1 + negative_ratio)
    batch = np.zeros((batch_size, 3))
    while True:
        for idx, (link_id, movie_id) in enumerate(random.sample(pairs, positive_samples)):
            batch[idx, :] = (link_id, movie_id, 1)
        idx = positive_samples
        while idx < batch_size:
            movie_id = random.randrange(len(movie_to_idx))
            link_id = random.randrange(len(top_links))
            if not (link_id, movie_id) in pairs_set:
                batch[idx, :] = (link_id, movie_id, -1)
                idx += 1
        np.random.shuffle(batch)
        yield {'link': batch[:, 0], 'movie': batch[:, 1]}, batch[:, 2]

next(batchifier(pairs, positive_samples=3, negative_ratio=2))
({'link': array([13365., 48731., 22418., 31254., 32643., 32318.,  3801., 20558.,
          1313.]),
  'movie': array([6238., 1854., 1529., 5530., 7628., 7685., 5874.,  849., 7236.])},
 array([-1., -1.,  1.,  1., -1., -1., -1., -1.,  1.]))

만 개의 영화 데이터 세트로 학습한다. movie_embedding 계층의 가중치에 접근하여 모델에서 영화 임베딩을 추출할 수 있다.

15번 epcho를 돌며, 컴퓨터 성능에 따라 다르겠지만 GPU가 없는 맥북에서는 epcho 당 약 2~3분 정도 소요됐다. (중간 중간 컴퓨터가 힘들어한다... 실습을 위해서라면 에포크 수를 적게 해보는 것도 괜찮을 것 같다...)

positive_samples_per_batch = 512

model.fit_generator(
    batchifier(pairs, positive_samples=positive_samples_per_batch, negative_ratio=10),
    epochs=15,
    steps_per_epoch=len(pairs) // positive_samples_per_batch,
    verbose=2
)

Epoch별 결과는 다음과 같다. loss가 줄었다가 다시 점차 증가하는 것을 알 수 있다. Epoch 8 (loss: 0.2206)이 가장 최저점이다.

Instructions for updating:
Use tf.cast instead.
Epoch 1/15
 - 138s - loss: 0.3855
Epoch 2/15
 - 129s - loss: 0.2407
Epoch 3/15
 - 141s - loss: 0.2411
Epoch 4/15
 - 147s - loss: 0.2407
Epoch 5/15
 - 150s - loss: 0.2415
Epoch 6/15
 - 135s - loss: 0.2308
Epoch 7/15
 - 123s - loss: 0.2243
Epoch 8/15
 - 123s - loss: 0.2206
Epoch 9/15
 - 154s - loss: 0.2299
Epoch 10/15
 - 135s - loss: 0.2312
Epoch 11/15
 - 139s - loss: 0.2332
Epoch 12/15
 - 169s - loss: 0.2499
Epoch 13/15
 - 152s - loss: 0.2394
Epoch 14/15
 - 138s - loss: 0.2303
movie = model.get_layer('movie_embedding')
movie_weights = movie.get_weights()[0]
movie_lengths = np.linalg.norm(movie_weights, axis=1)
normalized_movies = (movie_weights.T / movie_lengths).T

def similar_movies(movie):
    dists = np.dot(normalized_movies, normalized_movies[movie_to_idx[movie]])
    closest = np.argsort(dists)[-10:]
    for c in reversed(closest):
        print(c, movies[c][0], dists[c])

similar_movies('Rogue One')
link = model.get_layer('link_embedding')
link_weights = link.get_weights()[0]
link_lengths = np.linalg.norm(link_weights, axis=1)
normalized_links = (link_weights.T / link_lengths).T

def similar_links(link):
    dists = np.dot(normalized_links, normalized_links[link_to_idx[link]])
    closest = np.argsort(dists)[-10:]
    for c in reversed(closest):
        print(c, top_links[c], dists[c])

similar_links('George Lucas')

'Georage Lucas'와 유사한 링크 결과는 다음과 같다. 

조지 루카스와 유사성을 조회하니, 그가 제작한 <스타워즈>와 수상한 <Hugo Award> 등이 나왔다.

14913 George Lucas 1.0
50812 Star Wars (film) 0.9670632
66120 Star Wars 0.9511891
466 Hugo Award for Best Dramatic Presentation 0.9418189
2254 Raiders of the Lost Ark 0.92919797
60696 Saturn Award for Best Science Fiction Film 0.92867565
42371 Hugo Award 0.92317486
35358 Lucasfilm 0.91876715
20994 2001: A Space Odyssey (film) 0.9185802
12959 London Symphony Orchestra 0.91714984

 

4.3 영화 추천 시스템 만들기

방안

사용자가 지정한 best 항목과 worst 항목을 평점으로 가정하고, SVM을 이용하여 "'로그원'을 좋아한다면 '인터스텔라'도 좋아할 수 있습니다." 같은 제안을 할 수 있다.

 

4.4 단순 영화 평점 예측

 임베딩 모델을 학습한 벡터에 선형회귀(linear regression)를 사용하여 로튼 토마토 등급(영화 평점)을 예측하는 모델을 만들 수 있다.

 

영화의 신선도, 소식, 비형 등의 정보를 제공하는 '로튼 토마토'
로튼 토마토에서 제고하는 영화 '덤보'의 신선도(TOMATOMETER)와 관객 점수(AUDIENCE SCORE)

rotten_y = np.asarray([float(movie[-2][:-1]) / 100 for movie in movies if movie[-2]])
rotten_X = np.asarray([normalized_movies[movie_to_idx[movie[0]]] for movie in movies if movie[-2]])

TRAINING_CUT_OFF = int(len(rotten_X) * 0.8)
regr = LinearRegression()
regr.fit(rotten_X[:TRAINING_CUT_OFF], rotten_y[:TRAINING_CUT_OFF])

다음과 같은 결과가 나온다.

LinearRegression(copy_X=True, fit_intercept=True, n_jobs=1, normalize=False)
# 영화 평점 예측 점수 및 MSE
In:
error = (regr.predict(rotten_X[TRAINING_CUT_OFF:]) - rotten_y[TRAINING_CUT_OFF:])
'mean square error %2.2f' % np.mean(error ** 2)

Out: 
'mean square error 0.06'

상위 10,000개의 데이터 중 80%는 학습하는 데 활용하며, 나머지 20%로 검증한다. mean square error(평균 제곱근 편차) 값이 0.06으로, 좋은 결과가 나왔다. 

# 영화 평점 예측 점수의 평균
In: 
error = (np.mean(rotten_y[:TRAINING_CUT_OFF]) - rotten_y[TRAINING_CUT_OFF:])
'mean square error %2.2f' % np.mean(error ** 2)

Out:
'mean square error 0.09'

 

단, 인기가 좋은 영화는 예측 점수가 대체적으로 좋을 수 있기 때문에, 예측 결과와 평균 점수를 같이 고려해야 한다.

GitHup 코드에서 Gross를 예측한 예제 코드도 확인할 수 있다.

 

고찰 

간단한 방법부터 실행하여 이 방법과 방향이 맞는지 확인하여야 한다. 간단한 모델임에도 좋은 결과를 주지 못한다면, 복잡한 모델 또한 쓸만한 결과를 줄 가능성은 낮기 때문이다.

 

선형회귀모델은 단순하다. 대부분의 딥러닝 모델이 복합하고 해석이 어려운 것에 비해, 선형회귀모델은 각 요인(factor)의 기여도를 직접 확인해 볼 수 있다는 것이다.

 


이번 장은 위키피디아에서 파싱한 데이터에 대한 이해도가 낮아서 전반적으로 코드에 대한 해석과 분석 방법이 쉽지 않았다. 나중에 스킬업을 해서 해당 포스팅을 좀 더 친절하게 업데이트하도록 하겠습니다...

 

댓글