인공지능/자연어 처리

자연어 처리 python 실습 - 한국어 기계 번역 데이터 수집 및 전처리

이게될까 2024. 4. 26. 18:26
728x90
728x90

Introduction

Chapter 8. 기계 번역 (Machine Translation) Task 강의의 한국어 기계 번역 실습 (1) 데이터 수집 및 전처리 강의입니다.

이번 실습에서는 (1) 영어-한국어 번역 모델을 학습하기 위한 영어-한글 병렬 코퍼스를 수집하고, (2) 수집한 병렬 코퍼스를 전처리하는 과정을 진행해보겠습니다.

이번 강의에서는 번역 모델의 입/출력을 만들기 위해 자주 사용되는 여러가지 자연어 전처리 기술을 소개하며, 특히, 원본 언어의 문장(Source)을 입력으로 받고, 타겟 언어의 번역 결과(Target)을 출력하는 번역 모델의 특징에 맞춰 실습을 진행합니다.

1. 문장 전처리기 만들기

영어-한국어 병렬 코퍼스는 Source, Target 각각 하나의 문장으로 이루어져 있습니다.

영어-한국어 번역의 경우 source는 한국어 문장, target은 영어 문장에 해당합니다.

모델에 해당 정보를 전달하기 위해서는 하나의 문장을 여러 단어로 분리하고 각각의 단어를 토큰의 id로 바꿔줄 수 있는 token2id 사전이 필요합니다.

하나의 문장을 여러 단어로 변환 -> 토큰화

단어를 각각의 토큰 id로 변환 -> 토큰 투 아이디 사전

토큰 아이디를 보고 복구하는 과정도 필요하다. -> 아이디 투 토큰 사전


따라서, 주어진 문장쌍(Source, Target)을 토큰 id 단위로 바꾸어주는 전처리 함수를 작성합니다.

번역 모델에서 Target 문장에는 sos(start of sentence), eos(end of sentence) 토큰이 추가되고 각각은 문장의 시작과 끝을 알려주는 토큰으로 사용됩니다.
이번 실습에서 고려할 규칙은 다음과 같습니다.

1. 각 문장은 token2id를 활용하여 고유의 번호를 가진 토큰 id의 수열로 바뀜
2. token2id에 맞는 토큰이 없을 경우 <UNK> 토큰으로 처리함
3. Source 문장은 src_token2id로, Target 문장은 tgt_token2id를 사용해야함
4. Target 문장의 처음에는 <SOS> 토큰을, 문장의 끝에는 <EOS> 토큰을 넣음
5. 전처리된 문장의 총 토큰 개수는 max_len 이하로 구성됨

전처리 함수를 만들기 전에, 원본, 타겟 언어에서 공통으로 사용할 수 있는 언어 클래스를 선언합니다.

from typing import List, Dict, Tuple, Sequence
from collections import Counter
from itertools import chain

class Language(Sequence[List[str]]): #사전을 만들고 관리해준다.
    PAD_TOKEN = '<PAD>' # 길이 관리
    PAD_TOKEN_ID = 0
    UNK_TOKEN = '<UNK>' # 없는 단어
    UNK_TOKEN_ID = 1
    SOS_TOKEN = '<SOS>' # 시작
    SOS_TOKEN_ID = 2
    EOS_TOKEN = '<EOS>' # 끝
    EOS_TOKEN_ID = 3

    def __init__(self, sentences: List[str]) -> None:
        self._sentences: List[List[str]] = [sentence.split() for sentence in sentences] # 토큰화하기

        self.token2id: Dict[str, int] = None 
        self.id2token: List[str] = None

    def build_vocab(self, min_freq: int=1) -> None:
        SPECIAL_TOKENS: List[str] = [Language.PAD_TOKEN, Language.UNK_TOKEN, Language.SOS_TOKEN, Language.EOS_TOKEN]
        self.id2token = SPECIAL_TOKENS + [word for word, count in Counter(chain(*self._sentences)).items() if count >= min_freq] # 몇번 이상 나온 단어들만 사전에 넣는다.
        self.token2id = {word: idx for idx, word in enumerate(self.id2token)}

    def set_vocab(self, token2id: Dict[str, int], id2token: List[str]) -> None:
        self.token2id = token2id
        self.id2token = id2token

    def __getitem__(self, index: int) -> List[str]:
        return self._sentences[index]

    def __len__(self) -> int:
        return len(self._sentences)

사전 정의한 언어 클래스를 활용하여 전처리 함수를 작성합니다.

def preprocess(
    raw_src_sentence: List[str],
    raw_tgt_sentence: List[str],
    src_token2id: Dict[str, int],
    tgt_token2id: Dict[str, int],
    max_len: int
) -> Tuple[List[int], List[int]]:

    # 특수 토큰 정의
    unk_token_id = Language.UNK_TOKEN_ID
    sos_token_id = Language.SOS_TOKEN_ID
    eos_token_id = Language.EOS_TOKEN_ID

    src_sentence = list(map(lambda word: src_token2id.get(word, unk_token_id), raw_src_sentence[:max_len]))
    tgt_sentence = [sos_token_id] + list(map(lambda word: tgt_token2id.get(word, unk_token_id), raw_tgt_sentence[:max(max_len-2, 0)])) + [eos_token_id] # EOS와 SOS가 추가되었다.

    return src_sentence, tgt_sentence

preprocess 함수에 대한 테스트 코드

eng_sentences = ["Machine translation work is fun", "I'm going to eat more in the new year"]
kor_sentences = ["기계 번역 작업은 재미있다", "새해에는 밥을 더 먹어야지!"]
# 병렬 코퍼스여야 되니까 개수가 같아야 된다.
english = Language(eng_sentences)
korean = Language(kor_sentences)

english.build_vocab()
korean.build_vocab()

for eng_sen, kor_sen in zip(eng_sentences, kor_sentences):
  source, target = preprocess(eng_sen, kor_sen, english.token2id, korean.token2id, max_len=100)

  print(f"source is {source}")
  print(f"target is {target}")

source is [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
target is [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3]
source is [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
target is [2, 1, 1, 1, 1, 1, 1, 1, 1, 10, 1, 1, 1, 1, 1, 1, 3]

 

2. 데이터 클래스 선언

위에서 작성한 전처리 코드로 기계번역을 위한 데이터 셋을 만들어 보겠습니다.

class NMTDataset(Sequence[Tuple[List[int], List[int]]]):
    def __init__(self, source, target, max_len: int=30) -> None:
      assert len(source) == len(target)
      assert source.token2id is not None and target.token2id is not None

      self.source = source
      self.target = target
      self.max_len = max_len

    def __getitem__(self, index: int):
      return preprocess(
        self.source[index],
        self.target[index],
        self.source.token2id,
        self.target.token2id,
        self.max_len
      )

    def __len__(self) -> int:
      return len(self.source)
      
eng_sentences = ["Machine translation work is fun", "I'm going to eat more in the new year", "I'll be out early tomorrow morning"]
kor_sentences = ["기계 번역 작업은 재미있다", "새해에는 밥을 더 먹어야지!", "내일 아침에는 일찍 나갈거야"]

english = Language(eng_sentences)
korean = Language(kor_sentences)

english.build_vocab()
korean.build_vocab()

dataset = NMTDataset(source=english, target=korean)

print(dataset[0])

([4, 5, 6, 7, 8], [2, 4, 5, 6, 7, 3]) 

소스와 타겟에 대한 토큰들이 들어가 있는 것을 확인할 수 있다.

3. 데이터셋 전처리 (1) Collating

  • 문장들을 빠르게 처리하기 위해서는 병렬화가 필요하나, 문장들의 길이가 다 다르기 때문에 이를 배치화 하는 것은 쉽지 않습니다.
  • 다양한 길이의 문장을 배치화하기 위하여 한 배치 내의 최대 길이 문장을 기준으로 문장에 패딩을 넣는 과정이 필요합니다.
  • 패드을 넣기 위하여 PAD 라는 사전에 정의한 패드 토큰을 사용합니다.

예를 들어, 길이가 다른 문장들로 동일한 행렬 연산을 진행하기 위해서는 다음과 같은 두 가지 솔루션을 고려할 수 있음

  1. 전체 데이터셋을 가장 길이가 긴 문장에 맞춤 - 다 엄청 길어진다.
  2. 배치를 생성하여, 배치 마다 동적으로 패딩 - 배치안의 최대 길이로 맞춰준다. -> 짧은 곳은 짧아진다.

출처 : https://plainenglish.io/blog/understanding-collate-fn-in-pytorch-f9d1742647d3
첫 번째 솔루션은 더 간단해 보이나, 모든 문장들을 데이터셋 내 가장 길이가 긴 문장에 맞추게 되면 결과에 영향을 미치지 않는 패딩 처리를 위해 메모리와 컴퓨터 성능을 낭비하게 됨 -> 한개만 엄청 길다면 리소스 낭비가 엄청나다.


이에 대한 대안은 데이터를 배치로 구성하여, 배치 단위로 가장 긴 문장 길이에 배치 내 모든 문장들을 맞추는 것

 


Collate 함수는 주어진 데이터셋을 원하는 형태의 배치로 가공하기 위해 사용되는 함수로, 배치 단위별로 최대 길이에 맞게 패드 토큰을 추가할 수 있음!
아래와 같이 collate_fn을 작성하면 torch.utils.data.dataloader.DataLoade의 collate_fn 인자를 통해 사용할 수 있습니다.

#torch.nn.utils.rnn.pad_sequence 함수를 활용하면 패딩 기능을 쉽게 구현할 수 있음!

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

def collate_fn(batched_samples: List[Tuple[List[int], List[int]]]):
  PAD = Language.PAD_TOKEN_ID

  source_sentence_list, target_sentence_list = zip(*batched_samples)

  source_sentences = pad_sequence([
      torch.Tensor(sentence).to(torch.long) for sentence in source_sentence_list
  ], batch_first=True, padding_value=PAD) # 텐서 변환, 배치 내부에 가장 긴 문장에 따라 패딩 채우기

  target_sentences = pad_sequence([
      torch.Tensor(sentence).to(torch.long) for sentence in target_sentence_list
  ], batch_first=True, padding_value=PAD) # 맥스 길이가 달라질 수 있다.

  batch_size = len(batched_samples)

  assert source_sentences.shape[0] == batch_size and target_sentences.shape[0] == batch_size
  assert source_sentences.dtype == torch.long and target_sentences.dtype == torch.long
  # 맥스 사이즈는 다를 수 있어도 배치 사이즈는 같아야 한다!
  
  return source_sentences, target_sentences

collate_fn 함수에 대한 테스트 코드

batched_samples = [([1, 2, 3, 4], [1]), ([1, 2, 3], [1, 2]), ([1, 2], [1, 2, 3]), ([1], [1, 2, 3, 4])]
#길이가 잘 맞춰지는지 확인!
source_sentences, target_sentences = collate_fn(batched_samples)

print(f"source_sentences is {source_sentences}")
print(f"target_sentences is {target_sentences}")

source_sentences is tensor([[1, 2, 3, 4],
        [1, 2, 3, 0],
        [1, 2, 0, 0],
        [1, 0, 0, 0]])
target_sentences is tensor([[1, 0, 0, 0],
        [1, 2, 0, 0],
        [1, 2, 3, 0],
        [1, 2, 3, 4]])

가장 길이가 긴 4에다 잘 맞춰준다. 소스가 5가 최대였다면 소스는 5에, 타겟은 4에 맞춰진다.

batched_samples = [([1], [1, 2, 3]), ([1, 2], [1, 2, 3]), ([1, 2, 3], [1, 2]), ([1, 2, 3, 4], [1])]
source_sentences, target_sentences = collate_fn(batched_samples)

print(f"source_sentences is {source_sentences}")
print(f"target_sentences is {target_sentences}")

source_sentences is tensor([[1, 0, 0, 0],
        [1, 2, 0, 0],
        [1, 2, 3, 0],
        [1, 2, 3, 4]])
target_sentences is tensor([[1, 2, 3],
        [1, 2, 3],
        [1, 2, 0],
        [1, 0, 0]])

3개짜리에 맞춰서 패딩이 붙었다. - 소스와 타겟은 따로 작동된다.

 

4. 데이터셋 전처리 (2) Bucketing

Bucketing은 주어진 문장의 길이에 따라 데이터를 그룹화하여 패딩을 적용하는 기법으로,

Bucketing을 사용하면 모델의 학습 시간을 단축할 수 있음

예를 들어, bucketing을 적용하지 않은 경우, 배치에 패드 토큰의 개수가 늘어나 학습하는 데에 오랜 시간이 걸림

그에 비하여 아래 그림과 같이 문장의 길이에 따라 미리 그룹화하여 패딩을 적용하면 학습을 효율적으로 진행할 수 있습니다!

출처 : https://developers.google.com/machine-learning/data-prep/transform/bucketing

문장을 길이별로 sort하고, 배치가 길이순으로 정렬되기 때문에 pad를 많이 추가하지 않아도 된다. -> 비효율 방지하여 학습 속도 향상!


기계 번역 작업에서는 한 학습 샘플 안에 Source 문장과 Target 문장이 들어가 있으므로 한 번에 두 개의 문장을 고려해야 하는데,가장 쉽게 고려할 수 있는 방법은 한 쪽 언어를 기준으로 Bucketing 하는 것

일반적인 언어 조합에서 짧은 문장에 대한 번역문은 짧은 문장이기 때문에 Source 문장과 Target 문장은 대략 비슷한 길이를 가질 것 - 소스와 타겟은 일반적으로 비례한다.

따라서 Source를 기준으로 Bucketing을 하면 Target 쪽에 대해서도 괜찮은 Bucketing을 근사할 수 있음!
이번 실습에서는 그러한 가정을 하지 않고 Source 문장과 Target 문장을 모두 고려하는 Bucketing을 구현해봅시다.

import random
from collections import defaultdict

def bucketed_batch_indices(
    sentence_length: List[Tuple[int, int]],
    batch_size: int,
    max_pad_len: int
):
    bucket_dict = defaultdict(list)

    for index, (source_len, target_len) in enumerate(sentence_length):
        bucket_dict[(source_len // max_pad_len, target_len // max_pad_len)].append(index)

    batch_indices_list = [bucket[start:start+batch_size] for bucket in bucket_dict.values() for start in range(0, len(bucket), batch_size)]

    # 문장을 순서대로 배치하면 길이에 대한 편향이 생길 수 있으므로, 무작위로 섞어줘야 함
    random.shuffle(batch_indices_list) # 배치단위로 섞어준다.
	
    return batch_indices_list

bucketed_batch_indices 함수에 대한 테스트 코드

dataset_length = 1000
min_len = 10
max_len = 30
batch_size = 64
max_pad_len = 5

sentence_length = [(random.randint(min_len, max_len), random.randint(min_len, max_len)) for _ in range(dataset_length)]

batch_indices = bucketed_batch_indices(
  sentence_length,
  batch_size=batch_size,
  max_pad_len=max_pad_len
)

print(sorted(chain(*batch_indices)))
for idx, batch in enumerate(batch_indices):
    source_len, target_len = zip(*list(sentence_length[idx] for idx in batch))

    print(f"source_len is {source_len}")
    print(f"target_len is {target_len}")

    if idx > 10:
      break

source_len is (23, 23, 24, 22, 23, 24, 24, 24, 21, 24, 22, 21, 23, 23, 24, 22, 24, 22, 22, 22, 24, 23, 22, 21, 20, 20, 21, 21, 20, 23, 20, 24, 24, 24, 24, 20, 20, 24, 24, 24, 23, 22, 24, 23, 20, 22, 24, 20, 24, 24, 22, 23, 24, 21, 23, 24, 24)
target_len is (21, 21, 20, 24, 24, 24, 21, 21, 20, 21, 23, 21, 23, 22, 20, 22, 21, 23, 22, 21, 21, 24, 20, 24, 24, 24, 23, 22, 20, 22, 24, 22, 23, 21, 21, 24, 23, 20, 24, 22, 24, 21, 20, 23, 21, 20, 22, 21, 21, 24, 24, 23, 23, 23, 20, 22, 21)
source_len is (13, 12, 11, 12, 12, 14, 13, 10, 11, 12, 13, 11, 12, 12, 10, 14, 12, 14, 13, 13, 10, 13, 14, 11, 12, 10, 10, 12, 10, 11, 12, 13, 14, 11, 11, 12, 13, 13, 10, 14, 12, 14, 12, 14, 11, 10, 12, 11, 14, 10, 12, 14, 10, 14, 10, 11, 11, 14, 12, 13, 13, 12, 11, 13)
target_len is (16, 19, 17, 17, 16, 17, 18, 17, 15, 17, 19, 19, 17, 15, 17, 17, 16, 17, 18, 19, 18, 19, 19, 17, 18, 19, 16, 19, 16, 16, 18, 16, 18, 19, 17, 19, 15, 15, 15, 18, 19, 19, 16, 18, 17, 16, 16, 16, 17, 16, 16, 15, 19, 18, 15, 15, 15, 18, 17, 16, 16, 18, 16, 18)
source_len is (29, 26, 29, 28, 27, 25, 28, 27, 28, 28, 25, 26, 29, 27, 27, 28, 29, 25, 27, 28, 28, 28, 25, 25, 28, 27, 29, 27, 26, 28, 29, 26, 27, 28, 29, 26, 29, 28, 25, 26, 28, 25, 28, 29, 29, 27, 25, 25, 25, 26, 28, 27, 27, 26, 29, 25, 28, 29, 25, 27)
target_len is (12, 10, 11, 13, 10, 10, 10, 14, 11, 11, 12, 12, 14, 13, 13, 14, 10, 12, 14, 11, 13, 11, 12, 12, 10, 10, 14, 11, 12, 14, 14, 11, 14, 10, 12, 11, 13, 14, 11, 10, 13, 11, 14, 13, 12, 12, 10, 13, 14, 12, 11, 10, 13, 14, 10, 10, 10, 10, 13, 13)
source_len is (24, 24, 23, 23, 20, 24, 20, 20, 21, 22)
target_len is (30, 30, 30, 30, 30, 30, 30, 30, 30, 30)
source_len is (30, 30, 30, 30, 30, 30, 30, 30, 30, 30)
target_len is (15, 15, 15, 19, 19, 19, 18, 16, 17, 19)
source_len is (30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30)
target_len is (28, 26, 28, 27, 26, 28, 27, 27, 27, 28, 28, 26, 25, 28, 26, 26, 27, 25, 25, 26, 26, 27)
source_len is (25, 25, 28, 28, 28, 26, 28, 28, 27, 26, 27, 28, 25, 28, 26, 28, 27, 29, 27, 25, 26, 26, 28, 27, 26, 27, 28, 25, 26, 29, 25, 28, 25, 28, 25, 28, 29, 27, 29, 29, 25, 27, 29, 29, 29, 29, 26, 28, 29, 27, 29, 29, 27, 26, 29, 29, 26, 29, 26, 29, 25, 27, 27, 26)
target_len is (17, 19, 16, 17, 16, 19, 18, 17, 16, 15, 19, 15, 17, 18, 15, 16, 15, 19, 19, 16, 19, 15, 16, 19, 17, 15, 19, 15, 16, 17, 17, 19, 18, 18, 15, 18, 17, 19, 16, 18, 16, 15, 18, 17, 17, 19, 19, 19, 17, 17, 16, 19, 15, 16, 15, 18, 19, 17, 17, 16, 15, 19, 19, 17)
source_len is (25, 25, 26, 27, 28, 28, 29, 29, 25, 25)
target_len is (30, 30, 30, 30, 30, 30, 30, 30, 30, 30)
source_len is (30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30)
target_len is (14, 12, 13, 10, 13, 11, 10, 10, 14, 10, 11, 12, 14, 14, 12, 13)
source_len is (10, 12, 14, 13, 12, 14, 10, 11, 10, 14, 11, 11, 11, 10, 13, 14, 11, 11, 14, 12, 12, 13, 11, 13, 12, 11, 12, 11, 13, 12, 13, 13, 11, 10, 12, 13, 14, 11, 14, 13, 13, 11, 11, 11, 10, 12, 10, 12, 14, 12, 11, 13, 13, 14, 12, 13, 11, 11, 12, 14, 13, 14, 12, 10)
target_len is (24, 24, 20, 24, 22, 20, 20, 23, 23, 21, 21, 23, 21, 24, 22, 21, 20, 24, 21, 24, 22, 20, 20, 23, 21, 21, 20, 24, 20, 22, 22, 23, 22, 24, 20, 24, 22, 20, 23, 20, 23, 20, 23, 20, 20, 20, 20, 24, 23, 21, 20, 23, 24, 22, 23, 22, 21, 20, 20, 22, 21, 22, 24, 24)
source_len is (19, 19, 16, 15, 19, 19, 15, 17, 16, 19, 19, 15, 19, 16, 19, 19, 16, 19, 15, 18, 17, 19, 19, 15, 17, 16, 16, 18, 15, 18, 19, 19, 18, 15, 18, 18, 17, 18, 19, 17, 18, 17, 16, 16, 18, 18, 19, 17, 17, 16, 18, 16, 17, 18, 18, 16, 16, 16)
target_len is (17, 15, 18, 18, 19, 19, 17, 16, 19, 16, 19, 19, 19, 18, 19, 19, 16, 15, 19, 15, 15, 16, 15, 17, 18, 18, 16, 18, 19, 16, 15, 17, 15, 16, 15, 18, 17, 16, 17, 18, 15, 17, 16, 19, 17, 15, 16, 17, 19, 15, 19, 18, 17, 17, 18, 17, 19, 19)
source_len is (26, 26, 27, 25, 26, 25, 25, 28, 29, 29, 29, 29, 29, 26, 29, 25, 28, 29, 28, 28, 26, 29, 27, 29, 25, 25, 25, 26, 25, 27, 29, 25, 29, 27, 25, 26, 29, 28, 26, 25, 25, 28, 25, 29, 28)
target_len is (24, 24, 22, 24, 22, 22, 22, 23, 22, 20, 21, 21, 23, 21, 24, 24, 23, 20, 23, 23, 20, 20, 24, 22, 22, 23, 24, 23, 22, 23, 20, 23, 20, 20, 23, 22, 20, 20, 23, 23, 21, 22, 20, 21, 23)

비슷한 범주의 애들이 들어간 것을 확인할 수 있다.

최종 전처리 확인

eng_sentences = ["Machine translation work is fun", "I'm going to eat more in the new year", "I'll be out early tomorrow morning"] * 5
kor_sentences = ["기계 번역 작업은 재미있다", "새해에는 밥을 더 먹어야지!", "내일 아침에는 일찍 나갈거야"] * 5

english.build_vocab()
korean.build_vocab()

dataset = NMTDataset(source=english, target=korean) # 인덱스 타입으로 변환해줬다.

batch_size = 2
max_pad_len = 5

sentence_length = list(map(lambda pair: (len(pair[0]), len(pair[1])), dataset))

batch_sampler = bucketed_batch_indices(sentence_length, batch_size=batch_size, max_pad_len=max_pad_len)

dataloader = torch.utils.data.dataloader.DataLoader(dataset, collate_fn=collate_fn, batch_sampler=batch_sampler)

iterator = iter(dataloader)

source_sentences, target_sentences = next(iterator)
print(f"source_sentences: {source_sentences}")
print(f"target_sentences: {target_sentences}")

source_sentences: tensor([[18, 19, 20, 21, 22, 23]])
target_sentences: tensor([[ 2, 12, 13, 14, 15,  3]])

배치 내부에 최대한 비슷한 애들이 들어가 있다.

728x90