Gorio Tech Blog search

Matrix Factorization 설명 및 논분 리뷰

|

본 글은 2009년에 발표된 Matrix Factorization Techniques for Recommender Systems 논문을 리뷰하고 간단히 요약 정리한 글이다. 논문 원본은 이곳에서 다운 받을 수 있다.


1. Introduction

컨텐츠 기반 필터링은 각 사용자나 아이템에 대해 프로필을 만들고, 그 특성을 구체화하는 방식으로 이루어진다. 반면 위 방식의 대안이라고 할 수 있는 협업 필터링은 어떤 명시적(Explicit) 프로필을 만들지 않고, 이전 구매 기록이나 제품 평가 기록 등 과거 사용자 행동에만 의존해서 시스템을 구성한다. 이 방식은 유저-아이템 간의 상관관계를 찾아내는 것이 주 목적이라고 할 수 있다.

협업 필터링Domain-free 즉, 특별히 이 분야에 대한 지식이 필요하지 않다는 장점을 가진다. 반면 새로운 사용자와 아이템을 다루기에 부적합하다는 Cold Start Problem이라는 한계를 갖고 있다.

협업 필터링근접 이웃 방법잠재 요인 방법로 나뉜다. 후자의 경우 평점 패턴에서 20~100가지의 factor(요인)을 추론하는 것을 목적으로 한다.


2. MF Methods and A Basic MF Model

잠재 요인 협업 필터링을 구현하는 가장 좋은 방법 중 하나는 Matrix Factorization이다. 기본적으로 이 방법은 평점 패턴으로부터 추론한 요인 벡터들을 통해 사용자와 아이템의 특성을 잡아낸다. 이 때 사용자와 아이템 사이의 강한 관련성이 있다면 추천이 시행된다. 이 방법은 확장성, 높은 정확도, 유연성이라는 장점을 가진다.

추천 시스템은 여러 종류의 Input Data를 활용할 수 있다. 물론 가장 좋은 것은 양질의 명시적 피드백(Explicit Feedback)이 될 것인데, 이는 영화 평점이나 좋아요/싫어요와 같은 아이템에 대한 사용자의 선호 결과를 의미한다. 일반적으로 이러한 피드백은 그리 많이 이루어지지 않기 때문에, 이를 행렬로 정리하면 희소(Sparse) 행렬이 될 수 밖에 없다.

만약 이러한 명시적 피드백 조차 활용할 수 없을 때는, 추천 시스템은 암시적 피드백(Implicit Feedback)을 이용하여 사용자의 선호를 파악하게 된다. 이는 구매내역이나 검색기록, 검색 패턴, 커서의 움직임 등을 의미하며 이를 통해 사용자의 선호를 파악하는 것이 목표라고 할 수 있겠다.

Matrix Factorization(이하 MF 또는 행렬 분해) 모델은 사용자와 아이템 모두를 차원 f의 결합 잠재요인 공간에 매핑하는데, 사용자-아이템 상호작용은 이 공간에서 내적으로 모델링 된다.

아이템 i는 $ q_i $로, 사용자 u는 $ p_u $라는 벡터로 표현된다. 이 둘의 내적은 사용자-아이템 사이의 상호작용을 반영하며 이는 곧 아이템에 대한 사용자의 전반적인 관심을 표현한다고 볼 수 있다. 식은 아래와 같다.

[\hat{r_{ui}} = q^{T}_i p_u]

이 모델은 사실 SVD(Singular Vector Decomposition)과 매우 유사한데, 추천 시스템에서는 결측값의 존재로 이 SVD를 직접적으로 사용하는 것은 불가능하다. 결측값을 채워 넣는 것 역시 효율적이지 못하고 데이터의 왜곡 가능성 때문에 고려하기 힘들다.

따라서 오직 관측된 평점만을 직접적으로 모델링하는 방법이 제시되었으며, 이 때 과적합을 방지하기 위해 규제 항이 포함되었다. 요인 벡터 $ q_i, p_u $를 학습하기 위해 시스템은 관측된 평점 세트를 바탕으로 아래 식을 최소화하는 것을 목적으로 한다.

[\min_{q, p} \sum_{(u, i) \in K} ( r_{ui} - q^T_i p_u )^2 + \lambda (\Vert{q_i}\Vert^2 + \Vert{p_u}\Vert^2)]

이 때, K는 $ r_{ui} $가 측정된(known) 값일 때의 (u, i) 세트를 의미한다. 결과적으로 이 모델은 알려지지 않은 평점을 예측하는 것이 목적이기 때문에 과적합을 방지해야 하고, 이를 위해 규제항이 필요하고 $ \lambda $가 이 규제의 정도를 제어한다. $ \lambda $는 주로 Cross-Validation에 의해 결정된다.


3. Learning Algorithms and Adding Biases

이전 장에서 본 식을 최소화하기 위한 방법으로는 2가지가 제시된다.

3.1. Stochastic Gradient Descent

각각의 훈련 세트에 대해 본 알고리즘은 $ r_{ui} $를 예측하고 다음과 같은 예측 오차를 산출한다.
\(e_{ui} = r_{ui} - q^T_i p_u\)

이후 $ q_i $와 $ p_u $를 아래와 같이 업데이트 한다.

\(q_i := q_i + \gamma (e_{ui} p_u - \lambda q_i)\)
\(p_u := p_u + \gamma (e_{ui} q_i - \lambda p_u)\)

확률적 경사하강법은 구현이 쉽고 빠르다는 장점을 지닌다.

3.2. Alternating Least Squares

$ q_i $와 $ p_u $가 둘다 미지의 값이기 때문에 앞서 최소화하려고 했던 식은 convex하지 못하다. 그러나 만약 둘 중 하나를 고정(fixed)할 수 있다면, 이 최적화 문제는 quadratic하게 바뀌어 해를 구할 수 있게 된다. 따라서 ALS는 $ q_i $를 고정했다가 다음 번에는 $ p_u $를 고정하는 방식으로 작동한다. $ p_u $가 고정되어 있다면 본 알고리즘은 최소제곱법으로 $ q_i $를 다시 계산한다. 이러한 방법으로 목적 함수(2장에서 본 최소화 시켜야 할 식)를 최소화할 수 있는 것이다.

3.1장에서 본 SGD가 일반적으로 편리한 방법이긴 하지만 아래의 2가지 경우에는 이 ALS가 효과를 발휘하기도 한다.

  • 시스템이 병렬화를 지원하는 경우
  • 시스템이 암시적 데이터에 집중되어 있는 경우

3.3. Adding Biases

$ \hat{r_{ui}} = q^{T}_i p_u $ 식은 여러 평점 결과를 만들어 내는 사용자와 아이템 간의 상호관계를 파악하는 것이 목적이다. 그런데 사실 많은 경우에 이 상호작용 외에 사용자나 아이템 자체의 특성이 이러한 평점 결과에 영향을 미친다. 이것을 우리는 biases 또는 intercepts라고 부른다. 이를 앞서 보았던 방정식과 목적 함수에 적용해보면 아래와 같다.

[\hat{r_{ui}} = \mu + b_i + b_u + q^{T}_i p_u]

[\min_{p, q, b} \sum_{(u, i) \in K} ( r_{ui} - \mu - b_i - b_u - q^T_i p_u )^2 + \lambda (\Vert{q_i}\Vert^2 + \Vert{p_u}\Vert^2 + b^2_u + b^2_i)]


4. Additional Input Sources and Temporal Dynamics

종종 시스템은 Cold Start 문제에 직면하게 되는데, 평점 데이터에 기반한 추천 시스템을 만드는 상황에서는 사용자들이 평점 결과를 거의 남기지 않는 상황이 이 문제에 해당한다고 볼 수 있다. 이럴 때에는 사용자에 대한 추가적인 정보 소스들을 모두 통합할 필요가 있다. 즉, 행동 정보(Behavior Information)들이 필요하다는 것이다. 예를 들어 소매업자는 고객의 구매 기록이나 검색 기록 등을 활용할 수 있을 것이다.

단순화하기 위해 Boolean 암시적 피드백이 있는 경우를 생각해보자. $ N(u) $는 사용자 $u$가 암시적 선호를 표현한 아이템의 집합을 의미한다. 시스템은 이를 통해 사용자의 프로필을 만들어 낸다. $ N(u) $에 속한 아이템에 대한 선호를 표현한 사용자는 아래 벡터와 같이 표현된다.

[\sum_{i \in N(u)} x_i]

이 식을 정규화하는 것이 일반적으로 더 좋은 결과를 가져오기에, 정규화를 하겠다.

[ N(u) ^{-0.5} \sum_{i \in N(u)} x_i]

또 중요한 정보는 인구학적 정보와 같은 사용자 속성(User Attributes)이다. 유사하게 표현하면 아래와 같다.

[\sum_{a \in A(u)} y_a]

모든 Signal Source를 통합하여 개선된(Enhanced) 사용자 표현식은 아래와 같다.

[\hat{r_{ui}} = \mu + b_i + b_u + q^T_i [p_u + N(u) ^{-0.5} \sum_{i \in N(u)} x_i + \sum_{a \in A(u)} y_a]]

지금까지의 모델은 사실 정적(static)인 모델이었다. 즉, 시간의 변화를 반영하지 못한다는 뜻이다. 그러나 현실에서는 제품에 대한 인식, 인기는 새로운 선택지가 늘어남에 따라 시시각각 변하기 마련이다. 또한 고객들의 성향도 진화하여 그들의 취향은 때때로 변화한다. 따라서 추천 시스템은 시간에 따라 변하는 사용자-아이템 상호작용의 동적(dynamic)인 성질을 반영하는 Temporal Effect에 대해 설명할 수 있어야 한다.

총 3개의 항이 변화한다.
$ b_i(t) $: 아이템의 인기는 시간에 따라 변한다.
$ b_u(t) $: 사용자의 성향도 시간에 따라 변한다. (baseline rating)
$ p_u(t) $: 시간이 흐름에 따라 아이템에 대한 사용자의 선호는 변화할 수 있다.

단 (설정에 따라) 아이템의 성격은 (이미 만들어졌기에) 변하지 않으므로 아이템은 시간에 관한 함수로 구성되지 않는다. 최종적으로 정리하면 아래와 같은 식이 만들어진다.

[\hat{r_{ui}}(t) = \mu + b_i(t) + b_u(t) + q^T_i p_u(t)]


5. Inputs with varying confidence levels

모든 관측값이 같은 신뢰도(confidence)를 가지는 것은 아니다. 예를 들어 어떤 적대적 사용자는 별 이유 없이 낮은 평점을 제공할 수도 있는 것이다. 따라서 추천 시스템을 더욱 공고히 하기 위해서는, 예측된 선호도에 신뢰도를 붙여야(attach) 한다. 이 신뢰도는 action의 빈도를 설명하는 실수 값인데, 예를 들어 특정 사용자가 특정 show를 얼마나 오래, 자주 보았는가와 같은 값이 신뢰도가 될 수 있다. 이러한 특성을 목적 함수에 반영하면 아래와 같이 될 것이다.

[\min_{p, q, b} \sum_{(u, i) \in K} c_{ui}( r_{ui} - \mu - b_i - b_u - q^T_i p_u )^2 + \lambda (\Vert{q_i}\Vert^2 + \Vert{p_u}\Vert^2 + b^2_u + b^2_i)]

Comment  Read more

잠재요인 협업필터링 (Latent Factor Collaborative Filtering) 설명

|

1. Introduction

추천시스템은 이제는 너무 많은 산업에서 도입하고 있는 시스템이기에 웬만큼 참신하지 않은 이상 새롭게 들리지 않는 것이 현실이다. 그러나 소비자의 입장에서 추천시스템을 보는 것과, 이 시스템의 개발자가 추천시스템을 바라 보는 것에는 큰 차이가 있다. 성공적으로 추천 엔진을 도입한 산업, 기업들이 있는 반면 여러 가지 어려움으로 인해 실질적인 효과가 떨어지는 산업, 기업도 있기 마련이다.

사용자(User)의 행동 양식, 인구학적(Demographic) 정보, 아이템(Item)의 특성, 외부 변수 등 수많은 변인들을 관리하고 분석해서 사용자에게 가장 알맞는 아이템을 추천해주는 일은 분명 쉬운 일은 아니다. 이러한 어려움을 극복하기 위해 연구자들은 과거부터 여러 종류의 추천 시스템을 개발해왔는데, 지금부터 그에 대해 조금씩 알아보고자 한다.

추천 시스템을 만드는 방법에는 굉장히 다양한 방식이 존재하지만, 본 글에서는 가장 핵심이 되는 방법론들에 대해서만 간단히 언급하고자 한다. 추천 시스템은 크게 컨텐츠 기반 필터링(Content Based Filtering) 방식과 협업 필터링(Collaborative Filterin) 방식으로 나뉜다. 협업 필터링은 또 최근접 이웃(Nearest Neighbor) 협업 필터링잠재 요인(Latent Factor) 협업 필터링으로 나뉜다.

과거에는 컨텐츠 기반 필터링최근접 이웃 협업 필터링이 더욱 주목을 받았지만, 2009년에 있었던 넷플릭스 추천 컴퍼티션에서 행렬 분해(Matrix Factorization)를 이용한 잠재 요인 협업 필터링 방식이 우승을 차지하면서, 연구자들은 이 방식에 큰 관심을 갖게 되었다. 현재로서는 많은 경우에 이 방식이 우위를 차지하지만, 상황에 따라서는 다른 방식이 더 좋은 결과를 낼 때도 많고, 하이브리드 형식으로 결합하는 방식 또한 좋은 효율을 보여주는 경우도 많다.

아래에서 보충 설명을 하겠지만 추천 시스템의 대표적인 방법론들을 구조화하면 아래와 같다.

앞으로 여러 시리즈로 이어질 추천 시스템에 관한 글들은, 위에서 언급한 잠재 요인 협업 필터링과 이 방법론에서 출발하여 발전된 알고리즘에 대해 다룰 예정이다.

Matrix Factorization 개념에 Support Vector Machine의 개념을 결합한 것이 Factorization Machines이다. 여기서 더 나아가 개별 feature들의 메타정보(field)를 알고리즘에 반영한 것이 Field-aware Factorization Machines이다. 줄여서 각각 FMFFM이라고 부르는 것이 일반적이다.

로지스틱 모델과 달리 FFM은 가중치를 latent vector화 했기 때문에 연산량과 메모리 사용량이 더 많은 단점이 있지만, 최근 여러 논문에서는 system tuning을 통해 실제 광고 서빙에 사용하는 데 큰 지장이 없음을 밝혔다. 여력이 될 때 더욱 최신 연구들에 대해서도 글을 추가하도록 할 것이다.


2. 추천 시스템의 개요

2.1. 컨텐츠 기반 필터링

어떤 사용자가 특정 아이템을 선호할 때, 그 아이템과 비슷한 컨텐츠를 가진 다른 아이템을 추천하는 것이 이 방식의 기본 아이디어이다. 추가적으로 설명하자면, 이 방식은 사용자와 아이템에 대한 프로필을 만들고 그 특징을 활용한다. 예를 들어 어떤 특정 영화는 장르, 출연배우, 박스오피스 인기도 등 여러 특성을 지니게 될 텐데 이 특성(컨텐츠)들이 이 영화의 프로필을 형성하는 것이다.

2.2. 최근접 이웃 협업 필터링

모든 협업 필터링은 사용자-아이템 행렬 데이터에 의존한다. 사용자가 남긴 평점(rating) 데이터를 기반하여 남기지 않은 데이터를 추론하는 형식이다.

2.2.1. 사용자 기반 최근접 이웃 협업 필터링

특정 사용자와 유사한 사용자들을 선정하고, 이들을 TOP-N이라고 명명한 뒤 이들이 선호하는 아이템을 특정 사용자에게 추천하는 방식이다.

2.2.2. 아이템 기반 최근접 이웃 협업 필터링

어떤 사용자가 A라는 아이템을 선호한다고 할 때, 그 사용자는 A와 유사한 B라는 아이템 역시 선호할 것이라는 가정 하에 추천을 진행하는 방식이다. 아이템 기반 방식이 사용자 기반 방식 보다 정확도가 높은 것이 일반적이기에 본 방식이 더욱 자주 사용된다.

2.3. 잠재 요인 협업 필터링

사용자-아이템 평점 행렬에 잠재되어 있는 어떤 요인(factor)이 있다고 가정하고, 행렬 분해를 통해 그 요인들을 찾아내는 방식이다. 이 잠재 요인은 구체적으로 정의하는 것이 때로는 어렵지만, 실제 시스템에서는 추천의 근거를 마련하는 데에 있어 큰 역할을 수행한다.

예를 들어보면, 영화 장르를 잠재 요인으로 설정할 수 있다. 어떤 사용자는 판타지 영화를 다른 어떤 영화보다 좋아한다고 하면, 이 사용자에게 있어 영화를 선택할 때 가장 중요한 기준(요인)은 판타지 영화이냐 아니냐가 될 가능성이 높다. 그리고 이 사용자에게 다른 영화를 추천해준다고 한다면, 판타지 영화를 추천하는 것이 가장 합리적일 가능성이 높다는 것이다. 잠재 요인 협업 필터링은 이러한 요인들을 찾아 추천에 활용하게 된다.

지금부터는 이 행렬 분해를 어떻게 진행하는지에 대해 알아보도록 하겠다.


3. Singular Value Decomposition

특이값 분해Spectral Decomposition의 일반화 버전이라고 생각하면 쉽다. 즉, 정방행렬이라는 조건을 만족하지 않아도(행과 열의 개수가 달라도) 다차원 행렬을 저차원 행렬로 분해하는 차원 축소 기법이다.

Spectral Decomposition에 따르면 정방행렬 A는 아래와 같이 표현할 수 있다.

[A = P\Lambda P’ = P\Lambda P^T = \sum_{i=1}^{p} \lambda_i e_i e_i’]

여기서 $P$는 $\lambda$에 대응하는 고유벡터들을 열벡터로 가지는 직교행렬이다. $\Lambda$는 $A$의 고유값들을 대각원소로 가지는 대각행렬이다.

(m, n), m>n인 직사각 행렬 $A$에 대해 특이값 분해를 실시하면 아래와 같이 표현될 수 있다.

[A = U\Sigma V^T]

  • $U$: (m, m), $A$의 left singular 벡터로 구성된 직교행렬
  • $V$: (n, n), $A$의 right singular 벡터로 구성된 직교행렬
  • $\Sigma$: (m, n), 주 대각성분이 $\sqrt{\lambda_i}$인 직사각 대각행렬

$AA^T$를 위 식으로 표현하면 아래와 같다.

[AA^T = U\Sigma V^T V\Sigma U^T = U(\Sigma \Sigma^T) U^T]

여기서 $\Sigma \Sigma^T$는 $\Lambda$이다. (직접 계산해보라) 이 때문에 결과적으로 식은 아래와 같이 정리된다.

[AA^T = U\Lambda U^T]

여기서 $U$는 정방행렬이기에 위에서 본 Spectral Decomposition의 식을 참조하면, $U$는 $AA^T$를 Eigenvalue Decomposition으로 직교대각화하여 얻은 직교행렬임을 알 수 있다. $A$의 rank가 k일 때, 이 $U$의 왼쪽에서부터 k번째 열벡터까지를 좌특이벡터(Left Singular Vectors)라고 부른다.

같은 방식으로 $A^TA = V\Lambda V^T$에서 $V$는 $A^TA$를 Eigenvalue Decomposition으로 직교대각화하여 얻은 직교행렬이 된다.

SVD를 기하학적으로 설명하면, $V^T, U$에 의해서 A 행렬의 방향이 변화하게 되고 $\Sigma$에 의해서 scale이 조정된다고 볼 수 있다.


4. 잠재 요인 협업 필터링의 Matrix Factorization

위에서 설명한 SVD는 잠재요인을 밝혀내기에 아주 적합한 방법이지만, 실제 현실에서 원행렬 A에는 결측값이(당연히 모든 사용자가 모든 아이템에 대해 평점을 남겼다면, 굳이 추천 시스템이 필요하지 않을 것이다.) 많다. 따라서 이를 대체할 근사적인 방법이 필요하며, 그 방법에는 SGD(Stochastic Gradient Descent) 또는 ALS(Alternating Least Squares)가 있다. 이 방법들에 대해서는 다음 글을 참조하기 바란다.

SGD를 이용해서 행렬을 분해하면 다음과 같다.

이 때 요인의 개수는 하이퍼파라미터로 임의로 조정하거나, Cross-Validation을 통해 최적의 값을 찾을 수 있다. 위에서 분해된 행렬을 다시 내적하여 원 행렬을 예측해보면 아래와 같이 크게 차이가 나지 않음을 알 수 있다.


5. 간단한 예제

위에서 봤던 행렬 분해를 코드로 구현해보자. 좀 더 자세한 설명을 원한다면 아래 Reference에 있는 “파이썬 머신러닝 완벽 가이드”를 찾아보길 바란다.

import numpy as np
from sklearn.metrics import mean_squared_error

R = np.array([[4, np.NaN, np.NaN, 2, np.NaN],
              [np.NaN, 5, np.NaN, 3, 1],
              [np.NaN, np.NaN, 3, 4, 4],
              [5, 2, 1, 2, np.NaN]])

# 실제 R 행렬과 예측 행렬의 오차를 구하는 함수
def calculate_rmse(R, P, Q, non_zeros):
    error = 0

    full_pred_matrix = np.dot(P, Q.T)

    # 여기서 non_zeros는 아래 함수에서 확인할 수 있다.
    x_non_zero_ind = [non_zeros[0] for non_zeros in non_zeros]
    y_non_zero_ind = [non_zeros[1] for non_zeros in non_zeros]

    # 원 행렬 R에서 0이 아닌 값들만 추출한다.
    R_non_zeros = R[x_non_zero_ind, y_non_zero_ind]

    # 예측 행렬에서 원 행렬 R에서 0이 아닌 위치의 값들만 추출하여 저장한다.
    full_pred_matrix_non_zeros = full_pred_matrix[x_non_zero_ind, y_non_zero_ind]

    mse = mean_squared_error(R_non_zeros, full_pred_matrix_non_zeros)
    rmse = np.sqrt(mse)

    return rmse


def matrix_factorization(R, K, steps=200, learning_rate=0.01, r_lambda=0.01):
    num_users, num_items = R.shape

    np.random.seed(1)
    P = np.random.normal(scale=1.0/K, size=(num_users, K))
    Q = np.random.normal(scale=1.0/K, size=(num_items, K))

    # R>0인 행 위치, 열 위치, 값을 non_zeros 리스트에 저장한다.
    non_zeros = [ (i, j, R[i, j]) for i in range(num_users)
                  for j in range(num_items) if R[i, j] > 0 ]

    # SGD 기법으로 P, Q 매트릭스를 업데이트 함
    for step in range(steps):
        for i, j, r in non_zeros:
            # 잔차 구함
            eij = r - np.dot(P[i, :], Q[j, :].T)

            # Regulation을 반영한 SGD 업데이터 적용
            P[i, :] = P[i, :] + learning_rate*(eij * Q[j, :] - r_lambda*P[i, :])
            Q[j, :] = Q[j, :] + learning_rate*(eij * P[i, :] - r_lambda*Q[j, :])

        rmse = get_rmse(R, P, Q, non_zeros)
        if step % 10 == 0:
            print("iter step: {0}, rmse: {1:4f}".format(step, rmse))

    return P, Q

P, Q = matrix_factorization(R, K=3)
pred_matrix = np.dot(P, Q.T)
print(pred_matrix)

[[3.99062329 0.89653623 1.30649077 2.00210666 1.66340846]
 [6.69571106 4.97792757 0.97850229 2.98066034 1.0028451 ]
 [6.67689303 0.39076095 2.98728588 3.9769208  3.98610743]
 [4.96790858 2.00517956 1.00634763 2.01691675 1.14044567]]

6. Surprise 모듈을 활용한 예제

Movielens 데이터를 이용하여 잠재 요인 협업 필터링을 간단히 시연해보도록 하겠다. 본 모듈은 추천 시스템에 널리 쓰이는 대표적인 알고리즘들을 패키지화한 것으로, 사이킷런의 API와 프레임워크와 굉장히 유사하다. 다만 엄격한 Input 체계를 갖추고 있는데, 반드시 사용자 ID, 아이템 ID, 평점만이 포함되어 있는 Row 레벨 형태의 데이터만 Input으로 받아들인다.

# Surprise 패키지: scikit-surprise
from surprise import SVD, Dataset, accuracy, Reader
from surprise.model_selection import train_test_split, GridSearchCV

data = Dataset.load_builtin('ml-100k')

위에서 쓴 load_builtin 메서드는 Movielens 홈페이지에 들를 필요 없이 해당 사이트의 데이터를 다운로드 받고 로드하는 메서드인데, 사실 앞으로 다른 데이터를 쓴다면 크게 쓸 일이 없다. Surprise 모듈은 데이터 로드를 위해 2개의 메서드를 추가적으로 제공한다.

# load_from_file: OS 파일 로딩
ratings = pd.read_csv('data/ratings.csv')
ratings.to_csv('data/ratings_noh.csv', index=False, header=False)

# line_format: 칼럼을 순서대로 나열함. 공백으로 분리
# rating_scale: 평점의 단위
reader = Reader(line_format='user item rating timestamp', sep=',',
                rating_scale=(0.5, 5))
data = Dataset.load_from_file('data/ratings_noh.csv', reader=reader)
# load_from_df: Pandas DataFrame 으로 로딩
ratings = pd.read_csv('data/ratings.csv')
reader = Reader(rating_scale=(0.5, 50))
data = Dataset.load_from_df(df=ratings[['userId', 'movieId', 'rating']], reader=reader)

이제 데이터셋을 훈련 데이터와 테스트 데이터로 분할한 뒤 적합을 해보자.

trainset, testset = train_test_split(data, test_size=0.25, random_state=0)

# 알고리즘 객체 생성
# SVD: n_factors(K), n_epochs(디폴트 20), biased=True(베이스라인 사용자 편향 적용 여부)
algo = SVD(n_factors=50, random_state=0)
algo.fit(trainset=trainset)

예측을 위해선 test 메서드와 predict 메서드가 제공되는데, 전자의 경우 테스트 데이터셋 전체에 대한 예측 값을, 후자의 경우 하나의 개체에 대한 예측 값을 출력한다. 따라서 predict의 결과를 모은 것이 test의 결과라고 보면 이해하기 쉽다.

predictions = algo.test(testset=testset)
predictions[0:3]

[Prediction(uid='120', iid='282', r_ui=4.0, est=3.66..., details={'was_impossible': False}),
 Prediction(uid='882', iid='291', r_ui=4.0, est=3.97..., details={'was_impossible': False}),
 Prediction(uid='535', iid='507', r_ui=5.0, est=4.15..., details={'was_impossible': False})]

# userID, itemID 는 string 으로 입력해야 함
uid = str(196)
iid = str(302)
pred = algo.predict(uid, iid)
print(pred)

user: 196        item: 302        r_ui = None   est = 4.30   {'was_impossible': False}

# 정확도 평가
accuracy.rmse(predictions=predictions, verbose=True)

Cross-Validation을 통해 파라미터를 조정할 수도 있다. 코드 구현은 아래와 같다.

#cross_validate(algo=algo, data=data, measures=['RMSE', 'MAE'], cv=5, verbose=True)

algo = SVD()
param_grid = {'n_epochs': [20, 40, 60], 'n_factors': [50, 100, 200]}
grid = GridSearchCV(SVD, param_grid, measures=['RMSE', 'MAE'], cv=3)
grid.fit(data=data)

print(grid.best_score['rmse'])
print(grid.best_params['rmse'])

좀 더 자세한 정보와 다양한 기능에 대해 알아보고 싶다면 아래 공식 문서를 참조하길 바란다.


Reference

파이썬 머신러닝 완벽 가이드, 권철민, 위키북스 카카오 리포트 Surprise 모듈 문서 SVD 설명

Comment  Read more

파이썬 numba 모듈 설명

|

1. Introduction

파이썬을 사용하다 보면 편리한 기능에 감탄하는 경우가 많지만 종종 속도에 대한 아쉬움을 느낄 때가 있다. 특히 머신러닝과 관련된 작업들을 하다 보면, 데이터 처리를 하는 과정에 있어서 좀 더 빠른 진행을 요구하는 경우가 많은데, 이를 위한 모듈 중 하나가 Numba이다.

공식문서를 확인해보면 아래와 같은 설명을 찾을 수 있다.

Numba makes Python code fast.
Numba is an open source JIT compiler that translates a subset of Python and NumPy code into fast machine code.

해석하면, 파이썬과 넘파이 코드를 빠르게 실행시켜주는 JIT 컴파일러라고 할 수 있겠다.

Numba의 작동원리는 다음과 같다.

  • 데커레이팅된 함수에 대한 파이썬 bytecode를 일ㄹ고 이를 함수의 입력 인수 유형에 대한 정보와 결합한다.
  • 코드를 분석하고 최적화한 후, LLVM compiler library를 사용하여 함수의 machine code 버전을 반들고, 이를 사용자의 CPU 능력에 맞춘다.
  • 이 compiled된 버전이 앞으로 그 함수를 호출할 때마다 사용된다.

Numba를 사용하기 위해서 새로운 언어를 배운다거나 할 필요는 전혀 없다. 역시 공식문서에서는 아래와 같이 밝히고 있다.

You don’t need to replace the Python interpreter, run a separate compilation step, or even have a C/C++ compiler installed.
Just apply one of the Numba decorators to your Python function, and Numba does the rest.

Numba 모듈이 모든 파이썬 코드를 최적화해주는 것은 아니다. 일부 파이썬 코드와 Numpy에 대해서만 작동하며 대다수의 다른 모듈을 이용한 코드를 최적화 시켜주지는 못한다. 예를 들어 Numba은 Pandas를 이해하지 못한다. 그럼에도 특정 목적에 따라 충분히 활용할 수 있는 가치가 있는 모듈이라고 할 수 있겠다.


2. 기본적인 사용법

2.1. 예시

예시를 살펴보면서 Numba의 효과를 확인해보도록 하겠다.

from time import perf_counter
from numba import jit

# 일반적인 loop
def pure_sum(n):
    result = 0
    for i in range(0, n+1):
        result += i
    return result

# Numba 모듈 사용
@jit(nopython=True, cache=True)
def numba_sum(n):
    result = 0
    for i in range(0, n+1):
        result += i
    return result

# 시간 재기: 일반
start = perf_counter()
pure_sum(100000000)
print(perf_counter() - start)

# 시간 재기에 앞서 먼저 Compile을 해준다.
numba_sum(1)

# 시간 재기: Numba
start = perf_counter()
numba_sum(100000000)
print(perf_counter() - start)

결과는 아래와 같다.

6.040823099999898      # 일반
4.590000003190653e-05  # Numba

pure 파이썬 코드보다 훨씬 빠르다는 것을 확인할 수 있다.

2.2. @jit 데커레이터의 모드

@jit 데커레이터는 nopythonobject라는 2가지 compilation 모드로 작동한다. 위 예제에서 nopython=True를 통해 Numba에게 nopython 모드로 작동하라고 지시한 셈인데, 이 모드는 decorate된 function을 근본적으로 compile하여 Python Interpreter의 개입 없이 전체가 작동하도록 한다.

만약 nopython 모드가 잘 작동하지 않을 경우, Numba은 object 모드를 통해 compile 할 수 있다. @jit(nopython=True)가 아닌 @jit이라고만 데커레이팅하면 이 모드가 작동하게 된다. 본 모드에서는 Numba은 loop를 식별하여 machine code에서 compile하며 나머지는 Intereter code에서 compile하게 된다. 더 나은 성능을 기대한다면 이 모드가 아닌 nopython 모드를 사용해야 한다.

2.3. 다른 Compilation 옵션들

2.3.1. nogil

Numba가 파이썬 코드를 native type과 변수에서만 작동하는 native code로 최적화하고 싶을 때, 파이썬의 GIL(Global Interpreter Lock)을 유지하는 것은 불필요하다.

@jit(nogil=True) 옵션을 사용하면 Numba는 GIL을 해제할 것이다.

2.3.2. cache

파이썬 프로그램을 호출할 때, 컴파일 시간을 피하기 위해 function의 결과를 파일 기반 cache에 쓰도록 Numba에 지시할 수 있다. 이를 실행하기 위해서는 @jit(cache=True) 옵션을 사용하면 된다.

2.3.3. parallel

parallel semantics를 가진 function에 대해 자동화된 병렬화를 제공한다. 반드시 nopython=True 모드에서만 실행되어야 하며 @jit(nopython=True, parallel=True)를 통해 사용할 수 있다.


Reference

공식문서

Comment  Read more

파이썬 logging Module 설명

|

1. logging Module 소개

1.1. Introduction

logging 모듈은 파이썬 자체에 내장되어 있는 모듈로 사용이 간편함에도 불구하고 훌륭한 기능으로 널리 사용되고 있다. logging은 소프트웨어가 작동 중일 때 발생하는 여러 ‘사건’을 추적하고, 개발자는 이를 통해 어떤 ‘사건’이 발생하였고 따라서 앞으로 어떤 해결책을 강구해야 할지 판단하게 된다. 이러한 ‘사건’들은 각각 중요도가 다를 것인데, 본 logging 모듈은 이 중요도를 level이라고 정의하고 있다. level에 대한 설명은 아래 1.2장에서 확인할 수 있다.

지금은 잠시 간단한 예시를 확인해 보자. 예를 들어 다음과 같은 메서드를 만들었다고 하자.

def cal(a, b):
    try:
        result = a/b
    except ZeroDivisionError:
        logger.exception("Division by zero is not possible")
    else:
        return result

당연하게도 b에 0을 대입하면 에러가 발생할 것이다. 개발 코드 중에 실수로 b에 0을 대입할 가능성이 있다고 하자. 그렇다면 언제 어떻게 에러가 발생하는지 기록으로 남겨두면 좋을 것이다. 그래야 디버깅이 편리하고 효율적으로 이루어질 수 있다.

실제로 에러가 발생하면 다음과 같은 형식으로 메시지가 뜰 것이다.

cal(2, 0)

2019-12-12 22:29:49,091 - root - ERROR - Division by zero is not possible
Traceback (most recent call last):
  File "<ipython-input-38-41356b58271d>", line 3, in cal
    result = a/b
ZeroDivisionError: division by zero

위에서 볼 수 있는 메시지의 형식과 내용 등은 모두 logging 모듈로 제어할 수 있다. 예를 들어 root의 경우 RootLogger을 의미하는데, 사용자가 직접 설정한 Logger 이름이 출력되게 할 수 있다. 이러한 기능은 수많은 파일과 class 등이 난무할 때 어디서 문제가 발생하였는지 쉽게 알 수 있게 해줄 것이다.

본 글은 우선적으로 logging 모듈의 가장 기본적인 기능들을 정리하는 데에 초점을 맞추었다. logger Module에 대해 더욱 자세히 알고 싶다면 아래 Reference에 있는 참고 사이트를 확인하길 바란다.

1.2. 작동 원리 확인

1) Level 설정
logging은 level 설정을 통해 메시지의 중요도를 구분한다. 총 5개의 기본 level이 제공되는데, 설정에 변화를 주지 않는다면 WARNING이 기본 level로 지정되어 있다.

Level 설명
DEBUG 간단히 문제를 진단하고 싶을 때 필요한 자세한 정보를 기록함
INFO 계획대로 작동하고 있음을 알리는 확인 메시지
WARNING 소프트웨어가 작동은 하고 있지만,

예상치 못한 일이 발생했거나 할 것으로 예측된다는 것을 알림
ERROR 중대한 문제로 인해 소프트웨어가 몇몇 기능들을 수행하지 못함을 알림
CRITICAL 작동이 불가능한 수준의 심각한 에러가 발생함을 알림

2) logging work flow 확인
본 모듈을 작동시키는 중요한 구성 요소들은 아래와 같다.

logger, handler, filter, formatter

Log 사건 정보들은 LogRecord Instance 안에 있는 위 요소들 사이에서 전송되는 것이다.

이들의 역할을 알아보면,

Logger: 어플리케이션 코드가 직접 사용할 수 있는 인터페이스를 제공함
Handler: logger에 의해 만들어진 log 기록들을 적합한 위치로 보냄
Filter: 어떤 log 기록들이 출력되어야 하는지를 결정함
Formatter: log 기록들의 최종 출력본의 레이아웃을 결정함

logging은 Logger class의 Instance (=logger)를 선언하는 것으로 부터 시작한다. 각 logger는 name을 가지는데, 이 name들은 마침표를 통해 계층적 관계를 형성하게 된다. 즉 예를 들어 Basket.html이라는 logger가 있다고 한다면, 이는 Basket이라는 logger가 html이라는 logger의 부모 역할을 하게 되는 것이다. 파이썬의 부모-자식 상속 관계를 투영한 것으로, 설정을 변화시키지 않으면 자식 logger는 부모 logger의 여러 특성들을 물려받게 된다.

이후 Handler를 통해 log 기록들을 어디에 표시하고, 어디에 기록할지 결정하게 된다. Filter는 logging 모듈을 간단히 사용할 때는 잘 쓰이지는 않지만 level보다 더 복잡한 필터링을 원할 때 사용된다.

Formatter는 실제로 출력되는 형식을 결정한다.

work flow에 대해 더욱 자세히 알고 싶다면 이곳을 참조하기 바란다.


2. 실질적인 사용법

2.1. 차례대로 logging 준비하기

2.1.1. logger 생성

logging instance인 logger는 아래와 같은 구문으로 생성한다.

logger = logging.getLogger("name")

“name” 에는 String이 들어가는데, 아무것도 입력하지 않을 경우 root logger가 생성된다.

root logger는 모든 logger의 부모와 같은 존재로, 다른 모든 logger는 설정을 변화시키지 않으면 root logger의 자식이다. root logger을 바로 사용할 수도 있지만, 기능과 목적에 따라 다른 logger들을 생성하는 것이 낫다.

2.1.2. logger에 level 부여하기

logger를 생성했다면, 이제는 기본적인 level을 부여해줄 차례다.

logger.setLevel(logging.INFO)

앞서 생성한 logger에 INFO level을 부여하였다. 이제 이 logger 객체는 INFO 이상의 메시지를 출력할 수 있다.
level을 소문자로 바꾸어 메서드로 사용하면 메시지를 출력할 수 있다.

logger.info("Message")

현재로서 이 logger는 오직 console에만 메시지를 출력할 수 있을 뿐이다. 더욱 정교하게 만들기 위해서는 handler가 필요하다.

2.1.3. handler와 formatter 설정하기

handler object는 log 메시지의 level에 따라 적절한 log 메시지를 지정된 위치에 전달(dispatch)하는 역할을 수행한다.

logger는 addHandler 메서드를 통해 이러한 handler를 추가할 수 있다. handler는 기능과 목적에 따라 여러 개일 수 있으며, 각 handler는 다른 level과 다른 format을 가질 수도 있다.

handler의 종류는 15개 정도가 있는데, 가장 기본적인 것은 StreamHandlerFileHandler이다. 전자는 Stream(console)에 메시지를 전달하고, 후자는 File(예를 들어 info.log)에 메시지를 전달하는 역할을 한다. 다른 handler가 궁금하다면 이곳을 참조하기 바란다.

handler 객체의 level까지 설정했다면, 이제 이 메시지를 어떤 형식으로 출력할지에 대해 고민해야 한다.
이 때 필요한 것이 formatter이다. 아래와 같이 생성한다. format을 좀 더 편리하게 작성하는 방법에 대해서는 3장에서 설명하겠다.

logging.Formatter(
  fmt = None,     # 메시지 출력 형태. None일 경우 raw 메시지를 출력.
  datefmt = None, # 날짜 출력 형태. None일 경우 '%Y-%m-%d %H:%M:%S'.
  style = '%'     # '%', '{', '$' 중 하나. `fmt`의 style을 결정.
)

이제 준비는 끝났다. handler 객체는 아래와 같이 만들어진다.

# handler 객체 생성
stream_handler = logging.StreamHandler()
file_handler = logging.FileHandler(filename="information.log")

# formatter 객체 생성
formatter = logging.Formatter(fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s")

# handler에 level 설정
stream_handler.setLevel(logging.INFO)
file_handler.setLevel(logging.DEBUG)

# handler에 format 설정
stream_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)

부가적으로 하나 더 설명하자면, 위에 있는 간단한 예시의 경우 단지 하나의 logger를 생성했을 뿐이지만, 실제로는 여러개의 logger를 계층적으로 사용할 가능성이 높다. 이러한 계층적인 구조와 관련하여, 앞의 1.2장에서 언급한 부모-자식 관계와 관련하여 염두에 두어야 할 부분이 있다.

자식 logger는 부모 logger와 관련된 handler로 메시지를 전파(propagate)한다. 즉, 부모 logger의 설정은 자식 logger과 연결되어 있다. 이 때문에 사실 모든 logger에 대해 handler를 일일히 정의하고 설정하는 것은 불필요한 일이라고 볼 수 있다. 따라서 가장 효율적인 방법은 최상위 logger에 대해 handler 설정을 완료하고 때에 따라 자식 logger를 생성하는 것이 될 것이다. 만약 이러한 연결을 원치 않는다면, 아래와 같이 logger의 propagate attribute를 False로 설정해주면 된다.

logger.propagate = False

2.1.4. logger에 생성한 handler 추가하기

logger.addHandler(stream_handler)
logger.addHandler(file_handler)

위 과정을 거치면, 지금까지 설정한 모든 것들이 logger에 담기게 된다.

2.2. 빠른 Setting

위에서 차근차근 알아본 logging 모듈 사용법을 확실히 익혔다면, 기본적인 Setting 환경을 만들어두고 이를 조금씩 변형하여 사용하는 것이 편리할 것이다.

Setting을 진행하는 방법에는 여러가지가 있는데, 본 글에서는 그 중 1) json 파일로 setting하는 법과 2) 파이썬 코드로 하는 법에 대해 설명할 것이다. 본 예제에서는 INFO를 기본 level로 설정한다.

2.2.1. json 파일로 세팅

아래와 같은 json 파일을 만들어보자.

{
    "version": 1,
    "formatters": {
        "basic": {
            "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
        }
    },

    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "basic",
            "stream": "ext://sys.stdout"
        },

        "file_handler": {
            "class": "logging.FileHandler",
            "level": "DEBUG",
            "formatter": "basic",
            "filename": "info.log"
        }
    },

    "root": {
        "level": "INFO",
        "handlers": ["console", "file_handler"]
    }
}

첫 문단에서 basic이라는 이름의 format을 만들었다. 앞으로 설정을 변경하지 않는 이상 [시간-logger이름-level이름-메시지] 형식으로 출력됨을 의미한다.

두 번째 문단과 세 번째 문단은 2개의 handler에 대한 설정이다. console은 말 그대로 console(Stream)에 출력되는 handler로, logging.StreamHandler class로 구성되며 위에서 설정한 basic format을 사용함을 알 수 있다. 이 handler의 level은 INFO이다. file_handler는 디렉토리 내에 info.log란 파일을 생성하여 로그를 기록하면서 저장하는 handler이다. 이 handler의 level은 DEBUG이다.

마지막 문단에서는 root logger에 대한 설정을 마무리하고 있다.

이제 json 파일을 읽어오자.

with open("logging.json", "rt") as file:
    config = json.load(file)

logging.config.dictConfig(config)
logger = logging.getLogger()

2.2.2. 코드로 세팅

사실 코드로 세팅한다는 것은 위에 있는 정보들을 코드로 입력한다는 것에 불과하다. 위에서 자세히 설명한 것을 다시 한 번 확인하는 수준이라고 생각하면 될 것이다. 특별할 것이 없으므로 바로 확인해보자.

def make_logger(name=None):
    #1 logger instance를 만든다.
    logger = logging.getLogger(name)

    #2 logger의 level을 가장 낮은 수준인 DEBUG로 설정해둔다.
    logger.setLevel(logging.DEBUG)

    #3 formatter 지정
    formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
    
    #4 handler instance 생성
    console = logging.StreamHandler()
    file_handler = logging.FileHandler(filename="test.log")
    
    #5 handler 별로 다른 level 설정
    console.setLevel(logging.INFO)
    file_handler.setLevel(logging.DEBUG)

    #6 handler 출력 format 지정
    console.setFormatter(formatter)
    file_handler.setFormatter(formatter)

    #7 logger에 handler 추가
    logger.addHandler(console)
    logger.addHandler(file_handler)

    return logger

#2 과정에서 만일 가장 낮은 수준으로 level을 설정하지 않는다면, 아래 handler들에서 setLevel을 한 것이 무의미해진다는 점을 꼭 알아두길 바란다. (handler 별로 다른 level 설정하기)

위 코드를 보면 console에 표기되는 StreamHandler에는 INFO level을, 파일에 기록되는 FileHandler에는 DEBUG level을 설정한 것을 확인할 수 있다.

logger = make_logger()

logger.debug("test")
logger.info("test")
logger.warning("test")

위와 같은 코드를 입력하면, console 창에는 아래와 같이 기록되지만,

2019-12-13 14:50:40,133 - root - INFO - test
2019-12-13 14:50:40,679 - root - WARNING - test

test.log file에는 아래와 같이 기록됨을 확인할 수 있다.


3. Format 편리하게 설정하기

바로 위의 log 기록들은 사실 아주 도움이 되는 정보들이라고 하기는 어렵다. line 번호도 없고, file 이름도 없다. logging 모듈은 이러한 log 기록들을 남길 때 굉장히 다양한 형식을 지원하고 있다. 그 형식에 대해 알아보기 전에 먼저 log 기록들, 즉 LogRecord Objects에 대해 알아보도록 하자.

LogRecord 객체는 Logger에 의해 자동적으로 생성되며, 수동으로 생성하려면 makeLogRecord 메서드를 이용하면 된다.

logging.LogRecord(name, level, pathname, lineno, msg, …)

여기서 pathname은 logging call이 만들어지는 소스 파일의 전체 pathname을 의미한다.
lineno는 logging call이 만들어지는 소스파일의 라인 번호를 말한다.
msg는 event description 메시지를 의미한다.

이 LogRecord는 여러 속성(attribute)을 갖고 있는데, 이 속성들은 format을 정의하는데 활용된다.
그 리스트와 설명은 아래와 같다.

속성 이름 format 설명
asctime %(asctime)s 인간이 읽을 수 있는 시간 표시
created %(created)f logRecord가 만들어진 시간
filename %(filename)s pathname의 file 이름 부분
funcName %(funcName)s logging call을 포함하는 function의 이름
levelname %(levelname)s 메시지의 Text logging level: 예) INFO
lineno %(lineno)d logging call이 발생한 코드의 line 숫자
module %(module)s filename의 모듈 이름 부분
message %(message)s 메시지
name %(name)s logger의 이름
pathname %(pathname)s full pathname
thread %(thread)d thread ID
threadName %(threadName)s thread 이름

간단한 예는 아래와 같다.

LOG_FORMAT = "[%(asctime)-10s] (줄 번호: %(lineno)d) %(name)s:%(levelname)s - %(message)s"
logging.basicConfig(format=LOG_FORMAT)
logger = logging.getLogger("setting")
logger.setLevel(20)
logger.info("sth happened")

[2019-12-16 13:53:29,889] ( 번호: 6) setting:INFO - sth happened

Reference

공식문서 https://hamait.tistory.com/880 https://www.machinelearningplus.com/python/python-logging-guide/ https://snowdeer.github.io/python/2017/11/17/python-logging-example/

Comment  Read more

Light GBM 설명 및 사용법

|

1. Light GBM: A Highly Efficient Gradient Boosting Decision Tree 논문 리뷰

1.1. Background and Introduction

다중 분류, 클릭 예측, 순위 학습 등에 주로 사용되는 Gradient Boosting Decision Tree (GBDT)는 굉장히 유용한 머신러닝 알고리즘이며, XGBoost나 pGBRT 등 효율적인 기법의 설계를 가능하게 하였다. 이러한 구현은 많은 엔지니어링 최적화를 이룩하였지만 고차원이고 큰 데이터 셋에서는 만족스러운 결과를 내지 못하는 경우도 있었다. 왜냐하면 모든 가능한 분할점에 대해 정보 획득을 평가하기 위해 데이터 개체 전부를 스캔해야 했기 때문이다. 이는 당연하게도, 굉장히 시간 소모적이다.

본 논문은 이 문제를 해결하기 위해 2가지 최신 기술을 도입하였다.
첫 번째는 GOSS: Gradient-based One-Side Sampling이며, 기울기가 큰 데이터 개체가 정보 획득에 있어 더욱 큰 역할을 한다는 아이디어에 입각해 만들어진 테크닉이다. 큰 기울기를 갖는 개체들은 유지되며, 작은 기울기를 갖는 데이터 개체들은 일정 확률에 의해 랜덤하게 제거된다.

두 번째는 EFB: Exclusive Feature Bundling으로, 변수 개수를 줄이기 위해 상호배타적인 변수들을 묶는 기법이다. 원핫 인코딩된 변수와 같이 희소한(Sparse) 변수 공간에서는 많은 변수들이 상호 배타적인 경우가 많다. (0이 굉장히 많기 때문에) 본 테크닉은, 최적 묶음 문제를 그래프 색칠 문제로 치환하고 일정 근사 비율을 갖는 Greedy 알고리즘으로 이 문제를 해결한다.

1.2. Preliminaries

GBDT는 Decision Tree의 앙상블 모델이다. 각각의 반복에서 GBDT는 음의 기울기(잔차 오차)를 적합함으로써 Decision Tree를 학습시킨다. 이 학습 과정에서 가장 시간이 많이 소모되는 과정이 바로 최적의 분할점들을 찾는 것인데, 이를 위한 대표적인 방법에는 Pre-sorted(사전 정렬) 알고리즘Histogram-based 알고리즘이 있다.

Pre-sorted 알고리즘의 경우 사전 정렬한 변수 값에 대해 가능한 모든 분할점을 나열함으로써 간단하게 최적의 분할점을 찾을 수 있지만, 효율적이지 못하다는 단점이 있다. Histogram-based 알고리즘은 연속적인 변수 값을 이산적인 구간(bin)으로 나누고, 이 구간을 사용하여 학습과정 속에서 피쳐 히스토그램을 구성한다.

학습 데이터의 양을 줄이기 위해 가장 쉽게 생각할 수 있는 방법은 Down Sampling이 될 것이다. 이는 만약 데이터 개체의 중요도(Weight)가 설정한 임계값을 넘지 못할 경우 데이터 개체들이 필터링되는 과정을 말한다. SGB의 경우 약한 학습기를 학습시킬 때 무작위 부분집합을 사용하지만, SGB를 제외한 Down Sampling 방식은 AdaBoost에 기반하였기 때문에 바로 GBDT에 적용시킬 수 없다. 왜냐하면 AdaBoost와 달리 GBDT에는 데이터 개체에 기본 가중치가 존재하지 않기 대문이다.

비슷한 방식으로 피쳐 수를 줄이기 위해서는, 약한(Weak) 피쳐를 필터링하는 것이 자연스러울 것이다. 그러나 이러한 접근법은 변수들 사이에 중대한 중복요소가 있을 것이라는 가정에 의존하는데, 실제로는 이 가정이 옳지 않을 수도 있다.

실제 상황에서 사용되는 대용량 데이터셋은 많은 경우에 희소한(Sparse) 데이터셋일 확률이 높다. Pre-sorted 알고리즘에 기반한 GBDT의 경우 0값을 무시함으로써 학습 비용을 절감할 수 있지만, Histogram-based 알고리즘에 기반한 GBDT에는 효율적인 희소값 최적화 방법이 없다. 그 이유는 Histogram-based 알고리즘은 피쳐 값이 0이든 1이든, 각 데이터 개체마다 피쳐 구간(Bin) 값을 추출해야하기 때문이다. 따라서 Histogram-based 알고리즘에 기반한 GBDT가 희소 변수를 효과적으로 활용할 방안이 요구된다. 이를 해결하기 위한 방법이 바로 앞서 소개한 GOSSEFB인 것이다. GOSS는 데이터 개체 수를 줄이고, EFB는 피쳐 수를 줄이는 방법론이다.

1.3. GOSS: Gradient-based One-Sided Sampling

AdaBoost에서 Sample Weight는 데이터 개체의 중요도를 알려주는 역할을 수행하였다. GBDT에서는 기울기(Gradient)가 이 역할을 수행한다. 각 데이터 개체의 기울기가 작으면 훈련 오차가 작다는 것을 의미하므로, 이는 학습이 잘 되었다는 뜻이다. 이후 이 데이터를 그냥 제거한다면 데이터의 분포가 변화할 것이므로, 다른 접근법(GOSS)이 필요하다.

GOSS의 아이디어는 직관적이다. 큰 Gradient(훈련이 잘 안된)를 갖는 데이터 개체들은 모두 남겨두고, 작은 Gradient를 갖는 데이터 개체들에서는 무작위 샘플링을 진행하는 것이다. 이를 좀 더 상세히 설명하자면 아래와 같다.

1) 데이터 개체들의 Gradient의 절대값에 따라 데이터 개체들을 정렬함
2) 상위 100a% 개의 개체를 추출함
3) 나머지 개체들 집합에서 100b% 개의 개체를 무작위로 추출함
4) 정보 획득을 계산할 때, 위의 2-3 과정을 통해 추출된 Sampled Data를 상수( $ \frac{1-a} {b} $ )를 이용하여 증폭시킴

위 그림에 대하여 추가적으로 부연설명을 하면,
topN, randN은 2, 3 과정에서 뽑는 개수를 의미하며,
topSet, randSet 은 2, 3 과정에서 뽑힌 데이터 개체 집합을 의미한다.
w[randSet] x= fact은 증폭 벡터를 구성하는 과정으로, 증폭 벡터는 randSet에 해당하는 원소는 fact 값을 가지고, 나머지 원소는 1의 값을 가지는 벡터이다.

마지막으로 L: Weak Learner에 저장된 정보는, 훈련데이터, Loss, 증폭된 w벡터로 정리할 수 있겠다.

1.4. EFB: Exclusive Feature Bundling

희소한 변수 공간의 특성에 따라 배타적인 변수들을 하나의 변수로 묶을 수 있다. 그리고 이를 배타적 변수 묶음(Exclusive Feature Bundle)이라고 부른다. 정교하게 디자인된 변수 탐색 알고리즘을 통해, 각각의 변수들로 했던 것과 마찬가지로 변수 묶음들로부터도 동일한 변수 히스토그램들을 생성할 수 있게 된다.

이제 1) 어떤 변수들이 함께 묶여야 하는지 정해야 하며, 2) 어떻게 묶음을 구성할 것인가에 대해 알아볼 것이다.

정리: 변수들을 가장 적은 수의 배타적 묶음으로 나누는 문제는 NP-hard이다.
(NP-hard의 뜻을 알아보기 위해서는 이곳을 참조하길 바란다.)

증명: 그래프 색칠 문제를 본 논문의 문제로 환원한다. 그래프 색칠 문제는 NP-hard이므로 우리는 결론은 추론 가능하다.

$ G = (V, E) $ 라는 임의의 그래프가 있다고 하자. 이 G의 발생 행렬(Incidence Matrix)의 들이 우리 문제의 변수에 해당한다. 위 정리에서 최적의 묶음 전략을 찾는 것은 NP-hard라고 하였는데, 이는 다항 시간 안에 정확한 해를 구하는 것이 불가능하다는 의미이다. 따라서 좋은 근사 알고리즘을 찾기 위해서는 최적 묶음 문제를 그래프 색칠 문제로 치환해야 한다. 이 치환은 변수(feature)들을 꼭짓점(vertices)으로 간주하고 만약 두 변수가 상호배타적일 경우 그들 사이에 변(edge)을 추가하는 방식으로 이루어진다. 이후 Greedy 알고리즘을 사용한다.

1)에 관한 알고리즘을 설명하자면 다음과 같다.

  • 각 변마다 가중치가 있는 그래프를 구성하는데, 여기서 가중치는 변수들간의 충돌(conflicts)을 의미한다. 여기서 충돌이란 non-zero value가 동시에 존재하여 상호배타적이지 않은 상황을 의미한다.
  • 그래프 내에 있는 꼭짓점 차수에 따라 내림차순으로 변수들을 정렬한다.
  • 정렬한 리스트에 있는 각 변수를 확인하면서 이들을 작은 충돌(γ로 제어함)이 있는 기존 묶음에 할당하거나, 새로운 묶음을 만든다.

이 알고리즘의 시간 복잡도는 변수들의 개수의 제곱에 해당하며, 이는 나름 괜찮은 수준이지만 만약 변수들의 수가 매우 많다면 개선이 필요하다고 판단된다. 따라서 본 논문은 그래프를 직접 구성하지 않고 0이 아닌 값의 개수에 따라 정렬하는 방식(0이 아닌 값이 많을 수록 충돌을 일으킬 확률이 높으므로)으로 알고리즘을 수정하였다.

2)에 관해서 이야기하자면, 가장 중요한 것은 변수 묶음들로부터 원래(original) 변수들의 값을 식별할 수 있어야 한다는 것이다. Histogram-based 알고리즘은 변수의 연속적인 값 대신 이산적인 구간(bin)을 저장하므로, 배타적 변수들을 각각 다른 구간에 두어 변수 묶음을 구성할 수 있다. 이는 변수의 원래 값에 offset을 더하는 것으로 이루어 질 수 있다.

예를 들어 변수 묶음에 변수 2개가 속한다고 할 때,
원래 변수 A는 [0, 10)의 값을 취하고, 원래 변수 B는 [0, 20)의 값을 취한다.
이대로 두면 [0, 10) 범위 내에서 두 변수는 겹칠 것이므로,
변수 B에 offset 10을 더하여 가공한 변수가 [10, 30)의 값을 취하게 한다.
이후 A, B를 병합하고 [0, 30] 범위의 변수 묶음을 사용하여 기존의 변수 A, B를 대체한다.


2. Light GBM 적용

본 글에서는 Kaggle-Santander 데이터를 이용하여 간단한 적용 예시를 보이도록 하겠다. 초기에 lightgbm은 독자적인 모듈로 설계되었으나 편의를 위해 scikit-learn wrapper로 호환이 가능하게 추가로 설계되었다. 본 글에서는 scikit-learn wrapper Light GBM을 기준으로 설명할 것이다.

# Santander Data
   ID  var3  var15   ...    saldo_medio_var44_ult3     var38  TARGET
0   1     2     23   ...                       0.0  39205.17       0
1   3     2     34   ...                       0.0  49278.03       0
2   4     2     23   ...                       0.0  67333.77       0
[3 rows x 371 columns]

n_estimators 파라미터는 반복 수행하는 트리의 개수를 의미한다. 너무 크게 지정하면 학습 시간이 오래 걸리고 과적합이 발생할 수 있으니, 파라미터 튜닝 시에는 크지 않은 숫자로 지정하는 것이 좋다. num_leaves 파라미터는 하나의 트리가 가질 수 있는 최대 리프의 개수인데, 이 개수를 높이면 정확도는 높아지지만 트리의 깊이가 커져 모델의 복잡도가 증가한다는 점에 유의해야 한다.

먼저 기본적인 모델을 불러온다.

from lightgbm import LGBMClassifier
lgbm = LGBMClassifier(n_estimators=200)

공식문서을 참조하면 아래와 같은 몇몇 주의사항을 볼 수 있다.

Light GBM은 leaf-wise 방식을 취하고 있기 때문에 수렴이 굉장히 빠르지만, 파라미터 조정에 실패할 경우 과적합을 초래할 수 있다.

max_depth 파라미터는 트리의 최대 깊이를 의미하는데, 위에서 설명한 num_leaves 파라미터와 중요한 관계를 지닌다. 과적합을 방지하기 위해 num_leaves는 2^(max_depth)보다 작아야 한다. 예를 들어 max_depth가 7이기 때문에, 2^(max_depth)=98이 되는데, 이 때 num_leaves를 이보다 작은 70~80 정도로 설정하는 것이 낫다.

min_child_samples 파라미터는 최종 결정 클래스인 Leaf Node가 되기 위해서 최소한으로 필요한 데이터 개체의 수를 의미하며, 과적합을 제어하는 파라미터이다. 이 파라미터의 최적값은 훈련 데이터의 개수와 num_leaves에 의해 결정된다. 너무 큰 숫자로 설정하면 under-fitting이 일어날 수 있으며, 아주 큰 데이터셋이라면 적어도 수백~수천 정도로 가정하는 것이 편리하다.

sub_sample 파라미터는 과적합을 제어하기 위해 데이터를 샘플링하는 비율을 의미한다.

지금까지 설명한 num_leaves, max_depth, min_child_samples, sub_sample 파라미터가 Light GBM 파라미터 튜닝에 있어서 가장 중요한 파라미터들이다. 이들은 하나씩 튜닝할 수도 있고, 한 번에 튜닝할 수도 있다. 학습 데이터의 성격과 여유 시간에 따라 선택해야 한다. 이들에 대한 최적값을 어느 정도 확보했다면, 다음 단계로 넘어가도 좋다.

colsample_bytree 파라미터는 개별 트리를 학습할 때마다 무작위로 선택하는 피쳐의 비율을 제어한다. reg_alpha는 L1 규제를, reg_lambda는 L2 규제를 의미한다. 이들은 과적합을 제어하기에 좋은 옵션들이다.

learning_rate은 후반부에 건드리는 것이 좋은데, 초반부터 너무 작은 학습률을 지정하면 효율이 크게 떨어질 수 있기 때문이다. 정교한 결과를 위해, 마지막 순간에 더욱 좋은 결과를 도출하기 위해 영혼까지 끌어모으고 싶다면, learning_rate는 낮추고 num_estimators는 크게 하여 최상의 결과를 내보도록 하자.

다음은 위에서 처음 소개한 Santander Data를 바탕으로 한 예시이다.

params = {'max_depth': [10, 15, 20],
          'min_child_samples': [20, 40, 60],
          'subsample': [0.8, 1]}

grid = GridSearchCV(lgbm, param_grid=params)
grid.fit(X_train, Y_train, early_stopping_rounds=100, eval_metric='auc',
         eval_set=[(X_train, Y_train), (X_val, Y_val)])

print("최적 파라미터: ", grid.best_params_)
lgbm_roc_score = roc_auc_score(Y_test, grid.predict_proba(X_test)[:, 1], average='macro')
print("ROC AUC: {0:.4f}".format(lgbm_roc_score))

# 위 결과를 적용하여 재학습
lgbm = LGBMClassifier(n_estimators=1000, num_leaves=50, subsample=0.8,
                      min_child_samples=60, max_depth=20)

evals = [(X_test, Y_test)]
lgbm.fit(X_train, Y_train, early_stopping_rounds=100, eval_metric='auc',
         eval_set=evals, verbose=True)

score = roc_auc_score(Y_test, grid.predict_proba(X_test)[:, 1], average='macro')
print("ROC AUC: {0:.4f}".format(score))

Reference

LightGBM 공식 문서
논문 파이썬 머신러닝 완벽 가이드, 권철민, 위키북스

Comment  Read more