이미지 로딩 중...

바닥부터 만드는 ChatGPT 14편 마스킹 손실 함수 구현 - 슬라이드 1/9
A

AI Generated

2025. 11. 12. · 5 Views

바닥부터 만드는 ChatGPT 14편 마스킹 손실 함수 구현

ChatGPT와 같은 Transformer 모델에서 필수적인 마스킹 손실 함수 구현 방법을 배웁니다. 패딩 토큰을 올바르게 처리하고, 실제 토큰에만 손실을 계산하는 방법을 단계별로 알아봅니다.


목차

  1. 마스킹 손실 함수의 필요성
  2. 패딩 토큰과 마스킹 개념
  3. 마스크 생성 함수 구현
  4. 마스킹된 크로스 엔트로피 손실
  5. 손실 정규화 방법
  6. 배치 처리에서의 마스킹
  7. 그래디언트 흐름 제어
  8. 실전 마스킹 손실 함수 완성

1. 마스킹 손실 함수의 필요성

시작하며

여러분이 ChatGPT처럼 문장을 생성하는 모델을 훈련할 때 이런 상황을 겪어본 적 있나요? 배치로 여러 문장을 처리하는데, 문장 길이가 모두 달라서 짧은 문장에는 패딩(padding)을 추가해야 합니다.

그런데 이 패딩 토큰까지 손실 계산에 포함되면 어떻게 될까요? 이런 문제는 실제 NLP 모델 훈련에서 치명적입니다.

패딩 토큰은 의미 없는 값이므로, 이것까지 학습하면 모델이 잘못된 패턴을 배우게 됩니다. 예를 들어, "안녕하세요 [PAD] [PAD] [PAD]"라는 입력에서 [PAD] 토큰의 예측 오차까지 모델에 영향을 주면 실제 단어인 "안녕하세요"의 학습이 희석됩니다.

바로 이럴 때 필요한 것이 마스킹 손실 함수입니다. 실제 토큰에만 손실을 계산하고, 패딩 토큰은 완전히 무시하여 모델이 진짜 언어 패턴만 학습하도록 만들어줍니다.

개요

간단히 말해서, 마스킹 손실 함수는 특정 위치의 토큰을 손실 계산에서 제외하는 메커니즘입니다. 일반적인 크로스 엔트로피 손실은 모든 위치의 예측 오차를 동등하게 취급합니다.

하지만 실제 시퀀스 데이터에서는 패딩 토큰, 특수 토큰, 혹은 무시하고 싶은 토큰이 존재합니다. 마스킹 손실 함수를 사용하면 배치 내 모든 문장이 다른 길이를 가져도 효율적으로 처리할 수 있습니다.

전통적인 방법에서는 모든 문장을 같은 길이로 만들고 전체에 손실을 계산했다면, 이제는 마스크로 실제 데이터만 선택적으로 학습할 수 있습니다. 이 개념의 핵심 특징은 세 가지입니다.

첫째, 선택적 학습으로 의미 있는 토큰만 학습합니다. 둘째, 배치 효율성으로 다양한 길이의 문장을 동시 처리합니다.

셋째, 정확한 정규화로 실제 토큰 개수만큼 나누어 공정한 손실 값을 계산합니다. 이러한 특징들이 대규모 언어 모델 훈련에서 필수적인 이유입니다.

코드 예제

import torch
import torch.nn.functional as F

def masked_loss_basic(logits, targets, pad_token_id=0):
    # logits: (batch_size, seq_len, vocab_size)
    # targets: (batch_size, seq_len)

    # 마스크 생성: 패딩이 아닌 위치는 1, 패딩은 0
    mask = (targets != pad_token_id).float()

    # 크로스 엔트로피 손실 계산 (reduction='none'으로 위치별 손실 유지)
    loss = F.cross_entropy(
        logits.view(-1, logits.size(-1)),
        targets.view(-1),
        reduction='none'
    )

    # 마스크 적용: 패딩 위치의 손실은 0으로
    masked_loss = loss * mask.view(-1)

    # 실제 토큰 개수로 나누어 평균 계산
    return masked_loss.sum() / mask.sum()

설명

이것이 하는 일: 마스킹 손실 함수는 시퀀스의 특정 위치를 손실 계산에서 제외하여 모델이 의미 있는 토큰만 학습하도록 합니다. 첫 번째로, mask = (targets != pad_token_id).float() 부분이 핵심입니다.

타겟 시퀀스에서 패딩 토큰이 아닌 위치는 1로, 패딩 토큰 위치는 0으로 표시하는 마스크를 생성합니다. 이렇게 하는 이유는 나중에 손실 값에 곱했을 때 패딩 위치의 손실을 완전히 제거하기 위함입니다.

float() 변환은 이후 수치 계산에서 필요합니다. 두 번째로, F.cross_entropy(..., reduction='none')이 실행되면서 각 위치별로 개별 손실 값을 계산합니다.

일반적으로 reduction='mean'을 사용하면 자동으로 평균이 계산되지만, 우리는 마스킹 전에 각 위치의 손실이 필요하므로 'none'을 사용합니다. logits을 vocab_size 차원으로 펼치고, targets도 1차원으로 펼쳐서 계산합니다.

세 번째로, masked_loss = loss * mask.view(-1)에서 실제 마스킹이 적용됩니다. 손실 값에 마스크를 곱하면 패딩 위치(마스크 값 0)의 손실은 0이 되고, 실제 토큰 위치(마스크 값 1)의 손실만 남습니다.

이것이 선택적 학습의 핵심 메커니즘입니다. 마지막으로, masked_loss.sum() / mask.sum()이 올바른 평균을 계산합니다.

단순히 전체 토큰 수로 나누는 것이 아니라, 실제로 계산에 포함된 토큰 수(mask.sum())로 나눕니다. 이렇게 해야 짧은 문장과 긴 문장이 공정하게 비교됩니다.

예를 들어, 10개 토큰 중 3개가 패딩이면 7로 나누는 것이 맞습니다. 여러분이 이 코드를 사용하면 배치 내 문장 길이가 달라도 정확한 손실 계산이 가능하고, 모델이 패딩에 영향받지 않고 실제 언어 패턴만 학습하며, 훈련 안정성이 크게 향상되는 효과를 얻을 수 있습니다.

실전 팁

💡 pad_token_id는 tokenizer에 따라 다르므로 항상 확인하세요. BERT는 0, GPT-2는 보통 50256을 사용합니다. 잘못된 값을 사용하면 실제 토큰이 마스킹될 수 있습니다.

💡 mask.sum()이 0이 되는 경우(전체가 패딩)를 방지하려면 mask.sum() + 1e-8처럼 작은 값을 더해 division by zero를 막으세요.

💡 메모리 효율을 위해 mask를 bool 타입으로 저장하고 계산 시에만 float()로 변환하면 약 4배의 메모리를 절약할 수 있습니다.

💡 훈련 중 손실 값을 로깅할 때 마스킹되지 않은 손실과 마스킹된 손실을 함께 기록하면 디버깅이 훨씬 쉬워집니다.

💡 여러 개의 특수 토큰을 무시하려면 mask = ~torch.isin(targets, torch.tensor([pad_id, sep_id, cls_id]))처럼 isin을 활용하세요.


2. 패딩 토큰과 마스킹 개념

시작하며

여러분이 "안녕", "안녕하세요 반갑습니다"라는 두 문장을 동시에 처리하려 할 때 어떤 문제가 발생할까요? 첫 문장은 2개 토큰, 두 번째는 4개 토큰인데, 신경망은 고정된 크기의 입력을 요구합니다.

이런 문제는 모든 시퀀스 모델에서 발생하는 근본적인 도전입니다. 해결책은 짧은 문장에 특수한 "패딩 토큰"을 추가하여 길이를 맞추는 것입니다.

하지만 이 인위적인 토큰들이 모델 학습에 영향을 주면 안 됩니다. 바로 이럴 때 필요한 것이 마스킹 개념입니다.

실제 데이터와 패딩을 구분하고, 모델이 실제 데이터만 보도록 숨겨주는 메커니즘입니다.

개요

간단히 말해서, 패딩은 짧은 시퀀스를 길게 만드는 빈 공간이고, 마스킹은 그 빈 공간을 무시하는 기술입니다. 배치 처리에서는 모든 샘플이 같은 크기여야 GPU 연산이 가능합니다.

가장 긴 문장 길이에 맞춰 나머지를 패딩하는데, 이때 보통 0이나 특수 [PAD] 토큰을 사용합니다. 예를 들어, 최대 길이가 10이고 실제 문장이 6개 토큰이면 4개의 패딩을 추가하는 식입니다.

전통적인 RNN에서는 가변 길이를 직접 처리할 수 있었지만, 병렬 처리가 어려웠습니다. 이제는 패딩과 마스킹으로 고정 길이로 만들어 병렬 처리하면서도 가변 길이의 의미를 보존할 수 있습니다.

이 개념의 핵심은 세 가지입니다. 첫째, 패딩 토큰은 어휘 사전의 특별한 인덱스로 표현됩니다(보통 0).

둘째, 마스크는 실제/패딩을 나타내는 이진 텐서입니다(1과 0). 셋째, 어텐션, 손실, 평가 등 모든 단계에서 마스크가 일관되게 적용되어야 정확한 결과를 얻습니다.

코드 예제

import torch

def create_padding_mask(sequences, pad_token_id=0):
    # sequences: (batch_size, seq_len)
    # 패딩이 아닌 위치는 True, 패딩은 False
    mask = sequences != pad_token_id
    return mask

# 예시 사용
batch = torch.tensor([
    [5, 12, 87, 3, 0, 0, 0],    # 4개 실제 토큰 + 3개 패딩
    [23, 45, 0, 0, 0, 0, 0],     # 2개 실제 토큰 + 5개 패딩
    [8, 91, 34, 56, 78, 2, 0]    # 6개 실제 토큰 + 1개 패딩
])

mask = create_padding_mask(batch)
print("Mask shape:", mask.shape)  # (3, 7)
print("Mask:\n", mask)
# True는 실제 토큰, False는 패딩

# 실제 토큰 개수 계산
num_real_tokens = mask.sum(dim=1)
print("각 문장의 실제 토큰 수:", num_real_tokens)  # [4, 2, 6]

설명

이것이 하는 일: 패딩 마스크는 배치 내 각 토큰이 실제 데이터인지 패딩인지를 명확하게 표시하여 이후 모든 연산에서 참조할 수 있게 합니다. 첫 번째로, sequences != pad_token_id 비교 연산이 핵심입니다.

이것은 브로드캐스팅되어 텐서의 모든 원소와 pad_token_id를 비교하고, 같지 않으면 True(실제 토큰), 같으면 False(패딩)를 반환합니다. 이렇게 하는 이유는 불리언 마스크가 직관적이고 메모리 효율적이기 때문입니다(1바이트/값).

두 번째로, 예시 배치를 보면 세 문장이 모두 길이 7로 통일되어 있습니다. 첫 문장 [5, 12, 87, 3, 0, 0, 0]에서 처음 4개는 실제 토큰, 뒤 3개는 패딩입니다.

두 번째 문장은 2개만 실제 토큰이고 나머지 5개가 패딩입니다. 이런 불균형한 배치가 실제 훈련에서 매우 흔하게 발생합니다.

세 번째로, mask.sum(dim=1)은 각 문장의 실제 토큰 개수를 계산합니다. dim=1은 시퀀스 길이 차원을 따라 합산하므로, True(1)의 개수를 세는 것과 같습니다.

결과 [4, 2, 6]은 각 문장이 몇 개의 의미 있는 토큰을 가지는지 정확히 알려줍니다. 이 정보는 손실 정규화, 통계 계산 등에 필수적입니다.

네 번째로, 마스크의 데이터 타입을 상황에 맞게 변환할 수 있습니다. 불리언 마스크는 인덱싱에 사용하고(sequences[mask]), float 마스크는 수치 계산에 사용하며(loss * mask.float()), int 마스크는 카운팅에 사용합니다(mask.int().sum()).

PyTorch가 자동 변환을 지원하지만 명시적으로 하는 것이 더 안전합니다. 여러분이 이 코드를 사용하면 배치의 실제 데이터 분포를 정확히 파악할 수 있고, 메모리 효율적인 불리언 마스크로 저장하며, 이후 모든 연산에서 일관되게 참조하여 올바른 계산을 보장할 수 있습니다.

실전 팁

💡 마스크를 생성할 때 attention_mask와 loss_mask를 분리하세요. 어텐션에서는 False를 -inf로 변환하고, 손실에서는 0으로 변환하는 등 용도가 다릅니다.

💡 긴 시퀀스를 처리할 때는 동적 패딩을 사용하세요. 배치마다 최대 길이를 다르게 설정하면 불필요한 패딩을 줄여 메모리와 계산량을 절약합니다.

💡 mask.bool()과 mask.float()을 혼동하지 마세요. 인덱싱에는 bool, 곱셈 연산에는 float을 사용해야 에러가 없습니다.

💡 디버깅할 때 print(mask.sum(dim=1) / mask.shape[1])로 각 샘플의 패딩 비율을 확인하면 데이터 불균형을 쉽게 발견할 수 있습니다.


3. 마스크 생성 함수 구현

시작하며

여러분이 대규모 데이터셋으로 모델을 훈련할 때 이런 상황을 겪어본 적 있나요? 각 배치마다 마스크를 생성해야 하는데, 단순히 패딩만 체크하면 되는 줄 알았는데 실제로는 EOS 토큰, 특수 토큰, 어텐션 마스크 등 여러 종류의 마스킹이 필요합니다.

이런 복잡성은 실제 프로덕션 코드에서 자주 발생합니다. 각 마스크의 목적이 다르고, 결합 방식도 달라서 잘못 구현하면 모델이 이상하게 학습되거나 심지어 발산할 수 있습니다.

예를 들어, 디코더에서는 미래 토큰을 보지 못하게 하는 causal mask도 필요합니다. 바로 이럴 때 필요한 것이 체계적인 마스크 생성 함수입니다.

각 마스크의 목적을 명확히 하고, 재사용 가능하며 확장 가능한 함수로 구현하여 실수를 방지합니다.

개요

간단히 말해서, 마스크 생성 함수는 다양한 종류의 마스크를 올바른 형태와 데이터 타입으로 만들어주는 유틸리티입니다. 실제 Transformer 모델에서는 최소 3가지 마스크가 필요합니다.

패딩 마스크는 유효한 토큰을 구분하고, causal mask(lookahead mask)는 미래 정보 누출을 방지하며, attention mask는 특정 토큰 간 상호작용을 제어합니다. 각 마스크는 서로 다른 차원과 값 범위를 가집니다.

전통적인 방법에서는 매번 수동으로 마스크를 만들어 실수가 잦았다면, 이제는 표준화된 함수로 일관성을 유지하고 버그를 줄일 수 있습니다. 이 개념의 핵심 특징은 세 가지입니다.

첫째, 타입 안전성으로 bool, float, int 등 필요한 타입으로 정확히 생성합니다. 둘째, 차원 일치로 배치 크기, 시퀀스 길이를 자동으로 맞춥니다.

셋째, 결합 가능성으로 여러 마스크를 논리 연산으로 조합할 수 있습니다. 이러한 특징들이 안정적인 훈련을 가능하게 합니다.

코드 예제

import torch

def create_padding_mask(seq, pad_token_id=0):
    """패딩 마스크: (batch, seq_len) -> (batch, seq_len)"""
    return (seq != pad_token_id).float()

def create_causal_mask(seq_len, device='cpu'):
    """Causal 마스크: 미래 토큰을 가리는 하삼각 행렬"""
    # (seq_len, seq_len) 크기의 하삼각 행렬
    mask = torch.tril(torch.ones(seq_len, seq_len, device=device))
    return mask  # 1은 볼 수 있음, 0은 볼 수 없음

def create_combined_mask(seq, pad_token_id=0):
    """패딩 + Causal 마스크 결합"""
    batch_size, seq_len = seq.shape

    # 패딩 마스크: (batch, seq_len)
    pad_mask = create_padding_mask(seq, pad_token_id)

    # Causal 마스크: (seq_len, seq_len)
    causal_mask = create_causal_mask(seq_len, seq.device)

    # 브로드캐스팅으로 결합: (batch, seq_len, seq_len)
    # pad_mask를 (batch, 1, seq_len)로 확장
    pad_mask = pad_mask.unsqueeze(1)

    # 두 마스크의 교집합 (AND 연산)
    combined = pad_mask * causal_mask
    return combined

설명

이것이 하는 일: 마스크 생성 함수는 모델 학습과 추론에 필요한 다양한 마스크를 표준화된 방식으로 생성하여 코드 일관성과 안정성을 보장합니다. 첫 번째로, create_padding_mask 함수는 가장 기본적인 마스크를 생성합니다.

(seq != pad_token_id).float()은 불리언 비교 결과를 0.0과 1.0의 부동소수점으로 변환합니다. float 타입을 사용하는 이유는 이후 손실 계산에서 곱셈 연산을 할 때 타입 에러를 방지하기 위함입니다.

출력 차원은 (batch, seq_len)으로 각 토큰마다 하나의 마스크 값을 가집니다. 두 번째로, create_causal_mask 함수는 디코더에서 필수적인 하삼각 행렬을 만듭니다.

torch.tril은 lower triangular 함수로, 대각선 아래만 1이고 위는 0인 행렬을 생성합니다. 이것이 중요한 이유는 self-attention에서 각 위치가 자신과 이전 위치만 볼 수 있게 하여, GPT처럼 왼쪽에서 오른쪽으로 순차 생성하는 모델을 가능하게 하기 때문입니다.

device 파라미터로 GPU에 직접 생성하여 불필요한 전송을 피합니다. 세 번째로, create_combined_mask 함수가 두 마스크를 결합합니다.

핵심은 차원 확장입니다. pad_mask는 (batch, seq_len)인데, causal_mask는 (seq_len, seq_len)이므로 직접 곱할 수 없습니다.

unsqueeze(1)로 (batch, 1, seq_len)로 만들면 브로드캐스팅이 작동합니다. 최종 곱셈 pad_mask * causal_mask는 (batch, seq_len, seq_len)이 되고, 이는 어텐션 스코어 행렬과 정확히 같은 차원입니다.

네 번째로, 마스크 결합의 의미를 이해해야 합니다. 곱셈은 AND 연산과 같습니다.

어떤 위치가 (1) 패딩이 아니면서 동시에 (2) causal 관계를 만족해야만 최종 마스크가 1이 됩니다. 예를 들어, 미래 토큰이지만 패딩이면 0, 과거 토큰이지만 패딩이면 0, 과거 토큰이면서 실제 데이터일 때만 1입니다.

이 논리가 올바른 어텐션을 보장합니다. 여러분이 이 코드를 사용하면 매번 마스크를 새로 만들 필요 없이 검증된 함수를 재사용하고, 차원 불일치 에러를 사전에 방지하며, 복잡한 마스크 로직을 명확하게 표현할 수 있습니다.

실전 팁

💡 causal_mask는 시퀀스 길이가 같으면 재사용할 수 있으므로, 캐싱하여 반복 생성 비용을 없애세요. functools.lru_cache로 간단히 구현 가능합니다.

💡 어텐션에 사용할 때는 마스크 0 위치를 -1e9나 -float('inf')로 변환해야 softmax 후 확률이 0이 됩니다. mask = mask.masked_fill(mask == 0, float('-inf'))

💡 인코더-디코더 모델에서는 cross-attention용 마스크도 필요합니다. 인코더 출력의 패딩만 마스킹하고 causal은 적용하지 않습니다.

💡 마스크를 시각화하면 디버깅이 쉬워집니다. plt.imshow(mask[0].cpu()) 같은 코드로 어텐션 패턴을 직접 확인하세요.

💡 대규모 배치에서는 마스크 생성도 병목이 될 수 있습니다. 가능하면 데이터로더에서 미리 생성하여 GPU 전송 시간을 줄이세요.


4. 마스킹된 크로스 엔트로피 손실

시작하며

여러분이 언어 모델을 훈련하면서 손실 값을 확인할 때 이런 의문을 가져본 적 있나요? 왜 같은 배치인데도 손실 값이 불안정하게 튀거나, 짧은 문장과 긴 문장의 손실을 어떻게 공정하게 비교할까요?

이런 문제는 표준 크로스 엔트로피 손실이 모든 위치를 동등하게 취급하기 때문에 발생합니다. 10개 토큰 중 8개가 패딩인 샘플과 2개만 패딩인 샘플이 같은 가중치로 계산되면, 실제 정보 밀도가 무시됩니다.

이는 특히 배치 크기가 작을 때 그래디언트 품질에 악영향을 줍니다. 바로 이럴 때 필요한 것이 마스킹된 크로스 엔트로피 손실입니다.

각 샘플의 실제 토큰 개수를 정확히 반영하여 공정하고 안정적인 학습을 가능하게 합니다.

개요

간단히 말해서, 마스킹된 크로스 엔트로피는 패딩을 제외하고 실제 토큰의 예측 오차만 측정하는 손실 함수입니다. 일반 크로스 엔트로피는 모델 출력(logits)과 정답(targets)의 차이를 측정합니다.

하지만 패딩 위치의 차이는 의미가 없으므로 제거해야 합니다. 마스킹된 버전은 두 단계로 작동합니다: (1) 모든 위치의 손실 계산, (2) 마스크로 패딩 손실 제거 및 재정규화.

예를 들어, 100개 토큰 중 20개가 패딩이면 80개의 손실만 합산하고 80으로 나눕니다. 전통적인 방법에서는 패딩을 ignore_index로 설정하여 자동 처리했지만, 이는 마스크 정보를 재활용할 수 없고 유연성이 떨어졌습니다.

이제는 명시적 마스킹으로 완전한 제어와 투명성을 확보할 수 있습니다. 이 개념의 핵심 특징은 세 가지입니다.

첫째, 위치별 손실 계산으로 세밀한 제어가 가능합니다. 둘째, 동적 정규화로 배치마다 다른 패딩 비율에 적응합니다.

셋째, 수치 안정성으로 zero division이나 overflow를 방지합니다. 이러한 특징들이 대규모 모델 훈련에서 필수적입니다.

코드 예제

import torch
import torch.nn as nn
import torch.nn.functional as F

class MaskedCrossEntropyLoss(nn.Module):
    def __init__(self, pad_token_id=0, label_smoothing=0.0):
        super().__init__()
        self.pad_token_id = pad_token_id
        self.label_smoothing = label_smoothing

    def forward(self, logits, targets):
        # logits: (batch, seq_len, vocab_size)
        # targets: (batch, seq_len)

        vocab_size = logits.size(-1)

        # 1. 마스크 생성
        mask = (targets != self.pad_token_id).float()

        # 2. Flatten for cross entropy
        logits_flat = logits.view(-1, vocab_size)
        targets_flat = targets.view(-1)

        # 3. 위치별 크로스 엔트로피 계산
        loss = F.cross_entropy(
            logits_flat,
            targets_flat,
            reduction='none',
            label_smoothing=self.label_smoothing
        )

        # 4. 마스크 적용
        mask_flat = mask.view(-1)
        masked_loss = loss * mask_flat

        # 5. 정규화: 실제 토큰 수로 나누기
        total_loss = masked_loss.sum()
        num_tokens = mask_flat.sum()

        # Zero division 방지
        return total_loss / (num_tokens + 1e-8)

설명

이것이 하는 일: 마스킹된 크로스 엔트로피 손실은 언어 모델 훈련에서 패딩의 영향을 완전히 제거하고, 샘플 간 공정한 비교를 가능하게 하는 핵심 손실 함수입니다. 첫 번째로, mask = (targets != self.pad_token_id).float() 단계가 전체의 기초입니다.

targets 텐서에서 패딩이 아닌 위치를 1.0, 패딩 위치를 0.0으로 표시합니다. float() 변환이 중요한 이유는 이후 곱셈 연산에서 정수형 오버플로우나 타입 불일치를 방지하기 위함입니다.

이 마스크는 (batch, seq_len) 형태로 각 토큰의 유효성을 인코딩합니다. 두 번째로, flatten 과정이 필요합니다.

PyTorch의 F.cross_entropy는 (N, C) 형태의 입력을 기대합니다(N은 샘플 수, C는 클래스 수). 우리의 logits는 (batch, seq_len, vocab_size)이므로 (batch*seq_len, vocab_size)로 펼쳐야 합니다.

마찬가지로 targets는 (batch*seq_len)로 만듭니다. 이렇게 하면 배치의 모든 위치를 개별 분류 문제로 취급할 수 있습니다.

세 번째로, F.cross_entropy(..., reduction='none')이 핵심입니다. 기본 reduction='mean'을 사용하면 자동으로 평균이 계산되어 우리가 마스킹할 기회가 없어집니다.

reduction='none'은 (batch*seq_len,) 형태로 각 위치의 손실을 개별적으로 반환합니다. label_smoothing 파라미터는 과적합 방지를 위한 정규화 기법으로, 정답 레이블에 1이 아닌 0.9 같은 값을 주어 모델이 과도하게 확신하지 않도록 합니다.

네 번째로, masked_loss = loss * mask_flat 곱셈이 실제 마스킹을 수행합니다. 마스크가 0인 위치(패딩)의 손실은 0이 되고, 1인 위치(실제 토큰)의 손실만 남습니다.

이것은 요소별(element-wise) 곱셈으로 매우 효율적입니다. 브로드캐스팅이 아니라 같은 크기의 텐서끼리 곱하므로 추가 메모리 오버헤드가 없습니다.

다섯 번째로, 정규화 단계가 올바른 손실 값을 만듭니다. total_loss / (num_tokens + 1e-8)에서 분모가 핵심입니다.

전체 토큰 수(batch*seq_len)가 아니라 실제 토큰 수(mask.sum())로 나눕니다. 1e-8을 더하는 것은 만약 배치 전체가 패딩이면(거의 불가능하지만) division by zero를 방지합니다.

이 정규화 덕분에 "실제 토큰당 평균 손실"이라는 의미 있는 지표를 얻습니다. 여러분이 이 코드를 사용하면 패딩 비율이 다른 배치들을 공정하게 비교할 수 있고, 손실 값의 의미가 명확하여 하이퍼파라미터 튜닝이 쉬워지며, label smoothing 같은 고급 기법도 자연스럽게 통합할 수 있습니다.

실전 팁

💡 label_smoothing은 0.1 정도가 일반적으로 좋은 시작점입니다. 너무 크면(>0.3) 모델이 제대로 학습하지 못하고, 너무 작으면(<0.01) 효과가 미미합니다.

💡 perplexity를 계산하려면 torch.exp(loss)를 사용하세요. 마스킹된 손실에서 계산한 perplexity가 실제 언어 모델 품질을 정확히 반영합니다.

💡 혼합 정밀도 훈련(mixed precision)을 사용할 때는 손실 계산을 float32로 유지하세요. with torch.cuda.amp.autocast(enabled=False)로 감싸면 됩니다.

💡 배치 내 패딩 비율을 모니터링하세요. 1 - (num_tokens / (batch*seq_len))로 계산한 값이 0.5를 넘으면 데이터 효율이 낮은 것이므로 동적 배칭 전략을 고려하세요.

💡 대규모 vocab_size에서는 메모리가 문제될 수 있습니다. Adaptive softmax나 sampled softmax 같은 기법으로 계산량을 줄이면서도 마스킹을 유지할 수 있습니다.


5. 손실 정규화 방법

시작하며

여러분이 여러 배치의 손실 값을 비교하면서 이런 혼란을 겪어본 적 있나요? 배치 A는 손실 2.5, 배치 B는 1.8인데, A가 정말 더 나쁜 걸까요?

알고 보니 A는 패딩이 80%, B는 20%였다면요? 이런 문제는 손실 정규화 방법이 일관되지 않을 때 발생합니다.

단순히 배치 크기나 시퀀스 길이로 나누면 패딩 비율에 따라 손실 스케일이 달라집니다. 이는 학습률 설정을 어렵게 하고, 조기 종료 같은 기법의 정확성을 해칩니다.

바로 이럴 때 필요한 것이 올바른 손실 정규화입니다. 실제로 학습에 기여한 토큰 수만 고려하여 모든 배치를 동일한 기준으로 비교할 수 있게 합니다.

개요

간단히 말해서, 손실 정규화는 손실 합계를 어떤 값으로 나눌지 결정하는 전략으로, 모델 품질 비교의 공정성을 좌우합니다. 세 가지 일반적인 정규화 방법이 있습니다.

(1) 배치 정규화: 배치 크기로 나눔 - 샘플당 평균 손실. (2) 토큰 정규화: 전체 토큰 수로 나눔 - 토큰당 평균 손실이지만 패딩 포함.

(3) 유효 토큰 정규화: 실제 토큰 수로 나눔 - 진정한 토큰당 평균 손실. 각 방법은 다른 해석을 제공하며, 일관성이 핵심입니다.

전통적인 방법에서는 단순히 배치 크기로 나누어 샘플 간 비교가 불공정했다면, 이제는 유효 토큰 정규화로 데이터의 실제 분포를 정확히 반영할 수 있습니다. 이 개념의 핵심 특징은 세 가지입니다.

첫째, 해석 가능성으로 손실 값이 "실제 토큰당 평균 오차"라는 명확한 의미를 갖습니다. 둘째, 배치 독립성으로 배치 구성이 달라도 일관된 손실 스케일을 유지합니다.

셋째, 하이퍼파라미터 안정성으로 학습률 등의 설정이 데이터 특성에 덜 민감해집니다. 이러한 특징들이 재현 가능한 훈련을 가능하게 합니다.

코드 예제

import torch

def compute_normalized_loss(logits, targets, mask, method='effective_tokens'):
    """
    다양한 정규화 방법으로 손실 계산

    Args:
        logits: (batch, seq_len, vocab_size)
        targets: (batch, seq_len)
        mask: (batch, seq_len) - 1은 실제 토큰, 0은 패딩
        method: 'batch', 'total_tokens', 'effective_tokens'
    """
    batch_size, seq_len, vocab_size = logits.shape

    # 위치별 손실 계산
    loss = F.cross_entropy(
        logits.view(-1, vocab_size),
        targets.view(-1),
        reduction='none'
    )

    # 마스크 적용
    masked_loss = loss * mask.view(-1)
    total_loss = masked_loss.sum()

    # 정규화 방법별 처리
    if method == 'batch':
        # 배치 크기로 정규화
        return total_loss / batch_size
    elif method == 'total_tokens':
        # 전체 토큰 수로 정규화 (패딩 포함)
        return total_loss / (batch_size * seq_len)
    elif method == 'effective_tokens':
        # 유효 토큰 수로 정규화 (권장)
        num_effective = mask.sum()
        return total_loss / (num_effective + 1e-8)
    else:
        raise ValueError(f"Unknown method: {method}")

# 비교 예시
batch_size, seq_len, vocab_size = 4, 10, 100
logits = torch.randn(batch_size, seq_len, vocab_size)
targets = torch.randint(0, vocab_size, (batch_size, seq_len))

# 불균형한 마스크 생성 (패딩 비율이 다름)
mask = torch.tensor([
    [1,1,1,1,1,1,1,1,1,1],  # 패딩 없음
    [1,1,1,1,1,0,0,0,0,0],  # 50% 패딩
    [1,1,1,0,0,0,0,0,0,0],  # 70% 패딩
    [1,1,1,1,1,1,1,0,0,0],  # 30% 패딩
]).float()

for method in ['batch', 'total_tokens', 'effective_tokens']:
    loss = compute_normalized_loss(logits, targets, mask, method)
    print(f"{method}: {loss:.4f}")

설명

이것이 하는 일: 손실 정규화 방법은 다양한 배치 구성에서도 일관되고 해석 가능한 손실 값을 제공하여 모델 훈련의 신뢰성을 높입니다. 첫 번째로, 세 가지 정규화 방법의 차이를 이해해야 합니다.

method='batch'는 total_loss를 배치 크기로 나눕니다. 이는 "샘플당 평균 손실"을 의미하는데, 문제는 샘플마다 길이가 다르면 비교가 불공정하다는 것입니다.

긴 문장은 더 많은 손실을 누적하므로 짧은 문장보다 자동으로 손실이 커집니다. 두 번째로, method='total_tokens'는 batch_size * seq_len로 나눕니다.

이는 "토큰당 평균 손실"을 의미하지만, 패딩 토큰도 분모에 포함됩니다. 예를 들어, 실제 토큰 30개, 패딩 10개면 40으로 나누는데, 이는 손실을 실제보다 작게 만듭니다.

패딩 비율이 배치마다 다르면 손실 스케일도 달라져 문제입니다. 세 번째로, method='effective_tokens'가 권장 방법입니다.

num_effective = mask.sum()은 실제로 손실 계산에 기여한 토큰 개수를 정확히 셉니다. 패딩은 제외되므로 "실제 토큰당 평균 손실"이라는 명확한 의미를 갖습니다.

이 방법은 배치 구성이 어떻든 일관된 스케일을 유지하여, 다른 데이터셋이나 다른 훈련 단계의 손실도 직접 비교할 수 있게 합니다. 네 번째로, 예시에서 불균형한 마스크를 사용한 이유는 실전 시나리오를 반영하기 위함입니다.

첫 샘플은 패딩 없이 10개 토큰, 두 번째는 5개만 유효, 세 번째는 3개, 네 번째는 7개입니다. 배치 전체로는 25개 실제 토큰, 15개 패딩입니다.

각 정규화 방법은 다른 결과를 줍니다: batch 방법은 4로 나누고, total_tokens는 40으로 나누며, effective_tokens는 25로 나눕니다. 같은 손실 합계도 정규화에 따라 다른 값이 되는 것입니다.

다섯 번째로, + 1e-8 처리가 안전망입니다. 이론적으로 전체 배치가 패딩일 수 있고(데이터 버그), 이 경우 num_effective가 0이 되어 division by zero 에러가 발생합니다.

1e-8은 충분히 작아서 정상적인 경우에는 무시되지만, 0 나눗셈은 방지합니다. 프로덕션 코드에서는 이런 예외 상황도 대비해야 합니다.

여러분이 이 코드를 사용하면 배치마다 손실 스케일이 일관되어 학습 곡선이 안정적이고, 손실 값의 해석이 명확하여 모델 비교가 쉬우며, 하이퍼파라미터 튜닝 시 데이터 의존성이 줄어듭니다.

실전 팁

💡 논문이나 코드를 참조할 때 항상 정규화 방법을 확인하세요. "우리 모델의 손실은 2.3"이라는 보고만으로는 의미가 없습니다. 정규화 방법이 달라서 숫자만 다를 수 있습니다.

💡 분산 훈련(multi-GPU)에서는 각 GPU의 num_effective를 합산해야 합니다. torch.distributed.all_reduce로 전체 합을 구한 후 정규화하세요.

💡 검증 세트 손실은 항상 같은 정규화 방법으로 계산하세요. 훈련은 effective_tokens인데 검증은 batch로 하면 비교가 무의미합니다.

💡 로깅할 때 정규화 전 손실 합계와 토큰 수도 함께 기록하세요. 나중에 다른 정규화 방법으로 재계산할 수 있어 유용합니다.

💡 일부 라이브러리는 자동으로 정규화를 처리하므로 중복 정규화를 주의하세요. Hugging Face Transformers의 Trainer는 이미 정규화를 하므로 직접 하면 손실이 너무 작아집니다.


6. 배치 처리에서의 마스킹

시작하며

여러분이 GPU 메모리를 최대한 활용하려고 배치 크기를 늘릴 때 이런 딜레마를 겪어본 적 있나요? 문장 길이가 천차만별인데, 가장 긴 문장에 맞추면 짧은 문장들은 대부분 패딩이 되어 메모리 낭비가 심합니다.

이런 문제는 고정 길이 배칭의 근본적인 한계입니다. 최대 길이 512로 설정하면 평균 길이 100인 데이터셋에서 80%가 패딩이 됩니다.

이는 메모리뿐 아니라 계산 시간도 낭비하며, 심지어 배치 통계(batch statistics)도 왜곡시킵니다. 바로 이럴 때 필요한 것이 효율적인 배치 마스킹 전략입니다.

동적 패딩, 정렬 기반 배칭, 버킷팅 등의 기법으로 마스킹을 최소화하면서도 올바른 학습을 보장합니다.

개요

간단히 말해서, 배치 처리에서의 마스킹은 다양한 길이의 시퀀스를 효율적으로 묶어 GPU에서 병렬 처리하는 기술입니다. 핵심 아이디어는 배치 내 최대 길이를 동적으로 결정하는 것입니다.

전체 데이터셋의 최대 길이가 아니라, 현재 배치의 최대 길이만큼만 패딩합니다. 예를 들어, 배치 내 최대 길이가 150이면 512까지 패딩할 필요 없이 150까지만 패딩합니다.

이것만으로도 메모리와 계산량을 크게 절약할 수 있습니다. 전통적인 방법에서는 모든 배치를 같은 고정 길이로 만들어 비효율적이었다면, 이제는 동적 전략으로 각 배치에 최적화된 처리를 할 수 있습니다.

이 개념의 핵심 특징은 세 가지입니다. 첫째, 동적 패딩으로 배치마다 다른 최대 길이를 사용합니다.

둘째, 길이 기반 소팅으로 비슷한 길이끼리 묶어 패딩을 최소화합니다. 셋째, 버킷팅으로 길이 범위별로 배치를 구성하여 효율과 무작위성을 균형있게 유지합니다.

이러한 특징들이 대규모 훈련의 실용성을 높입니다.

코드 예제

import torch
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader

def collate_fn_dynamic_padding(batch, pad_token_id=0):
    """
    동적 패딩을 사용하는 collate 함수

    Args:
        batch: 리스트 of 딕셔너리 [{'input_ids': tensor, ...}, ...]
    """
    # 각 샘플의 길이 추출
    lengths = [item['input_ids'].size(0) for item in batch]
    max_len = max(lengths)

    # 배치 텐서 초기화
    batch_size = len(batch)
    input_ids = torch.full((batch_size, max_len), pad_token_id, dtype=torch.long)

    # 각 샘플을 배치에 복사
    for i, item in enumerate(batch):
        seq_len = lengths[i]
        input_ids[i, :seq_len] = item['input_ids']

    # 마스크 생성
    mask = (input_ids != pad_token_id).float()

    # 효율성 메트릭 계산
    total_tokens = batch_size * max_len
    effective_tokens = mask.sum().item()
    efficiency = effective_tokens / total_tokens

    return {
        'input_ids': input_ids,
        'mask': mask,
        'lengths': torch.tensor(lengths),
        'efficiency': efficiency  # 패딩 효율성 추적
    }

# 길이 기반 소팅 전략
def create_length_sorted_batches(dataset, batch_size):
    """비슷한 길이끼리 묶어 배칭"""
    # 길이순으로 정렬
    sorted_indices = sorted(
        range(len(dataset)),
        key=lambda i: len(dataset[i]['input_ids'])
    )

    # 배치로 분할
    batches = []
    for i in range(0, len(sorted_indices), batch_size):
        batch_indices = sorted_indices[i:i+batch_size]
        batches.append([dataset[idx] for idx in batch_indices])

    return batches

설명

이것이 하는 일: 배치 처리 마스킹은 다양한 길이의 시퀀스를 GPU에서 효율적으로 병렬 처리하면서 메모리 낭비를 최소화하고 훈련 속도를 높입니다. 첫 번째로, collate_fn_dynamic_padding 함수가 핵심입니다.

PyTorch DataLoader의 collate_fn 파라미터로 사용되며, 개별 샘플들을 배치로 합치는 역할을 합니다. max_len = max(lengths)가 동적 패딩의 핵심인데, 전체 데이터셋이 아니라 현재 배치만 고려하여 최대 길이를 결정합니다.

이렇게 하면 길이 10, 12, 15인 샘플들이 배치에 있을 때 15까지만 패딩하고, 512나 1024까지 패딩하지 않습니다. 두 번째로, 배치 텐서 초기화 torch.full((batch_size, max_len), pad_token_id)가 효율적입니다.

먼저 전체를 패딩 값으로 채운 후, 실제 데이터가 있는 부분만 덮어씁니다. 이것이 각 샘플을 개별적으로 패딩하는 것보다 빠른 이유는 메모리 할당이 한 번만 일어나고 연속적이기 때문입니다.

input_ids[i, :seq_len] = item['input_ids']로 각 샘플의 실제 데이터를 앞쪽부터 채웁니다. 세 번째로, efficiency = effective_tokens / total_tokens 계산이 유용한 메트릭입니다.

이 값은 0과 1 사이인데, 1에 가까울수록 패딩이 적고 효율적입니다. 예를 들어, 0.8이면 80%가 실제 데이터, 20%가 패딩입니다.

이 메트릭을 훈련 중 로깅하면 배칭 전략의 효과를 정량적으로 평가할 수 있습니다. 효율이 0.5 이하로 떨어지면 전략을 재검토해야 합니다.

네 번째로, create_length_sorted_batches 함수는 더 고급 전략입니다. 전체 데이터셋을 길이순으로 정렬한 후 연속된 샘플들을 배치로 묶습니다.

이렇게 하면 비슷한 길이끼리 모이므로 동적 패딩의 효과가 극대화됩니다. 예를 들어, 길이 50-60인 샘플들이 한 배치에 모이면 최대 60까지만 패딩하면 되지만, 랜덤 배칭이면 길이 10과 200이 섞여 200까지 패딩해야 합니다.

단점은 연속된 샘플이 비슷한 특성을 가질 수 있어 배치 다양성이 감소한다는 것입니다. 다섯 번째로, 실전에서는 완전 소팅과 랜덤 배칭의 중간인 "버킷팅"을 많이 씁니다.

길이를 몇 개 구간(버킷)으로 나누고, 같은 버킷 내에서 랜덤 샘플링합니다. 예: [0-50], [51-100], [101-200], [201+] 버킷을 만들고 각 버킷에서 독립적으로 배치를 구성합니다.

이렇게 하면 효율성과 무작위성을 모두 얻습니다. 여러분이 이 코드를 사용하면 동일한 GPU 메모리로 더 큰 배치를 처리하거나 더 긴 시퀀스를 다룰 수 있고, 훈련 시간이 20-50% 단축되며, 메모리 부족 에러가 줄어듭니다.

실전 팁

💡 torch.nn.utils.rnn.pad_sequence를 사용하면 collate 함수를 더 간단히 작성할 수 있습니다. 리스트 of 텐서를 자동으로 패딩해줍니다.

💡 분산 훈련에서는 각 GPU가 다른 max_len을 가질 수 있어 문제입니다. all_reduce로 전체 GPU의 최대값을 구해 모든 GPU에서 같은 길이를 사용하세요.

💡 추론(inference) 시에는 배치 크기를 1로 하거나, 비슷한 길이끼리 묶어 추론하면 레이턴시를 최소화할 수 있습니다.

💡 Hugging Face의 DataCollatorWithPadding을 사용하면 동적 패딩이 자동으로 처리됩니다. 직접 구현하기 전에 기존 도구를 확인하세요.

💡 매우 긴 문장(1000+ 토큰)이 드물게 나타나면 전체 배치를 길게 만들어 비효율적입니다. 최대 길이 제한을 두고 긴 문장은 잘라내거나 별도 처리하세요.


7. 그래디언트 흐름 제어

시작하며

여러분이 모델을 훈련하면서 역전파(backpropagation)를 디버깅할 때 이런 현상을 본 적 있나요? 그래디언트를 출력해보니 패딩 위치에도 그래디언트가 흐르고 있어서, 불필요한 파라미터 업데이트가 발생하거나 그래디언트가 폭발합니다.

이런 문제는 마스킹이 손실 계산에만 적용되고 그래디언트 흐름에는 적용되지 않을 때 발생합니다. 순전파에서 패딩 위치의 손실을 0으로 만들어도, 역전파 시 그 위치를 거쳐온 그래디언트는 여전히 모델로 전달됩니다.

특히 어텐션 메커니즘에서는 패딩이 다른 위치에 영향을 줄 수 있어 더 복잡합니다. 바로 이럴 때 필요한 것이 그래디언트 흐름 제어입니다.

마스킹을 계산 그래프에 올바르게 통합하여 패딩 위치로부터 그래디언트가 역전파되지 않도록 보장합니다.

개요

간단히 말해서, 그래디언트 흐름 제어는 역전파 시 특정 위치나 연결로 그래디언트가 흐르지 않도록 차단하는 기법입니다. PyTorch에서 마스킹은 자동으로 그래디언트 흐름에 영향을 줍니다.

손실에 마스크를 곱하면(loss * mask), 마스크가 0인 위치의 그래디언트도 0이 됩니다. 이것이 작동하는 이유는 chain rule 때문입니다: ∂loss/∂param = ∂loss/∂output × ∂output/∂param인데, 마스크로 ∂loss/∂output을 0으로 만들면 전체 그래디언트도 0이 됩니다.

전통적인 접근에서는 그래디언트를 수동으로 0으로 설정했지만, 이는 에러가 발생하기 쉽고 자동 미분 시스템과 충돌할 수 있었습니다. 이제는 마스킹을 계산 그래프에 포함시켜 자동으로 올바른 그래디언트 흐름을 얻을 수 있습니다.

이 개념의 핵심 특징은 세 가지입니다. 첫째, 자동 미분 호환으로 PyTorch의 autograd와 자연스럽게 작동합니다.

둘째, 선택적 차단으로 필요한 경로만 그래디언트를 막고 나머지는 유지합니다. 셋째, 메모리 효율로 추가 저장 공간 없이 마스크만으로 제어합니다.

이러한 특징들이 안정적이고 효율적인 훈련을 가능하게 합니다.

코드 예제

import torch
import torch.nn as nn

class MaskedLossWithGradientControl(nn.Module):
    def __init__(self, pad_token_id=0):
        super().__init__()
        self.pad_token_id = pad_token_id

    def forward(self, logits, targets):
        # logits: (batch, seq_len, vocab_size) - requires_grad=True
        # targets: (batch, seq_len)

        # 1. 마스크 생성 (그래디언트 불필요)
        with torch.no_grad():
            mask = (targets != self.pad_token_id).float()

        # 2. 크로스 엔트로피 계산 (그래디언트 필요)
        vocab_size = logits.size(-1)
        loss = F.cross_entropy(
            logits.view(-1, vocab_size),
            targets.view(-1),
            reduction='none'
        )
        loss = loss.view_as(targets)

        # 3. 마스크 적용 - 여기서 그래디언트 흐름이 제어됨
        # mask가 0인 위치는 그래디언트도 0이 됨
        masked_loss = loss * mask

        # 4. 정규화
        num_tokens = mask.sum()
        return masked_loss.sum() / (num_tokens + 1e-8)

    def check_gradients(self, logits, targets):
        """그래디언트 흐름 검증 (디버깅용)"""
        logits.requires_grad_(True)
        loss = self.forward(logits, targets)
        loss.backward()

        # 패딩 위치의 그래디언트 확인
        mask = (targets != self.pad_token_id)

        # logits의 그래디언트에서 패딩 위치 추출
        padding_grads = logits.grad[~mask]
        real_grads = logits.grad[mask]

        print(f"패딩 위치 그래디언트 평균: {padding_grads.abs().mean().item():.8f}")
        print(f"실제 위치 그래디언트 평균: {real_grads.abs().mean().item():.8f}")
        print(f"패딩 그래디언트가 0인지: {torch.allclose(padding_grads, torch.zeros_like(padding_grads), atol=1e-7)}")

설명

이것이 하는 일: 그래디언트 흐름 제어는 역전파 과정에서 패딩 위치로부터 불필요한 그래디언트가 모델 파라미터로 전달되는 것을 차단하여 학습의 정확성과 안정성을 보장합니다. 첫 번째로, with torch.no_grad() 블록이 중요합니다.

마스크 자체는 학습 가능한 파라미터가 아니므로 그래디언트를 추적할 필요가 없습니다. no_grad 컨텍스트 안에서 마스크를 생성하면 메모리를 절약하고 계산 그래프를 단순화할 수 있습니다.

이것이 없어도 결과는 같지만, 불필요한 계산과 메모리 사용이 발생합니다. 대규모 모델에서는 이런 최적화가 누적되어 상당한 차이를 만듭니다.

두 번째로, masked_loss = loss * mask가 그래디언트 제어의 핵심입니다. 순전파에서 이 곱셈은 단순히 패딩 위치의 손실을 0으로 만듭니다.

하지만 역전파에서는 더 중요한 일이 벌어집니다. chain rule에 따라 ∂(masked_loss)/∂loss = mask이므로, mask가 0인 위치는 loss에 대한 그래디언트가 0이 됩니다.

이 0 그래디언트가 더 뒤로 전파되면서 logits, 어텐션, 임베딩 등 모든 이전 레이어의 해당 위치 그래디언트도 0이 됩니다. 세 번째로, check_gradients 메서드는 실제로 그래디언트가 올바르게 제어되는지 검증합니다.

logits.grad[~mask]는 패딩 위치의 그래디언트를 추출하고, logits.grad[mask]는 실제 토큰 위치의 그래디언트를 추출합니다. 올바르게 구현되었다면 패딩 위치의 그래디언트는 완전히 0이어야 합니다(부동소수점 오차 1e-7 이내).

실제 위치는 0이 아닌 그래디언트를 가져야 하고, 둘의 평균을 비교하면 마스킹 효과를 명확히 볼 수 있습니다. 네 번째로, 어텐션 레이어에서의 그래디언트 흐름은 더 복잡합니다.

패딩 위치에서 다른 위치로의 어텐션은 차단해야 하지만, 다른 위치에서 패딩으로의 어텐션도 차단해야 합니다. 이를 위해 어텐션 스코어를 계산한 후 마스크 위치를 -inf로 설정하면(scores.masked_fill(mask == 0, -1e9)), softmax 후 확률이 0이 되고 자동으로 그래디언트도 차단됩니다.

이것이 어텐션 마스킹의 표준 패턴입니다. 다섯 번째로, 혼합 정밀도 훈련(mixed precision)을 사용할 때는 추가 주의가 필요합니다.

FP16에서는 아주 작은 그래디언트가 0으로 underflow될 수 있습니다. GradScaler가 이를 보정하지만, 마스킹된 그래디언트는 의도적으로 0이므로 스케일링해서는 안 됩니다.

PyTorch의 autocast가 자동으로 처리하지만, 커스텀 연산에서는 확인이 필요합니다. 여러분이 이 코드를 사용하면 패딩이 모델 학습에 전혀 영향을 주지 않음을 보장하고, 불필요한 그래디언트 계산을 피해 훈련 속도를 높이며, 그래디언트 폭발이나 소실 문제를 줄일 수 있습니다.

실전 팁

💡 torch.autograd.grad를 사용해 특정 레이어의 그래디언트를 직접 검사하면 마스킹이 전체 모델에 올바르게 적용되는지 확인할 수 있습니다.

💡 어텐션 마스크는 softmax 전에 -inf를 추가하는 방식이 softmax 후 0을 곱하는 것보다 수치적으로 안정적입니다. 후자는 NaN을 유발할 수 있습니다.

💡 그래디언트 클리핑(gradient clipping)을 사용할 때는 마스킹된 그래디언트도 노름 계산에 포함됩니다(이미 0이므로 영향 없음). 하지만 명시적으로 제외하고 싶다면 클리핑 전에 마스킹된 그래디언트를 제거하세요.

💡 분산 훈련에서 그래디언트를 all-reduce할 때 마스킹된 그래디언트(0)도 함께 전송됩니다. 이는 낭비지만 피하기 어렵습니다. 희소 그래디언트 통신을 지원하는 프레임워크를 사용하면 개선할 수 있습니다.

💡 디버깅 시 torch.autograd.set_detect_anomaly(True)를 활성화하면 그래디언트 계산 중 NaN이나 inf가 발생할 때 정확한 위치를 알려줍니다. 마스킹 버그를 찾는 데 매우 유용합니다.


8. 실전 마스킹 손실 함수 완성

시작하며

여러분이 지금까지 배운 모든 마스킹 기법을 실제 프로덕션 코드에 통합할 때 어떤 어려움을 겪을까요? 개별 기법은 이해했지만, 이들을 일관되게 결합하고, 엣지 케이스를 처리하며, 성능을 최적화하는 것은 또 다른 도전입니다.

이런 문제는 실전 코드의 복잡성 때문입니다. label smoothing, ignore index, 다중 특수 토큰, 분산 훈련 지원, 디버깅 기능 등 프로덕션에 필요한 모든 요소를 고려해야 합니다.

각 기능이 독립적으로는 작동해도 함께 사용하면 충돌이나 버그가 발생할 수 있습니다. 바로 이럴 때 필요한 것이 완성도 높은 마스킹 손실 함수입니다.

모든 배운 개념을 통합하고, 실전에서 바로 사용할 수 있는 견고한 구현을 만들어봅시다.

개요

간단히 말해서, 실전 마스킹 손실 함수는 지금까지 배운 모든 기법을 통합하여 프로덕션 환경에서 안정적으로 작동하는 완전한 솔루션입니다. 완성된 손실 함수는 여러 레이어로 구성됩니다.

(1) 입력 검증 레이어: 텐서 크기, 타입, 값 범위를 확인합니다. (2) 마스킹 레이어: 다중 조건(패딩, 특수 토큰, 커스텀 마스크)을 결합합니다.

(3) 손실 계산 레이어: 효율적인 크로스 엔트로피와 정규화를 수행합니다. (4) 후처리 레이어: 로깅, 메트릭 계산, 디버깅 정보를 제공합니다.

각 레이어가 독립적으로 테스트 가능하면서도 유기적으로 작동합니다. 전통적인 접근에서는 단순한 함수 하나로 모든 것을 처리하려 해서 유지보수가 어려웠다면, 이제는 모듈화된 클래스 기반 설계로 확장성과 재사용성을 확보할 수 있습니다.

이 개념의 핵심 특징은 세 가지입니다. 첫째, 견고성으로 모든 엣지 케이스를 처리하고 명확한 에러 메시지를 제공합니다.

둘째, 성능으로 불필요한 계산을 피하고 GPU 연산을 최적화합니다. 셋째, 관찰 가능성으로 훈련 중 상세한 메트릭과 디버깅 정보를 제공합니다.

이러한 특징들이 실제 서비스 배포를 가능하게 합니다.

코드 예제

import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import Optional, Dict, List

class ProductionMaskedLoss(nn.Module):
    """프로덕션급 마스킹 손실 함수"""

    def __init__(
        self,
        ignore_indices: List[int] = [0],  # 무시할 토큰 ID들 (패딩 등)
        label_smoothing: float = 0.0,
        reduction: str = 'mean',
        log_metrics: bool = False
    ):
        super().__init__()
        self.ignore_indices = set(ignore_indices)
        self.label_smoothing = label_smoothing
        self.reduction = reduction
        self.log_metrics = log_metrics

        # 메트릭 누적용
        self.reset_metrics()

    def reset_metrics(self):
        """메트릭 초기화"""
        self.total_loss = 0.0
        self.total_tokens = 0
        self.num_batches = 0

    def create_mask(
        self,
        targets: torch.Tensor,
        custom_mask: Optional[torch.Tensor] = None
    ) -> torch.Tensor:
        """
        다중 조건 마스크 생성

        Args:
            targets: (batch, seq_len)
            custom_mask: Optional (batch, seq_len) - 추가 마스킹 조건

        Returns:
            mask: (batch, seq_len) - 1은 계산, 0은 무시
        """
        # 무시할 인덱스가 아닌 위치 찾기
        mask = torch.ones_like(targets, dtype=torch.float)
        for idx in self.ignore_indices:
            mask = mask * (targets != idx).float()

        # 커스텀 마스크와 결합 (AND 연산)
        if custom_mask is not None:
            mask = mask * custom_mask.float()

        return mask

    def forward(
        self,
        logits: torch.Tensor,
        targets: torch.Tensor,
        custom_mask: Optional[torch.Tensor] = None,
        return_metrics: bool = False
    ) -> torch.Tensor:
        """
        손실 계산

        Args:
            logits: (batch, seq_len, vocab_size)
            targets: (batch, seq_len)
            custom_mask: Optional (batch, seq_len)
            return_metrics: 상세 메트릭 반환 여부
        """
        # 입력 검증
        assert logits.dim() == 3, f"logits should be 3D, got {logits.dim()}D"
        assert targets.dim() == 2, f"targets should be 2D, got {targets.dim()}D"
        assert logits.shape[:2] == targets.shape, "Shape mismatch"

        batch_size, seq_len, vocab_size = logits.shape

        # 마스크 생성
        mask = self.create_mask(targets, custom_mask)
        num_tokens = mask.sum()

        # 빈 배치 처리 (모든 토큰이 무시됨)
        if num_tokens == 0:
            return torch.tensor(0.0, device=logits.device, requires_grad=True)

        # 크로스 엔트로피 계산
        logits_flat = logits.view(-1, vocab_size)
        targets_flat = targets.view(-1)

        loss_per_token = F.cross_entropy(
            logits_flat,
            targets_flat,
            reduction='none',
            label_smoothing=self.label_smoothing
        )

        # 마스크 적용
        loss_per_token = loss_per_token.view(batch_size, seq_len)
        masked_loss = loss_per_token * mask

        # Reduction 적용
        if self.reduction == 'mean':
            loss = masked_loss.sum() / num_tokens
        elif self.reduction == 'sum':
            loss = masked_loss.sum()
        else:
            loss = masked_loss  # 'none'

        # 메트릭 누적
        if self.log_metrics:
            with torch.no_grad():
                self.total_loss += masked_loss.sum().item()
                self.total_tokens += num_tokens.item()
                self.num_batches += 1

        # 상세 메트릭 반환
        if return_metrics:
            metrics = self._compute_metrics(loss_per_token, mask, num_tokens)
            return loss, metrics

        return loss

    def _compute_metrics(
        self,
        loss_per_token: torch.Tensor,
        mask: torch.Tensor,
        num_tokens: torch.Tensor
    ) -> Dict[str, float]:
        """상세 메트릭 계산"""
        with torch.no_grad():
            masked_loss = loss_per_token * mask

            return {
                'loss': masked_loss.sum().item() / num_tokens.item(),
                'perplexity': torch.exp(masked_loss.sum() / num_tokens).item(),
                'num_tokens': num_tokens.item(),
                'avg_token_loss': masked_loss.sum().item() / num_tokens.item(),
                'max_token_loss': loss_per_token[mask.bool()].max().item(),
                'padding_ratio': 1.0 - (num_tokens.item() / mask.numel())
            }

    def get_epoch_metrics(self) -> Dict[str, float]:
        """에포크 누적 메트릭"""
        if self.total_tokens == 0:
            return {}

        avg_loss = self.total_loss / self.total_tokens
        return {
            'epoch_loss': avg_loss,
            'epoch_perplexity': torch.exp(torch.tensor(avg_loss)).item(),
            'total_tokens': self.total_tokens,
            'num_batches': self.num_batches
        }

설명

이것이 하는 일: 프로덕션 마스킹 손실 함수는 실전에서 필요한 모든 기능을 제공하는 완성도 높은 솔루션으로, 안정적인 훈련과 효과적인 디버깅을 동시에 지원합니다. 첫 번째로, 클래스 설계가 핵심입니다.

nn.Module을 상속하여 PyTorch 생태계와 자연스럽게 통합됩니다. __init__에서 설정을 받고, forward에서 실제 계산을 수행하는 표준 패턴을 따릅니다.

ignore_indices를 리스트로 받아 set으로 변환하는 이유는 membership 검사(in 연산)가 O(1)로 빠르기 때문입니다. 여러 특수 토큰(PAD, UNK, SEP 등)을 한번에 무시할 수 있어 유연합니다.

두 번째로, create_mask 메서드가 마스킹 로직을 캡슐화합니다. 반복문으로 각 ignore_index를 체크하고 마스크를 업데이트하는데, mask = mask * (targets != idx).float()는 누적적 AND 연산입니다.

첫 반복에서 mask는 모두 1이고, 각 ignore_index에 해당하는 위치가 0으로 바뀝니다. custom_mask 파라미터는 추가 유연성을 제공하여, 예를 들어 프롬프트 부분은 학습하지 않고 응답 부분만 학습하는 등의 고급 시나리오를 지원합니다.

세 번째로, 입력 검증이 실전에서 매우 중요합니다. assert logits.dim() == 3은 차원 불일치 버그를 즉시 발견합니다.

이것이 없으면 이상한 에러가 나중 단계에서 발생하여 디버깅이 어렵습니다. 명확한 에러 메시지(got {logits.dim()}D)는 문제 해결 시간을 크게 단축시킵니다.

shape mismatch 검사도 마찬가지로, logits와 targets의 배치/시퀀스 차원이 일치하는지 확인합니다. 네 번째로, 빈 배치 처리(if num_tokens == 0)가 edge case를 방어합니다.

이론적으로 모든 토큰이 무시되면 0으로 나누게 됩니다. 이 경우 0.0 손실을 반환하되, requires_grad=True로 계산 그래프를 유지하여 역전파가 중단되지 않도록 합니다.

실제로는 드물지만 데이터 버그나 극단적인 필터링에서 발생할 수 있습니다. 다섯 번째로, _compute_metrics 메서드가 관찰 가능성을 제공합니다.

단순 손실 값만이 아니라 perplexity(언어 모델 표준 지표), 최대 토큰 손실(어려운 토큰 식별), 패딩 비율(배칭 효율성) 등 다양한 메트릭을 계산합니다. with torch.no_grad() 안에서 계산하여 불필요한 그래디언트 추적을 피합니다.

return_metrics 플래그로 선택적 활성화가 가능하여 오버헤드를 제어할 수 있습니다. 여섯 번째로, get_epoch_metrics는 에포크 전체의 통계를 제공합니다.

각 배치의 메트릭을 누적하여 전체 평균을 계산합니다. 이것이 중요한 이유는 배치별 손실은 노이즈가 많지만, 에포크 평균은 안정적인 트렌드를 보여주기 때문입니다.

TensorBoard나 Weights & Biases 같은 로깅 도구와 쉽게 통합됩니다. 여러분이 이 코드를 사용하면 프로덕션 환경에서 즉시 사용 가능한 견고한 손실 함수를 얻고, 다양한 특수 토큰과 마스킹 조건을 유연하게 처리하며, 상세한 메트릭으로 훈련 과정을 깊이 이해할 수 있습니다.

실전 팁

💡 대규모 vocab_size에서는 F.cross_entropy가 메모리를 많이 사용합니다. Adaptive softmax(torch.nn.AdaptiveLogSoftmaxWithLoss)로 대체하면 10배 이상 빨라질 수 있습니다.

💡 이 클래스를 상속하여 프로젝트별 커스터마이징을 하세요. 예: 특정 토큰에 가중치를 주는 weighted loss, focal loss로 hard example 강조 등.

💡 분산 훈련에서는 메트릭 누적 전에 torch.distributed.all_reduce로 모든 GPU의 값을 합산해야 정확한 에포크 메트릭을 얻습니다.

💡 추론(inference) 시에는 self.eval() 모드를 사용하고, label_smoothing을 비활성화하세요. 훈련 설정 그대로 추론하면 부정확한 확률을 얻습니다.

💡 단위 테스트를 작성하세요. 알려진 입력(예: 모두 같은 토큰)에 대해 예상 손실을 계산하고, 구현이 수학과 일치하는지 확인합니다. pytest의 @pytest.mark.parametrize로 다양한 케이스를 자동 테스트할 수 있습니다.


#Python#Transformer#Loss Function#Masking#Deep Learning#ai

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.