텍스트 데이터와 토큰화
Part 1 에서 다룬 이미지 데이터는 픽셀이라는 연속적인 수치로 이미 표현되어 있어, 그대로 신경망에 흘려보낼 수 있었습니다. 그러나 자연어는 사정이 다릅니다. 문장은 이산적인 기호의 연속이며, 신경망이 입력으로 받을 수 있는 정수 시퀀스로 바꾸는 전처리 단계가 반드시 앞에 놓입니다.
이 장에서는 한국어·영어 병렬 말뭉치를 출발점으로, 텍스트를 어떻게 신경망이 다룰 수 있는 형태로 바꾸는지 따라갑니다. 핵심은 세 단계입니다. (1) 원시 텍스트를 살펴보고 정제하는 단계, (2) 문장을 토큰으로 쪼개고 각 토큰에 정수 ID 를 부여하는 토큰화(tokenization), (3) 가변 길이의 정수 시퀀스를 배치 단위로 묶을 수 있도록 길이를 맞추는 패딩입니다. 마지막에는 이렇게 만든 시퀀스를 임베딩층에 통과시켜 간단한 텍스트 분류기까지 학습시켜 봅니다.
1텍스트 데이터¶
데이터는 data/koen 폴더 아래에 split(train/validation/test)별 CSV 파일로 나뉘어 있습니다. 각 파일을 읽어 datasets 딕셔너리에 담고, split 별 행 수를 확인합니다.
from pathlib import Path
폴더 = Path('data/koen')
assert 폴더.is_dir(), f'{폴더} 폴더가 존재하지 않습니다.'
파일들 = list(폴더.glob('**/*.csv'))
datasets = {}
for 파일 in 파일들:
split = 파일.stem
datasets[split] = pd.read_csv(파일)
print(f'{split:<10}: {len(datasets[split]):,}')datasets['train'].sample(5)각 행에는 한국어 문장(ko)과 그에 대응하는 영어 문장(en), 그리고 문장의 출처를 나타내는 source 코드가 들어 있습니다. 말뭉치가 어떤 종류의 텍스트로 구성되어 있는지 파악하기 위해 source 코드별 도수와 비율을 확인합니다.
분류코드 = datasets['train']['source']
분류코드도수 = 분류코드.value_counts()
pd.DataFrame({'도수': 분류코드도수, '비율': 분류코드도수 / sum(분류코드도수)}).round(3)출처 코드가 가리키는 텍스트의 성격은 실제 문장을 들여다보면 분명해집니다. 같은 한국어라도 일상 대화에 쓰이는 구어체와 법률·기술 문서에 쓰이는 특허 텍스트는 어휘와 문장 구조가 크게 다릅니다. 두 부류를 각각 표본으로 뽑아 비교해 봅시다. 이 차이는 이 장 후반의 텍스트 분류 과제에서 두 클래스를 가르는 신호가 됩니다.
분류필터 = datasets['train']['source'] == 71265 # 구어체 텍스트
datasets['train'][분류필터].sample(5)분류필터 = datasets['train']['source'] == 563 # 특허 텍스트
datasets['train'][분류필터].sample(5)2형태소와 토큰화¶
텍스트를 신경망에 넣으려면 먼저 문장을 토큰이라는 더 작은 단위로 쪼개야 합니다. 그렇다면 어떤 단위로 쪼갤 것인가요? 언어학에서 그 자연스러운 후보가 형태소(morpheme) 입니다.
형태소는 일정한 의미가 있는 가장 작은 말의 단위로, 발화체 안에서 따로 떼어낼 수 있는 것을 뜻합니다. 즉 더 쪼개면 뜻이 사라지는 말의 단위입니다. 한국어의 형태소는 크게 두 갈래로 나뉩니다.
| 구분 | 설명 | 예 |
|---|---|---|
| 어휘 형태소 | 독립적인 의미를 가지는 단위 | 명사, 동사·형용사의 어간 |
| 문법 형태소 | 문법적 기능을 하며, 어휘 형태소와 결합한 상태로 활용 | 조사, 어미 |
한국어가 영어와 크게 다른 점이 여기 있습니다. 영어는 단어 사이가 공백으로 분리되지만, 한국어는 어휘 형태소에 조사·어미 같은 문법 형태소가 달라붙어 한 어절을 이룹니다. 따라서 단순한 공백 분리만으로는 의미 단위를 제대로 끊어낼 수 없습니다.
이 문제를 규칙으로 일일이 풀기보다, 우리는 말뭉치 자체로부터 적절한 분절 단위를 학습하는 방법을 택합니다. 통계적으로 자주 함께 등장하는 글자 조각을 하나의 하위 단어(subword) 토큰으로 묶는 방식입니다. 이렇게 하면 자주 쓰이는 형태소는 통째로, 드문 단어는 더 작은 조각으로 쪼개어 표현할 수 있어, 어휘 밖(OOV) 단어 문제도 자연스럽게 완화됩니다. 이를 구현한 대표적인 도구가 SentencePiece 입니다.
3토큰화¶
각 문장을 한 줄씩 텍스트 파일에 기록
sententcepiece 모델 훈련
토큰화 모델을 학습시키는 절차는 두 단계입니다. 먼저 각 문장을 한 줄에 하나씩 텍스트 파일로 기록하고, 그 파일을 입력으로 SentencePiece 모델을 훈련합니다. 한국어와 영어 문장을 각각 별도의 파일로 내보냅니다.
with open('한국어문장.txt', 'w') as 파일:
for 색인, 행 in datasets['train'].iterrows():
파일.write(행['ko'] + '\n')
# 결과 확인
with open('한국어문장.txt', 'r') as 파일:
for _ in range(5):
print(파일.readline().strip())with open('영어문장.txt', 'w') as 파일:
for 색인, 행 in datasets['train'].iterrows():
파일.write(행['en'] + '\n')
# 결과 확인
with open('영어문장.txt', 'r') as 파일:
for _ in range(5):
print(파일.readline().strip())pip install sentencepiece이제 두 파일을 입력으로 SentencePiece 모델을 훈련합니다. 주요 인자는 다음과 같습니다.
vocab_size: 학습할 어휘(토큰)의 총 개수. 여기서는 30,000 개로 설정합니다.input_sentence_size/shuffle_input_sentence: 전체 말뭉치 중 무작위로 일부 문장만 골라 훈련합니다. 대규모 말뭉치에서 학습 시간을 줄이기 위한 표본 추출입니다.특수 토큰 ID: 시퀀스 처리에 필요한 네 가지 토큰을 명시적으로 지정합니다.
| 토큰 | 역할 |
|---|---|
pad (0) | 패딩 — 배치 내 길이 정규화 |
unk (1) | 모름(unknown) — 어휘 밖(OOV) 단어 |
bos (2) | 문장 시작(Begin of Sentence) |
eos (3) | 문장 끝(End of Sentence) |
학습이 끝나면 model_prefix 에 지정한 이름으로 spm.model(모델)과 spm.vocab(어휘 목록) 파일이 생성됩니다.
import sentencepiece as spm
어휘수 = 30000
문장수 = 100000
spm.SentencePieceTrainer.train(
# 유의! 각 파일경로는 공백없이 쉼표로 구분
input='한국어문장.txt,영어문장.txt',
model_prefix='spm',
vocab_size=어휘수,
# 무작위로 선택된 문장으로 모델 훈련
input_sentence_size=문장수, # 훈련에 사용할 문장 수
shuffle_input_sentence=True, # 입력 문장 순서 섞기
# model_type='bpe' # Byte Pair Encoding (BPE): 1990년대
# 특수토큰 지정
pad_id=0, unk_id=1, bos_id=2, eos_id=3
)학습된 토크나이저를 불러와 실제 문장에 적용해 봅니다. encode 는 두 가지 출력을 낼 수 있습니다. out_type=str 은 토큰 조각(형태소) 자체를, out_type=int 은 각 토큰에 대응하는 정수 ID 를 돌려줍니다. 한국어 문장은 형태소 단위로, 영어 문장은 하위 단어 단위로 분절되는 모습을 확인할 수 있습니다. 마지막으로 decode 로 정수 시퀀스를 다시 원문으로 복원하면, 토큰화가 가역적임을 알 수 있습니다.
예문 = [
'한국어 문장을 형태소 단위로 분리합니다.',
'tokenize an English sentence into subwords.',
]
tokenizer = spm.SentencePieceProcessor(model_file='spm.model')
for text in 예문:
형태소목록 = tokenizer.encode(text, out_type=str)
정수시퀀스 = tokenizer.encode(text, out_type=int)
display(pd.DataFrame({
'형태소': 형태소목록,
'정수': 정수시퀀스
}).T)
텍스트복원 = tokenizer.decode(정수시퀀스)
print(텍스트복원)정수 ID 가 어떻게 배정되었는지 앞쪽 ID 들을 직접 들여다봅시다. 0~3 번은 앞서 지정한 특수 토큰(pad/unk/bos/eos)이 차지하고, 그 뒤로 말뭉치에서 학습된 일반 토큰들이 이어집니다.
# 정수ID
pd.DataFrame({
'토큰': [tokenizer.id_to_piece(i) for i in range(15)]
}).T토큰화를 데이터 파이프라인에 끼워 넣으려면, 텍스트를 정수 시퀀스로 바꾸는 변환을 데이터셋에 위임하는 것이 깔끔합니다. PyTorch 의 데이터셋 규약(__len__, __getitem__)을 따르는 TextDataset 을 정의하고, transform 으로 토큰화 함수를 주입합니다. 이렇게 하면 데이터셋이 인덱싱될 때마다 원문이 즉석에서 정수 시퀀스로 변환됩니다.
class TextDataset:
def __init__(self, texts, labels=None, transform=None):
self.texts = texts
self.labels = labels
self.transform = transform
def __len__(self):
return len(self.texts)
def __getitem__(self, 색인):
sample = self.texts[색인]
if self.transform:
sample = self.transform(sample)
if self.labels is not None:
label = self.labels[색인]
return sample, label
return sample
전처리 = lambda text: tokenizer.encode(text, out_type=int)
datasets = {}
for 파일경로 in 파일들:
frame = pd.read_csv(파일경로)
split = 파일경로.stem
datasets[split] = TextDataset(frame['ko'], transform=전처리)
sample = datasets['train'][0]
print(sample)4텍스트 분류¶
이제 토큰화한 텍스트로 실제 학습 과제를 풀어 봅니다. 앞서 본 두 부류, 구어체(71265) 와 특허(563) 텍스트를 구분하는 이진 분류기입니다.
데이터를 준비할 때 몇 가지 처리를 더합니다. 우선 결측값을 제거하고, 두 클래스에서 같은 수만큼 표본을 뽑아 클래스 균형을 맞춥니다. 레이블은 구어체를 0, 특허를 1 로 매핑합니다. 전처리 함수는 토큰화한 뒤 시퀀스를 최대 길이(여기서는 300)로 자릅니다 — 지나치게 긴 문서가 메모리와 계산을 압도하지 않도록 하는 안전장치입니다.
def 전처리(text, 최대길이):
정수시퀀스 = tokenizer.encode(text, out_type=int)
return 정수시퀀스[:최대길이]
datasets = {}
for split, 샘플수 in [('train', 10000), ('validation', 1000), ('test', 1000)]:
frame = pd.read_csv(폴더 / f'{split}.csv')
frame.dropna(inplace=True) # 결측값 제거
# 각 클래스에서 샘플링
구어체 = frame[frame['source'] == 71265].sample(샘플수, random_state=0)
특허 = frame[frame['source'] == 563].sample(샘플수, random_state=0)
samples = pd.concat([구어체, 특허], ignore_index=True)
datasets[split] = TextDataset(
texts=samples['ko'].tolist(),
# 텍스트 분류를 위해 클래스 레이블도 함께 제공해야 합니다.
labels=samples['source'].map({71265: 0, 563: 1}).tolist(),
transform=lambda text: 전처리(text, 최대길이=300)
)
print(f'{split:<10}: {len(datasets[split]):,}')
# 유형 도수 확인
print(samples['source'].value_counts().to_string())sample, label = datasets['train'][10001]
print(f'label: {label}\nsample: {sample}')
print('복원된 텍스트:', tokenizer.decode(sample))분류기를 설계하기 전에 입력 시퀀스의 길이 분포를 살펴야 합니다. 문서마다 토큰 수가 제각각이기 때문입니다. 길이의 기술 통계를 확인하면 패딩·자르기 기준을 정하는 데 도움이 됩니다.
문서길이 = [len(sample) for sample, _ in datasets['train']]
pd.Series(문서길이).describe().astype(int)신경망은 한 배치 안의 모든 샘플이 같은 길이여야 행렬 연산을 할 수 있습니다. 그래서 짧은 시퀀스는 뒤를 패딩 토큰(ID 0)으로 채우고, 긴 시퀀스는 잘라냅니다. 먼저 고정 길이로 정규화하는 기본 함수를 정의합니다.
def 시퀀스패딩(sample, 최대길이, 패딩값=0):
assert len(sample) > 0, '샘플이 비어 있습니다.'
sample = sample[:최대길이]
return sample + [패딩값] * (최대길이 - len(sample))
최대길이 = 20
정규화된_샘플 = []
for i in range(3):
sample, _ = datasets['train'][i]
정규화된_샘플.append(시퀀스패딩(sample, 최대길이))
pd.DataFrame(정규화된_샘플)그런데 모든 배치를 데이터셋 전체의 최대 길이로 맞추면, 짧은 문장이 대부분인 배치에서도 불필요하게 긴 패딩이 붙어 낭비가 생깁니다. 더 효율적인 방법은 배치마다 그 안에서 가장 긴 샘플의 길이에 맞춰 패딩하는 것입니다. 이 역할을 하는 함수를 DataLoader 의 collate_fn 으로 넘깁니다. 서로 다른 배치가 서로 다른 시퀀스 길이를 갖게 되는 것을 확인할 수 있습니다.
def 배치병합(batch):
samples, labels = zip(*batch)
문서길이 = [len(sample) for sample in samples]
최대길이 = max(문서길이) # 배치 내에서 가장 긴 샘플의 길이를 최대길이로 설정
정규화된_샘플 = [시퀀스패딩(sample, 최대길이) for sample in samples]
return np.array(정규화된_샘플), np.array(labels)
# 첫 번째 배치
sample_batch = [datasets['train'][i] for i in range(3)]
Xs, ys = 배치병합(sample_batch)
print(Xs.shape, ys.shape)
# 다른 배치
sample_batch = [datasets['train'][i] for i in range(5, 8)]
Xs, ys = 배치병합(sample_batch)
print(Xs.shape, ys.shape)배치병합 을 collate_fn 으로 지정해 DataLoader 를 구성합니다. 학습용 로더만 셔플하고, 각 배치의 시퀀스 길이가 배치별로 달라지는 동적 패딩이 실제로 동작하는지 길이 분포로 확인합니다.
dataloaders = {}
for split in datasets.keys():
dataloaders[split] = torch.utils.data.DataLoader(
datasets[split], shuffle=(split=='train'),
batch_size=32, num_workers=2, collate_fn=배치병합
)
배치시퀀스길이 = []
for X_batch, y_batch in dataloaders['train']:
# print(X_batch.shape, y_batch.shape)
배치시퀀스길이.append(X_batch.shape[1])
pd.Series(배치시퀀스길이).describe().astype(int)분류 모델은 단순하지만 텍스트 처리의 핵심 구성요소를 담고 있습니다.
임베딩층(
Embedding): 정수 토큰 ID 를 밀집 벡터로 변환합니다. 어휘 수만큼의 행과 임베딩 차원만큼의 열을 가진 행렬을 학습하며, 각 토큰은 이 행렬에서 자신의 ID 에 해당하는 행 벡터로 표현됩니다. 원-핫 표현과 달리 의미적으로 가까운 토큰이 가까운 벡터를 갖도록 학습됩니다.GlobalAveragePooling1D: 가변 길이의 토큰 벡터 시퀀스를 시퀀스 차원에 대해 평균 내어, 문서 하나를 고정 길이 벡터 하나로 요약합니다.밀집층: 요약 벡터를 받아 은닉 표현을 거쳐 하나의 출력으로 모읍니다. 이진 분류이므로 출력층은
sigmoid활성화와BinaryCrossentropy손실을 씁니다.
어휘 수는 토크나이저로부터 직접 가져와 임베딩층의 입력 차원으로 사용합니다.
import keras
from keras import layers
keras.backend.clear_session()
어휘수 = tokenizer.get_piece_size()
임베딩차원 = 64
print(f'어휘수: {어휘수}')
model = keras.Sequential([
layers.Input(shape=(None,)), # 가변 길이 시퀀스 입력
# 임베딩층: 정수 시퀀스를 밀집 벡터로 변환
layers.Embedding(input_dim=어휘수, output_dim=임베딩차원),
# 은닉층
layers.GlobalAveragePooling1D(), # 시퀀스 차원 평균
layers.Dense(64, activation='relu'),
# 출력층
layers.Dense(1, activation='sigmoid')
])
model.summary()
model.compile(
loss=keras.losses.BinaryCrossentropy(),
optimizer=keras.optimizers.RMSprop(),
metrics=['accuracy']
)
history = model.fit(
dataloaders['train'], validation_data=dataloaders['validation'],
epochs=10,
callbacks=[
keras.callbacks.ModelCheckpoint('text_classifier.keras', save_best_only=True)
]
)학습이 끝난 임베딩층의 가중치 행렬은 그 자체로 흥미로운 산물입니다. 각 행이 하나의 토큰에 대응하는 학습된 벡터이기 때문입니다. 특정 단어의 정수 ID 를 구해 해당 행 벡터를 꺼내 보면, 분류 과제를 풀기 위해 모델이 그 단어를 어떤 수치 벡터로 표현하게 되었는지 들여다볼 수 있습니다. 이 임베딩 표현은 다음 장에서 다룰 순환 신경망과 어텐션 기반 모델에서도 입력의 출발점이 됩니다.
임베딩행렬 = model.layers[0].get_weights()[0]
print(f'임베딩행렬: {임베딩행렬.shape}')
단어 = '사랑'
정수ID = tokenizer.piece_to_id(단어)
print(f'단어: {단어}, 정수ID: {정수ID}')
단어임베딩 = 임베딩행렬[정수ID]
print(f'임베딩 벡터: {단어임베딩[:10].round(3)} ...')