Към съдържанието
7/30Глава 7 от 30

Създайте BPE tokenizer: защо моделът ви не може да преброи R-тата

Обучете byte-pair encoder в 60 реда, вижте как сам открива „the“ и защо абзац на испански струва с 39% повече.

На тази страница

Попитайте модел, който може да издържи адвокатски изпит, колко букви r има в strawberry, и има немалък шанс да каже две.

Обичайното обяснение е, че езиковите модели са „лоши в броенето“ или „не разбират истински“. И двете твърдения са нефалсифицируеми и нито едното не е причината. Причината е механична, случва се преди моделът изобщо да тръгне, и може да се види в един ред:

TEXT
'strawberry'  ->  3 tokens  [496, 675, 15717]  ['str', 'aw', 'berry']

Моделът не гледа десет букви. Той гледа три числа. За да преброи r-тата, трябва да знае само от идентичността на token 496 колко r има вътре в низ, който не може да види — и после да направи същото за 675 и 15717 и да ги събере. От него се иска да отговори на въпрос за представяне, до което няма достъп.

Тази глава изгражда нещото, което произвежда тези три числа. Отнема около шейсет реда, това е същият алгоритъм, който използва всеки голям модел, и след като го напишете, дузина на пръв поглед несвързани странности се свеждат до една причина.

Има два очевидни начина да подадем текст към мрежа и и двата се провалят по причини, които си струва да се разберат, защото провалът определя формата на решението.

Думи. Разделяме по интервали, даваме номер на всяка дума. Английският има стотици хиляди словоформи и моделът се нуждае от embedding ред за всяка, така че речникът — и изходният слой, който трябва да произведе оценка за всеки запис — става огромен. По-лошо е какво се случва при inference: дума, която моделът никога не е виждал при обучението, няма номер. Това е проблемът out-of-vocabulary, а обичайната кръпка е всичко непознато да се мапва към един-единствен <UNK> token, което изхвърля информацията. Освен това „дума“ не е добре дефинирано понятие: китайският и японският не поставят интервали между думите, а немският може да слепва съществително след съществително практически безкрайно.

Символи. Няма проблем out-of-vocabulary, а речникът е от стотина и нещо символа. Но последователностите стават много дълги, а Глава 9 ще покаже, че цената на attention расте квадратично с дължината на последователността. Документ от 1000 думи е около 5000 символа — последователност четири до пет пъти по-дълга, отколкото е нужно, при квадратична цена. И всеки символ сам по себе си носи почти никакво значение, така че първите няколко слоя се изразходват да сглобяват обратно думи, които tokenizer можеше да предаде цели.

Отговорът е по средата: поддуми. Честите думи стават един token, редките думи се разделят на части, и нищо никога не е непознато, защото частите стигат до отделни байтове. Интересното е, че никой не проектира разделянето. Tokenizer се обучава върху същия тип данни като модела и научава кои байтови последователности заслужават собствен номер, като брои колко често се срещат заедно.

Алгоритъмът е от 1994 г. и първоначално е бил алгоритъм за компресия. Philip Gage го публикува в C Users Journal като начин за свиване на файлове чрез многократно заместване на най-честата двойка съседни байтове с байт, който не се среща в данните.1 Там си стои двайсет и две години, докато Sennrich, Haddow и Birch не го пренасочват към машинен превод през 2016 г., за да решат проблема out-of-vocabulary.2 Днес по същество така чете всеки голям езиков модел.

Цикълът на обучение е четири повтарящи се стъпки:

Кодирайте обучителния текст като UTF-8. Всяка стойност на байт 0–255 е token. Размер на речника: 256.

Минете през последователността и пребройте колко често се среща всяка двойка съседни token.

Вземете победителя, изсечете нов token id за него и заменете всяко срещане в последователността. Речникът нараства с едно; последователността се скъсява.

Съхранете двойката и id-то, в което се е превърнала, по ред. Този подреден списък е tokenizer — това е всичко необходимо, за да се кодира нов текст по-късно.

Ето целия trainer:

bpe.pyPYTHON
def get_stats(ids):
    counts = {}
    for a, b in zip(ids, ids[1:]):
        counts[(a, b)] = counts.get((a, b), 0) + 1
    return counts


def merge(ids, pair, idx):
    out, i = [], 0
    while i < len(ids):
        if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
            out.append(idx)
            i += 2
        else:
            out.append(ids[i])
            i += 1
    return out


class BPE:
    def __init__(self):
        self.merges = {}
        self.vocab = {i: bytes([i]) for i in range(256)}

    def train(self, text, vocab_size):
        ids = list(text.encode("utf-8"))
        for i in range(vocab_size - 256):
            stats = get_stats(ids)
            if not stats:
                break
            pair = max(stats, key=stats.get)      
            idx = 256 + i                         
            ids = merge(ids, pair, idx)           
            self.merges[pair] = idx               
            self.vocab[idx] = self.vocab[pair[0]] + self.vocab[pair[1]]
        return ids

Пуснете го върху 151 191 байта английска проза и отпечатайте първите дванайсет сливания в момента, в който се случват. Това е частта, която си струва да се чете бавно, защото никой не е казал на алгоритъма нищо за английския:

TEXT
merge   1: b'e' + b' '   -> b'e '     (occurred 4433 times)
merge   2: b' ' + b't'   -> b' t'     (occurred 3302 times)
merge   3: b'\xe2' + b'\x80' -> b'\xe2\x80'  (occurred 3247 times)
merge   4: b' ' + b'a'   -> b' a'     (occurred 2335 times)
merge   5: b' t' + b'h'  -> b' th'    (occurred 2253 times)
merge   6: b'i' + b'n'   -> b'in'     (occurred 2011 times)
merge   7: b't' + b' '   -> b't '     (occurred 1904 times)
merge   8: b'e' + b'r'   -> b'er'     (occurred 1813 times)
merge   9: b'd' + b' '   -> b'd '     (occurred 1703 times)
merge  10: b'o' + b'u'   -> b'ou'     (occurred 1554 times)
merge  11: b' ' + b's'   -> b' s'     (occurred 1467 times)
merge  12: b' th' + b'e '-> b' the '  (occurred 1270 times)

Три неща в този списък заслужават да бъдат посочени.

Сливане 12 е думата „the“ — с интервала преди нея и интервала след нея, като една единица, открита на дванайсетата итерация на цикъл, който брои двойки. Никой не е предоставил речник. Тя е там, защото тези пет байта се срещат заедно по-често от всякакви други пет в английския.

Сливане 3 изобщо не е текст. \xe2\x80 са първите два байта от UTF-8 кодирането на типографска пунктуация — дългото тире, къдравите кавички. Алгоритъмът няма представа, че UTF-8 съществува, и току-що е преоткрил част от структурата му, защото многобайтовите кодировки по конструкция са последователности от байтове, които винаги се появяват заедно.

Повечето ранни сливания включват интервал, и интервалът обикновено е от лявата страна. Това е произходът на едно от най-объркващите поведения на практика, към което след малко ще се върнем.

Всяко сливане прави последователността по-къса и речника по-голям. Докъде да се стигне е реално решение и може да бъде измерено — тук върху същите 151 191 байта:

размер на речникаполучени tokensкомпресия (байтове на token)
300101 0651,50
51268 2492,22
1 02450 3693,00
2 04839 3063,85
4 09630 7574,92

Намаляваща възвръщаемост, видимо. Удвояването от 512 до 1024 носи 0,78 байта на token; удвояването от 2048 до 4096 носи 1,07 — тук е по-добре само защото този корпус е достатъчно малък, за да продължават по-дългите сливания да се изплащат. В реален корпус кривата се изравнява рязко.

А цената на по-голям речник не е само памет. Всеки token се нуждае от embedding ред и — по-скъпо — изходният слой на модела трябва да произведе оценка за всеки запис в речника на всяка стъпка, така че крайното матрично умножение се мащабира с размера на речника. Реалните модели са между 32 000 и 200 000: GPT-2 използваше 50 257, cl100k на GPT-4 използва 100 277, o200k на GPT-4o приблизително удвоява това. Тенденцията е нагоре, а причината е в следващия раздел.

Ето същия абзац, преведен, измерен с реалните tokenizer-и, които OpenAI доставя:

езиксимволиtokens (cl100k)tokens (o200k)tokens/символнадценка спрямо английски
английски16431310,189
испански16943360,254+39 %
руски17878430,438+152 %
японски7279581,097+155 %

Същото съдържание, същият смисъл, а с cl100k руската версия консумира два и половина пъти повече tokens. Тъй като API-тата таксуват на token, а context window се измерват в tokens, това не е езиков куриоз — това е ред в бюджета, по-къс ефективен context window и по-бавен отговор, и трите наведнъж, за всеки, който не работи на английски.

Механизмът е в обучителните данни. Tokenizer, обучен основно върху английски, харчи бюджета си за сливания върху английски байтови последователности. Испанският споделя латиницата, така че все още получава известна полза; руският почти никаква, защото кирилските символи заемат два байта в UTF-8 и малко от тези двойки са били достатъчно чести в обучителния корпус, за да заслужат сливане. Японският е още по-зле: три байта на символ, и 72 символа стават 79 tokens — повече tokens, отколкото символи.

Колоната o200k показва, че това е решим проблем и че вече се решава. Удвояването на речника и ребалансирането на обучителните данни сваля надценката за испански от +39 % на +16 %, а за руски от +152 % на +39 %. Това е истинската причина речниците да продължават да растат: не компресия заради самата компресия, а фактът, че предишното поколение тихомълком таксуваше голяма част от света допълнително.

Кодиране и защо редът на сливанията има значение

Връзка към раздела: Кодиране и защо редът на сливанията има значение

Обучението произведе подреден списък от сливания. Кодирането на нов текст го преиграва — и трябва да го преиграе в същия ред, защото сливане 12 комбинира резултатите от сливания 5 и 1. Приложете ги в различен ред и ще получите различна, грешна tokenization, която няма да съвпадне с нищо, видяно от модела при обучението.

bpe.py (continued)PYTHON
    def encode(self, text):
        ids = list(text.encode("utf-8"))
        while len(ids) >= 2:
            stats = get_stats(ids)
            # the pair whose merge came FIRST during training wins   
            pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))   
            if pair not in self.merges:
                break
            ids = merge(ids, pair, self.merges[pair])
        return ids

    def decode(self, ids):
        return b"".join(self.vocab[i] for i in ids).decode("utf-8", errors="replace")

Декодирането е тривиално в сравнение: намирате байтовете на всяко id, конкатенирате, декодирате като UTF-8. Обърнете внимание на errors="replace": модел може да излъчи последователност от token, която завършва по средата на символ, и това не е хипотетично — точно това се случва, когато streaming отговор бъде прекъснат по средата на emoji, поради което streaming API-тата буферират частични байтове, вместо да декодират token по token.

Round-trip работи върху всичко, което е обещанието на byte-level BPE:

TEXT
'strawberry'                            -> 6 tokens, decode == original: True
'Alice was beginning to get very tired' -> 14 tokens, decode == original: True
'café — naïve — 日本語'                   -> 23 tokens, decode == original: True

Всичко останало, което всъщност е същото

Връзка към раздела: Всичко останало, което всъщност е същото

След като механизмът стане ясен, набор от на пръв поглед несвързани оплаквания се оказват едно и също оплакване.

Аритметика. Числата не се разделят по никакъв последователен начин:

TEXT
1234     -> 2 tokens  ['123', '4']
12345    -> 2 tokens  ['123', '45']
1000000  -> 3 tokens  ['100', '000', '0']
3.14159  -> 4 tokens  ['3', '.', '141', '59']
2024     -> 2 tokens  ['202', '4']

За да събере 1234 и 12345, моделът първо трябва да разбере, че ['123','4'] и ['123','45'] са числа, чиито цифри се подравняват по определен начин — а подравняването е различно за всяка двойка числа. Цифрите на едно число не са на едни и същи места от едно число до следващото. Някои по-нови tokenizer-и принуждават цифрите да се разделят на последователни групи по три точно за да премахнат това препятствие, и моделите, обучени с тях, са измеримо по-добри в аритметиката.

Отстъпи в Python.

TEXT
'    x = 1'      -> 5 tokens  ['   ', ' x', ' =', ' ', '1']
'        x = 1'  -> 5 tokens  ['       ', ' x', ' =', ' ', '1']
'\tx = 1'        -> 4 tokens  ['\tx', ' =', ' ', '1']

Четири интервала и осем интервала са различни единични token, а табулация е слята със символа след нея. Отстъпът, който в Python е синтаксис, е представен непоследователно — което е голяма част от причината моделите преди да произвеждат Python с едва доловимо грешни отстъпи, и причината tokenizer-и, фокусирани върху код, да добавят изрични token за често срещани поредици от отстъпи.

Правопис и обръщане. Същата причина като при броенето на r-тата: да поискате от модел да обърне strawberry означава да го накарате да пренареди букви вътре в три непрозрачни id-та. Моделите го правят, защото са запомнили изписвания по време на обучение, а не защото гледат, затова се справят добре с чести думи и зле с редки.

Glitch tokens. Най-поразителният случай е SolidGoldMagikarp и набор от подобни низове, които караха GPT-2 и GPT-3 да се държат странно — отказваха да ги повторят, произвеждаха несвързан изход, понякога обиждаха потребителя. Обяснението е прозаично и следва директно от факта, че tokenizer се обучава отделно от модела: тези низове са били чести в обучителния корпус на tokenizer (били са потребителски имена в Reddit), така че са заслужили собствен token, но са били редки или липсващи в обучителния корпус на модела. Резултатът е embedding ред, който е бил инициализиран случайно и почти никога не е обновяван. Моделът има символ, който на практика никога не е виждал, и поведението му там е каквото случайно е дала първоначалната инициализация.

WordPiece, използван от BERT, се различава от BPE по правилото за избор: вместо да слива най-честата двойка, той слива двойката, която най-много увеличава likelihood на обучителните данни — което нормализира според това колко чести вече са частите, така че двойка от две редки части може да победи двойка от две чести.

Unigram, от Kudo, работи наобратно: започва с голям кандидат-речник и итеративно премахва частите, чието изтриване вреди най-малко на likelihood на корпуса. Той също дава вероятност на всяка сегментация, което позволява sampling на различни tokenizations на един и същ низ като регуляризатор.

SentencePiece е имплементацията, която използват повечето неанглийски модели. Приносът ѝ е, че третира входа като суров поток без никаква предварителна tokenization, кодирайки интервала като видим символ, което означава, че работи еднакво за езици, които не разделят думите с интервали. Отдолу може да изпълнява или BPE, или Unigram.

Tokenizer е интерфейс със загуби между текст и числа, и всяко странно поведение в тази глава е интерфейсът, който прозира. Заслужава си да е ясно, че компромисът е умишлен: byte-level BPE означава, че няма вход, който да е непредставим, последователностите са четири до пет пъти по-къси, отколкото биха били при символи, и честите думи пристигат цели.

Цената е, че атомите на модела не са нашите атоми. Той разсъждава върху текст, който не може да изпише, в единици, избрани чрез честотно броене върху корпус, който не е видял, с цена по език, която никой не е договарял.

Сега имате последователност от цели числа. Това е входният формат за всичко в останалата част на Част II.

Това, което нямате, е каквато и да е причина едно цяло число да следва друго. Следващата глава въвежда целта, върху която се обучава всеки езиков модел, и тя е изненадващо проста: given the tokens so far, predict the next one. Тази единствена цел — без labels, без annotation, само текст със собственото си бъдеще като цел — е това, което превръща целия интернет в обучителни данни, и оттам идват първите истински представяния на модела.

Тя също изисква chain rule на вероятността от Глава 2 да бъде абсолютно точен, защото твърдението, че предсказването на един token наведнъж е същото като моделирането на цели документи, е факторизация, не метафора.

Глава 8 е autoregressive objective, embeddings и първото място, където моделът научава нещо, което никой не е поставил там.


Kudo, T. Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates (arXiv:1804.10959) въвежда модела Unigram; Kudo and Richardson, SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing (arXiv:1808.06226) е имплементацията, която използват повечето многоезични модели; Schuster and Nakajima, Japanese and Korean Voice Search (ICASSP 2012) е произходът на WordPiece. Let's build the GPT Tokenizer на Andrej Karpathy и придружаващото karpathy/minbpe repository са преките предшественици на кода в тази глава и стигат значително по-далеч, включително GPT-4 regex и обработка на special-token. Глава 6 от Hugging Face LLM Course разглежда трите алгоритъма един до друг с разработени примери.

  1. Gage, P. A New Algorithm for Data Compression. The C Users Journal 12(2), pp. 23–38 (1994). Byte-pair encoding като схема за компресия, двайсет и две години преди някой да го използва за езикови модели.

  2. Sennrich, R., Haddow, B. and Birch, A. Neural Machine Translation of Rare Words with Subword Units. arXiv:1508.07909 (2015; ACL 2016). Статията, която донесе BPE в NLP, мотивирана от out-of-vocabulary думи в превода.

  3. Radford, A., Wu, J., Child, R., Luan, D., Amodei, D. and Sutskever, I. Language Models are Unsupervised Multitask Learners (2019). Раздел 2.2 въвежда byte-level BPE с regex за предварителна tokenization, обсъден по-горе.

Готови ли сте LIA да избира вместо вас?

Създавайте с всички AI модели на едно място — започнете безплатно още днес.