리서치

PAPERS / 15번째 글

토크나이저 출력은 Zipf를 따르는가: 순위 10~1,000에서만 기울기 −0.8~−1.0이었고, 전 구간 기울기는 말뭉치 크기가 정했다

토크나이저 다섯으로 한국어·영어 위키백과 6MB씩을 잘라 순위-빈도 기울기를 쟀다. 가운데 두 자릿수는 −0.80~−1.00으로 Zipf에 가까웠지만 꼬리를 넣은 기울기는 −1.33~−2.27로 흩어졌고 말뭉치를 8배 키우자 −1.10에서 −1.39로 움직였다. BPE 어휘를 키우면 불균형 D가 오른다는 Gowda·May(2020)의 관찰은 재현됐다.

PALDYN Team28 MIN READ

Zipf 법칙은 말뭉치에서 낱말을 많이 나온 순으로 세우면 빈도가 순위에 반비례한다는 관찰입니다. 순위와 빈도에 둘 다 로그를 씌우면 기울기 −1-1 인 직선이 됩니다. 토크나이저가 자른 조각에도 같은 말이 흔히 붙습니다. 「서브워드도 Zipf를 따르므로 드문 조각이 길게 늘어선다」는 식입니다.

이 글은 그 말을 숫자로 확인합니다. 토크나이저 다섯 개로 한국어와 영어 위키백과 문단을 6MB씩 자르고, 순위-빈도 곡선의 기울기를 전 구간과 자릿수 구간으로 나눠 쟀습니다. 그다음 같은 말뭉치에서 BPE를 어휘 500부터 64,000까지 직접 학습시켜, 어휘를 키우면 분포가 어떻게 기우는지 봤습니다.

결과는 셋입니다. 순위 101,000의 가운데 두 자릿수는 기울기 −0.80−1.00으로 Zipf에 가까웠습니다. 꼬리까지 넣은 기울기는 −1.33~−2.27로 흩어졌고, 같은 토크나이저에서 말뭉치만 8배로 키워도 −1.10에서 −1.39로 움직였습니다. 어휘를 키울수록 분포가 한쪽으로 쏠린다는 논문의 관찰은 그대로 재현됐습니다.

BPE가 조각을 어떻게 만드는지는 서브워드 토크나이저가 맡고, 같은 글이 토크나이저마다 토큰 몇 개가 되는지는 한국어 토큰세가 맡습니다. 이 글은 분포의 모양만 다룹니다.

재현하려는 주장

논문의 두 문장

Gowda와 May의 「Finding the Optimal Vocabulary Size for Neural Machine Translation」(arXiv 2004.02334, Findings of EMNLP 2020)을 골랐습니다. 번역 모델의 출력층을 「조각 하나를 고르는 분류기」로 보고, BPE 어휘 크기가 그 분류기의 클래스 불균형을 어떻게 바꾸는지 따진 논문입니다. 이 글이 다시 재는 문장은 둘입니다.

첫째는 서론의 전제입니다. 「자연어 말뭉치에서 한 종류의 순위는 대략 그 빈도에 반비례한다」고 적고, BPE로 쪼갠 뒤에도 그 불균형이 남는다고 결론에서 말합니다. 순위-빈도 로그 기울기로 옮기면 −1-1 근처라는 뜻입니다.

둘째는 어휘 크기에 대한 관찰입니다. 논문은 불균형을 두 값으로 잽니다. 불균형 D는 관측한 분포가 균등분포에서 얼마나 떨어졌는지를 0~1로 적은 값이고, F95% 값은 많이 나온 순으로 95% 안에 드는 종류 가운데 가장 드문 것의 빈도입니다. 어휘를 키우면 문장이 짧아지는 대신 D가 오르고 F95%가 내려간다는 것, 그리고 「문장 길이가 짧으면서 F95%가 100 근처 이상인 어휘」가 최적에 가깝다는 규칙이 논문의 관찰입니다.

D=12∑i=1K∣pi−1K∣D = \frac{1}{2}\sum_{i=1}^{K}\left|p_i - \frac{1}{K}\right|

여기서 KK 는 종류 수, pip_i 는 종류 ii 가 전체 토큰에서 차지하는 비율입니다.

판정 기준

계획은 「기울기 값과 결정계수, 그리고 직선이 성립하는 순위 구간」으로 판정하고 꼬리를 잘라 내지 말라고 정했습니다. 꼬리를 자르면 어떤 데이터든 직선처럼 보이기 때문입니다. 그래서 같은 분포에서 넷을 함께 냅니다.

  1. 전 구간 기울기와 R2R^2 — 나온 종류 전부를 점 하나씩으로 놓고 그은 직선입니다.
  2. 자릿수 구간별 기울기 — 순위 19, 1099, 100999, 1,0009,999, 10,000 이상에서 따로 그은 직선입니다.
  3. 가운데 기울기 — 순위 10~1,000에서 그은 직선입니다.
  4. 직선 구간 — 가운데 직선에서 로그 0.1(1.26배) 넘게 벗어나지 않는 순위의 범위입니다. 아래쪽은 1~9위 가운데 벗어난 마지막 순위 다음부터, 위쪽은 1,000위 뒤에서 처음 벗어나는 순위 직전까지입니다.

전 구간 직선에 경계해야 할 점이 하나 있습니다. 종류 2만 개를 점으로 찍으면 그 95%가 1,000위 뒤에 있습니다. 한 번만 나온 종류가 끝에서 수평 계단을 이루므로, 전 구간 기울기는 사실상 꼬리의 모양이 정합니다.

측정

말뭉치와 토크나이저

말뭉치는 실험대 글들이 써 온 두 질의응답 데이터의 지문입니다. KorQuAD 1.0 학습 분할과 SQuAD 1.1 학습 분할 모두 위키백과 문단이라 갈래가 같습니다. 대역이 아니므로 내용이 같지는 않습니다.

크기는 UTF-8 바이트로 맞췄습니다. 한글 한 자가 3바이트이고 영어 한 자가 1바이트라, 같은 6MB면 한국어 255만 자와 영어 599만 자가 됩니다. 한국어 한 자가 영어 두세 자의 정보를 싣는다는 어림과 대체로 맞는 비율입니다. 문장 수는 마침표·물음표·느낌표 뒤 공백으로 어림해 34,159개와 40,189개였습니다. 논문이 「작은 데이터」로 부른 3만 문장 규모입니다.

토크나이저는 한국어 토큰세에서 토큰 없이 받히는 것이 확인된 목록에서 다섯을 골랐습니다. 한국어 전용 둘(klue/roberta-base 32K, A.X 4.0 Light 102K)과 다국어 셋(mBERT 119K, Qwen2.5 151K, multilingual-e5-small 250K)입니다.

재현 블록

pip install transformers datasets numpy
import time, re, numpy as np
from collections import Counter
from datasets import load_dataset
from transformers import AutoTokenizer

t0 = time.perf_counter()
def corpus(rid, limit=6_000_000):  # 위키백과 문단을 UTF-8 6MB까지 (한국어 약 255만 자, 영어 약 599만 자)
    out, b = [], 0
    for c in dict.fromkeys(load_dataset(rid, split="train")["context"]):
        if b >= limit: break
        out.append(c); b += len(c.encode())
    return out
C = {"ko": corpus("KorQuAD/squad_kor_v1"), "en": corpus("rajpurkar/squad")}
SENT = re.compile(r"[.!?](?:\s|$)")
for L, t in C.items():
    print(f"{L}: 문단 {len(t):,}  글자 {sum(map(len, t)):,}  문장(어림) {sum(len(SENT.findall(x)) for x in t):,}")

def zipf(cnt):  # 순위-빈도 로그-로그. 꼬리를 자르지 않고 전 구간에 한 직선, 그리고 자릿수 구간마다 기울기
    f = np.sort(np.fromiter(cnt.values(), float))[::-1]; r = np.arange(1, len(f) + 1)
    x, y = np.log10(r), np.log10(f)
    a, b = np.polyfit(x, y, 1); r2 = 1 - ((y - a * x - b) ** 2).sum() / ((y - y.mean()) ** 2).sum()
    band = [np.polyfit(x[m], y[m], 1)[0] if (m := (r >= lo) & (r < lo * 10)).sum() > 5 else np.nan for lo in (1, 10, 100, 1000, 10000)]
    m = (r >= 10) & (r <= 1000); am, bm = np.polyfit(x[m], y[m], 1)
    bad = np.abs(y - am * x - bm) > 0.1  # 가운데 직선에서 1.26배 넘게 벗어난 순위
    lo = r[:9][bad[:9]].max() + 1 if bad[:9].any() else 1
    hi = r[1000:][bad[1000:]].min() - 1 if bad[1000:].any() else r[-1]
    return len(f), a, r2, band, am, lo, hi, (f == 1).mean()

TOKS = ["klue/roberta-base", "google-bert/bert-base-multilingual-cased", "skt/A.X-4.0-Light",
        "Qwen/Qwen2.5-7B-Instruct", "intfloat/multilingual-e5-small"]
print(f"{'토크나이저':<14}{'언어':>3}{'어휘':>8}{'토큰 수':>10}{'쓰인 종류':>8}{'전 구간 기울기':>10}{'R²':>7}"
      f"   {'자릿수 구간별 기울기 1·10·100·1K·10K~':<32}{'10~1K':>7}{'직선 구간':>14}{'한 번만':>7}")
for name in TOKS:
    tk = AutoTokenizer.from_pretrained(name); tk.model_max_length = 10**9
    for L, t in C.items():
        cnt = Counter(i for ids in tk(t, add_special_tokens=False)["input_ids"] for i in ids)
        V, a, r2, band, am, lo, hi, h1 = zipf(cnt)
        bs = " ".join(f"{v:6.2f}" if v == v else "     -" for v in band)
        print(f"{name.split('/')[1][:14]:<14}{L:>4}{len(tk):>9,}{sum(cnt.values()):>11,}{V:>9,}{a:>10.3f}{r2:>9.3f}"
              f"   {bs:<34}{am:>7.3f}{f'{lo}~{hi:,}':>14}{h1:>8.1%}")

# 같은 토크나이저, 말뭉치만 줄이면: 전 구간 기울기와 직선 구간이 표본 크기를 따라 움직이는가
tk = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct"); tk.model_max_length = 10**9
for L, t in C.items():
    for share in (0.125, 0.25, 0.5, 1.0):
        sub = t[: int(len(t) * share)]
        cnt = Counter(i for ids in tk(sub, add_special_tokens=False)["input_ids"] for i in ids)
        V, a, r2, band, am, lo, hi, h1 = zipf(cnt)
        print(f"Qwen {L} 문단 {len(sub):>5,}  토큰 {sum(cnt.values()):>9,}  종류 {V:>6,}  전 구간 {a:.3f} (R² {r2:.3f})  10~1K {am:.3f}  직선 {lo}~{hi:,}  한 번만 {h1:.1%}")
print(f"elapsed={time.perf_counter() - t0:.0f}s")
python3 zipf.py

실제 출력

진행 표시줄과 허브 경고는 뺐습니다.

ko: 문단 4,987  글자 2,551,344  문장(어림) 34,159
en: 문단 8,893  글자 5,988,545  문장(어림) 40,189
토크나이저          언어      어휘      토큰 수   쓰인 종류  전 구간 기울기     R²   자릿수 구간별 기울기 1·10·100·1K·10K~      10~1K         직선 구간   한 번만
roberta-base    ko   32,000  1,343,726   25,038    -1.468    0.926    -0.26  -1.15  -0.91  -1.21  -2.85 -0.967       8~6,097   13.8%
roberta-base    en   32,000  2,357,713    2,338    -2.265    0.779    -0.51  -0.61  -1.44  -6.35      - -1.160      10~1,000    5.9%
bert-base-mult  ko  119,547  1,536,811   13,410    -2.031    0.945    -0.56  -0.61  -1.00  -2.68   0.00 -0.862      10~1,128   37.5%
bert-base-mult  en  119,547  1,309,022   27,194    -1.488    0.890    -0.72  -1.18  -0.76  -1.07  -3.03 -0.855      10~8,044   17.9%
A.X-4.0-Light   ko  102,400  1,128,630   49,376    -1.334    0.938    -0.80  -0.99  -0.79  -0.98  -1.94 -0.802       7~5,848   23.1%
A.X-4.0-Light   en  102,400  1,397,061   25,305    -1.462    0.898    -0.60  -1.18  -0.82  -1.14  -3.11 -0.901      10~7,057   13.6%
Qwen2.5-7B-Ins  ko  151,665  1,851,479   12,597    -2.014    0.952    -0.33  -0.68  -1.19  -2.34   0.00 -0.996      10~1,000   42.7%
Qwen2.5-7B-Ins  en  151,665  1,307,558   38,952    -1.393    0.941    -0.60  -1.25  -0.79  -1.09  -2.12 -0.912      10~8,828   22.5%
multilingual-e  ko  250,002  1,446,360   16,130    -1.948    0.929    -0.67  -0.80  -0.88  -2.49  -1.13 -0.811       4~1,390   32.6%
multilingual-e  en  250,002  1,408,748   26,224    -1.576    0.915    -0.59  -1.08  -0.76  -1.29  -2.85 -0.850      10~4,632   20.3%
Qwen ko 문단   623  토큰   232,772  종류  4,183  전 구간 -1.872 (R² 0.911)  10~1K -1.015  직선 10~1,000  한 번만 36.3%
Qwen ko 문단 1,246  토큰   473,171  종류  6,219  전 구간 -1.961 (R² 0.933)  10~1K -1.011  직선 10~1,000  한 번만 39.6%
Qwen ko 문단 2,493  토큰   922,761  종류  8,615  전 구간 -2.026 (R² 0.941)  10~1K -0.993  직선 10~1,000  한 번만 42.8%
Qwen ko 문단 4,987  토큰 1,851,479  종류 12,597  전 구간 -2.014 (R² 0.952)  10~1K -0.996  직선 10~1,000  한 번만 42.7%
Qwen en 문단 1,111  토큰   180,943  종류 17,904  전 구간 -1.097 (R² 0.973)  10~1K -0.922  직선 10~5,596  한 번만 41.5%
Qwen en 문단 2,223  토큰   315,020  종류 23,388  전 구간 -1.183 (R² 0.967)  10~1K -0.905  직선 10~6,206  한 번만 36.2%
Qwen en 문단 4,446  토큰   579,711  종류 29,902  전 구간 -1.269 (R² 0.960)  10~1K -0.903  직선 10~6,992  한 번만 30.0%
Qwen en 문단 8,893  토큰 1,307,558  종류 38,952  전 구간 -1.393 (R² 0.941)  10~1K -0.912  직선 10~8,828  한 번만 22.5%
elapsed=29s

기울기 판정

전 구간 직선

꼬리까지 넣은 기울기는 열 줄에서 −1.334부터 −2.265까지 흩어졌고, R2R^2 는 0.779~0.952였습니다. 「Zipf의 기울기는 −1-1」을 이 숫자로 확인하려 하면 어느 줄도 맞지 않습니다.

이 기울기가 분포의 성질이 아니라는 것은 출력 아래쪽 여덟 줄이 보여 줍니다. Qwen2.5 하나로 영어 문단을 1,111개에서 8,893개로 8배 늘리자 전 구간 기울기가 −1.097에서 −1.393으로 내려갔습니다. 같은 토크나이저, 같은 갈래의 글인데 표본 크기만으로 0.3이 움직였습니다. 한국어도 −1.872에서 −2.014로 갔습니다.

원인은 꼬리의 계단입니다. 말뭉치가 커지면 한 번만 나온 종류가 늘어나며 곡선이 오른쪽으로 길어지고, 길어진 꼬리는 전 구간 직선을 더 가파르게 끌어내립니다. 말뭉치 크기를 밝히지 않은 「기울기 −1.4」는 비교할 수 있는 숫자가 아닙니다.

가운데 두 자릿수

순위 10~1,000에서 그은 기울기는 사정이 다릅니다. 같은 8배 증가에서 Qwen2.5 영어는 −0.922·−0.905·−0.903·−0.912로 0.02 안에 머물렀고, 한국어는 −1.015에서 −0.996이었습니다. 표본을 키워도 흔들리지 않으니 이것이 분포의 성질에 가까운 값입니다.

그 값은 한국어 −0.802~−0.996, 영어 −0.850~−0.912입니다. 영어를 글자 단위로밖에 못 자르는 klue/roberta-base의 영어 줄(−1.160)만 빼면 열 줄 중 아홉이 −1-1 보다 조금 평평한 쪽에 섰습니다. 논문의 「대략 반비례」는 이 구간에서 성립합니다.

맨 앞 19위는 −0.26−0.80으로 더 평평합니다. 가장 흔한 조각 몇 개의 빈도가 순위만큼 벌어지지 않고 서로 비슷하다는 뜻입니다. 반대쪽 1,000위 뒤는 토크나이저와 언어에 따라 −0.98부터 −6.35까지 갈렸습니다.

직선이 끝나는 자리

토큰 순위와 빈도를 로그-로그로 그린 곡선 셋. Qwen2.5 한국어는 순위 1,000 근처에서 급히 꺾이고, A.X 한국어와 Qwen2.5 영어는 수천 위까지 기울기 −1에 가까운 직선을 따라간다

가장 크게 갈린 것은 기울기가 아니라 직선이 끝나는 순위였습니다. 다국어 토크나이저 셋은 한국어에서 직선이 1,0001,390위에서 끝났습니다. 같은 토크나이저가 영어에서는 4,6328,828위까지 갔습니다. 한국어 전용 둘은 한국어에서 5,848위와 6,097위까지 이어졌습니다.

이 차이는 쓰인 종류 수와 같이 움직입니다. Qwen2.5는 어휘 151,665개 가운데 한국어 6MB에서 12,597개(8.3%)만 썼고, 그중 42.7%가 한 번만 나왔습니다. A.X는 어휘 102,400개 가운데 49,376개(48.2%)를 썼습니다. 같은 글을 Qwen2.5는 1,851,479토큰으로, A.X는 1,128,630토큰으로 잘랐으니 Qwen2.5가 한국어를 더 잘게, 더 적은 종류로 자른 것입니다. 적은 종류가 많은 토큰을 나눠 가지면 곡선의 머리와 가운데가 위로 부풀고, 쓸 종류가 바닥나는 자리에서 급히 꺾입니다. 그림의 붉은 곡선이 1,000위 근처에서 꺾이는 자리가 거기입니다. 어떤 조각이 꺾이는 자리 뒤에 남는지는 이 글에서 뽑아 보지 않았습니다.

한국어와 영어

계획이 물은 것은 「한국어에서 기울기가 영어와 다른가」였습니다. 같은 토크나이저의 가운데 기울기를 한국어에서 영어를 빼서 견주면 mBERT −0.007, A.X +0.099, Qwen2.5 −0.084, e5 +0.039입니다. 한국어가 평평한 토크나이저와 가파른 토크나이저가 섞여 부호가 일정하지 않습니다.

표본을 8배 바꿀 때 가운데 기울기가 움직인 폭이 0.02 안이었으니, 0.08~0.10의 차이는 표본 잡음보다 큽니다. 그러나 방향이 토크나이저마다 다르므로 「한국어는 기울기가 다르다」고 말할 수는 없습니다. 언어보다 토크나이저가 정하는 값입니다. 언어 사이에서 일관되게 갈린 것은 앞 절의 직선이 끝나는 자리, 곧 그 토크나이저가 한국어 조각을 얼마나 품었느냐였습니다.

어휘 크기

재현 블록

논문의 두 번째 관찰은 같은 말뭉치에서 어휘만 바꿔야 잴 수 있습니다. 공개 토크나이저는 어휘와 학습 말뭉치가 함께 달라지므로, tokenizers로 바이트 단위 BPE를 위 6MB에 직접 학습시켰습니다.

pip install tokenizers datasets numpy
import time, numpy as np
from collections import Counter
from datasets import load_dataset
from tokenizers import Tokenizer, models, trainers, pre_tokenizers

t0 = time.perf_counter()
def corpus(rid, limit=6_000_000):  # zipf.py와 같은 문단
    out, b = [], 0
    for c in dict.fromkeys(load_dataset(rid, split="train")["context"]):
        if b >= limit: break
        out.append(c); b += len(c.encode())
    return out
C = {"ko": corpus("KorQuAD/squad_kor_v1"), "en": corpus("rajpurkar/squad")}

def measure(f):  # Gowda & May(2020)의 D와 F95%, 그리고 10~1K 순위 기울기
    f = np.sort(np.asarray(f, float))[::-1]; p = f / f.sum(); K = len(f)
    D = 0.5 * np.abs(p - 1 / K).sum()          # 균등분포에서 떨어진 정도, 0~1
    F95 = f[int(np.ceil(0.95 * K)) - 1]        # 상위 95% 안에서 가장 드문 종류의 빈도
    r = np.arange(1, K + 1); m = (r >= 10) & (r <= 1000)
    a = np.polyfit(np.log10(r[m]), np.log10(f[m]), 1)[0]
    return K, D, F95, a, (f < 100).mean()

print(f"{'언어':<3}{'어휘':>7}{'쓰인 종류':>9}{'문단당 토큰':>9}{'D':>8}{'F95%':>7}{'100번 미만':>9}{'10~1K 기울기':>11}")
for L, t in C.items():
    base = None
    for V in (500, 1000, 2000, 4000, 8000, 16000, 32000, 64000):
        tok = Tokenizer(models.BPE()); tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
        tok.train_from_iterator(t, trainers.BpeTrainer(vocab_size=V, show_progress=False,
                                initial_alphabet=pre_tokenizers.ByteLevel.alphabet()))
        cnt = Counter(i for e in tok.encode_batch(t) for i in e.ids)
        K, D, F95, a, rare = measure(list(cnt.values()))
        mu = sum(cnt.values()) / len(t); base = base or mu
        print(f"{L:<4}{V:>7,}{K:>10,}{mu:>8.1f} ({mu / base:.2f}){D:>7.3f}{F95:>7.0f}{rare:>9.1%}{a:>11.3f}")
print(f"elapsed={time.perf_counter() - t0:.0f}s")
python3 vocab.py

실제 출력

언어      어휘    쓰인 종류   문단당 토큰       D   F95%  100번 미만  10~1K 기울기
ko      500       431   578.5 (1.00)  0.403     40     7.7%     -1.631
ko    1,000       923   426.9 (0.74)  0.387    104     4.9%     -1.094
ko    2,000     1,917   348.0 (0.60)  0.432     82     5.6%     -0.762
ko    4,000     3,912   298.6 (0.52)  0.483     43    28.1%     -0.725
ko    8,000     7,893   261.5 (0.45)  0.516     20    69.4%     -0.708
ko   16,000    15,824   231.5 (0.40)  0.536     10    87.4%     -0.718
ko   32,000    31,521   206.6 (0.36)  0.553      5    95.3%     -0.750
ko   64,000    62,148   185.4 (0.32)  0.571      2    98.4%     -0.802
en      500       441   334.6 (1.00)  0.480     17    17.7%     -2.257
en    1,000       941   267.2 (0.80)  0.484     47     9.2%     -1.403
en    2,000     1,941   223.7 (0.67)  0.505     51     8.8%     -0.897
en    4,000     3,928   189.9 (0.57)  0.530     29    20.1%     -0.863
en    8,000     7,897   164.8 (0.49)  0.558     12    70.1%     -0.836
en   16,000    15,706   147.6 (0.44)  0.605      5    88.6%     -0.821
en   32,000    30,386   136.5 (0.41)  0.671      2    95.2%     -0.839
en   64,000    56,243   130.0 (0.39)  0.735      1    97.7%     -0.873
elapsed=60s

D와 F95%

어휘 1,000부터는 논문이 말한 방향이 그대로 나왔습니다. 어휘를 64배 키우는 동안 한국어 D는 0.387에서 0.571로, 영어 D는 0.484에서 0.735로 한 번도 꺾이지 않고 올랐습니다. F95%는 한국어 104에서 2로, 영어 47에서 1로 내려갔습니다(영어 1,000→2,000에서 47→51로 한 번 오른 것만 예외입니다). 문단당 토큰은 한국어 426.9에서 185.4로 줄었습니다. 논문이 「문장이 짧아지는 이득과 불균형이 커지는 손해의 맞교환」이라 부른 것이 우리 말뭉치에서도 그대로입니다.

어휘 500 줄은 이 흐름에서 벗어납니다. 바이트 단위 BPE는 바이트 256개를 기본 알파벳으로 깔고 시작하므로, 어휘 500이면 병합이 250번도 안 됩니다. 한글 세 바이트 가운데 드물게 나오는 바이트가 따로 종류로 남아 F95%를 끌어내리고 D를 올립니다. 쓰인 종류가 431·441개라 「10~1K 기울기」도 431위까지만 그은 값이므로 다른 줄과 견주지 않습니다.

가운데 기울기는 어휘 2,000부터 64,000까지 한국어 −0.708~−0.802, 영어 −0.821~−0.897에 머물렀습니다. 어휘를 키운다고 Zipf 쪽으로 가거나 멀어지지 않았습니다. 곡선의 가운데 기울기는 그대로 두고, 오른쪽으로 길어지며 드문 종류를 늘린 것이 D가 오른 이유입니다.

꺾이는 지점

「100번 미만」 열이 맞교환이 꺾이는 자리를 보여 줍니다. 어휘 2,000까지는 종류의 94% 이상이 100번 넘게 나옵니다(100번 미만이 한국어 5.6%, 영어 8.8%). 4,000에서 28.1%·20.1%가 되고, 8,000에서 69.4%·70.1%로 뜁니다. 같은 2,000→8,000 사이에 문단당 토큰은 한국어 25%, 영어 26%만 줄었습니다.

한 줄로 적으면 이렇습니다. 이 6MB 말뭉치에서는 어휘 2,000까지가 공짜이고, 4,000부터 드문 종류가 늘기 시작해 8,000에서는 종류의 70%가 100번도 안 나옵니다. 문장 길이를 4분의 1 줄이는 값으로 클래스 대부분을 드물게 만든 셈입니다.

논문의 규칙(F95%가 100 근처 이상인 가장 큰 어휘)을 이 표에 대면 100을 넘는 것은 한국어 1,000 하나이고, 「근처」를 넓게 잡아도 한국어 2,000(82)까지입니다. 영어는 어느 어휘도 51을 넘지 못합니다. 같은 논문이 3만~130만 문장 규모에서 8,000이 좋은 맞교환이었다고 적은 것과 이 판정은 어긋납니다. 우리 말뭉치의 어휘 8,000에서 F95%는 20과 12입니다. 논문의 번역 데이터에서 같은 규모의 F95%가 얼마였는지는 그림에만 있어 숫자로 대조하지 못했습니다.

결론과 한계

논문값과 우리 값

논문의 문장 우리 측정 판정
순위는 빈도에 대략 반비례 순위 101,000 기울기 −0.80−1.00 (영어를 글자로 자르는 klue 영어 −1.16 제외) 가운데 두 자릿수에서 재현
(전 구간 직선) −1.33~−2.27, R2R^2 0.78~0.95, 말뭉치 8배에 0.3 움직임 표본 크기가 정하는 값
어휘를 키우면 D가 오른다 한국어 0.387→0.571, 영어 0.484→0.735 (어휘 1K→64K) 재현
어휘를 키우면 F95%가 내려간다 한국어 104→2, 영어 47→1 재현
F95% 100 근처가 최적 근처 한국어 1K~2K, 영어 해당 없음 — 논문의 「3만 문장에 8K」와 어긋남 번역 품질이 없어 판정 못 함

확인되지 않은 것

  • 논문의 본론은 번역 품질(BLEU)이고 이 글은 번역 모델을 학습시키지 않았습니다. D와 F95%가 논문 방향으로 움직인다는 것까지만 확인했고, 그 움직임이 품질을 정한다는 주장은 다시 재지 못했습니다.
  • 한국어와 영어 말뭉치는 대역이 아닙니다. 같은 위키백과 갈래를 같은 바이트만큼 잘랐을 뿐이라, 언어 차이와 내용 차이를 가르지 못합니다.
  • 문장 수는 문장부호로 어림한 값입니다. 논문의 「3만 문장」과 견준 것은 규모의 자릿수만 맞춘 것입니다.
  • D는 관측된 종류로 계산했습니다. 공개 토크나이저처럼 어휘의 대부분이 안 쓰이는 경우에는 어휘 전체로 계산한 D와 크게 다를 수 있어, 공개 토크나이저 표에는 D를 싣지 않았습니다.
  • 직선 구간의 판정선(로그 0.1)과 가운데 구간(10~1,000위)은 우리가 정한 것입니다. 선을 바꾸면 구간의 끝이 움직입니다.
  • 토크나이저는 다섯, 말뭉치는 언어마다 하나입니다. 대화체나 코드가 섞인 글에서는 꼬리의 모양이 다를 수 있습니다.

환경

항목 값
OS Linux 6.18.44 x86_64 (glibc 2.39)
CPU / RAM Intel Xeon @ 2.10GHz, 4 vCPU / 15GB
Python 3.11.17
패키지 transformers 5.19.0, tokenizers 0.23.3, datasets 5.1.0, numpy 2.4.6 (PyTorch 없이 토크나이저만 사용)
토크나이저 klue/roberta-base (02f94ba5), google-bert/bert-base-multilingual-cased (3f076fdb), skt/A.X-4.0-Light (ba21c20e), Qwen/Qwen2.5-7B-Instruct (a09a3545), intfloat/multilingual-e5-small (614241f6)
데이터 KorQuAD/squad_kor_v1 (01aad238), rajpurkar/squad (7b6d24c4) 학습 분할 지문
논문 Gowda & May, arXiv 2004.02334 (ar5iv HTML로 정의와 관찰 문장 확인)
실행 시간 zipf.py 29초, vocab.py 60초
측정일 2026-10-14 (KST)

위 출력은 패키지를 새로 깐 빈 가상환경에서 다시 돌린 것이고, 처음 돌린 가상환경의 출력과 시간을 뺀 모든 숫자가 같았습니다. BPE 학습도 두 번 모두 같은 표를 냈습니다.


읽어주셔서 감사합니다. 😊

LATEST

논문의 최신 글

논문2026.10.13

화이트닝이 문장 임베딩을 정말 올리는가: 원본 BERT에서는 올렸고, 검색용으로 학습한 MiniLM에서는 검색과 STS를 둘 다 내렸다

Su 외(2021)의 BERT-whitening을 scifact 검색과 STS-B에서 다시 쟀다. 원본 BERT에서는 nDCG@10이 11.6%p, STS-B가 15.8점 올랐지만 all-MiniLM-L6-v2에서는 검색 −5.8%p, STS-B −4.9점이었다. 방향을 가른 것은 과제가 아니라 모델의 쏠림이었다.

26 MIN
논문2026.10.10

k1=1.2·b=0.75는 한국어에서도 최적인가: KLUE-MRC에서는 최적이었고, 갈린 것은 언어가 아니라 질문과 지문의 겹침이었다

BM25 기본값 k1=1.2·b=0.75를 scifact·KorQuAD·KLUE-MRC 세 코퍼스에서 11×12 격자로 다시 쟀다. KorQuAD의 최적 k1은 0.5였지만 KLUE-MRC에서는 1.2가 최적이었다. KLUE 안에서 질문과 지문의 겹침이 낮은 3분의 1만 떼면 최적 k1이 2.0까지 올라갔다.

30 MIN