지난 시간에는 트랜스포머 인코더를 활용해 텍스트를 분류하는 방법을 알아봤습니다. 이번 시간에는 텍스트 생성에 뛰어난 트랜스포머 디코더의 원리를 살펴보고, GPT-2 Base와 유사한 구조의 언어 모델을 직접 구현해보겠습니다.


1. 원본 트랜스포머의 디코더

원본 트랜스포머는 입력된 문장을 인코더에 넣어 문장의 의미를 표현하고 이전까지 생성한 단어와 문장의 의미를 가지고 디코더가 다음 단어를 예측하는 구조입니다

예를 들어 영어 문장을 한국어로 번역한다고 가정을 하면 먼저 인코더가 영어 문장을 읽고 단어들의 관계와 문장의 의미를 벡터로 표현합니다 그 다음 디코더는 인코더가 만든 정보를 참고하면서 번역 문장을 한 토큰씩 생성합니다

이때 디코더는 다음 두 정보를 함께 이용합니다.

  1. 인코더가 이해한 영어 문장의 의미
  2. 지금까지 생성한 토큰의 정보

이렇게 원본 트랜스포머 디코더는 이를 위해 크게 두 종류의 어텐션이 있습니다

  • 마스크드 셀프 어텐션: 이전까지 나온 출력 토큰들의 관계를 확인합니다.
  • 크로스 어텐션: 인코더가 처리한 입력 문장의 정보를 확인합니다.


2. GPT와 같은 디코더 전용 모델

GPT 계열 모델은 원본 트랜스포머에서 인코더를 사용하지 않고 주로 디코더 구조만 사용합니다

프롬프트 → 디코더 → 다음 토큰 예측 → 예측 결과를 다시 입력 → 다음 토큰 예측

이러한 방식을 자기회귀 생성, Autoregressive Generation이라고 합니다

이러한 디코더 언어 모델은 현재까지 입력된 토큰을 보고 바로 다음 토큰을 맞히도록 학습을 합니다

예를 들어 학습 문장이 “나는 학교에 간다” 를 학습시켜본다고 해보겠습니다 그러면 모델의 입력과 정답은 한 칸씩 밀려 있습니다

입력 토큰 맞혀야 하는 다음 토큰
나는 학교에
나는 학교에 간다
나는 학교에 간다 문장 종료

실제 학습에서는 문장 전체를 한꺼번에 넣지만, 개념적으로는

입력: 나는 학교에 간다
정답: 학교에 간다 [종료]

이를 시프트된 타깃, Shifted Target 이라고 합니다

모델은 자신이 예측한 토큰과 실제 다음 토큰의 차이를 계산하고 실제 정답의 확률이 높아지도록 학습을 진행합니다

또한 일반적인 지도학습에서는 사람이 직접 정답 레이블을 만들어야 하지만 특별히 언어 모델에서는 텍스트 자체로부터 입력과 정답을 자동으로 만들 수 있습니다

사람이 별도의 정답을 작성하지 않아도 원래 문장에서 다음 토큰을 정답으로 가져올 수 있습니다

이처럼 데이터 자체에서 학습에 필요한 정답을 만들어 사용하는 방식자기지도 학습(Self-supervised Learning)이라고 합니다.


3. 코잘 언어 모델링

여기서 코잘 언어 모델링이라는 개념이 나오는데 인과적 언어 모델링, Causal Language Modeling이라고 부릅니다

핵심은 현재 토큰을 예측할 때 이전 토큰만 볼 수 있고 미래 토큰은 볼 수 없다는 개념입니다

예를 들어 “나는 오늘 학교에 갔다”라는 문장이 있다고 합시다 이때에 학교에 라는 토큰을 처리할 때 볼 수 있는 범위는 “나는 오늘 학교에” 입니다 이를 확률로 표현하면 다음과 같습니다

P(문장)
= P(나는)
× P(오늘 | 나는)
× P(학교에 | 나는, 오늘)
× P(갔다 | 나는, 오늘, 학교에)

각 토큰은 현재 위치까지의 토큰들을 조건으로 다음 토큰의 확률을 계산합니다.

그런데 이런 생각이 들 수 있습니다 왜 마스크드 멀티 헤드 어텐션이 필요할까?

일반적인 셀프 어텐션에서는 문장의 모든 토큰이 서로를 볼 수 있습니다 그래서 마스크가 없다면 학교에를 처리할 때 뒤에 있는 정답인 갔다까지 볼 수 있습니다 실제 문장 생성 확인에서 미래 단어가 존재하지 않으므로 잘못된 학습이 됩니다

예를 들어 마스크가 없는 경우는 모든 토큰을 볼 수 있지만 코잘 마스크를 적용한 경우 한정적으로 볼 수 있게 됩니다

현재 토큰 볼 수 있는 토큰
나는 나는
오늘 나는, 오늘
학교에 나는, 오늘, 학교에
갔다 나는, 오늘, 학교에, 갔다

이를 행렬로 표현하면 다음과 같은 삼각형 구조가 됩니다.

Query / Key 나는 오늘 학교에 갔다
나는 O X X X
오늘 O O X X
학교에 O O O X
갔다 O O O O

이러한 마스크를 우리는 코잘 마스크 또는 미래 마스크라고 합니다 정확히는 미래 위치의 어텐션 점수에 매우 작은 값인 음의 무한대에 가까운 값을 더합니다 그 후 소프트맥스 함수를 적용하면 해당 위치의 어텐션 확률이 사실상 0이 됩니다

이렇게 하나의 어텐션만 사용하는 것이 아니라 여러 개의 어텐션 헤드를 동시에 사용하는데 각 헤드는 문장에서 서로 다른 관계에 집중할 수 있습니다

예를 들어 “성준은 준현에게 책을 주었다” 라는 문장이 있다면

각 헤드는 다음처럼 다른 관계를 학습할 수 있습니다

  • 헤드 1: 성준 ↔ 주었다 — 주어와 서술어 관계
  • 헤드 2: 준현에게 ↔ 주었다 — 대상과 행동 관계
  • 헤드 3: 책을 ↔ 주었다 — 목적어와 행동 관계

따라서 마스크드 멀티 헤드 어텐션은 여러 관점에서 토큰 사이의 관계를 분석하고 미래 토큰은 보지 못하게 제한을 합니다


4. 학습할 때와 생성할 때의 차이

학습할 때에는 정답 문장 전체가 이미 존재합니다 하지만 코잘 마스크를 사용하기 때문에 각 토큰은 미래 토큰을 볼 수 없습니다.

생성할 때에는 미래의 문장이 아직 없으므로 실제로 한 토큰씩 생성을 하게 됩니다 따라서 일반적으로 학습은 병렬처리가 가능하지만 생성은 앞 토큰의 결과가 필요하므로 순차적으로 진행되게 됩니다

트랜스포머 디코더는 마스크드 멀티 헤드 어텐션을 사용해 미래의 정답을 보지 않고, 이전 토큰들의 관계만 분석하면서 다음 토큰을 예측하도록 학습합니다.

그러면 이제는 패딩 마스크와 코잘 마스크를 결합해서 모델이 의미 없는 패딩 토큰과 아직 등장하지 않은 미래 토큰을 모두 참조하지 못하도록 최종 어텐션 마스크를 만들어보겠습니다

코잘 마스킹은 입력되는 토큰의 값과 무관하며, 토큰의 길이에 따라 달라집니다 입력되는 토큰의 개수가 10개이면 (10,10) 크기의 마스크 행렬을 만들어야 합니다

그러면 먼저 마스크 행렬을 하나 만들어 행렬의 주 대각선 위쪽에 있는 모든 값을 0으로 만들어보겠습니다


import keras
from keras import layers
import keras_nlp

def make_causal_mask(seq_len):
  n_hori = keras.ops.arange(seq_len)
  n_vert = keras.ops.expand_dims(n_hori,axis=1)
  mask = n_vert >= n_hori
  return mask

여기에서는 expand_dims() 함수를 활용해서 2차원 텐서를 바뀐 후 비교 연산을 통해서 결과를 얻게 됩니다

길이 5의 코잘 마스크 행렬

최대 시퀀스 길이가 5이고 실제 토큰이 3개라면 패딩 마스크는 [1, 1, 1, 0, 0]이 됩니다

여기서 앞의 세 위치는 실제 토큰이고 네 번째와 다섯 번째 위치는 길이를 맞추기 위해 추가한 패딩 토큰입니다 따라서 모든 토큰이 네 번째와 다섯 번째 패딩 위치를 참조하지 못하도록 가려야 합니다

코잘 마스크는 미래 토큰을 가리고 패딩 마스크는 실제 내용이 없는 패딩 토큰을 가립니다 두 마스크는 서로 다른 역할을 하므로 최종 어텐션 마스크에서는 두 조건을 함께 적용해야 합니다

이렇게 코잘 마스크와 패딩 마스크를 합치는 간단한 방법은 keras.ops.minimum() 함수를 사용하는 것입니다.


padding_mask = [1,1,1,0,0]
keras.ops.minimum(causal_mask, padding_mask)

해당 함수를 사용하면 위에서 만들었던 causal_mask의 불리언 값을 0과 1로 변환하여 각 행마다 padding_mask와 비교합니다

코잘 마스크와 패딩 마스크를 결합한 결과

마지막으로는 배치 차원을 고려하여 최종 어텐션 마스크를 만드는 함수 make_attention_mask() 를 살펴보겠습니다


def make_attention_mask(padding_mask):
  # padding_mask의 크기가 (2,5)라고 가정
  batch_size, seq_len = keras.ops.shape(padding_mask)
  # causal_mask의 크기는 (5,5)가 된다
  causal_mask = make_causal_mask(seq_len)
  # 배치 차원을 추가해 (2,5,5)로 만든다
  causal_mask = keras.ops.broadcast_to(
      causal_mask, (batch_size, seq_len, seq_len))
  # 브로드캐스팅을 위해 padding_mask의 크기를 (2,1,5)로 
  padding_mask = keras.ops.expand_dims(padding_mask, axis=1)
  return keras.ops.minimum(causal_mask,padding_mask)

쉽게 이해하기 위해서 padding_mask의 크기를 (2,5)라고 가정하겠습니다

이는 하나의 배치에 문장 2개가 들어 있고, 각 문장의 최대 토큰 길이가 5개라는 의미입니다 마스크에서 1은 실제 토큰, 0은 패딩 토큰을 나타냅니다

예를 들어 패딩 마스크가 다음과 같습니다


[ 
[1, 1, 1, 0, 0], 
[1, 1, 1, 1, 0] 
]

첫 번째 문장은 실제 토큰이 3개이고, 뒤의 2개는 패딩 토큰이고 두 번째 문장은 실제 토큰이 4개이고 마지막 1개가 패딩 토큰입니다

batch_size, seq_len = keras.ops.shape(padding_mask)

padding_mask의 크기가 (2,5)이므로 batch_size는 2 seq_len은 5가 됩니다 그 다음 길이가 5인 시퀀스에 적용할 코잘 마스크를 생성합니다

seq_len이 5이므로(5,5) 크기의 코잘 마스크가 생성됩니다

행은 정보를 찾으려는 현재 토큰인 query의 위치를 의미하고 열은 현재 토큰이 참조하려는 key의 위치를 의미합니다

코잘 마스크만 적용하면 세 번째 토큰은 첫 번째부터 세 번째 토큰까지만 볼 수 있고 자신보다 뒤에 있는 네 번째와 다섯 번째 토큰은 미래 위치이므로 볼 수 없습니다

여기에 [1, 1, 1, 0, 0] 형태의 패딩 마스크를 함께 적용하면 모든 토큰은 네 번째와 다섯 번째 위치를 참조할 수 없습니다 첫 번째 문장에서 이 두 위치는 실제 토큰이 아니라 패딩 토큰이기 때문입니다

그러나 현재 코잘 마스크의 크기는 (5,5)이고 배치에는 문장이 2개 있어서 따라서 각 문장에 동일한 코잘 마스크를 적용할 수 있도록 배치 차원을 추가해줘야 합니다

그냥 개념적으로는 동일한 (5,5) 코잘 마스크가 각 문장에 하나씩 적용됩니다

이어서 다음으로 패딩 마스크에도 차원을 확장하는데 기존 마스크는 (2,5)입니다


[ 
[1, 1, 1, 0, 0], 
[1, 1, 1, 1, 0] 
]

하지만 새로운 차원을 추가하면 (2,1,5)가 됩니다 첫 번째 문장을 기준으로 하면 [[1, 1, 1, 0, 0]]가 됩니다

가운데 크기를 1로 만드는 이유는 코잘 마스크와 결합할 때 브로드캐스팅을 적용하기 위해서 입니다


코잘 마스크: (2, 5, 5) 
패딩 마스크: (2, 1, 5)

패딩 마스크의 가운데 차원인 1이 시퀀스 길이인 5로 확장되면서 동일한 패딩 정보가 모든 행에 적용됩니다

첫 번째 문장의 패딩 마스크가 다음과 같다면,


[1, 1, 1, 0, 0]

브로드캐스팅된 형태는 개념적으로 다음과 같습니다


[
 [1, 1, 1, 0, 0],
 [1, 1, 1, 0, 0],
 [1, 1, 1, 0, 0],
 [1, 1, 1, 0, 0],
 [1, 1, 1, 0, 0]
]

모든 토큰이 네 번째와 다섯 번째 위치의 패딩 토큰을 참조하지 못하도록 동일한 마스크가 각 행에 적용되는 것입니다 그리고 minimum() 연산을 통해서 코잘 마스크와 패딩 마스크를 결합합니다 같은 위치에 있는 값 중 작은 값을 선택하면 됩니다

그렇게 최종 어텐션 마스크를 생성하게 됩니다 이러한 최종 마스크는 현재 토큰보다 뒤에 있는 미래 토큰은 참조할 수 없고 실제 문장이 포함되지 않은 패딩 토큰도 참조할 수 없습니다

최종 어텐션 마스크의 크기는 (2,5,5)이며 각 차원의 의미는 (batch_size, query_length, key_length) 입니다

결국 이 함수는 코잘 마스크와 패딩 마스크를 결합하여 각 토큰이 자신보다 뒤에 있는 미래 토큰과 실제 내용이 없는 패딩 토큰을 참조하지 못하도록 합니다

이렇게 생성한 최종 어텐션 마스크는 디코더의 마스크드 셀프 어텐션에 전달됩니다


5. 트랜스포머 디코더 모듈 만들기

앞에서 코잘 마스크와 패딩 마스크를 결합하여 최종 어텐션 마스크를 만들었습니다 이제 이 마스크를 사용해 미래 토큰과 패딩 토큰을 참조하지 않는 트랜스포머 디코더 모듈을 구현해보겠습니다

이번에 구현할 디코더는 GPT와 같은 디코더 전용 모델에 사용되는 구조입니다 따라서 인코더의 출력을 참고하는 크로스 어텐션은 사용하지 않고 아래와 같은 요소로 구성됩니다

  1. 층 정규화
  2. 마스크드 멀티 헤드 셀프 어텐션
  3. 드롭아웃
  4. 잔차 연결
  5. 피드 포워드 네트워크

입력은 층 정규화 → 마스크드 멀티 헤드 셀프 어텐션 → 드롭아웃 → 잔차 연결 → 층 정규화 → 피드 포워드 네트워크 → 드롭아웃 → 잔차 연결 → 출력 순서로 처리됩니다.

먼저 어텐션 마스크를 케라스 층으로 만들면 make_attention_mask() 함수는 패딩 마스크와 코잘 마스크를 결합하여 최종 어텐션 마스크를 반환합니다

이를 케라스 모델 내부에서 사용하기 위해서 간단한 사용자 정의 층으로 만들어보겠습니다


class AttentionMask(keras.layers.Layer):
  def call(self, padding_mask):
    return make_attention_mask(padding_mask)

AttentionMask 클래스는 keras.layers.Layer를 상속받습니다. 그리고 call() 메서드에서 앞서 만든 make_attention_mask() 함수를 호출합니다 따라서 크기가 (batch_size, seq_len)인 패딩 마스크를 입력하면 코잘 마스크와 결합된 (batch_size, seq_len, seq_len) 크기의 최종 어텐션 마스크를 반환합니다.


attention_mask = AttentionMask()(padding_mask)

이렇게 만든 어텐션 마스크는 뒤에서 MultiHeadAttention 층의 attention_mask 매개변수로 전달됩니다

이제 트랜스포머 디코더 모듈을 구현해보겠습니다


def transformer_decoder(
    x,
    padding_mask,
    dropout,
    activation="relu",
    norm_first=True,
):
    # 패딩 마스크와 코잘 마스크를 결합
    attention_mask = AttentionMask()(padding_mask)

    # 첫 번째 잔차 연결을 위해 입력을 저장
    residual = x

    # 각 어텐션 헤드가 사용할 차원을 계산
    key_dim = hidden_dim // num_heads

    # Pre-Norm을 사용하는 경우 어텐션 전에 정규화
    if norm_first:
        x = layers.LayerNormalization()(x)

    # 마스크드 멀티 헤드 셀프 어텐션
    x = layers.MultiHeadAttention(
        num_heads=num_heads,
        key_dim=key_dim,
        dropout=dropout,
    )(
        query=x,
        value=x,
        attention_mask=attention_mask,
    )

    x = layers.Dropout(dropout)(x)

    # 첫 번째 잔차 연결
    x = x + residual

    # Post-Norm을 사용하는 경우 잔차 연결 후 정규화
    if not norm_first:
        x = layers.LayerNormalization()(x)

    # 두 번째 잔차 연결을 위해 현재 값을 저장
    residual = x

    # Pre-Norm을 사용하는 경우 피드 포워드 네트워크 전에 정규화
    if norm_first:
        x = layers.LayerNormalization()(x)

    # 위치별 피드 포워드 네트워크
    x = layers.Dense(
        hidden_dim * 4,
        activation=activation,
    )(x)

    x = layers.Dense(hidden_dim)(x)
    x = layers.Dropout(dropout)(x)

    # 두 번째 잔차 연결
    x = x + residual

    # Post-Norm을 사용하는 경우 잔차 연결 후 정규화
    if not norm_first:
        x = layers.LayerNormalization()(x)

    return x

코드를 설명을 조금 하면 먼저 입력으로 전달된 패딩 마스크를 이용하여 최종 어텐션 마스크를 만들게 됩니다 이 마스크는 두 가지 조건이 포함되어 있는데

  • 현재 위치보다 뒤에 있는 미래 토큰을 가리는 코잘 마스크
  • 실제 내용이 없는 패딩 토큰을 가리는 패딩 마스크

따라서 디코더는 미래의 정답과 패딩 토큰을 참고하지 않고 현재까지 등장한 실제 토큰만을 이용해 어텐션을 계산하게 됩니다

그래서 어텐션 연산을 수행하기 전에 현재 입력 xresidual에 저장하고 이 값은 어텐션 연산이 끝난 뒤 다음과 같이 다시 더해집니다

x = x + residual

이러한 구조를 잔차 연결, Residual Connection이라고 합니다 잔차 연결은 층을 통과하기 전의 정보를 이후 출력에 직접 더해주는 방법입니다 이를 통해 모델이 깊어져도 기존 정보와 기울기가 비교적 안정적으로 전달될 수 있습니다

잔차 연결에서는 두 텐서를 서로 더해야 하므로 입력과 출력의 마지막의 차원이 같아야 합니다 따라서 디코더의 입력과 출력은 모두 hidden_dim 크기를 유지합니다

hidden_dim은 하나의 토큰을 표현하는 전체 임베딩 차원이고 num_heads는 어텐션 헤드의 개수입니다

예를 들어 각각 768, 12 라고 가정을 하면 각 어텐션 헤드가 처리하는 차원은 64차원으로 12개의 어텐션 헤드가 각각 64차원의 정보를 처리하게 됩니다

각 헤드는 서로 다른 관점에서 토큰 사이의 관계를 학습하고 마지막에는 각 헤드의 결과가 다시 결합됩니다

norm_first는 층 정규화를 어느 위치에 적용할지 결정하는 매개변수로 norm_first=True라면 어텐션이나 피드 포워드 네트워크를 통과하기 전에 층 정규화를 적용합니다

층 정규화 -> 어텐션 -> 잔차 연결 이러한 구조를 Pre-Norm이라고 합니다 반대로 False라면 잔차 연결을 수행한 뒤 층 정규화를 적용합니다 이러한 구조는 Post-Norm이라고 합니다

해당 코드에서는 하나의 함수를 이용해 두 가지 구조를 모두 선택할 수 있도록 만들었습니다


    # 마스크드 멀티 헤드 셀프 어텐션
    x = layers.MultiHeadAttention(
        num_heads=num_heads,
        key_dim=key_dim,
        dropout=dropout,
    )(
        query=x,
        value=x,
        attention_mask=attention_mask,
    )

여기에서는 멀티 헤드 셀프 어텐션을 수행합니다 queryvalue에 동일한 x를 전달했기 때문에 입력 문장 내부의 토큰들이 서로의 관계를 계산하는 셀프 어텐션이 됩니다

이때 key를 별도로 전달하지 않으면 valuekey로도 사용됩니다 따라서 개념적으로 모두 x 가 됩니다 그리고 attention_mask에서 앞서 만든 최종 어텐션 마스크를 전달합니다

일반적인 셀프 어텐션은 문장의 모든 토큰을 참조할 수 있지만 여기서는 코잘 마스크가 적용되어 자신보다 뒤에 있는 미래 토큰을 참조할 수 없습니다

또한 패딩 마스크도 함께 적용되므로 실제 내용이 없는 패딩 토큰도 참조하지 않습니다

따라서 해당 층이 디코더의 마스크드 멀티 헤드 셀프 어텐션 역할을 합니다

어텐션 결과에는 드롭아웃을 적용합니다 드롭아웃은 학습 중 일부 값을 무작위로 제외하여 모델이 특정 정보에 지나치게 의존하는 것을 줄여줍니다 그 다음 어텐션을 통과하기 전의 입력인 residual를 더합니다

그렇게 하면 어텐션을 통해 새롭게 계산한 정보와 기본 입력 정보를 함께 다음 단계로 전하게 됩니다

어텐션 연산이 끝나면 각 토큰은 다른 토큰과의 관계를 반영한 정보를 가지게 됩니다 이제 각 토큰의 표현을 개별적으로 변환하기 위해 피드 포워드 네트워크를 통과시킵니다

피드 포워드 네트워크는 코드에서 두 개의 밀집층으로 구성되는데 차원을 확장했다가 다시 원래 크기로 줄입니다 중간 차원을 크게 확장하면 모델이 각 토큰의 특징을 더 풍부하게 변환할 수 있습니다

그리고 마지막 차원을 다시 hidden_dim으로 줄여야 기존 입력과 잔차 연결을 수행할 수 있습니다

활성화 함수는 기본적으로 relu를 사용하게 설정했지만 GPT 계열에서는 gelu를 자주 사용합니다

그 다음에는 두 번째 잔차 연결을 하게 됩니다 피드 포워드 네트워크 출력에도 드롭아웃을 적용하고 네트워크를 통과하기 전에 입력을 더합니다 그래서 하나의 디코더 블록에는 총 두 번의 잔차연결이 됩니다 마지막으로 필요한 위치에 층 정규화를 적용한 뒤 결과를 반환합니다

이렇게 완성된 transformer_decoder() 함수는 입력과 동일한 형태의 출력을 반환합니다 입력의 크기가 같다면 디코더를 통과한 출력의 크기도 동일합니다 그래서 디코더 블록을 여러 개 연속으로 쌓을 수 있습니다

정리를 하면 이번에 만든 트랜스포머 디코더 모듈은 마스크드 멀티 헤드 셀프 어텐션을 통해 이전 토큰들의 관계를 분석하고 피드 포워드 네트워크를 통해 각 토큰의 표현을 변환합니다 그리고 층 정규화와 잔차 연결을 사용하여 디코더 블록을 안정적으로 쌓을 수 있도록 구성합니다

이제 완성한 디코더 모듈을 여러 층으로 쌓고 토큰 임베딩과 위치 임베딩을 연결하여 실제 GPT-2 구조의 언어 모델을 만들어보겠습니다


6. GPT-2 모델 만들기

앞에서 트랜스포머 디코더 모듈을 완성했습니다

이제 토큰 임베딩과 위치 임베딩을 연결하고 디코더 블록을 여러 층으로 쌓아 GPT-2 구조의 언어 모델을 만들어보겠습니다

이번에 구현할 모델은 GPT-2 Base와 유사한 하이퍼파라미터를 사용합니다. 먼저 모델을 만드는 데 필요한 하이퍼파라미터를 설정해야 합니다


vocab_size = 50257 
num_layers = 12 
num_heads = 12 
hidden_dim = 768 
dropout = 0.1 
activation = "gelu" 
max_seq_len = 1024

GPT-2 Base는 50257개의 토큰으로 구성된 어휘 사전을 사용하고 768차원의 은닉 표현을 사용합니다 또한 하나의 디코더 블록에는 12개의 어텐션 헤드가 있으며 이러한 디코더 블록을 12개 쌓습니다. 따라서 각 어텐션 헤드는 64차원을 처리합니다.


token_ids = keras.Input(
    shape=(None,),
    dtype="int32",
    name="token_ids",
)

padding_mask = keras.Input(
    shape=(None,),
    dtype="bool",
    name="padding_mask",
)

모델은 두 가지 값을 입력으로 받습니다

token_ids는 토큰마다 부여된 정수 형태의 ID인데 예를 들어 하나의 문장이 다음과 같이 토큰화 되어 있다고 해보겠습니다

[1024, 35, 827, 91]

각 숫자는 GPT-2의 어휘사전에 등록된 특정 토큰을 의미합니다

padding_mask는 입력에서 실제 토큰과 패딩 토큰을 구분하는 역할을 합니다 [1,1,1,1,0,0] 있다면 1은 실제 토큰이고 0은 문장의 길이를 맞추기 위해 추가한 패딩 토큰입니다

두 입력 모두 문장마다 길이가 달라질 수 있으므로 shape=(None,)으로 설정됩니다 여기서 None은 모델이 실행될 때 시퀀스 길이가 결정된다는 의미입니다

그 다음은 정수 형태의 토큰 ID를 벡터로 변환하기 위한 토큰 임베딩을 만듭니다


from keras_nlp.layers import ReversibleEmbedding

token_embedding_layer = ReversibleEmbedding(
    input_dim=vocab_size,
    output_dim=hidden_dim,
)

token_embedding = token_embedding_layer(token_ids)

ReversibleEmbedding은 정수 형태의 토큰 ID를 hidden_dim 크기의 벡터로 변환합니다 지금 보면 768이므로 각각의 토큰은 768차원의 벡터로 표현됩니다

입력의 크기가 (batch_size, seq_len) 이라면 토큰 임베딩을 통과한 결과는 (batch_size, seq_len, 768) 이 됩니다 하지만 셀프 어텐션은 토큰의 순서를 자체적으로 구분하지 못합니다 그래서 첫 번째 토큰과 두 번째 토큰이 서로 다른 위치에 있다는 정보를 모델에 전달하기 위해 위치 임베딩을 추가해야 합니다


pos_embedding = keras_nlp.layers.PositionEmbedding(
    sequence_length=max_seq_len,
)(token_embedding)

위치 임베딩 역시 토큰 임베딩과 동일하게 768차원의 벡터를 생성합니다 이제 토큰의 의미를 나타내는 토큰 임베딩과 토큰의 순서를 나타내는 위치 임베딩을 더합니다


x = token_embedding + pos_embedding

두 임베딩을 결합하면 각 토큰은 자신의 의미와 문장 안에서의 위치 정보를 함께 가지게 됩니다 그 다음에는 결합된 임베딩에 드롭아웃을 적용합니다


x = layers.Dropout(dropout)(x)

이제 앞에서 만든 transformer_decoder() 함수를 이용해 디코더 블록을 12개 쌓습니다.


for _ in range(num_layers):
    x = transformer_decoder(
        x,
        padding_mask,
        dropout,
        activation,
    )

num_layers가 12이므로 반복문이 실행되면서 입력은 총 12개의 트랜스포머 디코더 블록을 차례대로 통과합니다

각 디코더 블록에서는 마스크드 멀티 헤드 셀프 어텐션을 통해 이전 토큰들과의 관계를 분석하고 피드 포워드 네트워크를 통해 각 토큰의 표현을 변환합니다

디코더 블록의 입력과 출력은 모두 (batch_size, seq_len, hidden_dim) 을 유지하며 따라서 같은 디코더 블록을 여러 번 연속으로 연결할 수 있습니다

모든 디코더 블록을 통과한 결과에는 마지막 층 정규화를 적용합니다


x = layers.LayerNormalization()(x)

현재 x의 마지막 차원은 768 이지만 언어 모델이 다음 토큰을 예측하려면 768차원의 출력을 어휘사전 크기인 50257차원으로 변환해야 합니다

일반적으로는 밀집층을 사용할 수 있지만 우리는 앞에서 만든 ReversibleEmbedding을 반대 방향으로 다시 사용합니다


outputs = token_embedding_layer(
    x,
    reverse=True,
)

입력 단계에서는 토큰 ID를 768차원의 임베딩으로 썼지만 마지막 출력 단계에서는 reverse=True를 사용하여 반대 방향으로 변환합니다

이처럼 입력 임베딩의 가중치를 출력층에서도 다시 사용하는 방식을 가중치 공유 또는 Weight Tying이라고 합니다

입력 임베딩과 출력층이 같은 가중치를 공유하기 때문에 별도의 출력층 가중치를 추가하지 않고도 각 위치에서 다음 토큰에 대한 점수를 계산할 수 있습니다

현재 vocab_size가 50,257이므로 각 토큰 위치마다 50,257개의 로짓이 출력됩니다 각 위치의 로짓값은 서로 다르며, 어휘사전에 포함된 각 토큰이 다음 토큰으로 등장할 가능성을 나타냅니다


model = keras.Model(
    inputs={
        "token_ids": token_ids,
        "padding_mask": padding_mask,
    },
    outputs=outputs,
)

model.summary()

이제 입력과 출력을 연결하여 전체 GPT-2 모델을 완성합니다

GPT-2 유사 모델의 Keras 모델 요약

이렇게 토큰 임베딩과 위치 임베딩을 결합하고 트랜스포머 디코더 블록을 12개 쌓아 GPT-2 Base와 유사한 구조의 언어 모델을 만들어봤습니다.

모델에 토큰 ID와 패딩 마스크를 입력하면 각 토큰 위치에서 다음에 등장할 수 있는 모든 토큰의 점수가 출력됩니다

학습할 때는 이 출력과 한 칸씩 이동한 정답 토큰을 비교하고 실제 다음 토큰에 해당하는 확률이 높아지도록 모델의 가중치를 조정합니다


마무리

이번 시간에는 트랜스포머 디코더가 다음 토큰을 예측하는 원리부터 코잘 마스크와 패딩 마스크를 결합하는 방법 그리고 디코더 블록을 여러 층으로 쌓아 GPT-2 구조의 언어 모델을 만드는 과정까지 알아봤습니다

다음 시간에는 인코더가 입력 문장의 핵심 의미를 이해하고 디코더가 이를 바탕으로 새로운 문장을 생성하는 트랜스포머 인코더-디코더 모델을 만들어 실제 텍스트를 요약해보도록 하겠습니다

지금까지 에코노베이션 29기 안성준이였습니다