XLNet은 구글 연구팀(Yang et al., 2019)이 발표한 기법으로 공개 당시 20개 자연어 처리 데이터셋에서 최고 성능을 기록한 아키텍처입니다. 일부 데이터에 한해서는 기존 강자인 BERT를 크게 앞서 자연어 처리 연구자들의 주목을 받았습니다. XLNet은 트랜스포머 네트워크(Vaswani et al., 2017)를 개선한 ‘트랜스포머-XL(Dai et al., 2019)’의 확장판 성격의 모델입니다. 여기에서 XL이란 ‘eXtra-Long’으로, 기존 트랜스포머보다 좀 더 넓은 범위의 문맥(context)를 볼 수 있다는 점을 강조하는 의미로 쓰였습니다.
퍼뮤테이션 언어모델 (Permutaion Language Model)
Yang et al. (2019)는 임베딩 모델의 최근 흐름을 크게 두 가지로 나누어 정리했습니다. 하나는 오토리그레시브(AutoRegressive, AR) 모델이고 다른 하나는 오토인코딩(AutoEncoding, AE) 모델입니다. AR 모델은 데이터를 순차적으로 처리하는 기법의 총칭을 뜻한다. 이 관점에서 보면 ELMo나 GPT를 AR 범주로 분류할 수 있겠습니다. 두 모델 모두 이전 문맥을 바탕으로 다음 단어를 예측하는 과정에서 학습하기 때문입니다. 예컨대 ‘발 없는 말이 천리 간다’는 문장을 학습하는 경우 AE 모델은 그림 1처럼 단어를 순차적으로 읽어 나갑니다.
그림 1. 오토리그레시브(AR) 모델
AE 모델은 입력값을 복원하는 기법들을 두루 일컫습니다. 다시 말해 y=f(x)=x
를 지향합니다. BERT가 대표적인 AE 모델입니다. BERT는 문장 일부에 노이즈(마스킹)를 주어서, 문장을 원래대로 복원하는 과정에서 학습합니다. 다시 말해 마스킹 처리가 된 단어가 실제로 어떤 단어일지 맞추는 데 주안점을 둔다는 이야기입니다. Yang et al. (2019)는 이런 맥락에서 BERT를 디노이징 오토인코더(Denoising Autoencoder)라고 표현하기도 했습니다. 디노이징 오토인코더란 노이즈가 포함된 입력을 받아 해당 노이즈를 제거한 원본 입력을 출력하는 모델입니다. AR 모델은 그림 2처럼 학습합니다.
그림 2. 오토인코딩(AE) 모델
Yang et al. (2019)는 기존 AE, AR 모델 모두 문제가 있다고 주장했습니다. AR의 경우 문맥을 양방향(bidirectional)으로 볼 수 없는 태생적 한계를 지닙니다. 이전 단어를 입력으로 하고 다음 단어를 출력으로 하는 언어모델을 학습할 때, 맞춰야 할 단어를 포함한 이후 문맥 정보를 미리 알려줄 수는 없기 때문입니다. 물론 ELMo의 경우 모델의 최종 출력값을 만들 때 마지막 레이어에서 순방향(forward), 역방향(backward) LSTM 레이어의 계산 결과를 모두 사용하기는 합니다. 그러나 프리트레인을 할 때 순방향, 역방향 레이어를 각각 독립적으로 학습하기 때문에 ELMo를 진정한 의미의 양방향 모델이라고 말하기는 어렵습니다.
BERT로 대표되는 AE는 양방향 모델입니다. 그림 2처럼 마스크 단어를 예측할 때 앞뒤 문맥을 모두 살피기 때문입니다. 이 덕분에 각종 다운스트림 태스크에서 BERT가 상당 기간 절대 강자로 군림할 수 있었습니다. 하지만 AE 역시 단점이 없지 않습니다. 가장 큰 문제는 마스킹 처리한 토큰들을 서로 독립(independent)이라고 가정한다는 점입니다. 이 경우 마스킹 토큰들 사이에 의존 관계(dependency)를 따질 수 없게 됩니다.
그림 3은 Yang et al. (2019)이 든 예시 문장(New York is a city
)을 가지고 BERT의 학습 과정을 시각화한 것입니다. 영어 말뭉치에서 “New가 나온 다음에 York라는 단어가 나올 확률”과 “New가 나오지 않았을 경우에 York가 등장할 확률”은 분명히 다를 것입니다. 하지만 BERT 모델은 두 단어의 선후 관계나 등장 여부 등 정보를 전혀 따지지 않습니다. 그저 is a city
라는 문맥만 보고 New, York 각각의 마스크 토큰을 독립적으로 예측합니다.
그림 3. BERT 모델의 학습
더구나 BERT가 프리트레인할 때 사용하는 마스크 토큰은 파인 튜닝 과정에서는 쓰지 않습니다. 파인튜닝과 다른 프리트레인 환경을 구성하면 모델의 일반화(generalization) 성능이 떨어질 수 있습니다. 마지막으로 Yang et al. (2019)는 BERT가 긴 문맥을 학습하기 어렵다는 점도 단점으로 꼽았습니다.
Yang et al. (2019)는 AR과 AE 모델의 한계를 극복하기 위해 퍼뮤테이션 언어모델(permutaion language model)을 제안했습다. 토큰을 랜덤으로 셔플한 뒤 그 뒤바뀐 순서가 마치 원래 그랬던 것인 양 언어모델을 학습하는 기법이다. 그림 4는 발 없는 말이 천리 간다
를 퍼뮤테이션 언어모델로 학습하는 예시입니다. 그림 4를 보면 모델은 ‘없는, 이, 말, 발, 간다’를 입력받아 시퀀스의 마지막 단어인 ‘천리’를 예측한다.
그림 4. 퍼뮤테이션 언어모델 (1)
그림 4처럼 퍼뮤테이션을 수행하면 특정 토큰을 예측할 때 문장 전체 문맥을 살필 수 있게 됩니다. 해당 토큰을 제외한 문장의 부분집합 전부를 학습할 수 있다는 뜻입니다. 예컨대 발 없는 말이 천리 간다
는 문장을 한번 더 퍼뮤테이션해서 이번엔 발, 없는, 천리, 이, 말, 간다
가 나왔다고 가정해 봅시다. 그러면 ‘천리’라는 단어를 예측할 때의 입력 시퀀스는 발, 없는
이 됩니다.
그림 5는 천리
라는 단어를 맞추기 위해 상상해볼 수 있는 입력 시퀀스의 모음입니다. 그림 5에서 확인할 수 있는 것처럼 순방향 언어모델(발 없는 말
다음에 천리
를 맞춤), 역방향 언어모델(간다
다음에 천리
를 예측)은 퍼뮤테이션 언어모델의 부분 집합이 됩니다. 다시 말해 퍼뮤테이션 언어모델은 시퀀스를 순차적으로 학습하는 AR 모델이지만 퍼뮤테이션을 수행한 토큰 시퀀스 집합을 학습하는 과정에서 문장의 양방향 문맥을 모두 고려할 수 있게 된다는 이야기입니다.
그림 5. 퍼뮤테이션 언어모델 (2)
발, 없는, 말, 이
간다
없는, 간다
...
Yang et al. (2019)는 퍼뮤테이션 언어모델은 AR이기 때문에 BERT 같은 AE 모델의 단점 또한 극복할 수 있다고 설명합니다. 그림 6을 보면 퍼뮤테이션 언어모델은 셔플된 토큰 시퀀스를 순차적으로 읽어가면서 다음 단어를 예측합니다. 이전 문맥(New
)을 이번 예측(York
)에 활용합니다. 따라서 퍼뮤테이션 언어모델은 예측 단어(마스킹 토큰) 사이에 독립을 가정하는 BERT와 달리 단어 간 의존관계를 포착하기에 유리합니다. 뿐만 아니라 프리트레인 때 마스크를 하지 않기 때문에 프리트레인-파인튜닝 간 불일치 문제도 해결할 수 있습니다.
그림 6. 퍼뮤테이션 언어모델 (3)
퍼뮤테이션 언어모델의 학습 과정을 좀 더 구체적으로 살펴봅시다. 토큰 네 개짜리 문장을 랜덤으로 뒤섞은 결과가 그림 7처럼 $[3, 2, 4, 1]$이고 셔플된 시퀀스의 첫번째 단어(3번 토큰)를 맞춰야 하는 상황이라고 가정해 봅시다. 그러면 이 때 3번 토큰 정보를 넣어서는 안 됩니다. 3번 토큰을 맞춰야 하는데 모델에 3번 토큰 정보를 주면 문제가 너무 쉬워지기 때문입니다. 2번, 4번, 1번 토큰은 맞출 토큰(3번) 이후에 등장한 단어들이므로 이들 또한 입력에서 제외합니다. 결과적으로 이 상황에서 입력값은 이전 세그먼트(segment)의 메모리(memory) 정보뿐입니다. 메모리와 관련해서는 트랜스포머-XL(transformer-XL)에서 설명합니다.
그림 7. 퍼뮤테이션 언어모델 학습 (Yang et al., 2019)
같은 문장을 또한번 셔플했더니 $[2, 4, 3, 1]$이고 이번 스텝 역시 3번 토큰을 예측해야 한다고 가정합시다. 그러면 3번 토큰 이전 문맥(메모리, 2번, 4번 단어)이 입력됩니다. 3번 토큰은 정답이므로 입력에서 제외합니다. 그림 8과 같습니다.
그림 8. 퍼뮤테이션 언어모델 학습 (Yang et al., 2019)
셔플 시퀀스가 $[1, 4, 2, 3]$이고 3번 토큰을 맞춰야 한다면 입력벡터는 과거 문맥(메모리, 1번, 4번, 2번 단어)이 됩니다. 마찬가지로 $[4, 3, 1, 2]$이고 3번을 예측한다면 입력값은 메모리, 4번 단어가 됩니다. 각각 그림 9, 그림 10과 같습니다.
그림 9. 퍼뮤테이션 언어모델 학습 (Yang et al., 2019)
그림 10. 퍼뮤테이션 언어모델 학습 (Yang et al., 2019)
퍼뮤테이션 언어모델의 실제 구현은 토큰을 뒤섞는 게 아니라 어텐션 마스크(mask)로 실현됩니다. XLNet의 근간은 기존 트랜스포머 네트워크(Vaswani et al., 2017)이고, 그 핵심은 쿼리(query), 키(key) 벡터 간 셀프 어텐션(self-attention) 기법이기 때문입니다. 예컨대 토큰 네 개짜리 문장을 단어 등장 순서대로 예측해야 하는 상황을 가정해 봅시다. 이 경우 어텐션 마스크는 그림 11처럼 구축하면 됩니다.
그림 11 좌측 행렬은 셀프 어텐션을 수행할 때 소프트맥스 확률값에 적용하는 마스크 행렬입니다. 여기서 마스크란 소프트맥스 확률값을 0으로 무시하게끔 하는 역할을 한다는 뜻입니다. 소프트맥스 확률값이 0이 되면 해당 단어의 정보는 셀프 어텐션에 포함되지 않습니다. 그림 43에서 회색 동그라미는 확률값을 0으로 만드는 마스크라는 뜻이며, 붉은색 동그라미는 확률값을 살리는 의미를 지닙니다.
그림을 읽는 방법은 이렇습니다. 마스크 행렬의 행은 쿼리 단어, 열은 키 단어에 각각 대응합니다. 그림 11처럼 토큰 순서대로 예측해야 하는 경우 1번 단어를 예측할 때는 자기 자신(1번 단어)을 포함해 어떤 정보도 사용할 수 없습니다. 2번 단어를 맞춰야할 때는 이전 문맥인 1번 단어 정보를 활용합니다. 마찬가지로 3번 단어는 1, 2번 단어, 4번 단어는 1, 2, 3번 단어 정보를 쓰게끔 만듭니다. GPT가 그림 11과 같은 방식으로 학습합니다.
그림 11. 원래 시퀀스의 어텐션 마스크
그림 12는 퍼뮤테이션 언어모델이 사용하는 어텐션 마스크의 예시입니다. 셔플된 토큰 시퀀스가 $[3, 2, 4, 1]$이라고 가정해 봅시다. 그러면 3번 단어를 맞춰야할 때는 어떤 정보도 사용할 수 없습니다. 2번 단어를 예측할 때는 이전 문맥인 3번 단어 정보를 씁니다. 마찬가지로 4번 단어를 맞출 때는 3번, 2번 단어를, 1번 단어를 예측할 때는 3번, 2번, 4번 단어 정보를 입력합니다.
그림 12. 셔플된 시퀀스의 어텐션 마스크
퍼뮤테이션 언어모델 역시 단점이 있습니다. 예컨대 단어가 네 개인 문장을 랜덤 셔플한 결과가 아래와 같고 이번 스텝에서 셔플 시퀀스의 세번째를 예측해야 한다고 해봅시다. 이 경우 모델은 동일한 입력(3번, 2번 단어)을 받아 다른 출력을 내야 하는 모순에 직면합니다. Yang et al. (2019)는 이같은 문제를 해결하기 위해 투-스트림 어텐션(two-stream self attention) 기법을 제안했습니다. 다음 절에서 살펴봅시다.
- [3, 2, 4, 1]
- [3, 2, 1, 4]
투-스트림 셀프어텐션(Two-Stream Self Attention)
투-스트림 셀프어텐션은 쿼리 스트림(query stream)과 컨텐트 스트림(content stream) 두 가지를 혼합한 셀프 어텐션 기법입니다. 이 가운데 컨텐트 스트림은 기존 트랜스포머 네트워크(Vaswani et al., 2017)와 거의 유사합니다. 설명의 편의를 위해 컨텐트 스트림을 먼저 봅시다.
Yang et al. (2019)는 컨텐트 스트림 벡터를 $\mathbf{h}$라고 정의했습니다. 수식 1에서 $\mathbf{z}$는 원래 문장 순서를 랜덤 셔플한 인덱스 리스트입니다. 단어가 네 개인 문장이라면 이를 랜덤 셔플한 샘플 하나(예 : $[3, 2, 4, 1]$)가 바로 $\mathbf{z}$가 됩니다. $\mathbf{z}_t$는 $\mathbf{z}$의 $t$번째 요소를 가리킵니다. 수식 11의 계산 결과는 $m$번째 트랜스포머 블록의 $\mathbf{z}_t$에 해당하는 컨텐트 스트림 벡터입니다. $m$번째 블록의 컨텐트 스트림 벡터는 $m-1$번째 컨텐트 스트림 벡터에 기존 트랜스포머 블록(Vaswani et al., 2017)과 동일한 계산을 수행한 결과입니다.
수식 1. 컨텐트 스트림 (1)
[\mathbf{ h }{ { z }{ t } }^{ (m) }\leftarrow \textrm{Attention} \left( \mathbf{Q}=\mathbf{ h }{ { z }{ t } }^{ (m-1) },\mathbf{KV}=\mathbf{ h }{ { z }{ \le t } }^{ (m-1) };\theta \right)]
그림 13에서 $\mathbf{x}$는 토큰 임베딩을 뜻합니다. 단어가 네 개인 문장 나 어제 학교 갔어
가 있다면 $\mathbf{x}_1$은 나
, $\mathbf{x}_2$는 어제
, $\mathbf{x}_3$은 학교
, $\mathbf{x}_4$는 갔어
에 해당하는 토큰 임베딩입니다. 예컨대 이 문장을 셔플한 인덱스 리스트 $\mathbf{z}$가 $[3, 2, 4, 1]$이고 이번에 예측할 단어가 $\mathbf{z}$의 첫번째($\mathbf{z}_1=3$)라고 가정해 봅시다. 이 계산 과정은 그림 13의 상단 좌측과 같습니다.
수식 1과 그림 13을 자세히 보면 $\mathbf{z}$의 첫번째 컨텐트 스트림($\mathbf{g_{z_1}}=\mathbf{g_3}$)을 만들 때 자기 자신의 토큰 임베딩 정보($\mathbf{x_{z_1}}=\mathbf{x_{3}}=$ 학교
) 역시 셀프 어텐션 계산에 포함되는 걸 확인할 수 있습니다. 이후 설명하겠지만 쿼리 스트림을 계산할 때는 $\mathbf{x_3}$을 빼고 계산합니다. 자기 자신의 토큰 정보를 빼는 방식이 컨텐트 스트림에도 동일하게 적용되고 이 방식이 트랜스포머 블록별로 누적되면 $\mathbf{x_3}$ 이후 등장하는 토큰들을 예측할 때 $\mathbf{x_3}$ 정보를 참고하기 어려워져 불리합니다. 이 때문에 Yang et al. (2019)는 컨텐트 스트림을 만들 때는 $\mathbf{x_3}$을 포함하도록 설계했습니다.
어쨌든 이번에 예측할 단어가 $\mathbf{z}$의 첫번째($\mathbf{z_1}=3$)인 상황이라면 이전 문맥(메모리)과 자기 자신의 토큰 임베딩 정보($\mathbf{x_3}=$학교
)가 계산에 포함됩니다(그림 13 상단 좌측). $\mathbf{z}$의 두번째($\mathbf{z_2}=2$)를 계산한다면 이전 문맥(메모리, 학교)과 자기 자신($\mathbf{x_2}=$어제
)을 넣습니다(그림 13 상단 우측). 이는 이후 단어들도 마찬가지입니다(그림 13 하단 좌측, 그림 13 하단 우측).
그림 13. 컨텐트 스트림 (Yang et al., 2019)
컨텐트 스트림 $\mathbf{h}$를 수식 1과 그림 13을 간소화해 표기하면 수식 2와 같습니다. $\mathbf{z}$의 $t$번째 요소에 해당하는 컨텐트 스트림을 만들 때는 이전 문맥과 자기 자신에 대응하는 토큰 정보($\mathbf{x}$)를 활용한다는 의미입니다. 첫번째 트랜스포머 블록에 입력되는 컨텐츠 스트림 벡터의 초기값은 해당 단어에 해당하는 임베딩 벡터($\mathbf{x}$)입니다.
수식 2. 컨텐트 스트림 (2)
[{ h }{ \theta }\left( \mathbf{ x }{ { z }_{ \le t } } \right)]
쿼리 스트림은 토큰과 위치(position) 정보를 활용한 셀프어텐션 기법입니다. 쿼리 스트림을 만들 때는 이전 토큰 정보뿐 아니라 이번에 맞춰야할 타겟 단어의 위치 정보를 활용합니다. 쿼리 스트림 $\mathbf{g}$를 만드는 식은 수식 3과 같습니다.
이번 레이어($m$)에서 특정 시점($\mathbf{z_t}$)에 해당하는 단어의 쿼리 스트림 벡터 $\mathbf{g_{z_t^{(m)}}}$를 계산할 때는 이전 레이어($m-1$) $t$번째 “미만”의 컨텐트 스트림($\mathbf{h_{z_{<t}^{(m-1)}}}$)을 키($\mathbf{K}$)와 값($\mathbf{V}$)으로 씁니다. $\mathbf{g_{z_t^{(m)}}}$의 쿼리($\mathbf{Q}$)는 직전 레이어의 $\mathbf{g_{z_t^{(m-1)}}}$이며 $\mathbf{g_{z_t^{(m-1)}}}$에는 해당 시점의 단어 정보($\mathbf{x_{z_t}}$)를 빼고 위치 정보($\mathbf{z_t}$)만 넣습니다. 결과적으로 $\mathbf{g_{z_t}^{(m)}}$에는 지금 맞춰야할 단어의 임베딩 정보($\mathbf{x_{z_t}}$)가 들어가지 않습니다.
수식 3. 쿼리 스트림 (1)
[\mathbf{ g }{ { z }{ t } }^{ (m) }\leftarrow \textrm{Attention} \left( \mathbf{Q}=\mathbf{ g }{ { z }{ t } }^{ (m-1) },\mathbf{KV}=\mathbf{ h }{ { z }{ <t } }^{ (m-1) };\theta \right)]
예시를 다시 떠올려 봅시다. 원래 문장의 첫번째 단어의 임베딩인 $\mathbf{x}_1$은 나
, 두번째 단어 임베딩($\mathbf{x}_2$)은 어제
, 세번째 단어 임베딩($\mathbf{x}_3$)은 학교
, 네번째 단어 임베딩($\mathbf{x}_4$)은 갔어
에 대응하는 벡터입니다. 이 문장을 셔플한 인덱스 리스트 $\mathbf{z}$가 $[3, 2, 4, 1]$이고 이번에 예측할 단어가 $\mathbf{z}$의 세번째($\mathbf{z}_3=4$)라고 가정해 봅시다. 그림 14의 하단 좌측에 대응합니다.
$m$번째 레이어의 $\mathbf{z_t}$에 해당하는 단어의 쿼리 스트림을 구하려면 쿼리($\mathbf{Q}$), 키($\mathbf{K}$), 값($\mathbf{V}$)이 필요합니다. 1번째 레이어(그림 46 하단 좌측의 두번째 층)의 $\mathbf{z}$의 세번째($\mathbf{z_3}=4$)에 해당하는 단어의 쿼리 스트림($\mathbf{g_{z_3^{(1)}}}=\mathbf{g_{4^{(1)}}}$)을 계산할 때 키($\mathbf{K}$)와 값($\mathbf{V}$)은 $t$(=3)번째 미만의 컨텐트 스트림이 됩니다. 키($\mathbf{K}$), 값($\mathbf{V}$)에는 자기 자신의 임베딩($\mathbf{x_{z_3}}=\mathbf{x_4}=$갔어
)을 제외한 이전 문맥(메모리, $\mathbf{x_3}=$학교
, $\mathbf{x_2}=$어제
)과 관련한 컨텐트 스트림만 반영됩니다(검은 실선으로 표시).
쿼리($\mathbf{Q}$)를 만들 땐 $\mathbf{z}$의 세 번째에 해당하는 단어 임베딩 정보($\mathbf{x_{z_3}}=\mathbf{x_4}=$학교
)는 빼고 위치 정보($\mathbf{z_3}=4$)만 포함합니다(붉은색 점선으로 표시). 결과적으로 이번 레이어 쿼리 스트림 $\mathbf{g_{z_3}^{(1)}}=\mathbf{g_{4}^{(1)}}$을 계산할 때 쿼리($\mathbf{Q}$), 키($\mathbf{K}$), 값($\mathbf{V}$) 모두, 지금 맞춰야 할 단어 임베딩 정보($\mathbf{x_{z_3}}=\mathbf{x_4}=$갔어
)가 빠지게 됩니다. 다음 레이어의 쿼리 스트림 $\mathbf{g_{z_3}^{(2)}}=\mathbf{g_{4}^{(2)}}$ 역시, 쿼리($\mathbf{Q}$)는 이전 레이어의 쿼리 스트림($\mathbf{g_{z_3}^{(1)}}=\mathbf{g_{4}^{(1)}}$)이기 때문에, 레이어가 계속 거듭되더라도 지금 맞춰야 할 단어의 임베딩 정보($\mathbf{x_{z_3}}=\mathbf{x_4}=$갔어
)는 모델이 볼 수 없습니다.
그림 14. 쿼리 스트림 (Yang et al., 2019)
이번엔 그림 14의 우측 하단을 봅시다. 이번에 계산할 단어가 $\mathbf{z}$의 네번째($\mathbf{z_4}=1$)라면 이전 문맥(메모리, $\mathbf{x_3}=$학교
, $\mathbf{x_2}=$어제
, $\mathbf{x_4}=$갔어
)만 입력하고, 자기 자신의 토큰 정보($\mathbf{x_1}=$나
)는 쿼리 스트림에 넣지 않습니다. 하지만 $\mathbf{z_4}=1$이라는 위치 정보는 쿼리 스트림 계산에 포함됩니다. 이는 모델에 다음 질문을 던지는 것과 같다고 생각합니다.
- 여태까지 학교, 어제, 갔어라는 단어를 봤는데 말이야. 이번에 맞춰야 할 단어는 원래 문장에서 첫번째에 있었어. 이 단어는 뭘까?
Yang et al. (2019)이 쿼리 스트림에 자기 자신의 토큰 정보를 빼고, 이전 문맥 단어 정보와 자신의 위치 정보를 넣은 이유는 앞서 언급한 퍼뮤테이션 언어모델의 한계 때문입니다. 퍼뮤테이션 탓에 모델은 동일한 입력을 받아 다른 출력을 내야 하는 모순에 직면한다는 점을 이미 언급한 바 있습니다. 쿼리 스트림을 위와 같이 설계하게 되면 아래 두 퍼뮤테이션 시퀀스는 다음과 같은 의미를 지니게 돼 모순을 회피할 수 있습니다.
- [3, 2, 4, 1] : 여태까지 학교, 어제라는 단어를 봤는데 말이야. 이번에 맞춰야 할 단어는 원래 문장에서 네번째에 있었어. 이 단어는 뭘까?
- [3, 2, 1, 4] : 여태까지 학교, 어제라는 단어를 봤는데 말이야. 이번에 맞춰야 할 단어는 원래 문장에서 첫번째에 있었어. 이 단어는 뭘까?
쿼리 스트림 $\mathbf{g}$를 수식 13과 그림 46을 간소화해 표기하면 수식 4와 같습니다. $\mathbf{z}$의 $t$번째 요소에 해당하는 쿼리 스트림을 만들 때는 현 시점 “미만”의 이전 문맥에 대응하는 단어 정보($\mathbf{x_{<t}}$)와 자기 자신의 위치 정보($\mathbf{z_t}$)를 활용한다는 의미입니다. 첫번째 트랜스포머 블록의 쿼리 스트림을 계산할 때 사용되는 쿼리($\mathbf{Q}$)는 랜덤 초기화한 벡터이며 다른 모델 파라메터와 같이 학습합니다.
수식 4. 쿼리 스트림 (2)
[{ g }{ \theta }\left( \mathbf{ x }{ { z }{ <t } },{ z }{ t } \right)]
프리트레인 과정에서 트랜스포머-XL 레이어를 $m$개 사용했을 경우 $\mathbf{z_t}$번째 단어에 대응하는 XLNet 임베딩의 최종 출력 벡터(output)는 마지막 $m$번째 트랜스포머-XL 레이어의 쿼리 스트림 $\mathbf{g_{z_t^{(m)}}}$입니다. XLNet 모델은 이를 활용해 다음 단어 예측을 수행합니다.
트랜스포머-XL(Dai et al., 2019)는 XLNet 이전에 발표된 모델입니다. Yang et al. (2019)는 트랜스포머-XL의 세그먼트 리커런스(segment recurrence)와 상대 위치 임베딩(relative position embedding) 기법을 그대로 차용했습니다.
먼저 세그먼트 리커런스를 봅시다. Dai et al. (2019)는 기존 트랜스포머 네트워크(Vaswani et al., 2017)의 단점으로 고정된 길이의 문맥 정보만 활용할 수 있다는 점을 꼽았습니다. 이에 좀 더 긴 컨텍스트를 보기 위해 세그먼트 리커런스라는 기법을 제안했습니다.
기존 트랜스포머 네트워크는 문서가 연구자가 정한 최대 시퀀스 길이를 넘을 경우 그 길이 이후에 나타난 토큰들은 학습에서 제외합니다. 하지만 Dai et al. (2019)의 방식은 다릅니다. 우선 문서를 작은 세그먼트 단위로 자른다. 첫번째 세그먼트를 기존 트랜스포머 네트워크처럼 학습합니다. 첫번째 세그먼트를 충분히 학습했다면 이를 저장(cache)해 두고, 두번째 세그먼트를 학습합니다. 두번째 세그먼트를 계산할 때는 첫번째 세그먼트 정보를 활용합니다.
Dai et al. (2019)는 현재 세그먼트를 학습할 때 고려 대상에 포함하는 직전 세그먼트 계산 결과를 메모리(memory)라고 이름 붙였습니다. 투-스트림 셀프 어텐션의 예시 그림에서 입력 토큰이 현재 세그먼트, memory
라고 표현된 부분이 직전 세그먼트에 해당합니다. 단 현재 세그먼트를 계산할 때는 메모리를 학습하지 않습니다. 다시 말해 학습 손실(train loss)을 줄이기 위한 그래디언트를 메모리 쪽에 반영하지 않는다는 이야기입니다. 그림 15에서 확인할 수 있는 것처럼 이같은 세그먼트의 학습은 반복적(recurrence)으로 수행합니다.
그림 15. 세그먼트 리커런스의 학습 (Dai et al., 2019)
코드 1은 현재 세그먼트 계산 결과를 메모리에 저장하는 함수를 텐서플로로 구현한 것입니다. 이전 메모리 정보(prev_mem
)와 현재 계산 결과(curr_out
)를 합친(concat) 후 이들에 대해서는 그래디언트 업데이트를 하지 않도록(tf.stop_gradient
) 처리합니다. XLNet의 modeling.py
에 정의되어 있습니다.
코드 1. 현재 세그먼트를 메모리에 저장
def _cache_mem(curr_out, prev_mem, mem_len, reuse_len=None):
"""cache hidden states into memory."""
...
new_mem = tf.concat([prev_mem, curr_out], 0)[-mem_len:]
return tf.stop_gradient(new_mem)
세그먼트 리커런스 학습이 완료되면 모델이 고려할 수 있는 문맥 범위가 넓어집니다. 그림 16은 이를 시각화한 것입니다.
그림 16. 세그먼트 리커런스의 예측 (Dai et al., 2019)
Dai et al. (2019)는 단어 쌍 사이의 거리 정보인 상대 위치(relative position)를 활용했습니다. 원래 문장을 작은 세그먼트들로 쪼개고, 세그먼트별로 트랜스포머 네트워크를 학습하면 각 단어가 문장 내에서 차지하는 절대 위치(absolute position) 정보가 무의미해지기 때문입니다.
상대 위치와 절대 위치 개념을 예를 들어 설명하면 이렇습니다. 아래와 같은 문장이라면 천리
의 절대 위치는 5입니다. 문장에서 다섯 번째로 등장한 단어라는 의미입니다. 천리
를 기준으로 한 발
의 상대 위치는 4입니다. 천리
에서 발
까지 이동하려면 왼쪽으로 네 칸을 이동해야 한다는 뜻입니다.
Dai et al. (2019)가 세그먼트에 상대 위치를 적용한 걸 도식화하면 그림 17과 같다. 예컨대 토큰 수 기준 세그먼트 길이가 3이고 학습 대상 문장이 발, 없는, 말, 이, 천리, 간다
이며 이번이 두번째 세그먼트를 학습할 차례라고 가정해 봅시다. 그러면 모델은 현재 세그먼트 학습의 첫 단계에서 발, 없는, 말
이라는 메모리 정보를 입력받아 이
를 예측해야 합니다. 이
는 쿼리(query)가 되고, 발
, 없는
, 말
은 각각 키(key)가 됩니다. 이때 이
를 기준으로 한 발
의 상대 위치는 3입니다. 이
에서 발
까지 이동하려면 왼쪽으로 세 칸을 이동해야 한다는 의미입니다. 같은 방식으로 계산하면 이-없는
, 이-말
은 상대 위치가 각각 2, 1이 된다. 자기 자신의 상대 위치(이-이
)는 0입니다.
현재 세그먼트 학습의 두번째 스텝에서는 메모리 정보(발, 없는, 말)와 직전 단어(이)를 바탕으로 천리를 예측해야 합니다. 천리
가 새로운 쿼리가 되었기 때문에 이전 문맥 단어들과의 상대 거리를 다시 계산해야 합니다. 천리-발
, 천리-없는
, 천리-말
, 천리-이
, 천리-천리
의 상대 위치는 각각 4, 3, 2, 1, 0이 됩니다. 세그먼트 학습의 세번째 단계(쿼리가 간다
인 상황)도 같은 방식으로 수행하면 됩니다.
Dai et al. (2019)가 설계한 상대 위치는 음수값이 존재하지 않습니다. 트랜스포머-XL은 단어를 순차적으로 학습하는 AR 모델이기 때문입니다. 다시 말해 현재 쿼리 단어를 예측해야 하는 상황이라면, 쿼리 단어 이후의 단어에 어텐션이 걸리지 않도록 어텐션 마스크를 만듭니다. 예컨대 천리
를 기준으로 한 간다
의 상대 거리는 -1이겠지만, 간다는 천리 이후에 등장하는 단어이기 때문에 어텐션 계산에서 제외하게 됩니다. 따라서 천리-간다
의 상대 위치 역시 구할 필요가 없습니다.
그림 17. 상대 위치 시각화
수식 5는 기존 트랜스포머 네트워크의 셀프 어텐션(Vaswani et al., 2017)을 나타낸 것입니다.
수식 5. Scaled Dot-Product Attention (1)
[\textrm{Attention} \left( \mathbf{Q,K,V} \right) = \textrm{softmax} \left( \frac { \mathbf{Q K }^{ T } }{ \sqrt { { d }_{ k } } } \right) \mathbf{V}]
수식 6은 Dai et al. (2019)가 수식 5 가운데 $\mathbf{QK^T}$만 떼어내어 다시 표현한 것입니다. 수식 6의 $\mathbf{A}$는 수식 5의 $\mathbf{Q}$와 $\mathbf{K}$를 내적한 결과인 정방행렬(square matrix)입니다. 여기에서 $i$와 $j$는 각각 쿼리와 키의 인덱스(index)를 뜻합니다. 따라서 $\mathbf{A_{ij}}$ 값은 $i$번째 쿼리 단어와 $j$번째 키 단어가 태스크 수행에 얼마나 관련을 맺고 있는지를 나타내는 점수(score)가 됩니다. $\mathbf{W_q} \mathbf{E_{x_i}}$는 수식 15의 행렬 $\mathbf{Q}$의 $i$번째 행, 즉 $i$번째 쿼리 벡터를 가리킵니다. $\mathbf{W_k} \mathbf{E_{x_j}}$는 수식 15의 행렬 $\mathbf{K}$의 $j$번째 행, 즉 $j$번째 키 벡터를 의미합니다.
기존 트랜스포머 네트워크는 단어의 절대 위치(absolute position) 정보를 사용합니다. $\mathbf{U}_i$와 $\mathbf{U}_j$는 각각 문장에서 $i$번째로 등장한 단어와 $j$번째 단어의 절대 위치 정보가 담긴 임베딩을 나타냅니다. 따라서 $\mathbf{W}_q \mathbf{U}_i$는 쿼리 단어의 위치 정보, $\mathbf{W}_k \mathbf{U}_j$엔 키 단어의 위치 정보가 녹아 있습니다.
수식 6. 절대 위치에 기반한 셀프 어텐션
Dai et al. (2019)는 상대 위치에 기반한 셀프 어텐션 기법을 제안했습니다. 수식 7과 같습니다. 수식 6에서 바뀐 부분은 칼라 표시가 되어 있습니다.
우선 절대 위치 임베딩 행렬 $\mathbf{U}$를 상대 위치 임베딩 행렬 $\mathbf{R}$로 대체한 점이 눈에 띕니다. $\mathbf{R}$은 최대 시퀀스 길이 $\times$ $d$차원 크기를 갖는 행렬입니다. 쿼리 단어를 기준으로 한 키 단어의 상대 위치가 $k$일 때 $\mathbf{R}$의 $k$번째 행 벡터를 참조(lookup)해서 씁니다. Dai et al. (2019)는 행렬 $\mathbf{R}$을 사인(sin), 코사인(cosine) 함수 등을 활용해, 학습하지 않는 파라메터(non-trainable parameter)로 두었습니다.
그림 17에서 확인했던 것처럼 쿼리 단어를 기준으로 한 쿼리 단어의 상대 위치는 늘 0이기 때문에 쿼리 단어의 위치 정보를 사용하는 게 큰 의미가 없습니다. 이에 Dai et al. (2019)는 $\mathbf{W_q} \mathbf{U_i}$를 벡터($\mathbf{u}, \mathbf{v}$) 형태로 단순화했습니다.
수식 7. 상대 위치에 기반한 셀프 어텐션
수식 7의 (a)와 (c)는 기존 트랜스포머 네트워크 계산방식과 크게 달리진 것이 없습니다. 문제는 $\mathbf{R}$이 포함된 (b)와 (d)입니다. Dai et al. (2019)는 상대 위치가 포함된 셀프 어텐션 행렬 계산을 효율적으로 하는 테크닉을 제시했습니다. 우선 (b)를 봅시다. 그림 17처럼 최대 시퀀스 길이가 6이라 가정하고, (b)에서 $\mathbf{W_{k,r}} \mathbf{R_{i-j}}$를 떼어 다시 표현하면 수식 8과 같습니다.
수식 8. 상대 위치 셀프 어텐션 계산 예시 (1)
[\mathbf{Q}=\begin{bmatrix} { \left[ \mathbf{ W }{ k,R }\mathbf{ R }{ 5 } \right] }^{ T } \ { \left[ \mathbf{ W }{ k,R }\mathbf{ R }{ 4 } \right] }^{ T } \ { \left[ \mathbf{ W }{ k,R }\mathbf{ R }{ 3 } \right] }^{ T } \ { \left[ \mathbf{ W }{ k,R }\mathbf{ R }{ 2 } \right] }^{ T } \ { \left[ \mathbf{ W }{ k,R }\mathbf{ R }{ 1 } \right] }^{ T } \ { \left[ \mathbf{ W }{ k,R }\mathbf{ R }{ 0 } \right] }^{ T } \end{bmatrix}=\begin{bmatrix} { \mathbf{ Q }{ 0 } }^{ T } \ { \mathbf{ Q }{ 1 } }^{ T } \ { \mathbf{ Q }{ 2 } }^{ T } \ { \mathbf{ Q }{ 3 } }^{ T } \ { \mathbf{ Q }{ 4 } }^{ T } \ { \mathbf{ Q }{ 5 } }^{ T } \end{bmatrix}]
$\mathbf{W_q}\mathbf{E_{x_i}}$를 $\mathbf{q_i}$라 둡시다. 그림 18의 위쪽 그림과 같은 상황에서 수식 8을 참고해 (b)를 계산한 결과는 그림 18의 아래쪽 그림과 같습니다.
그림 18. 상대 위치 셀프 어텐션 계산 예시 (2)
이와 별개로 각 쿼리 벡터들을 모아 만든 행렬을 $\mathbf{q}$라고 둡시다. 그러면 $\mathbf{q}$와 $\mathbf{Q}$를 내적한 $\mathbf{qQ}$를 시각화한 것은 그림 19와 같습니다. 그림 18의 아래쪽 그림과 비교해서 봅시다. $\mathbf{qQ}$(그림 19)의 각 행별로 한 칸씩 왼쪽으로 옮기면 우리가 원하는 결과(그림 18 아래쪽)를 얻을 수 있습니다.
요컨대 쿼리 벡터를 모아 만든 행렬 $\mathbf{q}$와 상대 위치 정보가 담긴 $\mathbf{Q}$를 내적하고 살짝 후처리만 해주는 것으로도 상대 위치 셀프 어텐션을 계산할 수 있습니다. 행렬 내적 연산은 텐서플로 등 다양한 라이브러리에서 최적화되어 있기 때문에 상대 위치 계산에 큰 비용이 들지 않습니다. 수식 7의 (d)도 이와 유사한 방식으로 효율적으로 계산할 수 있습니다.
그림 19. 상대 위치 셀프 어텐션 계산 예시 (3)
모델 구현
이 절에서는 XLNet의 실제 구현 방식을 살펴봅니다. 우선 학습데이터를 만드는 코드는 data_utils.py
에 정의되어 있습니다. 이 코드를 실행하면 파일 단위로 분리되어 있는 말뭉치를 읽어들여 XLNet이 학습할 수 있는 tfrecord 파일로 변환합니다. 이 파일엔 모델의 입력인 토큰 ID 시퀀스(input
), 출력인 토큰 ID 시퀀스(target
), 세그먼트 ID 시퀀스(seg_id
) 등 정보를 배치(batch)별로 기록해 둡니다.
input
은 원래 문장을 구글 센텐스피스(sentencepiece) 패키지로 토크나이즈한 뒤 정수(integer) ID 시퀀스로 바꿔놓은 것입니다. target
은 input
을 한 칸씩 왼쪽으로 옮겨놓은 형태입니다. XLNet은 이전 단어 시퀀스로 다음 단어를 맞추는 언어모델이기 때문입니다. seg_id
는 이전 세그먼트에 속하는 토큰들은 ID가 0, 현재 세그먼트에 속하는 토큰들은 ID가 1인 리스트입니다. 이밖에 기록되는 정보로 is_masked
와 label
도 있는데 이후 설명하겠습니다.
그림 20은 원본 학습데이터의 예시다. 문장과 문장 사이는 줄바꿈 1개, 단락과 단락 사이는 <eop>, 문서와 문서 사이는 줄바꿈 2개로 구분합니다.
그림 20. XLNet 원본 학습데이터
이것은 첫번째 문장(sentence)입니다.
이것은 두번째 문장이며 단락(paragraph)의 마지막 문장입니다.\<eop>
이것은 두번째 단락입니다.
이것은 두번째 문서입니다.
(하략)
input
은 배치 사이즈(bsz_per_host
) x 최대 시퀀스 길이(seq_len
)의 크기의 행렬입니다. input
행렬의 행 하나는 다음과 같이 구성돼 있습니다. 이전 세그먼트의 마지막 문장과 현재 세그먼트의 첫번째 문장은 말뭉치에서 실제 이어진 문장을 사용합니다.
- 이전 세그먼트(길이 :
reuse_len
) + 현재 세그먼트
현재 세그먼트는 다음과 같이 구성돼 있습니다. 아래에서 SEP
는 문장과 문장 사이를 구분하는 역할을 하는 스페셜 토큰이며 CLS
는 문서의 종료를 알리는 스페셜 토큰입니다. Dai et al. (2019)도 BERT처럼 현재 세그먼트에 문장이 두 개(A, B)가 들어가도록 설계했습니다. 50%의 확률로 실제 이어진 문장 두 개, 나머지 50%의 확률로 이어지지 않는 문장 두 개를 A, B로 사용합니다.
이어진 문장을 썼을 경우 label
1, 랜덤 선택한 두 문장을 사용했다면 0을 부여합니다. Dai et al. (2019)에 따르면 BERT와 달리 다음 문장 예측(Next Sentence Prediction)은 XLNet 성능 향상엔 큰 도움이 되지 않는다고 합니다. 실제 XLNet 권장 파라메터에도 NSP가 빠져 있습니다. 어쨌든 문장 A와 B를 나누는 함수는 data_utils.py
의 _split_a_and_b
입니다.
- 문장A +
SEP
+ 문장B + SEP
+ CLS
Dai et al. (2019)는 프리트레인 계산량을 줄이기 위한 전략으로 부분 예측(partial prediction) 기법을 사용합니다. 입력값의 부분 집합만을 학습하는 것입니다. 이에 Dai et al. (2019)는 어텐션 마스크과 별개로 is_masked
라는 리스트(list) 형태의 변수를 만들었습니다. is_masked
의 요소가 True
라면 해당 토큰을 학습에 포함하고, False
이면 제외합니다. _sample_mask
는 is_masked
변수를 만드는 함수로 랜덤으로 마스크를 생성하되 가급적 n-gram 단위로 마스킹합니다.
예컨대 네 개 단어로 구성된 문장의 토큰 인덱스를 랜덤 셔플한 결과가 $[3, 2, 4, 1]$이고 _sample_mask
함수가 뽑은 is_masked
가 [False, False, True, True]라고 가정해 봅시다. 그러면 XLNet 모델은 수식 9와 같이 앞에서 한 칸씩 순차적으로 모두 학습하는 대신 앞의 두 개 단어를 학습에서 제외합니다.
수식 9. 부분 예측
[P\left( { x }{ 3 } \right) P\left( { x }{ 2 } |
{ x }{ 3 } \right) P\left( { x }{ 4 } |
{ x }{ 3,2 } \right) P\left( { x }{ 1 } |
{ x }{ 3,2,4 } \right) \ \rightarrow P\left( { x }{ 4 } |
{ x }{ 3,2 } \right) P\left( { x }{ 1 } |
{ x }_{ 3,2,4 } \right)] |
tfrecord를 구축했다면 XLNet 프리트레인을 시작할 준비를 마친 것입니다. data_utils.py
의 _local_perm
함수는 5.7.1절 퍼뮤테이션 언어모델을 학습하기 위한 어텐션 마스크를 생성하는 역할을 합니다. 모델은 프리트레인을 시작하고 tfrecord를 읽은 다음에야 _local_perm
를 호출합니다. 다시 말해 어텐션 마스크를 미리 만들어두는 것이 아니라 학습 과정에서 실시간으로 생성한다는 이야기입니다.
코드 2는 _local_perm
함수에서 토큰 인덱스 리스트를 퍼뮤테이션하는 부분만을 발췌한 것입니다. 퍼뮤테이션 가능한 모든 경우의 수(단어가 $n$개인 문장일 경우 $n!$가지)를 학습하는 게 아니라 토큰 인덱스를 랜덤으로 셔플하는 걸 1회 시행(sample)하고 해당 퍼뮤테이션 시퀀스로 어텐션 마스크를 만들고 있음을 확인할 수 있습니다.
코드 2. 퍼뮤테이션
def _local_perm(inputs, targets, is_masked,
perm_size, seq_len):
"""
Sample a permutation of the factorization order,
and create an attention mask accordingly.
"""
...
index = tf.range(seq_len, dtype=tf.int64)
index = tf.transpose(tf.reshape(index, [-1, perm_size]))
index = tf.random_shuffle(index)
index = tf.reshape(tf.transpose(index), [-1])
...
_local_perm
함수가 어텐션 마스크를 생성하는 과정을 간단한 예제와 함께 살펴봅시다. 예컨대 아래 같은 문장A 정보를 바탕으로 문장B를 예측하는 상황을 가정해 봅시다. 설명의 편의를 위해 이전 세그먼트(메모리)를 고려하지는 않고 현재 세그먼트만을 위한 어텐션 마스크를 만들어 봅니다.
앞서 설명한 바와 같이 현재 세그먼트에 해당하는 입력은 문장A + SEP
+ 문장B + SEP
+ CLS
입니다. input
은 문장A와 B를 센텐스피스 패키지로 토크나이즈한 뒤 정수 ID로 바꾸고, 여기에 SEP
ID(=4), CLS
ID(=3)를 추가한 리스트입니다. 그 결과가 아래와 같다고 가정합시다.
- 문장A 토크나이즈 결과 = [지금, 어디야, ?]
- 문장A의 ID 시퀀스 = [10, 20, 30]
- 문장B 토크나이즈 결과 = [나, 학교]
- 문장B의 ID 시퀀스 = [40, 50]
- input = [10, 20, 30, 4, 40, 50, 4, 3]
우리는 문장A를 바탕으로 문장B를 예측하는 상황을 가정했으므로 is_masked
는 아래처럼 정하는 것이 자연스럽습니다. 다시 말해 문장B(나, 학교)에만 is_masked
를 True
로 켜 놓는 것입니다. 코드 2에서 퍼뮤테이션 한 결과(index
) 역시 우연히 원래 토큰 시퀀스 순서와 동일하다고 가정합시다.
물론 실제 프리트레인 과정에서는 _sample_mask
, _local_perm
함수가 각각 is_masked
와 index
를 랜덤으로 정하기 때문에 이런 상황은 자주 발생하지 않습니다. 이해를 돕기 위한 예시이니 일단은 이렇다고 받아들입시다.
- is_masked = [False, False, False, False, True, True, False, False]
- index = [0,1,2,3,4,5,6,7]
코드 3은 _local_perm
함수를 거의 그대로 가져온 것입니다. 다만 우리가 가정한 상황에 맞는 어텐션 마스크(perm_mask
)를 출력해보기 위해 코드를 약간 고쳤다. 코드 3을 파이썬 콘솔에서 실행하면 그림 20과 같은 결과를 볼 수 있다.
코드 3. 어텐션 마스크 생성 예시 (python
)
import tensorflow as tf
CLS_ID, SEP_ID, seq_len = 3, 4, 8
inputs = [10,20,30,4,40,50,4,3]
is_masked = [False, False, False, False, True, True, False, False]
index = [0,1,2,3,4,5,6,7]
non_func_tokens = tf.logical_not(tf.logical_or(
tf.equal(inputs, SEP_ID),
tf.equal(inputs, CLS_ID)))
non_mask_tokens = tf.logical_and(tf.logical_not(is_masked), non_func_tokens)
masked_or_func_tokens = tf.logical_not(non_mask_tokens)
smallest_index = -tf.ones([seq_len], dtype=tf.int64)
rev_index = tf.where(non_mask_tokens, smallest_index, index)
target_tokens = tf.logical_and(masked_or_func_tokens, non_func_tokens)
target_mask = tf.cast(target_tokens, tf.float32)
self_rev_index = tf.where(target_tokens, rev_index, rev_index + 1)
perm_mask = tf.logical_and(
self_rev_index[:, None] <= rev_index[None, :],
masked_or_func_tokens)
perm_mask = tf.cast(perm_mask, tf.float32)
sess = tf.Session()
sess.run(perm_mask)
그림 20의 행렬은 seq_len x seq_len
크기의 정방행렬입니다. 각 행은 쿼리 단어, 열은 키 단어에 대응합니다. 행렬 요소값이 0이면 해당 쿼리 단어를 예측할 때 해당 키 단어 정보를 셀프 어텐션 계산에 활용하고 1이면 제외합니다.
예컨대 이번 스텝에서 학교
를 맞춰야 한다고 해 봅시다(그림 20 녹색 박스). 다시 말해 쿼리 단어가 학교
인 상황입니다. 그러면 모델은 지금
, 어디야
, ?
, SEP
, 나
를 바탕으로 학교
라는 단어를 맞춰야 합니다. 이때 자기 자신(학교
) 정보가 들어가는 건 반칙이므로 6행, 6열의 요소값은 1(파란색 표시)로 해둡니다. 물론 맞춰야할 단어 이후의 정보들도 이번 예측에 포함되면 안되므로 6행 7열, 6행 8열도 1로 합니다.
우리는 문장A를 바탕으로 문장B를 맞춰야 한다고 가정했습니다(부분 예측). 따라서 변수 문장A에 속한 단어와 관련된 정보는 처음부터 모델에 모두 줍니다(1~3행, 보라색 박스 표시). 단 모델이 문장B를 맞춰야 하는 순서가 오기 전까지는 문장B 정보를 가려놓습니다(5~6열, 노란색 박스 표시). 모델이 SEP, CLS 같은 스페셜 토큰 정보를 미리 보더라도 큰 의미가 없으므로 스페셜 토큰 정보 또한 제외합니다(4열, 7~8열, 붉은색 박스 표시).
한편 Dai et al. (2019)는 SEP
가 쿼리 단어일 때는 모델 입력에 SEP
정보를 포함하고, 정답(target)으로 SEP
다음 단어(문장B의 첫 단어나 CLS
)를 주도록 설계했습니다. 언어모델 학습 과정에서 SEP
토큰의 타겟으로 쓸 만한 것이 마땅치 않기 때문입니다.
예컨대 모델이 지금
, 어디야
, ?
를 입력받았을 때 문장B의 첫 단어인 나
를 출력하도록 합니다. 결과적으로 이번 타겟이 SEP
가 아니게 됐으니, 이번 스텝에는 SEP
정보를 줘도 관계 없습니다(4행 4열, 오렌지색 표시). 마지막으로 CLS
가 쿼리 단어일 때는 이후 맞춰야할 타겟이 없으므로 모델 입력에 자기 자신(CLS
)도 넣습니다(8행 8열, 오렌지색 표시).
그림 20. 어텐션 마스크 생성 예시
이제 트랜스포머-XL 메인 함수를 살펴봅시다. 코드 4는 트랜스포머-XL 블록의 전반부를 간략하게 정리한 것입니다. 메인 함수는 modeing.py
에 정의돼 있습니다. inp_k
는 토큰 ID 시퀀스를 가리킵니다. 이전 세그먼트와 현재 세그먼트(문장A + SEP
+ 문장B + SEP
+ CLS
) 토큰 ID들입니다. 이 ID들에 해당하는 토큰 임베딩을 참조(embedding_lookup
)해 만든 행렬이 바로 word_emb_k
입니다. 컨텐트 스트림(output_h
)의 초기값은 word_emb_k
입니다. 한편 쿼리 스트림(output_g
)의 초기값은 랜덤 초기화한 mask_emb_q
입니다.
pos_emb
는 상대 위치와 관련한 임베딩 행렬입니다. 이 행렬 구축에 관련된 함수를 따라가다 보면 tf.sin
, tf.cos
등으로 만들어 그래디언트 전파로 학습하는 파라메터가 아님을 다시 한번 확인할 수 있습니다. 아울러 코드 4에는 표시되어 있지 않지만 세그먼트 정보에 해당하는 입력 행렬 seg_mat
도 구축해 둡니다. 이 행렬은 word_emb_k
와 같은 크기로 세그먼트를 구분해주는 역할을 합니다.
코드 4. 트랜스포머-XL (1)
def transformer_xl(inp_k, n_token, n_layer, d_model,
n_head, d_head, d_inner, dropout, dropatt,
attn_type, bi_data, initializer,
is_training, mem_len=None,
inp_q=None, mems=None,
same_length=False, clamp_len=-1,
untie_r=False, use_tpu=True,
input_mask=None, perm_mask=None,
seg_id=None, reuse_len=None,
ff_activation='relu', target_mapping=None,
use_bfloat16=False, scope='transformer',
**kwargs):
...
with tf.variable_scope(scope):
word_emb_k, lookup_table = embedding_lookup(x=inp_k, ...)
...
mask_emb = tf.get_variable('mask_emb', [1, 1, d_model], dtype=tf_float)
...
word_emb_q = tf.tile(mask_emb,
[tf.shape(target_mapping)[0], bsz, 1])
...
output_h = tf.layers.dropout(word_emb_k,
dropout,
training=is_training)
...
output_g = tf.layers.dropout(word_emb_q,
dropout,
training=is_training)
...
pos_emb = relative_positional_encoding(
qlen, klen, d_model, clamp_len, attn_type, bi_data,
bsz=bsz, dtype=tf_float)
pos_emb = tf.layers.dropout(pos_emb, dropout,
training=is_training)
코드 5는 트랜스포머-XL을 본격적으로 계산합니다. inp_q
라는 변수는 부분 예측(partial prediction)과 관련이 있습니다. 예컨대 네 개 단어로 구성돼 있고 두번째 단어만 부분 예측을 한다면 inp_q
는 $[0, 1, 0, 0]$이 됩니다. 부분 예측은 프리트레인 과정에서만 수행하는 것으로 inp_q
에 어떤 값이 있다(not None)면 프리트레인을 수행하고 있는 것으로 간주합니다. 반대로 값이 없다면 파인튜닝이라고 인식합니다.
현재 학습이 프리트레인(inp_q is not None
)이라고 가정해 봅시다. 그러면 모델은 투-스트림 셀프어텐션 연산(two_stream_rel_attn
함수)을 수행해 컨텐트 스트림(output_h
)과 쿼리 스트림(output_g
)을 업데이트합니다. 코드를 자세히 보면 이전 레이어의 컨텐트/쿼리 스트림 출력값이 이번 레이어 컨텐트/쿼리 스트림의 입력값입니다.
이후 쿼리 스트림에 positionwise_ffn
함수를 적용합니다. 이 함수가 수행하는 역할은 BERT의 포인트 와이즈 피드포워드 뉴럴네트워크(Point-wise Feed-Forward Networks)와 같습니다. 모델의 최종 출력(output
)은 마지막 레이어의 각 토큰별 쿼리 스트림(output_g
)입니다.
attn_mask
는 쿼리 스트림을 만들기 위한 어텐션 마스크입니다. XLNet 코드를 따라가다 보면 attn_mask
는 자기 자신의 정보를 보지 못하도록 어텐션 마스크가 구성돼 있습니다. 반면 non_tgt_mask
는 자기 자신 정보도 볼 수 있도록 구축한 어텐션 마스크이다. 컨텐트 스트림을 계산하는 데 쓰입니다.
파인튜닝 때는 컨텐트 스트림(output_h
)만을 계산합니다. 기존 트랜스포머-XL 네트워크처럼 rel_multihead_attn
함수를 적용합니다. 파인튜닝 모델의 최종 출력은 마지막 레이어의 각 토큰별 컨텐트 스트림(output_h
)입니다.
코드 5. 트랜스포머-XL (2)
...
for i in range(n_layer):
# cache new mems
new_mems.append(_cache_mem(output_h, mems[i], mem_len, reuse_len))
...
with tf.variable_scope('layer_{}'.format(i)):
if inp_q is not None:
output_h, output_g = two_stream_rel_attn(
h=output_h,
g=output_g,
r=pos_emb,
...
attn_mask_h=non_tgt_mask,
attn_mask_g=attn_mask,
...)
reuse = True
else:
reuse = False
output_h = rel_multihead_attn(
h=output_h,
r=pos_emb,
...
attn_mask=non_tgt_mask,
...)
if inp_q is not None:
output_g = positionwise_ffn(inp=output_g, ...)
output_h = positionwise_ffn(inp=output_h, ...)
if inp_q is not None:
output = tf.layers.dropout(output_g, dropout,
training=is_training)
else:
output = tf.layers.dropout(output_h, dropout,
training=is_training)
return output, new_mems, lookup_table
XLNet은 이전 문맥으로 다음 단어를 맞추는 언어모델입니다. 언어모델 학습을 위한 마지막 레이어는 코드 6과 같이 정의합니다. 히든(hidden
) 벡터 하나를 가지고 선형변환을 한 뒤 소프트맥스 함수를 적용하고 여기에 정답 단어와 비교해 크로스 엔트로피 손실(cross entropy loss)을 구합니다. 이후 학습 손실을 최소화하는 방향으로 모델 전체 파라메터를 업데이트하게 됩니다. 코드 6은 modeling.py
에 정의돼 있습니다.
여기에서 손실 계산에 쓰이는 히든 벡터는 트랜스포머-XL 마지막 레이어의 출력 행렬(output
) 가운데 하나의 벡터입니다. 언어모델 학습 손실을 구할 때 히든 벡터를 하나만 쓰는 이유는, 투-스트림 셀프어텐션 기법 덕분입니다. 해당 히든 벡터에 자기 자신을 제외한 이전 문맥 정보(메모리 정보 포함) 모두가 녹아 있습니다.
코드 6. 언어모델 레이어
def lm_loss(hidden, target, n_token, d_model, initializer, lookup_table=None,
tie_weight=False, bi_data=True, use_tpu=False):
...
with tf.variable_scope('lm_loss'):
if tie_weight:
assert lookup_table is not None, \
'lookup_table cannot be None for tie_weight'
softmax_w = lookup_table
else:
softmax_w = tf.get_variable('weight',
[n_token, d_model],
dtype=hidden.dtype,
initializer=initializer)
softmax_b = tf.get_variable(
'bias', [n_token], dtype=hidden.dtype,
initializer=tf.zeros_initializer())
logits = tf.einsum('ibd,nd->ibn', hidden, softmax_w) + softmax_b
...
loss = tf.nn.sparse_softmax_cross_entropy_with_logits(
labels=target, logits=logits)
return loss
프리트레이닝 튜토리얼
Dai et al. (2019)는 벡터, 행렬 병렬처리에 특화된 TPU(Tensor Processing Unit) 환경에서 XLNet을 프리트레인하였습니다. 이 환경에서라면 트랜스포머-XL 블록을 24개 쌓은 XLNet-Base 모델도 배치 크기를 2048개 적용할 수 있어 프리트레인을 2.5일만에 끝낼 수 있다고 합니다. TPU 환경을 쓸 수 있는 독자는 아래의 XLNet 공식 레파지토리의 프리트레인 가이드를 참고하면 됩니다.
하지만 TPU 환경을 이용하려면 비싼 돈(2019년 하반기 기준 시간당 6.5달러)을 내야 합니다. 더구나 일반 GPU 환경에서는 이만한 크기의 모델을 메모리에 올리기조차 힘듭니다. 따라서 여기에서는 작은 말뭉치(네이버 영화 리뷰), 작은 레이어(3개)의 미니 XLNet을 GPU 환경에서 프레트레인하는 방법을 실습합니다.
우선 개발환경을 구축해야 합니다. 프리트레인 튜토리얼은 도커 이미지 위에서 동작하도록 만들었는데요. 다음 링크를 따라가면 도커 컨테이너를 띄우는 방법이 안내되어 있습니다.
- https://ratsgo.github.io/embedding/environment.html
그다음 프리트레인 데이터를 준비합니다. 코드 7을 실행해 전처리가 완료된 네이버 영화 리뷰 데이터를 내려받습니다. 이후 코드 8을 실행해 그림 20과 같은 형태의 문서로 만듭니다. 단, 네이버 영화 리뷰 데이터엔 단락 구분이 없으므로 영화 댓글 하나를 문서 하나로 보고 데이터를 처리합니다. 원시 말뭉치가 너무 크면 전처리가 너무 느려질 수 있으므로 말뭉치를 30만 줄(line) 단위로 분리하였습니다. 다른 데이터로 프리트레인 하고 싶다면 코드 8의 input_path
에 해당 파일 경로를 적어주면 됩니다.
코드 7. 전처리 완료된 말뭉치 다운로드 (bash
)
git pull origin master
bash preprocess.sh dump-processed
코드 8. XLNet 데이터 전처리 (bash
)
mkdir -p /notebooks/embedding/data/sentence-embeddings/pretrain-data
python preprocess/dump.py --preprocess_mode process-documents --input_path /notebooks/embedding/data/processed/corrected_ratings_corpus.txt --output_path /notebooks/embedding/data/processed/pretrain.txt
split -l 300000 /notebooks/embedding/data/processed/pretrain.txt /notebooks/embedding/data/sentence-embeddings/pretrain-data/data_
코드 9와 코드 10을 실행하면 XLNet이 쓰는 어휘 집합과 학습데이터(tfrecord 형태)를 구축할 수 있습니다.
코드 9. XLNet 어휘 집합 구축 (bash
)
mkdir -p /notebooks/embedding/data/sentence-embeddings/xlnet/pretrain-ckpt
python preprocess/unsupervised_nlputils.py --preprocess_mode make_xlnet_vocab --input_path /notebooks/embedding/data/processed/corrected_ratings_corpus.txt --vocab_path /notebooks/embedding/data/sentence-embeddings/xlnet/pretrain-ckpt/sp10m.cased.v3
코드 10. XLNet tfrecord 학습데이터 구축 (bash
)
cd models/xlnet
python data_utils.py --bsz_per_host=16 --num_core_per_host=1 --seq_len=256 --reuse_len=128 --input_glob=/notebooks/embedding/data/sentence-embeddings/pretrain-data/* --save_dir=/notebooks/embedding/data/sentence-embeddings/xlnet/pretrain-ckpt --num_passes=10 --bi_data=True --sp_path=/notebooks/embedding/data/sentence-embeddings/xlnet/pretrain-ckpt/sp10m.cased.v3.model --mask_alpha=6 --mask_beta=1 --num_predict=45
프리트레인을 시작하려면 코드 11을 실행하면 됩니다.
코드 11. XLNet 프리트레인 (bash
)
python train_gpu.py --record_info_dir=/notebooks/embedding/data/sentence-embeddings/xlnet/pretrain-ckpt/tfrecords --model_dir=/notebooks/embedding/data/sentence-embeddings/xlnet/pretrain-ckpt --train_batch_size=16 --seq_len=256 --reuse_len=128 --mem_len=192 --perm_size=128 --n_layer=3 --d_model=512 --d_embed=512 --n_head=8 --d_head=32 --d_inner=2048 --uncased=True --untie_r=True --mask_alpha=6 --mask_beta=1 --num_predict=45 --save_steps=10000
코드 12는 XLNet 프리트레인을 자동화한 스크립트입니다. 코드 8~11을 한꺼번에 수행합니다.
코드 12. XLNet 프리트레인 자동화 스크립트 (bash
)
git pull origin master
bash sentmodel.sh pretrain-xlnet