Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

트랜스포머와 어텐션

Part 1에서 우리는 단어를 벡터로 바꾸는 임베딩과 시퀀스를 처리하는 순환 신경망(RNN)을 다루었습니다. 이 장은 그 위에서, 2017년 이후 자연어 처리의 표준이 된 트랜스포머(Transformer) 구조를 그 핵심 기제인 어텐션(attention) 으로부터 쌓아 올립니다.

출발점은 하나의 관찰입니다. 단어 임베딩은 각 단어를 벡터로 표현하지만, 단어의 의미는 그 단어 자체가 아니라 문장 내에서 다른 단어들과의 관계, 즉 문맥에서 결정됩니다. 같은 표기의 단어라도 주변 단어에 따라 전혀 다른 뜻이 됩니다. 어텐션은 바로 이 단어들 사이의 관계를 수치로 표현하는 장치이며, 트랜스포머는 RNN과 합성곱을 모두 걷어내고 오로지 어텐션만으로 인코더와 디코더를 구성한 모델입니다.

1고정 길이 벡터의 한계

어텐션은 기계 번역에서 출발했습니다. 기존의 신경망 기계 번역은 인코더-디코더 구조를 사용합니다. 인코더 RNN이 원문 전체를 하나의 고정 길이 벡터로 압축하고, 디코더 RNN이 그 벡터로부터 번역문을 생성하는 방식입니다.

문제는 원문의 모든 정보를 단 하나의 벡터에 욱여넣어야 한다는 데 있습니다. 문장이 길어질수록 이 고정 길이 벡터는 의미를 온전히 담기 어려워지고, 번역 품질이 떨어집니다. 2015년에 제안된 어텐션은 이 병목을 해소합니다. 디코더가 매 단어를 생성할 때마다 원문의 어느 부분이 지금 필요한지를 소프트하게 정렬(soft alignment) 하여, 관련 있는 원문 표현들의 가중 합을 문맥 벡터로 사용하는 것입니다. 더 이상 원문 전체를 하나의 벡터에 압축할 필요가 없어지고, 정렬 기제까지 번역 목표에 맞춰 함께 학습됩니다. 이로써 긴 문장의 번역 성능이 크게 개선되었습니다.

초기 신경망 기계 번역(2014)은 인코더 RNN이 원문 전체를 하나의 고정 길이 벡터로 압축하고, 디코더 RNN이 그 벡터로부터 번역문을 생성합니다.

2015년 어텐션은 이 병목을 해소합니다. 디코더가 단어를 생성할 때마다 원문 표현들을 소프트 정렬 가중치로 가중 합하여 문맥 벡터로 사용합니다.

2셀프 어텐션: 문맥을 반영한 문장 표현

2017년 상반기에는 어텐션을 번역의 보조 도구가 아니라 문장 임베딩 자체를 만드는 방법으로 끌어올린 셀프 어텐션(self-attention)이 제시되었습니다. 핵심 동기는 단어 임베딩의 한계입니다. 단어 임베딩은 각 단어를 독립적인 벡터로 표현하지만, 바로 그 독립성이 문제입니다. 단어의 의미는 문장 내 다른 단어들과의 관계 속에서 정해지기 때문입니다. 셀프 어텐션의 목표는 각 단어가 문장의 나머지 부분과 얼마나 관련 있는지를 표현하는 데 있습니다.

이 방법은 다음 순서로 문장을 표현합니다.

  1. 단어 임베딩. 문장의 각 단어를 dd 차원 벡터로 표현합니다. nn개 단어로 된 문장 SSn×dn \times d 행렬입니다. 이 단계의 단어들은 아직 서로 독립적입니다.

  2. 양방향 LSTM으로 문맥 포착. 각 단어의 문맥은 앞선 단어들뿐 아니라 뒤따르는 단어들에도 달려 있으므로 양방향 LSTM을 씁니다. 한 방향이 uu개의 출력을 내면 단어마다 2u2u개의 출력이 모이고, 문장 전체의 문맥 HHn×2un \times 2u 행렬이 됩니다.

  3. 어노테이션(annotation). 문맥 HH를 입력으로 받는, 편향 없이 가중치만 학습하는 두 개의 완전연결 계층을 둡니다. 첫 계층의 뉴런 수 dad_a는 적당한 크기로 정합니다(원 논문에서는 350 정도). 마지막 출력은 소프트맥스로 처리하여, 어떤 단어가 문장 내 다른 단어들과 얼마나 연관되는지를 확률적 지표로 표현합니다.

그런데 문장은 여러 관점에서 동시에 읽힙니다. "엄마 아빠는 아기를 사랑해요"라는 문장에서 '사랑’은 연인의 사랑이 아니라 부모가 자식에게 갖는 사랑입니다. 이 의미는 '사랑’이라는 단어 자체가 아니라 문장 내 단어들의 순서와 배치에서 결정됩니다. 또 '아기’의 관점에서 본 '엄마’와의 관련성은 '엄마’의 관점에서 본 다른 단어들과의 관련성과 다를 수 있습니다.

그래서 하나의 관점이 아니라 rr개의 서로 다른 관점에서 관계를 평가합니다. 그 결과인 어노테이션 행렬 AAr×nr \times n 형태가 됩니다. 이 AA를 가중치로 삼아 양방향 LSTM의 출력 HH를 조합하면 문장 임베딩 MM이 만들어집니다. MM의 각 행은 입력 문장에 대한 하나의 벡터 표현이고, 행렬 전체는 같은 문장을 여러 관점에서 본 표현이 됩니다. 셀프 어텐션의 또 다른 강점은 여러 문맥을 통해 학습된 단어 간 조합 관계표를 미리 형성해 두고, 새 문장마다 처음부터 벡터화하는 대신 그 표에서 조합을 조회한다는 점입니다.

3어텐션의 네 가지 방향

2015년 이후 다양한 어텐션이 제안되면서, 그 갈래는 두 축으로 정리되었습니다.

구분종류설명
비교 대상크로스(cross)서로 다른 두 시퀀스(예: 원문과 번역문) 사이의 관련성
셀프(self)한 시퀀스 내부 단어들 사이의 관련성
연산 방식더하기(additive)2015년 최초의 크로스 어텐션 방식
곱하기(multiplicative)두 임베딩 벡터의 내적으로 어텐션을 구하는 방식

크로스 어텐션은 2015년 어텐션이 처음 제시되었을 때 원문과 번역문 단어들 사이의 관련성을 표현하는 데 쓰였습니다. 같은 해 스탠퍼드 쪽에서는 원문과 번역 임베딩의 내적으로 어텐션 가중치를 구하는 곱하기 어텐션이 정리되었습니다. 내적 방식은 더하기 방식보다 계산이 간단하고, 내적 연산에 최적화된 하드웨어와 소프트웨어를 활용할 수 있다는 장점이 있습니다. 트랜스포머가 채택한 것이 바로 이 곱하기 어텐션입니다.

4Query, Key, Value

2017년 트랜스포머 논문은 그동안 여러 형태로 쓰이던 어텐션을 질의(Query), 색인(Key), 값(Value) 세 입력으로 간결하게 정리했습니다. 곱하기 어텐션을 QKV로 정리하되, 계산 안정성을 위한 스케일링을 제외하면 이미 개발된 기법을 그대로 차용한 것입니다.

Q, K, V는 모두 임베딩으로 변환된 벡터이며, 실제 연산은 배치 단위로 수행합니다. 이 일반형이 왜 자연스러운지는 검색 엔진에 비유하면 분명합니다. 사용자가 'transformer’를 검색한다고 합시다. 이 단어는 맥락에 따라 변압기(전기공학)일 수도, 인공지능 모델일 수도 있습니다. 'electrical transformer’와 'ai transformer’는 다른 결과를 보여야 합니다.

  • Query: 맥락을 반영해 표현되어야 할 사용자의 질의

  • Key: 각 웹페이지를 요약한 색인 — 페이지 내용 자체는 아닙니다

  • Value: 웹페이지의 실제 내용을 담은 별도의 벡터

질의와 색인의 관계를 연산하여 그 결과를 값에 반영하면, 질의의 맥락이 반영된 페이지 중요도가 표현됩니다. 최대 세 개의 서로 다른 입력을 받아 맥락을 반영하는 QKV 어텐션이 어텐션의 가장 일반적인 형태입니다.

입력 임베딩에서 Query, Key, Value를 만들고, 질의와 색인의 관계를 값에 반영해 맥락이 반영된 표현을 얻습니다.

5스케일드 닷-프로덕트 어텐션

트랜스포머의 어텐션은 다음과 같이 정의됩니다. 입력 XX에 학습 가능한 가중치를 곱해 Q, K, V를 만든 뒤, QQKK의 내적으로 유사도(어텐션 점수)를 구하고, 이를 차원의 제곱근으로 나눈 다음 소프트맥스로 정규화하여 VV에 가중치로 적용합니다.

5.1어텐션

Q=XWQ,K=XWK,V=XWVQ = XW^Q, \quad K = XW^K, \quad V = XW^V
Attention(Q,K,V)=softmax(QKTdk)V\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V

값을 구할 때 소프트맥스로 정규화하는 이유는, 내적 결과가 음수이거나 단위가 제각각일 수 있기 때문입니다. 그리고 dk\sqrt{d_k}로 나누는 스케일링이 핵심입니다. 두 벡터를 곱하는 내적은 차원이 커질수록 큰 값은 더 커지고 작은 값은 더 작아져 분산이 커집니다. 이 상태로 소프트맥스를 통과시키면 분포가 한쪽으로 지나치게 쏠려 학습이 불안정해집니다. 차원의 제곱근으로 나누어 값을 줄여 주면 이를 완화할 수 있습니다.

다음은 임의의 질의와 값 시퀀스를 임베딩한 뒤 QKT/dQK^T / \sqrt{d}에 소프트맥스를 적용해 단어들 사이의 유사도 행렬을 구하는 예입니다. 여기서는 K=VK = V로 두었습니다.

어휘수 = 5000; 시퀀스길이 = 10; 벡터차원 = 128
query = np.random.randint(어휘수, size=(시퀀스길이,)) # 예: 원문; 검색 질의; 프롬프트
value = np.random.randint(어휘수, size=(시퀀스길이,)) # 예: 번역; 검색 결과; 생성 모델 응답

display(pd.DataFrame({'질의': query, '값': value}).T)

embedding = layers.Embedding(어휘수, 벡터차원)
model = keras.Sequential([embedding])
Q = model.predict(query)
V = model.predict(value)
K = V
print(f'Q={Q.shape}, K=V={V.shape}')

# QK
QK = Q @ K.T
# why? 정규화
QK /= np.sqrt(벡터차원)
QK = torch.softmax(torch.tensor(QK), dim=1).numpy()
print(f'QK={QK.shape}')
# 각 단어들의 유사도 행렬
display(pd.DataFrame(QK).round(3))

6셀프 어텐션과 동음이의어

트랜스포머에서 가장 핵심적인 것은 셀프 어텐션입니다. 기존에는 RNN을 보조하는 기법이었지만, 트랜스포머에서는 셀프 어텐션이 주인공입니다. 셀프 어텐션은 문장 내 단어들의 관계를 표현합니다.

다음 예문을 봅시다. '배’라는 단어가 세 번 나오지만 뜻은 모두 다릅니다. 첫 번째 '배’는 타는 운송 수단이고, 두 번째는 먹는 과일이며, 세 번째는 몸의 부위입니다. 표기가 동일한 동음이의어인데도 우리는 자연스럽게 구별합니다. 첫 번째는 '타고’와 함께 있고, 두 번째는 '먹으니’와 함께 있으며, 세 번째는 '아프다’와 함께 있기 때문입니다. 즉 표기가 아니라 문장 내 위치와 주변 단어의 맥락이 의미를 결정합니다.

예문 = '배 타고 배 먹으니 배 아프다'

이 문장을 모델에 넣으려면 먼저 정수 시퀀스로 바꿔야 합니다. SentencePiece로 학습한 토크나이저로 형태소 단위로 분절하고 각 토큰을 정수로 인코딩합니다.

import sentencepiece as spm

tokenizer = spm.SentencePieceProcessor('spm.model')
정수시퀀스 = tokenizer.encode(예문)
pd.DataFrame({
    '형태소': tokenizer.encode(예문, out_type=str),
    '정수인코딩': 정수시퀀스
}).T

단어들이 벡터로 표현되면, 문장 내에서 서로의 관계는 단순히 두 벡터의 내적으로 구할 수 있습니다. 그러나 단어 임베딩은 문장과 무관하게 독립적으로 형성되기 때문에 이대로는 '배’와 같은 동음이의어나 '나, 너, 우리’와 같은 대명사의 의미를 제대로 구별할 수 없습니다. 같은 단어라도 문맥에 따라 다른 벡터가 되어야 합니다.

2017년의 셀프 어텐션은 이를 위해 LSTM의 출력을 기반으로 삼았습니다. 그런데 단어들의 관계 표현이 목표라면 반드시 LSTM을 거쳐야 할까요? RNN을 빼고 셀프 어텐션 자체를 직접 학습할 수 있다면 그것으로 단어의 관계가 표현된 셈이라는 관찰이 트랜스포머의 핵심입니다. 정리하면, 목표는 문맥을 반영한 단어 벡터입니다. 단어들의 관계를 문맥 속에서 표현한 것이 셀프 어텐션이고, 이것을 원래 단어 벡터의 가중치로 반영해 합하면 문맥에 따라 새로운 단어 벡터가 형성됩니다.

7크로스 어텐션과 셀프 어텐션

트랜스포머는 기계 번역을 염두에 둔 모델입니다. 셀프 어텐션은 입력이 하나이지만, 번역에서는 원문과 번역문 두 입력을 다뤄야 합니다. 원문 임베딩과 번역 임베딩이 같은 차원이라면 둘 사이에도 여전히 내적 어텐션을 구할 수 있습니다. 셀프 어텐션과의 차이는 내적하는 대상이 서로 다른 시퀀스라는 점뿐입니다. 이렇게 구한 어텐션을 번역 임베딩에 반영하면 원문의 각 단어가 번역될 언어의 맥락을 반영한 새 벡터가 됩니다. 통역사가 원문을 듣고 그 의미를 전체적으로 파악한 뒤 적절한 번역을 생성하는 과정에 비유할 수 있습니다.

먼저 질의(목표 문장)와 색인(원문) 두 시퀀스를 각각 임베딩한 뒤 QKTQK^T로 어텐션 점수 행렬을 만드는 모델을 정의합니다. 출력의 크기는 (시퀀스 길이, 시퀀스 길이)로, 질의의 각 단어와 색인의 각 단어 사이 관련성을 담습니다.

시퀀스길이 = 10; 임베딩차원 = 32; 어휘수 = 100
query = keras.Input(shape=(시퀀스길이,), name='target_seq') # 예: 번역 목표 문장
key = keras.Input(shape=(시퀀스길이,), name='source_seq')   # 예: 번역 원문

임베딩 = layers.Embedding(input_dim=어휘수, output_dim=임베딩차원, name='embedding')
# 각 문장의 단어는 임베딩 차원 크기의 벡터로 변환됩니다. (배치 크기, 시퀀스 길이, 임베딩 차원)
Q = 임베딩(query) # (배치 크기, 시퀀스 길이, 임베딩 차원)
K = 임베딩(key) # (배치 크기, 시퀀스 길이, 임베딩 차원)

# Q와 K의 내적을 계산하여 어텐션 점수를 구합니다. QK^T
# 유사도 행렬이 됩니다.
KT = keras.ops.transpose(K, axes=(0, 2, 1)) # (배치 크기, 임베딩 차원, 시퀀스 길이)
어텐션점수 = keras.ops.matmul(Q, KT) # (배치 크기, 시퀀스 길이, 시퀀스 길이)

model = keras.Model(inputs=[query, key], outputs=어텐션점수)
# model.summary()
keras.utils.plot_model(model, show_shapes=True, show_layer_names=True)

7.1크로스 어텐션

질의와 색인에 서로 다른 시퀀스를 넣으면 크로스 어텐션입니다. 결과 행렬의 행은 질의 단어, 열은 색인 단어에 대응하며, 두 문장의 단어들이 서로 얼마나 관련되는지를 나타냅니다.

query_seq = np.random.randint(0, 어휘수, size=(1, 시퀀스길이)) # (1, 시퀀스 길이)
key_seq = np.random.randint(0, 어휘수, size=(1, 시퀀스길이))   # (1, 시퀀스 길이)

display(pd.DataFrame({'query_seq': query_seq.flatten(), 'key_seq': key_seq.flatten()}).T)

어텐션점수 = model.predict([query_seq, key_seq])
어텐션점수 = 어텐션점수.squeeze() # (시퀀스 길이, 시퀀스 길이)
print(f'어텐션 점수 행렬 크기: {어텐션점수.shape}')
display(pd.DataFrame(어텐션점수, index=query_seq.flatten(), columns=key_seq.flatten()).round(3))

7.2셀프 어텐션

같은 모델에 질의와 색인으로 같은 시퀀스를 넣으면 셀프 어텐션이 됩니다. 즉 Query = Key = Value가 모두 같은 문장에서 나온 벡터들입니다. 결과는 한 문장 내부 단어들 사이의 유사도 행렬이며, 대각선(자기 자신과의 관계)이 두드러집니다.

query_seq = np.random.randint(0, 어휘수, size=(1, 시퀀스길이)) # (1, 시퀀스 길이)

display(pd.DataFrame({'query_seq': query_seq.flatten(), 'key_seq': query_seq.flatten()}).T)

어텐션점수 = model.predict([query_seq, query_seq])
어텐션점수 = 어텐션점수.squeeze() # (시퀀스 길이, 시퀀스 길이)
print(f'어텐션 점수 행렬 크기: {어텐션점수.shape}')
display(pd.DataFrame(어텐션점수, index=query_seq.flatten(), columns=query_seq.flatten()).round(3))

8멀티 헤드 어텐션

트랜스포머는 곱하기 어텐션을 한 번 수행하는 대신, Q·K·V를 원래 차원보다 작은 여러 벡터로 선형 변환하여 각각에 대해 어텐션을 병렬로 구한 뒤 다시 합치고, 마지막으로 선형 변환을 한 번 더 수행하는 멀티 헤드 어텐션을 제시했습니다.

동작 방식은 다음과 같습니다. 임베딩을 거친 Q, K, V는 (시퀀스 길이, 모델 차원 dmodeld_\text{model})의 행렬입니다. 이를 헤드 수만큼 쪼개어, 각 헤드는 차원이 dmodel/헤드수d_\text{model} / \text{헤드수}인 작은 벡터를 담당합니다. dmodel=128d_\text{model} = 128, 헤드 8개라면 각 헤드는 16차원을 맡습니다. 첫 번째 헤드는 첫 16차원을, 두 번째 헤드는 다음 16차원을 처리하는 식으로, 각 헤드가 입력 벡터의 서로 다른 부분을 독립적으로 처리합니다.

왜 나눌까요? 단일 헤드는 패턴을 한 가지 방식으로만 학습합니다. 여러 헤드를 쓰면 같은 입력을 여러 관점에서 보며 서로 다른 관계와 패턴을 학습할 수 있고, 헤드들이 병렬로 계산되어 효율도 좋습니다. DNA에 비유하면, ACGT 뉴클레오타이드의 특정 조합이 단백질을 이루듯 시퀀스는 하위 개념들의 조합입니다. 시퀀스 전체를 하나로 취급하면 그 안의 관계들을 잘 보기 어려우므로, 여러 조각으로 나누어 각각 어텐션을 뽑는 것이 효과적입니다.

다음은 임베딩한 Q, K를 헤드별 선형 투영으로 나누어 헤드마다 QKTQK^T 어텐션 점수를 구하는, 멀티 헤드의 분할 과정을 직접 펼쳐 본 코드입니다.

멀티 헤드 어텐션은 임베딩 벡터를 여러 조각으로 나누어 각 헤드가 서로 다른 부분을 독립적으로 처리한 뒤, 결과를 다시 결합합니다.

query_seq = np.random.randint(0, 어휘수, size=(1, 시퀀스길이)) # (1, 시퀀스 길이)
key_seq = np.random.randint(0, 어휘수, size=(1, 시퀀스길이))   # (1, 시퀀스 길이)

display(pd.DataFrame({'query_seq': query_seq.flatten(), 'key_seq': key_seq.flatten()}).T)

# 멀티헤드 어텐션
임베딩차원 = 128; 멀티헤드수 = 4
임베딩 = layers.Embedding(
    input_dim=어휘수, output_dim=임베딩차원, name='embedding')
Q = 임베딩(query_seq) # (배치 크기, 시퀀스 길이, 임베딩 차원)
K = 임베딩(key_seq) # (배치 크기, 시퀀스 길이, 임베딩 차원)

# 멀티헤드
head_projections = [layers.Dense(임베딩차원 // 멀티헤드수) for _ in range(멀티헤드수)]
heads = []
for i in range(멀티헤드수):
    Qi = head_projections[i](Q) # (배치 크기, 시퀀스 길이, 임베딩 차원 // 멀티헤드수)
    Ki = head_projections[i](K) # (배치 크기, 시퀀스 길이, 임베딩 차원 // 멀티헤드수)
    KTi = keras.ops.transpose(Ki, axes=(0, 2, 1)) # (배치 크기, 임베딩 차원 // 멀티헤드수, 시퀀스 길이)
    어텐션점수i = keras.ops.matmul(Qi, KTi) # (배치 크기, 시퀀스 길이, 시퀀스 길이)
    heads.append(어텐션점수i)

이 분할·계산·결합 과정 전체는 Keras의 MultiHeadAttention 계층 하나로 처리됩니다. num_heads로 헤드 수를, key_dim으로 각 헤드의 차원을 지정합니다. query에 Q, value와 key에 K를 넘기면 (배치 크기, 시퀀스 길이, 임베딩 차원) 형태의 출력이 나옵니다.

멀티헤드어텐션 = keras.layers.MultiHeadAttention(num_heads=멀티헤드수, key_dim=임베딩차원 // 멀티헤드수)

xq = 임베딩(query_seq) # (배치 크기, 시퀀스 길이, 임베딩 차원)
xk = 임베딩(key_seq) # (배치 크기, 시퀀스 길이, 임베딩 차원)
어텐션출력 = 멀티헤드어텐션(query=xq, value=xk, key=xk)
print(f'멀티헤드 어텐션 출력 크기: {어텐션출력.shape}') # (배치 크기, 시퀀스 길이, 임베딩 차원)

9트랜스포머 구조: 인코더와 디코더

'트랜스포머’라는 이름은 모델이 인코더와 디코더로 나뉘는 데서 왔습니다. 인코더는 입력을 특정 벡터 표현으로 바꾸고, 디코더는 그 표현으로부터 출력을 생성합니다. 이 인코더-디코더 구조 자체는 기존 모델을 그대로 따릅니다. 다만 기존 모델이 단어 간 상관관계를 알아내려고 LSTM 같은 RNN과 합성곱을 썼다면, 트랜스포머는 RNN과 CNN에 대한 의존을 완전히 제거하고 어텐션만으로 인코더와 디코더를 구성합니다.

RNN은 이전 상태가 다음 상태의 입력이 되어 반드시 순차 처리가 필요합니다. 시퀀스가 길수록 메모리 요구가 커지고 병렬 연산이 어려운데, 데이터와 모델 규모가 커지는 추세에서 이는 큰 약점입니다. 셀프 어텐션이 문장 내 단어들의 상관관계를 표현할 수 있다면, 그것이 곧 RNN을 쓰던 이유 자체를 대체한다는 것이 트랜스포머의 착안입니다.

인코더의 한 계층은 두 개의 하위 계층으로 구성됩니다.

  1. 멀티 헤드 셀프 어텐션

  2. 완전연결(MLP) 계층

각 하위 계층은 2015년 ResNet에서 큰 성공을 거둔 잔류 연결(residual connection) 을 씁니다. 계층의 입력을 그 출력에 다시 더한 뒤 정규화를 거치는 방식입니다. 이렇게 구성한 인코더 계층을 여러 개(원 논문은 6개) 쌓습니다.

디코더도 비슷하지만 멀티 헤드 어텐션이 하나 더 추가되어 하위 계층이 세 개입니다. 추가된 어텐션은 인코더의 출력을 반영하기 위한 것입니다. 또한 출력 시퀀스는 직전 출력이 정해져야 다음 출력이 가능하므로, 전체 출력을 한꺼번에 주면 안 됩니다. 그래서 아직 생성되지 않은 위치를 가리는 마스크 처리를 합니다.

트랜스포머는 인코더와 디코더로 나뉩니다. 원 논문은 인코더와 디코더를 각각 6층 쌓았으며, RNN과 CNN 없이 어텐션만으로 구성합니다.

인코더 계층은 멀티 헤드 셀프 어텐션과 완전연결(MLP) 두 하위 계층으로, 디코더 계층은 마스크 어텐션이 추가된 세 하위 계층으로 구성됩니다. 각 하위 계층은 잔류 연결과 층 정규화(Add & Norm)를 쓰며, 디코더의 중간 어텐션은 인코더 출력을 반영합니다.

10위치 인코딩

어텐션만으로 구성하면 한 가지가 빠집니다. 어텐션은 집합 연산이라 단어의 순서 정보가 없습니다. 트랜스포머는 이를 사인·코사인 함수 기반의 위치 인코딩(positional encoding) 으로 주입합니다.

사인 함수는 주기적이고 연속적인 파동을 나타냅니다. 주파수는 파동이 1초에 몇 번 반복되는지를 나타내는 값으로(단위 Hz), 주파수가 높을수록 빠르게, 낮을수록 천천히 반복됩니다. 위치 인코딩은 임베딩 차원마다 주파수를 다르게 설정합니다. 각 차원의 주기는 2π2\pi부터 2π×100002\pi \times 10000까지의 범위에 걸칩니다. 또한 짝수 차원은 사인 함수로, 홀수 차원은 코사인 함수로 변환합니다.

차원마다 주파수를 달리하고 짝·홀수를 다르게 변환하는 이유는, 주파수 기반 인코딩의 다차원적 특성을 활용해 더 풍부한 위치 정보를 제공하기 위해서입니다. 이 방식은 위치 정보의 다양성을 극대화하여, 모델이 위치 인코딩을 통해 순서를 효과적으로 학습하도록 돕습니다.

11트랜스포머 인코더 구현

이제 앞의 개념들을 하나의 인코더 계층으로 조립해 봅시다. 핵심은 셀프 어텐션 → 완전연결 신경망(MLP) 의 순서입니다. 먼저 가장 단순한 형태로, 셀프 어텐션 출력을 MLP로 한 번 더 조정하는 흐름을 봅니다. MultiHeadAttention에 query와 value로 같은 입력을 넘기면 셀프 어텐션입니다.

keras.backend.clear_session()

벡터차원 = 128; 시퀀스길이 = 64
헤드수 = 8
어텐션계층 = layers.MultiHeadAttention(num_heads=헤드수, key_dim=벡터차원)
encoder_input = keras.Input(shape=(시퀀스길이, 벡터차원,))
encoder_input
# 셀프 어텐션
어텐션출력 = 어텐션계층(query=encoder_input, value=encoder_input)
# MLP 신경망
mlp = keras.Sequential([
    layers.Dense(1024, activation='relu'),
    layers.Dense(벡터차원)
])
# 조정된 벡터
x = mlp(어텐션출력)
x

여기에 잔류 연결과 층 정규화(layer normalization)를 더하면 인코더 계층이 완성됩니다. 셀프 어텐션의 출력에 입력 x0을 다시 더한 뒤 정규화하고, MLP의 출력에 다시 그 입력을 더한 뒤 정규화합니다. 두 하위 계층 모두 잔류 연결을 쓴다는 점에 주목하세요.

def get_encoder(inputs, 임베딩차원, 멀티헤드수, 잠재차원):
    # 계층 정의
    어텐션계층 = layers.MultiHeadAttention(
        num_heads=멀티헤드수, key_dim=임베딩차원 // 멀티헤드수)
    mlp = keras.Sequential([
        layers.Dense(잠재차원, activation='relu'),
        layers.Dense(임베딩차원)
    ])
    # 모델 구성
    # 1. 셀프 어텐션
    x0 = inputs
    Q, K = inputs, inputs
    x = 어텐션계층(Q, K) # Query = Key => 셀프 어텐션
    x = layers.LayerNormalization()(x + x0) # 층 정규화 (layer normalization)
    # 2. MLP
    x0 = x
    x = mlp(x)
    x = layers.LayerNormalization()(x + x0) # 층 정규화 (layer normalization)
    model = keras.Model(inputs, x)
    return model

inputs = keras.Input(shape=(None, 128)) # (배치 크기, 시퀀스 길이, 임베딩 차원)
encoder = get_encoder(inputs, 임베딩차원=128, 멀티헤드수=4, 잠재차원=256)
encoder.summary()

keras.utils.plot_model(encoder, show_shapes=True)

12텍스트 분류 트랜스포머

인코더 계층을 모듈로 분리해 두면 실제 과제에 곧바로 쓸 수 있습니다. 여기서는 transformer.py에 정의된 TransformerEncoder 클래스를 가져와 영화 리뷰 같은 텍스트의 이진 분류 모델을 구성합니다. 구조는 다음과 같습니다.

  • 임베딩 계층: mask_zero=True로 패딩 토큰을 무시합니다.

  • TransformerEncoder: 셀프 어텐션으로 문맥을 반영한 단어 표현을 만드는 은닉층.

  • GlobalAveragePooling1D: 시퀀스의 단어 벡터들을 평균하여 문장 하나의 벡터로 만듭니다.

  • 출력층: 시그모이드로 이진 분류.

import keras
from keras import layers
# transformer.py 파일에서 TransformerEncoder 클래스를 가져옵니다.
from transformer import TransformerEncoder

keras.backend.clear_session()

def build_model(어휘수, 임베딩차원, 멀티헤드수, 잠재차원):
    model = keras.Sequential([
        keras.Input(shape=(None,)), # 가변 길이 시퀀스 입력
        layers.Embedding(input_dim=어휘수, output_dim=임베딩차원, mask_zero=True), # 임베딩
        # 은닉층
        TransformerEncoder(embed_dim=임베딩차원, num_heads=멀티헤드수, dense_dim=잠재차원),
        # 출력층
        layers.GlobalAveragePooling1D(), # 벡터화
        layers.Dense(1, activation='sigmoid') # 이진 분류
    ])
    return model

model = build_model(어휘수=30000, 임베딩차원=128, 멀티헤드수=4, 잠재차원=256)
model.summary()

데이터로더를 불러와 모델을 컴파일하고 학습합니다. 손실은 이진 교차 엔트로피, 최적화기는 Adam을 쓰고, 검증 손실이 가장 좋은 시점의 가중치를 체크포인트로 저장합니다.

%run load_text_class_example.py

model.compile(
    loss=keras.losses.BinaryCrossentropy(),
    optimizer=keras.optimizers.Adam(),
    metrics=['acc'],
)

history = model.fit(
    dataloaders['train'], epochs=10,
    validation_data=dataloaders['validation'],
    callbacks=[
        keras.callbacks.ModelCheckpoint(
            'text_classifier-transformer.keras', save_best_only=True)
    ]
)

13사전학습 모델 활용: Huggingface Transformers

트랜스포머는 대규모 말뭉치로 미리 학습한 모델을 내려받아 재사용하는 생태계를 낳았습니다. Huggingface의 transformers 라이브러리는 GPT, BERT 등 대표적인 사전학습 모델을 통일된 인터페이스로 제공합니다. 먼저 설치된 버전을 확인합니다.

import torch
import transformers

print('PyTorch', torch.__version__)
print('Huggingface Transformers', transformers.__version__)

GPT-2를 내려받아 봅시다. AutoModelForCausalLM은 다음 단어를 예측하는 인과적 언어 모델 형태로 가중치를 불러옵니다.

from transformers import AutoModelForCausalLM

model_id = 'openai-community/gpt2'
gpt2 = AutoModelForCausalLM.from_pretrained(model_id)
print(gpt2)

모델 구조를 출력하면 우리가 이 장에서 쌓아 온 요소들 — 단어 임베딩(wte), 위치 임베딩(wpe), 멀티 헤드 어텐션과 MLP로 된 여러 개의 블록 — 이 그대로 들어 있음을 확인할 수 있습니다. 단어 임베딩 계층(wte)에서 이 모델의 어휘 수와 벡터 차원을 직접 읽어 볼 수 있습니다.

print(f'어휘수, 벡터차원 = {gpt2.transformer.wte}')

이처럼 트랜스포머는 어텐션이라는 단일한 기제 위에서 출발했습니다. 문맥에 따라 의미가 달라지는 단어를 표현하려는 문제의식이 셀프 어텐션으로 이어졌고, RNN을 걷어내고 어텐션만으로 인코더와 디코더를 구성한 트랜스포머가 그 위에 세워졌습니다. 멀티 헤드, 잔류 연결, 층 정규화, 위치 인코딩이라는 부품들이 결합해 오늘날 자연어 처리의 표준 구조를 이루며, GPT를 비롯한 대규모 사전학습 모델이 모두 이 구조를 토대로 삼습니다.