데이터 과학자에서 AI 연구자로 들어서며…

2018년 9월에 5년 넘게 몸담았던 DT조직에서 AI 조직으로 옮기면서 AI 연구자로 새로운 직무를 시작했다. 그동안 이러한 소식을 블로그에 올리지 못한것은 이 발걸음에 불확실함과 기대, 불안이 공존해 있었기 때문이었다. 4개월이 지나고 어느정도 바쁜 적응의 시간을 보내고 펜을 들어 그 소회를 고백해 보고자 한다.

데이터 분석, 기술, 문화

AI 연구자가 되기 전에 6년 넘게 데이터 분석 업무를 해왔다. 그 이전엔 검색 엔지니어 였고, 엔지니어에서 데이터 사이언티스트로 넘어가게 된 이유와 방법은 이전에 밝힌 바 있다.

첫 4년은 데이터 분석에서 기술적인 부분(통계학, 머신러닝 등)에 많은 관심을 가졌다. 실제 이 기간에 통계학 학사 학위부터 시작해 박사 학위를 시작한것도 이 때문이라고 이야기 할 수 있다. 현란한 기계학습 기법, 시각화 방법론(심지어 시각화 책까지 썼다.)에 심취해 있었고, 이러한 방법론이 이 분야의 거의 전부라 생각했었다. 많은 업무에서 단맛도 많이 봤고, 쓴맛도 많이 봤다. 쓴맛을 본 이유가 모두 기술이 부족해서라 생각했던 시절이었다.

이 기간에 수행했던 일들을 대략적으로 요약하면 아래와 같다(필자의 링크드인에서 발췌한 것이다).

  • Data analysis and modeling with MNO(mobile network operator) related data and problems.
  • Build many kind of Machine Learning model and apply them to real problems.
  • 2015 market top data analyst in SKT.
  • Research on detection of customer life event changes with bootstrap sampling on interesting duration.
  • Experience on handling high-cardinality customer behavior data to apply Machine Learning Model.
  • Apply Bayesian Churn prediction model which better performance than legacy model.
  • Forecasting mobile traffic usage for infra investment.
  • Forecasting number of daily incoming calls on customer center for optimal operation.
  • Fee product analysis for optimal product usage based on Quantile Regression analysis.
  • Anomaly detection about leakage of personal information on the agency.(find system error and case of Illegal usage)
  • Develop credit scoring model for postpaid phone billing(both personal and corporation). Enhances discrimination of every grades and define optimal sales policy for each credit grade. Model uses not only billing history but also Telco specific customer behavior features.
  • Based on high-dimensional behavioral attributes to pursue a differentiated with legacy credit score model.
  • Develop scoring model for new business.
  • Researching heavily on behavior data to make it use it for marketing and new business purposes.
  • Customer Targeting Model for services using both behavior and contextual features which can improve performance more than 3 times better than control group.
  • Build customer job prediction model with behavior features for targeting and new business.
  • Apply scoring model using Deep Neural Network, improving performance over AUC +0.05 compare with traditional machine learning algorithms.
  • Apply online learning with TensorFlow(Deep Stacked Network) to reduce concept drift of changing market situation.
  • Make use of high dimension data on prediction model using AutoEncoder, it improve prediction performance.

최근까지 2년은 딥러닝에 심취하면서 모델링 자동화 그리고 관리 자동화를 위한 시스템을 개발했다. 그리고 현재 많은 조직에서 그렇게 만들어진 시스템을 사용하고 있다. 이 시스템은 직접 데이터 분석 모델링을 하면서 편하게 사용할 수 있는 시스템을 만들기 위함이 컸는데 이 때문에 그 어디에도 없는 시스템이 되었다.

2년 동안 모델링 플랫폼 개발과 딥러닝 리서치에 집중 하면서 지난 4년 동안 해왔던 데이터 분석 업무에 대해서 한걸음 떨어져서 볼 수 있는 기회가 되었다. 그동안 왜 업무에 데이터 기반 의사결정을 적용하는데 몇몇은 실패를 했고 성공을 했는지….

먼저 밝힐것은 그 이유가 ‘기술’하고는 크게 관련이 없었다는 것이다. 랜덤포리스트를 사용하든 딥러닝을 사용하든 기술은 그걸 사용하는 모델러의 만족도 그 이상도 이하도 아니였다. 결국 가장 큰 이유는 비즈니스 자체 그리고 그 주변의 환경 그리고 그들 사이에 있는 사람이었다.

모델은 필요 없어요, 제가 직접 쓸 레버를 주세요.

데이터 기반으로 업무를 최적화 하자고 딥러닝 모델을 개발해서 업무 담당자에게 가져가면 아래와 같은 반응을 보일 것이다.

딥러닝은 해석 불가능해서 사용하기 어려워요.

사실 위 반응은 사용하기 싫다는 표현을 애둘러서 이야기 한 것일 가능성이 70% 이상인데, 순진한 모델러는 딥러닝 모델에 Lime 같은걸 어렵게 적용해서 다시 가져가지만 믿을 수 없다며 담당자는 소극적으로 대하게 된다. 이 쯤되면 모델러는 테스트를 하자고 제안하게 되는데, 대부분의 경우 기존 방법의 1~2% 정도 성능 차이를 내고 적용 리소스 대비 효과가 적다며 클로징 하게 된다.

위 일화엔 두가지 업무 적인 특징이 존재할 것이다. 첫번째는 이미 비즈니스 로직이 오랜 시간이 지나면서 사람이 컨트롤 할 수 있는 수준으로 최적화 되었을 것이다. 모델로 자동화를 하지 않아도 사람이 직접 해도 될 정도인 것이다. 만일 자동화가 절실한 다 대 다를 연결해주는 로직이라면(예를 들어 온라인 광고) 안할 이유가 없다. 두번째는 현업 담당자는 새로운 레버를 원한다는 것이다. 현업 담당자가 원하는 건 대부분 자신이 몰랐던 속성(레버) 한, 두개이다. 모든 데이터 기반으로 하고 있는 블랙박스 모형은 원하지 않는다. 무엇보다 자신이 컨트롤 하기 어려워 지게 되면 필요에 따라 메트릭을 생성하는데 어려움을 겪게 되고 이는 곧 자신의 성과에 영향을 미치게 될 것이기 때문이다.

이러한 상황에 1~2%의 성능 차이도 큰 비용의 차이를 가져오는 영역을 찾아서 데이터 기반으로 개선하는건 좋은 접근 방법이나 이 역시 많은 노력과 시간이 필요하며 현업 담당자가 단순한 레버 활용으로 땡겨낼 수 있는 성과일 가능성이 높다. 이러한 경우도 최소 5% 이상의 성능 차이는 보여줘야 되는데 상당히 어려운 일이다.

레버 뿐만 아니라 브레이크, 핸들까지 줄께요..

이러한 상황에서 가장 좋은 접근 방법은 담당자에게 그들이 원하는 도구를 만들어 주는 것이다. 이쯤되면 분석, 모델을 넘어 개발이 된다. 분석, 모델링을 넘어서 도구까지 만들어줄 생각까지 염두에 두고 커뮤니케이션 하는건 담당자와 지속적관 관계를 유지하고 데이터 기반 업무 의사결정을 유도할 수 있게 하는 큰 디딤돌이 된다. 어떠한 담당자도 처음보는 괴상한 모델러가 와서 자신의 모든 업무를 자동화 시킨다고 설레발 치는걸 좋아할 수 없다. 레버는 물론 브레이크, 핸들까지 만들어 줄 생각으로 접근하고, 지속적 점진적으로 감동,개선시켜야 된다. 누가 데이터 과학자를 섹시한 직업이라 했나? 이미 섹시한 사람만이 데이터 과학자로 성공할 가능성이 높다.

저런 고민 하고 싶지 않고 분석 모델링만 하고 싶어요.

프로세스…. 사람을 변화시키는건 매우 어렵다. 얼마나 어려우면 IBM에서는 사람을 해고하고 새로 채용하는게 프로세스를 개선하는 가장 빠른 방법이라 하겠나. 아마도 그걸 아는 분들은 저런 고민을 하고 싶지 않을 것이다. 그렇다면 저런 고민 하지 않고 순수한 분석, 모델링을 할 수 있는 곳은 어디일까?

앞서 이미 비즈니스 로직이 사람에 최적화 되어 자동화에 대한 니즈가 없다고 언급했다. 반대로 비즈니스 로직이 복잡해 담당자의 의사결정 자동화가 절실한 분야에 가라고 조언하고 싶다. e커머스, 온라인 광고 등등 온라인 서비스 회사는 매칭 경우의 수가 Many-to-Many여서 자동화가 필수이다. 이러한 영역에서 크리티컬한 의사 결정의 영역은 모두 머신러닝 모델이 수행하고 있다. 초단위로 모델을 관리하고 A/B 테스트를 상시로 구동하고 있으니, 기술을 뽐내고 갈고 닦기 좋은 영역이다.

그러니 좌절하거나 실망하지 말아요.

비즈니스 데이터 분석을 한다는건 상당한 불확실성의 영역에서 과학을 하는 것이다. 사실 이미 그 일이 성공할지 실패할지 본인의 실력과는 크게 상관없이 이미 정해져 있는 경우가 많다. 그렇다고 잘 될 분석만 가려서 할 수도 없고 누군가는 해야 될 상황이 대부분이다. 따라서 본인이 최선을 다하고 정말 뭐 할것 없이 수많은 시도를 했음에도 일이 더 이상 진행되지 않는다고 자신에 대해 실망하거나 자책하지 않았으면 좋겠다. 이런 상황에 데이터 분석 조직 리더는 분석가를 질책, 책망하기 보다는 같이 고민해주고 담당자가 직접 풀기 어려운 조직레벨의 이슈를 해결해 주거나 분석 방향을 조절 및 제안해 주는데 집중 해야되며, 특히 잘 될 업무, 실패할 수 있으나 잠재력이 있는 업무 등을 리더가 잘 구분해 분석가들의 업무 스트레스/성과를 잘 조절해주려는 노력이 필요하다.

데이터 분석은 뒤로하고… AI 연구자로…

비즈니스 데이터 분석을 해오면서 느낀 한계와 가능성은 전적으로 내 개인적인 경험이고 나름의 결론이라 모든 경우의 결론이라 할 수 없는것에 주의했으면 한다. 하지만 사람, 문화, 업무를 변화하시키는게 업무의 본질임은 많은 비즈니스 데이터 분석가들이 공감하는 부분이라 생각하며, 그 미션이 생각보다 쉽지 않다라는 것은 많은 경험자들의 공통 의견이라 생각한다.

앞서 언급한 데이터 분석 업무에 대한 나름의 결론을 장시간을 투자해 얻었고 더 할만한 영역도 없었으며, 조직 초기부터 참여하여 이제는 조직이 안정화된 업무를 하고 있어 스스로 하산해도 될 거란 판단이 들었다(참고로 몸담았던 DT 조직은 현재 국내 최고의 데이터 과학자 조직으로 인정받고 있다). 그러면서 취미로 하던 딥러닝 연구를 더 깊이 하기 위해 조직 이동을 하게 되었다.

필자가 왜 AI 연구자가 되었는지 이유는 아래 세가지다.

  • 데이터 분석/모델링은 많이 했고 나름의 결론도 냈어요. (이제 그만…)
  • 딥러닝 기술연마/연구를 계속 하고 싶어요(논문도 쓰고..).
  • 딥러닝 기술 기반으로 신기한 AI 서비스를 만들고 싶어요.

AI 연구에서 엔지니어링 스킬은 생각보다 중요

조직 이동전에는 연구에 엔지니어링 스킬이 어느정도 필요할지 감을 잡지 못했다. 지금은 엔지니어링 스킬이 연구를 위해 가장 필요한 체력적인 부분이라는 것에 100% 공감하고 있다. 축구 선수로 치면 체력이 좋아야 슛을 많이 시도할 수 있는 것과 같다.

이 측면에서 볼때 경력있는 엔지니어가 AI 연구에 적합한건 당연한 것이라 생각한다. 그동안 엔지니어링 스킬을 다져온걸 큰 다행이라 생각하고 있고, 지금 이 시간 휴일에도 이 기술을 딥러닝 기술과 함께 연마하고 있다. 개발을 수년간 지속적으로 해왔지만 지금도 연구를 위해 부족하다고 느끼는 부분이 있을 정도다.

기업의 AI 연구자로서..

생각해보면 AI 연구는 정말 어려운 것이다. 코딩도 (정확하게) 잘 해야되고, 학습 시간도 오래걸려(BERT는 8 GPU로 1달 학습해야됨. 1년에 12번 테스트만 해야 될까?) 최적화도 시켜야되며 이러한 과정을 수백번 반복되야 쓸만한 연구 결과 1개가 나오니 말이다. 그래서 요즘엔 회사에서 지속적인 AI연구를 할 수 있는 방법은 뭐가 될까를 고민하고 있다. 그리고 그 연구 방향이 회사에 도움이 되는 방향과 맥을 같이해야 된다는 (어찌보면 당연한) 스스로의 결론에 이르럿고, 이러한 생각을 가지고 한해를 시작하고 있다. 아마도 1년이 지나면 이러한 생각, 행동의 정답을 볼 수 있지 않을까 한다.

Attention API로 간단히 어텐션 사용하기

GluonNLP

NLP쪽에서 재현성의 이슈는 정말 어려운 문제이다. 실제 모형의 아키텍처와 적절한 전처리 로직이 잘 적용 되었을때 성능이 도출되나 대부분 리서치에서는 전처리 로직에 대한 충분한 설명이 되어 있지 않다. 따라서 아키텍처의 이해보다는 전처리에 대한 문제 때문에 후속 연구가 진행되지 못하는 경우가 많다.
전처리의 이슈가 큰 또 다른 이유는 처리 로직의 복잡도 때문에 같은 로직이더라도 다양한 구현 방식이 가능하다. 예를 들어 불용어 처리를 했다는 언급에는 어떠한 기준으로 용어를 필터링 했는지에 대한 다양한 질문들이 포함되어 있다. 이러한 아픔을 공감한 개발자들이 GluonNLP라는 프로젝트를 진행하고 있는데, 이 결과물을 기반으로 얼마나 쉽게 어텐션을 구현할 수 있는지 살펴보고자 한다.

어텐션 뿐만 아니라 다양한 언어모델과 이를 간편하게 사용할 수 있는 인터페이스 그리고 임베딩 모델을 제공하고 있으며, SOTA를 실행해 볼 수 있는 다양한 스크립트도 제공하고 있다. 특히나 언어모델, 임베딩 학습에 대한 시작과 활용의 시발점으로 하기에 매우 좋은 함수들도 제공하고 있어 이를 이해하고 사용하는 것 만으로도 큰 배움을 얻을 수 있다.

 

어텐션(Attention)

먼저 어텐션의 출현 배경을 설명하기 위해 일반적인 시퀀스 to 시퀀스의 구조를 살펴보자(이미 어텐션을 잘 아시는 분은 다음으로 넘어가도 된다).
인코더 정보를 디코더에 전달할 수 있는 방식은 고정된 길이의 짧은 히든상태 벡터이다. 이 히든상태 벡터는 디코더의 히든상태 입력으로 활용된다. 학습이 잘 되었다면 이 벡터에는 입력 문장에 대한 훌륭한 요약 정보를 포함하고 있을 것이다. 초기 신경망 번역기는 이러한 방식으로 구현되었는데, 입력 문장이 길어지면서 심각한 성능저하가 일어나기 시작했다. 이 짧은 히든상태 벡터에 긴 시퀀스의 정보를 모두 저장하기엔 한계가 있었으며, 이러한 문제의 해결을 위해 어텐션 개념이 만들어졌다1.
어텐션은 RNN과 같이 딥러닝 관련 레이어가 아니라 매커니즘으로, 특정 시퀀스를 출력하기 위해 입력 시퀀스의 어떠한 부분을 강조해야 될는지 학습을 할 수 있는 개념을 의미한다. 물론 입출력 시퀀스가 자기 자신이 되는 셀프 어텐션 등 다양한 방식이 존재한다.
아래 그림은 어텐션이 인코더 디코더 사이에서 학습되는 방식을 도식화 한것이다. 입력 시퀀스 중에서 “방”이라는 단어가 “room”이라는 단어가 시퀀스에서 출현시 강조되는 것이며, 그러한 강조 정보가 입력 시퀀스에 적용되어서 디코더에 입력된다. 매 디코더 시퀀스마다 이러한 계산이 진행되며 수많은 문장이 학습되면서 인코더 디코더에 입력되는 단어들의 상호간의 컨텍스트가 학습된다.
기계번역에서 어텐션은 다소 어려운 구현 방식중에 하나여서 좀더 일반적인 어텐션에 대해서 설명해보도록 하겠다(몇몇 잘못된 부분을 고쳤으며, 수식은 많은 부분 이곳2을 참고했다).
아래와 같이 n길이를 가지는 입력 x 시퀀스와 m길이를 가지는 출력 y 시퀀스가 있다고 가정하자.

 

$x = [x_1, x_2, x_3, …, x_n] \\ y = [y_1, y_2, y_3, …, y_m]$

 

인코더의 Bi-GRU의 전방 히든상태 $\overrightarrow{h_i}$와 후방 히든상태 $\overleftarrow{h_i}$를 결합(concat)한 벡터가 존재한다고 하자.

 

$h_i=[\overrightarrow{h_i^T};\overleftarrow{h_i^T}], i = 1,…,n$

 

디코더에서의 출력 시퀀스 t의 히든상태는 $s_t = f(s_{t-1}, y_{t-1}, c_t)$로 구성되는데, 여기서 어텐션 결과인 컨텍스트 벡터 $c_t$를 구하기 위해 입력 시퀀스 모든 히든상태에 대한 정렬 스코어(alignment score) $\alpha$를 가중합한 형태로 사용하게 된다. 자세한 수식은 아래와 같다.

 

$\begin{aligned} c_t &= \sum_{i=1}^n \alpha_{t,i} \boldsymbol{h}_i & \small{\text{; 출력 } y_t\text{에 대한 컨텍스트 벡터}} \\ \alpha_{t,i} &= \text{align}(y_t, x_i) & \small{\text{; 얼마나 두 단어 }y_t\text{ 와 }x_i\text{ 가 잘 매칭하는지…}} \\ &= \frac{\text{score}(s_{t-1}, h_i)}{\sum_{i=1}^n \text{score}(s_{t-1}, h_{i}))} & \small{\text{; 정렬 스코어를 소프트맥스로 계산}}. \end{aligned}$

 

$\alpha_{t,i}$는 출력 $t$에 대한 입력 단어 $i$의 가중치라 볼 수 있고, 이를 가중치 벡터(weight vector)라고 한다. 여기서 score를 어떻게 정의하느냐에 따라 다양한 어텐션 종류가 생성되는데 초기 논문3에서는 아래와 같이 풀리 커넥티드 레이어(fully connected layer)인 Dense로 구현된다.

 

$\text{score}(s_{t-1}, h_i) = \mathbf{v}_a^\top \tanh(\mathbf{W}_a[s_{t-1}; h_i])$

 

$\mathbf{v}_a^\top$와 $\mathbf{W}_a$는 가중치 행렬로 학습의 대상이 된다.
어텐션이 학습에 큰 도움이 되는 것이 알려진 뒤에 출력, 입력 단어간의 내적으로만 스코어를 계산하는 방식과 함께 가중치 행렬 하나만을 이용한 단순한 방식 등 다양한 변종이 출현하게 된다.

 

$\text{score}(s_{t-1}, h_i) = s_{t-1}^\top h_i \\ \text{score}(s_{t-1}, h_i) = s_{t-1}^\top\mathbf{W}_a h_i$

 

또한 인코더 디코더 쌍이 아닌 하나의 입력을 흡사 인코더 디코더인것 처럼 학습해 입력 단어들간의 상관성을 학습하여 효과를 거둔 셀프 어텐션(Self-Attention)도 많이 활용된다4. 하나의 문장을 기준으로 셀프 어텐션을 적절하게 학습했다면 아래와 같은 효과를 볼 수 있고, 심지어 문장 안에서 멀리 떨어진 단어들의 관계까지 학습이 가능하다.

 

셀프 어텐션을 이용한 네이버 영화 리뷰 분류 모델

여기서는 과거 블로그 포스팅에서 이용한 네이버 영화 리뷰 분류 모형을 기반으로 셀프 어텐션을 활용해 보도록 하겠다. GluonNLP에서는 위에서 언급한 score 함수의 구현 방식에 따라 MLPAttentionCell, DotProductAttentionCell  이렇게 두가지 어텐션을 제공하고 있다. 사실 가장 범용적인 어텐션 방식이기 때문에 편하게 사용하기 좋은 알고리즘이다.
풀리 커넥티드 레이어 기반의 셀프어텐션과 Bi-GRU를 이용해 어텐션 API를 사용해 구현하면 아래와 같다.
>> class SentClassificationModelAtt(gluon.HybridBlock):
>>     def __init__(self, vocab_size, num_embed, seq_len, hidden_size, **kwargs):
>>         super(SentClassificationModelAtt, self).__init__(**kwargs)
>>         self.seq_len = seq_len
>>         self.hidden_size = hidden_size 
>>         with self.name_scope():
>>             self.embed = nn.Embedding(input_dim=vocab_size, output_dim=num_embed)
>>             self.drop = nn.Dropout(0.3)
>>             self.bigru = rnn.GRU(self.hidden_size,dropout=0.2, bidirectional=True)
>>             self.attention = nlp.model.MLPAttentionCell(30, dropout=0.2)
>>             self.dense = nn.Dense(2)  
>>     def hybrid_forward(self, F ,inputs):
>>         em_out = self.drop(self.embed(inputs))
>>         bigruout = self.bigru(em_out).transpose((1,0,2))
>>         ctx_vector, weigth_vector = self.attention(bigruout, bigruout)
>>         outs = self.dense(ctx_vector) 
>>         return(outs, weigth_vector)

self.attention의 인자로 Query와 Key가 들어가는데, 이 부분을 이전 레이어의 출력으로 동일하게 채워주면 셀프 어텐션으로 동작하게 된다.

self.attention에서 두개의 결과를 반환하는데, 첫번째 결과는 모델에서 예측을 하는데 직접 쓰이게 되는 컨텍스트 벡터($c_t$)이고 두번째 결과는 가중치 벡터($\alpha_{t,i}$)이다.

어텐션은 예측 성능을 높이는데 큰 도움을 주기도 하며, 가중치 벡터를 직접 출력하여 모델이 결과를 어떻게 도출하는지 확인할 수 있는 창구 역할도 수행하게 된다. self.attention에서 출력한 결과를 가지고 입력 문장(“가지고 있는 이야기에 비해 정말로 수준떨어지는 연출, 편집”, 부정리뷰)의 어텐션 정보를 시각화 해보면 아래와 같다.

단순히 행렬을 시각화하고 각 시퀀스 단어를 축에 뿌려준 결과인데, “정말 수준 떨어지”와 같은 단어들이 상호간의 가중치를 서로 중요하게 발현하고 있음을 알 수 있다. 만일 모형이 과적합 되거나 제대로 학습되지 않았을 경우엔 이러한 가중치도 제대로 시각화 되기 어렵다.

결론

전체 소스코드를 이곳에 올려 놓았는데, 위 어텐션 활용 부분 뿐만 아니라 데이터 전처리 로직 전체를 GluonNLP를 이용해 고쳐봤다. 이전 블로그 포스팅 전처리 코드의 복잡도와 비교해보면 유사한 전처리를 했음해도 아주 간결하게 코드가 작성됨을 비교할 수 있을 것이다. 코드가 길어지면 재현성도 떨어지고, 실수도 늘게 마련이다. 따라서 이런 패키지는 고맙게 쓸 따름이다. 물론 기여할 수 있다면 더 보람될 것이다. 특히 NLP를 한다면 여기저기 신뢰성 없는 코드를 받아 쓰는 것 보다 좋은 결과를 내줄것이라 생각한다.




딥러닝 프레임워크로 임베딩 제대로 학습해보기

“gensim이 아닌 직접 딥러닝 네크워크 구조를 구현해 임베딩을 성공적으로 학습해본 경험이 있는지요?”

이 글은 네트워크 구조의 임베딩 학습을 숫하게 실패해본 분들을 위한 글이다.

많은 온라인 문서에서든 책에서든 word2vec을 설명하는 부분에서 딥러닝 프레임워크 기반 그래프 구조로 설명을 한다. 게다가 코드와 학습까지 Keras와 같은 프레임워크로 동작하는 예제를 제공하나, 추출된 단어 벡터를 기반으로 Word Analogy나 정성적인 평가에 대한 시도는 꼭 gensim과 같은 소프트웨어를 기반으로 학습한 벡터로 확인한다. 예를 들어 이러한 형태 말이다. 필자 역시 이런 저런 시도를 해본적이 있으나,과거 단 한번도 직접 네트워크를 만들어 gensim과 같은 퀄리티를 가진 임베딩을 생성해 본적이 없다.

최근에 큰 마음을 먹고 논문과 여러 글을 찾아본 결과 학습을 위한 중요한 몇가지가 빠져 있다는 사실을 발견했고, 이들 자료를 기반으로 Gluon으로 직접 활용 가능한 임베딩 학습 로직을 만들어 봤다.

이 글에서 Gluon기반의 NLP API1가 유용하게 사용되었다. 아마도 글을 보면서 그 간결함을 느껴보는 것도 좋을 것이라 생각된다.

늘상 해왔듯이 세종 말뭉치 기반 학습을 수행했으며, 마침 한글 임베딩에 대한 검증셋도 얼마전에 구할 수 있어 검증셋으로 임베딩 평가를 했다. 그 셋에 대한 설명은 아래에서 설명할 것이다.


임베딩 학습

예를 들어 아래와 같은 문장으로 Skip-Gram(중심단어를 기반으로 주변단어를 예측하는)학습을 시킨다고 가정해보자.

명절이 다가오면 주부들은 차례/제사상에 올릴 배와 사과 등 과일에 눈이 갈 수밖에 없다.

간단하게 하기 위해 위와 같은 문장에서 명사만 추출해서 학습한다.
“명절, 주부, 차례, 상, 배, 사과, 과일, 눈, 수”가 명사로 추출될 것이다. 윈도 사이즈를 2로 하면 Skip-Gram 학습셋은 아래와 같이 구성된다(1은 두 토큰이 윈도우 내에서 존재할때의 레이블 값이다).

명절, 주부, 차례, 상, 배, 사과, 과일, 눈, 수” -> (명절, 주부, (1)), (명절, 차례, (1))
명절, 주부, 차례, 상, 배, 사과, 과일, 눈, 수” -> (주부, 명절, (1)), (주부, 차례), (1), (주부, 상), (1)
명절, 주부, 차례, 상, 배, 사과, 과일, 눈, 수” -> (차례, 명절, (1)), (차례, 명절, (1)), (차례, 상, (1)), (차례, 배, (1))

“명절, 주부, 차례, 상, 배, 사과, 과일, 눈, 수” -> (사과, 상, (1)), (사과, 배, (1)), (사과, 과일, (1)), (사과, 눈, (1))

(사과, 과일)이라는 셋이 네트워크에서 학습되는 과정은 아래와 같이 내적계산을 기반으로 수행된다.

 

학습이 수행됨에 따라 $ W $값은 두 토큰이 유사한 방향으로 변화될 것이다. 이 방식이 개략적인 Skip-Gram 기반의 임베딩 학습 방법이다.
실제 임베딩의 학습은 위와 같은 셋으로 입력되지 않고 아래와 같은 형태로 입력된다.
(사과, (상, 배, 과일, 눈, 고양이, 사자, 가을, 눈사람), (1,1,1,1,0,0,0,0))
(중심단어, 주변단어, 정답)순이며, 주변단어에는 확률적으로 샘플링한 비 주변단어들이 함께 학습된다.
실제 학습 원리는 아래 수식과 같다.
$$ P(o|c)=\frac { exp({ u }_{ o }^{ T }{ v }_{ c }) }{ \sum _{ w=1 }^{ W }{ exp({ u }_{ w }^{ T }{ v }_{ c }) } } $$
$P(o|c)$는 중심단어가 주어졌을때 주변 단어가 나올 확률값이며 이를 최대화 하는게 학습의 목표가 된다. 확률을 최대화 하기 위해서는 $v_c$(중심단어에 대한 $W$의 벡터)와 $u_o$(주변단어에 대한 $W’$의 벡터)의 내적을 최대화 하는 방향으로의 임베딩 행렬이 업데이트 되어야 되며, 또한 분모의 값을 감소시키기 위해서 비 주변단어($u_w$)와의 유사도는 감소되어야 된다. 분모에서 모든 비 주변단어를 고려하는건 계산 리소스 낭비가 심하기 때문에 확률적인 샘플링을 통해 일정수의 비 주변단어를 함께 학습하는 방향으로 구현이 된다(이를 네거티브 샘플링(negative sampling)이라 한다).
또한 자주 출현하는 단어들에 대해서 학습셋에 포함되는 확률을 줄여주기 위한 서브샘플링(subsampling)기법을 통해 학습의 효율을 획기적으로 올리는 기법도 사용된다. 이를 통해 학습 데이터 자체를 줄여주어 학습속도를 올릴 수 있게 된다.
사실 일반적인 Skip-Gram을 학습하기 위해서는 네트워크 구조만큼이나 위 트릭들이 중요하며, 제대로된 임베딩 생성 여부를 판가름한다. 대부분의 word2vec 예제들 (특히 Keras 기반)의 재현이 잘 안되는건 이 때문이다.

그럼 word2vec을 학습하는 코드를 작성해보자. 역시 MXNet Gluon으로 작업했다.

이제부터는 핵심적인 코드 설명만 할 생각이다. 동작하는 전체 코드는 이곳에서 찾아볼 수 있다.

>> context_sampler = nlp.data.ContextSampler(coded=coded_dataset, batch_size=2048, window=5)
>> negatives_weights = nd.array([counter[w] for w in vocab.idx_to_token])
>> negatives_sampler = nlp.data.UnigramCandidateSampler(negatives_weights)

ContextSampler는 Gluon NLP에서 제공되는 API로 입력된 데이터셋을 기반으로 배치를 만들어주는역할을 하며 주어진 윈도우 크기를 기반으로 주변단어와 주변단어 길이에 따른 마스킹을 중심단어와 함께 생성해준다. UnigramCandidateSampler는 우리가 구한 단어 빈도를 기반으로 샘플링을 수행하는 API이다. 네거티브 샘플들은 빈도에 기반해서 무작위로 생성되게 된다.

또한 자주 출현하는 단어들에 대해서 학습셋에 포함되는 확률을 줄여주기 위한 서브샘플링(subsampling)기법을 통해 학습의 효율을 획기적으로 올리는 기법도 사용된다. 이를 통해 학습 데이터 자체를 줄여주어 학습속도를 올릴 수 있게 된다.

서브샘플링 로직은 아래 수식과 같은 방식으로 샘플링 확률이 결정된다.

$$ P({ w }_{ i })=1-\sqrt { \frac { t }{ f({ w }_{ i }) } } $$

$ P(w_i) $가 작아야 학습셋에 들어갈 확률이 높아지는데, 여기서 $f(w_i)$는 단어의 출현 확률을 의미한다. 따라서 출현 확률이 낮은 단어일 수록 학습셋에 포함될 확률이 높아진다.

>> frequent_token_subsampling = 1E-4
>> idx_to_counts = np.array([counter[w] for w in vocab.idx_to_token])
>> f = idx_to_counts / np.sum(idx_to_counts)
>> idx_to_pdiscard = 1 - np.sqrt(frequent_token_subsampling / f)
>> coded_dataset = [[vocab[token] for token in sentence
                     if token in vocab
                     and random.uniform(0, 1) > idx_to_pdiscard[vocab[token]]] 
                     for sentence in sejong_dataset]

 

데이터와 전처리

학습에 쓰인 데이터는 세종코퍼스약 9만 4천 문장이며, Gluon NLP의 API를 통해 Vocab 객체로 변환한다. 토크나이저는 KoNLPy의 mecab을 사용했다.

>> sejong_dataset = nlp.data.dataset.CorpusDataset('data/training_corpus_sejong_2017_test_U8_norm.txt', 
>>                                 tokenizer=lambda x:mecab.morphs(x.strip()))
>> counter = nlp.data.count_tokens(itertools.chain.from_iterable(sejong_dataset))
>> 
>> vocab = nlp.Vocab(counter, unknown_token='<unk>', padding_token=None,
                  bos_token=None, eos_token=None, min_freq=5)

 

모델

먼저 두개의 임베딩 레이어를 선언해준다. 이 레이어에서 생성된 가중치가 결국 우리가 원하던 word2vec 결과물이된다.

>> class embedding_model(nn.Block):
>>     def __init__(self, input_dim, output_dim, neg_weight, num_neg=5):
>>         super(embedding_model, self).__init__()
>>         self.num_neg = num_neg
>>         self.negatives_sampler = nlp.data.UnigramCandidateSampler(neg_weight)
>>         with self.name_scope():
>>             #center word embedding 
>>             self.w  = nn.Embedding(input_dim, output_dim)
>>             #context words embedding 
>>             self.w_ = nn.Embedding(input_dim, output_dim)
>>     
>>     def forward(self, center, context, context_mask):
>>         #이렇게 해주면 
>>         #nd.array를 선언시 디바이스를 지정하지 않아도 된다. 
>>         #멀티 GPU 학습시 필수 
>>         with center.context:
>>             #주변단어의 self.num_neg 배수 만큼 비 주변단어를 생성한다.  
>>             negs = self.negatives_sampler((context.shape[0], context.shape[1] * self.num_neg))
>>             negs = negs.as_in_context(center.context)
>>             context_negs = nd.concat(context, negs, dim=1)
>>             embed_c = self.w(center)
>>             #(n_batch, context_length, embedding_vector)
>>             embed_u = self.w_(context_negs)
>> 
>>             #컨텍스트 마스크의 크기를 self.num_neg 만큼 복제해 값이 있는 영역을 표현한다.
>>             #결국 주어진 주변단어 수 * self.num_neg 만큼만 학습을 하게 된다. 
>>             context_neg_mask = context_mask.tile((1, 1 + self.num_neg))
>> 
>>             #(n_batch, 1 , embedding_vector) * (n_batch, embedding_vector, context_length)
>>             #(n_batch, 1, context_length)
>>             pred = nd.batch_dot(embed_c, embed_u.transpose((0,2,1)))
>>             pred = pred.squeeze() * context_neg_mask
>>             
>>             #네거티브 샘플들은 레이블이 모두 0이다. 
>>             label = nd.concat(context_mask, nd.zeros_like(negs), dim=1)
>>         return pred, label

코드에서 특징적인 부분은 비 중심단어 생성을 위해 네거티브 샘플러를 쓰는 부분이다. Gluon NLP에서 잘 구현이 되어 있으니 감사히 쓸 수 밖에…

나머지 부분은 주석과 공식 API문서를 기반으로 생각해보면 큰 무리없이 이해가 가능할 것이다.

평가

임베딩 네트워크를 평가하는 방법은 사람이 스코어링한 단어의 관계 데이터를 기반으로 수행된다. 아쉽게도 지금까지 한글 임베딩에 대한 평가셋이 존재하지 않았으나, 최근 ACL Paper2에서 연구 데이터3를 공개해 간편하게 평가해 볼 수 있게 되었다.

정답셋에 존재하는 단어쌍의 스코어가 우리가 만든 word2vec의 단어 유사도와 순위가 얼마나 같은지 Spearman Rank Correlation4으로 평가한다.

>> import pandas as pd
>> 
>> wv_golden = pd.read_csv('data/WS353_korean.csv')
>> 
>> word1 = wv_golden['word 1'] 
>> word2 = wv_golden['word 2']
>> score = wv_golden['kor_score']
>> 
>> res = [[vocab.token_to_idx[i],vocab.token_to_idx[j],k] for i,j,k in zip(word1, word2, score) 
>>        if vocab.token_to_idx[i] != 0 and vocab.token_to_idx[j] != 0]
>> 
>> word12score = nd.array(res, ctx=ctx)
>> 
>> word1, word2, scores = (word12score[:,0], word12score[:,1], word12score[:,2])
>>
>> def pearson_correlation(w2v, word1, word2, scores):
>>     from scipy import stats
>>     evaluator = nlp.embedding.evaluation.WordEmbeddingSimilarity(
>>         idx_to_vec=w2v,
>>         similarity_function="CosineSimilarity")
>>     evaluator.initialize(ctx=ctx)
>>     evaluator.hybridize()
>>     pred = evaluator(word1, word2)
>>     scorr = stats.spearmanr(pred.asnumpy(), scores.asnumpy())
>>     return(scorr)

Gluon은 임베딩의 워드 유사도를 계산해주는 함수를 제공하고 있고, 이 함수를 통해서 빠르고 간단하게 계산이 가능하다. 위 함수를 매 에폭마다 수행하게해 성능 향상 여부를 확인한다.

학습

>> from tqdm import tqdm
>> 
>> ctx = mx.gpu()
>> 
>> num_negs = 5
>> vocab_size = len(vocab.idx_to_token)
>> vec_size = 100
>> 
>> embed = embedding_model(vocab_size, vec_size, negatives_weights, 5)
>> embed.initialize(mx.init.Xavier(), ctx=ctx)
>> 
>> loss = gluon.loss.SigmoidBinaryCrossEntropyLoss()
>> optimizer = gluon.Trainer(embed.collect_params(), 'adam', {'learning_rate':0.001})
>> 
>> avg_loss = []
>> corrs = []
>> interval = 50
>> 
>> epoch = 70
>> 
>> for e in range(epoch):    
>>     for i, batch in enumerate(tqdm(context_sampler)):
>>         center, context, context_mask  = [d.as_in_context(ctx) for d in batch]
>>         with autograd.record():
>>             pred, label = embed(center, context, context_mask)
>>             loss_val = loss(pred, label)
>>         loss_val.backward()
>>         optimizer.step(center.shape[0])
>>         avg_loss.append(loss_val.mean().asscalar())
>>     
>>     corr = pearson_correlation(embed.w.weight.data(), word1, word2, scores)
>>     corrs.append(corr.correlation)
>>     print("{} epoch, loss {}, corr".format(e + 1, loss_val.mean().asscalar()), corr.correlation)

코드는 학습 레이블이 forward()함수에서 생성된다는 것을 제외하고는 일반적인 학습 로직과 같다. 많이 쓰이는 Adam 옵티마이저를 사용하고 약 70 에폭을 학습했다.

학습 중 매 에폭마다의 상관관계를 시각화한 결과이며, 예상대로 상승하는 추세이며 약 0.5의 상관관계까지 상승하는 것을 알 수 있다.

논문에서 훨씬 더 많은 학습셋과 Skip-Gram으로 약 0.59의 상관관계까지 도출된 것에 비교하면 어느정도 만족할 만한 수준의 임베딩이 학습된 것을 알 수 있다.


전체 코드와 데이터는 이곳에서 확인할 수 있다.

References

  • https://arxiv.org/abs/1301.3781
  • http://gluon-nlp.mxnet.io/examples/word_embedding/word_embedding_training.html