텍스트 생성 — 트랜스포머 이전의 언어 모델(N-gram Language Models)

단어가 예상 밖이라면 모델이 나쁜 것입니다. 퍼플렉서티(Perplexity)는 그 놀라움을 숫자로 만듭니다. 스무딩(Smoothing)은 그 숫자가 무한대가 되지 않게 합니다.

유형: Build 언어: Python 선수 강의: Phase 5 · 01 (Text Processing), Phase 2 · 14 (Naive Bayes) 예상 시간: 약 45분

학습 목표

  • 엔그램 언어 모델(N-gram language model)이 카운트(count) 기반으로 다음 단어 확률(next word probability)을 계산하는 방식을 설명합니다.
  • 0 카운트 문제(zero-count problem)와 스무딩(smoothing)이 필요한 이유를 이해합니다.
  • 라플라스 스무딩(Laplace smoothing)과 크네저-네이 스무딩(Kneser-Ney smoothing)의 차이를 설명합니다.
  • 퍼플렉서티(perplexity)를 계산하고 언어 모델 기준선(language model baseline)으로 해석합니다.

문제

트랜스포머(Transformer), 순환 신경망(Recurrent Neural Network; RNN), 단어 임베딩(word embedding)이 등장하기 전, 언어 모델(language model)은 이전 n-1개 단어 다음에 어떤 단어가 얼마나 자주 등장했는지를 세어 다음 단어를 예측했습니다. 예를 들어 the cat 다음에 sat이 47번, jumped가 12번, refrigerator가 0번 나왔다면, 이 횟수를 정규화(normalize)해 확률 분포(probability distribution)를 얻습니다.

이것이 바로 엔그램 언어 모델입니다. 1980년부터 2015년까지 모든 음성 인식기(speech recognizer), 모든 맞춤법 검사기(spell checker), 모든 구문 기반 기계 번역 시스템(phrase-based machine translation system)을 움직였습니다. 지금도 저렴한 온디바이스 언어 모델링(on-device language modeling)이 필요한 곳에서 여전히 쓰입니다.

흥미로운 문제는 보지 못한 엔그램(unseen n-gram)을 어떻게 처리할 것인가입니다. 단순 카운트 기반 모델(raw count-based model)은 학습 중에 보지 못한 시퀀스(sequence)에 확률 0을 부여합니다. 이는 치명적인 결과를 낳습니다. 문장은 길고, 거의 모든 긴 문장에는 적어도 하나의 처음 보는 시퀀스가 포함되어 있기 때문입니다. 50년에 걸친 스무딩 연구가 이 문제를 해결했습니다. 크네저-네이 스무딩(Kneser-Ney smoothing)은 그 결과물이며, 현대 딥러닝(deep learning)은 이 경험적 전통을 그대로 물려받았습니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.엔그램 언어 모델(N-gram language model)은 학습 중에 보지 못한 시퀀스(sequence)에 확률 0을 부여합니다. 이것이 실제 텍스트에서 치명적인 문제가 되는 이유는 무엇인가요?

2.퍼플렉서티(perplexity)는 언어 모델을 평가할 때 무엇을 측정하나요?

0/2 답변 완료

개념

1. count training corpus "the cat sat" "the cat ran" "the dog sat" trigram counts: (the, cat, sat): 1 (the, cat, ran): 1 (the, dog, sat): 1 (the, cat, *): 2 2. smooth raw count gives zero to unseen n-grams. Kneser-Ney redistributes: discount seen counts use continuation prob for unseen words P(w | prev) = max(c-D, 0)/N + λ · P_cont(w) 3. generate sample from P(w | prev): "the" (start) -> "cat" (p=0.6) -> "sat" (p=0.4) -> "on" -> "the" locally plausible, globally incoherent. 4. evaluate perplexity = exp(- (1/N) Σ log P(w_i | ctx)) lower = better Brown corpus: KN 4-gram ~140 transformer ~20 Kneser-Ney stayed the best n-gram smoother for 20 years. Transformers closed the perplexity gap by ~10x, not by inventing a better smoother.

엔그램 확률(N-gram probability): P(w_i | w_{i-n+1}, ..., w_{i-1})입니다. n을 고정합니다. 보통 트라이그램(trigram)은 3, 4-그램(4-gram)은 4로 둡니다. 카운트를 이용해 다음과 같이 계산합니다.

P(w | context) = count(context, w) / count(context)

0 카운트 문제(The zero-count problem). 학습(training) 중에 보지 못한 엔그램은 모두 확률 0을 받습니다. 브라운 말뭉치(Brown corpus)를 다룬 2007년 연구에 따르면, 4-그램 모델조차 보류 집합(held-out set)에 등장하는 4-그램의 30%를 학습 중에 본 적이 없었다고 합니다. 스무딩 없이 실제 텍스트를 평가할 수 없는 이유입니다.

스무딩 접근법(Smoothing approaches), 복잡도가 높아지는 순서:

  1. 라플라스(Laplace, add-one). 모든 카운트에 1을 더합니다. 단순하지만 희귀 사건(rare event)에는 매우 좋지 않습니다.
  2. 굿-튜링(Good-Turing). 빈도의 빈도(frequency-of-frequencies)를 기반으로, 더 자주 등장한 사건의 확률 질량(probability mass)을 보지 못한 사건(unseen event)으로 재분배합니다.
  3. 보간(Interpolation). 엔그램, (n-1)-그램 등의 추정값을 조정 가능한 가중치(tunable weight)로 결합합니다.
  4. 백오프(Backoff). 엔그램 카운트가 0이면 (n-1)-그램으로 물러납니다. 카츠 백오프(Katz backoff)는 이를 정규화합니다.
  5. 절대 할인(Absolute discounting). 모든 카운트에서 고정된 할인값 D를 빼고, 그렇게 확보한 질량을 보지 못한 사건에 재분배합니다.
  6. 크네저-네이(Kneser-Ney). 절대 할인에, 하위 차수 모델(lower-order model)을 선택하는 영리한 방법을 결합합니다. 단순 빈도(raw frequency) 대신, 어떤 단어가 얼마나 다양한 문맥(context)에 등장하는지를 나타내는 연속 확률(continuation probability)을 사용합니다.

크네저-네이의 통찰(insight)은 깊이가 있습니다. San Francisco는 흔한 바이그램(bigram)입니다. 유니그램(unigram) Francisco는 대부분 San 뒤에서만 등장합니다. 순진한 절대 할인은 Francisco의 카운트가 높다는 이유로 높은 유니그램 확률을 부여합니다. 크네저-네이는 Francisco가 사실상 단 하나의 문맥에서만 나타난다는 사실을 포착해, 연속 확률을 그에 맞게 낮춥니다. 그 결과 Francisco로 끝나는 새로운 바이그램에는 적절히 낮은 확률이 매겨집니다.

평가: 퍼플렉서티(Evaluation: perplexity). 보류 집합(held-out test set)에서 단어별 평균 음의 로그 가능도(average negative log-likelihood)에 지수 함수(exponent)를 취한 값입니다. 낮을수록 좋습니다. 퍼플렉서티가 100이라는 것은, 모델이 100개 단어 중에서 균등하게 하나를 고르는 것만큼 혼란스러워한다는 뜻입니다.

perplexity = exp(- (1/N) * Σ log P(w_i | context_i))

직접 만들기

Step 1: 트라이그램 카운트 계산

from collections import Counter, defaultdict


def train_ngram(corpus_tokens, n=3):
    ngrams = Counter()
    contexts = Counter()
    for sentence in corpus_tokens:
        padded = ["<s>"] * (n - 1) + sentence + ["</s>"]
        for i in range(len(padded) - n + 1):
            ctx = tuple(padded[i:i + n - 1])
            word = padded[i + n - 1]
            ngrams[ctx + (word,)] += 1
            contexts[ctx] += 1
    return ngrams, contexts


def raw_probability(ngrams, contexts, context, word):
    ctx = tuple(context)
    if contexts.get(ctx, 0) == 0:
        return 0.0
    return ngrams.get(ctx + (word,), 0) / contexts[ctx]

입력은 토큰화된 문장(tokenized sentence) 리스트(list)입니다. 출력은 엔그램 카운트와 문맥 카운트입니다. <s></s>는 문장 경계(sentence boundary)를 나타냅니다.

Step 2: 라플라스 스무딩

def laplace_probability(ngrams, contexts, vocab_size, context, word):
    ctx = tuple(context)
    numerator = ngrams.get(ctx + (word,), 0) + 1
    denominator = contexts.get(ctx, 0) + vocab_size
    return numerator / denominator

모든 카운트에 1을 더합니다. 스무딩은 되지만, 보지 못한 사건에 너무 많은 질량을 배정해 드물지만 이미 본 사건(rare-known event)의 성능까지 떨어뜨립니다.

Step 3: 크네저-네이(바이그램, 보간형)

def kneser_ney_bigram_model(corpus_tokens, discount=0.75):
    unigrams = Counter()
    bigrams = Counter()
    unigram_contexts = defaultdict(set)

    for sentence in corpus_tokens:
        padded = ["<s>"] + sentence + ["</s>"]
        for i, w in enumerate(padded):
            unigrams[w] += 1
            if i > 0:
                prev = padded[i - 1]
                bigrams[(prev, w)] += 1
                unigram_contexts[w].add(prev)

    total_unique_bigrams = sum(len(ctx_set) for ctx_set in unigram_contexts.values())
    continuation_prob = {
        w: len(ctx_set) / total_unique_bigrams for w, ctx_set in unigram_contexts.items()
    }

    context_totals = Counter()
    for (prev, w), count in bigrams.items():
        context_totals[prev] += count

    unique_follow = defaultdict(set)
    for (prev, w) in bigrams:
        unique_follow[prev].add(w)

    def prob(prev, w):
        count = bigrams.get((prev, w), 0)
        denom = context_totals.get(prev, 0)
        if denom == 0:
            return continuation_prob.get(w, 1e-9)
        first_term = max(count - discount, 0) / denom
        lambda_prev = discount * len(unique_follow[prev]) / denom
        return first_term + lambda_prev * continuation_prob.get(w, 1e-9)

    return prob

핵심 구성 요소는 세 가지입니다. continuation_prob은 "이 단어가 얼마나 많은 서로 다른 문맥에 등장하는가"를 포착하며, 이것이 바로 크네저-네이의 혁신입니다. lambda_prev는 할인을 통해 확보한 자유 질량(freed mass)이며, 백오프 항에 가중치를 부여하는 데 쓰입니다. 최종 확률은 할인된 본항(discounted main term)과 가중된 연속 항(weighted continuation term)의 합입니다.

Step 4: 샘플링을 이용한 텍스트 생성

import random


def generate(prob_fn, vocab, prefix, max_len=30, seed=0):
    rng = random.Random(seed)
    tokens = list(prefix)
    for _ in range(max_len):
        candidates = [(w, prob_fn(tokens[-1], w)) for w in vocab]
        total = sum(p for _, p in candidates)
        r = rng.random() * total
        acc = 0.0
        for w, p in candidates:
            acc += p
            if r <= acc:
                tokens.append(w)
                break
        if tokens[-1] == "</s>":
            break
    return tokens

확률에 비례해 샘플링(sampling)합니다. 시드(seed)마다 늘 다른 출력이 나옵니다. 빔 서치(beam search)와 비슷한 결정적 출력을 원한다면 각 단계에서 최댓값(argmax)을 선택하는 그리디(greedy) 방식을 쓰면 됩니다. 여기에 무작위성의 강도를 조절하는 작은 노브(knob)인 온도(temperature)를 더할 수도 있습니다.

Step 5: 퍼플렉서티

import math


def perplexity(prob_fn, sentences):
    total_log_prob = 0.0
    total_tokens = 0
    for sentence in sentences:
        padded = ["<s>"] + sentence + ["</s>"]
        for i in range(1, len(padded)):
            p = prob_fn(padded[i - 1], padded[i])
            total_log_prob += math.log(max(p, 1e-12))
            total_tokens += 1
    return math.exp(-total_log_prob / total_tokens)

낮을수록 좋습니다. 브라운 말뭉치에서 잘 튜닝된 4-그램 크네저-네이 모델은 퍼플렉서티가 약 140 정도로 떨어집니다. 같은 평가 집합(test set)에서 트랜스포머 언어 모델(transformer LM)은 15~30 수준에 도달합니다. 약 10배 차이입니다. 이 격차가 바로 분야가 다음 단계로 이동하게 된 이유입니다.

사용하기

  • 고전 자연어 처리 교육(Classical NLP teaching). 스무딩(smoothing), 최대 가능도 추정(Maximum Likelihood Estimation; MLE), 퍼플렉서티(perplexity)를 가장 명료하게 학습할 수 있는 길입니다.
  • KenLM. 운영용(production) 엔그램 라이브러리(library)입니다. 낮은 지연 시간(low latency)이 중요한 음성 인식과 기계 번역(MT) 시스템에서 재채점기(rescorer)로 활용됩니다.
  • 온디바이스 자동완성(On-device autocomplete). 키보드(keyboard)에 들어가는 트라이그램 모델은 지금도 현역으로 쓰입니다.
  • 기준선(Baseline). 신경망 언어 모델(neural LM)이 좋다고 선언하기 전에 반드시 엔그램 언어 모델의 퍼플렉서티를 먼저 계산합니다. 트랜스포머가 크네저-네이를 큰 폭으로 이기지 못한다면 어딘가에 문제가 있다고 봐야 합니다.

산출물 만들기

outputs/prompt-lm-baseline.md로 저장합니다.

---
name: lm-baseline
description: Build a reproducible n-gram language model baseline before training a neural LM.
phase: 5
lesson: 16
---

Given a corpus and target use (next-word prediction, rescoring, perplexity baseline), output:

Guide the student in Korean.

1. N-gram order. Trigram for general English, 4-gram if corpus is large, 5-gram for speech rescoring.
2. Smoothing. Modified Kneser-Ney is the default; Laplace only for teaching.
3. Library. `kenlm` for production, `nltk.lm` for teaching, roll your own only to learn.
4. Evaluation. Held-out perplexity with consistent tokenization between train and test sets.

Refuse to report perplexity computed with different tokenization between systems being compared — perplexity numbers are comparable only under identical tokenization. Flag OOV rate in test set; KN handles OOV poorly unless you reserve a special <UNK> token during training.

이 프롬프트(prompt)는 말뭉치(corpus)와 목표 용도(다음 단어 예측, 재채점, 퍼플렉서티 기준선)를 받아 엔그램 차수, 스무딩 기법, 라이브러리, 평가 계획을 제안하게 합니다. 특히 서로 다른 토큰화(tokenization)로 계산한 퍼플렉서티를 비교하지 않도록 막고, 평가 집합의 어휘 밖(out-of-vocabulary; OOV) 비율을 확인하게 합니다.

연습문제

  1. 쉬움. 1,000문장 규모의 셰익스피어(Shakespeare) 말뭉치에 트라이그램 언어 모델을 학습하고 20개 문장을 생성합니다. 생성된 문장은 국소적으로는 그럴듯하지만 전체적으로는 일관성이 떨어질 것입니다. 이것이 정석 시연(demo)입니다.
  2. 중간. 보류 집합으로 분리한 셰익스피어 데이터에서 크네저-네이 모델의 퍼플렉서티를 구현합니다. 라플라스와 비교합니다. 크네저-네이가 퍼플렉서티를 30~50% 정도 낮추는 것을 확인할 수 있어야 합니다.
  3. 어려움. 트라이그램 맞춤법 교정기(spell corrector)를 만듭니다. 잘못 표기된 단어와 그 문맥이 주어졌을 때 교정 후보를 만들고, 학습한 언어 모델 아래에서의 문맥 확률로 순위를 매깁니다. 공개된 버크벡 맞춤법 말뭉치(Birkbeck spelling corpus)로 평가합니다.

핵심 용어

용어흔한 설명실제 의미
엔그램(N-gram)단어 시퀀스연속된 n개 토큰(token)의 시퀀스.
스무딩(Smoothing)0 피하기보지 못한 사건이 0이 아닌 확률을 받도록 확률 질량을 재분배하는 기법.
퍼플렉서티(Perplexity)언어 모델 품질 지표보류 데이터에서의 exp(-평균 로그 확률). 낮을수록 좋다.
백오프(Backoff)짧은 문맥으로 후퇴트라이그램 카운트가 0이면 바이그램을 사용. 카츠 백오프(Katz backoff)가 이를 형식화한다.
크네저-네이(Kneser-Ney)엔그램에 가장 좋은 스무딩절대 할인에 하위 차수 모델을 위한 연속 확률을 결합한 기법.
연속 확률(Continuation probability)크네저-네이 고유 개념단순 카운트가 아니라 w가 등장하는 문맥의 수로 가중한 P(w).

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

main
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 1개

lm-baseline

Build a reproducible n-gram language model baseline before training a neural LM.

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.라플라스 스무딩(Laplace smoothing)은 모든 엔그램 카운트에 1을 더합니다. 이 단순한 방법이 크네저-네이 스무딩(Kneser-Ney smoothing)보다 모델 품질을 떨어뜨리는 이유는 무엇인가요?

2.'Francisco'라는 단어가 말뭉치에서 높은 유니그램(unigram) 카운트를 가지고 있습니다. 크네저-네이 스무딩이 이 높은 카운트에도 불구하고 낮은 연속 확률(continuation probability)을 부여하는 이유는 무엇인가요?

3.잘 튜닝된 4-그램 크네저-네이 모델의 퍼플렉서티(perplexity)가 보류 집합에서 약 140이고, 같은 집합에서 트랜스포머 언어 모델(transformer LM)은 약 20입니다. 동료의 트랜스포머가 130을 기록했다면 어떤 결론을 내려야 하나요?

0/3 답변 완료

추가 문제 풀기

AI가 강의 내용을 바탕으로 새로운 문제를 생성합니다